From 224be18855ce222ef99acbaa84e35cf3be949924 Mon Sep 17 00:00:00 2001 From: beubax Date: Mon, 15 Jun 2026 12:56:42 +0530 Subject: [PATCH 01/15] docs: design user-scoped audit log --- ...2026-06-15-user-scoped-audit-log-design.md | 119 ++++++++++++++++++ 1 file changed, 119 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-15-user-scoped-audit-log-design.md diff --git a/docs/superpowers/specs/2026-06-15-user-scoped-audit-log-design.md b/docs/superpowers/specs/2026-06-15-user-scoped-audit-log-design.md new file mode 100644 index 00000000..18b33d19 --- /dev/null +++ b/docs/superpowers/specs/2026-06-15-user-scoped-audit-log-design.md @@ -0,0 +1,119 @@ +# User-Scoped Audit Log Design + +## Summary + +Allow non-admin users to view audit events scoped to their own principal, claimed identities, vault, and credential activity. Keep the existing admin/global audit view separate by authorization behavior, not by duplicating audit storage. + +## Goals + +- Let non-admin dashboard users review their own account-security events. +- Keep `GET /api/audit/events` as the single role-aware audit read endpoint. +- Enforce all non-admin scoping on the server. +- Add routine filtering and cursor pagination. +- Preserve the Store-backed `audit_events` registry as the source of truth. +- Keep `audit/` storage-free and independent from server-owned persistence. + +## Non-Goals + +- Do not add a second user-only audit store. +- Do not add a separate `/api/audit/me/events` endpoint for this bug. +- Do not expose secret-bearing metadata or payload expansion in the user audit UI. +- Do not redesign the full dashboard information architecture. + +## Architecture + +`GET /api/audit/events` remains the single audit read API. The route authenticates through the existing PoP-or-browser dependency and derives an effective scope from the caller: + +- Admin principals may query the global audit log, with optional filters. +- Non-admin principals are always scoped to `auth.principal_id`, regardless of query parameters. + +`POST /api/audit/events` remains PoP-protected and continues enriching external proxy events with the caller's `identity` and `principal_id`. + +The implementation should extend the current server-owned query surface: + +- `routes/audit.py` parses filters and pagination, resolves the caller, and computes the effective scope. +- `ServerAuditLog.list_events(...)` accepts richer filter and pagination arguments. +- `AuditEventRegistry.list_recent(...)` builds a parameterized query over the existing `audit_events` table. +- The UI calls the same endpoint for both admin and non-admin users. + +This preserves the current module boundaries: `audit/` emits structured events, while `server/` owns storage, authorization, and query behavior. + +## API Behavior + +`GET /api/audit/events` should support: + +- `limit`: bounded result count, clamped to the existing maximum of 500. +- `cursor`: opaque or documented cursor for fetching the next newest-first page. +- `event`: exact event-name filter. +- `provider`: exact provider filter. +- `identity`: exact identity-handle filter. +- `from`: inclusive lower timestamp bound. +- `to`: exclusive upper timestamp bound. + +Responses should remain newest-first and include pagination metadata: + +```json +{ + "entries": [], + "next_cursor": null, + "scope": "principal" +} +``` + +`scope` is `global` for admin global queries and `principal` for user-scoped queries. The response may include the effective `principal_id` for debugging only if it is the caller's own principal; it must not reveal other principals in user-scoped responses. + +## Authorization + +Authorization must fail closed. + +- Missing or invalid authentication returns `401`. +- Non-admin users cannot widen scope by passing `principal_id`, `vault_id`, or another identity in query parameters. +- If a non-admin passes an `identity` filter outside their own principal, the result is empty because the enforced `principal_id` condition still applies. +- Admin-only or global events without the caller's `principal_id` are never returned to non-admin callers. + +The server should not rely on UI filtering for security. + +## Data Flow + +1. A caller requests `/api/audit/events?limit=50&event=login&provider=github`. +2. The route resolves auth with `get_daemon_or_browser_auth_service`. +3. The route derives effective scope: + - admin: no forced `principal_id` for global results. + - user: forced `principal_id = auth.principal_id`. +4. The repository applies the effective scope, filters, timestamp range, limit, cursor, and newest-first ordering. +5. The response returns entries and `next_cursor`. +6. The dashboard maps entries to the existing `AuditRow` display model and renders either the global admin view or the account-scoped user view. + +## UI Design + +The dashboard already has an Audit tab and table. It should become visible to all authenticated users: + +- Sidebar shows `Audit Log` for all users. +- Dashboard home shows recent audit events for all users when available. +- `/audit` page renders for non-admin users instead of returning `null`. +- Admin copy remains: "Recent administrative and credential events." +- User copy becomes: "Recent account, identity, vault, and credential events for this principal." +- Filters cover event type, provider, identity, and time range. +- Pagination uses a "Load more" control backed by the server cursor. + +Source changes belong in `ui/src/...`. After the UI source changes, rebuild/export the static dashboard and refresh `src/authsome/ui/web`. + +## Error Handling + +- Invalid timestamps or malformed cursors should return FastAPI validation errors. +- Empty result sets return `200` with `entries: []`. +- Oversized limits are clamped rather than rejected. +- Store query errors should propagate through the existing server error handling; do not leak raw SQL or storage details in response bodies. + +## Testing + +Add focused tests for: + +- Two principals with overlapping providers where a non-admin only sees their own provider events. +- A non-admin cannot widen scope by passing another principal's ID or another identity. +- Admin callers still see global newest-first results. +- Filters narrow results within the effective scope. +- Cursor pagination returns newest-first pages without skipping or duplicating rows. +- UI source no longer hides the Audit Log navigation or `/audit` page from non-admin users. + +The existing Store-backed audit registry tests should continue to pass. From 32a7275b0c122bb80344b9f1a989340af4947df8 Mon Sep 17 00:00:00 2001 From: beubax Date: Mon, 15 Jun 2026 13:09:35 +0530 Subject: [PATCH 02/15] docs: plan user-scoped audit log --- .../plans/2026-06-15-user-scoped-audit-log.md | 1154 +++++++++++++++++ 1 file changed, 1154 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-15-user-scoped-audit-log.md diff --git a/docs/superpowers/plans/2026-06-15-user-scoped-audit-log.md b/docs/superpowers/plans/2026-06-15-user-scoped-audit-log.md new file mode 100644 index 00000000..aa8c92ce --- /dev/null +++ b/docs/superpowers/plans/2026-06-15-user-scoped-audit-log.md @@ -0,0 +1,1154 @@ +# User-Scoped Audit Log Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Let non-admin users view a server-authorized, principal-scoped audit log with filters and pagination while preserving the admin global audit view. + +**Architecture:** Keep `GET /api/audit/events` as the single role-aware audit read endpoint. Add a Store-backed paged audit query method, have the route enforce effective scope from the authenticated principal, and update the dashboard to fetch and render audit events for all authenticated users. + +**Tech Stack:** FastAPI, Pydantic/FastAPI query validation, SQLite/Postgres-compatible parameterized SQL through `StoreDatabase`, React 19, Next 16 App Router static export, SWR, TypeScript, uv, pnpm, pytest, ruff, ty. + +--- + +## File Structure + +- Modify `src/authsome/server/store/repositories.py` + - Add `AuditEventPage`. + - Add cursor helpers for newest-first pagination. + - Add `AuditEventRegistry.query_events`. + - Add `ServerAuditLog.query_events`. + - Keep `ServerAuditLog.list_events` returning a plain list for existing tests. +- Modify `src/authsome/server/routes/audit.py` + - Parse audit filters and pagination. + - Compute effective principal scope from `CredentialService`. + - Return `entries`, `next_cursor`, and `scope`. +- Modify `tests/server/test_audit_events.py` + - Add route tests for forced non-admin scope, filter behavior, and cursor pagination. +- Modify `ui/src/lib/authsome-api.ts` + - Export audit filter/page types. + - Add `fetchAuditEvents`. + - Fetch audit events for all dashboard users. +- Modify `ui/src/components/authsome-dashboard.tsx` + - Show Audit Log navigation to all users. + - Add filter controls and load-more behavior to `AuditView`. + - Use different section copy for admin vs non-admin. +- Modify `ui/src/app/(authenticated)/audit/page.tsx` + - Render the audit page for every authenticated user. +- Modify `docs/site/reference/audit-log.mdx` + - Document role-aware scoping, supported filters, and pagination. +- Generated by `scripts/build-ui.sh` + - Refresh `src/authsome/ui/web/**` from `ui/out/**`. + +--- + +### Task 1: Add Failing Server Audit Query Tests + +**Files:** +- Modify: `tests/server/test_audit_events.py` + +- [ ] **Step 1: Extend imports** + +Change the import block at the top of `tests/server/test_audit_events.py` to include deterministic timestamps and direct audit event emission: + +```python +import json +from datetime import UTC, datetime +from pathlib import Path +from urllib.parse import parse_qs, urlparse + +from fastapi import status +from fastapi.testclient import TestClient + +from authsome.audit import AuditEvent, emit, emit_event +from authsome.cli.identity import RuntimeIdentity +from tests.server.helpers import create_server_test_client +from tests.server.test_pop_auth import _auth_header +``` + +- [ ] **Step 2: Add a deterministic audit helper** + +Add this helper after `_claim_identity`: + +```python +def _emit_audit_event( + event_id: str, + event: str, + *, + principal_id: str | None, + identity: str | None, + provider: str | None = None, + connection: str | None = None, + status: str | None = "success", + timestamp: datetime | None = None, +) -> None: + emit( + AuditEvent( + event_id=event_id, + timestamp=timestamp or datetime(2026, 6, 15, 8, 0, tzinfo=UTC), + event=event, + principal_id=principal_id, + identity=identity, + provider=provider, + connection=connection, + status=status, + ) + ) +``` + +- [ ] **Step 3: Add non-admin scope and filter test** + +Append this test to `tests/server/test_audit_events.py`: + +```python +def test_non_admin_audit_filters_stay_within_own_principal(monkeypatch, tmp_path: Path) -> None: + monkeypatch.setenv("AUTHSOME_HOME", str(tmp_path)) + + with create_server_test_client() as client: + _claim_identity(client, tmp_path, "admin-ready-boldly-0001", email="admin@example.com") + _claim_identity(client, tmp_path, "steady-wisely-boldly-0042", email="user@example.com") + admin_whoami = client.get( + "/api/whoami", + headers=_auth_header(tmp_path, "GET", "/api/whoami", handle="admin-ready-boldly-0001"), + ).json() + user_whoami = client.get( + "/api/whoami", + headers=_auth_header(tmp_path, "GET", "/api/whoami", handle="steady-wisely-boldly-0042"), + ).json() + _emit_audit_event( + "audit_001", + "connection.login", + principal_id=admin_whoami["principal_id"], + identity="admin-ready-boldly-0001", + provider="github", + ) + _emit_audit_event( + "audit_002", + "connection.login", + principal_id=user_whoami["principal_id"], + identity="steady-wisely-boldly-0042", + provider="github", + ) + _emit_audit_event( + "audit_003", + "connection.logout", + principal_id=user_whoami["principal_id"], + identity="steady-wisely-boldly-0042", + provider="linear", + ) + + response = client.get( + "/api/audit/events?provider=github&limit=10", + headers=_auth_header( + tmp_path, + "GET", + "/api/audit/events?provider=github&limit=10", + handle="steady-wisely-boldly-0042", + ), + ) + + assert response.status_code == status.HTTP_200_OK + body = response.json() + assert body["scope"] == "principal" + assert body["next_cursor"] is None + assert [entry["event_id"] for entry in body["entries"]] == ["audit_002"] + assert body["entries"][0]["principal_id"] == user_whoami["principal_id"] + assert body["entries"][0]["provider"] == "github" +``` + +- [ ] **Step 4: Add query-widening test** + +Append this test: + +```python +def test_non_admin_audit_query_cannot_widen_scope_with_principal_or_identity( + monkeypatch, + tmp_path: Path, +) -> None: + monkeypatch.setenv("AUTHSOME_HOME", str(tmp_path)) + + with create_server_test_client() as client: + _claim_identity(client, tmp_path, "admin-ready-boldly-0001", email="admin@example.com") + _claim_identity(client, tmp_path, "steady-wisely-boldly-0042", email="user@example.com") + admin_whoami = client.get( + "/api/whoami", + headers=_auth_header(tmp_path, "GET", "/api/whoami", handle="admin-ready-boldly-0001"), + ).json() + user_whoami = client.get( + "/api/whoami", + headers=_auth_header(tmp_path, "GET", "/api/whoami", handle="steady-wisely-boldly-0042"), + ).json() + _emit_audit_event( + "audit_010", + "connection.login", + principal_id=admin_whoami["principal_id"], + identity="admin-ready-boldly-0001", + provider="github", + ) + _emit_audit_event( + "audit_011", + "connection.login", + principal_id=user_whoami["principal_id"], + identity="steady-wisely-boldly-0042", + provider="github", + ) + + path = ( + "/api/audit/events?" + f"principal_id={admin_whoami['principal_id']}&" + "identity=admin-ready-boldly-0001&limit=10" + ) + response = client.get( + path, + headers=_auth_header( + tmp_path, + "GET", + path, + handle="steady-wisely-boldly-0042", + ), + ) + + assert response.status_code == status.HTTP_200_OK + body = response.json() + assert body["scope"] == "principal" + assert body["entries"] == [] +``` + +- [ ] **Step 5: Add admin filter and cursor pagination test** + +Append this test: + +```python +def test_admin_audit_events_support_filters_and_cursor_pagination(monkeypatch, tmp_path: Path) -> None: + monkeypatch.setenv("AUTHSOME_HOME", str(tmp_path)) + + with create_server_test_client() as client: + _claim_identity(client, tmp_path, "admin-ready-boldly-0001", email="admin@example.com") + _claim_identity(client, tmp_path, "steady-wisely-boldly-0042", email="user@example.com") + admin_whoami = client.get( + "/api/whoami", + headers=_auth_header(tmp_path, "GET", "/api/whoami", handle="admin-ready-boldly-0001"), + ).json() + user_whoami = client.get( + "/api/whoami", + headers=_auth_header(tmp_path, "GET", "/api/whoami", handle="steady-wisely-boldly-0042"), + ).json() + _emit_audit_event( + "audit_100", + "connection.login", + principal_id=admin_whoami["principal_id"], + identity="admin-ready-boldly-0001", + provider="github", + timestamp=datetime(2026, 6, 15, 8, 0, tzinfo=UTC), + ) + _emit_audit_event( + "audit_101", + "connection.login", + principal_id=user_whoami["principal_id"], + identity="steady-wisely-boldly-0042", + provider="github", + timestamp=datetime(2026, 6, 15, 8, 1, tzinfo=UTC), + ) + _emit_audit_event( + "audit_102", + "connection.logout", + principal_id=user_whoami["principal_id"], + identity="steady-wisely-boldly-0042", + provider="github", + timestamp=datetime(2026, 6, 15, 8, 2, tzinfo=UTC), + ) + + first_path = "/api/audit/events?provider=github&limit=2" + first_response = client.get( + first_path, + headers=_auth_header( + tmp_path, + "GET", + first_path, + handle="admin-ready-boldly-0001", + ), + ) + first_body = first_response.json() + second_path = f"/api/audit/events?provider=github&limit=2&cursor={first_body['next_cursor']}" + second_response = client.get( + second_path, + headers=_auth_header( + tmp_path, + "GET", + second_path, + handle="admin-ready-boldly-0001", + ), + ) + + assert first_response.status_code == status.HTTP_200_OK + assert first_body["scope"] == "global" + assert [entry["event_id"] for entry in first_body["entries"]] == ["audit_102", "audit_101"] + assert first_body["next_cursor"] + + assert second_response.status_code == status.HTTP_200_OK + second_body = second_response.json() + assert second_body["scope"] == "global" + assert [entry["event_id"] for entry in second_body["entries"]] == ["audit_100"] + assert second_body["next_cursor"] is None +``` + +- [ ] **Step 6: Run new tests to verify they fail** + +Run: + +```bash +uv run pytest tests/server/test_audit_events.py::test_non_admin_audit_filters_stay_within_own_principal tests/server/test_audit_events.py::test_non_admin_audit_query_cannot_widen_scope_with_principal_or_identity tests/server/test_audit_events.py::test_admin_audit_events_support_filters_and_cursor_pagination -v +``` + +Expected: FAIL because `scope`, `next_cursor`, query filters, and cursor pagination are not implemented yet. + +- [ ] **Step 7: Commit failing tests** + +Run: + +```bash +git add tests/server/test_audit_events.py +git commit -m "test: cover user-scoped audit queries" +``` + +--- + +### Task 2: Implement Store-Backed Audit Filtering And Pagination + +**Files:** +- Modify: `src/authsome/server/store/repositories.py` +- Test: `tests/server/test_audit_events.py` + +- [ ] **Step 1: Add `AuditEventPage` near `AuditEventInsert`** + +Add this dataclass immediately after `AuditEventInsert`: + +```python +@dataclass(frozen=True) +class AuditEventPage: + """Paged audit event query result.""" + + entries: list[dict[str, Any]] + next_cursor: str | None +``` + +- [ ] **Step 2: Add cursor helpers before `AuditEventRegistry`** + +Add these helpers after `AuditEventPage`: + +```python +def _encode_audit_cursor(*, timestamp: str, event_id: str) -> str: + return f"{timestamp}|{event_id}" + + +def _decode_audit_cursor(cursor: str) -> tuple[str, str]: + timestamp, separator, event_id = cursor.partition("|") + if not separator or not timestamp or not event_id: + raise ValueError("Invalid audit cursor") + return timestamp, event_id +``` + +- [ ] **Step 3: Replace `AuditEventRegistry.list_recent`** + +Replace the existing `list_recent` method with these two methods: + +```python + async def query_events( + self, + *, + limit: int = 50, + principal_id: str | None = None, + event: str | None = None, + provider: str | None = None, + identity: str | None = None, + since: datetime | None = None, + until: datetime | None = None, + cursor: str | None = None, + ) -> AuditEventPage: + bounded_limit = min(max(limit, 1), 500) + conditions: list[str] = [] + params: list[Any] = [] + + if principal_id is not None: + conditions.append("principal_id = ?") + params.append(principal_id) + if event: + conditions.append("event = ?") + params.append(event) + if provider: + conditions.append("provider = ?") + params.append(provider) + if identity: + conditions.append("identity = ?") + params.append(identity) + if since is not None: + conditions.append("timestamp >= ?") + params.append(_dump_dt(since)) + if until is not None: + conditions.append("timestamp < ?") + params.append(_dump_dt(until)) + if cursor: + cursor_timestamp, cursor_event_id = _decode_audit_cursor(cursor) + conditions.append("(timestamp < ? OR (timestamp = ? AND event_id < ?))") + params.extend([cursor_timestamp, cursor_timestamp, cursor_event_id]) + + where_clause = f" WHERE {' AND '.join(conditions)}" if conditions else "" + rows = await self._db.fetch_all( + "SELECT event_id, timestamp, payload_json FROM audit_events" + f"{where_clause} " + "ORDER BY timestamp DESC, event_id DESC LIMIT ?", + [*params, bounded_limit + 1], + ) + visible_rows = rows[:bounded_limit] + entries = [json.loads(row["payload_json"]) for row in visible_rows] + next_cursor = None + if len(rows) > bounded_limit and visible_rows: + last = visible_rows[-1] + next_cursor = _encode_audit_cursor(timestamp=last["timestamp"], event_id=last["event_id"]) + return AuditEventPage(entries=entries, next_cursor=next_cursor) + + async def list_recent(self, *, limit: int = 50, principal_id: str | None = None) -> list[dict[str, Any]]: + page = await self.query_events(limit=limit, principal_id=principal_id) + return page.entries +``` + +- [ ] **Step 4: Add page method to `ServerAuditLog`** + +Add this method above the existing `list_events` method: + +```python + async def query_events( + self, + *, + limit: int = 50, + principal_id: str | None = None, + event: str | None = None, + provider: str | None = None, + identity: str | None = None, + since: datetime | None = None, + until: datetime | None = None, + cursor: str | None = None, + ) -> AuditEventPage: + await self.async_force_flush() + return await self._registry.query_events( + limit=limit, + principal_id=principal_id, + event=event, + provider=provider, + identity=identity, + since=since, + until=until, + cursor=cursor, + ) +``` + +- [ ] **Step 5: Run the new tests to verify store support is still missing at the route** + +Run: + +```bash +uv run pytest tests/server/test_audit_events.py::test_admin_audit_events_support_filters_and_cursor_pagination -v +``` + +Expected: FAIL because the route still ignores filters and still returns only `entries`. + +- [ ] **Step 6: Run focused compatibility tests** + +Run: + +```bash +uv run pytest tests/auth/test_service.py::TestRefreshFailureAudit -v +``` + +Expected: PASS because `ServerAuditLog.list_events()` still returns a list. + +- [ ] **Step 7: Commit store query implementation** + +Run: + +```bash +git add src/authsome/server/store/repositories.py +git commit -m "feat: add paged audit event queries" +``` + +--- + +### Task 3: Implement Role-Aware Audit Route Filters + +**Files:** +- Modify: `src/authsome/server/routes/audit.py` +- Test: `tests/server/test_audit_events.py` + +- [ ] **Step 1: Replace imports in `routes/audit.py`** + +Replace the import block with: + +```python +"""Audit event routes.""" + +from datetime import datetime +from typing import Annotated, Any, Literal + +from fastapi import APIRouter, Depends, HTTPException, Query, Request, status +``` + +Keep the existing `authsome`, `PrincipalRole`, `CredentialService`, and dependency imports below that block. + +- [ ] **Step 2: Replace `list_audit_events`** + +Replace the current `list_audit_events` function with: + +```python +@router.get("/events") +async def list_audit_events( + request: Request, + limit: int = 50, + cursor: str | None = None, + event: str | None = None, + provider: str | None = None, + identity: str | None = None, + principal_id: str | None = None, + from_: Annotated[datetime | None, Query(alias="from")] = None, + to: datetime | None = None, + auth: CredentialService = Depends(get_daemon_or_browser_auth_service), +) -> dict[str, Any]: + effective_principal_id = principal_id if auth.principal_role == PrincipalRole.ADMIN else auth.principal_id + scope: Literal["global", "principal"] = "global" if effective_principal_id is None else "principal" + try: + page = await request.app.state.audit_log.query_events( + limit=limit, + principal_id=effective_principal_id, + event=event, + provider=provider, + identity=identity, + since=from_, + until=to, + cursor=cursor, + ) + except ValueError as exc: + raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc + return {"entries": page.entries, "next_cursor": page.next_cursor, "scope": scope} +``` + +- [ ] **Step 3: Run server audit tests** + +Run: + +```bash +uv run pytest tests/server/test_audit_events.py -v +``` + +Expected: PASS. + +- [ ] **Step 4: Run focused lint** + +Run: + +```bash +uv run ruff check src/authsome/server/routes/audit.py src/authsome/server/store/repositories.py tests/server/test_audit_events.py +``` + +Expected: PASS. + +- [ ] **Step 5: Commit route implementation** + +Run: + +```bash +git add src/authsome/server/routes/audit.py tests/server/test_audit_events.py +git commit -m "feat: enforce role-aware audit event queries" +``` + +--- + +### Task 4: Update Dashboard Audit Fetching And Rendering + +**Files:** +- Modify: `ui/src/lib/authsome-api.ts` +- Modify: `ui/src/components/authsome-dashboard.tsx` +- Modify: `ui/src/app/(authenticated)/audit/page.tsx` + +- [ ] **Step 1: Read local Next 16 docs for touched App Router patterns** + +Run: + +```bash +sed -n '1,180p' ui/node_modules/next/dist/docs/01-app/01-getting-started/03-layouts-and-pages.md +sed -n '1,160p' ui/node_modules/next/dist/docs/01-app/02-guides/static-exports.md +``` + +Expected: Confirm the route files remain valid App Router client pages and the existing static export approach is unchanged. + +- [ ] **Step 2: Extend audit types in `ui/src/lib/authsome-api.ts`** + +Replace `AuditRow` with: + +```typescript +export type AuditRow = { + eventId: string; + time: string; + event: string; + source: string; + actor: string; + target: string; + status: string; + metadata: Record; +}; + +export type AuditFilters = { + event?: string; + provider?: string; + identity?: string; + from?: string; + to?: string; + cursor?: string | null; + limit?: number; +}; + +export type AuditEventsData = { + scope: "global" | "principal"; + nextCursor: string | null; + events: AuditRow[]; + total: number; +}; +``` + +Then update `DashboardData.audit` to include: + +```typescript + audit: { + canView: boolean; + scope: "global" | "principal"; + nextCursor: string | null; + total: number; + events: AuditRow[]; + }; +``` + +- [ ] **Step 3: Replace `AuditResponse` and add query helper** + +Replace `type AuditResponse` with: + +```typescript +type AuditResponse = { + entries: Array>; + next_cursor?: string | null; + scope?: "global" | "principal"; +}; +``` + +Add this helper after `buildAuditRows`: + +```typescript +function auditQueryString(filters: AuditFilters = {}): string { + const params = new URLSearchParams(); + params.set("limit", String(filters.limit ?? 50)); + if (filters.event) params.set("event", filters.event); + if (filters.provider) params.set("provider", filters.provider); + if (filters.identity) params.set("identity", filters.identity); + if (filters.from) params.set("from", filters.from); + if (filters.to) params.set("to", filters.to); + if (filters.cursor) params.set("cursor", filters.cursor); + return params.toString(); +} +``` + +- [ ] **Step 4: Add `fetchAuditEvents`** + +Add this export after the audit query helper: + +```typescript +export async function fetchAuditEvents(filters: AuditFilters = {}): Promise { + const data = await requestJson(`/api/audit/events?${auditQueryString(filters)}`); + const events = buildAuditRows(data.entries); + return { + scope: data.scope ?? "principal", + nextCursor: data.next_cursor ?? null, + events, + total: events.length, + }; +} +``` + +- [ ] **Step 5: Update `fetchDashboard()` audit loading** + +In `fetchDashboard()`, replace: + +```typescript + const isAdmin = whoami.principal_role === "admin"; + const audit = isAdmin ? await requestJson("/api/audit/events?limit=100") : { entries: [] }; +``` + +with: + +```typescript + const isAdmin = whoami.principal_role === "admin"; + const audit = await fetchAuditEvents({ limit: 100 }); +``` + +Then replace the returned `audit` block with: + +```typescript + audit: { + canView: true, + scope: audit.scope, + nextCursor: audit.nextCursor, + total: audit.total, + events: audit.events, + }, +``` + +- [ ] **Step 6: Update imports in `authsome-dashboard.tsx`** + +Add `AuditFilters`, `fetchAuditEvents` to the existing import from `@/lib/authsome-api`: + +```typescript + AuditFilters, + fetchAuditEvents, +``` + +- [ ] **Step 7: Make Audit Log navigation visible to all users** + +In `NAV_ITEMS`, replace: + +```typescript + { id: "audit", href: "/audit", label: "Audit Log", icon: , adminOnly: true }, +``` + +with: + +```typescript + { id: "audit", href: "/audit", label: "Audit Log", icon: }, +``` + +- [ ] **Step 8: Add audit filter helpers** + +Add these helpers above `AuditView`: + +```typescript +function normalizeAuditFilters(filters: AuditFilters): AuditFilters { + return Object.fromEntries( + Object.entries(filters).filter(([, value]) => value !== undefined && value !== null && String(value).trim() !== ""), + ) as AuditFilters; +} + +function localDateTimeToIso(value: string): string | undefined { + if (!value) return undefined; + const parsed = new Date(value); + return Number.isNaN(parsed.valueOf()) ? undefined : parsed.toISOString(); +} +``` + +- [ ] **Step 9: Replace `AuditView`** + +Replace the current `AuditView` function with: + +```tsx +export function AuditView({ data }: { data: DashboardData }) { + const [filters, setFilters] = useState({ event: "", provider: "", identity: "", from: "", to: "" }); + const [events, setEvents] = useState(data.audit.events); + const [nextCursor, setNextCursor] = useState(data.audit.nextCursor); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(""); + const description = data.account.isAdmin + ? "Recent administrative and credential events." + : "Recent account, identity, vault, and credential events for this principal."; + + useEffect(() => { + setEvents(data.audit.events); + setNextCursor(data.audit.nextCursor); + }, [data.audit.events, data.audit.nextCursor]); + + const providerOptions = useMemo( + () => Array.from(new Set(data.providers.map((provider) => provider.name))).sort(), + [data.providers], + ); + const identityOptions = useMemo( + () => Array.from(new Set(data.identities.map((identity) => identity.handle))).sort(), + [data.identities], + ); + + async function loadAuditPage(cursor?: string | null) { + setLoading(true); + setError(""); + try { + const page = await fetchAuditEvents( + normalizeAuditFilters({ + event: filters.event, + provider: filters.provider, + identity: filters.identity, + from: localDateTimeToIso(filters.from), + to: localDateTimeToIso(filters.to), + cursor, + limit: 50, + }), + ); + setEvents((current) => (cursor ? current.concat(page.events) : page.events)); + setNextCursor(page.nextCursor); + } catch (exc) { + setError(exc instanceof Error ? exc.message : "Audit events could not be loaded."); + } finally { + setLoading(false); + } + } + + return ( +
+ + + +
+ + + + + +
+
+ + + {error ? {error} : null} +
+
+
+ + + {events.length ? ( + + + + Time + Event + Actor + Target + Status + + + + {events.map((event) => ( + + {event.time} + {event.event} + {event.actor} + {event.target} + + {event.status === "success" ? ( + + {event.status} + + ) : event.status === "failure" || event.status === "error" ? ( + + {event.status} + + ) : event.status ? ( + {event.status} + ) : null} + + + ))} + +
+ ) : ( +
No audit events found.
+ )} +
+
+ {nextCursor ? ( +
+ +
+ ) : null} +
+ ); +} +``` + +- [ ] **Step 10: Let the authenticated audit page render for users** + +Replace `ui/src/app/(authenticated)/audit/page.tsx` with: + +```tsx +"use client"; + +import useSWR from "swr"; + +import { AuditView } from "@/components/authsome-dashboard"; +import { fetchDashboard } from "@/lib/authsome-api"; + +export default function AuditPage() { + const { data } = useSWR("authsome-dashboard", fetchDashboard); + if (!data) return null; + return ; +} +``` + +- [ ] **Step 11: Let generic view routing render audit for users** + +In `ui/src/components/authsome-dashboard.tsx`, replace: + +```typescript + if (view === "audit" && data.account.isAdmin) return ; +``` + +with: + +```typescript + if (view === "audit") return ; +``` + +- [ ] **Step 12: Run UI lint and type checks** + +Run: + +```bash +uv run pnpm --dir ui lint +uv run pnpm --dir ui exec tsc --noEmit +``` + +Expected: PASS. + +- [ ] **Step 13: Commit UI source changes** + +Run: + +```bash +git add ui/src/lib/authsome-api.ts ui/src/components/authsome-dashboard.tsx ui/src/app/(authenticated)/audit/page.tsx +git commit -m "feat: show scoped audit log in dashboard" +``` + +--- + +### Task 5: Document Role-Aware Audit Log Access + +**Files:** +- Modify: `docs/site/reference/audit-log.mdx` + +- [ ] **Step 1: Update the audit endpoint documentation** + +In `docs/site/reference/audit-log.mdx`, add this section after the paragraph that introduces querying the daemon: + +```mdx +### User-scoped access + +`GET /api/audit/events` is role-aware. Admin principals can review the global audit log. Non-admin principals receive only events scoped to their own principal, including their claimed identities, vault, providers, and credential lifecycle activity. Passing another `principal_id`, vault, or identity in query parameters cannot widen a non-admin query. + +Supported query parameters: + +| Parameter | Description | +| --- | --- | +| `limit` | Number of events to return, clamped to the server maximum. | +| `cursor` | Cursor returned by the previous page. | +| `event` | Exact event name. | +| `provider` | Exact provider name, such as `github`. | +| `identity` | Exact identity handle. | +| `from` | Inclusive lower timestamp bound. | +| `to` | Exclusive upper timestamp bound. | + +Results are sorted newest-first and include `next_cursor` when another page is available. +``` + +- [ ] **Step 2: Run docs-safe checks** + +Run: + +```bash +uv run ruff check src/ tests/ +``` + +Expected: PASS. + +- [ ] **Step 3: Commit docs** + +Run: + +```bash +git add docs/site/reference/audit-log.mdx +git commit -m "docs: describe scoped audit log access" +``` + +--- + +### Task 6: Rebuild Packaged Static UI + +**Files:** +- Generated: `src/authsome/ui/web/**` +- Generated: `ui/out/**` + +- [ ] **Step 1: Build and copy UI assets** + +Run: + +```bash +./scripts/build-ui.sh +``` + +Expected: Next builds successfully, `ui/out` is refreshed, and `src/authsome/ui/web` is replaced with the exported dashboard. + +- [ ] **Step 2: Check generated changes are scoped to UI assets** + +Run: + +```bash +git status --short +``` + +Expected: changes include `src/authsome/ui/web/**` and may include `ui/out/**` if tracked by Git. No Python source should change in this task. + +- [ ] **Step 3: Commit packaged UI assets** + +Run: + +```bash +git add src/authsome/ui/web ui/out +git commit -m "build: refresh dashboard assets" +``` + +--- + +### Task 7: Final Verification + +**Files:** +- Verify all changed files. + +- [ ] **Step 1: Run server tests** + +Run: + +```bash +uv run pytest tests/server/test_audit_events.py +``` + +Expected: PASS. + +- [ ] **Step 2: Run full test suite** + +Run: + +```bash +uv run pytest +``` + +Expected: PASS. + +- [ ] **Step 3: Run Python lint** + +Run: + +```bash +uv run ruff check src/ tests/ +``` + +Expected: PASS. + +- [ ] **Step 4: Run Python type check** + +Run: + +```bash +uv run ty check src/ +``` + +Expected: PASS. + +- [ ] **Step 5: Run UI lint and type check** + +Run: + +```bash +uv run pnpm --dir ui lint +uv run pnpm --dir ui exec tsc --noEmit +``` + +Expected: PASS. + +- [ ] **Step 6: Run pre-commit** + +Run: + +```bash +uv run pre-commit run --all-files +``` + +Expected: PASS. + +- [ ] **Step 7: Inspect final diff** + +Run: + +```bash +git diff --stat main HEAD +git status --short +``` + +Expected: diff contains only audit route/store/tests/docs, UI source, and packaged UI assets. Working tree is clean after the final commit. + +--- + +## Completion Notes + +- Keep all auth/vault/audit boundaries intact: `audit/` emits events only; server routes and Store repositories own authorization and persistence. +- Use `uv run` for every Python, pnpm, lint, type-check, and test command. +- Do not commit directly to `main`; stay on `feature/user-scoped-audit-log`. From e52edc12936ac818f998a194813847eb3e216037 Mon Sep 17 00:00:00 2001 From: beubax Date: Mon, 15 Jun 2026 13:28:56 +0530 Subject: [PATCH 03/15] test: cover user-scoped audit queries --- tests/server/test_audit_events.py | 207 +++++++++++++++++++++++++++++- 1 file changed, 206 insertions(+), 1 deletion(-) diff --git a/tests/server/test_audit_events.py b/tests/server/test_audit_events.py index a047c933..4494de65 100644 --- a/tests/server/test_audit_events.py +++ b/tests/server/test_audit_events.py @@ -1,11 +1,12 @@ import json +from datetime import UTC, datetime from pathlib import Path from urllib.parse import parse_qs, urlparse from fastapi import status from fastapi.testclient import TestClient -from authsome.audit import emit_event +from authsome.audit import AuditEvent, emit, emit_event from authsome.cli.identity import RuntimeIdentity from tests.server.helpers import create_server_test_client from tests.server.test_pop_auth import _auth_header @@ -27,6 +28,31 @@ def _claim_identity(client: TestClient, tmp_path: Path, handle: str, *, email: s assert client.post(f"{claim_path}/confirm", follow_redirects=False).status_code == status.HTTP_303_SEE_OTHER +def _emit_audit_event( # noqa: PLR0913 + event_id: str, + event: str, + *, + principal_id: str | None, + identity: str | None, + provider: str | None = None, + connection: str | None = None, + status: str | None = "success", + timestamp: datetime | None = None, +) -> None: + emit( + AuditEvent( + event_id=event_id, + timestamp=timestamp or datetime(2026, 6, 15, 8, 0, tzinfo=UTC), + event=event, + principal_id=principal_id, + identity=identity, + provider=provider, + connection=connection, + status=status, + ) + ) + + def test_audit_events_endpoint_returns_internal_events_for_admin(monkeypatch, tmp_path: Path) -> None: monkeypatch.setenv("AUTHSOME_HOME", str(tmp_path)) @@ -125,3 +151,182 @@ def test_admin_sees_all_audit_events_and_user_sees_only_own_principal(monkeypatc assert "user_event" in {entry["event"] for entry in user_entries} assert "admin_event" not in {entry["event"] for entry in user_entries} assert all(entry["principal_id"] == user_whoami["principal_id"] for entry in user_entries) + + +def test_non_admin_audit_filters_stay_within_own_principal(monkeypatch, tmp_path: Path) -> None: + monkeypatch.setenv("AUTHSOME_HOME", str(tmp_path)) + + with create_server_test_client() as client: + _claim_identity(client, tmp_path, "admin-ready-boldly-0001", email="admin@example.com") + _claim_identity(client, tmp_path, "steady-wisely-boldly-0042", email="user@example.com") + admin_whoami = client.get( + "/api/whoami", + headers=_auth_header(tmp_path, "GET", "/api/whoami", handle="admin-ready-boldly-0001"), + ).json() + user_whoami = client.get( + "/api/whoami", + headers=_auth_header(tmp_path, "GET", "/api/whoami", handle="steady-wisely-boldly-0042"), + ).json() + _emit_audit_event( + "audit_001", + "connection.login", + principal_id=admin_whoami["principal_id"], + identity="admin-ready-boldly-0001", + provider="github", + ) + _emit_audit_event( + "audit_002", + "connection.login", + principal_id=user_whoami["principal_id"], + identity="steady-wisely-boldly-0042", + provider="github", + ) + _emit_audit_event( + "audit_003", + "connection.logout", + principal_id=user_whoami["principal_id"], + identity="steady-wisely-boldly-0042", + provider="linear", + ) + + response = client.get( + "/api/audit/events?provider=github&limit=10", + headers=_auth_header( + tmp_path, + "GET", + "/api/audit/events?provider=github&limit=10", + handle="steady-wisely-boldly-0042", + ), + ) + + assert response.status_code == status.HTTP_200_OK + body = response.json() + assert body["scope"] == "principal" + assert body["next_cursor"] is None + assert [entry["event_id"] for entry in body["entries"]] == ["audit_002"] + assert body["entries"][0]["principal_id"] == user_whoami["principal_id"] + assert body["entries"][0]["provider"] == "github" + + +def test_non_admin_audit_query_cannot_widen_scope_with_principal_or_identity( + monkeypatch, + tmp_path: Path, +) -> None: + monkeypatch.setenv("AUTHSOME_HOME", str(tmp_path)) + + with create_server_test_client() as client: + _claim_identity(client, tmp_path, "admin-ready-boldly-0001", email="admin@example.com") + _claim_identity(client, tmp_path, "steady-wisely-boldly-0042", email="user@example.com") + admin_whoami = client.get( + "/api/whoami", + headers=_auth_header(tmp_path, "GET", "/api/whoami", handle="admin-ready-boldly-0001"), + ).json() + user_whoami = client.get( + "/api/whoami", + headers=_auth_header(tmp_path, "GET", "/api/whoami", handle="steady-wisely-boldly-0042"), + ).json() + _emit_audit_event( + "audit_010", + "connection.login", + principal_id=admin_whoami["principal_id"], + identity="admin-ready-boldly-0001", + provider="github", + ) + _emit_audit_event( + "audit_011", + "connection.login", + principal_id=user_whoami["principal_id"], + identity="steady-wisely-boldly-0042", + provider="github", + ) + + path = ( + f"/api/audit/events?principal_id={admin_whoami['principal_id']}&identity=admin-ready-boldly-0001&limit=10" + ) + response = client.get( + path, + headers=_auth_header( + tmp_path, + "GET", + path, + handle="steady-wisely-boldly-0042", + ), + ) + + assert response.status_code == status.HTTP_200_OK + body = response.json() + assert body["scope"] == "principal" + assert body["entries"] == [] + + +def test_admin_audit_events_support_filters_and_cursor_pagination(monkeypatch, tmp_path: Path) -> None: + monkeypatch.setenv("AUTHSOME_HOME", str(tmp_path)) + + with create_server_test_client() as client: + _claim_identity(client, tmp_path, "admin-ready-boldly-0001", email="admin@example.com") + _claim_identity(client, tmp_path, "steady-wisely-boldly-0042", email="user@example.com") + admin_whoami = client.get( + "/api/whoami", + headers=_auth_header(tmp_path, "GET", "/api/whoami", handle="admin-ready-boldly-0001"), + ).json() + user_whoami = client.get( + "/api/whoami", + headers=_auth_header(tmp_path, "GET", "/api/whoami", handle="steady-wisely-boldly-0042"), + ).json() + _emit_audit_event( + "audit_100", + "connection.login", + principal_id=admin_whoami["principal_id"], + identity="admin-ready-boldly-0001", + provider="github", + timestamp=datetime(2026, 6, 15, 8, 0, tzinfo=UTC), + ) + _emit_audit_event( + "audit_101", + "connection.login", + principal_id=user_whoami["principal_id"], + identity="steady-wisely-boldly-0042", + provider="github", + timestamp=datetime(2026, 6, 15, 8, 1, tzinfo=UTC), + ) + _emit_audit_event( + "audit_102", + "connection.logout", + principal_id=user_whoami["principal_id"], + identity="steady-wisely-boldly-0042", + provider="github", + timestamp=datetime(2026, 6, 15, 8, 2, tzinfo=UTC), + ) + + first_path = "/api/audit/events?provider=github&limit=2" + first_response = client.get( + first_path, + headers=_auth_header( + tmp_path, + "GET", + first_path, + handle="admin-ready-boldly-0001", + ), + ) + assert first_response.status_code == status.HTTP_200_OK + first_body = first_response.json() + second_path = f"/api/audit/events?provider=github&limit=2&cursor={first_body['next_cursor']}" + second_response = client.get( + second_path, + headers=_auth_header( + tmp_path, + "GET", + second_path, + handle="admin-ready-boldly-0001", + ), + ) + + assert first_body["scope"] == "global" + assert [entry["event_id"] for entry in first_body["entries"]] == ["audit_102", "audit_101"] + assert first_body["next_cursor"] + + assert second_response.status_code == status.HTTP_200_OK + second_body = second_response.json() + assert second_body["scope"] == "global" + assert [entry["event_id"] for entry in second_body["entries"]] == ["audit_100"] + assert second_body["next_cursor"] is None From 3a1a564673e054be14880c44782c42fcc9741ce6 Mon Sep 17 00:00:00 2001 From: beubax Date: Mon, 15 Jun 2026 13:34:50 +0530 Subject: [PATCH 04/15] feat: add paged audit event queries --- src/authsome/server/store/repositories.py | 132 +++++++++++++++++++--- 1 file changed, 118 insertions(+), 14 deletions(-) diff --git a/src/authsome/server/store/repositories.py b/src/authsome/server/store/repositories.py index d1a029ae..35be29d8 100644 --- a/src/authsome/server/store/repositories.py +++ b/src/authsome/server/store/repositories.py @@ -1,6 +1,8 @@ """Typed repositories for server-owned relational Store records.""" import asyncio +import base64 +import binascii import builtins import json import threading @@ -65,6 +67,42 @@ class AuditEventInsert: payload: dict[str, Any] +@dataclass(frozen=True) +class AuditEventPage: + """Paged audit event query result.""" + + entries: list[dict[str, Any]] + next_cursor: str | None + + +def _encode_audit_cursor(*, timestamp: str, event_id: str) -> str: + payload = json.dumps( + {"event_id": event_id, "timestamp": timestamp}, + separators=(",", ":"), + sort_keys=True, + ).encode("utf-8") + return base64.urlsafe_b64encode(payload).decode("ascii").rstrip("=") + + +def _decode_audit_cursor(cursor: str) -> tuple[str, str]: + valid_chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_" + if not cursor or any(char not in valid_chars for char in cursor): + raise ValueError("Invalid audit cursor") + try: + padded_cursor = cursor + "=" * (-len(cursor) % 4) + decoded = base64.urlsafe_b64decode(padded_cursor.encode("ascii")).decode("utf-8") + payload = json.loads(decoded) + if not isinstance(payload, dict): + raise ValueError + timestamp = payload.get("timestamp") + event_id = payload.get("event_id") + if not isinstance(timestamp, str) or not timestamp or not isinstance(event_id, str) or not event_id: + raise ValueError + except (binascii.Error, json.JSONDecodeError, UnicodeError, ValueError, TypeError): + raise ValueError("Invalid audit cursor") from None + return timestamp, event_id + + class AuditEventRegistry: """Relational audit event registry.""" @@ -96,21 +134,63 @@ async def insert_many(self, events: list[AuditEventInsert]) -> None: ], ) - async def list_recent(self, *, limit: int = 50, principal_id: str | None = None) -> list[dict[str, Any]]: + async def query_events( # noqa: PLR0913 + self, + *, + limit: int = 50, + principal_id: str | None = None, + event: str | None = None, + provider: str | None = None, + identity: str | None = None, + since: datetime | None = None, + until: datetime | None = None, + cursor: str | None = None, + ) -> AuditEventPage: bounded_limit = min(max(limit, 1), 500) - if principal_id is None: - rows = await self._db.fetch_all( - "SELECT payload_json FROM audit_events ORDER BY timestamp DESC, event_id DESC LIMIT ?", - [bounded_limit], - ) - else: - rows = await self._db.fetch_all( - "SELECT payload_json FROM audit_events " - "WHERE principal_id = ? " - "ORDER BY timestamp DESC, event_id DESC LIMIT ?", - [principal_id, bounded_limit], - ) - return [json.loads(row["payload_json"]) for row in rows] + conditions: list[str] = [] + params: list[Any] = [] + + if principal_id is not None: + conditions.append("principal_id = ?") + params.append(principal_id) + if event: + conditions.append("event = ?") + params.append(event) + if provider: + conditions.append("provider = ?") + params.append(provider) + if identity: + conditions.append("identity = ?") + params.append(identity) + if since is not None: + conditions.append("timestamp >= ?") + params.append(_dump_dt(since)) + if until is not None: + conditions.append("timestamp < ?") + params.append(_dump_dt(until)) + if cursor: + cursor_timestamp, cursor_event_id = _decode_audit_cursor(cursor) + conditions.append("(timestamp < ? OR (timestamp = ? AND event_id < ?))") + params.extend([cursor_timestamp, cursor_timestamp, cursor_event_id]) + + where_clause = f" WHERE {' AND '.join(conditions)}" if conditions else "" + rows = await self._db.fetch_all( + "SELECT event_id, timestamp, payload_json FROM audit_events" + f"{where_clause} " + "ORDER BY timestamp DESC, event_id DESC LIMIT ?", + [*params, bounded_limit + 1], + ) + visible_rows = rows[:bounded_limit] + entries = [json.loads(row["payload_json"]) for row in visible_rows] + next_cursor = None + if len(rows) > bounded_limit and visible_rows: + last = visible_rows[-1] + next_cursor = _encode_audit_cursor(timestamp=last["timestamp"], event_id=last["event_id"]) + return AuditEventPage(entries=entries, next_cursor=next_cursor) + + async def list_recent(self, *, limit: int = 50, principal_id: str | None = None) -> list[dict[str, Any]]: + page = await self.query_events(limit=limit, principal_id=principal_id) + return page.entries def configure_exporter(self, loop: asyncio.AbstractEventLoop | None = None): """Configure the process OTel logger provider to export audit logs to Store.""" @@ -260,6 +340,30 @@ async def async_force_flush(self) -> None: self._provider.force_flush() await self._exporter.async_force_flush() + async def query_events( # noqa: PLR0913 + self, + *, + limit: int = 50, + principal_id: str | None = None, + event: str | None = None, + provider: str | None = None, + identity: str | None = None, + since: datetime | None = None, + until: datetime | None = None, + cursor: str | None = None, + ) -> AuditEventPage: + await self.async_force_flush() + return await self._registry.query_events( + limit=limit, + principal_id=principal_id, + event=event, + provider=provider, + identity=identity, + since=since, + until=until, + cursor=cursor, + ) + async def list_events(self, *, limit: int = 50, principal_id: str | None = None) -> list[dict[str, Any]]: await self.async_force_flush() return await self._registry.list_recent(limit=limit, principal_id=principal_id) From 08446d1e2837c2553fd6c7b537161dc2c83367bf Mon Sep 17 00:00:00 2001 From: beubax Date: Mon, 15 Jun 2026 13:43:24 +0530 Subject: [PATCH 05/15] feat: enforce role-aware audit event queries --- src/authsome/server/routes/audit.py | 32 ++++++++++++++++++++++++----- 1 file changed, 27 insertions(+), 5 deletions(-) diff --git a/src/authsome/server/routes/audit.py b/src/authsome/server/routes/audit.py index 3b534d6e..04d44593 100644 --- a/src/authsome/server/routes/audit.py +++ b/src/authsome/server/routes/audit.py @@ -1,8 +1,9 @@ """Audit event routes.""" -from typing import Any +from datetime import datetime +from typing import Annotated, Any, Literal -from fastapi import APIRouter, Depends, Request +from fastapi import APIRouter, Depends, HTTPException, Query, Request, status from authsome import audit from authsome.identity.principal import PrincipalRole @@ -16,13 +17,34 @@ @router.get("/events") -async def list_audit_events( +async def list_audit_events( # noqa: PLR0913 request: Request, limit: int = 50, + cursor: str | None = None, + event: str | None = None, + provider: str | None = None, + identity: str | None = None, + principal_id: str | None = None, + from_: Annotated[datetime | None, Query(alias="from")] = None, + to: datetime | None = None, auth: CredentialService = Depends(get_daemon_or_browser_auth_service), ) -> dict[str, Any]: - principal_id = None if auth.principal_role == PrincipalRole.ADMIN else auth.principal_id - return {"entries": await request.app.state.audit_log.list_events(limit=limit, principal_id=principal_id)} + effective_principal_id = principal_id if auth.principal_role == PrincipalRole.ADMIN else auth.principal_id + scope: Literal["global", "principal"] = "global" if effective_principal_id is None else "principal" + try: + page = await request.app.state.audit_log.query_events( + limit=limit, + principal_id=effective_principal_id, + event=event, + provider=provider, + identity=identity, + since=from_, + until=to, + cursor=cursor, + ) + except ValueError as exc: + raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc + return {"entries": page.entries, "next_cursor": page.next_cursor, "scope": scope} @router.post("/events") From 0b8d752392eb8d87277a423501df012b127b8fb8 Mon Sep 17 00:00:00 2001 From: beubax Date: Mon, 15 Jun 2026 13:55:49 +0530 Subject: [PATCH 06/15] feat: show scoped audit log in dashboard --- ui/src/app/(authenticated)/audit/page.tsx | 2 +- ui/src/components/authsome-dashboard.tsx | 230 +++++++++++++++++++++- ui/src/lib/authsome-api.ts | 56 +++++- 3 files changed, 277 insertions(+), 11 deletions(-) diff --git a/ui/src/app/(authenticated)/audit/page.tsx b/ui/src/app/(authenticated)/audit/page.tsx index 282f21ce..90b5d240 100644 --- a/ui/src/app/(authenticated)/audit/page.tsx +++ b/ui/src/app/(authenticated)/audit/page.tsx @@ -7,6 +7,6 @@ import { fetchDashboard } from "@/lib/authsome-api"; export default function AuditPage() { const { data } = useSWR("authsome-dashboard", fetchDashboard); - if (!data || !data.account.isAdmin) return null; + if (!data) return null; return ; } diff --git a/ui/src/components/authsome-dashboard.tsx b/ui/src/components/authsome-dashboard.tsx index ec0fce08..14f541ed 100644 --- a/ui/src/components/authsome-dashboard.tsx +++ b/ui/src/components/authsome-dashboard.tsx @@ -26,11 +26,12 @@ import { import Image from "next/image"; import Link from "next/link"; import { usePathname, useRouter, useSearchParams } from "next/navigation"; -import { FormEvent, ReactNode, useEffect, useMemo, useState } from "react"; +import { FormEvent, ReactNode, useEffect, useMemo, useRef, useState } from "react"; import useSWR from "swr"; import { ApiError, + AuditFilters, ConnectionDetail, DashboardData, GlobalConnectionRow, @@ -38,6 +39,7 @@ import { ProviderDetail, ProviderView, SessionInputField, + fetchAuditEvents, fetchAuthSessionStatus, fetchClaimStatus, fetchConnectionDetail, @@ -105,7 +107,7 @@ const NAV_ITEMS: NavItem[] = [ { id: "agents", href: "/agents", label: "Agents", icon: }, { id: "principals", href: "/principal", label: "Principals", icon: , adminOnly: true }, { id: "vault", href: "/vault", label: "Vault", icon: }, - { id: "audit", href: "/audit", label: "Audit Log", icon: , adminOnly: true }, + { id: "audit", href: "/audit", label: "Audit Log", icon: }, { id: "settings", href: "/settings", label: "Settings", icon: }, ]; @@ -1412,13 +1414,222 @@ export function VaultView({ data }: { data: DashboardData }) { ); } +function normalizeAuditFilters(filters: AuditFilters): AuditFilters { + return Object.fromEntries( + Object.entries(filters) + .map(([key, value]) => [key, typeof value === "string" ? value.trim() : value]) + .filter(([, value]) => value !== undefined && value !== null && String(value).trim() !== ""), + ) as AuditFilters; +} + +function localDateTimeToIso(value: string): string | undefined { + if (!value) return undefined; + const parsed = new Date(value); + return Number.isNaN(parsed.valueOf()) ? undefined : parsed.toISOString(); +} + export function AuditView({ data }: { data: DashboardData }) { + const [filters, setFilters] = useState({ + event: "", + provider: "", + identity: "", + from: "", + to: "", + }); + const [auditResult, setAuditResult] = useState<{ + activeFilters: AuditFilters; + events: DashboardData["audit"]["events"]; + nextCursor: string | null; + } | null>(null); + const [errorMessage, setErrorMessage] = useState(""); + const [loadingAction, setLoadingAction] = useState<"apply" | "more" | null>(null); + const requestSequence = useRef(0); + const events = auditResult?.events ?? data.audit.events; + const nextCursor = auditResult?.nextCursor ?? data.audit.nextCursor; + const activeFilters = auditResult?.activeFilters ?? {}; + const providerNames = useMemo(() => data.providers.map((provider) => provider.name).sort(), [data.providers]); + const identityHandles = useMemo(() => data.identities.map((identity) => identity.handle).sort(), [data.identities]); + const eventNames = useMemo(() => Array.from(new Set(events.map((event) => event.eventName))).sort(), [events]); + const description = data.account.isAdmin + ? "Recent administrative and credential events." + : "Recent account, identity, vault, and credential events for this principal."; + + function updateFilter(name: keyof Pick, value: string) { + setFilters((current) => ({ ...current, [name]: value })); + } + + function filtersForRequest(cursor?: string | null): AuditFilters { + return normalizeAuditFilters({ + event: filters.event, + provider: filters.provider, + identity: filters.identity, + from: localDateTimeToIso(filters.from || ""), + to: localDateTimeToIso(filters.to || ""), + cursor, + limit: 50, + }); + } + + function nextRequestId(): number { + requestSequence.current += 1; + return requestSequence.current; + } + + function isLatestRequest(requestId: number): boolean { + return requestSequence.current === requestId; + } + + async function applyFilters(event: FormEvent) { + event.preventDefault(); + const requestFilters = filtersForRequest(); + const requestId = nextRequestId(); + setLoadingAction("apply"); + setErrorMessage(""); + try { + const result = await fetchAuditEvents(requestFilters); + if (!isLatestRequest(requestId)) return; + setAuditResult({ + activeFilters: requestFilters, + events: result.events, + nextCursor: result.nextCursor, + }); + } catch (error) { + if (!isLatestRequest(requestId)) return; + setErrorMessage(error instanceof Error ? error.message : "Failed to load audit events."); + } finally { + if (isLatestRequest(requestId)) { + setLoadingAction(null); + } + } + } + + async function clearFilters() { + const emptyFilters = { event: "", provider: "", identity: "", from: "", to: "" }; + const requestId = nextRequestId(); + setFilters(emptyFilters); + setLoadingAction("apply"); + setErrorMessage(""); + try { + const result = await fetchAuditEvents({ limit: 50 }); + if (!isLatestRequest(requestId)) return; + setAuditResult({ + activeFilters: {}, + events: result.events, + nextCursor: result.nextCursor, + }); + } catch (error) { + if (!isLatestRequest(requestId)) return; + setErrorMessage(error instanceof Error ? error.message : "Failed to load audit events."); + } finally { + if (isLatestRequest(requestId)) { + setLoadingAction(null); + } + } + } + + async function loadMore() { + if (!nextCursor) return; + const requestId = nextRequestId(); + setLoadingAction("more"); + setErrorMessage(""); + try { + const result = await fetchAuditEvents({ ...activeFilters, cursor: nextCursor, limit: 50 }); + if (!isLatestRequest(requestId)) return; + setAuditResult({ + activeFilters, + events: [...events, ...result.events], + nextCursor: result.nextCursor, + }); + } catch (error) { + if (!isLatestRequest(requestId)) return; + setErrorMessage(error instanceof Error ? error.message : "Failed to load more audit events."); + } finally { + if (isLatestRequest(requestId)) { + setLoadingAction(null); + } + } + } + return (
- + + + + Filters + {data.audit.scope === "global" ? "Global audit scope" : "Principal audit scope"} + + +
void applyFilters(event)}> + + + + + + + {eventNames.map((event) => ( + + + {providerNames.map((provider) => ( + + + {identityHandles.map((identity) => ( + +
+ + +
+
+ {errorMessage ?

{errorMessage}

: null} +
+
- {data.audit.events.length ? ( + {events.length ? ( @@ -1430,7 +1641,7 @@ export function AuditView({ data }: { data: DashboardData }) { - {data.audit.events.map((event) => ( + {events.map((event) => ( {event.time} {event.event} @@ -1458,6 +1669,13 @@ export function AuditView({ data }: { data: DashboardData }) { )} + {nextCursor ? ( +
+ +
+ ) : null} ); } @@ -2051,7 +2269,7 @@ function ActiveView({ connectionFilter, data, onRefresh, view }: { if (view === "agents") return ; if (view === "principals") return ; if (view === "vault") return ; - if (view === "audit" && data.account.isAdmin) return ; + if (view === "audit") return ; if (view === "settings") return ; return ; } diff --git a/ui/src/lib/authsome-api.ts b/ui/src/lib/authsome-api.ts index ab130baa..8f354372 100644 --- a/ui/src/lib/authsome-api.ts +++ b/ui/src/lib/authsome-api.ts @@ -42,6 +42,7 @@ export type IdentityRow = { export type AuditRow = { eventId: string; time: string; + eventName: string; event: string; source: string; actor: string; @@ -50,6 +51,23 @@ export type AuditRow = { metadata: Record; }; +export type AuditFilters = { + event?: string; + provider?: string; + identity?: string; + from?: string; + to?: string; + cursor?: string | null; + limit?: number; +}; + +export type AuditEventsData = { + scope: "global" | "principal"; + nextCursor: string | null; + events: AuditRow[]; + total: number; +}; + export type DashboardData = { version: string; account: { @@ -73,6 +91,8 @@ export type DashboardData = { }; audit: { canView: boolean; + scope: "global" | "principal"; + nextCursor: string | null; total: number; events: AuditRow[]; }; @@ -216,6 +236,8 @@ type ConnectionsResponse = { type AuditResponse = { entries: Array>; + next_cursor?: string | null; + scope?: "global" | "principal"; }; export type PrincipalRow = { @@ -472,6 +494,7 @@ function buildAuditRows(entries: AuditResponse["entries"]): AuditRow[] { return { eventId: String(entry.event_id || `${entry.timestamp || "event"}-${index}`), time: formatAuditTime(entry.timestamp), + eventName: String(entry.event || "audit_event"), event: humanize(entry.event), source: String(entry.source || "internal"), actor: String(entry.identity || entry.principal_id || "system"), @@ -482,6 +505,29 @@ function buildAuditRows(entries: AuditResponse["entries"]): AuditRow[] { }); } +function auditQueryString(filters: AuditFilters = {}): string { + const params = new URLSearchParams(); + params.set("limit", String(filters.limit ?? 50)); + if (filters.event) params.set("event", filters.event); + if (filters.provider) params.set("provider", filters.provider); + if (filters.identity) params.set("identity", filters.identity); + if (filters.from) params.set("from", filters.from); + if (filters.to) params.set("to", filters.to); + if (filters.cursor) params.set("cursor", filters.cursor); + return params.toString(); +} + +export async function fetchAuditEvents(filters: AuditFilters = {}): Promise { + const data = await requestJson(`/api/audit/events?${auditQueryString(filters)}`); + const events = buildAuditRows(data.entries); + return { + scope: data.scope ?? "principal", + nextCursor: data.next_cursor ?? null, + events, + total: events.length, + }; +} + function roleLabel(role: string | undefined): string | null { if (!role) { return null; @@ -496,7 +542,7 @@ export async function fetchDashboard(): Promise { requestJson("/api/connections"), ]); const isAdmin = whoami.principal_role === "admin"; - const audit = isAdmin ? await requestJson("/api/audit/events?limit=100") : { entries: [] }; + const audit = await fetchAuditEvents({ limit: 100 }); const providers = buildProviders(connectionsData); const connections = buildConnectionRows(connectionsData, providers); const globalConnections = buildGlobalConnectionRows(connectionsData); @@ -534,9 +580,11 @@ export async function fetchDashboard(): Promise { isDefault: true, }, audit: { - canView: isAdmin, - total: audit.entries.length, - events: buildAuditRows(audit.entries), + canView: true, + scope: audit.scope, + nextCursor: audit.nextCursor, + total: audit.total, + events: audit.events, }, }; } From 80cb2499844ac9373420dff1a895e0ac0641f892 Mon Sep 17 00:00:00 2001 From: beubax Date: Mon, 15 Jun 2026 14:07:51 +0530 Subject: [PATCH 07/15] docs: describe scoped audit log access --- docs/site/reference/audit-log.mdx | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/docs/site/reference/audit-log.mdx b/docs/site/reference/audit-log.mdx index 68ae8d43..6a95e073 100644 --- a/docs/site/reference/audit-log.mdx +++ b/docs/site/reference/audit-log.mdx @@ -49,6 +49,27 @@ authsome log # Print recent audit events authsome log --json # Output JSON format ``` +### User-scoped access + +`GET /api/audit/events` is role-aware. Admin principals can review the global audit log. +Non-admin principals receive only events scoped to their own principal, including their +claimed identities, vault, providers, and credential lifecycle activity. Passing another +`principal_id`, vault, or identity in query parameters cannot widen a non-admin query. + +Supported query parameters: + +| Parameter | Description | +| --- | --- | +| `limit` | Number of events to return, clamped to the server maximum. | +| `cursor` | Cursor returned by the previous page. | +| `event` | Exact event name. | +| `provider` | Exact provider name, such as `github`. | +| `identity` | Exact identity handle. | +| `from` | Inclusive lower timestamp bound. | +| `to` | Exclusive upper timestamp bound. | + +Results are sorted newest-first and include `next_cursor` when another page is available. + ## Privacy and Secrets **What the log contains:** From d54c4159bac2145e62b1ad1e89b8570284cdf932 Mon Sep 17 00:00:00 2001 From: beubax Date: Mon, 15 Jun 2026 14:15:25 +0530 Subject: [PATCH 08/15] fix: avoid audit shutdown deadlock --- src/authsome/server/app.py | 4 ++-- src/authsome/server/store/repositories.py | 11 ++++++++++- tests/auth/test_service.py | 2 +- tests/server/test_audit_events.py | 19 +++++++++++++++++++ .../server/test_runtime_backend_selection.py | 3 +++ 5 files changed, 35 insertions(+), 4 deletions(-) diff --git a/src/authsome/server/app.py b/src/authsome/server/app.py index 11c882f6..58db3d09 100644 --- a/src/authsome/server/app.py +++ b/src/authsome/server/app.py @@ -40,7 +40,7 @@ async def _cleanup_startup_resources(store, audit_log, runtime_state) -> None: with suppress(Exception): if audit_log is not None: - audit_log.shutdown() + await audit_log.async_shutdown() with suppress(Exception): if store is not None: await store.close() @@ -99,7 +99,7 @@ async def lifespan(app: FastAPI): try: shutdown_posthog() if audit_log is not None: - audit_log.shutdown() + await audit_log.async_shutdown() if store is not None: await store.close() finally: diff --git a/src/authsome/server/store/repositories.py b/src/authsome/server/store/repositories.py index 35be29d8..9b27a252 100644 --- a/src/authsome/server/store/repositories.py +++ b/src/authsome/server/store/repositories.py @@ -271,6 +271,10 @@ def shutdown(self) -> None: self._closed = True self.force_flush() + def close(self) -> None: + self._closed = True + self._drop_finished_futures() + def _is_loop_thread(self) -> bool: try: return asyncio.get_running_loop() is self._loop @@ -337,9 +341,14 @@ def force_flush(self) -> None: self._exporter.force_flush() async def async_force_flush(self) -> None: - self._provider.force_flush() + await asyncio.to_thread(self._provider.force_flush) await self._exporter.async_force_flush() + async def async_shutdown(self) -> None: + await self.async_force_flush() + _delegating_audit_exporter.set_active(None) + self._exporter.close() + async def query_events( # noqa: PLR0913 self, *, diff --git a/tests/auth/test_service.py b/tests/auth/test_service.py index dbe6d88c..f44f8eb8 100644 --- a/tests/auth/test_service.py +++ b/tests/auth/test_service.py @@ -147,7 +147,7 @@ async def audit_log(self, tmp_path) -> ServerAuditLog: # noqa: ANN001 try: yield log finally: - log.shutdown() + await log.async_shutdown() await store.close() @pytest.fixture diff --git a/tests/server/test_audit_events.py b/tests/server/test_audit_events.py index 4494de65..096ccc35 100644 --- a/tests/server/test_audit_events.py +++ b/tests/server/test_audit_events.py @@ -1,13 +1,16 @@ +import asyncio import json from datetime import UTC, datetime from pathlib import Path from urllib.parse import parse_qs, urlparse +import pytest from fastapi import status from fastapi.testclient import TestClient from authsome.audit import AuditEvent, emit, emit_event from authsome.cli.identity import RuntimeIdentity +from authsome.server.store import create_server_store from tests.server.helpers import create_server_test_client from tests.server.test_pop_auth import _auth_header @@ -330,3 +333,19 @@ def test_admin_audit_events_support_filters_and_cursor_pagination(monkeypatch, t assert second_body["scope"] == "global" assert [entry["event_id"] for entry in second_body["entries"]] == ["audit_100"] assert second_body["next_cursor"] is None + + +@pytest.mark.asyncio +async def test_audit_log_async_shutdown_flushes_events_without_blocking_loop(tmp_path: Path) -> None: + store = await create_server_store(home=tmp_path) + audit_log = store.audit_events.configure_exporter() + try: + emit_event("shutdown.flush", identity="agent-a", principal_id="principal_a", provider="github") + + await asyncio.wait_for(audit_log.async_shutdown(), timeout=1) + + entries = await store.audit_events.list_recent(limit=10, principal_id="principal_a") + finally: + await store.close() + + assert [entry["event"] for entry in entries] == ["shutdown.flush"] diff --git a/tests/server/test_runtime_backend_selection.py b/tests/server/test_runtime_backend_selection.py index 8003ed04..df816d86 100644 --- a/tests/server/test_runtime_backend_selection.py +++ b/tests/server/test_runtime_backend_selection.py @@ -50,6 +50,9 @@ def __init__(self) -> None: def shutdown(self) -> None: self.shutdown_called = True + async def async_shutdown(self) -> None: + self.shutdown() + class FakeStore: def __init__(self, home: Path, audit_log: FakeAuditLog) -> None: From 711b8509aa702d1dd92f43675d704baa45020e6f Mon Sep 17 00:00:00 2001 From: beubax Date: Mon, 15 Jun 2026 14:18:07 +0530 Subject: [PATCH 09/15] chore: remove obsolete implementation plans and design specifications --- .../plans/2026-06-15-user-scoped-audit-log.md | 1154 ----------------- ...stateless-production-deployments-design.md | 310 ----- ...2026-06-15-user-scoped-audit-log-design.md | 119 -- 3 files changed, 1583 deletions(-) delete mode 100644 docs/superpowers/plans/2026-06-15-user-scoped-audit-log.md delete mode 100644 docs/superpowers/specs/2026-06-10-stateless-production-deployments-design.md delete mode 100644 docs/superpowers/specs/2026-06-15-user-scoped-audit-log-design.md diff --git a/docs/superpowers/plans/2026-06-15-user-scoped-audit-log.md b/docs/superpowers/plans/2026-06-15-user-scoped-audit-log.md deleted file mode 100644 index aa8c92ce..00000000 --- a/docs/superpowers/plans/2026-06-15-user-scoped-audit-log.md +++ /dev/null @@ -1,1154 +0,0 @@ -# User-Scoped Audit Log Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Let non-admin users view a server-authorized, principal-scoped audit log with filters and pagination while preserving the admin global audit view. - -**Architecture:** Keep `GET /api/audit/events` as the single role-aware audit read endpoint. Add a Store-backed paged audit query method, have the route enforce effective scope from the authenticated principal, and update the dashboard to fetch and render audit events for all authenticated users. - -**Tech Stack:** FastAPI, Pydantic/FastAPI query validation, SQLite/Postgres-compatible parameterized SQL through `StoreDatabase`, React 19, Next 16 App Router static export, SWR, TypeScript, uv, pnpm, pytest, ruff, ty. - ---- - -## File Structure - -- Modify `src/authsome/server/store/repositories.py` - - Add `AuditEventPage`. - - Add cursor helpers for newest-first pagination. - - Add `AuditEventRegistry.query_events`. - - Add `ServerAuditLog.query_events`. - - Keep `ServerAuditLog.list_events` returning a plain list for existing tests. -- Modify `src/authsome/server/routes/audit.py` - - Parse audit filters and pagination. - - Compute effective principal scope from `CredentialService`. - - Return `entries`, `next_cursor`, and `scope`. -- Modify `tests/server/test_audit_events.py` - - Add route tests for forced non-admin scope, filter behavior, and cursor pagination. -- Modify `ui/src/lib/authsome-api.ts` - - Export audit filter/page types. - - Add `fetchAuditEvents`. - - Fetch audit events for all dashboard users. -- Modify `ui/src/components/authsome-dashboard.tsx` - - Show Audit Log navigation to all users. - - Add filter controls and load-more behavior to `AuditView`. - - Use different section copy for admin vs non-admin. -- Modify `ui/src/app/(authenticated)/audit/page.tsx` - - Render the audit page for every authenticated user. -- Modify `docs/site/reference/audit-log.mdx` - - Document role-aware scoping, supported filters, and pagination. -- Generated by `scripts/build-ui.sh` - - Refresh `src/authsome/ui/web/**` from `ui/out/**`. - ---- - -### Task 1: Add Failing Server Audit Query Tests - -**Files:** -- Modify: `tests/server/test_audit_events.py` - -- [ ] **Step 1: Extend imports** - -Change the import block at the top of `tests/server/test_audit_events.py` to include deterministic timestamps and direct audit event emission: - -```python -import json -from datetime import UTC, datetime -from pathlib import Path -from urllib.parse import parse_qs, urlparse - -from fastapi import status -from fastapi.testclient import TestClient - -from authsome.audit import AuditEvent, emit, emit_event -from authsome.cli.identity import RuntimeIdentity -from tests.server.helpers import create_server_test_client -from tests.server.test_pop_auth import _auth_header -``` - -- [ ] **Step 2: Add a deterministic audit helper** - -Add this helper after `_claim_identity`: - -```python -def _emit_audit_event( - event_id: str, - event: str, - *, - principal_id: str | None, - identity: str | None, - provider: str | None = None, - connection: str | None = None, - status: str | None = "success", - timestamp: datetime | None = None, -) -> None: - emit( - AuditEvent( - event_id=event_id, - timestamp=timestamp or datetime(2026, 6, 15, 8, 0, tzinfo=UTC), - event=event, - principal_id=principal_id, - identity=identity, - provider=provider, - connection=connection, - status=status, - ) - ) -``` - -- [ ] **Step 3: Add non-admin scope and filter test** - -Append this test to `tests/server/test_audit_events.py`: - -```python -def test_non_admin_audit_filters_stay_within_own_principal(monkeypatch, tmp_path: Path) -> None: - monkeypatch.setenv("AUTHSOME_HOME", str(tmp_path)) - - with create_server_test_client() as client: - _claim_identity(client, tmp_path, "admin-ready-boldly-0001", email="admin@example.com") - _claim_identity(client, tmp_path, "steady-wisely-boldly-0042", email="user@example.com") - admin_whoami = client.get( - "/api/whoami", - headers=_auth_header(tmp_path, "GET", "/api/whoami", handle="admin-ready-boldly-0001"), - ).json() - user_whoami = client.get( - "/api/whoami", - headers=_auth_header(tmp_path, "GET", "/api/whoami", handle="steady-wisely-boldly-0042"), - ).json() - _emit_audit_event( - "audit_001", - "connection.login", - principal_id=admin_whoami["principal_id"], - identity="admin-ready-boldly-0001", - provider="github", - ) - _emit_audit_event( - "audit_002", - "connection.login", - principal_id=user_whoami["principal_id"], - identity="steady-wisely-boldly-0042", - provider="github", - ) - _emit_audit_event( - "audit_003", - "connection.logout", - principal_id=user_whoami["principal_id"], - identity="steady-wisely-boldly-0042", - provider="linear", - ) - - response = client.get( - "/api/audit/events?provider=github&limit=10", - headers=_auth_header( - tmp_path, - "GET", - "/api/audit/events?provider=github&limit=10", - handle="steady-wisely-boldly-0042", - ), - ) - - assert response.status_code == status.HTTP_200_OK - body = response.json() - assert body["scope"] == "principal" - assert body["next_cursor"] is None - assert [entry["event_id"] for entry in body["entries"]] == ["audit_002"] - assert body["entries"][0]["principal_id"] == user_whoami["principal_id"] - assert body["entries"][0]["provider"] == "github" -``` - -- [ ] **Step 4: Add query-widening test** - -Append this test: - -```python -def test_non_admin_audit_query_cannot_widen_scope_with_principal_or_identity( - monkeypatch, - tmp_path: Path, -) -> None: - monkeypatch.setenv("AUTHSOME_HOME", str(tmp_path)) - - with create_server_test_client() as client: - _claim_identity(client, tmp_path, "admin-ready-boldly-0001", email="admin@example.com") - _claim_identity(client, tmp_path, "steady-wisely-boldly-0042", email="user@example.com") - admin_whoami = client.get( - "/api/whoami", - headers=_auth_header(tmp_path, "GET", "/api/whoami", handle="admin-ready-boldly-0001"), - ).json() - user_whoami = client.get( - "/api/whoami", - headers=_auth_header(tmp_path, "GET", "/api/whoami", handle="steady-wisely-boldly-0042"), - ).json() - _emit_audit_event( - "audit_010", - "connection.login", - principal_id=admin_whoami["principal_id"], - identity="admin-ready-boldly-0001", - provider="github", - ) - _emit_audit_event( - "audit_011", - "connection.login", - principal_id=user_whoami["principal_id"], - identity="steady-wisely-boldly-0042", - provider="github", - ) - - path = ( - "/api/audit/events?" - f"principal_id={admin_whoami['principal_id']}&" - "identity=admin-ready-boldly-0001&limit=10" - ) - response = client.get( - path, - headers=_auth_header( - tmp_path, - "GET", - path, - handle="steady-wisely-boldly-0042", - ), - ) - - assert response.status_code == status.HTTP_200_OK - body = response.json() - assert body["scope"] == "principal" - assert body["entries"] == [] -``` - -- [ ] **Step 5: Add admin filter and cursor pagination test** - -Append this test: - -```python -def test_admin_audit_events_support_filters_and_cursor_pagination(monkeypatch, tmp_path: Path) -> None: - monkeypatch.setenv("AUTHSOME_HOME", str(tmp_path)) - - with create_server_test_client() as client: - _claim_identity(client, tmp_path, "admin-ready-boldly-0001", email="admin@example.com") - _claim_identity(client, tmp_path, "steady-wisely-boldly-0042", email="user@example.com") - admin_whoami = client.get( - "/api/whoami", - headers=_auth_header(tmp_path, "GET", "/api/whoami", handle="admin-ready-boldly-0001"), - ).json() - user_whoami = client.get( - "/api/whoami", - headers=_auth_header(tmp_path, "GET", "/api/whoami", handle="steady-wisely-boldly-0042"), - ).json() - _emit_audit_event( - "audit_100", - "connection.login", - principal_id=admin_whoami["principal_id"], - identity="admin-ready-boldly-0001", - provider="github", - timestamp=datetime(2026, 6, 15, 8, 0, tzinfo=UTC), - ) - _emit_audit_event( - "audit_101", - "connection.login", - principal_id=user_whoami["principal_id"], - identity="steady-wisely-boldly-0042", - provider="github", - timestamp=datetime(2026, 6, 15, 8, 1, tzinfo=UTC), - ) - _emit_audit_event( - "audit_102", - "connection.logout", - principal_id=user_whoami["principal_id"], - identity="steady-wisely-boldly-0042", - provider="github", - timestamp=datetime(2026, 6, 15, 8, 2, tzinfo=UTC), - ) - - first_path = "/api/audit/events?provider=github&limit=2" - first_response = client.get( - first_path, - headers=_auth_header( - tmp_path, - "GET", - first_path, - handle="admin-ready-boldly-0001", - ), - ) - first_body = first_response.json() - second_path = f"/api/audit/events?provider=github&limit=2&cursor={first_body['next_cursor']}" - second_response = client.get( - second_path, - headers=_auth_header( - tmp_path, - "GET", - second_path, - handle="admin-ready-boldly-0001", - ), - ) - - assert first_response.status_code == status.HTTP_200_OK - assert first_body["scope"] == "global" - assert [entry["event_id"] for entry in first_body["entries"]] == ["audit_102", "audit_101"] - assert first_body["next_cursor"] - - assert second_response.status_code == status.HTTP_200_OK - second_body = second_response.json() - assert second_body["scope"] == "global" - assert [entry["event_id"] for entry in second_body["entries"]] == ["audit_100"] - assert second_body["next_cursor"] is None -``` - -- [ ] **Step 6: Run new tests to verify they fail** - -Run: - -```bash -uv run pytest tests/server/test_audit_events.py::test_non_admin_audit_filters_stay_within_own_principal tests/server/test_audit_events.py::test_non_admin_audit_query_cannot_widen_scope_with_principal_or_identity tests/server/test_audit_events.py::test_admin_audit_events_support_filters_and_cursor_pagination -v -``` - -Expected: FAIL because `scope`, `next_cursor`, query filters, and cursor pagination are not implemented yet. - -- [ ] **Step 7: Commit failing tests** - -Run: - -```bash -git add tests/server/test_audit_events.py -git commit -m "test: cover user-scoped audit queries" -``` - ---- - -### Task 2: Implement Store-Backed Audit Filtering And Pagination - -**Files:** -- Modify: `src/authsome/server/store/repositories.py` -- Test: `tests/server/test_audit_events.py` - -- [ ] **Step 1: Add `AuditEventPage` near `AuditEventInsert`** - -Add this dataclass immediately after `AuditEventInsert`: - -```python -@dataclass(frozen=True) -class AuditEventPage: - """Paged audit event query result.""" - - entries: list[dict[str, Any]] - next_cursor: str | None -``` - -- [ ] **Step 2: Add cursor helpers before `AuditEventRegistry`** - -Add these helpers after `AuditEventPage`: - -```python -def _encode_audit_cursor(*, timestamp: str, event_id: str) -> str: - return f"{timestamp}|{event_id}" - - -def _decode_audit_cursor(cursor: str) -> tuple[str, str]: - timestamp, separator, event_id = cursor.partition("|") - if not separator or not timestamp or not event_id: - raise ValueError("Invalid audit cursor") - return timestamp, event_id -``` - -- [ ] **Step 3: Replace `AuditEventRegistry.list_recent`** - -Replace the existing `list_recent` method with these two methods: - -```python - async def query_events( - self, - *, - limit: int = 50, - principal_id: str | None = None, - event: str | None = None, - provider: str | None = None, - identity: str | None = None, - since: datetime | None = None, - until: datetime | None = None, - cursor: str | None = None, - ) -> AuditEventPage: - bounded_limit = min(max(limit, 1), 500) - conditions: list[str] = [] - params: list[Any] = [] - - if principal_id is not None: - conditions.append("principal_id = ?") - params.append(principal_id) - if event: - conditions.append("event = ?") - params.append(event) - if provider: - conditions.append("provider = ?") - params.append(provider) - if identity: - conditions.append("identity = ?") - params.append(identity) - if since is not None: - conditions.append("timestamp >= ?") - params.append(_dump_dt(since)) - if until is not None: - conditions.append("timestamp < ?") - params.append(_dump_dt(until)) - if cursor: - cursor_timestamp, cursor_event_id = _decode_audit_cursor(cursor) - conditions.append("(timestamp < ? OR (timestamp = ? AND event_id < ?))") - params.extend([cursor_timestamp, cursor_timestamp, cursor_event_id]) - - where_clause = f" WHERE {' AND '.join(conditions)}" if conditions else "" - rows = await self._db.fetch_all( - "SELECT event_id, timestamp, payload_json FROM audit_events" - f"{where_clause} " - "ORDER BY timestamp DESC, event_id DESC LIMIT ?", - [*params, bounded_limit + 1], - ) - visible_rows = rows[:bounded_limit] - entries = [json.loads(row["payload_json"]) for row in visible_rows] - next_cursor = None - if len(rows) > bounded_limit and visible_rows: - last = visible_rows[-1] - next_cursor = _encode_audit_cursor(timestamp=last["timestamp"], event_id=last["event_id"]) - return AuditEventPage(entries=entries, next_cursor=next_cursor) - - async def list_recent(self, *, limit: int = 50, principal_id: str | None = None) -> list[dict[str, Any]]: - page = await self.query_events(limit=limit, principal_id=principal_id) - return page.entries -``` - -- [ ] **Step 4: Add page method to `ServerAuditLog`** - -Add this method above the existing `list_events` method: - -```python - async def query_events( - self, - *, - limit: int = 50, - principal_id: str | None = None, - event: str | None = None, - provider: str | None = None, - identity: str | None = None, - since: datetime | None = None, - until: datetime | None = None, - cursor: str | None = None, - ) -> AuditEventPage: - await self.async_force_flush() - return await self._registry.query_events( - limit=limit, - principal_id=principal_id, - event=event, - provider=provider, - identity=identity, - since=since, - until=until, - cursor=cursor, - ) -``` - -- [ ] **Step 5: Run the new tests to verify store support is still missing at the route** - -Run: - -```bash -uv run pytest tests/server/test_audit_events.py::test_admin_audit_events_support_filters_and_cursor_pagination -v -``` - -Expected: FAIL because the route still ignores filters and still returns only `entries`. - -- [ ] **Step 6: Run focused compatibility tests** - -Run: - -```bash -uv run pytest tests/auth/test_service.py::TestRefreshFailureAudit -v -``` - -Expected: PASS because `ServerAuditLog.list_events()` still returns a list. - -- [ ] **Step 7: Commit store query implementation** - -Run: - -```bash -git add src/authsome/server/store/repositories.py -git commit -m "feat: add paged audit event queries" -``` - ---- - -### Task 3: Implement Role-Aware Audit Route Filters - -**Files:** -- Modify: `src/authsome/server/routes/audit.py` -- Test: `tests/server/test_audit_events.py` - -- [ ] **Step 1: Replace imports in `routes/audit.py`** - -Replace the import block with: - -```python -"""Audit event routes.""" - -from datetime import datetime -from typing import Annotated, Any, Literal - -from fastapi import APIRouter, Depends, HTTPException, Query, Request, status -``` - -Keep the existing `authsome`, `PrincipalRole`, `CredentialService`, and dependency imports below that block. - -- [ ] **Step 2: Replace `list_audit_events`** - -Replace the current `list_audit_events` function with: - -```python -@router.get("/events") -async def list_audit_events( - request: Request, - limit: int = 50, - cursor: str | None = None, - event: str | None = None, - provider: str | None = None, - identity: str | None = None, - principal_id: str | None = None, - from_: Annotated[datetime | None, Query(alias="from")] = None, - to: datetime | None = None, - auth: CredentialService = Depends(get_daemon_or_browser_auth_service), -) -> dict[str, Any]: - effective_principal_id = principal_id if auth.principal_role == PrincipalRole.ADMIN else auth.principal_id - scope: Literal["global", "principal"] = "global" if effective_principal_id is None else "principal" - try: - page = await request.app.state.audit_log.query_events( - limit=limit, - principal_id=effective_principal_id, - event=event, - provider=provider, - identity=identity, - since=from_, - until=to, - cursor=cursor, - ) - except ValueError as exc: - raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc - return {"entries": page.entries, "next_cursor": page.next_cursor, "scope": scope} -``` - -- [ ] **Step 3: Run server audit tests** - -Run: - -```bash -uv run pytest tests/server/test_audit_events.py -v -``` - -Expected: PASS. - -- [ ] **Step 4: Run focused lint** - -Run: - -```bash -uv run ruff check src/authsome/server/routes/audit.py src/authsome/server/store/repositories.py tests/server/test_audit_events.py -``` - -Expected: PASS. - -- [ ] **Step 5: Commit route implementation** - -Run: - -```bash -git add src/authsome/server/routes/audit.py tests/server/test_audit_events.py -git commit -m "feat: enforce role-aware audit event queries" -``` - ---- - -### Task 4: Update Dashboard Audit Fetching And Rendering - -**Files:** -- Modify: `ui/src/lib/authsome-api.ts` -- Modify: `ui/src/components/authsome-dashboard.tsx` -- Modify: `ui/src/app/(authenticated)/audit/page.tsx` - -- [ ] **Step 1: Read local Next 16 docs for touched App Router patterns** - -Run: - -```bash -sed -n '1,180p' ui/node_modules/next/dist/docs/01-app/01-getting-started/03-layouts-and-pages.md -sed -n '1,160p' ui/node_modules/next/dist/docs/01-app/02-guides/static-exports.md -``` - -Expected: Confirm the route files remain valid App Router client pages and the existing static export approach is unchanged. - -- [ ] **Step 2: Extend audit types in `ui/src/lib/authsome-api.ts`** - -Replace `AuditRow` with: - -```typescript -export type AuditRow = { - eventId: string; - time: string; - event: string; - source: string; - actor: string; - target: string; - status: string; - metadata: Record; -}; - -export type AuditFilters = { - event?: string; - provider?: string; - identity?: string; - from?: string; - to?: string; - cursor?: string | null; - limit?: number; -}; - -export type AuditEventsData = { - scope: "global" | "principal"; - nextCursor: string | null; - events: AuditRow[]; - total: number; -}; -``` - -Then update `DashboardData.audit` to include: - -```typescript - audit: { - canView: boolean; - scope: "global" | "principal"; - nextCursor: string | null; - total: number; - events: AuditRow[]; - }; -``` - -- [ ] **Step 3: Replace `AuditResponse` and add query helper** - -Replace `type AuditResponse` with: - -```typescript -type AuditResponse = { - entries: Array>; - next_cursor?: string | null; - scope?: "global" | "principal"; -}; -``` - -Add this helper after `buildAuditRows`: - -```typescript -function auditQueryString(filters: AuditFilters = {}): string { - const params = new URLSearchParams(); - params.set("limit", String(filters.limit ?? 50)); - if (filters.event) params.set("event", filters.event); - if (filters.provider) params.set("provider", filters.provider); - if (filters.identity) params.set("identity", filters.identity); - if (filters.from) params.set("from", filters.from); - if (filters.to) params.set("to", filters.to); - if (filters.cursor) params.set("cursor", filters.cursor); - return params.toString(); -} -``` - -- [ ] **Step 4: Add `fetchAuditEvents`** - -Add this export after the audit query helper: - -```typescript -export async function fetchAuditEvents(filters: AuditFilters = {}): Promise { - const data = await requestJson(`/api/audit/events?${auditQueryString(filters)}`); - const events = buildAuditRows(data.entries); - return { - scope: data.scope ?? "principal", - nextCursor: data.next_cursor ?? null, - events, - total: events.length, - }; -} -``` - -- [ ] **Step 5: Update `fetchDashboard()` audit loading** - -In `fetchDashboard()`, replace: - -```typescript - const isAdmin = whoami.principal_role === "admin"; - const audit = isAdmin ? await requestJson("/api/audit/events?limit=100") : { entries: [] }; -``` - -with: - -```typescript - const isAdmin = whoami.principal_role === "admin"; - const audit = await fetchAuditEvents({ limit: 100 }); -``` - -Then replace the returned `audit` block with: - -```typescript - audit: { - canView: true, - scope: audit.scope, - nextCursor: audit.nextCursor, - total: audit.total, - events: audit.events, - }, -``` - -- [ ] **Step 6: Update imports in `authsome-dashboard.tsx`** - -Add `AuditFilters`, `fetchAuditEvents` to the existing import from `@/lib/authsome-api`: - -```typescript - AuditFilters, - fetchAuditEvents, -``` - -- [ ] **Step 7: Make Audit Log navigation visible to all users** - -In `NAV_ITEMS`, replace: - -```typescript - { id: "audit", href: "/audit", label: "Audit Log", icon: , adminOnly: true }, -``` - -with: - -```typescript - { id: "audit", href: "/audit", label: "Audit Log", icon: }, -``` - -- [ ] **Step 8: Add audit filter helpers** - -Add these helpers above `AuditView`: - -```typescript -function normalizeAuditFilters(filters: AuditFilters): AuditFilters { - return Object.fromEntries( - Object.entries(filters).filter(([, value]) => value !== undefined && value !== null && String(value).trim() !== ""), - ) as AuditFilters; -} - -function localDateTimeToIso(value: string): string | undefined { - if (!value) return undefined; - const parsed = new Date(value); - return Number.isNaN(parsed.valueOf()) ? undefined : parsed.toISOString(); -} -``` - -- [ ] **Step 9: Replace `AuditView`** - -Replace the current `AuditView` function with: - -```tsx -export function AuditView({ data }: { data: DashboardData }) { - const [filters, setFilters] = useState({ event: "", provider: "", identity: "", from: "", to: "" }); - const [events, setEvents] = useState(data.audit.events); - const [nextCursor, setNextCursor] = useState(data.audit.nextCursor); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(""); - const description = data.account.isAdmin - ? "Recent administrative and credential events." - : "Recent account, identity, vault, and credential events for this principal."; - - useEffect(() => { - setEvents(data.audit.events); - setNextCursor(data.audit.nextCursor); - }, [data.audit.events, data.audit.nextCursor]); - - const providerOptions = useMemo( - () => Array.from(new Set(data.providers.map((provider) => provider.name))).sort(), - [data.providers], - ); - const identityOptions = useMemo( - () => Array.from(new Set(data.identities.map((identity) => identity.handle))).sort(), - [data.identities], - ); - - async function loadAuditPage(cursor?: string | null) { - setLoading(true); - setError(""); - try { - const page = await fetchAuditEvents( - normalizeAuditFilters({ - event: filters.event, - provider: filters.provider, - identity: filters.identity, - from: localDateTimeToIso(filters.from), - to: localDateTimeToIso(filters.to), - cursor, - limit: 50, - }), - ); - setEvents((current) => (cursor ? current.concat(page.events) : page.events)); - setNextCursor(page.nextCursor); - } catch (exc) { - setError(exc instanceof Error ? exc.message : "Audit events could not be loaded."); - } finally { - setLoading(false); - } - } - - return ( -
- - - -
- - - - - -
-
- - - {error ? {error} : null} -
-
-
- - - {events.length ? ( -
- - - Time - Event - Actor - Target - Status - - - - {events.map((event) => ( - - {event.time} - {event.event} - {event.actor} - {event.target} - - {event.status === "success" ? ( - - {event.status} - - ) : event.status === "failure" || event.status === "error" ? ( - - {event.status} - - ) : event.status ? ( - {event.status} - ) : null} - - - ))} - -
- ) : ( -
No audit events found.
- )} -
-
- {nextCursor ? ( -
- -
- ) : null} -
- ); -} -``` - -- [ ] **Step 10: Let the authenticated audit page render for users** - -Replace `ui/src/app/(authenticated)/audit/page.tsx` with: - -```tsx -"use client"; - -import useSWR from "swr"; - -import { AuditView } from "@/components/authsome-dashboard"; -import { fetchDashboard } from "@/lib/authsome-api"; - -export default function AuditPage() { - const { data } = useSWR("authsome-dashboard", fetchDashboard); - if (!data) return null; - return ; -} -``` - -- [ ] **Step 11: Let generic view routing render audit for users** - -In `ui/src/components/authsome-dashboard.tsx`, replace: - -```typescript - if (view === "audit" && data.account.isAdmin) return ; -``` - -with: - -```typescript - if (view === "audit") return ; -``` - -- [ ] **Step 12: Run UI lint and type checks** - -Run: - -```bash -uv run pnpm --dir ui lint -uv run pnpm --dir ui exec tsc --noEmit -``` - -Expected: PASS. - -- [ ] **Step 13: Commit UI source changes** - -Run: - -```bash -git add ui/src/lib/authsome-api.ts ui/src/components/authsome-dashboard.tsx ui/src/app/(authenticated)/audit/page.tsx -git commit -m "feat: show scoped audit log in dashboard" -``` - ---- - -### Task 5: Document Role-Aware Audit Log Access - -**Files:** -- Modify: `docs/site/reference/audit-log.mdx` - -- [ ] **Step 1: Update the audit endpoint documentation** - -In `docs/site/reference/audit-log.mdx`, add this section after the paragraph that introduces querying the daemon: - -```mdx -### User-scoped access - -`GET /api/audit/events` is role-aware. Admin principals can review the global audit log. Non-admin principals receive only events scoped to their own principal, including their claimed identities, vault, providers, and credential lifecycle activity. Passing another `principal_id`, vault, or identity in query parameters cannot widen a non-admin query. - -Supported query parameters: - -| Parameter | Description | -| --- | --- | -| `limit` | Number of events to return, clamped to the server maximum. | -| `cursor` | Cursor returned by the previous page. | -| `event` | Exact event name. | -| `provider` | Exact provider name, such as `github`. | -| `identity` | Exact identity handle. | -| `from` | Inclusive lower timestamp bound. | -| `to` | Exclusive upper timestamp bound. | - -Results are sorted newest-first and include `next_cursor` when another page is available. -``` - -- [ ] **Step 2: Run docs-safe checks** - -Run: - -```bash -uv run ruff check src/ tests/ -``` - -Expected: PASS. - -- [ ] **Step 3: Commit docs** - -Run: - -```bash -git add docs/site/reference/audit-log.mdx -git commit -m "docs: describe scoped audit log access" -``` - ---- - -### Task 6: Rebuild Packaged Static UI - -**Files:** -- Generated: `src/authsome/ui/web/**` -- Generated: `ui/out/**` - -- [ ] **Step 1: Build and copy UI assets** - -Run: - -```bash -./scripts/build-ui.sh -``` - -Expected: Next builds successfully, `ui/out` is refreshed, and `src/authsome/ui/web` is replaced with the exported dashboard. - -- [ ] **Step 2: Check generated changes are scoped to UI assets** - -Run: - -```bash -git status --short -``` - -Expected: changes include `src/authsome/ui/web/**` and may include `ui/out/**` if tracked by Git. No Python source should change in this task. - -- [ ] **Step 3: Commit packaged UI assets** - -Run: - -```bash -git add src/authsome/ui/web ui/out -git commit -m "build: refresh dashboard assets" -``` - ---- - -### Task 7: Final Verification - -**Files:** -- Verify all changed files. - -- [ ] **Step 1: Run server tests** - -Run: - -```bash -uv run pytest tests/server/test_audit_events.py -``` - -Expected: PASS. - -- [ ] **Step 2: Run full test suite** - -Run: - -```bash -uv run pytest -``` - -Expected: PASS. - -- [ ] **Step 3: Run Python lint** - -Run: - -```bash -uv run ruff check src/ tests/ -``` - -Expected: PASS. - -- [ ] **Step 4: Run Python type check** - -Run: - -```bash -uv run ty check src/ -``` - -Expected: PASS. - -- [ ] **Step 5: Run UI lint and type check** - -Run: - -```bash -uv run pnpm --dir ui lint -uv run pnpm --dir ui exec tsc --noEmit -``` - -Expected: PASS. - -- [ ] **Step 6: Run pre-commit** - -Run: - -```bash -uv run pre-commit run --all-files -``` - -Expected: PASS. - -- [ ] **Step 7: Inspect final diff** - -Run: - -```bash -git diff --stat main HEAD -git status --short -``` - -Expected: diff contains only audit route/store/tests/docs, UI source, and packaged UI assets. Working tree is clean after the final commit. - ---- - -## Completion Notes - -- Keep all auth/vault/audit boundaries intact: `audit/` emits events only; server routes and Store repositories own authorization and persistence. -- Use `uv run` for every Python, pnpm, lint, type-check, and test command. -- Do not commit directly to `main`; stay on `feature/user-scoped-audit-log`. diff --git a/docs/superpowers/specs/2026-06-10-stateless-production-deployments-design.md b/docs/superpowers/specs/2026-06-10-stateless-production-deployments-design.md deleted file mode 100644 index 9f1b6d5e..00000000 --- a/docs/superpowers/specs/2026-06-10-stateless-production-deployments-design.md +++ /dev/null @@ -1,310 +0,0 @@ -# Stateless Production Deployments Design - -## Summary - -Prepare Authsome for stateless, horizontally scalable production deployments while preserving the local developer defaults. The server will select production infrastructure from environment variables: Postgres for the relational server Store when `AUTHSOME_DATABASE_URL` uses a Postgres scheme, and Redis for shared mutable server state plus encrypted vault KV when `AUTHSOME_REDIS_URL` is present. - -The design keeps the existing module ownership model intact. `identity`, `auth`, and `vault` remain reusable libraries with infrastructure-agnostic contracts and domain behavior. `server` remains the composition root that chooses concrete infrastructure and combines the libraries into Authsome business logic. - -## Goals - -- Make container and multi-replica deployments viable without relying on local process memory or ephemeral disk for hot-path mutable state. -- Keep SQLite, disk vault storage, and in-memory transient state working for local development and tests. -- Reuse `py-key-value-aio` Redis support for vault storage instead of creating a custom Redis vault backend. -- Keep Postgres and Redis optional for library installs, while installing production extras in the Docker image. -- Provide self-hosting documentation with Postgres, Redis, Docker, and secret-management guidance. - -## Non-Goals - -- Do not introduce an ORM or Alembic for this refactor. Use a lightweight schema-version migration runner inside the existing Store adapter. -- Do not move business logic into CLI or proxy. They continue to communicate with the server. -- Do not add stateful browser sessions in this refactor. Browser sessions remain signed stateless JWT cookies. -- Do not add email verification or signup abuse prevention in this refactor. Those are tracked separately in GitHub issue #411. -- Do not introduce `AUTHSOME_VAULT_BACKEND`. Redis vault selection follows `AUTHSOME_REDIS_URL`. - -## Current State - -The current code already has a relational server Store split from vault storage: - -- `src/authsome/server/store/database.py` supports SQLite and Postgres URL resolution, but Postgres uses a single `asyncpg` connection. -- `src/authsome/server/store/repositories.py` contains the five server-owned registries plus server config, custom provider definitions, and audit events. -- `src/authsome/server/dependencies.py` always creates the vault with `DiskStore`. -- `src/authsome/auth/sessions.py` stores auth flow sessions in process memory. -- `src/authsome/server/ui_sessions.py` keeps browser sessions stateless but stores pending identity-claim tokens in process memory. -- `src/authsome/identity/proof.py` validates PoP JWTs and currently owns an in-memory replay cache. -- `src/authsome/server/app.py` wires these components directly into `app.state`. - -These defaults work for local development but do not work across multiple replicas. Auth flow sessions, pending claim tokens, and PoP replay JTIs need shared state. Vault encrypted blobs need a backend that survives container restarts without requiring a mounted local volume in production. - -## Architecture - -Authsome keeps the existing boundaries: - -- `identity` owns identity and PoP token semantics. It creates PoP JWTs, verifies signatures, verifies request binding, and extracts proof claims. It remains infrastructure agnostic. -- `auth` owns flow/session models and abstract session-store behavior. Concrete Redis storage does not leak into auth flow code. -- `vault` owns encrypted KV semantics over an `AsyncKeyValue`. It does not define a Redis-specific vault API. -- `server` owns deployment topology. It selects SQLite or Postgres, DiskStore or RedisStore, memory or Redis state stores, and wires the selected implementations into services and routes. -- `cli` and `proxy` remain clients of the server business logic. -- `ui` and the relational Store remain server properties. - -Backend selection is simple: - -- `AUTHSOME_DATABASE_URL=postgresql://...` or `postgres://...` selects Postgres for the relational server Store. -- No Postgres URL selects SQLite. -- `AUTHSOME_REDIS_URL` selects Redis for auth flow sessions, pending claim tokens, PoP JTI replay cache, and raw vault KV. -- No Redis URL selects in-memory transient stores and disk vault KV. - -If an explicit Postgres or Redis backend is configured and the driver is missing or the service is unreachable, startup fails. There is no runtime fallback from Redis/Postgres to memory/disk after startup. - -Browser UI sessions stay stateless signed cookies for now. The disadvantages are known: server-side logout/revocation and active session visibility are not available. Verified signup and stateful browser-session management are deferred to issue #411. - -## Components - -### Server Configuration - -`src/authsome/server/config.py` will add: - -- `redis_url: str | None` -- Postgres pool settings, such as min and max pool size. -- TTL settings used by Redis-backed auth sessions, pending claim tokens, and replay cache where existing constants are currently hard-coded. - -Configuration remains environment-driven through the existing `AUTHSOME_` prefix. - -### Relational Store - -`src/authsome/server/store/database.py` keeps the current lightweight adapter but upgrades production behavior: - -- SQLite continues to use one `aiosqlite` connection. -- Postgres uses an `asyncpg` pool. -- Queries still use `?` placeholders at repository call sites, translated to Postgres positional parameters inside the adapter. -- Startup runs a lightweight schema-version migration runner. - -The migration runner should: - -- Maintain `store_schema_version`. -- Apply ordered migration functions or statements. -- Support SQLite and Postgres dialect fragments inside the Store module. -- Keep existing `CREATE TABLE IF NOT EXISTS` bootstrap behavior only as migration contents, not as ad hoc schema setup scattered through startup. - -The existing registries remain repository classes. They should not learn about pools, Postgres clients, or migration internals. - -### Replay Cache - -The anti-replay cache prevents reuse of a PoP JWT within its validity window. Each PoP JWT has a `jti`. After signature, method, URL, body hash, and expiry validation, the server checks whether that `jti` has already been used. If it has, the request is rejected. - -The split should be: - -- `identity.proof` owns proof semantics and accepts an injected infrastructure-agnostic replay checker. -- A tiny protocol defines the operation shape: `check_and_store(jti: str, exp: int) -> None`. -- Server-side implementations provide storage: - - Memory implementation for local dev and tests. - - Redis implementation for production. - -The Redis implementation should use an atomic set-if-not-exists operation with a TTL derived from `exp - now`. This lets replica B reject a JWT already accepted by replica A. - -No Redis import belongs in `identity`. - -### Auth Flow Sessions - -`AuthSession` remains the domain model in `src/authsome/auth/sessions.py`. - -The current in-memory `AuthSessionStore` behavior should be preserved behind a small store interface that covers the existing route needs: - -- `create(...)` -- `get(session_id)` -- `save(session)` -- `delete(session_id)` -- `index_oauth_state(session)` -- `get_by_oauth_state(state)` - -A Redis implementation can live server-side if it imports Redis-specific code. It should serialize `AuthSession` with Pydantic JSON, store each session under a namespaced key, and store OAuth state-to-session mappings under separate keys with matching TTLs. - -Local memory behavior remains available when `AUTHSOME_REDIS_URL` is absent. - -### Pending Claim Tokens - -Browser sessions remain stateless in `UiSessionStore`, but pending claim tokens need shared mutable state so claim links survive replica changes. - -The browser session methods stay simple: - -- `create_browser_session(...)` -- `get_browser_session(cookie_value)` -- `build_cookie_value(token)` -- `delete_browser_session(cookie_value)` - -Pending claim methods move behind a memory/Redis store: - -- `create_pending_claim(identity, ttl_seconds)` -- `get_pending_claim(token)` -- `consume_pending_claim(token)` - -`consume_pending_claim` should delete and return the token. The Redis version should be atomic where the Redis client makes that practical. - -### Vault KV Backend - -The vault continues to use `Vault -> AesGcmEncryptionWrapper -> AsyncKeyValue`. - -`src/authsome/server/dependencies.py` chooses the raw `AsyncKeyValue`: - -- No `AUTHSOME_REDIS_URL`: `DiskStore(directory=server_config.kv_store_dir)` -- `AUTHSOME_REDIS_URL`: `key_value.aio.stores.redis.RedisStore(url=server_config.redis_url)` - -The existing `DekManager` continues to load or create the wrapped DEK record through the raw KV backend. Redis stores only encrypted vault values and DEK wrapping metadata. The vault master key is never stored in Redis. - -### Secrets - -Master-key resolution keeps the current behavior in `src/authsome/server/secrets.py`: - -1. `AUTHSOME_MASTER_KEY` -2. `AUTHSOME_MASTER_KEY_FILE` or the default server key file -3. OS keyring -4. Generate a new base64 key, store it in keyring if possible, otherwise write the default key file - -There is no special production-mode enforcement tied to Redis or Postgres. The self-hosting guide should recommend `AUTHSOME_MASTER_KEY` or `AUTHSOME_MASTER_KEY_FILE` for containers and explain that generated file keys only survive when the filesystem is persistent. - -### App Lifecycle - -`src/authsome/server/app.py` remains the composition root: - -1. Load `ServerConfig`. -2. Open and migrate the relational Store. -3. If Redis is configured, create or validate Redis-backed state dependencies. -4. Create raw vault KV, load/create DEK, wrap with encryption, and construct `Vault`. -5. Create auth sessions, UI sessions/pending claim store, replay cache, provider repository, account auth service, bootstrap service, and ownership resolver. -6. Close Store pools and Redis-owned clients on shutdown. - -The existing `ownership_cache = {}` can remain a local optimization only if it is not correctness-critical. If it can become stale across replicas for claim/binding changes, it should be removed or given a conservative TTL. Correctness must come from the registries, not the process cache. - -## Data Flow - -### Startup - -Local startup without production URLs uses SQLite, DiskStore, and memory state. Postgres is selected only by a Postgres `AUTHSOME_DATABASE_URL`; Redis is selected only by `AUTHSOME_REDIS_URL`. - -If Redis is configured, startup should ping Redis before serving requests. If Postgres is configured, startup should acquire a connection from the pool and run migrations before serving requests. - -### PoP Requests - -1. The request arrives with `Authorization: PoP `. -2. `identity.proof.validate_proof_jwt()` validates the signature and request binding. -3. The injected replay checker stores the `jti` until expiry or raises if already seen. -4. The server resolves the identity registration and ownership through the relational Store. -5. The route receives the existing `ResolvedOwnership` and builds `CredentialService`. - -### Auth Flow Sessions - -1. A login flow creates an `AuthSession`. -2. The selected session store persists it with a TTL. -3. OAuth flows index `internal_state` to the session id. -4. Callback routes resolve the session by OAuth state or session id, update the session, and save it. -5. Expired or missing sessions behave as not found. - -### Pending Claim Links - -1. Identity bootstrap creates a pending claim token. -2. The selected pending claim store persists it with a TTL. -3. The claim route consumes the token. -4. Consumed or expired tokens behave as not found. - -### Vault Access - -1. `CredentialRepository` reads or writes credentials through `Vault`. -2. `Vault` updates its index records and plaintext domain values. -3. `AesGcmEncryptionWrapper` encrypts the values. -4. DiskStore or RedisStore stores encrypted blobs using the existing collection/key naming scheme, including `vault::...` collections. - -## Error Handling - -Startup failures: - -- Invalid database URL scheme fails clearly. -- Postgres driver missing, connection failure, bad credentials, or migration failure fails startup. -- Redis driver missing, connection failure, bad credentials, or ping failure fails startup. -- Vault DEK unwrap failure fails startup. - -Runtime behavior: - -- Redis outages during affected operations return 5xx responses. The server does not silently fall back to memory or disk. -- PoP replay detection returns the existing unauthorized proof-validation response. -- Expired sessions and pending claim tokens behave as not found. -- Health remains cheap and public. Keep `/api/health` and add a root `/health` alias for container health checks. -- Readiness checks the relational Store and vault. If Redis is configured, readiness also checks Redis connectivity. - -## Docker And Self-Hosting - -The Docker image should install production extras by default while the base Python package keeps them optional where possible. - -The Dockerfile should: - -- Keep a multi-stage build for UI and Python package. -- Use the `uv` toolchain for Python build/install. -- Run as a non-root user. -- Expose port 7998. -- Add a root `/health` alias backed by the same response as `/api/health`. -- Include a healthcheck against `/health`. - -`docker-compose.yml` should include: - -- `authsome` -- `postgres` -- `redis` - -The self-hosting guide should cover: - -- Prerequisites: Docker, Postgres, Redis. -- Environment variables: `AUTHSOME_DATABASE_URL`, `AUTHSOME_REDIS_URL`, `AUTHSOME_MASTER_KEY`, `AUTHSOME_MASTER_KEY_FILE`, `AUTHSOME_HOME`, `AUTHSOME_BASE_URL`, `AUTHSOME_HOST`, `AUTHSOME_PORT`, and analytics settings. -- Startup steps: pull or build image, set env vars, start service, run `authsome init`, verify `/health`. -- Compose example for local production simulation. -- Secret guidance: do not commit production `AUTHSOME_MASTER_KEY`; prefer a cloud secret manager, Doppler, Vault, or platform secrets. -- Migration guidance: relational schema migrations run at startup; back up Postgres and Redis according to operator policy. - -## Testing - -Default `uv run pytest` should continue to pass without external services. - -Tests to add or adjust: - -- SQLite migration tests. -- Postgres migration tests gated behind an optional service fixture or environment variable. -- Postgres pool adapter tests gated behind the same integration mechanism. -- Memory replay cache tests after moving it out of `identity`. -- Redis replay cache tests for duplicate rejection and TTL behavior. -- Auth session store contract tests run against memory and Redis implementations. -- Pending claim store contract tests run against memory and Redis implementations. -- Vault backend tests showing RedisStore is selected when `AUTHSOME_REDIS_URL` is present and values remain encrypted. -- Server lifecycle tests for local defaults and Redis/Postgres selection failures. -- Existing session recreation tests should split local and Redis behavior: memory sessions do not survive app recreation; Redis sessions do. -- Docker smoke test for image build and `/health`. - -Verification before completion should include: - -- `uv run pytest` -- `uv run ruff check` -- `uv run ty check` -- Docker build smoke test when Docker is available -- Redis/Postgres integration tests when services are available - -## Rollout Plan - -Implement in small phases inside one production-readiness branch: - -1. Add config fields and optional dependency extras. -2. Upgrade the relational Store to Postgres pooling and lightweight migrations. -3. Split replay-cache semantics cleanly from `identity` and add memory/Redis implementations. -4. Introduce auth session store contracts and Redis-backed auth sessions. -5. Split pending claim storage from stateless browser session signing and add Redis pending claims. -6. Reuse `py-key-value-aio[redis]` for vault raw KV when `AUTHSOME_REDIS_URL` is configured. -7. Update app lifecycle, readiness, Dockerfile, compose, and self-hosting docs. -8. Add gated integration tests and smoke verification. - -Each phase should preserve local defaults and keep implementation changes close to the modules that own the behavior. - -## Open Follow-Up - -GitHub issue #411 tracks hosted login hardening outside this refactor: - -- Email verification during signup. -- Signup abuse prevention. -- Stateful browser sessions. -- Server-side browser-session logout and revocation. -- Session visibility and account-security policies. diff --git a/docs/superpowers/specs/2026-06-15-user-scoped-audit-log-design.md b/docs/superpowers/specs/2026-06-15-user-scoped-audit-log-design.md deleted file mode 100644 index 18b33d19..00000000 --- a/docs/superpowers/specs/2026-06-15-user-scoped-audit-log-design.md +++ /dev/null @@ -1,119 +0,0 @@ -# User-Scoped Audit Log Design - -## Summary - -Allow non-admin users to view audit events scoped to their own principal, claimed identities, vault, and credential activity. Keep the existing admin/global audit view separate by authorization behavior, not by duplicating audit storage. - -## Goals - -- Let non-admin dashboard users review their own account-security events. -- Keep `GET /api/audit/events` as the single role-aware audit read endpoint. -- Enforce all non-admin scoping on the server. -- Add routine filtering and cursor pagination. -- Preserve the Store-backed `audit_events` registry as the source of truth. -- Keep `audit/` storage-free and independent from server-owned persistence. - -## Non-Goals - -- Do not add a second user-only audit store. -- Do not add a separate `/api/audit/me/events` endpoint for this bug. -- Do not expose secret-bearing metadata or payload expansion in the user audit UI. -- Do not redesign the full dashboard information architecture. - -## Architecture - -`GET /api/audit/events` remains the single audit read API. The route authenticates through the existing PoP-or-browser dependency and derives an effective scope from the caller: - -- Admin principals may query the global audit log, with optional filters. -- Non-admin principals are always scoped to `auth.principal_id`, regardless of query parameters. - -`POST /api/audit/events` remains PoP-protected and continues enriching external proxy events with the caller's `identity` and `principal_id`. - -The implementation should extend the current server-owned query surface: - -- `routes/audit.py` parses filters and pagination, resolves the caller, and computes the effective scope. -- `ServerAuditLog.list_events(...)` accepts richer filter and pagination arguments. -- `AuditEventRegistry.list_recent(...)` builds a parameterized query over the existing `audit_events` table. -- The UI calls the same endpoint for both admin and non-admin users. - -This preserves the current module boundaries: `audit/` emits structured events, while `server/` owns storage, authorization, and query behavior. - -## API Behavior - -`GET /api/audit/events` should support: - -- `limit`: bounded result count, clamped to the existing maximum of 500. -- `cursor`: opaque or documented cursor for fetching the next newest-first page. -- `event`: exact event-name filter. -- `provider`: exact provider filter. -- `identity`: exact identity-handle filter. -- `from`: inclusive lower timestamp bound. -- `to`: exclusive upper timestamp bound. - -Responses should remain newest-first and include pagination metadata: - -```json -{ - "entries": [], - "next_cursor": null, - "scope": "principal" -} -``` - -`scope` is `global` for admin global queries and `principal` for user-scoped queries. The response may include the effective `principal_id` for debugging only if it is the caller's own principal; it must not reveal other principals in user-scoped responses. - -## Authorization - -Authorization must fail closed. - -- Missing or invalid authentication returns `401`. -- Non-admin users cannot widen scope by passing `principal_id`, `vault_id`, or another identity in query parameters. -- If a non-admin passes an `identity` filter outside their own principal, the result is empty because the enforced `principal_id` condition still applies. -- Admin-only or global events without the caller's `principal_id` are never returned to non-admin callers. - -The server should not rely on UI filtering for security. - -## Data Flow - -1. A caller requests `/api/audit/events?limit=50&event=login&provider=github`. -2. The route resolves auth with `get_daemon_or_browser_auth_service`. -3. The route derives effective scope: - - admin: no forced `principal_id` for global results. - - user: forced `principal_id = auth.principal_id`. -4. The repository applies the effective scope, filters, timestamp range, limit, cursor, and newest-first ordering. -5. The response returns entries and `next_cursor`. -6. The dashboard maps entries to the existing `AuditRow` display model and renders either the global admin view or the account-scoped user view. - -## UI Design - -The dashboard already has an Audit tab and table. It should become visible to all authenticated users: - -- Sidebar shows `Audit Log` for all users. -- Dashboard home shows recent audit events for all users when available. -- `/audit` page renders for non-admin users instead of returning `null`. -- Admin copy remains: "Recent administrative and credential events." -- User copy becomes: "Recent account, identity, vault, and credential events for this principal." -- Filters cover event type, provider, identity, and time range. -- Pagination uses a "Load more" control backed by the server cursor. - -Source changes belong in `ui/src/...`. After the UI source changes, rebuild/export the static dashboard and refresh `src/authsome/ui/web`. - -## Error Handling - -- Invalid timestamps or malformed cursors should return FastAPI validation errors. -- Empty result sets return `200` with `entries: []`. -- Oversized limits are clamped rather than rejected. -- Store query errors should propagate through the existing server error handling; do not leak raw SQL or storage details in response bodies. - -## Testing - -Add focused tests for: - -- Two principals with overlapping providers where a non-admin only sees their own provider events. -- A non-admin cannot widen scope by passing another principal's ID or another identity. -- Admin callers still see global newest-first results. -- Filters narrow results within the effective scope. -- Cursor pagination returns newest-first pages without skipping or duplicating rows. -- UI source no longer hides the Audit Log navigation or `/audit` page from non-admin users. - -The existing Store-backed audit registry tests should continue to pass. From 4e7d31a7480d05cb1f0f1feeb3d94e41eafb6aeb Mon Sep 17 00:00:00 2001 From: beubax Date: Mon, 15 Jun 2026 14:26:23 +0530 Subject: [PATCH 10/15] test: isolate server storage env --- tests/conftest.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/tests/conftest.py b/tests/conftest.py index 17fa6d32..65a72ccb 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -12,6 +12,9 @@ os.environ["AUTHSOME_BASE_URL"] = TEST_AUTHSOME_BASE_URL os.environ["AUTHSOME_ENV"] = "test" os.environ["AUTHSOME_DO_NOT_TRACK"] = "true" +os.environ.pop("AUTHSOME_DATABASE_URL", None) +os.environ.pop("DATABASE_URL", None) +os.environ.pop("AUTHSOME_REDIS_URL", None) os.environ.pop("AUTHSOME_POSTHOG_API_KEY", None) os.environ.pop("POSTHOG_API_KEY", None) @@ -26,6 +29,9 @@ def _disable_analytics(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setenv("AUTHSOME_BASE_URL", TEST_AUTHSOME_BASE_URL) monkeypatch.setenv("AUTHSOME_ENV", "test") monkeypatch.setenv("AUTHSOME_DO_NOT_TRACK", "true") + monkeypatch.delenv("AUTHSOME_DATABASE_URL", raising=False) + monkeypatch.delenv("DATABASE_URL", raising=False) + monkeypatch.delenv("AUTHSOME_REDIS_URL", raising=False) monkeypatch.delenv("AUTHSOME_POSTHOG_API_KEY", raising=False) monkeypatch.delenv("POSTHOG_API_KEY", raising=False) analytics.shutdown_posthog() From a034e0781b7584b7cfcbf5c6f6358b149fb92564 Mon Sep 17 00:00:00 2001 From: beubax Date: Mon, 15 Jun 2026 14:49:21 +0530 Subject: [PATCH 11/15] test: count nested health routes --- tests/server/test_health_routes.py | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/tests/server/test_health_routes.py b/tests/server/test_health_routes.py index 9ad0042d..3d6869de 100644 --- a/tests/server/test_health_routes.py +++ b/tests/server/test_health_routes.py @@ -5,6 +5,18 @@ from tests.server.helpers import create_server_test_client +def _registered_paths(routes, prefix: str = "") -> list[str]: # noqa: ANN001 + paths: list[str] = [] + for route in routes: + path = getattr(route, "path", "") + full_path = f"{prefix.rstrip('/')}/{path.lstrip('/')}" if prefix else path + paths.append(full_path) + child_routes = getattr(route, "routes", None) + if child_routes is not None: + paths.extend(_registered_paths(child_routes, full_path)) + return paths + + def test_root_health_alias_matches_api_health(monkeypatch, tmp_path) -> None: monkeypatch.setenv("AUTHSOME_HOME", str(tmp_path)) @@ -21,6 +33,6 @@ def test_api_health_route_is_registered_once(monkeypatch, tmp_path) -> None: monkeypatch.setenv("AUTHSOME_HOME", str(tmp_path)) with create_server_test_client() as client: - api_health_routes = [route for route in client.app.router.routes if getattr(route, "path", "") == "/api/health"] + api_health_routes = [path for path in _registered_paths(client.app.router.routes) if path == "/api/health"] assert len(api_health_routes) == 1 From 0433db633c65baf1a8ed017ea4b671f6c2671818 Mon Sep 17 00:00:00 2001 From: beubax Date: Mon, 15 Jun 2026 17:04:43 +0530 Subject: [PATCH 12/15] test: verify health route via openapi --- tests/server/test_health_routes.py | 17 +++-------------- 1 file changed, 3 insertions(+), 14 deletions(-) diff --git a/tests/server/test_health_routes.py b/tests/server/test_health_routes.py index 3d6869de..a2ef5d08 100644 --- a/tests/server/test_health_routes.py +++ b/tests/server/test_health_routes.py @@ -5,18 +5,6 @@ from tests.server.helpers import create_server_test_client -def _registered_paths(routes, prefix: str = "") -> list[str]: # noqa: ANN001 - paths: list[str] = [] - for route in routes: - path = getattr(route, "path", "") - full_path = f"{prefix.rstrip('/')}/{path.lstrip('/')}" if prefix else path - paths.append(full_path) - child_routes = getattr(route, "routes", None) - if child_routes is not None: - paths.extend(_registered_paths(child_routes, full_path)) - return paths - - def test_root_health_alias_matches_api_health(monkeypatch, tmp_path) -> None: monkeypatch.setenv("AUTHSOME_HOME", str(tmp_path)) @@ -33,6 +21,7 @@ def test_api_health_route_is_registered_once(monkeypatch, tmp_path) -> None: monkeypatch.setenv("AUTHSOME_HOME", str(tmp_path)) with create_server_test_client() as client: - api_health_routes = [path for path in _registered_paths(client.app.router.routes) if path == "/api/health"] + openapi = client.get("/openapi.json") - assert len(api_health_routes) == 1 + assert openapi.status_code == status.HTTP_200_OK + assert list(openapi.json()["paths"]).count("/api/health") == 1 From 2a296e33a3ffd5b26d8192f0c995a21e188d6744 Mon Sep 17 00:00:00 2001 From: beubax Date: Mon, 15 Jun 2026 17:49:29 +0530 Subject: [PATCH 13/15] refactor: simplify audit query filters --- src/authsome/server/store/repositories.py | 32 ++++++++--------------- 1 file changed, 11 insertions(+), 21 deletions(-) diff --git a/src/authsome/server/store/repositories.py b/src/authsome/server/store/repositories.py index 9b27a252..e8d59765 100644 --- a/src/authsome/server/store/repositories.py +++ b/src/authsome/server/store/repositories.py @@ -147,27 +147,17 @@ async def query_events( # noqa: PLR0913 cursor: str | None = None, ) -> AuditEventPage: bounded_limit = min(max(limit, 1), 500) - conditions: list[str] = [] - params: list[Any] = [] - - if principal_id is not None: - conditions.append("principal_id = ?") - params.append(principal_id) - if event: - conditions.append("event = ?") - params.append(event) - if provider: - conditions.append("provider = ?") - params.append(provider) - if identity: - conditions.append("identity = ?") - params.append(identity) - if since is not None: - conditions.append("timestamp >= ?") - params.append(_dump_dt(since)) - if until is not None: - conditions.append("timestamp < ?") - params.append(_dump_dt(until)) + filter_specs = ( + ("principal_id = ?", principal_id, principal_id is not None), + ("event = ?", event, bool(event)), + ("provider = ?", provider, bool(provider)), + ("identity = ?", identity, bool(identity)), + ("timestamp >= ?", _dump_dt(since) if since is not None else None, since is not None), + ("timestamp < ?", _dump_dt(until) if until is not None else None, until is not None), + ) + conditions = [condition for condition, _value, enabled in filter_specs if enabled] + params = [value for _condition, value, enabled in filter_specs if enabled] + if cursor: cursor_timestamp, cursor_event_id = _decode_audit_cursor(cursor) conditions.append("(timestamp < ? OR (timestamp = ? AND event_id < ?))") From 71d52ad0f8d658df949c5f0949e650332c26fd42 Mon Sep 17 00:00:00 2001 From: beubax Date: Mon, 15 Jun 2026 18:06:50 +0530 Subject: [PATCH 14/15] refactor: remove audit event filters --- docs/site/reference/audit-log.mdx | 8 +- src/authsome/server/routes/audit.py | 20 +-- src/authsome/server/store/repositories.py | 35 +---- tests/server/test_audit_events.py | 50 ++++-- ui/src/components/authsome-dashboard.tsx | 179 +--------------------- ui/src/lib/authsome-api.ts | 22 +-- 6 files changed, 61 insertions(+), 253 deletions(-) diff --git a/docs/site/reference/audit-log.mdx b/docs/site/reference/audit-log.mdx index 6a95e073..5ceebc79 100644 --- a/docs/site/reference/audit-log.mdx +++ b/docs/site/reference/audit-log.mdx @@ -53,8 +53,7 @@ authsome log --json # Output JSON format `GET /api/audit/events` is role-aware. Admin principals can review the global audit log. Non-admin principals receive only events scoped to their own principal, including their -claimed identities, vault, providers, and credential lifecycle activity. Passing another -`principal_id`, vault, or identity in query parameters cannot widen a non-admin query. +claimed identities, vault, providers, and credential lifecycle activity. Supported query parameters: @@ -62,11 +61,6 @@ Supported query parameters: | --- | --- | | `limit` | Number of events to return, clamped to the server maximum. | | `cursor` | Cursor returned by the previous page. | -| `event` | Exact event name. | -| `provider` | Exact provider name, such as `github`. | -| `identity` | Exact identity handle. | -| `from` | Inclusive lower timestamp bound. | -| `to` | Exclusive upper timestamp bound. | Results are sorted newest-first and include `next_cursor` when another page is available. diff --git a/src/authsome/server/routes/audit.py b/src/authsome/server/routes/audit.py index 04d44593..19d770e8 100644 --- a/src/authsome/server/routes/audit.py +++ b/src/authsome/server/routes/audit.py @@ -1,9 +1,8 @@ """Audit event routes.""" -from datetime import datetime -from typing import Annotated, Any, Literal +from typing import Any, Literal -from fastapi import APIRouter, Depends, HTTPException, Query, Request, status +from fastapi import APIRouter, Depends, HTTPException, Request, status from authsome import audit from authsome.identity.principal import PrincipalRole @@ -17,29 +16,18 @@ @router.get("/events") -async def list_audit_events( # noqa: PLR0913 +async def list_audit_events( request: Request, limit: int = 50, cursor: str | None = None, - event: str | None = None, - provider: str | None = None, - identity: str | None = None, - principal_id: str | None = None, - from_: Annotated[datetime | None, Query(alias="from")] = None, - to: datetime | None = None, auth: CredentialService = Depends(get_daemon_or_browser_auth_service), ) -> dict[str, Any]: - effective_principal_id = principal_id if auth.principal_role == PrincipalRole.ADMIN else auth.principal_id + effective_principal_id = None if auth.principal_role == PrincipalRole.ADMIN else auth.principal_id scope: Literal["global", "principal"] = "global" if effective_principal_id is None else "principal" try: page = await request.app.state.audit_log.query_events( limit=limit, principal_id=effective_principal_id, - event=event, - provider=provider, - identity=identity, - since=from_, - until=to, cursor=cursor, ) except ValueError as exc: diff --git a/src/authsome/server/store/repositories.py b/src/authsome/server/store/repositories.py index e8d59765..e36b08a6 100644 --- a/src/authsome/server/store/repositories.py +++ b/src/authsome/server/store/repositories.py @@ -134,29 +134,20 @@ async def insert_many(self, events: list[AuditEventInsert]) -> None: ], ) - async def query_events( # noqa: PLR0913 + async def query_events( self, *, limit: int = 50, principal_id: str | None = None, - event: str | None = None, - provider: str | None = None, - identity: str | None = None, - since: datetime | None = None, - until: datetime | None = None, cursor: str | None = None, ) -> AuditEventPage: bounded_limit = min(max(limit, 1), 500) - filter_specs = ( - ("principal_id = ?", principal_id, principal_id is not None), - ("event = ?", event, bool(event)), - ("provider = ?", provider, bool(provider)), - ("identity = ?", identity, bool(identity)), - ("timestamp >= ?", _dump_dt(since) if since is not None else None, since is not None), - ("timestamp < ?", _dump_dt(until) if until is not None else None, until is not None), - ) - conditions = [condition for condition, _value, enabled in filter_specs if enabled] - params = [value for _condition, value, enabled in filter_specs if enabled] + conditions: list[str] = [] + params: list[Any] = [] + + if principal_id is not None: + conditions.append("principal_id = ?") + params.append(principal_id) if cursor: cursor_timestamp, cursor_event_id = _decode_audit_cursor(cursor) @@ -339,27 +330,17 @@ async def async_shutdown(self) -> None: _delegating_audit_exporter.set_active(None) self._exporter.close() - async def query_events( # noqa: PLR0913 + async def query_events( self, *, limit: int = 50, principal_id: str | None = None, - event: str | None = None, - provider: str | None = None, - identity: str | None = None, - since: datetime | None = None, - until: datetime | None = None, cursor: str | None = None, ) -> AuditEventPage: await self.async_force_flush() return await self._registry.query_events( limit=limit, principal_id=principal_id, - event=event, - provider=provider, - identity=identity, - since=since, - until=until, cursor=cursor, ) diff --git a/tests/server/test_audit_events.py b/tests/server/test_audit_events.py index 096ccc35..595d0f8c 100644 --- a/tests/server/test_audit_events.py +++ b/tests/server/test_audit_events.py @@ -45,7 +45,7 @@ def _emit_audit_event( # noqa: PLR0913 emit( AuditEvent( event_id=event_id, - timestamp=timestamp or datetime(2026, 6, 15, 8, 0, tzinfo=UTC), + timestamp=timestamp or datetime(2099, 1, 1, 8, 0, tzinfo=UTC), event=event, principal_id=principal_id, identity=identity, @@ -82,6 +82,17 @@ def test_audit_events_endpoint_returns_internal_events_for_admin(monkeypatch, tm assert entries[0]["provider"] == "github" +def test_audit_events_endpoint_only_documents_pagination_params(monkeypatch, tmp_path: Path) -> None: + monkeypatch.setenv("AUTHSOME_HOME", str(tmp_path)) + + with create_server_test_client() as client: + response = client.get("/openapi.json") + + assert response.status_code == status.HTTP_200_OK + params = response.json()["paths"]["/api/audit/events"]["get"]["parameters"] + assert {param["name"] for param in params} == {"limit", "cursor"} + + def test_external_audit_post_is_enriched_from_pop_identity(monkeypatch, tmp_path: Path) -> None: monkeypatch.setenv("AUTHSOME_HOME", str(tmp_path)) payload = {"event": {"event": "proxy_deny", "metadata": {"host": "api.example.com", "reason": "no_match"}}} @@ -156,7 +167,7 @@ def test_admin_sees_all_audit_events_and_user_sees_only_own_principal(monkeypatc assert all(entry["principal_id"] == user_whoami["principal_id"] for entry in user_entries) -def test_non_admin_audit_filters_stay_within_own_principal(monkeypatch, tmp_path: Path) -> None: +def test_non_admin_audit_query_params_do_not_filter_or_widen_scope(monkeypatch, tmp_path: Path) -> None: monkeypatch.setenv("AUTHSOME_HOME", str(tmp_path)) with create_server_test_client() as client: @@ -205,10 +216,9 @@ def test_non_admin_audit_filters_stay_within_own_principal(monkeypatch, tmp_path assert response.status_code == status.HTTP_200_OK body = response.json() assert body["scope"] == "principal" - assert body["next_cursor"] is None - assert [entry["event_id"] for entry in body["entries"]] == ["audit_002"] - assert body["entries"][0]["principal_id"] == user_whoami["principal_id"] - assert body["entries"][0]["provider"] == "github" + manual_entries = [entry for entry in body["entries"] if entry["event_id"].startswith("audit_00")] + assert [entry["event_id"] for entry in manual_entries] == ["audit_003", "audit_002"] + assert all(entry["principal_id"] == user_whoami["principal_id"] for entry in body["entries"]) def test_non_admin_audit_query_cannot_widen_scope_with_principal_or_identity( @@ -259,10 +269,13 @@ def test_non_admin_audit_query_cannot_widen_scope_with_principal_or_identity( assert response.status_code == status.HTTP_200_OK body = response.json() assert body["scope"] == "principal" - assert body["entries"] == [] + event_ids = {entry["event_id"] for entry in body["entries"]} + assert "audit_011" in event_ids + assert "audit_010" not in event_ids + assert all(entry["principal_id"] == user_whoami["principal_id"] for entry in body["entries"]) -def test_admin_audit_events_support_filters_and_cursor_pagination(monkeypatch, tmp_path: Path) -> None: +def test_admin_audit_events_support_cursor_pagination(monkeypatch, tmp_path: Path) -> None: monkeypatch.setenv("AUTHSOME_HOME", str(tmp_path)) with create_server_test_client() as client: @@ -282,7 +295,15 @@ def test_admin_audit_events_support_filters_and_cursor_pagination(monkeypatch, t principal_id=admin_whoami["principal_id"], identity="admin-ready-boldly-0001", provider="github", - timestamp=datetime(2026, 6, 15, 8, 0, tzinfo=UTC), + timestamp=datetime(2099, 1, 1, 8, 0, tzinfo=UTC), + ) + _emit_audit_event( + "audit_099", + "connection.logout", + principal_id=admin_whoami["principal_id"], + identity="admin-ready-boldly-0001", + provider="linear", + timestamp=datetime(2099, 1, 1, 7, 59, tzinfo=UTC), ) _emit_audit_event( "audit_101", @@ -290,7 +311,7 @@ def test_admin_audit_events_support_filters_and_cursor_pagination(monkeypatch, t principal_id=user_whoami["principal_id"], identity="steady-wisely-boldly-0042", provider="github", - timestamp=datetime(2026, 6, 15, 8, 1, tzinfo=UTC), + timestamp=datetime(2099, 1, 1, 8, 1, tzinfo=UTC), ) _emit_audit_event( "audit_102", @@ -298,10 +319,10 @@ def test_admin_audit_events_support_filters_and_cursor_pagination(monkeypatch, t principal_id=user_whoami["principal_id"], identity="steady-wisely-boldly-0042", provider="github", - timestamp=datetime(2026, 6, 15, 8, 2, tzinfo=UTC), + timestamp=datetime(2099, 1, 1, 8, 2, tzinfo=UTC), ) - first_path = "/api/audit/events?provider=github&limit=2" + first_path = "/api/audit/events?limit=2" first_response = client.get( first_path, headers=_auth_header( @@ -313,7 +334,7 @@ def test_admin_audit_events_support_filters_and_cursor_pagination(monkeypatch, t ) assert first_response.status_code == status.HTTP_200_OK first_body = first_response.json() - second_path = f"/api/audit/events?provider=github&limit=2&cursor={first_body['next_cursor']}" + second_path = f"/api/audit/events?limit=2&cursor={first_body['next_cursor']}" second_response = client.get( second_path, headers=_auth_header( @@ -331,8 +352,7 @@ def test_admin_audit_events_support_filters_and_cursor_pagination(monkeypatch, t assert second_response.status_code == status.HTTP_200_OK second_body = second_response.json() assert second_body["scope"] == "global" - assert [entry["event_id"] for entry in second_body["entries"]] == ["audit_100"] - assert second_body["next_cursor"] is None + assert [entry["event_id"] for entry in second_body["entries"]] == ["audit_100", "audit_099"] @pytest.mark.asyncio diff --git a/ui/src/components/authsome-dashboard.tsx b/ui/src/components/authsome-dashboard.tsx index 16803297..fd091f65 100644 --- a/ui/src/components/authsome-dashboard.tsx +++ b/ui/src/components/authsome-dashboard.tsx @@ -33,7 +33,6 @@ import useSWR from "swr"; import { ApiError, - AuditFilters, ConnectionDetail, DashboardData, GlobalConnectionRow, @@ -1437,62 +1436,20 @@ export function PrincipalsView() { ); } -function normalizeAuditFilters(filters: AuditFilters): AuditFilters { - return Object.fromEntries( - Object.entries(filters) - .map(([key, value]) => [key, typeof value === "string" ? value.trim() : value]) - .filter(([, value]) => value !== undefined && value !== null && String(value).trim() !== ""), - ) as AuditFilters; -} - -function localDateTimeToIso(value: string): string | undefined { - if (!value) return undefined; - const parsed = new Date(value); - return Number.isNaN(parsed.valueOf()) ? undefined : parsed.toISOString(); -} - export function AuditView({ data }: { data: DashboardData }) { - const [filters, setFilters] = useState({ - event: "", - provider: "", - identity: "", - from: "", - to: "", - }); const [auditResult, setAuditResult] = useState<{ - activeFilters: AuditFilters; events: DashboardData["audit"]["events"]; nextCursor: string | null; } | null>(null); const [errorMessage, setErrorMessage] = useState(""); - const [loadingAction, setLoadingAction] = useState<"apply" | "more" | null>(null); + const [loadingMore, setLoadingMore] = useState(false); const requestSequence = useRef(0); const events = auditResult?.events ?? data.audit.events; const nextCursor = auditResult?.nextCursor ?? data.audit.nextCursor; - const activeFilters = auditResult?.activeFilters ?? {}; - const providerNames = useMemo(() => data.providers.map((provider) => provider.name).sort(), [data.providers]); - const identityHandles = useMemo(() => data.identities.map((identity) => identity.handle).sort(), [data.identities]); - const eventNames = useMemo(() => Array.from(new Set(events.map((event) => event.eventName))).sort(), [events]); const description = data.account.isAdmin ? "Recent administrative and credential events." : "Recent account, identity, vault, and credential events for this principal."; - function updateFilter(name: keyof Pick, value: string) { - setFilters((current) => ({ ...current, [name]: value })); - } - - function filtersForRequest(cursor?: string | null): AuditFilters { - return normalizeAuditFilters({ - event: filters.event, - provider: filters.provider, - identity: filters.identity, - from: localDateTimeToIso(filters.from || ""), - to: localDateTimeToIso(filters.to || ""), - cursor, - limit: 50, - }); - } - function nextRequestId(): number { requestSequence.current += 1; return requestSequence.current; @@ -1502,64 +1459,15 @@ export function AuditView({ data }: { data: DashboardData }) { return requestSequence.current === requestId; } - async function applyFilters(event: FormEvent) { - event.preventDefault(); - const requestFilters = filtersForRequest(); - const requestId = nextRequestId(); - setLoadingAction("apply"); - setErrorMessage(""); - try { - const result = await fetchAuditEvents(requestFilters); - if (!isLatestRequest(requestId)) return; - setAuditResult({ - activeFilters: requestFilters, - events: result.events, - nextCursor: result.nextCursor, - }); - } catch (error) { - if (!isLatestRequest(requestId)) return; - setErrorMessage(error instanceof Error ? error.message : "Failed to load audit events."); - } finally { - if (isLatestRequest(requestId)) { - setLoadingAction(null); - } - } - } - - async function clearFilters() { - const emptyFilters = { event: "", provider: "", identity: "", from: "", to: "" }; - const requestId = nextRequestId(); - setFilters(emptyFilters); - setLoadingAction("apply"); - setErrorMessage(""); - try { - const result = await fetchAuditEvents({ limit: 50 }); - if (!isLatestRequest(requestId)) return; - setAuditResult({ - activeFilters: {}, - events: result.events, - nextCursor: result.nextCursor, - }); - } catch (error) { - if (!isLatestRequest(requestId)) return; - setErrorMessage(error instanceof Error ? error.message : "Failed to load audit events."); - } finally { - if (isLatestRequest(requestId)) { - setLoadingAction(null); - } - } - } - async function loadMore() { if (!nextCursor) return; const requestId = nextRequestId(); - setLoadingAction("more"); + setLoadingMore(true); setErrorMessage(""); try { - const result = await fetchAuditEvents({ ...activeFilters, cursor: nextCursor, limit: 50 }); + const result = await fetchAuditEvents({ cursor: nextCursor, limit: 50 }); if (!isLatestRequest(requestId)) return; setAuditResult({ - activeFilters, events: [...events, ...result.events], nextCursor: result.nextCursor, }); @@ -1568,7 +1476,7 @@ export function AuditView({ data }: { data: DashboardData }) { setErrorMessage(error instanceof Error ? error.message : "Failed to load more audit events."); } finally { if (isLatestRequest(requestId)) { - setLoadingAction(null); + setLoadingMore(false); } } } @@ -1576,80 +1484,7 @@ export function AuditView({ data }: { data: DashboardData }) { return (
- - - Filters - {data.audit.scope === "global" ? "Global audit scope" : "Principal audit scope"} - - -
void applyFilters(event)}> - - - - - - - {eventNames.map((event) => ( - - - {providerNames.map((provider) => ( - - - {identityHandles.map((identity) => ( - -
- - -
-
- {errorMessage ?

{errorMessage}

: null} -
-
+ {errorMessage ?

{errorMessage}

: null} {events.length ? ( @@ -1694,8 +1529,8 @@ export function AuditView({ data }: { data: DashboardData }) { {nextCursor ? (
-
) : null} diff --git a/ui/src/lib/authsome-api.ts b/ui/src/lib/authsome-api.ts index 926e9890..2a9176c4 100644 --- a/ui/src/lib/authsome-api.ts +++ b/ui/src/lib/authsome-api.ts @@ -51,12 +51,7 @@ export type AuditRow = { metadata: Record; }; -export type AuditFilters = { - event?: string; - provider?: string; - identity?: string; - from?: string; - to?: string; +export type AuditEventsQuery = { cursor?: string | null; limit?: number; }; @@ -517,20 +512,15 @@ function buildAuditRows(entries: AuditResponse["entries"]): AuditRow[] { }); } -function auditQueryString(filters: AuditFilters = {}): string { +function auditQueryString(query: AuditEventsQuery = {}): string { const params = new URLSearchParams(); - params.set("limit", String(filters.limit ?? 50)); - if (filters.event) params.set("event", filters.event); - if (filters.provider) params.set("provider", filters.provider); - if (filters.identity) params.set("identity", filters.identity); - if (filters.from) params.set("from", filters.from); - if (filters.to) params.set("to", filters.to); - if (filters.cursor) params.set("cursor", filters.cursor); + params.set("limit", String(query.limit ?? 50)); + if (query.cursor) params.set("cursor", query.cursor); return params.toString(); } -export async function fetchAuditEvents(filters: AuditFilters = {}): Promise { - const data = await requestJson(`/api/audit/events?${auditQueryString(filters)}`); +export async function fetchAuditEvents(query: AuditEventsQuery = {}): Promise { + const data = await requestJson(`/api/audit/events?${auditQueryString(query)}`); const events = buildAuditRows(data.entries); return { scope: data.scope ?? "principal", From de814ee15ed6d06520093e55d4197e0a5d2ee72f Mon Sep 17 00:00:00 2001 From: beubax Date: Mon, 15 Jun 2026 18:26:14 +0530 Subject: [PATCH 15/15] fix: restore logo dev token --- .gitleaksignore | 3 +++ ui/src/components/dashboard/dashboard-primitives.tsx | 4 +++- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/.gitleaksignore b/.gitleaksignore index 1f305a50..f0f6a276 100644 --- a/.gitleaksignore +++ b/.gitleaksignore @@ -1,2 +1,5 @@ # PostHog public project API key — intentionally committed, not a secret src/authsome/server/analytics.py:generic-api-key:50 + +# Logo.dev public project token — intentionally committed for provider logos +ui/src/components/dashboard/dashboard-primitives.tsx:generic-api-key:12 diff --git a/ui/src/components/dashboard/dashboard-primitives.tsx b/ui/src/components/dashboard/dashboard-primitives.tsx index 755e9591..4bf3429e 100644 --- a/ui/src/components/dashboard/dashboard-primitives.tsx +++ b/ui/src/components/dashboard/dashboard-primitives.tsx @@ -9,6 +9,8 @@ import { Input } from "@/components/ui/input"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { cn } from "@/lib/utils"; +const LOGO_DEV_TOKEN = "pk_BhJg_kBbQPqNGuuWcNs9Cg"; + export const INTERACTIVE_CARD_CLASS = "cursor-pointer border border-border/50 shadow-none transition-all hover:border-primary/60 hover:bg-primary/[0.03] hover:shadow-sm"; export const INTERACTIVE_ROW_CLASS = @@ -55,7 +57,7 @@ function providerLogoUrl(logo: string): string { return logo; } if (logo.startsWith("img.logo.dev")) { - return `https://${logo}`; + return `https://${logo}?token=${LOGO_DEV_TOKEN}`; } return logo; }