Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions .changeset/25413-walled-upgrade-report-owner-organization.md
Original file line number Diff line number Diff line change
@@ -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.
1 change: 1 addition & 0 deletions content/docs/deployment/environment-variables.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
Expand Down
211 changes: 51 additions & 160 deletions packages/plugins/plugin-security/src/access-upgrade-report.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
*
Expand All @@ -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';
Expand Down Expand Up @@ -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<string, any>) => void;
warn: (message: string, meta?: Record<string, any>) => void;
error?: (message: string, cause?: Error, meta?: Record<string, any>) => 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<string, unknown>;
}): Promise<unknown>;
}
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<AccessUpgradeAudience> {
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<string, unknown>)?.user_id);
if (userId !== undefined && !recipients.includes(userId)) recipients.push(userId);
}
return { recipients, organizationId };
}

function listed<T>(items: readonly T[]): { items: T[]; more?: number } {
return items.length > LOGGED_LIMIT
? { items: items.slice(0, LOGGED_LIMIT), more: items.length - LOGGED_LIMIT }
Expand Down Expand Up @@ -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<UpgradeReportDelivery, 'outcome' | 'findingSet'> {
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,
Expand All @@ -739,88 +688,30 @@ 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,
collisions: report.collisions.length,
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 };
}
Loading
Loading