Skip to content

Support OIDC authentication for CI clients (API and container registry) #7845

Description

@BaptisteCentreon

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.

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions