diff --git a/README.md b/README.md index a8c6f4e..dd86d02 100644 --- a/README.md +++ b/README.md @@ -152,16 +152,16 @@ client.auth.client.set_bearer_authorization(access_token) ## Service Base URLs -Each service client (`campus.auth`, `campus.api`) resolves its base URL in this order: +Each service client (`campus.auth`, `campus.api`, `campus.audit`) resolves its base URL in this order: -1. **Explicit URL config** — the `CAMPUS_AUTH_URL` / `CAMPUS_API_URL` environment variable, if set. Use this for local testing deployments and custom endpoints (e.g. `CAMPUS_AUTH_URL=http://localhost:5000`). +1. **Explicit URL config** — the `CAMPUS_AUTH_URL` / `CAMPUS_API_URL` / `CAMPUS_AUDIT_URL` environment variable, if set. Use this for local testing deployments and custom endpoints (e.g. `CAMPUS_AUTH_URL=http://localhost:5000`). 2. **ENV-based defaults** — selected by `ENV` (or `CAMPUS_ENV`): -| ENV value | auth | api | -|-----------|------|-----| -| `development` (default) | `https://campusauth-development.up.railway.app` | `https://campusapi-development.up.railway.app` | -| `staging` | `https://auth.campus.nyjc.dev` | `https://api.campus.nyjc.dev` | -| `production` | `https://auth.campus.nyjc.app` | `https://api.campus.nyjc.app` | +| ENV value | auth | api | audit | +|-----------|------|-----|-------| +| `development` (default) | `https://campusauth-development.up.railway.app` | `https://campusapi-development.up.railway.app` | `https://campusaudit-development.up.railway.app` | +| `staging` | `https://auth.campus.nyjc.dev` | `https://api.campus.nyjc.dev` | `https://audit.campus.nyjc.dev` | +| `production` | `https://auth.campus.nyjc.app` | `https://api.campus.nyjc.app` | `https://audit.campus.nyjc.app` | > **Deprecated (issue #52):** base URLs are no longer derived from the `HOSTNAME` environment variable. Deployments that relied on the `DEPLOY` service suffix (e.g. `campus.auth`) or `ENV=testing` to produce `https://{HOSTNAME}` URLs now get a `DeprecationWarning` and the ENV-based default instead — set `CAMPUS_AUTH_URL` / `CAMPUS_API_URL` explicitly to point the client at those deployments. @@ -173,6 +173,8 @@ Each service client (`campus.auth`, `campus.api`) resolves its base URL in this | `CLIENT_SECRET` | Yes | Server | OAuth client secret from Campus auth | | `CAMPUS_AUTH_URL` | No | All | Auth service base URL (overrides ENV default) | | `CAMPUS_API_URL` | No | All | API service base URL (overrides ENV default) | +| `CAMPUS_AUDIT_URL` | No | All | Audit service base URL (overrides ENV default) | +| `AUDIT_API_KEY` | Yes (audit) | All | Audit service API key (`audit_v1_...`); sent as the audit root's Bearer token | | `ENV` / `CAMPUS_ENV` | No | All | Deployment environment selecting default URLs: `development` (default), `staging`, `production` | ## Development diff --git a/campus_python/__init__.py b/campus_python/__init__.py index fc5e101..96b4424 100644 --- a/campus_python/__init__.py +++ b/campus_python/__init__.py @@ -18,6 +18,7 @@ from . import errors from .api.v1 import ApiRoot +from .audit.v1 import AuditRoot from .auth.v1 import AuthRoot from .integrations.v1 import IntegrationsRoot from .json_client import CampusRequest @@ -28,6 +29,7 @@ # Development Railway deployments, used when no explicit URL is configured AUTH_DEVELOPMENT_URL = "https://campusauth-development.up.railway.app" API_DEVELOPMENT_URL = "https://campusapi-development.up.railway.app" +AUDIT_DEVELOPMENT_URL = "https://campusaudit-development.up.railway.app" def _resolve_base_url(service: str, url_var: str, development_url: str) -> str: @@ -80,8 +82,10 @@ class Campus: - mode="device": For public clients (e.g., CLI) that don't have secrets. No credentials required; only public OAuth endpoints are accessible. - Service base URLs are resolved per service (auth, api) in this order: - 1. Explicit URL config: CAMPUS_AUTH_URL / CAMPUS_API_URL env vars. + Service base URLs are resolved per service (auth, api, audit) in + this order: + 1. Explicit URL config: CAMPUS_AUTH_URL / CAMPUS_API_URL / + CAMPUS_AUDIT_URL env vars. 2. ENV/CAMPUS_ENV defaults: development (Railway), staging, production. See the API Reference for usage examples. @@ -139,6 +143,30 @@ def api(self) -> ApiRoot: ) return self._api + @property + def audit(self) -> AuditRoot: + """Get the audit service resource. + + The audit service authenticates with one of its own API keys + (AUDIT_API_KEY env var, an `audit_v1_...` value) sent as a + Bearer token — CLIENT_ID/CLIENT_SECRET are not accepted — so + this root carries its own JsonClient, created in device mode + to avoid requiring app credentials. + """ + if not hasattr(self, "_audit"): + base_url = _resolve_base_url( + "audit", "CAMPUS_AUDIT_URL", AUDIT_DEVELOPMENT_URL + ) + client = CampusRequest( + base_url=base_url, + timeout=self.timeout, + mode="device", + ) + env.require("AUDIT_API_KEY") + client.set_bearer_authorization(env.get("AUDIT_API_KEY")) + self._audit = AuditRoot(json_client=client) + return self._audit + @property def integrations(self) -> IntegrationsRoot: """Get the integrations registry resource (auth service). diff --git a/campus_python/audit/v1/__init__.py b/campus_python/audit/v1/__init__.py new file mode 100644 index 0000000..4d29ea3 --- /dev/null +++ b/campus_python/audit/v1/__init__.py @@ -0,0 +1,37 @@ +"""campus.python.audit.v1 + +Campus Audit service resource root (/audit/v1). + +The audit service authenticates with a dedicated audit API key +(`audit_v1_...`) sent as a Bearer token — not CLIENT_ID/CLIENT_SECRET +— so its resource root gets its own JsonClient rather than sharing the +auth/api one. +""" + +from ...interface import ResourceRoot +from ...json_client.interface import JsonClient +from . import apikeys, traces + + +class AuditRoot(ResourceRoot): + """Campus Audit service resource.""" + url_prefix = "/audit/v1" + + def __init__(self, json_client: JsonClient): + super().__init__(json_client=json_client) + self._apikeys = None + self._traces = None + + @property + def apikeys(self) -> apikeys.APIKeys: + """Get the API keys resource.""" + if not self._apikeys: + self._apikeys = apikeys.APIKeys(root=self) + return self._apikeys + + @property + def traces(self) -> traces.Traces: + """Get the traces resource.""" + if not self._traces: + self._traces = traces.Traces(root=self) + return self._traces diff --git a/campus_python/audit/v1/apikeys.py b/campus_python/audit/v1/apikeys.py new file mode 100644 index 0000000..9480df5 --- /dev/null +++ b/campus_python/audit/v1/apikeys.py @@ -0,0 +1,147 @@ +"""campus.python.audit.v1.apikeys + +Campus Audit API keys resource (v1). + +API keys are the audit service's own auth material (audit_v1_...), +managed here: create, list, inspect, update, revoke, regenerate. +Plaintext key values are returned exactly once, at creation and at +regeneration. +""" + +from ...interface import JsonDict, Resource, ResourceCollection + + +class APIKeys(ResourceCollection): + """Campus Audit API keys resource.""" + path = "apikeys/" + + def __getitem__(self, api_key_id: str) -> "APIKeys.APIKey": + """Get a specific API key resource by ID.""" + return APIKeys.APIKey(api_key_id, parent=self) + + def new( + self, + *, + name: str, + owner_id: str, + scopes: "list[str]", + rate_limit: "int | None" = None, + expires_at: "str | None" = None, + ) -> JsonDict: + """Create an API key. + + Args: + name: Key name + owner_id: Owner user ID + scopes: Scope strings the key grants (e.g. ["traces:read"]) + rate_limit: Requests per minute (optional) + expires_at: ISO 8601 expiry (optional) + + Returns: + The created key record; its plaintext `api_key` value is + shown only here. + """ + payload: JsonDict = { + "name": name, + "owner_id": owner_id, + "scopes": scopes, + } + if rate_limit is not None: + payload["rate_limit"] = rate_limit + if expires_at is not None: + payload["expires_at"] = expires_at + resp = self.client.post(self.make_path(), json=payload) + resp.raise_for_status() + return resp.json() + + def list( + self, + *, + owner_id: "str | None" = None, + active_only: bool = True, + limit: "int | None" = None, + ) -> JsonDict: + """List API keys with optional filtering. + + Args: + owner_id: Filter by owner user ID + active_only: Only non-expired, non-revoked keys (default True) + limit: Page size (server default 50) + + Returns: + {"api_keys": [...], "count": int} + """ + query: JsonDict = {} + if owner_id is not None: + query["owner_id"] = owner_id + # The server parses bool query values from the lowercase + # literals true/false (flask_campus _BOOL_LITERALS); Python's + # str(True) capitalization would 422. + query["active_only"] = str(active_only).lower() + if limit is not None: + query["limit"] = limit + resp = self.client.get(self.make_path(), query=query) + resp.raise_for_status() + return resp.json() + + class APIKey(Resource): + """Single campus audit API key resource.""" + + def get(self) -> JsonDict: + """Get this API key's record (no key hash or plaintext).""" + resp = self.client.get(self.make_path(end_slash=True)) + resp.raise_for_status() + return resp.json() + + def update( + self, + *, + name: "str | None" = None, + scopes: "list[str] | None" = None, + rate_limit: "int | None" = None, + ) -> JsonDict: + """Update this API key's mutable fields. + + Only name, scopes, and rate_limit are mutable; use + revoke() to disable the key. The server rejects an empty + update, so at least one field must be provided. + + Returns: + The updated key record + """ + payload: JsonDict = {} + if name is not None: + payload["name"] = name + if scopes is not None: + payload["scopes"] = scopes + if rate_limit is not None: + payload["rate_limit"] = rate_limit + if not payload: + raise ValueError( + "At least one field must be provided for update" + ) + resp = self.client.patch( + self.make_path(end_slash=True), json=payload + ) + resp.raise_for_status() + return resp.json() + + def revoke(self) -> None: + """Revoke this API key (the record is kept for audit).""" + resp = self.client.delete(self.make_path(end_slash=True)) + resp.raise_for_status() + return None + + def regenerate(self) -> str: + """Regenerate this API key's value. + + The old value stops working immediately. + + Returns: + The new plaintext key value, shown only here. + """ + resp = self.client.post( + self.make_path("regenerate", end_slash=False) + ) + resp.raise_for_status() + return resp.json()["key"] diff --git a/campus_python/audit/v1/traces.py b/campus_python/audit/v1/traces.py new file mode 100644 index 0000000..39a3a16 --- /dev/null +++ b/campus_python/audit/v1/traces.py @@ -0,0 +1,159 @@ +"""campus.python.audit.v1.traces + +Campus Audit traces resource (v1). + +Span ingestion and trace queries. Traces are addressed by their 32-char +hex trace ID and spans by their 16-char hex span ID. +""" + +from typing import Any + +from ...interface import JsonDict, Resource, ResourceCollection + + +class Traces(ResourceCollection): + """Campus Audit traces resource.""" + path = "traces/" + + def __getitem__(self, trace_id: str) -> "Traces.Trace": + """Get a specific trace resource by ID.""" + return Traces.Trace(trace_id, parent=self) + + def ingest(self, spans: "list[dict[str, Any]]") -> JsonDict: + """Ingest a batch of trace spans. + + Args: + spans: Span dicts shaped like the server's TraceSpan + resource (trace_id, span_id, method, path, ...) + + Returns: + 201 full success: {"created": [...]}; 207 partial failure + adds {"failed": [...]} with per-span statuses. + """ + resp = self.client.post(self.make_path(), json={"spans": spans}) + resp.raise_for_status() + return resp.json() + + def list( + self, + *, + since: "str | None" = None, + until: "str | None" = None, + limit: "int | None" = None, + cursor: "str | None" = None, + ) -> JsonDict: + """List recent traces, newest first. + + Args: + since: ISO 8601 lower bound + until: ISO 8601 upper bound + limit: Page size (server clamps to its max) + cursor: Opaque token from a previous page's cursor.next + + Returns: + {"traces": [...], "cursor": {"next": ..., "has_more": bool}} + """ + query: JsonDict = {} + if since is not None: + query["since"] = since + if until is not None: + query["until"] = until + if limit is not None: + query["limit"] = limit + if cursor is not None: + query["cursor"] = cursor + resp = self.client.get( + self.make_path(), query=query or None + ) + resp.raise_for_status() + return resp.json() + + def search( + self, + *, + path: "str | None" = None, + status: "int | str | None" = None, + api_key_id: "str | None" = None, + client_id: "str | None" = None, + user_id: "str | None" = None, + since: "str | None" = None, + until: "str | None" = None, + limit: "int | None" = None, + cursor: "str | None" = None, + ) -> JsonDict: + """Filter and search traces. + + Args: + path: Filter by endpoint path + status: Filter by HTTP status code + api_key_id: Filter by audit API key + client_id: Filter by OAuth client + user_id: Filter by user + since, until, limit, cursor: As in list() + + Returns: + {"traces": [...], "cursor": {"next": ..., "has_more": bool}} + """ + query: JsonDict = {} + if path is not None: + query["path"] = path + if status is not None: + query["status"] = status + if api_key_id is not None: + query["api_key_id"] = api_key_id + if client_id is not None: + query["client_id"] = client_id + if user_id is not None: + query["user_id"] = user_id + if since is not None: + query["since"] = since + if until is not None: + query["until"] = until + if limit is not None: + query["limit"] = limit + if cursor is not None: + query["cursor"] = cursor + resp = self.client.get( + self.make_path("search", end_slash=False), query=query or None + ) + resp.raise_for_status() + return resp.json() + + class Trace(Resource): + """Single campus audit trace resource.""" + + @property + def spans(self) -> "Traces.Trace.Spans": + """Get the spans resource for this trace.""" + return Traces.Trace.Spans("spans", parent=self) + + def get(self) -> JsonDict: + """Get the full trace tree. + + Returns: + {"trace_id": ..., "root_span": } + """ + resp = self.client.get(self.make_path(end_slash=True)) + resp.raise_for_status() + return resp.json() + + class Spans(Resource): + """Spans of a single trace.""" + + def __getitem__(self, span_id: str) -> "Traces.Trace.Span": + """Get a specific span resource by ID.""" + return Traces.Trace.Span(span_id, parent=self) + + def list(self) -> "list[JsonDict]": + """List all spans in this trace (flat).""" + resp = self.client.get(self.make_path(end_slash=True)) + resp.raise_for_status() + return resp.json()["spans"] + + class Span(Resource): + """Single span of a trace, with full request/response detail.""" + + def get(self) -> JsonDict: + resp = self.client.get(self.make_path(end_slash=True)) + resp.raise_for_status() + return resp.json() diff --git a/tests/unit/test_audit.py b/tests/unit/test_audit.py new file mode 100644 index 0000000..ad76110 --- /dev/null +++ b/tests/unit/test_audit.py @@ -0,0 +1,206 @@ +"""Contract tests for the audit service client (issue #77). + +Routes mirror campus/audit/routes/ (campus weekly, via the trailing- +slash create_blueprint registrations under /audit/v1): +- /audit/v1/apikeys/... (collection/item slash rules, regenerate leaf) +- /audit/v1/traces/... (collection slash, /search leaf, spans nesting) + +Bool query values must reach the server as lowercase literals +(flask_campus _BOOL_LITERALS accepts true/false/1/0 only). +""" + +import os +import unittest +from unittest.mock import Mock, patch + +from campus_python import Campus +from campus_python.audit.v1 import AuditRoot + +API_KEY_RESOURCE = { + "id": "akm123", + "created_at": "2026-10-04T00:00:00+00:00", + "name": "ci-ingest", + "owner_id": "user1", + "scopes": "traces:write", + "rate_limit": 100, + "revoked": False, +} + +TRACE_SUMMARY = { + "trace_id": "a" * 32, + "root_span_id": "b" * 16, + "path": "/api/v1/timetable/", + "status_code": 200, +} + + +def make_audit() -> tuple[AuditRoot, Mock]: + """Create an AuditRoot backed by a mock JSON client.""" + client = Mock() + return AuditRoot(json_client=client), client + + +class TestAPIKeys(unittest.TestCase): + """API key management routes under /audit/v1/apikeys.""" + + def setUp(self): + self.audit, self.client = make_audit() + + def test_new_posts_required_and_optional_fields(self): + self.client.post.return_value.json.return_value = { + **API_KEY_RESOURCE, + "api_key": "audit_v1_abc", + } + record = self.audit.apikeys.new( + name="ci-ingest", + owner_id="user1", + scopes=["traces:write"], + rate_limit=100, + ) + self.client.post.assert_called_once_with( + "/audit/v1/apikeys/", + json={ + "name": "ci-ingest", + "owner_id": "user1", + "scopes": ["traces:write"], + "rate_limit": 100, + }, + ) + self.assertEqual(record["api_key"], "audit_v1_abc") + + def test_list_sends_lowercase_active_only(self): + self.client.get.return_value.json.return_value = { + "api_keys": [API_KEY_RESOURCE], "count": 1 + } + result = self.audit.apikeys.list(owner_id="user1", active_only=False) + self.client.get.assert_called_once_with( + "/audit/v1/apikeys/", + query={"owner_id": "user1", "active_only": "false"}, + ) + self.assertEqual(result["count"], 1) + + def test_get_uses_trailing_slash(self): + self.client.get.return_value.json.return_value = API_KEY_RESOURCE + self.audit.apikeys["akm123"].get() + self.client.get.assert_called_once_with("/audit/v1/apikeys/akm123/") + + def test_update_requires_a_field(self): + with self.assertRaises(ValueError): + self.audit.apikeys["akm123"].update() + self.client.patch.assert_not_called() + + def test_update_patches_mutable_fields(self): + self.client.patch.return_value.json.return_value = API_KEY_RESOURCE + self.audit.apikeys["akm123"].update(scopes=["traces:read"]) + self.client.patch.assert_called_once_with( + "/audit/v1/apikeys/akm123/", + json={"scopes": ["traces:read"]}, + ) + + def test_revoke_deletes_trailing_slash(self): + self.audit.apikeys["akm123"].revoke() + self.client.delete.assert_called_once_with("/audit/v1/apikeys/akm123/") + + def test_regenerate_returns_new_plaintext(self): + self.client.post.return_value.json.return_value = { + "key": "audit_v1_new" + } + key = self.audit.apikeys["akm123"].regenerate() + self.client.post.assert_called_once_with( + "/audit/v1/apikeys/akm123/regenerate" + ) + self.assertEqual(key, "audit_v1_new") + + +class TestTraces(unittest.TestCase): + """Trace ingest/query routes under /audit/v1/traces.""" + + def setUp(self): + self.audit, self.client = make_audit() + + def test_ingest_wraps_spans(self): + self.client.post.return_value.json.return_value = { + "created": ["b" * 16] + } + spans = [{"trace_id": "a" * 32, "span_id": "b" * 16}] + result = self.audit.traces.ingest(spans) + self.client.post.assert_called_once_with( + "/audit/v1/traces/", json={"spans": spans} + ) + self.assertEqual(result["created"], ["b" * 16]) + + def test_list_passes_pagination_query(self): + self.client.get.return_value.json.return_value = { + "traces": [TRACE_SUMMARY], + "cursor": {"next": "cur2", "has_more": True}, + } + result = self.audit.traces.list(since="2026-10-01T00:00:00Z", limit=10) + self.client.get.assert_called_once_with( + "/audit/v1/traces/", + query={"since": "2026-10-01T00:00:00Z", "limit": 10}, + ) + self.assertTrue(result["cursor"]["has_more"]) + + def test_search_gets_leaf_route_with_filters(self): + self.client.get.return_value.json.return_value = { + "traces": [], "cursor": {"next": None, "has_more": False} + } + self.audit.traces.search(path="/api/v1/timetable/", status=404) + self.client.get.assert_called_once_with( + "/audit/v1/traces/search", + query={"path": "/api/v1/timetable/", "status": 404}, + ) + + def test_trace_get_returns_tree_envelope(self): + self.client.get.return_value.json.return_value = { + "trace_id": "a" * 32, + "root_span": {"span_id": "b" * 16, "children": []}, + } + result = self.audit.traces["a" * 32].get() + self.client.get.assert_called_once_with(f"/audit/v1/traces/{'a' * 32}/") + self.assertIn("root_span", result) + + def test_spans_list_uses_trailing_slash(self): + self.client.get.return_value.json.return_value = {"spans": []} + self.audit.traces["a" * 32].spans.list() + self.client.get.assert_called_once_with( + f"/audit/v1/traces/{'a' * 32}/spans/" + ) + + def test_span_get_uses_trailing_slash(self): + self.client.get.return_value.json.return_value = {"span_id": "b" * 16} + self.audit.traces["a" * 32].spans["b" * 16].get() + self.client.get.assert_called_once_with( + f"/audit/v1/traces/{'a' * 32}/spans/{'b' * 16}/" + ) + + +class TestCampusAuditWiring(unittest.TestCase): + """Campus.audit resolves its own URL and Bearer-auths the API key.""" + + def test_audit_root_uses_audit_url_and_key(self): + with patch.dict("os.environ", { + "CAMPUS_AUDIT_URL": "https://audit.example.test", + "AUDIT_API_KEY": "audit_v1_testkey", + }): + campus = Campus(timeout=5, mode="device") + audit = campus.audit + self.assertEqual(audit.base_url, "https://audit.example.test") + self.assertEqual( + audit.client._session.headers["Authorization"], + "Bearer audit_v1_testkey", + ) + self.assertEqual(audit.url_prefix, "/audit/v1") + + def test_audit_requires_api_key(self): + environ = dict(os.environ) + environ["CAMPUS_AUDIT_URL"] = "https://audit.example.test" + environ.pop("AUDIT_API_KEY", None) + with patch("os.environ", environ): + campus = Campus(timeout=5, mode="device") + with self.assertRaises(OSError): + campus.audit + + +if __name__ == "__main__": + unittest.main()