2.8 KiB
tenant/cross-tenant
(no submodules)
Purpose
Design doc §9.1 (final paragraph) requires that a saga step whose
target tenant differs from the saga initiator's tenant be gated by
the saga.cross-tenant capability on the owner plugin. Without the
capability the orchestrator MUST refuse dispatch with
SAGA.CROSS_TENANT_DENIED — quietly running the step is a tenant
isolation violation.
This module exports the canonical capability id and two helpers the orchestrator (and any saga aware middleware) funnels every dispatch through. The check is pure and side-effect-free so it can be applied at registry register time, at orchestrator start time, and at every step dispatch — three checkpoints that catch wiring drift at three levels of granularity.
Features
CROSS_TENANT_CAPABILITY— the literal"saga.cross-tenant"capability id, pinned in the@suaveplan/api-typescapabilities table.hasCrossTenantCapability(owner)— predicate for non-throwing branches (e.g. when computing UI affordances).assertCrossTenantAllowed(owner, initiator, target)— throwsSagaError(SAGA.CROSS_TENANT_DENIED)when the gate refuses; no-op otherwise.
Usage: Basic
import {
assertCrossTenantAllowed,
CROSS_TENANT_CAPABILITY,
} from "@suaveplan/api-saga/tenant/cross-tenant";
assertCrossTenantAllowed(owner, "acme", "acme"); // same-tenant — ok
assertCrossTenantAllowed(owner, "acme", "globex"); // throws unless owner declares capability
Usage: Advanced
Compose the gate with the cross-cutting capability checker used
elsewhere in the host — hasCrossTenantCapability is non-throwing so
it composes inside conditional pipelines:
import { hasCrossTenantCapability } from "@suaveplan/api-saga";
const canDispatch =
initiatorTenantId === targetTenantId ||
hasCrossTenantCapability(owner);
if (!canDispatch) {
metrics.crossTenantDeniedTotal.add(1, {
saga: owner.saga.name,
owner: owner.pluginName,
});
throw new SagaError(SAGA_CODES.CROSS_TENANT_DENIED, "denied");
}
API Reference
CROSS_TENANT_CAPABILITY: "saga.cross-tenant"— literal constant.hasCrossTenantCapability(owner: RegisteredSagaOwner): boolean.assertCrossTenantAllowed(owner, initiatorTenantId, targetTenantId): void— no-op on accept, throwsSAGA.CROSS_TENANT_DENIEDon refuse.
Implementation Notes
The capability is owner-scoped rather than step-scoped because the
owner declares both. Per-step opt-outs would require manifest changes
to sagas.owns[].steps[] and threading a per-step capability list
through the orchestrator — a strictly larger surface than the design
doc calls for. If a future change needs finer granularity, the
registry's RegisteredSagaOwner.saga.capabilities shape is the
forward-compatible extension point.