diff --git a/.changeset/adr-0056-form-self-auth.md b/.changeset/adr-0056-form-self-auth.md new file mode 100644 index 0000000000..5cdcf67653 --- /dev/null +++ b/.changeset/adr-0056-form-self-auth.md @@ -0,0 +1,20 @@ +--- +"@objectstack/plugin-security": minor +"@objectstack/rest": patch +--- + +feat(security): declaration-derived public-form authorization (ADR-0056, Option A) + +Public form submissions are now authorized by the **declaration**, not by a +deployment-configured `guest_portal` profile. The form-submit route derives a narrow +`publicFormGrant: { object }` from the matched form's target object; the SecurityPlugin +honors it as a least-privilege capability — **create + the immediate read-back on THAT +object only**, with no userId, and crucially NOT the anonymous fall-open. This makes +public forms work under secure-by-default (`requireAuth`) **without** a hand-configured +`guest_portal`, scoped to exactly the declared object (the field allow-list is still +enforced at the route; `guest_portal`/`anonymous` are kept on the context for back-compat +with guest-detection hooks). It is the prerequisite that unblocks the eventual +`requireAuth` default flip, and generalizes the platform principle "public access = +declared + runtime-derived scoped grant" (the same shape share-links already use). +Proven by `form-self-auth` dogfood (create on target allowed; cross-object + update/delete +denied). plugin-security 108, rest 121, full dogfood 98 — no regression. diff --git a/packages/dogfood/test/form-self-auth.dogfood.test.ts b/packages/dogfood/test/form-self-auth.dogfood.test.ts new file mode 100644 index 0000000000..6fbb7c9688 --- /dev/null +++ b/packages/dogfood/test/form-self-auth.dogfood.test.ts @@ -0,0 +1,53 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// ADR-0056 (Option A) — declaration-derived public-form authorization. +// A public form submission carries a `publicFormGrant: { object }` derived from +// the form's declared target. The SecurityPlugin honors it as a NARROW capability: +// create + read-back on THAT object only — no userId, no deployment-configured +// `guest_portal`, and crucially NOT the anonymous fall-open (which would allow +// anything). This is what lets public forms survive a secure-by-default flip +// while staying least-privilege. Proven at the engine boundary (the same +// middleware the HTTP form-submit route flows through). + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import showcaseStack from '@objectstack/example-showcase'; +import { bootStack, type VerifyStack } from '@objectstack/verify'; + +const idOf = (r: any) => r?.id ?? r?.record?.id ?? r?.data?.id ?? r; + +describe('ADR-0056 Option A — public-form grant (declaration-derived)', () => { + let stack: VerifyStack; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let ql: any; + + beforeAll(async () => { + stack = await bootStack(showcaseStack); + await stack.signIn(); + ql = await stack.kernel.getServiceAsync('objectql'); + }, 60_000); + + afterAll(async () => { await stack?.stop(); }); + + it('authorizes CREATE on exactly the declared object (no userId / no guest_portal)', async () => { + const ctx = { publicFormGrant: { object: 'showcase_private_note' } }; + const r = await ql.insert('showcase_private_note', { title: 'from public form' }, { context: ctx }); + expect(idOf(r), 'create on the form target succeeds').toBeTruthy(); + }); + + it('is NARROW — does NOT extend to other objects (not the anonymous fall-open)', async () => { + const ctx = { publicFormGrant: { object: 'showcase_private_note' } }; + await expect( + ql.insert('showcase_announcement', { title: 'cross-object' }, { context: ctx }), + 'grant for private_note must not authorize announcement', + ).rejects.toThrow(); + }); + + it('does NOT permit update/delete (create + read-back only)', async () => { + const seed = await ql.insert('showcase_private_note', { title: 'seed' }, { context: { isSystem: true } }); + const ctx = { publicFormGrant: { object: 'showcase_private_note' } }; + await expect( + ql.update('showcase_private_note', { id: idOf(seed), title: 'hacked' }, { context: ctx }), + 'grant must not authorize update', + ).rejects.toThrow(); + }); +}); diff --git a/packages/plugins/plugin-security/src/security-plugin.ts b/packages/plugins/plugin-security/src/security-plugin.ts index 869d28142a..7aee1c1369 100644 --- a/packages/plugins/plugin-security/src/security-plugin.ts +++ b/packages/plugins/plugin-security/src/security-plugin.ts @@ -285,6 +285,28 @@ export class SecurityPlugin implements Plugin { return next(); } + // ADR-0056 (Option A) — declaration-derived PUBLIC-FORM grant. A public + // form submission carries `publicFormGrant: { object }` derived from the + // form's declared target (set by the rest-server form-submit route). It + // authorizes ONLY create + the immediate read-back on THAT object — never + // anything else, and never the anonymous fall-open. This lets public forms + // work under secure-by-default (requireAuth) WITHOUT a deployment-configured + // `guest_portal`, scoped to exactly the declared object (the field + // allow-list is enforced at the route; the context is request-scoped). + const formGrant = opCtx.context?.publicFormGrant; + if (formGrant && typeof formGrant === 'object' && (formGrant as { object?: string }).object) { + const grantObject = (formGrant as { object: string }).object; + const allowed = + opCtx.object === grantObject && + ['insert', 'find', 'findOne', 'count'].includes(opCtx.operation); + if (allowed) return next(); + throw new PermissionDeniedError( + `[Security] Access denied: public-form grant permits only create/read-back on '${grantObject}', ` + + `not '${opCtx.operation}' on '${opCtx.object}'`, + { operation: opCtx.operation, object: opCtx.object }, + ); + } + const roles = opCtx.context?.roles ?? []; const explicitPermissionSets = opCtx.context?.permissions ?? []; diff --git a/packages/rest/src/rest-server.ts b/packages/rest/src/rest-server.ts index baba2cf9a0..50e741135b 100644 --- a/packages/rest/src/rest-server.ts +++ b/packages/rest/src/rest-server.ts @@ -3708,12 +3708,15 @@ export class RestServer { Object.assign(filteredData, rawBody); } - // Anonymous execution context. Carries the `guest_portal` - // permission set name so the SecurityPlugin resolves it - // and enforces INSERT-only on the target object. - // Leaving `userId` undefined keeps `ctx.user?.id` falsy - // in object hooks (the canonical guest-detection check). + // 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 (requireAuth) WITHOUT a deployment-configured + // `guest_portal`. `guest_portal` + `anonymous` are kept for back-compat + // with object hooks (guest detection via falsy `ctx.user?.id`). const context: any = { + publicFormGrant: { object: match.object }, permissions: ['guest_portal'], anonymous: true, };