Skip to content

Commit 201e6e8

Browse files
authored
Merge pull request #820 from dataelement/agent/debugger/9d168ae7
fix: prevent cross-tenant organization user updates
2 parents 2b303a0 + f04fe66 commit 201e6e8

2 files changed

Lines changed: 115 additions & 3 deletions

File tree

backend/app/api/organization.py

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,11 @@
1818
router = APIRouter(prefix="/org", tags=["organization"])
1919

2020

21+
def _is_platform_admin(user: User) -> bool:
22+
"""Return whether the caller has platform-wide administrative authority."""
23+
return user.role == "platform_admin" or bool(getattr(user.identity, "is_platform_admin", False))
24+
25+
2126
# ─── Users Management ──────────────────────────────────
2227

2328
@router.get("/users", response_model=list[UserOut])
@@ -30,11 +35,11 @@ async def list_users(
3035
query = (
3136
select(User)
3237
.options(selectinload(User.identity))
33-
.where(User.is_active == True)
38+
.where(User.is_active)
3439
)
3540

3641
target_tenant_id = current_user.tenant_id
37-
if current_user.role in ("platform_admin", "org_admin") and tenant_id:
42+
if _is_platform_admin(current_user) and tenant_id:
3843
target_tenant_id = tenant_id
3944
if target_tenant_id:
4045
query = query.where(User.tenant_id == target_tenant_id)
@@ -52,17 +57,31 @@ async def admin_update_user(
5257
db: Any = None,
5358
):
5459
"""Admin update user profile."""
55-
result = await query_dao.execute(db,
60+
query = (
5661
select(User)
5762
.options(selectinload(User.identity))
5863
.where(User.id == user_id)
5964
)
65+
if not _is_platform_admin(current_user):
66+
query = query.where(User.tenant_id == current_user.tenant_id)
67+
68+
result = await query_dao.execute(db, query)
6069
user = result.scalar_one_or_none()
6170
if not user:
6271
raise HTTPException(status_code=404, detail="User not found")
6372

6473
update_data = data.model_dump(exclude_unset=True)
6574

75+
# Email is stored on the globally shared Identity rather than the tenant User.
76+
# An organization administrator must not be able to alter another member's
77+
# login and password-reset address, even if that member belongs to this tenant.
78+
if (
79+
"email" in update_data
80+
and not _is_platform_admin(current_user)
81+
and user.identity_id != current_user.identity_id
82+
):
83+
raise HTTPException(status_code=403, detail="Cannot modify another user's login email")
84+
6685
# Validate email uniqueness within tenant if changing
6786
if "email" in update_data and update_data["email"] != user.email:
6887
existing = await query_dao.execute(db,
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
import uuid
2+
from types import SimpleNamespace
3+
4+
import pytest
5+
from fastapi import HTTPException
6+
7+
from app.api import organization
8+
from app.schemas.schemas import UserUpdate
9+
10+
11+
class DummyResult:
12+
def __init__(self, value=None):
13+
self.value = value
14+
15+
def scalar_one_or_none(self):
16+
return self.value
17+
18+
def scalars(self):
19+
return self
20+
21+
def all(self):
22+
return []
23+
24+
25+
class RecordingDB:
26+
def __init__(self, responses):
27+
self.responses = list(responses)
28+
self.statements = []
29+
30+
async def execute(self, statement):
31+
self.statements.append(statement)
32+
return self.responses.pop(0)
33+
34+
35+
def _org_admin(*, tenant_id: uuid.UUID, identity_id: uuid.UUID) -> SimpleNamespace:
36+
return SimpleNamespace(
37+
role="org_admin",
38+
tenant_id=tenant_id,
39+
identity_id=identity_id,
40+
identity=SimpleNamespace(is_platform_admin=False),
41+
)
42+
43+
44+
@pytest.mark.asyncio
45+
async def test_org_admin_cannot_load_user_from_another_tenant_for_update() -> None:
46+
tenant_id = uuid.uuid4()
47+
db = RecordingDB([DummyResult()])
48+
49+
with pytest.raises(HTTPException) as raised:
50+
await organization.admin_update_user(
51+
user_id=uuid.uuid4(),
52+
data=UserUpdate(display_name="Changed"),
53+
current_user=_org_admin(tenant_id=tenant_id, identity_id=uuid.uuid4()),
54+
db=db,
55+
)
56+
57+
assert raised.value.status_code == 404
58+
assert "users.tenant_id" in str(db.statements[0])
59+
60+
61+
@pytest.mark.asyncio
62+
async def test_org_admin_cannot_list_users_from_another_tenant() -> None:
63+
tenant_id = uuid.uuid4()
64+
requested_tenant_id = uuid.uuid4()
65+
db = RecordingDB([DummyResult()])
66+
67+
users = await organization.list_users(
68+
tenant_id=requested_tenant_id,
69+
current_user=_org_admin(tenant_id=tenant_id, identity_id=uuid.uuid4()),
70+
db=db,
71+
)
72+
73+
assert users == []
74+
assert db.statements[0].compile().params["tenant_id_1"] == tenant_id
75+
76+
77+
@pytest.mark.asyncio
78+
async def test_org_admin_cannot_change_another_members_global_login_email() -> None:
79+
tenant_id = uuid.uuid4()
80+
current_identity_id = uuid.uuid4()
81+
target = SimpleNamespace(id=uuid.uuid4(), tenant_id=tenant_id, identity_id=uuid.uuid4())
82+
db = RecordingDB([DummyResult(target)])
83+
84+
with pytest.raises(HTTPException) as raised:
85+
await organization.admin_update_user(
86+
user_id=target.id,
87+
data=UserUpdate(email="new-address@example.com"),
88+
current_user=_org_admin(tenant_id=tenant_id, identity_id=current_identity_id),
89+
db=db,
90+
)
91+
92+
assert raised.value.status_code == 403
93+
assert raised.value.detail == "Cannot modify another user's login email"

0 commit comments

Comments
 (0)