Files
skills/docs/api-stack/api/api-saga/src/tenant/cross-tenant.md
2026-05-29 08:20:56 +01:00

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-types capabilities table.
  • hasCrossTenantCapability(owner) — predicate for non-throwing branches (e.g. when computing UI affordances).
  • assertCrossTenantAllowed(owner, initiator, target) — throws SagaError(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, throws SAGA.CROSS_TENANT_DENIED on 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.