ADR-0008: Invitation flow (email-token, TTL, one-time-use, audit)
- Status: Accepted (implemented in 0.2 step 3c, 2026-04-18)
- Date: 2026-04-18
- Deciders: Vitor Rodovalho
- Related: ADR-0007 Tenant Owner role
Context
Once Panorama has a web login flow (step 3b), the next natural question is "how does a new user land in my tenant". Two paths exist:
- Pre-seeded users — the tenant admin creates the
User+TenantMembershipvia CSV import or admin API. User logs in, sees their tenant. This is what step 3b assumes. - Invitations — admin enters an email address; Panorama sends a one-time link; target clicks and lands authenticated. This is the standard SaaS path and what users expect.
The real-world case the user called out (Amtrak/FDT project) has both shapes: some drivers are employees of the primary company (pre-seeded from HR), some are contractors or partners from other companies who need guest access. Both reduce to the same invitation primitive.
Prior art
| Product | Token TTL | One-time-use | Email-match required | Audit trail | Re-send allowed |
|---|---|---|---|---|---|
| Auth0 invitations | 7 days (configurable, 1h min, 30d max) | Yes | Yes | Yes (API) | Yes |
| Clerk invitations | 30 days | Yes | Yes | Yes | Yes |
| Slack | Never expires by default; revokable | No (reusable link within TTL) | Optional | Limited | Revoke + new link |
| GitHub org invite | 7 days | Yes | Yes | Yes | Yes |
| Linear | 30 days | Yes | Yes | Yes | Yes |
| Microsoft 365 B2B | 90 days (configurable) | Yes | Yes (verified at IdP) | Yes (very detailed) | Yes |
| Notion | 7 days | Yes | Yes | Limited | Yes |
Security patterns universal across all:
- Token is opaque (not a JWT) — random N-byte, URL-safe
- Token hash stored, not plaintext, same as password reset flows
- Email verification at acceptance — the accepting user's verified email must match the invitation's target email
- Rate limits on invitation creation per admin per hour
- Audit: created / emailed / bounced / opened / accepted / expired / revoked
Decision
Panorama ships an email-token, one-time-use, TTL'd invitation with a dedicated Invitation table, async delivery via BullMQ, and a first-class audit trail.
Data model
New table:
model Invitation {
id String @id @default(uuid()) @db.Uuid
tenantId String @db.Uuid
/// lowercased + trimmed at write time
email String
role String // 'owner' | 'fleet_admin' | 'fleet_staff' | 'driver'
/// SHA-256 of the plaintext token we email. Plaintext exists only in
/// the email + in the URL the target clicks. Panorama never persists
/// the plaintext.
tokenHash String
/// Optional: if the admin is re-inviting someone who already has a
/// Panorama User, link it up-front so acceptance is a no-login-needed
/// step for the user.
targetUserId String? @db.Uuid
invitedByUserId String @db.Uuid
expiresAt DateTime
acceptedAt DateTime?
/// If accepted, which user id actually consumed it.
acceptedByUserId String? @db.Uuid
revokedAt DateTime?
revokedByUserId String? @db.Uuid
/// Email outbox state — populated by the delivery worker.
emailQueuedAt DateTime?
emailSentAt DateTime?
emailBouncedAt DateTime?
emailLastError String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
invitedBy User @relation("Inviter", fields: [invitedByUserId], references: [id])
targetUser User? @relation("PreTarget", fields: [targetUserId], references: [id], onDelete: SetNull)
acceptedBy User? @relation("Acceptor", fields: [acceptedByUserId], references: [id], onDelete: SetNull)
/// At most one OPEN (non-accepted, non-revoked, non-expired) invite
/// per (tenantId, email) at a time. Enforced by a partial unique
/// index on the `acceptedAt IS NULL AND revokedAt IS NULL` predicate.
@@index([tenantId, email])
@@index([expiresAt])
@@map("invitations")
}The partial unique index is a hand-written SQL migration (Prisma doesn't model partial indexes yet):
CREATE UNIQUE INDEX invitations_one_open_per_tenant_email
ON invitations (tenant_id, email)
WHERE accepted_at IS NULL AND revoked_at IS NULL;Token mechanics
- Format: 32 random bytes, base64url-encoded (43 chars). Generated via
crypto.randomBytes(32). - Storage: we store
sha256(token)base64url-encoded. At acceptance, we hash the inbound token and look up by the hash. Constant-time comparison unnecessary because lookup is by index, not by string comparison against a known value. - Transport: emailed as part of an acceptance URL:
https://panorama.vitormr.dev/invitations/accept?t=<token> - Lifetime: 7 days by default. Configurable per tenant in
Tenant.invitationTtlSeconds(defaults to604800). Community caps TTL between 1 hour and 30 days. Enterprise unlocks 1 hour to 365 days.
Acceptance rules
When a request hits /invitations/accept?t=<token>:
- Compute
sha256(token)→ look upInvitationbytokenHash. - Check
acceptedAt IS NULL,revokedAt IS NULL,expiresAt > now(). On any fail → 410 Gone, with a response code that tells the UI whether to offer "request a new invite". - If the target is already logged in: a. Verify the current session's email equals the invitation email (case-insensitive). Mismatch → 403 with a "log out and retry" message. Never automatically accept an invite for an email that doesn't match the session. b. Create the
TenantMembershipwithstatus='active',acceptedAt=now(). CopyinvitedByUserIdfrom the invitation. c. Mark the invitationacceptedAt=now(),acceptedByUserId=currentUser.id. - If not logged in: a. Redirect to
/login?invite_token=<token>. Login UI shows: "You're signing in to accept an invite from {inviter} to join {tenant}." with the target email prefilled. b. After successful login (password or OIDC), re-enter the acceptance path from step 1 with the now-authenticated session.
Edge cases handled:
- User registers a different email via OIDC (Google returns
alice@personal.combut invite is foralice@acme.com) → email mismatch → UI asks them to use the exact email or request a new invite. - User has multiple Panorama memberships already — acceptance just appends another membership; nothing about other tenants changes.
- Invitation clicked twice — second click hits
acceptedAt IS NOT NULL, returns a "this invitation has been used" page that links to the user's current session (or login if not authenticated). - Timing/race: two concurrent accepts from the same session. The UPDATE on Invitation uses
WHERE acceptedAt IS NULLas a predicate andRETURNING *— Postgres serialises; at most one accept wins.
Email delivery (outbox pattern)
- Invitation creation writes the
Invitationrow AND anemail_outboxrow in the same transaction. Controller returns 201 without waiting for send. - A BullMQ worker polls the outbox, renders the trilingual (EN/PT-BR/ES matching tenant
locale) invitation template, sends via the configured SMTP or SES/SendGrid provider (enterprise), and writesemailSentAtback. - On SMTP failure, the job is retried with exponential backoff, up to 5 attempts over 24 hours.
- Bounce handling (0.3+): inbound webhook from SES/SendGrid updates
emailBouncedAt+ surfaces a warning to the inviter.
Rate limits
- Community default: 100 invitations per admin per hour, 1 000 per tenant per day. Rejections return 429 with
Retry-After. - Enterprise default: same, but configurable via Tenant settings and overridable by a Super Admin.
Rate limits are enforced using a sliding-window counter in Redis. If Redis is unavailable, the system fails closed (reject invite creation) rather than open — a temporary outage of the limiter is preferable to an uncapped invitation blast.
Resend / revoke
- Resend: admin POSTs
/invitations/:id/resend. Generates a new token (invalidates the old), resetsemailQueuedAt/SentAt/BouncedAt, re-queues the email. The old token stops working immediately. - Revoke: admin POSTs
/invitations/:id/revoke. SetsrevokedAt,revokedByUserId. Token stops accepting immediately. Email (if already delivered) is not recalled but the link 410s.
Audit events
Every state change writes an audit_events row:
panorama.invitation.createdpanorama.invitation.email_sent(oremail_bounced,email_failed)panorama.invitation.accepted(includes target user id)panorama.invitation.expired(cron-driven)panorama.invitation.revokedpanorama.invitation.resent
Tenant admins see a filtered view of these via the admin UI.
Expiration sweep
A BullMQ cron (every hour) closes out expired invitations:
UPDATE invitations
SET updated_at = NOW()
WHERE accepted_at IS NULL
AND revoked_at IS NULL
AND expires_at < NOW();
-- Emits `panorama.invitation.expired` per row via a TRIGGER.The sweep exists so expired invitations appear in the admin UI with the correct state without requiring an admin to land on them.
Security properties summary
| Threat | Mitigation |
|---|---|
| Token leakage | Hashed at rest; email uses TLS; token has TTL + one-time-use |
| Email interception → account takeover | Email-match required at acceptance (target email must equal session email) |
| Admin spamming targets | 100/hr/admin rate limit, Redis-backed |
| Bulk enumeration of invite URLs | Token is 32 random bytes (256-bit entropy); brute force infeasible |
| Stolen invite used after the employee leaves | TTL + one-time-use + explicit revoke by admin |
| Timing attack at lookup | Lookup uses SHA-256 hash as an index key; no sensitive comparison |
| Race in double-accept | WHERE acceptedAt IS NULL predicate + Postgres serialisation |
| Redis-down → unlimited invites | Limiter fails closed (refuses creation); deliberate |
Alternatives considered
Magic link (same token grants direct login, no account required)
What Slack's old invite links did. Tempting but couples authentication to invitation — we want invitations to layer on top of the existing auth (password / OIDC). Rejected.
JWT as the token
Opaque random is better for invitations: no client-side parsing, no "what if we leak the signing key", and revocation is trivial (flip revokedAt). JWTs would force us to keep a revocation list anyway. Rejected.
Embed the invitation in the membership row
What ADR-0007's schema already hints at (TenantMembership.status = 'invited'). Rejected for this flow:
- An invite exists before a Panorama
Usernecessarily exists (target has no account yet). - Invitation needs its own audit + rate-limit + email state that doesn't belong on a membership row.
- Embedding complicates the partial unique index ("one open invite per (tenant, email)").
Membership's invitedBy* columns remain useful — at acceptance, we COPY the inviter, invitedAt, acceptedAt from the Invitation row into the membership. Membership carries the post-facto audit; Invitation table carries the in-flight state.
No TTL — admin-managed only
Rejected. Employees leave; emails get forwarded; old tokens become a liability. Fixed TTL floors the risk window.
Consequences
Positive
- First-class invitation UX matches what customers expect.
- Guest-from-other-company scenario (Amtrak/FDT) works out of the box — same invite shape, they land as
role='driver'in the inviting tenant. - Audit trail gives tenant admins the answer to "who added this person?" without needing support tickets.
- Outbox pattern means email delivery failures don't block the admin UI, and retries are free.
Negative
- New table + unique partial index + trigger — more schema surface.
- BullMQ + Redis required for email delivery (they're already in the stack — just wiring).
- Rate-limit-closed-on-Redis-outage is a deliberate availability sacrifice we have to document prominently.
Neutral
- Enterprise additions (SCIM just-in-time provisioning, policy-based auto-approve) layer cleanly on this model without reopening it.
Execution order
- ✅ 0.2 step 2 — password + OIDC + session + multi-tenant switching.
- ✅ 0.2 step 3b — web login + /assets list. Users seeded by super-admin.
- ✅ 0.2 step 3c — Invitation flow (shipped 2026-04-18):
- Migration 0004 —
invitationstable + partial unique index on(tenantId, email) WHERE open. InvitationService(create / list / resend / revoke / preview / finalize) with sha256 tokens and conditional-UPDATE double-click defence.- BullMQ
invitation-emailqueue + worker with 5-attempt exponential backoff; repeatableinvitation-maintenancecron that sweeps expired invitations + rescues stuck-at-queued rows by rotating the token. /invitations/*REST surface:POST /invitations,GET /invitations,POST /:id/resend,POST /:id/revoke,GET/POST /invitations/accept.- Acceptance web page (
apps/web/src/app/invitations/accept) that handles the four states with server-rendered branching. - Trilingual email templates (EN / PT-BR / ES) inline in TS.
- Redis-backed sliding-window rate limits that fail closed on Redis outage per §Rate limits.
- Audit events:
panorama.invitation.{created,email_sent, email_bounced,email_failed,accepted,expired,revoked,resent}.
- Migration 0004 —
- 0.2 step 3d — Owner enforcement (ADR-0007) lands next.
- 0.3 — bounce-handling webhook, invitation analytics, enterprise SCIM provisioning replacing the invitation flow for IdP-managed tenants.
Implementation notes (filed alongside the 3c commits)
- The ADR's §Data-model Prisma snippet lists the email-outbox columns (
emailQueuedAt/emailSentAt/emailBouncedAt/emailLastError) on theInvitationrow; the final migration adds one more column,emailAttempts INT DEFAULT 0, so the retry count survives worker restarts (implementation detail of the ADR's "up to 5 attempts" retry policy, not a contract change). - Expiration audit events are emitted by the
invitation-maintenanceBullMQ cron, not by a PG trigger — the net effect matches the ADR (onepanorama.invitation.expiredper row) while avoiding the SECURITY-DEFINER trigger + RLS interaction onaudit_events. - The BullMQ job payload carries the plaintext token for the lifetime of the job (standard Auth0 / Clerk pattern — same trust zone as the app;
removeOnCompletescrubs it from Redis on success). Onlysha256(token)ever persists in the DB.
This ADR is the contract. Future deviation (e.g. TTL bounds change) will land as an ADR update first, code second — per the ADR workflow in 0000-index.md.