Skip to content

Commit 0f236fc

Browse files
YasenTcursoragent
andauthored
read only group and extra logging (#1307)
* read only group and extra logging * style: apply ruff format (line-length 120) to lightwell readonly group changes Co-authored-by: Cursor <cursoragent@cursor.com> * fix(tests): remove unused file_bindings argument in readonly group test Ruff's ARG001 check flagged the unused fixture in changed lines. Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 56ea112 commit 0f236fc

4 files changed

Lines changed: 438 additions & 9 deletions

File tree

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Added a hardcoded ``Lightwell-ReadOnly`` group: members get read-only (``SAFE_METHODS``) access to the ``lightwell`` domain's non-PyPI endpoints (repository/content/artifact listing, Pulp REST API, Maven content API), independent of any ``DomainOrg`` association. Unlike normal groups (linked to a domain via ``DomainOrg``, which grant unrestricted read+write access), this group grants no write access and does not bypass the existing ``lightwell-network`` feature check on the domain's PyPI views.

pulp_service/pulp_service/app/authorization.py

Lines changed: 56 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,13 @@
2727
# Feature entitlement required to read the lightwell domain's PyPI views (simple API, etc.)
2828
# for orgs that don't have a DomainOrg association with the domain. See has_permission().
2929
LIGHTWELL_NETWORK_FEATURE = "lightwell-network"
30+
# Hardcoded group whose members get read-only (SAFE_METHODS) access to the lightwell
31+
# domain's non-PyPI endpoints (repository/content/artifact listing, Pulp REST API, Maven
32+
# content API), independent of any DomainOrg association. This is a plain Group -- there is
33+
# no DomainOrg row backing this access, unlike the normal group-based access model. It does
34+
# NOT grant write access, and it does NOT apply to PyPI views, which are still gated
35+
# exclusively by the lightwell-network feature check (see _check_pypi_safe_method_access()).
36+
LIGHTWELL_READONLY_GROUP_NAME = "Lightwell-ReadOnly"
3037

3138

3239
class DomainBasedPermission(BasePermission):
@@ -64,12 +71,25 @@ def _has_pypi_read_access(self, request, domain):
6471
org_id = self.get_org_id(decoded_header_content)
6572

6673
if user.is_authenticated and self._has_domain_access(domain_pk, org_id, user):
74+
_logger.info("lightwell PyPI access GRANTED via DomainOrg: user=%s org_id=%s", user, org_id)
6775
return True
6876

6977
if org_id is None:
78+
_logger.info(
79+
"lightwell PyPI access DENIED: no org_id in identity header and no DomainOrg match (user=%s)",
80+
user,
81+
)
7082
return False
7183

72-
return self._has_lightwell_network_feature(org_id)
84+
allowed = self._has_lightwell_network_feature(org_id)
85+
_logger.info(
86+
"lightwell PyPI access %s via %s feature check: org_id=%s user=%s",
87+
"GRANTED" if allowed else "DENIED",
88+
LIGHTWELL_NETWORK_FEATURE,
89+
org_id,
90+
user,
91+
)
92+
return allowed
7393

7494
def _has_lightwell_network_feature(self, org_id):
7595
"""
@@ -82,6 +102,17 @@ def _has_lightwell_network_feature(self, org_id):
82102
except PermissionError:
83103
return False
84104

105+
def _has_lightwell_readonly_group_access(self, user):
106+
"""
107+
Members of the hardcoded LIGHTWELL_READONLY_GROUP_NAME group get read-only
108+
(SAFE_METHODS) access to the lightwell domain's non-PyPI endpoints, independent of
109+
any DomainOrg association. Unlike normal groups (linked to a domain via DomainOrg,
110+
which grant unrestricted read+write access), this is a standalone check based purely
111+
on group membership + the hardcoded lightwell domain name -- see has_permission() and
112+
scope_queryset() for where this is applied.
113+
"""
114+
return user.is_authenticated and user.groups.filter(name=LIGHTWELL_READONLY_GROUP_NAME).exists()
115+
85116
def _check_pypi_safe_method_access(self, request, view, domain):
86117
"""
87118
Returns True/False for a SAFE_METHOD request to a PyPI `view`, or None if `view`
@@ -102,22 +133,36 @@ def _check_pypi_safe_method_access(self, request, view, domain):
102133
return self._has_pypi_read_access(request, domain)
103134
return True
104135

136+
def _check_safe_method_access(self, request, view, domain, user):
137+
"""
138+
Returns True/False for a SAFE_METHOD request, or None if none of the SAFE_METHOD
139+
shortcuts (public domains, PyPI views, the lightwell read-only group) apply -- the
140+
caller should then fall through to the standard DomainOrg-based checks below.
141+
"""
142+
if domain and domain.name.startswith("public-"):
143+
return True
144+
145+
pypi_access = self._check_pypi_safe_method_access(request, view, domain)
146+
if pypi_access is not None:
147+
return pypi_access
148+
149+
if domain and domain.name == LIGHTWELL_DOMAIN_NAME and self._has_lightwell_readonly_group_access(user):
150+
return True
151+
152+
return None
153+
105154
def has_permission(self, request, view): # noqa: PLR0911
106155
# Admins have all permissions
107156
if request.user.is_superuser:
108157
return True
109158

110159
user = request.user
111160

112-
# Allow safe requests on public domains for all users, authenticated or not
113161
if request.method in SAFE_METHODS:
114162
domain = getattr(request, "pulp_domain", None)
115-
if domain and domain.name.startswith("public-"):
116-
return True
117-
118-
pypi_access = self._check_pypi_safe_method_access(request, view, domain)
119-
if pypi_access is not None:
120-
return pypi_access
163+
safe_method_access = self._check_safe_method_access(request, view, domain, user)
164+
if safe_method_access is not None:
165+
return safe_method_access
121166

122167
if not user.is_authenticated:
123168
return False
@@ -219,4 +264,7 @@ def scope_queryset(self, view, qs):
219264
if org_id is not None:
220265
query |= Q(domain_orgs__org_id=org_id)
221266

267+
if self._has_lightwell_readonly_group_access(user):
268+
query |= Q(name=LIGHTWELL_DOMAIN_NAME)
269+
222270
return qs.filter(query).distinct()
Lines changed: 234 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,234 @@
1+
"""
2+
Functional tests for the hardcoded LIGHTWELL_READONLY_GROUP_NAME group enforced by
3+
DomainBasedPermission, scoped specifically to the "lightwell" domain's non-PyPI endpoints
4+
(Pulp REST API: repository listing, etc.).
5+
6+
Unlike normal groups (created through CreateDomainView's group_name flow and linked to a
7+
domain via DomainOrg, which grant unrestricted read+write access), membership in this
8+
hardcoded group grants read-only (SAFE_METHODS) access to the lightwell domain, independent
9+
of any DomainOrg association. It does not grant write access, and it does not apply to the
10+
lightwell domain's PyPI views, which remain gated exclusively by the lightwell-network
11+
feature check (see test_lightwell_feature_permission.py) -- group membership must not bypass
12+
that check.
13+
14+
These follow the pattern used in test_group_based_permissions.py (group setup via gen_group
15+
/ UsersApi / GroupsUsersApi) and test_lightwell_feature_permission.py (the "lightwell"
16+
domain/PyPI fixtures).
17+
18+
NOTE: like test_lightwell_feature_permission.py, this is keyed off the literal domain name
19+
"lightwell" (see pulp_service.app.authorization.LIGHTWELL_DOMAIN_NAME), so the domain created
20+
here can't use a random per-test suffix. These tests assume they run against an ephemeral
21+
Pulp instance where no "lightwell" domain already exists, and are not run concurrently with
22+
other tests that also create a "lightwell" domain.
23+
"""
24+
25+
import json
26+
from base64 import b64encode
27+
from urllib.parse import urljoin
28+
from uuid import uuid4
29+
30+
import pytest
31+
import requests
32+
33+
from pulp_service.app.authorization import LIGHTWELL_READONLY_GROUP_NAME
34+
35+
LIGHTWELL_DOMAIN_NAME = "lightwell"
36+
37+
# An org with no DomainOrg association with the test domain and no lightwell-network
38+
# feature entitlement; only used to own the test domain/repo/distribution.
39+
DOMAIN_OWNER_ORG_ID = "555555555"
40+
# A distinct org used for the group members created in these tests, so they never
41+
# accidentally collide with the domain owner's DomainOrg association.
42+
GROUP_MEMBER_ORG_ID = "666666666"
43+
44+
45+
def _identity_header(org_id, username):
46+
identity = {
47+
"identity": {
48+
"org_id": org_id,
49+
"internal": {"org_id": org_id},
50+
"user": {"username": username},
51+
}
52+
}
53+
return b64encode(json.dumps(identity).encode()).decode()
54+
55+
56+
def _combined_username(org_id, username):
57+
"""Matches the "{org_id}|{username}" format RHTermsBasedRegistryAuthentication resolves
58+
identity headers to (see pulp_service.app.authentication)."""
59+
return f"{org_id}|{username}"
60+
61+
62+
@pytest.fixture
63+
def lightwell_readonly_group(gen_group):
64+
"""The hardcoded read-only group, created with its real (hardcoded) name."""
65+
return gen_group(name=LIGHTWELL_READONLY_GROUP_NAME)
66+
67+
68+
@pytest.fixture
69+
def configure_lightwell_domain(
70+
anonymous_user,
71+
gen_object_with_cleanup,
72+
pulpcore_bindings,
73+
file_bindings,
74+
python_bindings,
75+
bindings_cfg,
76+
):
77+
"""
78+
Creates the "lightwell" domain (owned by DOMAIN_OWNER_ORG_ID, no relation to the
79+
read-only group), with a File repository and a PyPI-distributed Python repository.
80+
81+
Returns (repos_url, pypi_url, owner_header).
82+
"""
83+
owner_header = _identity_header(DOMAIN_OWNER_ORG_ID, "lightwell-readonly-test-owner")
84+
85+
with anonymous_user:
86+
pulpcore_bindings.DomainsApi.api_client.default_headers["x-rh-identity"] = owner_header
87+
88+
gen_object_with_cleanup(
89+
pulpcore_bindings.DomainsApi,
90+
{
91+
"name": LIGHTWELL_DOMAIN_NAME,
92+
"storage_class": "pulpcore.app.models.storage.FileSystem",
93+
"storage_settings": {"MEDIA_ROOT": "/var/lib/pulp/media/"},
94+
},
95+
)
96+
97+
file_bindings.RepositoriesFileApi.api_client.default_headers["x-rh-identity"] = owner_header
98+
gen_object_with_cleanup(
99+
file_bindings.RepositoriesFileApi, {"name": str(uuid4())}, pulp_domain=LIGHTWELL_DOMAIN_NAME
100+
)
101+
102+
python_bindings.RepositoriesPythonApi.api_client.default_headers["x-rh-identity"] = owner_header
103+
repo = gen_object_with_cleanup(
104+
python_bindings.RepositoriesPythonApi, {"name": str(uuid4())}, pulp_domain=LIGHTWELL_DOMAIN_NAME
105+
)
106+
107+
python_bindings.DistributionsPypiApi.api_client.default_headers["x-rh-identity"] = owner_header
108+
pypi_base_path = str(uuid4())
109+
gen_object_with_cleanup(
110+
python_bindings.DistributionsPypiApi,
111+
{"name": str(uuid4()), "base_path": pypi_base_path, "repository": repo.pulp_href},
112+
pulp_domain=LIGHTWELL_DOMAIN_NAME,
113+
)
114+
115+
repos_url = urljoin(bindings_cfg.host, f"/api/pulp/{LIGHTWELL_DOMAIN_NAME}/api/v3/repositories/file/file/")
116+
pypi_url = urljoin(bindings_cfg.host, f"/api/pypi/{LIGHTWELL_DOMAIN_NAME}/{pypi_base_path}/simple/")
117+
118+
yield repos_url, pypi_url, owner_header
119+
120+
pulpcore_bindings.DomainsApi.api_client.default_headers.pop("x-rh-identity", None)
121+
file_bindings.RepositoriesFileApi.api_client.default_headers.pop("x-rh-identity", None)
122+
python_bindings.RepositoriesPythonApi.api_client.default_headers.pop("x-rh-identity", None)
123+
python_bindings.DistributionsPypiApi.api_client.default_headers.pop("x-rh-identity", None)
124+
125+
126+
@pytest.fixture
127+
def gen_readonly_group_member(pulpcore_bindings, gen_object_with_cleanup, lightwell_readonly_group):
128+
"""Creates a user that's a member of the hardcoded read-only group, and returns an
129+
x-rh-identity header for that user."""
130+
131+
def _gen_member(username_suffix):
132+
username = f"readonly-member-{username_suffix}-{uuid4()}"
133+
combined_username = _combined_username(GROUP_MEMBER_ORG_ID, username)
134+
gen_object_with_cleanup(
135+
pulpcore_bindings.UsersApi,
136+
{"username": combined_username, "groups": [lightwell_readonly_group.pulp_href]},
137+
)
138+
return _identity_header(GROUP_MEMBER_ORG_ID, username)
139+
140+
return _gen_member
141+
142+
143+
def test_readonly_group_member_can_read_lightwell_repositories(configure_lightwell_domain, gen_readonly_group_member):
144+
"""A user with no DomainOrg association, whose only access path is membership in the
145+
hardcoded read-only group, can list repositories in the lightwell domain."""
146+
repos_url, _, _ = configure_lightwell_domain
147+
headers = {"x-rh-identity": gen_readonly_group_member("read")}
148+
149+
response = requests.get(repos_url, headers=headers, timeout=30)
150+
151+
assert response.status_code == 200
152+
153+
154+
def test_non_member_denied_reading_lightwell_repositories(
155+
configure_lightwell_domain, gen_object_with_cleanup, pulpcore_bindings
156+
):
157+
"""A user with no DomainOrg association and no read-only group membership gets 403 when
158+
listing repositories in the lightwell domain."""
159+
repos_url, _, _ = configure_lightwell_domain
160+
username = f"non-member-{uuid4()}"
161+
combined_username = _combined_username(GROUP_MEMBER_ORG_ID, username)
162+
gen_object_with_cleanup(pulpcore_bindings.UsersApi, {"username": combined_username})
163+
headers = {"x-rh-identity": _identity_header(GROUP_MEMBER_ORG_ID, username)}
164+
165+
response = requests.get(repos_url, headers=headers, timeout=30)
166+
167+
assert response.status_code == 403
168+
169+
170+
def test_readonly_group_member_write_denied(configure_lightwell_domain, gen_readonly_group_member):
171+
"""The read-only group grants no write access -- a member must still be denied when
172+
trying to create a repository in the lightwell domain."""
173+
repos_url, _, _ = configure_lightwell_domain
174+
headers = {"x-rh-identity": gen_readonly_group_member("write")}
175+
176+
response = requests.post(repos_url, headers=headers, json={"name": str(uuid4())}, timeout=30)
177+
178+
assert response.status_code in (401, 403)
179+
180+
181+
def test_readonly_group_member_pypi_still_requires_feature(configure_lightwell_domain, gen_readonly_group_member):
182+
"""Read-only group membership does not bypass the lightwell-network feature check on
183+
PyPI views -- a member with no feature entitlement and no DomainOrg association still
184+
gets 403 on the PyPI simple API."""
185+
_, pypi_url, _ = configure_lightwell_domain
186+
headers = {"x-rh-identity": gen_readonly_group_member("pypi")}
187+
188+
response = requests.get(pypi_url, headers=headers, timeout=30)
189+
190+
assert response.status_code == 403
191+
192+
193+
def test_readonly_group_member_denied_on_other_domains(
194+
pulpcore_bindings, anonymous_user, gen_object_with_cleanup, gen_readonly_group_member, bindings_cfg
195+
):
196+
"""Membership in the lightwell read-only group grants no access to domains other than
197+
lightwell."""
198+
other_domain_owner_header = _identity_header("777777777", "other-domain-owner")
199+
domain_name = f"not-lightwell-{uuid4()}"
200+
201+
with anonymous_user:
202+
pulpcore_bindings.DomainsApi.api_client.default_headers["x-rh-identity"] = other_domain_owner_header
203+
gen_object_with_cleanup(
204+
pulpcore_bindings.DomainsApi,
205+
{
206+
"name": domain_name,
207+
"storage_class": "pulpcore.app.models.storage.FileSystem",
208+
"storage_settings": {"MEDIA_ROOT": "/var/lib/pulp/media/"},
209+
},
210+
)
211+
pulpcore_bindings.DomainsApi.api_client.default_headers.pop("x-rh-identity", None)
212+
213+
repos_url = urljoin(bindings_cfg.host, f"/api/pulp/{domain_name}/api/v3/repositories/file/file/")
214+
headers = {"x-rh-identity": gen_readonly_group_member("other-domain")}
215+
216+
response = requests.get(repos_url, headers=headers, timeout=30)
217+
218+
assert response.status_code == 403
219+
220+
221+
def test_readonly_group_member_sees_lightwell_domain_in_listing(
222+
configure_lightwell_domain, gen_readonly_group_member, bindings_cfg
223+
):
224+
"""The lightwell domain shows up in GET /domains/ for read-only group members, via
225+
DomainBasedPermission.scope_queryset()."""
226+
del configure_lightwell_domain # ensure the "lightwell" domain exists
227+
headers = {"x-rh-identity": gen_readonly_group_member("domain-list")}
228+
domains_url = urljoin(bindings_cfg.host, "/api/pulp/api/v3/domains/")
229+
230+
response = requests.get(domains_url, headers=headers, timeout=30)
231+
232+
assert response.status_code == 200
233+
domain_names = {domain["name"] for domain in response.json()["results"]}
234+
assert LIGHTWELL_DOMAIN_NAME in domain_names

0 commit comments

Comments
 (0)