Skip to content

Commit 3226c65

Browse files
committed
feat(rest): ADR-0056 D2 — surface fail-open anonymous posture (warn→enforce)
The data API's deny capability already exists (requireAuth=true → 401 for anonymous; share-link / guest_portal / control-plane exempt) but the DEFAULT is fail-open. Add a boot WARN when requireAuth is off so the insecure posture is explicit, WITHOUT flipping the global default (release-gated; flipping would break anonymous-dependent deployments). Proven by showcase-anonymous-deny (4/4): anonymous read+write 401, authenticated 200, control-plane open. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XVdnfUAx85amkerym26vdx
1 parent d42f40d commit 3226c65

3 files changed

Lines changed: 79 additions & 0 deletions

File tree

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
---
2+
"@objectstack/rest": patch
3+
---
4+
5+
feat(rest): warn on fail-open anonymous posture (ADR-0056 D2, warn→enforce)
6+
7+
Secure-by-default work for the data API. The deny capability already exists
8+
(`api.requireAuth=true` rejects anonymous via `enforceAuth`, and share-link /
9+
`guest_portal` / control-plane routes are exempt) — but the **default is fail-open**
10+
(`requireAuth=false`), so an object with no OWD/RLS is world-readable with no signal.
11+
This adds a boot-time WARN when running in that posture, making it explicit
12+
(consistent with D4/D8 honesty). The global default is deliberately NOT flipped here
13+
— that is a release-gated decision; flipping it would 401 deployments that rely on
14+
anonymous reads. Proven by the `showcase-anonymous-deny` dogfood test (anonymous
15+
read+write → 401, authenticated → 200, control-plane open).
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// ADR-0056 D2 — secure-by-default (anonymous deny) posture, proven on the real
4+
// showcase HTTP stack. With `requireAuth` on, an UNAUTHENTICATED request to the
5+
// data API is rejected (401), while authenticated members are unaffected and the
6+
// control-plane (`/auth/*`) stays open (sign-up itself is an anonymous call). This
7+
// is the enforcement capability D2 builds on; the framework does NOT flip the
8+
// global default — it makes the deny posture available + warns when it is off.
9+
10+
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
11+
import showcaseStack from '@objectstack/example-showcase';
12+
import { bootStack, type VerifyStack } from '@objectstack/verify';
13+
14+
const OBJ = '/data/showcase_private_note';
15+
16+
describe('showcase: anonymous default-deny (ADR-0056 D2)', () => {
17+
let stack: VerifyStack;
18+
let memberToken: string;
19+
20+
beforeAll(async () => {
21+
stack = await bootStack(showcaseStack); // harness runs requireAuth: true
22+
await stack.signIn();
23+
memberToken = await stack.signUp('d2-member@verify.test'); // anonymous /auth call → proves control-plane is open
24+
}, 60_000);
25+
26+
afterAll(async () => { await stack?.stop(); });
27+
28+
it('control-plane is open for anonymous (sign-up succeeded without a token)', () => {
29+
expect(memberToken, 'anonymous /auth/sign-up returned a token').toBeTruthy();
30+
});
31+
32+
it('anonymous READ of the data API is denied (401)', async () => {
33+
const r = await stack.api(OBJ, { method: 'GET' });
34+
expect(r.status, 'unauthenticated data read must be 401').toBe(401);
35+
});
36+
37+
it('anonymous WRITE of the data API is denied (401)', async () => {
38+
const w = await stack.api(OBJ, {
39+
method: 'POST',
40+
headers: { 'Content-Type': 'application/json' },
41+
body: JSON.stringify({ title: 'anon' }),
42+
});
43+
expect(w.status, 'unauthenticated data write must be 401').toBe(401);
44+
});
45+
46+
it('an AUTHENTICATED member is allowed (deny targets anonymity, not the API)', async () => {
47+
const ok = await stack.apiAs(memberToken, 'GET', OBJ);
48+
expect(ok.status).toBe(200);
49+
});
50+
});

packages/rest/src/rest-api-plugin.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -190,6 +190,20 @@ export function createRestApiPlugin(config: RestApiPluginConfig = {}): Plugin {
190190
restServer.registerRoutes();
191191

192192
ctx.logger.info('REST API successfully registered');
193+
194+
// ADR-0056 D2 (warn → enforce): surface the fail-open posture.
195+
// When `requireAuth` is off, anonymous requests reach the data API
196+
// and read any object with no OWD/RLS — secure-by-default would deny
197+
// them and route public access through share-links / `publicSharing`.
198+
// We do NOT flip the default here (it would break deployments that
199+
// rely on anonymous reads); we make the posture explicit instead.
200+
if (!(config.api as any)?.requireAuth) {
201+
ctx.logger.warn(
202+
'[security] anonymous access to the data API is ALLOWED (api.requireAuth=false) — ' +
203+
'objects without OWD/RLS are world-readable. For secure-by-default set ' +
204+
'api.requireAuth=true and expose public records via share-links / publicSharing (ADR-0056 D2).',
205+
);
206+
}
193207
} catch (err: any) {
194208
ctx.logger.error('Failed to register REST API routes', { error: err.message } as any);
195209
throw err;

0 commit comments

Comments
 (0)