Skip to content

Commit 88ecdfd

Browse files
Rename OIDC auth to workload identity, add docs and fixes
1 parent 6141926 commit 88ecdfd

14 files changed

Lines changed: 187 additions & 82 deletions

File tree

docs/admin/guides/_SUMMARY.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
* [Using external service](auth/external.md)
44
* [Using Keycloak](auth/keycloak.md)
55
* [Using JSON Header](auth/json_header.md)
6+
* [Using Workload Identity](auth/workload_identity.md)
67
* auth/*.md
78
* Configuration
89
* [Introduction](configure-pulp/index.md)
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
# Workload Identity Authentication
2+
3+
A CI job can authenticate to Pulp with a short-lived OIDC token from a third-party provider (for
4+
example GitHub Actions) instead of a stored username and password. The token is verified against the
5+
provider's public keys, its claims are matched against a set of rules, and the request is granted
6+
roles for that request only. No user is created and nothing is written to the role tables.
7+
8+
This suits supply-chain workflows where a pipeline pushes content and you want its permissions scoped
9+
to specific repositories without long-lived secrets.
10+
11+
!!! note
12+
The token is an OIDC token, but this is unrelated to the user-facing SSO login covered in
13+
[Using external service](external.md). It identifies a workload, not a person.
14+
15+
## How it works
16+
17+
On each request the token is read from the `Authorization` header, either as a `Bearer` token or as
18+
the password of a `Basic` header (the way `docker login` sends a token). The `iss` claim selects a
19+
configured provider, the signature is verified against the provider's JWKS, and `iss`, `aud` and
20+
`exp` are checked. The remaining claims are matched against the provider's rules to compute the roles
21+
and scopes for the request. A token that matches no rule is rejected with a 401.
22+
23+
## Enabling
24+
25+
Add the authentication class to `DEFAULT_AUTHENTICATION_CLASSES`, before `BasicAuthentication` so the
26+
`docker login` path reaches it, then populate `WORKLOAD_IDENTITY`:
27+
28+
```python title="settings.py"
29+
REST_FRAMEWORK["DEFAULT_AUTHENTICATION_CLASSES"] = [
30+
"pulpcore.app.workload_identity.authentication.WorkloadIdentityAuthentication",
31+
"pulpcore.app.authentication.BasicAuthentication",
32+
"rest_framework.authentication.SessionAuthentication",
33+
]
34+
```
35+
36+
No change to `AUTHENTICATION_BACKENDS` is needed. The feature stays off while `WORKLOAD_IDENTITY` is
37+
empty, so adding the class alone changes nothing.
38+
39+
With the example below, a push from the `main` branch of `my-org/app` is granted the
40+
`file.filerepository_owner` role on the repository named `prod`, and nothing else. See the
41+
configuration reference at the end for every option.
42+
43+
## Roles for asynchronous tasks
44+
45+
Operations that dispatch a task, such as a sync, return a task the client polls. A workload identity
46+
request is not a database user, so it is not automatically granted a role on the tasks it creates.
47+
Grant a role carrying `core.view_task` when the CI needs to read its own tasks.
48+
49+
## Configuration reference
50+
51+
Every option of the `WORKLOAD_IDENTITY` setting, annotated:
52+
53+
```python title="settings.py"
54+
WORKLOAD_IDENTITY = {
55+
# How matching rules combine.
56+
# "union" (default) collects the grants of every matching rule.
57+
# "first-match" stops at the first matching rule.
58+
"strategy": "union",
59+
60+
# One entry per trusted provider. The key is a name for your own reference.
61+
"providers": {
62+
"github": {
63+
# Required. Expected "iss" claim. Selects the provider and is verified while decoding.
64+
"issuer": "https://token.actions.githubusercontent.com",
65+
66+
# Required. URL of the provider's JWKS. Keys are fetched and cached.
67+
"jwks_url": "https://token.actions.githubusercontent.com/.well-known/jwks",
68+
69+
# Required. Expected "aud" claim.
70+
"audience": "https://pulp.example.com",
71+
72+
# Optional. Allowed signing algorithms. Default: ["RS256"].
73+
"algorithms": ["RS256"],
74+
75+
# Rules are evaluated in order. Each maps claims to grants.
76+
"rules": [
77+
{
78+
# Claim name to expected value. Values support "*" globbing.
79+
# Every entry must match (AND). A missing claim never matches.
80+
"match": {"repository": "my-org/app", "ref": "refs/heads/main"},
81+
82+
# Grants awarded when the rule matches.
83+
"grants": [
84+
{
85+
# Required. Name of a role that already exists in Pulp.
86+
# A role that does not exist confers no permission.
87+
"role": "file.filerepository_owner",
88+
89+
# Required. Where the role applies. One of:
90+
# {"type": "global"} everywhere
91+
# {"type": "domain", "domain": "<name>"} objects in a domain
92+
# {"type": "object", "name": "<name>"} one object by name
93+
# {"type": "object", "prn": "<prn>"} one object by PRN
94+
"scope": {"type": "object", "name": "prod"},
95+
},
96+
],
97+
},
98+
],
99+
},
100+
},
101+
}
102+
```

pulpcore/app/access_policy.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,13 @@ class DefaultAccessPolicy(AccessPolicy):
1515
An AccessPolicy that takes default statements from the view(set).
1616
"""
1717

18+
def get_user_group_values(self, user):
19+
"""Let a stateless principal supply its groups via ``group_names`` instead of the ORM."""
20+
group_names = getattr(user, "group_names", None)
21+
if group_names is not None:
22+
return list(group_names)
23+
return super().get_user_group_values(user)
24+
1825
@classmethod
1926
def get_access_policy(cls, view):
2027
"""

pulpcore/app/oidc/__init__.py

Lines changed: 0 additions & 5 deletions
This file was deleted.

pulpcore/app/role_util.py

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -173,11 +173,10 @@ def get_objects_for_user(
173173
accept_domain_perms=True,
174174
accept_global_perms=True,
175175
):
176-
from pulpcore.app.oidc.principal import OIDCPrincipal
176+
from pulpcore.app.workload_identity.principal import WorkloadIdentityPrincipal
177177

178-
if isinstance(user, OIDCPrincipal):
179-
# Stateless OIDC principal: scope from its per-request grants, not the role tables.
180-
from pulpcore.app.oidc.authz import grants_queryset
178+
if isinstance(user, WorkloadIdentityPrincipal):
179+
from pulpcore.app.workload_identity.authz import grants_queryset
181180

182181
grants = user.grants
183182
if isinstance(perms, str):

pulpcore/app/settings.py

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -316,11 +316,8 @@
316316
AUTHENTICATION_JSON_HEADER_JQ_FILTER = ""
317317
AUTHENTICATION_JSON_HEADER_OPENAPI_SECURITY_SCHEME = {}
318318

319-
# OIDC authentication for CI clients. Empty by default (feature off). See
320-
# pulpcore.app.oidc.config for the schema. Enable by adding
321-
# "pulpcore.app.oidc.authentication.OIDCAuthentication" to
322-
# REST_FRAMEWORK["DEFAULT_AUTHENTICATION_CLASSES"] and populating this setting.
323-
OIDC_AUTH = {}
319+
# Workload identity authentication for CI clients. Off while empty.
320+
WORKLOAD_IDENTITY = {}
324321

325322
ALLOWED_IMPORT_PATHS = []
326323

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
"""Workload identity authentication for CI clients.
2+
3+
A short-lived OIDC token from a third-party provider (for example GitHub Actions) becomes a
4+
stateless principal whose grants are computed per request from the ``WORKLOAD_IDENTITY`` setting.
5+
"""

pulpcore/app/oidc/authentication.py renamed to pulpcore/app/workload_identity/authentication.py

Lines changed: 10 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,7 @@
1-
"""DRF authentication that validates third-party OIDC tokens.
1+
"""DRF authentication that validates a third-party OIDC token against its provider's JWKS.
22
3-
This authenticator accepts an OIDC token issued by a configured third-party
4-
provider (for example a GitHub Actions workflow token), verifies it against the
5-
provider's JWKS, maps its claims to grants and returns a stateless
6-
``OIDCPrincipal``. No database user is involved.
7-
8-
The token may arrive either as a ``Bearer`` token or inside a ``Basic`` header
9-
(the way ``docker login`` passes a token, where the password field carries it).
3+
The token arrives as a ``Bearer`` token or as the password in a ``Basic`` header (``docker login``).
4+
On success its claims map to grants and a stateless ``WorkloadIdentityPrincipal`` is returned.
105
"""
116

127
import base64
@@ -17,16 +12,16 @@
1712
from rest_framework.authentication import BaseAuthentication
1813
from rest_framework.exceptions import AuthenticationFailed
1914

20-
from pulpcore.app.oidc import config, rules
21-
from pulpcore.app.oidc.principal import OIDCPrincipal
15+
from pulpcore.app.workload_identity import config, rules
16+
from pulpcore.app.workload_identity.principal import WorkloadIdentityPrincipal
2217

23-
_logger = logging.getLogger("pulpcore.oidc")
18+
_logger = logging.getLogger("pulpcore.workload_identity")
2419

2520

26-
class OIDCAuthentication(BaseAuthentication):
21+
class WorkloadIdentityAuthentication(BaseAuthentication):
2722
"""Authenticate requests bearing a third-party OIDC token.
2823
29-
On success this returns a stateless ``OIDCPrincipal`` whose permissions are
24+
On success this returns a stateless ``WorkloadIdentityPrincipal`` whose permissions are
3025
derived entirely from the grants earned by the token's claims. When the
3126
request carries no token, or a token that is not meant for us, the
3227
authenticator returns ``None`` so that other authenticators may run.
@@ -49,19 +44,16 @@ def _get_token(self, request):
4944
return None
5045
if ":" not in decoded:
5146
return None
52-
# docker login passes the token as the password; the username is ignored.
5347
_, _, password = decoded.partition(":")
5448
return password
5549
return None
5650

5751
def authenticate(self, request):
58-
"""Validate an OIDC token and return ``(OIDCPrincipal, claims)``, or ``None`` if not ours."""
52+
"""Validate an OIDC token and return ``(WorkloadIdentityPrincipal, claims)``, or ``None`` if not ours."""
5953
token = self._get_token(request)
6054
if not token:
6155
return None
6256

63-
# Peek at the issuer without verifying the signature. If this is not a
64-
# JWT at all, it is not meant for us.
6557
try:
6658
unverified = jwt.decode(token, options={"verify_signature": False})
6759
except jwt.PyJWTError:
@@ -72,7 +64,6 @@ def authenticate(self, request):
7264
if provider is None:
7365
return None
7466

75-
# Verify the token for real against the provider's JWKS.
7667
try:
7768
signing_key = config.jwks_client(provider).get_signing_key_from_jwt(token)
7869
claims = jwt.decode(
@@ -96,9 +87,7 @@ def authenticate(self, request):
9687
)
9788
raise AuthenticationFailed("No matching OIDC rule.")
9889

99-
# The username is intentionally empty: the container registry token
100-
# subject must be empty for a principal with no database user.
101-
return (OIDCPrincipal(grants, username=""), claims)
90+
return (WorkloadIdentityPrincipal(grants, username=""), claims)
10291

10392
def authenticate_header(self, request):
10493
"""Return the ``WWW-Authenticate`` value so failures are 401, not 403."""
Lines changed: 35 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,7 @@
66
* ``{"type": "domain", "domain": "<name>"}``
77
* ``{"type": "object", "name": "<name>"}`` (or ``"prn"``)
88
9-
This module is used both by the principal (single-object ``has_perm`` and the model-level
10-
permission set) and by ``get_objects_for_user`` (list filtering). Roles are read from the
11-
database to resolve their permissions; the grant assignment itself is never stored.
9+
Roles are read from the database to resolve their permissions; the grant assignment is never stored.
1210
"""
1311

1412
from django.db.models import Q
@@ -48,11 +46,19 @@ def _scope_matches(scope, obj):
4846
domain = getattr(obj, "pulp_domain", None)
4947
return domain is not None and domain.name == scope.get("domain")
5048
if stype == "object":
51-
checks = (("prn", "prn"), ("name", "name"))
52-
provided = [key for key, _ in checks if key in scope]
53-
if not provided:
49+
if "prn" not in scope and "name" not in scope:
5450
return False
55-
return all(str(getattr(obj, attr, None)) == str(scope[key]) for key, attr in checks if key in scope)
51+
if "prn" in scope:
52+
from pulpcore.app.util import get_prn
53+
54+
try:
55+
if get_prn(obj) != scope["prn"]:
56+
return False
57+
except Exception:
58+
return False
59+
if "name" in scope and str(getattr(obj, "name", None)) != str(scope["name"]):
60+
return False
61+
return True
5662
return False
5763

5864

@@ -67,15 +73,26 @@ def has_grant_perm(grants, permission, obj=None):
6773
return False
6874

6975

70-
def permissions_for(grants):
71-
"""The set of ``app_label.codename`` the grants confer, for model-level ``get_all_permissions``."""
76+
def permissions_for(grants, obj=None):
77+
"""The set of ``app_label.codename`` the grants confer, scoped to ``obj`` when given."""
7278
from pulpcore.app.models.role import Role
7379

7480
names = {g.get("role") for g in grants if g.get("role")}
75-
perms = set()
81+
if not names:
82+
return set()
83+
role_perms = {}
7684
for role in Role.objects.filter(name__in=names).prefetch_related("permissions__content_type"):
77-
for perm in role.permissions.all():
78-
perms.add(f"{perm.content_type.app_label}.{perm.codename}")
85+
role_perms[role.name] = {
86+
f"{perm.content_type.app_label}.{perm.codename}" for perm in role.permissions.all()
87+
}
88+
perms = set()
89+
for grant in grants:
90+
conferred = role_perms.get(grant.get("role"))
91+
if not conferred:
92+
continue
93+
if obj is not None and not _scope_matches(grant.get("scope", {}), obj):
94+
continue
95+
perms |= conferred
7996
return perms
8097

8198

@@ -98,7 +115,12 @@ def grants_queryset(grants, permission, queryset):
98115
clause = Q(pulp_domain__name=scope.get("domain"))
99116
elif stype == "object":
100117
if "prn" in scope:
101-
clause = Q(prn=scope["prn"])
118+
from pulpcore.app.util import extract_pk
119+
120+
try:
121+
clause = Q(pk=extract_pk(scope["prn"], only_prn=True))
122+
except Exception:
123+
clause = None
102124
elif "name" in scope:
103125
clause = Q(name=scope["name"])
104126
if clause is not None:
Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
"""Access to the ``OIDC_AUTH`` setting and per-provider JWKS clients."""
1+
"""Access to the ``WORKLOAD_IDENTITY`` setting and per-provider JWKS clients."""
22

33
import functools
44

@@ -7,7 +7,7 @@
77

88

99
def config():
10-
return getattr(settings, "OIDC_AUTH", {}) or {}
10+
return getattr(settings, "WORKLOAD_IDENTITY", {}) or {}
1111

1212

1313
def strategy():

0 commit comments

Comments
 (0)