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:
59
classes/frontend_permission_catalog.go
Normal file
59
classes/frontend_permission_catalog.go
Normal 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
192
classes/mcp_guard.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user