Skip to content

Commit 62b90d7

Browse files
fix(plugin-security,spec)!: a non-system caller that carries a principal and resolves no permission set gets the deny baseline at object admission and at the row scope (#21217)
Fixes #21079 Clause-②: yes (narrowing) Ruling E (`5933054144`, maintainer 「同意E」) as written: this card lands the deny baseline alone. The anonymous public-form picker is retired on its own card, #21180. Execution parameters are seat 1's release `5934091842`, and the claim is `5934758335`. The claim revision `5937041708` records the seat's **route B** and adds two surfaces: the two out-of-surface picker pins and the in-place delegator-arm fix. Patch round 1 implements both. ⚠️ Disclosure discipline applies (security family). This body states classes, positions, codes and statuses only. Every reading is private, in the dev's scratch space. ## What this changes For a non-system caller that carries a principal (a position, a named permission set or a user id) and resolves no permission set, an empty set list is now the ADR-0056 D2 deny baseline. The answer is the same at every layer, and no layer has a door-specific copy of it: - **Object admission.** The engine middleware's step 2 CRUD gate is no longer guarded on a resolved set. The evaluator answers the empty list as it answers any list, so the caller is refused with the `PERMISSION_DENIED` / 403 any ungranted caller gets. `canReadObject`, `canExport` and `canWriteObject` answer `false` for it. - **Row scope.** `getReadFilter` answers the deny sentinel for it, as its failure paths already did. - **The one exception.** `isPrincipalLessContext` is the one exception an empty list has. The middleware hands a principal-less context through, as before, and the three probes admit only that context. `getReadFilter` now reads the same predicate instead of an inline copy. ADR-0096 stages that context separately, and it is unchanged. - **Contract.** The `ISecurityService` docblocks in `packages/spec/src/contracts/security-service.ts` narrow to match: `canReadObject`'s arm list and its **Fails CLOSED** paragraph, `canExport`'s **Fails CLOSED** paragraph, `getMetadataReadableFields`'s "why the two differ" paragraph, and a deny-baseline paragraph on `getReadFilter`. No key, method, parameter, return type or export changes. Positions that stay as they are (phase 0's N3, re-derived on this tree): - step 1.5 (the capability gate), step 2.6 (the depth stash), steps 2.7, 2.8 and 3.6, and the delegator resolution all stay guarded on a resolved set. Ungated, the depth stash would answer `'org'` for an unmatched list, which widens. - `checkAuthoredRowWrite` still answers `'abstain'` for the empty list. - `computeLayeredRlsFilter` is unchanged. - Every field-level answer (#20995 / #21063) is unchanged. The comments that described the old admission are corrected. **A bounded in-place fix in the same class (declared here, as the four conditions require).** Three probes skipped the D10 delegator CRUD-grant arm when the **delegator** resolved no set: `canReadObject` arm 6, `canWriteObject` arm 8, and the `canExport` intersection. The middleware asks that arm over the delegator's empty list and refuses. Measured privately before the change: on an embedder with no baseline, an agent acting on behalf of a delegator who resolves no set was refused by the middleware (`PERMISSION_DENIED` / 403) while all three probes answered `true`. The fix drops the length guard on those three arms, so they read the same evaluator answer. The capability arms' delegator guard is left as it was, because the grant arm dominates it and no pin can observe it. The four conditions: 1. It is the same defect class (an empty set list grants by absence). 2. The fix is mechanical, and its shape is pinned by the middleware's own expression. 3. The file is this claim's. 4. It needs no new verification surface, because the same suites cover it. The claim revision `5937041708` adds these three arms to the claim's file surface. ## Measured (private; classes and positions only) Measured on base `0b12b9ea` and after the change. The engine-level probe drives the registered middleware and the three probes, and asks `getReadFilter`, for each member: | caller class | before | after | |---|---|---| | guest envelope, deployment with no guest set | admitted (find, insert); probes `true`; no row scope | refused 403 `PERMISSION_DENIED`; probes `false`; deny sentinel | | picker-shaped context, its named set not registered | admitted; probes `true`; no row scope | refused 403; probes `false`; deny sentinel | | signed-in user, baseline disabled, no grant | admitted; probes `true`; no row scope | refused 403; probes `false`; deny sentinel | | context naming only unresolvable sets, no user id | admitted; probes `true`; no row scope | refused 403; probes `false`; deny sentinel | | agent for a delegator who resolves no set | middleware refused; **probes `true`** | middleware refused; probes `false` | | principal-less context (boundary) | admitted | admitted (unchanged) | | signed-in member with a set (control) | granted object admitted, ungranted refused | unchanged | | picker-shaped context, its named set registered (control) | admitted by the set | unchanged | Door-level consequences (N4). These are measured on real `bootStack` boots, with plugin-security built at each leg and the build checked by `ablation-dist-preflight`: - **Stock showcase doors are unchanged.** 26 dogfood files are green on both legs: the 13 shared-showcase files plus 13 isolated showcase and security files (persona matrix, authz conformance, public form, public-form read-back masking, the declared showcase endpoints, the default profiles, and others). The other 3 files in the set are the moving ones. - **The app-declared anonymous endpoint goes from 200 to 403 `PERMISSION_DENIED`** on a deployment with no matching set. It still passes the authentication gate, so it answers 403, not 401. - **The public-form picker goes from 200 to 403 `PERMISSION_DENIED`** on a deployment without the set it names. - **A signed-in user on a baseline-disabled embedder loses the everything-default.** The record read and every query answer 403 `PERMISSION_DENIED`. - **The public form submit is unaffected.** Its grant admits ahead of object admission. - **Fork clause:** not triggered. No stock-boot door was found that serves this class. The measurement is the dogfood showcase harness on both legs. A real `pnpm dev -- --fresh` boot was NOT MEASURED this round; phase 0 measured 24 anonymous doors on one. ## Pins **New:** `zero-set-deny-baseline.test.ts` (18 cases). It covers each measured member (the guest envelope, a picker-shaped context, and a signed-in user on a baseline-disabled embedder). For each member it pins the premise (zero sets), that all seven engine operations are refused at object admission before they run (`code` and `status`), that the three probes answer `false`, and that the row scope is the deny sentinel. The controls are a set-holding member (admitted to its granted object, refused the other) and a registered named set. The principal-less context is pinned as the boundary. The second-principal case is pinned with a set-holding delegator as its control. No pin title states a request. **Moved:** 21 `plugin-security` cases in 5 files and 4 dogfood cases in 2 files, the same set phase 0 named and re-derived on this tree. Under route B there are also 5 picker cases in 2 more files (see the next section). Each now asserts the new answer's substance: - `zero-set-masking.test.ts` (9) and `zero-set-capability-fold.test.ts` (9). The field projections still exclude masked and capability-gated fields. Every query, read and write is now refused at object admission (`PERMISSION_DENIED` / 403), and the read's row scope is the deny sentinel. In the fold suite, explain agrees: `object_crud` denies, `allowed` is false, and `fls` still reports the field hidden. `canWriteObject` still equals the middleware, field for field. The masked-echo case was refused 400 at the echo gate and is now refused 403 at admission, ahead of it. No refusal assertion was weakened. - `can-write-object-admission.test.ts` (1). The org-bound zero-set twin is now **denied** by arm 4. The pin adds the middleware's own refusal (`code` and `status`). - `get-writable-fields.test.ts` (1). The field answer is unchanged. Every write the case names is refused at admission (`code` and `status`). - `predicate-related-read-tenant-scope.test.ts` (1). The write door's refusal is unchanged. The preview is now refused at its write gate, because `canWriteObject` answers the public-form grant context `false` (the grant is not one of its arms). The pin asserts that probe answer directly and keeps the "identical whatever the value" and "related columns never read" assertions. - Dogfood `declarative-endpoint-policy` (2). The explicit `authRequired: false` endpoint passes authentication, and its object read is refused 403 `PERMISSION_DENIED`. The declared cache directive is not on that refusal. The success side of that directive stays pinned in the runtime units and the showcase file. - Dogfood `zero-set-masking` (2). The picker door and the baseline-disabled record door are refused at admission. The record door is checked on the read and on a query that names an unmasked field. **The picker-door case is shared with #21180.** That card deletes the picker door and this dogfood case. Measured: an untouched picker case turns red under this change, so I re-pinned it to the deny baseline. I did not restructure it, and its fixture is untouched. The ruling gives this case to whichever card lands second. Under route B this PR is expected to land first, and #21180 then takes its own deletion of the case. ## Route B: the two picker pins from #21062, re-pinned PR #21136 (#21062) landed after phase 0's measurement. It added two picker pins, and both assume the picker serves a visitor with no session on a deployment with no matching set. Ruling E fixes no landing order between this card and #21180, which retires the picker. The seat ruled route B in claim revision `5937041708`: this card re-pins both files and lands on its own clock. The reasons it gives: - the picker has no producer outside spec, tests, docs and its own route, so the window in which the picker answers 403 costs nothing; - #21180 has no PR yet, so waiting for it has no stated end. Each fixture's intent is kept, and no case is deleted. #21180 deletes both files when it lands. - **`packages/rest/src/public-form-lookup-picker-queryable-key.test.ts`, 4 cases.** The no-guest-set class moves out of the shared loop into its own `describe`. - The picker whose first display field is masked: its listing and its search each answer `403` with `code: 'PERMISSION_DENIED'`, no row, and no stored value in the body. - The masked-only picker answers the same `code` and `status` as the engine's own refusal, and the engine is never asked. The pin also asks the engine for a query keyed on a field the caller MAY query on, which only object admission refuses. Without that line the case stayed green with the deny baseline put back; that was the first ablation run. - The no-masked-field control is refused too, and still keys on its first display field. - The premise and the "never keys on the masked field" cases are unchanged. The guest-set class and the lifted-rule class keep their pins as they were. - **`packages/qa/dogfood/test/public-picker-queryable-key.dogfood.test.ts`, 1 case.** For a visitor with no session and for a signed-in caller, the listing and the search answer `403` `PERMISSION_DENIED`, with nothing served and no stored value in the body. The masked-only picker answers the same. ## Ablations (one leg per negative pin) Each leg used `scripts/ablation-replace.mjs` in wrap mode, and its own `trap … EXIT INT TERM` restored the file from `HEAD` on an absolute path. Each leg landed (anchor count 1 to 0, blob changed). The final state was blob `a4a95715` equal to `HEAD`, with an empty `git diff HEAD` and a clean `git status`. Every leg ran on the `plugin-security` test files as they stand at `3e49b1af`; this round does not change them: | leg (forbidden behaviour put back) | red | |---|---| | step 2 guard `permissionSets.length > 0 &&` restored | 23 of 139 (all 3 deny-baseline middleware pins, plus the moved middleware pins) | | `canReadObject` arm 2 answers `true` | 3 (the probe pins) | | `canWriteObject` arm 4 returns through the organization wall | 9 | | `canExport` arm answers `true` | 3 | | `getReadFilter` zero-set line deleted | 6 (the deny-sentinel pins) | | delegator grant arm guarded on length, in `canReadObject`, `canWriteObject` and `canExport` (one leg each) | 1 each (the second-principal pin) | The first attempt at the `canExport` delegator leg was a no-op: the replacement contained the anchor, and the tool refused it with exit 1 and nothing run. It was re-run with a non-overlapping anchor, and that run is the one in the table. **The picker leg (patch round 1).** `scripts/ablation-replace.mjs` put back the base's step 2 guard. That guard is the only arm the picker door reads; the door asks `getQueryableFields` (unchanged) and then the engine. The wrapped child rebuilt `plugin-security`, and the preflight found the injected guard in 2 built files. Results: - `public-form-lookup-picker-queryable-key.test.ts`: 4 of 19 red, exactly the 4 re-pinned cases. - `public-picker-queryable-key.dogfood.test.ts`: 1 of 1 red. The restore was proven: blob `a4a95715` equals `HEAD`, `git diff HEAD` is empty, and after the rebuild the `--absent` preflight read the marker absent from all 6 built files and the tree clean. Both files then ran green again: 19/19 and 1/1. The dist-level leg put base `security-plugin.ts` back (blob `970439f6`, marker 3 to 0 in src), rebuilt, and ran the dogfood set. The 4 re-pinned dogfood cases were red, and the stock showcase files stayed green. It then restored and rebuilt, and the preflight found the marker in 2 built files again. ## Gates and tests (at `03ae8604`, after `origin/main` `097ef802` was merged as `2e1564f5`) - **Gate list.** `dispatch-gates --commands` derived 109 families on this head; the change set now has 14 paths. All 109 exit 0, and `--ran` reconciled them with recorded exit codes: 109 run, 0 NOT-MEASURED. `check:dual-build-cjs-loads` read the 8 packages it needs after they were built. - **plugin-security.** 156 files, 3345 passed, 23 skipped. `typecheck` exits 0. - **rest.** The full `local` project: 256 files, 4844 passed, 310 skipped. `typecheck` exits 0, and `tsconfig.test.json` lists the re-pinned file. - **dogfood.** `typecheck` exits 0. The dogfood set from round 1 (30 files) is 314 passed and 0 failed, and that now includes the picker file. - **Spec.** Unchanged since round 1, when the spec contract tests were 31/31 and `check:generated` reported all 15 artifacts up to date. - **Lint, narrowed.** - The population comes from `eslint.config.mjs` (`files: **/*.{ts,…}` minus `NEVER_LINTED`). All 12 changed `.ts` files are in it, and `--format json` counts 12 files, 0 errors and 0 warnings. - The config enables no type-aware linting and this diff touches no lint configuration, so it cannot move the verdict on an untouched file. The repo-wide `pnpm lint` is CI's. ## Docs (patch round 1) The drift check (`5936999532`) listed 8 hand-written pages. I read all 8, then grepped `content/docs/**` (outside `releases/` and the generated `references/`) and `skills/**` for sentences that say a caller with a principal and no resolved set is admitted, has no row scope, or falls through to sharing alone, or that say what the anonymous picker answers. Three passages in `content/docs/ui/forms.mdx` were made false by this PR, and they are rewritten. Only sentences about what the door answers changed; the text about the door's existence is #21180's. - **Section 2, the guest set.** It said the guest set is only needed for the back-compat path, and that it should be INSERT-only. Now: that holds for the submit. A form with a public picker needs a guest set that grants read on the picker's target object, because without it the picker answers `403 PERMISSION_DENIED` under the deny baseline. Any read granted there is readable by every anonymous visitor through the picker. - **The picker's errors table** gains a `403 PERMISSION_DENIED` row: no guest set grants read on the target object, so the search context resolves no set and is refused at object admission. The declaration bounds what is searched; it never grants the read. - **"Auth model"** said the lookup's result set "is bounded by the picker declaration instead". Now: the declaration bounds what is matched and projected, the guest set authorizes the search, and without one the search is refused at object admission. No other sentence was false. The other 7 drift-check pages either name the probes in the system-context bypass table (unchanged) or say nothing about a set-less caller. `skills/objectstack-api` describes `authRequired: false` as an authentication opt-out only, which is still true. Release-owned pages were read but not edited; none of the 4 the drift check names states the old zero-set admission. ## Acceptance notes (observations, not filed) - **ADR-0087 marker.** The changeset carries `not-required (no-migration-prescription)`, and the gate accepts it. The ruling's migration text is a grant decision, not a rewrite of anything an author wrote, so `objectstack migrate meta` has nothing to rewrite. Phase 0 expected this category to be wrong under route D. This is the same reading as the #21061 and #21063 changesets. - **A stale comment in `packages/rest/src/rest-server.ts`** (the export and explain fail-stance docblock). It still says the middleware skips its CRUD gate for an empty resolution. Carrier: #21180, which edits that file. It is a comment only. - **A misleading preview message.** When the write-gate probe refuses a caller, a traversing validation rule in the preview fails with a message that tells the author to fix the rule's condition. It does not say that the related read was withheld. This is existing behaviour for every refused caller, and this PR only adds callers to that class. Carrier: none. - **API-key principal:** NOT MEASURED (as in phase 0). It carries a user id, so the baseline applies on a stock deployment. --- _Generated by [Claude Code](https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 5b481e2 commit 62b90d7

14 files changed

Lines changed: 722 additions & 248 deletions
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
---
2+
'@objectstack/plugin-security': minor
3+
'@objectstack/spec': minor
4+
---
5+
6+
fix(plugin-security,spec)!: a non-system caller that carries a principal and resolves no permission set gets the deny baseline at object admission and at the row scope, and the security contract says so (#21079)
7+
8+
Clause-②: yes (narrowing)
9+
10+
<!-- adr-0087: not-required (no-migration-prescription) No authorable key, export, published type, stored shape or wire shape is removed or renamed, so objectstack migrate meta has nothing to rewrite. What moves is which callers object admission and the row scope accept: a caller that carries a principal and resolves no permission set is now refused, and the remedy is a grant decision (a permission set the deployment declares and assigns), not a rewrite of anything an author already wrote. -->
11+
12+
**BREAKING for callers who resolve no permission set.** Shipped as `minor` under the launch-window convention.
13+
14+
**What changed.** ADR-0056 D2 gives an unauthenticated principal the deny baseline, not "no checks", and ADR-0090 D9 gives a guest the `guest` position and nothing else. A non-system caller that carries a principal (a position, a named permission set or a user id) but resolves no permission set was instead admitted to every object no set grants, for reads and writes, and read with the record-sharing predicate as its only row scope. Now an empty set list grants nothing:
15+
16+
- **Object admission refuses it.** Every engine operation (find, findOne, count, aggregate, insert, update, delete) is refused with `403 PERMISSION_DENIED`, the same refusal any caller gets for an object its sets do not grant. `ISecurityService.canReadObject` and `canExport`, and the write preview's admission, answer `false` for it.
17+
- **Its row scope is the deny filter.** `ISecurityService.getReadFilter` answers the filter that matches zero rows for it, as it already did on a resolution failure.
18+
- **The second principal of a delegated request is held to the same answer.** An agent acting on behalf of a delegator who resolves no permission set was already refused by the engine; `canReadObject`, `canExport` and the write preview now refuse it too.
19+
20+
The field answers for this caller (`getReadableFields`, `getQueryableFields`, `getWritableFields`, `getMetadataReadableFields`) are unchanged: they are field-level answers, and the contract now says that object admission is not part of them. The `ISecurityService` docblocks in `@objectstack/spec/contracts` that stated the old zero-set admission (`canReadObject`, `canExport`, the metadata-plane field projection) and the deny cases of `getReadFilter` narrow to match. No method, parameter or return type changes.
21+
22+
**Who this reaches.**
23+
24+
- An unauthenticated request carried as the guest envelope, on a deployment that grants anonymous callers no permission set.
25+
- A context that names only permission sets the deployment does not register.
26+
- A signed-in user on an embedder that switches the baseline off (`fallbackPermissionSet: null`) and grants that user nothing.
27+
28+
A context that carries no principal at all (no position, no named set, no user id) is handed through as before; ADR-0096 stages it separately. A caller who resolves at least one permission set is decided by its sets, as before, and so is a system context. The public form submit is unaffected: its declaration-derived grant admits the create and its read-back ahead of object admission. Signed-in users of a stock `objectstack serve` deployment are unaffected: it applies the member baseline to every one of them, so none resolves an empty list.
29+
30+
**Migration.** A caller that resolves no permission set is refused object admission and reads nothing. An app-declared anonymous endpoint (`authRequired: false`) can no longer read or write objects until the `guest` anchor's bindings are resolved for anonymous callers (#21158). An embedder that sets `fallbackPermissionSet: null` must grant its signed-in users a set explicitly.
31+
32+
**For implementers of `ISecurityService`.** Answer `canReadObject`, `canExport` and the object-admission half of a write `false`, and `getReadFilter` with your deny filter, for a non-system caller that carries a principal and resolves no permission set; admit only the principal-less context.

‎content/docs/ui/forms.mdx‎

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -103,12 +103,19 @@ export default defineView({
103103
104104
## 2. (Optional) Create the `guest_portal` permission set
105105

106-
Authorization no longer depends on this set — the declaration-derived
107-
`publicFormGrant` (see the note above) is what permits the insert. You only need
108-
a `guest_portal` permission set if you rely on the legacy back-compat path (e.g.
109-
an older runtime, or object hooks that branch on the `guest_portal` permission).
110-
When present it is still attached to the anonymous context, so keep it
111-
INSERT-only on the target object — the guest-safe shape (ADR-0090 D9).
106+
The submit's authorization no longer depends on this set — the
107+
declaration-derived `publicFormGrant` (see the note above) is what permits the
108+
insert. For the submit you only need a `guest_portal` permission set if you rely
109+
on the legacy back-compat path (e.g. an older runtime, or object hooks that
110+
branch on the `guest_portal` permission). A form that declares a public picker
111+
(see *the public picker* below) is different: the picker's search runs under
112+
this set and nothing else, so without one that grants read on the picker's
113+
target object the picker answers `403 PERMISSION_DENIED` — the ADR-0056 D2 deny
114+
baseline refuses a caller that resolves no permission set. When present the set
115+
is attached to the anonymous context, so keep it INSERT-only on the form's
116+
target object — the guest-safe shape (ADR-0090 D9) — and remember that any read
117+
you grant a picker's target object is readable by every anonymous visitor
118+
through that picker.
112119

113120
{/* os:check */}
114121
```ts
@@ -284,14 +291,15 @@ Errors:
284291
|---|---|---|
285292
| `400 INVALID_REQUEST` | missing / blank slug or field |
286293
| `403 LOOKUP_NOT_PUBLIC` | the field has no `publicPicker` block — the deliberate loud default (#3022); also any server-managed anchor (`owner_id`, `organization_id`, …), which never gets a picker even if one is declared |
294+
| `403 PERMISSION_DENIED` | the deployment registers no `guest_portal` set that grants read on the picker's target object, so the search context resolves no permission set and is refused at object admission — the ADR-0056 D2 deny baseline. The picker's declaration bounds what is searched and projected; it never grants the read itself |
287295
| `404 FORM_NOT_FOUND` | slug not registered on any `sharing.allowAnonymous: true` view |
288296
| `500 LOOKUP_TARGET_MISSING` | the referenced object could not be resolved from either `publicPicker.object` or the field definition — the field names no target object at all (or its object metadata is unreachable). Until #7486 this also fired for a perfectly well-formed field, because the fallback read only the legacy spellings and not the canonical `reference`; declaring `object` was the workaround and is no longer needed. |
289297
| `500 INTERNAL_ERROR` | the field def **declares** a target this route cannot READ — a stored `reference` holding an object or an array rather than the object name `FieldSchema` declares. ⚠️ Deliberately **not** `LOOKUP_TARGET_MISSING`: "nothing names the target" and "the named target is unreadable" want different fixes from whoever owns the metadata, so they get different answers. The unreadable carrier is named in full in the server log (it is withheld from the response body, as every fault's text is); the picker's search never runs. |
290298

291299
### Auth model
292300

293301
- None of the three routes calls `enforceAuth`, so they work under the always-on anonymous-deny default (there is no `requireAuth` knob to configure since v17).
294-
- The execution context the **submit** route hands to ObjectQL is `{ publicFormGrant: { object }, permissions: ['guest_portal'], anonymous: true }` with no `userId`. The Security plugin honors `publicFormGrant` first — a create + read-back grant scoped to exactly the declared object — so authorization holds even without a `guest_portal` profile. `permissions: ['guest_portal']` is retained for back-compat. The **lookup** route's search context is `{ permissions: ['guest_portal'], anonymous: true }` — no `publicFormGrant` (it reads the picker's target object, not the form's), which is why its result set is bounded by the picker declaration instead.
302+
- The execution context the **submit** route hands to ObjectQL is `{ publicFormGrant: { object }, permissions: ['guest_portal'], anonymous: true }` with no `userId`. The Security plugin honors `publicFormGrant` first — a create + read-back grant scoped to exactly the declared object — so authorization holds even without a `guest_portal` profile. `permissions: ['guest_portal']` is retained for back-compat. The **lookup** route's search context is `{ permissions: ['guest_portal'], anonymous: true }` — no `publicFormGrant` (it reads the picker's target object, not the form's), so the picker declaration bounds what the search matches and projects, and the deployment's `guest_portal` set is what authorizes it: with no such set granting read on the target object, the search context resolves no permission set and is refused at object admission (`403 PERMISSION_DENIED`, the ADR-0056 D2 deny baseline).
295303
- No CSRF or auth header is needed; embed the form on any domain.
296304

297305
## 5. Embedding from a front-end

‎packages/plugins/plugin-security/src/can-write-object-admission.test.ts‎

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -671,10 +671,19 @@ describe('the arms the CRUD grant alone does not cover', () => {
671671
await expect(plugin.canWriteObject('invoice', 'insert', ORGLESS_CTX, PLAIN_PAYLOAD)).resolves.toBe(false);
672672
});
673673

674-
it('ADMITS the org-bound twin who resolves no permission set — arm 4 still admits', async () => {
675-
const { plugin } = await boot([], undefined, { orgScoping: true, noBaseline: true });
674+
it('[#21079] DENIES the org-bound twin who resolves no permission set — arm 4 is the deny baseline, wall or no wall', async () => {
675+
const { plugin, middleware } = await boot([], undefined, { orgScoping: true, noBaseline: true });
676676
await expect((plugin as any).resolvePermissionSetsForContext(WRITER_CTX)).resolves.toEqual([]);
677-
await expect(plugin.canWriteObject('invoice', 'insert', WRITER_CTX, PLAIN_PAYLOAD)).resolves.toBe(true);
677+
await expect(plugin.canWriteObject('invoice', 'insert', WRITER_CTX, PLAIN_PAYLOAD)).resolves.toBe(false);
678+
// …which is the middleware's own answer: refused at its CRUD gate, ahead of the wall.
679+
const refusal = await middleware(
680+
{ object: 'invoice', operation: 'insert', context: { ...WRITER_CTX }, options: {}, ast: { where: {} }, data: PLAIN_PAYLOAD },
681+
async () => {},
682+
).then(
683+
() => null,
684+
(e: { code?: unknown; status?: unknown; statusCode?: unknown }) => ({ code: e.code, status: e.status ?? e.statusCode }),
685+
);
686+
expect(refusal).toEqual({ code: 'PERMISSION_DENIED', status: 403 });
678687
});
679688

680689
it('ADMITS the same caller with an active organization — so the arm is not a blanket deny', async () => {

‎packages/plugins/plugin-security/src/get-writable-fields.test.ts‎

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -158,13 +158,24 @@ describe('getWritableFields — the answers the contract names', () => {
158158
expect(await plugin.getWritableFields('invoice', { isSystem: true })).toEqual(FIELDS);
159159
});
160160

161-
it('no permission sets resolved: the full field set minus the capability-gated field, which the write gate refuses', async () => {
161+
it('no permission sets resolved: the full field set minus the capability-gated field — a field answer, while [#21079] the write itself is refused at object admission', async () => {
162162
const { plugin, middleware } = await boot([], { noBaseline: true });
163163
// [#21063] The caller holds no capability, so `margin`'s
164-
// `requiredPermissions` refuses it on write; nothing else is refused.
164+
// `requiredPermissions` is the one field the field layer refuses it.
165165
expect(await plugin.getWritableFields('invoice', WRITER_CTX)).toEqual(FIELDS.filter((f) => f !== 'margin'));
166-
expect(await middlewareAdmits(middleware, 'update', WRITER_CTX, { margin: PAYLOAD_VALUE.margin })).toBe(false);
167-
expect(await middlewareAdmits(middleware, 'update', WRITER_CTX, { secret: PAYLOAD_VALUE.secret })).toBe(true);
166+
// [#21079] The answer is field-level only, as the contract states: this
167+
// caller carries a principal and resolves no set, so the ADR-0056 D2 deny
168+
// baseline refuses every write at the CRUD gate, whatever field it names.
169+
for (const field of ['margin', 'secret']) {
170+
const refusal = await middleware(
171+
{ object: 'invoice', operation: 'update', context: { ...WRITER_CTX }, options: {}, ast: { where: {} }, data: { [field]: PAYLOAD_VALUE[field] } },
172+
async () => {},
173+
).then(
174+
() => null,
175+
(e: { code?: unknown; status?: unknown; statusCode?: unknown }) => ({ code: e.code, status: e.status ?? e.statusCode }),
176+
);
177+
expect(refusal, `update naming ${field}`).toEqual({ code: 'PERMISSION_DENIED', status: 403 });
178+
}
168179
});
169180

170181
it('an unresolvable object is no answer (undefined), not an empty one', async () => {

‎packages/plugins/plugin-security/src/predicate-related-read-tenant-scope.test.ts‎

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -179,7 +179,7 @@ async function boot(kind: 'secret' | 'public', posture?: 'single' | 'group' | 'i
179179
});
180180

181181
const stored = async (object: string, name: string) => (await table(object).where({ name }).select('id')).length;
182-
return { engine, readsOfLine, userColumnsRead, stored, table };
182+
return { engine, readsOfLine, userColumnsRead, stored, table, plugin };
183183
}
184184

185185
/** Everything the caller sees of one write and one preview naming `line`. */
@@ -365,14 +365,25 @@ describe('#18682 — a caller with no userId that is not system is bound like a
365365
}
366366
});
367367

368-
it('a user only org Y holds, under no wall: every door refuses as not readable, whatever its value', async () => {
368+
it('a user only org Y holds, under no wall: every door refuses, whatever its value — the write as not readable, the preview at its write gate', async () => {
369369
const banned = await observe('secret', 'u_y', PUBLIC_FORM('qa_review'), 'single', 'qa_review');
370370
const clear = await observe('public', 'u_y', PUBLIC_FORM('qa_review'), 'single', 'qa_review');
371371

372372
expect(clear.seen).toEqual(banned.seen);
373373
expect(banned.seen.refusal).toMatchObject({ code: 'VALIDATION_FAILED', message: expect.stringContaining("could not read 'sys_user'") });
374374
expect(banned.seen.committed).toBe(0);
375-
expect(banned.seen.preview).toEqual({ valid: false, errors: [expect.stringContaining("could not read 'sys_user'")] });
375+
// [#21079] The preview asks the engine's write gate before it reads anything
376+
// related. This grant context names a set the deployment does not register,
377+
// so it resolves no permission set, and the ADR-0056 D2 deny baseline
378+
// refuses it there (the grant is not one of `canWriteObject`'s arms — its
379+
// docblock says why). The preview therefore refuses with the related user
380+
// never read, instead of reading it and failing the read.
381+
const h = await boot('public', 'single');
382+
expect(
383+
await h.plugin.canWriteObject('qa_review', 'insert', PUBLIC_FORM('qa_review'), { name: 'probe', reviewer: 'u_y' }),
384+
).toBe(false);
385+
expect(banned.seen.preview.valid).toBe(false);
386+
expect(banned.seen.preview.errors).toHaveLength(1);
376387
expect(banned.userColumnsRead).not.toContain('banned');
377388
});
378389

0 commit comments

Comments
 (0)