A viewer whose first login ran self-serve signup got an empty org of
their own (org_role owner), so they saw no incidents or calls, and the
earlier fix deliberately never moved a user who already had an org.
PATCH /admin/users/{uid} now moves a user when org_id is passed
explicitly (claims + org_members, audited with left_org_id; the old org
is not deleted). The user list returns org_id/org_role, and the admin
user panel shows the org and a 'Move to my organization' button when it
isn't yours.
Verified: c2-core pytest 496 passed; frontend tsc --noEmit clean.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
367 lines
14 KiB
Python
367 lines
14 KiB
Python
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}
|