devpi-admin 1.4.0__tar.gz → 1.4.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. {devpi_admin-1.4.0 → devpi_admin-1.4.1}/PKG-INFO +7 -1
  2. {devpi_admin-1.4.0 → devpi_admin-1.4.1}/README.md +6 -0
  3. {devpi_admin-1.4.0 → devpi_admin-1.4.1}/devpi_admin/_version.py +3 -3
  4. {devpi_admin-1.4.0 → devpi_admin-1.4.1}/devpi_admin/main.py +43 -2
  5. {devpi_admin-1.4.0 → devpi_admin-1.4.1}/devpi_admin.egg-info/PKG-INFO +7 -1
  6. {devpi_admin-1.4.0 → devpi_admin-1.4.1}/tests/test_acl_read.py +127 -15
  7. {devpi_admin-1.4.0 → devpi_admin-1.4.1}/.github/workflows/publish.yml +0 -0
  8. {devpi_admin-1.4.0 → devpi_admin-1.4.1}/.github/workflows/tests.yml +0 -0
  9. {devpi_admin-1.4.0 → devpi_admin-1.4.1}/.gitignore +0 -0
  10. {devpi_admin-1.4.0 → devpi_admin-1.4.1}/LICENSE +0 -0
  11. {devpi_admin-1.4.0 → devpi_admin-1.4.1}/devpi_admin/__init__.py +0 -0
  12. {devpi_admin-1.4.0 → devpi_admin-1.4.1}/devpi_admin/customizer.py +0 -0
  13. {devpi_admin-1.4.0 → devpi_admin-1.4.1}/devpi_admin/static/css/style.css +0 -0
  14. {devpi_admin-1.4.0 → devpi_admin-1.4.1}/devpi_admin/static/favicon.svg +0 -0
  15. {devpi_admin-1.4.0 → devpi_admin-1.4.1}/devpi_admin/static/index.html +0 -0
  16. {devpi_admin-1.4.0 → devpi_admin-1.4.1}/devpi_admin/static/js/api.js +0 -0
  17. {devpi_admin-1.4.0 → devpi_admin-1.4.1}/devpi_admin/static/js/app.js +0 -0
  18. {devpi_admin-1.4.0 → devpi_admin-1.4.1}/devpi_admin/static/js/marked.min.js +0 -0
  19. {devpi_admin-1.4.0 → devpi_admin-1.4.1}/devpi_admin/static/js/theme.js +0 -0
  20. {devpi_admin-1.4.0 → devpi_admin-1.4.1}/devpi_admin/tokens.py +0 -0
  21. {devpi_admin-1.4.0 → devpi_admin-1.4.1}/devpi_admin.egg-info/SOURCES.txt +0 -0
  22. {devpi_admin-1.4.0 → devpi_admin-1.4.1}/devpi_admin.egg-info/dependency_links.txt +0 -0
  23. {devpi_admin-1.4.0 → devpi_admin-1.4.1}/devpi_admin.egg-info/entry_points.txt +0 -0
  24. {devpi_admin-1.4.0 → devpi_admin-1.4.1}/devpi_admin.egg-info/requires.txt +0 -0
  25. {devpi_admin-1.4.0 → devpi_admin-1.4.1}/devpi_admin.egg-info/top_level.txt +0 -0
  26. {devpi_admin-1.4.0 → devpi_admin-1.4.1}/pyproject.toml +0 -0
  27. {devpi_admin-1.4.0 → devpi_admin-1.4.1}/setup.cfg +0 -0
  28. {devpi_admin-1.4.0 → devpi_admin-1.4.1}/tests/__init__.py +0 -0
  29. {devpi_admin-1.4.0 → devpi_admin-1.4.1}/tests/test_devpi_tokens_ui.py +0 -0
  30. {devpi_admin-1.4.0 → devpi_admin-1.4.1}/tests/test_filter.py +0 -0
  31. {devpi_admin-1.4.0 → devpi_admin-1.4.1}/tests/test_hooks.py +0 -0
  32. {devpi_admin-1.4.0 → devpi_admin-1.4.1}/tests/test_json_safe.py +0 -0
  33. {devpi_admin-1.4.0 → devpi_admin-1.4.1}/tests/test_package.py +0 -0
  34. {devpi_admin-1.4.0 → devpi_admin-1.4.1}/tests/test_pipconf.py +0 -0
  35. {devpi_admin-1.4.0 → devpi_admin-1.4.1}/tests/test_tokens.py +0 -0
  36. {devpi_admin-1.4.0 → devpi_admin-1.4.1}/tests/test_tween.py +0 -0
  37. {devpi_admin-1.4.0 → devpi_admin-1.4.1}/tests/test_view_helpers.py +0 -0
  38. {devpi_admin-1.4.0 → devpi_admin-1.4.1}/tests/test_wants_html.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: devpi-admin
3
- Version: 1.4.0
3
+ Version: 1.4.1
4
4
  Summary: Modern web UI plugin for devpi-server — drop-in replacement for devpi-web
5
5
  Author-email: Pavel Revak <pavelrevak@gmail.com>
6
6
  License: MIT
@@ -122,6 +122,12 @@ talks to the standard devpi JSON API directly.
122
122
  `DELETE` is **never** granted, even with `upload` scope - package removal must use
123
123
  password auth. Anything outside the bound index path returns 403, including the SPA,
124
124
  `/+admin-api/*` (so a token cannot mint further tokens), `/+login`, `/`, and `/<user>`.
125
+ **Bases exception**: `GET /<base>/<idx>/+f/<...>` is also allowed when `<base>/<idx>`
126
+ is in the bound stage's SRO (bases inheritance). devpi's `+simple/` view on a stage
127
+ emits file links pointing directly at the base index (e.g. mirror-fed packages on
128
+ `villapro/staging` link to `/root/pypi/+f/...`); without this exception pip would
129
+ follow the link with the bound token and get 403. Limited to `GET` on `+f/` —
130
+ cross-index `+simple/` and writes remain blocked.
125
131
  - **Issuance rules**: regular users may issue for themselves; root may issue for *other*
126
132
  users (admin delegation) but not for itself. Admin-token-authenticated requests cannot
127
133
  issue further tokens. Issuance verifies the target user is in `acl_read` /
@@ -98,6 +98,12 @@ talks to the standard devpi JSON API directly.
98
98
  `DELETE` is **never** granted, even with `upload` scope - package removal must use
99
99
  password auth. Anything outside the bound index path returns 403, including the SPA,
100
100
  `/+admin-api/*` (so a token cannot mint further tokens), `/+login`, `/`, and `/<user>`.
101
+ **Bases exception**: `GET /<base>/<idx>/+f/<...>` is also allowed when `<base>/<idx>`
102
+ is in the bound stage's SRO (bases inheritance). devpi's `+simple/` view on a stage
103
+ emits file links pointing directly at the base index (e.g. mirror-fed packages on
104
+ `villapro/staging` link to `/root/pypi/+f/...`); without this exception pip would
105
+ follow the link with the bound token and get 403. Limited to `GET` on `+f/` —
106
+ cross-index `+simple/` and writes remain blocked.
101
107
  - **Issuance rules**: regular users may issue for themselves; root may issue for *other*
102
108
  users (admin delegation) but not for itself. Admin-token-authenticated requests cannot
103
109
  issue further tokens. Issuance verifies the target user is in `acl_read` /
@@ -18,7 +18,7 @@ version_tuple: tuple[int | str, ...]
18
18
  commit_id: str | None
19
19
  __commit_id__: str | None
20
20
 
21
- __version__ = version = '1.4.0'
22
- __version_tuple__ = version_tuple = (1, 4, 0)
21
+ __version__ = version = '1.4.1'
22
+ __version_tuple__ = version_tuple = (1, 4, 1)
23
23
 
24
- __commit_id__ = commit_id = 'gc695723cf'
24
+ __commit_id__ = commit_id = 'gf69f25fba'
@@ -444,7 +444,7 @@ def devpi_admin_tween_factory(handler, registry):
444
444
  # Cache for the identity hook so it doesn't re-read keyfs.
445
445
  request.environ["adm.token_meta"] = meta
446
446
  request.environ["adm.is_admin_token"] = True
447
- denied = _admin_token_check(request, meta)
447
+ denied = _admin_token_check(request, meta, xom)
448
448
  if denied is not None:
449
449
  return denied
450
450
  # Invalid/expired token: let the identity hook return None and
@@ -510,7 +510,7 @@ def _request_carries_admin_token(request):
510
510
  return _extract_admin_token_secret(request) is not None
511
511
 
512
512
 
513
- def _admin_token_check(request, token_meta):
513
+ def _admin_token_check(request, token_meta, xom):
514
514
  """Restrict admin-token requests by scope and bound index.
515
515
 
516
516
  A token carries a ``scope`` (``read`` / ``upload``) and is bound to a
@@ -523,6 +523,13 @@ def _admin_token_check(request, token_meta):
523
523
  ``/<user>/<index>/...``. A token bound to ``alice/dev`` cannot
524
524
  reach ``/bob/prod`` or any management endpoint (``/+admin*``,
525
525
  ``/+admin-api/*``, ``/+login``, ``/+status``, ``/<user>``).
526
+ * Cross-base file downloads (``GET /<base>/<idx>/+f/...``) are
527
+ allowed when the target index is reachable through the bound
528
+ stage's SRO (bases inheritance). devpi's ``+simple/`` on a stage
529
+ returns links pointing to the base index hosting the file (e.g.
530
+ ``/root/pypi/+f/...`` for a mirror-fed package on
531
+ ``villapro/staging``); without this exception pip would follow
532
+ the link with the bound token and get 403.
526
533
 
527
534
  Returns ``None`` to allow, or an HTTPForbidden response to deny.
528
535
  """
@@ -553,11 +560,45 @@ def _admin_token_check(request, token_meta):
553
560
  prefix = "/" + bound
554
561
  if path == prefix or path.startswith(prefix + "/"):
555
562
  return None
563
+ # Bases inheritance for file downloads: a stage's +simple/ surfaces
564
+ # links into the base index (e.g. mirror) rather than rewriting them
565
+ # under the stage URL. Allow the resulting cross-index GET as long
566
+ # as the target is part of the bound stage's SRO.
567
+ if request.method == "GET":
568
+ file_match = _PKG_FILE_RE.match(path)
569
+ if file_match is not None:
570
+ other_user, other_index = file_match.group(1), file_match.group(2)
571
+ if _index_in_bound_sro(xom, bound, other_user, other_index):
572
+ return None
556
573
  return HTTPForbidden(json_body={
557
574
  "error": f"admin token bound to {bound} cannot access {path}",
558
575
  })
559
576
 
560
577
 
578
+ def _index_in_bound_sro(xom, bound, other_user, other_index):
579
+ """Return True iff ``other_user/other_index`` is in bound stage's SRO.
580
+
581
+ SRO (Stage Resolution Order) is devpi's transitive bases traversal —
582
+ the same chain its ``+simple/`` view follows when emitting links.
583
+ Best-effort: missing stage or any error returns False (deny). Runs
584
+ inside a read transaction because ``sro()`` touches each base's
585
+ ``ixconfig`` in keyfs.
586
+ """
587
+ bound_user, bound_idx = bound.split("/", 1)
588
+ try:
589
+ with xom.keyfs.read_transaction(allow_reuse=True):
590
+ bound_stage = xom.model.getstage(bound_user, bound_idx)
591
+ if bound_stage is None:
592
+ return False
593
+ target_name = f"{other_user}/{other_index}"
594
+ for stage in bound_stage.sro():
595
+ if stage.name == target_name:
596
+ return True
597
+ except Exception:
598
+ return False
599
+ return False
600
+
601
+
561
602
  def _user_listing_check(request):
562
603
  """Restrict ``GET /<user>/`` to that user or root.
563
604
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: devpi-admin
3
- Version: 1.4.0
3
+ Version: 1.4.1
4
4
  Summary: Modern web UI plugin for devpi-server — drop-in replacement for devpi-web
5
5
  Author-email: Pavel Revak <pavelrevak@gmail.com>
6
6
  License: MIT
@@ -122,6 +122,12 @@ talks to the standard devpi JSON API directly.
122
122
  `DELETE` is **never** granted, even with `upload` scope - package removal must use
123
123
  password auth. Anything outside the bound index path returns 403, including the SPA,
124
124
  `/+admin-api/*` (so a token cannot mint further tokens), `/+login`, `/`, and `/<user>`.
125
+ **Bases exception**: `GET /<base>/<idx>/+f/<...>` is also allowed when `<base>/<idx>`
126
+ is in the bound stage's SRO (bases inheritance). devpi's `+simple/` view on a stage
127
+ emits file links pointing directly at the base index (e.g. mirror-fed packages on
128
+ `villapro/staging` link to `/root/pypi/+f/...`); without this exception pip would
129
+ follow the link with the bound token and get 403. Limited to `GET` on `+f/` —
130
+ cross-index `+simple/` and writes remain blocked.
125
131
  - **Issuance rules**: regular users may issue for themselves; root may issue for *other*
126
132
  users (admin delegation) but not for itself. Admin-token-authenticated requests cannot
127
133
  issue further tokens. Issuance verifies the target user is in `acl_read` /
@@ -150,7 +150,7 @@ class AdminTokenCheckTests(unittest.TestCase):
150
150
  "/alice/dev/+simple/", "/alice/dev/+f/foo.whl",
151
151
  "/alice/dev/foo/1.0"):
152
152
  self.assertIsNone(
153
- _admin_token_check(self._make("GET", path), self._meta()),
153
+ _admin_token_check(self._make("GET", path), self._meta(), None),
154
154
  "GET %s should be allowed" % path)
155
155
 
156
156
  def test_read_scope_blocks_management_paths(self):
@@ -158,30 +158,31 @@ class AdminTokenCheckTests(unittest.TestCase):
158
158
  "/+admin-api/users/alice/tokens", "/+status",
159
159
  "/alice", "/alice/"):
160
160
  self.assertIsNotNone(
161
- _admin_token_check(self._make("GET", path), self._meta()),
161
+ _admin_token_check(self._make("GET", path), self._meta(), None),
162
162
  "GET %s should be blocked" % path)
163
163
 
164
164
  def test_read_scope_blocks_other_index(self):
165
165
  # Token bound to alice/dev cannot reach bob/prod.
166
166
  self.assertIsNotNone(_admin_token_check(
167
- self._make("GET", "/bob/prod"), self._meta()))
167
+ self._make("GET", "/bob/prod"), self._meta(), None))
168
168
  self.assertIsNotNone(_admin_token_check(
169
- self._make("GET", "/bob/prod/+simple/foo"), self._meta()))
169
+ self._make("GET", "/bob/prod/+simple/foo"), self._meta(), None))
170
170
  # Even alice's *other* index is blocked.
171
171
  self.assertIsNotNone(_admin_token_check(
172
- self._make("GET", "/alice/staging"), self._meta()))
172
+ self._make("GET", "/alice/staging"), self._meta(), None))
173
173
 
174
174
  def test_head_treated_like_get(self):
175
175
  self.assertIsNone(_admin_token_check(
176
- self._make("HEAD", "/alice/dev"), self._meta()))
176
+ self._make("HEAD", "/alice/dev"), self._meta(), None))
177
177
  self.assertIsNotNone(_admin_token_check(
178
- self._make("HEAD", "/+login"), self._meta()))
178
+ self._make("HEAD", "/+login"), self._meta(), None))
179
179
 
180
180
  def test_read_scope_blocks_writes_everywhere(self):
181
181
  for method in ("POST", "PUT", "PATCH", "DELETE"):
182
182
  for path in ("/alice/dev", "/alice/dev/foo", "/+login"):
183
183
  self.assertIsNotNone(
184
- _admin_token_check(self._make(method, path), self._meta()),
184
+ _admin_token_check(
185
+ self._make(method, path), self._meta(), None),
185
186
  "%s %s must be blocked for read-scope token"
186
187
  % (method, path))
187
188
 
@@ -191,10 +192,10 @@ class AdminTokenCheckTests(unittest.TestCase):
191
192
  meta = self._meta(scope="upload")
192
193
  for method in ("POST", "PUT"):
193
194
  self.assertIsNone(_admin_token_check(
194
- self._make(method, "/alice/dev"), meta),
195
+ self._make(method, "/alice/dev"), meta, None),
195
196
  "%s should be allowed" % method)
196
197
  self.assertIsNone(_admin_token_check(
197
- self._make(method, "/alice/dev/foo/1.0"), meta))
198
+ self._make(method, "/alice/dev/foo/1.0"), meta, None))
198
199
 
199
200
  def test_upload_scope_blocks_delete(self):
200
201
  # Even with upload scope, DELETE is never allowed — package
@@ -202,31 +203,142 @@ class AdminTokenCheckTests(unittest.TestCase):
202
203
  meta = self._meta(scope="upload")
203
204
  for path in ("/alice/dev", "/alice/dev/foo", "/alice/dev/foo/1.0"):
204
205
  self.assertIsNotNone(
205
- _admin_token_check(self._make("DELETE", path), meta),
206
+ _admin_token_check(self._make("DELETE", path), meta, None),
206
207
  "DELETE %s must be blocked even for upload scope" % path)
207
208
 
208
209
  def test_upload_scope_blocks_other_index(self):
209
210
  meta = self._meta(scope="upload")
210
211
  self.assertIsNotNone(_admin_token_check(
211
- self._make("POST", "/bob/prod"), meta))
212
+ self._make("POST", "/bob/prod"), meta, None))
212
213
 
213
214
  def test_upload_scope_blocks_management_paths(self):
214
215
  meta = self._meta(scope="upload")
215
216
  for path in ("/+login", "/+admin-api/token", "/+admin/", "/"):
216
217
  self.assertIsNotNone(
217
- _admin_token_check(self._make("POST", path), meta))
218
+ _admin_token_check(self._make("POST", path), meta, None))
218
219
 
219
220
  # --- malformed meta (defensive) ---
220
221
 
221
222
  def test_unknown_scope_blocked(self):
222
223
  meta = {"user": "alice", "index": "alice/dev", "scope": "weird"}
223
224
  self.assertIsNotNone(_admin_token_check(
224
- self._make("GET", "/alice/dev"), meta))
225
+ self._make("GET", "/alice/dev"), meta, None))
225
226
 
226
227
  def test_missing_index_blocked(self):
227
228
  meta = {"user": "alice", "scope": "read"}
228
229
  self.assertIsNotNone(_admin_token_check(
229
- self._make("GET", "/alice/dev"), meta))
230
+ self._make("GET", "/alice/dev"), meta, None))
231
+
232
+
233
+ class AdminTokenBasesAwareTests(unittest.TestCase):
234
+ """File-download cross-index GETs are allowed when the target index
235
+ is reachable through the bound stage's SRO (bases inheritance).
236
+
237
+ devpi's ``+simple/`` view on a stage emits links pointing to the
238
+ base index that physically hosts the file (e.g. mirror-fed packages
239
+ on ``villapro/staging`` link to ``/root/pypi/+f/...``); pip then
240
+ follows them with the bound token and must not be 403'd.
241
+ """
242
+
243
+ def _make(self, method, path):
244
+ req = MagicMock()
245
+ req.method = method
246
+ req.path = path
247
+ return req
248
+
249
+ def _meta(self, index="villapro/staging"):
250
+ return {
251
+ "user": index.split("/")[0], "index": index, "scope": "read"}
252
+
253
+ def _xom_with_sro(self, sro_names):
254
+ """Build a stub xom whose bound stage's sro() yields stages with
255
+ the given ``user/index`` names. ``read_transaction()`` is a
256
+ no-op context manager.
257
+ """
258
+ stages = [MagicMock(name=n) for n in sro_names]
259
+ for stage, n in zip(stages, sro_names):
260
+ stage.name = n
261
+ bound_stage = MagicMock()
262
+ bound_stage.sro.return_value = iter(stages)
263
+
264
+ xom = MagicMock()
265
+ xom.model.getstage.return_value = bound_stage
266
+ ctx = MagicMock()
267
+ ctx.__enter__ = MagicMock(return_value=ctx)
268
+ ctx.__exit__ = MagicMock(return_value=False)
269
+ xom.keyfs.read_transaction.return_value = ctx
270
+ return xom
271
+
272
+ def test_file_download_on_base_index_allowed(self):
273
+ # staging → private → pypi: pip follows mirror file link.
274
+ xom = self._xom_with_sro(
275
+ ["villapro/staging", "villapro/private", "root/pypi"])
276
+ result = _admin_token_check(
277
+ self._make("GET", "/root/pypi/+f/ab/cdef/setuptools-1.0.whl"),
278
+ self._meta(), xom)
279
+ self.assertIsNone(result)
280
+
281
+ def test_file_download_on_intermediate_base_allowed(self):
282
+ xom = self._xom_with_sro(
283
+ ["villapro/staging", "villapro/private", "root/pypi"])
284
+ result = _admin_token_check(
285
+ self._make("GET", "/villapro/private/+f/ab/cdef/pkg-1.0.whl"),
286
+ self._meta(), xom)
287
+ self.assertIsNone(result)
288
+
289
+ def test_file_download_on_unrelated_index_blocked(self):
290
+ xom = self._xom_with_sro(
291
+ ["villapro/staging", "villapro/private", "root/pypi"])
292
+ result = _admin_token_check(
293
+ self._make("GET", "/charlie/secret/+f/ab/cdef/pkg-1.0.whl"),
294
+ self._meta(), xom)
295
+ self.assertIsNotNone(result)
296
+
297
+ def test_simple_listing_on_base_not_exempted(self):
298
+ # Bases exception is scoped to +f/ file downloads only — pip
299
+ # always queries +simple/ on the bound stage URL (devpi merges
300
+ # the views), so cross-index +simple has no legitimate flow and
301
+ # must remain blocked to keep token scope meaningful.
302
+ xom = self._xom_with_sro(
303
+ ["villapro/staging", "root/pypi"])
304
+ result = _admin_token_check(
305
+ self._make("GET", "/root/pypi/+simple/setuptools/"),
306
+ self._meta(), xom)
307
+ self.assertIsNotNone(result)
308
+
309
+ def test_bound_stage_missing_denies(self):
310
+ xom = MagicMock()
311
+ xom.model.getstage.return_value = None
312
+ ctx = MagicMock()
313
+ ctx.__enter__ = MagicMock(return_value=ctx)
314
+ ctx.__exit__ = MagicMock(return_value=False)
315
+ xom.keyfs.read_transaction.return_value = ctx
316
+ result = _admin_token_check(
317
+ self._make("GET", "/root/pypi/+f/ab/cdef/pkg.whl"),
318
+ self._meta(), xom)
319
+ self.assertIsNotNone(result)
320
+
321
+ def test_sro_lookup_exception_denies(self):
322
+ # Best-effort: any error in SRO traversal falls through to deny,
323
+ # rather than masking a misconfiguration as accidental access.
324
+ xom = MagicMock()
325
+ xom.keyfs.read_transaction.side_effect = RuntimeError("boom")
326
+ result = _admin_token_check(
327
+ self._make("GET", "/root/pypi/+f/ab/cdef/pkg.whl"),
328
+ self._meta(), xom)
329
+ self.assertIsNotNone(result)
330
+
331
+ def test_upload_scope_does_not_use_bases_exception(self):
332
+ # Upload tokens write to the bound stage; cross-base writes
333
+ # don't have an analogous "+f link in +simple" justification.
334
+ # POST /root/pypi/... must stay blocked even with SRO match.
335
+ xom = self._xom_with_sro(["villapro/staging", "root/pypi"])
336
+ meta = {"user": "villapro", "index": "villapro/staging",
337
+ "scope": "upload"}
338
+ result = _admin_token_check(
339
+ self._make("POST", "/root/pypi/+f/ab/cdef/pkg.whl"),
340
+ meta, xom)
341
+ self.assertIsNotNone(result)
230
342
 
231
343
 
232
344
  class UserListingCheckTests(unittest.TestCase):
File without changes
File without changes
File without changes
File without changes