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
18 changes: 18 additions & 0 deletions .changeset/31216-user-effective-access.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
---
'@object-ui/app-shell': patch
'@object-ui/i18n': patch
'@object-ui/types': patch
'@object-ui/cli': patch
'@objectstack/platform-objects': patch
---

feat(console): the user page's Permission Sets tab shows the person's effective permission sets and where each comes from

Clause-②: yes (widening)
Metadata-protocol: none

- **`@object-ui/app-shell`**: new SDUI widget `security:effective-access` for a `sys_user` record page. It reads the person from the bound record (no props) and lists every permission set in force for them in the caller's organization through `security.describeEffectiveAccess(userId)` of `@objectstack/client`. Each set appears once, by its catalog label with its name beside it, with every origin the server reports: a direct grant, a position by name, the default binding, or a derived standing (the configured platform administrator, AI access). A `403` (a person the viewer may not manage) and a `501` (a server that does not report effective access) each show one quiet line and no list; any other failure is named. The widget reads again when a grant or a position is written on the page. Off a `sys_user` record it renders nothing.
- **`@objectstack/platform-objects`**: the `sys_user` record page's Permission Sets tab opens with the `security:effective-access` widget, above the list of stored grants. A person who holds access only through the default binding or a position no longer reads as having no permission sets; direct grants keep their remove action in the list below.
- **`@object-ui/i18n`**: `effectiveAccess.*` (the widget's title, body, refused, unserved, failed and empty lines, and one label per origin kind) added in all eleven packs.
- **`@object-ui/types`**: `SecurityEffectiveAccessSchema` (zod arm) and its TypeScript twin, so a page placing `{ type: 'security:effective-access' }` validates. The widget reads only `className`: `properties` may only be `{}`, and `body` / `children` are refused by name.
- **`@object-ui/cli`**: `objectui check` knows the `security:effective-access` type (the generated known-types list).
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,9 @@ const UNMAPPED_TYPE_EXEMPTIONS: Readonly<Record<string, string>> = {
'mcp:authorized-assistants':
'the user page\'s AI Assistants tab body (@object-ui/app-shell): the widget takes no props and reads '
+ 'the bound sys_user record, so its authored `properties` is `{}` and holds no expression key',
'security:effective-access':
'the user page\'s Permission Sets tab, above the grant list (@object-ui/app-shell): the widget takes no props '
+ 'and reads the bound sys_user record, so its authored `properties` is `{}` and holds no expression key',
};

/** The authored node at an audit path such as `regions[0].components[2].properties.items[10].children[0]`. */
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* The user page's "Permission Sets" tab shows the person's EFFECTIVE sets, not
* only their stored grant rows.
*
* A person who holds access only through the default binding or a position has
* no `sys_user_permission_set` row, so a tab holding only the grant list reads
* as "no records" for them. The tab's first child is the console widget
* `security:effective-access` (`@object-ui/app-shell`), which reads the bound
* `sys_user` record and lists every set the server resolves for the person
* with its origins. The grant list stays below it: a direct grant keeps its
* remove action there.
*
* Read from the shipped page metadata, so the pin fails if the widget leaves
* the tab, moves below the list, or starts authoring props.
*/

import { describe, expect, it } from 'vitest';
import { PageSchema } from '@objectstack/spec/ui';
import { SysUserDetailPage } from './sys-user.page.js';

type Rec = Record<string, unknown>;

describe('sys_user_detail — the Permission Sets tab', () => {
const tabs = (SysUserDetailPage.regions![0]!.components as Rec[]).find((c) => c.type === 'page:tabs') as Rec;
const items = (tabs.properties as Rec).items as Rec[];
const tab = items.find((it) => (it.label as Rec).en === 'Permission Sets')!;
const children = tab.children as Rec[];

it('parses through PageSchema', () => {
const parsed = PageSchema.safeParse(SysUserDetailPage);
expect(parsed.success, JSON.stringify(parsed.error?.issues ?? [])).toBe(true);
});

it('opens with the `security:effective-access` widget, with no props', () => {
expect(children[0]).toEqual({ type: 'security:effective-access', properties: {} });
});

it('keeps the stored grant list below it, where a direct grant is removed', () => {
expect(children.map((c) => c.type)).toEqual(['security:effective-access', 'record:related_list']);
expect((children[1]!.properties as Rec).objectName).toBe('sys_user_permission_set');
});

it('mounts the widget nowhere else on the page', () => {
const found: unknown[] = [];
const walk = (n: unknown): void => {
if (Array.isArray(n)) { n.forEach(walk); return; }
if (!n || typeof n !== 'object') return;
if ((n as Rec).type === 'security:effective-access') found.push(n);
Object.values(n as Rec).forEach(walk);
};
walk(SysUserDetailPage);
expect(found).toHaveLength(1);
});

it('carries no tab-level gate: the server refuses a viewer who may not manage the person', () => {
expect(tab.visibleWhen).toBeUndefined();
});
});
2 changes: 2 additions & 0 deletions packages/platform-objects/src/pages/sys-user.page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -287,6 +287,8 @@ export const SysUserDetailPage: Page = {
label: { en: 'Permission Sets', 'zh-CN': '权限集', 'ja-JP': '権限セット', 'es-ES': 'Conjuntos de permisos', 'zh-Hant': '權限集', ar: 'مجموعات الأذونات', fr: 'Ensembles d’autorisations' },
icon: 'lock',
children: [
// Effective sets with their origins (default / position / direct / derived), read from the server; the stored grant rows stay below.
{ type: 'security:effective-access', properties: {} },
{
type: 'record:related_list',
properties: {
Expand Down
2 changes: 2 additions & 0 deletions ui/packages/app-shell/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
"./dist/console/connect/ManagedPersonAssistants.js",
"./dist/console/diagnostics/CloudAiModelStatus.js",
"./dist/console/home/CloudWorkspaceTimezoneNotice.js",
"./dist/console/security/UserEffectiveAccess.js",
"./dist/services/builtinComponents.js",
"./dist/views/global-notifications-renderer.js",
"./dist/views/global-search-renderer.js",
Expand All @@ -31,6 +32,7 @@
"./src/console/connect/ManagedPersonAssistants.tsx",
"./src/console/diagnostics/CloudAiModelStatus.tsx",
"./src/console/home/CloudWorkspaceTimezoneNotice.tsx",
"./src/console/security/UserEffectiveAccess.tsx",
"./src/services/builtinComponents.tsx",
"./src/views/global-notifications-renderer.tsx",
"./src/views/global-search-renderer.tsx",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,273 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* `security:effective-access` — a person's effective permission sets on their
* `sys_user` record page, each with every origin the server's answer carries.
*
* Driven through the real input pipeline: the node a page authors
* (`{ type: 'security:effective-access', properties: {} }`) is rendered by
* `SchemaRenderer` inside the record context the page binds, and the widget
* is fed `security.describeEffectiveAccess(userId)`'s answer through the
* adapter's client.
*
* - §1 a person with access only through the default binding and a position
* shows those sets, each with its origin, and the set label;
* - §2 CONTROL: a person with one direct grant shows exactly that set;
* - §3 a set reached two ways is one row with both origins; derived origins
* say which standing;
* - §4 a 403 and a 501 show no list, each a quiet line; another failure
* names itself; an empty answer says so;
* - §5 off a `sys_user` record it renders nothing and reads nothing; a grant
* written on the page reads the answer again.
*
* `t` echoes the key with any interpolation values appended, so assertions
* read which string was asked for and with what.
*/

import '@testing-library/jest-dom/vitest';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { cleanup, render, screen, waitFor, within } from '@testing-library/react';

vi.mock('@object-ui/i18n', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
useObjectTranslation: () => ({
t: (key: string, opts?: Record<string, unknown>) => {
const vals = opts ? Object.values(opts).filter((v) => typeof v === 'string' || typeof v === 'number') : [];
return vals.length ? `«${key}|${vals.join('|')}»` : `«${key}»`;
},
language: 'en',
}),
}));

let adapter: unknown = null;
vi.mock('@object-ui/react', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
useAdapter: () => adapter,
}));

import '@object-ui/components';
import { ComponentRegistry } from '@object-ui/core';
import {
MetadataCtx,
notifyDataChanged,
RecordContextProvider,
SchemaRenderer,
type MetadataContextValue,
} from '@object-ui/react';
import './UserEffectiveAccess';
import type { SecurityEffectiveAccessSchema } from '@object-ui/types';
import { describeOrigin, type EffectiveAccessAnswer } from './effectiveAccess';

/** The node a page authors, typed on its `@object-ui/types` twin. */
const NODE: SecurityEffectiveAccessSchema = { type: 'security:effective-access', properties: {} };

/** The permission-set catalog the labels come from (`/meta/permission`). */
const CATALOG = [
{ name: 'member_default', label: 'Member default' },
{ name: 'sales_rep', label: 'Sales rep' },
{ name: 'admin_full_access', label: 'Full administrator' },
];

const describeMock = vi.fn<(userId: string) => Promise<EffectiveAccessAnswer>>();
const ensureTypeMock = vi.fn(async (_type: string): Promise<unknown[]> => CATALOG);

function refusal(status: number, message: string): Error {
return Object.assign(new Error(message), { httpStatus: status });
}

const metadata = {
ensureType: (type: string) => ensureTypeMock(type),
} as unknown as MetadataContextValue;

function renderOn(objectName: string, recordId: string | null) {
return render(
<MetadataCtx.Provider value={metadata}>
<RecordContextProvider objectName={objectName} recordId={recordId}>
<SchemaRenderer schema={NODE} />
</RecordContextProvider>
</MetadataCtx.Provider>,
);
}

/** The rendered rows, as set name → origin badge texts. */
async function rowsShown(): Promise<Record<string, string[]>> {
const list = await screen.findByTestId('effective-access-list');
const out: Record<string, string[]> = {};
for (const row of within(list).getAllByRole('listitem').filter((li) => li.dataset.testid?.startsWith('effective-access-row-'))) {
const name = row.dataset.testid!.slice('effective-access-row-'.length);
const origins = within(row).getByRole('list');
out[name] = within(origins).getAllByRole('listitem').map((li) => li.textContent ?? '');
}
return out;
}

beforeEach(() => {
describeMock.mockReset();
ensureTypeMock.mockClear();
adapter = { getClient: () => ({ security: { describeEffectiveAccess: describeMock } }) };
});

afterEach(() => {
cleanup();
});

describe('§0 registration', () => {
it('is registered as security:effective-access, with no bare fallback', () => {
expect(ComponentRegistry.get('security:effective-access')).toBeDefined();
expect(ComponentRegistry.get('effective-access')).toBeUndefined();
});
});

describe('§1 access only through the default binding and a position', () => {
it('shows those sets with their origins, not an empty list', async () => {
describeMock.mockResolvedValueOnce({
userId: 'user_mia',
organizationId: 'org_here',
positions: ['org_member', 'sales', 'everyone'],
permissionSets: [
{ name: 'sales_rep', origins: [{ kind: 'position', position: 'sales' }] },
{ name: 'member_default', origins: [{ kind: 'default' }] },
],
});
renderOn('sys_user', 'user_mia');

expect(await rowsShown()).toEqual({
sales_rep: ['«effectiveAccess.origin.position|sales»'],
member_default: ['«effectiveAccess.origin.default»'],
});
expect(describeMock).toHaveBeenCalledWith('user_mia');
expect(screen.queryByTestId('effective-access-empty')).toBeNull();
});

it('shows each set by its catalog label, with the name beside it', async () => {
describeMock.mockResolvedValueOnce({
userId: 'user_mia',
organizationId: 'org_here',
positions: ['everyone'],
permissionSets: [{ name: 'member_default', origins: [{ kind: 'default' }] }],
});
renderOn('sys_user', 'user_mia');
const row = await screen.findByTestId('effective-access-row-member_default');
await waitFor(() => expect(within(row).getByText('Member default')).toBeInTheDocument());
expect(within(row).getByText('member_default')).toBeInTheDocument();
expect(ensureTypeMock).toHaveBeenCalledWith('permission');
});

it('shows a set the catalog does not list by its name, never drops it', async () => {
ensureTypeMock.mockResolvedValueOnce([]);
describeMock.mockResolvedValueOnce({
userId: 'user_mia',
organizationId: 'org_here',
positions: [],
permissionSets: [{ name: 'uncatalogued_set', origins: [{ kind: 'default' }] }],
});
renderOn('sys_user', 'user_mia');
const row = await screen.findByTestId('effective-access-row-uncatalogued_set');
expect(within(row).getAllByText('uncatalogued_set')).toHaveLength(1);
});
});

describe('§2 CONTROL — one direct grant', () => {
it('shows exactly that set, as a direct grant', async () => {
describeMock.mockResolvedValueOnce({
userId: 'user_ana',
organizationId: 'org_here',
positions: [],
permissionSets: [{ name: 'sales_rep', origins: [{ kind: 'direct' }] }],
});
renderOn('sys_user', 'user_ana');
expect(await rowsShown()).toEqual({ sales_rep: ['«effectiveAccess.origin.direct»'] });
});
});

describe('§3 a set reached two ways', () => {
it('is one row carrying both origins, in the answer\'s order', async () => {
describeMock.mockResolvedValueOnce({
userId: 'user_mia',
organizationId: 'org_here',
positions: ['sales'],
permissionSets: [
{ name: 'sales_rep', origins: [{ kind: 'direct' }, { kind: 'position', position: 'sales' }] },
{
name: 'admin_full_access',
origins: [{ kind: 'direct' }, { kind: 'derived', source: 'platform_admin_config' }],
},
{ name: 'ai_seat', origins: [{ kind: 'derived', source: 'ai_access' }] },
],
});
renderOn('sys_user', 'user_mia');
expect(await rowsShown()).toEqual({
sales_rep: ['«effectiveAccess.origin.direct»', '«effectiveAccess.origin.position|sales»'],
admin_full_access: ['«effectiveAccess.origin.direct»', '«effectiveAccess.origin.derivedPlatformAdmin»'],
ai_seat: ['«effectiveAccess.origin.derivedAiAccess»'],
});
expect(screen.getAllByTestId(/^effective-access-row-/)).toHaveLength(3);
});

it('describes every origin kind the contract declares', () => {
const t = (key: string, opts?: Record<string, unknown>) => (opts ? `${key}|${Object.values(opts).join('|')}` : key);
expect(describeOrigin({ kind: 'direct' }, t)).toBe('effectiveAccess.origin.direct');
expect(describeOrigin({ kind: 'position', position: 'org_external' }, t)).toBe('effectiveAccess.origin.position|org_external');
expect(describeOrigin({ kind: 'default' }, t)).toBe('effectiveAccess.origin.default');
expect(describeOrigin({ kind: 'derived', source: 'platform_admin_config' }, t)).toBe('effectiveAccess.origin.derivedPlatformAdmin');
expect(describeOrigin({ kind: 'derived', source: 'ai_access' }, t)).toBe('effectiveAccess.origin.derivedAiAccess');
});
});

describe('§4 refusals never draw a list', () => {
it('a 403 (a person the caller may not manage) leaves one quiet line and no list', async () => {
describeMock.mockRejectedValueOnce(refusal(403, 'Forbidden'));
renderOn('sys_user', 'user_other');
expect(await screen.findByTestId('effective-access-refused')).toHaveTextContent('«effectiveAccess.refused»');
expect(screen.queryByTestId('effective-access-list')).toBeNull();
expect(screen.queryByTestId('effective-access-empty')).toBeNull();
});

it('a 501 (a server without the read) leaves one quiet line and no list', async () => {
describeMock.mockRejectedValueOnce(refusal(501, 'Not implemented'));
renderOn('sys_user', 'user_mia');
expect(await screen.findByTestId('effective-access-unserved')).toHaveTextContent('«effectiveAccess.unserved»');
expect(screen.queryByTestId('effective-access-list')).toBeNull();
expect(screen.queryByTestId('effective-access-empty')).toBeNull();
});

it('any other failure names itself, and draws no list', async () => {
describeMock.mockRejectedValueOnce(refusal(500, 'authorization store could not be read'));
renderOn('sys_user', 'user_mia');
expect(await screen.findByTestId('effective-access-failed')).toHaveTextContent(
'«effectiveAccess.loadFailed|authorization store could not be read»',
);
expect(screen.queryByTestId('effective-access-list')).toBeNull();
});

it('an answer with no set says so', async () => {
describeMock.mockResolvedValueOnce({ userId: 'user_mia', organizationId: null, positions: [], permissionSets: [] });
renderOn('sys_user', 'user_mia');
expect(await screen.findByTestId('effective-access-empty')).toHaveTextContent('«effectiveAccess.empty»');
});
});

describe('§5 where it draws, and when it reads again', () => {
it('renders nothing and reads nothing off a sys_user record', () => {
const other = renderOn('crm_account', 'acc_1');
expect(other.container).toBeEmptyDOMElement();
other.unmount();
const noId = renderOn('sys_user', null);
expect(noId.container).toBeEmptyDOMElement();
expect(describeMock).not.toHaveBeenCalled();
});

it('reads the answer again when a grant on the page changes', async () => {
describeMock.mockResolvedValue({
userId: 'user_mia',
organizationId: 'org_here',
positions: [],
permissionSets: [{ name: 'sales_rep', origins: [{ kind: 'direct' }] }],
});
renderOn('sys_user', 'user_mia');
await screen.findByTestId('effective-access-list');
expect(describeMock).toHaveBeenCalledTimes(1);
notifyDataChanged({ objectName: 'sys_user_permission_set' });
await waitFor(() => expect(describeMock).toHaveBeenCalledTimes(2));
});
});
Loading
Loading