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
109 changes: 105 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,17 @@ campus grant list
campus grant grant --grantee-type user --grantee-id someone@school.edu \
--resource-type users --level write

# campus.audit: mint a student's trace-reading key (operator, #54)
CAMPUS_AUDIT_API_KEY=<operator key> campus audit login
campus audit keys new --name "Alice" --owner alice@school.edu \
--scope traces:read --rate-limit 60
campus audit keys list --owner alice@school.edu

# …and the queries that key unlocks (from any machine the student logs in on)
campus audit traces list --limit 10
campus audit traces search --path /auth/v1/users/ --status 500
campus audit traces get --trace-id <32-char hex id>

# List vault entries
campus vault list --vault myvault

Expand Down Expand Up @@ -128,11 +139,88 @@ section of the campus login-flow doc, and the campus
(A2 no silent widening, A5 supersession-is-deletion, A8 management
scopes never self-confer).

### campus.audit: API-key auth (#54)

The audit service (`/audit/v1`) is a separate, user-blind deployment
that does **not** accept OAuth tokens: it authenticates with its own
API keys (`audit_v1_…`) sent as Bearer credentials. `campus audit` is
the CLI's only command group using this second credential, stored in
the same credential store as your OAuth tokens and bound to the audit
URL it was stored for (per `ENV`/`CAMPUS_ENV` or `CAMPUS_AUDIT_URL`,
like every other endpoint):

```bash
# Store a key: the CAMPUS_AUDIT_API_KEY env var, an interactive prompt
# (hidden input), or --api-key. Prefer the first two: --api-key lands
# the key in your shell history and in the process list. The key is
# verified against the service first (--skip-verify stores it without
# the round trip)
CAMPUS_AUDIT_API_KEY=audit_v1_… campus audit login
campus audit login # prompts for the key
campus audit login --api-key audit_v1_…

# …and the login state is cleared locally by:
campus audit logout

# What does this install hold? A purely offline local view (no network
# call): whether a key is stored, the audit URL it is bound to, the URL
# the CLI currently targets (a mismatch is called out the way the
# commands' guard does), and when the key was stored. Exit 1 when no
# key is stored. --json emits the same fields for scripts.
campus audit status
```

`--json` in this group prints the server response verbatim, so every
command has one shape: list envelopes keep their `count`
(`keys list`) and pagination `cursor` (`traces list/search`); a
bodyless action such as `keys revoke` reports `{"id": …, "revoked":
true}`.

Two roles use the group:

- **The operator** holds the key with `apikeys:*` scopes (the one
seeded from the server's `AUDIT_OPERATOR_API_KEY`) and administers
everyone else's keys — mint, inspect, update, rotate, revoke:

```bash
campus audit keys new --name "Alice" --owner alice@school.edu \
--scope traces:read --rate-limit 60 --expires-at 2026-12-31T23:59:59Z
campus audit keys list --owner alice@school.edu
campus audit keys get --key-id <id>
campus audit keys update --key-id <id> --scope traces:read --rate-limit 120
campus audit keys regenerate --key-id <id> # rotation; old value dies
campus audit keys revoke --key-id <id> # record kept for audit
```

Plaintext key material is shown exactly once, at creation and at
regeneration (the server keeps only the hash) — pass it to the
student, who stores it with `campus audit login`. `owner_id` is a
free-form label used for organization and filtering only (an email
is the convention, but the server accepts any string);
campus.audit is user-blind and self-contained by design, so it
never resolves to a campus user account.

- **Students** log in with their minted key and query traces
(gated on `traces:read`):

```bash
campus audit traces list --limit 10 # recent, newest first
campus audit traces search --path /auth/v1/users/ --status 500
campus audit traces search --user alice@school.edu --since 2026-10-20T00:00:00Z
campus audit traces get --trace-id <32-char hex>
```

List and search paginate: when a page has more, the command prints
the `--cursor` token to continue with. `logout` only clears the
local copy of a key — a leaked key is disabled with
`campus audit keys revoke` (operator) or by letting `--expires-at`
pass.

### Dry-run mode

All `client`, `user` and `vault` commands accept `--dry-run`. Instead of
authenticating and calling the API, the command prints the equivalent
Python snippet using the
All `client`, `user`, `vault` and `audit` commands accept `--dry-run`.
Instead of authenticating and calling the API, the command prints the
equivalent Python snippet using the
[campus-api-python](https://pypi.org/project/campus-api-python/)
library — useful for learning how to call the Campus API from Python:

Expand All @@ -149,10 +237,23 @@ client = campus.auth.clients.new(
print(client.id)
```

`campus audit` snippets open with the audit client instead — the
service takes its own API key, not an OAuth token:

```python
from campus_python import AuditRoot, CampusRequest

campus_audit = AuditRoot(json_client=CampusRequest(
base_url="<audit_url>", timeout=30, mode="device",
))
campus_audit.client.set_bearer_authorization("<audit_api_key>")
```

### Non-interactive use (scripts, CI, agents)

Destructive commands — `auth logout`, `client delete`, `client revoke`,
`user delete`, `vault delete` — confirm before acting. When no terminal is attached
`user delete`, `vault delete`, `audit logout`, `audit keys revoke`,
`audit keys regenerate` — confirm before acting. When no terminal is attached
the CLI never blocks waiting for input: it refuses immediately with an
error naming the remedy. Waive the confirmation per command with
`--confirm`/`-y`, or for a whole session with the `CAMPUS_ASSUME_YES`
Expand Down
27 changes: 27 additions & 0 deletions campus_cli/audit/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
"""campus.audit commands (#54).

The audit service (/audit/v1) is a separate, user-blind deployment
authenticating with its own API keys (`audit_v1_...`, Bearer) rather
than OAuth user tokens. The command group mirrors its two
capabilities: the operator's key lifecycle (campus audit keys) and
read-only trace queries (campus audit traces), plus login/logout and
the offline status view over the stored key — the CLI's only
non-OAuth credential.
"""

import typer

from campus_cli.audit.keys import keys_app
from campus_cli.audit.login import login_cmd, logout_cmd, status_cmd
from campus_cli.audit.traces import traces_app

audit_app = typer.Typer(help="Campus Audit service commands (API-key auth)")

# login/logout/status attach directly; keys and traces are sub-groups.
audit_app.command("login")(login_cmd)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Open — #62. campus auth status (#45) answers "what can this stored credential do?", and the audit group has no equivalent: a trace-only student key cannot run campus audit keys list (it lacks apikeys:read), so the stored key's state is only visible when a command fails. The audit service has no "who am I" endpoint, so this would have to be a local view — the URL the key is bound to, the URL currently targeted, and whether a key is stored. The information the URL-binding check already uses would be worth surfacing.

audit_app.command("logout")(logout_cmd)
audit_app.command("status")(status_cmd)
audit_app.add_typer(keys_app, name="keys", help="Audit API key lifecycle (operator)")
audit_app.add_typer(traces_app, name="traces", help="Trace queries (read-only)")

__all__ = ["audit_app"]
235 changes: 235 additions & 0 deletions campus_cli/audit/client.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,235 @@
"""Shared plumbing for campus.audit commands (#54).

The audit service (/audit/v1) authenticates with one of its own API
keys (`audit_v1_...`) sent as a Bearer token — OAuth user tokens are
not accepted — so it gets its own credential slot and client here,
parallel to the OAuth machinery in campus_cli.auth. Like the OAuth
tokens, the stored key is bound to the service URL it was stored
against and rejected on a target mismatch.
"""

import typer

from campus_cli.config import config
from campus_cli.credentials import CredentialError, credentials
from campus_cli.utils.output import print_error

# Key material of the campus.audit service is minted with this prefix;
# anything else is almost certainly a token or a paste error.
AUDIT_KEY_PREFIX = "audit_v1_"


def resolve_audit_url() -> str:
"""Resolve the audit service base URL the CLI currently targets.

Exits with a clean, actionable error when ENV/CAMPUS_ENV is invalid,
instead of letting config.audit_url's ValueError escape as a
traceback from whichever command touched it first.

Returns:
The targeted audit service base URL (no /audit/v1 prefix; the
resource root carries it).
"""
try:
return config.audit_url
except ValueError as e:
print_error(str(e))
raise typer.Exit(1) from e


def _stored_audit_key_url() -> str | None:
"""Read the audit URL the stored key was bound to.

Raises:
typer.Exit: If the credential store cannot be read (locked
keyring, unreadable fallback file) — the store
failure is reported, not raised as a traceback.
"""
try:
return credentials.get_audit_key_url()
except CredentialError as e:
print_error(f"Failed to read the stored audit API key: {e}")
raise typer.Exit(1) from e


def audit_endpoint_mismatch() -> tuple[str, str] | None:
"""
Check whether the stored audit key was stored for the targeted service URL.

Returns:
(stored_url, current_url) on mismatch, None otherwise. Keys
stored before URL binding (no stored URL) are treated as
matching; they bind at the next 'campus audit login'.
"""
stored = _stored_audit_key_url()
if not stored:
return None
current = resolve_audit_url()
if stored.rstrip("/") != current.rstrip("/"):
return (stored, current)
return None


def mismatch_message(stored: str | None, current: str | None) -> str | None:
"""The standing binding-mismatch sentence, or None when URLs match.

Shared by the command guard and 'campus audit status' (#62) so the
two word the problem identically.
"""
if not stored or not current:
return None
if stored.rstrip("/") == current.rstrip("/"):
return None
return (
f"Stored audit API key was stored for {stored} but the CLI is "
f"targeting {current}. Re-run 'campus audit login' against "
f"{current}."
)


def ensure_audit_endpoint_match() -> None:
"""
Fail fast if the stored audit key targets a different service URL.

Raises:
typer.Exit: If the stored key's audit URL differs from the one
the CLI currently targets.
"""
mismatch = audit_endpoint_mismatch()
if mismatch is None:
return
stored, current = mismatch
print_error(mismatch_message(stored, current))
raise typer.Exit(1)


def get_audit_client():
"""
Get an authenticated campus.audit client (#54).

Returns:
A campus_python AuditRoot authenticating with the stored key.

Raises:
typer.Exit: If no key is stored, or it targets another URL.
"""
try:
api_key = credentials.get_audit_api_key()
except CredentialError as e:
print_error(f"Failed to read the stored audit API key: {e}")
raise typer.Exit(1) from e
if not api_key:
print_error(
"Not authenticated with campus.audit. Run 'campus audit login'"
" (--api-key, or set CAMPUS_AUDIT_API_KEY) first."
)
raise typer.Exit(1)

ensure_audit_endpoint_match()

try:
from campus_python import AuditRoot, CampusRequest

client = CampusRequest(
base_url=resolve_audit_url(), timeout=30, mode="device"
)
client.set_bearer_authorization(api_key)
return AuditRoot(json_client=client)
except ImportError:
print_error("campus-api-python library not available.")
raise typer.Exit(1) from None


def _render_field_errors(e: Exception) -> str:
"""Render the server's field-level validation errors, when present.

The server's 422 body carries an errors array (field, code,
message) that campus_python parses into APIError.errors; without
this a rejected 'keys new' would report only "One or more fields
are invalid" with no indication of which field was rejected.
"""
parts = []
for field_error in getattr(e, "errors", None) or []:
piece = str(getattr(field_error, "field", "?"))
code = getattr(field_error, "code", None)
message = getattr(field_error, "message", None)
if code:
piece += f" ({code}"
if message:
piece += f": {message}"
piece += ")"
elif message:
piece += f": {message}"
parts.append(piece)
return "; ".join(parts)


def api_error_details(e: Exception) -> tuple[int | None, str]:
"""Best-effort (HTTP status, human message) from a failed audit call.

campus_python maps non-2xx responses to errors.APIError carrying
the status code and the server's error_description; raw transport
failures (requests.HTTPError or connection errors) keep their
exception text, with any structured Campus error body unpacked.

A non-2xx failure with no description (a proxy's HTML 502, an
empty body, a misrouted 404 page) falls back to "HTTP <status>" —
the status is the one breadcrumb an operator can act on. The 422
field-level errors array, when present, is appended so a rejected
payload names the offending field.
"""
status = getattr(e, "status_code", None)
description = getattr(e, "error_description", None)
if status is None:
response = getattr(e, "response", None)
status = getattr(response, "status_code", None)
try:
error_obj = response.json().get("error", "")
except (ValueError, AttributeError):
error_obj = ""
if isinstance(error_obj, dict):
description = error_obj.get("message") or error_obj.get("code")
elif error_obj:
description = error_obj

if description:
message = str(description)
elif status is not None:
message = f"HTTP {status}"
else:
message = str(e)

fields = _render_field_errors(e)
if fields:
message = f"{message} (fields: {fields})"
return status, message


def api_error_message(e: Exception) -> str:
"""Extract a human-readable message from a failed audit API call."""
return api_error_details(e)[1]


def fail_api(action: str, e: Exception) -> None:
"""Report a failed audit API call and exit 1.

Appends the standing remedy for the two rejections the operator can
act on without reading the docs: a rejected key (re-login) and a
scope shortfall (mint or widen the key).
"""
status, message = api_error_details(e)
hint = ""
if status == 401:
hint = " Re-run 'campus audit login' with a valid key."
elif status == 403:
hint = (
" The stored key lacks the required scope;"
" mint or update it with 'campus audit keys'."
)
# The server's message may already be a sentence: don't double the
# full stop when appending the remedy hint.
detail = message.strip()
if detail and not detail.endswith((".", "!", "?")):
detail += "."
print_error(f"Failed to {action}: {detail}{hint}")
raise typer.Exit(1) from e
Loading
Loading