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
119 changes: 119 additions & 0 deletions docs/admin/guides/auth/workload_identity.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
# 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: Bearer` header.
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`,
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.

## Domains

When `DOMAIN_ENABLED` is on, scope an object grant to a single tenant:
use a `prn`, or add a `domain` to a `name` scope.
A bare `name` matches that name in every domain, which breaks the isolation between domains.
Pulp raises a startup check warning when a name scope is left unqualified while domains are enabled.

## 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>"} every object in a domain
# {"type": "object", "name": "<name>"} one object by name
# {"type": "object", "name": "<name>", "domain": "<d>"} one object by name in a domain
# {"type": "object", "prn": "<prn>"} one object by PRN (domain-safe)
# With DOMAIN_ENABLED, qualify a name scope with a domain (or use prn):
# a bare name otherwise matches that name in every domain.
"scope": {"type": "object", "name": "prod"},
},
],
},
],
},
},
}
```
8 changes: 8 additions & 0 deletions pulpcore/app/access_policy.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,14 @@ 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 directly instead of via the ORM."""
from pulpcore.app.workload_identity.principal import WorkloadIdentityPrincipal

if isinstance(user, WorkloadIdentityPrincipal):
return list(user.group_names)
return super().get_user_group_values(user)

@classmethod
def get_access_policy(cls, view):
"""
Expand Down
90 changes: 90 additions & 0 deletions pulpcore/app/checks.py
Original file line number Diff line number Diff line change
Expand Up @@ -123,3 +123,93 @@ def check_artifact_checksums(app_configs, **kwargs):
)

return messages


@register(deploy=True)
def workload_identity_reserved_username(app_configs, **kwargs):
from pulpcore.app.workload_identity import config

messages = []
if not config.config():
return messages

username = config.basic_username()
try:
from django.contrib.auth import get_user_model

collides = get_user_model().objects.filter(username=username).exists()
except Exception:
return messages

if collides:
messages.append(
CheckWarning(
f"The WORKLOAD_IDENTITY basic_auth_username '{username}' is also a database user. "
"A token presented with this username over Basic auth is validated as a workload "
"identity token, not as that user's password. Set basic_auth_username to a name "
"that is not a real user to avoid ambiguity.",
id="pulpcore.W006",
)
)

return messages


@register(deploy=True)
def workload_identity_domain_scopes(app_configs, **kwargs):
from pulpcore.app.workload_identity import config

messages = []
if settings.DOMAIN_ENABLED or not config.config():
return messages

uses_domain = any(
grant.get("scope", {}).get("type") == "domain" or "domain" in grant.get("scope", {})
for provider in config.providers().values()
for rule in provider.get("rules", [])
for grant in rule.get("grants", [])
)
if uses_domain:
messages.append(
CheckWarning(
"WORKLOAD_IDENTITY has grant scopes that reference a domain, but DOMAIN_ENABLED is "
"False. Domain scoping has no effect while domains are disabled.",
id="pulpcore.W007",
)
)

return messages


@register(deploy=True)
def workload_identity_unqualified_name_scopes(app_configs, **kwargs):
from pulpcore.app.workload_identity import config

messages = []
if not settings.DOMAIN_ENABLED or not config.config():
return messages

risky = False
for provider in config.providers().values():
for rule in provider.get("rules", []):
for grant in rule.get("grants", []):
scope = grant.get("scope", {})
if (
scope.get("type") == "object"
and "name" in scope
and "domain" not in scope
and "prn" not in scope
):
risky = True

if risky:
messages.append(
CheckWarning(
"WORKLOAD_IDENTITY has object scopes matched by name only while DOMAIN_ENABLED is "
"True. A bare name matches that object in every domain, breaking domain isolation. "
"Add a 'domain' to the scope or use a 'prn'.",
id="pulpcore.W008",
)
)

return messages
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.
"""
100 changes: 100 additions & 0 deletions pulpcore/app/workload_identity/authentication.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
"""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 of a ``Basic`` header whose username
is the reserved workload-identity name. 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, or None.

Accepts a ``Bearer`` token, or a token carried as the password of a ``Basic`` header whose
username is the reserved workload-identity name. Any other ``Basic`` header is left for the
regular authenticators.
"""
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
username, sep, password = decoded.partition(":")
if not sep or username != config.basic_username():
return None
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"
Loading