Skip to content

Commit b513343

Browse files
fix(plugin-approvals): lookup-target titles and the record-lock refusal title are served only from fields the caller may read (#23629)
Fixes #23618 Clause-②: no The two approval-path titles this card enumerates are now served only from fields the caller may be served, by the served-fields rule PR #23614 applied to the subject's `record_title`, asked through one seam. Function level only, per the card. ## What changed All in `packages/plugins/plugin-approvals`. - **Row 1: lookup-target titles (`payload_display`).** `enrichRows` now titles each record a readable lookup points at from the TARGET record as served to the reader. - `ApprovalService.servedFields` is now the one place a row read asks `resolveReadableSnapshotFields` (readable, and not masked for this caller), as the reading caller, once per object per read. `servedSubjectFields` is that method for the rows' subject objects. - `enrichRows` asks it for the target objects into the same map, so a target that is also a subject object (a self-lookup) reuses the subject's answer and is not asked twice. - `titlesAsServed` is now the one spelling of how an approval read titles a record: read under the system context, `redactSnapshot(record, served)`, then the existing `pickTitle`. The subject's `record_title` (`resolveSubjectDisplays`) and the targets' `payload_display` both call it, so the two cannot drift. - What a reader gets: a target display field the reader may not read, or is served masked (#20964), gives that key no display value, and the snapshot's stored id stands. That is the answer a deleted or exposure-refused target (#22738) already gets. A title-eligible field the reader may read on the target still names the record, as for the subject (PR #23614, pin f). A reader who may read the display field gets the same title as before. The exposure gate is unchanged and still runs first, so a refused target is neither read nor asked. - **Row 2: the `RECORD_LOCKED` refusal (`lifecycle-hooks.ts`).** The per-record sentence now picks its title from the update pre-image as served to the WRITER. - The ask is the same seam (`resolveReadableSnapshotFields`, then `redactSnapshot`), made with the writer's own execution envelope: the one riding on the operation (`input.options.context`), stripped of its operation-private keys with `@objectstack/core`'s `withoutOperationPrivateKeys`. ⛔ It is never rebuilt from the hook session, which drops `permissions` and `posture`; a question asked with that projection would be a second derivation of the writer's authority. - A display field the writer may not be served gives no title, and the sentence degrades to the object's label (`This Opportunity is locked ...`). That is the degradation #18153 already defines; ⛔ never the id. A writer who may read the field is told the title as before. `RECORD_LOCKED` and 409 do not move. - The cost is one field-visibility question on the deny path only, and no record read. A write the lock does not refuse asks nothing. - `bindApprovalLockHook` takes a lazy field-visibility getter. `approvals-plugin.ts` passes the same lazily resolved `security` authority the snapshot redaction middleware reads. With none wired, the title passes through as before, exactly as the payload snapshot does. - **Comment only (bounded in-place change, triage ruling `6185594767` on #23556, option B).** `nameSubjectRecord`'s inline `[#23594]` comment named a condition PR #23614 already meets in the inbox. It now gives the real reason the `approval.requested` body stays label-and-id: it is written once at send time, with no per-recipient reader to narrow for. Two comment lines; the return value is unchanged. No test and no changeset line of its own. ## The dispatch's mechanism assumptions, measured 1. **Row 1: held.** The lookup keys come from the reader-redacted snapshot. On the base, the target record was titled from the whole record under the system context, gated only by the target's declared exposure. A restricted reader now gets no display value for that key. The console's inbox card drops a reference row the server could not resolve when it has the object's field metadata, and otherwise shows the stored text: the same answer a deleted target gets (read in `objectui` at `28fb2a0`, `payloadSummary`). This change does not move that. 2. **Row 2: the pre-image is the raw row, and the leak is reachable.** - By source, `readUpdatePriorRow` (`@objectstack/objectql`) is a raw driver read shaped to the declared columns. No read middleware runs on it and nothing in `plugin-security` touches `previous`. - Measured with the real `SecurityPlugin` and a real engine and driver, in a throwaway rig in `plugin-security`'s tree (deleted, never committed). - A writer whose permission set withholds the display field (the read door confirmed it withheld) was refused, on the base's wiring, with that field's value in the sentence. The hook's `previous` carried the field. `input.options.context` carried `permissions` and `posture`, which the session does not. - With this change: that writer is refused with the label-only sentence, on a by-id and on a predicate write. A writer who may read the field still gets the title. 3. **No whole-record or cross-principal leak in either row.** Both are a title-level disclosure to a caller already on the path: a routed approver reading the inbox, or a writer the security middleware already admitted to the row. A by-id or predicate write on a row the caller cannot read is answered not-found, or does not match, before the hook runs. `plugin-security`'s `by-id-write-unreadable-not-found` and `predicate-write-unreadable-not-matched` pins hold that. ## Pins - New `lookup-target-title-reader-fls.integration.test.ts`: the real `ApprovalService` on a real `ObjectQL` over `SqlDriver` (in-memory better-sqlite3), with a field-visibility source standing in for `plugin-security`'s. - (a) A reader who may not read a target's display field gets no title for that key on `getRequest` and `listRequests`, and the stored id stands. A self-lookup is narrowed by the reader's answer for the subject object. - (a-masked) A target display field served masked is withheld the same way. - (b) Control: a reader who may read every target field gets every title. - (c) A withheld target display field lends nothing; the title is picked from what the reader is served on the target. - (d) One ask per object per read, subject and targets together, on the single read and a multi-row list. - New `record-lock-refusal-writer-fls.integration.test.ts`: real engine, real driver, the real `beforeUpdate` hook. - (a) A writer who may not read the display field is refused without the title, by-id and on a predicate write. - (a-masked) The same, served masked. - (b) Control: a writer who may read it gets the title. - (c) One ask per refusal, made with the writer's own envelope, and none for a write the lock does not refuse. - `RECORD_LOCKED` and 409 are asserted on every refusal. - Updated: `payload-display-target-exposure.test.ts` and `implicit-reference-target-lookup.test.ts` pass `enrichRows`' new required context. No security service is wired there, so nothing is narrowed, which is the posture those pins measure. ## Red before, ablations after - **Before the fix**, the two new files on the base's source (commit `c0c5a0ba28`): 8 failed / 2 passed. The two passes are the controls (b); every other pin was red. - **Ablations**, at `d3a9c5f12a`, via `scripts/ablation-replace.mjs` in wrap mode, under an EXIT/INT/TERM restore trap. Each run covers the two new files plus `subject-title-reader-fls.integration.test.ts`, 18 tests. - **1a**: the targets' served answer replaced by `undefined` (the whole-target pick, asks kept). Predicted red: row 1 (a), (a-masked), (c). Observed: 3 failed / 15 passed, exactly those. Restore proven: blob `0a8b5fc6121d` equals HEAD and `git diff HEAD` is empty. - **1b**: the targets' served-fields ask deleted. Predicted red: row 1 (a), (a-masked), (c), (d). Observed: 4 failed / 14 passed, exactly those. Restore proven: blob `0a8b5fc6121d`. - **2**: the refusal titled from the raw pre-image again. Predicted red: row 2 (a) by-id, (a) predicate, (a-masked). Observed: 3 failed / 15 passed, exactly those. Restore proven: blob `25e7a598c358`. - No `dist` step: every subject is imported from `src` by relative path. ## Tests and gates - `pnpm --filter @objectstack/plugin-approvals exec vitest run --maxWorkers=2`, the whole package, at the final head `c5115facf0` (after merging `origin/main` `72329942f9`, then `pnpm install --frozen-lockfile` and a rebuild of the package and its closure, 32 tasks, exit 0): Test Files 77 passed (77), Tests 1072 passed (1072), lock `VERDICT command-exit 0`. It read the same at `d3a9c5f12a`, before the merge. - `pnpm --filter @objectstack/plugin-approvals typecheck`: exit 0. `check:test-typecheck` OK (8 files / 324 errors / 27 signatures held; no new signature). `tsc --listFiles` on `tsconfig.test.json` compiles all four new or edited test files. - Gates, at `c5115facf0`: `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 67 commands off this change set. All 67 ran, each exit code captured before any pipe, and all 67 exit 0. - `check:dual-build-cjs-loads` and `check:i18n` first answered 3, PREREQUISITE NOT MET (no workspace dist). After a workspace build (75 tasks, exit 0), both answered 0, and they are recorded at that measured 0, not as failures. - `--ran` reconciliation, with every exit code recorded: 67 derived, 67 run, 0 NOT-MEASURED (derived), 0 UNRUN, exit 0. - eslint (`--no-inline-config`) over the 7 changed TS files: exit 0, no findings. - Not owed locally: `check:dev-prereqs` and `check:skill-frame-freshness`, because no `packages/spec` build input or frame file is touched. The full farm is CI's. ## Changeset `.changeset/23618-approval-title-family-served-fields.md`: `@objectstack/plugin-approvals` **patch**. It narrows served values only. No schema, route, error code, translation key, export or notification field moves. `payload_display` and its values are already optional, and `bindApprovalLockHook`'s new parameter is optional. With no field-visibility source wired, every title is served as before. `Clause-②: no`, on this body, the changeset and the claim. ## Deviations 1. **Push order.** The pins, the fix and the changeset were committed locally and went up in one push, after the empty-branch probe, so nothing red reached the remote before the fix. That follows the card's security rule; AGENTS.md otherwise asks for a WIP push before long steps. 2. **File surface.** Beyond the claim's two sources and their tests: `approvals-plugin.ts`, one wiring line that hands the lock hook the authority the redaction already reads, and the two pass-through test files named above. 3. **The bounded comment change** above, by triage ruling `6185594767`. 4. **Attribution.** Commit trailers are the repository's model-free pair, and this body ends with the repository's session footer, rather than the harness reminder's lines. 5. **The throwaway measurement** lived in `packages/plugins/plugin-security/src` for one run and was deleted. The tree was verified clean. ## Acceptance notes - **Read, not measured, not filed (no carrier):** a lookup target is still read under the system context. Its title is therefore not narrowed by the reader's row-level or sharing access to the TARGET record, only by its declared exposure (#22738, which takes no caller) and now by field-level security. Whether a referenced record's title should follow the reader's row-level access is a design question beside #7497 ruling C, not a defect this card names. - **Not measured:** row 1 with `plugin-security` composed. The pins hold the seam to the field-visibility contract's three answers, as the payload and subject-title pins do. Row 2 was measured composed (assumption 2). - **The seam's posture carries over unchanged to both rows.** A read projection that throws, or answers `undefined`, passes the title through, logged at `warn` as for the snapshot. A source with no masked-for-this-caller answer fails closed (`[]`, no title). A write that carries no execution envelope asks the security service with none, and its answer for no caller decides. --- _Generated by [Claude Code](https://claude.ai/code/session_017WLmd5in9RXu7AjyHR8fKQ)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 23e689d commit b513343

8 files changed

Lines changed: 638 additions & 45 deletions
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
'@objectstack/plugin-approvals': patch
3+
---
4+
5+
fix(plugin-approvals): the inbox's referenced-record titles (`payload_display`) and the record-lock refusal's title are served only from fields the caller may read
6+
7+
Clause-②: no
8+
9+
- **Referenced-record titles follow the reader's field-level security on the referenced object.** `listRequests` and `getRequest` now pick each `payload_display` title from the referenced record as served to the reading caller: the same answer the payload snapshot and `record_title` are narrowed by (readable, and not masked for that caller), asked for the referenced object, once per object per read. A display field the reader may not read on the referenced object, or is served masked, gives that key no display value, so the snapshot's stored id stands, as it already does for a deleted referenced record. A title-eligible field the reader may read still names the record, and a reader who may read the display field gets the same title as before.
10+
- **The `RECORD_LOCKED` refusal names the record only from what the writer may read.** The per-record refusal sentence takes its title from the record as served to the refused writer, asked with the writer's own execution context. When the writer may not read the display field, or is served it masked, the sentence names the object's label alone (`This Opportunity is locked …`), as it already does for a record with no title; it never names the id. A writer who may read the field is told the title as before. The code (`RECORD_LOCKED`) and status (409) are unchanged.
11+
12+
A patch: a narrowing of served values only. Nothing is granted, and no schema, route, error code, translation key or export moves. With no field-visibility source wired, every title is served as before.

‎packages/plugins/plugin-approvals/src/approval-service.ts‎

Lines changed: 85 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1535,8 +1535,8 @@ export class ApprovalService implements IApprovalService {
15351535

15361536
/**
15371537
* [#10749 / #23594] The subject fields the READING caller may be served, per
1538-
* subject object of `rows`: {@link resolveReadableSnapshotFields}' answer
1539-
* (readable, and not masked for this caller), asked as the caller.
1538+
* subject object of `rows` — {@link ApprovalService.servedFields} for the
1539+
* rows' subject objects.
15401540
*
15411541
* It is the ONE answer both serve-time narrowings of a row read: the payload
15421542
* snapshot ({@link ApprovalService.redactPayloads}) and the subject's title
@@ -1546,13 +1546,31 @@ export class ApprovalService implements IApprovalService {
15461546
* pass-through, and it passes the title through exactly as it passes the
15471547
* snapshot.
15481548
*/
1549-
private async servedSubjectFields(
1549+
private servedSubjectFields(
15501550
rows: readonly ApprovalRequestRow[],
15511551
context: ExecutionContext,
15521552
): Promise<Map<string, string[] | undefined>> {
1553-
const served = new Map<string, string[] | undefined>();
1554-
for (const r of rows) {
1555-
const object = String((r as any).object_name ?? '');
1553+
return this.servedFields(rows.map((r) => String((r as any).object_name ?? '')), context);
1554+
}
1555+
1556+
/**
1557+
* [#10749 / #23594 / #23618] The fields the READING caller may be served on
1558+
* each of `objects`: {@link resolveReadableSnapshotFields}' answer (readable,
1559+
* and not masked for this caller), asked as the caller — the one place a row
1560+
* read asks it.
1561+
*
1562+
* `served` holds the answers this read has already asked; an object in it is
1563+
* not asked again. So one read asks once per object, whether that object is a
1564+
* request's subject or the target of a lookup in a snapshot
1565+
* ({@link ApprovalService.enrichRows}), and a target that is also a subject
1566+
* object is narrowed by the same answer as the subject.
1567+
*/
1568+
private async servedFields(
1569+
objects: Iterable<string>,
1570+
context: ExecutionContext,
1571+
served: Map<string, string[] | undefined> = new Map(),
1572+
): Promise<Map<string, string[] | undefined>> {
1573+
for (const object of objects) {
15561574
if (served.has(object)) continue;
15571575
served.set(object, await resolveReadableSnapshotFields(
15581576
this.fieldVisibility, object, context, this.logger,
@@ -6672,6 +6690,8 @@ export class ApprovalService implements IApprovalService {
66726690
* (ADR-0049) for `get`, the read `GET /data/{target}/{id}` performs and the
66736691
* operation the data door's `$expand` and the dataset door's labels ask of
66746692
* the same target. It takes no caller: the read runs under a system context.
6693+
* [#23618] What is SERVED from that read is the reader's: the title is picked
6694+
* from the target as served to the reader ({@link titlesAsServed}).
66756695
*
66766696
* A refused target is not read, so its key gets no display value and the
66776697
* snapshot's stored id stands, the answer a deleted target already gets. A
@@ -6745,23 +6765,48 @@ export class ApprovalService implements IApprovalService {
67456765
for (const [object, idSet] of byObject) {
67466766
const label = this.objectLabelOf(object);
67476767
if (label) objectLabels.set(object, label);
6748-
const ids = Array.from(idSet);
6749-
const displayField = this.resolveDisplayField(object);
6750-
const readable = served.get(object);
67516768
try {
6752-
const recs = await this.engine.find(object, {
6753-
where: { id: { $in: ids } }, limit: ids.length, context: SYSTEM_CTX,
6754-
});
6755-
for (const rec of (recs ?? []) as any[]) {
6756-
const asServed = readable === undefined ? rec : redactSnapshot(rec, readable).payload;
6757-
const title = ApprovalService.pickTitle(asServed, displayField);
6758-
if (rec?.id && title) titles.set(`${object} ${rec.id}`, title);
6759-
}
6769+
await this.titlesAsServed(object, idSet, served.get(object), titles);
67606770
} catch { /* object may be unregistered — the caller's fallback applies */ }
67616771
}
67626772
return { titles, objectLabels };
67636773
}
67646774

6775+
/**
6776+
* [#23594 / #23618] Title the `ids` of `object` into `into` (keyed
6777+
* `"<object> <id>"`), each picked from the record AS SERVED to the reader:
6778+
* `readable` is the reader's served fields for `object`
6779+
* ({@link ApprovalService.servedFields}), and `redactSnapshot` keeps exactly
6780+
* the fields the payload snapshot keeps, so a display field the reader may
6781+
* not read, or is served masked, gives no title, while a field the reader may
6782+
* be served still names the record. `undefined` is the seam's pass-through.
6783+
*
6784+
* The ONE spelling of how an approval read titles a record, shared by the
6785+
* subject's `record_title` ({@link ApprovalService.resolveSubjectDisplays})
6786+
* and the lookup targets' `payload_display` ({@link ApprovalService.enrichRows}).
6787+
* The records are read under the system context, so the read narrows nothing
6788+
* (#7497 ruling C: record access stays with object permissions, sharing and
6789+
* row-level rules); only what is served from it is narrowed. Throws what the
6790+
* read throws; each caller keeps its own best-effort catch.
6791+
*/
6792+
private async titlesAsServed(
6793+
object: string,
6794+
ids: Iterable<string>,
6795+
readable: string[] | undefined,
6796+
into: Map<string, string>,
6797+
): Promise<void> {
6798+
const list = Array.from(ids);
6799+
const displayField = this.resolveDisplayField(object);
6800+
const recs = await this.engine.find(object, {
6801+
where: { id: { $in: list } }, limit: list.length, context: SYSTEM_CTX,
6802+
});
6803+
for (const rec of (recs ?? []) as any[]) {
6804+
const asServed = readable === undefined ? rec : redactSnapshot(rec, readable).payload;
6805+
const title = ApprovalService.pickTitle(asServed, displayField);
6806+
if (rec?.id && title) into.set(`${object} ${rec.id}`, title);
6807+
}
6808+
}
6809+
67656810
/**
67666811
* An object's schema label, or `undefined` — the one spelling of "the
67676812
* object's label" the inbox row ({@link ApprovalService.resolveSubjectDisplays})
@@ -6787,7 +6832,8 @@ export class ApprovalService implements IApprovalService {
67876832
* reader to narrow for.
67886833
*/
67896834
private nameSubjectRecord(object: string, recordId: string): string {
6790-
// [#23594] The title would go here; withheld until resolveSubjectDisplays narrows it by each reader's FLS.
6835+
// [#23594 / #23556 ruling B] The inbox serves the title narrowed per reader (resolveSubjectDisplays); this body is
6836+
// written once at send time, with no per-recipient reader to narrow for, so it stays label-and-id.
67916837
return `${this.objectLabelOf(object) ?? object} ${recordId}`;
67926838
}
67936839

@@ -6804,10 +6850,20 @@ export class ApprovalService implements IApprovalService {
68046850
* does not serve `get` is not read ({@link servesPayloadDisplayTarget}).
68056851
* [#23594] `record_title` is narrowed by the reader's `served` fields, the
68066852
* answer the payload was redacted by ({@link resolveSubjectDisplays}).
6853+
* [#23618] So is every `payload_display` title, by the reader's served fields
6854+
* on the TARGET object: the lookup keys come from the already-redacted
6855+
* snapshot, and the record a readable key points at is titled from the target
6856+
* as served to `context`'s caller ({@link titlesAsServed}). A target display
6857+
* field the reader may not be served gives that key no display value, so the
6858+
* snapshot's stored id stands — the answer a deleted or exposure-refused
6859+
* target gets. The targets' answers are asked through
6860+
* {@link servedFields} into `served`, once per object per read, so a target
6861+
* that is also a subject object reuses the subject's answer.
68076862
*/
68086863
private async enrichRows(
68096864
rows: ApprovalRequestRow[],
6810-
served: ReadonlyMap<string, string[] | undefined>,
6865+
served: Map<string, string[] | undefined>,
6866+
context: ExecutionContext,
68116867
): Promise<void> {
68126868
if (!rows.length) return;
68136869

@@ -6845,18 +6901,11 @@ export class ApprovalService implements IApprovalService {
68456901
}
68466902
}
68476903
const refTitles = new Map<string, string>();
6848-
for (const [object, idSet] of refIds) {
6849-
if (!this.servesPayloadDisplayTarget(object)) continue;
6850-
const ids = Array.from(idSet);
6851-
const displayField = this.resolveDisplayField(object);
6904+
const targets = Array.from(refIds.keys()).filter((object) => this.servesPayloadDisplayTarget(object));
6905+
await this.servedFields(targets, context, served);
6906+
for (const object of targets) {
68526907
try {
6853-
const recs = await this.engine.find(object, {
6854-
where: { id: { $in: ids } }, limit: ids.length, context: SYSTEM_CTX,
6855-
});
6856-
for (const rec of (recs ?? []) as any[]) {
6857-
const title = ApprovalService.pickTitle(rec, displayField);
6858-
if (rec?.id && title) refTitles.set(`${object} ${rec.id}`, title);
6859-
}
6908+
await this.titlesAsServed(object, refIds.get(object)!, served.get(object), refTitles);
68606909
} catch { /* referenced object unreadable — leave unresolved */ }
68616910
}
68626911

@@ -7527,10 +7576,11 @@ export class ApprovalService implements IApprovalService {
75277576
const rows = await this.engine.find('sys_approval_request', findOpts);
75287577
const list = Array.isArray(rows) ? rows.map(rowFromRequest) : [];
75297578
// [#10749] Redact before enrichment — see `redactPayloads`. [#23594] The
7530-
// snapshot and the subject title are narrowed by ONE answer for the reader.
7579+
// snapshot and the subject title are narrowed by ONE answer for the reader;
7580+
// [#23618] the lookup targets' titles by the same reader's answers for them.
75317581
const served = await this.servedSubjectFields(list, context);
75327582
this.redactPayloads(list, served);
7533-
await this.enrichRows(list, served);
7583+
await this.enrichRows(list, served, context);
75347584
this.attachViewers(list, context, caller);
75357585
return list;
75367586
}
@@ -7640,10 +7690,11 @@ export class ApprovalService implements IApprovalService {
76407690
}
76417691
const row = rowFromRequest(raw);
76427692
// [#10749] Redact before enrichment — see `redactPayloads`. [#23594] The
7643-
// snapshot and the subject title are narrowed by ONE answer for the reader.
7693+
// snapshot and the subject title are narrowed by ONE answer for the reader;
7694+
// [#23618] the lookup targets' titles by the same reader's answers for them.
76447695
const served = await this.servedSubjectFields([row], context);
76457696
this.redactPayloads([row], served);
7646-
await this.enrichRows([row], served);
7697+
await this.enrichRows([row], served, context);
76477698
await this.attachFlowSteps(row);
76487699
await this.attachDecisionProgress(row, raw);
76497700
this.attachViewers([row], context, caller);

‎packages/plugins/plugin-approvals/src/approvals-plugin.ts‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -305,7 +305,9 @@ export class ApprovalsServicePlugin implements Plugin {
305305
if (!this.options.disableAutoHooks) {
306306
try {
307307
unbindAllHooks(engine);
308-
bindApprovalLockHook(engine, ctx.logger);
308+
// [#23618] The refusal's title is narrowed by the writer's served fields,
309+
// through the same lazily-resolved authority the snapshot redaction reads.
310+
bindApprovalLockHook(engine, ctx.logger, fieldVisibility);
309311
bindRecordDeleteCancelHook(engine, this.service, ctx.logger);
310312
bindDelegationWriteGuard(engine, ctx.logger);
311313
// [#10749] Generic-door snapshot redaction. Registered here so the

‎packages/plugins/plugin-approvals/src/implicit-reference-target-lookup.test.ts‎

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -73,12 +73,13 @@ function makeService(
7373
const resolve = (object: string) =>
7474
(service as unknown as { resolveLookupFields(o: string): Array<{ key: string; reference: string }> })
7575
.resolveLookupFields(object);
76-
// [#23594] No field-visibility answer for any object: the reader's served
77-
// fields narrow nothing here, as with no security service wired.
76+
// [#23594 / #23618] No security service wired, so no field-visibility answer
77+
// for any object, subject or target: the reader's served fields narrow
78+
// nothing here.
7879
const enrich = (rows: InboxRow[]) =>
7980
(service as unknown as {
80-
enrichRows(r: InboxRow[], served: ReadonlyMap<string, string[] | undefined>): Promise<void>;
81-
}).enrichRows(rows, new Map());
81+
enrichRows(r: InboxRow[], served: Map<string, string[] | undefined>, context: unknown): Promise<void>;
82+
}).enrichRows(rows, new Map(), { userId: 'u_reader', positions: [], permissions: [] });
8283
return { resolve, enrich, warn, finds };
8384
}
8485

‎packages/plugins/plugin-approvals/src/lifecycle-hooks.ts‎

Lines changed: 58 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -110,11 +110,28 @@
110110
* that. When no title is in hand the sentence degrades to the object's label,
111111
* and when that is missing too, to "This record" — ⛔ never back to the id.
112112
*
113+
* ## The title is the WRITER's to be served (#23618)
114+
*
115+
* `ctx.previous` is the row as STORED (a raw driver read, declared columns
116+
* only), narrowed by no caller's field-level security — and the sentence is
117+
* served to the writer. So the title is picked from the pre-image AS SERVED to
118+
* the writer, by the rule every approval-path title answers to
119+
* (`payload-redaction.ts`, #10749: keyed on the caller): the served-fields
120+
* answer, `resolveReadableSnapshotFields` (readable, and not masked for this
121+
* caller), asked with the writer's own envelope ({@link writerEnvelope}). A
122+
* display field the writer may not be served gives no title, and the sentence
123+
* degrades as above. Still no record read: the cost is one field-visibility
124+
* question, on the deny path only, so a write the lock does not refuse asks
125+
* nothing. With no field-visibility authority wired, the title passes through
126+
* as before, exactly as the payload snapshot does.
127+
*
113128
* Registered under `packageId: 'plugin-approvals:lock'` so it can be cleanly
114129
* unbound on plugin stop.
115130
*/
116131

132+
import { withoutOperationPrivateKeys } from '@objectstack/core';
117133
import { resolveDisplayField } from '@objectstack/spec/data';
134+
import { redactSnapshot, resolveReadableSnapshotFields, type FieldVisibilitySource } from './payload-redaction.js';
118135

119136
export const APPROVALS_HOOK_PACKAGE = 'plugin-approvals:lock';
120137

@@ -238,6 +255,23 @@ function recordTitleOf(
238255
return title;
239256
}
240257

258+
/**
259+
* [#23618] The WRITER's own execution envelope — the one riding on the
260+
* operation (`input.options.context`), minus its operation-private keys — or
261+
* `undefined` when the write carried none.
262+
*
263+
* ⛔ Never rebuilt from `ctx.session`: the session is a projection of the
264+
* envelope that drops `permissions` (and spells the org `organizationId`), so a
265+
* field-visibility question asked with it would be a second derivation of the
266+
* writer's authority beside the envelope the security middleware judged the
267+
* write by. The `__` keys go for the reason `withoutOperationPrivateKeys`
268+
* states, and the copy keeps a callee from writing back into the operation.
269+
*/
270+
function writerEnvelope(ctx: any): unknown {
271+
const exec = ctx?.input?.options?.context;
272+
return exec && typeof exec === 'object' ? withoutOperationPrivateKeys(exec as Record<string, unknown>) : undefined;
273+
}
274+
241275
/**
242276
* The END-USER sentence for a record held by a live approval (#18153).
243277
*
@@ -439,8 +473,17 @@ async function gatingRequests(
439473
/**
440474
* Bind the global record-lock hook. Caller is responsible for calling
441475
* {@link unbindAllHooks} first if re-binding.
476+
*
477+
* `fieldVisibility` resolves the field-visibility authority the refusal's title
478+
* is narrowed by (#23618) — lazily, per refusal, because the security plugin may
479+
* start after this one. Absent, or resolving nothing, the title is served as
480+
* before, exactly as the payload snapshot is (`resolveReadableSnapshotFields`).
442481
*/
443-
export function bindApprovalLockHook(engine: MinimalEngine, logger?: MinimalLogger): void {
482+
export function bindApprovalLockHook(
483+
engine: MinimalEngine,
484+
logger?: MinimalLogger,
485+
fieldVisibility?: () => FieldVisibilitySource | undefined,
486+
): void {
444487
engine.registerHook('beforeUpdate', async (ctx: any) => {
445488
const object = (ctx?.object ?? ctx?.objectName) as string | undefined;
446489
// No object name (shouldn't happen) or our own bookkeeping objects → skip.
@@ -518,12 +561,25 @@ export function bindApprovalLockHook(engine: MinimalEngine, logger?: MinimalLogg
518561
const previous = ctx?.previous as Record<string, unknown> | undefined;
519562
const previousId = previous ? String(previous.id ?? previous._id ?? '') : '';
520563
const record = previousId && previousId === recordId ? previous : undefined;
564+
// [#23618] …and it is the row as STORED, narrowed by no caller's
565+
// field-level security, while this sentence is served to the WRITER. So
566+
// the title is picked from it AS SERVED to the writer: the served-fields
567+
// answer the approval inbox narrows its titles and snapshots by (readable,
568+
// and not masked for this caller), asked with the writer's own envelope.
569+
// A display field the writer may not be served gives no title, and the
570+
// sentence degrades to the label — ⛔ never to the id.
571+
const served = record
572+
? await resolveReadableSnapshotFields(fieldVisibility?.(), object, writerEnvelope(ctx), logger)
573+
: undefined;
574+
const asServed = record && served !== undefined
575+
? redactSnapshot(record, served).payload as Record<string, unknown>
576+
: record;
521577

522578
logger?.info?.(
523579
`[approvals] update refused RECORD_LOCKED: record '${recordId}' of '${object}' ` +
524580
`is held by pending approval request '${String(pending?.id ?? '')}'`,
525581
);
526-
lockedError(recordLockRefusal(objectLabel, recordTitleOf(schema, record, recordId)));
582+
lockedError(recordLockRefusal(objectLabel, recordTitleOf(schema, asServed, recordId)));
527583
}
528584
}, { packageId: APPROVALS_HOOK_PACKAGE, priority: 50 });
529585

0 commit comments

Comments
 (0)