Skip to content

Commit 20b79be

Browse files
os-steveclaude
andauthored
fix(plugin-security): bring organization_admin_no_bypass under registry-driven managed-write denies (#14129)
* fix(plugin-security): bring organization_admin_no_bypass under registry-driven managed-write denies (#14029) The derived wall-less org-admin variant holds a write-granting '*' wildcard but was not in MANAGED_DENY_TARGET_SETS, so applyManagedWriteDenies walked it and skipped it at kernel:ready — and because deriveWallLessOrgAdmin takes a shallow copy at module load, injections into the parent could never propagate either. Add the variant to the target list, and replace the self-referential membership pin with one that derives the required floor (write-granting wildcard sets) from the real seeded sets and diffs it against the list. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016ZC5rNQj3WEet5HAmmAkMs * fix(plugin-security): widen the #14029 floor to the modifyAllRecords write route (contract-review blocking item) The independent-property floor in default-permission-sets.test.ts recognised only the three CRUD flags, but the evaluator grants writes by a second route: MODIFY_ALL_WRITE_KEYS + objPerm.modifyAllRecords (permission-evaluator.ts:224) covers allowEdit, allowDelete and the destructive class. A future default set shaped '*': { allowRead: true, modifyAllRecords: true } therefore held edit/delete on every future identity table in the evaluator's own terms, yet had all three CRUD flags false, escaped the floor and tripped no pin — contradicting the MANAGED_DENY_TARGET_SETS docblock promise that a write-granting set not listed there fails a pin. Same "a set that should be a member can hide" class this card exists to kill, one size smaller. - default-permission-sets.test.ts: add `|| wc.modifyAllRecords === true` to the floor filter. Value test (`=== true`) kept deliberately: Zod materialises both superuser bits with .default(false) (permission.zod.ts:268), so they are present-as-false and a key-existence test would misfire today. - managed-object-write-denies.ts: align the docblock — the floor is "grants any generic write class via the three write flags OR modifyAllRecords", not "grants create/edit/delete". - .changeset: the static baseline covers 28 managed tables (BETTER_AUTH_MANAGED_OBJECTS), not 30 (non-blocking item). Zero behaviour delta on today's tree: organization_admin and admin_full_access carry all three CRUD flags true, so the derived floor set is unchanged. Measured: with a temporary '*': { allowRead: true, modifyAllRecords: true } probe set absent from MANAGED_DENY_TARGET_SETS, the widened pin goes red naming exactly the probe; the old three-flag filter stays green on the same probe (the blind spot). Probe removed; restore proven by blob hash against HEAD. plugin-security suite 94 files / 1770 tests green; all three tsc programs green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016ZC5rNQj3WEet5HAmmAkMs --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent fa06ed5 commit 20b79be

5 files changed

Lines changed: 210 additions & 17 deletions

File tree

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
---
2+
"@objectstack/plugin-security": patch
3+
---
4+
5+
fix(plugin-security): registry-driven managed-object write denies now reach `organization_admin_no_bypass` (#14029)
6+
7+
`MANAGED_DENY_TARGET_SETS` named four default sets, and `applyManagedWriteDenies`
8+
matches on it exactly — so at `kernel:ready` the injection walked the derived
9+
`organization_admin_no_bypass` variant and skipped it. The variant is a shallow
10+
copy of `organization_admin` taken at module load (`deriveWallLessOrgAdmin`
11+
strips only the `viewAllRecords`/`modifyAllRecords` superuser bits), which means
12+
its `'*'` wildcard still grants create/edit/delete AND entries injected into the
13+
parent's `objects` can never propagate to it. Its own docblock declares
14+
"managed-write denies … carried over verbatim"; the behaviour violated that
15+
declared contract.
16+
17+
No gap opens on today's tree — the static `BETTER_AUTH_MANAGED_OBJECTS`
18+
baseline covers the 28 declared managed tables and is copied into the variant at
19+
derivation. The gap was the next `managedBy: 'better-auth'` schema that lands
20+
without a hand edit to that list: `organization_admin` would receive the
21+
injected deny while the wall-less variant's wildcard kept granting raw CRUD on
22+
an identity table — precisely the drift the registry-driven module exists to
23+
close (ADR-0092), on the posture (`auto-org-admin-grant` under a wall-less
24+
deployment) where the bits are least bounded.
25+
26+
- `ORGANIZATION_ADMIN_NO_BYPASS` is now a member of `MANAGED_DENY_TARGET_SETS`.
27+
The variant's pre-existing explicit entries (static baseline, RBAC read-only
28+
block) survive unchanged — the injection skips any object a set already
29+
names.
30+
- The membership pin no longer checks the list against itself: the required
31+
floor ("default sets holding a write-granting `'*'` wildcard") is derived
32+
from the real seeded sets and diffed against the list; a non-empty
33+
difference is red. `admin_full_access` stays deliberately excluded (admin
34+
rescue path) and that exclusion is pinned exactly.

packages/plugins/plugin-security/src/managed-object-write-denies.test.ts

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
MANAGED_DENY_TARGET_SETS,
99
} from './managed-object-write-denies.js';
1010
import { MCP_AGENT_PERMISSION_SET_WRITE, MCP_AGENT_PERMISSION_SET_READ } from '@objectstack/spec/ai';
11+
import { ORGANIZATION_ADMIN_NO_BYPASS } from '@objectstack/spec';
1112

1213
// Minimal PermissionSet-shaped fixtures (only name + objects matter here).
1314
const set = (name: string, objects: Record<string, unknown> = {}): any => ({ name, objects });
@@ -25,16 +26,17 @@ const schemas = [
2526
];
2627

2728
describe('applyManagedWriteDenies (#3325)', () => {
28-
it('injects a read-only-write deny for every better-auth object into the four target sets', () => {
29+
it('injects a read-only-write deny for every better-auth object into the five target sets', () => {
2930
const sets = [
3031
set('organization_admin'),
32+
set(ORGANIZATION_ADMIN_NO_BYPASS), // #14029 — derived at module load, so it needs its own injection
3133
set('member_default'),
3234
set('viewer_readonly'),
3335
set(MCP_AGENT_PERMISSION_SET_WRITE),
3436
];
3537
const res = applyManagedWriteDenies(sets, schemas);
36-
// 2 better-auth objects × 4 sets = 8 injections.
37-
expect(res.applied).toBe(8);
38+
// 2 better-auth objects × 5 sets = 10 injections.
39+
expect(res.applied).toBe(10);
3840
expect(res.skippedExisting).toBe(0);
3941
for (const s of sets) {
4042
expect(s.objects.sys_user).toEqual(DENY);
@@ -100,9 +102,20 @@ describe('applyManagedWriteDenies (#3325)', () => {
100102
expect(() => applyManagedWriteDenies([{ name: 'member_default' } as any], schemas)).not.toThrow();
101103
});
102104

103-
it('the target allowlist is exactly the four write-granting sets', () => {
105+
it('the target allowlist is exactly the five known sets (see the independent-property pin for the floor)', () => {
106+
// Exact membership record. This is deliberately NOT the only pin on the
107+
// list: `objects/default-permission-sets.test.ts` derives the required
108+
// floor ("holds a write-granting '*' wildcard") from the real default sets
109+
// and diffs it against this list, so a set that SHOULD be a member cannot
110+
// hide behind an assertion that only restates the list (#14029).
104111
expect([...MANAGED_DENY_TARGET_SETS].sort()).toEqual(
105-
['member_default', 'organization_admin', 'viewer_readonly', MCP_AGENT_PERMISSION_SET_WRITE].sort(),
112+
[
113+
'member_default',
114+
'organization_admin',
115+
ORGANIZATION_ADMIN_NO_BYPASS,
116+
'viewer_readonly',
117+
MCP_AGENT_PERMISSION_SET_WRITE,
118+
].sort(),
106119
);
107120
});
108121
});

packages/plugins/plugin-security/src/managed-object-write-denies.ts

Lines changed: 36 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,14 @@
44
* ADR-0092 / ADR-0103 — registry-driven managed-object write denies for the
55
* default permission sets.
66
*
7-
* The default sets this module targets (`organization_admin`, `member_default`,
8-
* `viewer_readonly`, and the MCP write set) DENY writes on the
7+
* The default sets this module targets (`organization_admin`, its derived
8+
* `organization_admin_no_bypass` variant, `member_default`, `viewer_readonly`,
9+
* and the MCP write set) DENY writes on the
910
* better-auth-managed identity tables so mutations must flow through the auth
1011
* pipeline (ADR-0092). What that entry narrows differs per set, and the
11-
* difference is load-bearing for the rest of this file: `organization_admin` and
12-
* the MCP write set grant CRUD through an `objects['*']` wildcard, so their
12+
* difference is load-bearing for the rest of this file: `organization_admin`,
13+
* its no-bypass variant and the MCP write set grant CRUD through an
14+
* `objects['*']` wildcard, so their
1315
* managed-table entries are a narrowing overlay; `viewer_readonly`'s wildcard is
1416
* read-only, so its entries are belt-and-suspenders over a wildcard that already
1517
* denies; and `member_default` carries NO wildcard at all since #5491 (the
@@ -59,6 +61,7 @@
5961
*/
6062

6163
import type { PermissionSet } from '@objectstack/spec/security';
64+
import { ORGANIZATION_ADMIN_NO_BYPASS } from '@objectstack/spec';
6265
import { MCP_AGENT_PERMISSION_SET_WRITE } from '@objectstack/spec/ai';
6366

6467
/**
@@ -76,16 +79,38 @@ export const MANAGED_DENY_ENTRY = {
7679

7780
/**
7881
* The default sets that must carry an explicit entry for every managed table.
79-
* Membership is NOT "holds a write-granting `'*'` wildcard" — only
80-
* `organization_admin` and the MCP write set hold one; `viewer_readonly`'s
81-
* wildcard is read-only and `member_default` has held none since #5491 (see the
82-
* module docblock for what the injected entry does in each). Explicit allowlist
83-
* — `admin_full_access` is deliberately excluded (it keeps its unqualified
84-
* wildcard so an admin can rescue data directly; the runtime guards are its
85-
* boundary), as are the MCP read / restricted sets (they grant no writes).
82+
*
83+
* Holding a write-granting `'*'` wildcard is the FLOOR of membership, not its
84+
* definition: every default set whose wildcard grants any generic write class
85+
* — via the three write flags (`allowCreate`/`allowEdit`/`allowDelete`) OR via
86+
* `modifyAllRecords`, whose super-user bypass grants edit/delete and the
87+
* destructive class by the evaluator's second route (`MODIFY_ALL_WRITE_KEYS`,
88+
* `permission-evaluator.ts`) — must be listed here (or carry a documented
89+
* exclusion below), because the wildcard is what would otherwise grant raw
90+
* writes on a newly-declared identity table — that floor is what
91+
* `default-permission-sets.test.ts` derives independently and diffs against
92+
* this list (#14029), so a future write-granting set that is not added here
93+
* fails a pin instead of silently keeping its wildcard.
94+
* Membership is WIDER than the floor: `viewer_readonly`'s wildcard is read-only
95+
* and `member_default` has held none since #5491 — their injected entries are
96+
* belt-and-suspenders and the read grant itself, respectively (see the module
97+
* docblock for what the injected entry does in each).
98+
*
99+
* `organization_admin_no_bypass` is a member in its own right (#14029): it is
100+
* derived from `organization_admin` by a SHALLOW copy taken at module load
101+
* (`deriveWallLessOrgAdmin`), so entries injected into the parent's `objects`
102+
* at `kernel:ready` can never propagate to it — dropping only the superuser
103+
* bits leaves `allowCreate`/`allowEdit`/`allowDelete` true on its wildcard,
104+
* exactly the shape this module exists to narrow.
105+
*
106+
* Documented exclusions: `admin_full_access` is deliberately NOT a member (it
107+
* keeps its unqualified wildcard so an admin can rescue data directly; the
108+
* runtime guards are its boundary), and the MCP read / restricted sets grant
109+
* no writes for a deny to narrow.
86110
*/
87111
export const MANAGED_DENY_TARGET_SETS: readonly string[] = [
88112
'organization_admin',
113+
ORGANIZATION_ADMIN_NO_BYPASS,
89114
'member_default',
90115
'viewer_readonly',
91116
MCP_AGENT_PERMISSION_SET_WRITE,

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

Lines changed: 114 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import * as PlatformObjects from '@objectstack/platform-objects';
77
import { PermissionSetSchema } from '@objectstack/spec/security';
88
import { ADMIN_FULL_ACCESS_CAPABILITIES } from '@objectstack/spec';
99
import { defaultPermissionSets, BETTER_AUTH_MANAGED_OBJECTS } from './default-permission-sets.js';
10-
import { MANAGED_DENY_TARGET_SETS } from '../managed-object-write-denies.js';
10+
import { applyManagedWriteDenies, MANAGED_DENY_TARGET_SETS } from '../managed-object-write-denies.js';
1111

1212
// Every object schema the platform-objects package exports whose bucket is
1313
// `better-auth` — the ground truth the static baseline must mirror.
@@ -368,3 +368,116 @@ describe('admin_full_access imports the kernel capability declaration unchanged
368368
expect(admin.systemPermissions).toEqual(ADMIN_FULL_ACCESS_CAPABILITIES.systemPermissions);
369369
});
370370
});
371+
372+
/**
373+
* [#14029] The managed-deny target list, pinned against an INDEPENDENT
374+
* property instead of against itself.
375+
*
376+
* The old shape of this pin iterated `MANAGED_DENY_TARGET_SETS` to assert
377+
* membership, so it was structurally unable to see a set that SHOULD have been
378+
* a member — which is exactly how `organization_admin_no_bypass` (a shallow
379+
* copy of `organization_admin` taken at module load, write-granting wildcard
380+
* intact) sat outside the list while `applyManagedWriteDenies` walked it and
381+
* skipped it at `kernel:ready`. The floor is therefore derived here from the
382+
* REAL seeded sets — "holds a `'*'` wildcard granting any generic write
383+
* class" — and diffed against the list; a non-empty difference is red.
384+
*/
385+
describe('managed-deny targets — independent-property floor + registry union reaches the derived variant (#14029)', () => {
386+
// Derived from `defaultPermissionSets`, never from the list under test.
387+
// "Grants a write" in the evaluator's own terms: the three CRUD flags OR
388+
// `modifyAllRecords` — the super-user bypass grants edit/delete and the
389+
// destructive class by a second route (`MODIFY_ALL_WRITE_KEYS`,
390+
// `permission-evaluator.ts`), so `'*': { modifyAllRecords: true }` is
391+
// write-granting even with all three CRUD flags false. Value tests
392+
// (`=== true`), not key-existence: Zod materialises the superuser bits with
393+
// `.default(false)` (`permission.zod.ts`), so they are present-as-false.
394+
const writeGrantingWildcardSets: string[] = defaultPermissionSets
395+
.filter((s: any) => {
396+
const wc = s.objects?.['*'];
397+
return (
398+
!!wc &&
399+
(wc.allowCreate === true ||
400+
wc.allowEdit === true ||
401+
wc.allowDelete === true ||
402+
wc.modifyAllRecords === true)
403+
);
404+
})
405+
.map((s) => s.name)
406+
.sort();
407+
408+
/**
409+
* The one documented exclusion: `admin_full_access` keeps its unqualified
410+
* wildcard so an admin can rescue data directly (recorded in the
411+
* `MANAGED_DENY_TARGET_SETS` docblock; runtime guards are its boundary).
412+
* Pinned exactly, like `EDIT_EXCEPTIONS` above: widening it is an edit HERE,
413+
* which is the moment a reviewer is asked why the new set may keep raw CRUD
414+
* on identity tables.
415+
*/
416+
const WILDCARD_DENY_EXCLUSIONS = ['admin_full_access'];
417+
418+
it('the property derivation is live (found the known write-granting sets)', () => {
419+
// If the filter silently matched nothing, the difference below would be
420+
// vacuously empty — guard the probe itself.
421+
expect(writeGrantingWildcardSets).toContain('organization_admin');
422+
expect(writeGrantingWildcardSets).toContain('organization_admin_no_bypass');
423+
expect(writeGrantingWildcardSets.length).toBeGreaterThanOrEqual(3);
424+
});
425+
426+
it('every write-granting wildcard set is a managed-deny target or a documented exclusion', () => {
427+
const missing = writeGrantingWildcardSets.filter(
428+
(name) => !MANAGED_DENY_TARGET_SETS.includes(name) && !WILDCARD_DENY_EXCLUSIONS.includes(name),
429+
);
430+
expect(missing, 'write-granting sets missing from MANAGED_DENY_TARGET_SETS').toEqual([]);
431+
});
432+
433+
it('the exclusion list is exactly admin_full_access, and it really is outside the target list', () => {
434+
expect(WILDCARD_DENY_EXCLUSIONS).toEqual(['admin_full_access']);
435+
expect([...MANAGED_DENY_TARGET_SETS]).not.toContain('admin_full_access');
436+
});
437+
438+
// ── The behaviour the membership buys, measured on the REAL derived set ──
439+
// (clones so the module-level instances other tests read stay unmutated;
440+
// the kernel path hands the same objects to the same function in place).
441+
442+
const FUTURE = 'sys_future_identity_table';
443+
const DENY = { allowRead: true, allowCreate: false, allowEdit: false, allowDelete: false };
444+
445+
it('a managedBy:better-auth object OUTSIDE the static list now reaches the variant (the card)', () => {
446+
const sets: any[] = structuredClone(defaultPermissionSets as any);
447+
const variant = sets.find((s) => s.name === 'organization_admin_no_bypass');
448+
const parent = sets.find((s) => s.name === 'organization_admin');
449+
expect(variant.objects[FUTURE]).toBeUndefined(); // genuinely not in the compile-time baseline
450+
applyManagedWriteDenies(sets, [{ name: FUTURE, managedBy: 'better-auth' }]);
451+
expect(variant.objects[FUTURE]).toEqual(DENY);
452+
// The fix ADDS a target; the parent keeps receiving its injection too.
453+
expect(parent.objects[FUTURE]).toEqual(DENY);
454+
});
455+
456+
it('reverse control: the variant pre-existing explicit entries survive the injection unchanged', () => {
457+
const sets: any[] = structuredClone(defaultPermissionSets as any);
458+
const variant = sets.find((s) => s.name === 'organization_admin_no_bypass');
459+
const before = structuredClone(variant.objects);
460+
const registry = [
461+
...BETTER_AUTH_MANAGED_OBJECTS.map((n) => ({ name: n, managedBy: 'better-auth' })),
462+
{ name: FUTURE, managedBy: 'better-auth' },
463+
];
464+
applyManagedWriteDenies(sets, registry);
465+
for (const name of BETTER_AUTH_MANAGED_OBJECTS) {
466+
expect(variant.objects[name], `variant entry ${name}`).toEqual(before[name]);
467+
}
468+
// Wildcard and the anti-escalation RBAC read-only block untouched as well.
469+
expect(variant.objects['*']).toEqual(before['*']);
470+
expect(variant.objects.sys_position).toEqual(before.sys_position);
471+
// Only the future table was new on the variant.
472+
expect(variant.objects[FUTURE]).toEqual(DENY);
473+
expect(Object.keys(variant.objects).sort()).toEqual([...Object.keys(before), FUTURE].sort());
474+
});
475+
476+
it('control: admin_full_access is untouched by the injection (admin rescue path)', () => {
477+
const sets: any[] = structuredClone(defaultPermissionSets as any);
478+
const admin = sets.find((s) => s.name === 'admin_full_access');
479+
const before = structuredClone(admin);
480+
applyManagedWriteDenies(sets, [{ name: FUTURE, managedBy: 'better-auth' }]);
481+
expect(admin).toEqual(before);
482+
});
483+
});

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

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1037,6 +1037,14 @@ const baseDefaultPermissionSets: PermissionSet[] = [
10371037
* silent privilege difference. The only intended delta is the superuser bits,
10381038
* so the only thing this function may do is remove them.
10391039
*
1040+
* ⚠️ The copy is SHALLOW and taken at MODULE LOAD, so what it carries over is
1041+
* the compile-time baseline only — the registry-driven managed-write denies
1042+
* that `applyManagedWriteDenies` injects into the parent's `objects` at
1043+
* `kernel:ready` can never propagate here. "Carried over verbatim" holds for
1044+
* the registry union because the variant is its own member of
1045+
* `MANAGED_DENY_TARGET_SETS` and receives the same injection directly
1046+
* (#14029), not because the derivation sees it.
1047+
*
10401048
* `auto-org-admin-grant` picks between the two by posture: a wall-enforcing
10411049
* posture bounds the bits (grant `organization_admin`); a wall-less one does
10421050
* not (grant this).

0 commit comments

Comments
 (0)