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.
- {devpi_admin-1.4.0 → devpi_admin-1.4.2}/.github/workflows/publish.yml +2 -2
- {devpi_admin-1.4.0 → devpi_admin-1.4.2}/.github/workflows/tests.yml +2 -2
- {devpi_admin-1.4.0 → devpi_admin-1.4.2}/PKG-INFO +7 -1
- {devpi_admin-1.4.0 → devpi_admin-1.4.2}/README.md +6 -0
- {devpi_admin-1.4.0 → devpi_admin-1.4.2}/devpi_admin/_version.py +3 -3
- {devpi_admin-1.4.0 → devpi_admin-1.4.2}/devpi_admin/main.py +43 -2
- {devpi_admin-1.4.0 → devpi_admin-1.4.2}/devpi_admin/static/css/style.css +5 -1
- {devpi_admin-1.4.0 → devpi_admin-1.4.2}/devpi_admin/static/js/app.js +73 -9
- {devpi_admin-1.4.0 → devpi_admin-1.4.2}/devpi_admin.egg-info/PKG-INFO +7 -1
- {devpi_admin-1.4.0 → devpi_admin-1.4.2}/tests/test_acl_read.py +127 -15
- {devpi_admin-1.4.0 → devpi_admin-1.4.2}/tests/test_devpi_tokens_ui.py +17 -0
- {devpi_admin-1.4.0 → devpi_admin-1.4.2}/.gitignore +0 -0
- {devpi_admin-1.4.0 → devpi_admin-1.4.2}/LICENSE +0 -0
- {devpi_admin-1.4.0 → devpi_admin-1.4.2}/devpi_admin/__init__.py +0 -0
- {devpi_admin-1.4.0 → devpi_admin-1.4.2}/devpi_admin/customizer.py +0 -0
- {devpi_admin-1.4.0 → devpi_admin-1.4.2}/devpi_admin/static/favicon.svg +0 -0
- {devpi_admin-1.4.0 → devpi_admin-1.4.2}/devpi_admin/static/index.html +0 -0
- {devpi_admin-1.4.0 → devpi_admin-1.4.2}/devpi_admin/static/js/api.js +0 -0
- {devpi_admin-1.4.0 → devpi_admin-1.4.2}/devpi_admin/static/js/marked.min.js +0 -0
- {devpi_admin-1.4.0 → devpi_admin-1.4.2}/devpi_admin/static/js/theme.js +0 -0
- {devpi_admin-1.4.0 → devpi_admin-1.4.2}/devpi_admin/tokens.py +0 -0
- {devpi_admin-1.4.0 → devpi_admin-1.4.2}/devpi_admin.egg-info/SOURCES.txt +0 -0
- {devpi_admin-1.4.0 → devpi_admin-1.4.2}/devpi_admin.egg-info/dependency_links.txt +0 -0
- {devpi_admin-1.4.0 → devpi_admin-1.4.2}/devpi_admin.egg-info/entry_points.txt +0 -0
- {devpi_admin-1.4.0 → devpi_admin-1.4.2}/devpi_admin.egg-info/requires.txt +0 -0
- {devpi_admin-1.4.0 → devpi_admin-1.4.2}/devpi_admin.egg-info/top_level.txt +0 -0
- {devpi_admin-1.4.0 → devpi_admin-1.4.2}/pyproject.toml +0 -0
- {devpi_admin-1.4.0 → devpi_admin-1.4.2}/setup.cfg +0 -0
- {devpi_admin-1.4.0 → devpi_admin-1.4.2}/tests/__init__.py +0 -0
- {devpi_admin-1.4.0 → devpi_admin-1.4.2}/tests/test_filter.py +0 -0
- {devpi_admin-1.4.0 → devpi_admin-1.4.2}/tests/test_hooks.py +0 -0
- {devpi_admin-1.4.0 → devpi_admin-1.4.2}/tests/test_json_safe.py +0 -0
- {devpi_admin-1.4.0 → devpi_admin-1.4.2}/tests/test_package.py +0 -0
- {devpi_admin-1.4.0 → devpi_admin-1.4.2}/tests/test_pipconf.py +0 -0
- {devpi_admin-1.4.0 → devpi_admin-1.4.2}/tests/test_tokens.py +0 -0
- {devpi_admin-1.4.0 → devpi_admin-1.4.2}/tests/test_tween.py +0 -0
- {devpi_admin-1.4.0 → devpi_admin-1.4.2}/tests/test_view_helpers.py +0 -0
- {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@
|
|
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@
|
|
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@
|
|
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@
|
|
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.
|
|
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.
|
|
22
|
-
__version_tuple__ = version_tuple = (1, 4,
|
|
21
|
+
__version__ = version = '1.4.2'
|
|
22
|
+
__version_tuple__ = version_tuple = (1, 4, 2)
|
|
23
23
|
|
|
24
|
-
__commit_id__ = commit_id = '
|
|
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
|
-
//
|
|
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
|
|
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
|
-
//
|
|
1269
|
-
//
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
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,
|
|
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.
|
|
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(
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|