Admin-created users join an org, so viewers can actually see data
POST /admin/users set a role claim but never an org_id/org_role claim or
an org_members doc. firestore.rules gates every read on the org_id claim,
so a viewer created from the Users page saw no incidents or calls.
New users now join the requested org (default: the acting admin's own,
else 'founding') as org_role 'member', with the same org_members doc
POST /auth/signup writes. PATCH /admin/users/{uid} attaches an org-less
user the same way, which heals accounts created before this fix; it never
silently moves a user who already has an org.
Verified: c2-core pytest 495 passed.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
d18bb612df
commit
17da2ff739
@@ -0,0 +1,85 @@
|
||||
"""
|
||||
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
|
||||
assert _run("patch", "/admin/users/u1", {"org_id": "org-A"}, fb)[0].status_code == 400
|
||||
Reference in New Issue
Block a user