ADR-016: credential delivery to the fleet
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.
This commit is contained in:
@@ -25,6 +25,7 @@ Architecture decisions live in `docs/adr/`. Read those before making schema or c
|
||||
- ADR-013: Plugin catalog, curated shelf, and lean per-site builds - PROPOSED
|
||||
- ADR-014: Schema-lean per-site builds (retire cross-plugin FKs, prune not-installed plugin tables) - ACCEPTED
|
||||
- ADR-015: Where a site's own data is allowed to live (setting with a neutral default, site-namespaced dir, or seed) - ACCEPTED
|
||||
- ADR-016: Credential delivery to the fleet (ShopDB resolves targeting, encrypted at rest, dedicated fetch scope) - PROPOSED
|
||||
|
||||
## Coding convention
|
||||
|
||||
|
||||
171
docs/adr/ADR-016-credential-delivery.md
Normal file
171
docs/adr/ADR-016-credential-delivery.md
Normal file
@@ -0,0 +1,171 @@
|
||||
# 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.
|
||||
@@ -28,6 +28,7 @@ Each ADR captures a single architectural decision: the context, the decision its
|
||||
| [013](ADR-013-plugin-catalog-and-lean-builds.md) | Plugin catalog, curated shelf, and lean per-site builds | PROPOSED |
|
||||
| [014](ADR-014-schema-lean-per-site.md) | Schema-lean per-site builds (retire cross-plugin FKs, prune not-installed plugin tables) | ACCEPTED |
|
||||
| [015](ADR-015-site-specific-configuration.md) | Where a site's own data is allowed to live (setting, site directory, or seed) | ACCEPTED |
|
||||
| [016](ADR-016-credential-delivery.md) | Credential delivery to the fleet (ShopDB-resolved targeting, encrypted at rest) | PROPOSED |
|
||||
|
||||
## Authoring
|
||||
|
||||
|
||||
Reference in New Issue
Block a user