devpi-admin 1.4.0__tar.gz → 1.4.2__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.2}/.github/workflows/publish.yml +2 -2
  2. {devpi_admin-1.4.0 → devpi_admin-1.4.2}/.github/workflows/tests.yml +2 -2
  3. {devpi_admin-1.4.0 → devpi_admin-1.4.2}/PKG-INFO +7 -1
  4. {devpi_admin-1.4.0 → devpi_admin-1.4.2}/README.md +6 -0
  5. {devpi_admin-1.4.0 → devpi_admin-1.4.2}/devpi_admin/_version.py +3 -3
  6. {devpi_admin-1.4.0 → devpi_admin-1.4.2}/devpi_admin/main.py +43 -2
  7. {devpi_admin-1.4.0 → devpi_admin-1.4.2}/devpi_admin/static/css/style.css +5 -1
  8. {devpi_admin-1.4.0 → devpi_admin-1.4.2}/devpi_admin/static/js/app.js +73 -9
  9. {devpi_admin-1.4.0 → devpi_admin-1.4.2}/devpi_admin.egg-info/PKG-INFO +7 -1
  10. {devpi_admin-1.4.0 → devpi_admin-1.4.2}/tests/test_acl_read.py +127 -15
  11. {devpi_admin-1.4.0 → devpi_admin-1.4.2}/tests/test_devpi_tokens_ui.py +17 -0
  12. {devpi_admin-1.4.0 → devpi_admin-1.4.2}/.gitignore +0 -0
  13. {devpi_admin-1.4.0 → devpi_admin-1.4.2}/LICENSE +0 -0
  14. {devpi_admin-1.4.0 → devpi_admin-1.4.2}/devpi_admin/__init__.py +0 -0
  15. {devpi_admin-1.4.0 → devpi_admin-1.4.2}/devpi_admin/customizer.py +0 -0
  16. {devpi_admin-1.4.0 → devpi_admin-1.4.2}/devpi_admin/static/favicon.svg +0 -0
  17. {devpi_admin-1.4.0 → devpi_admin-1.4.2}/devpi_admin/static/index.html +0 -0
  18. {devpi_admin-1.4.0 → devpi_admin-1.4.2}/devpi_admin/static/js/api.js +0 -0
  19. {devpi_admin-1.4.0 → devpi_admin-1.4.2}/devpi_admin/static/js/marked.min.js +0 -0
  20. {devpi_admin-1.4.0 → devpi_admin-1.4.2}/devpi_admin/static/js/theme.js +0 -0
  21. {devpi_admin-1.4.0 → devpi_admin-1.4.2}/devpi_admin/tokens.py +0 -0
  22. {devpi_admin-1.4.0 → devpi_admin-1.4.2}/devpi_admin.egg-info/SOURCES.txt +0 -0
  23. {devpi_admin-1.4.0 → devpi_admin-1.4.2}/devpi_admin.egg-info/dependency_links.txt +0 -0
  24. {devpi_admin-1.4.0 → devpi_admin-1.4.2}/devpi_admin.egg-info/entry_points.txt +0 -0
  25. {devpi_admin-1.4.0 → devpi_admin-1.4.2}/devpi_admin.egg-info/requires.txt +0 -0
  26. {devpi_admin-1.4.0 → devpi_admin-1.4.2}/devpi_admin.egg-info/top_level.txt +0 -0
  27. {devpi_admin-1.4.0 → devpi_admin-1.4.2}/pyproject.toml +0 -0
  28. {devpi_admin-1.4.0 → devpi_admin-1.4.2}/setup.cfg +0 -0
  29. {devpi_admin-1.4.0 → devpi_admin-1.4.2}/tests/__init__.py +0 -0
  30. {devpi_admin-1.4.0 → devpi_admin-1.4.2}/tests/test_filter.py +0 -0
  31. {devpi_admin-1.4.0 → devpi_admin-1.4.2}/tests/test_hooks.py +0 -0
  32. {devpi_admin-1.4.0 → devpi_admin-1.4.2}/tests/test_json_safe.py +0 -0
  33. {devpi_admin-1.4.0 → devpi_admin-1.4.2}/tests/test_package.py +0 -0
  34. {devpi_admin-1.4.0 → devpi_admin-1.4.2}/tests/test_pipconf.py +0 -0
  35. {devpi_admin-1.4.0 → devpi_admin-1.4.2}/tests/test_tokens.py +0 -0
  36. {devpi_admin-1.4.0 → devpi_admin-1.4.2}/tests/test_tween.py +0 -0
  37. {devpi_admin-1.4.0 → devpi_admin-1.4.2}/tests/test_view_helpers.py +0 -0
  38. {devpi_admin-1.4.0 → devpi_admin-1.4.2}/tests/test_wants_html.py +0 -0
@@ -12,12 +12,12 @@ jobs:
12
12
  id-token: write
13
13
 
14
14
  steps:
15
- - uses: actions/checkout@v4
15
+ - uses: actions/checkout@v5
16
16
  with:
17
17
  fetch-depth: 0 # needed for setuptools-scm to derive version from tag
18
18
 
19
19
  - name: Set up Python
20
- uses: actions/setup-python@v5
20
+ uses: actions/setup-python@v6
21
21
  with:
22
22
  python-version: "3.14"
23
23
 
@@ -14,12 +14,12 @@ jobs:
14
14
  python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"]
15
15
 
16
16
  steps:
17
- - uses: actions/checkout@v4
17
+ - uses: actions/checkout@v5
18
18
  with:
19
19
  fetch-depth: 0 # needed for setuptools-scm to read git tags
20
20
 
21
21
  - name: Set up Python ${{ matrix.python-version }}
22
- uses: actions/setup-python@v5
22
+ uses: actions/setup-python@v6
23
23
  with:
24
24
  python-version: ${{ matrix.python-version }}
25
25
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: devpi-admin
3
- Version: 1.4.0
3
+ Version: 1.4.2
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.2'
22
+ __version_tuple__ = version_tuple = (1, 4, 2)
23
23
 
24
- __commit_id__ = commit_id = 'gc695723cf'
24
+ __commit_id__ = commit_id = 'g1bf2e00e1'
@@ -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
 
@@ -1588,15 +1588,18 @@ body {
1588
1588
  align-items: center;
1589
1589
  justify-content: space-between;
1590
1590
  padding: 2px 0;
1591
+ gap: 4px;
1591
1592
  }
1592
1593
 
1593
1594
  .pkg-version-link {
1594
- flex: 1;
1595
+ flex: 1 1 auto;
1596
+ min-width: 0;
1595
1597
  padding: 5px 8px;
1596
1598
  border-radius: 4px;
1597
1599
  font-size: 13px;
1598
1600
  color: var(--text-muted);
1599
1601
  text-decoration: none;
1602
+ overflow-wrap: anywhere;
1600
1603
  }
1601
1604
 
1602
1605
  .pkg-version-link:hover {
@@ -1619,6 +1622,7 @@ body {
1619
1622
  font-size: 16px;
1620
1623
  line-height: 1;
1621
1624
  border-radius: 4px;
1625
+ flex-shrink: 0;
1622
1626
  }
1623
1627
 
1624
1628
  .pkg-version-del:hover {
@@ -1242,10 +1242,65 @@
1242
1242
  // Token types supported by the unified Issue modal. The selector at the
1243
1243
  // top of the modal switches the form between Devpi tokens (multi-index,
1244
1244
  // permission checkboxes) and Admin tokens (single index, scope select).
1245
- // Devpi is the default unless the plugin isn't installed.
1245
+ // When the caller doesn't pin a type, the modal defaults to whichever
1246
+ // backend the user *most recently* issued against — saves picking the
1247
+ // same radio every time. Falls back to Devpi (or Admin if the plugin
1248
+ // isn't installed) for users with no tokens yet.
1246
1249
  var TOKEN_TYPE_DEVPI = 'devpi';
1247
1250
  var TOKEN_TYPE_ADMIN = 'admin';
1248
1251
 
1252
+ function _detectRecentTokenType(username) {
1253
+ // Returns Promise<TOKEN_TYPE_*|null>. ``null`` means the user has
1254
+ // no tokens (or detection failed) — caller picks a static default.
1255
+ //
1256
+ // Admin tokens carry ``issued_at`` (epoch). Macaroon tokens have
1257
+ // no first-class issuance timestamp; we use ``not_before`` when
1258
+ // present (it tracks the issue moment for the typical "starts
1259
+ // now" flow) and fall back to ``expires`` so a freshly-issued
1260
+ // long-TTL token still wins over an older short-TTL one.
1261
+ var pAdmin = Api.get(
1262
+ '/+admin-api/users/' + encodeURIComponent(username) + '/tokens')
1263
+ .then(function (data) {
1264
+ var tokens = (data && data.result) || [];
1265
+ var max = 0;
1266
+ for (var i = 0; i < tokens.length; i++) {
1267
+ var t = tokens[i].issued_at || 0;
1268
+ if (t > max) max = t;
1269
+ }
1270
+ return max || null;
1271
+ })
1272
+ .catch(function () { return null; });
1273
+
1274
+ var pDevpi = hasDevpiTokens()
1275
+ ? Api.get('/' + encodeURIComponent(username) + '/+tokens')
1276
+ .then(function (data) {
1277
+ var tokens = (data && data.result
1278
+ && data.result.tokens) || {};
1279
+ var max = 0;
1280
+ for (var id in tokens) {
1281
+ if (!Object.prototype.hasOwnProperty.call(
1282
+ tokens, id)) continue;
1283
+ var parsed = parseMacaroonRestrictions(
1284
+ tokens[id].restrictions);
1285
+ var t = parsed.not_before
1286
+ || parsed.expires || 0;
1287
+ if (t > max) max = t;
1288
+ }
1289
+ return max || null;
1290
+ })
1291
+ .catch(function () { return null; })
1292
+ : Promise.resolve(null);
1293
+
1294
+ return Promise.all([pAdmin, pDevpi]).then(function (results) {
1295
+ var adminTs = results[0];
1296
+ var devpiTs = results[1];
1297
+ if (!adminTs && !devpiTs) return null;
1298
+ if (!adminTs) return TOKEN_TYPE_DEVPI;
1299
+ if (!devpiTs) return TOKEN_TYPE_ADMIN;
1300
+ return adminTs >= devpiTs ? TOKEN_TYPE_ADMIN : TOKEN_TYPE_DEVPI;
1301
+ });
1302
+ }
1303
+
1249
1304
  // Public entry point. `options` may include:
1250
1305
  // • `preselectType` — 'devpi' or 'admin'
1251
1306
  // • `preselectIndexes` — list of 'user/index' strings
@@ -1259,18 +1314,25 @@
1259
1314
  function showIssueTokenModal(username, options) {
1260
1315
  options = options || {};
1261
1316
  var presel = (options.preselectIndexes || []).slice();
1262
- var preselType = options.preselectType || TOKEN_TYPE_DEVPI;
1317
+ var explicitType = options.preselectType || null;
1263
1318
  _issueReturnTo = options.returnTo || function () {
1264
1319
  showTokensModal(username);
1265
1320
  };
1266
1321
  _issueLockedIndex = (options.lockIndex && presel.length)
1267
1322
  ? presel[0] : null;
1268
- // If the user asked for Devpi but the plugin isn't installed, silently
1269
- // fall back to Admin saves them a trip back through the kebab.
1270
- if (preselType === TOKEN_TYPE_DEVPI && !hasDevpiTokens()) {
1271
- preselType = TOKEN_TYPE_ADMIN;
1272
- }
1273
- fetchRoot().then(function (rootResult) {
1323
+ // Caller pinned the type honour it; otherwise detect from the
1324
+ // user's existing tokens (most-recently-issued backend wins).
1325
+ // Detection failure falls back to Devpi (or Admin if the plugin
1326
+ // isn't installed).
1327
+ var pType = explicitType
1328
+ ? Promise.resolve(explicitType)
1329
+ : _detectRecentTokenType(username);
1330
+ Promise.all([fetchRoot(), pType]).then(function (results) {
1331
+ var rootResult = results[0];
1332
+ var preselType = results[1] || TOKEN_TYPE_DEVPI;
1333
+ if (preselType === TOKEN_TYPE_DEVPI && !hasDevpiTokens()) {
1334
+ preselType = TOKEN_TYPE_ADMIN;
1335
+ }
1274
1336
  var indexInfos = getAllIndexes(rootResult);
1275
1337
  var aclByIndex = {};
1276
1338
  // Indexes accessible to the bound user: ones they own, ones
@@ -1304,11 +1366,13 @@
1304
1366
  };
1305
1367
  _renderIssueTokenModal(username, presel, preselType);
1306
1368
  }).catch(function () {
1369
+ var fallbackType = explicitType
1370
+ || (hasDevpiTokens() ? TOKEN_TYPE_DEVPI : TOKEN_TYPE_ADMIN);
1307
1371
  _issueContext = {
1308
1372
  accessibleIndexes: presel.slice(),
1309
1373
  aclByIndex: {},
1310
1374
  };
1311
- _renderIssueTokenModal(username, presel, preselType);
1375
+ _renderIssueTokenModal(username, presel, fallbackType);
1312
1376
  });
1313
1377
  }
1314
1378
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: devpi-admin
3
- Version: 1.4.0
3
+ Version: 1.4.2
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):
@@ -277,6 +277,23 @@ class UnifiedIssueModalTests(unittest.TestCase):
277
277
  def test_unified_entry_point_exists(self):
278
278
  self.assertIn("function showIssueTokenModal(username, options)", self.js)
279
279
 
280
+ def test_recent_token_type_detector_present(self):
281
+ # Auto-preselect: when caller doesn't pin a type, pick the
282
+ # backend the user most recently issued against.
283
+ self.assertIn("function _detectRecentTokenType(username)", self.js)
284
+ # Admin tokens compared by issued_at, macaroons by
285
+ # not_before / expires.
286
+ self.assertIn("tokens[i].issued_at", self.js)
287
+ self.assertIn("parsed.not_before", self.js)
288
+
289
+ def test_issue_modal_uses_detector_when_caller_is_silent(self):
290
+ # `options.preselectType` short-circuits detection; absence
291
+ # triggers _detectRecentTokenType.
292
+ self.assertIn("var explicitType = options.preselectType || null;",
293
+ self.js)
294
+ self.assertIn("explicitType\n ? Promise.resolve(explicitType)\n"
295
+ " : _detectRecentTokenType(username);", self.js)
296
+
280
297
  def test_unified_listing_has_issue_button(self):
281
298
  # Single Issue entry point on the unified per-user Tokens modal —
282
299
  # no preselectType so the user picks devpi/admin in the form.
File without changes
File without changes
File without changes
File without changes