- 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
280 lines
8.4 KiB
Go
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()
|
|
}
|