Skip to content

Commit 37e9acb

Browse files
os-zhuangclaude
andauthored
feat(app-showcase): ADR-0056 declarative OWD scenarios (private + public-read) + proofs (#2062)
* feat(app-showcase): declarative private-OWD scenario + dogfood proof (ADR-0056) First execution increment of ADR-0056: demonstrate the declarative owner-private capability on the real showcase. `showcase_private_note` declares `sharingModel: 'private'` + an `owner_id` field and NOTHING else — no RLS, no predicate, no permission-set rule. The engine derives owner scoping from the OWD baseline + auto-stamped owner_id. Proof (`showcase-private-owd.dogfood.test.ts`, 5/5, real HTTP): two plain sign-ups each create notes; each lists only their own, cannot read/write another owner's by id, can write their own — owner_id auto-stamped, zero authored RLS. Full dogfood 11 files / 86 tests; showcase units 20. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XVdnfUAx85amkerym26vdx * feat(app-showcase): public-read OWD scenario + proof (ADR-0056) Sibling of the private-note scenario: showcase_announcement declares sharingModel: 'read' (everyone reads, only owner edits) — no RLS authored. Proof showcase-public-read-owd (3 tests) shows cross-owner READ allowed but cross-owner WRITE denied, contrasting the private note's hidden rows. 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 8a3451c commit 37e9acb

6 files changed

Lines changed: 235 additions & 0 deletions

File tree

.changeset/showcase-private-owd.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
---
2+
"@objectstack/example-showcase": minor
3+
---
4+
5+
feat(app-showcase): declarative OWD scenarios — owner-private + public-read (ADR-0056)
6+
7+
Adds the two canonical Org-Wide-Default scenarios, each declaring its access policy
8+
in ONE word with no authored RLS:
9+
10+
- `showcase_private_note``sharingModel: 'private'`: a user sees and edits only
11+
the notes they own (owner-only read + write).
12+
- `showcase_announcement``sharingModel: 'read'`: every member reads every
13+
announcement, but only the owner may edit/delete it (public-read).
14+
15+
Both derive scoping from the OWD baseline + the auto-stamped `owner_id` — the
16+
declarative counterpart to the invoice's hand-written `owner = current_user.email`
17+
escape-hatch. Proven end-to-end (two users, real HTTP) by the new
18+
`showcase-private-owd` and `showcase-public-read-owd` dogfood tests, which together
19+
demonstrate the OWD read-visibility axis (`private` hides others' rows; `read`
20+
shows them but still protects writes).
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import { ObjectSchema, Field } from '@objectstack/spec/data';
4+
5+
/**
6+
* Team Announcement — the canonical PUBLIC-READ object (ADR-0056 OWD).
7+
*
8+
* Declares `sharingModel: 'read'`: every member can READ every announcement,
9+
* but only the OWNER may edit or delete it (the engine derives "everyone reads,
10+
* owner writes" from the OWD baseline + the auto-stamped `owner_id`). No RLS
11+
* policy is authored. This is the sibling of `showcase_private_note`
12+
* (`sharingModel: 'private'`, owner-only read): together the two objects
13+
* demonstrate the OWD read-visibility axis — `private` hides others' rows,
14+
* `read` shows them but still protects writes.
15+
*/
16+
export const Announcement = ObjectSchema.create({
17+
name: 'showcase_announcement',
18+
label: 'Announcement',
19+
pluralLabel: 'Announcements',
20+
icon: 'megaphone',
21+
description: 'A team announcement everyone can read but only its owner can edit — `read` OWD (ADR-0056).',
22+
23+
// Everyone reads; owner writes. No RLS authored.
24+
sharingModel: 'read',
25+
26+
fields: {
27+
title: Field.text({ label: 'Title', required: true, searchable: true, maxLength: 160 }),
28+
body: Field.text({ label: 'Body', maxLength: 2000 }),
29+
owner_id: Field.lookup('sys_user', { label: 'Owner' }),
30+
},
31+
});

examples/app-showcase/src/objects/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,3 +8,5 @@ export { Team, ProjectMembership } from './team.object.js';
88
export { Product, Invoice, InvoiceLine } from './invoice.object.js';
99
export { FieldZoo } from './field-zoo.object.js';
1010
export { Preference } from './preference.object.js';
11+
export { PrivateNote } from './private-note.object.js';
12+
export { Announcement } from './announcement.object.js';
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import { ObjectSchema, Field } from '@objectstack/spec/data';
4+
5+
/**
6+
* Personal Note — the canonical OWNER-PRIVATE object (ADR-0056, declarative OWD).
7+
*
8+
* It declares `sharingModel: 'private'` and nothing else: no hand-written RLS
9+
* policy, no owner predicate, no permission-set rule. The engine derives owner
10+
* scoping from the Org-Wide-Default baseline + the auto-stamped `owner_id` — a
11+
* user sees and edits only the notes they own.
12+
*
13+
* This is the declarative counterpart to the invoice's hand-written
14+
* `owner = current_user.email` escape-hatch (PR #2054): for plain "my records
15+
* are mine" ownership, an object declares ONE WORD and the platform enforces it.
16+
* The corresponding dogfood proof (`showcase-private-owd.dogfood.test.ts`) shows
17+
* two users each seeing only their own notes through the real HTTP stack.
18+
*/
19+
export const PrivateNote = ObjectSchema.create({
20+
name: 'showcase_private_note',
21+
label: 'Personal Note',
22+
pluralLabel: 'Personal Notes',
23+
icon: 'lock',
24+
description: 'A private journal entry visible only to its owner — declarative `private` OWD (ADR-0056).',
25+
26+
// The entire access policy: owner-private. No RLS authored anywhere.
27+
sharingModel: 'private',
28+
29+
fields: {
30+
title: Field.text({ label: 'Title', required: true, searchable: true, maxLength: 160 }),
31+
body: Field.text({ label: 'Body', maxLength: 2000 }),
32+
pinned: Field.boolean({ label: 'Pinned', defaultValue: false }),
33+
// Owner anchor — auto-stamped to the creating user on insert; the `private`
34+
// OWD reads it to scope visibility. Authors declare the field; the engine
35+
// fills it (no manual assignment, no predicate).
36+
owner_id: Field.lookup('sys_user', { label: 'Owner' }),
37+
},
38+
});
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// ADR-0056 — declarative `private` OWD proof on the REAL showcase app.
4+
// `showcase_private_note` declares `sharingModel: 'private'` and NOTHING else:
5+
// no RLS policy, no owner predicate, no permission-set rule. Two plain sign-ups
6+
// (governed only by the default member set) each create notes; the engine scopes
7+
// every read/write to the owner purely from the OWD baseline + the auto-stamped
8+
// `owner_id`. This is the canonical "declare one word, get owner isolation"
9+
// capability — proven end-to-end through the real HTTP stack.
10+
11+
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
12+
import showcaseStack from '@objectstack/example-showcase';
13+
import { bootStack, type VerifyStack } from '@objectstack/verify';
14+
15+
const OBJ = '/data/showcase_private_note';
16+
const idOf = (b: any) => b?.id ?? b?.record?.id ?? b?.data?.id ?? b?.recordId;
17+
18+
describe('showcase: declarative private OWD (ADR-0056)', () => {
19+
let stack: VerifyStack;
20+
let aToken: string;
21+
let bToken: string;
22+
let aNoteId: string;
23+
let bNoteId: string;
24+
25+
beforeAll(async () => {
26+
stack = await bootStack(showcaseStack);
27+
await stack.signIn(); // seed dev admin (first user)
28+
aToken = await stack.signUp('owd-alice@verify.test');
29+
bToken = await stack.signUp('owd-bob@verify.test');
30+
31+
const a1 = await stack.apiAs(aToken, 'POST', OBJ, { title: 'Alice private 1', body: 'a-body' });
32+
expect(a1.status, 'alice creates note').toBeLessThan(300);
33+
aNoteId = idOf(await a1.json());
34+
await stack.apiAs(aToken, 'POST', OBJ, { title: 'Alice private 2' });
35+
36+
const b1 = await stack.apiAs(bToken, 'POST', OBJ, { title: 'Bob private 1' });
37+
expect(b1.status, 'bob creates note').toBeLessThan(300);
38+
bNoteId = idOf(await b1.json());
39+
40+
expect(aNoteId, 'alice note id').toBeTruthy();
41+
expect(bNoteId, 'bob note id').toBeTruthy();
42+
}, 60_000);
43+
44+
afterAll(async () => { await stack?.stop(); });
45+
46+
it('owner_id is auto-stamped (no manual assignment, no predicate)', async () => {
47+
const ql: any = await stack.kernel.getServiceAsync('objectql');
48+
const row = await ql.findOne('showcase_private_note', { where: { id: aNoteId }, context: { isSystem: true } });
49+
expect(row?.owner_id, 'owner_id stamped to a real user id').toBeTruthy();
50+
});
51+
52+
it('a member LISTS only the notes they own', async () => {
53+
const r = await stack.apiAs(aToken, 'GET', OBJ);
54+
expect(r.status).toBe(200);
55+
const body: any = await r.json();
56+
const titles: string[] = (body.records ?? body.data ?? body ?? []).map((x: any) => x.title);
57+
expect(titles).toContain('Alice private 1');
58+
expect(titles).toContain('Alice private 2');
59+
expect(titles).not.toContain('Bob private 1'); // owner isolation, no RLS authored
60+
});
61+
62+
it('a member cannot READ another owner’s note by id', async () => {
63+
const r = await stack.apiAs(aToken, 'GET', `${OBJ}/${bNoteId}`);
64+
expect(r.status, 'alice must not read bob note').not.toBe(200);
65+
});
66+
67+
it('a member cannot WRITE another owner’s note, but can write their own', async () => {
68+
const foreign = await stack.apiAs(aToken, 'PATCH', `${OBJ}/${bNoteId}`, { body: 'hacked' });
69+
expect(foreign.status, 'alice must not edit bob note').not.toBeLessThan(300);
70+
71+
const own = await stack.apiAs(aToken, 'PATCH', `${OBJ}/${aNoteId}`, { body: 'updated' });
72+
expect(own.status, 'alice edits her own note').toBeLessThan(300);
73+
});
74+
75+
it('the OTHER member is symmetric (bob sees only his own)', async () => {
76+
const r = await stack.apiAs(bToken, 'GET', OBJ);
77+
const body: any = await r.json();
78+
const titles: string[] = (body.records ?? body.data ?? body ?? []).map((x: any) => x.title);
79+
expect(titles).toContain('Bob private 1');
80+
expect(titles).not.toContain('Alice private 1');
81+
const foreign = await stack.apiAs(bToken, 'GET', `${OBJ}/${aNoteId}`);
82+
expect(foreign.status).not.toBe(200);
83+
});
84+
});
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// ADR-0056 — `read` (public-read) OWD proof on the REAL showcase app.
4+
// `showcase_announcement` declares `sharingModel: 'read'` and nothing else: every
5+
// member READS every announcement, but only the OWNER may edit/delete it — derived
6+
// from the OWD baseline + auto-stamped `owner_id`, no RLS authored. This is the
7+
// sibling of the `private` proof: same owner-write protection, but rows are
8+
// VISIBLE across owners (the read-visibility axis of OWD).
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_announcement';
15+
const idOf = (b: any) => b?.id ?? b?.record?.id ?? b?.data?.id ?? b?.recordId;
16+
17+
describe('showcase: public-read OWD (ADR-0056)', () => {
18+
let stack: VerifyStack;
19+
let aToken: string;
20+
let bToken: string;
21+
let aAnnId: string;
22+
23+
beforeAll(async () => {
24+
stack = await bootStack(showcaseStack);
25+
await stack.signIn();
26+
aToken = await stack.signUp('pr-alice@verify.test');
27+
bToken = await stack.signUp('pr-bob@verify.test');
28+
29+
const a = await stack.apiAs(aToken, 'POST', OBJ, { title: 'Alice announces', body: 'hello team' });
30+
expect(a.status).toBeLessThan(300);
31+
aAnnId = idOf(await a.json());
32+
expect(aAnnId).toBeTruthy();
33+
}, 60_000);
34+
35+
afterAll(async () => { await stack?.stop(); });
36+
37+
it('every member READS another owner’s announcement (public-read)', async () => {
38+
const byId = await stack.apiAs(bToken, 'GET', `${OBJ}/${aAnnId}`);
39+
expect(byId.status, 'bob reads alice announcement by id').toBe(200);
40+
41+
const list = await stack.apiAs(bToken, 'GET', OBJ);
42+
const titles: string[] = ((await list.json()).records ?? []).map((x: any) => x.title);
43+
expect(titles, 'bob sees alice announcement in the list').toContain('Alice announces');
44+
});
45+
46+
it('but only the OWNER may edit it', async () => {
47+
const foreign = await stack.apiAs(bToken, 'PATCH', `${OBJ}/${aAnnId}`, { body: 'tampered' });
48+
expect(foreign.status, 'bob must not edit alice announcement').not.toBeLessThan(300);
49+
50+
const own = await stack.apiAs(aToken, 'PATCH', `${OBJ}/${aAnnId}`, { body: 'edited by owner' });
51+
expect(own.status, 'alice edits her own announcement').toBeLessThan(300);
52+
});
53+
54+
it('contrast with private: read-visibility is the distinguishing axis', async () => {
55+
// (sanity) the announcement is readable cross-owner — the very thing the
56+
// private note forbids — confirming `read` vs `private` are distinct OWDs.
57+
const r = await stack.apiAs(bToken, 'GET', `${OBJ}/${aAnnId}`);
58+
expect(r.status).toBe(200);
59+
});
60+
});

0 commit comments

Comments
 (0)