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,26 @@
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
from uuid import uuid4
|
||||
from app.internal import firestore as fstore
|
||||
|
||||
|
||||
async def write_audit(
|
||||
actor_uid: str,
|
||||
actor_email: str,
|
||||
action: str,
|
||||
target_uid: Optional[str] = None,
|
||||
target_email: Optional[str] = None,
|
||||
details: Optional[dict] = None,
|
||||
) -> None:
|
||||
"""Write an entry to the audit_log collection."""
|
||||
doc_id = str(uuid4())
|
||||
await fstore.doc_set("audit_log", doc_id, {
|
||||
"log_id": doc_id,
|
||||
"action": action,
|
||||
"actor_uid": actor_uid,
|
||||
"actor_email": actor_email,
|
||||
"target_uid": target_uid,
|
||||
"target_email": target_email,
|
||||
"details": details or {},
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
}, merge=False)
|
||||
@@ -37,12 +37,28 @@ async def require_service_or_firebase_token(
|
||||
raise HTTPException(status_code=401, detail="Invalid or expired token")
|
||||
|
||||
|
||||
def get_role(decoded: dict) -> str:
|
||||
"""Extract the effective role from a decoded Firebase token.
|
||||
|
||||
Checks the granular ``role`` claim first, then falls back to the legacy
|
||||
``admin`` boolean so existing tokens continue to work during the transition.
|
||||
"""
|
||||
if decoded.get("role") == "admin" or decoded.get("admin"):
|
||||
return "admin"
|
||||
role = decoded.get("role", "viewer")
|
||||
return role if role in ("admin", "operator", "viewer") else "viewer"
|
||||
|
||||
|
||||
async def require_admin_token(
|
||||
credentials: Optional[HTTPAuthorizationCredentials] = Security(_bearer),
|
||||
) -> dict:
|
||||
"""Verify a Firebase ID token AND require the admin custom claim."""
|
||||
"""Verify a Firebase ID token AND require the admin role.
|
||||
|
||||
Accepts both the legacy ``admin: True`` boolean claim and the newer
|
||||
``role: "admin"`` claim so tokens issued before the role migration still work.
|
||||
"""
|
||||
decoded = await require_firebase_token(credentials)
|
||||
if not decoded.get("admin"):
|
||||
if get_role(decoded) != "admin":
|
||||
raise HTTPException(status_code=403, detail="Admin access required")
|
||||
return decoded
|
||||
|
||||
@@ -77,7 +93,7 @@ async def require_service_key_or_admin(
|
||||
decoded = firebase_auth.verify_id_token(token)
|
||||
except Exception:
|
||||
raise HTTPException(status_code=401, detail="Invalid or expired token")
|
||||
if not decoded.get("admin"):
|
||||
if get_role(decoded) != "admin":
|
||||
raise HTTPException(status_code=403, detail="Admin access required")
|
||||
return decoded
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ from app.internal.vocabulary_learner import vocabulary_induction_loop
|
||||
from app.internal.recorrelation_sweep import recorrelation_loop
|
||||
from app.config import settings
|
||||
from app.internal.auth import require_firebase_token, require_service_or_firebase_token
|
||||
from app.routers import nodes, systems, calls, upload, tokens, incidents, alerts, admin, trips, places, links
|
||||
from app.routers import nodes, systems, calls, upload, tokens, incidents, alerts, admin, trips, places, links, users
|
||||
from app.internal import firestore as fstore
|
||||
|
||||
|
||||
@@ -72,6 +72,7 @@ app.include_router(trips.router, dependencies=[Depends(require_service_or_fi
|
||||
app.include_router(places.router, dependencies=[Depends(require_service_or_firebase_token)])
|
||||
app.include_router(upload.router) # auth is per-node, handled inline
|
||||
app.include_router(admin.router) # auth is per-endpoint (read: firebase, write: admin)
|
||||
app.include_router(users.router) # auth: admin only
|
||||
app.include_router(links.router) # auth is per-endpoint (generate: firebase, resolve: service key)
|
||||
|
||||
|
||||
|
||||
@@ -5,7 +5,6 @@ from app.internal.auth import require_admin_token, require_firebase_token
|
||||
from app.internal.feature_flags import get_flags, set_flags
|
||||
from app.internal import firestore as fstore
|
||||
|
||||
|
||||
async def _get_ai_enabled_system_ids(global_flags: dict) -> set[str]:
|
||||
"""Return system_ids where at least one AI function (STT or correlation) is effectively on."""
|
||||
global_stt = global_flags.get("stt_enabled", True)
|
||||
@@ -163,3 +162,15 @@ async def debug_correlation(
|
||||
"incidents": incident_records,
|
||||
"orphaned_calls": orphans[:250],
|
||||
}
|
||||
|
||||
|
||||
@router.get("/audit")
|
||||
async def get_audit_log(
|
||||
limit: int = Query(50, ge=1, le=200),
|
||||
offset: int = Query(0, ge=0),
|
||||
_=Depends(require_admin_token),
|
||||
):
|
||||
"""Return paginated audit log entries, most recent first."""
|
||||
entries = await fstore.collection_list("audit_log")
|
||||
entries.sort(key=lambda e: e.get("timestamp", ""), reverse=True)
|
||||
return entries[offset: offset + limit]
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import random
|
||||
import string
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from fastapi import APIRouter, HTTPException, Depends
|
||||
from uuid import uuid4
|
||||
from fastapi import APIRouter, HTTPException, Depends, Request
|
||||
from pydantic import BaseModel
|
||||
from app.internal import firestore as fstore
|
||||
from app.internal.auth import require_firebase_token, require_service_key
|
||||
@@ -126,3 +127,26 @@ async def unlink(decoded: dict = Depends(require_firebase_token)):
|
||||
await fstore.doc_delete("discord_links", discord_user_id)
|
||||
await fstore.doc_delete("firebase_discord_links", firebase_uid)
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Session recording — called by the frontend on each successful sign-in
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.post("/session")
|
||||
async def record_session(request: Request, decoded: dict = Depends(require_firebase_token)):
|
||||
"""Record a sign-in event for the authenticated user."""
|
||||
session_id = str(uuid4())
|
||||
ip = request.client.host if request.client else None
|
||||
user_agent = request.headers.get("user-agent", "")
|
||||
|
||||
await fstore.doc_set("user_sessions", session_id, {
|
||||
"session_id": session_id,
|
||||
"uid": decoded["uid"],
|
||||
"email": decoded.get("email", ""),
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
"ip": ip,
|
||||
"user_agent": user_agent,
|
||||
}, merge=False)
|
||||
|
||||
return {"ok": True}
|
||||
|
||||
@@ -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