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

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