diff --git a/.changeset/24130-form-submit-docstring.md b/.changeset/24130-form-submit-docstring.md new file mode 100644 index 00000000000..0a2a125a593 --- /dev/null +++ b/.changeset/24130-form-submit-docstring.md @@ -0,0 +1,11 @@ +--- +'@objectstack/rest': patch +--- + +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) + +Clause-②: no + +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. + +Text only; nothing the form routes do changes. diff --git a/content/docs/ui/forms.mdx b/content/docs/ui/forms.mdx index a53a5589fd3..68cbc3ccd0d 100644 --- a/content/docs/ui/forms.mdx +++ b/content/docs/ui/forms.mdx @@ -47,9 +47,10 @@ Only the spec'd whitelist of form fields is accepted; everything else (status, o > exactly that object, never anything else and never the anonymous fall-open. So > public forms work under the unconditional anonymous-deny posture (v17 > retired the `requireAuth: false` opt-out, #3963) with **no** -> `guest_portal` permission set. The `guest_portal` permission set + `anonymous` flag -> are still attached for **back-compat** (object hooks that detect a guest via a -> falsy `ctx.user?.id`), but they are no longer the authorization mechanism. +> `guest_portal` permission set. The submit's context also carries +> `permissions: ['guest_portal']` and `anonymous: true`. Neither authorizes +> anything and neither reaches an object hook; section 2 says the one thing the +> `guest_portal` name does. ## 1. Declare the form view @@ -102,32 +103,27 @@ export default defineView({ > - Multiple form views per object are fine — only the one(s) with `sharing.enabled === true` and `sharing.allowAnonymous === true` are exposed. > - 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. -## 2. (Optional) Create the `guest_portal` permission set - -The submit's authorization no longer depends on this set — the -declaration-derived `publicFormGrant` (see the note above) is what permits the -insert. For the submit you only need a `guest_portal` permission set if you rely -on the legacy back-compat path (e.g. an older runtime, or object hooks that -branch on the `guest_portal` permission). When present the set is attached to -the anonymous context, so keep it INSERT-only on the form's target object — the -guest-safe shape (ADR-0090 D9). - -{/* os:check */} -```ts -// hotcrm/src/security/guest-portal.permission.ts -import { definePermissionSet } from '@objectstack/spec/security'; - -// Guest-safe capability: INSERT-only on the intake objects. (`isProfile` no -// longer exists — the Profile concept was removed by ADR-0090 D2.) -export default definePermissionSet({ - name: 'guest_portal', - label: 'Public Form Submitters', - objects: { - lead: { allowCreate: true }, // no read/edit/delete - case: { allowCreate: true }, - }, -}); -``` +## 2. No permission set to create — what the `guest_portal` name does + +The submit is authorized by the declaration-derived `publicFormGrant` (see the +note above). The grant is decided before any permission set resolves, so the +form works the same whether or not a `guest_portal` permission set exists. The +submit's execution context still names `guest_portal` in `permissions`, and that +name reaches one thing only: + +- **The result masker, where a deployment registers a set of that name.** Only + the set's field permissions (`fields`, such as `readable: false`) 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 set's object + permissions (`allowCreate`, `allowRead`, …) are not read on this path, so they + neither grant nor limit the insert. +- **The visitor never sees that record.** The submit answers with the new + record's `id` only (section 4). Register a `guest_portal` set only if you want + fields masked on the record the route gets back; nothing else needs one. +- **No object hook can read it.** A hook's `ctx.session` and `ctx.user` carry + neither `permissions` nor `anonymous`, and on a public submission both are + unset because the context carries no user. Tell a public submission apart by + the missing `ctx.user` (section 3), not by a permission. ## 3. Stamp server-controlled defaults in a hook @@ -226,7 +222,7 @@ Errors: | `400 INVALID_REQUEST` | missing / blank slug (an empty body is coerced to `{}` and surfaces as `VALIDATION_FAILED` below, not here) | | `400 VALIDATION_ERROR` | the form's sections declare **no** fields, so it collects nothing — wire the fields and resubmit (#6920) | | `400 VALIDATION_FAILED` | object schema validators fail (`required`, `format`, `length`, …) | -| `403 PERMISSION_DENIED` | the resolved profile does not allow create on the target object | +| `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`. | | `404 FORM_NOT_FOUND` | slug not registered on any view whose form has `sharing.enabled: true` and `sharing.allowAnonymous: true` | | `5xx` (generic) | driver / hook threw — submit errors are mapped by `mapDataError`; there is no dedicated `FORM_SUBMIT_FAILED` code | @@ -247,7 +243,7 @@ lookup route. ### Auth model - Neither route calls `enforceAuth`, so both work under the always-on anonymous-deny default (there is no `requireAuth` knob to configure since v17). -- 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 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). - No CSRF or auth header is needed; embed the form on any domain. ## 5. Embedding from a front-end diff --git a/content/docs/ui/public-data-collection.mdx b/content/docs/ui/public-data-collection.mdx index ecea0119dce..6b04b0fb15d 100644 --- a/content/docs/ui/public-data-collection.mdx +++ b/content/docs/ui/public-data-collection.mdx @@ -46,6 +46,8 @@ POST /api/v1/forms/contact-us/submit → INSERT one showcase_inquiry 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. +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. + 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). ### 3. Keep the object private once inside diff --git a/examples/app-crm/src/objects/lead.object.ts b/examples/app-crm/src/objects/lead.object.ts index b5cae4f850c..10c9e9906f7 100644 --- a/examples/app-crm/src/objects/lead.object.ts +++ b/examples/app-crm/src/objects/lead.object.ts @@ -41,9 +41,11 @@ export const Lead = ObjectSchema.create({ status: Field.select({ label: 'Status', required: true, - // Write-path default so minimal creates (e.g. the public Web-to-Lead form, - // which doesn't expose status) satisfy `required` — the option `default` - // below is only a UI preselect. + // The write-path default: a create that omits `status` (e.g. the public + // Web-to-Lead form, which doesn't collect it) is stored as 'new', so it + // satisfies `required`. A declared `defaultValue` takes precedence over + // the option marked `default: true` below, which the engine falls back to + // on write only for a select that declares no `defaultValue`. defaultValue: 'new', options: [ { label: 'New', value: 'new', default: true, color: '#94A3B8' }, diff --git a/examples/app-crm/src/security/sales-positions.ts b/examples/app-crm/src/security/sales-positions.ts index 9cad970c3c3..8f228d43739 100644 --- a/examples/app-crm/src/security/sales-positions.ts +++ b/examples/app-crm/src/security/sales-positions.ts @@ -62,12 +62,23 @@ export const SalesUserPermissionSet = definePermissionSet({ }); /** - * Guest profile for the public Web-to-Lead form (lead.view.ts `web_to_lead`). + * The `guest_portal` permission set — the name the public form doors' submit + * context requests. ADR-0138 records this declaration as the reference for that + * name (fate: keep), which is why the example keeps it. * - * Applied to anonymous (unauthenticated) visitors who POST the public form. The - * anonymous permission path checks the FULL object name, so this MUST key - * `crm_lead` (not a short `lead`). INSERT-only — guests can never read, edit, or - * delete any record. + * It does NOT authorize the public Web-to-Lead form (lead.view.ts + * `web_to_lead`). That form's own declaration does: the submit route derives a + * `publicFormGrant` on `crm_lead` (create + read-back on that object only, + * ADR-0056 Option A), and the SecurityPlugin admits on that grant before any + * permission set resolves. The form works the same without this set. + * + * What the name reaches: the submit's context carries + * `permissions: ['guest_portal']`, so a set registered under this name is the + * one the grant's result masker reads over what the grant hands back, through + * its `fields` and the capabilities it holds against a field's + * `requiredPermissions`. Its `objects` bits are not read on that path. This + * declaration has object bits only, so on the form path it changes nothing, and + * the submit answers the created id only. */ export const GuestPortalProfile = definePermissionSet({ name: 'guest_portal', diff --git a/examples/app-crm/src/views/lead.view.ts b/examples/app-crm/src/views/lead.view.ts index 1c135b6779d..2a9f9d93f24 100644 --- a/examples/app-crm/src/views/lead.view.ts +++ b/examples/app-crm/src/views/lead.view.ts @@ -99,9 +99,14 @@ export const LeadViews = defineView({ * Hosted at GET/POST `/api/v1/forms/contact-us` (+ `/submit`). An * unauthenticated visitor — or an `EmbeddableForm` iframe on a marketing * site — submits it to create a `crm_lead`. `sharing.allowAnonymous` opens - * the public endpoint; the `guest_portal` profile (INSERT-only on crm_lead) - * authorizes the write. The lead object's own defaults/hooks stamp internal - * fields (status, owner, score). + * the public endpoint, and this declaration is also what authorizes the + * write: the submit route derives a `publicFormGrant` on `crm_lead` from it + * (create + read-back on that object only, ADR-0056 Option A). No + * permission set is needed, `GuestPortalProfile` included (see its + * comment). Only the fields in `sections` are written; the lead object's + * field defaults fill what the form does not collect (`status`, `source`, + * `lead_score`), and the server-managed anchors (`owner_id`, + * `organization_id`, audit columns) are never taken from a submission. */ web_to_lead: { type: 'simple', diff --git a/packages/rest/src/form-routes.ts b/packages/rest/src/form-routes.ts index 43166e1840c..d1bb32ecbf5 100644 --- a/packages/rest/src/form-routes.ts +++ b/packages/rest/src/form-routes.ts @@ -407,8 +407,8 @@ export function registerFormEndpoints(this: FormRouteHost, basePath: string): vo }, }); - // POST /forms/:slug/submit — INSERT a record on the target object - // with the `guest_portal` permission set attached. + // POST /forms/:slug/submit — INSERT a record on the target object, + // authorized by the form's own declaration (`publicFormGrant`, below). this.routeManager.register({ method: 'POST', path: `${basePath}/forms/:slug/submit`, @@ -432,9 +432,12 @@ export function registerFormEndpoints(this: FormRouteHost, basePath: string): vo // Only allow the fields declared on the matched FormView. // This prevents a public visitor from stuffing privileged // columns (owner_id, status, internal_notes, …) into the - // row. Object hooks (`beforeInsert`) are still responsible - // for stamping server-side defaults — see the CRM - // `lead.hook.ts` / `case.hook.ts` for the canonical pattern. + // row. What the form does not collect, the object fills: a + // field's `defaultValue` (`crm_lead.status` in + // `examples/app-crm`) or, on a select that declares none, its + // `default: true` option; or a `beforeInsert` hook where the + // value needs logic (`StampInquiryDefaultsHook` in + // `examples/app-showcase/src/data/hooks/index.ts`). const allowedFields = new Set(); // [#24131] …and, off the SAME entries, the fields the form // requires the visitor to fill: an entry declaring @@ -522,10 +525,26 @@ export function registerFormEndpoints(this: FormRouteHost, basePath: string): vo // ADR-0056 (Option A): authorization DERIVED from the declared // form — a narrow create grant scoped to exactly this form's target // object. The SecurityPlugin honors `publicFormGrant` (create + the - // immediate read-back, that object ONLY), so public forms work under - // secure-by-default (anonymous-deny) WITHOUT a deployment-configured - // `guest_portal`. `guest_portal` + `anonymous` are kept for back-compat - // with object hooks (guest detection via falsy `ctx.user?.id`). + // immediate read-back, that object ONLY) before it resolves any + // permission set, so public forms work under secure-by-default + // (anonymous-deny) whether or not the deployment registers a + // `guest_portal` set, and no set's object bits widen or narrow + // this insert. + // + // `permissions: ['guest_portal']` admits nothing. It names the set + // the SecurityPlugin resolves for this caller: where a deployment + // registers one under that name, the grant's result masker reads + // that set's field permissions (and the capabilities it holds + // against a field's `requiredPermissions`) over what the grant + // hands back; where none is registered, it resolves to no set and + // the masker reads the zero-set posture. ADR-0138 records the name + // (fate: keep). The answer below is the created id only (#22437), + // so the masked record never reaches the visitor through this door. + // + // Neither `permissions` nor `anonymous` reaches an object hook: + // the engine builds `ctx.session` and `ctx.user` without either + // key. With no `userId` here, `ctx.user` is unset, so a hook sees + // this submission as a write with no user, like any other. const context: any = { publicFormGrant: { object: match.object }, permissions: ['guest_portal'], diff --git a/packages/rest/src/rest-server.ts b/packages/rest/src/rest-server.ts index 5b2a228499d..2b9cc4a3e38 100644 --- a/packages/rest/src/rest-server.ts +++ b/packages/rest/src/rest-server.ts @@ -8273,15 +8273,23 @@ export class RestServer { * answering `{ id }` only (#22437) * * Both routes bypass `enforceAuth` even though anonymous-deny is on for the - * deployment (e.g. ObjectOS multi-tenant). Security is delegated to the - * `guest_portal` permission set carried on the execution context — the - * SecurityPlugin enforces INSERT-only access to the target object. If - * the deployment hasn't registered a `guest_portal` profile, the - * security middleware falls open with `permissions: []` (no userId), - * matching the existing anonymous-access semantics; deployers must - * keep secure-by-default deployments paired with a `guest_portal` - * profile (the CRM example does this) to enforce the INSERT-only - * contract. + * deployment (e.g. ObjectOS multi-tenant). The submit is authorized by the + * form's own declaration (ADR-0056 Option A): it hands the engine a + * `publicFormGrant` scoped to the form's target object, and the + * SecurityPlugin admits on that grant alone, before any permission set + * resolves. It admits the create and the read-back (`find` / `findOne` / + * `count`) on that object only, refuses every other operation and object, + * and strips the server-managed anchors (`PUBLIC_FORM_SERVER_MANAGED_FIELDS`) + * from the payload; the route's allow-list of the form's declared fields is + * the first net. No deployment configures anything for this, and the grant + * never falls open: it admits the same operations whether or not a + * `guest_portal` set is registered. + * + * The submit's context also names `guest_portal` in `permissions`. That + * name admits nothing. Where a deployment registers a set under it, the + * SecurityPlugin resolves that set as the caller's for the grant's result + * masker over what the grant hands back (ADR-0138 records the name); the + * submit answers the created id only (#22437). * * The matched FormView's parent ViewSchema is found by scanning * `protocol.getMetaItems({ type: 'view' })`. For each entry we inspect