Skip to content

Commit 665cab3

Browse files
fix(plugin-security,spec)!: a caller who resolves no permission set is not served a capability-gated field, and the security contract says so (#21134)
Fixes #21063 Clause-②: yes (narrowing) ## What this changes A field that declares `requiredPermissions` and no masking rule ("mask on read, deny on write; AND-gate", ADR-0066 D3) is no longer served to a non-system caller who resolves no permission set. That caller may not query on it, and a write payload naming it is refused. The explain engine already reported the field hidden for this caller class, and the record doors now agree with it. The service contract's field answers for this class narrow to match. This implements triage's ruling (`5925000390`) as written. The zero-set stand-in folds the capability gate through the fold the full-set path already takes. The full-set answer for every other class is unchanged. ## One derivation (H1) - `resolveCallerPosture`, the zero-set stand-in from PR #21051, now carries the posture's per-field capability contract (`fieldRequiredPermissions`) beside the masking rules. Every reader already folds that input with `foldFieldRequiredPermissions`, the ADR-0066 D3 helper the full-set path uses. A caller with no set holds no capability, so each capability-gated field reads as neither readable nor editable for it. The fold itself is one line in the stand-in, with no second derivation. - The readers that move for this class all go through that one input: - the step 4 result masker (the record doors); - the step 2.9 predicate guard and the 2.5b aggregate-input guard (`computeQueryGuardFieldPerms`); - the published projections, through `resolveProjectionFieldMask`. - ⛔ Not carried: the object's own capability contract (object-level `requiredPermissions`). This caller's object admission is unchanged: the capability and CRUD gates are both still guarded by a resolved set. ## The write half, and why it is in this PR (H3, measured) The stand-in is also what `getWritableFields` reads, so carrying the field contract moves that projection for this class too. Its contract says it is "the exact complement of the fields that gate refuses". The middleware's step 2.5 field write gate was guarded by a resolved set. Left that way, the plugin would publish a write answer the write path does not enforce. Two load-bearing edits in the same file follow from this, both declared here: - **Step 2.5 is no longer gated on a resolved set**, as 2.5a and 2.5b have not been since PR #21051. For a caller with no set, the evaluator's field map is empty, so the gate refuses only the capability-gated fields. That is "deny on write", as the field declares. The gate's verdict is now one helper, `computeForbiddenFieldWrites`, which replaces the two spellings the middleware and `canWriteObject` each carried. - **`canWriteObject` (the probe the write preview asks) gains a field arm for this class**, used when the caller carries a principal and supplies a payload. It asks the same helper over the same stand-in, so the preview answers such a payload as the write path does. On an unreadable posture the arm fails closed, as the projection answers `[]` there. ## Per reader, by class | Reader | Caller who resolves no set and carries a principal | Every other class | |:--|:--|:--| | Record doors (step 4 result masker) | capability-gated field: served stored → not served | unchanged | | Explain, `fls` layer | reported hidden (unchanged) | unchanged | | Predicate and aggregate guards (2.9, 2.5b) | a filter, sort, group or aggregate naming it: admitted → 403 `PERMISSION_DENIED` | unchanged | | Field write gate (2.5) | a payload naming it: admitted → 403 `PERMISSION_DENIED` | unchanged | | `getReadableFields`, `getQueryableFields`, `getWritableFields` | full set → full set minus the field | unchanged | | `getMetadataReadableFields` | the same, when the fallback set resolves nothing | unchanged | | `canWriteObject`, payload naming it | true → false | unchanged | | A principal-less context (no position, named set or user id) | handed through, unchanged | not applicable | A field that declares both `requiredPermissions` and a `maskingRule` is still served masked to this class, as since PR #21051. ## Contract (H2) `packages/spec/src/contracts/security-service.ts` changes in its docblocks only. No method, type or export moves, and `check:api-surface` is green. - `getReadableFields` now states the answer for this class: the full set minus the capability-gated fields it is not served. A masked field stays, as a served column. - `getMetadataReadableFields` no longer says the middleware "skips its whole field gate" for this caller. It now says the middleware skips its permission-set grant gates while a field's own declarations still apply. When the fallback set resolves nothing, the method answers as the data plane does. - `getWritableFields` now states that `requiredPermissions` is part of its answer for every non-system caller. ## Pins, red then green (H4, measured) - `packages/plugins/plugin-security/src/zero-set-capability-fold.test.ts` (new) covers the three ways this class arises, in the house style of `zero-set-masking.test.ts`. Each case first asserts that zero sets resolve. Then: - the record door and explain agree that the field is hidden; - the read projections leave the field out; - the query projection equals both query guards, field for field, across four positions; - the write gate refuses the field, and `getWritableFields` and `canWriteObject` agree with that gate field for field. - Controls in the same file: - a holder of the capability is served the stored value, explain hides nothing, and it may query and write the field; - a set without the capability gets the field hidden, as before; - the principal-less boundary is handed through. - **Red** on the pins commit `d49cc264d` (plugin source equal to the base): 12 failed, 8 passed. The 12 are the class cases; the premises, controls and boundary passed. H4 before the fix: the record door served the field (`gatedServed: true`) while explain reported it hidden (`explainHides: true`). - **Green** after the fix: the file passes 20 of 20. - `get-writable-fields.test.ts`: the one case that pinned the full set for this class now expects the capability-gated field excluded, and checks that the middleware agrees. ## Ablation (measured at `65d1da2b8`) Three one-anchor mutations went through `scripts/ablation-replace.mjs`. Each landed on disk (anchor count 1 → 0, injected marker 0 → 1, blob changed). Each was restored inside its EXIT/INT/TERM trap with `git checkout HEAD --` on the absolute path. Each restore was proven byte-identical: the blob `4d142d02…` equals HEAD's, and `git diff HEAD` is empty. The pins import the plugin source directly, so no `dist/` is on the resolution path. - **A1.** The stand-in line set back to an empty field contract: 12 failed, 8 passed. These are the same 12 as the red run. - **A2.** Step 2.5 gated on a resolved set again: 3 failed, 17 passed. That is one write case per class, where the middleware admits a payload that `getWritableFields` excludes. - **A3.** `canWriteObject`'s field arm for this class switched off: 3 failed, 17 passed. Here `canWriteObject` admits what the gate refuses. ## Consumers (Z3): suites run, not edited These ran at `65d1da2b8`, after building the upstream closures (turbo, `--filter='@objectstack/rest^...'` and the same form for each package below). - `@objectstack/rest`, the whole suite in two shards: 259 files, 5030 passed, 143 skipped. - `@objectstack/service-analytics`: 154 files, 3498 passed, 10 skipped. Its field gate reads the read and query projections, so for this class a capability-gated field is now refused as a group key, aggregate input or filter. - `@objectstack/plugin-approvals`: 52 files, 804 passed. Its snapshot redaction reads the read projection intersected with the query one, so for an approver in this class a capability-gated field is now dropped from the snapshot. - `@objectstack/metadata-core`, which consumes `getMetadataReadableFields`: 16 files, 298 passed. - `@objectstack/objectql` reads no field projection. It reads `canWriteObject` through the write-gate probe. Its four suites that boot plugin-security: 34 passed. The probe seam across both packages is pinned by plugin-security's `write-preview-field-gate-parity.test.ts`, green in the full run below. - `@objectstack/spec`, `src/contracts/`: 45 files, 434 passed. ## Local verification at `f692a171c` (after merging `main`, which brought PR #21101) - `@objectstack/plugin-security` test: 154 files, 3321 passed, 23 skipped. Typecheck passed, including the test layer. - `@objectstack/spec` `check:generated`: all 15 artifacts up to date. - `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack`: 87 commands. `--ran`: 87 derived, 84 run (each exit 0), 3 NOT MEASURED, 0 unrun. The three are `check:dual-build-cjs-loads`, `check:i18n` and `check:type-check-debt`, each refusing with PREREQUISITE NOT MET because it needs every package's `dist/`. - `check:adr-0087-registration`: both changesets read `[BREAKING+bang+clause-②-narrowing]`, `not-required (no-migration-prescription)`. `check:changeset-no-major`: no major bump. - `eslint --no-inline-config --format json` on the 4 touched TypeScript files: 4 linted, 0 errors, 0 warnings. All 4 are in the config's `**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}` population. The config enables no type-aware linting (its own text states this), so this diff cannot move the verdict on any untouched file. The repo-wide `pnpm lint` is left to CI. ## Acceptance notes - **NOT MEASURED: a real-boot reading of the record and explain doors for this class.** The record doors read the middleware's step 4, which the pins drive with the real plugin, and PR #21051's dogfood pin exercises this class on two real doors. A real boot here would rebuild every package downstream of the spec docblock change. - **NOT MEASURED: the three prerequisite gates named above.** CI builds the whole tree and runs them on this PR. - **The public-form read-back reads the same stand-in.** That read-back landed in PR #21101, so a capability-gated field is now also left out of the record echoed to a submitter who resolves no set. This follows from the one call; it is not pinned at that door, which is outside this card's surface. - **One corner is unchanged, and outside this surface.** Asked with no payload, `canWriteObject` still admits a caller of this class on an unreadable posture, while the middleware refuses it (since PR #21051). That is this caller's object admission. The preview probe always hands a payload, so no door asks it that way. Carrier: the `domain:services` split of #21061's direction 2, which reworks this caller's admission. - **Consumer suites ran at `65d1da2b8`, before the merge of `main`.** The merge touched none of this PR's lines. The plugin-security suite, `check:generated` and the gate union were re-run at the merged head. - **Untouched by design:** object admission and row scope for this class, the REST and analytics doors, and the public-form doors are #21061's and #21062's. --- _Generated by [Claude Code](https://claude.ai/code/session_017VaLJnYwhPsanVCe9dMCJU)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent c6954d6 commit 665cab3

6 files changed

Lines changed: 572 additions & 100 deletions

File tree

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
---
2+
'@objectstack/spec': minor
3+
---
4+
5+
docs(spec)!: the security service contract's field answers for a caller who resolves no permission set exclude the fields that declare `requiredPermissions` (#21063)
6+
7+
Clause-②: yes (narrowing)
8+
9+
<!-- adr-0087: not-required (no-migration-prescription) Only the prose of the ISecurityService interface moves: no method, parameter, return type, schema key or stored shape changes, so objectstack migrate meta has nothing to rewrite. The answers it now states are the ones every field-level requiredPermissions declaration already implied. -->
10+
11+
**BREAKING for implementers and consumers of `ISecurityService` field answers.**
12+
13+
**What changed.** The contract in `@objectstack/spec/contracts` now states the
14+
field answers for a non-system caller who resolves no permission set. Such a
15+
caller holds no permission-set field grant and no capability. No grant narrows
16+
its answers, and a field's own declarations still apply: a field that declares
17+
`requiredPermissions` is not in its `getReadableFields` answer (unless a
18+
`maskingRule` on the field serves it masked, which keeps it as a served
19+
column), and it is not in its `getWritableFields` answer.
20+
`getMetadataReadableFields` answers the same for that caller when the
21+
deployment's fallback set resolves to nothing. The contract used to say the
22+
data-plane answer for that caller was the full field set, because the engine
23+
middleware skipped its whole field gate for it. The middleware skips only its
24+
permission-set grant gates.
25+
26+
**Who this reaches.** An implementation of `ISecurityService` must answer this
27+
way for that caller. A consumer that relied on the full field set for that
28+
caller now receives the narrower answer from the reference implementation
29+
(`@objectstack/plugin-security`).
30+
31+
**What to do.** An implementation folds each field's `requiredPermissions`
32+
into its answer for this caller exactly as it does for a caller whose
33+
permission sets lack the capability. A consumer needs no change.
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
---
2+
'@objectstack/plugin-security': minor
3+
---
4+
5+
fix(plugin-security)!: a field that declares `requiredPermissions` is not served to a caller who resolves no permission set, and that caller may not query on it or write it (#21063)
6+
7+
Clause-②: yes (narrowing)
8+
9+
<!-- adr-0087: not-required (no-migration-prescription) Nothing an author wrote is rewritten or changes meaning: every field-level requiredPermissions already declared mask on read and deny on write for a caller who does not hold all of it, and this change makes the runtime honour that declaration for the one caller class it skipped. No stored metadata, schema key or published type moves. -->
10+
11+
**BREAKING for callers who resolve no permission set.**
12+
13+
**What changed.** A field that declares `requiredPermissions` is masked on read
14+
and denied on write unless the caller holds all of them (ADR-0066 D3). A caller
15+
who carries a principal but resolves no permission set holds no capability, so
16+
the gate applies to it, but the runtime served that caller the stored value,
17+
let it filter, sort, group and aggregate on the field, and accepted a write
18+
that named it. The explain engine already reported the field hidden for that
19+
caller. Now the field is not served to it (a field that also declares a
20+
`maskingRule` is served masked, as before). A filter, sort key, group key or
21+
aggregate that names the field is refused with `403 PERMISSION_DENIED`, and so
22+
is a write payload that names it, as for any other caller who lacks the
23+
capability.
24+
25+
The published field answers agree with what is served and refused.
26+
`ISecurityService.getReadableFields`, `getQueryableFields` and
27+
`getWritableFields` no longer list such a field for this caller, and neither
28+
does `getMetadataReadableFields` when the deployment's fallback set resolves to
29+
nothing. The write preview answers such a payload as the write path does.
30+
31+
**Who this reaches.** A caller who resolves no permission set but carries a
32+
position, a named permission set or a user id. A caller with none of the three
33+
is handed through untouched, as before, and the field projections say so. A
34+
caller who resolves at least one permission set is unaffected, and so is a
35+
system context. Whether this caller may read or write the object at all is
36+
unchanged.
37+
38+
**What to do.** Nothing, unless such a caller needs the field. A field's
39+
`requiredPermissions` name the capabilities that open it, so give the caller a
40+
permission set that holds all of them, or drop the requirement from the field.

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

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -158,9 +158,13 @@ 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, as the middleware skips its write gate', async () => {
162-
const { plugin } = await boot([], { noBaseline: true });
163-
expect(await plugin.getWritableFields('invoice', WRITER_CTX)).toEqual(FIELDS);
161+
it('no permission sets resolved: the full field set minus the capability-gated field, which the write gate refuses', async () => {
162+
const { plugin, middleware } = await boot([], { noBaseline: true });
163+
// [#21063] The caller holds no capability, so `margin`'s
164+
// `requiredPermissions` refuses it on write; nothing else is refused.
165+
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);
164168
});
165169

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

0 commit comments

Comments
 (0)