Skip to content
3 changes: 3 additions & 0 deletions docs/BACKLOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9419,6 +9419,7 @@ So this column does not close an unguarded hole. It replaces incidental, extra-g
> **Filed 2026-08-08. RESEARCH item: the goal is an HONEST pass, and "cannot honestly reach pass" is a valid finding.** ASVS **8.2.2** (L1) currently scores **partial**. The pinned verb asks that data-specific access be restricted to consumers with explicit permissions to specific data items, to mitigate IDOR and BOLA. **As filed**, one PHI-bearing object family had no object-level authorization at all: `GET /uploads` listed every user's files unfiltered, and browse and delete took a `file_id` straight through to `get_meta`/`read_bytes` with no channel and no owner check.
> **UPDATE 2026-08-11 -- THE UPLOADS HALF IS BUILT; THE ITEM STAYS OPEN.** Owner-only plus a `files:access_any` Administrator override, keyed on the immutable `uploader_id` (`Identity.user_id`), owner-ratified and recorded in [ADR 0134](adr/0134-offline-uploaded-logs-viewer-connection-decoupled-upload-browse-resend-deletion-phi-at-rest-posture-stdlib-multipart.md) Amendment A. All four routes now enforce it, including resend's SOURCE file (it previously checked only the TARGET inbound). So the three specific defects named above are fixed in the shipped code and the paragraph above describes the pre-fix state, not the current one.
> **WHY IT IS NOT CLOSED, and none of these is a formality.** (1) The principal-narrowing axis is UNTOUCHED: `Identity.allowed_channels` still defaults to `None` (`auth/identity.py:38`) = every channel. (2) This item's own acceptance says **"Both halves need answers; neither alone closes the cell"** -- one half is answered. (3) ADR 0134 Amendment A satisfies only the SECOND clause of the master-test-plan's exit criterion 12; `GET /uploads` remains pageless, so the first clause is open. (4) The scorecard carrying the 8.2.2 verdict lives outside this repo and is not re-scored by this work.
> **UPDATE 2026-09-03 -- THE PRINCIPAL-NARROWING AND PAGINATION LIMBS ARE BUILT; THE ITEM STAYS OPEN.** Reasons (1) and (3) above are answered and describe the pre-fix state from here on. **Reason (3):** `GET /uploads` takes `limit`/`offset` (50, 1..500 / 0.., the same bounds declared on the `/ui` twin so the console door is not the looser one), `total` still counts the whole visible set, and the window is applied AFTER the owner filter so a page's length can never encode how many of another operator's files fell inside it. `UploadStore.list_files` sorts by `(uploaded_at, file_id)` because a timestamp is not a total order and a tied file could otherwise land on two pages or none. **The exit-criterion clause COULD NOT BE VERIFIED from an engine checkout, and that is the honest status.** Reason (3) above is phrased against the master test plan's exit criterion 12, and that document is **vaulted** -- `git ls-files docs/testing` returns exactly one file here, `docs/testing/VERIFY.md`, because ADR 0160 moved the plan out and the ignore rules make its absence look like non-existence rather than misplacement. So the pagination is BUILT and its own behaviour is tested; whether it closes that clause is a question only a reader holding the plan can answer, and nobody should re-derive an answer from this row. What is independently true, and is why the work stands on its own: an unbounded listing over a PHI-bearing family is worth fixing whatever the plan says, and a prior ruling already recorded the pageless listing as a resource-consumption gap rather than an object-authorization one -- so this limb was never going to clear the 8.2.2 cell either way. **Reasons (2) and (4) still stand,** and so do the four limbs this item names that nobody has built: the audit-actor immutable-id work, the connection-flag object check plus its by-id-AND-collection parity test, the metrics-exposition scoping, and the directory-immutable identity binding (`#1143`). Reason (1) detail follows. `Identity.allowed_channels` now defaults to the EMPTY set, `_allowed_channels` resolves a NULL `channel_scope` column to no channels rather than every channel, and all-channels survives as a grant somebody typed -- the `*` token in the stored scope list, reusing the string `ad_group_channels.channel` has always used for a wildcard row. The AD sync persists `["*"]` for a wildcard group instead of collapsing to NULL, which after the flip would have inverted a deliberate estate-wide mapping into a deny-everything one. Administrators stay all-channels by role, so the first operator of a fresh install is not locked out; the console's landing page carries the unprovisioned-operator sentence, deliberately a banner and not a start-time refusal. The store was not touched: `create_user` still writes no scope, and writing none is now what denies. **Reasons (2), (3) and (4) all still stand** -- the pagination limb is untouched, and the re-score is not this work's to make.
> Verdict: research
> Closing-act: scorecard-rescore

Expand All @@ -9435,6 +9436,8 @@ So this column does not close an unguarded hole. It replaces incidental, extra-g

**STILL UNANSWERED, and it is what keeps this item open:** narrowing `allowed_channels` by default carries a first-run cost, because new non-admins and unmapped AD users would see an empty console until scoped, which reads as broken RBAC on day one. Both halves need answers; neither alone closes the cell.

**ANSWERED 2026-09-03 -- the first-run cost is PAID, not avoided.** The default is narrowed and the cost is met where it lands: on the landing page, which tells an operator with an empty scope why the lists are empty and who fixes it, instead of leaving an unexplained empty console whose obvious remedy is to widen somebody's grant. It is deliberately a page banner and NOT a start-time refusal -- refusing to start would make a fresh single-operator install unbootable for exactly the same condition, which trades one first-run failure for a worse one. Administrators are all-channels by role, unchanged, so the first administrator of a fresh install is never locked out of their own console; that is the property the flip had to preserve and the one a test now pins. An unmapped AD user is denied and sees the same sentence, and the AD group map is the AD-native way to grant them a scope. Zero deployments (CLAUDE.md section 0) is why this is a flip and not a staged migration: there is nothing configured to break and nobody to notify, so the simple correct end state was available and was taken.

**What would NOT be an honest pass.** Flipping the `allowed_channels` default and rescoring. The triage is explicit that it narrows the channel axis and does not close the cell, because uploads carry no channel dimension -- the verdict would move on a change that does not touch the binding defect. Re-declaring uploaded files as something other than "data items" is the other tempting move and does not survive contact with the requirement's text or with the fact that those files carry message bodies.

**Source:** filed 2026-08-08 from the ASVS ledger-coverage sweep, which found 80 of the 108 partial and fail cells carried no item naming them in `docs/BACKLOG.md`. The scorecard is the record of record for the verdict; this item tracks the research toward changing it.
Expand Down
49 changes: 32 additions & 17 deletions docs/SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -192,8 +192,10 @@ route handler only when all of them pass.
sensitive permission set on non-GET requests only. ADR 0118 relocated the knob, and the old
`[diagnostics].audit_all_authz` TOML spelling is **refused at load**.
4. **A second axis: per-channel scope** — `users.channel_scope` narrows operational routes to a set of
connections. Out-of-scope *message* access returns **404** (existence-hiding); connection control and
inbound injection return **403**. Denials are audited `auth.channel_denied`.
connections, and it **denies by default**: a new non-administrator is granted no channel until
somebody grants one (BACKLOG #1152; the full rule is *Per-channel scoping (DLQ-SCOPE)* below).
Out-of-scope *message* access returns **404** (existence-hiding); connection control and inbound
injection return **403**. Denials are audited `auth.channel_denied`.

The table below has **seven** rows. `require` is the ladder itself; five wrappers extend it
(`require_paced`, `require_phi_read`, `require_step_up`, `require_step_up_action`, and the shared
Expand Down Expand Up @@ -471,7 +473,7 @@ tuple: they act only on the caller's own account.
| Method | Path | Permission | Gate | Extra constraints |
|---|---|---|---|---|
| `POST` | `/uploads` | `files:upload` | `require_step_up` | stdlib multipart parse (no `python-multipart`) |
| `GET` | `/uploads` | `files:browse` | `require` | metadata only — no body, no summary; **owner-scoped** (ASVS 8.2.2) — the caller sees only the files they uploaded unless they hold `files:access_any` |
| `GET` | `/uploads` | `files:browse` | `require` | metadata only — no body, no summary; **owner-scoped** (ASVS 8.2.2) — the caller sees only the files they uploaded unless they hold `files:access_any`; **paged** `limit`/`offset` (50, 1..500 / 0..), the window applied AFTER the owner filter so a page's length can never encode another operator's file count |
| `GET` | `/uploads/{file_id}/messages` | `files:browse` | `require_step_up` | explicit `enforce_phi_read_hop` + `enforce_phi_read_pacing` (bulk decrypt + split); **owner-only** — another operator's file answers **404**, before the decrypt |
| `POST` | `/uploads/{file_id}/messages/search` | `files:browse` | `require_step_up` | the needle-bearing sibling of the browse GET (BACKLOG #1184); same owner-only 404 before the decrypt, same bulk PHI-read pacing |
| `POST` | `/uploads/{file_id}/resend` | `files:browse` | `require_step_up` | per-channel `can_access_channel` check on the target inbound (403) **and** an owner check on the source file (404) |
Expand All @@ -498,10 +500,13 @@ tuple: they act only on the caller's own account.
> **Owner-only** is the whole rule: list, browse, resend and delete reach the caller's own files.
> `files:access_any` is the explicit cross-operator override, granted to **Administrator** only (it is
> the whole catalogue), never to Operator, and never mintable onto a custom role
> (`CUSTOM_ROLE_FORBIDDEN_PERMISSIONS`). The channel axis is deliberately **not** used here —
> `Identity.allowed_channels` defaults to `null` (= every channel) and an uploaded file carries no
> channel, so a channel-scoped rule would protect nobody on a default install and would deny every
> scoped operator their own file. A denied by-id request answers **404** with the same body as a
> (`CUSTOM_ROLE_FORBIDDEN_PERMISSIONS`). The channel axis is deliberately **not** used here, and one
> of the two reasons originally given has since expired. The surviving reason decides it on its own:
> an uploaded file carries no channel, so a channel-scoped rule has nothing to match on and would
> deny every scoped operator their own file. The expired reason was that `Identity.allowed_channels`
> defaulted to `null` (= every channel), so such a rule would have protected nobody on a default
> install — BACKLOG #1152 flipped that default to deny, which changes nothing about the owner-only
> decision but does retire half of its stated justification. A denied by-id request answers **404** with the same body as a
> malformed or absent id; what makes the by-id routes non-enumerable is that a `file_id` is 128 bits
> of `secrets.token_hex(16)` and the listing no longer hands out another operator's — the denial is
> still distinguishable by timing and by its audit row. That denial is audited as `upload.denied` with
Expand Down Expand Up @@ -724,16 +729,26 @@ else would need its own authorization rule stated here.
Differences 3–5 are derived and pinned: a `/ui` route that is weaker than **any** JSON route holding
the same permission set on the same method reds CI until it is listed here.

> **Per-channel scoping (DLQ-SCOPE).** Operational permissions can be confined to a set of
> connections per user via `users.channel_scope` (`PUT /users/{id}/channel-scope`; `null` = all,
> the default). When a user is scoped, `messages:read/view_raw/replay`, dead-letter list/replay, and
> `connections:control` are restricted to their channels (out-of-scope message access returns 404 to
> avoid leaking existence; connection control returns 403; denials are audited `auth.channel_denied`).
> **Administrators are always all-channels.** Monitoring dashboards stay global. A channel-scoped user
> **cannot purge** a shared outbound (purge spans every inbound feeding it). **AD users** inherit their
> scope from the `ad_group_scope_map` (`GET/PUT /ad-group-scope-map`; channel `*` = all): on login the
> group-derived scope is persisted and stale sessions revoked. It's opt-in — with no matching mapped
> group, the user's existing scope (all by default) is left untouched.
> **Per-channel scoping (DLQ-SCOPE), and it DENIES BY DEFAULT (BACKLOG #1152, ASVS 8.2.2).**
> Operational permissions are confined to a set of connections per user via `users.channel_scope`
> (`PUT /users/{id}/channel-scope`). A new non-administrator is granted **no channel** — `create_user`
> writes no scope, and an absent scope denies — so `messages:read/view_raw/replay`, dead-letter
> list/replay and `connections:control` reach nothing until somebody grants a channel. Out-of-scope
> message access returns 404 to avoid leaking existence; connection control returns 403; denials are
> audited `auth.channel_denied`. All-channels survives as a grant somebody typed: the `*` token in the
> scope list (`{"channels": ["*"]}`). Sending `{"channels": null}` **clears** the scope and therefore
> denies — it is not the wide value it was before #1152.
>
> **Administrators are always all-channels**, by role, which is what keeps the first operator of a
> fresh install from locking themselves out of their own console. A non-administrator with an empty
> scope sees an empty console, and the landing page says so in a sentence rather than leaving it to
> read as broken RBAC; that is deliberately a page banner and not a start-time refusal, which would
> make a fresh single-operator install unbootable for the same condition. Monitoring dashboards stay
> global. A channel-scoped user **cannot purge** a shared outbound (purge spans every inbound feeding
> it). **AD users** inherit their scope from the `ad_group_scope_map` (`GET/PUT /ad-group-scope-map`;
> channel `*` = all): on login the group-derived scope is persisted — a wildcard row persists the
> explicit `["*"]` grant — and stale sessions revoked. It's opt-in: with no matching mapped group the
> user's existing scope is left untouched, which for a never-granted account means it stays denied.

> **`/config/reload` executes Python** from the target directory in-process, so it is constrained
> beyond the `config:deploy` permission: the directory must resolve **within** an allowed root —
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,15 @@ protect nobody on a default install while denying every scoped operator their ow
protection depends on configuration nobody has set is a control resting on a false premise (CLAUDE.md §11,
SDS-3.7). Owner-only protects with zero configuration.

> **Note added 2026-09-03 (BACKLOG #1152, the principal-narrowing limb).** The first clause above is no
> longer true of the code: `Identity.allowed_channels` now defaults to the EMPTY set, so an absent scope
> denies and the general lesson has been applied to the axis itself rather than only routed around. **The
> decision here is unchanged**, because the second clause was always sufficient on its own — an uploaded
> file carries no channel, so there is nothing for a channel rule to match and a scoped operator would be
> denied their own file. Recorded here rather than by editing the paragraph: a reader who checks the old
> premise against today's code must find out that it moved, not conclude that the owner-ratified decision
> lost its ground.

**Ownership is keyed on `uploader_id` (`Identity.user_id`), never on `username`.** A username is reassignable:
deleting a local account and recreating the name is unblocked, and the AD leg auto-provisions a fresh row for
a returning name with no administrator action at all. Keying an object-level control on it would mean that
Expand Down
Loading
Loading