Files
rfc-app/backend/migrations/019_user_invite_tokens.sql
T
Ben Stull 41b0c6af99 Release 0.17.0: admin-create user + invite email (custom message; claim-link claim flow)
Roadmap item #16 / §6.1. From the v0.9.0 /admin/users surface, an
admin can now create a user record before that person has ever
signed in — typing first name, last name, email, role, and an
optional custom message — and the framework sends an invite email
carrying a single-use claim link. The invitee clicks through to
/invites/claim?token=…, the token is consumed, the session is
established, and the user is routed to the passcode-set screen on
first sign-in.

New endpoints:
  * POST /api/admin/users — admin-only; provisions the users row +
    user_invite_tokens row + sends the invite email + writes a
    permission_events row with event_kind='user_invited'.
  * GET  /api/admin/users/invites — admin-only; lists active
    (not-claimed, not-expired) invites with the issuing admin.
  * POST /api/invites/claim — anonymous-reachable; validates the
    token, consumes the row, signs the invitee in (skipping OTC
    per the roadmap — clicking the email link is itself proof of
    email control), returns needs_passcode for the frontend's
    route-onward decision.

Schema: migration slot 019 — user_invite_tokens (id, email, role,
first/last name, custom_message, bcrypt token_hash, expires_at,
created_at, created_by_admin_id, claimed_at, claimed_by_user_id,
invited_user_id). Slot 018 reserved for the parallel #12 release
(per-RFC invitation) shipping in the same wave; distinct table
(rfc_invitations there vs. user_invite_tokens here) so they
coexist cleanly. No users-table changes — the brief floated a
NULL-column discriminator for "(pending invite)" but the existing
users.last_seen_at is NOT NULL with a datetime('now') default, so
the discriminator is the active user_invite_tokens row joined on
invited_user_id; the admin user-listing carries a pending_invite
field populated via that join.

Claim route: frontend /invites/claim?token=… (new
InviteClaim.jsx). Anonymous-reachable; renders "Claim my account"
CTA with an optional v0.11.0-style "trust this device" checkbox,
calls the claim endpoint, routes onward.

Token shape: opaque DB token (256 bits CSPRNG via
secrets.token_urlsafe(32), bcrypt-at-rest), not JWT. Opaque
chosen because admin revocation is then a single SQL UPDATE — JWT
would be stateless but harder to invalidate.

Open-question decisions: immediate-send (no admin-review-then-
send queue; future enhancement), no bulk-invite (deferred to
follow-up; v0.17.0 is one-at-a-time), 7-day expiry as a constant
(INVITE_TOKEN_TTL_DAYS in backend/app/invites.py; env-var
configurability is a §19.2 candidate), OTC skipped on first
sign-in (the token in the email is itself proof of email control;
subsequent sign-ins go through OTC / passcode unchanged).

Refusals on POST /api/admin/users:
  * 422 self-invite (use the role-change channel for self-edits)
  * 409 duplicate email (use the existing grant/role gestures)
  * 422 owner-grant by non-owner admin (§6.1 owner-zero is the
    only owner bootstrap path)
  * 422 pydantic — malformed email / unknown role /
    custom_message > 500 chars
  * 403 non-admin caller / 401 anonymous

15 new backend tests in test_admin_create_user_invite_vertical.py
(happy path, all four refusals, claim with valid / expired /
already-claimed / unknown token, pending-invite badge before and
after claim, listing admin-only). 234 total backend tests pass;
frontend build succeeds.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 04:48:02 -07:00

106 lines
5.9 KiB
SQL

-- §6.1 / v0.17.0: admin-create user with role + invite email (roadmap item #16).
--
-- Distinguishes from the v0.8.0 / v0.9.0 self-serve beta-access shape:
-- here an *admin* creates a `users` row *before* the invited person has
-- ever signed in, assigns them a role at creation time, and sends them
-- an invite email carrying a claim link. The invitee clicks the link,
-- the claim flow consumes the token (which is itself proof of email
-- control), the row is marked claimed, and the user is signed in
-- inheriting the pre-set role.
--
-- Migration slot 019 is allocated to this release. Slot 018 is reserved
-- for the parallel #12 release (per-RFC invitation, owner-only) shipping
-- in the same wave; the two features live in distinct tables
-- (`user_invite_tokens` here vs. `rfc_invitations` there) so they
-- coexist cleanly. Slot 016 was reserved+skipped by Session K during
-- v0.9.0 integration; slot 017 is the v0.11.0 device-trust table.
--
-- Open-question decisions settled in this release (see CHANGELOG):
-- * No `users` table changes — the brief floated `first_sign_in_at`
-- / `last_seen_at IS NULL` as the "(pending invite)" discriminator,
-- but the existing `users.last_seen_at` is NOT NULL with a
-- `datetime('now')` default (migrations/001) and there is no
-- `first_sign_in_at` column. Rather than land a schema migration to
-- introduce one, the discriminator is the existence of an active
-- (not-claimed, not-expired) row in `user_invite_tokens` joined on
-- `invited_user_id`. The admin user-listing carries a
-- `pending_invite` field populated via that join; on claim, the
-- invite row's `claimed_at` populates and the badge clears.
-- No new `permission_state` value is introduced either.
-- * The token is opaque (random URL-safe string, bcrypt-hashed at
-- rest), not a JWT, so admin revocation by row UPDATE works
-- without distributing a key-rotation gesture.
-- * The TTL is a constant (`INVITE_TOKEN_TTL_DAYS = 7` in
-- `backend/app/invites.py`); env-var configurability is a follow-up.
-- * Immediate-send (no admin-review-then-send queue) ships in this
-- release; admin-preview is a future enhancement.
-- * Bulk-invite (CSV paste) is deferred to a follow-up release;
-- v0.17.0 is one-at-a-time.
--
-- Storage shape:
--
-- * `id` — surrogate key. Lets the admin "pending invites" listing
-- address a row without leaking the token shape.
-- * `email` — the address the invite was sent to (case-insensitive
-- match at claim time, persisted verbatim for the audit trail).
-- * `role` — the role the invitee inherits on first sign-in. Pinned
-- via CHECK to the same set the §6.1 role flip accepts
-- (`owner` / `admin` / `contributor`) so a future role-set drift
-- fails loudly at insert rather than provisioning a ghost role.
-- * `first_name` / `last_name` — captured at create time so the
-- invitee skips the v0.8.0 capture-form step on first sign-in.
-- * `custom_message` — optional free-text from the admin (max 500
-- chars enforced at the API layer); embedded verbatim in the
-- email body if present.
-- * `token_hash` — bcrypt hash of the random opaque token. The
-- raw token only ever lives in the outbound email link and the
-- inbound claim body; server-side storage is the hash.
-- * `expires_at` — `created_at + 7 days` (default at the app layer
-- via `INVITE_TOKEN_TTL_DAYS`). A row past this stamp is dead;
-- the claim path refuses with HTTP 410.
-- * `created_at` — when the admin issued the invite.
-- * `created_by_admin_id` — FK into users(id) for the admin who
-- created the invite (no cascade; if the admin's row is deleted
-- the invite history stays so the audit trail survives).
-- * `claimed_at` — non-NULL once the invitee successfully claims.
-- A second claim attempt against an already-claimed row returns
-- HTTP 410.
-- * `claimed_by_user_id` — FK into users(id) for the user row
-- that consumed the token. In the common case this equals the
-- freshly-provisioned row that was created at invite time; the
-- FK lets the admin's "claimed" list join through.
-- * `invited_user_id` — FK into users(id) for the pre-provisioned
-- row. Created at invite time with `last_seen_at IS NULL` so the
-- v0.9.0 admin user-management page can render a "(pending
-- invite)" badge alongside existing users.
--
-- Indexing:
-- * Unique index on `token_hash` documents the no-collision
-- invariant (256 bits of CSPRNG entropy; collision is
-- structurally impossible, the unique constraint catches a
-- bug at insert time).
-- * Index on `(email, claimed_at)` so the "is this email already
-- invited?" pre-check the admin endpoint runs is a covering walk.
-- * Index on `(created_by_admin_id, created_at DESC)` for the
-- admin's "invites I've sent" listing.
CREATE TABLE user_invite_tokens (
id INTEGER PRIMARY KEY AUTOINCREMENT,
email TEXT NOT NULL,
role TEXT NOT NULL CHECK (role IN ('owner', 'admin', 'contributor')),
first_name TEXT NOT NULL DEFAULT '',
last_name TEXT NOT NULL DEFAULT '',
custom_message TEXT NOT NULL DEFAULT '',
token_hash TEXT NOT NULL,
expires_at TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
created_by_admin_id INTEGER NOT NULL REFERENCES users(id),
claimed_at TEXT,
claimed_by_user_id INTEGER REFERENCES users(id),
invited_user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE
);
CREATE UNIQUE INDEX idx_user_invite_tokens_hash ON user_invite_tokens (token_hash);
CREATE INDEX idx_user_invite_tokens_email ON user_invite_tokens (email, claimed_at);
CREATE INDEX idx_user_invite_tokens_admin ON user_invite_tokens (created_by_admin_id, created_at DESC);