From 91143d94fb3d53c749bd7defb5591d14479655b3 Mon Sep 17 00:00:00 2001 From: cproudlock Date: Tue, 11 Aug 2026 10:54:42 -0400 Subject: [PATCH] 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. --- CLAUDE.md | 1 + docs/adr/ADR-016-credential-delivery.md | 171 ++++++++++++++++++++++++ docs/adr/README.md | 1 + 3 files changed, 173 insertions(+) create mode 100644 docs/adr/ADR-016-credential-delivery.md diff --git a/CLAUDE.md b/CLAUDE.md index 587dad0..ee1f527 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 diff --git a/docs/adr/ADR-016-credential-delivery.md b/docs/adr/ADR-016-credential-delivery.md new file mode 100644 index 0000000..9bb4244 --- /dev/null +++ b/docs/adr/ADR-016-credential-delivery.md @@ -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: +``` + +- 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. diff --git a/docs/adr/README.md b/docs/adr/README.md index f745c77..ffef8cc 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -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