Skip to content
Open
Show file tree
Hide file tree
Changes from 5 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
109 changes: 109 additions & 0 deletions docs/admin/guides/auth/workload_identity.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
# 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.

## 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
30 changes: 30 additions & 0 deletions pulpcore/app/checks.py
Original file line number Diff line number Diff line change
Expand Up @@ -123,3 +123,33 @@ 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
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