Skip to content

Commit 6da03ee

Browse files
authored
feat(identity): sys_user profile-field editing via standard form (ADR-0092 D4) (#2832)
Completes ADR-0092 D4 (closes #2817), verified end-to-end in a running Console (edit name → persisted; email/role read-only): - sys_user: userActions.edit + per-field readonly governance (name/image editable, everything else read-only). - /me/permissions now reflects permission-set ∩ identity-write-guard: foldWildcardSuperUser fixes the false-negative (admin's '*' modifyAll shadowed by organization_admin's explicit identity deny), and clampManagedObjectWrites re-applies the guard's user-context policy so managed tables the guard blocks (sys_member/session/account) stay non-editable while sys_user (opted in) is editable. Fixes #2836. - .objectui-sha bump pulls in objectui#2395 (ObjectForm honors userActions.edit instead of blanket-disabling managed objects). The identity write guard (#2828) still restricts the actual write to {name, image}; the UI now shows exactly what the server enforces.
1 parent 33ebd34 commit 6da03ee

7 files changed

Lines changed: 297 additions & 1 deletion

File tree

.changeset/console-5da9905b30fc.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
"@objectstack/console": patch
3+
---
4+
5+
Console (objectui) refreshed to `5da9905b30fc`. Frontend changes in this range:
6+
7+
- fix(plugin-form): honor userActions.edit on managed objects, don't blanket-disable fields (ADR-0092 D4) (#2395)
8+
9+
objectui range: `6fa8e6aeb67c...5da9905b30fc`
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
---
2+
"@objectstack/plugin-hono-server": patch
3+
---
4+
5+
fix(security): `/me/permissions` now reflects permission-set ∩ identity-write-guard, matching real server enforcement (ADR-0057 D10)
6+
7+
The `/api/v1/auth/me/permissions` per-object map merged each permission set's
8+
explicit `objects` entries most-permissively per key, but treated `'*'` and
9+
named objects as independent keys — so a wildcard "Modify/View All Data" grant
10+
was never propagated into a per-object entry another set explicitly denied.
11+
That made the client's field-level security STRICTER than the server's actual
12+
enforcement (`PermissionEvaluator.checkObjectPermission` allows as soon as any
13+
set grants, including via the `'*'` modifyAll/viewAll super-user bypass, with
14+
no deny-wins).
15+
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.
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
---
2+
"@objectstack/platform-objects": minor
3+
---
4+
5+
feat(identity): open the standard Edit affordance on sys_user for profile fields (ADR-0092 D4)
6+
7+
`sys_user` now sets `userActions: { edit: true }`, so the generic row-edit
8+
form is available (create / import / delete stay off). The two profile fields
9+
(`name`, `image`) are editable; every other column — `email`, `role`, ban
10+
state, phone, and all system-managed stamps — is marked `readonly` so the
11+
standard edit form renders it non-editable.
12+
13+
This is safe because the server boundary is the identity write guard shipped
14+
in the previous change (ADR-0092 D2): a user-context update to `sys_user` may
15+
only touch `{name, image}` regardless of what any form submits; everything
16+
else is stripped or rejected. The `readonly` flags here are UX only.
17+
18+
The dedicated action dialogs are unaffected — `create_user` / `invite_user` /
19+
`set_user_role` reference `email` and `role` as action **params** (their own
20+
inputs), which do not inherit the field-level `readonly` and stay editable
21+
(verified in the running Console).
22+
23+
Note: the Console's record-form renderer must honor `userActions.edit` +
24+
per-field `readonly` on `managedBy:'better-auth'` objects for the edit form to
25+
be functional; that is an objectui-side change vendored via `objectui:refresh`
26+
and tracked separately.

.objectui-sha

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
6fa8e6aeb67c53169b7c9c4bbc1ef7cf897d0cbf
1+
5da9905b30fc06fa2d262fbcbccbcf881f9da360

packages/platform-objects/src/identity/sys-user.object.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,15 @@ export const SysUser = ObjectSchema.create({
2020
icon: 'user',
2121
isSystem: true,
2222
managedBy: 'better-auth',
23+
// ADR-0092 D4 — the ONE generic affordance opened on an identity table:
24+
// standard row editing. Safe because the plugin-auth identity write guard
25+
// (ADR-0092 D2) enforces the profile whitelist server-side — a user-context
26+
// update may only touch SYS_USER_PROFILE_EDIT_FIELDS ({name, image});
27+
// everything else is stripped/rejected regardless of what a form submits.
28+
// The permission layer still decides WHO may edit (platform admins only by
29+
// default; member/org-admin sets keep allowEdit: false). create / import /
30+
// delete stay bucket-default (off).
31+
userActions: { edit: true },
2332
// ADR-0010 §3.7 — identity table is managed by better-auth; schema must not drift.
2433
protection: {
2534
lock: 'full',
@@ -462,22 +471,29 @@ export const SysUser = ObjectSchema.create({
462471
group: 'Identity',
463472
}),
464473

474+
// ADR-0092 D4 — with the generic edit affordance open, every non-profile
475+
// field is readonly so the standard edit form renders it non-editable.
476+
// This is UX only; the server boundary is the plugin-auth identity write
477+
// guard (ADR-0092 D2), which strips/rejects these regardless.
465478
email: Field.email({
466479
label: 'Email',
467480
required: true,
481+
readonly: true, // login identity — change flows through better-auth change-email verification
468482
searchable: true,
469483
group: 'Identity',
470484
}),
471485

472486
email_verified: Field.boolean({
473487
label: 'Email Verified',
474488
defaultValue: false,
489+
readonly: true,
475490
group: 'Identity',
476491
}),
477492

478493
two_factor_enabled: Field.boolean({
479494
label: 'Two-Factor Enabled',
480495
defaultValue: false,
496+
readonly: true,
481497
group: 'Identity',
482498
description: 'Whether two-factor authentication is enabled for this user. Maintained by the better-auth `twoFactor` plugin.',
483499
}),
@@ -486,6 +502,7 @@ export const SysUser = ObjectSchema.create({
486502
role: Field.text({
487503
label: 'Platform Role',
488504
required: false,
505+
readonly: true, // ADR-0092 — set via the Set Platform Role action, never the edit form
489506
maxLength: 64,
490507
group: 'Admin',
491508
description: 'Platform-level role (admin, user, …). Set via the Set Platform Role action.',
@@ -494,20 +511,23 @@ export const SysUser = ObjectSchema.create({
494511
banned: Field.boolean({
495512
label: 'Banned',
496513
defaultValue: false,
514+
readonly: true, // ADR-0092 — toggled via Ban/Unban actions (session side effects)
497515
group: 'Admin',
498516
description: 'When true, the user cannot sign in. Toggle via Ban User / Unban User actions.',
499517
}),
500518

501519
ban_reason: Field.text({
502520
label: 'Ban Reason',
503521
required: false,
522+
readonly: true, // ADR-0092 — written by the Ban User action
504523
maxLength: 255,
505524
group: 'Admin',
506525
}),
507526

508527
ban_expires: Field.datetime({
509528
label: 'Ban Expires',
510529
required: false,
530+
readonly: true, // ADR-0092 — written by the Ban User action
511531
group: 'Admin',
512532
description: 'When set, the ban auto-clears at this time.',
513533
}),
@@ -618,6 +638,7 @@ export const SysUser = ObjectSchema.create({
618638
ai_access: Field.boolean({
619639
label: 'AI Access',
620640
defaultValue: false,
641+
readonly: true, // ADR-0092 — a licensed-seat grant; flows through the AiSeatPlugin enforcement path
621642
group: 'Admin',
622643
description:
623644
'Whether this user holds an AI seat — grants access to the in-UI AI ' +
@@ -639,13 +660,15 @@ export const SysUser = ObjectSchema.create({
639660
manager_id: Field.lookup('sys_user', {
640661
label: 'Manager',
641662
required: false,
663+
readonly: true, // ADR-0092 — drives own_and_reports RLS scope; org-structure maintenance is its own surface
642664
group: 'Organization',
643665
description: "This user's direct manager. Forms the reporting chain the `own_and_reports` hierarchy scope walks (ADR-0057 / @objectstack/security-enterprise).",
644666
}),
645667

646668
primary_business_unit_id: Field.lookup('sys_business_unit', {
647669
label: 'Primary Business Unit',
648670
required: false,
671+
readonly: true, // ADR-0092 — denormalised projection maintained by plugin-sharing; never hand-edited
649672
group: 'Organization',
650673
description: "The user's primary business unit — a denormalised projection of sys_business_unit_member.is_primary, maintained by plugin-sharing (ADR-0057 addendum D12). Lets a user-lookup filter candidates by business unit without traversing the membership junction. Do not edit directly; set it via business-unit membership.",
651674
}),
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import { describe, it, expect } from 'vitest';
4+
import { foldWildcardSuperUser, clampManagedObjectWrites, type ManagedSchemaLike } from './hono-plugin.js';
5+
6+
/**
7+
* ADR-0057 D10 / ADR-0092 D5 — the `/me/permissions` per-object FLS map must
8+
* mirror the server's actual enforcement, which grants writes via a `'*'`
9+
* modifyAll super-user bypass regardless of another set's explicit per-object
10+
* deny (most-permissive merge, no deny-wins).
11+
*/
12+
describe('foldWildcardSuperUser', () => {
13+
it('lifts an explicit per-object deny when the wildcard is a modifyAll super-user grant', () => {
14+
const objects: Record<string, any> = {
15+
'*': { allowRead: true, allowEdit: true, viewAllRecords: true, modifyAllRecords: true },
16+
// As produced when admin_full_access ('*') composes with organization_admin
17+
// (explicit sys_user deny) — the naive merge leaves allowEdit:false.
18+
sys_user: { allowRead: true, allowEdit: false, allowCreate: false, allowDelete: false },
19+
};
20+
foldWildcardSuperUser(objects);
21+
expect(objects.sys_user).toMatchObject({ allowRead: true, allowEdit: true, allowCreate: true, allowDelete: true });
22+
// The wildcard entry itself is untouched.
23+
expect(objects['*'].modifyAllRecords).toBe(true);
24+
});
25+
26+
it('viewAll-only wildcard lifts read but NOT write', () => {
27+
const objects: Record<string, any> = {
28+
'*': { allowRead: true, viewAllRecords: true },
29+
sys_session: { allowRead: false, allowEdit: false },
30+
};
31+
foldWildcardSuperUser(objects);
32+
expect(objects.sys_session.allowRead).toBe(true);
33+
expect(objects.sys_session.allowEdit).toBe(false);
34+
});
35+
36+
it('no-ops when the wildcard is not a super-user grant', () => {
37+
const objects: Record<string, any> = {
38+
'*': { allowRead: true, allowEdit: true }, // plain allow, no view/modifyAll
39+
sys_user: { allowRead: true, allowEdit: false },
40+
};
41+
foldWildcardSuperUser(objects);
42+
expect(objects.sys_user.allowEdit).toBe(false); // untouched — no super-user bypass
43+
});
44+
45+
it('no-ops when there is no wildcard entry', () => {
46+
const objects: Record<string, any> = { sys_user: { allowEdit: false } };
47+
foldWildcardSuperUser(objects);
48+
expect(objects.sys_user.allowEdit).toBe(false);
49+
});
50+
});
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: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,85 @@ export interface HonoPluginOptions {
8181
* - `@objectstack/rest` → CRUD, metadata, discovery, UI, batch
8282
* - `createDispatcherPlugin()` → auth, graphql, analytics, packages, etc.
8383
*/
84+
85+
/**
86+
* Fold the `'*'` wildcard super-user grant into every per-object entry of a
87+
* `/me/permissions` `objects` map, mutating it in place.
88+
*
89+
* The endpoint merges each resolved permission set's explicit `objects` entries
90+
* most-permissively per key, but treats `'*'` and named objects as independent
91+
* keys — so a wildcard "Modify/View All Data" grant is never propagated into a
92+
* per-object entry another set explicitly denied. That makes the client's
93+
* per-object FLS STRICTER than the server's actual enforcement
94+
* (`PermissionEvaluator.checkObjectPermission`, which returns allow as soon as
95+
* ANY set grants — including via the `'*'` modifyAll/viewAll super-user bypass,
96+
* with no deny-wins). The mismatch surfaces for a platform admin
97+
* (`admin_full_access` `'*': {modifyAllRecords}`) who ALSO holds
98+
* `organization_admin` (which denies writes on identity tables): the client
99+
* would see `sys_user.allowEdit:false` and disable a form the server accepts
100+
* (verified: `PATCH /data/sys_user {name}` → 200). ADR-0057 D10 makes the
101+
* server the authoritative gate; the client must mirror it, never diverge.
102+
*
103+
* The super-user grant covers private/managed objects on the server, so folding
104+
* it here is exactly as broad as real enforcement — never broader.
105+
*/
106+
export function foldWildcardSuperUser(objects: Record<string, any>): void {
107+
const wild = objects?.['*'];
108+
if (!wild) return;
109+
const superRead = wild.viewAllRecords === true || wild.modifyAllRecords === true;
110+
const superWrite = wild.modifyAllRecords === true;
111+
if (!superRead && !superWrite) return;
112+
for (const [obj, acc] of Object.entries(objects) as Array<[string, any]>) {
113+
if (obj === '*' || !acc) continue;
114+
if (superRead) acc.allowRead = true;
115+
if (superWrite) {
116+
acc.allowEdit = true;
117+
acc.allowCreate = true;
118+
acc.allowDelete = true;
119+
}
120+
}
121+
}
122+
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+
84163
export class HonoServerPlugin implements Plugin {
85164
name = 'com.objectstack.server.hono';
86165
type = 'server';
@@ -796,6 +875,21 @@ export class HonoServerPlugin implements Plugin {
796875
}
797876
}
798877
}
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).
888+
foldWildcardSuperUser(objects);
889+
clampManagedObjectWrites(objects, (name) => {
890+
try { return ql?.getSchema?.(name) as ManagedSchemaLike | undefined; }
891+
catch { return undefined; }
892+
});
799893
return c.json({
800894
authenticated: true,
801895
userId: execCtx.userId,

0 commit comments

Comments
 (0)