Repository navigation
Commit d117382
fix(platform-objects,plugin-audit,plugin-approvals)!: mail bodies are read only with manage_platform_settings, and the audit mirrors never copy them in clear (#24688)
Closes #24625
Clause-②: yes (narrowing)
Dispatched by the epic seat (`epic:#15194`) under claim 6278944518, on
the maintainer's order. Measured record: 6278938192.
## The defect, re-measured on `main` `bfc1539fe`
The test this PR adds was run on unchanged `main`, over #24552's 18.0
fixture under `single`. The fixture holds one 18.0 password-reset row
and one organization-invitation row in `sys_email`. Result: 9 red, 11
green, matching the seat's matrix. The 9 red were:
- An organization administrator without `manage_platform_settings` was
served `body_text` and `body_html` of the 18.0 reset row on `/data`
list, get and query.
- Global search found the reset row by a term from its body, for that
same administrator.
- The `sys_activity` and `sys_audit_log` mirrors of the reset row served
the body to that administrator.
- On HEAD, the mirror rows written for a new `sys_identity_email` row
held its body in clear at rest. So did the mirror rows for a new
`sys_email` row.
Anonymous (401), member (403, no search hit), and the identity log for
both non-platform principals (403) were already closed.
## The fix, and why this one
**The selector question first.** No column tells an 18.0 identity row in
`sys_email` apart from tenant mail:
- A magic link carries no `related_object`, and `sys_email` has no
template column.
- Notification mail (`service-messaging`'s email channel) also carries
no `related_object`.
So a row gate of "related to `sys_user` or to nothing" would hide every
notification from the organization administrator. Per the dispatch, with
no reliable selector the fix is field-level.
1. **Read gate (platform-objects).** `body_text` and `body_html` on
`sys_email` and `sys_identity_email` declare `requiredPermissions:
['manage_platform_settings']` (ADR-0066 D3). This one declaration closes
every door that serves rows through the engine:
- `/data` list, get and query (the key is dropped, `?select=` included).
A body predicate is refused with 403.
- Export.
- Global search, which matches only on queryable fields.
- The `sys_activity` / `sys_audit_log` read redaction, which serves a
mirror the fields the data plane serves. The #21154 value-column guard
also refuses that administrator's body probes on the mirrors, pinned or
not.
The administrator keeps every row: envelope, subject, delivery state and
linkage. They lose only the bodies.
2. **Mirror writers (plugin-audit).** The ledger view masks both logs'
body columns on every row, the way it masks a credential field. A
non-empty body is stored as the read mask and an unset one stays `null`.
This covers the create, update and delete snapshots and the activity
row. The two names live once, in
`platform-objects/src/audit/mail-log-body.ts`.
3. **A shipped defect this change would have triggered
(plugin-approvals).** `sys_approval_request` free-text search
auto-included `payload_json`. The snapshot guard refuses any query
naming that column to a reader withheld any field of any object. Once
mail bodies are withheld from the organization administrator, that
administrator's **whole global search sweep returned 403** (measured on
this branch before the fix). Their approvals list search would also have
failed. The object now declares `searchableFields` = the ADR-0061
auto-default minus `payload_json`. A test holds the declared list to
that derivation. A structured filter on the snapshot is unchanged for
the readers the guard admits.
**What is not changed.**
- The platform administrator reads every body.
- The mail pipeline reads as the system and is not narrowed.
- Rows and mirror copies already at rest keep what they stored. They are
served only to readers who are served the body. Rewriting them belongs
to the 18.x ceremony (#24581), not here.
## Tests: principal × door
`packages/qa/dogfood/test/identity-mail-read-gate.dogfood.test.ts` runs
over the 18.0 fixture: 22 cases. Every probe is a link-token span read
as the system and compared by inclusion, so no value appears in a title,
message or log.
| principal | `/data` list / get / query | body filter | global search |
`sys_activity` / `sys_audit_log` | `sys_identity_email` |
|---|---|---|---|---|---|
| anonymous | 401 | — | — | — | — |
| member | 403 | — | no hit | no body | 403 |
| organization admin | rows served (identity row **and** its own
invitation), no body | 403 | 200, no hit | rows served, no body; body
probe 403 | 403 |
| platform admin (control) | bodies served | 200, one row | hit | 18.0
copy served | 200 with body |
The writers are pinned too: the mirror rows of a HEAD-written
`sys_identity_email` row and of a HEAD-written `sys_email` row hold no
body at rest. Unit tests:
- `platform-objects/src/audit/mail-log-body.test.ts`: the declaration,
with no object-level gate on `sys_email`.
- `plugin-audit/src/audit-internal-field-omission.test.ts`: the mask on
every snapshot side, null preserved, and a non-mail-log control.
- `plugin-approvals/src/sys-approval-request.search-fields.test.ts`.
**Reverse verification.** Each leg used `scripts/ablation-replace.mjs`,
then a rebuild of the package, then `ablation-dist-preflight` (marker
present, then absent after restore). Each leg was restored to the HEAD
blob with `git diff HEAD` empty.
- **A1:** body `requiredPermissions` removed from `sys_email` → 8
dogfood red: the organization-admin list, get, query, search and both
mirror reads and both mirror probes.
- **A2:** ledger mask disabled → 4 plugin-audit unit red and 2 dogfood
writer red. The preflight exit code on this leg was read through a pipe,
so it is void. The dist reach is shown by the dogfood red instead.
- **A3:** `payload_json` put back into the approvals search set → 1 unit
red and the organization-admin global search red.
## Proof (head `e7f2dd039`)
- **R1:** `pnpm dev -- --fresh -p 38625` boots the showcase. The seeded
admin signs in, and `/data/sys_email`, `/data/sys_identity_email`,
`/data/showcase_task` and `/search` answer 200. A password-reset request
answers 200. The log has no error lines. The server was stopped and the
port is free.
- **R2:** `upgrade-18x-harness.dogfood.test.ts` is green. It ran
together with the new matrix, `search-skip-unreadable`,
`audit-log-admin-search`, `activity-text-predicate` and
`signup-organization-census`: 6 files, 62 passed.
- **R3:** the harness's v17 case is still refused at boot (same run).
- **R4:** `packages/cli` `serve-walled-posture-refusal.test.ts`
(integration project): 5 passed.
- **Packages:**
- Typecheck exit 0 for platform-objects, plugin-audit, plugin-approvals,
plugin-email and dogfood.
- vitest: platform-objects 1196 passed, plugin-audit 935,
plugin-approvals 1140, plugin-email 571.
- **Gates:** `node scripts/pm/dispatch-gates.mjs --commands` derived 74
commands, and all 74 exited 0. The `--ran` reconciliation reads 74 run,
0 NOT-MEASURED.
- Two commands first exited 3 on environment prerequisites.
`check-plugin-teardown-shape --self-test` needed its pinned fixture
commit fetched. `check:dual-build-cjs-loads` needed a full build. Both
were re-run green after the fix.
- `check-governed-merges --branch HEAD`: NOT governed, 531 changed
lines.
## Acceptance notes
- **Scope beyond the claimed surface:**
`plugin-approvals/src/sys-approval-request.object.ts` (fix 3). It is the
shipped defect this change would otherwise trigger for every
organization administrator. Without it, the narrowing ships a
global-search outage.
- **Behaviour an operator will see:**
- An organization administrator no longer sees any mail body, including
the organization's own notification and invitation bodies. They still
see that each message was sent, to whom and with what result.
- An unpinned value-column query on `sys_activity` / `sys_audit_log` is
now refused to them, as the #21154 guard does for any reader withheld a
field. Those copies hold 18.0 identity bodies at rest, so this is the
oracle closing, not a regression.
- Free-text search on approval requests no longer matches inside the
JSON snapshot, for any reader.
- **Not done here:** mirror rows written before this release still hold
bodies at rest. They are served only to readers who are served the body.
The ceremony owns moving the identity rows (#24581).
- **Released line:** `@objectstack/plugin-email@17.3.0` has the same
exposure. The backport to `release/17.x` is owed when that branch is cut
(seat record 6278938192).
🤖 Generated with [Claude Code](https://claude.com/claude-code)
https://claude.ai/code/session_01CAtkMwk5tY1jPaUCdKosnk
---
_Generated by [Claude
Code](https://claude.ai/code/session_01CAtkMwk5tY1jPaUCdKosnk)_
---------
Co-authored-by: Claude <noreply@anthropic.com>1 parent 6464df3 commit d117382
11 files changed
Lines changed: 530 additions & 1 deletion
File tree
- .changeset
- packages
- platform-objects/src/audit
- plugins
- plugin-approvals/src
- plugin-audit/src
- qa/dogfood/test
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
13 | 13 | | |
14 | 14 | | |
15 | 15 | | |
| 16 | + | |
16 | 17 | | |
17 | 18 | | |
18 | 19 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
95 | 95 | | |
96 | 96 | | |
97 | 97 | | |
| 98 | + | |
| 99 | + | |
| 100 | + | |
| 101 | + | |
| 102 | + | |
| 103 | + | |
| 104 | + | |
| 105 | + | |
| 106 | + | |
| 107 | + | |
| 108 | + | |
| 109 | + | |
| 110 | + | |
| 111 | + | |
| 112 | + | |
| 113 | + | |
| 114 | + | |
| 115 | + | |
98 | 116 | | |
99 | 117 | | |
100 | 118 | | |
101 | 119 | | |
| 120 | + | |
102 | 121 | | |
103 | 122 | | |
104 | 123 | | |
105 | 124 | | |
106 | 125 | | |
107 | 126 | | |
| 127 | + | |
108 | 128 | | |
109 | 129 | | |
110 | 130 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
121 | 121 | | |
122 | 122 | | |
123 | 123 | | |
| 124 | + | |
124 | 125 | | |
125 | 126 | | |
126 | 127 | | |
127 | 128 | | |
128 | 129 | | |
129 | 130 | | |
| 131 | + | |
130 | 132 | | |
131 | 133 | | |
132 | 134 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
72 | 72 | | |
73 | 73 | | |
74 | 74 | | |
| 75 | + | |
| 76 | + | |
| 77 | + | |
| 78 | + | |
| 79 | + | |
| 80 | + | |
| 81 | + | |
| 82 | + | |
| 83 | + | |
| 84 | + | |
| 85 | + | |
| 86 | + | |
| 87 | + | |
| 88 | + | |
75 | 89 | | |
76 | 90 | | |
77 | 91 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
39 | 39 | | |
40 | 40 | | |
41 | 41 | | |
42 | | - | |
| 42 | + | |
| 43 | + | |
43 | 44 | | |
44 | 45 | | |
45 | 46 | | |
| |||
289 | 290 | | |
290 | 291 | | |
291 | 292 | | |
| 293 | + | |
| 294 | + | |
| 295 | + | |
| 296 | + | |
| 297 | + | |
| 298 | + | |
| 299 | + | |
| 300 | + | |
| 301 | + | |
| 302 | + | |
| 303 | + | |
| 304 | + | |
| 305 | + | |
| 306 | + | |
| 307 | + | |
| 308 | + | |
| 309 | + | |
| 310 | + | |
| 311 | + | |
| 312 | + | |
| 313 | + | |
| 314 | + | |
| 315 | + | |
| 316 | + | |
| 317 | + | |
| 318 | + | |
| 319 | + | |
| 320 | + | |
| 321 | + | |
| 322 | + | |
| 323 | + | |
| 324 | + | |
| 325 | + | |
| 326 | + | |
| 327 | + | |
| 328 | + | |
| 329 | + | |
| 330 | + | |
| 331 | + | |
| 332 | + | |
| 333 | + | |
| 334 | + | |
| 335 | + | |
| 336 | + | |
| 337 | + | |
| 338 | + | |
| 339 | + | |
| 340 | + | |
| 341 | + | |
| 342 | + | |
| 343 | + | |
| 344 | + | |
| 345 | + | |
| 346 | + | |
| 347 | + | |
| 348 | + | |
| 349 | + | |
| 350 | + | |
| 351 | + | |
| 352 | + | |
| 353 | + | |
| 354 | + | |
| 355 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
18 | 18 | | |
19 | 19 | | |
20 | 20 | | |
| 21 | + | |
| 22 | + | |
21 | 23 | | |
22 | 24 | | |
23 | 25 | | |
| |||
1751 | 1753 | | |
1752 | 1754 | | |
1753 | 1755 | | |
| 1756 | + | |
| 1757 | + | |
| 1758 | + | |
| 1759 | + | |
| 1760 | + | |
| 1761 | + | |
| 1762 | + | |
| 1763 | + | |
| 1764 | + | |
| 1765 | + | |
| 1766 | + | |
| 1767 | + | |
| 1768 | + | |
1754 | 1769 | | |
1755 | 1770 | | |
1756 | 1771 | | |
| |||
0 commit comments