diff --git a/docs/dsl-reference.md b/docs/dsl-reference.md index 408c307a..4766a614 100644 --- a/docs/dsl-reference.md +++ b/docs/dsl-reference.md @@ -694,6 +694,91 @@ modules: - `{{config "key"}}` expressions are expanded in all `config` blocks across `modules`, `pipelines`, `workflows`, and `triggers` - Environment variable source reads `APP_DATABASE_URL` for key `database_url` when `prefix: "APP_"` +### Private Step Credentials + +`config.provider.config.step_credentials` explicitly authorizes private credential +delivery to an exact external step. This is an application-scoped grant, not a +tenant wildcard or automatic discovery of sensitive fields. + +The following is a **configuration excerpt, not a complete runnable application**. +It omits plugin loading and remaining capture configuration. Supply the named +environment variables through the operator's environment; no token belongs in +this YAML. The full native child-pipeline proof is in +[`engine_step_credentials_test.go`](../engine_step_credentials_test.go), with +results and limitations in the +[validation ledger](validation/2026-10-05-scoped-step-credentials.md). + +```yaml +modules: + - name: app-config + type: config.provider + config: + schema: + product_capture.compute_token: + env: PRODUCT_CAPTURE_COMPUTE_TOKEN + required: true + sensitive: true + server_url: + env: PRODUCT_CAPTURE_SERVER_URL + required: true + sources: + - type: defaults + - type: env + step_credentials: + - plugin: workflow-plugin-product-capture + step_type: step.product_capture + step_name: capture + field: auth_token_ref + ref: config:product_capture.compute_token + scope: application + +pipelines: + capture-child: + steps: + - name: capture + type: step.product_capture + config: + server_url: '{{ config "server_url" | default "" }}' + auth_token_ref: config:product_capture.compute_token + url_field: url +``` + +The grant matches the trusted manager-registered plugin identity, exact step type +and name, and a root configuration field containing exactly the literal `ref`. +The plugin/type names above are example consumer data, not provider-specific host +branches. Unknown grant fields, duplicate fields/references or ambiguous step +declarations, missing or templated references, mismatches, and unsupported scopes +or sources fail closed. +Only `scope: application` and literal `config:` references are supported; Vault, +tenant scope and wildcards are not. Neither `sensitive: true` nor schema metadata +authorizes delivery. + +A credentialed build requires exactly one `config.provider`. Use a separate +`configprovider.New()` instance per application; reuse after a credentialed build +is rejected. The transform loads and freezes one snapshot, used for expansion, +module registration, credential resolution and same-app runtime URL evaluation +in both Go templates and `${ config("server_url") }` expressions. `Init` does not +reload environment values. Private lookups never fall back to the compatibility +global registry; environment rotation requires rebuilding with a fresh instance. +Absent or empty grants preserve the existing no-grant behavior. + +Delivery uses only a fresh per-call legacy `ExecuteStepRequest.Config.config` +namespace, keyed by `product_capture.compute_token` in this example. Creation +retains the literal reference. No new credential carrier is placed in shared +metadata, pipeline input, outputs, environment or workload payloads. Granted +steps allow only an absent contract descriptor, `UNSPECIFIED` or `LEGACY_STRUCT`; +typed modes and unknown numeric modes reject before private lookup or RPC. +Credential echoes in returned strings/keys or errors are rejected, not rewritten +into otherwise legitimate consumer payloads, receipts or artifacts. + +Native plugins remain trusted: an authorized process can retain a received value. +Existing legacy global-registry and inherited-environment access is not removed; +this feature does not retrofit a process sandbox or protect against arbitrary +encoding by a malicious plugin. To roll back, withdraw `step_credentials` and +rebuild. The released v0.1.65 no-grant control stops with the original unresolved +reference and zero Compute requests, rather than using an input/metadata/env +fallback. + --- diff --git a/docs/validation/2026-10-05-scoped-step-credentials.md b/docs/validation/2026-10-05-scoped-step-credentials.md new file mode 100644 index 00000000..8a533ace --- /dev/null +++ b/docs/validation/2026-10-05-scoped-step-credentials.md @@ -0,0 +1,173 @@ +# Scoped Step Credentials Validation + +Date: 2026-10-05, source verification freeze06:41 UTC. Status: local source +verification complete; PR publication/CI are subsequent gates recorded in PR +metadata and the workspace checkpoint. No release, deployment or live-cloud claim. + +## Source And Provenance + +Branch: `fix/scoped-step-credentials-20261005`. +Baseline: `c5d53caa78226d0b079fb5ac3b244e19bd99d83c`. +Task1: `ba4ec152f26584c5347ee7cedc64900f3cd6d3f0`. +Task2: `e39801ee4bef3bf6b730da1a67daca65477ac44e`. +Task3 / Task4 starting HEAD: `be8ba74826f6b5c18be40bd477bb102cbfed59fe`. +Task4 source correction removes only the unused free `configTransformHook`; +the instance method and registered hook are unchanged. + +Receipts: implementer handoffs and corresponding reviews at +`/private/tmp/scoped-step-credentials-task{1,2,3}{,-review}-20261005.md`. +Rows below attribute prior executions to their owners, not to this doc writer. +Task1 review SHIP-IT; Task2 Important unknown-mode finding closed with an explicit +private allowlist (cycle2 SHIP-IT); Task3 SHIP-IT, no Important/Critical findings. + +## Behavioral Ledger + +| Owner / run | Exact outcome | What it proves | +| --- | --- | --- | +| T1 baseline ownership RED and module-global inverse RED | exit1, both ownership assertions fail | A/B source replacement and transform-to-Init snapshot divergence detected through actual application Init | +| T1 preflight inverse RED | exit1, template alias accepted assertion | Authorization must precede reference expansion | +| T1 validator RED | exit1, missing literal-validator assertion | Every bound root field needs immutable-reference validation | +| T1 restored required race GREEN | exit0; module2.554s, configprovider1.770s, interfaces2.704s (no selected tests) | Original source/validator paths restored | +| T1 expanded race GREEN | exit0; 57 test/subtest PASS lines, 0 FAIL, 0 selected skips | Exact/foreign targets, unknown fields/scopes/sources, malformed/missing/empty refs, duplicate/ambiguous grants, mutation/cancellation, private frozen source, service duplication, env snapshot and plugin reuse controls | +| T2 initial behavioral RED | exit1, 8 subtest failures | 3 missing carriers; 4 endpoint/global-fallback cases; Current.config spoof | +| T2 independent carrier / evaluator inverses | each exit1; 3 carrier / 5 endpoint-spoof-missing-key failures | Private carrier and same-app evaluation are independently necessary; both restored GREEN | +| T2 final required / expanded race before review correction | exit0; 185 / 205 test events PASS, 0 FAIL, 0 SKIP; 3 / 4 package PASS | Includes nested/escaped/marker-equal echoes, safe warnings, exact ordinary payloads, map immutability, nil/Stop/error paths and existing typed controls | +| T2 review correction, lead | 4 behavioral RED subtests, then restored expanded race GREEN | Numeric99/-99 reject at factory and Execute before lookup/Create/Execute; no-grant routing unchanged | +| T3 native first GREEN, session26046 | exit0, 6 cases, 24 authorized POSTs | Fresh SDK subprocesses through full host and nested YAML | +| T3 binding inverse, session2558 | exit1, 6 behavioral failures, 0 skips, 0 HTTP requests | Removing only binding produces genuine missing-carrier failure | +| T3 evaluator inverse, session72090 | exit1, 4 wrong-endpoint failures; 2 pure-expansion controls PASS | Removing only scoped evaluator sends first-built token to wrong endpoint; rejected POST, no authorized payload | +| T3 restored native, session77470 | exit0, package33.016s, 6 cases, 24 exact POSTs, 0 skips | Both build orders, interleaving and pure/composed Go/Expr URLs; A=2/B=2 per case | +| T3 actual released065, session10990 | exit0, package2.856s, 2 cases, 0 skips; granted1/no-grant0 requests | Real consumer403 Stop versus original unresolved-ref Stop | +| T3 lead fresh combined race, session94320 | exit0, package34.440s; native6/24 POSTs, actual0652/granted1/no-grant0 | Independent local acceptance; all26 PIDs reaped, IPC closed, listeners gone | +| Lead fresh no-body-logging guard | PASS (lead-supplied receipt) | Existing guard retained; not a new logging policy or manager retrofit | +| Lead full lint before T4, session45314 | RED: exactly unused free wrapper; untouched baseline full lint exit0 | Known introduced finding; post-removal full lint still Pending | +| T4 focused configprovider, session85478 | `go test ./plugins/configprovider -count=1 -timeout=90s`, exit0, package0.594s | Wrapper-only removal retains package behavior; gofmt listing empty and diff check exit0 | + +T1/T2 session IDs were not supplied in their implementer receipts; none are +invented here. T2 totals count parent/subtest events, not assertions. Its 15 +no-grant matrix cases cover five modes (nil, UNSPECIFIED, LEGACY_STRUCT, +PROTO_WITH_LEGACY_STRUCT, STRICT_PROTO) across nil app, no service and foreign +binding. Granted typed/unknown modes reject; absent/empty grants, ordinary and +zero-value/global evaluators, unrelated plugins and normal plugin reuse remain +controls. Credentialed reuse fails; separate credentialed apps retain their own +single frozen snapshot, including declared-env mutation before Init. + +Preliminary native session22557 was a sandbox listener denial, not behavioral RED. +Released sessions20706/40296 failed fixture provider-image/URL configuration, not +host RED; corrected fixture then passed session62627 and final10990. Task3 focused +lint sessions61052/99263 and vet91905 exited0; these are not full-repository gates. + +## Runtime Boundary And Hashes + +Actual local runtime: **Darwin/arm64 only**. Mandatory native test always builds +the committed SDK fixture and uses modular/StdEngine, real manager discovery/load, +BuildFromConfig/Start, parent -> response-child -> URL-only capture-child, +ExecutePipelineContext, Stop/Shutdown. Six cases make 24 genuine loopback POSTs; +72 unrelated native invocations receive no carrier. Workload JSON and returned +context/errors/logs are checked without publishing token/header/environment data. + +Released065 is separately opted in with `WORKFLOW_CAPTURE_V0165_PLUGIN_DIR`, +hash-checked before spawn with disk/runtime name/version/type checks; no download, +source rebuild or PATH fallback. Granted result is exactly +`POST /v1/tasks: got status 403`, Stop=true, one authorized request. Withdrawn +grant yields `config ref "product_capture.compute_token" not resolved`, Stop=true, +zero requests; the following continuation marker is absent in both cases. +Loopback Compute returning403 is the substituted dependency. **No task was +accepted, successful capture/live Amazon access or signed provider proof was +established**, and no worker/browser/artifact/proof activity followed. + +| SHA256 subject | Recorded digest | +| --- | --- | +| Unchanged released Darwin/arm64065 binary | `fbce8a8169aa740002e165fa3263e7322155ffa3d2da11e6bd44a507ddace775` | +| Native fixture binary in independent inverses and restored GREEN | `374b5c4e4bdde24c475258646a469bef08b2c248e1e900b41e7f6db2fb528dcb` | +| `engine_step_credentials_test.go` | `f84995a26920365c64a72ab15a52699d1bdf034baf5f9a0e421312a8851e3f8d` | +| `plugin/external/testdata/step-credentials-plugin/main.go` | `2b1cf873f8b57b2674643166f6f3ebe38e38c73fbb5f7fad3907e5e0767947c2` | + +Author restored/inverse runs each reaped24/24 children and closed12/12 loopback +listeners; final065 reaped2/2 and closed2/2 listeners. All recorded author sessions +finished. Lead fresh combined cleanup is separately recorded above. No manager, +SDK/proto, CI, dependency pin or native-process policy change is included. + +Optional065 without an external asset path is **SKIPPED / NOT EXECUTED**, never +065 PASS and never substituted by the mandatory native fixture. Hosted CI had not +run at this source freeze. Linux/Windows runtime and other released-asset platforms +are unverified. Nonblocking fixture limitation: the cached065 asset and test +TempDir must share a filesystem because setup hard-links the verified binary; +a cross-filesystem path fails setup (EXDEV), not credential acceptance. +Native plugins remain trusted and can retain values; legacy global and inherited +environment access is not retroactively sandboxed. Detection rejects known-value +echoes rather than rewriting legitimate payloads; arbitrary malicious encodings +are not a containment guarantee. + +## Final Lead Verification + +Ran sequentially with the offline local Go1.26.5 toolchain, `GOWORK=off`, +`GOENV=off`, `GOTOOLCHAIN=local`, `GOPROXY=off`, `GOSUMDB=off`, +`GOFLAGS=-mod=readonly`. Tests removed live DO/AWS/GitHub/Compute credential +variables and set `WFCTL_E2E_DNS_IMPORT=0`; only dummy values, local SDK processes +and owned loopback HTTP were used. Both full/final-focused tests explicitly set +`WORKFLOW_CAPTURE_V0165_PLUGIN_DIR` to the verified cached Darwin/arm64 asset. +Counts below are JSON test/subtest events, not assertions. + +Lead checkpoint: uncached full-race session21054 started around06:27; native +already PASS, 151 packages with0 failures reported, CLI still running. JSON: +`/private/tmp/scoped-step-credentials-full-tests-20261005.jsonl`. This is an +in-progress lead receipt, **not a completed full-race PASS**. Task4 did not +duplicate this run, full lint or review. + +| Gate | Status | +| --- | --- | +| `go test -race ./... -count=1 -timeout=20m -json` | session21054 exit0:152 package PASS,19 no-test packages,13327 test/subtest PASS,0 FAIL,58 SKIP; CLI540.343s | +| Final post-cleanup focused race, exact command below | session18056 exit0:7 package PASS,255 test/subtest PASS,0 FAIL,0 SKIP; native6/24 exact POSTs, released0652/granted1 versus ungranted0 | +| `go vet ./...` | session99655 exit0 | +| `go build ./...` | session24220 exit0 | +| `golangci-lint run --timeout=10m` | session26960 exit0,0 issues; introduced wrapper finding removed | +| `golangci-lint run --timeout=10m --new-from-rev=c5d53caa78226d0b079fb5ac3b244e19bd99d83c` | session13233 exit0,0 issues | +| `golangci-lint run --timeout=10m . ./plugin/external/testdata/step-credentials-plugin` | session58299 exit0,0 issues; includes normally wildcard-excluded fixture | +| Formatting/diff/scope audit | Changed Go files `gofmt -l` exit0/empty; `git diff --check` exit0; strict four-task/one-PR lock check PASS | +| Final independent source review | SHIP-IT; unchanged Task1-3 hashes and exact dead-helper/doc delta checked, no Important/Critical findings; no cosmetic review loop | +| Publication/CI | Not executed at this source freeze; exactly one source PR is required, no merge/release/deploy authority | + +```sh +go test -race . ./module ./plugins/configprovider ./interfaces ./plugin/external ./pipeline ./secrets \ + -run 'Test(EngineScopedCredentials|StepCredential|ConfigProvider|ConfigTransform|GlobalConfigRegistry|RemoteStep|ScopedConfigLookup|TemplateEngine|ExprEngine|NoBodyLoggingInterceptor|ExternalPluginAdapter|CreateTypedConfigRequest|Redactor)' \ + -count=1 -timeout=180s -json +``` + +Full race began after the final runtime changes and Task3 commit; the only later +source edit removed the unused unexported wrapper. Fresh post-cleanup focused +race/vet/build/full lint above validate that final delta. No behavior, test, +SDK/proto/manager/provider, dependency/release pin or CI changes followed. +Final native/065 cleanup assertions passed; native stdout contained zero dummy +canary matches. No owned verification sessions or fixture runtimes remain. +The lint executable was built with Go1.26.5 from the already cached +`github.com/golangci/golangci-lint/v2@v2.12.0` module; its version reports `devel`, +not an official release binary. Untouched c5d53caa full lint also exited0. + +### Full-Suite Skips + +These are not acceptance PASS, and no skip was added or broadened by this change. +Mandatory native and the explicitly supplied actual065 tests both passed locally. + +| Package | Skipped events | Existing reason/boundary | +| --- | --- | --- | +| Root examples | 29 | No-module configs or declared unsupported/missing example services/modules/plugins | +| `cmd/wfctl` | 6 | Two DO-plugin-dependent plan rows lack that plugin; old-plan downgrade baseline unconfigured; two live Docker tests not opted in; plan-index regeneration not requested | +| `iac/conformance` | 3 | Intentional skip-report controls and optional ProviderValidator absence | +| `module` | 5 | Three Docker CLI unavailable; two disposable PostgreSQL URLs absent | +| `pkg/k8s/imageloader` | 1 | Docker unavailable on the verification PATH | +| `sandbox` | 2 | Docker CLI unavailable on that PATH | +| `secrets` | 6 | Vault executable absent | +| `store` | 6 | `PG_URL` absent | + +No live Docker, DO provisioning/API acceptance, PostgreSQL or Vault integration +is inferred from those skipped rows. Generated local JSON receipts are retained +under `/private/tmp/scoped-step-credentials-{full-tests,final-focused}-20261005.jsonl`; +raw transcripts, environment values, bearer headers and consumer bodies are not +copied into public evidence. + +Operator grant/lifecycle/rollback guidance: +[Private Step Credentials](../dsl-reference.md#private-step-credentials). +Rollback withdraws grants and rebuilds (or reverts the single source PR), then +reruns no-grant/missing-ref controls. No token-in-input/metadata/env workaround; +callback, staging, browser and successful provider-proof acceptance stay separate. diff --git a/engine_step_credentials_test.go b/engine_step_credentials_test.go new file mode 100644 index 00000000..a2328d8f --- /dev/null +++ b/engine_step_credentials_test.go @@ -0,0 +1,703 @@ +package workflow_test + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/csv" + "encoding/json" + "errors" + "fmt" + "io" + "log" + "log/slog" + "net" + "net/http" + "net/http/httptest" + "os" + "os/exec" + "path/filepath" + "reflect" + "runtime" + "slices" + "strconv" + "strings" + "sync" + "testing" + "time" + + "github.com/GoCodeAlone/modular" + workflow "github.com/GoCodeAlone/workflow" + "github.com/GoCodeAlone/workflow/config" + "github.com/GoCodeAlone/workflow/interfaces" + "github.com/GoCodeAlone/workflow/module" + pluginpkg "github.com/GoCodeAlone/workflow/plugin" + "github.com/GoCodeAlone/workflow/plugin/external" + "github.com/GoCodeAlone/workflow/plugins/configprovider" + "github.com/GoCodeAlone/workflow/plugins/pipelinesteps" + "google.golang.org/grpc/connectivity" +) + +const ( + scopedFixtureName = "step-credentials-plugin" + scopedForeignName = "step-credentials-unrelated" + scopedCaptureName = "workflow-plugin-product-capture" + scopedCaptureSHA = "fbce8a8169aa740002e165fa3263e7322155ffa3d2da11e6bd44a507ddace775" + scopedKey = "product_capture.compute_token" + scopedProductURL = "https://example.invalid/products/credential-proof" +) + +type scopedObservation struct { + PID int `json:"pid"` + CarrierPresence bool `json:"carrier_presence"` +} + +type scopedChildOutput struct { + URL string `json:"url"` + PID int `json:"pid"` + CarrierPresence bool `json:"carrier_presence"` + Error string `json:"error"` + Continued bool `json:"continued"` +} + +type scopedCallOutput struct { + Result struct { + URL string `json:"url"` + Result scopedChildOutput `json:"result"` + } `json:"result"` +} + +type scopedURLPayload struct { + URL string `json:"url"` +} + +// Only the Compute HTTP dependency is substituted; both SDK/host sides are real. +type scopedTaskPayload struct { + Workload struct { + Kind string `json:"kind"` + Provider struct { + Operation string `json:"operation"` + Input scopedURLPayload `json:"input"` + } `json:"provider"` + } `json:"workload"` +} + +type scopedLog struct { + mu sync.Mutex + buf bytes.Buffer +} + +func (l *scopedLog) Write(p []byte) (int, error) { + l.mu.Lock() + defer l.mu.Unlock() + return l.buf.Write(p) +} + +func (l *scopedLog) snapshot() string { + l.mu.Lock() + defer l.mu.Unlock() + return l.buf.String() +} + +func scopedNoPrivateValue(t *testing.T, values []string, surface string, data []byte) { + t.Helper() + for _, value := range values { + if bytes.Contains(data, []byte(value)) { + t.Fatalf("private canary appeared in %s (value withheld)", surface) + } + } +} + +func scopedDecode(t *testing.T, data any, dst any) { + t.Helper() + b, err := json.Marshal(data) + if err != nil { + t.Fatal("could not encode proof output") + } + if err := json.Unmarshal(b, dst); err != nil { + t.Fatal("could not decode proof output") + } +} + +type scopedEndpoint struct { + label string + server *httptest.Server + mu sync.Mutex + count int + valid int +} + +func newScopedEndpoint(t *testing.T, label, token string, canaries []string, released bool) *scopedEndpoint { + t.Helper() + e := &scopedEndpoint{label: label} + e.server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + defer func() { + if err := r.Body.Close(); err != nil { + t.Error("loopback request body Close failed") + } + }() + body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20)) + path := "/submit" + if released { + path = "/v1/tasks" + } + valid := err == nil && r.Method == http.MethodPost && r.URL.RequestURI() == path && + "http://"+r.Host+path == e.server.URL+path && r.Header.Get("Authorization") == "Bearer "+token + for _, canary := range canaries { + valid = valid && !bytes.Contains(body, []byte(canary)) + } + if released { + var task scopedTaskPayload + valid = valid && json.Unmarshal(body, &task) == nil && task.Workload.Kind == "provider" && + task.Workload.Provider.Operation == "capture_product" && task.Workload.Provider.Input.URL == scopedProductURL + } else { + var payload scopedURLPayload + decoder := json.NewDecoder(bytes.NewReader(body)) + decoder.DisallowUnknownFields() + valid = valid && decoder.Decode(&payload) == nil && payload.URL == scopedProductURL && decoder.Decode(&struct{}{}) == io.EOF + } + e.mu.Lock() + e.count++ + if valid { + e.valid++ + } + e.mu.Unlock() + if !valid { + w.WriteHeader(http.StatusBadRequest) + return + } + if released { + w.WriteHeader(http.StatusForbidden) + return + } + w.WriteHeader(http.StatusNoContent) + })) + t.Cleanup(func() { + e.server.CloseClientConnections() + e.server.Close() + conn, err := net.DialTimeout("tcp", e.server.Listener.Addr().String(), 100*time.Millisecond) + if err == nil { + if err := conn.Close(); err != nil { + t.Error("listener probe connection Close failed") + } + t.Error("owned loopback listener survived Close") + } + e.mu.Lock() + defer e.mu.Unlock() + t.Logf("loopback app=%s final POST count=%d exact_endpoint_header_payload=%d listener_gone=%t", e.label, e.count, e.valid, err != nil) + }) + return e +} + +func (e *scopedEndpoint) assertCounts(t *testing.T, want int) { + t.Helper() + e.mu.Lock() + defer e.mu.Unlock() + if e.count != want || e.valid != want { + t.Fatalf("loopback app=%s POST count=%d exact_endpoint_header_payload=%d want=%d (headers/body withheld)", e.label, e.count, e.valid, want) + } + t.Logf("loopback app=%s POST count=%d exact_endpoint_header_payload=%d", e.label, e.count, e.valid) +} + +func scopedHash(t *testing.T, path string) string { + t.Helper() + f, err := os.Open(path) + if err != nil { + t.Fatal(err) + } + defer func() { + if err := f.Close(); err != nil { + t.Error("proof binary Close failed") + } + }() + h := sha256.New() + if _, err := io.Copy(h, f); err != nil { + t.Fatal(err) + } + return fmt.Sprintf("%x", h.Sum(nil)) +} + +func buildScopedFixture(t *testing.T) string { + t.Helper() + binary := filepath.Join(t.TempDir(), scopedFixtureName) + if runtime.GOOS == "windows" { + binary += ".exe" + } + ctx, cancel := context.WithTimeout(t.Context(), 90*time.Second) + defer cancel() + args := []string{"build"} + race := false + switch runtime.GOOS { + case "linux": + race = slices.Contains([]string{"amd64", "arm64", "loong64", "ppc64le", "riscv64", "s390x"}, runtime.GOARCH) + case "darwin": + race = runtime.GOARCH == "amd64" || runtime.GOARCH == "arm64" + case "freebsd", "netbsd", "windows": + race = runtime.GOARCH == "amd64" + } + if race { + args = append(args, "-race") + } + args = append(args, "-o", binary, "./plugin/external/testdata/step-credentials-plugin") + cmd := exec.CommandContext(ctx, "go", args...) + cmd.Env = append(os.Environ(), "GOWORK=off") + if output, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("build native SDK fixture: %v\n%s", err, output) + } + t.Logf("native fixture build race=%t sha256=%s", race, scopedHash(t, binary)) + return binary +} + +func scopedPluginRoot(t *testing.T, binary, name string, manifest []byte) string { + t.Helper() + root := t.TempDir() + dir := filepath.Join(root, name) + if err := os.Mkdir(dir, 0o700); err != nil { + t.Fatal(err) + } + // The manager discovers //. Keep that alias on Windows + // as well as the normal .exe file, without changing manager launch policy. + dest := filepath.Join(dir, name) + if runtime.GOOS == "windows" { + if err := os.Link(binary, dest+".exe"); err != nil { + t.Fatal(err) + } + } + if err := os.Link(binary, dest); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "plugin.json"), manifest, 0o600); err != nil { + t.Fatal(err) + } + return root +} + +func scopedFixtureRoot(t *testing.T, binary, name string) string { + t.Helper() + types := []string{"step.scoped_submit", "step.scoped_observe"} + if name == scopedForeignName { + types = []string{"step.scoped_foreign"} + } + manifest, err := json.Marshal(pluginpkg.PluginManifest{ + Name: name, Version: "0.1.0", Author: "workflow-tests", Description: "Native scoped step credential fixture", StepTypes: types, + }) + if err != nil { + t.Fatal(err) + } + root := scopedPluginRoot(t, binary, name, manifest) + if name == scopedFixtureName { + dir := filepath.Join(root, scopedForeignName) + if err := os.Mkdir(dir, 0o700); err != nil { + t.Fatal(err) + } + if err := os.Link(binary, filepath.Join(dir, scopedForeignName)); err != nil { + t.Fatal(err) + } + if runtime.GOOS == "windows" { + if err := os.Link(binary, filepath.Join(dir, scopedForeignName+".exe")); err != nil { + t.Fatal(err) + } + } + foreignManifest, err := json.Marshal(pluginpkg.PluginManifest{Name: scopedForeignName, Version: "0.1.0", Author: "workflow-tests", Description: "Unrelated native target", StepTypes: []string{"step.scoped_foreign"}}) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "plugin.json"), foreignManifest, 0o600); err != nil { + t.Fatal(err) + } + } + return root +} + +type scopedHost struct { + engine *workflow.StdEngine + manager *external.ExternalPluginManager + adapter *external.ExternalPluginAdapter + logs *scopedLog + canaries []string + pid int + foreign *external.ExternalPluginAdapter + foreignPID int +} + +func newScopedHost(t *testing.T, root, name string, canaries []string) *scopedHost { + t.Helper() + h := &scopedHost{logs: &scopedLog{}, canaries: canaries} + logger := slog.New(slog.NewTextHandler(h.logs, nil)) + app := modular.NewStdApplication(modular.NewStdConfigProvider(nil), logger) + h.engine = workflow.NewStdEngine(app, logger) + h.manager = external.NewExternalPluginManager(root, log.New(h.logs, "", 0)) + h.manager.SetProcessOutput(h.logs, h.logs) + t.Cleanup(func() { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if err := h.engine.Stop(ctx); err != nil { + scopedNoPrivateValue(t, canaries, "Stop error", []byte(err.Error())) + t.Error("engine Stop failed") + } + h.manager.Shutdown() + managerEmpty := len(h.manager.LoadedPlugins()) == 0 + if !managerEmpty { + t.Error("manager retained an owned process") + } + ipcClosed := h.adapter == nil || h.adapter.Conn().GetState() == connectivity.Shutdown + if !ipcClosed { + t.Error("owned plugin IPC connection survived Shutdown") + } + if h.foreign != nil && h.foreign.Conn().GetState() != connectivity.Shutdown { + ipcClosed = false + t.Error("unrelated owned plugin IPC survived Shutdown") + } + for _, pid := range []int{h.pid, h.foreignPID} { + if pid <= 0 { + continue + } + for scopedPIDAlive(t, pid) && ctx.Err() == nil { + time.Sleep(10 * time.Millisecond) + } + reaped := !scopedPIDAlive(t, pid) + if !reaped { + t.Error("owned plugin PID survived manager Shutdown/reap") + } + t.Logf("owned pid=%d reaped=%t ipc_closed=%t manager_empty=%t", pid, reaped, ipcClosed, managerEmpty) + } + transcript := h.logs.snapshot() + scopedNoPrivateValue(t, canaries, "host/plugin logs", []byte(transcript)) + for _, signature := range []string{"panic:", "runtime error:", "no such host", "module not found", "import error", "version mismatch", "incompatible api version", "schema drift", "missing column", "constraint violation", "permission denied", "address already in use", "goroutine ", "data race"} { + if strings.Contains(strings.ToLower(transcript), signature) { + t.Errorf("host/plugin failure signature=%q (transcript withheld)", signature) + } + } + t.Log("host/plugin failure-signature audit complete; raw transcript withheld") + }) + names, err := h.manager.DiscoverPlugins() + wantNames := []string{name} + if name == scopedFixtureName { + wantNames = append(wantNames, scopedForeignName) + } + if err != nil || !reflect.DeepEqual(names, wantNames) { + t.Fatal("manager did not discover the exact owned plugin") + } + h.adapter, err = h.manager.LoadPlugin(name) + if err != nil { + t.Fatalf("manager LoadPlugin failed: %v", err) + } + if err := h.adapter.ContractRegistryError(); err != nil { + t.Fatal("plugin contract discovery failed") + } + h.pid = scopedOwnedPID(t, filepath.Join(root, name, name)) + for _, p := range []pluginpkg.EnginePlugin{configprovider.New(), pipelinesteps.New(), h.adapter} { + if err := h.engine.LoadPlugin(p); err != nil { + t.Fatalf("engine LoadPlugin failed: %v", err) + } + } + if name == scopedFixtureName { + h.foreign, err = h.manager.LoadPlugin(scopedForeignName) + if err != nil { + t.Fatal("manager did not load the unrelated plugin") + } + h.foreignPID = scopedOwnedPID(t, filepath.Join(root, scopedForeignName, scopedForeignName)) + if err := h.engine.LoadPlugin(h.foreign); err != nil { + t.Fatal("engine did not load the unrelated plugin") + } + } + return h +} + +// These read-only, bounded checks inspect only a PID/path owned by this test. +func scopedOwnedPID(t *testing.T, binary string) int { + t.Helper() + if runtime.GOOS == "windows" { + // The native fixture reports its PID through a real SDK observation call. + return 0 + } + ctx, cancel := context.WithTimeout(t.Context(), 3*time.Second) + defer cancel() + output, err := exec.CommandContext(ctx, "ps", "-axo", "pid=,ppid=,args=").Output() + if err != nil { + t.Fatal("read-only owned-process inspection failed") + } + for _, line := range strings.Split(string(output), "\n") { + fields := strings.Fields(line) + if len(fields) >= 3 && fields[1] == strconv.Itoa(os.Getpid()) && strings.Join(fields[2:], " ") == binary { + pid, err := strconv.Atoi(fields[0]) + if err == nil && pid > 0 && pid != os.Getpid() { + return pid + } + } + } + t.Fatal("could not identify the exact manager-owned native process") + return 0 +} + +func scopedPIDAlive(t *testing.T, pid int) bool { + t.Helper() + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + if runtime.GOOS == "windows" { + output, err := exec.CommandContext(ctx, "tasklist", "/FI", fmt.Sprintf("PID eq %d", pid), "/FO", "CSV", "/NH").Output() + if err != nil { + t.Error("read-only owned-PID check failed") + return true + } + rows, _ := csv.NewReader(bytes.NewReader(output)).ReadAll() + for _, row := range rows { + if len(row) > 1 && row[1] == strconv.Itoa(pid) { + return true + } + } + return false + } + output, err := exec.CommandContext(ctx, "ps", "-p", strconv.Itoa(pid), "-o", "pid=").Output() + if ctx.Err() != nil { + t.Error("owned-PID check timed out") + return true + } + if err != nil { + var exitErr *exec.ExitError + if !errors.As(err, &exitErr) || exitErr.ExitCode() != 1 { + t.Error("read-only owned-PID check failed") + return true + } + } + return strings.TrimSpace(string(output)) != "" +} + +func scopedYAML(name, stepType, token, endpoint, expression string, grant, native bool) string { + grants := "" + if grant { + grants = fmt.Sprintf(` step_credentials: + - plugin: %s + step_type: %s + step_name: capture + field: auth_token_ref + ref: config:%s + scope: application +`, name, stepType, scopedKey) + } + observers := "" + after := "" + if native { + observers = ` - name: unrelated_before + type: step.scoped_observe + config: {} +` + after = ` - name: unrelated_after + type: step.scoped_observe + config: {} + - name: unrelated_plugin + type: step.scoped_foreign + config: {} +` + } + extraConfig := "" + urlConfig := " url: '{{ .url }}'\n" + if !native { + urlConfig = " url_field: url\n" + extraConfig = ` product_id: product-local + org_id: org-local + pool_id: pool-local + policy_id: policy-local + timeout_seconds: 5 + allowed_hosts: [example.invalid] + provider_image_ref: "example.invalid/credential-proof@sha256:0000000000000000000000000000000000000000000000000000000000000000" + request_timeout: 3s + wait_timeout: 3s +` + } + return fmt.Sprintf(`modules: + - name: settings + type: config.provider + config: + schema: + %s: {default: %q, sensitive: true} + server_url: {default: %q} + sources: [{type: defaults}] +%spipelines: + parent: + timeout: 10s + steps: +%s - name: response + type: step.workflow_call + config: + workflow: response-child + timeout: 5s + input: {url: '{{ .url }}'} +%s response-child: + timeout: 5s + steps: + - name: capture_child + type: step.workflow_call + config: + workflow: capture-child + timeout: 5s + input: {url: '{{ .url }}'} + capture-child: + timeout: 5s + steps: + - name: capture + type: %s + config: + server_url: %q + auth_token_ref: config:%s +%s%s - name: continued + type: step.set + config: {values: {continued: true}} +`, scopedKey, token, endpoint, grants, observers, after, stepType, expression, scopedKey, urlConfig, extraConfig) +} + +func (h *scopedHost) build(t *testing.T, yaml string) { + t.Helper() + cfg, err := config.LoadFromString(yaml) + if err != nil { + t.Fatal("config loader rejected proof YAML") + } + if err := h.engine.BuildFromConfig(cfg); err != nil { + scopedNoPrivateValue(t, h.canaries, "Build error", []byte(err.Error())) + t.Fatalf("BuildFromConfig: %v", err) + } + ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) + defer cancel() + if err := h.engine.Start(ctx); err != nil { + t.Fatal("engine Start failed") + } +} + +func (h *scopedHost) execute(t *testing.T) (*interfaces.PipelineContext, scopedChildOutput) { + t.Helper() + input := map[string]any{"url": scopedProductURL} + ctx, cancel := context.WithTimeout(t.Context(), 15*time.Second) + defer cancel() + pc, err := h.engine.ExecutePipelineContext(ctx, "parent", input) + if err != nil { + scopedNoPrivateValue(t, h.canaries, "execution error", []byte(err.Error())) + t.Fatalf("ExecutePipelineContext: %v", err) + } + b, err := json.Marshal(pc) + if err != nil { + t.Fatal("cannot inspect full pipeline context") + } + scopedNoPrivateValue(t, h.canaries, "Context/Metadata/StepOutputs/result", b) + scopedNoPrivateValue(t, h.canaries, "host environment", []byte(strings.Join(os.Environ(), "\n"))) + if !reflect.DeepEqual(input, map[string]any{"url": scopedProductURL}) { + t.Fatal("URL-only caller input was mutated") + } + var result scopedCallOutput + scopedDecode(t, pc.StepOutputs["response"], &result) + if result.Result.URL != scopedProductURL || result.Result.Result.URL != scopedProductURL { + t.Fatal("nested children did not receive the original URL-only input") + } + return pc, result.Result.Result +} + +func TestEngineScopedCredentialsNative(t *testing.T) { + binary := buildScopedFixture(t) + for _, syntax := range []struct{ name, expression string }{ + {"pure_expansion", `{{ config "server_url" }}`}, + {"composed_go", `{{ config "server_url" | default "" }}`}, + {"expression", `${ config("server_url") }`}, + } { + for _, reverse := range []bool{false, true} { + t.Run(fmt.Sprintf("%s/reverse_%t", syntax.name, reverse), func(t *testing.T) { + tokens := []string{"dummy-private-native-A-canary", "dummy-private-native-B-canary"} + endpoints := []*scopedEndpoint{newScopedEndpoint(t, "A", tokens[0], tokens, false), newScopedEndpoint(t, "B", tokens[1], tokens, false)} + hosts := []*scopedHost{ + newScopedHost(t, scopedFixtureRoot(t, binary, scopedFixtureName), scopedFixtureName, tokens), + newScopedHost(t, scopedFixtureRoot(t, binary, scopedFixtureName), scopedFixtureName, tokens), + } + order := []int{0, 1} + if reverse { + slices.Reverse(order) + } + for _, i := range order { + hosts[i].build(t, scopedYAML(scopedFixtureName, "step.scoped_submit", tokens[i], endpoints[i].server.URL, syntax.expression, true, true)) + } + last := order[1] + if got, ok := module.GetConfigRegistry().Get("server_url"); !ok || got != endpoints[last].server.URL { + t.Fatal("second build did not replace the compatibility mirror") + } + var registries [2]*module.ConfigRegistry + for i, h := range hosts { + if err := h.engine.App().GetService("config.registry", ®istries[i]); err != nil { + t.Fatal("application lost its private registry") + } + } + if registries[0] == registries[1] || registries[0] == module.GetConfigRegistry() || registries[1] == module.GetConfigRegistry() { + t.Fatal("applications did not retain separate private registries") + } + counts := [2]int{} + for _, i := range []int{order[0], order[1], order[0], order[1]} { + pc, result := hosts[i].execute(t) + var observer scopedObservation + scopedDecode(t, pc.StepOutputs["unrelated_before"], &observer) + if observer.CarrierPresence || observer.PID <= 0 || observer.PID == os.Getpid() { + t.Fatal("unrelated native target got a carrier or did not run in a child process") + } + if hosts[i].pid == 0 { + hosts[i].pid = observer.PID + } + if !result.CarrierPresence || result.PID != hosts[i].pid || observer.PID != result.PID || result.Error != "" || !result.Continued { + t.Fatal("credentialed native step did not consume the execution-private carrier") + } + var after, foreign scopedObservation + scopedDecode(t, pc.StepOutputs["unrelated_after"], &after) + scopedDecode(t, pc.StepOutputs["unrelated_plugin"], &foreign) + if hosts[i].foreignPID == 0 { + hosts[i].foreignPID = foreign.PID + } + if after.CarrierPresence || after.PID != result.PID || foreign.CarrierPresence || foreign.PID <= 0 || foreign.PID != hosts[i].foreignPID || foreign.PID == result.PID || foreign.PID == os.Getpid() { + t.Fatal("same-application unrelated target/plugin received a private carrier") + } + counts[i]++ + for j := range endpoints { + endpoints[j].assertCounts(t, counts[j]) + } + } + }) + } + } +} + +func TestEngineScopedCredentialsReleasedCapture065(t *testing.T) { + dir := os.Getenv("WORKFLOW_CAPTURE_V0165_PLUGIN_DIR") + if dir == "" { + t.Skip("optional released065 asset absent; set WORKFLOW_CAPTURE_V0165_PLUGIN_DIR for mandatory local acceptance") + } + if runtime.GOOS != "darwin" || runtime.GOARCH != "arm64" { + t.Fatal("opted-in released065 proof requires the pinned Darwin/arm64 asset") + } + binary := filepath.Join(dir, scopedCaptureName) + if got := scopedHash(t, binary); got != scopedCaptureSHA { + t.Fatal("released065 binary SHA256 mismatch; refusing to spawn") + } + manifest, err := os.ReadFile(filepath.Join(dir, "plugin.json")) + if err != nil { + t.Fatal(err) + } + var disk pluginpkg.PluginManifest + if err := json.Unmarshal(manifest, &disk); err != nil || disk.Name != scopedCaptureName || disk.Version != "0.1.65" || !slices.Contains(disk.StepTypes, "step.product_capture") { + t.Fatal("released065 disk manifest identity/version/step mismatch") + } + t.Logf("released065 verified before spawn sha256=%s manifest_version=%s", scopedCaptureSHA, disk.Version) + for _, grant := range []bool{true, false} { + t.Run(fmt.Sprintf("grant_%t", grant), func(t *testing.T) { + token := "dummy-private-released065-canary" + endpoint := newScopedEndpoint(t, "released065", token, []string{token}, true) + h := newScopedHost(t, scopedPluginRoot(t, binary, scopedCaptureName, manifest), scopedCaptureName, []string{token}) + if h.adapter.Name() != scopedCaptureName || h.adapter.Version() != "0.1.65" || !slices.Contains(h.engine.RegisteredStepTypes(), "step.product_capture") { + t.Fatal("released065 runtime identity/version/step mismatch") + } + h.build(t, scopedYAML(scopedCaptureName, "step.product_capture", token, endpoint.server.URL, `{{ config "server_url" }}`, grant, false)) + _, result := h.execute(t) + wantError, wantRequests := `config ref "product_capture.compute_token" not resolved`, 0 + if grant { + wantError, wantRequests = "POST /v1/tasks: got status 403", 1 + } + if result.Error != wantError || result.Continued { + t.Fatal("released065 did not return the exact expected Stop/error (output withheld)") + } + endpoint.assertCounts(t, wantRequests) + t.Logf("released065 runtime_version=%s grant=%t status=%q Stop=true", h.adapter.Version(), grant, result.Error) + }) + } +} diff --git a/interfaces/step_credentials.go b/interfaces/step_credentials.go new file mode 100644 index 00000000..6022c36b --- /dev/null +++ b/interfaces/step_credentials.go @@ -0,0 +1,33 @@ +package interfaces + +import "context" + +// StepCredentialTarget uses the host's manager registration, not plugin metadata. +type StepCredentialTarget struct { + Plugin, StepType, StepName string +} + +// StepCredentialGrant is explicit operator authority for one literal root field. +type StepCredentialGrant struct { + Plugin string `json:"plugin"` + StepType string `json:"step_type"` + StepName string `json:"step_name"` + Field string `json:"field"` + Ref string `json:"ref"` + Scope string `json:"scope"` +} + +type ResolvedStepCredential struct { + Ref, Value string +} + +// BoundStepCredentials is host-only and must never enter serialized config. +type BoundStepCredentials struct { + ConfigLookup func(string) (string, bool) + ValidateConfig func(map[string]any) error + Resolve func(context.Context) ([]ResolvedStepCredential, error) +} + +type StepCredentialBinder interface { + BindStep(StepCredentialTarget, map[string]any) (*BoundStepCredentials, error) +} diff --git a/module/config_provider.go b/module/config_provider.go index a92a3915..ef842ccd 100644 --- a/module/config_provider.go +++ b/module/config_provider.go @@ -10,6 +10,7 @@ import ( "sync" "github.com/GoCodeAlone/modular" + "github.com/GoCodeAlone/workflow/interfaces" "github.com/GoCodeAlone/workflow/pipeline" ) @@ -61,6 +62,56 @@ func NewConfigRegistry() *ConfigRegistry { } } +// MirrorConfigRegistry publishes a completed snapshot for legacy readers only. +func MirrorConfigRegistry(registry *ConfigRegistry) { + registry.mu.RLock() + values := make(map[string]string, len(registry.values)) + sensitive := make(map[string]bool, len(registry.sensitive)) + for k, v := range registry.values { + values[k] = v + } + for k, v := range registry.sensitive { + sensitive[k] = v + } + frozen := registry.frozen + registry.mu.RUnlock() + globalConfigRegistry.mu.Lock() + globalConfigRegistry.values, globalConfigRegistry.sensitive, globalConfigRegistry.frozen = values, sensitive, frozen + globalConfigRegistry.mu.Unlock() +} + +// LoadConfigProvider reuses the provider's schema/source validation in both paths. +func LoadConfigProvider(registry *ConfigRegistry, cfg map[string]any) error { + schemaRaw, ok := cfg["schema"].(map[string]any) + if !ok { + return fmt.Errorf("config.provider requires a 'schema' section") + } + schema, err := ParseSchema(schemaRaw) + if err != nil { + return fmt.Errorf("invalid schema: %w", err) + } + sourcesRaw, ok := cfg["sources"] + if !ok { + return fmt.Errorf("config.provider requires a 'sources' section") + } + sourcesSlice, ok := sourcesRaw.([]any) + if !ok { + return fmt.Errorf("'sources' must be a list") + } + sources := make([]map[string]any, 0, len(sourcesSlice)) + for _, s := range sourcesSlice { + source, ok := s.(map[string]any) + if !ok { + return fmt.Errorf("each source must be a map") + } + sources = append(sources, source) + } + if err := LoadConfigSources(registry, sources, schema); err != nil { + return fmt.Errorf("loading config sources: %w", err) + } + return ValidateRequired(registry, schema) +} + // Set stores a value in the registry. Returns an error if the registry is frozen. func (r *ConfigRegistry) Set(key, value string, sensitive bool) error { r.mu.Lock() @@ -265,9 +316,20 @@ func expandConfigRefsSlice(registry *ConfigRegistry, items []any) { // build time via the ConfigTransformHook. The module exists to hold the config // registry reference for service discovery. type ConfigProviderModule struct { - name string - config map[string]any - registry *ConfigRegistry + name string + config map[string]any + registry *ConfigRegistry + binder interfaces.StepCredentialBinder + privateErr error +} + +// NewConfigProviderModuleWithCredentials takes the exact transform snapshot. +func NewConfigProviderModuleWithCredentials(name string, registry *ConfigRegistry, grants []interfaces.StepCredentialGrant) *ConfigProviderModule { + binder, err := NewStepCredentialBinder(registry, grants) + if len(grants) == 0 && err == nil { + err = fmt.Errorf("private config provider requires grants") + } + return &ConfigProviderModule{name: name, registry: registry, binder: binder, privateErr: err} } // NewConfigProviderModule creates a new ConfigProviderModule. @@ -287,6 +349,20 @@ func (m *ConfigProviderModule) Dependencies() []string { return nil } // Init registers the config registry as a service in the application. func (m *ConfigProviderModule) Init(app modular.Application) error { + if m.privateErr != nil { + return m.privateErr + } + if m.binder != nil { + if _, exists := app.GetServiceEntry(StepCredentialsService); exists { + return fmt.Errorf("step credentials service already registered") + } + if _, exists := app.GetServiceEntry("config.registry"); exists { + return fmt.Errorf("private config registry already registered") + } + if err := app.RegisterService(StepCredentialsService, m.binder); err != nil { + return err + } + } return app.RegisterService("config.registry", m.registry) } diff --git a/module/config_provider_test.go b/module/config_provider_test.go index 00f7b431..f64f84b4 100644 --- a/module/config_provider_test.go +++ b/module/config_provider_test.go @@ -4,6 +4,8 @@ import ( "os" "sort" "testing" + + "github.com/GoCodeAlone/workflow/interfaces" ) // --- ConfigRegistry tests --- @@ -27,6 +29,63 @@ func TestConfigRegistryGetMissing(t *testing.T) { } } +func TestConfigProviderPrivateRegistration(t *testing.T) { + r := NewConfigRegistry() + _ = r.Set("compute.token", "dummy", false) + r.Freeze() + grants := []interfaces.StepCredentialGrant{credentialGrant()} + m := NewConfigProviderModuleWithCredentials("settings", r, grants) + app := CreateIsolatedApp(t) + if err := m.Init(app); err != nil { + t.Fatal(err) + } + var got *ConfigRegistry + if err := app.GetService("config.registry", &got); err != nil || got != r { + t.Fatal("snapshot pointer not preserved") + } + if err := NewConfigProviderModuleWithCredentials("other", r, grants).Init(app); err == nil { + t.Fatal("duplicate service accepted") + } + if err := NewConfigProviderModuleWithCredentials("bad", nil, grants).Init(CreateIsolatedApp(t)); err == nil { + t.Fatal("nil source accepted") + } + if err := NewConfigProviderModuleWithCredentials("empty", r, nil).Init(CreateIsolatedApp(t)); err == nil { + t.Fatal("empty private grants accepted") + } + normal := NewConfigProviderModule("normal", map[string]any{}) + if normal.Registry() != GetConfigRegistry() { + t.Fatal("default constructor changed") + } + normalApp := CreateIsolatedApp(t) + if err := normal.Init(normalApp); err != nil { + t.Fatal(err) + } + if _, exists := normalApp.GetServiceEntry(StepCredentialsService); exists { + t.Fatal("normal constructor authorized credentials") + } +} + +func TestGlobalConfigRegistryCompletedMirror(t *testing.T) { + t.Cleanup(GetConfigRegistry().Reset) + r := NewConfigRegistry() + _ = r.Set("compute.token", "dummy-private", true) + r.Freeze() + MirrorConfigRegistry(r) + if v, ok := GetConfigRegistry().Get("compute.token"); !ok || v != "dummy-private" { + t.Fatal("mirror missing value") + } + if !GetConfigRegistry().IsSensitive("compute.token") { + t.Fatal("mirror lost sensitivity") + } + if err := GetConfigRegistry().Set("other", "value", false); err == nil { + t.Fatal("mirror not frozen") + } + GetConfigRegistry().Reset() + if v, ok := r.Get("compute.token"); !ok || v != "dummy-private" { + t.Fatal("mirror reset changed private source") + } +} + func TestConfigRegistrySensitive(t *testing.T) { r := NewConfigRegistry() _ = r.Set("secret", "s3cr3t", true) diff --git a/module/scoped_step_credentials.go b/module/scoped_step_credentials.go new file mode 100644 index 00000000..a77239c7 --- /dev/null +++ b/module/scoped_step_credentials.go @@ -0,0 +1,132 @@ +package module + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "regexp" + "strings" + + "github.com/GoCodeAlone/workflow/interfaces" +) + +const StepCredentialsService = "step.credentials" + +var credentialFieldPattern = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`) +var credentialKeyPattern = regexp.MustCompile(`^[A-Za-z0-9_][A-Za-z0-9_-]*(\.[A-Za-z0-9_][A-Za-z0-9_-]*)*$`) + +// DecodeStepCredentialGrants deliberately ignores schema sensitivity and names. +func DecodeStepCredentialGrants(raw any) ([]interfaces.StepCredentialGrant, error) { + data, err := json.Marshal(raw) + if err != nil || bytes.Equal(data, []byte("null")) { + return nil, errors.New("invalid step credential grants") + } + var grants []interfaces.StepCredentialGrant + d := json.NewDecoder(bytes.NewReader(data)) + d.DisallowUnknownFields() + if err := d.Decode(&grants); err != nil { + return nil, errors.New("invalid step credential grants") + } + if err := validateStepCredentialGrants(grants); err != nil { + return nil, err + } + return grants, nil +} + +func validateStepCredentialGrants(grants []interfaces.StepCredentialGrant) error { + type identity struct{ stepType, stepName, field string } + type reference struct{ stepType, stepName, ref string } + fields := make(map[identity]bool) + refs := make(map[reference]bool) + for _, g := range grants { + for _, name := range []string{g.Plugin, g.StepType, g.StepName} { + if name == "" || strings.ContainsAny(name, " \t\r\n{}$") { + return errors.New("invalid step credential target") + } + } + if g.Scope != "application" || !credentialFieldPattern.MatchString(g.Field) || + !strings.HasPrefix(g.Ref, "config:") || !credentialKeyPattern.MatchString(strings.TrimPrefix(g.Ref, "config:")) { + return errors.New("invalid step credential grant") + } + f := identity{g.StepType, g.StepName, g.Field} + r := reference{g.StepType, g.StepName, g.Ref} + if fields[f] || refs[r] { + return errors.New("duplicate or ambiguous step credential grants") + } + fields[f], refs[r] = true, true + } + return nil +} + +type stepCredentialBinder struct { + registry *ConfigRegistry + grants []interfaces.StepCredentialGrant +} + +func NewStepCredentialBinder(registry *ConfigRegistry, grants []interfaces.StepCredentialGrant) (interfaces.StepCredentialBinder, error) { + if registry == nil || registry == globalConfigRegistry { + return nil, errors.New("step credentials require a private frozen registry") + } + registry.mu.RLock() + frozen := registry.frozen + registry.mu.RUnlock() + if !frozen { + return nil, errors.New("step credentials require a private frozen registry") + } + if err := validateStepCredentialGrants(grants); err != nil { + return nil, err + } + return &stepCredentialBinder{registry: registry, grants: append([]interfaces.StepCredentialGrant(nil), grants...)}, nil +} + +func (b *stepCredentialBinder) BindStep(target interfaces.StepCredentialTarget, cfg map[string]any) (*interfaces.BoundStepCredentials, error) { + var bound []interfaces.StepCredentialGrant + for _, g := range b.grants { + if g.Plugin != target.Plugin || g.StepType != target.StepType || g.StepName != target.StepName { + continue + } + literal, ok := cfg[g.Field].(string) + if !ok || literal != g.Ref { + return nil, errors.New("step credential literal does not match grant") + } + // Strings and grants are copied; the caller's config map is not retained. + g.Ref = literal + bound = append(bound, g) + } + if len(bound) == 0 { + return nil, nil + } + return &interfaces.BoundStepCredentials{ + ConfigLookup: b.registry.Get, + ValidateConfig: func(cfg map[string]any) error { + for _, g := range bound { + literal, ok := cfg[g.Field].(string) + if !ok || literal != g.Ref { + return errors.New("step credential literal does not match grant") + } + } + return nil + }, + Resolve: func(ctx context.Context) ([]interfaces.ResolvedStepCredential, error) { + if ctx == nil { + return nil, errors.New("step credential context is required") + } + if err := ctx.Err(); err != nil { + return nil, err + } + resolved := make([]interfaces.ResolvedStepCredential, 0, len(bound)) + for _, g := range bound { + value, ok := b.registry.Get(strings.TrimPrefix(g.Ref, "config:")) + if !ok || value == "" { + return nil, errors.New("step credential value is unavailable") + } + resolved = append(resolved, interfaces.ResolvedStepCredential{Ref: g.Ref, Value: value}) + } + if err := ctx.Err(); err != nil { + return nil, err + } + return resolved, nil + }, + }, nil +} diff --git a/module/scoped_step_credentials_test.go b/module/scoped_step_credentials_test.go new file mode 100644 index 00000000..2f3c008a --- /dev/null +++ b/module/scoped_step_credentials_test.go @@ -0,0 +1,197 @@ +package module + +import ( + "context" + "errors" + "testing" + + "github.com/GoCodeAlone/workflow/interfaces" +) + +func credentialGrant() interfaces.StepCredentialGrant { + return interfaces.StepCredentialGrant{Plugin: "test-plugin", StepType: "step.capture", StepName: "capture", Field: "auth_token_ref", Ref: "config:compute.token", Scope: "application"} +} + +func TestStepCredentialGrants(t *testing.T) { + t.Cleanup(GetConfigRegistry().Reset) + GetConfigRegistry().Reset() + _ = GetConfigRegistry().Set("compute.token", "dummy-global-fallback", true) + _ = GetConfigRegistry().Set("missing", "dummy-global-fallback", true) + GetConfigRegistry().Freeze() + g := credentialGrant() + t.Run("strict decode", func(t *testing.T) { + valid := map[string]any{"plugin": g.Plugin, "step_type": g.StepType, "step_name": g.StepName, "field": g.Field, "ref": g.Ref, "scope": g.Scope} + if grants, err := DecodeStepCredentialGrants([]any{valid}); err != nil || len(grants) != 1 || grants[0] != g { + t.Fatal("valid grant rejected") + } + valid["sensitive"] = true + if _, err := DecodeStepCredentialGrants([]any{valid}); err == nil { + t.Fatal("unknown field accepted") + } + for _, raw := range []any{nil, "not a list", map[string]any{}, []any{nil}, []any{"bad"}, []any{map[string]any{"plugin": 1}}, []any{g, g}} { + if _, err := DecodeStepCredentialGrants(raw); err == nil { + t.Fatal("malformed grant accepted") + } + } + if grants, err := DecodeStepCredentialGrants([]any{}); err != nil || len(grants) != 0 { + t.Fatal("empty grants rejected") + } + }) + t.Run("invalid authority", func(t *testing.T) { + for _, change := range []func(*interfaces.StepCredentialGrant){ + func(g *interfaces.StepCredentialGrant) { g.Plugin = "" }, + func(g *interfaces.StepCredentialGrant) { g.StepType = "" }, + func(g *interfaces.StepCredentialGrant) { g.StepName = "" }, + func(g *interfaces.StepCredentialGrant) { g.Scope = "tenant" }, + func(g *interfaces.StepCredentialGrant) { g.Scope = "" }, + func(g *interfaces.StepCredentialGrant) { g.Field = "nested.ref" }, + func(g *interfaces.StepCredentialGrant) { g.Ref = "secret:key" }, + func(g *interfaces.StepCredentialGrant) { g.Ref = "" }, + func(g *interfaces.StepCredentialGrant) { g.Ref = "config:" }, + func(g *interfaces.StepCredentialGrant) { g.Ref = "config:bad..key" }, + func(g *interfaces.StepCredentialGrant) { g.Ref = "config: key" }, + func(g *interfaces.StepCredentialGrant) { g.Ref = `{{config "alias"}}` }, + func(g *interfaces.StepCredentialGrant) { g.Ref = `${ config("alias") }` }, + } { + bad := g + change(&bad) + if _, err := DecodeStepCredentialGrants([]interfaces.StepCredentialGrant{bad}); err == nil { + t.Fatal("invalid authority accepted") + } + } + for _, change := range []func(*interfaces.StepCredentialGrant){ + func(g *interfaces.StepCredentialGrant) { g.Ref = "config:other" }, + func(g *interfaces.StepCredentialGrant) { g.Plugin = "other-plugin" }, + func(g *interfaces.StepCredentialGrant) { g.Field = "another_ref" }, + } { + bad := g + change(&bad) + if _, err := DecodeStepCredentialGrants([]interfaces.StepCredentialGrant{g, bad}); err == nil { + t.Fatal("ambiguous grants accepted") + } + } + }) + r := NewConfigRegistry() + _ = r.Set("compute.token", "dummy-private", false) + _ = r.Set("server_url", "http://app-a", false) + _ = r.Set("ungranted_secret", "dummy-other", true) + r.Freeze() + grants := []interfaces.StepCredentialGrant{g} + binder, err := NewStepCredentialBinder(r, grants) + if err != nil { + t.Fatal(err) + } + grants[0].Ref = "config:ungranted_secret" + target := interfaces.StepCredentialTarget{Plugin: g.Plugin, StepType: g.StepType, StepName: g.StepName} + t.Run("exact target copied ref", func(t *testing.T) { + cfg := map[string]any{g.Field: g.Ref} + bound, err := binder.BindStep(target, cfg) + if err != nil || bound == nil { + t.Fatal("binding failed") + } + cfg[g.Field] = "config:ungranted_secret" + if err := bound.ValidateConfig(cfg); err == nil { + t.Fatal("raw mutation accepted by validator") + } + resolved, err := bound.Resolve(context.Background()) + if err != nil || len(resolved) != 1 || resolved[0].Ref != g.Ref || resolved[0].Value != "dummy-private" { + t.Fatal("binding did not retain exact original grant") + } + resolved[0].Value = "mutated" + again, err := bound.Resolve(context.Background()) + if err != nil || again[0].Value != "dummy-private" { + t.Fatal("result mutation affected binding") + } + if value, ok := bound.ConfigLookup("server_url"); !ok || value != "http://app-a" { + t.Fatal("wrong config lookup") + } + if _, ok := bound.ConfigLookup("missing"); ok { + t.Fatal("unexpected missing value") + } + ctx, cancel := context.WithCancel(context.Background()) + cancel() + if values, err := bound.Resolve(ctx); !errors.Is(err, context.Canceled) || values != nil { + t.Fatal("cancellation did not fail closed") + } + if values, err := bound.Resolve(nil); err == nil || values != nil { + t.Fatal("nil context accepted") + } + }) + t.Run("foreign targets", func(t *testing.T) { + for _, foreign := range []interfaces.StepCredentialTarget{ + {Plugin: "other", StepType: g.StepType, StepName: g.StepName}, + {Plugin: g.Plugin, StepType: "other", StepName: g.StepName}, + {Plugin: g.Plugin, StepType: g.StepType, StepName: "other"}, + } { + if bound, err := binder.BindStep(foreign, nil); err != nil || bound != nil { + t.Fatal("foreign target got binding") + } + } + }) + t.Run("wrong construction refs", func(t *testing.T) { + for _, value := range []any{nil, 1, "", "config:other", `{{config "alias"}}`, `${ config("alias") }`} { + if bound, err := binder.BindStep(target, map[string]any{g.Field: value}); err == nil || bound != nil { + t.Fatal("wrong ref accepted") + } + } + }) + t.Run("validate all bound literals", func(t *testing.T) { + second := g + second.Field, second.Ref = "other_ref", "config:other.token" + b, err := NewStepCredentialBinder(r, []interfaces.StepCredentialGrant{g, second}) + if err != nil { + t.Fatal(err) + } + cfg := map[string]any{g.Field: g.Ref, second.Field: second.Ref} + bound, err := b.BindStep(target, cfg) + if err != nil || bound == nil || bound.ValidateConfig == nil { + t.Fatal("binding must expose literal validator") + } + if err := bound.ValidateConfig(cfg); err != nil { + t.Fatal("original literals rejected") + } + for _, field := range []string{g.Field, second.Field} { + for _, bad := range []any{nil, 1, "", "config:ungranted_secret", `{{config "alias"}}`, `${ config("alias") }`} { + changed := map[string]any{g.Field: g.Ref, second.Field: second.Ref} + changed[field] = bad + if err := bound.ValidateConfig(changed); err == nil || err.Error() != "step credential literal does not match grant" { + t.Fatal("changed literal did not fail with safe error") + } + } + } + if err := bound.ValidateConfig(nil); err == nil { + t.Fatal("missing fields accepted") + } + delete(cfg, g.Field) + if err := bound.ValidateConfig(cfg); err == nil { + t.Fatal("deleted field accepted") + } + }) + t.Run("missing and empty source", func(t *testing.T) { + for _, empty := range []bool{false, true} { + source := NewConfigRegistry() + if empty { + _ = source.Set("compute.token", "", false) + } + source.Freeze() + b, err := NewStepCredentialBinder(source, []interfaces.StepCredentialGrant{g}) + if err != nil { + t.Fatal(err) + } + bound, err := b.BindStep(target, map[string]any{g.Field: g.Ref}) + if err != nil { + t.Fatal(err) + } + if values, err := bound.Resolve(context.Background()); err == nil || values != nil { + t.Fatal("missing/empty source accepted") + } + } + }) + t.Run("private frozen source required", func(t *testing.T) { + for _, source := range []*ConfigRegistry{nil, GetConfigRegistry(), NewConfigRegistry()} { + if _, err := NewStepCredentialBinder(source, []interfaces.StepCredentialGrant{g}); err == nil { + t.Fatal("invalid source accepted") + } + } + }) +} diff --git a/pipeline/expr.go b/pipeline/expr.go index bae631eb..35522751 100644 --- a/pipeline/expr.go +++ b/pipeline/expr.go @@ -18,13 +18,21 @@ import ( // ${ steps["parse"]["id"] } – hyphenated step names // ${ x == "active" && y > 5 } – boolean / comparison // ${ "Hello " + body.name } – string concat -type ExprEngine struct{} +type ExprEngine struct { + configLookup func(string) (string, bool) + scopedConfig bool +} // NewExprEngine creates a new ExprEngine. func NewExprEngine() *ExprEngine { return &ExprEngine{} } +// NewExprEngineWithConfigLookup uses a private, non-shadowable config helper. +func NewExprEngineWithConfigLookup(lookup func(string) (string, bool)) *ExprEngine { + return &ExprEngine{configLookup: lookup, scopedConfig: true} +} + // exprEnv builds the evaluation environment from a PipelineContext, // merging data fields and all registered functions. func (e *ExprEngine) exprEnv(pc *interfaces.PipelineContext) map[string]any { @@ -45,6 +53,9 @@ func (e *ExprEngine) exprEnv(pc *interfaces.PipelineContext) map[string]any { // Current merged state at top level (overrides same-named functions). maps.Copy(env, pc.Current) + if e.scopedConfig { + env["config"] = configLookupFunc(e.configLookup) + } // Named namespaces always win — they override same-named data fields so that // steps["x"], trigger, body, meta are always namespace references. diff --git a/pipeline/expr_lookup_test.go b/pipeline/expr_lookup_test.go new file mode 100644 index 00000000..52a912f8 --- /dev/null +++ b/pipeline/expr_lookup_test.go @@ -0,0 +1,39 @@ +package pipeline + +import ( + "testing" + + "github.com/GoCodeAlone/workflow/interfaces" +) + +func TestScopedConfigLookupExpr(t *testing.T) { + previous := ConfigLookup + t.Cleanup(func() { ConfigLookup = previous }) + ConfigLookup = func(string) (string, bool) { return "global-b", true } + lookup := func(key string) (string, bool) { return "private-a", key == "url" } + pc := &interfaces.PipelineContext{Current: map[string]any{}} + for _, engine := range []*ExprEngine{NewExprEngine(), {}} { + got, err := engine.Evaluate(`config("url")`, pc) + if err != nil || got != "global-b" { + t.Fatal("global/zero expression behavior changed") + } + } + pc.Current["config"] = func(string) string { return "spoof" } + got, err := NewExprEngine().Evaluate(`config("url")`, pc) + if err != nil || got != "spoof" { + t.Fatal("legacy Current function precedence changed") + } + for _, spoof := range []any{"spoof", func(string) string { return "spoof" }, nil} { + pc.Current["config"] = spoof + got, err := NewExprEngineWithConfigLookup(lookup).Evaluate(`config("url") + "/tasks"`, pc) + if err != nil || got != "private-a/tasks" { + t.Fatal("Current shadowed private config helper") + } + } + for _, engine := range []*ExprEngine{NewExprEngineWithConfigLookup(lookup), NewExprEngineWithConfigLookup(nil)} { + got, err := engine.Evaluate(`config("missing")`, pc) + if err != nil || got != "" { + t.Fatal("missing private key fell back globally") + } + } +} diff --git a/pipeline/template.go b/pipeline/template.go index 408ee9bd..690aac17 100644 --- a/pipeline/template.go +++ b/pipeline/template.go @@ -29,13 +29,33 @@ import ( var ConfigLookup func(key string) (string, bool) // TemplateEngine resolves {{ .field }} expressions against a PipelineContext. -type TemplateEngine struct{} +type TemplateEngine struct { + configLookup func(string) (string, bool) + scopedConfig bool +} // NewTemplateEngine creates a new TemplateEngine. func NewTemplateEngine() *TemplateEngine { return &TemplateEngine{} } +// NewTemplateEngineWithConfigLookup uses only lookup, including for missing keys. +// A nil lookup is an empty private source, not a request for the global default. +func NewTemplateEngineWithConfigLookup(lookup func(string) (string, bool)) *TemplateEngine { + return &TemplateEngine{configLookup: lookup, scopedConfig: true} +} + +func configLookupFunc(lookup func(string) (string, bool)) func(string) string { + return func(key string) string { + if lookup != nil { + if value, ok := lookup(key); ok { + return value + } + } + return "" + } +} + // templateData builds the data map that Go templates see. func (te *TemplateEngine) templateData(pc *interfaces.PipelineContext) map[string]any { data := make(map[string]any) @@ -198,6 +218,9 @@ func PreprocessTemplate(tmplStr string) string { // helper functions (step, trigger) that access PipelineContext data directly. func (te *TemplateEngine) funcMapWithContext(pc *interfaces.PipelineContext) template.FuncMap { fm := TemplateFuncMap() + if te.scopedConfig { + fm["config"] = configLookupFunc(te.configLookup) + } // step accesses step outputs by name and optional nested keys. // Usage: {{ step "parse-request" "path_params" "id" }} @@ -312,7 +335,11 @@ func (te *TemplateEngine) Resolve(tmplStr string, pc *interfaces.PipelineContext // Process ${ } blocks first so their output can flow into Go template blocks. if hasExpr { var err error - tmplStr, err = ResolveExprBlocks(tmplStr, pc, NewExprEngine()) + ee := NewExprEngine() + if te.scopedConfig { + ee = NewExprEngineWithConfigLookup(te.configLookup) + } + tmplStr, err = ResolveExprBlocks(tmplStr, pc, ee) if err != nil { return "", err } @@ -361,16 +388,19 @@ func (te *TemplateEngine) Resolve(tmplStr string, pc *interfaces.PipelineContext if pc != nil && pc.Logger != nil { logger = pc.Logger } - pipelineName := "" - if pc != nil && pc.Metadata != nil { - if v, ok := pc.Metadata["pipeline"]; ok { - pipelineName = fmt.Sprint(v) + if te.scopedConfig { + // Expression results can become template field names; diagnostics must + // not include the action, execErr, or context-derived attributes. + logger.Warn("scoped template resolved missing key to zero value") + } else { + pipelineName := "" + if pc != nil && pc.Metadata != nil { + if v, ok := pc.Metadata["pipeline"]; ok { + pipelineName = fmt.Sprint(v) + } } + logger.Warn("template resolved missing key to zero value", "pipeline", pipelineName, "error", execErr) } - logger.Warn("template resolved missing key to zero value", - "pipeline", pipelineName, - "error", execErr, - ) // Re-execute with zero mode to preserve backward-compatible output. buf.Reset() diff --git a/pipeline/template_lookup_test.go b/pipeline/template_lookup_test.go new file mode 100644 index 00000000..0bf66a7e --- /dev/null +++ b/pipeline/template_lookup_test.go @@ -0,0 +1,64 @@ +package pipeline + +import ( + "bytes" + "log/slog" + "strings" + "testing" + + "github.com/GoCodeAlone/workflow/interfaces" +) + +func TestScopedConfigLookupTemplate(t *testing.T) { + previous := ConfigLookup + t.Cleanup(func() { ConfigLookup = previous }) + ConfigLookup = func(string) (string, bool) { return "global-b", true } + lookup := func(key string) (string, bool) { return "private-a", key == "url" } + pc := &interfaces.PipelineContext{Current: map[string]any{"config": "spoof"}} + for _, engine := range []*TemplateEngine{NewTemplateEngine(), {}} { + got, err := engine.Resolve(`{{ config "url" }}`, pc) + if err != nil || got != "global-b" { + t.Fatal("global/zero template behavior changed") + } + } + for _, source := range []string{`{{ config "url" | default "" }}/tasks`, `${ config("url") }/tasks`, `{{ upper (config "url") }}`} { + want := "private-a/tasks" + if strings.Contains(source, "upper") { + want = "PRIVATE-A" + } + got, err := NewTemplateEngineWithConfigLookup(lookup).Resolve(source, pc) + if err != nil || got != want { + t.Fatal("private template source not used") + } + } + for _, engine := range []*TemplateEngine{NewTemplateEngineWithConfigLookup(lookup), NewTemplateEngineWithConfigLookup(nil)} { + for _, source := range []string{`{{ config "missing" }}`, `${ config("missing") }`} { + got, err := engine.Resolve(source, pc) + if err != nil || got != "" { + t.Fatal("missing private key fell back globally") + } + } + } +} + +func TestScopedConfigLookupTemplateWarnings(t *testing.T) { + previous := ConfigLookup + t.Cleanup(func() { ConfigLookup = previous }) + ConfigLookup = func(string) (string, bool) { return "dummy_private_a", true } + var logs bytes.Buffer + pc := &interfaces.PipelineContext{Current: map[string]any{}, Metadata: map[string]any{"pipeline": "dummy_private_a"}, Logger: slog.New(slog.NewJSONHandler(&logs, nil))} + source := `{{ .${ config("token") } }}` + if _, err := NewTemplateEngineWithConfigLookup(ConfigLookup).Resolve(source, pc); err != nil { + t.Fatal("scoped warning fixture failed") + } + if !strings.Contains(logs.String(), "WARN") || strings.Contains(logs.String(), "dummy_private_a") || strings.Contains(logs.String(), `"error"`) || strings.Contains(logs.String(), `"pipeline"`) { + t.Fatal("scoped warning leaked derived data") + } + logs.Reset() + if _, err := NewTemplateEngine().Resolve(source, pc); err != nil { + t.Fatal("legacy warning fixture failed") + } + if !strings.Contains(logs.String(), "WARN") || !strings.Contains(logs.String(), "dummy_private_a") { + t.Fatal("legacy diagnostics changed") + } +} diff --git a/plugin/external/adapter.go b/plugin/external/adapter.go index 11d59797..76eb8e18 100644 --- a/plugin/external/adapter.go +++ b/plugin/external/adapter.go @@ -2,6 +2,7 @@ package external import ( "context" + "errors" "fmt" "net/http" "path/filepath" @@ -12,7 +13,11 @@ import ( "github.com/GoCodeAlone/workflow/config" "github.com/GoCodeAlone/workflow/deploy" "github.com/GoCodeAlone/workflow/iac/providerclient" + "github.com/GoCodeAlone/workflow/interfaces" + "github.com/GoCodeAlone/workflow/module" + "github.com/GoCodeAlone/workflow/pipeline" "github.com/GoCodeAlone/workflow/plugin" + "github.com/GoCodeAlone/workflow/plugin/external/internal/structpbutil" pb "github.com/GoCodeAlone/workflow/plugin/external/proto" "github.com/GoCodeAlone/workflow/schema" "google.golang.org/grpc" @@ -352,6 +357,10 @@ func contractModeUsesTyped(mode pb.ContractMode) bool { return mode == pb.ContractMode_CONTRACT_MODE_STRICT_PROTO || mode == pb.ContractMode_CONTRACT_MODE_PROTO_WITH_LEGACY_STRUCT } +func stepCredentialContractAllowed(contract *pb.ContractDescriptor) bool { + return contract == nil || contract.Mode == pb.ContractMode_CONTRACT_MODE_UNSPECIFIED || contract.Mode == pb.ContractMode_CONTRACT_MODE_LEGACY_STRUCT +} + // --- NativePlugin interface --- func (a *ExternalPluginAdapter) Name() string { return a.manifest.Name } @@ -514,8 +523,33 @@ func (a *ExternalPluginAdapter) StepFactories() map[string]plugin.StepFactory { factories := make(map[string]plugin.StepFactory, len(resp.Types)) for _, typeName := range resp.Types { tn := typeName // capture - factories[tn] = func(name string, cfg map[string]any, _ modular.Application) (any, error) { + factories[tn] = func(name string, cfg map[string]any, app modular.Application) (any, error) { contract := a.contracts.step(tn) + var bound *interfaces.BoundStepCredentials + if app != nil { + var binder interfaces.StepCredentialBinder + if err := app.GetService(module.StepCredentialsService, &binder); err == nil { + copiedConfig := structpbutil.NormalizeMap(cfg) + var bindErr error + bound, bindErr = binder.BindStep(interfaces.StepCredentialTarget{Plugin: a.name, StepType: tn, StepName: name}, copiedConfig) + if bindErr != nil { + return nil, errors.New("remote step credential binding failed") + } + if bound != nil { + cfg = copiedConfig + } + } else if !errors.Is(err, modular.ErrServiceNotFound) { + return nil, errors.New("remote step credential service unavailable") + } + } + if bound != nil { + if !stepCredentialContractAllowed(contract) { + return nil, errors.New("remote step credentials require a legacy contract") + } + if bound.ConfigLookup == nil || bound.ValidateConfig == nil || bound.Resolve == nil { + return nil, errors.New("remote step credential binding incomplete") + } + } config, typedConfig, configErr := createTypedConfigRequest(contract, cfg, a.contractTypes) if configErr != nil { return nil, fmt.Errorf("create remote step %s: %w", tn, configErr) @@ -529,10 +563,18 @@ func (a *ExternalPluginAdapter) StepFactories() map[string]plugin.StepFactory { if createErr != nil { return nil, fmt.Errorf("create remote step %s: %w", tn, createErr) } + if createResp == nil { + return nil, errors.New("create remote step: empty response") + } if createResp.Error != "" { return nil, fmt.Errorf("create remote step %s: %s", tn, createResp.Error) } - return NewRemoteStepWithContractTypes(name, createResp.HandleId, a.client.client, cfg, contract, a.contractTypes), nil + step := NewRemoteStepWithContractTypes(name, createResp.HandleId, a.client.client, cfg, contract, a.contractTypes) + if bound != nil { + step.credentials = bound + step.tmpl = pipeline.NewTemplateEngineWithConfigLookup(bound.ConfigLookup) + } + return step, nil } } return factories diff --git a/plugin/external/adapter_test.go b/plugin/external/adapter_test.go index ce7e3516..94f7fba7 100644 --- a/plugin/external/adapter_test.go +++ b/plugin/external/adapter_test.go @@ -1,12 +1,17 @@ package external import ( + "bytes" "context" "errors" + "log/slog" "net" "strings" "testing" + "github.com/GoCodeAlone/modular" + "github.com/GoCodeAlone/workflow/interfaces" + "github.com/GoCodeAlone/workflow/module" "github.com/GoCodeAlone/workflow/plugin" pb "github.com/GoCodeAlone/workflow/plugin/external/proto" "google.golang.org/grpc" @@ -22,6 +27,271 @@ import ( "google.golang.org/protobuf/types/known/emptypb" ) +func TestScopedConfigLookupWarnings(t *testing.T) { + global := module.GetConfigRegistry() + global.Reset() + t.Cleanup(global.Reset) + _ = global.Set("client.token", "dummy_private_a", false) + global.Freeze() + a, _, app := scopedCredentialFixture(t, map[string]string{"client.token": "dummy_private_a"}, nil) + step := scopedFactoryStep(t, a, app, map[string]any{"auth_ref": "config:client.token", "composed": `{{ .${ config("client.token") } }}`}) + var logs bytes.Buffer + pc := module.NewPipelineContext(nil, nil) + pc.Logger = slog.New(slog.NewJSONHandler(&logs, nil)) + pc.Metadata["pipeline"] = "dummy_private_a" + if _, err := step.Execute(context.Background(), pc); err != nil { + t.Fatal("composed missing field execution failed") + } + if !strings.Contains(logs.String(), "WARN") { + t.Fatal("missing-field warning absent") + } + if strings.Contains(logs.String(), "dummy_private_a") || strings.Contains(logs.String(), `"error"`) || strings.Contains(logs.String(), `"pipeline"`) { + t.Fatal("scoped warning contains execution/context-derived data") + } + logs.Reset() + if _, err := module.NewTemplateEngine().Resolve(`{{ .${ config("client.token") } }}`, pc); err != nil { + t.Fatal("legacy control failed") + } + if !strings.Contains(logs.String(), "WARN") || !strings.Contains(logs.String(), "dummy_private_a") { + t.Fatal("legacy diagnostic control changed") + } +} + +type scopedStepRecorder struct { + adapterTestPluginServiceClient + executeCalls int + execute func(context.Context, *pb.ExecuteStepRequest) (*pb.ExecuteStepResponse, error) +} + +func (c *scopedStepRecorder) ExecuteStep(ctx context.Context, req *pb.ExecuteStepRequest, opts ...grpc.CallOption) (*pb.ExecuteStepResponse, error) { + c.executeCalls++ + c.lastRequest = req + if c.execute != nil { + return c.execute(ctx, req) + } + return c.stubPluginServiceClient.ExecuteStep(ctx, req, opts...) +} + +func scopedCredentialGrant() interfaces.StepCredentialGrant { + return interfaces.StepCredentialGrant{Plugin: "trusted-plugin", StepType: "step.generic", StepName: "transfer", Field: "auth_ref", Ref: "config:client.token", Scope: "application"} +} + +func scopedCredentialFixture(t *testing.T, values map[string]string, contract *pb.ContractDescriptor) (*ExternalPluginAdapter, *scopedStepRecorder, modular.Application) { + t.Helper() + r := module.NewConfigRegistry() + for k, v := range values { + if err := r.Set(k, v, false); err != nil { + t.Fatal("fixture registry failed") + } + } + r.Freeze() + binder, err := module.NewStepCredentialBinder(r, []interfaces.StepCredentialGrant{scopedCredentialGrant()}) + if err != nil { + t.Fatal("fixture binder failed") + } + app := modular.NewStdApplication(modular.NewStdConfigProvider(nil), nil) + if err := app.RegisterService(module.StepCredentialsService, binder); err != nil { + t.Fatal("fixture registration failed") + } + registry := &pb.ContractRegistry{} + if contract != nil { + registry.Contracts = []*pb.ContractDescriptor{contract} + } + c := &scopedStepRecorder{adapterTestPluginServiceClient: adapterTestPluginServiceClient{ + manifest: &pb.Manifest{Name: "trusted-plugin"}, registry: registry, stepTypes: []string{"step.generic"}, + }} + a, err := NewExternalPluginAdapter("trusted-plugin", &PluginClient{client: c}, nil) + if err != nil { + t.Fatal("fixture adapter failed") + } + return a, c, app +} + +func scopedFactoryStep(t *testing.T, a *ExternalPluginAdapter, app modular.Application, cfg map[string]any) *RemoteStep { + t.Helper() + v, err := a.StepFactories()["step.generic"]("transfer", cfg, app) + if err != nil { + t.Fatal("scoped step construction failed") + } + return v.(*RemoteStep) +} + +func TestScopedConfigLookup(t *testing.T) { + global := module.GetConfigRegistry() + global.Reset() + t.Cleanup(global.Reset) + _ = global.Set("server_url", "http://app-b.invalid", false) + _ = global.Set("global_only", "foreign-value", false) + _ = global.Set("client.token", "dummy-global-b", false) + global.Freeze() + for _, tc := range []struct { + name, template, want string + spoof bool + }{ + {"composed Go", `{{ config "server_url" | default "" }}/tasks`, "http://app-a.invalid/tasks", false}, + {"expression", `${ config("server_url") }/tasks`, "http://app-a.invalid/tasks", false}, + {"Current config spoof", `${ config("server_url") }/tasks`, "http://app-a.invalid/tasks", true}, + {"missing Go scoped key", `{{ config "global_only" | default "" }}`, "", false}, + {"missing expr scoped key", `${ config("global_only") }`, "", false}, + } { + t.Run(tc.name, func(t *testing.T) { + a, c, app := scopedCredentialFixture(t, map[string]string{"server_url": "http://app-a.invalid", "client.token": "dummy-private-a"}, nil) + cfg := map[string]any{"auth_ref": "config:client.token", "url": tc.template} + step := scopedFactoryStep(t, a, app, cfg) + pc := module.NewPipelineContext(nil, nil) + if tc.spoof { + pc.Current["config"] = "spoof" + } + _, err := step.Execute(context.Background(), pc) + if err != nil { + t.Fatal("same-app config evaluation failed") + } + if c.lastRequest == nil || c.lastRequest.Config.AsMap()["url"] != tc.want { + t.Fatal("wrong app endpoint or global fallback selected") + } + }) + } +} + +type trackingCredentialBinder struct { + delegate interfaces.StepCredentialBinder + binds, resolves, validates, lookups int + resolveErr error + validateErrAt int + resolved []interfaces.ResolvedStepCredential +} + +func (b *trackingCredentialBinder) BindStep(target interfaces.StepCredentialTarget, cfg map[string]any) (*interfaces.BoundStepCredentials, error) { + b.binds++ + bound, err := b.delegate.BindStep(target, cfg) + if err != nil || bound == nil { + return bound, err + } + return &interfaces.BoundStepCredentials{ + ConfigLookup: func(key string) (string, bool) { b.lookups++; return bound.ConfigLookup(key) }, + ValidateConfig: func(cfg map[string]any) error { + b.validates++ + if b.validates == b.validateErrAt { + return errors.New("dummy-private-a") + } + return bound.ValidateConfig(cfg) + }, + Resolve: func(ctx context.Context) ([]interfaces.ResolvedStepCredential, error) { + b.resolves++ + if b.resolveErr != nil { + return nil, b.resolveErr + } + if b.resolved != nil { + return b.resolved, nil + } + return bound.Resolve(ctx) + }, + }, nil +} + +func trackedCredentialApp(t *testing.T, app modular.Application) (modular.Application, *trackingCredentialBinder) { + t.Helper() + var binder interfaces.StepCredentialBinder + if err := app.GetService(module.StepCredentialsService, &binder); err != nil { + t.Fatal("fixture binder unavailable") + } + b := &trackingCredentialBinder{delegate: binder} + tracked := modular.NewStdApplication(modular.NewStdConfigProvider(nil), nil) + if err := tracked.RegisterService(module.StepCredentialsService, b); err != nil { + t.Fatal("fixture binder registration failed") + } + return tracked, b +} + +func TestRemoteStepScopedBinding(t *testing.T) { + t.Run("no grant retains legacy config ownership", func(t *testing.T) { + a, c, app := scopedCredentialFixture(t, nil, nil) + a.name = "foreign-plugin" + cfg := map[string]any{"auth_ref": "config:client.token", "value": "original"} + step := scopedFactoryStep(t, a, app, cfg) + cfg["value"] = "legacy-mutation" + if _, err := step.Execute(context.Background(), module.NewPipelineContext(nil, nil)); err != nil { + t.Fatal("ungranted execute failed") + } + if c.lastRequest.Config.AsMap()["value"] != "legacy-mutation" { + t.Fatal("nil binding changed legacy ownership") + } + }) + t.Run("typed container construction copy", func(t *testing.T) { + a, c, app := scopedCredentialFixture(t, map[string]string{"client.token": "dummy-private-a"}, nil) + m := map[string]string{"value": "original"} + items := []map[string]any{{"value": "original"}} + cfg := map[string]any{"auth_ref": "config:client.token", "typed_map": m, "typed_items": items} + step := scopedFactoryStep(t, a, app, cfg) + m["value"] = "mutated" + items[0]["value"] = "mutated" + if _, err := step.Execute(context.Background(), module.NewPipelineContext(nil, nil)); err != nil { + t.Fatal("typed container execute failed") + } + sent := c.lastRequest.Config.AsMap() + if sent["typed_map"].(map[string]any)["value"] != "original" || sent["typed_items"].([]any)[0].(map[string]any)["value"] != "original" { + t.Fatal("typed config containers alias caller") + } + }) + t.Run("trusted registration survives public rename", func(t *testing.T) { + a, c, app := scopedCredentialFixture(t, map[string]string{"client.token": "dummy-private-a"}, nil) + a.manifest.Name = "foreign-plugin" + step := scopedFactoryStep(t, a, app, map[string]any{"auth_ref": "config:client.token"}) + if _, err := step.Execute(context.Background(), module.NewPipelineContext(nil, nil)); err != nil { + t.Fatal("trusted binding failed") + } + if _, ok := c.lastRequest.Config.AsMap()["config"]; !ok { + t.Fatal("public name replaced trusted identity") + } + }) + t.Run("manifest spoof cannot grant", func(t *testing.T) { + a, c, app := scopedCredentialFixture(t, map[string]string{"client.token": "dummy-private-a"}, nil) + a.name = "foreign-plugin" + step := scopedFactoryStep(t, a, app, map[string]any{"auth_ref": "config:client.token"}) + if _, err := step.Execute(context.Background(), module.NewPipelineContext(nil, nil)); err != nil { + t.Fatal("ungranted step failed") + } + if _, ok := c.lastRequest.Config.AsMap()["config"]; ok { + t.Fatal("manifest spoof acquired credentials") + } + }) + t.Run("mismatch rejects before RPC", func(t *testing.T) { + a, c, app := scopedCredentialFixture(t, nil, nil) + _, err := a.StepFactories()["step.generic"]("transfer", map[string]any{"auth_ref": "config:other.token"}, app) + if err == nil || c.lastCreateStepReq != nil { + t.Fatal("binding mismatch reached CreateStep") + } + }) + for _, mode := range []pb.ContractMode{pb.ContractMode_CONTRACT_MODE_PROTO_WITH_LEGACY_STRUCT, pb.ContractMode_CONTRACT_MODE_STRICT_PROTO, 99, -99} { + t.Run(mode.String(), func(t *testing.T) { + contract := &pb.ContractDescriptor{Kind: pb.ContractKind_CONTRACT_KIND_STEP, StepType: "step.generic", Mode: mode, ConfigMessage: "workflow.plugin.v1.Manifest", InputMessage: "workflow.plugin.v1.Manifest", OutputMessage: "workflow.plugin.v1.Manifest"} + if mode == 99 || mode == -99 { + contract.ConfigMessage, contract.InputMessage, contract.OutputMessage = "google.protobuf.Struct", "google.protobuf.Struct", "google.protobuf.Struct" + } + a, c, app := scopedCredentialFixture(t, nil, contract) + app, b := trackedCredentialApp(t, app) + _, err := a.StepFactories()["step.generic"]("transfer", map[string]any{"auth_ref": "config:client.token"}, app) + if err == nil || c.lastCreateStepReq != nil || c.executeCalls != 0 || b.binds != 1 || b.resolves != 0 || b.lookups != 0 { + t.Fatal("granted typed mode did not reject before lookup/Create RPC") + } + }) + } + t.Run("deep construction copy", func(t *testing.T) { + a, c, app := scopedCredentialFixture(t, map[string]string{"client.token": "dummy-private-a"}, nil) + cfg := map[string]any{"auth_ref": "config:client.token", "nested": map[string]any{"items": []any{map[string]any{"value": "original"}}}} + step := scopedFactoryStep(t, a, app, cfg) + cfg["auth_ref"] = "config:other.token" + cfg["nested"].(map[string]any)["items"].([]any)[0].(map[string]any)["value"] = "mutated" + if _, err := step.Execute(context.Background(), module.NewPipelineContext(nil, nil)); err != nil { + t.Fatal("caller mutation changed bound config") + } + sent := c.lastRequest.Config.AsMap() + if sent["auth_ref"] != "config:client.token" || sent["nested"].(map[string]any)["items"].([]any)[0].(map[string]any)["value"] != "original" { + t.Fatal("construction config aliases caller") + } + }) +} + // newTestAdapter builds an ExternalPluginAdapter with a populated manifest // and optional config fragment without a real gRPC connection. func newTestAdapter(manifest *pb.Manifest, configFragment []byte) *ExternalPluginAdapter { diff --git a/plugin/external/remote_step.go b/plugin/external/remote_step.go index f7a1742a..7b8ca233 100644 --- a/plugin/external/remote_step.go +++ b/plugin/external/remote_step.go @@ -3,25 +3,32 @@ package external import ( "context" "encoding/json" + "errors" "fmt" + "strings" + "github.com/GoCodeAlone/workflow/interfaces" "github.com/GoCodeAlone/workflow/module" pb "github.com/GoCodeAlone/workflow/plugin/external/proto" + "github.com/GoCodeAlone/workflow/secrets" "google.golang.org/protobuf/reflect/protoregistry" "google.golang.org/protobuf/types/known/structpb" ) // RemoteStep implements module.PipelineStep by delegating to a gRPC plugin. type RemoteStep struct { - name string - handleID string - config map[string]any - client pb.PluginServiceClient - contract *pb.ContractDescriptor - types protoregistry.MessageTypeResolver - tmpl *module.TemplateEngine + name string + handleID string + config map[string]any + client pb.PluginServiceClient + contract *pb.ContractDescriptor + types protoregistry.MessageTypeResolver + tmpl *module.TemplateEngine + credentials *interfaces.BoundStepCredentials } +var errStepCredentialExecution = errors.New("remote step credential execution rejected") + // NewRemoteStep creates a remote step proxy. // config holds the raw (possibly template-containing) step configuration that // will be resolved against the live pipeline context on each Execute call. @@ -49,7 +56,49 @@ func (s *RemoteStep) Name() string { return s.name } -func (s *RemoteStep) Execute(ctx context.Context, pc *module.PipelineContext) (*module.StepResult, error) { +func (s *RemoteStep) Execute(ctx context.Context, pc *module.PipelineContext) (result *module.StepResult, err error) { + var detector *secrets.Redactor + var carrier map[string]any + if s.credentials != nil { + if ctx == nil || !stepCredentialContractAllowed(s.contract) { + return nil, errStepCredentialExecution + } + if err := s.credentials.ValidateConfig(s.config); err != nil { + return nil, errStepCredentialExecution + } + if err := ctx.Err(); err != nil { + return nil, err + } + resolved, resolveErr := s.credentials.Resolve(ctx) + if resolveErr != nil || len(resolved) == 0 { + return nil, errStepCredentialExecution + } + detector = secrets.NewRedactor() + carrier = make(map[string]any, len(resolved)) + for i, credential := range resolved { + if !strings.HasPrefix(credential.Ref, "config:") || credential.Value == "" { + return nil, errStepCredentialExecution + } + key := strings.TrimPrefix(credential.Ref, "config:") + if key == "" { + return nil, errStepCredentialExecution + } + if _, duplicate := carrier[key]; duplicate { + return nil, errStepCredentialExecution + } + carrier[key] = credential.Value + detector.AddValue(fmt.Sprintf("credential%d", i), credential.Value) + } + // Arm the guard before evaluating any ordinary templates or encoding data. + defer func() { + if err != nil && detector.ContainsValue(err.Error()) { + result, err = nil, errStepCredentialExecution + } + }() + if err := ctx.Err(); err != nil { + return nil, err + } + } // Resolve template expressions in the step config against the current // pipeline context so that dynamic values (e.g. outputs of earlier steps) // are available to the plugin. When no config was provided, skip resolution @@ -62,6 +111,20 @@ func (s *RemoteStep) Execute(ctx context.Context, pc *module.PipelineContext) (* return nil, fmt.Errorf("remote step %q (handle %s) config resolve: %w", s.name, s.handleID, err) } } + if s.credentials != nil { + if err := s.credentials.ValidateConfig(resolvedConfig); err != nil { + return nil, errStepCredentialExecution + } + if _, collision := resolvedConfig["config"]; collision { + return nil, errStepCredentialExecution + } + // ResolveMap creates a fresh map; the private namespace is never merged + // with caller data or copied back into the step or pipeline context. + resolvedConfig["config"] = carrier + if err := ctx.Err(); err != nil { + return nil, err + } + } // Convert step outputs to proto map stepOutputs := make(map[string]*structpb.Struct) @@ -82,6 +145,9 @@ func (s *RemoteStep) Execute(ctx context.Context, pc *module.PipelineContext) (* if err != nil { return nil, fmt.Errorf("remote step execute: %w", err) } + if resp == nil { + return nil, errors.New("remote step execute: empty response") + } if resp.Error != "" { return nil, fmt.Errorf("remote step execute: %s", resp.Error) } @@ -98,6 +164,9 @@ func (s *RemoteStep) Execute(ctx context.Context, pc *module.PipelineContext) (* return nil, fmt.Errorf("remote step %q typed output decode: %w", s.name, err) } } + if containsStepCredential(output, detector) { + return nil, errStepCredentialExecution + } return &module.StepResult{ Output: output, @@ -105,6 +174,29 @@ func (s *RemoteStep) Execute(ctx context.Context, pc *module.PipelineContext) (* }, nil } +func containsStepCredential(value any, detector *secrets.Redactor) bool { + if detector == nil { + return false + } + switch v := value.(type) { + case string: + return detector.ContainsValue(v) + case map[string]any: + for key, item := range v { + if detector.ContainsValue(key) || containsStepCredential(item, detector) { + return true + } + } + case []any: + for _, item := range v { + if containsStepCredential(item, detector) { + return true + } + } + } + return false +} + func (s *RemoteStep) executeRequest(pc *module.PipelineContext, resolvedConfig map[string]any, stepOutputs map[string]*structpb.Struct) (*pb.ExecuteStepRequest, error) { // trigger_data and metadata are always sent as Struct — there's no // typed alternative — so encode them up front. diff --git a/plugin/external/remote_step_test.go b/plugin/external/remote_step_test.go index 92345923..c0608ae5 100644 --- a/plugin/external/remote_step_test.go +++ b/plugin/external/remote_step_test.go @@ -2,12 +2,16 @@ package external import ( "context" + "encoding/json" + "errors" "fmt" "math" "reflect" "strings" "testing" + "github.com/GoCodeAlone/modular" + "github.com/GoCodeAlone/workflow/interfaces" "github.com/GoCodeAlone/workflow/module" pb "github.com/GoCodeAlone/workflow/plugin/external/proto" "google.golang.org/grpc" @@ -19,6 +23,273 @@ import ( "google.golang.org/protobuf/types/known/structpb" ) +func TestRemoteStepNoGrantContractMatrix(t *testing.T) { + for _, mode := range []pb.ContractMode{-1, pb.ContractMode_CONTRACT_MODE_UNSPECIFIED, pb.ContractMode_CONTRACT_MODE_LEGACY_STRUCT, pb.ContractMode_CONTRACT_MODE_PROTO_WITH_LEGACY_STRUCT, pb.ContractMode_CONTRACT_MODE_STRICT_PROTO} { + for _, appMode := range []string{"nil app", "no service", "foreign binding"} { + t.Run(fmt.Sprintf("%d/%s", mode, appMode), func(t *testing.T) { + var contract *pb.ContractDescriptor + if mode != -1 { + contract = &pb.ContractDescriptor{Kind: pb.ContractKind_CONTRACT_KIND_STEP, StepType: "step.generic", Mode: mode, ConfigMessage: "workflow.plugin.v1.Manifest", InputMessage: "workflow.plugin.v1.Manifest", OutputMessage: "workflow.plugin.v1.Manifest"} + } + a, c, app := scopedCredentialFixture(t, nil, contract) + if appMode == "nil app" { + app = nil + } + if appMode == "no service" { + app = modular.NewStdApplication(modular.NewStdConfigProvider(nil), nil) + } + cfg := map[string]any{"name": "ordinary-config"} + v, err := a.StepFactories()["step.generic"]("ungranted", cfg, app) + if err != nil { + t.Fatal("no-grant creation changed") + } + legacyOutput, err := mapToStruct(map[string]any{"name": "ordinary-output"}) + if err != nil { + t.Fatal("fixture output failed") + } + c.response = &pb.ExecuteStepResponse{Output: legacyOutput, TypedOutput: mustAnyFromMapForTest(t, "workflow.plugin.v1.Manifest", map[string]any{"name": "ordinary-output"})} + pc := module.NewPipelineContext(map[string]any{"name": "ordinary-input"}, nil) + result, err := v.(*RemoteStep).Execute(context.Background(), pc) + if err != nil || result == nil || !reflect.DeepEqual(result.Output, map[string]any{"name": "ordinary-output"}) { + t.Fatal("no-grant output changed") + } + typed := mode == pb.ContractMode_CONTRACT_MODE_PROTO_WITH_LEGACY_STRUCT || mode == pb.ContractMode_CONTRACT_MODE_STRICT_PROTO + strict := mode == pb.ContractMode_CONTRACT_MODE_STRICT_PROTO + if (c.lastRequest.TypedConfig != nil) != typed || (c.lastRequest.TypedInput != nil) != typed || (c.lastRequest.Config == nil) != strict || (c.lastRequest.Current == nil) != strict { + t.Fatal("no-grant typed/legacy transport changed") + } + if !strict && !reflect.DeepEqual(c.lastRequest.Config.AsMap(), cfg) { + t.Fatal("no-grant config changed") + } + }) + } + } +} + +func TestRemoteStepScopedFailures(t *testing.T) { + for _, name := range []string{"missing", "empty", "cancelled", "provider", "raw reference mutation", "post-resolution validation", "collision", "config resolve", "config encode", "current encode", "trigger encode", "step output encode", "RPC echo", "response error echo", "RPC escaped echo", "response escaped error", "nil response", "empty resolution", "invalid resolved ref", "empty resolved value"} { + t.Run(name, func(t *testing.T) { + values := map[string]string{"client.token": "dummy-private-a"} + if name == "missing" { + delete(values, "client.token") + } + if name == "empty" { + values["client.token"] = "" + } + a, c, app := scopedCredentialFixture(t, values, nil) + app, b := trackedCredentialApp(t, app) + cfg := map[string]any{"auth_ref": "config:client.token"} + pc := module.NewPipelineContext(nil, nil) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + wantRPC := false + switch name { + case "cancelled": + cancel() + case "provider": + b.resolveErr = errors.New("provider dummy-private-a") + case "post-resolution validation": + b.validateErrAt = 2 + case "collision": + cfg["config"] = map[string]any{"existing": "ordinary"} + case "config resolve": + cfg["dummy-private-a"] = "{{ .missing }}" + pc.StrictTemplates = true + case "current encode": + pc.Current["bad"] = "dummy-private-a" + string([]byte{0xff}) + case "trigger encode": + pc.TriggerData["bad"] = "dummy-private-a" + string([]byte{0xff}) + case "step output encode": + pc.StepOutputs["dummy-private-a"] = map[string]any{"bad": make(chan int)} + case "RPC echo": + wantRPC = true + c.execute = func(context.Context, *pb.ExecuteStepRequest) (*pb.ExecuteStepResponse, error) { + return nil, errors.New("RPC dummy-private-a") + } + case "response error echo": + wantRPC = true + c.response = &pb.ExecuteStepResponse{Error: "provider dummy-private-a", StopPipeline: true} + case "RPC escaped echo": + wantRPC = true + c.execute = func(context.Context, *pb.ExecuteStepRequest) (*pb.ExecuteStepResponse, error) { + return nil, errors.New(`RPC error {"echo":"\u0064ummy-private-a"}`) + } + case "response escaped error": + wantRPC = true + c.response = &pb.ExecuteStepResponse{Error: `provider error {"echo":"\u0064ummy-private-a"}`, StopPipeline: true} + case "nil response": + wantRPC = true + c.execute = func(context.Context, *pb.ExecuteStepRequest) (*pb.ExecuteStepResponse, error) { return nil, nil } + case "empty resolution": + b.resolved = []interfaces.ResolvedStepCredential{} + case "invalid resolved ref": + b.resolved = []interfaces.ResolvedStepCredential{{Ref: "secret:client.token", Value: "dummy-private-a"}} + case "empty resolved value": + b.resolved = []interfaces.ResolvedStepCredential{{Ref: "config:client.token"}} + } + step := scopedFactoryStep(t, a, app, cfg) + if name == "config encode" { + step.config["bad"] = "dummy-private-a" + string([]byte{0xff}) + } + if name == "raw reference mutation" { + step.config["auth_ref"] = "config:other.token" + } + defer func() { + if recover() != nil { + t.Error("credentialed execution panicked instead of failing closed") + } + }() + result, err := step.Execute(ctx, pc) + if err == nil || result != nil { + t.Fatal("unsafe execution did not fail closed") + } + if strings.Contains(err.Error(), "dummy-private-a") { + t.Fatal("credential leaked in execution error") + } + if (strings.Contains(name, "echo") || name == "response escaped error") && err.Error() != "remote step credential execution rejected" { + t.Fatal("encoded echo did not return fixed safe error") + } + if (c.executeCalls != 0) != wantRPC { + t.Fatal("unexpected execution RPC boundary") + } + if name == "raw reference mutation" && (b.resolves != 0 || b.lookups != 0) { + t.Fatal("runtime reference became source authority") + } + if name == "post-resolution validation" && b.validates != 2 { + t.Fatal("resolved config was not revalidated") + } + }) + } +} + +func TestRemoteStepScopedUnknownContractMode(t *testing.T) { + for _, mode := range []pb.ContractMode{99, -99} { + t.Run(mode.String(), func(t *testing.T) { + a, c, app := scopedCredentialFixture(t, map[string]string{"client.token": "dummy-private-a"}, nil) + app, b := trackedCredentialApp(t, app) + step := scopedFactoryStep(t, a, app, map[string]any{"auth_ref": "config:client.token"}) + // Defend execution too if a descriptor changes after valid construction. + step.contract = &pb.ContractDescriptor{Mode: mode, ConfigMessage: "google.protobuf.Struct", InputMessage: "google.protobuf.Struct", OutputMessage: "google.protobuf.Struct"} + result, err := step.Execute(context.Background(), module.NewPipelineContext(nil, nil)) + if result != nil || err == nil || err.Error() != "remote step credential execution rejected" || b.resolves != 0 || b.lookups != 0 || c.executeCalls != 0 { + t.Fatal("unknown credential contract mode reached lookup or Execute RPC") + } + }) + } +} + +func TestRemoteStepScopedEchoes(t *testing.T) { + for _, tc := range []struct { + name, token string + output func(string) map[string]any + stop bool + }{ + {"nested string", "dummy-private-a", func(v string) map[string]any { + return map[string]any{"nested": []any{map[string]any{"message": "echo " + v}}} + }, false}, + {"map key", "dummy-private-a", func(v string) map[string]any { return map[string]any{"nested": map[string]any{v: "ordinary"}} }, true}, + {"escaped JSON", "dummy-\"private\na", func(v string) map[string]any { + raw, _ := json.Marshal(map[string]any{"echo": v}) + return map[string]any{"receipt": string(raw)} + }, false}, + {"unicode escaped JSON", "dummy-private-a", func(string) map[string]any { return map[string]any{"receipt": `{"echo":"\u0064ummy-private-a"}`} }, false}, + {"marker equals value", "[REDACTED:credential0]", func(v string) map[string]any { return map[string]any{"message": v} }, false}, + } { + t.Run(tc.name, func(t *testing.T) { + a, c, app := scopedCredentialFixture(t, map[string]string{"client.token": tc.token}, nil) + output := tc.output(tc.token) + wire, err := mapToStruct(output) + if err != nil { + t.Fatal("fixture output encode failed") + } + c.response = &pb.ExecuteStepResponse{Output: wire, StopPipeline: tc.stop} + step := scopedFactoryStep(t, a, app, map[string]any{"auth_ref": "config:client.token"}) + result, err := step.Execute(context.Background(), module.NewPipelineContext(nil, nil)) + if err == nil || result != nil || strings.Contains(err.Error(), tc.token) { + t.Fatal("credential echo escaped fail-closed boundary") + } + if err.Error() != "remote step credential execution rejected" { + t.Fatal("credential echo did not return fixed safe error") + } + if !reflect.DeepEqual(c.response.Output.AsMap(), output) { + t.Fatal("consumer output was rewritten") + } + }) + } +} + +func TestRemoteStepScopedOrdinaryPayload(t *testing.T) { + for _, stop := range []bool{false, true} { + a, c, app := scopedCredentialFixture(t, map[string]string{"client.token": "dummy-private-a"}, nil) + app, b := trackedCredentialApp(t, app) + output := map[string]any{"signed_receipt": `{"original":"exact\\bytes"}`, "nested": []any{map[string]any{"count": float64(3)}}, "ordinary_marker": "[REDACTED:other]"} + wire, err := mapToStruct(output) + if err != nil { + t.Fatal("fixture output encode failed") + } + c.response = &pb.ExecuteStepResponse{Output: wire, StopPipeline: stop} + step := scopedFactoryStep(t, a, app, map[string]any{"auth_ref": "config:client.token"}) + for range 2 { + result, err := step.Execute(context.Background(), module.NewPipelineContext(nil, nil)) + if err != nil || result == nil || result.Stop != stop || !reflect.DeepEqual(result.Output, output) { + t.Fatal("legitimate consumer payload changed") + } + } + if b.resolves != 2 || b.validates != 4 { + t.Fatal("per-call resolution/validation missing") + } + c.response = &pb.ExecuteStepResponse{Error: "POST /tasks: got status 403", StopPipeline: true} + _, err = step.Execute(context.Background(), module.NewPipelineContext(nil, nil)) + if err == nil || err.Error() != "remote step execute: POST /tasks: got status 403" { + t.Fatal("ordinary consumer error changed") + } + } +} + +func TestRemoteStepScopedCredentials(t *testing.T) { + for _, tc := range []struct { + name string + contract *pb.ContractDescriptor + }{ + {"absent", nil}, + {"unspecified", &pb.ContractDescriptor{Kind: pb.ContractKind_CONTRACT_KIND_STEP, StepType: "step.generic"}}, + {"legacy", &pb.ContractDescriptor{Kind: pb.ContractKind_CONTRACT_KIND_STEP, StepType: "step.generic", Mode: pb.ContractMode_CONTRACT_MODE_LEGACY_STRUCT}}, + } { + t.Run(tc.name, func(t *testing.T) { + a, c, app := scopedCredentialFixture(t, map[string]string{"client.token": "dummy-private-a"}, tc.contract) + cfg := map[string]any{"auth_ref": "config:client.token", "payload": map[string]any{"value": "ordinary"}} + step := scopedFactoryStep(t, a, app, cfg) + if c.lastCreateStepReq == nil || !reflect.DeepEqual(c.lastCreateStepReq.Config.AsMap(), cfg) { + t.Fatal("creation did not remain ref-only") + } + pc := module.NewPipelineContext(map[string]any{"item": "ordinary"}, map[string]any{"pipeline": "parent"}) + before := module.NewPipelineContext(map[string]any{"item": "ordinary"}, map[string]any{"pipeline": "parent"}) + _, err := step.Execute(context.Background(), pc) + if err != nil { + t.Fatal("credentialed execute failed") + } + if c.lastRequest == nil { + t.Fatal("execution RPC missing") + } + carrier, ok := c.lastRequest.Config.AsMap()["config"].(map[string]any) + if !ok || carrier["client.token"] != "dummy-private-a" || len(carrier) != 1 { + t.Fatal("missing exact private credential carrier") + } + if _, exists := cfg["config"]; exists { + t.Fatal("raw config was mutated") + } + if !reflect.DeepEqual(pc, before) { + t.Fatal("shared pipeline data mutated") + } + for _, data := range []any{pc.Current, pc.TriggerData, pc.Metadata, pc.StepOutputs, c.lastRequest.Current.AsMap(), c.lastRequest.TriggerData.AsMap(), c.lastRequest.Metadata.AsMap()} { + if strings.Contains(fmt.Sprint(data), "dummy-private-a") { + t.Fatal("credential escaped private carrier") + } + } + }) + } +} + // stubPluginServiceClient is a minimal PluginServiceClient that captures // ExecuteStep requests for assertion in tests. type stubPluginServiceClient struct { diff --git a/plugin/external/testdata/step-credentials-plugin/main.go b/plugin/external/testdata/step-credentials-plugin/main.go new file mode 100644 index 00000000..59f78541 --- /dev/null +++ b/plugin/external/testdata/step-credentials-plugin/main.go @@ -0,0 +1,137 @@ +// The real SDK owns process serving and RPC conversion. Only HTTP is loopback. +package main + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "io" + "net" + "net/http" + "net/url" + "os" + "path/filepath" + "strings" + "time" + + "github.com/GoCodeAlone/workflow/plugin/external/sdk" +) + +type provider struct{ name string } + +func (p *provider) Manifest() sdk.PluginManifest { + return sdk.PluginManifest{Name: p.name, Version: "0.1.0", Author: "workflow-tests", Description: "Native scoped step credential fixture"} +} + +func (p *provider) StepTypes() []string { + if p.name == "step-credentials-unrelated" { + return []string{"step.scoped_foreign"} + } + return []string{"step.scoped_submit", "step.scoped_observe"} +} + +type stepConfig struct { + ServerURL string `json:"server_url"` + AuthTokenRef string `json:"auth_token_ref"` + URL string `json:"url"` + ConfigDir string `json:"_config_dir,omitempty"` +} + +type observation struct { + PID int `json:"pid"` + CarrierPresence bool `json:"carrier_presence"` +} + +type urlPayload struct { + URL string `json:"url"` +} + +type step struct { + submit bool + config stepConfig +} + +func (p *provider) CreateStep(typeName, _ string, raw map[string]any) (sdk.StepInstance, error) { + allowed := false + for _, name := range p.StepTypes() { + allowed = allowed || name == typeName + } + if !allowed { + return nil, errors.New("unknown fixture step type") + } + b, err := json.Marshal(raw) + if err != nil { + return nil, errors.New("fixture creation config encoding failed") + } + decoder := json.NewDecoder(bytes.NewReader(b)) + decoder.DisallowUnknownFields() + var cfg stepConfig + if err := decoder.Decode(&cfg); err != nil { + return nil, errors.New("fixture requires strict ref-only creation config") + } + submit := typeName == "step.scoped_submit" + if submit && (cfg.ServerURL == "" || cfg.URL == "" || !strings.HasPrefix(cfg.AuthTokenRef, "config:") || strings.TrimPrefix(cfg.AuthTokenRef, "config:") == "" || strings.ContainsAny(cfg.AuthTokenRef, "{} \t\r\n")) { + return nil, errors.New("fixture requires endpoint, URL and literal config ref") + } + return &step{submit: submit, config: cfg}, nil +} + +func (s *step) Execute(ctx context.Context, trigger map[string]any, outputs map[string]map[string]any, current, metadata, runtimeConfig map[string]any) (*sdk.StepResult, error) { + _, present := runtimeConfig["config"] + observed := observation{PID: os.Getpid(), CarrierPresence: present} + if s.submit { + values, ok := runtimeConfig["config"].(map[string]any) + if !ok { + return nil, errors.New("credential carrier missing") + } + key := strings.TrimPrefix(s.config.AuthTokenRef, "config:") + token, ok := values[key].(string) + if !ok || token == "" || runtimeConfig["auth_token_ref"] != s.config.AuthTokenRef { + return nil, errors.New("credential carrier missing or ref changed") + } + // Inspect the actual received value, never a compiled expected credential. + for _, data := range []any{trigger, outputs, current, metadata, os.Environ(), s.config} { + b, err := json.Marshal(data) + if err != nil || bytes.Contains(b, []byte(token)) { + return nil, errors.New("private value appeared outside execution carrier") + } + } + productURL, ok := runtimeConfig["url"].(string) + if !ok || len(current) != 1 || current["url"] != productURL || len(trigger) != 1 || trigger["url"] != productURL || len(outputs) != 0 { + return nil, errors.New("capture child input was not URL-only") + } + endpoint, ok := runtimeConfig["server_url"].(string) + parsed, parseErr := url.Parse(endpoint) + if !ok || parseErr != nil || parsed.Scheme != "http" || !net.ParseIP(parsed.Hostname()).IsLoopback() { + return nil, errors.New("fixture endpoint must be loopback HTTP") + } + body, err := json.Marshal(urlPayload{URL: productURL}) + if err != nil { + return nil, errors.New("fixture URL payload encoding failed") + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint+"/submit", bytes.NewReader(body)) + if err != nil { + return nil, errors.New("fixture request construction failed") + } + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Content-Type", "application/json") + transport := &http.Transport{Proxy: nil} + defer transport.CloseIdleConnections() + client := &http.Client{Transport: transport, Timeout: 3 * time.Second, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }} + resp, err := client.Do(req) + if err != nil { + return nil, errors.New("fixture loopback POST failed") + } + _, drainErr := io.Copy(io.Discard, resp.Body) + closeErr := resp.Body.Close() + if drainErr != nil || closeErr != nil || resp.StatusCode != http.StatusNoContent { + return nil, errors.New("fixture loopback POST rejected") + } + } + return &sdk.StepResult{Output: map[string]any{"pid": observed.PID, "carrier_presence": observed.CarrierPresence}}, nil +} + +func main() { + sdk.Serve(&provider{name: strings.TrimSuffix(filepath.Base(os.Args[0]), ".exe")}) +} diff --git a/plugins/configprovider/plugin.go b/plugins/configprovider/plugin.go index 64f4d0cb..cdf79682 100644 --- a/plugins/configprovider/plugin.go +++ b/plugins/configprovider/plugin.go @@ -6,10 +6,13 @@ package configprovider import ( + "encoding/json" "fmt" + "sync" "github.com/GoCodeAlone/modular" "github.com/GoCodeAlone/workflow/config" + "github.com/GoCodeAlone/workflow/interfaces" "github.com/GoCodeAlone/workflow/module" "github.com/GoCodeAlone/workflow/plugin" ) @@ -18,6 +21,12 @@ import ( // that resolves {{config "key"}} references at config load time. type Plugin struct { plugin.BaseEnginePlugin + mu sync.Mutex + credentialed bool + constructed bool + providerName string + registry *module.ConfigRegistry + grants []interfaces.StepCredentialGrant } // New creates a new config provider plugin. @@ -45,6 +54,15 @@ func New() *Plugin { func (p *Plugin) ModuleFactories() map[string]plugin.ModuleFactory { return map[string]plugin.ModuleFactory{ "config.provider": func(name string, cfg map[string]any) modular.Module { + p.mu.Lock() + defer p.mu.Unlock() + if p.credentialed { + if p.constructed || p.registry == nil || name != p.providerName { + return module.NewConfigProviderModuleWithCredentials(name, nil, nil) + } + p.constructed = true + return module.NewConfigProviderModuleWithCredentials(name, p.registry, p.grants) + } return module.NewConfigProviderModule(name, cfg) }, } @@ -62,15 +80,50 @@ func (p *Plugin) ConfigTransformHooks() []plugin.ConfigTransformHook { { Name: "config-provider-expansion", Priority: 1000, // Run before other transform hooks - Hook: configTransformHook, + Hook: p.configTransformHook, }, } } // configTransformHook processes all config.provider modules in the configuration. -func configTransformHook(cfg *config.WorkflowConfig) error { - registry := module.GetConfigRegistry() - registry.Reset() +func (p *Plugin) configTransformHook(cfg *config.WorkflowConfig) error { + p.mu.Lock() + defer p.mu.Unlock() + if p.credentialed { + return fmt.Errorf("credentialed configprovider plugin cannot be reused; create a separate instance per application") + } + if cfg == nil { + return fmt.Errorf("config is required") + } + var grants []interfaces.StepCredentialGrant + providerCount := 0 + for _, modCfg := range cfg.Modules { + if modCfg.Type != "config.provider" { + continue + } + providerCount++ + if raw, exists := modCfg.Config["step_credentials"]; exists { + decoded, err := module.DecodeStepCredentialGrants(raw) + if err != nil { + return err + } + grants = append(grants, decoded...) + } + } + var registry *module.ConfigRegistry + if len(grants) > 0 { + if providerCount != 1 { + return fmt.Errorf("step credentials require exactly one config.provider module") + } + if err := validateOriginalCredentialSteps(cfg, grants); err != nil { + return err + } + p.credentialed = true + registry = module.NewConfigRegistry() + } else { + registry = module.GetConfigRegistry() + registry.Reset() + } found := false for _, modCfg := range cfg.Modules { @@ -82,6 +135,9 @@ func configTransformHook(cfg *config.WorkflowConfig) error { if err := processConfigProvider(registry, modCfg.Config); err != nil { return fmt.Errorf("config.provider module %q: %w", modCfg.Name, err) } + if p.credentialed { + p.providerName = modCfg.Name + } } if !found { @@ -89,6 +145,11 @@ func configTransformHook(cfg *config.WorkflowConfig) error { } registry.Freeze() + if p.credentialed { + p.registry = registry + p.grants = append([]interfaces.StepCredentialGrant(nil), grants...) + module.MirrorConfigRegistry(registry) + } // Expand {{config "key"}} in all module configs (except config.provider itself) for i := range cfg.Modules { @@ -128,45 +189,41 @@ func configTransformHook(cfg *config.WorkflowConfig) error { return nil } -// processConfigProvider parses a single config.provider module's config, -// loads sources, and validates required keys. func processConfigProvider(registry *module.ConfigRegistry, cfg map[string]any) error { - // Parse schema - schemaRaw, ok := cfg["schema"].(map[string]any) - if !ok { - return fmt.Errorf("config.provider requires a 'schema' section") - } - schemaEntries, err := module.ParseSchema(schemaRaw) - if err != nil { - return fmt.Errorf("invalid schema: %w", err) - } - - // Parse and load sources - sourcesRaw, ok := cfg["sources"] - if !ok { - return fmt.Errorf("config.provider requires a 'sources' section") - } - sourcesSlice, ok := sourcesRaw.([]any) - if !ok { - return fmt.Errorf("'sources' must be a list") - } - sources := make([]map[string]any, 0, len(sourcesSlice)) - for _, s := range sourcesSlice { - sm, ok := s.(map[string]any) - if !ok { - return fmt.Errorf("each source must be a map") - } - sources = append(sources, sm) - } - - if err := module.LoadConfigSources(registry, sources, schemaEntries); err != nil { - return fmt.Errorf("loading config sources: %w", err) - } + return module.LoadConfigProvider(registry, cfg) +} - // Validate required keys - if err := module.ValidateRequired(registry, schemaEntries); err != nil { - return err +// validateOriginalCredentialSteps checks literals before any template expansion. +func validateOriginalCredentialSteps(cfg *config.WorkflowConfig, grants []interfaces.StepCredentialGrant) error { + // Parse declared pipeline topology, not arbitrary nested maps or schema hints. + var steps []config.PipelineStepConfig + for _, raw := range cfg.Pipelines { + data, err := json.Marshal(raw) + if err != nil { + return fmt.Errorf("invalid credentialed pipeline") + } + var pl config.PipelineConfig + if err := json.Unmarshal(data, &pl); err != nil { + return fmt.Errorf("invalid credentialed pipeline") + } + steps = append(steps, pl.Steps...) + steps = append(steps, pl.Compensation...) + } + for _, g := range grants { + matches := 0 + for _, step := range steps { + if step.Type != g.StepType || step.Name != g.StepName { + continue + } + matches++ + literal, ok := step.Config[g.Field].(string) + if !ok || literal != g.Ref { + return fmt.Errorf("original step credential literal does not match grant") + } + } + if matches != 1 { + return fmt.Errorf("step credential target is missing or ambiguous") + } } - return nil } diff --git a/plugins/configprovider/plugin_test.go b/plugins/configprovider/plugin_test.go index bdc544b9..2fde00fe 100644 --- a/plugins/configprovider/plugin_test.go +++ b/plugins/configprovider/plugin_test.go @@ -1,12 +1,206 @@ package configprovider import ( + "context" + "log/slog" "testing" + "github.com/GoCodeAlone/modular" "github.com/GoCodeAlone/workflow/config" + "github.com/GoCodeAlone/workflow/interfaces" "github.com/GoCodeAlone/workflow/module" ) +func stepCredentialConfig(token, endpoint string) *config.WorkflowConfig { + return &config.WorkflowConfig{ + Modules: []config.ModuleConfig{{Name: "settings", Type: "config.provider", Config: map[string]any{ + "schema": map[string]any{ + "compute_token": map[string]any{"default": token, "env": "STEP_CREDENTIAL_TEST_TOKEN"}, + "server_url": map[string]any{"default": endpoint, "env": "STEP_CREDENTIAL_TEST_URL"}, + }, + "sources": []any{map[string]any{"type": "defaults"}}, + "step_credentials": []any{map[string]any{"plugin": "test-plugin", "step_type": "step.capture", "step_name": "capture", "field": "auth_token_ref", "ref": "config:compute_token", "scope": "application"}}, + }}}, + Pipelines: map[string]any{"main": map[string]any{"steps": []any{map[string]any{ + "name": "capture", "type": "step.capture", "config": map[string]any{"auth_token_ref": "config:compute_token", "url": `{{config "server_url"}}`}, + }}}}, + } +} + +func TestConfigProviderModuleApplicationOwned(t *testing.T) { + t.Cleanup(module.GetConfigRegistry().Reset) + a, b := New(), New() + ca, cb := stepCredentialConfig("dummy-a", "http://app-a"), stepCredentialConfig("dummy-b", "http://app-b") + if err := a.ConfigTransformHooks()[0].Hook(ca); err != nil { + t.Fatal(err) + } + ma := a.ModuleFactories()["config.provider"]("settings", ca.Modules[0].Config).(*module.ConfigProviderModule) + appA := modular.NewStdApplication(modular.NewStdConfigProvider(nil), slog.Default()) + appA.RegisterModule(ma) + if err := appA.Init(); err != nil { + t.Fatal(err) + } + if err := b.ConfigTransformHooks()[0].Hook(cb); err != nil { + t.Fatal(err) + } + mb := b.ModuleFactories()["config.provider"]("settings", cb.Modules[0].Config).(*module.ConfigProviderModule) + appB := modular.NewStdApplication(modular.NewStdConfigProvider(nil), slog.Default()) + appB.RegisterModule(mb) + if err := appB.Init(); err != nil { + t.Fatal(err) + } + var appRegistry *module.ConfigRegistry + if err := appA.GetService("config.registry", &appRegistry); err != nil || appRegistry != ma.Registry() { + t.Fatal("app DI lost exact registry") + } + if got, ok := ma.Registry().Get("compute_token"); !ok || got != "dummy-a" { + t.Fatal("application A registry was replaced by application B") + } + if got, ok := mb.Registry().Get("compute_token"); !ok || got != "dummy-b" { + t.Fatal("application B registry did not retain its source") + } + for _, c := range []struct { + cfg *config.WorkflowConfig + url string + }{{ca, "http://app-a"}, {cb, "http://app-b"}} { + step := c.cfg.Pipelines["main"].(map[string]any)["steps"].([]any)[0].(map[string]any)["config"].(map[string]any) + if step["url"] != c.url { + t.Fatal("application transform expansion crossed sources") + } + } +} + +func TestConfigTransformApplicationOwned(t *testing.T) { + t.Cleanup(module.GetConfigRegistry().Reset) + t.Setenv("STEP_CREDENTIAL_TEST_TOKEN", "dummy-before") + t.Setenv("STEP_CREDENTIAL_TEST_URL", "http://before") + p := New() + cfg := stepCredentialConfig("", "") + cfg.Modules[0].Config["sources"] = []any{map[string]any{"type": "env"}} + if err := p.ConfigTransformHooks()[0].Hook(cfg); err != nil { + t.Fatal(err) + } + t.Setenv("STEP_CREDENTIAL_TEST_TOKEN", "dummy-after") + t.Setenv("STEP_CREDENTIAL_TEST_URL", "http://after") + b := New() + if err := b.ConfigTransformHooks()[0].Hook(stepCredentialConfig("dummy-b", "http://app-b")); err != nil { + t.Fatal(err) + } + m := p.ModuleFactories()["config.provider"]("settings", cfg.Modules[0].Config).(*module.ConfigProviderModule) + app := modular.NewStdApplication(modular.NewStdConfigProvider(nil), slog.Default()) + app.RegisterModule(m) + if err := app.Init(); err != nil { + t.Fatal(err) + } + var binder interfaces.StepCredentialBinder + if err := app.GetService(module.StepCredentialsService, &binder); err != nil { + t.Fatal(err) + } + bound, err := binder.BindStep(interfaces.StepCredentialTarget{Plugin: "test-plugin", StepType: "step.capture", StepName: "capture"}, map[string]any{"auth_token_ref": "config:compute_token"}) + if err != nil || bound == nil { + t.Fatal("missing app binding") + } + values, err := bound.Resolve(context.Background()) + if err != nil || len(values) != 1 || values[0].Value != "dummy-before" { + t.Fatal("Init reloaded token source") + } + step := cfg.Pipelines["main"].(map[string]any)["steps"].([]any)[0].(map[string]any)["config"].(map[string]any) + if step["url"] != "http://before" { + t.Fatal("transform did not use declared environment") + } + if got, ok := m.Registry().Get("server_url"); !ok || got != step["url"] { + t.Fatal("module endpoint differs from transform snapshot") + } + if got, ok := m.Registry().Get("compute_token"); !ok || got != "dummy-before" { + t.Fatal("module token differs from transform snapshot") + } +} + +func TestConfigTransformStepCredentialPreflight(t *testing.T) { + t.Cleanup(module.GetConfigRegistry().Reset) + for _, tc := range []struct { + name string + change func(*config.WorkflowConfig) + }{ + {"template alias", func(c *config.WorkflowConfig) { + c.Modules[0].Config["schema"].(map[string]any)["alias"] = map[string]any{"default": "config:compute_token"} + c.Pipelines["main"].(map[string]any)["steps"].([]any)[0].(map[string]any)["config"].(map[string]any)["auth_token_ref"] = `{{config "alias"}}` + }}, + {"ref mutation", func(c *config.WorkflowConfig) { + c.Pipelines["main"].(map[string]any)["steps"].([]any)[0].(map[string]any)["config"].(map[string]any)["auth_token_ref"] = "config:other" + }}, + {"missing field", func(c *config.WorkflowConfig) { + delete(c.Pipelines["main"].(map[string]any)["steps"].([]any)[0].(map[string]any)["config"].(map[string]any), "auth_token_ref") + }}, + {"ambiguous target", func(c *config.WorkflowConfig) { c.Pipelines["other"] = c.Pipelines["main"] }}, + {"missing target", func(c *config.WorkflowConfig) { c.Pipelines = nil }}, + {"multiple providers", func(c *config.WorkflowConfig) { + c.Modules = append(c.Modules, config.ModuleConfig{Name: "other", Type: "config.provider", Config: map[string]any{}}) + }}, + {"unknown grant field", func(c *config.WorkflowConfig) { + c.Modules[0].Config["step_credentials"].([]any)[0].(map[string]any)["sensitive"] = true + }}, + {"duplicates", func(c *config.WorkflowConfig) { + gs := c.Modules[0].Config["step_credentials"].([]any) + c.Modules[0].Config["step_credentials"] = append(gs, gs[0]) + }}, + } { + t.Run(tc.name, func(t *testing.T) { + cfg := stepCredentialConfig("dummy", "http://app") + tc.change(cfg) + if err := New().ConfigTransformHooks()[0].Hook(cfg); err == nil { + t.Fatal("invalid granted build accepted") + } + }) + } +} + +func TestConfigTransformStepCredentialReuse(t *testing.T) { + t.Cleanup(module.GetConfigRegistry().Reset) + t.Run("normal reuse and empty grants", func(t *testing.T) { + p := New() + for i := 0; i < 2; i++ { + cfg := stepCredentialConfig("dummy", "http://normal") + cfg.Modules[0].Config["step_credentials"] = []any{} + if err := p.ConfigTransformHooks()[0].Hook(cfg); err != nil { + t.Fatal(err) + } + m := p.ModuleFactories()["config.provider"]("settings", cfg.Modules[0].Config).(*module.ConfigProviderModule) + if m.Registry() != module.GetConfigRegistry() { + t.Fatal("normal source behavior changed") + } + app := module.CreateIsolatedApp(t) + if err := m.Init(app); err != nil { + t.Fatal(err) + } + if _, exists := app.GetServiceEntry(module.StepCredentialsService); exists { + t.Fatal("empty grants authorized private service") + } + } + }) + t.Run("granted reuse fails before source reload", func(t *testing.T) { + p := New() + cfg := stepCredentialConfig("dummy-a", "http://a") + if err := p.ConfigTransformHooks()[0].Hook(cfg); err != nil { + t.Fatal(err) + } + if err := p.ConfigTransformHooks()[0].Hook(stepCredentialConfig("dummy-b", "http://b")); err == nil { + t.Fatal("granted plugin reused") + } + if v, _ := module.GetConfigRegistry().Get("compute_token"); v != "dummy-a" { + t.Fatal("reuse reloaded global") + } + m := p.ModuleFactories()["config.provider"]("settings", cfg.Modules[0].Config) + if err := m.Init(module.CreateIsolatedApp(t)); err != nil { + t.Fatal(err) + } + duplicate := p.ModuleFactories()["config.provider"]("settings", cfg.Modules[0].Config) + if err := duplicate.Init(module.CreateIsolatedApp(t)); err == nil { + t.Fatal("granted module constructed twice") + } + }) +} + func TestPluginMetadata(t *testing.T) { p := New() if p.Name() != "configprovider" { diff --git a/secrets/redactor.go b/secrets/redactor.go index d194911a..45e04e71 100644 --- a/secrets/redactor.go +++ b/secrets/redactor.go @@ -2,7 +2,10 @@ package secrets import ( "context" + "encoding/json" + "net/url" "sort" + "strconv" "strings" "sync" ) @@ -73,6 +76,66 @@ func (r *Redactor) AddValue(label, value string) { r.mu.Unlock() } +// ContainsValue detects known values without rewriting text. Unlike comparing +// Redact's output, it also detects a value equal to its replacement marker. +// JSON and URL escapes are checked for consumers returning encoded receipts. +func (r *Redactor) ContainsValue(text string) bool { + if r == nil { + return false + } + r.mu.RLock() + defer r.mu.RUnlock() + if len(r.known) == 0 { + return false + } + return r.containsValue(text) +} + +func (r *Redactor) containsValue(text string) bool { + for _, value := range r.known { + if value == "" { + continue + } + if strings.Contains(text, value) { + return true + } + encoded, _ := json.Marshal(value) + ascii := strconv.QuoteToASCII(value) + for _, escaped := range []string{string(encoded[1 : len(encoded)-1]), ascii[1 : len(ascii)-1], url.QueryEscape(value), url.PathEscape(value)} { + if strings.Contains(text, escaped) { + return true + } + } + } + // Decode JSON string literals even inside free-text error prefixes. Each + // decoded literal is shorter than its source, so nested escaping terminates. + if !strings.Contains(text, `\`) { + return false + } + for rest := text; ; { + start := strings.IndexByte(rest, '"') + if start < 0 { + break + } + rest = rest[start:] + decoder := json.NewDecoder(strings.NewReader(rest)) + var decoded string + if decoder.Decode(&decoded) == nil { + if r.containsValue(decoded) { + return true + } + rest = rest[decoder.InputOffset():] + } else { + rest = rest[1:] + } + } + var unquoted string + if json.Unmarshal([]byte(`"`+text+`"`), &unquoted) == nil && unquoted != text { + return r.containsValue(unquoted) + } + return false +} + // LoadFromProvider performs a FULL-REPLACE of the known-value set from a // secrets.Provider: it enumerates the provider's keys via List, resolves each // via Get, builds a fresh map off-lock, then swaps it in under a single write diff --git a/secrets/redactor_test.go b/secrets/redactor_test.go index 07e22f9b..1346c29e 100644 --- a/secrets/redactor_test.go +++ b/secrets/redactor_test.go @@ -2,11 +2,72 @@ package secrets import ( "context" + "encoding/json" "strings" "sync" "testing" ) +func TestRedactorContainsValueNestedEscapes(t *testing.T) { + r := NewRedactor() + r.AddValue("credential", "dummy-private-a") + encoded := `{"echo":"\u0064ummy-private-a"}` + for range 10 { + raw, _ := json.Marshal(encoded) + encoded = string(raw) + } + if !r.ContainsValue(encoded) { + t.Fatal("nested JSON escaping bypassed detector") + } + if r.ContainsValue(`{"ordinary":"\\u0064ummy-public-a"}`) { + t.Fatal("ordinary escaped payload rejected") + } +} + +func TestRedactorContainsValue(t *testing.T) { + var nilRedactor *Redactor + for _, r := range []*Redactor{nilRedactor, {}, NewRedactor()} { + detector, ok := any(r).(interface{ ContainsValue(string) bool }) + if !ok { + t.Fatal("known-value presence detector missing") + } + if detector.ContainsValue("ordinary") { + t.Fatal("empty detector matched") + } + r.AddValue("credential", "[REDACTED:credential]") + if r != nil && !detector.ContainsValue("[REDACTED:credential]") { + t.Fatal("replacement-marker-equal value missed") + } + } + r := NewRedactor() + r.AddValue("credential", "dummy-\"private\na") + detector, ok := any(r).(interface{ ContainsValue(string) bool }) + if !ok { + t.Fatal("known-value presence detector missing") + } + for _, s := range []string{"echo dummy-\"private\na", `{"echo":"dummy-\"private\na"}`, `dummy-%22private%0Aa`} { + if !detector.ContainsValue(s) { + t.Fatal("escaped credential missed") + } + } + if detector.ContainsValue("ordinary [REDACTED:other]") { + t.Fatal("ordinary marker matched") + } + r.AddValue("unicode", "dummy-private-a") + if !detector.ContainsValue(`{"echo":"\u0064ummy-private-a"}`) { + t.Fatal("unicode JSON escape missed") + } + if !detector.ContainsValue(`provider error {"echo":"\u0064ummy-private-a"}`) { + t.Fatal("escaped JSON embedded in free text missed") + } + if err := r.LoadFromProvider(context.Background(), &stubProvider{vals: map[string]string{"new": "new-value"}}); err != nil { + t.Fatal(err) + } + if detector.ContainsValue("dummy-private-a") || !detector.ContainsValue("new-value") { + t.Fatal("detector diverged from known-value store") + } +} + // stubProvider is a minimal in-memory secrets.Provider used to exercise // Redactor.LoadFromProvider. It satisfies the full Provider interface // (Name/Get/Set/Delete/List). @@ -96,6 +157,7 @@ func TestRedactor_Concurrent(t *testing.T) { go func() { defer wg.Done() _ = r.Redact("v") + _ = r.ContainsValue("v") }() } wg.Wait() // -race: no data race