diff --git a/.changeset/console-5da9905b30fc.md b/.changeset/console-5da9905b30fc.md new file mode 100644 index 0000000000..d1da7b2615 --- /dev/null +++ b/.changeset/console-5da9905b30fc.md @@ -0,0 +1,9 @@ +--- +"@objectstack/console": patch +--- + +Console (objectui) refreshed to `5da9905b30fc`. Frontend changes in this range: + +- fix(plugin-form): honor userActions.edit on managed objects, don't blanket-disable fields (ADR-0092 D4) (#2395) + +objectui range: `6fa8e6aeb67c...5da9905b30fc` diff --git a/.changeset/me-permissions-wildcard-fold.md b/.changeset/me-permissions-wildcard-fold.md new file mode 100644 index 0000000000..95b532624e --- /dev/null +++ b/.changeset/me-permissions-wildcard-fold.md @@ -0,0 +1,37 @@ +--- +"@objectstack/plugin-hono-server": patch +--- + +fix(security): `/me/permissions` now reflects permission-set ∩ identity-write-guard, matching real server enforcement (ADR-0057 D10) + +The `/api/v1/auth/me/permissions` per-object map merged each permission set's +explicit `objects` entries most-permissively per key, but treated `'*'` and +named objects as independent keys — so a wildcard "Modify/View All Data" grant +was never propagated into a per-object entry another set explicitly denied. +That made the client's field-level security STRICTER than the server's actual +enforcement (`PermissionEvaluator.checkObjectPermission` allows as soon as any +set grants, including via the `'*'` modifyAll/viewAll super-user bypass, with +no deny-wins). + +The real effective answer for a user-context caller is `permission-set grant ∩ +identity-write-guard policy`, and the payload now computes both: + +1. `foldWildcardSuperUser` lifts each per-object entry's read/write bits when + the merged `'*'` is a super-user grant — fixing the false-NEGATIVE where a + platform admin (`admin_full_access` `'*': {modifyAllRecords}`) who also holds + `organization_admin` (explicit identity denies) resolved to + `sys_user.allowEdit:false` and a disabled edit form, though the server + accepts the write (`PATCH /data/sys_user {name}` → 200). +2. `clampManagedObjectWrites` re-clamps `managedBy: 'better-auth'` objects by + their write affordance — fixing the false-POSITIVE the fold would otherwise + introduce: the identity write guard (ADR-0092 D2) blocks user-context writes + on identity tables except where the object opted in (`userActions.edit`), so + `sys_member` / `sys_account` / `sys_session` stay `allowEdit:false` for the + admin (read stays granted). Only `better-auth` objects are clamped — the + guard covers only them; `system`/`config`/`append-only` objects have no such + guard and their permission-set result stands. + +Net: the Console's per-object FLS now equals real server enforcement — the +ADR-0092 D4 `sys_user` profile-edit affordance is unblocked for platform admins +(the guard still narrows the write to `{name, image}`), and no other identity +table is shown as editable when the guard would reject it. diff --git a/.changeset/sys-user-edit-affordance.md b/.changeset/sys-user-edit-affordance.md new file mode 100644 index 0000000000..acc4969507 --- /dev/null +++ b/.changeset/sys-user-edit-affordance.md @@ -0,0 +1,26 @@ +--- +"@objectstack/platform-objects": minor +--- + +feat(identity): open the standard Edit affordance on sys_user for profile fields (ADR-0092 D4) + +`sys_user` now sets `userActions: { edit: true }`, so the generic row-edit +form is available (create / import / delete stay off). The two profile fields +(`name`, `image`) are editable; every other column — `email`, `role`, ban +state, phone, and all system-managed stamps — is marked `readonly` so the +standard edit form renders it non-editable. + +This is safe because the server boundary is the identity write guard shipped +in the previous change (ADR-0092 D2): a user-context update to `sys_user` may +only touch `{name, image}` regardless of what any form submits; everything +else is stripped or rejected. The `readonly` flags here are UX only. + +The dedicated action dialogs are unaffected — `create_user` / `invite_user` / +`set_user_role` reference `email` and `role` as action **params** (their own +inputs), which do not inherit the field-level `readonly` and stay editable +(verified in the running Console). + +Note: the Console's record-form renderer must honor `userActions.edit` + +per-field `readonly` on `managedBy:'better-auth'` objects for the edit form to +be functional; that is an objectui-side change vendored via `objectui:refresh` +and tracked separately. diff --git a/.objectui-sha b/.objectui-sha index 755762e7d7..e4e5da3f9c 100644 --- a/.objectui-sha +++ b/.objectui-sha @@ -1 +1 @@ -6fa8e6aeb67c53169b7c9c4bbc1ef7cf897d0cbf +5da9905b30fc06fa2d262fbcbccbcf881f9da360 diff --git a/packages/platform-objects/src/identity/sys-user.object.ts b/packages/platform-objects/src/identity/sys-user.object.ts index 564d9c195e..b9c74e09ee 100644 --- a/packages/platform-objects/src/identity/sys-user.object.ts +++ b/packages/platform-objects/src/identity/sys-user.object.ts @@ -20,6 +20,15 @@ export const SysUser = ObjectSchema.create({ icon: 'user', isSystem: true, managedBy: 'better-auth', + // ADR-0092 D4 — the ONE generic affordance opened on an identity table: + // standard row editing. Safe because the plugin-auth identity write guard + // (ADR-0092 D2) enforces the profile whitelist server-side — a user-context + // update may only touch SYS_USER_PROFILE_EDIT_FIELDS ({name, image}); + // everything else is stripped/rejected regardless of what a form submits. + // The permission layer still decides WHO may edit (platform admins only by + // default; member/org-admin sets keep allowEdit: false). create / import / + // delete stay bucket-default (off). + userActions: { edit: true }, // ADR-0010 §3.7 — identity table is managed by better-auth; schema must not drift. protection: { lock: 'full', @@ -462,9 +471,14 @@ export const SysUser = ObjectSchema.create({ group: 'Identity', }), + // ADR-0092 D4 — with the generic edit affordance open, every non-profile + // field is readonly so the standard edit form renders it non-editable. + // This is UX only; the server boundary is the plugin-auth identity write + // guard (ADR-0092 D2), which strips/rejects these regardless. email: Field.email({ label: 'Email', required: true, + readonly: true, // login identity — change flows through better-auth change-email verification searchable: true, group: 'Identity', }), @@ -472,12 +486,14 @@ export const SysUser = ObjectSchema.create({ email_verified: Field.boolean({ label: 'Email Verified', defaultValue: false, + readonly: true, group: 'Identity', }), two_factor_enabled: Field.boolean({ label: 'Two-Factor Enabled', defaultValue: false, + readonly: true, group: 'Identity', description: 'Whether two-factor authentication is enabled for this user. Maintained by the better-auth `twoFactor` plugin.', }), @@ -486,6 +502,7 @@ export const SysUser = ObjectSchema.create({ role: Field.text({ label: 'Platform Role', required: false, + readonly: true, // ADR-0092 — set via the Set Platform Role action, never the edit form maxLength: 64, group: 'Admin', description: 'Platform-level role (admin, user, …). Set via the Set Platform Role action.', @@ -494,6 +511,7 @@ export const SysUser = ObjectSchema.create({ banned: Field.boolean({ label: 'Banned', defaultValue: false, + readonly: true, // ADR-0092 — toggled via Ban/Unban actions (session side effects) group: 'Admin', description: 'When true, the user cannot sign in. Toggle via Ban User / Unban User actions.', }), @@ -501,6 +519,7 @@ export const SysUser = ObjectSchema.create({ ban_reason: Field.text({ label: 'Ban Reason', required: false, + readonly: true, // ADR-0092 — written by the Ban User action maxLength: 255, group: 'Admin', }), @@ -508,6 +527,7 @@ export const SysUser = ObjectSchema.create({ ban_expires: Field.datetime({ label: 'Ban Expires', required: false, + readonly: true, // ADR-0092 — written by the Ban User action group: 'Admin', description: 'When set, the ban auto-clears at this time.', }), @@ -618,6 +638,7 @@ export const SysUser = ObjectSchema.create({ ai_access: Field.boolean({ label: 'AI Access', defaultValue: false, + readonly: true, // ADR-0092 — a licensed-seat grant; flows through the AiSeatPlugin enforcement path group: 'Admin', description: 'Whether this user holds an AI seat — grants access to the in-UI AI ' + @@ -639,6 +660,7 @@ export const SysUser = ObjectSchema.create({ manager_id: Field.lookup('sys_user', { label: 'Manager', required: false, + readonly: true, // ADR-0092 — drives own_and_reports RLS scope; org-structure maintenance is its own surface group: 'Organization', description: "This user's direct manager. Forms the reporting chain the `own_and_reports` hierarchy scope walks (ADR-0057 / @objectstack/security-enterprise).", }), @@ -646,6 +668,7 @@ export const SysUser = ObjectSchema.create({ primary_business_unit_id: Field.lookup('sys_business_unit', { label: 'Primary Business Unit', required: false, + readonly: true, // ADR-0092 — denormalised projection maintained by plugin-sharing; never hand-edited group: 'Organization', 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.", }), diff --git a/packages/plugins/plugin-hono-server/src/fold-wildcard-superuser.test.ts b/packages/plugins/plugin-hono-server/src/fold-wildcard-superuser.test.ts new file mode 100644 index 0000000000..df2126d5f6 --- /dev/null +++ b/packages/plugins/plugin-hono-server/src/fold-wildcard-superuser.test.ts @@ -0,0 +1,107 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect } from 'vitest'; +import { foldWildcardSuperUser, clampManagedObjectWrites, type ManagedSchemaLike } from './hono-plugin.js'; + +/** + * ADR-0057 D10 / ADR-0092 D5 — the `/me/permissions` per-object FLS map must + * mirror the server's actual enforcement, which grants writes via a `'*'` + * modifyAll super-user bypass regardless of another set's explicit per-object + * deny (most-permissive merge, no deny-wins). + */ +describe('foldWildcardSuperUser', () => { + it('lifts an explicit per-object deny when the wildcard is a modifyAll super-user grant', () => { + const objects: Record = { + '*': { allowRead: true, allowEdit: true, viewAllRecords: true, modifyAllRecords: true }, + // As produced when admin_full_access ('*') composes with organization_admin + // (explicit sys_user deny) — the naive merge leaves allowEdit:false. + sys_user: { allowRead: true, allowEdit: false, allowCreate: false, allowDelete: false }, + }; + foldWildcardSuperUser(objects); + expect(objects.sys_user).toMatchObject({ allowRead: true, allowEdit: true, allowCreate: true, allowDelete: true }); + // The wildcard entry itself is untouched. + expect(objects['*'].modifyAllRecords).toBe(true); + }); + + it('viewAll-only wildcard lifts read but NOT write', () => { + const objects: Record = { + '*': { allowRead: true, viewAllRecords: true }, + sys_session: { allowRead: false, allowEdit: false }, + }; + foldWildcardSuperUser(objects); + expect(objects.sys_session.allowRead).toBe(true); + expect(objects.sys_session.allowEdit).toBe(false); + }); + + it('no-ops when the wildcard is not a super-user grant', () => { + const objects: Record = { + '*': { allowRead: true, allowEdit: true }, // plain allow, no view/modifyAll + sys_user: { allowRead: true, allowEdit: false }, + }; + foldWildcardSuperUser(objects); + expect(objects.sys_user.allowEdit).toBe(false); // untouched — no super-user bypass + }); + + it('no-ops when there is no wildcard entry', () => { + const objects: Record = { sys_user: { allowEdit: false } }; + foldWildcardSuperUser(objects); + expect(objects.sys_user.allowEdit).toBe(false); + }); +}); + +/** + * ADR-0092 D2 — the identity write guard is a second enforcement layer the + * permission sets don't model. The client hint must reflect permission ∩ guard: + * managed (`better-auth`) objects are user-context-writable only where the + * object opened the affordance; others (system/config/…) are untouched. + */ +describe('clampManagedObjectWrites', () => { + const SCHEMAS: Record = { + sys_user: { managedBy: 'better-auth', userActions: { edit: true } }, + sys_member: { managedBy: 'better-auth' }, + sys_session: { managedBy: 'better-auth' }, + sys_automation_run: { managedBy: 'system' }, // NOT better-auth → not guarded + crm_lead: { managedBy: 'platform' }, + }; + const schemaOf = (n: string) => SCHEMAS[n]; + + it('keeps write on a managed object that opened the edit affordance (sys_user)', () => { + const objects: Record = { sys_user: { allowRead: true, allowEdit: true, allowCreate: true, allowDelete: true } }; + clampManagedObjectWrites(objects, schemaOf); + // edit opted-in stays; create/delete were NOT opted in → clamped off. + expect(objects.sys_user).toMatchObject({ allowRead: true, allowEdit: true, allowCreate: false, allowDelete: false }); + }); + + it('clamps write to false on managed objects the guard blocks (sys_member, sys_session)', () => { + const objects: Record = { + sys_member: { allowRead: true, allowEdit: true, allowCreate: true, allowDelete: true }, + sys_session: { allowRead: true, allowEdit: true }, + }; + clampManagedObjectWrites(objects, schemaOf); + expect(objects.sys_member).toMatchObject({ allowRead: true, allowEdit: false, allowCreate: false, allowDelete: false }); + expect(objects.sys_session.allowEdit).toBe(false); + expect(objects.sys_session.allowRead).toBe(true); // read never clamped + }); + + it('leaves non-better-auth managed buckets untouched (system objects have no write guard)', () => { + const objects: Record = { sys_automation_run: { allowEdit: true }, crm_lead: { allowEdit: true } }; + clampManagedObjectWrites(objects, schemaOf); + expect(objects.sys_automation_run.allowEdit).toBe(true); + expect(objects.crm_lead.allowEdit).toBe(true); + }); + + it('fold + clamp compose to permission ∩ guard for a platform admin', () => { + // As produced for a platform admin (admin_full_access '*' modifyAll) who + // also holds organization_admin (explicit managed denies). + const objects: Record = { + '*': { allowRead: true, allowEdit: true, viewAllRecords: true, modifyAllRecords: true }, + sys_user: { allowRead: true, allowEdit: false, allowCreate: false, allowDelete: false }, + sys_member: { allowRead: true, allowEdit: false, allowCreate: false, allowDelete: false }, + }; + foldWildcardSuperUser(objects); + clampManagedObjectWrites(objects, schemaOf); + expect(objects.sys_user.allowEdit).toBe(true); // opened + admin → editable + expect(objects.sys_member.allowEdit).toBe(false); // guard blocks → not editable + expect(objects.sys_member.allowRead).toBe(true); // read still granted by super-user + }); +}); diff --git a/packages/plugins/plugin-hono-server/src/hono-plugin.ts b/packages/plugins/plugin-hono-server/src/hono-plugin.ts index ad75403e61..d5aec23cbe 100644 --- a/packages/plugins/plugin-hono-server/src/hono-plugin.ts +++ b/packages/plugins/plugin-hono-server/src/hono-plugin.ts @@ -81,6 +81,85 @@ export interface HonoPluginOptions { * - `@objectstack/rest` → CRUD, metadata, discovery, UI, batch * - `createDispatcherPlugin()` → auth, graphql, analytics, packages, etc. */ + +/** + * Fold the `'*'` wildcard super-user grant into every per-object entry of a + * `/me/permissions` `objects` map, mutating it in place. + * + * The endpoint merges each resolved permission set's explicit `objects` entries + * most-permissively per key, but treats `'*'` and named objects as independent + * keys — so a wildcard "Modify/View All Data" grant is never propagated into a + * per-object entry another set explicitly denied. That makes the client's + * per-object FLS STRICTER than the server's actual enforcement + * (`PermissionEvaluator.checkObjectPermission`, which returns allow as soon as + * ANY set grants — including via the `'*'` modifyAll/viewAll super-user bypass, + * with no deny-wins). The mismatch surfaces for a platform admin + * (`admin_full_access` `'*': {modifyAllRecords}`) who ALSO holds + * `organization_admin` (which denies writes on identity tables): the client + * would see `sys_user.allowEdit:false` and disable a form the server accepts + * (verified: `PATCH /data/sys_user {name}` → 200). ADR-0057 D10 makes the + * server the authoritative gate; the client must mirror it, never diverge. + * + * The super-user grant covers private/managed objects on the server, so folding + * it here is exactly as broad as real enforcement — never broader. + */ +export function foldWildcardSuperUser(objects: Record): void { + const wild = objects?.['*']; + if (!wild) return; + const superRead = wild.viewAllRecords === true || wild.modifyAllRecords === true; + const superWrite = wild.modifyAllRecords === true; + if (!superRead && !superWrite) return; + for (const [obj, acc] of Object.entries(objects) as Array<[string, any]>) { + if (obj === '*' || !acc) continue; + if (superRead) acc.allowRead = true; + if (superWrite) { + acc.allowEdit = true; + acc.allowCreate = true; + acc.allowDelete = true; + } + } +} + +/** Minimal schema shape the managed-write clamp needs. */ +export interface ManagedSchemaLike { + managedBy?: string; + userActions?: { create?: boolean; edit?: boolean; delete?: boolean } | null; +} + +/** + * Re-clamp a `/me/permissions` `objects` map by the SECOND server-side + * enforcement layer that permission sets don't model: the identity write guard + * (ADR-0092 D2). The guard fail-closed rejects USER-CONTEXT insert/update/delete + * on every `managedBy: 'better-auth'` object except where the object opted a + * write affordance in (`userActions.{create,edit,delete}` — e.g. sys_user opens + * `edit` for its profile fields; the field-level `readonly` flags then narrow it + * to `{name, image}`). + * + * Without this clamp, {@link foldWildcardSuperUser} would report `allowEdit:true` + * for a platform admin on identity tables the guard actually blocks (sys_member, + * sys_account, …) — a false-POSITIVE that mirrors, inverted, the false-negative + * the fold fixes. The real effective answer for a user-context caller is + * `permission-set grant ∩ guard policy`, and the guard policy for a managed + * object is exactly its resolved CRUD affordance. Only `better-auth` objects are + * clamped — the guard covers only them; `system`/`config`/`append-only` objects + * have no such guard, so their permission-set result stands (an admin CAN write + * them via the data API, and the hint must not under-report that). + */ +export function clampManagedObjectWrites( + objects: Record, + schemaOf: (objectName: string) => ManagedSchemaLike | undefined, +): void { + for (const [obj, acc] of Object.entries(objects) as Array<[string, any]>) { + if (obj === '*' || !acc) continue; + const schema = schemaOf(obj); + if (schema?.managedBy !== 'better-auth') continue; + const ua = schema.userActions ?? {}; + if (ua.edit !== true) acc.allowEdit = false; + if (ua.create !== true) acc.allowCreate = false; + if (ua.delete !== true) acc.allowDelete = false; + } +} + export class HonoServerPlugin implements Plugin { name = 'com.objectstack.server.hono'; type = 'server'; @@ -796,6 +875,21 @@ export class HonoServerPlugin implements Plugin { } } } + // Make the client's per-object FLS reflect the server's ACTUAL + // effective enforcement = permission-set grant ∩ identity write + // guard (ADR-0057 D10). (1) Fold the `'*'` super-user grant into + // every object so an admin's wildcard is not shadowed by another + // set's explicit deny; (2) re-clamp `better-auth` managed objects + // by their write affordance, since the guard (ADR-0092 D2) blocks + // user-context writes there except where the object opted in + // (sys_user → edit). Together these remove both the false-negative + // (admin sees sys_user editable) and the false-positive (admin does + // NOT see sys_member editable, matching the guard). + foldWildcardSuperUser(objects); + clampManagedObjectWrites(objects, (name) => { + try { return ql?.getSchema?.(name) as ManagedSchemaLike | undefined; } + catch { return undefined; } + }); return c.json({ authenticated: true, userId: execCtx.userId,