Skip to content

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_id and metadata so 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.

MethodPathScopeDescription
GET/api/v1/directory/userstenant:users.readList users (cursor-paginated; email and organizationId filters).
GET/api/v1/directory/users/{id}tenant:users.readOne user by surrogate id. 404 cross-tenant / cross-environment / missing.
GET/api/v1/directory/users/external_id/{externalId}tenant:users.readOne user by your own external id.
POST/api/v1/directory/userstenant:users.writePre-provision a user. subject required. 201 + record; 409 user_exists; 400 invalid_subject / invalid_metadata.
PUT | PATCH/api/v1/directory/users/{id}tenant:users.writePartial update of SoR + profile fields. 200; 404; 400 invalid_metadata.
DELETE/api/v1/directory/users/{id}tenant:users.writeDelete 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 only

Deleting 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

SymptomCauseFix
404 on a GET/PATCH/DELETE you expected to workThe 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 createA record with that subject already exists in this environment.Update it (PATCH) instead, or use a different environment.
400 invalid_metadatametadata was a scalar or array.Send a JSON object ({ ... }).
400 invalid_subjectsubject was blank.Supply the opaque subject the user authenticates as.
403Your token lacks tenant:users.write (or tenant:users.read on a read).Request the scope.
428 production_confirmation_required on DELETEProduction-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 (for subject) the create body only.
  • The directory holds no credentials. Passwords, MFA, and login stay in identity-service and the external federation members.
  • metadata is not a token. It is tenant-authored data on the record; it is not minted into any access or ID token by this surface.