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
25 changes: 25 additions & 0 deletions .changeset/adr-0090-p2-audience-anchors.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
---
'@objectstack/spec': minor
'@objectstack/core': minor
'@objectstack/runtime': minor
'@objectstack/plugin-security': minor
---

ADR-0090 P2 — audience anchors: `everyone`/`guest` builtin positions.

- `EVERYONE_POSITION` / `GUEST_POSITION` constants in `@objectstack/spec`;
both anchors seeded (system-managed) alongside the builtin identity names.
- Every authenticated principal implicitly holds `everyone` in
`ctx.positions`, so sets bound to it resolve as ordinary position-bound
grants — ADDITIVE. The fallback CLIFF is abolished: the configured
baseline (`fallbackPermissionSet`, default `member_default`) now applies
in addition to explicit grants instead of only when the user had none,
and is also seeded as an `everyone` binding (same table/audit/explain
path as admin-authored defaults).
- Sessionless HTTP principals resolve as `principalKind: 'guest'` holding
exactly `['guest']`; internal bare contexts are untouched.
- Audience-anchor binding gate: `sys_position_permission_set` writes that
would bind a high-privilege set (VAMA, delete/purge/transfer, system
permissions, `'*'` wildcard) to `everyone`/`guest` are rejected at the
data layer, unconditionally (`describeHighPrivilegeBits` predicate is
exported and shared with the seed-time validation).
21 changes: 21 additions & 0 deletions packages/core/src/security/resolve-authz-context.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -169,3 +169,24 @@ describe('resolveLocalizationContext — batched fallback read (#2409)', () => {
expect(loc.currency).toBeUndefined();
});
});

describe('audience anchors in the resolver (ADR-0090 D5)', () => {
it('every authenticated principal implicitly holds `everyone` (additive, no cliff)', async () => {
const ql = makeQl({
sys_member: [{ user_id: 'u1', role: 'member', organization_id: 'o1' }],
sys_user_position: [{ user_id: 'u1', position: 'contributor', organization_id: null }],
});
const ctx = await resolveAuthzContext({ ql, headers: H(), getSession: session('u1', { org: 'o1' }) });
// holding an explicit position must NOT cost the baseline anchor
expect(ctx.positions).toContain('contributor');
expect(ctx.positions).toContain('everyone');
});

it('anonymous resolution never gains `everyone`', async () => {
const ql = makeQl({});
const ctx = await resolveAuthzContext({ ql, headers: H(), getSession: async () => undefined });
expect(ctx.positions).not.toContain('everyone');
expect(ctx.userId).toBeUndefined();
});
});

6 changes: 6 additions & 0 deletions packages/core/src/security/resolve-authz-context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,12 @@ export async function resolveAuthzContext(input: ResolveAuthzInput): Promise<Res
);
let hasPlatformAdminGrant = false;

// 5b. [ADR-0090 D5] Audience anchor: every AUTHENTICATED member implicitly
// holds the built-in `everyone` position, so sets bound to it resolve
// below exactly like any other position-bound grant — ADDITIVE, with no
// "only when the user has nothing else" cliff.
if (!ctx.positions.includes('everyone')) ctx.positions.push('everyone');

// 6a. Position-bound permission sets (sys_position_permission_set): a position
// carries its permission sets.
if (ctx.positions.length > 0) {
Expand Down
78 changes: 78 additions & 0 deletions packages/plugins/plugin-security/src/audience-anchors.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
// ADR-0090 D5/D9 — audience anchors: everyone/guest seeding and the
// high-privilege binding gate.

import { describe, it, expect } from 'vitest';
import { bootstrapBuiltinRoles } from './bootstrap-builtin-positions';
import { describeHighPrivilegeBits } from './security-plugin';

function makeQl() {
const tables: Record<string, any[]> = { sys_position: [] };
return {
tables,
async find(object: string, opts: any) {
const where = opts?.where ?? {};
return (tables[object] ?? []).filter((r) =>
Object.entries(where).every(([k, v]) => (r as any)[k] === v),
);
},
async insert(object: string, data: any) {
(tables[object] ??= []).push(data);
return data;
},
async update(object: string, data: any) {
const t = tables[object] ?? [];
const i = t.findIndex((r) => r.id === data.id);
if (i >= 0) t[i] = { ...t[i], ...data };
return t[i];
},
} as any;
}

describe('audience anchors (ADR-0090 D5/D9)', () => {
it('seeds everyone and guest alongside the builtin identity names', async () => {
const ql = makeQl();
const res = await bootstrapBuiltinRoles(ql);
const names = ql.tables.sys_position.map((r: any) => r.name);
expect(names).toEqual(
expect.arrayContaining(['platform_admin', 'org_owner', 'org_admin', 'org_member', 'everyone', 'guest']),
);
expect(res.seeded).toBe(6);
// system-managed, undeletable posture
for (const r of ql.tables.sys_position) expect(r.managed_by).toBe('system');
});

it('re-seed is idempotent (updates, no duplicates)', async () => {
const ql = makeQl();
await bootstrapBuiltinRoles(ql);
const res2 = await bootstrapBuiltinRoles(ql);
expect(res2.seeded).toBe(0);
expect(ql.tables.sys_position.filter((r: any) => r.name === 'everyone')).toHaveLength(1);
});
});

describe('describeHighPrivilegeBits (anchor-binding predicate)', () => {
it('flags VAMA, destructive bits, wildcards and system permissions', () => {
expect(describeHighPrivilegeBits({ objects: { a: { viewAllRecords: true } } })).toMatch(/View\/Modify All/);
expect(describeHighPrivilegeBits({ objects: { a: { modifyAllRecords: true } } })).toMatch(/View\/Modify All/);
expect(describeHighPrivilegeBits({ objects: { a: { allowDelete: true } } })).toMatch(/delete\/purge\/transfer/);
expect(describeHighPrivilegeBits({ objects: { a: { allowPurge: true } } })).toMatch(/delete\/purge\/transfer/);
expect(describeHighPrivilegeBits({ objects: { a: { allowTransfer: true } } })).toMatch(/delete\/purge\/transfer/);
expect(describeHighPrivilegeBits({ objects: { '*': { allowRead: true } } })).toMatch(/wildcard/);
expect(describeHighPrivilegeBits({ systemPermissions: ['manage_users'], objects: {} })).toMatch(/system permissions/);
});

it('accepts a low-privilege self-service set (the intended anchor shape)', () => {
expect(
describeHighPrivilegeBits({
objects: { crm_account: { allowRead: true }, helpdesk_ticket: { allowCreate: true, allowRead: true, allowEdit: true } },
}),
).toBeNull();
});

it('reads the sys_permission_set ROW shape too (JSON string columns, snake_case)', () => {
expect(describeHighPrivilegeBits({ objects: JSON.stringify({ a: { modifyAllRecords: true } }) })).toMatch(/View\/Modify All/);
expect(describeHighPrivilegeBits({ system_permissions: ['setup.access'] })).toMatch(/system permissions/);
expect(describeHighPrivilegeBits({ objects: JSON.stringify({ a: { allowRead: true } }) })).toBeNull();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,26 @@
* the platform-admin and declared-role bootstraps.
*/

import { BUILTIN_IDENTITY_NAMES, BUILTIN_IDENTITY_METADATA } from '@objectstack/spec';
import { BUILTIN_IDENTITY_NAMES, BUILTIN_IDENTITY_METADATA, EVERYONE_POSITION, GUEST_POSITION } from '@objectstack/spec';

/**
* [ADR-0090 D5/D9] Audience anchors seeded alongside the identity names.
* `everyone` — implicit for every authenticated member; its bindings are the
* tenant's default grants. `guest` — implicit for unauthenticated principals.
* Both are system-managed and undeletable like the identity rows.
*/
const AUDIENCE_ANCHOR_METADATA: Record<string, { label: string; description: string }> = {
[EVERYONE_POSITION]: {
label: 'Everyone',
description:
'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.',
},
[GUEST_POSITION]: {
label: 'Guest',
description:
'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).',
},
};

const SYSTEM_CTX = { isSystem: true };

Expand Down Expand Up @@ -53,19 +72,22 @@ export async function bootstrapBuiltinRoles(
}
let seeded = 0;
let updated = 0;
for (const name of BUILTIN_IDENTITY_NAMES) {
const meta = BUILTIN_IDENTITY_METADATA[name];
const rows: Array<[string, { label: string; description: string }]> = [
...BUILTIN_IDENTITY_NAMES.map((n) => [n, BUILTIN_IDENTITY_METADATA[n]] as [string, { label: string; description: string }]),
...Object.entries(AUDIENCE_ANCHOR_METADATA),
];
for (const [name, meta] of rows) {
const fields = { label: meta.label, description: meta.description, managed_by: 'system' };
const existing = await tryFind(ql, 'sys_position', { name }, 1);
if (existing[0]?.id) {
if (await tryUpdate(ql, 'sys_position', { id: existing[0].id, ...fields })) updated += 1;
} else {
const created = await tryInsert(ql, 'sys_position', {
id: genId('role'), name, ...fields, active: true, is_default: false,
id: genId('position'), name, ...fields, active: true, is_default: false,
});
if (created) seeded += 1;
}
}
options.logger?.info?.('[security] built-in identity roles seeded into sys_position', { seeded, updated, total: BUILTIN_IDENTITY_NAMES.length });
options.logger?.info?.('[security] built-in identity names + audience anchors seeded into sys_position', { seeded, updated, total: rows.length });
return { seeded, updated };
}
Original file line number Diff line number Diff line change
Expand Up @@ -304,17 +304,26 @@ export const defaultPermissionSets: PermissionSet[] = [
// verified against the target row before the mutation). Objects that
// model transferable ownership with a dedicated owner field should
// override these with a per-object policy.
// [ADR-0090 P2] Applicability domain made EXPLICIT: with the baseline
// resolving additively for every authenticated principal (the
// `everyone` anchor — no more fallback cliff), these members-only
// write restrictions must say who they bind. `org_member` is the
// rank-and-file membership identity; org admins/owners and platform
// admins are outside the domain, matching the pre-anchor behavior
// where they simply never resolved this set.
{
name: 'owner_only_writes',
object: '*',
operation: 'update',
using: 'created_by == current_user.id',
positions: ['org_member'],
},
{
name: 'owner_only_deletes',
object: '*',
operation: 'delete',
using: 'created_by == current_user.id',
positions: ['org_member'],
},
// ── better-auth system tables that lack `organization_id` and would
// otherwise be left unprotected by the wildcard rule above. ────
Expand Down
16 changes: 15 additions & 1 deletion packages/plugins/plugin-security/src/rls-compiler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -277,7 +277,9 @@ export class RLSCompiler {
getApplicablePolicies(
objectName: string,
operation: string,
allPolicies: RowLevelSecurityPolicy[]
allPolicies: RowLevelSecurityPolicy[],
/** [ADR-0090 P2] Caller's held positions — enforces the policy `positions` applicability domain (formerly an unenforced property; ADR-0049 enforce-or-remove). */
heldPositions?: string[],
): RowLevelSecurityPolicy[] {
// Map engine operation to RLS operation type
const rlsOp = this.mapOperationToRLS(operation);
Expand All @@ -286,6 +288,18 @@ export class RLSCompiler {
// Check object match
if (policy.object !== objectName && policy.object !== '*') return false;

// [ADR-0090 P2] Applicability domain: a policy that declares
// `positions` applies only to callers holding one of them. With the
// `everyone` anchor making the baseline set resolve for ALL
// authenticated principals, this domain is what keeps a
// members-only restriction (e.g. owner-scoped writes, #1985) from
// handcuffing admins who resolve the baseline additively.
const domain = (policy as { positions?: string[] }).positions;
if (Array.isArray(domain) && domain.length > 0) {
const held = heldPositions ?? [];
if (!domain.some((d) => held.includes(d))) return false;
}

// Check operation match
if (policy.operation === 'all') return true;
if (policy.operation === rlsOp) return true;
Expand Down
Loading