Controller and share credentials are currently edited as cleartext into a script on the SFLD share, one value for the whole fleet, re-applied by GE-Enforce every cycle. Rotating means editing that file and hoping - nothing reports which bays picked the new value up - and sites need per-bay variation, which a manifest scope cannot express because scopes are per PC TYPE. A scope per bay is a spreadsheet, not a model. ShopDB therefore owns the definitions and the targeting, and a PC receives a decided answer rather than rules to evaluate. Targeting uses the axes GE-Enforce already filters on plus those the asset model knows: hostname (including -like globs), machine, model, controller OS, PC type with alias expansion, GE-Enforce profile, and a selector over the stored DNC projection - "every machine whose DataHost is that share" is answerable from data already held, and it also answers which machines need a credential at all. Overlapping matches are guaranteed rather than exceptional, so precedence is explicit: an integer priority, then scope specificity, then rule id for stability. The resolver must be able to explain which rule won and why, or nobody will trust it. It reuses plugins/geenforce/filters.py; there are already two implementations of this matching logic kept honest by a parity harness, and a third would drift silently. Targeting stays separate from detection, mirroring PCTypes versus DetectionMethod: who gets a credential is a server question, whether it is already applied is a local one, and conflating them rewrites the secret every cycle instead of healing drift. Stored Fernet-encrypted with the key OUTSIDE the database, write-only through the API and masked on read, fetched with a dedicated credentials.fetch scope so a leaked collector key does not yield controller passwords, and every fetch audited. PROPOSED, not accepted: it makes ShopDB a credential store, and the key becomes a single point of loss - restore the database without it and every credential is unrecoverable, which docs/BACKUP-RESTORE.md must state in the same change that implements this. Registry-only provisioning is recommended as a first step regardless, since it removes the cleartext from the share immediately and the client helper is identical either way.
172 lines
7.5 KiB
Markdown
172 lines
7.5 KiB
Markdown
# ADR-016: Credential delivery to the fleet
|
|
|
|
- Status: PROPOSED
|
|
- 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:
|
|
|
|
```powershell
|
|
$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:
|
|
|
|
1. Each rule carries an integer `priority`. Higher wins. This is the only
|
|
thing an operator needs to reason about.
|
|
2. Ties break on scope specificity, in the order of the table above (hostname
|
|
most specific, site default least).
|
|
3. 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. `cryptography` is 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_password` already is. A
|
|
fleet PC receives a value; a human never reads one back.
|
|
- A dedicated `credentials.manage` permission for authoring.
|
|
|
|
### Delivery
|
|
|
|
```
|
|
GET /api/collector/credentials X-API-Key: <credentials.fetch token>
|
|
```
|
|
|
|
- A DEDICATED `credentials.fetch` scope, not `collector.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 /add` on 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.md` must
|
|
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.
|