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>
This commit is contained in:
parent
b0fd3976f1
commit
fff8de783a
169 changed files with 55769 additions and 282 deletions
196
internal/adminapi/rbac.go
Normal file
196
internal/adminapi/rbac.go
Normal file
|
|
@ -0,0 +1,196 @@
|
|||
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,
|
||||
})
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue