Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions .changeset/24625-identity-mail-read-gate.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
---
'@objectstack/platform-objects': minor
'@objectstack/plugin-audit': minor
'@objectstack/plugin-approvals': minor
---

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

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.

<!-- 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). -->

**BREAKING** (a read narrowing), shipped as `minor` under the launch-window convention for breaking changes.

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.

- **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.
- **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.
- **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.
- **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.

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.
1 change: 1 addition & 0 deletions packages/platform-objects/src/audit/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ export { SysNotification } from './sys-notification.object.js';
export { SysAttachment } from './sys-attachment.object.js';
export { SysEmail } from './sys-email.object.js';
export { SysIdentityEmail } from './sys-identity-email.object.js';
export { MAIL_LOG_OBJECTS, MAIL_LOG_BODY_FIELDS, isMailLogObject } from './mail-log-body.js';
export { SysEmailTemplate } from './sys-email-template.object.js';
// sys_saved_report / sys_report_schedule retired with the saved-report stack (#20102).
// sys_approval_request / sys_approval_action moved to @objectstack/plugin-approvals (ADR-0029 K2.b).
Expand Down
39 changes: 39 additions & 0 deletions packages/platform-objects/src/audit/mail-log-body.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* [#24625] Both mail logs gate their BODY columns on the platform-only
* capability, at the field: the 18.0 identity rows still in `sys_email` carry
* reset and sign-in links and no column tells them apart from tenant mail, so
* the gate is on the columns of every row, while the row itself stays readable
* by an organization administrator.
*/

import { describe, it, expect } from 'vitest';
import { SysEmail } from './sys-email.object.js';
import { SysIdentityEmail } from './sys-identity-email.object.js';
import { MAIL_LOG_BODY_FIELDS, MAIL_LOG_OBJECTS, isMailLogObject } from './mail-log-body.js';

type FieldDecl = { requiredPermissions?: string[] };

describe('the mail logs\' body columns', () => {
it('names exactly the two mail logs', () => {
expect([...MAIL_LOG_OBJECTS].sort()).toEqual(['sys_email', 'sys_identity_email']);
expect(isMailLogObject('sys_email')).toBe(true);
expect(isMailLogObject('sys_identity_email')).toBe(true);
expect(isMailLogObject('sys_email_template')).toBe(false);
});

it.each([SysEmail, SysIdentityEmail])('$name gates each body column, and only those, on manage_platform_settings', (def) => {
const fields = def.fields as Record<string, FieldDecl>;
const gated = Object.entries(fields)
.filter(([, f]) => (f.requiredPermissions ?? []).length > 0)
.map(([name]) => name)
.sort();
expect(gated).toEqual([...MAIL_LOG_BODY_FIELDS].sort());
for (const name of MAIL_LOG_BODY_FIELDS) expect(fields[name].requiredPermissions).toEqual(['manage_platform_settings']);
});

it('leaves sys_email itself without an object-level read gate: the organization administrator keeps the row', () => {
expect(SysEmail.requiredPermissions).toBeUndefined();
});
});
30 changes: 30 additions & 0 deletions packages/platform-objects/src/audit/mail-log-body.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

import { SysEmail } from './sys-email.object.js';
import { SysIdentityEmail } from './sys-identity-email.object.js';

/**
* [#24625] The mail logs' BODY columns, named once for the readers that copy a
* mail-log row somewhere else.
*
* Both logs (`sys_email` and its identity sibling `sys_identity_email`) gate
* these columns on `manage_platform_settings` at the field: identity mail —
* password reset, account invitation, verification, magic link, email-change
* notice — carries sign-in and reset links in its body, and the 18.0 identity
* rows still in `sys_email` cannot be told apart from tenant mail by any
* column. A writer that COPIES a mail-log row (the `sys_activity` and
* `sys_audit_log` mirrors) masks these columns in the copy the way it masks a
* credential field, so the body is never held in clear a second time; the log
* row itself stays the one place the body lives, behind its gate.
*/

/** The two mail logs whose rows carry a body. */
export const MAIL_LOG_OBJECTS: readonly string[] = [SysEmail.name, SysIdentityEmail.name];

/** The body columns a copy of a mail-log row masks. */
export const MAIL_LOG_BODY_FIELDS: readonly string[] = ['body_text', 'body_html'];

/** True when `objectName` is one of the two mail logs. */
export function isMailLogObject(objectName: string): boolean {
return MAIL_LOG_OBJECTS.includes(objectName);
}
20 changes: 20 additions & 0 deletions packages/platform-objects/src/audit/sys-email.object.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,16 +95,36 @@ export const SysEmail = ObjectSchema.create({
group: 'Content',
}),

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

body_html: Field.textarea({
label: 'Body (HTML)',
required: false,
requiredPermissions: ['manage_platform_settings'],
group: 'Content',
}),

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -121,12 +121,14 @@ export const SysIdentityEmail = ObjectSchema.create({
label: 'Body (text)',
required: false,
searchable: true,
requiredPermissions: ['manage_platform_settings'],
group: 'Content',
}),

body_html: Field.textarea({
label: 'Body (HTML)',
required: false,
requiredPermissions: ['manage_platform_settings'],
group: 'Content',
}),

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,20 @@ export const SysApprovalRequest = ObjectSchema.create({
// (`sys-approval-request.page.ts` says why).
highlightFields: ['process_label', 'record_id', 'status', 'current_step_label', 'submitter_id', 'updated_at'],

// [#24625] Free-text search (ADR-0061) scans the auto-default set minus the
// snapshot, `payload_json`. A query naming the snapshot is refused to every
// reader withheld any field of any object (`payload-predicate-guard.ts`),
// and a `$search` names every searched column. With the snapshot in the
// set, one field withheld anywhere — an organization administrator's mail
// bodies — turned that reader's list search here, and its whole global
// search sweep, into a 403. A structured filter on the snapshot is
// unchanged, for the readers the guard admits.
searchableFields: [
'process_name', 'process_label', 'object_name', 'record_id', 'submitter_comment', 'status',
'cancel_reason', 'current_step', 'current_step_label', 'pending_approvers', 'pending_approver_labels',
'flow_run_id', 'flow_node_id', 'node_config_json',
],

// Curated built-in list views — render as segmented tabs in the console.
// Filters use {current_user_id} substitution wired by the console.
//
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* [#24625] `sys_approval_request` free-text search scans everything the
* ADR-0061 auto-default would, except the snapshot. A `$search` names every
* searched column, and the snapshot guard refuses a query naming
* `payload_json` to any reader withheld a field of any object — so with the
* snapshot searched, such a reader's list search and global search sweep were
* a 403. The declared list is held to the auto-default minus the snapshot, so
* a textual field added later is a red here rather than silently unsearched.
*/

import { describe, it, expect } from 'vitest';
import { resolveSearchFields } from '@objectstack/spec/data';
import { SysApprovalRequest } from './sys-approval-request.object.js';

const fields = SysApprovalRequest.fields as Record<string, { type?: string }>;

describe('sys_approval_request free-text search set', () => {
it('is the auto-default set without the snapshot column', () => {
const declared = resolveSearchFields({ fields, searchableFields: SysApprovalRequest.searchableFields });
const auto = resolveSearchFields({ fields });
expect(auto).toContain('payload_json');
expect(declared).not.toContain('payload_json');
expect([...declared].sort()).toEqual(auto.filter((f) => f !== 'payload_json').sort());
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,8 @@ import {
SysTwoFactor,
SysVerification,
} from '@objectstack/platform-objects/identity';
import { SysEmail } from '@objectstack/platform-objects/audit';
import { SysEmail, SysIdentityEmail } from '@objectstack/platform-objects/audit';
import { SECRET_MASK } from '@objectstack/objectql/core';
import { installAuditWriters } from './audit-writers.js';
import { singlePostureDefaultOrganization } from './__tests__/single-posture-tenancy.js';

Expand Down Expand Up @@ -289,3 +290,66 @@ describe('[#21197] the ledger CRUD mirror omits `internal` fields from both snap
});
});
});

/**
* [#24625] The mail logs' BODY columns are masked in every ledger copy, like a
* credential field: identity-mail bodies carry reset and sign-in links, and no
* column tells an 18.0 identity row in `sys_email` apart from tenant mail, so
* both logs are masked on every row. Same in-memory boot as above; the control
* (`subject`) proves the mirror still records the row.
*/
describe('[#24625] the ledger CRUD mirror masks a mail log\'s body columns', () => {
const BODY = ['body_text', 'body_html'];

describe.each([SysEmail, SysIdentityEmail].map((d) => [d.name, d as unknown as ObjectDef] as const))('%s', (_name, def) => {
it('create, update and delete record the subject and only the mask of each body', async () => {
const { engine, storeFor } = await boot(def);
const created = await engine.insert(def.name, seedRow(def, BODY, 'subject', 'create'), SYS);
const id = String(created.id);
const changed = seedRow(def, BODY, 'subject', 'update');
await engine.update(def.name, { subject: changed.subject, body_text: changed.body_text, body_html: changed.body_html }, { where: { id }, ...SYS } as any);
await engine.delete(def.name, { where: { id }, ...SYS } as any);

const rows = ledgerRows(storeFor, def.name);
const one = (a: string) => rows.filter((r) => r.action === a)[0];
const sides = {
createNew: parse(one('create').new_value),
updateOld: parse(one('update').old_value),
updateNew: parse(one('update').new_value),
deleteOld: parse(one('delete').old_value),
};
expect(sides.createNew.subject).toBe('control-create');
expect(sides.updateNew.subject).toBe('control-update');
for (const [side, snapshot] of Object.entries(sides)) {
for (const field of BODY) expect(snapshot[field], `${def.name}.${field} on ${side}`).toBe(SECRET_MASK);
}

const atRest = JSON.stringify([...rows, ...activityRows(storeFor, def.name)]);
for (const field of BODY) {
for (const phase of ['create', 'update']) expect(atRest.includes(marker(def.name, field, phase))).toBe(false);
}
expect(activityRows(storeFor, def.name).length, 'the activity mirror ran').toBeGreaterThan(0);
});

it('an unset body stays null in the copy — the mask never invents a value', async () => {
const { engine, storeFor } = await boot(def);
const row = seedRow(def, [], 'subject', 'create');
await engine.insert(def.name, { ...row, body_text: 'MARKER-plain-only', body_html: null }, SYS);
const created = parse(ledgerRows(storeFor, def.name).filter((r) => r.action === 'create')[0].new_value);
expect(created.body_text).toBe(SECRET_MASK);
expect(created.body_html ?? null).toBeNull();
});
});

it('an object that is not a mail log keeps its own textarea in the copy (control)', async () => {
const { engine, storeFor } = await boot(SysEmail as unknown as ObjectDef);
const other = {
name: 'mail_body_control', label: 'Control',
fields: { id: f('id', 'text', { primaryKey: true }), body_text: f('body_text', 'textarea') },
};
engine.registry.registerObject(other as any, 'com.objectstack.test.mail-body-control');
await engine.insert('mail_body_control', { body_text: 'control-body' }, SYS);
const created = parse(ledgerRows(storeFor, 'mail_body_control').filter((r) => r.action === 'create')[0].new_value);
expect(created.body_text).toBe('control-body');
});
});
15 changes: 15 additions & 0 deletions packages/plugins/plugin-audit/src/audit-writers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ import type { IDataEngine } from '@objectstack/spec/contracts';
// for the same value, so it honours the same declaration the same way — the
// producer's declaration consumed, not a masking rule of this package's own.
import { SECRET_MASK, collectInternalReadFields, collectMaskedReadFields } from '@objectstack/objectql/core';
// [#24625] The mail logs and their body columns, named once beside the objects.
import { MAIL_LOG_BODY_FIELDS, isMailLogObject } from '@objectstack/platform-objects/audit';
// [#7230] ADR-0079's record display-name contract, imported rather than
// re-derived. `resolveDisplayField` IS the definition of "which field is this
// object's human title" (`nameField` → deprecated `displayNameField` alias →
Expand Down Expand Up @@ -1746,6 +1748,19 @@ export function installAuditWriters(
for (const field of collectInternalReadFields(getObjectDef(objectName))) {
delete out[field];
}
// [#24625] A mail log's BODY is masked like a credential field, on every
// side this view is taken for. Identity mail (reset, verification, magic
// link, account invitation, email-change notice) carries sign-in and reset
// links in its body; every `sys_identity_email` row is one, and the 18.0
// identity rows still in `sys_email` cannot be told apart from tenant mail
// by any column, so both logs' bodies are masked on every row. The log row
// keeps the body behind its field gate; the copy never holds it in clear.
if (isMailLogObject(objectName)) {
for (const field of MAIL_LOG_BODY_FIELDS) {
if (!(field in out)) continue;
out[field] = out[field] == null ? null : SECRET_MASK;
}
}
// [#21120] A stored metadata BODY (`sys_metadata` / `sys_metadata_history`)
// is not a top-level secret field, so the mask above never touches it. The
// audit copy is a credential read exit exactly like `/meta`, so the body is
Expand Down
Loading
Loading