Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .changeset/console-5da9905b30fc.md
Original file line number Diff line number Diff line change
@@ -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`
37 changes: 37 additions & 0 deletions .changeset/me-permissions-wildcard-fold.md
Original file line number Diff line number Diff line change
@@ -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.
26 changes: 26 additions & 0 deletions .changeset/sys-user-edit-affordance.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 1 addition & 1 deletion .objectui-sha
Original file line number Diff line number Diff line change
@@ -1 +1 @@
6fa8e6aeb67c53169b7c9c4bbc1ef7cf897d0cbf
5da9905b30fc06fa2d262fbcbccbcf881f9da360
23 changes: 23 additions & 0 deletions packages/platform-objects/src/identity/sys-user.object.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -462,22 +471,29 @@ 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',
}),

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.',
}),
Expand All @@ -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.',
Expand All @@ -494,20 +511,23 @@ 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.',
}),

ban_reason: Field.text({
label: 'Ban Reason',
required: false,
readonly: true, // ADR-0092 — written by the Ban User action
maxLength: 255,
group: 'Admin',
}),

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.',
}),
Expand Down Expand Up @@ -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 ' +
Expand All @@ -639,13 +660,15 @@ 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).",
}),

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.",
}),
Expand Down
Original file line number Diff line number Diff line change
@@ -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<string, any> = {
'*': { 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<string, any> = {
'*': { 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<string, any> = {
'*': { 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<string, any> = { 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<string, ManagedSchemaLike> = {
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<string, any> = { 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<string, any> = {
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<string, any> = { 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<string, any> = {
'*': { 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
});
});
94 changes: 94 additions & 0 deletions packages/plugins/plugin-hono-server/src/hono-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, any>): 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<string, any>,
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';
Expand Down Expand Up @@ -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,
Expand Down