60 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 | 04 | execute | 4 |
|
|
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, an admin manages a user's groups from the user form, with the site-admin group protected by its own permission; manages user groups and their frontend permissions; manages organisations and their members; and the application repository carries all of it.
Close success criteria 2 and 4 and the rest of criterion 1: add the `groups` field to the user form together with the privileged-group guard (D-04 to D-07, threat T-12-18 revisited), port the User Groups and Organisations screens (D-06, D-22, D-24) with the organisation `members` relation manager, then record the plugin in the application repository (parity allow-list entry, submodule pointer) on a framework that contains v0.1.3.Purpose: the admin form becomes the first writer of users_groups; it must never be able to make a site admin without the required permission.
Output: the files in the frontmatter, committed in the plugin checkout and in fonoteka.go.
Repos: plugin code is committed inside the plugin checkout with git -C ../fonoteka.go/plugins/golem15/user (repo sm-user-plugin) and never staged from the application repository. The application repository (git -C ../fonoteka.go) gets one local commit in Task 4 (the allow-list entry together with the bumped pointer). Nothing is pushed by this plan: the plugin is published in plan 05 Task 3, after the security review and only when the framework tag v0.1.3 is on origin; pushing fonoteka.go stays a step for the user after that. Planning docs are committed separately in summercms.go. Never add co-author tags. The PHP reference is read-only. The plugin is shared across projects: additive changes only.
The groups field and its guard land in the same commit (Task 1), so there is no commit at which users_groups has an unguarded writer.
<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.)
- Reused from plan 03, not created or changed here: the config key
golem15.user.privileged_groupsand, inclasses/privileged.go, the constantPermissionManagePrivilegedGroupsandPrivilegedGroupCodes,IsPrivilegedCode,PrivilegedGroupIDs,IsPrivilegedMember. - Models:
models.UsersGroup(pivot);models.UserGroupgainsFillable(),Rules()and the read-only fieldUsersCount;models.OrganisationgainsFillable(),Rules(),MorphName(),AttachRelations(),BeforeValidate();models.Slugify;models.ParsePermissionSetandPermissionSet.Texthelpers. - Controllers:
golem15.user.usergroups(model nameGolem15\User\Models\UserGroup, config dircontrollers/usergroups) andgolem15.user.organisations(model nameGolem15\User\Models\Organisation, config dircontrollers/organisations); the users controller gains thegroupsrelation contract andAdminRelationLocks. - YAML:
controllers/usergroups/config_list.yaml,config_form.yaml;models/usergroup/fields.yaml,columns.yaml;controllers/organisations/config_list.yaml,config_form.yaml,config_relation.yaml;models/organisation/fields.yaml,columns.yaml; thegroupsfield inmodels/user/fields.yaml. - Navigation side items
usergroupsandorganisations. - Lang keys under
golem15.user::lang.group.*,groups.*,organisation.*, plususer.groups,user.empty_groups,users.privileged_group_forbidden. - Tests:
TestAdminPrivilegedGroups,TestAdminUserGroupsField,TestAdminGroups,TestAdminOrganisations,TestAdminOrganisationMembers. - Application: one
allowedDiffsentry and the submodule pointer ofplugins/golem15/user.
Planner decisions recorded for this plan
- Config key (D-05 discretion).
golem15.user.privileged_groups. Since the plan-check revision the key andclasses/privileged.goare created by plan 03 Task 1, where the D-30 guard needs them first; this plan only uses them. - Single writer, and how success criterion 2 is met. No groups relation manager is added on the user form or on the group form: the relation manager's unlink has no hook to guard (RESEARCH anti-pattern). The user form's
groupsrelation field (PHP'stype: relationfield) is the only place a membership changes. A user's groups are managed through that relation field and an organisation's members through a relation manager, as in PHP; ROADMAP success criterion 2 was reworded at the plan check to say exactly that. - Publication (plan-check revision). This plan no longer pushes sm-user-plugin. Of the two routes the orchestrator offered, the push moved to plan 05 Task 3 (after the security review, and only when
git ls-remote --tags origin v0.1.3lists the framework tag); until then the pointer bumps of Task 4 and of plan 05 Task 2 are local commits in fonoteka.go, which these plans never push. - What D-06 covers. Exactly the three cases D-06 names (create with a privileged code, change a code to or from a privileged one, delete a privileged group). Editing the name, description or frontend permissions of a privileged group without touching its code needs only
golem15.users.access_groups. - Group permissions column.
UserGroup.Permissionsstays*stringso the exported model does not change; the controller converts throughPermissionSethelpers. - Unsupported rule tokens. PHP's
regex(group code) andalpha_dash(organisation slug) are checked in the controllers' before-hooks and answered as 422, becauselagoon.Validatedoes not know them (RESEARCH Pitfall 2). - Deleting an organisation with members. The members'
organisation_idandorganisation_roleare cleared in the same transaction before the delete; the users are not deleted. - Framework baseline. The application resolves the framework through its local replace, so no go.mod version line changes; "builds on v0.1.3" is proven by the tag being an ancestor of the framework head the suites run against.
- Spec-less probe fallback: skipped (no requirement IDs, no SPEC.md); no probe predicates were generated. Edge cases come from RESEARCH "T-12-18: Every users_groups Write Path", Pitfalls 2 and 13 and the UI-SPEC rows lifted into
must_haves.
(1) Reuse, do not redefine: the config key golem15.user.privileged_groups (default admin) and, in classes/privileged.go, the constant PermissionManagePrivilegedGroups and the functions PrivilegedGroupCodes, IsPrivilegedCode, PrivilegedGroupIDs and IsPrivilegedMember exist since plan 03 Task 1, where the takeover guard of D-30 needed them. This task changes neither config/config.yaml nor classes/privileged.go; the list is still read from the config on every request and nothing is cached at boot.
(2) models/users_group.go: UsersGroup with UserID (column user_id) and UserGroupID (column user_group_id), both part of the primary key, table users_groups; registered like the other models.
(3) Users controller: AdminFieldRelations gains the contract for the field groups (kind belongsToMany, related model UserGroup, pivot model UsersGroup, parent foreign key user_id, related foreign key user_group_id, label column name). The controller implements cabana.RelationLockProvider: for the field groups, when the principal on the context passes cabana.Allows for PermissionManagePrivilegedGroups the lock is empty; otherwise it carries the privileged group ids (read with the request's database handle) and the message golem15.user::lang.users.privileged_group_forbidden; every other field gets an empty lock. Add the assertion line for the new interface in admin_registry.go. The framework then marks those options and labels locked and refuses, on create and on update, any save that changes the privileged subset with 403 before a row is written. No relation manager for groups is added.
(4) models/user/fields.yaml: add groups (type relation, tab account, label golem15.user::lang.user.groups, emptyOption golem15.user::lang.user.empty_groups). lang (en and pl): user.groups, user.empty_groups, and users.privileged_group_forbidden with the UI-SPEC text ("You do not have permission to change membership of privileged groups. Nothing was saved." and its Polish text).
(5) admin_privileged_test.go. TestAdminUserGroupsField: an admin with golem15.users.access_users sets a user's ordinary groups, the pivot holds exactly the chosen groups, clearing the field removes them, an unknown group id answers 422, and the user API payload of that user still carries groups as an empty list. TestAdminPrivilegedGroups (matrix with and without golem15.users.manage_privileged_groups, a group with code admin and an ordinary group, both created by the test because the migrations seed only guest and registered): without the permission the relation options and the record's labels flag the admin group locked; adding it on update answers 403 with the message key's text and details on groups, and neither the pivot nor any column of the user changed although the same body also changed the name; removing it from a user who has it answers 403 and the pivot is unchanged; creating a user with it answers 403 and no user row exists; changing only ordinary groups while the privileged membership stays as it is answers 200; classes.HasGroupCode for code admin gives the same answer before and after each refused request. With the permission each of those requests answers 200 and the pivot reflects it. With the config key overridden to another code, that group is the locked one and admin is not. A group whose code is NULL is never locked. The same test also ties the field to the takeover guard of plan 03 (D-30), without any direct insert into users_groups: after an admin holding the permission adds the admin group to a user through the groups field, an admin without it gets 403 when changing that user's email; after the holder removes the group again through the field, the same email change answers 200.
go -C ../fonoteka.go vet ./plugins/golem15/user/... && go -C ../fonoteka.go test ./plugins/golem15/user -run '^(TestAdminPrivilegedGroups|TestAdminUserGroupsField)$' -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 named run lacks "--- PASS: TestAdminPrivilegedGroups" or "--- PASS: TestAdminUserGroupsField"; 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 '^TestAdminPrivilegedGroups$' -count=1 -v prints "--- PASS: TestAdminPrivilegedGroups"; the test asserts 403 with an unchanged pivot for add and for remove on update, 403 with no new user row on create, 200 for an ordinary-group change, and the unchanged answer of classes.HasGroupCode after each refused request; it also asserts that a privileged membership given through the groups field makes that user's email change answer 403 for an admin without the permission (D-30).
- git -C ../fonoteka.go/plugins/golem15/user merge-base --is-ancestor "$(git -C ../fonoteka.go/plugins/golem15/user log --diff-filter=A --format=%H -- classes/privileged.go)" "$(git -C ../fonoteka.go/plugins/golem15/user log --diff-filter=A --format=%H -- models/users_group.go)" succeeds and the two shas differ (the helpers of plan 03 predate the groups field; they are reused, not recreated).
- grep -c 'AdminRelationLocks' ../fonoteka.go/plugins/golem15/user/controllers/users_admin_controller.go prints at least 1.
- git -C ../fonoteka.go/plugins/golem15/user log -1 --format=%H -- models/user/fields.yaml and git -C ../fonoteka.go/plugins/golem15/user log --diff-filter=A --format=%H -- models/users_group.go print the same commit sha, and that commit is the one that introduces the lock provider: with that sha as SHA, git -C ../fonoteka.go/plugins/golem15/user grep -q 'AdminRelationLocks' SHA -- controllers/users_admin_controller.go exits 0 and the same command with SHA^ in place of SHA exits 1 (the field, the pivot model and its guard landed together).
- go -C ../fonoteka.go test ./plugins/golem15/fonoteka -run '^TestPhase12Threats$' -count=1 passes (User.Groups is still never serialized).
</acceptance_criteria>
Group membership is managed from the user form; making or unmaking a site admin needs the privileged-groups permission, enforced on the server for create and update, and an admin without it sees the group locked.
(1) models/user_group.go: add Fillable() returning name, code and description; Rules() with name "required|between:3,64" and code "required|unique:user_groups" (only tokens lagoon.Validate supports); a read-only field UsersCount on a column named users_count with the arrow-only GORM permission and json "-" (the column exists only in the list query's select). Permissions stays a nullable string. models/permission_set.go: helpers ParsePermissionSet(text) (the tolerant decode used by Scan, for a nullable string column) and PermissionSet.Text() (the object text, or nil for an empty set).
(2) controllers/usergroups_admin_controller.go: ID golem15.user.usergroups; ModelName the PHP class string of the UserGroup model; ConfigDir controllers/usergroups; RequiredPermissions golem15.users.access_groups; NewRecord a new models.UserGroup. ListExtendQuery selects the group columns plus, as a second select argument, a scalar subquery counting the group's users_groups rows aliased users_count (two select arguments, so the list's count query stays a plain count). FormBeforeCreate and FormBeforeUpdate: (a) refuse a code that does not match the pattern of letters, digits, underscore and hyphen only with a ValidationError on code; (b) the D-06 check: read the stored code of the group by id through the request's transaction (none on create) and compare it with the submitted code; when they differ in privilege (the submitted code is privileged on create; on update the stored or the submitted code is privileged and the two codes differ) and the principal does not pass cabana.Allows for PermissionManagePrivilegedGroups, return a cabana.ForbiddenError with the message golem15.user::lang.groups.privileged_forbidden and details on code. FormBeforeDelete: a group whose stored code is privileged needs the same permission, else the same ForbiddenError. FormAfterDelete: delete the group's users_groups rows in the same transaction. PermissionEditorProvider for the field permissions: options from classes.FrontendPermissionOptions, values parsed from the nullable Permissions text, the setter writing the object text back (checkbox mode stores 1 per allowed code). Register the controller in controllers.AdminControllers and add its assertion lines.
(3) YAML. controllers/usergroups/config_list.yaml: list pointing at models/usergroup/columns.yaml, the modelClass, title golem15.user::lang.groups.list_title, recordUrl golem15/user/usergroups/update/:id, noRecordsMessage, recordsPerPage 20, showSetup true, showSorting true, no showCheckboxes, toolbar buttons [create] with the search prompt, messages empty golem15.user::lang.groups.list_empty and create golem15.user::lang.groups.new_group. models/usergroup/columns.yaml: id (invisible), name (searchable), code, created_at (type datetime), users_count (label golem15.user::lang.group.users_count, sortable false). controllers/usergroups/config_form.yaml: name, form pointing at models/usergroup/fields.yaml, the modelClass, defaultRedirect golem15/user/usergroups, create and update redirects to the update form and the list, messages (create, update, saved, deleteConfirm golem15.user::lang.groups.delete_confirm, deleted); no title keys under create or update and no preview block. models/usergroup/fields.yaml: name (type text, span left, required), code (type text, span right, preset name, comment golem15.user::lang.group.code_comment), description (type textarea, size tiny, label golem15.user::lang.group.description_field), permissions (type permissioneditor, mode checkbox, span full, tab golem15.user::lang.user.permissions_tab). Add every new file to the embed list in admin.go.
(4) admin_navigation.go: add the side item usergroups (label golem15.user::lang.groups.all_groups, icon users, permission golem15.users.access_groups, controller golem15.user.usergroups).
(5) lang (en and pl), PHP values plus the UI-SPEC texts for new keys: group.id, name, code, code_comment, description_field, created_at, users_count; groups.all_groups, menu_label, list_title, new_group, list_empty, delete_confirm, privileged_forbidden.
(6) Tests. admin_groups_test.go TestAdminGroups: an admin with only golem15.users.access_users gets 403 on the groups list and sees no usergroups side item; an admin with golem15.users.access_groups lists the groups with users_count (0 for a group without members, the member count otherwise) and the list total is right; creates a group (a code with a space answers 422 on code; a duplicate code answers 422); edits name and description; saves two frontend permissions in checkbox mode and user_groups.permissions holds a JSON object with value 1 for each; a value of -1 answers 422; deletes an ordinary group and its users_groups rows are gone while the member users remain. Extend TestAdminPrivilegedGroups in admin_privileged_test.go with the D-06 matrix: without the extra permission creating a group with code admin, renaming an ordinary group's code to admin, renaming the admin group's code to something else and deleting the admin group each answer 403 and change nothing; editing the admin group's name alone answers 200; with the permission all four succeed.
go -C ../fonoteka.go vet ./plugins/golem15/user/... && go -C ../fonoteka.go test ./plugins/golem15/user -run '^(TestAdminGroups|TestAdminPrivilegedGroups)$' -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: TestAdminGroups" or "--- PASS: TestAdminPrivilegedGroups"; 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 '^TestAdminGroups$' -count=1 -v prints "--- PASS: TestAdminGroups"; the test asserts users_count 0 for an empty group, the correct list total, a 422 for an invalid code and the pivot cleanup on delete.
- go -C ../fonoteka.go test ./plugins/golem15/user -run '^TestAdminPrivilegedGroups$' -count=1 -v prints "--- PASS: TestAdminPrivilegedGroups" with the four D-06 cases refused without the permission and allowed with it.
- grep -c 'golem15.users.access_groups' ../fonoteka.go/plugins/golem15/user/controllers/usergroups_admin_controller.go prints at least 1.
- grep -c 'mode: checkbox' ../fonoteka.go/plugins/golem15/user/models/usergroup/fields.yaml prints 1 and grep -c 'preset: name' ../fonoteka.go/plugins/golem15/user/models/usergroup/fields.yaml prints 1.
- go -C ../fonoteka.go/plugins/golem15/user doc ./models UserGroup still shows Permissions as a pointer to string (the exported model field kept its type).
</acceptance_criteria>
User Groups is a working admin screen with the users count and the checkbox permission editor, and a privileged group code can be created, renamed or deleted only with the privileged-groups permission.
(1) models/organisation.go: add Fillable() (name, slug, description), Rules() (name "required|max:255", slug "required|unique:golem15_user_organisations", description "nullable|max:5000"), MorphName() returning the PHP class string of the Organisation model, AttachRelations() declaring the public relation named avatar, and BeforeValidate (lagoon.HasBeforeValidate) that fills an empty slug from the name with Slugify. No field or column is added to the model (the table is shared and column-diffed against the PHP snapshot). models/slug.go (in the models package, because the classes package imports models): Slugify(text) (lower-case ASCII; every run of other characters becomes one hyphen; hyphens trimmed at both ends), the same rule the SPA preset uses.
(2) controllers/organisations_admin_controller.go: ID golem15.user.organisations; ModelName the PHP class string; ConfigDir controllers/organisations; RequiredPermissions golem15.users.access_users; NewRecord a new models.Organisation. FormBeforeCreate and FormBeforeUpdate refuse a slug that contains anything but letters, digits, underscore and hyphen with a ValidationError on slug. AdminRelationContracts (cabana.AdminRelationContractProvider): one contract named members, kind cabana.RelationHasMany, related model models.User, foreign key organisation_id, columns map name to name and email to email. RelationExtendManageQuery returns a query that matches nothing for any relation name other than members. FormBeforeDelete clears organisation_id and organisation_role of the organisation's members in the same transaction, so the delete does not fail on the users foreign key and no user is deleted. Register the controller and its assertion lines.
(3) YAML. controllers/organisations/config_list.yaml: list pointing at models/organisation/columns.yaml, the modelClass, title, recordUrl golem15/user/organisations/update/:id, recordsPerPage 20, toolbar buttons [create] with the search prompt, no showCheckboxes, messages empty golem15.user::lang.organisation.list_empty and create golem15.user::lang.organisation.new. models/organisation/columns.yaml: id (invisible), name (searchable), slug (searchable), created_at (type datetime, sortable true, label backend::lang.list.column_created). controllers/organisations/config_form.yaml: name, form, modelClass, defaultRedirect golem15/user/organisations, redirects, messages; no preview block. models/organisation/fields.yaml: name (type text, span left, required), slug (type text, span right, comment golem15.user::lang.organisation.slug_comment, preset with field name and type slug), description (type textarea, size small), avatar (type fileupload, mode image, imageWidth 120, imageHeight 120), members (type relation-manager, relation members, tab golem15.user::lang.organisation.members, span full, context update). controllers/organisations/config_relation.yaml: members with label golem15.user::lang.organisation.members, a messages block (link golem15.user::lang.organisation.add_member, unlinkSelected golem15.user::lang.organisation.remove_members, unlinkConfirm golem15.user::lang.organisation.remove_members_confirm, empty golem15.user::lang.organisation.members_empty), view with inline list columns name and email, toolbarButtons link|unlink and showSearch true, manage with the same inline columns and showSearch true. Add every new file to the embed list in admin.go.
(4) admin_navigation.go: add the side item organisations (label golem15.user::lang.organisation.menu_label, icon users-round, permission golem15.users.access_users, controller golem15.user.organisations).
(5) lang (en and pl), PHP values plus the UI-SPEC texts for new keys: organisation.menu_label, id, name, slug, slug_comment, description, avatar, members, new, list_empty, add_member, remove_members, remove_members_confirm (with :count), members_empty.
(6) README.md of the plugin: complete the admin section with User Groups (the D-06 rule, the config key golem15.user.privileged_groups, the single-writer rule for users_groups) and Organisations (members), still naming no consuming application.
(7) admin_organisations_test.go. TestAdminOrganisations: an admin with golem15.users.access_users lists organisations, creates one with an empty slug and gets the slug derived from the name, a slug with a space answers 422, a duplicate slug answers 422, an avatar uploaded through the admin file route is stored against the organisation's morph name, and deleting an organisation with members succeeds, leaves the users in place and clears their organisation_id; an admin without the permission gets 403. TestAdminOrganisationMembers: linking two existing users sets their organisation_id, unlinking one clears it, the linked list shows only this organisation's members, a user of another organisation moves when linked here, and the users_groups rows of every involved user are unchanged throughout. The navigation of an admin holding both users permissions lists the side items users, usergroups and organisations in that order.
go -C ../fonoteka.go vet ./plugins/golem15/user/... && go -C ../fonoteka.go test ./plugins/golem15/user -run '^(TestAdminOrganisations|TestAdminOrganisationMembers)$' -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 named run lacks "--- PASS: TestAdminOrganisations" or "--- PASS: TestAdminOrganisationMembers"; 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 '^TestAdminOrganisationMembers$' -count=1 -v prints "--- PASS: TestAdminOrganisationMembers"; the test asserts that link sets and unlink clears organisation_id and that users_groups is unchanged.
- go -C ../fonoteka.go test ./plugins/golem15/user -run '^TestAdminOrganisations$' -count=1 -v prints "--- PASS: TestAdminOrganisations".
- grep -c 'imageWidth: 120' ../fonoteka.go/plugins/golem15/user/models/organisation/fields.yaml prints 1 and grep -c 'link|unlink' ../fonoteka.go/plugins/golem15/user/controllers/organisations/config_relation.yaml prints 1.
- git -C ../fonoteka.go/plugins/golem15/user diff --stat 8a65890 -- updates/10_organisations.go prints nothing (no column was added to the shared organisations table).
- grep -c 'golem15.user.organisations' ../fonoteka.go/plugins/golem15/user/admin_navigation.go prints 1 and grep -c 'golem15.user.usergroups' ../fonoteka.go/plugins/golem15/user/admin_navigation.go prints 1.
- go -C ../fonoteka.go test ./plugins/golem15/user/... -count=1 passes and git -C ../fonoteka.go/plugins/golem15/user status --short prints nothing after the task's commits.
</acceptance_criteria>
Organisations is a working admin screen with avatar and a members relation manager, and all three screens are in the navigation under their permissions.
(1) ../fonoteka.go/parity/schema_diff_test.go: add one allowedDiffs entry keyed golem15_user_frontend_permissions, worded like the existing user_groups entry and naming its source: Winter's frontend permissions table from the user plugin's updates/v2.8.0 (Phase 12.1 D-15); the frozen snapshot predates the user-plugin dump; same columns as PHP with TIMESTAMPTZ timestamps. Add no entry for users (the snapshot's users table already has permissions and last_seen, and the test only requires PHP to have more columns than Go) and none for golem15_user_organisations.
(2) Confirm the framework baseline: the tag v0.1.3 is an ancestor of the summercms.go head the application builds against (the application resolves the framework through its local replace; no go.mod version line changes).
(3) Run the application's whole suite against the plugin's working copy: go vet and go test over the application workspace, including the schema parity test and the user API parity flows.
(4) Do not push the plugin. Its checkout stays ahead of its origin until plan 05 Task 3, which pushes master once, after the security review, and only when git ls-remote --tags origin v0.1.3 lists the framework tag: a published plugin commit must never depend on a framework contract that is not published, and the shared plugin is not published before the review. Never force a push.
(5) In fonoteka.go, stage parity/schema_diff_test.go and the submodule pointer of plugins/golem15/user only, and commit them together (for example "build: bump sm-user-plugin (admin screens for users, groups and organisations)"). Do not stage any other submodule and do not push fonoteka.go. The pointer now names a plugin commit that is not on the plugin's origin; that is safe only while fonoteka.go itself is unpushed, so record the two pending steps in the summary in this order: first "push sm-user-plugin" (done by plan 05 Task 3 when its conditions hold), then "push fonoteka.go" (the user).
(6) Record in the summary: the plugin head sha and that it is not pushed, the plugin's remote head sha read at the start and again at the end of the task, the application commit sha, the framework head sha and the measured run times of the two full suites.
git merge-base --is-ancestor v0.1.3 HEAD && go -C ../fonoteka.go vet ./... && go -C ../fonoteka.go test ./parity -run '^(TestSchemaMatchesPHPSnapshot|TestUserAPINuxtFlows|TestParityCorpus)$' -count=1 -v && go -C ../fonoteka.go test ./... -count=1 && test -z "$(git -C ../fonoteka.go/plugins/golem15/user status --short)" && test "$(git -C ../fonoteka.go rev-parse HEAD:plugins/golem15/user)" = "$(git -C ../fonoteka.go/plugins/golem15/user rev-parse HEAD)" && git -C ../fonoteka.go/plugins/golem15/user fetch --quiet origin && test -n "$(git ls-remote --tags origin v0.1.3)" -o "$(git -C ../fonoteka.go/plugins/golem15/user rev-list --count origin/master..HEAD)" != "0"
<fails_when>Any command exits non-zero; the parity run lacks "--- PASS: TestSchemaMatchesPHPSnapshot" or prints "Go extra table"; the full application run prints a line starting with "FAIL"; the pointer recorded in fonoteka.go differs from the plugin checkout's head; the last term exits 1, which means the plugin checkout has no commit ahead of its freshly fetched origin (its head is published) while the framework tag v0.1.3 is not on the framework's origin.</fails_when>
<acceptance_criteria>
- go -C ../fonoteka.go test ./parity -run '^TestSchemaMatchesPHPSnapshot$' -count=1 -v prints "--- PASS: TestSchemaMatchesPHPSnapshot".
- grep -c '"golem15_user_frontend_permissions"' ../fonoteka.go/parity/schema_diff_test.go prints 1.
- git -C ../fonoteka.go rev-parse HEAD:plugins/golem15/user equals git -C ../fonoteka.go/plugins/golem15/user rev-parse HEAD.
- The sha printed by git -C ../fonoteka.go/plugins/golem15/user ls-remote origin refs/heads/master at the end of the task equals the sha noted at its start (both are in the summary): this task pushed nothing.
- The summary lists the pending steps in order: "push sm-user-plugin" (plan 05 Task 3, conditional on v0.1.3 being on origin), then "push fonoteka.go".
- git -C ../fonoteka.go show --stat --format= HEAD lists exactly parity/schema_diff_test.go and plugins/golem15/user.
- go -C ../fonoteka.go vet ./... && go -C ../fonoteka.go test ./... -count=1 exits 0 at that commit, and git -C ../fonoteka.go diff HEAD -- go.mod prints nothing.
</acceptance_criteria>
The application builds and passes with the plugin's admin screens, recorded by a local pointer commit on a framework that contains v0.1.3; publication waits for plan 05.
<threat_model>
Trust Boundaries
| Boundary | Description |
|---|---|
| Backend admin → users_groups | The admin form is the first writer of group membership; membership of the privileged group makes a site admin |
| Backend admin → user_groups codes | Changing a group's code changes the privilege of every member |
| Configuration → privilege | The privileged list decides which codes are protected |
| Plugin repository → host applications | A push publishes migrations and permission codes to every project mounting the plugin; this plan does not push (the push is plan 05 Task 3, threat T-12.1-40) |
STRIDE Threat Register
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|---|---|---|---|---|---|
| T-12.1-28 | Elevation of Privilege | T-12-18 revisited: adding or removing a privileged group through the user form's groups field | critical | mitigate | AdminRelationLocks returns the privileged group ids for an admin without golem15.users.manage_privileged_groups; the framework refuses any change of that subset with 403 before a row is written, on create and update; field and guard land in one commit (Task 1, TestAdminPrivilegedGroups). |
| T-12.1-29 | Elevation of Privilege | creating, renaming to or from, or deleting a privileged group code | high | mitigate | Before-hooks compare the stored and the submitted code against the privileged list and return ForbiddenError without the extra permission (Task 2, TestAdminPrivilegedGroups D-06 matrix). |
| T-12.1-30 | Elevation of Privilege | a second, unguarded writer of users_groups | high | mitigate | No groups relation manager exists; bulk actions, record actions and the organisation routes are asserted to leave users_groups unchanged; only the cleanup of a permanently deleted user or group removes rows (Tasks 1 to 3; plan 03 Task 2). |
| T-12.1-31 | Tampering | privileged list cached, mis-compared or bypassed through a NULL code | medium | mitigate | Read from config per request, default [admin], case-sensitive compare, NULL code never privileged; tested with an overridden list (Task 1). |
| T-12.1-32 | Tampering | organisation members manager moves users between organisations | low | accept | PHP's add and remove behave the same; the routes need golem15.users.access_users, write only organisation_id and are scoped by the relation contract (RESEARCH T-12-18 path 6). |
| T-12.1-33 | Tampering | a published application pointer references an unpublished plugin commit, the plugin is published while the framework contract it needs is not, or the app is built against a framework without v0.1.3 | low | mitigate | Task 4 pushes nothing: the pointer bump is a local commit in fonoteka.go, which these plans never push; the summary orders the pending steps (plugin first, then the application); the verify command fails when the plugin's head is on its origin while git ls-remote --tags origin v0.1.3 is empty, and runs git merge-base --is-ancestor v0.1.3 HEAD (Task 4). The push itself is governed by T-12.1-40 in plan 05. |
| T-12.1-34 | Information Disclosure | group membership reaches a user API payload | high | mitigate | User.Groups keeps json "-", payloads come from the explicit map, TestPhase12Threats and the parity flows run in every task (D-08). |
| T-12.1-SC | Tampering | npm/pip/cargo installs | high | mitigate | No package is installed; git diff HEAD -- go.mod in the application and the plugin shows no new require line. Any need for a module stops at a blocking human checkpoint. |
| </threat_model> |
<success_criteria>
- Users, User Groups and Organisations each have a list and a form reachable from the navigation and gated by backend permissions (SC-1); a user's groups are managed through the relation field on the user form and an organisation's members through a relation manager (SC-2).
- T-12-18 is closed: the admin form is the only writer of
users_groups, and privileged membership and privileged codes needgolem15.users.manage_privileged_groupson the server (SC-4). - The application passes its full suite with the plugin at the head its local pointer commit records, on a framework that contains v0.1.3; neither repository was pushed. </success_criteria>