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
20 changes: 20 additions & 0 deletions .changeset/adr-0056-form-self-auth.md
Original file line number Diff line number Diff line change
@@ -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.
53 changes: 53 additions & 0 deletions packages/dogfood/test/form-self-auth.dogfood.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
22 changes: 22 additions & 0 deletions packages/plugins/plugin-security/src/security-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ?? [];

Expand Down
13 changes: 8 additions & 5 deletions packages/rest/src/rest-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
Expand Down
Loading