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: 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 effective 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. Effective scopes are the grant itself for a personal token, and // for an OAuth subject also the declared names the catalog implies. The // expanded list is not written back onto the subject. func (g *MCPUserGuard) MCPAllows(ctx context.Context, subject pact.MCPSubject, req pact.MCPRequirements, permissions []string) (bool, error) { scopes := g.mcpEffectiveScopes(subject) for _, scope := range req.Scopes { if !slices.Contains(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 // subject's effective scopes, so user.scope sees what the session may call. // For an OAuth subject that includes declared names the catalog marks as // implied by a stock scope the grant holds; a personal token's scopes are // the grant unchanged. 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, and implied names are not written onto a token row or // onto subject.Scopes. 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: g.mcpEffectiveScopes(subject), Valid: true}, } if subject.ClientID == "" { credential.ID = subject.TokenID } return pact.MCPDispatch{Principal: principal, Credential: credential, Guards: []string{"api_token"}}, nil } // mcpEffectiveScopes is the scope list MCPAllows and MCPDispatch share. // A personal token (ClientID empty) is returned unchanged: the same slice, // with no declared name added. An OAuth subject gets a clone of its grant // plus each catalog name whose Implies is read or write, when the clone // holds that stock scope and not the name. write does not satisfy Implies // read. Nothing the catalog does not mark is appended. The clone is not // written back onto subject.Scopes, a token row, or a fingerprint. func (g *MCPUserGuard) mcpEffectiveScopes(subject pact.MCPSubject) []string { if subject.ClientID == "" { return subject.Scopes } scopes := slices.Clone(subject.Scopes) for _, entry := range APITokenScopesFor(g.app()).MemberTokenScopes() { if entry.Implies != "read" && entry.Implies != "write" { continue } if entry.Name == "" || slices.Contains(scopes, entry.Name) || !slices.Contains(scopes, entry.Implies) { continue } scopes = append(scopes, entry.Name) } return scopes } // 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 }