From de33f8a5148ff9f6d30e5b392b76e57af751b5ed Mon Sep 17 00:00:00 2001 From: Manoj Bajaj Date: Thu, 28 May 2026 13:51:03 +0530 Subject: [PATCH 1/2] feat: Add docs for design of principal roles and audit Entire-Checkpoint: 48bda8cba15d --- CONTEXT.md | 32 ++++++++++++++----- ...d => 0004-proxy-unmatched-pass-through.md} | 0 docs/adr/0005-audit-otel-sqlite.md | 22 +++++++++++++ docs/adr/0006-principal-roles-admin-user.md | 18 +++++++++++ 4 files changed, 64 insertions(+), 8 deletions(-) rename docs/adr/{0003-proxy-unmatched-pass-through.md => 0004-proxy-unmatched-pass-through.md} (100%) create mode 100644 docs/adr/0005-audit-otel-sqlite.md create mode 100644 docs/adr/0006-principal-roles-admin-user.md diff --git a/CONTEXT.md b/CONTEXT.md index fa54cd6d..50d78993 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -88,18 +88,19 @@ Think of this as the secrets layer. Encrypts and decrypts credential blobs trans ### `audit/` — Structured event recording -Think of this as the append-only ledger. Records who did what and when. +Think of this as the audit instrumentation layer. Defines what happened; `server/` decides where it goes. **Owns:** -- `AuditEvent` model -- `log()` / `alog()` — append to a structured JSON-lines log file -- `setup()` / `clear()` — log file lifecycle (called by server at startup/shutdown) +- `AuditEvent` domain model — mandatory fields: `identity`, `principal_id`, `provider`, `connection`; optional: `method`, `path`, `status`, `metadata` +- `log()` / `alog()` — emit an `AuditEvent` as an OTel `LogRecord` via `get_logger_provider()` +- Translation from `AuditEvent` → OTel `LogRecord` **Does not own:** -- Business logic -- Any storage beyond the append-only log file +- Storage — no file I/O, no database +- Provider lifecycle (`setup()` / `clear()` removed — owned by `server/`) +- Knowledge of where events are routed -**Imports nothing from this codebase.** Imported by: `auth/`, `server/` +**Imports:** `opentelemetry-api` only (no SDK, no storage). **Imports nothing from this codebase.** Imported by: `server/`, `proxy/` --- @@ -120,6 +121,9 @@ Think of this as the daemon process. Wires identity + auth + vault + audit toget - `server/app.py` — FastAPI application factory and lifespan - `server/routes/` — HTTP API surface - `server/schemas.py` — API response schemas +- `server/audit_store.py` — `SQLiteLogExporter` (OTel `LogExporter` impl) + `AuditStore` query interface; `LoggerProvider` lifecycle (setup at startup, shutdown at teardown) +- `server/routes/audit.py` — `GET /audit/events` (filtered, paginated admin read) +- `POST /audit/events` — ingest endpoint for proxy-side external AuditEvents; server enriches `principal_id` from PoP JWT **All filesystem interaction for server-owned state lives here.** No other module writes to server-owned paths. @@ -141,6 +145,7 @@ A mitmproxy-based HTTPS proxy. Intercepts outgoing agent requests and injects au - Credential loading (asks the server) - Route catalog construction (asks the server) - Provider definitions +- Audit storage — ships External AuditEvents to server via `POST /audit/events` (fire-and-forget); does not call `audit.log()` directly **Imported by:** `cli/` @@ -178,12 +183,15 @@ Click-based CLI and HTTP client. Everything here is a client to the server HTTP **PoP JWT**: Short-lived (60 s) Proof-of-Possession token signed with the Identity's Ed25519 private key. Bound to `htm`, `htu`, `body_sha256`. Sent as `Authorization: PoP `. -**Principal**: Non-cryptographic logical partition (human or team) that owns Vaults. Identified by an opaque **PrincipalId** (e.g., `principal_abc123def456`). Has no cryptographic key. +**Principal**: Non-cryptographic logical partition (human or team) that owns Vaults. Identified by an opaque **PrincipalId** (e.g., `principal_abc123def456`). Has no cryptographic key. Carries exactly one **PrincipalRole**. _Avoid_: User, account, PrincipalHandle, profile **PrincipalId**: Opaque stable identifier for a Principal. Never the email or handle — those can change; the PrincipalId cannot. _Avoid_: principal_handle, principal_name, username +**PrincipalRole**: Authorization tier for a Principal. Either `admin` or `user`. The first Principal created on a server is always `admin`; all subsequent Principals are `user`. Stored as a column on the Principal record — not in environment variables or a separate table. +_Avoid_: permission level, access level, user type + **Vault**: Named credential store owned by exactly one Principal. Identified by an opaque **VaultId** (e.g., `vault_a1b2c3d4e5f6`). All credential store keys are prefixed `vault::...`. _Avoid_: credential store, token store, secret store, profile store @@ -240,6 +248,14 @@ AuthService does not query registries, does not know about server filesystem pat Every `AuditEvent` carries `identity` (the agent Handle) and `principal_id` (the PrincipalId). Both are required — every auditable action has an acting agent and an owning principal. +**External AuditEvent**: An event produced by the proxy layer — records an outbound HTTP call an agent made through the proxy to a third-party API (e.g., a call to `api.github.com`). Classified by provider and connection. Mandatory fields: identity, principal_id, provider, connection. Optional fields: HTTP method, path, response status. +_Avoid_: proxy event, API event, outbound event + +**Internal AuditEvent**: An event produced by the server layer — records credential lifecycle operations (login, logout, token refresh, revocation) and auth flow steps. +_Avoid_: server event, auth event, lifecycle event + +**Audit delivery**: External AuditEvents are shipped from the proxy to the server via `POST /audit/events` (fire-and-forget, best-effort). The proxy does not write to a local audit file. The server is the single source of truth for all audit events. `principal_id` is resolved server-side from the PoP JWT on the ingest request — the proxy does not need to supply it. + --- ## Flagged Ambiguities diff --git a/docs/adr/0003-proxy-unmatched-pass-through.md b/docs/adr/0004-proxy-unmatched-pass-through.md similarity index 100% rename from docs/adr/0003-proxy-unmatched-pass-through.md rename to docs/adr/0004-proxy-unmatched-pass-through.md diff --git a/docs/adr/0005-audit-otel-sqlite.md b/docs/adr/0005-audit-otel-sqlite.md new file mode 100644 index 00000000..ae297e9a --- /dev/null +++ b/docs/adr/0005-audit-otel-sqlite.md @@ -0,0 +1,22 @@ +# Audit events use OTel Logs API with a SQLite exporter owned by the server + +The proxy runs on the client machine and the server runs remotely, so writing audit events to a local file produces two disjoint logs that an IT admin cannot view in one place. We need a single server-owned audit store that both the proxy and the server write into. + +**Decision:** `audit/` is a pure leaf that imports only `opentelemetry-api`. It defines `AuditEvent`, translates it to an OTel `LogRecord`, and emits via the globally registered `LoggerProvider` — with no knowledge of where events go. `server/` owns a custom `SQLiteLogExporter` (implementing the OTel `LogExporter` interface), registers a `LoggerProvider` with a `BatchLogRecordProcessor` at daemon startup, and exposes `GET /audit/events` for admin queries. The proxy ships External AuditEvents to the server fire-and-forget via `POST /audit/events` rather than writing to a local file; the server enriches each inbound event with `principal_id` resolved from the PoP JWT. + +**Considered alternatives:** + +- *Flat JSON-lines file per process* — the current approach. Rejected because it produces two disjoint audit logs in the client/server topology, with no queryable interface for the admin view. +- *Proxy hosted on server* — rejected because it routes all agent traffic through the server machine, adding a network round-trip to every API call and making the server a traffic bottleneck. +- *Pure OTLP to an external collector* — rejected as the primary store because it requires operator-provisioned infrastructure. OTLP remains a valid future second exporter on the same `LoggerProvider` for teams that already run Grafana or Datadog. +- *SQLite owned by `audit/`* — rejected to preserve `audit/` as a dependency-free leaf. Storage decisions belong to `server/`, consistent with how all other server-owned state is managed. + +**Not considered:** replacing loguru with OTel for operational logging. Loguru (68 call sites) serves developers debugging live systems — free-form, level-filtered, short-retention. Audit serves IT admins answering compliance questions — structured, required fields, long-retention, queryable. Routing loguru through the SQLite exporter would fill the admin view with operational noise. They are different things with different audiences and must stay separate. + +**Consequences:** + +- `audit/` gains `opentelemetry-api` as a dependency; `server/` gains `opentelemetry-sdk` and `aiosqlite` (or `sqlite3`). +- `audit.setup()` / `audit.clear()` are removed; `server/app.py` lifespan manages the `LoggerProvider`. +- The proxy's two existing direct `audit.log()` calls (`proxy_no_credentials`, `proxy_deny`) move to fire-and-forget HTTP posts to the server. +- A second OTLP exporter can be added to the `LoggerProvider` at any time without touching `audit/` or `proxy/`. +- All audit events — Internal (server) and External (proxy) — are queryable from a single `GET /audit/events` endpoint. diff --git a/docs/adr/0006-principal-roles-admin-user.md b/docs/adr/0006-principal-roles-admin-user.md new file mode 100644 index 00000000..16f8d658 --- /dev/null +++ b/docs/adr/0006-principal-roles-admin-user.md @@ -0,0 +1,18 @@ +# Principal roles: admin / user, first-created principal is admin + +Principals need an authorization tier to gate deployment-level operations (audit log access, provider registration/deletion, cross-vault credential revocation) from per-principal operations (own connections, claim accept/reject). We store a `role` column (`admin` | `user`) directly on the `principals` table. The first Principal created on a server is assigned `admin`; all subsequent Principals receive `user`. Role assignment is immutable at creation time (mutation is deferred to a future milestone). + +## Considered options + +**Environment variable (`AUTHSOME_ADMIN_PRINCIPALS`)** — the prior approach. Rejected because it requires knowing the PrincipalId before the server starts, cannot be changed without a restart, and is invisible to the UI and route layer. + +**Separate `principal_roles` table** — considered for future extensibility (multiple roles per principal). Rejected as premature: the role model is binary and a join adds complexity without benefit today. + +**Default admin account created at server init** — would require deciding on credentials before any real user exists. Rejected in favour of first-user-becomes-admin, which is zero-config and correct for both local and hosted deployments. + +## Consequences + +- `AUTHSOME_ADMIN_PRINCIPALS` env var and `is_admin_principal()` are removed entirely. +- Admin enforcement at the route level uses a `get_admin_auth_service` FastAPI dependency (parallel to `get_protected_auth_service`) that raises `HTTP 403` for non-admin principals. +- Admin-only routes: `GET /audit/events`, `POST /providers`, `DELETE /providers/{provider}`, `POST /connections/{provider}/revoke`. +- Schema migration: `ALTER TABLE principals ADD COLUMN role TEXT NOT NULL DEFAULT 'user'`, followed by setting the earliest-created principal to `'admin'`. From 0ed077b9df60808f840d37460de3a75c5dd8303c Mon Sep 17 00:00:00 2001 From: Manoj Bajaj Date: Thu, 28 May 2026 15:13:56 +0530 Subject: [PATCH 2/2] feat: implement audit events and principal roles Implement ADR 005 by routing audit events through OpenTelemetry with a server-owned SQLite exporter and query endpoint. Implement ADR 006 by persisting principal roles, assigning first principal admin, and enforcing admin-only routes at the FastAPI dependency layer. Entire-Checkpoint: df60966364f1 --- pyproject.toml | 2 + src/authsome/audit/__init__.py | 111 +++++----- src/authsome/cli/client.py | 6 + src/authsome/cli/main.py | 22 +- src/authsome/identity/principal.py | 8 + src/authsome/paths.py | 5 + src/authsome/proxy/runner.py | 2 + src/authsome/proxy/server.py | 40 +++- src/authsome/server/app.py | 10 +- src/authsome/server/audit.py | 207 ++++++++++++++++++ src/authsome/server/credential_service.py | 33 ++- src/authsome/server/dependencies.py | 6 + src/authsome/server/ownership.py | 25 ++- src/authsome/server/routes/_deps.py | 20 ++ src/authsome/server/routes/audit.py | 40 ++++ src/authsome/server/routes/connections.py | 4 +- src/authsome/server/routes/providers.py | 6 +- src/authsome/server/routes/ui.py | 7 +- src/authsome/server/store/database.py | 36 ++- src/authsome/server/store/repositories.py | 14 +- tests/auth/test_service.py | 36 ++- tests/auth/test_service_provider_clients.py | 6 +- tests/common/test_audit.py | 28 --- tests/proxy/test_proxy.py | 72 +++--- tests/server/test_audit_events.py | 106 +++++++++ tests/server/test_ownership.py | 54 +++++ .../server/test_provider_operation_policy.py | 57 +++-- tests/server/test_ui_dashboard.py | 4 - tests/server/test_ui_sessions.py | 5 +- uv.lock | 43 ++++ 30 files changed, 791 insertions(+), 224 deletions(-) create mode 100644 src/authsome/server/audit.py create mode 100644 src/authsome/server/routes/audit.py delete mode 100644 tests/common/test_audit.py create mode 100644 tests/server/test_audit_events.py diff --git a/pyproject.toml b/pyproject.toml index 23c52df5..969d6fc8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -43,6 +43,8 @@ dependencies = [ "base58>=2.1.1", "posthog>=3.0", "browser-cookie3>=0.19", + "opentelemetry-api>=1.42.1", + "opentelemetry-sdk>=1.42.1", ] [project.optional-dependencies] diff --git a/src/authsome/audit/__init__.py b/src/authsome/audit/__init__.py index 5abbbe5f..080ee2f6 100644 --- a/src/authsome/audit/__init__.py +++ b/src/authsome/audit/__init__.py @@ -1,85 +1,74 @@ -"""Structured server-side event logging helpers.""" +"""Structured audit event emission. + +The audit package is intentionally storage-free. It turns Authsome audit +events into OpenTelemetry log records and leaves export decisions to the +server composition root. +""" from __future__ import annotations -import json -import threading import uuid from datetime import datetime -from pathlib import Path -from typing import Any +from typing import Any, Literal +from opentelemetry._logs import SeverityNumber, get_logger from pydantic import BaseModel, Field from authsome.utils import utc_now +AuditSource = Literal["internal", "external"] + class AuditEvent(BaseModel): - """Structured server-side event record.""" + """Structured audit event record emitted through OpenTelemetry logs.""" event_id: str = Field(default_factory=lambda: f"audit_{uuid.uuid4().hex}") timestamp: datetime = Field(default_factory=utc_now) event: str + source: AuditSource = "internal" provider: str | None = None connection: str | None = None identity: str | None = None + principal_id: str | None = None status: str | None = None metadata: dict[str, Any] = Field(default_factory=dict) -_log_path: Path | None = None -_lock = threading.Lock() - - -def _build_event(event_type: str, **kwargs: Any) -> AuditEvent: - filtered_kwargs = {k: v for k, v in kwargs.items() if v is not None} - return AuditEvent( - event=event_type, - provider=filtered_kwargs.pop("provider", None), - connection=filtered_kwargs.pop("connection", None), - identity=filtered_kwargs.pop("identity", None), - status=filtered_kwargs.pop("status", None), - metadata=filtered_kwargs, +def emit(event: AuditEvent) -> AuditEvent: + """Emit an audit event through the globally registered OTel logger.""" + logger = get_logger("authsome.audit") + logger.emit( + timestamp=int(event.timestamp.timestamp() * 1_000_000_000), + severity_number=SeverityNumber.INFO, + severity_text="INFO", + body=event.event, + attributes=event.model_dump(mode="json"), + event_name=event.event, + ) + return event + + +def emit_event( + event: str, + *, + source: AuditSource = "internal", + identity: str | None = None, + principal_id: str | None = None, + provider: str | None = None, + connection: str | None = None, + status: str | None = None, + **metadata: Any, +) -> AuditEvent: + """Build and emit a structured audit event.""" + return emit( + AuditEvent( + event=event, + source=source, + identity=identity, + principal_id=principal_id, + provider=provider, + connection=connection, + status=status, + metadata={key: value for key, value in metadata.items() if value is not None}, + ) ) - - -def setup(path: Path) -> None: - """Configure the server-side structured log path.""" - global _log_path - path.parent.mkdir(parents=True, exist_ok=True) - if not path.exists(): - path.touch() - _log_path = path - - -def clear() -> None: - """Clear configured server-side log state.""" - global _log_path - _log_path = None - - -def _serialize_event(event: AuditEvent) -> str: - payload = event.model_dump(mode="json") - metadata = payload.pop("metadata", {}) - if isinstance(metadata, dict): - payload.update(metadata) - return json.dumps(payload, separators=(",", ":")) - - -# TODO: Better to use an audit library: otel or something similar -def log(event_type: str, **kwargs: Any) -> None: - """Append a structured server event to the configured log file.""" - if _log_path is None: - return - line = _serialize_event(_build_event(event_type, **kwargs)) - with _lock: - _log_path.parent.mkdir(parents=True, exist_ok=True) - with _log_path.open("a", encoding="utf-8") as handle: - handle.write(line) - handle.write("\n") - - -# FIXME: Why is there a log and alog ? -async def alog(event_type: str, **kwargs: Any) -> None: - """Async wrapper around structured server event logging.""" - log(event_type, **kwargs) diff --git a/src/authsome/cli/client.py b/src/authsome/cli/client.py index e15c1378..d27e7a6f 100644 --- a/src/authsome/cli/client.py +++ b/src/authsome/cli/client.py @@ -248,6 +248,12 @@ async def get_provider(self, provider: str) -> dict[str, Any]: async def register_provider(self, definition_dict: dict[str, Any], force: bool = False) -> None: await self._post("/providers", {"definition": definition_dict, "force": force}) + async def list_audit_events(self, *, limit: int = 50) -> dict[str, Any]: + return await self._get(f"/audit/events?limit={limit}") + + async def record_audit_event(self, event: dict[str, Any]) -> None: + await self._post("/audit/events", {"event": event}) + async def register_identity(self, handle: str, did: str) -> dict[str, Any]: return await self._post("/identities/register", {"handle": handle, "did": did}, protected=False) diff --git a/src/authsome/cli/main.py b/src/authsome/cli/main.py index bbee077f..e4671961 100644 --- a/src/authsome/cli/main.py +++ b/src/authsome/cli/main.py @@ -34,7 +34,7 @@ auth_command, setup_logging, ) -from authsome.paths import get_client_log_path, get_server_log_path +from authsome.paths import get_client_log_path from authsome.utils import connection_is_active, format_error_code, redact @@ -645,23 +645,9 @@ async def log_cmd(ctx_obj: ContextObj, lines: int, raw: bool) -> None: ctx_obj.print_json({"log_file": str(log_path), "entries": raw_lines}) return - audit_path = get_server_log_path(home) - try: - raw_lines = audit_path.read_text(encoding="utf-8", errors="replace").splitlines()[-lines:] - except FileNotFoundError: - raw_lines = [] - - parsed: list[dict] = [] - for line in raw_lines: - line = line.strip() - if not line: - continue - try: - parsed.append(json.loads(line)) - except Exception: - parsed.append({"raw": line}) - - ctx_obj.print_json({"log_file": str(audit_path), "entries": parsed}) + actx = await ctx_obj.initialize() + events = await actx.runtime_client.list_audit_events(limit=lines) + ctx_obj.print_json(events) @cli.group(name="daemon") diff --git a/src/authsome/identity/principal.py b/src/authsome/identity/principal.py index 717c441f..1a9c01bb 100644 --- a/src/authsome/identity/principal.py +++ b/src/authsome/identity/principal.py @@ -22,11 +22,19 @@ class ClaimStatus(StrEnum): REJECTED = "rejected" +class PrincipalRole(StrEnum): + """Authorization tier assigned to a Principal.""" + + ADMIN = "admin" + USER = "user" + + class PrincipalRecord(BaseModel): """Principal account record.""" principal_id: str email: str + role: PrincipalRole = PrincipalRole.USER password_hash: str | None = None created_at: datetime = Field(default_factory=utc_now) updated_at: datetime = Field(default_factory=utc_now) diff --git a/src/authsome/paths.py b/src/authsome/paths.py index ead32de8..8998792f 100644 --- a/src/authsome/paths.py +++ b/src/authsome/paths.py @@ -33,3 +33,8 @@ def get_client_log_path(home: Path | None = None) -> Path: def get_server_log_path(home: Path | None = None) -> Path: """Return the default server log file path.""" return get_server_home(home) / "logs" / "authsome.log" + + +def get_server_audit_db_path(home: Path | None = None) -> Path: + """Return the server-owned audit event database path.""" + return get_server_home(home) / "audit" / "events.sqlite3" diff --git a/src/authsome/proxy/runner.py b/src/authsome/proxy/runner.py index 55fb0a5f..3e2b5c9c 100644 --- a/src/authsome/proxy/runner.py +++ b/src/authsome/proxy/runner.py @@ -26,6 +26,8 @@ async def proxy_routes(self, scope: str = "connected") -> Any: ... async def list_providers_by_source(self) -> Any: ... + async def record_audit_event(self, event: dict[str, Any]) -> Any: ... + class ProxyRunner: """Launch a subprocess behind the Authsome local auth proxy.""" diff --git a/src/authsome/proxy/server.py b/src/authsome/proxy/server.py index 14a0864d..4f3cbafd 100644 --- a/src/authsome/proxy/server.py +++ b/src/authsome/proxy/server.py @@ -17,7 +17,6 @@ from mitmproxy.options import Options from mitmproxy.tools.dump import DumpMaster -from authsome import audit from authsome.cli.client_config import ProxyMode from authsome.proxy.router import RouteMatch, RouteResolution from authsome.server.urls import DEFAULT_SERVER_BASE_URL @@ -39,6 +38,8 @@ async def resolve_credentials(self, **kwargs: Any) -> Any: ... async def proxy_routes(self, scope: str = "connected") -> Any: ... + async def record_audit_event(self, event: dict[str, Any]) -> Any: ... + @dataclass(frozen=True) class _RouteTarget: @@ -390,11 +391,12 @@ async def request(self, flow: http.HTTPFlow) -> None: headers = await self._get_auth_headers(match) except Exception as exc: normalized_host = _normalize_host(flow.request.host) - audit.log( + self._record_external_audit( "proxy_no_credentials", host=normalized_host, provider=match.provider, connection=match.connection, + error=str(exc), ) if policy == "deny": logger.warning( @@ -434,13 +436,45 @@ def _deny_request( match: RouteMatch | None = None, ) -> None: host = _normalize_host(flow.request.host) - audit.log("proxy_deny", host=host, reason=reason) + self._record_external_audit( + "proxy_deny", + host=host, + reason=reason, + provider=match.provider if match else None, + connection=match.connection if match else None, + ) logger.warning("Proxy deny: host={} reason={}", host, reason) if flow.request.method.upper() == "CONNECT": flow.kill() return flow.response = http.Response.make(403, _deny_body(reason, match).encode("utf-8")) + def _record_external_audit( + self, + event: str, + *, + provider: str | None = None, + connection: str | None = None, + **metadata: Any, + ) -> None: + payload = { + "event": event, + "source": "external", + "provider": provider, + "connection": connection, + "metadata": {key: value for key, value in metadata.items() if value is not None}, + } + try: + asyncio.create_task(self._send_external_audit(payload)) + except RuntimeError: + logger.debug("Could not enqueue proxy audit event {}", event) + + async def _send_external_audit(self, payload: dict[str, Any]) -> None: + try: + await self._client.record_audit_event(payload) + except Exception as exc: + logger.debug("Could not send proxy audit event {}: {}", payload.get("event"), exc) + async def _get_auth_headers(self, match: RouteMatch) -> dict[str, str]: cache_key = (match.provider, match.connection or "") now = utc_now() diff --git a/src/authsome/server/app.py b/src/authsome/server/app.py index 45764be9..06ef7c2a 100644 --- a/src/authsome/server/app.py +++ b/src/authsome/server/app.py @@ -9,22 +9,23 @@ from fastapi.responses import JSONResponse from fastapi.staticfiles import StaticFiles -from authsome import audit from authsome.auth.sessions import AuthSessionStore from authsome.errors import AuthsomeError from authsome.identity.proof import ReplayCache from authsome.server.analytics import init_posthog, shutdown_posthog +from authsome.server.audit import configure_server_audit_log from authsome.server.dependencies import ( create_hosted_account_service, create_identity_bootstrap_service, create_ownership_resolver, create_store, create_vault, + get_server_audit_db_path, get_server_base_url, - get_server_log_path, load_server_config, load_ui_session_signing_secret, ) +from authsome.server.routes.audit import router as audit_router from authsome.server.routes.auth import router as auth_router from authsome.server.routes.connections import router as connections_router from authsome.server.routes.health import router as health_router @@ -42,7 +43,7 @@ async def lifespan(app: FastAPI): """Manage daemon lifecycle.""" app.state.store = await create_store() app.state.server_config = await load_server_config(app.state.store) - audit.setup(get_server_log_path(app.state.store.home)) + app.state.audit_log = configure_server_audit_log(get_server_audit_db_path(app.state.store.home)) app.state.vault = await create_vault(app.state.store.home) app.state.auth_sessions = AuthSessionStore() app.state.ui_sessions = UiSessionStore(load_ui_session_signing_secret(app.state.store.home)) @@ -65,7 +66,7 @@ async def lifespan(app: FastAPI): app.state.ownership_cache = {} yield shutdown_posthog() - audit.clear() + app.state.audit_log.shutdown() await app.state.store.close() @@ -102,6 +103,7 @@ def ui_auth_required_handler(request: Request, exc: UiAuthRequiredError): app.include_router(health_router) app.include_router(identities_router) + app.include_router(audit_router) app.include_router(auth_router) app.include_router(connections_router) app.include_router(providers_router) diff --git a/src/authsome/server/audit.py b/src/authsome/server/audit.py new file mode 100644 index 00000000..61e37fb7 --- /dev/null +++ b/src/authsome/server/audit.py @@ -0,0 +1,207 @@ +"""Server-owned audit log export and query support.""" + +from __future__ import annotations + +import json +import sqlite3 +import threading +from collections.abc import Sequence +from pathlib import Path +from typing import Any + +from loguru import logger +from opentelemetry._logs import set_logger_provider +from opentelemetry.sdk._logs import LoggerProvider +from opentelemetry.sdk._logs.export import ( + BatchLogRecordProcessor, + LogRecordExporter, + LogRecordExportResult, +) + +from authsome.audit import AuditEvent +from authsome.utils import utc_now + + +class SQLiteLogExporter(LogRecordExporter): + """Persist Authsome audit OTel log records into a server-owned SQLite DB.""" + + def __init__(self, path: Path) -> None: + self._path = path + self._lock = threading.Lock() + self._closed = False + self._ensure_schema() + + @property + def path(self) -> Path: + return self._path + + def export(self, batch: Sequence[Any]) -> LogRecordExportResult: + if self._closed: + return LogRecordExportResult.FAILURE + + rows: list[tuple[str, str, str, str | None, str | None, str | None, str | None, str | None, str]] = [] + for item in batch: + payload = _payload_from_log_record(item) + try: + event = AuditEvent.model_validate(payload) + except ValueError as exc: + logger.debug("Skipping non-Authsome OTel log record: {}", exc) + continue + normalized = event.model_dump(mode="json") + stored_payload = _flatten_event_payload(normalized) + rows.append( + ( + event.event_id, + normalized["timestamp"], + event.event, + event.source, + event.principal_id, + event.identity, + event.provider, + event.connection, + json.dumps(stored_payload, separators=(",", ":"), sort_keys=True), + ) + ) + + if not rows: + return LogRecordExportResult.SUCCESS + + with self._lock, self._connect() as connection: + connection.executemany( + "INSERT OR IGNORE INTO audit_events " + "(event_id, timestamp, event, source, principal_id, identity, provider, connection, payload_json) " + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", + rows, + ) + return LogRecordExportResult.SUCCESS + + def shutdown(self) -> None: + self._closed = True + + def list_events(self, *, limit: int = 50) -> list[dict[str, Any]]: + """Return recent audit events as JSON-compatible dictionaries.""" + bounded_limit = min(max(limit, 1), 500) + with self._lock, self._connect() as connection: + rows = connection.execute( + "SELECT payload_json FROM audit_events ORDER BY timestamp DESC, event_id DESC LIMIT ?", + [bounded_limit], + ).fetchall() + return [json.loads(row["payload_json"]) for row in rows] + + def _ensure_schema(self) -> None: + self._path.parent.mkdir(parents=True, exist_ok=True) + with self._lock, self._connect() as connection: + connection.execute( + "CREATE TABLE IF NOT EXISTS audit_events (" + "event_id TEXT PRIMARY KEY, " + "timestamp TEXT NOT NULL, " + "event TEXT NOT NULL, " + "source TEXT NOT NULL, " + "principal_id TEXT, " + "identity TEXT, " + "provider TEXT, " + "connection TEXT, " + "payload_json TEXT NOT NULL, " + "created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP" + ")" + ) + connection.execute( + "CREATE INDEX IF NOT EXISTS idx_audit_events_timestamp ON audit_events(timestamp DESC, event_id DESC)" + ) + connection.execute("CREATE INDEX IF NOT EXISTS idx_audit_events_principal ON audit_events(principal_id)") + + def _connect(self) -> sqlite3.Connection: + connection = sqlite3.connect(self._path) + connection.row_factory = sqlite3.Row + return connection + + +class _DelegatingLogRecordExporter(LogRecordExporter): + """Stable global exporter that forwards to the current server exporter.""" + + def __init__(self) -> None: + self._lock = threading.Lock() + self._active: SQLiteLogExporter | None = None + + def set_active(self, exporter: SQLiteLogExporter | None) -> None: + with self._lock: + self._active = exporter + + def export(self, batch: Sequence[Any]) -> LogRecordExportResult: + with self._lock: + exporter = self._active + if exporter is None: + return LogRecordExportResult.SUCCESS + return exporter.export(batch) + + def shutdown(self) -> None: + with self._lock: + exporter = self._active + self._active = None + if exporter is not None: + exporter.shutdown() + + +_delegating_exporter = _DelegatingLogRecordExporter() +_provider_lock = threading.Lock() +_logger_provider: LoggerProvider | None = None + + +class ServerAuditLog: + """Server composition object for emitting and querying audit records.""" + + def __init__(self, exporter: SQLiteLogExporter, provider: LoggerProvider) -> None: + self._exporter = exporter + self._provider = provider + + @property + def path(self) -> Path: + return self._exporter.path + + def force_flush(self) -> None: + self._provider.force_flush() + + def list_events(self, *, limit: int = 50) -> list[dict[str, Any]]: + self.force_flush() + return self._exporter.list_events(limit=limit) + + def shutdown(self) -> None: + self.force_flush() + _delegating_exporter.set_active(None) + self._exporter.shutdown() + + +def configure_server_audit_log(path: Path) -> ServerAuditLog: + """Configure the process OTel logger provider to export audit logs to SQLite.""" + global _logger_provider + + exporter = SQLiteLogExporter(path) + with _provider_lock: + if _logger_provider is None: + provider = LoggerProvider() + provider.add_log_record_processor(BatchLogRecordProcessor(_delegating_exporter)) + try: + set_logger_provider(provider) + except Exception as exc: # pragma: no cover - OTel guards against repeated global setup + logger.warning("Could not set OpenTelemetry logger provider: {}", exc) + _logger_provider = provider + _delegating_exporter.set_active(exporter) + return ServerAuditLog(exporter, _logger_provider) + + +def _payload_from_log_record(item: Any) -> dict[str, Any]: + log_record = getattr(item, "log_record", item) + attributes = dict(getattr(log_record, "attributes", None) or {}) + if "event" in attributes: + return attributes + + event_name = getattr(log_record, "event_name", None) or getattr(log_record, "body", "audit_event") + return AuditEvent(event=str(event_name), timestamp=utc_now()).model_dump(mode="json") + + +def _flatten_event_payload(payload: dict[str, Any]) -> dict[str, Any]: + flattened = dict(payload) + metadata = flattened.pop("metadata", {}) + if isinstance(metadata, dict): + flattened.update(metadata) + return flattened diff --git a/src/authsome/server/credential_service.py b/src/authsome/server/credential_service.py index 1cd1c456..a9bdb04f 100644 --- a/src/authsome/server/credential_service.py +++ b/src/authsome/server/credential_service.py @@ -8,7 +8,6 @@ import importlib.resources import json -import os from datetime import timedelta from typing import Any from urllib.parse import urlparse @@ -45,6 +44,7 @@ TokenExpiredError, UnsupportedFlowError, ) +from authsome.identity.principal import PrincipalRole from authsome.server.store.repositories import ProviderDefinitionRepository from authsome.utils import build_store_key, format_duration, is_filesystem_safe, parse_store_key, utc_now from authsome.vault import Vault @@ -66,15 +66,6 @@ } -def is_admin_principal(principal_id: str | None) -> bool: - """Return whether a principal is listed in AUTHSOME_ADMIN_PRINCIPALS.""" - if not principal_id: - return False - raw = os.environ.get("AUTHSOME_ADMIN_PRINCIPALS", "") - principals = {item.strip() for item in raw.split(",") if item.strip()} - return principal_id in principals - - class AuthService: """ Authentication and credential lifecycle service. @@ -89,12 +80,14 @@ def __init__( provider_definitions: ProviderDefinitionRepository, identity: str | None = None, principal_id: str | None = None, + principal_role: PrincipalRole = PrincipalRole.USER, vault_id: str | None = None, deployment_mode: str = "local", ) -> None: self._vault = vault self._identity = identity self._principal_id = principal_id + self._principal_role = principal_role self._vault_id = vault_id self._deployment_mode = "hosted" if deployment_mode == "hosted" else "local" self._provider_definitions = provider_definitions @@ -130,6 +123,10 @@ def require_identity(self) -> str: def principal_id(self) -> str | None: return self._principal_id + @property + def principal_role(self) -> PrincipalRole: + return self._principal_role + @property def vault_id(self) -> str | None: return self._vault_id @@ -192,7 +189,7 @@ async def resolve_credentials(self, **kwargs: Any) -> dict[str, Any]: } async def register_provider(self, definition: ProviderDefinition, *, force: bool = False) -> None: - self._ensure_local_provider_admin_operation_allowed("register", definition.name) + self._ensure_admin_operation_allowed("register", definition.name) self._validate_provider(definition) await self._provider_definitions.save(definition, force=force) logger.info("Registered provider: {}", definition.name) @@ -201,8 +198,8 @@ async def remove_provider(self, name: str) -> bool: """Remove a custom provider. Returns True if removed.""" return await self._provider_definitions.delete(name) - def _ensure_local_provider_admin_operation_allowed(self, operation: str, provider: str) -> None: - if is_admin_principal(self._principal_id): + def _ensure_admin_operation_allowed(self, operation: str, provider: str) -> None: + if self._principal_role == PrincipalRole.ADMIN: return if self._deployment_mode == "hosted": raise OperationNotAllowedError( @@ -212,7 +209,7 @@ def _ensure_local_provider_admin_operation_allowed(self, operation: str, provide ) def _ensure_provider_client_mutation_allowed(self, provider: str) -> None: - if is_admin_principal(self._principal_id): + if self._principal_role == PrincipalRole.ADMIN: return if self._deployment_mode == "hosted": raise OperationNotAllowedError( @@ -753,7 +750,7 @@ async def revoke(self, provider: str, vault_ids: list[str] | None = None) -> Non The server layer resolves the full list of vault IDs and passes them in. When vault_ids is None, only this service's own vault_id is used. """ - self._ensure_local_provider_admin_operation_allowed("revoke", provider) + self._ensure_admin_operation_allowed("revoke", provider) await self.get_provider(provider) ids_to_revoke = vault_ids if vault_ids is not None else ([self._vault_id] if self._vault_id else []) for vault_id in ids_to_revoke: @@ -761,6 +758,7 @@ async def revoke(self, provider: str, vault_ids: list[str] | None = None) -> Non vault=self._vault, identity=self._identity, principal_id=self._principal_id, + principal_role=self._principal_role, vault_id=vault_id, deployment_mode=self._deployment_mode, provider_definitions=self._provider_definitions, @@ -780,7 +778,7 @@ async def revoke(self, provider: str, vault_ids: list[str] | None = None) -> Non async def remove(self, provider: str) -> None: """Revoke all tokens and remove the provider definition if it is local.""" - self._ensure_local_provider_admin_operation_allowed("remove", provider) + self._ensure_admin_operation_allowed("remove", provider) await self.revoke(provider) if await self.is_local_provider(provider): await self._provider_definitions.delete(provider) @@ -977,11 +975,12 @@ async def _get_oauth_token(self, record: ConnectionRecord, provider: str, connec return refreshed.access_token except RefreshFailedError as exc: fallback_available = record.expires_at and now < record.expires_at - await audit.alog( + audit.emit_event( "refresh_failed", provider=provider, connection=connection, identity=self._identity, + principal_id=self._principal_id, error=str(exc), fallback_available=bool(fallback_available), ) diff --git a/src/authsome/server/dependencies.py b/src/authsome/server/dependencies.py index 816b09ad..011d1c22 100644 --- a/src/authsome/server/dependencies.py +++ b/src/authsome/server/dependencies.py @@ -14,6 +14,7 @@ from authsome.auth.models.config import ServerConfig from authsome.identity import current_from_home from authsome.paths import get_authsome_home as _get_authsome_home +from authsome.paths import get_server_audit_db_path as _get_server_audit_db_path from authsome.paths import get_server_home as _get_server_home from authsome.paths import get_server_log_path as _get_server_log_path from authsome.server.hosted_auth import HostedAccountService @@ -47,6 +48,11 @@ def get_server_log_path(home: Path | None = None) -> Path: return _get_server_log_path(home) +def get_server_audit_db_path(home: Path | None = None) -> Path: + """Return the daemon-owned audit event database path.""" + return _get_server_audit_db_path(home) + + def get_server_base_url() -> str: """Return the daemon's canonical external base URL.""" return build_server_base_url() diff --git a/src/authsome/server/ownership.py b/src/authsome/server/ownership.py index 6f7e7b69..64d550b5 100644 --- a/src/authsome/server/ownership.py +++ b/src/authsome/server/ownership.py @@ -5,7 +5,7 @@ from abc import ABC, abstractmethod from dataclasses import dataclass -from authsome.identity.principal import ClaimStatus +from authsome.identity.principal import ClaimStatus, PrincipalRole from authsome.server.store.repositories import ( IdentityClaimRegistry, PrincipalRegistry, @@ -23,6 +23,7 @@ class ResolvedOwnership: identity: str principal_id: str vault_id: str + role: PrincipalRole class OwnershipResolver(ABC): @@ -74,7 +75,12 @@ async def resolve(self, *, identity: str) -> ResolvedOwnership: vaults=self._vaults, bindings=self._bindings, ) - return ResolvedOwnership(identity=identity, principal_id=principal.principal_id, vault_id=vault_id) + return ResolvedOwnership( + identity=identity, + principal_id=principal.principal_id, + vault_id=vault_id, + role=principal.role, + ) async def claim_identity_for_principal(self, *, identity: str, principal_id: str) -> ResolvedOwnership: return await self.resolve(identity=identity) @@ -102,8 +108,16 @@ async def resolve(self, *, identity: str) -> ResolvedOwnership: raise ValueError(f"Identity '{identity}' claim has been rejected") if claim.claim_status != ClaimStatus.ACCEPTED: raise ValueError(f"Identity '{identity}' claim is pending principal approval") + principal = await self._principals.get(claim.principal_id) + if principal is None: + raise ValueError(f"Principal '{claim.principal_id}' not found") binding = await self._bindings.require_default_vault(claim.principal_id) - return ResolvedOwnership(identity=identity, principal_id=claim.principal_id, vault_id=binding.vault_id) + return ResolvedOwnership( + identity=identity, + principal_id=claim.principal_id, + vault_id=binding.vault_id, + role=principal.role, + ) async def claim_identity_for_principal(self, *, identity: str, principal_id: str) -> ResolvedOwnership: vault_id = await ensure_principal_default_vault( @@ -113,4 +127,7 @@ async def claim_identity_for_principal(self, *, identity: str, principal_id: str ) await self._claims.claim_identity(identity, principal_id) await self._claims.accept_claim(identity) - return ResolvedOwnership(identity=identity, principal_id=principal_id, vault_id=vault_id) + principal = await self._principals.get(principal_id) + if principal is None: + raise ValueError(f"Principal '{principal_id}' not found") + return ResolvedOwnership(identity=identity, principal_id=principal_id, vault_id=vault_id, role=principal.role) diff --git a/src/authsome/server/routes/_deps.py b/src/authsome/server/routes/_deps.py index 3126d972..39b41a40 100644 --- a/src/authsome/server/routes/_deps.py +++ b/src/authsome/server/routes/_deps.py @@ -6,6 +6,7 @@ from authsome.auth.sessions import AuthSessionStore from authsome.identity import current_from_home +from authsome.identity.principal import PrincipalRole from authsome.identity.proof import POP_AUTH_SCHEME, ProofValidationError, validate_proof_jwt from authsome.server.credential_service import AuthService from authsome.server.dependencies import get_deployment_mode @@ -30,6 +31,7 @@ async def get_auth_service( vault=request.app.state.vault, identity=identity, principal_id=resolved.principal_id, + principal_role=resolved.role, vault_id=resolved.vault_id, deployment_mode=get_deployment_mode(), provider_definitions=request.app.state.provider_definition_repository, @@ -41,10 +43,14 @@ async def get_auth_service( binding = await request.app.state.principal_vault_binding_registry.get_default_vault(principal_id) if binding is None: return None + principal = await request.app.state.store.principals.get(principal_id) + if principal is None: + return None return AuthService( vault=request.app.state.vault, identity=None, principal_id=principal_id, + principal_role=principal.role, vault_id=binding.vault_id, deployment_mode=get_deployment_mode(), provider_definitions=request.app.state.provider_definition_repository, @@ -83,6 +89,7 @@ async def get_principal_browser_auth_service(request: Request) -> AuthService: ) request.state.ui_identity = None request.state.ui_principal_id = session.principal_id + request.state.ui_principal_role = auth.principal_role request.state.ui_email = session.email request.state.ui_session_token = session.token return auth @@ -128,6 +135,8 @@ async def get_protected_auth_service(request: Request) -> AuthService: request.state.did = claims.issuer request.state.principal_id = resolved.principal_id request.state.vault_id = resolved.vault_id + request.state.principal_role = resolved.role + request.state.ownership = resolved request.state.registration_status = "registered" request.app.state.ownership_cache[claims.subject] = resolved return await require_auth_service( @@ -138,6 +147,13 @@ async def get_protected_auth_service(request: Request) -> AuthService: ) +async def get_admin_auth_service(request: Request) -> AuthService: + auth = await get_protected_auth_service(request) + if auth.principal_role != PrincipalRole.ADMIN: + raise HTTPException(status_code=403, detail="Admin role required") + return auth + + def get_vault_registry(request: Request) -> VaultRegistry: return request.app.state.vault_registry @@ -162,8 +178,10 @@ async def resolve_ui_request_identity(request: Request) -> str | None: try: resolved = await request.app.state.ownership_resolver.resolve(identity=identity.handle) request.state.ui_principal_id = resolved.principal_id + request.state.ui_principal_role = resolved.role except ValueError: request.state.ui_principal_id = None + request.state.ui_principal_role = None return identity.handle cookie_value = request.cookies.get(UI_SESSION_COOKIE_NAME) @@ -177,6 +195,8 @@ async def resolve_ui_request_identity(request: Request) -> str | None: request.state.ui_identity = None request.state.ui_principal_id = session.principal_id + principal = await request.app.state.store.principals.get(session.principal_id) + request.state.ui_principal_role = principal.role if principal else None request.state.ui_email = session.email request.state.ui_session_token = session.token return None diff --git a/src/authsome/server/routes/audit.py b/src/authsome/server/routes/audit.py new file mode 100644 index 00000000..da389745 --- /dev/null +++ b/src/authsome/server/routes/audit.py @@ -0,0 +1,40 @@ +"""Audit event routes.""" + +from __future__ import annotations + +from typing import Any + +from fastapi import APIRouter, Depends, Request + +from authsome import audit +from authsome.server.credential_service import AuthService +from authsome.server.routes._deps import get_admin_auth_service, get_protected_auth_service + +router = APIRouter(prefix="/audit", tags=["audit"]) + + +@router.get("/events") +async def list_audit_events( + request: Request, + limit: int = 50, + auth: AuthService = Depends(get_admin_auth_service), +) -> dict[str, Any]: + _ = auth + return {"entries": request.app.state.audit_log.list_events(limit=limit)} + + +@router.post("/events") +async def record_external_audit_event( + body: dict[str, Any], + auth: AuthService = Depends(get_protected_auth_service), +) -> dict[str, str]: + event_payload = body.get("event", body) + event = audit.AuditEvent.model_validate(event_payload).model_copy( + update={ + "source": "external", + "identity": auth.identity, + "principal_id": auth.principal_id, + } + ) + audit.emit(event) + return {"status": "ok", "event_id": event.event_id} diff --git a/src/authsome/server/routes/connections.py b/src/authsome/server/routes/connections.py index 701c4822..4d82b577 100644 --- a/src/authsome/server/routes/connections.py +++ b/src/authsome/server/routes/connections.py @@ -7,7 +7,7 @@ from authsome.auth.models.enums import ExportFormat from authsome.server.analytics import capture_event from authsome.server.credential_service import AuthService -from authsome.server.routes._deps import get_protected_auth_service, get_vault_registry +from authsome.server.routes._deps import get_admin_auth_service, get_protected_auth_service, get_vault_registry from authsome.server.store.repositories import VaultRegistry router = APIRouter(tags=["connections"]) @@ -48,7 +48,7 @@ async def logout(provider: str, connection: str, auth: AuthService = Depends(get @router.post("/connections/{provider}/revoke") async def revoke( provider: str, - auth: AuthService = Depends(get_protected_auth_service), + auth: AuthService = Depends(get_admin_auth_service), vault_registry: VaultRegistry = Depends(get_vault_registry), ): all_vaults = await vault_registry.list_all() diff --git a/src/authsome/server/routes/providers.py b/src/authsome/server/routes/providers.py index 3044ddd1..dde64954 100644 --- a/src/authsome/server/routes/providers.py +++ b/src/authsome/server/routes/providers.py @@ -7,7 +7,7 @@ from authsome.auth.models.provider import ProviderDefinition from authsome.server.analytics import capture_event from authsome.server.credential_service import AuthService -from authsome.server.routes._deps import get_protected_auth_service +from authsome.server.routes._deps import get_admin_auth_service, get_protected_auth_service router = APIRouter(prefix="/providers", tags=["providers"]) @@ -26,7 +26,7 @@ async def get_provider(provider: str, auth: AuthService = Depends(get_protected_ @router.post("") -async def register_provider(body: dict, auth: AuthService = Depends(get_protected_auth_service)): +async def register_provider(body: dict, auth: AuthService = Depends(get_admin_auth_service)): definition_payload = body.get("definition", body) definition = ProviderDefinition.model_validate(definition_payload) await auth.register_provider(definition, force=bool(body.get("force", False))) @@ -43,7 +43,7 @@ async def register_provider(body: dict, auth: AuthService = Depends(get_protecte @router.delete("/{provider}") -async def delete_provider(provider: str, auth: AuthService = Depends(get_protected_auth_service)): +async def delete_provider(provider: str, auth: AuthService = Depends(get_admin_auth_service)): await auth.remove(provider) capture_event( auth.require_identity(), diff --git a/src/authsome/server/routes/ui.py b/src/authsome/server/routes/ui.py index ac56a8af..1283d638 100644 --- a/src/authsome/server/routes/ui.py +++ b/src/authsome/server/routes/ui.py @@ -22,7 +22,8 @@ from authsome.auth.models.enums import AuthType, FlowType from authsome.auth.models.provider import ProviderDefinition from authsome.auth.sessions import AuthSession, AuthSessionStore -from authsome.server.credential_service import AuthService, is_admin_principal +from authsome.identity.principal import PrincipalRole +from authsome.server.credential_service import AuthService from authsome.server.dependencies import get_deployment_mode from authsome.server.routes._deps import ( UI_SESSION_COOKIE_NAME, @@ -77,8 +78,8 @@ def _ui_cookie_secure(server_base_url: str) -> bool: def _ui_policy(request: Request, auth: AuthService | None = None) -> dict[str, Any]: hosted = _is_hosted_ui() - principal_id = auth.principal_id if auth is not None else getattr(request.state, "ui_principal_id", None) - show_provider_client_details = not hosted or is_admin_principal(principal_id) + role = auth.principal_role if auth is not None else getattr(request.state, "ui_principal_role", None) + show_provider_client_details = not hosted or role == PrincipalRole.ADMIN return { "ui_mode": "hosted" if hosted else "local", "show_provider_client_details": show_provider_client_details, diff --git a/src/authsome/server/store/database.py b/src/authsome/server/store/database.py index 697be0a5..b25752c1 100644 --- a/src/authsome/server/store/database.py +++ b/src/authsome/server/store/database.py @@ -13,6 +13,7 @@ import aiosqlite from authsome.paths import get_authsome_home, get_server_home +from authsome.utils import utc_now StoreBackend = Literal["sqlite", "postgres"] @@ -162,7 +163,7 @@ def build_schema(backend: StoreBackend) -> list[str]: ")", "CREATE TABLE IF NOT EXISTS principals (" "principal_id TEXT PRIMARY KEY, email TEXT NOT NULL UNIQUE, password_hash TEXT, " - "created_at TEXT NOT NULL, updated_at TEXT NOT NULL" + "role TEXT NOT NULL DEFAULT 'user', created_at TEXT NOT NULL, updated_at TEXT NOT NULL" ")", "CREATE TABLE IF NOT EXISTS vaults (" "vault_id TEXT PRIMARY KEY, handle TEXT NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL" @@ -191,6 +192,39 @@ def build_schema(backend: StoreBackend) -> list[str]: async def initialize_schema(database: StoreDatabase) -> None: await database.execute_many(build_schema(database.backend)) + await migrate_principal_roles(database) + + +async def migrate_principal_roles(database: StoreDatabase) -> None: + """Add persisted principal roles to existing Store databases.""" + if not await _column_exists(database, table="principals", column="role"): + await database.execute("ALTER TABLE principals ADD COLUMN role TEXT NOT NULL DEFAULT 'user'") + + existing_admin = await database.fetch_one("SELECT principal_id FROM principals WHERE role = ? LIMIT 1", ["admin"]) + if existing_admin is not None: + return + + first_principal = await database.fetch_one( + "SELECT principal_id FROM principals ORDER BY created_at ASC, principal_id ASC LIMIT 1" + ) + if first_principal is None: + return + await database.execute( + "UPDATE principals SET role = ?, updated_at = ? WHERE principal_id = ?", + ["admin", utc_now().isoformat(), first_principal["principal_id"]], + ) + + +async def _column_exists(database: StoreDatabase, *, table: str, column: str) -> bool: + if database.backend == "sqlite": + rows = await database.fetch_all(f"PRAGMA table_info({table})") # noqa: S608 + return any(row["name"] == column for row in rows) + + row = await database.fetch_one( + "SELECT column_name FROM information_schema.columns WHERE table_name = ? AND column_name = ?", + [table, column], + ) + return row is not None async def create_server_store(home: Path | None = None, database_url: str | None = None): diff --git a/src/authsome/server/store/repositories.py b/src/authsome/server/store/repositories.py index 119b5c7e..e5984e4f 100644 --- a/src/authsome/server/store/repositories.py +++ b/src/authsome/server/store/repositories.py @@ -17,6 +17,7 @@ ClaimStatus, IdentityClaimRecord, PrincipalRecord, + PrincipalRole, PrincipalVaultBindingRecord, VaultRecord, ) @@ -107,17 +108,19 @@ async def create_by_email(self, email: str, *, password_hash: str | None = None) raise ValueError(f"Principal '{normalized}' already exists") raise ValueError(f"Hosted account '{normalized}' is already registered") now = utc_now() + role = await self._role_for_new_principal() record = PrincipalRecord( principal_id=f"principal_{uuid.uuid4().hex[:12]}", email=normalized, + role=role, password_hash=password_hash, created_at=now, updated_at=now, ) await self._db.execute( - "INSERT INTO principals (principal_id, email, password_hash, created_at, updated_at) " - "VALUES (?, ?, ?, ?, ?)", - [record.principal_id, record.email, record.password_hash, _dump_dt(now), _dump_dt(now)], + "INSERT INTO principals (principal_id, email, password_hash, role, created_at, updated_at) " + "VALUES (?, ?, ?, ?, ?, ?)", + [record.principal_id, record.email, record.password_hash, record.role.value, _dump_dt(now), _dump_dt(now)], ) return record @@ -137,11 +140,16 @@ def _record(row: dict[str, Any]) -> PrincipalRecord: return PrincipalRecord( principal_id=row["principal_id"], email=row["email"], + role=PrincipalRole(row["role"]), password_hash=row["password_hash"], created_at=_dt(row["created_at"]), updated_at=_dt(row["updated_at"]), ) + async def _role_for_new_principal(self) -> PrincipalRole: + existing = await self._db.fetch_one("SELECT principal_id FROM principals LIMIT 1") + return PrincipalRole.ADMIN if existing is None else PrincipalRole.USER + class VaultRegistry: """Relational vault registry.""" diff --git a/tests/auth/test_service.py b/tests/auth/test_service.py index 44614237..eb45cad8 100644 --- a/tests/auth/test_service.py +++ b/tests/auth/test_service.py @@ -1,16 +1,14 @@ """Tests for AuthService business logic.""" -import json from datetime import timedelta -from pathlib import Path from unittest import mock import pytest -from authsome import audit from authsome.auth.models.connection import ConnectionRecord from authsome.auth.models.enums import AuthType, ConnectionStatus from authsome.errors import RefreshFailedError +from authsome.server.audit import ServerAuditLog, configure_server_audit_log from authsome.server.credential_service import AuthService from authsome.utils import utc_now @@ -34,11 +32,10 @@ class TestAuthServiceRefreshLogs: """Tests validating that token refresh failure writes correct logs and audit trails.""" @pytest.fixture - def audit_log(self, tmp_path: Path) -> Path: - log_file = tmp_path / "audit.log" - audit.setup(log_file) - yield log_file - audit.clear() + def audit_log(self, tmp_path) -> ServerAuditLog: # noqa: ANN001 + log = configure_server_audit_log(tmp_path / "audit.sqlite3") + yield log + log.shutdown() @pytest.fixture def service(self) -> AuthService: @@ -50,7 +47,7 @@ def service(self) -> AuthService: provider_definitions=EmptyProviderDefinitions(), ) - async def test_refresh_failure_fallback_available(self, audit_log: Path, service: AuthService): + async def test_refresh_failure_fallback_available(self, audit_log: ServerAuditLog, service: AuthService): """Verify behavior when refresh fails but current token is valid (close to expiry).""" now = utc_now() # Close to expiry (<5m) triggers auto-refresh @@ -85,14 +82,14 @@ async def test_refresh_failure_fallback_available(self, audit_log: Path, service assert "expires in " in log_msg # 3. Audit verified - lines = audit_log.read_text().splitlines() - assert len(lines) == 1 - entry = json.loads(lines[0]) + entries = audit_log.list_events() + assert len(entries) == 1 + entry = entries[0] assert entry["event"] == "refresh_failed" assert entry["fallback_available"] is True assert "API down" in entry["error"] - async def test_refresh_failure_expired(self, audit_log: Path, service: AuthService): + async def test_refresh_failure_expired(self, audit_log: ServerAuditLog, service: AuthService): """Verify behavior when refresh fails and current token is already expired.""" now = utc_now() # Already expired @@ -124,9 +121,9 @@ async def test_refresh_failure_expired(self, audit_log: Path, service: AuthServi assert "token expired" in log_msg # 2. Audit written - lines = audit_log.read_text().splitlines() - assert len(lines) == 1 - entry = json.loads(lines[0]) + entries = audit_log.list_events() + assert len(entries) == 1 + entry = entries[0] assert entry["event"] == "refresh_failed" assert entry["fallback_available"] is False @@ -153,10 +150,3 @@ def test_auth_service_scopes_collection_by_vault_id() -> None: provider_definitions=EmptyProviderDefinitions(), ) assert service._coll == "vault:vault_default" - - -def test_auth_service_requires_provider_definitions() -> None: - mock_vault = mock.AsyncMock() - - with pytest.raises(TypeError): - AuthService(mock_vault, identity="agent-a", vault_id="vault_default") # type: ignore[call-arg] diff --git a/tests/auth/test_service_provider_clients.py b/tests/auth/test_service_provider_clients.py index ba39b527..646d5f73 100644 --- a/tests/auth/test_service_provider_clients.py +++ b/tests/auth/test_service_provider_clients.py @@ -13,6 +13,7 @@ from authsome.auth.sessions import AuthSession from authsome.errors import OperationNotAllowedError from authsome.identity import create_identity +from authsome.identity.principal import PrincipalRole from authsome.server.credential_service import AuthService from authsome.server.dependencies import ( create_store, @@ -193,7 +194,7 @@ async def test_update_provider_configuration_persists_submitted_scopes() -> None @pytest.mark.asyncio -async def test_hosted_admin_provider_config_satisfies_next_identity_login(monkeypatch: pytest.MonkeyPatch) -> None: +async def test_hosted_admin_provider_config_satisfies_next_identity_login() -> None: store: dict[tuple[str, str], str] = {} vault = mock.AsyncMock() @@ -205,12 +206,11 @@ async def put_value(key: str, value: str, *, collection: str) -> None: vault.get.side_effect = get_value vault.put.side_effect = put_value - monkeypatch.setenv("AUTHSOME_ADMIN_PRINCIPALS", "principal_admin") - admin_service = _service( vault, identity=None, principal_id="principal_admin", + principal_role=PrincipalRole.ADMIN, deployment_mode="hosted", ) identity_service = _service( diff --git a/tests/common/test_audit.py b/tests/common/test_audit.py deleted file mode 100644 index 8108fa52..00000000 --- a/tests/common/test_audit.py +++ /dev/null @@ -1,28 +0,0 @@ -"""Tests for daemon audit event models.""" - -from authsome.audit import AuditEvent - - -def test_audit_event_captures_known_fields() -> None: - event = AuditEvent( - event="login", - provider="github", - connection="default", - identity="steady-wisely-boldly-0042", - status="success", - ) - - assert event.event == "login" - assert event.provider == "github" - assert event.connection == "default" - assert event.identity == "steady-wisely-boldly-0042" - assert event.status == "success" - - -def test_audit_event_metadata_defaults_to_empty_mapping() -> None: - event = AuditEvent(event="proxy_miss") - - assert event.metadata == {} - payload = event.model_dump(mode="json") - assert payload["event"] == "proxy_miss" - assert "timestamp" in payload diff --git a/tests/proxy/test_proxy.py b/tests/proxy/test_proxy.py index d0f59040..19a36907 100644 --- a/tests/proxy/test_proxy.py +++ b/tests/proxy/test_proxy.py @@ -2,6 +2,7 @@ from __future__ import annotations +import asyncio from pathlib import Path from unittest import mock from unittest.mock import patch @@ -520,16 +521,19 @@ async def test_addon_denies_no_match_with_generic_body_in_connected_deny(self) - auth = mock.AsyncMock() flow = self._make_flow(host="example.com", path="/") - with patch("authsome.proxy.server.audit.log") as log_mock: - addon, _router, patcher = self._make_addon(auth, None, miss_reason="no_match", mode="connected_deny") - try: - await addon.request(flow) - finally: - patcher.stop() + addon, _router, patcher = self._make_addon(auth, None, miss_reason="no_match", mode="connected_deny") + try: + await addon.request(flow) + await asyncio.sleep(0) + finally: + patcher.stop() assert flow.response.status_code == 403 assert flow.response.content == b"Forbidden by Authsome proxy policy" - log_mock.assert_called_once_with("proxy_deny", host="example.com", reason="no_match") + auth.record_audit_event.assert_awaited_once() + event = auth.record_audit_event.await_args.args[0] + assert event["event"] == "proxy_deny" + assert event["metadata"] == {"host": "example.com", "reason": "no_match"} auth.resolve_credentials.assert_not_called() @pytest.mark.asyncio @@ -538,29 +542,37 @@ async def test_addon_denies_no_credentials_with_provider_hint_in_configured_deny auth.resolve_credentials.side_effect = RuntimeError("no connection for openai") flow = self._make_flow() - with patch("authsome.proxy.server.audit.log") as log_mock: - addon, _router, patcher = self._make_addon( - auth, - RouteMatch(provider="openai", connection="default"), - mode="configured_deny", - ) - try: - await addon.request(flow) - finally: - patcher.stop() + addon, _router, patcher = self._make_addon( + auth, + RouteMatch(provider="openai", connection="default"), + mode="configured_deny", + ) + try: + await addon.request(flow) + await asyncio.sleep(0) + finally: + patcher.stop() assert flow.response.status_code == 403 body = flow.response.content.decode("utf-8") assert "openai" in body assert "authsome login openai" in body assert f"{DEFAULT_SERVER_BASE_URL}/apps/openai" in body - log_mock.assert_any_call( - "proxy_no_credentials", - host="api.openai.com", - provider="openai", - connection="default", - ) - log_mock.assert_any_call("proxy_deny", host="api.openai.com", reason="no_credentials") + events = [call.args[0] for call in auth.record_audit_event.await_args_list] + assert { + "event": "proxy_no_credentials", + "source": "external", + "provider": "openai", + "connection": "default", + "metadata": {"host": "api.openai.com", "error": "no connection for openai"}, + } in events + assert { + "event": "proxy_deny", + "source": "external", + "provider": "openai", + "connection": "default", + "metadata": {"host": "api.openai.com", "reason": "no_credentials"}, + } in events @pytest.mark.asyncio async def test_addon_kills_connect_tunnel_on_deny(self) -> None: @@ -568,12 +580,12 @@ async def test_addon_kills_connect_tunnel_on_deny(self) -> None: flow = self._make_flow(host="example.com", path="/") flow.request.method = "CONNECT" - with patch("authsome.proxy.server.audit.log"): - addon, _router, patcher = self._make_addon(auth, None, miss_reason="no_match", mode="connected_deny") - try: - await addon.request(flow) - finally: - patcher.stop() + addon, _router, patcher = self._make_addon(auth, None, miss_reason="no_match", mode="connected_deny") + try: + await addon.request(flow) + await asyncio.sleep(0) + finally: + patcher.stop() flow.kill.assert_called_once() diff --git a/tests/server/test_audit_events.py b/tests/server/test_audit_events.py new file mode 100644 index 00000000..80c69487 --- /dev/null +++ b/tests/server/test_audit_events.py @@ -0,0 +1,106 @@ +from __future__ import annotations + +import json +from pathlib import Path +from urllib.parse import urlparse + +from fastapi.testclient import TestClient + +from authsome.audit import emit_event +from authsome.identity import create_identity +from authsome.server.app import create_app +from tests.server.test_pop_auth import _auth_header + + +def _register_identity(client: TestClient, tmp_path: Path, handle: str) -> None: + identity = create_identity(tmp_path, handle) + response = client.post("/identities/register", json={"handle": identity.handle, "did": identity.did}) + assert response.status_code == 200 + + +def _claim_identity(client: TestClient, tmp_path: Path, handle: str, *, email: str) -> None: + identity = create_identity(tmp_path, handle) + response = client.post("/identities/register", json={"handle": identity.handle, "did": identity.did}) + assert response.status_code == 200 + claim_path = urlparse(response.json()["claim_url"]).path + registered = client.post( + "/auth/register", + data={"email": email, "password": "password-1", "next": claim_path}, + follow_redirects=False, + ) + assert registered.status_code == 303 + assert client.post(f"{claim_path}/confirm", follow_redirects=False).status_code == 303 + + +def test_audit_events_endpoint_returns_internal_events_for_admin(monkeypatch, tmp_path: Path) -> None: + monkeypatch.setenv("AUTHSOME_HOME", str(tmp_path)) + monkeypatch.delenv("AUTHSOME_DEPLOYMENT_MODE", raising=False) + + with TestClient(create_app()) as client: + _register_identity(client, tmp_path, "steady-wisely-boldly-0042") + whoami = client.get("/whoami", headers=_auth_header(tmp_path, "GET", "/whoami")).json() + emit_event( + "login", + identity="steady-wisely-boldly-0042", + principal_id=whoami["principal_id"], + provider="github", + ) + + response = client.get( + "/audit/events?limit=10", + headers=_auth_header(tmp_path, "GET", "/audit/events?limit=10"), + ) + + assert response.status_code == 200 + entries = response.json()["entries"] + assert entries[0]["event"] == "login" + assert entries[0]["identity"] == "steady-wisely-boldly-0042" + assert entries[0]["principal_id"] == whoami["principal_id"] + assert entries[0]["provider"] == "github" + + +def test_external_audit_post_is_enriched_from_pop_identity(monkeypatch, tmp_path: Path) -> None: + monkeypatch.setenv("AUTHSOME_HOME", str(tmp_path)) + monkeypatch.delenv("AUTHSOME_DEPLOYMENT_MODE", raising=False) + payload = {"event": {"event": "proxy_deny", "metadata": {"host": "api.example.com", "reason": "no_match"}}} + body = json.dumps(payload, separators=(",", ":"), sort_keys=True).encode("utf-8") + + with TestClient(create_app()) as client: + _register_identity(client, tmp_path, "steady-wisely-boldly-0042") + posted = client.post( + "/audit/events", + content=body, + headers={ + **_auth_header(tmp_path, "POST", "/audit/events", body=body), + "Content-Type": "application/json", + }, + ) + response = client.get( + "/audit/events?limit=10", + headers=_auth_header(tmp_path, "GET", "/audit/events?limit=10"), + ) + + assert posted.status_code == 200 + entries = response.json()["entries"] + assert entries[0]["event"] == "proxy_deny" + assert entries[0]["source"] == "external" + assert entries[0]["identity"] == "steady-wisely-boldly-0042" + assert entries[0]["principal_id"].startswith("principal_") + assert entries[0]["host"] == "api.example.com" + assert entries[0]["reason"] == "no_match" + + +def test_hosted_user_cannot_query_audit_events(monkeypatch, tmp_path: Path) -> None: + monkeypatch.setenv("AUTHSOME_HOME", str(tmp_path)) + monkeypatch.setenv("AUTHSOME_DEPLOYMENT_MODE", "hosted") + + with TestClient(create_app()) 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") + response = client.get( + "/audit/events", + headers=_auth_header(tmp_path, "GET", "/audit/events"), + ) + + assert response.status_code == 403 + assert response.json()["detail"] == "Admin role required" diff --git a/tests/server/test_ownership.py b/tests/server/test_ownership.py index 0de8c89f..2904e088 100644 --- a/tests/server/test_ownership.py +++ b/tests/server/test_ownership.py @@ -2,14 +2,17 @@ from pathlib import Path +import aiosqlite import pytest +from authsome.identity.principal import PrincipalRole from authsome.server.ownership import ( LOCAL_PRINCIPAL_EMAIL, HostedOwnershipResolver, LocalOwnershipResolver, ) from authsome.server.store import create_server_store +from authsome.server.store.database import StoreDatabaseConfig, open_store_database @pytest.mark.asyncio @@ -32,6 +35,7 @@ async def test_hosted_resolution_maps_identity_to_default_vault(tmp_path: Path) assert context.principal_id == principal.principal_id assert context.vault_id == vault.vault_id + assert context.role == PrincipalRole.ADMIN finally: await store.close() @@ -52,7 +56,57 @@ async def test_local_resolution_creates_implicit_principal_and_vault(tmp_path: P assert principal is not None assert principal.email == LOCAL_PRINCIPAL_EMAIL + assert principal.role == PrincipalRole.ADMIN assert binding is not None assert binding.vault_id == context.vault_id + assert context.role == PrincipalRole.ADMIN finally: await store.close() + + +@pytest.mark.asyncio +async def test_principal_registry_assigns_first_principal_admin_then_users(tmp_path: Path) -> None: + store = await create_server_store(home=tmp_path) + try: + first = await store.principals.create_by_email("admin@example.com") + second = await store.principals.create_by_email("user@example.com") + + assert first.role == PrincipalRole.ADMIN + assert second.role == PrincipalRole.USER + finally: + await store.close() + + +@pytest.mark.asyncio +async def test_principal_role_migration_promotes_earliest_existing_principal(tmp_path: Path) -> None: + db_path = tmp_path / "server" / "authsome.db" + db_path.parent.mkdir(parents=True) + connection = await aiosqlite.connect(db_path) + try: + await connection.execute( + "CREATE TABLE principals (" + "principal_id TEXT PRIMARY KEY, email TEXT NOT NULL UNIQUE, password_hash TEXT, " + "created_at TEXT NOT NULL, updated_at TEXT NOT NULL" + ")" + ) + await connection.execute( + "INSERT INTO principals (principal_id, email, created_at, updated_at) VALUES (?, ?, ?, ?)", + ["principal_first", "first@example.com", "2026-01-01T00:00:00+00:00", "2026-01-01T00:00:00+00:00"], + ) + await connection.execute( + "INSERT INTO principals (principal_id, email, created_at, updated_at) VALUES (?, ?, ?, ?)", + ["principal_second", "second@example.com", "2026-01-02T00:00:00+00:00", "2026-01-02T00:00:00+00:00"], + ) + await connection.commit() + finally: + await connection.close() + + database = await open_store_database(StoreDatabaseConfig(backend="sqlite", dsn=str(db_path), home=tmp_path)) + try: + first = await database.fetch_one("SELECT role FROM principals WHERE principal_id = ?", ["principal_first"]) + second = await database.fetch_one("SELECT role FROM principals WHERE principal_id = ?", ["principal_second"]) + + assert first == {"role": PrincipalRole.ADMIN.value} + assert second == {"role": PrincipalRole.USER.value} + finally: + await database.close() diff --git a/tests/server/test_provider_operation_policy.py b/tests/server/test_provider_operation_policy.py index cb5f2446..cb168c05 100644 --- a/tests/server/test_provider_operation_policy.py +++ b/tests/server/test_provider_operation_policy.py @@ -9,7 +9,7 @@ from tests.server.test_pop_auth import _auth_header -def _register_identity(client: TestClient, tmp_path: Path, handle: str) -> None: +def _register_identity(client: TestClient, tmp_path: Path, handle: str, *, email: str = "dev@example.com") -> None: identity = create_identity(tmp_path, handle) response = client.post("/identities/register", json={"handle": identity.handle, "did": identity.did}) assert response.status_code == 200 @@ -19,7 +19,7 @@ def _register_identity(client: TestClient, tmp_path: Path, handle: str) -> None: assert client.get(claim_path).status_code == 200 registered = client.post( "/auth/register", - data={"email": "dev@example.com", "password": "password-1", "next": claim_path}, + data={"email": email, "password": "password-1", "next": claim_path}, follow_redirects=False, ) assert registered.status_code == 303 @@ -27,20 +27,24 @@ def _register_identity(client: TestClient, tmp_path: Path, handle: str) -> None: assert claimed.status_code == 303 +def _register_admin_then_user(client: TestClient, tmp_path: Path, user_handle: str) -> None: + _register_identity(client, tmp_path, "admin-ready-boldly-0001", email="admin@example.com") + _register_identity(client, tmp_path, user_handle, email="user@example.com") + + def test_hosted_revoke_is_rejected(monkeypatch, tmp_path: Path) -> None: monkeypatch.setenv("AUTHSOME_HOME", str(tmp_path)) monkeypatch.setenv("AUTHSOME_DEPLOYMENT_MODE", "hosted") with TestClient(create_app()) as client: - _register_identity(client, tmp_path, "steady-wisely-boldly-0042") + _register_admin_then_user(client, tmp_path, "steady-wisely-boldly-0042") response = client.post( "/connections/github/revoke", headers=_auth_header(tmp_path, "POST", "/connections/github/revoke"), ) - assert response.status_code == 400 - assert response.json()["error"] == "OperationNotAllowedError" - assert response.json()["operation"] == "revoke" + assert response.status_code == 403 + assert response.json()["detail"] == "Admin role required" def test_hosted_remove_is_rejected(monkeypatch, tmp_path: Path) -> None: @@ -48,15 +52,14 @@ def test_hosted_remove_is_rejected(monkeypatch, tmp_path: Path) -> None: monkeypatch.setenv("AUTHSOME_DEPLOYMENT_MODE", "hosted") with TestClient(create_app()) as client: - _register_identity(client, tmp_path, "steady-wisely-boldly-0042") + _register_admin_then_user(client, tmp_path, "steady-wisely-boldly-0042") response = client.delete( "/providers/github", headers=_auth_header(tmp_path, "DELETE", "/providers/github"), ) - assert response.status_code == 400 - assert response.json()["error"] == "OperationNotAllowedError" - assert response.json()["operation"] == "remove" + assert response.status_code == 403 + assert response.json()["detail"] == "Admin role required" def test_hosted_register_provider_is_rejected(monkeypatch, tmp_path: Path) -> None: @@ -73,6 +76,35 @@ def test_hosted_register_provider_is_rejected(monkeypatch, tmp_path: Path) -> No } body = json.dumps(payload, separators=(",", ":"), sort_keys=True).encode("utf-8") + with TestClient(create_app()) as client: + _register_admin_then_user(client, tmp_path, "steady-wisely-boldly-0042") + response = client.post( + "/providers", + content=body, + headers={ + **_auth_header(tmp_path, "POST", "/providers", body=body), + "Content-Type": "application/json", + }, + ) + + assert response.status_code == 403 + assert response.json()["detail"] == "Admin role required" + + +def test_hosted_first_principal_admin_can_register_provider(monkeypatch, tmp_path: Path) -> None: + monkeypatch.setenv("AUTHSOME_HOME", str(tmp_path)) + monkeypatch.setenv("AUTHSOME_DEPLOYMENT_MODE", "hosted") + payload = { + "definition": { + "name": "custom-api", + "display_name": "Custom API", + "auth_type": "api_key", + "flow": "api_key", + "api_key": {"header_name": "Authorization"}, + } + } + body = json.dumps(payload, separators=(",", ":"), sort_keys=True).encode("utf-8") + with TestClient(create_app()) as client: _register_identity(client, tmp_path, "steady-wisely-boldly-0042") response = client.post( @@ -84,6 +116,5 @@ def test_hosted_register_provider_is_rejected(monkeypatch, tmp_path: Path) -> No }, ) - assert response.status_code == 400 - assert response.json()["error"] == "OperationNotAllowedError" - assert response.json()["operation"] == "register" + assert response.status_code == 200 + assert response.json()["status"] == "ok" diff --git a/tests/server/test_ui_dashboard.py b/tests/server/test_ui_dashboard.py index 002cf604..3794a0c3 100644 --- a/tests/server/test_ui_dashboard.py +++ b/tests/server/test_ui_dashboard.py @@ -444,10 +444,6 @@ def test_hosted_admin_provider_configure_route_opens_edit_flow(monkeypatch, tmp_ assert registered.status_code == 303 assert client.post(f"{claim_path}/confirm", follow_redirects=False).status_code == 303 - principal_id = asyncio.run( - client.app.state.ownership_resolver.resolve(identity="steady-wisely-boldly-0042") - ).principal_id - monkeypatch.setenv("AUTHSOME_ADMIN_PRINCIPALS", principal_id) _seed_provider_client(client, provider="github", client_id="cid-123", client_secret="secret-123") response = client.post("/apps/github/configure", follow_redirects=False) diff --git a/tests/server/test_ui_sessions.py b/tests/server/test_ui_sessions.py index 7406b89a..66b77efe 100644 --- a/tests/server/test_ui_sessions.py +++ b/tests/server/test_ui_sessions.py @@ -177,6 +177,7 @@ def test_hosted_ui_hides_server_managed_oauth_client_details(monkeypatch, tmp_pa monkeypatch.setenv("AUTHSOME_DEPLOYMENT_MODE", "hosted") with TestClient(create_app()) as client: + _claim_identity_via_hosted_ui(client, tmp_path, "admin-ready-boldly-0001", "admin@example.com") _claim_identity_via_hosted_ui(client, tmp_path, "steady-wisely-boldly-0042", "dev@example.com") vault = client.app.state.vault key = build_store_key(provider="github", record_type="server") @@ -197,10 +198,6 @@ def test_hosted_admin_ui_shows_provider_client_details(monkeypatch, tmp_path: Pa with TestClient(create_app()) as client: _claim_identity_via_hosted_ui(client, tmp_path, "steady-wisely-boldly-0042", "dev@example.com") - principal_id = asyncio.run( - client.app.state.ownership_resolver.resolve(identity="steady-wisely-boldly-0042") - ).principal_id - monkeypatch.setenv("AUTHSOME_ADMIN_PRINCIPALS", principal_id) _seed_provider_client(client, provider="github", client_id="cid-123", client_secret="top-secret") response = client.get("/apps/github") diff --git a/uv.lock b/uv.lock index e21c325a..6d15ab1f 100644 --- a/uv.lock +++ b/uv.lock @@ -177,6 +177,8 @@ dependencies = [ { name = "keyring" }, { name = "loguru" }, { name = "mitmproxy" }, + { name = "opentelemetry-api" }, + { name = "opentelemetry-sdk" }, { name = "posthog" }, { name = "py-key-value-aio", extra = ["disk"] }, { name = "pydantic" }, @@ -212,6 +214,8 @@ requires-dist = [ { name = "keyring", specifier = ">=24.0" }, { name = "loguru", specifier = ">=0.7" }, { name = "mitmproxy", specifier = ">=11.0" }, + { name = "opentelemetry-api", specifier = ">=1.42.1" }, + { name = "opentelemetry-sdk", specifier = ">=1.42.1" }, { name = "posthog", specifier = ">=3.0" }, { name = "pre-commit", marker = "extra == 'dev'", specifier = ">=4.6.0" }, { name = "py-key-value-aio", extras = ["disk"] }, @@ -1133,6 +1137,45 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/88/b2/d0896bdcdc8d28a7fc5717c305f1a861c26e18c05047949fb371034d98bd/nodeenv-1.10.0-py2.py3-none-any.whl", hash = "sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827", size = 23438, upload-time = "2025-12-20T14:08:52.782Z" }, ] +[[package]] +name = "opentelemetry-api" +version = "1.42.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b4/1c/125e1c936c0873796771b7f04f6c93b9f1bf5d424cea90fda94a99f61da8/opentelemetry_api-1.42.1.tar.gz", hash = "sha256:56c63bea9f77b62856be8c47600474acad853b2924b99b1687c4cb6297166716", size = 72296, upload-time = "2026-05-21T16:32:49.335Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a3/ca/9520cc1f3dfbbd03ac5903bbf55833e257bc64b1cf30fa8b0d6df374d821/opentelemetry_api-1.42.1-py3-none-any.whl", hash = "sha256:51a69edacadbc03a8950ace1c4c21099cacc538820ac2c9e36277e78cebba714", size = 61311, upload-time = "2026-05-21T16:32:28.822Z" }, +] + +[[package]] +name = "opentelemetry-sdk" +version = "1.42.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-api" }, + { name = "opentelemetry-semantic-conventions" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/40/f7/b390bd9bfd703bf98a68fea1f27786c6872331fd617164a54b8a59bdc008/opentelemetry_sdk-1.42.1.tar.gz", hash = "sha256:8c834e8f8c9ba4171d4ec843d0cb8a67e4c7394d3f9e9297e582cbd9456ddbf7", size = 239262, upload-time = "2026-05-21T16:33:04.641Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8f/6b/4287766cfbde577ae2272e8884abac325aeaac0d64f41c61d5b8cc595105/opentelemetry_sdk-1.42.1-py3-none-any.whl", hash = "sha256:083cd4bbfaa5aa7b5a9e552430d9951219967cfb27aa61feb13a77aba1fc839d", size = 170907, upload-time = "2026-05-21T16:32:45.894Z" }, +] + +[[package]] +name = "opentelemetry-semantic-conventions" +version = "0.63b1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-api" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/93/99/4d7dd6df64795951413ce6e815f8cf1eb191daf7196ae86574589643d5f3/opentelemetry_semantic_conventions-0.63b1.tar.gz", hash = "sha256:3daf963611334b365e98a57438183eb012d3bfb40b2d931a9af613476b8701a9", size = 148340, upload-time = "2026-05-21T16:33:05.455Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/7a/7fe66f5f3682b1dd47d88cc4e11f1c6c0966b737de2d16671146e23c39a5/opentelemetry_semantic_conventions-0.63b1-py3-none-any.whl", hash = "sha256:dfe5ef4dee82586b746f522b818ceb298d00b3d59f660042bd79404bff8d0682", size = 203713, upload-time = "2026-05-21T16:32:47.016Z" }, +] + [[package]] name = "packaging" version = "26.2"