diff --git a/service/src/ai_document_plugin_service/api/auth.py b/service/src/ai_document_plugin_service/api/auth.py index 6e06671..48647c5 100644 --- a/service/src/ai_document_plugin_service/api/auth.py +++ b/service/src/ai_document_plugin_service/api/auth.py @@ -12,6 +12,7 @@ DSW_USER_VALIDATION_TIMEOUT_SECONDS = 10.0 DSW_USER_VALIDATION_SUCCESS_STATUS = 200 DSW_ADMIN_ROLE = 'admin' +DSW_ADMIN_PERMISSION = 'SettingsManageRolePermission' @dataclass(frozen=True) @@ -20,11 +21,7 @@ class AuthenticatedUser: api_url: str user_uuid: UUID tenant_uuid: UUID - role: str - - @property - def is_admin(self) -> bool: - return self.role.strip().lower() == DSW_ADMIN_ROLE + is_admin: bool def is_allowed_request(api_url: str, tenant_uuid: UUID, allowed_apis: tuple[AllowedApi, ...]) -> bool: @@ -77,16 +74,22 @@ def _fetch_dsw_user(api_url: str, token: str) -> dict[str, object] | None: return user -def _extract_role(user: dict[str, object], api_url: str) -> str: +def _is_admin(user: dict[str, object], api_url: str) -> bool: role = user.get('role') - if not isinstance(role, str) or not role.strip(): - msg = ( - f'Unexpected response from DSW at {api_url}: the /users/current payload ' - f'did not include a valid string "role" (got {role!r}). ' - f'The tenant may be running an incompatible DSW version. Received payload: {user}' - ) - raise ValueError(msg) - return role + if isinstance(role, str): + # DSW version < 0.4.33 + return role == DSW_ADMIN_ROLE + if isinstance(role, dict): + # DSW version >= 0.4.33 + permissions = role.get('permissions') + if isinstance(permissions, list): + return DSW_ADMIN_PERMISSION in permissions + msg = ( + f'Unexpected response from DSW at {api_url}: the /users/current payload ' + f'did not include a valid "role" string or a "role" object with a "permissions" list. ' + f'The tenant may be running an incompatible DSW version. Received payload: {user}' + ) + raise ValueError(msg) def verify_authenticated( @@ -116,11 +119,10 @@ def verify_authenticated( if user is None: raise fastapi.HTTPException(status_code=401, detail='Unauthorized') - role = _extract_role(user, normalized_api_url) return AuthenticatedUser( token=token, api_url=normalized_api_url, user_uuid=user_uuid, tenant_uuid=tenant_uuid, - role=role, + is_admin=_is_admin(user, normalized_api_url), ) diff --git a/service/tests/api/test_auth.py b/service/tests/api/test_auth.py index 6b4c3c5..ecf1411 100644 --- a/service/tests/api/test_auth.py +++ b/service/tests/api/test_auth.py @@ -113,7 +113,13 @@ def test_protected_route_succeeds_when_dsw_validates_user( mock_httpx_get.return_value = httpx.Response( 200, request=httpx.Request('GET', ALLOWED_URL), - json={'role': 'researcher'}, + json={ + 'role': { + 'name': 'Researcher', + 'permissions': ['ProjectsViewRolePermission'], + 'uuid': '31ccc093-3ab0-4459-b109-ab1d8dc2313f', + } + }, ) template_uuid = UUID('99999999-9999-9999-9999-999999999999') mock_postgres_db.return_value.list_templates = AsyncMock( @@ -137,3 +143,39 @@ def test_protected_route_succeeds_when_dsw_validates_user( assert response.status_code == 200 assert response.json() == [{'uuid': str(template_uuid), 'title': 'Template 1', 'scope': 'tenant'}] mock_httpx_get.assert_called_once() + + +@patch('ai_document_plugin_service.api.auth.httpx.get') +@patch('ai_document_plugin_service.di.PostgresDB') +def test_protected_route_succeeds_when_dsw_validates_user( + mock_postgres_db: MagicMock, + mock_httpx_get: MagicMock, + monkeypatch, +) -> None: + mock_httpx_get.return_value = httpx.Response( + 200, + request=httpx.Request('GET', ALLOWED_URL), + json={'role': 'researcher'}, # Test DSW version below 4.33 + ) + template_uuid = UUID('99999999-9999-9999-9999-999999999999') + mock_postgres_db.return_value.list_templates = AsyncMock( + return_value=[ + TemplateRecord( + uuid=template_uuid, + title='Template 1', + content={'sections': []}, + tenant_uuid=UUID(ALLOWED_TENANT_UUID), + user_uuid=None, + ), + ], + ) + mock_postgres_db.return_value.dispose = AsyncMock() + + _use_test_config(monkeypatch) + + client = TestClient(create_app(run_migrations=False)) + response = client.get('/templates', headers=_auth_headers()) + + assert response.status_code == 200 + assert response.json() == [{'uuid': str(template_uuid), 'title': 'Template 1', 'scope': 'tenant'}] + mock_httpx_get.assert_called_once() \ No newline at end of file diff --git a/service/tests/service/test_template_service.py b/service/tests/service/test_template_service.py index df6c686..21514e3 100644 --- a/service/tests/service/test_template_service.py +++ b/service/tests/service/test_template_service.py @@ -38,13 +38,13 @@ def _database() -> AsyncMock: return database -def _user(*, role: str = 'researcher', user_uuid: uuid.UUID = USER_UUID) -> AuthenticatedUser: +def _user(*, is_admin: bool = False, user_uuid: uuid.UUID = USER_UUID) -> AuthenticatedUser: return AuthenticatedUser( token='token', api_url='https://dsw.example.com/wizard-api', user_uuid=user_uuid, tenant_uuid=TENANT_UUID, - role=role, + is_admin=is_admin, ) @@ -144,7 +144,7 @@ async def test_create_tenant_template_by_admin_has_no_owner() -> None: database.create_template.return_value = template_uuid payload = TemplateCreateRequest(title='Common', content=VALID_CONTENT, scope=TemplateScope.TENANT) - detail = await TemplateService(database).create(_user(role='admin'), payload) + detail = await TemplateService(database).create(_user(is_admin=True), payload) database.create_template.assert_awaited_once_with( title='Common', @@ -160,7 +160,7 @@ async def test_create_tenant_template_by_non_admin_is_denied() -> None: payload = TemplateCreateRequest(title='Common', content=VALID_CONTENT, scope=TemplateScope.TENANT) with pytest.raises(AccessDeniedError): - await TemplateService(database).create(_user(role='researcher'), payload) + await TemplateService(database).create(_user(is_admin=False), payload) database.create_template.assert_not_awaited() @@ -172,7 +172,7 @@ async def test_create_authorizes_before_validating() -> None: payload = TemplateCreateRequest(title='', content={}, scope=TemplateScope.TENANT) with pytest.raises(AccessDeniedError): - await TemplateService(database).create(_user(role='researcher'), payload) + await TemplateService(database).create(_user(is_admin=False), payload) async def test_create_rejects_blank_title() -> None: @@ -228,7 +228,7 @@ async def test_update_tenant_template_by_admin() -> None: database.update_template.return_value = True payload = TemplateUpdateRequest(title='Renamed', content=VALID_CONTENT) - detail = await TemplateService(database).update(_user(role='admin'), template_uuid, payload) + detail = await TemplateService(database).update(_user(is_admin=True), template_uuid, payload) assert detail.scope is TemplateScope.TENANT database.update_template.assert_awaited_once() @@ -241,7 +241,7 @@ async def test_update_tenant_template_by_non_admin_is_denied() -> None: payload = TemplateUpdateRequest(title='Renamed', content=VALID_CONTENT) with pytest.raises(AccessDeniedError): - await TemplateService(database).update(_user(role='researcher'), template_uuid, payload) + await TemplateService(database).update(_user(is_admin=False), template_uuid, payload) database.update_template.assert_not_awaited() @@ -293,7 +293,7 @@ async def test_delete_tenant_template_by_admin() -> None: database = _database() database.get_template.return_value = _record(template_uuid=template_uuid, user_uuid=None) - await TemplateService(database).delete(_user(role='admin'), template_uuid) + await TemplateService(database).delete(_user(is_admin=True), template_uuid) database.delete_template.assert_awaited_once_with(template_uuid, TENANT_UUID) @@ -304,7 +304,7 @@ async def test_delete_tenant_template_by_non_admin_is_denied() -> None: database.get_template.return_value = _record(template_uuid=template_uuid, user_uuid=None) with pytest.raises(AccessDeniedError): - await TemplateService(database).delete(_user(role='researcher'), template_uuid) + await TemplateService(database).delete(_user(is_admin=False), template_uuid) database.delete_template.assert_not_awaited() @@ -325,7 +325,7 @@ async def test_delete_other_users_by_admin_is_hidden() -> None: database.get_template.return_value = _record(template_uuid=template_uuid, user_uuid=OTHER_USER_UUID) with pytest.raises(NotFoundError): - await TemplateService(database).delete(_user(role="admin"), template_uuid) + await TemplateService(database).delete(_user(is_admin=True), template_uuid) database.delete_template.assert_not_awaited()