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" "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: and the codes of // user.permission:. 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. The // credential has ID 0 and 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}, } 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, 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 } if err := db.Model(&models.APIToken{}).Where("id = ?", token.ID).UpdateColumn("last_used_at", now).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 }