From 6791b3b829eda9e58760b18f92a469121d38c000 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 20 Jul 2026 01:20:16 +0000 Subject: [PATCH] fix(security): derive better-auth managed-object write denies from the registry (#3325) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The default permission sets deny generic writes on better-auth identity tables via a hand-maintained BETTER_AUTH_MANAGED_OBJECTS list — the drift ADR-0092 forbids, and it had already drifted: 17 listed vs 22 schemas declaring managedBy:'better-auth', leaving sys_scim_provider, sys_sso_provider and three sys_oauth_* tables wildcard-granted for writes at the evaluator (the identity write guard still 403'd the write, so a defense-in-depth gap, not a live hole). - New applyManagedWriteDenies (managed-object-write-denies.ts) unions the denies from the live registry into the four write-granting default sets at kernel:ready, mutating the shared in-memory bootstrapPermissionSets in place (the array the evaluator resolves and the seeder serializes — a DB-row-only fix would be dead code). Never touches admin_full_access, never overrides an existing entry, ignores userActions (better-auth is hard-denied: sys_user's userActions.edit opens only a field whitelist the identity guard enforces). - Static list completed to 22 as a compile-time baseline, now pinned bidirectionally against the @objectstack/platform-objects schemas by test so it cannot silently rot again. - Engine-owned system/append-only objects deliberately NOT denied here (a per-object entry overrides the wildcard and would drop viewAllRecords; their writes are already rejected by the ADR-0103 engine guard). Tests: helper behavior, drift pin, seed-row serialization + resync, and a reference-sharing integration pin. plugin-security 539 passed. Refs #3325, follows #3220 / ADR-0092 / ADR-0103 / ADR-0049. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Fp9yZxRQ3mb7p4vVwqFXKE --- .changeset/managed-deny-registry-drift.md | 11 ++ .../src/bootstrap-platform-admin.test.ts | 34 +++++ .../src/managed-object-write-denies.test.ts | 108 +++++++++++++++ .../src/managed-object-write-denies.ts | 128 ++++++++++++++++++ .../objects/default-permission-sets.test.ts | 62 +++++++++ .../src/objects/default-permission-sets.ts | 19 ++- .../src/security-plugin.test.ts | 56 ++++++++ .../plugin-security/src/security-plugin.ts | 26 +++- 8 files changed, 440 insertions(+), 4 deletions(-) create mode 100644 .changeset/managed-deny-registry-drift.md create mode 100644 packages/plugins/plugin-security/src/managed-object-write-denies.test.ts create mode 100644 packages/plugins/plugin-security/src/managed-object-write-denies.ts create mode 100644 packages/plugins/plugin-security/src/objects/default-permission-sets.test.ts diff --git a/.changeset/managed-deny-registry-drift.md b/.changeset/managed-deny-registry-drift.md new file mode 100644 index 0000000000..32acb14c6a --- /dev/null +++ b/.changeset/managed-deny-registry-drift.md @@ -0,0 +1,11 @@ +--- +"@objectstack/plugin-security": patch +--- + +**Derive the better-auth managed-object write denies from the live registry (#3325, follow-through of ADR-0092 / ADR-0103).** The default permission sets deny generic writes on better-auth identity tables via a hand-maintained `BETTER_AUTH_MANAGED_OBJECTS` list — exactly the drift ADR-0092 forbids, and it had already drifted (the list carried 17 names while 22 schemas declare `managedBy: 'better-auth'`, leaving `sys_scim_provider`, `sys_sso_provider`, and three `sys_oauth_*` tables wildcard-granted for writes at the permission-evaluator layer; the identity write guard still 403'd the actual write, so this was a defense-in-depth gap, not a live hole). + +- New `applyManagedWriteDenies` (`managed-object-write-denies.ts`) injects a read-only-write deny for every registered `managedBy: 'better-auth'` object into the four write-granting default sets (`organization_admin`, `member_default`, `viewer_readonly`, MCP write) at `kernel:ready`, mutating the shared in-memory `bootstrapPermissionSets` in place (the array the evaluator resolves and the seeder serializes — a DB-row-only fix would be dead code). Never touches `admin_full_access`, never overrides an existing explicit entry, ignores `userActions` (the better-auth bucket is hard-denied — `sys_user`'s `userActions.edit` opens only a field-level whitelist the identity guard enforces). +- The static `BETTER_AUTH_MANAGED_OBJECTS` list is completed to 22 and kept as a compile-time baseline (covers the pre-`kernel:ready` window), now pinned bidirectionally against the `@objectstack/platform-objects` schemas by a test so it cannot silently rot again. +- Engine-owned `system`/`append-only` objects are deliberately NOT given deny entries — a per-object entry overrides the wildcard and would drop `viewAllRecords`; their writes are already rejected by the ADR-0103 engine guard. + +No public API change; the helper is internal. Behavior is byte-preserving for the 17 already-listed tables and closes the gap on the 5 that had drifted. diff --git a/packages/plugins/plugin-security/src/bootstrap-platform-admin.test.ts b/packages/plugins/plugin-security/src/bootstrap-platform-admin.test.ts index c65eb7964a..1905575d86 100644 --- a/packages/plugins/plugin-security/src/bootstrap-platform-admin.test.ts +++ b/packages/plugins/plugin-security/src/bootstrap-platform-admin.test.ts @@ -15,6 +15,7 @@ import { describe, it, expect } from 'vitest'; import { bootstrapPlatformAdmin } from './bootstrap-platform-admin.js'; +import { applyManagedWriteDenies } from './managed-object-write-denies.js'; /** Minimal in-memory ql. Only `sys_permission_set` is modeled; the admin- * promotion tables return empty, so promotion short-circuits and we assert the @@ -107,3 +108,36 @@ describe('bootstrapPlatformAdmin — insert-once vs resync (#2705)', () => { expect(JSON.parse(row(ql)!.system_permissions)).toEqual(['setup.access']); }); }); + +// #3325 — the transform runs upstream in runBootstrap; here we confirm that once +// a set has been enriched by applyManagedWriteDenies, bootstrapPlatformAdmin +// serializes the injected denies into the seeded object_permissions JSON. +describe('bootstrapPlatformAdmin — managed write denies land in the seed row (#3325)', () => { + const schemas = [ + { name: 'sys_sso_provider', managedBy: 'better-auth' }, + { name: 'crm_lead', managedBy: 'platform' }, + ]; + // member_default is a target set; give it the wildcard shape the real one has. + const enriched = () => { + const set = { name: 'member_default', label: 'Member', objects: { '*': { allowRead: true, allowCreate: true } } } as any; + applyManagedWriteDenies([set], schemas); + return set; + }; + + it('fresh DB: the injected deny serializes into object_permissions', async () => { + const ql = makeQl([]); + await bootstrapPlatformAdmin(ql, [enriched()]); + const perms = JSON.parse(row(ql)!.object_permissions); + expect(perms.sys_sso_provider).toEqual({ allowRead: true, allowCreate: false, allowEdit: false, allowDelete: false }); + expect(perms.crm_lead).toBeUndefined(); // platform bucket never denied + }); + + it('resync: reconciles a stale platform-owned row that predates the deny', async () => { + const ql = makeQl([ + { id: 'ps_old', name: 'member_default', system_permissions: '[]', object_permissions: '{}', managed_by: null }, + ]); + const r = await bootstrapPlatformAdmin(ql, [enriched()], { resync: true }); + expect(r.resynced).toBe(1); + expect(JSON.parse(row(ql)!.object_permissions).sys_sso_provider.allowCreate).toBe(false); + }); +}); diff --git a/packages/plugins/plugin-security/src/managed-object-write-denies.test.ts b/packages/plugins/plugin-security/src/managed-object-write-denies.test.ts new file mode 100644 index 0000000000..4b681b8d3e --- /dev/null +++ b/packages/plugins/plugin-security/src/managed-object-write-denies.test.ts @@ -0,0 +1,108 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// ADR-0092 / ADR-0103 / #3325 — registry-driven managed-object write denies. + +import { describe, it, expect } from 'vitest'; +import { + applyManagedWriteDenies, + MANAGED_DENY_ENTRY, + MANAGED_DENY_TARGET_SETS, +} from './managed-object-write-denies.js'; +import { MCP_AGENT_PERMISSION_SET_WRITE, MCP_AGENT_PERMISSION_SET_READ } from '@objectstack/spec/ai'; + +// Minimal PermissionSet-shaped fixtures (only name + objects matter here). +const set = (name: string, objects: Record = {}): any => ({ name, objects }); + +const DENY = { allowRead: true, allowCreate: false, allowEdit: false, allowDelete: false }; + +const schemas = [ + { name: 'sys_user', managedBy: 'better-auth', userActions: { edit: true } }, // intentional divergence + { name: 'sys_sso_provider', managedBy: 'better-auth' }, + { name: 'crm_lead', managedBy: 'platform' }, + { name: 'sys_setting', managedBy: 'system' }, + { name: 'sys_audit_log', managedBy: 'append-only' }, + { name: 'sys_sharing_rule', managedBy: 'config' }, + { name: 'sys_no_bucket' }, // unset +]; + +describe('applyManagedWriteDenies (#3325)', () => { + it('injects a read-only-write deny for every better-auth object into the four target sets', () => { + const sets = [ + set('organization_admin'), + set('member_default'), + set('viewer_readonly'), + set(MCP_AGENT_PERMISSION_SET_WRITE), + ]; + const res = applyManagedWriteDenies(sets, schemas); + // 2 better-auth objects × 4 sets = 8 injections. + expect(res.applied).toBe(8); + expect(res.skippedExisting).toBe(0); + for (const s of sets) { + expect(s.objects.sys_user).toEqual(DENY); + expect(s.objects.sys_sso_provider).toEqual(DENY); + } + }); + + it('hard-denies sys_user despite userActions.edit:true (permission-set booleans cannot whitelist fields)', () => { + const s = set('member_default'); + applyManagedWriteDenies([s], schemas); + expect(s.objects.sys_user).toEqual(DENY); + expect(s.objects.sys_user.allowEdit).toBe(false); + }); + + it('ignores platform / config / system / append-only / unset buckets (pins the ADR-0103 deferral)', () => { + const s = set('member_default'); + applyManagedWriteDenies([s], schemas); + for (const name of ['crm_lead', 'sys_setting', 'sys_audit_log', 'sys_sharing_rule', 'sys_no_bucket']) { + expect(s.objects[name]).toBeUndefined(); + } + }); + + it('never touches admin_full_access or the MCP read/restricted sets', () => { + const admin = set('admin_full_access', { '*': { allowRead: true, allowCreate: true } }); + const mcpRead = set(MCP_AGENT_PERMISSION_SET_READ, { '*': { allowRead: true } }); + const res = applyManagedWriteDenies([admin, mcpRead], schemas); + expect(res.applied).toBe(0); + expect(admin.objects).toEqual({ '*': { allowRead: true, allowCreate: true } }); + expect(mcpRead.objects.sys_user).toBeUndefined(); + }); + + it('never overrides an existing explicit entry (protects the org-admin RBAC block / static baseline)', () => { + // Mirror org-admin: sys_user already carved out read-only; keep it verbatim. + const existing = { allowRead: true, allowCreate: false, allowEdit: false, allowDelete: false, viewAllRecords: true }; + const s = set('organization_admin', { sys_user: existing }); + const res = applyManagedWriteDenies([s], schemas); + expect(s.objects.sys_user).toBe(existing); // same reference, untouched (viewAllRecords preserved) + expect(res.skippedExisting).toBe(1); // sys_user skipped + expect(res.applied).toBe(1); // sys_sso_provider injected + expect(s.objects.sys_sso_provider).toEqual(DENY); + }); + + it('is idempotent — a second apply injects nothing', () => { + const s = set('viewer_readonly'); + const first = applyManagedWriteDenies([s], schemas); + const second = applyManagedWriteDenies([s], schemas); + expect(first.applied).toBe(2); + expect(second.applied).toBe(0); + expect(second.skippedExisting).toBe(2); + }); + + it('injects a distinct object per entry (no shared reference across tables)', () => { + const s = set('member_default'); + applyManagedWriteDenies([s], schemas); + expect(s.objects.sys_user).not.toBe(s.objects.sys_sso_provider); + expect(s.objects.sys_user).not.toBe(MANAGED_DENY_ENTRY); + }); + + it('tolerates empty / malformed input', () => { + expect(applyManagedWriteDenies([], schemas)).toEqual({ applied: 0, skippedExisting: 0 }); + expect(applyManagedWriteDenies([set('member_default')], [])).toEqual({ applied: 0, skippedExisting: 0 }); + // a target set with no objects map is skipped, not thrown on + expect(() => applyManagedWriteDenies([{ name: 'member_default' } as any], schemas)).not.toThrow(); + }); + + it('the target allowlist is exactly the four write-granting sets', () => { + expect([...MANAGED_DENY_TARGET_SETS].sort()).toEqual( + ['member_default', 'organization_admin', 'viewer_readonly', MCP_AGENT_PERMISSION_SET_WRITE].sort(), + ); + }); +}); diff --git a/packages/plugins/plugin-security/src/managed-object-write-denies.ts b/packages/plugins/plugin-security/src/managed-object-write-denies.ts new file mode 100644 index 0000000000..3037a7fe14 --- /dev/null +++ b/packages/plugins/plugin-security/src/managed-object-write-denies.ts @@ -0,0 +1,128 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * ADR-0092 / ADR-0103 — registry-driven managed-object write denies for the + * default permission sets. + * + * The default write-granting permission sets (`organization_admin`, + * `member_default`, `viewer_readonly`, and the MCP write set) grant CRUD via a + * `'*'` wildcard, then DENY writes on the better-auth-managed identity tables so + * mutations must flow through the auth pipeline (ADR-0092). That deny-list was a + * hand-maintained object-name array (`BETTER_AUTH_MANAGED_OBJECTS` in + * `default-permission-sets.ts`) — precisely the drift ADR-0092 forbids, and it + * HAD drifted (the static list missed schemas that later declared + * `managedBy: 'better-auth'`). This module derives the deny-list from the live + * registry so the permission layer can never silently disagree with the schemas. + * + * ── Reference-sharing contract (why this mutates in place) ── + * The runtime permission evaluator resolves the default sets from the + * in-memory `bootstrapPermissionSets` objects, NOT from the seeded + * `sys_permission_set.object_permissions` DB JSON (a DB-row-only fix would be + * dead code). Those same `PermissionSet` instances are the ones registered via + * `manifest.register({ permissions })`, handed to the evaluator fallback, AND + * serialized into the seed row — the registry stores items by reference. So a + * single in-place mutation of `set.objects` here, run once at `kernel:ready` + * before the platform-admin seeder, updates every consumer atomically. + * + * ── Why the better-auth bucket is hard-denied, ignoring `userActions` ── + * `sys_user` declares `userActions: { edit: true }` (it opens name/image edits + * for the UI and the identity guard's field whitelist), so its resolved CRUD + * affordance grants `edit`. But a permission-set boolean cannot express a + * field-level whitelist, so the default sets must still deny `allowEdit` on + * `sys_user` — the field-level narrowing is the identity write guard's job. We + * therefore key off the raw `managedBy: 'better-auth'` bucket and deny writes + * unconditionally, rather than deriving from `resolveCrudAffordances` (which + * would wrongly widen `sys_user`). This byte-preserves the prior behavior. + * + * ── Deliberately NOT covered: engine-owned system / append-only objects ── + * ADR-0103's engine-owned objects (`sys_audit_log`, `sys_automation_run`, …) + * are also wildcard-granted in these sets, but we do NOT inject deny entries for + * them: a per-object entry FULLY OVERRIDES the wildcard (lookup, not merge — see + * `default-permission-sets.ts`), so injecting `{ allowRead: true, ...writes:false }` + * would silently drop the wildcard's `viewAllRecords` / `modifyAllRecords` — a + * read-side narrowing. Their user-context writes are already rejected at the + * engine by `assertEngineOwnedWriteAllowed` (ADR-0103) and reflected in the + * `/me/permissions` clamp, so the permission-set layer needs no change for them. + */ + +import type { PermissionSet } from '@objectstack/spec/security'; +import { MCP_AGENT_PERMISSION_SET_WRITE } from '@objectstack/spec/ai'; + +/** + * The write posture forced onto every managed identity table: readable, but no + * generic create / edit / delete. Same shape as the entries the static + * `denyWritesOnManagedObjects()` baseline emits (deliberately no + * `viewAllRecords` / `modifyAllRecords` key). + */ +export const MANAGED_DENY_ENTRY = { + allowRead: true, + allowCreate: false, + allowEdit: false, + allowDelete: false, +}; + +/** + * The default sets whose `'*'` wildcard grants writes and therefore must carry + * the managed-table denies. Explicit allowlist — `admin_full_access` is + * deliberately excluded (it keeps its unqualified wildcard so an admin can + * rescue data directly; the runtime guards are its boundary), as are the MCP + * read / restricted sets (they grant no writes). + */ +export const MANAGED_DENY_TARGET_SETS: readonly string[] = [ + 'organization_admin', + 'member_default', + 'viewer_readonly', + MCP_AGENT_PERMISSION_SET_WRITE, +]; + +interface SchemaLike { + name?: string; + managedBy?: string; +} + +export interface ApplyManagedWriteDeniesResult { + /** Number of (set, object) deny entries newly injected. */ + applied: number; + /** Number of (set, object) pairs left untouched because an entry already existed. */ + skippedExisting: number; +} + +/** + * Inject a read-only-write deny entry for every `managedBy: 'better-auth'` + * object into each target set that lacks an explicit entry for it. In-place and + * idempotent (never overrides an existing entry — so the static baseline and + * any deliberate per-object carve-out, e.g. org-admin's RBAC read-only block, + * are preserved). Returns counts for logging / tests. + */ +export function applyManagedWriteDenies( + sets: PermissionSet[], + schemas: SchemaLike[], +): ApplyManagedWriteDeniesResult { + const managedNames = Array.from( + new Set( + (schemas ?? []) + .filter((s) => s?.managedBy === 'better-auth' && typeof s?.name === 'string') + .map((s) => s.name as string), + ), + ).sort(); + + const targets = new Set(MANAGED_DENY_TARGET_SETS); + let applied = 0; + let skippedExisting = 0; + + for (const set of sets ?? []) { + if (!set || !targets.has((set as { name?: string }).name ?? '')) continue; + const objects = (set as { objects?: Record }).objects; + if (!objects || typeof objects !== 'object') continue; + for (const name of managedNames) { + if (name in objects) { + skippedExisting++; + continue; + } + objects[name] = { ...MANAGED_DENY_ENTRY }; + applied++; + } + } + + return { applied, skippedExisting }; +} diff --git a/packages/plugins/plugin-security/src/objects/default-permission-sets.test.ts b/packages/plugins/plugin-security/src/objects/default-permission-sets.test.ts new file mode 100644 index 0000000000..ead92fd4c7 --- /dev/null +++ b/packages/plugins/plugin-security/src/objects/default-permission-sets.test.ts @@ -0,0 +1,62 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// #3325 — pin the better-auth managed-object deny-list against the real schemas +// so it can never silently drift again (ADR-0092: registry-driven, no rot). + +import { describe, it, expect } from 'vitest'; +import * as PlatformObjects from '@objectstack/platform-objects'; +import { defaultPermissionSets, BETTER_AUTH_MANAGED_OBJECTS } from './default-permission-sets.js'; +import { MANAGED_DENY_TARGET_SETS } from '../managed-object-write-denies.js'; + +// Every object schema the platform-objects package exports whose bucket is +// `better-auth` — the ground truth the static baseline must mirror. +const betterAuthSchemaNames = Object.values(PlatformObjects as Record) + .filter((v) => v && typeof v === 'object' && typeof v.name === 'string' && v.managedBy === 'better-auth') + .map((v) => v.name as string) + .sort(); + +const listNames = [...BETTER_AUTH_MANAGED_OBJECTS].sort(); +const setByName = (name: string): any => defaultPermissionSets.find((s) => s.name === name); + +describe('BETTER_AUTH_MANAGED_OBJECTS ↔ schemas (drift pin, #3325)', () => { + it('found the better-auth identity schemas to compare against', () => { + // Guard against a broken import silently passing the bidirectional check. + expect(betterAuthSchemaNames.length).toBeGreaterThanOrEqual(20); + }); + + it('every listed name is a real object declaring managedBy:"better-auth"', () => { + const notBetterAuth = listNames.filter((n) => !betterAuthSchemaNames.includes(n)); + expect(notBetterAuth).toEqual([]); + }); + + it('every better-auth schema is in the list (this is the drift that #3325 fixes)', () => { + const missing = betterAuthSchemaNames.filter((n) => !listNames.includes(n)); + expect(missing).toEqual([]); + }); + + it('the list has no duplicates', () => { + expect(listNames.length).toBe(new Set(listNames).size); + }); +}); + +describe('default permission sets carry the managed denies (static baseline)', () => { + it('each write-granting target set denies create/edit/delete on every managed object', () => { + for (const setName of MANAGED_DENY_TARGET_SETS) { + const set = setByName(setName); + expect(set, `set ${setName} exists`).toBeTruthy(); + for (const obj of BETTER_AUTH_MANAGED_OBJECTS) { + const entry = set.objects[obj]; + expect(entry, `${setName} has entry for ${obj}`).toBeTruthy(); + expect(entry.allowCreate).toBe(false); + expect(entry.allowEdit).toBe(false); + expect(entry.allowDelete).toBe(false); + expect(entry.allowRead).toBe(true); + } + } + }); + + it('admin_full_access keeps its bare wildcard (zero per-object entries) — admin rescue path', () => { + const admin = setByName('admin_full_access'); + expect(admin).toBeTruthy(); + expect(Object.keys(admin.objects)).toEqual(['*']); + }); +}); 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 e3386ba817..f04c86adba 100644 --- a/packages/plugins/plugin-security/src/objects/default-permission-sets.ts +++ b/packages/plugins/plugin-security/src/objects/default-permission-sets.ts @@ -22,10 +22,18 @@ import { * permission sets keep their `*` wildcard so they can rescue data * directly when needed. * - * Each entry mirrors the `managedBy: 'better-auth'` flag declared on - * the corresponding object schema in `packages/platform-objects/src/identity/`. + * This is the COMPILE-TIME BASELINE. At `kernel:ready` it is unioned with the + * live registry by `applyManagedWriteDenies` (see `managed-object-write-denies.ts`), + * which injects a deny entry for every registered `managedBy: 'better-auth'` + * object the baseline missed — so a newly-declared identity table is covered + * automatically without editing this list. The baseline still matters: it covers + * the pre-`kernel:ready` window and hook-less test-stub kernels where the + * registry may be empty. `objects/default-permission-sets.test.ts` pins this + * list bidirectionally against the `@objectstack/platform-objects` schemas so it + * cannot silently rot again (the drift that motivated ADR-0092's registry-driven + * rule). */ -const BETTER_AUTH_MANAGED_OBJECTS = [ +export const BETTER_AUTH_MANAGED_OBJECTS = [ 'sys_user', 'sys_account', 'sys_session', @@ -39,10 +47,15 @@ const BETTER_AUTH_MANAGED_OBJECTS = [ 'sys_verification', 'sys_jwks', 'sys_device_code', + 'sys_scim_provider', + 'sys_sso_provider', 'sys_oauth_application', 'sys_oauth_access_token', 'sys_oauth_refresh_token', 'sys_oauth_consent', + 'sys_oauth_resource', + 'sys_oauth_client_resource', + 'sys_oauth_client_assertion', ] as const; const denyWritesOnManagedObjects = (): Record { expect(d.principal.onBehalfOf?.userId).toBe(DELEGATOR); }); }); + +// --------------------------------------------------------------------------- +// [#3325] Registry-driven managed-object write denies — reference-sharing pin. +// runBootstrap unions the better-auth denies from the LIVE registry into the +// in-memory bootstrapPermissionSets — the SAME array the evaluator resolves and +// bootstrapPlatformAdmin serializes. This pins that a registry object the static +// baseline does NOT list still gets denied (the one real implementation risk: +// if the registry ever cloned items, the in-place mutation would stop reaching +// the evaluator). +// --------------------------------------------------------------------------- +describe('managed-object write denies wiring (#3325)', () => { + it('injects a registry-only better-auth object into the shared default sets', async () => { + const ql: any = { + registerMiddleware: vi.fn(), + getSchema: () => undefined, + findOne: async () => null, + find: async () => [], + count: async () => 0, + insert: async (_o: string, d: any) => ({ id: d?.id ?? 'x' }), + update: async () => {}, + // A better-auth object the static BETTER_AUTH_MANAGED_OBJECTS list does NOT contain. + _registry: { + listItems: (type: string) => + type === 'object' ? [{ name: 'sys_fake_identity', managedBy: 'better-auth' }] : [], + }, + }; + const services: Record = { + manifest: { register: vi.fn() }, + objectql: ql, + metadata: { get: async () => null, list: async () => [] }, + }; + const ctx: any = { + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, + registerService: vi.fn(), + getService: (name: string) => { + if (!(name in services)) throw new Error(`service not registered: ${name}`); + return services[name]; + }, + // No `hook` → start() runs runBootstrap synchronously via the fallback, + // and the transform executes in runBootstrap's synchronous prefix (before + // the first await), so the mutation is visible right after start() resolves. + }; + const plugin = new SecurityPlugin(); + await plugin.init(ctx); + await plugin.start(ctx); + + const sets = (plugin as any).bootstrapPermissionSets as any[]; + const member = sets.find((s) => s.name === 'member_default'); + expect(member.objects.sys_fake_identity).toEqual({ + allowRead: true, allowCreate: false, allowEdit: false, allowDelete: false, + }); + // admin_full_access keeps its bare wildcard — never denied. + const admin = sets.find((s) => s.name === 'admin_full_access'); + expect(admin.objects.sys_fake_identity).toBeUndefined(); + }); +}); diff --git a/packages/plugins/plugin-security/src/security-plugin.ts b/packages/plugins/plugin-security/src/security-plugin.ts index 1463887327..0058cbcd46 100644 --- a/packages/plugins/plugin-security/src/security-plugin.ts +++ b/packages/plugins/plugin-security/src/security-plugin.ts @@ -14,7 +14,8 @@ import { } from './explain-engine.js'; import type { ExplainDecision, ExplainOperation } from '@objectstack/spec/security'; import { bootstrapDeclaredPositions } from './bootstrap-declared-positions.js'; -import { bootstrapDeclaredPermissions, upsertPackagePermissionSet } from './bootstrap-declared-permissions.js'; +import { bootstrapDeclaredPermissions, upsertPackagePermissionSet, readDeclared } from './bootstrap-declared-permissions.js'; +import { applyManagedWriteDenies } from './managed-object-write-denies.js'; import { createPermissionSetWriteThrough, registerPermissionSetProjection, @@ -1540,8 +1541,31 @@ export class SecurityPlugin implements Plugin { // onMetadataMutation fallback appends listeners, and re-wiring that would // project each save N times. let envProjectionWired = false; + // [#3325 / ADR-0092] Union the better-auth managed-object write denies into + // the default sets from the LIVE registry, replacing the hand-maintained + // baseline as the source of truth (a newly-declared identity table is then + // covered without editing a list). This mutates the shared + // `bootstrapPermissionSets` instances IN PLACE — the same objects the + // permission evaluator resolves and `bootstrapPlatformAdmin` serializes into + // the seed row — so it must run BEFORE the seeder below, and once (the + // transform is idempotent, but the once-flag avoids re-reading the registry / + // re-logging on every runBootstrap re-entry). See managed-object-write-denies.ts. + let managedDeniesApplied = false; const runBootstrap = async () => { try { + if (!managedDeniesApplied) { + const { applied, skippedExisting } = applyManagedWriteDenies( + this.bootstrapPermissionSets, + readDeclared(ql, 'object'), + ); + managedDeniesApplied = true; + if (applied > 0) { + ctx.logger.info( + `[security] managed-object write denies unioned from registry (ADR-0092): ` + + `${applied} injected, ${skippedExisting} already present`, + ); + } + } const report = await bootstrapPlatformAdmin(ql, this.bootstrapPermissionSets, { logger: ctx.logger, });