Skip to content

Commit 4abbef3

Browse files
committed
Add new PackagePermissionGuard
The new content guard allows for finer control on who can upload & download a package from an index. fixes: #727 Assisted By: Cursor agent composer-1
1 parent 198588b commit 4abbef3

File tree

9 files changed

+694
-4
lines changed

9 files changed

+694
-4
lines changed

CHANGES/727.feature

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
Added PackagePermissionGuard content guard for fine-grained package-level access control.
2+
3+
PackagePermissionGuard allows controlling download and upload permissions for individual PyPI packages
4+
within a single distribution/index. Policies map package names to lists of user/group PRNs that are
5+
allowed to access those packages.
6+

docs/admin/guides/rbac.md

Lines changed: 93 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,88 @@ new RBACContentGuard:
7373
!!! warning
7474
The PyPI access policies do not support `creation_hooks` or `queryset_scoping`.
7575

76+
## PackagePermissionGuard
77+
78+
The PackagePermissionGuard provides fine-grained, package-level access control within a single distribution/index.
79+
Unlike RBACContentGuard which applies to all packages in a distribution, PackagePermissionGuard allows you to
80+
control download and upload permissions for individual PyPI packages.
81+
82+
### Creating a PackagePermissionGuard
83+
84+
```bash
85+
pulp content-guard package-permission create --name my-package-guard
86+
```
87+
88+
### Managing Package Permissions
89+
90+
PackagePermissionGuard uses two separate policies:
91+
- `download_policy`: Controls who can download specific packages
92+
- `upload_policy`: Controls who can upload specific packages
93+
94+
Both policies map package names (normalized) to lists of user/group PRNs.
95+
96+
#### Adding Permissions
97+
98+
Use the `add` action to grant users/groups access to packages:
99+
100+
```bash
101+
# Add download permissions for multiple packages
102+
pulp content-guard package-permission add \
103+
--name my-package-guard \
104+
--packages shelf-reader django \
105+
--users-groups prn:core.user:alice prn:core.group:developers \
106+
--policy-type download
107+
108+
# Add upload permissions
109+
pulp content-guard package-permission add \
110+
--name my-package-guard \
111+
--packages shelf-reader \
112+
--users-groups prn:core.user:bob \
113+
--policy-type upload
114+
```
115+
116+
#### Removing Permissions
117+
118+
Use the `remove` action to revoke access:
119+
120+
```bash
121+
# Remove specific users/groups from a package
122+
pulp content-guard package-permission remove \
123+
--name my-package-guard \
124+
--packages shelf-reader \
125+
--users-groups prn:core.user:alice \
126+
--policy-type download
127+
128+
# Remove all permissions for a package (use '*' in users-groups)
129+
pulp content-guard package-permission remove \
130+
--name my-package-guard \
131+
--packages django \
132+
--users-groups '*' \
133+
--policy-type download
134+
135+
# Remove all packages from a policy (use '*' in packages)
136+
pulp content-guard package-permission remove \
137+
--name my-package-guard \
138+
--packages '*' \
139+
--users-groups '' \
140+
--policy-type download
141+
```
142+
143+
### How PackagePermissionGuard Works
144+
145+
- **Downloads**: During downloads the guard's `permit()` method checks the `download_policy` to see
146+
if there is a policy for the package. If no policy the download is permited. If there is one it
147+
then checks that the user of the request is in the policy list for that package.
148+
149+
- **Uploads**: For uploads, the endpoint's access policy checks the `upload_policy` to see if the
150+
user/group has permission for the package being uploaded. If the package or user is not in the
151+
policy the upload is denied.
152+
153+
!!! note
154+
For the content guard to properly protect uploads the `legacy` and `simple` AccessPolies must
155+
use the `package_permission_check` condition for their `create` action. This is the current
156+
default with a fallback to `index_has_repo_perm` when there is no content guard on the distro.
157+
76158
## Index Specific Access Conditions
77159

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

174+
### `package_permission_check`
175+
176+
This access condition checks PackagePermissionGuard for package-level permissions. It extracts the package name
177+
from the request (from URL path for downloads,from filename for uploads) and checks if the user/group has
178+
permission for that specific package in the guard's policy. This condition is used by default in PyPI upload
179+
endpoints to enable package-level upload control when a PackagePermissionGuard is attached to the distribution.
180+
181+
- For downloads: Checks `download_policy` and denies only if package in policy and user/group is not.
182+
- For uploads: Checks `upload_policy` and denies access if package not in policy or user/group not allowed
183+
92184
!!! note
93-
Both access condition methods are compatible with the Pulp Domains feature.
185+
All access condition methods are compatible with the Pulp Domains feature.

pulp_python/app/global_access_conditions.py

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,10 @@
11
from django.conf import settings
2+
from django.contrib.auth.models import AnonymousUser
3+
from packaging.utils import canonicalize_name
4+
from pathlib import PurePath
5+
from pulp_python.app.models import PackagePermissionGuard
6+
from pypi_simple import parse_filename, UnparsableFilenameError
7+
from pulpcore.plugin.util import get_prn
28

39

410
# Access Condition methods that can be used with PyPI access policies
@@ -28,3 +34,71 @@ def index_has_repo_perm(request, view, action, perm="python.view_pythonrepositor
2834
if repo := view.distribution.repository:
2935
return request.user.has_perm(perm, obj=repo.cast())
3036
return True
37+
38+
39+
def package_permission_check(request, view, action, policy_type="upload"):
40+
"""
41+
Access Policy condition that checks PackagePermissionGuard for package-level permissions.
42+
43+
Checks if the user/group has permission for the specific package being accessed.
44+
45+
Args:
46+
policy_type: "download" or "upload" - which policy to check
47+
"""
48+
if hasattr(view, "content_guard"):
49+
content_guard = view.content_guard
50+
else:
51+
content_guard = view.distribution.content_guard
52+
53+
# If no guard attached, deny access
54+
if not content_guard or not isinstance(content_guard.cast(), PackagePermissionGuard):
55+
return False
56+
57+
guard = content_guard.cast()
58+
policy = guard.download_policy if policy_type == "download" else guard.upload_policy
59+
60+
# Extract package name from request
61+
package_name = None
62+
63+
if policy_type == "upload":
64+
# For uploads, extract from filename in request.FILES
65+
# The file is uploaded as multipart/form-data with field name 'content'
66+
if hasattr(request, 'FILES') and 'content' in request.FILES:
67+
file_obj = request.FILES['content']
68+
package_name = canonicalize_name(parse_filename(file_obj.name)[0])
69+
else:
70+
# For downloads, extract from URL path
71+
if hasattr(view, "kwargs"):
72+
# Check URL kwargs for package name
73+
if 'package' in view.kwargs:
74+
package_name = canonicalize_name(view.kwargs['package'])
75+
elif 'meta' in view.kwargs:
76+
# Metadata endpoint: pypi/{package}/json/ or pypi/{package}/{version}/json/
77+
meta_path = PurePath(view.kwargs['meta'])
78+
if meta_path.match("*/json") or meta_path.match("*/*/json"):
79+
package_name = canonicalize_name(meta_path.parts[0])
80+
else:
81+
path = PurePath(request.path_info)
82+
try:
83+
package_name = parse_filename(path.name)[0]
84+
except UnparsableFilenameError:
85+
print(f"No package name found in path: {request.path_info}")
86+
87+
# Downloads are permissive, only deny if package in policy but user is not listed
88+
# Uploads are strict, only allow if package in policy and user is listed
89+
if package_name not in policy:
90+
return policy_type == "download"
91+
92+
allowed_prns = policy[package_name]
93+
if not request.user or isinstance(request.user, AnonymousUser):
94+
return False
95+
user_prn = get_prn(request.user)
96+
group_prns = [get_prn(group) for group in request.user.groups.all()]
97+
98+
if user_prn and user_prn in allowed_prns:
99+
return True
100+
101+
if any(group_prn in allowed_prns for group_prn in group_prns):
102+
return True
103+
104+
return False
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
# Generated by Django 4.2.25 on 2025-10-31 12:50
2+
3+
from django.db import migrations, models
4+
import django.db.models.deletion
5+
import pulpcore.app.models.access_policy
6+
7+
8+
class Migration(migrations.Migration):
9+
10+
dependencies = [
11+
("core", "0145_domainize_import_export"),
12+
("python", "0015_alter_pythonpackagecontent_options"),
13+
]
14+
15+
operations = [
16+
migrations.CreateModel(
17+
name="PackagePermissionGuard",
18+
fields=[
19+
(
20+
"contentguard_ptr",
21+
models.OneToOneField(
22+
auto_created=True,
23+
on_delete=django.db.models.deletion.CASCADE,
24+
parent_link=True,
25+
primary_key=True,
26+
serialize=False,
27+
to="core.contentguard",
28+
),
29+
),
30+
(
31+
"download_policy",
32+
models.JSONField(
33+
default=dict,
34+
help_text="Dictionary mapping package names to lists of user/group PRNsthat are allowed to download those packages.",
35+
),
36+
),
37+
(
38+
"upload_policy",
39+
models.JSONField(
40+
default=dict,
41+
help_text="Dictionary mapping package names to lists of user/group PRNsthat are allowed to upload those packages.",
42+
),
43+
),
44+
],
45+
options={
46+
"permissions": [
47+
(
48+
"manage_roles_packagepermissionguard",
49+
"Can manage role assignments on package permission guard",
50+
)
51+
],
52+
"default_related_name": "%(app_label)s_%(model_name)s",
53+
},
54+
bases=("core.contentguard", pulpcore.app.models.access_policy.AutoAddObjPermsMixin),
55+
),
56+
]

pulp_python/app/models.py

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,16 @@
11
from logging import getLogger
2+
from gettext import gettext as _
23

34
from aiohttp.web import json_response
45
from django.contrib.postgres.fields import ArrayField
56
from django.core.exceptions import ObjectDoesNotExist
67
from django.db import models
78
from django.conf import settings
9+
from rest_framework.views import APIView
810
from pulpcore.plugin.models import (
911
AutoAddObjPermsMixin,
1012
Content,
13+
ContentGuard,
1114
Publication,
1215
Distribution,
1316
Remote,
@@ -20,6 +23,7 @@
2023
artifact_to_python_content_data,
2124
canonicalize_name,
2225
python_content_to_json,
26+
DIST_EXTENSIONS,
2327
PYPI_LAST_SERIAL,
2428
PYPI_SERIAL_CONSTANT,
2529
)
@@ -328,3 +332,51 @@ def finalize_new_version(self, new_version):
328332
"""
329333
remove_duplicates(new_version)
330334
validate_repo_version(new_version)
335+
336+
337+
class PackagePermissionGuard(ContentGuard, AutoAddObjPermsMixin):
338+
"""
339+
A content guard that protects individual PyPI packages within a distribution/index.
340+
341+
This guard allows fine-grained control over which users/groups can download or upload
342+
specific packages. Policies are stored as JSON dictionaries mapping package names to
343+
lists of user/group PRNs.
344+
"""
345+
346+
TYPE = "package_permission"
347+
348+
download_policy = models.JSONField(
349+
default=dict,
350+
help_text=_(
351+
"Dictionary mapping package names to lists of user/group PRNs"
352+
"that are allowed to download those packages."
353+
),
354+
)
355+
upload_policy = models.JSONField(
356+
default=dict,
357+
help_text=_(
358+
"Dictionary mapping package names to lists of user/group PRNs"
359+
"that are allowed to upload those packages."
360+
),
361+
)
362+
363+
def permit(self, request):
364+
"""
365+
Check if the request can download the package. Do nothing if requesting metadata.
366+
"""
367+
from .global_access_conditions import package_permission_check
368+
369+
if drequest := request.get("drf_request", None):
370+
if not any(drequest.path.endswith(ext) for ext in DIST_EXTENSIONS.keys()):
371+
return
372+
view = APIView()
373+
setattr(view, "content_guard", self)
374+
if package_permission_check(drequest, view, "GET", "download"):
375+
return
376+
raise PermissionError("Access to the requested resource is not authorized.")
377+
378+
class Meta:
379+
default_related_name = "%(app_label)s_%(model_name)s"
380+
permissions = [
381+
("manage_roles_packagepermissionguard", "Can manage role assignments on package permission guard"),
382+
]

pulp_python/app/pypi/views.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -230,7 +230,7 @@ class SimpleView(PackageUploadMixin, ViewSet):
230230
"action": ["create"],
231231
"principal": "authenticated",
232232
"effect": "allow",
233-
"condition": "index_has_repo_perm:python.modify_pythonrepository",
233+
"condition_expression": "package_permission_check:upload or index_has_repo_perm:python.modify_pythonrepository",
234234
},
235235
],
236236
}
@@ -403,7 +403,7 @@ class UploadView(PackageUploadMixin, ViewSet):
403403
"action": ["create"],
404404
"principal": "authenticated",
405405
"effect": "allow",
406-
"condition": "index_has_repo_perm:python.modify_pythonrepository",
406+
"condition_expression": "package_permission_check:upload or index_has_repo_perm:python.modify_pythonrepository",
407407
},
408408
],
409409
}

0 commit comments

Comments
 (0)