Repository navigation
Commit 9c8b65a
fix(plugin-security)!: the record an anonymous public-form submit echoes back passes the result masker (#21101)
Part of #21062
Clause-②: no (narrowing)
## What this changes
This is position 1 of #21062: the public-form submit read-back. Position
2 (the public picker's sort and search key) is a separate dispatch, so
#21062 remains open for it.
A public form's submission is authorized by the ADR-0056
declaration-derived grant (`publicFormGrant`), which admits the create
and the read-back on the form's declared object and nothing else. Its
branch of the engine middleware in
`packages/plugins/plugin-security/src/security-plugin.ts` runs before
any permission set is resolved, and it used to `return next()` before
step 4, the result masker. So the record echoed in the submit door's
`201` body carried every field whose `maskingRule` applies as stored, to
an anonymous submitter. That included a field filled from its
`defaultValue` that the form never shows. PR #21051's zero-set fix does
not reach this, because it is a different mechanism.
`maskingRule`'s describe says the field is "Masked for every non-system
caller unless …", and an anonymous submitter is a non-system caller. The
echo is now masked for that caller.
## One masker (triage's direction, H4)
- **Step 4's body moves, unchanged, into the private method
`maskOperationResult(opCtx, permissionSets, secMeta, delegatorSets)`.**
The middleware's step 4 calls it for the caller it resolved. The grant
branch calls it for the caller the grant stands in for. There is no
second masker and no masking code in the grant branch. The move is
byte-identical: the base block and the method body, compared after a
two-space dedent, differ by nothing.
- **The caller the grant stands in for is resolved as the data plane
resolves it.** The grant branch calls `resolvePermissionSetsForContext`
on the grant's context, then `resolveCallerPosture` for those sets. The
zero-set stand-in is called, not edited. The anonymous submitter acts
for nobody, so the delegator is `null`.
- **Those inputs are read before `next()` and fail closed, as the data
plane's do.** A permission-resolution failure is refused with `403
PERMISSION_DENIED`, and so is a posture that cannot be read (the data
plane's own `unresolvedPosture*` wording). Nothing is written behind
either refusal.
- **A grant context is never the principal-less hand-off.** A grant
context that carries no position, no named set and no user id has its
read-back masked too.
- **Admission is unchanged.** The grant still admits the declared object
only, the create plus the read-back operations (`insert`, `find`,
`findOne`, `count`) and nothing else. The server-managed field strip is
untouched.
## Where the read-back comes from (H2), and which skipped steps bear on
it
- **Measured on a real boot:** on the submit door, the only operation
the grant's context reaches the engine with is the `insert`. The echo is
the insert's result; this door does no `findOne` read-back. The grant's
other admitted operations (`find`, `findOne`, `count`) are issued by no
public door in the tree. They are masked by the same call now.
- **The grant branch skips every gate between it and step 4.** For this
card, step 4 is the one that matters.
- **Not load-bearing here:** the predicate guard (step 2.9). It would
matter only to a grant `find` / `findOne` / `count` carrying a
predicate, which no public door issues (see Acceptance notes).
- **Not load-bearing here:** the masked-echo write refusal (step 2.5a).
It refuses a value on a write; it does not expose anything.
- **Admission, not this card:** the CRUD, field-write, row-level, owner
and tenant gates.
## The caller the grant stands in for (H3)
| Deployment | Sets resolved for the submit route's grant context |
Posture the masker reads |
|:--|:--|:--|
| No guest set registered (the showcase's shape) | none | the zero-set
stand-in: the object's masking rules, no capability fold |
| A guest set registered under the name the route's context requests |
that guest set | the object's posture |
The grant's context carries no user id, so no baseline set applies on
either deployment.
## Per deployment, per field class (H1)
The anonymous submit door was measured on real boots: `bootStack` with
the real `SecurityPlugin`, ObjectQL, SQL driver, REST and auth layers.
It was measured at the base `dff98c1f51` and at the fix `491331ca3e`.
The readings are given by class here; the raw readings are in the seat's
private scratch.
| Field class | No guest set, before | No guest set, after | Guest set,
before | Guest set, after |
|:--|:--|:--|:--|:--|
| Masking rule, collected by the form | stored | **masked** | stored |
**masked** |
| Masking rule, filled from its default, not on the form | stored |
**masked** | stored | **masked** |
| No rule, collected by the form (control) | stored | stored | stored |
stored |
| No rule, filled from its default, not on the form | stored | stored |
stored | stored |
| Capability-gated (`requiredPermissions`), no rule, filled from its
default, not on the form | stored | stored ¹ | stored | **absent** |
The submit answered `201` in every cell. A server-managed field the
submitter supplied never landed, before or after.
¹ The zero-set stand-in carries no capability fold, so a caller with no
set is served this class as stored. That is #21063's class, not
addressed here; this PR does not edit the stand-in.
## Pins, red then green
-
**`packages/plugins/plugin-security/src/public-form-grant-masking.test.ts`**
(new, 15 cases).
- It covers both deployment shapes. Each case first asserts what
resolution answers for the grant's caller there.
- Per deployment, it pins five things:
- every masked field is echoed masked on the submit, the defaulted one
included;
- the read-back operations the grant admits serve them masked;
- the field with no rule is served as stored;
- admission is unchanged: the create runs and the server-managed fields
are stripped;
- a count passes, and another operation or another object is refused
`403 PERMISSION_DENIED` before it runs.
- A boundary block pins three more cases: a grant context with no
principal is masked too, and a permission-resolution failure or an
unreadable posture refuses before the operation runs.
-
**`packages/qa/dogfood/test/public-form-read-back-masking.dogfood.test.ts`**
(new, 2 cases). It runs a real boot of each deployment shape and drives
the anonymous submit door over HTTP. Each case asserts:
- the scene is real (a system read holds the stored values);
- both masked field classes are echoed masked;
- the field with no rule is echoed as stored;
- the create succeeds, and a supplied server-managed field never lands.
- **Red on the pins commit `503bf4bafa`** (plugin source at the base):
unit 7 failed, 8 passed. The seven are the four masking pins (two per
deployment), the principal-less pin and the two fail-closed pins. The
premises, the control and the admission pins were green. Dogfood 2 of 2
failed, each on the stored value served where the mask was expected.
- **Green on the fix commit `491331ca3e`:** unit 15 of 15, after
`@objectstack/plugin-security` was rebuilt. Dogfood 2 of 2
(`ablation-dist-preflight` found the marker in 4 built files).
`@objectstack/plugin-security` typecheck passed, the test layer
included.
## Ablation
The mutation makes the grant branch return before the masker again:
`await next(); this.maskOperationResult(…); return;` became `return
next();`. It was made through `scripts/ablation-replace.mjs` in wrap
mode, with an outer `EXIT INT TERM` trap that restores the absolute path
from `HEAD`. The anchor hit 1 → 0 and the blob moved `42d2348c…` →
`67bf5842…`.
- **Predicted:** exactly the five masker pins red (four masking pins and
the principal-less pin), and the other ten green. The fail-closed pins
stay green because the inputs are still read. Dogfood 2 of 2 red.
- **Observed:**
- `@objectstack/plugin-security` was rebuilt, and
`ablation-dist-preflight --absent` found the marker absent from all 6
built files.
- Unit: 5 failed, 10 passed. These are exactly the predicted five.
- Dogfood: 2 of 2 failed, on the stored value.
- **Restore:** `git checkout HEAD --` on the absolute path.
- The restore was proven byte-identical: the blob is `42d2348c…`, equal
to `HEAD`; `git diff HEAD` is empty; and the whole-tree status has 0
entries.
- It was then rebuilt, and the preflight found the marker present in 2
built files.
- Unit 15 of 15 and dogfood 2 of 2.
## Local verification, at the merged head `d076cc606a`
All of the following ran in one locked sequential script, after a full
workspace build (`turbo run build`, docs excluded). Every exit code was
0.
- **Unit and type checks:**
- `@objectstack/plugin-security` test: 153 files, 3301 passed, 23
skipped. This includes the new pin and the existing `publicFormGrant`
suites.
- `@objectstack/plugin-security` typecheck passed, the test layer
included (`check:test-typecheck` OK).
- `@objectstack/dogfood` typecheck passed.
- **Dogfood (`isolated` project):** the new pin, `zero-set-masking` and
`showcase-public-form`, 8 of 8. The `form-self-auth` dogfood
(`shared-showcase` project), 3 of 3. These are the neighbours that drive
the grant.
- **Neighbour route suite:** `@objectstack/rest`
`src/public-form-routes.test.ts`, 21 of 21.
- **Derived gates:** `node scripts/pm/dispatch-gates.mjs --commands
--repo objectstack-ai/objectstack` derived 67 commands. All 67 ran.
`--ran` reconciliation: 67 derived, 67 run, 0 not measured.
- `check-adr-0087-registration`: the changeset reads
`[BREAKING+bang+clause-②-narrowing]`, `not-required
(no-migration-prescription)`.
- `check-changeset-no-major`: no `major` bump.
- `check:nul-bytes`, `check:doc-authoring`,
`check:cross-package-test-inputs`, `check:test-source-alias` and
`check:engine-double-contract`: green.
- **The four roster families whose rosters sit under this diff's
directories** (`check-changeset-fixed`, `check:authz-resolver`,
`check:error-code-casing`, `check:filter-alias-parity`): green.
- **Lint:** `eslint --no-inline-config --format json` on the 3 touched
TypeScript files linted 3 files, with 0 errors and 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 3 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.
## Surface
- **The `publicFormGrant` branch** of the engine middleware: the comment
and the masker call after `next()`.
- **Step 4 of the same middleware:** its body is moved, unchanged, into
`maskOperationResult`, which sits beside `computeReadPartialMaskRules`.
The step itself is now one call.
- This edit sits beside the grant region in the same file. It is
declared here because the masker cannot be reached from the grant branch
without either a copy or this move.
- **Not edited:**
- the zero-set stand-in (`resolveCallerPosture`, and the comment at its
call site): it is called, not changed;
- `packages/rest/src/**`;
- `packages/spec/src/**`.
## Acceptance notes
- **Not measured locally: CI's whole-root and workflow-valued
families.** `dispatch-gates` names 11 whole-root families, 6
workflow-valued families, 5 path-scheduled CI jobs and 4 type-check
programs that no local command places. They run on this PR.
- **A grant `find` / `findOne` / `count` with a predicate on a masked
field is admitted.** The predicate guard (step 2.9) does not run under
the grant. This was measured at the engine only. No public door in the
tree issues those operations under the grant; the submit door issues the
`insert` alone. It is not filed, because it has no public-door reach.
- **The masked-echo write refusal (step 2.5a) does not run under the
grant.** A submitter can store a value shaped like a mask placeholder
into a masked field. That is value integrity on an anonymous create, not
exposure, and it is unchanged here.
- **On a deployment with no guest set, a capability-gated field with no
masking rule is still echoed stored.** That is the zero-set stand-in's
missing capability fold, which is #21063's class; #21063 is not
addressed here.
- **The two fail-closed refusals are new answers on failure paths
only.** On the submit door the posture is read from the live registry
the insert itself requires, so an unreadable posture is not reached
there in a healthy composition. The refusal is pinned at the unit layer.
- **`main` was merged once, before the gate run.** It had moved 7
commits after the branch was cut, and none of them touches
`plugin-security`. The pins were re-run on the merged head. `main` has
since moved 8 more commits, none touching `plugin-security` or this
diff's files. They are not merged here; the merge queue rebuilds on
them.
---
_Generated by [Claude
Code](https://claude.ai/code/session_01XY5uCwTjZj7884yYtyur4H)_
---------
Co-authored-by: Claude <noreply@anthropic.com>1 parent 8368f1c commit 9c8b65a
4 files changed
Lines changed: 537 additions & 25 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 | + | |
| 44 | + | |
Lines changed: 253 additions & 0 deletions
| 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 | + | |
| 44 | + | |
| 45 | + | |
| 46 | + | |
| 47 | + | |
| 48 | + | |
| 49 | + | |
| 50 | + | |
| 51 | + | |
| 52 | + | |
| 53 | + | |
| 54 | + | |
| 55 | + | |
| 56 | + | |
| 57 | + | |
| 58 | + | |
| 59 | + | |
| 60 | + | |
| 61 | + | |
| 62 | + | |
| 63 | + | |
| 64 | + | |
| 65 | + | |
| 66 | + | |
| 67 | + | |
| 68 | + | |
| 69 | + | |
| 70 | + | |
| 71 | + | |
| 72 | + | |
| 73 | + | |
| 74 | + | |
| 75 | + | |
| 76 | + | |
| 77 | + | |
| 78 | + | |
| 79 | + | |
| 80 | + | |
| 81 | + | |
| 82 | + | |
| 83 | + | |
| 84 | + | |
| 85 | + | |
| 86 | + | |
| 87 | + | |
| 88 | + | |
| 89 | + | |
| 90 | + | |
| 91 | + | |
| 92 | + | |
| 93 | + | |
| 94 | + | |
| 95 | + | |
| 96 | + | |
| 97 | + | |
| 98 | + | |
| 99 | + | |
| 100 | + | |
| 101 | + | |
| 102 | + | |
| 103 | + | |
| 104 | + | |
| 105 | + | |
| 106 | + | |
| 107 | + | |
| 108 | + | |
| 109 | + | |
| 110 | + | |
| 111 | + | |
| 112 | + | |
| 113 | + | |
| 114 | + | |
| 115 | + | |
| 116 | + | |
| 117 | + | |
| 118 | + | |
| 119 | + | |
| 120 | + | |
| 121 | + | |
| 122 | + | |
| 123 | + | |
| 124 | + | |
| 125 | + | |
| 126 | + | |
| 127 | + | |
| 128 | + | |
| 129 | + | |
| 130 | + | |
| 131 | + | |
| 132 | + | |
| 133 | + | |
| 134 | + | |
| 135 | + | |
| 136 | + | |
| 137 | + | |
| 138 | + | |
| 139 | + | |
| 140 | + | |
| 141 | + | |
| 142 | + | |
| 143 | + | |
| 144 | + | |
| 145 | + | |
| 146 | + | |
| 147 | + | |
| 148 | + | |
| 149 | + | |
| 150 | + | |
| 151 | + | |
| 152 | + | |
| 153 | + | |
| 154 | + | |
| 155 | + | |
| 156 | + | |
| 157 | + | |
| 158 | + | |
| 159 | + | |
| 160 | + | |
| 161 | + | |
| 162 | + | |
| 163 | + | |
| 164 | + | |
| 165 | + | |
| 166 | + | |
| 167 | + | |
| 168 | + | |
| 169 | + | |
| 170 | + | |
| 171 | + | |
| 172 | + | |
| 173 | + | |
| 174 | + | |
| 175 | + | |
| 176 | + | |
| 177 | + | |
| 178 | + | |
| 179 | + | |
| 180 | + | |
| 181 | + | |
| 182 | + | |
| 183 | + | |
| 184 | + | |
| 185 | + | |
| 186 | + | |
| 187 | + | |
| 188 | + | |
| 189 | + | |
| 190 | + | |
| 191 | + | |
| 192 | + | |
| 193 | + | |
| 194 | + | |
| 195 | + | |
| 196 | + | |
| 197 | + | |
| 198 | + | |
| 199 | + | |
| 200 | + | |
| 201 | + | |
| 202 | + | |
| 203 | + | |
| 204 | + | |
| 205 | + | |
| 206 | + | |
| 207 | + | |
| 208 | + | |
| 209 | + | |
| 210 | + | |
| 211 | + | |
| 212 | + | |
| 213 | + | |
| 214 | + | |
| 215 | + | |
| 216 | + | |
| 217 | + | |
| 218 | + | |
| 219 | + | |
| 220 | + | |
| 221 | + | |
| 222 | + | |
| 223 | + | |
| 224 | + | |
| 225 | + | |
| 226 | + | |
| 227 | + | |
| 228 | + | |
| 229 | + | |
| 230 | + | |
| 231 | + | |
| 232 | + | |
| 233 | + | |
| 234 | + | |
| 235 | + | |
| 236 | + | |
| 237 | + | |
| 238 | + | |
| 239 | + | |
| 240 | + | |
| 241 | + | |
| 242 | + | |
| 243 | + | |
| 244 | + | |
| 245 | + | |
| 246 | + | |
| 247 | + | |
| 248 | + | |
| 249 | + | |
| 250 | + | |
| 251 | + | |
| 252 | + | |
| 253 | + | |
0 commit comments