Skip to content

Commit d117382

Browse files
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
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
---
2+
'@objectstack/platform-objects': minor
3+
'@objectstack/plugin-audit': minor
4+
'@objectstack/plugin-approvals': minor
5+
---
6+
7+
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
8+
9+
Clause-②: yes (narrowing). A principal without `manage_platform_settings` (an organization administrator, a member) loses read access to the body of every mail-log row.
10+
11+
<!-- adr-0087: not-required (no-migration-prescription) A read-access narrowing on two existing fields (sys_email.body_text and body_html, and the same columns of sys_identity_email), plus a masked value in the audit copies and one object's free-text search set. No spec key, enum value, export, option, config field, response field or stored shape is removed, renamed or re-shaped, so there is no tombstone and nothing for `objectstack migrate meta` to rewrite. The three packages publish (not unpublished); no ADR-0087 id covers this behaviour and this diff adds none (not registered / already-registered); and the change is a behaviour change, not only an interface or type change (not runtime-interface-only / type-surface-only). -->
12+
13+
**BREAKING** (a read narrowing), shipped as `minor` under the launch-window convention for breaking changes.
14+
15+
An 18.0 install wrote its identity mail (password reset, account invitation, verification, magic link, email-change notice) into `sys_email`, stamped with the organization. Those bodies carry reset and sign-in links. `sys_email` had no read gate of its own, so an organization administrator could read them through the data API, global search, and the `sys_activity` / `sys_audit_log` mirrors. New identity mail goes to `sys_identity_email`, but its mirror rows still held the body in clear.
16+
17+
- **Before:** any reader of `sys_email` rows was served `body_text` and `body_html`, could filter and search on them, and found them in the activity and audit copies of the row. The audit writers copied both logs' bodies verbatim.
18+
- **Now:** `body_text` and `body_html` on `sys_email` and `sys_identity_email` declare `requiredPermissions: ['manage_platform_settings']`. A reader without the capability still gets the row: envelope, subject, delivery state and linkage. The body keys are left out. A filter on a body is refused (403). Global search does not match on a body. The activity and audit mirrors serve the reader no body. Their unpinned value-column queries are refused to such a reader, as for any reader withheld a field. The audit writers store `••••••••` for a non-empty body in every copy of a mail-log row (create, update and delete snapshots, and the activity row), as they do for a credential field. `sys_approval_request` free-text search no longer scans `payload_json`, so a reader withheld any field keeps working list and global search instead of a 403. A structured filter on the snapshot is unchanged.
19+
- **Unchanged:** the platform administrator reads every body. The mail pipeline (send, queue worker, outbox sweep, reclaim) reads as the system and is not narrowed. Rows and mirror copies written before this release keep what they stored; the mirrors still serve those copies only to a reader served the body.
20+
- **Why the whole column:** no column tells an 18.0 identity row apart from tenant mail. A magic link carries no `related_object`, and the log has no template column. So the gate covers every row's body, the organization's own mail included.
21+
22+
Remedy: a user who must read mail bodies (for example, to debug a notification template) needs a permission set that grants `manage_platform_settings`. An organization administrator still sees that each message was sent, to whom and with what result.

‎packages/platform-objects/src/audit/index.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ export { SysNotification } from './sys-notification.object.js';
1313
export { SysAttachment } from './sys-attachment.object.js';
1414
export { SysEmail } from './sys-email.object.js';
1515
export { SysIdentityEmail } from './sys-identity-email.object.js';
16+
export { MAIL_LOG_OBJECTS, MAIL_LOG_BODY_FIELDS, isMailLogObject } from './mail-log-body.js';
1617
export { SysEmailTemplate } from './sys-email-template.object.js';
1718
// sys_saved_report / sys_report_schedule retired with the saved-report stack (#20102).
1819
// sys_approval_request / sys_approval_action moved to @objectstack/plugin-approvals (ADR-0029 K2.b).
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#24625] Both mail logs gate their BODY columns on the platform-only
5+
* capability, at the field: the 18.0 identity rows still in `sys_email` carry
6+
* reset and sign-in links and no column tells them apart from tenant mail, so
7+
* the gate is on the columns of every row, while the row itself stays readable
8+
* by an organization administrator.
9+
*/
10+
11+
import { describe, it, expect } from 'vitest';
12+
import { SysEmail } from './sys-email.object.js';
13+
import { SysIdentityEmail } from './sys-identity-email.object.js';
14+
import { MAIL_LOG_BODY_FIELDS, MAIL_LOG_OBJECTS, isMailLogObject } from './mail-log-body.js';
15+
16+
type FieldDecl = { requiredPermissions?: string[] };
17+
18+
describe('the mail logs\' body columns', () => {
19+
it('names exactly the two mail logs', () => {
20+
expect([...MAIL_LOG_OBJECTS].sort()).toEqual(['sys_email', 'sys_identity_email']);
21+
expect(isMailLogObject('sys_email')).toBe(true);
22+
expect(isMailLogObject('sys_identity_email')).toBe(true);
23+
expect(isMailLogObject('sys_email_template')).toBe(false);
24+
});
25+
26+
it.each([SysEmail, SysIdentityEmail])('$name gates each body column, and only those, on manage_platform_settings', (def) => {
27+
const fields = def.fields as Record<string, FieldDecl>;
28+
const gated = Object.entries(fields)
29+
.filter(([, f]) => (f.requiredPermissions ?? []).length > 0)
30+
.map(([name]) => name)
31+
.sort();
32+
expect(gated).toEqual([...MAIL_LOG_BODY_FIELDS].sort());
33+
for (const name of MAIL_LOG_BODY_FIELDS) expect(fields[name].requiredPermissions).toEqual(['manage_platform_settings']);
34+
});
35+
36+
it('leaves sys_email itself without an object-level read gate: the organization administrator keeps the row', () => {
37+
expect(SysEmail.requiredPermissions).toBeUndefined();
38+
});
39+
});
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import { SysEmail } from './sys-email.object.js';
4+
import { SysIdentityEmail } from './sys-identity-email.object.js';
5+
6+
/**
7+
* [#24625] The mail logs' BODY columns, named once for the readers that copy a
8+
* mail-log row somewhere else.
9+
*
10+
* Both logs (`sys_email` and its identity sibling `sys_identity_email`) gate
11+
* these columns on `manage_platform_settings` at the field: identity mail —
12+
* password reset, account invitation, verification, magic link, email-change
13+
* notice — carries sign-in and reset links in its body, and the 18.0 identity
14+
* rows still in `sys_email` cannot be told apart from tenant mail by any
15+
* column. A writer that COPIES a mail-log row (the `sys_activity` and
16+
* `sys_audit_log` mirrors) masks these columns in the copy the way it masks a
17+
* credential field, so the body is never held in clear a second time; the log
18+
* row itself stays the one place the body lives, behind its gate.
19+
*/
20+
21+
/** The two mail logs whose rows carry a body. */
22+
export const MAIL_LOG_OBJECTS: readonly string[] = [SysEmail.name, SysIdentityEmail.name];
23+
24+
/** The body columns a copy of a mail-log row masks. */
25+
export const MAIL_LOG_BODY_FIELDS: readonly string[] = ['body_text', 'body_html'];
26+
27+
/** True when `objectName` is one of the two mail logs. */
28+
export function isMailLogObject(objectName: string): boolean {
29+
return MAIL_LOG_OBJECTS.includes(objectName);
30+
}

‎packages/platform-objects/src/audit/sys-email.object.ts‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,16 +95,36 @@ export const SysEmail = ObjectSchema.create({
9595
group: 'Content',
9696
}),
9797

98+
// [#24625] The two BODY columns are read-gated on the platform-only
99+
// capability (ADR-0066 D3 field `requiredPermissions`: the key is dropped
100+
// on read for a caller without it). An 18.0 install wrote its identity
101+
// mail — password reset, account invitation, verification, magic link,
102+
// email-change notice — into this log, stamped with the organization, and
103+
// those bodies carry sign-in and reset links. The rows stay here until the
104+
// 18.x ceremony moves them, and no column tells them apart from tenant mail
105+
// reliably: a magic link carries no `related_object`, and the log has no
106+
// template column. So the gate is on the columns, for every row: an
107+
// organization administrator keeps the row (envelope, subject, delivery
108+
// state, linkage) and loses only the body. The same field-level answer
109+
// closes every door that serves a row through the engine — the data API,
110+
// export, global search (which matches only on the fields the caller may
111+
// query, so a body term finds nothing) and the `sys_activity` /
112+
// `sys_audit_log` read redaction (which serves a mirror the fields the data
113+
// plane serves). The mirrors' WRITERS mask these columns too
114+
// (`mail-log-body.ts`). The delivery pipeline reads as the system, which a
115+
// field gate does not narrow.
98116
body_text: Field.textarea({
99117
label: 'Body (text)',
100118
required: false,
101119
searchable: true,
120+
requiredPermissions: ['manage_platform_settings'],
102121
group: 'Content',
103122
}),
104123

105124
body_html: Field.textarea({
106125
label: 'Body (HTML)',
107126
required: false,
127+
requiredPermissions: ['manage_platform_settings'],
108128
group: 'Content',
109129
}),
110130

‎packages/platform-objects/src/audit/sys-identity-email.object.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,12 +121,14 @@ export const SysIdentityEmail = ObjectSchema.create({
121121
label: 'Body (text)',
122122
required: false,
123123
searchable: true,
124+
requiredPermissions: ['manage_platform_settings'],
124125
group: 'Content',
125126
}),
126127

127128
body_html: Field.textarea({
128129
label: 'Body (HTML)',
129130
required: false,
131+
requiredPermissions: ['manage_platform_settings'],
130132
group: 'Content',
131133
}),
132134

‎packages/plugins/plugin-approvals/src/sys-approval-request.object.ts‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,20 @@ export const SysApprovalRequest = ObjectSchema.create({
7272
// (`sys-approval-request.page.ts` says why).
7373
highlightFields: ['process_label', 'record_id', 'status', 'current_step_label', 'submitter_id', 'updated_at'],
7474

75+
// [#24625] Free-text search (ADR-0061) scans the auto-default set minus the
76+
// snapshot, `payload_json`. A query naming the snapshot is refused to every
77+
// reader withheld any field of any object (`payload-predicate-guard.ts`),
78+
// and a `$search` names every searched column. With the snapshot in the
79+
// set, one field withheld anywhere — an organization administrator's mail
80+
// bodies — turned that reader's list search here, and its whole global
81+
// search sweep, into a 403. A structured filter on the snapshot is
82+
// unchanged, for the readers the guard admits.
83+
searchableFields: [
84+
'process_name', 'process_label', 'object_name', 'record_id', 'submitter_comment', 'status',
85+
'cancel_reason', 'current_step', 'current_step_label', 'pending_approvers', 'pending_approver_labels',
86+
'flow_run_id', 'flow_node_id', 'node_config_json',
87+
],
88+
7589
// Curated built-in list views — render as segmented tabs in the console.
7690
// Filters use {current_user_id} substitution wired by the console.
7791
//
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#24625] `sys_approval_request` free-text search scans everything the
5+
* ADR-0061 auto-default would, except the snapshot. A `$search` names every
6+
* searched column, and the snapshot guard refuses a query naming
7+
* `payload_json` to any reader withheld a field of any object — so with the
8+
* snapshot searched, such a reader's list search and global search sweep were
9+
* a 403. The declared list is held to the auto-default minus the snapshot, so
10+
* a textual field added later is a red here rather than silently unsearched.
11+
*/
12+
13+
import { describe, it, expect } from 'vitest';
14+
import { resolveSearchFields } from '@objectstack/spec/data';
15+
import { SysApprovalRequest } from './sys-approval-request.object.js';
16+
17+
const fields = SysApprovalRequest.fields as Record<string, { type?: string }>;
18+
19+
describe('sys_approval_request free-text search set', () => {
20+
it('is the auto-default set without the snapshot column', () => {
21+
const declared = resolveSearchFields({ fields, searchableFields: SysApprovalRequest.searchableFields });
22+
const auto = resolveSearchFields({ fields });
23+
expect(auto).toContain('payload_json');
24+
expect(declared).not.toContain('payload_json');
25+
expect([...declared].sort()).toEqual(auto.filter((f) => f !== 'payload_json').sort());
26+
});
27+
});

‎packages/plugins/plugin-audit/src/audit-internal-field-omission.test.ts‎

Lines changed: 65 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,8 @@ import {
3939
SysTwoFactor,
4040
SysVerification,
4141
} from '@objectstack/platform-objects/identity';
42-
import { SysEmail } from '@objectstack/platform-objects/audit';
42+
import { SysEmail, SysIdentityEmail } from '@objectstack/platform-objects/audit';
43+
import { SECRET_MASK } from '@objectstack/objectql/core';
4344
import { installAuditWriters } from './audit-writers.js';
4445
import { singlePostureDefaultOrganization } from './__tests__/single-posture-tenancy.js';
4546

@@ -289,3 +290,66 @@ describe('[#21197] the ledger CRUD mirror omits `internal` fields from both snap
289290
});
290291
});
291292
});
293+
294+
/**
295+
* [#24625] The mail logs' BODY columns are masked in every ledger copy, like a
296+
* credential field: identity-mail bodies carry reset and sign-in links, and no
297+
* column tells an 18.0 identity row in `sys_email` apart from tenant mail, so
298+
* both logs are masked on every row. Same in-memory boot as above; the control
299+
* (`subject`) proves the mirror still records the row.
300+
*/
301+
describe('[#24625] the ledger CRUD mirror masks a mail log\'s body columns', () => {
302+
const BODY = ['body_text', 'body_html'];
303+
304+
describe.each([SysEmail, SysIdentityEmail].map((d) => [d.name, d as unknown as ObjectDef] as const))('%s', (_name, def) => {
305+
it('create, update and delete record the subject and only the mask of each body', async () => {
306+
const { engine, storeFor } = await boot(def);
307+
const created = await engine.insert(def.name, seedRow(def, BODY, 'subject', 'create'), SYS);
308+
const id = String(created.id);
309+
const changed = seedRow(def, BODY, 'subject', 'update');
310+
await engine.update(def.name, { subject: changed.subject, body_text: changed.body_text, body_html: changed.body_html }, { where: { id }, ...SYS } as any);
311+
await engine.delete(def.name, { where: { id }, ...SYS } as any);
312+
313+
const rows = ledgerRows(storeFor, def.name);
314+
const one = (a: string) => rows.filter((r) => r.action === a)[0];
315+
const sides = {
316+
createNew: parse(one('create').new_value),
317+
updateOld: parse(one('update').old_value),
318+
updateNew: parse(one('update').new_value),
319+
deleteOld: parse(one('delete').old_value),
320+
};
321+
expect(sides.createNew.subject).toBe('control-create');
322+
expect(sides.updateNew.subject).toBe('control-update');
323+
for (const [side, snapshot] of Object.entries(sides)) {
324+
for (const field of BODY) expect(snapshot[field], `${def.name}.${field} on ${side}`).toBe(SECRET_MASK);
325+
}
326+
327+
const atRest = JSON.stringify([...rows, ...activityRows(storeFor, def.name)]);
328+
for (const field of BODY) {
329+
for (const phase of ['create', 'update']) expect(atRest.includes(marker(def.name, field, phase))).toBe(false);
330+
}
331+
expect(activityRows(storeFor, def.name).length, 'the activity mirror ran').toBeGreaterThan(0);
332+
});
333+
334+
it('an unset body stays null in the copy — the mask never invents a value', async () => {
335+
const { engine, storeFor } = await boot(def);
336+
const row = seedRow(def, [], 'subject', 'create');
337+
await engine.insert(def.name, { ...row, body_text: 'MARKER-plain-only', body_html: null }, SYS);
338+
const created = parse(ledgerRows(storeFor, def.name).filter((r) => r.action === 'create')[0].new_value);
339+
expect(created.body_text).toBe(SECRET_MASK);
340+
expect(created.body_html ?? null).toBeNull();
341+
});
342+
});
343+
344+
it('an object that is not a mail log keeps its own textarea in the copy (control)', async () => {
345+
const { engine, storeFor } = await boot(SysEmail as unknown as ObjectDef);
346+
const other = {
347+
name: 'mail_body_control', label: 'Control',
348+
fields: { id: f('id', 'text', { primaryKey: true }), body_text: f('body_text', 'textarea') },
349+
};
350+
engine.registry.registerObject(other as any, 'com.objectstack.test.mail-body-control');
351+
await engine.insert('mail_body_control', { body_text: 'control-body' }, SYS);
352+
const created = parse(ledgerRows(storeFor, 'mail_body_control').filter((r) => r.action === 'create')[0].new_value);
353+
expect(created.body_text).toBe('control-body');
354+
});
355+
});

‎packages/plugins/plugin-audit/src/audit-writers.ts‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ import type { IDataEngine } from '@objectstack/spec/contracts';
1818
// for the same value, so it honours the same declaration the same way — the
1919
// producer's declaration consumed, not a masking rule of this package's own.
2020
import { SECRET_MASK, collectInternalReadFields, collectMaskedReadFields } from '@objectstack/objectql/core';
21+
// [#24625] The mail logs and their body columns, named once beside the objects.
22+
import { MAIL_LOG_BODY_FIELDS, isMailLogObject } from '@objectstack/platform-objects/audit';
2123
// [#7230] ADR-0079's record display-name contract, imported rather than
2224
// re-derived. `resolveDisplayField` IS the definition of "which field is this
2325
// object's human title" (`nameField` → deprecated `displayNameField` alias →
@@ -1751,6 +1753,19 @@ export function installAuditWriters(
17511753
for (const field of collectInternalReadFields(getObjectDef(objectName))) {
17521754
delete out[field];
17531755
}
1756+
// [#24625] A mail log's BODY is masked like a credential field, on every
1757+
// side this view is taken for. Identity mail (reset, verification, magic
1758+
// link, account invitation, email-change notice) carries sign-in and reset
1759+
// links in its body; every `sys_identity_email` row is one, and the 18.0
1760+
// identity rows still in `sys_email` cannot be told apart from tenant mail
1761+
// by any column, so both logs' bodies are masked on every row. The log row
1762+
// keeps the body behind its field gate; the copy never holds it in clear.
1763+
if (isMailLogObject(objectName)) {
1764+
for (const field of MAIL_LOG_BODY_FIELDS) {
1765+
if (!(field in out)) continue;
1766+
out[field] = out[field] == null ? null : SECRET_MASK;
1767+
}
1768+
}
17541769
// [#21120] A stored metadata BODY (`sys_metadata` / `sys_metadata_history`)
17551770
// is not a top-level secret field, so the mask above never touches it. The
17561771
// audit copy is a credential read exit exactly like `/meta`, so the body is

0 commit comments

Comments
 (0)