I am opening this to discuss the approach before I start writing code. If the direction makes sense, I am happy to implement the whole feature, with tests.
Context
Long lived CI secrets are the main vector in recent supply chain attacks; the fix is short lived per-job OIDC tokens instead of stored secrets. A Pulp that publishes from CI should authenticate the job by its OIDC token, validated against the provider public keys, with nothing stored on either side.
Proposal
A validated token becomes a stateless principal (no database user). Rules grant it existing roles at a scope (global, domain, or object) for that request only. Nothing is stored, so the short token lifetime is the revocation.
Providers live in one setting. The token iss selects the provider.
OIDC_AUTH = {
"strategy": "union", # union: every matching rule adds grants. first-match: stop at the first.
"providers": {
"github": {
"issuer": "https://token.actions.githubusercontent.com", # required, the selector
"jwks_url": "https://token.actions.githubusercontent.com/.well-known/jwks", # required
"audience": "https://pulp.example.com", # required, must equal the aud the client asked
"algorithms": ["RS256"], # optional, default ["RS256"]
"rules": [
# A rule matches when ALL its "match" claims match, glob supported.
# Keep matches as specific as the grant is powerful (anchor on sub or ref, not just
# repository, and mind pull_request / fork subjects). No rule matches -> rejected.
{
"match": {"repository": "org/infra", "ref": "refs/heads/main"},
# scope: {"type": "global"} | {"type": "domain", "domain": "x"}
# | {"type": "object", "name"/"prn"/"href": "x"}
"grants": [
{"role": "file.repository_owner", "scope": {"type": "domain", "domain": "prod"}},
],
},
{
"match": {"repository": "org/*"},
"grants": [{"role": "core.status_viewer", "scope": {"type": "global"}}],
},
],
},
},
}
Changes
1. [OIDC] Authentication class (DRF)
If there is no bearer token it returns None, so Basic and Session auth still run. Otherwise it reads iss to pick the provider, verifies the signature against that provider's cached JWKS. Checks iss/aud/exp, and maps the claims to grants.
It returns a stateless principal, and also reads the token from the Basic password so docker login works.
2. [Core] Principal answers its own permissions
The principal is a plain object, not the user model, with its own has_perm and get_all_permissions. That keeps it out of AUTHENTICATION_BACKENDS, so ModelBackend (which crashes on a user with no id) never runs. It answers from the grants, never touches the UserRole/GroupRole tables, and the object-creator hooks skip it since it is not a user.
It exposes the user attributes the request path reads (is_authenticated, is_active, is_superuser, pk, username, groups, has_perm, get_all_permissions).
3. [Core] Grant-aware object filtering
List endpoints scope their queryset through get_objects_for_user (role_util.py). Add a branch there: when the principal carries grants, build the filter from them (global, domain, or object scope), otherwise keep the current database path.
The viewsets that override scope_queryset (task, content, repository, container) need the same branch, and my_permissions stays consistent through get_all_permissions. With no OIDC principal, nothing changes.
4. [Config] Task access
Viewing or cancelling a task uses a normal grant (core.view_task/core.change_task) at domain scope, with no special mechanism. One CI can follow another's task when both hold the grant.
5. [OIDC] Container registry (pulp_container)
/token/ uses the default auth classes, so the OIDC class applies. AuthorizationService computes the pull/push scope through the registry access policy, which calls has_perm, so the grants drive it.
The one change: issue an empty token subject for a principal with no user, so pull and push fall back to the anonymous, scope-bearing path (this drops the subject identity from the token). Optionally, the registry token can be capped to the time left on the OIDC token, so it never outlives the login grant.
Out of scope
- Role management endpoints (
list_roles, add_role, remove_role) manage stored assignments. A per-request grant is not a stored assignment, so it does not appear there.
- Task purge, which checks a permission inside a worker where the grants do not exist. Purge is admin and cron housekeeping, not a CI action.
- A durable model of the OIDC identities and logging the OIDC context of each request, which are a separate audit and management proposal.
I am opening this to discuss the approach before I start writing code. If the direction makes sense, I am happy to implement the whole feature, with tests.
Context
Long lived CI secrets are the main vector in recent supply chain attacks; the fix is short lived per-job OIDC tokens instead of stored secrets. A Pulp that publishes from CI should authenticate the job by its OIDC token, validated against the provider public keys, with nothing stored on either side.
https://owasp.org/Top10/2025/A03_2025-Software_Supply_Chain_Failures/
Proposal
A validated token becomes a stateless principal (no database user). Rules grant it existing roles at a scope (
global,domain, orobject) for that request only. Nothing is stored, so the short token lifetime is the revocation.Providers live in one setting. The token
issselects the provider.Changes
1. [OIDC] Authentication class (DRF)
If there is no bearer token it returns
None, so Basic and Session auth still run. Otherwise it readsissto pick the provider, verifies the signature against that provider's cached JWKS. Checksiss/aud/exp, and maps the claims to grants.It returns a stateless principal, and also reads the token from the Basic password so
docker loginworks.2. [Core] Principal answers its own permissions
The principal is a plain object, not the user model, with its own
has_permandget_all_permissions. That keeps it out ofAUTHENTICATION_BACKENDS, soModelBackend(which crashes on a user with no id) never runs. It answers from the grants, never touches theUserRole/GroupRoletables, and the object-creator hooks skip it since it is not a user.It exposes the user attributes the request path reads (
is_authenticated,is_active,is_superuser,pk,username,groups,has_perm,get_all_permissions).3. [Core] Grant-aware object filtering
List endpoints scope their queryset through
get_objects_for_user(role_util.py). Add a branch there: when the principal carries grants, build the filter from them (global, domain, or object scope), otherwise keep the current database path.The viewsets that override
scope_queryset(task, content, repository, container) need the same branch, andmy_permissionsstays consistent throughget_all_permissions. With no OIDC principal, nothing changes.4. [Config] Task access
Viewing or cancelling a task uses a normal grant (
core.view_task/core.change_task) at domain scope, with no special mechanism. One CI can follow another's task when both hold the grant.5. [OIDC] Container registry (pulp_container)
/token/uses the default auth classes, so the OIDC class applies.AuthorizationServicecomputes the pull/push scope through the registry access policy, which callshas_perm, so the grants drive it.The one change: issue an empty token subject for a principal with no user, so pull and push fall back to the anonymous, scope-bearing path (this drops the subject identity from the token). Optionally, the registry token can be capped to the time left on the OIDC token, so it never outlives the login grant.
Out of scope
list_roles,add_role,remove_role) manage stored assignments. A per-request grant is not a stored assignment, so it does not appear there.