owpengram-server/internal/adminapi/rbac.go
Egor Egorov fff8de783a
feat: add NFT usernames and bot verification (#22)
Implements collectible usernames, official verification workflows, and third-party bot verification after maintainer protocol and migration review.

The composite activity/moderation rating remains an admin-only read model; Telegram Stars Rating wire fields stay unset pending a dedicated official-semantics implementation.

Reviewed-Head: 2796345775ea0f908fb7734601e5e1dee4b653b9
Original-Head: fa082b892fd5180c9c9bc53c81c21cf5d250a75b

Co-authored-by: Egor Egorov <business.egor.sg@gmail.com>
2026-07-28 01:18:00 +08:00

196 lines
7.4 KiB
Go

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"
)
// 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,
})
}