From 1452baf3aedc28d630e1fd85883ac439a543b443 Mon Sep 17 00:00:00 2001 From: chintakjoshi Date: Sat, 18 Apr 2026 21:28:15 -0400 Subject: [PATCH] fix/admin routes now use first-party cookie sessions --- app/routers/_admin_access.py | 22 ++- app/routers/admin.py | 80 ++++++-- app/schemas/admin.py | 7 + app/services/admin_service.py | 66 +++++++ docs/admin-dashboard-integration.md | 46 +++++ docs/service-api.md | 3 +- pyproject.toml | 2 +- sdk/pyproject.toml | 2 +- tests/integration/test_admin_router_real.py | 183 ++++++++++++++++++ .../test_admin_access_browser_sessions.py | 116 +++++++++++ tests/unit/test_admin_service_edge_cases.py | 59 ++++++ uv.lock | 2 +- 12 files changed, 565 insertions(+), 23 deletions(-) create mode 100644 tests/unit/test_admin_access_browser_sessions.py diff --git a/app/routers/_admin_access.py b/app/routers/_admin_access.py index 208cf6e..4758b5e 100644 --- a/app/routers/_admin_access.py +++ b/app/routers/_admin_access.py @@ -3,14 +3,19 @@ from __future__ import annotations import hmac +import json from typing import Annotated from fastapi import Depends, Request from sqlalchemy.ext.asyncio import AsyncSession from app.config import get_settings +from app.core.browser_sessions import ( + extract_access_token, + require_csrf_for_cookie_authenticated_request, +) from app.dependencies import get_database_session -from app.services.admin_service import AdminService, get_admin_service +from app.services.admin_service import AdminService, AdminServiceError, get_admin_service def extract_bearer_token(request: Request) -> str | None: @@ -38,6 +43,19 @@ async def require_admin_claims( admin_service: AdminService, ) -> dict[str, object]: """Validate caller as an admin via bootstrap key or bearer token.""" + csrf_error = require_csrf_for_cookie_authenticated_request(request) + if csrf_error is not None: + try: + payload = json.loads(csrf_error.body.decode("utf-8")) + except Exception: + payload = {} + raise AdminServiceError( + detail=str(payload.get("detail", "Invalid CSRF token.")), + code=str(payload.get("code", "invalid_csrf_token")), + status_code=csrf_error.status_code, + headers=dict(csrf_error.headers), + ) + settings = get_settings() configured_admin_api_key = settings.admin_api_key supplied_admin_api_key = extract_admin_api_key(request) @@ -60,7 +78,7 @@ async def require_admin_claims( claims = await admin_service.validate_admin_access_token( db_session=db_session, - token=extract_bearer_token(request), + token=extract_access_token(request)[0], ) request.state.user = { "user_id": str(claims.get("sub", "")) or None, diff --git a/app/routers/admin.py b/app/routers/admin.py index d05b7b5..1152265 100644 --- a/app/routers/admin.py +++ b/app/routers/admin.py @@ -32,6 +32,7 @@ AdminSessionRevokeRequest, AdminSessionRevokeResponse, AdminSigningKeyRotateResponse, + AdminSuspiciousSessionItem, AdminUserDeleteResponse, AdminUserDetail, AdminUserEraseResponse, @@ -130,6 +131,24 @@ def _audit_log_item(row) -> AdminAuditLogItem: ) +def _session_item_payload(row) -> dict[str, object]: + """Serialize shared admin session fields for list and queue responses.""" + return { + "session_id": row.session_id, + "user_id": row.user_id, + "created_at": row.created_at, + "last_seen_at": row.last_seen_at, + "expires_at": row.expires_at, + "revoked_at": row.revoked_at, + "revoke_reason": row.revoke_reason, + "ip_address": row.ip_address, + "user_agent": row.user_agent, + "device_label": parse_device_label(row.user_agent), + "is_suspicious": row.is_suspicious, + "suspicious_reasons": row.suspicious_reasons, + } + + def _session_filter_metadata(payload: AdminSessionFilterRevokeRequest) -> dict[str, object]: """Serialize only the explicit filtered-revoke selectors for audit/webhook metadata.""" metadata: dict[str, object] = {} @@ -433,6 +452,49 @@ async def revoke_user_sessions( ) +@router.get( + "/sessions/suspicious", + response_model=CursorPageResponse[AdminSuspiciousSessionItem], +) +async def list_suspicious_sessions( + request: Request, + db_session: Annotated[AsyncSession, Depends(get_database_session)], + admin_service: Annotated[AdminService, Depends(get_admin_service)], + email: Annotated[str | None, Query()] = None, + role: Annotated[str | None, Query()] = None, + cursor: Annotated[str | None, Query()] = None, + limit: Annotated[int, Query(ge=1, le=200)] = 50, +) -> CursorPageResponse[AdminSuspiciousSessionItem] | JSONResponse: + """List active suspicious sessions across all users for security triage.""" + try: + await _require_admin_claims( + request, + db_session=db_session, + admin_service=admin_service, + ) + page = await admin_service.list_suspicious_sessions_page( + db_session=db_session, + email=email, + role=role, + cursor=cursor, + limit=limit, + ) + except AdminServiceError as exc: + return _error_response(exc.status_code, exc.detail, exc.code, headers=exc.headers) + return CursorPageResponse( + data=[ + AdminSuspiciousSessionItem( + user_email=row.user_email, + user_role=row.user_role, + **_session_item_payload(row), + ) + for row in page.items + ], + next_cursor=page.next_cursor, + has_more=page.has_more, + ) + + @router.get( "/users/{user_id}/sessions", response_model=CursorPageResponse[AdminSessionItem], @@ -463,23 +525,7 @@ async def list_user_sessions( except AdminServiceError as exc: return _error_response(exc.status_code, exc.detail, exc.code, headers=exc.headers) return CursorPageResponse( - data=[ - AdminSessionItem( - session_id=row.session_id, - user_id=row.user_id, - created_at=row.created_at, - last_seen_at=row.last_seen_at, - expires_at=row.expires_at, - revoked_at=row.revoked_at, - revoke_reason=row.revoke_reason, - ip_address=row.ip_address, - user_agent=row.user_agent, - device_label=parse_device_label(row.user_agent), - is_suspicious=row.is_suspicious, - suspicious_reasons=row.suspicious_reasons, - ) - for row in page.items - ], + data=[AdminSessionItem(**_session_item_payload(row)) for row in page.items], next_cursor=page.next_cursor, has_more=page.has_more, ) diff --git a/app/schemas/admin.py b/app/schemas/admin.py index e666347..ba92569 100644 --- a/app/schemas/admin.py +++ b/app/schemas/admin.py @@ -81,6 +81,13 @@ class AdminSessionItem(BaseModel): suspicious_reasons: list[str] +class AdminSuspiciousSessionItem(AdminSessionItem): + """Admin-facing global suspicious-session row with user context.""" + + user_email: str + user_role: str + + class AdminSessionRevokeResponse(BaseModel): """Admin response for single-session revocation.""" diff --git a/app/services/admin_service.py b/app/services/admin_service.py index 67107ac..1729fd5 100644 --- a/app/services/admin_service.py +++ b/app/services/admin_service.py @@ -126,6 +126,14 @@ class AdminSessionSummary: suspicious_reasons: list[str] +@dataclass(frozen=True) +class AdminSuspiciousSessionSummary(AdminSessionSummary): + """Global suspicious-session row enriched with user identity details.""" + + user_email: str + user_role: str + + @dataclass(frozen=True) class AdminSessionDetailSummary(AdminSessionSummary): """Admin-facing session detail payload with attributable timeline events.""" @@ -512,6 +520,64 @@ async def list_user_sessions_page( ] return build_page(summaries, limit=limit) + async def list_suspicious_sessions_page( + self, + db_session: AsyncSession, + *, + email: str | None = None, + role: str | None = None, + cursor: str | None = None, + limit: int = 50, + ) -> CursorPage[AdminSuspiciousSessionSummary]: + """Return active suspicious sessions across all non-deleted users.""" + limit = max(1, min(limit, 200)) + cursor_position = decode_cursor(cursor) if cursor is not None else None + statement = ( + select(Session, User.email, User.role) + .join(User, User.id == Session.user_id) + .where( + Session.deleted_at.is_(None), + Session.is_suspicious.is_(True), + Session.revoked_at.is_(None), + Session.expires_at > datetime.now(UTC), + User.deleted_at.is_(None), + ) + .order_by(Session.created_at.desc(), Session.id.desc()) + ) + normalized_role = role.strip() if role is not None else None + if normalized_role: + statement = statement.where(User.role == normalized_role) + normalized_email = email.strip().lower() if email is not None else None + if normalized_email: + statement = statement.where(func.lower(User.email).like(f"%{normalized_email}%")) + + statement = apply_created_at_cursor( + statement, + model=Session, + cursor=cursor_position, + ).limit(limit + 1) + rows = list((await db_session.execute(statement)).all()) + summaries = [ + AdminSuspiciousSessionSummary( + id=session_row.id, + session_id=session_row.session_id, + user_id=session_row.user_id, + created_at=session_row.created_at, + last_seen_at=session_row.last_seen_at, + expires_at=session_row.expires_at, + revoked_at=session_row.revoked_at, + revoke_reason=session_row.revoke_reason, + ip_address=session_row.ip_address, + user_agent=session_row.user_agent, + is_suspicious=bool(getattr(session_row, "is_suspicious", False)), + suspicious_reasons=list(getattr(session_row, "suspicious_reasons", []) or []), + user_email=str(user_email), + user_role=str(user_role), + ) + for session_row, user_email, user_role in rows + ] + return build_page(summaries, limit=limit) + async def get_user_session_detail( self, db_session: AsyncSession, diff --git a/docs/admin-dashboard-integration.md b/docs/admin-dashboard-integration.md index 8b8f0d4..555745f 100644 --- a/docs/admin-dashboard-integration.md +++ b/docs/admin-dashboard-integration.md @@ -100,12 +100,50 @@ friendly device label using `device_label` (server-derived). | Method | Path | Purpose | Step-Up | |--------|------|---------|---------| +| GET | `/sessions/suspicious` | Global active suspicious-session queue | No | | GET | `/users/{user_id}/sessions` | List sessions for a user | No | | GET | `/users/{user_id}/sessions/{session_id}` | Session detail with attributable timeline | No | | POST | `/users/{user_id}/sessions/revoke-by-filter` | Preview or revoke sessions matching explicit selectors | Yes | | DELETE | `/users/{user_id}/sessions/{session_id}` | Revoke one session | Yes | | DELETE | `/users/{user_id}/sessions` | Revoke all sessions for a user | Yes | +`GET /sessions/suspicious` query params: + +- `email` — optional case-insensitive email substring filter +- `role` — optional exact auth role filter +- `cursor` — opaque pagination cursor +- `limit` — 1–200 (default 50) + +Suspicious queue item shape: + +```json +{ + "session_id": "uuid", + "user_id": "uuid", + "user_email": "person@example.com", + "user_role": "user", + "created_at": "2026-04-16T09:00:00Z", + "last_seen_at": "2026-04-16T10:15:00Z", + "expires_at": "2026-04-23T09:00:00Z", + "revoked_at": null, + "revoke_reason": null, + "ip_address": "203.0.113.10", + "user_agent": "Mozilla/5.0 ...", + "device_label": "Chrome on Windows", + "is_suspicious": true, + "suspicious_reasons": ["new_ip", "prior_failures"] +} +``` + +Suspicious queue notes: + +- The queue only returns active suspicious sessions. Revoked, expired, and + soft-deleted-user sessions are excluded. +- `user_email` and `user_role` let dashboards render a standalone global queue + without first hydrating the per-user directory. +- Use this route for the top-level security triage queue; use the per-user + session routes after the operator drills into one account. + `GET /users/{user_id}/sessions` query params: - `status` — `active`, `revoked`, or `all` (default `active`) @@ -389,6 +427,14 @@ for supported query parameters. 4. Action buttons wire to PATCH role, DELETE sessions, DELETE user, and PATCH OTP — each must first obtain an action token via the step-up flow. +### Suspicious Queue + +1. `GET /admin/sessions/suspicious?limit=50` — initial global queue load +2. Render email, role, device, IP, risk reasons, and last seen +3. Use `next_cursor` to page through the queue +4. Open the user workspace with `user_id`, then switch to per-user session + endpoints for deeper inspection or revoke actions + ### Session Table Columns Recommended columns: diff --git a/docs/service-api.md b/docs/service-api.md index 1739539..665c966 100644 --- a/docs/service-api.md +++ b/docs/service-api.md @@ -108,7 +108,8 @@ The admin surface lives under `/admin/*`. Major areas include: - users (list, detail, role, delete, erase, OTP toggle) -- sessions (per-user list, detail, single revoke, filtered revoke, bulk revoke) +- sessions (global suspicious queue, per-user list, detail, single revoke, + filtered revoke, bulk revoke) - user history (per-user audit feed) - API keys - OAuth clients diff --git a/pyproject.toml b/pyproject.toml index 922ee5b..32fadb2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "auth-service" -version = "1.5.0" +version = "1.5.1" description = "Authentication microservice and SDK scaffold." readme = "README.md" requires-python = ">=3.11" diff --git a/sdk/pyproject.toml b/sdk/pyproject.toml index dee9900..7d33445 100644 --- a/sdk/pyproject.toml +++ b/sdk/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "auth-service-sdk" -version = "1.5.0" +version = "1.5.1" description = "SDK middleware and client for auth-service token and API key verification." readme = "README.md" requires-python = ">=3.11" diff --git a/tests/integration/test_admin_router_real.py b/tests/integration/test_admin_router_real.py index 38bc136..8a6416a 100644 --- a/tests/integration/test_admin_router_real.py +++ b/tests/integration/test_admin_router_real.py @@ -295,6 +295,51 @@ async def test_admin_routes_accept_local_dev_bootstrap_key( assert response.json()["data"][0]["email"] == "listed@example.com" +@pytest.mark.asyncio +async def test_admin_routes_accept_cookie_authenticated_admin_sessions( + app_factory, db_session +) -> None: + """Admin routes should accept first-party browser sessions backed by auth cookies.""" + app: FastAPI = app_factory() + await _create_user( + db_session, + email="admin-cookie@example.com", + password="Password123!", + role="admin", + ) + await _create_user(db_session, email="listed@example.com", password="Password123!") + + async with AsyncClient( + transport=ASGITransport(app=app), + base_url="http://testserver", + ) as client: + csrf_response = await client.get("/auth/csrf") + assert csrf_response.status_code == 200 + csrf_token = csrf_response.json()["csrf_token"] + + login = await client.post( + "/auth/login", + json={"email": "admin-cookie@example.com", "password": "Password123!"}, + headers={ + "x-auth-session-transport": "cookie", + "x-csrf-token": csrf_token, + }, + ) + assert login.status_code == 200 + assert login.json() == { + "authenticated": True, + "session_transport": "cookie", + } + + response = await client.get( + "/admin/users?limit=12", + headers={"x-auth-session-transport": "cookie"}, + ) + + assert response.status_code == 200 + assert response.json()["data"][0]["email"] == "listed@example.com" + + @pytest.mark.asyncio async def test_admin_users_list_supports_cursor_pagination(app_factory, db_session) -> None: """Admin user listing uses the documented cursor page shape.""" @@ -342,6 +387,144 @@ async def test_admin_users_list_supports_cursor_pagination(app_factory, db_sessi assert str(first_payload["data"][0]["id"]) != str(second_payload["data"][0]["id"]) +@pytest.mark.asyncio +async def test_admin_suspicious_session_queue_lists_active_sessions_across_users( + app_factory, + db_session, +) -> None: + """Admins can page through active suspicious sessions across all users.""" + app: FastAPI = app_factory() + security_admin = await _create_user( + db_session, + email="security-admin@example.com", + password="Password123!", + role="admin", + email_verified=True, + ) + first_target = await _create_user( + db_session, + email="queue-user@example.com", + password="Password123!", + role="user", + email_verified=True, + ) + second_target = await _create_user( + db_session, + email="queue-admin@example.com", + password="Password123!", + role="admin", + email_verified=True, + ) + revoked_target = await _create_user( + db_session, + email="queue-revoked@example.com", + password="Password123!", + role="user", + email_verified=True, + ) + + async with AsyncClient( + transport=ASGITransport(app=app), + base_url="http://testserver", + ) as client: + await _login_with_optional_otp( + client, + email="queue-user@example.com", + password="Password123!", + ) + await _login_with_optional_otp( + client, + email="queue-admin@example.com", + password="Password123!", + ) + await _login_with_optional_otp( + client, + email="queue-revoked@example.com", + password="Password123!", + ) + admin_access_token = await _login_with_optional_otp( + client, + email="security-admin@example.com", + password="Password123!", + ) + headers = {"authorization": f"Bearer {admin_access_token}"} + + async with get_session_factory()() as lookup_session: + session_rows = list( + ( + await lookup_session.execute( + select(Session).where( + Session.user_id.in_( + [ + security_admin.id, + first_target.id, + second_target.id, + revoked_target.id, + ] + ) + ) + ) + ) + .scalars() + .all() + ) + admin_session = next(row for row in session_rows if row.user_id == security_admin.id) + first_session = next(row for row in session_rows if row.user_id == first_target.id) + second_session = next(row for row in session_rows if row.user_id == second_target.id) + revoked_session = next(row for row in session_rows if row.user_id == revoked_target.id) + + admin_session.is_suspicious = False + admin_session.suspicious_reasons = [] + first_session.is_suspicious = True + first_session.suspicious_reasons = ["new_ip"] + second_session.is_suspicious = True + second_session.suspicious_reasons = ["prior_failures"] + revoked_session.is_suspicious = True + revoked_session.suspicious_reasons = ["new_user_agent"] + revoked_session.revoked_at = datetime.now(UTC) + await lookup_session.commit() + + first_page = await client.get("/admin/sessions/suspicious?limit=1", headers=headers) + assert first_page.status_code == 200 + first_payload = first_page.json() + assert len(first_payload["data"]) == 1 + assert first_payload["has_more"] is True + assert first_payload["next_cursor"] is not None + assert first_payload["data"][0]["is_suspicious"] is True + assert first_payload["data"][0]["revoked_at"] is None + assert first_payload["data"][0]["user_email"] in { + "queue-user@example.com", + "queue-admin@example.com", + } + assert first_payload["data"][0]["device_label"] + + second_page = await client.get( + f"/admin/sessions/suspicious?limit=10&cursor={first_payload['next_cursor']}", + headers=headers, + ) + assert second_page.status_code == 200 + second_payload = second_page.json() + listed_session_ids = { + item["session_id"] for item in [*first_payload["data"], *second_payload["data"]] + } + assert listed_session_ids == { + str(first_session.session_id), + str(second_session.session_id), + } + assert str(revoked_session.session_id) not in listed_session_ids + + filtered = await client.get( + "/admin/sessions/suspicious?role=admin&email=queue-admin", + headers=headers, + ) + assert filtered.status_code == 200 + filtered_payload = filtered.json() + assert [item["user_email"] for item in filtered_payload["data"]] == [ + "queue-admin@example.com" + ] + assert [item["user_role"] for item in filtered_payload["data"]] == ["admin"] + + @pytest.mark.asyncio async def test_admin_session_detail_route_returns_session_metadata_and_timeline( app_factory, diff --git a/tests/unit/test_admin_access_browser_sessions.py b/tests/unit/test_admin_access_browser_sessions.py new file mode 100644 index 0000000..cca87bb --- /dev/null +++ b/tests/unit/test_admin_access_browser_sessions.py @@ -0,0 +1,116 @@ +"""Admin access helper tests for browser-cookie sessions.""" + +from __future__ import annotations + +from uuid import uuid4 + +import pytest +from fastapi import Request + +from app.routers import _admin_access +from app.services.admin_service import AdminServiceError + +pytestmark = pytest.mark.usefixtures("browser_session_settings_env") + + +def _request( + *, + method: str, + path: str, + headers: dict[str, str] | None = None, +) -> Request: + """Build a Starlette request for direct helper invocation.""" + header_list = [ + (key.lower().encode("utf-8"), value.encode("utf-8")) + for key, value in (headers or {}).items() + ] + + async def _receive() -> dict[str, object]: + return {"type": "http.request", "body": b"", "more_body": False} + + return Request( + { + "type": "http", + "method": method, + "path": path, + "headers": header_list, + "client": ("127.0.0.1", 12345), + "scheme": "http", + "server": ("testserver", 80), + "query_string": b"", + }, + receive=_receive, + ) + + +class _AdminServiceStub: + def __init__(self) -> None: + self.calls: list[dict[str, object]] = [] + self.claims = { + "sub": str(uuid4()), + "email": "admin@example.com", + "role": "admin", + } + + async def validate_admin_access_token(self, **kwargs: object) -> dict[str, object]: + self.calls.append(dict(kwargs)) + return self.claims + + +@pytest.mark.asyncio +async def test_require_admin_claims_accepts_access_cookie_for_safe_requests() -> None: + """Cookie-authenticated admin GET requests should validate via the access cookie.""" + admin_service = _AdminServiceStub() + db_session = object() + request = _request( + method="GET", + path="/admin/users", + headers={ + "cookie": "auth_access=cookie-admin-token; auth_refresh=refresh-token", + "x-auth-session-transport": "cookie", + }, + ) + + claims = await _admin_access.require_admin_claims( + request, + db_session=db_session, # type: ignore[arg-type] + admin_service=admin_service, # type: ignore[arg-type] + ) + + assert claims["role"] == "admin" + assert admin_service.calls == [ + { + "db_session": db_session, + "token": "cookie-admin-token", + } + ] + assert request.state.user["email"] == "admin@example.com" + assert request.state.user["role"] == "admin" + + +@pytest.mark.asyncio +async def test_require_admin_claims_rejects_cookie_authenticated_unsafe_requests_without_csrf() -> ( + None +): + """Cookie-authenticated admin mutations should require a valid CSRF token.""" + admin_service = _AdminServiceStub() + request = _request( + method="DELETE", + path="/admin/users/123", + headers={ + "cookie": "auth_access=cookie-admin-token; auth_refresh=refresh-token", + "x-auth-session-transport": "cookie", + }, + ) + + with pytest.raises(AdminServiceError) as exc_info: + await _admin_access.require_admin_claims( + request, + db_session=object(), # type: ignore[arg-type] + admin_service=admin_service, # type: ignore[arg-type] + ) + + assert exc_info.value.status_code == 403 + assert exc_info.value.code == "invalid_csrf_token" + assert exc_info.value.detail == "Invalid CSRF token." + assert admin_service.calls == [] diff --git a/tests/unit/test_admin_service_edge_cases.py b/tests/unit/test_admin_service_edge_cases.py index ffdd971..336acd2 100644 --- a/tests/unit/test_admin_service_edge_cases.py +++ b/tests/unit/test_admin_service_edge_cases.py @@ -317,6 +317,65 @@ async def _get_active_user(**kwargs: object) -> object: assert page.items[0].suspicious_reasons == ["new_ip", "prior_failures"] +@pytest.mark.asyncio +async def test_list_suspicious_sessions_page_returns_enriched_cursor_results() -> None: + """Global suspicious-session listings should carry user context and cursor metadata.""" + service = _service() + now = datetime.now(UTC) + first_session = SimpleNamespace( + id=uuid4(), + session_id=uuid4(), + user_id=uuid4(), + created_at=now, + last_seen_at=now, + expires_at=now + timedelta(hours=1), + revoked_at=None, + revoke_reason=None, + ip_address="203.0.113.20", + user_agent="Mozilla/5.0 Chrome/120 Windows", + is_suspicious=True, + suspicious_reasons=["new_ip"], + ) + second_session = SimpleNamespace( + id=uuid4(), + session_id=uuid4(), + user_id=uuid4(), + created_at=now - timedelta(minutes=5), + last_seen_at=now - timedelta(minutes=1), + expires_at=now + timedelta(hours=1), + revoked_at=None, + revoke_reason=None, + ip_address="198.51.100.20", + user_agent="Mozilla/5.0 Safari/17 macOS", + is_suspicious=True, + suspicious_reasons=["prior_failures"], + ) + db_session = _DBSessionStub( + [ + SimpleNamespace( + all=lambda: [ + (first_session, "first@example.com", "user"), + (second_session, "second@example.com", "admin"), + ] + ) + ] + ) + + page = await service.list_suspicious_sessions_page( + db_session=db_session, # type: ignore[arg-type] + limit=1, + ) + + assert len(page.items) == 1 + assert page.items[0].session_id == first_session.session_id + assert page.items[0].user_email == "first@example.com" + assert page.items[0].user_role == "user" + assert page.items[0].is_suspicious is True + assert page.items[0].suspicious_reasons == ["new_ip"] + assert page.has_more is True + assert page.next_cursor is not None + + @pytest.mark.asyncio async def test_admin_mutation_and_proxy_helpers_cover_error_mapping() -> None: """Admin service covers role updates, delete/session/OTP, webhook proxies, and helper branches.""" diff --git a/uv.lock b/uv.lock index 618c205..192857e 100644 --- a/uv.lock +++ b/uv.lock @@ -106,7 +106,7 @@ wheels = [ [[package]] name = "auth-service" -version = "1.5.0" +version = "1.5.1" source = { editable = "." } dependencies = [ { name = "alembic" },