Files
summercms/modules/pier/identity.go
Jakub Zych 240457291f fix(pier): widen returning identities, warm the Redis store, end stateless listen streams, refuse personal tokens without a TokenID
- Identity.fingerprint covers scopes, both subjects, the ceiling and the
  token kind; a stateful lookup or stateless hit whose fingerprint changed
  re-filters the server with the event diff under its own mutex (A-WR-04)
- Mount warms a Redis store in the background with a 1 s to 30 s backoff
  and every request kicks a pending open without waiting, so stateless-only
  instances subscribe to fan-out (A-WR-05)
- a stateless subscriptions/listen POST re-verifies its bearer every
  recheck interval; an LRU-evicted stateless server closes its sessions
  (A-WR-06)
- resolvePersonalToken refuses a subject with TokenID 0, and the
  pact.MCPPersonalTokenResolver contract says one resolver per application,
  false means unauthorized and TokenID keys the binding (A-WR-09, A-IN-03)
2026-10-23 15:59:54 +02:00

322 lines
9.8 KiB
Go

package pier
import (
"context"
"errors"
"slices"
"strconv"
"strings"
"sync"
"time"
"git.golem15.com/golem15/summercms/modules/backpack"
"git.golem15.com/golem15/summercms/modules/bouncer"
"git.golem15.com/golem15/summercms/modules/cabana"
"git.golem15.com/golem15/summercms/modules/pact"
)
// OAuthTokenPrefix is the prefix of the OAuth access tokens the published
// pact.MCPTokenVerifier verifies; every other bearer is a personal token.
const OAuthTokenPrefix = "oas_at_"
// Token kinds of an Identity.
const (
TokenKindOAuth = "oauth"
TokenKindPersonal = "pat"
)
// LinkCacheTTL bounds how long a resolved backend link is reused on one
// instance before it is read again (P-07); a link event drops it at once.
const LinkCacheTTL = 5 * time.Second
// ErrUnauthorized is what identity resolution answers for a missing,
// unknown, expired or revoked bearer; the endpoint turns it into a 401 with
// the protected resource metadata challenge.
var ErrUnauthorized = errors.New("pier: unauthorized")
// Identity is who one MCP request acts as: the frontend subject (the OAuth
// grant's user or a frontend personal token), the backend subject (the
// linked admin when the grant carries the backend scope, or an admin
// personal token), the scopes of the credential, the session binding key
// (D-35: oauth:<client_id>:<user_id>, pat:user:<token_id> or
// pat:backend:<token_id>), the credential's expiry and its kind. Every
// request resolves its own Identity from its bearer; a session never
// replaces it.
type Identity struct {
Frontend *pact.MCPSubject
Backend *pact.MCPSubject
Scopes []string
Binding string
ExpiresAt time.Time
TokenKind string
// ceiling says the read/write ceiling (D-20) applies: OAuth grants and
// frontend personal tokens carry read and write; an admin personal
// token is capped through its cabana scope limit instead.
ceiling bool
// credential is the admin personal token row stored next to the
// backend principal on a dispatched call.
credential any
}
// subjectFor returns the subject acting under guard, nil when the identity
// has none for it.
func (id Identity) subjectFor(guard string) *pact.MCPSubject {
if id.Backend != nil && id.Backend.Guard == guard {
return id.Backend
}
if id.Frontend != nil && id.Frontend.Guard == guard {
return id.Frontend
}
return nil
}
// writes reports whether the identity may call tools that are not
// read-only: no ceiling, or the write scope granted.
func (id Identity) writes() bool {
return !id.ceiling || slices.Contains(id.Scopes, "write")
}
// fingerprint encodes the authorization-relevant parts of the identity
// (P-26): the sorted scopes, both subjects (guard, user, client, token and
// their sorted scopes), the ceiling and the token kind. Two requests of one
// binding with different fingerprints may see different tools, so a cached
// server is re-filtered when it changes (A-WR-04).
func (id Identity) fingerprint() string {
var b strings.Builder
sorted := func(in []string) string {
out := slices.Clone(in)
slices.Sort(out)
return strings.Join(out, ",")
}
subject := func(s *pact.MCPSubject) {
if s == nil {
b.WriteString("-|")
return
}
b.WriteString(s.Guard)
b.WriteByte(':')
b.WriteString(strconv.FormatUint(uint64(s.UserID), 10))
b.WriteByte(':')
b.WriteString(s.ClientID)
b.WriteByte(':')
b.WriteString(strconv.FormatUint(uint64(s.TokenID), 10))
b.WriteByte(':')
b.WriteString(sorted(s.Scopes))
b.WriteByte('|')
}
b.WriteString(sorted(id.Scopes))
b.WriteByte('|')
subject(id.Frontend)
subject(id.Backend)
b.WriteString(strconv.FormatBool(id.ceiling))
b.WriteByte('|')
b.WriteString(id.TokenKind)
return b.String()
}
// subjects lists the identity's subjects (frontend first).
func (id Identity) subjects() []pact.MCPSubject {
var out []pact.MCPSubject
if id.Frontend != nil {
out = append(out, *id.Frontend)
}
if id.Backend != nil {
out = append(out, *id.Backend)
}
return out
}
// has reports whether the identity includes the subject (guard and user id).
func (id Identity) has(guard string, userID uint) bool {
for _, s := range id.subjects() {
if s.Guard == guard && s.UserID == userID {
return true
}
}
return false
}
// backendTokenFunc resolves an admin personal token; tests inject one in
// place of cabana.ResolveBackendToken, which needs a database.
type backendTokenFunc func(ctx context.Context, app *backpack.App, raw string) (*bouncer.Principal, *cabana.BackendAPIToken, []string, error)
// identityResolver turns a bearer into an Identity through the published
// verifier and resolvers, with the per-instance link cache.
type identityResolver struct {
app *backpack.App
cfg Config
frontendGuard string
backendTokens backendTokenFunc
mu sync.Mutex
links map[uint]linkEntry
now func() time.Time
}
type linkEntry struct {
link pact.LinkedBackendUser
ok bool
at time.Time
}
func newIdentityResolver(app *backpack.App, cfg Config, frontendGuard string) *identityResolver {
return &identityResolver{
app: app,
cfg: cfg,
frontendGuard: frontendGuard,
backendTokens: cabana.ResolveBackendToken,
links: map[uint]linkEntry{},
now: time.Now,
}
}
// resolve verifies raw afresh: an OAuth access token through the published
// pact.MCPTokenVerifier (missing verifier: unauthorized); with
// mcp.allow_personal_tokens true, an admin g15_ token through cabana and
// any other prefix through the published pact.MCPPersonalTokenResolver;
// with mcp.allow_personal_tokens false (the default) any bearer that is
// not an OAuth token is unauthorized without a lookup (D-19). A
// backend subject for an OAuth grant exists only with the backend scope and
// a link the published pact.MCPLinkedBackendResolver confirms (D-08).
func (r *identityResolver) resolve(ctx context.Context, raw string) (Identity, error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return Identity{}, ErrUnauthorized
}
if strings.HasPrefix(raw, OAuthTokenPrefix) {
return r.resolveOAuth(ctx, raw)
}
if !r.cfg.AllowPersonalTokens {
return Identity{}, ErrUnauthorized
}
if strings.HasPrefix(raw, cabana.APITokenPrefix) {
return r.resolveBackendToken(ctx, raw)
}
return r.resolvePersonalToken(ctx, raw)
}
func (r *identityResolver) resolveOAuth(ctx context.Context, raw string) (Identity, error) {
verifier, ok := r.app.Lookup[pact.MCPTokenVerifier]()
if !ok || verifier == nil {
return Identity{}, ErrUnauthorized
}
grant, err := verifier.MCPVerifyToken(ctx, raw)
if err != nil || grant.UserID == 0 {
return Identity{}, ErrUnauthorized
}
scopes := append([]string(nil), grant.Scopes...)
id := Identity{
Scopes: scopes,
Binding: "oauth:" + grant.ClientID + ":" + strconv.FormatUint(uint64(grant.UserID), 10),
ExpiresAt: grant.ExpiresAt,
TokenKind: TokenKindOAuth,
ceiling: true,
}
if r.frontendGuard != "" {
id.Frontend = &pact.MCPSubject{
Guard: r.frontendGuard,
UserID: grant.UserID,
Scopes: scopes,
ClientID: grant.ClientID,
TokenID: grant.TokenID,
}
}
if slices.Contains(scopes, "backend") {
if link, ok := r.linkedBackend(ctx, grant.UserID); ok {
id.Backend = &pact.MCPSubject{
Guard: BackendGuard,
UserID: link.ID,
Scopes: scopes,
ClientID: grant.ClientID,
TokenID: grant.TokenID,
}
}
}
return id, nil
}
func (r *identityResolver) resolveBackendToken(ctx context.Context, raw string) (Identity, error) {
principal, row, scopes, err := r.backendTokens(ctx, r.app, raw)
if err != nil || principal == nil || row == nil {
return Identity{}, ErrUnauthorized
}
id := Identity{
Scopes: append([]string(nil), scopes...),
Binding: "pat:backend:" + strconv.FormatUint(uint64(row.ID), 10),
TokenKind: TokenKindPersonal,
Backend: &pact.MCPSubject{
Guard: BackendGuard,
UserID: principal.ID,
Scopes: append([]string(nil), scopes...),
TokenID: row.ID,
},
credential: row,
}
if row.ExpiresAt != nil {
id.ExpiresAt = *row.ExpiresAt
}
return id, nil
}
func (r *identityResolver) resolvePersonalToken(ctx context.Context, raw string) (Identity, error) {
resolver, ok := r.app.Lookup[pact.MCPPersonalTokenResolver]()
if !ok || resolver == nil {
return Identity{}, ErrUnauthorized
}
subject, ok, err := resolver.MCPResolvePersonalToken(ctx, raw)
// TokenID keys the binding (pat:user:<token_id>): a subject without one
// would put every user of the resolver on one binding (A-WR-09).
if err != nil || !ok || subject.UserID == 0 || subject.TokenID == 0 {
return Identity{}, ErrUnauthorized
}
if subject.Guard == "" {
subject.Guard = r.frontendGuard
}
if subject.Guard == "" || subject.Guard == BackendGuard {
return Identity{}, ErrUnauthorized
}
return Identity{
Frontend: &subject,
Scopes: append([]string(nil), subject.Scopes...),
Binding: "pat:user:" + strconv.FormatUint(uint64(subject.TokenID), 10),
TokenKind: TokenKindPersonal,
ceiling: true,
}, nil
}
// linkedBackend answers the backend account linked to a frontend user
// through the published resolver, reusing an answer younger than
// LinkCacheTTL on this instance (P-07).
func (r *identityResolver) linkedBackend(ctx context.Context, userID uint) (pact.LinkedBackendUser, bool) {
resolver, ok := r.app.Lookup[pact.MCPLinkedBackendResolver]()
if !ok || resolver == nil {
return pact.LinkedBackendUser{}, false
}
now := r.now()
r.mu.Lock()
if e, ok := r.links[userID]; ok && now.Sub(e.at) < LinkCacheTTL {
r.mu.Unlock()
return e.link, e.ok
}
r.mu.Unlock()
link, found, err := resolver.LinkedBackendUser(ctx, userID)
if err != nil {
return pact.LinkedBackendUser{}, false
}
if link.ID == 0 {
found = false
}
r.mu.Lock()
r.links[userID] = linkEntry{link: link, ok: found, at: now}
r.mu.Unlock()
return link, found
}
// forgetLink drops the cached link of one frontend user (a link event).
func (r *identityResolver) forgetLink(userID uint) {
r.mu.Lock()
delete(r.links, userID)
r.mu.Unlock()
}