Skip to content
Draft
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
6 changes: 6 additions & 0 deletions CHANGES/727.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
Added PackagePermissionGuard content guard for fine-grained package-level access control.

PackagePermissionGuard allows controlling download and upload permissions for individual PyPI packages
within a single distribution/index. Policies map package names to lists of user/group PRNs that are
allowed to access those packages.

94 changes: 93 additions & 1 deletion docs/admin/guides/rbac.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,88 @@ new RBACContentGuard:
!!! warning
The PyPI access policies do not support `creation_hooks` or `queryset_scoping`.

## PackagePermissionGuard

The PackagePermissionGuard provides fine-grained, package-level access control within a single distribution/index.
Unlike RBACContentGuard which applies to all packages in a distribution, PackagePermissionGuard allows you to
control download and upload permissions for individual PyPI packages.

### Creating a PackagePermissionGuard

```bash
pulp content-guard package-permission create --name my-package-guard
```

### Managing Package Permissions

PackagePermissionGuard uses two separate policies:
- `download_policy`: Controls who can download specific packages
- `upload_policy`: Controls who can upload specific packages

Both policies map package names (normalized) to lists of user/group PRNs.

#### Adding Permissions

Use the `add` action to grant users/groups access to packages:

```bash
# Add download permissions for multiple packages
pulp content-guard package-permission add \
--name my-package-guard \
--packages shelf-reader django \
--users-groups prn:core.user:alice prn:core.group:developers \
--policy-type download

# Add upload permissions
pulp content-guard package-permission add \
--name my-package-guard \
--packages shelf-reader \
--users-groups prn:core.user:bob \
--policy-type upload
```

#### Removing Permissions

Use the `remove` action to revoke access:

```bash
# Remove specific users/groups from a package
pulp content-guard package-permission remove \
--name my-package-guard \
--packages shelf-reader \
--users-groups prn:core.user:alice \
--policy-type download

# Remove all permissions for a package (use '*' in users-groups)
pulp content-guard package-permission remove \
--name my-package-guard \
--packages django \
--users-groups '*' \
--policy-type download

# Remove all packages from a policy (use '*' in packages)
pulp content-guard package-permission remove \
--name my-package-guard \
--packages '*' \
--users-groups '' \
--policy-type download
```

### How PackagePermissionGuard Works

- **Downloads**: During downloads the guard's `permit()` method checks the `download_policy` to see
if there is a policy for the package. If no policy the download is permited. If there is one it
then checks that the user of the request is in the policy list for that package.

- **Uploads**: For uploads, the endpoint's access policy checks the `upload_policy` to see if the
user/group has permission for the package being uploaded. If the package or user is not in the
policy the upload is denied.

!!! note
For the content guard to properly protect uploads the `legacy` and `simple` AccessPolies must
use the `package_permission_check` condition for their `create` action. This is the current
default with a fallback to `index_has_repo_perm` when there is no content guard on the distro.

## Index Specific Access Conditions

Pulp Python comes with two specific access condition methods that can be used in the PyPI access policies.
Expand All @@ -89,5 +171,15 @@ the modify python repository permission.
This access condition checks if the user has the supplied permission on the index (distribution) itself. If no
permission is specified for the method then it will use `python.view_pythondistribution` as its default.

### `package_permission_check`

This access condition checks PackagePermissionGuard for package-level permissions. It extracts the package name
from the request (from URL path for downloads,from filename for uploads) and checks if the user/group has
permission for that specific package in the guard's policy. This condition is used by default in PyPI upload
endpoints to enable package-level upload control when a PackagePermissionGuard is attached to the distribution.

- For downloads: Checks `download_policy` and denies only if package in policy and user/group is not.
- For uploads: Checks `upload_policy` and denies access if package not in policy or user/group not allowed

!!! note
Both access condition methods are compatible with the Pulp Domains feature.
All access condition methods are compatible with the Pulp Domains feature.
74 changes: 74 additions & 0 deletions pulp_python/app/global_access_conditions.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,10 @@
from django.conf import settings
from django.contrib.auth.models import AnonymousUser
from packaging.utils import canonicalize_name
from pathlib import PurePath
from pulp_python.app.models import PackagePermissionGuard
from pypi_simple import parse_filename, UnparsableFilenameError
from pulpcore.plugin.util import get_prn


# Access Condition methods that can be used with PyPI access policies
Expand Down Expand Up @@ -28,3 +34,71 @@ def index_has_repo_perm(request, view, action, perm="python.view_pythonrepositor
if repo := view.distribution.repository:
return request.user.has_perm(perm, obj=repo.cast())
return True


def package_permission_check(request, view, action, policy_type="upload"):
"""
Access Policy condition that checks PackagePermissionGuard for package-level permissions.

Checks if the user/group has permission for the specific package being accessed.

Args:
policy_type: "download" or "upload" - which policy to check
"""
if hasattr(view, "content_guard"):
content_guard = view.content_guard
else:
content_guard = view.distribution.content_guard

# If no guard attached, deny access
if not content_guard or not isinstance(content_guard.cast(), PackagePermissionGuard):
return False

guard = content_guard.cast()
policy = guard.download_policy if policy_type == "download" else guard.upload_policy

# Extract package name from request
package_name = None

if policy_type == "upload":
# For uploads, extract from filename in request.FILES
# The file is uploaded as multipart/form-data with field name 'content'
if hasattr(request, 'FILES') and 'content' in request.FILES:
file_obj = request.FILES['content']
package_name = canonicalize_name(parse_filename(file_obj.name)[0])
else:
# For downloads, extract from URL path
if hasattr(view, "kwargs"):
# Check URL kwargs for package name
if 'package' in view.kwargs:
package_name = canonicalize_name(view.kwargs['package'])
elif 'meta' in view.kwargs:
# Metadata endpoint: pypi/{package}/json/ or pypi/{package}/{version}/json/
meta_path = PurePath(view.kwargs['meta'])
if meta_path.match("*/json") or meta_path.match("*/*/json"):
package_name = canonicalize_name(meta_path.parts[0])
else:
path = PurePath(request.path_info)
try:
package_name = parse_filename(path.name)[0]
except UnparsableFilenameError:
print(f"No package name found in path: {request.path_info}")

# Downloads are permissive, only deny if package in policy but user is not listed
# Uploads are strict, only allow if package in policy and user is listed
if package_name not in policy:
return policy_type == "download"

allowed_prns = policy[package_name]
if not request.user or isinstance(request.user, AnonymousUser):
return False
user_prn = get_prn(request.user)
group_prns = [get_prn(group) for group in request.user.groups.all()]

if user_prn and user_prn in allowed_prns:
return True

if any(group_prn in allowed_prns for group_prn in group_prns):
return True

return False
56 changes: 56 additions & 0 deletions pulp_python/app/migrations/0016_packagepermissionguard.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
# Generated by Django 4.2.25 on 2025-10-31 12:50

from django.db import migrations, models
import django.db.models.deletion
import pulpcore.app.models.access_policy


class Migration(migrations.Migration):

dependencies = [
("core", "0145_domainize_import_export"),
("python", "0015_alter_pythonpackagecontent_options"),
]

operations = [
migrations.CreateModel(
name="PackagePermissionGuard",
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",
),
),
(
"download_policy",
models.JSONField(
default=dict,
help_text="Dictionary mapping package names to lists of user/group PRNsthat are allowed to download those packages.",
),
),
(
"upload_policy",
models.JSONField(
default=dict,
help_text="Dictionary mapping package names to lists of user/group PRNsthat are allowed to upload those packages.",
),
),
],
options={
"permissions": [
(
"manage_roles_packagepermissionguard",
"Can manage role assignments on package permission guard",
)
],
"default_related_name": "%(app_label)s_%(model_name)s",
},
bases=("core.contentguard", pulpcore.app.models.access_policy.AutoAddObjPermsMixin),
),
]
52 changes: 52 additions & 0 deletions pulp_python/app/models.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,16 @@
from logging import getLogger
from gettext import gettext as _

from aiohttp.web import json_response
from django.contrib.postgres.fields import ArrayField
from django.core.exceptions import ObjectDoesNotExist
from django.db import models
from django.conf import settings
from rest_framework.views import APIView
from pulpcore.plugin.models import (
AutoAddObjPermsMixin,
Content,
ContentGuard,
Publication,
Distribution,
Remote,
Expand All @@ -20,6 +23,7 @@
artifact_to_python_content_data,
canonicalize_name,
python_content_to_json,
DIST_EXTENSIONS,
PYPI_LAST_SERIAL,
PYPI_SERIAL_CONSTANT,
)
Expand Down Expand Up @@ -328,3 +332,51 @@ def finalize_new_version(self, new_version):
"""
remove_duplicates(new_version)
validate_repo_version(new_version)


class PackagePermissionGuard(ContentGuard, AutoAddObjPermsMixin):
"""
A content guard that protects individual PyPI packages within a distribution/index.

This guard allows fine-grained control over which users/groups can download or upload
specific packages. Policies are stored as JSON dictionaries mapping package names to
lists of user/group PRNs.
"""

TYPE = "package_permission"

download_policy = models.JSONField(
default=dict,
help_text=_(
"Dictionary mapping package names to lists of user/group PRNs"
"that are allowed to download those packages."
),
)
upload_policy = models.JSONField(
default=dict,
help_text=_(
"Dictionary mapping package names to lists of user/group PRNs"
"that are allowed to upload those packages."
),
)

def permit(self, request):
"""
Check if the request can download the package. Do nothing if requesting metadata.
"""
from .global_access_conditions import package_permission_check

if drequest := request.get("drf_request", None):
if not any(drequest.path.endswith(ext) for ext in DIST_EXTENSIONS.keys()):
return
view = APIView()
setattr(view, "content_guard", self)
if package_permission_check(drequest, view, "GET", "download"):
return
raise PermissionError("Access to the requested resource is not authorized.")

class Meta:
default_related_name = "%(app_label)s_%(model_name)s"
permissions = [
("manage_roles_packagepermissionguard", "Can manage role assignments on package permission guard"),
]
4 changes: 2 additions & 2 deletions pulp_python/app/pypi/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -230,7 +230,7 @@ class SimpleView(PackageUploadMixin, ViewSet):
"action": ["create"],
"principal": "authenticated",
"effect": "allow",
"condition": "index_has_repo_perm:python.modify_pythonrepository",
"condition_expression": "package_permission_check:upload or index_has_repo_perm:python.modify_pythonrepository",
},
],
}
Expand Down Expand Up @@ -403,7 +403,7 @@ class UploadView(PackageUploadMixin, ViewSet):
"action": ["create"],
"principal": "authenticated",
"effect": "allow",
"condition": "index_has_repo_perm:python.modify_pythonrepository",
"condition_expression": "package_permission_check:upload or index_has_repo_perm:python.modify_pythonrepository",
},
],
}
Expand Down
Loading
Loading