""" Organization-scoped routes. Two things live here: 1. Org profile (name) — closes the "Save changes" button that's been disabled in app/settings/organization since there was no organizations concept server-side to save into (see DEFERRED.md, now resolved). 2. Per-org enrollment tokens (SAAS_PLAN.md B2b) — the credential that lets a customer's own node join THEIR org specifically. Before this, every node enrolled with the same fleet-wide ENROLLMENT_TOKEN (routers/enrollment.py), which had no way to say which org a newly enrolled node belonged to — every node landed in the same pool. """ import hashlib import secrets from datetime import datetime, timezone from fastapi import APIRouter, HTTPException, Depends from pydantic import BaseModel from app.internal import firestore as fstore from app.internal.auth import require_firebase_token, require_org, require_org_owner_token from app.internal.logger import logger router = APIRouter(prefix="/org", tags=["org"]) def _hash_token(token: str) -> str: return hashlib.sha256(token.encode()).hexdigest() # --------------------------------------------------------------------------- # Org profile # --------------------------------------------------------------------------- @router.get("") async def get_org(decoded: dict = Depends(require_firebase_token)): """Any member of the org (owner or member) can read the org profile.""" org_id = require_org(decoded) org = await fstore.doc_get("organizations", org_id) if not org: raise HTTPException(404, "Organization not found.") return org class OrgUpdateBody(BaseModel): name: str @router.patch("") async def update_org(body: OrgUpdateBody, decoded: dict = Depends(require_org_owner_token)): org_id = require_org(decoded) name = body.name.strip() if not name: raise HTTPException(400, "name must not be empty.") await fstore.doc_update("organizations", org_id, {"name": name}) return {"ok": True, "name": name} # --------------------------------------------------------------------------- # Enrollment tokens — mint/list/revoke. Minting and revoking are owner-only # (this is fleet-security-sensitive, same tier as node approval); any org # member can list them (metadata only, never the raw value) since anyone on # the team might be the one physically standing up the next node. # --------------------------------------------------------------------------- class MintTokenBody(BaseModel): label: str class MintTokenResponse(BaseModel): token_id: str token: str # raw value — returned exactly once, never again, never stored label: str @router.post("/enrollment-tokens", response_model=MintTokenResponse) async def mint_enrollment_token(body: MintTokenBody, decoded: dict = Depends(require_org_owner_token)): org_id = require_org(decoded) label = body.label.strip() or "Unnamed token" raw = secrets.token_hex(24) token_hash = _hash_token(raw) now = datetime.now(timezone.utc).isoformat() # Doc id IS the hash (matches enrollment.py's pickup_secret_hash pattern) — # also stored as a field so list/delete below don't need a second lookup. await fstore.doc_set("enrollment_tokens", token_hash, { "token_hash": token_hash, "org_id": org_id, "label": label, "created_at": now, "created_by_uid": decoded.get("uid"), "revoked": False, "uses": 0, }, merge=False) logger.info(f"Enrollment token minted for org={org_id!r} label={label!r} by uid={decoded.get('uid')}") return MintTokenResponse(token_id=token_hash, token=raw, label=label) @router.get("/enrollment-tokens") async def list_enrollment_tokens(decoded: dict = Depends(require_firebase_token)): org_id = require_org(decoded) tokens = await fstore.collection_list("enrollment_tokens", org_id=org_id) return [ { "token_id": t.get("token_hash"), "label": t.get("label"), "created_at": t.get("created_at"), "revoked": t.get("revoked", False), "uses": t.get("uses", 0), } for t in tokens ] @router.delete("/enrollment-tokens/{token_id}") async def revoke_enrollment_token(token_id: str, decoded: dict = Depends(require_org_owner_token)): org_id = require_org(decoded) doc = await fstore.doc_get("enrollment_tokens", token_id) if not doc or doc.get("org_id") != org_id: raise HTTPException(404, "Enrollment token not found.") await fstore.doc_update("enrollment_tokens", token_id, {"revoked": True}) logger.info(f"Enrollment token revoked: org={org_id!r} token_id={token_id}") return {"ok": True}