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:
- Inserts a
TenantInvitationrow:status='pending',expires_at = now() + 7 days, andinvite_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. - 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 callsencrypt_secret()before anything touches the database.control.platform_jobs.payloadfor asend_emailjob is therefore always{"schema": "causeloop.email.v1", "ciphertext": "<base64>"}— never a plaintext address, subject, or token.
Delivery
Theproduct-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 themailpitservice (port1025inside the Compose network, exposed on the host as127.0.0.1:11025), with the Mailpit web UI/API reachable at127.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 byGET /health/ready) refuses to advertisesend_emailcapability 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):
- Looks up the invitation by
invite_token_hashwherestatus='pending'and unexpired — anything else is400. - In the tenant’s own schema, upserts a
TenantUserrow 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’srole_profile_keyvia auser_rolesrow. An invitation for a role profile that was never seeded (shouldn’t happen — the four defaults seed during provisioning) is a500, not a silent no-op. - Marks the invitation
status='accepted',accepted_at=now(). - Calls
login_tenant_user()to sign the new user straight in.
/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.
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.