From 224be18855ce222ef99acbaa84e35cf3be949924 Mon Sep 17 00:00:00 2001 From: beubax Date: Mon, 15 Jun 2026 12:56:42 +0530 Subject: [PATCH 01/19] 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 0000000..18b33d1 --- /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/19] 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 0000000..aa8c92c --- /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/19] 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 a047c93..4494de6 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/19] 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 d1a029a..35be29d 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 @@ class AuditEventRegistry: ], ) - 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 @@ class ServerAuditLog: 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/19] 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 3b534d6..04d4459 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 = APIRouter(prefix="/audit", tags=["audit"]) @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/19] 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 282f21c..90b5d24 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 ec0fce0..14f541e 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 ab130ba..8f35437 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/19] 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 68ae8d4..6a95e07 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/19] 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 11c882f..58db3d0 100644 --- a/src/authsome/server/app.py +++ b/src/authsome/server/app.py @@ -40,7 +40,7 @@ from authsome.server.ui_sessions import UiSessionStore 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 35be29d..9b27a25 100644 --- a/src/authsome/server/store/repositories.py +++ b/src/authsome/server/store/repositories.py @@ -271,6 +271,10 @@ class _StoreAuditExporter(LogRecordExporter): 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 @@ class ServerAuditLog: 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 dbe6d88..f44f8eb 100644 --- a/tests/auth/test_service.py +++ b/tests/auth/test_service.py @@ -147,7 +147,7 @@ class TestAuthServiceRefreshLogs: 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 4494de6..096ccc3 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 8003ed0..df816d8 100644 --- a/tests/server/test_runtime_backend_selection.py +++ b/tests/server/test_runtime_backend_selection.py @@ -50,6 +50,9 @@ class FakeAuditLog: 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/19] 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 aa8c92c..0000000 --- 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 9f1b6d5..0000000 --- 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 18b33d1..0000000 --- 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/19] 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 17fa6d3..65a72cc 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -12,6 +12,9 @@ os.environ["AUTHSOME_HOME"] = _tmp_dir.name 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/19] 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 9ad0042..3d6869d 100644 --- a/tests/server/test_health_routes.py +++ b/tests/server/test_health_routes.py @@ -5,6 +5,18 @@ from fastapi import status 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/19] 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 3d6869d..a2ef5d0 100644 --- a/tests/server/test_health_routes.py +++ b/tests/server/test_health_routes.py @@ -5,18 +5,6 @@ from fastapi import status 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 972e00c575956f659a0d8ab4775802920e5407b2 Mon Sep 17 00:00:00 2001 From: Ankit Ranjan Date: Mon, 15 Jun 2026 17:27:10 +0530 Subject: [PATCH 13/19] refactor: rename identity to agent throughout CLI and UI for improved terminology consistency --- docs/internal/cli-design-review.md | 6 +- docs/internal/manual-testing.md | 18 +-- docs/site/reference/cli.mdx | 16 +-- src/authsome/cli/client.py | 12 +- src/authsome/cli/commands/__init__.py | 4 +- .../cli/commands/{profile.py => agent.py} | 40 +++--- src/authsome/cli/commands/core.py | 7 +- src/authsome/server/account_auth.py | 27 +++- src/authsome/server/routes/ui.py | 34 +++++ tests/cli/test_client_signing.py | 4 +- tests/cli/test_identity.py | 49 ++++--- tests/cli/test_init.py | 10 +- tests/cli/test_whoami.py | 6 +- tests/server/test_account_auth.py | 29 ++++ tests/server/test_ui_account.py | 40 ++++++ ui/src/app/layout.tsx | 2 +- ui/src/components/dashboard/auth-flows.tsx | 4 +- .../dashboard/connection-detail-view.tsx | 2 +- .../components/dashboard/connection-views.tsx | 2 +- .../components/dashboard/dashboard-shell.tsx | 2 +- .../components/dashboard/overview-views.tsx | 20 +-- ui/src/components/dashboard/settings-view.tsx | 126 ++++++++++++++++-- ui/src/lib/authsome-api.ts | 24 ++-- 23 files changed, 362 insertions(+), 122 deletions(-) rename src/authsome/cli/commands/{profile.py => agent.py} (56%) create mode 100644 tests/server/test_ui_account.py diff --git a/docs/internal/cli-design-review.md b/docs/internal/cli-design-review.md index 74e880d..22078c5 100644 --- a/docs/internal/cli-design-review.md +++ b/docs/internal/cli-design-review.md @@ -73,9 +73,9 @@ Docs say default: `~/.authsome/logs/authsome.log`. --- -### 1e. `profile` command exists but is not documented +### 1e. Legacy `profile` command is removed -`authsome profile` with subcommands `create` and `use` appears in the CLI but is absent from `docs/site/reference/cli.mdx`. +The local signing-key command surface is `authsome agent create` and `authsome agent use`. --- @@ -224,7 +224,7 @@ If the behavior differs for bundled vs. custom providers, the `--help` text shou | P1 | Fix `--quiet` — stop suppressing data output | Medium | | P1 | Fix `--force` on `register` — imply `--yes` or document split | Small | | P1 | Add `connection set-default` subgroup (or alias to match docs) | Small | -| P1 | Document `profile` command, `shell` export format, corrected `--log-file` path | Small | +| P1 | Document `shell` export format and corrected `--log-file` path | Small | | P2 | Resolve `inspect` vs `get` overlap — pick a clear model | Medium | | P2 | Add human-readable default to `inspect` and `daemon status` | Medium | | P2 | Fix `daemon stop` — wait for actual stop before returning | Medium | diff --git a/docs/internal/manual-testing.md b/docs/internal/manual-testing.md index 380c029..105f7c5 100644 --- a/docs/internal/manual-testing.md +++ b/docs/internal/manual-testing.md @@ -39,17 +39,17 @@ uv run authsome whoami **Expected (first run):** the command prints a claim URL to stderr, opens it in a browser, and blocks while polling: ``` -Open this URL in your browser to claim this identity: +Open this URL in your browser to claim this agent: http://127.0.0.1:7998/claim?token=claim_ ``` **Human action:** 1. The browser opens the claim page automatically (open the printed URL yourself if it doesn't, e.g. on a headless box). 2. Register with an email + password — or log in if the account already exists. The first account on a fresh server becomes the **admin** Principal. -3. Confirm that the displayed identity handle is yours. +3. Confirm that the displayed agent handle is yours. 4. The CLI unblocks and `whoami` prints your context. Subsequent commands reuse the accepted claim — no browser step. -**Expected (after claim):** a JSON object (`{"v": 1, ...}`) with key fields `authsome_version`, `home_directory`, `profile` (registered non-default identity handle), `principal_id`, `vault_id`, `did`, `registration_status`, `daemon_url`, `configured_encryption_mode`, `effective_encryption_source`, `encryption_backend`, `vault_status` (`OK`), `connected_providers_count` (`0`), `connected_providers` (`[]`), and `issues` (`[]`). +**Expected (after claim):** a JSON object (`{"v": 1, ...}`) with key fields `authsome_version`, `home_directory`, `agent` (registered non-default agent handle), `principal_id`, `vault_id`, `did`, `registration_status`, `daemon_url`, `configured_encryption_mode`, `effective_encryption_source`, `encryption_backend`, `vault_status` (`OK`), `connected_providers_count` (`0`), `connected_providers` (`[]`), and `issues` (`[]`). ```bash uv run authsome doctor @@ -327,20 +327,20 @@ uv run authsome provider list # github connection gone --- -## 15. Profiles +## 15. Agents ```bash -uv run authsome profile create --handle work +uv run authsome agent create --handle work ``` -**Expected:** `{"v": 1, "status": "created", "profile": "work", "did": "did:key:...", ...}`. A new local Ed25519 keypair; the next protected command for this profile triggers its own browser claim. +**Expected:** `{"v": 1, "status": "created", "agent": "work", "did": "did:key:...", ...}`. A new local Ed25519 keypair; the next protected command for this agent triggers its own browser claim. ```bash -uv run authsome profile use work -uv run authsome whoami # profile reflects "work" (claim required on first use) +uv run authsome agent use work +uv run authsome whoami # agent reflects "work" (claim required on first use) ``` -**Expected:** `profile use` → `{"status": "active", "profile": "work", ...}`. +**Expected:** `agent use` -> `{"status": "active", "agent": "work", ...}`. --- diff --git a/docs/site/reference/cli.mdx b/docs/site/reference/cli.mdx index 78cf8a1..9bc80db 100644 --- a/docs/site/reference/cli.mdx +++ b/docs/site/reference/cli.mdx @@ -12,11 +12,11 @@ All commands support `--json` for machine-readable output, `--quiet` to suppress | `connections` | Inspect and manage stored provider connections. | | `daemon` | Manage the local Authsome daemon. | | `doctor` | Run health checks on directory layout and encryption. | -| `init` | Initialize local storage and register a fresh profile. | +| `agent` | Manage local agents backed by signing keys. | +| `init` | Initialize local storage and register a fresh agent. | | `log` | View structured audit entries or the raw client debug log. | | `login ` | Authenticate with PROVIDER using the configured flow. | | `logout ` | Log out of the specified PROVIDER connection. | -| `profile` | Manage local profiles backed by identity keys. | | `provider` | Manage provider definitions and provider-level operations. | | `run -- ` | Run COMMAND as a subprocess injected with authentication credentials. | | `scan` | Scan env files and process env for provider API keys. | @@ -38,8 +38,8 @@ All commands support `--json` for machine-readable output, `--quiet` to suppress ### `init` / `whoami` / `doctor` ```bash -authsome init # initialize local storage and register profile -authsome whoami # show identity context and encryption mode +authsome init # initialize local storage and register agent +authsome whoami # show agent context and encryption mode authsome doctor # run health checks authsome doctor --json # structured output for monitoring ``` @@ -153,14 +153,14 @@ Sets the default connection for a provider. The proxy and library calls use the authsome connections set-default github work ``` -### `profile` +### `agent` ```bash -authsome profile create # create a new local profile keypair -authsome profile use # switch the active local profile +authsome agent create # create a new local agent keypair +authsome agent use # switch the active local agent ``` -Profiles are backed by Ed25519 identity keys at `~/.authsome/identities/`. Each profile has its own credential namespace in the vault. +Agents are backed by Ed25519 signing keys at `~/.authsome/identities/`. Credentials are scoped to the active vault, not to the agent key. ### `daemon` diff --git a/src/authsome/cli/client.py b/src/authsome/cli/client.py index 8b09fb0..b057acf 100644 --- a/src/authsome/cli/client.py +++ b/src/authsome/cli/client.py @@ -53,7 +53,7 @@ def raise_for_error(response: httpx.Response) -> None: try: data = response.json() if response.status_code == status.HTTP_401_UNAUTHORIZED and data.get("detail") == "Unknown identity handle": - raise err_mod.IdentityNotRegisteredError("current identity") from exc + raise err_mod.IdentityNotRegisteredError("current agent") from exc error_name = data.get("error") message = data.get("message") if error_name and message: @@ -175,15 +175,15 @@ class AuthsomeApiClient: self._open_claim_url(claim_url) await self._poll_claim_completion(runtime.handle) elif reg_status == "rejected": - raise RuntimeError(f"Identity '{runtime.handle}' claim was rejected by the server") + raise RuntimeError(f"Agent '{runtime.handle}' claim was rejected by the server") def _open_claim_url(self, claim_url: str) -> None: - print(f"Open this URL in your browser to claim this identity:\n {claim_url}", file=sys.stderr) + print(f"Open this URL in your browser to claim this agent:\n {claim_url}", file=sys.stderr) with suppress(Exception): webbrowser.open(claim_url) async def _poll_claim_completion(self, handle: str, *, timeout_seconds: int = 300) -> None: - print("Waiting for identity to be claimed...", file=sys.stderr) + print("Waiting for agent to be claimed...", file=sys.stderr) deadline = asyncio.get_running_loop().time() + timeout_seconds while True: status = await self.get_identity_status(handle) @@ -191,9 +191,9 @@ class AuthsomeApiClient: if reg_status == "claimed": return if reg_status == "rejected": - raise RuntimeError(f"Identity '{handle}' claim was rejected") + raise RuntimeError(f"Agent '{handle}' claim was rejected") if asyncio.get_running_loop().time() >= deadline: - raise TimeoutError(f"Timed out waiting for identity '{handle}' to be claimed") + raise TimeoutError(f"Timed out waiting for agent '{handle}' to be claimed") await asyncio.sleep(1) async def _get(self, path: str, *, protected: bool = True) -> dict[str, Any]: diff --git a/src/authsome/cli/commands/__init__.py b/src/authsome/cli/commands/__init__.py index 7099fcc..804870c 100644 --- a/src/authsome/cli/commands/__init__.py +++ b/src/authsome/cli/commands/__init__.py @@ -1,9 +1,9 @@ """CLI command registration.""" +import authsome.cli.commands.agent as agent_module import authsome.cli.commands.connections as connections_module import authsome.cli.commands.core as core_module import authsome.cli.commands.daemon as daemon_module -import authsome.cli.commands.profile as profile_module import authsome.cli.commands.provider as provider_module @@ -19,5 +19,5 @@ def register_commands(cli) -> None: cli.add_command(core_module.log_cmd) cli.add_command(provider_module.provider) cli.add_command(connections_module.connections) - cli.add_command(profile_module.profile) + cli.add_command(agent_module.agent) cli.add_command(daemon_module.daemon) diff --git a/src/authsome/cli/commands/profile.py b/src/authsome/cli/commands/agent.py similarity index 56% rename from src/authsome/cli/commands/profile.py rename to src/authsome/cli/commands/agent.py index e9276a6..c8aa1c3 100644 --- a/src/authsome/cli/commands/profile.py +++ b/src/authsome/cli/commands/agent.py @@ -1,4 +1,4 @@ -"""Profile CLI commands.""" +"""Local agent CLI commands.""" import click @@ -9,23 +9,19 @@ from authsome.cli.identity import RuntimeIdentity from authsome.config import get_authsome_config -@click.group(name="profile") -def profile() -> None: - """Manage local profiles backed by identity keys.""" +@click.group(name="agent") +def agent() -> None: + """Manage local agents backed by signing keys.""" -@profile.command(name="create") -@click.option("--handle", default=None, metavar="HANDLE", help="Create or reuse a specific local profile handle.") -@auth_command -async def profile_create(ctx_obj: ContextObj, handle: str | None) -> None: - """Create a local profile keypair.""" +async def _create_agent(ctx_obj: ContextObj, handle: str | None) -> None: home = get_authsome_config().home identity = RuntimeIdentity.create(home, handle) data = { "status": "created", "home": str(home), - "profile": identity.handle, + "agent": identity.handle, "did": identity.did, "registration_status": "local", "switched": True, @@ -33,18 +29,30 @@ async def profile_create(ctx_obj: ContextObj, handle: str | None) -> None: ctx_obj.print_json(data) -@profile.command(name="use") -@click.argument("handle") -@auth_command -async def profile_use(ctx_obj: ContextObj, handle: str) -> None: - """Select the active local profile.""" +async def _use_agent(ctx_obj: ContextObj, handle: str) -> None: home = get_authsome_config().home identity = RuntimeIdentity.from_filesystem(home, handle) ClientConfig.load(home).model_copy(update={"active_identity": identity.handle}).save(home) data = { "status": "active", - "profile": identity.handle, + "agent": identity.handle, "did": identity.did, } ctx_obj.print_json(data) + + +@agent.command(name="create") +@click.option("--handle", default=None, metavar="HANDLE", help="Create or reuse a specific local agent handle.") +@auth_command +async def agent_create(ctx_obj: ContextObj, handle: str | None) -> None: + """Create a local agent keypair.""" + await _create_agent(ctx_obj, handle) + + +@agent.command(name="use") +@click.argument("handle") +@auth_command +async def agent_use(ctx_obj: ContextObj, handle: str) -> None: + """Select the active local agent.""" + await _use_agent(ctx_obj, handle) diff --git a/src/authsome/cli/commands/core.py b/src/authsome/cli/commands/core.py index 668615d..4ec4e94 100644 --- a/src/authsome/cli/commands/core.py +++ b/src/authsome/cli/commands/core.py @@ -269,7 +269,7 @@ async def run(ctx_obj: ContextObj, command: tuple[str]) -> None: @click.command() @auth_command async def init(ctx_obj: ContextObj) -> None: - """Initialize local storage and register a fresh profile.""" + """Initialize local storage and register a fresh agent.""" home = get_authsome_config().home RuntimeIdentity.ensure_local(home) @@ -280,7 +280,7 @@ async def init(ctx_obj: ContextObj) -> None: data = { "status": "initialized", "home": str(home), - "profile": identity.handle, + "agent": identity.handle, "did": identity.did, "registration_status": "registered", "configured_encryption_mode": whoami_data.get("configured_encryption_mode"), @@ -319,10 +319,11 @@ async def whoami(ctx_obj: ContextObj) -> None: issues.append(f"connections: {exc}") vault_status = "ERROR" + agent = whoami_data.get("identity", whoami_data.get("active_identity")) data = { "authsome_version": whoami_data["version"], "home_directory": whoami_data["home"], - "profile": whoami_data.get("identity", whoami_data.get("active_identity")), + "agent": agent, "principal_id": whoami_data.get("principal_id"), "vault_id": whoami_data.get("vault_id"), "did": whoami_data.get("did"), diff --git a/src/authsome/server/account_auth.py b/src/authsome/server/account_auth.py index 73b1f75..ae5a01c 100644 --- a/src/authsome/server/account_auth.py +++ b/src/authsome/server/account_auth.py @@ -64,12 +64,31 @@ class AccountAuthService: principal = await self._principals.get_by_email(self._normalize_email(email)) if principal is None or not principal.password_hash: raise ValueError("Invalid email or password") - try: - self._hasher.verify(principal.password_hash, password) - except (VerificationError, VerifyMismatchError) as exc: - raise ValueError("Invalid email or password") from exc + self._verify_password(principal.password_hash, password, message="Invalid email or password") return self._sessions.create_browser_session(principal_id=principal.principal_id, email=principal.email) + async def change_password( + self, + *, + principal_id: str, + current_password: str, + new_password: str, + ) -> PrincipalRecord: + principal = await self._principals.get(principal_id) + if principal is None or not principal.password_hash: + raise ValueError("Invalid current password") + self._verify_password(principal.password_hash, current_password, message="Invalid current password") + self._validate_password(new_password) + return await self._principals.update_password(principal_id, password_hash=self._hasher.hash(new_password)) + + def _verify_password(self, password_hash: str, password: str, *, message: str) -> None: + try: + self._hasher.verify(password_hash, password) + except (VerificationError, VerifyMismatchError) as exc: + raise ValueError(message) from exc + except ValueError as exc: + raise ValueError(message) from exc + @staticmethod def _normalize_email(email: str) -> str: normalized = email.strip().lower() diff --git a/src/authsome/server/routes/ui.py b/src/authsome/server/routes/ui.py index f19db76..4730626 100644 --- a/src/authsome/server/routes/ui.py +++ b/src/authsome/server/routes/ui.py @@ -102,6 +102,11 @@ def _account_auth_next_url(value: Any) -> str: return next_url +def _append_query(url: str, values: dict[str, str]) -> str: + separator = "&" if "?" in url else "?" + return f"{url}{separator}{urlencode(values)}" + + @router.post("/auth/providers/{provider_name}/connect", include_in_schema=False) async def connect_provider( # noqa: PLR0913 provider_name: str, @@ -243,6 +248,35 @@ async def register_account( return response +@router.post("/auth/password", include_in_schema=False) +async def change_account_password(request: Request) -> Response: + await resolve_ui_request_identity(request) + principal_id = getattr(request.state, "ui_principal_id", None) + form = await request.form() + next_url = _account_auth_next_url(form.get("next") or "/settings?tab=security") + if not principal_id: + return RedirectResponse(url=_account_auth_entry_url(next_url), status_code=status.HTTP_303_SEE_OTHER) + + try: + await request.app.state.account_auth_service.change_password( + principal_id=principal_id, + current_password=str(form.get("current_password", "")), + new_password=str(form.get("new_password", "")), + ) + except ValueError as exc: + return RedirectResponse( + url=_append_query(next_url, {"password_error": str(exc)}), + status_code=status.HTTP_303_SEE_OTHER, + ) + + audit.emit_event("account.password_changed", principal_id=principal_id, status="success") + capture_event(getattr(request.state, "ui_email", ""), "account_password_changed", {"principal_id": principal_id}) + return RedirectResponse( + url=_append_query(next_url, {"password_changed": "1"}), + status_code=status.HTTP_303_SEE_OTHER, + ) + + @router.post("/auth/login", include_in_schema=False) async def login_account( request: Request, diff --git a/tests/cli/test_client_signing.py b/tests/cli/test_client_signing.py index c724fb7..67e17cd 100644 --- a/tests/cli/test_client_signing.py +++ b/tests/cli/test_client_signing.py @@ -244,7 +244,7 @@ async def test_unregistered_identity_registers_on_first_use(monkeypatch, tmp_pat @pytest.mark.asyncio -async def test_bootstrapped_identity_is_saved_as_active_profile(monkeypatch, tmp_path: Path) -> None: +async def test_bootstrapped_identity_is_saved_as_active_agent(monkeypatch, tmp_path: Path) -> None: monkeypatch.setenv("AUTHSOME_HOME", str(tmp_path)) captured: dict = {} @@ -322,7 +322,7 @@ async def test_env_identity_private_key_without_handle_errors(monkeypatch, tmp_p @pytest.mark.asyncio -async def test_env_identity_does_not_update_active_profile(monkeypatch, tmp_path: Path) -> None: +async def test_env_identity_does_not_update_active_agent(monkeypatch, tmp_path: Path) -> None: monkeypatch.setenv("AUTHSOME_HOME", str(tmp_path)) stored = RuntimeIdentity.create(tmp_path, "steady-wisely-boldly-0042") ClientConfig(active_identity=stored.handle).save(tmp_path) diff --git a/tests/cli/test_identity.py b/tests/cli/test_identity.py index cf9e84a..d1cfcb7 100644 --- a/tests/cli/test_identity.py +++ b/tests/cli/test_identity.py @@ -1,4 +1,4 @@ -"""Tests for `authsome profile` commands.""" +"""Tests for `authsome agent` commands.""" import json from pathlib import Path @@ -8,48 +8,47 @@ from authsome.cli.identity import RuntimeIdentity from authsome.cli.main import cli -class TestProfileCommands: - """Tests for local profile management commands.""" +class TestAgentCommands: + """Tests for local agent management commands.""" - def test_profile_create_writes_local_keypair(self, runner, mock_client, tmp_path: Path) -> None: + def test_root_help_shows_agent_not_legacy_profile(self, runner) -> None: + result = runner.invoke(cli, ["--log-file", "", "--help"]) + + assert result.exit_code == 0, result.output + assert "agent" in result.output + assert "profile" not in result.output + + def test_profile_command_is_removed(self, runner) -> None: + result = runner.invoke(cli, ["--log-file", "", "profile", "--help"]) + + assert result.exit_code != 0 + assert "No such command 'profile'" in result.output + + def test_agent_create_writes_local_keypair(self, runner, mock_client, tmp_path: Path) -> None: result = runner.invoke( cli, - ["--log-file", "", "profile", "create", "--handle", "steady-wisely-boldly-0042"], + ["--log-file", "", "agent", "create", "--handle", "steady-wisely-boldly-0042"], ) assert result.exit_code == 0, result.output data = json.loads(result.output) assert data["status"] == "created" - assert data["profile"] == "steady-wisely-boldly-0042" + assert data["agent"] == "steady-wisely-boldly-0042" assert data["switched"] is True stored = RuntimeIdentity.from_filesystem(tmp_path, "steady-wisely-boldly-0042") assert stored.did == data["did"] assert ClientConfig.load(tmp_path).active_identity == stored.handle - def test_profile_create_switches_active_profile(self, runner, mock_client, tmp_path: Path) -> None: - runner.invoke(cli, ["--log-file", "", "profile", "create", "--handle", "steady-wisely-boldly-0042"]) - result = runner.invoke( - cli, - ["--log-file", "", "profile", "create", "--handle", "rapid-brightly-firmly-0007"], - ) - - data = json.loads(result.output) - assert result.exit_code == 0, result.output - assert data["status"] == "created" - assert data["profile"] == "rapid-brightly-firmly-0007" - assert data["switched"] is True - assert ClientConfig.load(tmp_path).active_identity == "rapid-brightly-firmly-0007" - - def test_profile_use_sets_active_identity(self, runner, mock_client, tmp_path: Path) -> None: - runner.invoke(cli, ["--log-file", "", "profile", "create", "--handle", "steady-wisely-boldly-0042"]) - runner.invoke(cli, ["--log-file", "", "profile", "create", "--handle", "rapid-brightly-firmly-0007"]) + def test_agent_use_sets_active_agent(self, runner, mock_client, tmp_path: Path) -> None: + runner.invoke(cli, ["--log-file", "", "agent", "create", "--handle", "steady-wisely-boldly-0042"]) + runner.invoke(cli, ["--log-file", "", "agent", "create", "--handle", "rapid-brightly-firmly-0007"]) stored = RuntimeIdentity.from_filesystem(tmp_path, "steady-wisely-boldly-0042") - result = runner.invoke(cli, ["--log-file", "", "profile", "use", "steady-wisely-boldly-0042"]) + result = runner.invoke(cli, ["--log-file", "", "agent", "use", "steady-wisely-boldly-0042"]) assert result.exit_code == 0, result.output data = json.loads(result.output) assert data["status"] == "active" - assert data["profile"] == stored.handle + assert data["agent"] == stored.handle assert data["did"] == stored.did assert ClientConfig.load(tmp_path).active_identity == stored.handle diff --git a/tests/cli/test_init.py b/tests/cli/test_init.py index 0a01a73..10cd6a0 100644 --- a/tests/cli/test_init.py +++ b/tests/cli/test_init.py @@ -26,7 +26,8 @@ def test_init_removes_legacy_default_state_and_registers_identity( assert result.exit_code == 0, result.output data = json.loads(result.output) - assert data["profile"] != "default" + assert data["agent"] != "default" + assert "profile" not in data assert data["registration_status"] == "registered" assert data["configured_encryption_mode"] == "auto" assert data["effective_encryption_source"] == "local_key" @@ -38,10 +39,10 @@ def test_init_removes_legacy_default_state_and_registers_identity( config_data = ClientConfig.load(tmp_path) assert config_data.version == __version__ - assert config_data.active_identity == data["profile"] + assert config_data.active_identity == data["agent"] -def test_init_skips_registration_for_registered_active_profile( +def test_init_skips_registration_for_registered_active_agent( runner, mock_client, tmp_path: Path, @@ -53,6 +54,7 @@ def test_init_skips_registration_for_registered_active_profile( assert result.exit_code == 0, result.output data = json.loads(result.output) - assert data["profile"] == identity.handle + assert data["agent"] == identity.handle + assert "profile" not in data assert data["configured_encryption_mode"] == "auto" mock_client.ensure_identity_ready.assert_called_once() diff --git a/tests/cli/test_whoami.py b/tests/cli/test_whoami.py index c6566fe..f0674d3 100644 --- a/tests/cli/test_whoami.py +++ b/tests/cli/test_whoami.py @@ -40,7 +40,8 @@ class TestWhoamiCommand: assert result.exit_code == 0, result.output data = json.loads(result.output) assert data["authsome_version"] == "1.2.3" - assert data["profile"] == "steady-wisely-boldly-0042" + assert data["agent"] == "steady-wisely-boldly-0042" + assert "profile" not in data assert data["principal_id"] == "principal_1" assert data["vault_id"] == "vault_default" assert data["vault_status"] == "OK" @@ -90,7 +91,8 @@ class TestWhoamiCommand: assert result.exit_code == 0 data = json.loads(result.output) - assert data["profile"] == "steady-wisely-boldly-0042" + assert data["agent"] == "steady-wisely-boldly-0042" + assert "profile" not in data assert data["vault_status"] == "ERROR" assert data["connected_providers_count"] == 0 assert any("connections:" in issue for issue in data["issues"]) diff --git a/tests/server/test_account_auth.py b/tests/server/test_account_auth.py index 44fb59b..68ba6e6 100644 --- a/tests/server/test_account_auth.py +++ b/tests/server/test_account_auth.py @@ -98,3 +98,32 @@ async def test_login_rejects_wrong_password(tmp_path: Path) -> None: await service.login(email="dev@example.com", password="wrong-password") finally: await _close(store) + + +@pytest.mark.asyncio +async def test_change_password_requires_current_password_and_updates_login(tmp_path: Path) -> None: + service, store = await _service(tmp_path) + + try: + principal = await service.register(email="dev@example.com", password="password-1") + + with pytest.raises(ValueError, match="Invalid current password"): + await service.change_password( + principal_id=principal.principal_id, + current_password="wrong-password", + new_password="password-2", + ) + + await service.change_password( + principal_id=principal.principal_id, + current_password="password-1", + new_password="password-2", + ) + + with pytest.raises(ValueError, match="Invalid email or password"): + await service.login(email="dev@example.com", password="password-1") + + session = await service.login(email="dev@example.com", password="password-2") + assert session.principal_id == principal.principal_id + finally: + await _close(store) diff --git a/tests/server/test_ui_account.py b/tests/server/test_ui_account.py new file mode 100644 index 0000000..22c1f33 --- /dev/null +++ b/tests/server/test_ui_account.py @@ -0,0 +1,40 @@ +from fastapi import status + +from tests.server.helpers import create_server_test_client + + +def test_browser_session_can_change_account_password(monkeypatch, tmp_path) -> None: + monkeypatch.setenv("AUTHSOME_HOME", str(tmp_path)) + + with create_server_test_client() as client: + registered = client.post( + "/api/auth/register", + data={"email": "dev@example.com", "password": "password-1", "next": "/settings?tab=security"}, + follow_redirects=False, + ) + response = client.post( + "/api/auth/password", + data={ + "current_password": "password-1", + "new_password": "password-2", + "next": "/settings?tab=security", + }, + follow_redirects=False, + ) + client.post("/api/logout", follow_redirects=False) + old_login = client.post( + "/api/auth/login", + data={"email": "dev@example.com", "password": "password-1", "next": "/"}, + follow_redirects=False, + ) + new_login = client.post( + "/api/auth/login", + data={"email": "dev@example.com", "password": "password-2", "next": "/"}, + follow_redirects=False, + ) + + assert registered.status_code == status.HTTP_303_SEE_OTHER + assert response.status_code == status.HTTP_303_SEE_OTHER + assert response.headers["location"] == "/settings?tab=security&password_changed=1" + assert old_login.headers["location"] == "/login?next=%2F&error=Invalid+email+or+password&tab=login" + assert new_login.status_code == status.HTTP_303_SEE_OTHER diff --git a/ui/src/app/layout.tsx b/ui/src/app/layout.tsx index 4167159..4582cea 100644 --- a/ui/src/app/layout.tsx +++ b/ui/src/app/layout.tsx @@ -17,7 +17,7 @@ const jetbrainsMono = JetBrains_Mono({ export const metadata: Metadata = { title: "Authsome Dashboard", - description: "Local dashboard for Authsome identities, providers, and connections.", + description: "Local dashboard for Authsome agents, providers, and connections.", }; export default function RootLayout({ diff --git a/ui/src/components/dashboard/auth-flows.tsx b/ui/src/components/dashboard/auth-flows.tsx index ff99f11..7e67282 100644 --- a/ui/src/components/dashboard/auth-flows.tsx +++ b/ui/src/components/dashboard/auth-flows.tsx @@ -134,7 +134,7 @@ export function AuthsomeClaim({ token }: { token: string }) { if (!data) { return ( ); @@ -156,7 +156,7 @@ export function AuthsomeClaim({ token }: { token: string }) { return (