Files
shopdb-flask/docs/COLLECTOR-AUTH.md
cproudlock 534f179ee6 Make main the lean product a site receives
main was 16 days and 259 commits behind the line the product is actually built
on, at contract 0.30.0 against 0.35.0, with no per-PC credential code at all. It
was neither current nor curated - just stale. This brings it forward and, at the
same time, makes it mean something: main is what a receiving site installs, and
nothing else.

WHAT IT CARRIES. Twelve plugins - backups, computers, endpoints, guides,
knowledgebase, machines, network, notifications, printers, slides, tools,
warranty - plus applications, which is manifest-less core and always ships. The
only manifest dependency inside that set is endpoints on computers, and both are
in it, so the closure is self-contained.

WHAT IT DOES NOT. employees, geenforce, measuringtools, printedparts and usb
stay on the site line. So do the Windows installer tree, the VM rehearsal
helpers, the agent tooling config, one site's data loader, the internal audit
and findings records, and the planner spreadsheet. 1,135 tracked files against
1,438.

A REMOVAL THAT BREAKS THE APP IS NOT A REMOVAL, and two nearly shipped:

deploy/site-profile-universal.json still named all seventeen plugins. It is the
file docs/DEPLOY.md tells a site to provision from, so a fresh install would
have failed at `flask plugin apply-profile` with PluginError - the first thing a
receiving site does, broken by the branch meant to serve them. It now names the
twelve that exist, and its comment says the two must agree.

agent/ was never refreshed. Whoever populated this branch copied shopdb/,
plugins/, frontend/ and tests/ from the site line and left the agent tree at its
September state: 22 C# and PowerShell sources missing, including AgentCredentials,
EnrollmentService, AgentHttpFactory and the fleet-alert client, plus one file the
site line had deleted still present. The old tree is internally consistent, so it
compiles, and nothing in CI builds it - every gate was green over a server that
speaks enrollment and per-PC credentials to a bundled agent source that cannot.
Resynced whole.

DECISIONS THIS ENCODES, rather than defers:

- The plugin-authoring lab builds printedparts, whose manifest depends on
  employees, so it could not stay. The lab remains on the site line and main
  ships no build-along tutorial. That is a real loss for an adopting developer
  and is recorded here rather than discovered later.
- Recognition, Training and Recertification notifications are no longer seeded.
  Each splits one notification into a card per employee and renders a photo,
  which needs a plugin this branch does not bundle. Seeding them would offer an
  author a type that cannot resolve anybody. A site that installs employees gets
  them from that plugin's own seed.
- Windows/IIS is documented as the legacy path throughout, and the README leads
  with Docker, because that is what a receiving site gets.

The API description is regenerated for THIS branch - 347 paths and 483
operations, down from 415 and 573 - so the published contract describes what is
here rather than what the site line serves. docs/CONFIG.md lost the two
measuring-tool label keys the code no longer declares, which two lanes had
decided opposite ways.

Suite: 3124 passed, 6 skipped. Naming and style gates pass. The docs gates run
rather than skip, and every index line count is derived from the file it names.
2026-09-19 11:24:40 -04:00

15 KiB

Collector authentication

This page is the one authoritative description of how a fleet client proves who it is. Every other page links here rather than restating it.

There are two credentials, and which one a PC uses is a property of that PC, not of the site:

Per-PC credential Shared collector key
Status current legacy - still works, do not build on it
Header X-Endpoint-Credential X-API-Key (or Authorization: Bearer)
Identifies this one PC any holder of the key
Issued by enrollment, once per PC an admin, once per fleet
Revocable per PC only fleet-wide
Blast radius if it leaks one PC's report every PC's report

A PC that has enrolled sends its credential and nothing else. A PC that has not enrolled sends the shared key, exactly as the whole fleet used to. Never both on one request.

Per-PC credentials need the endpoints plugin on the server and an endpoint agent (or reporter script) that knows how to enroll. A site running older clients is on the shared key and keeps working; that is the point of keeping both.

Both halves are in this tree. plugins/endpoints/api/agentauth.py resolves the credential and decides the shared-key fall-through, plugins/endpoints/api/enrollment.py carries enroll, approve, reject, revoke, allow-reenroll and the migration waves, plugins/endpoints/models/credential.py holds the three tables, and shopdb/core/api/collector.py reads the header on the generic collector route. The C# agent sends it (agent/ShopDbEndpointAgent.Core/AgentHttpFactory.cs) and both shipped PowerShell reporters fall back to the shared key on their own. The decision record is ADR-019; the wire contract is plugins/endpoints/AGENT-CONTRACT.md section 9, and the fleet rollout order is plugins/endpoints/docs/PER-PC-CREDENTIALS-ROLLOUT.md.


1. Which endpoints take which

Endpoint Per-PC credential Shared key
POST /api/collector/computers yes yes
POST /api/collector/printers yes yes
POST /api/collector/endpoints (and /api/endpoints/collect) yes yes
POST /api/collector/<other plugin> only if that plugin's schema declares acceptsagentcredential yes
POST /api/collector/pc, /apps, /heartbeat, /bulk no yes, see section 5
GET /api/collector/status no yes
GET /api/collector/_schemas no no - JWT only, it is for humans

A plugin's own unattended routes are outside this table. They authenticate with a scoped managed service token, or with a source-IP allowlist, and they accept neither a per-PC credential nor a collector key. That is the distinction people get wrong: the collector credential says WHICH PC is writing a report, a service token says WHAT an integration is allowed to do.

2. How the server decides

For a collector endpoint whose plugin schema declares acceptsagentcredential:

  1. X-Endpoint-Credential present? Resolve it to an asset. Unknown or revoked is 401 CREDENTIAL_INVALID. The asset the credential resolves to is the asset the request acts for, whatever hostname the body carries.
  2. Otherwise fall through to shared-key auth (section 4).
  3. If the shared key authenticated but the named PC has enrolled, the request is refused 401 SHARED_KEY_REFUSED. A PC with its own secret must use it; a shared-key call in its name is an older agent or somebody else.
  4. A payload that names a different asset than the credential is 403 ASSET_MISMATCH.

A PC that has NEVER enrolled has no credential row, is never refused by step 3, and keeps reporting on the shared key indefinitely. That is what carries an un-migrated fleet.

Error codes

HTTP Code Meaning What the client does
401 CREDENTIAL_INVALID unknown or revoked credential delete the stored credential, retry once with the shared key, wake enrollment
401 SHARED_KEY_REFUSED shared key sent for an enrolled PC, or the shared key is switched off use the credential; if there is none, enroll
403 ASSET_MISMATCH the credential's asset is not the one the request names log and stop. Do not retry the same request
401 Invalid API key the shared key is wrong or missing fix the key
500 Collector API key not configured server-side: no env key set and no managed token presented a SERVER config gap, not a client problem

ASSET_MISMATCH also covers a hostname that is on no computer record at all. The code and status are the same because nothing changes for the client, but the message says which of the two it is and names the hostname. An admin fixes it by setting the hostname on the enrolled computer.

3. Per-PC credentials (current)

Shape and storage

A credential is sdbe_ plus 43 base64url characters (32 random bytes). The server stores only its SHA-256 plus the first 12 characters for display; the full secret exists exactly once, in the enrollment response.

On the PC it lives in %ProgramData%\ShopDB\EndpointAgent\credential\credential.bin, DPAPI LocalMachine, in a folder ACL'd to SYSTEM and Administrators only. The file records the baseurl it was issued for, and the credential must never be sent anywhere else. enroll.json beside it holds the enrollment nonce in plain text.

Enrolling

POST {baseurl}/api/endpoints/enroll with hostname and serialnumber (required), plus an optional deviceid, an optional agentversion, and a persisted enrollnonce of 32 to 128 characters from A-Za-z0-9_-.

The route never refuses a call for its authentication. A PC installed without the shared key still records a request an admin can approve. The shared key only decides whether a credential is issued on the spot.

Because it takes no auth and writes rows, it carries the two bounds an open door needs: a per-IP fixed window of 300 calls a minute, sized for a whole shop floor behind one address rather than for one PC, and a cap on how many PENDING rows may exist at once (plugin.endpoints.maxpendingenrollments, default 500). The cap turns away only a hostname and serial never seen before, and it drops abandoned rows before it refuses a real PC, so neither bound can lock out a machine already in the queue.

Status Body What it means
201 {"status":"enrolled","credential":"sdbe_...","assetid":123} issued. Store it and switch headers; no restart
202 {"status":"pending","requestid":45,"reason":"serial mismatch"} queued for an admin. Retry on a backoff
403 ENROLL_NONCE_MISMATCH the approved request belongs to a different nonce. An admin must reject and re-approve

A credential is auto-issued only when ALL of these hold: the shared key authenticated the call, plugin.endpoints.autoenroll is on, the hostname matches exactly one computer, the serial on record matches (trimmed, case-insensitive), and the asset has no active credential. Otherwise the request is pending with one of: unknown hostname, duplicate hostname, no serial on record, serial mismatch, already enrolled, awaiting approval, nonce changed, rejected, reenroll blocked.

There is one request row per (hostname, serial number); repeated calls update it. The nonce is what an approval binds to, so a second machine claiming the same hostname and serial cannot collect the approved credential. A REJECTED row binds to the identity instead of to the nonce: any nonce from that hostname and serial stays rejected, so a reinstall cannot argue its way back into the queue, and approving the request is the only way out of a rejection.

Revoking, rotating, rolling back

Revoking sets revokedat on the asset's one credential row. Re-issuing updates that same row, so an asset never carries two credentials.

By default a revoked PC falls back to the shared key. That is deliberate: a revoke is also how an admin rotates a credential, and how a PC is rolled back to an older agent that only knows the shared key. Denying by default would silence that PC instead of rolling it back.

  • blockreenroll on the revoke stops the PC enrolling again until an admin calls allow-reenroll. That is what makes a revoke stick for a compromised PC.
  • plugin.endpoints.revokeddeniessharedkey (default off) makes a revoke a real eviction: the PC reports nothing until it enrolls again. Turn it on only once the fleet is off the shared key, because it also closes the rollback path.

Admin API, for reference: GET /api/endpoints/enrollment (needs endpoints.fleet), and the audited admin-role writes .../requests/<id>/approve, .../requests/<id>/reject, .../assets/<assetid>/revoke, .../assets/<assetid>/allow-reenroll.

Server pins

A pinned agent accepts the TLS handshake if and only if the leaf certificate's SubjectPublicKeyInfo SHA-256 is in its pin set; with no pins it requires a clean Windows chain validation.

HKLM:\SOFTWARE\GE\ShopDB ServerPins (REG_MULTI_SZ, either registry view) is authoritative whenever the value exists, even when empty - an empty value means "registry authoritative, no pins". Only when it is absent from both views do the serverpins entries in the agent's appsettings.json apply. The agent logs which source is in effect at startup and on every change, and re-reads the set within about a minute with no restart.

The Server Certificate settings page shows the pin; the client kit has Get-ShopDBServerPin.ps1 to compute one and Set-ShopDBServerPins.ps1 to write the set (-None for an empty authoritative value, -Clear to delete it).

4. The shared collector key (legacy)

What counts as one

  • A managed API token (shopdb_pat_...) scoped to ONLY collector.ingest, or to collector.ingest.<pluginname> for one collector. This is the form to use. It can be minted, rotated and revoked from Settings > API Tokens, and its use shows in lastusedat and the audit log.
  • An environment key: COLLECTOR_API_KEY_<PLUGINNAME> (per plugin, uppercased) or COLLECTOR_API_KEY (shared fallback). Resolution is per-plugin first, then shared. These stay supported as a bootstrap and legacy path.

An unscoped token, or one scoped to some other permission, is NOT a collector credential and is rejected. collector.ingest gates no normal route and is denied on every role-gated endpoint, so a scoped token that leaks can submit collector payloads and do nothing else - not even with an admin owner, because scoping suspends the admin bypass.

Header only, fail-closed

The key travels in X-API-Key (Bearer also accepted for a managed token). The ?api_key=<key> querystring form was removed on every collector endpoint - querystring keys leak into access logs, proxy history and browser history. A caller still putting it in the URL gets 401.

If neither an env key nor a managed token is available server-side, every request is refused. An unconfigured server never silently accepts unauthenticated data.

Minting one

  1. Settings > API Tokens > New Token.
  2. Check Restrict permissions, tick only Submit collector payloads (fleet reporting) (collector.ingest). Name it, optionally set an expiry, Create.
  3. Copy the shopdb_pat_... secret, shown once, and deliver it to the fleet (section 6).

If you prefer a non-admin owner, create a low-privilege user whose role holds only collector.ingest and let it mint its own token; the scope ceiling then caps anything it mints. The admin-minted route is simpler and equally contained.

Rotating one

  1. Mint the replacement.
  2. Deliver it (section 6) - one value per site.
  3. Watch the new token's lastusedat climb and the old one's go stale.
  4. Revoke the old token. Revocation is immediate.

The endpoint agent's key has its own kill switch: Settings > Endpoint agent key generates a key scoped to collector.ingest.endpoints, revokes any previous one in the same request, and switches off the .env fallback for endpoints (plugin.endpoints.envkeyfallback). Revoking it stops all endpoint-agent traffic without touching the computers or printers collectors.

Switching it off

plugin.endpoints.sharedkeyallowed (default on) gates the shared key for the endpoints collector. Turning it off is the last step of a migration, not the first: every PC that has not enrolled stops reporting the moment it flips.

5. The four legacy core routes

POST /api/collector/pc, /apps, /heartbeat and /bulk predate both the generic collector contract and per-PC credentials. They take their target hostname from the body and rewrite that PC's logged-in user, boot time, serial number and installed apps, so the shared key is an any-host write key on them and no per-PC credential narrows it. plugin.endpoints.sharedkeyallowed does not reach them.

collector.legacysharedkeyrefused (default false, today's behaviour) closes them to the shared key; a collector-scoped managed token still works. Every shared-key use on these routes is logged whether the gate is on or off, so read the "Legacy collector route used with the shared key" warnings for a while first

  • whatever still calls them stops working the moment it is on. Neither shipped reporter is affected; they post to /api/collector/computers and /api/collector/printers.

Per ADR-006 the legacy /pc path is deprecated and slated for removal before v1.0. New integrations target POST /api/collector/<plugin>.

6. Delivering the shared key to a PC

Do NOT hardcode a key in a script that lives on a file share: a literal key there is published to everyone with share read. In order of preference:

  1. HKLM:\SOFTWARE\GE\ShopDB value CollectorKey, written at imaging time, by DSC, or by an Intune platform script. This is where both shipped reporters look. Restrict the key's ACL to SYSTEM and Administrators so an interactive or kiosk user cannot read it.
  2. A per-site config value the machine account already reads - collectorApiKey in C:\Enrollment\site-config.json. Rotation is one value per site, no re-image and no script redeploy.
  3. A machine-local file staged at imaging (C:\Enrollment\collector-api-key.txt), ACL'd to SYSTEM. Tighter blast radius
    • the key never sits on the share at all - but rotation means touching every PC.

Never put a token in a scheduled task's -Argument string: task XML is world-readable. Never put one in a manifest: manifests sync broadly.

A per-PC credential needs none of this. It is issued to the PC by enrollment and never leaves it.