Skip to content

Commit 6b6e0cf

Browse files
committed
feat(security): ADR-0056 Option A — declaration-derived public-form authorization
Public form submit now derives a narrow `publicFormGrant: { object }` from the form's declared target; the SecurityPlugin honors it as create + read-back on that object ONLY (no userId, no deployment guest_portal, never the anonymous fall-open). Lets public forms survive secure-by-default while staying least-privilege — the prerequisite for the requireAuth default flip. Proven by form-self-auth dogfood (3/3: target create allowed; cross-object + update/delete denied). No regression (plugin-security 108, rest 121, full dogfood 98). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XVdnfUAx85amkerym26vdx
1 parent 755dcd8 commit 6b6e0cf

4 files changed

Lines changed: 103 additions & 5 deletions

File tree

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
---
2+
"@objectstack/plugin-security": minor
3+
"@objectstack/rest": patch
4+
---
5+
6+
feat(security): declaration-derived public-form authorization (ADR-0056, Option A)
7+
8+
Public form submissions are now authorized by the **declaration**, not by a
9+
deployment-configured `guest_portal` profile. The form-submit route derives a narrow
10+
`publicFormGrant: { object }` from the matched form's target object; the SecurityPlugin
11+
honors it as a least-privilege capability — **create + the immediate read-back on THAT
12+
object only**, with no userId, and crucially NOT the anonymous fall-open. This makes
13+
public forms work under secure-by-default (`requireAuth`) **without** a hand-configured
14+
`guest_portal`, scoped to exactly the declared object (the field allow-list is still
15+
enforced at the route; `guest_portal`/`anonymous` are kept on the context for back-compat
16+
with guest-detection hooks). It is the prerequisite that unblocks the eventual
17+
`requireAuth` default flip, and generalizes the platform principle "public access =
18+
declared + runtime-derived scoped grant" (the same shape share-links already use).
19+
Proven by `form-self-auth` dogfood (create on target allowed; cross-object + update/delete
20+
denied). plugin-security 108, rest 121, full dogfood 98 — no regression.
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// ADR-0056 (Option A) — declaration-derived public-form authorization.
4+
// A public form submission carries a `publicFormGrant: { object }` derived from
5+
// the form's declared target. The SecurityPlugin honors it as a NARROW capability:
6+
// create + read-back on THAT object only — no userId, no deployment-configured
7+
// `guest_portal`, and crucially NOT the anonymous fall-open (which would allow
8+
// anything). This is what lets public forms survive a secure-by-default flip
9+
// while staying least-privilege. Proven at the engine boundary (the same
10+
// middleware the HTTP form-submit route flows through).
11+
12+
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
13+
import showcaseStack from '@objectstack/example-showcase';
14+
import { bootStack, type VerifyStack } from '@objectstack/verify';
15+
16+
const idOf = (r: any) => r?.id ?? r?.record?.id ?? r?.data?.id ?? r;
17+
18+
describe('ADR-0056 Option A — public-form grant (declaration-derived)', () => {
19+
let stack: VerifyStack;
20+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
21+
let ql: any;
22+
23+
beforeAll(async () => {
24+
stack = await bootStack(showcaseStack);
25+
await stack.signIn();
26+
ql = await stack.kernel.getServiceAsync('objectql');
27+
}, 60_000);
28+
29+
afterAll(async () => { await stack?.stop(); });
30+
31+
it('authorizes CREATE on exactly the declared object (no userId / no guest_portal)', async () => {
32+
const ctx = { publicFormGrant: { object: 'showcase_private_note' } };
33+
const r = await ql.insert('showcase_private_note', { title: 'from public form' }, { context: ctx });
34+
expect(idOf(r), 'create on the form target succeeds').toBeTruthy();
35+
});
36+
37+
it('is NARROW — does NOT extend to other objects (not the anonymous fall-open)', async () => {
38+
const ctx = { publicFormGrant: { object: 'showcase_private_note' } };
39+
await expect(
40+
ql.insert('showcase_announcement', { title: 'cross-object' }, { context: ctx }),
41+
'grant for private_note must not authorize announcement',
42+
).rejects.toThrow();
43+
});
44+
45+
it('does NOT permit update/delete (create + read-back only)', async () => {
46+
const seed = await ql.insert('showcase_private_note', { title: 'seed' }, { context: { isSystem: true } });
47+
const ctx = { publicFormGrant: { object: 'showcase_private_note' } };
48+
await expect(
49+
ql.update('showcase_private_note', { id: idOf(seed), title: 'hacked' }, { context: ctx }),
50+
'grant must not authorize update',
51+
).rejects.toThrow();
52+
});
53+
});

packages/plugins/plugin-security/src/security-plugin.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -285,6 +285,28 @@ export class SecurityPlugin implements Plugin {
285285
return next();
286286
}
287287

288+
// ADR-0056 (Option A) — declaration-derived PUBLIC-FORM grant. A public
289+
// form submission carries `publicFormGrant: { object }` derived from the
290+
// form's declared target (set by the rest-server form-submit route). It
291+
// authorizes ONLY create + the immediate read-back on THAT object — never
292+
// anything else, and never the anonymous fall-open. This lets public forms
293+
// work under secure-by-default (requireAuth) WITHOUT a deployment-configured
294+
// `guest_portal`, scoped to exactly the declared object (the field
295+
// allow-list is enforced at the route; the context is request-scoped).
296+
const formGrant = opCtx.context?.publicFormGrant;
297+
if (formGrant && typeof formGrant === 'object' && (formGrant as { object?: string }).object) {
298+
const grantObject = (formGrant as { object: string }).object;
299+
const allowed =
300+
opCtx.object === grantObject &&
301+
['insert', 'find', 'findOne', 'count'].includes(opCtx.operation);
302+
if (allowed) return next();
303+
throw new PermissionDeniedError(
304+
`[Security] Access denied: public-form grant permits only create/read-back on '${grantObject}', ` +
305+
`not '${opCtx.operation}' on '${opCtx.object}'`,
306+
{ operation: opCtx.operation, object: opCtx.object },
307+
);
308+
}
309+
288310
const roles = opCtx.context?.roles ?? [];
289311
const explicitPermissionSets = opCtx.context?.permissions ?? [];
290312

packages/rest/src/rest-server.ts

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3708,12 +3708,15 @@ export class RestServer {
37083708
Object.assign(filteredData, rawBody);
37093709
}
37103710

3711-
// Anonymous execution context. Carries the `guest_portal`
3712-
// permission set name so the SecurityPlugin resolves it
3713-
// and enforces INSERT-only on the target object.
3714-
// Leaving `userId` undefined keeps `ctx.user?.id` falsy
3715-
// in object hooks (the canonical guest-detection check).
3711+
// ADR-0056 (Option A): authorization DERIVED from the declared
3712+
// form — a narrow create grant scoped to exactly this form's target
3713+
// object. The SecurityPlugin honors `publicFormGrant` (create + the
3714+
// immediate read-back, that object ONLY), so public forms work under
3715+
// secure-by-default (requireAuth) WITHOUT a deployment-configured
3716+
// `guest_portal`. `guest_portal` + `anonymous` are kept for back-compat
3717+
// with object hooks (guest detection via falsy `ctx.user?.id`).
37163718
const context: any = {
3719+
publicFormGrant: { object: match.object },
37173720
permissions: ['guest_portal'],
37183721
anonymous: true,
37193722
};

0 commit comments

Comments
 (0)