Files
summercms/.planning/phases/12.1-user-plugin-admin-screens/12.1-04-PLAN.md
2026-10-04 19:41:08 +02:00

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
12.1-03
../fonoteka.go/plugins/golem15/user/models/slug.go
../fonoteka.go/plugins/golem15/user/models/users_group.go
../fonoteka.go/plugins/golem15/user/models/user_group.go
../fonoteka.go/plugins/golem15/user/models/organisation.go
../fonoteka.go/plugins/golem15/user/models/permission_set.go
../fonoteka.go/plugins/golem15/user/models/user/fields.yaml
../fonoteka.go/plugins/golem15/user/models/usergroup/fields.yaml
../fonoteka.go/plugins/golem15/user/models/usergroup/columns.yaml
../fonoteka.go/plugins/golem15/user/models/organisation/fields.yaml
../fonoteka.go/plugins/golem15/user/models/organisation/columns.yaml
../fonoteka.go/plugins/golem15/user/controllers/users_admin_controller.go
../fonoteka.go/plugins/golem15/user/controllers/usergroups_admin_controller.go
../fonoteka.go/plugins/golem15/user/controllers/organisations_admin_controller.go
../fonoteka.go/plugins/golem15/user/controllers/admin_registry.go
../fonoteka.go/plugins/golem15/user/controllers/usergroups/config_list.yaml
../fonoteka.go/plugins/golem15/user/controllers/usergroups/config_form.yaml
../fonoteka.go/plugins/golem15/user/controllers/organisations/config_list.yaml
../fonoteka.go/plugins/golem15/user/controllers/organisations/config_form.yaml
../fonoteka.go/plugins/golem15/user/controllers/organisations/config_relation.yaml
../fonoteka.go/plugins/golem15/user/admin.go
../fonoteka.go/plugins/golem15/user/admin_navigation.go
../fonoteka.go/plugins/golem15/user/lang/en/lang.yaml
../fonoteka.go/plugins/golem15/user/lang/pl/lang.yaml
../fonoteka.go/plugins/golem15/user/README.md
../fonoteka.go/plugins/golem15/user/admin_privileged_test.go
../fonoteka.go/plugins/golem15/user/admin_groups_test.go
../fonoteka.go/plugins/golem15/user/admin_organisations_test.go
../fonoteka.go/parity/schema_diff_test.go
../fonoteka.go/plugins/golem15/user
true
SC-1
SC-2
SC-4
tokens raw_tokens tasks confidence
240000 240000 4 low
truths artifacts key_links
Per D-26, this is plan 04 of five: the groups field with its guard, the User Groups and Organisations screens in sm-user-plugin, and in fonoteka.go the parity allow-list entry and the submodule pointer on a framework that contains v0.1.3; plan 05 adds the full unit tests, the gate and the security review, and publishes the plugin after that review.
Per D-04 (threat T-12-18 revisited), golem15.users.access_users is enough to change a user's membership of ordinary groups, and adding or removing a privileged group additionally requires golem15.users.manage_privileged_groups; the check runs on the server for the only path that writes users_groups, the user form's groups field, on create and on update.
Per D-05, the privileged groups are the list of group codes at the config key golem15.user.privileged_groups, default [admin], read per request; codes compare case-sensitively and a group without a code is never privileged. The key and classes/privileged.go exist since plan 03 Task 1 (D-30 needed them there); this plan reuses them and changes neither file.
Per D-30, the takeover guard of plan 03 follows the membership this plan makes editable: once an admin holding golem15.users.manage_privileged_groups gives a user a privileged group through the groups field, an admin without that permission can no longer change that user's email or password or permanently delete them, and the protection ends when the membership is removed.
Per D-06, the User Groups screen is gated by golem15.users.access_groups; creating a group with a privileged code, changing a group's code to or from a privileged one, and deleting a privileged group each require golem15.users.manage_privileged_groups and are otherwise refused with 403 and change nothing; ordinary groups are created, edited and deleted freely.
Per D-07, for an admin without the extra permission the privileged groups appear in the user form's groups field as locked options and labels, and a save that would change a privileged membership anyway is refused with 403, the message users.privileged_group_forbidden and details on groups, with no partial save (no column and no pivot row changes).
Per D-08, User.Groups stays out of every user API payload: only the admin API reads or writes group membership, and the application's TestPhase12Threats and user API parity flows pass unchanged.
The user form's groups field is the single writer of users_groups besides the cleanup of a permanently deleted user or group: there is no groups relation manager, and no bulk action, record action or organisation route changes a membership.
Per D-15 and D-16, the group form's Permissions tab is a permissioneditor in checkbox mode whose options come from golem15_user_frontend_permissions and whose values are stored in user_groups.permissions as a JSON object of code to 1.
Per D-22 and D-20, Organisations has a list and a form (name, slug preset from name with type slug, description, avatar as a fileupload image of 120 by 120) gated by golem15.users.access_users, and a `members` relation manager (hasMany, link and unlink) that sets and clears the user's organisation_id.
Per D-24, the User Groups list shows the users_count column (not sortable), filled by one query.
Per D-29, a User Group can be deleted with the standard form delete; the delete is guarded per D-06 and removes the group's users_groups rows in the same transaction.
Per D-25, the application repository records the plugin checkout's head in one local commit, and the framework tree it builds against contains the tag v0.1.3; the application's full test suite passes, including TestSchemaMatchesPHPSnapshot with one new allow-list entry for golem15_user_frontend_permissions.
This plan publishes nothing: neither sm-user-plugin nor fonoteka.go is pushed. The plugin is pushed once, in plan 05 Task 3, after the security review and only when the framework tag v0.1.3 is on origin, so a published plugin commit never depends on an unpublished framework contract.
Users, User Groups and Organisations are all reachable from the admin navigation item `user`, each side item shown only to an admin holding its permission.
The plugin's changes are additive: no existing exported function, route, payload or shipped migration changes, and no Go module is added.
UI P2 error: On the Users form a validation failure marks the field (422) and a refused privileged-group change shows the forbidden banner with `users.privileged_group_forbidden` and marks `groups`.
UI P4 empty: The User Groups list with no rows shows `groups.list_empty`.
UI P4 error: A refused privileged-group create, re-code or delete shows `groups.privileged_forbidden`: a banner on save and a toast on delete.
UI P4 populated: The User Groups list shows Name, Code, Created and Users, with no checkboxes, bulk actions or preview; rows open the update form.
UI P4 zero-one-many: The User Groups `Users` column shows 0 for a group with no members, not the empty-value dash.
UI P5 empty: The Organisations list with no rows shows `organisation.list_empty`, and an organisation with no members shows `organisation.members_empty`.
UI P5 populated: The Organisations list shows Name, Slug and Created; rows open the update form, which has no preview.
UI P5 partial: The Organisations Members tab shows on update only.
UI P5 zero-one-many: The members unlink confirm uses `:count` and states that the users are not deleted.
path provides
../fonoteka.go/plugins/golem15/user/models/users_group.go UsersGroup pivot model for users_groups (user_id, user_group_id)
path provides contains
../fonoteka.go/plugins/golem15/user/controllers/usergroups_admin_controller.go User Groups admin controller with the D-06 guards, users_count and the checkbox permission editor golem15.user.usergroups
path provides contains
../fonoteka.go/plugins/golem15/user/controllers/organisations_admin_controller.go Organisations admin controller with the members relation manager golem15.user.organisations
path provides
../fonoteka.go/plugins/golem15/user/admin_privileged_test.go TestAdminPrivilegedGroups and TestAdminUserGroupsField
path provides contains
../fonoteka.go/parity/schema_diff_test.go allowedDiffs entry for golem15_user_frontend_permissions golem15_user_frontend_permissions
from to via pattern
../fonoteka.go/plugins/golem15/user/controllers/users_admin_controller.go ../fonoteka.go/plugins/golem15/user/classes/privileged.go AdminRelationLocks returns the privileged group ids for an admin without the extra permission AdminRelationLocks
from to via pattern
../fonoteka.go/plugins/golem15/user/controllers/usergroups_admin_controller.go ../fonoteka.go/plugins/golem15/user/classes/privileged.go FormBeforeCreate, FormBeforeUpdate and FormBeforeDelete compare the stored and the submitted code against the privileged list IsPrivilegedCode
from to via pattern
../fonoteka.go/plugins/golem15/user/admin_navigation.go ../fonoteka.go/plugins/golem15/user/controllers/admin_registry.go side items users, usergroups and organisations point at the three registered controllers golem15.user.organisations
from to via pattern
../fonoteka.go/parity/schema_diff_test.go ../fonoteka.go/plugins/golem15/user/updates/202610040003_create_frontend_permissions.go the allow-list names the Go-only table the migration creates golem15_user_frontend_permissions

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>

@.planning/PROJECT.md @.planning/STATE.md @.planning/phases/12.1-user-plugin-admin-screens/12.1-CONTEXT.md @.planning/phases/12.1-user-plugin-admin-screens/12.1-RESEARCH.md @.planning/phases/12.1-user-plugin-admin-screens/12.1-PATTERNS.md @.planning/phases/12.1-user-plugin-admin-screens/12.1-UI-SPEC.md @.planning/phases/12.1-user-plugin-admin-screens/12.1-02-SUMMARY.md @.planning/phases/12.1-user-plugin-admin-screens/12.1-03-SUMMARY.md @.planning/phases/12-p-ytarium-api-collections-and-albums/12-01-PLAN.md - Framework contracts at v0.1.3: `cabana.FieldRelationContract` (belongsToMany needs `NewPivot`, `ParentForeignKey`, `RelatedForeignKey`), `cabana.RelationLock{IDs, Message}`, `cabana.RelationLockProvider.AdminRelationLocks(ctx, field)`, `cabana.ForbiddenError{Message, Details}`, `cabana.ValidationError`, `cabana.Allows(principal, codes)`, `cabana.TxFromContext`, `cabana.RelationContract` (`Kind: cabana.RelationHasMany`, `ForeignKey`, `Columns`), `cabana.AdminRelationContractProvider`, `cabana.PermissionEditorProvider`, `pact.FormBeforeCreate`, `pact.FormBeforeUpdate`, `pact.FormBeforeDelete`, `pact.FormAfterDelete`, `pact.ListExtendQuery`, `pact.RelationExtendManageQuery`; YAML keys `preset`, `invisible`, `type: permissioneditor` with `mode: checkbox`, `type: fileupload`, `type: relation-manager`; `lagoon.HasBeforeValidate` (`BeforeValidate(tx *gorm.DB) error`); `lagoon.Validate` supports only nullable, required, integer, numeric, between, min, max, in, unique, boolean, email, confirmed, different, mimes (any other token turns a save into a 500). - From plan 03 (final names in 12.1-03-SUMMARY.md): `usersAdminController` (AdminFieldRelations with `organisation`, FormRules, FormVirtualFields, hooks), `controllers.AdminControllers`, the explicit embed list in `admin.go`, `Navigation()` with the side item `users`, the permission code `golem15.users.manage_privileged_groups` (registered, no default role), the config key `golem15.user.privileged_groups` and `classes/privileged.go` with `classes.PermissionManagePrivilegedGroups`, `classes.PrivilegedGroupCodes`, `classes.IsPrivilegedCode`, `classes.PrivilegedGroupIDs` and `classes.IsPrivilegedMember`, the D-30 takeover guard at the top of the users controller's `FormBeforeUpdate` and in its `FormBeforeDelete` (test `TestAdminPrivilegedMember`), `models.PermissionSet`, `classes.FrontendPermissionOptions`, `classes.ForceDeleteCleanup` (already removes a deleted user's users_groups rows), the harness helpers in `admin_harness_test.go`. - Plugin models: `models.UserGroup{ID, Name, Code *string, Description *string, Permissions *string, CreatedAt, UpdatedAt *time.Time}` (no Fillable, no Rules yet), `models.Organisation{ID, Slug, Name, Description *string, CreatedAt, UpdatedAt}` (no Fillable, Rules, MorphName yet), `models.User.OrganisationID *uint`, `models.User.Groups` (many2many users_groups, json "-"). The pivot `users_groups` has columns user_id and user_group_id with the composite primary key named user_group and no foreign keys. - `classes.HasGroupCode(ctx, db, userID, code)` and `classes.UserGroupCodes`: the application's site-admin predicate reads group code `admin` through them (`../fonoteka.go/plugins/golem15/fonoteka/classes/gates.go`). - Config: `compass.Config` has `String`, `Int`, `Bool`, `Has`, `Lookup`, `LoadSection`; plugin defaults live in `config/config.yaml` under the namespace `golem15.user`. - Application: `../fonoteka.go/parity/schema_diff_test.go` (`allowedDiffs`; the existing `user_groups` entry is the wording model; `users` is special-cased and needs no entry; `golem15_user_organisations` is column-diffed, so it gets no new column), `TestSchemaMatchesPHPSnapshot`, `TestUserAPINuxtFlows`, `TestPhase12Threats` (subtest T-12-18). - PHP reference (read-only): `/media/nvme/dev/golem15/fonoteka/plugins/golem15/user` (`controllers/UserGroups.php`, `controllers/usergroups/*`, `models/usergroup/*.yaml`, `models/UserGroup.php`, `controllers/Organisations.php`, `controllers/organisations/*`, `models/organisation/*.yaml`, `models/Organisation.php`, `lang/en/lang.php`, `lang/pl/lang.php`).

Artifacts this phase produces

(This plan's share.)

  • Reused from plan 03, not created or changed here: the config key golem15.user.privileged_groups and, in classes/privileged.go, the constant PermissionManagePrivilegedGroups and PrivilegedGroupCodes, IsPrivilegedCode, PrivilegedGroupIDs, IsPrivilegedMember.
  • Models: models.UsersGroup (pivot); models.UserGroup gains Fillable(), Rules() and the read-only field UsersCount; models.Organisation gains Fillable(), Rules(), MorphName(), AttachRelations(), BeforeValidate(); models.Slugify; models.ParsePermissionSet and PermissionSet.Text helpers.
  • Controllers: golem15.user.usergroups (model name Golem15\User\Models\UserGroup, config dir controllers/usergroups) and golem15.user.organisations (model name Golem15\User\Models\Organisation, config dir controllers/organisations); the users controller gains the groups relation contract and AdminRelationLocks.
  • 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; the groups field in models/user/fields.yaml.
  • Navigation side items usergroups and organisations.
  • Lang keys under golem15.user::lang.group.*, groups.*, organisation.*, plus user.groups, user.empty_groups, users.privileged_group_forbidden.
  • Tests: TestAdminPrivilegedGroups, TestAdminUserGroupsField, TestAdminGroups, TestAdminOrganisations, TestAdminOrganisationMembers.
  • Application: one allowedDiffs entry and the submodule pointer of plugins/golem15/user.

Planner decisions recorded for this plan

  • Config key (D-05 discretion). golem15.user.privileged_groups. Since the plan-check revision the key and classes/privileged.go are 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 groups relation field (PHP's type: relation field) 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.3 lists 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.Permissions stays *string so the exported model does not change; the controller converts through PermissionSet helpers.
  • Unsupported rule tokens. PHP's regex (group code) and alpha_dash (organisation slug) are checked in the controllers' before-hooks and answered as 422, because lagoon.Validate does not know them (RESEARCH Pitfall 2).
  • Deleting an organisation with members. The members' organisation_id and organisation_role are 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.
Task 1: An admin sets a user's groups from the user form; the site-admin group shows locked and cannot be added or removed without the privileged-groups permission D-04 (user-confirmed): the permission code is stored in backend roles; renaming it later means rewriting role rows in every host application. ../fonoteka.go/plugins/golem15/user/models/users_group.go, ../fonoteka.go/plugins/golem15/user/models/user/fields.yaml, ../fonoteka.go/plugins/golem15/user/controllers/users_admin_controller.go, ../fonoteka.go/plugins/golem15/user/controllers/admin_registry.go, ../fonoteka.go/plugins/golem15/user/lang/en/lang.yaml, ../fonoteka.go/plugins/golem15/user/lang/pl/lang.yaml, ../fonoteka.go/plugins/golem15/user/admin_privileged_test.go .planning/phases/12.1-user-plugin-admin-screens/12.1-RESEARCH.md ("T-12-18: Every users_groups Write Path"; Security Domain), .planning/phases/12.1-user-plugin-admin-screens/12.1-UI-SPEC.md (S6; "Plugin screens": Users form groups and forbidden save), .planning/phases/12.1-user-plugin-admin-screens/12.1-PATTERNS.md (privileged.go section; field relations in the users controller section), .planning/phases/12-p-ytarium-api-collections-and-albums/12-01-PLAN.md (threat T-12-18 as recorded), .planning/phases/12.1-user-plugin-admin-screens/12.1-03-SUMMARY.md, ../fonoteka.go/plugins/golem15/user/controllers/users_admin_controller.go, ../fonoteka.go/plugins/golem15/user/classes/user_groups.go, ../fonoteka.go/plugins/golem15/user/classes/privileged.go (from plan 03; reused unchanged), ../fonoteka.go/plugins/golem15/user/models/user.go, ../fonoteka.go/plugins/golem15/user/models/user_group.go, ../fonoteka.go/plugins/golem15/user/updates/202610020001_create_user_groups.go, ../fonoteka.go/plugins/golem15/user/config/config.yaml, ../fonoteka.go/plugins/golem15/user/plugin.go (how config is read), ../fonoteka.go/plugins/golem15/user/admin_harness_test.go, ../fonoteka.go/plugins/golem15/fonoteka/models/album_artist.go (a pivot model), ../fonoteka.go/plugins/golem15/fonoteka/classes/gates.go (the site-admin predicate), modules/cabana/relation_field.go (RelationLockProvider, checkRelationLocks), docs/backend/relation-manager.md (locked options), modules/compass/config.go Per D-04, D-05, D-07, D-08 and D-30; RESEARCH "T-12-18" path 1; UI-SPEC S6. Repo: sm-user-plugin; the field, the pivot model and the guard go into one commit.

(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.

Task 2: An admin with the groups permission lists, creates, edits and deletes user groups with their frontend permissions; privileged codes are protected Plugin controller, YAML and additive model methods on the tagged framework contract. ../fonoteka.go/plugins/golem15/user/models/user_group.go, ../fonoteka.go/plugins/golem15/user/models/permission_set.go, ../fonoteka.go/plugins/golem15/user/models/usergroup/fields.yaml, ../fonoteka.go/plugins/golem15/user/models/usergroup/columns.yaml, ../fonoteka.go/plugins/golem15/user/controllers/usergroups_admin_controller.go, ../fonoteka.go/plugins/golem15/user/controllers/admin_registry.go, ../fonoteka.go/plugins/golem15/user/controllers/usergroups/config_list.yaml, ../fonoteka.go/plugins/golem15/user/controllers/usergroups/config_form.yaml, ../fonoteka.go/plugins/golem15/user/admin.go, ../fonoteka.go/plugins/golem15/user/admin_navigation.go, ../fonoteka.go/plugins/golem15/user/lang/en/lang.yaml, ../fonoteka.go/plugins/golem15/user/lang/pl/lang.yaml, ../fonoteka.go/plugins/golem15/user/admin_groups_test.go, ../fonoteka.go/plugins/golem15/user/admin_privileged_test.go .planning/phases/12.1-user-plugin-admin-screens/12.1-UI-SPEC.md ("Plugin screens": User Groups), .planning/phases/12.1-user-plugin-admin-screens/12.1-RESEARCH.md ("PHP Screen Inventory" User Groups; gaps G9 and G10; "T-12-18" paths 3 and 4; Pitfall 2; Open Question 3, resolved by D-29), .planning/phases/12.1-user-plugin-admin-screens/12.1-PATTERNS.md (usergroups controller section; plugin YAML), ../fonoteka.go/plugins/golem15/user/models/user_group.go, ../fonoteka.go/plugins/golem15/user/models/permission_set.go, ../fonoteka.go/plugins/golem15/user/classes/permissions.go (FrontendPermissionOptions), ../fonoteka.go/plugins/golem15/user/classes/privileged.go, ../fonoteka.go/plugins/golem15/user/controllers/users_admin_controller.go, ../fonoteka.go/plugins/golem15/user/admin.go, ../fonoteka.go/plugins/golem15/user/admin_navigation.go, ../fonoteka.go/plugins/golem15/fonoteka/controllers/collections_admin_controller.go (hooks reading the transaction and the principal), ../fonoteka.go/plugins/golem15/fonoteka/controllers/collections/config_form.yaml, docs/backend/forms.md (permission editor, preset), docs/backend/lists-and-filters.md, modules/lagoon/validate.go, /media/nvme/dev/golem15/fonoteka/plugins/golem15/user/controllers/UserGroups.php, /media/nvme/dev/golem15/fonoteka/plugins/golem15/user/controllers/usergroups/config_list.yaml and config_form.yaml, /media/nvme/dev/golem15/fonoteka/plugins/golem15/user/models/usergroup/fields.yaml and columns.yaml, /media/nvme/dev/golem15/fonoteka/plugins/golem15/user/models/UserGroup.php Per D-06, D-15, D-16, D-24 and D-29; UI-SPEC "User Groups". Repo: sm-user-plugin.

(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.

Task 3: An admin lists, creates and edits organisations with an avatar and manages each organisation's members Plugin controller, YAML and additive model methods; the shared organisations table gets no new column. ../fonoteka.go/plugins/golem15/user/models/organisation.go, ../fonoteka.go/plugins/golem15/user/models/slug.go, ../fonoteka.go/plugins/golem15/user/models/organisation/fields.yaml, ../fonoteka.go/plugins/golem15/user/models/organisation/columns.yaml, ../fonoteka.go/plugins/golem15/user/controllers/organisations_admin_controller.go, ../fonoteka.go/plugins/golem15/user/controllers/admin_registry.go, ../fonoteka.go/plugins/golem15/user/controllers/organisations/config_list.yaml, ../fonoteka.go/plugins/golem15/user/controllers/organisations/config_form.yaml, ../fonoteka.go/plugins/golem15/user/controllers/organisations/config_relation.yaml, ../fonoteka.go/plugins/golem15/user/admin.go, ../fonoteka.go/plugins/golem15/user/admin_navigation.go, ../fonoteka.go/plugins/golem15/user/lang/en/lang.yaml, ../fonoteka.go/plugins/golem15/user/lang/pl/lang.yaml, ../fonoteka.go/plugins/golem15/user/README.md, ../fonoteka.go/plugins/golem15/user/admin_organisations_test.go .planning/phases/12.1-user-plugin-admin-screens/12.1-UI-SPEC.md ("Plugin screens": Organisations), .planning/phases/12.1-user-plugin-admin-screens/12.1-RESEARCH.md ("PHP Screen Inventory" Organisations; "Relation fields and relation managers"; "File upload (D-20)"; gap G10; "T-12-18" path 6), .planning/phases/12.1-user-plugin-admin-screens/12.1-PATTERNS.md (organisations controller section; config_relation.yaml), ../fonoteka.go/plugins/golem15/user/models/organisation.go, ../fonoteka.go/plugins/golem15/user/models/user.go, ../fonoteka.go/plugins/golem15/user/updates/10_organisations.go, ../fonoteka.go/plugins/golem15/user/updates/organisations_test.go, ../fonoteka.go/plugins/golem15/user/admin.go, ../fonoteka.go/plugins/golem15/user/README.md, ../fonoteka.go/plugins/golem15/fonoteka/controllers/collections_admin_controller.go (AdminRelationContracts, RelationExtendManageQuery failing closed), ../fonoteka.go/plugins/golem15/fonoteka/controllers/collections/config_relation.yaml, ../fonoteka.go/plugins/golem15/feedback/models/submission.go (MorphName, AttachRelations), modules/cabana/relation.go (RelationContract, hasMany link and unlink), docs/backend/relation-manager.md, docs/backend/forms.md (file uploads, preset), /media/nvme/dev/golem15/fonoteka/plugins/golem15/user/controllers/Organisations.php, /media/nvme/dev/golem15/fonoteka/plugins/golem15/user/controllers/organisations/config_list.yaml, config_form.yaml and config_relation.yaml, /media/nvme/dev/golem15/fonoteka/plugins/golem15/user/models/organisation/fields.yaml and columns.yaml, /media/nvme/dev/golem15/fonoteka/plugins/golem15/user/models/Organisation.php Per D-22 and D-20; UI-SPEC "Organisations". Repo: sm-user-plugin.

(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.

Task 4: The application repository carries the plugin's admin screens in one local commit: the schema allow-list names the new table and the pointer records the plugin head, on a framework that contains v0.1.3; nothing is pushed One local commit in fonoteka.go (the allow-list entry and the submodule pointer). Nothing is published here: the push of sm-user-plugin, which is the costly step, is plan 05 Task 3. `git -C ../fonoteka.go/plugins/golem15/user status --short` prints nothing (every plugin change of plans 03 and 04 is committed), and `git merge-base --is-ancestor v0.1.3 HEAD` succeeds in summercms.go. ../fonoteka.go/parity/schema_diff_test.go, ../fonoteka.go/plugins/golem15/user .planning/phases/12.1-user-plugin-admin-screens/12.1-RESEARCH.md ("Parity schema allow-list"; Pitfall 13), ../fonoteka.go/parity/schema_diff_test.go, ../fonoteka.go/.gitmodules, ../fonoteka.go/go.mod, .planning/phases/12-p-ytarium-api-collections-and-albums/12-01-PLAN.md (how the user plugin was pushed and its pointer bumped), .planning/phases/12.1-user-plugin-admin-screens/12.1-03-SUMMARY.md Per D-25 and RESEARCH Pitfall 13. Repo: fonoteka.go (one local commit). Nothing is pushed by this task, neither sm-user-plugin nor fonoteka.go. Before anything else, read the plugin's remote head with `git -C ../fonoteka.go/plugins/golem15/user ls-remote origin refs/heads/master` and note the sha for the summary.

(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 ./... &amp;&amp; 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>
- `go -C ../fonoteka.go vet ./plugins/golem15/user/... && go -C ../fonoteka.go test ./plugins/golem15/user/... -count=1` green after every commit in the plugin checkout. - `go -C ../fonoteka.go vet ./... && go -C ../fonoteka.go test ./... -count=1` green at the application commit of Task 4. - `go vet ./... && go test ./... -count=1` still green in summercms.go (no framework change in this plan). - The application's pointer equals the plugin head, and nothing was pushed: the plugin's remote head is the sha it was before the plan.

<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 need golem15.users.manage_privileged_groups on 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>
Create `.planning/phases/12.1-user-plugin-admin-screens/12.1-04-SUMMARY.md` when done. Record the commit shas of the three repositories, the plugin's remote head before and after, the pending steps in order ("push sm-user-plugin" in plan 05 Task 3, then "push fonoteka.go") and the measured run times.