Multi-tenancy¶
One Drupal instance can host several isolated workflow realms, called tenants. Each tenant runs its own processes: instances, tokens and variables partition per tenant, so a query, a listing or a purge in one tenant never reaches another's rows.
Every install starts with one tenant out of the box, the default tenant, and that is all a single-tenant site ever needs. Multi-tenancy stays invisible until you create a second tenant, so a single-tenant site behaves as if tenancy were absent.
How isolation works¶
Each partitioned row records the tenant it belongs to in a tenant field. The
value is stamped at create time by the engine, from the active tenant, and
defaults to the default tenant so a row is never tenantless. A process
workflow is either shared across every tenant (the default) or scoped
to a single tenant.
A workflow's scope is set by the Tenant scope select in the Model section of the Complete Modeler, beside the Enabled checkbox that retires a workflow without deleting it, or by importing the workflow's configuration. The BPMN.io canvas does not offer it: its model panel carries a fixed set of fields and discards any a model owner adds, so a select there would look like a choice and change nothing (see Modeler internals). Saving a workflow from BPMN.io leaves whatever scope it already has untouched.
A delegation is likewise a single tenant's: someone covering for a person in one realm is a stranger in another, and every question the resolver answers names its tenant.
A run is refused outright if its workflow does not apply to the tenant it would run in, so a subprocess node can only start a child its parent's tenant can reach. Both editors scope the child list to the same rule, rather than offering a choice that can only dead-letter the first token to arrive and, in offering it, naming what other tenants have. Note which way the rule falls for a shared parent: it runs in whichever tenant starts it, so the only child guaranteed to be reachable wherever it lands is a shared one.
The list is where the rule is met, not where it is enforced. The workflow form also refuses a selection that breaks it, but the canvas cannot: the Modeler API validates a plugin's settings on a form state of its own making, which carries no model, so nothing there knows the tenant. The engine is the boundary on every path, config import included, and refuses the run.
Managing tenants¶
The Tenants list at /admin/orchestra/tenants holds every realm
the site has. Add tenant creates one from a label, the machine name that
every row in the realm is stamped with, and a weight that orders the listings.
The machine name is offered once and fixed afterwards: renaming it would leave
the instances, tokens, variables and workflows it stamped behind under the old
name.
Each row reaches Edit for the label and the weight, Retention and Reading for what the realm keeps and who may read its runs, and Delete. Deleting a tenant is not a way to purge a database, and it deletes nothing but the tenant. A realm can only go once nothing belongs to it: its runs are the record of work that happened, and its workflows are process definitions someone authored, so neither is collateral of removing the realm they live in. The confirmation page lists whatever is still in the way, per kind and with counts, and its delete button stays disabled until that list is empty. Clearing the list is the administrator's own work, by deleting those rows or moving them to another tenant.
The refusal is enforced in Tenant::preDelete(), ahead of the cascade
ConfigEntityBase::preDelete() would otherwise run, because a config entity's
dependents are deleted there before any hook_ENTITY_TYPE_predelete could look
at them. So it holds for a programmatic delete() as much as for the form.
For an ordinary tenant it steps aside during a config import or a module
uninstall, which is exactly where the parent skips its own cascade: those flows
manage their deletions themselves.
What counts is every entity type that declares an orchestra_tenant entity
key, naming the field (or, on a config entity, the exported property) that
records its tenant, the way core's owner and published keys name theirs:
entity_keys: [..., 'orchestra_tenant' => 'tenant']. A module adding such a
type declares the key the same way, and its rows are then in the way like the
engine's own. The key is named after Orchestra because a tenant of another
module is another realm: a module keeping tenants of its own names its key
after itself, and Orchestra never takes it for one of its own.
The default tenant is a case of its own: every request that resolves to no tenant falls back to it, so once it is gone the engine cannot resolve a tenant at all. It is refused even to a config import, whatever the sync store says, and only an uninstall may take it, the engine going with it.
Removing a tenant with drush config:delete bypasses all of this. It writes to
the configuration directly, and nothing carries a raw config write back to the
entity system, so the realm's rows are left behind with no tenant to belong to
and no guard consulted. Use the form, or a config import, which deletes through
the entity system as the form does.
Resolving the active tenant¶
Which tenant a request acts in is decided by tenant resolvers: services
tagged orchestra_engine.tenant_resolver implementing
TenantResolverInterface. Each resolver returns a tenant machine name or NULL
to defer; the first non-null answer wins, and if none answer the default tenant
is used. With no resolver installed every request resolves to the default
tenant.
Because the first answer wins, the order is the boundary, and a resolver
declares its place in it with a priority on the tag. The shipped ones set the
scale: orchestra_server resolves from the OAuth consumer's own binding at
100, and orchestra_domain resolves from the request's host at 0. An
authenticated machine identity states which tenant it acts in; a hostname is
whatever the caller dialed, so it must not be able to overrule one. Resolve
from an authenticated identity above 0 and from anything the caller chooses at
or below it. Leave the priority off and the order is whatever the container
happened to build, which is module registration order.
A resolver that answers a tenant id no tenant matches is treated as having
deferred, and the next one is asked. That is a fallthrough, not a gate: if your
binding must never widen to the default tenant, say so by throwing, as
ConsumerTenantResolver does for a consumer whose binding is empty or points
at a tenant that has been deleted.
That fallback is what keeps a single-tenant site simple, and it is also what a
site can back itself into: a second tenant, added on the Tenants list above or
arriving with a config import, cannot be reached at all while no resolver is
installed. No request ever acts in it, so nothing can be created there, and the
listings and access checks, scoped to the tenant in effect, hide anything that
is already there. The site looks multi-tenant and is not. Orchestra says so on
the status report, with a warning naming how many tenants are out of reach, so
install a connector (orchestra_domain below, or orchestra_server for
remote consumers), tag a resolver of your own, or delete the tenants you are not
using.
What a refusal may say¶
Scoping every read and write to the tenant in effect is only half of it: what a caller is told when it asks about something outside the boundary has to be scoped too. A reply that separates "there is no such thing" from "that one is not yours" is an oracle. Nothing is handed over, but a caller holding one tenant's credentials can walk a list of machine names and read off, from which answer comes back, what the other tenants have — and a machine name is rarely nothing.
So across the boundary the two answer alike. Starting a workflow through the
client contract (OrchestraClientInterface, and so the HTTP API built on it)
is refused as Unknown workflow "..." whether the name belongs to nothing at
all or to another tenant's workflow. A workflow that is this tenant's and
cannot be started as it stands still says which it is — disabled, or without a
valid start node — because that names the caller's own configuration, which
they are entitled to and need in order to fix it.
Inside a tenant the engine keeps all four reasons apart, and that is what reaches the log: an operator reading it is not a caller on the other side of a boundary, and they need to know which thing to go and fix.
To resolve tenants your own way, implement the interface and tag the service:
services:
my_module.tenant_resolver:
class: Drupal\my_module\MyTenantResolver
tags:
- { name: orchestra_engine.tenant_resolver, priority: 0 }
Mapping a domain to a tenant¶
The optional orchestra_domain submodule maps the active domain (from the
Domain module) to a tenant, so each
domain runs its own isolated set of processes on one Drupal instance. Each
tenant names the domains it lives on, on its own Domains form: its
primary domain, which is required and speaks for it, any secondary
domains it is also reached through, and a workspace domain when its
operators work on a separate site. A request served on any of them acts in that
tenant, falling back to the default when no tenant names the domain. A domain
belongs to one tenant, and the form refuses one another tenant already names.
A tenant with no workspace domain has its task links rendered on its primary
domain, never on another tenant's, since a link there would open another
tenant's inbox.
The names are stored on the tenant rather than on the domain record, so a domain record carries nothing of Orchestra's. It is just another tenant resolver, so the kernel itself stays domain-agnostic.
Tenant-scoped operators¶
The process instances UI (provided by orchestra_ui) lives under Structure at
/admin/orchestra/instances, with a menu link and a canonical link
template per instance. Two permissions gate it:
- Access Orchestra process instances (
access orchestra instances): a tenant-scoped operator who sees and drives only their own tenant's instances. The list and the per-instance page resolve the active tenant the usual way and show only that tenant's rows. - Administer Orchestra (
administer orchestra): the cross-tenant super-permission. A holder reaches every tenant's instances and is not narrowed to the active one.
The list's filter follows the same split: a tenant-scoped operator narrows by workflow (their tenant's plus the shared ones) and status, while the tenant filter itself is offered only to an administrator, whose list spans every tenant.
The Incidents page beside it (/admin/orchestra/incidents), which
lists the open incidents of every running instance, takes the same two
permissions and the same split: a tenant-scoped operator sees their tenant's
incidents, an administrator every tenant's.
So you can give an operator day-to-day control of one realm's processes without handing them the keys to every other tenant on the site.
Who reads a realm's runs¶
Separately from those operator permissions, a tenant decides how much of a run's own audience may read it: its Reading operation on the Tenants list sets the read access for every workflow in the realm, overriding the site setting, and any workflow of the realm may override the tenant in turn. That is about reading a run without administrative rights, not about driving it, and it never crosses tenants: a run is readable only from inside its own tenant, whatever the scope says.
How a realm's notifications are sent¶
A tenant's Notifications operation names the notification profiles its workflows' notifications follow, one per kind of notification, overriding the site's; a workflow and a node may name another in turn. A profile may be scoped to one tenant, as a status is: it is offered there only, and it sends that tenant's notifications only, wherever it is named.