Skip to content

Commit 526b5f0

Browse files
committed
fix(security): clamp managed-object writes in /me/permissions to permission ∩ guard (ADR-0092 complete fix)
Completes the /me/permissions consistency fix. foldWildcardSuperUser alone fixed the false-NEGATIVE (admin couldn't see sys_user editable) but introduced a false-POSITIVE: it reported allowEdit:true for every better-auth identity table, including ones the identity write guard (#2828) actually blocks (sys_member, sys_account, sys_session, …). clampManagedObjectWrites re-applies the guard's user-context policy: a managedBy:'better-auth' object is writable only where it opted in via userActions.{edit,create,delete} (sys_user → edit; the field readonly flags then narrow it to {name,image}). Only better-auth objects are clamped — system/config/append-only have no such guard, so their permission-set result stands (admin CAN write them; must not under-report). Net: the Console's per-object FLS now equals real server enforcement (permission-set ∩ guard). Verified against a running stack: sys_user allowEdit:true + form editable; sys_member/session/account allowEdit:false (read preserved). +4 unit tests (8 total). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YGAN5VvvBi4YRGwpNT6e21
1 parent bccf7c0 commit 526b5f0

3 files changed

Lines changed: 135 additions & 16 deletions

File tree

.changeset/me-permissions-wildcard-fold.md

Lines changed: 23 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
"@objectstack/plugin-hono-server": patch
33
---
44

5-
fix(security): `/me/permissions` folds the `'*'` wildcard super-user grant into per-object FLS, matching server enforcement (ADR-0057 D10)
5+
fix(security): `/me/permissions` now reflects permission-set ∩ identity-write-guard, matching real server enforcement (ADR-0057 D10)
66

77
The `/api/v1/auth/me/permissions` per-object map merged each permission set's
88
explicit `objects` entries most-permissively per key, but treated `'*'` and
@@ -13,14 +13,25 @@ enforcement (`PermissionEvaluator.checkObjectPermission` allows as soon as any
1313
set grants, including via the `'*'` modifyAll/viewAll super-user bypass, with
1414
no deny-wins).
1515

16-
Concretely: a platform admin (`admin_full_access``'*': {modifyAllRecords}`)
17-
who also holds `organization_admin` (which denies writes on identity tables)
18-
resolved to `sys_user.allowEdit:false` in this payload, so the Console disabled
19-
the standard edit form — even though the server accepts the write (`PATCH
20-
/data/sys_user {name}` → 200). The new `foldWildcardSuperUser` post-pass lifts
21-
each per-object entry's read/write bits when the merged wildcard is a
22-
super-user grant, so the client mirrors the server (never broader — the
23-
super-user grant already covers private/managed objects server-side). This
24-
unblocks the ADR-0092 D4 `sys_user` profile-edit affordance for platform
25-
admins; the identity write guard still restricts the actual write to
26-
`{name, image}`.
16+
The real effective answer for a user-context caller is `permission-set grant ∩
17+
identity-write-guard policy`, and the payload now computes both:
18+
19+
1. `foldWildcardSuperUser` lifts each per-object entry's read/write bits when
20+
the merged `'*'` is a super-user grant — fixing the false-NEGATIVE where a
21+
platform admin (`admin_full_access` `'*': {modifyAllRecords}`) who also holds
22+
`organization_admin` (explicit identity denies) resolved to
23+
`sys_user.allowEdit:false` and a disabled edit form, though the server
24+
accepts the write (`PATCH /data/sys_user {name}` → 200).
25+
2. `clampManagedObjectWrites` re-clamps `managedBy: 'better-auth'` objects by
26+
their write affordance — fixing the false-POSITIVE the fold would otherwise
27+
introduce: the identity write guard (ADR-0092 D2) blocks user-context writes
28+
on identity tables except where the object opted in (`userActions.edit`), so
29+
`sys_member` / `sys_account` / `sys_session` stay `allowEdit:false` for the
30+
admin (read stays granted). Only `better-auth` objects are clamped — the
31+
guard covers only them; `system`/`config`/`append-only` objects have no such
32+
guard and their permission-set result stands.
33+
34+
Net: the Console's per-object FLS now equals real server enforcement — the
35+
ADR-0092 D4 `sys_user` profile-edit affordance is unblocked for platform admins
36+
(the guard still narrows the write to `{name, image}`), and no other identity
37+
table is shown as editable when the guard would reject it.

packages/plugins/plugin-hono-server/src/fold-wildcard-superuser.test.ts

Lines changed: 58 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
22

33
import { describe, it, expect } from 'vitest';
4-
import { foldWildcardSuperUser } from './hono-plugin.js';
4+
import { foldWildcardSuperUser, clampManagedObjectWrites, type ManagedSchemaLike } from './hono-plugin.js';
55

66
/**
77
* ADR-0057 D10 / ADR-0092 D5 — the `/me/permissions` per-object FLS map must
@@ -48,3 +48,60 @@ describe('foldWildcardSuperUser', () => {
4848
expect(objects.sys_user.allowEdit).toBe(false);
4949
});
5050
});
51+
52+
/**
53+
* ADR-0092 D2 — the identity write guard is a second enforcement layer the
54+
* permission sets don't model. The client hint must reflect permission ∩ guard:
55+
* managed (`better-auth`) objects are user-context-writable only where the
56+
* object opened the affordance; others (system/config/…) are untouched.
57+
*/
58+
describe('clampManagedObjectWrites', () => {
59+
const SCHEMAS: Record<string, ManagedSchemaLike> = {
60+
sys_user: { managedBy: 'better-auth', userActions: { edit: true } },
61+
sys_member: { managedBy: 'better-auth' },
62+
sys_session: { managedBy: 'better-auth' },
63+
sys_automation_run: { managedBy: 'system' }, // NOT better-auth → not guarded
64+
crm_lead: { managedBy: 'platform' },
65+
};
66+
const schemaOf = (n: string) => SCHEMAS[n];
67+
68+
it('keeps write on a managed object that opened the edit affordance (sys_user)', () => {
69+
const objects: Record<string, any> = { sys_user: { allowRead: true, allowEdit: true, allowCreate: true, allowDelete: true } };
70+
clampManagedObjectWrites(objects, schemaOf);
71+
// edit opted-in stays; create/delete were NOT opted in → clamped off.
72+
expect(objects.sys_user).toMatchObject({ allowRead: true, allowEdit: true, allowCreate: false, allowDelete: false });
73+
});
74+
75+
it('clamps write to false on managed objects the guard blocks (sys_member, sys_session)', () => {
76+
const objects: Record<string, any> = {
77+
sys_member: { allowRead: true, allowEdit: true, allowCreate: true, allowDelete: true },
78+
sys_session: { allowRead: true, allowEdit: true },
79+
};
80+
clampManagedObjectWrites(objects, schemaOf);
81+
expect(objects.sys_member).toMatchObject({ allowRead: true, allowEdit: false, allowCreate: false, allowDelete: false });
82+
expect(objects.sys_session.allowEdit).toBe(false);
83+
expect(objects.sys_session.allowRead).toBe(true); // read never clamped
84+
});
85+
86+
it('leaves non-better-auth managed buckets untouched (system objects have no write guard)', () => {
87+
const objects: Record<string, any> = { sys_automation_run: { allowEdit: true }, crm_lead: { allowEdit: true } };
88+
clampManagedObjectWrites(objects, schemaOf);
89+
expect(objects.sys_automation_run.allowEdit).toBe(true);
90+
expect(objects.crm_lead.allowEdit).toBe(true);
91+
});
92+
93+
it('fold + clamp compose to permission ∩ guard for a platform admin', () => {
94+
// As produced for a platform admin (admin_full_access '*' modifyAll) who
95+
// also holds organization_admin (explicit managed denies).
96+
const objects: Record<string, any> = {
97+
'*': { allowRead: true, allowEdit: true, viewAllRecords: true, modifyAllRecords: true },
98+
sys_user: { allowRead: true, allowEdit: false, allowCreate: false, allowDelete: false },
99+
sys_member: { allowRead: true, allowEdit: false, allowCreate: false, allowDelete: false },
100+
};
101+
foldWildcardSuperUser(objects);
102+
clampManagedObjectWrites(objects, schemaOf);
103+
expect(objects.sys_user.allowEdit).toBe(true); // opened + admin → editable
104+
expect(objects.sys_member.allowEdit).toBe(false); // guard blocks → not editable
105+
expect(objects.sys_member.allowRead).toBe(true); // read still granted by super-user
106+
});
107+
});

packages/plugins/plugin-hono-server/src/hono-plugin.ts

Lines changed: 54 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,46 @@ export function foldWildcardSuperUser(objects: Record<string, any>): void {
120120
}
121121
}
122122

123+
/** Minimal schema shape the managed-write clamp needs. */
124+
export interface ManagedSchemaLike {
125+
managedBy?: string;
126+
userActions?: { create?: boolean; edit?: boolean; delete?: boolean } | null;
127+
}
128+
129+
/**
130+
* Re-clamp a `/me/permissions` `objects` map by the SECOND server-side
131+
* enforcement layer that permission sets don't model: the identity write guard
132+
* (ADR-0092 D2). The guard fail-closed rejects USER-CONTEXT insert/update/delete
133+
* on every `managedBy: 'better-auth'` object except where the object opted a
134+
* write affordance in (`userActions.{create,edit,delete}` — e.g. sys_user opens
135+
* `edit` for its profile fields; the field-level `readonly` flags then narrow it
136+
* to `{name, image}`).
137+
*
138+
* Without this clamp, {@link foldWildcardSuperUser} would report `allowEdit:true`
139+
* for a platform admin on identity tables the guard actually blocks (sys_member,
140+
* sys_account, …) — a false-POSITIVE that mirrors, inverted, the false-negative
141+
* the fold fixes. The real effective answer for a user-context caller is
142+
* `permission-set grant ∩ guard policy`, and the guard policy for a managed
143+
* object is exactly its resolved CRUD affordance. Only `better-auth` objects are
144+
* clamped — the guard covers only them; `system`/`config`/`append-only` objects
145+
* have no such guard, so their permission-set result stands (an admin CAN write
146+
* them via the data API, and the hint must not under-report that).
147+
*/
148+
export function clampManagedObjectWrites(
149+
objects: Record<string, any>,
150+
schemaOf: (objectName: string) => ManagedSchemaLike | undefined,
151+
): void {
152+
for (const [obj, acc] of Object.entries(objects) as Array<[string, any]>) {
153+
if (obj === '*' || !acc) continue;
154+
const schema = schemaOf(obj);
155+
if (schema?.managedBy !== 'better-auth') continue;
156+
const ua = schema.userActions ?? {};
157+
if (ua.edit !== true) acc.allowEdit = false;
158+
if (ua.create !== true) acc.allowCreate = false;
159+
if (ua.delete !== true) acc.allowDelete = false;
160+
}
161+
}
162+
123163
export class HonoServerPlugin implements Plugin {
124164
name = 'com.objectstack.server.hono';
125165
type = 'server';
@@ -835,10 +875,21 @@ export class HonoServerPlugin implements Plugin {
835875
}
836876
}
837877
}
838-
// Fold the `'*'` wildcard super-user grant into every per-object
839-
// entry (see foldWildcardSuperUser) so the client's per-object FLS
840-
// matches the server's actual enforcement (ADR-0057 D10).
878+
// Make the client's per-object FLS reflect the server's ACTUAL
879+
// effective enforcement = permission-set grant ∩ identity write
880+
// guard (ADR-0057 D10). (1) Fold the `'*'` super-user grant into
881+
// every object so an admin's wildcard is not shadowed by another
882+
// set's explicit deny; (2) re-clamp `better-auth` managed objects
883+
// by their write affordance, since the guard (ADR-0092 D2) blocks
884+
// user-context writes there except where the object opted in
885+
// (sys_user → edit). Together these remove both the false-negative
886+
// (admin sees sys_user editable) and the false-positive (admin does
887+
// NOT see sys_member editable, matching the guard).
841888
foldWildcardSuperUser(objects);
889+
clampManagedObjectWrites(objects, (name) => {
890+
try { return ql?.getSchema?.(name) as ManagedSchemaLike | undefined; }
891+
catch { return undefined; }
892+
});
842893
return c.json({
843894
authenticated: true,
844895
userId: execCtx.userId,

0 commit comments

Comments
 (0)