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 9480df5..e96a6b9 100644 --- a/campus_python/audit/v1/apikeys.py +++ b/campus_python/audit/v1/apikeys.py @@ -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": @@ -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) @@ -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: @@ -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).""" @@ -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. @@ -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 """ @@ -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: 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/")