From dd1f9ce1ba0dd326c4142245b45d6f3bada30440 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 31 Oct 2026 04:35:01 +0000 Subject: [PATCH 1/3] feat(plugin-security): Setup People & Organization entries for position holders and permission-set assignment Two type: 'component' entries under group_people_org, contributed by plugin-security beside its catalog entries and gated the same way (no item gate; Setup's setup.access and the ungated group anchor decide). Labels in the four platform-objects Setup bundles, with recorded source digests. Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_016WV4czVnUdQQCVKSW5J2uk --- .../src/apps/translations/en.ts | 5 +++++ .../apps/translations/es-ES.source-hashes.ts | 2 ++ .../src/apps/translations/es-ES.ts | 2 ++ .../apps/translations/ja-JP.source-hashes.ts | 2 ++ .../src/apps/translations/ja-JP.ts | 2 ++ .../apps/translations/zh-CN.source-hashes.ts | 2 ++ .../src/apps/translations/zh-CN.ts | 2 ++ .../plugin-security/src/security-plugin.ts | 22 +++++++++++++++++++ 8 files changed, 39 insertions(+) diff --git a/packages/platform-objects/src/apps/translations/en.ts b/packages/platform-objects/src/apps/translations/en.ts index 46643b5ace2..58994b20ae8 100644 --- a/packages/platform-objects/src/apps/translations/en.ts +++ b/packages/platform-objects/src/apps/translations/en.ts @@ -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' }, diff --git a/packages/platform-objects/src/apps/translations/es-ES.source-hashes.ts b/packages/platform-objects/src/apps/translations/es-ES.source-hashes.ts index 015edadb013..9ffda44a473 100644 --- a/packages/platform-objects/src/apps/translations/es-ES.source-hashes.ts +++ b/packages/platform-objects/src/apps/translations/es-ES.source-hashes.ts @@ -90,6 +90,8 @@ export const esESSourceHashes: Readonly> = { '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', diff --git a/packages/platform-objects/src/apps/translations/es-ES.ts b/packages/platform-objects/src/apps/translations/es-ES.ts index 6212070aaef..d7fffb6aec8 100644 --- a/packages/platform-objects/src/apps/translations/es-ES.ts +++ b/packages/platform-objects/src/apps/translations/es-ES.ts @@ -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' }, diff --git a/packages/platform-objects/src/apps/translations/ja-JP.source-hashes.ts b/packages/platform-objects/src/apps/translations/ja-JP.source-hashes.ts index aa99cfdf373..cefe3fa208b 100644 --- a/packages/platform-objects/src/apps/translations/ja-JP.source-hashes.ts +++ b/packages/platform-objects/src/apps/translations/ja-JP.source-hashes.ts @@ -90,6 +90,8 @@ export const jaJPSourceHashes: Readonly> = { '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', diff --git a/packages/platform-objects/src/apps/translations/ja-JP.ts b/packages/platform-objects/src/apps/translations/ja-JP.ts index abd6a707db3..0e7783db220 100644 --- a/packages/platform-objects/src/apps/translations/ja-JP.ts +++ b/packages/platform-objects/src/apps/translations/ja-JP.ts @@ -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: 'ケイパビリティ' }, diff --git a/packages/platform-objects/src/apps/translations/zh-CN.source-hashes.ts b/packages/platform-objects/src/apps/translations/zh-CN.source-hashes.ts index 2ee7c9ecb1a..d5b1ab3f0c9 100644 --- a/packages/platform-objects/src/apps/translations/zh-CN.source-hashes.ts +++ b/packages/platform-objects/src/apps/translations/zh-CN.source-hashes.ts @@ -90,6 +90,8 @@ export const zhCNSourceHashes: Readonly> = { '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', diff --git a/packages/platform-objects/src/apps/translations/zh-CN.ts b/packages/platform-objects/src/apps/translations/zh-CN.ts index 82ab9b6024e..5c0d1d5d9e8 100644 --- a/packages/platform-objects/src/apps/translations/zh-CN.ts +++ b/packages/platform-objects/src/apps/translations/zh-CN.ts @@ -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: '能力' }, diff --git a/packages/plugins/plugin-security/src/security-plugin.ts b/packages/plugins/plugin-security/src/security-plugin.ts index 5aba0e16596..4024fcc2133 100644 --- a/packages/plugins/plugin-security/src/security-plugin.ts +++ b/packages/plugins/plugin-security/src/security-plugin.ts @@ -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' }, + ], + }, ], }); From f3a47b972a640710d6e59520c2da1c8dbcb3b7d2 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 31 Oct 2026 04:36:19 +0000 Subject: [PATCH 2/3] test(plugin-security): pin the People & Organization assignment entries and their labels Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_016WV4czVnUdQQCVKSW5J2uk --- ...le-org-assignment-nav-contribution.test.ts | 197 ++++++++++++++++++ 1 file changed, 197 insertions(+) create mode 100644 packages/plugins/plugin-security/src/people-org-assignment-nav-contribution.test.ts diff --git a/packages/plugins/plugin-security/src/people-org-assignment-nav-contribution.test.ts b/packages/plugins/plugin-security/src/people-org-assignment-nav-contribution.test.ts new file mode 100644 index 00000000000..eb5e8fba1a7 --- /dev/null +++ b/packages/plugins/plugin-security/src/people-org-assignment-nav-contribution.test.ts @@ -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 { + 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 { + 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..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; + 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); + } + } + }); +}); From d1bf48213a5bb8a2f9a2f08977ec9ba64ee6ec6c Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 31 Oct 2026 04:39:57 +0000 Subject: [PATCH 3/3] chore: changeset for the Setup People & Organization assignment entries Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_016WV4czVnUdQQCVKSW5J2uk --- .../setup-people-org-assignment-entries.md | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 .changeset/setup-people-org-assignment-entries.md diff --git a/.changeset/setup-people-org-assignment-entries.md b/.changeset/setup-people-org-assignment-entries.md new file mode 100644 index 00000000000..d15badde646 --- /dev/null +++ b/.changeset/setup-people-org-assignment-entries.md @@ -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.