Product documentation
The directory user projection (read + CRUD)
Read and manage the environment-global cross-federation user directory in product-api — the record Thoryn materializes on first login for every federated or Thoryn-managed subject — with the real /api/v1/directory/users endpoints, the tenant + environment isolation rules, the external_id + metadata system-of-record fields, and how a pre-provisioned record reconciles cleanly on the user's first login.
The directory user projection (read + CRUD)
Thoryn is an identity broker: your users sign in through whichever federation member you attach (Microsoft Entra ID, Okta, Google Workspace, SAML, or Thoryn's own identity-service), and for federated users the authorization hub owns no user record at all — it only mints tokens from the IdP's claims.
The directory user projection closes that gap. It is an environment-global user
record, owned by product-api (the customer plane), that Thoryn materializes on
first login for every subject — federated or Thoryn-managed — the same way WorkOS
provisions a User on first SSO login. It is the system of record for the platform
user (a stable id, your own external_id, tenant metadata) and a cache of the
profile fields (email, name, picture, locale, last_sign_in_at) kept
eventually-consistent from whichever IdP authenticated the user.
This guide covers reading the directory and the mutating CRUD. It does not cover credentials, MFA, or password reset — those stay in identity-service and the external IdPs; the directory never stores credentials.
For end users
- List and read your tenant's users to see who has signed in, from any federation.
- Pre-provision a user before their first login — create the record with your
own
external_idandmetadataso it is ready the moment they authenticate. - Update the
external_id,metadata, and cached profile fields of a record. - Delete a record you no longer want to keep.
Every call is scoped to one tenant and one environment (sandbox vs production).
A record in one environment is invisible to a caller in another — a cross-environment
id returns 404, never 403, so a caller cannot even learn that the record exists.
Pre-provision, then let login reconcile
The key idea for CRUD is the subject — the opaque identifier your user will
authenticate as (for a federated user, the sub their IdP asserts; you typically
know it from your directory or an earlier SCIM sync). When you POST a record with
that subject, you are pre-provisioning: attaching your external_id and metadata
ahead of time.
When that subject later logs in, Thoryn's materialize hook finds your record and
refreshes only the profile cache (email, name, picture) from the IdP claims —
it never touches your tenant-authored external_id or metadata. So a
pre-provisioned record and a first login converge cleanly, with your
system-of-record fields intact.
Endpoint reference
Base path: /api/v1/directory/users. The tenant comes from the tnt claim and the
environment from the env claim on your access token — never from the path or
body.
| Method | Path | Scope | Description |
|---|---|---|---|
GET | /api/v1/directory/users | tenant:users.read | List users (cursor-paginated; email and organizationId filters). |
GET | /api/v1/directory/users/{id} | tenant:users.read | One user by surrogate id. 404 cross-tenant / cross-environment / missing. |
GET | /api/v1/directory/users/external_id/{externalId} | tenant:users.read | One user by your own external id. |
POST | /api/v1/directory/users | tenant:users.write | Pre-provision a user. subject required. 201 + record; 409 user_exists; 400 invalid_subject / invalid_metadata. |
PUT | PATCH | /api/v1/directory/users/{id} | tenant:users.write | Partial update of SoR + profile fields. 200; 404; 400 invalid_metadata. |
DELETE | /api/v1/directory/users/{id} | tenant:users.write | Delete the record. 204; 404. |
Both tenant:users.read and tenant:users.write are already part of the standard
tenant-admin scope set (granted since the user-management work under SSO-1884), so no
scope change is needed to call these endpoints.
Create a user
POST /api/v1/directory/users
Authorization: Bearer <token with tenant:users.write>
Content-Type: application/json
{
"subject": "okta|00u1a2b3c4",
"externalId": "emp-4821",
"metadata": { "department": "engineering", "costCenter": "CC-12" },
"email": "alice@example.com",
"firstName": "Alice"
}subject is required. metadata, when present, must be a JSON object (a scalar
or array returns 400 invalid_metadata). A second POST with the same subject in
the same environment returns 409 user_exists — the same subject can, however, be
pre-provisioned independently in a different environment.
Update a user
PUT and PATCH both perform a partial merge — a field you omit (or send as
null) keeps its stored value. subject, environmentId, and tenantId are not
accepted in the body and can never be changed.
PATCH /api/v1/directory/users/{id}
Authorization: Bearer <token with tenant:users.write>
Content-Type: application/json
{ "externalId": "emp-4821-new", "metadata": { "department": "platform" } }The profile fields (email, firstName, lastName, name, profilePictureUrl,
locale) are a cache: you can seed or correct them here, but the next login
re-sources any field the IdP asserts.
Delete a user
DELETE /api/v1/directory/users/{id}
Authorization: Bearer <token with tenant:users.write>
X-Thoryn-Confirm: <your-workspace-slug> # required on the production environment onlyDeleting the record erases its PII columns. On the production environment the
call is guarded: it must carry an X-Thoryn-Confirm header equal to your workspace
slug (the console raises a typed-name confirmation modal). Sandbox environments are
ungated.
Configuration
No configuration is required to use these endpoints beyond a tenant-admin access
token carrying the appropriate scope. The projection table and its PII columns are
provisioned by the V107 migration; the read + CRUD surface is always on.
Data protection (DSAR)
The profile-cache columns (email, first_name, last_name, name,
profile_picture_url) are personal data. They are covered by the GDPR Article 15
export fan-out (core/lib/dsar): a DSAR request for a subject gathers that
subject's directory records across every environment of the tenant. The
per-record erasure primitive is the DELETE endpoint above.
Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
404 on a GET/PATCH/DELETE you expected to work | The id belongs to a different environment (or tenant) than your token's env/tnt. | Call with a token scoped to the record's environment. The 404 is deliberate — no existence leak. |
409 user_exists on create | A record with that subject already exists in this environment. | Update it (PATCH) instead, or use a different environment. |
400 invalid_metadata | metadata was a scalar or array. | Send a JSON object ({ ... }). |
400 invalid_subject | subject was blank. | Supply the opaque subject the user authenticates as. |
403 | Your token lacks tenant:users.write (or tenant:users.read on a read). | Request the scope. |
428 production_confirmation_required on DELETE | Production-environment destructive call without the confirmation header. | Add X-Thoryn-Confirm: <workspace-slug> (the console does this via its modal). |
Security notes
- Tenant + environment isolation is enforced server-side on every call. Cross
scope resolves to
404— never rely on the client to hide records. - Writes never let you set
subject/environment_id/tenant_id. They come from the token context and (forsubject) the create body only. - The directory holds no credentials. Passwords, MFA, and login stay in identity-service and the external federation members.
metadatais not a token. It is tenant-authored data on the record; it is not minted into any access or ID token by this surface.