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:
@@ -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)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
@@ -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
39
controllers/token_me.go
Normal 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})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
112
frontend_permission_catalog_smoke_test.go
Normal file
112
frontend_permission_catalog_smoke_test.go
Normal 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
24
mcp.go
Normal 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
185
mcp_guard_smoke_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
22
plugin.go
22
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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user