Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6658c26fe0 | ||
|
|
2b42e5ee9a | ||
|
|
fa41b9a30c | ||
|
|
17da2ff739 | ||
|
|
33bb60b165 |
@@ -6,6 +6,7 @@ from pydantic import BaseModel
|
|||||||
from firebase_admin import auth as firebase_auth
|
from firebase_admin import auth as firebase_auth
|
||||||
from app.internal.auth import require_admin_token
|
from app.internal.auth import require_admin_token
|
||||||
from app.internal import firestore as fstore
|
from app.internal import firestore as fstore
|
||||||
|
from app.internal.tenancy import FOUNDING_ORG_ID
|
||||||
from app.internal import audit
|
from app.internal import audit
|
||||||
|
|
||||||
router = APIRouter(prefix="/admin/users", tags=["users"])
|
router = APIRouter(prefix="/admin/users", tags=["users"])
|
||||||
@@ -22,9 +23,13 @@ class UserCreate(BaseModel):
|
|||||||
role: str = "viewer"
|
role: str = "viewer"
|
||||||
display_name: Optional[str] = None
|
display_name: Optional[str] = None
|
||||||
owned_node_ids: list[str] = []
|
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):
|
class UserUpdate(BaseModel):
|
||||||
|
org_id: Optional[str] = None # attach an org-less user; defaults to the admin's org
|
||||||
role: Optional[str] = None
|
role: Optional[str] = None
|
||||||
owned_node_ids: Optional[list[str]] = None
|
owned_node_ids: Optional[list[str]] = None
|
||||||
display_name: Optional[str] = None
|
display_name: Optional[str] = None
|
||||||
@@ -34,6 +39,32 @@ class UserUpdate(BaseModel):
|
|||||||
# Helpers
|
# 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]:
|
def _ms_to_iso(ms: Optional[int]) -> Optional[str]:
|
||||||
if ms is None:
|
if ms is None:
|
||||||
return None
|
return None
|
||||||
@@ -66,6 +97,9 @@ def _format_user(fb_user: firebase_auth.UserRecord, link: Optional[dict] = None)
|
|||||||
"discord_linked": bool(link and link.get("discord_user_id")),
|
"discord_linked": bool(link and link.get("discord_user_id")),
|
||||||
"discord_username": link.get("discord_username") if link else None,
|
"discord_username": link.get("discord_username") if link else None,
|
||||||
"discord_user_id": link.get("discord_user_id") 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"),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -101,6 +135,7 @@ async def create_user(body: UserCreate, decoded: dict = Depends(require_admin_to
|
|||||||
raise HTTPException(400, f"Invalid role. Must be one of: {', '.join(sorted(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:
|
if body.role == "operator" and not body.owned_node_ids:
|
||||||
raise HTTPException(400, "Operator role requires at least one owned node.")
|
raise HTTPException(400, "Operator role requires at least one owned node.")
|
||||||
|
org_id = await _resolve_org(body.org_id, decoded)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
fb_user: firebase_auth.UserRecord = await asyncio.to_thread(
|
fb_user: firebase_auth.UserRecord = await asyncio.to_thread(
|
||||||
@@ -115,10 +150,11 @@ async def create_user(body: UserCreate, decoded: dict = Depends(require_admin_to
|
|||||||
raise HTTPException(400, f"Failed to create user: {e}")
|
raise HTTPException(400, f"Failed to create user: {e}")
|
||||||
|
|
||||||
# Set custom claims
|
# Set custom claims
|
||||||
claims: dict = {"role": body.role, "owned_node_ids": body.owned_node_ids}
|
claims: dict = {"role": body.role, "owned_node_ids": body.owned_node_ids, "org_id": org_id, "org_role": "member"}
|
||||||
if body.role == "admin":
|
if body.role == "admin":
|
||||||
claims["admin"] = True
|
claims["admin"] = True
|
||||||
await asyncio.to_thread(firebase_auth.set_custom_user_claims, fb_user.uid, claims)
|
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
|
# Write Firestore profile
|
||||||
now = datetime.now(timezone.utc).isoformat()
|
now = datetime.now(timezone.utc).isoformat()
|
||||||
@@ -145,7 +181,7 @@ async def create_user(body: UserCreate, decoded: dict = Depends(require_admin_to
|
|||||||
action="user.create",
|
action="user.create",
|
||||||
target_uid=fb_user.uid,
|
target_uid=fb_user.uid,
|
||||||
target_email=body.email,
|
target_email=body.email,
|
||||||
details={"role": body.role, "owned_node_ids": body.owned_node_ids},
|
details={"role": body.role, "owned_node_ids": body.owned_node_ids, "org_id": org_id},
|
||||||
)
|
)
|
||||||
|
|
||||||
return {**_format_user(fb_user), "invite_link": invite_link}
|
return {**_format_user(fb_user), "invite_link": invite_link}
|
||||||
@@ -197,7 +233,26 @@ async def update_user(uid: str, body: UserUpdate, decoded: dict = Depends(requir
|
|||||||
else:
|
else:
|
||||||
new_claims.pop("admin", None)
|
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)
|
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:
|
if body.display_name is not None:
|
||||||
await asyncio.to_thread(firebase_auth.update_user, uid, display_name=body.display_name)
|
await asyncio.to_thread(firebase_auth.update_user, uid, display_name=body.display_name)
|
||||||
@@ -218,6 +273,9 @@ async def update_user(uid: str, body: UserUpdate, decoded: dict = Depends(requir
|
|||||||
"new_role": new_role,
|
"new_role": new_role,
|
||||||
"old_nodes": current_nodes,
|
"old_nodes": current_nodes,
|
||||||
"new_nodes": new_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 {}),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
"""
|
||||||
|
Admin-created users must land in an org. firestore.rules gates every read on
|
||||||
|
the org_id claim, so a viewer created via POST /admin/users without one saw no
|
||||||
|
incidents or calls at all (reported 2026-09-27).
|
||||||
|
"""
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from unittest.mock import AsyncMock, patch
|
||||||
|
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from app.main import app
|
||||||
|
from app.internal.auth import require_admin_token
|
||||||
|
from app.routers import users
|
||||||
|
|
||||||
|
client = TestClient(app)
|
||||||
|
ADMIN = {"uid": "admin-1", "email": "a@x", "role": "admin", "org_id": "org-A"}
|
||||||
|
|
||||||
|
|
||||||
|
def _as(decoded):
|
||||||
|
app.dependency_overrides[require_admin_token] = lambda: decoded
|
||||||
|
|
||||||
|
|
||||||
|
def teardown_function():
|
||||||
|
app.dependency_overrides.pop(require_admin_token, None)
|
||||||
|
|
||||||
|
|
||||||
|
def _fb(**kw):
|
||||||
|
base = dict(uid="u1", email="v@x", display_name="", custom_claims={}, disabled=False,
|
||||||
|
email_verified=False, user_metadata=SimpleNamespace(creation_timestamp=0, last_sign_in_timestamp=None))
|
||||||
|
return SimpleNamespace(**{**base, **kw})
|
||||||
|
|
||||||
|
|
||||||
|
def _run(method, path, body, fb_user, orgs=("org-A", "founding")):
|
||||||
|
fa = users.firebase_auth
|
||||||
|
with patch.object(fa, "create_user", return_value=fb_user, create=True), \
|
||||||
|
patch.object(fa, "get_user", return_value=fb_user, create=True), \
|
||||||
|
patch.object(fa, "set_custom_user_claims", create=True) as set_claims, \
|
||||||
|
patch.object(fa, "generate_password_reset_link", return_value="link", create=True), \
|
||||||
|
patch.object(users.fstore, "doc_get", AsyncMock(side_effect=lambda c, i: {"org_id": i} if c == "organizations" and i in orgs else None)), \
|
||||||
|
patch.object(users.fstore, "doc_set", AsyncMock()) as doc_set, \
|
||||||
|
patch.object(users.audit, "write_audit", AsyncMock()):
|
||||||
|
resp = getattr(client, method)(path, json=body)
|
||||||
|
members = [c for c in doc_set.await_args_list if c.args[0] == "org_members"]
|
||||||
|
return resp, set_claims, members
|
||||||
|
|
||||||
|
|
||||||
|
def test_created_viewer_joins_the_admins_org_as_member():
|
||||||
|
_as(ADMIN)
|
||||||
|
resp, set_claims, members = _run("post", "/admin/users", {"email": "v@x", "role": "viewer"}, _fb())
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
claims = set_claims.call_args.args[1]
|
||||||
|
assert (claims["org_id"], claims["org_role"], claims["role"]) == ("org-A", "member", "viewer")
|
||||||
|
assert members and members[0].args[2]["org_id"] == "org-A"
|
||||||
|
|
||||||
|
|
||||||
|
def test_admin_without_an_org_claim_defaults_to_founding():
|
||||||
|
_as({k: v for k, v in ADMIN.items() if k != "org_id"})
|
||||||
|
resp, set_claims, _ = _run("post", "/admin/users", {"email": "v@x", "role": "viewer"}, _fb())
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
assert set_claims.call_args.args[1]["org_id"] == "founding"
|
||||||
|
|
||||||
|
|
||||||
|
def test_unknown_org_is_rejected():
|
||||||
|
_as(ADMIN)
|
||||||
|
resp, set_claims, _ = _run("post", "/admin/users", {"email": "v@x", "role": "viewer", "org_id": "nope"}, _fb())
|
||||||
|
assert resp.status_code == 400
|
||||||
|
set_claims.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
def test_editing_an_orgless_user_heals_them():
|
||||||
|
_as(ADMIN)
|
||||||
|
resp, set_claims, members = _run("patch", "/admin/users/u1", {"role": "viewer"}, _fb(custom_claims={"role": "viewer"}))
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
assert set_claims.call_args.args[1]["org_id"] == "org-A"
|
||||||
|
assert members
|
||||||
|
|
||||||
|
|
||||||
|
def test_editing_never_silently_moves_an_existing_org():
|
||||||
|
_as(ADMIN)
|
||||||
|
fb = _fb(custom_claims={"role": "viewer", "org_id": "org-B", "org_role": "member"})
|
||||||
|
resp, set_claims, members = _run("patch", "/admin/users/u1", {"role": "viewer"}, fb)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert set_claims.call_args.args[1]["org_id"] == "org-B"
|
||||||
|
assert not members
|
||||||
|
|
||||||
|
|
||||||
|
def test_explicit_org_id_moves_a_self_provisioned_owner_into_the_network():
|
||||||
|
_as(ADMIN)
|
||||||
|
fb = _fb(custom_claims={"role": "viewer", "org_id": "own-empty-org", "org_role": "owner"})
|
||||||
|
resp, set_claims, members = _run("patch", "/admin/users/u1", {"org_id": "org-A"}, fb)
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
claims = set_claims.call_args.args[1]
|
||||||
|
assert (claims["org_id"], claims["org_role"]) == ("org-A", "member")
|
||||||
|
assert members and members[0].args[2]["org_id"] == "org-A"
|
||||||
@@ -367,6 +367,26 @@ function UserDetailPanel({
|
|||||||
const [deleting, setDeleting] = useState(false);
|
const [deleting, setDeleting] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [showSessions, setShowSessions] = useState(false);
|
const [showSessions, setShowSessions] = useState(false);
|
||||||
|
const { orgId: myOrgId } = useAuth();
|
||||||
|
const [moving, setMoving] = useState(false);
|
||||||
|
|
||||||
|
// A user outside the admin's org reads none of its incidents or calls —
|
||||||
|
// e.g. a viewer whose first login self-provisioned an empty org.
|
||||||
|
async function handleMoveToMyOrg() {
|
||||||
|
if (!myOrgId) return;
|
||||||
|
if (!confirm(`Move ${detail.email ?? "this user"} into your organization as a member? They'll need to sign out and back in.`)) return;
|
||||||
|
setMoving(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const updated = await c2api.updateUser(user.uid, { org_id: myOrgId });
|
||||||
|
onUpdated(updated);
|
||||||
|
setDetail((d) => ({ ...d, ...updated }));
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : String(e));
|
||||||
|
} finally {
|
||||||
|
setMoving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Fetch full detail (sessions) lazily
|
// Fetch full detail (sessions) lazily
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -496,6 +516,25 @@ function UserDetailPanel({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="border-t border-gray-800 pt-4 space-y-2 text-xs">
|
<div className="border-t border-gray-800 pt-4 space-y-2 text-xs">
|
||||||
|
<div className="flex justify-between items-center gap-3">
|
||||||
|
<span className="text-gray-500">Organization</span>
|
||||||
|
<span className={`font-mono truncate ${detail.org_id && detail.org_id === myOrgId ? "text-gray-300" : "text-yellow-400"}`}>
|
||||||
|
{!detail.org_id
|
||||||
|
? "None: sees no data"
|
||||||
|
: detail.org_id === myOrgId
|
||||||
|
? `Your org (${detail.org_role ?? "member"})`
|
||||||
|
: `Other org ${detail.org_id.slice(0, 8)}… (${detail.org_role ?? "member"})`}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{myOrgId && detail.org_id !== myOrgId && (
|
||||||
|
<button
|
||||||
|
onClick={handleMoveToMyOrg}
|
||||||
|
disabled={moving}
|
||||||
|
className="w-full bg-yellow-900/60 hover:bg-yellow-800/60 disabled:opacity-50 text-yellow-200 px-3 py-1.5 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
{moving ? "Moving…" : "Move to my organization"}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
<span className="text-gray-500">Status</span>
|
<span className="text-gray-500">Status</span>
|
||||||
<span className={detail.disabled ? "text-red-400" : "text-green-400"}>
|
<span className={detail.disabled ? "text-red-400" : "text-green-400"}>
|
||||||
|
|||||||
@@ -328,7 +328,10 @@ export const c2api = {
|
|||||||
}),
|
}),
|
||||||
getUser: (uid: string) =>
|
getUser: (uid: string) =>
|
||||||
request<import("@/lib/types").UserRecord>(`/admin/users/${uid}`),
|
request<import("@/lib/types").UserRecord>(`/admin/users/${uid}`),
|
||||||
updateUser: (uid: string, body: { role?: string; owned_node_ids?: string[]; display_name?: string }) =>
|
updateUser: (
|
||||||
|
uid: string,
|
||||||
|
body: { role?: string; owned_node_ids?: string[]; display_name?: string; org_id?: string },
|
||||||
|
) =>
|
||||||
request<import("@/lib/types").UserRecord>(`/admin/users/${uid}`, {
|
request<import("@/lib/types").UserRecord>(`/admin/users/${uid}`, {
|
||||||
method: "PATCH",
|
method: "PATCH",
|
||||||
body: JSON.stringify(body),
|
body: JSON.stringify(body),
|
||||||
|
|||||||
@@ -14,6 +14,9 @@ export interface UserRecord {
|
|||||||
discord_linked: boolean;
|
discord_linked: boolean;
|
||||||
discord_username: string | null;
|
discord_username: string | null;
|
||||||
discord_user_id: string | null;
|
discord_user_id: string | null;
|
||||||
|
/** The org whose data this user can read; null = none (sees nothing). */
|
||||||
|
org_id?: string | null;
|
||||||
|
org_role?: "owner" | "member" | null;
|
||||||
// only present on GET /admin/users/{uid}
|
// only present on GET /admin/users/{uid}
|
||||||
sessions?: UserSession[];
|
sessions?: UserSession[];
|
||||||
// only present on POST /admin/users response
|
// only present on POST /admin/users response
|
||||||
|
|||||||
Reference in New Issue
Block a user