Skip to main content
Every data-plane request resolves a subject tenant_user before doing anything.

Resolution order

  1. Explicit :user_id in the path (/v1/users/:user_id/...)
  2. X-Naive-User-Id header
  3. The API key’s active_tenant_user_id (set at key creation)
  4. The company’s default tenant_user (auto-created on signup)
The sentinels default and me in positions 1–2 mean “use the default user”.

Cross-tenant guard (the ballgame)

After picking a candidate user, the resolver asserts:
resolved_tenant_user.company_id === api_key.company_id
If it doesn’t match, the request returns 404 — not 403. The existence of another company’s user is itself information we never leak. X-Naive-User-Id is treated as untrusted input — validated against the key’s company exactly like a path param, never as an identity assertion. A workspace-wide key can target any user in its own company, never another company’s. Ids are also shape-checked: a malformed (non-uuid) :user_id / X-Naive-User-Id / resource id returns a clean 404, never a 500 from the database rejecting the uuid cast. (default/me sentinels are exempt — they resolve before the check.) Every handler that loads a resource by id (card, inbox, vault entry, connection, …) additionally passes it through assertSameCompany(resource, req) so a forged resource id from another tenant 404s.

Workspace vs kit-scoped keys

  • Workspace-wide (account_kit_id null): can target any user in the company.
  • Kit-scoped (account_kit_id set): locked to one AccountKit.
Both are bounded by the company. That boundary is the entire premise of tenant isolation, so the guard runs on every request — not as an afterthought.