From 3c2fa8f466b83e2a40e9fed38e418fcf67436970 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 10:49:49 +0000 Subject: [PATCH 1/3] wip(plugin-security): walled-posture accepted defaults as host configuration --- .../plugin-security/src/security-plugin.ts | 64 ++++- .../src/suggested-audience-bindings.ts | 247 +++++++++++++++--- 2 files changed, 268 insertions(+), 43 deletions(-) diff --git a/packages/plugins/plugin-security/src/security-plugin.ts b/packages/plugins/plugin-security/src/security-plugin.ts index f5dfe9fb042..29e1bca1e26 100644 --- a/packages/plugins/plugin-security/src/security-plugin.ts +++ b/packages/plugins/plugin-security/src/security-plugin.ts @@ -65,6 +65,9 @@ import { confirmAudienceBindingSuggestion, dismissAudienceBindingSuggestion, readAcceptedEveryoneSets, + readAcceptedDefaultPermissionSetsOption, + refuseAcceptedDefaultsWithoutWall, + type DeploymentAcceptance, type SuggestionDeps, type SuggestionListFilter, } from './suggested-audience-bindings.js'; @@ -907,6 +910,34 @@ export interface SecurityPluginOptions { * @default 'member_default' */ fallbackPermissionSet?: string | null; + /** + * [ADR-0131 D5] The package defaults this deployment accepts for the + * `everyone` anchor, declared as host configuration — read ONLY under a + * walled posture (`group` / `isolated`, `postureEnforcesWall`). + * + * Under a wall, platform standing is configuration, and no reserved + * organization owns deployment-level rows (ADR-0131 §1.5). So a walled host + * names here the `isDefault: true` permission sets every signed-in member + * holds beside the baseline. Declared, this list IS the deployment-level + * acceptance: the `everyone` definition derives from the baseline plus these + * sets, the `sys_audience_binding_suggestion` record is neither read nor + * written, and its list / accept / dismiss doors answer 409 + * `SUGGESTION_STATE` naming this option. `[]` declares that nothing beyond + * the baseline is accepted. + * + * Each name passes the same checks an accepted suggestion does, on every + * derivation: the security catalog serves it under some package, it declares + * `isDefault: true`, and it carries no bit the `everyone` anchor forbids + * (ADR-0090 D7). A name failing one is not granted and is reported — at + * `error` for a forbidden bit. + * + * Omitted (the default), the acceptance is the record, owned by the Default + * Organization, as before. Declared under the `single` posture it is refused + * at `start()`: there the record is the acceptance (ADR-0131 D5's + * Default-Organization row), and a declaration nothing reads would only + * look enforced. + */ + acceptedDefaultPermissionSets?: readonly string[]; } /** @@ -1166,6 +1197,8 @@ export class SecurityPlugin implements Plugin { private fieldMasker = new FieldMasker(); private readonly bootstrapPermissionSets: PermissionSet[]; private readonly fallbackPermissionSet: string | null; + /** [ADR-0131 D5] {@link SecurityPluginOptions.acceptedDefaultPermissionSets}, frozen. */ + private readonly acceptedDefaultPermissionSets: readonly string[] | undefined; /** * [#7555, ADR-0090 D5] The HUMAN baseline, as the list of set names it is: * {@link fallbackPermissionSet} COMPOSED WITH the platform baseline @@ -1479,6 +1512,15 @@ export class SecurityPlugin implements Plugin { // [#7555] …and the baseline that actually resolves is that name COMPOSED // with the platform's own, never one displacing the other (ADR-0090 D5). this.baselinePermissionSets = composeHumanBaselinePermissionSets(this.fallbackPermissionSet); + this.acceptedDefaultPermissionSets = readAcceptedDefaultPermissionSetsOption(options.acceptedDefaultPermissionSets); + } + + /** + * [ADR-0131 D5] Where the deployment-level acceptance lives: the posture this + * plugin resolved, and the host's declaration (read only under a wall). + */ + private deploymentAcceptance(): DeploymentAcceptance { + return { posture: this.tenancyPosture, acceptedDefaultPermissionSets: this.acceptedDefaultPermissionSets }; } async init(ctx: PluginContext): Promise { @@ -1895,6 +1937,10 @@ export class SecurityPlugin implements Plugin { } catch { /* engine absent — nothing to widen */ } + // [ADR-0131 D5] The host's accepted package defaults are read only under a + // wall; declared under `single` they are refused here, the first moment the + // posture is known, rather than left looking enforced. + refuseAcceptedDefaultsWithoutWall(this.tenancyPosture, this.acceptedDefaultPermissionSets); // [ADR-0105 D11] Optional app/plugin membership resolver for the §7.3.1 // `IN (current_user.)` sets. Absent is the norm — probe once. try { @@ -2012,6 +2058,7 @@ export class SecurityPlugin implements Plugin { metadata, posture: () => this.tenancyPosture, rederiveEveryone: () => this.declareEveryone(ctx.logger), + acceptedDefaultPermissionSets: this.acceptedDefaultPermissionSets, logger: ctx.logger, }; // Typed against the published contract so the registered surface cannot @@ -4375,7 +4422,7 @@ export class SecurityPlugin implements Plugin { if (evt?.type !== 'permission' || evt.state === 'draft') return; void (async () => { try { - await reconcileAudienceBindingSuggestions(ql, ctx.logger); + await reconcileAudienceBindingSuggestions(ql, ctx.logger, this.deploymentAcceptance()); } catch (e) { ctx.logger.warn('[security] audience-binding suggestion sync failed (non-fatal)', { error: (e as Error).message }); } @@ -4471,7 +4518,7 @@ export class SecurityPlugin implements Plugin { // `manage_metadata` holder's decision — never auto-bound. ONE // deployment-level pass, owned by the Default Organization (#22801 → A). try { - await reconcileAudienceBindingSuggestions(ql, ctx.logger); + await reconcileAudienceBindingSuggestions(ql, ctx.logger, this.deploymentAcceptance()); } catch (e) { ctx.logger.warn('[security] audience-binding suggestion sync failed (non-fatal)', { error: (e as Error).message }); } @@ -4670,13 +4717,16 @@ export class SecurityPlugin implements Plugin { logger.warn('[security] the everyone anchor does not name a baseline set — high-privilege bits', { set, offending }); }); try { - const derived = await readAcceptedEveryoneSets(ql, this.metadata); + const derived = await readAcceptedEveryoneSets(ql, this.metadata, this.deploymentAcceptance()); for (const name of derived.accepted) if (!sets.includes(name)) sets.push(name); for (const w of derived.withheld) { - const line = - `[security] the everyone anchor does not carry the accepted set '${w.set}' (package '${w.packageId}'): ` + - `${w.reason}. Its acceptance stays recorded and grants nothing until the set is served again as ` + - 'accepted; a manage_metadata holder can revoke it in Setup.'; + const line = w.source === 'host-configuration' + ? `[security] the everyone anchor does not carry the set '${w.set}' the security plugin's ` + + `\`acceptedDefaultPermissionSets\` option declares: ${w.reason}. It grants nothing while that holds; ` + + 'remove the name from the option, or serve the set again as an isDefault set some package declares.' + : `[security] the everyone anchor does not carry the accepted set '${w.set}' (package '${w.packageId}'): ` + + `${w.reason}. Its acceptance stays recorded and grants nothing until the set is served again as ` + + 'accepted; a manage_metadata holder can revoke it in Setup.'; if (w.forbidden) logger.error(line, undefined, { set: w.set, package: w.packageId }); else logger.warn(line, { set: w.set, package: w.packageId }); } diff --git a/packages/plugins/plugin-security/src/suggested-audience-bindings.ts b/packages/plugins/plugin-security/src/suggested-audience-bindings.ts index 9e604828fd8..bf54b4f3b1e 100644 --- a/packages/plugins/plugin-security/src/suggested-audience-bindings.ts +++ b/packages/plugins/plugin-security/src/suggested-audience-bindings.ts @@ -43,6 +43,19 @@ * Organization> }` ({@link resolveDefaultOrganizationId}), and a row any * other organization owns — or none — is not this surface's. * + * ## Under a wall, the host may declare the acceptance instead (#23385) + * + * ADR-0131 D5 makes platform standing configuration under a walled posture, + * and §1.5 rejects a reserved organization that would own deployment-level + * rows. So a walled host may declare the accepted package defaults on the + * security plugin's `acceptedDefaultPermissionSets` option + * ({@link DeploymentAcceptance}). Declared, that list is the acceptance: the + * derivation reads it through the same catalog and high-risk checks a row + * passes, the sync reads and writes nothing, and the record's doors refuse, + * naming the option. Not declared, the record stands as described above, and + * a walled deployment with no organization to own it is told to declare the + * option — never to create the Default Organization. `single` is unchanged. + * * ## Rows written before the acceptance was deployment-level * * Until #22801 a TENANT administrator confirmed per organization, and that @@ -91,6 +104,105 @@ const NO_RECORD_OWNER_MESSAGE = "than one organization. Remedy: create the Default Organization (slug 'default') — the organizations " + "plugin's bootstrap creates it for the platform administrator."; +/** + * [ADR-0131 D5, #23385] The security plugin option a walled host declares its + * accepted package defaults on. Every walled-posture refusal names it. + */ +const ACCEPTED_DEFAULTS_OPTION = 'acceptedDefaultPermissionSets'; + +/** + * Where the deployment-level acceptance lives (ADR-0131 D5, #23385). + * + * Under a walled posture platform standing is configuration, so a host may + * declare the accepted package defaults as configuration too — the security + * plugin's `acceptedDefaultPermissionSets` option. When it does, that list IS + * the acceptance: the record is not read, the sync writes no row, and the + * accept / dismiss / list doors refuse, naming the option. No organization — + * reserved or otherwise — owns anything for it (ADR-0131 §1.5). + * + * Otherwise — the `single` posture, or a walled host that declares nothing — + * the acceptance is the record, owned by the Default Organization, exactly as + * before. Omitted, it is read as that record under `single`: every caller that + * predates the option. + */ +export interface DeploymentAcceptance { + /** The deployment's tenancy posture (ADR-0105 D1). */ + posture?: TenancyPosture; + /** The host's declaration — read only under a walled posture. */ + acceptedDefaultPermissionSets?: readonly string[]; +} + +/** + * The security plugin's construction-time read of the option: a list of + * permission-set names, possibly empty, deduplicated and frozen. Anything else + * — `null`, a bare string, an empty or non-string entry — is refused loudly at + * construction rather than read as "nothing accepted". + */ +export function readAcceptedDefaultPermissionSetsOption(value: unknown): readonly string[] | undefined { + if (value === undefined) return undefined; + if (!Array.isArray(value) || value.some((n) => typeof n !== 'string' || n.trim() === '')) { + throw new TypeError( + `[security] the SecurityPlugin option \`${ACCEPTED_DEFAULTS_OPTION}\` must be a list of permission-set ` + + `names — an empty list accepts none beyond the baseline; got ` + + `${Array.isArray(value) ? 'a list with an entry that is not a non-empty name' : value === null ? 'null' : typeof value}.`, + ); + } + return Object.freeze([...new Set(value as string[])]); +} + +/** + * Refuse the option under a posture that does not read it. Called by the + * security plugin once its posture is resolved: under `single` the record is + * the acceptance (ADR-0131 D5's Default-Organization row), so a declaration + * there would be configuration nothing enforces. + */ +export function refuseAcceptedDefaultsWithoutWall( + posture: TenancyPosture, + declared: readonly string[] | undefined, +): void { + if (declared === undefined || postureEnforcesWall(posture)) return; + throw new Error( + `[security] the SecurityPlugin option \`${ACCEPTED_DEFAULTS_OPTION}\` is declared, and this deployment runs the ` + + `'${posture}' tenancy posture, which does not read it: without an organization wall the deployment-level ` + + 'acceptance is the audience-binding suggestion record, owned by the Default Organization (ADR-0131 D5), ' + + `decided in Setup. Remedy: remove \`${ACCEPTED_DEFAULTS_OPTION}\`, or run a walled posture (group / isolated).`, + ); +} + +/** The host-declared acceptance, when it is the one in force (walled + declared). */ +function hostDeclaredAcceptance(deployment?: DeploymentAcceptance): readonly string[] | undefined { + if (!deployment?.posture || !postureEnforcesWall(deployment.posture)) return undefined; + return deployment.acceptedDefaultPermissionSets; +} + +/** + * The refusal when no organization can own the record. Under `single` it is + * D5's Default-Organization row; under a wall it names the configuration path, + * never a reserved organization (ADR-0131 §1.5). + */ +function noRecordOwnerMessage(deployment?: DeploymentAcceptance): string { + const posture = deployment?.posture; + if (!posture || !postureEnforcesWall(posture)) return NO_RECORD_OWNER_MESSAGE; + return ( + `The audience-binding acceptance is deployment-level, and this deployment runs the '${posture}' tenancy ` + + "posture with no organization that can own the record: no organization has slug 'default' and there is " + + 'more than one organization. Under a walled posture the deployment-level acceptance is host configuration ' + + '(ADR-0131 D5), never a row a reserved organization owns (ADR-0131 §1.5). Remedy: declare the accepted ' + + `package defaults on the security plugin's \`${ACCEPTED_DEFAULTS_OPTION}\` option — an empty list when none ` + + 'is accepted — and restart.' + ); +} + +/** The refusal of every record door when the host declared the acceptance. */ +function hostDeclaredMessage(deployment: DeploymentAcceptance): string { + return ( + `This deployment runs the '${deployment.posture}' tenancy posture and declares its accepted package defaults ` + + `on the security plugin's \`${ACCEPTED_DEFAULTS_OPTION}\` option, so the deployment-level acceptance is host ` + + 'configuration (ADR-0131 D5) and this door records none. Remedy: add or remove the permission set\'s name ' + + `in \`${ACCEPTED_DEFAULTS_OPTION}\` and restart.` + ); +} + /** Thrown when the referenced suggestion row does not exist → HTTP 404. */ export class SuggestionNotFoundError extends Error { readonly code = 'SUGGESTION_NOT_FOUND'; @@ -129,6 +241,11 @@ export interface SuggestionDeps { * owns this; an accept or a revoke calls it once the record is written. */ rederiveEveryone: () => Promise; + /** + * [ADR-0131 D5, #23385] The host's declared accepted package defaults — + * read only under a walled posture ({@link DeploymentAcceptance}). + */ + acceptedDefaultPermissionSets?: readonly string[]; logger?: { info?: (m: string, meta?: any) => void; warn?: (m: string, meta?: any) => void }; } @@ -178,6 +295,11 @@ export interface SuggestionListFilter { packageId?: string; } +/** The acceptance source the doors judge `deps` under. */ +function deploymentOf(deps: SuggestionDeps): DeploymentAcceptance { + return { posture: deps.posture(), acceptedDefaultPermissionSets: deps.acceptedDefaultPermissionSets }; +} + function genId(prefix: string): string { const rand = Math.random().toString(36).slice(2, 10); const ts = Date.now().toString(36); @@ -465,22 +587,32 @@ async function recordRows(ql: any, organizationId: string, where: Record { const out = { accepted: [] as string[], withheld: [] as WithheldAcceptance[] }; - const organizationId = await resolveDefaultOrganizationId(ql); - if (!organizationId) return out; - const rows = await ql.find(SUGGESTION_OBJECT, { - where: { status: 'confirmed', anchor: EVERYONE_POSITION }, - limit: 1000, - context: scopedSystemCtx(organizationId), - }); - const accepted = (Array.isArray(rows) ? rows : []).filter((r) => ownedBy(r, organizationId) && isDeploymentDecision(r)); - if (accepted.length === 0) return out; + const declared = hostDeclaredAcceptance(deployment); + let candidates: Array<{ set: string; packageId?: string }>; + if (declared) { + candidates = declared.map((set) => ({ set })); + } else { + const organizationId = await resolveDefaultOrganizationId(ql); + if (!organizationId) return out; + const rows = await ql.find(SUGGESTION_OBJECT, { + where: { status: 'confirmed', anchor: EVERYONE_POSITION }, + limit: 1000, + context: scopedSystemCtx(organizationId), + }); + candidates = (Array.isArray(rows) ? rows : []) + .filter((r) => ownedBy(r, organizationId) && isDeploymentDecision(r)) + .map((r) => ({ set: String(r.permission_set_name), packageId: String(r.package_id) })); + } + if (candidates.length === 0) return out; + const source: WithheldAcceptance['source'] = declared ? 'host-configuration' : 'record'; const reader = securityCatalogReaderOf(ql); const capabilities = await readDeclaredCapabilityContext(ql, metadata); - for (const row of accepted) { - const set = String(row.permission_set_name); - const packageId = String(row.package_id); - const withhold = (reason: string, forbidden = false) => out.withheld.push({ set, packageId, reason, forbidden }); + for (const { set, packageId: accepting } of candidates) { const entry = await reader?.resolve('permission', set); + const owner = entry ? declaredOwnerOf(entry) : undefined; + const withhold = (reason: string, forbidden = false) => + out.withheld.push({ set, packageId: accepting ?? owner ?? '', reason, forbidden, source }); if (!entry) { withhold('the security catalog serves no set of that name (package uninstalled or disabled)'); continue; } - if (declaredOwnerOf(entry) !== packageId) { withhold(`the set is declared by '${declaredOwnerOf(entry) ?? 'no package'}' now`); continue; } + // A row is the accepting package's; a declared name, like every suggestion, + // must be some package's (ADR-0086 D3 — an unowned default is not offered). + if (accepting !== undefined ? owner !== accepting : owner === undefined) { + withhold(`the set is declared by '${owner ?? 'no package'}' now`); + continue; + } if (entry.definition.isDefault !== true) { withhold('the set no longer declares isDefault: true'); continue; } const offending = describeAnchorForbiddenBits(entry.definition, EVERYONE_POSITION, capabilities); if (offending) { withhold(`it carries ${offending}, which the everyone anchor forbids (ADR-0090 D7)`, true); continue; } @@ -605,21 +752,28 @@ function reportSuggestionWriteRefusals( * pruned; a decided one stays as audit history and grants nothing without * its declaration ({@link readAcceptedEveryoneSets}). * + * Under a walled posture whose host declares the acceptance + * ({@link DeploymentAcceptance}) there is no record to reconcile: nothing is + * read or written, and the outcome is zero. + * * Throws {@link SuggestionStateError} when something is declared and the - * deployment has no Default Organization to own the rows. + * deployment has no Default Organization to own the rows — naming, under a + * wall, the configuration path instead. */ export async function syncAudienceBindingSuggestions( ql: any, logger?: SuggestionDeps['logger'], + deployment?: DeploymentAcceptance, ): Promise { const out: SuggestionSyncOutcome = { created: 0, confirmedObserved: 0, pruned: 0, reopened: 0, refused: 0 }; if (!ql || typeof ql.find !== 'function' || typeof ql.insert !== 'function') return out; + if (hostDeclaredAcceptance(deployment)) return out; const declared = await collectDeclaredSuggestions(ql, logger); const organizationId = await resolveDefaultOrganizationId(ql); if (!organizationId) { if (declared.length === 0) return out; - throw new SuggestionStateError(NO_RECORD_OWNER_MESSAGE); + throw new SuggestionStateError(noRecordOwnerMessage(deployment)); } // [#12981] ONE refusal log for the whole pass, reported once at the end. @@ -708,24 +862,36 @@ export async function syncAudienceBindingSuggestions( * administrator confirmed per organization before the acceptance was * deployment-level. Those rows grant nothing (they never did), are never * promoted and are not deleted; the count is reported once per call so an - * operator knows the decisions they hold were not carried over. + * operator knows the decisions they hold were not carried over. When the host + * declares the acceptance under a wall ({@link DeploymentAcceptance}), no row + * is read, so every row is counted, and the line says why. */ export async function reconcileAudienceBindingSuggestions( ql: any, logger?: SuggestionDeps['logger'], + deployment?: DeploymentAcceptance, ): Promise { const out: SuggestionReconcileOutcome = { created: 0, confirmedObserved: 0, pruned: 0, reopened: 0, refused: 0, unread: 0, }; if (!ql || typeof ql.find !== 'function' || typeof ql.insert !== 'function') return out; - Object.assign(out, await syncAudienceBindingSuggestions(ql, logger)); + Object.assign(out, await syncAudienceBindingSuggestions(ql, logger, deployment)); - const organizationId = await resolveDefaultOrganizationId(ql); + const hostDeclared = hostDeclaredAcceptance(deployment) !== undefined; + const organizationId = hostDeclared ? undefined : await resolveDefaultOrganizationId(ql); // A bare system context is deliberate: it is the only scope that reads every // organization's rows, and this read only counts them. const unread = (await tryFind(ql, SUGGESTION_OBJECT, {}, 1000)).filter((r) => !ownedBy(r, organizationId ?? '')); out.unread = unread.length; - if (unread.length > 0) { + if (unread.length > 0 && hostDeclared) { + logger?.warn?.( + `[security] ${unread.length} audience-binding suggestion row(s) are not read: this deployment runs the ` + + `'${deployment?.posture}' tenancy posture and declares its accepted package defaults on the security ` + + `plugin's \`${ACCEPTED_DEFAULTS_OPTION}\` option, which is the deployment-level acceptance (ADR-0131 D5). ` + + 'The rows grant nothing and are not deleted. Remedy: name in that option every set the deployment accepts.', + { unread: unread.length, organizations: [...new Set(unread.map((r) => r?.organization_id ?? null))] }, + ); + } else if (unread.length > 0) { logger?.warn?.( `[security] ${unread.length} audience-binding suggestion row(s) are owned by an organization other than ` + 'the Default Organization, or by none. They were confirmed or dismissed per organization by a tenant ' + @@ -771,10 +937,14 @@ function assertDeploymentAuthority(deps: SuggestionDeps, callerCtx: any, action: } } -/** The Default Organization, or the loud refusal when there is none. */ -async function requireRecordOwner(ql: any): Promise { +/** + * The Default Organization, or the loud refusal: the host declared the + * acceptance under a wall, or no organization can own the record. + */ +async function requireRecordOwner(ql: any, deployment: DeploymentAcceptance): Promise { + if (hostDeclaredAcceptance(deployment)) throw new SuggestionStateError(hostDeclaredMessage(deployment)); const organizationId = await resolveDefaultOrganizationId(ql); - if (!organizationId) throw new SuggestionStateError(NO_RECORD_OWNER_MESSAGE); + if (!organizationId) throw new SuggestionStateError(noRecordOwnerMessage(deployment)); return organizationId; } @@ -786,14 +956,18 @@ async function recordRow(ql: any, organizationId: string, id: string): Promise { assertDeploymentAuthority(deps, callerCtx, 'listing audience-binding suggestions'); - const synced = await syncAudienceBindingSuggestions(deps.ql, deps.logger); + const deployment = deploymentOf(deps); + if (hostDeclaredAcceptance(deployment)) throw new SuggestionStateError(hostDeclaredMessage(deployment)); + const synced = await syncAudienceBindingSuggestions(deps.ql, deps.logger, deployment); const organizationId = await resolveDefaultOrganizationId(deps.ql); if (!organizationId) return { suggestions: [], synced }; const where: Record = {}; @@ -807,7 +981,8 @@ export async function listAudienceBindingSuggestions( * `everyone` anchor's derived definition (module doc). * * Refuses, before anything is written: a caller without the deployment - * authority (403); a `guest`-anchor row (409 — the `guest` anchor's sets are + * authority (403); a walled deployment whose host declares the acceptance, or + * one with no organization to own the record (409, naming the option); a `guest`-anchor row (409 — the `guest` anchor's sets are * not derived from acceptances, so recording one would grant nothing); a set * the security catalog does not serve under the suggesting package, or that * no longer declares `isDefault` (409); a set carrying a bit the `everyone` @@ -823,7 +998,7 @@ export async function confirmAudienceBindingSuggestion( ): Promise<{ suggestion: any; bindingCreated: boolean }> { const { ql } = deps; assertDeploymentAuthority(deps, callerCtx, 'accepting an audience-binding suggestion'); - const organizationId = await requireRecordOwner(ql); + const organizationId = await requireRecordOwner(ql, deploymentOf(deps)); const row = await recordRow(ql, organizationId, id); if (row.status !== 'pending') { @@ -907,7 +1082,7 @@ export async function dismissAudienceBindingSuggestion( ): Promise<{ suggestion: any }> { const { ql } = deps; assertDeploymentAuthority(deps, callerCtx, 'dismissing an audience-binding suggestion'); - const organizationId = await requireRecordOwner(ql); + const organizationId = await requireRecordOwner(ql, deploymentOf(deps)); const row = await recordRow(ql, organizationId, id); const revoke = row.status === 'confirmed' && isDeploymentDecision(row); From 90462a7bbef58f3cd5fda80f1bf540c8cbd720d5 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 10:55:32 +0000 Subject: [PATCH 2/3] test(plugin-security): pin the walled-posture accepted-defaults declaration --- .../src/suggested-audience-bindings.test.ts | 175 ++++++++++++++++++ .../src/walled-accepted-defaults.boot.test.ts | 112 +++++++++++ 2 files changed, 287 insertions(+) create mode 100644 packages/plugins/plugin-security/src/walled-accepted-defaults.boot.test.ts diff --git a/packages/plugins/plugin-security/src/suggested-audience-bindings.test.ts b/packages/plugins/plugin-security/src/suggested-audience-bindings.test.ts index 087322a01c8..b78f885525f 100644 --- a/packages/plugins/plugin-security/src/suggested-audience-bindings.test.ts +++ b/packages/plugins/plugin-security/src/suggested-audience-bindings.test.ts @@ -13,6 +13,9 @@ import { dismissAudienceBindingSuggestion, SuggestionNotFoundError, SuggestionStateError, + readAcceptedDefaultPermissionSetsOption, + refuseAcceptedDefaultsWithoutWall, + type DeploymentAcceptance, type SuggestionDeps, } from './suggested-audience-bindings'; import { declareEveryoneBaseline } from './builtin-positions'; @@ -763,3 +766,175 @@ describe('[ADR-0086 D3] a set\'s owner is its binding — a body `packageId` on ]); }); }); + +// ── [ADR-0131 D5] Under a wall, the deployment-level acceptance may be host +// configuration — `acceptedDefaultPermissionSets` on the security plugin's +// options. No organization owns anything for it (ADR-0131 §1.5), and the +// `single` posture's record is untouched: every block above runs with no +// `DeploymentAcceptance`, which is read as that record. +describe('[ADR-0131 D5] the walled posture: deployment-level acceptance is host configuration', () => { + const OPTION = 'acceptedDefaultPermissionSets'; + /** Two organizations, neither with slug `default`: no record owner (a walled multi-organization deployment). */ + const noRecordOwner = (ql: any) => { + ql.tables.sys_organization = [{ id: 'org_a', slug: 'a' }, { id: 'org_b', slug: 'b' }]; + return ql; + }; + const walled = (declared?: readonly string[]): DeploymentAcceptance => ({ + posture: 'isolated', + ...(declared !== undefined ? { acceptedDefaultPermissionSets: declared } : {}), + }); + /** The security plugin's composition under a wall: baseline + whatever the derivation accepts. */ + const walledDeps = (ql: any, declared?: readonly string[]): SuggestionDeps => ({ + ql, + posture: () => 'isolated', + acceptedDefaultPermissionSets: declared, + rederiveEveryone: async () => { + const { accepted } = await readAcceptedEveryoneSets(ql, undefined, walled(declared)); + declareEveryoneBaseline(ql.registry, SECURITY_PACKAGE, [...new Set([...BASELINE, ...accepted])]); + }, + }); + const capture = () => { + const warns: string[] = []; + return { warns, logger: { warn: (m: string) => { warns.push(m); }, info: () => undefined } }; + }; + + it('a declared EMPTY list: the boot reconcile neither throws nor warns, everyone is the baseline, and the door refuses naming the option', async () => { + const ql = noRecordOwner(makeQl([CRM_PACKAGE])); + const { warns, logger } = capture(); + + // The two `(non-fatal)` warn sites in the security plugin log exactly when this rejects. + await expect(reconcileAudienceBindingSuggestions(ql, logger, walled([]))).resolves.toMatchObject({ created: 0, unread: 0 }); + expect(warns).toEqual([]); + expect(rows(ql)).toHaveLength(0); + + const deps = walledDeps(ql, []); + await deps.rederiveEveryone(); + expect(everyoneSets(ql)).toEqual(BASELINE); + expect(await readAcceptedEveryoneSets(ql, undefined, walled([]))).toEqual({ accepted: [], withheld: [] }); + + for (const door of [ + confirmAudienceBindingSuggestion(deps, OPERATOR_CTX, 'sug_any'), + dismissAudienceBindingSuggestion(deps, OPERATOR_CTX, 'sug_any'), + listAudienceBindingSuggestions(deps, OPERATOR_CTX, {}), + ]) { + const refusal = door.catch((e: unknown) => e); + await expect(refusal).resolves.toMatchObject({ code: 'SUGGESTION_STATE', statusCode: 409 }); + const message = String(((await refusal) as Error).message); + expect(message).toMatch(/^This deployment runs the 'isolated' tenancy posture and declares its accepted package defaults/); + expect(message).toContain(`\`${OPTION}\``); + expect(message).not.toContain('create the Default Organization'); + } + // The deployment authority still answers first: a non-operator is a 403, not a 409. + await expect(confirmAudienceBindingSuggestion(deps, ADMIN_CTX, 'sug_any')) + .rejects.toMatchObject({ code: 'PERMISSION_DENIED', statusCode: 403 }); + }); + + it('a declared set is granted through everyone — and only through the declaration (anti-vacuity: absent before)', async () => { + const ql = noRecordOwner(makeQl([CRM_PACKAGE])); + expect(everyoneSets(ql)).not.toContain('crm_readonly'); + + const derived = await readAcceptedEveryoneSets(ql, undefined, walled(['crm_readonly'])); + expect(derived).toEqual({ accepted: ['crm_readonly'], withheld: [] }); + + await walledDeps(ql, ['crm_readonly']).rederiveEveryone(); + expect(everyoneSets(ql)).toEqual([...BASELINE, 'crm_readonly']); + expect(ql.registry.getItem('position', 'everyone')._packageId).toBe(SECURITY_PACKAGE); + expect(rows(ql)).toHaveLength(0); + }); + + it('a declared name passes the everyone anchor\'s high-risk gate — a forbidden set is withheld as forbidden, never granted (ADR-0090 D7)', async () => { + const evil = { + enabled: true, + manifest: { + id: 'com.example.evil', + permissions: [{ name: 'evil_default', isDefault: true, objects: { '*': { viewAllRecords: true } } }], + }, + }; + const ql = noRecordOwner(makeQl([evil])); + const derived = await readAcceptedEveryoneSets(ql, undefined, walled(['evil_default'])); + expect(derived.accepted).toEqual([]); + expect(derived.withheld).toEqual([ + expect.objectContaining({ set: 'evil_default', forbidden: true, source: 'host-configuration', packageId: 'com.example.evil' }), + ]); + await walledDeps(ql, ['evil_default']).rederiveEveryone(); + expect(everyoneSets(ql)).toEqual(BASELINE); + }); + + it('a declared name the catalog does not serve as a package\'s isDefault set is withheld with its reason, as an accepted row is', async () => { + const unowned = { enabled: true, manifest: { permissions: [{ name: 'loose_default', isDefault: true, objects: {} }] } }; + const ql = noRecordOwner(makeQl([CRM_PACKAGE, unowned])); + const derived = await readAcceptedEveryoneSets( + ql, undefined, walled(['no_such_set', 'crm_admin', 'loose_default', 'crm_readonly']), + ); + expect(derived.accepted).toEqual(['crm_readonly']); + expect(derived.withheld).toEqual([ + { set: 'no_such_set', packageId: '', forbidden: false, source: 'host-configuration', + reason: 'the security catalog serves no set of that name (package uninstalled or disabled)' }, + { set: 'crm_admin', packageId: 'com.example.crm', forbidden: false, source: 'host-configuration', + reason: 'the set no longer declares isDefault: true' }, + { set: 'loose_default', packageId: '', forbidden: false, source: 'host-configuration', + reason: "the set is declared by 'no package' now" }, + ]); + }); + + it('declared, the record is not read even where an organization could own it — one source, never two', async () => { + const ql = makeQl([CRM_PACKAGE]); // a `default` organization exists + const deps = makeDeps(ql); + const { suggestions } = await listAudienceBindingSuggestions(deps, ADMIN_CTX, {}); + await confirmAudienceBindingSuggestion(deps, ADMIN_CTX, suggestions[0].id); + expect((await readAcceptedEveryoneSets(ql)).accepted).toEqual(['crm_readonly']); + + expect(await readAcceptedEveryoneSets(ql, undefined, walled([]))).toEqual({ accepted: [], withheld: [] }); + // The sync writes nothing; the rows it no longer reads are reported once, naming the option. + const { warns, logger } = capture(); + const before = JSON.stringify(rows(ql)); + await expect(reconcileAudienceBindingSuggestions(ql, logger, walled([]))).resolves.toMatchObject({ created: 0, unread: 1 }); + expect(JSON.stringify(rows(ql))).toBe(before); + expect(warns).toHaveLength(1); + expect(warns[0]).toContain(`\`${OPTION}\``); + }); + + it('a walled boot with NO declaration keeps today\'s behaviour, and the refusal names the option, not the Default Organization', async () => { + const ql = noRecordOwner(makeQl([CRM_PACKAGE])); + const sync = reconcileAudienceBindingSuggestions(ql, undefined, walled()).catch((e: unknown) => e); + await expect(sync).resolves.toBeInstanceOf(SuggestionStateError); + await expect(sync).resolves.toMatchObject({ code: 'SUGGESTION_STATE', statusCode: 409 }); + const message = String(((await sync) as Error).message); + expect(message).toMatch(/^The audience-binding acceptance is deployment-level, and this deployment runs the 'isolated' tenancy posture with no organization that can own the record/); + expect(message).toContain(`\`${OPTION}\``); + expect(message).toContain('ADR-0131 §1.5'); + expect(message).not.toContain('create the Default Organization'); + + const door = confirmAudienceBindingSuggestion(walledDeps(ql), OPERATOR_CTX, 'sug_any').catch((e: unknown) => e); + await expect(door).resolves.toMatchObject({ code: 'SUGGESTION_STATE', statusCode: 409 }); + expect(String(((await door) as Error).message)).toBe(message); + expect(everyoneSets(ql)).toEqual(BASELINE); + }); + + it('under `single` the refusal keeps D5\'s Default-Organization text, and the posture never reads a declaration', async () => { + for (const deployment of [undefined, { posture: 'single' as const }, { posture: 'single' as const, acceptedDefaultPermissionSets: ['crm_readonly'] }]) { + const ql = noRecordOwner(makeQl([CRM_PACKAGE])); + const sync = syncAudienceBindingSuggestions(ql, undefined, deployment).catch((e: unknown) => e); + await expect(sync).resolves.toMatchObject({ code: 'SUGGESTION_STATE', statusCode: 409 }); + const message = String(((await sync) as Error).message); + expect(message).toMatch(/^The audience-binding acceptance is recorded once per deployment and owned by the Default Organization/); + expect(message).toContain("Remedy: create the Default Organization (slug 'default')"); + expect((await readAcceptedEveryoneSets(ql, undefined, deployment)).accepted).toEqual([]); + } + }); + + it('the option is a closed list of names — anything else is refused at construction, and `single` refuses a declaration', () => { + expect(readAcceptedDefaultPermissionSetsOption(undefined)).toBeUndefined(); + expect(readAcceptedDefaultPermissionSetsOption([])).toEqual([]); + expect(readAcceptedDefaultPermissionSetsOption(['a', 'b', 'a'])).toEqual(['a', 'b']); + for (const bad of [null, 'crm_readonly', [''], [' '], [1], {}]) { + expect(() => readAcceptedDefaultPermissionSetsOption(bad)).toThrow(/`acceptedDefaultPermissionSets` must be a list of permission-set names/); + } + expect(() => refuseAcceptedDefaultsWithoutWall('single', [])).toThrow( + /`acceptedDefaultPermissionSets` is declared, and this deployment runs the 'single' tenancy posture, which does not read it/, + ); + expect(() => refuseAcceptedDefaultsWithoutWall('single', undefined)).not.toThrow(); + expect(() => refuseAcceptedDefaultsWithoutWall('isolated', [])).not.toThrow(); + expect(() => refuseAcceptedDefaultsWithoutWall('group', ['crm_readonly'])).not.toThrow(); + }); +}); diff --git a/packages/plugins/plugin-security/src/walled-accepted-defaults.boot.test.ts b/packages/plugins/plugin-security/src/walled-accepted-defaults.boot.test.ts new file mode 100644 index 00000000000..f1efe651148 --- /dev/null +++ b/packages/plugins/plugin-security/src/walled-accepted-defaults.boot.test.ts @@ -0,0 +1,112 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +/** + * [ADR-0131 D5] `SecurityPluginOptions.acceptedDefaultPermissionSets` — the + * walled posture's host-declared deployment-level acceptance — is read only + * under a wall, and refused loudly everywhere it would not be read: a + * malformed value at construction, a declaration under `single` at `start()`, + * the first moment the posture is known. The derivation and the doors that + * read it are pinned in `suggested-audience-bindings.test.ts`. + */ + +import { afterEach, describe, expect, it } from 'vitest'; +import { ObjectKernel, type Plugin, type PluginContext } from '@objectstack/core'; +import { ObjectQLPlugin } from '@objectstack/objectql'; +import { SqlDriver } from '@objectstack/driver-sql'; +import { SysMember, SysOrganization, SysUser } from '@objectstack/platform-objects/identity'; +import { SecurityPlugin } from './security-plugin.js'; + +/** Publishes an in-memory SQLite driver and the identity objects the authz store reads. */ +function basePlugins(): Plugin[] { + return [ + { + name: 'test.driver.sqlite', + type: 'standard', + version: '1.0.0', + async init(ctx: PluginContext) { + ctx.registerService( + 'driver.default', + new SqlDriver({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true }), + ); + }, + }, + new ObjectQLPlugin(), + { + name: 'test.identity-stand-in', + type: 'standard', + version: '1.0.0', + dependencies: ['com.objectstack.engine.objectql'], + async init(ctx: PluginContext) { + ctx.getService<{ register(m: unknown): unknown }>('manifest').register({ + id: 'test.identity-stand-in', + namespace: 'sys', + version: '1.0.0', + type: 'plugin', + name: 'Identity stand-in', + objects: [SysUser, SysMember, SysOrganization], + }); + }, + }, + ]; +} + +/** The `tenancy` service a walled deployment's auth plugin registers (ADR-0105 D1). */ +function tenancyPlugin(posture: 'isolated'): Plugin { + return { + name: 'test.tenancy', + type: 'standard', + version: '1.0.0', + async init(ctx: PluginContext) { + ctx.registerService('tenancy', { posture }); + }, + }; +} + +describe('[ADR-0131 D5] acceptedDefaultPermissionSets is read under a wall and refused where it is not', () => { + let kernel: ObjectKernel | undefined; + + afterEach(async () => { + try { + await kernel?.shutdown(); + } catch { + /* a refused boot leaves the kernel stopped */ + } + kernel = undefined; + }); + + async function boot(security: SecurityPlugin, extra: Plugin[] = []): Promise { + kernel = new ObjectKernel({ logger: { level: 'silent' } }); + for (const p of [...basePlugins(), ...extra]) await kernel.use(p); + await kernel.use(security); + try { + await kernel.bootstrap(); + return undefined; + } catch (err) { + return err; + } + } + + it('refuses a malformed declaration at construction', () => { + expect(() => new SecurityPlugin({ acceptedDefaultPermissionSets: 'crm_readonly' as never })).toThrow( + /`acceptedDefaultPermissionSets` must be a list of permission-set names/, + ); + expect(() => new SecurityPlugin({ acceptedDefaultPermissionSets: [] })).not.toThrow(); + }); + + it('refuses the boot when the `single` posture would ignore the declaration', async () => { + const err = await boot(new SecurityPlugin({ acceptedDefaultPermissionSets: [] })); + + expect(err).toBeInstanceOf(Error); + expect((err as Error).message).toMatch( + /failed to start - rollback complete: \[security\] the SecurityPlugin option `acceptedDefaultPermissionSets` is declared, and this deployment runs the 'single' tenancy posture/, + ); + }); + + it('CONTROL: `single` without the declaration, and a wall with it, both boot', async () => { + expect(await boot(new SecurityPlugin())).toBeUndefined(); + expect(kernel!.getState()).toBe('running'); + await kernel!.shutdown(); + + expect(await boot(new SecurityPlugin({ acceptedDefaultPermissionSets: [] }), [tenancyPlugin('isolated')])).toBeUndefined(); + expect(kernel!.getState()).toBe('running'); + }); +}); From 70b3cb0927cd817a09c947928dc33ed222e28e8e Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 10:55:56 +0000 Subject: [PATCH 3/3] chore(plugin-security): changeset + export DeploymentAcceptance --- .changeset/23385-walled-accepted-defaults.md | 26 +++++++++++++++++++ packages/plugins/plugin-security/src/index.ts | 1 + 2 files changed, 27 insertions(+) create mode 100644 .changeset/23385-walled-accepted-defaults.md diff --git a/.changeset/23385-walled-accepted-defaults.md b/.changeset/23385-walled-accepted-defaults.md new file mode 100644 index 00000000000..41831652b51 --- /dev/null +++ b/.changeset/23385-walled-accepted-defaults.md @@ -0,0 +1,26 @@ +--- +'@objectstack/plugin-security': minor +--- + +feat(plugin-security): under a walled posture, the deployment-level acceptance of package defaults can be host configuration (`acceptedDefaultPermissionSets`, ADR-0131 D5) (#23385) + +Clause-②: yes (widening) + +**What was wrong.** On a walled (`group` / `isolated`) deployment with several organizations and none of slug `default`, the audience-binding suggestion record has no organization to own it. Every boot logged `[security] audience-binding suggestion sync failed (non-fatal)`, and the accept door answered `409 SUGGESTION_STATE` with the remedy "create the Default Organization (slug 'default')". That is a reserved organization for deployment-level rows, the shape ADR-0131 §1.5 rejects. + +**What is new.** `SecurityPluginOptions.acceptedDefaultPermissionSets?: readonly string[]` is read only under a walled posture. When it is declared, possibly as `[]`: + +- the `everyone` anchor derives from the baseline plus the declared sets. Each name passes the same checks an accepted suggestion does: the security catalog serves it under some package, it declares `isDefault: true`, and it carries no bit the `everyone` anchor forbids (ADR-0090 D7). A name that fails a check grants nothing and is reported, at `error` for a forbidden bit; +- the `sys_audience_binding_suggestion` record is neither read nor written, so the boot no longer warns. Existing rows are counted once and reported, not deleted; +- the record's list, accept and dismiss doors answer `409 SUGGESTION_STATE` naming the option. + +```ts +new SecurityPlugin({ acceptedDefaultPermissionSets: [] }); // nothing beyond the baseline +new SecurityPlugin({ acceptedDefaultPermissionSets: ['crm_readonly'] }); // every member also holds crm_readonly +``` + +A walled deployment that declares nothing keeps today's behaviour. When it has no organization to own the record, the refusal now names `acceptedDefaultPermissionSets` instead of "create the Default Organization". + +**Refused loudly.** A value that is not a list of non-empty names is refused at construction. A declaration under the `single` posture is refused at `start()`: there the record owned by the Default Organization is the acceptance. The `single` posture is otherwise unchanged, including its refusal text. + +**Exported for callers of the module's functions.** `readAcceptedEveryoneSets`, `syncAudienceBindingSuggestions` and `reconcileAudienceBindingSuggestions` take an optional trailing `DeploymentAcceptance` (`{ posture, acceptedDefaultPermissionSets }`), and `SuggestionDeps` gains `acceptedDefaultPermissionSets`. Omitted, they read the record as before. `WithheldAcceptance` gains `source: 'record' | 'host-configuration'`. diff --git a/packages/plugins/plugin-security/src/index.ts b/packages/plugins/plugin-security/src/index.ts index 5db58b89387..e2571e68ac7 100644 --- a/packages/plugins/plugin-security/src/index.ts +++ b/packages/plugins/plugin-security/src/index.ts @@ -103,6 +103,7 @@ export type { SuggestionSyncOutcome, SuggestionReconcileOutcome, WithheldAcceptance, + DeploymentAcceptance, } from './suggested-audience-bindings.js'; // [maintainer ruling 2026-08-24 — 「同意 第一步(创业阶段,Salesforce 式)」] // Lock the base, clone to customize: the write-door provenance rule and its