Ctrl K
ComunidadGuías…

internal/adminapi/rbac.go

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
package adminapi

import (
	"context"
	"crypto/subtle"
	"net/http"
	"strings"
)

// Admin API authorisation.
//
// Every request arrives with a bearer token, and the token decides which
// permissions the request carries:
//
//   - TELESRV_ADMIN_API_TOKEN is the master token and carries every permission.
//     This is what keeps the existing surface working unchanged: all the routes
//     that predate permissions stay mounted through authenticated(), which is
//     defined as "requires every permission", so the master token reaches them
//     exactly as before.
//   - A scoped token from TELESRV_ADMIN_SCOPED_TOKENS carries only the
//     permissions its entry lists. A scoped token is therefore *not* a weaker
//     master token: it authenticates successfully and is then refused with 403 on
//     anything outside its list, including every legacy route. Widening a scoped
//     token to the legacy surface would be a silent privilege escalation, so the
//     wildcard has to be spelled out in configuration to get it.
//
// The two-step answer matters for diagnosis: 401 means "I do not know this
// token", 403 means "I know you and you may not do this".

// Permission names. They are the same strings the operator writes into
// TELESRV_ADMIN_UI_PERMISSIONS / TELESRV_ADMIN_SCOPED_TOKENS.
const (
	// PermissionAll is the wildcard: a principal carrying it passes every check.
	PermissionAll = "*"
	// PermissionVerificationReview guards the whole official-verification review
	// surface: the queue, one application, the counters, and the claim/approve/
	// reject decisions.
	PermissionVerificationReview = "verification.review"
	// PermissionVerificationRevoke is required *in addition* to
	// PermissionVerificationReview to clear a badge that was already granted.
	// Taking a badge away is visible to every client of a public peer, so it is
	// deliberately not implied by the right to review new applications.
	PermissionVerificationRevoke = "verification.revoke"
	// PermissionBotVerificationReview guards the third-party verification read
	// surface -- verifiers, icons, granted marks, the queue and its counters -- plus
	// the decisions on the applications filed with a verifier bot.
	//
	// This is NOT verification.review. Third-party verification is a separate
	// mechanism over separate tables (verification_icons, bot_verifier_settings,
	// custom_verifications, custom_verification_requests), so a token trusted to
	// work one queue is not thereby trusted with the other: neither permission
	// implies the other.
	PermissionBotVerificationReview = "botverification.review"
	// PermissionBotVerificationManage guards the configuration half: granting,
	// switching and revoking verifier status, the icon catalogue, and stripping a
	// granted mark.
	//
	// It is separate from the review right because these are the actions that
	// decide how much a third-party mark is worth. Handing out the queue is
	// routine; handing out the ability to appoint verifiers is not.
	PermissionBotVerificationManage = "botverification.manage"
	// PermissionPremiumManage guards grants, revocations and refunds. It is kept
	// separate from Stars grants because a Premium refund mutates both ledgers.
	PermissionPremiumManage = "premium.manage"
	// PermissionDonationsManage guards the crypto donations admin surface:
	// viewing wallet status/deposit history and editing a chain's RPC/WS
	// endpoint, enabled flag, confirmation depth and pricing config. It
	// never exposes the wallet mnemonic or private keys -- those exist only
	// in server memory and the encrypted-at-rest seed row, reachable by no
	// admin API route at all.
	PermissionDonationsManage = "donations.manage"
	// PermissionPricesManage guards the shop pricing surface: reading the
	// effective product prices and stars rate, and changing either. It is
	// separate from premium.manage because a shop price decides what a
	// customer is charged, not what a grant costs the operator.
	PermissionPricesManage = "prices.manage"
	// PermissionBotTokenRead is intentionally narrower than unrestricted admin
	// access because it reveals a live credential.
	PermissionBotTokenRead = "bots.token.read"
)

// CodeForbidden is the stable code for a permission failure, so the panel can
// tell an authorisation refusal apart from a domain refusal.
const CodeForbidden = "FORBIDDEN"

// ScopedToken is one bearer token restricted to a permission set. It mirrors
// config.AdminScopedToken; the adminapi package keeps its own shape so it does
// not depend on the configuration loader.
type ScopedToken struct {
	// Name is the audit identity of actions performed with this token.
	Name        string
	Token       string
	Permissions []string
}

// permissionSet is a resolved permission list.
type permissionSet struct {
	all   bool
	names map[string]struct{}
}

func newPermissionSet(permissions []string) permissionSet {
	set := permissionSet{names: make(map[string]struct{}, len(permissions))}
	for _, permission := range permissions {
		permission = strings.TrimSpace(permission)
		if permission == "" {
			continue
		}
		if permission == PermissionAll {
			set.all = true
			continue
		}
		set.names[permission] = struct{}{}
	}
	return set
}

// Has reports whether the set grants the permission.
func (p permissionSet) Has(permission string) bool {
	if p.all {
		return true
	}
	_, ok := p.names[permission]
	return ok
}

// principal is the authenticated caller.
type principal struct {
	// name is the scoped token's audit identity, or "" for the master token,
	// whose actions are attributed by the actor the caller states in the body.
	name        string
	permissions permissionSet
}

type principalKey struct{}

// principalName returns the scoped-token identity behind the request, or "" when
// the request came in on the master token.
func principalName(ctx context.Context) string {
	if p, ok := ctx.Value(principalKey{}).(principal); ok {
		return p.name
	}
	return ""
}

// principalFor resolves the bearer token to a principal.
//
// Every configured token is compared, and every comparison is constant time and
// unconditional: returning as soon as one matches would leak, through timing,
// which token position a guess collided with.
func (s *Server) principalFor(r *http.Request) (principal, bool) {
	got := strings.TrimSpace(strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer "))
	if got == "" {
		return principal{}, false
	}
	matched := false
	resolved := principal{}
	if subtle.ConstantTimeCompare([]byte(got), []byte(s.token)) == 1 && s.token != "" {
		matched = true
		resolved = principal{permissions: permissionSet{all: true}}
	}
	for _, scoped := range s.scoped {
		if subtle.ConstantTimeCompare([]byte(got), []byte(scoped.Token)) == 1 && scoped.Token != "" && !matched {
			matched = true
			resolved = principal{name: scoped.Name, permissions: newPermissionSet(scoped.Permissions)}
		}
	}
	return resolved, matched
}

// authenticated guards a route that requires unrestricted rights.
//
// This is every route that predates the permission model. Keeping them here is
// the documented behaviour: the master token carries every permission, so nothing
// about the existing surface changes, while a bounded scoped token cannot use one
// of them as a side door.
func (s *Server) authenticated(next http.HandlerFunc) http.HandlerFunc {
	return s.authorized(PermissionAll, next)
}

// authorized guards a route behind one permission.
func (s *Server) authorized(permission string, next http.HandlerFunc) http.HandlerFunc {
	return s.authorizedAll([]string{permission}, next)
}

// authorizedAll guards a route behind every listed permission. Revocation uses it
// to require the review right and the revoke right together, so the revoke right
// alone cannot be handed out as a way into the review surface.
func (s *Server) authorizedAll(permissions []string, next http.HandlerFunc) http.HandlerFunc {
	return func(w http.ResponseWriter, r *http.Request) {
		caller, ok := s.principalFor(r)
		if !ok {
			writeError(w, http.StatusUnauthorized, "unauthorized")
			return
		}
		for _, permission := range permissions {
			if !caller.permissions.Has(permission) {
				writeForbidden(w, permission)
				return
			}
		}
		next(w, r.WithContext(context.WithValue(r.Context(), principalKey{}, caller)))
	}
}

// writeForbidden names the missing permission, so an operator configuring a
// scoped token is told what to add instead of having to guess.
func writeForbidden(w http.ResponseWriter, permission string) {
	writeJSON(w, http.StatusForbidden, map[string]string{
		"error":      "permission " + permission + " is required",
		"code":       CodeForbidden,
		"permission": permission,
	})
}