Skip to content

Commit 56ea112

Browse files
YasenTcursoragent
andauthored
fix(auth): require lightwell-network feature for lightwell domain's P… (#1306)
* fix(auth): require lightwell-network feature for lightwell domain's PyPI reads DomainBasedPermission previously let any authenticated user with a valid identity header read the lightwell domain's PyPI views (simple API, package metadata), leaking proprietary package info to non-subscribers. SAFE_METHOD requests to those views now require either a DomainOrg association or the lightwell-network feature entitlement, checked against the Features Service using the same cache as FeatureContentGuard. public- domains, other domains' PyPI views, non-PyPI endpoints, and write operations are unaffected. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(tests): add missing timeout to requests.post in lightwell feature test Fixes ruff S113 violation flagged by CI. Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent a398e43 commit 56ea112

8 files changed

Lines changed: 533 additions & 32 deletions

File tree

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Fixed ``DomainBasedPermission`` allowing any authenticated user to read the ``lightwell`` domain's PyPI views (simple API, package metadata) without a subscription check. Reading these views now requires either a ``DomainOrg`` association with the domain or the ``lightwell-network`` feature entitlement (checked against the Features Service, using the same cache as ``FeatureContentGuard``); ``public-`` prefixed domains, other domains' PyPI views, and write operations are unaffected.

pulp_service/pulp_service/app/authorization.py

Lines changed: 69 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
from pulpcore.plugin.models import Domain
1212
from pulpcore.plugin.util import extract_pk, get_domain_pk
1313

14-
from pulp_service.app.models import DomainOrg
14+
from pulp_service.app.models import DomainOrg, FeatureContentGuard
1515

1616
_logger = logging.getLogger(__name__)
1717
org_id_var = ContextVar("org_id")
@@ -20,6 +20,14 @@
2020
user_id_var = ContextVar("user_id")
2121
group_var = ContextVar("group")
2222

23+
# The domain whose PyPI views require the lightwell-network feature entitlement (see
24+
# has_permission()). Other domains' PyPI views keep the pre-existing "any SAFE_METHOD
25+
# request is allowed" behavior.
26+
LIGHTWELL_DOMAIN_NAME = "lightwell"
27+
# Feature entitlement required to read the lightwell domain's PyPI views (simple API, etc.)
28+
# for orgs that don't have a DomainOrg association with the domain. See has_permission().
29+
LIGHTWELL_NETWORK_FEATURE = "lightwell-network"
30+
2331

2432
class DomainBasedPermission(BasePermission):
2533
"""
@@ -41,23 +49,76 @@ def _has_domain_access(self, domain_pk, org_id, user):
4149

4250
return DomainOrg.objects.filter(query).exists()
4351

44-
def has_permission(self, request, view):
52+
def _has_pypi_read_access(self, request, domain):
53+
"""
54+
Checks SAFE_METHOD access to the lightwell domain's PyPI views.
55+
56+
Users with a DomainOrg association bypass the feature check entirely (existing
57+
permission model). Everyone else -- including unauthenticated users -- must belong
58+
to an org that has the lightwell-network feature entitlement.
59+
"""
60+
user = request.user
61+
domain_pk = domain.pk if domain is not None else get_domain_pk()
62+
63+
decoded_header_content = self.get_decoded_identity_header(request)
64+
org_id = self.get_org_id(decoded_header_content)
65+
66+
if user.is_authenticated and self._has_domain_access(domain_pk, org_id, user):
67+
return True
68+
69+
if org_id is None:
70+
return False
71+
72+
return self._has_lightwell_network_feature(org_id)
73+
74+
def _has_lightwell_network_feature(self, org_id):
75+
"""
76+
Checks (with caching) whether `org_id` has the lightwell-network feature, via the
77+
same cache/lookup mechanism as FeatureContentGuard.
78+
"""
79+
guard = FeatureContentGuard(features=[LIGHTWELL_NETWORK_FEATURE], pulp_domain=None)
80+
try:
81+
return guard.check_feature(org_id)
82+
except PermissionError:
83+
return False
84+
85+
def _check_pypi_safe_method_access(self, request, view, domain):
86+
"""
87+
Returns True/False for a SAFE_METHOD request to a PyPI `view`, or None if `view`
88+
isn't a PyPI view (the caller should then fall through to the standard
89+
DomainOrg-based checks below).
90+
91+
Only the lightwell domain's PyPI views require a DomainOrg association or the
92+
lightwell-network feature entitlement. Other domains' PyPI views keep the
93+
pre-existing "any SAFE_METHOD request is allowed" behavior; non-PyPI endpoints
94+
(Maven, repository listing, Pulp REST API, ...) never hit this method.
95+
"""
96+
from pulp_python.app.pypi.views import PyPIMixin
97+
98+
if not isinstance(view, PyPIMixin):
99+
return None
100+
101+
if domain and domain.name == LIGHTWELL_DOMAIN_NAME:
102+
return self._has_pypi_read_access(request, domain)
103+
return True
104+
105+
def has_permission(self, request, view): # noqa: PLR0911
45106
# Admins have all permissions
46107
if request.user.is_superuser:
47108
return True
48109

49110
user = request.user
50111

51-
# Allow safe requests on PyPI views and public domains for all users
112+
# Allow safe requests on public domains for all users, authenticated or not
52113
if request.method in SAFE_METHODS:
53-
from pulp_python.app.pypi.views import PyPIMixin
54-
55-
if isinstance(view, PyPIMixin):
56-
return True
57114
domain = getattr(request, "pulp_domain", None)
58-
if domain and "public-" in domain.name:
115+
if domain and domain.name.startswith("public-"):
59116
return True
60117

118+
pypi_access = self._check_pypi_safe_method_access(request, view, domain)
119+
if pypi_access is not None:
120+
return pypi_access
121+
61122
if not user.is_authenticated:
62123
return False
63124

pulp_service/pulp_service/app/models.py

Lines changed: 25 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,30 @@ def _set_cached_result(feature_cache, key, allowed):
164164
entry = {"allowed": allowed, "expires_at": time.time() + feature_cache.default_expires_ttl}
165165
feature_cache.set(key, json.dumps(entry), expires=feature_cache.default_expires_ttl)
166166

167+
def check_feature(self, account_id):
168+
"""
169+
Returns whether `account_id` has all of `self.features`, per the Features Service.
170+
171+
Reuses the same `FeatureContentGuardCache` cache key scheme as `permit()` so that
172+
callers checking the same (account_id, features) pair -- e.g. `DomainBasedPermission`
173+
checking the `lightwell-network` feature -- share cache entries with this guard and
174+
avoid redundant Features Service calls. May raise `PermissionError` if the Features
175+
Service call fails (see `_check_for_feature`).
176+
"""
177+
cache_key = f"{account_id}-{','.join(self.features)}"
178+
cache_key_digest = sha256(bytes(cache_key, "utf8")).hexdigest()
179+
feature_cache = FeatureContentGuardCache()
180+
account_allowed = self._get_cached_result(feature_cache, cache_key_digest)
181+
182+
if account_allowed is None:
183+
_logger.debug("Feature cache MISS for key %s", cache_key_digest)
184+
account_allowed = self._check_for_feature(account_id)
185+
self._set_cached_result(feature_cache, cache_key_digest, account_allowed)
186+
else:
187+
_logger.debug("Feature cache HIT for key %s", cache_key_digest)
188+
189+
return account_allowed
190+
167191
def permit(self, request):
168192
try:
169193
header_content = request.headers[self.header_name]
@@ -190,17 +214,7 @@ def permit(self, request):
190214
_logger.exception("Access not allowed - Invalid JSON or Path not found.")
191215
raise PermissionError(_("Access denied.")) from exc
192216

193-
cache_key = f"{header_value}-{','.join(self.features)}"
194-
cache_key_digest = sha256(bytes(cache_key, "utf8")).hexdigest()
195-
feature_cache = FeatureContentGuardCache()
196-
account_allowed = self._get_cached_result(feature_cache, cache_key_digest)
197-
198-
if account_allowed is None:
199-
_logger.debug("Feature cache MISS for key %s", cache_key_digest)
200-
account_allowed = self._check_for_feature(header_value)
201-
self._set_cached_result(feature_cache, cache_key_digest, account_allowed)
202-
else:
203-
_logger.debug("Feature cache HIT for key %s", cache_key_digest)
217+
account_allowed = self.check_feature(header_value)
204218

205219
if not account_allowed:
206220
_logger.warning("Access not allowed - Features not available for the user.")

pulp_service/pulp_service/tests/functional/constants.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,13 @@
3838
CONTENT_GUARD_FEATURES_NOT_SUBSCRIBED = ["rhods"]
3939
CONTENT_GUARD_FILTER = ".identity.org_id"
4040

41+
# LIGHTWELL-NETWORK FEATURE CONSTANTS
42+
# Used to test the lightwell-network feature check enforced by DomainBasedPermission on the
43+
# "lightwell" domain's PyPI views. These are real staging Features Service accounts.
44+
LIGHTWELL_NETWORK_FEATURE = "lightwell-network"
45+
LIGHTWELL_ENTITLED_ORG_ID = "20368420" # has the lightwell-network feature
46+
LIGHTWELL_NOT_ENTITLED_ORG_ID = "1979710" # does not have the lightwell-network feature
47+
4148
# VULNERABILITY REPORT CONSTANTS
4249
# NPM
4350
NPM_REMOTE_REGISTRY = "https://registry.npmjs.org/"

pulp_service/pulp_service/tests/functional/test_authentication.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -120,7 +120,10 @@ def test_get_requests_without_auth_to_simple_api(
120120
python_bindings,
121121
gen_object_with_cleanup,
122122
):
123-
"""Test that all domains allow GET requests without authentication but block other methods."""
123+
"""Test that all domains (other than "lightwell") allow GET requests without
124+
authentication but block other methods. The "lightwell" domain is excluded from this
125+
behavior -- see test_lightwell_feature_permission.py.
126+
"""
124127
# Create a user with credentials to set up the domain
125128
setup_user = {
126129
"identity": {"org_id": 33333, "internal": {"org_id": 33333}, "user": {"username": "publicdomainuser"}}
Lines changed: 200 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,200 @@
1+
"""
2+
Functional tests for the lightwell-network feature check enforced by DomainBasedPermission,
3+
scoped specifically to the "lightwell" domain's PyPI views (simple API, package metadata,
4+
etc.). Other domains' PyPI views, and all non-PyPI endpoints (even within the lightwell
5+
domain), keep the pre-existing permission model unaffected by this feature check.
6+
7+
These follow the pattern used in test_feature_service.py: they exercise the real Features
8+
Service (no mocking) using known staging accounts. Org LIGHTWELL_ENTITLED_ORG_ID has the
9+
lightwell-network feature; org LIGHTWELL_NOT_ENTITLED_ORG_ID does not.
10+
11+
NOTE: the feature check is keyed off the literal domain name "lightwell" (see
12+
pulp_service.app.authorization.LIGHTWELL_DOMAIN_NAME), so the domain created here can't use
13+
a random per-test suffix like most other functional tests in this suite. These tests assume
14+
they run against an ephemeral Pulp instance where no "lightwell" domain already exists.
15+
"""
16+
17+
import json
18+
from base64 import b64encode
19+
from urllib.parse import urljoin
20+
from uuid import uuid4
21+
22+
import pytest
23+
import requests
24+
25+
from pulp_service.tests.functional.constants import (
26+
LIGHTWELL_ENTITLED_ORG_ID,
27+
LIGHTWELL_NOT_ENTITLED_ORG_ID,
28+
)
29+
30+
LIGHTWELL_DOMAIN_NAME = "lightwell"
31+
32+
# An org with no DomainOrg association with the test domains and no lightwell-network
33+
# feature entitlement; only used to own the test domain/repo/distribution.
34+
DOMAIN_OWNER_ORG_ID = "555555555"
35+
36+
37+
def _identity_header(org_id, username):
38+
identity = {
39+
"identity": {
40+
"org_id": org_id,
41+
"internal": {"org_id": org_id},
42+
"user": {"username": username},
43+
}
44+
}
45+
return b64encode(json.dumps(identity).encode()).decode()
46+
47+
48+
@pytest.fixture
49+
def configure_pypi_distribution(
50+
anonymous_user,
51+
gen_object_with_cleanup,
52+
pulpcore_bindings,
53+
python_bindings,
54+
bindings_cfg,
55+
):
56+
"""
57+
Creates a domain owned by DOMAIN_OWNER_ORG_ID, with a Python repository and a PyPI
58+
distribution.
59+
60+
Returns a (domain_name, pypi_simple_url, repos_url, owner_header) tuple.
61+
"""
62+
owner_header = _identity_header(DOMAIN_OWNER_ORG_ID, "lightwell-test-owner")
63+
64+
def _configure(domain_name):
65+
with anonymous_user:
66+
pulpcore_bindings.DomainsApi.api_client.default_headers["x-rh-identity"] = owner_header
67+
68+
gen_object_with_cleanup(
69+
pulpcore_bindings.DomainsApi,
70+
{
71+
"name": domain_name,
72+
"storage_class": "pulpcore.app.models.storage.FileSystem",
73+
"storage_settings": {"MEDIA_ROOT": "/var/lib/pulp/media/"},
74+
},
75+
)
76+
77+
python_bindings.RepositoriesPythonApi.api_client.default_headers["x-rh-identity"] = owner_header
78+
repo = gen_object_with_cleanup(
79+
python_bindings.RepositoriesPythonApi, {"name": str(uuid4())}, pulp_domain=domain_name
80+
)
81+
82+
python_bindings.DistributionsPypiApi.api_client.default_headers["x-rh-identity"] = owner_header
83+
base_path = str(uuid4())
84+
gen_object_with_cleanup(
85+
python_bindings.DistributionsPypiApi,
86+
{"name": str(uuid4()), "base_path": base_path, "repository": repo.pulp_href},
87+
pulp_domain=domain_name,
88+
)
89+
90+
pypi_url = urljoin(bindings_cfg.host, f"/api/pypi/{domain_name}/{base_path}/simple/")
91+
repos_url = urljoin(bindings_cfg.host, f"/api/pulp/{domain_name}/api/v3/repositories/python/python/")
92+
return domain_name, pypi_url, repos_url, owner_header
93+
94+
yield _configure
95+
96+
pulpcore_bindings.DomainsApi.api_client.default_headers.pop("x-rh-identity", None)
97+
python_bindings.RepositoriesPythonApi.api_client.default_headers.pop("x-rh-identity", None)
98+
python_bindings.DistributionsPypiApi.api_client.default_headers.pop("x-rh-identity", None)
99+
100+
101+
@pytest.fixture
102+
def configure_lightwell_pypi_distribution(configure_pypi_distribution):
103+
"""Same as configure_pypi_distribution, but uses the literal "lightwell" domain name that
104+
DomainBasedPermission gates behind the lightwell-network feature."""
105+
106+
def _configure():
107+
return configure_pypi_distribution(LIGHTWELL_DOMAIN_NAME)
108+
109+
return _configure
110+
111+
112+
def test_org_without_feature_denied_on_lightwell_pypi_simple_api(configure_lightwell_pypi_distribution):
113+
"""A user whose org doesn't have the lightwell-network feature and has no DomainOrg
114+
association gets 403 on the lightwell domain's PyPI simple API."""
115+
_, pypi_url, _, _ = configure_lightwell_pypi_distribution()
116+
headers = {"x-rh-identity": _identity_header(LIGHTWELL_NOT_ENTITLED_ORG_ID, "not-entitled-user")}
117+
118+
response = requests.get(pypi_url, headers=headers, timeout=30)
119+
120+
assert response.status_code == 403
121+
122+
123+
def test_org_with_feature_allowed_on_lightwell_pypi_simple_api(configure_lightwell_pypi_distribution):
124+
"""A user whose org has the lightwell-network feature can read the lightwell domain's
125+
PyPI simple API, even without a DomainOrg association."""
126+
_, pypi_url, _, _ = configure_lightwell_pypi_distribution()
127+
headers = {"x-rh-identity": _identity_header(LIGHTWELL_ENTITLED_ORG_ID, "entitled-user")}
128+
129+
response = requests.get(pypi_url, headers=headers, timeout=30)
130+
131+
assert response.status_code == 200
132+
133+
134+
def test_domain_org_association_bypasses_feature_check(configure_lightwell_pypi_distribution):
135+
"""The domain owner (has a DomainOrg association) can read the lightwell domain's PyPI
136+
simple API regardless of the lightwell-network feature."""
137+
_, pypi_url, _, owner_header = configure_lightwell_pypi_distribution()
138+
headers = {"x-rh-identity": owner_header}
139+
140+
response = requests.get(pypi_url, headers=headers, timeout=30)
141+
142+
assert response.status_code == 200
143+
144+
145+
def test_unauthenticated_denied_on_lightwell_pypi_simple_api(configure_lightwell_pypi_distribution):
146+
"""Without any identity at all (no org_id to check a feature for), the lightwell domain's
147+
PyPI simple API must not be readable."""
148+
_, pypi_url, _, _ = configure_lightwell_pypi_distribution()
149+
150+
response = requests.get(pypi_url, timeout=30)
151+
152+
assert response.status_code in (401, 403)
153+
154+
155+
def test_write_operations_unaffected_by_feature_check(configure_lightwell_pypi_distribution):
156+
"""The lightwell-network feature only grants read access -- an entitled org with no
157+
DomainOrg association must still be denied write access to the lightwell domain's PyPI
158+
views."""
159+
_, pypi_url, _, _ = configure_lightwell_pypi_distribution()
160+
headers = {"x-rh-identity": _identity_header(LIGHTWELL_ENTITLED_ORG_ID, "entitled-write-user")}
161+
162+
response = requests.post(pypi_url, headers=headers, data={}, timeout=30)
163+
164+
assert response.status_code in (401, 403)
165+
166+
167+
def test_non_pypi_endpoints_unaffected_by_feature_check(configure_lightwell_pypi_distribution):
168+
"""Non-PyPI endpoints (here, the Pulp REST API's repository listing) must keep using the
169+
existing DomainOrg-based permission model, even within the lightwell domain: the
170+
lightwell-network feature grants no access to them."""
171+
_, _, repos_url, _ = configure_lightwell_pypi_distribution()
172+
headers = {"x-rh-identity": _identity_header(LIGHTWELL_ENTITLED_ORG_ID, "entitled-rest-user")}
173+
174+
response = requests.get(repos_url, headers=headers, timeout=30)
175+
176+
assert response.status_code == 403
177+
178+
179+
def test_other_domains_pypi_simple_api_unaffected_by_feature_check(configure_pypi_distribution):
180+
"""Domains other than "lightwell" must keep the pre-existing behavior: any SAFE_METHOD
181+
request -- even unauthenticated -- can read the PyPI simple API, regardless of the
182+
lightwell-network feature."""
183+
domain_name = f"not-lightwell-{uuid4()}"
184+
_, pypi_url, _, _ = configure_pypi_distribution(domain_name)
185+
186+
response = requests.get(pypi_url, timeout=30)
187+
188+
assert response.status_code == 200
189+
190+
191+
def test_public_domain_allows_unauthenticated_pypi_access(configure_pypi_distribution):
192+
"""A public- domain's PyPI simple API stays open to unauthenticated SAFE_METHOD
193+
requests -- unaffected by the lightwell-network feature check (which never applies to
194+
public- domains, or to non-"lightwell" domains in general)."""
195+
domain_name = f"public-{uuid4()}"
196+
_, pypi_url, _, _ = configure_pypi_distribution(domain_name)
197+
198+
response = requests.get(pypi_url, timeout=30)
199+
200+
assert response.status_code == 200

0 commit comments

Comments
 (0)