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
14 changes: 14 additions & 0 deletions .changeset/adr-0057-d10-nav-capability-gate.md
Original file line numberDiff line numberDiff line change
@@ -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.
Original file line numberDiff line numberDiff line change
Expand Up@@ -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).
Original file line numberDiff line numberDiff line change
Expand Up@@ -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' },
],
},
{
Expand Down
7 changes: 6 additions & 1 deletion packages/rest/src/rest-api-plugin.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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<any>(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');
Expand Down
61 changes: 58 additions & 3 deletions packages/rest/src/rest-server.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -542,6 +542,10 @@ export class RestServer {
private i18nServiceProvider?: (environmentId?: string) => Promise<any | undefined>;
private analyticsServiceProvider?: (environmentId?: string) => Promise<any | undefined>;
private settingsServiceProvider?: (environmentId?: string) => Promise<any | undefined>;
/** 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,
Expand All@@ -560,6 +564,7 @@ export class RestServer {
i18nServiceProvider?: (environmentId?: string) => Promise<any | undefined>,
analyticsServiceProvider?: (environmentId?: string) => Promise<any | undefined>,
settingsServiceProvider?: (environmentId?: string) => Promise<any | undefined>,
serviceExistsProvider?: (name: string) => boolean,
) {
this.protocol = protocol;
this.config = this.normalizeConfig(config);
Expand All@@ -577,6 +582,7 @@ export class RestServer {
this.i18nServiceProvider = i18nServiceProvider;
this.analyticsServiceProvider = analyticsServiceProvider;
this.settingsServiceProvider = settingsServiceProvider;
this.serviceExistsProvider = serviceExistsProvider;
}

/**
Expand DownExpand Up@@ -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;
Expand All@@ -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<string>): any | null {
private filterAppForUser(item: any, sysPerms: Set<string>, 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,
Expand 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;

Expand All@@ -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
Expand All@@ -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<Set<string> | 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<boolean>) | 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<string>();
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<string>();
for (const name of wanted) { if (await probe(name)) registered.add(name); }
return registered;
}

/**
* Build a `TranslationBundle` (`Record<locale, TranslationData>`) from an
* `II18nService` instance. Returns `undefined` when no locales are
Expand DownExpand Up@@ -1918,8 +1969,10 @@ export class RestServer {
const sysPerms = new Set<string>(
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
Expand DownExpand Up@@ -2239,7 +2292,9 @@ export class RestServer {
const sysPerms = new Set<string>(
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',
Expand Down
51 changes: 51 additions & 0 deletions packages/rest/src/rest.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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<string>(), (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<string>(), () => 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<string>()))).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<string>(), () => 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)
// ---------------------------------------------------------------------------
Expand Down
Loading