devpi-admin 1.3.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.3.0 → devpi_admin-1.4.1}/PKG-INFO +103 -33
  2. {devpi_admin-1.3.0 → devpi_admin-1.4.1}/README.md +102 -32
  3. {devpi_admin-1.3.0 → devpi_admin-1.4.1}/devpi_admin/_version.py +3 -3
  4. {devpi_admin-1.3.0 → devpi_admin-1.4.1}/devpi_admin/main.py +48 -3
  5. {devpi_admin-1.3.0 → devpi_admin-1.4.1}/devpi_admin/static/css/style.css +285 -31
  6. {devpi_admin-1.3.0 → devpi_admin-1.4.1}/devpi_admin/static/js/app.js +1706 -794
  7. {devpi_admin-1.3.0 → devpi_admin-1.4.1}/devpi_admin.egg-info/PKG-INFO +103 -33
  8. {devpi_admin-1.3.0 → devpi_admin-1.4.1}/devpi_admin.egg-info/SOURCES.txt +1 -0
  9. {devpi_admin-1.3.0 → devpi_admin-1.4.1}/tests/test_acl_read.py +127 -15
  10. devpi_admin-1.4.1/tests/test_devpi_tokens_ui.py +527 -0
  11. {devpi_admin-1.3.0 → devpi_admin-1.4.1}/.github/workflows/publish.yml +0 -0
  12. {devpi_admin-1.3.0 → devpi_admin-1.4.1}/.github/workflows/tests.yml +0 -0
  13. {devpi_admin-1.3.0 → devpi_admin-1.4.1}/.gitignore +0 -0
  14. {devpi_admin-1.3.0 → devpi_admin-1.4.1}/LICENSE +0 -0
  15. {devpi_admin-1.3.0 → devpi_admin-1.4.1}/devpi_admin/__init__.py +0 -0
  16. {devpi_admin-1.3.0 → devpi_admin-1.4.1}/devpi_admin/customizer.py +0 -0
  17. {devpi_admin-1.3.0 → devpi_admin-1.4.1}/devpi_admin/static/favicon.svg +0 -0
  18. {devpi_admin-1.3.0 → devpi_admin-1.4.1}/devpi_admin/static/index.html +0 -0
  19. {devpi_admin-1.3.0 → devpi_admin-1.4.1}/devpi_admin/static/js/api.js +0 -0
  20. {devpi_admin-1.3.0 → devpi_admin-1.4.1}/devpi_admin/static/js/marked.min.js +0 -0
  21. {devpi_admin-1.3.0 → devpi_admin-1.4.1}/devpi_admin/static/js/theme.js +0 -0
  22. {devpi_admin-1.3.0 → devpi_admin-1.4.1}/devpi_admin/tokens.py +0 -0
  23. {devpi_admin-1.3.0 → devpi_admin-1.4.1}/devpi_admin.egg-info/dependency_links.txt +0 -0
  24. {devpi_admin-1.3.0 → devpi_admin-1.4.1}/devpi_admin.egg-info/entry_points.txt +0 -0
  25. {devpi_admin-1.3.0 → devpi_admin-1.4.1}/devpi_admin.egg-info/requires.txt +0 -0
  26. {devpi_admin-1.3.0 → devpi_admin-1.4.1}/devpi_admin.egg-info/top_level.txt +0 -0
  27. {devpi_admin-1.3.0 → devpi_admin-1.4.1}/pyproject.toml +0 -0
  28. {devpi_admin-1.3.0 → devpi_admin-1.4.1}/setup.cfg +0 -0
  29. {devpi_admin-1.3.0 → devpi_admin-1.4.1}/tests/__init__.py +0 -0
  30. {devpi_admin-1.3.0 → devpi_admin-1.4.1}/tests/test_filter.py +0 -0
  31. {devpi_admin-1.3.0 → devpi_admin-1.4.1}/tests/test_hooks.py +0 -0
  32. {devpi_admin-1.3.0 → devpi_admin-1.4.1}/tests/test_json_safe.py +0 -0
  33. {devpi_admin-1.3.0 → devpi_admin-1.4.1}/tests/test_package.py +0 -0
  34. {devpi_admin-1.3.0 → devpi_admin-1.4.1}/tests/test_pipconf.py +0 -0
  35. {devpi_admin-1.3.0 → devpi_admin-1.4.1}/tests/test_tokens.py +0 -0
  36. {devpi_admin-1.3.0 → devpi_admin-1.4.1}/tests/test_tween.py +0 -0
  37. {devpi_admin-1.3.0 → devpi_admin-1.4.1}/tests/test_view_helpers.py +0 -0
  38. {devpi_admin-1.3.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.3.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
@@ -38,16 +38,16 @@ talks to the standard devpi JSON API directly.
38
38
  - Server info with version of devpi-server and all installed plugins (auto-detected)
39
39
  - Cache metrics with hit-rate bars (storage, changelog, relpath caches)
40
40
  - Whoosh search index queue status
41
- - **Replica status** (master only, authenticated users only) - per-replica cards with
42
- authoritative `applied_serial` vs. master serial. Three states:
43
- - **in sync** - replica matches master serial
41
+ - **Replica status** (primary only, authenticated users only) - per-replica cards with
42
+ authoritative `applied_serial` vs. primary serial. Three states:
43
+ - **in sync** - replica matches primary serial
44
44
  - **lagging** - replica is behind but advancing
45
45
  - **stuck** - replica has been polling the same serial for >=30 s; usually means a
46
46
  server-side plugin (`devpi-admin`, `devpi-web`, ...) is missing or out of date on the replica
47
47
  - **Topbar health indicator** - the `devpi admin` logo is coloured green / orange / red
48
48
  on every page, refreshed every 30 s in the background:
49
49
  - server reachable, all replicas in sync
50
- - at least one replica lagging (visible to authenticated master operators)
50
+ - at least one replica lagging (visible to authenticated primary operators)
51
51
  - server not responding
52
52
 
53
53
  ### Indexes
@@ -55,19 +55,13 @@ talks to the standard devpi JSON API directly.
55
55
  - Warning tags for ACL edge cases:
56
56
  - **`world-writable`** - `acl_upload` contains `:ANONYMOUS:`; supply-chain risk
57
57
  - **`no upload`** - `acl_upload` is empty; nobody (not even owner / root) can publish
58
- - Adaptive kebab menu - items hint whether a token will be issued:
59
- - **`pip.conf`** (public read index, no auth needed) vs. **`pip.conf + token`** (private read)
60
- - **`.pypirc`** (public upload, no auth needed) vs. **`.pypirc + token`** (private upload).
61
- Hidden when nobody can upload (`acl_upload` empty).
62
- - **`pip.conf` modal** - issues a short-lived `read`-scope token bound to the index. Returns
63
- the full `pip.conf` (Copy / Download), a one-off `pip install --index-url ...` command, and
64
- the raw `user:token` pair for `curl`, `devpi login`, etc. Anonymous-readable indexes show
65
- a static pip.conf without credentials.
66
- - **`.pypirc` modal** - issues an `upload`-scope token bound to the index. Returns the full
67
- `.pypirc` (Copy / Download), `TWINE_*` environment variable block, a one-shot
68
- `twine upload --repository-url ... -u ... -p ... dist/*` command, and the raw `user:token`
69
- pair. Anonymous-upload indexes (rare; world-writable) show a static `.pypirc` without
70
- credentials and a security warning.
58
+ - Index card kebab:
59
+ - **`pip.conf`** (public indexes only) static one-click pip.conf with the index URL,
60
+ no token issuance needed
61
+ - **`Tokens`** (owner / root only) — opens the per-index unified Tokens modal with two
62
+ sections (Admin + Devpi), shows existing tokens for this index, lets you issue new
63
+ ones with the index pre-filled and locked
64
+ - **`Edit`** / **`Delete`** (owner / root)
71
65
  - Create / edit / delete indexes via modal dialogs
72
66
  - `bases` editor with drag & drop priority ordering and transitive inheritance display
73
67
  - `acl_upload` and `acl_read` tag pickers with user selection dropdown
@@ -128,12 +122,19 @@ talks to the standard devpi JSON API directly.
128
122
  `DELETE` is **never** granted, even with `upload` scope - package removal must use
129
123
  password auth. Anything outside the bound index path returns 403, including the SPA,
130
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.
131
131
  - **Issuance rules**: regular users may issue for themselves; root may issue for *other*
132
132
  users (admin delegation) but not for itself. Admin-token-authenticated requests cannot
133
133
  issue further tokens. Issuance verifies the target user is in `acl_read` /
134
134
  `acl_upload` of the target index.
135
- - **Management rules**: list / revoke is allowed for the token owner or root. Per-index
136
- token list endpoint shows only the caller's own tokens (root sees all).
135
+ - **Management rules**: list / revoke is allowed for the token owner or root. The
136
+ per-index token list shows all tokens for **index owner / root**; other callers see
137
+ only tokens bound to themselves.
137
138
  - **Auto-cleanup**:
138
139
  - User delete -> all tokens for that user removed from keyfs
139
140
  - Index delete -> all tokens bound to that index removed (USER subscriber diffs the
@@ -147,9 +148,33 @@ talks to the standard devpi JSON API directly.
147
148
 
148
149
  ### Users
149
150
  - Create, edit (email, password), delete users (admin only)
150
- - **Tokens manager** (kebab -> Tokens) - per-user list with label, **index, scope**,
151
- expiry, issuer, IP; revoke individual or "Reset all". Wide modal layout so the table
152
- doesn't overflow on stage indexes with long names.
151
+ - **Tokens manager** (kebab -> Tokens) - unified modal with one or two sections:
152
+ - **Admin tokens** (built-in) per-user list with label, index, scope, expiry,
153
+ issuer, IP; individual revoke or "Reset all"
154
+ - **Devpi tokens** (only when the `devpi-tokens` plugin is installed) — list of
155
+ macaroon tokens with parsed restrictions (indexes, allowed permissions, projects,
156
+ expires, not-before); individual revoke
157
+ - Empty sections hide automatically (no clutter). Banner above Devpi section
158
+ (dismissible per user) explains the different threat model — raw secret in keyfs
159
+ vs. hash-only Admin storage.
160
+ - **Issue token** (`+ Issue new` button) — single unified modal for both backends:
161
+ - Token type selector at the top picks Admin vs. Devpi (Devpi default when the
162
+ plugin is installed). Hidden when only Admin is available.
163
+ - Index picker shows everything the bound user can access (owns, or appears in
164
+ `acl_read` / `acl_upload`). Devpi uses a multi tag picker; Admin uses a single
165
+ select.
166
+ - Admin scope dropdown adapts to the picked index: public indexes get only
167
+ `upload` (read tokens are useless when anyone can read), private indexes get
168
+ both with `read` as default.
169
+ - Devpi permissions are checkboxes; destructive operations (`del_*`,
170
+ `index_modify`, `index_delete`) are tucked behind an "Advanced" toggle with a
171
+ visual warning.
172
+ - Expiry: presets (1 hour to 1 year) plus a `Custom…` datetime option. Optional
173
+ Not-before for delayed activation (Devpi only).
174
+ - On success the modal swaps to a read-once view with the raw token, pip.conf,
175
+ `.pypirc`, `TWINE_*` env, and a `user:token` pair — but only the configs that
176
+ actually match the issued token's intent (no pip.conf for upload-only or
177
+ public-index tokens; no `.pypirc` for read-only tokens).
153
178
 
154
179
  ### Packages
155
180
  - Client-side search with PEP 503 name normalization and relevance ranking
@@ -210,10 +235,10 @@ for HTML requests while `devpi-web` would still serve its own HTML on other rout
210
235
  ### Replicas: install on every node
211
236
 
212
237
  `devpi-admin` registers custom keyfs keys (`+admin/tokens/...`,
213
- `+admin/user-tokens/...`, `+admin/index-tokens/...`). Master writes to these on every
214
- token issue / revoke. **Replicas without `devpi-admin` installed cannot apply those
215
- changelog entries** - `import_changes` fails with `AssertionError` on the missing
216
- keyfs key, the replica rolls back to the prior serial, and replication stalls.
238
+ `+admin/user-tokens/...`, `+admin/index-tokens/...`). The primary writes to these on
239
+ every token issue / revoke. **Replicas without `devpi-admin` installed cannot apply
240
+ those changelog entries** - `import_changes` fails with `AssertionError` on the
241
+ missing keyfs key, the replica rolls back to the prior serial, and replication stalls.
217
242
 
218
243
  The dashboard's stuck-replica detection is designed exactly for this: a `stuck`
219
244
  state on a replica card almost always means a plugin (typically `devpi-admin` itself,
@@ -222,14 +247,14 @@ is straightforward:
222
247
 
223
248
  ```bash
224
249
  # on the replica
225
- ~/.venv/bin/pip install --upgrade devpi-admin # match master version
250
+ ~/.venv/bin/pip install --upgrade devpi-admin # match primary version
226
251
  systemctl restart devpi
227
252
  ```
228
253
 
229
254
  Replication resumes from the failed serial automatically - no manual keyfs surgery.
230
255
 
231
- **Upgrade order:** replicas first, then master. If you upgrade master first and that
232
- release introduces a new keyfs key, replicas would crash on the very next poll.
256
+ **Upgrade order:** replicas first, then primary. If you upgrade the primary first and
257
+ that release introduces a new keyfs key, replicas would crash on the very next poll.
233
258
 
234
259
  See `INSTALL.md` section 11 for full step-by-step replica setup and dashboard interpretation.
235
260
 
@@ -255,6 +280,51 @@ ExecStart=/opt/pypi/venv/bin/devpi-server \
255
280
 
256
281
  See `INSTALL.md` for a full systemd unit example.
257
282
 
283
+ ### Optional plugins
284
+
285
+ #### `devpi-tokens` coexistence
286
+
287
+ `devpi-admin` plays nicely with the optional `devpi-tokens` plugin. When
288
+ installed, the SPA detects it (via `/+api` features list and `/+status`
289
+ versioninfo) and **automatically merges Devpi tokens into the same Tokens
290
+ modal** that lists Admin tokens — same kebab item ("Tokens"), same
291
+ "+ Issue new" flow, same per-index Tokens modal. The user picks the
292
+ backend in the Issue form via a token-type selector.
293
+
294
+ ```bash
295
+ /var/lib/pypi/venv/bin/pip install devpi-tokens
296
+ systemctl --user restart devpi
297
+ ```
298
+
299
+ The two token systems run side by side without conflict:
300
+
301
+ | | Admin tokens | Devpi tokens |
302
+ |---|---|---|
303
+ | Plugin | `devpi-admin` (built-in) | `devpi-tokens` (optional) |
304
+ | Storage | SHA-256 hash in keyfs | Raw HMAC key in keyfs |
305
+ | Listable (incl. derived) | yes, all | initial only — derived macaroons are stateless |
306
+ | Audit log on lookup | yes | no |
307
+ | HTTP method whitelist | `read` blocks DELETE; `upload` blocks DELETE | relies on `--allowed` permission filter |
308
+ | Multi-index per token | no (1:1) | yes |
309
+ | Per-project filter | no | yes (`--projects`) |
310
+ | Cross-user index | no (must be in ACL) | yes (any user/index pair) |
311
+ | CLI compatibility | UI / API only | works with `devpi token-login` |
312
+
313
+ **Threat model note.** Macaroon HMAC verification requires the secret in
314
+ plaintext on the server, so `devpi-tokens` cannot hash-store; a leaked
315
+ backup or replica disk dump exposes working credentials. Prefer Admin
316
+ tokens for privileged workflows. The UI surfaces this via a (dismissible
317
+ per-user) security banner above the Devpi section of every Tokens modal.
318
+
319
+ `acl_read` (provided by `devpi-admin`) applies to both token systems
320
+ identically — devpi evaluates `pkg_read` ACL against whichever identity
321
+ the auth chain produced, regardless of token source.
322
+
323
+ **Testing without the plugin installed.** Append `?no-devpi-tokens` to any
324
+ SPA URL to make the UI behave as if the plugin weren't there (kebab item
325
+ disappears, type selector hides, etc.). Saves you a `pip uninstall + restart`
326
+ round-trip when verifying graceful degradation.
327
+
258
328
  ## Usage
259
329
 
260
330
  After restart, open:
@@ -527,7 +597,7 @@ Revoke a single token.
527
597
  - **200:** `{"revoked": true, "id": "abc..."}`
528
598
  - **404:** token id not found
529
599
 
530
- ### Replication observability (master only)
600
+ ### Replication observability (primary only)
531
601
 
532
602
  #### `GET /+admin-api/replicas`
533
603
  Last-known poll info per replica, captured from each `GET /+changelog/{N}-` request via
@@ -538,7 +608,7 @@ applied (`start_serial - 1` from its most recent poll). Compare against `/+statu
538
608
  Why this isn't `polling_replicas` from `/+status`: devpi-server overwrites
539
609
  `xom.polling_replicas[uuid].serial` during streaming and gives a misleading "caught up"
540
610
  reading once the response generator drains. Capturing `start_serial` at the request
541
- boundary is the only stable signal master alone can produce.
611
+ boundary is the only stable signal the primary alone can produce.
542
612
 
543
613
  - **Auth:** required
544
614
  - **200:**
@@ -558,7 +628,7 @@ boundary is the only stable signal master alone can produce.
558
628
  }
559
629
  ```
560
630
  - Entries auto-expire after 10 min of silence. Dict size capped at 256 entries
561
- (least-recently-seen evicted first) so an attacker spamming UUIDs cannot exhaust master memory.
631
+ (least-recently-seen evicted first) so an attacker spamming UUIDs cannot exhaust primary memory.
562
632
 
563
633
  ## Project layout
564
634
 
@@ -14,16 +14,16 @@ talks to the standard devpi JSON API directly.
14
14
  - Server info with version of devpi-server and all installed plugins (auto-detected)
15
15
  - Cache metrics with hit-rate bars (storage, changelog, relpath caches)
16
16
  - Whoosh search index queue status
17
- - **Replica status** (master only, authenticated users only) - per-replica cards with
18
- authoritative `applied_serial` vs. master serial. Three states:
19
- - **in sync** - replica matches master serial
17
+ - **Replica status** (primary only, authenticated users only) - per-replica cards with
18
+ authoritative `applied_serial` vs. primary serial. Three states:
19
+ - **in sync** - replica matches primary serial
20
20
  - **lagging** - replica is behind but advancing
21
21
  - **stuck** - replica has been polling the same serial for >=30 s; usually means a
22
22
  server-side plugin (`devpi-admin`, `devpi-web`, ...) is missing or out of date on the replica
23
23
  - **Topbar health indicator** - the `devpi admin` logo is coloured green / orange / red
24
24
  on every page, refreshed every 30 s in the background:
25
25
  - server reachable, all replicas in sync
26
- - at least one replica lagging (visible to authenticated master operators)
26
+ - at least one replica lagging (visible to authenticated primary operators)
27
27
  - server not responding
28
28
 
29
29
  ### Indexes
@@ -31,19 +31,13 @@ talks to the standard devpi JSON API directly.
31
31
  - Warning tags for ACL edge cases:
32
32
  - **`world-writable`** - `acl_upload` contains `:ANONYMOUS:`; supply-chain risk
33
33
  - **`no upload`** - `acl_upload` is empty; nobody (not even owner / root) can publish
34
- - Adaptive kebab menu - items hint whether a token will be issued:
35
- - **`pip.conf`** (public read index, no auth needed) vs. **`pip.conf + token`** (private read)
36
- - **`.pypirc`** (public upload, no auth needed) vs. **`.pypirc + token`** (private upload).
37
- Hidden when nobody can upload (`acl_upload` empty).
38
- - **`pip.conf` modal** - issues a short-lived `read`-scope token bound to the index. Returns
39
- the full `pip.conf` (Copy / Download), a one-off `pip install --index-url ...` command, and
40
- the raw `user:token` pair for `curl`, `devpi login`, etc. Anonymous-readable indexes show
41
- a static pip.conf without credentials.
42
- - **`.pypirc` modal** - issues an `upload`-scope token bound to the index. Returns the full
43
- `.pypirc` (Copy / Download), `TWINE_*` environment variable block, a one-shot
44
- `twine upload --repository-url ... -u ... -p ... dist/*` command, and the raw `user:token`
45
- pair. Anonymous-upload indexes (rare; world-writable) show a static `.pypirc` without
46
- credentials and a security warning.
34
+ - Index card kebab:
35
+ - **`pip.conf`** (public indexes only) static one-click pip.conf with the index URL,
36
+ no token issuance needed
37
+ - **`Tokens`** (owner / root only) — opens the per-index unified Tokens modal with two
38
+ sections (Admin + Devpi), shows existing tokens for this index, lets you issue new
39
+ ones with the index pre-filled and locked
40
+ - **`Edit`** / **`Delete`** (owner / root)
47
41
  - Create / edit / delete indexes via modal dialogs
48
42
  - `bases` editor with drag & drop priority ordering and transitive inheritance display
49
43
  - `acl_upload` and `acl_read` tag pickers with user selection dropdown
@@ -104,12 +98,19 @@ talks to the standard devpi JSON API directly.
104
98
  `DELETE` is **never** granted, even with `upload` scope - package removal must use
105
99
  password auth. Anything outside the bound index path returns 403, including the SPA,
106
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.
107
107
  - **Issuance rules**: regular users may issue for themselves; root may issue for *other*
108
108
  users (admin delegation) but not for itself. Admin-token-authenticated requests cannot
109
109
  issue further tokens. Issuance verifies the target user is in `acl_read` /
110
110
  `acl_upload` of the target index.
111
- - **Management rules**: list / revoke is allowed for the token owner or root. Per-index
112
- token list endpoint shows only the caller's own tokens (root sees all).
111
+ - **Management rules**: list / revoke is allowed for the token owner or root. The
112
+ per-index token list shows all tokens for **index owner / root**; other callers see
113
+ only tokens bound to themselves.
113
114
  - **Auto-cleanup**:
114
115
  - User delete -> all tokens for that user removed from keyfs
115
116
  - Index delete -> all tokens bound to that index removed (USER subscriber diffs the
@@ -123,9 +124,33 @@ talks to the standard devpi JSON API directly.
123
124
 
124
125
  ### Users
125
126
  - Create, edit (email, password), delete users (admin only)
126
- - **Tokens manager** (kebab -> Tokens) - per-user list with label, **index, scope**,
127
- expiry, issuer, IP; revoke individual or "Reset all". Wide modal layout so the table
128
- doesn't overflow on stage indexes with long names.
127
+ - **Tokens manager** (kebab -> Tokens) - unified modal with one or two sections:
128
+ - **Admin tokens** (built-in) per-user list with label, index, scope, expiry,
129
+ issuer, IP; individual revoke or "Reset all"
130
+ - **Devpi tokens** (only when the `devpi-tokens` plugin is installed) — list of
131
+ macaroon tokens with parsed restrictions (indexes, allowed permissions, projects,
132
+ expires, not-before); individual revoke
133
+ - Empty sections hide automatically (no clutter). Banner above Devpi section
134
+ (dismissible per user) explains the different threat model — raw secret in keyfs
135
+ vs. hash-only Admin storage.
136
+ - **Issue token** (`+ Issue new` button) — single unified modal for both backends:
137
+ - Token type selector at the top picks Admin vs. Devpi (Devpi default when the
138
+ plugin is installed). Hidden when only Admin is available.
139
+ - Index picker shows everything the bound user can access (owns, or appears in
140
+ `acl_read` / `acl_upload`). Devpi uses a multi tag picker; Admin uses a single
141
+ select.
142
+ - Admin scope dropdown adapts to the picked index: public indexes get only
143
+ `upload` (read tokens are useless when anyone can read), private indexes get
144
+ both with `read` as default.
145
+ - Devpi permissions are checkboxes; destructive operations (`del_*`,
146
+ `index_modify`, `index_delete`) are tucked behind an "Advanced" toggle with a
147
+ visual warning.
148
+ - Expiry: presets (1 hour to 1 year) plus a `Custom…` datetime option. Optional
149
+ Not-before for delayed activation (Devpi only).
150
+ - On success the modal swaps to a read-once view with the raw token, pip.conf,
151
+ `.pypirc`, `TWINE_*` env, and a `user:token` pair — but only the configs that
152
+ actually match the issued token's intent (no pip.conf for upload-only or
153
+ public-index tokens; no `.pypirc` for read-only tokens).
129
154
 
130
155
  ### Packages
131
156
  - Client-side search with PEP 503 name normalization and relevance ranking
@@ -186,10 +211,10 @@ for HTML requests while `devpi-web` would still serve its own HTML on other rout
186
211
  ### Replicas: install on every node
187
212
 
188
213
  `devpi-admin` registers custom keyfs keys (`+admin/tokens/...`,
189
- `+admin/user-tokens/...`, `+admin/index-tokens/...`). Master writes to these on every
190
- token issue / revoke. **Replicas without `devpi-admin` installed cannot apply those
191
- changelog entries** - `import_changes` fails with `AssertionError` on the missing
192
- keyfs key, the replica rolls back to the prior serial, and replication stalls.
214
+ `+admin/user-tokens/...`, `+admin/index-tokens/...`). The primary writes to these on
215
+ every token issue / revoke. **Replicas without `devpi-admin` installed cannot apply
216
+ those changelog entries** - `import_changes` fails with `AssertionError` on the
217
+ missing keyfs key, the replica rolls back to the prior serial, and replication stalls.
193
218
 
194
219
  The dashboard's stuck-replica detection is designed exactly for this: a `stuck`
195
220
  state on a replica card almost always means a plugin (typically `devpi-admin` itself,
@@ -198,14 +223,14 @@ is straightforward:
198
223
 
199
224
  ```bash
200
225
  # on the replica
201
- ~/.venv/bin/pip install --upgrade devpi-admin # match master version
226
+ ~/.venv/bin/pip install --upgrade devpi-admin # match primary version
202
227
  systemctl restart devpi
203
228
  ```
204
229
 
205
230
  Replication resumes from the failed serial automatically - no manual keyfs surgery.
206
231
 
207
- **Upgrade order:** replicas first, then master. If you upgrade master first and that
208
- release introduces a new keyfs key, replicas would crash on the very next poll.
232
+ **Upgrade order:** replicas first, then primary. If you upgrade the primary first and
233
+ that release introduces a new keyfs key, replicas would crash on the very next poll.
209
234
 
210
235
  See `INSTALL.md` section 11 for full step-by-step replica setup and dashboard interpretation.
211
236
 
@@ -231,6 +256,51 @@ ExecStart=/opt/pypi/venv/bin/devpi-server \
231
256
 
232
257
  See `INSTALL.md` for a full systemd unit example.
233
258
 
259
+ ### Optional plugins
260
+
261
+ #### `devpi-tokens` coexistence
262
+
263
+ `devpi-admin` plays nicely with the optional `devpi-tokens` plugin. When
264
+ installed, the SPA detects it (via `/+api` features list and `/+status`
265
+ versioninfo) and **automatically merges Devpi tokens into the same Tokens
266
+ modal** that lists Admin tokens — same kebab item ("Tokens"), same
267
+ "+ Issue new" flow, same per-index Tokens modal. The user picks the
268
+ backend in the Issue form via a token-type selector.
269
+
270
+ ```bash
271
+ /var/lib/pypi/venv/bin/pip install devpi-tokens
272
+ systemctl --user restart devpi
273
+ ```
274
+
275
+ The two token systems run side by side without conflict:
276
+
277
+ | | Admin tokens | Devpi tokens |
278
+ |---|---|---|
279
+ | Plugin | `devpi-admin` (built-in) | `devpi-tokens` (optional) |
280
+ | Storage | SHA-256 hash in keyfs | Raw HMAC key in keyfs |
281
+ | Listable (incl. derived) | yes, all | initial only — derived macaroons are stateless |
282
+ | Audit log on lookup | yes | no |
283
+ | HTTP method whitelist | `read` blocks DELETE; `upload` blocks DELETE | relies on `--allowed` permission filter |
284
+ | Multi-index per token | no (1:1) | yes |
285
+ | Per-project filter | no | yes (`--projects`) |
286
+ | Cross-user index | no (must be in ACL) | yes (any user/index pair) |
287
+ | CLI compatibility | UI / API only | works with `devpi token-login` |
288
+
289
+ **Threat model note.** Macaroon HMAC verification requires the secret in
290
+ plaintext on the server, so `devpi-tokens` cannot hash-store; a leaked
291
+ backup or replica disk dump exposes working credentials. Prefer Admin
292
+ tokens for privileged workflows. The UI surfaces this via a (dismissible
293
+ per-user) security banner above the Devpi section of every Tokens modal.
294
+
295
+ `acl_read` (provided by `devpi-admin`) applies to both token systems
296
+ identically — devpi evaluates `pkg_read` ACL against whichever identity
297
+ the auth chain produced, regardless of token source.
298
+
299
+ **Testing without the plugin installed.** Append `?no-devpi-tokens` to any
300
+ SPA URL to make the UI behave as if the plugin weren't there (kebab item
301
+ disappears, type selector hides, etc.). Saves you a `pip uninstall + restart`
302
+ round-trip when verifying graceful degradation.
303
+
234
304
  ## Usage
235
305
 
236
306
  After restart, open:
@@ -503,7 +573,7 @@ Revoke a single token.
503
573
  - **200:** `{"revoked": true, "id": "abc..."}`
504
574
  - **404:** token id not found
505
575
 
506
- ### Replication observability (master only)
576
+ ### Replication observability (primary only)
507
577
 
508
578
  #### `GET /+admin-api/replicas`
509
579
  Last-known poll info per replica, captured from each `GET /+changelog/{N}-` request via
@@ -514,7 +584,7 @@ applied (`start_serial - 1` from its most recent poll). Compare against `/+statu
514
584
  Why this isn't `polling_replicas` from `/+status`: devpi-server overwrites
515
585
  `xom.polling_replicas[uuid].serial` during streaming and gives a misleading "caught up"
516
586
  reading once the response generator drains. Capturing `start_serial` at the request
517
- boundary is the only stable signal master alone can produce.
587
+ boundary is the only stable signal the primary alone can produce.
518
588
 
519
589
  - **Auth:** required
520
590
  - **200:**
@@ -534,7 +604,7 @@ boundary is the only stable signal master alone can produce.
534
604
  }
535
605
  ```
536
606
  - Entries auto-expire after 10 min of silence. Dict size capped at 256 entries
537
- (least-recently-seen evicted first) so an attacker spamming UUIDs cannot exhaust master memory.
607
+ (least-recently-seen evicted first) so an attacker spamming UUIDs cannot exhaust primary memory.
538
608
 
539
609
  ## Project layout
540
610
 
@@ -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.3.0'
22
- __version_tuple__ = version_tuple = (1, 3, 0)
21
+ __version__ = version = '1.4.1'
22
+ __version_tuple__ = version_tuple = (1, 4, 1)
23
23
 
24
- __commit_id__ = commit_id = 'gca7f689da'
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
 
@@ -1345,7 +1386,11 @@ def _list_index_tokens_view(request):
1345
1386
  # Hide existence from anyone without read access.
1346
1387
  raise HTTPNotFound(json_body={"error": "index not found"})
1347
1388
  items = tokens.list_for_index(xom, idx_user, idx_name)
1348
- if auth_user != "root":
1389
+ # Index owner / root see every token bound to this index — they're
1390
+ # the people responsible for revoke decisions and need full audit
1391
+ # visibility. Non-owners see only their own tokens (the bound user
1392
+ # they themselves authenticate as), matching the per-user listing.
1393
+ if auth_user != "root" and auth_user != idx_user:
1349
1394
  items = [(tid, meta) for tid, meta in items
1350
1395
  if meta.get("user") == auth_user]
1351
1396
  result = [_format_token_record(tid, meta) for tid, meta in items]