Skip to content

Commit 9975c26

Browse files
docs(rest,app-crm): the public form submit is authorized by its declaration, not by a guest_portal set (#24237)
Fixes #24130 Clause-②: no ## What changes Comments, one published docstring, one docs paragraph, and a `patch` changeset. No behaviour of the form routes changes. - `packages/rest/src/rest-server.ts` — the JSDoc above `registerFormEndpoints`, and nothing else in the file. It said security is delegated to the `guest_portal` set, that a deployment without one "falls open", and that secure-by-default deployments must pair the routes with one. It now states the mechanism the runtime runs: the declaration-derived `publicFormGrant` is admitted before any permission set resolves; it says what the grant admits, refuses and strips, and what the `guest_portal` name on the context reaches. - `packages/rest/src/form-routes.ts` — three comments: - the route header ("with the `guest_portal` permission set attached"); - the stale `lead.hook.ts` / `case.hook.ts` pointer; - the comment above the submit context ("`guest_portal` + `anonymous` are kept for back-compat with object hooks"). - `examples/app-crm/src/views/lead.view.ts` — the `web_to_lead` comment no longer says the `guest_portal` profile authorizes the write. - `examples/app-crm/src/security/sales-positions.ts` — `GuestPortalProfile` is **kept**, and its comment now says why (H3 below). - `content/docs/ui/public-data-collection.mdx` — one paragraph in section 2 saying what the `guest_portal` name on the submit context does and does not do. - `.changeset/24130-form-submit-docstring.md` — `@objectstack/rest`: `patch`. The docstring ships; measured below. ## Measured Tree: `c564b0e45`, the merge of `origin/main` `2bcd406a2` into this branch. ### H1 — what authorizes the anonymous INSERT, and what `permissions: ['guest_portal']` does I read `packages/plugins/plugin-security/src/security-plugin.ts` and did not change it. - **Admission.** The middleware's `publicFormGrant` branch admits `insert` / `find` / `findOne` / `count` on the grant's object and throws `PermissionDeniedError` for any other operation or object. It decides this before any permission set resolves, and it strips `PUBLIC_FORM_SERVER_MANAGED_FIELDS` from inserted rows. - **The `guest_portal` name.** The branch then calls `resolvePermissionSetsForContext(opCtx.context)`, which requests the context's `permissions` names by name. The answer is used only as input to `resolveCallerPosture` and `maskOperationResult` over the operation's result. - **What a registered set contributes.** The masker reads the set's `fields` (`getFieldPermissions`) and its system permissions (`getSystemPermissions`, held against a field's `requiredPermissions`). The set's `objects` bits are never read on this branch. - **What the visitor sees.** The route answers `{ id }` only. Live check: a real boot of `examples/app-crm`, through `bootStack` from `@objectstack/verify`, in a throwaway dogfood probe that is not committed. It ran with and without `GuestPortalProfile` in the stack's `permissions`: | probe | with `GuestPortalProfile` | without | |---|---|---| | permission sets the stack declares | `crm_sales_user`, `guest_portal` | `crm_sales_user` | | clean `POST /forms/contact-us/submit` | `201`, body `{ id }` | `201`, body `{ id }` | | `POST` that also sends `status: 'qualified'`, `lead_score: 99`, and forged `owner_id` / `organization_id` | `201`, body `{ id }` | `201`, body `{ id }` | | both stored rows, read as system | `status: new`, `lead_score: 0`, `source: web`, `owner_id: null`, forged organization not stored | same | | anonymous `GET /data/crm_lead` | `401` | `401` | | anonymous `GET /data/crm_lead/ID` | `401` | `401` | | a `beforeInsert` hook on `crm_lead`, `ctx.session` / `ctx.user` | `null` / `null` | `null` / `null` | The probe asserted the two shapes' answers equal and passed (`Tests 1 passed`). This matches the card's measurement on 17.7.0. A second throwaway probe ran at the engine level (`packages/objectql`, not committed): - **Route context.** With the route's context verbatim, a `beforeInsert` hook's `ctx.session` and `ctx.user` are both unset. - **Positive control.** A context with `userId: 'u_ctl'`, `positions: ['p_ctl']`, `permissions: ['guest_portal']`, `anonymous: true` reaches the hook as `session: { userId, positions }` and `user: { id }`. Neither `permissions` nor `anonymous` gets through, because `buildSession` / `buildUser` in `packages/objectql/src/engine.ts` copy neither key. So "kept for back-compat with object hooks" was false: no hook can read either key. ### H2 — the hook-file pointer `examples/app-crm/src/hooks/` holds `index.ts` and `opportunity.hook.ts` only. Neither `lead.hook.ts` nor `case.hook.ts` exists. The comment now names the three patterns that do exist: - a field's `defaultValue` (`crm_lead.status`); - on a select that declares no `defaultValue`, its `default: true` option. `packages/objectql/src/engine.ts` (about `:7086`): "A field that declares NO `defaultValue` falls back to the option marked `default: true`." This is how `source` lands as `web` in the table above; - a `beforeInsert` hook where the value needs logic: `StampInquiryDefaultsHook` in `examples/app-showcase/src/data/hooks/index.ts`, the hook on the showcase's public-form target. ### H3 — `GuestPortalProfile`: kept, and the example says why The dispatch preferred dropping the profile if nothing reads it. Two things read it: - The grant's masker resolves the set by name on every form submit (H1). - ADR-0138 (Accepted) records this exact declaration in its fate table: "the form doors' `guest_portal` set name | `packages/rest/src/rest-server.ts#RestServer.registerFormEndpoints`; the reference declaration is `examples/app-crm/src/security/sales-positions.ts#GuestPortalProfile` | live: the form doors' context names that set, and the result masker resolves it where a deployment registers one … | **keep**". Dropping the profile would leave an accepted record citing a deleted symbol, and correcting the ADR is a Tier H edit outside this card's surface. So the example keeps it. Its comment now says: - what it does not do: authorize the form; - what the name reaches: the masker's field permissions; - that, as declared here (object bits only), it changes nothing on the form path. The table above shows this. `security/index.ts`, `objectstack.config.ts` and `access-matrix.json` are unchanged. ### Changeset — `patch`, because the docstring ships `registerFormEndpoints` is private, but its docstring is in the published files. After the build: - the new phrase "admits on that grant alone" appears once each in `packages/rest/dist/index.d.ts`, `index.d.cts`, `index.js` and `index.cjs`; - the old phrase "falls open with" appears zero times; - control: a pre-existing `form-routes.ts` body comment (`Symmetric with #6601`) is in `src` once and in `dist` zero times, and so is the new `form-routes.ts` text. So a reader of the published `@objectstack/rest` sees the corrected docstring, and that takes a `patch`, not `skip-changeset`. `examples/app-crm` is `private: true`. ## Tests and gates (head `c564b0e45`) - **① Build.** `pnpm turbo run build --filter=@objectstack/dogfood --filter=@objectstack/example-crm --concurrency=2` covers rest's dependency closure plus rest, plugin-security and verify. Result: `Tasks: 66 successful, 66 total`, lock `VERDICT command-exit 0`. - The CRM's `objectstack build` ran in the same run: `✓ Build complete`, `Security: 3 Positions 2 Permissions`, with no access-matrix drift. - **② Package tests.** - `@objectstack/rest` `vitest run --project local`: `Test Files 294 passed (294)`, `Tests 6028 passed | 326 skipped (6354)`, exit 0. - `@objectstack/rest` `typecheck` (`tsc --noEmit && check:test-typecheck`): exit 0. - `@objectstack/example-crm` `vitest run`: `Test Files 6 passed (6)`, `Tests 46 passed (46)`, exit 0. - `@objectstack/example-crm` `typecheck`: exit 0. - **③ Derived gates.** `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 93 commands. All 93 exit 0. - On the first pass, two exited 3 (`PREREQUISITE NOT MET`, unbuilt packages outside this closure): `check:skill-examples` and `check:dual-build-cjs-loads`. I built the eight packages they named, re-ran both, and both exited 0. - `--ran`: "Run reconciliation — 93 derived, 93 run, 0 NOT-MEASURED, 0 UNRUN." - **Lint on the changed files.** `pnpm exec eslint --no-inline-config` over the four changed `.ts` files: exit 0, no output. - **Not run locally.** `test:repo` and the repo-wide `pnpm lint` are CI's. ## Patch round 1 The seat's review of `c564b0e45` folded two adjacent sites into this PR. The claim's file surface was revised in place: comment 6243651386, "Revised 2026-10-18T01:09Z". Head: `91ca7f3b7`. `main` moved 5 commits since `2bcd406a2`, none on a file this PR touches, so it was not merged in. Each round-0 sentence above still describes `c564b0e45`, and nothing in it changed. - **`content/docs/ui/forms.mdx`, the `guest_portal` passages only.** - The ADR-0056 callout no longer says the set and the `anonymous` flag are attached "for back-compat" with object hooks. It now says neither key authorizes anything or reaches an object hook. - Section 2, "(Optional) Create the `guest_portal` permission set", is **kept, retitled and rewritten**: "No permission set to create — what the `guest_portal` name does". It now says what is true: - admission is the declaration-derived `publicFormGrant`, decided before any set resolves; - a registered set's object bits are not read on this path; - only its field permissions, and the capabilities it holds against a field's `requiredPermissions`, feed the result masker over the record the route gets back, and the visitor gets the `id` only; - so a deployment registers one only to mask fields on that record, and nothing else needs it; - no hook can read `permissions` or `anonymous`, so a public submission is told apart by the missing `ctx.user`. - Section 2's `os:check` snippet, which declared an INSERT-only set, is removed: its whole content was object bits that nothing reads. The section stays because a reader who sees `permissions: ['guest_portal']` in the context shape needs to know what that name does. - The auth-model bullet no longer says `permissions: ['guest_portal']` "is retained for back-compat". - **`examples/app-crm/src/objects/lead.object.ts`, the `status` comment only.** It no longer says an option default is "only a UI preselect". It now says the declared `defaultValue: 'new'` is what the write uses, and that this takes precedence over the `default: true` option, which the engine falls back to on write only for a select that declares no `defaultValue`. Evidence: - The two probes in H1 above. On the CRM with and without `GuestPortalProfile`, the answers were identical, and the `beforeInsert` hook saw `ctx.session` / `ctx.user` unset. On the engine, the positive control showed that `permissions` and `anonymous` never reach a hook. The CRM probe also stored `source: web` for a select that declares no `defaultValue`. - `packages/objectql/src/engine.ts`, about `:7086`: "A field that declares NO `defaultValue` falls back to the option marked `default: true` ({@link resolveOptionDefault}, #7246)". The `applyFieldDefaults` branch reaches `resolveOptionDefault(f)` only when `f.defaultValue == null`, so a declared `defaultValue` wins. Tests and gates on `91ca7f3b7`, every exit captured before any pipe: - **Build.** `pnpm turbo run build` over the round-0 closure plus the eight packages the two dist-reading gates need: `Tasks: 74 successful, 74 total`, lock `VERDICT command-exit 0`. The CRM's `objectstack build` re-ran on the new `lead.object.ts`: `✓ Build complete`, `Security: 3 Positions 2 Permissions`. - **CRM tests and typecheck.** `@objectstack/example-crm` `vitest run`: `Test Files 6 passed (6)`, `Tests 46 passed (46)`, exit 0. `typecheck`: exit 0. - **Lint.** `pnpm exec eslint --no-inline-config examples/app-crm/src/objects/lead.object.ts`: exit 0, no output. - **Derived gates.** `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` gave the same 93 commands as round 0, and all 93 exit 0 on the first pass. `check:skill-examples` reads "262 prose examples type-check" (263 before: the removed snippet); `check:doc-anchors` reads "536 internal #fragment link(s) … all resolve". `--ran`: "Run reconciliation — 93 derived, 93 run, 0 NOT-MEASURED, 0 UNRUN". - **Stale-tree note.** The derivation warned that the tree is 5 commits behind `origin/main` and that one derived gate, `scripts/check-position-name-fold-loaders.mjs`, changed there (#24236). I also ran `main`'s copy of that script at its own path against this tree, restored by `git checkout HEAD` and proved by blob hash (`bd90f10a6`, the HEAD blob): scan exit 0, `--self-test` exit 0 (39 cases). ## Patch round 2 **Patch round 2** fixes the `403 PERMISSION_DENIED` row of `content/docs/ui/forms.mdx`'s submit error table, and nothing else. The claim was revised for it (comment 6243651386, "Revised 2026-10-18T01:33Z … patch round 2"). Head: `860ca38a9`. `main` did not move on `forms.mdx`, so it was not merged. The row said "the resolved profile does not allow create on the target object", which contradicted section 2 on the same page. It now names the conditions the submit answers `403` for, each measured by a throwaway dogfood probe (deleted, never committed) through the real `POST /api/v1/forms/:slug/submit` on one `bootStack` boot: | case | answer | |---|---| | baseline submit | `201` | | permission-set resolution throws (fault injected on the boot's own `SecurityPlugin` instance) | `403 PERMISSION_DENIED` "[Security] Access denied: permission subsystem unavailable for operation 'insert' …"; after restore `201` | | the object's security posture unresolved (fault injected) | `403 PERMISSION_DENIED` "… the security posture of object '…' could not be resolved …"; after restore `201` | | a code-registered `beforeInsert` hook throws `PermissionDeniedError` | `403 PERMISSION_DENIED`, the hook's message | | a code-registered hook throws a plain `Error` (control) | `500 INTERNAL_ERROR` | | an `afterInsert` hook writes a different object through `ctx.api`, unelevated | `403 PERMISSION_DENIED` "[Security] Access denied: public-form grant permits only create/read-back on 'pfx_cross', not 'insert' on 'pfx_other'" | | a declarative (sandboxed) `beforeInsert` hook throws | `400`, the hook's message | The first two arms are the grant branch's fail-closed throws in `security-plugin.ts`; the cross-object arm is its refusal of any operation outside the grant, and it shows that a hook's nested write carries the submission's context. A permission set's object bits appear in none of them. Gates on `860ca38a9`, every exit captured before any pipe: `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` gave the same 93 commands as round 1, and all 93 exit 0. `--ran`: "Run reconciliation — 93 derived, 93 run, 0 NOT-MEASURED, 0 UNRUN". `check:doc-anchors` and `check:doc-authoring` are green. The same stale-tree note as round 1 applies, and `main`'s copy of `scripts/check-position-name-fold-loaders.mjs`, run again at its own path with a trap restore proved by blob hash (`bd90f10a6`), exits 0 on scan and on `--self-test`. The build for the probe was `Tasks: 74 successful, 74 total` (all cached), lock `VERDICT` exit 0. ## Acceptance notes None of these sites is changed here. Each needs a carrier. The `forms.mdx` and `lead.object.ts` items that were listed here in round 0 are fixed in patch round 1 above, and the `forms.mdx` `403` row listed here in round 1 is fixed in patch round 2. - **`skills/objectstack-api/SKILL.md` about `:87`** describes the submit context as `{ permissions: ['guest_portal'], anonymous: true }` and omits `publicFormGrant`. This is a governed surface (Tier H); the seat files it as a card to its lane. - **`anonymous: true` on the submit context has no reader.** There are zero non-test property reads of `anonymous` in `packages/` (pattern `(\.|\?\.)anonymous\b|\[['"]anonymous['"]\]`), against 1 for the control key `publicFormGrant` with the same pattern, at `5a9a80bd2`. The card changes no behaviour, so the key stays. - **ADR-0096's census row** for `rest-server.ts#enforceAuth` reads "fall open when `guest_portal` unregistered (partly mitigated by the `publicFormGrant`)". It is an accepted record's census, a governed surface, and left as is. --- _Generated by [Claude Code](https://claude.ai/code/session_01QyoWKK9RwUNxBxmevy9Neb)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent acacab0 commit 9975c26

8 files changed

Lines changed: 114 additions & 60 deletions

File tree

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
'@objectstack/rest': patch
3+
---
4+
5+
docs(rest): the public form routes' docstring says what authorizes an anonymous submit — the form's own declaration (`publicFormGrant`), not a `guest_portal` permission set — and that nothing falls open without one (#24130)
6+
7+
Clause-②: no
8+
9+
The docstring of `RestServer#registerFormEndpoints`, which ships in `@objectstack/rest`'s published declarations, said the submit route delegates security to a `guest_portal` permission set on its execution context, that a deployment without that set "falls open", and that secure-by-default deployments must pair the form routes with one. None of that is what runs. The submit hands the engine a `publicFormGrant` scoped to the form's target object, and the security plugin admits on that grant alone, before any permission set resolves: the create and the read-back on that object only, every other operation and object refused, the server-managed anchors stripped from the payload. The grant never falls open and admits the same operations whether or not a `guest_portal` set is registered. The docstring now says this, and says what the `guest_portal` name on the context does reach: where a deployment registers a set under it, that set is what the grant's result masker reads over the record the grant hands back, and the submit answers the created id only.
10+
11+
Text only; nothing the form routes do changes.

‎content/docs/ui/forms.mdx‎

Lines changed: 27 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -47,9 +47,10 @@ Only the spec'd whitelist of form fields is accepted; everything else (status, o
4747
> exactly that object, never anything else and never the anonymous fall-open. So
4848
> public forms work under the unconditional anonymous-deny posture (v17
4949
> retired the `requireAuth: false` opt-out, #3963) with **no**
50-
> `guest_portal` permission set. The `guest_portal` permission set + `anonymous` flag
51-
> are still attached for **back-compat** (object hooks that detect a guest via a
52-
> falsy `ctx.user?.id`), but they are no longer the authorization mechanism.
50+
> `guest_portal` permission set. The submit's context also carries
51+
> `permissions: ['guest_portal']` and `anonymous: true`. Neither authorizes
52+
> anything and neither reaches an object hook; section 2 says the one thing the
53+
> `guest_portal` name does.
5354
5455
## 1. Declare the form view
5556

@@ -102,32 +103,27 @@ export default defineView({
102103
> - Multiple form views per object are fine — only the one(s) with `sharing.enabled === true` and `sharing.allowAnonymous === true` are exposed.
103104
> - On a **walled** tenancy posture (`group` or `isolated` in force), a form whose object is walled by an organization column is **not offered**. An anonymous submission carries no organization, and an insert without one into a walled object is refused, so both anonymous endpoints answer the form exactly as they answer a withdrawn one (`404 FORM_NOT_FOUND`). The administrator is told why, at the form's `sharing`: the view's read (`GET /api/v1/meta/view/:name`) carries it in `_diagnostics.warnings`, and a save or publish of the view answers success with a `public-form-intake-unavailable` warning in `advisories`. If the object's rows belong to no organization, declare `tenancy: { enabled: false }` on it and the form is offered again.
104105
105-
## 2. (Optional) Create the `guest_portal` permission set
106-
107-
The submit's authorization no longer depends on this set — the
108-
declaration-derived `publicFormGrant` (see the note above) is what permits the
109-
insert. For the submit you only need a `guest_portal` permission set if you rely
110-
on the legacy back-compat path (e.g. an older runtime, or object hooks that
111-
branch on the `guest_portal` permission). When present the set is attached to
112-
the anonymous context, so keep it INSERT-only on the form's target object — the
113-
guest-safe shape (ADR-0090 D9).
114-
115-
{/* os:check */}
116-
```ts
117-
// hotcrm/src/security/guest-portal.permission.ts
118-
import { definePermissionSet } from '@objectstack/spec/security';
119-
120-
// Guest-safe capability: INSERT-only on the intake objects. (`isProfile` no
121-
// longer exists — the Profile concept was removed by ADR-0090 D2.)
122-
export default definePermissionSet({
123-
name: 'guest_portal',
124-
label: 'Public Form Submitters',
125-
objects: {
126-
lead: { allowCreate: true }, // no read/edit/delete
127-
case: { allowCreate: true },
128-
},
129-
});
130-
```
106+
## 2. No permission set to create — what the `guest_portal` name does
107+
108+
The submit is authorized by the declaration-derived `publicFormGrant` (see the
109+
note above). The grant is decided before any permission set resolves, so the
110+
form works the same whether or not a `guest_portal` permission set exists. The
111+
submit's execution context still names `guest_portal` in `permissions`, and that
112+
name reaches one thing only:
113+
114+
- **The result masker, where a deployment registers a set of that name.** Only
115+
the set's field permissions (`fields`, such as `readable: false`) and the
116+
capabilities it holds against a field's `requiredPermissions` are read, to mask
117+
the created record the server hands back to the route. The set's object
118+
permissions (`allowCreate`, `allowRead`, …) are not read on this path, so they
119+
neither grant nor limit the insert.
120+
- **The visitor never sees that record.** The submit answers with the new
121+
record's `id` only (section 4). Register a `guest_portal` set only if you want
122+
fields masked on the record the route gets back; nothing else needs one.
123+
- **No object hook can read it.** A hook's `ctx.session` and `ctx.user` carry
124+
neither `permissions` nor `anonymous`, and on a public submission both are
125+
unset because the context carries no user. Tell a public submission apart by
126+
the missing `ctx.user` (section 3), not by a permission.
131127

132128
## 3. Stamp server-controlled defaults in a hook
133129

@@ -226,7 +222,7 @@ Errors:
226222
| `400 INVALID_REQUEST` | missing / blank slug (an empty body is coerced to `{}` and surfaces as `VALIDATION_FAILED` below, not here) |
227223
| `400 VALIDATION_ERROR` | the form's sections declare **no** fields, so it collects nothing — wire the fields and resubmit (#6920) |
228224
| `400 VALIDATION_FAILED` | object schema validators fail (`required`, `format`, `length`, …) |
229-
| `403 PERMISSION_DENIED` | the resolved profile does not allow create on the target object |
225+
| `403 PERMISSION_DENIED` | never a permission set refusing the create (section 2). It answers when the security layer fails closed: the permission sets for the submission could not be resolved ("permission subsystem unavailable"), or the target object's security posture could not be resolved. It also answers when a write made under the submission's context leaves the grant, such as an `afterInsert` hook writing a **different** object through `ctx.api` (the grant covers only the form's object), or when a hook registered in code throws a `403` refusal of its own (`PermissionDeniedError`). A declarative hook's `throw` answers `400`, not `403`. |
230226
| `404 FORM_NOT_FOUND` | slug not registered on any view whose form has `sharing.enabled: true` and `sharing.allowAnonymous: true` |
231227
| `5xx` (generic) | driver / hook threw — submit errors are mapped by `mapDataError`; there is no dedicated `FORM_SUBMIT_FAILED` code |
232228

@@ -247,7 +243,7 @@ lookup route.
247243
### Auth model
248244

249245
- Neither route calls `enforceAuth`, so both work under the always-on anonymous-deny default (there is no `requireAuth` knob to configure since v17).
250-
- 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.
246+
- The execution context the **submit** route hands to ObjectQL is `{ publicFormGrant: { object }, permissions: ['guest_portal'], anonymous: true }` with no `userId`. The Security plugin admits on `publicFormGrant` alone — a create + read-back grant scoped to exactly the declared object, decided before any permission set resolves — so no `guest_portal` set is needed. Neither `permissions` nor `anonymous` authorizes anything or reaches an object hook; where a set named `guest_portal` is registered, the result masker reads its field permissions (section 2).
251247
- No CSRF or auth header is needed; embed the form on any domain.
252248

253249
## 5. Embedding from a front-end

‎content/docs/ui/public-data-collection.mdx‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,8 @@ POST /api/v1/forms/contact-us/submit → INSERT one showcase_inquiry
4646

4747
Only the fields listed in `sections[].fields` are accepted on submit; everything else is stripped server-side. The submit route also enforces each entry's `required: true` before anything is written — a submission that leaves such a field absent, `null`, blank, or an empty list on a multi-value field is refused with `400 VALIDATION_FAILED` naming the field — except on an entry the form hides (`hidden: true`) or shows only conditionally (`visibleWhen` on the entry or its section), which nothing server-side evaluates. Server-controlled fields (`status`, `source`, owner stamps) are set by a defaults hook, never by the submitter. The submit route derives a narrow `publicFormGrant: { object: 'showcase_inquiry' }` — create + read-back of the row it just made, and nothing else.
4848

49+
The submit's execution context also names a `guest_portal` permission set. That name authorizes nothing: the grant is decided before any permission set resolves, so the form works the same whether or not a set of that name exists, and such a set's object permissions (`allowCreate`, `allowRead`, …) are not read on this path. Where a deployment registers one, only its field permissions, and the capabilities it holds against a field's `requiredPermissions`, are read, to mask the created record the server hands back to the route. The visitor never sees that record: the submit answers with the new record's `id` only.
50+
4951
System-managed anchors (`owner_id`, `organization_id`, audit columns, `id`) are **never** accepted from a public submission — not even when a section declares them, and not through the zero-sections all-fields fallback. The route filter and the data layer enforce the same set (`PUBLIC_FORM_SERVER_MANAGED_FIELDS` from `@objectstack/spec/security`), so an anonymous visitor cannot forge record ownership or tenant placement regardless of how the form is declared (#3022).
5052

5153
### 3. Keep the object private once inside

‎examples/app-crm/src/objects/lead.object.ts‎

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -41,9 +41,11 @@ export const Lead = ObjectSchema.create({
4141
status: Field.select({
4242
label: 'Status',
4343
required: true,
44-
// Write-path default so minimal creates (e.g. the public Web-to-Lead form,
45-
// which doesn't expose status) satisfy `required` — the option `default`
46-
// below is only a UI preselect.
44+
// The write-path default: a create that omits `status` (e.g. the public
45+
// Web-to-Lead form, which doesn't collect it) is stored as 'new', so it
46+
// satisfies `required`. A declared `defaultValue` takes precedence over
47+
// the option marked `default: true` below, which the engine falls back to
48+
// on write only for a select that declares no `defaultValue`.
4749
defaultValue: 'new',
4850
options: [
4951
{ label: 'New', value: 'new', default: true, color: '#94A3B8' },

‎examples/app-crm/src/security/sales-positions.ts‎

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -62,12 +62,23 @@ export const SalesUserPermissionSet = definePermissionSet({
6262
});
6363

6464
/**
65-
* Guest profile for the public Web-to-Lead form (lead.view.ts `web_to_lead`).
65+
* The `guest_portal` permission set — the name the public form doors' submit
66+
* context requests. ADR-0138 records this declaration as the reference for that
67+
* name (fate: keep), which is why the example keeps it.
6668
*
67-
* Applied to anonymous (unauthenticated) visitors who POST the public form. The
68-
* anonymous permission path checks the FULL object name, so this MUST key
69-
* `crm_lead` (not a short `lead`). INSERT-only — guests can never read, edit, or
70-
* delete any record.
69+
* It does NOT authorize the public Web-to-Lead form (lead.view.ts
70+
* `web_to_lead`). That form's own declaration does: the submit route derives a
71+
* `publicFormGrant` on `crm_lead` (create + read-back on that object only,
72+
* ADR-0056 Option A), and the SecurityPlugin admits on that grant before any
73+
* permission set resolves. The form works the same without this set.
74+
*
75+
* What the name reaches: the submit's context carries
76+
* `permissions: ['guest_portal']`, so a set registered under this name is the
77+
* one the grant's result masker reads over what the grant hands back, through
78+
* its `fields` and the capabilities it holds against a field's
79+
* `requiredPermissions`. Its `objects` bits are not read on that path. This
80+
* declaration has object bits only, so on the form path it changes nothing, and
81+
* the submit answers the created id only.
7182
*/
7283
export const GuestPortalProfile = definePermissionSet({
7384
name: 'guest_portal',

‎examples/app-crm/src/views/lead.view.ts‎

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -99,9 +99,14 @@ export const LeadViews = defineView({
9999
* Hosted at GET/POST `/api/v1/forms/contact-us` (+ `/submit`). An
100100
* unauthenticated visitor — or an `EmbeddableForm` iframe on a marketing
101101
* site — submits it to create a `crm_lead`. `sharing.allowAnonymous` opens
102-
* the public endpoint; the `guest_portal` profile (INSERT-only on crm_lead)
103-
* authorizes the write. The lead object's own defaults/hooks stamp internal
104-
* fields (status, owner, score).
102+
* the public endpoint, and this declaration is also what authorizes the
103+
* write: the submit route derives a `publicFormGrant` on `crm_lead` from it
104+
* (create + read-back on that object only, ADR-0056 Option A). No
105+
* permission set is needed, `GuestPortalProfile` included (see its
106+
* comment). Only the fields in `sections` are written; the lead object's
107+
* field defaults fill what the form does not collect (`status`, `source`,
108+
* `lead_score`), and the server-managed anchors (`owner_id`,
109+
* `organization_id`, audit columns) are never taken from a submission.
105110
*/
106111
web_to_lead: {
107112
type: 'simple',

‎packages/rest/src/form-routes.ts‎

Lines changed: 28 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -407,8 +407,8 @@ export function registerFormEndpoints(this: FormRouteHost, basePath: string): vo
407407
},
408408
});
409409

410-
// POST /forms/:slug/submit — INSERT a record on the target object
411-
// with the `guest_portal` permission set attached.
410+
// POST /forms/:slug/submit — INSERT a record on the target object,
411+
// authorized by the form's own declaration (`publicFormGrant`, below).
412412
this.routeManager.register({
413413
method: 'POST',
414414
path: `${basePath}/forms/:slug/submit`,
@@ -432,9 +432,12 @@ export function registerFormEndpoints(this: FormRouteHost, basePath: string): vo
432432
// Only allow the fields declared on the matched FormView.
433433
// This prevents a public visitor from stuffing privileged
434434
// columns (owner_id, status, internal_notes, …) into the
435-
// row. Object hooks (`beforeInsert`) are still responsible
436-
// for stamping server-side defaults — see the CRM
437-
// `lead.hook.ts` / `case.hook.ts` for the canonical pattern.
435+
// row. What the form does not collect, the object fills: a
436+
// field's `defaultValue` (`crm_lead.status` in
437+
// `examples/app-crm`) or, on a select that declares none, its
438+
// `default: true` option; or a `beforeInsert` hook where the
439+
// value needs logic (`StampInquiryDefaultsHook` in
440+
// `examples/app-showcase/src/data/hooks/index.ts`).
438441
const allowedFields = new Set<string>();
439442
// [#24131] …and, off the SAME entries, the fields the form
440443
// requires the visitor to fill: an entry declaring
@@ -522,10 +525,26 @@ export function registerFormEndpoints(this: FormRouteHost, basePath: string): vo
522525
// ADR-0056 (Option A): authorization DERIVED from the declared
523526
// form — a narrow create grant scoped to exactly this form's target
524527
// object. The SecurityPlugin honors `publicFormGrant` (create + the
525-
// immediate read-back, that object ONLY), so public forms work under
526-
// secure-by-default (anonymous-deny) WITHOUT a deployment-configured
527-
// `guest_portal`. `guest_portal` + `anonymous` are kept for back-compat
528-
// with object hooks (guest detection via falsy `ctx.user?.id`).
528+
// immediate read-back, that object ONLY) before it resolves any
529+
// permission set, so public forms work under secure-by-default
530+
// (anonymous-deny) whether or not the deployment registers a
531+
// `guest_portal` set, and no set's object bits widen or narrow
532+
// this insert.
533+
//
534+
// `permissions: ['guest_portal']` admits nothing. It names the set
535+
// the SecurityPlugin resolves for this caller: where a deployment
536+
// registers one under that name, the grant's result masker reads
537+
// that set's field permissions (and the capabilities it holds
538+
// against a field's `requiredPermissions`) over what the grant
539+
// hands back; where none is registered, it resolves to no set and
540+
// the masker reads the zero-set posture. ADR-0138 records the name
541+
// (fate: keep). The answer below is the created id only (#22437),
542+
// so the masked record never reaches the visitor through this door.
543+
//
544+
// Neither `permissions` nor `anonymous` reaches an object hook:
545+
// the engine builds `ctx.session` and `ctx.user` without either
546+
// key. With no `userId` here, `ctx.user` is unset, so a hook sees
547+
// this submission as a write with no user, like any other.
529548
const context: any = {
530549
publicFormGrant: { object: match.object },
531550
permissions: ['guest_portal'],

‎packages/rest/src/rest-server.ts‎

Lines changed: 17 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -7312,15 +7312,23 @@ export class RestServer {
73127312
* answering `{ id }` only (#22437)
73137313
*
73147314
* Both routes bypass `enforceAuth` even though anonymous-deny is on for the
7315-
* deployment (e.g. ObjectOS multi-tenant). Security is delegated to the
7316-
* `guest_portal` permission set carried on the execution context — the
7317-
* SecurityPlugin enforces INSERT-only access to the target object. If
7318-
* the deployment hasn't registered a `guest_portal` profile, the
7319-
* security middleware falls open with `permissions: []` (no userId),
7320-
* matching the existing anonymous-access semantics; deployers must
7321-
* keep secure-by-default deployments paired with a `guest_portal`
7322-
* profile (the CRM example does this) to enforce the INSERT-only
7323-
* contract.
7315+
* deployment (e.g. ObjectOS multi-tenant). The submit is authorized by the
7316+
* form's own declaration (ADR-0056 Option A): it hands the engine a
7317+
* `publicFormGrant` scoped to the form's target object, and the
7318+
* SecurityPlugin admits on that grant alone, before any permission set
7319+
* resolves. It admits the create and the read-back (`find` / `findOne` /
7320+
* `count`) on that object only, refuses every other operation and object,
7321+
* and strips the server-managed anchors (`PUBLIC_FORM_SERVER_MANAGED_FIELDS`)
7322+
* from the payload; the route's allow-list of the form's declared fields is
7323+
* the first net. No deployment configures anything for this, and the grant
7324+
* never falls open: it admits the same operations whether or not a
7325+
* `guest_portal` set is registered.
7326+
*
7327+
* The submit's context also names `guest_portal` in `permissions`. That
7328+
* name admits nothing. Where a deployment registers a set under it, the
7329+
* SecurityPlugin resolves that set as the caller's for the grant's result
7330+
* masker over what the grant hands back (ADR-0138 records the name); the
7331+
* submit answers the created id only (#22437).
73247332
*
73257333
* The matched FormView's parent ViewSchema is found by scanning
73267334
* `protocol.getMetaItems({ type: 'view' })`. For each entry we inspect

0 commit comments

Comments
 (0)