Skip to main content

Multi-tenancy

One Nexora deployment can serve several teams or customers, each seeing only its own executions, history, dead letters and schedules. Every execution belongs to a tenant, named by a tenant id such as acme.

Single-tenant is the default and behaves exactly as Nexora did before tenants existed: everything belongs to the tenant default and nothing is filtered.

Tenant ids​

1 to 64 characters of a-z, 0-9, - and _, starting with a letter or digit. Ids are lowercase so Acme and acme can never become two tenants. Anything else is rejected where it enters: the builder, an Intent, the X-Tenant-ID header, or nexora user.

Library: tenants on intents​

Intent intent = new Intent("process order", Map.of("orderId", "123"))
.withTenantId("acme");
engine.execute(intent);

An intent without a tenant runs for default. The tenant is stored with the execution, its steps, webhook deliveries, its dead letter if it fails, and any schedule created from it, so every firing of that schedule runs for the same tenant. A replay runs for the tenant of the execution it replays.

Capabilities read the tenant from their request, for per-tenant credentials or state:

public CapabilityResult execute(CapabilityRequest request) {
String tenant = request.tenantId(); // never null; "default" for single-tenant work
...
}

Planners and the engine see the same value as ExecutionContext.tenantId().

Tenant-scoped lookups treat another tenant's execution exactly like an unknown id:

CallAnother tenant's id
engine.replay(tenantId, executionId)ExecutionNotFoundException
engine.cancel(tenantId, executionId)fails with ExecutionNotFoundException
engine.cancelSchedule(tenantId, scheduleId)returns false, changes nothing
engine.listSchedules(tenantId)not listed
store.findById(tenantId, executionId)Optional.empty()
store.findByFilter(filter.forTenant(tenantId))not returned, not counted
store.findDeadLetters(tenantId, state, offset, limit)not returned

One engine per tenant​

Engines sharing a database each need their own store, because an engine closes its store when it shuts down. Build every store on one caller-owned pool; a store over a caller's DataSource leaves the pool open:

HikariDataSource pool = HikariCPDataSourceFactory.create(jdbcUrl, PoolConfig.defaults());
NexoraEngine acme = NexoraEngine.builder()
.withExecutionStore(new JdbcExecutionStore(pool))
.withTenantId("acme")
.build();
NexoraEngine globex = NexoraEngine.builder()
.withExecutionStore(new JdbcExecutionStore(pool))
.withTenantId("globex")
.build();

A tenant-bound engine runs intents without a tenant for acme, refuses an intent for any other tenant with IllegalArgumentException, and its replay, cancel and schedule calls only reach acme's work. Its cron scheduler reloads and fires only acme's schedules.

Bound and unbound engines can share one store. An unbound engine serves every tenant, so it also loads acme's schedules, but every firing is claimed in the store before it runs and exactly one engine wins each slot (see Cron scheduling). Whichever engine wins runs the firing for the schedule's own tenant, so every engine sharing the store should have the capabilities that tenant's schedules need.

Capabilities for some tenants only​

A capability registered for every tenant (the default) can be restricted:

new CapabilityDescriptor("acme-billing", "Bills through Acme's account", inputs, outputs, true, false)
.withTenantIds(Set.of("acme"));

Other tenants neither plan with it (CapabilityRegistry.listFor(tenantId)) nor can invoke it: a step that names it fails with CAPABILITY_NOT_FOUND, the same answer as for an id that does not exist, so tenants cannot discover each other's capabilities. engine.listCapabilities(tenantId) lists what a tenant may use.

Observe server: --multi-tenant​

nexora user add alice --tenant acme
nexora user add bob --tenant globex
nexora observe --multi-tenant

Every /api route for executions, history, dead letters, schedules, webhook deliveries and the process snapshot, and the live WebSocket stream, then acts for exactly one tenant. Which one comes from the credential wherever the credential has one. The X-Tenant-ID header can confirm that tenant but never change it, since trusting a client-chosen header alone would let any user read any tenant:

CredentialTenantX-Tenant-ID
User key (nexora login, POST /api/keys) or user sessionThe user's tenantOptional. Another tenant: 403 TENANT_FORBIDDEN
Server API keyNamed per requestRequired. Missing: 400 TENANT_REQUIRED
Dashboard session opened with the server API keyThe tenant named when signing inOptional. Another tenant: 403 TENANT_FORBIDDEN

An invalid header is 400 INVALID_TENANT_ID. Errors use the usual schema:

{"error": {"code": "TENANT_REQUIRED", "message": "This server is multi-tenant: send X-Tenant-ID to name the tenant"}}

Another tenant's execution, dead letter or schedule answers 404, exactly like one that does not exist. New executions and schedules are created for the request's tenant.

# Server key: name the tenant on every request
curl -H "Authorization: Bearer $NEXORA_API_KEY" -H "X-Tenant-ID: acme" \
https://nexora.example.com/api/executions

# CLI with the server key
nexora cancel <execution-id> --tenant acme --server https://nexora.example.com

# CLI after `nexora login` as alice: her key already carries acme
nexora replay <execution-id> --server https://nexora.example.com

In the dashboard, users sign in as usual and see only their tenant. Signing in with the server API key asks for the tenant to open; that session stays on it.

Moving a user to another tenant ends their sessions and keys, like a password change:

nexora user tenant alice globex

Without --multi-tenant the server is single-tenant: every caller sees every execution and X-Tenant-ID is ignored.

Execution metrics at /metrics carry a tenant label (see Observability); /metrics itself stays public and unscoped, like every scrape endpoint.

Try it​

mvn -q install -DskipTests
# Library: tenants on intents, a tenant-only capability, a bound engine, a schedule firing once, metrics
java -jar tests/multi-tenant-demo/target/multi-tenant-demo.jar
# Server: alice (acme) and bob (globex) on a --multi-tenant observe server, with a guided tour
java -jar tests/login-demo/target/login-demo.jar

See the login demo for the tour.

Database​

Migration V3__add_tenant_id.sql adds a tenant_id column (VARCHAR(64) NOT NULL DEFAULT 'default') to nexora_executions, nexora_steps, nexora_webhook_deliveries, nexora_dead_letters and nexora_schedules. Existing rows join the default tenant and keep all their data; PostgreSQL 11 and later add the column without rewriting the table. Steps and webhook deliveries copy the tenant of their execution when written, so every table can be filtered on its own.

Locking down direct database access (PostgreSQL)​

The multi-tenant observe server and the tenant-scoped API above filter by tenant. Code that calls an ExecutionStore directly must use those tenant-scoped methods, or check the tenantId of what it reads: the by-id lookups (findById(id), findDeadLetterById, findScheduleById) return any tenant's row. People and tools that query the database directly, such as reporting users, are not covered by Nexora at all. PostgreSQL row-level security can hold them to their own tenant.

Nexora's own database user owns the tables (it runs the migrations), and table owners bypass row-level security, so Nexora itself is unaffected. Do not use FORCE ROW LEVEL SECURITY.

Map database roles to tenants in a table only an administrator can write, so a reader cannot pick another tenant for itself (a session variable could simply be SET to any value):

CREATE TABLE nexora_tenant_roles (
role_name NAME PRIMARY KEY,
tenant_id VARCHAR(64) NOT NULL
);
REVOKE ALL ON nexora_tenant_roles FROM PUBLIC;

DO $$
DECLARE t TEXT;
BEGIN
FOREACH t IN ARRAY ARRAY['nexora_executions', 'nexora_steps', 'nexora_webhook_deliveries',
'nexora_dead_letters', 'nexora_schedules'] LOOP
EXECUTE format('ALTER TABLE %I ENABLE ROW LEVEL SECURITY', t);
EXECUTE format(
'CREATE POLICY tenant_isolation ON %I FOR SELECT USING (tenant_id IN '
'(SELECT tenant_id FROM nexora_tenant_roles WHERE role_name = current_user))', t);
END LOOP;
END $$;

Then give each reader a role, its tenant, and read access:

CREATE ROLE acme_reporting LOGIN PASSWORD '...';
INSERT INTO nexora_tenant_roles VALUES ('acme_reporting', 'acme');
GRANT SELECT ON nexora_executions, nexora_steps, nexora_webhook_deliveries,
nexora_dead_letters, nexora_schedules TO acme_reporting;
GRANT SELECT ON nexora_tenant_roles TO acme_reporting;

acme_reporting now sees only acme's rows. A role with no entry in nexora_tenant_roles sees nothing. The policies only allow reads; grant no INSERT, UPDATE or DELETE to these roles.