> ## Documentation Index
> Fetch the complete documentation index at: https://docs.causeloop.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Invitations and impersonation

> The invitation lifecycle from POST /invite through the encrypted email outbox to account creation, plus the impersonation ('Open portal') flow and its audit trail.

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

```mermaid theme={null}
sequenceDiagram
    participant Staff as Onboarding admin
    participant API as onboarding_api.py
    participant DB as control.tenant_invitations
    participant Queue as control.platform_jobs
    participant Worker as product-worker
    participant Mail as SMTP (Mailpit locally)
    participant Invitee as Invitee

    Staff->>API: POST /admin/tenants/{id}/invite {email, role_profile_key}
    API->>DB: insert (status=pending, 7-day expiry, invite_token_hash)
    API->>Queue: enqueue send_email (encrypted payload)
    Queue->>Worker: claim_next_job()
    Worker->>Mail: decrypt payload, send_email()
    Mail->>Invitee: "Accept your invitation: /accept-invite?workspace=...&token=..."
    Invitee->>API: POST /accept-invite {workspace, token, password, full_name?}
    API->>API: create tenant_user row, grant role_profile
    API->>DB: status: pending -> accepted
    API->>API: mint session
```

### 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.

<Warning>
  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](/features/client-onboarding-pipeline#7-invite--at-least-one-user-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.
</Warning>

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.

```bash theme={null}
curl -sX POST http://localhost:18000/admin/tenants/$TENANT_ID/invite \
  -H 'content-type: application/json' -H "x-csrf-token: $CSRF" \
  --cookie "cl_admin_session=$SESSION; cl_csrf=$CSRF" \
  -d '{"email": "riskadmin@auroasavings.com", "role_profile_key": "org_admin"}'
# -> {"status": "queued", "email": "...", "role_profile_key": "org_admin", "delivery_job_id": "..."}
```

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:

```json theme={null}
[
  {
    "id": "...",
    "email": "admin@client.com",
    "role_profile_key": "org_admin",
    "status": "accepted",
    "delivery_status": "succeeded",
    "delivery_attempts": 1,
    "created_at": "...",
    "expires_at": "...",
    "accepted_at": "..."
  }
]
```

`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](/features/client-onboarding-pipeline#2-provision--draftonboarding--onboarding)). 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.

```mermaid theme={null}
flowchart LR
    A["Staff clicks Open portal\n(only shown for live tenants)"] --> B{"tenant.lifecycle_state == live?"}
    B -- no --> C["409 Tenant is not live yet"]
    B -- yes --> D{"any active TenantUser exists?"}
    D -- no --> E["409 Tenant has no active users to impersonate"]
    D -- yes --> F["mint cl_tenant_session for the\nearliest-created active user"]
    F --> G["control.audit_log: employee_impersonation\n(unconditional, every call)"]
    G --> H["fresh CSRF cookie issued\n(new session, new scope)"]
```

`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.

## Related

* [Client onboarding pipeline](/features/client-onboarding-pipeline)
* [Staff portal](/features/staff-portal)
* [Security model](/architecture/security)
* [Workers and jobs](/architecture/workers-and-jobs)
