You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Commit 62b90d7
Browse filesBrowse the repository at this point in the historyBrowse 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>
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.
Copy file name to clipboardExpand all lines: content/docs/ui/forms.mdx
+15-7Lines changed: 15 additions & 7 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -103,12 +103,19 @@ export default defineView({
103
103
104
104
## 2. (Optional) Create the `guest_portal` permission set
105
105
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.
112
119
113
120
{/* os:check */}
114
121
```ts
@@ -284,14 +291,15 @@ Errors:
284
291
|---|---|---|
285
292
|`400 INVALID_REQUEST`| missing / blank slug or field |
286
293
|`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 |
287
295
|`404 FORM_NOT_FOUND`| slug not registered on any `sharing.allowAnonymous: true` view |
288
296
|`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. |
289
297
|`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. |
290
298
291
299
### Auth model
292
300
293
301
- 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).
295
303
- No CSRF or auth header is needed; embed the form on any domain.
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()=>{
/** 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
365
365
}
366
366
});
367
367
368
-
it('a user only org Y holds, under no wall: every door refusesas 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()=>{
0 commit comments