diff --git a/pkg/api/copy.go b/pkg/api/copy.go index 03c0251..847cc72 100644 --- a/pkg/api/copy.go +++ b/pkg/api/copy.go @@ -99,10 +99,26 @@ func (s *RegistrarDescriptor) Copy() RegistrarDescriptor { } func (s *SecretsConfigDescriptor) Copy() SecretsConfigDescriptor { + var secretsConfigCopy *EnvironmentSecretsConfig + if s.SecretsConfig != nil { + secretsConfigCopy = &EnvironmentSecretsConfig{ + Mode: s.SecretsConfig.Mode, + Secrets: make(map[string]SecretsConfigMap), + } + for k, v := range s.SecretsConfig.Secrets { + secretsConfigCopy.Secrets[k] = SecretsConfigMap{ + InheritAll: v.InheritAll, + Include: append([]string{}, v.Include...), + Exclude: append([]string{}, v.Exclude...), + Override: lo.Assign(map[string]string{}, v.Override), + } + } + } return SecretsConfigDescriptor{ - Type: s.Type, - Config: s.Config.Copy(), - Inherit: s.Inherit, + Type: s.Type, + Config: s.Config.Copy(), + Inherit: s.Inherit, + SecretsConfig: secretsConfigCopy, } } diff --git a/pkg/api/models.go b/pkg/api/models.go index 9608211..582976f 100644 --- a/pkg/api/models.go +++ b/pkg/api/models.go @@ -57,6 +57,20 @@ func (m *StacksMap) ReconcileForDeploy(params StackParams) (*StacksMap, error) { if parentStack, ok := current[parentStackName]; ok { stack.Server = parentStack.Server.Copy() stack.Secrets = parentStack.Secrets.Copy() + + // Apply environment-specific secret filtering for child stacks + if stack.Server.Secrets.SecretsConfig != nil { + resolver := NewSecretResolver(stack.Server.Secrets.SecretsConfig) + env := params.Environment + if clientDesc.ParentEnv != "" { + env = clientDesc.ParentEnv + } + filteredSecrets, err := resolver.ResolveSecrets(stack.Secrets.Values, env) + if err != nil { + return nil, errors.Wrapf(err, "failed to resolve secrets for stack %q in environment %q", stackName, env) + } + stack.Secrets.Values = filteredSecrets + } } else { return nil, errors.Errorf("parent stack %q is not configured for %q in %q", clientDesc.ParentStack, stackName, params.Environment) } diff --git a/pkg/api/read.go b/pkg/api/read.go index 814e04e..bf97cd5 100644 --- a/pkg/api/read.go +++ b/pkg/api/read.go @@ -140,6 +140,12 @@ func ReadServerConfigs(descriptor *ServerDescriptor) (*ServerDescriptor, error) res = *withSecrets } + if withSecretsConfig, err := DetectSecretsConfigType(&res); err != nil { + return nil, err + } else { + res = *withSecretsConfig + } + if withTemplates, err := DetectTemplatesType(&res); err != nil { return nil, err } else { @@ -302,6 +308,57 @@ func DetectSecretsType(descriptor *ServerDescriptor) (*ServerDescriptor, error) return descriptor, nil } +func DetectSecretsConfigType(descriptor *ServerDescriptor) (*ServerDescriptor, error) { + if descriptor.Secrets.IsInherited() { + return descriptor, nil + } + // SecretsConfig is optional, skip if not set + if descriptor.Secrets.SecretsConfig == nil { + return descriptor, nil + } + + // Validate the mode + if descriptor.Secrets.SecretsConfig.Mode == "" { + return nil, errors.Errorf("secretsConfig.mode is required when secretsConfig is specified") + } + + // Validate mode is one of: include, exclude, override + validModes := map[string]bool{ + "include": true, + "exclude": true, + "override": true, + } + if !validModes[descriptor.Secrets.SecretsConfig.Mode] { + return nil, errors.Errorf("invalid secretsConfig.mode %q, must be one of: include, exclude, override", descriptor.Secrets.SecretsConfig.Mode) + } + + // Validate each environment config + for envName, envConfig := range descriptor.Secrets.SecretsConfig.Secrets { + // Validate include mode + if descriptor.Secrets.SecretsConfig.Mode == "include" { + if len(envConfig.Include) == 0 { + return nil, errors.Errorf("secretsConfig.secrets.%s: at least one secret must be specified in include mode", envName) + } + } + + // Validate exclude mode + if descriptor.Secrets.SecretsConfig.Mode == "exclude" { + if !envConfig.InheritAll && len(envConfig.Exclude) == 0 { + return nil, errors.Errorf("secretsConfig.secrets.%s: inheritAll must be true or exclude must not be empty in exclude mode", envName) + } + } + + // Validate override mode + if descriptor.Secrets.SecretsConfig.Mode == "override" { + if len(envConfig.Override) == 0 { + return nil, errors.Errorf("secretsConfig.secrets.%s: at least one override must be specified in override mode", envName) + } + } + } + + return descriptor, nil +} + func DetectProvisionerType(descriptor *ServerDescriptor) (*ServerDescriptor, error) { if descriptor.Provisioner.IsInherited() { return descriptor, nil diff --git a/pkg/api/secrets.go b/pkg/api/secrets.go index bd04d92..d748030 100644 --- a/pkg/api/secrets.go +++ b/pkg/api/secrets.go @@ -1,6 +1,11 @@ package api -import "github.com/pkg/errors" +import ( + "regexp" + "strings" + + "github.com/pkg/errors" +) const SecretsSchemaVersion = "1.0" @@ -24,3 +29,214 @@ func (a *AuthDescriptor) AuthConfig() (AuthConfig, error) { } return c, nil } + +// SecretResolver handles environment-specific secret resolution +type SecretResolver struct { + config *EnvironmentSecretsConfig + secretRef *regexp.Regexp +} + +// NewSecretResolver creates a new secret resolver +func NewSecretResolver(config *EnvironmentSecretsConfig) *SecretResolver { + return &SecretResolver{ + config: config, + secretRef: regexp.MustCompile(`^\$\{secret:([^}]+)\}$`), + } +} + +// ResolveSecrets applies environment-specific filtering to secrets +// Returns a filtered map of secret key-value pairs for the specified environment +func (sr *SecretResolver) ResolveSecrets(allSecrets map[string]string, env string) (map[string]string, error) { + if sr.config == nil { + // No filtering configured, return all secrets + return allSecrets, nil + } + + envConfig, ok := sr.config.Secrets[env] + if !ok { + // No config for this environment, return all secrets (backwards compatibility) + return allSecrets, nil + } + + switch sr.config.Mode { + case "include": + return sr.resolveIncludeMode(allSecrets, envConfig) + case "exclude": + return sr.resolveExcludeMode(allSecrets, envConfig) + case "override": + return sr.resolveOverrideMode(allSecrets, envConfig) + default: + return nil, errors.Errorf("unknown secrets config mode: %q", sr.config.Mode) + } +} + +// resolveIncludeMode resolves secrets in include mode +// Only secrets listed in the Include array are available +func (sr *SecretResolver) resolveIncludeMode(allSecrets map[string]string, envConfig SecretsConfigMap) (map[string]string, error) { + result := make(map[string]string) + + for _, ref := range envConfig.Include { + value, err := sr.resolveSecretReference(ref, allSecrets) + if err != nil { + return nil, errors.Wrapf(err, "failed to resolve secret reference %q", ref) + } + // Extract the key name from the reference + key := sr.extractKeyFromReference(ref) + result[key] = value + } + + return result, nil +} + +// resolveExcludeMode resolves secrets in exclude mode +// All secrets are available except those in the Exclude array (when InheritAll is true) +func (sr *SecretResolver) resolveExcludeMode(allSecrets map[string]string, envConfig SecretsConfigMap) (map[string]string, error) { + result := make(map[string]string) + + if envConfig.InheritAll { + // Copy all secrets first + for k, v := range allSecrets { + result[k] = v + } + } + + // Remove excluded secrets + for _, ref := range envConfig.Exclude { + key := sr.extractKeyFromReference(ref) + delete(result, key) + } + + return result, nil +} + +// resolveOverrideMode resolves secrets in override mode +// Secrets can be literal values or references to other secrets +func (sr *SecretResolver) resolveOverrideMode(allSecrets map[string]string, envConfig SecretsConfigMap) (map[string]string, error) { + result := make(map[string]string) + + for key, refOrValue := range envConfig.Override { + value, err := sr.resolveSecretReference(refOrValue, allSecrets) + if err != nil { + return nil, errors.Wrapf(err, "failed to resolve override for secret %q", key) + } + result[key] = value + } + + return result, nil +} + +// resolveSecretReference resolves a secret reference which can be: +// 1. Direct reference: "SECRET_NAME" or "~SECRET_NAME" - fetch directly from allSecrets +// 2. Mapped reference: "${secret:OTHER_SECRET}" - fetch OTHER_SECRET from allSecrets +// 3. Literal value: any other string - use as-is +func (sr *SecretResolver) resolveSecretReference(ref string, allSecrets map[string]string) (string, error) { + // Check for mapped reference pattern ${secret:KEY} + if matches := sr.secretRef.FindStringSubmatch(ref); matches != nil { + // Mapped reference - fetch the referenced secret + referencedKey := matches[1] + value, ok := allSecrets[referencedKey] + if !ok { + return "", errors.Errorf("referenced secret %q not found", referencedKey) + } + return value, nil + } + + // Check for direct reference with ~ prefix + if strings.HasPrefix(ref, "~") { + // Direct reference - remove the ~ and fetch + key := strings.TrimPrefix(ref, "~") + value, ok := allSecrets[key] + if !ok { + return "", errors.Errorf("secret %q not found", key) + } + return value, nil + } + + // Check if ref exists as a key in allSecrets (plain reference) + if value, ok := allSecrets[ref]; ok { + return value, nil + } + + // Otherwise, treat as literal value + return ref, nil +} + +// extractKeyFromReference extracts the secret key from a reference string +// For "SECRET_NAME" or "~SECRET_NAME" -> "SECRET_NAME" +// For "${secret:OTHER_SECRET}" -> the key that would reference this (not the referenced key) +func (sr *SecretResolver) extractKeyFromReference(ref string) string { + // If it's a mapped reference, we need to determine what key this would be stored as + // For simplicity, we use the reference string itself as the key + if matches := sr.secretRef.FindStringSubmatch(ref); matches != nil { + // This is a mapped reference - the key depends on context + // For include mode, the key is the reference itself + return ref + } + + // For ~ prefix, remove it + if strings.HasPrefix(ref, "~") { + return strings.TrimPrefix(ref, "~") + } + + return ref +} + +// GetAvailableSecrets returns the list of secret keys that will be available for the given environment +func (sr *SecretResolver) GetAvailableSecrets(allSecrets map[string]string, env string) ([]string, error) { + if sr.config == nil { + // No filtering, all secrets are available + keys := make([]string, 0, len(allSecrets)) + for k := range allSecrets { + keys = append(keys, k) + } + return keys, nil + } + + envConfig, ok := sr.config.Secrets[env] + if !ok { + // No config for this environment, all secrets are available + keys := make([]string, 0, len(allSecrets)) + for k := range allSecrets { + keys = append(keys, k) + } + return keys, nil + } + + switch sr.config.Mode { + case "include": + result := make([]string, 0, len(envConfig.Include)) + for _, ref := range envConfig.Include { + key := sr.extractKeyFromReference(ref) + result = append(result, key) + } + return result, nil + case "exclude": + if envConfig.InheritAll { + result := make([]string, 0, len(allSecrets)) + for k := range allSecrets { + excluded := false + for _, excRef := range envConfig.Exclude { + excKey := sr.extractKeyFromReference(excRef) + if k == excKey { + excluded = true + break + } + } + if !excluded { + result = append(result, k) + } + } + return result, nil + } + // If not inheriting all, only include non-excluded secrets + return []string{}, nil + case "override": + result := make([]string, 0, len(envConfig.Override)) + for k := range envConfig.Override { + result = append(result, k) + } + return result, nil + default: + return nil, errors.Errorf("unknown secrets config mode: %q", sr.config.Mode) + } +} diff --git a/pkg/api/secrets_config_test.go b/pkg/api/secrets_config_test.go new file mode 100644 index 0000000..5610159 --- /dev/null +++ b/pkg/api/secrets_config_test.go @@ -0,0 +1,592 @@ +package api + +import ( + "testing" + + . "github.com/onsi/gomega" +) + +func TestSecretResolver_IncludeMode(t *testing.T) { + RegisterTestingT(t) + + allSecrets := map[string]string{ + "API_KEY_PROD": "prod-key", + "API_KEY_STAGING": "staging-key", + "DATABASE_PASSWORD": "db-password", + "SMTP_PASSWORD": "smtp-password", + } + + config := &EnvironmentSecretsConfig{ + Mode: "include", + Secrets: map[string]SecretsConfigMap{ + "staging": { + Include: []string{"~API_KEY_STAGING", "~DATABASE_PASSWORD"}, + }, + }, + } + + resolver := NewSecretResolver(config) + + result, err := resolver.ResolveSecrets(allSecrets, "staging") + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(Equal(map[string]string{ + "API_KEY_STAGING": "staging-key", + "DATABASE_PASSWORD": "db-password", + })) +} + +func TestSecretResolver_ExcludeMode(t *testing.T) { + RegisterTestingT(t) + + allSecrets := map[string]string{ + "API_KEY_PROD": "prod-key", + "API_KEY_STAGING": "staging-key", + "DATABASE_PASSWORD": "db-password", + "SMTP_PASSWORD": "smtp-password", + } + + config := &EnvironmentSecretsConfig{ + Mode: "exclude", + Secrets: map[string]SecretsConfigMap{ + "staging": { + InheritAll: true, + Exclude: []string{"~API_KEY_PROD"}, + }, + }, + } + + resolver := NewSecretResolver(config) + + result, err := resolver.ResolveSecrets(allSecrets, "staging") + Expect(err).ToNot(HaveOccurred()) + + Expect(result).To(Equal(map[string]string{ + "API_KEY_STAGING": "staging-key", + "DATABASE_PASSWORD": "db-password", + "SMTP_PASSWORD": "smtp-password", + })) + + // Verify API_KEY_PROD is excluded + _, exists := result["API_KEY_PROD"] + Expect(exists).To(BeFalse()) +} + +func TestSecretResolver_OverrideMode(t *testing.T) { + RegisterTestingT(t) + + allSecrets := map[string]string{ + "API_KEY_STAGING": "staging-key", + "DATABASE_PASSWORD_STAGING": "staging-db-password", + } + + config := &EnvironmentSecretsConfig{ + Mode: "override", + Secrets: map[string]SecretsConfigMap{ + "staging": { + Override: map[string]string{ + "API_KEY": "${secret:API_KEY_STAGING}", + "DATABASE_PASSWORD": "${secret:DATABASE_PASSWORD_STAGING}", + "APP_NAME": "my-app", + }, + }, + }, + } + + resolver := NewSecretResolver(config) + + result, err := resolver.ResolveSecrets(allSecrets, "staging") + Expect(err).ToNot(HaveOccurred()) + + Expect(result).To(Equal(map[string]string{ + "API_KEY": "staging-key", + "DATABASE_PASSWORD": "staging-db-password", + "APP_NAME": "my-app", + })) +} + +func TestSecretResolver_MappedReference(t *testing.T) { + RegisterTestingT(t) + + allSecrets := map[string]string{ + "API_KEY_STAGING": "staging-key", + } + + config := &EnvironmentSecretsConfig{ + Mode: "override", + Secrets: map[string]SecretsConfigMap{ + "staging": { + Override: map[string]string{ + "API_KEY": "${secret:API_KEY_STAGING}", + }, + }, + }, + } + + resolver := NewSecretResolver(config) + + result, err := resolver.ResolveSecrets(allSecrets, "staging") + Expect(err).ToNot(HaveOccurred()) + + Expect(result).To(Equal(map[string]string{ + "API_KEY": "staging-key", + })) +} + +func TestSecretResolver_LiteralValue(t *testing.T) { + RegisterTestingT(t) + + allSecrets := map[string]string{} + + config := &EnvironmentSecretsConfig{ + Mode: "override", + Secrets: map[string]SecretsConfigMap{ + "staging": { + Override: map[string]string{ + "API_KEY": "literal-api-key", + }, + }, + }, + } + + resolver := NewSecretResolver(config) + + result, err := resolver.ResolveSecrets(allSecrets, "staging") + Expect(err).ToNot(HaveOccurred()) + + Expect(result).To(Equal(map[string]string{ + "API_KEY": "literal-api-key", + })) +} + +func TestSecretResolver_NoConfig(t *testing.T) { + RegisterTestingT(t) + + allSecrets := map[string]string{ + "API_KEY": "api-key", + } + + // Nil config = no filtering + resolver := NewSecretResolver(nil) + + result, err := resolver.ResolveSecrets(allSecrets, "staging") + Expect(err).ToNot(HaveOccurred()) + + // All secrets should be returned + Expect(result).To(Equal(allSecrets)) +} + +func TestSecretResolver_NoEnvironmentConfig(t *testing.T) { + RegisterTestingT(t) + + allSecrets := map[string]string{ + "API_KEY": "api-key", + } + + config := &EnvironmentSecretsConfig{ + Mode: "include", + Secrets: map[string]SecretsConfigMap{ + "production": { + Include: []string{"~API_KEY"}, + }, + }, + } + + resolver := NewSecretResolver(config) + + // Request environment not in config - should return all secrets (backwards compatibility) + result, err := resolver.ResolveSecrets(allSecrets, "staging") + Expect(err).ToNot(HaveOccurred()) + + Expect(result).To(Equal(allSecrets)) +} + +func TestSecretResolver_InvalidMode(t *testing.T) { + RegisterTestingT(t) + + allSecrets := map[string]string{ + "API_KEY": "api-key", + } + + config := &EnvironmentSecretsConfig{ + Mode: "invalid", + Secrets: map[string]SecretsConfigMap{ + "staging": {}, + }, + } + + resolver := NewSecretResolver(config) + + _, err := resolver.ResolveSecrets(allSecrets, "staging") + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("unknown secrets config mode")) +} + +func TestSecretResolver_NonExistentSecretReference(t *testing.T) { + RegisterTestingT(t) + + allSecrets := map[string]string{ + "API_KEY": "api-key", + } + + config := &EnvironmentSecretsConfig{ + Mode: "include", + Secrets: map[string]SecretsConfigMap{ + "staging": { + Include: []string{"~NON_EXISTENT_SECRET"}, + }, + }, + } + + resolver := NewSecretResolver(config) + + _, err := resolver.ResolveSecrets(allSecrets, "staging") + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("secret")) + Expect(err.Error()).To(ContainSubstring("not found")) +} + +func TestSecretResolver_GetAvailableSecrets(t *testing.T) { + RegisterTestingT(t) + + allSecrets := map[string]string{ + "API_KEY": "api-key", + "DATABASE_PASSWORD": "db-password", + "SMTP_PASSWORD": "smtp-password", + } + + t.Run("Include mode", func(t *testing.T) { + RegisterTestingT(t) + + config := &EnvironmentSecretsConfig{ + Mode: "include", + Secrets: map[string]SecretsConfigMap{ + "staging": { + Include: []string{"~API_KEY", "~DATABASE_PASSWORD"}, + }, + }, + } + + resolver := NewSecretResolver(config) + available, err := resolver.GetAvailableSecrets(allSecrets, "staging") + Expect(err).ToNot(HaveOccurred()) + + Expect(available).To(ConsistOf("API_KEY", "DATABASE_PASSWORD")) + }) + + t.Run("Exclude mode", func(t *testing.T) { + RegisterTestingT(t) + + config := &EnvironmentSecretsConfig{ + Mode: "exclude", + Secrets: map[string]SecretsConfigMap{ + "staging": { + InheritAll: true, + Exclude: []string{"~SMTP_PASSWORD"}, + }, + }, + } + + resolver := NewSecretResolver(config) + available, err := resolver.GetAvailableSecrets(allSecrets, "staging") + Expect(err).ToNot(HaveOccurred()) + + Expect(available).To(ConsistOf("API_KEY", "DATABASE_PASSWORD")) + }) + + t.Run("Override mode", func(t *testing.T) { + RegisterTestingT(t) + + config := &EnvironmentSecretsConfig{ + Mode: "override", + Secrets: map[string]SecretsConfigMap{ + "staging": { + Override: map[string]string{ + "API_KEY": "new-key", + }, + }, + }, + } + + resolver := NewSecretResolver(config) + available, err := resolver.GetAvailableSecrets(allSecrets, "staging") + Expect(err).ToNot(HaveOccurred()) + + Expect(available).To(ConsistOf("API_KEY")) + }) + + t.Run("No config", func(t *testing.T) { + RegisterTestingT(t) + + resolver := NewSecretResolver(nil) + available, err := resolver.GetAvailableSecrets(allSecrets, "staging") + Expect(err).ToNot(HaveOccurred()) + + Expect(available).To(ConsistOf("API_KEY", "DATABASE_PASSWORD", "SMTP_PASSWORD")) + }) +} + +func TestSecretsConfigDescriptor_Copy(t *testing.T) { + RegisterTestingT(t) + + original := &SecretsConfigDescriptor{ + Type: "test-type", + SecretsConfig: &EnvironmentSecretsConfig{ + Mode: "include", + Secrets: map[string]SecretsConfigMap{ + "staging": { + Include: []string{"~API_KEY"}, + Override: map[string]string{ + "KEY": "value", + }, + }, + }, + }, + } + + copied := original.Copy() + + // Verify values match + Expect(copied.Type).To(Equal(original.Type)) + Expect(copied.SecretsConfig).ToNot(BeNil()) + Expect(copied.SecretsConfig.Mode).To(Equal(original.SecretsConfig.Mode)) + + // Verify deep copy + Expect(copied.SecretsConfig.Secrets["staging"].Include).To(Equal(original.SecretsConfig.Secrets["staging"].Include)) + Expect(copied.SecretsConfig.Secrets["staging"].Override).To(Equal(original.SecretsConfig.Secrets["staging"].Override)) + + // Modify original and verify copy is unaffected + original.SecretsConfig.Secrets["staging"].Include[0] = "~MODIFIED" + Expect(copied.SecretsConfig.Secrets["staging"].Include[0]).To(Equal("~API_KEY")) + + original.SecretsConfig.Secrets["staging"].Override["KEY"] = "modified" + Expect(copied.SecretsConfig.Secrets["staging"].Override["KEY"]).To(Equal("value")) +} + +func TestValidateSecretAccess_IncludeMode(t *testing.T) { + RegisterTestingT(t) + + descriptor := &ServerDescriptor{ + Secrets: SecretsConfigDescriptor{ + SecretsConfig: &EnvironmentSecretsConfig{ + Mode: "include", + Secrets: map[string]SecretsConfigMap{ + "staging": { + Include: []string{"~API_KEY", "~DATABASE_PASSWORD"}, + }, + }, + }, + }, + } + + clientConfig := &StackConfigCompose{ + Secrets: map[string]string{ + "API_KEY": "placeholder", + }, + } + + errs := ValidateSecretAccess(descriptor, clientConfig, "staging") + Expect(errs).To(BeEmpty()) + + // Test with secret not in include list + clientConfigInvalid := &StackConfigCompose{ + Secrets: map[string]string{ + "SMTP_PASSWORD": "placeholder", + }, + } + + errs = ValidateSecretAccess(descriptor, clientConfigInvalid, "staging") + Expect(errs).ToNot(BeEmpty()) + Expect(errs[0].Error()).To(ContainSubstring("SMTP_PASSWORD")) + Expect(errs[0].Error()).To(ContainSubstring("not in the include list")) +} + +func TestValidateSecretAccess_ExcludeMode(t *testing.T) { + RegisterTestingT(t) + + descriptor := &ServerDescriptor{ + Secrets: SecretsConfigDescriptor{ + SecretsConfig: &EnvironmentSecretsConfig{ + Mode: "exclude", + Secrets: map[string]SecretsConfigMap{ + "staging": { + InheritAll: true, + Exclude: []string{"~PROD_SECRET"}, + }, + }, + }, + }, + } + + clientConfig := &StackConfigCompose{ + Secrets: map[string]string{ + "API_KEY": "placeholder", + }, + } + + errs := ValidateSecretAccess(descriptor, clientConfig, "staging") + Expect(errs).To(BeEmpty()) + + // Test with excluded secret + clientConfigInvalid := &StackConfigCompose{ + Secrets: map[string]string{ + "PROD_SECRET": "placeholder", + }, + } + + errs = ValidateSecretAccess(descriptor, clientConfigInvalid, "staging") + Expect(errs).ToNot(BeEmpty()) + Expect(errs[0].Error()).To(ContainSubstring("PROD_SECRET")) + Expect(errs[0].Error()).To(ContainSubstring("excluded")) +} + +func TestValidateSecretAccess_OverrideMode(t *testing.T) { + RegisterTestingT(t) + + descriptor := &ServerDescriptor{ + Secrets: SecretsConfigDescriptor{ + SecretsConfig: &EnvironmentSecretsConfig{ + Mode: "override", + Secrets: map[string]SecretsConfigMap{ + "staging": { + Override: map[string]string{ + "API_KEY": "new-key", + }, + }, + }, + }, + }, + } + + clientConfig := &StackConfigCompose{ + Secrets: map[string]string{ + "API_KEY": "placeholder", + }, + } + + errs := ValidateSecretAccess(descriptor, clientConfig, "staging") + Expect(errs).To(BeEmpty()) + + // Test with secret not in override map + clientConfigInvalid := &StackConfigCompose{ + Secrets: map[string]string{ + "OTHER_SECRET": "placeholder", + }, + } + + errs = ValidateSecretAccess(descriptor, clientConfigInvalid, "staging") + Expect(errs).ToNot(BeEmpty()) + Expect(errs[0].Error()).To(ContainSubstring("OTHER_SECRET")) + Expect(errs[0].Error()).To(ContainSubstring("not in the override list")) +} + +func TestValidateSecretAccess_NoConfig(t *testing.T) { + RegisterTestingT(t) + + descriptor := &ServerDescriptor{ + Secrets: SecretsConfigDescriptor{ + // No SecretsConfig set + }, + } + + clientConfig := &StackConfigCompose{ + Secrets: map[string]string{ + "API_KEY": "placeholder", + }, + } + + errs := ValidateSecretAccess(descriptor, clientConfig, "staging") + Expect(errs).To(BeEmpty()) +} + +func TestReconcileForDeploy_SecretFiltering(t *testing.T) { + RegisterTestingT(t) + + stacks := &StacksMap{ + "parent": { + Name: "parent", + Secrets: SecretsDescriptor{ + Values: map[string]string{ + "API_KEY_STAGING": "staging-key", + "DATABASE_PASSWORD": "db-password", + "PROD_SECRET": "prod-secret", + }, + }, + Server: ServerDescriptor{ + Secrets: SecretsConfigDescriptor{ + SecretsConfig: &EnvironmentSecretsConfig{ + Mode: "include", + Secrets: map[string]SecretsConfigMap{ + "staging": { + Include: []string{"~API_KEY_STAGING", "~DATABASE_PASSWORD"}, + }, + }, + }, + }, + }, + }, + "child": { + Name: "child", + Client: ClientDescriptor{ + Stacks: map[string]StackClientDescriptor{ + "staging": { + ParentStack: "parent", + }, + }, + }, + }, + } + + params := StackParams{ + StackName: "child", + Environment: "staging", + } + + result, err := stacks.ReconcileForDeploy(params) + Expect(err).ToNot(HaveOccurred()) + + childStack := (*result)["child"] + + // Verify only included secrets are available + Expect(childStack.Secrets.Values).To(HaveKey("API_KEY_STAGING")) + Expect(childStack.Secrets.Values).To(HaveKey("DATABASE_PASSWORD")) + Expect(childStack.Secrets.Values).ToNot(HaveKey("PROD_SECRET")) +} + +func TestExtractKeyFromRef(t *testing.T) { + RegisterTestingT(t) + + tests := []struct { + name string + ref string + expected string + }{ + { + name: "direct reference", + ref: "SECRET_NAME", + expected: "SECRET_NAME", + }, + { + name: "tilde prefix", + ref: "~SECRET_NAME", + expected: "SECRET_NAME", + }, + { + name: "secret reference pattern", + ref: "${secret:OTHER_SECRET}", + expected: "OTHER_SECRET", + }, + { + name: "literal value", + ref: "literal-value", + expected: "literal-value", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + RegisterTestingT(t) + result := extractKeyFromRef(tt.ref) + Expect(result).To(Equal(tt.expected)) + }) + } +} diff --git a/pkg/api/server.go b/pkg/api/server.go index a483572..1774379 100644 --- a/pkg/api/server.go +++ b/pkg/api/server.go @@ -131,9 +131,34 @@ type CloudComposeDescriptor struct { } type SecretsConfigDescriptor struct { - Type string `json:"type" yaml:"type"` - Config `json:",inline" yaml:",inline"` - Inherit `json:",inline" yaml:",inline"` + Type string `json:"type" yaml:"type"` + Config `json:",inline" yaml:",inline"` + Inherit `json:",inline" yaml:",inline"` + SecretsConfig *EnvironmentSecretsConfig `json:"secretsConfig,omitempty" yaml:"secretsConfig,omitempty"` +} + +// EnvironmentSecretsConfig defines environment-specific secret filtering rules +type EnvironmentSecretsConfig struct { + // Mode determines how secrets are filtered: "include", "exclude", or "override" + Mode string `json:"mode" yaml:"mode"` + + // Secrets is a map of environment names to their secret configurations + Secrets map[string]SecretsConfigMap `json:"secrets" yaml:"secrets"` +} + +// SecretsConfigMap defines secret reference patterns for an environment +type SecretsConfigMap struct { + // InheritAll when true, all secrets from the parent are inherited (for exclude mode) + InheritAll bool `json:"inheritAll,omitempty" yaml:"inheritAll,omitempty"` + + // Include lists secrets to make available (for include mode) + Include []string `json:"include,omitempty" yaml:"include,omitempty"` + + // Exclude lists secrets to hide (for exclude mode) + Exclude []string `json:"exclude,omitempty" yaml:"exclude,omitempty"` + + // Override provides literal values or mappings for secrets (for override mode) + Override map[string]string `json:"override,omitempty" yaml:"override,omitempty"` } // ProvisionerDescriptor describes the provisioner schema diff --git a/pkg/api/validation.go b/pkg/api/validation.go new file mode 100644 index 0000000..7009628 --- /dev/null +++ b/pkg/api/validation.go @@ -0,0 +1,149 @@ +package api + +import ( + "regexp" + + "github.com/pkg/errors" + "github.com/samber/lo" +) + +var ( + // secretRefPattern matches ${secret:KEY} references + secretRefPattern = regexp.MustCompile(`\$\{secret:([^}]+)\}`) +) + +// ValidateSecretReferences validates that all secret references in a client configuration +// are valid and reference available secrets +func ValidateSecretReferences(clientConfig *StackConfigCompose, availableSecrets []string) []error { + var errs []error + + // Check secrets referenced in the config + for secretName := range clientConfig.Secrets { + if !lo.Contains(availableSecrets, secretName) { + errs = append(errs, errors.Errorf("secret %q is not available in the current environment", secretName)) + } + } + + return errs +} + +// ValidateSecretAccess validates that all secrets referenced in a client config +// will be available after applying the parent stack's secretsConfig +func ValidateSecretAccess(descriptor *ServerDescriptor, clientConfig *StackConfigCompose, env string) []error { + var errs []error + + // If no secretsConfig is defined, all secrets are available (backwards compatibility) + if descriptor.Secrets.SecretsConfig == nil { + return nil + } + + // We need to check against all potential secrets (from the parent stack's secrets descriptor) + // Since we don't have the actual values here, we'll validate the configuration structure + envConfig, ok := descriptor.Secrets.SecretsConfig.Secrets[env] + if !ok { + // No config for this environment - in include/override mode this means no secrets available + if descriptor.Secrets.SecretsConfig.Mode == "include" || descriptor.Secrets.SecretsConfig.Mode == "override" { + for secretName := range clientConfig.Secrets { + errs = append(errs, errors.Errorf("environment %q is not configured in secretsConfig, secret %q will not be available", env, secretName)) + } + return errs + } + // For exclude mode, if no config, all secrets are available + return nil + } + + // Check based on mode + switch descriptor.Secrets.SecretsConfig.Mode { + case "include": + // Only secrets in the Include list are available + for secretName := range clientConfig.Secrets { + found := false + for _, ref := range envConfig.Include { + key := extractKeyFromRef(ref) + if key == secretName { + found = true + break + } + } + if !found { + errs = append(errs, errors.Errorf("secret %q is not in the include list for environment %q", secretName, env)) + } + } + case "exclude": + // Check if any referenced secret is in the exclude list + for secretName := range clientConfig.Secrets { + for _, excRef := range envConfig.Exclude { + excKey := extractKeyFromRef(excRef) + if excKey == secretName { + errs = append(errs, errors.Errorf("secret %q is excluded for environment %q", secretName, env)) + } + } + } + case "override": + // Only secrets in the Override map are available + for secretName := range clientConfig.Secrets { + if _, ok := envConfig.Override[secretName]; !ok { + errs = append(errs, errors.Errorf("secret %q is not in the override list for environment %q", secretName, env)) + } + } + } + + return errs +} + +// extractKeyFromRef extracts the secret key from a reference string +func extractKeyFromRef(ref string) string { + // Check for ${secret:KEY} pattern + if matches := secretRefPattern.FindStringSubmatch(ref); matches != nil { + return matches[1] + } + // Check for ~KEY pattern + if len(ref) > 0 && ref[0] == '~' { + return ref[1:] + } + return ref +} + +// ValidateSecretConfigReferences validates that all secret references in a secretsConfig +// are valid (e.g., ${secret:KEY} references point to existing secrets) +func ValidateSecretConfigValues(config *EnvironmentSecretsConfig, allSecrets map[string]string) []error { + var errs []error + + if config == nil { + return nil + } + + for envName, envConfig := range config.Secrets { + // Validate override references + for key, refOrValue := range envConfig.Override { + if matches := secretRefPattern.FindStringSubmatch(refOrValue); matches != nil { + // This is a ${secret:KEY} reference, validate the referenced key exists + referencedKey := matches[1] + if _, ok := allSecrets[referencedKey]; !ok { + errs = append(errs, errors.Errorf("in environment %q, override for secret %q references non-existent secret %q", envName, key, referencedKey)) + } + } + } + + // Validate include/exclude references + for _, ref := range envConfig.Include { + if matches := secretRefPattern.FindStringSubmatch(ref); matches != nil { + referencedKey := matches[1] + if _, ok := allSecrets[referencedKey]; !ok { + errs = append(errs, errors.Errorf("in environment %q, include list references non-existent secret %q", envName, referencedKey)) + } + } + } + + for _, ref := range envConfig.Exclude { + if matches := secretRefPattern.FindStringSubmatch(ref); matches != nil { + referencedKey := matches[1] + if _, ok := allSecrets[referencedKey]; !ok { + errs = append(errs, errors.Errorf("in environment %q, exclude list references non-existent secret %q", envName, referencedKey)) + } + } + } + } + + return errs +}