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:
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user