From 07d0ee185bab5558df98e7284846a301d1e31ed7 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 23 Oct 2026 15:44:56 +0000 Subject: [PATCH] feat(plugin-security): under a walled posture, raise the access upgrade report in-app in the organization OS_PLATFORM_OWNER_ORGANIZATION names Under group/isolated the boot access upgrade report reached the operator only as one error line. OS_PLATFORM_OWNER_ORGANIZATION (an id or slug, read only under a wall) now names the operators' own organization: the report emits the same notification, same dedupKey, stamped with it and addressed to the OS_PLATFORM_OWNER_EMAIL operators, beside the operator line. A value naming no organization logs one error and raises nothing in-app. A configured operator who is not a verified member of it, and an administrator of it who is not a configured operator, each get a warn. Unset, and under single, nothing changes. The walled and single delivery moves into upgrade-report-delivery.ts so the non-access report (#24447) can call it. Claude-Session: https://claude.ai/code/session_01XHebWJmL3ZgbVpjM6JERNA Co-authored-by: Claude --- ...alled-upgrade-report-owner-organization.md | 19 + .../docs/deployment/environment-variables.mdx | 1 + .../src/access-upgrade-report.ts | 211 ++------ .../src/access-upgrade-report.wiring.test.ts | 58 ++- .../src/auto-org-admin-grant.ts | 3 +- .../src/upgrade-report-delivery.test.ts | 280 +++++++++++ .../src/upgrade-report-delivery.ts | 461 ++++++++++++++++++ packages/types/src/env.test.ts | 28 ++ packages/types/src/env.ts | 39 ++ 9 files changed, 936 insertions(+), 164 deletions(-) create mode 100644 .changeset/25413-walled-upgrade-report-owner-organization.md create mode 100644 packages/plugins/plugin-security/src/upgrade-report-delivery.test.ts create mode 100644 packages/plugins/plugin-security/src/upgrade-report-delivery.ts diff --git a/.changeset/25413-walled-upgrade-report-owner-organization.md b/.changeset/25413-walled-upgrade-report-owner-organization.md new file mode 100644 index 00000000000..72b88c826da --- /dev/null +++ b/.changeset/25413-walled-upgrade-report-owner-organization.md @@ -0,0 +1,19 @@ +--- +'@objectstack/types': patch +'@objectstack/plugin-security': patch +--- + +feat: under a walled posture, `OS_PLATFORM_OWNER_ORGANIZATION` raises the access upgrade report in-app in the platform operators' own organization, beside the operator log line + +Clause-②: yes (widening: a new optional environment variable, `PLATFORM_OWNER_ORGANIZATION_ENV` and `resolvePlatformOwnerOrganization()` exported from `@objectstack/types`; unset, every posture behaves as before) +Metadata-protocol: none + +Under `group` and `isolated` the boot access upgrade report reached the operator only as one `error` line in the server log. The notification family is organization-owned, a walled system write that names no organization is refused (ADR-0131 D9), and the operator belongs to no organization by default. + +- **New setting:** `OS_PLATFORM_OWNER_ORGANIZATION`, the id or slug of the operators' own organization, beside `OS_PLATFORM_OWNER_EMAIL`. It is read only under a walled posture. +- **What it does:** with it set, the report emits the same `security.access_upgrade` notification, with the same `dedupKey` per finding set, stamped with that organization and addressed to the `OS_PLATFORM_OWNER_EMAIL` operators. It lands in that organization's Notification Events and the operators' inbox, and in no other organization. The operator line stays beside it. +- **Use a dedicated organization that holds the operators only.** Every administrator of it can read the notification in Setup, and the findings can name other organizations' ids and rule names. Boot logs a `warn` for a configured operator who is not a verified member of it. It logs another `warn` for an administrator of it who is not a configured operator: an `owner` or `admin` member, or an `admin_full_access` or organization-admin grant scoped to it. +- **A value naming no organization** logs one `error` naming the value and the fix, and raises nothing in-app. It never falls back to another organization. +- **Unchanged:** unset, the operator line is the only channel, worded as before. Under `single` the setting is ignored, and the Default Organization's administrators are told as before. + +The walled and `single` delivery now lives in one helper, `upgrade-report-delivery.ts`, so the non-access upgrade report (#24447) can raise its findings the same way. diff --git a/content/docs/deployment/environment-variables.mdx b/content/docs/deployment/environment-variables.mdx index 351c3cbc0ac..744c6f15ca5 100644 --- a/content/docs/deployment/environment-variables.mdx +++ b/content/docs/deployment/environment-variables.mdx @@ -102,6 +102,7 @@ Read by `@objectstack/service-print` when `PrintServicePlugin` is composed; see | `OS_MULTI_ORG_ENABLED` | boolean | `false` | Superseded by `OS_TENANCY_POSTURE`, and still honoured: `true` selects the `isolated` posture. When `true`, organization creation/switching UI is exposed. | | `OS_AUTOMATION_SCHEDULED_WORK_ENABLED` | boolean | `false` | Whether this deployment runs **package-authored scheduled work**: time-triggered flows (a `type: 'schedule'` flow with a `config.schedule` cadence, and the `timeRelative` sweep) and packaged `defineJob` cron jobs. **OFF by default in every posture and every kernel** — a clock-driven workload's cost is a fact about the deployment, not about the flow, so an author cannot decide it and no metadata key asks them to. Accepts `true`/`1`/`on`/`yes` (case-insensitive); anything else, including unset, is off. While it is off nothing arms, and every such flow is listed by `getTriggerBindingAudit()` and the CLI startup summary as **disabled by deployment policy** — never as a binding failure, and nothing about the flow needs fixing. While it is on, what a time-triggered flow owes depends on the posture: under `isolated` it must declare `config.organization` on its start node or it is not armed; under `group` the declaration is optional and an undeclared sweep reads group-wide with each run acting as its own swept record's organization (a record-less cron there carries none, and its tenant-scoped writes are refused — declare one if it writes); under `single` it needs no declaration and its runs carry no organization. ⛔ Platform-internal scheduled work — approvals escalation, the lifecycle Reaper, the messaging dispatch loop, membership backfill — is **not** gated by this: the boundary is *authored by a package*, not *runs on the job service*. `os doctor` prints the effective value. | | `OS_PLATFORM_OWNER_EMAIL` | csv | — | The deployment's platform administrators: one email address, or a comma-separated list. A caller resolves `PLATFORM_ADMIN` when their own stored `sys_user` row carries a declared address **and** reads email-verified. Comparison is trimmed and case-insensitive; duplicates collapse and blank entries are dropped. One **unparseable** entry refuses the **whole** variable rather than just that entry — the deployment then has zero configured administrators, loudly — because a silently narrower administrator set is the worse failure. **Required under the walled postures** (`group` / `isolated`): unset or blank there **refuses to boot**, since the first self-registrant is not promoted and no grant row is written. Unset under `single` is normal — that posture still promotes the first human account. Read live per resolution, so revocation is a config change plus a process reload; there is no runtime endpoint that changes it. See [First boot: create the admin](/docs/deployment/self-hosting#first-boot-create-the-admin). | +| `OS_PLATFORM_OWNER_ORGANIZATION` | string | — | Walled postures only (`group` / `isolated`): the id or slug of the platform operators' own organization. The boot upgrade reports raise deployment-level findings, such as the access upgrade report, as one `error` line addressed to the operator. With this set they are also raised in-app: one notification stamped with that organization and addressed to the `OS_PLATFORM_OWNER_EMAIL` operators, deduplicated per finding set across boots. No other organization is told. **Use a dedicated organization that holds the operators only**: every administrator of it can read the notification in Setup, Notification Events, and the findings can name other organizations' ids and rule names. Boot logs a `warn` for a configured operator who is not a verified member of it, and for an administrator of it who is not a configured operator. A value that names no organization logs one `error` naming the value and raises nothing in-app; it never falls back to another organization. Unset, the log line is the only channel. Ignored under `single`, where the Default Organization's administrators are told. | | `OS_OIDC_PROVIDER_ENABLED` | boolean | tracks MCP | When `true`, expose this instance as an OIDC identity provider. When unset it follows the MCP server surface (`OS_MCP_SERVER_ENABLED`, on by default) — the MCP human-client track is OAuth 2.1, so every MCP-enabled deployment is its own authorization server. | | `OS_COOKIE_DOMAIN` | string | — | Cookie domain for cross-subdomain session sharing (e.g. `.example.com`). | | `OS_TRUSTED_ORIGINS` | csv | — | Comma-separated list of origins permitted for auth callbacks / CSRF. | diff --git a/packages/plugins/plugin-security/src/access-upgrade-report.ts b/packages/plugins/plugin-security/src/access-upgrade-report.ts index 7c40d60d294..f86b30b022a 100644 --- a/packages/plugins/plugin-security/src/access-upgrade-report.ts +++ b/packages/plugins/plugin-security/src/access-upgrade-report.ts @@ -77,23 +77,21 @@ * ## Who is told * * The log, always, once per category. And the people who can act on the - * choice: + * choice, through `upgrade-report-delivery.ts`: * * - under `single`, through the existing notification ingress - * (`messaging.emit()`, ADR-0030: a `sys_notification` event in Setup's - * Notification Events, and the in-app inbox), to the Default - * Organization's `admin_full_access` holders and any - * `OS_PLATFORM_OWNER_EMAIL` address. The notification's `dedupKey` is the - * finding set's digest, so an unchanged set notifies once, not once per - * boot. No messaging service, nobody to tell, or a refused emit: one log - * line says so, with the fix for that cause; - * - under a wall (`group`, `isolated`), the operator alone, by one `error` - * line addressed to the operator. The ledger and the catalog are - * deployment-level and only the operator may switch or clone there - * (ADR-0126 §5, ADR-0131 D3); the notification family is - * organization-scoped, so a walled write with no organization is refused - * (ADR-0131 D9), and stamping an organization would tell a tenant. No - * notification is attempted. + * (`messaging.emit()`, ADR-0030), to the Default Organization's + * `admin_full_access` holders and any `OS_PLATFORM_OWNER_EMAIL` address. + * The notification's `dedupKey` is the finding set's digest, so an + * unchanged set notifies once, not once per boot; + * - under a wall (`group`, `isolated`), the operator alone. The ledger and + * the catalog are deployment-level and only the operator may switch or + * clone there (ADR-0126 §5, ADR-0131 D3). One `error` line addressed to the + * operator is written. When `OS_PLATFORM_OWNER_ORGANIZATION` names an + * organization, the same notification is also raised in-app there, + * addressed to the operators (#25413). Otherwise no notification is + * attempted: a walled write with no organization is refused (ADR-0131 D9), + * and stamping a tenant's organization would tell that tenant. * * ## When it runs * @@ -104,14 +102,16 @@ import { createHash } from 'node:crypto'; import { METADATA_ACTIVATION_TABLE, - readDefaultOrganizationId, - resolvePlatformAdminEmails, type SecurityCatalogEntry, type SecurityCatalogReader, } from '@objectstack/core'; import { isTenantAuthored } from '@objectstack/metadata-core'; -import { ADMIN_FULL_ACCESS } from '@objectstack/spec/identity'; -import { GRANT_OBJECT, GRANT_SET_NAME_FIELD } from './grant-permission-set-name.js'; +import { + deliverUpgradeReport, + type UpgradeReportDelivery, + type UpgradeReportLogger, + type UpgradeReportNotifier, +} from './upgrade-report-delivery.js'; /** The notification topic the report is raised on. */ export const ACCESS_UPGRADE_TOPIC = 'security.access_upgrade'; @@ -549,63 +549,19 @@ export function hasAccessUpgradeFindings(report: AccessUpgradeReport): boolean { // ───────────────────────────────────────────────────────────────────────────── /** The kernel logger, as this module uses it. */ -export interface AccessUpgradeReportLogger { - info?: (message: string, meta?: Record) => void; - warn: (message: string, meta?: Record) => void; - error?: (message: string, cause?: Error, meta?: Record) => void; -} +export type AccessUpgradeReportLogger = UpgradeReportLogger; /** The notification ingress, as this module uses it — service-messaging's `emit`. */ -export interface AccessUpgradeNotifier { - emit(input: { - topic: string; - audience: readonly string[]; - severity: 'warning'; - dedupKey: string; - organizationId?: string; - payload: Record; - }): Promise; -} +export type AccessUpgradeNotifier = UpgradeReportNotifier; export interface AccessUpgradeBootDeps extends AccessUpgradeReportDeps { logger?: AccessUpgradeReportLogger; /** The `messaging` service; absent when the composition has none. */ messaging?: AccessUpgradeNotifier; - /** `true` under `group` / `isolated`: the operator alone is told, in the log. */ + /** `true` under `group` / `isolated`: the operator alone is told (see `upgrade-report-delivery.ts`). */ walled: boolean; } -/** Who the report is delivered to, and the organization a `single` delivery is stamped with. */ -export interface AccessUpgradeAudience { - recipients: string[]; - organizationId?: string; -} - -/** - * The people who can act on an access choice under `single`: any configured - * platform operator and every holder of the Default Organization's - * `admin_full_access` grant. A read that throws propagates. (Under a wall no - * audience is resolved: see {@link reportAccessUpgrade}.) - */ -export async function resolveAccessUpgradeAudience( - engine: AccessUpgradeReportEngine, -): Promise { - const recipients = [...resolvePlatformAdminEmails().emails]; - const organizationId = await readDefaultOrganizationId(engine); - if (organizationId === undefined || !engine.getObject(GRANT_OBJECT)) return { recipients }; - const grants = await engine.find(GRANT_OBJECT, { - where: { [GRANT_SET_NAME_FIELD]: ADMIN_FULL_ACCESS, organization_id: organizationId }, - fields: ['user_id'], - limit: 10000, - context: SYSTEM_CTX, - }); - for (const row of Array.isArray(grants) ? grants : []) { - const userId = nameOf((row as Record)?.user_id); - if (userId !== undefined && !recipients.includes(userId)) recipients.push(userId); - } - return { recipients, organizationId }; -} - 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 } @@ -706,29 +662,22 @@ function logReport(report: AccessUpgradeReport, logger?: AccessUpgradeReportLogg } } -/** What the boot report did with its findings. */ -export interface AccessUpgradeDelivery { - /** - * `notified`: handed to the ingress (`single`). `operator-log`: walled, so - * the operator's line in this log is the channel. `undelivered`: `single` - * findings that reached no administrator, said in the log with the cause. - */ - outcome: 'clean' | 'stopped' | 'notified' | 'operator-log' | 'undelivered'; - recipients: string[]; +/** What the boot report did with its findings: `clean` or `stopped`, else the delivery's own answer. */ +export interface AccessUpgradeDelivery extends Omit { + outcome: 'clean' | 'stopped' | UpgradeReportDelivery['outcome']; /** The finding set's digest — the notification's `dedupKey`. */ findingSet?: string; - reason?: string; } +/** Why only the operator can act on access findings under a wall: the delivery's operator line. */ +const ACCESS_OPERATOR_REASON = + 'the activation ledger and the security catalog are shared by every organization — so only the operator can act ' + + 'on them, through the activation and clone doors.'; + /** * The boot entry: collect, log, and raise the findings to the people who can - * act on them. Never throws for a read or a delivery that did not happen. - * - * Under a wall the notification is never attempted: the findings are - * deployment-level, the operator alone can act on them, and the notification - * family is organization-scoped, so a walled system write with no organization - * is refused (ADR-0131 D9) and stamping one would hand the finding to a tenant. - * One `error` line addressed to the operator is the channel there. + * act on them, through `upgrade-report-delivery.ts`. Never throws for a read + * or a delivery that did not happen. */ export async function reportAccessUpgrade( engine: AccessUpgradeReportEngine, @@ -739,55 +688,6 @@ export async function reportAccessUpgrade( if (report.stopped) return { report, delivery: { outcome: 'stopped', recipients: [] } }; if (!hasAccessUpgradeFindings(report)) return { report, delivery: { outcome: 'clean', recipients: [] } }; - const findingSet = accessUpgradeDedupKey(report); - if (deps.walled) { - const operators = [...resolvePlatformAdminEmails().emails]; - logError( - deps.logger, - '[security] platform operator: the access upgrade findings above are deployment-level — the activation ledger ' + - 'and the security catalog are shared by every organization — so only the operator can act on them, through ' + - 'the activation and clone doors. This tenancy posture has no in-app channel for a deployment-level ' + - 'notification, so this log is where they are raised; no organization is told. ' + - (operators.length > 0 - ? 'The configured operator(s) are named in this line.' - : 'No operator is configured: set OS_PLATFORM_OWNER_EMAIL to name the platform operator.'), - { findingSet, operators }, - ); - return { report, delivery: { outcome: 'operator-log', recipients: operators, findingSet } }; - } - - const undelivered = ( - reason: string, - fix: string, - recipients: string[] = [], - ): { report: AccessUpgradeReport; delivery: AccessUpgradeDelivery } => { - logError( - deps.logger, - `[security] the access upgrade findings above reached no administrator: ${reason}. They are in this log only. ` + - `Fix: ${fix}`, - { findingSet }, - ); - return { report, delivery: { outcome: 'undelivered', recipients, findingSet, reason } }; - }; - if (!deps.messaging || typeof deps.messaging.emit !== 'function') { - return undelivered('this composition has no messaging service', 'compose the messaging service.'); - } - - let audience: AccessUpgradeAudience; - try { - audience = await resolveAccessUpgradeAudience(engine); - } catch (e) { - return undelivered( - `the administrators could not be read (${errorText(e)})`, - 'restore the store the grants are read from; the next boot reads them again.', - ); - } - if (audience.recipients.length === 0) { - return undelivered( - 'no administrator is configured or granted', - 'grant admin_full_access in the Default Organization, or set OS_PLATFORM_OWNER_EMAIL.', - ); - } const counts = { reEnablements: report.reEnablements.length, orphans: report.orphans.length, @@ -795,32 +695,23 @@ export async function reportAccessUpgrade( brokenReferences: report.brokenReferences.length, uncoveredDenies: report.uncoveredDenies.length, }; - try { - await deps.messaging.emit({ - topic: ACCESS_UPGRADE_TOPIC, - audience: audience.recipients, - severity: 'warning', - dedupKey: findingSet, - ...(audience.organizationId !== undefined ? { organizationId: audience.organizationId } : {}), - payload: { - title: 'An upgrade changed what your access settings cover', - body: - 'A package upgrade renamed, removed or collided with a sharing rule, permission set, position or ' + - 'capability your settings 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 line "[security] access upgrade" lists each finding with its fix.', - counts, - findings: accessUpgradeSummary(report), - }, - }); - } catch (e) { - return undelivered( - `the notification ingress refused it (${errorText(e)})`, - 'repair the cause the ingress names; the next boot raises the same findings again.', - audience.recipients, - ); - } - return { report, delivery: { outcome: 'notified', recipients: audience.recipients, findingSet } }; + const delivery = await deliverUpgradeReport(engine, deps, { + topic: ACCESS_UPGRADE_TOPIC, + findingSet: accessUpgradeDedupKey(report), + subject: 'the access upgrade findings above', + operatorReason: ACCESS_OPERATOR_REASON, + payload: { + title: 'An upgrade changed what your access settings cover', + body: + 'A package upgrade renamed, removed or collided with a sharing rule, permission set, position or ' + + 'capability your settings 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 line "[security] access upgrade" lists each finding with its fix.', + counts, + findings: accessUpgradeSummary(report), + }, + }); + return { report, delivery }; } diff --git a/packages/plugins/plugin-security/src/access-upgrade-report.wiring.test.ts b/packages/plugins/plugin-security/src/access-upgrade-report.wiring.test.ts index d0dcbee799b..7f34067e2ae 100644 --- a/packages/plugins/plugin-security/src/access-upgrade-report.wiring.test.ts +++ b/packages/plugins/plugin-security/src/access-upgrade-report.wiring.test.ts @@ -13,6 +13,13 @@ * the administrator under `single` (a notification) and the operator alone under * a wall (a log line addressed to the operator, no notification); with * the old name still shipped (CONTROL), nobody is told. + * + * [#25413] And under a wall with `OS_PLATFORM_OWNER_ORGANIZATION` naming the + * operator's dedicated organization: one notification stamped with that + * organization and addressed to the operator, beside the operator line, and + * none in any other organization. The same finding set carries the same + * `dedupKey` on the next boot, which is what the ingress deduplicates on. An + * unknown value raises nothing in-app and says so. */ import { afterEach, describe, expect, it, vi } from 'vitest'; @@ -23,7 +30,7 @@ import { SysMetadataObject, SysMetadataHistoryObject, SysMetadataCommitObject, SysMetadataAuditObject, } from '@objectstack/metadata-core'; import { resetPlatformAdminEmailMemo } from '@objectstack/core'; -import { SysOrganization } from '@objectstack/platform-objects/identity'; +import { SysMember, SysOrganization, SysUser } from '@objectstack/platform-objects/identity'; import { SysMetadataActivation } from '@objectstack/platform-objects/system'; import { SecurityPlugin } from './security-plugin.js'; @@ -33,25 +40,30 @@ import { ACCESS_UPGRADE_TOPIC } from './access-upgrade-report.js'; const SYS = { isSystem: true } as const; const POSTURE_ENV = 'OS_TENANCY_POSTURE'; const OWNER_ENV = 'OS_PLATFORM_OWNER_EMAIL'; +const OWNER_ORG_ENV = 'OS_PLATFORM_OWNER_ORGANIZATION'; const OPERATOR = 'operator@u1.example'; const VENDOR = 'com.acme.u1'; const DEFAULT_ORG = 'org_u1_default'; +const OWNER_ORG = 'org_u1_platform'; +const TENANT_ORG = 'org_u1_tenant'; const engines: ObjectQL[] = []; afterEach(async () => { vi.restoreAllMocks(); delete process.env[POSTURE_ENV]; delete process.env[OWNER_ENV]; + delete process.env[OWNER_ORG_ENV]; resetPlatformAdminEmailMemo(); while (engines.length) { try { await engines.pop()?.destroy(); } catch { /* noop */ } } }); -async function boot(posture: 'single' | 'isolated' | 'group', renamed: boolean) { +async function boot(posture: 'single' | 'isolated' | 'group', renamed: boolean, ownerOrganization?: string) { const walled = posture !== 'single'; if (walled) process.env[POSTURE_ENV] = posture; process.env[OWNER_ENV] = OPERATOR; + if (ownerOrganization !== undefined) process.env[OWNER_ORG_ENV] = ownerOrganization; resetPlatformAdminEmailMemo(); const engine = new ObjectQL(); @@ -64,7 +76,7 @@ async function boot(posture: 'single' | 'isolated' | 'group', renamed: boolean) id: 'com.objectstack.qa.access-upgrade-report', name: 'Access upgrade report', version: '1.0.0', type: 'plugin', scope: 'system', objects: [ - SysOrganization, SysUserPermissionSet, SysMetadataActivation, + SysOrganization, SysUser, SysMember, SysUserPermissionSet, SysMetadataActivation, SysMetadataObject, SysMetadataHistoryObject, SysMetadataCommitObject, SysMetadataAuditObject, ], } as any); @@ -110,6 +122,17 @@ async function boot(posture: 'single' | 'isolated' | 'group', renamed: boolean) // The customer's choice: the vendor's set switched off, recorded with its package. await engine.insert('sys_organization', { id: DEFAULT_ORG, name: 'Default', slug: 'default' }, { context: SYS } as any); + if (walled) { + // The operator's dedicated organization, which the operator owns, and a tenant beside it. + await engine.insert('sys_organization', { id: OWNER_ORG, name: 'Platform operations', slug: 'u1-platform-ops' }, { context: SYS } as any); + await engine.insert('sys_organization', { id: TENANT_ORG, name: 'Tenant', slug: 'u1-tenant' }, { context: SYS } as any); + await engine.insert('sys_user', { + id: 'usr_u1_operator', name: 'Operator', email: OPERATOR, email_verified: true, + }, { context: SYS } as any); + await engine.insert('sys_member', { + id: 'mem_u1_operator', organization_id: OWNER_ORG, user_id: 'usr_u1_operator', role: 'owner', + }, { context: { isSystem: true, tenantId: OWNER_ORG } } as any); + } await engine.insert('sys_metadata_activation', { id: 'act_u1', metadata_type: 'permission', name: 'u1_sales_rep', package_id: VENDOR, active: false, }, { context: SYS } as any); @@ -147,6 +170,35 @@ describe('[ADR-0126 §6 wall 3] the access upgrade report, wired at kernel:boots }); } + for (const posture of ['isolated', 'group'] as const) { + it(`${posture} with OS_PLATFORM_OWNER_ORGANIZATION: one notification in the operator's organization, to the operator, beside the operator line`, async () => { + const first = await boot(posture, true, 'u1-platform-ops'); + expect(first.emit).toHaveBeenCalledTimes(1); + const input = (first.emit.mock.calls[0] as any[])[0]; + expect({ topic: input.topic, severity: input.severity, audience: input.audience, organizationId: input.organizationId }) + .toEqual({ topic: ACCESS_UPGRADE_TOPIC, severity: 'warning', audience: [OPERATOR], organizationId: OWNER_ORG }); + expect(input.payload.findings.reEnablements[0].name).toBe('u1_sales_rep'); + const lines = first.logger.error.mock.calls.map(([m, , meta]: any[]) => ({ m: String(m), meta })); + const operator = lines.find((l: any) => l.m.startsWith('[security] platform operator:')); + expect(operator?.m).toContain('A copy is raised in-app in the organization OS_PLATFORM_OWNER_ORGANIZATION names'); + expect(operator?.meta).toMatchObject({ operators: [OPERATOR], organizationId: OWNER_ORG }); + // The dedicated organization holds the operator only: no visibility warning. + expect(first.logger.warn.mock.calls.some(([m]: any[]) => String(m).includes('OS_PLATFORM_OWNER_ORGANIZATION names'))).toBe(false); + + // The next boot, same finding set: the same dedupKey, the ingress's idempotency key. + const second = await boot(posture, true, 'u1-platform-ops'); + expect((second.emit.mock.calls[0] as any[])[0].dedupKey).toBe(input.dedupKey); + }); + } + + it('isolated with an unknown OS_PLATFORM_OWNER_ORGANIZATION: nothing is raised in-app, and the error names the value', async () => { + const { emit, logger } = await boot('isolated', true, 'u1-platform-opz'); + expect(emit).not.toHaveBeenCalled(); + const lines = logger.error.mock.calls.map(([m]: any[]) => String(m)); + expect(lines.some((m: string) => m.includes('"u1-platform-opz" names no organization by id or by slug'))).toBe(true); + expect(lines.find((m: string) => m.startsWith('[security] platform operator:'))).toContain('no organization is told'); + }); + for (const posture of ['single', 'isolated'] as const) { it(`${posture} CONTROL: the switched-off name still ships — nobody is told`, async () => { const { emit, logger } = await boot(posture, false); diff --git a/packages/plugins/plugin-security/src/auto-org-admin-grant.ts b/packages/plugins/plugin-security/src/auto-org-admin-grant.ts index 8b955e51e4b..35a0372fd64 100644 --- a/packages/plugins/plugin-security/src/auto-org-admin-grant.ts +++ b/packages/plugins/plugin-security/src/auto-org-admin-grant.ts @@ -272,7 +272,8 @@ function parseRoles(raw: unknown): string[] { .filter((s) => s.length > 0); } -function isAdminRole(raw: unknown): boolean { +/** An `owner` or `admin` membership grade: the roles this module turns into an organization-admin grant. */ +export function isAdminRole(raw: unknown): boolean { const roles = parseRoles(raw); return roles.includes('owner') || roles.includes('admin'); } diff --git a/packages/plugins/plugin-security/src/upgrade-report-delivery.test.ts b/packages/plugins/plugin-security/src/upgrade-report-delivery.test.ts new file mode 100644 index 00000000000..d32b1454b28 --- /dev/null +++ b/packages/plugins/plugin-security/src/upgrade-report-delivery.test.ts @@ -0,0 +1,280 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#25413] The delivery of a boot upgrade report — pins for the walled + * in-app copy `OS_PLATFORM_OWNER_ORGANIZATION` turns on, each beside a + * CONTROL: the setting resolved by id and by slug, an unknown value, the + * setting unset, a configured operator who cannot see the copy, an + * administrator of the organization who is not an operator, and `single`, + * which never reads the setting. + * + * The engine is an in-memory double of the two reads the delivery makes + * (`find` by equality, `getObject`). The access report's own wiring, on a real + * engine under `isolated` and `group`, is pinned in + * `access-upgrade-report.wiring.test.ts`. + */ + +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { resetPlatformAdminEmailMemo } from '@objectstack/core'; +import { deliverUpgradeReport, type UpgradeReportNotice, type UpgradeReportNotifier } from './upgrade-report-delivery.js'; + +const OWNER_EMAIL_ENV = 'OS_PLATFORM_OWNER_EMAIL'; +const OWNER_ORG_ENV = 'OS_PLATFORM_OWNER_ORGANIZATION'; +const OPERATOR = 'operator@vendor.example'; +const DEFAULT_ORG = 'org_default'; +const OWNER_ORG = 'org_platform'; +const TENANT_ORG = 'org_tenant'; + +afterEach(() => { + delete process.env[OWNER_EMAIL_ENV]; + delete process.env[OWNER_ORG_ENV]; + resetPlatformAdminEmailMemo(); + vi.restoreAllMocks(); +}); + +type Row = Record; + +const OBJECTS = ['sys_organization', 'sys_member', 'sys_user', 'sys_user_permission_set']; + +/** Scalar equality only: a combinator or an operator object is refused, never answered silently wrong. */ +function matches(row: Row, where: Row): boolean { + return Object.entries(where).every(([k, v]) => { + if (k.startsWith('$') || (v !== null && typeof v === 'object')) throw new Error(`fake engine: unsupported where '${k}'`); + return row[k] === v; + }); +} + +function fakeEngine(tables: Record, failOn?: string) { + const getObject = (name: string) => (OBJECTS.includes(name) ? { name, fields: {} } : undefined); + return { + registry: { getObject }, + getObject, + async find(object: string, options: Record): Promise { + if (failOn === object) throw new Error(`${object} is unreachable`); + const where = (options.where ?? {}) as Row; + const rows = (tables[object] ?? []).filter((row) => matches(row, where)); + return rows.slice(0, options.limit ?? rows.length); + }, + }; +} + +/** + * A walled deployment: a tenant organization with its own administrator, and + * the operator's dedicated organization, which the operator (a verified + * account) owns and nobody else administers. + */ +function deployment(edit: (t: Record) => void = () => {}) { + const tables: Record = { + sys_organization: [ + { id: DEFAULT_ORG, slug: 'default' }, + { id: OWNER_ORG, slug: 'platform-ops' }, + { id: TENANT_ORG, slug: 'tenant' }, + ], + sys_user: [ + { id: 'usr_operator', email: OPERATOR, email_verified: true }, + { id: 'usr_tenant_admin', email: 'admin@tenant.example', email_verified: true }, + ], + sys_member: [ + { id: 'm1', organization_id: OWNER_ORG, user_id: 'usr_operator', role: 'owner' }, + { id: 'm2', organization_id: TENANT_ORG, user_id: 'usr_tenant_admin', role: 'owner' }, + ], + sys_user_permission_set: [ + { id: 'g1', user_id: 'usr_tenant_admin', permission_set: 'organization_admin', organization_id: TENANT_ORG }, + { id: 'g2', user_id: 'usr_default_admin', permission_set: 'admin_full_access', organization_id: DEFAULT_ORG }, + ], + }; + edit(tables); + return tables; +} + +const NOTICE: UpgradeReportNotice = { + topic: 'security.access_upgrade', + findingSet: 'security.access_upgrade:0123456789abcdef0123456789abcdef', + subject: 'the access upgrade findings above', + operatorReason: 'the activation ledger and the security catalog are shared by every organization — so only the operator can act on them, through the activation and clone doors.', + payload: { title: 'An upgrade changed what your access settings cover', body: 'Nothing was changed.', counts: { orphans: 1 } }, +}; + +const notifier = () => ({ emit: vi.fn(async (_input: Parameters[0]) => ({ notificationId: 'n1' })) }); +const logger = () => ({ info: vi.fn(), warn: vi.fn(), error: vi.fn() }); + +async function deliver( + tables: Record, + opts: { walled?: boolean; ownerOrganization?: string; failOn?: string; messaging?: { emit: any } | null } = {}, +) { + process.env[OWNER_EMAIL_ENV] = OPERATOR; + if (opts.ownerOrganization !== undefined) process.env[OWNER_ORG_ENV] = opts.ownerOrganization; + resetPlatformAdminEmailMemo(); + const messaging = opts.messaging === null ? undefined : opts.messaging ?? notifier(); + const log = logger(); + const delivery = await deliverUpgradeReport( + fakeEngine(tables, opts.failOn), + { walled: opts.walled ?? true, messaging, logger: log }, + NOTICE, + ); + const errors = log.error.mock.calls.map(([m, , meta]) => ({ m: String(m), meta })); + const warns = log.warn.mock.calls.map(([m, meta]) => ({ m: String(m), meta })); + return { delivery, emit: messaging?.emit, errors, warns }; +} + +const operatorLine = (r: { errors: Array<{ m: string; meta: any }> }) => + r.errors.filter((l) => l.m.startsWith('[security] platform operator:')); + +describe('walled, OS_PLATFORM_OWNER_ORGANIZATION naming a dedicated organization', () => { + for (const [how, value] of [['its id', OWNER_ORG], ['its slug', 'platform-ops'], ['its id with stray spaces', ` ${OWNER_ORG} `]] as const) { + it(`by ${how}: one notification in that organization, to the operator, with the operator line beside it`, async () => { + const r = await deliver(deployment(), { ownerOrganization: value }); + expect(r.emit).toHaveBeenCalledTimes(1); + const input = r.emit!.mock.calls[0][0]; + expect(input).toEqual({ + topic: NOTICE.topic, audience: [OPERATOR], severity: 'warning', dedupKey: NOTICE.findingSet, + organizationId: OWNER_ORG, payload: NOTICE.payload, + }); + expect(r.delivery).toEqual({ outcome: 'notified', recipients: [OPERATOR], findingSet: NOTICE.findingSet, organizationId: OWNER_ORG }); + const lines = operatorLine(r); + expect(lines).toHaveLength(1); + expect(lines[0].m).toContain('A copy is raised in-app in the organization OS_PLATFORM_OWNER_ORGANIZATION names'); + expect(lines[0].meta).toEqual({ findingSet: NOTICE.findingSet, operators: [OPERATOR], organizationId: OWNER_ORG }); + // A dedicated organization the operator owns: neither warning fires. + expect(r.warns).toEqual([]); + expect(r.errors).toHaveLength(1); + }); + } +}); + +describe('walled, the setting unset or naming nothing: never a fallback', () => { + it('unset (the CONTROL for the setting): the operator line only, worded as before — no emit', async () => { + const r = await deliver(deployment()); + expect(r.emit).not.toHaveBeenCalled(); + expect(r.delivery).toEqual({ outcome: 'operator-log', recipients: [OPERATOR], findingSet: NOTICE.findingSet }); + expect(r.errors).toEqual([{ + m: '[security] platform operator: the access upgrade findings above are deployment-level — the activation ' + + 'ledger and the security catalog are shared by every organization — so only the operator can act on them, ' + + 'through the activation and clone doors. This tenancy posture has no in-app channel for a deployment-level ' + + 'notification, so this log is where they are raised; no organization is told. The configured operator(s) ' + + 'are named in this line.', + meta: { findingSet: NOTICE.findingSet, operators: [OPERATOR] }, + }]); + }); + + it('blank counts as unset', async () => { + const r = await deliver(deployment(), { ownerOrganization: ' ' }); + expect(r.emit).not.toHaveBeenCalled(); + expect(r.errors).toHaveLength(1); + }); + + it('a value naming no organization: one error naming the value and the fix, then the operator line — no emit, no other organization told', async () => { + const r = await deliver(deployment(), { ownerOrganization: 'platform-opz' }); + expect(r.emit).not.toHaveBeenCalled(); + expect(r.delivery).toMatchObject({ outcome: 'operator-log', recipients: [OPERATOR], reason: '"platform-opz" names no organization by id or by slug' }); + expect(r.errors).toHaveLength(2); + expect(r.errors[0].m).toBe( + '[security] OS_PLATFORM_OWNER_ORGANIZATION is set, but the access upgrade findings above are not raised ' + + 'in-app: "platform-opz" names no organization by id or by slug. The operator line below is the only channel ' + + 'for this boot. Fix: set OS_PLATFORM_OWNER_ORGANIZATION to the id or slug of an existing organization that ' + + 'holds the platform operators only, or unset it.', + ); + expect(r.errors[0].meta).toEqual({ findingSet: NOTICE.findingSet, value: 'platform-opz' }); + expect(operatorLine(r)[0].m).toContain('no organization is told'); + }); + + it('CONTROL for the unknown value: the same deployment once the organization exists is told in-app', async () => { + const r = await deliver(deployment((t) => { t.sys_organization.push({ id: 'org_opz', slug: 'platform-opz' }); }), { ownerOrganization: 'platform-opz' }); + expect(r.delivery).toMatchObject({ outcome: 'notified', organizationId: 'org_opz' }); + }); + + it('the organizations cannot be read: said with the cause, the operator line stands — no emit', async () => { + const r = await deliver(deployment(), { ownerOrganization: OWNER_ORG, failOn: 'sys_organization' }); + expect(r.emit).not.toHaveBeenCalled(); + expect(r.delivery.outcome).toBe('operator-log'); + expect(r.errors[0].m).toContain('the organizations could not be read (sys_organization is unreachable)'); + expect(operatorLine(r)).toHaveLength(1); + }); + + it('no messaging service: said with the fix, the operator line stands', async () => { + const r = await deliver(deployment(), { ownerOrganization: OWNER_ORG, messaging: null }); + expect(r.delivery).toMatchObject({ outcome: 'operator-log', reason: 'this composition has no messaging service' }); + expect(r.errors[0].m).toContain('Fix: compose the messaging service.'); + }); + + it('an emit the ingress refuses: said with its cause, never thrown — the operator line says no organization is told', async () => { + const messaging = { emit: vi.fn(async () => { throw new Error('outbox down'); }) }; + const r = await deliver(deployment(), { ownerOrganization: OWNER_ORG, messaging }); + expect(r.delivery).toMatchObject({ outcome: 'operator-log', reason: 'the notification ingress refused it (outbox down)' }); + expect(operatorLine(r)[0].m).toContain('no organization is told'); + }); + + it('no operator configured: nobody to address the copy to, so none is raised, and the fix names the operator variable', async () => { + process.env[OWNER_ORG_ENV] = OWNER_ORG; + resetPlatformAdminEmailMemo(); + const messaging = notifier(); + const log = logger(); + const delivery = await deliverUpgradeReport(fakeEngine(deployment()), { walled: true, messaging, logger: log }, NOTICE); + expect(messaging.emit).not.toHaveBeenCalled(); + expect(delivery).toMatchObject({ outcome: 'operator-log', recipients: [], reason: 'no operator is configured to address it to' }); + const lines = log.error.mock.calls.map(([m]) => String(m)); + expect(lines[0]).toContain('Fix: set OS_PLATFORM_OWNER_EMAIL to name the platform operator.'); + expect(lines[1]).toContain('No operator is configured: set OS_PLATFORM_OWNER_EMAIL'); + }); +}); + +describe('walled, who can see the in-app copy', () => { + it('an operator who is not a member of the organization: a warning that the copy will not be visible to them — the copy still goes', async () => { + const r = await deliver(deployment((t) => { t.sys_member = t.sys_member.filter((m) => m.user_id !== 'usr_operator'); }), { ownerOrganization: OWNER_ORG }); + expect(r.emit).toHaveBeenCalledTimes(1); + const warn = r.warns.find((w) => w.m.includes('will not be visible to them')); + expect(warn?.meta).toEqual({ organizationId: OWNER_ORG, operators: [{ email: OPERATOR, state: 'not-member' }] }); + }); + + it('an operator with no account, and one whose account is not verified, are each named with their state', async () => { + process.env[OWNER_EMAIL_ENV] = `${OPERATOR},second@vendor.example,third@vendor.example`; + process.env[OWNER_ORG_ENV] = OWNER_ORG; + resetPlatformAdminEmailMemo(); + const tables = deployment((t) => { t.sys_user.push({ id: 'usr_third', email: 'third@vendor.example', email_verified: false }); }); + const log = logger(); + await deliverUpgradeReport(fakeEngine(tables), { walled: true, messaging: notifier(), logger: log }, NOTICE); + const warn = log.warn.mock.calls.find(([m]) => String(m).includes('will not be visible to them')); + expect(warn?.[1]?.operators).toEqual([ + { email: 'second@vendor.example', state: 'no-account' }, + { email: 'third@vendor.example', state: 'unverified' }, + ]); + }); + + it('an administrator of it who is not a configured operator — by membership grade or by grant — is flagged', async () => { + const r = await deliver(deployment((t) => { + t.sys_member.push({ id: 'm3', organization_id: OWNER_ORG, user_id: 'usr_ops_admin', role: 'member,admin' }); + t.sys_member.push({ id: 'm4', organization_id: OWNER_ORG, user_id: 'usr_ops_member', role: 'member' }); + t.sys_user_permission_set.push({ id: 'g3', user_id: 'usr_granted', permission_set: 'admin_full_access', organization_id: OWNER_ORG }); + }), { ownerOrganization: OWNER_ORG }); + expect(r.emit).toHaveBeenCalledTimes(1); + const warn = r.warns.find((w) => w.m.includes('are not configured operators')); + expect(warn?.m).toContain('can read the in-app copy of the access upgrade findings above in Setup, Notification Events'); + expect(warn?.meta).toEqual({ organizationId: OWNER_ORG, administrators: ['usr_granted', 'usr_ops_admin'] }); + }); + + it('CONTROL: a plain member, and the tenant organization\'s own administrator, are not flagged', async () => { + const r = await deliver(deployment((t) => { + t.sys_member.push({ id: 'm4', organization_id: OWNER_ORG, user_id: 'usr_ops_member', role: 'member' }); + }), { ownerOrganization: OWNER_ORG }); + expect(r.warns).toEqual([]); + }); + + it('the members cannot be read: a warning that the check did not run — the copy still goes', async () => { + const r = await deliver(deployment(), { ownerOrganization: OWNER_ORG, failOn: 'sys_member' }); + expect(r.emit).toHaveBeenCalledTimes(1); + expect(r.warns.some((w) => w.m.includes('could not be read (sys_member is unreachable)'))).toBe(true); + }); +}); + +describe('single never reads the setting', () => { + it('set or unset, the Default Organization is stamped and its administrators are told', async () => { + for (const ownerOrganization of [undefined, OWNER_ORG]) { + const r = await deliver(deployment(), { walled: false, ...(ownerOrganization ? { ownerOrganization } : {}) }); + expect(r.emit!.mock.calls[0][0]).toMatchObject({ audience: [OPERATOR, 'usr_default_admin'], organizationId: DEFAULT_ORG }); + expect(r.delivery).toMatchObject({ outcome: 'notified', organizationId: DEFAULT_ORG }); + expect(operatorLine(r)).toEqual([]); + expect(r.warns).toEqual([]); + delete process.env[OWNER_ORG_ENV]; + } + }); +}); diff --git a/packages/plugins/plugin-security/src/upgrade-report-delivery.ts b/packages/plugins/plugin-security/src/upgrade-report-delivery.ts new file mode 100644 index 00000000000..a6c807122c3 --- /dev/null +++ b/packages/plugins/plugin-security/src/upgrade-report-delivery.ts @@ -0,0 +1,461 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The delivery of a boot upgrade report (ADR-0126 §6): who is told, and through + * which channel, once a report has a finding set to raise. The access upgrade + * report (`access-upgrade-report.ts`) hands its findings here; so can any + * other boot report whose findings are deployment-level. + * + * ## Under `single` + * + * Through the existing notification ingress (`messaging.emit()`, ADR-0030: a + * `sys_notification` event in Setup's Notification Events, and the in-app + * inbox), stamped with the Default Organization, to its `admin_full_access` + * holders and any `OS_PLATFORM_OWNER_EMAIL` address. The notification's + * `dedupKey` is the finding set's digest, so an unchanged set notifies once, + * not once per boot. No messaging service, nobody to tell, or a refused emit: + * one log line says so, with the fix for that cause. + * + * ## Under a wall (`group`, `isolated`) + * + * The findings are deployment-level, and only the operator can act on them. + * One `error` line addressed to the operator is written, always. The + * notification family is organization-scoped: a walled system write that names + * no organization is refused (ADR-0131 D9), and stamping a tenant's + * organization would hand the findings to that tenant. So: + * + * - **`OS_PLATFORM_OWNER_ORGANIZATION` unset:** the operator line only. No + * notification is attempted. + * - **Set** (#25413): the value is an organization id, else a slug, read from + * `sys_organization` at this boot. The same notification (same topic, same + * `dedupKey`) is emitted, stamped with that organization and addressed to + * the configured operators. The operator line stays beside it. No other + * organization is told. + * - **Set, naming no organization:** one `error` naming the value and the + * fix, and the operator line. It never falls back to another organization. + * - **A configured operator who is not a member of it**, or who has no + * verified account: a `warn` that the in-app copy will not be visible to + * them. + * - **An administrator of it who is not a configured operator:** a `warn`. The + * organization must hold operators only. Any administrator of it can read + * the event in Setup, Diagnostics, Notification Events, and the findings can + * name other organizations' ids and rule names. "Administrator" here is an + * `owner`/`admin` membership grade, or an `admin_full_access` or + * organization-admin grant scoped to it. Both are read with two `find`s, + * the same two that answer the membership check. A set authored to read + * `sys_notification` some other way is not read. + * + * ## Never throws + * + * A read or a delivery that did not happen is said in the log with its cause. + * The caller's report has already been logged in full by then. + */ + +import { + readDefaultOrganizationId, + resolvePlatformAdminEmails, + type PlatformAdminEmailConfig, +} from '@objectstack/core'; +import { ORGANIZATION_ADMIN, ORGANIZATION_ADMIN_NO_BYPASS } from '@objectstack/spec'; +import { ADMIN_FULL_ACCESS } from '@objectstack/spec/identity'; +import { PLATFORM_OWNER_ORGANIZATION_ENV, resolvePlatformOwnerOrganization } from '@objectstack/types'; +import { isAdminRole } from './auto-org-admin-grant.js'; +import { GRANT_OBJECT, GRANT_SET_NAME_FIELD } from './grant-permission-set-name.js'; +import { resolvePlatformAdminStanding } from './platform-admin-service.js'; + +const ORGANIZATION_OBJECT = 'sys_organization'; +const MEMBER_OBJECT = 'sys_member'; +const SYSTEM_CTX = { isSystem: true } as const; + +/** The grants that make their holder an administrator of the organization they are scoped to. */ +const ADMINISTRATOR_SETS: ReadonlySet = new Set([ADMIN_FULL_ACCESS, ORGANIZATION_ADMIN, ORGANIZATION_ADMIN_NO_BYPASS]); + +/** The engine surface the delivery reads through — ObjectQL satisfies it as it stands. */ +export interface UpgradeReportEngine { + getObject(name: string): unknown; + find(object: string, options: Record): Promise; +} + +/** The kernel logger, as the delivery uses it. */ +export interface UpgradeReportLogger { + info?: (message: string, meta?: Record) => void; + warn: (message: string, meta?: Record) => void; + error?: (message: string, cause?: Error, meta?: Record) => void; +} + +/** The notification ingress, as the delivery uses it — service-messaging's `emit`. */ +export interface UpgradeReportNotifier { + emit(input: { + topic: string; + audience: readonly string[]; + severity: 'warning'; + dedupKey: string; + organizationId?: string; + payload: Record; + }): Promise; +} + +export interface UpgradeReportDeliveryDeps { + logger?: UpgradeReportLogger; + /** The `messaging` service; absent when the composition has none. */ + messaging?: UpgradeReportNotifier; + /** `true` under `group` / `isolated`. */ + walled: boolean; +} + +/** One report's finding set, as the delivery raises it. */ +export interface UpgradeReportNotice { + /** The notification topic, e.g. `security.access_upgrade`. */ + topic: string; + /** The finding set's digest: the notification's `dedupKey`, and the field the log lines carry. */ + findingSet: string; + /** What the log lines call the findings, e.g. `the access upgrade findings above`. */ + subject: string; + /** + * Why only the operator can act on them, completing " are + * deployment-level — ": one sentence ending in a full stop. + */ + operatorReason: string; + /** The notification's payload: its title and body, and the report's own data. */ + payload: { title: string; body: string } & Record; +} + +/** What the delivery did with a finding set. */ +export interface UpgradeReportDelivery { + /** + * `notified`: handed to the ingress (`single`, or walled with the owner + * organization set). `operator-log`: walled, and the operator's line in this + * log is the only channel. `undelivered`: `single` findings that reached no + * administrator, said in the log with the cause. + */ + outcome: 'notified' | 'operator-log' | 'undelivered'; + recipients: string[]; + findingSet: string; + /** The organization the notification was stamped with, when one was. */ + organizationId?: string; + reason?: string; +} + +/** Who a `single` delivery is addressed to, and the organization it is stamped with. */ +export interface UpgradeReportAudience { + recipients: string[]; + organizationId?: string; +} + +const errorText = (e: unknown): string => (e instanceof Error ? e.message : String(e)); + +function idOf(value: unknown): string | undefined { + if (typeof value === 'string' && value.length > 0) return value; + if (typeof value === 'number') return String(value); + return undefined; +} + +function rowsOf(value: unknown): Record[] { + return Array.isArray(value) ? value.filter((r): r is Record => !!r && typeof r === 'object') : []; +} + +function logError(logger: UpgradeReportLogger | undefined, message: string, meta: Record): void { + if (logger?.error) logger.error(message, undefined, meta); + else logger?.warn(message, meta); +} + +/** + * The people who can act on a deployment-level finding under `single`: any + * configured platform operator and every holder of the Default Organization's + * `admin_full_access` grant. A read that throws propagates. + */ +export async function resolveUpgradeReportAudience(engine: UpgradeReportEngine): Promise { + const recipients = [...resolvePlatformAdminEmails().emails]; + const organizationId = await readDefaultOrganizationId(engine); + if (organizationId === undefined || !engine.getObject(GRANT_OBJECT)) return { recipients }; + const grants = await engine.find(GRANT_OBJECT, { + where: { [GRANT_SET_NAME_FIELD]: ADMIN_FULL_ACCESS, organization_id: organizationId }, + fields: ['user_id'], + limit: 10000, + context: SYSTEM_CTX, + }); + for (const row of rowsOf(grants)) { + const userId = idOf(row.user_id); + if (userId !== undefined && !recipients.includes(userId)) recipients.push(userId); + } + return { recipients, organizationId }; +} + +/** + * The organization `OS_PLATFORM_OWNER_ORGANIZATION` names: by id first, then by + * slug. `undefined` when neither matches, or when this composition has no + * `sys_organization`. A read that throws propagates. + */ +export async function resolvePlatformOwnerOrganizationId( + engine: UpgradeReportEngine, + value: string, +): Promise { + if (!engine.getObject(ORGANIZATION_OBJECT)) return undefined; + for (const field of ['id', 'slug'] as const) { + const rows = rowsOf(await engine.find(ORGANIZATION_OBJECT, { + where: { [field]: value }, + fields: ['id'], + limit: 1, + context: SYSTEM_CTX, + })); + const id = idOf(rows[0]?.id); + if (id !== undefined) return id; + } + return undefined; +} + +/** A configured operator the in-app copy will not be visible to, and why. */ +export interface UnreachedOperator { + email: string; + state: 'no-account' | 'unverified' | 'not-member'; +} + +/** What the boot reads of the owner organization's people, for the two warnings. */ +export interface OwnerOrganizationAudit { + unreachedOperators: UnreachedOperator[]; + /** User ids of the organization's administrators who are not configured operators. */ + nonOperatorAdministrators: string[]; +} + +/** + * Read the owner organization's members and admin grants against the + * configured operators. The operator half reads `sys_user` the way the + * platform-admin standing does (both spellings, the config's normalization); + * an operator counts as reached when a verified account of theirs is a member. + * A read that throws propagates. + */ +export async function auditOwnerOrganization( + engine: UpgradeReportEngine, + organizationId: string, + config: PlatformAdminEmailConfig, +): Promise { + const members = engine.getObject(MEMBER_OBJECT) + ? rowsOf(await engine.find(MEMBER_OBJECT, { + where: { organization_id: organizationId }, + fields: ['user_id', 'role'], + limit: 10000, + context: SYSTEM_CTX, + })) + : []; + const grants = engine.getObject(GRANT_OBJECT) + ? rowsOf(await engine.find(GRANT_OBJECT, { + where: { organization_id: organizationId }, + fields: ['user_id', GRANT_SET_NAME_FIELD], + limit: 10000, + context: SYSTEM_CTX, + })) + : []; + const memberIds = new Set(members.map((m) => idOf(m.user_id)).filter((id): id is string => id !== undefined)); + + const operatorIds = new Set(); + const unreachedOperators: UnreachedOperator[] = []; + for (const entry of await resolvePlatformAdminStanding(engine, config)) { + if (entry.userId !== undefined) operatorIds.add(entry.userId); + const state: UnreachedOperator['state'] | undefined = !entry.registered + ? 'no-account' + : !entry.verified || entry.userId === undefined + ? 'unverified' + : memberIds.has(entry.userId) ? undefined : 'not-member'; + if (state !== undefined) unreachedOperators.push({ email: entry.email, state }); + } + + const administrators = new Set(); + for (const m of members) { + const userId = idOf(m.user_id); + if (userId !== undefined && isAdminRole(m.role)) administrators.add(userId); + } + for (const g of grants) { + const userId = idOf(g.user_id); + const set = g[GRANT_SET_NAME_FIELD]; + if (userId !== undefined && typeof set === 'string' && ADMINISTRATOR_SETS.has(set)) administrators.add(userId); + } + const nonOperatorAdministrators = [...administrators].filter((id) => !operatorIds.has(id)).sort(); + return { unreachedOperators, nonOperatorAdministrators }; +} + +/** + * Raise a report's finding set to the people who can act on it (see the module + * header for each posture). Never throws. + */ +export async function deliverUpgradeReport( + engine: UpgradeReportEngine, + deps: UpgradeReportDeliveryDeps, + notice: UpgradeReportNotice, +): Promise { + return deps.walled ? deliverWalled(engine, deps, notice) : deliverSingle(engine, deps, notice); +} + +async function deliverSingle( + engine: UpgradeReportEngine, + deps: UpgradeReportDeliveryDeps, + notice: UpgradeReportNotice, +): Promise { + const { findingSet } = notice; + const undelivered = (reason: string, fix: string, recipients: string[] = []): UpgradeReportDelivery => { + logError( + deps.logger, + `[security] ${notice.subject} reached no administrator: ${reason}. They are in this log only. Fix: ${fix}`, + { findingSet }, + ); + return { outcome: 'undelivered', recipients, findingSet, reason }; + }; + if (!deps.messaging || typeof deps.messaging.emit !== 'function') { + return undelivered('this composition has no messaging service', 'compose the messaging service.'); + } + + let audience: UpgradeReportAudience; + try { + audience = await resolveUpgradeReportAudience(engine); + } catch (e) { + return undelivered( + `the administrators could not be read (${errorText(e)})`, + 'restore the store the grants are read from; the next boot reads them again.', + ); + } + if (audience.recipients.length === 0) { + return undelivered( + 'no administrator is configured or granted', + 'grant admin_full_access in the Default Organization, or set OS_PLATFORM_OWNER_EMAIL.', + ); + } + try { + await deps.messaging.emit({ + topic: notice.topic, + audience: audience.recipients, + severity: 'warning', + dedupKey: findingSet, + ...(audience.organizationId !== undefined ? { organizationId: audience.organizationId } : {}), + payload: notice.payload, + }); + } catch (e) { + return undelivered( + `the notification ingress refused it (${errorText(e)})`, + 'repair the cause the ingress names; the next boot raises the same findings again.', + audience.recipients, + ); + } + return { + outcome: 'notified', + recipients: audience.recipients, + findingSet, + ...(audience.organizationId !== undefined ? { organizationId: audience.organizationId } : {}), + }; +} + +async function deliverWalled( + engine: UpgradeReportEngine, + deps: UpgradeReportDeliveryDeps, + notice: UpgradeReportNotice, +): Promise { + const { findingSet } = notice; + const config = resolvePlatformAdminEmails(); + const operators = [...config.emails]; + const noOperator = 'No operator is configured: set OS_PLATFORM_OWNER_EMAIL to name the platform operator.'; + const value = resolvePlatformOwnerOrganization(); + + /** The line addressed to the operator — always written; its wording says whether a copy went in-app. */ + const operatorLine = (organizationId?: string): void => { + logError( + deps.logger, + `[security] platform operator: ${notice.subject} are deployment-level — ${notice.operatorReason} ` + + (organizationId === undefined + ? 'This tenancy posture has no in-app channel for a deployment-level notification, so this log is where ' + + 'they are raised; no organization is told. ' + : `A copy is raised in-app in the organization ${PLATFORM_OWNER_ORGANIZATION_ENV} names, addressed to ` + + 'the configured operator(s); no other organization is told. ') + + (operators.length > 0 ? 'The configured operator(s) are named in this line.' : noOperator), + { findingSet, operators, ...(organizationId !== undefined ? { organizationId } : {}) }, + ); + }; + const logOnly = (reason?: string): UpgradeReportDelivery => { + operatorLine(); + return { outcome: 'operator-log', recipients: operators, findingSet, ...(reason !== undefined ? { reason } : {}) }; + }; + /** The setting is set, but the in-app copy cannot go: one `error` with the cause and the fix, then the operator line. */ + const notRaisedInApp = (reason: string, fix: string): UpgradeReportDelivery => { + logError( + deps.logger, + `[security] ${PLATFORM_OWNER_ORGANIZATION_ENV} is set, but ${notice.subject} are not raised in-app: ${reason}. ` + + `The operator line below is the only channel for this boot. Fix: ${fix}`, + { findingSet, value }, + ); + return logOnly(reason); + }; + + if (value === undefined) return logOnly(); + + let organizationId: string | undefined; + try { + organizationId = await resolvePlatformOwnerOrganizationId(engine, value); + } catch (e) { + return notRaisedInApp( + `the organizations could not be read (${errorText(e)})`, + 'restore the store sys_organization is read from; the next boot reads it again.', + ); + } + if (organizationId === undefined) { + return notRaisedInApp( + `"${value}" names no organization by id or by slug`, + `set ${PLATFORM_OWNER_ORGANIZATION_ENV} to the id or slug of an existing organization that holds the ` + + 'platform operators only, or unset it.', + ); + } + if (!deps.messaging || typeof deps.messaging.emit !== 'function') { + return notRaisedInApp('this composition has no messaging service', 'compose the messaging service.'); + } + if (operators.length === 0) { + return notRaisedInApp( + 'no operator is configured to address it to', + 'set OS_PLATFORM_OWNER_EMAIL to name the platform operator.', + ); + } + + try { + const audit = await auditOwnerOrganization(engine, organizationId, config); + if (audit.unreachedOperators.length > 0) { + deps.logger?.warn( + `[security] ${audit.unreachedOperators.length} configured operator(s) are not verified members of the ` + + `organization ${PLATFORM_OWNER_ORGANIZATION_ENV} names, so the in-app copy of ${notice.subject} will not ` + + 'be visible to them; the operator line in this log still reaches them. Fix: add each to that ' + + `organization (and verify the account), or point ${PLATFORM_OWNER_ORGANIZATION_ENV} at an organization ` + + 'they belong to.', + { organizationId, operators: audit.unreachedOperators }, + ); + } + if (audit.nonOperatorAdministrators.length > 0) { + deps.logger?.warn( + `[security] ${audit.nonOperatorAdministrators.length} administrator(s) of the organization ` + + `${PLATFORM_OWNER_ORGANIZATION_ENV} names are not configured operators, and every administrator of it ` + + `can read the in-app copy of ${notice.subject} in Setup, Notification Events. The findings are ` + + "deployment-level and can name other organizations' ids. Fix: keep that organization for the platform " + + 'operators only: remove the others, or name them in OS_PLATFORM_OWNER_EMAIL if they operate the platform.', + { organizationId, administrators: audit.nonOperatorAdministrators }, + ); + } + } catch (e) { + deps.logger?.warn( + `[security] the members of the organization ${PLATFORM_OWNER_ORGANIZATION_ENV} names could not be read ` + + `(${errorText(e)}), so whether the operators can see the in-app copy, and whether anyone else can, is not ` + + 'checked at this boot.', + { organizationId }, + ); + } + + try { + await deps.messaging.emit({ + topic: notice.topic, + audience: operators, + severity: 'warning', + dedupKey: findingSet, + organizationId, + payload: notice.payload, + }); + } catch (e) { + return notRaisedInApp( + `the notification ingress refused it (${errorText(e)})`, + 'repair the cause the ingress names; the next boot raises the same findings again.', + ); + } + operatorLine(organizationId); + return { outcome: 'notified', recipients: operators, findingSet, organizationId }; +} diff --git a/packages/types/src/env.test.ts b/packages/types/src/env.test.ts index a16b3e03fca..bc0bed8a464 100644 --- a/packages/types/src/env.test.ts +++ b/packages/types/src/env.test.ts @@ -23,6 +23,8 @@ import { stampSearchPinyinEnabled, SCHEDULED_WORK_DISABLED_REASON, SCHEDULED_WORK_ENV, + PLATFORM_OWNER_ORGANIZATION_ENV, + resolvePlatformOwnerOrganization, } from './env.js'; describe('readEnvWithDeprecation', () => { @@ -694,3 +696,29 @@ describe('scheduledWorkDisabledReason (#21110 — a host-injected OFF carries it }); }); }); + +describe('resolvePlatformOwnerOrganization (#25413)', () => { + const original = process.env[PLATFORM_OWNER_ORGANIZATION_ENV]; + afterEach(() => { + if (original === undefined) delete process.env[PLATFORM_OWNER_ORGANIZATION_ENV]; + else process.env[PLATFORM_OWNER_ORGANIZATION_ENV] = original; + }); + + it('is spelled OS_PLATFORM_OWNER_ORGANIZATION', () => { + expect(PLATFORM_OWNER_ORGANIZATION_ENV).toBe('OS_PLATFORM_OWNER_ORGANIZATION'); + }); + + it('unset and blank answer undefined (the control)', () => { + delete process.env[PLATFORM_OWNER_ORGANIZATION_ENV]; + expect(resolvePlatformOwnerOrganization()).toBeUndefined(); + process.env[PLATFORM_OWNER_ORGANIZATION_ENV] = ' '; + expect(resolvePlatformOwnerOrganization()).toBeUndefined(); + }); + + it('echoes an id or a slug trimmed, read live on each call', () => { + process.env[PLATFORM_OWNER_ORGANIZATION_ENV] = ' org_platform '; + expect(resolvePlatformOwnerOrganization()).toBe('org_platform'); + process.env[PLATFORM_OWNER_ORGANIZATION_ENV] = 'platform-ops'; + expect(resolvePlatformOwnerOrganization()).toBe('platform-ops'); + }); +}); diff --git a/packages/types/src/env.ts b/packages/types/src/env.ts index f1023245684..4c4d5545d9a 100644 --- a/packages/types/src/env.ts +++ b/packages/types/src/env.ts @@ -507,6 +507,45 @@ export function resolvePlatformOwnerEmail(): string | undefined { return trimmed === '' ? undefined : trimmed; } +/** + * [#25413] The env variable naming the PLATFORM OWNER's organization — the + * organization a walled deployment raises its deployment-level reports in, + * beside {@link PLATFORM_OWNER_EMAIL_ENV}, which names the owner account. + * + * Exported as a constant so every message that names it quotes exactly one + * spelling (plugin-security's `upgrade-report-delivery.ts`). + */ +export const PLATFORM_OWNER_ORGANIZATION_ENV = 'OS_PLATFORM_OWNER_ORGANIZATION'; + +/** + * [#25413] Resolve the env-declared platform owner organization — + * `OS_PLATFORM_OWNER_ORGANIZATION`: an organization id or slug. + * + * Read only under a WALLED tenancy posture (`group` / `isolated`). There a + * deployment-level notification has no organization of its own: the + * notification family is organization-owned (ADR-0131: no NULL + * `organization_id`), and a walled system write that names none is refused + * (D9). Naming the owner's organization gives the boot upgrade reports an + * in-app channel in it, addressed to the {@link PLATFORM_OWNER_EMAIL_ENV} + * operators. `single` never consults it: the Default Organization is that + * posture's answer. + * + * Returns the operator's value trimmed, or `undefined` when unset/blank. + * Resolving it to an organization (id first, then slug) is the CONSUMER's job, + * and so is saying loudly when it names none — this resolver echoes what the + * operator typed so that message can quote it verbatim. + * + * Reads `process.env` live on each call, through `globalThis` like the other + * resolvers here (this package targets non-Node runtimes too). + */ +export function resolvePlatformOwnerOrganization(): string | undefined { + const raw = (globalThis as { process?: { env?: Record } }) + .process?.env?.[PLATFORM_OWNER_ORGANIZATION_ENV]; + if (raw == null) return undefined; + const trimmed = String(raw).trim(); + return trimmed === '' ? undefined : trimmed; +} + /** * Escape hatch for the degraded-tenancy boot guard (ADR-0093 D5). *