From 276696eeb050a9f0fbce019b963e4eb755110f62 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 22:56:17 +0000 Subject: [PATCH 1/3] fix(platform-objects,plugin-audit): gate mail-log bodies on manage_platform_settings and mask them in the ledger mirrors Claude-Session: https://claude.ai/code/session_01CAtkMwk5tY1jPaUCdKosnk Co-authored-by: Claude --- packages/platform-objects/src/audit/index.ts | 1 + .../src/audit/mail-log-body.test.ts | 39 +++ .../src/audit/mail-log-body.ts | 30 ++ .../src/audit/sys-email.object.ts | 20 ++ .../src/audit/sys-identity-email.object.ts | 2 + .../src/audit-internal-field-omission.test.ts | 66 +++- .../plugins/plugin-audit/src/audit-writers.ts | 15 + .../identity-mail-read-gate.dogfood.test.ts | 283 ++++++++++++++++++ 8 files changed, 455 insertions(+), 1 deletion(-) create mode 100644 packages/platform-objects/src/audit/mail-log-body.test.ts create mode 100644 packages/platform-objects/src/audit/mail-log-body.ts create mode 100644 packages/qa/dogfood/test/identity-mail-read-gate.dogfood.test.ts diff --git a/packages/platform-objects/src/audit/index.ts b/packages/platform-objects/src/audit/index.ts index 3fe76ef375c..70e3da6439d 100644 --- a/packages/platform-objects/src/audit/index.ts +++ b/packages/platform-objects/src/audit/index.ts @@ -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). diff --git a/packages/platform-objects/src/audit/mail-log-body.test.ts b/packages/platform-objects/src/audit/mail-log-body.test.ts new file mode 100644 index 00000000000..fc64afb919e --- /dev/null +++ b/packages/platform-objects/src/audit/mail-log-body.test.ts @@ -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; + 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(); + }); +}); diff --git a/packages/platform-objects/src/audit/mail-log-body.ts b/packages/platform-objects/src/audit/mail-log-body.ts new file mode 100644 index 00000000000..e02202e9b5b --- /dev/null +++ b/packages/platform-objects/src/audit/mail-log-body.ts @@ -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); +} diff --git a/packages/platform-objects/src/audit/sys-email.object.ts b/packages/platform-objects/src/audit/sys-email.object.ts index 5e88fa22e8b..4095336aad8 100644 --- a/packages/platform-objects/src/audit/sys-email.object.ts +++ b/packages/platform-objects/src/audit/sys-email.object.ts @@ -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', }), diff --git a/packages/platform-objects/src/audit/sys-identity-email.object.ts b/packages/platform-objects/src/audit/sys-identity-email.object.ts index 0885f512a47..2051dd21cb6 100644 --- a/packages/platform-objects/src/audit/sys-identity-email.object.ts +++ b/packages/platform-objects/src/audit/sys-identity-email.object.ts @@ -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', }), diff --git a/packages/plugins/plugin-audit/src/audit-internal-field-omission.test.ts b/packages/plugins/plugin-audit/src/audit-internal-field-omission.test.ts index 6027ad45058..b89e685f118 100644 --- a/packages/plugins/plugin-audit/src/audit-internal-field-omission.test.ts +++ b/packages/plugins/plugin-audit/src/audit-internal-field-omission.test.ts @@ -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'; @@ -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'); + }); +}); diff --git a/packages/plugins/plugin-audit/src/audit-writers.ts b/packages/plugins/plugin-audit/src/audit-writers.ts index 70e3235b024..fad085a8a1d 100644 --- a/packages/plugins/plugin-audit/src/audit-writers.ts +++ b/packages/plugins/plugin-audit/src/audit-writers.ts @@ -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 → @@ -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 diff --git a/packages/qa/dogfood/test/identity-mail-read-gate.dogfood.test.ts b/packages/qa/dogfood/test/identity-mail-read-gate.dogfood.test.ts new file mode 100644 index 00000000000..5bfdaaadb95 --- /dev/null +++ b/packages/qa/dogfood/test/identity-mail-read-gate.dogfood.test.ts @@ -0,0 +1,283 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// [#24625] Identity-mail bodies are read only by the platform capability, on +// every door, over an 18.0 database. +// +// ## What was measured +// +// 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, and `sys_email` declared no read gate of its own. An +// organization administrator without `manage_platform_settings` read those +// bodies — and the reset and sign-in links in them — on the data API, in +// global search, and in the `sys_activity` / `sys_audit_log` mirrors. New +// identity mail goes to `sys_identity_email` (object-gated on the capability), +// but its mirror rows held the body in clear at rest. +// +// ## The fix pinned here +// +// No column tells an 18.0 identity row apart from tenant mail reliably (a magic +// link carries no `related_object`; the log has no template column), so the +// gate is on the BODY columns of both mail logs: field `requiredPermissions: +// ['manage_platform_settings']`. The mirror writers mask the same columns. +// +// ## The matrix, principal × door +// +// - anonymous: refused on the data API; +// - member: refused on the data API, no hit in search, no body in a mirror; +// - organization admin (no platform capability): the identity row AND the +// organization's own non-identity row are served — envelope, subject and +// delivery state — without either body; a filter on a body is refused; a +// body term finds nothing in search; the mirrors serve no body; the +// identity log refuses outright; +// - platform admin (the control): the bodies on every door, so each "absent" +// above is a gate and not an empty fixture. +// +// And the writers: a mirror row written for a mail-log row on HEAD carries no +// body in clear, for the identity log and the tenant log alike. +// +// ⛔ No body, link or token value appears in a test title, an assertion +// message or a log line: every probe compares a value read as the system +// against a response by inclusion, and reports a boolean. +import { mkdtempSync, readFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import type { VerifyStack } from '@objectstack/verify'; +import { FIXTURE_PEOPLE, bootFixture } from './upgrade-harness/populate-fixture.js'; +import { loadSqlIntoSqlite } from './upgrade-harness/sqlite-text-dump.js'; + +const FIXTURE = new URL('./upgrade-harness/fixture-18.0/', import.meta.url); +const fixtureSql = readFileSync(new URL('database.sql', FIXTURE), 'utf8'); + +const SYS = { context: { isSystem: true } } as const; +const BODY_FIELDS = ['body_text', 'body_html'] as const; + +type Row = Record; +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const rowsOf = (body: any): Row[] => (Array.isArray(body) ? body : (body?.records ?? body?.data ?? body?.value ?? [])); +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const recordOf = (body: any): Row => body?.record ?? body?.data ?? body; +const filterOf = (where: Record) => encodeURIComponent(JSON.stringify(where)); +const hasBodyKey = (row: Row) => BODY_FIELDS.some((f) => f in row); + +/** + * The longest run of link-token characters in a stored body — the part of a + * reset or invitation link that IS the credential. A response "contains the + * body" exactly when it contains this span; JSON escaping cannot hide it. + */ +function credentialSpanOf(body: unknown): string { + const runs = String(body ?? '').match(/[A-Za-z0-9_-]{16,}/g) ?? []; + const longest = runs.sort((a, b) => b.length - a.length)[0]; + if (!longest) throw new Error('the stored body carries no link token to probe with'); + return longest; +} + +describe('[#24625] identity-mail bodies over an 18.0 database: principal × door', () => { + const dir = mkdtempSync(join(tmpdir(), 'dogfood-identity-mail-gate-')); + const file = join(dir, 'upgraded.db'); + let stack: VerifyStack; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let ql: any; + const tok: Record<'platform' | 'orgAdmin' | 'member', string> = { platform: '', orgAdmin: '', member: '' }; + /** The fixture's two `sys_email` rows: the 18.0 password reset and the organization invitation. */ + let reset: Row; + let invitation: Row; + let resetSpan = ''; + + const call = async (who: keyof typeof tok | 'anonymous', method: string, path: string, body?: unknown) => { + const res = who === 'anonymous' + ? await stack.api(path, { method, headers: { 'Content-Type': 'application/json' }, ...(body ? { body: JSON.stringify(body) } : {}) }) + : await stack.apiAs(tok[who], method, path, body); + const text = await res.text(); + let json: unknown; + try { json = JSON.parse(text); } catch { json = undefined; } + return { status: res.status, text, json }; + }; + + beforeAll(async () => { + loadSqlIntoSqlite(fixtureSql, file); + stack = await bootFixture(file); + ql = await stack.kernel.getServiceAsync('objectql'); + + const mails: Row[] = await ql.find('sys_email', { where: {}, limit: 50, ...SYS }); + reset = mails.find((m) => m.related_object === 'sys_user')!; + invitation = mails.find((m) => m.related_object === 'sys_invitation')!; + expect(reset, 'the fixture holds the 18.0 password-reset row').toBeTruthy(); + expect(invitation, 'the fixture holds the organization-invitation row').toBeTruthy(); + resetSpan = credentialSpanOf(reset.body_text); + + // The organization administrator: fixture member A, promoted to the + // organization's `admin` role. The membership hook grants the scoped + // organization-admin set; nothing grants the platform capability. + const [userA] = await ql.find('sys_user', { where: { email: FIXTURE_PEOPLE.memberA.email }, limit: 1, ...SYS }); + const [membership] = await ql.find('sys_member', { where: { user_id: userA.id }, limit: 1, ...SYS }); + expect(membership?.organization_id, 'member A belongs to the fixture organization').toBe(reset.organization_id); + await ql.update('sys_member', { id: membership.id, role: 'admin' }, SYS); + + tok.platform = await stack.signIn(FIXTURE_PEOPLE.admin.email, FIXTURE_PEOPLE.admin.password); + tok.orgAdmin = await stack.signIn(FIXTURE_PEOPLE.memberA.email, FIXTURE_PEOPLE.memberA.password); + tok.member = await stack.signIn(FIXTURE_PEOPLE.memberB.email, FIXTURE_PEOPLE.memberB.password); + for (const who of ['platform', 'orgAdmin', 'member'] as const) { + await call(who, 'POST', '/auth/organization/set-active', { organizationId: reset.organization_id }); + } + }, 240_000); + + afterAll(async () => { + await stack?.stop(); + rmSync(dir, { recursive: true, force: true }); + }); + + describe('the data API: list, get and query of sys_email', () => { + it('anonymous is refused', async () => { + expect((await call('anonymous', 'GET', '/data/sys_email')).status).toBe(401); + }); + + it('a member is refused', async () => { + const res = await call('member', 'GET', '/data/sys_email'); + expect(res.status).toBe(403); + expect(res.text.includes(resetSpan)).toBe(false); + }); + + it('the organization admin lists both rows — the identity row and its own invitation — without a body', async () => { + const res = await call('orgAdmin', 'GET', '/data/sys_email'); + expect(res.status).toBe(200); + const rows = rowsOf(res.json); + const byId = new Map(rows.map((r) => [r.id, r])); + // The rows are served, with their envelope and state: the gate is on the body only. + for (const mail of [reset, invitation]) { + const row = byId.get(mail.id); + expect(row, 'the row is listed').toBeTruthy(); + expect(row!.subject).toBe(mail.subject); + expect(row!.status).toBe(mail.status); + expect(hasBodyKey(row!)).toBe(false); + } + expect(res.text.includes(resetSpan)).toBe(false); + }); + + it('the organization admin gets the identity row by id without a body, even naming the body in select', async () => { + for (const path of [`/data/sys_email/${reset.id}`, `/data/sys_email/${reset.id}?select=id,subject,body_text,body_html`]) { + const res = await call('orgAdmin', 'GET', path); + expect(res.text.includes(resetSpan)).toBe(false); + if (res.status === 200) expect(hasBodyKey(recordOf(res.json))).toBe(false); + else expect(res.status).toBe(403); + } + }); + + it('the organization admin queries the identity row without a body, and a filter on the body is refused', async () => { + const byId = await call('orgAdmin', 'POST', '/data/sys_email/query', { where: { id: reset.id } }); + expect(byId.status).toBe(200); + expect(rowsOf(byId.json).map((r) => r.id)).toEqual([reset.id]); + expect(hasBodyKey(rowsOf(byId.json)[0])).toBe(false); + // A body predicate would disclose the body by row presence: refused, not answered. + const probe = await call('orgAdmin', 'POST', '/data/sys_email/query', { where: { body_text: { $contains: resetSpan } } }); + expect(probe.status).toBe(403); + }); + + it('the platform admin is served the bodies (control)', async () => { + const list = await call('platform', 'GET', '/data/sys_email'); + expect(list.status).toBe(200); + const row = rowsOf(list.json).find((r) => r.id === reset.id); + expect(row && BODY_FIELDS.every((f) => typeof row[f] === 'string' && row[f].length > 0)).toBe(true); + expect(list.text.includes(resetSpan)).toBe(true); + const probe = await call('platform', 'POST', '/data/sys_email/query', { where: { body_text: { $contains: resetSpan } } }); + expect(probe.status).toBe(200); + expect(rowsOf(probe.json).map((r) => r.id)).toEqual([reset.id]); + }); + }); + + describe('global search', () => { + const sysEmailHits = (json: any): string[] => + (Array.isArray(json?.hits) ? json.hits : []).filter((h: any) => h?.object === 'sys_email').map((h: any) => String(h.id)); + + it.each(['member', 'orgAdmin'] as const)('a body term finds nothing for the %s', async (who) => { + const res = await call(who, 'GET', `/search?q=${encodeURIComponent(resetSpan)}`); + expect(res.status).toBe(200); + expect(sysEmailHits(res.json)).not.toContain(reset.id); + // The hits only: the body echoes the query term itself. + expect(JSON.stringify((res.json as any)?.hits ?? []).includes(resetSpan)).toBe(false); + }); + + it('the same term finds the identity row for the platform admin (control)', async () => { + const res = await call('platform', 'GET', `/search?q=${encodeURIComponent(resetSpan)}`); + expect(res.status).toBe(200); + expect(sysEmailHits(res.json)).toContain(reset.id); + }); + }); + + describe('the mirrors: sys_activity and sys_audit_log of the 18.0 identity row', () => { + const mirrorPath = (object: 'sys_activity' | 'sys_audit_log') => + `/data/${object}?$filter=${filterOf({ object_name: 'sys_email', record_id: reset.id })}`; + + it.each(['sys_activity', 'sys_audit_log'] as const)('%s: the organization admin is served the rows without the body', async (object) => { + const res = await call('orgAdmin', 'GET', mirrorPath(object)); + expect(res.status).toBe(200); + expect(res.text.includes(resetSpan)).toBe(false); + }); + + it.each(['sys_activity', 'sys_audit_log'] as const)('%s: a member is served no body', async (object) => { + const res = await call('member', 'GET', mirrorPath(object)); + expect(res.text.includes(resetSpan)).toBe(false); + }); + + it.each(['sys_activity', 'sys_audit_log'] as const)('%s: the platform admin reads the 18.0 copy (control: the redaction is per reader)', async (object) => { + const res = await call('platform', 'GET', mirrorPath(object)); + expect(res.status).toBe(200); + expect(rowsOf(res.json).length).toBeGreaterThan(0); + expect(res.text.includes(resetSpan)).toBe(true); + }); + }); + + describe('sys_identity_email and the mirror writers on HEAD', () => { + let identityRow: Row; + let identitySpan = ''; + let tenantRow: Row; + let tenantSpan = ''; + + beforeAll(async () => { + // HEAD's own identity mail, through the real door: a password reset. + const requested = await stack.api('/auth/request-password-reset', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email: FIXTURE_PEOPLE.memberB.email, redirectTo: '/reset-password' }), + }); + expect(requested.status).toBe(200); + [identityRow] = await ql.find('sys_identity_email', { where: {}, orderBy: [{ field: 'created_at', order: 'desc' }], limit: 1, ...SYS }); + expect(identityRow, 'HEAD wrote the reset to the identity log').toBeTruthy(); + identitySpan = credentialSpanOf(identityRow.body_text); + + // HEAD's own tenant mail: an organization invitation. + const before = new Set((await ql.find('sys_email', { where: {}, limit: 100, ...SYS })).map((r: Row) => r.id)); + const invite = await call('platform', 'POST', '/auth/organization/invite-member', { + email: 'identity-mail-gate.invitee@verify.test', role: 'member', organizationId: invitation.organization_id, + }); + expect(invite.status).toBe(200); + tenantRow = (await ql.find('sys_email', { where: {}, limit: 100, ...SYS })).find((r: Row) => !before.has(r.id)); + expect(tenantRow, 'HEAD wrote the invitation to the tenant log').toBeTruthy(); + tenantSpan = credentialSpanOf(tenantRow.body_text); + }, 60_000); + + it.each(['orgAdmin', 'member'] as const)('the %s is refused the identity log', async (who) => { + const res = await call(who, 'GET', '/data/sys_identity_email'); + expect(res.status).toBe(403); + expect(res.text.includes(identitySpan)).toBe(false); + }); + + it('the platform admin reads the identity log, bodies included (control)', async () => { + const res = await call('platform', 'GET', `/data/sys_identity_email/${identityRow.id}`); + expect(res.status).toBe(200); + expect(res.text.includes(identitySpan)).toBe(true); + }); + + it.each([ + ['sys_identity_email', () => identityRow, () => identitySpan], + ['sys_email', () => tenantRow, () => tenantSpan], + ] as const)('the mirror rows written for a %s row hold no body in clear, at rest', async (object, row, span) => { + const activity: Row[] = await ql.find('sys_activity', { where: { object_name: object, record_id: row().id }, limit: 50, ...SYS }); + const audit: Row[] = await ql.find('sys_audit_log', { where: { object_name: object, record_id: row().id }, limit: 50, ...SYS }); + // Non-vacuity: the writers did mirror the row. + expect(activity.length + audit.length).toBeGreaterThan(0); + expect(JSON.stringify([activity, audit]).includes(span())).toBe(false); + }); + }); +}); From 9a6c93200a2b4ab51287f7137fee51a279eee095 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 23:03:09 +0000 Subject: [PATCH 2/3] fix(plugin-approvals): free-text search leaves the approval snapshot out, so a reader withheld one field keeps its global search Claude-Session: https://claude.ai/code/session_01CAtkMwk5tY1jPaUCdKosnk Co-authored-by: Claude --- .../src/sys-approval-request.object.ts | 14 ++++++++++ ...sys-approval-request.search-fields.test.ts | 27 ++++++++++++++++++ .../identity-mail-read-gate.dogfood.test.ts | 28 +++++++++++++------ 3 files changed, 61 insertions(+), 8 deletions(-) create mode 100644 packages/plugins/plugin-approvals/src/sys-approval-request.search-fields.test.ts diff --git a/packages/plugins/plugin-approvals/src/sys-approval-request.object.ts b/packages/plugins/plugin-approvals/src/sys-approval-request.object.ts index 744082918d9..1fac8d803b4 100644 --- a/packages/plugins/plugin-approvals/src/sys-approval-request.object.ts +++ b/packages/plugins/plugin-approvals/src/sys-approval-request.object.ts @@ -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. // diff --git a/packages/plugins/plugin-approvals/src/sys-approval-request.search-fields.test.ts b/packages/plugins/plugin-approvals/src/sys-approval-request.search-fields.test.ts new file mode 100644 index 00000000000..3622355ffff --- /dev/null +++ b/packages/plugins/plugin-approvals/src/sys-approval-request.search-fields.test.ts @@ -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; + +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()); + }); +}); diff --git a/packages/qa/dogfood/test/identity-mail-read-gate.dogfood.test.ts b/packages/qa/dogfood/test/identity-mail-read-gate.dogfood.test.ts index 5bfdaaadb95..2eb2097e9c4 100644 --- a/packages/qa/dogfood/test/identity-mail-read-gate.dogfood.test.ts +++ b/packages/qa/dogfood/test/identity-mail-read-gate.dogfood.test.ts @@ -62,12 +62,15 @@ const filterOf = (where: Record) => encodeURIComponent(JSON.str const hasBodyKey = (row: Row) => BODY_FIELDS.some((f) => f in row); /** - * The longest run of link-token characters in a stored body — the part of a - * reset or invitation link that IS the credential. A response "contains the - * body" exactly when it contains this span; JSON escaping cannot hide it. + * The longest run of link-token characters in a stored body that no other + * column of the row carries — the part of a reset or invitation link that IS + * the credential (an invitation link also names the invitation, which the row + * keeps in clear in `related_id`). A response "contains the body" exactly when + * it contains this span; JSON escaping cannot hide it. */ -function credentialSpanOf(body: unknown): string { - const runs = String(body ?? '').match(/[A-Za-z0-9_-]{16,}/g) ?? []; +function credentialSpanOf(row: Row): string { + const elsewhere = JSON.stringify(Object.fromEntries(Object.entries(row).filter(([k]) => !(BODY_FIELDS as readonly string[]).includes(k)))); + const runs = (String(row.body_text ?? '').match(/[A-Za-z0-9_-]{16,}/g) ?? []).filter((r) => !elsewhere.includes(r)); const longest = runs.sort((a, b) => b.length - a.length)[0]; if (!longest) throw new Error('the stored body carries no link token to probe with'); return longest; @@ -105,7 +108,7 @@ describe('[#24625] identity-mail bodies over an 18.0 database: principal × door invitation = mails.find((m) => m.related_object === 'sys_invitation')!; expect(reset, 'the fixture holds the 18.0 password-reset row').toBeTruthy(); expect(invitation, 'the fixture holds the organization-invitation row').toBeTruthy(); - resetSpan = credentialSpanOf(reset.body_text); + resetSpan = credentialSpanOf(reset); // The organization administrator: fixture member A, promoted to the // organization's `admin` role. The membership hook grants the scoped @@ -215,6 +218,15 @@ describe('[#24625] identity-mail bodies over an 18.0 database: principal × door expect(res.text.includes(resetSpan)).toBe(false); }); + it.each([ + ['sys_activity', 'metadata'], + ['sys_audit_log', 'new_value'], + ] as const)('%s: a body probe on the copy is refused to the organization admin, named parent or not', async (object, column) => { + for (const where of [{ [column]: { $contains: resetSpan } }, { object_name: 'sys_email', [column]: { $contains: resetSpan } }]) { + expect((await call('orgAdmin', 'POST', `/data/${object}/query`, { where })).status).toBe(403); + } + }); + it.each(['sys_activity', 'sys_audit_log'] as const)('%s: a member is served no body', async (object) => { const res = await call('member', 'GET', mirrorPath(object)); expect(res.text.includes(resetSpan)).toBe(false); @@ -244,7 +256,7 @@ describe('[#24625] identity-mail bodies over an 18.0 database: principal × door expect(requested.status).toBe(200); [identityRow] = await ql.find('sys_identity_email', { where: {}, orderBy: [{ field: 'created_at', order: 'desc' }], limit: 1, ...SYS }); expect(identityRow, 'HEAD wrote the reset to the identity log').toBeTruthy(); - identitySpan = credentialSpanOf(identityRow.body_text); + identitySpan = credentialSpanOf(identityRow); // HEAD's own tenant mail: an organization invitation. const before = new Set((await ql.find('sys_email', { where: {}, limit: 100, ...SYS })).map((r: Row) => r.id)); @@ -254,7 +266,7 @@ describe('[#24625] identity-mail bodies over an 18.0 database: principal × door expect(invite.status).toBe(200); tenantRow = (await ql.find('sys_email', { where: {}, limit: 100, ...SYS })).find((r: Row) => !before.has(r.id)); expect(tenantRow, 'HEAD wrote the invitation to the tenant log').toBeTruthy(); - tenantSpan = credentialSpanOf(tenantRow.body_text); + tenantSpan = credentialSpanOf(tenantRow); }, 60_000); it.each(['orgAdmin', 'member'] as const)('the %s is refused the identity log', async (who) => { From e7f2dd03933e75164c156918ab9b4a8a60e7b4f6 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 23:10:52 +0000 Subject: [PATCH 3/3] =?UTF-8?q?chore(changeset):=20mail-log=20body=20read?= =?UTF-8?q?=20gate=20(Clause-=E2=91=A1=20narrowing)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude-Session: https://claude.ai/code/session_01CAtkMwk5tY1jPaUCdKosnk Co-authored-by: Claude --- .changeset/24625-identity-mail-read-gate.md | 22 +++++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 .changeset/24625-identity-mail-read-gate.md diff --git a/.changeset/24625-identity-mail-read-gate.md b/.changeset/24625-identity-mail-read-gate.md new file mode 100644 index 00000000000..6fee35f239d --- /dev/null +++ b/.changeset/24625-identity-mail-read-gate.md @@ -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. + + + +**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.