Files
summercms/modules/pier/identity.go
Jakub Zych cfdbaf0e47 feat(19.7-02): pier MCP module tracer with in-process dispatch and sessions
- modules/pier: Host contract, mcp.* config, catalog compiled from
  pact.HasMCP (prefixing, route binding, guard resolution, schema
  reflection, annotations), per-request identity through the published
  verifier and resolvers, built-in backend guard provider, in-process
  dispatch with the bouncer marker, per-identity SDK server with the
  per-request re-filter, stateful session router with rehydration over a
  memory store, audit lines
- modules/surf/pier_on.go: the summercms_mcp hook adapting the router to
  pier.Host; the untagged hello binary carries no SDK
- go.mod: go-sdk v1.8.0 and jsonschema-go v0.4.3 (decision note committed
  before this change)
- TestPierTracerSmoke: SDK client lists and calls acme_echo end to end
  through surf's compiled guard chain; README and root table row
2026-10-22 18:15:59 +02:00

280 lines
8.4 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")
}
// 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, an admin g15_ token through cabana and any
// other prefix through the published pact.MCPPersonalTokenResolver. 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)
if err != nil || !ok || subject.UserID == 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()
}