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:
@@ -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}
|
||||
Reference in New Issue
Block a user