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

@@ -163,6 +163,13 @@ func adminEnvConfig(t *testing.T) *compass.Config {
// newAdminEnv boots the plugin alone with the admin API mounted. configure
// functions adjust the config before the plugin is activated.
func newAdminEnv(t *testing.T, configure ...func(*compass.Config)) *adminEnv {
t.Helper()
return newAdminEnvWith(t, nil, configure...)
}
// newAdminEnvWith is newAdminEnv with more registered plugins activated
// after golem15.user, by ID.
func newAdminEnvWith(t *testing.T, extra []string, configure ...func(*compass.Config)) *adminEnv {
t.Helper()
gdb, sqlDB := adminHarnessDB(t)
app := backpack.New(adminEnvConfig(t))
@@ -180,7 +187,7 @@ func newAdminEnv(t *testing.T, configure ...func(*compass.Config)) *adminEnv {
}
// The registered plugin value is shared by every boot in this binary;
// its blacklist sweep is started at most once and is harmless here.
plugins, err := party.Activate(app, []string{"golem15.user"})
plugins, err := party.Activate(app, append([]string{"golem15.user"}, extra...))
if err != nil {
t.Fatal(err)
}

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
}

View File

@@ -12,6 +12,7 @@ import (
"git.golem15.com/golem15/sm-user-plugin/models"
"git.golem15.com/golem15/summercms/modules/backpack"
"git.golem15.com/golem15/summercms/modules/bouncer"
"git.golem15.com/golem15/summercms/modules/pact"
"git.golem15.com/golem15/summercms/modules/wire"
"gorm.io/gorm"
)
@@ -106,6 +107,8 @@ func APITokenDestroy(app *backpack.App) http.HandlerFunc {
writeOpaque500(w)
return
}
// An MCP session opened with this token ends.
classes.FireSubjectChanged(r.Context(), app, classes.SubjectGuardUser, user.ID, pact.MCPSubjectRevoked)
writeJSON(w, http.StatusOK, map[string]any{"data": map[string]any{"revoked": true}})
}
}

39
controllers/token_me.go Normal file
View File

@@ -0,0 +1,39 @@
package controllers
import (
"net/http"
"git.golem15.com/golem15/sm-user-plugin/models"
"git.golem15.com/golem15/summercms/modules/backpack"
"git.golem15.com/golem15/summercms/modules/bouncer"
)
// TokenMe serves GET /_user/api/v1/token/me behind user.api_token and
// user.scope:read: the token owner's user payload, the same {"user": ...}
// body GET /fetch answers for a session. It is the target of the MCP tool
// user_me. The guard answers 401 for a missing or unusable token and the
// scope middleware 403 for a token without read.
func TokenMe(app *backpack.App) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
principal, ok := bouncer.User(r.Context())
if !ok || principal == nil || principal.ID == 0 {
writeUnauthorized(w)
return
}
gdb, _, ok := sessionDeps(w, app)
if !ok {
return
}
var user models.User
if err := gdb.WithContext(r.Context()).Where("id = ?", principal.ID).Take(&user).Error; err != nil {
writeUnauthorized(w)
return
}
payload, err := apiArray(r.Context(), app, &user)
if err != nil {
writeOpaque500(w)
return
}
writeJSON(w, http.StatusOK, map[string]any{"user": payload})
}
}

View File

@@ -10,6 +10,8 @@ import (
"git.golem15.com/golem15/sm-user-plugin/models"
"git.golem15.com/golem15/summercms/modules/bouncer"
"git.golem15.com/golem15/summercms/modules/cabana"
"git.golem15.com/golem15/summercms/modules/lagoon"
"git.golem15.com/golem15/summercms/modules/pact"
"gorm.io/gorm"
)
@@ -133,8 +135,9 @@ func (c usergroupsAdminController) FormBeforeDelete(ctx context.Context, model a
// FormAfterDelete removes the memberships of the deleted group in the same
// transaction: users_groups has no foreign key that would do it. The member
// users stay.
func (usergroupsAdminController) FormAfterDelete(ctx context.Context, model any) error {
// users stay; each gets a pact.MCPSubjectChanged permissions event after the
// delete commits, since the group's grants are gone.
func (c usergroupsAdminController) FormAfterDelete(ctx context.Context, model any) error {
group, ok := model.(*models.UserGroup)
if !ok || group == nil {
return errors.New("user groups admin: form model is not a user group")
@@ -143,8 +146,59 @@ func (usergroupsAdminController) FormAfterDelete(ctx context.Context, model any)
if !ok {
return errors.New("user groups admin: a delete needs its transaction")
}
return tx.Session(&gorm.Session{NewDB: true}).WithContext(ctx).
Where("user_group_id = ?", group.ID).Delete(&models.UsersGroup{}).Error
members, err := groupMemberIDs(ctx, tx, group.ID)
if err != nil {
return err
}
if err := tx.Session(&gorm.Session{NewDB: true}).WithContext(ctx).
Where("user_group_id = ?", group.ID).Delete(&models.UsersGroup{}).Error; err != nil {
return err
}
c.permissionsChanged(ctx, tx, members)
return nil
}
// FormAfterUpdate tells the MCP endpoint that the members of the group may
// do something else now (the form saves the group's permissions): one
// pact.MCPSubjectChanged permissions event per member, the members read in
// the save's transaction and the events fired after it commits.
func (c usergroupsAdminController) FormAfterUpdate(ctx context.Context, model any) error {
group, ok := model.(*models.UserGroup)
if !ok || group == nil {
return errors.New("user groups admin: form model is not a user group")
}
tx, ok := cabana.TxFromContext(ctx)
if !ok {
return errors.New("user groups admin: an update needs its transaction")
}
members, err := groupMemberIDs(ctx, tx, group.ID)
if err != nil {
return err
}
c.permissionsChanged(ctx, tx, members)
return nil
}
// permissionsChanged fires one permissions subject event per user after tx
// commits.
func (c usergroupsAdminController) permissionsChanged(ctx context.Context, tx *gorm.DB, userIDs []uint) {
if len(userIDs) == 0 {
return
}
app := c.app.application()
lagoon.AfterCommit(ctx, tx, func(ctx context.Context, _ *gorm.DB) {
for _, id := range userIDs {
classes.FireSubjectChanged(ctx, app, classes.SubjectGuardUser, id, pact.MCPSubjectPermissions)
}
})
}
// groupMemberIDs reads the ids of the group's members through tx.
func groupMemberIDs(ctx context.Context, tx *gorm.DB, groupID uint) ([]uint, error) {
ids := []uint{}
err := tx.Session(&gorm.Session{NewDB: true}).WithContext(ctx).
Model(&models.UsersGroup{}).Where("user_group_id = ?", groupID).Order("user_id").Pluck("user_id", &ids).Error
return ids, err
}
// requirePrivilegedPermission answers nil when the administrator on ctx
@@ -184,7 +238,9 @@ func sameCode(a, b *string) bool {
}
// AdminPermissionOptions offers the rows of
// golem15_user_frontend_permissions, by tab and code. None is locked.
// golem15_user_frontend_permissions, by tab and code, then the frontend
// permission codes activated plugins declare (classes.FrontendPermissionCatalog).
// None is locked.
func (c usergroupsAdminController) AdminPermissionOptions(ctx context.Context, field string) ([]cabana.PermissionOption, error) {
if field != permissionsField {
return nil, fmt.Errorf("user groups admin: unknown permission field %q", field)
@@ -193,19 +249,7 @@ func (c usergroupsAdminController) AdminPermissionOptions(ctx context.Context, f
if db == nil {
return nil, errors.New("user groups admin: gorm handle is missing")
}
rows, err := classes.FrontendPermissionOptions(ctx, db)
if err != nil {
return nil, err
}
options := make([]cabana.PermissionOption, 0, 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)
}
return options, nil
return classes.FrontendPermissionCatalog(ctx, c.app.application(), db)
}
// AdminPermissionValues returns the group's stored permissions, read with

View File

@@ -250,6 +250,24 @@ func (c usersAdminController) FormBeforeUpdate(ctx context.Context, model any) e
return nil
}
// FormAfterUpdate tells the MCP endpoint that what the user may do may have
// changed (their permissions or group memberships are saved by this form):
// one pact.MCPSubjectChanged permissions event for the user, fired after the
// save commits. Every MCP call re-checks anyway; the event only refreshes
// the tool list sooner.
func (c usersAdminController) FormAfterUpdate(ctx context.Context, model any) error {
user, ok := model.(*models.User)
if !ok || user == nil {
return errors.New("users admin: form model is not a user")
}
tx, _ := cabana.TxFromContext(ctx)
app := c.application()
lagoon.AfterCommit(ctx, tx, func(ctx context.Context, _ *gorm.DB) {
classes.FireSubjectChanged(ctx, app, classes.SubjectGuardUser, user.ID, pact.MCPSubjectPermissions)
})
return nil
}
// FormBeforeCreate checks the email against deactivated users too and stores
// the hash of the submitted password. A user created here starts not
// activated: is_activated is never written from the form, and only the
@@ -427,7 +445,9 @@ func (c usersAdminController) AdminRelationLocks(ctx context.Context, field stri
const permissionsField = "permissions"
// AdminPermissionOptions offers the rows of
// golem15_user_frontend_permissions, by tab and code. None is locked.
// golem15_user_frontend_permissions, by tab and code, then the frontend
// permission codes activated plugins declare (classes.FrontendPermissionCatalog).
// None is locked.
func (c usersAdminController) AdminPermissionOptions(ctx context.Context, field string) ([]cabana.PermissionOption, error) {
if field != permissionsField {
return nil, fmt.Errorf("users admin: unknown permission field %q", field)
@@ -436,19 +456,7 @@ func (c usersAdminController) AdminPermissionOptions(ctx context.Context, field
if db == nil {
return nil, errors.New("users admin: gorm handle is missing")
}
rows, err := classes.FrontendPermissionOptions(ctx, db)
if err != nil {
return nil, err
}
options := make([]cabana.PermissionOption, 0, 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)
}
return options, nil
return classes.FrontendPermissionCatalog(ctx, c.application(), db)
}
// AdminPermissionValues returns the user's own stored permissions.

View File

@@ -0,0 +1,112 @@
package user
import (
"net/http"
"testing"
"git.golem15.com/golem15/summercms/modules/backpack"
"git.golem15.com/golem15/summercms/modules/pact"
"git.golem15.com/golem15/summercms/modules/party"
)
// The frontend permission codes the acmeBlogPermissions test plugin declares.
const (
acmeBlogManage = "acme.blog.manage"
// acmeBlogSeeded is also a seeded row of golem15_user_frontend_permissions
// in the smoke, with another label: the seeded row wins.
acmeBlogSeeded = "acme.blog.seeded"
)
// acmeBlogPermissions is a plugin that only declares frontend permission
// codes (pact.HasFrontendPermissions). It is registered for the test binary
// and activated only by the tests that name it.
type acmeBlogPermissions struct{}
func (acmeBlogPermissions) ID() string { return "acme.blog" }
func (acmeBlogPermissions) Requires() []string { return nil }
func (acmeBlogPermissions) Register(*backpack.App) error { return nil }
func (acmeBlogPermissions) Boot(*backpack.App) error { return nil }
func (acmeBlogPermissions) FrontendPermissions() []pact.FrontendPermission {
return []pact.FrontendPermission{
{Code: acmeBlogManage, Label: "Manage blog posts", Tab: "Blog", Comment: "Create, edit and delete posts."},
{Code: acmeBlogSeeded, Label: "Plugin label", Tab: "Blog"},
{Code: acmeBlogManage, Label: "Declared twice", Tab: "Blog"},
}
}
func init() { party.Register(acmeBlogPermissions{}) }
type offeredPermission struct {
Code string `json:"code"`
Label string `json:"label"`
Tab string `json:"tab"`
Comment string `json:"comment"`
}
// permissionOptions reads the options of the permissions field of a form
// schema.
func (e *adminEnv) permissionOptions(t *testing.T, token, path string) []offeredPermission {
t.Helper()
var form struct {
Fields []struct {
Name string `json:"name"`
Options []offeredPermission `json:"permissionOptions"`
} `json:"fields"`
}
adminInto(t, e.expect(t, http.StatusOK, http.MethodGet, adminAPI(path+"/schema/form"), token, nil), &form)
for _, field := range form.Fields {
if field.Name == "permissions" {
return field.Options
}
}
t.Fatalf("%s has no permissions field", path)
return nil
}
// TestFrontendPermissionCatalogSmoke: both permission editors offer the
// seeded rows first, then the codes an activated plugin declares; a seeded
// row wins over a declared code and a code is offered once.
func TestFrontendPermissionCatalogSmoke(t *testing.T) {
env := newAdminEnvWith(t, []string{"acme.blog"})
if err := env.gdb.Exec(`INSERT INTO golem15_user_frontend_permissions (code, label, tab) VALUES (?, 'Seeded label', 'Blog') ON CONFLICT (code) DO NOTHING`, acmeBlogSeeded).Error; err != nil {
t.Fatal(err)
}
var seeded int64
if err := env.gdb.Raw(`SELECT COUNT(*) FROM golem15_user_frontend_permissions`).Scan(&seeded).Error; err != nil {
t.Fatal(err)
}
token := env.admin(t, permAccessUsers, permAccessGroups)
for _, path := range []string{adminUsersPath, adminGroupsPath} {
options := env.permissionOptions(t, token, path)
if int64(len(options)) != seeded+1 {
t.Fatalf("%s offers %d options, want the %d seeded rows and one declared code: %+v", path, len(options), seeded, options)
}
last := options[len(options)-1]
if last != (offeredPermission{Code: acmeBlogManage, Label: "Manage blog posts", Tab: "Blog", Comment: "Create, edit and delete posts."}) {
t.Fatalf("%s: last option = %+v, want the declared acme.blog.manage", path, last)
}
found := 0
for _, option := range options[:len(options)-1] {
if option.Code == acmeBlogManage {
t.Fatalf("%s offers %s twice", path, acmeBlogManage)
}
if option.Code == acmeBlogSeeded {
found++
if option.Label != "Seeded label" {
t.Fatalf("%s: duplicate code label = %q, want the seeded row's", path, option.Label)
}
}
}
if found != 1 {
t.Fatalf("%s offers %s %d times", path, acmeBlogSeeded, found)
}
}
// Without the declaring plugin the editors offer the seeded rows only.
plain := newAdminEnv(t)
plainToken := plain.admin(t, permAccessUsers)
if options := plain.permissionOptions(t, plainToken, adminUsersPath); int64(len(options)) != seeded {
t.Fatalf("without acme.blog: %d options, want %d", len(options), seeded)
}
}

24
mcp.go Normal file
View File

@@ -0,0 +1,24 @@
package user
import (
"net/http"
"git.golem15.com/golem15/summercms/modules/pact"
)
var _ pact.HasMCP = (*Plugin)(nil)
// MCP declares the plugin's MCP tools (D-18). me (exposed as user_me) runs
// GET /_user/api/v1/token/me as the signed-in frontend user under the
// "user" guard and answers the user payload.
func (p *Plugin) MCP() pact.MCP {
return pact.MCP{Tools: []pact.MCPTool{{
Name: "me",
Title: "Current user",
Description: "Returns the profile of the signed-in frontend user.",
Guard: "user",
Method: http.MethodGet,
Path: "/_user/api/v1/token/me",
Input: struct{}{},
}}}
}

185
mcp_guard_smoke_test.go Normal file
View File

@@ -0,0 +1,185 @@
package user
import (
"context"
"errors"
"net/http"
"slices"
"strconv"
"strings"
"testing"
"git.golem15.com/golem15/sm-user-plugin/classes"
"git.golem15.com/golem15/sm-user-plugin/models"
"git.golem15.com/golem15/summercms/modules/bouncer"
"git.golem15.com/golem15/summercms/modules/pact"
)
// mintUserToken stores a personal API token of the user with the scopes and
// returns its secret and row.
func (e *adminEnv) mintUserToken(t *testing.T, user *models.User, scopes ...string) (string, *models.APIToken) {
t.Helper()
secret, token, err := classes.MintAPIToken(classes.DefaultAPITokenPrefix, user.ID, "mcp "+adminStamp(), scopes, nil, nil)
if err != nil {
t.Fatal(err)
}
if err := e.gdb.Create(token).Error; err != nil {
t.Fatal(err)
}
return secret, token
}
// grantGroup stores the codes as the group's permissions.
func (e *adminEnv) grantGroup(t *testing.T, group *models.UserGroup, codes ...string) {
t.Helper()
parts := make([]string, 0, len(codes))
for _, code := range codes {
parts = append(parts, strconv.Quote(code)+":1")
}
if err := e.gdb.Exec(`UPDATE user_groups SET permissions = ? WHERE id = ?`, "{"+strings.Join(parts, ",")+"}", group.ID).Error; err != nil {
t.Fatal(err)
}
}
// TestUserMCPGuardSmoke: the "user" guard provider is registered and
// published as the personal token resolver; it reads the plugin's route
// middleware, allows a tool with exactly the checks the routes run (scopes
// from the session, permission codes from the user's groups, every code
// required), dispatches with the group grants and an in-memory token
// credential, and resolves usr_ tokens. GET /token/me, the user_me target,
// answers the user payload to a read token only, and revoking a token fires
// the revoked subject event.
func TestUserMCPGuardSmoke(t *testing.T) {
env := newAdminEnv(t)
spy := env.spySubjects()
ctx := context.Background()
guards, ok := env.app.Lookup[*pact.MCPGuards]()
if !ok || guards == nil {
t.Fatal("*pact.MCPGuards is not published")
}
guard, ok := guards.Lookup("user")
if !ok || guard.MCPGuardName() != "user" {
t.Fatalf("user guard = %v %v", guard, ok)
}
resolver, ok := env.app.Lookup[pact.MCPPersonalTokenResolver]()
if !ok || resolver == nil {
t.Fatal("pact.MCPPersonalTokenResolver is not published")
}
if !guard.MCPTokenGuarded([]string{"throttle:user-api", "user.api_token", "user.scope:read"}) || guard.MCPTokenGuarded([]string{"throttle:user-api", "jwt.auth"}) {
t.Fatal("MCPTokenGuarded must answer true only with user.api_token")
}
req, err := guard.MCPRequirements([]string{"throttle:user-api", "user.api_token", "user.scope:read", "user.scope:write", "user.permission:acme.posts.edit"})
if err != nil || !slices.Equal(req.Scopes, []string{"read", "write"}) || !slices.Equal(req.Permissions, []string{"acme.posts.edit"}) {
t.Fatalf("MCPRequirements = %+v %v", req, err)
}
if _, err := guard.MCPRequirements([]string{"user.api_token", "user.bogus"}); err == nil {
t.Fatal("an unknown user.* middleware must be an error")
}
stamp := adminStamp()
codeA, codeB, codeOwn := "mcp."+stamp+".a", "mcp."+stamp+".b", "mcp."+stamp+".own"
user := env.seedUser(t, "mcp", func(u *models.User) { u.Permissions = models.PermissionSet{codeOwn: 1} })
groupA, groupB := env.seedGroup(t, "MCP A "+stamp, "mcp-a-"+stamp), env.seedGroup(t, "MCP B "+stamp, "mcp-b-"+stamp)
env.grantGroup(t, groupA, codeA)
env.grantGroup(t, groupB, codeB)
env.join(t, user, groupA, groupB)
subject := pact.MCPSubject{Guard: "user", UserID: user.ID, Scopes: []string{"read"}}
allows := func(req pact.MCPRequirements, perms ...string) bool {
t.Helper()
ok, err := guard.MCPAllows(ctx, subject, req, perms)
if err != nil {
t.Fatal(err)
}
return ok
}
if allows(pact.MCPRequirements{Scopes: []string{"read", "write"}}) {
t.Fatal("a scope the session lacks must refuse")
}
if !allows(pact.MCPRequirements{Scopes: []string{"read"}, Permissions: []string{codeA}}, codeB) {
t.Fatal("codes granted by the user's groups must allow")
}
if allows(pact.MCPRequirements{Scopes: []string{"read"}, Permissions: []string{codeA, "mcp." + stamp + ".missing"}}) {
t.Fatal("every code is required: one missing code must refuse")
}
if allows(pact.MCPRequirements{Scopes: []string{"read"}}, codeOwn) {
t.Fatal("a user-level permission alone must not grant (the route checks group grants)")
}
if ok, err := guard.MCPAllows(ctx, pact.MCPSubject{Guard: "user", UserID: user.ID + 1_000_000, Scopes: []string{"read"}}, pact.MCPRequirements{}, nil); err != nil || ok {
t.Fatalf("an unknown user = %v %v", ok, err)
}
dispatch, err := guard.MCPDispatch(ctx, subject, http.MethodGet)
if err != nil {
t.Fatal(err)
}
principal, ok := dispatch.Principal.(*bouncer.Principal)
if !ok || principal.ID != user.ID || !principal.PermissionGrants[codeA] || !principal.PermissionGrants[codeB] || principal.PermissionGrants[codeOwn] {
t.Fatalf("dispatch principal = %+v", dispatch.Principal)
}
credential, ok := dispatch.Credential.(*models.APIToken)
if !ok || credential.ID != 0 || credential.UserID != user.ID || !credential.HasScope("read") || credential.HasScope("write") {
t.Fatalf("dispatch credential = %+v", dispatch.Credential)
}
if !slices.Equal(dispatch.Guards, []string{"api_token"}) {
t.Fatalf("dispatch guards = %v", dispatch.Guards)
}
if _, matched, err := resolver.MCPResolvePersonalToken(ctx, "oas_at_x"); matched || err != nil {
t.Fatalf("a foreign prefix = %v %v", matched, err)
}
readSecret, readToken := env.mintUserToken(t, user, "read", "ai")
got, matched, err := resolver.MCPResolvePersonalToken(ctx, readSecret)
if err != nil || !matched || got.Guard != "user" || got.UserID != user.ID || got.TokenID != readToken.ID || !slices.Equal(got.Scopes, []string{"read", "ai"}) {
t.Fatalf("resolve = %+v %v %v", got, matched, err)
}
var stamped models.APIToken
if err := env.gdb.First(&stamped, readToken.ID).Error; err != nil || stamped.LastUsedAt == nil {
t.Fatalf("last_used_at not stamped: %+v %v", stamped, err)
}
if _, matched, err := resolver.MCPResolvePersonalToken(ctx, classes.DefaultAPITokenPrefix+"not-a-token"); !matched || !errors.Is(err, pact.ErrMCPTokenInvalid) {
t.Fatalf("an unknown usr_ token = %v %v", matched, err)
}
// GET /token/me: the user_me target.
rec := env.expect(t, http.StatusOK, http.MethodGet, "/_user/api/v1/token/me", readSecret, nil)
if !strings.Contains(rec.Body.String(), `"email":"`+user.Email+`"`) || !strings.HasPrefix(rec.Body.String(), `{"user":`) {
t.Fatalf("token/me = %s", rec.Body.String())
}
writeSecret, _ := env.mintUserToken(t, user, "write")
rec = env.expect(t, http.StatusForbidden, http.MethodGet, "/_user/api/v1/token/me", writeSecret, nil)
if !strings.Contains(rec.Body.String(), "Missing required scope: read") {
t.Fatalf("token/me with write = %s", rec.Body.String())
}
env.expect(t, http.StatusUnauthorized, http.MethodGet, "/_user/api/v1/token/me", freshUserToken(t, user), nil)
// Revoking the token through the API fires revoked; the resolver then
// refuses it.
spy.reset()
env.expect(t, http.StatusOK, http.MethodDelete, "/_user/api/v1/tokens/"+strconv.FormatUint(uint64(readToken.ID), 10), freshUserToken(t, user), nil)
if !spy.has("user", user.ID, pact.MCPSubjectRevoked) {
t.Fatalf("revoke fired %+v", spy.events)
}
if _, matched, err := resolver.MCPResolvePersonalToken(ctx, readSecret); !matched || !errors.Is(err, pact.ErrMCPTokenInvalid) {
t.Fatalf("a revoked token = %v %v", matched, err)
}
// Saving a group's permissions on the admin screen fires permissions
// for each member after the save commits.
if err := env.gdb.Exec(`INSERT INTO golem15_user_frontend_permissions (code, label, tab) VALUES (?, 'A', 'MCP'), (?, 'B', 'MCP')`, codeA, codeB).Error; err != nil {
t.Fatal(err)
}
spy.reset()
manager := env.admin(t, permAccessGroups)
env.expect(t, http.StatusOK, http.MethodPut, groupPath(groupA.ID), manager, map[string]any{"name": groupA.Name, "code": *groupA.Code, "permissions": map[string]int{codeA: 1, codeB: 1}})
if !spy.has("user", user.ID, pact.MCPSubjectPermissions) {
t.Fatalf("group save fired %+v", spy.events)
}
// Saving the user on the Users screen fires permissions too.
spy.reset()
users := env.admin(t, permAccessUsers)
env.expect(t, http.StatusOK, http.MethodPut, userPath(user.ID, ""), users, map[string]any{"name": "MCP renamed", "email": user.Email})
if !spy.has("user", user.ID, pact.MCPSubjectPermissions) {
t.Fatalf("user save fired %+v", spy.events)
}
}

View File

@@ -96,9 +96,9 @@ func TestPasswordPublicRoutes(t *testing.T) {
t.Fatalf("unset: Routes error %v", err)
}
// 18 routes up to v0.1.9, plus GET /backend-link/{code} and POST
// /backend-link/confirm (the account link).
if len(base) != 20 {
t.Fatalf("unset: %d routes, want 20", len(base))
// /backend-link/confirm (the account link) and GET /token/me.
if len(base) != 21 {
t.Fatalf("unset: %d routes, want 21", len(base))
}
seen := 0
for _, route := range base {
@@ -110,8 +110,8 @@ func TestPasswordPublicRoutes(t *testing.T) {
t.Fatalf("unset: found %d of the password routes, want %d", seen, len(passwordRouteKeys))
}
off := withoutPasswordRoutes(base)
if len(off) != 17 {
t.Fatalf("base without the password routes has %d routes, want 17", len(off))
if len(off) != 18 {
t.Fatalf("base without the password routes has %d routes, want 18", len(off))
}
for _, c := range []struct {

View File

@@ -110,9 +110,31 @@ func (p *Plugin) Boot(app *backpack.App) error {
if err := p.bootBackendLinks(app); err != nil {
return err
}
if err := p.bootMCPGuard(app); err != nil {
return err
}
return classes.RegisterUserAuthorizer(app)
}
// bootMCPGuard registers the "user" MCP guard provider in the application's
// *pact.MCPGuards (publishing the registry when no one did yet) and
// publishes the same value as pact.MCPPersonalTokenResolver for the
// plugin's personal API tokens. Without the MCP module both stay unused.
func (p *Plugin) bootMCPGuard(app *backpack.App) error {
guards, ok := app.Lookup[*pact.MCPGuards]()
if !ok || guards == nil {
guards = pact.NewMCPGuards()
if err := app.Publish(guards); err != nil {
return err
}
}
guard := &classes.MCPUserGuard{App: app}
if err := guards.Register(guard); err != nil {
return err
}
return app.Publish[pact.MCPPersonalTokenResolver](guard)
}
// bootBackendLinks publishes the account link service as *classes.BackendLinks
// and as the MCP linked-backend resolver, and removes a frontend user's link
// when the user is deleted or deactivated. The request lifetime is checked

View File

@@ -45,6 +45,7 @@ func (p *Plugin) Routes(r pact.Router) error {
g.Get("/backend-link/{code}", controllers.BackendLinkShow(p.app))
g.Where("code", "[a-f0-9]{64}")
g.Post("/backend-link/confirm", controllers.BackendLinkConfirm(p.app), "jwt.auth")
g.Get("/token/me", controllers.TokenMe(p.app), "user.api_token", "user.scope:read")
})
return nil
}