Skip to content
Merged
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: 4 additions & 0 deletions CHANGES/387.feature.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
Added Role-Based Access Control (RBAC) to all npm viewsets and the npm publish API.
Users can now be assigned creator, owner, or viewer roles on repositories, remotes,
and distributions. The npm publish endpoint requires ``modify_npmrepository``
permission on the backing repository.
55 changes: 55 additions & 0 deletions pulp_npm/app/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
from gettext import gettext as _

from django.db.models.signals import post_migrate

from pulpcore.plugin import PulpPluginAppConfig


Expand All @@ -9,3 +13,54 @@ class PulpNpmPluginAppConfig(PulpPluginAppConfig):
version = "0.10.0.dev"
python_package_name = "pulp-npm"
domain_compatible = True

def ready(self):
super().ready()
post_migrate.connect(
_populate_npm_publish_access_policies,
sender=self,
dispatch_uid="populate_npm_publish_access_policies",
)


def _populate_npm_publish_access_policies(sender, apps, verbosity, **kwargs):
"""
Create or update AccessPolicy records for plain APIViews that
pulpcore can't auto-discover (`NpmPublishView`, `NpmPingView`, `NpmWhoamiView`).

On first run: creates the DB record from each view's `DEFAULT_ACCESS_POLICY`.
On subsequent runs: updates the record to match the code **unless** an admin
has customized it, in which case it's left alone.
"""

from pulp_npm.app.npm_publish_api import NpmPingView, NpmPublishView, NpmWhoamiView

try:
AccessPolicy = apps.get_model("core", "AccessPolicy")
except LookupError:
if verbosity >= 1:
print(_("AccessPolicy model does not exist. Skipping initialization."))
return

for viewset in (NpmPublishView, NpmPingView, NpmWhoamiView):
access_policy = getattr(viewset, "DEFAULT_ACCESS_POLICY", None)
if access_policy is None:
continue
viewset_name = viewset.urlpattern()
db_access_policy, created = AccessPolicy.objects.get_or_create(
viewset_name=viewset_name, defaults=access_policy
)
if created:
if verbosity >= 1:
print(f"Access policy for {viewset_name} created.")
elif not db_access_policy.customized:
dirty = False
for key in ["statements", "creation_hooks", "queryset_scoping"]:
value = access_policy.get(key)
if getattr(db_access_policy, key, None) != value:
setattr(db_access_policy, key, value)
dirty = True
if dirty:
db_access_policy.save()
if verbosity >= 1:
print(f"Access policy for {viewset_name} updated.")
20 changes: 20 additions & 0 deletions pulp_npm/app/global_access_conditions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
from django.conf import settings


def npm_has_repository_perm(request, view, action, perm="npm.view_npmrepository"):
"""
Check if the user has ``perm`` on the distribution's backing repository.

Used by the npm publish API views where the distribution (and its
repository) is resolved from the URL path rather than from a viewset
querset. Returns ``True`` if the distribution has no repository.
"""
if request.user.has_perm(perm):
return True
if settings.DOMAIN_ENABLED:
if request.user.has_perm(perm, obj=request.pulp_domain):
return True
if repo := view.distribution.repository:
# Cast repo, need NpmRepository instead of Repository
return request.user.has_perm(perm, obj=repo.cast())
return True
38 changes: 38 additions & 0 deletions pulp_npm/app/migrations/0006_add_rbac_permissions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
from django.db import migrations

class Migration(migrations.Migration):
dependencies = [
("npm", "0005_alter_package_version"),
]

operations = [
migrations.AlterModelOptions(
name="npmremote",
options={
"default_related_name": "%(app_label)s_%(model_name)s",
"permissions": [
("manage_roles_npmremote", "Can manage roles on npm remotes"),
],
}
),
migrations.AlterModelOptions(
name="npmrepository",
options={
"default_related_name": "%(app_label)s_%(model_name)s",
"permissions": [
("sync_npmrepository", "Can start a sync task"),
("modify_npmrepository", "Can modify content of the repository"),
("manage_roles_npmrepository", "Can manage roles on npm repositories"),
]
}
),
migrations.AlterModelOptions(
name="npmdistribution",
options={
"default_related_name": "%(app_label)s_%(model_name)s",
"permissions": [
("manage_roles_npmdistribution", "Can manage roles on npm distributions"),
]
}
),
]
18 changes: 15 additions & 3 deletions pulp_npm/app/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
from django.db import models

from pulpcore.plugin.models import (
AutoAddObjPermsMixin,
Content,
Distribution,
Remote,
Expand Down Expand Up @@ -52,7 +53,7 @@ class Meta:
unique_together = ("name", "version", "_pulp_domain")


class NpmRemote(Remote):
class NpmRemote(Remote, AutoAddObjPermsMixin):
"""
A Remote for NpmContent.

Expand All @@ -71,9 +72,12 @@ def get_remote_artifact_content_type(self, relative_path=None):

class Meta:
default_related_name = "%(app_label)s_%(model_name)s"
permissions = [
("manage_roles_npmremote", "Can manage roles on npm remotes"),
]


class NpmRepository(Repository):
class NpmRepository(Repository, AutoAddObjPermsMixin):
"""
A Repository for NpmContent.

Expand All @@ -89,9 +93,14 @@ class NpmRepository(Repository):

class Meta:
default_related_name = "%(app_label)s_%(model_name)s"
permissions = [
("sync_npmrepository", "Can start a sync task"),
("modify_npmrepository", "Can modify content of the repository"),
("manage_roles_npmrepository", "Can manage roles on npm repositories"),
]


class NpmDistribution(Distribution):
class NpmDistribution(Distribution, AutoAddObjPermsMixin):
"""
Distribution for "npm" content.
"""
Expand All @@ -100,6 +109,9 @@ class NpmDistribution(Distribution):

class Meta:
default_related_name = "%(app_label)s_%(model_name)s"
permissions = [
("manage_roles_npmdistribution", "Can manage roles on npm distributions"),
]

def content_handler(self, path):
data = {}
Expand Down
79 changes: 74 additions & 5 deletions pulp_npm/app/npm_publish_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
from rest_framework.response import Response
from rest_framework.views import APIView

from pulpcore.plugin.access_policy import AccessPolicyFromDB
from pulpcore.plugin.models import Artifact, ContentArtifact
from pulpcore.plugin.tasking import dispatch
from pulpcore.plugin.util import get_domain
Expand Down Expand Up @@ -42,16 +43,43 @@ class NpmPublishView(APIView):
"""Handle npm/yarn publish requests (``PUT /<package>``)."""

parser_classes = [JSONParser]
permission_classes = [AccessPolicyFromDB]

def initial(self, request, *args, **kwargs):
self.action = request.method.lower()
super().initial(request, *args, **kwargs)

DEFAULT_ACCESS_POLICY = {
"statements": [
{
"action": ["put"],
"principal": "authenticated",
"effect": "allow",
"condition": "npm_has_repository_perm:npm.modify_npmrepository",
},
],
}

@classmethod
def urlpattern(cls):
return "npm/publish"

@property
def distribution(self):
if not hasattr(self, "_distribution"):
domain = get_domain()
self._distribution = get_object_or_404(
NpmDistribution,
base_path=self.kwargs["path"],
pulp_domain=domain,
)
return self._distribution

def put(self, request, path, package_name):
package_name = _decode_package_name(package_name)
domain = get_domain()

distribution = get_object_or_404(
NpmDistribution,
base_path=path,
pulp_domain=domain,
)
distribution = self.distribution
repository = distribution.repository
if not repository:
return Response(
Expand Down Expand Up @@ -160,13 +188,54 @@ def _save_artifact(tarball_bytes, domain):
class NpmPingView(APIView):
"""Handle ``GET /-/ping`` -- registry health check."""

authentication_classes = []
permission_classes = [AccessPolicyFromDB]

def initial(self, request, *args, **kwargs):
self.action = request.method.lower()
super().initial(request, *args, **kwargs)

DEFAULT_ACCESS_POLICY = {
"statements": [
{
"action": ["get"],
"principal": "*",
"effect": "allow",
}
]
}

@classmethod
def urlpattern(cls):
return "npm/ping"

def get(self, request, **kwargs):
return Response({})


class NpmWhoamiView(APIView):
"""Handle ``GET /-/whoami`` -- return the authenticated user."""

permission_classes = [AccessPolicyFromDB]

def initial(self, request, *args, **kwargs):
self.action = request.method.lower()
super().initial(request, *args, **kwargs)

DEFAULT_ACCESS_POLICY = {
"statements": [
{
"action": ["get"],
"principal": "authenticated",
"effect": "allow",
},
],
}

@classmethod
def urlpattern(cls):
return "npm/whoami"

def get(self, request, **kwargs):
username = None
if hasattr(request, "user") and request.user and request.user.is_authenticated:
Expand Down
4 changes: 4 additions & 0 deletions pulp_npm/app/settings.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
DRF_ACCESS_POLICY = {
"dynaconf_merge_unique": True,
"reusable_conditions": ["pulp_npm.app.global_access_conditions"],
}
Loading