Skip to content
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
124 changes: 124 additions & 0 deletions docs/admin/guides/auth/workload_identity.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
# 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`, add the backend to
`AUTHENTICATION_BACKENDS`, 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",
]

AUTHENTICATION_BACKENDS = [
"django.contrib.auth.backends.ModelBackend",
"pulpcore.backends.ObjectRolePermissionBackend",
"pulpcore.app.workload_identity.backend.WorkloadIdentityBackend",
]
```

The backend answers the permission checks for a workload identity request, so it must be present
for the feature to grant anything. Nothing is active until all three pieces are configured.

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"},
},
],
},
],
},
},
}
```
12 changes: 12 additions & 0 deletions pulpcore/app/access_policy.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from copy import deepcopy

from django.conf import settings
from django.contrib.auth import get_user_model
from rest_access_policy import AccessPolicy
from rest_framework.exceptions import APIException

Expand All @@ -15,6 +16,17 @@ class DefaultAccessPolicy(AccessPolicy):
An AccessPolicy that takes default statements from the view(set).
"""

def get_user_group_values(self, user):
"""Read groups from the ORM only for real database users.

drf-access-policy assumes groups live in `django.contrib.auth` and prefetches them,
which does not work for a stateless principal. Any non-database user (a workload-identity
principal, or another one added later) supplies its groups via a `group_names` attribute.
"""
if isinstance(user, get_user_model()):
return super().get_user_group_values(user)
return list(getattr(user, "group_names", []))

Comment thread
BaptisteCentreon marked this conversation as resolved.
@classmethod
def get_access_policy(cls, view):
"""
Expand Down
102 changes: 102 additions & 0 deletions pulpcore/app/checks.py
Original file line number Diff line number Diff line change
Expand Up @@ -123,3 +123,105 @@ def check_artifact_checksums(app_configs, **kwargs):
)

return messages


_WORKLOAD_IDENTITY_BACKEND = "pulpcore.app.workload_identity.backend.WorkloadIdentityBackend"


@register(deploy=True)
Comment thread
BaptisteCentreon marked this conversation as resolved.
def workload_identity_reserved_username(app_configs, **kwargs):
if _WORKLOAD_IDENTITY_BACKEND not in settings.AUTHENTICATION_BACKENDS:
return []

from pulpcore.app.workload_identity import config
Comment thread
BaptisteCentreon marked this conversation as resolved.

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(
Comment thread
BaptisteCentreon marked this conversation as resolved.
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):
if _WORKLOAD_IDENTITY_BACKEND not in settings.AUTHENTICATION_BACKENDS:
return []

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):
if _WORKLOAD_IDENTITY_BACKEND not in settings.AUTHENTICATION_BACKENDS:
return []

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
22 changes: 22 additions & 0 deletions pulpcore/app/role_util.py
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,28 @@ def get_objects_for_user(
accept_domain_perms=True,
accept_global_perms=True,
):
if (
"pulpcore.app.workload_identity.backend.WorkloadIdentityBackend"
in settings.AUTHENTICATION_BACKENDS
):
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 @@ -317,6 +317,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.
"""
Loading
Loading