Skip to content

Multi-Tenancy Architecture

Arbitex is a multi-tenant system. Each customer organization (org) is a fully isolated tenant. This reference describes the isolation model, per-org configuration surface, data isolation guarantees, and how the Hybrid Outpost extends tenancy to on-premises deployments.


An org (organization) is the top-level tenant unit in Arbitex. Every org has a UUID, referred to throughout the system as org_id or tenant_id. These identifiers are interchangeable — tenant_id is the field name used in database models; org_id is used in API paths.

There is no organizations table — org identity is carried by tenant_id on every scoped resource. All data access is filtered by tenant_id at the query layer. There is no application-level object that “owns” a tenant; isolation is enforced by the presence of tenant_id on every resource.


Every resource that is tenant-scoped carries a tenant_id (UUID) column. Resources are isolated at the database query layer — every query that accesses tenant data filters by tenant_id == current_user.tenant_id. Cross-tenant access is structurally prevented by this pattern.

Tenant-scoped resources:

Resource Table Isolation field
Users users tenant_id
Groups groups tenant_id
API keys api_keys tenant_id
Audit log entries audit_logs tenant_id
IP allowlist entries ip_allowlist_entries tenant_id
Policy chains policy_pack_chains scope_id (org UUID)
Policy packs policy_packs tenant_id
Policy rules policy_rules (scoped via pack → chain)
DLP rules org_dlp_rules org_id
Compliance bundle bindings org_compliance_bundles org_id
SCIM tokens org_scim_tokens org_id
Outpost audit sync entries audit_logs tenant_id + outpost_id

When a user authenticates (JWT or API key), the platform loads the tenant_id from the user record and places it in the request scope state. This value flows through all middleware and route handlers as the authoritative tenant identifier for the request.

The require_admin dependency verifies that the user’s role == "admin" and that their tenant_id matches the resource being accessed. Admin privileges are scoped to a single tenant — there is no global admin role in the application layer.

The audit log search API (GET /api/v1/admin/audit-logs/) enforces tenant scoping explicitly:

base_query = select(AuditLog).where(AuditLog.tenant_id == current_user.tenant_id)

An admin cannot query audit entries for any other org. The tenant_id filter is applied before any user-supplied filter parameters — it cannot be overridden by the API caller.


Arbitex JWTs are HS256 (user tokens) or RS256 (M2M tokens). Neither format embeds tenant_id directly in the token payload. Instead, tenant context is loaded from the database on every authenticated request.

The access token payload contains:

Claim Value Purpose
sub User UUID string Identifies the authenticated user
role "admin" or "user" Role claim for fast authorization checks
jti UUID string JWT ID for per-token revocation
type "access" Distinguishes access tokens from refresh/MFA tokens
iat Unix timestamp Issued-at — used for bulk revocation check
exp Unix timestamp Expiry
mfa_verified Boolean (optional) Present and true if MFA was completed in this session
passkey_enrollment_required Boolean (optional) Present and true if org policy requires passkey and user has none

The tenant_id is not in the token. It is loaded from users.tenant_id after token validation. This is a deliberate security design: a compromised token cannot claim a different tenant — the tenant binding lives in the database, not the token.

Extraction flow in get_current_user (backend/app/core/dependencies.py):

# 1. Decode and verify JWT (HS256 or RS256 depending on alg header)
payload = decode_access_token(token)
# 2. Check JTI revocation via bloom-filter-accelerated blacklist
jti = payload.get("jti")
if jti and await blacklist_backend.is_blacklisted(jti, db):
raise HTTPException(401, "Token has been revoked")
# 3. Load user by sub claim — tenant_id is on the user row
user_id = uuid.UUID(payload.get("sub"))
user = await db.execute(select(User).where(User.id == user_id))
# user.tenant_id is now the authoritative tenant context for the request

API keys use the X-API-Key header or Authorization: ApiKey <key> prefix. Key authentication resolves to a User record with the same tenant_id semantics as JWT authentication.

Key format: mmx_<32 hex chars> (40 characters total). Keys are stored as SHA-256 hashes (api_key_auth.py):

# Lookup: hash incoming key, find matching APIKey row
key_hash = hashlib.sha256(key.encode("utf-8")).hexdigest()
api_key_record = await db.execute(
select(APIKey).where(APIKey.key_hash == key_hash)
)
# Resolve to User — tenant_id comes from user row
user = await db.execute(select(User).where(User.id == api_key_record.user_id))
# user.tenant_id anchors the request to the correct org

Authentication precedence in get_current_user:

  1. X-API-Key header checked first
  2. Authorization: ApiKey <key> prefix checked second
  3. JWT Bearer token checked last

Regardless of auth method, the resolved User.tenant_id is the authoritative tenant context. There is no way to authenticate as a user in one org and operate against resources in another.

MFA enforcement is configured per-org (org_mfa_policies table, enforcement_level field):

  • "off" — MFA is not enforced (default)
  • "optional" — MFA is encouraged but not required
  • "required" — All sensitive endpoint requests must carry a JWT with mfa_verified=True; requests without this claim are rejected with HTTP 403

When MFA is required and a user authenticates with password only, the login flow returns a short-lived MFA challenge token (type: "mfa_challenge", 5-minute expiry). The user must complete TOTP verification before a full access token with mfa_verified: true is issued. This claim travels with the JWT for the duration of the session.

Passkey enforcement is similarly per-org (org_passkey_policies table, passkey_level field: "off" | "encouraged" | "required"). When "required" and the user has no registered passkey, the access token carries passkey_enrollment_required: true, triggering the mandatory enrollment flow in the frontend.


API keys are scoped to a tenant_id via the owning user. All keys belonging to users in org A are isolated from org B — there is no cross-org key sharing. Keys carry no org-level grant; they authenticate as a specific user whose tenant_id determines the tenant scope.

On the Cloud portal side, the api_keys table (cloud/db/models.py) uses a direct org_id FK to organizations.id:

class ApiKey(Base):
__tablename__ = "api_keys"
org_id = Column(String(36), ForeignKey("organizations.id", ondelete="CASCADE"), ...)
key_hash = Column(String(64), ...) # SHA-256 hex digest
scopes = Column(JSON, ...) # JSON array of scope strings

This means cloud-plane API keys are org-bound at the row level. Deleting or suspending an org cascades to its API key rows.

The authentication path makes cross-org key use structurally impossible. Presenting an API key from org A against an endpoint scoped to org B produces HTTP 401 — the key’s SHA-256 hash either resolves to a user in org A (query succeeds, but the user’s tenant_id is org A, so all queries are filtered to org A) or to no user at all. There is no pathway by which a key from org A returns data from org B.

API keys are individually revocable. The is_enabled flag and expires_at column on each key row allow rotation without affecting other keys or other orgs. On the platform side, rotating a key in org A involves no state change in org B’s tables — there is no shared key pool.

SCIM tokens have a distinct rotation model (org_scim_tokens table): the active token is identified by rotated_at IS NULL. On rotation, the previous token row gets rotated_at set to the rotation timestamp (preserving history for audit), and a new row is inserted. The raw token is returned exactly once at rotation time and never stored. Rotation in one org does not touch rows for any other org.


Each org has an independent configuration surface. No configuration is shared between orgs.

Policy evaluation uses two chain scopes:

  • scope="org" — the org-level policy chain, applied to all users in the org
  • scope="user" — per-user override chain (personal policy adjustments)

The org chain is the primary enforcement mechanism. Rules use user_groups conditions to target specific groups within the org. Group membership is a condition on rules, not a separate chain scope.

Policy packs (bundles) are either:

  • Compliance bundles — pre-built packs locked for editing (is_bundle=true)
  • Custom packs — org-created packs with admin-editable rules

Policy chains and packs are scoped to tenant_id. A pack created by one org is invisible to all other orgs.

Org-level DLP rules (org_dlp_rules table, org_id column) layer on top of platform-level patterns. Each org can add custom detection patterns without affecting other tenants. Platform DLP patterns are global (no tenant_id) and run before org-specific rules.

Model access controls are configured per group within an org (group_model_access table). Routing rules (routing_rules table) are scoped to tenant_id. An org’s routing rules, fallback chains, and latency thresholds are independent of all other orgs.

Usage quotas (quota table) are scoped per-org. Token budgets, daily request limits, and cost caps apply within a single org’s usage counters (org_usage_counter table, org_id column). Usage from one org does not consume quota from another.

Per-org SCIM tokens (org_scim_tokens table, org_id column) are stored as bcrypt hashes. Each org has its own SCIM bearer token. Token rotation on one org does not affect any other org’s provisioning. See SCIM provisioning guide for configuration details.

Per-org IP allowlist entries (ip_allowlist_entries.tenant_id) are checked in isolation. An org with IP restrictions does not affect access for users in other orgs. An org with no allowlist entries permits all source IPs, regardless of what other orgs have configured.


DLP configuration is isolated at two levels: the org can add custom detectors and can suppress specific platform-level detectors. Neither change affects any other org.

The OrgDLPLayer class (backend/app/core/org_dlp_layer.py) is the composable integration point. It is instantiated per-request with the org’s UUID:

layer = OrgDLPLayer(org_id)
await layer.load(db) # loads rules via 60-second TTL cache

On load, rules from org_dlp_rules are separated into two groups:

  • suppress_default rules — store the platform pattern name to exclude from results
  • custom_pattern rules — regex patterns compiled with safe_compile() (timeout-protected)

The DLP pipeline runs in this order:

  1. Platform scan — global dlp_rules (no org_id) are evaluated first. Results are a list of DLPMatch objects.
  2. Org suppression filterlayer.filter_platform_matches(matches) removes any match whose detector_name is in the org’s suppressed set.
  3. Org custom patternsawait layer.scan_custom_patterns(text) evaluates the org’s compiled regex patterns and produces additional DLPMatch objects.
  4. Merge — filtered platform matches and org custom matches are combined for downstream action evaluation.

Custom pattern match names use the format org_custom:{rule_name}:{action_tier} in the detector_name field, making org-sourced matches distinguishable from platform matches in audit logs.

When a compliance bundle is active for an org (org_compliance_bundles table), its bundle rules are evaluated during DLP scanning and cannot be suppressed by org custom rules. The disabled_at column on OrgComplianceBundle supports soft-disable while preserving activation history for audit purposes. Bundle definitions are global (platform-level); per-org bindings in org_compliance_bundles activate them for individual orgs.


Usage is tracked in org_usage_counters (one row per org, OrgUsageCounter model). The counter row stores:

Field Description
org_id Unique — one row per org (enforced by uq_org_usage_counters_org_id)
plan_tier Denormalized tier key for fast limit lookup without a join
request_count Cumulative requests in the current billing period
period_start / period_end First and last day of the current billing month
last_request_at Timestamp of the most recent request increment

Plan tiers (PlanTier enum) and their request limits:

Tier Key Monthly request limit
Developer Free devfree_saas 100,000
Developer Pro devpro_saas 1,000,000
Team team_saas 1,000,000
Enterprise SaaS enterprise_saas Custom
Enterprise Outpost enterprise_outpost Custom

The service performs atomic upsert increments (INSERT ... ON CONFLICT) on the counter row. When period_start falls before the first day of the current calendar month, the service resets request_count to 0 and updates the period columns — this is the billing rollover. Usage from one org does not affect another org’s counter.

The org_usage_rollups table materializes aggregated usage at three granularities per org:

period_type Window
hourly 1-hour window
daily 24-hour window
monthly Calendar month

Each rollup row is keyed by (org_id, period_type, period_start) — a unique constraint ensures idempotent upserts. The model_breakdown JSONB column stores per-model statistics (request count, input tokens, output tokens, cost) for the window. This enables org-specific usage dashboards and per-model cost attribution without cross-tenant data exposure.

The Outpost reads budget caps from the signed policy bundle delivered by the Platform. Budget enforcement is local to the Outpost instance, which is org-bound. The budget_enforcement_enabled configuration flag controls whether budget checks are active (default: True). When a budget cap is hit, the Outpost blocks requests for that org without impacting other org instances.


Data type Isolation mechanism
Conversations and messages tenant_id on conversations and messages tables
Audit trail tenant_id filter on all queries; HMAC chain is per-org sequence
API keys tenant_id on api_keys; keys can only authenticate users in the same org
Groups and memberships tenant_id on groups; group policies apply only within the org
Users tenant_id on users; cross-org user queries are not possible
Policy engine configuration Chain scope uses org UUID as scope_id
DLP configuration org_id on custom rules; org_id on compliance bundle bindings
SIEM configuration tenant_id on SIEM connector configs
Compliance bundle state Per-org enable/disable binding in org_compliance_bundles
MFA policy org_id on org_mfa_policies — one row per org
Passkey policy org_id on org_passkey_policies — one row per org
Usage counters org_id on org_usage_counters — one row per org
Usage rollups org_id on org_usage_rollups — rows keyed by org
IP allowlist org_id on org_ip_allowlists — entries are per-org

What is shared across orgs (platform-level)

Section titled “What is shared across orgs (platform-level)”
Shared resource Notes
Platform-level DLP patterns dlp_rules without org_id — available to all orgs, read-only
Compliance bundle definitions Bundle templates are global; per-org bindings activate them
Model catalog Provider configurations and model availability are platform-level
GeoIP / CredInt databases Shared MaxMind and CredInt Bloom filter databases
Infrastructure Kubernetes cluster, PostgreSQL instance (separate databases per environment, row-level tenant isolation within tables)

Arbitex does not store prompt or completion content by default. When prompt_text and response_text appear in audit entries, they are present only when prompt retention is explicitly enabled for the org. The default behavior is to omit this content from audit entries — only the metadata (token counts, model, provider, action, DLP category match) is retained.


IP allowlist middleware reads org context from scope["state"]["org_id"], which is set by authentication middleware from the authenticated user’s tenant_id. An attacker cannot specify a different org_id — it is derived from the authenticated JWT or API key, not from request parameters.

No query in the platform codebase uses unbounded access to tenant-scoped tables. All queries that access users, groups, audit logs, policy configurations, or API keys include an explicit tenant_id filter as part of the base query construction. The filter is applied before any user-supplied filters.

The require_admin FastAPI dependency enforces both role and tenant scope. It:

  1. Verifies the JWT is valid and the user exists
  2. Checks user.role == "admin"
  3. Loads the user’s tenant_id for downstream use in query scoping

A user with role=admin in org A cannot access resources in org B — the tenant_id carried in their JWT anchors all queries to their own org.

Per-org SCIM tokens (org_scim_tokens) use org_id as the scope key. Token verification looks up only WHERE org_id = ? for the specific org in the path parameter. Presenting a valid SCIM token for org A against org B’s SCIM endpoint returns 401.


The platform user model has two roles: "admin" and "user" (defined in UserRole enum, backend/app/models/user.py). The role=admin designation is org-scoped — it grants admin capabilities within the user’s own tenant only. There is no application-layer global admin role.

Capability role=user role=admin (org-scoped)
Read own conversations Yes Yes
Manage own API keys Yes Yes
View org audit logs No Yes (own org only)
Manage org users and groups No Yes (own org only)
Configure policy chains No Yes (own org only)
Activate compliance bundles No Yes (own org only)
Configure SCIM provisioning No Yes (own org only)
Configure IP allowlist No Yes (own org only)
Access another org’s data No No

Org admin access to any admin endpoint is verified by require_admin (core/dependencies.py), which both checks role == "admin" and anchors the session to the user’s tenant_id. An org admin cannot escalate to access another org.

The Cloud portal (arbitex-cloud) has a separate admin role model in org_admins table (cloud/db/models.py). Portal admin roles are:

Role Key Scope
Owner owner Full control of the org in the portal; can invite/remove admins
Admin admin Org admin in the portal
Member member Read-only access to portal org settings

These roles govern access to the Cloud portal (outpost management, billing, org provisioning). They are distinct from the platform UserRole admin designation that governs access to platform API admin endpoints.

The customer admin API endpoints enforce tenant_id filtering — Arbitex staff cannot use the customer API to access data across organizations. There is no “super-admin” bypass in the customer API surface.

The Cloud portal (cloud/db/models.py) supports bulk revocation via the tokens_revoked_before column on the Organization table. Setting this to a timestamp invalidates all tokens issued before that time for the org. Individual token revocation uses the jwt_revocations table keyed by jti. Both checks are performed on every authenticated request in get_org_context (cloud/api/deps.py):

# 1. Individual jti revocation
revoked = await db.execute(
select(JwtRevocation).where(JwtRevocation.jti == jti)
)
# 2. Bulk revocation: token iat must be > org.tokens_revoked_before
org_result = await db.execute(
select(Organization.tokens_revoked_before).where(Organization.id == sub)
)

A Redis cache (keyed by jti) accelerates repeat-request validation within the token’s remaining TTL. The cache is invalidated on revocation events.


Each Outpost instance is bound to exactly one org. The org_id is an explicit configuration field (OutpostSettings, outpost/config.py):

org_id: str = Field(
default="",
description="Organisation UUID. Required for Cloud API paths (heartbeat, etc.).",
)

This org_id is required for all management plane operations. If it is not set, heartbeat and policy sync are skipped:

if not self.settings.platform_management_url or not self.settings.outpost_id or not self.settings.org_id:
logger.debug("Heartbeat skipped — platform URL, outpost ID, or org ID not configured")
return False

The heartbeat endpoint URL is built from org_id:

POST /v1/orgs/{org_id}/outposts/{outpost_id}/heartbeat

This URL structure means the Platform can verify at the routing layer that the outpost_id in the path belongs to the org_id in the path, cross-checking against the tenant_id embedded in the presenting mTLS certificate.

When an Outpost registers, the Cloud control plane issues a client certificate with tenant_id embedded. Subsequent communications use mTLS — the Outpost presents this certificate on every connection. The ssl.SSLContext is built with check_hostname = True and verify_mode = ssl.CERT_REQUIRED, preventing unverified connections:

ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
ctx.load_cert_chain(certfile=cert_path, keyfile=key_path)
ctx.load_verify_locations(cafile=ca_path)
ctx.check_hostname = True
ctx.verify_mode = ssl.CERT_REQUIRED

The outpost will not start without the three certificate files (OUTPOST_CERT_PATH, OUTPOST_KEY_PATH, OUTPOST_CA_PATH). There is no fallback to unverified connections.

The policy bundle delivered to each Outpost at GET /v1/internal/org/{org_id}/policy is bound to the requesting org by the URL structure. The bundle is additionally protected by HMAC-SHA256 (POLICY_HMAC_KEY) for tamper detection:

The canonical form for HMAC verification is:

canonical_bundle = {k: v for k, v in bundle.items() if k != "bundle_hmac"}
canonical_json = json.dumps(canonical_bundle, sort_keys=True, separators=(",", ":"))
expected_hmac = hmac.new(key.encode(), canonical_json.encode(), hashlib.sha256).hexdigest()

Verification additionally enforces a freshness check: bundles older than 3600 seconds (1 hour) based on generated_at or synced_at are rejected, even if the HMAC is valid. A tampered or stale bundle is never applied — the Outpost continues operating with its cached bundle rather than applying an unverified one.

Provider API keys within the bundle can be encrypted with a Fernet key (PROVIDER_KEY_ENCRYPTION_KEY). When set, api_key_encrypted fields are decrypted at runtime; plaintext api_key fields are used only in dev mode.

Policy sync uses the same mTLS client as heartbeat. The policy delivered is exactly the chain for the Outpost’s org — no other org’s policies are ever included in a bundle. The Outpost’s org_id is embedded in the sync URL:

GET /v1/internal/org/{org_id}/policy

ETag-based conditional requests (If-None-Match) mean the Outpost only processes a new bundle when the policy has actually changed. The cached bundle is written atomically (write-to-temp, then rename) with 0o600 permissions so only the outpost process owner can read it.


Org creation assigns a UUID as the tenant_id. Initial configuration:

  • No users (first admin is invited post-creation)
  • No policy chain (platform default ALLOW applies)
  • No DLP configuration
  • No compliance bundles active
  • No IP allowlist (all IPs permitted)

After creation, org admins:

  1. Configure authentication (SAML IdP, OIDC, or local)
  2. Set up SCIM provisioning (optional)
  3. Create groups and assign users
  4. Activate compliance bundles and build policy chains
  5. Configure model access and routing rules
  6. Optionally configure IP allowlisting

Configuration changes (policy updates, user management, compliance bundle toggles) are audit-logged with the admin user ID and timestamp.

When an org is suspended, user.is_active = false is set for all users in the org. API keys and SCIM tokens continue to exist in the database but authentication fails because user accounts are inactive. Data is retained intact for the retention period.

On org deletion, all tenant-scoped rows are removed (cascades and SET NULL foreign keys preserve audit log integrity — user_id is set to NULL on audit entries rather than deleting the entries).


The Hybrid Outpost is a self-hosted Gateway deployment that connects to the Arbitex Cloud control plane. Each Outpost instance maps to a single org via the org’s tenant_id.

When an Outpost registers with the Cloud control plane (POST /cloud/api/outposts/register), it is issued a client certificate with tenant_id embedded. Subsequent communications from the Outpost present this certificate for mTLS authentication. The tenant_id in the certificate anchors all Outpost operations to the correct org.

The Cloud control plane syncs the org’s policy chain to the Outpost on registration and on each heartbeat (PUT /cloud/api/outposts/{outpost_id}/heartbeat). The policy chain delivered is exactly the chain for the Outpost’s org — no other org’s policies are ever sent to an Outpost instance.

Outpost-generated audit events are synced to the Cloud control plane and stored with:

  • tenant_id = the org’s UUID (same as cloud-generated entries)
  • source = "outpost"
  • outpost_id = the Outpost instance UUID

The HMAC chain covers Outpost-synced entries identically to cloud entries. The audit log presents a unified, chronologically ordered view of all events for the org regardless of origin.

In an air-gap deployment, Outpost audit events remain on-premises until sync occurs. For organizations with data residency requirements, the audit sync interval can be configured, or sync can be disabled entirely (the Outpost then provides local-only audit access via its own API).