diff --git a/docs/admin/guides/_SUMMARY.md b/docs/admin/guides/_SUMMARY.md index fe1d8f15e76..1965cc35575 100644 --- a/docs/admin/guides/_SUMMARY.md +++ b/docs/admin/guides/_SUMMARY.md @@ -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) diff --git a/docs/admin/guides/auth/workload_identity.md b/docs/admin/guides/auth/workload_identity.md new file mode 100644 index 00000000000..01cb87c95fe --- /dev/null +++ b/docs/admin/guides/auth/workload_identity.md @@ -0,0 +1,102 @@ +# 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` header, either as a `Bearer` token or as +the password of a `Basic` header (the way `docker login` sends a token). 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`, before `BasicAuthentication` so the +`docker login` path reaches it, 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": ""} objects in a domain + # {"type": "object", "name": ""} one object by name + # {"type": "object", "prn": ""} one object by PRN + "scope": {"type": "object", "name": "prod"}, + }, + ], + }, + ], + }, + }, +} +``` diff --git a/pulpcore/app/access_policy.py b/pulpcore/app/access_policy.py index 6cb41c02b7b..a6175f993f1 100644 --- a/pulpcore/app/access_policy.py +++ b/pulpcore/app/access_policy.py @@ -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) + if group_names is not None: + return list(group_names) + return super().get_user_group_values(user) + @classmethod def get_access_policy(cls, view): """ diff --git a/pulpcore/app/role_util.py b/pulpcore/app/role_util.py index 103c68541e3..2bd319793a6 100644 --- a/pulpcore/app/role_util.py +++ b/pulpcore/app/role_util.py @@ -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: diff --git a/pulpcore/app/settings.py b/pulpcore/app/settings.py index 6961b88f302..f901015d2e6 100644 --- a/pulpcore/app/settings.py +++ b/pulpcore/app/settings.py @@ -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 = [] diff --git a/pulpcore/app/workload_identity/__init__.py b/pulpcore/app/workload_identity/__init__.py new file mode 100644 index 00000000000..63efe9d54b2 --- /dev/null +++ b/pulpcore/app/workload_identity/__init__.py @@ -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. +""" diff --git a/pulpcore/app/workload_identity/authentication.py b/pulpcore/app/workload_identity/authentication.py new file mode 100644 index 00000000000..64a9b3c5d3a --- /dev/null +++ b/pulpcore/app/workload_identity/authentication.py @@ -0,0 +1,94 @@ +"""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 in a ``Basic`` header (``docker login``). +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 (Bearer, or Basic password), or None.""" + 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 + if ":" not in decoded: + return None + _, _, password = decoded.partition(":") + 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" diff --git a/pulpcore/app/workload_identity/authz.py b/pulpcore/app/workload_identity/authz.py new file mode 100644 index 00000000000..c6a28a0c26b --- /dev/null +++ b/pulpcore/app/workload_identity/authz.py @@ -0,0 +1,131 @@ +"""Shared authorization logic over a list of grants. + +A grant is ``{"role": , "scope": {...}}``. Scope is one of: + +* ``{"type": "global"}`` +* ``{"type": "domain", "domain": ""}`` +* ``{"type": "object", "name": ""}`` (or ``"prn"``) + +Roles are read from the database to resolve their permissions; the grant assignment is never stored. +""" + +from django.db.models import Q + + +def _split(permission): + app_label, _, codename = permission.partition(".") + return app_label, codename + + +def _role_has_perm(role_name, app_label, codename): + from pulpcore.app.models.role import Role + + if not role_name: + return False + try: + role = Role.objects.get(name=role_name) + except Role.DoesNotExist: + return False + return role.permissions.filter( + content_type__app_label=app_label, codename=codename + ).exists() + + +def _scope_matches(scope, obj): + """Whether a scope applies to a single object (``obj`` may be ``None`` for model-level).""" + from pulpcore.app.models import Domain + + stype = scope.get("type") + if stype == "global": + return True + if obj is None: + return False + if stype == "domain": + if isinstance(obj, Domain): + return obj.name == scope.get("domain") + domain = getattr(obj, "pulp_domain", None) + return domain is not None and domain.name == scope.get("domain") + if stype == "object": + if "prn" not in scope and "name" not in scope: + return False + if "prn" in scope: + from pulpcore.app.util import get_prn + + try: + if get_prn(obj) != scope["prn"]: + return False + except Exception: + return False + if "name" in scope and str(getattr(obj, "name", None)) != str(scope["name"]): + return False + return True + return False + + +def has_grant_perm(grants, permission, obj=None): + """True if any grant confers ``permission`` and its scope matches ``obj``.""" + app_label, codename = _split(permission) + for grant in grants: + if _role_has_perm(grant.get("role"), app_label, codename) and _scope_matches( + grant.get("scope", {}), obj + ): + return True + return False + + +def permissions_for(grants, obj=None): + """The set of ``app_label.codename`` the grants confer, scoped to ``obj`` when given.""" + from pulpcore.app.models.role import Role + + names = {g.get("role") for g in grants if g.get("role")} + if not names: + return set() + role_perms = {} + for role in Role.objects.filter(name__in=names).prefetch_related("permissions__content_type"): + role_perms[role.name] = { + f"{perm.content_type.app_label}.{perm.codename}" for perm in role.permissions.all() + } + perms = set() + for grant in grants: + conferred = role_perms.get(grant.get("role")) + if not conferred: + continue + if obj is not None and not _scope_matches(grant.get("scope", {}), obj): + continue + perms |= conferred + return perms + + +def grants_queryset(grants, permission, queryset): + """Return ``queryset`` filtered to the objects the grants allow for ``permission``.""" + app_label, codename = _split(permission) + relevant = [g for g in grants if _role_has_perm(g.get("role"), app_label, codename)] + if not relevant: + return queryset.none() + + has_domain = hasattr(queryset.model, "pulp_domain") + predicate = None + for grant in relevant: + scope = grant.get("scope", {}) + stype = scope.get("type") + if stype == "global": + return queryset + clause = None + if stype == "domain" and has_domain: + clause = Q(pulp_domain__name=scope.get("domain")) + elif stype == "object": + if "prn" in scope: + from pulpcore.app.util import extract_pk + + try: + clause = Q(pk=extract_pk(scope["prn"], only_prn=True)) + except Exception: + clause = None + elif "name" in scope: + clause = Q(name=scope["name"]) + if clause is not None: + predicate = clause if predicate is None else (predicate | clause) + + if predicate is None: + return queryset.none() + return queryset.filter(predicate) diff --git a/pulpcore/app/workload_identity/config.py b/pulpcore/app/workload_identity/config.py new file mode 100644 index 00000000000..d72c5c4d818 --- /dev/null +++ b/pulpcore/app/workload_identity/config.py @@ -0,0 +1,35 @@ +"""Access to the ``WORKLOAD_IDENTITY`` setting and per-provider JWKS clients.""" + +import functools + +from django.conf import settings +from jwt import PyJWKClient + + +def config(): + return getattr(settings, "WORKLOAD_IDENTITY", {}) or {} + + +def strategy(): + return config().get("strategy", "union") + + +def providers(): + return config().get("providers", {}) or {} + + +def provider_for_issuer(issuer): + """Return the provider entry whose ``issuer`` matches, or ``None``.""" + for entry in providers().values(): + if entry.get("issuer") == issuer: + return entry + return None + + +@functools.lru_cache(maxsize=None) +def _jwks_client(jwks_url): + return PyJWKClient(jwks_url, cache_keys=True) + + +def jwks_client(provider): + return _jwks_client(provider["jwks_url"]) diff --git a/pulpcore/app/workload_identity/principal.py b/pulpcore/app/workload_identity/principal.py new file mode 100644 index 00000000000..be5fe2145f5 --- /dev/null +++ b/pulpcore/app/workload_identity/principal.py @@ -0,0 +1,59 @@ +"""A stateless ``request.user`` for workload-identity clients. + +It does not subclass the user model, and answers permission checks from its own grants so Django +never dispatches them to ``AUTHENTICATION_BACKENDS``. +""" + +from pulpcore.app.workload_identity.authz import has_grant_perm, permissions_for + + +class WorkloadIdentityPrincipal: + """A user-like object backed by grants rather than a database row, safe as ``request.user``.""" + + is_authenticated = True + is_active = True + is_anonymous = False + is_superuser = False + is_staff = False + pk = None + id = None + group_names = () + + def __init__(self, grants, username=""): + self.grants = grants + self.username = username + + @property + def groups(self): + """An empty ``Group`` queryset so ``user.groups.all()`` never crashes.""" + from pulpcore.app.models import Group + + return Group.objects.none() + + def has_perm(self, perm, obj=None): + """Whether the grants confer ``perm`` (optionally scoped to ``obj``).""" + return has_grant_perm(self.grants, perm, obj) + + def has_perms(self, perm_list, obj=None): + """Whether the grants confer every permission in ``perm_list``.""" + return all(self.has_perm(perm, obj) for perm in perm_list) + + def has_module_perms(self, app_label): + """Whether the grants confer any permission in ``app_label``.""" + prefix = f"{app_label}." + return any(perm.startswith(prefix) for perm in self.get_all_permissions()) + + def get_all_permissions(self, obj=None): + """``app_label.codename`` at the model level, bare codenames for an object (Pulp's convention).""" + perms = permissions_for(self.grants, obj) + if obj is None: + return perms + return {perm.split(".", 1)[1] for perm in perms} + + def get_group_permissions(self, obj=None): + """The permissions from groups; always empty for a principal.""" + return set() + + def __str__(self): + """The username, or ``"oidc-principal"`` when it is empty.""" + return self.username or "oidc-principal" diff --git a/pulpcore/app/workload_identity/rules.py b/pulpcore/app/workload_identity/rules.py new file mode 100644 index 00000000000..fc0a498cebf --- /dev/null +++ b/pulpcore/app/workload_identity/rules.py @@ -0,0 +1,32 @@ +"""Match token claims against the provider rules and collect the grants they earn. + +Nothing here touches the database. A grant is ``{"role": , "scope": {...}}``. +""" + +import fnmatch + +from pulpcore.app.workload_identity.config import strategy + + +def _matches(match_spec, claims): + """All claim conditions must match (AND). Values support ``*`` globbing.""" + for claim, pattern in match_spec.items(): + value = claims.get(claim) + if value is None or not fnmatch.fnmatchcase(str(value), str(pattern)): + return False + return True + + +def grants_for(provider, claims): + """Return the list of grants the claims earn for this provider. + + ``strategy`` "union" accumulates every matching rule; "first-match" stops at the first. + """ + grants = [] + first_match = strategy() == "first-match" + for rule in provider.get("rules", []): + if _matches(rule.get("match", {}), claims): + grants.extend(rule.get("grants", [])) + if first_match: + break + return grants diff --git a/pulpcore/tests/unit/workload_identity/__init__.py b/pulpcore/tests/unit/workload_identity/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/pulpcore/tests/unit/workload_identity/test_workload_identity.py b/pulpcore/tests/unit/workload_identity/test_workload_identity.py new file mode 100644 index 00000000000..33ce57af165 --- /dev/null +++ b/pulpcore/tests/unit/workload_identity/test_workload_identity.py @@ -0,0 +1,141 @@ +import pytest +from django.contrib.auth.models import Permission +from django.test import override_settings + +from pulpcore.app.models import Repository +from pulpcore.app.models.role import Role +from pulpcore.app.role_util import get_objects_for_user + +from pulpcore.app.workload_identity.authz import grants_queryset, has_grant_perm, permissions_for +from pulpcore.app.workload_identity.principal import WorkloadIdentityPrincipal +from pulpcore.app.workload_identity.rules import grants_for + + +PROVIDER = { + "issuer": "https://issuer", + "rules": [ + { + "match": {"repository": "org/infra", "ref": "refs/heads/main"}, + "grants": [{"role": "role1", "scope": {"type": "global"}}], + }, + { + "match": {"repository": "org/*"}, + "grants": [{"role": "role2", "scope": {"type": "domain", "domain": "default"}}], + }, + ], +} + + +# --- rules.grants_for (no database) --- + + +def test_rules_no_match(): + assert grants_for(PROVIDER, {"repository": "other/x"}) == [] + + +@override_settings(WORKLOAD_IDENTITY={"strategy": "union"}) +def test_rules_union_accumulates(): + grants = grants_for(PROVIDER, {"repository": "org/infra", "ref": "refs/heads/main"}) + assert {g["role"] for g in grants} == {"role1", "role2"} + + +@override_settings(WORKLOAD_IDENTITY={"strategy": "first-match"}) +def test_rules_first_match_stops(): + grants = grants_for(PROVIDER, {"repository": "org/infra", "ref": "refs/heads/main"}) + assert [g["role"] for g in grants] == ["role1"] + + +def test_rules_glob_only(): + grants = grants_for(PROVIDER, {"repository": "org/app"}) + assert [g["role"] for g in grants] == ["role2"] + + +# --- database fixtures --- + + +@pytest.fixture +def repo_viewer_role(db): + role = Role.objects.create(name="repo_viewer") + role.permissions.add( + Permission.objects.get(content_type__app_label="core", codename="view_repository") + ) + return role + + +@pytest.fixture +def repo_a(db): + return Repository.objects.create(name="repo-a") + + +@pytest.fixture +def repo_b(db): + return Repository.objects.create(name="repo-b") + + +# --- authz.has_grant_perm --- + + +def test_has_grant_perm_global(repo_viewer_role, repo_a): + grants = [{"role": "repo_viewer", "scope": {"type": "global"}}] + assert has_grant_perm(grants, "core.view_repository", repo_a) is True + + +def test_has_grant_perm_object_scope(repo_viewer_role, repo_a, repo_b): + grants = [{"role": "repo_viewer", "scope": {"type": "object", "name": "repo-a"}}] + assert has_grant_perm(grants, "core.view_repository", repo_a) is True + assert has_grant_perm(grants, "core.view_repository", repo_b) is False + + +def test_has_grant_perm_wrong_permission(repo_viewer_role, repo_a): + grants = [{"role": "repo_viewer", "scope": {"type": "global"}}] + assert has_grant_perm(grants, "core.change_repository", repo_a) is False + + +def test_has_grant_perm_missing_role(db, repo_a): + grants = [{"role": "does_not_exist", "scope": {"type": "global"}}] + assert has_grant_perm(grants, "core.view_repository", repo_a) is False + + +# --- authz.permissions_for --- + + +def test_permissions_for(repo_viewer_role): + grants = [{"role": "repo_viewer", "scope": {"type": "global"}}] + assert "core.view_repository" in permissions_for(grants) + + +# --- authz.grants_queryset --- + + +def test_grants_queryset_global(repo_viewer_role, repo_a, repo_b): + grants = [{"role": "repo_viewer", "scope": {"type": "global"}}] + qs = grants_queryset(grants, "core.view_repository", Repository.objects.all()) + assert {"repo-a", "repo-b"} <= set(qs.values_list("name", flat=True)) + + +def test_grants_queryset_object(repo_viewer_role, repo_a, repo_b): + grants = [{"role": "repo_viewer", "scope": {"type": "object", "name": "repo-a"}}] + qs = grants_queryset(grants, "core.view_repository", Repository.objects.all()) + assert set(qs.values_list("name", flat=True)) == {"repo-a"} + + +def test_grants_queryset_no_relevant_grant(db, repo_a): + grants = [{"role": "does_not_exist", "scope": {"type": "global"}}] + qs = grants_queryset(grants, "core.view_repository", Repository.objects.all()) + assert qs.count() == 0 + + +# --- principal + get_objects_for_user branch --- + + +def test_principal_is_authenticated_and_has_perm(repo_viewer_role, repo_a): + principal = WorkloadIdentityPrincipal([{"role": "repo_viewer", "scope": {"type": "global"}}]) + assert principal.is_authenticated is True + assert principal.pk is None + assert principal.has_perm("core.view_repository", repo_a) is True + + +def test_get_objects_for_user_scopes_by_grants(repo_viewer_role, repo_a, repo_b): + principal = WorkloadIdentityPrincipal([{"role": "repo_viewer", "scope": {"type": "object", "name": "repo-a"}}]) + qs = get_objects_for_user(principal, "core.view_repository", Repository.objects.all()) + assert set(qs.values_list("name", flat=True)) == {"repo-a"} diff --git a/pyproject.toml b/pyproject.toml index 4502deab44b..700f1ba489a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -31,6 +31,7 @@ dependencies = [ "backoff>=2.1.2,<2.3", # Looks like only bugfixes in z-Stream. "click>=8.1.0,<8.5", # Uses milestone.feature.fix https://palletsprojects.com/versions . "cryptography>=44.0.3,<50.0", # SemVer compatible https://cryptography.io/en/latest/api-stability/#versioning . + "pyjwt>=2.4,<3", # SemVer. Used to validate OIDC tokens against a provider JWKS. "Django>=5.2.0,<5.3", # LTS version, we aim at supporting one or two at a time. "django-filter>=24.3,<=25.2", # Uses CalVer, not released often https://github.com/carltongibson/django-filter "django-guid>=3.4.0,<3.7", # Looks like only bugfixes in z-Stream.