MCPUserGuard.MCPDispatch now sets the in-memory credential's ID to the subject's TokenID when the subject came from a personal usr_ token (no ClientID), so a handler running under MCP dispatch can record which token made a change. An OAuth subject's TokenID names the OAuth server's token row, so its credential keeps ID 0. The credential is still never saved.
207 lines
7.4 KiB
Go
207 lines
7.4 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 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.
|
|
func (g *MCPUserGuard) MCPAllows(ctx context.Context, subject pact.MCPSubject, req pact.MCPRequirements, permissions []string) (bool, error) {
|
|
for _, scope := range req.Scopes {
|
|
if !slices.Contains(subject.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
|
|
// session's scopes, so user.scope sees what the session was granted. 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. 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: slices.Clone(subject.Scopes), Valid: true},
|
|
}
|
|
if subject.ClientID == "" {
|
|
credential.ID = subject.TokenID
|
|
}
|
|
return pact.MCPDispatch{Principal: principal, Credential: credential, Guards: []string{"api_token"}}, nil
|
|
}
|
|
|
|
// 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
|
|
}
|