Repository navigation
feat(audit): named roles for audit API keys (#918) - #935
Merged
Merged
Conversation
A key's privilege set is a list of scope strings. That is precise, but
it puts the policy decision — "what may this person hold?" — in every
issuer's head, spelled out in scope names every time, and a
mis-assembled list is a silent privilege problem.
A role is a named bundle of scopes. The catalogue lives in
campus/audit/roles.py, next to nothing else it depends on, and is
deliberately small and explicit:
traces-viewer traces:read
traces-searcher traces:read, traces:search
traces-producer traces:read, traces:search, traces:write
key-operator apikeys:read, apikeys:write
No role resolves to a wildcard: the operator key deliberately holds
none, and a standing "*" role would be a privilege-escalation footgun
in the one place keys get minted.
The catalogue is additive. A role name is accepted wherever a scope
list is accepted — the resource layer, POST /audit/v1/apikeys, PATCH
/audit/v1/apikeys/<id>, and the key-management page (#927) — and a raw
scope list still works everywhere, so no key, client or script breaks.
A role and an explicit scope list are mutually exclusive: naming both
leaves it ambiguous which was meant. An unknown role is refused with a
message naming the roles that exist.
The key-management page now offers the roles in a picker alongside the
free-text scope field, so the operator grants a reviewed privilege set
by default and can still hand-assemble one for anything the catalogue
does not cover.
This was referenced Oct 20, 2026
ngjunsiang
added this pull request to stack #937
October 20, 2026 22:11
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #918
Summary
A key's privilege set is a list of scope strings. That is precise, but it puts the policy decision — "what may this person hold?" — in every issuer's head, spelled out in scope names every time, and a mis-assembled list is a silent privilege problem.
A role is a named bundle of scopes. The catalogue lives in
campus/audit/roles.pyand is deliberately small and explicit:traces-viewertraces:readtraces-searchertraces:read,traces:searchtraces-producertraces:read,traces:search,traces:writekey-operatorapikeys:read,apikeys:writeNo role resolves to a wildcard. The operator key deliberately holds none, and a standing
*role would be a privilege-escalation footgun in the one place keys get minted — the catalogue's top role mirrors the operator's scope set instead.Additive, not a replacement
A role name is accepted wherever a scope list is accepted:
resources.apikeys.new(..., role=...)),POST /audit/v1/apikeys/(arolefield in the body),PATCH /audit/v1/apikeys/<id>/(arolefield),A raw scope list still works everywhere, so no key, client or script breaks. The catalogue adds a vocabulary; it does not take the old one away.
The two rules that keep it honest
role, the page as a form error.Testing
tests/unit/audit/test_key_roles.py(9 tests): every role resolves to one scope list; the catalogue can express every scope the operator key holds; no role resolves to a wildcard; an unknown role is rejected with a message naming the available roles; role and scopes are mutually exclusive; a scope list alone still resolves; and the resource layer mints with a role (and refuses an unknown one).tests/contract/audit/test_audit_apikeys.py(4 new tests): the API accepts a role and grants exactly that bundle, refuses an unknown role with the available names, refuses role-plus-scopes, and re-scopes viaPATCHwith a role.tests/unit/audit/test_web_keys.py(3 new tests): the page mints with a role, refuses an unknown role, and refuses role-plus-scopes.Unit suite passing, contract suite passing,
ruff check .clean,pyrightclean oncampus/.Stack
This is the top link of the three-PR stack: #932 (PR #933) → #927 (PR #934) → #918 (this PR). It is based on
feat/audit-key-management-page; only this PR's own changes appear in its diff.Follow-ups this does not cover (cross-repo)
The catalogue reaches the CLI through the SDK, and both live in other repositories:
campus_python.audit.v1.apikeysresource needs aroleparameter onnew()(and on the update path), mirroring what this PR does server-side.campus audit keys newshould offer the role names, and a relock picks up the SDK change (precedent: campus-cli#52).The API is the contract either way: the CLI can name a role today by resolving it client-side, once the catalogue is known to it.