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
- A Tenant owns many SystemConnections (edge 1).
- Each connection belongs to one adapter (edge 2) — the connection carries the credentials, the adapter carries the behaviour.
- 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.
- A CustomerIdentity links per-connection
Customerrows that are the same real person (edge 4). - 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_id → tenant_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
SystemConnectionTokenhas apurpose.handshakekeeps the original behaviour exactly (single-use, ~1h expiry,used_atconsumed onactivate()).accessis the long-lived pull-out credential.- The
connectionguard (App\Auth\SystemConnectionTokenGuard) authenticates the incoming pull-out call by itsaccesstoken and resolves the owningSystemConnection; theEnsureSystemConnectionIsActivemiddleware applies thetenant_id/health scope for the request. - Admin access is re-pointed off
App: see Admin access model for the full picture (reach derived fromsystem_user, connection scoping split full/status-only). The access API isTenant::accessibleBy(),SystemConnection::accessibleBy()andfullyAccessibleBy(),SyncRule::accessibleBy()andmanageableBy().App's membership pivot — and theappstable itself — have been dropped. - The internal products (rapidmail, User Platform) each get a
SystemConnectionlike any adapter; when they pull Conflux data they present that connection'saccesstoken.
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_userpivot (system_id,user_id); relationsUser::operatedSystems()andSystem::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::scopeAccessibleBycomputes the derivation above, with theSuperAdminshort-circuit unchanged.SystemConnectionscoping 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.