diff --git a/.gitleaksignore b/.gitleaksignore index 1f305a50..f0f6a276 100644 --- a/.gitleaksignore +++ b/.gitleaksignore @@ -1,2 +1,5 @@ # PostHog public project API key — intentionally committed, not a secret src/authsome/server/analytics.py:generic-api-key:50 + +# Logo.dev public project token — intentionally committed for provider logos +ui/src/components/dashboard/dashboard-primitives.tsx:generic-api-key:12 diff --git a/docs/site/reference/audit-log.mdx b/docs/site/reference/audit-log.mdx index 68ae8d43..5ceebc79 100644 --- a/docs/site/reference/audit-log.mdx +++ b/docs/site/reference/audit-log.mdx @@ -49,6 +49,21 @@ 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. + +Supported query parameters: + +| Parameter | Description | +| --- | --- | +| `limit` | Number of events to return, clamped to the server maximum. | +| `cursor` | Cursor returned by the previous page. | + +Results are sorted newest-first and include `next_cursor` when another page is available. + ## Privacy and Secrets **What the log contains:** diff --git a/docs/superpowers/specs/2026-06-10-stateless-production-deployments-design.md b/docs/superpowers/specs/2026-06-10-stateless-production-deployments-design.md deleted file mode 100644 index 9f1b6d5e..00000000 --- a/docs/superpowers/specs/2026-06-10-stateless-production-deployments-design.md +++ /dev/null @@ -1,310 +0,0 @@ -# Stateless Production Deployments Design - -## Summary - -Prepare Authsome for stateless, horizontally scalable production deployments while preserving the local developer defaults. The server will select production infrastructure from environment variables: Postgres for the relational server Store when `AUTHSOME_DATABASE_URL` uses a Postgres scheme, and Redis for shared mutable server state plus encrypted vault KV when `AUTHSOME_REDIS_URL` is present. - -The design keeps the existing module ownership model intact. `identity`, `auth`, and `vault` remain reusable libraries with infrastructure-agnostic contracts and domain behavior. `server` remains the composition root that chooses concrete infrastructure and combines the libraries into Authsome business logic. - -## Goals - -- Make container and multi-replica deployments viable without relying on local process memory or ephemeral disk for hot-path mutable state. -- Keep SQLite, disk vault storage, and in-memory transient state working for local development and tests. -- Reuse `py-key-value-aio` Redis support for vault storage instead of creating a custom Redis vault backend. -- Keep Postgres and Redis optional for library installs, while installing production extras in the Docker image. -- Provide self-hosting documentation with Postgres, Redis, Docker, and secret-management guidance. - -## Non-Goals - -- Do not introduce an ORM or Alembic for this refactor. Use a lightweight schema-version migration runner inside the existing Store adapter. -- Do not move business logic into CLI or proxy. They continue to communicate with the server. -- Do not add stateful browser sessions in this refactor. Browser sessions remain signed stateless JWT cookies. -- Do not add email verification or signup abuse prevention in this refactor. Those are tracked separately in GitHub issue #411. -- Do not introduce `AUTHSOME_VAULT_BACKEND`. Redis vault selection follows `AUTHSOME_REDIS_URL`. - -## Current State - -The current code already has a relational server Store split from vault storage: - -- `src/authsome/server/store/database.py` supports SQLite and Postgres URL resolution, but Postgres uses a single `asyncpg` connection. -- `src/authsome/server/store/repositories.py` contains the five server-owned registries plus server config, custom provider definitions, and audit events. -- `src/authsome/server/dependencies.py` always creates the vault with `DiskStore`. -- `src/authsome/auth/sessions.py` stores auth flow sessions in process memory. -- `src/authsome/server/ui_sessions.py` keeps browser sessions stateless but stores pending identity-claim tokens in process memory. -- `src/authsome/identity/proof.py` validates PoP JWTs and currently owns an in-memory replay cache. -- `src/authsome/server/app.py` wires these components directly into `app.state`. - -These defaults work for local development but do not work across multiple replicas. Auth flow sessions, pending claim tokens, and PoP replay JTIs need shared state. Vault encrypted blobs need a backend that survives container restarts without requiring a mounted local volume in production. - -## Architecture - -Authsome keeps the existing boundaries: - -- `identity` owns identity and PoP token semantics. It creates PoP JWTs, verifies signatures, verifies request binding, and extracts proof claims. It remains infrastructure agnostic. -- `auth` owns flow/session models and abstract session-store behavior. Concrete Redis storage does not leak into auth flow code. -- `vault` owns encrypted KV semantics over an `AsyncKeyValue`. It does not define a Redis-specific vault API. -- `server` owns deployment topology. It selects SQLite or Postgres, DiskStore or RedisStore, memory or Redis state stores, and wires the selected implementations into services and routes. -- `cli` and `proxy` remain clients of the server business logic. -- `ui` and the relational Store remain server properties. - -Backend selection is simple: - -- `AUTHSOME_DATABASE_URL=postgresql://...` or `postgres://...` selects Postgres for the relational server Store. -- No Postgres URL selects SQLite. -- `AUTHSOME_REDIS_URL` selects Redis for auth flow sessions, pending claim tokens, PoP JTI replay cache, and raw vault KV. -- No Redis URL selects in-memory transient stores and disk vault KV. - -If an explicit Postgres or Redis backend is configured and the driver is missing or the service is unreachable, startup fails. There is no runtime fallback from Redis/Postgres to memory/disk after startup. - -Browser UI sessions stay stateless signed cookies for now. The disadvantages are known: server-side logout/revocation and active session visibility are not available. Verified signup and stateful browser-session management are deferred to issue #411. - -## Components - -### Server Configuration - -`src/authsome/server/config.py` will add: - -- `redis_url: str | None` -- Postgres pool settings, such as min and max pool size. -- TTL settings used by Redis-backed auth sessions, pending claim tokens, and replay cache where existing constants are currently hard-coded. - -Configuration remains environment-driven through the existing `AUTHSOME_` prefix. - -### Relational Store - -`src/authsome/server/store/database.py` keeps the current lightweight adapter but upgrades production behavior: - -- SQLite continues to use one `aiosqlite` connection. -- Postgres uses an `asyncpg` pool. -- Queries still use `?` placeholders at repository call sites, translated to Postgres positional parameters inside the adapter. -- Startup runs a lightweight schema-version migration runner. - -The migration runner should: - -- Maintain `store_schema_version`. -- Apply ordered migration functions or statements. -- Support SQLite and Postgres dialect fragments inside the Store module. -- Keep existing `CREATE TABLE IF NOT EXISTS` bootstrap behavior only as migration contents, not as ad hoc schema setup scattered through startup. - -The existing registries remain repository classes. They should not learn about pools, Postgres clients, or migration internals. - -### Replay Cache - -The anti-replay cache prevents reuse of a PoP JWT within its validity window. Each PoP JWT has a `jti`. After signature, method, URL, body hash, and expiry validation, the server checks whether that `jti` has already been used. If it has, the request is rejected. - -The split should be: - -- `identity.proof` owns proof semantics and accepts an injected infrastructure-agnostic replay checker. -- A tiny protocol defines the operation shape: `check_and_store(jti: str, exp: int) -> None`. -- Server-side implementations provide storage: - - Memory implementation for local dev and tests. - - Redis implementation for production. - -The Redis implementation should use an atomic set-if-not-exists operation with a TTL derived from `exp - now`. This lets replica B reject a JWT already accepted by replica A. - -No Redis import belongs in `identity`. - -### Auth Flow Sessions - -`AuthSession` remains the domain model in `src/authsome/auth/sessions.py`. - -The current in-memory `AuthSessionStore` behavior should be preserved behind a small store interface that covers the existing route needs: - -- `create(...)` -- `get(session_id)` -- `save(session)` -- `delete(session_id)` -- `index_oauth_state(session)` -- `get_by_oauth_state(state)` - -A Redis implementation can live server-side if it imports Redis-specific code. It should serialize `AuthSession` with Pydantic JSON, store each session under a namespaced key, and store OAuth state-to-session mappings under separate keys with matching TTLs. - -Local memory behavior remains available when `AUTHSOME_REDIS_URL` is absent. - -### Pending Claim Tokens - -Browser sessions remain stateless in `UiSessionStore`, but pending claim tokens need shared mutable state so claim links survive replica changes. - -The browser session methods stay simple: - -- `create_browser_session(...)` -- `get_browser_session(cookie_value)` -- `build_cookie_value(token)` -- `delete_browser_session(cookie_value)` - -Pending claim methods move behind a memory/Redis store: - -- `create_pending_claim(identity, ttl_seconds)` -- `get_pending_claim(token)` -- `consume_pending_claim(token)` - -`consume_pending_claim` should delete and return the token. The Redis version should be atomic where the Redis client makes that practical. - -### Vault KV Backend - -The vault continues to use `Vault -> AesGcmEncryptionWrapper -> AsyncKeyValue`. - -`src/authsome/server/dependencies.py` chooses the raw `AsyncKeyValue`: - -- No `AUTHSOME_REDIS_URL`: `DiskStore(directory=server_config.kv_store_dir)` -- `AUTHSOME_REDIS_URL`: `key_value.aio.stores.redis.RedisStore(url=server_config.redis_url)` - -The existing `DekManager` continues to load or create the wrapped DEK record through the raw KV backend. Redis stores only encrypted vault values and DEK wrapping metadata. The vault master key is never stored in Redis. - -### Secrets - -Master-key resolution keeps the current behavior in `src/authsome/server/secrets.py`: - -1. `AUTHSOME_MASTER_KEY` -2. `AUTHSOME_MASTER_KEY_FILE` or the default server key file -3. OS keyring -4. Generate a new base64 key, store it in keyring if possible, otherwise write the default key file - -There is no special production-mode enforcement tied to Redis or Postgres. The self-hosting guide should recommend `AUTHSOME_MASTER_KEY` or `AUTHSOME_MASTER_KEY_FILE` for containers and explain that generated file keys only survive when the filesystem is persistent. - -### App Lifecycle - -`src/authsome/server/app.py` remains the composition root: - -1. Load `ServerConfig`. -2. Open and migrate the relational Store. -3. If Redis is configured, create or validate Redis-backed state dependencies. -4. Create raw vault KV, load/create DEK, wrap with encryption, and construct `Vault`. -5. Create auth sessions, UI sessions/pending claim store, replay cache, provider repository, account auth service, bootstrap service, and ownership resolver. -6. Close Store pools and Redis-owned clients on shutdown. - -The existing `ownership_cache = {}` can remain a local optimization only if it is not correctness-critical. If it can become stale across replicas for claim/binding changes, it should be removed or given a conservative TTL. Correctness must come from the registries, not the process cache. - -## Data Flow - -### Startup - -Local startup without production URLs uses SQLite, DiskStore, and memory state. Postgres is selected only by a Postgres `AUTHSOME_DATABASE_URL`; Redis is selected only by `AUTHSOME_REDIS_URL`. - -If Redis is configured, startup should ping Redis before serving requests. If Postgres is configured, startup should acquire a connection from the pool and run migrations before serving requests. - -### PoP Requests - -1. The request arrives with `Authorization: PoP `. -2. `identity.proof.validate_proof_jwt()` validates the signature and request binding. -3. The injected replay checker stores the `jti` until expiry or raises if already seen. -4. The server resolves the identity registration and ownership through the relational Store. -5. The route receives the existing `ResolvedOwnership` and builds `CredentialService`. - -### Auth Flow Sessions - -1. A login flow creates an `AuthSession`. -2. The selected session store persists it with a TTL. -3. OAuth flows index `internal_state` to the session id. -4. Callback routes resolve the session by OAuth state or session id, update the session, and save it. -5. Expired or missing sessions behave as not found. - -### Pending Claim Links - -1. Identity bootstrap creates a pending claim token. -2. The selected pending claim store persists it with a TTL. -3. The claim route consumes the token. -4. Consumed or expired tokens behave as not found. - -### Vault Access - -1. `CredentialRepository` reads or writes credentials through `Vault`. -2. `Vault` updates its index records and plaintext domain values. -3. `AesGcmEncryptionWrapper` encrypts the values. -4. DiskStore or RedisStore stores encrypted blobs using the existing collection/key naming scheme, including `vault::...` collections. - -## Error Handling - -Startup failures: - -- Invalid database URL scheme fails clearly. -- Postgres driver missing, connection failure, bad credentials, or migration failure fails startup. -- Redis driver missing, connection failure, bad credentials, or ping failure fails startup. -- Vault DEK unwrap failure fails startup. - -Runtime behavior: - -- Redis outages during affected operations return 5xx responses. The server does not silently fall back to memory or disk. -- PoP replay detection returns the existing unauthorized proof-validation response. -- Expired sessions and pending claim tokens behave as not found. -- Health remains cheap and public. Keep `/api/health` and add a root `/health` alias for container health checks. -- Readiness checks the relational Store and vault. If Redis is configured, readiness also checks Redis connectivity. - -## Docker And Self-Hosting - -The Docker image should install production extras by default while the base Python package keeps them optional where possible. - -The Dockerfile should: - -- Keep a multi-stage build for UI and Python package. -- Use the `uv` toolchain for Python build/install. -- Run as a non-root user. -- Expose port 7998. -- Add a root `/health` alias backed by the same response as `/api/health`. -- Include a healthcheck against `/health`. - -`docker-compose.yml` should include: - -- `authsome` -- `postgres` -- `redis` - -The self-hosting guide should cover: - -- Prerequisites: Docker, Postgres, Redis. -- Environment variables: `AUTHSOME_DATABASE_URL`, `AUTHSOME_REDIS_URL`, `AUTHSOME_MASTER_KEY`, `AUTHSOME_MASTER_KEY_FILE`, `AUTHSOME_HOME`, `AUTHSOME_BASE_URL`, `AUTHSOME_HOST`, `AUTHSOME_PORT`, and analytics settings. -- Startup steps: pull or build image, set env vars, start service, run `authsome init`, verify `/health`. -- Compose example for local production simulation. -- Secret guidance: do not commit production `AUTHSOME_MASTER_KEY`; prefer a cloud secret manager, Doppler, Vault, or platform secrets. -- Migration guidance: relational schema migrations run at startup; back up Postgres and Redis according to operator policy. - -## Testing - -Default `uv run pytest` should continue to pass without external services. - -Tests to add or adjust: - -- SQLite migration tests. -- Postgres migration tests gated behind an optional service fixture or environment variable. -- Postgres pool adapter tests gated behind the same integration mechanism. -- Memory replay cache tests after moving it out of `identity`. -- Redis replay cache tests for duplicate rejection and TTL behavior. -- Auth session store contract tests run against memory and Redis implementations. -- Pending claim store contract tests run against memory and Redis implementations. -- Vault backend tests showing RedisStore is selected when `AUTHSOME_REDIS_URL` is present and values remain encrypted. -- Server lifecycle tests for local defaults and Redis/Postgres selection failures. -- Existing session recreation tests should split local and Redis behavior: memory sessions do not survive app recreation; Redis sessions do. -- Docker smoke test for image build and `/health`. - -Verification before completion should include: - -- `uv run pytest` -- `uv run ruff check` -- `uv run ty check` -- Docker build smoke test when Docker is available -- Redis/Postgres integration tests when services are available - -## Rollout Plan - -Implement in small phases inside one production-readiness branch: - -1. Add config fields and optional dependency extras. -2. Upgrade the relational Store to Postgres pooling and lightweight migrations. -3. Split replay-cache semantics cleanly from `identity` and add memory/Redis implementations. -4. Introduce auth session store contracts and Redis-backed auth sessions. -5. Split pending claim storage from stateless browser session signing and add Redis pending claims. -6. Reuse `py-key-value-aio[redis]` for vault raw KV when `AUTHSOME_REDIS_URL` is configured. -7. Update app lifecycle, readiness, Dockerfile, compose, and self-hosting docs. -8. Add gated integration tests and smoke verification. - -Each phase should preserve local defaults and keep implementation changes close to the modules that own the behavior. - -## Open Follow-Up - -GitHub issue #411 tracks hosted login hardening outside this refactor: - -- Email verification during signup. -- Signup abuse prevention. -- Stateful browser sessions. -- Server-side browser-session logout and revocation. -- Session visibility and account-security policies. diff --git a/src/authsome/server/app.py b/src/authsome/server/app.py index 11c882f6..58db3d09 100644 --- a/src/authsome/server/app.py +++ b/src/authsome/server/app.py @@ -40,7 +40,7 @@ async def _cleanup_startup_resources(store, audit_log, runtime_state) -> None: with suppress(Exception): if audit_log is not None: - audit_log.shutdown() + await audit_log.async_shutdown() with suppress(Exception): if store is not None: await store.close() @@ -99,7 +99,7 @@ async def lifespan(app: FastAPI): try: shutdown_posthog() if audit_log is not None: - audit_log.shutdown() + await audit_log.async_shutdown() if store is not None: await store.close() finally: diff --git a/src/authsome/server/routes/audit.py b/src/authsome/server/routes/audit.py index 3b534d6e..19d770e8 100644 --- a/src/authsome/server/routes/audit.py +++ b/src/authsome/server/routes/audit.py @@ -1,8 +1,8 @@ """Audit event routes.""" -from typing import Any +from typing import Any, Literal -from fastapi import APIRouter, Depends, Request +from fastapi import APIRouter, Depends, HTTPException, Request, status from authsome import audit from authsome.identity.principal import PrincipalRole @@ -19,10 +19,20 @@ async def list_audit_events( request: Request, limit: int = 50, + cursor: str | 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 = None if auth.principal_role == PrincipalRole.ADMIN else auth.principal_id + scope: Literal["global", "principal"] = "global" if effective_principal_id is None else "principal" + try: + page = await request.app.state.audit_log.query_events( + limit=limit, + principal_id=effective_principal_id, + 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") diff --git a/src/authsome/server/store/repositories.py b/src/authsome/server/store/repositories.py index d1a029ae..e36b08a6 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,44 @@ async def insert_many(self, events: list[AuditEventInsert]) -> None: ], ) - async def list_recent(self, *, limit: int = 50, principal_id: str | None = None) -> list[dict[str, Any]]: + async def query_events( + self, + *, + limit: int = 50, + principal_id: str | 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 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.""" @@ -191,6 +252,10 @@ def shutdown(self) -> None: self._closed = True self.force_flush() + def close(self) -> None: + self._closed = True + self._drop_finished_futures() + def _is_loop_thread(self) -> bool: try: return asyncio.get_running_loop() is self._loop @@ -257,9 +322,28 @@ def force_flush(self) -> None: self._exporter.force_flush() async def async_force_flush(self) -> None: - self._provider.force_flush() + await asyncio.to_thread(self._provider.force_flush) await self._exporter.async_force_flush() + async def async_shutdown(self) -> None: + await self.async_force_flush() + _delegating_audit_exporter.set_active(None) + self._exporter.close() + + async def query_events( + self, + *, + limit: int = 50, + principal_id: str | None = None, + cursor: str | None = None, + ) -> AuditEventPage: + await self.async_force_flush() + return await self._registry.query_events( + limit=limit, + principal_id=principal_id, + 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) diff --git a/tests/auth/test_service.py b/tests/auth/test_service.py index dbe6d88c..f44f8eb8 100644 --- a/tests/auth/test_service.py +++ b/tests/auth/test_service.py @@ -147,7 +147,7 @@ async def audit_log(self, tmp_path) -> ServerAuditLog: # noqa: ANN001 try: yield log finally: - log.shutdown() + await log.async_shutdown() await store.close() @pytest.fixture diff --git a/tests/conftest.py b/tests/conftest.py index 17fa6d32..65a72ccb 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -12,6 +12,9 @@ os.environ["AUTHSOME_BASE_URL"] = TEST_AUTHSOME_BASE_URL os.environ["AUTHSOME_ENV"] = "test" os.environ["AUTHSOME_DO_NOT_TRACK"] = "true" +os.environ.pop("AUTHSOME_DATABASE_URL", None) +os.environ.pop("DATABASE_URL", None) +os.environ.pop("AUTHSOME_REDIS_URL", None) os.environ.pop("AUTHSOME_POSTHOG_API_KEY", None) os.environ.pop("POSTHOG_API_KEY", None) @@ -26,6 +29,9 @@ def _disable_analytics(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setenv("AUTHSOME_BASE_URL", TEST_AUTHSOME_BASE_URL) monkeypatch.setenv("AUTHSOME_ENV", "test") monkeypatch.setenv("AUTHSOME_DO_NOT_TRACK", "true") + monkeypatch.delenv("AUTHSOME_DATABASE_URL", raising=False) + monkeypatch.delenv("DATABASE_URL", raising=False) + monkeypatch.delenv("AUTHSOME_REDIS_URL", raising=False) monkeypatch.delenv("AUTHSOME_POSTHOG_API_KEY", raising=False) monkeypatch.delenv("POSTHOG_API_KEY", raising=False) analytics.shutdown_posthog() diff --git a/tests/server/test_audit_events.py b/tests/server/test_audit_events.py index a047c933..595d0f8c 100644 --- a/tests/server/test_audit_events.py +++ b/tests/server/test_audit_events.py @@ -1,12 +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 emit_event +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 @@ -27,6 +31,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(2099, 1, 1, 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)) @@ -53,6 +82,17 @@ def test_audit_events_endpoint_returns_internal_events_for_admin(monkeypatch, tm assert entries[0]["provider"] == "github" +def test_audit_events_endpoint_only_documents_pagination_params(monkeypatch, tmp_path: Path) -> None: + monkeypatch.setenv("AUTHSOME_HOME", str(tmp_path)) + + with create_server_test_client() as client: + response = client.get("/openapi.json") + + assert response.status_code == status.HTTP_200_OK + params = response.json()["paths"]["/api/audit/events"]["get"]["parameters"] + assert {param["name"] for param in params} == {"limit", "cursor"} + + def test_external_audit_post_is_enriched_from_pop_identity(monkeypatch, tmp_path: Path) -> None: monkeypatch.setenv("AUTHSOME_HOME", str(tmp_path)) payload = {"event": {"event": "proxy_deny", "metadata": {"host": "api.example.com", "reason": "no_match"}}} @@ -125,3 +165,207 @@ 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_query_params_do_not_filter_or_widen_scope(monkeypatch, tmp_path: Path) -> None: + monkeypatch.setenv("AUTHSOME_HOME", str(tmp_path)) + + with create_server_test_client() as client: + _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" + manual_entries = [entry for entry in body["entries"] if entry["event_id"].startswith("audit_00")] + assert [entry["event_id"] for entry in manual_entries] == ["audit_003", "audit_002"] + assert all(entry["principal_id"] == user_whoami["principal_id"] for entry in body["entries"]) + + +def test_non_admin_audit_query_cannot_widen_scope_with_principal_or_identity( + 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" + event_ids = {entry["event_id"] for entry in body["entries"]} + assert "audit_011" in event_ids + assert "audit_010" not in event_ids + assert all(entry["principal_id"] == user_whoami["principal_id"] for entry in body["entries"]) + + +def test_admin_audit_events_support_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(2099, 1, 1, 8, 0, tzinfo=UTC), + ) + _emit_audit_event( + "audit_099", + "connection.logout", + principal_id=admin_whoami["principal_id"], + identity="admin-ready-boldly-0001", + provider="linear", + timestamp=datetime(2099, 1, 1, 7, 59, tzinfo=UTC), + ) + _emit_audit_event( + "audit_101", + "connection.login", + principal_id=user_whoami["principal_id"], + identity="steady-wisely-boldly-0042", + provider="github", + timestamp=datetime(2099, 1, 1, 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(2099, 1, 1, 8, 2, tzinfo=UTC), + ) + + first_path = "/api/audit/events?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?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", "audit_099"] + + +@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_health_routes.py b/tests/server/test_health_routes.py index 9ad0042d..a2ef5d08 100644 --- a/tests/server/test_health_routes.py +++ b/tests/server/test_health_routes.py @@ -21,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 = [route for route in client.app.router.routes if getattr(route, "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 diff --git a/tests/server/test_runtime_backend_selection.py b/tests/server/test_runtime_backend_selection.py index 8003ed04..df816d86 100644 --- a/tests/server/test_runtime_backend_selection.py +++ b/tests/server/test_runtime_backend_selection.py @@ -50,6 +50,9 @@ def __init__(self) -> None: def shutdown(self) -> None: self.shutdown_called = True + async def async_shutdown(self) -> None: + self.shutdown() + class FakeStore: def __init__(self, home: Path, audit_log: FakeAuditLog) -> None: diff --git a/ui/src/app/(authenticated)/audit/page.tsx b/ui/src/app/(authenticated)/audit/page.tsx index 06774492..91514324 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 5aa1f27e..b0609b8e 100644 --- a/ui/src/components/authsome-dashboard.tsx +++ b/ui/src/components/authsome-dashboard.tsx @@ -81,7 +81,7 @@ function ActiveView({ } if (view === "agents") return ; if (view === "principals") return ; - if (view === "audit" && data.account.isAdmin) return ; + if (view === "audit") return ; if (view === "settings") return ; return ; } diff --git a/ui/src/components/dashboard/dashboard-shell.tsx b/ui/src/components/dashboard/dashboard-shell.tsx index ef3f207e..d93d5710 100644 --- a/ui/src/components/dashboard/dashboard-shell.tsx +++ b/ui/src/components/dashboard/dashboard-shell.tsx @@ -56,7 +56,7 @@ export const NAV_ITEMS: NavItem[] = [ { id: "connections", href: "/connections", label: "Connections", icon: }, { id: "agents", href: "/agents", label: "Agents", icon: }, { id: "principals", href: "/principal", label: "Principals", icon: , adminOnly: true }, - { 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: }, ]; diff --git a/ui/src/components/dashboard/overview-views.tsx b/ui/src/components/dashboard/overview-views.tsx index 57f546d2..6c1958cd 100644 --- a/ui/src/components/dashboard/overview-views.tsx +++ b/ui/src/components/dashboard/overview-views.tsx @@ -2,16 +2,17 @@ import { UserRound } from "lucide-react"; import Link from "next/link"; +import { useRef, useState } from "react"; import useSWR from "swr"; import { PageEmptyState, PageErrorState, PageLoadingState } from "@/components/dashboard/page-state"; import { ProviderSummary } from "@/components/dashboard/provider-views"; import { SectionHeader } from "@/components/dashboard/section-header"; import { Badge } from "@/components/ui/badge"; -import { buttonVariants } from "@/components/ui/button"; +import { Button, buttonVariants } from "@/components/ui/button"; import { Card, CardContent } from "@/components/ui/card"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; -import { DashboardData, PrincipalRow, fetchPrincipals } from "@/lib/authsome-api"; +import { DashboardData, PrincipalRow, fetchAuditEvents, fetchPrincipals } from "@/lib/authsome-api"; export function DashboardView({ data }: { data: DashboardData }) { const recentEvents = data.audit.events.slice(0, 5); @@ -182,12 +183,57 @@ export function PrincipalsView() { } export function AuditView({ data }: { data: DashboardData }) { + const [auditResult, setAuditResult] = useState<{ + events: DashboardData["audit"]["events"]; + nextCursor: string | null; + } | null>(null); + const [errorMessage, setErrorMessage] = useState(""); + const [loadingMore, setLoadingMore] = useState(false); + const requestSequence = useRef(0); + const events = auditResult?.events ?? data.audit.events; + const nextCursor = auditResult?.nextCursor ?? data.audit.nextCursor; + const description = data.account.isAdmin + ? "Recent administrative and credential events." + : "Recent account, identity, vault, and credential events for this principal."; + + function nextRequestId(): number { + requestSequence.current += 1; + return requestSequence.current; + } + + function isLatestRequest(requestId: number): boolean { + return requestSequence.current === requestId; + } + + async function loadMore() { + if (!nextCursor) return; + const requestId = nextRequestId(); + setLoadingMore(true); + setErrorMessage(""); + try { + const result = await fetchAuditEvents({ cursor: nextCursor, limit: 50 }); + if (!isLatestRequest(requestId)) return; + setAuditResult({ + 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)) { + setLoadingMore(false); + } + } + } + return (
- + + {errorMessage ?

{errorMessage}

: null} - {data.audit.events.length ? ( + {events.length ? ( @@ -199,7 +245,7 @@ export function AuditView({ data }: { data: DashboardData }) { - {data.audit.events.map((event) => ( + {events.map((event) => ( {event.time} {event.event} @@ -225,6 +271,13 @@ export function AuditView({ data }: { data: DashboardData }) { ) : } + {nextCursor ? ( +
+ +
+ ) : null} ); } diff --git a/ui/src/lib/authsome-api.ts b/ui/src/lib/authsome-api.ts index 6684256d..2a9176c4 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,18 @@ export type AuditRow = { metadata: Record; }; +export type AuditEventsQuery = { + 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 +86,8 @@ export type DashboardData = { }; audit: { canView: boolean; + scope: "global" | "principal"; + nextCursor: string | null; total: number; events: AuditRow[]; }; @@ -218,6 +233,8 @@ type ConnectionsResponse = { type AuditResponse = { entries: Array>; + next_cursor?: string | null; + scope?: "global" | "principal"; }; export type PrincipalRow = { @@ -484,6 +501,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"), @@ -494,6 +512,24 @@ function buildAuditRows(entries: AuditResponse["entries"]): AuditRow[] { }); } +function auditQueryString(query: AuditEventsQuery = {}): string { + const params = new URLSearchParams(); + params.set("limit", String(query.limit ?? 50)); + if (query.cursor) params.set("cursor", query.cursor); + return params.toString(); +} + +export async function fetchAuditEvents(query: AuditEventsQuery = {}): Promise { + const data = await requestJson(`/api/audit/events?${auditQueryString(query)}`); + const events = buildAuditRows(data.entries); + return { + scope: data.scope ?? "principal", + nextCursor: data.next_cursor ?? null, + events, + total: events.length, + }; +} + function roleLabel(role: string | undefined): string | null { if (!role) { return null; @@ -508,7 +544,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); @@ -546,9 +582,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, }, }; }