- 12.1-SECURITY-REVIEW.md: every threat T-12.1-01 to T-12.1-40 and T-12.1-SC with its mitigation, test and observed result; T-12-18 revisited; the D-30 guard and its boundary; the eleven handed-over items; five findings that need a decision - 12.1-VALIDATION.md: per-task map with real task ids and measured run times, signed off - deferred-items.md: older framework files that name an application
40 KiB
phase, reviewed, reviewer, threats_open, findings_for_decision, gate, removal_harness
| phase | reviewed | reviewer | threats_open | findings_for_decision | gate | removal_harness |
|---|---|---|---|---|---|---|
| 12.1 | 2026-10-05 | gsd-executor, plan 12.1-05 (self-performed code-and-test review of plans 12.1-01 to 12.1-05; no reviewer agent could be spawned from the executor's runtime, per the 08-10 and Phase 12 precedent). The orchestrator may additionally run /gsd-secure-phase 12.1. | 0 | 5 | scripts/check-phase12.1.sh --all | scripts/check-phase12.1.sh --removal |
Phase 12.1 Security Review
This is a code-and-test review of every threat in the registers of plans 12.1-01 to 12.1-05: T-12.1-01 to T-12.1-40 and T-12.1-SC. Severity and disposition are copied from the originating plan. T-12.1-SC is declared by every plan and is listed once. The Phase 12 threat T-12-18 (the user groups table), which this phase revisits, is cited by its original id and has its own section.
threats_open: 0 counts the registered threats: each is mitigated with a passing test or accepted with its rationale. It does not mean nothing is left to decide. findings_for_decision: 5 counts review findings that are not vulnerabilities at the severity of this register but need the owner's decision; they are listed under "Findings that need a decision" and are not silently closed.
A high or critical threat counts as mitigated only when its named test fails with the protection removed. scripts/check-phase12.1.sh --removal does this: it refuses a file with uncommitted changes, applies an anchor-exact mutation, runs the named test, requires it to fail on an assertion (a build failure does not count), and restores the file byte for byte, checked with cmp. Results are under "Removal checks".
Repositories: summercms.go (framework: modules/cabana, modules/pact, admin/), sm-user-plugin (the user plugin, mounted in the application workspace at plugins/golem15/user). Framework tests are in modules/cabana; plugin tests run inside the application workspace. Gate stages are modes of scripts/check-phase12.1.sh.
Threat register
| Threat | Category | Component | Severity | Disposition | Production mitigation | Test or gate stage | Observed result | Residual risk |
|---|---|---|---|---|---|---|---|---|
| T-12.1-01 | Elevation of Privilege | bulk action ids (IDOR) | high | mitigate | modules/cabana/crud.go CRUDService.BulkAction resolves ids with lockScoped (the controller's list scope, FOR UPDATE) inside the action's transaction and answers partialSelection (409) unless every id resolved; plugin code receives records, never ids |
TestPhase121Threats/T-12.1-01, TestBulkActionScope, TestBulkActionPartial, TestBulkActionAbsent; stage --security |
pass; removal checks RC-01 (scope) and RC-02 (partial selection) fail the subtest | None known |
| T-12.1-02 | Elevation of Privilege | running an action without its permission, or an undeclared action | high | mitigate | modules/cabana/actions.go bulkActionOf and recordActionOf answer 404 unless the action is declared in YAML and registered; allowAction checks the action's own permissions (403); the list schema and meta.actions are filtered per principal |
TestPhase121Threats/T-12.1-02, TestBulkActionPermissions, TestBulkActionUndeclared, TestRecordActionPermissions, TestListSchemaBulkActionsFiltered |
pass; removal check RC-03 fails the subtest | None known |
| T-12.1-03 | Tampering | CSRF on the two new POST routes | high | mitigate | modules/cabana/http.go mounts both routes behind requireAjax; a cookie request without X-Requested-With is refused |
TestPhase121Threats/T-12.1-03, TestBulkActionCSRF, TestPhase10CSRF (route count) |
pass; removal checks RC-04 and RC-05 fail the subtest | None known |
| T-12.1-04 | Elevation of Privilege | record action on a record outside the form scope or in the wrong state | high | mitigate | CRUDService.RecordAction loads the record with loadRecord (form scope, row lock) and answers 404 outside it; Applies is asked again inside the transaction and a false answer is 409 |
TestPhase121Threats/T-12.1-04, TestRecordActionScope, TestRecordActionApplies |
pass; removal checks RC-06 (scope) and RC-07 (Applies) fail the subtest | None known |
| T-12.1-05 | Tampering | half-applied bulk mutation | medium | mitigate | One transaction per bulk action (CRUDService.transaction); any error from Run rolls every row back |
TestPhase121Threats/T-12.1-05, TestBulkActionRollback, TestBulkActionConcurrent |
pass | None known |
| T-12.1-06 | Information Disclosure | internal error text in 403 or 500 bodies | medium | mitigate | actionFailure and lifecycleFailure log the cause and answer the fixed 500 body; only *cabana.ForbiddenError and *cabana.ValidationError carry text, localized |
TestPhase121Threats/T-12.1-06, TestForbiddenFromEveryHook, TestRecordActionAppliesError, TestRowStateHookError |
pass | A plugin that puts internal detail into a ForbiddenError message shows it |
| T-12.1-07 | Tampering | plugin-supplied row state or label used as markup or CSS class | low | mitigate | The server sends only the three known states (ListMeta.RowStates); the SPA maps a state to a fixed class table and renders labels as text (RowStateBadges.vue, DataTable.vue statesOf) |
TestPhase121Threats/T-12.1-07, TestRowStateUnknownDropped; RowStateBadges.test.ts and DataTable.test.ts (the backstop case "a row state outside the fixed set renders no badge and no class") |
pass | None known |
| T-12.1-08 | Repudiation | bulk and record actions leave no trace | low | mitigate | One slog.Info line per run with controller, action, admin id and affected count; no record contents |
TestPhase121Threats/T-12.1-08 |
pass | The line is an application log, not a tamper-proof audit trail |
| T-12.1-09 | Tampering | mass assignment through virtual or password fields | high | mitigate | BindWritableFields (crud.go) never binds a field listed in FormVirtualFields; liftVirtualValues takes virtual values out of the body, honours the field's context and hands them to hooks on the transaction context only |
TestPhase121Threats/T-12.1-09, TestVirtualFieldsContext, TestVirtualFieldsNested, TestPasswordFieldNeverProjected |
pass; removal checks RC-08 (context) and RC-09 (binding) fail | None known |
| T-12.1-10 | Information Disclosure | password value in a response, log or URL | high | mitigate | A type: password field must be virtual (boot error otherwise, extension.go checkVirtualFields), so it is never bound or projected; projectRecord also skips protected keys; the SPA sends it only in the save body |
TestPhase121Threats/T-12.1-10, TestPasswordFieldNeverProjected, TestPhase121BootErrors; PasswordField.test.ts, FormView.test.ts |
pass; removal check RC-10 fails the subtest | The SPA shows whatever a record response carries for the field: the guarantee is the server's (observation OB-1) |
| T-12.1-11 | Elevation of Privilege | protected foreign key written through a relation field | high | mitigate | relation_field.go: a belongsTo field on a protected key is read-only unless its contract sets WritableForeignKey; the submitted id still passes the scoped options query |
TestPhase121Threats/T-12.1-11, TestWritableForeignKeyOptIn, TestWritableForeignKeyScope |
pass; removal check RC-11 fails the subtest | None known |
| T-12.1-12 | Elevation of Privilege | locked relation ids changed by a crafted request (mechanism behind T-12-18) | high | mitigate | crud.go calls checkRelationLocks inside the save transaction, before any row write, on create and update; the locked subset before and after must be equal, else 403 and rollback |
TestPhase121Threats/T-12.1-12, TestRelationLockCreate, TestRelationLockUpdate, TestRelationLockBelongsTo, TestRelationLockAbsentField; RelationField.test.ts (backstop) |
pass; removal check RC-12 fails the subtest | The lock covers form saves only; a relation manager on the same relation is not covered (H-01, now documented) |
| T-12.1-13 | Tampering | permission code injection or out-of-range value | high | mitigate | field_permission.go: a submitted code must be offered by the controller for this request (422), a value must be in the mode's set (422), a locked code must keep its stored value (403); stored codes that are not offered are kept untouched |
TestPhase121Threats/T-12.1-13, TestPermissionEditorModes, TestPermissionEditorUnknownCode, TestPermissionEditorLocked, TestPermissionEditorKeepsUnoffered; PermissionEditorField.test.ts (backstop) |
pass; removal checks RC-13 (code) and RC-14 (lock) fail the subtest | A locked code stored with a value outside the mode's set blocks every save of that record (H-02, now documented) |
| T-12.1-14 | Tampering | a preview-only field written by a crafted body | medium | mitigate | A field whose context is preview only is never writable: contextAllows leaves it out of the binding for create and update |
TestPhase121Threats/T-12.1-14, TestPreviewFieldNeverWritten |
pass | None known |
| T-12.1-15 | Elevation of Privilege | script or markup through the status hint or callout classes | medium | mitigate | Partials are served as a node tree with an attribute allowlist (partial_render.go); the SPA builds elements from nodes and has no raw-HTML sink |
TestPhase121Threats/T-12.1-15, TestPreviewHeaderPartialScoped; check-phase12.1.sh --hygiene (no v-html or innerHTML in admin/src) |
pass | None known |
| T-12.1-16 | Tampering | open redirect or foreign route through preview/:id in plugin YAML |
medium | mitigate | admin/src/app/winterUrl.ts mapWinterUrl emits only the current controller's list, create, record and preview routes, digits only, else the list |
winterUrl.test.ts (the backstop case and the foreign-controller and non-numeric-id cases), router.test.ts ("preview route"); stage --spa |
pass | None known |
| T-12.1-17 | Tampering | an unreviewed contract published by the tag | high | mitigate | Plan 02 Task 5 was a blocking decision checkpoint listing the published names and six contract properties; the owner chose tag-local. The tag v0.1.3 is annotated, local, on df5cace, where every plan gate passed |
check-phase12.1.sh --openapi (TestPhase09ContractInventory, TestPhase10OpenAPIConformance), --hygiene (the tag exists); the tag is not on origin (git ls-remote --tags origin v0.1.3 is empty) |
pass; no removal row (NR-01) | The tag is still local and can be re-cut; nothing depends on it publicly |
| T-12.1-18 | Elevation of Privilege | Users screens and actions without the users permission | high | mitigate | usersAdminController.RequiredPermissions (golem15.users.access_users), usergroupsAdminController (access_groups), organisationsAdminController (access_users); every bulk and record action names its permission as well |
plugin TestPhase121Threats/T-12.1-18 (every route of the three screens 403 without the permission), TestAdminUsersTracer, TestAdminGroups, TestAdminOrganisations, TestAdminRegistration |
pass; removal check RC-15 fails the subtest | None known |
| T-12.1-19 | Information Disclosure | password hash, plain password or reset codes in admin responses | high | mitigate | The user form's password fields are virtual; no column of models/user/columns.yaml or fields.yaml is a secret; relation contracts list name and email only |
plugin TestPhase121Threats/T-12.1-19 (list, show, partial, update, 422, create, bulk and record action bodies scanned for the plain text, the hash and both codes) |
pass; removal check RC-16 (a password column added to the list) fails the subtest |
None known |
| T-12.1-20 | Tampering | mass assignment of is_activated, permissions or organisation_id through the user form | high | mitigate | Only declared form fields are bound; is_activated, organisation_id and permissions are protected fill keys; the organisation is written through its relation field, permissions through the validated editor; FormBeforeCreate forces is_activated false |
plugin TestPhase121Threats/T-12.1-20, TestAdminUserFormFields |
pass; removal check RC-17 (a protected column offered as a form field) fails the subtest | None known |
| T-12.1-21 | Spoofing | tokens issued before an admin password reset stay valid | medium | mitigate | FormBeforeUpdate stamps tokens_valid_after when a password was submitted |
plugin TestPhase121Threats/T-12.1-21, TestAdminUserPassword |
pass | None known |
| T-12.1-22 | Denial of Service | invitation mail abuse | low | mitigate | One invitation per create, only with send_invite true in the body, sent after the commit (lagoon.AfterCommit); none on update |
plugin TestPhase121Threats/T-12.1-22, TestAdminUserInvite |
pass | An admin with the users permission can create many users; there is no per-admin rate limit |
| T-12.1-23 | Tampering | permanent delete leaves orphans or half-deleted users | medium | mitigate | FormAfterDelete runs classes.ForceDeleteCleanup (throttle rows, memberships, attachments) and the row delete in the delete's transaction; blobs go after the commit |
plugin TestPhase121Threats/T-12.1-23, TestAdminUserForceDelete, TestAdminActions |
pass | A blob removal that fails after the commit is logged and leaves the blob |
| T-12.1-24 | Information Disclosure | last_seen, permissions, timestamps or groups leak into user API payloads | high | mitigate | models.User: LastSeen, Permissions, CreatedAt, UpdatedAt and Groups are json:"-"; the user API builds its payload field by field (groups an empty list, permissions null) |
plugin TestPhase121Threats/T-12.1-24 (every user API body, and the marshalled model), TestLastSeen, TestAdminFieldsNeverMarshal; application TestUserAPINuxtFlows, TestParityCorpus; stage --app |
pass; removal checks RC-18 and RC-27 fail the subtest (after the subtest was extended, see FX-3) | None known |
| T-12.1-25 | Denial of Service | last_seen write fails or amplifies writes on the auth path | low | mitigate | classes.TouchLastSeen writes at most once per five minutes; login and refresh log and ignore its error |
plugin TestPhase121Threats/T-12.1-25, TestLastSeen, TestTouchLastSeen |
pass | None known |
| T-12.1-26 | Elevation of Privilege | a deactivated site admin is re-enabled by restore or activate | medium | accept | PHP behaves the same (the membership survives a soft delete) and the action needs golem15.users.access_users (RESEARCH T-12-18 path 8). | none (accepted) | accepted | An administrator with the users permission can re-enable a deactivated member of a privileged group, and can deactivate or ban one (see "D-30: boundary") |
| T-12.1-27 | Elevation of Privilege | a wrong port of the permission merge grants a frontend permission | medium | mitigate | classes/permissions.go MergedPermissions (highest group value, user value overrides) and HasPermission (exactly 1; wildcards as Winter) |
plugin TestPhase121Threats/T-12.1-27, TestMergedPermissions (a table of stored shapes, group sets and wildcards), TestPermissionSetScan |
pass | Boolean values in stored JSON are not grants here, unlike the API-token reader (H-05) |
| 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 | usersAdminController.AdminRelationLocks locks the ids of the groups whose code is in golem15.user.privileged_groups for an administrator without golem15.users.manage_privileged_groups; the framework refuses a create or update that changes the locked subset with 403 before any row is written |
plugin TestPhase121Threats/T-12.1-28 (add, remove and create matrix with and without the permission, a crafted body that also changes other fields, classes.HasGroupCode before and after), TestAdminPrivilegedGroups, TestAdminUserGroupsField |
pass; removal check RC-19 fails the subtest | None known |
| T-12.1-29 | Elevation of Privilege | creating, renaming to or from, or deleting a privileged group code | high | mitigate | usergroupsAdminController FormBeforeCreate, FormBeforeUpdate (stored code read through the write's transaction) and FormBeforeDelete call requirePrivilegedPermission |
plugin TestPhase121Threats/T-12.1-29, TestAdminPrivilegedGroups, TestAdminGroupsEdge, TestAdminGroupsControllerGuards |
pass; removal check RC-20 fails the subtest | Validation answers before the guard (H-09) |
| T-12.1-30 | Elevation of Privilege | a second, unguarded writer of users_groups | high | mitigate | The groups field is the only writer besides the two deletes; no relation manager for groups exists on any controller; bulk and record actions and the organisation members manager do not touch the table | plugin TestPhase121Threats/T-12.1-30 (the table byte-identical before and after every bulk action, record action, members link and unlink; no groups relation route answers), TestAdminOrganisationMembers |
pass; removal check RC-21 (a membership write added to an action) fails the subtest | None known |
| T-12.1-31 | Tampering | privileged list cached, mis-compared or bypassed through a NULL code | medium | mitigate | classes/privileged.go: the list is read from config on every call, compared case-sensitively, blank entries dropped; a group without a code is never privileged |
plugin TestPhase121Threats/T-12.1-31, TestPrivilegedGroupCodes, TestIsPrivilegedCode, TestIsPrivilegedMember |
pass | An application that sets the list to empty has no privileged group |
| 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). |
none (accepted) | accepted | The role column outlives the membership (H-06); a member of another organisation cannot be linked until released |
| 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 | Nothing was pushed by plans 03 to 05; the pointer bumps are local commits; the publication step is T-12.1-40 | check-phase12.1.sh --app (the recorded pointer equals the plugin head; both trees clean), the plans' verify commands |
pass; the plugin's remote head is unchanged (0fe5b91) |
The application repository must not be pushed before the plugin |
| T-12.1-34 | Information Disclosure | group membership reaches a user API payload | high | mitigate | User.Groups is json:"-"; the user API's payload builder writes groups as an empty list |
plugin TestPhase121Threats/T-12.1-34, application TestPhase12Threats (subtest T-12-18), TestHiddenNeverMarshals |
pass; removal check RC-22 fails the subtest | None known |
| T-12.1-35 | Repudiation | a gate that passes without measuring (zero tests, skips, a filter that matches nothing) | medium | mitigate | detect in the gate refuses a failure, a build failure, a skip, zero tests, "no tests to run", non-JSON output and a required name without a passing top-level test |
check-phase12.1.sh --self-test (planted inputs for each) |
pass; a plugin test renamed by hand made --security refuse with "missing named test" |
The whole-module runs of --go and --app accept packages without tests |
| T-12.1-36 | Information Disclosure | a consuming-application name in framework fixtures, tests, docs or gate output | low | mitigate | check-phase12.1.sh --hygiene scans the phase's framework files, every module README, the root README, docs and admin/src; gate output about the application workspace is masked |
check-phase12.1.sh --hygiene, --self-test (four planted spellings found, the mask checked) |
pass | Thirteen older Go files in other modules name the application; they predate this phase (OB-3) |
| T-12.1-37 | Tampering | a mitigation silently removed by a later change | medium | mitigate | TestPhase121Threats in both repositories, the required name prefixes of --security, and the removal harness |
check-phase12.1.sh --security, --removal, --evidence |
pass; 27 removal rows fail as required | The removal stage is not part of --all; it must be run on purpose |
| T-12.1-38 | Elevation of Privilege | takeover of a privileged-group member's account: an admin holding only golem15.users.access_users changes the member's email or sets a password | critical | mitigate | usersAdminController.FormBeforeUpdate starts with guardCredentials: a changed email or a submitted password on a member of a privileged group needs golem15.users.manage_privileged_groups (guardPrivilegedMember, classes.IsPrivilegedMember through the write's transaction); refusal is a 403 with details and nothing is written |
plugin TestPhase121Threats/T-12.1-38, TestAdminPrivilegedMember, TestAdminUsersEdge (superuser; unreadable membership is the opaque 500), TestAdminUsersControllerGuards, TestIsPrivilegedMember |
pass; removal checks RC-23 (the guard) and RC-25 (the clean statement) fail | See "D-30: boundary" |
| T-12.1-39 | Tampering | permanent delete of a privileged-group member, by the form button or inside a bulk delete | high | mitigate | usersAdminController.FormBeforeDelete calls guardPrivilegedMember; the framework calls it per record inside the bulk delete's transaction, so one refused record rolls the selection back |
plugin TestPhase121Threats/T-12.1-39, TestAdminPrivilegedMember, TestAdminUsersEdge |
pass; removal check RC-24 fails the subtest | See "D-30: boundary" |
| T-12.1-40 | Tampering | the shared plugin is published before the security review, or while the framework contract it builds on is not published | medium | mitigate | One push point in the phase, after the gate and this review, and only when the framework tag is on origin and no framework production code changed after it | check-phase12.1.sh --app (pointer and clean trees); the four conditions are recorded in 12.1-05-SUMMARY.md |
pass; condition (c) does not hold, so nothing was pushed and the push is recorded as pending | The push is a manual step the owner takes after pushing the framework tag |
| T-12.1-SC | Tampering | npm/pip/cargo installs | high | mitigate | No package was installed and no manifest changed in the phase | check-phase12.1.sh --hygiene (go.mod, go.sum, admin/package.json and admin/package-lock.json are unchanged since v0.1.3 and have no local change) |
pass; no removal row (NR-02) | None known |
Removal checks
Each row is one anchor-exact mutation from scripts/check-phase12.1.sh --removal. The anchor occurs exactly once in its file; the test must fail on an assertion; the file is restored and compared with cmp. All 27 rows were run on 2026-10-05 and all failed as required; both working trees were clean afterwards. RC-18 first survived: see FX-3.
| Check | Threat | File | What the mutation removes | Test that must fail | Result |
|---|---|---|---|---|---|
| RC-01 | T-12.1-01 | modules/cabana/crud.go |
the list scope inside lockScoped |
TestPhase121Threats/T-12.1-01 |
fails as required |
| RC-02 | T-12.1-01 | modules/cabana/crud.go |
the partial-selection refusal in BulkAction |
TestPhase121Threats/T-12.1-01 |
fails as required |
| RC-03 | T-12.1-02 | modules/cabana/actions.go |
the action's own permission check (allowAction) |
TestPhase121Threats/T-12.1-02 |
fails as required |
| RC-04 | T-12.1-03 | modules/cabana/http.go |
requireAjax on the bulk action route |
TestPhase121Threats/T-12.1-03 |
fails as required |
| RC-05 | T-12.1-03 | modules/cabana/http.go |
requireAjax on the record action route |
TestPhase121Threats/T-12.1-03 |
fails as required |
| RC-06 | T-12.1-04 | modules/cabana/crud.go |
the form scope of loadRecord in RecordAction |
TestPhase121Threats/T-12.1-04 |
fails as required |
| RC-07 | T-12.1-04 | modules/cabana/crud.go |
the Applies refusal in RecordAction |
TestPhase121Threats/T-12.1-04 |
fails as required |
| RC-08 | T-12.1-09 | modules/cabana/crud.go |
the context check when virtual values are lifted | TestVirtualFieldsContext, TestVirtualFieldsSmoke |
fails as required |
| RC-09 | T-12.1-09 | modules/cabana/crud.go |
the virtual-field skip in BindWritableFields |
TestPhase121Threats/T-12.1-09 |
fails as required (the controller no longer boots) |
| RC-10 | T-12.1-10 | modules/cabana/crud.go |
the same skip, seen from the password field | TestPhase121Threats/T-12.1-10 |
fails as required (the controller no longer boots) |
| RC-11 | T-12.1-11 | modules/cabana/relation_field.go |
the read-only rule of a protected foreign key without the opt-in | TestPhase121Threats/T-12.1-11 |
fails as required |
| RC-12 | T-12.1-12 | modules/cabana/crud.go |
the relations handed to checkRelationLocks |
TestPhase121Threats/T-12.1-12 |
fails as required |
| RC-13 | T-12.1-13 | modules/cabana/field_permission.go |
the offered-code check | TestPhase121Threats/T-12.1-13 |
fails as required |
| RC-14 | T-12.1-13 | modules/cabana/field_permission.go |
the locked-code check | TestPhase121Threats/T-12.1-13 |
fails as required |
| RC-15 | T-12.1-18 | controllers/users_admin_controller.go |
the Users controller's required permission | plugin TestPhase121Threats/T-12.1-18 |
fails as required |
| RC-16 | T-12.1-19 | models/user/columns.yaml |
the absence of a password list column |
plugin TestPhase121Threats/T-12.1-19 |
fails as required |
| RC-17 | T-12.1-20 | models/user/fields.yaml |
the absence of organisation_role from the form |
plugin TestPhase121Threats/T-12.1-20 |
fails as required |
| RC-18 | T-12.1-24 | models/user.go |
json:"-" on LastSeen |
plugin TestPhase121Threats/T-12.1-24 |
fails as required (survived before FX-3) |
| RC-19 | T-12.1-28 | controllers/users_admin_controller.go |
the locked ids returned by AdminRelationLocks |
plugin TestPhase121Threats/T-12.1-28 |
fails as required |
| RC-20 | T-12.1-29 | controllers/usergroups_admin_controller.go |
the permission check of requirePrivilegedPermission |
plugin TestPhase121Threats/T-12.1-29 |
fails as required |
| RC-21 | T-12.1-30 | classes/admin_actions.go |
the rule that no action writes users_groups (a delete is added to ActivateUsers) |
plugin TestPhase121Threats/T-12.1-30 |
fails as required |
| RC-22 | T-12.1-34 | models/user.go |
json:"-" on Groups |
plugin TestPhase121Threats/T-12.1-34 |
fails as required |
| RC-23 | T-12.1-38 | controllers/users_admin_controller.go |
the guardCredentials call at the top of FormBeforeUpdate (D-30) |
plugin TestPhase121Threats/T-12.1-38 |
fails as required |
| RC-24 | T-12.1-39 | controllers/users_admin_controller.go |
the guardPrivilegedMember call in FormBeforeDelete (D-30) |
plugin TestPhase121Threats/T-12.1-39 |
fails as required |
| RC-25 | T-12.1-38 | classes/admin_actions.go |
the clean statement of fresh (fix f496959) |
plugin TestIsPrivilegedMember |
fails as required |
| RC-26 | D-23 | models/user.go |
the group filter value check (fix 43fabfa) |
plugin TestAdminUsersGroupFilterValue |
fails as required |
| RC-27 | T-12.1-24 | models/user.go |
json:"-" on Permissions |
plugin TestPhase121Threats/T-12.1-24 |
fails as required |
High threats without a removal row:
| Waiver | Threat | Reason |
|---|---|---|
| NR-01 | T-12.1-17 | A process control (the owner's decision checkpoint before the tag); there is no line of code to remove. The contract inventory and conformance tests run in --openapi. |
| NR-02 | T-12.1-SC | A process control (no install); --hygiene compares the four dependency manifests with the tag. |
Where a protection has two layers, one mutation removes one of them. RC-09 and RC-10 remove the binding skip, after which the controller refuses to boot: the second layer (a password field must be virtual; projectRecord skips protected keys) fails closed. The review did not build a mutation that removes both layers at once.
T-12-18 revisited
Phase 12 recorded T-12-18 as "no route writes users_groups; Groups is never serialized; the site-admin predicate reads group codes server-side only". This phase adds writers. The eight paths of 12.1-RESEARCH.md ("T-12-18: Every users_groups Write Path"):
| Path | What it writes | Guard or disposition | Evidence |
|---|---|---|---|
1. User form groups field (create and update) |
replaces the user's pivot rows | AdminRelationLocks plus the framework's checkRelationLocks: the privileged subset must not change without golem15.users.manage_privileged_groups; 403 and rollback |
TestPhase121Threats/T-12.1-28, TestAdminPrivilegedGroups; RC-12, RC-19 |
| 2. User permanent delete (form and bulk) | removes the user's pivot rows | access_users; for a privileged member also the extra permission (D-30) |
TestPhase121Threats/T-12.1-23 and /T-12.1-39; RC-24 |
| 3. Group create or re-code with a privileged code | no pivot write; changes who is privileged | extra permission in FormBeforeCreate and FormBeforeUpdate |
TestPhase121Threats/T-12.1-29; RC-20 |
| 4. Delete of a privileged group | removes that group's pivot rows | extra permission in FormBeforeDelete; FormAfterDelete removes the rows |
TestPhase121Threats/T-12.1-29, TestAdminGroups |
| 5. Bulk and record actions | nothing in users_groups |
none needed; pinned | TestPhase121Threats/T-12.1-30; RC-21 |
| 6. Organisation members manager | users.organisation_id only |
accepted (T-12.1-32) | TestAdminOrganisationMembers, TestPhase121Threats/T-12.1-30 |
| 7. Config change of the privileged list | which codes are privileged | operational; read per request | TestPhase121Threats/T-12.1-31, TestPrivilegedGroupCodes |
| 8. Restore or activate of a deactivated site admin | no pivot write; re-enables the account | accepted (T-12.1-26) | none (accepted) |
Groups still never reaches a user API payload (T-12.1-24, T-12.1-34; the application's TestPhase12Threats subtest T-12-18 still passes). The consumer of the membership is unchanged: classes.HasGroupCode.
D-30: takeover guard for privileged-group members
D-30 added T-12.1-38 (critical) and T-12.1-39 (high) at the plan check. For an administrator who holds golem15.users.access_users and not golem15.users.manage_privileged_groups, three operations on a member of a privileged group are refused with 403 and change nothing:
- changing the member's email (
FormBeforeUpdate,guardCredentials); - submitting a password for the member (the same check; the two together in one body, with other fields, are refused as a whole);
- permanently deleting the member, by the form's delete and inside a bulk delete, which is then refused as a whole (
FormBeforeDelete).
The guard landed in sm-user-plugin commit b410a7f (plan 12.1-03, with classes/privileged.go) and was corrected in f496959 during this plan (FX-1). Both checks have a removal row: with guardCredentials removed, TestPhase121Threats/T-12.1-38 fails; with the delete guard removed, TestPhase121Threats/T-12.1-39 fails (RC-23, RC-24). A superuser passes; a membership that cannot be read is an opaque 500 and nothing changes (TestAdminUsersEdge): the guard never answers "not privileged" on an error.
D-30: boundary
What an administrator with only golem15.users.access_users may still do to a member of a privileged group, and why:
| Operation | Allowed | Why |
|---|---|---|
| Change name, surname and other profile fields | yes | not a credential; D-30 names email and password |
| Change the organisation, the avatar, the frontend permissions | yes | not a credential; D-30 does not cover them. Frontend permissions are what the application's own features check, so this can change what the member may do in the app |
| Add or remove an ordinary group | yes | D-04: ordinary groups need only the users permission; the privileged membership itself stays locked |
| Deactivate (soft delete), restore, activate | yes | D-13 and T-12.1-26 (accepted): reversible, as in PHP |
| Ban, unban, unsuspend | yes | D-14; reversible |
| Change email, set a password, permanently delete | no | D-30 |
The guard protects against takeover and permanent removal. It does not protect availability: deactivating or banning a site administrator locks that person out until someone with the users permission reverses it. This is the accepted behaviour of D-30 and T-12.1-26; it is listed as FD-5 because the owner may want the two actions guarded too.
Items handed over by plans 02 to 04
Each item was checked against code and, where a test could show it, pinned.
| Item | Statement | Disposition | Evidence |
|---|---|---|---|
| H-01 | Relation locks are enforced on form create and update only; relation-manager link and unlink routes do not ask RelationLockProvider |
Contract property, accepted by the owner at the tag decision. Not reachable in the plugin: no relation manager exists for groups. The framework page did not say so; it does now | TestPhase121Threats/T-12.1-30 (no groups relation route); docs/backend/relation-manager.md ("The lock covers the form field only"); decision item FD-2 |
| H-02 | A locked permission code whose stored value is outside the mode's set makes every save by that administrator a 403 | Contract property, accepted at the tag decision. Not reachable in the plugin: neither controller locks a code. Now documented | TestPermissionEditorLocked; docs/backend/forms.md; usersAdminController.AdminPermissionOptions ("None is locked") |
| H-03 | A relation hook returning cabana.ForbiddenError is untested |
Closed | TestForbiddenFromEveryHook (before and after create, update and delete, bulk delete, relation link and the child hooks), TestForbiddenRollsBack |
| H-04 | A users.permissions value that is not a JSON object fails login, fetch and the admin form for that row |
By decision (plan 03): a damaged value must not silently lose its denials. Only the validated editor writes the column, so a damaged value needs direct SQL or a faulty import | TestPermissionSetScan, TestMergedPermissions ("a damaged row is an error"); cutover note in the summary |
| H-05 | UserPermissionGrants accepts booleans in user_groups.permissions; PermissionSet skips them and an edit drops them |
Known difference, towards fewer grants. Pinned so a change is deliberate; decision item FD-3 | TestAdminGroupsLegacyBooleanPermissions, TestMergedPermissions ("a boolean value is not a grant") |
| H-06 | Unlinking an organisation member clears organisation_id and keeps organisation_role |
Real gap, low: the actor already holds the users permission, which can do more than this. The fix needs a framework unlink hook or a plugin route, so it was not improvised. Pinned; decision item FD-1 | TestAdminOrganisationRoleOutlivesMembership |
| H-07 | Deleting an organisation cascades into the application's credential tables, reachable with the users permission | As in PHP; the screen is the place organisations are administered. No confirm text says so. Accepted; mentioned under FD-1 | TestAdminOrganisations (delete releases the members), TestAdminOrganisationsEdge |
| H-08 | The seeded guest and registered groups can be deleted or re-coded with the groups permission |
Accepted: no Go code reads the two codes. An application protects them by naming their codes in golem15.user.privileged_groups |
TestAdminGroupsEdge (with guest in the list, delete and re-code are 403 and the row stays) |
| H-09 | A request that would be refused with 403 answers 422 when it also fails validation | Accepted: nothing is saved either way, and the 422 reveals only that a code exists, which the list shows | TestAdminGroupsEdge ("validation answers before the guard") |
| H-10 | A host application loading the plugin needs admin.jwt.secret or fails to start |
Compatibility note for a shared plugin, not a vulnerability: failing closed is the right behaviour. In the plugin README | TestAdminNeedsSecret; decision item FD-4 |
| H-11 | "password": "" without confirmation answers 422 |
Framework rule order (confirmed before nullable). The SPA leaves an empty password out, so the admin never sends it. Accepted |
TestPhase121Threats/T-12.1-20; formState.test.ts ("leaves an empty password out on update") |
Findings that need a decision
These are not open threats of the register. They are recorded here so they are decided, not forgotten.
| Finding | What | Suggested step |
|---|---|---|
| FD-1 | organisation_role outlives the membership after an unlink (H-06), and an organisation delete removes the application's organisation credentials without a warning (H-07) |
Clear the role on unlink (needs a framework unlink hook or a plugin-side route) and add a confirm text to the organisation delete |
| FD-2 | Relation locks do not cover a relation manager on the same relation (H-01) | Either enforce the lock on the manager's link and unlink routes or make "a locked field plus a manager on the same relation" a boot error; a framework change after v0.1.3 |
| FD-3 | Two readers of user_groups.permissions differ on boolean values (H-05) |
Normalize booleans to 1 in the cutover import, or teach ParsePermissionSet to read true as 1 |
| FD-4 | Every host application of the shared plugin now needs admin.jwt.secret (H-10) |
Tell the other projects before they update the plugin |
| FD-5 | Deactivating or banning a member of a privileged group needs only the users permission (D-30 boundary, T-12.1-26) | Decide whether deactivate and ban should refuse a privileged member as the permanent delete does |
Smaller observations, no decision needed:
- OB-1 (T-12.1-10). The SPA's password input shows whatever a record response carries for that field. The server never sends one (
TestPhase121Threats/T-12.1-10, RC-10). A defensive''on load inFormView.vuewould be a framework change after the tag; it was not made. - OB-2, sorting by an invisible column.
sort=surnameon the Users list is accepted although the column is not shown. It reveals order, not values, and the column is searchable by design. - OB-3. Thirteen Go files in other framework modules (tests of
modules/tide,modules/wristbandand others) name the application. They predate this phase and are outside its hygiene scope; recorded indeferred-items.md.
Fixes made during the review
| Fix | Repository | Commit | What |
|---|---|---|---|
| FX-1 (T-12.1-38, T-12.1-39) | sm-user-plugin | f496959 |
classes.fresh chained WithContext onto a lazy NewDB session, which clones the caller's statement with its conditions. On a handle that carried a condition IsPrivilegedMember answered false for a privileged member, so the takeover guard would have passed. No caller passes such a handle today (the hooks hand over the write's transaction). fresh now builds the empty statement first; IsPrivilegedMember and PrivilegedGroupIDs read through it. Tests: TestIsPrivilegedMember, TestAdminActions (both fail without the fix; RC-25) |
| FX-2 (D-23) | sm-user-plugin | 43fabfa |
User.FilterScope handed the group filter value to the database as it came; filter[groups]=abc made the Users list answer 500. A value that is not a positive whole number now lists nobody. Test: TestAdminUsersGroupFilterValue (fails with a 500 without the fix; RC-26) |
| FX-3 (T-12.1-24) | sm-user-plugin | 2b04cda |
Test only. The removal check for the json:"-" tag of LastSeen survived: the subtest read the user API payloads, which are built field by field. It now marshals the model too |
| Documentation (H-01, H-02) | summercms.go | see 12.1-05-SUMMARY.md | Two sentences in docs/backend/relation-manager.md and docs/backend/forms.md state the limits of relation locks and locked permission codes. No Go or SPA source changed |
No framework production code (modules, cmd, admin/src) changed in this plan: git diff --name-only v0.1.3 HEAD -- modules cmd admin/src lists only Go test files and files under testdata.