Skip to content

Commit 01917c2

Browse files
os-zhuangclaude
andauthored
feat(security): ADR-0090 P2 — everyone/guest audience anchors, additive baseline, anchor binding gate (#2708)
* feat(security)!: ADR-0090 P2 — everyone/guest audience anchors, additive baseline, anchor binding gate Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012oLzaP8n7A3YKFmgaHWC8H * chore(spec): regenerate API-surface snapshot for the P2 audience-anchor exports (6 additions, 0 breaking) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012oLzaP8n7A3YKFmgaHWC8H * fix(security): enforce the RLS `positions` applicability domain; scope the #1985 owner policies to org_member The additive `everyone` baseline (P2) surfaced a latent hole: the policy `positions` field was an UNENFORCED property (ADR-0049 class) — a members-only restriction inside the baseline set silently bound everyone who resolved it, which pre-anchor meant "users with no other grants" and post-anchor meant "every authenticated principal" (the two-doors dogfood regression: an admin editing an env-authored set hit the baseline's created_by RLS and got 403). getApplicablePolicies now filters by the caller's held positions when a policy declares a domain, and the two #1985 owner-scoped write/delete policies in member_default declare `positions: ['org_member']` — the rank-and-file identity, matching the pre-anchor behavior where org/platform admins simply never resolved this set. tenant_isolation stays undomained (it is correct for every principal). Dogfood 39 files / 191 tests green; plugin-security 198 green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012oLzaP8n7A3YKFmgaHWC8H --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 6d83431 commit 01917c2

13 files changed

Lines changed: 370 additions & 17 deletions
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
---
2+
'@objectstack/spec': minor
3+
'@objectstack/core': minor
4+
'@objectstack/runtime': minor
5+
'@objectstack/plugin-security': minor
6+
---
7+
8+
ADR-0090 P2 — audience anchors: `everyone`/`guest` builtin positions.
9+
10+
- `EVERYONE_POSITION` / `GUEST_POSITION` constants in `@objectstack/spec`;
11+
both anchors seeded (system-managed) alongside the builtin identity names.
12+
- Every authenticated principal implicitly holds `everyone` in
13+
`ctx.positions`, so sets bound to it resolve as ordinary position-bound
14+
grants — ADDITIVE. The fallback CLIFF is abolished: the configured
15+
baseline (`fallbackPermissionSet`, default `member_default`) now applies
16+
in addition to explicit grants instead of only when the user had none,
17+
and is also seeded as an `everyone` binding (same table/audit/explain
18+
path as admin-authored defaults).
19+
- Sessionless HTTP principals resolve as `principalKind: 'guest'` holding
20+
exactly `['guest']`; internal bare contexts are untouched.
21+
- Audience-anchor binding gate: `sys_position_permission_set` writes that
22+
would bind a high-privilege set (VAMA, delete/purge/transfer, system
23+
permissions, `'*'` wildcard) to `everyone`/`guest` are rejected at the
24+
data layer, unconditionally (`describeHighPrivilegeBits` predicate is
25+
exported and shared with the seed-time validation).

packages/core/src/security/resolve-authz-context.test.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,3 +169,24 @@ describe('resolveLocalizationContext — batched fallback read (#2409)', () => {
169169
expect(loc.currency).toBeUndefined();
170170
});
171171
});
172+
173+
describe('audience anchors in the resolver (ADR-0090 D5)', () => {
174+
it('every authenticated principal implicitly holds `everyone` (additive, no cliff)', async () => {
175+
const ql = makeQl({
176+
sys_member: [{ user_id: 'u1', role: 'member', organization_id: 'o1' }],
177+
sys_user_position: [{ user_id: 'u1', position: 'contributor', organization_id: null }],
178+
});
179+
const ctx = await resolveAuthzContext({ ql, headers: H(), getSession: session('u1', { org: 'o1' }) });
180+
// holding an explicit position must NOT cost the baseline anchor
181+
expect(ctx.positions).toContain('contributor');
182+
expect(ctx.positions).toContain('everyone');
183+
});
184+
185+
it('anonymous resolution never gains `everyone`', async () => {
186+
const ql = makeQl({});
187+
const ctx = await resolveAuthzContext({ ql, headers: H(), getSession: async () => undefined });
188+
expect(ctx.positions).not.toContain('everyone');
189+
expect(ctx.userId).toBeUndefined();
190+
});
191+
});
192+

packages/core/src/security/resolve-authz-context.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -204,6 +204,12 @@ export async function resolveAuthzContext(input: ResolveAuthzInput): Promise<Res
204204
);
205205
let hasPlatformAdminGrant = false;
206206

207+
// 5b. [ADR-0090 D5] Audience anchor: every AUTHENTICATED member implicitly
208+
// holds the built-in `everyone` position, so sets bound to it resolve
209+
// below exactly like any other position-bound grant — ADDITIVE, with no
210+
// "only when the user has nothing else" cliff.
211+
if (!ctx.positions.includes('everyone')) ctx.positions.push('everyone');
212+
207213
// 6a. Position-bound permission sets (sys_position_permission_set): a position
208214
// carries its permission sets.
209215
if (ctx.positions.length > 0) {
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
// ADR-0090 D5/D9 — audience anchors: everyone/guest seeding and the
3+
// high-privilege binding gate.
4+
5+
import { describe, it, expect } from 'vitest';
6+
import { bootstrapBuiltinRoles } from './bootstrap-builtin-positions';
7+
import { describeHighPrivilegeBits } from './security-plugin';
8+
9+
function makeQl() {
10+
const tables: Record<string, any[]> = { sys_position: [] };
11+
return {
12+
tables,
13+
async find(object: string, opts: any) {
14+
const where = opts?.where ?? {};
15+
return (tables[object] ?? []).filter((r) =>
16+
Object.entries(where).every(([k, v]) => (r as any)[k] === v),
17+
);
18+
},
19+
async insert(object: string, data: any) {
20+
(tables[object] ??= []).push(data);
21+
return data;
22+
},
23+
async update(object: string, data: any) {
24+
const t = tables[object] ?? [];
25+
const i = t.findIndex((r) => r.id === data.id);
26+
if (i >= 0) t[i] = { ...t[i], ...data };
27+
return t[i];
28+
},
29+
} as any;
30+
}
31+
32+
describe('audience anchors (ADR-0090 D5/D9)', () => {
33+
it('seeds everyone and guest alongside the builtin identity names', async () => {
34+
const ql = makeQl();
35+
const res = await bootstrapBuiltinRoles(ql);
36+
const names = ql.tables.sys_position.map((r: any) => r.name);
37+
expect(names).toEqual(
38+
expect.arrayContaining(['platform_admin', 'org_owner', 'org_admin', 'org_member', 'everyone', 'guest']),
39+
);
40+
expect(res.seeded).toBe(6);
41+
// system-managed, undeletable posture
42+
for (const r of ql.tables.sys_position) expect(r.managed_by).toBe('system');
43+
});
44+
45+
it('re-seed is idempotent (updates, no duplicates)', async () => {
46+
const ql = makeQl();
47+
await bootstrapBuiltinRoles(ql);
48+
const res2 = await bootstrapBuiltinRoles(ql);
49+
expect(res2.seeded).toBe(0);
50+
expect(ql.tables.sys_position.filter((r: any) => r.name === 'everyone')).toHaveLength(1);
51+
});
52+
});
53+
54+
describe('describeHighPrivilegeBits (anchor-binding predicate)', () => {
55+
it('flags VAMA, destructive bits, wildcards and system permissions', () => {
56+
expect(describeHighPrivilegeBits({ objects: { a: { viewAllRecords: true } } })).toMatch(/View\/Modify All/);
57+
expect(describeHighPrivilegeBits({ objects: { a: { modifyAllRecords: true } } })).toMatch(/View\/Modify All/);
58+
expect(describeHighPrivilegeBits({ objects: { a: { allowDelete: true } } })).toMatch(/delete\/purge\/transfer/);
59+
expect(describeHighPrivilegeBits({ objects: { a: { allowPurge: true } } })).toMatch(/delete\/purge\/transfer/);
60+
expect(describeHighPrivilegeBits({ objects: { a: { allowTransfer: true } } })).toMatch(/delete\/purge\/transfer/);
61+
expect(describeHighPrivilegeBits({ objects: { '*': { allowRead: true } } })).toMatch(/wildcard/);
62+
expect(describeHighPrivilegeBits({ systemPermissions: ['manage_users'], objects: {} })).toMatch(/system permissions/);
63+
});
64+
65+
it('accepts a low-privilege self-service set (the intended anchor shape)', () => {
66+
expect(
67+
describeHighPrivilegeBits({
68+
objects: { crm_account: { allowRead: true }, helpdesk_ticket: { allowCreate: true, allowRead: true, allowEdit: true } },
69+
}),
70+
).toBeNull();
71+
});
72+
73+
it('reads the sys_permission_set ROW shape too (JSON string columns, snake_case)', () => {
74+
expect(describeHighPrivilegeBits({ objects: JSON.stringify({ a: { modifyAllRecords: true } }) })).toMatch(/View\/Modify All/);
75+
expect(describeHighPrivilegeBits({ system_permissions: ['setup.access'] })).toMatch(/system permissions/);
76+
expect(describeHighPrivilegeBits({ objects: JSON.stringify({ a: { allowRead: true } }) })).toBeNull();
77+
});
78+
});

packages/plugins/plugin-security/src/bootstrap-builtin-positions.ts

Lines changed: 27 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,26 @@
1717
* the platform-admin and declared-role bootstraps.
1818
*/
1919

20-
import { BUILTIN_IDENTITY_NAMES, BUILTIN_IDENTITY_METADATA } from '@objectstack/spec';
20+
import { BUILTIN_IDENTITY_NAMES, BUILTIN_IDENTITY_METADATA, EVERYONE_POSITION, GUEST_POSITION } from '@objectstack/spec';
21+
22+
/**
23+
* [ADR-0090 D5/D9] Audience anchors seeded alongside the identity names.
24+
* `everyone` — implicit for every authenticated member; its bindings are the
25+
* tenant's default grants. `guest` — implicit for unauthenticated principals.
26+
* Both are system-managed and undeletable like the identity rows.
27+
*/
28+
const AUDIENCE_ANCHOR_METADATA: Record<string, { label: string; description: string }> = {
29+
[EVERYONE_POSITION]: {
30+
label: 'Everyone',
31+
description:
32+
'Built-in audience anchor: every authenticated member holds this position implicitly. Permission sets bound to it are the default grants for the tenant (ADR-0090 D5). High-privilege sets cannot be bound here.',
33+
},
34+
[GUEST_POSITION]: {
35+
label: 'Guest',
36+
description:
37+
'Built-in audience anchor: unauthenticated principals hold this position implicitly and exclusively. Bindings face the strictest checks — named objects only, read-mostly, never a wildcard (ADR-0090 D9).',
38+
},
39+
};
2140

2241
const SYSTEM_CTX = { isSystem: true };
2342

@@ -53,19 +72,22 @@ export async function bootstrapBuiltinRoles(
5372
}
5473
let seeded = 0;
5574
let updated = 0;
56-
for (const name of BUILTIN_IDENTITY_NAMES) {
57-
const meta = BUILTIN_IDENTITY_METADATA[name];
75+
const rows: Array<[string, { label: string; description: string }]> = [
76+
...BUILTIN_IDENTITY_NAMES.map((n) => [n, BUILTIN_IDENTITY_METADATA[n]] as [string, { label: string; description: string }]),
77+
...Object.entries(AUDIENCE_ANCHOR_METADATA),
78+
];
79+
for (const [name, meta] of rows) {
5880
const fields = { label: meta.label, description: meta.description, managed_by: 'system' };
5981
const existing = await tryFind(ql, 'sys_position', { name }, 1);
6082
if (existing[0]?.id) {
6183
if (await tryUpdate(ql, 'sys_position', { id: existing[0].id, ...fields })) updated += 1;
6284
} else {
6385
const created = await tryInsert(ql, 'sys_position', {
64-
id: genId('role'), name, ...fields, active: true, is_default: false,
86+
id: genId('position'), name, ...fields, active: true, is_default: false,
6587
});
6688
if (created) seeded += 1;
6789
}
6890
}
69-
options.logger?.info?.('[security] built-in identity roles seeded into sys_position', { seeded, updated, total: BUILTIN_IDENTITY_NAMES.length });
91+
options.logger?.info?.('[security] built-in identity names + audience anchors seeded into sys_position', { seeded, updated, total: rows.length });
7092
return { seeded, updated };
7193
}

packages/plugins/plugin-security/src/objects/default-permission-sets.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -304,17 +304,26 @@ export const defaultPermissionSets: PermissionSet[] = [
304304
// verified against the target row before the mutation). Objects that
305305
// model transferable ownership with a dedicated owner field should
306306
// override these with a per-object policy.
307+
// [ADR-0090 P2] Applicability domain made EXPLICIT: with the baseline
308+
// resolving additively for every authenticated principal (the
309+
// `everyone` anchor — no more fallback cliff), these members-only
310+
// write restrictions must say who they bind. `org_member` is the
311+
// rank-and-file membership identity; org admins/owners and platform
312+
// admins are outside the domain, matching the pre-anchor behavior
313+
// where they simply never resolved this set.
307314
{
308315
name: 'owner_only_writes',
309316
object: '*',
310317
operation: 'update',
311318
using: 'created_by == current_user.id',
319+
positions: ['org_member'],
312320
},
313321
{
314322
name: 'owner_only_deletes',
315323
object: '*',
316324
operation: 'delete',
317325
using: 'created_by == current_user.id',
326+
positions: ['org_member'],
318327
},
319328
// ── better-auth system tables that lack `organization_id` and would
320329
// otherwise be left unprotected by the wildcard rule above. ────

packages/plugins/plugin-security/src/rls-compiler.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -277,7 +277,9 @@ export class RLSCompiler {
277277
getApplicablePolicies(
278278
objectName: string,
279279
operation: string,
280-
allPolicies: RowLevelSecurityPolicy[]
280+
allPolicies: RowLevelSecurityPolicy[],
281+
/** [ADR-0090 P2] Caller's held positions — enforces the policy `positions` applicability domain (formerly an unenforced property; ADR-0049 enforce-or-remove). */
282+
heldPositions?: string[],
281283
): RowLevelSecurityPolicy[] {
282284
// Map engine operation to RLS operation type
283285
const rlsOp = this.mapOperationToRLS(operation);
@@ -286,6 +288,18 @@ export class RLSCompiler {
286288
// Check object match
287289
if (policy.object !== objectName && policy.object !== '*') return false;
288290

291+
// [ADR-0090 P2] Applicability domain: a policy that declares
292+
// `positions` applies only to callers holding one of them. With the
293+
// `everyone` anchor making the baseline set resolve for ALL
294+
// authenticated principals, this domain is what keeps a
295+
// members-only restriction (e.g. owner-scoped writes, #1985) from
296+
// handcuffing admins who resolve the baseline additively.
297+
const domain = (policy as { positions?: string[] }).positions;
298+
if (Array.isArray(domain) && domain.length > 0) {
299+
const held = heldPositions ?? [];
300+
if (!domain.some((d) => held.includes(d))) return false;
301+
}
302+
289303
// Check operation match
290304
if (policy.operation === 'all') return true;
291305
if (policy.operation === rlsOp) return true;

0 commit comments

Comments
 (0)