feat: manage a user's groups from the user form, privileged groups locked

- groups relation field on the user form, the only writer of users_groups
- UsersGroup pivot model
- AdminRelationLocks: adding or removing a group whose code is in
  golem15.user.privileged_groups needs golem15.users.manage_privileged_groups,
  on create and on update; refused saves answer 403 and write nothing
- TestAdminUserGroupsField and TestAdminPrivilegedGroups
This commit is contained in:
Jakub Zych
2026-10-05 13:44:15 +02:00
parent 5805c6347f
commit 9611708792
8 changed files with 504 additions and 6 deletions

View File

@@ -336,9 +336,15 @@ func (c usersAdminController) FormAfterCreate(ctx context.Context, model any) er
return nil
}
// AdminFieldRelations binds the organisation picker. organisation_id is a
// protected column, so the contract opts in to writing it through this one
// field; the submitted id is still checked against the options.
// groupsField is the form's group membership field.
const groupsField = "groups"
// AdminFieldRelations binds the organisation picker and the groups field.
// organisation_id is a protected column, so the contract opts in to writing
// it through this one field; the submitted id is still checked against the
// options. groups is the users_groups pivot: a save that carries the field
// replaces the user's memberships, and AdminRelationLocks decides which of
// them the administrator may change.
func (usersAdminController) AdminFieldRelations() []cabana.FieldRelationContract {
return []cabana.FieldRelationContract{
{
@@ -349,9 +355,47 @@ func (usersAdminController) AdminFieldRelations() []cabana.FieldRelationContract
LabelColumn: "name",
WritableForeignKey: true,
},
{
Field: groupsField,
Kind: "belongsToMany",
NewRelated: func() any { return &models.UserGroup{} },
NewPivot: func() any { return &models.UsersGroup{} },
ParentForeignKey: "user_id",
RelatedForeignKey: "user_group_id",
LabelColumn: "name",
},
}
}
// AdminRelationLocks locks the privileged groups in the groups field for an
// administrator who lacks golem15.users.manage_privileged_groups: the
// framework shows them locked and answers 403, before any row is written, to
// a create or update that would add or remove one. Membership of a
// privileged group makes a site administrator, so golem15.users.access_users
// alone must not be able to grant or revoke it. The privileged codes come
// from golem15.user.privileged_groups on every call and the group ids are
// read through the write's transaction when there is one. An administrator
// without a principal on the context gets the locks. Every other field has
// none.
func (c usersAdminController) AdminRelationLocks(ctx context.Context, field string) (cabana.RelationLock, error) {
if field != groupsField {
return cabana.RelationLock{}, nil
}
principal, _ := bouncer.User(ctx)
if cabana.Allows(principal, []string{classes.PermissionManagePrivilegedGroups}) {
return cabana.RelationLock{}, nil
}
db := requestDB(ctx, c.db)
if db == nil {
return cabana.RelationLock{}, errors.New("users admin: gorm handle is missing")
}
ids, err := classes.PrivilegedGroupIDs(ctx, db, classes.PrivilegedGroupCodes(c.config()))
if err != nil {
return cabana.RelationLock{}, err
}
return cabana.RelationLock{IDs: ids, Message: usersLang + "privileged_group_forbidden"}, nil
}
// permissionsField is the form's permission editor field.
const permissionsField = "permissions"