88 KiB
phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, estimate, must_haves
| phase | plan | type | wave | depends_on | files_modified | autonomous | requirements | estimate | must_haves | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 12.1-user-plugin-admin-screens | 03 | execute | 3 |
|
|
true |
|
|
|
Phase Goal
ROADMAP Phase 12.1 goal (verbatim; not in user-story form, no story invented): Backend admins manage frontend users, user groups and organisations in the admin SPA without SQL, so the PHP backend is not needed for user administration after cutover. The Users, User Groups and Organisations screens of the PHP user plugin are ported to golem15.user, driven by its fields.yaml/columns.yaml.
This plan's slice: after it, a backend admin with the users permission opens Users from the navigation, filters and searches the list, sees who is deactivated, banned or not activated, opens a user's preview, activates, bans, unbans, unsuspends, deactivates, restores and permanently deletes users, creates a user with a password and an invitation, and edits name, email, organisation, avatar and frontend permissions.
Give `golem15.user` (repo `sm-user-plugin`) its admin foundation (three additive migrations, five permissions, navigation, the embedded admin YAML, a test harness) and port the PHP Users screen onto the framework contracts of v0.1.3: list with filters and row state, preview with status hint and record actions, form with password, invitation, organisation picker, avatar and the frontend permission editor, the delete semantics of PHP `Users.php`, the merged-permission resolver and the `last_seen` write.Purpose: success criteria 1 and 3 for the Users screen; the groups field and its guard (success criterion 4) follow in plan 04 so users_groups never has an unguarded writer. The takeover guard of D-30 (a privileged-group member's email, password and permanent delete need golem15.users.manage_privileged_groups) lands here, in the commit that creates the Users controller, so no write path of this plan ever exists without it.
Output: the files listed in the frontmatter, committed inside the plugin checkout.
Repos: every code change of this plan is committed inside the plugin checkout with git -C ../fonoteka.go/plugins/golem15/user (repo sm-user-plugin). Never stage plugin files from the application repository, and do not bump the submodule pointer or push (plan 04 bumps the pointer in a local commit; the plugin is pushed once, at the end of plan 05, after the security review and only when the framework tag v0.1.3 is on origin). Planning docs are committed separately in summercms.go. Never add co-author tags. The PHP reference at /media/nvme/dev/golem15/fonoteka is read-only. The plugin is shared across projects: migrations are additive, existing routes, payloads and exported functions keep their behaviour.
Known transitional state: the application's schema parity test (TestSchemaMatchesPHPSnapshot) reports the new table golem15_user_frontend_permissions until plan 04 adds its allow-list entry in the application repository. This plan therefore verifies the plugin's own suite, the application's user API parity flows and its admin boot tests, not the schema parity test.
<execution_context>
@/.claude/gsd-core/workflows/execute-plan.md
@/.claude/gsd-core/templates/summary.md
</execution_context>
Artifacts this phase produces
(This plan's share.)
- Migrations (gormigrate ids, one file each):
202610040001_add_users_permissions(users.permissions TEXT NULL),202610040002_add_users_last_seen(users.last_seen TIMESTAMPTZ NULL),202610040003_create_frontend_permissions(tablegolem15_user_frontend_permissions: id, code unique, label, tab, comment, created_at, updated_at). - Models:
models.UserfieldsPermissions PermissionSet,LastSeen *time.Time, read-onlyCreatedAt,UpdatedAt(all json "-"), methodsAttachRelations(),FilterScopes(),FilterScope();models.PermissionSet;models.FrontendPermission. - Classes:
ActivateUsers,DeactivateUsers,RestoreUsers,BanUsers,UnbanUsers,UnsuspendUser,ThrottleStates,ForceDeleteCleanup,MergedPermissions,HasPermission,UserHasPermission,FrontendPermissionOptions,TouchLastSeen; inclasses/privileged.gothe constantPermissionManagePrivilegedGroupsandPrivilegedGroupCodes,IsPrivilegedCode,PrivilegedGroupIDs,IsPrivilegedMember(plan 04 reuses them). - Config key
golem15.user.privileged_groups(list of group codes, default[admin]). - Admin: controller id
golem15.user.users(model nameGolem15\User\Models\User, config dircontrollers/users);controllers.AdminControllers; plugin methodsAdminFS,AdminControllers,Permissions,Navigation; permission codesgolem15.users.access_users,golem15.users.access_groups,golem15.users.access_settings,golem15.users.impersonate_user,golem15.users.manage_privileged_groups; navigation codeuserwith side itemusers. - YAML:
controllers/users/config_list.yaml,config_form.yaml,config_filter.yaml,_hint.htm;models/user/fields.yaml,columns.yaml. - Bulk actions
activate,deactivate,restore,ban,unban; record actionsactivate,unban,unsuspend. - Mail templates
golem15.user::mail.invite,golem15.user::mail.invite-en. - Lang keys under
golem15.user::lang.plugin.*,users.*,user.*, plusorganisation.labelandorganisation.empty_organisation(en and pl); among them the two D-30 messagesusers.privileged_member_forbiddenandusers.privileged_member_delete_forbidden. - Tests:
admin_harness_test.go,TestAdminUsersTracer,TestAdminPrivilegedMember,TestAdminUserActions,TestAdminUserForceDelete,TestAdminUserPassword,TestAdminUserInvite,TestAdminAvatarSharedWithAPI,TestMergedPermissions,TestPermissionSetScan,TestLastSeen, migration tests inupdates/admin_columns_test.go.
Planner decisions recorded for this plan
- Privileged-groups permission (D-04 discretion). Code
golem15.users.manage_privileged_groups, tabgolem15.user::lang.plugin.tab, labelgolem15.user::lang.plugin.manage_privileged_groups. It is registered here and has no default role, so only a superuser or a role that was granted it explicitly holds it; the four PHP codes default to thedeveloperrole like the application's own permissions. This plan enforces it for the takeover paths of D-30; plan 04 enforces it for group membership and group codes. - Takeover guard (D-30) and its ordering. D-30 was decided after the plan check. The config key
golem15.user.privileged_groups(D-05 discretion) andclasses/privileged.gomove from plan 04 into Task 1 of this plan, because Task 1's commit is the first that can write a user's email. The whole guard (email, password, delete) is written in that commit: the password half reads the form-only value, which does not exist until Task 3 declares the field, and the delete half sits inFormBeforeDelete, which guards the permanent delete from the moment Task 2 adds it. Plan 04 reuses the helpers unchanged. - What D-30 covers. Exactly the three operations D-30 names: email, password, permanent delete (form button and bulk delete). Everything else on a privileged member (name, surname, organisation, avatar, frontend permissions; activate, deactivate, restore, ban, unban, unsuspend) still needs only
golem15.users.access_users, as in PHP (D-14): none of it hands the member's credentials to the acting admin, and each can be undone by another admin. The security review of plan 05 records this boundary next to the accepted risk T-12.1-26. - Presentation of a refusal (D-30). The email and password inputs are not shown locked: the framework at v0.1.3 has no per-record read-only state for text and password fields, and D-27 fixes the seam list. The refusal uses the forbidden presentation of UI-SPEC S6: the banner with marked fields on save, a danger toast on delete. The two message keys were added after the UI-SPEC sign-off; their texts are in Task 1 and follow the wording of
users.privileged_group_forbidden. - Navigation in two steps. A navigation entry must point at a registered controller, so this plan registers the main item
userwith the side itemusersonly; plan 04 addsusergroupsandorganisationstogether with their controllers. - Ban semantics on Go throttle rows. Ban sets
is_bannedon everyuser_throttlerow of the user and makes sure a row with a NULL IP exists and is banned, so a login from any address is refused; unban clears the flag on every row; a user is banned when any row is banned; unsuspend clearsis_suspended,suspended_atandattemptson every row; suspended is a pure read (a row withis_suspendedandsuspended_atinsidegolem15.user.throttle.suspension_minutes). - Activate. Sets
is_activated, stampsactivated_at, clears the activation code columns and restores the user when deactivated (as the GoVerifyActivationCodedoes); users already active are skipped. - Bulk success copy. A bulk action returns its fixed success phrase only when every selected user was affected; otherwise it returns no message and the affected count, so the framework's count toast is shown and never overstates (UI-SPEC checker note; D-29).
- Preview content. No scoreboard (UI-SPEC: optional, left out).
- Password stamp. An admin password reset stamps
tokens_valid_after, as the user's own change-password path does, so existing tokens stop working. - Spec-less probe fallback: skipped (no requirement IDs, no SPEC.md); no probe predicates were generated. Edge cases come from RESEARCH "Common Pitfalls" 1 to 6, 11 and 12 and the UI-SPEC rows lifted into
must_haves.
(1) Migrations, one new file each, in the style of 202609220005_extend_users.go and 202610020001_create_user_groups.go (package-level slice, self-registration in init, rollback with IF EXISTS, a header comment naming the PHP source): id 202610040001_add_users_permissions adds users.permissions TEXT NULL; id 202610040002_add_users_last_seen adds users.last_seen TIMESTAMPTZ NULL; id 202610040003_create_frontend_permissions creates golem15_user_frontend_permissions with id SERIAL PRIMARY KEY, code VARCHAR(255) NOT NULL with a unique index, label VARCHAR(255) NOT NULL, tab VARCHAR(255) NOT NULL, comment VARCHAR(255) NULL, created_at and updated_at TIMESTAMPTZ NULL (PHP updates/v2.8.0/create_frontend_permissions_table.php). No shipped migration file is edited. updates/admin_columns_test.go: each migration applies and rolls back on a real Postgres through the existing harness.
(2) models/user.go: add LastSeen *time.Time (column last_seen), CreatedAt time.Time and UpdatedAt time.Time as read-only GORM fields (the arrow-only permission, so GORM never writes or stamps them) on columns created_at and updated_at; all three carry json "-". Add FilterScopes() returning the single name filterByGroup and FilterScope(name, db, value) narrowing users to members of the group id in value through a subquery on users_groups (an unknown name returns a query that matches nothing). Do not change Fillable(), Rules(), Hidden() or any existing field.
(3) Registration. admin_permissions.go: Permissions() with the five codes of the Artifacts section, tab constant golem15.user::lang.plugin.tab, labels golem15.user::lang.plugin.access_users, access_groups, access_settings, impersonate_user and manage_privileged_groups; the four PHP codes carry the default role developer; the privileged-groups code carries no role. admin_navigation.go: Navigation() with one item: code user, label golem15.user::lang.users.menu_label, icon user, permissions golem15.users.* (wildcard), order 555, controller golem15.user.users, side menu item users (label golem15.user::lang.users.menu_label, icon user, permission golem15.users.access_users, controller golem15.user.users). admin.go: an embed.FS with an explicit //go:embed list of every admin YAML and partial file (the controllers directory also holds a non-admin file, so no directory embed), AdminFS(), AdminControllers() delegating to controllers.AdminControllers with a closure that returns the plugin's app lazily, and the four compile-time assertions (pact.AdminAssets, pact.HasAdminControllers, pact.HasPermissions, pact.HasNavigation). controllers/admin_registry.go: AdminControllers(app func() *backpack.App) []pact.AdminController returning the users controller, plus the compile-time assertions of every optional interface the controller implements. controllers/request_db.go: the requestDB helper copied from the application analog (transaction from cabana.TxFromContext, else the pool).
(4) controllers/users_admin_controller.go: type usersAdminController holding the lazy app accessor; ID golem15.user.users; ModelName the PHP class string of the User model; ConfigDir controllers/users; RequiredPermissions golem15.users.access_users; NewRecord a new models.User. ListExtendQuery and FormExtendQuery both return the unscoped query (D-13 withTrashed). ListRowStates (pact.ListRowStates): one call of classes.ThrottleStates for the page's user ids, then per row RowStateDeleted when DeletedAt is set, RowStateNegative when banned, RowStateDisabled when not activated. FormRules (pact.FormRules): for create and update the email rule "required|between:6,255|email|unique:users" (password rules join in Task 3); the model's own Rules() stays the register contract. FilterOptions (pact.FilterOptions, on the controller): for scope filterByGroup the rows of user_groups ordered by name as value (the id as text) and label (the name); any other scope returns nothing. classes/admin_actions.go starts with ThrottleStates(ctx, db, userIDs) returning the banned and the suspended id sets per the ban semantics recorded above (a pure read).
(4a) Takeover guard (D-30, with the list of D-05). config/config.yaml: add the top-level key privileged_groups with the single default value admin (config path golem15.user.privileged_groups). classes/privileged.go, free functions in the style of classes/user_groups.go: constant PermissionManagePrivilegedGroups with the value golem15.users.manage_privileged_groups; PrivilegedGroupCodes(cfg) reading the list from the config on every call (a missing key or a nil config gives the single code admin; blank entries are dropped); IsPrivilegedCode(codes, code) taking the group's nullable code, comparing case-sensitively and returning false for a missing code; PrivilegedGroupIDs(ctx, db, codes) returning the ids of user_groups rows whose code is in the list; IsPrivilegedMember(ctx, db, codes, userID) reporting whether the user belongs to at least one group whose code is in the list, built on the existing classes.UserGroupCodes (a user in no group and a group without a code give false). Nothing is cached at boot. In the controller, one private helper serves both hooks: it answers nil when the principal on the context passes cabana.Allows for PermissionManagePrivilegedGroups, or when classes.IsPrivilegedMember (asked through requestDB, so inside the write transaction) is false; otherwise it answers a cabana.ForbiddenError with the message key and details it was given. A failed query is returned as it is and becomes the opaque 500. FormBeforeUpdate (pact.FormBeforeUpdate) begins with the guard, ahead of everything later tasks add to the hook: it reads the stored email of the user by id through requestDB with deactivated users included and compares it with the email on the model the hook receives, which is the value the save would write; it reads a submitted password from cabana.VirtualFieldsFromContext under the name password (no such form field exists until Task 3 declares it, so this half does nothing until then and guards the password from the commit that adds the field); when the email differs or a non-empty password was submitted it asks the helper with the message golem15.user::lang.users.privileged_member_forbidden and details that name email, password or both, each with a one-element list holding that message. FormBeforeDelete (pact.FormBeforeDelete) asks the helper with the message golem15.user::lang.users.privileged_member_delete_forbidden and no details; the framework calls this hook for the form's delete and once per record inside the single transaction of the bulk delete, so one refused record rolls the whole selection back. An update that changes neither value, a user who is not a privileged member and an admin holding the permission pass untouched; create is not guarded (a new user has no membership). Add the assertion lines for pact.FormBeforeUpdate and pact.FormBeforeDelete in admin_registry.go.
(5) YAML in the Go dialect (never copy PHP YAML verbatim: tabs, secondaryTabs, cssClass, hidden, disabled, valueFrom, a scalar toolbar.buttons and conditions are boot errors). controllers/users/config_list.yaml: list pointing at models/user/columns.yaml, the modelClass, title golem15.user::lang.users.list_title, recordUrl golem15/user/users/update/:id (Task 2 switches it to preview), noRecordsMessage, recordsPerPage 20, showCheckboxes true, showSetup true, filter config_filter.yaml, toolbar with an empty buttons list and the search prompt, and a messages block with empty golem15.user::lang.users.list_empty, rowStateDeleted golem15.user::lang.users.state_deactivated, rowStateNegative golem15.user::lang.users.state_banned, rowStateDisabled golem15.user::lang.users.state_not_activated. models/user/columns.yaml: id (invisible), name (searchable), surname (searchable, invisible), email (searchable), created_at (type datetime, label golem15.user::lang.user.created_at), last_seen (type datetime), created_ip_address and last_ip_address (searchable, invisible); no username and no is_guest column. controllers/users/config_filter.yaml: groups (label golem15.user::lang.group.label, modelClass of the UserGroup PHP class string, nameFrom name, scope filterByGroup), created_date (type daterange, column created_at), activated (type switch, column is_activated). controllers/users/config_form.yaml: name golem15.user::lang.user.label, form pointing at models/user/fields.yaml, the modelClass, defaultRedirect golem15/user/users, update redirect golem15/user/users/update/:id and redirectClose golem15/user/users, a messages block (update, saved, deleteConfirm golem15.user::lang.users.delete_confirm, deleted). models/user/fields.yaml: name, surname and email as type text under the tab golem15.user::lang.user.account (name and surname move into the Account tab; email span full).
(6) lang/en/lang.yaml and lang/pl/lang.yaml: add the keys this task names under plugin (tab, access_users, access_groups, access_settings, impersonate_user, manage_privileged_groups), users (menu_label, list_title, list_empty, state_deactivated, state_banned, state_not_activated, delete_confirm), user (label, id, name, surname, email, account, created_at, last_seen, created_ip_address, last_ip_address, status_activated) and group (label), with the PHP values from lang/en/lang.php and lang/pl/lang.php and the UI-SPEC texts for keys marked new or changed there. Keep the existing account keys. Two more keys under users for D-30 (new, decided after the UI-SPEC): privileged_member_forbidden with the text "This user belongs to a privileged group. You do not have permission to change their email or password. Nothing was saved." and in Polish "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 with the text "Users who belong to a privileged group cannot be deleted without the permission to manage privileged groups. Nothing was deleted." and in Polish "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."
(7) plugin.go: no behaviour change; the new capabilities live in admin.go, admin_permissions.go and admin_navigation.go.
(8) Test harness admin_harness_test.go (package user): boot the plugin with cabana mounted on a real Postgres (the application's assembleTracer shows the recipe: application object, lagoon.Publish, party.Activate of golem15.user alone, lagoon.Migrate, surf.Assemble), helpers to insert a backend role with a chosen permission JSON and a backend user in it, log in and return the token, plus adminAPI path building and JSON request helpers. admin_users_test.go TestAdminUsersTracer: the navigation lists the item user for an admin holding golem15.users.access_users and not for an admin with no permission; that admin gets 403 on the Users list; the list returns active and deactivated users with meta.row_states (deleted for a deactivated user, negative for a banned one, disabled for a not activated one); a search by an invisible column (surname) finds the user and the row has no surname key; the groups filter narrows to a group's members and its options route lists the groups; the daterange and the activated filter work; an update of name and email answers 200 and persists; an update of a deactivated user answers 200 and keeps deleted_at; a duplicate email answers 422; no response body contains a password key. TestAdminPrivilegedMember (D-30) in the same file, with a group whose code is admin and an ordinary group created by the test and memberships inserted directly into users_groups (the groups field does not exist until plan 04): as an admin holding only golem15.users.access_users, an update that changes a privileged member's email answers 403 with the text of users.privileged_member_forbidden and details on email, and the stored email and name are unchanged although the same body also changed the name; an update of the same user that sends the stored email unchanged together with a new name answers 200 and the name persists; the form delete of that user answers 403 with the text of users.privileged_member_delete_forbidden and the row is still there with deleted_at unchanged; a member of the ordinary group only gets 200 for the same email change; a deactivated privileged member is protected the same way; with the config key overridden to the ordinary group's code the protection moves to that group's member; as an admin who also holds golem15.users.manage_privileged_groups the email change answers 200 and the delete succeeds.
go -C ../fonoteka.go vet ./plugins/golem15/user/... && go -C ../fonoteka.go test ./plugins/golem15/user/updates -run '^(TestAdminColumns|TestUserGroups)' -count=1 -v && go -C ../fonoteka.go test ./plugins/golem15/user -run '^(TestAdminUsersTracer|TestAdminPrivilegedMember)$' -count=1 -v && go -C ../fonoteka.go test ./plugins/golem15/user/... -count=1 && go -C ../fonoteka.go test ./plugins/golem15/fonoteka -run '^(TestAdmin|TestPhase09|TestPhase10|TestPhase12Threats)' -count=1 && go -C ../fonoteka.go test ./parity -run '^TestUserAPINuxtFlows$' -count=1
<fails_when>Any command exits non-zero; the tracer run lacks "--- PASS: TestAdminUsersTracer" or "--- PASS: TestAdminPrivilegedMember"; the updates run lacks a "--- PASS: TestAdminColumns" line; any run prints "no tests to run" or a line starting with "FAIL".</fails_when>
<acceptance_criteria>
- go -C ../fonoteka.go test ./plugins/golem15/user -run '^TestAdminUsersTracer$' -count=1 -v prints "--- PASS: TestAdminUsersTracer"; the test asserts navigation gating, the three row states, the groups filter and its options, an update of a deactivated user, and a 422 on a duplicate email.
- git -C ../fonoteka.go/plugins/golem15/user ls-files updates | grep -c '2026100400' prints 3.
- git -C ../fonoteka.go/plugins/golem15/user diff --stat 8a65890 -- updates/00_base.go updates/10_organisations.go updates/202609220005_extend_users.go updates/202609220006_create_user_throttle.go updates/202609220007_create_jwt_blacklist.go updates/202610020001_create_user_groups.go prints nothing (shipped migrations are untouched).
- grep -c 'golem15.users.manage_privileged_groups' ../fonoteka.go/plugins/golem15/user/admin_permissions.go prints 1 and grep -c 'impersonate_user' ../fonoteka.go/plugins/golem15/user/admin_permissions.go prints at least 1.
- TestAdminUsersTracer asserts, on the list schema served by the admin API, that the column keys are exactly id, name, surname, email, created_at, last_seen, created_ip_address and last_ip_address (so neither the dropped login-name column of D-18 nor the dropped guest column of D-03 exists) and that the filters are exactly groups, created_date and activated.
- go -C ../fonoteka.go test ./parity -run '^TestUserAPINuxtFlows$' -count=1 passes (the user API payloads are unchanged).
- git -C ../fonoteka.go/plugins/golem15/user diff --stat 8a65890 -- go.mod shows no added require line for a new module.
- go -C ../fonoteka.go test ./plugins/golem15/user -run '^TestAdminPrivilegedMember$' -count=1 -v prints "--- PASS: TestAdminPrivilegedMember"; the test asserts, for an admin without golem15.users.manage_privileged_groups, 403 with an unchanged row for an email change and for the form delete of a privileged member, 200 for a name-only update of that member, and success for both refused requests once the admin holds the permission (D-30).
- git -C ../fonoteka.go/plugins/golem15/user log --diff-filter=A --format=%H -- controllers/users_admin_controller.go and git -C ../fonoteka.go/plugins/golem15/user log --diff-filter=A --format=%H -- classes/privileged.go print the same commit sha, and the controller file as of that commit contains the guard: git -C ../fonoteka.go/plugins/golem15/user grep -q 'IsPrivilegedMember' "$(git -C ../fonoteka.go/plugins/golem15/user log --diff-filter=A --format=%H -- classes/privileged.go)" -- controllers/users_admin_controller.go exits 0.
- grep -c 'privileged_groups' ../fonoteka.go/plugins/golem15/user/config/config.yaml prints 1 and grep -c 'privileged_member_delete_forbidden' ../fonoteka.go/plugins/golem15/user/lang/pl/lang.yaml prints 1.
</acceptance_criteria>
The user plugin is an admin plugin: it registers permissions and navigation, its Users list with filters, search and row states works for an admin with the users permission, and a user's name and email can be edited; the email and the delete of a privileged-group member are refused without the privileged-groups permission.
(1) classes/admin_actions.go, free functions taking the context and the transaction handle (never opening a second transaction): ActivateUsers(ctx, db, users) returning the number activated (skips users already active; sets is_activated, stamps activated_at, clears activation_code and activation_code_issued_at, clears deleted_at when set); DeactivateUsers (sets deleted_at on users that are not deactivated; returns the number changed); RestoreUsers (clears deleted_at on deactivated users); BanUsers and UnbanUsers per the ban semantics recorded above; UnsuspendUser(ctx, db, userID) (clears is_suspended, suspended_at and attempts on every row of the user); ForceDeleteCleanup(ctx, db, bucket, user) which deletes the user's user_throttle rows and users_groups rows, removes the user's attachments with attach.DeleteForOwner collecting the blob keys, and registers the blob deletion with lagoon.AfterCommit so blobs go only after the commit. None of these functions writes users_groups except the cleanup of a user being permanently deleted.
(2) Controller. AdminBulkActions (pact.HasAdminBulkActions): activate, deactivate, restore, ban and unban, each with label golem15.user::lang.users.NAME_selected, confirm golem15.user::lang.users.NAME_selected_confirm, permission golem15.users.access_users, and a Run that asserts every record is a user, calls the class function with the transaction from cabana.TxFromContext, returns the affected count, and returns the message golem15.user::lang.users.NAME_selected_success only when the affected count equals the number of records (otherwise an empty message). AdminRecordActions (pact.HasAdminRecordActions): activate (label golem15.user::lang.users.activate_user, confirm users.activate_confirm, success users.activated_success, applies when the user is not activated), unban (label users.unban_user, confirm users.unban_confirm, success users.unbanned_success, applies when banned) and unsuspend (label users.unsuspend_user, confirm users.unsuspend_confirm, success users.unsuspend_success, applies when suspended), each with permission golem15.users.access_users and an Applies that is a pure read. FormAfterDelete (pact.FormAfterDelete): call ForceDeleteCleanup and then delete the user row unscoped inside the same transaction, so the form delete button and the bulk delete are permanent (D-13). The FormBeforeDelete guard of Task 1 stays as it is and runs before this hook, so the permanent delete is guarded from the commit that introduces it (D-30): for an admin without golem15.users.manage_privileged_groups the delete of a privileged-group member is refused with 403, no cleanup runs and no blob deletion is registered, and a bulk delete whose selection contains such a user deletes none of the selected users. The bulk action deactivate is not a permanent delete and is not guarded. PartialData (pact.AdminPartialData) for the partial named hint: a curated view model with the callout kind (danger or warning) and the title and text phrase keys, chosen by precedence banned, then deactivated, then not activated, then suspended; an empty view model when none applies; an unknown partial name is an error. No impersonate action and no guest action exists.
(3) YAML. config_list.yaml: recordUrl becomes golem15/user/users/preview/:id; toolbar.buttons becomes [delete]; bulkActions [activate, deactivate, restore, ban, unban]; messages gain deleteConfirm golem15.user::lang.users.delete_selected_confirm and deleted. config_form.yaml: a preview block with headerPartial hint; recordActions [activate, unban, unsuspend]; messages edit golem15.user::lang.users.update_details; update.redirectClose golem15/user/users/preview/:id. models/user/fields.yaml gains created_ip_address and last_ip_address (type text, context preview, tab account, without the PHP disabled key). New controllers/users/_hint.htm: one callout using the summer-callout classes and role status, rendering nothing for an empty view model; add it and every changed YAML file to the embed list in admin.go.
(4) lang (en and pl), with the PHP values and the UI-SPEC texts for new and changed keys: users.activate_selected, deactivate_selected, restore_selected, ban_selected, unban_selected and their _confirm and _success keys; users.delete_selected_confirm; users.update_details; users.banned_hint_title and banned_hint_desc; users.trashed_hint_title and trashed_hint_desc (the PHP values copied verbatim); users.activate_warning_title and activate_warning_desc; users.suspended_hint_title and suspended_hint_desc; users.activate_user, activate_confirm, activated_success; users.unban_user, unban_confirm, unbanned_success; users.unsuspend_user, unsuspend_confirm, unsuspend_success.
(5) Tests in admin_users_test.go. TestAdminUserActions: bulk activate on two inactive and one active user answers affected 2 with no message and all three are active; bulk deactivate sets deleted_at and the users are still listed with the deleted state; bulk restore clears it; bulk ban makes a login attempt of that user fail and the row state negative; bulk unban allows login again; the record action activate is offered only for a not activated user and answers 409 on a second run; unban is offered only for a banned user; unsuspend is offered only for a user suspended inside the window and clears the suspension; the preview partial renders the banned callout before the deactivated one; the users_groups rows of every user are identical before and after each action. TestAdminUserForceDelete: the form delete and the bulk delete remove the user row, its user_throttle rows, its users_groups rows and its system_files rows for a user who has a throttle row, an ordinary group and an avatar, and the blob is deleted after the commit; an admin without golem15.users.access_users gets 403. Extend TestAdminPrivilegedMember (D-30) with the permanent delete: as an admin holding only golem15.users.access_users, the form delete of a privileged member who has a throttle row and an avatar answers 403 and the user row, its users_groups, user_throttle and system_files rows and its blob are all still there; a bulk delete of that member together with two ordinary users answers 403 with the text of users.privileged_member_delete_forbidden and all three users and their rows still exist; the bulk action deactivate on the member answers 200; as an admin who also holds golem15.users.manage_privileged_groups the form delete and the bulk delete remove the member with the full cleanup.
go -C ../fonoteka.go vet ./plugins/golem15/user/... && go -C ../fonoteka.go test ./plugins/golem15/user -run '^(TestAdminUserActions|TestAdminUserForceDelete|TestAdminUsersTracer|TestAdminPrivilegedMember)$' -count=1 -v && go -C ../fonoteka.go test ./plugins/golem15/user/... -count=1 && go -C ../fonoteka.go test ./plugins/golem15/fonoteka -run '^(TestAdmin|TestPhase09|TestPhase10|TestPhase12Threats)' -count=1
<fails_when>Any command exits non-zero; the named run lacks "--- PASS: TestAdminUserActions", "--- PASS: TestAdminUserForceDelete" or "--- PASS: TestAdminPrivilegedMember"; any run prints "no tests to run" or a line starting with "FAIL".</fails_when>
<acceptance_criteria>
- go -C ../fonoteka.go test ./plugins/golem15/user -run '^TestAdminUserActions$' -count=1 -v prints "--- PASS: TestAdminUserActions"; the test asserts the affected count of bulk activate with an already active user in the selection and that users_groups is unchanged after every action.
- go -C ../fonoteka.go test ./plugins/golem15/user -run '^TestAdminUserForceDelete$' -count=1 -v prints "--- PASS: TestAdminUserForceDelete"; the test deletes a user that has a user_throttle row (the foreign key without ON DELETE) and asserts no orphan row remains.
- grep -c 'preview/:id' ../fonoteka.go/plugins/golem15/user/controllers/users/config_list.yaml prints 1.
- TestAdminUserActions asserts that the list schema's bulk actions are exactly delete, activate, deactivate, restore, ban and unban and that the record actions the controller registers are exactly activate, unban and unsuspend (D-01 and D-03: nothing else is offered).
- grep -c 'summer-callout' ../fonoteka.go/plugins/golem15/user/controllers/users/_hint.htm prints at least 1.
- go -C ../fonoteka.go test ./plugins/golem15/user -run '^TestAdminPrivilegedMember$' -count=1 -v prints "--- PASS: TestAdminPrivilegedMember"; the test asserts that a bulk delete of one privileged member and two ordinary users by an admin without golem15.users.manage_privileged_groups answers 403 and leaves all three users, their throttle, pivot and attachment rows in place, and that the same bulk delete succeeds with the permission (D-30).
</acceptance_criteria>
The Users preview, the status hint, the three record actions, the five bulk actions and the permanent delete behave as PHP Users.php does, on the framework's scoped action routes.
(1) Permission data. models/permission_set.go: PermissionSet (a map of code to integer) with a tolerant Scan (NULL, an empty string, the texts [] and null give an empty set; a JSON object gives its entries, accepting integers and numeric strings and skipping entries whose value is not numeric; any other content is an error) and a Value that writes NULL for an empty set and otherwise a JSON object of integers without zero values. models/user.go: add Permissions PermissionSet on column permissions with json "-". models/frontend_permission.go: FrontendPermission (ID, Code, Label, Tab, Comment as a nullable string, CreatedAt and UpdatedAt as nullable times; table golem15_user_frontend_permissions; registered in init). Rows come from migrations or seeds, as in PHP; no plugin-declared registry.
(2) Resolver in classes/permissions.go, a line-by-line port of PHP: MergedPermissions(ctx, db, userID) reads the permissions of the user's groups through users_groups (scanned tolerantly) and keeps per code the highest value across groups (most positive wins), then overlays the user's own values (the user's value replaces the group's); HasPermission(merged, code) is true only when the matching value is exactly 1 and supports a trailing or leading asterisk wildcard on the asked code and on the stored code, as Winter's hasPermission does; UserHasPermission(ctx, db, userID, code) combines the two; FrontendPermissionOptions(ctx, db) returns the table's rows ordered by tab then code. Nothing in the application calls the resolver yet.
(3) Form. models/user/fields.yaml (tab account unless said otherwise): send_invite (type checkbox, default true, context create, label and comment from user.send_invite and user.send_invite_comment); password (type password, span left, context create and update, label user.password, comment user.password_comment); password_confirmation (type password, span right, context create and update, label user.confirm_password, comment user.confirm_password_comment); organisation (type relation, nameFrom name, span full, emptyOption golem15.user::lang.organisation.empty_organisation, label golem15.user::lang.organisation.label); avatar (type fileupload, mode image, imageWidth 260, imageHeight 260, label user.avatar); permissions (type permissioneditor, mode radio, span full, context update, tab golem15.user::lang.user.permissions_tab). No username, block_mail, groups (plan 04) or guest field. config_list.yaml toolbar.buttons becomes [create, delete] and messages gain create golem15.user::lang.users.new_user. config_form.yaml: create.redirect golem15/user/users/preview/:id, create.redirectClose golem15/user/users, messages create.
(4) Controller. FormVirtualFields: password, password_confirmation, send_invite. FormRules: on create the password rule is required, between the configured minimum length (golem15.user.password.min_length, default 8) and 255, confirmed; on update it is nullable with the same bounds and confirmed; the email rule stays. The takeover guard of Task 1 stays the first statement of FormBeforeUpdate and is not changed: now that password is a declared form-only field, a non-empty password submitted for a privileged-group member by an admin without golem15.users.manage_privileged_groups is refused with 403 and details on password before the uniqueness check, the hashing and the token stamp below run (D-30); everything this step adds to the hook goes after the guard. FormBeforeCreate and FormBeforeUpdate: refuse an email that another user, deactivated ones included, already has with a ValidationError on email (the unique rule ignores deactivated rows); when a password was submitted (read from cabana.VirtualFieldsFromContext) store its hash from bouncer.HashPassword at the configured cost (golem15.user.password.bcrypt_cost, default 10); on update a submitted password also stamps tokens_valid_after with the current time; an absent or empty password on update changes nothing. A user created in the admin keeps is_activated false (D-29). FormAfterCreate: when send_invite is true, issue an activation code with classes.IssueActivationCode inside the transaction and register with lagoon.AfterCommit the sending of exactly one mail from the template golem15.user::mail.invite (the locale variant chosen as the other user mails do) carrying the user's name and the activation link built by the existing activationLink; a create that rolls back sends nothing. AdminFieldRelations (cabana.FieldRelationProvider): organisation as belongsTo to models.Organisation with foreign key organisation_id, label column name and WritableForeignKey true. PermissionEditorProvider for the field permissions: options from FrontendPermissionOptions (code, label, tab, comment; none locked), values from the user's Permissions, and the setter assigning the validated map to it. models/user.go: AttachRelations() declaring the relation named avatar with the same public flag the user API uses when it stores an avatar.
(5) Mail. views/mail/invite.htm and invite-en.htm ported from the PHP invite template in the structure of the existing activate templates; plugin.go MailTemplates() gains golem15.user::mail.invite and golem15.user::mail.invite-en.
(6) lang (en and pl): users.new_user; user.password and user.password_comment (new, UI-SPEC texts); user.confirm_password and its comment; user.send_invite and its comment; user.avatar; user.permissions_tab (new); organisation.label and organisation.empty_organisation; the invite mail's subject key if the template uses one.
(7) Tests. classes/permissions_test.go: TestMergedPermissions as a table of cases (two groups with different values for one code keep the higher; a user value of -1 overrides a group's 1; a user value of 1 overrides a group's -1; a code held only at 0 or -1 is not held; trailing and leading wildcards on either side; a user with no groups) and TestPermissionSetScan (NULL, empty string, the text [], an object with numeric strings, an object with a non-numeric entry, malformed JSON; Value for an empty set and for a set with a zero). admin_users_test.go: TestAdminUserPassword (create with matching passwords answers 201, the stored hash verifies with bouncer.CheckPassword, the user is not activated, and no create, show, update or list response contains the password or a password key; a confirmation mismatch answers 422 on password; an update without a password keeps the hash and tokens_valid_after; an update with a new password changes the hash and stamps tokens_valid_after; a create with the email of a deactivated user answers 422 on email), TestAdminUserInvite (create with send_invite true sends exactly one invite mail whose link activates the user through the existing activation route; with send_invite false no mail; a create that fails validation sends none), TestAdminAvatarSharedWithAPI (an avatar uploaded through the admin file route and saved appears as avatar_url and has_avatar in the user API payload; an avatar uploaded through the user API appears in the admin file list of the avatar field), plus cases in an existing or new test for the organisation picker (a save sets and clears organisation_id) and the permission editor (the form schema offers the seeded permissions grouped by tab; saving allow and deny stores the JSON object; an unknown code answers 422; the user API payload still carries permissions as null). Extend TestAdminPrivilegedMember (D-30) with the password: as an admin holding only golem15.users.access_users, an update that submits a password and its confirmation for a privileged member answers 403 with the text of users.privileged_member_forbidden and details on password, the stored hash and tokens_valid_after are unchanged, a login with the old password still succeeds and a login with the attempted password fails; a body that changes the email and submits a password names both fields in the details; a body with an empty password and a new name answers 200; changing the avatar, the organisation and the frontend permissions of that member answers 200; as an admin who also holds golem15.users.manage_privileged_groups the password reset answers 200 and stamps tokens_valid_after.
go -C ../fonoteka.go vet ./plugins/golem15/user/... && go -C ../fonoteka.go test ./plugins/golem15/user/classes -run '^(TestMergedPermissions|TestPermissionSetScan)$' -count=1 -v && go -C ../fonoteka.go test ./plugins/golem15/user -run '^(TestAdminUserPassword|TestAdminUserInvite|TestAdminAvatarSharedWithAPI|TestAdminPrivilegedMember)$' -count=1 -v && go -C ../fonoteka.go test ./plugins/golem15/user/... -count=1 && go -C ../fonoteka.go test ./plugins/golem15/fonoteka -run '^(TestAdmin|TestPhase09|TestPhase10|TestPhase12Threats)' -count=1 && go -C ../fonoteka.go test ./parity -run '^TestUserAPINuxtFlows$' -count=1
<fails_when>Any command exits non-zero; a named run lacks any of "--- PASS: TestMergedPermissions", "--- PASS: TestPermissionSetScan", "--- PASS: TestAdminUserPassword", "--- PASS: TestAdminUserInvite", "--- PASS: TestAdminAvatarSharedWithAPI", "--- PASS: TestAdminPrivilegedMember"; any run prints "no tests to run" or a line starting with "FAIL".</fails_when>
<acceptance_criteria>
- go -C ../fonoteka.go test ./plugins/golem15/user/classes -run '^TestMergedPermissions$' -count=1 -v prints "--- PASS: TestMergedPermissions"; its table includes a case where a user-level 1 overrides a group-level -1 (the port is not "deny wins").
- go -C ../fonoteka.go test ./plugins/golem15/user -run '^TestAdminUserPassword$' -count=1 -v prints "--- PASS: TestAdminUserPassword"; the test asserts that an update changing only the name answers 200 (RESEARCH Pitfall 1) and that no response contains the password.
- go -C ../fonoteka.go test ./plugins/golem15/user -run '^TestAdminUserInvite$' -count=1 -v prints "--- PASS: TestAdminUserInvite"; the test asserts exactly one mail.
- go -C ../fonoteka.go test ./plugins/golem15/user -run '^TestAdminAvatarSharedWithAPI$' -count=1 -v prints "--- PASS: TestAdminAvatarSharedWithAPI".
- go -C ../fonoteka.go test ./plugins/golem15/user -run '^TestAdminPrivilegedMember$' -count=1 -v prints "--- PASS: TestAdminPrivilegedMember"; the test asserts that a password submitted for a privileged member by an admin without golem15.users.manage_privileged_groups answers 403 with an unchanged hash and an unchanged tokens_valid_after, that the old password still logs in, and that the same reset answers 200 with the permission (D-30).
- grep -c 'imageWidth: 260' ../fonoteka.go/plugins/golem15/user/models/user/fields.yaml prints 1.
- grep -c 'golem15.user::mail.invite' ../fonoteka.go/plugins/golem15/user/plugin.go prints 2.
- git -C ../fonoteka.go/plugins/golem15/user diff 8a65890 -- models/user.go shows no change inside the Rules(), Fillable() and Hidden() methods.
- go -C ../fonoteka.go test ./parity -run '^TestUserAPINuxtFlows$' -count=1 passes.
</acceptance_criteria>
The Users form carries the PHP create and update behaviour (password, invitation, organisation, avatar, frontend permissions), and any plugin can ask the resolver whether a user holds a frontend permission.
(1) classes/last_seen.go: TouchLastSeen(ctx, db, userID) runs one UPDATE on users that sets last_seen to the current time for that id only when last_seen is NULL or older than five minutes, and returns the error to the caller without logging secrets. It does not touch updated_at and applies to deactivated users too.
(2) controllers/api_controller.go: Login calls TouchLastSeen after the successful credential and throttle checks and before the response; Refresh calls it after a successful refresh with the subject id read from the new token's claims (Refresh never loads the user row). In both, an error is logged at warning level with the user id and the handler continues: the response, its status and its body are exactly what they are today. No payload gains a key.
(3) last_seen_test.go TestLastSeen: a login sets last_seen; a second login within five minutes leaves the value unchanged; with last_seen moved six minutes into the past a refresh updates it; with the users table made unwritable for that statement (for example a database handle whose update fails) login and refresh still answer 200 with the same body shape; the login, fetch and refresh payloads contain none of the keys last_seen, created_at, updated_at, and their permissions key is still null and groups still an empty list; marshalling a models.User with encoding/json yields no last_seen, permissions, created_at or updated_at key.
(4) README.md of the plugin (the standard structure, no consuming-application name): the admin screens (Users now; User Groups and Organisations announced as part of the same release and filled in by plan 04), the five permission codes, the navigation item, the three migrations, the frontend permission resolver (MergedPermissions, HasPermission, UserHasPermission), the last_seen rule, and the protection of privileged-group members (D-30): the config key golem15.user.privileged_groups with its default, and the rule that changing such a user's email or password or permanently deleting them needs golem15.users.manage_privileged_groups on top of golem15.users.access_users.
(5) Run the plugin's full suite and the application checks named in the verify block; record the measured run times in the summary for VALIDATION.md.
go -C ../fonoteka.go vet ./plugins/golem15/user/... && go -C ../fonoteka.go test ./plugins/golem15/user -run '^(TestLastSeen|TestLogin|TestRefresh)' -count=1 -v && go -C ../fonoteka.go test ./plugins/golem15/user/... -count=1 && go -C ../fonoteka.go test ./plugins/golem15/fonoteka -run '^(TestAdmin|TestPhase09|TestPhase10|TestPhase12Threats)' -count=1 && go -C ../fonoteka.go test ./parity -run '^(TestUserAPINuxtFlows|TestParityCorpus)$' -count=1
<fails_when>Any command exits non-zero; the named run lacks "--- PASS: TestLastSeen"; any run prints "no tests to run" or a line starting with "FAIL".</fails_when>
<acceptance_criteria>
- go -C ../fonoteka.go test ./plugins/golem15/user -run '^TestLastSeen$' -count=1 -v prints "--- PASS: TestLastSeen"; the test asserts the five-minute rule, that a failing write leaves login and refresh at 200, and that no user payload carries last_seen.
- grep -c 'TouchLastSeen' ../fonoteka.go/plugins/golem15/user/controllers/api_controller.go prints 2.
- go -C ../fonoteka.go test ./parity -run '^(TestUserAPINuxtFlows|TestParityCorpus)$' -count=1 passes (payloads byte-identical).
- grep -c 'manage_privileged_groups' ../fonoteka.go/plugins/golem15/user/README.md prints at least 1 and grep -ci 'fonoteka\|płytarium\|plytarium' ../fonoteka.go/plugins/golem15/user/README.md prints 0.
- git -C ../fonoteka.go/plugins/golem15/user status --short prints nothing after the task's commits.
</acceptance_criteria>
last_seen is recorded on login and refresh under the five-minute rule without any effect on authentication or payloads, the Users list shows it, and the plugin README documents the admin half.
<threat_model>
Trust Boundaries
| Boundary | Description |
|---|---|
| Backend admin → admin API (Users controller) | Field values, virtual values, ids and action names from an authenticated admin |
| Admin screens → frontend user accounts | Admin actions change who can log in (activate, ban, password, delete) |
| Admin with the users permission → accounts of privileged-group members | Whoever controls such an account's email or password holds its site-admin powers (D-30) |
| User plugin → user API payloads | New model fields must not reach the Nuxt contract |
| Plugin → mail transport | An admin-triggered invitation leaves the system |
STRIDE Threat Register
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|---|---|---|---|---|---|
| T-12.1-18 | Elevation of Privilege | Users screens and actions without the users permission | high | mitigate | RequiredPermissions golem15.users.access_users on the controller and on every bulk and record action; navigation filtered per principal; 403 asserted for an admin without it (Tasks 1, 2). |
| T-12.1-19 | Information Disclosure | password hash, plain password or reset codes in admin responses | high | mitigate | No form field maps to the hash; password is a virtual password field that the framework never projects; model secrets keep json "-"; tests scan every response body (Tasks 1, 3). |
| T-12.1-20 | Tampering | mass assignment of is_activated, permissions or organisation_id through the user form | high | mitigate | The framework's protected fill keys stay; activation only through the activate actions (D-29); permissions only through the permission editor's validated path; organisation_id only through the relation field with the explicit opt-in and scoped ids (Task 3). |
| T-12.1-21 | Spoofing | tokens issued before an admin password reset stay valid | medium | mitigate | A password submitted on update stamps tokens_valid_after, as ChangePassword does (Task 3, TestAdminUserPassword). |
| T-12.1-22 | Denial of Service | invitation mail abuse | low | mitigate | send_invite is create-only and sends one mail per created user after the commit; there is no bulk invite action (Task 3, TestAdminUserInvite). |
| T-12.1-23 | Tampering | permanent delete leaves orphans or half-deleted users | medium | mitigate | ForceDeleteCleanup and the unscoped delete run in the save transaction; blobs are deleted after the commit; the standard confirm states that the delete is permanent (Task 2, TestAdminUserForceDelete). |
| T-12.1-24 | Information Disclosure | last_seen, permissions, timestamps or groups leak into user API payloads | high | mitigate | json "-" on every new field; payloads come from the explicit apiArray map; TestLastSeen, TestUserAPINuxtFlows, TestParityCorpus and TestPhase12Threats (Tasks 1, 3, 4). |
| T-12.1-25 | Denial of Service | last_seen write fails or amplifies writes on the auth path | low | mitigate | One conditional UPDATE at most every five minutes per user; errors are logged and ignored (Task 4). |
| T-12.1-26 | Elevation of Privilege | a deactivated site admin is re-enabled by restore or activate | medium | accept | PHP behaves the same (the membership survives a soft delete) and the action needs golem15.users.access_users; recorded for the security review of plan 05 (RESEARCH T-12-18 path 8). |
| T-12.1-27 | Elevation of Privilege | a wrong port of the permission merge grants a frontend permission | medium | mitigate | Line-by-line port of getMergedPermissions and hasPermission with a table test covering overrides, the exactly-1 rule and wildcards; no caller exists yet (Task 3, TestMergedPermissions). |
| T-12.1-38 | Elevation of Privilege | takeover of a privileged-group member's account: an admin holding only golem15.users.access_users resets that user's password, or changes their email and then uses the password-reset mail, and logs in as a site admin | critical | mitigate | D-30 (user decision at the plan check; differs from PHP on purpose). The guard at the top of the Users controller's FormBeforeUpdate refuses a changed email or a submitted password on a privileged member with cabana.ForbiddenError (403, rollback, nothing saved) unless the principal passes cabana.Allows for golem15.users.manage_privileged_groups; membership is read inside the write transaction against the per-request list of D-05; the guard exists from the commit that creates the controller (Tasks 1 and 3, TestAdminPrivilegedMember; removal test in plan 05). |
| T-12.1-39 | Tampering | permanent delete of a privileged-group member, by the form button or inside a bulk delete, by an admin holding only golem15.users.access_users | high | mitigate | D-30. FormBeforeDelete refuses with cabana.ForbiddenError before the row, its cleanup or any blob deletion is touched; the bulk delete runs in one transaction, so a selection containing one such user is refused as a whole (Tasks 1 and 2, TestAdminPrivilegedMember; removal test in plan 05). Outside D-30 by its wording and left at golem15.users.access_users as in PHP (D-14): deactivate, restore, ban, unban, unsuspend and activate on a privileged member; none of them gives the acting admin the member's credentials, and T-12.1-26 records the related accepted risk. |
| T-12.1-SC | Tampering | npm/pip/cargo installs | high | mitigate | No package is installed: the plugin's go.mod gains no require line (acceptance check in Task 1); any need for a module stops at a blocking human checkpoint. |
| </threat_model> |
<success_criteria>
- Users has its list (columns, search, the three PHP filters, row states), preview (hint, preview-only fields, record actions) and create and update form (password, invitation, organisation, avatar, permissions), reachable from the navigation and gated by backend permissions.
- activate, unban, unsuspend, delete and the bulk actions behave as PHP Users.php, with the deviations recorded in D-29.
- The frontend permission data and resolver exist and are proven by unit tests; last_seen is written under the five-minute rule.
- A privileged-group member's email, password and permanent delete need
golem15.users.manage_privileged_groups; without it the request is refused with 403 and nothing changes, a bulk delete as a whole (D-30). - The user API payloads are byte-identical; migrations are additive; no dependency was added. </success_criteria>