Skip to content

feat(audit): named roles for audit API keys (#918) - #935

Merged
ngjunsiang merged 1 commit into
feat/audit-key-management-pagefrom
feat/audit-key-roles
Oct 20, 2026
Merged

ngjunsiang merged 1 commit into
feat/audit-key-management-pagefrom
feat/audit-key-roles

Conversation

@ngjunsiang

Copy link
Copy Markdown
Contributor

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.py and is deliberately small and explicit:

Role Grants
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'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:

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

  • A role and an explicit scope list are mutually exclusive. Naming both leaves it ambiguous which was meant, and silently preferring one would hide a mistake in the caller's request. Both the API and the page refuse it.
  • An unknown role is refused with a message naming the roles that exist — the API as a field error on 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 via PATCH with 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, pyright clean on campus/.

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-api-python — the SDK's campus_python.audit.v1.apikeys resource needs a role parameter on new() (and on the update path), mirroring what this PR does server-side.
  • campus-cli — campus audit keys new should 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.

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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant