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