From 981de5b4c1d70368a248c87f4da5acdcd075ca40 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 9 Jul 2026 03:54:44 +0000 Subject: [PATCH 1/3] =?UTF-8?q?feat(security)!:=20ADR-0090=20P2=20?= =?UTF-8?q?=E2=80=94=20everyone/guest=20audience=20anchors,=20additive=20b?= =?UTF-8?q?aseline,=20anchor=20binding=20gate?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_012oLzaP8n7A3YKFmgaHWC8H --- .changeset/adr-0090-p2-audience-anchors.md | 25 ++++ .../security/resolve-authz-context.test.ts | 21 +++ .../src/security/resolve-authz-context.ts | 6 + .../src/audience-anchors.test.ts | 78 ++++++++++ .../src/bootstrap-builtin-positions.ts | 32 +++- .../plugin-security/src/security-plugin.ts | 137 +++++++++++++++++- .../resolve-execution-context.test.ts | 24 ++- .../src/security/resolve-execution-context.ts | 12 ++ packages/spec/src/identity/position.zod.ts | 12 ++ packages/spec/src/index.ts | 2 +- 10 files changed, 336 insertions(+), 13 deletions(-) create mode 100644 .changeset/adr-0090-p2-audience-anchors.md create mode 100644 packages/plugins/plugin-security/src/audience-anchors.test.ts diff --git a/.changeset/adr-0090-p2-audience-anchors.md b/.changeset/adr-0090-p2-audience-anchors.md new file mode 100644 index 0000000000..df8c8b3435 --- /dev/null +++ b/.changeset/adr-0090-p2-audience-anchors.md @@ -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). diff --git a/packages/core/src/security/resolve-authz-context.test.ts b/packages/core/src/security/resolve-authz-context.test.ts index b21a3379a0..eda0482600 100644 --- a/packages/core/src/security/resolve-authz-context.test.ts +++ b/packages/core/src/security/resolve-authz-context.test.ts @@ -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(); + }); +}); + diff --git a/packages/core/src/security/resolve-authz-context.ts b/packages/core/src/security/resolve-authz-context.ts index 1d04cc3ffa..031ab0ffc5 100644 --- a/packages/core/src/security/resolve-authz-context.ts +++ b/packages/core/src/security/resolve-authz-context.ts @@ -204,6 +204,12 @@ export async function resolveAuthzContext(input: ResolveAuthzInput): Promise 0) { diff --git a/packages/plugins/plugin-security/src/audience-anchors.test.ts b/packages/plugins/plugin-security/src/audience-anchors.test.ts new file mode 100644 index 0000000000..a3adae4e78 --- /dev/null +++ b/packages/plugins/plugin-security/src/audience-anchors.test.ts @@ -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 = { 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(); + }); +}); diff --git a/packages/plugins/plugin-security/src/bootstrap-builtin-positions.ts b/packages/plugins/plugin-security/src/bootstrap-builtin-positions.ts index f602849f44..d7daf1398d 100644 --- a/packages/plugins/plugin-security/src/bootstrap-builtin-positions.ts +++ b/packages/plugins/plugin-security/src/bootstrap-builtin-positions.ts @@ -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 = { + [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 }; @@ -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 }; } diff --git a/packages/plugins/plugin-security/src/security-plugin.ts b/packages/plugins/plugin-security/src/security-plugin.ts index 7bd3a4ccbd..62ef18f218 100644 --- a/packages/plugins/plugin-security/src/security-plugin.ts +++ b/packages/plugins/plugin-security/src/security-plugin.ts @@ -127,6 +127,29 @@ export interface SecurityPluginOptions { * - objectql service (ObjectQL engine with middleware support) * - metadata service (MetadataFacade for reading permission sets and RLS policies) */ +/** + * [ADR-0090 D5/D9] Does a permission-set definition (authored shape OR + * sys_permission_set row shape with JSON-ish columns) carry bits too + * dangerous for an audience anchor? Returns a human-readable description of + * the first offending bit, or null when the set is anchor-safe. + */ +export function describeHighPrivilegeBits(def: any): string | null { + if (!def || typeof def !== 'object') return null; + const sys = def.systemPermissions ?? def.system_permissions; + if (Array.isArray(sys) && sys.length > 0) return 'system permissions'; + let objects: any = def.objects; + if (typeof objects === 'string') { try { objects = JSON.parse(objects); } catch { objects = undefined; } } + if (objects && typeof objects === 'object') { + for (const [objName, rawPerm] of Object.entries(objects)) { + const p: any = rawPerm ?? {}; + if (p.viewAllRecords || p.modifyAllRecords) return `View/Modify All Data on '${objName}'`; + if (p.allowDelete || p.allowPurge || p.allowTransfer) return `delete/purge/transfer on '${objName}'`; + if (objName === '*') return "a '*' wildcard grant"; + } + } + return null; +} + export class SecurityPlugin implements Plugin { name = 'com.objectstack.security'; type = 'standard'; @@ -411,6 +434,14 @@ export class SecurityPlugin implements Plugin { // the publish materializer pass straight through. await this.assertPackageManagedWriteGate(opCtx); + // [ADR-0090 D5/D9] Audience-anchor binding guard — like the package + // gate above, an unconditional data-layer boundary: a permission set + // carrying high-privilege bits must never be bound to the `everyone` + // or `guest` positions, no matter who asks. (Boot/system writes carry + // `isSystem` and short-circuited above; the dev-mode default binding + // is validated at seed time by the same predicate.) + await this.assertAudienceAnchorBindingGate(opCtx); + const roles = opCtx.context?.positions ?? []; const explicitPermissionSets = opCtx.context?.permissions ?? []; @@ -844,6 +875,45 @@ export class SecurityPlugin implements Plugin { } catch (e) { ctx.logger.warn('[security] declared-permission seeding failed', { error: (e as Error).message }); } + + // [ADR-0090 D5] Bind the configured baseline set to the `everyone` + // audience anchor (idempotent). This makes the CLI/dev fallback + // (`fallbackPermissionSet` — the app's `isDefault` suggestion) visible + // as an ordinary position binding: same table, same audit path, same + // explain surface as any admin-authored default grant. The binding is + // validated with the SAME high-privilege predicate the write gate + // enforces — a dangerous baseline is refused loudly, never seeded. + try { + if (this.fallbackPermissionSet) { + const boot = this.bootstrapPermissionSets.find((p) => p.name === this.fallbackPermissionSet); + const offending = boot ? describeHighPrivilegeBits(boot) : null; + if (offending) { + ctx.logger.warn('[security] refusing to bind fallback set to everyone — high-privilege bits', { + set: this.fallbackPermissionSet, offending, + }); + } else { + const everyoneRows = await ql.find('sys_position', { where: { name: 'everyone' }, limit: 1, context: { isSystem: true } }); + const everyone: any = Array.isArray(everyoneRows) && everyoneRows[0] ? everyoneRows[0] : null; + const setRows = await ql.find('sys_permission_set', { where: { name: this.fallbackPermissionSet }, limit: 1, context: { isSystem: true } }); + const set: any = Array.isArray(setRows) && setRows[0] ? setRows[0] : null; + if (everyone?.id && set?.id) { + const existing = await ql.find('sys_position_permission_set', { + where: { position_id: everyone.id, permission_set_id: set.id }, limit: 1, context: { isSystem: true }, + }); + if (!(Array.isArray(existing) && existing[0])) { + await ql.insert('sys_position_permission_set', { + id: `pps_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}`, + position_id: everyone.id, + permission_set_id: set.id, + }, { context: { isSystem: true } }); + ctx.logger.info('[security] baseline set bound to everyone anchor (ADR-0090 D5)', { set: this.fallbackPermissionSet }); + } + } + } + } + } catch (e) { + ctx.logger.warn('[security] everyone-anchor baseline binding failed (non-fatal)', { error: (e as Error).message }); + } // [ADR-0086 P2 — 块1] Register the publish-time materializer so a // permission set authored/edited through the PACKAGE door (saved as a // `permission` draft, then published) lands in sys_permission_set with @@ -1067,13 +1137,17 @@ export class SecurityPlugin implements Plugin { private async resolvePermissionSetsForContext( context: any, ): Promise { - const roles = context?.positions ?? []; + const positions = context?.positions ?? []; const explicitPermissionSets = context?.permissions ?? []; - const requested = [...roles, ...explicitPermissionSets]; - // Implicit baseline: an authenticated request that named no roles/perms - // still gets the configured baseline (default `member_default`) so tenant + - // owner RLS apply before an admin assigns a profile. - if (requested.length === 0 && context?.userId && this.fallbackPermissionSet) { + const requested = [...positions, ...explicitPermissionSets]; + // [ADR-0090 D5] Baseline is ADDITIVE, always: the configured baseline set + // (default `member_default`) applies to every authenticated request IN + // ADDITION to whatever else resolved. The former "only when the user has + // nothing else" conditional was the fallback CLIFF — receiving your first + // explicit grant silently cost you the entire baseline. The `everyone` + // audience anchor carries the same semantics for admin-authored defaults; + // this keeps the CLI-configured baseline coherent with it. + if (context?.userId && this.fallbackPermissionSet && !requested.includes(this.fallbackPermissionSet)) { requested.push(this.fallbackPermissionSet); } let permissionSets = await this.permissionEvaluator.resolvePermissionSets( @@ -1122,6 +1196,57 @@ export class SecurityPlugin implements Plugin { * here (the middleware short-circuits on `isSystem`), so the seeder and the * publish materializer are unaffected. */ + /** + * [ADR-0090 D5/D9] Reject binding a HIGH-PRIVILEGE permission set to an + * audience anchor (`everyone` / `guest`). The anchors are implicit for + * whole principal classes, so a dangerous binding here is an instant + * tenant-wide (or anonymous-wide) grant — the one shape the model must + * make unrepresentable rather than merely discouraged. + */ + private async assertAudienceAnchorBindingGate(opCtx: any): Promise { + if (opCtx?.object !== 'sys_position_permission_set') return; + if (!['insert', 'update'].includes(opCtx.operation)) return; + const rows = Array.isArray(opCtx.data) + ? opCtx.data + : (opCtx.data && typeof opCtx.data === 'object' ? [opCtx.data] : []); + if (rows.length === 0) return; + + const ql = this.ql; + for (const row of rows) { + const positionId = (row as any)?.position_id; + if (!positionId || !ql?.find) continue; + let positionName = ''; + try { + const posRows = await ql.find('sys_position', { where: { id: positionId }, limit: 1, context: { isSystem: true } }); + positionName = String((Array.isArray(posRows) && posRows[0] ? (posRows[0] as any).name : '') ?? ''); + } catch { positionName = ''; } + if (positionName !== 'everyone' && positionName !== 'guest') continue; + + // Resolve the target set definition (bootstrap sets by name, else the + // sys_permission_set row itself carries the authored definition). + const setId = (row as any)?.permission_set_id; + let setName = ''; + let setDef: any = null; + try { + const setRows = await ql.find('sys_permission_set', { where: { id: setId }, limit: 1, context: { isSystem: true } }); + const sr: any = Array.isArray(setRows) && setRows[0] ? setRows[0] : null; + if (sr) { + setName = String(sr.name ?? ''); + setDef = sr; + } + } catch { /* fall through to bootstrap lookup below */ } + const boot = this.bootstrapPermissionSets.find((p) => p.name === setName); + const offending = describeHighPrivilegeBits(boot ?? setDef); + if (offending) { + throw new PermissionDeniedError( + `[Security] Access denied: permission set '${setName || setId}' cannot be bound to the '${positionName}' audience anchor — it carries ${offending} (ADR-0090 D5/D9). ` + + `Audience anchors accept low-privilege sets only; grant powerful sets through ordinary positions instead.`, + { operation: opCtx.operation, object: opCtx.object, position: positionName, permissionSet: setName || setId }, + ); + } + } + } + private async assertPackageManagedWriteGate(opCtx: any): Promise { if (opCtx?.object !== 'sys_permission_set') return; const op = opCtx.operation; diff --git a/packages/runtime/src/security/resolve-execution-context.test.ts b/packages/runtime/src/security/resolve-execution-context.test.ts index e5972a6d14..3acb249dfc 100644 --- a/packages/runtime/src/security/resolve-execution-context.test.ts +++ b/packages/runtime/src/security/resolve-execution-context.test.ts @@ -127,7 +127,9 @@ describe('resolveExecutionContext — API key verify path', () => { const ctx = await resolveExecutionContext(makeOpts([], {})); expect(ctx.userId).toBeUndefined(); expect(ctx.isSystem).toBe(false); - expect(ctx.positions).toEqual([]); + // ADR-0090 D9: a sessionless HTTP principal is a guest — it holds the + // built-in guest position implicitly (and exclusively). + expect(ctx.positions).toEqual(['guest']); expect(ctx.permissions).toEqual([]); }); @@ -331,3 +333,23 @@ describe('resolveExecutionContext — platform-scoped (null-org) grants (ADR-006 expect(ctx.systemPermissions).not.toContain('should_not_appear'); }); }); + +describe('principal taxonomy at the HTTP entry (ADR-0090 D9/D10)', () => { + it('a sessionless request resolves as a guest principal holding only the guest position', async () => { + const ctx = await resolveExecutionContext(makeOpts([], {})); + expect(ctx.principalKind).toBe('guest'); + expect(ctx.positions).toEqual(['guest']); + expect(ctx.userId).toBeUndefined(); + expect(ctx.isSystem).toBe(false); + }); + + it('an authenticated (API-key) request resolves as a human principal, never guest', async () => { + const raw = 'osk_p2_anchor_test'; + const rows = [{ id: 'k1', key: hashApiKey(raw), revoked: false, user_id: 'u9', expires_at: FUTURE }]; + const ctx = await resolveExecutionContext(makeOpts(rows, { 'x-api-key': raw })); + expect(ctx.principalKind).toBe('human'); + expect(ctx.userId).toBe('u9'); + expect(ctx.positions).not.toContain('guest'); + }); +}); + diff --git a/packages/runtime/src/security/resolve-execution-context.ts b/packages/runtime/src/security/resolve-execution-context.ts index e0a657c43e..dd41ede6e0 100644 --- a/packages/runtime/src/security/resolve-execution-context.ts +++ b/packages/runtime/src/security/resolve-execution-context.ts @@ -82,6 +82,18 @@ export async function resolveExecutionContext(opts: ResolveOptions): Promise z.object({ description: z.string().optional(), })); +/** + * [ADR-0090 D5/D9] Built-in AUDIENCE ANCHOR positions. `everyone` is held + * implicitly by every authenticated org member — sets bound to it are the + * tenant's default grants (resolved per-request; additive, no fallback + * cliff). `guest` is held implicitly (and exclusively) by unauthenticated + * principals; its bindings face the strictest lint tier. Packages SUGGEST + * bindings to these anchors at install time — never auto-bind. + */ +export const EVERYONE_POSITION = 'everyone'; +export const GUEST_POSITION = 'guest'; +export const AUDIENCE_ANCHOR_POSITIONS = [EVERYONE_POSITION, GUEST_POSITION] as const; + export type Position = z.infer; /** Authoring input for {@link Position} — defaulted fields are optional. */ export type PositionInput = z.input; diff --git a/packages/spec/src/index.ts b/packages/spec/src/index.ts index a3411bd3cd..90ea92514b 100644 --- a/packages/spec/src/index.ts +++ b/packages/spec/src/index.ts @@ -88,7 +88,7 @@ export { defineSkill } from './ai/skill.zod'; export { defineDatasource } from './data/datasource.zod'; export { defineConnector } from './integration/connector.zod'; export { defineSharingRule } from './security/sharing.zod'; -export { definePosition } from './identity/position.zod'; +export { definePosition, EVERYONE_POSITION, GUEST_POSITION, AUDIENCE_ANCHOR_POSITIONS } from './identity/position.zod'; export { definePermissionSet } from './security/permission.zod'; export { defineEmailTemplateDefinition } from './system/email-template.zod'; export { defineReport } from './ui/report.zod'; From fb1f5f1013ea71dd93729d531047a9533b0a9d2b Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 9 Jul 2026 04:05:04 +0000 Subject: [PATCH 2/3] chore(spec): regenerate API-surface snapshot for the P2 audience-anchor exports (6 additions, 0 breaking) Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_012oLzaP8n7A3YKFmgaHWC8H --- packages/spec/api-surface.json | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/spec/api-surface.json b/packages/spec/api-surface.json index 86aa1d9d13..3ad89ecbe7 100644 --- a/packages/spec/api-surface.json +++ b/packages/spec/api-surface.json @@ -1,6 +1,7 @@ { ".": [ "ADMIN_FULL_ACCESS (const)", + "AUDIENCE_ANCHOR_POSITIONS (const)", "Agent (type)", "BUILTIN_IDENTITY_METADATA (const)", "BUILTIN_IDENTITY_NAMES (const)", @@ -17,6 +18,7 @@ "DatasourceMappingRule (type)", "DatasourceMappingRuleSchema (const)", "DefineStackOptions (interface)", + "EVERYONE_POSITION (const)", "EvalUser (type)", "EvalUserInput (type)", "EvalUserSchema (const)", @@ -30,6 +32,7 @@ "ExpressionMetaSchema (const)", "ExpressionSchema (const)", "F (const)", + "GUEST_POSITION (const)", "MAP_SUPPORTED_FIELDS (const)", "METADATA_ALIASES (const)", "MapSupportedField (type)", @@ -4165,6 +4168,7 @@ ], "./identity": [ "ADMIN_FULL_ACCESS (const)", + "AUDIENCE_ANCHOR_POSITIONS (const)", "AUTH_CONSTANTS (const)", "AUTH_ERROR_CODES (const)", "Account (type)", @@ -4181,9 +4185,11 @@ "BUILTIN_IDENTITY_ORG_OWNER (const)", "BUILTIN_IDENTITY_PLATFORM_ADMIN (const)", "BuiltinIdentityName (type)", + "EVERYONE_POSITION (const)", "EvalUser (type)", "EvalUserInput (type)", "EvalUserSchema (const)", + "GUEST_POSITION (const)", "Invitation (type)", "InvitationSchema (const)", "InvitationStatus (type)", From a68f1e68e920d7d541b6c05c4cde3582a7d4828a Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 9 Jul 2026 04:12:04 +0000 Subject: [PATCH 3/3] fix(security): enforce the RLS `positions` applicability domain; scope the #1985 owner policies to org_member MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_012oLzaP8n7A3YKFmgaHWC8H --- .../src/objects/default-permission-sets.ts | 9 +++++++++ .../plugins/plugin-security/src/rls-compiler.ts | 16 +++++++++++++++- .../plugin-security/src/security-plugin.ts | 7 ++++--- 3 files changed, 28 insertions(+), 4 deletions(-) diff --git a/packages/plugins/plugin-security/src/objects/default-permission-sets.ts b/packages/plugins/plugin-security/src/objects/default-permission-sets.ts index 6ff453841b..f1d59de6ca 100644 --- a/packages/plugins/plugin-security/src/objects/default-permission-sets.ts +++ b/packages/plugins/plugin-security/src/objects/default-permission-sets.ts @@ -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. ──── diff --git a/packages/plugins/plugin-security/src/rls-compiler.ts b/packages/plugins/plugin-security/src/rls-compiler.ts index f8d21f1e52..4f17989c65 100644 --- a/packages/plugins/plugin-security/src/rls-compiler.ts +++ b/packages/plugins/plugin-security/src/rls-compiler.ts @@ -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); @@ -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; diff --git a/packages/plugins/plugin-security/src/security-plugin.ts b/packages/plugins/plugin-security/src/security-plugin.ts index 62ef18f218..17c6fc9f9f 100644 --- a/packages/plugins/plugin-security/src/security-plugin.ts +++ b/packages/plugins/plugin-security/src/security-plugin.ts @@ -1359,7 +1359,7 @@ export class SecurityPlugin implements Plugin { : this.permissionEvaluator.hasSuperuserReadBypass(object, permissionSets, { isPrivate: meta.isPrivate }); if (bypass) return null; } - const allRlsPolicies = this.collectRLSPolicies(permissionSets, object, operation); + const allRlsPolicies = this.collectRLSPolicies(permissionSets, object, operation, (context?.positions ?? []) as string[]); if (allRlsPolicies.length === 0) return null; // Field-existence safety: wildcard policies (`object: '*'`) target fields // like `organization_id` that may not exist on every object. Treat such a @@ -1573,7 +1573,8 @@ export class SecurityPlugin implements Plugin { private collectRLSPolicies( permissionSets: PermissionSet[], objectName: string, - operation: string + operation: string, + heldPositions?: string[], ): RowLevelSecurityPolicy[] { const allPolicies: RowLevelSecurityPolicy[] = []; @@ -1602,7 +1603,7 @@ export class SecurityPlugin implements Plugin { } } - return this.rlsCompiler.getApplicablePolicies(objectName, operation, allPolicies); + return this.rlsCompiler.getApplicablePolicies(objectName, operation, allPolicies, heldPositions); } /**