From eb9799c9c3a274b0c94da815509a1b40018ec8ca Mon Sep 17 00:00:00 2001 From: JS Date: Wed, 21 Oct 2026 06:17:13 +0800 Subject: [PATCH 1/2] feat(audit): accept a named role when creating or re-scoping an API key MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The audit service has a named role catalogue for API keys (campus#918, campus PR #935): a role name is accepted wherever a scope list is accepted, and resolves to exactly one scope list. The SDK could not express that — APIKeys.new() required a scope list and there was nowhere to name a role — so every caller hand-assembled the scope list the catalogue exists to avoid. APIKeys.new() and APIKey.update() now take a role, and scopes becomes optional so a key can be created by role alone. The SDK does not validate the role, and does not duplicate the catalogue: the service owns it. An unknown role is refused by the server, which names the roles that exist. A role and a scope list together are refused server-side too (they leave it ambiguous which was meant), so this change sends both rather than silently preferring one — the server's answer is the right one. --- campus_python/audit/v1/apikeys.py | 30 +++++++++++++++-- tests/unit/test_audit.py | 53 +++++++++++++++++++++++++++++++ 2 files changed, 81 insertions(+), 2 deletions(-) diff --git a/campus_python/audit/v1/apikeys.py b/campus_python/audit/v1/apikeys.py index 9480df5..9aaecc5 100644 --- a/campus_python/audit/v1/apikeys.py +++ b/campus_python/audit/v1/apikeys.py @@ -6,6 +6,10 @@ 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). """ from ...interface import JsonDict, Resource, ResourceCollection @@ -24,16 +28,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) @@ -44,8 +58,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: @@ -98,6 +115,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. @@ -106,6 +124,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 """ @@ -114,6 +138,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: diff --git a/tests/unit/test_audit.py b/tests/unit/test_audit.py index 45aec82..e40a31d 100644 --- a/tests/unit/test_audit.py +++ b/tests/unit/test_audit.py @@ -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 @@ -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/") From 157762d06c1d6e15b8364ff8d9d747d99d25bacf Mon Sep 17 00:00:00 2001 From: JS Date: Wed, 21 Oct 2026 06:28:54 +0800 Subject: [PATCH 2/2] docs(audit): bring the audit client's documentation up to date MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The role parameter came with docstrings, but the README still described a package layout from before the audit client existed — the `audit/` package (apikeys, traces, the ingest circuit breaker) was missing from the tree, and the audit client was missing from the feature list. The apikeys module now also quotes the service's role catalogue, for orientation: it is explicitly not a contract, since the service is the authority and refuses an unknown role with the names of the roles it does have. --- README.md | 13 +++++++++++-- campus_python/audit/v1/apikeys.py | 23 +++++++++++++++++++++-- 2 files changed, 32 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 2736630..c383230 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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 diff --git a/campus_python/audit/v1/apikeys.py b/campus_python/audit/v1/apikeys.py index 9aaecc5..e96a6b9 100644 --- a/campus_python/audit/v1/apikeys.py +++ b/campus_python/audit/v1/apikeys.py @@ -10,13 +10,28 @@ 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": @@ -102,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)."""