Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
4 changes: 2 additions & 2 deletions .github/workflows/scripts/before_install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -49,8 +49,8 @@ plugin_name: "pulpcore"
legacy_component_name: "pulpcore"
component_name: "core"
component_version: "${COMPONENT_VERSION}"
pulp_env: {"PULP_CA_BUNDLE": "/etc/pulp/certs/pulp_webserver.crt"}
pulp_settings: {"allowed_export_paths": ["/tmp"], "allowed_import_paths": ["/tmp"], "api_root": "/pulp/", "content_path_prefix": "/somewhere/else/", "csrf_trusted_origins": ["https://pulp:443"], "distributed_publication_retention_period": 3, "orphan_protection_time": 0, "task_diagnostics": ["memory"], "task_protection_time": 10, "tmpfile_protection_time": 10, "upload_protection_time": 10}
pulp_env: {"ENVVAR_HEADER_GUARD_TEST_SECRET": "functional-test-secret-value", "PULP_CA_BUNDLE": "/etc/pulp/certs/pulp_webserver.crt"}
pulp_settings: {"allowed_export_paths": ["/tmp"], "allowed_import_paths": ["/tmp"], "api_root": "/pulp/", "content_path_prefix": "/somewhere/else/", "csrf_trusted_origins": ["https://pulp:443"], "distributed_publication_retention_period": 3, "envvar_header_content_guard_allowed_vars": ["ENVVAR_HEADER_GUARD_TEST_SECRET"], "orphan_protection_time": 0, "task_diagnostics": ["memory"], "task_protection_time": 10, "tmpfile_protection_time": 10, "upload_protection_time": 10}
pulp_scheme: "https"
image:
name: "pulp"
Expand Down
1 change: 1 addition & 0 deletions CHANGES/8007.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Added ``EnvVarHeaderContentGuard`` to validate a Base64-encoded header against a content-app environment variable listed in ``ENVVAR_HEADER_CONTENT_GUARD_ALLOWED_VARS``.
13 changes: 13 additions & 0 deletions docs/admin/reference/settings.md
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,19 @@ ALLOWED_IMPORT_PATHS = ['/mnt/foo/bar'] # only a subpath is needed

Defaults to `[]`, meaning `file:///` urls are not allowed in any Remote.

### ENVVAR\_HEADER\_CONTENT\_GUARD\_ALLOWED\_VARS

Names of process environment variables that `EnvVarHeaderContentGuard` may read.

```
ENVVAR_HEADER_CONTENT_GUARD_ALLOWED_VARS = ["SHARED_SECRET"]
```

Defaults to `[]`, meaning no environment variable may be used. The secret must be present in the
**content app** process environment (not only the API). Creating this guard type is a privileged
operation: only names on this list can be referenced, which prevents using the content app as an
oracle for other process secrets.

### ANALYTICS

If `True`, Pulp will anonymously post analytics information to
Expand Down
31 changes: 31 additions & 0 deletions docs/user/guides/protect-content.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,37 @@ pulp content-guard header create --name header-guard --header-name X-Pulp-User -
pulp content-guard header create --name header-guard --header-name X-Auth-Service --header-value true --jq-filter '.authenticated'
```

### EnvVar Header Content Guard

The env-var header content guard checks a request header against a secret stored in a
**content-app** environment variable. Proxies must send `base64(utf-8(secret))` in the configured
header. Pulp reads the plaintext secret from the content app at request time, so rotating the
secret is a deployment change rather than a database update.

The environment variable name must be listed in
`ENVVAR_HEADER_CONTENT_GUARD_ALLOWED_VARS`. Creating this guard is privileged: the name is a
pointer into the content-app process environment, so this type should not be granted to
untrusted tenants.

Set the secret on every content-app replica (and typically the API as well). Setting it only on
the API causes all content requests to be denied.

Pulp CLI commands for this guard type are not available yet. Use the REST API:

```bash
# Allow the env var, then create a guard that checks X-Pulp-Shared-Secret against it
# ENVVAR_HEADER_CONTENT_GUARD_ALLOWED_VARS = ["SHARED_SECRET"]
export GUARD_HREF=$(curl -s -X POST :24817/pulp/api/v3/contentguards/core/envvar_header/ \
-H "Content-Type: application/json" \
-d '{"name": "shared-secret-guard", "header_name": "X-Pulp-Shared-Secret", "env_var": "SHARED_SECRET"}' \
| jq -r '.pulp_href')

# Assign it to an existing file distribution (DISTRO_HREF is that distribution's pulp_href)
curl -s -X PATCH :24817${DISTRO_HREF} \
-H "Content-Type: application/json" \
-d "{\"content_guard\": \"${GUARD_HREF}\"}"
```

### Composite Content Guard

The composite content guard combines multiple guards using OR logic - if any of the configured guards allows access, the request is permitted. This enables flexible authentication schemes, like allowing access via either certificates OR RBAC authentication.
Expand Down
43 changes: 43 additions & 0 deletions pulpcore/app/migrations/0158_envvarheadercontentguard.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
# Generated by Django 5.2.15 on 2026-08-26

import django.db.models.deletion
from django.db import migrations, models

import pulpcore.app.models.access_policy


class Migration(migrations.Migration):
dependencies = [
("core", "0157_distribution_base_path_constraint"),
]

operations = [
migrations.CreateModel(
name="EnvVarHeaderContentGuard",
fields=[
(
"contentguard_ptr",
models.OneToOneField(
auto_created=True,
on_delete=django.db.models.deletion.CASCADE,
parent_link=True,
primary_key=True,
serialize=False,
to="core.contentguard",
),
),
("header_name", models.TextField()),
("env_var", models.TextField()),
],
options={
"permissions": (
(
"manage_roles_envvarheadercontentguard",
"Can manage role assignments on EnvVar Header content guard",
),
),
"default_related_name": "%(app_label)s_%(model_name)s",
},
bases=("core.contentguard", pulpcore.app.models.access_policy.AutoAddObjPermsMixin),
),
]
2 changes: 2 additions & 0 deletions pulpcore/app/models/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@
CompositeContentGuard,
ContentRedirectContentGuard,
HeaderContentGuard,
EnvVarHeaderContentGuard,
ArtifactDistribution,
)

Expand Down Expand Up @@ -149,6 +150,7 @@
"CompositeContentGuard",
"ContentRedirectContentGuard",
"HeaderContentGuard",
"EnvVarHeaderContentGuard",
"ArtifactDistribution",
"Remote",
"Repository",
Expand Down
72 changes: 72 additions & 0 deletions pulpcore/app/models/publication.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import hashlib
import hmac
import json
import logging
import os
Expand Down Expand Up @@ -556,6 +557,77 @@ class Meta:
)


class EnvVarHeaderContentGuard(ContentGuard, AutoAddObjPermsMixin):
"""
Content guard that validates a Base64-encoded header against a server-side environment variable.

Clients and proxies must send the expected secret as a Base64-encoded UTF-8 string in
``header_name``. Pulp decodes the header, then compares the result to the value of
``os.environ[env_var]`` using a timing-safe comparison.

``env_var`` must be listed in ``settings.ENVVAR_HEADER_CONTENT_GUARD_ALLOWED_VARS``.
The expected secret is read from the content-app process environment at request time
so rotation only requires updating the environment and redeploying.
"""

TYPE = "envvar_header"

header_name = models.TextField()
env_var = models.TextField()

def permit(self, request):
if self.env_var not in settings.ENVVAR_HEADER_CONTENT_GUARD_ALLOWED_VARS:
_logger.debug(
"Access not allowed. Environment variable %s is not in "
"ENVVAR_HEADER_CONTENT_GUARD_ALLOWED_VARS.",
self.env_var,
)
raise PermissionError(_("Access denied."))

header_content = request.headers.get(self.header_name)
if not header_content:
_logger.debug("Access not allowed. Header %s not found.", self.header_name)
raise PermissionError(_("Access denied."))

try:
header_decoded_content = b64decode(header_content, validate=True)
except Base64DecodeError:
_logger.debug("Access not allowed - Header content is not Base64 encoded.")
raise PermissionError(_("Access denied.")) from None

try:
header_value = header_decoded_content.decode("utf-8")
except UnicodeDecodeError:
_logger.debug("Access not allowed - Header content is not valid UTF-8.")
raise PermissionError(_("Access denied.")) from None

expected = os.environ.get(self.env_var)
if expected is None or expected.rstrip("\r\n") == "":
_logger.warning(
"Access not allowed. Environment variable %s is unset or empty.", self.env_var
)
raise PermissionError(_("Access denied."))

expected_stripped = expected.rstrip("\r\n")
if not hmac.compare_digest(
header_value.encode("utf-8"),
expected_stripped.encode("utf-8"),
):
_logger.debug("Access not allowed. Header value does not match environment variable.")
raise PermissionError(_("Access denied."))

return

class Meta:
default_related_name = "%(app_label)s_%(model_name)s"
permissions = (
(
"manage_roles_envvarheadercontentguard",
"Can manage role assignments on EnvVar Header content guard",
),
)


class CompositeContentGuard(ContentGuard, AutoAddObjPermsMixin):
"""
Content guard to allow a list of contentguards to be evaluated on access.
Expand Down
1 change: 1 addition & 0 deletions pulpcore/app/serializers/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@
CompositeContentGuardSerializer,
ContentRedirectContentGuardSerializer,
HeaderContentGuardSerializer,
EnvVarHeaderContentGuardSerializer,
ArtifactDistributionSerializer,
)
from .purge import PurgeSerializer
Expand Down
34 changes: 34 additions & 0 deletions pulpcore/app/serializers/publication.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from gettext import gettext as _

from django.conf import settings
from django.db.models import Q
from drf_spectacular.utils import extend_schema_field
from rest_framework import serializers
Expand Down Expand Up @@ -173,6 +174,39 @@ class Meta(ContentGuardSerializer.Meta):
fields = ContentGuardSerializer.Meta.fields + ("header_name", "header_value", "jq_filter")


class EnvVarHeaderContentGuardSerializer(ContentGuardSerializer, GetOrCreateSerializerMixin):
"""
A serializer for EnvVarHeaderContentGuard.

The guard expects the request header named ``header_name`` to carry a Base64-encoded
UTF-8 representation of the secret. The plaintext secret is read from ``env_var`` on
the server at request time and is never stored in or returned by the API.
"""

header_name = serializers.CharField(help_text=_("The header name the guard will check on."))
env_var = serializers.CharField(
help_text=_(
"Name of a content-app environment variable holding the expected secret "
"(plaintext UTF-8). Must be listed in ENVVAR_HEADER_CONTENT_GUARD_ALLOWED_VARS. "
"The request header must send that value Base64-encoded. "
"The value is never stored in or returned by the API."
),
)

def validate_env_var(self, value):
if value not in settings.ENVVAR_HEADER_CONTENT_GUARD_ALLOWED_VARS:
raise serializers.ValidationError(
_(
"Environment variable '{}' is not in ENVVAR_HEADER_CONTENT_GUARD_ALLOWED_VARS."
).format(value)
)
return value

class Meta(ContentGuardSerializer.Meta):
model = models.EnvVarHeaderContentGuard
fields = ContentGuardSerializer.Meta.fields + ("header_name", "env_var")


class DistributionSerializer(ModelSerializer):
"""
The Serializer for the Distribution model.
Expand Down
4 changes: 4 additions & 0 deletions pulpcore/app/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -321,6 +321,10 @@

ALLOWED_EXPORT_PATHS = []

# Process environment variable names EnvVarHeaderContentGuard may read.
# Empty list means no variable is allowed.
ENVVAR_HEADER_CONTENT_GUARD_ALLOWED_VARS = []

# https://docs.djangoproject.com/en/5.2/ref/settings/#std-setting-CACHES
CACHES = {
"default": {
Expand Down
1 change: 1 addition & 0 deletions pulpcore/app/viewsets/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@
CompositeContentGuardViewSet,
ContentRedirectContentGuardViewSet,
HeaderContentGuardViewSet,
EnvVarHeaderContentGuardViewSet,
ArtifactDistributionViewSet,
)
from .reclaim import ReclaimSpaceViewSet
Expand Down
78 changes: 78 additions & 0 deletions pulpcore/app/viewsets/publication.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
ContentGuard,
ContentRedirectContentGuard,
Distribution,
EnvVarHeaderContentGuard,
HeaderContentGuard,
Publication,
RBACContentGuard,
Expand All @@ -20,6 +21,7 @@
ContentGuardSerializer,
ContentRedirectContentGuardSerializer,
DistributionSerializer,
EnvVarHeaderContentGuardSerializer,
HeaderContentGuardSerializer,
PublicationSerializer,
RBACContentGuardSerializer,
Expand Down Expand Up @@ -412,6 +414,82 @@ class HeaderContentGuardViewSet(ContentGuardViewSet, RolesMixin):
}


class EnvVarHeaderContentGuardViewSet(ContentGuardViewSet, RolesMixin):
"""
Content guard that validates a Base64-encoded header against a server-side environment variable.
"""

endpoint_name = "envvar_header"
queryset = EnvVarHeaderContentGuard.objects.all()
serializer_class = EnvVarHeaderContentGuardSerializer
queryset_filtering_required_permission = "core.view_envvarheadercontentguard"

DEFAULT_ACCESS_POLICY = {
"statements": [
{
"action": ["list"],
"principal": "authenticated",
"effect": "allow",
},
{
"action": ["create"],
"principal": "authenticated",
"effect": "allow",
"condition": "has_model_or_domain_perms:core.add_envvarheadercontentguard",
},
{
"action": ["retrieve", "my_permissions"],
"principal": "authenticated",
"effect": "allow",
"condition": (
"has_model_or_domain_or_obj_perms:core.view_envvarheadercontentguard"
),
},
{
"action": ["update", "partial_update"],
"principal": "authenticated",
"effect": "allow",
"condition": (
"has_model_or_domain_or_obj_perms:core.change_envvarheadercontentguard"
),
},
{
"action": ["destroy"],
"principal": "authenticated",
"effect": "allow",
"condition": (
"has_model_or_domain_or_obj_perms:core.delete_envvarheadercontentguard"
),
},
{
"action": ["list_roles", "add_role", "remove_role"],
"principal": "authenticated",
"effect": "allow",
"condition": (
"has_model_or_domain_or_obj_perms:core.manage_roles_envvarheadercontentguard"
),
},
],
"creation_hooks": [
{
"function": "add_roles_for_object_creator",
"parameters": {"roles": ["core.envvarheadercontentguard_owner"]},
},
],
"queryset_scoping": {"function": "scope_queryset"},
}
LOCKED_ROLES = {
"core.envvarheadercontentguard_creator": ["core.add_envvarheadercontentguard"],
"core.envvarheadercontentguard_owner": [
"core.view_envvarheadercontentguard",
"core.change_envvarheadercontentguard",
"core.delete_envvarheadercontentguard",
"core.manage_roles_envvarheadercontentguard",
],
"core.envvarheadercontentguard_viewer": ["core.view_envvarheadercontentguard"],
}


class CompositeContentGuardViewSet(ContentGuardViewSet, RolesMixin):
"""
Content guard that queries a list-of content-guards for access permissions.
Expand Down
Loading
Loading