Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .changeset/24130-form-submit-docstring.md
Original file line number Diff line number Diff line change
@@ -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.
58 changes: 27 additions & 31 deletions content/docs/ui/forms.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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 |

Expand All @@ -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
Expand Down
2 changes: 2 additions & 0 deletions content/docs/ui/public-data-collection.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 5 additions & 3 deletions examples/app-crm/src/objects/lead.object.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' },
Expand Down
21 changes: 16 additions & 5 deletions examples/app-crm/src/security/sales-positions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
11 changes: 8 additions & 3 deletions examples/app-crm/src/views/lead.view.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
37 changes: 28 additions & 9 deletions packages/rest/src/form-routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`,
Expand All @@ -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<string>();
// [#24131] …and, off the SAME entries, the fields the form
// requires the visitor to fill: an entry declaring
Expand Down Expand Up @@ -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'],
Expand Down
26 changes: 17 additions & 9 deletions packages/rest/src/rest-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading