Product version to 0.9.0, frontend in lock-step, Unreleased notes moved into a dated section per ADR-007. The plugin contract stays at 0.16.0: it moved after v0.8.1 and is already recorded in this release's notes, and nothing since touched the contract surface. A minor rather than a patch: collector behaviour changed in ways an integrator must know about. A reported machine number no longer becomes the PC's asset number, it builds a controls link instead; a second PC claiming a machine is treated as a claim rather than a handover; and a backup revision chain is now per source PC rather than per asset.
7.5 KiB
ADR-016: Credential delivery to the fleet
- Status: ACCEPTED
- Date: 2026-08-11
- Deciders: cproudlock
- Relates to: ADR-006 (collector contract), ADR-012 (GE-Enforce manifest ownership), ADR-015 (site-specific configuration)
Context
Shopfloor bays need credentials they cannot prompt for. A collections bay loses the Windows Credential Manager entry for its controller subnet periodically - suspected Defender or Intune scrub - and without it, controller-side connections need an interactive prompt, so an unattended bay simply stops working. A machine whose NTLARS config points at a program share needs a credential to reach that share for the same reason.
Today this is solved by Set-ControllerCredential.ps1 on the SFLD share:
$Target = '192.168.1.1'
$Username = 'CHANGEME'
$Password = 'CHANGEME'
A person edits the values into the file, GE-Enforce runs it every cycle, and
cmdkey /add re-applies it. It works, and it has three problems.
The password is cleartext in a file on a share, readable by anything that can read the share - a wider exposure than the machine-local secret it becomes. Rotating it means editing that file and hoping: there is no way to ask which bays have picked up the new value. And it is one value for everything, while the requirement is per-bay variation: sites will have machines needing different credentials from their neighbours.
Per-bay variation is what rules out the obvious alternative. GE-Enforce scopes
are per PC TYPE, so expressing per-machine credentials as manifest scopes means
a scope per bay, which is not a model. The manifest can deliver a value
(Registry entries with inline payloads already do this for the display
scope), but it cannot decide WHICH value a given PC should get.
Decision
ShopDB owns credential definitions and their targeting. A PC asks for its own credentials and receives a decided answer; it never receives rules to evaluate.
Targeting
A credential rule carries a targeting expression using the SAME axes GE-Enforce already filters on, plus those the asset model knows:
| Axis | Source | Notes |
|---|---|---|
| hostname | reported | exact or -like glob (WJRP*) |
| machine | PC -> machine link | the machine the PC drives |
| model | asset model | e.g. every Okuma of one model |
| controller OS | asset / reported | version-valued, like the existing CmmVersion gate |
| PC type | ComputerType | with GE-Enforce ALIAS EXPANSION |
| GE-Enforce profile | manifest scope | the imaging profile |
| DNC/NTLARS setting | stored backup projection | e.g. any machine whose DataHost is a given UNC |
| site default | setting | the fallback |
The DNC axis is not a special case bolted on: the NTLARS projection ShopDB
already stores contains DataHost, DataPath and MarkMasterPath, so "every
machine that talks to \ntshare" is a selector over data already held. It is
also the axis that answers "which machines even need a credential", which
nothing answers today.
Precedence
Overlapping matches are guaranteed, not exceptional - a hostname glob and a per-model rule will both hit the same bay. Precedence is therefore EXPLICIT, never emergent:
- Each rule carries an integer
priority. Higher wins. This is the only thing an operator needs to reason about. - Ties break on scope specificity, in the order of the table above (hostname most specific, site default least).
- Remaining ties break on the lowest rule id, so the result is stable rather than dependent on row order.
A resolve endpoint must be able to EXPLAIN itself - "this PC got rule 12,
matched on model, beating rule 4 on priority" - or nobody will trust it. The
GE-Enforce simulator (applicable_entry_names) already set this precedent.
Reuse the existing matcher
plugins/geenforce/filters.py already mirrors the engine's targeting: alias
groups, hostname globs, the rule that a machine-number filter with no machine
number EXCLUDES rather than defaults. The resolver reuses it. It must not
become a third implementation of the same matching logic - there are already
two (the PowerShell engine and its Python mirror), kept honest by a parity
harness, and a third would drift silently.
Alias expansion in particular is not optional: if a rule says PCTypes: Standard, it must mean what it means in GE-Enforce, or the two systems will
disagree about who a rule covers.
Targeting is not detection
Who gets a credential and whether it is already applied are separate
questions, exactly as PCTypes and DetectionMethod are separate in the
manifest. The client decides the second locally - is this cmdkey entry
already present and correct - and rewrites only on drift. Conflating them
rewrites the credential every cycle instead of healing it, and turns the audit
trail into noise.
Storage
- Encrypted at rest with Fernet.
cryptographyis already a dependency. - The key lives OUTSIDE the database - instance file or environment, like
SECRET_KEY. A key in the database it protects is not encryption. - Write-only through the API. Masked on read, as
smtp_passwordalready is. A fleet PC receives a value; a human never reads one back. - A dedicated
credentials.managepermission for authoring.
Delivery
GET /api/collector/credentials X-API-Key: <credentials.fetch token>
- A DEDICATED
credentials.fetchscope, notcollector.ingest. A leaked collector key must not yield controller passwords. - The caller is resolved by hostname; it receives only what it is entitled to, and never the rule set.
- Every fetch is audited: which PC, which credential, when. The audit log exists; this belongs in it.
- The client half is a shared helper alongside
ShopdbBackupClient.psm1: fetch, compare,cmdkey /addon drift, log once per state.
Consequences
Positive:
- The cleartext credential leaves the share.
- Rotation becomes a settings change plus a report of who has picked it up, instead of a file edit and a hope.
- Per-bay, per-model and per-share credentials become expressible, which is the actual requirement.
- "Which machines need a credential" becomes answerable from the DNC data already stored.
Negative, and these are real:
- ShopDB becomes a credential store. That is a different security posture from an asset database, and it earns the obligations above rather than choosing them.
- The key is a new single point of loss. Restore the database without it
and every stored credential is unrecoverable.
docs/BACKUP-RESTORE.mdmust say so IN THE SAME CHANGE, not later. - A PC that cannot reach ShopDB gets no credential. The client must keep the last known good value rather than clearing a working entry on a failed fetch.
- Targeting complexity is real complexity. Eight axes with priorities will produce a rule set someone has to debug, which is why the explain endpoint is a requirement and not a nicety.
Alternatives rejected
Keep editing the script on the share. Cannot express per-bay values, keeps the cleartext, no rotation story.
A manifest scope per bay. Per-machine credentials as GE-Enforce scopes is a spreadsheet, not a model, and the manifest cannot resolve WHICH value applies.
Encrypted payload in the manifest. DPAPI is per-machine, and one manifest serves many machines, so the payload cannot be encrypted to its readers.
Registry-only provisioning (no ShopDB). A worthwhile FIRST STEP - it moves the secret off the share immediately and needs no new endpoint - but it leaves provisioning per-bay by hand and offers no rotation. Recommended as tier one regardless, since the client helper is the same either way.