Commit a9d36d5
fix(plugin-security)!: a caller who resolves no permission set is served a masked field masked and may not query on it (#21051)
Fixes #20995
Clause-②: no (narrowing)
## What this changes
A field that declares `maskingRule` is masked for every non-system
caller unless the caller holds all of the field's `requiredPermissions`
(the describe on `maskingRule` in
`packages/spec/src/data/field.zod.ts`). A caller who carries a principal
but resolves no permission set holds no capability, so the rule applies
to it. The runtime served that caller the stored value and let it
filter, sort, group and aggregate on the field. It now serves the masked
value and refuses those queries, as it already did for every other
masked caller.
**Correction to the card's source-read, measured.** The card read the
result masker as already masking for this caller. It does not: for a
caller who resolves no set, the middleware stood a posture stand-in in
for the object, and the stand-in carried no masking rule. The result
masker, the predicate guard and the field projections therefore all
agreed on "stored and queryable". The disagreement was between that one
answer and the `maskingRule` describe, and the explain engine, which
reads the real posture and reports the field partially masked.
**Reach (triage step 1), measured on real boots.** Doors that serve this
caller class with a masked field in reach exist at public entry points:
an anonymous public-form door that serves a referenced object's declared
display fields, on a deployment that registers no profile for public
forms, and every data door for a signed-in user on a deployment whose
baseline is switched off. The readings, by door and class, are in the
seat's private scratch file `issue-20995/reach-readings.txt`. Nothing
here states a request.
## One derivation (triage step 2)
- `resolveCallerPosture` is the posture every gate and both field
projections read. For a caller who resolves a set it is
`getObjectSecurityMeta`, unchanged. For a caller who resolves none it is
the stand-in with two things carried from the posture: the object's
masking rules, and whether the posture resolved. Every rule then applies
(`computePartialMaskRules` holds nothing to lift it), and an unreadable
posture fails closed for this caller as for any other.
- The masked-echo write refusal (step 2.5a) and the aggregate-input
guard (step 2.5b) are no longer gated on a resolved set. The predicate
guard (step 2.9) never was. Without 2.5a a caller now served the mask
could save it back over the stored value.
- `isPrincipalLessContext` is the one predicate for the hand-off the
middleware makes before it resolves anything (no position, no named set,
no user id). The middleware and `resolveProjectionFieldMask` both read
it, so the boundary of the class has one answer too: that context is
still served stored and the projections still answer the full set.
- The projection asks the on-behalf-of delegator only when the caller
resolved a set, as the middleware does.
- ⛔ Not carried into the stand-in: the per-field capability fold and the
object's capability contract. `getReadableFields` still answers the full
set for this caller (a masked field is a served column), which is what
the service contract in
`packages/spec/src/contracts/security-service.ts` states. No spec file
is touched.
## Per door, by class
| Door, by class | Caller class | Before | After |
|:--|:--|:--|:--|
| Anonymous public-form door serving a referenced object's declared
display fields | visitor with no session, on a deployment with no
public-form profile (resolves no set) | masked field served stored; a
search or sort on it answered | masked field served masked; a search or
sort on it refused, 403 `PERMISSION_DENIED` |
| Record, list and query doors | signed-in user on a deployment with the
baseline switched off, holding no grant (resolves no set) | stored; a
filter on it answered | masked; filter, sort, group and aggregate
refused, 403 |
| Doors that compile their own query and read `getQueryableFields` | a
caller who resolves no set | the field queryable, its value served
stored as a group key | the field not queryable, refused 403 |
| Engine middleware, any operation | a context that names only sets
resolving to nothing, or holds only an audience anchor no set is named
after | stored and queryable | masked and not queryable |
| Engine middleware | principal-less context (internal) | stored and
queryable | unchanged |
| Engine middleware | a caller who resolves at least one set | unchanged
| unchanged |
`ISecurityService.getQueryableFields` for this caller drops the masked
fields. `getReadableFields` and `getWritableFields` answer as before.
## Pins, red then green
- `packages/plugins/plugin-security/src/zero-set-masking.test.ts` (new).
Three caller classes, each by what resolution answers for it: a user id
on a deployment with no baseline, no user id with only unresolvable
named sets, and no user id with only an audience anchor. Each case first
asserts the premise (zero sets). Then the query projection equals the
middleware's query guards field for field across four positions and
excludes the masked fields; result masking serves them masked and the
read projection keeps them; and a masked-echo write is refused with 400
`VALIDATION_ERROR`. A last block pins the boundary: a principal-less
context is handed through untouched and the projections agree.
- `packages/plugins/plugin-security/src/get-queryable-fields.test.ts`:
the one case that pinned the full field set for this caller now expects
the masked fields excluded.
- `packages/qa/dogfood/test/zero-set-masking.dogfood.test.ts` (new). A
real boot, two doors: the anonymous public-form door and the record door
for a signed-in user with no baseline. Each asserts the scene is real (a
system read holds the stored value), the masked field is served masked
beside a field served as stored, and on the record door a predicate on
it is refused.
- Red on the pins commit `4b7105c437` (tree equal to HEAD for the plugin
source): 10 failed, 14 passed. These are the 9 zero-set cases and the
updated `getQueryableFields` case. Premises and boundary green.
- Green on the fix commit `21a8de2f92`: the two files 24 of 24. With the
masking, readable and writable suites beside them, 76 of 76.
## Ablation
The fix reverted by writing the pre-fix blob of `security-plugin.ts`
(from `4b7105c437`) into the tree at `075fa174e6`. Landing proven by
blob hash (`58bf1990…`, the pre-fix blob) and by the marker
`resolveCallerPosture` counting 8, then 0. Then:
- unit pins: 10 failed, 14 passed, the same ten as the red run;
- `plugin-security` rebuilt, and `ablation-dist-preflight --absent`
passed: the marker is in none of the 6 built files;
- dogfood pin: 2 of 2 failed, each on the stored value served where the
mask was expected.
Restored with `git checkout HEAD --` on the absolute path inside an
`EXIT INT TERM` trap. Restore proven byte-identical: the blob hash
equals HEAD's (`2a1f47aa…`), `git diff HEAD` is empty and the status is
clean. Then rebuilt, and the preflight found the marker in 4 built files
with the tree clean. Unit pins 24 of 24 and the dogfood pin 2 of 2.
## Consumers (Z3), suites run, not edited
- `@objectstack/plugin-approvals` test: 52 files, 804 passed. Its
snapshot redaction reads the read projection intersected with the query
one, so a masked field is now dropped from the snapshot for an approver
who resolves no set, where it was served stored.
- `@objectstack/service-analytics` test: 150 files, 3458 passed. Its
field gate reads `getQueryableFields`, so for this caller a masked field
is now refused as a group key, aggregate input or filter.
## Local verification, at `075fa174e6`
- `@objectstack/plugin-security` test: 151 files, 3276 passed, 23
skipped. Typecheck passed, including the test layer
(`check:test-typecheck` OK).
- `@objectstack/dogfood` typecheck: passed.
- `node scripts/pm/dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack`: 67 commands, all run in one locked
sequential script, every exit 0. `--ran` reconciliation: 67 derived, 67
run, 0 not measured.
- `eslint --no-inline-config --format json` on the 4 touched TypeScript
files: 4 files linted, 0 errors, 0 warnings. The population is
`eslint.config.mjs`'s `**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}` minus its
never-linted build directories, and all 4 files are in it. The config
enables no type-aware linting (its own header states this), so this diff
cannot move the verdict on any untouched file. The repo-wide `pnpm lint`
is left to CI.
- `check:adr-0087-registration`: the changeset reads
`[BREAKING+bang+clause-②-narrowing]`, `not-required
(no-migration-prescription)`.
## Acceptance notes
- **Not measured: an approval inbox read on a real boot by an approver
who resolves no set.** The consumer suites cover that seam, and the
field answers it reads are pinned here.
- **Not measured: CI's whole-root and workflow-valued families.**
`dispatch-gates` names 11 whole-root families and 6 workflow-valued ones
as not placeable locally. They run on this PR.
- **The gate scripts were read on the base tree.** After this branch was
cut, `origin/main` moved by 7 commits. None of them touches this PR's
files, so nothing was merged. Two of those commits changed gate scripts
(`scripts/check-test-typecheck.mts`,
`scripts/cross-package-test-inputs.mjs`), and CI runs the new copies.
- **The narrowing reaches one authoring shape.** A public-form lookup
whose first declared display field carries a masking rule now answers
403 for every request from a caller who resolves no set. That door
sorts, and searches, by its first display field. The changeset states
the class. Whether authoring should refuse that shape is a separate
question, reported to the seat.
- **Contract docblock, for the spec owner.**
`getMetadataReadableFields`' docblock in
`packages/spec/src/contracts/security-service.ts` says the middleware
"skips its whole field gate" for a caller with no permission sets. That
now holds for the grant-based gates and not for masking. The answers it
states are unchanged: the full read set on the data plane. This PR
leaves that file alone, by the claim's surface. The plugin's own
docblock is updated.
- **Unchanged by design, and outside this surface:** the per-field
capability fold for this caller, admission and row scope for this
caller, and the public-form submit path, which never resolves sets.
Further findings in the same caller class went to the seat privately, by
class, under the disclosure discipline.
---
_Generated by [Claude
Code](https://claude.ai/code/session_01XY5uCwTjZj7884yYtyur4H)_
---------
Co-authored-by: Claude <noreply@anthropic.com>1 parent 94990a2 commit a9d36d5
5 files changed
Lines changed: 542 additions & 36 deletions
File tree
- .changeset
- packages
- plugins/plugin-security/src
- qa/dogfood/test
| 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 | + | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
Lines changed: 4 additions & 2 deletions
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
178 | 178 | | |
179 | 179 | | |
180 | 180 | | |
181 | | - | |
| 181 | + | |
182 | 182 | | |
183 | | - | |
| 183 | + | |
| 184 | + | |
| 185 | + | |
184 | 186 | | |
185 | 187 | | |
186 | 188 | | |
| |||
Lines changed: 114 additions & 34 deletions
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
327 | 327 | | |
328 | 328 | | |
329 | 329 | | |
| 330 | + | |
| 331 | + | |
| 332 | + | |
| 333 | + | |
| 334 | + | |
| 335 | + | |
| 336 | + | |
| 337 | + | |
| 338 | + | |
| 339 | + | |
| 340 | + | |
| 341 | + | |
| 342 | + | |
| 343 | + | |
| 344 | + | |
| 345 | + | |
| 346 | + | |
| 347 | + | |
330 | 348 | | |
331 | 349 | | |
332 | 350 | | |
| |||
2139 | 2157 | | |
2140 | 2158 | | |
2141 | 2159 | | |
2142 | | - | |
2143 | | - | |
2144 | | - | |
2145 | | - | |
2146 | | - | |
2147 | | - | |
| 2160 | + | |
| 2161 | + | |
| 2162 | + | |
2148 | 2163 | | |
2149 | 2164 | | |
2150 | 2165 | | |
| |||
2219 | 2234 | | |
2220 | 2235 | | |
2221 | 2236 | | |
2222 | | - | |
2223 | | - | |
2224 | | - | |
2225 | | - | |
2226 | | - | |
2227 | | - | |
2228 | | - | |
2229 | | - | |
2230 | | - | |
| 2237 | + | |
| 2238 | + | |
| 2239 | + | |
| 2240 | + | |
2231 | 2241 | | |
2232 | 2242 | | |
2233 | 2243 | | |
| |||
2244 | 2254 | | |
2245 | 2255 | | |
2246 | 2256 | | |
2247 | | - | |
2248 | | - | |
| 2257 | + | |
| 2258 | + | |
| 2259 | + | |
| 2260 | + | |
2249 | 2261 | | |
2250 | 2262 | | |
2251 | 2263 | | |
| |||
2858 | 2870 | | |
2859 | 2871 | | |
2860 | 2872 | | |
2861 | | - | |
| 2873 | + | |
| 2874 | + | |
| 2875 | + | |
2862 | 2876 | | |
2863 | 2877 | | |
2864 | | - | |
2865 | | - | |
| 2878 | + | |
2866 | 2879 | | |
2867 | 2880 | | |
2868 | 2881 | | |
| |||
2885 | 2898 | | |
2886 | 2899 | | |
2887 | 2900 | | |
2888 | | - | |
| 2901 | + | |
| 2902 | + | |
| 2903 | + | |
2889 | 2904 | | |
2890 | 2905 | | |
2891 | 2906 | | |
| |||
5420 | 5435 | | |
5421 | 5436 | | |
5422 | 5437 | | |
5423 | | - | |
| 5438 | + | |
5424 | 5439 | | |
5425 | 5440 | | |
5426 | | - | |
| 5441 | + | |
| 5442 | + | |
| 5443 | + | |
5427 | 5444 | | |
5428 | 5445 | | |
5429 | 5446 | | |
| |||
5495 | 5512 | | |
5496 | 5513 | | |
5497 | 5514 | | |
5498 | | - | |
5499 | | - | |
5500 | | - | |
| 5515 | + | |
| 5516 | + | |
| 5517 | + | |
| 5518 | + | |
| 5519 | + | |
| 5520 | + | |
5501 | 5521 | | |
5502 | 5522 | | |
5503 | 5523 | | |
| |||
5508 | 5528 | | |
5509 | 5529 | | |
5510 | 5530 | | |
5511 | | - | |
| 5531 | + | |
| 5532 | + | |
5512 | 5533 | | |
5513 | 5534 | | |
5514 | 5535 | | |
| |||
5548 | 5569 | | |
5549 | 5570 | | |
5550 | 5571 | | |
5551 | | - | |
5552 | | - | |
5553 | | - | |
| 5572 | + | |
| 5573 | + | |
| 5574 | + | |
| 5575 | + | |
| 5576 | + | |
| 5577 | + | |
| 5578 | + | |
| 5579 | + | |
5554 | 5580 | | |
5555 | | - | |
| 5581 | + | |
5556 | 5582 | | |
5557 | 5583 | | |
5558 | 5584 | | |
5559 | | - | |
| 5585 | + | |
| 5586 | + | |
5560 | 5587 | | |
5561 | 5588 | | |
5562 | 5589 | | |
5563 | 5590 | | |
5564 | 5591 | | |
5565 | 5592 | | |
5566 | 5593 | | |
5567 | | - | |
| 5594 | + | |
| 5595 | + | |
| 5596 | + | |
| 5597 | + | |
5568 | 5598 | | |
5569 | 5599 | | |
5570 | | - | |
| 5600 | + | |
5571 | 5601 | | |
5572 | 5602 | | |
5573 | 5603 | | |
| |||
5591 | 5621 | | |
5592 | 5622 | | |
5593 | 5623 | | |
| 5624 | + | |
| 5625 | + | |
| 5626 | + | |
| 5627 | + | |
| 5628 | + | |
| 5629 | + | |
| 5630 | + | |
| 5631 | + | |
| 5632 | + | |
| 5633 | + | |
| 5634 | + | |
| 5635 | + | |
| 5636 | + | |
| 5637 | + | |
| 5638 | + | |
| 5639 | + | |
| 5640 | + | |
| 5641 | + | |
| 5642 | + | |
| 5643 | + | |
| 5644 | + | |
| 5645 | + | |
| 5646 | + | |
| 5647 | + | |
| 5648 | + | |
| 5649 | + | |
| 5650 | + | |
| 5651 | + | |
| 5652 | + | |
| 5653 | + | |
| 5654 | + | |
| 5655 | + | |
| 5656 | + | |
| 5657 | + | |
| 5658 | + | |
| 5659 | + | |
| 5660 | + | |
| 5661 | + | |
| 5662 | + | |
| 5663 | + | |
| 5664 | + | |
| 5665 | + | |
| 5666 | + | |
| 5667 | + | |
| 5668 | + | |
| 5669 | + | |
| 5670 | + | |
| 5671 | + | |
| 5672 | + | |
| 5673 | + | |
5594 | 5674 | | |
5595 | 5675 | | |
5596 | 5676 | | |
| |||
0 commit comments