Skip to content

Commit 3c628ce

Browse files
authored
feat(auth)!: retire the api.requireAuth opt-out — anonymous data access is always denied (#3963 step 2) (#4043)
`api.requireAuth: false` let a deployment open the entire data plane with one boolean. Retired: auth is a kernel concern, and anonymous callers are now denied unconditionally on every HTTP surface that touches object data. Every surface that legitimately serves a session-less caller derives its own narrow authorization from a declaration (control-plane allowlist, publicFormGrant, share-link token, book.audience:'public', MCP token) — none needs the global switch. Retirement ran the full repo process: retiredKey() tombstones on both schemas, protocol-18 conversion + migration step18, regenerated artifacts. Auth-less stacks that would serve data now fail at boot (A1). shouldDenyAnonymous no longer accepts a requireAuth input. Closes #3963.
1 parent a65a4bc commit 3c628ce

50 files changed

Lines changed: 595 additions & 413 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.changeset/drop-require-auth.md

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
---
2+
"@objectstack/spec": major
3+
"@objectstack/rest": major
4+
"@objectstack/runtime": major
5+
"@objectstack/core": minor
6+
"@objectstack/cli": minor
7+
"@objectstack/plugin-hono-server": minor
8+
"@objectstack/plugin-dev": minor
9+
---
10+
11+
feat(auth)!: retire the `api.requireAuth` opt-out — anonymous access to object data is always denied (#3963)
12+
13+
`api.requireAuth: false` let a deployment open its ENTIRE data plane with one
14+
config key. It is removed. Auth is a kernel concern, not a deployment posture:
15+
anonymous callers are denied on every HTTP surface that reaches object data,
16+
unconditionally.
17+
18+
Every surface that legitimately serves a session-less caller already derives its
19+
own narrow authorization from a DECLARATION, so none of them needed the global
20+
switch:
21+
22+
- control plane (`/auth/*`, `/health`, `/ready`, `/discovery`, ADR-0069
23+
remediation) — the auth-gate allowlist;
24+
- public form submission — `publicFormGrant` (ADR-0056 Option A);
25+
- share links — the capability token, validated then read as SYSTEM;
26+
- a `book.audience: 'public'` read — the ADR-0046 §6.7 audience gate (#3995);
27+
- MCP — an OAuth token or API key.
28+
29+
**Breaking changes.**
30+
31+
- `api.requireAuth` is a retired key. It is tombstoned (`retiredKey`) in both
32+
`RestApiConfigSchema` and the stack `api` block, so authoring it now fails with
33+
a fix-it message rather than being silently stripped (the ADR-0104 / #3733
34+
quiet-failure this whole line of work has been closing). `os migrate meta`
35+
drops it via the protocol-18 conversion `stack-api-require-auth-removed`.
36+
- `shouldDenyAnonymous` (@objectstack/core) no longer takes a `requireAuth`
37+
input; it denies any anonymous, non-system caller outside the control-plane
38+
allowlist.
39+
- A stack that mounts **no auth at all** now FAILS AT BOOT when it would serve a
40+
data API (`objectstack serve`, plugin-dev), instead of getting an explicit
41+
fail-open. Enable auth (the `auth` tier or AuthPlugin), or run without the data
42+
API. There is no anonymous-data carve-out any more — publishing a public
43+
surface is done by declaration (see above).
44+
45+
**Migration.** Delete `api.requireAuth` from the stack config (or run
46+
`os migrate meta`). If you were serving data publicly with `requireAuth: false`,
47+
replace it with the declaration that fits: a public form view, a share link, or
48+
`book.audience: 'public'`. If you have an auth-less stack that intentionally
49+
served data, it must now mount auth or stop serving the data API.

content/docs/references/api/rest-server.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -192,7 +192,7 @@ const result = BatchEndpointsConfig.parse(data);
192192
| **enableOpenApi** | `boolean` || Enable OpenAPI 3.1 spec & docs viewer endpoints |
193193
| **enableProjectScoping** | `boolean` || Enable project-scoped routing for data/meta/AI APIs |
194194
| **projectResolution** | `Enum<'required' \| 'optional' \| 'auto'>` || Project ID resolution strategy |
195-
| **requireAuth** | `boolean` | | Reject anonymous requests on ALL HTTP surfaces that reach object data (REST /data, /meta, raw-hono /data) with HTTP 401 (secure-by-default; set false to serve them publicly) |
195+
| **requireAuth** | `any` | optional | [REMOVED] `api.requireAuth` was removed in @objectstack/spec 18 (#3963). Anonymous access to object data is now always denied — auth is a kernel concern, not a deployment posture. Delete the key. To publish something publicly, declare it: a public form view (`sharing.allowAnonymous`), a share link, or `book.audience: 'public'` — each derives its own narrow authorization instead of opening the whole data plane. |
196196
| **documentation** | `{ enabled: boolean; title: string; description?: string; version?: string; … }` | optional | OpenAPI/Swagger documentation config |
197197
| **responseFormat** | `{ envelope: boolean; includeMetadata: boolean; includePagination: boolean }` | optional | Response format options |
198198

packages/cli/src/commands/serve.ts

Lines changed: 24 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1728,26 +1728,34 @@ export default class Serve extends Command {
17281728
// (env-native auth IS the membership — ADR-0024 D9) by setting
17291729
// `api.enforceProjectMembership: false`. Undefined → dispatcher default.
17301730
const enforceProjectMembership = apiConfig.enforceProjectMembership;
1731-
// `requireAuth: true` rejects anonymous requests on `/api/v1/data/*`
1732-
// with HTTP 401 before they reach ObjectQL. The platform default is
1733-
// now secure-by-default (ADR-0056 D2): deny anonymous. The CLI keeps
1734-
// one deliberate carve-out — a stack with NO `auth` tier has no way
1735-
// to authenticate anyone, so denying would brick its data API
1736-
// entirely; such auth-less playgrounds get an EXPLICIT `false`
1737-
// (fail-open), which the REST plugin surfaces with a boot warning.
1738-
// Apps can always override via stack `api.requireAuth`.
1739-
// Auth availability = tier auto-registers it OR the stack mounts
1740-
// AuthPlugin explicitly (hasAuthPlugin, computed above). Keying only on
1741-
// the tier would hand an explicit fail-open to a stack that ships auth
1742-
// via `plugins:` under a minimal tier set — re-opening the very hole
1743-
// the flip closes. Only a stack with NO auth at all gets the carve-out.
1744-
const requireAuth = apiConfig.requireAuth
1745-
?? ((tierEnabled('auth') || hasAuthPlugin) ? true : false);
1731+
// [#3963] Anonymous access to object data is denied unconditionally —
1732+
// there is no `api.requireAuth` opt-out any more (auth is a kernel
1733+
// concern; every legitimately session-less surface derives its own narrow
1734+
// authorization from a declaration instead).
1735+
//
1736+
// The CLI used to hand an EXPLICIT fail-open to a stack with no auth at
1737+
// all, reasoning that nobody could authenticate against it so denying
1738+
// would brick its data API. Under A1 that inverts the conclusion: a stack
1739+
// with no auth has no security model, so it must not serve a data API —
1740+
// and it should say so at boot instead of quietly serving object data to
1741+
// the internet. Auth availability = the tier auto-registers it OR the
1742+
// stack mounts AuthPlugin explicitly.
1743+
if (flags.server && !(tierEnabled('auth') || hasAuthPlugin)) {
1744+
throw new Error(
1745+
'This stack mounts no auth, so no caller can authenticate — and anonymous access to object '
1746+
+ 'data is always denied (#3963), which would leave the data API unusable.\n'
1747+
+ 'Fix it one of two ways:\n'
1748+
+ ` • enable auth — add the 'auth' tier (or mount AuthPlugin in \`plugins\`);\n`
1749+
+ ' • or serve without the data API — run with --no-server, or drop the REST/dispatcher plugins.\n'
1750+
+ "Publishing a genuinely public surface does not need anonymous data access: use a public form "
1751+
+ "view, a share link, or `book.audience: 'public'`.",
1752+
);
1753+
}
17461754

17471755
try {
17481756
const { createRestApiPlugin } = await import('@objectstack/rest');
17491757
await kernel.use(
1750-
createRestApiPlugin({ api: { api: { enableProjectScoping, projectResolution, requireAuth } } as any }),
1758+
createRestApiPlugin({ api: { api: { enableProjectScoping, projectResolution } } as any }),
17511759
);
17521760
trackPlugin('RestAPI');
17531761
} catch (e: any) {
@@ -1767,9 +1775,6 @@ export default class Serve extends Command {
17671775
createDispatcherPlugin({
17681776
scoping: { enableProjectScoping, projectResolution },
17691777
enforceProjectMembership,
1770-
// Keep the dispatcher's `auth: true` service routes (AI) in
1771-
// lockstep with the REST `/data` gate above — same `requireAuth`.
1772-
requireAuth,
17731778
observability,
17741779
}),
17751780
);

packages/cli/src/utils/merge-boot-config.test.ts

Lines changed: 12 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -7,28 +7,27 @@ import { mergeBootConfig } from './merge-boot-config.js';
77
const BOOT_API = { enableProjectScoping: false, projectResolution: 'none' } as const;
88

99
describe('mergeBootConfig (#4002)', () => {
10-
it('keeps the authored api keys the boot result does not set', () => {
10+
it('keeps an authored api key the boot result does not set', () => {
1111
const merged: any = mergeBootConfig(
12-
{ api: { requireAuth: false, enforceProjectMembership: false } },
12+
{ api: { enforceProjectMembership: false } },
1313
{ api: { ...BOOT_API }, plugins: [] },
1414
);
1515

16-
// The two live knobs the CLI reads a few lines later — both were dropped
17-
// by the old shallow spread.
18-
expect(merged.api.requireAuth).toBe(false);
16+
// `enforceProjectMembership` is a live knob the CLI reads a few lines
17+
// later — dropped by the old shallow spread, kept by the per-key merge.
1918
expect(merged.api.enforceProjectMembership).toBe(false);
2019
});
2120

2221
it('lets the boot result win on the keys it decides', () => {
2322
// Environment scoping is not the author's call on a standalone host.
2423
const merged: any = mergeBootConfig(
25-
{ api: { enableProjectScoping: true, projectResolution: 'auto', requireAuth: false } },
24+
{ api: { enableProjectScoping: true, projectResolution: 'auto', enforceProjectMembership: false } },
2625
{ api: { ...BOOT_API } },
2726
);
2827

2928
expect(merged.api.enableProjectScoping).toBe(false);
3029
expect(merged.api.projectResolution).toBe('none');
31-
expect(merged.api.requireAuth).toBe(false); // untouched by boot → survives
30+
expect(merged.api.enforceProjectMembership).toBe(false); // untouched by boot → survives
3231
});
3332

3433
it('still replaces every other top-level key wholesale', () => {
@@ -49,8 +48,8 @@ describe('mergeBootConfig (#4002)', () => {
4948
});
5049

5150
it('carries an api block through when only one side has one', () => {
52-
expect((mergeBootConfig({ api: { requireAuth: false } }, {}) as any).api)
53-
.toEqual({ requireAuth: false });
51+
expect((mergeBootConfig({ api: { enforceProjectMembership: false } }, {}) as any).api)
52+
.toEqual({ enforceProjectMembership: false });
5453
expect((mergeBootConfig({}, { api: { ...BOOT_API } }) as any).api)
5554
.toEqual({ ...BOOT_API });
5655
});
@@ -60,16 +59,16 @@ describe('mergeBootConfig (#4002)', () => {
6059
// character-indexed keys from a string spread.
6160
expect((mergeBootConfig({ api: 'nonsense' as any }, { api: { ...BOOT_API } }) as any).api)
6261
.toEqual({ ...BOOT_API });
63-
expect((mergeBootConfig({ api: { requireAuth: false } }, { api: null as any }) as any).api)
64-
.toEqual({ requireAuth: false });
62+
expect((mergeBootConfig({ api: { enforceProjectMembership: false } }, { api: null as any }) as any).api)
63+
.toEqual({ enforceProjectMembership: false });
6564
});
6665

6766
it('does not mutate either input', () => {
68-
const authored = { api: { requireAuth: false } };
67+
const authored = { api: { enforceProjectMembership: false } };
6968
const boot = { api: { ...BOOT_API } };
7069
mergeBootConfig(authored, boot);
7170

72-
expect(authored).toEqual({ api: { requireAuth: false } });
71+
expect(authored).toEqual({ api: { enforceProjectMembership: false } });
7372
expect(boot).toEqual({ api: { ...BOOT_API } });
7473
});
7574
});

packages/client/src/client.batch-transaction.test.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,17 @@ describe('data.batchTransaction (live Hono, #1604)', () => {
3737
beforeAll(async () => {
3838
kernel = new LiteKernel();
3939
kernel.use(new ObjectQLPlugin());
40+
// [#3963] The anonymous-deny gate is unconditional now, so the live-server
41+
// client suites need an authenticated session. Register a minimal auth
42+
// service that resolves a fixed user for every request.
43+
kernel.use({
44+
metadata: { name: 'test-auth', version: '1.0.0' },
45+
async init(ctx: any) {
46+
ctx.registerService('auth', {
47+
api: { getSession: async () => ({ user: { id: 'test-user' } }) },
48+
});
49+
},
50+
} as any);
4051

4152
kernel.use(
4253
new HonoServerPlugin({

packages/client/src/client.environment-scoping.test.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,17 @@ describe('Project-scoped REST routing (live Hono)', () => {
2828
beforeAll(async () => {
2929
kernel = new LiteKernel();
3030
kernel.use(new ObjectQLPlugin());
31+
// [#3963] The anonymous-deny gate is unconditional now, so the live-server
32+
// client suites need an authenticated session. Register a minimal auth
33+
// service that resolves a fixed user for every request.
34+
kernel.use({
35+
metadata: { name: 'test-auth', version: '1.0.0' },
36+
async init(ctx: any) {
37+
ctx.registerService('auth', {
38+
api: { getSession: async () => ({ user: { id: 'test-user' } }) },
39+
});
40+
},
41+
} as any);
3142

3243
const honoPlugin = new HonoServerPlugin({
3344
port: 0,

packages/client/src/client.hono.test.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,17 @@ describe('ObjectStackClient (with Hono Server)', () => {
1313
// 1. Setup Kernel
1414
kernel = new LiteKernel();
1515
kernel.use(new ObjectQLPlugin());
16+
// [#3963] The anonymous-deny gate is unconditional now, so the live-server
17+
// client suites need an authenticated session. Register a minimal auth
18+
// service that resolves a fixed user for every request.
19+
kernel.use({
20+
metadata: { name: 'test-auth', version: '1.0.0' },
21+
async init(ctx: any) {
22+
ctx.registerService('auth', {
23+
api: { getSession: async () => ({ user: { id: 'test-user' } }) },
24+
});
25+
},
26+
} as any);
1627

1728
// 2. Setup Hono Plugin
1829
// This suite exercises the CLIENT's data operations over the raw-hono
@@ -25,7 +36,6 @@ describe('ObjectStackClient (with Hono Server)', () => {
2536
const honoPlugin = new HonoServerPlugin({
2637
port: 0,
2738
registerStandardEndpoints: true,
28-
restConfig: { api: { requireAuth: false } } as any,
2939
});
3040
kernel.use(honoPlugin);
3141

packages/core/src/security/anonymous-deny.test.ts

Lines changed: 17 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -3,49 +3,48 @@
33
// #2567 Phase 2 — the shared anonymous-deny decision. These lock the exact
44
// contract every HTTP seam now delegates to, including the load-bearing
55
// `undefined`-path trap (a naive allowlist call would reopen GraphQL).
6+
//
7+
// [#3963] The `requireAuth` opt-out is gone: an anonymous, non-system caller
8+
// outside the control-plane allowlist is denied unconditionally. There is no
9+
// longer a posture that turns the decision off.
610

711
import { describe, it, expect } from 'vitest';
812
import { shouldDenyAnonymous, ANONYMOUS_DENY_BODY, ANONYMOUS_DENY_STATUS } from './anonymous-deny.js';
913

10-
describe('shouldDenyAnonymous — the shared HTTP anonymous-deny decision (#2567)', () => {
11-
it('no-op when requireAuth is off (demo / single-tenant)', () => {
12-
expect(shouldDenyAnonymous({ requireAuth: false })).toBe(false);
13-
expect(shouldDenyAnonymous({ requireAuth: undefined })).toBe(false);
14-
});
15-
16-
it('denies an anonymous caller under requireAuth', () => {
17-
expect(shouldDenyAnonymous({ requireAuth: true })).toBe(true);
14+
describe('shouldDenyAnonymous — the shared HTTP anonymous-deny decision (#2567, #3963)', () => {
15+
it('denies an anonymous caller (unconditionally — no opt-out)', () => {
16+
expect(shouldDenyAnonymous({})).toBe(true);
1817
});
1918

2019
it('passes an authenticated caller', () => {
21-
expect(shouldDenyAnonymous({ requireAuth: true, userId: 'u1' })).toBe(false);
20+
expect(shouldDenyAnonymous({ userId: 'u1' })).toBe(false);
2221
});
2322

2423
it('passes an internal system context', () => {
25-
expect(shouldDenyAnonymous({ requireAuth: true, isSystem: true })).toBe(false);
24+
expect(shouldDenyAnonymous({ isSystem: true })).toBe(false);
2625
});
2726

2827
it('passes an OPTIONS preflight even when anonymous', () => {
29-
expect(shouldDenyAnonymous({ requireAuth: true, method: 'OPTIONS' })).toBe(false);
30-
expect(shouldDenyAnonymous({ requireAuth: true, method: 'options' })).toBe(false);
28+
expect(shouldDenyAnonymous({ method: 'OPTIONS' })).toBe(false);
29+
expect(shouldDenyAnonymous({ method: 'options' })).toBe(false);
3130
});
3231

3332
it('exempts a real control-plane path (auth / health)', () => {
34-
expect(shouldDenyAnonymous({ requireAuth: true, path: '/api/v1/auth/login' })).toBe(false);
35-
expect(shouldDenyAnonymous({ requireAuth: true, path: '/api/v1/health' })).toBe(false);
33+
expect(shouldDenyAnonymous({ path: '/api/v1/auth/login' })).toBe(false);
34+
expect(shouldDenyAnonymous({ path: '/api/v1/health' })).toBe(false);
3635
});
3736

3837
it('denies a real data path', () => {
39-
expect(shouldDenyAnonymous({ requireAuth: true, path: '/api/v1/data/sys_user' })).toBe(true);
38+
expect(shouldDenyAnonymous({ path: '/api/v1/data/sys_user' })).toBe(true);
4039
});
4140

4241
// The trap: isAuthGateAllowlisted(undefined) === true. A body-routed seam
4342
// (GraphQL) passes no path; it MUST still deny anonymous, not fall through to
4443
// the allowlist. Guards against silently reopening #2567.
4544
it('denies when path is undefined/empty (body-routed seam — GraphQL trap guard)', () => {
46-
expect(shouldDenyAnonymous({ requireAuth: true, path: undefined })).toBe(true);
47-
expect(shouldDenyAnonymous({ requireAuth: true, path: null })).toBe(true);
48-
expect(shouldDenyAnonymous({ requireAuth: true, path: '' })).toBe(true);
45+
expect(shouldDenyAnonymous({ path: undefined })).toBe(true);
46+
expect(shouldDenyAnonymous({ path: null })).toBe(true);
47+
expect(shouldDenyAnonymous({ path: '' })).toBe(true);
4948
});
5049

5150
it('exposes a stable 401 body + status for seams to return', () => {

0 commit comments

Comments
 (0)