Skip to content
Open
Show file tree
Hide file tree
Changes from 3 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
Comment thread
BaptisteCentreon marked this conversation as resolved.
Outdated
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)
Comment thread
BaptisteCentreon marked this conversation as resolved.
Outdated
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(":")
Comment thread
BaptisteCentreon marked this conversation as resolved.
Outdated
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"
129 changes: 129 additions & 0 deletions pulpcore/app/workload_identity/authz.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
"""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
Loading