Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/admin/guides/_SUMMARY.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
* [Using external service](auth/external.md)
* [Using Keycloak](auth/keycloak.md)
* [Using JSON Header](auth/json_header.md)
* [Using Workload Identity](auth/workload_identity.md)
* auth/*.md
* Configuration
* [Introduction](configure-pulp/index.md)
Expand Down
102 changes: 102 additions & 0 deletions docs/admin/guides/auth/workload_identity.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
# Workload Identity Authentication

A CI job can authenticate to Pulp with a short-lived OIDC token from a third-party provider (for
example GitHub Actions) instead of a stored username and password. The token is verified against the
provider's public keys, its claims are matched against a set of rules, and the request is granted
roles for that request only. No user is created and nothing is written to the role tables.

This suits supply-chain workflows where a pipeline pushes content and you want its permissions scoped
to specific repositories without long-lived secrets.

!!! note
The token is an OIDC token, but this is unrelated to the user-facing SSO login covered in
[Using external service](external.md). It identifies a workload, not a person.

## How it works

On each request the token is read from the `Authorization` header, either as a `Bearer` token or as
the password of a `Basic` header (the way `docker login` sends a token). The `iss` claim selects a
configured provider, the signature is verified against the provider's JWKS, and `iss`, `aud` and
`exp` are checked. The remaining claims are matched against the provider's rules to compute the roles
and scopes for the request. A token that matches no rule is rejected with a 401.

## Enabling

Add the authentication class to `DEFAULT_AUTHENTICATION_CLASSES`, before `BasicAuthentication` so the
`docker login` path reaches it, then populate `WORKLOAD_IDENTITY`:

```python title="settings.py"
REST_FRAMEWORK["DEFAULT_AUTHENTICATION_CLASSES"] = [
"pulpcore.app.workload_identity.authentication.WorkloadIdentityAuthentication",
"pulpcore.app.authentication.BasicAuthentication",
"rest_framework.authentication.SessionAuthentication",
]
```

No change to `AUTHENTICATION_BACKENDS` is needed. The feature stays off while `WORKLOAD_IDENTITY` is
empty, so adding the class alone changes nothing.

With the example below, a push from the `main` branch of `my-org/app` is granted the
`file.filerepository_owner` role on the repository named `prod`, and nothing else. See the
configuration reference at the end for every option.

## Roles for asynchronous tasks

Operations that dispatch a task, such as a sync, return a task the client polls. A workload identity
request is not a database user, so it is not automatically granted a role on the tasks it creates.
Grant a role carrying `core.view_task` when the CI needs to read its own tasks.

## Configuration reference

Every option of the `WORKLOAD_IDENTITY` setting, annotated:

```python title="settings.py"
WORKLOAD_IDENTITY = {
# How matching rules combine.
# "union" (default) collects the grants of every matching rule.
# "first-match" stops at the first matching rule.
"strategy": "union",

# One entry per trusted provider. The key is a name for your own reference.
"providers": {
"github": {
# Required. Expected "iss" claim. Selects the provider and is verified while decoding.
"issuer": "https://token.actions.githubusercontent.com",

# Required. URL of the provider's JWKS. Keys are fetched and cached.
"jwks_url": "https://token.actions.githubusercontent.com/.well-known/jwks",

# Required. Expected "aud" claim.
"audience": "https://pulp.example.com",

# Optional. Allowed signing algorithms. Default: ["RS256"].
"algorithms": ["RS256"],

# Rules are evaluated in order. Each maps claims to grants.
"rules": [
{
# Claim name to expected value. Values support "*" globbing.
# Every entry must match (AND). A missing claim never matches.
"match": {"repository": "my-org/app", "ref": "refs/heads/main"},

# Grants awarded when the rule matches.
"grants": [
{
# Required. Name of a role that already exists in Pulp.
# A role that does not exist confers no permission.
"role": "file.filerepository_owner",

# Required. Where the role applies. One of:
# {"type": "global"} everywhere
# {"type": "domain", "domain": "<name>"} objects in a domain
# {"type": "object", "name": "<name>"} one object by name
# {"type": "object", "prn": "<prn>"} one object by PRN
"scope": {"type": "object", "name": "prod"},
},
],
},
],
},
},
}
```
7 changes: 7 additions & 0 deletions pulpcore/app/access_policy.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,13 @@ class DefaultAccessPolicy(AccessPolicy):
An AccessPolicy that takes default statements from the view(set).
"""

def get_user_group_values(self, user):
"""Let a stateless principal supply its groups via ``group_names`` instead of the ORM."""
group_names = getattr(user, "group_names", None)
if group_names is not None:
return list(group_names)
return super().get_user_group_values(user)

@classmethod
def get_access_policy(cls, view):
"""
Expand Down
18 changes: 18 additions & 0 deletions pulpcore/app/role_util.py
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,24 @@ def get_objects_for_user(
accept_domain_perms=True,
accept_global_perms=True,
):
from pulpcore.app.workload_identity.principal import WorkloadIdentityPrincipal

if isinstance(user, WorkloadIdentityPrincipal):
from pulpcore.app.workload_identity.authz import grants_queryset

grants = user.grants
if isinstance(perms, str):
return grants_queryset(grants, perms, qs)
if any_perm:
result = qs.none()
for permission_name in perms:
result |= grants_queryset(grants, permission_name, qs)
return result
result = qs.all()
for permission_name in perms:
result &= grants_queryset(grants, permission_name, qs)
return result

new_qs = qs.none()
replace = False
if "pulpcore.backends.ObjectRolePermissionBackend" in settings.AUTHENTICATION_BACKENDS:
Expand Down
3 changes: 3 additions & 0 deletions pulpcore/app/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -316,6 +316,9 @@
AUTHENTICATION_JSON_HEADER_JQ_FILTER = ""
AUTHENTICATION_JSON_HEADER_OPENAPI_SECURITY_SCHEME = {}

# Workload identity authentication for CI clients. Off while empty.
WORKLOAD_IDENTITY = {}

ALLOWED_IMPORT_PATHS = []

ALLOWED_EXPORT_PATHS = []
Expand Down
5 changes: 5 additions & 0 deletions pulpcore/app/workload_identity/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
"""Workload identity authentication for CI clients.

A short-lived OIDC token from a third-party provider (for example GitHub Actions) becomes a
stateless principal whose grants are computed per request from the ``WORKLOAD_IDENTITY`` setting.
"""
94 changes: 94 additions & 0 deletions pulpcore/app/workload_identity/authentication.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
"""DRF authentication that validates a third-party OIDC token against its provider's JWKS.

The token arrives as a ``Bearer`` token or as the password in a ``Basic`` header (``docker login``).
On success its claims map to grants and a stateless ``WorkloadIdentityPrincipal`` is returned.
"""

import base64
import binascii
import logging

import jwt
from rest_framework.authentication import BaseAuthentication
from rest_framework.exceptions import AuthenticationFailed

from pulpcore.app.workload_identity import config, rules
from pulpcore.app.workload_identity.principal import WorkloadIdentityPrincipal

_logger = logging.getLogger("pulpcore.workload_identity")


class WorkloadIdentityAuthentication(BaseAuthentication):
"""Authenticate requests bearing a third-party OIDC token.

On success this returns a stateless ``WorkloadIdentityPrincipal`` whose permissions are
derived entirely from the grants earned by the token's claims. When the
request carries no token, or a token that is not meant for us, the
authenticator returns ``None`` so that other authenticators may run.
"""

def _get_token(self, request):
"""Return the token from the Authorization header (Bearer, or Basic password), or None."""
header = request.META.get("HTTP_AUTHORIZATION", "")
parts = header.split()
if len(parts) != 2:
return None
scheme, value = parts
scheme = scheme.lower()
if scheme == "bearer":
return value
if scheme == "basic":
try:
decoded = base64.b64decode(value).decode("utf-8")
except (binascii.Error, ValueError, UnicodeDecodeError):
return None
if ":" not in decoded:
return None
_, _, password = decoded.partition(":")
return password
return None

def authenticate(self, request):
"""Validate an OIDC token and return ``(WorkloadIdentityPrincipal, claims)``, or ``None`` if not ours."""
token = self._get_token(request)
if not token:
return None

try:
unverified = jwt.decode(token, options={"verify_signature": False})
except jwt.PyJWTError:
return None
issuer = unverified.get("iss")

provider = config.provider_for_issuer(issuer)
if provider is None:
return None

try:
signing_key = config.jwks_client(provider).get_signing_key_from_jwt(token)
claims = jwt.decode(
token,
signing_key.key,
algorithms=provider.get("algorithms", ["RS256"]),
issuer=provider["issuer"],
audience=provider["audience"],
options={"require": ["exp", "iss", "aud"]},
)
except jwt.PyJWTError as exc:
_logger.info("Rejecting OIDC token from %s: %s", issuer, exc)
raise AuthenticationFailed("Invalid OIDC token.")

grants = rules.grants_for(provider, claims)
if not grants:
_logger.info(
"No matching OIDC rule for sub=%r repository=%r",
claims.get("sub"),
claims.get("repository"),
)
raise AuthenticationFailed("No matching OIDC rule.")

return (WorkloadIdentityPrincipal(grants, username=""), claims)

def authenticate_header(self, request):
"""Return the ``WWW-Authenticate`` value so failures are 401, not 403."""
return "Bearer"
131 changes: 131 additions & 0 deletions pulpcore/app/workload_identity/authz.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
"""Shared authorization logic over a list of grants.

A grant is ``{"role": <role name>, "scope": {...}}``. Scope is one of:

* ``{"type": "global"}``
* ``{"type": "domain", "domain": "<name>"}``
* ``{"type": "object", "name": "<name>"}`` (or ``"prn"``)

Roles are read from the database to resolve their permissions; the grant assignment is never stored.
"""

from django.db.models import Q


def _split(permission):
app_label, _, codename = permission.partition(".")
return app_label, codename


def _role_has_perm(role_name, app_label, codename):
from pulpcore.app.models.role import Role

if not role_name:
return False
try:
role = Role.objects.get(name=role_name)
except Role.DoesNotExist:
return False
return role.permissions.filter(
content_type__app_label=app_label, codename=codename
).exists()


def _scope_matches(scope, obj):
"""Whether a scope applies to a single object (``obj`` may be ``None`` for model-level)."""
from pulpcore.app.models import Domain

stype = scope.get("type")
if stype == "global":
return True
if obj is None:
return False
if stype == "domain":
if isinstance(obj, Domain):
return obj.name == scope.get("domain")
domain = getattr(obj, "pulp_domain", None)
return domain is not None and domain.name == scope.get("domain")
if stype == "object":
if "prn" not in scope and "name" not in scope:
return False
if "prn" in scope:
from pulpcore.app.util import get_prn

try:
if get_prn(obj) != scope["prn"]:
return False
except Exception:
return False
if "name" in scope and str(getattr(obj, "name", None)) != str(scope["name"]):
return False
return True
return False


def has_grant_perm(grants, permission, obj=None):
"""True if any grant confers ``permission`` and its scope matches ``obj``."""
app_label, codename = _split(permission)
for grant in grants:
if _role_has_perm(grant.get("role"), app_label, codename) and _scope_matches(
grant.get("scope", {}), obj
):
return True
return False


def permissions_for(grants, obj=None):
"""The set of ``app_label.codename`` the grants confer, scoped to ``obj`` when given."""
from pulpcore.app.models.role import Role

names = {g.get("role") for g in grants if g.get("role")}
if not names:
return set()
role_perms = {}
for role in Role.objects.filter(name__in=names).prefetch_related("permissions__content_type"):
role_perms[role.name] = {
f"{perm.content_type.app_label}.{perm.codename}" for perm in role.permissions.all()
}
perms = set()
for grant in grants:
conferred = role_perms.get(grant.get("role"))
if not conferred:
continue
if obj is not None and not _scope_matches(grant.get("scope", {}), obj):
continue
perms |= conferred
return perms


def grants_queryset(grants, permission, queryset):
"""Return ``queryset`` filtered to the objects the grants allow for ``permission``."""
app_label, codename = _split(permission)
relevant = [g for g in grants if _role_has_perm(g.get("role"), app_label, codename)]
if not relevant:
return queryset.none()

has_domain = hasattr(queryset.model, "pulp_domain")
predicate = None
for grant in relevant:
scope = grant.get("scope", {})
stype = scope.get("type")
if stype == "global":
return queryset
clause = None
if stype == "domain" and has_domain:
clause = Q(pulp_domain__name=scope.get("domain"))
elif stype == "object":
if "prn" in scope:
from pulpcore.app.util import extract_pk

try:
clause = Q(pk=extract_pk(scope["prn"], only_prn=True))
except Exception:
clause = None
elif "name" in scope:
clause = Q(name=scope["name"])
if clause is not None:
predicate = clause if predicate is None else (predicate | clause)

if predicate is None:
return queryset.none()
return queryset.filter(predicate)
Loading