diff --git a/.changeset/24446-u2-marketplace-access-upgrade-refusal.md b/.changeset/24446-u2-marketplace-access-upgrade-refusal.md new file mode 100644 index 00000000000..c2cac867612 --- /dev/null +++ b/.changeset/24446-u2-marketplace-access-upgrade-refusal.md @@ -0,0 +1,25 @@ +--- +'@objectstack/cloud-connection': patch +'@objectstack/plugin-security': patch +'@objectstack/spec': patch +'@objectstack/cli': patch +--- + +feat(cloud-connection, plugin-security, spec, cli): a marketplace upgrade that would un-make an access choice is refused until it is confirmed (ADR-0126 §6 wall 3) + +Clause-②: yes (narrowing): a marketplace re-install that installed before is now refused `409` while it would un-make an access choice made in this system, until the same call confirms it (narrowing); the door gains the `confirmAccessUpgrade` body field and the `ACCESS_UPGRADE_CONFIRMATION_REQUIRED` code, plugin-security gains the `security.accessUpgradeCheck` service and its exports, and the CLI gains `--confirm-access-upgrade ` (widening). +Metadata-protocol: none + + + +**BREAKING** — `POST /api/v1/marketplace/install-local` stops an upgrade it used to run. A managed re-install over an installed package now asks the security plugin what replacing that package with the incoming version would un-make, and refuses when the answer is not empty. + +- **What it compares.** The boot report's comparison (`collectAccessUpgradeFindings`, unchanged) runs twice: over the registry as it stands, and over the registry with the package's permission sets, positions, capabilities, declared sharing rules, objects and fields taken from the incoming manifest. The door refuses on what the second run finds that the first does not, so a finding that predates the upgrade, or that another package caused, never blocks it; the boot report keeps raising those. +- **What it refuses on.** A switch-off whose name the upgrade drops while it ships a new name of the same type (a suspected re-enablement, naming the positions that bind a renamed permission set), a switch whose name it drops, a declared sharing rule whose name a rule authored in this system holds, an object, field or capability a permission set or sharing rule authored in this system names and the upgrade drops, and a field deny the upgraded set gains that a permission set authored in this system granting the object lacks. A clone records no base (ADR-0126 §9), so "the old name carrying a clone" is carried by the switch-off half: the sanctioned ceremony is clone plus switch-off. +- **The refusal.** `409 ACCESS_UPGRADE_CONFIRMATION_REQUIRED`, before any write. `error.details.findings` names each finding with a one-line `describe`; `error.details.findingSet` is the finding set's token; `error.details.confirmWith` is the retry. +- **The confirmation.** FROM a re-install that ran on any answer TO the same request carrying `"confirmAccessUpgrade": ""` (CLI: `os package install --confirm-access-upgrade `). The token confirms exactly the findings it names: when they change, it no longer matches and the call is refused again with the fresh set. A bare `true` confirms nothing. A confirmed install records `accessUpgradeConfirmation` (the token, who, when) in its ledger entry; the next boot's report still raises whatever stands. +- **Who sees it.** Under `group` and `isolated` the platform operator alone reaches the check: the walled-catalog refusal answers a tenant administrator `403` first, and shows no finding. Under `single`, the administrator (`manage_metadata`). +- **Fail closed.** A check that could not read the activation ledger, the security catalog or the declared sharing rules answers `503 SERVICE_UNAVAILABLE` and writes nothing: a comparison that did not run is never "no finding". A composition without the security plugin does not check the re-install against the access choices it could un-make; the upgrade proceeds and one `warn` line says no check ran. +- **Unchanged.** A fresh install is never checked. An upgrade with no finding installs exactly as before. + +The error-code ledger registers `ACCESS_UPGRADE_CONFIRMATION_REQUIRED` under `@objectstack/cloud-connection`. `@objectstack/plugin-security` exports `ACCESS_UPGRADE_CHECK_SERVICE`, `createAccessUpgradeCheck` and `accessUpgradeDelta`, and `SecurityPlugin` registers the check under `security.accessUpgradeCheck`. diff --git a/content/docs/deployment/cli.mdx b/content/docs/deployment/cli.mdx index 026f714883a..1550991797e 100644 --- a/content/docs/deployment/cli.mdx +++ b/content/docs/deployment/cli.mdx @@ -2982,6 +2982,7 @@ credentials do not apply here. | `--email ` | `OS_RUNTIME_EMAIL` | Account email on the target runtime | | `--password ` | `OS_RUNTIME_PASSWORD` | Account password on the target runtime | | `--confirm-global-uniques` | — | Affirm this app's installation-wide `unique` constraints are genuinely platform-wide — see below | +| `--confirm-access-upgrade ` | — | Confirm an upgrade's access findings with the finding-set token the runtime's refusal printed — see below | | `--timeout ` | `OS_CLOUD_TIMEOUT_MS` | HTTP timeout in milliseconds, default `120000`. `0` disables it | **`--confirm-global-uniques` answers a stop; it does not force one past.** @@ -2997,6 +2998,22 @@ recurring nag. It is deliberately not called `--force`, and deliberately not default-on. The other answer is to edit the app's metadata to `unique: 'organization'` and rebuild. +**`--confirm-access-upgrade ` confirms exactly the findings it names.** +Upgrading an installed package stops with `ACCESS_UPGRADE_CONFIRMATION_REQUIRED` +when the new version would un-make an access choice made in the runtime +(ADR-0126 §6): a permission set, position or sharing rule switched off there +that the upgrade renames, so the new name is active again; a switch whose name +it drops; a sharing rule name a rule authored there already holds; an object, +field or capability a permission set or sharing rule authored there names and +the upgrade drops; a field deny the upgraded set gains that a set authored there +lacks. Nothing is installed. The command prints each finding and the exact +retry, carrying the finding set's token. Review the findings first: the switch +you meant to keep is fixed by switching the new name off too, after the upgrade. +The token is not a standing yes — once the findings change it no longer matches +and the upgrade stops again with the new set; the confirmation is recorded in +the install's ledger entry. Under `group` and `isolated` only the platform +operator reaches this check. A fresh install is never checked. + A `404` means the target runtime does not mount `MarketplaceInstallLocalPlugin` (from `@objectstack/cloud-connection`). The endpoint is opt-in, so a runtime composed without it will not accept installs. diff --git a/content/docs/references/api/automation-api.mdx b/content/docs/references/api/automation-api.mdx index 7eac0753bc7..162e60e1a66 100644 --- a/content/docs/references/api/automation-api.mdx +++ b/content/docs/references/api/automation-api.mdx @@ -352,7 +352,7 @@ const result = AutomationApiErrorCode.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **code** | `Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| … +341 more>` | ✅ | The inner refusal's code in its WIRE spelling - the ADR-0112 member a record save of the same write answers in `error.code` (e.g. `UNIQUE_VIOLATION`); the closed set `ApiErrorSchema.code` parses against | +| **code** | `Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| … +342 more>` | ✅ | The inner refusal's code in its WIRE spelling - the ADR-0112 member a record save of the same write answers in `error.code` (e.g. `UNIQUE_VIOLATION`); the closed set `ApiErrorSchema.code` parses against | | **message** | `string` | ✅ | The inner refusal's own diagnostic text, verbatim, without the node and operation prefixes the run's failure text adds | ### Allowed Values: `FlowInnerRefusal.code` @@ -406,6 +406,7 @@ const result = AutomationApiErrorCode.parse(data); * `EXTERNAL_SERVICE_ERROR` * `INTEGRATION_ERROR` * `WEBHOOK_DELIVERY_FAILED` +* `ACCESS_UPGRADE_CONFIRMATION_REQUIRED` * `ACCOUNT_LOCKED` * `ACTION_CONFIRMATION_REQUIRED` * `ACTION_DISABLED` diff --git a/content/docs/references/api/contract.mdx b/content/docs/references/api/contract.mdx index 2c9a32d4f22..2b28286be19 100644 --- a/content/docs/references/api/contract.mdx +++ b/content/docs/references/api/contract.mdx @@ -28,7 +28,7 @@ const result = ApiErrorSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **code** | `Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| … +341 more>` | ✅ | Error code (e.g. VALIDATION_ERROR; StandardErrorCode ∪ the ledger the serving side registers — ERROR_CODE_LEDGER for framework packages) | +| **code** | `Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| … +342 more>` | ✅ | Error code (e.g. VALIDATION_ERROR; StandardErrorCode ∪ the ledger the serving side registers — ERROR_CODE_LEDGER for framework packages) | | **declaredCode** | `string` | optional | The producer-declared code, verbatim, when it is not a member of the closed `code` vocabulary — the open, author-authored channel (app-specific spellings; ADR-0112) | | **message** | `string` | ✅ | Readable error message | | **userMessage** | `string` | optional | Producer-marked user-facing refusal text, verbatim. Present exactly when the producer opted in at throw time; consumers render it to end users and keep their generic substitution for anything unmarked. Status-agnostic; never replaces `message`. | @@ -89,6 +89,7 @@ const result = ApiErrorSchema.parse(data); * `EXTERNAL_SERVICE_ERROR` * `INTEGRATION_ERROR` * `WEBHOOK_DELIVERY_FAILED` +* `ACCESS_UPGRADE_CONFIRMATION_REQUIRED` * `ACCOUNT_LOCKED` * `ACTION_CONFIRMATION_REQUIRED` * `ACTION_DISABLED` diff --git a/content/docs/references/api/error-code-ledger.mdx b/content/docs/references/api/error-code-ledger.mdx index a81814775c7..c8fc503286e 100644 --- a/content/docs/references/api/error-code-ledger.mdx +++ b/content/docs/references/api/error-code-ledger.mdx @@ -256,6 +256,7 @@ const result = ErrorCode.parse(data); * `EXTERNAL_SERVICE_ERROR` * `INTEGRATION_ERROR` * `WEBHOOK_DELIVERY_FAILED` +* `ACCESS_UPGRADE_CONFIRMATION_REQUIRED` * `ACCOUNT_LOCKED` * `ACTION_CONFIRMATION_REQUIRED` * `ACTION_DISABLED` diff --git a/packages/cli/src/commands/package/install.ts b/packages/cli/src/commands/package/install.ts index 0eef39c0bdb..510b14f9374 100644 --- a/packages/cli/src/commands/package/install.ts +++ b/packages/cli/src/commands/package/install.ts @@ -117,6 +117,16 @@ export default class PackageInstall extends Command { + "installing into an 'isolated'-posture runtime (ADR-0120 D5e). Recorded in the install manifest; asked once.", default: false, }), + // [ADR-0126 §6 wall 3] The confirmation of an upgrade's access findings — + // a switch-off a vendor rename re-enables, a choice whose name the + // package no longer ships. A TOKEN, not a boolean: the finding set's + // digest the runtime's refusal printed, so it confirms exactly the + // findings shown, and a set that changed since is refused again. + 'confirm-access-upgrade': Flags.string({ + description: + "Confirm the access findings of this upgrade with the finding-set token the runtime's refusal printed " + + '(ACCESS_UPGRADE_CONFIRMATION_REQUIRED). It confirms exactly those findings; a changed set is refused again.', + }), }; async run(): Promise { @@ -198,6 +208,7 @@ export default class PackageInstall extends Command { // ---- Install --------------------------------------------------------- printStep(`Installing ${label} into ${runtime}...`); if (flags['confirm-global-uniques']) body.confirmGlobalUniques = true; + if (flags['confirm-access-upgrade'] !== undefined) body.confirmAccessUpgrade = flags['confirm-access-upgrade']; const installMode = flags['install-mode'] as InstallMode | undefined; if (installMode !== undefined) body.installMode = installMode; const res = await this.request(`${runtime}/api/v1/marketplace/install-local`, { @@ -227,6 +238,20 @@ export default class PackageInstall extends Command { this.exit(1); return; } + // [ADR-0126 §6 wall 3] The access upgrade stop: every finding, then + // the exact retry — the decision is the installer's, per finding. + if (res.body?.error?.code === 'ACCESS_UPGRADE_CONFIRMATION_REQUIRED') { + const details = res.body.error.details ?? {}; + const findings: Array<{ describe?: string }> = Array.isArray(details.findings) ? details.findings : []; + printError("Upgrade stopped — it would change what this system's access choices cover"); + console.log(''); + for (const finding of findings) console.log(` • ${finding.describe ?? JSON.stringify(finding)}`); + console.log(''); + console.log(' Review each finding. To upgrade anyway, confirm exactly these findings:'); + console.log(` os package install ${args.package} --confirm-access-upgrade ${details.findingSet}`); + this.exit(1); + return; + } if (res.status === 401) { printError( 'The runtime rejected the call as unauthenticated. Pass --email/--password ' + diff --git a/packages/cli/test/package-install-access-upgrade.test.ts b/packages/cli/test/package-install-access-upgrade.test.ts new file mode 100644 index 00000000000..42d3ad897a8 --- /dev/null +++ b/packages/cli/test/package-install-access-upgrade.test.ts @@ -0,0 +1,101 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [ADR-0126 §6 wall 3] `os package install --confirm-access-upgrade ` — + * the install-local body's `confirmAccessUpgrade`, and how the command renders + * the runtime's `ACCESS_UPGRADE_CONFIRMATION_REQUIRED` refusal. + * + * Pinned: + * - the flag reaches the body as `confirmAccessUpgrade`, the token verbatim; + * without the flag the body carries no such key; + * - the refusal prints every finding's `describe` line and the exact retry, + * naming the finding set's token, and exits 1. + */ + +import { describe, it, expect, afterEach, vi } from 'vitest'; +import PackageInstall from '../src/commands/package/install.js'; + +type Answer = { ok: boolean; status: number; body: unknown }; + +function stubRuntime(answer: Answer): { sent: Array> } { + const sent: Array> = []; + vi.stubGlobal('fetch', vi.fn(async (_url: string, init?: { body?: string }) => { + if (init?.body) sent.push(JSON.parse(init.body)); + return { + ok: answer.ok, + status: answer.status, + statusText: answer.ok ? 'OK' : 'Refused', + headers: { get: () => null }, + json: async () => answer.body, + } as any; + })); + return { sent }; +} + +async function runInstall(args: string[]): Promise<{ out: string; exit: number | undefined }> { + const lines: string[] = []; + const capture = (...a: unknown[]) => { lines.push(a.map(String).join(' ')); }; + vi.spyOn(console, 'log').mockImplementation(capture); + vi.spyOn(console, 'error').mockImplementation(capture); + vi.spyOn(process.stderr, 'write').mockImplementation(((chunk: unknown) => { lines.push(String(chunk)); return true; }) as any); + let exit: number | undefined; + try { + await PackageInstall.run(['com.acme.crm', '--runtime', 'http://runtime.test', ...args]); + } catch (err: any) { + exit = err?.oclif?.exit ?? err?.code ?? 1; + } + return { out: lines.join('\n'), exit }; +} + +const TOKEN = 'security.access_upgrade:0123456789abcdef0123456789abcdef'; +const INSTALLED = { + manifestId: 'com.acme.crm', version: '2.0.0', versionId: 'ver_2', installedAt: '2026-10-23T00:00:00.000Z', + hotLoaded: true, storageDir: '/srv/runtime/.objectstack/installed-packages', +}; + +describe('os package install --confirm-access-upgrade (ADR-0126 §6 wall 3)', () => { + afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + it('sends the token verbatim as `confirmAccessUpgrade`', async () => { + const { sent } = stubRuntime({ ok: true, status: 200, body: { success: true, data: INSTALLED } }); + await runInstall(['--confirm-access-upgrade', TOKEN]); + expect(sent).toHaveLength(1); + expect(sent[0]).toMatchObject({ packageId: 'com.acme.crm', confirmAccessUpgrade: TOKEN }); + }); + + it('sends no `confirmAccessUpgrade` without the flag', async () => { + const { sent } = stubRuntime({ ok: true, status: 200, body: { success: true, data: INSTALLED } }); + await runInstall([]); + expect(sent[0]).not.toHaveProperty('confirmAccessUpgrade'); + }); + + it('renders the refusal: every finding, then the exact retry with the token, and exits 1', async () => { + stubRuntime({ + ok: false, + status: 409, + body: { + success: false, + error: { + code: 'ACCESS_UPGRADE_CONFIRMATION_REQUIRED', + message: 'Upgrading "com.acme.crm" would change what access choices made in this system cover', + details: { + manifestId: 'com.acme.crm', + findingSet: TOKEN, + findings: [ + { kind: 're-enablement', describe: "permission 'crm_rep' is switched off in this system and the upgrade no longer ships it" }, + { kind: 'broken-reference', describe: "permission set 'cust_exporter', authored in this system, names capability 'crm.export'" }, + ], + }, + }, + }, + }); + const { out, exit } = await runInstall([]); + expect(exit).toBe(1); + expect(out).toContain("permission 'crm_rep' is switched off in this system"); + expect(out).toContain("names capability 'crm.export'"); + expect(out).toContain(`os package install com.acme.crm --confirm-access-upgrade ${TOKEN}`); + }); +}); diff --git a/packages/cloud-connection/package.json b/packages/cloud-connection/package.json index a1a21f8ad83..33cdaf94652 100644 --- a/packages/cloud-connection/package.json +++ b/packages/cloud-connection/package.json @@ -34,6 +34,7 @@ }, "devDependencies": { "@objectstack/lint": "workspace:*", + "@objectstack/plugin-security": "workspace:*", "@types/node": "^26.6.3", "typescript": "^6.0.3", "vitest": "^4.1.11" diff --git a/packages/cloud-connection/src/local-manifest-source.ts b/packages/cloud-connection/src/local-manifest-source.ts index 97f0846f498..eb118f29ac0 100644 --- a/packages/cloud-connection/src/local-manifest-source.ts +++ b/packages/cloud-connection/src/local-manifest-source.ts @@ -78,6 +78,15 @@ export interface InstalledManifestEntry { * boot-time nagging #4884 forbids. */ globalUniqueAttestation?: GlobalUniqueAttestation; + /** + * [ADR-0126 §6 wall 3] The installer's confirmation of the access-class + * findings this upgrade created — a switch-off a vendor rename re-enables, + * a choice whose name the package no longer ships. Recorded for the audit: + * the finding set confirmed (its digest), by whom, when. It answers only + * the install that wrote it; the next upgrade is checked afresh, and the + * boot report still raises whatever stands. + */ + accessUpgradeConfirmation?: { findingSet: string; confirmedBy: string; confirmedAt: string }; } /** diff --git a/packages/cloud-connection/src/marketplace-install-local-access-upgrade.test.ts b/packages/cloud-connection/src/marketplace-install-local-access-upgrade.test.ts new file mode 100644 index 00000000000..783c469e094 --- /dev/null +++ b/packages/cloud-connection/src/marketplace-install-local-access-upgrade.test.ts @@ -0,0 +1,419 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [ADR-0126 §6 wall 3] The marketplace door's access upgrade refusal: a + * re-install over an installed package that would un-make an access choice + * made in this system is refused `409 ACCESS_UPGRADE_CONFIRMATION_REQUIRED`, + * before any write, until the same call carries the finding set's token. + * + * The door runs here against the security plugin's REAL check + * (`createAccessUpgradeCheck`, aliased to this checkout's source), registered + * under the service name the door asks for. The check reads an in-memory + * access state: a registry holding version 1 of the vendor package as the boot + * registered it, the activation ledger, the customer's own permission set and + * the declared sharing rules. So each pin below is the comparison's real + * answer, delivered through the door's real refusal. + * + * Pinned, for each of the four access types (permission set, position, + * sharing rule, capability): + * + * - `single`: the administrator (a `manage_metadata` holder) is refused with + * the finding, and nothing is registered or written; + * - walled (`isolated`, `group`): the platform operator is refused with the + * finding; a tenant administrator is refused by the D12 wall first (403) and + * is shown no finding — the check never runs for them; + * - CONTROL: the same upgrade that keeps the name installs unchanged (200). + * + * And the confirmation: the token installs and is recorded; a stale token and + * a bare `true` are refused again; a fresh install is never checked; a + * finding that predates the upgrade does not block it; a check that could not + * read answers 503 and writes nothing; a composition without the check + * installs and says so. + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +// [#21321] An install binds the package's handlers through +// `@objectstack/runtime` (a lazy `import()` inside the plugin). Its first load +// is paid here, at module top — never inside a clocked `it`. +import '@objectstack/runtime'; +import { ACCESS_UPGRADE_CHECK_SERVICE, createAccessUpgradeCheck } from '@objectstack/plugin-security'; +import { MarketplaceInstallLocalPlugin } from './marketplace-install-local-plugin.js'; +import { declareInstallerPlatformOwner, installerAuthService, withInstallerGrants } from './install-local-principal.fixtures.js'; +import { LocalManifestSource } from './local-manifest-source.js'; + +type Handler = (c: any) => Promise; +type Row = Record; + +const VENDOR = 'com.acme.access'; +const INSTALLER = 'usr_installer'; +const POSTURE_ENV = 'OS_TENANCY_POSTURE'; + +// ── The vendor package, version 1 and the upgrades ────────────────────────── + +interface Names { set: string; position: string; rule: string; capability: string | null } +const V1: Names = { set: 'acme_sales_rep', position: 'acme_lead', rule: 'acme_share_open', capability: 'acme.export' }; + +function vendorBody(version: string, names: Names): Row { + return { + id: VENDOR, + namespace: 'acme', + version, + objects: [{ name: 'acme_deal', label: 'Deal', fields: { amount: { type: 'number' }, stage: { type: 'text' } } }], + capabilities: names.capability ? [{ name: names.capability, label: 'Export deals' }] : [], + permissions: [{ + name: names.set, + label: 'Sales rep', + objects: { acme_deal: { allowRead: true } }, + ...(names.capability ? { systemPermissions: [names.capability] } : {}), + }], + positions: [{ name: names.position, label: 'Lead', permissionSets: [names.set] }], + sharingRules: [{ name: names.rule, object: 'acme_deal', type: 'criteria', criteria: { stage: 'open' }, accessLevel: 'read' }], + }; +} + +// ── The access state the check reads ──────────────────────────────────────── + +interface AccessState { + ledger: Row[]; + customerSets: Row[]; + failLedger?: boolean; +} + +/** The registry as the boot left it with version 1 installed, and the engine the check reads through. */ +function accessEngine(state: AccessState, installed: Row) { + const items = new Map>(); + const put = (type: string, def: Row) => { + if (!items.has(type)) items.set(type, new Map()); + items.get(type)!.set(def.name as string, { ...def, _packageId: VENDOR }); + }; + for (const def of installed.permissions as Row[]) put('permission', def); + for (const def of installed.positions as Row[]) put('position', def); + for (const def of installed.capabilities as Row[]) put('capability', def); + // The registry injects system fields into every object it registers. + const objects = new Map(); + for (const object of installed.objects as Row[]) { + objects.set(object.name as string, { + ...object, + fields: { id: {}, owner: {}, organization_id: {}, created_at: {}, ...(object.fields as Row) }, + }); + } + objects.set('sys_metadata_activation', { name: 'sys_metadata_activation', fields: {} }); + objects.set('sys_sharing_rule', { name: 'sys_sharing_rule', fields: {} }); + const registry = { + getItem: (type: string, name: string) => items.get(type)?.get(name), + listItems: (type: string) => [...(items.get(type)?.values() ?? [])], + isPackageDisabled: () => false, + getPackage: (id: string) => (id === VENDOR ? { manifest: installed } : undefined), + }; + const tables: Record = { sys_metadata_activation: state.ledger, sys_sharing_rule: [] }; + return { + registry, + getObject: (name: string) => objects.get(name), + find: async (object: string, options: { limit?: number; offset?: number }) => { + if (object === 'sys_metadata_activation' && state.failLedger) throw new Error('ledger store offline'); + const rows = tables[object] ?? []; + const offset = options?.offset ?? 0; + return rows.slice(offset, offset + (options?.limit ?? rows.length)); + }, + }; +} + +function accessCheck(state: AccessState, installed: Row) { + const engine = accessEngine(state, installed); + const declared = (installed.sharingRules as Row[]).map((r) => ({ name: r.name as string, object: r.object as string, packageId: VENDOR })); + const check = createAccessUpgradeCheck({ + engine: () => engine, + metadata: () => ({ + get: async (type: string, name: string) => (type === 'permission' ? state.customerSets.find((s) => s.name === name) : undefined), + list: async (type: string) => (type === 'permission' ? state.customerSets : []), + }), + sharingRules: () => ({ listDeclaredRules: async () => declared }), + }); + return { check: vi.fn(check.check) }; +} + +// ── The door ──────────────────────────────────────────────────────────────── + +function makeRawApp() { + const routes = new Map(); + return { + routes, + get: (p: string, h: Handler) => routes.set(`GET ${p}`, h), + post: (p: string, h: Handler) => routes.set(`POST ${p}`, h), + delete: (p: string, h: Handler) => routes.set(`DELETE ${p}`, h), + }; +} + +function makeC(body: any) { + const json = vi.fn((payload: any, status?: number) => ({ payload, status: status ?? 200 })); + return { + req: { + url: 'http://localhost:3000/api/v1/marketplace/install-local', + raw: new Request('http://localhost:3000/x'), + json: async () => body, + param: () => undefined, + }, + json, + }; +} + +let dir: string; +let savedPosture: string | undefined; +let restoreOwner: (() => void) | undefined; + +beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'mil-access-')); + savedPosture = process.env[POSTURE_ENV]; + delete process.env[POSTURE_ENV]; +}); +afterEach(() => { + restoreOwner?.(); + restoreOwner = undefined; + rmSync(dir, { recursive: true, force: true }); + if (savedPosture === undefined) delete process.env[POSTURE_ENV]; + else process.env[POSTURE_ENV] = savedPosture; + vi.restoreAllMocks(); +}); + +type Caller = 'administrator' | 'operator' | 'tenant-administrator'; + +/** + * Mount the door with version 1 already installed through it (so the next + * install is a re-install), then switch to `posture` and `caller`. + */ +async function mountWithV1( + state: AccessState, + options: { posture?: 'single' | 'isolated' | 'group'; caller?: Caller; withCheck?: boolean } = {}, +) { + const installed = vendorBody('1.0.0', V1); + const { check } = accessCheck(state, installed); + const register = vi.fn(); + const rawApp = makeRawApp(); + const logger = { info: vi.fn(), warn: vi.fn(), error: vi.fn() }; + const services: Record = { + manifest: { register }, + auth: installerAuthService(INSTALLER), + objectql: withInstallerGrants({ syncSchemas: vi.fn(async () => undefined) }, INSTALLER), + ...(options.withCheck === false ? {} : { [ACCESS_UPGRADE_CHECK_SERVICE]: { check } }), + }; + const hooks = new Map(); + const ctx = { + hook: (e: string, h: any) => hooks.set(e, h), + getService: (name: string) => { + if (name === 'http-server') return { getRawApp: () => rawApp }; + if (!(name in services)) throw new Error(`no ${name}`); + return services[name]; + }, + logger, + }; + const plugin = new MarketplaceInstallLocalPlugin({ controlPlaneUrl: 'off', storageDir: dir }); + await plugin.start(ctx as any); + await hooks.get('kernel:ready')?.(); + const install = rawApp.routes.get('POST /api/v1/marketplace/install-local')!; + + const first = await install(makeC({ manifest: installed })); + expect(first.status).toBe(200); + expect(check).not.toHaveBeenCalled(); // a fresh install has no choice of the customer's to un-make + register.mockClear(); + logger.warn.mockClear(); + + const posture = options.posture ?? 'single'; + if (posture !== 'single') process.env[POSTURE_ENV] = posture; + if (options.caller !== 'tenant-administrator') restoreOwner = declareInstallerPlatformOwner(INSTALLER); + return { install, register, check, logger }; +} + +const ledgerVersion = (): string | undefined => new LocalManifestSource(dir).read(VENDOR).entry?.version; + +// ── The four types ────────────────────────────────────────────────────────── + +interface TypeCase { + type: string; + state: () => AccessState; + /** The upgrade that would un-make the choice. */ + upgrade: Names; + /** What the refusal names. */ + expected: Row; +} + +const TYPES: TypeCase[] = [ + { + type: 'permission set', + state: () => ({ ledger: [{ id: 'a1', metadata_type: 'permission', name: V1.set, package_id: VENDOR, active: false }], customerSets: [] }), + upgrade: { ...V1, set: 'acme_sales_representative' }, + expected: { + kind: 're-enablement', type: 'permission', name: V1.set, packageId: VENDOR, + candidates: [{ name: 'acme_sales_representative', positions: [V1.position] }], + }, + }, + { + type: 'position', + state: () => ({ ledger: [{ id: 'a1', metadata_type: 'position', name: V1.position, package_id: VENDOR, active: false }], customerSets: [] }), + upgrade: { ...V1, position: 'acme_team_lead' }, + expected: { + kind: 're-enablement', type: 'position', name: V1.position, packageId: VENDOR, + candidates: [{ name: 'acme_team_lead', positions: [] }], + }, + }, + { + type: 'sharing rule', + state: () => ({ ledger: [{ id: 'a1', metadata_type: 'sharing_rule', name: V1.rule, package_id: VENDOR, active: false }], customerSets: [] }), + upgrade: { ...V1, rule: 'acme_share_open_deals' }, + expected: { + kind: 're-enablement', type: 'sharing_rule', name: V1.rule, packageId: VENDOR, + candidates: [{ name: 'acme_share_open_deals', positions: [] }], + }, + }, + { + type: 'capability', + state: () => ({ + ledger: [], + customerSets: [{ name: 'cust_exporter', label: 'Exporter', _provenance: 'org', objects: {}, systemPermissions: [V1.capability] }], + }), + upgrade: { ...V1, capability: null }, + expected: { + kind: 'broken-reference', type: 'permission', name: 'cust_exporter', + missing: [{ kind: 'capability', name: V1.capability }], + }, + }, +]; + +describe('[ADR-0126 §6 wall 3] install-local refuses an upgrade that would un-make an access choice', () => { + for (const t of TYPES) { + describe(t.type, () => { + it('single: the administrator is refused 409 with the finding, and nothing is registered or written', async () => { + const { install, register, check } = await mountWithV1(t.state()); + const res = await install(makeC({ manifest: vendorBody('2.0.0', t.upgrade) })); + expect(res.status).toBe(409); + expect(res.payload.error.code).toBe('ACCESS_UPGRADE_CONFIRMATION_REQUIRED'); + const findings = res.payload.error.details.findings; + expect(findings).toHaveLength(1); + expect(findings[0]).toMatchObject(t.expected); + expect(typeof findings[0].describe).toBe('string'); + expect(res.payload.error.details.confirmWith.body).toEqual({ confirmAccessUpgrade: res.payload.error.details.findingSet }); + expect(check).toHaveBeenCalledTimes(1); + expect(register).not.toHaveBeenCalled(); + expect(ledgerVersion()).toBe('1.0.0'); + }); + + it('single CONTROL: the upgrade that keeps the name installs unchanged', async () => { + const { install, register, check } = await mountWithV1(t.state()); + const res = await install(makeC({ manifest: vendorBody('2.0.0', V1) })); + expect(res.status).toBe(200); + expect(res.payload.data.upgradedFrom).toBe('previous-marketplace-version'); + expect(check).toHaveBeenCalledTimes(1); + expect(register).toHaveBeenCalledTimes(1); + expect(ledgerVersion()).toBe('2.0.0'); + expect(new LocalManifestSource(dir).read(VENDOR).entry?.accessUpgradeConfirmation).toBeUndefined(); + }); + + for (const posture of ['isolated', 'group'] as const) { + it(`${posture}: the operator is refused 409 with the finding`, async () => { + const { install, register } = await mountWithV1(t.state(), { posture, caller: 'operator' }); + const res = await install(makeC({ manifest: vendorBody('2.0.0', t.upgrade) })); + expect(res.status).toBe(409); + expect(res.payload.error.code).toBe('ACCESS_UPGRADE_CONFIRMATION_REQUIRED'); + expect(res.payload.error.details.findings[0]).toMatchObject(t.expected); + expect(register).not.toHaveBeenCalled(); + expect(ledgerVersion()).toBe('1.0.0'); + }); + + it(`${posture}: a tenant administrator is refused by the wall first, and shown no finding`, async () => { + const { install, register, check } = await mountWithV1(t.state(), { posture, caller: 'tenant-administrator' }); + const res = await install(makeC({ manifest: vendorBody('2.0.0', t.upgrade) })); + expect(res.status).toBe(403); + expect(res.payload.error.code).toBe('FORBIDDEN'); + expect(res.payload.error.details?.findings).toBeUndefined(); + expect(check).not.toHaveBeenCalled(); + expect(register).not.toHaveBeenCalled(); + }); + } + + it('isolated CONTROL: the operator installs the upgrade that keeps the name', async () => { + const { install, register } = await mountWithV1(t.state(), { posture: 'isolated', caller: 'operator' }); + const res = await install(makeC({ manifest: vendorBody('2.0.0', V1) })); + expect(res.status).toBe(200); + expect(register).toHaveBeenCalledTimes(1); + expect(ledgerVersion()).toBe('2.0.0'); + }); + }); + } +}); + +describe('[ADR-0126 §6 wall 3] the confirmation is the finding set\'s token, on the same call', () => { + const renameSet = TYPES[0]; + + it('the token installs the upgrade, and the ledger entry records who confirmed which set', async () => { + const { install, register, logger } = await mountWithV1(renameSet.state()); + const upgrade = vendorBody('2.0.0', renameSet.upgrade); + const refused = await install(makeC({ manifest: upgrade })); + const findingSet = refused.payload.error.details.findingSet; + expect(findingSet).toMatch(/^security\.access_upgrade:[0-9a-f]{32}$/); + + const res = await install(makeC({ manifest: upgrade, confirmAccessUpgrade: findingSet })); + expect(res.status).toBe(200); + expect(register).toHaveBeenCalledTimes(1); + const entry = new LocalManifestSource(dir).read(VENDOR).entry; + expect(entry?.version).toBe('2.0.0'); + expect(entry?.accessUpgradeConfirmation).toMatchObject({ findingSet, confirmedBy: INSTALLER }); + expect(logger.warn.mock.calls.some(([m]: any[]) => String(m).includes(`confirmed by ${INSTALLER}`))).toBe(true); + }); + + it('a bare `true` confirms nothing', async () => { + const { install, register } = await mountWithV1(renameSet.state()); + const res = await install(makeC({ manifest: vendorBody('2.0.0', renameSet.upgrade), confirmAccessUpgrade: true })); + expect(res.status).toBe(409); + expect(res.payload.error.message).toContain('`true` confirms nothing'); + expect(register).not.toHaveBeenCalled(); + }); + + it('a stale token — the findings changed since it was issued — is refused again with the fresh set', async () => { + const { install, register } = await mountWithV1(renameSet.state()); + const refused = await install(makeC({ manifest: vendorBody('2.0.0', renameSet.upgrade) })); + const stale = refused.payload.error.details.findingSet; + // A different upgrade: the set is renamed to another name. + const res = await install(makeC({ manifest: vendorBody('2.0.1', { ...V1, set: 'acme_sales_agent' }), confirmAccessUpgrade: stale })); + expect(res.status).toBe(409); + expect(res.payload.error.details.findingSet).not.toBe(stale); + expect(res.payload.error.details.findings[0].candidates).toEqual([{ name: 'acme_sales_agent', positions: [V1.position] }]); + expect(register).not.toHaveBeenCalled(); + }); +}); + +describe('[ADR-0126 §6 wall 3] what the door refuses on, and what it does not', () => { + it('a finding that predates the upgrade does not block it: only what the upgrade creates is refused', async () => { + // A switch-off whose name nothing ships any more, left by some other + // package long ago: the boot report raises it; this upgrade did not make it. + const state: AccessState = { + ledger: [{ id: 'old', metadata_type: 'permission', name: 'gone_set', package_id: 'com.other.pkg', active: false }], + customerSets: [], + }; + const { install, register } = await mountWithV1(state); + const res = await install(makeC({ manifest: vendorBody('2.0.0', V1) })); + expect(res.status).toBe(200); + expect(register).toHaveBeenCalledTimes(1); + }); + + it('a check that could not read refuses 503 and writes nothing — an unread store is never "no finding"', async () => { + const state = { ...TYPES[0].state(), failLedger: false }; + const { install, register } = await mountWithV1(state); + state.failLedger = true; + const res = await install(makeC({ manifest: vendorBody('2.0.0', V1) })); + expect(res.status).toBe(503); + expect(res.payload.error.code).toBe('SERVICE_UNAVAILABLE'); + expect(res.payload.error.details.reading).toBe('sys_metadata_activation'); + expect(register).not.toHaveBeenCalled(); + expect(ledgerVersion()).toBe('1.0.0'); + }); + + it('a composition with no access check installs the upgrade and says no check ran', async () => { + const { install, register, logger } = await mountWithV1(TYPES[0].state(), { withCheck: false }); + const res = await install(makeC({ manifest: vendorBody('2.0.0', TYPES[0].upgrade) })); + expect(res.status).toBe(200); + expect(register).toHaveBeenCalledTimes(1); + expect(logger.warn.mock.calls.some(([m]: any[]) => String(m).includes('no access upgrade check ran'))).toBe(true); + }); +}); diff --git a/packages/cloud-connection/src/marketplace-install-local-plugin.ts b/packages/cloud-connection/src/marketplace-install-local-plugin.ts index ec1b9115e0e..389102d01ae 100644 --- a/packages/cloud-connection/src/marketplace-install-local-plugin.ts +++ b/packages/cloud-connection/src/marketplace-install-local-plugin.ts @@ -71,7 +71,20 @@ * A mode the package's `installModes` does not permit answers * `422 INSTALL_MODE_NOT_PERMITTED`, before any write. * - * GET /api/v1/marketplace/install-local + * [ADR-0126 §6 wall 3] A managed RE-install over an installed package + * asks the security plugin's access upgrade check + * (`security.accessUpgradeCheck`) what the upgrade would un-make: a + * switch-off a vendor rename re-enables, a switch, clone reference or + * sharing rule row whose name it no longer ships, a sharing rule name + * collision, a field deny a customer's set lacks. A finding answers + * `409 ACCESS_UPGRADE_CONFIRMATION_REQUIRED` with `details.findings` + * and the finding set's token, before any write; the same request + * with `confirmAccessUpgrade: ` installs, and records the + * confirmation in the ledger entry. A check that could not read + * answers `503 SERVICE_UNAVAILABLE`. Under a wall only the operator + * reaches this step (ADR-0131 D12 items 7–8). + * + * GET /api/v1/marketplace/install-local * → lists currently installed marketplace packages. Requires an * authenticated principal (anonymous → 401); `installedBy` and * `storageDir` are served only to a `manage_metadata` holder @@ -317,6 +330,107 @@ const UNRUNNABLE_REFUSAL_STATUS = 422; const INSTALL_MODE_NOT_PERMITTED_CODE = 'INSTALL_MODE_NOT_PERMITTED'; const INSTALL_MODE_NOT_PERMITTED_STATUS = 422; +/** + * [ADR-0126 §6 wall 3] The refusal of an upgrade that would un-make an access + * choice made in this system — a switch-off a vendor rename re-enables, a + * switch, clone or sharing rule row whose name the package no longer ships, a + * declared sharing rule an organization's row already holds, a name a + * customer's set or rule references and the upgrade drops, a field deny the + * customer's sets lack. 409: the request is well formed and holds until the + * caller answers, as the D5e gate's does. Its own code because the caller's + * branch is its own: the same request with the finding set's token installs, + * and neither the D5e answer (per-index unique confirmations) nor a conflict + * (which no confirmation lifts) leads there. + */ +const ACCESS_UPGRADE_CONFIRMATION_REQUIRED = 'ACCESS_UPGRADE_CONFIRMATION_REQUIRED'; +const ACCESS_UPGRADE_CONFIRMATION_STATUS = 409; + +/** + * [ADR-0126 §6 wall 3] The service plugin-security registers the access + * upgrade check under — asked as a capability question, so this package takes + * no dependency on plugin-security. Without it, a re-install is not checked + * against the access choices it could un-make, and the door says so once per + * upgrade. + */ +const ACCESS_UPGRADE_CHECK_SERVICE = 'security.accessUpgradeCheck'; + +/** A finding of the access upgrade check, as this door reads it (`access-upgrade-report.ts`). */ +type AccessFinding = Record; + +/** The check's answer, as this door reads it (`access-upgrade-check.ts` declares it). */ +type AccessUpgradeCheckAnswer = + | { outcome: 'clean' } + | { + outcome: 'findings'; + findingSet: string; + findings: { + orphans: AccessFinding[]; + reEnablements: AccessFinding[]; + collisions: AccessFinding[]; + brokenReferences: AccessFinding[]; + uncoveredDenies: AccessFinding[]; + }; + } + | { outcome: 'stopped'; stop?: string; reading: string; error?: string }; + +/** The service, narrowed per consumer: the one verb this door calls. */ +type AccessUpgradeCheckService = { + check?(request: { packageId: string; manifest: unknown; previousManifest?: unknown }): Promise; +}; + +/** One finding per line, for the refusal's `details.findings`: what it is and what un-made it. */ +function describeAccessFindings(report: Extract['findings']): AccessFinding[] { + const out: AccessFinding[] = []; + const reEnabled = new Set(report.reEnablements.map((r) => `${r.type}:${r.name}`)); + for (const r of report.reEnablements) { + const candidates = (r.candidates as Array<{ name: string; positions: string[] }>); + const through = candidates.flatMap((c) => c.positions ?? []); + out.push({ + kind: 're-enablement', type: r.type, name: r.name, packageId: r.packageId, candidates, + describe: `${r.type} '${r.name}' is switched off in this system and the upgrade no longer ships it; ` + + `the package ships ${candidates.map((c) => `'${c.name}'`).join(', ')} with no switch, ` + + 'active by default — suspected to be its new name' + + (through.length > 0 ? `, granted through position(s) ${[...new Set(through)].map((p) => `'${p}'`).join(', ')}` : '') + + '.', + }); + } + for (const o of report.orphans) { + if (reEnabled.has(`${o.type}:${o.name}`)) continue; + out.push({ + kind: 'orphan', type: o.type, name: o.name, packageId: o.packageId, switchedOff: o.switchedOff, + describe: `${o.type} '${o.name}' carries a ${o.switchedOff ? 'switch-off' : 'switch'} in this system, ` + + 'and the upgrade no longer ships it, so the switch would cover nothing.', + }); + } + for (const col of report.collisions) { + out.push({ + kind: 'collision', type: 'sharing_rule', name: col.rule, rowId: col.rowId, organizationId: col.organizationId, + describe: `the upgrade declares sharing rule '${col.rule}', whose name a sharing rule authored in this system ` + + 'holds, so the declared rule would be served nowhere in that scope.', + }); + } + for (const b of report.brokenReferences) { + const missing = (b.missing as Array<{ kind: string; name: string }>); + out.push({ + kind: 'broken-reference', type: b.type, name: b.name, + ...(b.rowId !== undefined ? { rowId: b.rowId, organizationId: b.organizationId ?? null } : {}), + missing, + describe: `${b.type === 'permission' ? 'permission set' : 'sharing rule'} '${b.name}', authored in this system, ` + + `names ${missing.map((m) => `${m.kind} '${m.name}'`).join(', ')}, which the upgrade no longer ships.`, + }); + } + for (const u of report.uncoveredDenies) { + const fields = (u.fields as Array<{ field: string; denies: string }>); + out.push({ + kind: 'uncovered-deny', type: 'permission', name: u.customerSet, managedSet: u.managedSet, object: u.object, fields, + describe: `the upgraded set '${u.managedSet}' denies ${fields.map((f) => `${f.denies} on '${f.field}'`).join(', ')}, ` + + `and permission set '${u.customerSet}', authored in this system and granting '${u.object}', has no entry ` + + 'for it, so the field stays open through it.', + }); + } + return out; +} + /** * [ADR-0131 D6] The one verb the `unmanaged` install mode calls: the * copy-once import. Typed from the producer's own method signature, the @@ -1564,6 +1678,34 @@ export class MarketplaceInstallLocalPlugin implements Plugin { }, 409); } + // ADR-0087 D2 — the fetched bundle is a published artifact, so it is + // converted forward ONCE here ({@link convertPackageBodyForward}); the + // converted body is what the access check compares (2c), what + // registers (3), what the binder binds (4c) and what the side effects + // replay (5). The ledger entry (4) keeps the body as installed, and + // rehydrate converts it again at every boot. + const converted = this.convertPackageBodyForward(ctx, manifest); + + // 2c. [ADR-0126 §6 wall 3] ⭐ AN UPGRADE NEVER UN-MAKES AN ACCESS + // CHOICE UNCONFIRMED. On a re-install over an installed package — + // a fresh install has no choice of the customer's to un-make — the + // security plugin's check compares the customer's switch-offs, + // clones and sharing rule rows against the registry as the incoming + // version would leave it, and answers what THIS upgrade would + // create. Any finding stops the install `409 + // ACCESS_UPGRADE_CONFIRMATION_REQUIRED` until the same call carries + // the finding set's token; a check that could not read answers 503. + // Before any write, after the D5e gate, so a stopped install leaves + // the runtime as it found it. Who can reach this step is the door's + // admission's: under a wall, the operator alone (the D12 refusal + // above); under `single`, a `manage_metadata` holder. + let accessUpgradeConfirmation: InstalledEntry['accessUpgradeConfirmation']; + if (conflict === 'marketplace') { + const access = await this.evaluateAccessUpgradeGate(ctx, manifestId, converted, previousEntry, body, userId); + if (access.refusal) return c.json(access.refusal.body, access.refusal.status); + accessUpgradeConfirmation = access.confirmation; + } + // 3. Hot-register FIRST so a malformed inline manifest fails the // install loudly rather than persisting a broken record that // would also fail on every subsequent rehydrate. @@ -1576,12 +1718,6 @@ export class MarketplaceInstallLocalPlugin implements Plugin { // running kernel held a package it did not, and the console read // that answer as "the app should now appear". let hotLoadError: string | undefined; - // ADR-0087 D2 — the fetched bundle is a published artifact, so it is - // converted forward ONCE here ({@link convertPackageBodyForward}); the - // converted body is what registers, what the binder binds (4c) and - // what the side effects replay (5). The ledger entry (4) keeps the - // body as installed, and rehydrate converts it again at every boot. - const converted = this.convertPackageBodyForward(ctx, manifest); try { const manifestService = ctx.getService('manifest') as any; // Awaited: register also bridges the manifest's objects into the @@ -1625,6 +1761,8 @@ export class MarketplaceInstallLocalPlugin implements Plugin { // ceremony is not re-run for constraints already answered for, and // fold in whatever this install confirmed. ...(gate.attestation ? { globalUniqueAttestation: gate.attestation } : {}), + // [ADR-0126 §6 wall 3] The confirmation step 2c admitted, recorded. + ...(accessUpgradeConfirmation ? { accessUpgradeConfirmation } : {}), }; try { this.ledger.write(entry); @@ -2275,6 +2413,124 @@ export class MarketplaceInstallLocalPlugin implements Plugin { return outcomes; }; + /** + * [ADR-0126 §6 wall 3] Step 2c of {@link handleInstall}: ask the security + * plugin's access upgrade check what replacing the installed package with + * `converted` would un-make, and decide. + * + * - **No check service** (no security plugin composed): the re-install is + * not checked against the access choices it could un-make. The upgrade + * proceeds, and one `warn` line says no check ran. + * - **Stopped** (a store the comparison reads did not answer, or the check + * threw): `503 SERVICE_UNAVAILABLE`, nothing written. A comparison that + * did not run is never taken for "no finding". + * - **Clean**: the upgrade proceeds unchanged. + * - **Findings**: `409 ACCESS_UPGRADE_CONFIRMATION_REQUIRED`, naming each, + * unless `body.confirmAccessUpgrade` is the finding set's token. A token + * is the confirmation of the findings it names: one that no longer + * matches (the findings changed since it was issued) is refused again + * with the fresh set, and a bare `true` confirms nothing. + */ + private evaluateAccessUpgradeGate = async ( + ctx: PluginContext, + manifestId: string, + converted: unknown, + previousEntry: InstalledEntry | null | undefined, + body: any, + userId: string, + ): Promise<{ + refusal?: { body: Record; status: 409 | 503 }; + confirmation?: InstalledEntry['accessUpgradeConfirmation']; + }> => { + let checker: AccessUpgradeCheckService | undefined; + try { checker = ctx.getService(ACCESS_UPGRADE_CHECK_SERVICE); } catch { /* not composed */ } + if (typeof checker?.check !== 'function') { + ctx.logger?.warn?.( + `[MarketplaceInstallLocal] upgrade of ${manifestId}: no access upgrade check ran — this composition has no ` + + `'${ACCESS_UPGRADE_CHECK_SERVICE}' service (the security plugin), so this re-install is not checked ` + + 'against the access choices it could un-make. Compose the security plugin to have upgrades checked.', + ); + return {}; + } + let answer: AccessUpgradeCheckAnswer; + try { + answer = await checker.check({ + packageId: manifestId, + manifest: converted, + ...(previousEntry?.manifest ? { previousManifest: this.convertPackageBodyForward(ctx, previousEntry.manifest) } : {}), + }); + } catch (err: any) { + answer = { outcome: 'stopped', reading: 'the access upgrade check', error: String(err?.message ?? err) }; + } + if (answer.outcome === 'clean') return {}; + if (answer.outcome === 'stopped') { + ctx.logger?.warn?.( + `[MarketplaceInstallLocal] upgrade of ${manifestId} refused: the access upgrade check could not read ` + + `${answer.reading}${answer.error ? ` (${answer.error})` : ''}`, + ); + return { + refusal: { + status: 503, + body: { + success: false, + error: { + code: 'SERVICE_UNAVAILABLE', + message: `Upgrading "${manifestId}" is refused: the check that compares this system's access ` + + `choices with the upgrade could not read ${answer.reading}, and a comparison that did not ` + + 'run is never taken for "no finding". Nothing was installed or written. Retry once that ' + + 'store answers.', + details: { manifestId, reading: answer.reading, ...(answer.stop ? { stop: answer.stop } : {}) }, + }, + }, + }, + }; + } + + const findingSet = answer.findingSet; + const token = body?.confirmAccessUpgrade; + if (typeof token === 'string' && token === findingSet) { + const confirmedAt = new Date().toISOString(); + ctx.logger?.warn?.( + `[MarketplaceInstallLocal] upgrade of ${manifestId} confirmed by ${userId} over its access findings ` + + `(${findingSet}); the boot report raises whatever still stands`, + ); + return { confirmation: { findingSet, confirmedBy: userId, confirmedAt } }; + } + const findings = describeAccessFindings(answer.findings); + const stale = token !== undefined + ? ' The confirmation sent does not confirm these findings: a confirmation is the finding set\'s token from ' + + 'this refusal, it no longer matches once the findings change, and `true` confirms nothing.' + : ''; + ctx.logger?.warn?.( + `[MarketplaceInstallLocal] upgrade of ${manifestId} stopped: ${findings.length} access finding(s) unconfirmed ` + + `(${findingSet})`, + ); + return { + refusal: { + status: ACCESS_UPGRADE_CONFIRMATION_STATUS, + body: { + success: false, + error: { + code: ACCESS_UPGRADE_CONFIRMATION_REQUIRED, + message: `Upgrading "${manifestId}" would change what access choices made in this system cover — ` + + `${findings.length} finding(s): ${findings.map((f) => f.describe).join(' ')} ` + + 'Nothing was installed or written. Review each finding; to upgrade anyway, send the same ' + + `request with "confirmAccessUpgrade": "${findingSet}".${stale}`, + details: { + manifestId, + findingSet, + findings, + confirmWith: { + body: { confirmAccessUpgrade: findingSet }, + cli: `os package install … --confirm-access-upgrade ${findingSet}`, + }, + }, + }, + }, + }, + }; + }; + /** * [ADR-0120 D5e] Decide whether this install must stop for the * `isolated`-posture confirmation, and what attestation the ledger entry diff --git a/packages/cloud-connection/vitest.config.ts b/packages/cloud-connection/vitest.config.ts index 4365d5c58a5..a6ef0d5e896 100644 --- a/packages/cloud-connection/vitest.config.ts +++ b/packages/cloud-connection/vitest.config.ts @@ -78,6 +78,14 @@ export default defineConfig({ find: /^@objectstack\/metadata-protocol$/, replacement: path.resolve(__dirname, '../metadata-protocol/src/index.ts'), }, + // [ADR-0126 §6 wall 3] The access upgrade suite runs the door against + // the security plugin's REAL check (`createAccessUpgradeCheck`), so it + // judges THIS checkout's comparison, not a `plugin-security/dist` that + // may be behind it. Anchored like the entries above. + { + find: /^@objectstack\/plugin-security$/, + replacement: path.resolve(__dirname, '../plugins/plugin-security/src/index.ts'), + }, ], }, // Discovery is untouched — but this file is NOT "the alias and nothing else". diff --git a/packages/plugins/plugin-security/src/access-upgrade-check.test.ts b/packages/plugins/plugin-security/src/access-upgrade-check.test.ts new file mode 100644 index 00000000000..37fc8fddf03 --- /dev/null +++ b/packages/plugins/plugin-security/src/access-upgrade-check.test.ts @@ -0,0 +1,272 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [ADR-0126 §6 wall 3] The access upgrade check as `SecurityPlugin` registers + * it (`security.accessUpgradeCheck`): a REAL `ObjectQL` engine over the SQL + * driver holding version 1 of a vendor package, the real plugin started on + * it, and the service the plugin registered asked about version 2. + * + * Pins what the marketplace door refuses on: the findings the upgrade would + * CREATE — for each of the four access types, with a CONTROL that keeps the + * name — and nothing that predates it. Also the two clone-body cases the card + * names (a field the new version removed, a field deny the managed set + * gained), read through the registry's own record of version 1, and the + * fail-closed answers. + */ + +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { ObjectQL } from '@objectstack/objectql'; +import { SqlDriver } from '@objectstack/driver-sql'; +import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol'; +import { + SysMetadataObject, SysMetadataHistoryObject, SysMetadataCommitObject, SysMetadataAuditObject, +} from '@objectstack/metadata-core'; +import { SysOrganization } from '@objectstack/platform-objects/identity'; +import { SysMetadataActivation } from '@objectstack/platform-objects/system'; + +import { SecurityPlugin } from './security-plugin.js'; +import { SysUserPermissionSet } from './objects/sys-user-permission-set.object.js'; +import { + ACCESS_UPGRADE_CHECK_SERVICE, + accessUpgradeDelta, + type AccessUpgradeCheck, + type AccessUpgradeCheckResult, +} from './access-upgrade-check.js'; +import type { AccessUpgradeReport } from './access-upgrade-report.js'; + +const SYS = { isSystem: true } as const; +const VENDOR = 'com.acme.u2'; + +interface Names { set: string; position: string; rule: string; capability: string | null } +const V1: Names = { set: 'u2_sales_rep', position: 'u2_lead', rule: 'u2_share_open', capability: 'u2.export' }; + +function vendorBody(version: string, names: Names, extra: { fields?: Record; setFields?: Record } = {}) { + return { + id: VENDOR, name: 'U2 vendor', version, type: 'app', + objects: [{ name: 'u2_deal', label: 'Deal', fields: extra.fields ?? { amount: { type: 'number' }, stage: { type: 'text' } } }], + capabilities: names.capability ? [{ name: names.capability, label: 'Export' }] : [], + permissions: [{ + name: names.set, label: 'Sales rep', + objects: { u2_deal: { allowRead: true, allowEdit: true } }, + ...(extra.setFields ? { fields: extra.setFields } : {}), + ...(names.capability ? { systemPermissions: [names.capability] } : {}), + }], + positions: [{ name: names.position, label: 'Lead', permissionSets: [names.set] }], + sharingRules: [{ name: names.rule, object: 'u2_deal' }], + }; +} + +const engines: ObjectQL[] = []; +afterEach(async () => { + vi.restoreAllMocks(); + while (engines.length) { + try { await engines.pop()?.destroy(); } catch { /* noop */ } + } +}); + +interface Seed { + ledger?: Array<{ type: string; name: string; packageId?: string; active: boolean }>; + customerSets?: Array>; + failLedger?: boolean; +} + +async function boot(seed: Seed = {}, start = true) { + const engine = new ObjectQL(); + engine.registerDriver( + new SqlDriver({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true }), + true, + ); + await engine.init(); + engine.registerApp({ + id: 'com.objectstack.qa.access-upgrade-check', name: 'Access upgrade check', version: '1.0.0', + type: 'plugin', scope: 'system', + objects: [ + SysOrganization, SysUserPermissionSet, SysMetadataActivation, + SysMetadataObject, SysMetadataHistoryObject, SysMetadataCommitObject, SysMetadataAuditObject, + ], + } as any); + // Version 1 of the vendor package, as the boot registered it. + const installed = vendorBody('1.0.0', V1, { setFields: { 'u2_deal.amount': { readable: true, editable: true } } }); + engine.registerApp(installed as any); + await engine.syncSchemas(); + engines.push(engine); + const registry = (engine as any).registry; + for (const set of installed.permissions) registry.registerItem('permission', set, 'name', VENDOR); + for (const position of installed.positions) registry.registerItem('position', position, 'name', VENDOR); + for (const capability of installed.capabilities) registry.registerItem('capability', capability, 'name', VENDOR); + + const customerSets = seed.customerSets ?? []; + const metadata = { + get: async (type: string, name: string) => (type === 'permission' ? customerSets.find((s) => s.name === name) ?? null : null), + list: async (type: string) => (type === 'permission' ? customerSets : []), + }; + const services: Record = { + manifest: { register: vi.fn() }, + objectql: engine, + metadata, + protocol: new ObjectStackProtocolImplementation(engine as never, () => new Map(), undefined), + sharingRules: { listDeclaredRules: async () => installed.sharingRules.map((r) => ({ ...r, packageId: VENDOR })) }, + }; + const registered = new Map(); + const ctx: any = { + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, + hook: () => undefined, + registerService: (name: string, service: unknown) => registered.set(name, service), + getService: (name: string) => { + if (!(name in services)) throw new Error(`service not registered: ${name}`); + return services[name]; + }, + }; + const plugin = new SecurityPlugin({ fallbackPermissionSet: 'member_default' }); + await plugin.init(ctx); + if (start) await plugin.start(ctx); + vi.spyOn((engine as any).logger, 'warn').mockImplementation(() => undefined); + + let n = 0; + for (const row of seed.ledger ?? []) { + await engine.insert('sys_metadata_activation', { + id: `act_${n++}`, metadata_type: row.type, name: row.name, package_id: row.packageId ?? VENDOR, active: row.active, + }, { context: SYS } as any); + } + if (seed.failLedger) { + const find = engine.find.bind(engine); + vi.spyOn(engine, 'find').mockImplementation(async (object: string, options: any) => { + if (object === 'sys_metadata_activation') throw new Error('ledger store offline'); + return find(object, options); + }); + } + return registered.get(ACCESS_UPGRADE_CHECK_SERVICE) as AccessUpgradeCheck; +} + +const ask = (check: AccessUpgradeCheck, names: Names, extra?: Parameters[2]) => + check.check({ packageId: VENDOR, manifest: vendorBody('2.0.0', names, extra) }); + +function findings(result: AccessUpgradeCheckResult): AccessUpgradeReport { + if (result.outcome !== 'findings') throw new Error(`expected findings, got ${JSON.stringify(result)}`); + return result.findings; +} + +describe('[ADR-0126 §6 wall 3] security.accessUpgradeCheck, as SecurityPlugin registers it', () => { + it('permission set: a switched-off set the upgrade renames is a suspected re-enablement, naming the binding position', async () => { + const check = await boot({ ledger: [{ type: 'permission', name: V1.set, active: false }] }); + const report = findings(await ask(check, { ...V1, set: 'u2_sales_representative' })); + expect(report.reEnablements).toEqual([{ + type: 'permission', name: V1.set, packageId: VENDOR, + candidates: [{ name: 'u2_sales_representative', positions: [V1.position] }], + }]); + }); + + it('position: a switched-off position the upgrade renames is a suspected re-enablement', async () => { + const check = await boot({ ledger: [{ type: 'position', name: V1.position, active: false }] }); + const report = findings(await ask(check, { ...V1, position: 'u2_team_lead' })); + expect(report.reEnablements).toEqual([{ + type: 'position', name: V1.position, packageId: VENDOR, candidates: [{ name: 'u2_team_lead', positions: [] }], + }]); + }); + + it('sharing rule: a switched-off declared rule the upgrade renames is a suspected re-enablement', async () => { + const check = await boot({ ledger: [{ type: 'sharing_rule', name: V1.rule, active: false }] }); + const report = findings(await ask(check, { ...V1, rule: 'u2_share_open_deals' })); + expect(report.reEnablements).toEqual([{ + type: 'sharing_rule', name: V1.rule, packageId: VENDOR, candidates: [{ name: 'u2_share_open_deals', positions: [] }], + }]); + }); + + it('capability: a customer set naming a capability the upgrade drops is a broken reference', async () => { + const check = await boot({ + customerSets: [{ name: 'cust_exporter', label: 'Exporter', _provenance: 'org', objects: {}, systemPermissions: [V1.capability] }], + }); + const report = findings(await ask(check, { ...V1, capability: null })); + expect(report.brokenReferences).toEqual([ + { type: 'permission', name: 'cust_exporter', missing: [{ kind: 'capability', name: V1.capability }] }, + ]); + }); + + for (const type of ['permission', 'position', 'sharing_rule'] as const) { + it(`${type} CONTROL: the upgrade that keeps the switched-off name is clean`, async () => { + const name = type === 'permission' ? V1.set : type === 'position' ? V1.position : V1.rule; + const check = await boot({ ledger: [{ type, name, active: false }] }); + expect(await ask(check, V1)).toEqual({ outcome: 'clean' }); + }); + } + + it('capability CONTROL: the upgrade that keeps the capability is clean', async () => { + const check = await boot({ + customerSets: [{ name: 'cust_exporter', label: 'Exporter', _provenance: 'org', objects: {}, systemPermissions: [V1.capability] }], + }); + expect(await ask(check, V1)).toEqual({ outcome: 'clean' }); + }); + + it('a customer set naming a field the upgrade removes is a broken reference — version 1 read from the registry', async () => { + const check = await boot({ + customerSets: [{ + name: 'cust_deal_reader', label: 'Deal reader', _provenance: 'org', + objects: { u2_deal: { allowRead: true } }, fields: { 'u2_deal.stage': { readable: true } }, + }], + }); + const report = findings(await ask(check, V1, { fields: { amount: { type: 'number' } } })); + expect(report.brokenReferences).toEqual([ + { type: 'permission', name: 'cust_deal_reader', missing: [{ kind: 'field', name: 'u2_deal.stage' }] }, + ]); + }); + + it('a field deny the managed set gains, which a customer set granting the object lacks, is reported', async () => { + const check = await boot({ + customerSets: [{ name: 'cust_deal_reader', label: 'Deal reader', _provenance: 'org', objects: { u2_deal: { allowRead: true } } }], + }); + const report = findings(await ask(check, V1, { setFields: { 'u2_deal.amount': { readable: false } } })); + expect(report.uncoveredDenies).toEqual([{ + managedSet: V1.set, packageId: VENDOR, customerSet: 'cust_deal_reader', object: 'u2_deal', + fields: [{ field: 'u2_deal.amount', denies: 'read' }], + }]); + }); + + it('a finding that predates the upgrade is not the upgrade\'s: the answer is clean', async () => { + const check = await boot({ ledger: [{ type: 'permission', name: 'gone_set', packageId: 'com.other.pkg', active: false }] }); + expect(await ask(check, V1)).toEqual({ outcome: 'clean' }); + }); + + it('a ledger that cannot be read stops the check — never "no finding"', async () => { + const check = await boot({ ledger: [{ type: 'permission', name: V1.set, active: false }], failLedger: true }); + expect(await ask(check, V1)).toMatchObject({ outcome: 'stopped', stop: 'scan-unreadable', reading: 'sys_metadata_activation' }); + }); + + it('before the plugin starts the check is unbound, and says so', async () => { + const check = await boot({}, false); + expect(await ask(check, V1)).toMatchObject({ outcome: 'stopped', stop: 'unbound' }); + }); +}); + +describe('accessUpgradeDelta', () => { + const empty = (): AccessUpgradeReport => ({ + orphans: [], reEnablements: [], collisions: [], brokenReferences: [], uncoveredDenies: [], absent: [], + }); + + it('keeps only what grew: a new candidate, a new missing name, a new uncovered field', () => { + const before = empty(); + before.reEnablements.push({ type: 'permission', name: 'a', packageId: 'p', candidates: [{ name: 'b', positions: [] }] }); + before.brokenReferences.push({ type: 'permission', name: 's', missing: [{ kind: 'field', name: 'o.x' }] }); + before.uncoveredDenies.push({ managedSet: 'm', packageId: 'p', customerSet: 'c', object: 'o', fields: [{ field: 'o.x', denies: 'read' }] }); + const after = empty(); + after.reEnablements.push({ + type: 'permission', name: 'a', packageId: 'p', candidates: [{ name: 'b', positions: [] }, { name: 'c', positions: [] }], + }); + after.brokenReferences.push({ type: 'permission', name: 's', missing: [{ kind: 'field', name: 'o.x' }, { kind: 'field', name: 'o.y' }] }); + after.uncoveredDenies.push({ + managedSet: 'm', packageId: 'p', customerSet: 'c', object: 'o', + fields: [{ field: 'o.x', denies: 'read' }, { field: 'o.y', denies: 'edit' }], + }); + const delta = accessUpgradeDelta(before, after); + expect(delta.reEnablements[0].candidates).toEqual([{ name: 'c', positions: [] }]); + expect(delta.brokenReferences[0].missing).toEqual([{ kind: 'field', name: 'o.y' }]); + expect(delta.uncoveredDenies[0].fields).toEqual([{ field: 'o.y', denies: 'edit' }]); + }); + + it('an unchanged report has an empty delta', () => { + const report = empty(); + report.orphans.push({ type: 'position', name: 'x', packageId: null, switchedOff: true }); + report.collisions.push({ rule: 'r', packageId: 'p', rowId: '1', organizationId: 'o' }); + const delta = accessUpgradeDelta(report, report); + expect([delta.orphans, delta.collisions, delta.reEnablements, delta.brokenReferences, delta.uncoveredDenies]).toEqual([[], [], [], [], []]); + }); +}); diff --git a/packages/plugins/plugin-security/src/access-upgrade-check.ts b/packages/plugins/plugin-security/src/access-upgrade-check.ts new file mode 100644 index 00000000000..ded2b5283e4 --- /dev/null +++ b/packages/plugins/plugin-security/src/access-upgrade-check.ts @@ -0,0 +1,274 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The access-class upgrade check at the marketplace door (ADR-0126 §6 wall 3: + * no upgrade un-makes a customer's choice) — the pre-write half of + * `access-upgrade-report.ts`. + * + * ## What it answers + * + * "If package P were replaced by this incoming manifest, which access-class + * findings would the upgrade CREATE?" It runs the boot report's comparison + * ({@link collectAccessUpgradeFindings}) unchanged, twice, and restates none + * of its rules: + * + * 1. over the registry as it stands; + * 2. over a PROJECTION of that registry in which P's items are the incoming + * manifest's: the security catalog (permission sets, positions, + * capabilities), the declared sharing rules, and P's objects and fields. + * + * The customer's state — the activation ledger and the `sys_sharing_rule` + * rows — is read through the engine as it stands in both runs: it is what an + * upgrade must not un-make, so it is never projected. + * + * The answer is the DELTA: what run 2 finds that run 1 does not. A finding + * that predates this upgrade, or that another package caused, is the boot + * report's to raise; refusing on it would block every re-install of every + * package until it was cleared. + * + * ## The projection + * + * - **Catalog**: an entry whose `packageId` is P is dropped; the incoming + * `permissions`, `positions` and `capabilities` are entries with + * `packageId: P`. + * - **Declared sharing rules**: the same, from the incoming `sharingRules`. + * - **Objects**: an object the incoming manifest declares answers the + * registry's field set (which carries the fields the registry injects) less + * the fields P's previous version declared, plus the incoming declaration's. + * An object P's previous version declared and the incoming one does not + * answers "absent". "P's previous version" is the manifest the caller hands + * in (the door's ledger entry) or, without one, the registry's own record of + * the package. + * + * ## What it does not do + * + * It writes nothing and refuses nothing: the door decides. A read that did not + * happen is `stopped`, never "no finding". + */ + +import { createSecurityCatalogReader, type SecurityCatalogEntry, type SecurityCatalogReader } from '@objectstack/core'; +import { + accessUpgradeDedupKey, + collectAccessUpgradeFindings, + hasAccessUpgradeFindings, + type AccessUpgradeReport, + type AccessUpgradeReportEngine, + type AccessUpgradeReportStop, + type DeclaredRuleLike, + type DeclaredSharingRuleReader, +} from './access-upgrade-report.js'; + +/** The service name the check is registered under. */ +export const ACCESS_UPGRADE_CHECK_SERVICE = 'security.accessUpgradeCheck'; + +/** What the door hands the check. */ +export interface AccessUpgradeCheckRequest { + /** The package being replaced — the manifest id, the registry's `_packageId`. */ + packageId: string; + /** The incoming package body, flat and converted forward (what the door registers). */ + manifest: unknown; + /** P's previous body, when the door holds it (its ledger entry). */ + previousManifest?: unknown; +} + +export type AccessUpgradeCheckResult = + | { outcome: 'clean' } + /** `findings` holds only what this upgrade would create; `findingSet` is its digest. */ + | { outcome: 'findings'; findings: AccessUpgradeReport; findingSet: string } + | { outcome: 'stopped'; stop: AccessUpgradeReportStop | 'unbound'; reading: string; error?: string }; + +/** The service the door reaches. */ +export interface AccessUpgradeCheck { + check(request: AccessUpgradeCheckRequest): Promise; +} + +/** The engine surface the check reads: the report's, plus the registry the catalog is read from. */ +export interface AccessUpgradeCheckEngine extends AccessUpgradeReportEngine { + registry?: unknown; +} + +/** Where the check finds what it reads, resolved at every call: the boot fills them after registration. */ +export interface AccessUpgradeCheckSources { + engine(): AccessUpgradeCheckEngine | undefined; + metadata(): unknown; + sharingRules(): DeclaredSharingRuleReader | undefined; +} + +type Definition = Record; + +function isRecord(value: unknown): value is Definition { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function named(items: unknown): Definition[] { + if (!Array.isArray(items)) return []; + return items.filter((i): i is Definition => isRecord(i) && typeof i.name === 'string' && i.name !== ''); +} + +/** The objects a body declares: name → declared field names. */ +function declaredObjects(body: unknown): Map> { + const out = new Map>(); + if (!isRecord(body)) return out; + for (const object of named(body.objects)) { + out.set(object.name as string, new Set(isRecord(object.fields) ? Object.keys(object.fields) : [])); + } + return out; +} + +const CATALOG_KEYS = { permission: 'permissions', position: 'positions', capability: 'capabilities' } as const; + +function incomingEntries(body: unknown, type: keyof typeof CATALOG_KEYS, packageId: string): SecurityCatalogEntry[] { + if (!isRecord(body)) return []; + return named(body[CATALOG_KEYS[type]]).map((definition) => ({ + type, + name: definition.name as string, + definition: { ...definition, _packageId: packageId }, + source: 'registry' as const, + packageId, + })); +} + +/** The catalog with P's entries replaced by the incoming manifest's. */ +function projectCatalog(base: SecurityCatalogReader, body: unknown, packageId: string): SecurityCatalogReader { + return { + async list(type) { + const incoming = incomingEntries(body, type, packageId); + const names = new Set(incoming.map((e) => e.name)); + const kept = (await base.list(type)).filter((e) => e.packageId !== packageId && !names.has(e.name)); + return [...kept, ...incoming]; + }, + async resolve(type, name) { + const incoming = incomingEntries(body, type, packageId).find((e) => e.name === name); + if (incoming) return incoming; + const held = await base.resolve(type, name); + return held && held.packageId === packageId ? undefined : held; + }, + }; +} + +/** The declared sharing rules with P's replaced; `null` (no declarations read) stays `null`. */ +function projectSharingRules(base: DeclaredSharingRuleReader, body: unknown, packageId: string): DeclaredSharingRuleReader { + return { + async listDeclaredRules() { + const held = await base.listDeclaredRules(); + if (held === null) return null; + const incoming: DeclaredRuleLike[] = isRecord(body) + ? named(body.sharingRules).map((r) => ({ + name: r.name as string, + ...(typeof r.object === 'string' ? { object: r.object } : {}), + packageId, + })) + : []; + const names = new Set(incoming.map((r) => r.name)); + return [...held.filter((r) => r.packageId !== packageId && !names.has(r.name)), ...incoming]; + }, + }; +} + +/** The engine with P's objects and fields replaced; `find` (the customer's state) passes through. */ +function projectEngine(base: AccessUpgradeReportEngine, body: unknown, previous: unknown): AccessUpgradeReportEngine { + const incoming = declaredObjects(body); + const before = declaredObjects(previous); + return { + find: (object, options) => base.find(object, options), + getObject(name) { + const declared = incoming.get(name); + if (!declared) return before.has(name) ? undefined : base.getObject(name); + const held = base.getObject(name) as { fields?: unknown } | undefined | null; + const fields: Definition = {}; + if (held && isRecord(held.fields)) { + const dropped = before.get(name) ?? new Set(); + for (const field of Object.keys(held.fields)) if (!dropped.has(field)) fields[field] = held.fields[field]; + } + for (const field of declared) fields[field] = true; + return { ...(isRecord(held) ? held : {}), name, fields }; + }, + }; +} + +const key = (value: unknown): string => JSON.stringify(value); + +/** + * What `after` finds that `before` does not. A finding that only GREW — a new + * re-enablement candidate, a new missing name in an item that already had + * one, a new uncovered field — is kept with its new parts only. + */ +export function accessUpgradeDelta(before: AccessUpgradeReport, after: AccessUpgradeReport): AccessUpgradeReport { + const orphans = new Set(before.orphans.map(key)); + const collisions = new Set(before.collisions.map(key)); + const candidates = new Map(before.reEnablements.map((r) => [key([r.type, r.name]), new Set(r.candidates.map(key))])); + const missing = new Map(before.brokenReferences.map((b) => [key([b.type, b.name, b.rowId ?? null]), new Set(b.missing.map(key))])); + const denies = new Map(before.uncoveredDenies.map((u) => [key([u.managedSet, u.customerSet, u.object]), new Set(u.fields.map(key))])); + const report: AccessUpgradeReport = { + orphans: after.orphans.filter((o) => !orphans.has(key(o))), + reEnablements: [], + collisions: after.collisions.filter((c) => !collisions.has(key(c))), + brokenReferences: [], + uncoveredDenies: [], + absent: after.absent, + }; + for (const r of after.reEnablements) { + const had = candidates.get(key([r.type, r.name])) ?? new Set(); + const fresh = r.candidates.filter((c) => !had.has(key(c))); + if (fresh.length > 0) report.reEnablements.push({ ...r, candidates: fresh }); + } + for (const b of after.brokenReferences) { + const had = missing.get(key([b.type, b.name, b.rowId ?? null])) ?? new Set(); + const fresh = b.missing.filter((m) => !had.has(key(m))); + if (fresh.length > 0) report.brokenReferences.push({ ...b, missing: fresh }); + } + for (const u of after.uncoveredDenies) { + const had = denies.get(key([u.managedSet, u.customerSet, u.object])) ?? new Set(); + const fresh = u.fields.filter((f) => !had.has(key(f))); + if (fresh.length > 0) report.uncoveredDenies.push({ ...u, fields: fresh }); + } + return report; +} + +/** The registry's own record of a package's manifest, when it keeps one. */ +function registeredManifest(engine: AccessUpgradeCheckEngine, packageId: string): unknown { + const registry = engine.registry as { getPackage?: (id: string) => { manifest?: unknown } | undefined } | undefined; + return typeof registry?.getPackage === 'function' ? registry.getPackage(packageId)?.manifest : undefined; +} + +const stoppedBy = (report: AccessUpgradeReport): AccessUpgradeCheckResult => ({ + outcome: 'stopped', + stop: report.stopped as AccessUpgradeReportStop, + reading: report.stoppedReading ?? 'a store the comparison reads', + ...(report.stoppedError !== undefined ? { error: report.stoppedError } : {}), +}); + +/** Build the check over sources resolved at every call. */ +export function createAccessUpgradeCheck(sources: AccessUpgradeCheckSources): AccessUpgradeCheck { + return { + async check(request) { + const engine = sources.engine(); + const metadata = sources.metadata(); + if (!engine?.registry || !metadata) { + return { + outcome: 'stopped', + stop: 'unbound', + reading: "the security plugin's data engine and metadata service, which are not bound yet", + }; + } + let catalog: SecurityCatalogReader; + try { + catalog = createSecurityCatalogReader({ registry: engine.registry as never, metadata: metadata as never }); + } catch (e) { + return { outcome: 'stopped', stop: 'catalog-unreadable', reading: 'the security catalog', error: (e as Error)?.message }; + } + const sharingRules = sources.sharingRules(); + const before = await collectAccessUpgradeFindings(engine, { catalog, sharingRules }); + if (before.stopped) return stoppedBy(before); + const previous = request.previousManifest ?? registeredManifest(engine, request.packageId); + const after = await collectAccessUpgradeFindings(projectEngine(engine, request.manifest, previous), { + catalog: projectCatalog(catalog, request.manifest, request.packageId), + sharingRules: sharingRules ? projectSharingRules(sharingRules, request.manifest, request.packageId) : undefined, + }); + if (after.stopped) return stoppedBy(after); + const delta = accessUpgradeDelta(before, after); + if (!hasAccessUpgradeFindings(delta)) return { outcome: 'clean' }; + return { outcome: 'findings', findings: delta, findingSet: accessUpgradeDedupKey(delta) }; + }, + }; +} diff --git a/packages/plugins/plugin-security/src/index.ts b/packages/plugins/plugin-security/src/index.ts index e2571e68ac7..8c10769b6b4 100644 --- a/packages/plugins/plugin-security/src/index.ts +++ b/packages/plugins/plugin-security/src/index.ts @@ -137,3 +137,19 @@ export type { PackagedPermissionSetOverlayReading, OverlayDetectionOptions, } from './packaged-permission-set-overlay-detection.js'; +// [ADR-0126 §6 wall 3] The access upgrade check the marketplace door asks +// before it replaces an installed package — registered by `SecurityPlugin` +// under `ACCESS_UPGRADE_CHECK_SERVICE`; exported for a host that composes its +// own sources, and for the door's own tests. +export { + ACCESS_UPGRADE_CHECK_SERVICE, + accessUpgradeDelta, + createAccessUpgradeCheck, +} from './access-upgrade-check.js'; +export type { + AccessUpgradeCheck, + AccessUpgradeCheckEngine, + AccessUpgradeCheckRequest, + AccessUpgradeCheckResult, + AccessUpgradeCheckSources, +} from './access-upgrade-check.js'; diff --git a/packages/plugins/plugin-security/src/security-plugin.ts b/packages/plugins/plugin-security/src/security-plugin.ts index 2ab9121f2cf..bb67e55846a 100644 --- a/packages/plugins/plugin-security/src/security-plugin.ts +++ b/packages/plugins/plugin-security/src/security-plugin.ts @@ -47,6 +47,7 @@ import { type AccessUpgradeNotifier, type DeclaredSharingRuleReader, } from './access-upgrade-report.js'; +import { ACCESS_UPGRADE_CHECK_SERVICE, createAccessUpgradeCheck } from './access-upgrade-check.js'; import { explainAccessForAsker, buildContextForUser, @@ -1720,6 +1721,29 @@ export class SecurityPlugin implements Plugin { }), ); + // [ADR-0126 §6 wall 3] The access upgrade check the marketplace door asks + // before it replaces an installed package: the boot report's comparison, + // run over the registry and over its projection with the incoming + // manifest, answering only what the upgrade would create. Reached as a + // service, so the door takes no dependency on this plugin. Its sources are + // resolved at every call: the engine and the metadata service are bound in + // `start()`. See `access-upgrade-check.ts`. + ctx.registerService( + ACCESS_UPGRADE_CHECK_SERVICE, + createAccessUpgradeCheck({ + engine: () => this.ql as any, + metadata: () => this.metadata, + sharingRules: () => { + try { + const service = ctx.getService>(SHARING_RULES_SERVICE); + return typeof service?.listDeclaredRules === 'function' ? (service as DeclaredSharingRuleReader) : undefined; + } catch { + return undefined; + } + }, + }), + ); + ctx.getService<{ register(m: any): void }>('manifest').register({ ...securityPluginManifestHeader, objects: securityObjects, diff --git a/packages/spec/src/api/error-code-ledger.zod.ts b/packages/spec/src/api/error-code-ledger.zod.ts index b3ec431391d..424c142d3a7 100644 --- a/packages/spec/src/api/error-code-ledger.zod.ts +++ b/packages/spec/src/api/error-code-ledger.zod.ts @@ -1142,6 +1142,16 @@ export const ERROR_CODE_LEDGER = { 'STACK_PROVENANCE_MISSING', ], '@objectstack/cloud-connection': [ + // [ADR-0126 §6 wall 3] install-local refused a marketplace UPGRADE that + // would un-make an access choice made in this system (a switch-off a + // vendor rename re-enables, a switch, clone reference or sharing rule row + // whose name the package no longer ships, a sharing rule name collision, a + // field deny a customer's set lacks), 409, before any write, until the + // same request carries the finding set's token (`confirmAccessUpgrade`); + // `details.findings` names each. Its own code because the caller's branch + // is its own: neither UNIQUE_SCOPE_CONFIRMATION_REQUIRED's per-index + // answer nor a conflict (which no confirmation lifts) leads to it. + 'ACCESS_UPGRADE_CONFIRMATION_REQUIRED', 'CLOUD_FETCH_FAILED', // fetching the manifest/bundle from cloud failed 'CLOUD_UNCONFIGURED', // no cloud endpoint configured on this runtime 'DEVICE_CODE_FAILED', // cloud rejected the device-code exchange diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 67cc9c6cab2..5faacde46ea 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -788,6 +788,9 @@ importers: '@objectstack/lint': specifier: workspace:* version: link:../lint + '@objectstack/plugin-security': + specifier: workspace:* + version: link:../plugins/plugin-security '@types/node': specifier: ^26.6.3 version: 26.6.3