Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 18 additions & 16 deletions service/src/ai_document_plugin_service/api/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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:
Expand Down Expand Up @@ -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):
Comment thread
hranasit marked this conversation as resolved.
# 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(
Expand Down Expand Up @@ -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),
)
44 changes: 43 additions & 1 deletion service/tests/api/test_auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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()
20 changes: 10 additions & 10 deletions service/tests/service/test_template_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)


Expand Down Expand Up @@ -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',
Expand All @@ -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()

Expand All @@ -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:
Expand Down Expand Up @@ -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()
Expand All @@ -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()

Expand Down Expand Up @@ -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)

Expand All @@ -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()

Expand All @@ -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()

Expand Down