Skip to content

Commit

Permalink
TODO: Add a permission to pull from pull-through distributions
Browse files Browse the repository at this point in the history
closes pulp#1624
  • Loading branch information
lubosmj committed Jul 7, 2024
1 parent 78ca84c commit 52d3314
Show file tree
Hide file tree
Showing 10 changed files with 253 additions and 98 deletions.
2 changes: 2 additions & 0 deletions CHANGES/1624.bugfix
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
Permitted users with the `pull_new_distribution` permission to pull data via pull-through
distributions.
4 changes: 4 additions & 0 deletions pulp_container/app/access_policy.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,10 @@ def get_policy_statements(self, request, view):
access_policy_obj = AccessPolicyModel.objects.get(
viewset_name="distributions/container/container"
)
elif isinstance(view.get_object(), models.ContainerPullThroughDistribution):
access_policy_obj = AccessPolicyModel.objects.get(
viewset_name="distributions/container/pull-through"
)
else:
access_policy_obj = AccessPolicyModel.objects.get(
viewset_name="pulp_container/namespaces"
Expand Down
176 changes: 106 additions & 70 deletions pulp_container/app/authorization.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,17 @@

from django.conf import settings
from django.http import HttpRequest
from django.db.models import F, Value
from rest_framework.request import Request

from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives import serialization

from pulp_container.app.models import ContainerDistribution, ContainerNamespace
from pulp_container.app.models import (
ContainerDistribution,
ContainerNamespace,
ContainerPullThroughDistribution,
)
from pulp_container.app.access_policy import RegistryAccessPolicy

TOKEN_EXPIRATION_TIME = settings.get("TOKEN_EXPIRATION_TIME", 300)
Expand All @@ -41,6 +46,101 @@ def __getattr__(self, *args, **kwargs):
FakeViewWithSerializer = partial(FakeView, get_serializer=get_serializer)


def get_pull_through_distribution(path):
return (
ContainerPullThroughDistribution.objects.annotate(path=Value(path))
.filter(path__startswith=F("base_path"))
.order_by("-base_path")
.first()
)


class PermissionChecker:
def __init__(self, user):
self.user = user
self.access_policy = RegistryAccessPolicy()

def has_permission(self, obj, method, action, data):
"""Check if user has permission to perform action."""

# Fake the request
request = Request(HttpRequest())
request.method = method
request.user = self.user
request._full_data = data
# Fake the corresponding view
view = FakeViewWithSerializer(action, lambda: obj)
return self.access_policy.has_permission(request, view)

def has_pull_permissions(self, path):
"""
Check if the user has permissions to pull from the repository specified by the path.
"""
try:
distribution = ContainerDistribution.objects.get(base_path=path)
except ContainerDistribution.DoesNotExist:
namespace_name = path.split("/")[0]
try:
namespace = ContainerNamespace.objects.get(name=namespace_name)
except ContainerNamespace.DoesNotExist:
# Check if the user is allowed to create a new namespace
return self.has_permission(None, "POST", "create", {"name": namespace_name})

if pt_distribution := get_pull_through_distribution(path):
# Check if the user is allowed to create a new distribution
return self.has_modify_pull_through_cache_permissions(pt_distribution)
else:
# Check if the user is allowed to view distributions in the namespace
return self.has_permission(
namespace, "GET", "view_distribution", {"name": namespace_name}
)

if pt_distribution := get_pull_through_distribution(path):
# Check if the user is allowed to pull new content via a pull-through distribution
if self.has_modify_pull_through_cache_permissions(pt_distribution, distribution):
return True

# Check if the user has general pull permissions
return self.has_permission(distribution, "GET", "pull", {"base_path": path})

def has_push_permissions(self, path):
"""
Check if the user has permissions to push to the repository specified by the path.
"""
try:
distribution = ContainerDistribution.objects.get(base_path=path)
except ContainerDistribution.DoesNotExist:
namespace_name = path.split("/")[0]
try:
namespace = ContainerNamespace.objects.get(name=namespace_name)
except ContainerNamespace.DoesNotExist:
# Check if user is allowed to create a new namespace
return self.has_permission(None, "POST", "create", {"name": namespace_name})
# Check if user is allowed to create a new distribution in the namespace
return self.has_permission(namespace, "POST", "create_distribution", {})

return self.has_permission(distribution, "POST", "push", {"base_path": path})

def has_view_catalog_permissions(self, path):
"""
Check if the authenticated user has permission to access the catalog endpoint.
"""
if path != "catalog":
return False

return self.has_permission(ContainerDistribution(), "GET", "catalog", {})

def has_modify_pull_through_cache_permissions(self, pt_distribution, distribution=None):
has_distro_perms = False
if distribution:
has_distro_perms = self.has_permission(distribution, "GET", "pull_new_content", {})
return (
has_distro_perms
or self.has_permission(pt_distribution, "GET", "pull_new_distribution", {})
or self.has_permission(pt_distribution.namespace, "POST", "create_distribution", {})
)


class AuthorizationService:
"""
A class responsible for generating and managing a Bearer token.
Expand All @@ -62,14 +162,15 @@ def __init__(self, user, service, scopes):
self.user = user
self.service = service
self.scopes = scopes
self.access_policy = RegistryAccessPolicy()

permission_checker = PermissionChecker(user)

self.actions_permissions = defaultdict(
lambda: lambda *args: False,
{
"pull": self.has_pull_permissions,
"push": self.has_push_permissions,
"*": self.has_view_catalog_permissions,
"pull": permission_checker.has_pull_permissions,
"push": permission_checker.has_push_permissions,
"*": permission_checker.has_view_catalog_permissions,
},
)

Expand Down Expand Up @@ -184,71 +285,6 @@ def permit_scope(self, scope):

return [{"type": typ, "name": name, "actions": list(permitted_actions)}]

def has_permission(self, obj, method, action, data):
"""Check if user has permission to perform action."""

# Fake the request
request = Request(HttpRequest())
request.method = method
request.user = self.user
request._full_data = data
# Fake the corresponding view
view = FakeViewWithSerializer(action, lambda: obj)
return self.access_policy.has_permission(request, view)

def has_pull_permissions(self, path):
"""
Check if the user has permissions to pull from the repository specified by the path.
"""
try:
distribution = ContainerDistribution.objects.get(base_path=path)
except ContainerDistribution.DoesNotExist:
namespace_name = path.split("/")[0]
try:
namespace = ContainerNamespace.objects.get(name=namespace_name)
except ContainerNamespace.DoesNotExist:
# Check if user is allowed to create a new namespace
return self.has_permission(None, "POST", "create", {"name": namespace_name})
# Check if user is allowed to view distributions in the namespace
return self.has_permission(
namespace, "GET", "view_distribution", {"name": namespace_name}
)

return self.has_permission(distribution, "GET", "pull", {"base_path": path})

def has_push_permissions(self, path):
"""
Check if the user has permissions to push to the repository specified by the path.
"""
try:
distribution = ContainerDistribution.objects.get(base_path=path)
except ContainerDistribution.DoesNotExist:
namespace_name = path.split("/")[0]
try:
namespace = ContainerNamespace.objects.get(name=namespace_name)
except ContainerNamespace.DoesNotExist:
# Check if user is allowed to create a new namespace
return self.has_permission(None, "POST", "create", {"name": namespace_name})
# Check if user is allowed to create a new distribution in the namespace
return self.has_permission(namespace, "POST", "create_distribution", {})

return self.has_permission(distribution, "POST", "push", {"base_path": path})

def has_view_catalog_permissions(self, path):
"""
Check if the authenticated user has permission to access the catalog endpoint.
"""
if path != "catalog":
return False

# Fake the request
request = Request(HttpRequest())
request.method = "GET"
request.user = self.user
# Fake the view
view = FakeViewWithSerializer("catalog", lambda: ContainerDistribution())
return self.access_policy.has_permission(request, view)

@staticmethod
def generate_claim_set(issuer, issued_at, subject, audience, access):
"""
Expand Down
7 changes: 3 additions & 4 deletions pulp_container/app/global_access_conditions.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,10 +27,9 @@ def has_namespace_obj_perms(request, view, action, permission):
for dist in obj.distributions.all():
if request.user.has_perm(permission, dist.cast().namespace):
return True
elif type(obj) is models.ContainerPushRepositoryVersion:
for dist in obj.repository.distributions.all():
if request.user.has_perm(permission, dist.cast().namespace):
return True
elif type(obj) is models.ContainerPullThroughDistribution:
namespace = obj.namespace
return request.user.has_perm(permission, namespace)
return False


Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
# Generated by Django 4.2.11 on 2024-07-07 21:38

from django.db import migrations


class Migration(migrations.Migration):

dependencies = [
('container', '0040_add_remote_repo_filter'),
]

operations = [
migrations.AlterModelOptions(
name='containernamespace',
options={'permissions': [('namespace_add_containerdistribution', 'Add any distribution to a namespace'), ('namespace_delete_containerdistribution', 'Delete any distribution from a namespace'), ('namespace_view_containerdistribution', 'View any distribution in a namespace'), ('namespace_pull_containerdistribution', 'Pull from any distribution in a namespace'), ('namespace_push_containerdistribution', 'Push to any distribution in a namespace'), ('namespace_change_containerdistribution', 'Change any distribution in a namespace'), ('namespace_view_containerpushrepository', 'View any push repository in a namespace'), ('namespace_modify_content_containerpushrepository', 'Modify content in any push repository in a namespace'), ('namespace_modify_content_containerrepository', 'Modify content in any repository in a namespace'), ('namespace_change_containerpushrepository', 'Update any existing push repository in a namespace'), ('manage_roles_containernamespace', 'Can manage role assignments on container namespace')]},
),
migrations.AlterModelOptions(
name='containerpullthroughdistribution',
options={'default_related_name': '%(app_label)s_%(model_name)s', 'permissions': [('manage_roles_containerpullthroughdistribution', 'Can manage role assignments on pull-through cache distribution'), ('pull_new_containerdistribution', 'Can pull new content via the pull-through cache distribution')]},
),
]
8 changes: 8 additions & 0 deletions pulp_container/app/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -332,6 +332,10 @@ class Meta:
"namespace_modify_content_containerpushrepository",
"Modify content in any push repository in a namespace",
),
(
"namespace_modify_content_containerrepository",
"Modify content in any repository in a namespace",
),
(
"namespace_change_containerpushrepository",
"Update any existing push repository in a namespace",
Expand Down Expand Up @@ -711,6 +715,10 @@ class Meta:
"manage_roles_containerpullthroughdistribution",
"Can manage role assignments on pull-through cache distribution",
),
(
"pull_new_containerdistribution",
"Can pull new content via the pull-through cache distribution",
),
]


Expand Down
28 changes: 21 additions & 7 deletions pulp_container/app/registry_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@
from rest_framework.status import HTTP_401_UNAUTHORIZED

from pulp_container.app import models, serializers
from pulp_container.app.authorization import AuthorizationService
from pulp_container.app.authorization import AuthorizationService, PermissionChecker
from pulp_container.app.cache import (
find_base_path_cached,
FlatpakIndexStaticCache,
Expand Down Expand Up @@ -291,7 +291,9 @@ def get_drv_pull(self, path):
Get distribution, repository and repository_version for pull access.
"""
try:
distribution = models.ContainerDistribution.objects.get(base_path=path)
distribution = models.ContainerDistribution.objects.prefetch_related(
"pull_through_distribution"
).get(base_path=path)
except models.ContainerDistribution.DoesNotExist:
# get a pull-through cache distribution whose base_path is a substring of the path
return self.get_pull_through_drv(path)
Expand Down Expand Up @@ -345,6 +347,7 @@ def get_pull_through_drv(self, path):
remote=remote,
repository=repository,
private=pull_through_cache_distribution.private,
namespace=pull_through_cache_distribution.namespace,
)
except IntegrityError:
# some entities needed to be created, but their keys already exist in the database
Expand Down Expand Up @@ -1036,10 +1039,13 @@ def handle_safe_method(self, request, path, pk):
tag = models.Tag.objects.get(name=pk, pk__in=repository_version.content)
except models.Tag.DoesNotExist:
distribution = distribution.cast()
permission_checker = PermissionChecker(request.user)
if (
distribution.remote
and distribution.pull_through_distribution_id
and request.user.is_authenticated
and distribution.pull_through_distribution
and permission_checker.has_modify_pull_through_cache_permissions(
distribution.pull_through_distribution, distribution
)
):
remote = distribution.remote.cast()
# issue a head request first to ensure that the content exists on the remote
Expand Down Expand Up @@ -1090,10 +1096,13 @@ def handle_safe_method(self, request, path, pk):
manifest = None

distribution = distribution.cast()
permission_checker = PermissionChecker(request.user)
if (
distribution.remote
and distribution.pull_through_distribution_id
and request.user.is_authenticated
and distribution.pull_through_distribution
and permission_checker.has_modify_pull_through_cache_permissions(
distribution.pull_through_distribution, distribution
)
):
remote = distribution.remote.cast()
self.fetch_manifest(remote, pk)
Expand All @@ -1109,7 +1118,7 @@ def get_content_units_to_add(self, manifest, tag):
models.MEDIA_TYPE.MANIFEST_LIST,
models.MEDIA_TYPE.INDEX_OCI,
):
for listed_manifest in manifest.listed_manifests:
for listed_manifest in manifest.listed_manifests.all():
add_content_units.append(listed_manifest.pk)
add_content_units.append(listed_manifest.config_blob_id)
add_content_units.extend(listed_manifest.blobs.values_list("pk", flat=True))
Expand Down Expand Up @@ -1149,6 +1158,11 @@ def fetch_manifest(self, remote, pk):
digest = response.headers.get("docker-content-digest")
return models.Manifest.objects.filter(digest=digest).first()

@staticmethod
def has_modify_permissions(user, distribution):
permission_checker = PermissionChecker(user)
return permission_checker.has_permission(distribution, "GET", "pull_new_content", {})

def put(self, request, path, pk=None):
"""
Responds with the actual manifest
Expand Down
Loading

0 comments on commit 52d3314

Please sign in to comment.