Skip to content

Commit 9d897b3

Browse files
authored
fix(security): derive better-auth managed-object write denies from the registry (#3325) (#3326)
Derive the better-auth managed-object write deny-list from the live registry instead of a hand-maintained list (which had drifted 17/22). New applyManagedWriteDenies unions the denies into the shared in-memory default permission sets at kernel:ready; static baseline completed to 22 and pinned by test; engine-owned system/append-only objects deliberately excluded. plugin-security 539 passed. Closes #3325. Follows #3220 / ADR-0092 / ADR-0103 / ADR-0049.
1 parent b320158 commit 9d897b3

8 files changed

Lines changed: 440 additions & 4 deletions
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
"@objectstack/plugin-security": patch
3+
---
4+
5+
**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).
6+
7+
- 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).
8+
- 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.
9+
- 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.
10+
11+
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.

packages/plugins/plugin-security/src/bootstrap-platform-admin.test.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515

1616
import { describe, it, expect } from 'vitest';
1717
import { bootstrapPlatformAdmin } from './bootstrap-platform-admin.js';
18+
import { applyManagedWriteDenies } from './managed-object-write-denies.js';
1819

1920
/** Minimal in-memory ql. Only `sys_permission_set` is modeled; the admin-
2021
* promotion tables return empty, so promotion short-circuits and we assert the
@@ -107,3 +108,36 @@ describe('bootstrapPlatformAdmin — insert-once vs resync (#2705)', () => {
107108
expect(JSON.parse(row(ql)!.system_permissions)).toEqual(['setup.access']);
108109
});
109110
});
111+
112+
// #3325 — the transform runs upstream in runBootstrap; here we confirm that once
113+
// a set has been enriched by applyManagedWriteDenies, bootstrapPlatformAdmin
114+
// serializes the injected denies into the seeded object_permissions JSON.
115+
describe('bootstrapPlatformAdmin — managed write denies land in the seed row (#3325)', () => {
116+
const schemas = [
117+
{ name: 'sys_sso_provider', managedBy: 'better-auth' },
118+
{ name: 'crm_lead', managedBy: 'platform' },
119+
];
120+
// member_default is a target set; give it the wildcard shape the real one has.
121+
const enriched = () => {
122+
const set = { name: 'member_default', label: 'Member', objects: { '*': { allowRead: true, allowCreate: true } } } as any;
123+
applyManagedWriteDenies([set], schemas);
124+
return set;
125+
};
126+
127+
it('fresh DB: the injected deny serializes into object_permissions', async () => {
128+
const ql = makeQl([]);
129+
await bootstrapPlatformAdmin(ql, [enriched()]);
130+
const perms = JSON.parse(row(ql)!.object_permissions);
131+
expect(perms.sys_sso_provider).toEqual({ allowRead: true, allowCreate: false, allowEdit: false, allowDelete: false });
132+
expect(perms.crm_lead).toBeUndefined(); // platform bucket never denied
133+
});
134+
135+
it('resync: reconciles a stale platform-owned row that predates the deny', async () => {
136+
const ql = makeQl([
137+
{ id: 'ps_old', name: 'member_default', system_permissions: '[]', object_permissions: '{}', managed_by: null },
138+
]);
139+
const r = await bootstrapPlatformAdmin(ql, [enriched()], { resync: true });
140+
expect(r.resynced).toBe(1);
141+
expect(JSON.parse(row(ql)!.object_permissions).sys_sso_provider.allowCreate).toBe(false);
142+
});
143+
});
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
// ADR-0092 / ADR-0103 / #3325 — registry-driven managed-object write denies.
3+
4+
import { describe, it, expect } from 'vitest';
5+
import {
6+
applyManagedWriteDenies,
7+
MANAGED_DENY_ENTRY,
8+
MANAGED_DENY_TARGET_SETS,
9+
} from './managed-object-write-denies.js';
10+
import { MCP_AGENT_PERMISSION_SET_WRITE, MCP_AGENT_PERMISSION_SET_READ } from '@objectstack/spec/ai';
11+
12+
// Minimal PermissionSet-shaped fixtures (only name + objects matter here).
13+
const set = (name: string, objects: Record<string, unknown> = {}): any => ({ name, objects });
14+
15+
const DENY = { allowRead: true, allowCreate: false, allowEdit: false, allowDelete: false };
16+
17+
const schemas = [
18+
{ name: 'sys_user', managedBy: 'better-auth', userActions: { edit: true } }, // intentional divergence
19+
{ name: 'sys_sso_provider', managedBy: 'better-auth' },
20+
{ name: 'crm_lead', managedBy: 'platform' },
21+
{ name: 'sys_setting', managedBy: 'system' },
22+
{ name: 'sys_audit_log', managedBy: 'append-only' },
23+
{ name: 'sys_sharing_rule', managedBy: 'config' },
24+
{ name: 'sys_no_bucket' }, // unset
25+
];
26+
27+
describe('applyManagedWriteDenies (#3325)', () => {
28+
it('injects a read-only-write deny for every better-auth object into the four target sets', () => {
29+
const sets = [
30+
set('organization_admin'),
31+
set('member_default'),
32+
set('viewer_readonly'),
33+
set(MCP_AGENT_PERMISSION_SET_WRITE),
34+
];
35+
const res = applyManagedWriteDenies(sets, schemas);
36+
// 2 better-auth objects × 4 sets = 8 injections.
37+
expect(res.applied).toBe(8);
38+
expect(res.skippedExisting).toBe(0);
39+
for (const s of sets) {
40+
expect(s.objects.sys_user).toEqual(DENY);
41+
expect(s.objects.sys_sso_provider).toEqual(DENY);
42+
}
43+
});
44+
45+
it('hard-denies sys_user despite userActions.edit:true (permission-set booleans cannot whitelist fields)', () => {
46+
const s = set('member_default');
47+
applyManagedWriteDenies([s], schemas);
48+
expect(s.objects.sys_user).toEqual(DENY);
49+
expect(s.objects.sys_user.allowEdit).toBe(false);
50+
});
51+
52+
it('ignores platform / config / system / append-only / unset buckets (pins the ADR-0103 deferral)', () => {
53+
const s = set('member_default');
54+
applyManagedWriteDenies([s], schemas);
55+
for (const name of ['crm_lead', 'sys_setting', 'sys_audit_log', 'sys_sharing_rule', 'sys_no_bucket']) {
56+
expect(s.objects[name]).toBeUndefined();
57+
}
58+
});
59+
60+
it('never touches admin_full_access or the MCP read/restricted sets', () => {
61+
const admin = set('admin_full_access', { '*': { allowRead: true, allowCreate: true } });
62+
const mcpRead = set(MCP_AGENT_PERMISSION_SET_READ, { '*': { allowRead: true } });
63+
const res = applyManagedWriteDenies([admin, mcpRead], schemas);
64+
expect(res.applied).toBe(0);
65+
expect(admin.objects).toEqual({ '*': { allowRead: true, allowCreate: true } });
66+
expect(mcpRead.objects.sys_user).toBeUndefined();
67+
});
68+
69+
it('never overrides an existing explicit entry (protects the org-admin RBAC block / static baseline)', () => {
70+
// Mirror org-admin: sys_user already carved out read-only; keep it verbatim.
71+
const existing = { allowRead: true, allowCreate: false, allowEdit: false, allowDelete: false, viewAllRecords: true };
72+
const s = set('organization_admin', { sys_user: existing });
73+
const res = applyManagedWriteDenies([s], schemas);
74+
expect(s.objects.sys_user).toBe(existing); // same reference, untouched (viewAllRecords preserved)
75+
expect(res.skippedExisting).toBe(1); // sys_user skipped
76+
expect(res.applied).toBe(1); // sys_sso_provider injected
77+
expect(s.objects.sys_sso_provider).toEqual(DENY);
78+
});
79+
80+
it('is idempotent — a second apply injects nothing', () => {
81+
const s = set('viewer_readonly');
82+
const first = applyManagedWriteDenies([s], schemas);
83+
const second = applyManagedWriteDenies([s], schemas);
84+
expect(first.applied).toBe(2);
85+
expect(second.applied).toBe(0);
86+
expect(second.skippedExisting).toBe(2);
87+
});
88+
89+
it('injects a distinct object per entry (no shared reference across tables)', () => {
90+
const s = set('member_default');
91+
applyManagedWriteDenies([s], schemas);
92+
expect(s.objects.sys_user).not.toBe(s.objects.sys_sso_provider);
93+
expect(s.objects.sys_user).not.toBe(MANAGED_DENY_ENTRY);
94+
});
95+
96+
it('tolerates empty / malformed input', () => {
97+
expect(applyManagedWriteDenies([], schemas)).toEqual({ applied: 0, skippedExisting: 0 });
98+
expect(applyManagedWriteDenies([set('member_default')], [])).toEqual({ applied: 0, skippedExisting: 0 });
99+
// a target set with no objects map is skipped, not thrown on
100+
expect(() => applyManagedWriteDenies([{ name: 'member_default' } as any], schemas)).not.toThrow();
101+
});
102+
103+
it('the target allowlist is exactly the four write-granting sets', () => {
104+
expect([...MANAGED_DENY_TARGET_SETS].sort()).toEqual(
105+
['member_default', 'organization_admin', 'viewer_readonly', MCP_AGENT_PERMISSION_SET_WRITE].sort(),
106+
);
107+
});
108+
});
Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* ADR-0092 / ADR-0103 — registry-driven managed-object write denies for the
5+
* default permission sets.
6+
*
7+
* The default write-granting permission sets (`organization_admin`,
8+
* `member_default`, `viewer_readonly`, and the MCP write set) grant CRUD via a
9+
* `'*'` wildcard, then DENY writes on the better-auth-managed identity tables so
10+
* mutations must flow through the auth pipeline (ADR-0092). That deny-list was a
11+
* hand-maintained object-name array (`BETTER_AUTH_MANAGED_OBJECTS` in
12+
* `default-permission-sets.ts`) — precisely the drift ADR-0092 forbids, and it
13+
* HAD drifted (the static list missed schemas that later declared
14+
* `managedBy: 'better-auth'`). This module derives the deny-list from the live
15+
* registry so the permission layer can never silently disagree with the schemas.
16+
*
17+
* ── Reference-sharing contract (why this mutates in place) ──
18+
* The runtime permission evaluator resolves the default sets from the
19+
* in-memory `bootstrapPermissionSets` objects, NOT from the seeded
20+
* `sys_permission_set.object_permissions` DB JSON (a DB-row-only fix would be
21+
* dead code). Those same `PermissionSet` instances are the ones registered via
22+
* `manifest.register({ permissions })`, handed to the evaluator fallback, AND
23+
* serialized into the seed row — the registry stores items by reference. So a
24+
* single in-place mutation of `set.objects` here, run once at `kernel:ready`
25+
* before the platform-admin seeder, updates every consumer atomically.
26+
*
27+
* ── Why the better-auth bucket is hard-denied, ignoring `userActions` ──
28+
* `sys_user` declares `userActions: { edit: true }` (it opens name/image edits
29+
* for the UI and the identity guard's field whitelist), so its resolved CRUD
30+
* affordance grants `edit`. But a permission-set boolean cannot express a
31+
* field-level whitelist, so the default sets must still deny `allowEdit` on
32+
* `sys_user` — the field-level narrowing is the identity write guard's job. We
33+
* therefore key off the raw `managedBy: 'better-auth'` bucket and deny writes
34+
* unconditionally, rather than deriving from `resolveCrudAffordances` (which
35+
* would wrongly widen `sys_user`). This byte-preserves the prior behavior.
36+
*
37+
* ── Deliberately NOT covered: engine-owned system / append-only objects ──
38+
* ADR-0103's engine-owned objects (`sys_audit_log`, `sys_automation_run`, …)
39+
* are also wildcard-granted in these sets, but we do NOT inject deny entries for
40+
* them: a per-object entry FULLY OVERRIDES the wildcard (lookup, not merge — see
41+
* `default-permission-sets.ts`), so injecting `{ allowRead: true, ...writes:false }`
42+
* would silently drop the wildcard's `viewAllRecords` / `modifyAllRecords` — a
43+
* read-side narrowing. Their user-context writes are already rejected at the
44+
* engine by `assertEngineOwnedWriteAllowed` (ADR-0103) and reflected in the
45+
* `/me/permissions` clamp, so the permission-set layer needs no change for them.
46+
*/
47+
48+
import type { PermissionSet } from '@objectstack/spec/security';
49+
import { MCP_AGENT_PERMISSION_SET_WRITE } from '@objectstack/spec/ai';
50+
51+
/**
52+
* The write posture forced onto every managed identity table: readable, but no
53+
* generic create / edit / delete. Same shape as the entries the static
54+
* `denyWritesOnManagedObjects()` baseline emits (deliberately no
55+
* `viewAllRecords` / `modifyAllRecords` key).
56+
*/
57+
export const MANAGED_DENY_ENTRY = {
58+
allowRead: true,
59+
allowCreate: false,
60+
allowEdit: false,
61+
allowDelete: false,
62+
};
63+
64+
/**
65+
* The default sets whose `'*'` wildcard grants writes and therefore must carry
66+
* the managed-table denies. Explicit allowlist — `admin_full_access` is
67+
* deliberately excluded (it keeps its unqualified wildcard so an admin can
68+
* rescue data directly; the runtime guards are its boundary), as are the MCP
69+
* read / restricted sets (they grant no writes).
70+
*/
71+
export const MANAGED_DENY_TARGET_SETS: readonly string[] = [
72+
'organization_admin',
73+
'member_default',
74+
'viewer_readonly',
75+
MCP_AGENT_PERMISSION_SET_WRITE,
76+
];
77+
78+
interface SchemaLike {
79+
name?: string;
80+
managedBy?: string;
81+
}
82+
83+
export interface ApplyManagedWriteDeniesResult {
84+
/** Number of (set, object) deny entries newly injected. */
85+
applied: number;
86+
/** Number of (set, object) pairs left untouched because an entry already existed. */
87+
skippedExisting: number;
88+
}
89+
90+
/**
91+
* Inject a read-only-write deny entry for every `managedBy: 'better-auth'`
92+
* object into each target set that lacks an explicit entry for it. In-place and
93+
* idempotent (never overrides an existing entry — so the static baseline and
94+
* any deliberate per-object carve-out, e.g. org-admin's RBAC read-only block,
95+
* are preserved). Returns counts for logging / tests.
96+
*/
97+
export function applyManagedWriteDenies(
98+
sets: PermissionSet[],
99+
schemas: SchemaLike[],
100+
): ApplyManagedWriteDeniesResult {
101+
const managedNames = Array.from(
102+
new Set(
103+
(schemas ?? [])
104+
.filter((s) => s?.managedBy === 'better-auth' && typeof s?.name === 'string')
105+
.map((s) => s.name as string),
106+
),
107+
).sort();
108+
109+
const targets = new Set(MANAGED_DENY_TARGET_SETS);
110+
let applied = 0;
111+
let skippedExisting = 0;
112+
113+
for (const set of sets ?? []) {
114+
if (!set || !targets.has((set as { name?: string }).name ?? '')) continue;
115+
const objects = (set as { objects?: Record<string, unknown> }).objects;
116+
if (!objects || typeof objects !== 'object') continue;
117+
for (const name of managedNames) {
118+
if (name in objects) {
119+
skippedExisting++;
120+
continue;
121+
}
122+
objects[name] = { ...MANAGED_DENY_ENTRY };
123+
applied++;
124+
}
125+
}
126+
127+
return { applied, skippedExisting };
128+
}
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
// #3325 — pin the better-auth managed-object deny-list against the real schemas
3+
// so it can never silently drift again (ADR-0092: registry-driven, no rot).
4+
5+
import { describe, it, expect } from 'vitest';
6+
import * as PlatformObjects from '@objectstack/platform-objects';
7+
import { defaultPermissionSets, BETTER_AUTH_MANAGED_OBJECTS } from './default-permission-sets.js';
8+
import { MANAGED_DENY_TARGET_SETS } from '../managed-object-write-denies.js';
9+
10+
// Every object schema the platform-objects package exports whose bucket is
11+
// `better-auth` — the ground truth the static baseline must mirror.
12+
const betterAuthSchemaNames = Object.values(PlatformObjects as Record<string, any>)
13+
.filter((v) => v && typeof v === 'object' && typeof v.name === 'string' && v.managedBy === 'better-auth')
14+
.map((v) => v.name as string)
15+
.sort();
16+
17+
const listNames = [...BETTER_AUTH_MANAGED_OBJECTS].sort();
18+
const setByName = (name: string): any => defaultPermissionSets.find((s) => s.name === name);
19+
20+
describe('BETTER_AUTH_MANAGED_OBJECTS ↔ schemas (drift pin, #3325)', () => {
21+
it('found the better-auth identity schemas to compare against', () => {
22+
// Guard against a broken import silently passing the bidirectional check.
23+
expect(betterAuthSchemaNames.length).toBeGreaterThanOrEqual(20);
24+
});
25+
26+
it('every listed name is a real object declaring managedBy:"better-auth"', () => {
27+
const notBetterAuth = listNames.filter((n) => !betterAuthSchemaNames.includes(n));
28+
expect(notBetterAuth).toEqual([]);
29+
});
30+
31+
it('every better-auth schema is in the list (this is the drift that #3325 fixes)', () => {
32+
const missing = betterAuthSchemaNames.filter((n) => !listNames.includes(n));
33+
expect(missing).toEqual([]);
34+
});
35+
36+
it('the list has no duplicates', () => {
37+
expect(listNames.length).toBe(new Set(listNames).size);
38+
});
39+
});
40+
41+
describe('default permission sets carry the managed denies (static baseline)', () => {
42+
it('each write-granting target set denies create/edit/delete on every managed object', () => {
43+
for (const setName of MANAGED_DENY_TARGET_SETS) {
44+
const set = setByName(setName);
45+
expect(set, `set ${setName} exists`).toBeTruthy();
46+
for (const obj of BETTER_AUTH_MANAGED_OBJECTS) {
47+
const entry = set.objects[obj];
48+
expect(entry, `${setName} has entry for ${obj}`).toBeTruthy();
49+
expect(entry.allowCreate).toBe(false);
50+
expect(entry.allowEdit).toBe(false);
51+
expect(entry.allowDelete).toBe(false);
52+
expect(entry.allowRead).toBe(true);
53+
}
54+
}
55+
});
56+
57+
it('admin_full_access keeps its bare wildcard (zero per-object entries) — admin rescue path', () => {
58+
const admin = setByName('admin_full_access');
59+
expect(admin).toBeTruthy();
60+
expect(Object.keys(admin.objects)).toEqual(['*']);
61+
});
62+
});

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

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,10 +22,18 @@ import {
2222
* permission sets keep their `*` wildcard so they can rescue data
2323
* directly when needed.
2424
*
25-
* Each entry mirrors the `managedBy: 'better-auth'` flag declared on
26-
* the corresponding object schema in `packages/platform-objects/src/identity/`.
25+
* This is the COMPILE-TIME BASELINE. At `kernel:ready` it is unioned with the
26+
* live registry by `applyManagedWriteDenies` (see `managed-object-write-denies.ts`),
27+
* which injects a deny entry for every registered `managedBy: 'better-auth'`
28+
* object the baseline missed — so a newly-declared identity table is covered
29+
* automatically without editing this list. The baseline still matters: it covers
30+
* the pre-`kernel:ready` window and hook-less test-stub kernels where the
31+
* registry may be empty. `objects/default-permission-sets.test.ts` pins this
32+
* list bidirectionally against the `@objectstack/platform-objects` schemas so it
33+
* cannot silently rot again (the drift that motivated ADR-0092's registry-driven
34+
* rule).
2735
*/
28-
const BETTER_AUTH_MANAGED_OBJECTS = [
36+
export const BETTER_AUTH_MANAGED_OBJECTS = [
2937
'sys_user',
3038
'sys_account',
3139
'sys_session',
@@ -39,10 +47,15 @@ const BETTER_AUTH_MANAGED_OBJECTS = [
3947
'sys_verification',
4048
'sys_jwks',
4149
'sys_device_code',
50+
'sys_scim_provider',
51+
'sys_sso_provider',
4252
'sys_oauth_application',
4353
'sys_oauth_access_token',
4454
'sys_oauth_refresh_token',
4555
'sys_oauth_consent',
56+
'sys_oauth_resource',
57+
'sys_oauth_client_resource',
58+
'sys_oauth_client_assertion',
4659
] as const;
4760

4861
const denyWritesOnManagedObjects = (): Record<string, {

0 commit comments

Comments
 (0)