Skip to content

Commit e7f6539

Browse files
os-zhuangclaude
andauthored
feat(security): ADR-0056 D1 canonical OWD vocab + D2 anonymous-deny posture (#2065)
* feat(spec,sharing): ADR-0056 D1 — canonical OWD vocabulary on object.sharingModel Add the canonical OWD names (public_read / public_read_write) to the object.sharingModel enum alongside the legacy read/read_write/full aliases (non-breaking), and map them in the sharing runtime onto the three enforced behaviours. Unknown values stay enum-rejected (authoring-time fail-closed). Showcase announcement switched to canonical `public_read`, proven end-to-end. sharing-service 32 tests (+4 D1); OWD dogfood proofs 8/8; liveness green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XVdnfUAx85amkerym26vdx * 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 --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 3c1b717 commit e7f6539

8 files changed

Lines changed: 147 additions & 9 deletions

File tree

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
---
2+
"@objectstack/spec": minor
3+
"@objectstack/plugin-sharing": patch
4+
"@objectstack/example-showcase": patch
5+
---
6+
7+
feat(spec,sharing): canonical OWD vocabulary on `object.sharingModel` (ADR-0056 D1)
8+
9+
Reconciles the Org-Wide-Default naming so authors use ONE vocabulary. `object.sharingModel`
10+
now accepts the canonical OWD names — `private` | `public_read` | `public_read_write` |
11+
`controlled_by_parent` — alongside the legacy `read` / `read_write` / `full` aliases (kept,
12+
non-breaking). The sharing runtime maps them onto the three enforced behaviours
13+
(`public_read` ≡ legacy `read` = everyone reads / owner writes; `public_read_write` =
14+
unscoped). Unknown values remain rejected by the enum (authoring-time, fail-closed). The
15+
showcase announcement now declares the canonical `public_read`, exercised end-to-end by the
16+
public-read dogfood proof.
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).

examples/app-showcase/src/objects/announcement.object.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { ObjectSchema, Field } from '@objectstack/spec/data';
55
/**
66
* Team Announcement — the canonical PUBLIC-READ object (ADR-0056 OWD).
77
*
8-
* Declares `sharingModel: 'read'`: every member can READ every announcement,
8+
* Declares `sharingModel: 'public_read'`: every member can READ every announcement,
99
* but only the OWNER may edit or delete it (the engine derives "everyone reads,
1010
* owner writes" from the OWD baseline + the auto-stamped `owner_id`). No RLS
1111
* policy is authored. This is the sibling of `showcase_private_note`
@@ -20,8 +20,9 @@ export const Announcement = ObjectSchema.create({
2020
icon: 'megaphone',
2121
description: 'A team announcement everyone can read but only its owner can edit — `read` OWD (ADR-0056).',
2222

23-
// Everyone reads; owner writes. No RLS authored.
24-
sharingModel: 'read',
23+
// Everyone reads; owner writes. Canonical OWD name (ADR-0056 D1); `read` is
24+
// the legacy alias. No RLS authored.
25+
sharingModel: 'public_read',
2526

2627
fields: {
2728
title: Field.text({ label: 'Title', required: true, searchable: true, maxLength: 160 }),
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/plugins/plugin-sharing/src/sharing-service.test.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,18 @@ const PUBLIC_SCHEMA = {
100100
fields: { id: {}, name: {}, owner_id: {} },
101101
};
102102

103+
// ADR-0056 D1 — canonical OWD vocabulary maps onto the same enforced behaviours.
104+
const CANON_PUBLIC_READ_SCHEMA = {
105+
name: 'kbarticle',
106+
sharingModel: 'public_read', // canonical alias of legacy `read`
107+
fields: { id: {}, name: {}, owner_id: {} },
108+
};
109+
const CANON_PUBLIC_RW_SCHEMA = {
110+
name: 'whiteboard',
111+
sharingModel: 'public_read_write', // canonical alias of "public" (no record filter)
112+
fields: { id: {}, name: {}, owner_id: {} },
113+
};
114+
103115
const ORPHAN_SCHEMA = {
104116
name: 'note',
105117
sharingModel: 'private',
@@ -118,6 +130,8 @@ describe('SharingService.buildReadFilter', () => {
118130
lead: LEAD_SCHEMA,
119131
task: PUBLIC_SCHEMA,
120132
note: ORPHAN_SCHEMA,
133+
kbarticle: CANON_PUBLIC_READ_SCHEMA,
134+
whiteboard: CANON_PUBLIC_RW_SCHEMA,
121135
sys_record_share: { name: 'sys_record_share' },
122136
});
123137
svc = new SharingService({ engine });
@@ -139,6 +153,14 @@ describe('SharingService.buildReadFilter', () => {
139153
expect(await svc.buildReadFilter('lead', { userId: 'u1' })).toBeNull();
140154
});
141155

156+
it('canonical public_read reads like `read` (everyone reads → no filter) [ADR-0056 D1]', async () => {
157+
expect(await svc.buildReadFilter('kbarticle', { userId: 'u1' })).toBeNull();
158+
});
159+
160+
it('canonical public_read_write is unscoped on read [ADR-0056 D1]', async () => {
161+
expect(await svc.buildReadFilter('whiteboard', { userId: 'u1' })).toBeNull();
162+
});
163+
142164
it('returns null for objects without owner_id even when private', async () => {
143165
expect(await svc.buildReadFilter('note', { userId: 'u1' })).toBeNull();
144166
});
@@ -171,6 +193,8 @@ describe('SharingService.canEdit', () => {
171193
account: ACCOUNT_SCHEMA,
172194
lead: LEAD_SCHEMA,
173195
task: PUBLIC_SCHEMA,
196+
kbarticle: CANON_PUBLIC_READ_SCHEMA,
197+
whiteboard: CANON_PUBLIC_RW_SCHEMA,
174198
sys_record_share: { name: 'sys_record_share' },
175199
});
176200
svc = new SharingService({ engine });
@@ -181,6 +205,9 @@ describe('SharingService.canEdit', () => {
181205
engine._tables.lead = [
182206
{ id: 'l1', name: 'Lead1', owner_id: 'alice' },
183207
];
208+
engine._tables.kbarticle = [
209+
{ id: 'k1', name: 'KB1', owner_id: 'alice' },
210+
];
184211
});
185212

186213
it('returns true for system context', async () => {
@@ -213,6 +240,15 @@ describe('SharingService.canEdit', () => {
213240
expect(await svc.canEdit('lead', 'l1', { userId: 'alice' })).toBe(true);
214241
expect(await svc.canEdit('lead', 'l1', { userId: 'bob' })).toBe(false);
215242
});
243+
244+
it('canonical public_read gates writes to the owner [ADR-0056 D1]', async () => {
245+
expect(await svc.canEdit('kbarticle', 'k1', { userId: 'alice' })).toBe(true);
246+
expect(await svc.canEdit('kbarticle', 'k1', { userId: 'bob' })).toBe(false);
247+
});
248+
249+
it('canonical public_read_write lets anyone write [ADR-0056 D1]', async () => {
250+
expect(await svc.canEdit('whiteboard', 'anything', { userId: 'bob' })).toBe(true);
251+
});
216252
});
217253

218254
describe('SharingService.grant / listShares / revoke', () => {

packages/plugins/plugin-sharing/src/sharing-service.ts

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -45,15 +45,21 @@ const SYSTEM_CTX = { isSystem: true, roles: [], permissions: [] } as const;
4545
const OWNER_FIELD = 'owner_id';
4646

4747
/**
48-
* Effective sharing model. Anything other than `private` / `read` is
49-
* treated as public — that includes objects that don't declare
50-
* `sharingModel` at all, so existing CRM behaviour is preserved
51-
* until an admin opts an object in.
48+
* Effective sharing model — collapses the authorable OWD vocabulary onto the
49+
* three behaviours this service enforces (ADR-0056 D1):
50+
* - `private` → owner-only read + write
51+
* - `public_read` / legacy `read` → everyone reads, owner writes
52+
* - everything else → public (no record-level filter)
53+
*
54+
* "Everything else" covers the canonical `public_read_write`, the legacy
55+
* `read_write` / `full` aliases, `controlled_by_parent` (scoped separately by
56+
* the security plugin), and objects that declare no `sharingModel` at all — so
57+
* existing behaviour is preserved until an admin opts an object in.
5258
*/
5359
function effectiveSharingModel(schema: any): 'private' | 'read' | 'public' {
5460
const m = schema?.sharingModel ?? schema?.security?.sharingModel;
5561
if (m === 'private') return 'private';
56-
if (m === 'read') return 'read';
62+
if (m === 'read' || m === 'public_read') return 'read';
5763
return 'public';
5864
}
5965

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;

packages/spec/src/data/object.zod.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -598,7 +598,7 @@ const ObjectSchemaBase = z.object({
598598
* ids)` on reads and requires master edit-access on by-id writes. No RLS policy
599599
* is authored — the inheritance is derived from the relationship.
600600
*/
601-
sharingModel: z.enum(['private', 'read', 'read_write', 'full', 'controlled_by_parent']).optional().describe('Default sharing model'),
601+
sharingModel: z.enum(['private', 'public_read', 'public_read_write', 'controlled_by_parent', 'read', 'read_write', 'full']).optional().describe('Org-Wide Default record visibility (OWD). Canonical: private (owner-only) | public_read (everyone reads, owner writes) | public_read_write (everyone reads+writes) | controlled_by_parent (derived from the master record). Legacy aliases: read=public_read, read_write/full=public_read_write. ADR-0056 D1.'),
602602

603603
/**
604604
* Public Share-Link Policy

0 commit comments

Comments
 (0)