feat: add role-based user management, audit log, and session tracking

Introduces a full user management system with three roles (admin, operator,
viewer), an audit log, and per-session login history.

Backend:
- app/internal/audit.py: write_audit() helper → audit_log Firestore collection
- app/internal/auth.py: get_role() helper; require_admin_token accepts both
  legacy admin:true claim and new role:"admin" claim for backward compat
- app/routers/users.py: CRUD under /admin/users — list, create (returns
  one-time invite link), get (with sessions), patch role/nodes/name,
  disable, enable, delete; operator role requires ≥1 owned node
- app/routers/links.py: POST /auth/session records sign-in events to
  user_sessions Firestore collection
- app/routers/admin.py: GET /admin/audit paginated endpoint
- app/main.py: register users router

Frontend:
- AuthProvider: exposes role, isAdmin, isOperator, ownedNodeIds from claims
- Nav: role-gated links — viewers get dashboard/calls/incidents/map/alerts/
  trips; operators add nodes/systems/tokens; admins add admin
- admin/page.tsx: new Users tab (list table, create modal, inline edit panel
  with role/nodes editor, disable/enable/delete, login history) and Audit
  Log tab (paginated, color-coded actions)
- login/page.tsx: calls recordSession() on email and Google sign-in
- nodes, systems, tokens pages: role guards redirect viewers to dashboard
- profile/page.tsx: shows accurate role badge and label
- lib/types.ts: UserRole, UserRecord, UserSession, AuditEntry types
- lib/c2api.ts: user management methods + recordSession

Firestore collections added: user_profiles, audit_log, user_sessions
Firebase custom claims schema: { role, owned_node_ids, admin (legacy) }
This commit is contained in:
Logan
2026-06-22 00:02:09 -04:00
parent 961cc6f36e
commit 1f17b6c0d2
16 changed files with 1261 additions and 148 deletions
+308
View File
@@ -0,0 +1,308 @@
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 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] = []
class UserUpdate(BaseModel):
role: Optional[str] = None
owned_node_ids: Optional[list[str]] = None
display_name: Optional[str] = None
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
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,
}
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.")
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}
if body.role == "admin":
claims["admin"] = True
await asyncio.to_thread(firebase_auth.set_custom_user_claims, fb_user.uid, claims)
# 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},
)
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)
await asyncio.to_thread(firebase_auth.set_custom_user_claims, uid, new_claims)
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,
},
)
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}