From 961170879212c355c418076d0d58bda320d8b899 Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Mon, 5 Oct 2026 13:44:15 +0200 Subject: [PATCH] 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 --- admin_privileged_test.go | 424 ++++++++++++++++++++++++++ admin_users_test.go | 6 +- controllers/admin_registry.go | 1 + controllers/users_admin_controller.go | 50 ++- lang/en/lang.yaml | 3 + lang/pl/lang.yaml | 3 + models/user/fields.yaml | 6 + models/users_group.go | 17 ++ 8 files changed, 504 insertions(+), 6 deletions(-) create mode 100644 admin_privileged_test.go create mode 100644 models/users_group.go diff --git a/admin_privileged_test.go b/admin_privileged_test.go new file mode 100644 index 0000000..50ceab4 --- /dev/null +++ b/admin_privileged_test.go @@ -0,0 +1,424 @@ +package user + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "reflect" + "strconv" + "testing" + + "git.golem15.com/golem15/sm-user-plugin/classes" + "git.golem15.com/golem15/sm-user-plugin/models" + "git.golem15.com/golem15/summercms/modules/compass" +) + +const privilegedGroupForbidden = "You do not have permission to change membership of privileged groups. Nothing was saved." + +// groupOptions returns the choices of the user form's groups field that +// match search, as id to locked. +func (e *adminEnv) groupOptions(t *testing.T, token, search string) map[uint]bool { + t.Helper() + var options []struct { + Value uint `json:"value"` + Locked bool `json:"locked"` + } + path := adminAPI(adminUsersPath + "/fields/groups/options?per_page=100&search=" + search) + adminInto(t, e.expect(t, http.StatusOK, http.MethodGet, path, token, nil), &options) + out := map[uint]bool{} + for _, option := range options { + out[option.Value] = option.Locked + } + return out +} + +// recordGroups reads a user through the admin API and returns the ids of +// the groups field and, per id, whether its label is flagged locked. +func (e *adminEnv) recordGroups(t *testing.T, token string, userID uint) ([]uint, map[uint]bool) { + t.Helper() + rec := e.expect(t, http.StatusOK, http.MethodGet, userPath(userID, ""), token, nil) + var data struct { + Groups []uint `json:"groups"` + } + answer := adminInto(t, rec, &data) + var meta struct { + Labels map[string][]struct { + Value uint `json:"value"` + Locked bool `json:"locked"` + } `json:"labels"` + } + if err := json.Unmarshal(answer.Meta, &meta); err != nil { + t.Fatalf("record meta %q: %v", string(answer.Meta), err) + } + locked := map[uint]bool{} + for _, label := range meta.Labels["groups"] { + locked[label.Value] = label.Locked + } + return data.Groups, locked +} + +// sortedIDs returns the ids of the groups in ascending order, the order +// groupIDs reads the pivot in. +func sortedIDs(groups ...*models.UserGroup) []uint { + ids := make([]uint, 0, len(groups)) + for _, group := range groups { + ids = append(ids, group.ID) + } + for i := 1; i < len(ids); i++ { + for j := i; j > 0 && ids[j-1] > ids[j]; j-- { + ids[j-1], ids[j] = ids[j], ids[j-1] + } + } + return ids +} + +// TestAdminUserGroupsField covers the groups field of the user form with +// ordinary groups: it is the writer of users_groups, it replaces the user's +// memberships only when the field is sent, it refuses ids that are not +// groups, and none of it reaches a user API payload (D-08). +func TestAdminUserGroupsField(t *testing.T) { + env := newAdminEnv(t) + token := env.admin(t, permAccessUsers) + stamp := adminStamp() + first := env.seedGroup(t, "First "+stamp, "first-"+stamp) + second := env.seedGroup(t, "Second "+stamp, "second-"+stamp) + user := env.seedUser(t, "groups", nil) + base := map[string]any{"name": "groups", "email": user.Email} + save := func(status int, groups any) *httptest.ResponseRecorder { + t.Helper() + body := map[string]any{"groups": groups} + for key, value := range base { + body[key] = value + } + return env.expect(t, status, http.MethodPut, userPath(user.ID, ""), token, body) + } + pivot := func(want ...*models.UserGroup) { + t.Helper() + if got := env.groupIDs(t, user.ID); !reflect.DeepEqual(got, sortedIDs(want...)) { + t.Fatalf("users_groups of %d = %v, want %v", user.ID, got, sortedIDs(want...)) + } + } + + // The field is a multiple relation field on the Account tab, and its + // options are the user groups; none is locked for ordinary codes. + var form struct { + Fields []struct { + Name string `json:"name"` + Type string `json:"type"` + Multiple bool `json:"multiple"` + Tab string `json:"tab"` + } `json:"fields"` + } + adminInto(t, env.expect(t, http.StatusOK, http.MethodGet, adminAPI(adminUsersPath+"/schema/form"), token, nil), &form) + found := false + for _, field := range form.Fields { + if field.Name != "groups" { + continue + } + found = true + if field.Type != "relation" || !field.Multiple || field.Tab != "Account" { + t.Fatalf("groups field = %+v", field) + } + } + if !found { + t.Fatal("the user form has no groups field") + } + if options := env.groupOptions(t, token, stamp); len(options) != 2 || options[first.ID] || options[second.ID] { + t.Fatalf("group options = %v", options) + } + + // Setting the field writes exactly the chosen memberships. + saved := save(http.StatusOK, []uint{first.ID, second.ID}) + pivot(first, second) + var record struct { + Groups []uint `json:"groups"` + } + adminInto(t, saved, &record) + if !reflect.DeepEqual(record.Groups, sortedIDs(first, second)) { + t.Fatalf("record groups = %v", record.Groups) + } + if ids, locked := env.recordGroups(t, token, user.ID); !reflect.DeepEqual(ids, sortedIDs(first, second)) || locked[first.ID] || locked[second.ID] || len(locked) != 2 { + t.Fatalf("shown groups = %v locked %v", ids, locked) + } + save(http.StatusOK, []uint{second.ID}) + pivot(second) + + // A save without the field leaves the memberships alone. + env.expect(t, http.StatusOK, http.MethodPut, userPath(user.ID, ""), token, map[string]any{"name": "renamed", "email": user.Email}) + pivot(second) + if got := env.reload(t, user.ID); got.Name == nil || *got.Name != "renamed" { + t.Fatalf("name after a save without groups = %v", got.Name) + } + + // An id that is not a group, a duplicate and a value that is not a list + // are refused on the field and change nothing. + for _, bad := range []any{[]uint{second.ID, 987654321}, []uint{first.ID, first.ID}, first.ID, []string{"x"}} { + rec := save(http.StatusUnprocessableEntity, bad) + if details := adminDecode(t, rec).Error.Details; len(details["groups"]) == 0 { + t.Fatalf("refusal of %v has no detail on groups: %s", bad, rec.Body.String()) + } + pivot(second) + } + // The plain relation name of the model is not a way around the field. + env.expect(t, http.StatusOK, http.MethodPut, userPath(user.ID, ""), token, map[string]any{ + "name": "renamed", "email": user.Email, "Groups": []map[string]any{{"id": first.ID}}, "users_groups": []uint{first.ID}, + }) + pivot(second) + + // The user API payload still answers an empty list (D-08), although the + // user is a member of a group. + status, userToken := env.userLogin(t, user.Email, "correct-horse") + if status != http.StatusOK { + t.Fatalf("user login = %d", status) + } + fetch := env.call(t, http.MethodGet, "/_user/api/v1/fetch", userToken, nil) + var body struct { + User map[string]any `json:"user"` + } + if err := json.Unmarshal(fetch.Body.Bytes(), &body); err != nil || fetch.Code != http.StatusOK { + t.Fatalf("fetch = %d %s (%v)", fetch.Code, fetch.Body.String(), err) + } + if groups, ok := body.User["groups"].([]any); !ok || len(groups) != 0 { + t.Fatalf("user payload groups = %v", body.User["groups"]) + } + + // Clearing the field removes every membership. + save(http.StatusOK, []uint{}) + pivot() + + // A create with the field writes the memberships of the new user. + const password = "groups-password-1" + created := env.expect(t, http.StatusCreated, http.MethodPost, adminAPI(adminUsersPath), token, map[string]any{ + "name": "Created", "email": "groups-created-" + stamp + "@example.test", + "password": password, "password_confirmation": password, "send_invite": false, + "groups": []uint{first.ID}, + }) + if got := env.groupIDs(t, createdID(t, created)); !reflect.DeepEqual(got, []uint{first.ID}) { + t.Fatalf("users_groups of the created user = %v", got) + } + // A refused create leaves no user behind. + refusedEmail := "groups-refused-" + stamp + "@example.test" + env.expect(t, http.StatusUnprocessableEntity, http.MethodPost, adminAPI(adminUsersPath), token, map[string]any{ + "name": "Refused", "email": refusedEmail, "password": password, "password_confirmation": password, "send_invite": false, + "groups": []uint{987654321}, + }) + var refused int64 + if err := env.gdb.Raw(`SELECT COUNT(*) FROM users WHERE email = ?`, refusedEmail).Scan(&refused).Error; err != nil || refused != 0 { + t.Fatalf("a refused create left %d users (%v)", refused, err) + } +} + +// TestAdminPrivilegedGroups covers threat T-12-18 as revisited by D-04 to +// D-07: golem15.users.access_users changes ordinary memberships, and adding +// or removing a privileged group additionally needs +// golem15.users.manage_privileged_groups, on create and on update. It also +// ties the field to the takeover guard (D-30). +func TestAdminPrivilegedGroups(t *testing.T) { + env := newAdminEnv(t) + limited := env.admin(t, permAccessUsers) + manager := env.admin(t, permAccessUsers, permManagePrivileged) + + stamp := adminStamp() + // The migrations seed only guest and registered; the default privileged + // list is [admin]. + adminGroup := env.seedGroup(t, "Admins "+stamp, "admin") + ordinaryCode := "ordinary-" + stamp + ordinary := env.seedGroup(t, "Ordinary "+stamp, ordinaryCode) + uncoded := env.seedGroup(t, "Uncoded "+stamp, "") + // A code that differs from a privileged one only by case is ordinary. + upper := env.seedGroup(t, "Upper "+stamp, "ADMIN") + + isAdmin := func(e *adminEnv, userID uint) bool { + t.Helper() + held, err := classes.HasGroupCode(t.Context(), e.gdb, userID, "admin") + if err != nil { + t.Fatal(err) + } + return held + } + put := func(e *adminEnv, status int, token string, user *models.User, extra map[string]any) *httptest.ResponseRecorder { + t.Helper() + body := map[string]any{"name": "kept", "email": user.Email} + for key, value := range extra { + body[key] = value + } + return e.expect(t, status, http.MethodPut, userPath(user.ID, ""), token, body) + } + assertRefused := func(rec *httptest.ResponseRecorder) { + t.Helper() + answer := adminDecode(t, rec) + if answer.Error.Code != "forbidden" || answer.Error.Message != privilegedGroupForbidden { + t.Fatalf("refusal = %s", rec.Body.String()) + } + if got := answer.Error.Details["groups"]; len(answer.Error.Details) != 1 || len(got) != 1 || got[0] != privilegedGroupForbidden { + t.Fatalf("refusal details = %v", answer.Error.Details) + } + } + pivot := func(e *adminEnv, user *models.User, want ...*models.UserGroup) { + t.Helper() + if got := e.groupIDs(t, user.ID); !reflect.DeepEqual(got, sortedIDs(want...)) { + t.Fatalf("users_groups of %d = %v, want %v", user.ID, got, sortedIDs(want...)) + } + } + unchanged := func(original *models.User) { + t.Helper() + got := env.reload(t, original.ID) + if got == nil || got.Email != original.Email || !reflect.DeepEqual(got.Name, original.Name) || + got.Password != original.Password || !reflect.DeepEqual(got.OrganisationID, original.OrganisationID) { + t.Fatalf("user %d changed: %+v", original.ID, got) + } + } + + // Without the permission the privileged group is flagged locked among the + // options; a group without a code and one whose code differs by case are + // not. With the permission nothing is locked. + if options := env.groupOptions(t, limited, stamp); len(options) != 4 || !options[adminGroup.ID] || options[ordinary.ID] || options[uncoded.ID] || options[upper.ID] { + t.Fatalf("options without the permission = %v", options) + } + if options := env.groupOptions(t, manager, stamp); len(options) != 4 || options[adminGroup.ID] || options[ordinary.ID] || options[uncoded.ID] || options[upper.ID] { + t.Fatalf("options with the permission = %v", options) + } + + // Adding the privileged group on update is refused as a whole: the name + // in the same body is not saved and the pivot is unchanged. + target := env.seedUser(t, "target", nil) + env.join(t, target, ordinary) + if isAdmin(env, target.ID) { + t.Fatal("the target starts as a site admin") + } + assertRefused(put(env, http.StatusForbidden, limited, target, map[string]any{"name": "Promoted", "groups": []uint{ordinary.ID, adminGroup.ID}})) + pivot(env, target, ordinary) + unchanged(target) + if isAdmin(env, target.ID) { + t.Fatal("a refused save made a site admin") + } + // The privileged group alone, replacing the ordinary one, is refused too. + assertRefused(put(env, http.StatusForbidden, limited, target, map[string]any{"groups": []uint{adminGroup.ID}})) + pivot(env, target, ordinary) + + // Removing it from a member is refused: by leaving it out, and by + // clearing the field. + member := env.seedUser(t, "member", nil) + env.join(t, member, adminGroup, ordinary) + for _, groups := range [][]uint{{ordinary.ID}, {}} { + assertRefused(put(env, http.StatusForbidden, limited, member, map[string]any{"name": "Demoted", "groups": groups})) + pivot(env, member, adminGroup, ordinary) + unchanged(member) + if !isAdmin(env, member.ID) { + t.Fatal("a refused save removed a site admin") + } + } + // The member's privileged label is flagged locked for the limited admin + // and not for the manager. + if ids, locked := env.recordGroups(t, limited, member.ID); !reflect.DeepEqual(ids, sortedIDs(adminGroup, ordinary)) || !locked[adminGroup.ID] || locked[ordinary.ID] { + t.Fatalf("member groups for the limited admin = %v locked %v", ids, locked) + } + if _, locked := env.recordGroups(t, manager, member.ID); locked[adminGroup.ID] || locked[ordinary.ID] { + t.Fatalf("member groups for the manager are locked: %v", locked) + } + + // Creating a user who is already in the privileged group is refused and + // leaves no user row. + const password = "privileged-password-1" + create := func(token, email string, groups []uint) *httptest.ResponseRecorder { + t.Helper() + return env.call(t, http.MethodPost, adminAPI(adminUsersPath), token, map[string]any{ + "name": "Created", "email": email, "password": password, "password_confirmation": password, "send_invite": false, + "groups": groups, + }) + } + refusedEmail := "born-admin-" + stamp + "@example.test" + rec := create(limited, refusedEmail, []uint{adminGroup.ID, ordinary.ID}) + if rec.Code != http.StatusForbidden { + t.Fatalf("create with a privileged group = %d %s", rec.Code, rec.Body.String()) + } + assertRefused(rec) + var born int64 + if err := env.gdb.Raw(`SELECT COUNT(*) FROM users WHERE email = ?`, refusedEmail).Scan(&born).Error; err != nil || born != 0 { + t.Fatalf("a refused create left %d users (%v)", born, err) + } + + // Ordinary changes need only the users permission, also on a member of + // the privileged group as long as that membership is sent back as it is; + // a group without a code and a case variant are ordinary. + put(env, http.StatusOK, limited, member, map[string]any{"groups": []uint{adminGroup.ID, uncoded.ID, upper.ID}}) + pivot(env, member, adminGroup, uncoded, upper) + put(env, http.StatusOK, limited, target, map[string]any{"groups": []uint{uncoded.ID}}) + pivot(env, target, uncoded) + put(env, http.StatusOK, limited, target, map[string]any{"groups": []uint{}}) + pivot(env, target) + if isAdmin(env, target.ID) || !isAdmin(env, member.ID) { + t.Fatal("ordinary changes moved the site-admin membership") + } + + // With the permission every refused request succeeds. + put(env, http.StatusOK, manager, target, map[string]any{"groups": []uint{ordinary.ID, adminGroup.ID}}) + pivot(env, target, ordinary, adminGroup) + if !isAdmin(env, target.ID) { + t.Fatal("the manager could not add the privileged group") + } + put(env, http.StatusOK, manager, target, map[string]any{"groups": []uint{ordinary.ID}}) + pivot(env, target, ordinary) + put(env, http.StatusOK, manager, member, map[string]any{"groups": []uint{}}) + pivot(env, member) + if isAdmin(env, target.ID) || isAdmin(env, member.ID) { + t.Fatal("the manager could not remove the privileged group") + } + rec = create(manager, "born-admin-ok-"+stamp+"@example.test", []uint{adminGroup.ID}) + if rec.Code != http.StatusCreated { + t.Fatalf("manager create with a privileged group = %d %s", rec.Code, rec.Body.String()) + } + if bornID := createdID(t, rec); !isAdmin(env, bornID) { + t.Fatal("the manager's create did not write the privileged membership") + } + + // D-30: the takeover guard follows the membership this field writes. No + // row is inserted into users_groups directly here. + guarded := env.seedUser(t, "guarded", nil) + newEmail := "guarded-new-" + stamp + "@example.test" + put(env, http.StatusOK, manager, guarded, map[string]any{"groups": []uint{adminGroup.ID}}) + rec = env.expect(t, http.StatusForbidden, http.MethodPut, userPath(guarded.ID, ""), limited, map[string]any{"name": "kept", "email": newEmail}) + if answer := adminDecode(t, rec); answer.Error.Message != privilegedMemberForbidden || len(answer.Error.Details["email"]) != 1 { + t.Fatalf("takeover refusal = %s", rec.Body.String()) + } + if got := env.reload(t, guarded.ID); got.Email != guarded.Email { + t.Fatalf("a refused takeover changed the email to %q", got.Email) + } + put(env, http.StatusOK, manager, guarded, map[string]any{"groups": []uint{}}) + env.expect(t, http.StatusOK, http.MethodPut, userPath(guarded.ID, ""), limited, map[string]any{"name": "kept", "email": newEmail}) + if got := env.reload(t, guarded.ID); got.Email != newEmail { + t.Fatalf("email after the membership was removed = %q", got.Email) + } + + // The privileged list is config, read per request. With the ordinary + // group's code in it, that group is the locked one and admin is not. + moved := newAdminEnv(t, func(cfg *compass.Config) { + if err := cfg.Set("golem15.user.privileged_groups", []string{ordinaryCode}); err != nil { + t.Fatal(err) + } + }) + movedLimited := moved.admin(t, permAccessUsers) + if options := moved.groupOptions(t, movedLimited, stamp); len(options) != 4 || options[adminGroup.ID] || !options[ordinary.ID] || options[uncoded.ID] { + t.Fatalf("options under the moved list = %v", options) + } + put(moved, http.StatusOK, movedLimited, member, map[string]any{"groups": []uint{adminGroup.ID}}) + pivot(moved, member, adminGroup) + assertRefused(put(moved, http.StatusForbidden, movedLimited, member, map[string]any{"groups": []uint{adminGroup.ID, ordinary.ID}})) + pivot(moved, member, adminGroup) + // target is in the ordinary group, which is now the privileged one. + assertRefused(put(moved, http.StatusForbidden, movedLimited, target, map[string]any{"groups": []uint{}})) + pivot(moved, target, ordinary) + + // An empty list means no group is privileged: nothing is locked. + open := newAdminEnv(t, func(cfg *compass.Config) { + if err := cfg.Set("golem15.user.privileged_groups", []string{}); err != nil { + t.Fatal(err) + } + }) + openLimited := open.admin(t, permAccessUsers) + for id, locked := range open.groupOptions(t, openLimited, stamp) { + if locked { + t.Fatalf("group %s is locked although no code is privileged", strconv.FormatUint(uint64(id), 10)) + } + } +} diff --git a/admin_users_test.go b/admin_users_test.go index 5b337c4..effb881 100644 --- a/admin_users_test.go +++ b/admin_users_test.go @@ -1418,9 +1418,9 @@ func TestAdminUserFormFields(t *testing.T) { }; !reflect.DeepEqual(offered, want) { t.Fatalf("permission options = %v, want %v", offered, want) } - // The form has no login-name, block-mail, guest or groups field (D-03, - // D-18, D-21; groups arrive with their guard). - if want := []string{"name", "surname", "email", "send_invite", "password", "password_confirmation", "organisation", "avatar", "created_ip_address", "last_ip_address", "permissions"}; !reflect.DeepEqual(names, want) { + // The form has no login-name, block-mail or guest field (D-03, D-18, + // D-21). + if want := []string{"name", "surname", "email", "send_invite", "password", "password_confirmation", "groups", "organisation", "avatar", "created_ip_address", "last_ip_address", "permissions"}; !reflect.DeepEqual(names, want) { t.Fatalf("form fields = %v, want %v", names, want) } diff --git a/controllers/admin_registry.go b/controllers/admin_registry.go index 640d2fc..75fbfcf 100644 --- a/controllers/admin_registry.go +++ b/controllers/admin_registry.go @@ -34,5 +34,6 @@ func init() { var _ pact.FormBeforeCreate = usersAdminController{} var _ pact.FormAfterCreate = usersAdminController{} var _ cabana.FieldRelationProvider = usersAdminController{} + var _ cabana.RelationLockProvider = usersAdminController{} var _ cabana.PermissionEditorProvider = usersAdminController{} } diff --git a/controllers/users_admin_controller.go b/controllers/users_admin_controller.go index 0839b01..9258825 100644 --- a/controllers/users_admin_controller.go +++ b/controllers/users_admin_controller.go @@ -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" diff --git a/lang/en/lang.yaml b/lang/en/lang.yaml index 4510609..52ff2b6 100644 --- a/lang/en/lang.yaml +++ b/lang/en/lang.yaml @@ -53,6 +53,7 @@ users: unsuspend_success: User has been unsuspended. privileged_member_forbidden: This user belongs to a privileged group. You do not have permission to change their email or password. Nothing was saved. privileged_member_delete_forbidden: Users who belong to a privileged group cannot be deleted without the permission to manage privileged groups. Nothing was deleted. + privileged_group_forbidden: You do not have permission to change membership of privileged groups. Nothing was saved. user: label: User id: ID @@ -72,6 +73,8 @@ user: confirm_password: Password Confirmation confirm_password_comment: Enter the password again to confirm it. avatar: Avatar + groups: Groups + empty_groups: There are no user groups available. permissions_tab: Permissions group: label: Group diff --git a/lang/pl/lang.yaml b/lang/pl/lang.yaml index 7b1c801..7fd5248 100644 --- a/lang/pl/lang.yaml +++ b/lang/pl/lang.yaml @@ -53,6 +53,7 @@ users: unsuspend_success: Udało się odblokować użytkownika. privileged_member_forbidden: Ten użytkownik należy do grupy uprzywilejowanej. Nie masz uprawnień do zmiany jego adresu e-mail ani hasła. Nic nie zostało zapisane. privileged_member_delete_forbidden: Użytkowników należących do grupy uprzywilejowanej nie można usunąć bez uprawnienia do zarządzania grupami uprzywilejowanymi. Nic nie zostało usunięte. + privileged_group_forbidden: Nie masz uprawnień do zmiany członkostwa w grupach uprzywilejowanych. Nic nie zostało zapisane. user: label: Użytkownik id: ID @@ -72,6 +73,8 @@ user: confirm_password: Potwierdzenie hasła confirm_password_comment: Wprowadź ponownie hasło, aby je potwierdzić. avatar: Avatar + groups: Grupy + empty_groups: Brak dostępnych grup użytkowników. permissions_tab: Uprawnienia group: label: Grupa diff --git a/models/user/fields.yaml b/models/user/fields.yaml index 5329660..946e160 100644 --- a/models/user/fields.yaml +++ b/models/user/fields.yaml @@ -35,6 +35,12 @@ fields: span: right tab: golem15.user::lang.user.account context: [create, update] + groups: + label: golem15.user::lang.user.groups + type: relation + span: full + emptyOption: golem15.user::lang.user.empty_groups + tab: golem15.user::lang.user.account organisation: label: golem15.user::lang.organisation.label type: relation diff --git a/models/users_group.go b/models/users_group.go new file mode 100644 index 0000000..dfc0c49 --- /dev/null +++ b/models/users_group.go @@ -0,0 +1,17 @@ +package models + +// UsersGroup is one row of users_groups, the pivot behind User.Groups: the +// user is a member of the group. The table has exactly these two columns and +// a composite primary key over them. The admin user form's groups field is +// its only writer, apart from the cleanup of a permanently deleted user or +// group. +type UsersGroup struct { + UserID uint `gorm:"column:user_id;primaryKey"` + UserGroupID uint `gorm:"column:user_group_id;primaryKey"` +} + +func (UsersGroup) TableName() string { return "users_groups" } + +func init() { + Register(UsersGroup{}) +}