Skip to content

Commit 8bb2c31

Browse files
feat(security): the record field-access answer says whether the record itself is writable (#27232)
<!-- claude-projects-attribution: {"github_login":"os-support-ai"} --> _Requested by **Yibo Wang** · [project thread](https://claude.ai/code/project/chan_01BVUyyPie8gHwoPM18iABwB?thread=cmsg_01BVUyyPie8gHwoPM18iABwBHS5r3FWzbMqPq7Ve6wZPUp)_ Fixes #27175 Ruling-ref: 6457467702 Clause-②: yes (widening) Metadata-protocol: added Skills-docs: none — `skills/**` names no reader of the record field-access answer (`git grep -n "field-access\|getRecordFieldAccess\|fieldAccess" -- skills/` is empty); the hand-written page that does, `content/docs/permissions/field-level-security.mdx`, is updated here ## 维护者速读 - 改了什么:记录的字段访问答案(`GET /data/{object}/{id}/field-access`)新增 `writable: boolean`,表示「这条记录本身能不能被更新」。 - 怎么算的:不是另写一套判断。plugin-security 保留它注册到引擎上的那个授权中间件函数,对这条记录跑一次「按 id 更新、空 payload」的模拟操作,在链尾、引擎执行器(钩子、校验、写库、审计)之前截停;链尾再问 plugin-sharing 的按 id 写闸(抽成 `SharingService.admitsByIdWrite`,其中间件也只调用它)。任何一处拒绝即 `false`。 - 风险与回滚:探测不写任何东西:上下文用副本,不推进权限缓存 epoch(单测钉住),dogfood 钉住无审计行、记录不变。`editable` 不变。回滚即 revert。 - 先看:`packages/plugins/plugin-security/src/record-row-write-probe.ts`,`security-plugin.ts` 的 `isRecordRowWritable`,`plugin-sharing/src/sharing-service.ts` 的 `admitsByIdWrite`。 - 两分钟验证:`pnpm --filter @objectstack/dogfood exec vitest run test/record-field-access-when.dogfood.test.ts`,预期 10/10(他人拥有的 `public_read` 行 `writable: false` 且 PATCH 403;可写行 `true` 且 PATCH 200)。 - 需要你做的:无,席位落地;随后 ui 席位接 #27138。 ## What changes **One row-level verdict on the record projection.** `GetRecordFieldAccessResponseSchema` (`packages/spec/src/api/protocol.zod.ts`) gains `writable: boolean`: whether the caller may update this record at all, judged by the write gate on the stored row. `false` means an update is refused whole, whatever it changes. The describe names in customer language what `true` does not cover. `editable` is unchanged and stays field-level; its describe now points at `writable`. `ISecurityService.getRecordFieldAccess` (`contracts/security-service.ts`) documents the member. The shape is a boolean only: the gate has no closed row-reason vocabulary (its refusals are `PERMISSION_DENIED` with a catalog sentence, or sharing's `FORBIDDEN`), and a boolean discloses nothing more than the PATCH would. **The verdict is the gate's own code, run in a would-pass mode.** A by-id update is refused for its row by two engine middlewares, so the verdict asks both, through their own code: - *plugin-security's authorization middleware.* `registerAuthzMiddleware` now returns the handler it registers, and `SecurityPlugin` keeps it (`authzMiddleware`). `isRecordRowWritable` hands that same function a synthetic by-id `update` of the row (empty payload, a copy of the caller's context, the opCtx shape `engine.update()` builds) and a terminal `next()` that ends the probe with a sentinel only `probeRecordRowWrite` catches (`record-row-write-probe.ts`). So every step a real update meets runs: the engine-owned write guard, the delegated-admin gate, principal and posture resolution, the `requiredPermissions` AND-gate (1.5), the CRUD grant (2), the access-depth stamp (2.6), the RLS pre-image with the ownership floor and its sharing deferral (2.7), the controlled-by-parent master (2.8), and the payload gates over an empty payload (2.5, 3.5, 3.6, 3.7). Any throw is `false`. The handler body is unchanged except for one guard (below). - *plugin-sharing's middleware.* Its by-id gate (`canEdit` / `canDelete`, the delegator's gate, then the `probeAuthoredRowWrite` deferral) moved into one method, `SharingService.admitsByIdWrite(object, id, verb, context)`. The middleware now calls it and refuses exactly when it answers `false`; the refusal messages and log lines are unchanged. The probe's terminal asks the same method with the context as the security middleware stamped it (`__writeScope`, an external principal's grant), which is what the sharing middleware reads in the real chain (it registers at `kernel:ready`, after the security middleware, so it runs inside it). `byIdWriteGateInstalled` is set when the middleware is installed; under `enforce: false` no by-id write meets the gate, and the probe skips it too. A transferred record shows why both legs are needed: its former creator passes the security middleware's ownership floor (`created_by == current_user.id`) and is refused only by sharing. **No side effects.** The engine's executor, where `beforeUpdate` hooks, validation, the driver write, `afterUpdate` hooks and audit run, sits after the last middleware, so the probe never reaches it. Before that point the handler refuses, reads, and stamps the operation and the context; the probe owns the operation and passes a copy of the context. Read through the closure for anything else that writes: no emits, no audit, no counters. The one write to shared state is the permission-set memo epoch the handler advances on a write when the engine does not own that seam; it is now skipped for a probe (`isRowWriteProbe`, a module-private symbol on the operation context that no request can carry). Caches that any read fills (permission-set resolution) fill as they would on a read. **Not in the verdict (declared residuals, named in the describe):** - a pending approval's record lock: a `beforeUpdate` hook in plugin-approvals, judged on the values sent (the status mirror field stays writable), with its own reader, `IApprovalService.getRecordLock`, which the record detail page already reads; - conditional read-only rules and validation rules on the values a save sends; - an external datasource that refuses writes (engine `assertWriteAllowed`, object-level); - other plugins' middlewares for their own objects (mail, attachments). So `true` never means "this save will succeed"; `false` always means the gate refuses the whole row. **Known effect:** a probe that is refused logs the gate's usual refusal `warn` line (for example "Access denied: not permitted to update this 'rfa_notice' record (row-level security)"), as a refused PATCH does. Suppressing it would mean probe-awareness at every refusal site; left as is. Files: - `packages/spec/src/api/protocol.zod.ts`, `packages/spec/src/contracts/security-service.ts`; regenerated `content/docs/references/api/protocol.mdx`, `packages/spec/authorable-surface/api.json`. - `packages/plugins/plugin-security/src/record-row-write-probe.ts` (new), `security-plugin-authz-middleware.ts` (returns the handler; the probe epoch guard; header note), `security-plugin.ts` (`authzMiddleware`, `isRecordRowWritable`, `writable` in `getRecordFieldAccess`). - `packages/plugins/plugin-sharing/src/sharing-service.ts` (`admitsByIdWrite`, `byIdWriteGateInstalled`), `sharing-plugin.ts` (the middleware calls it; the flag is set where the middleware is installed). - `packages/qa/dogfood/test/record-field-access-when.dogfood.test.ts`, `fixtures/record-field-access-fixture.ts`. - `content/docs/permissions/field-level-security.mdx`: the projection bullet now describes `writable`. - Changesets (root `.changeset/`): `@objectstack/spec`, `@objectstack/plugin-security`, `@objectstack/plugin-sharing`, each `minor`, `Clause-②: yes (widening)`, `Metadata-protocol: added`. The spec changeset carries a "Supersedes, in the same release" paragraph for the unreleased #26594 note that lists the answer's old shape. `@objectstack/client` (`data.fieldAccess`) and `@objectstack/rest` relay the answer unchanged and pick the member up from the spec type; their src does not move. ## Why #27175 / #27138: a user who may read a shared record but not change it opens Edit, fills the form, saves, and loses the work to a refusal of the whole update. The permission contract promises a client never advertises an edit the gate refuses. Ruling 6457467702 (letter B) forbids a client-side re-derivation, so the server has to say it, and triage 6465932161 asks for the gate's own computation returned as one boolean. With `writable`, ui seat 2 can dispatch #27138: `RecordFieldAccessScope` reads it and the edit surfaces show the record read-only when it is false. ## Verification Head `58e92a401` (four commits plus a merge of `origin/main` `fd73f2b39`, which touched none of these files). Run on the shared container under `os-verify-lock`. - `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack`: 108 derived commands, each run and its exit recorded. `--ran` reconciliation: 108 run, 0 NOT-MEASURED, 0 UNRUN. 106 exited 0 on the first run. `spec check:skill-examples` and `check:dual-build-cjs-loads` exited 3 (their prerequisite, built dist of client-react, studio and the knowledge/embedder packages, was absent); after building those packages both exited 0. Nothing in the diff changed between the runs. - Build: `turbo run build --filter='@objectstack/dogfood^...' --filter=@objectstack/plugin-sharing --filter=@objectstack/plugin-security` exit 0, again after the main merge. `pnpm --filter @objectstack/spec check:generated` exit 0 (it found `content/docs/references/**` stale once; `--fix` regenerated only that, committed). - Tests: plugin-security 236 files, 5104 passed / 45 skipped; plugin-sharing 57 files, 1280 passed; rest `record-field-access-route.test.ts` 5/5; `typecheck` of spec, plugin-security and plugin-sharing exit 0. - Dogfood: `pnpm --filter @objectstack/dogfood exec vitest run test/record-field-access-when.dogfood.test.ts` 10/10. New legs: the `public_read` row another user owns answers `writable: false` and both PATCHes answer 403; the draft and approved contracts and the member's own notice answer `true` and their PATCH answers 200; three probes leave the stored row equal and add no `sys_audit_log` update row, while one real save adds exactly one (the `AuditPlugin` is booted for this). - Unit pins (`security-plugin.test.ts`, `[#27175] writable`): `writable` equals a by-id update through the same handler, inside and outside the RLS write scope, and with an object grant lacking edit; the epoch is not advanced and the caller's context is not stamped (a real update through the same handler does advance it); the sharing leg is asked with `__writeScope` stamped and decides the verdict both ways, and is skipped when `byIdWriteGateInstalled` is false; no registered middleware answers `false`. `sharing-service.test.ts`: the middleware refuses exactly where `admitsByIdWrite` answers false, for update and delete, four (row, user) pairs each, and asks the method once per write. - Ablations (each from a committed state, restored with `git restore`, rebuilt): `probeRecordRowWrite` forced to `true` reds 3 unit pins and the dogfood `public_read` leg; the epoch guard removed reds the no-side-effect pin alone. ## Out-of-scope findings (for seat 1 to file) - The approvals record lock is not in `writable` (see residuals). The record detail page reads `getRecordLock`; whether the edit dialog (`ModalForm` / `DrawerForm` / the edit page) does is the ui half's question when #27138 is dispatched: an approval-locked record is the same "edit, then lose the work" case. - The client (`packages/client/src/index.ts` `data.fieldAccess`) and rest route comments describe the answer as the field projection only; accurate, but they do not name `writable`. Left untouched to keep those packages out of the release. ## Human Minimum - [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic. - [x] 2. Every name I added uses the casing for its kind. - [ ] 3. No formatter ran; `pnpm lint` passes. (No formatter ran. `pnpm lint` is CI's run.) - [x] 4. One issue, one branch off an up-to-date `main`, one pull request. (The branch is the harness-pinned `claude/project-thread-askd75`, named in the claim.) - [x] 5. A change that publishes carries a changeset, and both declaration lines are filled in here and in the changeset. - [x] 6. `packages/spec` touched: its generated artifacts are regenerated and committed. - [x] 7. No hand edits to `content/docs/references/`, `packages/*/CHANGELOG.md` or `content/docs/releases/`. - [x] 8. Title, body and comments are in English. (The 维护者速读 block is in Chinese by the repo's dispatch rule.) - [x] The *Before Submitting* list in CONTRIBUTING.md is done. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01WGufzj5HG79HLtAhJJ2vzy --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 1e8e529 commit 8bb2c31

17 files changed

Lines changed: 614 additions & 79 deletions
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
'@objectstack/plugin-security': minor
3+
---
4+
5+
feat(plugin-security): `getRecordFieldAccess` answers `writable`, from the write gate run over an update of the row
6+
7+
Clause-②: yes (widening)
8+
Metadata-protocol: added
9+
10+
The record field-access answer gains `writable`. It is not composed from the gate's parts: the plugin keeps the authorization middleware it registers on the engine and runs that same function over a by-id update of the stored row with an empty payload, ending the chain before the engine's executor (hooks, validation, the driver write and audit). Every row-level gate the write meets is therefore the one the verdict meets: the object grant, the row-level security pre-image with the ownership floor, and the controlled-by-parent master. At the chain's end it asks plugin-sharing's by-id gate with the context as the middleware stamped it. Any refusal answers `false`, and so does a boot with no engine. The probe runs on a copy of the caller's context and advances no write epoch, so it leaves no shared state behind. `editable` is unchanged.
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
'@objectstack/plugin-sharing': minor
3+
---
4+
5+
feat(plugin-sharing): `SharingService.admitsByIdWrite` — the by-id write gate the middleware refuses on
6+
7+
Clause-②: yes (widening)
8+
Metadata-protocol: added
9+
10+
The sharing middleware's by-id `update` / `delete` gate is now one service method, `admitsByIdWrite(object, recordId, verb, context)`, and the middleware refuses exactly when it answers `false`. The same calls run in the same order: `canEdit` / `canDelete`, the delegator's gate for an on-behalf-of write, then the authored-row-write deferral. `byIdWriteGateInstalled` says whether the middleware is installed (`false` under `enforce: false`). `plugin-security` asks the method for the record field-access answer's `writable`, so that verdict is the sharing gate's own. Refusals, their messages and their log lines are unchanged.
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
'@objectstack/spec': minor
3+
---
4+
5+
feat(spec): the record field-access answer says whether the record itself is writable
6+
7+
Clause-②: yes (widening)
8+
Metadata-protocol: added
9+
10+
`GetRecordFieldAccessResponseSchema` (`@objectstack/spec/api`, the answer of `GET /api/v1/data/{object}/{id}/field-access`) gains `writable: boolean`: whether the caller may update this record at all, judged by the write gate on the stored row (the object's edit permission, record sharing and ownership, row-level security, and the parent's edit access for a record controlled by its parent). `false` means an update that leaves the record as stored is refused: the caller may not edit it, or the stored record fails a row-level security `check` (a save moving the checked value back inside the check can still pass). `true` does not promise a save succeeds: a pending approval's record lock, conditional read-only and validation rules on the values sent, a data source that refuses writes, the platform's own rules for its own records (comments, attachments, mail messages, approval requests) and an object the API serves read-only are still judged at save. `editable` is unchanged and stays field-level. `ISecurityService.getRecordFieldAccess` documents the member: the write gate's own verdict, never a second evaluator.
11+
12+
Supersedes, in the same release: the `@objectstack/spec` note "a record-scoped field projection, judged on the stored row by the write gate" lists the answer as `{ object, recordId, readable, editable }`; it is now `{ object, recordId, readable, editable, writable }`.

‎content/docs/permissions/field-level-security.mdx‎

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -66,11 +66,20 @@ fields: {
6666
answer a record's edit form and the record page's in-place edit read. A
6767
list view's inline edit locks a whole column, which cannot split per row,
6868
so it keeps the row-blind answer. A row that is absent or that the caller
69-
cannot read answers the record read's `404`. The answer is field-level
69+
cannot read answers the record read's `404`. `editable` is field-level
7070
only: the object grant and the record's row-level write scope (sharing,
71-
row-level security, ownership) are not part of it, so on a row the caller
72-
may read but not write it still lists fields as editable, and the update
73-
is refused as a whole.
71+
row-level security, ownership) are not part of it. Those are `writable`,
72+
the row's own verdict: `false` on a row the caller may read but not write,
73+
where an update is refused as a whole, so a client need not offer the
74+
edit at all. It comes from the write gate too, run over an update of the
75+
row without performing it. A stored row outside a row-level `check` also
76+
answers `false`: an update that leaves it as it is fails the check, while a
77+
save that moves the checked value back inside it can pass. `true` does not
78+
promise a save succeeds: a pending approval's record lock, rules judged on
79+
the values sent (conditional read-only, validation), a data source that
80+
refuses writes, the platform's own rules for its own records (comments,
81+
attachments, mail messages, approval requests) and an object the API serves
82+
read-only still apply when the record is saved.
7483
- **Union without deny is unchanged.** A matched conditioned lock is still
7584
out-voted by another set's unconditioned `editable: true`; an entry
7685
without `when` votes on every row, as before (ADR-0066 ⑧, note of

‎content/docs/references/api/protocol.mdx‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1757,7 +1757,8 @@ The published metadata item body, opaque by ruling (1C). Shape is the item's own
17571757
| **object** | `string` | ✅ | Object name |
17581758
| **recordId** | `string` | ✅ | The record the projection was judged on |
17591759
| **readable** | `string[]` | ✅ | Fields the caller may read on this object. Reads are judged row-blind, so this equals the object-wide read projection |
1760-
| **editable** | `string[]` | ✅ | Fields the caller may write on this record, as field-level security judges an update of the stored row: a row-conditioned field permission locks a field only on the rows its condition names. A field absent here is refused by the write gate. The object grant, the record's row-level write scope (sharing, row-level security, ownership) and a field's own rules (readonly, computed types) are not part of it |
1760+
| **editable** | `string[]` | ✅ | Fields the caller may write on this record, as field-level security judges an update of the stored row: a row-conditioned field permission locks a field only on the rows its condition names. A field absent here is refused by the write gate. The object grant, the record's row-level write scope (sharing, row-level security, ownership; see `writable`) and a field's own rules (readonly, computed types) are not part of it |
1761+
| **writable** | `boolean` | ✅ | Whether the caller may update this record at all, judged by the write gate on the stored row: the object's edit permission, record sharing and ownership, row-level security, and a parent record's edit access for a record controlled by its parent. False means an update that leaves the record as it is stored is refused: either the caller may not edit this record, or the stored record fails a row-level security check that every save must satisfy, which a save moving the checked value back inside that check can still pass. True does not promise that a save succeeds: a pending approval that locks the record, conditional read-only and validation rules on the values sent, a data source that refuses writes, the platform's own rules for its own records (comments, attachments, mail messages, approval requests) and an object the API serves read-only are judged when the record is saved |
17611762

17621763

17631764
---
Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#27175] Would a by-id update of this stored row pass the row-level write
5+
* gates? — the verdict the record field-access answer carries as `writable`,
6+
* so a record's edit form stops offering an edit the gate refuses whole.
7+
*
8+
* ⛔ No second evaluator. The answer is not composed here from the gates'
9+
* parts: the probe RUNS the gates. It hands the authorization middleware this
10+
* plugin registers on the engine — the same function object, so every step
11+
* the write meets is the step the probe meets — a synthetic by-id `update` of
12+
* `recordId` with an empty payload, and ends the chain where the engine would
13+
* take over. Reaching that point means the middleware admitted the write; any
14+
* refusal on the way is `false`. Ruling of record 6457467702 (letter B) on the
15+
* record projection: the client never re-derives a lock, the server answers
16+
* with the gate's own judgement.
17+
*
18+
* Two middlewares refuse a by-id write for its ROW, and the probe asks both:
19+
*
20+
* - this plugin's: the object grant (step 2, with the ADR-0066 D3 capability
21+
* gate and the ADR-0090 D10 delegator ahead of it), the row-level security
22+
* pre-image with the platform ownership floor and its sharing deferral
23+
* (step 2.7), and the controlled-by-parent master (step 2.8);
24+
* - plugin-sharing's, which runs inside this one: its by-id gate, asked
25+
* through `SharingService.admitsByIdWrite` — the method its middleware
26+
* refuses on — with the context exactly as this middleware stamped it for
27+
* the next one (the write depth, an external principal's grant). Skipped
28+
* when the deployment runs without that middleware (`enforce: false`), as
29+
* the write skips it.
30+
*
31+
* ## Why an empty payload
32+
*
33+
* The question is the ROW, not a change set: every gate above judges the
34+
* stored row and the caller, and none reads the payload. The gates that do —
35+
* field-level security (its own answer is `editable`, field by field), the
36+
* masked echo, the owner forge — admit an empty one, so the probe asks
37+
* nothing about which values a save may carry. One payload gate still speaks:
38+
* the RLS `check` post-image (step 3.6) is the stored row merged with nothing,
39+
* so a stored row outside a `check` scope answers `false`, while a save that
40+
* moves the checked field back inside that scope is admitted by the same
41+
* step. That is the gate's own verdict on an update that leaves the row as it
42+
* is, and the published describe says so.
43+
*
44+
* ## Why it writes nothing
45+
*
46+
* The engine's executor — the `beforeUpdate` hooks, validation, the driver
47+
* write, the `afterUpdate` hooks and every audit row they produce — runs
48+
* after the last middleware, and the probe never reaches it: the chain's end
49+
* throws a sentinel that only this function catches. Before that point the
50+
* middleware refuses, reads, and stamps its operation and the context it is
51+
* handed; the probe owns the operation and hands a copy of the caller's
52+
* context, so the caller's own is never stamped. Its one shared write, the
53+
* permission-set memo epoch it advances for a write, is skipped for a probe
54+
* ({@link isRowWriteProbe}): a probe changes no grant.
55+
*
56+
* ## What `true` does not cover
57+
*
58+
* Rules outside those two middlewares, which a save can still meet: a
59+
* pending approval's record lock (its own reader, the approval service's
60+
* `getRecordLock`), conditional read-only rules and validation rules on the
61+
* values sent, an external datasource that refuses writes, another plugin's
62+
* own rules for its own objects (comments, attachments, mail messages,
63+
* approval requests), and the REST door's exposure policy for the object (an
64+
* object served read-only refuses the PATCH before the engine). ⛔ So `true`
65+
* never means "this save will succeed"; `false` means the gate refuses an
66+
* update that leaves the row as it is stored.
67+
*
68+
* Fails CLOSED: no middleware registered (no engine), an unexpected throw, or
69+
* a middleware that answers without passing the operation on are all `false`.
70+
*/
71+
72+
/**
73+
* Marks the synthetic operation {@link probeRecordRowWrite} hands the
74+
* middleware. A module-private symbol on the operation context, which only
75+
* this module constructs — no request, payload or execution context can carry
76+
* it.
77+
*/
78+
export const ROW_WRITE_PROBE: unique symbol = Symbol('objectstack.security.rowWriteProbe');
79+
80+
/** Whether `opCtx` is a row write probe's synthetic operation. */
81+
export function isRowWriteProbe(opCtx: unknown): boolean {
82+
return !!opCtx && typeof opCtx === 'object' && (opCtx as Record<symbol, unknown>)[ROW_WRITE_PROBE] === true;
83+
}
84+
85+
/**
86+
* Log a gate refusal. A refused write logs `warn`: someone tried to change a
87+
* row they may not. A refused probe is a form asking whether to offer an edit
88+
* at all, so it logs `debug` with `probe: true` — otherwise every open of a
89+
* read-only record writes a line indistinguishable from a denied write, and a
90+
* warning channel that fires on reads is one operators learn to ignore.
91+
*/
92+
export function logGateRefusal(
93+
logger: { warn?: (...args: any[]) => void; debug?: (...args: any[]) => void } | null | undefined,
94+
opCtx: unknown,
95+
...args: [message: string, meta?: Record<string, unknown>]
96+
): void {
97+
if (isRowWriteProbe(opCtx)) {
98+
logger?.debug?.(args[0], { ...args[1], probe: true });
99+
return;
100+
}
101+
logger?.warn?.(...args);
102+
}
103+
104+
/** The engine middleware signature `registerAuthzMiddleware` registers. */
105+
export type AuthzMiddlewareFn = (opCtx: any, chainNext: () => Promise<void>) => Promise<void>;
106+
107+
/** Thrown at the chain's end: the middleware admitted the write. Never escapes. */
108+
class RowWriteProbeReachedEngine {}
109+
110+
/**
111+
* Run `middleware` over a synthetic by-id update of `object`/`recordId` under
112+
* a copy of `context`, and answer whether the write reaches the engine.
113+
* `sharingAdmits` is asked at the chain's end with the context as the
114+
* middleware stamped it; see the module header for both legs.
115+
*/
116+
export async function probeRecordRowWrite(
117+
middleware: AuthzMiddlewareFn | null | undefined,
118+
object: string,
119+
recordId: string,
120+
context: unknown,
121+
sharingAdmits: (stampedContext: any) => Promise<boolean>,
122+
): Promise<boolean> {
123+
if (typeof middleware !== 'function') return false;
124+
const probeContext: Record<string, unknown> = { ...((context ?? {}) as Record<string, unknown>) };
125+
// The shape the engine's `update()` builds for a by-id update.
126+
const opCtx: Record<string | symbol, unknown> = {
127+
object,
128+
operation: 'update',
129+
data: {},
130+
options: { where: { id: recordId }, context: probeContext },
131+
context: probeContext,
132+
[ROW_WRITE_PROBE]: true,
133+
};
134+
const reachedEngine = new RowWriteProbeReachedEngine();
135+
let admitted = false;
136+
try {
137+
await middleware(opCtx, async () => {
138+
admitted = (await sharingAdmits(opCtx.context)) === true;
139+
throw reachedEngine;
140+
});
141+
} catch (e) {
142+
if (e === reachedEngine) return admitted;
143+
return false;
144+
}
145+
return false;
146+
}

‎packages/plugins/plugin-security/src/security-plugin-authz-middleware.ts‎

Lines changed: 28 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,14 @@
7070
* imported from the module `security-plugin.ts` imports it from.
7171
*
7272
* Nothing re-exports this module, so the published surface does not move.
73+
*
74+
* ## [#27175] The handler is also returned
75+
*
76+
* The function registers the handler and returns it, and `start()` keeps it,
77+
* so the record field-access answer's `writable` can run the very handler a
78+
* write meets over a synthetic by-id update (`record-row-write-probe.ts`).
79+
* The handler knows that operation in one place only: it advances no write
80+
* epoch for it.
7381
*/
7482
import {
7583
type PluginContext,
@@ -92,6 +100,7 @@ import { crossFieldClassRefusalCarriedBy, findCrossFieldClassRefusal } from '@ob
92100
import type { SecurityPlugin } from './security-plugin.js';
93101
import { isGuestEnvelope, requiredCapsForOperation } from './security-plugin.js';
94102
import { assertFlowSystemIdentityWriteAllowed } from './flow-system-identity-write-refusal.js';
103+
import { isRowWriteProbe, logGateRefusal, type AuthzMiddlewareFn } from './record-row-write-probe.js';
95104
import {
96105
intersectDelegatedScope,
97106
} from './explain-engine.js';
@@ -270,8 +279,11 @@ export function registerAuthzMiddleware(
270279
ctx: PluginContext,
271280
ql: IObjectQLEngine,
272281
metadata: IMetadataService,
273-
): void {
274-
ql.registerMiddleware(async (opCtx: any, chainNext: () => Promise<void>) => {
282+
): AuthzMiddlewareFn {
283+
// [#27175] The handler is kept as well as registered: the record
284+
// field-access answer's `writable` runs THIS function over a synthetic by-id
285+
// update (`record-row-write-probe.ts`), so the verdict is the gate's own.
286+
const authzMiddleware: AuthzMiddlewareFn = async (opCtx: any, chainNext: () => Promise<void>) => {
275287
// [#21771] Whether THIS operation was issued from inside another engine
276288
// operation's pipeline (a cascade, a hook's `ctx.api` write), read before
277289
// this operation opens a scope of its own; and the scoped `next` every
@@ -309,8 +321,12 @@ export function registerAuthzMiddleware(
309321
// (which only compares equality) but not harmless on the bus: the
310322
// `authz.invalidated` bridge publishes per bump. The guard below is
311323
// therefore about message count, not about correctness.
324+
//
325+
// [#27175] A row write probe is no write: it changes no grant, and it
326+
// must leave no shared state behind, so it advances nothing.
312327
if (
313328
!this.epoch.seamOwnedByEngine &&
329+
!isRowWriteProbe(opCtx) &&
314330
(opCtx.operation === 'insert' || opCtx.operation === 'update' || opCtx.operation === 'delete')
315331
) {
316332
this.epoch.bump('write');
@@ -663,7 +679,7 @@ export function registerAuthzMiddleware(
663679
const developerMessage =
664680
`[Security] Access denied: '${opCtx.object}' (operation '${opCtx.operation}') requires capability ` +
665681
`[${required.join(', ')}] — ${missing.length > 0 ? 'caller' : 'the delegator'} is missing [${allMissing.join(', ')}]`;
666-
ctx.logger.warn(developerMessage, {
682+
logGateRefusal(ctx.logger, opCtx, developerMessage, {
667683
operation: opCtx.operation,
668684
object: opCtx.object,
669685
requiredPermissions: required,
@@ -793,7 +809,7 @@ export function registerAuthzMiddleware(
793809
const developerMessage =
794810
`[Security] Access denied: operation '${opCtx.operation}' on object '${opCtx.object}' ` +
795811
`is not permitted for positions [${positions.join(', ')}]`;
796-
ctx.logger.warn(developerMessage, {
812+
logGateRefusal(ctx.logger, opCtx, developerMessage, {
797813
operation: opCtx.operation,
798814
object: opCtx.object,
799815
positions,
@@ -1108,7 +1124,7 @@ export function registerAuthzMiddleware(
11081124
const developerMessage =
11091125
`[Security] Access denied: not permitted to ${opCtx.operation} this ` +
11101126
`'${opCtx.object}' record (row-level security)`;
1111-
ctx.logger.warn(developerMessage, {
1127+
logGateRefusal(ctx.logger, opCtx, developerMessage, {
11121128
operation: opCtx.operation,
11131129
object: opCtx.object,
11141130
recordId: targetId,
@@ -1758,7 +1774,9 @@ export function registerAuthzMiddleware(
17581774
// the engine (an insert, a predicate update) and a by-id update
17591775
// judged here answer a caller identically.
17601776
const denyCheck = (): never => {
1761-
this.logger.warn?.(
1777+
logGateRefusal(
1778+
this.logger,
1779+
opCtx,
17621780
`[Security] RLS check FAILED on ${opCtx.operation} '${opCtx.object}' — write denied (fail-closed)`,
17631781
);
17641782
// [#7451] The third end-user situation, and the only one of the
@@ -1774,7 +1792,7 @@ export function registerAuthzMiddleware(
17741792
// Naming the object without the field would send the user hunting.
17751793
const developerMessage =
17761794
`[Security] Access denied: the ${opCtx.operation} would violate a row-level CHECK on '${opCtx.object}'`;
1777-
ctx.logger.warn(developerMessage, {
1795+
logGateRefusal(ctx.logger, opCtx, developerMessage, {
17781796
operation: opCtx.operation,
17791797
object: opCtx.object,
17801798
positions,
@@ -2381,5 +2399,7 @@ export function registerAuthzMiddleware(
23812399
// {@link maskOperationResult}, which the ADR-0056 public-form grant
23822400
// branch above reads too — one masker for both.
23832401
this.maskOperationResult(opCtx, permissionSets, secMeta, delegatorSets);
2384-
});
2402+
};
2403+
ql.registerMiddleware(authzMiddleware);
2404+
return authzMiddleware;
23852405
}

0 commit comments

Comments
 (0)