Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| abd3626ce3 |
+113
-202
@@ -197,6 +197,119 @@ consent infrastructure is wired so item #13 (v0.15.0) can read from
|
|||||||
(because their `cookie_consent` row does not yet exist); their
|
(because their `cookie_consent` row does not yet exist); their
|
||||||
current sessions remain valid.
|
current sessions remain valid.
|
||||||
|
|
||||||
|
## 0.11.0 — 2026-05-28
|
||||||
|
|
||||||
|
**Minor — schema migration required; no new env vars.** This release
|
||||||
|
ships the "trust this device for 30 days" gesture (roadmap item #9,
|
||||||
|
SPEC §6.2). After a successful OTC or passcode sign-in, the user
|
||||||
|
can check a single checkbox to mint a server-issued opaque
|
||||||
|
device-trust token; the token rides as a long-lived HttpOnly +
|
||||||
|
Secure + SameSite=Lax cookie, and the matching row's hash lives in a
|
||||||
|
new `device_trust` table. On a subsequent visit, the cookie is
|
||||||
|
presented at `POST /auth/device-trust/start` — if a non-expired,
|
||||||
|
non-revoked row matches, the session is re-established without
|
||||||
|
another OTC / passcode roundtrip. A new `/settings/notifications`
|
||||||
|
"Trusted devices" section lists active rows (created-at, last-seen,
|
||||||
|
expiry, rough UA label) with per-row "Revoke" and a "Revoke all
|
||||||
|
devices" button. The cookie is "essential" per the v0.13.0 cookie-
|
||||||
|
consent contract — it is part of authentication, not analytics — and
|
||||||
|
is set regardless of the user's analytics / other-cookies choice.
|
||||||
|
|
||||||
|
The session model gains a cookie, not a session-store change: the
|
||||||
|
existing `rfc_session` cookie still carries the in-flight session
|
||||||
|
state; the new `rfc_device_trust` cookie is consulted only by
|
||||||
|
`/auth/device-trust/start` to bootstrap a fresh session on a return
|
||||||
|
visit. The raw token only ever lives in the outbound `Set-Cookie`
|
||||||
|
header and the inbound `Cookie` header; server-side storage is the
|
||||||
|
bcrypt hash; constant-time comparison via `bcrypt.checkpw` on the
|
||||||
|
candidate walk. The raw token is never logged.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **`device_trust` table** (`backend/migrations/017_device_trust.sql`).
|
||||||
|
Per-row id, `user_id` (FK with cascade), `device_token_hash`
|
||||||
|
(bcrypt at rest, unique index documents the no-collision
|
||||||
|
invariant), `created_at`, `expires_at` (`created_at + 30 days`),
|
||||||
|
`user_agent` (verbatim, app-layer-truncated to 1024 chars),
|
||||||
|
`last_seen_at` (refreshed on every successful lookup), `revoked_at`
|
||||||
|
(NULL means active). Secondary index on `(user_id, revoked_at)` so
|
||||||
|
the /settings list query is a covering walk.
|
||||||
|
- **`backend/app/device_trust.py`** — sibling of `otc.py` and
|
||||||
|
`passcode.py`. Carries `issue(user_id, user_agent)`,
|
||||||
|
`lookup(raw_token)`, `list_for_user(user_id)`, `revoke(user_id,
|
||||||
|
row_id)`, and `revoke_all(user_id)`. The 30-day window and the
|
||||||
|
cookie name (`rfc_device_trust`) live as module-level constants;
|
||||||
|
env-ifying them is a §19.2 candidate.
|
||||||
|
- **`§17` endpoints**
|
||||||
|
- `POST /auth/device-trust/start` — anonymous-reachable. Reads the
|
||||||
|
`rfc_device_trust` cookie; on a hit, signs the user in. On a
|
||||||
|
miss (expired, revoked, or unknown), clears the stale cookie and
|
||||||
|
returns 401.
|
||||||
|
- `GET /api/auth/me/devices` — list active trusted devices for
|
||||||
|
the signed-in user.
|
||||||
|
- `DELETE /api/auth/me/devices/{id}` — revoke a single row.
|
||||||
|
User-id scope enforced in SQL so a hostile client cannot
|
||||||
|
revoke another user's row by guessing ids.
|
||||||
|
- `DELETE /api/auth/me/devices` — revoke every active row.
|
||||||
|
- **OTC and passcode verify bodies** gain an optional
|
||||||
|
`trust_device: bool` field (default false). When true and verify
|
||||||
|
succeeds, the endpoint mints a fresh device-trust row and sets
|
||||||
|
the cookie on the response. Pre-v0.11.0 clients that omit the
|
||||||
|
field continue to behave as before.
|
||||||
|
- **Login.jsx** gains a "Trust this device for 30 days" checkbox
|
||||||
|
on both the OTC and passcode verify steps, plus a silent on-mount
|
||||||
|
call to `POST /auth/device-trust/start` so a returning user with
|
||||||
|
a valid cookie skips the email step entirely. A failure is
|
||||||
|
intentionally invisible — the user proceeds to the normal email
|
||||||
|
step.
|
||||||
|
- **`/settings/notifications` "Trusted devices" section** — lists
|
||||||
|
active rows with per-row "Revoke" + a "Revoke all devices" button
|
||||||
|
(with a `confirm()` prompt because the gesture is broad). The
|
||||||
|
surface intentionally does not single out the row whose cookie
|
||||||
|
the current request carries so a user can revoke "this device"
|
||||||
|
alongside any other from one place.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **`backend/app/main.py`** — the OTC and passcode verify endpoints
|
||||||
|
now also accept the `trust_device` flag and accept an injected
|
||||||
|
`Response` so they can attach the cookie. Two helpers
|
||||||
|
(`_set_device_trust_cookie`, `_clear_device_trust_cookie`) carry
|
||||||
|
the cookie attribute set in one place so the contract is
|
||||||
|
consistent across endpoints. The `Response` import is added
|
||||||
|
alongside the existing FastAPI re-exports.
|
||||||
|
- **`backend/app/api.py`** — imports `device_trust as device_trust_mod`
|
||||||
|
alongside `auth`/`db`; mounts the three `/api/auth/me/devices*`
|
||||||
|
endpoints immediately after `/api/auth/me/beta-request` so the
|
||||||
|
auth-shaped neighborhood stays clustered.
|
||||||
|
- **`frontend/src/api.js`** — exports `startDeviceTrust()`,
|
||||||
|
`listMyDevices()`, `revokeMyDevice(id)`, `revokeAllMyDevices()`.
|
||||||
|
`verifyOtc` and `verifyPasscode` accept an optional
|
||||||
|
`{ trustDevice }` argument that rides on the POST body.
|
||||||
|
|
||||||
|
### Upgrade steps (from 0.10.0)
|
||||||
|
|
||||||
|
- You **MUST** apply schema migration `017_device_trust.sql`. The
|
||||||
|
migration creates a single new table with one secondary index;
|
||||||
|
the framework runs migrations automatically at process start, so
|
||||||
|
no manual step is required beyond restarting the backend so the
|
||||||
|
migration runner picks the file up.
|
||||||
|
- You **MUST** rebuild the frontend and restart the backend after
|
||||||
|
upgrading. `frontend/package.json#version` and `VERSION` both
|
||||||
|
move to `0.11.0` and the new `Set-Cookie` shape requires the
|
||||||
|
backend to be on the matching version.
|
||||||
|
- You **MUST** serve the deployment over HTTPS. The
|
||||||
|
`rfc_device_trust` cookie is set with `Secure=True` — a
|
||||||
|
cleartext deployment will never receive the cookie back from
|
||||||
|
the browser, so the trust gesture will appear to silently fail.
|
||||||
|
Production OHM deployments already serve over HTTPS; local
|
||||||
|
development against `http://localhost` is unaffected (no cookie
|
||||||
|
is set, the OTC/passcode paths continue to work).
|
||||||
|
- You **MAY** announce the new feature to your users. Existing
|
||||||
|
signed-in sessions are unaffected — the device-trust cookie is
|
||||||
|
opt-in on the next sign-in, and a user who never checks the box
|
||||||
|
keeps the v0.10.0 behavior verbatim.
|
||||||
|
|
||||||
## 0.10.0 — 2026-05-28
|
## 0.10.0 — 2026-05-28
|
||||||
|
|
||||||
**Minor — schema migration required; new auth path is additive.**
|
**Minor — schema migration required; new auth path is additive.**
|
||||||
@@ -337,208 +450,6 @@ v0.7.0. The lockout shape (5 attempts, 15 minutes) and the length
|
|||||||
range (4–20) are hard-coded in `backend/app/passcode.py`. See
|
range (4–20) are hard-coded in `backend/app/passcode.py`. See
|
||||||
§19.2 for the env-tunable candidate.
|
§19.2 for the env-tunable candidate.
|
||||||
|
|
||||||
## 0.9.0 — 2026-05-28
|
|
||||||
|
|
||||||
**Minor — no schema migration; reuses v0.8.0 columns and existing
|
|
||||||
SMTP.** This release ships the admin user-management surface at
|
|
||||||
`/admin/users` and the new-beta-request notifications that feed
|
|
||||||
it (roadmap item #7, SPEC §6.1 / §15 / §17). The two halves
|
|
||||||
compose: admins receive an inbox + email signal the moment a
|
|
||||||
pending user submits the v0.8.0 capture form; clicking through
|
|
||||||
lands on the page where they Grant or Revoke access.
|
|
||||||
|
|
||||||
The page consumes the v0.8.0 schema columns
|
|
||||||
(`permission_state`, `first_name`, `last_name`,
|
|
||||||
`beta_request_reason`, `permission_decided_by`,
|
|
||||||
`permission_decided_at`) without adding new ones — migration
|
|
||||||
slot 016 stays reserved for a future release. The single new
|
|
||||||
write endpoint, `POST /api/admin/users/<id>/permission`,
|
|
||||||
replaces v0.8.0's documented manual `UPDATE users SET
|
|
||||||
permission_state='granted'` gesture with an audited UI flip.
|
|
||||||
|
|
||||||
Admin notifications ride the existing §15 chokepoint — the
|
|
||||||
`new_beta_request` event_kind is added to the enum with
|
|
||||||
category `admin-actionable`, fan-out is to every owner / admin
|
|
||||||
minus the requester themselves, and the §15.4 email dispatch
|
|
||||||
only reaches recipients whose `email_admin_actionable` toggle
|
|
||||||
is on (the default for owners + admins). The event is the
|
|
||||||
framework's first non-RFC-scoped notification — `rfc_slug` is
|
|
||||||
NULL and the email deep-link points `/admin/users` instead of
|
|
||||||
`/rfc/<slug>`.
|
|
||||||
|
|
||||||
Decision on `/admin/allowlist`: the surface stays as a
|
|
||||||
sibling sub-tab, not folded into `/admin/users`. The two have
|
|
||||||
different keys (allowlist by email pre-sign-up, user list by
|
|
||||||
user_id post-sign-up) and a union row would be confusing
|
|
||||||
rather than clarifying. The allowlist's fast-path-bypass role
|
|
||||||
from v0.8.0 is unchanged; retiring the table outright is
|
|
||||||
deferred to a later session (see §19.2).
|
|
||||||
|
|
||||||
### Upgrade steps (from 0.10.0)
|
|
||||||
|
|
||||||
1. **MAY** rebuild the frontend. The build is the same shape
|
|
||||||
as v0.10.0; the lockfile pins to `0.9.0` so `npm install`
|
|
||||||
in `frontend/` updates it cleanly. No new env vars on the
|
|
||||||
frontend; the existing `VITE_APP_NAME` requirement carries
|
|
||||||
over.
|
|
||||||
2. **MAY** restart the backend. No schema migration runs in
|
|
||||||
this release — every v0.9.0 column is from
|
|
||||||
`014_beta_access.sql` (v0.8.0). Restart only if you want
|
|
||||||
the new endpoints registered in this version's binary.
|
|
||||||
3. **SHOULD** verify SMTP can reach the deployment's admin
|
|
||||||
inbox before the first pending user submits the capture
|
|
||||||
form. v0.9.0 reuses the v0.7.0 SMTP configuration
|
|
||||||
(`SMTP_HOST`, `SMTP_PORT`, `SMTP_USER`, `SMTP_PASSWORD`,
|
|
||||||
`SMTP_STARTTLS`, `EMAIL_FROM`, `EMAIL_FROM_NAME`); a
|
|
||||||
misconfigured deployment will still write the inbox row,
|
|
||||||
but the admin won't hear about it through email. No new
|
|
||||||
env var is required — the framework reuses the existing
|
|
||||||
admin-user list (role IN ('owner', 'admin')) as the
|
|
||||||
notification recipients.
|
|
||||||
4. **SHOULD** announce the surface to existing admins. Wording
|
|
||||||
suggestion: "There's now a Users tab in /admin where you can
|
|
||||||
Grant or Revoke beta access — and you'll get an email +
|
|
||||||
inbox row when a fresh request lands. The manual SQL gesture
|
|
||||||
from v0.8.0 still works but is no longer the documented
|
|
||||||
path."
|
|
||||||
5. **MAY** drain the existing pending queue through the new UI.
|
|
||||||
If your deployment carried pending users through the v0.8.0
|
|
||||||
manual-UPDATE window, the Pending bucket on `/admin/users`
|
|
||||||
surfaces all of them with their captured profile. Granting
|
|
||||||
from the UI stamps `permission_decided_by` /
|
|
||||||
`permission_decided_at`, which any prior manual UPDATE
|
|
||||||
gestures may have left NULL (no harm done — the v0.8.0
|
|
||||||
contract didn't require those stamps).
|
|
||||||
|
|
||||||
### Added
|
|
||||||
|
|
||||||
- **`POST /api/admin/users/<id>/permission`** — body
|
|
||||||
`{state: 'pending'|'granted'|'revoked'}`. Flips the column,
|
|
||||||
stamps `permission_decided_by` + `permission_decided_at`,
|
|
||||||
and writes a `permission_events` row with event_kind in
|
|
||||||
`{permission_granted, permission_revoked,
|
|
||||||
permission_repended}`. Refuses 422 on self-flip (symmetric
|
|
||||||
to `set_mute` / `set_role` self-action refusals) and 422
|
|
||||||
on invalid state. Returns `{ok, permission_state, changed}`
|
|
||||||
where `changed=false` indicates a no-op (the requested
|
|
||||||
state already matched).
|
|
||||||
- **Widened `GET /api/admin/users` response** carrying
|
|
||||||
`permission_state`, `first_name`, `last_name`,
|
|
||||||
`beta_request_reason`, `created_at`,
|
|
||||||
`permission_decided_at`, plus joined
|
|
||||||
`permission_decided_by_login` /
|
|
||||||
`permission_decided_by_display`. Sort order surfaces
|
|
||||||
`pending` rows first (the admin queue), then `granted`,
|
|
||||||
then `revoked`; within a bucket, owners precede admins
|
|
||||||
precede contributors, with recency as the tiebreaker.
|
|
||||||
- **`new_beta_request` event_kind** in the §15.1 enum. Fired
|
|
||||||
by `notify.fan_out_new_beta_request` from the first
|
|
||||||
successful `POST /api/auth/me/beta-request` (re-submits
|
|
||||||
from the same pending user don't re-fire — the row's
|
|
||||||
first-time-complete check guards against carpet-bombing).
|
|
||||||
Recipients: every owner + admin minus the requester
|
|
||||||
themselves. Category: `admin-actionable`. Deep-link:
|
|
||||||
`/admin/users`. Actor: the requester per §15.9.
|
|
||||||
- **`/admin/users` page enhancements** in `Admin.jsx`. The
|
|
||||||
Users tab gains a state filter chip row (All / Pending /
|
|
||||||
Granted / Revoked with counts), a Grant / Revoke control
|
|
||||||
column, a Permission state badge, and an expandable
|
|
||||||
"why they want access" row beneath each pending user.
|
|
||||||
Sign-up timestamp surfaces in a new column.
|
|
||||||
- **`frontend/src/api.js#setUserPermission`** — client for
|
|
||||||
the new endpoint, neighboring `setUserMute` and
|
|
||||||
`setUserRole`.
|
|
||||||
- **`/beta-pending` copy update** in `BetaPending.jsx`. The
|
|
||||||
"your request is in review" page now honestly references
|
|
||||||
the admin-email signal v0.9.0 ships and admits the
|
|
||||||
framework does not commit to an SLA — turnaround depends
|
|
||||||
on operator availability, and the deployment operator is
|
|
||||||
the right person to ask if a wait runs long. No
|
|
||||||
deployment-specific text is baked in; per-deployment copy
|
|
||||||
lives in §13 of the deployment's repo, not in the
|
|
||||||
framework.
|
|
||||||
- **`backend/tests/test_admin_users_vertical.py`** — 10 new
|
|
||||||
tests covering: a beta-request submission fans
|
|
||||||
notifications to every admin + owner (and not to the
|
|
||||||
requester or to contributors); the requester's profile
|
|
||||||
fields land in the notification payload; re-submitting
|
|
||||||
the capture form does not re-fan; the `admin-actionable`
|
|
||||||
category mapping is wired; the `/api/admin/users`
|
|
||||||
listing carries the v0.8.0 columns with `pending` rows
|
|
||||||
sorted first; the permission-flip endpoint promotes
|
|
||||||
pending → granted with the right audit shape; the
|
|
||||||
endpoint promotes granted → revoked; the endpoint
|
|
||||||
refuses self-flip with 422; the endpoint refuses
|
|
||||||
non-admin callers with 403 and anonymous callers with
|
|
||||||
401; the endpoint refuses invalid states with 422; a
|
|
||||||
state-already-matches flip returns `changed=false`
|
|
||||||
without writing an audit row.
|
|
||||||
|
|
||||||
### Changed
|
|
||||||
|
|
||||||
- **`backend/app/api.py`** — the `POST /api/auth/me/beta-request`
|
|
||||||
handler now calls `notify.fan_out_new_beta_request` after the
|
|
||||||
capture UPDATE lands, gated on the row not previously having
|
|
||||||
all three profile fields populated (so re-submits don't
|
|
||||||
re-fan).
|
|
||||||
- **`backend/app/api_admin.py`** — the `list_users` query joins
|
|
||||||
against `users d ON d.id = u.permission_decided_by` for the
|
|
||||||
deciding-admin handle. The new `set_permission` endpoint
|
|
||||||
lives alongside `set_role` / `set_mute`.
|
|
||||||
- **`backend/app/notify.py`** — adds
|
|
||||||
`CATEGORY_ADMIN_ACTIONABLE`, the `fan_out_new_beta_request`
|
|
||||||
helper, and the `new_beta_request` arm in `render_summary`.
|
|
||||||
- **`backend/app/email.py`** — the `_EVENT_TO_CATEGORY` map
|
|
||||||
carries `new_beta_request → admin-actionable`, and
|
|
||||||
`_deep_link` routes framework-scoped admin signals to
|
|
||||||
`/admin/users` instead of `/rfc/<slug>`.
|
|
||||||
- **`frontend/src/components/Admin.jsx`** — the `UsersTab`
|
|
||||||
component is rewritten with state filter chips, a per-row
|
|
||||||
`UserRow` + `PermissionCell` decomposition, and a
|
|
||||||
`useMemo`-cached counts table. The pending-row reason
|
|
||||||
blockquote renders as a secondary `<tr>` beneath the user
|
|
||||||
row when present.
|
|
||||||
- **`frontend/src/components/BetaPending.jsx`** — pending-state
|
|
||||||
copy revised to reference the admin email signal honestly.
|
|
||||||
- **`frontend/src/App.css`** — new admin-chip / permission-badge
|
|
||||||
/ user-row-reason rules.
|
|
||||||
- **`SPEC.md`** §6 opening, §15.1 event-kinds enum, §17 admin
|
|
||||||
endpoints, §19.2 candidates list — per §19.3 rule-2.
|
|
||||||
- **`VERSION`** → `0.9.0`. `frontend/package.json#version` and
|
|
||||||
the lockfile mirror.
|
|
||||||
|
|
||||||
### Environment variables
|
|
||||||
|
|
||||||
None new. The release reuses the v0.7.0 SMTP configuration
|
|
||||||
(`SMTP_HOST`, `SMTP_PORT`, `SMTP_USER`, `SMTP_PASSWORD`,
|
|
||||||
`SMTP_STARTTLS`, `EMAIL_FROM`, `EMAIL_FROM_NAME`,
|
|
||||||
`EMAIL_ENABLED`, `EMAIL_BUNDLE_THRESHOLD`) and the existing
|
|
||||||
admin-user list (role IN ('owner', 'admin')) as the
|
|
||||||
notification recipients. A deployment whose SMTP is misconfigured
|
|
||||||
will still see the inbox rows; only the email channel is muted.
|
|
||||||
|
|
||||||
### Deferred to later releases
|
|
||||||
|
|
||||||
- **Grant / revoke notification to the user** — symmetric
|
|
||||||
signal: when an admin grants or revokes access, fire a
|
|
||||||
`personal-direct` notification (event_kind
|
|
||||||
`permission_change_affecting_me`, already in the §15.1
|
|
||||||
enum) so the affected user sees the state change in
|
|
||||||
their inbox and email. v0.9.0 audits the gesture in
|
|
||||||
`permission_events` but does not yet escape the
|
|
||||||
app-internal log to the user. See §19.2.
|
|
||||||
- **Decline-with-reason on revoke** — the current Revoke
|
|
||||||
gesture takes only a confirmation; a follow-up release
|
|
||||||
may capture a free-text reason in
|
|
||||||
`permission_events.details`. See §19.2.
|
|
||||||
- **Allowlist deprecation** — the `/admin/allowlist` sub-tab
|
|
||||||
stays in place in v0.9.0 (the two surfaces have different
|
|
||||||
keys and a union row would be confusing). Retiring the
|
|
||||||
table outright is deferred to a session that can re-read
|
|
||||||
the post-v0.9.0 operator experience and decide whether
|
|
||||||
the fast-path-bypass role is still pulling weight. See
|
|
||||||
§19.2.
|
|
||||||
|
|
||||||
## 0.8.0 — 2026-05-28
|
## 0.8.0 — 2026-05-28
|
||||||
|
|
||||||
**Minor — schema migration required; admission semantics shift.**
|
**Minor — schema migration required; admission semantics shift.**
|
||||||
|
|||||||
@@ -339,6 +339,16 @@ and exact columns are illustrative; the implementing session can adjust.
|
|||||||
on first write and updated on every change. Absence of a row means
|
on first write and updated on every change. Absence of a row means
|
||||||
"no choice yet" — the banner shows. Anonymous viewers persist their
|
"no choice yet" — the banner shows. Anonymous viewers persist their
|
||||||
choice in `localStorage` only, with no corresponding row here.
|
choice in `localStorage` only, with no corresponding row here.
|
||||||
|
- `device_trust` — per-row record of the §6.2 device-trust gesture
|
||||||
|
(v0.11.0, roadmap item #9). One row per `(user, trusted device)`
|
||||||
|
pair; a user with three trusted devices has three rows. Columns:
|
||||||
|
`id`, `user_id` (FK users, ON DELETE CASCADE), `device_token_hash`
|
||||||
|
(bcrypt at rest, with a unique index documenting the no-collision
|
||||||
|
invariant of the 256-bit CSPRNG token space), `created_at`,
|
||||||
|
`expires_at` (`created_at + 30 days`), `user_agent` (verbatim,
|
||||||
|
application-layer-truncated to 1024 chars), `last_seen_at`
|
||||||
|
(refreshed on every successful lookup), `revoked_at` (NULL means
|
||||||
|
active). The raw token never lives in this table — only the hash.
|
||||||
|
|
||||||
**Super-draft scoping.** For rows in `threads` and `changes` where the
|
**Super-draft scoping.** For rows in `threads` and `changes` where the
|
||||||
entry referenced by `rfc_slug` is in state `super-draft`, `branch_name`
|
entry referenced by `rfc_slug` is in state `super-draft`, `branch_name`
|
||||||
@@ -374,7 +384,24 @@ them:
|
|||||||
separate "forgot passcode" flow. The user can remove the passcode
|
separate "forgot passcode" flow. The user can remove the passcode
|
||||||
at any time from the §6.2 sign-in settings tab, returning to
|
at any time from the §6.2 sign-in settings tab, returning to
|
||||||
OTC-only.
|
OTC-only.
|
||||||
3. **Gitea OAuth fallback (migration only).** The v0.1 OAuth
|
3. **Device trust (cookie-only, 30 days).** Added in v0.11.0
|
||||||
|
(roadmap item #9). After a successful OTC or passcode sign-in,
|
||||||
|
the visitor may check "trust this device for 30 days." The
|
||||||
|
framework then mints a server-issued opaque token, hashes it
|
||||||
|
(bcrypt) into the `device_trust` table, and sets a long-lived
|
||||||
|
HttpOnly + Secure + SameSite=Lax cookie carrying the raw token.
|
||||||
|
On a subsequent visit, `POST /auth/device-trust/start` resolves
|
||||||
|
the cookie and re-establishes the session without an OTC /
|
||||||
|
passcode roundtrip. The user can list and revoke their trusted
|
||||||
|
devices from the `/settings/notifications` "Trusted devices"
|
||||||
|
section; a revoked or expired cookie is cleared on the next
|
||||||
|
request. The cookie is "essential" per §14.5 — it is part of
|
||||||
|
authentication, not analytics, and is set regardless of the
|
||||||
|
user's analytics / other-cookies choice. The raw token only
|
||||||
|
ever lives in the outbound `Set-Cookie` header and the inbound
|
||||||
|
`Cookie` header; server-side storage is the hash, with
|
||||||
|
constant-time comparison on lookup.
|
||||||
|
4. **Gitea OAuth fallback (migration only).** The v0.1 OAuth
|
||||||
callback remains functional during the v0.7.0 window, with a
|
callback remains functional during the v0.7.0 window, with a
|
||||||
small "Sign in with Gitea (fallback)" link on `/login` so users
|
small "Sign in with Gitea (fallback)" link on `/login` so users
|
||||||
with active OAuth sessions or older invite paths still have a
|
with active OAuth sessions or older invite paths still have a
|
||||||
@@ -398,23 +425,9 @@ endpoints accept the user. The capture-fields step (first name,
|
|||||||
last name, free-text "why I should be included in the beta") feeds
|
last name, free-text "why I should be included in the beta") feeds
|
||||||
the admin's triage queue. The `allowed_emails` table stays in the
|
the admin's triage queue. The `allowed_emails` table stays in the
|
||||||
schema as a fast-path bypass — the v0.3.0 admin UI still manages
|
schema as a fast-path bypass — the v0.3.0 admin UI still manages
|
||||||
it — but the OTC request path no longer consults it. v0.9.0
|
it — but the OTC request path no longer consults it. v0.9.0's
|
||||||
(roadmap item #7) shipped the user-management surface that
|
admin user-management page replaces the allowlist UI and ships the
|
||||||
consumes the `permission_state` column: `/admin/users` carries
|
pending-queue triage surface.
|
||||||
every user with their state, profile, and sign-up reason, plus
|
|
||||||
Grant / Revoke controls that flip the column and write a
|
|
||||||
`permission_events` audit row. The capture-form submission also
|
|
||||||
fans a `new_beta_request` notification out to every admin/owner
|
|
||||||
through the §15 substrate, so the queue surfaces in the inbox +
|
|
||||||
email channels admins already have.
|
|
||||||
|
|
||||||
The `/admin/allowlist` sub-tab stays in place alongside
|
|
||||||
`/admin/users` rather than merging: the two surfaces have
|
|
||||||
different keys (allowlist by email pre-sign-up, user list by
|
|
||||||
user_id post-sign-up) and a union row would be confusing rather
|
|
||||||
than clarifying. The allowlist's role narrowed to "fast-path
|
|
||||||
bypass for known-good emails" with the v0.8.0 admission shift;
|
|
||||||
v0.9.0 retains that role unchanged.
|
|
||||||
|
|
||||||
### 6.1 Four roles, each a strict superset of the one below
|
### 6.1 Four roles, each a strict superset of the one below
|
||||||
|
|
||||||
@@ -2300,14 +2313,8 @@ signal taxonomy this section commits to. The starting set:
|
|||||||
`graduation_complete`, `graduation_rolled_back`, `rfc_withdrawn`,
|
`graduation_complete`, `graduation_rolled_back`, `rfc_withdrawn`,
|
||||||
`rfc_reopened`, `claim_opened`, `claim_merged`,
|
`rfc_reopened`, `claim_opened`, `claim_merged`,
|
||||||
`permission_change_affecting_me`, `app_wide_mute_set`,
|
`permission_change_affecting_me`, `app_wide_mute_set`,
|
||||||
`app_wide_mute_lifted`, `new_beta_request`, `digest_emitted`.
|
`app_wide_mute_lifted`, `digest_emitted`. The enum is extensible; the
|
||||||
The enum is extensible; the build session adjusts as new gestures
|
build session adjusts as new gestures are wired in.
|
||||||
are wired in. The `new_beta_request` event (v0.9.0, roadmap item
|
|
||||||
#7) is framework-scoped rather than RFC-scoped — the row's
|
|
||||||
`rfc_slug` is NULL and the deep-link points `/admin/users`
|
|
||||||
instead of `/rfc/<slug>` — but otherwise rides the standard
|
|
||||||
fan-out chokepoint with category `admin-actionable` so the §15.4
|
|
||||||
email gate only reaches owners/admins.
|
|
||||||
|
|
||||||
### 15.2 The inbox
|
### 15.2 The inbox
|
||||||
|
|
||||||
@@ -2823,6 +2830,28 @@ The follow-up session will refine this. A minimal starting set:
|
|||||||
return HTTP 400 with a generic message; the no-passcode-set
|
return HTTP 400 with a generic message; the no-passcode-set
|
||||||
failure also collapses to 400 so the response does not enumerate
|
failure also collapses to 400 so the response does not enumerate
|
||||||
account state. v0.10.0.
|
account state. v0.10.0.
|
||||||
|
- `POST /auth/device-trust/start` — unauthenticated. Reads the
|
||||||
|
`rfc_device_trust` cookie (set previously by an OTC or passcode
|
||||||
|
verify with `trust_device: true`). On a non-expired, non-revoked
|
||||||
|
match, re-establishes the session and returns HTTP 200 with the
|
||||||
|
minimal user payload. On a miss (no cookie, expired, revoked, or
|
||||||
|
unknown), returns HTTP 401 and clears the stale cookie via the
|
||||||
|
response's `Set-Cookie` header. The failure modes collapse to
|
||||||
|
one shape so a probing client cannot enumerate "your row was
|
||||||
|
revoked" vs. "this token never existed". v0.11.0.
|
||||||
|
- `GET /api/auth/me/devices` — authenticated. Returns the active
|
||||||
|
(`revoked_at IS NULL` AND `expires_at > now`) device-trust rows
|
||||||
|
for the signed-in user: `id`, `created_at`, `expires_at`,
|
||||||
|
`last_seen_at`, `user_agent`. The bcrypt hash is structurally
|
||||||
|
private and is never surfaced. v0.11.0.
|
||||||
|
- `DELETE /api/auth/me/devices/{id}` — authenticated. Stamps
|
||||||
|
`revoked_at` on the row with id `{id}` belonging to the
|
||||||
|
signed-in user. The user-id scope is enforced in SQL so a
|
||||||
|
hostile client cannot revoke another user's row by guessing
|
||||||
|
ids; a row that does not match returns HTTP 404. v0.11.0.
|
||||||
|
- `DELETE /api/auth/me/devices` — authenticated. Revokes every
|
||||||
|
active row for the signed-in user; returns the count revoked.
|
||||||
|
v0.11.0.
|
||||||
- `GET /api/rfcs` — list entries with state, id, title, slug, repo,
|
- `GET /api/rfcs` — list entries with state, id, title, slug, repo,
|
||||||
owners, last_active_at, has_open_prs, starred-by-me. Supports
|
owners, last_active_at, has_open_prs, starred-by-me. Supports
|
||||||
search, sort, filter chips, and the `unclaimed` predicate.
|
search, sort, filter chips, and the `unclaimed` predicate.
|
||||||
@@ -2965,15 +2994,8 @@ The follow-up session will refine this. A minimal starting set:
|
|||||||
- `POST /api/rfcs/<slug>/prs/<pr_number>/withdraw` — withdraw per §10.8.
|
- `POST /api/rfcs/<slug>/prs/<pr_number>/withdraw` — withdraw per §10.8.
|
||||||
- `POST /api/rfcs/<slug>/prs/<pr_number>/resolution-branch` — cut a
|
- `POST /api/rfcs/<slug>/prs/<pr_number>/resolution-branch` — cut a
|
||||||
fresh resolution branch and replay per §10.9.
|
fresh resolution branch and replay per §10.9.
|
||||||
- `GET /api/admin/users` — list users for the §6 / Slice 7 admin
|
- `GET /api/admin/users` — list users with role and write-mute state,
|
||||||
surface. v0.9.0 (roadmap item #7) widened the payload to carry
|
for the §6 / Slice 7 admin surface.
|
||||||
`permission_state`, `first_name`, `last_name`, `beta_request_reason`,
|
|
||||||
`created_at`, `permission_decided_at`, and the joined
|
|
||||||
`permission_decided_by_login` / `permission_decided_by_display`
|
|
||||||
for the user-management page. Sort order surfaces `pending` rows
|
|
||||||
first (the daily admin queue), then `granted`, then `revoked`;
|
|
||||||
within a bucket, owners precede admins precede contributors,
|
|
||||||
with recency as the tiebreaker.
|
|
||||||
- `POST /api/admin/users/<id>/role` — set role. Only owners may grant
|
- `POST /api/admin/users/<id>/role` — set role. Only owners may grant
|
||||||
or revoke `owner`; admins may flip contributor ↔ admin freely. An
|
or revoke `owner`; admins may flip contributor ↔ admin freely. An
|
||||||
owner-self-demotion is refused on this endpoint; owner succession
|
owner-self-demotion is refused on this endpoint; owner succession
|
||||||
@@ -2982,16 +3004,6 @@ The follow-up session will refine this. A minimal starting set:
|
|||||||
write-mute (not the §15.8 notification mutes). Refused on owners
|
write-mute (not the §15.8 notification mutes). Refused on owners
|
||||||
and admins — for them, the role-change channel is the right
|
and admins — for them, the role-change channel is the right
|
||||||
refusal. Writes a `permission_events` row.
|
refusal. Writes a `permission_events` row.
|
||||||
- `POST /api/admin/users/<id>/permission` — v0.9.0 (roadmap item #7).
|
|
||||||
Flip `permission_state` between `pending`, `granted`, and `revoked`.
|
|
||||||
Stamps `permission_decided_by` + `permission_decided_at` on the
|
|
||||||
row and writes a `permission_events` row with event_kind in
|
|
||||||
`{permission_granted, permission_revoked, permission_repended}`.
|
|
||||||
Refuses with 422 if the admin tries to flip their own row
|
|
||||||
(symmetric to the `set_mute` / `set_role` self-action refusals).
|
|
||||||
v0.8.0 shipped the column shape with no admin UI — operators ran
|
|
||||||
a manual `UPDATE users` to grant access; v0.9.0 retires the
|
|
||||||
manual gesture.
|
|
||||||
- `GET /api/admin/audit` — paged read of the `actions` log with
|
- `GET /api/admin/audit` — paged read of the `actions` log with
|
||||||
filters `action_kind`, `actor_user_id`, `rfc_slug`, plus `before_id`
|
filters `action_kind`, `actor_user_id`, `rfc_slug`, plus `before_id`
|
||||||
for the page boundary. Returns the joined actor login/display so
|
for the page boundary. Returns the joined actor login/display so
|
||||||
@@ -3831,64 +3843,56 @@ on every other page until an admin grants. v0.8.0 (roadmap item
|
|||||||
Candidates surfaced during v0.8.0 (open beta-access request flow,
|
Candidates surfaced during v0.8.0 (open beta-access request flow,
|
||||||
§6.1 / §14.1, item #6):
|
§6.1 / §14.1, item #6):
|
||||||
|
|
||||||
- **Admin user-management page** (`/admin/users`). *Shipped in
|
- **Admin user-management page** (`/admin/users`). *Surfaced by
|
||||||
v0.9.0 (roadmap item #7).* The listing surfaces every user with
|
v0.8.0 — the release ships the pending-state column but no
|
||||||
permission_state, profile fields, sign-up reason, and a Grant /
|
admin UI to triage it.* For v0.8.0, the admin gesture is an
|
||||||
Revoke control set; the `POST /api/admin/users/<id>/permission`
|
out-of-band `UPDATE users SET permission_state='granted' WHERE
|
||||||
endpoint flips the column and writes a `permission_events` row.
|
email=?`. v0.9.0 (roadmap item #7) is expected to ship the
|
||||||
v0.9.0 left the `/admin/allowlist` sub-tab in place rather than
|
triage queue: a list of `permission_state='pending'` rows
|
||||||
merging (see allowlist deprecation below). The grant/revoke
|
sorted by `created_at`, each showing the captured first /
|
||||||
notify-the-user surface is deferred (see the new candidate
|
last / why fields, with Grant and Revoke buttons that stamp
|
||||||
below).
|
`permission_decided_by` and `permission_decided_at` (schema
|
||||||
- **Allowlist deprecation.** *Decision deferred past v0.9.0.*
|
slots already in place per `migrations/014_beta_access.sql`).
|
||||||
v0.9.0 considered merging `/admin/allowlist` into the new
|
The page composes naturally with the existing `/admin/allowlist`
|
||||||
`/admin/users` page but kept the surface as a sibling sub-tab:
|
surface — both are admission-control gestures — so v0.9.0 may
|
||||||
the two have different keys (allowlist by email pre-sign-up,
|
fold the allowlist UI into this page (see next candidate).
|
||||||
user list by user_id post-sign-up) and a union row would be
|
Decision points: do grants / revokes also fire email
|
||||||
confusing rather than clarifying. The fast-path-bypass role
|
notifications to the user (probably yes — the notifications
|
||||||
the allowlist has carried since v0.8.0 stays intact; the
|
layer from v0.6.0 has the personal-direct channel for it); is
|
||||||
cutover to retire the table outright is a later session.
|
there a "decline with reason" gesture that surfaces in the
|
||||||
Decision points unchanged from v0.8.0: drop the table outright
|
user's view (probably yes — symmetric with §9.3's
|
||||||
(a schema migration) or leave it as a non-functional surface
|
proposal-decline shape); does the page support bulk grants
|
||||||
and remove only the UI (a frontend-only change); how to handle
|
(probably no for v0.9.0 — the queue volume is operator-scale,
|
||||||
existing `allowed_emails` rows at the cutover (probably: walk
|
not user-scale). Earns its session as the v0.9.0 design pass.
|
||||||
them into the pending queue with `permission_state='granted'`
|
- **Allowlist deprecation.** *Surfaced by v0.8.0 — the
|
||||||
for any matching `users` row, leave unmatched rows as a no-op
|
`allowed_emails` table stays in the schema but the OTC
|
||||||
since v0.8.0 doesn't consult them anymore). Earns its session
|
request path no longer consults it.* v0.8.0 left the table
|
||||||
once the v0.9.0 admin queue has run long enough to confirm the
|
and the `/admin/allowlist` UI in place as a fast-path bypass
|
||||||
allowlist's bypass role is no longer pulling weight.
|
for deployments that want to pre-mark known-good emails (the
|
||||||
- **Admin notification on new beta request.** *Shipped in v0.9.0
|
v0.8.0 contract is that those emails still go through the
|
||||||
(roadmap item #7).* The `POST /api/auth/me/beta-request`
|
pending-grant flow; the table itself is no longer a gate). A
|
||||||
handler now calls `notify.fan_out_new_beta_request`, which
|
future release retires both — probably v0.9.0 alongside the
|
||||||
fans a `new_beta_request` event (category `admin-actionable`,
|
admin user-management page, since the two surfaces are
|
||||||
rfc_slug NULL) out to every owner / admin. The §15 chokepoint
|
functionally redundant once the pending queue lands.
|
||||||
handles the SSE broadcast and the §15.4 email dispatch; the
|
Decision points: drop the table outright (a schema migration)
|
||||||
email reaches only recipients whose `email_admin_actionable`
|
or leave it as a non-functional surface and remove only the
|
||||||
toggle is on (the default for owners + admins).
|
UI (a frontend-only change); how to handle existing
|
||||||
- **Grant / revoke notification to the user.** *Surfaced by
|
`allowed_emails` rows at the cutover (probably: walk them
|
||||||
v0.9.0.* The new flip endpoint stamps `permission_decided_by` +
|
into the pending queue with `permission_state='granted'` for
|
||||||
writes a `permission_events` row but does not yet signal the
|
any matching `users` row, leave unmatched rows as a no-op
|
||||||
affected user that their state changed. A future release could
|
since v0.8.0 doesn't consult them anymore). Earns its
|
||||||
fire a `personal-direct` notification (event_kind
|
session as a sub-topic of the v0.9.0 admin user-management
|
||||||
`permission_change_affecting_me`, already in the §15.1 enum) so
|
pass.
|
||||||
a granted user sees "Your beta-access request was approved" in
|
- **Admin notification on new beta request.** *Surfaced by
|
||||||
their inbox and email, and a revoked user sees a parallel
|
v0.8.0 — the capture endpoint writes to the row but does
|
||||||
refusal notice. Decision points: does revocation include a
|
not signal admins.* v0.9.0 candidate (item #7 again): when a
|
||||||
reason field (probably yes — symmetric with §9.3's decline
|
`POST /api/auth/me/beta-request` lands, fire an `admin-actionable`
|
||||||
comment); does grant carry a welcome message (probably no —
|
notification (per §15.4's category set) to every owner / admin
|
||||||
the existing welcome surfaces are sufficient); does the
|
so the queue doesn't go stale. The §15 infrastructure already
|
||||||
notification escape the §15.8 mute path (probably yes — it's
|
supports the category; the open question is whether the
|
||||||
a personal-direct admission state change). Earns its session
|
notification is per-request (one email per submission) or
|
||||||
as a follow-up to the v0.9.0 page.
|
digested (a daily summary). Earns its session alongside the
|
||||||
- **Decline-with-reason on permission revoke.** *Surfaced by
|
admin user-management page.
|
||||||
v0.9.0.* The current Revoke gesture takes only a confirmation;
|
|
||||||
there is no audit-visible reason captured. A future release
|
|
||||||
could add a free-text reason input that lands in the
|
|
||||||
`permission_events.details` JSON column (no schema change
|
|
||||||
needed — the column is already JSON-shaped). This is the
|
|
||||||
symmetric companion to the §9.3 proposal-decline contract.
|
|
||||||
Earns its session alongside the grant/revoke notification
|
|
||||||
candidate above.
|
|
||||||
- **Removing the Gitea OAuth fallback.** *Surfaced by v0.7.0.*
|
- **Removing the Gitea OAuth fallback.** *Surfaced by v0.7.0.*
|
||||||
v0.7.0 keeps `/auth/callback` functional and links to it as a
|
v0.7.0 keeps `/auth/callback` functional and links to it as a
|
||||||
"Sign in with Gitea (fallback)" affordance on the new `/login`
|
"Sign in with Gitea (fallback)" affordance on the new `/login`
|
||||||
@@ -3903,19 +3907,51 @@ Candidates surfaced during v0.8.0 (open beta-access request flow,
|
|||||||
message), and whether the `/auth/login` and `/auth/callback`
|
message), and whether the `/auth/login` and `/auth/callback`
|
||||||
routes get a tombstone redirect to `/login` or just 404. Earns
|
routes get a tombstone redirect to `/login` or just 404. Earns
|
||||||
its session once the OTC adoption curve flattens.
|
its session once the OTC adoption curve flattens.
|
||||||
- **Device trust (30-day skip).** *Surfaced by v0.7.0 — the
|
- **Device trust (30-day skip).** *Settled in v0.11.0 (roadmap
|
||||||
signed-in cookie already lasts 30 days via SessionMiddleware,
|
item #9). The shape: a distinct `rfc_device_trust` cookie
|
||||||
but every sign-in still requires a fresh OTC or passcode.* The
|
(HttpOnly + Secure + SameSite=Lax + 30-day Max-Age) carrying a
|
||||||
roadmap item-#9 candidate adds a "trust this device" affordance
|
server-issued opaque token, keyed against a `device_trust` table
|
||||||
on the verify step that issues a longer-lived rotating token,
|
whose rows store the bcrypt hash. `POST /auth/device-trust/start`
|
||||||
so returning visitors on the same device skip both the OTC and
|
resolves a presented cookie at next visit. A
|
||||||
the passcode step. The shape question is whether the trust is a
|
`/settings/notifications` "Trusted devices" section lists active
|
||||||
signed cookie distinct from the session, a row in a `device_trust`
|
rows with per-row + bulk revoke. The trust outlives a sign-out
|
||||||
table keyed by a random device-id, or a property of the session
|
(sign-out clears the session cookie, not the device-trust
|
||||||
itself; and whether the trust survives password-equivalent events
|
cookie) and is not affected by passcode set/change/clear — the
|
||||||
— v0.10.0's passcode-change and passcode-clear gestures are the
|
next two items below carry the remaining open questions.*
|
||||||
v1 instances — or only survives explicit logout. Earns its
|
- **Cross-device session revocation surface.** v0.11.0's
|
||||||
session as the v0.11.0 design pass.
|
`/settings/notifications → Trusted devices` revokes the
|
||||||
|
long-lived device-trust grants. What it does NOT revoke is an
|
||||||
|
active session cookie sitting in another browser, or the
|
||||||
|
v0.10.0 passcode-failure-counter shape, or a stale
|
||||||
|
password-equivalent that some future release ships. The natural
|
||||||
|
next step is a single "active sessions and devices" surface
|
||||||
|
that lists everything currently authenticating as this user —
|
||||||
|
device-trust rows + active session cookies (if/when the
|
||||||
|
framework moves to server-side sessions) + future credential
|
||||||
|
shapes — and lets the user kill any of them with one gesture.
|
||||||
|
Earns its session when a second cross-cutting concern lands
|
||||||
|
(the most likely first trigger: future Yubikey / WebAuthn
|
||||||
|
support, which surfaces another credential to revoke).
|
||||||
|
- **Password-equivalent change invalidates device trust.** v0.11.0
|
||||||
|
intentionally leaves device-trust rows live across a passcode
|
||||||
|
set / change / clear. The argument is structural: the user has
|
||||||
|
the v0.10.0 lockout, the v0.11.0 per-device revoke list, and a
|
||||||
|
fresh sign-in path via OTC, so the cookie is not a high-value
|
||||||
|
bypass relative to the keys-to-the-account a passcode change
|
||||||
|
signals. The argument against is the conventional "changing a
|
||||||
|
password should kill every active session" expectation users
|
||||||
|
bring from other systems. This earns its own session once the
|
||||||
|
evidence is in: either a security-review finding that says
|
||||||
|
"this is the wrong default," or user feedback that says "I
|
||||||
|
expected my old laptop to sign out when I changed my passcode."
|
||||||
|
- **Device-trust window tunables via env.** v0.11.0 hard-codes
|
||||||
|
the 30-day window in `backend/app/device_trust.py`
|
||||||
|
(`TRUST_DURATION_DAYS = 30`). Surfacing it as an env var
|
||||||
|
(`DEVICE_TRUST_DURATION_DAYS`?) is small and obvious; deferring
|
||||||
|
follows the same pattern as the v0.10.0 passcode-lockout
|
||||||
|
hard-coding — name the tunable when a deployment wants it
|
||||||
|
different rather than shipping a knob that has no operator
|
||||||
|
asking for it.
|
||||||
- **Cloudflare Turnstile (or equivalent) on `/auth/otc/request`.**
|
- **Cloudflare Turnstile (or equivalent) on `/auth/otc/request`.**
|
||||||
*Surfaced by v0.7.0 — the endpoint is now the new abuse hot
|
*Surfaced by v0.7.0 — the endpoint is now the new abuse hot
|
||||||
path.* Per-email cooldown stops the trivial loop; what it
|
path.* Per-email cooldown stops the trivial loop; what it
|
||||||
|
|||||||
+63
-13
@@ -26,12 +26,12 @@ from . import (
|
|||||||
api_prs,
|
api_prs,
|
||||||
auth,
|
auth,
|
||||||
db,
|
db,
|
||||||
|
device_trust as device_trust_mod,
|
||||||
docs as docs_mod,
|
docs as docs_mod,
|
||||||
entry as entry_mod,
|
entry as entry_mod,
|
||||||
cache,
|
cache,
|
||||||
funder,
|
funder,
|
||||||
health,
|
health,
|
||||||
notify,
|
|
||||||
philosophy,
|
philosophy,
|
||||||
providers as providers_mod,
|
providers as providers_mod,
|
||||||
)
|
)
|
||||||
@@ -238,20 +238,70 @@ def make_router(
|
|||||||
user.user_id,
|
user.user_id,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
# v0.9.0 (roadmap item #7): notify every admin/owner of the
|
|
||||||
# fresh request. Only the first submission is the
|
|
||||||
# "newly-pending" gesture — re-submits from the same user
|
|
||||||
# would otherwise carpet the admin inbox. We fire only when
|
|
||||||
# this is the row's first time getting all three fields
|
|
||||||
# populated (the prior row carried at least one NULL).
|
|
||||||
prior = row # captured before the UPDATE above
|
|
||||||
was_already_complete = bool(
|
|
||||||
prior["first_name"] and prior["last_name"] and prior["beta_request_reason"]
|
|
||||||
)
|
|
||||||
if not was_already_complete:
|
|
||||||
notify.fan_out_new_beta_request(requester_user_id=user.user_id)
|
|
||||||
return {"ok": True}
|
return {"ok": True}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------
|
||||||
|
# v0.11.0: trust device for 30 days (§6.2, roadmap item #9).
|
||||||
|
#
|
||||||
|
# The mint path lives on the OAuth router (issuing the cookie is
|
||||||
|
# coupled to OTC/passcode verify). This module owns the read/revoke
|
||||||
|
# surface the /settings/devices page calls.
|
||||||
|
# ---------------------------------------------------------------
|
||||||
|
|
||||||
|
@router.get("/api/auth/me/devices")
|
||||||
|
async def list_my_devices(request: Request) -> dict[str, Any]:
|
||||||
|
"""Active device-trust rows for the signed-in user.
|
||||||
|
|
||||||
|
Active = not revoked, not expired. The current request's
|
||||||
|
device (if any) is *not* singled out here — the surface
|
||||||
|
shows the same row shape for every device so the user can
|
||||||
|
revoke any of them without the page leaking which row
|
||||||
|
carries the cookie they're using right now.
|
||||||
|
"""
|
||||||
|
user = auth.require_user(request)
|
||||||
|
rows = device_trust_mod.list_for_user(user.user_id)
|
||||||
|
return {
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"id": r.id,
|
||||||
|
"created_at": r.created_at,
|
||||||
|
"expires_at": r.expires_at,
|
||||||
|
"last_seen_at": r.last_seen_at,
|
||||||
|
"user_agent": r.user_agent,
|
||||||
|
}
|
||||||
|
for r in rows
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
@router.delete("/api/auth/me/devices/{device_id}")
|
||||||
|
async def revoke_my_device(device_id: int, request: Request) -> dict[str, Any]:
|
||||||
|
"""Revoke a single device-trust row for the signed-in user.
|
||||||
|
|
||||||
|
The user-id scope is enforced in SQL so a hostile client
|
||||||
|
cannot revoke another user's row by guessing ids. A row that
|
||||||
|
doesn't exist, doesn't belong to this user, or is already
|
||||||
|
revoked reads as 404 — the wrong-vs-already-revoked
|
||||||
|
distinction would only help a probing client enumerate ids.
|
||||||
|
"""
|
||||||
|
user = auth.require_user(request)
|
||||||
|
ok = device_trust_mod.revoke(user.user_id, device_id)
|
||||||
|
if not ok:
|
||||||
|
raise HTTPException(404, "Device not found")
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
@router.delete("/api/auth/me/devices")
|
||||||
|
async def revoke_all_my_devices(request: Request) -> dict[str, Any]:
|
||||||
|
"""Revoke every active device-trust row for the signed-in user.
|
||||||
|
|
||||||
|
The user's current request stays authenticated via its
|
||||||
|
session cookie; the device-trust cookie carried on the
|
||||||
|
current device is also revoked, but `rfc_session` keeps the
|
||||||
|
request flow alive until sign-out / expiry.
|
||||||
|
"""
|
||||||
|
user = auth.require_user(request)
|
||||||
|
count = device_trust_mod.revoke_all(user.user_id)
|
||||||
|
return {"ok": True, "revoked": count}
|
||||||
|
|
||||||
# ---------------------------------------------------------------
|
# ---------------------------------------------------------------
|
||||||
# §7: the catalog
|
# §7: the catalog
|
||||||
# ---------------------------------------------------------------
|
# ---------------------------------------------------------------
|
||||||
|
|||||||
+4
-117
@@ -50,16 +50,6 @@ class MuteBody(BaseModel):
|
|||||||
muted: bool
|
muted: bool
|
||||||
|
|
||||||
|
|
||||||
class PermissionStateBody(BaseModel):
|
|
||||||
# v0.9.0: the admin flip from the user-management page (roadmap
|
|
||||||
# item #7). `pending` is not surfaceable from the admin UI —
|
|
||||||
# only the OTC verify path lands a row in `pending` — but we
|
|
||||||
# accept it in the pattern in case a future restore-to-queue
|
|
||||||
# gesture wants to re-pend a granted user; today the UI only
|
|
||||||
# exposes `granted` and `revoked`.
|
|
||||||
state: str = Field(pattern="^(pending|granted|revoked)$")
|
|
||||||
|
|
||||||
|
|
||||||
class AllowlistAddBody(BaseModel):
|
class AllowlistAddBody(BaseModel):
|
||||||
email: str = Field(min_length=3, max_length=320)
|
email: str = Field(min_length=3, max_length=320)
|
||||||
note: str | None = Field(default=None, max_length=200)
|
note: str | None = Field(default=None, max_length=200)
|
||||||
@@ -78,41 +68,13 @@ def make_router(config: Config) -> APIRouter:
|
|||||||
|
|
||||||
@router.get("/api/admin/users")
|
@router.get("/api/admin/users")
|
||||||
async def list_users(request: Request) -> dict[str, Any]:
|
async def list_users(request: Request) -> dict[str, Any]:
|
||||||
"""v0.9.0: the user-management surface (roadmap item #7).
|
|
||||||
|
|
||||||
The listing carries every column the admin queue needs to triage
|
|
||||||
pending beta-access requests alongside the existing role/mute
|
|
||||||
affordances. Sort order surfaces pending requests first (so the
|
|
||||||
admin lands on the inbox shape), then granted, then revoked;
|
|
||||||
within a state, ownership/role and recency are the tiebreakers
|
|
||||||
so the legacy ordering (owner first, then admin, then by name)
|
|
||||||
is preserved inside the granted bucket.
|
|
||||||
|
|
||||||
`permission_decided_by_login` joins the deciding admin row so
|
|
||||||
the UI can render "granted by @ben" without a second round-trip.
|
|
||||||
"""
|
|
||||||
auth.require_admin(request)
|
auth.require_admin(request)
|
||||||
rows = db.conn().execute(
|
rows = db.conn().execute(
|
||||||
"""
|
"""
|
||||||
SELECT u.id, u.gitea_login, u.display_name, u.email, u.role, u.muted,
|
SELECT id, gitea_login, display_name, email, role, muted,
|
||||||
u.created_at, u.last_seen_at,
|
created_at, last_seen_at
|
||||||
u.permission_state, u.first_name, u.last_name,
|
FROM users
|
||||||
u.beta_request_reason,
|
ORDER BY role = 'owner' DESC, role = 'admin' DESC, display_name COLLATE NOCASE
|
||||||
u.permission_decided_by, u.permission_decided_at,
|
|
||||||
d.gitea_login AS decided_by_login,
|
|
||||||
d.display_name AS decided_by_display
|
|
||||||
FROM users u
|
|
||||||
LEFT JOIN users d ON d.id = u.permission_decided_by
|
|
||||||
ORDER BY
|
|
||||||
CASE u.permission_state
|
|
||||||
WHEN 'pending' THEN 0
|
|
||||||
WHEN 'granted' THEN 1
|
|
||||||
WHEN 'revoked' THEN 2
|
|
||||||
ELSE 3
|
|
||||||
END,
|
|
||||||
u.role = 'owner' DESC, u.role = 'admin' DESC,
|
|
||||||
COALESCE(u.last_seen_at, u.created_at) DESC,
|
|
||||||
u.display_name COLLATE NOCASE
|
|
||||||
"""
|
"""
|
||||||
).fetchall()
|
).fetchall()
|
||||||
return {
|
return {
|
||||||
@@ -126,13 +88,6 @@ def make_router(config: Config) -> APIRouter:
|
|||||||
"muted": bool(r["muted"]),
|
"muted": bool(r["muted"]),
|
||||||
"created_at": r["created_at"],
|
"created_at": r["created_at"],
|
||||||
"last_seen_at": r["last_seen_at"],
|
"last_seen_at": r["last_seen_at"],
|
||||||
"permission_state": r["permission_state"] or "granted",
|
|
||||||
"first_name": r["first_name"] or "",
|
|
||||||
"last_name": r["last_name"] or "",
|
|
||||||
"beta_request_reason": r["beta_request_reason"] or "",
|
|
||||||
"permission_decided_at": r["permission_decided_at"],
|
|
||||||
"permission_decided_by_login": r["decided_by_login"],
|
|
||||||
"permission_decided_by_display": r["decided_by_display"],
|
|
||||||
}
|
}
|
||||||
for r in rows
|
for r in rows
|
||||||
]
|
]
|
||||||
@@ -181,74 +136,6 @@ def make_router(config: Config) -> APIRouter:
|
|||||||
)
|
)
|
||||||
return {"ok": True, "role": body.role, "changed": True}
|
return {"ok": True, "role": body.role, "changed": True}
|
||||||
|
|
||||||
# ----- Permission state (§6.1, v0.9.0 roadmap item #7) -----
|
|
||||||
|
|
||||||
@router.post("/api/admin/users/{user_id}/permission")
|
|
||||||
async def set_permission(user_id: int, body: PermissionStateBody, request: Request) -> dict[str, Any]:
|
|
||||||
"""Flip a user's `permission_state` between pending/granted/revoked.
|
|
||||||
|
|
||||||
v0.8.0 wired the column shape but shipped no admin UI for it —
|
|
||||||
the grant gesture was a manual `UPDATE users` against the DB.
|
|
||||||
v0.9.0 (roadmap item #7) lands the admin user-management page;
|
|
||||||
this endpoint is its single write surface.
|
|
||||||
|
|
||||||
Audit shape: every flip writes a `permission_events` row with
|
|
||||||
event_kind in {'permission_granted', 'permission_revoked',
|
|
||||||
'permission_repended'} so §6.5's log carries the change. The
|
|
||||||
`permission_decided_by` / `permission_decided_at` columns on
|
|
||||||
the user row are co-stamped so the user listing can render
|
|
||||||
"granted by @ben at <date>" without a second join through
|
|
||||||
the audit table.
|
|
||||||
|
|
||||||
Refuses with 422 if the admin tries to flip their own row
|
|
||||||
(no self-grant / self-revoke; symmetric to set_mute's
|
|
||||||
self-mute refusal and set_role's self-downgrade refusal).
|
|
||||||
"""
|
|
||||||
viewer = auth.require_admin(request)
|
|
||||||
target = db.conn().execute(
|
|
||||||
"SELECT id, role, permission_state FROM users WHERE id = ?",
|
|
||||||
(user_id,),
|
|
||||||
).fetchone()
|
|
||||||
if target is None:
|
|
||||||
raise HTTPException(404, "User not found")
|
|
||||||
if target["id"] == viewer.user_id:
|
|
||||||
raise HTTPException(422, "You cannot change your own permission state")
|
|
||||||
|
|
||||||
before = target["permission_state"] or "granted"
|
|
||||||
after = body.state
|
|
||||||
if before == after:
|
|
||||||
return {"ok": True, "permission_state": after, "changed": False}
|
|
||||||
|
|
||||||
db.conn().execute(
|
|
||||||
"""
|
|
||||||
UPDATE users
|
|
||||||
SET permission_state = ?,
|
|
||||||
permission_decided_by = ?,
|
|
||||||
permission_decided_at = datetime('now')
|
|
||||||
WHERE id = ?
|
|
||||||
""",
|
|
||||||
(after, viewer.user_id, user_id),
|
|
||||||
)
|
|
||||||
event_kind = {
|
|
||||||
"granted": "permission_granted",
|
|
||||||
"revoked": "permission_revoked",
|
|
||||||
"pending": "permission_repended",
|
|
||||||
}[after]
|
|
||||||
db.conn().execute(
|
|
||||||
"""
|
|
||||||
INSERT INTO permission_events
|
|
||||||
(actor_user_id, subject_user_id, event_kind, details)
|
|
||||||
VALUES (?, ?, ?, ?)
|
|
||||||
""",
|
|
||||||
(
|
|
||||||
viewer.user_id,
|
|
||||||
user_id,
|
|
||||||
event_kind,
|
|
||||||
json.dumps({"before": before, "after": after}),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
return {"ok": True, "permission_state": after, "changed": True}
|
|
||||||
|
|
||||||
# ----- Write-mute (§6.2) -----
|
# ----- Write-mute (§6.2) -----
|
||||||
|
|
||||||
@router.post("/api/admin/users/{user_id}/mute")
|
@router.post("/api/admin/users/{user_id}/mute")
|
||||||
|
|||||||
@@ -0,0 +1,351 @@
|
|||||||
|
"""§6.2 / v0.11.0: trust device for 30 days (roadmap item #9).
|
||||||
|
|
||||||
|
After a successful OTC or passcode sign-in, a contributor may check
|
||||||
|
"trust this device for 30 days." The framework then issues a
|
||||||
|
server-issued opaque token, hashes it (bcrypt) for storage in the
|
||||||
|
`device_trust` table, and sets a long-lived cookie carrying the raw
|
||||||
|
token. On a subsequent visit, the cookie is presented at
|
||||||
|
`/auth/device-trust/start`; if a non-expired, non-revoked row matches,
|
||||||
|
the session is re-established without another OTC / passcode round
|
||||||
|
trip.
|
||||||
|
|
||||||
|
The shape:
|
||||||
|
|
||||||
|
* `issue(user_id, user_agent)` — mint a fresh CSPRNG token, hash it,
|
||||||
|
insert a row, and return the raw token + row id so the endpoint
|
||||||
|
can set the cookie. The 30-day expiry is the only knob; the
|
||||||
|
`revoked_at` column stays NULL.
|
||||||
|
* `lookup(raw_token)` — walk the user's active rows (the unique
|
||||||
|
index keys on the hash, so we read a small candidate set), check
|
||||||
|
the bcrypt hash in constant time, drop any row whose `expires_at`
|
||||||
|
has passed or whose `revoked_at` is non-NULL, and return the
|
||||||
|
matched row or None. On a hit, refresh `last_seen_at`.
|
||||||
|
* `list_for_user(user_id)` — return the active rows for the
|
||||||
|
/settings/devices surface. Revoked + expired rows are filtered out
|
||||||
|
so the surface only shows live trust grants.
|
||||||
|
* `revoke(user_id, row_id)` — stamp `revoked_at` on the row. The
|
||||||
|
next lookup refuses the cookie token (the row is dead).
|
||||||
|
* `revoke_all(user_id)` — bulk-revoke every active row for the user.
|
||||||
|
The /settings/devices surface's "revoke all" button calls this.
|
||||||
|
|
||||||
|
Cookie shape: `rfc_device_trust`. HttpOnly, Secure, SameSite=Lax,
|
||||||
|
Max-Age=2592000 (30 days), Path=/. The cookie value is the raw token;
|
||||||
|
server-side storage is the hash. The cookie is "essential" per the
|
||||||
|
v0.13.0 cookie-consent banner (it is part of authentication, not
|
||||||
|
analytics), so the framework sets it regardless of analytics /
|
||||||
|
other-cookies choices.
|
||||||
|
|
||||||
|
Constant-time comparison: bcrypt's `checkpw` is already constant-time
|
||||||
|
over the hash bytes. We walk the candidate set linearly with `_check`
|
||||||
|
which delegates to `bcrypt.checkpw`; no early-exit shortcut leaks
|
||||||
|
which row was the match.
|
||||||
|
|
||||||
|
The raw token never appears in a log line or an exception message;
|
||||||
|
the helpers carry the token only as a parameter and forget it after
|
||||||
|
hashing.
|
||||||
|
|
||||||
|
The cookie sits orthogonal to the §6.1 `permission_state` gate: a
|
||||||
|
revoked or pending user with a valid device-trust cookie still
|
||||||
|
re-establishes their session (the cookie identifies the user, not
|
||||||
|
their admission state), and the existing `require_contributor` /
|
||||||
|
`require_admin` dependencies in `auth.py` continue to refuse the
|
||||||
|
unrelated write surfaces.
|
||||||
|
"""
|
||||||
|
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 — hard-coded in v0.11.0 (§19.2 candidate to env-ify later).
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
TRUST_DURATION_DAYS = 30
|
||||||
|
COOKIE_NAME = "rfc_device_trust"
|
||||||
|
COOKIE_MAX_AGE_SECONDS = TRUST_DURATION_DAYS * 24 * 60 * 60
|
||||||
|
# 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 cookie.
|
||||||
|
TOKEN_BYTES = 32
|
||||||
|
# User-Agent header values seen in the wild can be unbounded; clamp
|
||||||
|
# to a reasonable ceiling so a hostile UA doesn't bloat the row.
|
||||||
|
USER_AGENT_MAX_LENGTH = 1024
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Issue
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class IssueOutcome:
|
||||||
|
"""The shape returned from `issue`.
|
||||||
|
|
||||||
|
`raw_token` is the cookie value to send to the client; it never
|
||||||
|
appears in storage. `row_id` is the surrogate key for the
|
||||||
|
/settings/devices UI to address the row by id.
|
||||||
|
"""
|
||||||
|
raw_token: str
|
||||||
|
row_id: int
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
def _trim_user_agent(ua: str) -> str:
|
||||||
|
ua = (ua or "").strip()
|
||||||
|
if len(ua) > USER_AGENT_MAX_LENGTH:
|
||||||
|
return ua[:USER_AGENT_MAX_LENGTH]
|
||||||
|
return ua
|
||||||
|
|
||||||
|
|
||||||
|
def issue(user_id: int, user_agent: str) -> IssueOutcome:
|
||||||
|
"""Mint a fresh device-trust token + row for `user_id`.
|
||||||
|
|
||||||
|
The row's expiry is set 30 days in the future. The hash, not the
|
||||||
|
raw token, lands in the database. The caller (the endpoint) sets
|
||||||
|
the cookie with the raw token returned here.
|
||||||
|
"""
|
||||||
|
raw = _new_token()
|
||||||
|
h = _hash(raw)
|
||||||
|
ua = _trim_user_agent(user_agent)
|
||||||
|
cur = db.conn().execute(
|
||||||
|
f"""
|
||||||
|
INSERT INTO device_trust (user_id, device_token_hash, expires_at, user_agent)
|
||||||
|
VALUES (?, ?, datetime('now', '+{TRUST_DURATION_DAYS} days'), ?)
|
||||||
|
""",
|
||||||
|
(user_id, h, ua),
|
||||||
|
)
|
||||||
|
row_id = cur.lastrowid
|
||||||
|
return IssueOutcome(raw_token=raw, row_id=row_id)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Lookup
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class LookupOutcome:
|
||||||
|
"""The result of `lookup`.
|
||||||
|
|
||||||
|
`user` is populated only on a hit. `reason` distinguishes the
|
||||||
|
failure modes so the endpoint can decide whether to clear the
|
||||||
|
cookie ('expired', 'revoked', 'unknown') or just refuse ('invalid').
|
||||||
|
"""
|
||||||
|
ok: bool
|
||||||
|
user: SessionUser | None
|
||||||
|
reason: str # 'ok' | 'invalid' | 'unknown' | 'expired' | 'revoked'
|
||||||
|
row_id: int | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def lookup(raw_token: str) -> LookupOutcome:
|
||||||
|
"""Resolve a presented cookie token to a user.
|
||||||
|
|
||||||
|
A hit refreshes `last_seen_at` on the matched row. A miss returns
|
||||||
|
a reason so the endpoint can clear the stale cookie if the row
|
||||||
|
was revoked or expired (vs. simply unknown, which probably means
|
||||||
|
the cookie was forged or the row was wiped by a /settings/devices
|
||||||
|
revoke from another browser).
|
||||||
|
"""
|
||||||
|
raw = (raw_token or "").strip()
|
||||||
|
if not raw:
|
||||||
|
return LookupOutcome(ok=False, user=None, reason="invalid")
|
||||||
|
|
||||||
|
# The unique index on `device_token_hash` would let us SELECT by
|
||||||
|
# hash if bcrypt were a stable hash, but bcrypt incorporates a
|
||||||
|
# per-row salt — equal tokens produce different hashes. We walk
|
||||||
|
# the candidate set instead. In practice the set is small (a
|
||||||
|
# human has a handful of trusted devices) and bcrypt is cheap on
|
||||||
|
# the order of milliseconds; the walk is bounded by the user's
|
||||||
|
# active device count.
|
||||||
|
#
|
||||||
|
# We don't pre-filter by `revoked_at IS NULL` here so that a
|
||||||
|
# token presented for a recently-revoked row produces a
|
||||||
|
# 'revoked' outcome (the endpoint surfaces a different shape).
|
||||||
|
# Same for expired: we let the walk hit and classify after.
|
||||||
|
rows = db.conn().execute(
|
||||||
|
"""
|
||||||
|
SELECT id, user_id, device_token_hash, expires_at, revoked_at
|
||||||
|
FROM device_trust
|
||||||
|
ORDER BY id DESC
|
||||||
|
""",
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
|
matched = None
|
||||||
|
for row in rows:
|
||||||
|
if _check(raw, row["device_token_hash"]):
|
||||||
|
matched = row
|
||||||
|
break
|
||||||
|
|
||||||
|
if matched is None:
|
||||||
|
return LookupOutcome(ok=False, user=None, reason="unknown")
|
||||||
|
|
||||||
|
if matched["revoked_at"] is not None:
|
||||||
|
return LookupOutcome(ok=False, user=None, reason="revoked", row_id=matched["id"])
|
||||||
|
|
||||||
|
expired = db.conn().execute(
|
||||||
|
"SELECT datetime(?) < datetime('now') AS expired",
|
||||||
|
(matched["expires_at"],),
|
||||||
|
).fetchone()["expired"]
|
||||||
|
if expired:
|
||||||
|
return LookupOutcome(ok=False, user=None, reason="expired", row_id=matched["id"])
|
||||||
|
|
||||||
|
# Refresh last-seen so the /settings/devices surface can show the
|
||||||
|
# user when each device was last active. This is the only write
|
||||||
|
# the lookup path does on the hot read.
|
||||||
|
db.conn().execute(
|
||||||
|
"UPDATE device_trust SET last_seen_at = datetime('now') WHERE id = ?",
|
||||||
|
(matched["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["user_id"],),
|
||||||
|
).fetchone()
|
||||||
|
if user_row is None:
|
||||||
|
# The user row was deleted but the device_trust row hadn't
|
||||||
|
# cascaded yet (shouldn't happen under the FK ON DELETE
|
||||||
|
# CASCADE — be defensive anyway). Treat as 'unknown' so the
|
||||||
|
# endpoint clears the cookie.
|
||||||
|
return LookupOutcome(ok=False, user=None, reason="unknown", row_id=matched["id"])
|
||||||
|
|
||||||
|
# Also stamp last_seen_at on the user row so the user's overall
|
||||||
|
# activity stamp keeps pace with cookie-only sign-ins.
|
||||||
|
db.conn().execute(
|
||||||
|
"UPDATE users SET last_seen_at = datetime('now') WHERE id = ?",
|
||||||
|
(matched["user_id"],),
|
||||||
|
)
|
||||||
|
|
||||||
|
return LookupOutcome(
|
||||||
|
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",
|
||||||
|
row_id=matched["id"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# List / revoke (for the /settings/devices surface)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class DeviceRow:
|
||||||
|
"""The shape the /settings/devices endpoint returns.
|
||||||
|
|
||||||
|
Note the absence of `device_token_hash` — the hash is structurally
|
||||||
|
private, and the surface has no use for it.
|
||||||
|
"""
|
||||||
|
id: int
|
||||||
|
created_at: str
|
||||||
|
expires_at: str
|
||||||
|
last_seen_at: str
|
||||||
|
user_agent: str
|
||||||
|
|
||||||
|
|
||||||
|
def list_for_user(user_id: int) -> list[DeviceRow]:
|
||||||
|
"""Active device-trust rows for the user, freshest first.
|
||||||
|
|
||||||
|
Filters out revoked rows and rows whose expiry has passed; the
|
||||||
|
surface only shows live trust grants. A user wondering "which
|
||||||
|
devices are signed in" gets the answer that matches what the
|
||||||
|
framework would actually accept on a presented cookie.
|
||||||
|
"""
|
||||||
|
rows = db.conn().execute(
|
||||||
|
"""
|
||||||
|
SELECT id, created_at, expires_at, last_seen_at, user_agent
|
||||||
|
FROM device_trust
|
||||||
|
WHERE user_id = ?
|
||||||
|
AND revoked_at IS NULL
|
||||||
|
AND datetime(expires_at) > datetime('now')
|
||||||
|
ORDER BY last_seen_at DESC, id DESC
|
||||||
|
""",
|
||||||
|
(user_id,),
|
||||||
|
).fetchall()
|
||||||
|
return [
|
||||||
|
DeviceRow(
|
||||||
|
id=row["id"],
|
||||||
|
created_at=row["created_at"],
|
||||||
|
expires_at=row["expires_at"],
|
||||||
|
last_seen_at=row["last_seen_at"],
|
||||||
|
user_agent=row["user_agent"] or "",
|
||||||
|
)
|
||||||
|
for row in rows
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def revoke(user_id: int, row_id: int) -> bool:
|
||||||
|
"""Revoke a single device-trust row for the given user.
|
||||||
|
|
||||||
|
Returns True iff a row was matched (still active, belongs to the
|
||||||
|
user). The user-id scope is enforced in SQL so a hostile client
|
||||||
|
cannot revoke another user's row by guessing ids.
|
||||||
|
"""
|
||||||
|
cur = db.conn().execute(
|
||||||
|
"""
|
||||||
|
UPDATE device_trust
|
||||||
|
SET revoked_at = datetime('now')
|
||||||
|
WHERE id = ?
|
||||||
|
AND user_id = ?
|
||||||
|
AND revoked_at IS NULL
|
||||||
|
""",
|
||||||
|
(row_id, user_id),
|
||||||
|
)
|
||||||
|
return cur.rowcount > 0
|
||||||
|
|
||||||
|
|
||||||
|
def revoke_all(user_id: int) -> int:
|
||||||
|
"""Revoke every active device-trust row for the user. Returns the
|
||||||
|
count of rows touched.
|
||||||
|
|
||||||
|
The /settings/devices "revoke all" button calls this. The user's
|
||||||
|
current request stays authenticated via its session cookie; the
|
||||||
|
device-trust cookie on the current device is also revoked, but
|
||||||
|
the session middleware's `rfc_session` cookie keeps the request
|
||||||
|
flow alive until the user signs out or the session cookie
|
||||||
|
expires.
|
||||||
|
"""
|
||||||
|
cur = db.conn().execute(
|
||||||
|
"""
|
||||||
|
UPDATE device_trust
|
||||||
|
SET revoked_at = datetime('now')
|
||||||
|
WHERE user_id = ?
|
||||||
|
AND revoked_at IS NULL
|
||||||
|
""",
|
||||||
|
(user_id,),
|
||||||
|
)
|
||||||
|
return cur.rowcount
|
||||||
@@ -139,10 +139,6 @@ _EVENT_TO_CATEGORY: dict[str, str] = {
|
|||||||
"graduation_complete": "personal-direct",
|
"graduation_complete": "personal-direct",
|
||||||
"super_draft_graduation_ready": "admin-actionable",
|
"super_draft_graduation_ready": "admin-actionable",
|
||||||
"claim_opened": "structural",
|
"claim_opened": "structural",
|
||||||
# v0.9.0: roadmap item #7. A fresh beta-access request lands as
|
|
||||||
# an admin-actionable signal so it consults `email_admin_actionable`
|
|
||||||
# and reaches owners/admins only.
|
|
||||||
"new_beta_request": "admin-actionable",
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -289,13 +285,6 @@ def _deep_link(payload: dict, cfg: EmailConfig) -> str:
|
|||||||
slug = payload.get("rfc_slug")
|
slug = payload.get("rfc_slug")
|
||||||
pr = payload.get("pr_number")
|
pr = payload.get("pr_number")
|
||||||
branch = payload.get("branch_name")
|
branch = payload.get("branch_name")
|
||||||
event_kind = payload.get("event_kind")
|
|
||||||
# v0.9.0: framework-scoped admin signals link to the admin
|
|
||||||
# surface, not /rfc/... The `new_beta_request` event is the
|
|
||||||
# canonical example; future framework-scoped admin events
|
|
||||||
# may reuse the same branch.
|
|
||||||
if event_kind == "new_beta_request":
|
|
||||||
return f"{cfg.app_url}/admin/users"
|
|
||||||
if slug and pr:
|
if slug and pr:
|
||||||
return f"{cfg.app_url}/rfc/{slug}/pr/{pr}"
|
return f"{cfg.app_url}/rfc/{slug}/pr/{pr}"
|
||||||
if slug and branch:
|
if slug and branch:
|
||||||
|
|||||||
+134
-5
@@ -10,8 +10,8 @@ import logging
|
|||||||
import secrets
|
import secrets
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
|
|
||||||
from fastapi import APIRouter, FastAPI, HTTPException, Request
|
from fastapi import APIRouter, FastAPI, HTTPException, Request, Response
|
||||||
from fastapi.responses import RedirectResponse
|
from fastapi.responses import JSONResponse, RedirectResponse
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
from starlette.middleware.sessions import SessionMiddleware
|
from starlette.middleware.sessions import SessionMiddleware
|
||||||
|
|
||||||
@@ -20,6 +20,7 @@ from . import (
|
|||||||
auth,
|
auth,
|
||||||
cache,
|
cache,
|
||||||
db,
|
db,
|
||||||
|
device_trust as device_trust_mod,
|
||||||
digest,
|
digest,
|
||||||
email_otc,
|
email_otc,
|
||||||
hygiene,
|
hygiene,
|
||||||
@@ -43,6 +44,12 @@ class OtcRequestBody(BaseModel):
|
|||||||
class OtcVerifyBody(BaseModel):
|
class OtcVerifyBody(BaseModel):
|
||||||
email: str = Field(min_length=3, max_length=320)
|
email: str = Field(min_length=3, max_length=320)
|
||||||
code: str = Field(min_length=1, max_length=16)
|
code: str = Field(min_length=1, max_length=16)
|
||||||
|
# v0.11.0 — "trust this device for 30 days" checkbox on the Login.jsx
|
||||||
|
# OTC step. When true and verify succeeds, the server issues a fresh
|
||||||
|
# device-trust row and sets the `rfc_device_trust` cookie on the
|
||||||
|
# response. Defaults to false so existing clients that don't send
|
||||||
|
# the flag continue to behave the way they did pre-v0.11.0.
|
||||||
|
trust_device: bool = False
|
||||||
|
|
||||||
|
|
||||||
class PasscodeSetBody(BaseModel):
|
class PasscodeSetBody(BaseModel):
|
||||||
@@ -52,6 +59,8 @@ class PasscodeSetBody(BaseModel):
|
|||||||
class PasscodeVerifyBody(BaseModel):
|
class PasscodeVerifyBody(BaseModel):
|
||||||
email: str = Field(min_length=3, max_length=320)
|
email: str = Field(min_length=3, max_length=320)
|
||||||
passcode: str = Field(min_length=1, max_length=64)
|
passcode: str = Field(min_length=1, max_length=64)
|
||||||
|
# v0.11.0 — same trust-device opt-in as the OTC verify body.
|
||||||
|
trust_device: bool = False
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
@@ -117,6 +126,48 @@ def create_app() -> FastAPI:
|
|||||||
app = create_app()
|
app = create_app()
|
||||||
|
|
||||||
|
|
||||||
|
def _set_device_trust_cookie(response: Response, raw_token: str) -> None:
|
||||||
|
"""Attach the v0.11.0 device-trust cookie to the response.
|
||||||
|
|
||||||
|
HttpOnly + Secure + SameSite=Lax + 30-day Max-Age + Path=/. The
|
||||||
|
cookie value is the raw token; server-side storage is the hash.
|
||||||
|
The cookie is "essential" per the v0.13.0 cookie-consent contract
|
||||||
|
(it is part of authentication), so we set it regardless of the
|
||||||
|
user's analytics / other-cookies choice.
|
||||||
|
|
||||||
|
Secure=True means the cookie is only ever sent over HTTPS. The
|
||||||
|
SessionMiddleware in `create_app` keeps `https_only=False` for
|
||||||
|
dev parity, but the device-trust cookie holds a 30-day credential
|
||||||
|
and must not travel cleartext — production deployments serve over
|
||||||
|
HTTPS, so Secure on the device-trust cookie is non-negotiable.
|
||||||
|
"""
|
||||||
|
response.set_cookie(
|
||||||
|
key=device_trust_mod.COOKIE_NAME,
|
||||||
|
value=raw_token,
|
||||||
|
max_age=device_trust_mod.COOKIE_MAX_AGE_SECONDS,
|
||||||
|
path="/",
|
||||||
|
secure=True,
|
||||||
|
httponly=True,
|
||||||
|
samesite="lax",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _clear_device_trust_cookie(response: Response) -> None:
|
||||||
|
"""Delete the device-trust cookie on the response.
|
||||||
|
|
||||||
|
Used when the framework detects a presented cookie that is
|
||||||
|
expired, revoked, or otherwise stale — the next request from
|
||||||
|
this device will not carry a dead token.
|
||||||
|
"""
|
||||||
|
response.delete_cookie(
|
||||||
|
key=device_trust_mod.COOKIE_NAME,
|
||||||
|
path="/",
|
||||||
|
secure=True,
|
||||||
|
httponly=True,
|
||||||
|
samesite="lax",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _oauth_router(config) -> APIRouter:
|
def _oauth_router(config) -> APIRouter:
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
@@ -177,7 +228,7 @@ def _oauth_router(config) -> APIRouter:
|
|||||||
return {"ok": True}
|
return {"ok": True}
|
||||||
|
|
||||||
@router.post("/auth/otc/verify")
|
@router.post("/auth/otc/verify")
|
||||||
async def otc_verify(body: OtcVerifyBody, request: Request):
|
async def otc_verify(body: OtcVerifyBody, request: Request, response: Response):
|
||||||
result = otc.verify_code(body.email, body.code)
|
result = otc.verify_code(body.email, body.code)
|
||||||
if not result.ok or result.user is None:
|
if not result.ok or result.user is None:
|
||||||
raise HTTPException(400, "Invalid or expired code")
|
raise HTTPException(400, "Invalid or expired code")
|
||||||
@@ -202,6 +253,18 @@ def _oauth_router(config) -> APIRouter:
|
|||||||
and not last_name
|
and not last_name
|
||||||
and not beta_request_reason
|
and not beta_request_reason
|
||||||
)
|
)
|
||||||
|
# v0.11.0 — opt-in device trust. The checkbox lives on the
|
||||||
|
# Login.jsx OTC step; when true, the server mints a fresh
|
||||||
|
# device-trust row and sets the long-lived cookie. The cookie
|
||||||
|
# is "essential" per the v0.13.0 consent contract (it is part
|
||||||
|
# of authentication, not analytics) so it lands regardless of
|
||||||
|
# the user's analytics / other-cookies choice. We capture the
|
||||||
|
# User-Agent at issuance so the /settings/devices surface can
|
||||||
|
# render a rough device label.
|
||||||
|
if body.trust_device:
|
||||||
|
ua = request.headers.get("user-agent", "")
|
||||||
|
outcome = device_trust_mod.issue(result.user.user_id, ua)
|
||||||
|
_set_device_trust_cookie(response, outcome.raw_token)
|
||||||
return {
|
return {
|
||||||
"ok": True,
|
"ok": True,
|
||||||
"user": {
|
"user": {
|
||||||
@@ -254,12 +317,16 @@ def _oauth_router(config) -> APIRouter:
|
|||||||
return {"ok": True}
|
return {"ok": True}
|
||||||
|
|
||||||
@router.post("/auth/passcode/verify")
|
@router.post("/auth/passcode/verify")
|
||||||
async def passcode_verify(body: PasscodeVerifyBody, request: Request):
|
async def passcode_verify(body: PasscodeVerifyBody, request: Request, response: Response):
|
||||||
"""Sign in with email + passcode. Returns the standard session
|
"""Sign in with email + passcode. Returns the standard session
|
||||||
payload on success; HTTP 423 with `locked_until` when the
|
payload on success; HTTP 423 with `locked_until` when the
|
||||||
account is in the lockout window; HTTP 400 for every other
|
account is in the lockout window; HTTP 400 for every other
|
||||||
failure (the wrong-vs-unknown distinction is intentionally
|
failure (the wrong-vs-unknown distinction is intentionally
|
||||||
collapsed so a probing client cannot enumerate emails)."""
|
collapsed so a probing client cannot enumerate emails).
|
||||||
|
|
||||||
|
v0.11.0: the body's `trust_device` flag, if true, mints a
|
||||||
|
fresh device-trust row and sets the long-lived cookie. Same
|
||||||
|
opt-in contract as `/auth/otc/verify`."""
|
||||||
result = passcode_mod.verify_passcode(body.email, body.passcode)
|
result = passcode_mod.verify_passcode(body.email, body.passcode)
|
||||||
if result.reason == "locked":
|
if result.reason == "locked":
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
@@ -272,6 +339,10 @@ def _oauth_router(config) -> APIRouter:
|
|||||||
if not result.ok or result.user is None:
|
if not result.ok or result.user is None:
|
||||||
raise HTTPException(400, "Invalid passcode")
|
raise HTTPException(400, "Invalid passcode")
|
||||||
auth.store_session(request, result.user)
|
auth.store_session(request, result.user)
|
||||||
|
if body.trust_device:
|
||||||
|
ua = request.headers.get("user-agent", "")
|
||||||
|
outcome = device_trust_mod.issue(result.user.user_id, ua)
|
||||||
|
_set_device_trust_cookie(response, outcome.raw_token)
|
||||||
return {
|
return {
|
||||||
"ok": True,
|
"ok": True,
|
||||||
"user": {
|
"user": {
|
||||||
@@ -282,4 +353,62 @@ def _oauth_router(config) -> APIRouter:
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------
|
||||||
|
# v0.11.0: trust device for 30 days (§6.2, roadmap item #9).
|
||||||
|
#
|
||||||
|
# The /auth/device-trust/start endpoint resolves a presented
|
||||||
|
# `rfc_device_trust` cookie. If it matches a non-expired,
|
||||||
|
# non-revoked row, the session is re-established and the client
|
||||||
|
# is told to skip OTC/passcode entry. A stale cookie (expired or
|
||||||
|
# revoked) is cleared on the response. A miss is structurally
|
||||||
|
# silent — the client falls back to the email step.
|
||||||
|
#
|
||||||
|
# The endpoint is anonymous-reachable: a returning visitor with
|
||||||
|
# the cookie hits this before the email step. We do not gate it
|
||||||
|
# on a session because the entire point is to establish one.
|
||||||
|
# ---------------------------------------------------------------
|
||||||
|
|
||||||
|
@router.post("/auth/device-trust/start")
|
||||||
|
async def device_trust_start(request: Request):
|
||||||
|
"""Sign in via a presented device-trust cookie.
|
||||||
|
|
||||||
|
On a hit, re-establishes the session in the cookie store and
|
||||||
|
returns a user payload shaped like /auth/otc/verify (minus
|
||||||
|
`needs_profile`, which a returning device-trust user is
|
||||||
|
structurally past — they signed in at least once before).
|
||||||
|
On a miss, returns 401 + clears the stale cookie. An
|
||||||
|
'unknown' miss (cookie present but no row matches) also
|
||||||
|
clears, since the token is dead to the server either way.
|
||||||
|
|
||||||
|
Note on response construction: we return a `JSONResponse`
|
||||||
|
directly rather than raising `HTTPException` on the miss
|
||||||
|
path because FastAPI's exception handler builds a new
|
||||||
|
response from scratch and would drop any `set_cookie` /
|
||||||
|
`delete_cookie` calls. The hand-built `JSONResponse` lets
|
||||||
|
us attach the cookie-clear header alongside the 401.
|
||||||
|
"""
|
||||||
|
raw = request.cookies.get(device_trust_mod.COOKIE_NAME, "")
|
||||||
|
if not raw:
|
||||||
|
return JSONResponse({"detail": "No device trust"}, status_code=401)
|
||||||
|
outcome = device_trust_mod.lookup(raw)
|
||||||
|
if not outcome.ok or outcome.user is None:
|
||||||
|
# Clear the stale cookie so subsequent requests don't
|
||||||
|
# keep replaying a dead token. We surface 401 in all
|
||||||
|
# cases so a probing client can't tell "your row was
|
||||||
|
# revoked" from "this token never existed".
|
||||||
|
response = JSONResponse({"detail": "Device trust invalid"}, status_code=401)
|
||||||
|
_clear_device_trust_cookie(response)
|
||||||
|
return response
|
||||||
|
auth.store_session(request, outcome.user)
|
||||||
|
return {
|
||||||
|
"ok": True,
|
||||||
|
"user": {
|
||||||
|
"id": outcome.user.user_id,
|
||||||
|
"display_name": outcome.user.display_name,
|
||||||
|
"email": outcome.user.email,
|
||||||
|
"role": outcome.user.role,
|
||||||
|
"permission_state": outcome.user.permission_state,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
return router
|
return router
|
||||||
|
|||||||
@@ -64,7 +64,6 @@ log = logging.getLogger(__name__)
|
|||||||
CATEGORY_PERSONAL = "personal-direct"
|
CATEGORY_PERSONAL = "personal-direct"
|
||||||
CATEGORY_STRUCTURAL = "structural"
|
CATEGORY_STRUCTURAL = "structural"
|
||||||
CATEGORY_CHURN = "churn"
|
CATEGORY_CHURN = "churn"
|
||||||
CATEGORY_ADMIN_ACTIONABLE = "admin-actionable"
|
|
||||||
|
|
||||||
# Action kinds whose actor's first interaction with a slug triggers
|
# Action kinds whose actor's first interaction with a slug triggers
|
||||||
# auto-watch per §15.6. The substantive-gesture list in the spec is
|
# auto-watch per §15.6. The substantive-gesture list in the spec is
|
||||||
@@ -209,67 +208,6 @@ def fan_out_from_action(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def fan_out_new_beta_request(
|
|
||||||
*,
|
|
||||||
requester_user_id: int,
|
|
||||||
) -> None:
|
|
||||||
"""v0.9.0 (roadmap item #7): announce a fresh beta-access request to
|
|
||||||
every admin/owner.
|
|
||||||
|
|
||||||
Called from `POST /api/auth/me/beta-request` after the row's
|
|
||||||
first/last/why fields are populated. Fan-out shape mirrors the §15
|
|
||||||
chokepoint contract: one row per recipient, written via `_emit_one`
|
|
||||||
so the SSE broadcast + email dispatch run through the same surface
|
|
||||||
every other notification uses. The event has no rfc_slug (it is
|
|
||||||
framework-scoped, not RFC-scoped); the deep-link payload points
|
|
||||||
`/admin/users` instead of `/rfc/<slug>`.
|
|
||||||
|
|
||||||
Actor is the requester per §15.9 (the underlying user, never the
|
|
||||||
bot). Category is `admin-actionable` so the §15.4 email gate
|
|
||||||
consults `email_admin_actionable` (owners/admins-only by
|
|
||||||
construction) and the digest exclusion rules treat it identically
|
|
||||||
to other admin-actionable signals (graduation_ready et al).
|
|
||||||
|
|
||||||
Recipients are owners + admins minus the requester themselves
|
|
||||||
(a self-promotion shouldn't reach the requester's own inbox). The
|
|
||||||
requester is never in the role set in practice — the endpoint
|
|
||||||
refuses 'granted'/'revoked' callers and a fresh OTC user lands
|
|
||||||
`contributor`+`pending` — but we filter regardless so the call
|
|
||||||
is robust to future changes in the auth gate.
|
|
||||||
"""
|
|
||||||
requester = db.conn().execute(
|
|
||||||
"SELECT first_name, last_name, email, display_name FROM users WHERE id = ?",
|
|
||||||
(requester_user_id,),
|
|
||||||
).fetchone()
|
|
||||||
if requester is None:
|
|
||||||
return
|
|
||||||
first = (requester["first_name"] or "").strip()
|
|
||||||
last = (requester["last_name"] or "").strip()
|
|
||||||
email = requester["email"] or ""
|
|
||||||
display = requester["display_name"] or email or "a new user"
|
|
||||||
full_name = (f"{first} {last}").strip() or display
|
|
||||||
details = {
|
|
||||||
"requester_user_id": requester_user_id,
|
|
||||||
"requester_first_name": first,
|
|
||||||
"requester_last_name": last,
|
|
||||||
"requester_email": email,
|
|
||||||
"requester_display": full_name,
|
|
||||||
}
|
|
||||||
for recipient_id in _admin_user_ids():
|
|
||||||
if recipient_id == requester_user_id:
|
|
||||||
continue
|
|
||||||
_emit_one(
|
|
||||||
recipient_user_id=recipient_id,
|
|
||||||
event_kind="new_beta_request",
|
|
||||||
category=CATEGORY_ADMIN_ACTIONABLE,
|
|
||||||
actor_user_id=requester_user_id,
|
|
||||||
rfc_slug=None,
|
|
||||||
branch_name=None,
|
|
||||||
pr_number=None,
|
|
||||||
details=details,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def fan_out_chat_message(
|
def fan_out_chat_message(
|
||||||
*,
|
*,
|
||||||
actor_user_id: int,
|
actor_user_id: int,
|
||||||
@@ -769,16 +707,6 @@ def render_summary(event_kind: str, actor_display: str | None, rfc_title: str |
|
|||||||
return f"{actor} began graduating {title}."
|
return f"{actor} began graduating {title}."
|
||||||
if event_kind == "pr_conflict_with_main":
|
if event_kind == "pr_conflict_with_main":
|
||||||
return f"{actor} started a resolution branch on {title}."
|
return f"{actor} started a resolution branch on {title}."
|
||||||
if event_kind == "new_beta_request":
|
|
||||||
# v0.9.0: framework-scoped, not RFC-scoped. The actor (the
|
|
||||||
# requester) and the captured full name + email read as
|
|
||||||
# one self-contained sentence; the inbox row and the email
|
|
||||||
# body share this text per §15.4.
|
|
||||||
full_name = extras.get("requester_display") or actor
|
|
||||||
email_addr = extras.get("requester_email") or ""
|
|
||||||
if email_addr:
|
|
||||||
return f"New beta-access request from {full_name} ({email_addr})."
|
|
||||||
return f"New beta-access request from {full_name}."
|
|
||||||
return f"{event_kind} on {title}"
|
return f"{event_kind} on {title}"
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
-- §6.2 / v0.11.0: trust device for 30 days (roadmap item #9).
|
||||||
|
--
|
||||||
|
-- After a successful OTC or passcode sign-in, the user can check
|
||||||
|
-- "trust this device for 30 days." The framework then issues a
|
||||||
|
-- server-issued opaque device-trust token, stores its hash on this
|
||||||
|
-- table, and sets a long-lived HttpOnly + Secure + SameSite=Lax
|
||||||
|
-- cookie carrying the raw token. On a subsequent visit, the cookie is
|
||||||
|
-- presented at `/auth/device-trust/start`; if the server can match the
|
||||||
|
-- hash to a non-expired non-revoked row, the user is signed in without
|
||||||
|
-- another OTC / passcode round-trip.
|
||||||
|
--
|
||||||
|
-- v0.11.0 introduces no new env vars. The 30-day window is hard-coded
|
||||||
|
-- in `backend/app/device_trust.py`; raising or lowering it (or making
|
||||||
|
-- it user-selectable) is a §19.2 candidate, alongside the cross-device
|
||||||
|
-- session-revocation surface this table will eventually share with the
|
||||||
|
-- v0.10.0 passcode-lockout shape (see SPEC §19.2 / SESSIONS-AND-DEVICES).
|
||||||
|
--
|
||||||
|
-- Storage shape:
|
||||||
|
--
|
||||||
|
-- * `id` — surrogate key. Lets the revoke-device UI address a single
|
||||||
|
-- row by id without leaking the token shape.
|
||||||
|
-- * `user_id` — FK into users(id) with cascade on delete. A deleted
|
||||||
|
-- user automatically loses every trusted device.
|
||||||
|
-- * `device_token_hash` — bcrypt hash of the random opaque token
|
||||||
|
-- issued at trust-time. The raw token only ever lives in the
|
||||||
|
-- outbound `Set-Cookie` header and the inbound `Cookie` header;
|
||||||
|
-- server-side storage is the hash, so a DB compromise does not
|
||||||
|
-- hand attackers a stash of valid device tokens.
|
||||||
|
-- * `created_at` — when the row was issued.
|
||||||
|
-- * `expires_at` — `created_at + 30 days`. A row past this timestamp
|
||||||
|
-- is dead; the lookup path refuses it without further checks.
|
||||||
|
-- * `user_agent` — the User-Agent header captured at issuance.
|
||||||
|
-- Stored verbatim (truncated to 1024 chars at the application
|
||||||
|
-- layer) so the revoke-device UI can show a rough device label.
|
||||||
|
-- Not used for any auth decision — purely a hint to the user
|
||||||
|
-- reviewing their device list.
|
||||||
|
-- * `last_seen_at` — refreshed every time the row authenticates a
|
||||||
|
-- request. Lets the revoke-device UI surface "last used 3 days
|
||||||
|
-- ago" so the user can tell which row corresponds to which
|
||||||
|
-- device.
|
||||||
|
-- * `revoked_at` — NULL means active; non-NULL stamps when the user
|
||||||
|
-- (or admin) revoked the row. Lookups treat any non-NULL value
|
||||||
|
-- as "this row is dead" without consulting the expiry; the
|
||||||
|
-- revoke gesture is intentionally one-way (a revoked device must
|
||||||
|
-- re-trust to come back online).
|
||||||
|
--
|
||||||
|
-- Indexing: a unique index on `device_token_hash` so collisions are
|
||||||
|
-- detectable at insert time (the token space is 256 bits of CSPRNG
|
||||||
|
-- entropy, so a collision is structurally impossible, but the
|
||||||
|
-- declaration documents the invariant). A separate index on
|
||||||
|
-- `(user_id, revoked_at)` so the revoke-device UI's list query is
|
||||||
|
-- a covering walk.
|
||||||
|
--
|
||||||
|
-- The bcrypt dependency reused here was added in v0.7.0 for OTC and
|
||||||
|
-- extended in v0.10.0 for passcodes; v0.11.0 needs no new dep.
|
||||||
|
--
|
||||||
|
-- The cookie shape: `rfc_device_trust` carries the raw token,
|
||||||
|
-- HttpOnly, Secure, SameSite=Lax, Max-Age=2592000 (30 days). It is
|
||||||
|
-- "essential" per the v0.13.0 cookie-consent banner (it is part of
|
||||||
|
-- authentication, not analytics), so it is set regardless of the
|
||||||
|
-- user's analytics / other-cookies choices.
|
||||||
|
|
||||||
|
CREATE TABLE device_trust (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
device_token_hash TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
expires_at TEXT NOT NULL,
|
||||||
|
user_agent TEXT NOT NULL DEFAULT '',
|
||||||
|
last_seen_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
revoked_at TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX idx_device_trust_token_hash ON device_trust (device_token_hash);
|
||||||
|
CREATE INDEX idx_device_trust_user ON device_trust (user_id, revoked_at);
|
||||||
@@ -1,425 +0,0 @@
|
|||||||
"""End-to-end integration tests for v0.9.0's admin user-management page
|
|
||||||
and new-beta-request notifications (roadmap item #7, §6.1 / §15).
|
|
||||||
|
|
||||||
The release lands two halves of the same surface:
|
|
||||||
|
|
||||||
* **Admin notification on new beta request.** When a pending user
|
|
||||||
submits `POST /api/auth/me/beta-request`, every owner/admin
|
|
||||||
receives a `new_beta_request` notification (the §15 substrate
|
|
||||||
insert lands the row; the §15.4 email path dispatches subject to
|
|
||||||
the recipient's `email_admin_actionable` toggle).
|
|
||||||
|
|
||||||
* **Admin user-management surface** at `/admin/users`. The
|
|
||||||
`GET /api/admin/users` listing carries every user with their
|
|
||||||
permission_state, profile fields, sign-up reason, and decision
|
|
||||||
audit. The new `POST /api/admin/users/<id>/permission` endpoint
|
|
||||||
flips the column and writes a `permission_events` row.
|
|
||||||
|
|
||||||
The tests prove:
|
|
||||||
|
|
||||||
* The first beta-request submission fans a `new_beta_request`
|
|
||||||
row out to every admin/owner (and not to the requester
|
|
||||||
themselves). The row carries the captured profile in
|
|
||||||
`payload.extras`.
|
|
||||||
* Re-submitting the form from the same pending user doesn't
|
|
||||||
re-fan (we only notify on the row's first complete state).
|
|
||||||
* `GET /api/admin/users` carries the v0.9.0 columns
|
|
||||||
(permission_state, first/last/reason, decided_by).
|
|
||||||
* `POST /api/admin/users/<id>/permission` flips the state,
|
|
||||||
stamps decided_by/at, and writes a `permission_events` row.
|
|
||||||
* The endpoint refuses self-flip (422) and refuses non-admin
|
|
||||||
callers (403).
|
|
||||||
* The endpoint accepts only the three valid states (422 on
|
|
||||||
anything else).
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from test_propose_vertical import ( # noqa: F401 — fixtures land via import
|
|
||||||
FakeGitea,
|
|
||||||
app_with_fake_gitea,
|
|
||||||
provision_user_row,
|
|
||||||
sign_in_as,
|
|
||||||
tmp_env,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _reset_outbound():
|
|
||||||
from app import email as email_mod
|
|
||||||
email_mod.reset_sent_envelopes()
|
|
||||||
|
|
||||||
|
|
||||||
def _outbound_otc_codes(to_address: str | None = None) -> list[str]:
|
|
||||||
from app import email as email_mod
|
|
||||||
out = []
|
|
||||||
for env in email_mod.sent_envelopes():
|
|
||||||
if env.get("kind") != "otc":
|
|
||||||
continue
|
|
||||||
if to_address is not None and env["to"] != to_address:
|
|
||||||
continue
|
|
||||||
for line in env["body"].splitlines():
|
|
||||||
tok = line.strip()
|
|
||||||
if tok.isdigit() and len(tok) == 6:
|
|
||||||
out.append(tok)
|
|
||||||
break
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
def _provision_pending_user(client, email: str) -> int:
|
|
||||||
"""Sign in a fresh OTC user (lands `pending`) and return their user_id."""
|
|
||||||
from app import db
|
|
||||||
_reset_outbound()
|
|
||||||
client.post("/auth/otc/request", json={"email": email})
|
|
||||||
code = _outbound_otc_codes(email)[-1]
|
|
||||||
client.post("/auth/otc/verify", json={"email": email, "code": code})
|
|
||||||
row = db.conn().execute(
|
|
||||||
"SELECT id FROM users WHERE email = ? COLLATE NOCASE", (email,)
|
|
||||||
).fetchone()
|
|
||||||
return row["id"]
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Admin notification on beta-request submission
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
def test_beta_request_submission_notifies_every_admin(app_with_fake_gitea):
|
|
||||||
"""First-time submission of a beta-request fans a notification out
|
|
||||||
to every owner and admin. The requester themselves never receives
|
|
||||||
a row (filtered out by user_id even if they happened to be in the
|
|
||||||
admin set, which they aren't in practice — fresh OTC users are
|
|
||||||
`contributor`+`pending`)."""
|
|
||||||
from fastapi.testclient import TestClient
|
|
||||||
from app import db
|
|
||||||
|
|
||||||
app, _fake = app_with_fake_gitea
|
|
||||||
with TestClient(app) as client:
|
|
||||||
# Provision two admins and one owner so the fan-out has multiple
|
|
||||||
# targets. The OWNER_GITEA_LOGIN-derived ownership doesn't fire
|
|
||||||
# here (no OAuth round-trip in this path); we seed the role
|
|
||||||
# directly.
|
|
||||||
provision_user_row(user_id=10, login="ownerzero", role="owner")
|
|
||||||
provision_user_row(user_id=11, login="admin_one", role="admin")
|
|
||||||
provision_user_row(user_id=12, login="admin_two", role="admin")
|
|
||||||
provision_user_row(user_id=13, login="contrib_one", role="contributor")
|
|
||||||
|
|
||||||
# Sign in a fresh OTC user → permission_state='pending'.
|
|
||||||
requester_id = _provision_pending_user(client, "newbie@example.com")
|
|
||||||
|
|
||||||
# Capture-form submit.
|
|
||||||
r = client.post(
|
|
||||||
"/api/auth/me/beta-request",
|
|
||||||
json={
|
|
||||||
"first_name": "Newt",
|
|
||||||
"last_name": "Newcomer",
|
|
||||||
"beta_request_reason": "I want to write the Human RFC.",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
assert r.status_code == 200, r.text
|
|
||||||
|
|
||||||
# Every owner + admin gets a `new_beta_request` notification.
|
|
||||||
# The contributor (id=13) does not. The requester (whoever id
|
|
||||||
# they got) does not.
|
|
||||||
rows = db.conn().execute(
|
|
||||||
"""
|
|
||||||
SELECT recipient_user_id, event_kind, actor_user_id, payload
|
|
||||||
FROM notifications
|
|
||||||
WHERE event_kind = 'new_beta_request'
|
|
||||||
"""
|
|
||||||
).fetchall()
|
|
||||||
recipients = sorted(r["recipient_user_id"] for r in rows)
|
|
||||||
assert recipients == [10, 11, 12], f"unexpected recipients: {recipients}"
|
|
||||||
# Actor is the requester (§15.9: never the bot).
|
|
||||||
for r in rows:
|
|
||||||
assert r["actor_user_id"] == requester_id
|
|
||||||
import json as _json
|
|
||||||
extras = _json.loads(r["payload"])
|
|
||||||
assert extras["requester_first_name"] == "Newt"
|
|
||||||
assert extras["requester_last_name"] == "Newcomer"
|
|
||||||
assert extras["requester_email"] == "newbie@example.com"
|
|
||||||
|
|
||||||
|
|
||||||
def test_beta_request_resubmit_does_not_re_notify(app_with_fake_gitea):
|
|
||||||
"""Once a user has completed the capture form, re-submitting it
|
|
||||||
(the endpoint is idempotent for pending users) must not re-fan a
|
|
||||||
fresh notification to every admin — that would carpet-bomb the
|
|
||||||
inbox on every typo correction."""
|
|
||||||
from fastapi.testclient import TestClient
|
|
||||||
from app import db
|
|
||||||
|
|
||||||
app, _fake = app_with_fake_gitea
|
|
||||||
with TestClient(app) as client:
|
|
||||||
provision_user_row(user_id=20, login="adminzero", role="admin")
|
|
||||||
_provision_pending_user(client, "carpet@example.com")
|
|
||||||
|
|
||||||
body = {
|
|
||||||
"first_name": "Carpet",
|
|
||||||
"last_name": "Bomb",
|
|
||||||
"beta_request_reason": "first draft",
|
|
||||||
}
|
|
||||||
r1 = client.post("/api/auth/me/beta-request", json=body)
|
|
||||||
assert r1.status_code == 200
|
|
||||||
|
|
||||||
# Re-submit with edited reason — endpoint accepts (idempotent
|
|
||||||
# update), but the admin inbox stays at one row.
|
|
||||||
body2 = dict(body, beta_request_reason="cleaner final draft")
|
|
||||||
r2 = client.post("/api/auth/me/beta-request", json=body2)
|
|
||||||
assert r2.status_code == 200
|
|
||||||
|
|
||||||
rows = db.conn().execute(
|
|
||||||
"SELECT COUNT(*) AS n FROM notifications WHERE event_kind = 'new_beta_request'"
|
|
||||||
).fetchone()
|
|
||||||
assert rows["n"] == 1
|
|
||||||
|
|
||||||
|
|
||||||
def test_beta_request_notification_is_admin_actionable_category(app_with_fake_gitea):
|
|
||||||
"""The §15.4 category mapping must route `new_beta_request` to the
|
|
||||||
admin-actionable bucket so the email gate consults
|
|
||||||
`email_admin_actionable` (and skips for non-admin recipients).
|
|
||||||
"""
|
|
||||||
from app import email as email_mod
|
|
||||||
|
|
||||||
assert email_mod.category_for("new_beta_request", "structural") == "admin-actionable"
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# /api/admin/users — listing carries the v0.9.0 columns
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
def test_admin_users_listing_carries_permission_columns(app_with_fake_gitea):
|
|
||||||
"""The Users tab consumes this shape — confirm every required
|
|
||||||
column is on the response."""
|
|
||||||
from fastapi.testclient import TestClient
|
|
||||||
from app import db
|
|
||||||
|
|
||||||
app, _fake = app_with_fake_gitea
|
|
||||||
with TestClient(app) as client:
|
|
||||||
# Seed an admin and a pending user with all the v0.8.0 columns
|
|
||||||
# populated. Direct-DB insert avoids the OTC dance (which would
|
|
||||||
# overwrite the cookie); the test above proves the capture
|
|
||||||
# pathway end-to-end and this one just exercises the listing
|
|
||||||
# surface's shape.
|
|
||||||
provision_user_row(user_id=30, login="ben", role="owner")
|
|
||||||
db.conn().execute(
|
|
||||||
"""
|
|
||||||
INSERT INTO users (id, gitea_id, gitea_login, email,
|
|
||||||
display_name, avatar_url, role,
|
|
||||||
permission_state, first_name, last_name,
|
|
||||||
beta_request_reason)
|
|
||||||
VALUES (31, NULL, NULL, 'pendinguser@example.com',
|
|
||||||
'pendinguser', '', 'contributor',
|
|
||||||
'pending', 'Penn', 'Ding', 'I want in.')
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
|
|
||||||
sign_in_as(
|
|
||||||
client, user_id=30, gitea_login="ben",
|
|
||||||
display_name="Ben", role="owner",
|
|
||||||
)
|
|
||||||
|
|
||||||
r = client.get("/api/admin/users")
|
|
||||||
assert r.status_code == 200
|
|
||||||
items = r.json()["items"]
|
|
||||||
assert isinstance(items, list)
|
|
||||||
pending = next(
|
|
||||||
(i for i in items if i["email"] == "pendinguser@example.com"), None,
|
|
||||||
)
|
|
||||||
assert pending is not None
|
|
||||||
assert pending["permission_state"] == "pending"
|
|
||||||
assert pending["first_name"] == "Penn"
|
|
||||||
assert pending["last_name"] == "Ding"
|
|
||||||
assert pending["beta_request_reason"] == "I want in."
|
|
||||||
assert pending["permission_decided_at"] is None
|
|
||||||
assert pending["permission_decided_by_login"] is None
|
|
||||||
# Pending bucket is listed first (sort order).
|
|
||||||
assert items[0]["permission_state"] == "pending"
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# /api/admin/users/<id>/permission — the flip endpoint
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
def test_permission_flip_grant_promotes_pending_to_granted(app_with_fake_gitea):
|
|
||||||
"""The end-to-end gesture: a fresh OTC user lands pending, an admin
|
|
||||||
flips them to granted via the endpoint, the row reflects the new
|
|
||||||
state + decided_by/at, and a `permission_events` audit row lands."""
|
|
||||||
from fastapi.testclient import TestClient
|
|
||||||
from app import db
|
|
||||||
|
|
||||||
app, _fake = app_with_fake_gitea
|
|
||||||
with TestClient(app) as client:
|
|
||||||
# Pending user.
|
|
||||||
pending_id = _provision_pending_user(client, "flip@example.com")
|
|
||||||
|
|
||||||
# Admin acting on them.
|
|
||||||
provision_user_row(user_id=40, login="adminflipper", role="admin")
|
|
||||||
sign_in_as(
|
|
||||||
client, user_id=40, gitea_login="adminflipper",
|
|
||||||
display_name="Admin Flipper", role="admin",
|
|
||||||
)
|
|
||||||
|
|
||||||
r = client.post(
|
|
||||||
f"/api/admin/users/{pending_id}/permission",
|
|
||||||
json={"state": "granted"},
|
|
||||||
)
|
|
||||||
assert r.status_code == 200, r.text
|
|
||||||
body = r.json()
|
|
||||||
assert body["permission_state"] == "granted"
|
|
||||||
assert body["changed"] is True
|
|
||||||
|
|
||||||
# Row reflects the new state + decision stamp.
|
|
||||||
row = db.conn().execute(
|
|
||||||
"SELECT permission_state, permission_decided_by, permission_decided_at "
|
|
||||||
"FROM users WHERE id = ?",
|
|
||||||
(pending_id,),
|
|
||||||
).fetchone()
|
|
||||||
assert row["permission_state"] == "granted"
|
|
||||||
assert row["permission_decided_by"] == 40
|
|
||||||
assert row["permission_decided_at"] is not None
|
|
||||||
|
|
||||||
# Audit row landed in permission_events.
|
|
||||||
events = db.conn().execute(
|
|
||||||
"""
|
|
||||||
SELECT actor_user_id, subject_user_id, event_kind
|
|
||||||
FROM permission_events
|
|
||||||
WHERE event_kind = 'permission_granted'
|
|
||||||
"""
|
|
||||||
).fetchall()
|
|
||||||
assert len(events) == 1
|
|
||||||
assert events[0]["actor_user_id"] == 40
|
|
||||||
assert events[0]["subject_user_id"] == pending_id
|
|
||||||
|
|
||||||
|
|
||||||
def test_permission_flip_revoke_promotes_granted_to_revoked(app_with_fake_gitea):
|
|
||||||
"""Revoke is the symmetric gesture. Used when an account earned a
|
|
||||||
grant then later lost it (§6.1 / `revoked` state)."""
|
|
||||||
from fastapi.testclient import TestClient
|
|
||||||
from app import db
|
|
||||||
|
|
||||||
app, _fake = app_with_fake_gitea
|
|
||||||
with TestClient(app) as client:
|
|
||||||
provision_user_row(user_id=50, login="goner", role="contributor")
|
|
||||||
# Default permission_state is 'granted' via the column default.
|
|
||||||
provision_user_row(user_id=51, login="adminrevoker", role="admin")
|
|
||||||
sign_in_as(
|
|
||||||
client, user_id=51, gitea_login="adminrevoker",
|
|
||||||
display_name="Admin Revoker", role="admin",
|
|
||||||
)
|
|
||||||
|
|
||||||
r = client.post(
|
|
||||||
"/api/admin/users/50/permission",
|
|
||||||
json={"state": "revoked"},
|
|
||||||
)
|
|
||||||
assert r.status_code == 200, r.text
|
|
||||||
|
|
||||||
row = db.conn().execute(
|
|
||||||
"SELECT permission_state FROM users WHERE id = 50"
|
|
||||||
).fetchone()
|
|
||||||
assert row["permission_state"] == "revoked"
|
|
||||||
|
|
||||||
events = db.conn().execute(
|
|
||||||
"SELECT event_kind FROM permission_events "
|
|
||||||
"WHERE event_kind = 'permission_revoked' AND subject_user_id = 50"
|
|
||||||
).fetchall()
|
|
||||||
assert len(events) == 1
|
|
||||||
|
|
||||||
|
|
||||||
def test_permission_flip_refuses_self(app_with_fake_gitea):
|
|
||||||
"""Symmetric to set_mute / set_role: an admin can't self-flip.
|
|
||||||
The state-change channel for one's own grant is somebody else's
|
|
||||||
hand."""
|
|
||||||
from fastapi.testclient import TestClient
|
|
||||||
|
|
||||||
app, _fake = app_with_fake_gitea
|
|
||||||
with TestClient(app) as client:
|
|
||||||
provision_user_row(user_id=60, login="selfflipper", role="admin")
|
|
||||||
sign_in_as(
|
|
||||||
client, user_id=60, gitea_login="selfflipper",
|
|
||||||
display_name="Self Flipper", role="admin",
|
|
||||||
)
|
|
||||||
r = client.post(
|
|
||||||
"/api/admin/users/60/permission",
|
|
||||||
json={"state": "revoked"},
|
|
||||||
)
|
|
||||||
assert r.status_code == 422
|
|
||||||
|
|
||||||
|
|
||||||
def test_permission_flip_refuses_non_admin(app_with_fake_gitea):
|
|
||||||
"""The endpoint is admin-only (§17 admin/* requires require_admin).
|
|
||||||
A contributor caller is refused 403; an anonymous caller 401."""
|
|
||||||
from fastapi.testclient import TestClient
|
|
||||||
|
|
||||||
app, _fake = app_with_fake_gitea
|
|
||||||
with TestClient(app) as client:
|
|
||||||
provision_user_row(user_id=70, login="target", role="contributor")
|
|
||||||
provision_user_row(user_id=71, login="contrib", role="contributor")
|
|
||||||
sign_in_as(
|
|
||||||
client, user_id=71, gitea_login="contrib",
|
|
||||||
display_name="Contrib", role="contributor",
|
|
||||||
)
|
|
||||||
r = client.post(
|
|
||||||
"/api/admin/users/70/permission",
|
|
||||||
json={"state": "granted"},
|
|
||||||
)
|
|
||||||
assert r.status_code == 403
|
|
||||||
|
|
||||||
client.cookies.clear()
|
|
||||||
r = client.post(
|
|
||||||
"/api/admin/users/70/permission",
|
|
||||||
json={"state": "granted"},
|
|
||||||
)
|
|
||||||
assert r.status_code == 401
|
|
||||||
|
|
||||||
|
|
||||||
def test_permission_flip_refuses_invalid_state(app_with_fake_gitea):
|
|
||||||
"""Pydantic regex pattern refuses anything outside the three
|
|
||||||
canonical states with 422."""
|
|
||||||
from fastapi.testclient import TestClient
|
|
||||||
|
|
||||||
app, _fake = app_with_fake_gitea
|
|
||||||
with TestClient(app) as client:
|
|
||||||
provision_user_row(user_id=80, login="targetx", role="contributor")
|
|
||||||
provision_user_row(user_id=81, login="adminx", role="admin")
|
|
||||||
sign_in_as(
|
|
||||||
client, user_id=81, gitea_login="adminx",
|
|
||||||
display_name="Admin X", role="admin",
|
|
||||||
)
|
|
||||||
r = client.post(
|
|
||||||
"/api/admin/users/80/permission",
|
|
||||||
json={"state": "banished"},
|
|
||||||
)
|
|
||||||
assert r.status_code == 422
|
|
||||||
|
|
||||||
|
|
||||||
def test_permission_flip_no_op_when_state_already_matches(app_with_fake_gitea):
|
|
||||||
"""An admin flipping a granted user to granted gets 200 with
|
|
||||||
`changed: false` — no audit row, no decided_at update."""
|
|
||||||
from fastapi.testclient import TestClient
|
|
||||||
from app import db
|
|
||||||
|
|
||||||
app, _fake = app_with_fake_gitea
|
|
||||||
with TestClient(app) as client:
|
|
||||||
provision_user_row(user_id=90, login="alreadygranted", role="contributor")
|
|
||||||
provision_user_row(user_id=91, login="adminN", role="admin")
|
|
||||||
sign_in_as(
|
|
||||||
client, user_id=91, gitea_login="adminN",
|
|
||||||
display_name="Admin N", role="admin",
|
|
||||||
)
|
|
||||||
|
|
||||||
before_events = db.conn().execute(
|
|
||||||
"SELECT COUNT(*) AS n FROM permission_events"
|
|
||||||
).fetchone()["n"]
|
|
||||||
|
|
||||||
r = client.post(
|
|
||||||
"/api/admin/users/90/permission",
|
|
||||||
json={"state": "granted"},
|
|
||||||
)
|
|
||||||
assert r.status_code == 200
|
|
||||||
body = r.json()
|
|
||||||
assert body["changed"] is False
|
|
||||||
|
|
||||||
after_events = db.conn().execute(
|
|
||||||
"SELECT COUNT(*) AS n FROM permission_events"
|
|
||||||
).fetchone()["n"]
|
|
||||||
assert after_events == before_events
|
|
||||||
@@ -0,0 +1,494 @@
|
|||||||
|
"""End-to-end integration tests for the v0.11.0 trust-device vertical
|
||||||
|
(§6.2, roadmap item #9).
|
||||||
|
|
||||||
|
After a successful OTC or passcode sign-in with `trust_device=true`
|
||||||
|
on the body, the server mints a fresh `device_trust` row and sets the
|
||||||
|
`rfc_device_trust` cookie. On a subsequent visit, the cookie carries
|
||||||
|
a session re-established by `POST /auth/device-trust/start`. The
|
||||||
|
tests below prove:
|
||||||
|
|
||||||
|
* `trust_device=false` (default, including omitted) on OTC verify
|
||||||
|
does NOT set the device-trust cookie and does NOT insert a row.
|
||||||
|
* `trust_device=true` on OTC verify DOES set the cookie (HttpOnly +
|
||||||
|
Secure + SameSite=Lax + 30-day Max-Age) and DOES insert a row.
|
||||||
|
The row's hash is NOT the raw token; only the hash lives in the
|
||||||
|
database.
|
||||||
|
* Same shape for passcode verify.
|
||||||
|
* On a returning visit with the cookie, `POST /auth/device-trust/start`
|
||||||
|
re-establishes the session — `GET /api/auth/me` reads the right
|
||||||
|
user without an OTC roundtrip.
|
||||||
|
* `last_seen_at` refreshes on a successful lookup.
|
||||||
|
* `POST /auth/device-trust/start` with no cookie returns 401.
|
||||||
|
* `POST /auth/device-trust/start` with a forged / unknown cookie
|
||||||
|
returns 401 + clears the cookie.
|
||||||
|
* A revoked row refuses the cookie (401) and clears it.
|
||||||
|
* An expired row refuses the cookie (401) and clears it.
|
||||||
|
* `GET /api/auth/me/devices` lists the user's active rows.
|
||||||
|
* `DELETE /api/auth/me/devices/{id}` revokes a single row.
|
||||||
|
* `DELETE /api/auth/me/devices/{id}` for another user's row reads 404.
|
||||||
|
* `DELETE /api/auth/me/devices` revokes every active row.
|
||||||
|
* Constant-time path: bcrypt.checkpw guards lookup; the raw token
|
||||||
|
is never written to logs or to the DB.
|
||||||
|
|
||||||
|
The fakes from `test_propose_vertical` give us a working app harness.
|
||||||
|
The OTC envelope buffer from `test_otc_vertical` is reused for the
|
||||||
|
OTC roundtrips this suite needs.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from test_propose_vertical import ( # noqa: F401
|
||||||
|
FakeGitea,
|
||||||
|
app_with_fake_gitea,
|
||||||
|
provision_user_row,
|
||||||
|
tmp_env,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Helpers — mirror the OTC suite's outbound-buffer helpers.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
COOKIE_NAME = "rfc_device_trust"
|
||||||
|
|
||||||
|
# The device-trust cookie is set with Secure=True, which httpx (the
|
||||||
|
# TestClient's underlying transport) will only return on an https
|
||||||
|
# scheme. We use a `base_url="https://testserver"` so the cookie
|
||||||
|
# roundtrips faithfully — that mirrors how production deployments
|
||||||
|
# serve the framework (per the v0.11.0 upgrade-step requiring HTTPS).
|
||||||
|
HTTPS_BASE = "https://testserver"
|
||||||
|
|
||||||
|
|
||||||
|
def _reset_outbound():
|
||||||
|
from app import email as email_mod
|
||||||
|
email_mod.reset_sent_envelopes()
|
||||||
|
|
||||||
|
|
||||||
|
def _outbound_otc_codes(to_address: str | None = None) -> list[str]:
|
||||||
|
from app import email as email_mod
|
||||||
|
out = []
|
||||||
|
for env in email_mod.sent_envelopes():
|
||||||
|
if env.get("kind") != "otc":
|
||||||
|
continue
|
||||||
|
if to_address is not None and env["to"] != to_address:
|
||||||
|
continue
|
||||||
|
for line in env["body"].splitlines():
|
||||||
|
tok = line.strip()
|
||||||
|
if tok.isdigit() and len(tok) == 6:
|
||||||
|
out.append(tok)
|
||||||
|
break
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _sign_in_via_otc(client, email: str, *, trust_device: bool = False) -> None:
|
||||||
|
r = client.post("/auth/otc/request", json={"email": email})
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
code = _outbound_otc_codes(email)[-1]
|
||||||
|
body = {"email": email, "code": code, "trust_device": trust_device}
|
||||||
|
r = client.post("/auth/otc/verify", json=body)
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
|
||||||
|
|
||||||
|
def _device_rows_for_email(email: str) -> list[dict]:
|
||||||
|
from app import db
|
||||||
|
rows = db.conn().execute(
|
||||||
|
"""
|
||||||
|
SELECT dt.*
|
||||||
|
FROM device_trust dt
|
||||||
|
JOIN users u ON u.id = dt.user_id
|
||||||
|
WHERE u.email = ? COLLATE NOCASE
|
||||||
|
ORDER BY dt.id
|
||||||
|
""",
|
||||||
|
(email,),
|
||||||
|
).fetchall()
|
||||||
|
return [dict(r) for r in rows]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# trust_device flag controls cookie issuance
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_otc_verify_without_trust_device_does_not_issue_cookie(app_with_fake_gitea):
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
app, _fake = app_with_fake_gitea
|
||||||
|
with TestClient(app, base_url=HTTPS_BASE) as client:
|
||||||
|
_reset_outbound()
|
||||||
|
_sign_in_via_otc(client, "alice@example.com", trust_device=False)
|
||||||
|
# No cookie set on the response.
|
||||||
|
assert COOKIE_NAME not in {c.name for c in client.cookies.jar}
|
||||||
|
# No row inserted.
|
||||||
|
assert _device_rows_for_email("alice@example.com") == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_otc_verify_with_trust_device_issues_cookie_and_row(app_with_fake_gitea):
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
app, _fake = app_with_fake_gitea
|
||||||
|
with TestClient(app, base_url=HTTPS_BASE) as client:
|
||||||
|
_reset_outbound()
|
||||||
|
r = client.post("/auth/otc/request", json={"email": "alice@example.com"})
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
code = _outbound_otc_codes("alice@example.com")[-1]
|
||||||
|
r = client.post(
|
||||||
|
"/auth/otc/verify",
|
||||||
|
json={"email": "alice@example.com", "code": code, "trust_device": True},
|
||||||
|
headers={"User-Agent": "Mozilla/5.0 (TestBrowser)"},
|
||||||
|
)
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
|
||||||
|
# Cookie present on the response.
|
||||||
|
set_cookie = r.headers.get("set-cookie", "")
|
||||||
|
assert COOKIE_NAME in set_cookie
|
||||||
|
# Cookie attribute set asserts the spec'd shape. Starlette emits
|
||||||
|
# the attribute names case-insensitively (`samesite=lax`,
|
||||||
|
# `httponly`); we normalize when asserting.
|
||||||
|
lower = set_cookie.lower()
|
||||||
|
assert "httponly" in lower
|
||||||
|
assert "secure" in lower
|
||||||
|
assert "samesite=lax" in lower
|
||||||
|
assert "max-age=" in lower
|
||||||
|
|
||||||
|
# Row inserted; hash is not the raw token.
|
||||||
|
rows = _device_rows_for_email("alice@example.com")
|
||||||
|
assert len(rows) == 1
|
||||||
|
row = rows[0]
|
||||||
|
assert row["revoked_at"] is None
|
||||||
|
assert row["user_agent"] == "Mozilla/5.0 (TestBrowser)"
|
||||||
|
cookie_token = client.cookies.get(COOKIE_NAME)
|
||||||
|
assert cookie_token
|
||||||
|
assert cookie_token != row["device_token_hash"]
|
||||||
|
# bcrypt hash shape (starts with $2)
|
||||||
|
assert row["device_token_hash"].startswith("$2")
|
||||||
|
|
||||||
|
|
||||||
|
def test_passcode_verify_with_trust_device_issues_cookie(app_with_fake_gitea):
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
app, _fake = app_with_fake_gitea
|
||||||
|
with TestClient(app, base_url=HTTPS_BASE) as client:
|
||||||
|
_reset_outbound()
|
||||||
|
_sign_in_via_otc(client, "alice@example.com")
|
||||||
|
|
||||||
|
# Set a passcode.
|
||||||
|
r = client.post("/auth/passcode/set", json={"passcode": "secret123"})
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
|
||||||
|
# Sign out so the passcode verify path is the active sign-in.
|
||||||
|
client.cookies.clear()
|
||||||
|
|
||||||
|
# Passcode verify with trust_device=true issues a row.
|
||||||
|
r = client.post(
|
||||||
|
"/auth/passcode/verify",
|
||||||
|
json={"email": "alice@example.com", "passcode": "secret123", "trust_device": True},
|
||||||
|
headers={"User-Agent": "Test/Phone"},
|
||||||
|
)
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
set_cookie = r.headers.get("set-cookie", "")
|
||||||
|
assert COOKIE_NAME in set_cookie
|
||||||
|
|
||||||
|
rows = _device_rows_for_email("alice@example.com")
|
||||||
|
assert len(rows) == 1
|
||||||
|
assert rows[0]["user_agent"] == "Test/Phone"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# /auth/device-trust/start
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_device_trust_start_with_no_cookie_returns_401(app_with_fake_gitea):
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
app, _fake = app_with_fake_gitea
|
||||||
|
with TestClient(app, base_url=HTTPS_BASE) as client:
|
||||||
|
r = client.post("/auth/device-trust/start")
|
||||||
|
assert r.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
def test_device_trust_start_with_valid_cookie_establishes_session(app_with_fake_gitea):
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
app, _fake = app_with_fake_gitea
|
||||||
|
with TestClient(app, base_url=HTTPS_BASE) as client:
|
||||||
|
_reset_outbound()
|
||||||
|
# Trust the device.
|
||||||
|
r = client.post("/auth/otc/request", json={"email": "alice@example.com"})
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
code = _outbound_otc_codes("alice@example.com")[-1]
|
||||||
|
r = client.post(
|
||||||
|
"/auth/otc/verify",
|
||||||
|
json={"email": "alice@example.com", "code": code, "trust_device": True},
|
||||||
|
)
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
trust_cookie = client.cookies.get(COOKIE_NAME)
|
||||||
|
assert trust_cookie
|
||||||
|
|
||||||
|
# Clear the session cookie so only the device-trust cookie is in
|
||||||
|
# play. We keep `rfc_device_trust` and drop `rfc_session`.
|
||||||
|
for cookie in list(client.cookies.jar):
|
||||||
|
if cookie.name != COOKIE_NAME:
|
||||||
|
client.cookies.jar.clear(cookie.domain, cookie.path, cookie.name)
|
||||||
|
|
||||||
|
# The session cookie is gone — /api/auth/me reads anonymous.
|
||||||
|
me = client.get("/api/auth/me").json()
|
||||||
|
assert me["authenticated"] is False
|
||||||
|
|
||||||
|
# Hit the trust-start endpoint; the cookie re-establishes the session.
|
||||||
|
r = client.post("/auth/device-trust/start")
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
assert r.json()["user"]["email"] == "alice@example.com"
|
||||||
|
|
||||||
|
# /api/auth/me now reads authenticated.
|
||||||
|
me = client.get("/api/auth/me").json()
|
||||||
|
assert me["authenticated"] is True
|
||||||
|
assert me["user"]["email"] == "alice@example.com"
|
||||||
|
|
||||||
|
|
||||||
|
def test_device_trust_start_refreshes_last_seen_at(app_with_fake_gitea):
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
from app import db
|
||||||
|
|
||||||
|
app, _fake = app_with_fake_gitea
|
||||||
|
with TestClient(app, base_url=HTTPS_BASE) as client:
|
||||||
|
_reset_outbound()
|
||||||
|
r = client.post("/auth/otc/request", json={"email": "alice@example.com"})
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
code = _outbound_otc_codes("alice@example.com")[-1]
|
||||||
|
r = client.post(
|
||||||
|
"/auth/otc/verify",
|
||||||
|
json={"email": "alice@example.com", "code": code, "trust_device": True},
|
||||||
|
)
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
|
||||||
|
# Force the existing row's last_seen_at into the past so we can
|
||||||
|
# assert the refresh moved it forward.
|
||||||
|
db.conn().execute(
|
||||||
|
"""
|
||||||
|
UPDATE device_trust
|
||||||
|
SET last_seen_at = datetime('now', '-7 days')
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
# Hit the start endpoint.
|
||||||
|
r = client.post("/auth/device-trust/start")
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
|
||||||
|
# last_seen_at is now recent (within the last minute).
|
||||||
|
row = db.conn().execute(
|
||||||
|
"SELECT last_seen_at, datetime('now') >= datetime(last_seen_at, '-1 minute') AS fresh FROM device_trust LIMIT 1"
|
||||||
|
).fetchone()
|
||||||
|
assert row["fresh"] == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_device_trust_start_with_revoked_row_refuses_and_clears(app_with_fake_gitea):
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
from app import db
|
||||||
|
|
||||||
|
app, _fake = app_with_fake_gitea
|
||||||
|
with TestClient(app, base_url=HTTPS_BASE) as client:
|
||||||
|
_reset_outbound()
|
||||||
|
r = client.post("/auth/otc/request", json={"email": "alice@example.com"})
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
code = _outbound_otc_codes("alice@example.com")[-1]
|
||||||
|
r = client.post(
|
||||||
|
"/auth/otc/verify",
|
||||||
|
json={"email": "alice@example.com", "code": code, "trust_device": True},
|
||||||
|
)
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
|
||||||
|
# Revoke the row out-of-band.
|
||||||
|
db.conn().execute(
|
||||||
|
"UPDATE device_trust SET revoked_at = datetime('now')"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Now the start endpoint refuses + clears the cookie.
|
||||||
|
r = client.post("/auth/device-trust/start")
|
||||||
|
assert r.status_code == 401
|
||||||
|
# The cookie is cleared via a Set-Cookie header with Max-Age=0
|
||||||
|
# (Starlette's `delete_cookie` shape).
|
||||||
|
set_cookie = r.headers.get("set-cookie", "")
|
||||||
|
assert COOKIE_NAME in set_cookie
|
||||||
|
assert "Max-Age=0" in set_cookie or 'expires=Thu, 01 Jan 1970' in set_cookie.lower().replace("expires=thu", "expires=Thu")
|
||||||
|
|
||||||
|
|
||||||
|
def test_device_trust_start_with_expired_row_refuses_and_clears(app_with_fake_gitea):
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
from app import db
|
||||||
|
|
||||||
|
app, _fake = app_with_fake_gitea
|
||||||
|
with TestClient(app, base_url=HTTPS_BASE) as client:
|
||||||
|
_reset_outbound()
|
||||||
|
r = client.post("/auth/otc/request", json={"email": "alice@example.com"})
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
code = _outbound_otc_codes("alice@example.com")[-1]
|
||||||
|
r = client.post(
|
||||||
|
"/auth/otc/verify",
|
||||||
|
json={"email": "alice@example.com", "code": code, "trust_device": True},
|
||||||
|
)
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
|
||||||
|
# Backdate the expiry into the past.
|
||||||
|
db.conn().execute(
|
||||||
|
"UPDATE device_trust SET expires_at = datetime('now', '-1 day')"
|
||||||
|
)
|
||||||
|
|
||||||
|
r = client.post("/auth/device-trust/start")
|
||||||
|
assert r.status_code == 401
|
||||||
|
set_cookie = r.headers.get("set-cookie", "")
|
||||||
|
assert COOKIE_NAME in set_cookie
|
||||||
|
|
||||||
|
|
||||||
|
def test_device_trust_start_with_forged_cookie_refuses_and_clears(app_with_fake_gitea):
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
app, _fake = app_with_fake_gitea
|
||||||
|
with TestClient(app, base_url=HTTPS_BASE) as client:
|
||||||
|
# No real row; just paste a cookie value.
|
||||||
|
client.cookies.set(COOKIE_NAME, "definitely-not-a-real-token-value-xxx")
|
||||||
|
r = client.post("/auth/device-trust/start")
|
||||||
|
assert r.status_code == 401
|
||||||
|
set_cookie = r.headers.get("set-cookie", "")
|
||||||
|
assert COOKIE_NAME in set_cookie
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# /api/auth/me/devices — list + revoke
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_devices_requires_session(app_with_fake_gitea):
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
app, _fake = app_with_fake_gitea
|
||||||
|
with TestClient(app, base_url=HTTPS_BASE) as client:
|
||||||
|
r = client.get("/api/auth/me/devices")
|
||||||
|
assert r.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_devices_returns_active_rows_only(app_with_fake_gitea):
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
from app import db
|
||||||
|
|
||||||
|
app, _fake = app_with_fake_gitea
|
||||||
|
with TestClient(app, base_url=HTTPS_BASE) as client:
|
||||||
|
_reset_outbound()
|
||||||
|
_sign_in_via_otc(client, "alice@example.com", trust_device=True)
|
||||||
|
|
||||||
|
# Add a second trusted device by re-running the verify flow.
|
||||||
|
# OTC has a per-email cooldown, so drop the cooldown rather
|
||||||
|
# than waiting it out.
|
||||||
|
db.conn().execute("UPDATE otc_codes SET consumed_at = datetime('now', '-1 hour'), created_at = datetime('now', '-1 hour')")
|
||||||
|
r = client.post("/auth/otc/request", json={"email": "alice@example.com"})
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
code = _outbound_otc_codes("alice@example.com")[-1]
|
||||||
|
r = client.post(
|
||||||
|
"/auth/otc/verify",
|
||||||
|
json={"email": "alice@example.com", "code": code, "trust_device": True},
|
||||||
|
headers={"User-Agent": "Test/Tablet"},
|
||||||
|
)
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
|
||||||
|
# Revoke one row directly.
|
||||||
|
db.conn().execute(
|
||||||
|
"UPDATE device_trust SET revoked_at = datetime('now') WHERE id = 1"
|
||||||
|
)
|
||||||
|
|
||||||
|
# /api/auth/me/devices returns only the un-revoked one.
|
||||||
|
r = client.get("/api/auth/me/devices")
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
items = r.json()["items"]
|
||||||
|
assert len(items) == 1
|
||||||
|
assert items[0]["user_agent"] == "Test/Tablet"
|
||||||
|
|
||||||
|
|
||||||
|
def test_revoke_single_device_kills_the_row(app_with_fake_gitea):
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
from app import db
|
||||||
|
|
||||||
|
app, _fake = app_with_fake_gitea
|
||||||
|
with TestClient(app, base_url=HTTPS_BASE) as client:
|
||||||
|
_reset_outbound()
|
||||||
|
_sign_in_via_otc(client, "alice@example.com", trust_device=True)
|
||||||
|
|
||||||
|
r = client.get("/api/auth/me/devices")
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
items = r.json()["items"]
|
||||||
|
assert len(items) == 1
|
||||||
|
device_id = items[0]["id"]
|
||||||
|
|
||||||
|
# Revoke it.
|
||||||
|
r = client.delete(f"/api/auth/me/devices/{device_id}")
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
|
||||||
|
# List is empty.
|
||||||
|
r = client.get("/api/auth/me/devices")
|
||||||
|
assert r.json()["items"] == []
|
||||||
|
|
||||||
|
# The row in the table has revoked_at populated.
|
||||||
|
row = db.conn().execute(
|
||||||
|
"SELECT revoked_at FROM device_trust WHERE id = ?", (device_id,)
|
||||||
|
).fetchone()
|
||||||
|
assert row["revoked_at"] is not None
|
||||||
|
|
||||||
|
|
||||||
|
def test_revoke_other_users_device_reads_404(app_with_fake_gitea):
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
from app import db
|
||||||
|
|
||||||
|
app, _fake = app_with_fake_gitea
|
||||||
|
with TestClient(app, base_url=HTTPS_BASE) as client:
|
||||||
|
_reset_outbound()
|
||||||
|
# Alice trusts a device.
|
||||||
|
_sign_in_via_otc(client, "alice@example.com", trust_device=True)
|
||||||
|
alice_device_id = client.get("/api/auth/me/devices").json()["items"][0]["id"]
|
||||||
|
|
||||||
|
# Bob signs in (without a trusted device of his own).
|
||||||
|
client.cookies.clear()
|
||||||
|
db.conn().execute("UPDATE otc_codes SET consumed_at = datetime('now', '-1 hour'), created_at = datetime('now', '-1 hour')")
|
||||||
|
_sign_in_via_otc(client, "bob@example.com", trust_device=False)
|
||||||
|
|
||||||
|
# Bob tries to revoke Alice's row by id.
|
||||||
|
r = client.delete(f"/api/auth/me/devices/{alice_device_id}")
|
||||||
|
assert r.status_code == 404
|
||||||
|
|
||||||
|
# Alice's row is still active.
|
||||||
|
row = db.conn().execute(
|
||||||
|
"SELECT revoked_at FROM device_trust WHERE id = ?", (alice_device_id,)
|
||||||
|
).fetchone()
|
||||||
|
assert row["revoked_at"] is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_revoke_all_devices_kills_every_active_row(app_with_fake_gitea):
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
from app import db
|
||||||
|
|
||||||
|
app, _fake = app_with_fake_gitea
|
||||||
|
with TestClient(app, base_url=HTTPS_BASE) as client:
|
||||||
|
_reset_outbound()
|
||||||
|
_sign_in_via_otc(client, "alice@example.com", trust_device=True)
|
||||||
|
|
||||||
|
# Add a second device.
|
||||||
|
db.conn().execute("UPDATE otc_codes SET consumed_at = datetime('now', '-1 hour'), created_at = datetime('now', '-1 hour')")
|
||||||
|
r = client.post("/auth/otc/request", json={"email": "alice@example.com"})
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
code = _outbound_otc_codes("alice@example.com")[-1]
|
||||||
|
r = client.post(
|
||||||
|
"/auth/otc/verify",
|
||||||
|
json={"email": "alice@example.com", "code": code, "trust_device": True},
|
||||||
|
)
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
|
||||||
|
# Two active rows.
|
||||||
|
assert len(client.get("/api/auth/me/devices").json()["items"]) == 2
|
||||||
|
|
||||||
|
# Revoke all.
|
||||||
|
r = client.delete("/api/auth/me/devices")
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
assert r.json()["revoked"] == 2
|
||||||
|
|
||||||
|
# List is empty.
|
||||||
|
assert client.get("/api/auth/me/devices").json()["items"] == []
|
||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "rfc-app-frontend",
|
"name": "rfc-app-frontend",
|
||||||
"version": "0.9.0",
|
"version": "0.11.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "rfc-app-frontend",
|
"name": "rfc-app-frontend",
|
||||||
"version": "0.9.0",
|
"version": "0.11.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@codemirror/commands": "^6.10.3",
|
"@codemirror/commands": "^6.10.3",
|
||||||
"@codemirror/lang-markdown": "^6.5.0",
|
"@codemirror/lang-markdown": "^6.5.0",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "rfc-app-frontend",
|
"name": "rfc-app-frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.9.0",
|
"version": "0.11.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
+59
-48
@@ -455,6 +455,65 @@
|
|||||||
.otc-fallback a:hover { color: #1a1a1a; text-decoration: underline; }
|
.otc-fallback a:hover { color: #1a1a1a; text-decoration: underline; }
|
||||||
.otc-fallback-sep { color: #ccc; }
|
.otc-fallback-sep { color: #ccc; }
|
||||||
|
|
||||||
|
/* v0.11.0 — "trust this device for 30 days" checkbox on the verify
|
||||||
|
step. Sits above the action row, padded so it doesn't crowd the
|
||||||
|
passcode/code input. */
|
||||||
|
.otc-trust-device {
|
||||||
|
display: flex; align-items: center; gap: 8px;
|
||||||
|
font-size: 13px; color: #444;
|
||||||
|
margin: 8px 0 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
.otc-trust-device input[type="checkbox"] {
|
||||||
|
width: auto; margin: 0; cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* v0.11.0 — /settings/devices revoke-device UI. */
|
||||||
|
.device-list {
|
||||||
|
list-style: none; padding: 0; margin: 12px 0 0;
|
||||||
|
}
|
||||||
|
.device-list-item {
|
||||||
|
display: flex; align-items: center; justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
border: 1px solid #eee; border-radius: 6px;
|
||||||
|
padding: 10px 12px; margin: 0 0 8px;
|
||||||
|
background: #fafafa;
|
||||||
|
}
|
||||||
|
.device-list-item .device-meta {
|
||||||
|
flex: 1; min-width: 0;
|
||||||
|
}
|
||||||
|
.device-list-item .device-ua {
|
||||||
|
font-size: 13px; color: #1a1a1a;
|
||||||
|
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
.device-list-item .device-stamps {
|
||||||
|
font-size: 12px; color: #777;
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
.device-list-item button {
|
||||||
|
font-size: 12px; padding: 4px 10px;
|
||||||
|
border: 1px solid #ccc; border-radius: 4px;
|
||||||
|
background: white; cursor: pointer;
|
||||||
|
}
|
||||||
|
.device-list-item button:hover:not(:disabled) {
|
||||||
|
background: #f5f5f5;
|
||||||
|
}
|
||||||
|
.device-revoke-all {
|
||||||
|
margin-top: 8px;
|
||||||
|
font-size: 13px; padding: 6px 12px;
|
||||||
|
border: 1px solid #cb6a6a; border-radius: 4px;
|
||||||
|
background: white; color: #cb6a6a; cursor: pointer;
|
||||||
|
}
|
||||||
|
.device-revoke-all:hover:not(:disabled) {
|
||||||
|
background: #fff5f5;
|
||||||
|
}
|
||||||
|
.device-empty {
|
||||||
|
font-size: 13px; color: #777;
|
||||||
|
background: #fafafa; border: 1px solid #eee; border-radius: 6px;
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
/* --- Beta-pending page (post-OAuth-rejection) --- */
|
/* --- Beta-pending page (post-OAuth-rejection) --- */
|
||||||
|
|
||||||
.beta-pending {
|
.beta-pending {
|
||||||
@@ -1889,54 +1948,6 @@
|
|||||||
display: inline-flex; align-items: center; gap: 6px;
|
display: inline-flex; align-items: center; gap: 6px;
|
||||||
font-size: 13px; cursor: pointer;
|
font-size: 13px; cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* v0.9.0 — admin user-management surface (roadmap item #7). */
|
|
||||||
.admin-filter-chips {
|
|
||||||
display: flex; gap: 6px; margin-bottom: 16px; flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
.admin-chip {
|
|
||||||
display: inline-flex; align-items: center; gap: 6px;
|
|
||||||
background: #fff; border: 1px solid #d1d5db; border-radius: 999px;
|
|
||||||
padding: 4px 12px; font-size: 12px; color: #374151; cursor: pointer;
|
|
||||||
}
|
|
||||||
.admin-chip:hover { background: #f9fafb; }
|
|
||||||
.admin-chip.active {
|
|
||||||
background: #111; color: #fff; border-color: #111;
|
|
||||||
}
|
|
||||||
.admin-chip-count {
|
|
||||||
font-size: 11px; opacity: 0.7;
|
|
||||||
}
|
|
||||||
.admin-users-table td { vertical-align: top; padding-top: 10px; padding-bottom: 10px; }
|
|
||||||
.permission-cell { display: flex; flex-direction: column; gap: 4px; }
|
|
||||||
.permission-actions { display: flex; gap: 6px; }
|
|
||||||
.permission-badge {
|
|
||||||
display: inline-block;
|
|
||||||
font-size: 11px; font-weight: 600;
|
|
||||||
padding: 2px 8px; border-radius: 999px;
|
|
||||||
text-transform: uppercase; letter-spacing: 0.04em;
|
|
||||||
width: max-content;
|
|
||||||
}
|
|
||||||
.permission-badge-pending {
|
|
||||||
background: #fef3c7; color: #92400e;
|
|
||||||
}
|
|
||||||
.permission-badge-granted {
|
|
||||||
background: #dcfce7; color: #166534;
|
|
||||||
}
|
|
||||||
.permission-badge-revoked {
|
|
||||||
background: #fee2e2; color: #991b1b;
|
|
||||||
}
|
|
||||||
.permission-decided { font-size: 11px; }
|
|
||||||
.user-row-reason td {
|
|
||||||
background: #fffbeb; border-top: none !important;
|
|
||||||
padding: 0 16px 12px !important;
|
|
||||||
}
|
|
||||||
.user-reason-block {
|
|
||||||
border-left: 3px solid #f59e0b;
|
|
||||||
padding: 8px 12px; font-size: 13px;
|
|
||||||
background: #fffbeb;
|
|
||||||
}
|
|
||||||
.user-reason-block strong { display: block; margin-bottom: 4px; color: #92400e; }
|
|
||||||
.user-reason-block p { margin: 0; white-space: pre-wrap; color: #374151; }
|
|
||||||
.grad-queue { list-style: none; padding: 0; margin: 8px 0 24px; }
|
.grad-queue { list-style: none; padding: 0; margin: 8px 0 24px; }
|
||||||
.grad-queue li { padding: 8px 0; border-bottom: 1px solid #f3f4f6; }
|
.grad-queue li { padding: 8px 0; border-bottom: 1px solid #f3f4f6; }
|
||||||
.grad-queue-link { color: #111; text-decoration: none; font-size: 14px; }
|
.grad-queue-link { color: #111; text-decoration: none; font-size: 14px; }
|
||||||
|
|||||||
+38
-17
@@ -40,11 +40,16 @@ export async function requestOtc(email) {
|
|||||||
return jsonOrThrow(res)
|
return jsonOrThrow(res)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function verifyOtc(email, code) {
|
export async function verifyOtc(email, code, { trustDevice = false } = {}) {
|
||||||
|
// v0.11.0 — `trustDevice` is the "trust this device for 30 days"
|
||||||
|
// checkbox on the Login.jsx OTC step. When true, the server mints
|
||||||
|
// a fresh device-trust row and sets the long-lived cookie; on
|
||||||
|
// subsequent visits, the cookie skips the OTC roundtrip via
|
||||||
|
// `startDeviceTrust()`.
|
||||||
const res = await fetch('/auth/otc/verify', {
|
const res = await fetch('/auth/otc/verify', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ email, code }),
|
body: JSON.stringify({ email, code, trust_device: !!trustDevice }),
|
||||||
})
|
})
|
||||||
return jsonOrThrow(res)
|
return jsonOrThrow(res)
|
||||||
}
|
}
|
||||||
@@ -82,15 +87,44 @@ export async function checkPasscode(email) {
|
|||||||
return jsonOrThrow(res)
|
return jsonOrThrow(res)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function verifyPasscode(email, passcode) {
|
export async function verifyPasscode(email, passcode, { trustDevice = false } = {}) {
|
||||||
|
// v0.11.0 — same trust-device opt-in as `verifyOtc`.
|
||||||
const res = await fetch('/auth/passcode/verify', {
|
const res = await fetch('/auth/passcode/verify', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ email, passcode }),
|
body: JSON.stringify({ email, passcode, trust_device: !!trustDevice }),
|
||||||
})
|
})
|
||||||
return jsonOrThrow(res)
|
return jsonOrThrow(res)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── v0.11.0: trust device for 30 days (§6.2, roadmap item #9) ─────────────
|
||||||
|
//
|
||||||
|
// On a returning visit with a valid device-trust cookie, `startDeviceTrust`
|
||||||
|
// re-establishes the session without an OTC / passcode roundtrip. The
|
||||||
|
// cookie is HttpOnly so the client cannot read it; the call is a pure POST
|
||||||
|
// that the browser attaches the cookie to automatically.
|
||||||
|
//
|
||||||
|
// `listMyDevices`, `revokeMyDevice`, and `revokeAllMyDevices` drive the
|
||||||
|
// /settings/devices revoke-device UI. The signed-in user is the implicit
|
||||||
|
// subject; the cookie carries the session.
|
||||||
|
|
||||||
|
export async function startDeviceTrust() {
|
||||||
|
const res = await fetch('/auth/device-trust/start', { method: 'POST' })
|
||||||
|
return jsonOrThrow(res)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listMyDevices() {
|
||||||
|
return jsonOrThrow(await fetch('/api/auth/me/devices'))
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function revokeMyDevice(deviceId) {
|
||||||
|
return jsonOrThrow(await fetch(`/api/auth/me/devices/${deviceId}`, { method: 'DELETE' }))
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function revokeAllMyDevices() {
|
||||||
|
return jsonOrThrow(await fetch('/api/auth/me/devices', { method: 'DELETE' }))
|
||||||
|
}
|
||||||
|
|
||||||
export async function setPasscode(passcode) {
|
export async function setPasscode(passcode) {
|
||||||
// Requires an active session — the server returns 401 if not signed
|
// Requires an active session — the server returns 401 if not signed
|
||||||
// in. The signed-in user is the implicit subject; the body carries
|
// in. The signed-in user is the implicit subject; the body carries
|
||||||
@@ -661,19 +695,6 @@ export async function setUserMute(userId, muted) {
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
// v0.9.0 — roadmap item #7. Flip a user's permission_state between
|
|
||||||
// 'pending', 'granted', and 'revoked'. The Users tab on the admin
|
|
||||||
// page wires Grant / Revoke buttons against this endpoint; the
|
|
||||||
// returned `changed` flag is false when the requested state already
|
|
||||||
// matched the row.
|
|
||||||
export async function setUserPermission(userId, state) {
|
|
||||||
return jsonOrThrow(await fetch(`/api/admin/users/${userId}/permission`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ state }),
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function listAuditLog({ actionKind, actorUserId, rfcSlug, beforeId, limit } = {}) {
|
export async function listAuditLog({ actionKind, actorUserId, rfcSlug, beforeId, limit } = {}) {
|
||||||
const params = new URLSearchParams()
|
const params = new URLSearchParams()
|
||||||
if (actionKind) params.set('action_kind', actionKind)
|
if (actionKind) params.set('action_kind', actionKind)
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ import {
|
|||||||
listAdminUsers,
|
listAdminUsers,
|
||||||
setUserRole,
|
setUserRole,
|
||||||
setUserMute,
|
setUserMute,
|
||||||
setUserPermission,
|
|
||||||
listAuditLog,
|
listAuditLog,
|
||||||
listPermissionEvents,
|
listPermissionEvents,
|
||||||
listGraduationQueue,
|
listGraduationQueue,
|
||||||
@@ -69,26 +68,12 @@ export default function Admin({ viewer }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Users + role + write-mute + permission grant/revoke (§6.1 / §6.2) ──────
|
// ── Users + role + write-mute (§6.1 / §6.2) ────────────────────────────────
|
||||||
//
|
|
||||||
// v0.9.0 (roadmap item #7) lands the user-management surface. The table
|
|
||||||
// shows every user with their permission_state, sign-up reason (when
|
|
||||||
// pending), role, write-mute, and Grant / Revoke controls. State filter
|
|
||||||
// chips above the table narrow to one bucket — the "Pending" chip is the
|
|
||||||
// admin's daily inbox shape.
|
|
||||||
|
|
||||||
const STATE_CHIPS = [
|
|
||||||
{ value: 'all', label: 'All' },
|
|
||||||
{ value: 'pending', label: 'Pending' },
|
|
||||||
{ value: 'granted', label: 'Granted' },
|
|
||||||
{ value: 'revoked', label: 'Revoked' },
|
|
||||||
]
|
|
||||||
|
|
||||||
function UsersTab() {
|
function UsersTab() {
|
||||||
const [users, setUsers] = useState(null)
|
const [users, setUsers] = useState(null)
|
||||||
const [busy, setBusy] = useState({})
|
const [busy, setBusy] = useState({})
|
||||||
const [error, setError] = useState(null)
|
const [error, setError] = useState(null)
|
||||||
const [stateFilter, setStateFilter] = useState('all')
|
|
||||||
|
|
||||||
async function refresh() {
|
async function refresh() {
|
||||||
setError(null)
|
setError(null)
|
||||||
@@ -128,193 +113,68 @@ function UsersTab() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function flipPermission(userId, state) {
|
|
||||||
setBusy(b => ({ ...b, [userId]: true }))
|
|
||||||
setError(null)
|
|
||||||
try {
|
|
||||||
await setUserPermission(userId, state)
|
|
||||||
// Refresh the full row so permission_decided_{at,by_*} update too.
|
|
||||||
await refresh()
|
|
||||||
} catch (e) {
|
|
||||||
setError(e.message)
|
|
||||||
} finally {
|
|
||||||
setBusy(b => ({ ...b, [userId]: false }))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const counts = useMemo(() => {
|
|
||||||
const c = { all: 0, pending: 0, granted: 0, revoked: 0 }
|
|
||||||
if (users) {
|
|
||||||
c.all = users.length
|
|
||||||
for (const u of users) {
|
|
||||||
const s = u.permission_state || 'granted'
|
|
||||||
if (s in c) c[s] += 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return c
|
|
||||||
}, [users])
|
|
||||||
|
|
||||||
if (users == null) return <p className="muted">Loading users…</p>
|
if (users == null) return <p className="muted">Loading users…</p>
|
||||||
|
|
||||||
const filtered = stateFilter === 'all'
|
|
||||||
? users
|
|
||||||
: users.filter(u => (u.permission_state || 'granted') === stateFilter)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="admin-tab">
|
<div className="admin-tab">
|
||||||
<header className="admin-tab-header">
|
<header className="admin-tab-header">
|
||||||
<h2>Users</h2>
|
<h2>Users</h2>
|
||||||
<p className="muted">
|
<p className="muted">
|
||||||
The pending bucket is the beta-access review queue (§6.1 /
|
Role changes write to <code>permission_events</code>. The §6.2
|
||||||
v0.8.0). Grant or revoke writes to <code>permission_events</code>
|
write-mute applies to contributors only — promote to admin to
|
||||||
and stamps <code>permission_decided_by</code> +{' '}
|
remove a user's ability to write without silencing them.
|
||||||
<code>permission_decided_at</code>. Role and write-mute controls
|
|
||||||
retain their v0.7.0 semantics — promote to admin to remove a
|
|
||||||
user's ability to write without silencing them.
|
|
||||||
</p>
|
</p>
|
||||||
</header>
|
</header>
|
||||||
{error && <p className="settings-note warning">{error}</p>}
|
{error && <p className="settings-note warning">{error}</p>}
|
||||||
|
<table className="admin-table">
|
||||||
<div className="admin-filter-chips">
|
<thead>
|
||||||
{STATE_CHIPS.map(chip => (
|
<tr>
|
||||||
<button
|
<th>User</th>
|
||||||
key={chip.value}
|
<th>Role</th>
|
||||||
type="button"
|
<th>Write-muted</th>
|
||||||
className={`admin-chip${stateFilter === chip.value ? ' active' : ''}`}
|
<th>Last seen</th>
|
||||||
onClick={() => setStateFilter(chip.value)}
|
</tr>
|
||||||
>
|
</thead>
|
||||||
{chip.label} <span className="admin-chip-count">{counts[chip.value] ?? 0}</span>
|
<tbody>
|
||||||
</button>
|
{users.map(u => (
|
||||||
))}
|
<tr key={u.id}>
|
||||||
</div>
|
<td>
|
||||||
|
<div className="user-cell">
|
||||||
{filtered.length === 0 ? (
|
<span className="user-handle">@{u.gitea_login}</span>
|
||||||
<p className="muted">No users in this bucket.</p>
|
<span className="muted">{u.display_name}</span>
|
||||||
) : (
|
</div>
|
||||||
<table className="admin-table admin-users-table">
|
</td>
|
||||||
<thead>
|
<td>
|
||||||
<tr>
|
<select
|
||||||
<th>User</th>
|
value={u.role}
|
||||||
<th>State</th>
|
onChange={e => changeRole(u.id, e.target.value)}
|
||||||
<th>Role</th>
|
disabled={!!busy[u.id]}
|
||||||
<th>Write-muted</th>
|
>
|
||||||
<th>Signed up</th>
|
<option value="contributor">Contributor</option>
|
||||||
<th>Last seen</th>
|
<option value="admin">Admin</option>
|
||||||
|
<option value="owner">Owner</option>
|
||||||
|
</select>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{u.role === 'contributor' ? (
|
||||||
|
<label className="mute-toggle">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={!!u.muted}
|
||||||
|
onChange={e => toggleMute(u.id, e.target.checked)}
|
||||||
|
disabled={!!busy[u.id]}
|
||||||
|
/>
|
||||||
|
{u.muted ? 'Muted' : 'Active'}
|
||||||
|
</label>
|
||||||
|
) : (
|
||||||
|
<span className="muted">N/A</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="muted">{u.last_seen_at}</td>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
))}
|
||||||
<tbody>
|
</tbody>
|
||||||
{filtered.map(u => (
|
</table>
|
||||||
<UserRow
|
|
||||||
key={u.id}
|
|
||||||
user={u}
|
|
||||||
busy={!!busy[u.id]}
|
|
||||||
onChangeRole={role => changeRole(u.id, role)}
|
|
||||||
onToggleMute={muted => toggleMute(u.id, muted)}
|
|
||||||
onFlipPermission={state => flipPermission(u.id, state)}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function UserRow({ user: u, busy, onChangeRole, onToggleMute, onFlipPermission }) {
|
|
||||||
const state = u.permission_state || 'granted'
|
|
||||||
const fullName = [u.first_name, u.last_name].filter(Boolean).join(' ').trim()
|
|
||||||
const handle = u.gitea_login ? `@${u.gitea_login}` : (u.email || u.display_name)
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<tr>
|
|
||||||
<td>
|
|
||||||
<div className="user-cell">
|
|
||||||
<span className="user-handle">{handle}</span>
|
|
||||||
<span className="muted">
|
|
||||||
{fullName || u.display_name}
|
|
||||||
{u.email ? ` · ${u.email}` : ''}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<PermissionCell user={u} busy={busy} onFlipPermission={onFlipPermission} />
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<select
|
|
||||||
value={u.role}
|
|
||||||
onChange={e => onChangeRole(e.target.value)}
|
|
||||||
disabled={busy}
|
|
||||||
>
|
|
||||||
<option value="contributor">Contributor</option>
|
|
||||||
<option value="admin">Admin</option>
|
|
||||||
<option value="owner">Owner</option>
|
|
||||||
</select>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
{u.role === 'contributor' ? (
|
|
||||||
<label className="mute-toggle">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={!!u.muted}
|
|
||||||
onChange={e => onToggleMute(e.target.checked)}
|
|
||||||
disabled={busy}
|
|
||||||
/>
|
|
||||||
{u.muted ? 'Muted' : 'Active'}
|
|
||||||
</label>
|
|
||||||
) : (
|
|
||||||
<span className="muted">N/A</span>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
<td className="muted">{u.created_at || '—'}</td>
|
|
||||||
<td className="muted">{u.last_seen_at || '—'}</td>
|
|
||||||
</tr>
|
|
||||||
{state === 'pending' && u.beta_request_reason ? (
|
|
||||||
<tr className="user-row-reason">
|
|
||||||
<td colSpan={6}>
|
|
||||||
<div className="user-reason-block">
|
|
||||||
<strong>Why they want access:</strong>
|
|
||||||
<p>{u.beta_request_reason}</p>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
) : null}
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function PermissionCell({ user: u, busy, onFlipPermission }) {
|
|
||||||
const state = u.permission_state || 'granted'
|
|
||||||
const decidedSuffix = u.permission_decided_at
|
|
||||||
? ` · by ${u.permission_decided_by_login ? '@' + u.permission_decided_by_login : '—'} at ${u.permission_decided_at}`
|
|
||||||
: ''
|
|
||||||
return (
|
|
||||||
<div className="permission-cell">
|
|
||||||
<span className={`permission-badge permission-badge-${state}`}>{state}</span>
|
|
||||||
<div className="permission-actions">
|
|
||||||
{state !== 'granted' && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="btn-link-quiet"
|
|
||||||
disabled={busy}
|
|
||||||
onClick={() => onFlipPermission('granted')}
|
|
||||||
>Grant</button>
|
|
||||||
)}
|
|
||||||
{state === 'granted' && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="btn-link-quiet"
|
|
||||||
disabled={busy}
|
|
||||||
onClick={() => {
|
|
||||||
if (confirm(`Revoke access for ${u.display_name || u.email}?`)) {
|
|
||||||
onFlipPermission('revoked')
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>Revoke</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{decidedSuffix && (
|
|
||||||
<div className="permission-decided muted">{decidedSuffix.replace(/^ · /, '')}</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,11 +27,8 @@ export default function BetaPending({ viewer }) {
|
|||||||
{isPending ? (
|
{isPending ? (
|
||||||
<>
|
<>
|
||||||
<p>
|
<p>
|
||||||
Thanks for telling us a bit about yourself. The deployment's
|
Thanks for telling us a bit about yourself. An admin will
|
||||||
admins are notified by email as soon as a request lands;
|
review your request and get back to you as soon as we can.
|
||||||
we don't commit to a fixed SLA — turnaround depends on
|
|
||||||
operator availability — and the deployment operator is
|
|
||||||
the right person to ask if a wait runs long.
|
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
While you wait, the catalog on the left lists every super-draft
|
While you wait, the catalog on the left lists every super-draft
|
||||||
|
|||||||
@@ -72,6 +72,7 @@ import {
|
|||||||
checkPasscode,
|
checkPasscode,
|
||||||
verifyPasscode,
|
verifyPasscode,
|
||||||
setPasscode as apiSetPasscode,
|
setPasscode as apiSetPasscode,
|
||||||
|
startDeviceTrust,
|
||||||
} from '../api'
|
} from '../api'
|
||||||
|
|
||||||
export default function Login() {
|
export default function Login() {
|
||||||
@@ -84,6 +85,13 @@ export default function Login() {
|
|||||||
const [code, setCode] = useState('')
|
const [code, setCode] = useState('')
|
||||||
const [passcode, setPasscode] = useState('')
|
const [passcode, setPasscode] = useState('')
|
||||||
const [newPasscode, setNewPasscode] = useState('')
|
const [newPasscode, setNewPasscode] = useState('')
|
||||||
|
// v0.11.0 — "trust this device for 30 days" checkbox, shared by the
|
||||||
|
// OTC and passcode verify steps. The flag rides on the verify POST;
|
||||||
|
// a checked box mints a device-trust row server-side and sets the
|
||||||
|
// long-lived `rfc_device_trust` cookie. Defaults off so the user
|
||||||
|
// makes an explicit choice — auth credentials shouldn't persist by
|
||||||
|
// default.
|
||||||
|
const [trustDevice, setTrustDevice] = useState(false)
|
||||||
// v0.8.0 — capture-profile fields.
|
// v0.8.0 — capture-profile fields.
|
||||||
const [firstName, setFirstName] = useState('')
|
const [firstName, setFirstName] = useState('')
|
||||||
const [lastName, setLastName] = useState('')
|
const [lastName, setLastName] = useState('')
|
||||||
@@ -105,6 +113,28 @@ export default function Login() {
|
|||||||
else if (step === 'set-passcode') newPasscodeRef.current?.focus()
|
else if (step === 'set-passcode') newPasscodeRef.current?.focus()
|
||||||
}, [step])
|
}, [step])
|
||||||
|
|
||||||
|
// v0.11.0 — on mount, try the device-trust cookie path. If the
|
||||||
|
// browser still carries a valid `rfc_device_trust` cookie from a
|
||||||
|
// prior "trust this device" gesture, the server re-establishes the
|
||||||
|
// session without any user input and we redirect home. The cookie
|
||||||
|
// is HttpOnly so we can't peek at it; we just call the endpoint and
|
||||||
|
// see whether it returns 200. 401 (no cookie / invalid / revoked)
|
||||||
|
// is the structural-silent case — the user proceeds to the email
|
||||||
|
// step normally. We do not surface any UI about the attempt; a
|
||||||
|
// failure should be invisible.
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false
|
||||||
|
;(async () => {
|
||||||
|
try {
|
||||||
|
await startDeviceTrust()
|
||||||
|
if (!cancelled) window.location.assign('/')
|
||||||
|
} catch (_) {
|
||||||
|
// No trusted device — fall through to the email step.
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
return () => { cancelled = true }
|
||||||
|
}, [])
|
||||||
|
|
||||||
async function submitEmail(e) {
|
async function submitEmail(e) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
if (!email.trim() || !email.includes('@')) {
|
if (!email.trim() || !email.includes('@')) {
|
||||||
@@ -143,7 +173,7 @@ export default function Login() {
|
|||||||
setBusy(true)
|
setBusy(true)
|
||||||
setStatus('')
|
setStatus('')
|
||||||
try {
|
try {
|
||||||
await verifyPasscode(email.trim(), passcode.trim())
|
await verifyPasscode(email.trim(), passcode.trim(), { trustDevice })
|
||||||
// Reload so App.jsx's getMe() picks up the fresh session. A
|
// Reload so App.jsx's getMe() picks up the fresh session. A
|
||||||
// returning passcode user is by definition already past the
|
// returning passcode user is by definition already past the
|
||||||
// §6.1 capture step (they couldn't have set a passcode while
|
// §6.1 capture step (they couldn't have set a passcode while
|
||||||
@@ -189,7 +219,7 @@ export default function Login() {
|
|||||||
setBusy(true)
|
setBusy(true)
|
||||||
setStatus('')
|
setStatus('')
|
||||||
try {
|
try {
|
||||||
await verifyOtc(email.trim(), code.trim())
|
await verifyOtc(email.trim(), code.trim(), { trustDevice })
|
||||||
// OTC verified — the server has signed in the user. Fetch the
|
// OTC verified — the server has signed in the user. Fetch the
|
||||||
// canonical /api/auth/me to decide where to land:
|
// canonical /api/auth/me to decide where to land:
|
||||||
// * needs_profile → §6.1 capture (then /beta-pending).
|
// * needs_profile → §6.1 capture (then /beta-pending).
|
||||||
@@ -378,6 +408,19 @@ export default function Login() {
|
|||||||
required
|
required
|
||||||
disabled={busy}
|
disabled={busy}
|
||||||
/>
|
/>
|
||||||
|
{/* v0.11.0 — trust device for 30 days. The checkbox lives
|
||||||
|
on the verify step so the user makes the trust gesture
|
||||||
|
in the same breath as signing in. Off by default; the
|
||||||
|
user opts in deliberately. */}
|
||||||
|
<label className="otc-trust-device">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={trustDevice}
|
||||||
|
onChange={e => setTrustDevice(e.target.checked)}
|
||||||
|
disabled={busy}
|
||||||
|
/>
|
||||||
|
<span>Trust this device for 30 days</span>
|
||||||
|
</label>
|
||||||
<div className="otc-actions">
|
<div className="otc-actions">
|
||||||
<button type="submit" disabled={busy || !passcode.trim()}>
|
<button type="submit" disabled={busy || !passcode.trim()}>
|
||||||
{busy ? 'Signing in…' : 'Sign in'}
|
{busy ? 'Signing in…' : 'Sign in'}
|
||||||
@@ -420,6 +463,17 @@ export default function Login() {
|
|||||||
required
|
required
|
||||||
disabled={busy}
|
disabled={busy}
|
||||||
/>
|
/>
|
||||||
|
{/* v0.11.0 — trust device for 30 days. Same shape as the
|
||||||
|
passcode step; the user opts in deliberately. */}
|
||||||
|
<label className="otc-trust-device">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={trustDevice}
|
||||||
|
onChange={e => setTrustDevice(e.target.checked)}
|
||||||
|
disabled={busy}
|
||||||
|
/>
|
||||||
|
<span>Trust this device for 30 days</span>
|
||||||
|
</label>
|
||||||
<div className="otc-actions">
|
<div className="otc-actions">
|
||||||
<button type="submit" disabled={busy || code.length !== 6}>
|
<button type="submit" disabled={busy || code.length !== 6}>
|
||||||
{busy ? 'Signing in…' : 'Sign in'}
|
{busy ? 'Signing in…' : 'Sign in'}
|
||||||
|
|||||||
@@ -32,6 +32,9 @@ import {
|
|||||||
getMe,
|
getMe,
|
||||||
setPasscode,
|
setPasscode,
|
||||||
clearPasscode,
|
clearPasscode,
|
||||||
|
listMyDevices,
|
||||||
|
revokeMyDevice,
|
||||||
|
revokeAllMyDevices,
|
||||||
} from '../api.js'
|
} from '../api.js'
|
||||||
import { getConsent, onConsentChange, hydrateFromServer } from '../lib/consent.js'
|
import { getConsent, onConsentChange, hydrateFromServer } from '../lib/consent.js'
|
||||||
|
|
||||||
@@ -54,11 +57,126 @@ export default function NotificationSettings({ viewer }) {
|
|||||||
<WatchesSection />
|
<WatchesSection />
|
||||||
<MutesSection viewer={viewer} />
|
<MutesSection viewer={viewer} />
|
||||||
<SignInSection />
|
<SignInSection />
|
||||||
|
<DevicesSection />
|
||||||
<PrivacyCookiesSection />
|
<PrivacyCookiesSection />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── v0.11.0: trusted devices (§6.2, roadmap item #9) ──────────────────────
|
||||||
|
//
|
||||||
|
// Lists the user's active device-trust rows and lets them revoke any
|
||||||
|
// or all. A revoke marks the row dead server-side; the matching
|
||||||
|
// device's next visit will be refused and the cookie cleared. The
|
||||||
|
// surface intentionally does not single out the row whose cookie the
|
||||||
|
// current request carries — every row reads identically, so the user
|
||||||
|
// can revoke "this device" alongside any other from a single page.
|
||||||
|
|
||||||
|
function DevicesSection() {
|
||||||
|
const [devices, setDevices] = useState(null)
|
||||||
|
const [error, setError] = useState(null)
|
||||||
|
const [busy, setBusy] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
refresh()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
async function refresh() {
|
||||||
|
try {
|
||||||
|
const { items } = await listMyDevices()
|
||||||
|
setDevices(items || [])
|
||||||
|
setError(null)
|
||||||
|
} catch (e) {
|
||||||
|
setError(e.message || 'Could not load trusted devices.')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onRevoke(deviceId) {
|
||||||
|
setBusy(true)
|
||||||
|
try {
|
||||||
|
await revokeMyDevice(deviceId)
|
||||||
|
await refresh()
|
||||||
|
} catch (e) {
|
||||||
|
setError(e.message || 'Could not revoke device.')
|
||||||
|
} finally {
|
||||||
|
setBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onRevokeAll() {
|
||||||
|
if (!confirm('Revoke trust on every device, including this one? You will be asked to sign in via email next time.')) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setBusy(true)
|
||||||
|
try {
|
||||||
|
await revokeAllMyDevices()
|
||||||
|
await refresh()
|
||||||
|
} catch (e) {
|
||||||
|
setError(e.message || 'Could not revoke devices.')
|
||||||
|
} finally {
|
||||||
|
setBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SectionShell
|
||||||
|
title="Trusted devices"
|
||||||
|
subtitle="Devices where you've checked “Trust this device for 30 days.” Sign-in is automatic on these devices until the trust expires or you revoke it."
|
||||||
|
>
|
||||||
|
{devices === null && <p className="settings-note">Loading…</p>}
|
||||||
|
{devices !== null && devices.length === 0 && (
|
||||||
|
<p className="device-empty">
|
||||||
|
No trusted devices. Sign in and check “Trust this device for 30 days”
|
||||||
|
to add the device you're on now.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{devices !== null && devices.length > 0 && (
|
||||||
|
<>
|
||||||
|
<ul className="device-list">
|
||||||
|
{devices.map(d => (
|
||||||
|
<li key={d.id} className="device-list-item">
|
||||||
|
<div className="device-meta">
|
||||||
|
<div className="device-ua">{d.user_agent || 'Unknown device'}</div>
|
||||||
|
<div className="device-stamps">
|
||||||
|
Trusted {formatStamp(d.created_at)} · last seen {formatStamp(d.last_seen_at)} · expires {formatStamp(d.expires_at)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onRevoke(d.id)}
|
||||||
|
disabled={busy}
|
||||||
|
title="Revoke trust on this device"
|
||||||
|
>
|
||||||
|
Revoke
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="device-revoke-all"
|
||||||
|
onClick={onRevokeAll}
|
||||||
|
disabled={busy}
|
||||||
|
>
|
||||||
|
Revoke all devices
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{error && <p className="settings-note warning">{error}</p>}
|
||||||
|
</SectionShell>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatStamp(stamp) {
|
||||||
|
// The server emits SQLite `datetime('now')` strings (UTC, no
|
||||||
|
// timezone marker). Parse defensively; fall back to the raw stamp
|
||||||
|
// if Date can't make sense of it.
|
||||||
|
if (!stamp) return '—'
|
||||||
|
const d = new Date(stamp.replace(' ', 'T') + 'Z')
|
||||||
|
if (Number.isNaN(d.getTime())) return stamp
|
||||||
|
return d.toLocaleString()
|
||||||
|
}
|
||||||
|
|
||||||
// ── §6.2 sign-in (v0.10.0 / roadmap item #8): passcode management ──────────
|
// ── §6.2 sign-in (v0.10.0 / roadmap item #8): passcode management ──────────
|
||||||
|
|
||||||
function SignInSection() {
|
function SignInSection() {
|
||||||
|
|||||||
Reference in New Issue
Block a user