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/setup-people-org-assignment-entries.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
---
'@objectstack/plugin-security': patch
'@objectstack/platform-objects': patch
---

feat(plugin-security): Setup → People & Organization opens the Position Holders and Permission Set Assignment pages

Clause-②: no
Metadata-protocol: none

The Setup app's People & Organization group gains two entries, contributed by `@objectstack/plugin-security` beside its Positions / Capabilities / Permission Sets entries:

- **Position Holders** (`nav_position_holders`) — `type: 'component'`, `componentRef: 'security:position-holders'`.
- **Permission Set Assignment** (`nav_permission_set_assignment`) — `type: 'component'`, `componentRef: 'security:permission-set-assignment'`.

The console registers the two pages under those refs; until then an entry opens the console's not-found page. The entries carry no gate of their own, exactly like the catalog entries: the Setup app's `setup.access` and the ungated group anchor decide who sees them, and they disappear wherever the plugin is not installed.

`@objectstack/platform-objects`' Setup bundles carry both labels in `en`, `zh-CN`, `ja-JP` and `es-ES`, with each translation's source digest recorded.
5 changes: 5 additions & 0 deletions packages/platform-objects/src/apps/translations/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,11 @@ export const en: TranslationData = {
nav_teams: { label: 'Teams' },
nav_organizations: { label: 'Organizations' },
nav_invitations: { label: 'Invitations' },
// Contributed by @objectstack/plugin-security (ADR-0146 D7): the two
// assignment pages, `security:position-holders` and
// `security:permission-set-assignment`.
nav_position_holders: { label: 'Position Holders' },
nav_permission_set_assignment: { label: 'Permission Set Assignment' },

// Access Control
nav_positions: { label: 'Positions' },
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,8 @@ export const esESSourceHashes: Readonly<Record<string, string>> = {
'apps.setup.navigation.nav_teams.label': '3fe8519872f65108',
'apps.setup.navigation.nav_organizations.label': '07488357631b973d',
'apps.setup.navigation.nav_invitations.label': '4ebb9fd13de7a546',
'apps.setup.navigation.nav_position_holders.label': '627cacb50fd1b0d8',
'apps.setup.navigation.nav_permission_set_assignment.label': 'fcf7d1b69ee9c298',
'apps.setup.navigation.nav_positions.label': '517a2743f949b5f0',
'apps.setup.navigation.nav_capabilities.label': '0b59005e10add41f',
'apps.setup.navigation.nav_permission_sets.label': '4addfe2b8b28b78a',
Expand Down
2 changes: 2 additions & 0 deletions packages/platform-objects/src/apps/translations/es-ES.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,8 @@ export const esES: TranslationData = {
nav_teams: { label: 'Equipos' },
nav_organizations: { label: 'Organizaciones' },
nav_invitations: { label: 'Invitaciones' },
nav_position_holders: { label: 'Titulares de posiciones' },
nav_permission_set_assignment: { label: 'Asignación de conjuntos de permisos' },

nav_positions: { label: 'Posiciones' },
nav_capabilities: { label: 'Capacidades' },
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,8 @@ export const jaJPSourceHashes: Readonly<Record<string, string>> = {
'apps.setup.navigation.nav_teams.label': '3fe8519872f65108',
'apps.setup.navigation.nav_organizations.label': '07488357631b973d',
'apps.setup.navigation.nav_invitations.label': '4ebb9fd13de7a546',
'apps.setup.navigation.nav_position_holders.label': '627cacb50fd1b0d8',
'apps.setup.navigation.nav_permission_set_assignment.label': 'fcf7d1b69ee9c298',
'apps.setup.navigation.nav_positions.label': '517a2743f949b5f0',
'apps.setup.navigation.nav_capabilities.label': '0b59005e10add41f',
'apps.setup.navigation.nav_permission_sets.label': '4addfe2b8b28b78a',
Expand Down
2 changes: 2 additions & 0 deletions packages/platform-objects/src/apps/translations/ja-JP.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,8 @@ export const jaJP: TranslationData = {
nav_teams: { label: 'チーム' },
nav_organizations: { label: 'すべての組織' },
nav_invitations: { label: '招待' },
nav_position_holders: { label: 'ポジション保持者' },
nav_permission_set_assignment: { label: '権限セットの割り当て' },

nav_positions: { label: 'ポジション' },
nav_capabilities: { label: 'ケイパビリティ' },
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,8 @@ export const zhCNSourceHashes: Readonly<Record<string, string>> = {
'apps.setup.navigation.nav_teams.label': '3fe8519872f65108',
'apps.setup.navigation.nav_organizations.label': '07488357631b973d',
'apps.setup.navigation.nav_invitations.label': '4ebb9fd13de7a546',
'apps.setup.navigation.nav_position_holders.label': '627cacb50fd1b0d8',
'apps.setup.navigation.nav_permission_set_assignment.label': 'fcf7d1b69ee9c298',
'apps.setup.navigation.nav_positions.label': '517a2743f949b5f0',
'apps.setup.navigation.nav_capabilities.label': '0b59005e10add41f',
'apps.setup.navigation.nav_permission_sets.label': '4addfe2b8b28b78a',
Expand Down
2 changes: 2 additions & 0 deletions packages/platform-objects/src/apps/translations/zh-CN.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,8 @@ export const zhCN: TranslationData = {
nav_teams: { label: '团队' },
nav_organizations: { label: '所有组织' },
nav_invitations: { label: '邀请' },
nav_position_holders: { label: '岗位持有人' },
nav_permission_set_assignment: { label: '权限集分配' },

nav_positions: { label: '岗位' },
nav_capabilities: { label: '能力' },
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,197 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
//
// [ADR-0146 D7] The Security plugin's two Setup → People & Organization
// entries: "Position Holders" and "Permission Set Assignment".
//
// The pages are console components the app shell registers under
// `security:position-holders` and `security:permission-set-assignment`; Setup's
// navigation is server metadata, so without an entry naming each ref the pages
// are reachable by typed URL only. This file pins the half this repository
// owns — the two entries, the refs they name, the group they sit in, and their
// gate. The console pins the ref → page half on its side.
//
// Why plugin-security and not `@objectstack/platform-objects`: the pages read
// and write this plugin's assignment rows, so the entries live and die with the
// plugin, exactly like its Positions / Capabilities / Permission Sets entries.
// That is also their gate: none of their own. The catalog entries carry no
// `requiredPermissions`, `requiresService` or `requiresObject`; the Setup app's
// `setup.access` and the group anchor decide who sees them.

import { describe, it, expect } from 'vitest';
import { SchemaRegistry } from '@objectstack/objectql';
import { SETUP_APP, SETUP_NAV_CONTRIBUTIONS, SetupAppTranslations } from '@objectstack/platform-objects/apps';
import { NavigationContributionSchema } from '@objectstack/spec/ui';
import { SecurityPlugin } from './security-plugin.js';

type NavItem = {
id?: string;
type?: string;
label?: string;
componentRef?: string;
requiredPermissions?: string[];
requiresService?: string;
requiresObject?: string;
visible?: unknown;
children?: NavItem[];
};

type Contribution = { app: string; group?: string; priority?: number; items: NavItem[] };

/** Run the plugin's init() and return the manifest it registered. */
async function registeredManifest(): Promise<any> {
const registered: any[] = [];
const ctx: any = {
logger: { info() {}, warn() {}, error() {}, debug() {} },
getService: (name: string) => {
if (name === 'manifest') return { register: (m: unknown) => registered.push(m) };
throw new Error(`service not registered: ${name}`);
},
registerService() {},
hook() {},
};
await new SecurityPlugin().init(ctx);
expect(registered).toHaveLength(1);
return registered[0];
}

const POSITION_HOLDERS = {
id: 'nav_position_holders',
type: 'component',
label: 'Position Holders',
componentRef: 'security:position-holders',
};
const PERMISSION_SET_ASSIGNMENT = {
id: 'nav_permission_set_assignment',
type: 'component',
label: 'Permission Set Assignment',
componentRef: 'security:permission-set-assignment',
};

/** The item keys that decide who sees an entry. */
const GATE_KEYS = ['requiredPermissions', 'requiresService', 'requiresObject', 'visible'] as const;
const gateOf = (item: NavItem) => Object.fromEntries(GATE_KEYS.map((k) => [k, item[k]]));

/**
* The Setup app as `GET /api/v1/meta/app` folds it: the real `SchemaRegistry`
* over `SETUP_APP`, platform-objects' own contributions, and — when
* `withSecurity` — this plugin's, registered under its manifest id.
*/
async function renderedSetup(withSecurity: boolean): Promise<NavItem[]> {
const registry = new SchemaRegistry({ multiTenant: false, collisionPolicy: 'error' });
(registry as any).logLevel = 'silent';
registry.registerApp(structuredClone(SETUP_APP), '@objectstack/platform-objects');
for (const contribution of SETUP_NAV_CONTRIBUTIONS) {
registry.registerAppNavContribution(structuredClone(contribution) as any, '@objectstack/platform-objects');
}
if (withSecurity) {
const manifest = await registeredManifest();
for (const contribution of manifest.navigationContributions) {
registry.registerAppNavContribution(structuredClone(contribution), manifest.id);
}
}
return (registry.getApp('setup')?.navigation ?? []) as NavItem[];
}

const groupChildren = (nav: NavItem[], group: string): NavItem[] =>
nav.find((n) => n.id === group)?.children ?? [];

describe('SecurityPlugin — Setup People & Organization assignment entries (ADR-0146 D7)', () => {
it('contributes both entries into group_people_org, naming the two component refs, and it parses', async () => {
const manifest = await registeredManifest();
const contributions = manifest.navigationContributions as Contribution[];
const peopleOrg = contributions.filter((c) => c.app === 'setup' && c.group === 'group_people_org');
expect(peopleOrg).toHaveLength(1);
expect(peopleOrg[0].items).toEqual([
expect.objectContaining(POSITION_HOLDERS),
expect.objectContaining(PERMISSION_SET_ASSIGNMENT),
]);
// Registry KEYS, never console paths.
for (const item of peopleOrg[0].items) {
expect(item.componentRef).toMatch(/^[a-z0-9_-]+:[a-z0-9_-]+$/);
}
for (const contribution of contributions) {
expect(() => NavigationContributionSchema.parse(contribution)).not.toThrow();
}
});

it('carries the same gate as the Positions / Permission Sets entries: none of its own', async () => {
const manifest = await registeredManifest();
const items = (manifest.navigationContributions as Contribution[]).flatMap((c) => c.items);
const byId = new Map(items.map((i) => [i.id, i]));
const positions = byId.get('nav_positions');
const permissionSets = byId.get('nav_permission_sets');
// Control: the catalog entries really are in this manifest, so the
// comparison below is against a real gate, not two undefined lookups.
expect(positions).toBeDefined();
expect(permissionSets).toBeDefined();
for (const id of [POSITION_HOLDERS.id, PERMISSION_SET_ASSIGNMENT.id]) {
const entry = byId.get(id);
expect(entry, id).toBeDefined();
expect(gateOf(entry!), id).toEqual(gateOf(positions!));
expect(gateOf(entry!), id).toEqual(gateOf(permissionSets!));
}
});

it('the rendered Setup nav serves both under People & Organization, beside the base entries', async () => {
const nav = await renderedSetup(true);
const children = groupChildren(nav, 'group_people_org');
// Control: platform-objects' base entries fold into the same group.
expect(children.map((i) => i.id)).toContain('nav_users');
expect(children.find((i) => i.id === POSITION_HOLDERS.id)).toMatchObject(POSITION_HOLDERS);
expect(children.find((i) => i.id === PERMISSION_SET_ASSIGNMENT.id)).toMatchObject(PERMISSION_SET_ASSIGNMENT);
// The group anchor they land in is ungated, like Access Control's.
const anchor = nav.find((n) => n.id === 'group_people_org');
const accessControl = nav.find((n) => n.id === 'group_access_control');
expect(anchor?.requiredPermissions ?? []).toEqual(accessControl?.requiredPermissions ?? []);
// Both entries open in exactly one place.
const all = nav.flatMap((n) => n.children ?? []);
expect(all.filter((i) => i.componentRef === POSITION_HOLDERS.componentRef)).toHaveLength(1);
expect(all.filter((i) => i.componentRef === PERMISSION_SET_ASSIGNMENT.componentRef)).toHaveLength(1);
});

it('without this plugin, neither entry appears — hidden exactly where the catalog entries are', async () => {
const nav = await renderedSetup(false);
const ids = nav.flatMap((n) => n.children ?? []).map((i) => i.id);
// Control: the fold still serves platform-objects' entries.
expect(ids).toContain('nav_users');
expect(ids).not.toContain('nav_positions');
expect(ids).not.toContain('nav_permission_sets');
expect(ids).not.toContain(POSITION_HOLDERS.id);
expect(ids).not.toContain(PERMISSION_SET_ASSIGNMENT.id);
});
});

// The labels. Setup's nav labels — this plugin's catalog entries included —
// live in `@objectstack/platform-objects`' hand-authored Setup bundles
// (`apps.setup.navigation.<id>.label`); this plugin's own bundle carries no
// `apps` section. So the population is every locale of the SERVED Setup bundle
// that carries the People & Organization group, and the ids are read off this
// plugin's manifest, not retyped. Served, not raw: a translation whose recorded
// source digest no longer matches is served as the English source
// (`withSourceFallback`), and that is what an administrator reads.
describe('SecurityPlugin — People & Organization assignment entries are labelled in every Setup locale', () => {
it('each locale carrying group_people_org labels both entries; translated locales do not serve the English', async () => {
const manifest = await registeredManifest();
const ids = (manifest.navigationContributions as Contribution[])
.filter((c) => c.app === 'setup' && c.group === 'group_people_org')
.flatMap((c) => c.items.map((i) => i.id!));
expect(ids).toEqual([POSITION_HOLDERS.id, PERMISSION_SET_ASSIGNMENT.id]);

const navOf = (locale: string) =>
((SetupAppTranslations as any)[locale]?.apps?.setup?.navigation ?? {}) as Record<string, { label?: string }>;
const locales = Object.keys(SetupAppTranslations).filter((l) => navOf(l).group_people_org?.label);
// Control: the four Setup locales carry the group, so the loop below judges something.
expect(locales.sort()).toEqual(['en', 'es-ES', 'ja-JP', 'zh-CN']);

const en = navOf('en');
expect(en[POSITION_HOLDERS.id]?.label).toBe(POSITION_HOLDERS.label);
expect(en[PERMISSION_SET_ASSIGNMENT.id]?.label).toBe(PERMISSION_SET_ASSIGNMENT.label);
for (const locale of locales.filter((l) => l !== 'en')) {
const nav = navOf(locale);
for (const id of ids) {
expect(nav[id]?.label, `${locale} ${id}`).toBeTruthy();
expect(nav[id]?.label, `${locale} ${id} serves the English source`).not.toBe(en[id]?.label);
}
}
});
});
22 changes: 22 additions & 0 deletions packages/plugins/plugin-security/src/security-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1645,6 +1645,28 @@ export class SecurityPlugin implements Plugin {
{ id: 'nav_permission_sets', type: 'url', label: 'Permission Sets', url: '/apps/setup/metadata/permission?scope=environment', icon: 'lock' },
],
},
{
// [ADR-0146 D7] The two ASSIGNMENT pages — who holds a position, and
// which users carry a permission set. They sit with the people they
// assign (People & Organization), not with the catalog above. The
// console binds each `componentRef` to its page (app-shell
// `builtinComponents`), and the pages read and write this plugin's
// assignment rows, so the entries live and die with this plugin like
// the catalog entries. Gate: none of their own, exactly like
// Positions / Permission Sets — the Setup app's `setup.access` and the
// ungated group anchor decide who sees them (ADR-0146 D7: the scope of
// `setup.access` and the delegated-administrator gate), and the writes
// the pages make keep their own server-side gates (the ADR-0090 D12
// delegated-admin gate among them). Priority 200 keeps them after
// platform-objects' base People & Organization entries (100).
app: 'setup',
group: 'group_people_org',
priority: 200,
items: [
{ id: 'nav_position_holders', type: 'component', label: 'Position Holders', componentRef: 'security:position-holders', icon: 'user-check' },
{ id: 'nav_permission_set_assignment', type: 'component', label: 'Permission Set Assignment', componentRef: 'security:permission-set-assignment', icon: 'key-round' },
],
},
],
});

Expand Down
Loading