- 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
193 lines
6.7 KiB
Go
193 lines
6.7 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"
|
|
"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
|
|
}
|