Repository navigation
Commit 665cab3
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
- .changeset
- packages
- plugins/plugin-security/src
- spec/src/contracts
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
Lines changed: 7 additions & 3 deletions
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
158 | 158 | | |
159 | 159 | | |
160 | 160 | | |
161 | | - | |
162 | | - | |
163 | | - | |
| 161 | + | |
| 162 | + | |
| 163 | + | |
| 164 | + | |
| 165 | + | |
| 166 | + | |
| 167 | + | |
164 | 168 | | |
165 | 169 | | |
166 | 170 | | |
| |||
0 commit comments