-
Notifications
You must be signed in to change notification settings - Fork 159
Workload Identity #7850
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Workload Identity #7850
Changes from all commits
6141926
88ecdfd
4accbf4
2c4aeb5
cd380ef
35e29b2
9707260
60e5109
6b78c53
be36f71
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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"}, | ||
| }, | ||
| ], | ||
| }, | ||
| ], | ||
| }, | ||
| }, | ||
| } | ||
| ``` |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -123,3 +123,93 @@ def check_artifact_checksums(app_configs, **kwargs): | |
| ) | ||
|
|
||
| return messages | ||
|
|
||
|
|
||
| @register(deploy=True) | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. All of these should check |
||
| def workload_identity_reserved_username(app_configs, **kwargs): | ||
| from pulpcore.app.workload_identity import config | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Keep this import here. (We don't want to load these modules if not configured.) |
||
|
|
||
| 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( | ||
|
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): | ||
| 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 | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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 | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Mask this similar to: |
||
|
|
||
| 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: | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -158,6 +158,7 @@ | |
| AUTHENTICATION_BACKENDS = [ | ||
| "django.contrib.auth.backends.ModelBackend", | ||
| "pulpcore.backends.ObjectRolePermissionBackend", | ||
| "pulpcore.app.workload_identity.backend.WorkloadIdentityBackend", | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This should be an addon. |
||
| ] | ||
|
|
||
| ROOT_URLCONF = "pulpcore.app.urls" | ||
|
|
@@ -316,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 = [] | ||
|
|
||
| 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. | ||
| """ |
| 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 the token and return `(principal, claims)`, or `None` if it is 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" |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I don't think there's a reason for this import to be lazy.