Skip to content

Tenant Model

Decided, not yet built

This page describes the target architecture for tenancy — decided, but not yet implemented. It supersedes the Multi-Tenancy note in the Architecture page. For how Conflux recognises the same customer across systems, see Cross-System Identity. For the rationale behind each individual decision, see the Decisions section — this page covers the concept, not the "why".

In one sentence

Conflux gains a missing entity — the Tenant — that holds the same company together across every connected system; this lets the current App entity be dissolved and turns Conflux into a clean adapter hub with one real owner behind every connection.

What it's about

Every externally connected system — a shop like WooCommerce, or a SaaS product like User Platform or rapidmail itself — is, from Conflux's point of view, an interchangeable adapter (in code: a System with a SystemDriver and an integration). It is something you can ask for data, that can send you signals, and that you can send signals to.

Picture Conflux as a train station. Adapters are the tracks to different cities. But the actual passenger — the Tenant, e.g. "Acme GmbH" — boards and alights at several tracks. The station has to know it is the same passenger, so their luggage (the data) ends up in the right place.

Today's model has no seat for that passenger. App (rapidmail, User Platform) is neither the passenger nor a track — it is a third, ill-fitting category. This concept introduces the passenger, removes the ill-fitting category, and adds the two capabilities Conflux is missing to move luggage in every direction.

Glossary

Term Meaning
Adapter / System An externally connected system (shop or SaaS). Defines only the how / what / where: data mapping, required fields, transfer style. In code: System + SystemDriver + an integration.
Tenant (new) The real company that is the same across several adapters (e.g. Acme GmbH with a WooCommerce shop and a User Platform account). Answers the who. The code entity is named Tenant; product/UI copy may say "Mandant".
SystemConnection The concrete, credential-bearing connection of a Tenant to an adapter. Exists today, but currently hangs off App instead of a Tenant.
SyncRule (new) A data-driven rule stating that one data domain flows from a source connection to a target connection. Replaces hard-wired sync logic.
Sync direction One of four capabilities an adapter may support: PullIn, PushIn, PushOut, PullOut (see below).
App (removed) The former Sanctum-authenticated tenant/access boundary. It conflated a data scope and an API-client identity, and was neither the Tenant. Fully dissolved — model, admin resource, and the laravel/sanctum dependency itself are all gone. See ADR-0002.

Target picture: three levels, Tenant instead of App

graph LR
    T["Tenant<br/>Acme GmbH"]
    T -->|"1 · hasMany"| SC1["SystemConnection<br/>Acme@WooCommerce"]
    T -->|"1 · hasMany"| SC2["SystemConnection<br/>Acme@User Platform"]
    SC1 -->|"2 · belongsTo"| A1["System / Adapter<br/>WooCommerce"]
    SC2 -->|"2 · belongsTo"| A2["System / Adapter<br/>User Platform"]
    SC1 -.->|"3 · owns rows"| C1["Customer rows"]
    SC2 -.->|"3 · owns rows"| C2["Customer rows"]
    CI["CustomerIdentity<br/>match_key = norm. email"]
    CI -.->|"4 · links"| C1
    CI -.->|"4 · links"| C2
    T ==>|"5 · scopes"| CI
  1. A Tenant owns many SystemConnections (edge 1).
  2. Each connection belongs to one adapter (edge 2) — the connection carries the credentials, the adapter carries the behaviour.
  3. Customer/Order/Product rows already hang off the connection today (edge 3), not off the Tenant. This matters: the correlation gap lives here, one level below the Tenant.
  4. A CustomerIdentity links per-connection Customer rows that are the same real person (edge 4).
  5. The Tenant is the scope inside which correlation and admin access are safe (edge 5) — email matching only ever happens within one Tenant.

Tenant is a new entity rather than a renamed App — see ADR-0002 for why, and how App is dissolved into the adapter model in the same move. Edges 4 and 5 — how CustomerIdentity links Customer rows within a Tenant's scope — are covered in Cross-System Identity.

The two functions of a Tenant

The word "Tenant" does two jobs that must be kept apart. Conflating them is exactly what made App awkward.

  • Function 1 — connection grouping. The Tenant bundles several SystemConnections (Acme@WooCommerce + Acme@User Platform). This is set explicitly at onboarding — a human states that these connections belong to the same company. No guessing.
  • Function 2 — the scope for identity and access. The Tenant is the boundary inside which cross-system customer correlation is allowed to run (see Cross-System Identity for the mechanism), and the boundary that admin users are granted access to. Without this scope, matching customers by email would merge unrelated companies that happen to share a connected platform — a data leak. Access to that boundary is derived from a support user's platform membership — see Admin access model.

Entities and data model

Entity Status Core Replaces / relates to
Tenant new name, is_active; not Authenticatable; hasMany SystemConnection; admin access derived from system_user (see Admin access model) replaces App as the scope unit
system_user + PlatformSupport new operator-membership pivot (system_id, user_id) + a scoped role; derives platform-support tenant access from the connection graph the derived half of admin access
SystemConnection changed app_idtenant_id; unique becomes ['tenant_id','system_id','external_user_id'] still the credential holder (credentials stays encrypted:json)
System / adapter extended new direction-matrix field (own column, not capabilities); system_user support membership; internal products are already onboarded as ordinary System rows new SystemDriver::RapidMail, ::UserPlatform
SystemConnectionToken repurposed new purpose: handshake (short-lived, single-use, as today) vs. access (long-lived, for pull-out) replaces the App Sanctum token for pull-out
SyncRule new source_connection_id, target_connection_id, data_domain, is_active, optional field-mapping overrides data-driven sync control

Adapter direction matrix

An adapter can support any combination of four directions. Today this is rigidly tied to the system type; it should be a free matrix per adapter.

Direction Meaning Example Today
PullIn Conflux actively queries the adapter Bulk export from WooCommerce ✅ present
PushIn Adapter reports to Conflux Webhook from WooCommerce ✅ present
PullOut Adapter reads Conflux rapidmail reads via REST ✅ present, but bound to App → moves to a per-connection token
PushOut Conflux actively writes to the adapter Write order status back to WooCommerce ❌ missing entirely

Direction is modelled as its own field — a domain × direction map on System — rather than overloading the existing capabilities field. See ADR-0004 for why.

Sync rules: additive, data-driven

The matrix says what an adapter can do. The SyncRule says what actually runs for a given Tenant.

SyncRule
  source_connection_id   → e.g. Acme@WooCommerce
  target_connection_id   → e.g. Acme@User Platform
  data_domain            → Customers | Orders | Products   (existing SystemCapability enum)
  is_active              → bool
  field_mappings (opt.)  → JSON overrides per rule

Rules are read, not just recorded: a connection's scheduled pull-in fetches only the domains an active rule moves out of it, and the admin panel's manual bulk sync offers only those domains. See Scheduled Sync for the dispatch path and Sync Rules for the full derivation.

Each SyncRule is unidirectional (source → target); bidirectional sync is two rules. See ADR-0005 for why, and how a rule is validated against the direction matrix.

Details (technical): matrix ↔ rule interaction

Validation reads the domain×direction map of both endpoints. For source → target, domain = Customers: assert Customers is in the source's matrix with an inbound direction (pull_in/push_in), and in the target's matrix with an outbound-delivery direction (push_out) — or that the target pulls via pull_out.

The rule engine, not the integration, decides what flows; integrations only expose capabilities and execute transfers.

A rule's source and target connection must also belong to the same Tenant — the direction matrix alone says nothing about ownership, and without this check a rule could move one company's data into another's. See ADR-0010. Both this check and the matrix check only run while a rule is is_active; a rule can be saved inactive as an unvalidated draft. See Sync Rules for the full validation and model reference.

Auth model now that App is gone

App used to be Authenticatable (HasApiTokens) and was the Sanctum principal that pull-out consumers authenticated as. App — and the laravel/sanctum dependency itself — has since been removed entirely, so pull-out auth had to land somewhere else.

Tenant is deliberately not Authenticatable; pull-out requests authenticate with a per-connection bearer token instead, via a dedicated connection guard. See ADR-0006 for why, and API Authentication for how the guard resolves a token to a SystemConnection.

Details (technical): what changed concretely
  • SystemConnectionToken has a purpose. handshake keeps the original behaviour exactly (single-use, ~1h expiry, used_at consumed on activate()). access is the long-lived pull-out credential.
  • The connection guard (App\Auth\SystemConnectionTokenGuard) authenticates the incoming pull-out call by its access token and resolves the owning SystemConnection; the EnsureSystemConnectionIsActive middleware applies the tenant_id/health scope for the request.
  • Admin access is re-pointed off App: see Admin access model for the full picture (reach derived from system_user, connection scoping split full/status-only). The access API is Tenant::accessibleBy(), SystemConnection::accessibleBy() and fullyAccessibleBy(), SyncRule::accessibleBy() and manageableBy(). App's membership pivot — and the apps table itself — have been dropped.
  • The internal products (rapidmail, User Platform) each get a SystemConnection like any adapter; when they pull Conflux data they present that connection's access token.

Admin access model

The auth model above is the machine side — how a system authenticates to pull data. This is the human side: which tenants a person sees when they log into the admin panel. Conflux keeps the two apart on purpose; binding a person's reach to a data pipe is one of the things that made App awkward.

Two kinds of human log in:

  • Platform operators (internal). Staff who run Conflux itself — adapters, tokens, connections, sync. SuperAdmin, unscoped.
  • Platform support (consuming side). Support staff of a consuming platform (e.g. User Platform) who open the tenant behind one of their clients to help them. Scoped to the tenants connected to their platform, and to their data within each.

End customers (shop operators) do not log into the admin panel — they self-serve inside their shop via the plugin and a per-connection token.

Platform-support access is derived from system membership rather than granted per tenant, so no per-tenant assignment row exists anywhere, and a support user's own connections are visible in full while sibling connections are status-only. See ADR-0007 for the full rationale.

Details (technical): what changes concretely
  • New system_user pivot (system_id, user_id); relations User::operatedSystems() and System::supportUsers().
  • New SystemRole::PlatformSupport; permissions = view tenants / connections / synced data + trigger a resync on own connections; no token or credential access, no create/delete of tenants or systems.
  • Tenant::scopeAccessibleBy computes the derivation above, with the SuperAdmin short-circuit unchanged.
  • SystemConnection scoping splits into a full scope (own systems under accessible tenants) and a status-only scope (siblings under accessible tenants); the admin panel surfaces the latter read-only.
  • Data-record queries scope through SystemConnection::accessibleBy(), so the records of sibling connections are visible too.