Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions .changeset/adr-0056-d1-owd-vocab.md
Original file line number Diff line number Diff line change
@@ -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.
15 changes: 15 additions & 0 deletions .changeset/adr-0056-d2-anonymous-deny.md
Original file line number Diff line number Diff line change
@@ -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).
7 changes: 4 additions & 3 deletions examples/app-showcase/src/objects/announcement.object.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand All @@ -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 }),
Expand Down
50 changes: 50 additions & 0 deletions packages/dogfood/test/showcase-anonymous-deny.dogfood.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
36 changes: 36 additions & 0 deletions packages/plugins/plugin-sharing/src/sharing-service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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 });
Expand All @@ -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();
});
Expand Down Expand Up @@ -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 });
Expand All @@ -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 () => {
Expand Down Expand Up @@ -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', () => {
Expand Down
16 changes: 11 additions & 5 deletions packages/plugins/plugin-sharing/src/sharing-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
}

Expand Down
14 changes: 14 additions & 0 deletions packages/rest/src/rest-api-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
2 changes: 1 addition & 1 deletion packages/spec/src/data/object.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading