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 5654ca8e9..db2795747 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- 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 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/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-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-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) + } + }) + } +} 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() }