diff --git a/.changeset/adr-0057-d10-nav-capability-gate.md b/.changeset/adr-0057-d10-nav-capability-gate.md new file mode 100644 index 0000000000..2141f2c145 --- /dev/null +++ b/.changeset/adr-0057-d10-nav-capability-gate.md @@ -0,0 +1,14 @@ +--- +"@objectstack/rest": minor +"@objectstack/platform-objects": minor +--- + +Setup nav: gate Organizations/Invitations on multi-org; enforce `requiresService` server-side (ADR-0057 addendum D10). + +`rest-server`'s `filterAppForUser` now honours `NavigationItem.requiresService` — entries +whose named kernel service isn't registered are dropped from the served app metadata +(fail-open when the kernel can't be probed; previously the field was a frontend-only hint). +Applies `requiresService: 'org-scoping'` to the Setup app's Organizations and Invitations +entries, so they surface only in multi-org (multi-tenant) deployments and disappear in +single-tenant. Business Units is intentionally left ungated — it is open per the open/paid +seam + D12 ("pick people by BU"); only the hierarchy rollup capability is enterprise. diff --git a/docs/adr/0057-erp-authorization-core-business-units-and-scope-depth.md b/docs/adr/0057-erp-authorization-core-business-units-and-scope-depth.md index a66e97d391..8123a8f671 100644 --- a/docs/adr/0057-erp-authorization-core-business-units-and-scope-depth.md +++ b/docs/adr/0057-erp-authorization-core-business-units-and-scope-depth.md @@ -512,3 +512,34 @@ One proof per surfacing decision, ratcheted with its PR: - **PS-2.** D10 — relocate `nav_business_units` to the hierarchy-security capability (ADR-0029 K2), retiring the inert `requiresObject` gate. - **PS-3.** D12 — `primary_business_unit_id` field + sync hook + backfill + picker proof. + +### PS-2 implementation note (2026-06-22) — D10 realized via server-enforced `requiresService` + +Implementing D10 refined the mechanism (no cross-repo relocation needed): + +- The Setup nav is filtered **server-side** (`rest-server` `filterAppForUser`), which + previously honoured only `requiredPermissions`. `requiresObject` is a **client-side** + (objectui) gate, not enforced in this repo. +- The spec already carries `NavigationItem.requiresService` (a kernel-service capability + gate). PS-2 gives it **server-side teeth**: gated entries are dropped from the served + payload (fail-open when the kernel can't be probed). Satisfies ADR-0049 (enforced, not + merely declared-for-frontend), in-repo and testable. +- **Organizations / Invitations** → `requiresService: 'org-scoping'`. `org-scoping` is the + canonical multi-org probe (SecurityPlugin already uses it), registered only in + multi-tenant mode → single-tenant hides both entries. +- **Business Units is deliberately NOT gated.** The only available signal would be + `hierarchy-security` (the **paid** rollup resolver), and gating on it would hide the + management UI for functionality that is **open**: BU as owning-unit, the explicit + `business_unit` sharing recipient, and the D12 "pick people by BU" projection. The + "ceiling" in D9 refers to the **hierarchy rollup capability**, not BU's data/management + surface. With Organizations hidden in single-tenant and the `kind='team'` collision + removed (D11), the residual community menu is Users + Teams + Business Units — two + distinct, legitimately co-present concepts, which was never the ambiguity this addendum + set out to remove. If hiding BU in vanilla deployments later proves desirable, gate it on + an explicit `business-units` opt-in, never on the paid resolver. + +Proof: `filterAppForUser` unit tests (rest.test.ts) — requiresService entries drop when the +gate reports the service absent, persist when present, fail-open with no gate, and +requiresObject entries are untouched. An end-to-end Setup-nav test is not feasible in the +verify harness: the platform Setup app's navigation is not materialized there (`/meta/app` +lists only the business app; `/meta/app/setup` returns a protection stub). diff --git a/packages/platform-objects/src/apps/setup-nav.contributions.ts b/packages/platform-objects/src/apps/setup-nav.contributions.ts index 82e8e7b3d3..30366f4cfb 100644 --- a/packages/platform-objects/src/apps/setup-nav.contributions.ts +++ b/packages/platform-objects/src/apps/setup-nav.contributions.ts @@ -49,8 +49,8 @@ export const SETUP_NAV_CONTRIBUTIONS: NavigationContribution[] = [ { id: 'nav_users', type: 'object', label: 'Users', objectName: 'sys_user', icon: 'user' }, { id: 'nav_business_units', type: 'object', label: 'Business Units', objectName: 'sys_business_unit', icon: 'building', requiresObject: 'sys_business_unit' }, { id: 'nav_teams', type: 'object', label: 'Teams', objectName: 'sys_team', icon: 'users-round' }, - { id: 'nav_organizations', type: 'object', label: 'Organizations', objectName: 'sys_organization', icon: 'building-2' }, - { id: 'nav_invitations', type: 'object', label: 'Invitations', objectName: 'sys_invitation', icon: 'mail' }, + { id: 'nav_organizations', type: 'object', label: 'Organizations', objectName: 'sys_organization', icon: 'building-2', requiresService: 'org-scoping' }, + { id: 'nav_invitations', type: 'object', label: 'Invitations', objectName: 'sys_invitation', icon: 'mail', requiresService: 'org-scoping' }, ], }, { diff --git a/packages/rest/src/rest-api-plugin.ts b/packages/rest/src/rest-api-plugin.ts index ac07b9942c..0b297d9d90 100644 --- a/packages/rest/src/rest-api-plugin.ts +++ b/packages/rest/src/rest-api-plugin.ts @@ -185,8 +185,13 @@ export function createRestApiPlugin(config: RestApiPluginConfig = {}): Plugin { ctx.logger.info('Hydrating REST API from Protocol...'); + // Single-env service-existence probe for nav capability gates + // (ADR-0057 D10). Multi-env uses the per-request kernel instead. + const serviceExistsProvider = (name: string): boolean => { + try { return ctx.getService(name) != null; } catch { return false; } + }; try { - const restServer = new RestServer(server, protocol, config.api as any, kernelManager, envRegistry, defaultEnvironmentIdProvider, authServiceProvider, objectQLProvider, emailServiceProvider, sharingServiceProvider, reportsServiceProvider, approvalsServiceProvider, sharingRulesServiceProvider, i18nServiceProvider, analyticsServiceProvider, settingsServiceProvider); + const restServer = new RestServer(server, protocol, config.api as any, kernelManager, envRegistry, defaultEnvironmentIdProvider, authServiceProvider, objectQLProvider, emailServiceProvider, sharingServiceProvider, reportsServiceProvider, approvalsServiceProvider, sharingRulesServiceProvider, i18nServiceProvider, analyticsServiceProvider, settingsServiceProvider, serviceExistsProvider); restServer.registerRoutes(); ctx.logger.info('REST API successfully registered'); diff --git a/packages/rest/src/rest-server.ts b/packages/rest/src/rest-server.ts index d448dc8240..2a042114d3 100644 --- a/packages/rest/src/rest-server.ts +++ b/packages/rest/src/rest-server.ts @@ -542,6 +542,10 @@ export class RestServer { private i18nServiceProvider?: (environmentId?: string) => Promise; private analyticsServiceProvider?: (environmentId?: string) => Promise; private settingsServiceProvider?: (environmentId?: string) => Promise; + /** Sync probe: is a kernel service registered? Single-env path for nav + * capability gates (ADR-0057 D10) — resolveExecCtx sets no kernel in + * single-kernel deployments, so this prevents the gate failing open. */ + private serviceExistsProvider?: (name: string) => boolean; constructor( server: IHttpServer, @@ -560,6 +564,7 @@ export class RestServer { i18nServiceProvider?: (environmentId?: string) => Promise, analyticsServiceProvider?: (environmentId?: string) => Promise, settingsServiceProvider?: (environmentId?: string) => Promise, + serviceExistsProvider?: (name: string) => boolean, ) { this.protocol = protocol; this.config = this.normalizeConfig(config); @@ -577,6 +582,7 @@ export class RestServer { this.i18nServiceProvider = i18nServiceProvider; this.analyticsServiceProvider = analyticsServiceProvider; this.settingsServiceProvider = settingsServiceProvider; + this.serviceExistsProvider = serviceExistsProvider; } /** @@ -1076,6 +1082,10 @@ export class RestServer { ...(timezone ? { timezone } : {}), ...(locale ? { locale } : {}), ...(currency ? { currency } : {}), + // Internal: resolved kernel so the nav-serving path can probe + // requiresService capability gates (ADR-0057 D10). NOT an + // authorization input — never read by RLS/permission logic. + __kernel: kernel, } as any; } catch { return undefined; @@ -1095,7 +1105,7 @@ export class RestServer { * shallow copy with a filtered `navigation` tree otherwise — the original * is never mutated so cached metadata stays clean. */ - private filterAppForUser(item: any, sysPerms: Set): any | null { + private filterAppForUser(item: any, sysPerms: Set, serviceGate?: (name: string) => boolean): any | null { if (!item || typeof item !== 'object') return item; // ADR-0045: an unpublished app (`hidden: true`) is externally // unobservable — only builders (studio/setup access) receive it at all, @@ -1108,6 +1118,11 @@ export class RestServer { if (reqApp.length > 0 && !reqApp.every((p: string) => sysPerms.has(p))) { return null; } + // ADR-0057 D10 — capability gate: hide when the named kernel service is + // absent. Fail-open when the gate can't be probed (serviceGate undefined). + if (typeof item.requiresService === 'string' && serviceGate && serviceGate(item.requiresService) === false) { + return null; + } const nav = Array.isArray(item.navigation) ? item.navigation : null; if (!nav) return item; @@ -1117,6 +1132,7 @@ export class RestServer { if (!e || typeof e !== 'object') continue; const req = Array.isArray(e.requiredPermissions) ? e.requiredPermissions : []; if (req.length > 0 && !req.every((p: string) => sysPerms.has(p))) continue; + if (typeof e.requiresService === 'string' && serviceGate && serviceGate(e.requiresService) === false) continue; if (Array.isArray(e.children) && e.children.length > 0) { const kids = filterNav(e.children); // Drop empty groups so the sidebar doesn't render a label @@ -1133,6 +1149,41 @@ export class RestServer { return { ...item, navigation: filterNav(nav) }; } + /** + * Probe which `requiresService` capability gates referenced anywhere in + * `items` are actually registered in the runtime kernel. Returns `null` + * when the kernel can't be probed — callers then SKIP service gating + * (fail-open, matching the prior "send everything, let the client hide" + * behaviour). ADR-0057 addendum D10. + */ + private async resolveRegisteredServices(kernel: any, items: any[]): Promise | null> { + // Prefer the per-request kernel (multi-env, resolved via kernelManager). + // Fall back to the single-env service-existence provider — in single-kernel + // deployments resolveExecCtx never sets a kernel, so without this the gate + // would fail open (ADR-0057 D10). + let probe: ((name: string) => Promise) | null = null; + if (kernel && typeof kernel.getServiceAsync === 'function') { + probe = async (name) => { try { return (await kernel.getServiceAsync(name)) != null; } catch { return false; } }; + } else if (this.serviceExistsProvider) { + const exists = this.serviceExistsProvider; + probe = async (name) => { try { return exists(name) === true; } catch { return false; } }; + } + if (!probe) return null; + const wanted = new Set(); + const walk = (e: any): void => { + if (!e || typeof e !== 'object') return; + if (typeof e.requiresService === 'string') wanted.add(e.requiresService); + const kids = Array.isArray(e.navigation) ? e.navigation + : Array.isArray(e.children) ? e.children : null; + if (kids) for (const k of kids) walk(k); + }; + for (const it of items) walk(it); + if (wanted.size === 0) return new Set(); + const registered = new Set(); + for (const name of wanted) { if (await probe(name)) registered.add(name); } + return registered; + } + /** * Build a `TranslationBundle` (`Record`) from an * `II18nService` instance. Returns `undefined` when no locales are @@ -1918,8 +1969,10 @@ export class RestServer { const sysPerms = new Set( Array.isArray(ctx.systemPermissions) ? ctx.systemPermissions : [], ); + const registered = await this.resolveRegisteredServices((ctx as any).__kernel, list); + const serviceGate = registered ? (n: string) => registered.has(n) : undefined; const filtered = list - .map((it: any) => this.filterAppForUser(it, sysPerms)) + .map((it: any) => this.filterAppForUser(it, sysPerms, serviceGate)) .filter((it: any) => it != null); visible = Array.isArray(raw) ? filtered @@ -2239,7 +2292,9 @@ export class RestServer { const sysPerms = new Set( Array.isArray(ctx.systemPermissions) ? ctx.systemPermissions : [], ); - visible = this.filterAppForUser(item, sysPerms); + const registered = await this.resolveRegisteredServices((ctx as any).__kernel, [item]); + const serviceGate = registered ? (n: string) => registered.has(n) : undefined; + visible = this.filterAppForUser(item, sysPerms, serviceGate); if (visible == null) { res.status(404).json({ error: 'not_found', diff --git a/packages/rest/src/rest.test.ts b/packages/rest/src/rest.test.ts index 64ee22b61b..fdaaba2370 100644 --- a/packages/rest/src/rest.test.ts +++ b/packages/rest/src/rest.test.ts @@ -1874,6 +1874,57 @@ describe('filterAppForUser — ADR-0045 hidden-app gate', () => { }); }); +// --------------------------------------------------------------------------- +// ADR-0057 D10 — requiresService capability gate (filterAppForUser) +// --------------------------------------------------------------------------- + +describe('filterAppForUser — ADR-0057 D10 requiresService gate', () => { + const make = () => new RestServer(createMockServer() as any, createMockProtocol() as any); + const app = () => ({ + name: 'setup', + navigation: [ + { id: 'nav_users', type: 'object' }, + { id: 'nav_business_units', type: 'object', requiresObject: 'sys_business_unit' }, + { id: 'nav_organizations', type: 'object', requiresService: 'org-scoping' }, + { id: 'nav_invitations', type: 'object', requiresService: 'org-scoping' }, + ], + }); + const ids = (a: any): string[] => (a?.navigation ?? []).map((e: any) => e.id); + + it('drops requiresService entries when the gate reports the service absent', () => { + const rest: any = make(); + const out = rest.filterAppForUser(app(), new Set(), (n: string) => n !== 'org-scoping'); + expect(ids(out)).toEqual(['nav_users', 'nav_business_units']); + }); + + it('keeps requiresService entries when the service is present', () => { + const rest: any = make(); + const out = rest.filterAppForUser(app(), new Set(), () => true); + expect(ids(out)).toContain('nav_organizations'); + expect(ids(out)).toContain('nav_invitations'); + }); + + it('fail-open: with no service gate, requiresService entries are kept (prior behaviour)', () => { + const rest: any = make(); + expect(ids(rest.filterAppForUser(app(), new Set()))).toContain('nav_organizations'); + }); + + it('the service gate does not touch requiresObject entries (client-side concern)', () => { + const rest: any = make(); + const out = rest.filterAppForUser(app(), new Set(), () => false); + expect(ids(out)).toContain('nav_business_units'); + expect(ids(out)).not.toContain('nav_organizations'); + }); + + it('resolveRegisteredServices probes only referenced services and reports presence', async () => { + const rest: any = make(); + const kernel = { getServiceAsync: async (n: string) => { if (n === 'org-scoping') return {}; throw new Error('not registered'); } }; + const reg = await rest.resolveRegisteredServices(kernel, [app()]); + expect(reg.has('org-scoping')).toBe(true); + expect(reg.size).toBe(1); + }); +}); + // --------------------------------------------------------------------------- // Object API exposure — enable.apiEnabled / enable.apiMethods (ADR-0049 #1889) // ---------------------------------------------------------------------------