PayCargo · AI Platform · Security

How tenants keep customer data apart

More than one customer works on the same platform. A tenant is one customer's walled-off space. How is a tenant created, who can see it, and what stops one customer's data reaching another?
In one sentence: every tenant is a row in one registry that only obs can write; a person sees a tenant only if a Cognito group the tenant names includes them; the tenant they pick in the Console is only a request — every backend checks it again against the signed sign-in token — and the database itself refuses to return another tenant's rows. There is no default tenant: someone who belongs to none sees nothing.

The words

TermMeaning
TenantOne customer's space, e.g. t_aries (“Aries Freight Systems”). The id is permanent: it becomes the partition key in every tenant-scoped table, so it is never renamed or reused.
Tenant registryDynamoDB table ai-sandbox-tenants, owned and written only by obs, encrypted with the platform's own key. Every tenant-aware service reads it.
groupsThe Cognito groups whose members may work in the tenant. New tenants get admins and tenant-<slug>.
admin_groupsThe groups whose members may administer it (invite people, set roles). New tenants get admins and tenant-<slug>-admin. Administrators can always see their tenant too.
purpose / roleproduction or test (test tenants are labelled “· test”); customer or corpus (shared reference data).

How a request reaches the right tenant

obs ──writes──▶ ai-sandbox-tenants ◀──reads (cached 60s)── Console · ap-invoice · decision core · mock-email (registry) Person signs in ─▶ Console asks "which tenants may I see?" (live Cognito groups × registry) ─▶ top-bar picker shows them; choice remembered per person in the browser ─▶ cartridge iframe receives it (SDK: PCSDK.auth.tenant(), onTenantChange) ─▶ cartridge calls its backend with header x-tenant: t_aries ─▶ backend: requireTenant(token, x-tenant) ── not allowed ─▶ 403, never a fallback ─▶ decision core: same check, then SET app.tenant_id ─▶ Postgres row security
  1. The Console lists only what you may see. GET /api/cartridges returns tenants: [{id, name, purpose, role}], computed from your live Cognito groups (looked up at request time, so a group change applies without signing out) against the registry.
  2. The picker is a hint. The choice sits in the browser (localStorage, per person). A remembered tenant you can no longer see is dropped.
  3. Cartridges get it from the SDK (1.18+): PCSDK.auth.tenant(), PCSDK.auth.onTenantChange(cb), and PCSDK.auth.tenantHeaders(). PCSDK.api adds x-tenant to every call automatically.
  4. Every backend checks again. requireTenant in shared/modules/tenant.js compares the requested tenant with the groups in the verified token. Asking for a tenant you may not see is a 403 — it never quietly falls back to one you can.
  5. The database is the last wall. The decision core opens every transaction with set_config('app.tenant_id', …); its tables have row-level security FORCEd, and its app role apcore_app cannot bypass it. A query without a tenant returns nothing.

Who sees which tenant

One rule, admits() in shared/modules/tenant.js, used by every service:

Machines are different

A service token (OAuth client credentials) has no person and no groups. It is mapped to exactly one tenant in the registry and keeps it; a header may only move it to another tenant its own groups allow.

Running it: creating a tenant and adding people

  1. Open obs from the Console (needs admins). Go to Access → Tenants and create the tenant: id (t_ + lowercase letters, digits, _), name, purpose, role. Its groups are derived from the id — they can't be typed in, so nobody can create a tenant whose audience is somebody else's group.
  2. obs creates the two Cognito groups (tenant-<slug>, tenant-<slug>-admin), writes the registry row (refusing an id that already exists), and records a tenant-created admin event with who did it.
  3. On Access → Users, put people in one of them: tenant-<slug> to work in it, tenant-<slug>-admin to administer it.
  4. They reload the Console. Services pick a new tenant up within a minute (the registry is cached for 60 seconds per service).
Live today
Tenantst_aries — Aries Freight Systems (production, customer)
Groupsadmins, tenant-aries, tenant-aries-admin, cart-doc-vault
Registryai-sandbox-tenants in account 753974371195

Which apps are tenant-aware

App / serviceStatusHow
ConsoleDoneLists your tenants, top-bar picker, passes the choice to cartridges.
ap-invoice (“AI Automation”)DoneFollows the Console's tenant (reloads on switch); backend requireTenant; data keyed by tenant_id.
Decision coreDoneRe-checks x-tenant (accepts legacy X-Tenant-Id); Postgres row-level security per tenant.
obsDoneOwns the registry and the Access → Tenants / Users screens.
Mock EmailDone (Phase 3)You see the outbox of the Console's tenant. Machine mail with no registered tenant (obs's job alerts) lands in the _platform outbox, which is not a tenant; only platform admins can open it, from the Platform mail switch.
Doc VaultDone (Phase 3)A new project is stamped with the Console's tenant, and you see only that tenant's projects (owned or shared with you). Projects with no tenant — everything created before Phase 3, and system projects such as Foundation Audit's — are platform projects, shown with a server icon to platform admins only. Another tenant's project answers 404, as if it did not exist.
Mock TMSDone (Phase 3)The Tenants tab lists tenants from the registry (only those your groups admit, never a corpus tenant) and opens on the Console's tenant; the ledger route refuses any other with a 403. The simulator's own built-in book stays shared: it is the TMS being simulated, not customer data.
Foundation AuditPlatform-only (by design)An operator tool with no customer data: it edits the grounding docs and shows the audit log. It does not follow the tenant. The reports it publishes land in a Doc Vault platform project.
Platform tools showing the tenantLater (Phase 4)Logs, audit and obs views labelled by tenant.

For developers: making a cartridge tenant-aware

Reference: shared/modules/tenant.js (who may see), shared/modules/tenant-admin.js (who may administer), shared/modules/README.md, and the walkthrough section “Tenant-scoped data” in docs/cartridge-walkthrough.md.

What can go wrong, and what happens

SituationResult
Registry can't be readThe service keeps its last good list; if it never had one, the list is empty and nobody is admitted. A failed read never invents a tenant.
Someone edits x-tenant in the browser403 from the backend; logged as tenant-refused.
Someone is removed from a groupThe Console stops listing the tenant on the next load; backends refuse it as soon as their token no longer carries the group (at the latest when it renews).
Database query without a tenantReturns no rows: row security reads a missing app.tenant_id as “no tenant”.