Skip to main content
Tenant users never self-register. Every account starts as a control.tenant_invitations row created by a staff member, delivered by email, and turned into a real users row only when the invitee sets a password. Staff can also step directly into a live tenant’s console as one of its own users — impersonation — without ever knowing that user’s password, at the cost of an unconditional audit-log entry every time.

Invitation lifecycle

Invite

Call: POST /admin/tenants/{tenant_id}/invite with {email, role_profile_key} (default role_profile_key="org_admin"), 201, onboarding_admin only. create_invitation() (services/api/auth/tenant_auth.py) does two things in one control-schema transaction:
  1. Inserts a TenantInvitation row: status='pending', expires_at = now() + 7 days, and invite_token_hash — the SHA-256 hash of a freshly generated raw token. The raw token itself is never persisted anywhere; it only ever exists in the outgoing email body.
  2. Calls enqueue_email_job(), which requires an already-encrypted payload — build_email_job_payload() JSON-encodes {to, subject, body} (the body includes the raw token embedded in an /accept-invite?workspace=...&token=... link) and immediately calls encrypt_secret() before anything touches the database. control.platform_jobs.payload for a send_email job is therefore always {"schema": "causeloop.email.v1", "ciphertext": "<base64>"} — never a plaintext address, subject, or token.
Because the invitation insert and the email enqueue share one transaction, an invitation row can never exist without its delivery job also having been durably queued, and vice versa.

Delivery

The product-worker claims the send_email job (max 8 attempts, the highest of any job type — email delivery is worth retrying longer than a training run) and calls deliver_email_job(), which decrypts the payload only at the moment of send and dispatches through whichever provider CAUSELOOP_EMAIL_PROVIDER selects:
  • console — logs to stdout and an in-memory dev mailbox (test default).
  • local-smtp — the containerized local product stack’s path: SMTP to the mailpit service (port 1025 inside the Compose network, exposed on the host as 127.0.0.1:11025), with the Mailpit web UI/API reachable at 127.0.0.1:18025. This is the concrete “SMTP (Mailpit locally)” path — every invitation and password-reset email sent by a locally running product stack lands there, inspectable without any real mail provider.
  • aws-ses / azure-communication — the two supported cloud providers; email_delivery_readiness() (checked at worker startup and by GET /health/ready) refuses to advertise send_email capability at all unless the provider’s required env vars are present, so a misconfigured worker fails to start rather than silently dropping invitations.

Accept

Call: POST /accept-invite (auth_api.py, mounted at the product API root — not under /admin) with {workspace, token, password (min 8 chars), full_name?}. accept_invitation() (tenant_auth.py):
  1. Looks up the invitation by invite_token_hash where status='pending' and unexpired — anything else is 400.
  2. In the tenant’s own schema, upserts a TenantUser row keyed on (tenant_id, email) (ON CONFLICT DO UPDATE — so re-accepting the same email re-activates and resets its password rather than erroring) and grants the invitation’s role_profile_key via a user_roles row. An invitation for a role profile that was never seeded (shouldn’t happen — the four defaults seed during provisioning) is a 500, not a silent no-op.
  3. Marks the invitation status='accepted', accepted_at=now().
  4. Calls login_tenant_user() to sign the new user straight in.
Step 4 requires the tenant’s lifecycle_state to be live — the same gate any tenant login enforces. Since invitations can be sent any time after provisioning — including before the tenant has gone live — an invitee who accepts early gets a 401 at this last step even though their account was already created and the invitation was already marked accepted. The frontend’s accept-invite page (src/app/accept-invite/page.tsx) special-cases exactly this: a 401 here shows “Your account was created, but this workspace isn’t live yet… sign in at /login once it’s ready” rather than a generic invitation-failed message.
The accept-invite page itself (/accept-invite?workspace=...&token=...) is a single form: optional full name, password, confirm password (client-side minimum 8 characters, matched against the confirm field before submitting). A missing workspace or token query parameter renders a static “Invitation link incomplete” card instead of the form.
With the local product stack running (docker compose -f infra/docker-compose.product.yml up), open http://127.0.0.1:18025 to read the Mailpit UI and find the invitation email — the /accept-invite?workspace=...&token=... link inside it is the only place the raw token ever appears.

Invitation listing and delivery tracking

Call: GET /admin/tenants/{tenant_id}/invitations — every invitation for the tenant, newest first, each cross-referenced against its delivery job:
status (pending / accepted) describes the invitation itself. delivery_status and delivery_attempts are a separate, joined read against control.platform_jobs, looked up by the deterministic idempotency key email:tenant-invitation:{invitation_id} every send_email job for an invitation is enqueued with — so delivery_status reflects the email’s job state (queued/running/succeeded/failed/cancelled), not the invitation’s own lifecycle. A delivery_status of "missing" means no matching job row was found at all, which shouldn’t happen given the invite route’s atomic insert-and-enqueue, but is reported rather than assumed. The tenant detail page’s invite step renders this as {email} — {role} — invite {status} · email {delivery_status} per row.

Role profile display mapping

role_profile_key is one of the four seeded profiles (org_admin, risk_admin, analyst, viewer; see Client onboarding pipeline). The tenant console maps a signed-in user’s actual permission set — not the raw key — to a display label (mapPermissionsToWorkspaceRole(), src/lib/api/tenantAuth.ts): a user holding users.manage (which org_admin grants) displays as Risk Admin; sources.manage (without users.manage) as Loop Owner; reviews.write alone as Analyst; otherwise Auditor. This is why the wizard’s activation step labels its org_admin invitee “Risk Admin” rather than echoing the raw role key.

Impersonation

Call: POST /admin/tenants/{tenant_id}/impersonate, onboarding_admin only — the staff portal’s Open portal → action. impersonate_tenant_user() (services/api/auth/tenant_auth.py) requires lifecycle_state == "live" (409 otherwise) and then picks the tenant’s earliest-created active TenantUser — not the invited admin specifically, just whichever user account exists longest. If there is no active TenantUser at all, the call is 409 with “Tenant has no active users to impersonate.” Since the only way a TenantUser row is created is through accept_invitation(), this is precisely the “live tenant with an accepted invite” rule: a tenant can be live (all three go-live prerequisites — dataset, active checkpoint, at least one pending/accepted invitation — are satisfied) while its one invitation is still pending, in which case impersonation is still blocked until that invitee actually accepts. A successful impersonation mints a real cl_tenant_session cookie for that user via the same _mint_tenant_session() helper an ordinary tenant login uses — there is no separate “impersonated” session type, no reduced permission set, and no visible marker in the console that the session was staff-initiated. The only record that this happened is the audit trail: every successful call writes a control.audit_log row (actor_type="employee", action="employee_impersonation", resource_id = the impersonated user’s id, detail={"impersonated_email": ...}) — unconditionally, with no opt-out flag, because impersonation is a real grant into customer data, not a read-only preview. A call rejected by either 409 above (tenant not live, or no active users) raises before that audit write ever runs, so a failed impersonation attempt leaves no audit-log row — only a completed impersonation is logged. The route also issues a new, non-httpOnly CSRF cookie on the response: the CSRF cookie already present belongs to the calling employee’s /admin/* session, and the freshly minted tenant session needs its own, correctly scoped to tenant-console mutations.