diff --git a/.changeset/adr-0056-d1-owd-vocab.md b/.changeset/adr-0056-d1-owd-vocab.md new file mode 100644 index 0000000000..3ce6f7f575 --- /dev/null +++ b/.changeset/adr-0056-d1-owd-vocab.md @@ -0,0 +1,16 @@ +--- +"@objectstack/spec": minor +"@objectstack/plugin-sharing": patch +"@objectstack/example-showcase": patch +--- + +feat(spec,sharing): canonical OWD vocabulary on `object.sharingModel` (ADR-0056 D1) + +Reconciles the Org-Wide-Default naming so authors use ONE vocabulary. `object.sharingModel` +now accepts the canonical OWD names — `private` | `public_read` | `public_read_write` | +`controlled_by_parent` — alongside the legacy `read` / `read_write` / `full` aliases (kept, +non-breaking). The sharing runtime maps them onto the three enforced behaviours +(`public_read` ≡ legacy `read` = everyone reads / owner writes; `public_read_write` = +unscoped). Unknown values remain rejected by the enum (authoring-time, fail-closed). The +showcase announcement now declares the canonical `public_read`, exercised end-to-end by the +public-read dogfood proof. diff --git a/.changeset/adr-0056-d2-anonymous-deny.md b/.changeset/adr-0056-d2-anonymous-deny.md new file mode 100644 index 0000000000..81f396d645 --- /dev/null +++ b/.changeset/adr-0056-d2-anonymous-deny.md @@ -0,0 +1,15 @@ +--- +"@objectstack/rest": patch +--- + +feat(rest): warn on fail-open anonymous posture (ADR-0056 D2, warn→enforce) + +Secure-by-default work for the data API. The deny capability already exists +(`api.requireAuth=true` rejects anonymous via `enforceAuth`, and share-link / +`guest_portal` / control-plane routes are exempt) — but the **default is fail-open** +(`requireAuth=false`), so an object with no OWD/RLS is world-readable with no signal. +This adds a boot-time WARN when running in that posture, making it explicit +(consistent with D4/D8 honesty). The global default is deliberately NOT flipped here +— that is a release-gated decision; flipping it would 401 deployments that rely on +anonymous reads. Proven by the `showcase-anonymous-deny` dogfood test (anonymous +read+write → 401, authenticated → 200, control-plane open). diff --git a/examples/app-showcase/src/objects/announcement.object.ts b/examples/app-showcase/src/objects/announcement.object.ts index 071d414e8e..de216597d2 100644 --- a/examples/app-showcase/src/objects/announcement.object.ts +++ b/examples/app-showcase/src/objects/announcement.object.ts @@ -5,7 +5,7 @@ import { ObjectSchema, Field } from '@objectstack/spec/data'; /** * Team Announcement — the canonical PUBLIC-READ object (ADR-0056 OWD). * - * Declares `sharingModel: 'read'`: every member can READ every announcement, + * Declares `sharingModel: 'public_read'`: every member can READ every announcement, * but only the OWNER may edit or delete it (the engine derives "everyone reads, * owner writes" from the OWD baseline + the auto-stamped `owner_id`). No RLS * policy is authored. This is the sibling of `showcase_private_note` @@ -20,8 +20,9 @@ export const Announcement = ObjectSchema.create({ icon: 'megaphone', description: 'A team announcement everyone can read but only its owner can edit — `read` OWD (ADR-0056).', - // Everyone reads; owner writes. No RLS authored. - sharingModel: 'read', + // Everyone reads; owner writes. Canonical OWD name (ADR-0056 D1); `read` is + // the legacy alias. No RLS authored. + sharingModel: 'public_read', fields: { title: Field.text({ label: 'Title', required: true, searchable: true, maxLength: 160 }), diff --git a/packages/dogfood/test/showcase-anonymous-deny.dogfood.test.ts b/packages/dogfood/test/showcase-anonymous-deny.dogfood.test.ts new file mode 100644 index 0000000000..43eba8e53b --- /dev/null +++ b/packages/dogfood/test/showcase-anonymous-deny.dogfood.test.ts @@ -0,0 +1,50 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// ADR-0056 D2 — secure-by-default (anonymous deny) posture, proven on the real +// showcase HTTP stack. With `requireAuth` on, an UNAUTHENTICATED request to the +// data API is rejected (401), while authenticated members are unaffected and the +// control-plane (`/auth/*`) stays open (sign-up itself is an anonymous call). This +// is the enforcement capability D2 builds on; the framework does NOT flip the +// global default — it makes the deny posture available + warns when it is off. + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import showcaseStack from '@objectstack/example-showcase'; +import { bootStack, type VerifyStack } from '@objectstack/verify'; + +const OBJ = '/data/showcase_private_note'; + +describe('showcase: anonymous default-deny (ADR-0056 D2)', () => { + let stack: VerifyStack; + let memberToken: string; + + beforeAll(async () => { + stack = await bootStack(showcaseStack); // harness runs requireAuth: true + await stack.signIn(); + memberToken = await stack.signUp('d2-member@verify.test'); // anonymous /auth call → proves control-plane is open + }, 60_000); + + afterAll(async () => { await stack?.stop(); }); + + it('control-plane is open for anonymous (sign-up succeeded without a token)', () => { + expect(memberToken, 'anonymous /auth/sign-up returned a token').toBeTruthy(); + }); + + it('anonymous READ of the data API is denied (401)', async () => { + const r = await stack.api(OBJ, { method: 'GET' }); + expect(r.status, 'unauthenticated data read must be 401').toBe(401); + }); + + it('anonymous WRITE of the data API is denied (401)', async () => { + const w = await stack.api(OBJ, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ title: 'anon' }), + }); + expect(w.status, 'unauthenticated data write must be 401').toBe(401); + }); + + it('an AUTHENTICATED member is allowed (deny targets anonymity, not the API)', async () => { + const ok = await stack.apiAs(memberToken, 'GET', OBJ); + expect(ok.status).toBe(200); + }); +}); diff --git a/packages/plugins/plugin-sharing/src/sharing-service.test.ts b/packages/plugins/plugin-sharing/src/sharing-service.test.ts index a785cd22ab..dbba8ea9bf 100644 --- a/packages/plugins/plugin-sharing/src/sharing-service.test.ts +++ b/packages/plugins/plugin-sharing/src/sharing-service.test.ts @@ -100,6 +100,18 @@ const PUBLIC_SCHEMA = { fields: { id: {}, name: {}, owner_id: {} }, }; +// ADR-0056 D1 — canonical OWD vocabulary maps onto the same enforced behaviours. +const CANON_PUBLIC_READ_SCHEMA = { + name: 'kbarticle', + sharingModel: 'public_read', // canonical alias of legacy `read` + fields: { id: {}, name: {}, owner_id: {} }, +}; +const CANON_PUBLIC_RW_SCHEMA = { + name: 'whiteboard', + sharingModel: 'public_read_write', // canonical alias of "public" (no record filter) + fields: { id: {}, name: {}, owner_id: {} }, +}; + const ORPHAN_SCHEMA = { name: 'note', sharingModel: 'private', @@ -118,6 +130,8 @@ describe('SharingService.buildReadFilter', () => { lead: LEAD_SCHEMA, task: PUBLIC_SCHEMA, note: ORPHAN_SCHEMA, + kbarticle: CANON_PUBLIC_READ_SCHEMA, + whiteboard: CANON_PUBLIC_RW_SCHEMA, sys_record_share: { name: 'sys_record_share' }, }); svc = new SharingService({ engine }); @@ -139,6 +153,14 @@ describe('SharingService.buildReadFilter', () => { expect(await svc.buildReadFilter('lead', { userId: 'u1' })).toBeNull(); }); + it('canonical public_read reads like `read` (everyone reads → no filter) [ADR-0056 D1]', async () => { + expect(await svc.buildReadFilter('kbarticle', { userId: 'u1' })).toBeNull(); + }); + + it('canonical public_read_write is unscoped on read [ADR-0056 D1]', async () => { + expect(await svc.buildReadFilter('whiteboard', { userId: 'u1' })).toBeNull(); + }); + it('returns null for objects without owner_id even when private', async () => { expect(await svc.buildReadFilter('note', { userId: 'u1' })).toBeNull(); }); @@ -171,6 +193,8 @@ describe('SharingService.canEdit', () => { account: ACCOUNT_SCHEMA, lead: LEAD_SCHEMA, task: PUBLIC_SCHEMA, + kbarticle: CANON_PUBLIC_READ_SCHEMA, + whiteboard: CANON_PUBLIC_RW_SCHEMA, sys_record_share: { name: 'sys_record_share' }, }); svc = new SharingService({ engine }); @@ -181,6 +205,9 @@ describe('SharingService.canEdit', () => { engine._tables.lead = [ { id: 'l1', name: 'Lead1', owner_id: 'alice' }, ]; + engine._tables.kbarticle = [ + { id: 'k1', name: 'KB1', owner_id: 'alice' }, + ]; }); it('returns true for system context', async () => { @@ -213,6 +240,15 @@ describe('SharingService.canEdit', () => { expect(await svc.canEdit('lead', 'l1', { userId: 'alice' })).toBe(true); expect(await svc.canEdit('lead', 'l1', { userId: 'bob' })).toBe(false); }); + + it('canonical public_read gates writes to the owner [ADR-0056 D1]', async () => { + expect(await svc.canEdit('kbarticle', 'k1', { userId: 'alice' })).toBe(true); + expect(await svc.canEdit('kbarticle', 'k1', { userId: 'bob' })).toBe(false); + }); + + it('canonical public_read_write lets anyone write [ADR-0056 D1]', async () => { + expect(await svc.canEdit('whiteboard', 'anything', { userId: 'bob' })).toBe(true); + }); }); describe('SharingService.grant / listShares / revoke', () => { diff --git a/packages/plugins/plugin-sharing/src/sharing-service.ts b/packages/plugins/plugin-sharing/src/sharing-service.ts index b5124b4853..dec4714bc7 100644 --- a/packages/plugins/plugin-sharing/src/sharing-service.ts +++ b/packages/plugins/plugin-sharing/src/sharing-service.ts @@ -45,15 +45,21 @@ const SYSTEM_CTX = { isSystem: true, roles: [], permissions: [] } as const; const OWNER_FIELD = 'owner_id'; /** - * Effective sharing model. Anything other than `private` / `read` is - * treated as public — that includes objects that don't declare - * `sharingModel` at all, so existing CRM behaviour is preserved - * until an admin opts an object in. + * Effective sharing model — collapses the authorable OWD vocabulary onto the + * three behaviours this service enforces (ADR-0056 D1): + * - `private` → owner-only read + write + * - `public_read` / legacy `read` → everyone reads, owner writes + * - everything else → public (no record-level filter) + * + * "Everything else" covers the canonical `public_read_write`, the legacy + * `read_write` / `full` aliases, `controlled_by_parent` (scoped separately by + * the security plugin), and objects that declare no `sharingModel` at all — so + * existing behaviour is preserved until an admin opts an object in. */ function effectiveSharingModel(schema: any): 'private' | 'read' | 'public' { const m = schema?.sharingModel ?? schema?.security?.sharingModel; if (m === 'private') return 'private'; - if (m === 'read') return 'read'; + if (m === 'read' || m === 'public_read') return 'read'; return 'public'; } diff --git a/packages/rest/src/rest-api-plugin.ts b/packages/rest/src/rest-api-plugin.ts index 01b9c9ecfc..ac07b9942c 100644 --- a/packages/rest/src/rest-api-plugin.ts +++ b/packages/rest/src/rest-api-plugin.ts @@ -190,6 +190,20 @@ export function createRestApiPlugin(config: RestApiPluginConfig = {}): Plugin { restServer.registerRoutes(); ctx.logger.info('REST API successfully registered'); + + // ADR-0056 D2 (warn → enforce): surface the fail-open posture. + // When `requireAuth` is off, anonymous requests reach the data API + // and read any object with no OWD/RLS — secure-by-default would deny + // them and route public access through share-links / `publicSharing`. + // We do NOT flip the default here (it would break deployments that + // rely on anonymous reads); we make the posture explicit instead. + if (!(config.api as any)?.requireAuth) { + ctx.logger.warn( + '[security] anonymous access to the data API is ALLOWED (api.requireAuth=false) — ' + + 'objects without OWD/RLS are world-readable. For secure-by-default set ' + + 'api.requireAuth=true and expose public records via share-links / publicSharing (ADR-0056 D2).', + ); + } } catch (err: any) { ctx.logger.error('Failed to register REST API routes', { error: err.message } as any); throw err; diff --git a/packages/spec/src/data/object.zod.ts b/packages/spec/src/data/object.zod.ts index 4dc31cdcfe..8a9feb5f61 100644 --- a/packages/spec/src/data/object.zod.ts +++ b/packages/spec/src/data/object.zod.ts @@ -598,7 +598,7 @@ const ObjectSchemaBase = z.object({ * ids)` on reads and requires master edit-access on by-id writes. No RLS policy * is authored — the inheritance is derived from the relationship. */ - sharingModel: z.enum(['private', 'read', 'read_write', 'full', 'controlled_by_parent']).optional().describe('Default sharing model'), + 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.'), /** * Public Share-Link Policy