How the platform owner, a tenant, a tenant's applications, and an application's end users each obtain credentials — and where their roles and policies come from.
onRequest hook
(services/api-gateway/src/plugins/authGate.ts) requires a valid tenant JWT for
every path unless it is on the public allowlist (auth bootstrap, health, .well-known,
metrics, SAML, OAuth callback, signed webhooks), self-guards as admin, or is a WebSocket
upgrade. An endpoint you did not explicitly make public is closed. Kill-switch:
AUTH_GATE_MODE=enforce|report|off.
| Principal | Who | Credential | Presented as |
|---|---|---|---|
| Platform operator | You, running ProjexCloud | ADMIN_OPS_TOKEN (one shared operator secret) |
Admin header on /admin/* |
| Tenant admin human |
LeadFlow's own staff — the tenant owner | Password → JWT | Authorization: Bearer <jwt> |
| Tenant application machine |
An app LeadFlow built on the SDKs | API key pk_live_… / pk_test_… |
Authorization: Bearer pk_live_… |
| App end user human |
LeadFlow's customers — millions of them | Password → JWT, scoped to an app_id |
Authorization: Bearer <jwt> |
pk_live_ / pk_test_ prefix — there is no
X-API-Key header. Sending the key anywhere else is the single most common
integration mistake.
Operator routes are not part of the tenant API. They self-guard with
ADMIN_OPS_TOKEN and reject a tenant JWT, which is why they are excluded
from the published tenant OpenAPI spec: offering them to tenants would advertise calls that can
only ever fail.
curl -X POST https://cloud.projexlight.com/admin/federation/profiles \
-H "Authorization: Bearer $ADMIN_OPS_TOKEN" \
-H "Content-Type: application/json" -d '{ ... }'
There is exactly one operator secret; the former FEDERATION_ADMIN_TOKEN was
consolidated into it. Rotate it at the environment, not per call. Operator endpoints are
published separately via openapi-operator.json
(node scripts/catalog/build-openapi.js --include-admin).
This is the bootstrap call, and the only one that needs no credential. It is a single transaction that creates the entire spine for a new tenant.
POST /api/auth/signup-tenant public
curl -X POST https://cloud.projexlight.com/api/auth/signup-tenant \
-H "Content-Type: application/json" \
-d '{
"email": "ops@leadflow.io",
"password": "•••••••••",
"org_name": "LeadFlow",
"app_id": "leadflow",
"display_name": "LeadFlow Production",
"region": "us-east-1"
}'
In one transaction it writes:
identity.person — the human being, independent of any tenant. This row is the root of identity and is never duplicated per app.identity.alias (email, envelope-encrypted) and identity.credential (password) — how that person proves who they are.tenant.org → tenant.app → tenant.tenant — the organisation, the application, and the tenant record (isolation_tier S, status trial).identity.tenant_membership — binds the person to the tenant, and carries bu_id and role_template_id.identity.app_identity — that person's identity within that app, unique on (person_id, app_id).profile.band_l2 — the display/profile band, so a portal can greet the user without a second fetch.POST /api/auth/send-verification-email ·
POST /api/auth/verify-email ·
GET /api/auth/verification-status
The alias is written with verified_at NULL. Verification uses a
purpose-scoped, short-lived token — deliberately not a session token, so a verification link
that leaks cannot be replayed as a login.
POST /api/auth/login public
curl -X POST https://cloud.projexlight.com/api/auth/login \
-H "Content-Type: application/json" \
-d '{ "email":"ops@leadflow.io", "password":"•••", "tenant_id":"<optional>" }'
Passing tenant_id selects which membership the token is minted against. Omit it
and you get a person-level token carrying no tenant claims — useful when a person belongs to
several tenants and must choose.
Authority is not a list of scopes on a human's token. It is derived from the persona and its grants at request time, so revoking a role takes effect immediately instead of when the token expires.
{
"sub": "<person_id>", "email": "...", "display_name": "...",
"org_id": "...", "app_id": "leadflow", "tenant_id": "...", "bu_id": "...",
"parent_tenant_id": null, "root_tenant_id": "...", "reseller_id": null,
"primary_persona_id": "...", "all_persona_ids": ["..."],
"admin_pool_index": "...", "app_pool_index": { "leadflow": "pool-3" },
"actor": { "kind": "human" }, "amr": ["pwd"]
// "scopes" is present ONLY on machine tokens. An empty array on a human token
// would read as "no authority" rather than "not scope-based".
}
An application is the unit a machine credential belongs to. Create it with a human JWT — these routes reject an API key on purpose.
| Step | Endpoint | Auth |
|---|---|---|
| Create the application | POST /api/applications | human JWT |
| Mint a key | POST /api/applications/{application_id}/keys | human JWT |
| Rotate | POST /api/api-keys/{key_id}/rotate | human JWT |
| Revoke | POST /api/api-keys/{key_id}/revoke | human JWT |
| Disable the whole app | POST /api/applications/{application_id}/disable | human JWT |
| Exchange for a short-lived token | POST /api/auth/token | public · client_credentials |
environment is a property of the application, not of an
individual key, and the prefix is derived from it. A test application therefore cannot mint a
credential that reaches production data by accident — the guarantee is structural rather than a
naming convention someone has to honour.
# Machine-to-machine: the key IS the bearer.
curl https://cloud.projexlight.com/api/crm/next-actions \
-H "Authorization: Bearer pk_live_a1b2c3..."
These are your tenant's customers, and there may be millions per app. They never touch
signup-tenant; that call provisions a tenant and would be catastrophic here.
POST /api/auth/register public
Creates the identity.person plus alias and credential. To make that person a
user of a specific app, attach the app-scoped rows:
| Endpoint | Creates | Why it is separate |
|---|---|---|
POST /api/app-identities | identity.app_identity | One person, many apps — unique on (person_id, app_id). The same human in two of your apps is one person_id with two app identities, not two accounts. |
POST /api/app-identities/{id}/memberships | tenant_membership | Which tenant, which business unit, and the starting role_template_id. |
POST /api/personas | persona.persona | The acting identity: kind + primary_role_template_id. Everything downstream keys on persona_id. |
POST /api/role-assignments | role assignment | Grants beyond the starting template. Body is {persona_id, role_template_id}. /api/personas/{persona_id}/roles is GET-only — it lists what a persona already holds. |
POST /api/personas/{persona_id}/shred | — | Crypto-shred one persona without erasing the person, who may still be a user of your other apps. |
The row an end user's data hangs off is the persona, not the person. That is
what lets one human be a customer in leadflow-crm and a contractor in
leadflow-field with different roles, different business units, and independent
deletion — while still being recognised as one person for MDM and consent. It also means an
app-level deletion request shreds a persona rather than tearing a hole through every other app
that person uses.
identity.person has seven columns and not one of them says what the
person is:
person_id · home_region · person_key_ref · status · mdm_method · created_at · erased_at
There is no kind, no type, no is_provider. That is
deliberate and it is what makes every scenario below possible. "Tenant admin", "app user" and
"provider" are not properties of a human — they are relationships, and they live one
layer up.
identity.alias declares
UNIQUE (kind, value_hash), so a given email resolves to exactly one
person_id across the whole platform. A user who later becomes a provider is
necessarily the same person. There is no merge step, no account linking, and no
identity migration — because the split never happened.
supported today Nothing new is created at the person layer.
POST /api/memberships → tenant_membership for the OTHER tenant
POST /api/app-identities → app_identity for that tenant's app_id
POST /api/memberships/{membership_id}/personas → a SECOND persona, independent roles
POST /api/auth/login { tenant_id } → JWT minted against that membership
Their existing personas are untouched. Omitting tenant_id at login returns a
person-level token carrying no tenant claims, which is how you let someone who belongs to
several tenants choose.
/api/auth/register again for this person. It fails on
the unique alias — and that failure is the constraint doing its job, not a bug to work around.
gap The data model supports it completely. The provisioning endpoint does not.
POST /api/auth/signup-tenant throws PersonExistsError when the email
already has an alias, and POST /api/tenants accepts no person_id to
bind an owner. So neither call promotes an existing user, and there is no self-service path.
Compose it from an authorised caller today:
POST /api/tenants → the new tenant (+ its tenant.app row)
POST /api/memberships → bind the EXISTING person_id to it
POST /api/memberships/{membership_id}/personas → owner persona + owner role_template
POST /api/applications → their application
POST /api/applications/{application_id}/keys → pk_live_…
The right fix is to make signup-tenant reuse the existing
person_id when the alias matches and the caller proves control of it
(a verified session, or re-authentication) instead of refusing outright. It is a change at one
call site, not a schema change. Creating a second person for the same human would be the wrong
repair: it breaks MDM convergence and splits their audit trail permanently.
supported today A person has exactly one
identity.credential, so one password works everywhere. Which
provider's app you enter is chosen at login, not at signup.
# No tenant named → a person-level token, no tenant claims. Use it to discover.
POST /api/auth/login { email, password }
# List what this person is subscribed to ← the switcher reads this
GET /api/memberships
→ [{ tenant_id, tenant_display_name, app_id, app_display_name,
bu_id, role_template_name, has_app_identity, joined_at }, …]
# Enter one provider's app → scoped token; app_identity auto-mints on first login
POST /api/auth/login { email, password, tenant_id, app_id }
tenant_id returns 403 NoMembership unless a membership already
exists, so nobody can self-join a tenant by guessing an id. The provider admits them with
POST /api/memberships; the app_identity then mints itself on first
login (FR-IDN-5). The gate is the membership, and it sits with the provider.
An API key belongs to an application, and an application belongs to a tenant. So the order is: be a tenant → sign in as a human → create the application → mint the key.
POST /api/auth/login { email, password, tenant_id }
→ { token } # every step below sends: Authorization: Bearer <token>
POST /api/applications
{
"name": "LeadFlow Mobile",
"slug": "leadflow-mobile",
"description": "iOS + Android client",
"environment": "test" // "live" or "test" — see the warning below
}
→ { application_id, slug, environment, status: "active" }
environment is fixed on the APPLICATION, not on the key, and the
key prefix is derived from it. A test application can never mint a credential that
reaches production data — the guarantee is structural, not a naming convention. You cannot flip
it later; create a second application for production.
POST /api/applications/{application_id}/keys
{ "name": "server-side worker", "scopes": ["crm.contact.read", "crm.deal.write"] }
→ { key_id, api_key: "pk_test_a1b2c3…", prefix, created_at }
The full secret is returned once. Store it in your secret manager at that moment — the platform keeps only a hash, so there is no "show me the key again" call by design.
<domain>.<resource>.<action> — three dot-separated
parts, and the validator will not catch a wrong shape. It accepts any non-empty string
array, so "crm" or "crm:read" is stored happily and then satisfies
nothing: every call 403s. The required scope is derived from the request path
rather than declared per route — action is read for GET/HEAD and
write otherwise, and both segments are singularised:
| Request | Required scope |
|---|---|
GET /api/crm/contacts | crm.contact.read |
POST /api/sla/clocks | sla.clock.write |
POST /api/sla/policies/{id}/rungs | sla.policy.write |
GET /api/sequences | sequence.sequence.read |
sequence.sequence.read. Tail wildcards are accepted and are what you usually want for
a server-side integration: crm.* covers every CRM resource and action, including ones
added after the key was issued. A missing scope answers 403 naming both the required and
the granted scopes, so it diagnoses itself — and it is a 403, never a 401.
curl https://cloud.projexlight.com/api/crm/next-actions \
-H "Authorization: Bearer pk_live_a1b2c3…"
Same header as a JWT. The gateway routes on the pk_ prefix.
{ "error": "Forbidden",
"details": ["API key pk_test_ZZWB…MUUL is missing required scope: credit.balance.read"],
"required_scopes": ["credit.balance.read"],
"granted_scopes": ["crm.*", "sla.*", …] }
If the 403 names a scope, fix the scope — it is not a persona problem. A key scoped
["crm","rebac","consent"] fails every route this way while looking
entirely reasonable in the portal.
A key is additionally bound to a synthetic persona (machine identity, created
with the key in the same transaction) so that machine traffic is attributable in the audit trail as
actor: {kind: service} rather than borrowing a human's. That persona starts with no
role grants. Routes that authorize on tenant + scope — most reads — work immediately; a route that
consults ReBAC or a role template will 403 without naming a scope, and that is the case
that needs a grant:
POST /api/role-assignments {persona_id, role_template_id}
POST /api/auth/token # public, client_credentials
{ "grant_type": "client_credentials", "client_secret": "pk_test_a1b2c3…" }
→ { access_token, expires_in: 900, scope, tenant_id, application_id }
Prefer this when the credential would otherwise sit on a device or in a browser: a short-lived token limits the blast radius of a leak to its lifetime.
client_id is optional — and sending the wrong one is a hard
401 invalid_client. The client_secret alone identifies the key,
so the safest call omits client_id entirely. If you do send it, it must be the
application's slug or its application_id — not the
tenant's app_id, which is a different row entirely and the easiest of the two to
reach for. A mismatch is rejected rather than ignored, deliberately: silently accepting it would
let a copy-paste error reach production and surface later as unexplained failures.
An optional scope (space-delimited, RFC 6749) may narrow the key's scopes
and can never widen them; requesting one the key does not hold is invalid_scope.
The gate enforces the token's scope claim, so narrowing is real, not decorative.
| Action | Call | Effect |
|---|---|---|
| Rotate | POST /api/api-keys/{key_id}/rotate | Issues a new secret for the same key record. Deploy the new value, then revoke the old. |
| Revoke one key | POST /api/api-keys/{key_id}/revoke | That credential stops working; the application's other keys are untouched — which is the reason to mint one key per consumer rather than sharing one. |
| Disable everything | POST /api/applications/{application_id}/disable | Kills every key the application holds at once. |
| List | GET /api/applications/{application_id}/keys | Metadata and prefixes only — never the secrets. |
tenant.role_template is keyed on (tenant_id, app_id, name) with
parent_role_template_id for inheritance and a JSONB permissions map.
The important detail is that tenant_id is nullable:
tenant_id IS NULL → a platform-default template for an app, unique on (app_id, name). Ship your app's standard roles once.tenant_id set → a tenant override of that same role name. A tenant can redefine "Manager" without forking your app.| Layer | SDK | Answers | Reach for it when |
|---|---|---|---|
| RBAC | role templates | "What may this role do?" | Permissions are the same for everyone holding the role. |
| ReBAC | sdk-rebac | "May this persona act on that record?" | Authority comes from a relationship — owner, delegate, carer, account team. Contextual roles carry a trust state and evidence. |
| ABAC | sdk-policy | "Do the attributes permit it right now?" | The decision depends on region, consent, time, or record state rather than on identity. |
They compose in that order, and a human token deliberately carries no scopes so that a revoked grant takes effect on the next request rather than at token expiry.
tenant.tenant.app_id is TEXT NOT NULL REFERENCES
tenant.app(app_id) — a tenant row belongs to exactly one app. Meanwhile the same
table carries app_pool_index JSONB, a map of app → pool, which assumes a tenant
spans several. Those two facts contradict each other, and the JWT follows the
single-app_id side.
Three ways to model "LeadFlow owns five apps" with what exists today:
| Option | How | Cost |
|---|---|---|
| A. Tenant hierarchy no migration | One root tenant for LeadFlow, one child tenant per app via parent_tenant_id / root_tenant_id. Billing and org roll up to the root. |
Works now and the columns already exist. Cross-app queries must walk root_tenant_id, and a person needs a membership per child tenant. |
| B. Join table migration | Add tenant.tenant_app (tenant_id, app_id), make tenant.app_id nullable, and treat it as the tenant's primary app for back-compat. |
The honest fix, and it makes app_pool_index coherent. Touches every query that assumes one app per tenant — do it deliberately, not incidentally. |
| C. One tenant per app no migration | Five flat tenants, related only in your own billing layer. | Simplest, but "LeadFlow" stops existing as a platform concept and nothing rolls up. |
Recommendation: start with A — it needs no migration, the
hierarchy columns are already indexed, and root_tenant_id is already carried in the
JWT so app-to-app authorisation can be answered from the token. Move to B when
cross-app queries become common enough that walking the hierarchy is the bottleneck.
Millions of end users per app is a routing question, not an identity one.
app_pool_index maps an app to its database pool and admin_pool_index
keeps tenant administration on a separate pool from end-user traffic, so a busy app cannot
starve the console. Isolation tier (S shared / P private /
G dedicated) is per tenant, so a large customer can be moved without touching the
others. The audit ledger is chained per pool — each pool's sequence restarts at 1, so
verify a chain within one pool rather than across them.
Once you serve several customers you need a screen that reads across all of them — subscriptions, usage, support. That actor is the one that must cross tenant boundaries, so it is also the single path every leak can run through. Two rules decide whether it holds.
The tempting shortcut is an operator value in your own app's role column. That
turns a cross-tenant authority into a tenant-scoped one, and then "which customers may this
person see" has no answer the schema can give. Use the hierarchy that already exists:
tenant.tenant parent_tenant_id, root_tenant_id (materialised), reseller_id
tenant.org org_id, parent_org_id
tenant.reseller reseller_id, org_id
Your company is the root tenant; each customer is a child tenant carrying
parent_tenant_id. root_tenant_id is materialised on write so ancestor
queries stay O(1), and it is already in the JWT — so "is this operator allowed
to act on that customer" is answerable from the token without a join. This is the same Option A
recommended above for modelling several apps; one hierarchy serves both.
/console portal is not this. That is for ProjexCloud operators,
and a tenant identity cannot sign into it. Your operator console is your own surface, built on
your own root tenant.
A session flag such as SET app.bypass_rls = true is the obvious alternative and
it is the weaker one: a bug on an ordinary request can set a flag, whereas a bug cannot reach a
different connection pool. The platform is already multi-pool — registerPool(pool_index,
cfg) in db-runtime, with admin_pool_index and
app_pool_index carried in the JWT — so this is configuration, not new machinery.
Mount operator routes on their own router with their own guard; a tenant route then has no code
path into an operator query rather than merely being trusted not to take one.
Every cross-tenant action must name the human who took it. Writing a constant actor and a caller-supplied "on behalf of" string produces an audit trail that cannot answer the only question anyone will ask it. Where a call accepts a claimed identity, store it alongside the authenticated principal rather than instead of it, so a disagreement is visible.
tenant_id query parameter for scoping, and where one appears in a body it is
checked against the credential and answers 403 on mismatch. A resource
belonging to another tenant answers 404, not 403 — a 403 would confirm the id
is real.
Attribution is not isolation, and the difference is easy to miss. Some
records name the tenant that decided something without that decision being tenant-local.
An identity merge is the clearest case: it acts on the global L1 identity.person,
so a merge one tenant decides is visible to every tenant sharing that person. The tenant column
tells you who acted; it does not promise the effect stopped there. When you build on a
person-centric SDK, check which of the two a column means before you present it as a boundary.
# 1 · LeadFlow onboards (once)
POST /api/auth/signup-tenant → person + org + app + tenant + membership + app_identity
POST /api/auth/verify-email
POST /api/auth/login → tenant-admin JWT
# 2 · LeadFlow registers an app and a machine credential (human JWT)
POST /api/applications → application_id
POST /api/applications/{application_id}/keys → pk_live_...
# 3 · LeadFlow defines the roles its customers can hold
POST /api/role-templates → "Agent", "Manager"
# 4 · A LeadFlow customer signs up in the app (public)
POST /api/auth/register → person_id
POST /api/app-identities → app_identity (person × app)
POST /api/app-identities/{id}/memberships → tenant + bu + role_template
POST /api/personas → persona_id ← everything keys on this
POST /api/auth/login → end-user JWT
# 5 · Finer-grained authority as the relationship demands it
POST /api/rebac/... → "this agent owns that account"
POST /api/policy/... → "...only in-region, only with consent"
docs/CONSUMPTION-CONTRACT.md — which SDK owns which table, and what an app may extend locally.