import asyncio from datetime import datetime, timezone from typing import Optional from fastapi import APIRouter, HTTPException, Depends, Query from pydantic import BaseModel from firebase_admin import auth as firebase_auth from app.internal.auth import require_admin_token from app.internal import firestore as fstore from app.internal.tenancy import FOUNDING_ORG_ID from app.internal import audit router = APIRouter(prefix="/admin/users", tags=["users"]) VALID_ROLES = {"admin", "operator", "viewer"} # --------------------------------------------------------------------------- # Pydantic models # --------------------------------------------------------------------------- class UserCreate(BaseModel): email: str role: str = "viewer" display_name: Optional[str] = None owned_node_ids: list[str] = [] # Org the new user joins as a member. Defaults to the creating admin's own # org — without one, firestore.rules lets the user read nothing at all. org_id: Optional[str] = None class UserUpdate(BaseModel): org_id: Optional[str] = None # attach an org-less user; defaults to the admin's org role: Optional[str] = None owned_node_ids: Optional[list[str]] = None display_name: Optional[str] = None # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- async def _resolve_org(requested: Optional[str], decoded: dict) -> str: """The org a user created/edited here belongs to: the one asked for, else the acting admin's own. Every read the frontend makes is gated on the org_id claim (firestore.rules inOrg()), so a user without one sees no incidents, calls or nodes — which is how admin-created viewers came out before this existed.""" # A platform admin needn't have an org claim (isPlatformAdmin reads every # org), so fall back to the founding org every pre-tenancy node, call and # incident was stamped with (app/internal/tenancy.py). org_id = requested or decoded.get("org_id") or FOUNDING_ORG_ID if not await fstore.doc_get("organizations", org_id): raise HTTPException(400, f"Organization '{org_id}' does not exist.") return org_id async def _write_membership(uid: str, email: Optional[str], org_id: str) -> None: """Same org_members shape as POST /auth/signup (routers/links.py).""" await fstore.doc_set("org_members", uid, { "uid": uid, "org_id": org_id, "org_role": "member", "email": email, "added_at": datetime.now(timezone.utc).isoformat(), }, merge=False) def _ms_to_iso(ms: Optional[int]) -> Optional[str]: if ms is None: return None return datetime.fromtimestamp(ms / 1000, tz=timezone.utc).isoformat() def _extract_role_nodes(fb_user: firebase_auth.UserRecord) -> tuple[str, list[str]]: claims = fb_user.custom_claims or {} if claims.get("role") == "admin" or claims.get("admin"): role = "admin" else: role = claims.get("role", "viewer") if role not in VALID_ROLES: role = "viewer" owned_node_ids = claims.get("owned_node_ids") or [] return role, owned_node_ids def _format_user(fb_user: firebase_auth.UserRecord, link: Optional[dict] = None) -> dict: role, owned_node_ids = _extract_role_nodes(fb_user) return { "uid": fb_user.uid, "email": fb_user.email, "display_name": fb_user.display_name, "role": role, "owned_node_ids": owned_node_ids, "disabled": fb_user.disabled, "creation_time": _ms_to_iso(fb_user.user_metadata.creation_timestamp), "last_sign_in": _ms_to_iso(fb_user.user_metadata.last_sign_in_timestamp), "discord_linked": bool(link and link.get("discord_user_id")), "discord_username": link.get("discord_username") if link else None, "discord_user_id": link.get("discord_user_id") if link else None, # Which org's data this user can read (firestore.rules gates on it). "org_id": (fb_user.custom_claims or {}).get("org_id"), "org_role": (fb_user.custom_claims or {}).get("org_role"), } def _list_fb_users() -> list[firebase_auth.UserRecord]: users: list[firebase_auth.UserRecord] = [] page = firebase_auth.list_users() while page: users.extend(page.users) page = page.get_next_page() return users # --------------------------------------------------------------------------- # Endpoints # --------------------------------------------------------------------------- @router.get("") async def list_users(decoded: dict = Depends(require_admin_token)): """List all Firebase Auth users with role, node ownership, and Discord link status.""" fb_users = await asyncio.to_thread(_list_fb_users) links: list[Optional[dict]] = await asyncio.gather(*[ fstore.doc_get("firebase_discord_links", u.uid) for u in fb_users ]) return [_format_user(u, lnk) for u, lnk in zip(fb_users, links)] @router.post("") async def create_user(body: UserCreate, decoded: dict = Depends(require_admin_token)): """Create a new Firebase Auth user and set their role. Returns a one-time invite link.""" if body.role not in VALID_ROLES: raise HTTPException(400, f"Invalid role. Must be one of: {', '.join(sorted(VALID_ROLES))}") if body.role == "operator" and not body.owned_node_ids: raise HTTPException(400, "Operator role requires at least one owned node.") org_id = await _resolve_org(body.org_id, decoded) try: fb_user: firebase_auth.UserRecord = await asyncio.to_thread( firebase_auth.create_user, email=body.email, display_name=body.display_name or "", email_verified=False, ) except firebase_auth.EmailAlreadyExistsError: raise HTTPException(409, "A user with this email already exists.") except Exception as e: raise HTTPException(400, f"Failed to create user: {e}") # Set custom claims claims: dict = {"role": body.role, "owned_node_ids": body.owned_node_ids, "org_id": org_id, "org_role": "member"} if body.role == "admin": claims["admin"] = True await asyncio.to_thread(firebase_auth.set_custom_user_claims, fb_user.uid, claims) await _write_membership(fb_user.uid, body.email, org_id) # Write Firestore profile now = datetime.now(timezone.utc).isoformat() await fstore.doc_set("user_profiles", fb_user.uid, { "uid": fb_user.uid, "email": body.email, "display_name": body.display_name or "", "role": body.role, "owned_node_ids": body.owned_node_ids, "created_by_uid": decoded["uid"], "created_at": now, }, merge=False) # Generate a one-time invite/password-reset link invite_link: Optional[str] = None try: invite_link = await asyncio.to_thread(firebase_auth.generate_password_reset_link, body.email) except Exception: pass await audit.write_audit( actor_uid=decoded["uid"], actor_email=decoded.get("email", ""), action="user.create", target_uid=fb_user.uid, target_email=body.email, details={"role": body.role, "owned_node_ids": body.owned_node_ids, "org_id": org_id}, ) return {**_format_user(fb_user), "invite_link": invite_link} @router.get("/{uid}") async def get_user(uid: str, decoded: dict = Depends(require_admin_token)): """Get a single user with full detail, including recent sessions.""" try: fb_user: firebase_auth.UserRecord = await asyncio.to_thread(firebase_auth.get_user, uid) except firebase_auth.UserNotFoundError: raise HTTPException(404, "User not found.") link, raw_sessions = await asyncio.gather( fstore.doc_get("firebase_discord_links", uid), fstore.collection_where("user_sessions", [("uid", "==", uid)]), ) raw_sessions.sort(key=lambda s: s.get("timestamp", ""), reverse=True) return { **_format_user(fb_user, link), "sessions": raw_sessions[:20], } @router.patch("/{uid}") async def update_user(uid: str, body: UserUpdate, decoded: dict = Depends(require_admin_token)): """Update a user's role, owned nodes, or display name.""" try: fb_user: firebase_auth.UserRecord = await asyncio.to_thread(firebase_auth.get_user, uid) except firebase_auth.UserNotFoundError: raise HTTPException(404, "User not found.") current_role, current_nodes = _extract_role_nodes(fb_user) new_role = body.role if body.role is not None else current_role new_nodes = body.owned_node_ids if body.owned_node_ids is not None else current_nodes if new_role not in VALID_ROLES: raise HTTPException(400, f"Invalid role. Must be one of: {', '.join(sorted(VALID_ROLES))}") if new_role == "operator" and not new_nodes: raise HTTPException(400, "Operator role requires at least one owned node.") # Merge with existing claims (preserve any other claims already set) existing_claims: dict = dict(fb_user.custom_claims or {}) new_claims = {**existing_claims, "role": new_role, "owned_node_ids": new_nodes} if new_role == "admin": new_claims["admin"] = True else: new_claims.pop("admin", None) # Heal users created before POST /users set an org (they could read # nothing): any edit attaches them to the requested/admin's org. An # existing org is never silently moved. # Heal users created before POST /admin/users set an org (they could read # nothing): any edit attaches them to the requested/admin's org. Moving a # user who already has an org only happens when org_id is passed # explicitly — e.g. a viewer whose first login self-provisioned an empty # org of their own via POST /auth/signup (seen 2026-09-27). attached_org: Optional[str] = None left_org: Optional[str] = None current_org = existing_claims.get("org_id") if not current_org or (body.org_id and body.org_id != current_org): attached_org = await _resolve_org(body.org_id, decoded) left_org = current_org new_claims["org_id"] = attached_org new_claims["org_role"] = "member" await asyncio.to_thread(firebase_auth.set_custom_user_claims, uid, new_claims) if attached_org: await _write_membership(uid, fb_user.email, attached_org) if body.display_name is not None: await asyncio.to_thread(firebase_auth.update_user, uid, display_name=body.display_name) profile_data: dict = {"uid": uid, "role": new_role, "owned_node_ids": new_nodes} if body.display_name is not None: profile_data["display_name"] = body.display_name await fstore.doc_set("user_profiles", uid, profile_data, merge=True) await audit.write_audit( actor_uid=decoded["uid"], actor_email=decoded.get("email", ""), action="user.update", target_uid=uid, target_email=fb_user.email, details={ "old_role": current_role, "new_role": new_role, "old_nodes": current_nodes, "new_nodes": new_nodes, **({"attached_org_id": attached_org} if attached_org else {}), # The org left behind is not deleted: it may hold nodes or data. **({"left_org_id": left_org} if left_org else {}), }, ) updated: firebase_auth.UserRecord = await asyncio.to_thread(firebase_auth.get_user, uid) link = await fstore.doc_get("firebase_discord_links", uid) return _format_user(updated, link) @router.post("/{uid}/disable") async def disable_user(uid: str, decoded: dict = Depends(require_admin_token)): """Disable a user — they can no longer sign in but their data is preserved.""" if uid == decoded.get("uid"): raise HTTPException(400, "Cannot disable your own account.") try: fb_user: firebase_auth.UserRecord = await asyncio.to_thread(firebase_auth.get_user, uid) except firebase_auth.UserNotFoundError: raise HTTPException(404, "User not found.") await asyncio.to_thread(firebase_auth.update_user, uid, disabled=True) await audit.write_audit( actor_uid=decoded["uid"], actor_email=decoded.get("email", ""), action="user.disable", target_uid=uid, target_email=fb_user.email, ) return {"ok": True} @router.post("/{uid}/enable") async def enable_user(uid: str, decoded: dict = Depends(require_admin_token)): """Re-enable a previously disabled user.""" try: fb_user: firebase_auth.UserRecord = await asyncio.to_thread(firebase_auth.get_user, uid) except firebase_auth.UserNotFoundError: raise HTTPException(404, "User not found.") await asyncio.to_thread(firebase_auth.update_user, uid, disabled=False) await audit.write_audit( actor_uid=decoded["uid"], actor_email=decoded.get("email", ""), action="user.enable", target_uid=uid, target_email=fb_user.email, ) return {"ok": True} @router.delete("/{uid}") async def delete_user(uid: str, decoded: dict = Depends(require_admin_token)): """Permanently delete a user from Firebase Auth and clean up Firestore data.""" if uid == decoded.get("uid"): raise HTTPException(400, "Cannot delete your own account.") try: fb_user: firebase_auth.UserRecord = await asyncio.to_thread(firebase_auth.get_user, uid) except firebase_auth.UserNotFoundError: raise HTTPException(404, "User not found.") email = fb_user.email # Clean up Discord link if present link = await fstore.doc_get("firebase_discord_links", uid) if link and link.get("discord_user_id"): await asyncio.gather( fstore.doc_delete("discord_links", link["discord_user_id"]), fstore.doc_delete("firebase_discord_links", uid), ) # Delete Firestore profile (sessions are kept for audit history) await fstore.doc_delete("user_profiles", uid) # Delete from Firebase Auth await asyncio.to_thread(firebase_auth.delete_user, uid) await audit.write_audit( actor_uid=decoded["uid"], actor_email=decoded.get("email", ""), action="user.delete", target_uid=uid, target_email=email, ) return {"ok": True}