Without SMTP, registration marks the address verified with no proof, and an ADMIN_EMAILS / ALLOWED_EMAILS address gets no invite row to gate it. So a stranger who registered such an address (or a sibling spelling) before its owner kept their own password when the owner's first Google sign-in adopted the row, activated it and made it admin, and could then sign in with it. The adoption now treats a never-activated row like an unverified one: the password goes, password_revoked_at is stamped and the session epoch bumped. An account already let in keeps its password. The helper is renamed to say what it now judges (revoke_untrusted_preset_password). test_google_callback.py drives the callback's account-matching branches through the real route, none of which a test ran before: the squat (red on the old rule), adoption by mailbox, the sub-collision and unverified-address refusals, the refused sign-in recorded in Google's locale, and the link flow's conflict, mismatch and attach answers.
755 lines
32 KiB
Python
755 lines
32 KiB
Python
"""Admin-only onboarding: review access requests (Invites), approve/deny, manual add."""
|
|
|
|
from collections.abc import Iterable
|
|
from datetime import datetime, timezone
|
|
|
|
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException
|
|
from pydantic import BaseModel
|
|
from sqlalchemy import or_, select
|
|
from sqlalchemy.orm import Session
|
|
|
|
from app import audit
|
|
from app import email as email_mod
|
|
from app import mailtext
|
|
from app.audit import AuditAction
|
|
from app.auth import (
|
|
admin_user,
|
|
can_activate,
|
|
count_admins,
|
|
env_allowed_canonicals,
|
|
find_user_by_email,
|
|
is_allowed,
|
|
operator_contact,
|
|
purge_user,
|
|
)
|
|
from app.db import get_db
|
|
from app.models import (
|
|
Invite,
|
|
User,
|
|
)
|
|
from app.routes._common import get_or_404
|
|
from app.routes.auth import (
|
|
APPROVAL_VERIFY_TTL,
|
|
issue_set_password_link,
|
|
issue_verification_link,
|
|
send_verification_link,
|
|
)
|
|
from app.utils import canonical_email, iso, valid_email
|
|
from app.refusal import refused
|
|
|
|
router = APIRouter(prefix="/api/admin", tags=["admin"])
|
|
|
|
|
|
def _accounts_for(db: Session, emails: Iterable[str]) -> set[str]:
|
|
"""Which of these addresses already have an account, as a set of CANONICAL forms. Batched, so
|
|
listing the invites costs one extra query. An invite whose address has an account must not be
|
|
deletable: `is_allowed` reads this row, so removing it would silently revoke a live user's access.
|
|
|
|
Canonical on both sides, because `is_allowed` matches that way: an invite for `x.y@gmail.com`
|
|
whose account registered as `xy@gmail.com` would otherwise look account-less, offer the admin a
|
|
delete button, and take away a live user's access — precisely the accident this guard exists to
|
|
prevent."""
|
|
wanted = {canonical_email(e) for e in emails if e}
|
|
if not wanted:
|
|
return set()
|
|
rows = db.execute(
|
|
select(User.email_canonical).where(User.email_canonical.in_(wanted))
|
|
).scalars()
|
|
return {e for e in rows if e}
|
|
|
|
|
|
def _env_allowed(email: str) -> bool:
|
|
"""Whether the env bootstrap lists still let this address in. `is_allowed` consults them only
|
|
when no Invite row exists, so this is exactly what deleting the row would fall back to — and the
|
|
admin has to know that BEFORE they confirm, not from a toast afterwards.
|
|
|
|
Canonical, because `is_allowed` is: a warning that disagrees with the gate it is warning about is
|
|
worse than no warning."""
|
|
return canonical_email(email or "") in env_allowed_canonicals()
|
|
|
|
|
|
def _serialize(inv: Invite, *, has_account: bool = False) -> dict:
|
|
return {
|
|
"id": inv.id,
|
|
"email": inv.email,
|
|
"status": inv.status,
|
|
"note": inv.note,
|
|
"requested_at": iso(inv.requested_at),
|
|
"decided_at": iso(inv.decided_at),
|
|
"decided_by": inv.decided_by,
|
|
# When (if ever) the admin sent this person an invitation. Adding an address is silent, so
|
|
# without this the admin has no way to answer "did I already write to them?" — and the only
|
|
# failure mode is writing to a real person twice.
|
|
"invited_at": iso(inv.invited_at),
|
|
# Drives the delete affordance: an entry backed by an account is not removable here.
|
|
"has_account": has_account,
|
|
# …and whether removing it would actually take access away, or leave the env allow-list
|
|
# granting it anyway (for an ADMIN_EMAILS address, as an admin).
|
|
"still_allowed_by_env": _env_allowed(inv.email),
|
|
# The language this person asked us in, or null when nobody ever told us (a hand-added
|
|
# whitelist row). The invitation dialog preselects it, and falls back to the admin's own UI
|
|
# language — a guess, but a visible one the admin can correct before sending.
|
|
"lang": inv.lang,
|
|
}
|
|
|
|
|
|
@router.get("/invites")
|
|
def list_invites(
|
|
status: str | None = None,
|
|
_: User = Depends(admin_user),
|
|
db: Session = Depends(get_db),
|
|
) -> list[dict]:
|
|
stmt = select(Invite)
|
|
if status:
|
|
stmt = stmt.where(Invite.status == status)
|
|
# Pending first, then most-recently requested.
|
|
rows = db.execute(stmt.order_by(Invite.requested_at.desc())).scalars().all()
|
|
order = {"pending": 0, "approved": 1, "denied": 2}
|
|
rows.sort(key=lambda i: order.get(i.status, 9))
|
|
accounts = _accounts_for(db, (i.email for i in rows))
|
|
return [_serialize(i, has_account=canonical_email(i.email) in accounts) for i in rows]
|
|
|
|
|
|
def _welcome_mail_parts(db: Session, email: str) -> tuple[str | None, bool, int]:
|
|
"""What a welcome or invitation mail should tell this address to do next: a confirmation link (if
|
|
there is an unconfirmed account behind it), whether an account exists at all, and how long the
|
|
link lasts.
|
|
|
|
This is the C option's whole mechanic. In the common ordering — the address is approved and the
|
|
person signs up either side of that — they would otherwise have to find and open TWO messages:
|
|
one saying they are in, another asking them to confirm. Carrying the link inside the mail that is
|
|
already going out makes it one mail and one click.
|
|
|
|
Resolved through `find_user_by_email`, so an account registered under another spelling of the
|
|
same Gmail mailbox is still recognised — and, deliberately, so several candidates resolve to
|
|
"no single answer" rather than a guess: an invitation that carried the wrong account's link would
|
|
be worse than one that simply pointed at the sign-up page.
|
|
"""
|
|
hours = int(APPROVAL_VERIFY_TTL.total_seconds() // 3600)
|
|
user = find_user_by_email(db, email)
|
|
if user is None:
|
|
return None, False, hours
|
|
if user.email_verified or not email_mod.email_enabled(db):
|
|
return None, True, hours
|
|
return issue_verification_link(db, user, APPROVAL_VERIFY_TTL), True, hours
|
|
|
|
|
|
def _activate_user_by_email(db: Session, email: str) -> None:
|
|
"""Approving access activates a matching (e.g. pending password-registration) account so
|
|
it can sign in. No-op if no such user exists yet (a Google user is created at first login).
|
|
|
|
This path deliberately does NOT revoke a preset password. Revoking here destroyed the
|
|
password a legitimate registrant had just chosen and made "your password was cleared for
|
|
security" the DEFAULT experience, because approve-before-verify is the normal ordering.
|
|
|
|
That was once justified by saying `password_login` refuses an unverified account BEFORE it
|
|
looks at `is_active`, so a pre-emptor could not sign in "approved or not". A review of the
|
|
finished epic showed that argument only holds while the address stays unverified — and the
|
|
approval mail hands the real owner a confirmation link precisely so they will end that state.
|
|
Nothing replaced the revoke, and that is deliberate: the same review later tried "the newest
|
|
registration owns an unverified account" and reverted it, because the rule is symmetric — an
|
|
attacker can always register last — and it handed anonymous callers a write onto a real
|
|
account's credential. The owner's recourse is password-reset, which proves the mailbox, rewrites
|
|
the hash and bumps the session epoch. The residual is logged as SEC-16. The revoke also stays on
|
|
the Google-adoption path (auth.revoke_untrusted_preset_password), where ownership is
|
|
actually attested."""
|
|
# Canonical, with the literal spelling preferred when both exist: approving
|
|
# `npeter83.temu@gmail.com` must activate the account that registered as `npeter83temu@gmail.com`,
|
|
# since Gmail delivers both to one mailbox and the admin decided about the mailbox.
|
|
email = email.lower()
|
|
candidates = (
|
|
db.execute(
|
|
select(User).where(User.email_canonical == canonical_email(email)).order_by(User.id)
|
|
)
|
|
.scalars()
|
|
.all()
|
|
)
|
|
user = next((u for u in candidates if u.email == email), None) or next(iter(candidates), None)
|
|
if user is not None and not user.is_active:
|
|
user.is_active = True
|
|
db.flush() # the handler commits (CLAUDE.md: helpers flush)
|
|
|
|
|
|
def _decide(db: Session, invite_id: int, admin: User, approved: bool) -> Invite:
|
|
inv = get_or_404(db, Invite, invite_id, what="invite")
|
|
# Idempotency guard: only a still-pending invite can be decided. Without this a repeat/stale
|
|
# approve or deny re-runs its side effects — re-activating a user, or (now that deny emails)
|
|
# sending a duplicate decision mail, even flipping an already-approved invite to "denied" and
|
|
# mailing a rejection to an already-active account.
|
|
if inv.status != "pending":
|
|
raise refused(409, "invite_decided")
|
|
inv.status = "approved" if approved else "denied"
|
|
inv.decided_at = datetime.now(timezone.utc)
|
|
inv.decided_by = admin.email
|
|
audit.record(
|
|
db,
|
|
admin.id,
|
|
AuditAction.INVITE_APPROVE if approved else AuditAction.INVITE_DENY,
|
|
target_type="invite",
|
|
target_id=inv.id,
|
|
summary=inv.email,
|
|
)
|
|
db.flush() # the handler commits, with the activation that goes with it
|
|
return inv
|
|
|
|
|
|
@router.post("/invites/{invite_id}/approve")
|
|
def approve_invite(
|
|
invite_id: int,
|
|
background: BackgroundTasks,
|
|
admin: User = Depends(admin_user),
|
|
db: Session = Depends(get_db),
|
|
) -> dict:
|
|
inv = _decide(db, invite_id, admin, approved=True)
|
|
_activate_user_by_email(db, inv.email)
|
|
# Computed here, not in the background task: issuing the token needs this request's session, and
|
|
# the task must not outlive it. Approval answers a request the person MADE, so unlike the
|
|
# invitation below it is sent automatically — a reply is owed, not a nudge.
|
|
link, has_account, hours = _welcome_mail_parts(db, inv.email)
|
|
lang = mailtext.lang_for_email(db, inv.email)
|
|
background.add_task(email_mod.send_access_approved, inv.email, lang, link, has_account, hours)
|
|
# Before the response, so the mail (a background task, after it) never describes a decision
|
|
# the database does not hold yet.
|
|
db.commit()
|
|
return _serialize(inv, has_account=bool(_accounts_for(db, [inv.email])))
|
|
|
|
|
|
@router.post("/invites/{invite_id}/deny")
|
|
def deny_invite(
|
|
invite_id: int,
|
|
background: BackgroundTasks,
|
|
admin: User = Depends(admin_user),
|
|
db: Session = Depends(get_db),
|
|
) -> dict:
|
|
# Symmetry with approve: the requester hears back either way, instead of a silent limbo. No
|
|
# account is activated, so this is an email only (the requester has no in-app inbox yet).
|
|
inv = _decide(db, invite_id, admin, approved=False)
|
|
background.add_task(
|
|
email_mod.send_access_denied, inv.email, mailtext.lang_for_email(db, inv.email)
|
|
)
|
|
db.commit()
|
|
return _serialize(inv, has_account=bool(_accounts_for(db, [inv.email])))
|
|
|
|
|
|
class AddInviteIn(BaseModel):
|
|
email: str = ""
|
|
|
|
|
|
@router.post("/invites")
|
|
def add_invite(
|
|
body: AddInviteIn,
|
|
admin: User = Depends(admin_user),
|
|
db: Session = Depends(get_db),
|
|
) -> dict:
|
|
"""Manually whitelist an email (approved straight away). Convenience for the admin."""
|
|
email = body.email.strip().lower()
|
|
if not valid_email(email):
|
|
raise refused(400, "invalid_email")
|
|
# Reuse a canonically-equal row instead of adding a second one for the same mailbox. Prod carries
|
|
# exactly that pair for one person from 2026-06-16 (hartmann.peter1986@ / hartmannpeter1986@),
|
|
# which is how an admin ends up approving the same human twice and still seeing them bounce.
|
|
canon = canonical_email(email)
|
|
rows = (
|
|
db.execute(select(Invite).where(Invite.email_canonical == canon).order_by(Invite.id))
|
|
.scalars()
|
|
.all()
|
|
)
|
|
inv = next((r for r in rows if r.email == email), None) or next(iter(rows), None)
|
|
if inv is None:
|
|
inv = Invite(email=email)
|
|
db.add(inv)
|
|
inv.status = "approved"
|
|
inv.decided_at = datetime.now(timezone.utc)
|
|
inv.decided_by = admin.email
|
|
db.flush() # a new invite's id, for the audit row — before it, the row named no target
|
|
audit.record(
|
|
db, admin.id, AuditAction.INVITE_ADD, target_type="invite", target_id=inv.id, summary=email
|
|
)
|
|
# One transaction: the whitelisting and the activation it implies land together or not at all.
|
|
_activate_user_by_email(db, email)
|
|
db.commit()
|
|
return _serialize(inv, has_account=bool(_accounts_for(db, [email])))
|
|
|
|
|
|
class InviteSendIn(BaseModel):
|
|
# Which language to write this person in. Optional so an existing caller (and a test) can post
|
|
# an empty body, but the admin UI always sends it: this is the one recipient the app has no
|
|
# signal about, because the admin typed the address in themselves.
|
|
lang: str | None = None
|
|
|
|
|
|
@router.post("/invites/{invite_id}/invite")
|
|
def send_invitation(
|
|
invite_id: int,
|
|
body: InviteSendIn | None = None,
|
|
admin: User = Depends(admin_user),
|
|
db: Session = Depends(get_db),
|
|
) -> dict:
|
|
"""Tell someone their address is whitelisted — only when the admin asks for it.
|
|
|
|
Whitelisting stays SILENT on purpose. The operator opens a door as a possibility, usually having
|
|
already agreed it with the person, and an address can sit approved for weeks with nobody
|
|
registering; a mail fired automatically would be nudging someone who never asked to be nudged.
|
|
So the send is its own act, on its own button, and `invited_at` records it so the admin can see
|
|
they have already written rather than writing twice.
|
|
|
|
Only an APPROVED row can be invited: there is nothing to invite someone to while their request is
|
|
undecided, and inviting a denied address would contradict the decision."""
|
|
inv = get_or_404(db, Invite, invite_id, what="invite")
|
|
if inv.status != "approved":
|
|
raise HTTPException(
|
|
status_code=409,
|
|
detail={"code": "invite_send", "reason": "not_approved", "email": inv.email},
|
|
)
|
|
if not email_mod.email_enabled(db):
|
|
# Same reasoning as the admin-side verification resend: mail is fail-soft everywhere else,
|
|
# but a button the admin pressed must not answer with a success toast for a mail that was
|
|
# never sent.
|
|
raise HTTPException(status_code=409, detail={"code": "invite_send", "reason": "email_off"})
|
|
link, has_account, hours = _welcome_mail_parts(db, inv.email)
|
|
# An explicit pick from the dialog is recorded, so a later mail to this address matches the one
|
|
# the admin already sent. Only the explicit pick — writing the fallback back would turn a column
|
|
# that means "what this person told us" into "what we last guessed".
|
|
picked = body.lang if body else None
|
|
if picked in mailtext.SUPPORTED:
|
|
lang = inv.lang = picked
|
|
else:
|
|
lang = mailtext.lang_for_email(db, inv.email)
|
|
# Sent INLINE, so `invited_at` records the send rather than the intent. Every other mail here is
|
|
# fail-soft in the background, which is right when nobody is waiting on it — but this column
|
|
# exists to answer "did I already write to them?", and the UI dims the button and warns about a
|
|
# second mail on the strength of it. Stamping it before a send that then failed makes the one
|
|
# question it answers a lie, and talks the admin out of the retry that would fix it.
|
|
#
|
|
# One thing a failed send does leave behind: the confirmation token `_welcome_mail_parts` minted
|
|
# to render the link, which commits inside `_issue_token`. Accepted rather than chased — the
|
|
# token is single-use, expires on its own, and was mailed nowhere; deferring it would mean
|
|
# rewriting a helper three callers share so the handler owns its commit.
|
|
if not email_mod.send_access_invitation(inv.email, lang, link, has_account, hours):
|
|
raise HTTPException(
|
|
status_code=422, detail={"code": "invite_send", "reason": "send_failed"}
|
|
)
|
|
# Only after the mail is actually out. Same as the other two paths that decide for an address
|
|
# (`approve_invite`, `add_invite`): an approved row means this mailbox may come in, so finish it,
|
|
# or the mail can promise "your account is ready — just sign in" to someone `password_login` will
|
|
# refuse for `is_active`. Doing it BEFORE the send would mean a request the UI reports as failed
|
|
# had quietly activated an account behind the admin's back.
|
|
_activate_user_by_email(db, inv.email)
|
|
inv.invited_at = datetime.now(timezone.utc)
|
|
audit.record(
|
|
db,
|
|
admin.id,
|
|
AuditAction.INVITE_SEND,
|
|
target_type="invite",
|
|
target_id=inv.id,
|
|
summary=inv.email,
|
|
)
|
|
db.commit()
|
|
return _serialize(inv, has_account=has_account)
|
|
|
|
|
|
@router.delete("/invites/{invite_id}")
|
|
def delete_invite(
|
|
invite_id: int,
|
|
admin: User = Depends(admin_user),
|
|
db: Session = Depends(get_db),
|
|
) -> dict:
|
|
"""Remove a whitelist/request entry outright — the counterpart to add_invite, for an address that
|
|
never became an account (an approval added by mistake, or one that is no longer wanted). Deny
|
|
records a decision and keeps the row for the audit trail; delete forgets the address. Neither
|
|
stops the person asking again: `upsert_pending_invite` reopens a denied row, and a deleted one
|
|
simply comes back as a fresh pending request.
|
|
|
|
Refused once an account exists for the address, whatever its state: this row is the account's
|
|
approval gate, and orphaning the account from it would leave a person who can neither sign in nor
|
|
be approved. Delete the USER instead — purge_user removes the invite along with it.
|
|
|
|
Reports `still_allowed_by_env`, because deleting the row is NOT always a revoke: `is_allowed`
|
|
falls back to the env ALLOWED_EMAILS / ADMIN_EMAILS bootstrap lists once no invite exists, so for
|
|
an address on those the deletion leaves access intact (and an ADMIN_EMAILS address is still
|
|
promoted to admin on its next Google sign-in). The admin has to hear that, not guess it."""
|
|
inv = get_or_404(db, Invite, invite_id, what="invite")
|
|
if _accounts_for(db, [inv.email]):
|
|
raise refused(409, "address_has_account")
|
|
still_allowed_by_env = _env_allowed(inv.email)
|
|
audit.record(
|
|
db,
|
|
admin.id,
|
|
AuditAction.INVITE_DELETE,
|
|
target_type="invite",
|
|
target_id=inv.id,
|
|
summary=inv.email,
|
|
)
|
|
db.delete(inv)
|
|
db.commit()
|
|
return {"deleted": invite_id, "still_allowed_by_env": still_allowed_by_env}
|
|
|
|
|
|
# --- Users & roles --------------------------------------------------------------------
|
|
|
|
|
|
def _serialize_user(u: User) -> dict:
|
|
return {
|
|
"id": u.id,
|
|
"email": u.email,
|
|
"display_name": u.display_name,
|
|
"role": u.role,
|
|
"is_active": u.is_active,
|
|
"is_suspended": u.is_suspended,
|
|
"email_verified": u.email_verified,
|
|
"has_password": u.password_hash is not None,
|
|
# Distinguishes "this account never had a password" (Google-only) from "SEC-10 cleared it" —
|
|
# the same distinction _send_reset_if_eligible needs, and the one an admin has to see before
|
|
# deciding what kind of help someone needs.
|
|
"password_revoked_at": iso(u.password_revoked_at),
|
|
"has_google": u.google_sub is not None,
|
|
# Only when it DIFFERS from the account address: "connected" is not the same
|
|
# statement as "connected as someone else", and the second one is the one that explains a
|
|
# confusing sign-in.
|
|
"google_email": u.google_email if u.google_email != u.email else None,
|
|
"created_at": iso(u.created_at),
|
|
# NULL = no sign-in since the column existed (0085).
|
|
"last_login_at": iso(u.last_login_at),
|
|
}
|
|
|
|
|
|
@router.get("/users")
|
|
def list_users(_: User = Depends(admin_user), db: Session = Depends(get_db)) -> list[dict]:
|
|
rows = db.execute(select(User).order_by(User.created_at.desc())).scalars().all()
|
|
return [_serialize_user(u) for u in rows]
|
|
|
|
|
|
class SetRoleIn(BaseModel):
|
|
role: str
|
|
|
|
|
|
@router.patch("/users/{user_id}/role")
|
|
def set_user_role(
|
|
user_id: int,
|
|
body: SetRoleIn,
|
|
background: BackgroundTasks,
|
|
admin: User = Depends(admin_user),
|
|
db: Session = Depends(get_db),
|
|
) -> dict:
|
|
"""Promote/demote a user. Guards: can't change your own role and can't remove the
|
|
last remaining admin. Emails the user when it actually changes."""
|
|
role = body.role
|
|
if role not in ("user", "admin"):
|
|
raise refused(400, "invalid_role")
|
|
target = get_or_404(db, User, user_id, what="user")
|
|
if target.id == admin.id:
|
|
raise refused(400, "own_role")
|
|
if (
|
|
target.role == "admin"
|
|
and role == "user"
|
|
and not target.is_suspended
|
|
and count_admins(db, active_only=True) <= 1
|
|
):
|
|
raise refused(400, "last_admin")
|
|
changed = target.role != role
|
|
if changed:
|
|
audit.record(
|
|
db,
|
|
admin.id,
|
|
AuditAction.USER_ROLE_CHANGE,
|
|
target_type="user",
|
|
target_id=target.id,
|
|
summary=target.email,
|
|
before={"role": target.role},
|
|
after={"role": role},
|
|
)
|
|
target.role = role
|
|
db.commit()
|
|
if changed:
|
|
background.add_task(
|
|
email_mod.send_role_changed,
|
|
target.email,
|
|
mailtext.lang_for_user(target),
|
|
role,
|
|
operator_contact(),
|
|
)
|
|
return _serialize_user(target)
|
|
|
|
|
|
class SetSuspendedIn(BaseModel):
|
|
suspended: bool = False
|
|
|
|
|
|
@router.patch("/users/{user_id}/suspend")
|
|
def set_user_suspended(
|
|
user_id: int,
|
|
body: SetSuspendedIn,
|
|
background: BackgroundTasks,
|
|
admin: User = Depends(admin_user),
|
|
db: Session = Depends(get_db),
|
|
) -> dict:
|
|
"""Suspend or un-suspend an account. A suspended user can't sign in by any method and any
|
|
live session is dropped on its next request. Guards: can't suspend yourself, and can't suspend the last active admin (that would lock admin out). On
|
|
un-suspend the user is emailed they can return (suspend is announced reactively, on a blocked
|
|
sign-in)."""
|
|
suspended = body.suspended
|
|
target = get_or_404(db, User, user_id, what="user")
|
|
if target.id == admin.id:
|
|
raise refused(400, "own_suspend")
|
|
if (
|
|
suspended
|
|
and target.role == "admin"
|
|
and not target.is_suspended
|
|
and count_admins(db, active_only=True) <= 1
|
|
):
|
|
raise refused(400, "last_active_admin")
|
|
was_suspended = target.is_suspended
|
|
if was_suspended != suspended:
|
|
audit.record(
|
|
db,
|
|
admin.id,
|
|
AuditAction.USER_SUSPEND if suspended else AuditAction.USER_UNSUSPEND,
|
|
target_type="user",
|
|
target_id=target.id,
|
|
summary=target.email,
|
|
)
|
|
target.is_suspended = suspended
|
|
db.commit()
|
|
if was_suspended and not suspended:
|
|
background.add_task(
|
|
email_mod.send_account_unsuspended,
|
|
target.email,
|
|
mailtext.lang_for_user(target),
|
|
operator_contact(),
|
|
)
|
|
return _serialize_user(target)
|
|
|
|
|
|
@router.delete("/users/{user_id}")
|
|
def admin_delete_user(
|
|
user_id: int,
|
|
background: BackgroundTasks,
|
|
admin: User = Depends(admin_user),
|
|
db: Session = Depends(get_db),
|
|
) -> dict:
|
|
"""Admin-triggered account deletion — the same full erasure a user can perform on themselves
|
|
(cascade delete of all personal data + access-request cleanup + Google-grant revoke). Guards:
|
|
not yourself (use Settings → Account), and not the last admin."""
|
|
target = get_or_404(db, User, user_id, what="user")
|
|
if target.id == admin.id:
|
|
raise refused(400, "own_delete")
|
|
if (
|
|
target.role == "admin"
|
|
and not target.is_suspended
|
|
and count_admins(db, active_only=True) <= 1
|
|
):
|
|
raise refused(400, "last_admin")
|
|
# Record BEFORE the purge — the target row (and its email) is gone afterwards.
|
|
audit.record(
|
|
db,
|
|
admin.id,
|
|
AuditAction.USER_DELETE,
|
|
target_type="user",
|
|
target_id=target.id,
|
|
summary=target.email,
|
|
)
|
|
purge_user(db, target, background)
|
|
return {"deleted": user_id}
|
|
|
|
|
|
# --- Stuck sign-ups: the admin lifeline -----------------------------------------------------------
|
|
|
|
|
|
def _pending_access_rows(db: Session) -> list[User]:
|
|
"""Accounts that have signed up but still cannot sign in, newest first.
|
|
|
|
Two different dead ends share this list, because from the admin's chair they look the same — a
|
|
person who registered and is still outside:
|
|
· `is_active=False` — approved BEFORE they signed up, so nothing ever landed in the
|
|
Access-requests queue and its Approve button doesn't apply (`_decide` only takes a pending
|
|
row). This is roadmap UX-25, and it is why the list exists.
|
|
· `email_verified=False` — the confirmation mail hasn't been acted on. Not the admin's to
|
|
decide, but theirs to help with: on 2026-09-03 the first mail to a fresh Gmail landed in
|
|
Spam, so "send it again" is a real move.
|
|
A suspended account is deliberately excluded: it cannot sign in either, but that is a decision
|
|
already made, not a person stuck waiting on one.
|
|
"""
|
|
return list(
|
|
db.execute(
|
|
select(User)
|
|
.where(
|
|
User.is_suspended.is_(False),
|
|
or_(User.is_active.is_(False), User.email_verified.is_(False)),
|
|
)
|
|
.order_by(User.created_at.desc())
|
|
)
|
|
.scalars()
|
|
.all()
|
|
)
|
|
|
|
|
|
@router.get("/pending-access")
|
|
def list_pending_access(_: User = Depends(admin_user), db: Session = Depends(get_db)) -> list[dict]:
|
|
"""The sign-ups that are still outside, with which gate is shut for each. `can_activate` cannot
|
|
be derived on the client — it needs the invite/allow-list lookup — so it is answered here.
|
|
|
|
One `is_allowed` query per row rather than a batched re-implementation of it: the row count is
|
|
the admin's own backlog of stuck sign-ups (single digits on any real instance), and a second,
|
|
subtly different copy of the "may this address sign in" rule is exactly the drift this codebase
|
|
keeps getting bitten by."""
|
|
out = []
|
|
for u in _pending_access_rows(db):
|
|
# The whole rule in one line: this address MAY sign in, and this account still can't. An
|
|
# undecided request fails it (nothing is allowed yet) and so does a denied one — both belong
|
|
# to the queue above, which owns that decision. Without this, every fresh registration
|
|
# appeared in both lists at once, which UAT caught on 2026-09-05.
|
|
if not is_allowed(db, u.email):
|
|
continue
|
|
out.append(_serialize_user(u) | {"can_activate": can_activate(db, u)})
|
|
return out
|
|
|
|
|
|
@router.get("/users/{user_id}/access")
|
|
def user_access(
|
|
user_id: int,
|
|
_: User = Depends(admin_user),
|
|
db: Session = Depends(get_db),
|
|
) -> dict:
|
|
"""Everything the account panel shows about why this person can or cannot get in.
|
|
|
|
Its own endpoint rather than extra columns on /users: `is_allowed` and `can_activate` each run a
|
|
query, and the panel opens for ONE user at a time — folding them into the list would pay that cost
|
|
per row on every load to answer a question nobody asked yet."""
|
|
target = get_or_404(db, User, user_id, what="user")
|
|
return _serialize_user(target) | {
|
|
"is_allowed": is_allowed(db, target.email),
|
|
"can_activate": can_activate(db, target),
|
|
"email_configured": email_mod.email_enabled(db),
|
|
}
|
|
|
|
|
|
@router.post("/users/{user_id}/set-password-link")
|
|
def create_set_password_link(
|
|
user_id: int,
|
|
admin: User = Depends(admin_user),
|
|
db: Session = Depends(get_db),
|
|
) -> dict:
|
|
"""Mint a one-time link that lets this account set a password, with no email involved.
|
|
|
|
The answer to SEC-15, SEC-16 and "no SMTP and someone forgot their password" at once. The admin
|
|
hands the link over by whatever channel they trust; opening it sets a password and signs out
|
|
whoever held the old one.
|
|
|
|
Deliberately NOT gated on the account's state. It is useful for a pre-empted address, for a
|
|
revoked password, for a Google-only account that wants one, and for a plain forgotten password on
|
|
an instance with no mail — narrowing it to one of those would only make the admin's real case the
|
|
one it refuses."""
|
|
target = get_or_404(db, User, user_id, what="user")
|
|
url, expires_at = issue_set_password_link(db, target)
|
|
# The URL itself is NOT recorded: the audit log is read by humans and kept long, and a live
|
|
# credential-setting link is the last thing that belongs in it. That the link was issued, by whom,
|
|
# for whom, is the auditable fact.
|
|
audit.record(
|
|
db,
|
|
admin.id,
|
|
AuditAction.USER_SETPW_LINK,
|
|
target_type="user",
|
|
target_id=target.id,
|
|
summary=target.email,
|
|
)
|
|
db.commit()
|
|
return {"ok": True, "url": url, "expires_at": iso(expires_at), "email": target.email}
|
|
|
|
|
|
@router.post("/users/{user_id}/activate")
|
|
def activate_user(
|
|
user_id: int,
|
|
admin: User = Depends(admin_user),
|
|
db: Session = Depends(get_db),
|
|
) -> dict:
|
|
"""Let in a registrant whose address was approved before they signed up (roadmap UX-25).
|
|
|
|
Admin-side ONLY, and that is the whole design. Two earlier attempts at this were reverted after
|
|
review, and both moved authority rather than visibility: auto-activating on registration turns a
|
|
standing whitelist entry into a bearer grant for whoever registers the address first, and
|
|
reopening the approved invite to `pending` lets an anonymous sign-up REVOKE a standing approval.
|
|
This endpoint changes nothing about what an unauthenticated caller can cause — the admin could
|
|
already do exactly this by re-entering the address in the whitelist box (`add_invite` →
|
|
`_activate_user_by_email`). What was missing was any way to know they had to."""
|
|
target = get_or_404(db, User, user_id, what="user")
|
|
if target.is_active:
|
|
return _serialize_user(target) # idempotent: a double click is not an error
|
|
if not is_allowed(db, target.email):
|
|
raise HTTPException(
|
|
status_code=409,
|
|
detail={"code": "activate", "reason": "not_allowed", "email": target.email},
|
|
)
|
|
audit.record(
|
|
db,
|
|
admin.id,
|
|
AuditAction.USER_ACTIVATE,
|
|
target_type="user",
|
|
target_id=target.id,
|
|
summary=target.email,
|
|
before={"is_active": False},
|
|
after={"is_active": True},
|
|
)
|
|
target.is_active = True
|
|
db.commit()
|
|
return _serialize_user(target)
|
|
|
|
|
|
@router.post("/users/{user_id}/resend-verification")
|
|
def resend_user_verification(
|
|
user_id: int,
|
|
admin: User = Depends(admin_user),
|
|
db: Session = Depends(get_db),
|
|
) -> dict:
|
|
"""Re-send an account's confirmation link from the admin side.
|
|
|
|
The public /auth/verify/resend answers uniformly and reveals nothing, which is right for an
|
|
anonymous caller but leaves the admin unable to tell a delivered mail from a dropped one. Here
|
|
the caller is already authenticated as an admin, so this one says plainly what happened.
|
|
|
|
Not rate-limited: an admin is not a threat model to enumerate against, and the deliberate silence
|
|
of the public endpoint is exactly what this exists to escape."""
|
|
target = get_or_404(db, User, user_id, what="user")
|
|
if target.email_verified:
|
|
raise HTTPException(
|
|
status_code=409,
|
|
detail={"code": "resend", "reason": "not_applicable", "email": target.email},
|
|
)
|
|
if not email_mod.email_enabled(db):
|
|
# Mail is fail-soft everywhere else, which is what left the 0.78.5 sign-ups with nothing to
|
|
# fall back on. An admin pressing a button deserves the failure, not a success toast.
|
|
raise HTTPException(status_code=409, detail={"code": "resend", "reason": "email_off"})
|
|
if not send_verification_link(db, target):
|
|
# Same rule as the invitation above: a button an admin pressed must not answer with a
|
|
# success for a mail that was dropped. Mail is fail-soft everywhere else because nobody is
|
|
# waiting on the answer; here somebody is.
|
|
raise HTTPException(status_code=422, detail={"code": "resend", "reason": "send_failed"})
|
|
audit.record(
|
|
db,
|
|
admin.id,
|
|
AuditAction.USER_VERIFY_RESEND,
|
|
target_type="user",
|
|
target_id=target.id,
|
|
summary=target.email,
|
|
)
|
|
db.commit()
|
|
return {"ok": True, "email": target.email}
|
|
|
|
|
|
@router.post("/purge-discovery")
|
|
def purge_discovery(admin: User = Depends(admin_user), db: Session = Depends(get_db)) -> dict:
|
|
"""Reclaim all un-kept discovery content NOW (ignoring the grace period): live-search result
|
|
videos nobody watched/saved/subscribed to, plus explored-but-unsubscribed channels. The
|
|
scheduled discovery-cleanup job does the same on its interval; this is the on-demand button."""
|
|
from app.sync.explore import purge_unkept_explored, purge_unkept_search
|
|
|
|
result = {**purge_unkept_search(db, grace_days=0), **purge_unkept_explored(db)}
|
|
audit.record(
|
|
db,
|
|
admin.id,
|
|
AuditAction.DISCOVERY_PURGE,
|
|
summary=", ".join(f"{k}: {v}" for k, v in result.items()) or None,
|
|
after=result,
|
|
)
|
|
db.commit()
|
|
return {"ok": True, **result}
|