feat(auth): sync Keycloak users for authorization
This commit is contained in:
@@ -20,6 +20,7 @@ from app.domain.schemas.admin_metadata import (
|
||||
ProjectMemberUpdateRequest,
|
||||
)
|
||||
from app.infra.db.metadb.repositories.metadata_repository import ProjectDbRouting
|
||||
from app.services.keycloak_user_sync import KeycloakUserSnapshot
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@@ -176,6 +177,66 @@ async def test_batch_sync_metadata_users_returns_per_user_results(monkeypatch):
|
||||
assert admin_metadata.log_audit_event.await_count == 2
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_sync_keycloak_user_directory_preserves_authorization(monkeypatch):
|
||||
new_id = uuid4()
|
||||
existing_id = uuid4()
|
||||
repo = SimpleNamespace(
|
||||
session=SimpleNamespace(rollback=AsyncMock()),
|
||||
sync_user_snapshot_from_keycloak=AsyncMock(
|
||||
side_effect=[
|
||||
(_user(keycloak_id=new_id, is_active=False), True),
|
||||
(_user(keycloak_id=existing_id, role="admin"), False),
|
||||
]
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
admin_metadata,
|
||||
"fetch_keycloak_users",
|
||||
AsyncMock(
|
||||
return_value=[
|
||||
KeycloakUserSnapshot(new_id, "new-user", "new@example.com"),
|
||||
KeycloakUserSnapshot(existing_id, "admin-user", "admin@example.com"),
|
||||
KeycloakUserSnapshot(uuid4(), "no-email", None),
|
||||
KeycloakUserSnapshot(
|
||||
uuid4(),
|
||||
"service-account-client",
|
||||
"service@example.com",
|
||||
service_account_client_id="client",
|
||||
),
|
||||
]
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr(admin_metadata, "log_audit_event", AsyncMock())
|
||||
|
||||
response = await admin_metadata.sync_keycloak_user_directory(
|
||||
current_user=_user(role="admin", is_superuser=True),
|
||||
keycloak_payload={"iss": "https://keycloak.example.com/realms/tjwater"},
|
||||
keycloak_access_token="admin-token",
|
||||
metadata_repo=repo,
|
||||
)
|
||||
|
||||
assert response.scanned == 4
|
||||
assert response.created == 1
|
||||
assert response.updated == 1
|
||||
assert response.skipped == 2
|
||||
assert [issue.reason for issue in response.issues] == [
|
||||
"missing_email",
|
||||
"service_account",
|
||||
]
|
||||
assert repo.sync_user_snapshot_from_keycloak.await_count == 2
|
||||
admin_metadata.fetch_keycloak_users.assert_awaited_once_with(
|
||||
issuer="https://keycloak.example.com/realms/tjwater",
|
||||
bearer_token="admin-token",
|
||||
)
|
||||
assert repo.sync_user_snapshot_from_keycloak.await_args_list[1].kwargs == {
|
||||
"keycloak_id": existing_id,
|
||||
"username": "admin-user",
|
||||
"email": "admin@example.com",
|
||||
}
|
||||
admin_metadata.log_audit_event.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_update_metadata_user_updates_role_and_active_status(monkeypatch):
|
||||
user_id = uuid4()
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
from uuid import uuid4
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from app.services import keycloak_user_sync
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def anyio_backend():
|
||||
return "asyncio"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_fetch_keycloak_users_uses_admin_token_and_paginates():
|
||||
user_ids = [uuid4() for _ in range(101)]
|
||||
requested_first: list[int] = []
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
assert request.headers["authorization"] == "Bearer admin-token"
|
||||
first = int(request.url.params["first"])
|
||||
requested_first.append(first)
|
||||
page_ids = user_ids[first : first + 100]
|
||||
return httpx.Response(
|
||||
200,
|
||||
json=[
|
||||
{
|
||||
"id": str(user_id),
|
||||
"username": f"user-{first + index}",
|
||||
"email": f"user-{first + index}@example.com",
|
||||
}
|
||||
for index, user_id in enumerate(page_ids)
|
||||
],
|
||||
)
|
||||
|
||||
async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client:
|
||||
users = await keycloak_user_sync.fetch_keycloak_users(
|
||||
issuer="https://keycloak.example.com/realms/tjwater",
|
||||
bearer_token="admin-token",
|
||||
client=client,
|
||||
)
|
||||
|
||||
assert len(users) == 101
|
||||
assert requested_first == [0, 100]
|
||||
assert users[-1].username == "user-100"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_fetch_keycloak_users_requires_realm_issuer():
|
||||
with pytest.raises(keycloak_user_sync.KeycloakUserSyncConfigurationError):
|
||||
await keycloak_user_sync.fetch_keycloak_users(
|
||||
issuer="",
|
||||
bearer_token="admin-token",
|
||||
)
|
||||
@@ -0,0 +1,44 @@
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
|
||||
from app.infra.db.metadb.repositories.metadata_repository import MetadataRepository
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def anyio_backend():
|
||||
return "asyncio"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_keycloak_snapshot_sync_preserves_existing_authorization():
|
||||
keycloak_id = uuid4()
|
||||
user = SimpleNamespace(
|
||||
keycloak_id=keycloak_id,
|
||||
username="old-name",
|
||||
email="old@example.com",
|
||||
role="admin",
|
||||
is_active=True,
|
||||
is_superuser=True,
|
||||
updated_at=None,
|
||||
)
|
||||
session = SimpleNamespace(commit=AsyncMock(), refresh=AsyncMock())
|
||||
repository = MetadataRepository(session)
|
||||
repository.get_user_by_keycloak_id = AsyncMock(return_value=user)
|
||||
|
||||
result, created = await repository.sync_user_snapshot_from_keycloak(
|
||||
keycloak_id=keycloak_id,
|
||||
username="new-name",
|
||||
email="new@example.com",
|
||||
)
|
||||
|
||||
assert created is False
|
||||
assert result.username == "new-name"
|
||||
assert result.email == "new@example.com"
|
||||
assert result.role == "admin"
|
||||
assert result.is_active is True
|
||||
assert result.is_superuser is True
|
||||
session.commit.assert_awaited_once()
|
||||
session.refresh.assert_awaited_once_with(user)
|
||||
Reference in New Issue
Block a user