Files
sm-user-plugin/classes/mcp_guard.go
Jakub Zych 25fd981d7f feat(32.1-02): expand OAuth MCP scopes from the published catalog
- Publish the compiled member-token catalog as pact.MemberTokenScopeCatalog
- Imply declared names only when the MCP subject has a ClientID
- Reject a bad Implies and the reserved names backend and offline_access
2026-10-28 15:01:04 +01:00

238 lines
8.9 KiB
Go

package classes
import (
"context"
"fmt"
"slices"
"strings"
"time"
"git.golem15.com/golem15/sm-user-plugin/models"
"git.golem15.com/golem15/summercms/modules/backpack"
"git.golem15.com/golem15/summercms/modules/cabana"
"git.golem15.com/golem15/summercms/modules/lagoon"
"git.golem15.com/golem15/summercms/modules/pact"
"git.golem15.com/golem15/summercms/modules/towel"
"gorm.io/gorm"
)
// The middleware names the guard provider reads on a route.
const (
mcpTokenMiddleware = "user.api_token"
mcpScopeMiddleware = "user.scope:"
mcpPermissionMiddleware = "user.permission:"
)
var (
_ pact.MCPGuard = (*MCPUserGuard)(nil)
_ pact.MCPPersonalTokenResolver = (*MCPUserGuard)(nil)
)
// MCPUserGuard is the plugin's MCP guard provider, named "user": MCP tools
// bind to the plugin's token-guarded routes (user.api_token, user.scope:*,
// user.permission:*) through it, and it resolves the plugin's personal API
// tokens (the usr_ prefix, golem15.user.api_tokens.prefix) at the MCP
// endpoint. Boot registers it in the application's *pact.MCPGuards and
// publishes it as pact.MCPPersonalTokenResolver.
//
// Its checks are the ones the routes run: a scope must be granted to the
// session (as user.scope checks the token), and a permission code must be
// granted by one of the user's groups (as user.permission checks the
// principal PrincipalByID loads; a user's own permission overrides do not
// grant, D-35).
type MCPUserGuard struct {
App *backpack.App
}
// MCPGuardName is "user".
func (g *MCPUserGuard) MCPGuardName() string { return SubjectGuardUser }
// MCPTokenGuarded reports whether the route runs the user.api_token guard.
func (g *MCPUserGuard) MCPTokenGuarded(middleware []string) bool {
return slices.Contains(middleware, mcpTokenMiddleware)
}
// MCPRequirements collects the scopes of user.scope:<scope> and the codes of
// user.permission:<code>. Another user.* name than these and user.api_token
// is an error, so a guard this provider does not understand cannot be
// skipped silently.
func (g *MCPUserGuard) MCPRequirements(middleware []string) (pact.MCPRequirements, error) {
var req pact.MCPRequirements
for _, name := range middleware {
switch {
case name == mcpTokenMiddleware:
case strings.HasPrefix(name, mcpScopeMiddleware):
scope := strings.TrimPrefix(name, mcpScopeMiddleware)
if scope == "" {
return pact.MCPRequirements{}, fmt.Errorf("golem15.user: middleware %q names no scope", name)
}
if !slices.Contains(req.Scopes, scope) {
req.Scopes = append(req.Scopes, scope)
}
case strings.HasPrefix(name, mcpPermissionMiddleware):
code := strings.TrimPrefix(name, mcpPermissionMiddleware)
if code == "" {
return pact.MCPRequirements{}, fmt.Errorf("golem15.user: middleware %q names no permission", name)
}
if !slices.Contains(req.Permissions, code) {
req.Permissions = append(req.Permissions, code)
}
case strings.HasPrefix(name, "user."):
return pact.MCPRequirements{}, fmt.Errorf("golem15.user: the MCP guard does not know middleware %q", name)
}
}
return req, nil
}
// MCPAllows requires every scope of req in the subject's effective scopes,
// then every permission code of req and of the tool, one by one (the route
// runs one user.permission middleware per code), granted to the user's
// groups. Effective scopes are the grant itself for a personal token, and
// for an OAuth subject also the declared names the catalog implies. The
// expanded list is not written back onto the subject.
func (g *MCPUserGuard) MCPAllows(ctx context.Context, subject pact.MCPSubject, req pact.MCPRequirements, permissions []string) (bool, error) {
scopes := g.mcpEffectiveScopes(subject)
for _, scope := range req.Scopes {
if !slices.Contains(scopes, scope) {
return false, nil
}
}
db, err := g.db()
if err != nil {
return false, err
}
principal, err := PrincipalByID(ctx, db.WithContext(ctx), subject.UserID)
if err != nil {
return false, err
}
if principal == nil {
return false, nil
}
for _, code := range append(slices.Clone(req.Permissions), permissions...) {
if !cabana.Allows(principal, []string{code}) {
return false, nil
}
}
return true, nil
}
// MCPDispatch builds the principal (the user with the group grants, as the
// api_token guard loads it) and an in-memory token credential carrying the
// subject's effective scopes, so user.scope sees what the session may call.
// For an OAuth subject that includes declared names the catalog marks as
// implied by a stock scope the grant holds; a personal token's scopes are
// the grant unchanged. For a personal-token subject (ClientID empty) the
// credential's ID is the subject's TokenID, the user_api_tokens row the
// session was opened with, so a handler can record which token made a
// change; an OAuth subject's TokenID is the OAuth server's own token row,
// not one of this plugin's, and its credential keeps ID 0. The credential
// is never saved, and implied names are not written onto a token row or
// onto subject.Scopes. The marker is honoured by the api_token guard only.
func (g *MCPUserGuard) MCPDispatch(ctx context.Context, subject pact.MCPSubject, _ string) (pact.MCPDispatch, error) {
db, err := g.db()
if err != nil {
return pact.MCPDispatch{}, err
}
principal, err := PrincipalByID(ctx, db.WithContext(ctx), subject.UserID)
if err != nil {
return pact.MCPDispatch{}, err
}
if principal == nil {
return pact.MCPDispatch{}, fmt.Errorf("golem15.user: user %d is gone", subject.UserID)
}
credential := &models.APIToken{
UserID: subject.UserID,
Scopes: lagoon.Jsonable[[]string]{Data: g.mcpEffectiveScopes(subject), Valid: true},
}
if subject.ClientID == "" {
credential.ID = subject.TokenID
}
return pact.MCPDispatch{Principal: principal, Credential: credential, Guards: []string{"api_token"}}, nil
}
// mcpEffectiveScopes is the scope list MCPAllows and MCPDispatch share.
// A personal token (ClientID empty) is returned unchanged: the same slice,
// with no declared name added. An OAuth subject gets a clone of its grant
// plus each catalog name whose Implies is read or write, when the clone
// holds that stock scope and not the name. write does not satisfy Implies
// read. Nothing the catalog does not mark is appended. The clone is not
// written back onto subject.Scopes, a token row, or a fingerprint.
func (g *MCPUserGuard) mcpEffectiveScopes(subject pact.MCPSubject) []string {
if subject.ClientID == "" {
return subject.Scopes
}
scopes := slices.Clone(subject.Scopes)
for _, entry := range APITokenScopesFor(g.app()).MemberTokenScopes() {
if entry.Implies != "read" && entry.Implies != "write" {
continue
}
if entry.Name == "" || slices.Contains(scopes, entry.Name) || !slices.Contains(scopes, entry.Implies) {
continue
}
scopes = append(scopes, entry.Name)
}
return scopes
}
// MCPResolvePersonalToken resolves a personal API token of this plugin into
// an MCP subject with the token's own scopes. A token without the configured
// prefix answers false; one with it that is unknown, revoked, expired or
// whose user is gone answers pact.ErrMCPTokenInvalid. A usable token gets
// its last_used_at stamped, and its last_used_ip when the context carries
// the client address (towel.ClientIP, which the MCP endpoint sets), as the
// api_token guard does.
func (g *MCPUserGuard) MCPResolvePersonalToken(ctx context.Context, raw string) (pact.MCPSubject, bool, error) {
prefix, err := APITokenPrefix(g.app())
if err != nil || !strings.HasPrefix(raw, prefix) {
return pact.MCPSubject{}, false, nil
}
db, err := g.db()
if err != nil {
return pact.MCPSubject{}, true, err
}
db = db.WithContext(ctx)
var token models.APIToken
result := db.Where("token_hash = ?", HashAPIToken(raw)).Limit(1).Find(&token)
if result.Error != nil {
return pact.MCPSubject{}, true, result.Error
}
now := time.Now()
if result.RowsAffected == 0 || !token.IsUsableAt(now) {
return pact.MCPSubject{}, true, pact.ErrMCPTokenInvalid
}
principal, err := PrincipalByID(ctx, db, token.UserID)
if err != nil {
return pact.MCPSubject{}, true, err
}
if principal == nil {
return pact.MCPSubject{}, true, pact.ErrMCPTokenInvalid
}
used := map[string]any{"last_used_at": now}
if ip, ok := towel.ClientIP(ctx); ok && strings.TrimSpace(ip) != "" {
used["last_used_ip"] = strings.TrimSpace(ip)
}
if err := db.Model(&models.APIToken{}).Where("id = ?", token.ID).UpdateColumns(used).Error; err != nil {
return pact.MCPSubject{}, true, err
}
return pact.MCPSubject{Guard: SubjectGuardUser, UserID: token.UserID, Scopes: slices.Clone(token.Scopes.Get()), TokenID: token.ID}, true, nil
}
func (g *MCPUserGuard) app() *backpack.App {
if g == nil {
return nil
}
return g.App
}
func (g *MCPUserGuard) db() (*gorm.DB, error) {
app := g.app()
if app == nil {
return nil, fmt.Errorf("golem15.user: app is nil")
}
gdb, ok := app.Lookup[*gorm.DB]()
if !ok || gdb == nil {
return nil, fmt.Errorf("golem15.user: gorm handle is missing")
}
return gdb, nil
}