From 51957026871e30c14f83f9e1476f5418e1e35112 Mon Sep 17 00:00:00 2001 From: eding Date: Sat, 12 Sep 2026 22:39:59 +0200 Subject: [PATCH] feat: manage app OpenBao secrets --- cmd/app_secret.go | 216 +++++++++++++++++++++++++++ cmd/app_secret_test.go | 77 ++++++++++ docs/secret-grants.md | 52 ++++--- internal/bootstrap/bootstrap_test.go | 12 +- internal/config/config.go | 10 ++ internal/config/config_test.go | 4 +- internal/config/types.go | 5 +- internal/openbao/app_secret.go | 138 +++++++++++++++++ internal/openbao/app_secret_test.go | 122 +++++++++++++++ internal/openbao/bootstrap.go | 12 +- internal/openbao/bootstrap_test.go | 13 +- 11 files changed, 623 insertions(+), 38 deletions(-) create mode 100644 cmd/app_secret.go create mode 100644 cmd/app_secret_test.go create mode 100644 internal/openbao/app_secret.go create mode 100644 internal/openbao/app_secret_test.go diff --git a/cmd/app_secret.go b/cmd/app_secret.go new file mode 100644 index 0000000..a21b24c --- /dev/null +++ b/cmd/app_secret.go @@ -0,0 +1,216 @@ +package cmd + +import ( + "errors" + "fmt" + "io" + "os" + "path/filepath" + "reflect" + + "github.com/Pingu-Studio/MaidnCLI/internal/config" + "github.com/Pingu-Studio/MaidnCLI/internal/openbao" + "github.com/spf13/cobra" +) + +var appSecretConfigPath, appSecretFile, appSecretTokenFile, appSecretGrantEnvironment string +var appSecretShared, appSecretDeleteYes bool +var appSecretGrantShared, appSecretGrantSecrets []string + +var loadAppSecretConfig = config.Load +var saveAppSecretConfig = config.Save +var storeAppSecret = openbao.StoreManagedSecret +var listAppSecrets = openbao.ListManagedSecrets +var appSecretStatus = openbao.ManagedSecretStatus +var deleteAppSecret = openbao.DeleteManagedSecret + +var appSecretCmd = &cobra.Command{ + Use: "secret", + Short: "Manage application and shared OpenBao secret values.", +} + +var appSecretSetCmd = &cobra.Command{ + Use: "set ", + Short: "Store a value from stdin or --file.", + Args: cobra.ExactArgs(2), + RunE: runAppSecretSet, +} + +var appSecretListCmd = &cobra.Command{ + Use: "list ", + Short: "List secret names without values.", + Args: cobra.ExactArgs(1), + RunE: runAppSecretList, +} + +var appSecretDeleteCmd = &cobra.Command{ + Use: "delete ", + Short: "Permanently delete a secret after explicit confirmation.", + Args: cobra.ExactArgs(2), + RunE: runAppSecretDelete, +} + +var appSecretGrantCmd = &cobra.Command{ + Use: "grant ", + Short: "Add a declarative SecretGrant to the private bootstrap config.", + Args: cobra.ExactArgs(2), + RunE: runAppSecretGrant, +} + +var appSecretStatusCmd = &cobra.Command{ + Use: "status ", + Short: "Report whether a secret exists without reading its value.", + Args: cobra.ExactArgs(2), + RunE: runAppSecretStatus, +} + +func init() { + appCmd.AddCommand(appSecretCmd) + appSecretCmd.AddCommand(appSecretSetCmd, appSecretListCmd, appSecretDeleteCmd, appSecretGrantCmd, appSecretStatusCmd) + appSecretCmd.PersistentFlags().StringVar(&appSecretConfigPath, "config", "", "Path to private bootstrap config YAML") + _ = appSecretCmd.MarkPersistentFlagRequired("config") + appSecretCmd.PersistentFlags().StringVar(&appSecretTokenFile, "token-file", "", "Path to restricted OpenBao token file for secret CRUD") + + appSecretSetCmd.Flags().StringVar(&appSecretFile, "file", "", "Read the secret value from this file instead of stdin") + for _, command := range []*cobra.Command{appSecretSetCmd, appSecretListCmd, appSecretDeleteCmd, appSecretStatusCmd} { + command.Flags().BoolVar(&appSecretShared, "shared", false, "Use shared// instead of apps//") + } + appSecretDeleteCmd.Flags().BoolVar(&appSecretDeleteYes, "yes", false, "Confirm permanent deletion") + appSecretGrantCmd.Flags().StringVar(&appSecretGrantEnvironment, "environment", "", "Runtime environment: staging or production") + appSecretGrantCmd.Flags().StringSliceVar(&appSecretGrantSecrets, "secret", nil, "Application secret name granted to this consumer (repeat for each)") + appSecretGrantCmd.Flags().StringSliceVar(&appSecretGrantShared, "shared", nil, "Shared secret group allowed by this grant") +} + +func runAppSecretSet(cmd *cobra.Command, args []string) error { + path, kubeconfig, err := appSecretTarget(args) + if err != nil { + return err + } + value, err := readAppSecretValue(cmd) + if err != nil { + return err + } + if err := storeAppSecret(kubeconfig, appSecretTokenFile, path, value); err != nil { + return err + } + fmt.Fprintf(cmd.OutOrStdout(), "stored %s\n", path) + return nil +} + +func runAppSecretList(cmd *cobra.Command, args []string) error { + kubeconfig, err := appSecretKubeconfigFromConfig() + if err != nil { + return err + } + values, err := listAppSecrets(kubeconfig, appSecretTokenFile, appSecretShared, args[0]) + if err != nil { + return err + } + for _, value := range values { + fmt.Fprintln(cmd.OutOrStdout(), value) + } + return nil +} + +func runAppSecretDelete(cmd *cobra.Command, args []string) error { + if !appSecretDeleteYes { + return errors.New("delete requires --yes") + } + path, kubeconfig, err := appSecretTarget(args) + if err != nil { + return err + } + if err := deleteAppSecret(kubeconfig, appSecretTokenFile, path); err != nil { + return err + } + fmt.Fprintf(cmd.OutOrStdout(), "deleted %s\n", path) + return nil +} + +func runAppSecretGrant(cmd *cobra.Command, args []string) error { + cfg, err := loadRequiredAppSecretConfig() + if err != nil { + return err + } + grant := config.SecretGrant{Application: args[0], Consumer: args[1], Environment: appSecretGrantEnvironment, Secrets: appSecretGrantSecrets, Shared: appSecretGrantShared} + if err := config.ValidateSecretGrants([]config.SecretGrant{grant}); err != nil { + return err + } + for _, existing := range cfg.SecretGrants { + if existing.Application == grant.Application && existing.Consumer == grant.Consumer && existing.Environment == grant.Environment { + if reflect.DeepEqual(existing, grant) { + fmt.Fprintln(cmd.OutOrStdout(), "grant already declared") + return nil + } + return errors.New("secret grant already exists with a different definition") + } + } + cfg.SecretGrants = append(cfg.SecretGrants, grant) + if err := config.ValidateSecretGrants(cfg.SecretGrants); err != nil { + return err + } + if err := saveAppSecretConfig(appSecretConfigPath, cfg); err != nil { + return fmt.Errorf("save declarative secret grant: %w", err) + } + fmt.Fprintln(cmd.OutOrStdout(), "grant declared; reconcile OpenBao through the reviewed bootstrap workflow") + return nil +} + +func runAppSecretStatus(cmd *cobra.Command, args []string) error { + path, kubeconfig, err := appSecretTarget(args) + if err != nil { + return err + } + present, err := appSecretStatus(kubeconfig, appSecretTokenFile, path) + if err != nil { + return err + } + if present { + fmt.Fprintf(cmd.OutOrStdout(), "%s: present\n", path) + } else { + fmt.Fprintf(cmd.OutOrStdout(), "%s: absent\n", path) + } + return nil +} + +func appSecretTarget(args []string) (string, string, error) { + if len(args) != 2 { + return "", "", errors.New("secret target requires an application or shared group and a secret name") + } + path, err := openbao.ManagedSecretPath(appSecretShared, args[0], args[1]) + if err != nil { + return "", "", err + } + kubeconfig, err := appSecretKubeconfigFromConfig() + return path, kubeconfig, err +} + +func loadRequiredAppSecretConfig() (config.Config, error) { + if appSecretConfigPath == "" { + return config.Config{}, errors.New("--config is required") + } + return loadAppSecretConfig(appSecretConfigPath) +} + +func appSecretKubeconfigFromConfig() (string, error) { + cfg, err := loadRequiredAppSecretConfig() + if err != nil { + return "", err + } + return filepath.Join(cfg.Git.CloneParent, cfg.Talos.RepoDirName, cfg.Talos.GeneratedDir, "kubeconfig"), nil +} + +func readAppSecretValue(cmd *cobra.Command) ([]byte, error) { + if appSecretFile != "" { + value, err := os.ReadFile(appSecretFile) + if err != nil { + return nil, fmt.Errorf("read secret file: %w", err) + } + return value, nil + } + value, err := io.ReadAll(cmd.InOrStdin()) + if err != nil { + return nil, fmt.Errorf("read secret stdin: %w", err) + } + return value, nil +} diff --git a/cmd/app_secret_test.go b/cmd/app_secret_test.go new file mode 100644 index 0000000..0414735 --- /dev/null +++ b/cmd/app_secret_test.go @@ -0,0 +1,77 @@ +package cmd + +import ( + "bytes" + "errors" + "path/filepath" + "strings" + "testing" + + "github.com/Pingu-Studio/MaidnCLI/internal/config" + "github.com/spf13/cobra" +) + +func TestAppSecretSetReadsValueFromStdinWithoutOutput(t *testing.T) { + originalLoad, originalStore := loadAppSecretConfig, storeAppSecret + originalConfig, originalFile, originalToken, originalShared := appSecretConfigPath, appSecretFile, appSecretTokenFile, appSecretShared + t.Cleanup(func() { + loadAppSecretConfig, storeAppSecret = originalLoad, originalStore + appSecretConfigPath, appSecretFile, appSecretTokenFile, appSecretShared = originalConfig, originalFile, originalToken, originalShared + }) + appSecretConfigPath, appSecretFile, appSecretTokenFile, appSecretShared = "private.yaml", "", "restricted-token", false + loadAppSecretConfig = func(string) (config.Config, error) { + return config.Config{Git: config.GitConfig{CloneParent: "checkouts"}, Talos: config.TalosConfig{RepoDirName: "talos", GeneratedDir: "generated"}, SOPS: config.SOPSConfig{RecoveryIdentityPath: "must-not-pass", RecoveryBundlePath: "must-not-pass"}}, nil + } + const value = "do-not-print" + storeAppSecret = func(kubeconfig, tokenPath, path string, got []byte) error { + if kubeconfig != filepath.Join("checkouts", "talos", "generated", "kubeconfig") || tokenPath != "restricted-token" || path != "apps/orders-api/publish" || string(got) != value { + t.Fatal("set did not pass only kubeconfig, token path, target, and stdin value") + } + return nil + } + output := new(bytes.Buffer) + command := &cobra.Command{} + command.SetIn(strings.NewReader(value)) + command.SetOut(output) + if err := runAppSecretSet(command, []string{"orders-api", "publish"}); err != nil || strings.Contains(output.String(), value) { + t.Fatal("set leaked its value or failed") + } +} + +func TestAppSecretDeleteRequiresExplicitConfirmation(t *testing.T) { + originalYes := appSecretDeleteYes + t.Cleanup(func() { appSecretDeleteYes = originalYes }) + appSecretDeleteYes = false + if err := runAppSecretDelete(&cobra.Command{}, []string{"orders-api", "publish"}); err == nil || !strings.Contains(err.Error(), "--yes") { + t.Fatalf("delete confirmation error = %v", err) + } +} + +func TestAppSecretGrantOnlySavesNewDeclarativeDefinition(t *testing.T) { + originalLoad, originalSave := loadAppSecretConfig, saveAppSecretConfig + originalConfig, originalEnvironment, originalSecrets, originalShared := appSecretConfigPath, appSecretGrantEnvironment, appSecretGrantSecrets, appSecretGrantShared + t.Cleanup(func() { + loadAppSecretConfig, saveAppSecretConfig = originalLoad, originalSave + appSecretConfigPath, appSecretGrantEnvironment, appSecretGrantSecrets, appSecretGrantShared = originalConfig, originalEnvironment, originalSecrets, originalShared + }) + appSecretConfigPath, appSecretGrantEnvironment, appSecretGrantSecrets, appSecretGrantShared = "private.yaml", "production", []string{"database"}, []string{"rabbitmq"} + loadAppSecretConfig = func(string) (config.Config, error) { return config.Config{}, nil } + saved := false + saveAppSecretConfig = func(path string, cfg config.Config) error { + saved = path == "private.yaml" && len(cfg.SecretGrants) == 1 && cfg.SecretGrants[0].Application == "orders-api" && cfg.SecretGrants[0].Consumer == "runtime" && cfg.SecretGrants[0].Environment == "production" && len(cfg.SecretGrants[0].Secrets) == 1 && cfg.SecretGrants[0].Secrets[0] == "database" && len(cfg.SecretGrants[0].Shared) == 1 && cfg.SecretGrants[0].Shared[0] == "rabbitmq" + return nil + } + command := &cobra.Command{} + command.SetOut(new(bytes.Buffer)) + if err := runAppSecretGrant(command, []string{"orders-api", "runtime"}); err != nil || !saved { + t.Fatalf("runAppSecretGrant() = %v, saved = %t", err, saved) + } + + loadAppSecretConfig = func(string) (config.Config, error) { + return config.Config{SecretGrants: []config.SecretGrant{{Application: "orders-api", Consumer: "runtime", Environment: "production", Secrets: []string{"database"}, Shared: []string{"other"}}}}, nil + } + saveAppSecretConfig = func(string, config.Config) error { return errors.New("must not save ambiguous grant") } + if err := runAppSecretGrant(command, []string{"orders-api", "runtime"}); err == nil || !strings.Contains(err.Error(), "different definition") { + t.Fatalf("ambiguous grant error = %v", err) + } +} diff --git a/docs/secret-grants.md b/docs/secret-grants.md index c2890b7..22fb6ee 100644 --- a/docs/secret-grants.md +++ b/docs/secret-grants.md @@ -8,9 +8,9 @@ gets one reviewed grant. | Consumer | OpenBao path | Kubernetes namespace | Intended use | | --- | --- | --- | --- | -| `build` | `apps//build/*` | `tekton-pipelines` | Read-only dependency credentials | -| `publish` | `apps//publish/*` | `tekton-pipelines` | One app's artifact repository credential | -| `runtime` | `apps//runtime//*` | `-` | Service runtime credentials | +| `build` | declared `apps//` entries | `tekton-pipelines` | Read-only dependency credentials | +| `publish` | declared `apps//` entries | `tekton-pipelines` | One app's artifact repository credential | +| `runtime` | declared `apps//` entries | `-` | Service runtime credentials | | shared | `shared//*` | Granted consumer only | Deliberately shared broker, database, or API credentials | `build` code is repository-controlled. Anything granted to it is readable by a @@ -24,23 +24,30 @@ secret values and is reviewed with the platform configuration: ```yaml secretGrants: + - application: orders-api + consumer: publish + secrets: + - registry + shared: + - internal-npm - application: orders-api - consumer: publish - shared: - - internal-npm - - application: orders-api - consumer: runtime - environment: staging + consumer: runtime + environment: staging + secrets: + - database-staging shared: - rabbitmq - application: orders-api - consumer: runtime - environment: production + consumer: runtime + environment: production + secrets: + - database-production shared: - rabbitmq ``` -MaidnCLI validates application, consumer, environment, and shared-grant names. +MaidnCLI validates application, consumer, environment, application-secret, and +shared-grant names. It creates one OpenBao policy and Kubernetes-auth role for every declaration. The role names are deterministic: @@ -50,10 +57,12 @@ maidn--publish maidn--runtime- ``` -The policy permits only the consumer's own path and the exact `shared/` -paths listed in its declaration. A shared value is stored once, for example at -`shared/rabbitmq`, and each service requiring it declares that same shared -grant. Do not copy it into application paths. +The policy permits only the exact `apps//` paths and +`shared//*` paths listed in its declaration. The CLI stores one property +named `value` at each `apps//` or `shared//` path. +A shared value is stored once, for example at `shared/rabbitmq/password`, and +each service requiring it declares that shared grant. Do not copy it into +application paths. ## GitOps resources @@ -102,10 +111,10 @@ spec: name: orders-api-rabbitmq creationPolicy: Owner data: - - secretKey: password + - secretKey: value remoteRef: - key: shared/rabbitmq - property: password + key: shared/rabbitmq/password + property: value ``` The workload references only `orders-api-rabbitmq` in its own namespace. Each @@ -127,8 +136,9 @@ code and therefore requires a narrowly scoped, disposable credential. ## Operations 1. Create the least-privilege upstream credential. -2. Write its value to the declared OpenBao path through a secure stdin-based - operator workflow. Never put it in YAML, a URL, a command argument, or Git. +2. Write its value to the declared OpenBao path with `cicd-tool app secret set` + using stdin or `--file` and a least-privilege `--token-file`. Never put a + value or token in YAML, a URL, a command argument, output, or Git. 3. Add the reviewed grant and GitOps resources. 4. Bootstrap or reconcile to create the OpenBao role and policy. 5. Verify the target ExternalSecret becomes Ready without printing its Secret. diff --git a/internal/bootstrap/bootstrap_test.go b/internal/bootstrap/bootstrap_test.go index ff227f9..0e53a68 100644 --- a/internal/bootstrap/bootstrap_test.go +++ b/internal/bootstrap/bootstrap_test.go @@ -52,7 +52,7 @@ func TestInitializeOpenBaoConfiguresDeclaredSecretGrants(t *testing.T) { called = kubeconfig == filepath.Join("generated", "kubeconfig") && identity == "identity" && bundle == "bundle" && len(grants) == 1 && grants[0].Application == "orders-api" return nil } - r := Runner{Config: config.Config{SOPS: config.SOPSConfig{RecoveryIdentityPath: "identity", RecoveryBundlePath: "bundle", RecoveryRecipient: "recipient", AgeKeyPath: "age", OperationalSecretsPath: "secrets"}, SecretGrants: []config.SecretGrant{{Application: "orders-api", Consumer: "runtime", Environment: "staging"}}}} + r := Runner{Config: config.Config{SOPS: config.SOPSConfig{RecoveryIdentityPath: "identity", RecoveryBundlePath: "bundle", RecoveryRecipient: "recipient", AgeKeyPath: "age", OperationalSecretsPath: "secrets"}, SecretGrants: []config.SecretGrant{{Application: "orders-api", Consumer: "runtime", Environment: "staging", Secrets: []string{"database"}}}}} if _, err := r.initializeOpenBaoForCluster("generated"); err != nil || !called { t.Fatalf("secret grant configuration was not invoked: called=%t err=%v", called, err) } @@ -148,8 +148,14 @@ func TestGeneratedDeliveryInitializesStagingAndPromotesByPullRequest(t *testing. t.Fatalf("generated delivery does not contain %q", expected) } } - production := rendered[strings.Index(rendered, `if [ "$ENVIRONMENT" = production ]; then`):] - production = production[:strings.Index(production, "\n else\n")] + start := strings.Index(rendered, `if [ "$ENVIRONMENT" = production ]; then`) + if start < 0 { + t.Fatal("generated delivery does not contain a production manifest path") + } + production := rendered[start:] + if end := strings.Index(production, "\n else\n"); end >= 0 { + production = production[:end] + } if strings.Contains(production, `git push origin "$MANIFESTS_BRANCH"`) { t.Fatal("production path pushes directly to manifests main") } diff --git a/internal/config/config.go b/internal/config/config.go index 09d1431..1cd6292 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -514,6 +514,16 @@ func ValidateSecretGrants(grants []SecretGrant) error { return errors.New("duplicate secret grant consumer") } seen[key] = true + if len(grant.Secrets) == 0 { + return errors.New("secret grant requires at least one application secret") + } + secretNames := map[string]bool{} + for _, secret := range grant.Secrets { + if !name.MatchString(secret) || secretNames[secret] { + return errors.New("secret grant application secret names must be unique lowercase DNS labels") + } + secretNames[secret] = true + } for _, shared := range grant.Shared { if !name.MatchString(shared) { return errors.New("shared secret grant name must be a lowercase DNS label") diff --git a/internal/config/config_test.go b/internal/config/config_test.go index d07f9d8..bf9dc4e 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -120,13 +120,15 @@ func TestValidateRejectsCredentialBearingDeliveryURLs(t *testing.T) { } func TestValidateSecretGrants(t *testing.T) { - if err := ValidateSecretGrants([]SecretGrant{{Application: "orders-api", Consumer: "runtime", Environment: "production", Shared: []string{"rabbitmq"}}}); err != nil { + if err := ValidateSecretGrants([]SecretGrant{{Application: "orders-api", Consumer: "runtime", Environment: "production", Secrets: []string{"database"}, Shared: []string{"rabbitmq"}}}); err != nil { t.Fatal(err) } for _, grant := range []SecretGrant{ {Application: "orders-api", Consumer: "runtime", Environment: "preview"}, {Application: "orders-api", Consumer: "build", Environment: "staging"}, + {Application: "orders-api", Consumer: "publish"}, {Application: "orders-api", Consumer: "publish", Shared: []string{"../platform"}}, + {Application: "orders-api", Consumer: "publish", Secrets: []string{"database", "database"}}, } { if err := ValidateSecretGrants([]SecretGrant{grant}); err == nil { t.Fatalf("invalid secret grant accepted: %#v", grant) diff --git a/internal/config/types.go b/internal/config/types.go index 3168d07..f5bbef2 100644 --- a/internal/config/types.go +++ b/internal/config/types.go @@ -16,12 +16,13 @@ type Config struct { SOPS SOPSConfig `yaml:"sops"` } -// SecretGrant gives one application consumer access to its own OpenBao path -// and explicitly named shared paths. It contains references, never values. +// SecretGrant gives one application consumer access to named application and +// shared OpenBao paths. It contains references, never values. type SecretGrant struct { Application string `yaml:"application"` Consumer string `yaml:"consumer"` Environment string `yaml:"environment,omitempty"` + Secrets []string `yaml:"secrets"` Shared []string `yaml:"shared,omitempty"` } diff --git a/internal/openbao/app_secret.go b/internal/openbao/app_secret.go new file mode 100644 index 0000000..dc6f47f --- /dev/null +++ b/internal/openbao/app_secret.go @@ -0,0 +1,138 @@ +package openbao + +import ( + "encoding/json" + "errors" + "os" + "regexp" + "strings" +) + +var managedSecretPart = regexp.MustCompile(`^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$`) + +// ManagedSecretPath limits application-secret operations to the declared app +// and shared OpenBao namespaces. +func ManagedSecretPath(shared bool, owner, name string) (string, error) { + if !managedSecretPart.MatchString(owner) || !managedSecretPart.MatchString(name) { + return "", errors.New("application, shared group, and secret names must be lowercase DNS labels") + } + if shared { + return "shared/" + owner + "/" + name, nil + } + return "apps/" + owner + "/" + name, nil +} + +func managedSecretScope(shared bool, owner string) (string, error) { + if !managedSecretPart.MatchString(owner) { + return "", errors.New("application and shared group names must be lowercase DNS labels") + } + if shared { + return "shared/" + owner, nil + } + return "apps/" + owner, nil +} + +// StoreManagedSecret keeps the token and value on stdin all the way to OpenBao. +func StoreManagedSecret(kubeconfig, tokenPath, path string, value []byte) error { + if _, err := managedSecretPathParts(path); err != nil { + return err + } + if len(value) == 0 { + return errors.New("secret value must not be empty") + } + token, err := readRestrictedToken(tokenPath) + if err != nil { + return err + } + return writeSecret(kubeconfig, token, path, map[string]string{"value": string(value)}) +} + +// ListManagedSecrets returns only secret names from KV metadata. +func ListManagedSecrets(kubeconfig, tokenPath string, shared bool, owner string) ([]string, error) { + scope, err := managedSecretScope(shared, owner) + if err != nil { + return nil, err + } + token, err := readRestrictedToken(tokenPath) + if err != nil { + return nil, err + } + output, err := execInPod(kubeconfig, []byte(token+"\n"), "sh", "-ec", "read -r token\nexport BAO_TOKEN=\"$token\"\nbao kv list -format=json secret/metadata/"+scope) + if err != nil { + if strings.Contains(strings.ToLower(string(output)), "no value found") { + return nil, nil + } + return nil, errors.New("list OpenBao secret metadata") + } + var response struct { + Data struct { + Keys []string `json:"keys"` + } `json:"data"` + } + if err := json.Unmarshal(output, &response); err != nil { + return nil, errors.New("parse OpenBao secret metadata") + } + return response.Data.Keys, nil +} + +// ManagedSecretStatus checks KV metadata without reading the secret value. +func ManagedSecretStatus(kubeconfig, tokenPath, path string) (bool, error) { + if _, err := managedSecretPathParts(path); err != nil { + return false, err + } + token, err := readRestrictedToken(tokenPath) + if err != nil { + return false, err + } + output, err := execInPod(kubeconfig, []byte(token+"\n"), "sh", "-ec", "read -r token\nexport BAO_TOKEN=\"$token\"\nbao kv metadata get -format=json secret/"+path) + if err != nil { + if strings.Contains(strings.ToLower(string(output)), "no value found") { + return false, nil + } + return false, errors.New("read OpenBao secret metadata") + } + var response struct { + Data json.RawMessage `json:"data"` + } + if err := json.Unmarshal(output, &response); err != nil || len(response.Data) == 0 { + return false, errors.New("parse OpenBao secret metadata") + } + return true, nil +} + +func DeleteManagedSecret(kubeconfig, tokenPath, path string) error { + if _, err := managedSecretPathParts(path); err != nil { + return err + } + token, err := readRestrictedToken(tokenPath) + if err != nil { + return err + } + if _, err := execInPodMutation(kubeconfig, []byte(token+"\n"), "sh", "-ec", "read -r token\nexport BAO_TOKEN=\"$token\"\nbao kv metadata delete secret/"+path+" >/dev/null"); err != nil { + return errors.New("delete OpenBao secret") + } + return nil +} + +func readRestrictedToken(path string) (string, error) { + if path == "" { + return "", errors.New("--token-file is required") + } + contents, err := os.ReadFile(path) + if err != nil { + return "", errors.New("read OpenBao token file") + } + token := strings.TrimSpace(string(contents)) + if token == "" || strings.ContainsAny(token, " \t\r\n") { + return "", errors.New("OpenBao token file must contain one token") + } + return token, nil +} + +func managedSecretPathParts(path string) ([]string, error) { + parts := strings.Split(path, "/") + if len(parts) != 3 || (parts[0] != "apps" && parts[0] != "shared") || !managedSecretPart.MatchString(parts[1]) || !managedSecretPart.MatchString(parts[2]) { + return nil, errors.New("invalid managed OpenBao secret path") + } + return parts, nil +} diff --git a/internal/openbao/app_secret_test.go b/internal/openbao/app_secret_test.go new file mode 100644 index 0000000..a2f601b --- /dev/null +++ b/internal/openbao/app_secret_test.go @@ -0,0 +1,122 @@ +package openbao + +import ( + "encoding/base64" + "errors" + "os" + "path/filepath" + "strings" + "testing" +) + +func restrictedTokenFile(t *testing.T) string { + t.Helper() + path := filepath.Join(t.TempDir(), "openbao-token") + if err := os.WriteFile(path, []byte("test-restricted-token\n"), 0600); err != nil { + t.Fatal(err) + } + return path +} + +func TestManagedSecretPathOnlyAllowsAppAndSharedNamespaces(t *testing.T) { + for _, test := range []struct { + shared bool + owner string + name string + want string + }{ + {false, "orders-api", "publish", "apps/orders-api/publish"}, + {true, "rabbitmq", "password", "shared/rabbitmq/password"}, + } { + got, err := ManagedSecretPath(test.shared, test.owner, test.name) + if err != nil || got != test.want { + t.Fatalf("ManagedSecretPath(%t, %q, %q) = %q, %v", test.shared, test.owner, test.name, got, err) + } + } + if _, err := ManagedSecretPath(false, "orders-api", "../root"); err == nil { + t.Fatal("ManagedSecretPath accepted an unsafe path") + } +} + +func TestStoreManagedSecretUsesRestrictedTokenStdinWithoutRecoveryInputs(t *testing.T) { + originalDecrypt, originalExec := decryptRecovery, execInPodMutation + t.Cleanup(func() { decryptRecovery, execInPodMutation = originalDecrypt, originalExec }) + decryptRecovery = func(_, _ string) ([]byte, error) { + t.Fatal("application secret CRUD must not read recovery material") + return nil, nil + } + const value = "must-not-appear-in-command" + execInPodMutation = func(_ string, input []byte, args ...string) ([]byte, error) { + if string(input) != "test-restricted-token\n"+base64.StdEncoding.EncodeToString([]byte(value))+"\n" { + t.Fatal("secret token and value were not framed on stdin") + } + command := strings.Join(args, " ") + if !strings.Contains(command, "bao kv put secret/apps/orders-api/publish value=\"$value0\"") || strings.Contains(command, value) || strings.Contains(command, "test-restricted-token") { + t.Fatal("secret command exposed a value or token") + } + return nil, nil + } + if err := StoreManagedSecret("kubeconfig", restrictedTokenFile(t), "apps/orders-api/publish", []byte(value)); err != nil { + t.Fatal(err) + } +} + +func TestListAndStatusReadMetadataOnly(t *testing.T) { + originalDecrypt, originalExec := decryptRecovery, execInPod + t.Cleanup(func() { decryptRecovery, execInPod = originalDecrypt, originalExec }) + decryptRecovery = func(_, _ string) ([]byte, error) { + t.Fatal("application secret CRUD must not read recovery material") + return nil, nil + } + calls := 0 + execInPod = func(_ string, input []byte, args ...string) ([]byte, error) { + calls++ + if string(input) != "test-restricted-token\n" || strings.Contains(strings.Join(args, " "), " kv get ") || strings.Contains(strings.Join(args, " "), "test-restricted-token") { + t.Fatal("metadata query used an unsafe secret-read boundary") + } + if strings.Contains(strings.Join(args, " "), "kv list") { + return []byte(`{"data":{"keys":["publish"]}}`), nil + } + return []byte(`{"data":{"created_time":"2026-01-01T00:00:00Z"}}`), nil + } + tokenPath := restrictedTokenFile(t) + values, err := ListManagedSecrets("kubeconfig", tokenPath, false, "orders-api") + if err != nil || len(values) != 1 || values[0] != "publish" { + t.Fatalf("ListManagedSecrets() = %q, %v", values, err) + } + present, err := ManagedSecretStatus("kubeconfig", tokenPath, "apps/orders-api/publish") + if err != nil || !present || calls != 2 { + t.Fatalf("ManagedSecretStatus() = %t, %v; calls = %d", present, err, calls) + } +} + +func TestDeleteManagedSecretUsesMetadataDelete(t *testing.T) { + originalDecrypt, originalExec := decryptRecovery, execInPodMutation + t.Cleanup(func() { decryptRecovery, execInPodMutation = originalDecrypt, originalExec }) + decryptRecovery = func(_, _ string) ([]byte, error) { + t.Fatal("application secret CRUD must not read recovery material") + return nil, nil + } + execInPodMutation = func(_ string, input []byte, args ...string) ([]byte, error) { + if string(input) != "test-restricted-token\n" || !strings.Contains(strings.Join(args, " "), "bao kv metadata delete secret/shared/rabbitmq/password") || strings.Contains(strings.Join(args, " "), "test-restricted-token") { + t.Fatal("delete did not use a metadata delete with token on stdin") + } + return nil, nil + } + if err := DeleteManagedSecret("kubeconfig", restrictedTokenFile(t), "shared/rabbitmq/password"); err != nil { + t.Fatal(err) + } +} + +func TestStoreManagedSecretRedactsTokenAndValueFromFailures(t *testing.T) { + original := execInPodMutation + t.Cleanup(func() { execInPodMutation = original }) + const value = "must-not-leak-value" + execInPodMutation = func(_ string, _ []byte, _ ...string) ([]byte, error) { + return []byte("test-restricted-token " + value), errors.New("failed") + } + err := StoreManagedSecret("kubeconfig", restrictedTokenFile(t), "apps/orders-api/publish", []byte(value)) + if err == nil || strings.Contains(err.Error(), "test-restricted-token") || strings.Contains(err.Error(), value) { + t.Fatal("secret operation leaked a token or value") + } +} diff --git a/internal/openbao/bootstrap.go b/internal/openbao/bootstrap.go index 6554b79..3d05db6 100644 --- a/internal/openbao/bootstrap.go +++ b/internal/openbao/bootstrap.go @@ -236,7 +236,7 @@ func parseRecoveryMaterial(plaintext []byte) (RecoveryMaterial, error) { return material, nil } -func writeSecret(kubeconfig, rootToken, secretPath string, values map[string]string) error { +func writeSecret(kubeconfig, token, secretPath string, values map[string]string) error { if !regexp.MustCompile(`^[a-z0-9][a-z0-9/_-]*$`).MatchString(secretPath) || len(values) == 0 { return fmt.Errorf("invalid OpenBao secret path %q", secretPath) } @@ -250,7 +250,7 @@ func writeSecret(kubeconfig, rootToken, secretPath string, values map[string]str sort.Strings(keys) arguments := make([]string, 0, len(keys)) input := strings.Builder{} - input.WriteString(rootToken) + input.WriteString(token) input.WriteByte('\n') for _, key := range keys { arguments = append(arguments, fmt.Sprintf("%s=\"$value%d\"", key, len(arguments))) @@ -263,7 +263,7 @@ func writeSecret(kubeconfig, rootToken, secretPath string, values map[string]str reads = append(reads, fmt.Sprintf("read -r value%d_b64", index)) decodes = append(decodes, fmt.Sprintf("value%d=$(printf '%%s' \"$value%d_b64\" | base64 -d; printf x)\nvalue%d=${value%d%%x}", index, index, index, index)) } - script := "read -r root_token\n" + strings.Join(reads, "\n") + "\n" + strings.Join(decodes, "\n") + "\nexport BAO_TOKEN=\"$root_token\"\nbao kv put secret/" + secretPath + " " + strings.Join(arguments, " ") + " >/dev/null" + script := "read -r token\n" + strings.Join(reads, "\n") + "\n" + strings.Join(decodes, "\n") + "\nexport BAO_TOKEN=\"$token\"\nbao kv put secret/" + secretPath + " " + strings.Join(arguments, " ") + " >/dev/null" _, err := execInPodMutation(kubeconfig, []byte(input.String()), "sh", "-ec", script) if err != nil { return fmt.Errorf("write OpenBao secret %q", secretPath) @@ -393,14 +393,14 @@ func ConfigureSecretGrants(kubeconfig, identityPath, bundlePath string, grants [ for _, grant := range grants { name := "maidn-" + grant.Application + "-" + grant.Consumer namespace := "tekton-pipelines" - path := "apps/" + grant.Application + "/" + grant.Consumer if grant.Consumer == "runtime" { name += "-" + grant.Environment namespace = grant.Application + "-" + grant.Environment - path += "/" + grant.Environment } script.WriteString("cat >/tmp/" + name + ".hcl <<'EOF'\n") - script.WriteString("path \"secret/data/" + path + "/*\" {\n capabilities = [\"read\"]\n}\n") + for _, secret := range grant.Secrets { + script.WriteString("path \"secret/data/apps/" + grant.Application + "/" + secret + "\" {\n capabilities = [\"read\"]\n}\n") + } for _, shared := range grant.Shared { script.WriteString("path \"secret/data/shared/" + shared + "/*\" {\n capabilities = [\"read\"]\n}\n") } diff --git a/internal/openbao/bootstrap_test.go b/internal/openbao/bootstrap_test.go index a82e8db..29ef6a2 100644 --- a/internal/openbao/bootstrap_test.go +++ b/internal/openbao/bootstrap_test.go @@ -141,15 +141,15 @@ func TestConfigureSecretGrantsScopesApplicationAndSharedPaths(t *testing.T) { return nil, nil } grants := []config.SecretGrant{ - {Application: "orders-api", Consumer: "publish", Shared: []string{"artifact-cache"}}, - {Application: "orders-api", Consumer: "runtime", Environment: "production", Shared: []string{"rabbitmq"}}, + {Application: "orders-api", Consumer: "publish", Secrets: []string{"registry"}, Shared: []string{"artifact-cache"}}, + {Application: "orders-api", Consumer: "runtime", Environment: "production", Secrets: []string{"database"}, Shared: []string{"rabbitmq"}}, } if err := ConfigureSecretGrants("kubeconfig", "identity", "bundle", grants); err != nil { t.Fatal(err) } for _, want := range []string{ - `secret/data/apps/orders-api/publish/*`, - `secret/data/apps/orders-api/runtime/production/*`, + `secret/data/apps/orders-api/registry`, + `secret/data/apps/orders-api/database`, `secret/data/shared/artifact-cache/*`, `secret/data/shared/rabbitmq/*`, `bound_service_account_names=maidn-orders-api-publish`, @@ -162,6 +162,9 @@ func TestConfigureSecretGrantsScopesApplicationAndSharedPaths(t *testing.T) { if strings.Contains(script, `secret/data/*`) { t.Fatal("secret grant widened access to every OpenBao secret") } + if strings.Contains(script, `secret/data/apps/orders-api/registry/*`) || strings.Contains(script, `secret/data/apps/orders-api/database/*`) { + t.Fatal("secret grant widened access beyond declared application secrets") + } } func TestConfigureSecretGrantsPreservesRedactedPolicyDiagnostics(t *testing.T) { @@ -174,7 +177,7 @@ func TestConfigureSecretGrantsPreservesRedactedPolicyDiagnostics(t *testing.T) { execInPodMutation = func(_ string, _ []byte, _ ...string) ([]byte, error) { return []byte("policy write denied for " + rootToken), errors.New("exit status 1") } - err := ConfigureSecretGrants("kubeconfig", "identity", "bundle", []config.SecretGrant{{Application: "orders-api", Consumer: "publish"}}) + err := ConfigureSecretGrants("kubeconfig", "identity", "bundle", []config.SecretGrant{{Application: "orders-api", Consumer: "publish", Secrets: []string{"registry"}}}) if err == nil || !strings.Contains(err.Error(), "policy write denied") || strings.Contains(err.Error(), rootToken) { t.Fatalf("policy diagnostics were not useful and redacted: %v", err) }