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

@@ -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.