From b0ec366cf315765e62ce23a7f497ef1fe2d7e0dd Mon Sep 17 00:00:00 2001 From: cons-tan-tan <132136681+cons-tan-tan@users.noreply.github.com> Date: Wed, 14 Oct 2026 15:06:23 +0000 Subject: [PATCH 1/2] feat: expose resolution restrictions in the Go SDK Expose read-only and no-files options through the strict native call format so older libraries reject restrictions before resolution. Refuse generation that requires storage before invoking generators, and keep hidden dependency names out of scoped refusal diagnostics. --- CHANGELOG.md | 6 + libsecretspec/src/tests.rs | 190 +++++++++++++++++++++++++++ secretspec-go/README.md | 18 +++ secretspec-go/binding_purego.go | 8 +- secretspec-go/binding_purego_test.go | 28 ++++ secretspec-go/secretspec.go | 48 +++++-- secretspec-go/secretspec_test.go | 75 +++++++++++ secretspec/src/error.rs | 3 + secretspec/src/native.rs | 10 ++ secretspec/src/secrets.rs | 47 +++++-- 10 files changed, 404 insertions(+), 29 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5654ca8e9..7a824581a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Go SDK callers can refuse generated-value writeback with `WithReadOnly` + and reject `as_path` declarations with `WithNoFiles`. Older native libraries + reject restricted requests before resolution. Read-only resolution rejects + generation that requires storage before running the generator, + including in `serve --read-only`. + - Pin static binary installations using a version in the installer URL, such as `https://install.secretspec.dev/v0.21.1`. The URL without a version still installs the latest release. diff --git a/libsecretspec/src/tests.rs b/libsecretspec/src/tests.rs index 1e7b93c0a..27f159c68 100644 --- a/libsecretspec/src/tests.rs +++ b/libsecretspec/src/tests.rs @@ -175,6 +175,196 @@ fn call_rejects_unknown_versions_operations_and_source_combinations() { } } +#[test] +fn call_rejects_unknown_options_before_loading_the_manifest() { + let request = serde_json::json!({ + "request_version": 1, "operation": "resolve", + "source": { "kind": "path", "path": "nonexistent.toml" }, + "options": { "unknown_restriction": true } + }); + let result = call(&request.to_string()); + assert_eq!(result["ok"], false); + assert_eq!(result["error"]["kind"], "invalid_request"); +} + +#[test] +fn call_read_only_reads_stored_values_but_refuses_generated_writes() { + let dir = TempDir::new().unwrap(); + let (path, provider) = write_project( + &dir, + r#" +[project] +name = "read-only-ffi" +revision = "1.0" + +[profiles.default] +TOKEN = { description = "token", type = "password", generate = true } +"#, + "TOKEN=already-stored\n", + ); + let request = serde_json::json!({ + "request_version": 1, "operation": "resolve", + "source": { "kind": "path", "path": path }, + "options": { "provider": provider, "read_only": true, "reason": "read-only test" } + }); + let stored = call(&request.to_string()); + assert_eq!(stored["ok"], true, "{stored}"); + assert_eq!( + stored["response"]["secrets"]["TOKEN"]["value"], + "already-stored" + ); + let env_path = dir.path().join(".env"); + assert_eq!( + fs::read_to_string(&env_path).unwrap(), + "TOKEN=already-stored\n" + ); + + fs::write(&env_path, "# preserve this file\n").unwrap(); + let missing = call(&request.to_string()); + assert_eq!(missing["ok"], false, "{missing}"); + assert_eq!(missing["error"]["kind"], "produced_value_write_refused"); + assert_eq!( + fs::read_to_string(&env_path).unwrap(), + "# preserve this file\n" + ); + + // The same declaration can generate and save when the restriction is off. + let mut writable = request; + writable["options"]["read_only"] = false.into(); + let generated = call(&writable.to_string()); + assert_eq!(generated["ok"], true, "{generated}"); + assert!(fs::read_to_string(&env_path).unwrap().contains("TOKEN=")); +} + +#[cfg(unix)] +#[test] +fn call_read_only_rejects_persisted_command_before_generation() { + let dir = TempDir::new().unwrap(); + let marker = dir.path().join("generated"); + let command = format!( + "printf ran > '{}'; printf generated-value", + marker.display().to_string().replace('\'', "'\\''") + ); + let (path, provider) = write_project( + &dir, + &format!( + r#" +[project] +name = "read-only-command-ffi" +revision = "1.0" + +[profiles.default.TOKEN] +description = "token" +type = "command" +[profiles.default.TOKEN.generate] +command = {} + +[profiles.default.COMPOSED] +description = "composed" +composed = "prefix:${{TOKEN}}" + +[scopes.composed] +secrets = ["COMPOSED"] +"#, + serde_json::to_string(&command).unwrap() + ), + "# unchanged\n", + ); + let mut request = serde_json::json!({ + "request_version": 1, "operation": "resolve", + "source": { "kind": "path", "path": path }, + "options": { "provider": provider, "read_only": true, "reason": "read-only command test" } + }); + for scope in [None, Some("composed")] { + request["options"]["scope"] = scope.into(); + let refused = call(&request.to_string()); + assert_eq!(refused["ok"], false, "{refused}"); + assert_eq!( + refused["error"]["kind"], "produced_value_write_refused", + "{refused}" + ); + assert_eq!( + refused["error"]["message"] + .as_str() + .unwrap() + .contains("TOKEN"), + scope.is_none(), + "{refused}" + ); + assert!(!marker.exists(), "the refused generator ran"); + } + assert_eq!( + fs::read_to_string(dir.path().join(".env")).unwrap(), + "# unchanged\n" + ); + + // The same generator remains allowed when its value is never stored. + request["options"]["provider"] = "null://".into(); + request["options"]["scope"] = serde_json::Value::Null; + let generated = call(&request.to_string()); + assert_eq!(generated["ok"], true, "{generated}"); + assert_eq!( + generated["response"]["secrets"]["TOKEN"]["value"], + "generated-value" + ); + assert_eq!(fs::read_to_string(marker).unwrap(), "ran"); +} + +#[test] +fn call_no_files_checks_the_selected_dependency_closure_before_resolution() { + let dir = TempDir::new().unwrap(); + let (path, provider) = write_project( + &dir, + r#" +[project] +name = "no-files-ffi" +revision = "1.0" + +[profiles.default] +FILE = { description = "file", type = "password", generate = true, as_path = true } +PLAIN = { description = "plain", default = "inline-value" } +COMPOSED = { description = "composed", composed = "prefix:${FILE}" } + +[scopes.plain] +secrets = ["PLAIN"] +[scopes.composed] +secrets = ["COMPOSED"] +"#, + "# unchanged\n", + ); + let mut request = serde_json::json!({ + "request_version": 1, "operation": "resolve", + "source": { "kind": "path", "path": path }, + "options": { "provider": provider, "no_files": true, "reason": "no files test" } + }); + for scope in [None, Some("composed")] { + request["options"]["scope"] = scope.into(); + let result = call(&request.to_string()); + assert_eq!(result["ok"], false, "{result}"); + assert_eq!(result["error"]["kind"], "file_value_refused", "{result}"); + assert_eq!( + result["error"]["message"] + .as_str() + .unwrap() + .contains("FILE"), + scope.is_none(), + "{result}" + ); + // Even generation preceding file materialization must not have run. + assert_eq!( + fs::read_to_string(dir.path().join(".env")).unwrap(), + "# unchanged\n" + ); + } + request["options"]["scope"] = "plain".into(); + let plain = call(&request.to_string()); + assert_eq!(plain["ok"], true, "{plain}"); + assert_eq!( + plain["response"]["secrets"]["PLAIN"]["value"], + "inline-value" + ); +} + #[test] fn call_rejects_unknown_inline_declaration_fields() { let request = serde_json::json!({ diff --git a/secretspec-go/README.md b/secretspec-go/README.md index a45072b06..9cd47629a 100644 --- a/secretspec-go/README.md +++ b/secretspec-go/README.md @@ -78,6 +78,24 @@ resolved, err := secretspec.New().WithScope("api").Load() `resolved.Close()` (e.g. `defer resolved.Close()`) when done so the secret files do not accumulate in the temp dir. +## Resolution restrictions (unreleased) + +Use `WithReadOnly(true)` to refuse resolution that needs to save a generated +or prompted value back to a provider. Stored values remain readable even when +their declarations enable generation. This uses the same restriction as +`secretspec serve --read-only`. Generation that requires storage is refused +before the generator runs. Local caches, auditing, temporary files and provider +commands remain enabled; generators that do not persist values may still run. + +Use `WithNoFiles(true)` when your integration cannot own temporary files. +It rejects `as_path` declarations in the selected profile/scope, including +hidden dependencies, before fetching values or creating files. Unselected +declarations do not cause a refusal. + +The target release for these options has not been assigned. Use a native +library built from the same checkout as the SDK. Older libraries reject +restricted requests before resolution. Both options default to false. + ## Value-free report `Report()` returns the inventory/preflight view: per-secret status and diff --git a/secretspec-go/binding_purego.go b/secretspec-go/binding_purego.go index 30cb87f8c..3d063ccb0 100644 --- a/secretspec-go/binding_purego.go +++ b/secretspec-go/binding_purego.go @@ -118,9 +118,9 @@ func ensureLoaded() error { return loadErr } -// ensureCallLoaded binds the capability-gated call symbol only for SDKs that -// request an inline declaration. Keeping it out of ensureLoaded preserves the -// legacy path/search API for an older runtime that predates secretspec_call. +// ensureCallLoaded binds the capability-gated call symbol for versioned requests. +// Keeping it out of ensureLoaded preserves the legacy path/search API for an +// older runtime that predates secretspec_call. func ensureCallLoaded() error { if err := ensureLoaded(); err != nil { return err @@ -129,7 +129,7 @@ func ensureCallLoaded() error { defer func() { if r := recover(); r != nil { callErr = &Error{Kind: "capability", Message: fmt.Sprintf( - "loaded libsecretspec does not support inline specs (missing secretspec_call): %v", r)} + "loaded libsecretspec does not support this request (missing secretspec_call): %v", r)} } }() purego.RegisterLibFunc(&cCall, libHandle, "secretspec_call") diff --git a/secretspec-go/binding_purego_test.go b/secretspec-go/binding_purego_test.go index 0cad5b599..0d1241f2e 100644 --- a/secretspec-go/binding_purego_test.go +++ b/secretspec-go/binding_purego_test.go @@ -3,10 +3,38 @@ package secretspec import ( + "errors" "runtime" "testing" ) +func TestRestrictionsDoNotFallBackToLegacyResolver(t *testing.T) { + if err := ensureCallLoaded(); err != nil { + t.Fatal(err) + } + previousErr, previousResolve := callErr, cResolve + t.Cleanup(func() { callErr, cResolve = previousErr, previousResolve }) + callErr = &Error{Kind: "capability", Message: "missing secretspec_call"} + cResolve = func(string) uintptr { + t.Fatal("restricted request used the legacy resolver") + return 0 + } + for _, builder := range []*Builder{New().WithReadOnly(true), New().WithNoFiles(true)} { + _, err := builder.Load() + if !errors.Is(err, callErr) { + t.Fatalf("expected capability error, got %v", err) + } + } + cResolve = previousResolve + manifestPath, provider := writeProject(t, "DATABASE_URL=postgres://db\n") + resolved, err := New().WithPath(manifestPath).WithProvider(provider). + WithReadOnly(false).WithNoFiles(false).WithReason("compatibility test").Load() + if err != nil { + t.Fatalf("disabled restrictions must still work without secretspec_call: %v", err) + } + defer resolved.Close() +} + func TestLibraryNamesPreferLibsecretspecAndRetainPre020Fallback(t *testing.T) { names := libNames() want := []string{"libsecretspec.so", "libsecretspec_ffi.so"} diff --git a/secretspec-go/secretspec.go b/secretspec-go/secretspec.go index 03899b475..a6ed2b4b7 100644 --- a/secretspec-go/secretspec.go +++ b/secretspec-go/secretspec.go @@ -198,8 +198,7 @@ func (b *Builder) set(key string, value any) *Builder { func (b *Builder) WithPath(path string) *Builder { // Sources are a tagged union in the native request. Choosing a path after - // an inline source deliberately selects the legacy path source instead of - // serializing an ambiguous request. + // an inline source replaces it instead of serializing an ambiguous request. b.inline = nil return b.set("path", path) } @@ -214,6 +213,18 @@ func (b *Builder) WithCaller(caller CallerContext) *Builder { } func (b *Builder) WithNoValues(v bool) *Builder { return b.set("no_values", v) } +// WithReadOnly refuses resolutions that would write generated or prompted values +// back to a provider. Existing values remain readable. This does not disable +// local caches or as_path files. Older native libraries fail +// instead of silently ignoring the restriction. +// This option is unreleased; use a native library from the same checkout. +func (b *Builder) WithReadOnly(v bool) *Builder { return b.set("read_only", v) } + +// WithNoFiles rejects as_path declarations in the selected profile/scope and +// their dependencies before creating temporary files. +// This option is unreleased; use a native library from the same checkout. +func (b *Builder) WithNoFiles(v bool) *Builder { return b.set("no_files", v) } + // WithInlineSpec resolves a strict, versioned inline declaration instead of a // filesystem manifest. `spec` is serialized as the native inline-spec v2 (0.21+) // document (project/profiles/secrets); `baseDir` resolves relative provider @@ -402,9 +413,8 @@ func (b *Builder) Report() (*Report, error) { }, nil } -// execute keeps the legacy request exactly as it was for path/search callers. -// Inline callers use the separate C symbol, making an old runtime's missing -// capability an explicit load error instead of a filesystem fallback. +// Restrictions use the strict native call format so older libraries reject them +// before resolution instead of ignoring unknown legacy request fields. func (b *Builder) execute(mode string) (string, error) { if b.req == nil { b.req = map[string]any{} @@ -416,7 +426,15 @@ func (b *Builder) execute(mode string) (string, error) { if mode != "" { options["mode"] = mode } - if b.inline == nil { + readOnly, _ := options["read_only"].(bool) + noFiles, _ := options["no_files"].(bool) + if !readOnly { + delete(options, "read_only") + } + if !noFiles { + delete(options, "no_files") + } + if b.inline == nil && !readOnly && !noFiles { if err := ensureLoaded(); err != nil { return "", err } @@ -429,14 +447,22 @@ func (b *Builder) execute(mode string) (string, error) { if err := ensureCallLoaded(); err != nil { return "", err } + source := map[string]any{"kind": "search"} + if path, ok := options["path"]; ok { + source = map[string]any{"kind": "path", "path": path} + delete(options, "path") + } + if b.inline != nil { + source = map[string]any{ + "kind": "inline", "spec_version": 2, + "base_dir": b.inline.baseDir, "spec": b.inline.spec, + } + } payload, err := json.Marshal(map[string]any{ "request_version": 1, "operation": "resolve", - "source": map[string]any{ - "kind": "inline", "spec_version": 2, - "base_dir": b.inline.baseDir, "spec": b.inline.spec, - }, - "options": options, + "source": source, + "options": options, }) if err != nil { return "", err diff --git a/secretspec-go/secretspec_test.go b/secretspec-go/secretspec_test.go index 1b04ab09f..814ac160e 100644 --- a/secretspec-go/secretspec_test.go +++ b/secretspec-go/secretspec_test.go @@ -7,6 +7,7 @@ import ( "os/exec" "path/filepath" "runtime" + "strings" "testing" ) @@ -197,6 +198,80 @@ func TestMissingRequired(t *testing.T) { } } +func TestReadOnly(t *testing.T) { + manifestPath, provider := writeProject(t, "TOKEN=stored-token\n") + if err := os.WriteFile(manifestPath, []byte(` +[project] +name = "go-read-only" +revision = "1.0" +[profiles.default] +TOKEN = { description = "token", type = "password", generate = true } +`), 0o600); err != nil { + t.Fatal(err) + } + builder := New().WithPath(manifestPath).WithProvider(provider). + WithReadOnly(true).WithNoFiles(true).WithReason("go read-only test") + resolved, err := builder.Load() + if err != nil { + t.Fatal(err) + } + defer resolved.Close() + if resolved.Secrets["TOKEN"].Get() != "stored-token" { + t.Fatal("did not read the stored value") + } + envPath := filepath.Join(filepath.Dir(manifestPath), ".env") + if err := os.WriteFile(envPath, []byte("# unchanged\n"), 0o600); err != nil { + t.Fatal(err) + } + report, err := builder.Report() + if err != nil { + t.Fatal(err) + } + if len(report.Secrets) != 1 || report.Secrets[0].Status != "missing_required" { + t.Fatalf("expected missing required secret in report, got %+v", report.Secrets) + } + // Report must not leave its mode on a reused builder. + _, err = builder.Load() + var native *Error + if !errors.As(err, &native) || native.Kind != "produced_value_write_refused" { + t.Fatalf("expected produced_value_write_refused, got %v", err) + } + content, err := os.ReadFile(envPath) + if err != nil || string(content) != "# unchanged\n" { + t.Fatalf("store changed after refusal: %q, %v", content, err) + } + // Clearing both restrictions restores the writable legacy request path. + generated, err := builder.WithReadOnly(false).WithNoFiles(false).Load() + if err != nil { + t.Fatalf("disabled restrictions should allow generation: %v", err) + } + defer generated.Close() + if generated.Secrets["TOKEN"].Get() == "" { + t.Fatal("expected a generated token") + } + content, err = os.ReadFile(envPath) + if err != nil || !strings.Contains(string(content), "TOKEN=") { + t.Fatalf("generated token was not stored: %v", err) + } +} + +func TestNoFilesWithInlineSpec(t *testing.T) { + spec := map[string]any{ + "project": map[string]any{"name": "go-no-files"}, + "profiles": map[string]any{"default": map[string]any{ + "secrets": map[string]any{ + "FILE": map[string]any{"description": "file", "as_path": true, "default": "contents"}, + }, + }}, + } + _, err := New().WithInlineSpec(spec, t.TempDir()).WithProvider("null://"). + WithNoFiles(true).WithReason("go no files test").Load() + var native *Error + if !errors.As(err, &native) || native.Kind != "file_value_refused" { + t.Fatalf("expected file_value_refused, got %v", err) + } +} + func TestAsPath(t *testing.T) { dir := t.TempDir() manifestPath := filepath.Join(dir, "secretspec.toml") diff --git a/secretspec/src/error.rs b/secretspec/src/error.rs index 115229b1a..34148f27c 100644 --- a/secretspec/src/error.rs +++ b/secretspec/src/error.rs @@ -81,6 +81,8 @@ pub enum SecretSpecError { "Secret '{0}' would be produced and stored, and this session may not write to a provider" )] ProducedValueWriteRefused(String), + #[error("Secret '{0}' uses as_path, but this caller does not accept temporary files")] + FileValueRefused(String), #[error( "Composed secret '{0}' is derived from other secrets and has no stored value to change" )] @@ -190,6 +192,7 @@ impl SecretSpecError { SecretSpecError::PromptUnavailable(_) => "prompt_unavailable", SecretSpecError::PromptValueEmpty(_) => "prompt_value_empty", SecretSpecError::ProducedValueWriteRefused(_) => "produced_value_write_refused", + SecretSpecError::FileValueRefused(_) => "file_value_refused", SecretSpecError::ComposedSecretReadOnly(_) => "composed_secret_read_only", SecretSpecError::ExtractedSecretReadOnly(_) => "extracted_secret_read_only", SecretSpecError::DerivedSecretReadOnly { .. } => "derived_secret_read_only", diff --git a/secretspec/src/native.rs b/secretspec/src/native.rs index 53e9bfc7c..7d5ff1424 100644 --- a/secretspec/src/native.rs +++ b/secretspec/src/native.rs @@ -57,6 +57,10 @@ struct Options { #[serde(default)] no_values: bool, #[serde(default)] + read_only: bool, + #[serde(default)] + no_files: bool, + #[serde(default)] mode: Mode, } @@ -442,6 +446,12 @@ enum Source { } fn apply_options(mut app: Secrets, options: Options) -> serde_json::Value { + if options.read_only { + app.refuse_produced_writes(); + } + if options.no_files { + app.refuse_file_values(); + } if let Some(provider) = options.provider { app.set_provider(provider); } diff --git a/secretspec/src/secrets.rs b/secretspec/src/secrets.rs index 05aeafa69..9a7214427 100644 --- a/secretspec/src/secrets.rs +++ b/secretspec/src/secrets.rs @@ -1811,7 +1811,7 @@ impl<'secrets, 'plan, 'filter, 'addresses> if materialize.values() { generated = true; let generated_value = manager - .try_generate_secret(planned, profile)? + .try_generate_secret(planned, profile, diagnostic_name)? .expect("compiled Generate policy has a generator"); self.insert_generated( planned, @@ -2630,6 +2630,8 @@ pub struct Secrets { /// operator withheld the mutation methods; see /// [`Secrets::refuse_produced_writes`]. refuse_produced_writes: bool, + /// Reject file-valued declarations, including hidden dependencies. + refuse_file_values: bool, } /// Credential-free description of one provider write, computed after routing @@ -2883,6 +2885,7 @@ impl Secrets { provider_credential_prompt: None, progress: true, refuse_produced_writes: false, + refuse_file_values: false, } } @@ -3018,6 +3021,7 @@ impl Secrets { provider_credential_prompt: None, progress: true, refuse_produced_writes: false, + refuse_file_values: false, }) } @@ -3077,11 +3081,15 @@ impl Secrets { /// means those writes too, so this closes the paths that would otherwise /// let a read reach the store. It does not touch SecretSpec's own cache: /// populating a derived copy is not a change to the secret. - #[cfg(feature = "cli")] pub(crate) fn refuse_produced_writes(&mut self) { self.refuse_produced_writes = true; } + /// Refuse resolutions requiring `as_path` files before any are created. + pub(crate) fn refuse_file_values(&mut self) { + self.refuse_file_values = true; + } + /// Rejects a produced value that would have to be stored, before it is /// generated or asked of a person. fn ensure_produced_write_allowed(&self, name: &str) -> Result<()> { @@ -6674,6 +6682,7 @@ impl Secrets { &self, planned: &PlannedSecret, profile_name: &str, + diagnostic_name: &str, ) -> Result> { let name = planned.name.as_str(); let gen_config = match &planned.config().generate { @@ -6696,12 +6705,6 @@ impl Secrets { } }; - let value = if secret_type == "x509_identity" { - GeneratedValue::Binary(crate::x509_identity::generate(gen_config)?) - } else { - GeneratedValue::Text(crate::generator::generate(secret_type, gen_config)?) - }; - // Store the generated value at the plan's address, through the plan's // write route: the same decisions every other write path executes. let route = planned @@ -6716,8 +6719,22 @@ impl Secrets { )?; let addr = address.as_address(); let backend = self.write_provider_for_route(route, Some(profile_name))?; + let persistence = backend.generated_value_persistence(); + + // Reject a persisting resolution before generation: a command generator + // can itself have side effects. Ephemeral values never touch a store, + // so the caller's write restriction must not prevent their generation. + if persistence == ProducedValuePersistence::Persist { + self.ensure_produced_write_allowed(diagnostic_name)?; + } + + let value = if secret_type == "x509_identity" { + GeneratedValue::Binary(crate::x509_identity::generate(gen_config)?) + } else { + GeneratedValue::Text(crate::generator::generate(secret_type, gen_config)?) + }; - if backend.generated_value_persistence() == ProducedValuePersistence::Ephemeral { + if persistence == ProducedValuePersistence::Ephemeral { if self.progress { eprintln!( "{} {} - generated for this resolution without provider storage (profile: {})", @@ -6729,11 +6746,6 @@ impl Secrets { return Ok(Some(value)); } - // Checked only on the persisting branch: an ephemeral provider returned - // above without touching a store, and refusing that would deny a read - // that never wrote anything. - self.ensure_produced_write_allowed(name)?; - // The provider states why a write is refused; wrapping it here would // only nest a second "Provider operation failed" prefix. backend.check_writable(addr)?; @@ -7938,6 +7950,13 @@ impl Secrets { output_filter: Option<&HashSet>, read_addresses: Option<&mut HashMap>, ) -> ExecutionResult { + if self.refuse_file_values + && let Some(secret) = plan.secrets.iter().find(|secret| secret.as_path()) + { + return Err(SecretSpecError::FileValueRefused( + Self::diagnostic_secret_name(&secret.name, output_filter).to_string(), + )); + } ResolutionExecution::new(self, plan, materialize, output_filter, read_addresses).run() } From 3a7f62c3311380b14c197bdfb6435300d4241b07 Mon Sep 17 00:00:00 2001 From: cons-tan-tan <132136681+cons-tan-tan@users.noreply.github.com> Date: Wed, 14 Oct 2026 15:08:42 +0000 Subject: [PATCH 2/2] feat: add a read-only Terraform provider prototype Resolve existing manifests as secretspec_secrets ephemeral resources or data sources, returning a sensitive map for the selected profile/scope. Enable SDK read-only and no-files restrictions for every lookup. Document the state persistence tradeoff and local development setup, and run Terraform acceptance tests in the SDK CI job. --- .github/workflows/sdks.yml | 8 + CHANGELOG.md | 11 +- scripts/ci-sdks.sh | 3 + secretspec-terraform/.gitignore | 5 + secretspec-terraform/README.md | 103 +++++++++ .../examples/data-source/main.tf | 18 ++ .../examples/ephemeral/main.tf | 22 ++ secretspec-terraform/examples/secretspec.toml | 16 ++ secretspec-terraform/go.mod | 36 ++++ secretspec-terraform/go.sum | 101 +++++++++ .../internal/provider/provider.go | 45 ++++ .../internal/provider/secrets.go | 90 ++++++++ .../internal/provider/secrets_data_source.go | 46 ++++ .../provider/secrets_ephemeral_resource.go | 46 ++++ .../internal/provider/secrets_test.go | 83 +++++++ secretspec-terraform/main.go | 17 ++ secretspec-terraform/terraform_test.go | 203 ++++++++++++++++++ 17 files changed, 848 insertions(+), 5 deletions(-) create mode 100644 secretspec-terraform/.gitignore create mode 100644 secretspec-terraform/README.md create mode 100644 secretspec-terraform/examples/data-source/main.tf create mode 100644 secretspec-terraform/examples/ephemeral/main.tf create mode 100644 secretspec-terraform/examples/secretspec.toml create mode 100644 secretspec-terraform/go.mod create mode 100644 secretspec-terraform/go.sum create mode 100644 secretspec-terraform/internal/provider/provider.go create mode 100644 secretspec-terraform/internal/provider/secrets.go create mode 100644 secretspec-terraform/internal/provider/secrets_data_source.go create mode 100644 secretspec-terraform/internal/provider/secrets_ephemeral_resource.go create mode 100644 secretspec-terraform/internal/provider/secrets_test.go create mode 100644 secretspec-terraform/main.go create mode 100644 secretspec-terraform/terraform_test.go diff --git a/.github/workflows/sdks.yml b/.github/workflows/sdks.yml index 40edf4743..94086adbc 100644 --- a/.github/workflows/sdks.yml +++ b/.github/workflows/sdks.yml @@ -61,7 +61,15 @@ jobs: name: devenv - name: Install devenv.sh run: nix profile install nixpkgs#devenv + - name: Install Terraform for provider acceptance tests + uses: hashicorp/setup-terraform@dfe3c3f87815947d99a8997f908cb6525fc44e9e # v4.0.1 + with: + # Keep testing the minimum supported ephemeral-resource release series. + terraform_version: "1.10.5" + terraform_wrapper: false - name: Build cdylib and run all SDK suites + env: + TF_ACC: "1" run: devenv shell -- bash scripts/ci-sdks.sh swift: diff --git a/CHANGELOG.md b/CHANGELOG.md index 7a824581a..db2795747 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,11 +9,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- Go SDK callers can refuse generated-value writeback with `WithReadOnly` - and reject `as_path` declarations with `WithNoFiles`. Older native libraries - reject restricted requests before resolution. Read-only resolution rejects - generation that requires storage before running the generator, - including in `serve --read-only`. +- Read existing manifests from Terraform using `secretspec_secrets` ephemeral + resources or data sources. Both refuse generated-value writeback and + `as_path` declarations. Go SDK callers can select these restrictions with + `WithReadOnly` and `WithNoFiles`; older native libraries fail without + resolving the request. Read-only resolution rejects generation that requires + storage before running the generator, including in `serve --read-only`. - Pin static binary installations using a version in the installer URL, such as `https://install.secretspec.dev/v0.21.1`. The URL without a version still diff --git a/scripts/ci-sdks.sh b/scripts/ci-sdks.sh index 9604184ed..3d39477ab 100755 --- a/scripts/ci-sdks.sh +++ b/scripts/ci-sdks.sh @@ -106,6 +106,9 @@ run_go() { echo "==> Go (default purego/dlopen path)" ( cd secretspec-go && go test ./... ) + echo "==> Terraform provider" + ( cd secretspec-terraform && go test ./... ) + echo "==> Go (-tags static: cgo links the archive in)" # Stage the debug archive + header + generated cgo LDFLAGS, then exercise the # static binding. This is the glibc self-contained build; the fully-static musl diff --git a/secretspec-terraform/.gitignore b/secretspec-terraform/.gitignore new file mode 100644 index 000000000..86896af5c --- /dev/null +++ b/secretspec-terraform/.gitignore @@ -0,0 +1,5 @@ +.terraform/ +.terraform.lock.hcl +terraform.tfstate* +*.tfplan +terraform-provider-secretspec diff --git a/secretspec-terraform/README.md b/secretspec-terraform/README.md new file mode 100644 index 000000000..08953c22f --- /dev/null +++ b/secretspec-terraform/README.md @@ -0,0 +1,103 @@ +# SecretSpec Terraform provider (unreleased prototype) + +Read an existing `secretspec.toml` through the SecretSpec Go SDK and Rust +resolver. The prototype offers `secretspec_secrets` as both an ephemeral +resource and a data source. It has no managed resources. The target release +has not been assigned. + +| | Ephemeral resource | Data source | +| --- | --- | --- | +| Terraform | 1.10+ | 1.0+ | +| Result | Sensitive `map(string)` in `values` | Sensitive `map(string)` in `values` | +| Persistence | Values omitted from state and saved plans | Entire map saved, including unused entries | +| References | Ephemeral contexts, such as provider configuration, write-only arguments, or provisioners | Ordinary resource arguments and other Terraform expressions | + +Both accept a required `path` and optional `profile`, `scope`, and `reason`. +Relative paths are resolved from Terraform's working directory. Set `reason` +when the manifest's access policy requires it. Write-only arguments require +Terraform 1.11+ and support from the consuming provider. +Profile and scope selection otherwise follows SecretSpec's defaults. Provider +selection comes from the manifest and SecretSpec configuration. Missing optional +secrets are omitted. Values remain strings, including values with a SecretSpec +type declaration. There is no single-secret lookup; use a manifest scope to +limit the returned map. + +Existing values can be read even when their declarations enable generation. +Both refuse resolution that requires saving a generated or prompted value +before generation or prompting. `as_path` is rejected before file creation, +including hidden dependencies of composed or derived values. Local caches, +auditing and provider commands remain enabled; generators that do not persist +values may still run. This is not a sandbox for those commands. +Generic provider errors are summarized because their text can contain secret +values. + +## Run the prototype locally + +Requires Go 1.25+, the repository's Rust toolchain, and Terraform. Build the +provider and native library from the **same checkout**. + +From the repository root: + +```sh +cargo build -p libsecretspec +mkdir -p "$PWD/target/terraform-provider" +(cd secretspec-terraform && go build -o ../target/terraform-provider/terraform-provider-secretspec .) + +# Linux; on macOS use libsecretspec.dylib. +export SECRETSPEC_FFI_LIB="$PWD/target/debug/libsecretspec.so" +``` + +Create a Terraform CLI configuration file with the **absolute** provider +directory: + +```hcl +provider_installation { + dev_overrides { + "cachix/secretspec" = "/absolute/path/to/secretspec/target/terraform-provider" + } + direct {} +} +``` + +`cachix/secretspec` is the local development address. These examples use +[development overrides](https://developer.hashicorp.com/terraform/cli/config/config-file#development-overrides-for-provider-developers) +and run without `terraform init`, which would still look for a published +provider in the Registry. + +The [ephemeral example](examples/ephemeral/main.tf) consumes a token in a +provisioner without persisting it. The [data source example](examples/data-source/main.tf) +uses it in an ordinary resource argument and persists it; that example requires +Terraform 1.4+ because it uses `terraform_data`. Both use the shared +[example manifest](examples/secretspec.toml), which reads `API_TOKEN` from the +environment. + +From the repository root, try the ephemeral example with a dummy token. Set +`TF_CLI_CONFIG_FILE` to the absolute path of the CLI configuration created above: + +```sh +export TF_CLI_CONFIG_FILE="/absolute/path/to/terraform.tfrc" +export API_TOKEN="example-token" +terraform -chdir=secretspec-terraform/examples/ephemeral plan +terraform -chdir=secretspec-terraform/examples/ephemeral apply +``` + +`plan` checks secret resolution; the first `apply` also runs the provisioner, +which checks that the token is non-empty. To try the data source, use +`secretspec-terraform/examples/data-source` as the `-chdir` path instead. + +## Tests + +From the repository root, with `SECRETSPEC_FFI_LIB` pointing to the freshly built +library: + +```sh +cd secretspec-terraform +go test ./... +TF_ACC=1 go test ./... +``` + +The second command also runs real Terraform plan/apply operations using only +temporary dotenv stores and the built-in `terraform_data` resource. It verifies +sensitive output, ephemeral omission from saved plans/state, full-map data +source persistence, scope/profile selection, and refusal before provider +writeback. Terraform 1.10+ must be on `PATH`, or set `TF_ACC_TERRAFORM_PATH`. diff --git a/secretspec-terraform/examples/data-source/main.tf b/secretspec-terraform/examples/data-source/main.tf new file mode 100644 index 000000000..bbeb91cc6 --- /dev/null +++ b/secretspec-terraform/examples/data-source/main.tf @@ -0,0 +1,18 @@ +# Unreleased SecretSpec prototype: configure the local dev_overrides first. +terraform { + required_version = ">= 1.4" + required_providers { + secretspec = { source = "cachix/secretspec" } + } +} + +data "secretspec_secrets" "app" { + path = "${path.module}/../secretspec.toml" + scope = "terraform" + reason = "Configure application" +} + +# The entire data source map and this input are persisted in state. +resource "terraform_data" "app" { + input = data.secretspec_secrets.app.values["API_TOKEN"] +} diff --git a/secretspec-terraform/examples/ephemeral/main.tf b/secretspec-terraform/examples/ephemeral/main.tf new file mode 100644 index 000000000..cc3a9d004 --- /dev/null +++ b/secretspec-terraform/examples/ephemeral/main.tf @@ -0,0 +1,22 @@ +# Unreleased SecretSpec prototype: configure the local dev_overrides first. +terraform { + required_version = ">= 1.10" + required_providers { + secretspec = { source = "cachix/secretspec" } + } +} + +ephemeral "secretspec_secrets" "app" { + path = "${path.module}/../secretspec.toml" + scope = "terraform" + reason = "Configure application" +} + +resource "terraform_data" "app" { + provisioner "local-exec" { + command = "test -n \"$API_TOKEN\"" + environment = { + API_TOKEN = ephemeral.secretspec_secrets.app.values["API_TOKEN"] + } + } +} diff --git a/secretspec-terraform/examples/secretspec.toml b/secretspec-terraform/examples/secretspec.toml new file mode 100644 index 000000000..f93fe099d --- /dev/null +++ b/secretspec-terraform/examples/secretspec.toml @@ -0,0 +1,16 @@ +# Used by the unreleased Terraform integration prototype. +[project] +name = "terraform-example" +revision = "1.0" + +[providers] +env = "env://" + +[defaults] +providers = ["env"] + +[profiles.default] +API_TOKEN = { description = "Token consumed by Terraform" } + +[scopes.terraform] +secrets = ["API_TOKEN"] diff --git a/secretspec-terraform/go.mod b/secretspec-terraform/go.mod new file mode 100644 index 000000000..6783732b9 --- /dev/null +++ b/secretspec-terraform/go.mod @@ -0,0 +1,36 @@ +module github.com/cachix/secretspec/secretspec-terraform + +go 1.25.0 + +require ( + github.com/cachix/secretspec/secretspec-go v0.0.0 + github.com/hashicorp/terraform-plugin-framework v1.19.0 +) + +require ( + github.com/ebitengine/purego v0.8.2 // indirect + github.com/fatih/color v1.18.0 // indirect + github.com/golang/protobuf v1.5.4 // indirect + github.com/hashicorp/go-hclog v1.6.3 // indirect + github.com/hashicorp/go-plugin v1.7.0 // indirect + github.com/hashicorp/go-uuid v1.0.3 // indirect + github.com/hashicorp/terraform-plugin-go v0.31.0 // indirect + github.com/hashicorp/terraform-plugin-log v0.10.0 // indirect + github.com/hashicorp/terraform-registry-address v0.4.0 // indirect + github.com/hashicorp/terraform-svchost v0.1.1 // indirect + github.com/hashicorp/yamux v0.1.2 // indirect + github.com/mattn/go-colorable v0.1.14 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mitchellh/go-testing-interface v1.14.1 // indirect + github.com/oklog/run v1.1.0 // indirect + github.com/vmihailenco/msgpack/v5 v5.4.1 // indirect + github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect + golang.org/x/net v0.48.0 // indirect + golang.org/x/sys v0.39.0 // indirect + golang.org/x/text v0.32.0 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 // indirect + google.golang.org/grpc v1.79.2 // indirect + google.golang.org/protobuf v1.36.11 // indirect +) + +replace github.com/cachix/secretspec/secretspec-go => ../secretspec-go diff --git a/secretspec-terraform/go.sum b/secretspec-terraform/go.sum new file mode 100644 index 000000000..cbc3ed46a --- /dev/null +++ b/secretspec-terraform/go.sum @@ -0,0 +1,101 @@ +github.com/bufbuild/protocompile v0.14.1 h1:iA73zAf/fyljNjQKwYzUHD6AD4R8KMasmwa/FBatYVw= +github.com/bufbuild/protocompile v0.14.1/go.mod h1:ppVdAIhbr2H8asPk6k4pY7t9zB1OU5DoEw9xY/FUi1c= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/ebitengine/purego v0.8.2 h1:jPPGWs2sZ1UgOSgD2bClL0MJIqu58nOmIcBuXr62z1I= +github.com/ebitengine/purego v0.8.2/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= +github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk= +github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= +github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k= +github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M= +github.com/hashicorp/go-plugin v1.7.0 h1:YghfQH/0QmPNc/AZMTFE3ac8fipZyZECHdDPshfk+mA= +github.com/hashicorp/go-plugin v1.7.0/go.mod h1:BExt6KEaIYx804z8k4gRzRLEvxKVb+kn0NMcihqOqb8= +github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8= +github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= +github.com/hashicorp/terraform-plugin-framework v1.19.0 h1:q0bwyhxAOR3vfdgbk9iplv3MlTv/dhBHTXjQOtQDoBA= +github.com/hashicorp/terraform-plugin-framework v1.19.0/go.mod h1:YRXOBu0jvs7xp4AThBbX4mAzYaMJ1JgtFH//oGKxwLc= +github.com/hashicorp/terraform-plugin-go v0.31.0 h1:0Fz2r9DQ+kNNl6bx8HRxFd1TfMKUvnrOtvJPmp3Z0q8= +github.com/hashicorp/terraform-plugin-go v0.31.0/go.mod h1:A88bDhd/cW7FnwqxQRz3slT+QY6yzbHKc6AOTtmdeS8= +github.com/hashicorp/terraform-plugin-log v0.10.0 h1:eu2kW6/QBVdN4P3Ju2WiB2W3ObjkAsyfBsL3Wh1fj3g= +github.com/hashicorp/terraform-plugin-log v0.10.0/go.mod h1:/9RR5Cv2aAbrqcTSdNmY1NRHP4E3ekrXRGjqORpXyB0= +github.com/hashicorp/terraform-registry-address v0.4.0 h1:S1yCGomj30Sao4l5BMPjTGZmCNzuv7/GDTDX99E9gTk= +github.com/hashicorp/terraform-registry-address v0.4.0/go.mod h1:LRS1Ay0+mAiRkUyltGT+UHWkIqTFvigGn/LbMshfflE= +github.com/hashicorp/terraform-svchost v0.1.1 h1:EZZimZ1GxdqFRinZ1tpJwVxxt49xc/S52uzrw4x0jKQ= +github.com/hashicorp/terraform-svchost v0.1.1/go.mod h1:mNsjQfZyf/Jhz35v6/0LWcv26+X7JPS+buii2c9/ctc= +github.com/hashicorp/yamux v0.1.2 h1:XtB8kyFOyHXYVFnwT5C3+Bdo8gArse7j2AQ0DA0Uey8= +github.com/hashicorp/yamux v0.1.2/go.mod h1:C+zze2n6e/7wshOZep2A70/aQU6QBRWJO/G6FT1wIns= +github.com/jhump/protoreflect v1.17.0 h1:qOEr613fac2lOuTgWN4tPAtLL7fUSbuJL5X5XumQh94= +github.com/jhump/protoreflect v1.17.0/go.mod h1:h9+vUUL38jiBzck8ck+6G/aeMX8Z4QUY/NiJPwPNi+8= +github.com/mattn/go-colorable v0.1.9/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= +github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4= +github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= +github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= +github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= +github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mitchellh/go-testing-interface v1.14.1 h1:jrgshOhYAUVNMAJiKbEu7EqAwgJJ2JqpQmpLJOu07cU= +github.com/mitchellh/go-testing-interface v1.14.1/go.mod h1:gfgS7OtZj6MA4U1UrDRp04twqAjfvlZyCfX3sDjEym8= +github.com/oklog/run v1.1.0 h1:GEenZ1cK0+q0+wsJew9qUg/DyD8k3JzYsZAi5gYi2mA= +github.com/oklog/run v1.1.0/go.mod h1:sVPdnTZT1zYwAJeCMu2Th4T21pA3FPOQRfWjQlk7DVU= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.7.2/go.mod h1:R6va5+xMeoiuVRoj+gSkQ7d3FALtqAAGI1FQKckRals= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/vmihailenco/msgpack/v5 v5.4.1 h1:cQriyiUvjTwOHg8QZaPihLWeRAAVoCpE00IUPn0Bjt8= +github.com/vmihailenco/msgpack/v5 v5.4.1/go.mod h1:GaZTsDaehaPpQVyxrf5mtQlH+pc21PIudVV/E3rRQok= +github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAhO7/IwNM9g= +github.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV6mEfg5OIWRZA9qds= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48= +go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8= +go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0= +go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs= +go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18= +go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE= +go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2WKg+sEJTtB8= +go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew= +go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI= +go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA= +golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU= +golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY= +golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220503163025-988cb79eb6c6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= +golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU= +golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY= +gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= +gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= +google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 h1:gRkg/vSppuSQoDjxyiGfN4Upv/h/DQmIR10ZU8dh4Ww= +google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk= +google.golang.org/grpc v1.79.2 h1:fRMD94s2tITpyJGtBBn7MkMseNpOZU8ZxgC3MMBaXRU= +google.golang.org/grpc v1.79.2/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/secretspec-terraform/internal/provider/provider.go b/secretspec-terraform/internal/provider/provider.go new file mode 100644 index 000000000..319fd5719 --- /dev/null +++ b/secretspec-terraform/internal/provider/provider.go @@ -0,0 +1,45 @@ +package provider + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-framework/datasource" + "github.com/hashicorp/terraform-plugin-framework/ephemeral" + "github.com/hashicorp/terraform-plugin-framework/provider" + "github.com/hashicorp/terraform-plugin-framework/provider/schema" + "github.com/hashicorp/terraform-plugin-framework/resource" +) + +var _ provider.ProviderWithEphemeralResources = &secretSpecProvider{} + +type secretSpecProvider struct{} + +func New() provider.Provider { return &secretSpecProvider{} } + +func (p *secretSpecProvider) Metadata(_ context.Context, _ provider.MetadataRequest, resp *provider.MetadataResponse) { + resp.TypeName = "secretspec" + resp.Version = "dev" +} + +func (p *secretSpecProvider) Schema(_ context.Context, _ provider.SchemaRequest, resp *provider.SchemaResponse) { + resp.Schema = schema.Schema{ + Description: "Read secrets from an existing SecretSpec manifest (unreleased prototype).", + } +} + +func (p *secretSpecProvider) Configure(context.Context, provider.ConfigureRequest, *provider.ConfigureResponse) { +} + +func (p *secretSpecProvider) Resources(context.Context) []func() resource.Resource { return nil } + +func (p *secretSpecProvider) DataSources(context.Context) []func() datasource.DataSource { + return []func() datasource.DataSource{ + func() datasource.DataSource { return &secretsDataSource{} }, + } +} + +func (p *secretSpecProvider) EphemeralResources(context.Context) []func() ephemeral.EphemeralResource { + return []func() ephemeral.EphemeralResource{ + func() ephemeral.EphemeralResource { return &secretsEphemeralResource{} }, + } +} diff --git a/secretspec-terraform/internal/provider/secrets.go b/secretspec-terraform/internal/provider/secrets.go new file mode 100644 index 000000000..72d14cc30 --- /dev/null +++ b/secretspec-terraform/internal/provider/secrets.go @@ -0,0 +1,90 @@ +package provider + +import ( + "context" + "errors" + "fmt" + + secretspec "github.com/cachix/secretspec/secretspec-go" + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +type secretsModel struct { + Path types.String `tfsdk:"path"` + Profile types.String `tfsdk:"profile"` + Scope types.String `tfsdk:"scope"` + Reason types.String `tfsdk:"reason"` + Values types.Map `tfsdk:"values"` +} + +const ( + pathDescription = "Path to secretspec.toml. Relative paths are resolved from Terraform's working directory." + profileDescription = "SecretSpec profile. When omitted, SecretSpec's normal profile selection applies." + scopeDescription = "Named manifest scope restricting which secrets are returned. Dependencies may also be read." + reasonDescription = "Access reason passed to SecretSpec, required when the manifest's access policy requires one." +) + +func (m *secretsModel) resolve(ctx context.Context, operation string) diag.Diagnostics { + var diags diag.Diagnostics + for _, attr := range []struct { + name string + value types.String + }{{"path", m.Path}, {"profile", m.Profile}, {"scope", m.Scope}, {"reason", m.Reason}} { + if attr.value.IsUnknown() { + diags.AddAttributeError(path.Root(attr.name), "Unknown SecretSpec configuration", "The value must be known before secrets can be resolved.") + } + } + if m.Path.IsNull() || m.Path.ValueString() == "" { + diags.AddAttributeError(path.Root("path"), "Missing manifest path", "Provide a non-empty path to secretspec.toml.") + } + if diags.HasError() { + return diags + } + if err := ctx.Err(); err != nil { + diags.AddError("SecretSpec read cancelled", err.Error()) + return diags + } + + builder := secretspec.New().WithPath(m.Path.ValueString()). + WithReadOnly(true).WithNoFiles(true). + WithCaller(secretspec.CallerContext{Name: "terraform", Operation: operation}) + if !m.Profile.IsNull() { + builder.WithProfile(m.Profile.ValueString()) + } + if !m.Scope.IsNull() { + builder.WithScope(m.Scope.ValueString()) + } + if !m.Reason.IsNull() { + builder.WithReason(m.Reason.ValueString()) + } + resolved, err := builder.Load() + if err != nil { + diags.AddError("Unable to resolve SecretSpec secrets", resolutionError(err)) + return diags + } + defer resolved.Close() + m.Values, diags = types.MapValueFrom(ctx, types.StringType, resolved.Fields()) + return diags +} + +func resolutionError(err error) string { + var missing *secretspec.MissingRequiredError + if errors.As(err, &missing) { + return missing.Error() + } + var native *secretspec.Error + if errors.As(err, &native) { + switch native.Kind { + case "produced_value_write_refused", "file_value_refused": + return native.Message + case "load", "capability", "version", "invalid_request": + return "This prototype requires a libsecretspec build with read-only and no-files support. Check SECRETSPEC_FFI_LIB and build the library from the same checkout." + default: + // Provider and conversion errors can contain secret values. + return fmt.Sprintf("SecretSpec failed (%s). Check the manifest, provider credentials, profile, scope and access reason.", native.Kind) + } + } + return "SecretSpec could not resolve the requested secrets. Check the native library and manifest configuration." +} diff --git a/secretspec-terraform/internal/provider/secrets_data_source.go b/secretspec-terraform/internal/provider/secrets_data_source.go new file mode 100644 index 000000000..a8faf6918 --- /dev/null +++ b/secretspec-terraform/internal/provider/secrets_data_source.go @@ -0,0 +1,46 @@ +package provider + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-framework/datasource" + "github.com/hashicorp/terraform-plugin-framework/datasource/schema" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +var _ datasource.DataSource = &secretsDataSource{} + +type secretsDataSource struct{} + +func (d *secretsDataSource) Metadata(_ context.Context, req datasource.MetadataRequest, resp *datasource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_secrets" +} + +func (d *secretsDataSource) Schema(_ context.Context, _ datasource.SchemaRequest, resp *datasource.SchemaResponse) { + resp.Schema = schema.Schema{ + Description: "Read a SecretSpec profile/scope without writing back to its providers (unreleased prototype). All returned values are stored in Terraform state and saved plans, including unused map entries. Use a scope to limit them. as_path is unsupported.", + Attributes: map[string]schema.Attribute{ + "path": schema.StringAttribute{Required: true, Description: pathDescription}, + "profile": schema.StringAttribute{Optional: true, Description: profileDescription}, + "scope": schema.StringAttribute{Optional: true, Description: scopeDescription}, + "reason": schema.StringAttribute{Optional: true, Description: reasonDescription}, + "values": schema.MapAttribute{ + Computed: true, Sensitive: true, ElementType: types.StringType, + Description: "Resolved inline values. Sensitive hides their display; the entire map is persisted in state and saved plans.", + }, + }, + } +} + +func (d *secretsDataSource) Read(ctx context.Context, req datasource.ReadRequest, resp *datasource.ReadResponse) { + var data secretsModel + resp.Diagnostics.Append(req.Config.Get(ctx, &data)...) + if resp.Diagnostics.HasError() { + return + } + resp.Diagnostics.Append(data.resolve(ctx, "data_read")...) + if resp.Diagnostics.HasError() { + return + } + resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) +} diff --git a/secretspec-terraform/internal/provider/secrets_ephemeral_resource.go b/secretspec-terraform/internal/provider/secrets_ephemeral_resource.go new file mode 100644 index 000000000..a6d5fa169 --- /dev/null +++ b/secretspec-terraform/internal/provider/secrets_ephemeral_resource.go @@ -0,0 +1,46 @@ +package provider + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-framework/ephemeral" + "github.com/hashicorp/terraform-plugin-framework/ephemeral/schema" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +var _ ephemeral.EphemeralResource = &secretsEphemeralResource{} + +type secretsEphemeralResource struct{} + +func (e *secretsEphemeralResource) Metadata(_ context.Context, req ephemeral.MetadataRequest, resp *ephemeral.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_secrets" +} + +func (e *secretsEphemeralResource) Schema(_ context.Context, _ ephemeral.SchemaRequest, resp *ephemeral.SchemaResponse) { + resp.Schema = schema.Schema{ + Description: "Read a SecretSpec profile/scope without writing back to its providers (unreleased prototype, Terraform 1.10+). Values are omitted from state and plans and can only be used in ephemeral contexts. as_path is unsupported.", + Attributes: map[string]schema.Attribute{ + "path": schema.StringAttribute{Required: true, Description: pathDescription}, + "profile": schema.StringAttribute{Optional: true, Description: profileDescription}, + "scope": schema.StringAttribute{Optional: true, Description: scopeDescription}, + "reason": schema.StringAttribute{Optional: true, Description: reasonDescription}, + "values": schema.MapAttribute{ + Computed: true, Sensitive: true, ElementType: types.StringType, + Description: "Resolved inline values, available only during the current Terraform operation.", + }, + }, + } +} + +func (e *secretsEphemeralResource) Open(ctx context.Context, req ephemeral.OpenRequest, resp *ephemeral.OpenResponse) { + var data secretsModel + resp.Diagnostics.Append(req.Config.Get(ctx, &data)...) + if resp.Diagnostics.HasError() { + return + } + resp.Diagnostics.Append(data.resolve(ctx, "ephemeral_open")...) + if resp.Diagnostics.HasError() { + return + } + resp.Diagnostics.Append(resp.Result.Set(ctx, &data)...) +} diff --git a/secretspec-terraform/internal/provider/secrets_test.go b/secretspec-terraform/internal/provider/secrets_test.go new file mode 100644 index 000000000..5593fff81 --- /dev/null +++ b/secretspec-terraform/internal/provider/secrets_test.go @@ -0,0 +1,83 @@ +package provider + +import ( + "context" + "errors" + "os" + "path/filepath" + "strings" + "testing" + + secretspec "github.com/cachix/secretspec/secretspec-go" + "github.com/hashicorp/terraform-plugin-framework/datasource" + "github.com/hashicorp/terraform-plugin-framework/ephemeral" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +func TestValuesAreSensitive(t *testing.T) { + var data datasource.SchemaResponse + (&secretsDataSource{}).Schema(context.Background(), datasource.SchemaRequest{}, &data) + if !data.Schema.Attributes["values"].IsSensitive() { + t.Fatal("data source values must be sensitive") + } + var temporary ephemeral.SchemaResponse + (&secretsEphemeralResource{}).Schema(context.Background(), ephemeral.SchemaRequest{}, &temporary) + if !temporary.Schema.Attributes["values"].IsSensitive() { + t.Fatal("ephemeral values must be sensitive") + } +} + +func TestResolveReadOnly(t *testing.T) { + for _, key := range []string{"SECRETSPEC_PROFILE", "SECRETSPEC_PROVIDER", "SECRETSPEC_SCOPE"} { + t.Setenv(key, "") + } + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + t.Setenv("XDG_CACHE_HOME", t.TempDir()) + t.Setenv("XDG_STATE_HOME", t.TempDir()) + dir := t.TempDir() + manifest := `[project] +name = "terraform-test" +revision = "1.0" +[providers] +store = "dotenv://.env" +[defaults] +providers = ["store"] +[profiles.default] +TOKEN = { description = "token", type = "password", generate = true } +OPTIONAL = { description = "optional", required = false } +` + for name, contents := range map[string]string{"secretspec.toml": manifest, ".env": "TOKEN=stored-token\n"} { + if err := os.WriteFile(filepath.Join(dir, name), []byte(contents), 0o600); err != nil { + t.Fatal(err) + } + } + model := secretsModel{Path: types.StringValue(filepath.Join(dir, "secretspec.toml")), Reason: types.StringValue("test")} + if diags := model.resolve(context.Background(), "test"); diags.HasError() { + t.Fatal(diags) + } + if len(model.Values.Elements()) != 1 || !model.Values.Elements()["TOKEN"].Equal(types.StringValue("stored-token")) { + t.Fatal("expected stored value and omitted optional value") + } + if err := os.WriteFile(filepath.Join(dir, ".env"), []byte("# unchanged\n"), 0o600); err != nil { + t.Fatal(err) + } + if diags := model.resolve(context.Background(), "test"); !diags.HasError() { + t.Fatal("expected generation to be refused") + } + contents, err := os.ReadFile(filepath.Join(dir, ".env")) + if err != nil || string(contents) != "# unchanged\n" { + t.Fatalf("store changed: %q, %v", contents, err) + } +} + +func TestResolutionErrorDoesNotExposeProviderValues(t *testing.T) { + for _, err := range []error{ + &secretspec.Error{Kind: "provider", Message: "failed to parse secret-value"}, + &secretspec.Error{Kind: "invalid_request", Message: "secret-value"}, + errors.New("secret-value"), + } { + if strings.Contains(resolutionError(err), "secret-value") { + t.Fatal("diagnostic exposed a value from an underlying error") + } + } +} diff --git a/secretspec-terraform/main.go b/secretspec-terraform/main.go new file mode 100644 index 000000000..6dcb2f2f0 --- /dev/null +++ b/secretspec-terraform/main.go @@ -0,0 +1,17 @@ +package main + +import ( + "context" + "log" + + "github.com/cachix/secretspec/secretspec-terraform/internal/provider" + "github.com/hashicorp/terraform-plugin-framework/providerserver" +) + +func main() { + if err := providerserver.Serve(context.Background(), provider.New, providerserver.ServeOpts{ + Address: "registry.terraform.io/cachix/secretspec", + }); err != nil { + log.Fatal(err) + } +} diff --git a/secretspec-terraform/terraform_test.go b/secretspec-terraform/terraform_test.go new file mode 100644 index 000000000..f4a30cfaa --- /dev/null +++ b/secretspec-terraform/terraform_test.go @@ -0,0 +1,203 @@ +package main + +import ( + "bytes" + "encoding/json" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +// These tests run the actual Terraform protocol and inspect saved plans/state. +// Set TF_ACC=1, SECRETSPEC_FFI_LIB and optionally TF_ACC_TERRAFORM_PATH. +func TestTerraform(t *testing.T) { + if os.Getenv("TF_ACC") != "1" { + t.Skip("set TF_ACC=1 to run Terraform CLI tests") + } + terraform := os.Getenv("TF_ACC_TERRAFORM_PATH") + if terraform == "" { + var err error + terraform, err = exec.LookPath("terraform") + if err != nil { + t.Fatal(err) + } + } + lib, err := filepath.Abs(os.Getenv("SECRETSPEC_FFI_LIB")) + if err != nil || os.Getenv("SECRETSPEC_FFI_LIB") == "" { + t.Fatal("set SECRETSPEC_FFI_LIB to the freshly built native library") + } + t.Setenv("SECRETSPEC_FFI_LIB", lib) + for _, key := range []string{"SECRETSPEC_PROFILE", "SECRETSPEC_PROVIDER", "SECRETSPEC_SCOPE", "TF_LOG", "TF_LOG_PATH", "TF_CLI_ARGS", "TF_DATA_DIR"} { + t.Setenv(key, "") + } + t.Setenv("CHECKPOINT_DISABLE", "1") + t.Setenv("TF_IN_AUTOMATION", "1") + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + t.Setenv("XDG_CACHE_HOME", t.TempDir()) + t.Setenv("XDG_STATE_HOME", t.TempDir()) + binDir := t.TempDir() + build := exec.Command("go", "build", "-o", filepath.Join(binDir, "terraform-provider-secretspec"), ".") + if out, err := build.CombinedOutput(); err != nil { + t.Fatalf("build provider: %v\n%s", err, out) + } + cliConfig := filepath.Join(t.TempDir(), "terraform.tfrc") + write := func(path, content string) { + t.Helper() + if err := os.WriteFile(path, []byte(content), 0o600); err != nil { + t.Fatal(err) + } + } + write(cliConfig, fmt.Sprintf("provider_installation {\n dev_overrides {\n \"cachix/secretspec\" = %q\n }\n direct {}\n}\n", filepath.ToSlash(binDir))) + t.Setenv("TF_CLI_CONFIG_FILE", cliConfig) + + for _, kind := range []string{"data", "ephemeral"} { + t.Run(kind, func(t *testing.T) { + dir := t.TempDir() + run := func(wantSuccess bool, args ...string) []byte { + t.Helper() + cmd := exec.Command(terraform, args...) + cmd.Dir = dir + out, err := cmd.CombinedOutput() + if (err == nil) != wantSuccess { + t.Fatalf("terraform %v: %v\n%s", args, err, out) + } + return out + } + const token = "terraform-test-secret-7c64a2" + const unused = "terraform-test-unused-secret-509fc3" + store := "TOKEN=" + token + "\nUNUSED=" + unused + "\n" + write(filepath.Join(dir, ".env"), store) + write(filepath.Join(dir, "expected.txt"), token) + manifest := `[project] +name = "terraform-cli-test" +revision = "1.0" +[providers] +store = "dotenv://.env" +[defaults] +providers = ["store"] +[profiles.default] +OUTSIDE_SCOPE = { description = "must not be read", as_path = true } +[profiles.production] +TOKEN = { description = "token", type = "password", generate = true } +UNUSED = { description = "returned but never referenced" } +OPTIONAL = { description = "optional", required = false } +[scopes.terraform] +secrets = ["TOKEN", "UNUSED", "OPTIONAL"] +` + write(filepath.Join(dir, "secretspec.toml"), manifest) + config := fmt.Sprintf(`terraform { + required_providers { + secretspec = { source = "cachix/secretspec" } + } +} +%s "secretspec_secrets" "test" { + path = "${path.module}/secretspec.toml" + profile = "production" + scope = "terraform" + reason = "Terraform acceptance test" +} +`, kind) + if kind == "data" { + config += `resource "terraform_data" "consumer" { + input = data.secretspec_secrets.test.values["TOKEN"] +} +` + } else { + config += `resource "terraform_data" "consumer" { + provisioner "local-exec" { + command = "test \"$TOKEN\" = \"$(cat expected.txt)\"" + environment = { TOKEN = ephemeral.secretspec_secrets.test.values["TOKEN"] } + } +} +` + } + write(filepath.Join(dir, "main.tf"), config) + run(true, "validate", "-no-color") + planOutput := run(true, "plan", "-input=false", "-no-color", "-out=plan") + applyOutput := run(true, "apply", "-input=false", "-no-color", "plan") + for _, output := range [][]byte{planOutput, applyOutput} { + if bytes.Contains(output, []byte(token)) || bytes.Contains(output, []byte(unused)) { + t.Fatal("CLI output exposed a sensitive value") + } + } + for _, target := range []string{"plan", "terraform.tfstate"} { + output := run(true, "show", "-json", target) + var document map[string]any + if err := json.Unmarshal(output, &document); err != nil { + t.Fatalf("invalid Terraform JSON: %v\n%s", err, output) + } + if kind == "ephemeral" { + if bytes.Contains(output, []byte(token)) || bytes.Contains(output, []byte(unused)) { + t.Fatalf("ephemeral value persisted in %s", target) + } + continue + } + var resources []any + appendResources := func(value any) { + if values, ok := value.(map[string]any); ok { + root := values["root_module"].(map[string]any) + resources = append(resources, root["resources"].([]any)...) + } + } + appendResources(document["values"]) + appendResources(document["planned_values"]) + // Plan-time data reads may be recorded in the refreshed prior state. + if prior, ok := document["prior_state"].(map[string]any); ok { + appendResources(prior["values"]) + } + found := false + for _, item := range resources { + resource := item.(map[string]any) + if resource["address"] != "data.secretspec_secrets.test" { + continue + } + found = true + values := resource["values"].(map[string]any)["values"].(map[string]any) + if len(values) != 2 || values["TOKEN"] != token || values["UNUSED"] != unused { + t.Fatal("data source must persist the entire resolved map") + } + if resource["sensitive_values"].(map[string]any)["values"] != true { + t.Fatal("data source values must be sensitive") + } + } + if !found { + t.Fatalf("data source missing from %s: %s", target, output) + } + } + contents, err := os.ReadFile(filepath.Join(dir, ".env")) + if err != nil || string(contents) != store { + t.Fatal("reading existing values changed the store") + } + + // A profile supplied by another resource is unknown until apply. + deferred := strings.Replace(config, `profile = "production"`, `profile = terraform_data.profile.output`, 1) + deferred += `resource "terraform_data" "profile" { + input = "production" +} +` + write(filepath.Join(dir, "main.tf"), deferred) + run(true, "plan", "-input=false", "-no-color", "-replace=terraform_data.consumer", "-out=deferred.plan") + run(true, "apply", "-input=false", "-no-color", "deferred.plan") + write(filepath.Join(dir, "main.tf"), config) + + write(filepath.Join(dir, ".env"), "UNUSED="+unused+"\n") + output := run(false, "plan", "-input=false", "-no-color") + if !strings.Contains(string(output), "may not write") { + t.Fatalf("expected read-only error: %s", output) + } + contents, err = os.ReadFile(filepath.Join(dir, ".env")) + if err != nil || string(contents) != "UNUSED="+unused+"\n" { + t.Fatal("generation refusal changed the store") + } + + write(filepath.Join(dir, "secretspec.toml"), strings.Replace(manifest, "generate = true", "generate = true, as_path = true", 1)) + output = run(false, "plan", "-input=false", "-no-color") + if !strings.Contains(string(output), "as_path") { + t.Fatalf("expected as_path refusal: %s", output) + } + }) + } +}