feat: user MCP guard provider, plugin frontend permissions and the user_me tool

- classes.FrontendPermissionCatalog: the seeded rows, then codes activated
  plugins declare through pact.HasFrontendPermissions (seeded row wins);
  both permission editors offer it
- classes.MCPUserGuard (guard "user"): reads user.api_token, user.scope:*
  and user.permission:*, allows with the route's checks (session scopes,
  group grants, every code), dispatches with an in-memory token credential,
  resolves usr_ personal tokens; registered in *pact.MCPGuards and published
  as pact.MCPPersonalTokenResolver
- GET /_user/api/v1/token/me (user.api_token, user.scope:read) and the MCP
  tool me (user_me)
- pact.MCPSubjectChanged on token revoke, Users saves and User Groups saves
  and deletes
This commit is contained in:
Jakub Zych
2026-10-23 12:08:38 +02:00
parent 7ac80c9aea
commit 0f545f5c81
13 changed files with 734 additions and 38 deletions

View File

@@ -0,0 +1,59 @@
package classes
import (
"context"
"strings"
"git.golem15.com/golem15/summercms/modules/backpack"
"git.golem15.com/golem15/summercms/modules/cabana"
"git.golem15.com/golem15/summercms/modules/pact"
"git.golem15.com/golem15/summercms/modules/party"
"gorm.io/gorm"
)
// FrontendPermissionCatalog is what the user and user group permission
// editors offer (D-09): the rows of golem15_user_frontend_permissions first,
// in FrontendPermissionOptions order and unchanged, then the codes activated
// plugins declare through pact.HasFrontendPermissions, in activation order.
// A declared code that a seeded row already has is skipped (the seeded row
// wins), and so is a code an earlier plugin declared; an empty code is
// skipped. The table, its rows and the stored permissions are never
// written.
func FrontendPermissionCatalog(ctx context.Context, app *backpack.App, db *gorm.DB) ([]cabana.PermissionOption, error) {
rows, err := FrontendPermissionOptions(ctx, db)
if err != nil {
return nil, err
}
options := make([]cabana.PermissionOption, 0, len(rows))
seen := make(map[string]bool, len(rows))
for _, row := range rows {
option := cabana.PermissionOption{Code: row.Code, Label: row.Label, Tab: row.Tab}
if row.Comment != nil {
option.Comment = *row.Comment
}
options = append(options, option)
seen[row.Code] = true
}
if app == nil {
return options, nil
}
activated, ok := app.Lookup[party.Activated]()
if !ok {
return options, nil
}
for _, plugin := range activated {
declares, ok := plugin.(pact.HasFrontendPermissions)
if !ok || declares == nil {
continue
}
for _, perm := range declares.FrontendPermissions() {
code := strings.TrimSpace(perm.Code)
if code == "" || seen[code] {
continue
}
seen[code] = true
options = append(options, cabana.PermissionOption{Code: code, Label: perm.Label, Tab: perm.Tab, Comment: perm.Comment})
}
}
return options, nil
}

192
classes/mcp_guard.go Normal file
View File

@@ -0,0 +1,192 @@
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:<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. 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
}