Skip to content

Commit 30f6831

Browse files
committed
Take account of admin/editor roles conferred by group membership
Fixes /projects?only_editable=true returning UPDATE:false for users who hold the role via a group rather than direct team membership.
1 parent 0ccdee7 commit 30f6831

3 files changed

Lines changed: 185 additions & 3 deletions

File tree

validation_service_api/validation_service/auth.py

Lines changed: 35 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,7 @@ def __init__(self, token, allow_anonymous=False):
8888
self.token = token
8989
self._identity = None
9090
self._teams = None
91+
self._groups = None
9192
self._collab_info = {}
9293
self._connection_error = False
9394
self.username = None
@@ -111,6 +112,13 @@ def get_identity(self):
111112
self.username = username
112113
return self._identity
113114

115+
def get_groups(self):
116+
if self._groups is None:
117+
payload = _decode_jwt_payload(self.token.credentials)
118+
raw = payload.get("group") or payload.get("groups") or []
119+
self._groups = set(raw) if isinstance(raw, list) else set()
120+
return self._groups
121+
114122
async def _has_role(self, role, collab_name, client):
115123
roles_url = f"{settings.EBRAINS_IDM_API_URL}/teams/{collab_name}/{role}/users"
116124
headers = {"Authorization": f"Bearer {self.token.credentials}"}
@@ -123,6 +131,32 @@ async def _has_role(self, role, collab_name, client):
123131
return True
124132
return False
125133

134+
async def _has_role_via_group(self, role, collab_name, client):
135+
user_groups = self.get_groups()
136+
if not user_groups:
137+
return False
138+
url = f"{settings.EBRAINS_IDM_API_URL}/teams/{collab_name}/{role}/groups"
139+
headers = {"Authorization": f"Bearer {self.token.credentials}"}
140+
try:
141+
res = await client.get(url, headers=headers,
142+
timeout=settings.AUTHENTICATION_TIMEOUT)
143+
if res.status_code == 404:
144+
return False
145+
res.raise_for_status()
146+
except Exception as err:
147+
logger.warning("Group role check failed for %s/%s: %s",
148+
collab_name, role, err)
149+
return False
150+
role_group_names = {g.get("name") for g in res.json() if isinstance(g, dict)}
151+
return bool(user_groups & role_group_names)
152+
153+
async def _user_has_role(self, role, collab_name, client):
154+
direct, via_group = await asyncio.gather(
155+
self._has_role(role, collab_name, client),
156+
self._has_role_via_group(role, collab_name, client),
157+
)
158+
return direct or via_group
159+
126160
async def get_teams(self):
127161
if self._teams is None:
128162
identity = self.get_identity()
@@ -144,9 +178,8 @@ async def get_teams(self):
144178
)
145179
)
146180
for role in ("administrator", "editor"):
147-
# todo: get groups as well and check for group membership
148181
async with AsyncClient() as client:
149-
found = await asyncio.gather(*[self._has_role(role, collab_name, client) for collab_name in collab_names])
182+
found = await asyncio.gather(*[self._user_has_role(role, collab_name, client) for collab_name in collab_names])
150183
for include, collab_name in zip(found, collab_names.copy()):
151184
if include:
152185
self._teams.append(f"collab-{collab_name}-{role}")

validation_service_api/validation_service/tests/fixtures.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,11 @@
1818
not os.environ.get("VF_TEST_TOKEN"), reason="VF_TEST_TOKEN not set"
1919
)
2020

21+
requires_group_token = pytest.mark.skipif(
22+
not os.environ.get("VF_GROUP_TEST_TOKEN"),
23+
reason="VF_GROUP_TEST_TOKEN not set",
24+
)
25+
2126
client = TestClient(app)
2227
token = HTTPAuthorizationCredentials(
2328
scheme="Bearer",

validation_service_api/validation_service/tests/test_user.py

Lines changed: 145 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,16 @@
44
Note: These will fail if the test user's permissions are too elevated.
55
"""
66

7+
import base64
8+
import json
79
import os
10+
from unittest.mock import AsyncMock, MagicMock
811

912
from fastapi.security import HTTPAuthorizationCredentials
1013
import pytest
1114

1215
from ..auth import User
13-
from .fixtures import requires_token
16+
from .fixtures import requires_group_token, requires_token
1417

1518

1619

@@ -19,6 +22,18 @@
1922
)
2023

2124

25+
def _make_token(payload: dict) -> HTTPAuthorizationCredentials:
26+
"""Build a syntactically-valid JWT whose payload decodes to the given dict.
27+
Not signed — only suitable for tests that exercise _decode_jwt_payload."""
28+
def b64(b: bytes) -> str:
29+
return base64.urlsafe_b64encode(b).rstrip(b"=").decode()
30+
header = b64(b'{"alg":"none","typ":"JWT"}')
31+
body = b64(json.dumps(payload).encode())
32+
return HTTPAuthorizationCredentials(
33+
scheme="Bearer", credentials=f"{header}.{body}.sig"
34+
)
35+
36+
2237
@pytest.mark.asyncio
2338
@requires_token
2439
async def test_user__is_admin():
@@ -60,3 +75,132 @@ async def test_can_edit_collab():
6075
user = User(token, allow_anonymous=False)
6176
can_edit = await user.can_edit_collab("model-validation")
6277
assert not can_edit
78+
79+
80+
# --- Unit tests for group-based role resolution (no live IDM calls) ---
81+
82+
83+
def test_get_groups_from_jwt():
84+
user = User(
85+
_make_token({"sub": "1", "preferred_username": "alice",
86+
"group": ["unit-x", "unit-y"]}),
87+
allow_anonymous=False,
88+
)
89+
assert user.get_groups() == {"unit-x", "unit-y"}
90+
91+
92+
def test_get_groups_absent_claim_returns_empty_set():
93+
user = User(
94+
_make_token({"sub": "1", "preferred_username": "alice"}),
95+
allow_anonymous=False,
96+
)
97+
assert user.get_groups() == set()
98+
99+
100+
def _mock_client(*, json_data=None, status_code=200):
101+
resp = MagicMock()
102+
resp.status_code = status_code
103+
resp.json.return_value = json_data or []
104+
resp.raise_for_status = MagicMock()
105+
client = MagicMock()
106+
client.get = AsyncMock(return_value=resp)
107+
return client
108+
109+
110+
@pytest.mark.asyncio
111+
async def test_has_role_via_group_matches():
112+
user = User(
113+
_make_token({"sub": "1", "preferred_username": "alice",
114+
"group": ["unit-x"]}),
115+
allow_anonymous=False,
116+
)
117+
client = _mock_client(json_data=[{"name": "unit-x"}, {"name": "other"}])
118+
assert await user._has_role_via_group("administrator", "foo", client) is True
119+
120+
121+
@pytest.mark.asyncio
122+
async def test_has_role_via_group_no_intersection():
123+
user = User(
124+
_make_token({"sub": "1", "preferred_username": "alice",
125+
"group": ["unit-z"]}),
126+
allow_anonymous=False,
127+
)
128+
client = _mock_client(json_data=[{"name": "unit-x"}])
129+
assert await user._has_role_via_group("administrator", "foo", client) is False
130+
131+
132+
@pytest.mark.asyncio
133+
async def test_has_role_via_group_empty_groups_skips_call():
134+
user = User(
135+
_make_token({"sub": "1", "preferred_username": "alice"}),
136+
allow_anonymous=False,
137+
)
138+
client = _mock_client()
139+
assert await user._has_role_via_group("administrator", "foo", client) is False
140+
client.get.assert_not_called()
141+
142+
143+
@pytest.mark.asyncio
144+
async def test_has_role_via_group_404_returns_false():
145+
user = User(
146+
_make_token({"sub": "1", "preferred_username": "alice",
147+
"group": ["unit-x"]}),
148+
allow_anonymous=False,
149+
)
150+
client = _mock_client(status_code=404)
151+
assert await user._has_role_via_group("administrator", "foo", client) is False
152+
153+
154+
@pytest.mark.asyncio
155+
async def test_user_has_role_direct_only():
156+
user = User(
157+
_make_token({"sub": "1", "preferred_username": "alice"}),
158+
allow_anonymous=False,
159+
)
160+
user._has_role = AsyncMock(return_value=True)
161+
user._has_role_via_group = AsyncMock(return_value=False)
162+
assert await user._user_has_role("administrator", "foo", MagicMock()) is True
163+
164+
165+
@pytest.mark.asyncio
166+
async def test_user_has_role_via_group_only():
167+
user = User(
168+
_make_token({"sub": "1", "preferred_username": "alice",
169+
"group": ["unit-x"]}),
170+
allow_anonymous=False,
171+
)
172+
user._has_role = AsyncMock(return_value=False)
173+
user._has_role_via_group = AsyncMock(return_value=True)
174+
assert await user._user_has_role("administrator", "foo", MagicMock()) is True
175+
176+
177+
@pytest.mark.asyncio
178+
async def test_user_has_role_neither():
179+
user = User(
180+
_make_token({"sub": "1", "preferred_username": "alice"}),
181+
allow_anonymous=False,
182+
)
183+
user._has_role = AsyncMock(return_value=False)
184+
user._has_role_via_group = AsyncMock(return_value=False)
185+
assert await user._user_has_role("administrator", "foo", MagicMock()) is False
186+
187+
188+
# --- Integration test: requires a token whose holder gets admin via a group ---
189+
190+
191+
@pytest.mark.asyncio
192+
@requires_group_token
193+
async def test_user_teams_includes_group_conferred_admin():
194+
"""With a token from a user who holds administrator on a collab via group
195+
membership (e.g. closed-loop-motor-t3-4), the collab should be reported as
196+
administrator, not viewer."""
197+
group_token = HTTPAuthorizationCredentials(
198+
scheme="Bearer",
199+
credentials=os.environ["VF_GROUP_TEST_TOKEN"],
200+
)
201+
expected_collab = os.environ.get(
202+
"VF_GROUP_TEST_COLLAB", "closed-loop-motor-t3-4"
203+
)
204+
user = User(group_token, allow_anonymous=False)
205+
teams = await user.get_teams()
206+
assert f"collab-{expected_collab}-administrator" in teams

0 commit comments

Comments
 (0)