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.
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.pyresolves the credential and decides the shared-key fall-through,plugins/endpoints/api/enrollment.pycarries enroll, approve, reject, revoke, allow-reenroll and the migration waves,plugins/endpoints/models/credential.pyholds the three tables, andshopdb/core/api/collector.pyreads 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 isplugins/endpoints/AGENT-CONTRACT.mdsection 9, and the fleet rollout order isplugins/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:
X-Endpoint-Credentialpresent? Resolve it to an asset. Unknown or revoked is401 CREDENTIAL_INVALID. The asset the credential resolves to is the asset the request acts for, whatever hostname the body carries.- Otherwise fall through to shared-key auth (section 4).
- 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. - 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.
blockreenrollon the revoke stops the PC enrolling again until an admin callsallow-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 ONLYcollector.ingest, or tocollector.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 inlastusedatand the audit log. - An environment key:
COLLECTOR_API_KEY_<PLUGINNAME>(per plugin, uppercased) orCOLLECTOR_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
- Settings > API Tokens > New Token.
- Check Restrict permissions, tick only Submit collector payloads (fleet
reporting) (
collector.ingest). Name it, optionally set an expiry, Create. - 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
- Mint the replacement.
- Deliver it (section 6) - one value per site.
- Watch the new token's
lastusedatclimb and the old one's go stale. - 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/computersand/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:
HKLM:\SOFTWARE\GE\ShopDBvalueCollectorKey, 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.- A per-site config value the machine account already reads -
collectorApiKeyinC:\Enrollment\site-config.json. Rotation is one value per site, no re-image and no script redeploy. - 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.