Files
shopdb-flask/docs/adr/ADR-019-per-pc-endpoint-credentials.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

218 lines
11 KiB
Markdown

<!-- docmeta
purpose: explanation
audience: developer
status: current
summary: Why every endpoint gets its own enrolled secret instead of one fleet-wide shared key.
loadwith: none
-->
# ADR-019: Per-PC endpoint credentials
- Status: ACCEPTED
- Date: 2026-09-18
- Deciders: ShopDB maintainers
- Relates to: ADR-006 (collector contract), ADR-012 (GE-Enforce manifest
ownership), ADR-015 (site-specific configuration)
- Supersedes: ADR-016 (credential delivery to the fleet)
## Context
The endpoints plugin authenticates every agent-facing call with ONE key. The
same `X-API-Key` value is on every PC in the fleet, and the server cannot tell
one caller from another: a request names its own hostname and is believed.
That key is not only an inventory-ingest key. It is also the key on the command
channel (`AGENT-CONTRACT.md` section 8), where the server hands an agent running
as LocalSystem an allowlisted action to execute - including `run_script`, which
fetches a reviewed PowerShell body and runs it. Anyone holding the key can claim
commands as any PC and post results as any PC.
And the key is readable. The agent installer writes it to
`HKLM\SOFTWARE\GE\ShopDB` `CollectorKey`, and the DACL that subkey inherits by
default grants `BUILTIN\Users` read, so any logged-on user on any bay could read
the fleet key (the reason is recorded in `plugins/endpoints/api/siteagent.py`
beside the code that now locks the key down). The same value also lived in
`appsettings.json` in the agent directory, in a text file on the GE-Enforce
share, and in the `Args` of the manifest entry that wrote it.
One fleet-wide secret, readable by a standard user on any bay, authenticating a
SYSTEM command channel. Rotating it meant touching every one of those places at
once, and nothing could say which PCs had picked the new value up.
ADR-016 answered a different question about credentials - which controller
password a given bay should receive, decided by server-side targeting rules - and
was never built. The question underneath it is "which PC is this", and it needs
no targeting at all.
## Decision
Each endpoint holds its OWN secret, obtained by enrolling, and the server
derives the calling asset from the secret.
### The credential
- `X-Endpoint-Credential: sdbe_` + 43 characters of base64url (32 random bytes).
- The server stores only the SHA-256 of the secret plus its first 12 characters
as a display prefix (`EndpointCredential` in `plugins/endpoints/models/credential.py`).
The full value exists once, in the enroll response.
- One row per asset, so an asset never carries two credentials. Re-issuing after
a revoke updates the same row.
- A request whose credential resolves to one asset while naming another is
refused `403 ASSET_MISMATCH`. A PC can no longer report as another PC.
### Enrollment
`POST /api/endpoints/enroll` never refuses a call for its authentication. The
shared key, if present and still allowed, only decides whether the credential is
issued automatically. Auto-issue requires all of: the shared key authenticated
the call, `plugin.endpoints.autoenroll` is on, the hostname matches exactly one
computer record, the serial on record matches, and the asset has no active
credential. Anything else records a PENDING request an admin approves in the UI.
The agent generates an enrollment nonce once and persists it. The nonce is
recorded on first sight and an approval is bound to it, so a second machine
claiming the same hostname and serial cannot collect the approved credential. A
rejected request binds to the identity instead, so a reinstall cannot argue its
way back into the queue.
### Storage on the endpoint
`%ProgramData%\ShopDB\EndpointAgent\credential\credential.bin`, DPAPI
`LocalMachine`, in a directory ACLed to SYSTEM and Administrators only. The tray
holds no secret at all: it runs as the interactive operator and asks the SYSTEM
service over a local named pipe. `HKLM\SOFTWARE\GE\ShopDB` is locked to SYSTEM
and Administrators in both registry views.
### Server pinning
The agent pins the server's public key: the lowercase hex SHA-256 of the leaf
certificate's SubjectPublicKeyInfo, in `ServerPins` (REG_MULTI_SZ, both views),
authoritative whenever the value exists even when empty. Locking the registry
key is what makes the registry safe to treat as authoritative, since a standard
user can no longer write a pin.
### The shared key stays, deliberately
`plugin.endpoints.sharedkeyallowed` keeps the shared key working for PCs that
have NOT enrolled. It is always refused for a PC that holds an active credential
(`401 SHARED_KEY_REFUSED`). A PC whose credential was REVOKED falls back to the
shared key, which is what makes a rollback to an older agent possible;
`plugin.endpoints.revokeddeniessharedkey` turns that off and is off by default,
because it is only safe once the fleet is off the shared key.
## Alternatives rejected
**Client certificates, 2026-09-14.** This was not a thought experiment: it was
built and then removed three days later. `client_cert.py`, certificate renewal,
expiry monitoring, an unauthenticated `/test/client-cert` route, nginx
client-certificate forwarding and an agent certificate script all shipped on a
branch and were all deleted.
Three reasons, and the first is fatal on its own:
1. **The header was forgeable.** Behind IIS the client certificate reaches the
app as a request header, and nothing stopped a caller from simply sending
that header itself.
2. **The private key was readable by users** on the endpoint, so the certificate
was no better protected than the key it replaced.
3. **The shared key remained underneath it** as a fallback, so the weakest path
was still open and the certificate bought nothing.
It also cost more than it looked. The attempt put five underscore-spelled
certificate columns on the CORE `Asset` model for a plugin's feature and
retracted them again, with no contract bump in either direction - an ADR-001 and
ADR-002 breach inside an unreleased branch. Its migrations
(`endpoints0021clientcerts`, `0022`) survive as retired no-ops so a database
that ran them still upgrades; `0023` drops the columns where a database has
them.
**ShopDB-resolved credential targeting (ADR-016).** Decided 2026-08-11 and never
implemented. It gave a PC a credential chosen by rules over eight targeting axes
with an explicit priority order and an explain endpoint to debug the result. The
targeting was the whole design, and the exposure that needed closing was not a
targeting problem: the fleet needed identity, not a rule engine. ADR-016 is
superseded rather than deleted because the problem it opens with - a cleartext
controller password on a share, with no rotation story - is still unsolved. This
ADR does not solve it.
**Keep one key and just lock down where it lives.** The registry ACL is part of
this decision anyway, and it is worth having on its own. It does not help: one
secret shared by every PC still means the server cannot identify a caller, a
revoked bay cannot be evicted without rotating the whole fleet, and any
administrator or any process running as SYSTEM on any bay still reads the key
that drives every other bay's command channel.
## Consequences
**The shared key remains until every consumer is migrated.** That is the main
cost of this decision and it is not short. The key lives in a GE-Enforce
manifest entry's `Args`, in `CollectorKey` in both registry views, in a text file
in the share's `common/configs`, in `appsettings.json` in the agent directory, in
every generated `install.ps1` that was kept, and on display kiosks. Each one has
to be cleared in a specific order - retire the entry that rewrites the value
before deleting the value - and the agent never deletes the shared key itself,
because other SYSTEM scripts may still read it. The runbook
(`plugins/endpoints/docs/PER-PC-CREDENTIALS-ROLLOUT.md`) carries the table.
**Turning the shared key off is wider than the agent.** It is also refused on
`/api/collector/computers` and `/api/collector/printers`, so a PC with reporter
scripts but no enrolled agent goes silent at cutover.
**Enrollment is a new admin queue.** Anything that cannot be auto-issued waits
for a person: unknown or duplicate hostname, missing or mismatched serial, a
one-off PC nobody wrote a manifest entry for. A dashboard row counts PCs waiting
and PCs blocked from re-enrolling.
**Pins have to arrive before the agent does.** An agent that pins with no pin set
falls back to plain Windows chain validation, which a self-signed or internal-CA
server may fail, and a pin set after the fact is a per-bay debugging job.
Rotating the server key becomes a two-phase fleet operation: publish old and new
pins everywhere, switch the certificate, then retire the old pin. Switching
first cuts off every pinned PC, including its route to the corrected pin list.
**GE-Enforce can undo an upgrade.** Agents installed from the share are held to a
file hash by a manifest entry. A store upgrade that lands a newer agent while the
entry still carries the old hash is reverted on the next cycle, so the entries
have to be re-stamped or removed before the waves reach those PCs.
**Revoke means two different things depending on one setting.** With
`revokeddeniessharedkey` off, revoking drops a PC back to the shared key, which
is how rollback and credential rotation work. With it on, revoking is a real
eviction and the PC reports nothing until it enrolls again.
**A PC that cannot reach ShopDB keeps its credential.** Unlike ADR-016's fetch
model, there is nothing to re-fetch per cycle: the secret is local and long-lived
until an admin revokes it.
## Implementation status
**Built, and in this tree.** `plugins/endpoints/api/agentauth.py`,
`api/enrollment.py` and `models/credential.py` are all present,
`shopdb/core/api/collector.py` reads `X-Endpoint-Credential` on the generic
collector route, `AGENT-CONTRACT.md` carries section 9, and
`tests/test_plugins/test_endpoints_credentials.py` covers the auth decisions.
The C# agent enrolls and sends the header, and both shipped PowerShell reporters
send it when a credential is present and fall back to the shared key when it is
not.
Proven on a Windows test PC against a test server: self-enrollment, the shared
key refused for an enrolled PC, a request naming another asset refused, pins
enforced, the tray pipe working for a non-admin user, both SYSTEM reporters
sending with the per-PC credential, revoke followed by re-enrollment, rollback
to the previous agent, and enrolled PCs still working with the shared key
switched off.
What is NOT finished is the fleet migration, which is the consequence this ADR
opens with: every PC that has not enrolled is still on the shared key by design,
and the key still lives in the places listed above. The runbook is
`plugins/endpoints/docs/PER-PC-CREDENTIALS-ROLLOUT.md`.
## References
- `plugins/endpoints/api/agentauth.py` - credential and shared-key authentication
- `plugins/endpoints/api/enrollment.py` - enroll, approve, revoke, migration waves
- `plugins/endpoints/models/credential.py` - credential, enroll request, wave
- `plugins/endpoints/AGENT-CONTRACT.md` section 9 - the wire contract
- `plugins/endpoints/docs/PER-PC-CREDENTIALS-ROLLOUT.md` - the rollout runbook
- ADR-016 - the superseded targeting model