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.
293 lines
15 KiB
Markdown
293 lines
15 KiB
Markdown
<!-- docmeta
|
|
purpose: reference
|
|
audience: adopter, integrator, developer
|
|
status: current
|
|
summary: The two ways a shop-floor PC authenticates to the collector API - its own per-PC credential, or the legacy shared key.
|
|
loadwith:
|
|
-->
|
|
|
|
# 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](adr/ADR-019-per-pc-endpoint-credentials.md); 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.
|