From b28800ad3931954e1b80b6b6af5bb9a5d64b559a Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Thu, 22 Oct 2026 15:27:39 +0200 Subject: [PATCH] docs(phase-19.6.1): add/update security threat verification --- .../19.6.1-SECURITY.md | 113 ++++++++++++++++++ 1 file changed, 113 insertions(+) create mode 100644 .planning/phases/19.6.1-backend-administrators-screen/19.6.1-SECURITY.md diff --git a/.planning/phases/19.6.1-backend-administrators-screen/19.6.1-SECURITY.md b/.planning/phases/19.6.1-backend-administrators-screen/19.6.1-SECURITY.md new file mode 100644 index 00000000..2ee47df4 --- /dev/null +++ b/.planning/phases/19.6.1-backend-administrators-screen/19.6.1-SECURITY.md @@ -0,0 +1,113 @@ +--- +phase: "19.6.1" +slug: "backend-administrators-screen" +status: verified +# threats_open = count of OPEN threats at or above workflow.security_block_on severity (the blocking gate) +threats_open: 0 +asvs_level: 1 +created: "2026-10-22" +--- + +# Phase 19.6.1 — Security + +> Per-phase security contract: threat register, accepted risks, and audit trail. + +--- + +## Trust Boundaries + +| Boundary | Description | Data Crossing | +|----------|-------------|---------------| +| admin SPA / API client → cabana admin API | Request bodies, ids, query filters, trigger/lock display state and relation labels are untrusted; the principal comes from the JWT or personal token guard | login/email/password, role code, permission maps, record-action names, virtual `is_superuser` | +| admin with `backend.manage_users` / `backend.manage_roles` → other admins and role codes | A delegated, non-superuser administrator acts on accounts and roles that may hold more privilege, including codes plugins attach grants to | role_id, permissions, activation, password set, contributed record actions | +| contributing plugin → Administrators record-action Run (D-14) | Plugin code runs inside the target's transaction behind the target's permission | `pact.AdminRecordActionInput.Record`, action name | +| concurrent write transactions → last-superuser invariant | Two requests may reduce the superuser set at once | demote / deactivate / delete of an active superuser | +| controller lock provider → record response | Lock notes and values come from plugin code and reach every viewer of the record | `meta.permissionLocks` | +| plugin fields.yaml → boot compiler | A plugin's YAML may declare malformed triggers | `trigger` keys | +| test suite → privilege rules | A rule without a named test can regress silently | status, body text, unchanged rows | + +--- + +## Threat Register + +| Threat ID | Category | Component | Severity | Disposition | Mitigation | Status | +|-----------|----------|-----------|----------|-------------|------------|--------| +| T-19.6.1-01 | Elevation | generic CRUD fill of `is_superuser`, `role_id`, `permissions`, `is_activated`, `password` | high | mitigate | `protectedFillKey` in `modules/cabana/crud.go:1450` keeps them unfillable; only usersAdmin hooks, the radio editor and the WritableForeignKey role relation write them | closed | +| T-19.6.1-02 | Elevation | FormBeforeCreate/Update virtual `is_superuser` | critical | mitigate | presence check on `VirtualFieldsFromContext` in `modules/cabana/backend_users_admin.go:731`; a non-superuser sending the key answers 403 `superuser_forbidden` | closed | +| T-19.6.1-03 | Elevation | own record: role, superuser, permissions, activation | high | mitigate | `priorRoleID` snapshot compared in FormAfterUpdate; `AdminSetPermissionValues` self refusal; self checks on `is_superuser`, deactivate and delete (`backend_users_admin.go`) | closed | +| T-19.6.1-04 | Elevation | permission editors and role picker | high | mitigate | `PermissionOption.Locked` and `RelationLockProvider` (`relation_field.go:74`); conservative `grantsCovered` (`backend_privileges.go:82`) | closed | +| T-19.6.1-05 | Elevation | password set, deactivate, delete, actions on a higher-privileged admin | critical | mitigate | `guardOutranked` (`backend_privileges.go:123`) on update, delete, record and bulk actions; own-password refused with `own_password` (`backend_users_admin.go:800`); own password is My account + `current_password` (`account.go:341`) | closed | +| T-19.6.1-06 | Denial of service | demote, deactivate, delete of the last active superuser | high | mitigate | `guardLastSuperuser` takes `pg_advisory_xact_lock` (`superuserLockKey` in `backend_privileges.go:18`) then counts other active superusers in the write tx | closed | +| T-19.6.1-07 | Information disclosure | list, show, partials, filters for non-superusers | medium | mitigate | `ListExtendQuery` / `FormExtendQuery` (`backend_users_admin.go:153`) filter `backend_users.is_superuser`; `hiddenSchemaFields` drops the field, column and filter | closed | +| T-19.6.1-08 | Elevation | superuser's scoped personal API token | high | mitigate | `actsAsSuperuser` requires `p.IsSuperuser` and unscoped or full token scope (`backend_privileges.go:24`) | closed | +| T-19.6.1-09 | Elevation | contributed record actions (D-14) | high | mitigate | `protect()` checks the target's `RequiredPermissions` (`http.go:1161`); `compileContributions` (`registry.go:269`) validates contributed permissions at boot; run path is the existing row-locked RecordAction | closed | +| T-19.6.1-10 | Tampering | contributed action names | medium | mitigate | boot error on reserved names, a name the owner or an earlier contributor registers, nil Run, empty label and unknown controller ID (`action_extensions.go:86`, `extension.go`) | closed | +| T-19.6.1-11 | Spoofing | sessions after deactivate, delete, password set | high | mitigate | `tokens_valid_after = revocationCutoff()` (`backend_privileges.go:182`) on all three; `FindByID` refuses inactive and deleted users for personal tokens | closed | +| T-19.6.1-12 | Spoofing | personal API tokens after a password set for another admin | medium | accept | same semantics as `admin:reset-password`; operator revokes with `admin:token:revoke` | closed | +| T-19.6.1-13 | Tampering | unprojected `is_superuser` switch | high | mitigate | `extendRecordData` (`backend_users_admin.go:436`) projects the stored flag so an untouched save never demotes | closed | +| T-19.6.1-14 | Elevation | upgrade of existing installs | medium | mitigate | `backend.manage_users` and `backend.manage_roles` declared with no `Roles` grants (`backend_screens.go:80`); only superusers reach the screens until a grant is made | closed | +| T-19.6.1-15 | Denial of service | role delete with holders | low | mitigate | unscoped holder count refuses with 403 `delete_has_users` before the FK would fail (`backend_roles_admin.go:247`) | closed | +| T-19.6.1-16 | Information disclosure | login/email collision messages revealing deleted accounts | low | accept | only holders of `backend.manage_users` see them, and they can list deleted admins anyway | closed | +| T-19.6.1-17 | Denial of service | oversized passwords | low | mitigate | 72-byte cap before bcrypt, min 4, confirmation (`storePassword` in `backend_users_admin.go:857`, `passwordMaxBytes` in `account.go:47`) | closed | +| T-19.6.1-18 | Elevation | trigger-disabled permission overrides | medium | mitigate | a disabled field is omitted from the save body; the server keeps stored values; superusers bypass overrides; no server rule depends on the trigger | closed | +| T-19.6.1-19 | Elevation | record-locked codes in a hand-crafted save body | high | mitigate | `applyPermissionValues` (`field_permission.go:291`) refuses a present record-locked value that differs from the stored one (403) and carries the stored value otherwise; D-08 `AdminSetPermissionValues` refusal stays | closed | +| T-19.6.1-20 | Information disclosure | `meta.permissionLocks` | low | accept | sent only on show and save responses behind `protect()` to admins who may open the record; it repeats codes the permission editor already shows | closed | +| T-19.6.1-21 | Tampering | malformed trigger YAML | low | mitigate | `compileTrigger` and `checkTriggers` (`field_trigger.go:28`) stop boot with errors naming controller, field and key | closed | +| T-19.6.1-22 | Tampering | stale committed SPA build or OpenAPI | medium | mitigate | `check-admin-openapi.sh --check` and `check-admin-dist.sh` in plan 02 verify; dist rebuilt from the committed lockfile | closed | +| T-19.6.1-23 | Tampering | note text injected into markup | medium | mitigate | notes render by Vue text interpolation only; no `v-html` in PermissionEditorField, FormField, FormView, RelationField or RelationChildModal | closed | +| T-19.6.1-24 | Repudiation | privilege regressions (D-05..D-09, D-19, D-20) going unnoticed | high | mitigate | one named test per rule; `scripts/check-phase19.6.1.sh` requires every name and refuses skipped or zero-match runs | closed | +| T-19.6.1-25 | Denial of service | a deadlock in the last-superuser guard hidden by a flaky race | high | mitigate | held-lock race in `administrators_lifecycle_postgres_test.go` waits for two `pg_locks` waiters before releasing; unsynchronized rounds assert no 500 or deadlock | closed | +| T-19.6.1-26 | Tampering | cross-test interference through shared superuser rows | medium | mitigate | `isolateSuperusers` (`administrators_harness_test.go:340`) plus per-test unique rows and cleanup; no `t.Parallel` in the package | closed | +| T-19.6.1-27 | Information disclosure | tests or fixtures naming consuming applications | low | mitigate | the gate's docs stage name check and the plan 03 acceptance greps | closed | +| T-19.6.1-28 | Elevation | `rolesAdmin.FormBeforeUpdate` submitted code | critical | mitigate | `refuseCodeDeclaredCode` (`backend_roles_admin.go:162`) on update; 422 `code_locked`; row unchanged | closed | +| T-19.6.1-29 | Elevation | `rolesAdmin.FormBeforeCreate` submitted code | critical | mitigate | same refusal on create (`FormBeforeCreate` at `backend_roles_admin.go:175`); 422 `code_locked`; no row inserted | closed | +| T-19.6.1-30 | Elevation | compileActionExtensions contributed Run on AdministratorsControllerID | critical | mitigate | `wrapAdministratorsContributedRun` (`action_extensions.go:108`) calls `guardOutranked` inside the action transaction before `next`; 403 `user.outranked`; Run not called | closed | +| T-19.6.1-31 | Elevation | wrap type assert of `in.Record` | high | mitigate | if the record is not a `*BackendUser`, return an error and do not call `next` (`action_extensions.go:111`) | closed | +| T-19.6.1-32 | Tampering | `FORM_REMEMBER_LABEL` label string | low | accept | display only; server still authorizes the posted id; RelationField interpolates text (no `v-html`) | closed | +| T-19.6.1-SC | Tampering | npm/pip/cargo installs | high | mitigate | no package added; plan 02 `npm --prefix admin ci` installs only the committed lockfile; plan summaries `tech-stack.added: []` | closed | + +*Status: open · closed · open — below high threshold (non-blocking)* +*Severity: critical > high > medium > low — only open threats at or above workflow.security_block_on count toward threats_open* +*Disposition: mitigate (implementation required) · accept (documented risk) · transfer (third-party)* + +--- + +## Accepted Risks Log + +| Risk ID | Threat Ref | Rationale | Accepted By | Date | +|---------|------------|-----------|-------------|------| +| AR-19.6.1-12 | T-19.6.1-12 | Setting another admin's password does not revoke that admin's personal API tokens — same semantics as `admin:reset-password`. The operator revokes them with `admin:token:revoke`. Widening the phase to revoke tokens on password set was flagged and declined. | plan 01 (authored) | 2026-10-22 | +| AR-19.6.1-16 | T-19.6.1-16 | Login/email collision messages can reveal that a deleted account exists. Only holders of `backend.manage_users` see them, and they can list deleted admins anyway. | plan 01 (authored) | 2026-10-22 | +| AR-19.6.1-20 | T-19.6.1-20 | `meta.permissionLocks` repeats lock codes and notes on show/save. Sent only behind `protect()` to admins who may open the record; it repeats codes the permission editor already shows. | plan 02 (authored) | 2026-10-22 | +| AR-19.6.1-32 | T-19.6.1-32 | Remembered relation option labels are display-only client state. The server still authorizes the posted id; RelationField interpolates the label as text. | plan 05 (authored) | 2026-10-22 | + +*Accepted risks do not resurface in future audit runs.* + +--- + +## Security Audit Trail + +| Audit Date | Threats Total | Closed | Open | Run By | +|------------|---------------|--------|------|--------| +| 2026-10-22 | 33 | 33 | 0 | msd-secure-phase (ASVS L1, register authored at plan time) | + +## Security Audit 2026-10-22 + +| Metric | Count | +|--------|-------| +| Threats found | 33 | +| Closed | 33 | +| Open | 0 | + +State B (no prior SECURITY.md). Plans 01–05 each contained a parseable `` (`register_authored_at_plan_time: true`). SUMMARYs 01–03 reported no unregistered threat flags; 04–05 have no Threat Flags section. ASVS L1 + `threats_open: 0` short-circuited past the auditor (grep-depth sufficient). `block_on: high`. + +--- + +## Sign-Off + +- [x] All threats have a disposition (mitigate / accept / transfer) +- [x] Accepted risks documented in Accepted Risks Log +- [x] `threats_open: 0` confirmed +- [x] `status: verified` set in frontmatter + +**Approval:** verified 2026-10-22