From 7e12df4c01dec7a8e33bf0c4e7c2f318a9d9ac2b Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 23 Oct 2026 16:18:23 +0000 Subject: [PATCH] feat(plugin-security): a boot upgrade impact report for the non-access classes, raised to the administrator like the access upgrade report At kernel:bootstrapped, beside the access upgrade report, read the customer's stored state against the registry for the classes that report does not cover, and raise the finding set through upgrade-report-delivery.ts under its own topic (system.upgrade_impact) and dedupKey: - flow, action and webhook switches: ledger orphans, suspected re-enablements by rename, and a switched-off action whose name more than one object now declares - a stored flow a packaged flow of the same name shadows, and a sys_webhook row withholding a declared webhook - overlay rows of the five O types bound to a package that no longer ships the item - a field an object extension declares that its base layer or another package's extension also declares, a stored object row bound to a package that does not own the object, and a navigation contribution whose group its app no longer has - a stored setting key its registered namespace no longer declares (the value is never read) It writes and refuses nothing; a store that cannot be read stops the report for that boot and says so. Claude-Session: https://claude.ai/code/session_01XHebWJmL3ZgbVpjM6JERNA Co-authored-by: Claude --- .changeset/24447-upgrade-impact-report.md | 18 + .../plugin-security/src/security-plugin.ts | 25 + .../src/upgrade-impact-report.test.ts | 664 +++++++++++++ .../src/upgrade-impact-report.ts | 884 ++++++++++++++++++ .../src/upgrade-impact-report.wiring.test.ts | 241 +++++ 5 files changed, 1832 insertions(+) create mode 100644 .changeset/24447-upgrade-impact-report.md create mode 100644 packages/plugins/plugin-security/src/upgrade-impact-report.test.ts create mode 100644 packages/plugins/plugin-security/src/upgrade-impact-report.ts create mode 100644 packages/plugins/plugin-security/src/upgrade-impact-report.wiring.test.ts diff --git a/.changeset/24447-upgrade-impact-report.md b/.changeset/24447-upgrade-impact-report.md new file mode 100644 index 00000000000..3bdc289dce0 --- /dev/null +++ b/.changeset/24447-upgrade-impact-report.md @@ -0,0 +1,18 @@ +--- +'@objectstack/plugin-security': patch +--- + +feat: a boot upgrade impact report for the non-access classes (flows, actions, webhooks, overlays, object extensions, navigation contributions, settings), raised to the administrator like the access upgrade report + +Clause-②: yes (widening: a new boot report and a new notification topic, `system.upgrade_impact`; nothing is written or refused, and no existing path changes) +Metadata-protocol: none + +A package upgrade that renames, removes or collides with a name could leave the customer's stored state meaning something else, with nothing said beyond a scattered log line. At every boot, beside the access upgrade report, the security plugin now reads that state against the registry and lists: + +- **Flow, action and webhook switches:** an activation ledger row whose name no package ships any more, and a switched-off one whose package now ships unswitched names of the same type (a suspected rename that makes the item active again). Also a switched-off action whose name more than one object now declares, because the one row switches every one of them off. +- **Stored rows a package now overrides:** a stored flow whose name a package ships (the packaged flow runs, the stored one never does), and a `sys_webhook` row holding the name of a webhook a package declares (the declaration is withheld for the whole deployment). +- **Overlay orphans:** a customization of a view, dashboard, report, translation or email template bound to an installed package that no longer ships the item. It keeps serving as an item of its own. +- **Object extensions:** a field an extension declares that the object it extends (or another package's extension) also declares, where the last declaration silently wins. Also a stored object row bound to a package that does not own the object, because another package now ships the name, which the boot refuses to serve. And a navigation contribution whose group its app no longer has. +- **Settings:** a stored value whose namespace no longer declares its key. The value is never read, and the default applies. The report never reads a value. + +Each category logs one `error` with its fix. The finding set is raised through the same delivery as the access report, under its own topic and `dedupKey`. Under `single` it goes to the Default Organization's administrators. Under a wall it goes to the operator alone, and in-app in the organization `OS_PLATFORM_OWNER_ORGANIZATION` names when that is set. A clean boot logs one `info` line. A store that cannot be read stops the report for that boot, and it says so. diff --git a/packages/plugins/plugin-security/src/security-plugin.ts b/packages/plugins/plugin-security/src/security-plugin.ts index 4dbc128019a..279c715b38c 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 DeclaredSharingRuleReader, } from './access-upgrade-report.js'; import { ACCESS_UPGRADE_CHECK_SERVICE, createAccessUpgradeCheck } from './access-upgrade-check.js'; +import { reportUpgradeImpact, SETTINGS_SERVICE, type SettingsManifestReader } from './upgrade-impact-report.js'; import { explainAccessForAsker, buildContextForUser, @@ -4759,6 +4760,30 @@ export class SecurityPlugin implements Plugin { { error: (e as Error)?.message }, ); } + // [#24447] The upgrade impact report: the same comparison for the + // non-access classes — flow, action and webhook switches, stored + // overlays and object rows, object extensions, navigation + // contributions and stored settings — raised the same way. It writes + // and refuses nothing. See `upgrade-impact-report.ts`. + try { + const optional = (name: string): T | undefined => { + try { return ctx.getService(name) ?? undefined; } catch { return undefined; } + }; + const settings = optional>(SETTINGS_SERVICE); + const messaging = optional>('messaging'); + await reportUpgradeImpact(ql as any, { + settings: typeof settings?.listManifests === 'function' ? (settings as SettingsManifestReader) : undefined, + messaging: typeof messaging?.emit === 'function' ? (messaging as AccessUpgradeNotifier) : undefined, + walled: postureEnforcesWall(this.tenancyPosture), + logger: ctx.logger, + }); + } catch (e) { + ctx.logger.warn( + '[security] the upgrade impact report did not run — a switch, stored row, extension or setting an ' + + 'upgrade left behind goes unreported for this boot', + { error: (e as Error)?.message }, + ); + } }); } diff --git a/packages/plugins/plugin-security/src/upgrade-impact-report.test.ts b/packages/plugins/plugin-security/src/upgrade-impact-report.test.ts new file mode 100644 index 00000000000..a6002af16a1 --- /dev/null +++ b/packages/plugins/plugin-security/src/upgrade-impact-report.test.ts @@ -0,0 +1,664 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#24447] The upgrade-impact comparison for the non-access classes and its + * boot report — one pin per finding class, each beside a CONTROL in which the + * upgrade left the customer's state intact and nothing is raised; the + * acceptance case under `single` (the administrator is told) and walled (the + * operator alone). + * + * The engine is an in-memory double of the reads the report makes (`find` by + * equality, `getObject`), and the registry a double of the `SchemaRegistry` + * reads it declares (`UpgradeImpactRegistry`). Both are the module's declared + * seams. The plugin's own wiring, on a real engine and registry, is pinned in + * `upgrade-impact-report.wiring.test.ts`. + */ + +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { resetPlatformAdminEmailMemo } from '@objectstack/core'; +import { + NAV_DIAGNOSTICS, + SCHEMA_REGISTRY, + SETTINGS_SERVICE, + UPGRADE_IMPACT_TOPIC, + collectUpgradeImpactFindings, + reportUpgradeImpact, + upgradeImpactDedupKey, + type NavDiagnosticLike, + type ObjectContributorLike, + type SettingsManifestLike, + type UpgradeImpactRegistry, +} from './upgrade-impact-report.js'; +import type { UpgradeReportNotifier } from './upgrade-report-delivery.js'; + +const OWNER_ENV = 'OS_PLATFORM_OWNER_EMAIL'; +const OPERATOR = 'operator@vendor.example'; +const DEFAULT_ORG = 'org_default'; +const VENDOR = 'com.acme.crm'; +const OTHER_VENDOR = 'com.acme.addon'; +const AUTHORED = 'app.customer_crm'; + +afterEach(() => { + delete process.env[OWNER_ENV]; + resetPlatformAdminEmailMemo(); + vi.restoreAllMocks(); +}); + +type Row = Record; + +/** The stores the deployment holds, by name. */ +const STORES = [ + 'sys_metadata_activation', 'sys_metadata', 'sys_webhook', 'sys_setting', 'sys_platform_setting', + 'sys_organization', 'sys_user_permission_set', +]; + +/** The double refuses what it does not implement, rather than matching it silently. */ +function matches(row: Row, where: Row): boolean { + for (const [key, value] of Object.entries(where)) { + if (key.startsWith('$') || (value !== null && typeof value === 'object')) { + throw new Error(`the engine double does not implement the where clause ${key}`); + } + if (row[key] !== value) return false; + } + return true; +} + +interface Fake { + tables: Record; + stores: string[]; + failOn?: string; + registry?: Partial; + finds: Array<{ object: string; fields?: string[] }>; +} + +function fakeEngine(fake: Fake) { + return { + registry: fake.registry, + getObject: (name: string) => (fake.stores.includes(name) ? { name, fields: {} } : undefined), + async find(object: string, options: Record): Promise { + fake.finds.push({ object, fields: options.fields }); + if (fake.failOn === object) throw new Error(`${object} is unreachable`); + const where = (options.where ?? {}) as Row; + const rows = (fake.tables[object] ?? []).filter((row) => matches(row, where)); + const offset = options.offset ?? 0; + return rows.slice(offset, offset + (options.limit ?? rows.length)); + }, + }; +} + +/** One registered item: a code artifact unless `tenant`. */ +interface Item { type: string; name: string; packageId?: string; member?: string; tenant?: boolean } + +/** One served object: its owner, its overlay and extenders, and the actions it serves. */ +interface ObjectFixture { + name: string; + owner?: { packageId: string; fields: string[] }; + overlay?: { packageId?: string; fields: string[] }; + extenders?: Array<{ packageId: string; fields: string[] }>; + actions?: Array<{ name: string; packageId?: string; tenant?: boolean }>; +} + +const bodyOf = (i: Item) => ({ + name: i.name, + _packageId: i.packageId ?? 'sys_metadata', + _provenance: i.tenant ? 'org' : 'package', + ...(i.member === undefined ? {} : { locale: i.member }), +}); + +/** A double of the `SchemaRegistry` reads, answering as the real one does. */ +function fakeRegistry(r: { + items?: Item[]; + objects?: ObjectFixture[]; + packages?: Record; + apps?: Record; +}): UpgradeImpactRegistry { + const items = r.items ?? []; + const objects = r.objects ?? []; + const packages: Record = { + [VENDOR]: { source: 'code' }, [OTHER_VENDOR]: { source: 'code' }, [AUTHORED]: { source: 'authored' }, ...(r.packages ?? {}), + }; + const disabled = (id?: string) => !!id && packages[id]?.disabled === true; + const contributors = (o: ObjectFixture): ObjectContributorLike[] => [ + ...(o.owner ? [{ packageId: o.owner.packageId, ownership: 'own', definition: { name: o.name, fields: Object.fromEntries(o.owner.fields.map((f) => [f, {}])), _packageId: o.owner.packageId } }] : []), + ...(o.overlay ? [{ packageId: o.overlay.packageId ?? 'sys_metadata', ownership: 'overlay', definition: { name: o.name, fields: Object.fromEntries(o.overlay.fields.map((f) => [f, {}])), _provenance: 'org' } }] : []), + ...(o.extenders ?? []).map((e) => ({ packageId: e.packageId, ownership: 'extend', definition: { name: o.name, fields: Object.fromEntries(e.fields.map((f) => [f, {}])) } })), + ]; + return { + // Like the real lookup: prefer the named package, else any package's artifact. + getArtifactItem(type, name, packageId, member) { + const artifacts = items.filter((i) => i.type === type && i.name === name && !i.tenant && i.packageId + && (member === undefined || i.member === member)); + const found = artifacts.find((i) => i.packageId === packageId) ?? artifacts[0]; + return found ? bodyOf(found) : undefined; + }, + listItems(type, packageId) { + return items.filter((i) => i.type === type && (!packageId || i.packageId === packageId) && !disabled(i.packageId)).map(bodyOf); + }, + getAllObjects() { + return objects.map((o) => ({ + name: o.name, + _packageId: o.owner?.packageId, + actions: (o.actions ?? []).map((a) => ({ + name: a.name, + ...(a.packageId ? { _packageId: a.packageId } : {}), + ...(a.tenant ? { _provenance: 'org' } : {}), + })), + })); + }, + getObjectContributors(name) { + const o = objects.find((x) => x.name === name); + return o ? contributors(o) : []; + }, + getPackagedObjectOwner(name) { + const o = objects.find((x) => x.name === name); + return o?.owner ? { packageId: o.owner.packageId } : undefined; + }, + getPackage: (id) => packages[id], + isPackageDisabled: disabled, + getAllApps: () => Object.keys(r.apps ?? {}).map((name) => ({ name })), + getAppNavDiagnostics: (app) => r.apps?.[app] ?? [], + }; +} + +const MANIFESTS: SettingsManifestLike[] = [ + { namespace: 'mail', specifiers: [{ type: 'group', label: 'Sender' }, { key: 'from_address', type: 'text' }, { key: 'reply_to', type: 'text' }] }, + { namespace: 'storage', specifiers: [{ key: 'provider', type: 'select' }] }, +]; + +/** A deployment: its stored state, its registry, and the people to tell. */ +function deployment(opts: { + ledger?: Row[]; + stored?: Row[]; + webhooks?: Row[]; + settings?: Row[]; + platformSettings?: Row[]; + registry?: Parameters[0] | null; + manifests?: SettingsManifestLike[] | null; + stores?: string[]; +}) { + const fake: Fake = { + stores: opts.stores ?? STORES, + finds: [], + registry: opts.registry === null ? undefined : fakeRegistry(opts.registry ?? {}), + tables: { + sys_metadata_activation: opts.ledger ?? [], + sys_metadata: opts.stored ?? [], + sys_webhook: opts.webhooks ?? [], + sys_setting: opts.settings ?? [], + sys_platform_setting: opts.platformSettings ?? [], + sys_organization: [{ id: DEFAULT_ORG, slug: 'default' }], + sys_user_permission_set: [ + { id: 'g_admin', user_id: 'usr_admin', permission_set: 'admin_full_access', organization_id: DEFAULT_ORG }, + ], + }, + }; + const settings = opts.manifests === null ? undefined : { listManifests: () => opts.manifests ?? MANIFESTS }; + return { fake, engine: fakeEngine(fake), settings }; +} + +const switchedOff = (metadata_type: string, name: string, package_id: string | null = VENDOR): Row => + ({ id: `act_${metadata_type}_${name}`, metadata_type, name, package_id, active: false }); +const storedRow = (type: string, name: string, package_id: string | null, extra: Row = {}): Row => + ({ id: `md_${type}_${name}_${package_id ?? 'none'}`, type, name, package_id, state: 'active', ...extra }); + +const notifier = () => { + const emit = vi.fn(async (_input: Parameters[0]) => ({ notificationId: 'n1' })); + return { emit }; +}; +const logger = () => ({ info: vi.fn(), warn: vi.fn(), error: vi.fn() }); + +/** Run the boot entry under a posture; the operator address is always configured. */ +async function boot(d: ReturnType, posture: 'single' | 'walled') { + process.env[OWNER_ENV] = OPERATOR; + resetPlatformAdminEmailMemo(); + const messaging = notifier(); + const log = logger(); + const out = await reportUpgradeImpact(d.engine, { settings: d.settings, messaging, walled: posture === 'walled', logger: log }); + return { ...out, messaging, log }; +} + +const errorLines = (r: Awaited>) => r.log.error.mock.calls.map(([m]) => String(m)); + +// ── C: the switched types ──────────────────────────────────────────────────── + +/** The acceptance: a flow the customer switched off, renamed by the vendor. */ +const renamedFlow = (renamed: boolean) => deployment({ + ledger: [switchedOff('flow', 'acme_lead_router')], + registry: { + items: [ + { type: 'flow', name: renamed ? 'acme_lead_routing' : 'acme_lead_router', packageId: VENDOR }, + { type: 'flow', name: 'acme_nightly_sync', packageId: VENDOR }, + { type: 'flow', name: 'acme_other_vendor_flow', packageId: OTHER_VENDOR }, + ], + }, +}); + +describe('flow: a switched-off flow renamed by the vendor', () => { + for (const posture of ['single', 'walled'] as const) { + it(`${posture}: names the old name and every unswitched name of its package, and tells ${posture === 'single' ? 'the administrator' : 'the operator alone'}`, async () => { + const r = await boot(renamedFlow(true), posture); + expect(r.report.reEnablements).toEqual([{ + type: 'flow', name: 'acme_lead_router', packageId: VENDOR, candidates: ['acme_lead_routing', 'acme_nightly_sync'], + }]); + expect(r.report.orphans).toEqual([{ type: 'flow', name: 'acme_lead_router', packageId: VENDOR, switchedOff: true }]); + expect(errorLines(r).some((m) => m.includes('a rename that makes the item active again'))).toBe(true); + // The orphan is the re-enablement's own row: it is not logged a second time. + expect(errorLines(r).some((m) => m.includes('so the switch covers nothing'))).toBe(false); + if (posture === 'single') { + expect(r.delivery.outcome).toBe('notified'); + const input = r.messaging.emit.mock.calls[0][0]; + expect({ topic: input.topic, audience: [...input.audience], organizationId: input.organizationId, dedupKey: input.dedupKey }) + .toEqual({ topic: UPGRADE_IMPACT_TOPIC, audience: [OPERATOR, 'usr_admin'], organizationId: DEFAULT_ORG, dedupKey: upgradeImpactDedupKey(r.report) }); + expect((input.payload as any).counts.reEnablements).toBe(1); + } else { + expect(r.messaging.emit).not.toHaveBeenCalled(); + expect(r.delivery).toMatchObject({ outcome: 'operator-log', recipients: [OPERATOR] }); + const line = r.log.error.mock.calls.find(([m]) => String(m).startsWith('[security] platform operator:')); + expect(String(line?.[0])).toContain('the upgrade impact findings above are deployment-level'); + expect(line?.[2]).toEqual({ findingSet: upgradeImpactDedupKey(r.report), operators: [OPERATOR] }); + } + }); + + it(`${posture} CONTROL: the switched-off name still ships — no finding, nobody told`, async () => { + const r = await boot(renamedFlow(false), posture); + expect(r.report.orphans).toEqual([]); + expect(r.report.reEnablements).toEqual([]); + expect(r.delivery.outcome).toBe('clean'); + expect(r.messaging.emit).not.toHaveBeenCalled(); + expect(r.log.info.mock.calls.some(([m]) => String(m).includes('upgrade impact report: every flow'))).toBe(true); + }); + } + + it('a candidate a ledger row already covers is no candidate; a row with no package is an orphan, never a re-enablement; a switched-ON orphan is a dead row', async () => { + const d = deployment({ + ledger: [ + switchedOff('flow', 'acme_lead_router'), + switchedOff('flow', 'acme_nightly_sync'), + switchedOff('flow', 'env_gone', null), + { ...switchedOff('webhook', 'acme_old_hook'), active: true }, + ], + registry: { + items: [ + { type: 'flow', name: 'acme_lead_routing', packageId: VENDOR }, + { type: 'flow', name: 'acme_nightly_sync', packageId: VENDOR }, + { type: 'webhook', name: 'acme_new_hook', packageId: VENDOR }, + ], + }, + }); + const report = await collectUpgradeImpactFindings(d.engine, d); + expect(report.reEnablements.map((r) => [r.name, r.candidates])).toEqual([['acme_lead_router', ['acme_lead_routing']]]); + expect(report.orphans.map((o) => [o.type, o.name, o.switchedOff])).toEqual([ + ['flow', 'acme_lead_router', true], ['flow', 'env_gone', true], ['webhook', 'acme_old_hook', false], + ]); + }); + + it('a disabled package\'s switched-off flow is no orphan: its items are withheld, not gone', async () => { + const d = deployment({ + ledger: [switchedOff('flow', 'acme_lead_router')], + registry: { items: [{ type: 'flow', name: 'acme_lead_router', packageId: VENDOR }], packages: { [VENDOR]: { source: 'code', disabled: true } } }, + }); + expect((await collectUpgradeImpactFindings(d.engine, d)).orphans).toEqual([]); + }); + + it('a tenant-authored flow of the switched name is no shipped flow: the switch is still an orphan', async () => { + const d = deployment({ + ledger: [switchedOff('flow', 'acme_lead_router')], + registry: { items: [{ type: 'flow', name: 'acme_lead_router', packageId: VENDOR, tenant: true }] }, + }); + expect((await collectUpgradeImpactFindings(d.engine, d)).orphans.map((o) => o.name)).toEqual(['acme_lead_router']); + }); +}); + +describe('action: a switched-off action, renamed or declared on a second object', () => { + const objects = (opts: { renamed?: boolean; second?: boolean }): ObjectFixture[] => [ + { name: 'lead', owner: { packageId: VENDOR, fields: ['id'] }, actions: [{ name: opts.renamed ? 'acme_convert_lead' : 'acme_convert' }] }, + { name: 'contact', owner: { packageId: OTHER_VENDOR, fields: ['id'] }, actions: opts.second ? [{ name: 'acme_convert' }] : [] }, + { name: 'note', actions: [{ name: 'env_action', tenant: true }] }, + ]; + + it('renamed: an orphan and a suspected re-enablement naming the embedded action its object\'s package ships', async () => { + const d = deployment({ ledger: [switchedOff('action', 'acme_convert')], registry: { objects: objects({ renamed: true }) } }); + const report = await collectUpgradeImpactFindings(d.engine, d); + expect(report.reEnablements).toEqual([{ type: 'action', name: 'acme_convert', packageId: VENDOR, candidates: ['acme_convert_lead'] }]); + expect(report.sharedActionSwitches).toEqual([]); + }); + + it('declared on a second object by an upgrade: the one row switches both off', async () => { + const d = deployment({ ledger: [switchedOff('action', 'acme_convert')], registry: { objects: objects({ second: true }) } }); + const r = await boot(d, 'single'); + expect(r.report.sharedActionSwitches).toEqual([{ name: 'acme_convert', packageId: VENDOR, objects: ['contact', 'lead'] }]); + expect(r.report.orphans).toEqual([]); + expect(errorLines(r).some((m) => m.includes('declared on more than one object'))).toBe(true); + expect(r.delivery.outcome).toBe('notified'); + }); + + it('CONTROL: one object declares it — no finding; a standalone code action resolves the name too', async () => { + const d = deployment({ + ledger: [switchedOff('action', 'acme_convert'), switchedOff('action', 'acme_global_export')], + registry: { objects: objects({}), items: [{ type: 'action', name: 'acme_global_export', packageId: VENDOR }] }, + }); + const report = await collectUpgradeImpactFindings(d.engine, d); + expect(report.orphans).toEqual([]); + expect(report.sharedActionSwitches).toEqual([]); + }); + + it('a switched-ON row on a shared name is no finding: it switches nothing off', async () => { + const d = deployment({ + ledger: [{ ...switchedOff('action', 'acme_convert'), active: true }], + registry: { objects: objects({ second: true }) }, + }); + expect((await collectUpgradeImpactFindings(d.engine, d)).sharedActionSwitches).toEqual([]); + }); +}); + +describe('stored flow: a row whose name a package now ships', () => { + it('names the row and the package whose flow runs instead; a plural type spelling is the same type', async () => { + const d = deployment({ + stored: [storedRow('flow', 'acme_lead_router', null), storedRow('flows', 'acme_nightly_sync', AUTHORED), storedRow('flow', 'env_only', null)], + registry: { items: [{ type: 'flow', name: 'acme_lead_router', packageId: VENDOR }, { type: 'flow', name: 'acme_nightly_sync', packageId: VENDOR }] }, + }); + const r = await boot(d, 'single'); + expect(r.report.shadowedFlows).toEqual([ + { name: 'acme_lead_router', rowId: 'md_flow_acme_lead_router_none', rowPackageId: null, packageId: VENDOR }, + { name: 'acme_nightly_sync', rowId: `md_flows_acme_nightly_sync_${AUTHORED}`, rowPackageId: AUTHORED, packageId: VENDOR }, + ]); + expect(errorLines(r).some((m) => m.includes('the stored one never does'))).toBe(true); + }); + + it('CONTROL: no package ships the stored names — no finding', async () => { + const d = deployment({ + stored: [storedRow('flow', 'env_only', null)], + registry: { items: [{ type: 'flow', name: 'env_only', tenant: true }] }, + }); + expect((await collectUpgradeImpactFindings(d.engine, d)).shadowedFlows).toEqual([]); + }); + + it('only active rows are read', async () => { + const d = deployment({ + stored: [storedRow('flow', 'acme_lead_router', null, { state: 'archived' })], + registry: { items: [{ type: 'flow', name: 'acme_lead_router', packageId: VENDOR }] }, + }); + expect((await collectUpgradeImpactFindings(d.engine, d)).shadowedFlows).toEqual([]); + }); +}); + +describe('webhook: a sys_webhook row holding a declared name', () => { + it('names the row, its organization and the package whose declaration is withheld', async () => { + const d = deployment({ + webhooks: [ + { id: 'wh_1', name: 'acme_deal_won', organization_id: 'org_tenant' }, + { id: 'wh_2', name: 'tenant_own_hook', organization_id: 'org_tenant' }, + ], + registry: { items: [{ type: 'webhook', name: 'acme_deal_won', packageId: VENDOR }] }, + }); + const r = await boot(d, 'walled'); + expect(r.report.withheldWebhooks).toEqual([{ name: 'acme_deal_won', packageId: VENDOR, rowId: 'wh_1', organizationId: 'org_tenant' }]); + expect(errorLines(r).some((m) => m.includes('the declared webhook is withheld for the whole deployment'))).toBe(true); + }); + + it('CONTROL: no package declares the row\'s name — no finding', async () => { + const d = deployment({ webhooks: [{ id: 'wh_2', name: 'tenant_own_hook', organization_id: 'org_tenant' }] }); + expect((await collectUpgradeImpactFindings(d.engine, d)).withheldWebhooks).toEqual([]); + }); +}); + +// ── O: the overlay types ───────────────────────────────────────────────────── + +describe('overlay: a customization bound to a package that no longer ships the item', () => { + it('names each orphan; a bundled type\'s member is judged as that member', async () => { + const d = deployment({ + stored: [ + storedRow('dashboard', 'acme_pipeline', VENDOR), + storedRow('report', 'acme_revenue', VENDOR), + storedRow('email_template', 'acme_welcome', VENDOR, { id: 'md_tpl_zh', bundle_member: 'zh-CN' }), + storedRow('email_template', 'acme_welcome', VENDOR, { id: 'md_tpl_en' }), + ], + registry: { + items: [ + { type: 'dashboard', name: 'acme_pipeline_v2', packageId: VENDOR }, + { type: 'report', name: 'acme_revenue', packageId: VENDOR }, + { type: 'email_template', name: 'acme_welcome', packageId: VENDOR, member: 'en' }, + ], + }, + }); + const r = await boot(d, 'single'); + expect(r.report.overlayOrphans).toEqual([ + { type: 'dashboard', name: 'acme_pipeline', rowId: `md_dashboard_acme_pipeline_${VENDOR}`, packageId: VENDOR }, + { type: 'email_template', name: 'acme_welcome', member: 'zh-CN', rowId: 'md_tpl_zh', packageId: VENDOR }, + ]); + expect(errorLines(r).some((m) => m.includes('bound to a package that no longer ships the item'))).toBe(true); + }); + + it('another package shipping the same name does not cover the row: the row is bound to its own package', async () => { + const d = deployment({ + stored: [storedRow('view', 'acme_board', VENDOR)], + registry: { items: [{ type: 'view', name: 'acme_board', packageId: OTHER_VENDOR }] }, + }); + expect((await collectUpgradeImpactFindings(d.engine, d)).overlayOrphans.map((o) => o.name)).toEqual(['acme_board']); + }); + + it('CONTROL: the package still ships it; and rows that are not judged — package-less, authored, unknown or disabled package, not an overlay type', async () => { + const d = deployment({ + stored: [ + storedRow('view', 'acme_board', VENDOR), + storedRow('view', 'env_board', null), + storedRow('view', 'customer_board', AUTHORED), + storedRow('view', 'mystery_board', 'com.removed.pkg'), + storedRow('translation', 'zh-CN', 'com.disabled.pkg'), + storedRow('page', 'acme_home', VENDOR), + ], + registry: { + items: [{ type: 'view', name: 'acme_board', packageId: VENDOR }], + packages: { 'com.disabled.pkg': { source: 'code', disabled: true } }, + }, + }); + expect((await collectUpgradeImpactFindings(d.engine, d)).overlayOrphans).toEqual([]); + }); +}); + +// ── E: the extensions ──────────────────────────────────────────────────────── + +describe('extension: a field more than one package declares', () => { + it('the vendor adds a field an extension already declares: the extension\'s is served', async () => { + const d = deployment({ + registry: { + objects: [ + { name: 'account', owner: { packageId: VENDOR, fields: ['id', 'name', 'rating'] }, extenders: [{ packageId: AUTHORED, fields: ['rating', 'tier'] }] }, + { name: 'contact', owner: { packageId: VENDOR, fields: ['id', 'email'] }, extenders: [{ packageId: OTHER_VENDOR, fields: ['score'] }, { packageId: AUTHORED, fields: ['score'] }] }, + ], + }, + }); + const r = await boot(d, 'single'); + expect(r.report.fieldCollisions).toEqual([ + { object: 'account', field: 'rating', declaredBy: [{ packageId: VENDOR, layer: 'own' }, { packageId: AUTHORED, layer: 'extend' }] }, + { object: 'contact', field: 'score', declaredBy: [{ packageId: OTHER_VENDOR, layer: 'extend' }, { packageId: AUTHORED, layer: 'extend' }] }, + ]); + expect(errorLines(r).some((m) => m.includes('a review item'))).toBe(true); + }); + + it('with a tenant overlay the overlay is the base layer the extension replaces a field of', async () => { + const d = deployment({ + registry: { + objects: [{ + name: 'account', owner: { packageId: VENDOR, fields: ['id', 'name'] }, + overlay: { packageId: VENDOR, fields: ['id', 'name', 'region'] }, extenders: [{ packageId: OTHER_VENDOR, fields: ['region'] }], + }], + }, + }); + expect((await collectUpgradeImpactFindings(d.engine, d)).fieldCollisions).toEqual([ + { object: 'account', field: 'region', declaredBy: [{ packageId: VENDOR, layer: 'overlay' }, { packageId: OTHER_VENDOR, layer: 'extend' }] }, + ]); + }); + + it('CONTROL: distinct field names, a package extending its own object, an overlay over the owner — no finding', async () => { + const d = deployment({ + registry: { + objects: [ + { name: 'account', owner: { packageId: VENDOR, fields: ['id', 'name'] }, extenders: [{ packageId: AUTHORED, fields: ['tier'] }] }, + { name: 'lead', owner: { packageId: VENDOR, fields: ['id', 'source'] }, extenders: [{ packageId: VENDOR, fields: ['source'] }] }, + { name: 'deal', owner: { packageId: VENDOR, fields: ['id', 'stage'] }, overlay: { fields: ['id', 'stage'] } }, + ], + }, + }); + expect((await collectUpgradeImpactFindings(d.engine, d)).fieldCollisions).toEqual([]); + }); +}); + +describe('extension: a stored object row a package now owns', () => { + it('a row bound to another package is refused at boot: named with the owner', async () => { + const d = deployment({ + stored: [storedRow('object', 'invoice', AUTHORED), storedRow('objects', 'account', OTHER_VENDOR)], + registry: { + objects: [ + { name: 'invoice', owner: { packageId: VENDOR, fields: ['id'] } }, + { name: 'account', owner: { packageId: VENDOR, fields: ['id'] } }, + ], + }, + }); + const r = await boot(d, 'single'); + expect(r.report.refusedObjectRows).toEqual([ + { name: 'account', rowId: `md_objects_account_${OTHER_VENDOR}`, packageId: OTHER_VENDOR, ownerPackageId: VENDOR }, + { name: 'invoice', rowId: `md_object_invoice_${AUTHORED}`, packageId: AUTHORED, ownerPackageId: VENDOR }, + ]); + expect(errorLines(r).some((m) => m.includes('so the row is not served'))).toBe(true); + }); + + it('CONTROL: a row bound to the owner, a package-less overlay (ADR-0005\'s sanctioned shape), an object no package owns — no finding', async () => { + const d = deployment({ + stored: [storedRow('object', 'account', VENDOR), storedRow('object', 'deal', null), storedRow('object', 'custom_thing', AUTHORED)], + registry: { + objects: [ + { name: 'account', owner: { packageId: VENDOR, fields: ['id'] } }, + { name: 'deal', owner: { packageId: VENDOR, fields: ['id'] } }, + { name: 'custom_thing' }, + ], + }, + }); + expect((await collectUpgradeImpactFindings(d.engine, d)).refusedObjectRows).toEqual([]); + }); +}); + +describe('extension: a navigation contribution whose group its app no longer has', () => { + it('names the app, the group, the contributing package and the relocated items', async () => { + const d = deployment({ + registry: { apps: { crm: [{ app: 'crm', group: 'sales_tools', packageId: OTHER_VENDOR, relocated: ['addon_quotes'] }], setup: [] } }, + }); + const r = await boot(d, 'single'); + expect(r.report.relocatedNavContributions).toEqual([{ app: 'crm', group: 'sales_tools', packageId: OTHER_VENDOR, relocated: ['addon_quotes'] }]); + expect(errorLines(r).some((m) => m.includes("moved to the app's top level"))).toBe(true); + }); + + it('CONTROL: every contribution found its group — no finding', async () => { + const d = deployment({ registry: { apps: { crm: [], setup: [] } } }); + expect((await collectUpgradeImpactFindings(d.engine, d)).relocatedNavContributions).toEqual([]); + }); + + it('a registry with no navigation diagnostics names what is absent and judges nothing of it', async () => { + const d = deployment({}); + const registry = d.fake.registry as any; + delete registry.getAppNavDiagnostics; + const report = await collectUpgradeImpactFindings(d.engine, d); + expect(report.absent).toEqual([NAV_DIAGNOSTICS]); + }); +}); + +// ── Settings ───────────────────────────────────────────────────────────────── + +describe('settings: a stored key its namespace no longer declares', () => { + it('names the store, namespace and key, the row count and the organizations — never a value', async () => { + const d = deployment({ + settings: [ + { id: 's1', namespace: 'mail', key: 'sender_address', organization_id: 'org_a', value: 'secret-ish' }, + { id: 's2', namespace: 'mail', key: 'sender_address', organization_id: 'org_b' }, + { id: 's3', namespace: 'mail', key: 'sender_address', organization_id: null }, + { id: 's4', namespace: 'mail', key: 'from_address', organization_id: 'org_a' }, + { id: 's5', namespace: 'unregistered_plugin', key: 'anything', organization_id: 'org_a' }, + ], + platformSettings: [{ id: 'p1', namespace: 'storage', key: 'bucket' }, { id: 'p2', namespace: 'storage', key: 'provider' }], + }); + const r = await boot(d, 'single'); + expect(r.report.settingOrphans).toEqual([ + { store: 'sys_platform_setting', namespace: 'storage', name: 'bucket', rows: 1, organizations: [] }, + { store: 'sys_setting', namespace: 'mail', name: 'sender_address', rows: 3, organizations: ['org_a', 'org_b'] }, + ]); + expect(JSON.stringify(r.messaging.emit.mock.calls[0][0])).not.toContain('secret-ish'); + // The value columns are never read. + for (const find of d.fake.finds.filter((f) => f.object.endsWith('setting'))) { + expect(find.fields?.some((f) => f.startsWith('value'))).toBe(false); + } + expect(errorLines(r).some((m) => m.includes('the stored value is never read'))).toBe(true); + }); + + it('CONTROL: every stored key is declared — no finding; a layout-only specifier with no key declares nothing', async () => { + const d = deployment({ settings: [{ id: 's4', namespace: 'mail', key: 'from_address', organization_id: 'org_a' }] }); + expect((await collectUpgradeImpactFindings(d.engine, d)).settingOrphans).toEqual([]); + }); + + it('without the settings service nothing of settings is judged, and the absence is named', async () => { + const d = deployment({ manifests: null, settings: [{ id: 's1', namespace: 'mail', key: 'sender_address' }] }); + const report = await collectUpgradeImpactFindings(d.engine, d); + expect(report.settingOrphans).toEqual([]); + expect(report.absent).toEqual([SETTINGS_SERVICE]); + expect(d.fake.finds.some((f) => f.object === 'sys_setting')).toBe(false); + }); +}); + +// ── A read that did not happen ─────────────────────────────────────────────── + +describe('a read that did not happen is not "resolves nowhere"', () => { + for (const store of ['sys_metadata_activation', 'sys_metadata', 'sys_webhook', 'sys_setting'] as const) { + it(`${store} unreadable: the report stops, names the store, reports no finding and tells nobody`, async () => { + const d = deployment({ + ledger: [switchedOff('flow', 'acme_gone')], + settings: [{ id: 's1', namespace: 'mail', key: 'sender_address' }], + }); + d.fake.failOn = store; + const r = await boot(d, 'single'); + expect(r.report).toMatchObject({ stopped: 'scan-unreadable', stoppedReading: store, orphans: [], settingOrphans: [] }); + expect(r.delivery.outcome).toBe('stopped'); + expect(r.messaging.emit).not.toHaveBeenCalled(); + expect(r.log.warn.mock.calls.some(([m]) => String(m).includes(`the upgrade impact report stopped: ${store} could not be read`))).toBe(true); + }); + } + + it('the settings manifests unreadable: the report stops', async () => { + const d = deployment({}); + const r = await reportUpgradeImpact(d.engine, { + settings: { listManifests: () => { throw new Error('settings registry broke'); } }, walled: false, logger: logger(), + }); + expect(r.report).toMatchObject({ stopped: 'settings-unreadable', stoppedError: 'settings registry broke' }); + }); + + it('absent stores are named and judge nothing that needs them', async () => { + const d = deployment({ stores: ['sys_metadata', 'sys_organization', 'sys_user_permission_set'], ledger: [switchedOff('flow', 'acme_gone')] }); + const report = await collectUpgradeImpactFindings(d.engine, d); + expect(report.absent).toEqual(['sys_metadata_activation', 'sys_webhook', 'sys_setting', 'sys_platform_setting']); + expect(report.orphans).toEqual([]); + }); + + it('an engine with no registry judges no registry class, and still judges the settings', async () => { + const d = deployment({ registry: null, ledger: [switchedOff('flow', 'acme_gone')], settings: [{ id: 's1', namespace: 'mail', key: 'sender_address' }] }); + const report = await collectUpgradeImpactFindings(d.engine, d); + expect(report.absent).toEqual([SCHEMA_REGISTRY]); + expect(report.orphans).toEqual([]); + expect(report.settingOrphans.map((s) => s.name)).toEqual(['sender_address']); + expect(d.fake.finds.some((f) => f.object === 'sys_metadata_activation')).toBe(false); + }); +}); + +describe('the finding set', () => { + it('an unchanged set has one dedupKey across boots, under its own topic; a changed set has another', async () => { + const first = await boot(renamedFlow(true), 'single'); + const second = await boot(renamedFlow(true), 'single'); + expect(first.messaging.emit.mock.calls[0][0].dedupKey).toBe(second.messaging.emit.mock.calls[0][0].dedupKey); + expect(upgradeImpactDedupKey(first.report)).toMatch(/^system\.upgrade_impact:[0-9a-f]{32}$/); + const changed = await boot(deployment({ ...{ ledger: [switchedOff('flow', 'acme_other_gone')] } }), 'single'); + expect(changed.messaging.emit.mock.calls[0][0].dedupKey).not.toBe(first.messaging.emit.mock.calls[0][0].dedupKey); + }); + + it('it writes nothing: the engine double offers no write verb and none is asked for', async () => { + const d = deployment({ ledger: [switchedOff('flow', 'acme_gone')] }); + await boot(d, 'single'); + expect(Object.keys(d.engine).sort()).toEqual(['find', 'getObject', 'registry']); + }); +}); diff --git a/packages/plugins/plugin-security/src/upgrade-impact-report.ts b/packages/plugins/plugin-security/src/upgrade-impact-report.ts new file mode 100644 index 00000000000..47a807db7c6 --- /dev/null +++ b/packages/plugins/plugin-security/src/upgrade-impact-report.ts @@ -0,0 +1,884 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The upgrade-impact comparison for the non-access classes, and its boot + * report (#24447; ADR-0126 §6 wall 3: no upgrade un-makes a customer's + * choice). The access classes are `access-upgrade-report.ts`'s; this module + * reads the rest the same way. + * + * ## What it compares + * + * A code-package change rewrites the packaged base, and the customer's state + * stays where it was written: the activation ledger (`sys_metadata_activation`, + * one row per bare `(metadata_type, name)`), the stored metadata rows + * (`sys_metadata`), the organizations' `sys_webhook` rows and the stored + * settings (`sys_setting`, `sys_platform_setting`). That state is kept by + * NAME, so an upgrade that renames, removes or collides with a name can leave + * it meaning something else. At every boot this module reads it against the + * post-boot registry and lists, per class of the ruling on #24443: + * + * **C, the switched types** (flow, action, webhook): + * + * 1. **Orphans** — ledger rows whose name no package ships at this boot. + * 2. **Suspected re-enablements** — a switched-off orphan whose recorded + * `package_id` is P, and every name of the same type P now ships that no + * ledger row names: a rename makes the item active again by default. + * "Suspected", as in the access report: nothing records which name + * replaced which. + * 3. **Switch-offs on a shared action name** — a switched-off action whose + * name more than one served object declares. The ledger addresses an + * action by name alone, so the row switches every one of them off; the + * activation door refuses such a flip, but an upgrade can add the second + * declaration after the row was written. + * 4. **Shadowed stored flows** — an active `sys_metadata` flow row whose name + * a package ships. The packaged flow is armed and the stored one never runs + * (ADR-0126 §2, `service-automation`'s `flow-precedence.ts`). + * 5. **Withheld declared webhooks** — a `sys_webhook` row holding the name of + * a webhook a package declares. The row answers and the declaration is + * withheld for the whole deployment (`plugin-webhooks`' `declared-webhooks.ts`). + * + * Validations and scheduled jobs have no switch and no stored row of their + * own, so nothing of theirs is kept by name for an upgrade to strand. + * + * **O, the overlay types** (view, dashboard, report, translation, email + * template): + * + * 6. **Overlay orphans** — an active `sys_metadata` row bound to an installed, + * enabled code or marketplace package that no longer ships the item (the + * row's bundle member, for a bundled type). Its artifact lookup finds + * nothing, so the row registers and serves as an item of its own beside + * whatever replaced it. + * + * **E, the extensions** (objects and apps): + * + * 7. **Field collisions** — a field an `extend` contributor declares that the + * object's base layer (the tenant overlay when there is one, else the + * owner) or another package's extension also declares. The fold is + * last-writer-wins on `fields`, so the later declaration replaces the + * others with no diagnostic (`objectql`'s `mergeObjectDefinitions`). + * Read as "declared twice", not "conflicting": a review item. + * 8. **Refused object rows** — an active `sys_metadata` object row bound to a + * package Q while a code package P ≠ Q owns the name. The boot hydration + * refuses the row (ADR-0029 D9.9, `OBJECT_OVERLAY_PACKAGE_MISMATCH`), so + * the object answers with P's definition and the row is not served. + * 9. **Relocated navigation contributions** — a contribution naming a group + * its target app no longer has; the registry moved its items to the app's + * top level (`getAppNavDiagnostics`, #14553). + * + * **Settings:** + * + * 10. **Setting orphans** — a stored value whose namespace a registered + * manifest declares and whose key it does not. The settings door refuses + * an undeclared key, so the value is never read and the default applies. + * + * ## What it does not read, and why + * + * - A package-less object row over a packaged owner is ADR-0005's sanctioned + * environment-wide overlay (ADR-0029 D9.9's accepted case), and it cannot + * be told apart from an object the environment authored before a package + * shipped the name. It is not listed. + * - An overlay row bound to a package the registry does not hold is not + * judged: it may be an authored package this composition does not hydrate, + * or a package removed from the deployment, and the registry cannot tell + * which. A row bound to a disabled package is not judged either: its + * package's items are withheld whatever they are. + * - A stored setting whose namespace no manifest registers is not judged: the + * namespace may belong to a plugin this composition does not load, or be + * held by the tenancy posture (`branding` under a wall). + * - A navigation contribution aimed at an app no package registers is not + * listed: the registry offers no enumeration of contributions by target. + * - View diagnostics inside an overlay (a broken column or filter reference) + * are the metadata protocol's diagnostics, not this comparison's. + * + * ## What it does not do + * + * It writes nothing and refuses nothing at boot. It never reads a setting's + * value or a stored row's body: a finding names the row, never its content. + * + * ## A read that did not happen is not "resolves nowhere" + * + * As `access-upgrade-report.ts`: a scan or a manifest read that throws stops + * the report, which says which store could not be read and reports no + * finding. A composition without one of the stores, without the settings + * service, or with a registry that does not offer the reads below names what + * is absent and judges nothing that needs it. + * + * ## Who is told + * + * The log, always, once per category. And the people who can act, through + * `upgrade-report-delivery.ts`, under this report's own topic and `dedupKey`: + * the Default Organization's administrators under `single`; under a wall, the + * operator alone, and in-app in the organization + * `OS_PLATFORM_OWNER_ORGANIZATION` names when it is set. The ledger, the + * stored metadata and the packages are deployment-level; a stored setting or + * webhook row names its organization, so the operator can tell it. + * + * ## When it runs + * + * At `kernel:bootstrapped`, beside the access upgrade report: every + * definition this boot registers, and every settings manifest, is in place by + * then. + */ + +import { createHash } from 'node:crypto'; +import { METADATA_ACTIVATION_TABLE } from '@objectstack/core'; +import { isCodeArtifactBody, isTenantAuthored } from '@objectstack/metadata-core'; +import { PLURAL_TO_SINGULAR } from '@objectstack/spec/shared'; +import { + deliverUpgradeReport, + type UpgradeReportDelivery, + type UpgradeReportLogger, + type UpgradeReportNotifier, +} from './upgrade-report-delivery.js'; + +/** The notification topic the report is raised on. */ +export const UPGRADE_IMPACT_TOPIC = 'system.upgrade_impact'; + +/** The ledger types this report reads; the access types are the access report's. */ +export const UPGRADE_IMPACT_LEDGER_TYPES = ['flow', 'action', 'webhook'] as const; +export type UpgradeImpactLedgerType = (typeof UPGRADE_IMPACT_LEDGER_TYPES)[number]; + +/** The overlay types (ADR-0126 §3, regime O). */ +export const UPGRADE_IMPACT_OVERLAY_TYPES = ['view', 'dashboard', 'report', 'translation', 'email_template'] as const; +export type UpgradeImpactOverlayType = (typeof UPGRADE_IMPACT_OVERLAY_TYPES)[number]; + +/** The stored metadata rows. */ +export const STORED_METADATA_OBJECT = 'sys_metadata'; + +/** The organization-authored webhook rows (plugin-webhooks). */ +export const WEBHOOK_OBJECT = 'sys_webhook'; + +/** The stored settings: the organization and user layers, and the deployment layer. */ +export const SETTING_OBJECTS = ['sys_setting', 'sys_platform_setting'] as const; +export type SettingStore = (typeof SETTING_OBJECTS)[number]; + +/** The service name service-settings registers under. */ +export const SETTINGS_SERVICE = 'settings'; + +/** What the report names when the engine's registry does not offer the reads it needs. */ +export const SCHEMA_REGISTRY = 'the schema registry'; + +/** What the report names when the registry offers no navigation diagnostics. */ +export const NAV_DIAGNOSTICS = 'the navigation contribution diagnostics'; + +const SCAN_PAGE_SIZE = 500; +const LOGGED_LIMIT = 50; +const SYSTEM_CTX = { isSystem: true } as const; + +/** One registered object contributor, as `SchemaRegistry.getObjectContributors` answers it. */ +export interface ObjectContributorLike { + packageId?: string; + ownership?: string; + definition?: unknown; +} + +/** One relocated contribution, as `SchemaRegistry.getAppNavDiagnostics` answers it. */ +export interface NavDiagnosticLike { + app?: string; + group?: string; + packageId?: string; + relocated?: readonly string[]; +} + +/** The registry reads this report needs — ObjectQL's `SchemaRegistry` offers each as it stands. */ +export interface UpgradeImpactRegistry { + getArtifactItem(type: string, name: string, packageId?: string, member?: string): unknown; + listItems(type: string, packageId?: string): unknown[]; + getAllObjects(): unknown[]; + getObjectContributors(name: string): readonly ObjectContributorLike[]; + getPackagedObjectOwner(name: string): { packageId?: string } | undefined; + getPackage(id: string): { source?: string } | undefined; + isPackageDisabled(id?: string): boolean; + getAllApps?(): unknown[]; + getAppNavDiagnostics?(app: string): readonly NavDiagnosticLike[]; +} + +const REGISTRY_READS = [ + 'getArtifactItem', 'listItems', 'getAllObjects', 'getObjectContributors', 'getPackagedObjectOwner', 'getPackage', + 'isPackageDisabled', +] as const; + +/** The engine surface the report reads through — ObjectQL satisfies it as it stands. */ +export interface UpgradeImpactEngine { + getObject(name: string): unknown; + find(object: string, options: Record): Promise; + registry?: unknown; +} + +/** One registered settings manifest, as `SettingsService.listManifests` answers it. */ +export interface SettingsManifestLike { + namespace?: unknown; + specifiers?: unknown; +} + +/** The read service-settings offers this report. */ +export interface SettingsManifestReader { + listManifests(): readonly SettingsManifestLike[]; +} + +export interface UpgradeImpactReportDeps { + /** service-settings; absent when the composition has none. */ + settings?: SettingsManifestReader; +} + +/** A ledger row whose name no package ships. */ +export interface SwitchOrphan { + type: UpgradeImpactLedgerType; + name: string; + packageId: string | null; + /** `true`: the customer switched it off. `false`: a row with no effect. */ + switchedOff: boolean; +} + +/** A switched-off orphan whose package ships unswitched names of its type. */ +export interface SuspectedSwitchReEnablement { + type: UpgradeImpactLedgerType; + /** The switched-off name no package ships. */ + name: string; + packageId: string; + /** The names of the same type the package ships with no ledger row. */ + candidates: string[]; +} + +/** A switched-off action whose name more than one served object declares. */ +export interface SharedActionSwitch { + name: string; + packageId: string | null; + /** The objects declaring an action of that name, every one switched off by the row. */ + objects: string[]; +} + +/** An active stored flow row a packaged flow of the same name shadows. */ +export interface ShadowedStoredFlow { + name: string; + rowId: string; + /** The stored row's own binding; `null` for a package-less row. */ + rowPackageId: string | null; + /** The package shipping the flow that runs instead. */ + packageId: string; +} + +/** A `sys_webhook` row holding a declared webhook's name. */ +export interface WithheldDeclaredWebhook { + name: string; + /** The package declaring the withheld webhook. */ + packageId: string; + rowId: string; + organizationId: string | null; +} + +/** An overlay row bound to a package that no longer ships the item. */ +export interface OverlayOrphan { + type: UpgradeImpactOverlayType; + name: string; + /** A bundled type's member, when the row names one. */ + member?: string; + rowId: string; + packageId: string; +} + +/** One layer declaring a field, in the fold's order. */ +export interface FieldDeclarer { + packageId: string | null; + layer: 'own' | 'overlay' | 'extend'; +} + +/** A field more than one package declares on one object; the last declarer is served. */ +export interface ExtensionFieldCollision { + object: string; + field: string; + declaredBy: FieldDeclarer[]; +} + +/** A stored object row bound to a package that does not own the object. */ +export interface RefusedObjectRow { + name: string; + rowId: string; + packageId: string; + ownerPackageId: string; +} + +/** A navigation contribution whose group its app no longer has. */ +export interface RelocatedNavContribution { + app: string; + group: string; + packageId: string | null; + /** Ids of the items moved to the app's top level. */ + relocated: string[]; +} + +/** Stored values of a key their registered namespace no longer declares. */ +export interface SettingOrphan { + store: SettingStore; + namespace: string; + /** + * The key. Not spelled `key`: the core logger redacts a field of that name, + * which would hide from the operator the very key the finding names. + */ + name: string; + /** How many rows hold a value for the key. */ + rows: number; + /** The organizations those rows belong to, when they name one. */ + organizations: string[]; +} + +export type UpgradeImpactReportStop = 'scan-unreadable' | 'settings-unreadable'; + +export interface UpgradeImpactReport { + orphans: SwitchOrphan[]; + reEnablements: SuspectedSwitchReEnablement[]; + sharedActionSwitches: SharedActionSwitch[]; + shadowedFlows: ShadowedStoredFlow[]; + withheldWebhooks: WithheldDeclaredWebhook[]; + overlayOrphans: OverlayOrphan[]; + fieldCollisions: ExtensionFieldCollision[]; + refusedObjectRows: RefusedObjectRow[]; + relocatedNavContributions: RelocatedNavContribution[]; + settingOrphans: SettingOrphan[]; + /** What this composition does not provide; the parts that need it were not judged. */ + absent: string[]; + stopped?: UpgradeImpactReportStop; + stoppedReading?: string; + stoppedError?: string; +} + +/** The finding lists, in the order the report logs them. */ +const FINDING_KEYS = [ + 'reEnablements', 'orphans', 'sharedActionSwitches', 'shadowedFlows', 'withheldWebhooks', 'overlayOrphans', + 'fieldCollisions', 'refusedObjectRows', 'relocatedNavContributions', 'settingOrphans', +] as const; +type FindingKey = (typeof FINDING_KEYS)[number]; + +function emptyReport(absent: string[] = []): UpgradeImpactReport { + return { + orphans: [], reEnablements: [], sharedActionSwitches: [], shadowedFlows: [], withheldWebhooks: [], + overlayOrphans: [], fieldCollisions: [], refusedObjectRows: [], relocatedNavContributions: [], settingOrphans: [], + absent, + }; +} + +class ReportStop extends Error { + constructor(readonly stop: UpgradeImpactReportStop, readonly reading: string, readonly reason: unknown) { + super(stop); + } +} + +const errorText = (e: unknown): string => (e instanceof Error ? e.message : String(e)); + +function nameOf(value: unknown): string | undefined { + if (typeof value !== 'string') return undefined; + const name = value.trim(); + return name === '' ? undefined : name; +} + +/** A row's package binding: `null` for none and for the `'sys_metadata'` sentinel. */ +function bindingOf(value: unknown): string | null { + const binding = nameOf(value); + return binding === undefined || binding === 'sys_metadata' ? null : binding; +} + +function organizationOf(row: Record): string | null { + const value = row.organization_id ?? row.organizationId; + if (value === null || value === undefined || value === '') return null; + return String(value); +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +/** A stored row's type, singular (the boot hydration's own normalization). */ +function storedTypeOf(row: Record): string | undefined { + const type = nameOf(row.type); + return type === undefined ? undefined : (PLURAL_TO_SINGULAR[type] ?? type); +} + +const compare = (a: string, b: string): number => (a < b ? -1 : a > b ? 1 : 0); +const sortedBy = (items: T[], ...keys: Array<(item: T) => string>): T[] => + items.sort((a, b) => { + for (const key of keys) { + const order = compare(key(a), key(b)); + if (order !== 0) return order; + } + return 0; + }); + +function registryOf(engine: UpgradeImpactEngine): UpgradeImpactRegistry | undefined { + const registry = engine?.registry as Partial | undefined; + if (!registry) return undefined; + return REGISTRY_READS.every((read) => typeof registry[read] === 'function') + ? (registry as UpgradeImpactRegistry) + : undefined; +} + +async function scanAll( + engine: UpgradeImpactEngine, + object: string, + fields: string[], + where: Record = {}, +): Promise[]> { + const out: Record[] = []; + for (let offset = 0; ; offset += SCAN_PAGE_SIZE) { + let page: unknown; + try { + page = await engine.find(object, { + where, + fields, + orderBy: [{ field: 'id', order: 'asc' }], + limit: SCAN_PAGE_SIZE, + offset, + context: SYSTEM_CTX, + }); + } catch (e) { + throw new ReportStop('scan-unreadable', object, e); + } + const rows = Array.isArray(page) ? (page as Record[]) : []; + for (const row of rows) if (isRecord(row)) out.push(row); + if (rows.length < SCAN_PAGE_SIZE) break; + } + return out; +} + +/** The package that ships `(type, name)` as a code artifact, or `undefined`. */ +function shippingPackage(registry: UpgradeImpactRegistry, type: string, name: string): string | undefined { + const artifact = registry.getArtifactItem(type, name); + return isCodeArtifactBody(artifact) ? String((artifact as { _packageId: unknown })._packageId) : undefined; +} + +interface ActionDeclaration { + object: string; + /** The package shipping it; `null` for an action the environment authored. */ + packageId: string | null; +} + +/** + * Every action the served objects declare, by name. Attribution follows the + * runtime's `shippingPackageOf` (`@objectstack/runtime`'s `/actions` domain): + * a code artifact's own package, else the object's packaged owner for an + * embedded action, never one the environment authored. + */ +function servedActionDeclarations(registry: UpgradeImpactRegistry): Map { + const byName = new Map(); + for (const object of registry.getAllObjects()) { + if (!isRecord(object)) continue; + const objectName = nameOf(object.name); + if (objectName === undefined || !Array.isArray(object.actions)) continue; + const owner = bindingOf(registry.getPackagedObjectOwner(objectName)?.packageId); + for (const action of object.actions as unknown[]) { + if (!isRecord(action)) continue; + const name = nameOf(action.name); + if (name === undefined) continue; + const packageId = isCodeArtifactBody(action) + ? String(action._packageId) + : isTenantAuthored(action) ? null : owner; + const list = byName.get(name) ?? []; + list.push({ object: objectName, packageId }); + byName.set(name, list); + } + } + return byName; +} + +/** + * Read the customer's state and the registry, and return what an upgrade left + * behind. Logs nothing; {@link reportUpgradeImpact} is the boot entry. A read + * that did not happen is returned in `stopped`, never thrown. + */ +export async function collectUpgradeImpactFindings( + engine: UpgradeImpactEngine, + deps: UpgradeImpactReportDeps = {}, +): Promise { + const report = emptyReport(); + const has = (object: string): boolean => { + const present = !!engine?.getObject?.(object); + if (!present) report.absent.push(object); + return present; + }; + const registry = registryOf(engine); + if (!registry) report.absent.push(SCHEMA_REGISTRY); + + try { + const ledgerRows = registry && has(METADATA_ACTIVATION_TABLE) + ? await scanAll(engine, METADATA_ACTIVATION_TABLE, ['id', 'metadata_type', 'name', 'package_id', 'active']) + : []; + const storedRows = registry && has(STORED_METADATA_OBJECT) + ? await scanAll(engine, STORED_METADATA_OBJECT, ['id', 'type', 'name', 'package_id', 'bundle_member'], { state: 'active' }) + : []; + const webhookRows = registry && has(WEBHOOK_OBJECT) + ? await scanAll(engine, WEBHOOK_OBJECT, ['id', 'name', 'organization_id']) + : []; + + let manifests: readonly SettingsManifestLike[] | undefined; + if (deps.settings) { + try { + manifests = deps.settings.listManifests(); + } catch (e) { + throw new ReportStop('settings-unreadable', 'the settings manifests', e); + } + if (!Array.isArray(manifests)) { + throw new ReportStop('settings-unreadable', 'the settings manifests', new Error('listManifests answered no list')); + } + } else { + report.absent.push(SETTINGS_SERVICE); + } + const settingRows: Array<{ store: SettingStore; rows: Record[] }> = []; + if (manifests) { + for (const store of SETTING_OBJECTS) { + if (!has(store)) continue; + const fields = store === 'sys_setting' ? ['id', 'namespace', 'key', 'organization_id'] : ['id', 'namespace', 'key']; + settingRows.push({ store, rows: await scanAll(engine, store, fields) }); + } + } + + if (registry) { + // ── C: 1. orphans, 2. suspected re-enablements, 3. switch-offs on a shared action name. + const actions = servedActionDeclarations(registry); + const shipper = (type: UpgradeImpactLedgerType, name: string): string | null | undefined => { + const shipped = shippingPackage(registry, type, name); + if (shipped !== undefined || type !== 'action') return shipped; + const declared = actions.get(name); + return declared ? (declared.find((d) => d.packageId !== null)?.packageId ?? null) : undefined; + }; + const shippedNames = (type: UpgradeImpactLedgerType, packageId: string): string[] => { + const names = new Set(); + for (const item of registry.listItems(type, packageId)) { + if (!isRecord(item) || !isCodeArtifactBody(item) || item._packageId !== packageId) continue; + const name = nameOf(item.name); + if (name !== undefined) names.add(name); + } + if (type === 'action') { + for (const [name, declared] of actions) if (declared.some((d) => d.packageId === packageId)) names.add(name); + } + return [...names].sort(compare); + }; + + const ledgerNames: Record> = { flow: new Set(), action: new Set(), webhook: new Set() }; + const ledger: Array<{ type: UpgradeImpactLedgerType; name: string; packageId: string | null; switchedOff: boolean }> = []; + for (const row of ledgerRows) { + const type = row.metadata_type as UpgradeImpactLedgerType; + const name = nameOf(row.name); + if (!(UPGRADE_IMPACT_LEDGER_TYPES as readonly string[]).includes(type) || name === undefined) continue; + ledgerNames[type].add(name); + ledger.push({ type, name, packageId: bindingOf(row.package_id), switchedOff: row.active === false || row.active === 0 }); + } + for (const row of ledger) { + if (shipper(row.type, row.name) !== undefined) { + if (row.type !== 'action' || !row.switchedOff) continue; + const objects = [...new Set((actions.get(row.name) ?? []).map((d) => d.object))].sort(compare); + if (objects.length > 1) report.sharedActionSwitches.push({ name: row.name, packageId: row.packageId, objects }); + continue; + } + report.orphans.push({ ...row }); + if (!row.switchedOff || row.packageId === null) continue; + const candidates = shippedNames(row.type, row.packageId).filter((name) => !ledgerNames[row.type].has(name)); + if (candidates.length > 0) { + report.reEnablements.push({ type: row.type, name: row.name, packageId: row.packageId, candidates }); + } + } + + // ── C: 4. shadowed stored flows; O: 6. overlay orphans; E: 8. refused object rows. + for (const row of storedRows) { + const type = storedTypeOf(row); + const name = nameOf(row.name); + if (type === undefined || name === undefined) continue; + const rowId = String(row.id); + const binding = bindingOf(row.package_id); + if (type === 'flow') { + const packageId = shippingPackage(registry, 'flow', name); + if (packageId !== undefined) report.shadowedFlows.push({ name, rowId, rowPackageId: binding, packageId }); + } else if (type === 'object') { + const owner = bindingOf(registry.getPackagedObjectOwner(name)?.packageId); + if (binding !== null && owner !== null && binding !== owner) { + report.refusedObjectRows.push({ name, rowId, packageId: binding, ownerPackageId: owner }); + } + } else if ((UPGRADE_IMPACT_OVERLAY_TYPES as readonly string[]).includes(type) && binding !== null) { + const pkg = registry.getPackage(binding); + if (!pkg || pkg.source === 'authored' || registry.isPackageDisabled(binding)) continue; + const member = nameOf(row.bundle_member); + const artifact = registry.getArtifactItem(type, name, binding, member); + if (isCodeArtifactBody(artifact) && (artifact as { _packageId: unknown })._packageId === binding) continue; + report.overlayOrphans.push({ + type: type as UpgradeImpactOverlayType, name, ...(member === undefined ? {} : { member }), rowId, packageId: binding, + }); + } + } + + // ── C: 5. withheld declared webhooks. + for (const row of webhookRows) { + const name = nameOf(row.name); + const packageId = name === undefined ? undefined : shippingPackage(registry, 'webhook', name); + if (name === undefined || packageId === undefined) continue; + report.withheldWebhooks.push({ name, packageId, rowId: String(row.id), organizationId: organizationOf(row) }); + } + + // ── E: 7. field collisions. + for (const object of registry.getAllObjects()) { + const objectName = isRecord(object) ? nameOf(object.name) : undefined; + if (objectName === undefined) continue; + const contributors = registry.getObjectContributors(objectName); + const base = contributors.find((c) => c.ownership === 'overlay') ?? contributors.find((c) => c.ownership === 'own'); + const extenders = contributors.filter((c) => c.ownership === 'extend'); + if (extenders.length === 0) continue; + const declarers = new Map(); + for (const layer of base ? [base, ...extenders] : extenders) { + const fields = isRecord(layer.definition) && isRecord(layer.definition.fields) ? layer.definition.fields : {}; + for (const field of Object.keys(fields)) { + const list = declarers.get(field) ?? []; + list.push({ packageId: bindingOf(layer.packageId), layer: layer.ownership as FieldDeclarer['layer'] }); + declarers.set(field, list); + } + } + for (const [field, declaredBy] of declarers) { + if (!declaredBy.some((d) => d.layer === 'extend')) continue; + if (new Set(declaredBy.map((d) => d.packageId)).size < 2) continue; + report.fieldCollisions.push({ object: objectName, field, declaredBy }); + } + } + + // ── E: 9. relocated navigation contributions. + if (typeof registry.getAllApps === 'function' && typeof registry.getAppNavDiagnostics === 'function') { + for (const app of registry.getAllApps()) { + const appName = isRecord(app) ? nameOf(app.name) : undefined; + if (appName === undefined) continue; + for (const d of registry.getAppNavDiagnostics(appName)) { + const group = nameOf(d?.group); + if (group === undefined) continue; + report.relocatedNavContributions.push({ + app: appName, + group, + packageId: bindingOf(d.packageId), + relocated: Array.isArray(d.relocated) ? d.relocated.map(String) : [], + }); + } + } + } else { + report.absent.push(NAV_DIAGNOSTICS); + } + } + + // ── Settings: 10. setting orphans. + if (manifests) { + const declared = new Map>(); + for (const manifest of manifests) { + const namespace = nameOf(manifest?.namespace); + if (namespace === undefined) continue; + const keys = declared.get(namespace) ?? new Set(); + for (const spec of Array.isArray(manifest.specifiers) ? manifest.specifiers : []) { + const key = isRecord(spec) ? nameOf(spec.key) : undefined; + if (key !== undefined) keys.add(key); + } + declared.set(namespace, keys); + } + for (const { store, rows } of settingRows) { + const orphans = new Map(); + for (const row of rows) { + const namespace = nameOf(row.namespace); + const key = nameOf(row.key); + if (namespace === undefined || key === undefined) continue; + const keys = declared.get(namespace); + if (!keys || keys.has(key)) continue; + const id = JSON.stringify([namespace, key]); + const orphan = orphans.get(id) ?? { store, namespace, name: key, rows: 0, organizations: [] }; + orphan.rows += 1; + const organization = organizationOf(row); + if (organization !== null && !orphan.organizations.includes(organization)) orphan.organizations.push(organization); + orphans.set(id, orphan); + } + for (const orphan of orphans.values()) { + orphan.organizations.sort(compare); + report.settingOrphans.push(orphan); + } + } + } + } catch (e) { + if (!(e instanceof ReportStop)) throw e; + return { ...emptyReport(report.absent), stopped: e.stop, stoppedReading: e.reading, stoppedError: errorText(e.reason) }; + } + + sortedBy(report.orphans, (o) => o.type, (o) => o.name); + sortedBy(report.reEnablements, (r) => r.type, (r) => r.name); + sortedBy(report.sharedActionSwitches, (s) => s.name); + sortedBy(report.shadowedFlows, (s) => s.name, (s) => s.rowId); + sortedBy(report.withheldWebhooks, (w) => w.name, (w) => w.rowId); + sortedBy(report.overlayOrphans, (o) => o.type, (o) => o.name, (o) => o.member ?? '', (o) => o.rowId); + sortedBy(report.fieldCollisions, (c) => c.object, (c) => c.field); + sortedBy(report.refusedObjectRows, (r) => r.name, (r) => r.rowId); + sortedBy(report.relocatedNavContributions, (r) => r.app, (r) => r.group, (r) => r.packageId ?? ''); + sortedBy(report.settingOrphans, (s) => s.store, (s) => s.namespace, (s) => s.name); + return report; +} + +/** How many findings of each class the report carries. */ +export function upgradeImpactCounts(report: UpgradeImpactReport): Record { + return Object.fromEntries(FINDING_KEYS.map((key) => [key, report[key].length])) as Record; +} + +/** Does the report carry anything an administrator must hear? */ +export function hasUpgradeImpactFindings(report: UpgradeImpactReport): boolean { + return FINDING_KEYS.some((key) => report[key].length > 0); +} + +// ───────────────────────────────────────────────────────────────────────────── +// The boot report: the log, and the administrator-visible notification. +// ───────────────────────────────────────────────────────────────────────────── + +export interface UpgradeImpactBootDeps extends UpgradeImpactReportDeps { + logger?: UpgradeReportLogger; + /** The `messaging` service; absent when the composition has none. */ + messaging?: UpgradeReportNotifier; + /** `true` under `group` / `isolated`: the operator alone is told (see `upgrade-report-delivery.ts`). */ + walled: boolean; +} + +function listed(items: readonly T[]): { items: T[]; more?: number } { + return items.length > LOGGED_LIMIT + ? { items: items.slice(0, LOGGED_LIMIT), more: items.length - LOGGED_LIMIT } + : { items: [...items] }; +} + +function logError(logger: UpgradeReportLogger | undefined, message: string, meta: Record): void { + if (logger?.error) logger.error(message, undefined, meta); + else logger?.warn(message, meta); +} + +const SWITCH_DOORS = + 'POST /api/v1/automation//toggle, POST /api/v1/actions/_activation// or ' + + 'POST /api/v1/security/_activation/webhook/, with { "enabled": false }'; + +/** The finding set as stable data: what the digest, the notification and the log carry. */ +export function upgradeImpactSummary(report: UpgradeImpactReport): Record { + return Object.fromEntries(FINDING_KEYS.map((key) => [key, report[key]])) as Record; +} + +/** One notification per distinct finding set: the set's digest. */ +export function upgradeImpactDedupKey(report: UpgradeImpactReport): string { + const digest = createHash('sha256').update(JSON.stringify(upgradeImpactSummary(report))).digest('hex').slice(0, 32); + return `${UPGRADE_IMPACT_TOPIC}:${digest}`; +} + +/** Each category's log line, in {@link FINDING_KEYS} order. */ +const CATEGORY_LINES: Record string> = { + reEnablements: (n) => + `[security] upgrade impact: ${n} flow, action or webhook switch-off(s) name an item its package no longer ` + + 'ships, while the same package ships name(s) of that type no switch covers — a rename that makes the item ' + + "active again (ADR-0126 §6: no upgrade un-makes a customer's choice). Suspected: nothing records which name " + + `replaced which. Nothing was changed. Fix: review each candidate, and switch it off if it is the renamed item (${SWITCH_DOORS}).`, + orphans: (n) => + `[security] upgrade impact: ${n} activation ledger row(s) for a flow, action or webhook name an item no ` + + 'package ships at this boot, so the switch covers nothing. Nothing was changed. Fix: if the item was renamed, ' + + 'switch the new name as the old one was; reinstalling the package that shipped it clears the line at the next boot.', + sharedActionSwitches: (n) => + `[security] upgrade impact: ${n} switched-off action name(s) are declared on more than one object, and the ` + + 'activation ledger addresses an action by its name alone (ADR-0126 §4), so the one row switches the action ' + + 'off on every one of those objects. Nothing was changed. Fix: if only one of them was meant, switch the row ' + + 'back on (POST /api/v1/actions/_activation// with { "enabled": true }) and ask the packages ' + + 'for distinct machine names.', + shadowedFlows: (n) => + `[security] upgrade impact: ${n} stored flow row(s) hold the name of a flow a package ships, so the packaged ` + + 'flow runs and the stored one never does (ADR-0126 §2: a packaged base is never overridden). Nothing was ' + + 'changed. Fix: save the stored flow under a new name, which makes it a flow of its own, and switch the ' + + 'packaged one off if it must not run (POST /api/v1/automation//toggle with { "enabled": false }).', + withheldWebhooks: (n) => + `[security] upgrade impact: ${n} sys_webhook row(s) hold the name of a webhook a package declares, so the row ` + + 'answers and the declared webhook is withheld for the whole deployment. Nothing was changed. Fix: rename or ' + + 'delete the row to let the declaration answer; to keep the row as well, rename it, and switch the declared ' + + 'one off if it must not fire (POST /api/v1/security/_activation/webhook/ with { "enabled": false }).', + overlayOrphans: (n) => + `[security] upgrade impact: ${n} customization(s) of a view, dashboard, report, translation or email template ` + + 'are bound to a package that no longer ships the item, so each is served as an item of its own beside ' + + 'whatever replaced it. Nothing was changed. Fix: if the item was renamed, customize the new name and delete ' + + 'the orphaned row (DELETE /api/v1/meta//?package=); if it was removed, delete the row or ' + + 'keep it as it is.', + fieldCollisions: (n) => + `[security] upgrade impact: ${n} object field(s) are declared both by an object extension and by the layer it ` + + 'extends or another package\'s extension, and the last declaration replaces the others, so the field is ' + + 'served with that one\'s definition. Read as "declared twice", not "conflicting": a review item. Nothing was ' + + 'changed. Fix: rename the extension\'s field if it was not meant to replace the other.', + refusedObjectRows: (n) => + `[security] upgrade impact: ${n} stored object row(s) are bound to a package that does not own the object, ` + + 'because another package ships that name, so the row is not served and the object answers with the owning ' + + "package's definition (ADR-0029 D9.9). Nothing was changed. Fix: move the customization into an object of " + + 'its own name, or bind it to the owning package; the row stays readable and deletable through the metadata API.', + relocatedNavContributions: (n) => + `[security] upgrade impact: ${n} navigation contribution(s) name a group their app no longer has, so their ` + + "items were moved to the app's top level. Nothing was changed. Fix: point the contribution at a group the " + + 'app ships.', + settingOrphans: (n) => + `[security] upgrade impact: ${n} stored setting key(s) are no longer declared by their namespace, so the ` + + "stored value is never read and the key's default applies. Nothing was changed. Fix: if the key was renamed, " + + 'set the new key to the value you meant through the settings page; the old row has no effect.', +}; + +function logReport(report: UpgradeImpactReport, logger?: UpgradeReportLogger): void { + if (report.stopped) { + logger?.warn( + `[security] the upgrade impact report stopped: ${report.stoppedReading} could not be read, and an unread ` + + 'answer is never taken for "resolves nowhere". It reports nothing for this boot and runs again on the next.', + { stop: report.stopped, error: report.stoppedError }, + ); + return; + } + for (const key of FINDING_KEYS) { + const items: readonly unknown[] = key === 'orphans' + ? report.orphans.filter((o) => !report.reEnablements.some((r) => r.type === o.type && r.name === o.name)) + : report[key]; + if (items.length === 0) continue; + logError(logger, CATEGORY_LINES[key](items.length), { count: items.length, [key]: listed(items) }); + } + if (!hasUpgradeImpactFindings(report)) { + logger?.info?.( + '[security] upgrade impact report: every flow, action and webhook switch, stored metadata row, object ' + + 'extension, navigation contribution and stored setting matches the registry at this boot', + { absent: report.absent }, + ); + } +} + +/** What the boot report did with its findings: `clean` or `stopped`, else the delivery's own answer. */ +export interface UpgradeImpactDelivery extends Omit { + outcome: 'clean' | 'stopped' | UpgradeReportDelivery['outcome']; + /** The finding set's digest — the notification's `dedupKey`. */ + findingSet?: string; +} + +/** Why only the operator can act on these findings under a wall: the delivery's operator line. */ +const UPGRADE_IMPACT_OPERATOR_REASON = + 'the activation ledger, the stored metadata and the installed packages are shared by every organization — so ' + + 'only the operator can act on them; a stored setting or webhook row names its organization, so the operator ' + + 'can tell it.'; + +/** + * The boot entry: collect, log, and raise the findings to the people who can + * act on them, through `upgrade-report-delivery.ts`. Never throws for a read + * or a delivery that did not happen. + */ +export async function reportUpgradeImpact( + engine: UpgradeImpactEngine, + deps: UpgradeImpactBootDeps, +): Promise<{ report: UpgradeImpactReport; delivery: UpgradeImpactDelivery }> { + const report = await collectUpgradeImpactFindings(engine, deps); + logReport(report, deps.logger); + if (report.stopped) return { report, delivery: { outcome: 'stopped', recipients: [] } }; + if (!hasUpgradeImpactFindings(report)) return { report, delivery: { outcome: 'clean', recipients: [] } }; + + const counts = upgradeImpactCounts(report); + const delivery = await deliverUpgradeReport(engine, deps, { + topic: UPGRADE_IMPACT_TOPIC, + findingSet: upgradeImpactDedupKey(report), + subject: 'the upgrade impact findings above', + operatorReason: UPGRADE_IMPACT_OPERATOR_REASON, + payload: { + title: 'An upgrade changed what your customizations cover', + body: + 'A package upgrade renamed, removed or collided with a flow, action, webhook, view, dashboard, report, ' + + 'translation, email template, object, navigation group or setting your customizations name. ' + + (counts.reEnablements > 0 + ? `${counts.reEnablements} item(s) you switched off may be active again under a new name. ` + : '') + + 'Nothing was changed. The server log lines "[security] upgrade impact" list each finding with its fix.', + counts, + findings: upgradeImpactSummary(report), + }, + }); + return { report, delivery }; +} diff --git a/packages/plugins/plugin-security/src/upgrade-impact-report.wiring.test.ts b/packages/plugins/plugin-security/src/upgrade-impact-report.wiring.test.ts new file mode 100644 index 00000000000..a4913baae5f --- /dev/null +++ b/packages/plugins/plugin-security/src/upgrade-impact-report.wiring.test.ts @@ -0,0 +1,241 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#24447] The upgrade impact report as `SecurityPlugin` wires it: a REAL + * `ObjectQL` engine over the SQL driver, two code packages registered through + * the engine's own manifest path (so every artifact carries the stamps a boot + * gives it), the real plugin started on it, and the `kernel:bootstrapped` + * handler the plugin registered fired by the test. The ledger rows, the stored + * metadata rows, the webhook row and the setting row are real rows; the + * registry is the engine's; `messaging` and `settings` are the services the + * plugin looks up by name. + * + * Pins the acceptance end to end, every class at once: the vendor package + * after its upgrade (a flow renamed, a dashboard renamed, an action added to a + * second object, a webhook and an object declared under names the customer + * holds, a field an extension already declares, a navigation group dropped, a + * settings key renamed) reaches the administrator under `single` and the + * operator alone under a wall; the same customer state before the upgrade + * (CONTROL) raises nothing. + */ + +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 { createLogger, resetPlatformAdminEmailMemo } from '@objectstack/core'; +import { SysMember, SysOrganization, SysUser } from '@objectstack/platform-objects/identity'; +import { SysMetadataActivation, SysPlatformSetting, SysSetting } from '@objectstack/platform-objects/system'; + +import { SecurityPlugin } from './security-plugin.js'; +import { SysUserPermissionSet } from './objects/sys-user-permission-set.object.js'; +import { UPGRADE_IMPACT_TOPIC } from './upgrade-impact-report.js'; + +const SYS = { isSystem: true } as const; +const POSTURE_ENV = 'OS_TENANCY_POSTURE'; +const OWNER_ENV = 'OS_PLATFORM_OWNER_EMAIL'; +const OPERATOR = 'operator@u3.example'; +const VENDOR = 'com.acme.u3'; +const ADDON = 'com.acme.u3addon'; +const CUSTOMER = 'app.u3_customer'; +const DEFAULT_ORG = 'org_u3_default'; + +const engines: ObjectQL[] = []; +afterEach(async () => { + vi.restoreAllMocks(); + delete process.env[POSTURE_ENV]; + delete process.env[OWNER_ENV]; + resetPlatformAdminEmailMemo(); + while (engines.length) { + try { await engines.pop()?.destroy(); } catch { /* noop */ } + } +}); + +/** A minimal `sys_webhook`: plugin-webhooks is not a dependency of this package, and the report reads two columns. */ +const SysWebhookStub = { name: 'sys_webhook', label: 'Webhook', fields: { name: { type: 'text', label: 'Name' } } }; + +const text = (label: string) => ({ type: 'text', label }); +const action = (name: string) => ({ name, label: name, type: 'script', target: 'noop' }); + +/** The vendor package, before or after its upgrade. */ +function vendorManifest(upgraded: boolean) { + return { + id: VENDOR, name: 'u3_vendor', version: upgraded ? '2.0.0' : '1.0.0', type: 'app', scope: 'system', + objects: [ + { name: 'u3_account', label: 'Account', fields: { title: text('Title'), ...(upgraded ? { rating: text('Rating') } : {}) } }, + { name: 'u3_lead', label: 'Lead', fields: { title: text('Title') }, actions: [action('u3_convert')] }, + { name: 'u3_contact', label: 'Contact', fields: { title: text('Title') }, actions: upgraded ? [action('u3_convert')] : [] }, + ...(upgraded ? [{ name: 'u3_invoice', label: 'Invoice', fields: { title: text('Title') } }] : []), + ], + flows: [ + { name: upgraded ? 'u3_lead_routing' : 'u3_lead_router', label: 'Route', type: 'autolaunched', nodes: [], edges: [] }, + { name: 'u3_nightly', label: 'Nightly', type: 'autolaunched', nodes: [], edges: [] }, + ], + dashboards: [{ name: upgraded ? 'u3_pipeline_v2' : 'u3_pipeline', label: 'Pipeline', widgets: [] }], + webhooks: upgraded ? [{ name: 'u3_deal_won', object: 'u3_lead', url: 'https://hooks.u3.example/won', triggers: ['create'] }] : [], + apps: [{ + name: 'u3_crm', label: 'CRM', + navigation: upgraded ? [] : [{ id: 'u3_tools', type: 'group', label: 'Tools', children: [] }], + }], + }; +} + +/** The add-on: an extension of the vendor's account, and a navigation contribution into its app. */ +const ADDON_MANIFEST = { + id: ADDON, name: 'u3_addon', version: '1.0.0', type: 'app', scope: 'system', + objectExtensions: [{ extend: 'u3_account', fields: { rating: text('Addon rating') } }], + navigationContributions: [{ app: 'u3_crm', group: 'u3_tools', items: [{ id: 'u3_addon_quotes', type: 'url', label: 'Quotes', url: '/quotes' }] }], +}; + +async function boot(posture: 'single' | 'isolated', upgraded: boolean) { + const walled = posture !== 'single'; + if (walled) process.env[POSTURE_ENV] = posture; + process.env[OWNER_ENV] = OPERATOR; + resetPlatformAdminEmailMemo(); + + 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.upgrade-impact-report', name: 'Upgrade impact report', version: '1.0.0', + type: 'plugin', scope: 'system', + objects: [ + SysOrganization, SysUser, SysMember, SysUserPermissionSet, SysMetadataActivation, SysSetting, SysPlatformSetting, + SysMetadataObject, SysMetadataHistoryObject, SysMetadataCommitObject, SysMetadataAuditObject, SysWebhookStub, + ], + } as any); + engine.registerApp(vendorManifest(upgraded) as any); + engine.registerApp(ADDON_MANIFEST as any); + await engine.syncSchemas(); + engines.push(engine); + + const metadata = { get: async () => null, list: async () => [] }; + const protocol = new ObjectStackProtocolImplementation(engine as never, () => new Map(), undefined); + const emit = vi.fn(async () => ({ notificationId: 'n1' })); + // The settings registry after (or before) the upgrade renamed `sender_address`. + const settings = { + listManifests: () => [{ namespace: 'mail', specifiers: [{ key: upgraded ? 'from_address' : 'sender_address', type: 'text' }] }], + }; + const services: Record = { + manifest: { register: vi.fn() }, + objectql: engine, + metadata, + protocol, + messaging: { emit }, + settings, + sharingRules: { listDeclaredRules: async () => [] }, + ...(walled ? { 'org-scoping': { name: 'com.objectstack.org-scoping' }, tenancy: { posture } } : {}), + }; + const logger = { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }; + const hooks = new Map Promise>>(); + const ctx: any = { + logger, + hook: (event: string, handler: () => Promise) => { + if (!hooks.has(event)) hooks.set(event, []); + hooks.get(event)!.push(handler); + }, + registerService: vi.fn(), + 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); + await plugin.start(ctx); + vi.spyOn((engine as any).logger, 'warn').mockImplementation(() => undefined); + + // The customer's state, written before the upgrade and the same in both runs. + await engine.insert('sys_organization', { id: DEFAULT_ORG, name: 'Default', slug: 'default' }, { context: SYS } as any); + await engine.insert('sys_user_permission_set', { + id: 'g_u3_admin', user_id: 'usr_u3_admin', permission_set: 'admin_full_access', organization_id: DEFAULT_ORG, + }, { context: { isSystem: true, tenantId: DEFAULT_ORG } } as any); + for (const row of [ + { id: 'act_u3_flow', metadata_type: 'flow', name: 'u3_lead_router', package_id: VENDOR, active: false }, + { id: 'act_u3_action', metadata_type: 'action', name: 'u3_convert', package_id: VENDOR, active: false }, + ]) await engine.insert('sys_metadata_activation', row, { context: SYS } as any); + for (const row of [ + { id: 'md_u3_dashboard', type: 'dashboard', name: 'u3_pipeline', package_id: VENDOR, metadata: '{}' }, + { id: 'md_u3_flow', type: 'flow', name: 'u3_lead_routing', package_id: null, metadata: '{}' }, + { id: 'md_u3_object', type: 'object', name: 'u3_invoice', package_id: CUSTOMER, metadata: '{}' }, + ]) await engine.insert('sys_metadata', { ...row, state: 'active', scope: 'platform' }, { context: SYS } as any); + await engine.insert('sys_webhook', { id: 'wh_u3', name: 'u3_deal_won' }, { context: { isSystem: true, tenantId: DEFAULT_ORG } } as any); + await engine.insert('sys_setting', { + id: 'set_u3', namespace: 'mail', key: 'sender_address', scope: 'tenant', organization_id: DEFAULT_ORG, value: '"ops@u3.example"', + }, { context: { isSystem: true, tenantId: DEFAULT_ORG } } as any); + + for (const handler of hooks.get('kernel:bootstrapped') ?? []) await handler(); + const upgradeInput = (emit.mock.calls as any[]).map(([input]) => input).find((input) => input.topic === UPGRADE_IMPACT_TOPIC); + return { emit, logger, upgradeInput }; +} + +const lines = (logger: { error: { mock: { calls: any[] } } }) => logger.error.mock.calls.map(([m]: any[]) => String(m)); + +/** The error lines replayed through the core logger, as a deployment writes them — redaction included. */ +function written(logger: { error: { mock: { calls: any[] } } }): string[] { + const out: string[] = []; + const stderr = vi.spyOn(process.stderr, 'write').mockImplementation((chunk: any) => (out.push(String(chunk)), true)); + try { + const core = createLogger({ format: 'json' }); + for (const [message, error, meta] of logger.error.mock.calls) core.error(message, error, meta); + } finally { + stderr.mockRestore(); + } + return out; +} + +describe('[#24447] the upgrade impact report, wired at kernel:bootstrapped', () => { + it('single: the upgrade\'s every non-access impact reaches the administrator, under its own topic', async () => { + const { upgradeInput, logger } = await boot('single', true); + expect(upgradeInput).toBeDefined(); + expect({ severity: upgradeInput.severity, audience: upgradeInput.audience, organizationId: upgradeInput.organizationId }) + .toEqual({ severity: 'warning', audience: [OPERATOR, 'usr_u3_admin'], organizationId: DEFAULT_ORG }); + const findings = upgradeInput.payload.findings; + expect(findings.reEnablements).toEqual([ + { type: 'flow', name: 'u3_lead_router', packageId: VENDOR, candidates: ['u3_lead_routing', 'u3_nightly'] }, + ]); + expect(findings.sharedActionSwitches).toEqual([{ name: 'u3_convert', packageId: VENDOR, objects: ['u3_contact', 'u3_lead'] }]); + expect(findings.shadowedFlows).toEqual([{ name: 'u3_lead_routing', rowId: 'md_u3_flow', rowPackageId: null, packageId: VENDOR }]); + expect(findings.withheldWebhooks).toEqual([{ name: 'u3_deal_won', packageId: VENDOR, rowId: 'wh_u3', organizationId: DEFAULT_ORG }]); + expect(findings.overlayOrphans).toEqual([{ type: 'dashboard', name: 'u3_pipeline', rowId: 'md_u3_dashboard', packageId: VENDOR }]); + expect(findings.fieldCollisions).toEqual([{ + object: 'u3_account', field: 'rating', declaredBy: [{ packageId: VENDOR, layer: 'own' }, { packageId: ADDON, layer: 'extend' }], + }]); + expect(findings.refusedObjectRows).toEqual([{ name: 'u3_invoice', rowId: 'md_u3_object', packageId: CUSTOMER, ownerPackageId: VENDOR }]); + expect(findings.relocatedNavContributions).toEqual([{ app: 'u3_crm', group: 'u3_tools', packageId: ADDON, relocated: ['u3_addon_quotes'] }]); + expect(findings.settingOrphans).toEqual([{ store: 'sys_setting', namespace: 'mail', name: 'sender_address', rows: 1, organizations: [DEFAULT_ORG] }]); + expect(JSON.stringify(upgradeInput)).not.toContain('ops@u3.example'); + expect(lines(logger).filter((m) => m.startsWith('[security] upgrade impact:'))).toHaveLength(9); + }); + + it('the logged findings keep every value through the core logger\'s redaction — a setting key is named, not hidden', async () => { + const { logger } = await boot('single', true); + const out = written(logger).filter((l) => l.includes('[security] upgrade impact:')); + expect(out).toHaveLength(9); + expect(out.filter((l) => l.includes('***REDACTED***'))).toEqual([]); + expect(out.find((l) => l.includes('stored setting key(s)'))).toContain('"name":"sender_address"'); + }); + + it('isolated: the same findings are raised to the operator alone, in the log — no upgrade impact notification', async () => { + const { upgradeInput, logger } = await boot('isolated', true); + expect(upgradeInput).toBeUndefined(); + const operator = logger.error.mock.calls.find(([m]: any[]) => String(m).includes('the upgrade impact findings above are deployment-level')); + expect(operator?.[2]?.operators).toEqual([OPERATOR]); + expect(lines(logger).filter((m) => m.startsWith('[security] upgrade impact:'))).toHaveLength(9); + }); + + for (const posture of ['single', 'isolated'] as const) { + it(`${posture} CONTROL: the same customer state before the upgrade — nothing raised, one clean line`, async () => { + const { upgradeInput, logger } = await boot(posture, false); + expect(upgradeInput).toBeUndefined(); + expect(lines(logger).filter((m) => m.includes('upgrade impact'))).toEqual([]); + expect(logger.info.mock.calls.some(([m]: any[]) => String(m).includes('[security] upgrade impact report: every flow'))).toBe(true); + }); + } +});