Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 9 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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
Expand Down
32 changes: 30 additions & 2 deletions campus_python/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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:
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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).
Expand Down
37 changes: 37 additions & 0 deletions campus_python/audit/v1/__init__.py
Original file line number Diff line number Diff line change
@@ -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
147 changes: 147 additions & 0 deletions campus_python/audit/v1/apikeys.py
Original file line number Diff line number Diff line change
@@ -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"]
Loading
Loading