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
13 changes: 11 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ The Campus API Python library is a comprehensive client for interacting with the
- Support for server-to-server and device authentication modes
- OAuth2 integration with Google Workspace (nyjc.edu.sg domain)
- Comprehensive API coverage for assignments, circles, submissions, and timetable
- Campus Audit client: API keys (mint, scope, rotate, revoke) and traces
- Session management and secure request handling
- Error handling and logging support

Expand Down Expand Up @@ -82,24 +83,32 @@ campus-api-python/
│ ├── __init__.py # Main Campus client class
│ ├── errors.py # Error handling
│ ├── interface.py # Core interfaces
│ ├── tracing.py # Trace context propagation (X-Request-ID)
│ ├── api/
│ │ └── v1/ # API service clients
│ │ ├── assignments.py
│ │ ├── circles.py
│ │ ├── submissions.py
│ │ └── timetable.py
│ ├── audit/
│ │ ├── ratelimit.py # Ingest circuit breaker (campus.audit)
│ │ └── v1/ # Audit service clients
│ │ ├── apikeys.py # Audit API keys (mint, scope, rotate, revoke)
│ │ └── traces.py # Traces (list, search, ingest)
│ ├── auth/
│ │ └── v1/ # Authentication clients
│ │ ├── broker.py
│ │ ├── clients.py
│ │ ├── connections.py
│ │ ├── credentials.py
│ │ ├── grants.py
│ │ ├── logins.py
│ │ ├── oauth.py
│ │ ├── root.py
│ │ ├── sessions.py
│ │ ├── users.py
│ │ └── vaults.py
│ ├── integrations/
│ │ └── v1/ # Integrations registry (auth service, public)
│ ├── integrations/ # Integrations registry (auth service, public)
│ └── json_client/ # JSON client implementation
│ ├── __init__.py
│ └── interface.py
Expand Down
53 changes: 49 additions & 4 deletions campus_python/audit/v1/apikeys.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,32 @@
managed here: create, list, inspect, update, revoke, regenerate.
Plaintext key values are returned exactly once, at creation and at
regeneration.

A key's privilege set may be given as a scope list or as a named role
the service resolves to one (the catalogue is the service's, so it is
not duplicated here).

The service's catalogue (campus#918) currently holds:

traces-viewer traces:read
traces-searcher traces:read, traces:search
traces-producer traces:read, traces:search, traces:write
key-operator apikeys:read, apikeys:write

That list is quoted here for orientation, not as a contract: the
service is the authority, and it refuses an unknown role with the
names of the roles it does have. No role resolves to a wildcard.
"""

from ...interface import JsonDict, Resource, ResourceCollection


class APIKeys(ResourceCollection):
"""Campus Audit API keys resource."""
"""Campus Audit API keys resource.

Every write takes a privilege set: either a scope list or a named
role the service resolves to one.
"""
path = "apikeys/"

def __getitem__(self, api_key_id: str) -> "APIKeys.APIKey":
Expand All @@ -24,16 +43,26 @@ def new(
*,
name: str,
owner_id: str,
scopes: "list[str]",
scopes: "list[str] | None" = None,
role: "str | None" = None,
rate_limit: "int | None" = None,
expires_at: "str | None" = None,
) -> JsonDict:
"""Create an API key.

Give either `scopes` or a named `role` — the service resolves a
role to exactly one scope list. The two are mutually exclusive
server-side: sending both is refused (422) rather than one
silently winning, so this method does not pre-judge them. An
unknown role is likewise refused, with the server naming the
roles that exist — the catalogue is the service's, so it is not
duplicated here.

Args:
name: Key name
owner_id: Owner user ID
scopes: Scope strings the key grants (e.g. ["traces:read"])
role: A named role the service resolves to a scope list
rate_limit: Requests per minute (optional)
expires_at: ISO 8601 expiry (optional)

Expand All @@ -44,8 +73,11 @@ def new(
payload: JsonDict = {
"name": name,
"owner_id": owner_id,
"scopes": scopes,
}
if scopes is not None:
payload["scopes"] = scopes
if role is not None:
payload["role"] = role
if rate_limit is not None:
payload["rate_limit"] = rate_limit
if expires_at is not None:
Expand Down Expand Up @@ -85,7 +117,11 @@ def list(
return resp.json()

class APIKey(Resource):
"""Single campus audit API key resource."""
"""Single campus audit API key resource.

update() takes a privilege set too: a scope list or a named
role.
"""

def get(self) -> JsonDict:
"""Get this API key's record (no key hash or plaintext)."""
Expand All @@ -98,6 +134,7 @@ def update(
*,
name: "str | None" = None,
scopes: "list[str] | None" = None,
role: "str | None" = None,
rate_limit: "int | None" = None,
) -> JsonDict:
"""Update this API key's mutable fields.
Expand All @@ -106,6 +143,12 @@ def update(
revoke() to disable the key. The server rejects an empty
update, so at least one field must be provided.

A `role` re-scopes the key to the scope list the service
resolves that role to. It is mutually exclusive with
`scopes` server-side: sending both is refused (422) rather
than one silently winning, so this method does not
pre-judge them.

Returns:
The updated key record
"""
Expand All @@ -114,6 +157,8 @@ def update(
payload["name"] = name
if scopes is not None:
payload["scopes"] = scopes
if role is not None:
payload["role"] = role
if rate_limit is not None:
payload["rate_limit"] = rate_limit
if not payload:
Expand Down
53 changes: 53 additions & 0 deletions tests/unit/test_audit.py
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,50 @@ def test_new_posts_required_and_optional_fields(self):
)
self.assertEqual(record["api_key"], "audit_v1_abc")

def test_new_posts_a_role_instead_of_scopes(self):
"""A key can be created by named role; the service resolves it."""
self.client.post.return_value.json.return_value = {
**API_KEY_RESOURCE,
"api_key": "audit_v1_abc",
}
self.audit.apikeys.new(
name="alice-key",
owner_id="user1",
role="traces-viewer",
)
self.client.post.assert_called_once_with(
"/audit/v1/apikeys/",
json={
"name": "alice-key",
"owner_id": "user1",
"role": "traces-viewer",
},
)

def test_new_sends_both_role_and_scopes_when_given(self):
"""The SDK does not pre-judge the combination: the server
refuses a role and a scope list together, and that error is
the answer."""
self.client.post.return_value.json.return_value = {
**API_KEY_RESOURCE,
"api_key": "audit_v1_abc",
}
self.audit.apikeys.new(
name="alice-key",
owner_id="user1",
scopes=["traces:read"],
role="traces-viewer",
)
self.client.post.assert_called_once_with(
"/audit/v1/apikeys/",
json={
"name": "alice-key",
"owner_id": "user1",
"scopes": ["traces:read"],
"role": "traces-viewer",
},
)

def test_list_sends_lowercase_active_only(self):
self.client.get.return_value.json.return_value = {
"api_keys": [API_KEY_RESOURCE], "count": 1
Expand Down Expand Up @@ -99,6 +143,15 @@ def test_update_patches_mutable_fields(self):
json={"scopes": ["traces:read"]},
)

def test_update_patches_a_role(self):
"""A key can be re-scoped by named role."""
self.client.patch.return_value.json.return_value = API_KEY_RESOURCE
self.audit.apikeys["akm123"].update(role="traces-searcher")
self.client.patch.assert_called_once_with(
"/audit/v1/apikeys/akm123/",
json={"role": "traces-searcher"},
)

def test_revoke_deletes_trailing_slash(self):
self.audit.apikeys["akm123"].revoke()
self.client.delete.assert_called_once_with("/audit/v1/apikeys/akm123/")
Expand Down
Loading