Organizations
Create tenants, bind identity, issue credentials, fund credits, and grant models.
An organization is Deeplinq's tenant boundary. It owns credits, model grants, projects, datasets, teams, integrations, policy overrides, and audit lineage.
It lives inside exactly one application — the product it belongs to, and therefore the identity-provider realm whose issuer signs its tokens. Its people are members of that realm, and the engine resolves each of them to a principal it owns. See Identity model.
Onboarding checklist
Every organization needs:
- an application to live in — its product, and therefore its issuer;
- an internal Deeplinq organization record;
- at least one authentication path;
- at least one administrator (ordinary members need no operator action);
- a positive credit balance;
- at least one model grant.
Create the organization:
curl --user "$ADMIN_USER:$ADMIN_PASSWORD" \
-H "Content-Type: application/json" \
-X POST "$BASE_URL/v1/admin/orgs" \
-d '{"name":"Acme Corp"}'Omitting application_id puts it in the default application (realm
deeplinq). Name one explicitly to put it in another product:
-d '{"name":"Acme Corp","application_id":"<application uuid>"}'An organization belongs to exactly one application and cannot be moved between them without re-provisioning — see Applications.
The returned UUID remains stable if you later change the organization's name or external issuer binding. Grants and balances key on this UUID.
Authentication paths
Identity setup
One call provisions everything an organization's backend needs at the identity provider — the organization there, its machine user, and a sealed client secret — and registers the result with the engine:
curl --user "$ADMIN_USER:$ADMIN_PASSWORD" \
-H "Content-Type: application/json" \
-X POST "$BASE_URL/v1/admin/orgs/$ORG_ID/identity-setup" \
-d '{"admin_email":"admin@acme.com"}'It provisions everything the organization needs, inside its application's realm:
- an organization at the identity provider, stamped with this engine's own organization id — the stamp is what proves it is ours on every later run, and a name never is;
- a service-account client with a sealed secret, for the tenant's backend, carrying mappers that put the organization and the audience into its tokens;
- the first administrator, with a temporary password they must change at first sign-in;
- that administrator's
org-adminrole in the engine's own record — which is what tenant authorization reads. The identity provider is not asked to decide it.
admin_email is optional and used once: an organization that already has
an administrator ignores it, so a later run can neither create a second one nor
reset a working one. Send an empty body ({}) to converge everything else —
that is what "Sync identity" does on every visit. GET .../provisioning
reports admin_login once one exists.
You supply no password. The engine generates one, returns it as
admin_password in that response only, and keeps no copy:
{ "admin_login": "admin@acme.com", "admin_created": true, "admin_password": "…" }Hand it to the administrator out of band. The identity provider marks it
changeRequired, so it is spent at their first sign-in and cannot be reused;
if it is lost before then, reset it at the identity provider rather than
re-running setup.
No identity-provider credential goes in the request. The engine runs this on its own standing
identity-provider service account — narrowly scoped to creating organizations
and machine users, nothing more — configured once at deployment
(TENANT_PROVISIONER_CLIENT_ID/TENANT_PROVISIONER_CLIENT_SECRET; see
configuration). A platform admin's own
session, however they signed in, is never asked for a personal
identity-provider credential to run it.
It is idempotent and resumable: a failed run picks back up from whichever step
it reached (GET /v1/admin/orgs/$ORG_ID/provisioning shows where), and a
repeat call on an already-provisioned organization adopts what exists rather
than duplicating it. Idempotence is per-concern rather than per-run: the roles
and the project grant reconverge on every call, which is how an
organization provisioned before this flow existed heals — a working machine
credential otherwise masks an organization no person can sign in to. The identity-provider organization is named after this
engine organization's own name — rename the organization here before running
setup if you want that reflected in the tenant's sign-in domain.
Collect the minted secret once, then acknowledge it so the engine drops its own copy — after that, a lost secret means rotating, not re-reading:
curl --user "$ADMIN_USER:$ADMIN_PASSWORD" \
-X POST "$BASE_URL/v1/admin/orgs/$ORG_ID/machine-credentials/secret"
curl --user "$ADMIN_USER:$ADMIN_PASSWORD" \
-X POST "$BASE_URL/v1/admin/orgs/$ORG_ID/machine-credentials/secret/acknowledge"The resulting token is organization-level: it cannot use owner-scoped resources such as conversations, personal connections, or runs — those need an end-user token (see authentication).
Machine credential lifecycle
A machine credential is what a tenant's backend authenticates as: an
unattended server exchanging a client id and secret for an access token, with
no human present to approve anything. Every organization has two rotation
slots, a and b.
Two slots is not decoration. The identity provider overwrites a machine user's secret when a new one is generated — minting a second secret on the same machine user kills the first instantly. A rotation with any overlap therefore needs two machine users, which is what the slots are.
# List the organization's credentials, their slots, and their status.
curl --user "$ADMIN_USER:$ADMIN_PASSWORD" \
"$BASE_URL/v1/admin/orgs/$ORG_ID/machine-credentials"| Action | Effect |
|---|---|
POST .../machine-credentials/rotate | Mints a new credential into the free slot. The live one keeps serving. |
DELETE .../machine-credentials/{cred_id} | Retire: disables the credential and kills its live tokens, freeing its slot. The row stays as history. |
POST .../machine-credentials/{cred_id}/kill | Kill tokens: leaves the credential enabled but refuses every token already issued from it, immediately. |
Kill and retire are different tools. Tokens are verified offline, so the identity provider cannot recall one it has already minted — killing sets a per-credential cutoff instant, and that is the only lever that stops a token mid-life. Retire implies a kill, because disabling a credential while its already-minted tokens kept working for the rest of their lifetime would be a lie.
Rotation refuses in exactly two situations, both deliberate:
- A secret is still pending collection. Rotating would overwrite a secret nobody has received, and no one would learn it was lost. Collect and acknowledge it first.
- Both slots hold a live credential. The server will not guess which working credential to destroy. Retire one first.
The safe rotation order, with no interruption to the tenant:
rotate— mints into the free slot;- collect and acknowledge the new secret;
- switch the tenant's backend to the new client id and secret;
- confirm traffic is arriving on it (the credential list shows last used);
- retire the old credential.
Registering an existing binding
Already created the organization and its machine user at the identity provider yourself — outside Deeplinq, or on a deployment with no standing provisioning credential configured? Register what exists instead of creating anything:
curl --user "$ADMIN_USER:$ADMIN_PASSWORD" \
-H "Content-Type: application/json" \
-X POST "$BASE_URL/v1/admin/orgs/$ORG_ID/identity-binding" \
-d '{"issuer":"https://identity.example.com","remote_org_id":"329487325098234"}'A token naming any other organization is refused with a 401; there is no
auto-provisioning. Read back the binding with
GET /v1/admin/orgs/$ORG_ID/identity-binding.
Registering a binding an organization already holds is success, not a
conflict, when every part matches. That means the same organization,
provider, issuer, and remote organization, with the binding still active. A signup or a bind replayed
after a lost response converges instead of being refused forever. A suspended
binding, a different remote organization, or a remote organization already held
by another engine organization is still a 409, and nothing is reactivated,
repointed, or adopted.
The issuer and the organization must belong to the same application
A binding names an issuer, a remote organization, and an engine organization
independently. Those three used to be accepted in any combination, so an
operator could bind application A's issuer to an organization whose
application_id is B. A perfectly ordinary A token then reached B's tenant
while being governed and billed as A. The model catalog, guardrail policy,
memory model, knowledge budgets, and project limits all read that pair as
verified.
Authentication now resolves the organization's application together with the
binding and refuses the credential when it differs from the application whose
authorization server signed the token. The caller sees the lane's uniform
401; the engine log records the cause application-scope alongside both
application ids and the organization. POST /v1/admin/orgs/{id}/identity-binding
refuses to create the pair, so the state is not reachable through the admin
surface or through onboarding either.
Existing rows were deliberately not migrated. An incoherent binding written before this shipped keeps its row and stops authenticating the moment the engine restarts on the new build, with every denial naming the organization and both application ids in the log. The fix is to re-bind the organization from the application it actually belongs to, or to move nothing and correct the issuer registration. There is no data migration to wait for, and no deploy-time sweep took tenants offline without an operator in the loop.
The comparison runs only where both sides name an application. The
single-issuer TENANT_OIDC_* override and the console's own authorization
server name none, so they are unaffected.
The external reference
Separate from the identity binding, an organization carries the engine's own external reference for it:
curl --user "$ADMIN_USER:$ADMIN_PASSWORD" \
-H "Content-Type: application/json" \
-X POST "$BASE_URL/v1/admin/orgs/$ORG_ID/bind" \
-d '{"external_org_ref":"acme-production"}'This is the name the organization is known by in operator surfaces. It is not the organization a token names — that is the identity binding above — and rebinding it leaves every grant intact, because entitlements key on the engine-minted UUID.
Credits and model grants
Fund credits with a stable idempotency key:
curl --user "$ADMIN_USER:$ADMIN_PASSWORD" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: acme-credit-2026-07" \
-X POST "$BASE_URL/v1/admin/orgs/$ORG_ID/credits" \
-d '{"amount_micro_usd":100000000}'Grant each model independently:
curl --user "$ADMIN_USER:$ADMIN_PASSWORD" \
-H "Content-Type: application/json" \
-X POST "$BASE_URL/v1/admin/orgs/$ORG_ID/models" \
-d '{"model":"gpt-5-mini"}'Revoke a model without changing connector or pricing state:
DELETE /v1/admin/orgs/{org_id}/models/{model}Administrators and roles
| Role | Authority | Where it is held |
|---|---|---|
platform-admin | Platform control plane | a break-glass session, or an operator's token carrying the role granted in the console's own realm |
org-admin | Organization policy, teams, integrations, webhooks, and spend controls | the engine, per organization |
org-member | Application use and resources granted to the caller | the engine, per organization |
A tenant's roles come from the engine, not from the token. A role claim the identity provider puts in a token earns nothing here. The reason is specific and was measured: a Keycloak realm role is realm-wide while the organization is chosen at sign-in, so honouring the claim let one organization's administrator administer every organization they belonged to. See Who decides what a person may do.
Managing them
Console → the organization → Access → Administrators. Everything below is a button there; the API is the same call.
Add someone by the login they sign in with — an operator knows an email address, never a principal UUID:
curl --user "$ADMIN_USER:$ADMIN_PASSWORD" \
-H "Content-Type: application/json" \
-X POST "$BASE_URL/v1/admin/orgs/$ORG_ID/administrators" \
-d '{"login":"jane@acme.com","role":"org-admin"}'The person must already exist at the identity provider for this organization. One who does not is refused rather than silently enrolled — a principal with no identity behind it can never sign in.
List who administers it:
curl --user "$ADMIN_USER:$ADMIN_PASSWORD" \
"$BASE_URL/v1/admin/orgs/$ORG_ID/administrators"{ "administrators": [
{ "principal_id": "…", "login": "jane@acme.com", "roles": ["org-admin"] }
] }Revoke:
curl --user "$ADMIN_USER:$ADMIN_PASSWORD" -X DELETE \
"$BASE_URL/v1/admin/orgs/$ORG_ID/administrators/$PRINCIPAL_ID?role=org-admin"Resetting an administrator's password
Provisioning shows a generated password once, on the run that creates the first administrator. When nobody collected it — or someone simply forgets theirs — mint a new one:
curl --user "$ADMIN_USER:$ADMIN_PASSWORD" -X POST \
"$BASE_URL/v1/admin/orgs/$ORG_ID/administrators/$PRINCIPAL_ID/password"{ "login": "jane@acme.com", "password": "…",
"notice": "shown once — it is temporary and must be changed at the next sign-in" }The engine never stores it, and the audit event records that a reset happened and for whom — never the value. The identity provider marks it temporary, so it dies at the person's next sign-in and an operator is never left holding a working credential for somebody else's account.
Reserved platform-* roles are refused by a database constraint and stripped
again on read: a tenant record can never confer platform authority.
Deleting and restoring
Deleting an organization is reversible and erases nothing (ADR-0078). It
flips a status column — active → deleted — and that is the whole
operation. Every byte is retained indefinitely: credits, datasets, documents,
memories, audit lineage, the identity-provider organization and its people.
curl --user "$ADMIN_USER:$ADMIN_PASSWORD" \
-X DELETE "$BASE_URL/v1/admin/orgs/$ORG_ID"{ "id": "…", "name": "Acme Corp", "status": "deleted" }What changes is access, everywhere and at once. A deleted organization is
refused on both OAuth2 tenant lanes (machine and end-user), the
X-Deeplinq-Admin-Org delegation header, the memory bearer, the
TENANT_OIDC_* environment override, the billing top-up, the connector OAuth
callback — a connect_state issued before the deletion stops being redeemable
— and at the entry of every durable worker, so no ingest, agent turn, or
scheduled delivery spends on it after the fact.
Undo it:
curl --user "$ADMIN_USER:$ADMIN_PASSWORD" \
-X POST "$BASE_URL/v1/admin/orgs/$ORG_ID/restore"{ "id": "…", "name": "Acme Corp", "status": "active" }Restoring returns the organization to service with everything it had. There is no window to beat and no export to take first.
Finding a deleted organization
The listing hides deleted rows by default — the working set is what an
operator wants to see — and ?status= widens it. Each row carries its own
status, so a mixed listing is readable without a fetch per row:
curl --user "$ADMIN_USER:$ADMIN_PASSWORD" \
"$BASE_URL/v1/admin/orgs?status=deleted" # the recycle bin
curl --user "$ADMIN_USER:$ADMIN_PASSWORD" \
"$BASE_URL/v1/admin/orgs?status=all" # everythingGET /v1/admin/orgs/{id} returns a deleted organization with its status, so
the console can render it and offer restore.
Purging
Deleting removes access; purging removes the data. A purge is irreversible
(ADR-0078 Phase 2) — it erases the organization across seven stores: the
provider grants held at every connected OAuth provider, object storage, the
chunk and memory vector tenants, the memory graphs, the identity provider, and
PostgreSQL, through a fenced, checkpointed background workflow. It only runs
on an organization that is already deleted:
curl --user "$ADMIN_USER:$ADMIN_PASSWORD" \
-X POST "$BASE_URL/v1/admin/orgs/$ORG_ID/purge"{ "id": "…", "name": "Acme Corp", "status": "purging" }An active organization is refused 409 not_deleted — purge is never a
shortcut past the reversible step, so a mistyped id costs a restore rather
than a customer. Calling purge again on an already-purging organization
is idempotent: it re-drives a stalled workflow from the first incomplete
step instead of starting over.
To delete and purge in one call — the terraform destroy interlock — pass
confirm equal to the organization's exact current name:
curl --user "$ADMIN_USER:$ADMIN_PASSWORD" \
-X DELETE "$BASE_URL/v1/admin/orgs/$ORG_ID?purge=true&confirm=Acme%20Corp"{ "id": "…", "name": "Acme Corp", "status": "purging" }A confirm that does not match exactly answers 400 confirmation_mismatch —
typing the name is the one step a script cannot perform by accident.
Watching a purge
curl --user "$ADMIN_USER:$ADMIN_PASSWORD" \
"$BASE_URL/v1/admin/orgs/$ORG_ID/purge"{
"org_id": "…", "status": "purging", "generation": 1,
"stuck": "identity_provider",
"checkpoints": [
{ "store": "provider_grants", "status": "done", "objects": 2, "updated_at": "2026-08-11T09:14:01Z" },
{ "store": "blobs", "status": "done", "objects": 14, "updated_at": "2026-08-11T09:14:03Z" },
{ "store": "chunk_vectors", "status": "done", "objects": 1, "updated_at": "2026-08-11T09:14:03Z" },
{ "store": "memory_vectors", "status": "done", "objects": 1, "updated_at": "2026-08-11T09:14:04Z" },
{ "store": "memory_graphs", "status": "done", "objects": 3, "updated_at": "2026-08-11T09:14:04Z" },
{ "store": "identity_provider", "status": "failed", "objects": 0,
"last_error": "delete keycloak organization: 503 Service Unavailable",
"updated_at": "2026-08-11T09:14:06Z" }
]
}That is the state this route exists for: six stores erased, the identity
provider refusing, and stuck naming it. stuck is omitted from the response
entirely when nothing is stuck, and every checkpoint carries the updated_at
of its last write.
A store that has not run yet has no checkpoint at all rather than a pending
one — which is why an organization that has never been purged answers
generation 0, an empty checkpoint list, and "stuck": "provider_grants".
That is the first required store with no receipt, not a store that is failing.
org.purge.completed fires only once every required store reports done
or unsupported — Microsoft connections report unsupported, never done,
because it publishes no revocation endpoint. A store that is skipped, dark,
failed, or not configured leaves the organization purging and emits
org.purge.failed naming the stuck store instead, which is exactly what
stuck above shows. There is no path from a partial erasure to a completion
event.
PostgreSQL is the LAST step, and deleting the org row is what makes the
organization unresolvable — so a fully complete purge is never observable
here: GET /v1/admin/orgs/{id} and its purge route both 404, byte-identical
to an id that never existed. The full per-store receipt set of a successful
purge lives in the org.purge.completed audit event, which is where you read
it afterwards.
What survives: audit_log, audit_chain_anchors, and ledger_entries —
the immutable credit/debit source rows, so GET /v1/usage/users still
answers over a historical window. billing_balances and
billing_user_monthly_spend, both projections, do not.
What "erased" means for identity: an organization purge erases the
organization's identity objects — its service accounts and, when the
console identity-setup path provisioned one, its first administrator — never
the application-tier account of the person who signed up through it. One
account can own a personal organization and have created business
organizations that survive this purge, so erasing the account here would
destroy identity still in use elsewhere. Reading identity_provider: done as
"the signed-up person is gone" is a mistake — their account is only erased
when the application itself is purged, dropping the whole realm.
Purge is a mechanism, not a retention policy. Retention stays unbounded until an operator explicitly purges — no sweeper, no window, no configured clock — so on its own this satisfies neither GDPR Art. 5(1)(e) storage limitation nor Art. 17 erasure-without-undue-delay, and it defines no DSAR intake or legal hold. Only a platform-admin can call it.
Offboarding and rotation
- Delete the organization when you are done with it — reversible, immediate, and it removes the tenant from every listing and every authentication lane. See Deleting and restoring above.
- Purge it once you are certain — irreversible, and erases every store it touched. See Purging above.
- Rotate a machine credential through the organization's second slot — see machine credential lifecycle above.
- Suspect a leaked credential? Kill its live tokens immediately, then rotate; disabling alone leaves already-minted tokens working until they expire.
- Offboard a person at the identity provider — that is what revokes their access. Deeplinq does not own a user directory.
- Revoke model grants before removing provider capacity when you need a controlled tenant-facing shutdown.
- Export audit evidence and reconcile billing before deleting external account records.
The admin console's organization detail page combines identity, machine credentials, credits, model grants, integrations, usage, and a read-only dataset inventory for the same tenant boundary. Dataset details expose file metadata, registered websites, and recent ingestion runs; they do not expose source contents or mutation controls.