Files
rfc-app/backend/app/invites.py
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

426 lines
16 KiB
Python

"""§6.1 / v0.17.0: admin-create user with role + invite email (roadmap item #16).
Distinguishes from the v0.8.0 self-serve beta-access flow:
* **Self-serve (v0.8.0)** — anyone with an email can request OTC sign-in;
a fresh `users` row lands in `permission_state='pending'`; an admin
grants or revokes via the v0.9.0 user-management page.
* **Admin-create (v0.17.0)** — an admin types first/last/email/role
*before* the invitee has signed in. The framework provisions the
`users` row with the chosen role and `permission_state='granted'`
(the admin's hand is the grant) and `last_seen_at IS NULL` as the
"invited but not yet arrived" discriminator. An invite-token row
lands in `user_invite_tokens`; the admin's chosen `custom_message`
(if any) rides in the email body alongside the claim link.
* **Claim flow** — the invitee clicks the link, which lands them at
`/invites/claim?token=…`. The page POSTs `/api/invites/claim` with
the token. The framework verifies the token (not expired, not
claimed, hash matches), marks the row claimed, signs the user in,
and returns a payload telling the frontend whether to route to
passcode-set (if v0.10.0 passcode flow is in play and the user has
no passcode yet) or to `/`. **No OTC roundtrip** — clicking the
unique token in the email is itself proof of email control, per
the roadmap. This is the intentional UX shortcut for first
sign-in; subsequent sign-ins use the standard OTC / passcode
paths.
The shape:
* `create_invite(...)` — provision the invitee `users` row + the
`user_invite_tokens` row, return the raw token for the admin
endpoint to put in the outbound email link.
* `claim(raw_token)` — validate the token, mark it claimed, return
the `SessionUser` the endpoint signs in. Distinguishes the failure
modes (`expired`, `claimed`, `unknown`, `invalid`) so the endpoint
can map them to HTTP 410 vs HTTP 404 cleanly.
* `list_pending_invites()` — return active invites for the admin
listing surface. Filters out claimed + expired rows so the surface
only shows live invites.
Token shape: opaque DB token (256 bits of CSPRNG entropy via
`secrets.token_urlsafe(32)`), bcrypt-hashed at rest. Opaque chosen
over JWT because revocation is then a single SQL UPDATE — a JWT
would be stateless but harder to invalidate, and admin-issued
invites are exactly the kind of thing an admin should be able to
yank back. The raw token only ever lives in the outbound email link
and the inbound claim body; server-side storage is the hash.
TTL: hard-coded to 7 days via `INVITE_TOKEN_TTL_DAYS`. Env-var
configurability is a §19.2 candidate — the constant is exposed
here as a single point of edit if a deployment wants to override.
The 500-char ceiling on `custom_message` is enforced at the
Pydantic body level in `api_admin.py`; this module trusts what
the endpoint hands it.
"""
from __future__ import annotations
import logging
import secrets
from dataclasses import dataclass
import bcrypt
from . import db
from .auth import SessionUser
log = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Tunables — intentionally hard-coded in v0.17.0 (§19.2 candidate to env-ify).
# ---------------------------------------------------------------------------
INVITE_TOKEN_TTL_DAYS = 7
# 256 bits of CSPRNG entropy. `secrets.token_urlsafe(32)` yields ~43
# URL-safe characters; the bcrypt hash is what's stored, so the raw
# token only ever lives in the outbound email link.
TOKEN_BYTES = 32
# Free-text ceiling for the admin's optional custom message. Matched
# at the Pydantic body bound in `api_admin.py`; mentioned here so the
# bound is documented in one place.
CUSTOM_MESSAGE_MAX_LENGTH = 500
# ---------------------------------------------------------------------------
# Token + hash helpers (mirror device_trust.py shape)
# ---------------------------------------------------------------------------
def _new_token() -> str:
return secrets.token_urlsafe(TOKEN_BYTES)
def _hash(token: str) -> str:
return bcrypt.hashpw(token.encode("utf-8"), bcrypt.gensalt()).decode("ascii")
def _check(token: str, token_hash: str) -> bool:
try:
return bcrypt.checkpw(token.encode("utf-8"), token_hash.encode("ascii"))
except (ValueError, TypeError):
return False
# ---------------------------------------------------------------------------
# Create
# ---------------------------------------------------------------------------
@dataclass
class CreateOutcome:
"""The shape returned from `create_invite`.
`raw_token` is what the admin endpoint puts in the outbound email
link; it never appears in storage. `invite_id` is the surrogate
key for the admin's "invites I've sent" listing. `invited_user_id`
is the freshly-provisioned `users` row id so the admin surface can
join through to the user-management page.
"""
raw_token: str
invite_id: int
invited_user_id: int
def create_invite(
*,
email: str,
first_name: str,
last_name: str,
role: str,
custom_message: str,
created_by_admin_id: int,
) -> CreateOutcome:
"""Provision the invitee `users` row + the `user_invite_tokens` row.
Caller (`api_admin.py`) is responsible for the admin-only auth check,
the self-email refusal (422), and the duplicate-email refusal (409).
This function trusts what it's handed and writes both rows
transactionally — the v0.10.0 `passcode.py` / v0.11.0 `device_trust.py`
helpers follow the same separation-of-concerns pattern.
The invitee `users` row is provisioned with:
* `permission_state='granted'` — the admin's hand is the grant;
the v0.8.0 self-serve `pending` queue is for the other path.
* `last_seen_at = NULL` — the discriminator for "invited but
not yet arrived" per the §16 / roadmap design. Every sign-in
path stamps `last_seen_at` to now, so a NULL value means the
invited user has not clicked through yet.
* `gitea_id = NULL`, `gitea_login = NULL` — same as a v0.7.0
OTC-provisioned user; the OAuth identity is grandfathered if
the user ever lands through that path.
* `display_name` defaults to "<first> <last>" (or local-part of
email if both are empty) so the user-management page reads a
sensible label before the user has signed in.
* `first_name` / `last_name` / `beta_request_reason` — the
first two from the admin's typed values; reason stays blank
(this user did not self-request access).
"""
email_clean = email.strip()
first_clean = (first_name or "").strip()
last_clean = (last_name or "").strip()
display = " ".join(p for p in (first_clean, last_clean) if p).strip()
if not display:
display = email_clean.split("@", 1)[0] or email_clean
# 1. Provision the invitee users row. The grant is the admin's
# hand; no permission_events row is necessary for the grant itself
# (we are not transitioning from pending → granted, we are landing
# a fresh row directly into granted).
#
# Note on the "pending invite" discriminator: the brief floated
# `first_sign_in_at NULL` / `last_seen_at NULL` as the marker the
# admin user-management page reads off the row to render the
# "(pending invite)" badge. The schema didn't cooperate — the
# existing `users.last_seen_at` column is NOT NULL with a
# `datetime('now')` default (see `migrations/001_users_and_audit.sql`),
# and there is no `first_sign_in_at` column. Rather than introduce
# a schema migration to add one (the brief explicitly said "likely
# no `users` table changes"), the discriminator is the existence of
# an active row in `user_invite_tokens` joined on `invited_user_id`.
# The admin listing's `pending_invite` field joins through that
# table; the claim flow stamps `claimed_at` on the invite row,
# which clears the badge naturally. This shape keeps the
# discriminator scoped to the v0.17.0 surface and avoids
# double-tracking against an existing column.
cur = db.conn().execute(
"""
INSERT INTO users (
gitea_id, gitea_login, email, display_name, avatar_url,
role, permission_state, first_name, last_name
)
VALUES (NULL, NULL, ?, ?, '', ?, 'granted', ?, ?)
""",
(email_clean, display, role, first_clean, last_clean),
)
invited_user_id = cur.lastrowid
# 2. Mint the token, hash it, write the invite row.
raw = _new_token()
h = _hash(raw)
cur = db.conn().execute(
f"""
INSERT INTO user_invite_tokens (
email, role, first_name, last_name, custom_message,
token_hash, expires_at, created_by_admin_id, invited_user_id
)
VALUES (?, ?, ?, ?, ?, ?, datetime('now', '+{INVITE_TOKEN_TTL_DAYS} days'), ?, ?)
""",
(
email_clean,
role,
first_clean,
last_clean,
(custom_message or "").strip(),
h,
created_by_admin_id,
invited_user_id,
),
)
invite_id = cur.lastrowid
return CreateOutcome(
raw_token=raw,
invite_id=invite_id,
invited_user_id=invited_user_id,
)
# ---------------------------------------------------------------------------
# Claim
# ---------------------------------------------------------------------------
@dataclass
class ClaimOutcome:
"""The result of `claim`.
`user` is populated only on success. `reason` distinguishes the
failure modes so the endpoint can return distinct HTTP statuses
(HTTP 410 for expired/claimed — the token is dead; HTTP 400 for
unknown/invalid — the request shape is wrong).
"""
ok: bool
user: SessionUser | None
reason: str # 'ok' | 'invalid' | 'unknown' | 'expired' | 'claimed'
invite_id: int | None = None
def claim(raw_token: str) -> ClaimOutcome:
"""Validate the presented token and consume it.
Walks the active invite rows looking for a bcrypt hash match.
Mirrors `device_trust.lookup`: bcrypt's per-row salt means we
cannot SELECT by hash, but the set is small (a deployment's
outstanding invites at any moment) and bcrypt is cheap on the
order of milliseconds.
On a hit:
* Mark the row claimed (stamp `claimed_at = now`,
`claimed_by_user_id = invited_user_id` — the admin's
pre-provisioned row is the claimant).
* Stamp `last_seen_at = now` on the user row so the v0.9.0
admin user-management page no longer shows "(pending invite)".
* Return a populated `SessionUser` for the endpoint to sign in.
On a miss:
* `unknown` — no row matched. The token may have been forged or
the invite was admin-revoked.
* `expired` — row matched but `expires_at` is in the past.
* `claimed` — row matched but `claimed_at` is non-NULL. The
token was already consumed; the user must contact the admin
for a fresh invite.
* `invalid` — the token string itself was empty or unparseable.
"""
raw = (raw_token or "").strip()
if not raw:
return ClaimOutcome(ok=False, user=None, reason="invalid")
rows = db.conn().execute(
"""
SELECT id, token_hash, expires_at, claimed_at, invited_user_id, role
FROM user_invite_tokens
ORDER BY id DESC
"""
).fetchall()
matched = None
for row in rows:
if _check(raw, row["token_hash"]):
matched = row
break
if matched is None:
return ClaimOutcome(ok=False, user=None, reason="unknown")
if matched["claimed_at"] is not None:
return ClaimOutcome(
ok=False, user=None, reason="claimed", invite_id=matched["id"],
)
expired = db.conn().execute(
"SELECT datetime(?) < datetime('now') AS expired",
(matched["expires_at"],),
).fetchone()["expired"]
if expired:
return ClaimOutcome(
ok=False, user=None, reason="expired", invite_id=matched["id"],
)
# Consume the row before signing in so a parallel claim of the same
# token cannot double-sign-in. (Mirrors `otc.verify_code`'s consume-
# before-provision shape.)
db.conn().execute(
"""
UPDATE user_invite_tokens
SET claimed_at = datetime('now'),
claimed_by_user_id = invited_user_id
WHERE id = ?
""",
(matched["id"],),
)
# Stamp last_seen_at on the user row so the user's activity stamp
# is current after the claim (mirroring otc.verify_code's
# last-seen update on the provision path). The "(pending invite)"
# badge's clear is driven by the invite row's `claimed_at`
# transition above; this update is for the general user-listing's
# recency ordering.
db.conn().execute(
"UPDATE users SET last_seen_at = datetime('now') WHERE id = ?",
(matched["invited_user_id"],),
)
user_row = db.conn().execute(
"""
SELECT id, gitea_id, gitea_login, email, display_name, avatar_url,
role, permission_state
FROM users
WHERE id = ?
""",
(matched["invited_user_id"],),
).fetchone()
if user_row is None:
# The invitee user row was deleted between create_invite and
# claim (shouldn't happen under the FK ON DELETE CASCADE — the
# cascade would drop the invite row too — be defensive anyway).
return ClaimOutcome(
ok=False, user=None, reason="unknown", invite_id=matched["id"],
)
return ClaimOutcome(
ok=True,
user=SessionUser(
user_id=user_row["id"],
gitea_id=user_row["gitea_id"] or 0,
gitea_login=user_row["gitea_login"] or "",
display_name=user_row["display_name"],
email=user_row["email"] or "",
avatar_url=user_row["avatar_url"] or "",
role=user_row["role"],
permission_state=user_row["permission_state"] or "granted",
),
reason="ok",
invite_id=matched["id"],
)
# ---------------------------------------------------------------------------
# List pending invites — for the admin's review surface
# ---------------------------------------------------------------------------
@dataclass
class PendingInviteRow:
"""The shape the `GET /api/admin/users/invites` endpoint returns.
Note the absence of `token_hash` — the hash is structurally private,
and the surface has no use for it. The raw token is also not on
the listing; it lives only in the email link.
"""
id: int
email: str
role: str
first_name: str
last_name: str
custom_message: str
created_at: str
expires_at: str
created_by_admin_id: int
invited_user_id: int
def list_pending_invites() -> list[PendingInviteRow]:
"""Active invites (not claimed, not expired), freshest first.
The admin's "I sent these but they haven't been claimed yet" view.
Filters mirror the `device_trust.list_for_user` shape: the surface
only shows live records the framework would actually accept on a
presented token.
"""
rows = db.conn().execute(
"""
SELECT id, email, role, first_name, last_name, custom_message,
created_at, expires_at, created_by_admin_id, invited_user_id
FROM user_invite_tokens
WHERE claimed_at IS NULL
AND datetime(expires_at) > datetime('now')
ORDER BY created_at DESC, id DESC
"""
).fetchall()
return [
PendingInviteRow(
id=row["id"],
email=row["email"],
role=row["role"],
first_name=row["first_name"] or "",
last_name=row["last_name"] or "",
custom_message=row["custom_message"] or "",
created_at=row["created_at"],
expires_at=row["expires_at"],
created_by_admin_id=row["created_by_admin_id"],
invited_user_id=row["invited_user_id"],
)
for row in rows
]