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
17 changes: 17 additions & 0 deletions .changeset/24760-action-state-visibility.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
---
'@objectstack/platform-objects': patch
---

fix(platform-objects): Setup no longer offers Ban with Unban, Revoke with Restore, or Unlock on every account, whatever the record's state

Clause-②: no (an action's offering: `visible` is judged in the console, and no server door reads it, so no accept set or served shape moves; if the dev measures a door that does, the dev stops, and the seat re-declares)

Five row and record-header actions judged only the caller's standing, so Setup offered both halves of each pair on every row. Each now also reads the record's state, in the form `sys_oauth_application`'s Disable / Enable pair already uses:

- `sys_user` (Setup › Users): *Ban User* is offered when `banned` is not true, *Unban User* when it is. A timed ban past `ban_expires` still reads banned until better-auth clears it at the user's next sign-in, so Unban stays offered for as long as the row says banned.
- `sys_user`: *Unlock Account* is offered when `locked_until` is set, or when the user has two-factor enabled. Unlock also clears the second-factor lock, which lives on `sys_two_factor` and not on the user row, so a two-factor user keeps the button. An expired `locked_until` keeps it too: the failed-attempt counter stays at the threshold until Unlock or a successful sign-in resets it.
- `sys_api_key` (Setup › API Keys): *Revoke API Key* is offered on a live key, *Restore API Key* on a revoked one.

The `sys_user` actions keep their platform-admin standing (`current_user.isPlatformAdmin == true`) first. A row that does not carry the field offers neither action of a pair. The console asks for every field an object action's predicate reads, so the default Users list carries `banned` without showing it.

Unchanged: labels, texts, targets and params, and what the ban, unban, unlock and key routes accept and answer. Who may restore a key that a forced password reset revoked is still decided by the server.
132 changes: 132 additions & 0 deletions packages/platform-objects/src/identity/action-state-visibility.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* Setup's Users and API Keys offer an action only in the record state it
* changes: Ban on a user who is not banned and Unban on one who is, Revoke on
* a live key and Restore on a revoked one, Unlock on an account that can hold
* a lock. Each pair used to judge the caller's standing alone, so both halves
* were offered on every row.
*
* The predicates are EVALUATED through the canonical engine the console
* delegates to (`@objectstack/formula`), on the parsed object, so the stored
* envelope with its `requiresFeature` lowering is what is judged. A string
* match would stay green on a predicate that faults or never answers true.
* The bindings are the rows a list or record header can present: the field
* projected with each value, projected as null, and not projected at all.
*/

import { describe, expect, it } from 'vitest';
import { celEngine } from '@objectstack/formula';
import { SysUser } from './sys-user.object.js';
import { SysApiKey } from './sys-api-key.object.js';

type Principal = { id: string; email: string; positions: string[]; isPlatformAdmin: boolean };

const ADMIN: Principal = { id: 'u_admin', email: 'admin@example.com', positions: [], isPlatformAdmin: true };
const MEMBER: Principal = { id: 'u_member', email: 'member@example.com', positions: ['org_owner'], isPlatformAdmin: false };
const FEATURES = { admin: true, apiKey: true };

function visibleOf(def: { actions?: unknown }, name: string): string {
const action = ((def.actions ?? []) as Array<Record<string, unknown>>).find((a) => a.name === name);
if (!action) throw new Error(`no action ${name}`);
const raw = action.visible;
const source = typeof raw === 'string' ? raw : (raw as { source?: unknown } | undefined)?.source;
if (typeof source !== 'string') throw new Error(`action ${name} has no CEL source`);
return source;
}

/** The verdict, or the fault spelled out so a faulting predicate fails loudly. */
function offered(source: string, record: Record<string, unknown>, user: Principal = ADMIN): boolean | string {
const r = celEngine.evaluate(
{ dialect: 'cel', source },
{ record, extra: { current_user: user, user, ctx: { user }, features: FEATURES } },
);
if (!r.ok) return `FAULT ${r.error.message.split('\n')[0].trim()}`;
return typeof r.value === 'boolean' ? r.value : `NON-BOOLEAN ${JSON.stringify(r.value)}`;
}

describe('sys_user Ban / Unban switch with `banned`', () => {
const ban = visibleOf(SysUser, 'ban_user');
const unban = visibleOf(SysUser, 'unban_user');

it('a user who is not banned is offered Ban and not Unban', () => {
for (const banned of [false, null]) {
expect(offered(ban, { banned }), `banned: ${banned}`).toBe(true);
expect(offered(unban, { banned }), `banned: ${banned}`).toBe(false);
}
});

it('a banned user is offered Unban and not Ban, a timed ban past its expiry included', () => {
// better-auth clears `banned` only at the user's next sign-in, so a row
// past `ban_expires` still says banned and Unban must stay offered.
for (const row of [{ banned: true }, { banned: true, ban_expires: '2020-01-01T00:00:00.000Z' }]) {
expect(offered(unban, row), JSON.stringify(row)).toBe(true);
expect(offered(ban, row), JSON.stringify(row)).toBe(false);
}
});

it('a row that does not carry `banned` offers neither, and never both', () => {
expect(offered(ban, {})).toBe(false);
expect(offered(unban, {})).toBe(false);
});

it('the standing leg stays: a caller without it is offered neither on any row', () => {
for (const banned of [true, false, null]) {
expect(offered(ban, { banned }, MEMBER)).toBe(false);
expect(offered(unban, { banned }, MEMBER)).toBe(false);
}
});
});

describe('sys_user Unlock is offered on an account that can hold a lock', () => {
const unlock = visibleOf(SysUser, 'unlock_user');

it('an account with no lock stamp and no second factor is not offered Unlock', () => {
expect(offered(unlock, { locked_until: null, two_factor_enabled: false })).toBe(false);
expect(offered(unlock, { locked_until: null, two_factor_enabled: null })).toBe(false);
});

it('a password-stage lock is offered Unlock, an expired stamp included', () => {
// An expired lock leaves `failed_login_count` at the threshold, so the
// next failure re-locks; Unlock is what resets that counter.
for (const locked_until of ['2999-01-01T00:00:00.000Z', '2020-01-01T00:00:00.000Z']) {
expect(offered(unlock, { locked_until, two_factor_enabled: false }), locked_until).toBe(true);
}
});

it('a two-factor user is offered Unlock: a second-factor lock lives on `sys_two_factor`, not this row', () => {
expect(offered(unlock, { locked_until: null, two_factor_enabled: true })).toBe(true);
});

it('a row that carries neither field is not offered Unlock, and the standing leg stays', () => {
expect(offered(unlock, {})).toBe(false);
expect(offered(unlock, { locked_until: '2999-01-01T00:00:00.000Z' }, MEMBER)).toBe(false);
expect(offered(unlock, { two_factor_enabled: true }, MEMBER)).toBe(false);
});
});

describe('sys_api_key Revoke / Restore switch with `revoked`', () => {
const revoke = visibleOf(SysApiKey, 'revoke_api_key');
const restore = visibleOf(SysApiKey, 'restore_api_key');

it('a live key is offered Revoke and not Restore', () => {
for (const revoked of [false, null]) {
expect(offered(revoke, { revoked }), `revoked: ${revoked}`).toBe(true);
expect(offered(restore, { revoked }), `revoked: ${revoked}`).toBe(false);
}
});

it('a revoked key is offered Restore and not Revoke, whatever the reason', () => {
// Who may restore a key a forced password reset revoked is the server's
// (plugin-auth `api-key-restore-guard.ts`), not this predicate's.
for (const row of [{ revoked: true }, { revoked: true, revoked_reason: 'admin_password_reset' }]) {
expect(offered(restore, row), JSON.stringify(row)).toBe(true);
expect(offered(revoke, row), JSON.stringify(row)).toBe(false);
}
});

it('a row that does not carry `revoked` offers neither, and never both', () => {
expect(offered(revoke, {})).toBe(false);
expect(offered(restore, {})).toBe(false);
});
});
8 changes: 8 additions & 0 deletions packages/platform-objects/src/identity/sys-api-key.object.ts
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,13 @@ export const SysApiKey = ObjectSchema.create({
method: 'PATCH',
target: '/api/v1/data/sys_api_key/{id}',
bodyExtra: { revoked: true },
// Offered on a live key only, Restore on a revoked one, in the form of
// `sys_oauth_application`'s Disable / Enable pair: a null `revoked`
// reads as live. Display only, no door reads `visible`; who may restore
// a key a forced password reset revoked is the server's
// (`api-key-restore-guard.ts` in plugin-auth). Pinned in
// `action-state-visibility.test.ts`.
visible: 'has(record.revoked) && record.revoked != true',
confirmText: 'Revoke this API key? Any clients using it will immediately lose access.',
successMessage: 'API key revoked',
refreshAfter: true,
Expand All @@ -150,6 +157,7 @@ export const SysApiKey = ObjectSchema.create({
method: 'PATCH',
target: '/api/v1/data/sys_api_key/{id}',
bodyExtra: { revoked: false },
visible: 'has(record.revoked) && record.revoked == true',
confirmText: 'Restore this revoked API key? Existing clients holding the key will regain access.',
successMessage: 'API key restored',
refreshAfter: true,
Expand Down
34 changes: 28 additions & 6 deletions packages/platform-objects/src/identity/sys-user.object.ts
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,17 @@ export const SysUser = ObjectSchema.create({
// ⛔ Never `'platform_admin' in current_user.positions`: `EvalUserSchema`
// rules that standing is read from this key, never from the array. The
// repo-wide pin is `platform-admin-affordance-standing.test.ts`.
//
// Ban / Unban / Unlock also read the ROW's state after the standing, in
// the form of `sys_oauth_application`'s Disable / Enable pair, so a pair
// that undoes each other is never offered together. Display only: no door
// reads `visible` (the routes still answer whatever they are sent). On a
// list row the console projects every `record.<field>` an object action's
// predicate reads (objectui `listViewPredicates`), so the default Users
// view carries `banned` without showing it; the record header reads the
// whole row. `banned` stays true past `ban_expires` until better-auth
// clears it at the user's next sign-in, so Unban is offered while the row
// says banned. Pinned in `action-state-visibility.test.ts`.
{
name: 'ban_user',
label: 'Ban User',
Expand All @@ -148,8 +159,9 @@ export const SysUser = ObjectSchema.create({
locations: ['list_item', 'record_header'],
type: 'api',
target: '/api/v1/auth/admin/ban-user',
// Platform-admin standing (ADR-0068 D4) — see the block header above.
visible: 'current_user.isPlatformAdmin == true',
// Platform-admin standing (ADR-0068 D4), then a user not banned yet —
// see the block header above. A null `banned` reads as not banned.
visible: 'current_user.isPlatformAdmin == true && has(record.banned) && record.banned != true',
requiresFeature: 'admin',
recordIdParam: 'userId',
successMessage: 'User banned',
Expand All @@ -176,8 +188,9 @@ export const SysUser = ObjectSchema.create({
locations: ['list_item', 'record_header'],
type: 'api',
target: '/api/v1/auth/admin/unban-user',
// Platform-admin standing (ADR-0068 D4) — see the block header above.
visible: 'current_user.isPlatformAdmin == true',
// Platform-admin standing (ADR-0068 D4), then a banned user — see the
// block header above.
visible: 'current_user.isPlatformAdmin == true && has(record.banned) && record.banned == true',
requiresFeature: 'admin',
recordIdParam: 'userId',
successMessage: 'User unbanned',
Expand All @@ -194,8 +207,17 @@ export const SysUser = ObjectSchema.create({
locations: ['list_item', 'record_header'],
type: 'api',
target: '/api/v1/auth/admin/unlock-user',
// Platform-admin standing (ADR-0068 D4) — see the block header above.
visible: 'current_user.isPlatformAdmin == true',
// Platform-admin standing (ADR-0068 D4), then an account that can hold
// a lock. `unlockUser` clears TWO stages: this row's `locked_until`
// (the password check) and `sys_two_factor.locked_until` (the second
// factor), which this row does not carry. So the button is offered when
// `locked_until` is set, or when the user has two-factor enabled and may
// be locked at the second factor; gating on `locked_until` alone would
// take away the only early release of a second-factor lock. The stamp is
// not compared with now() on purpose: an expired lock leaves
// `failed_login_count` at the threshold, so one more failure re-locks,
// and Unlock is what resets that counter.
visible: 'current_user.isPlatformAdmin == true && ((has(record.locked_until) && record.locked_until != null) || (has(record.two_factor_enabled) && record.two_factor_enabled == true))',
requiresFeature: 'admin',
recordIdParam: 'userId',
successMessage: 'Account unlocked',
Expand Down
8 changes: 5 additions & 3 deletions packages/platform-objects/src/platform-objects.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -731,9 +731,11 @@ describe('feature-gate lowering matrix (#2874)', () => {
// #2874 P2b — audit gates: capability-dependent actions that previously
// shipped UNGATED (rendered even with the backing plugin off, then 404'd).
// Their doors are platform-admin gated, so the standing leads.
['SysUser', SysUser, 'ban_user', `(${STANDING}) && features.admin == true`],
['SysUser', SysUser, 'unban_user', `(${STANDING}) && features.admin == true`],
['SysUser', SysUser, 'unlock_user', `(${STANDING}) && features.admin == true`],
// The three also read the row's state after the standing (the
// evaluated truth tables are `identity/action-state-visibility.test.ts`).
['SysUser', SysUser, 'ban_user', `(${STANDING} && has(record.banned) && record.banned != true) && features.admin == true`],
['SysUser', SysUser, 'unban_user', `(${STANDING} && has(record.banned) && record.banned == true) && features.admin == true`],
['SysUser', SysUser, 'unlock_user', `(${STANDING} && ((has(record.locked_until) && record.locked_until != null) || (has(record.two_factor_enabled) && record.two_factor_enabled == true))) && features.admin == true`],
['SysUser', SysUser, 'set_user_password', `(${STANDING}) && features.admin == true`],
['SysUser', SysUser, 'impersonate_user', `(${STANDING}) && features.admin == true`],
['SysUser', SysUser, 'enable_two_factor', '(has(record.id) && record.id == ctx.user.id && has(record.two_factor_enabled) && record.two_factor_enabled != true) && features.twoFactor == true'],
Expand Down
Loading