SEC-10 (account pre-emption / "pre-hijack"): a password set on an account whose email was never verified is untrusted — whoever registered the address (to pre-empt the real owner) could have chosen it. Both the Google-adoption callback and admin activation (_activate_user_by_email) now drop that pre-set password and bump the session epoch when the account isn't email_verified, so a later Google login / admin approval can't hand the pre-set password a working login. No-op for a verified account (the legit no-SMTP flow). +2 tests. SEC-11 (link/upgrade bound to the session default): the OAuth link/upgrade GETs resolved to the session DEFAULT account, so a tab acting as a secondary account would link Google / the YouTube grant to the wrong account. The frontend now threads the active account through the navigation URL (withActiveAccount -> ?account=<active>), and _complete_link accepts any wallet account, not only the default, so the identity lands on the account that started the flow.
401 lines
14 KiB
Python
401 lines
14 KiB
Python
"""Admin-only onboarding: review access requests (Invites), approve/deny, manual add.
|
|
Also the demo-account admin surface: the demo email whitelist + a manual state reset."""
|
|
from datetime import datetime, timezone
|
|
|
|
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException
|
|
from pydantic import BaseModel
|
|
from sqlalchemy import select
|
|
from sqlalchemy.orm import Session
|
|
|
|
from app import audit
|
|
from app import email as email_mod
|
|
from app.audit import AuditAction
|
|
from app.auth import (
|
|
admin_user,
|
|
count_admins,
|
|
get_or_create_demo_user,
|
|
operator_contact,
|
|
purge_user,
|
|
)
|
|
from app.db import get_db
|
|
from app.demo import reset_demo_state
|
|
from app.routes.auth import bump_session_epoch
|
|
from app.models import (
|
|
DemoWhitelist,
|
|
Invite,
|
|
User,
|
|
)
|
|
from app.utils import iso, valid_email
|
|
|
|
router = APIRouter(prefix="/api/admin", tags=["admin"])
|
|
|
|
|
|
def _serialize(inv: Invite) -> 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,
|
|
}
|
|
|
|
|
|
@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))
|
|
return [_serialize(i) for i in rows]
|
|
|
|
|
|
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)."""
|
|
user = db.execute(select(User).where(User.email == email.lower())).scalar_one_or_none()
|
|
if user is not None and not user.is_active:
|
|
# SEC-10: same guard as the Google-adoption path — approving an account whose email was never
|
|
# verified must not activate a password set on it by whoever registered the address (a
|
|
# pre-emption). Drop the untrusted password + revoke any session it minted; the owner re-sets
|
|
# one via password-reset. (No-op when email_verified is True, e.g. legit no-SMTP registrations.)
|
|
if not user.email_verified:
|
|
user.password_hash = None
|
|
bump_session_epoch(user)
|
|
user.is_active = True
|
|
db.commit()
|
|
|
|
|
|
def _decide(db: Session, invite_id: int, admin: User, approved: bool) -> Invite:
|
|
inv = db.get(Invite, invite_id)
|
|
if inv is None:
|
|
raise HTTPException(status_code=404, detail="No such 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 HTTPException(status_code=409, detail="Invite already 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.commit()
|
|
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)
|
|
background.add_task(email_mod.send_access_approved, inv.email)
|
|
return _serialize(inv)
|
|
|
|
|
|
@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)
|
|
return _serialize(inv)
|
|
|
|
|
|
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 HTTPException(status_code=400, detail="Enter a valid email address.")
|
|
inv = db.execute(select(Invite).where(Invite.email == email)).scalar_one_or_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
|
|
audit.record(db, admin.id, AuditAction.INVITE_ADD, target_type="invite", target_id=inv.id, summary=email)
|
|
db.commit()
|
|
_activate_user_by_email(db, email)
|
|
return _serialize(inv)
|
|
|
|
|
|
# --- 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,
|
|
"is_demo": u.is_demo,
|
|
"has_password": u.password_hash is not None,
|
|
"has_google": u.google_sub is not None,
|
|
"created_at": iso(u.created_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, can't touch the demo
|
|
account, 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 HTTPException(status_code=400, detail="role must be 'user' or 'admin'")
|
|
target = db.get(User, user_id)
|
|
if target is None:
|
|
raise HTTPException(status_code=404, detail="No such user")
|
|
if target.is_demo:
|
|
raise HTTPException(status_code=400, detail="The demo account's role can't be changed.")
|
|
if target.id == admin.id:
|
|
raise HTTPException(status_code=400, detail="You can't change your own role.")
|
|
if (
|
|
target.role == "admin"
|
|
and role == "user"
|
|
and not target.is_suspended
|
|
and count_admins(db, active_only=True) <= 1
|
|
):
|
|
raise HTTPException(status_code=400, detail="Can't remove the 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, 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 the demo account, 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 = db.get(User, user_id)
|
|
if target is None:
|
|
raise HTTPException(status_code=404, detail="No such user")
|
|
if target.is_demo:
|
|
raise HTTPException(status_code=400, detail="The demo account can't be suspended.")
|
|
if target.id == admin.id:
|
|
raise HTTPException(status_code=400, detail="You can't suspend your own account.")
|
|
if (
|
|
suspended
|
|
and target.role == "admin"
|
|
and not target.is_suspended
|
|
and count_admins(db, active_only=True) <= 1
|
|
):
|
|
raise HTTPException(status_code=400, detail="Can't suspend the 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, 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 the demo account, not yourself (use Settings → Account), and not the last admin."""
|
|
target = db.get(User, user_id)
|
|
if target is None:
|
|
raise HTTPException(status_code=404, detail="No such user")
|
|
if target.is_demo:
|
|
raise HTTPException(status_code=400, detail="The demo account can't be deleted.")
|
|
if target.id == admin.id:
|
|
raise HTTPException(
|
|
status_code=400, detail="Delete your own account from Settings → Account."
|
|
)
|
|
if target.role == "admin" and not target.is_suspended and count_admins(db, active_only=True) <= 1:
|
|
raise HTTPException(status_code=400, detail="Can't delete the 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}
|
|
|
|
|
|
# --- Demo account: email whitelist + state reset -------------------------------------
|
|
|
|
def _serialize_demo(row: DemoWhitelist) -> dict:
|
|
return {
|
|
"id": row.id,
|
|
"email": row.email,
|
|
"note": row.note,
|
|
"added_by": row.added_by,
|
|
"created_at": iso(row.created_at),
|
|
}
|
|
|
|
|
|
@router.get("/demo/whitelist")
|
|
def list_demo_whitelist(
|
|
_: User = Depends(admin_user), db: Session = Depends(get_db)
|
|
) -> list[dict]:
|
|
rows = (
|
|
db.execute(select(DemoWhitelist).order_by(DemoWhitelist.created_at.desc()))
|
|
.scalars()
|
|
.all()
|
|
)
|
|
return [_serialize_demo(r) for r in rows]
|
|
|
|
|
|
class AddDemoWhitelistIn(BaseModel):
|
|
email: str = ""
|
|
note: str | None = None
|
|
|
|
|
|
@router.post("/demo/whitelist")
|
|
def add_demo_whitelist(
|
|
body: AddDemoWhitelistIn,
|
|
admin: User = Depends(admin_user),
|
|
db: Session = Depends(get_db),
|
|
) -> dict:
|
|
"""Add an email that may enter the shared demo account from the login page (no Google
|
|
sign-in). Idempotent — re-adding an existing email just returns it."""
|
|
email = body.email.strip().lower()
|
|
if not valid_email(email):
|
|
raise HTTPException(status_code=400, detail="Enter a valid email address.")
|
|
row = db.execute(
|
|
select(DemoWhitelist).where(DemoWhitelist.email == email)
|
|
).scalar_one_or_none()
|
|
if row is None:
|
|
row = DemoWhitelist(
|
|
email=email,
|
|
note=(body.note or "").strip() or None,
|
|
added_by=admin.email,
|
|
)
|
|
db.add(row)
|
|
audit.record(db, admin.id, AuditAction.DEMO_ADD, target_type="demo", target_id=email, summary=email)
|
|
db.commit()
|
|
return _serialize_demo(row)
|
|
|
|
|
|
@router.delete("/demo/whitelist/{entry_id}")
|
|
def remove_demo_whitelist(
|
|
entry_id: int,
|
|
admin: User = Depends(admin_user),
|
|
db: Session = Depends(get_db),
|
|
) -> dict:
|
|
row = db.get(DemoWhitelist, entry_id)
|
|
if row is not None:
|
|
audit.record(db, admin.id, AuditAction.DEMO_REMOVE, target_type="demo", target_id=row.email, summary=row.email)
|
|
db.delete(row)
|
|
db.commit()
|
|
return {"deleted": entry_id}
|
|
|
|
|
|
@router.post("/demo/reset")
|
|
def reset_demo(
|
|
admin: User = Depends(admin_user), db: Session = Depends(get_db)
|
|
) -> dict:
|
|
"""Manually clean up the shared demo sandbox back to a baseline. A scheduled job does the
|
|
same automatically (admin-tunable interval); this is the admin's on-demand button."""
|
|
demo = get_or_create_demo_user(db)
|
|
seeded = reset_demo_state(db, demo)
|
|
audit.record(db, admin.id, AuditAction.DEMO_RESET, summary=f"{seeded} sample playlists re-seeded")
|
|
db.commit()
|
|
return {"reset": True, "playlists_seeded": seeded}
|
|
|
|
|
|
@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 result
|