diff --git a/.changeset/suggested-audience-bindings-surface.md b/.changeset/suggested-audience-bindings-surface.md new file mode 100644 index 0000000000..cc1ddcd762 --- /dev/null +++ b/.changeset/suggested-audience-bindings-surface.md @@ -0,0 +1,39 @@ +--- +"@objectstack/plugin-security": minor +"@objectstack/runtime": minor +"@objectstack/rest": minor +--- + +ADR-0090 D5/D9 — suggested audience bindings become a queryable, confirmable surface. + +A package permission set declaring `isDefault: true` is an install-time +SUGGESTION to bind the set to the built-in `everyone` position — never +auto-bound. Until now the flag was only read at bootstrap as the fallback-set +name; after an install there was no way to see or act on the suggestion. + +**`@objectstack/plugin-security`**: new `sys_audience_binding_suggestion` +system object (read-only over the data API; unique per +package × set × anchor) plus a convergent reconciler +(`syncAudienceBindingSuggestions`) that reads every declared `isDefault` set — +boot-declared stack metadata AND installed package manifests, so a runtime +`POST /api/v1/packages` install is visible immediately — and keeps the table +honest: undeclared → pending row pruned, bound out-of-band → marked +`confirmed` (observed). The `security` service gains +`listAudienceBindingSuggestions` / `confirmAudienceBindingSuggestion` / +`dismissAudienceBindingSuggestion`, all pre-gated on tenant-level admin +(ADR-0066 superuser wildcard — anchors stay tenant-level only per D12). +Confirm writes the `sys_position_permission_set` row **with the caller's +execution context**, so the D5/D9 audience-anchor gate (no high-privilege +set on `everyone`/`guest`) and the D12 delegated-admin gate enforce the +binding; a set not yet materialized (installed this session) is first +seeded through the same provenance-checked upsert as the boot seeder +(ADR-0086 D4). + +**`@objectstack/rest`** and **`@objectstack/runtime`**: the HTTP surface, +registered on both API layers (the RestServer that `objectstack dev`/hono +serves, and the runtime HttpDispatcher used by the adapters) — +`GET /api/v1/security/suggested-bindings?status=&packageId=`, +`POST /api/v1/security/suggested-bindings/:id/confirm`, +`POST /api/v1/security/suggested-bindings/:id/dismiss` (401 unauthenticated, +403/404/409 mapped from the service's typed errors, 501/503 without +plugin-security). diff --git a/content/docs/permissions/permission-sets.mdx b/content/docs/permissions/permission-sets.mdx index 6fb4c3aa9e..4eb0527336 100644 --- a/content/docs/permissions/permission-sets.mdx +++ b/content/docs/permissions/permission-sets.mdx @@ -137,6 +137,25 @@ suggestion individually; nothing auto-binds, and the D7 linter rejects an `isDefault` set that carries anchor-forbidden bits (VAMA, destructive bits, wildcards, system permissions). +Pending suggestions are materialized as `sys_audience_binding_suggestion` +rows (one per package × set × anchor, read-only over the data API) and +resolved through the security surface — both installing a package at runtime +and declaring the set in the stack produce them: + +```http +GET /api/v1/security/suggested-bindings?status=pending # list (reconciles first) +POST /api/v1/security/suggested-bindings/:id/confirm # create the everyone binding +POST /api/v1/security/suggested-bindings/:id/dismiss # decline the prompt +``` + +All three require a tenant-level administrator (the anchors are tenant-level +only — D12). Confirm writes the `sys_position_permission_set` row **as the +caller**, so the audience-anchor gate re-checks the forbidden-bits predicate +at the data layer; a suggestion whose binding was created out-of-band (boot +baseline, manual bind) is marked `confirmed` automatically, and uninstalling +the package prunes its pending suggestions. Studio surfaces pending +suggestions after marketplace installs and in the Access pillar. + ## Delegated administration — `adminScope` (ADR-0090 D12) A permission set may carry an `adminScope`, making its holders **scoped diff --git a/packages/plugins/plugin-security/src/bootstrap-declared-permissions.ts b/packages/plugins/plugin-security/src/bootstrap-declared-permissions.ts index 30b85236db..8b51f757f9 100644 --- a/packages/plugins/plugin-security/src/bootstrap-declared-permissions.ts +++ b/packages/plugins/plugin-security/src/bootstrap-declared-permissions.ts @@ -61,7 +61,7 @@ interface SeedOptions { * boot path; the metadata-service facade only surfaces these once the * compiled-artifact loader runs (serve.ts). */ -function readDeclared(engine: any, type: string): any[] { +export function readDeclared(engine: any, type: string): any[] { try { const reg = engine?._registry; if (reg?.listItems) { diff --git a/packages/plugins/plugin-security/src/delegated-admin-gate.ts b/packages/plugins/plugin-security/src/delegated-admin-gate.ts index 38be63291d..2e03b6ac2f 100644 --- a/packages/plugins/plugin-security/src/delegated-admin-gate.ts +++ b/packages/plugins/plugin-security/src/delegated-admin-gate.ts @@ -77,7 +77,7 @@ function parseMaybeJson(v: unknown): any { } /** ADR-0066 tenant-level admin: a resolved set whose '*' entry carries modifyAllRecords. */ -function isTenantAdmin(sets: PermissionSet[]): boolean { +export function isTenantAdmin(sets: PermissionSet[]): boolean { for (const ps of sets) { const objects: any = parseMaybeJson((ps as any).objects) ?? {}; const wildcard = objects?.['*']; diff --git a/packages/plugins/plugin-security/src/index.ts b/packages/plugins/plugin-security/src/index.ts index 368a09c7fe..877a24fab0 100644 --- a/packages/plugins/plugin-security/src/index.ts +++ b/packages/plugins/plugin-security/src/index.ts @@ -28,7 +28,16 @@ export { bootstrapPlatformAdmin } from './bootstrap-platform-admin.js'; export { bootstrapDeclaredPermissions } from './bootstrap-declared-permissions.js'; export { claimSeedOwnership } from './claim-seed-ownership.js'; export { appDefaultPermissionSetName } from './app-default-permission-set.js'; -export { DelegatedAdminGate } from './delegated-admin-gate.js'; +export { DelegatedAdminGate, isTenantAdmin } from './delegated-admin-gate.js'; export { explainAccess, buildContextForUser } from './explain-engine.js'; export type { ExplainEngineDeps, ExplainInput } from './explain-engine.js'; export type { DelegatedAdminGateDeps } from './delegated-admin-gate.js'; +export { + syncAudienceBindingSuggestions, + listAudienceBindingSuggestions, + confirmAudienceBindingSuggestion, + dismissAudienceBindingSuggestion, + SuggestionNotFoundError, + SuggestionStateError, +} from './suggested-audience-bindings.js'; +export type { SuggestionDeps, SuggestionListFilter, SuggestionSyncOutcome } from './suggested-audience-bindings.js'; diff --git a/packages/plugins/plugin-security/src/manifest.ts b/packages/plugins/plugin-security/src/manifest.ts index a2216ab84e..140cb1091e 100644 --- a/packages/plugins/plugin-security/src/manifest.ts +++ b/packages/plugins/plugin-security/src/manifest.ts @@ -15,6 +15,7 @@ import { SysUserPermissionSet, SysPositionPermissionSet, SysUserPosition, + SysAudienceBindingSuggestion, defaultPermissionSets, } from './objects/index.js'; @@ -29,6 +30,7 @@ export const securityObjects = [ SysUserPermissionSet, SysPositionPermissionSet, SysUserPosition, + SysAudienceBindingSuggestion, ]; /** Default platform permission sets (admin / member / viewer). */ diff --git a/packages/plugins/plugin-security/src/objects/index.ts b/packages/plugins/plugin-security/src/objects/index.ts index 39ac2517cc..054c188cec 100644 --- a/packages/plugins/plugin-security/src/objects/index.ts +++ b/packages/plugins/plugin-security/src/objects/index.ts @@ -15,4 +15,5 @@ export { SysPermissionSet } from './sys-permission-set.object.js'; export { SysUserPermissionSet } from './sys-user-permission-set.object.js'; export { SysPositionPermissionSet } from './sys-position-permission-set.object.js'; export { SysUserPosition } from './sys-user-position.object.js'; +export { SysAudienceBindingSuggestion } from './sys-audience-binding-suggestion.object.js'; export { defaultPermissionSets } from './default-permission-sets.js'; diff --git a/packages/plugins/plugin-security/src/objects/sys-audience-binding-suggestion.object.ts b/packages/plugins/plugin-security/src/objects/sys-audience-binding-suggestion.object.ts new file mode 100644 index 0000000000..1aaf64db24 --- /dev/null +++ b/packages/plugins/plugin-security/src/objects/sys-audience-binding-suggestion.object.ts @@ -0,0 +1,123 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { ObjectSchema, Field } from '@objectstack/spec/data'; + +/** + * sys_audience_binding_suggestion — a package's install-time SUGGESTION to + * bind one of its permission sets to an audience anchor (ADR-0090 D5/D9). + * + * A package declaring `isDefault: true` on a permission set is asking the + * admin: "bind this set to the `everyone` position so authenticated users + * get it by default". It is NEVER auto-bound — installing a package must not + * silently widen every tenant user's access. This table is the queryable + * surface between the two moments: rows are produced (pending) when the + * declaration is observed — boot seeding, package-door publish, or the + * suggested-bindings list endpoint syncing against installed manifests — and + * resolved when a tenant admin confirms (the binding row is created under + * the D5/D9 anchor gate + D12 delegated-admin gate) or dismisses. + * + * Rows are system-managed: the API surface is read-only (get/list); state + * changes flow exclusively through the `security` service confirm/dismiss + * methods so the gates cannot be sidestepped by a generic data write. + * + * @namespace sys + */ +export const SysAudienceBindingSuggestion = ObjectSchema.create({ + name: 'sys_audience_binding_suggestion', + label: 'Audience Binding Suggestion', + pluralLabel: 'Audience Binding Suggestions', + icon: 'shield-question', + isSystem: true, + managedBy: 'system', + description: 'Package-suggested audience-anchor binding awaiting admin confirmation (ADR-0090 D5/D9).', + titleFormat: '{package_id}: {permission_set_name} → {anchor}', + highlightFields: ['package_id', 'permission_set_name', 'anchor', 'status'], + + fields: { + id: Field.text({ + label: 'Suggestion ID', + required: true, + readonly: true, + description: 'UUID of the suggestion row.', + }), + + package_id: Field.text({ + label: 'Package', + required: true, + readonly: true, + description: 'Owning package that ships the suggested permission set (ADR-0086 D3 provenance).', + }), + + permission_set_name: Field.text({ + label: 'Permission Set', + required: true, + readonly: true, + description: 'Name of the suggested permission set (resolved against sys_permission_set at confirm time).', + }), + + anchor: Field.select({ + label: 'Audience Anchor', + required: true, + readonly: true, + defaultValue: 'everyone', + description: 'Audience anchor position the package suggests binding to (ADR-0090 D9).', + options: [ + { value: 'everyone', label: 'Everyone' }, + { value: 'guest', label: 'Guest' }, + ], + }), + + status: Field.select({ + label: 'Status', + required: true, + defaultValue: 'pending', + description: 'pending = awaiting admin decision; confirmed = binding exists; dismissed = admin declined.', + options: [ + { value: 'pending', label: 'Pending' }, + { value: 'confirmed', label: 'Confirmed' }, + { value: 'dismissed', label: 'Dismissed' }, + ], + }), + + resolved_by: Field.lookup('sys_user', { + label: 'Resolved By', + required: false, + description: 'Admin who confirmed/dismissed. Empty on a confirmed row means the binding was observed (e.g. bound at boot or by hand), not confirmed through the prompt.', + }), + + resolved_at: Field.datetime({ + label: 'Resolved At', + required: false, + description: 'When the suggestion left the pending state.', + }), + + created_at: Field.datetime({ + label: 'Created At', + defaultValue: 'NOW()', + readonly: true, + }), + + updated_at: Field.datetime({ + label: 'Updated At', + defaultValue: 'NOW()', + readonly: true, + }), + }, + + indexes: [ + { fields: ['package_id', 'permission_set_name', 'anchor'], unique: true }, + { fields: ['status'] }, + { fields: ['package_id'] }, + ], + + enable: { + trackHistory: true, + searchable: false, + apiEnabled: true, + // Read-only over the generic data API — confirm/dismiss go through the + // `security` service so the anchor + delegated-admin gates always apply. + apiMethods: ['get', 'list'], + trash: false, + mru: false, + }, +}); diff --git a/packages/plugins/plugin-security/src/security-plugin.ts b/packages/plugins/plugin-security/src/security-plugin.ts index 9a72752c71..b15f8ff20b 100644 --- a/packages/plugins/plugin-security/src/security-plugin.ts +++ b/packages/plugins/plugin-security/src/security-plugin.ts @@ -9,6 +9,14 @@ import { explainAccess, buildContextForUser } from './explain-engine.js'; import type { ExplainDecision, ExplainOperation } from '@objectstack/spec/security'; import { bootstrapDeclaredPositions } from './bootstrap-declared-positions.js'; import { bootstrapDeclaredPermissions, upsertPackagePermissionSet } from './bootstrap-declared-permissions.js'; +import { + syncAudienceBindingSuggestions, + listAudienceBindingSuggestions, + confirmAudienceBindingSuggestion, + dismissAudienceBindingSuggestion, + type SuggestionDeps, + type SuggestionListFilter, +} from './suggested-audience-bindings.js'; import { bootstrapBuiltinRoles } from './bootstrap-builtin-positions.js'; import { bootstrapSystemCapabilities } from './bootstrap-system-capabilities.js'; import { RLSCompiler, RLS_DENY_FILTER } from './rls-compiler.js'; @@ -386,6 +394,15 @@ export class SecurityPlugin implements Plugin { // service only once the metadata/ql/dbLoader handles are wired (above), so // a degraded start never exposes a half-initialised resolver. try { + // [ADR-0090 D5/D9] Suggested audience bindings — shared deps for the + // list/confirm/dismiss surface (same set resolution as the middleware + // and the delegated-admin gate, so admin-ness can never drift). + const suggestionDeps: SuggestionDeps = { + ql, + metadata, + resolveSets: (context: any) => this.resolvePermissionSetsForContext(context), + logger: ctx.logger, + }; ctx.registerService('security', { getReadFilter: (object: string, context?: any) => this.getReadFilter(object, context), // [ADR-0090 D6] First-class access explanation. Same code paths as @@ -393,8 +410,17 @@ export class SecurityPlugin implements Plugin { // construction. Explaining ANOTHER user requires `manage_users`. explain: (request: { object: string; operation: string; userId?: string }, callerContext?: any) => this.explainAccessForCaller(request, callerContext), + // [ADR-0090 D5/D9] Install-time suggestion surface: packages suggest + // audience-anchor bindings; a tenant admin confirms (the binding is + // written under the anchor + delegated-admin gates) or dismisses. + listAudienceBindingSuggestions: (callerContext?: any, filter?: SuggestionListFilter) => + listAudienceBindingSuggestions(suggestionDeps, callerContext, filter), + confirmAudienceBindingSuggestion: (callerContext: any, id: string) => + confirmAudienceBindingSuggestion(suggestionDeps, callerContext, id), + dismissAudienceBindingSuggestion: (callerContext: any, id: string) => + dismissAudienceBindingSuggestion(suggestionDeps, callerContext, id), }); - ctx.logger.info('[security] registered "security" service (getReadFilter, explain) — ADR-0021 D-C / ADR-0090 D6'); + ctx.logger.info('[security] registered "security" service (getReadFilter, explain, audience-binding suggestions) — ADR-0021 D-C / ADR-0090 D5/D6/D9'); } catch (e) { ctx.logger.warn?.('[security] failed to register "security" service', { error: (e as Error).message, @@ -951,6 +977,12 @@ export class SecurityPlugin implements Plugin { async (args: { body: unknown; packageId: string | null }) => { const r = await upsertPackagePermissionSet(ql, args.body, args.packageId, ctx.logger); const applied = r.seeded + r.updated; + // [ADR-0090 D5] A published set carrying the install-time + // suggestion flag surfaces (or retires) its pending + // suggestion row right away — same convergent sync as boot. + if (applied > 0 && (args.body as { isDefault?: boolean } | null)?.isDefault !== undefined) { + try { await syncAudienceBindingSuggestions(ql, this.metadata, ctx.logger); } catch { /* non-fatal */ } + } // A publish that materialized nothing did NOT go live — report it // as a failure with the reason so the package-door UI never shows // a clean publish over a set the admin surface can't see (ADR-0049 @@ -981,6 +1013,17 @@ export class SecurityPlugin implements Plugin { } catch (e) { ctx.logger.warn('[security] built-in role seeding failed', { error: (e as Error).message }); } + // [ADR-0090 D5/D9] Reconcile the suggested-audience-binding surface: + // every declared `isDefault: true` set that is not already bound to + // its anchor becomes a PENDING suggestion row awaiting admin + // confirmation — never auto-bound. Runs after the anchors are seeded + // and after the baseline binding above, so the app's own fallback set + // (already bound) never nags. + try { + await syncAudienceBindingSuggestions(ql, this.metadata, ctx.logger); + } catch (e) { + ctx.logger.warn('[security] audience-binding suggestion sync failed (non-fatal)', { error: (e as Error).message }); + } // [ADR-0066 D1] Back-compat seed the capability registry (sys_capability) // from the curated platform set + the default grants' systemPermissions. try { diff --git a/packages/plugins/plugin-security/src/suggested-audience-bindings.test.ts b/packages/plugins/plugin-security/src/suggested-audience-bindings.test.ts new file mode 100644 index 0000000000..bf94c4a3da --- /dev/null +++ b/packages/plugins/plugin-security/src/suggested-audience-bindings.test.ts @@ -0,0 +1,285 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// ADR-0090 D5/D9 — suggested audience bindings: the queryable install-time +// suggestion surface and the tenant-admin confirm/dismiss flow. + +import { describe, it, expect } from 'vitest'; +import { + syncAudienceBindingSuggestions, + listAudienceBindingSuggestions, + confirmAudienceBindingSuggestion, + dismissAudienceBindingSuggestion, + SuggestionNotFoundError, + SuggestionStateError, + type SuggestionDeps, +} from './suggested-audience-bindings'; + +/** In-memory ObjectQL stub (same shape as audience-anchors.test.ts) with an + * installed-package registry and insert-call recording so tests can assert + * WHICH context a write carried. */ +function makeQl(packages: any[] = []) { + const tables: Record = { + sys_position: [{ id: 'pos_everyone', name: 'everyone' }, { id: 'pos_guest', name: 'guest' }], + sys_permission_set: [], + sys_position_permission_set: [], + sys_audience_binding_suggestion: [], + }; + const insertCalls: Array<{ object: string; data: any; opts: any }> = []; + return { + tables, + insertCalls, + _registry: { + getAllPackages: () => packages, + listItems: (_type: string) => [], + }, + async find(object: string, opts: any) { + const where = opts?.where ?? {}; + return (tables[object] ?? []).filter((r) => + Object.entries(where).every(([k, v]) => (r as any)[k] === v), + ); + }, + async insert(object: string, data: any, opts?: any) { + insertCalls.push({ object, data, opts }); + (tables[object] ??= []).push(data); + return data; + }, + async update(object: string, data: any) { + const t = tables[object] ?? []; + const i = t.findIndex((r) => r.id === data.id); + if (i >= 0) t[i] = { ...t[i], ...data }; + return t[i]; + }, + async delete(object: string, opts: any) { + const id = opts?.where?.id; + const t = tables[object] ?? []; + const i = t.findIndex((r) => r.id === id); + if (i >= 0) t.splice(i, 1); + return true; + }, + } as any; +} + +const CRM_PACKAGE = { + enabled: true, + manifest: { + id: 'com.example.crm', + permissions: [ + { name: 'crm_readonly', isDefault: true, objects: { crm_account: { allowRead: true } } }, + { name: 'crm_admin', objects: { crm_account: { allowRead: true, allowEdit: true, allowDelete: true } } }, + ], + }, +}; + +const ADMIN_SET = { name: 'admin_full', objects: { '*': { modifyAllRecords: true } } } as any; +const MEMBER_SET = { name: 'member', objects: { crm_account: { allowRead: true } } } as any; + +function makeDeps(ql: any, resolvedSets: any[] = [ADMIN_SET]): SuggestionDeps { + return { ql, resolveSets: async () => resolvedSets }; +} + +const ADMIN_CTX = { userId: 'usr_admin' }; + +describe('syncAudienceBindingSuggestions (ADR-0090 D5/D9)', () => { + it('creates a pending suggestion for an installed package isDefault set', async () => { + const ql = makeQl([CRM_PACKAGE]); + const out = await syncAudienceBindingSuggestions(ql); + expect(out.created).toBe(1); + const rows = ql.tables.sys_audience_binding_suggestion; + expect(rows).toHaveLength(1); + expect(rows[0]).toMatchObject({ + package_id: 'com.example.crm', + permission_set_name: 'crm_readonly', + anchor: 'everyone', + status: 'pending', + }); + }); + + it('is idempotent — a second sync creates nothing new', async () => { + const ql = makeQl([CRM_PACKAGE]); + await syncAudienceBindingSuggestions(ql); + const out2 = await syncAudienceBindingSuggestions(ql); + expect(out2.created).toBe(0); + expect(ql.tables.sys_audience_binding_suggestion).toHaveLength(1); + }); + + it('skips a set that is already bound to the anchor (e.g. the boot baseline)', async () => { + const ql = makeQl([CRM_PACKAGE]); + ql.tables.sys_permission_set.push({ id: 'ps_1', name: 'crm_readonly', package_id: 'com.example.crm' }); + ql.tables.sys_position_permission_set.push({ id: 'pps_1', position_id: 'pos_everyone', permission_set_id: 'ps_1' }); + const out = await syncAudienceBindingSuggestions(ql); + expect(out.created).toBe(0); + expect(ql.tables.sys_audience_binding_suggestion).toHaveLength(0); + }); + + it('marks a pending suggestion confirmed when the binding appears out-of-band', async () => { + const ql = makeQl([CRM_PACKAGE]); + await syncAudienceBindingSuggestions(ql); + ql.tables.sys_permission_set.push({ id: 'ps_1', name: 'crm_readonly', package_id: 'com.example.crm' }); + ql.tables.sys_position_permission_set.push({ id: 'pps_1', position_id: 'pos_everyone', permission_set_id: 'ps_1' }); + const out = await syncAudienceBindingSuggestions(ql); + expect(out.confirmedObserved).toBe(1); + expect(ql.tables.sys_audience_binding_suggestion[0].status).toBe('confirmed'); + expect(ql.tables.sys_audience_binding_suggestion[0].resolved_by).toBeUndefined(); + }); + + it('prunes a pending suggestion once its declaration is gone (uninstall)', async () => { + const ql = makeQl([CRM_PACKAGE]); + await syncAudienceBindingSuggestions(ql); + ql._registry.getAllPackages = () => []; + const out = await syncAudienceBindingSuggestions(ql); + expect(out.pruned).toBe(1); + expect(ql.tables.sys_audience_binding_suggestion).toHaveLength(0); + }); + + it('keeps a dismissed suggestion as history (never pruned, never re-created)', async () => { + const ql = makeQl([CRM_PACKAGE]); + await syncAudienceBindingSuggestions(ql); + const deps = makeDeps(ql); + const row = ql.tables.sys_audience_binding_suggestion[0]; + await dismissAudienceBindingSuggestion(deps, ADMIN_CTX, row.id); + const out = await syncAudienceBindingSuggestions(ql); + expect(out.created).toBe(0); + expect(out.pruned).toBe(0); + expect(ql.tables.sys_audience_binding_suggestion).toHaveLength(1); + expect(ql.tables.sys_audience_binding_suggestion[0].status).toBe('dismissed'); + }); + + it('ignores non-isDefault sets and unowned declarations', async () => { + const ql = makeQl([ + { enabled: true, manifest: { id: 'p1', permissions: [{ name: 'plain', objects: {} }] } }, + { enabled: true, manifest: { permissions: [{ name: 'orphan', isDefault: true, objects: {} }] } }, + ]); + const out = await syncAudienceBindingSuggestions(ql); + expect(out.created).toBe(0); + }); +}); + +describe('listAudienceBindingSuggestions', () => { + it('reconciles then returns rows, filterable by status/packageId', async () => { + const ql = makeQl([CRM_PACKAGE]); + const deps = makeDeps(ql); + const { suggestions, synced } = await listAudienceBindingSuggestions(deps, ADMIN_CTX, { status: 'pending' }); + expect(synced.created).toBe(1); + expect(suggestions).toHaveLength(1); + const none = await listAudienceBindingSuggestions(deps, ADMIN_CTX, { packageId: 'other.pkg' }); + expect(none.suggestions).toHaveLength(0); + }); + + it('denies a non-tenant-admin caller', async () => { + const ql = makeQl([CRM_PACKAGE]); + const deps = makeDeps(ql, [MEMBER_SET]); + await expect(listAudienceBindingSuggestions(deps, { userId: 'usr_member' })).rejects.toThrow(/tenant-level administrator/); + }); + + it('denies an unauthenticated caller', async () => { + const ql = makeQl([CRM_PACKAGE]); + const deps = makeDeps(ql); + await expect(listAudienceBindingSuggestions(deps, {})).rejects.toThrow(/authenticated/); + }); +}); + +describe('confirmAudienceBindingSuggestion', () => { + it('materializes the set if needed and creates the binding WITH the caller context', async () => { + const ql = makeQl([CRM_PACKAGE]); + const deps = makeDeps(ql); + const { suggestions } = await listAudienceBindingSuggestions(deps, ADMIN_CTX, { status: 'pending' }); + const { suggestion, bindingCreated } = await confirmAudienceBindingSuggestion(deps, ADMIN_CTX, suggestions[0].id); + + expect(bindingCreated).toBe(true); + expect(suggestion.status).toBe('confirmed'); + expect(suggestion.resolved_by).toBe('usr_admin'); + expect(suggestion.resolved_at).toBeTruthy(); + + // set was materialized through the provenance-checked upsert + const setRow = ql.tables.sys_permission_set.find((r: any) => r.name === 'crm_readonly'); + expect(setRow).toMatchObject({ package_id: 'com.example.crm', managed_by: 'package' }); + + // the binding row exists and the insert carried the CALLER context — the + // "admin confirms" write must run under the anchor + delegated-admin + // gates, never isSystem + const bindingInsert = ql.insertCalls.find((c: any) => c.object === 'sys_position_permission_set'); + expect(bindingInsert.data.position_id).toBe('pos_everyone'); + expect(bindingInsert.opts?.context).toBe(ADMIN_CTX); + expect(bindingInsert.opts?.context?.isSystem).toBeUndefined(); + }); + + it('is a recorded no-op when the binding already exists', async () => { + const ql = makeQl([CRM_PACKAGE]); + const deps = makeDeps(ql); + const { suggestions } = await listAudienceBindingSuggestions(deps, ADMIN_CTX, {}); + ql.tables.sys_permission_set.push({ id: 'ps_1', name: 'crm_readonly', package_id: 'com.example.crm' }); + ql.tables.sys_position_permission_set.push({ id: 'pps_1', position_id: 'pos_everyone', permission_set_id: 'ps_1' }); + const { bindingCreated, suggestion } = await confirmAudienceBindingSuggestion(deps, ADMIN_CTX, suggestions[0].id); + expect(bindingCreated).toBe(false); + expect(suggestion.status).toBe('confirmed'); + }); + + it('refuses a suggested set carrying anchor-forbidden bits (early gate)', async () => { + const pkg = { + enabled: true, + manifest: { + id: 'com.example.evil', + permissions: [{ name: 'evil_default', isDefault: true, objects: { '*': { viewAllRecords: true } } }], + }, + }; + const ql = makeQl([pkg]); + const deps = makeDeps(ql); + const { suggestions } = await listAudienceBindingSuggestions(deps, ADMIN_CTX, {}); + await expect(confirmAudienceBindingSuggestion(deps, ADMIN_CTX, suggestions[0].id)) + .rejects.toThrow(/cannot be bound to the 'everyone' audience anchor/); + expect(ql.tables.sys_position_permission_set).toHaveLength(0); + // still pending — the admin can retry after the package fixes the set + expect(ql.tables.sys_audience_binding_suggestion[0].status).toBe('pending'); + }); + + it('refuses when the set name is owned by a different package (ADR-0086 D4)', async () => { + const ql = makeQl([CRM_PACKAGE]); + const deps = makeDeps(ql); + const { suggestions } = await listAudienceBindingSuggestions(deps, ADMIN_CTX, {}); + ql.tables.sys_permission_set.push({ id: 'ps_x', name: 'crm_readonly', package_id: 'com.other' }); + await expect(confirmAudienceBindingSuggestion(deps, ADMIN_CTX, suggestions[0].id)) + .rejects.toThrow(SuggestionStateError); + }); + + it('404s on an unknown id and 409s on a non-pending row', async () => { + const ql = makeQl([CRM_PACKAGE]); + const deps = makeDeps(ql); + await expect(confirmAudienceBindingSuggestion(deps, ADMIN_CTX, 'nope')).rejects.toThrow(SuggestionNotFoundError); + const { suggestions } = await listAudienceBindingSuggestions(deps, ADMIN_CTX, {}); + await dismissAudienceBindingSuggestion(deps, ADMIN_CTX, suggestions[0].id); + await expect(confirmAudienceBindingSuggestion(deps, ADMIN_CTX, suggestions[0].id)) + .rejects.toThrow(/already dismissed/); + }); + + it('denies a non-tenant-admin caller before touching anything', async () => { + const ql = makeQl([CRM_PACKAGE]); + await listAudienceBindingSuggestions(makeDeps(ql), ADMIN_CTX, {}); + const memberDeps = makeDeps(ql, [MEMBER_SET]); + const row = ql.tables.sys_audience_binding_suggestion[0]; + await expect(confirmAudienceBindingSuggestion(memberDeps, { userId: 'usr_member' }, row.id)) + .rejects.toThrow(/tenant-level administrator/); + expect(ql.tables.sys_position_permission_set).toHaveLength(0); + expect(row.status).toBe('pending'); + }); +}); + +describe('dismissAudienceBindingSuggestion', () => { + it('marks a pending suggestion dismissed with the resolver identity', async () => { + const ql = makeQl([CRM_PACKAGE]); + const deps = makeDeps(ql); + const { suggestions } = await listAudienceBindingSuggestions(deps, ADMIN_CTX, {}); + const { suggestion } = await dismissAudienceBindingSuggestion(deps, ADMIN_CTX, suggestions[0].id); + expect(suggestion.status).toBe('dismissed'); + expect(suggestion.resolved_by).toBe('usr_admin'); + // no binding was ever written + expect(ql.tables.sys_position_permission_set).toHaveLength(0); + }); + + it('409s on an already-resolved row', async () => { + const ql = makeQl([CRM_PACKAGE]); + const deps = makeDeps(ql); + const { suggestions } = await listAudienceBindingSuggestions(deps, ADMIN_CTX, {}); + await dismissAudienceBindingSuggestion(deps, ADMIN_CTX, suggestions[0].id); + await expect(dismissAudienceBindingSuggestion(deps, ADMIN_CTX, suggestions[0].id)) + .rejects.toThrow(SuggestionStateError); + }); +}); diff --git a/packages/plugins/plugin-security/src/suggested-audience-bindings.ts b/packages/plugins/plugin-security/src/suggested-audience-bindings.ts new file mode 100644 index 0000000000..abc03ce399 Binary files /dev/null and b/packages/plugins/plugin-security/src/suggested-audience-bindings.ts differ diff --git a/packages/rest/src/rest-api-plugin.ts b/packages/rest/src/rest-api-plugin.ts index 401146dc47..78e1034e2c 100644 --- a/packages/rest/src/rest-api-plugin.ts +++ b/packages/rest/src/rest-api-plugin.ts @@ -191,6 +191,14 @@ export function createRestApiPlugin(config: RestApiPluginConfig = {}): Plugin { } catch { return undefined; } }; + // Security service resolver — used by the ADR-0090 D5/D9 + // /security/suggested-bindings routes (plugin-security). + const securityServiceProvider = async (_environmentId?: string): Promise => { + try { + return ctx.getService('security'); + } catch { return undefined; } + }; + if (!server) { ctx.logger.warn(`RestApiPlugin: HTTP Server service '${serverService}' not found. REST routes skipped.`); return; @@ -209,7 +217,7 @@ export function createRestApiPlugin(config: RestApiPluginConfig = {}): Plugin { 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, serviceExistsProvider); + const restServer = new RestServer(server, protocol, config.api as any, kernelManager, envRegistry, defaultEnvironmentIdProvider, authServiceProvider, objectQLProvider, emailServiceProvider, sharingServiceProvider, reportsServiceProvider, approvalsServiceProvider, sharingRulesServiceProvider, i18nServiceProvider, analyticsServiceProvider, settingsServiceProvider, serviceExistsProvider, securityServiceProvider); 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 eef869f5fc..0a654cf753 100644 --- a/packages/rest/src/rest-server.ts +++ b/packages/rest/src/rest-server.ts @@ -969,6 +969,9 @@ export class RestServer { private i18nServiceProvider?: (environmentId?: string) => Promise; private analyticsServiceProvider?: (environmentId?: string) => Promise; private settingsServiceProvider?: (environmentId?: string) => Promise; + /** [ADR-0090 D5/D9] `security` service resolver — used by the + * /security/suggested-bindings routes (plugin-security). */ + private securityServiceProvider?: (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. */ @@ -999,6 +1002,7 @@ export class RestServer { analyticsServiceProvider?: (environmentId?: string) => Promise, settingsServiceProvider?: (environmentId?: string) => Promise, serviceExistsProvider?: (name: string) => boolean, + securityServiceProvider?: (environmentId?: string) => Promise, ) { this.protocol = protocol; this.config = this.normalizeConfig(config); @@ -1017,6 +1021,7 @@ export class RestServer { this.analyticsServiceProvider = analyticsServiceProvider; this.settingsServiceProvider = settingsServiceProvider; this.serviceExistsProvider = serviceExistsProvider; + this.securityServiceProvider = securityServiceProvider; } /** @@ -1880,6 +1885,7 @@ export class RestServer { this.registerReportsEndpoints(bp); this.registerApprovalsEndpoints(bp); this.registerAnalyticsEndpoints(bp); + this.registerSecurityEndpoints(bp); // Data-action routes (e.g. GET /data/:object/export, POST // /data/:object/import) use static-literal action segments that // MUST be registered BEFORE the greedy GET /data/:object/:id @@ -5107,6 +5113,106 @@ export class RestServer { }); } + /** + * Register the security admin endpoints (ADR-0090 D5/D9) — suggested + * audience bindings. A package permission set declaring `isDefault: true` + * is an install-time SUGGESTION to bind it to the `everyone` position; + * these routes surface pending suggestions and let a tenant admin resolve + * them. The `security` service (plugin-security) does the real gating: + * tenant-admin pre-check on all three, and confirm writes the binding + * with the caller's execution context so the audience-anchor and + * delegated-admin gates enforce it — never auto-bound, never system. + * + * GET {basePath}/security/suggested-bindings?status=&packageId= + * POST {basePath}/security/suggested-bindings/:id/confirm + * POST {basePath}/security/suggested-bindings/:id/dismiss + * + * Routes return 501 when the `security` service is not registered + * (deployment without plugin-security). Typed service errors carry their + * HTTP status (403 permission / 404 not found / 409 state). + */ + private registerSecurityEndpoints(basePath: string): void { + const dataPath = basePath; + const isScoped = basePath.includes('/environments/:environmentId'); + + const resolveService = async (environmentId?: string) => { + if (!this.securityServiceProvider) return undefined; + try { + const svc = await this.securityServiceProvider(environmentId); + return svc && typeof svc.listAudienceBindingSuggestions === 'function' ? svc : undefined; + } catch { return undefined; } + }; + const respond501 = (res: any) => res.status(501).json({ + code: 'NOT_IMPLEMENTED', + message: 'Security service is not configured on this deployment', + }); + const handleError = (err: any, res: any, defaultCode: string) => { + const status = typeof err?.statusCode === 'number' ? err.statusCode : 500; + if (status !== 500) { + return res.status(status).json({ code: err?.code ?? defaultCode, error: String(err?.message ?? err) }); + } + logError(`[REST] suggested-bindings ${defaultCode}:`, err); + return res.status(500).json({ code: defaultCode, error: String(err?.message ?? err).slice(0, 500) }); + }; + + // LIST (reconciles against installed packages / declared sets first) + this.routeManager.register({ + method: 'GET', + path: `${dataPath}/security/suggested-bindings`, + handler: async (req: any, res: any) => { + try { + const environmentId = isScoped ? req.params?.environmentId : undefined; + const context = await this.resolveExecCtx(environmentId, req); + if (this.enforceAuth(req, res, context)) return; + const svc = await resolveService(environmentId); + if (!svc) return respond501(res); + const result = await svc.listAudienceBindingSuggestions(context ?? {}, { + status: req.query?.status ? String(req.query.status) : undefined, + packageId: req.query?.packageId ? String(req.query.packageId) : undefined, + }); + res.json({ data: result }); + } catch (err: any) { handleError(err, res, 'SUGGESTION_LIST_FAILED'); } + }, + metadata: { summary: 'List suggested audience bindings (ADR-0090 D5/D9)', tags: ['security'] }, + }); + + // CONFIRM — creates the anchor binding as the caller (gated write) + this.routeManager.register({ + method: 'POST', + path: `${dataPath}/security/suggested-bindings/:id/confirm`, + handler: async (req: any, res: any) => { + try { + const environmentId = isScoped ? req.params?.environmentId : undefined; + const context = await this.resolveExecCtx(environmentId, req); + if (this.enforceAuth(req, res, context)) return; + const svc = await resolveService(environmentId); + if (!svc) return respond501(res); + const result = await svc.confirmAudienceBindingSuggestion(context ?? {}, String(req.params.id)); + res.json({ data: result }); + } catch (err: any) { handleError(err, res, 'SUGGESTION_CONFIRM_FAILED'); } + }, + metadata: { summary: 'Confirm a suggested audience binding (creates the everyone/guest binding)', tags: ['security'] }, + }); + + // DISMISS — records the admin's decline; nothing is bound + this.routeManager.register({ + method: 'POST', + path: `${dataPath}/security/suggested-bindings/:id/dismiss`, + handler: async (req: any, res: any) => { + try { + const environmentId = isScoped ? req.params?.environmentId : undefined; + const context = await this.resolveExecCtx(environmentId, req); + if (this.enforceAuth(req, res, context)) return; + const svc = await resolveService(environmentId); + if (!svc) return respond501(res); + const result = await svc.dismissAudienceBindingSuggestion(context ?? {}, String(req.params.id)); + res.json({ data: result }); + } catch (err: any) { handleError(err, res, 'SUGGESTION_DISMISS_FAILED'); } + }, + metadata: { summary: 'Dismiss a suggested audience binding', tags: ['security'] }, + }); + } + /** * Register saved-report + scheduled-digest endpoints (M11.C16). * diff --git a/packages/runtime/src/http-dispatcher.test.ts b/packages/runtime/src/http-dispatcher.test.ts index db37b534ff..4343768ba1 100644 --- a/packages/runtime/src/http-dispatcher.test.ts +++ b/packages/runtime/src/http-dispatcher.test.ts @@ -485,6 +485,74 @@ describe('HttpDispatcher', () => { }); }); + // ADR-0090 D5/D9: the /api/v1/security/suggested-bindings surface, + // dispatched to the `security` service registered by plugin-security. + describe('handleSecurity (ADR-0090 D5/D9 suggested audience bindings)', () => { + const secKernel = (service: any) => + ({ context: { getService: (name: string) => (name === 'security' ? service : null) } } as any); + const ctx = (userId?: string) => + ({ request: {}, executionContext: userId ? { userId } : undefined } as any); + const makeService = () => ({ + listAudienceBindingSuggestions: vi.fn().mockResolvedValue({ suggestions: [{ id: 's1', status: 'pending' }], synced: { created: 1 } }), + confirmAudienceBindingSuggestion: vi.fn().mockResolvedValue({ suggestion: { id: 's1', status: 'confirmed' }, bindingCreated: true }), + dismissAudienceBindingSuggestion: vi.fn().mockResolvedValue({ suggestion: { id: 's1', status: 'dismissed' } }), + }); + + it('GET /suggested-bindings lists via the service with status/packageId filters', async () => { + const service = makeService(); + const d = new HttpDispatcher(secKernel(service)); + const result = await d.handleSecurity('/suggested-bindings', 'GET', undefined, { status: 'pending', packageId: 'com.example.crm' }, ctx('u1')); + expect(result.handled).toBe(true); + expect(result.response?.status).toBe(200); + expect(result.response?.body?.data?.suggestions).toHaveLength(1); + expect(service.listAudienceBindingSuggestions).toHaveBeenCalledWith({ userId: 'u1' }, { status: 'pending', packageId: 'com.example.crm' }); + }); + + it('POST /suggested-bindings/:id/confirm passes the caller execution context', async () => { + const service = makeService(); + const d = new HttpDispatcher(secKernel(service)); + const result = await d.handleSecurity('/suggested-bindings/s1/confirm', 'POST', undefined, {}, ctx('u1')); + expect(result.handled).toBe(true); + expect(result.response?.body?.data?.bindingCreated).toBe(true); + expect(service.confirmAudienceBindingSuggestion).toHaveBeenCalledWith({ userId: 'u1' }, 's1'); + }); + + it('POST /suggested-bindings/:id/dismiss dismisses via the service', async () => { + const service = makeService(); + const d = new HttpDispatcher(secKernel(service)); + const result = await d.handleSecurity('/suggested-bindings/s1/dismiss', 'POST', undefined, {}, ctx('u1')); + expect(result.handled).toBe(true); + expect(result.response?.body?.data?.suggestion?.status).toBe('dismissed'); + }); + + it('maps typed service errors onto their HTTP status (403/404/409)', async () => { + const service = makeService(); + service.confirmAudienceBindingSuggestion = vi.fn().mockRejectedValue( + Object.assign(new Error('[Security] Access denied: tenant admin required'), { statusCode: 403 }), + ); + const d = new HttpDispatcher(secKernel(service)); + const result = await d.handleSecurity('/suggested-bindings/s1/confirm', 'POST', undefined, {}, ctx('u1')); + expect(result.handled).toBe(true); + expect(result.response?.status).toBe(403); + }); + + it('401s an anonymous request without touching the service', async () => { + const service = makeService(); + const d = new HttpDispatcher(secKernel(service)); + const result = await d.handleSecurity('/suggested-bindings', 'GET', undefined, {}, ctx()); + expect(result.handled).toBe(true); + expect(result.response?.status).toBe(401); + expect(service.listAudienceBindingSuggestions).not.toHaveBeenCalled(); + }); + + it('503s when the security service is missing (plugin-security not loaded)', async () => { + const d = new HttpDispatcher(secKernel(null)); + const result = await d.handleSecurity('/suggested-bindings', 'GET', undefined, {}, ctx('u1')); + expect(result.handled).toBe(true); + expect(result.response?.status).toBe(503); + }); + }); + describe('handleAuth with async service', () => { it('should resolve auth service from Promise', async () => { const mockAuth = { diff --git a/packages/runtime/src/http-dispatcher.ts b/packages/runtime/src/http-dispatcher.ts index 30b96303bf..42779100ae 100644 --- a/packages/runtime/src/http-dispatcher.ts +++ b/packages/runtime/src/http-dispatcher.ts @@ -2090,6 +2090,69 @@ export class HttpDispatcher { return { handled: false }; } + /** + * Handle the security admin surface (`/security/...`) — ADR-0090 D5/D9 + * suggested audience bindings. A package's `isDefault: true` permission + * set is an install-time SUGGESTION to bind it to the `everyone` position; + * these routes let an admin see and resolve those suggestions. The + * `security` service does the real gating (tenant-admin pre-check, and the + * confirm write runs under the audience-anchor + delegated-admin gates + * with the caller's execution context — never auto-bound, never system). + * + * Routes: + * GET /security/suggested-bindings?status=&packageId= → list (reconciles first) + * POST /security/suggested-bindings/:id/confirm → create the anchor binding + * POST /security/suggested-bindings/:id/dismiss → decline the suggestion + */ + async handleSecurity(path: string, method: string, _body: any, query: any, context: HttpProtocolContext): Promise { + const service = await this.resolveService('security', context.environmentId) as any; + if (!service || typeof service.listAudienceBindingSuggestions !== 'function') { + return { handled: true, response: this.error('Security service not available', 503) }; + } + + const ec = context.executionContext; + if (!ec?.userId && !ec?.isSystem) { + return { handled: true, response: this.error('Authentication required', 401) }; + } + + const m = method.toUpperCase(); + // split+filter drops leading/trailing/duplicate slashes without a + // regex over request-controlled input (CodeQL js/polynomial-redos). + const parts = path.split('/').filter(Boolean); + if (parts[0] !== 'suggested-bindings') return { handled: false }; + + try { + // GET /security/suggested-bindings + if (parts.length === 1 && m === 'GET') { + const status = query?.status ? String(query.status) : undefined; + const packageId = query?.packageId ? String(query.packageId) : undefined; + const result = await service.listAudienceBindingSuggestions(ec, { status, packageId }); + return { handled: true, response: this.success(result) }; + } + + // POST /security/suggested-bindings/:id/confirm|dismiss + if (parts.length === 3 && m === 'POST') { + const id = decodeURIComponent(parts[1]); + if (parts[2] === 'confirm') { + const result = await service.confirmAudienceBindingSuggestion(ec, id); + return { handled: true, response: this.success(result) }; + } + if (parts[2] === 'dismiss') { + const result = await service.dismissAudienceBindingSuggestion(ec, id); + return { handled: true, response: this.success(result) }; + } + } + + return { handled: false }; + } catch (err: any) { + // The service throws typed errors carrying their HTTP status: + // PermissionDeniedError → 403, SuggestionNotFoundError → 404, + // SuggestionStateError → 409. + const status = typeof err?.statusCode === 'number' ? err.statusCode : 500; + return { handled: true, response: this.error(err?.message ?? 'Security operation failed', status) }; + } + } + /** * Handles i18n requests * path: sub-path after /i18n/ @@ -3990,6 +4053,12 @@ export class HttpDispatcher { return this.handleNotification(cleanPath.substring(14), method, body, query, context); } + // Security admin surface (ADR-0090 D5/D9) — suggested audience + // bindings, dispatched to the `security` service (plugin-security). + if (cleanPath === '/security' || cleanPath.startsWith('/security/')) { + return this.handleSecurity(cleanPath.substring(9), method, body, query, context); + } + if (cleanPath.startsWith('/packages')) { return this.handlePackages(cleanPath.substring(9), method, body, query, context); }