From 0f545f5c81b3ef6f47985b2b8d88bb7099b91695 Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Fri, 23 Oct 2026 12:08:38 +0200 Subject: [PATCH] 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 --- admin_harness_test.go | 9 +- classes/frontend_permission_catalog.go | 59 +++++++ classes/mcp_guard.go | 192 +++++++++++++++++++++ controllers/api_tokens.go | 3 + controllers/token_me.go | 39 +++++ controllers/usergroups_admin_controller.go | 80 +++++++-- controllers/users_admin_controller.go | 36 ++-- frontend_permission_catalog_smoke_test.go | 112 ++++++++++++ mcp.go | 24 +++ mcp_guard_smoke_test.go | 185 ++++++++++++++++++++ password_routes_test.go | 10 +- plugin.go | 22 +++ routes.go | 1 + 13 files changed, 734 insertions(+), 38 deletions(-) create mode 100644 classes/frontend_permission_catalog.go create mode 100644 classes/mcp_guard.go create mode 100644 controllers/token_me.go create mode 100644 frontend_permission_catalog_smoke_test.go create mode 100644 mcp.go create mode 100644 mcp_guard_smoke_test.go diff --git a/admin_harness_test.go b/admin_harness_test.go index e7c7b35..16511a6 100644 --- a/admin_harness_test.go +++ b/admin_harness_test.go @@ -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) } diff --git a/classes/frontend_permission_catalog.go b/classes/frontend_permission_catalog.go new file mode 100644 index 0000000..28d80b4 --- /dev/null +++ b/classes/frontend_permission_catalog.go @@ -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 +} diff --git a/classes/mcp_guard.go b/classes/mcp_guard.go new file mode 100644 index 0000000..baac739 --- /dev/null +++ b/classes/mcp_guard.go @@ -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: 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 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 +} diff --git a/controllers/api_tokens.go b/controllers/api_tokens.go index 5f93c5a..b9ee93c 100644 --- a/controllers/api_tokens.go +++ b/controllers/api_tokens.go @@ -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}}) } } diff --git a/controllers/token_me.go b/controllers/token_me.go new file mode 100644 index 0000000..4b83ae1 --- /dev/null +++ b/controllers/token_me.go @@ -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}) + } +} diff --git a/controllers/usergroups_admin_controller.go b/controllers/usergroups_admin_controller.go index 70432e9..3b5d83c 100644 --- a/controllers/usergroups_admin_controller.go +++ b/controllers/usergroups_admin_controller.go @@ -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 diff --git a/controllers/users_admin_controller.go b/controllers/users_admin_controller.go index f2eaa1c..ae27012 100644 --- a/controllers/users_admin_controller.go +++ b/controllers/users_admin_controller.go @@ -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. diff --git a/frontend_permission_catalog_smoke_test.go b/frontend_permission_catalog_smoke_test.go new file mode 100644 index 0000000..a834820 --- /dev/null +++ b/frontend_permission_catalog_smoke_test.go @@ -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) + } +} diff --git a/mcp.go b/mcp.go new file mode 100644 index 0000000..9444db8 --- /dev/null +++ b/mcp.go @@ -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{}{}, + }}} +} diff --git a/mcp_guard_smoke_test.go b/mcp_guard_smoke_test.go new file mode 100644 index 0000000..aa5fa85 --- /dev/null +++ b/mcp_guard_smoke_test.go @@ -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) + } +} diff --git a/password_routes_test.go b/password_routes_test.go index e054f09..b0dcbb4 100644 --- a/password_routes_test.go +++ b/password_routes_test.go @@ -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 { diff --git a/plugin.go b/plugin.go index 2be47f8..05e7675 100644 --- a/plugin.go +++ b/plugin.go @@ -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 diff --git a/routes.go b/routes.go index 615605a..cd71088 100644 --- a/routes.go +++ b/routes.go @@ -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 }