Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
81 changes: 81 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,29 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

- **Engine-side sensitive-output routing** (v0.27.0): `ResourceDriver` outputs flagged with
`Sensitive: {key: true}` on Create/Update are routed through the configured `secrets.Provider`
and replaced in state with `secret_ref://<resource>_<key>` placeholders. Plugins remain
platform-agnostic: a plugin compiled into a wfctl-from-CI run, a wfctl-from-CLI run, or a
workflow-cloud server transparently gets sensitive-output handling without each host writing
its own routing. Read paths (adoption, refresh) are sanitize-only — no `provider.Set` calls
from a Read path (prevents cache pollution). On `SaveResource` failure after `provider.Set`
succeeded, the engine compensates with `driver.Delete` + `provider.Delete` to prevent orphan
cloud resources + routed secrets. See `docs/plans/2026-05-09-engine-sensitive-output-routing-design.md`.
- **`iac/sensitive` package**: `Route(ctx, provider, resourceName, *out) (sanitized, hydrated, error)`,
`Revoke(ctx, provider, resourceName, mergedKeys) error`, `IsPlaceholder(v any) bool`,
`MaskSensitiveForDiff(driverKeys, desired, current) (map, map)`, `Placeholder(resource, key) string`,
`PlaceholderPrefix string` (`"secret_ref://"`), `SecretKey(resource, key) string`. Routing
trigger is exclusively per-call `out.Sensitive[k]==true`; `ResourceDriver.SensitiveKeys()`
remains a display-masking-only signal. Limitation: only string-typed sensitive output values
are supported in v0.27.0.
- **`wfctl infra audit-state-secrets`** (with `--prune`): walks state.Outputs vs.
`secrets.Provider` to detect orphans, missing routed values, legacy plaintext, and mistaken
`secret://...` config-references in state. Distinct from `audit-secrets` which audits the
`secrets.generate` config block. Exit codes: 0 = no findings, 1 = findings, 2 = audit error.
For write-only providers (GitHub Actions Get returns ErrUnsupported), emits structured
ADVISORY lines for each placeholder it cannot verify, but does not exit non-zero on those alone.

- **`interfaces.ResourceReplacer`**: optional driver interface for resources requiring
orchestration beyond naive Delete-then-Create (e.g., Droplets with attached Block Storage
Volumes). Drivers implementing `Replace(ctx, oldRef, spec) → (*ResourceOutput, error)` take
Expand Down Expand Up @@ -53,6 +76,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Changed

- **`applyWithProviderAndStore` and `applyPrecomputedPlanWithStore` signatures**: each gains
trailing `cfgFile string` and `hydratedOut map[string]string` parameters. Existing tests can
pass empty string and nil. The cfgFile is used to load the configured `secrets.Provider`
for sensitive-output routing; the hydratedOut map is filled by the helper so post-apply
consumers can rehydrate `secret_ref://` placeholders without going through `provider.Get`
(works on write-only providers like GitHub Actions).
- **`applyInfraModules` return signature**: now returns `(map[string]string, error)` where the
map is the hydrated routed-secret values. Callers update from `if err := applyInfraModules(...)`
to `if _, err := applyInfraModules(...)` (or capture the map for hand-off to
`syncInfraOutputSecrets`).
- **`syncInfraOutputSecrets` and `resolveInfraOutput` signatures**: each gains a trailing
`hydrated map[string]string` parameter for in-process routed-secret hand-off. Callers without
same-process apply context pass `nil`; sensitive placeholders then surface a documented
cold-start constraint error explaining the fallback path (`secret://<resource>_<key>` direct
reference).
- **`adoptExistingResources` and `runInfraRefreshOutputs`**: now route state writes through
`persistResourceWithSecretRouting` in read-mode (sanitize-only). Pre-existing
`secret_ref://...` placeholders are preserved across re-applies; newly-declared sensitive
keys on Read paths are dropped (not routed) to prevent cache pollution.

- **BREAKING (`wfctl infra plan`)**: configs declaring at least one `iac.provider` module now
require the plugin process to load successfully — `plan` invokes the same loader that `apply`
uses so `platform.ComputePlan` can dispatch `ResourceDriver.Diff` for honest Replace-action
Expand All @@ -77,6 +120,44 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
tags (additive — previously these fields serialised as `null` / `[]` in JSON; they are now
omitted entirely when empty, which is what most consumers expect).

### Migration (engine-side sensitive-output routing, v0.27.0)

- **Greenfield envs**: no action required. Plugins that opt into routing add
`Sensitive: {key: true}` to their `ResourceOutput` returns on Create/Update. Operators add a
`secrets:` block to their workflow config (recommend `provider: env` with a prefix for
local runs).
- **Pre-existing state with plaintext secrets**: run `wfctl infra audit-state-secrets` to
enumerate. Rotate via `wfctl infra bootstrap --force-rotate <name>` running v0.27.0 (the
rotation regenerates with engine-side routing).
- **Apply runs against plugins that newly emit `Sensitive` outputs without a `secrets:` block**:
the engine now hard-fails BEFORE the per-resource persistence loop with a named-resource
diagnostic. Add `secrets:` to your config to proceed.

### Rollback (engine-side sensitive-output routing, v0.27.0)

**Validation status:** mechanical analysis only. v0.26.0 source (verified at
git tag v0.26.0) has no awareness of `secret_ref://` prefix; the legacy
`Outputs: r.Outputs` path writes whatever the driver returned and reads
state values verbatim. So a v0.26.0 binary running against state written
by v0.27.0 will treat `secret_ref://...` strings as literal values
(documented in step 3 below). Live runtime smoke against a v0.26.0
binary is deferred to first-rollback-event; the test path is well-defined
and a stop-the-line gate.

To pin to v0.26.x:

1. Pin `setup-wfctl@v0.26.x` and rebuild.
2. State records written under v0.27.0 contain `secret_ref://...` placeholders. v0.26.x
consumers do not understand these and treat them as literal strings (e.g.,
`infra_output` generators copy the literal placeholder into a downstream secret).
3. Recovery: rotate the affected secrets via `wfctl infra bootstrap --force-rotate <name>`
running v0.27.0 first to regenerate plaintext state, OR manually edit the state record
(filesystem JSON) to inline the value from `secrets.Provider`.
4. The new package `iac/sensitive`, helper `persistResourceWithSecretRouting`, and
`audit-state-secrets` command are additive; reverting the call sites to v0.26.x literal
`Outputs: r.Outputs` shape is a one-commit revert of `infra_apply.go` +
`infra_refresh_outputs.go`.

## [0.18.11.1] - 2026-04-25

### Fixed
Expand Down
71 changes: 71 additions & 0 deletions DOCUMENTATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -1830,6 +1830,77 @@ The plugin is auto-registered via `init()` in `plugin/admincore/plugin.go`. No Y

---

## Sensitive output routing (v0.27.0+)

When a `ResourceDriver.Create` or `Update` returns a `*ResourceOutput` with
`Sensitive: {key: true}`, the engine routes those output values through the
configured `secrets.Provider` instead of writing them plaintext to state.
Plugins remain platform-agnostic: a plugin compiled into a wfctl-from-CI
run, a wfctl-from-CLI run, or a workflow-cloud server transparently gets
sensitive-output handling without each host writing its own routing.

**Routed secret naming:** `<resource_name>_<output_key>` (e.g., a resource
named `coredump-deploy-key` with `secret_key` output is stored under
`coredump-deploy-key_secret_key`).

**State placeholder:** Routed fields appear in `state.Outputs` as
`secret_ref://<resource>_<key>` strings. Distinct from the user-supplied
`secret://<key>` config-reference convention.

**Required configuration:** A `secrets:` block in your workflow config.
For local/ad-hoc runs, environment variables are the simplest option:

```yaml
secrets:
provider: env
config:
prefix: WORKFLOW_
```

If a plugin emits sensitive outputs but no `secrets.Provider` is configured,
apply hard-fails BEFORE the per-resource persistence loop with a named
resource and remediation. This prevents partial-apply mid-stream.

**Failure modes:**

- `secrets.Provider.Set` fails: apply errors; rerun is idempotent.
- `state.SaveResource` fails after `Set` succeeded: the engine compensates
by calling `driver.Delete` + `provider.Delete` (with a fresh 30s
context, so compensation runs even on Ctrl-C cancel) and surfaces a
combined error naming the compensation outcome.

**Read paths (refresh, adoption):** never call `provider.Set`. Placeholders
are inherited from prior state to avoid cache pollution. Newly-declared
sensitive keys on Read paths are dropped.

**Drift detection:** `iac/sensitive.MaskSensitiveForDiff` masks sensitive
keys from both desired and current sides before `driver.Diff`, preventing
false-positive drift on keys that the cloud refuses to re-emit (e.g., DO
Spaces `secret_key`). As of v0.27.0, no in-tree call site dispatches
`driver.Diff` against state.Outputs that may contain placeholders; the
helper is exported for future consumers.

**Cold-start consumers:** `secret_ref://` placeholders cannot be rehydrated
cross-process on write-only providers (GitHub Actions). Same-process
consumers (e.g., `infra_output:` generators in the same `wfctl infra apply`
invocation) get the routed value via in-memory hand-off. Cross-process
consumers reference the routed secret by name via `secret://<resource>_<key>`
directly.

**Limitation (v0.27.0):** only string-typed sensitive output values are
supported. Non-string sensitive outputs (e.g., `[]byte`, `int`) yield an
error from `Route`. Future expansion via a `MarshalSensitive` interface
is out of scope.

**Recovery:** `wfctl infra audit-state-secrets` audits state for orphan
secrets, missing routed values, legacy plaintext fields, and mistaken
config-references in state. Distinct from `audit-secrets` which audits
the `secrets.generate` config block. Run both as part of regular hygiene:

```sh
wfctl infra audit-state-secrets --config infra.yaml [--prune]
```

## IaC Provider Plugin Interfaces

Cloud-provider plugins implement the core `interfaces.IaCProvider` Go
Expand Down
23 changes: 20 additions & 3 deletions cmd/wfctl/infra.go
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,11 @@ func runInfra(args []string) error {
return runInfraPruneCmd(args[1:])
case "rotate-and-prune":
return runInfraRotateAndPruneCmd(args[1:])
case "audit-state-secrets":
if rc := runInfraAuditStateSecrets(args[1:], os.Stdout); rc != 0 {
return fmt.Errorf("audit-state-secrets exited with code %d", rc)
}
return nil
default:
return infraUsage()
}
Expand Down Expand Up @@ -123,6 +128,7 @@ Actions:
audit-keys List cloud-side resources of --type via the provider's EnumeratorAll
prune Destructively delete cloud resources by --created-before / --exclude-access-key (two-key opt-in)
rotate-and-prune All-in-one: rotate canonical credential, then prune older keys with the new key as exclusion target
audit-state-secrets Audit state.Outputs vs. secrets.Provider for orphans, legacy, missing

Options:
--config <file> Config file (default: infra.yaml or config/infra.yaml)
Expand Down Expand Up @@ -1119,6 +1125,13 @@ func platformNow() time.Time {
}

func runInfraApply(args []string) error {
// runHydrated carries routed-secret values from the same-process apply
// (sensitive.Route's hydrated map) to syncInfraOutputSecrets below.
// Empty when no driver emitted sensitive outputs; nil for the
// precomputed-plan branch unless threaded through. Required for
// rehydration on write-only providers (GitHub Actions secrets are
// write-only after Set).
var runHydrated map[string]string
Comment thread
intel352 marked this conversation as resolved.
fs := flag.NewFlagSet("infra apply", flag.ContinueOnError)
var configFlag string
fs.StringVar(&configFlag, "config", "", "Config file")
Expand Down Expand Up @@ -1388,9 +1401,11 @@ func runInfraApply(args []string) error {
if plan.DesiredHash != currentHash {
return fmt.Errorf("plan stale: config hash mismatch (run wfctl infra plan again)")
}
if err := applyFromPrecomputedPlan(ctx, plan, cfgFile, envName); err != nil {
h, err := applyFromPrecomputedPlan(ctx, plan, cfgFile, envName)
if err != nil {
return err
}
runHydrated = h
// Fall through to post-apply infra_output secrets sync below —
// same as the live-diff path so STAGING_DATABASE_URL and similar
// infra_output secrets are always refreshed after a successful apply.
Expand All @@ -1407,9 +1422,11 @@ func runInfraApply(args []string) error {
)
}
if hasInfraModules(cfgFile) {
if err := applyInfraModules(ctx, cfgFile, envName); err != nil {
h, err := applyInfraModules(ctx, cfgFile, envName)
if err != nil {
return err
}
runHydrated = h
} else {
pipelineCfg := cfgFile
if envName != "" {
Expand Down Expand Up @@ -1456,7 +1473,7 @@ func runInfraApply(args []string) error {
}
}
}
return syncInfraOutputSecrets(ctx, secretsCfg, secretsProvider, states, wfCfg, envName)
return syncInfraOutputSecrets(ctx, secretsCfg, secretsProvider, states, wfCfg, envName, runHydrated)
}

func runInfraStatus(args []string) error {
Expand Down
Loading
Loading