From 12175169e4820015f858d597773a1bd23c11e63d Mon Sep 17 00:00:00 2001 From: cproudlock Date: Sun, 12 Jul 2026 09:13:33 -0400 Subject: [PATCH] Accept managed collector service tokens on the collector API A token scoped to the new collector.ingest permission is a collector service token: the collector endpoints accept it via X-API-Key or Bearer alongside the env fleet keys (which remain the fallback), giving the fleet credential rotation, revocation, and last-used visibility from the API Tokens page. Containment holds both ways: a collector token authorizes nothing else, and no other credential gains collector access. Shared token validation refactored out of the auth shim; a Collector service token quick-preset in the create modal; integration guide documents minting, rotation via site-config.json, and the service-identity pattern. 765 tests pass; live acceptance matrix verified. Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 23 ++++ docs/COLLECTOR-INTEGRATION.md | 81 ++++++++++++ docs/CONFIG.md | 10 +- frontend/src/views/settings/ApiTokensList.vue | 34 +++++ shopdb/core/api/collector.py | 104 +++++++++++---- shopdb/core/models/user.py | 6 + shopdb/utils/apitoken_auth.py | 15 ++- tests/test_core/test_apitokens.py | 16 +++ tests/test_core/test_collector_contract.py | 121 ++++++++++++++++++ 9 files changed, 381 insertions(+), 29 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3db0a64..4a0fa16 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -48,6 +48,29 @@ ADR-007 and ADR-002. create/edit modals gain a "Restrict permissions" section (a category-grouped checkbox grid limited to the permissions the creator holds) and the token lists show a full-access / N-permissions access chip. +- Managed collector service tokens: the collector ingest API + (`/api/collector/` + the legacy `/pc` `/apps` `/heartbeat` `/bulk` + `/status` endpoints) now ALSO accepts a managed API token scoped to the new + `collector.ingest` permission (category `collector`), on top of the existing + `COLLECTOR_API_KEY[_]` env keys (which stay supported as a + bootstrap/legacy fallback - nothing breaks). The token may be presented in + `X-API-Key` (as GE-Enforce sends today) OR as an `Authorization: Bearer` + token; both transports validate the PAT the same way the login shim does + (hash lookup, active, unexpired, active owner) via a shared + `resolve_api_token` helper refactored out of `apitoken_auth.py`, require + `collector.ingest` in the token's scope list AND that the owner holds it, and + stamp `lastusedat` (same 60s throttle). A token scoped to ONLY + `collector.ingest` is a collector service token: it authorizes the collector + API and NOTHING else - the existing scoped-token machinery denies it on every + permission- and role-gated route and on import mode, so a leaked collector + token cannot touch the regular API. Recommended flow (documented): an admin + mints the scoped token (the scope suspends the admin bypass, containing it); + rotate by minting a new one, deploying via `site-config.json`, watching + `lastusedat`, then revoking the old. The Settings > API Tokens create modal + gains a "Collector service token" quick-preset (pre-selects only + `collector.ingest`). Docs: `docs/COLLECTOR-INTEGRATION.md` (new "Managed + collector tokens" section) and `docs/CONFIG.md`. Core feature; no + plugin-contract change. - Vendor-model photos on asset detail heroes: computers and printers now surface the linked model's `imageurl` in their extension payloads (the field machines already exposed), and the machine, PC, printer, network diff --git a/docs/COLLECTOR-INTEGRATION.md b/docs/COLLECTOR-INTEGRATION.md index b6fd5cf..118de1d 100644 --- a/docs/COLLECTOR-INTEGRATION.md +++ b/docs/COLLECTOR-INTEGRATION.md @@ -30,6 +30,80 @@ a real caller (the GE-Enforce fleet agent) to it. HTTP 500 `Collector API key not configured` and rejects every request. An unconfigured server never silently accepts unauthenticated data. - A caller that sends the wrong key (or no key) gets HTTP 401 `Invalid API key`. +- In addition to the env keys, a managed API token scoped to `collector.ingest` + is accepted as a collector credential on every collector endpoint. See + "Managed collector tokens" below; env keys remain the fallback. + +### Managed collector tokens (recommended) + +Alongside the env keys, every collector endpoint (`/api/collector/`, +`/pc`, `/apps`, `/heartbeat`, `/bulk`, `/status`) also accepts a **managed API +token** (PAT) scoped to the `collector.ingest` permission. The env keys stay +supported as a bootstrap/legacy fallback - nothing breaks - but a managed token +is the preferred credential because it can be minted, rotated, and revoked from +the UI (Settings > API Tokens) and its use shows up in `lastusedat` and the +audit log. + +What makes a token a collector service token: it is scoped to ONLY +`collector.ingest`. That scope authorizes the collector ingest API and NOTHING +else. The existing scoped-token machinery contains it automatically - a scoped +token passes `require_permission` only for its listed permissions and is denied +on every role-gated (`require_role`) endpoint and on import mode, and +`collector.ingest` gates no normal route. So a collector token that leaks cannot +be used to read or write anything through the regular API; it can only submit +collector payloads. + +Both wire transports are accepted (send whichever is convenient; GE-Enforce +sends `X-API-Key` today, so that stays ergonomic): + +``` +POST /api/collector/computers +X-API-Key: shopdb_pat_<40 hex> +``` +or +``` +POST /api/collector/computers +Authorization: Bearer shopdb_pat_<40 hex> +``` + +An unscoped PAT, or a PAT scoped to some other permission, is NOT a collector +token and is rejected (401) - only `collector.ingest` in the scope list counts. +A revoked or expired token is rejected (401) on both transports. + +#### How to mint one (admin flow) + +The simplest contained flow: an **admin** mints the token, scoped to +`collector.ingest`. Because the token is scoped, the admin-role bypass is +suspended for it, so the token is contained to the collector API even though its +owner is an admin - it cannot act with admin authority anywhere. + +1. Settings > API Tokens > New Token. +2. Click the **Collector service token** preset (pre-selects only + `collector.ingest`), name it (e.g. `wj-fleet-collector`), optionally set an + expiry, Create. +3. Copy the `shopdb_pat_...` secret (shown once) and deploy it to the fleet the + same way as the env key: the `collectorApiKey` field in per-site + `site-config.json` (see "Delivering the API key to clients" below). The + client sends it in `X-API-Key` exactly as it sends an env key today - no + client code change. + +Service identity (documented, not built): if you prefer a non-admin owner, +create a dedicated low-privilege user (e.g. `svc-collector`) whose role holds +only `collector.ingest`, plus `apitokens.create` if that user is to mint its own +token. The scope ceiling then caps any token it mints at `collector.ingest`. +The admin-minted route above is simpler and equally contained, so it is the +recommended default. + +#### Rotation + +Managed tokens rotate without a fleet re-image: + +1. Mint a new collector token (steps above). +2. Deploy it via `site-config.json` (`collectorApiKey`) - update the one per-site + value. +3. Confirm the new token is in use: watch its `lastusedat` climb in Settings > + API Tokens (and the old token's `lastusedat` go stale). +4. Revoke the old token once traffic has moved. Revocation is immediate. ### Generic endpoint contract: `POST /api/collector/` @@ -256,6 +330,13 @@ collector schema, and POSTs with the `X-API-Key` header over TLS 1.2. Every field name below was checked against `get_collector_schema` in `plugins/computers/plugin.py`. +The `X-API-Key` value can be EITHER a `COLLECTOR_API_KEY[_COMPUTERS]` env key OR +a managed token scoped to `collector.ingest` (a `shopdb_pat_...` secret; see +"Managed collector tokens"). The script is identical for both - it just carries +whatever `collectorApiKey` the site-config supplies - so switching a site from an +env key to a managed token (and rotating it) is a config change, not a script +change. + ```powershell # Send-ShopdbCollectorReport.ps1 # Reports this PC's identity to shopdb-flask via POST /api/collector/computers. diff --git a/docs/CONFIG.md b/docs/CONFIG.md index a1e0761..4de9723 100644 --- a/docs/CONFIG.md +++ b/docs/CONFIG.md @@ -59,9 +59,17 @@ limit is approximate across multiple gunicorn workers). | Variable | Required | Default | Notes | |----------|----------|---------|-------| -| `COLLECTOR_API_KEY` | No | (empty) | Shared key for `/api/collector/*`. Endpoint fails closed (denies) when unset. Sent as the `X-API-Key` header. | +| `COLLECTOR_API_KEY` | No | (empty) | Shared key for `/api/collector/*`. Endpoint fails closed (denies) when unset and no managed token is presented. Sent as the `X-API-Key` header. | | `COLLECTOR_API_KEY_` | No | (empty) | Per-plugin override, e.g. `COLLECTOR_API_KEY_COMPUTERS`. Checked before the shared key. | +The collector endpoints ALSO accept a managed API token (PAT) scoped to the +`collector.ingest` permission, sent in `X-API-Key` or as an +`Authorization: Bearer` token. Env keys stay supported as a bootstrap/legacy +fallback; a managed token is preferred because it is minted, rotated, and +revoked from Settings > API Tokens with `lastusedat` visibility. A +collector-scoped token is contained to the collector API and nothing else. See +`docs/COLLECTOR-INTEGRATION.md` (Managed collector tokens). + ### Zabbix (printer supply monitoring) | Variable | Required | Default | Notes | diff --git a/frontend/src/views/settings/ApiTokensList.vue b/frontend/src/views/settings/ApiTokensList.vue index 1227987..1557794 100644 --- a/frontend/src/views/settings/ApiTokensList.vue +++ b/frontend/src/views/settings/ApiTokensList.vue @@ -125,6 +125,18 @@ Leave blank for a token that never expires. +
+ + + For GE-Enforce / fleet reporting. Pre-selects only + collector.ingest; the token works only on the + collector API and nothing else. + +
+