From 5cff72ce5b6bbc893aa3b913dc58f744daf1cfb8 Mon Sep 17 00:00:00 2001 From: Ludovic Vielle Date: Mon, 29 Jun 2026 11:41:18 +0200 Subject: [PATCH] Add Parameter Store to bootstrap resolver probod-bootstrap could only resolve aws:// Secrets Manager refs. Add awsps:// for SSM Parameter Store (GetParameter with decryption) and awssm:// as an explicit Secrets Manager prefix. Keep aws:// for backward compatibility. Values are cached per run per backend. Update Helm, .env.example, and probod-bootstrap changelog. Signed-off-by: Ludovic Vielle --- .env.example | 19 +-- cmd/probod-bootstrap/CHANGELOG.md | 5 + contrib/claude/config.md | 2 +- contrib/helm/README.md | 59 +++++++--- entrypoint.sh | 13 ++- go.mod | 1 + go.sum | 2 + pkg/bootstrap/resolver.go | 187 ++++++++++++++++++++---------- pkg/bootstrap/resolver_test.go | 182 +++++++++-------------------- 9 files changed, 255 insertions(+), 215 deletions(-) diff --git a/.env.example b/.env.example index 71707a4be..6356cc90c 100644 --- a/.env.example +++ b/.env.example @@ -31,14 +31,17 @@ # PROBOD_AUTH_COOKIE_SECRET=this-is-a-secure-secret-for-cookie-signing-at-least-32-bytes # PROBOD_AUTH_PASSWORD_PEPPER=this-is-a-secure-pepper-for-password-hashing-at-least-32-bytes # -# Optional: load secrets from AWS Secrets Manager by setting env vars to -# aws:// (e.g. PROBOD_ENCRYPTION_KEY=aws://probo/sandbox/probod/encryption_key). -# The path after aws:// is the Secrets Manager secret name or ARN; the -# plaintext SecretString is used directly. Plain env values are also supported. -# Requires AWS credentials for Secrets Manager resolution (IAM role, AWS_ACCESS_KEY_ID, -# or AWS_PROFILE) and AWS_REGION. These use the standard AWS SDK default chain, not -# PROBOD_AWS_*. -# PROBOD_ENCRYPTION_KEY=aws://probo/sandbox/probod/encryption_key +# Optional: load secrets from AWS by setting env vars to awssm://, aws://, or awsps:// refs. +# awssm:// or aws:// — Secrets Manager +# (e.g. PROBOD_ENCRYPTION_KEY=awssm://probo/probod/encryption_key). +# The path after the prefix is the secret name or ARN; the plaintext SecretString is used directly. +# awsps:// — SSM Parameter Store (e.g. PROBOD_ENCRYPTION_KEY=awsps:///probo/probod/encryption_key). +# The path after awsps:// is the parameter name; String and SecureString values are supported. +# Plain env values are also supported. Requires AWS credentials (IAM role, AWS_ACCESS_KEY_ID, +# or AWS_PROFILE) and AWS_REGION via the standard AWS SDK default chain, not PROBOD_AWS_*. +# PROBOD_ENCRYPTION_KEY=aws://probo/probod/encryption_key +# PROBOD_ENCRYPTION_KEY=awssm://probo/probod/encryption_key +# PROBOD_ENCRYPTION_KEY=awsps:///probo/probod/encryption_key # ── Cookie ──────────────────────────────────────────────────────────── # PROBOD_AUTH_COOKIE_DOMAIN=localhost diff --git a/cmd/probod-bootstrap/CHANGELOG.md b/cmd/probod-bootstrap/CHANGELOG.md index a14825e5c..33fc38b77 100644 --- a/cmd/probod-bootstrap/CHANGELOG.md +++ b/cmd/probod-bootstrap/CHANGELOG.md @@ -4,6 +4,11 @@ All notable changes to `probod-bootstrap` will be documented in this file. ## Unreleased +### Added + +- AWS Systems Manager Parameter Store resolution: env values prefixed with `awsps://` are fetched at startup using the standard AWS SDK credential chain and cached per run +- Secrets Manager prefix `awssm://` as an explicit alias alongside the existing `aws://` prefix + ## [0.2.0] - 2026-06-24 ### Breaking Changes diff --git a/contrib/claude/config.md b/contrib/claude/config.md index 2e573f9c7..a9529a475 100644 --- a/contrib/claude/config.md +++ b/contrib/claude/config.md @@ -27,7 +27,7 @@ Go struct (pkg/probod/) │ ├─► bootstrap builder.go (env var → struct) │ │ - │ ├─► Resolver (aws:// secret-id refs + plaintext env literals) + │ ├─► Resolver (aws:// / awssm:// / awsps:// refs + plaintext env literals) │ ├─► GNUmakefile dev-config (env vars → probod-bootstrap → cfg/dev.yaml) │ ├─► e2e/internal/testutil/ (env map → bootstrap.Build, tests) │ ├─► contrib/lima/provision.sh (env vars → probod-bootstrap) diff --git a/contrib/helm/README.md b/contrib/helm/README.md index 82493e506..d4f781689 100644 --- a/contrib/helm/README.md +++ b/contrib/helm/README.md @@ -322,15 +322,19 @@ spec: key: probo/db-password ``` -### Native AWS Secrets Manager (probod-bootstrap) +### Native AWS resolution (probod-bootstrap) -Alternatively, `probod-bootstrap` can fetch secrets directly from AWS Secrets -Manager without External Secrets Operator. Point env vars at individual secrets -with the `aws://` prefix (e.g. -`PROBOD_ENCRYPTION_KEY=aws://probo/sandbox/probod/encryption_key`). The path -after `aws://` is the secret name or ARN; the plaintext `SecretString` is used -directly. Each env var can reference a different secret. Plain env values are -also supported for non-sensitive config. +Alternatively, `probod-bootstrap` can fetch secrets directly from AWS without +External Secrets Operator. Point env vars at individual secrets or parameters +using `awssm://` or `aws://` (Secrets Manager) or `awsps://` (Parameter Store). Plain env +values are also supported for non-sensitive config. + +#### Secrets Manager (`awssm://` or `aws://`) + +Use the `awssm://` or `aws://` prefix (e.g. +`PROBOD_ENCRYPTION_KEY=aws://probo/probod/encryption_key`). The path +after the prefix is the secret name or ARN; the plaintext `SecretString` is +used directly. Each env var can reference a different secret. Grant the caller `secretsmanager:GetSecretValue` on each secret (EKS IRSA example): @@ -342,20 +346,47 @@ env: - name: PROBOD_BASE_URL value: "https://app.example.com" - name: PROBOD_ENCRYPTION_KEY - value: "aws://probo/sandbox/probod/encryption_key" + value: "awssm://probo/probod/encryption_key" - name: PROBOD_AUTH_COOKIE_SECRET - value: "aws://probo/sandbox/probod/cookie_secret" + value: "awssm://probo/probod/cookie_secret" - name: PROBOD_AUTH_PASSWORD_PEPPER - value: "aws://probo/sandbox/probod/password_pepper" + value: "awssm://probo/probod/password_pepper" - name: PROBOD_OAUTH2_SERVER_SIGNING_KEY - value: "aws://probo/sandbox/probod/oauth2_signing_key" + value: "awssm://probo/probod/oauth2_signing_key" ``` Each secret in AWS Secrets Manager stores a single plaintext value (for example a base64 key, password, or PEM). -When `PROBOD_ENCRYPTION_KEY` or another bootstrap env var is set (including -`aws://` references), the container entrypoint runs `probod-bootstrap`. +#### Parameter Store (`awsps://`) + +Use the `awsps://` prefix (e.g. +`PROBOD_ENCRYPTION_KEY=awsps:///probo/probod/encryption_key`). The path +after `awsps://` is the SSM parameter name; both `String` and `SecureString` +values are supported. + +Grant the caller `ssm:GetParameter` on each parameter (and `kms:Decrypt` when +using SecureString parameters with a custom KMS key): + +```yaml +env: + - name: AWS_REGION + value: "us-east-1" + - name: PROBOD_BASE_URL + value: "https://app.example.com" + - name: PROBOD_ENCRYPTION_KEY + value: "awsps:///probo/probod/encryption_key" + - name: PROBOD_AUTH_COOKIE_SECRET + value: "awsps:///probo/probod/cookie_secret" + - name: PROBOD_AUTH_PASSWORD_PEPPER + value: "awsps:///probo/probod/password_pepper" + - name: PROBOD_OAUTH2_SERVER_SIGNING_KEY + value: "awsps:///probo/probod/oauth2_signing_key" +``` + +When `PROBOD_ENCRYPTION_KEY` is set (including `aws://`, `awssm://`, or +`awsps://` references), the container entrypoint runs `probod-bootstrap` to +regenerate the config from all `PROBOD_*` env vars. ## Full Values diff --git a/entrypoint.sh b/entrypoint.sh index da160f144..accaec91a 100644 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -4,18 +4,19 @@ set -e # Configuration file path CONFIG_FILE="${CONFIG_FILE:-/etc/probod/config.yml}" -# If bootstrap env vars are set, always (re)generate the config from them. -# This includes literal values and aws:// Secrets Manager references. -# This ensures that updated env vars take effect even when a stale config -# file exists on a persistent volume. When no env vars are present, fall -# back to an existing config file (e.g., mounted from a ConfigMap). +# When PROBOD_ENCRYPTION_KEY is set, always (re)generate the config from env vars. +# This includes literal values and aws:// / awssm:// / awsps:// AWS references. +# probod-bootstrap reads every PROBOD_* var; the entrypoint only checks this one +# to decide whether to run it, including when a stale config file exists on a +# persistent volume. When it is unset, fall back to an existing config file +# (e.g., mounted from a ConfigMap). if [ -n "$PROBOD_ENCRYPTION_KEY" ]; then echo "Generating configuration file from environment variables at: $CONFIG_FILE" probod-bootstrap -output "$CONFIG_FILE" elif [ -f "$CONFIG_FILE" ]; then echo "Using existing configuration file at: $CONFIG_FILE" else - echo "Error: no bootstrap env vars set and no config file found at $CONFIG_FILE" >&2 + echo "Error: PROBOD_ENCRYPTION_KEY is unset and no config file found at $CONFIG_FILE" >&2 exit 1 fi diff --git a/go.mod b/go.mod index 126cd4266..7be1de050 100644 --- a/go.mod +++ b/go.mod @@ -60,6 +60,7 @@ require ( github.com/atotto/clipboard v0.1.4 // indirect github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.28 // indirect github.com/aws/aws-sdk-go-v2/service/signin v1.1.4 // indirect + github.com/aws/aws-sdk-go-v2/service/ssm v1.69.3 // indirect github.com/aws/aws-sdk-go-v2/service/sso v1.31.2 // indirect github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.5 // indirect github.com/aws/aws-sdk-go-v2/service/sts v1.43.2 // indirect diff --git a/go.sum b/go.sum index 3ceb603d3..cf313a384 100644 --- a/go.sum +++ b/go.sum @@ -95,6 +95,8 @@ github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.42.3 h1:L9gPLf3sFH1/ao3oB github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.42.3/go.mod h1:9DKRlwDCw2OUDlyCIFcQCroL5M0mQTUU9qW8JEDcXmI= github.com/aws/aws-sdk-go-v2/service/signin v1.1.4 h1:YcpVyIPLCbiypN6KSphijN5fC7DDjX114SqA7prnnxg= github.com/aws/aws-sdk-go-v2/service/signin v1.1.4/go.mod h1:5ZICS++oFTRPfa1GsBqFDWX/8WamZ/QQOcCzIuU/zLw= +github.com/aws/aws-sdk-go-v2/service/ssm v1.69.3 h1:58LjP8cp8UEHA1LG/JZ4fG9SobHE82kLYe46mogbSI4= +github.com/aws/aws-sdk-go-v2/service/ssm v1.69.3/go.mod h1:16Zd02ocSJp68o4r36MQ4Rikf/Ulv4On5qjMpJJf5Mo= github.com/aws/aws-sdk-go-v2/service/sso v1.31.2 h1:ySNWu7TPmj5fKFIa1GYvX+Ddxd5ccruqC20aMNuyWDM= github.com/aws/aws-sdk-go-v2/service/sso v1.31.2/go.mod h1:A+U9luAOwFeB1kseyWCITVg7/NntoPebCFR9pQ4ch9A= github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.5 h1:KSzGGqfk39O+WU3OEyYbx6F7sLDQCqxlOJ+2IksfK6U= diff --git a/pkg/bootstrap/resolver.go b/pkg/bootstrap/resolver.go index 1a82e92a9..ee340dc99 100644 --- a/pkg/bootstrap/resolver.go +++ b/pkg/bootstrap/resolver.go @@ -24,27 +24,24 @@ import ( "github.com/aws/aws-sdk-go-v2/aws" "github.com/aws/aws-sdk-go-v2/config" "github.com/aws/aws-sdk-go-v2/service/secretsmanager" + "github.com/aws/aws-sdk-go-v2/service/ssm" ) -const awsSecretRefPrefix = "aws://" - -type ( - SecretsManagerClient interface { - GetSecretValue( - ctx context.Context, - params *secretsmanager.GetSecretValueInput, - optFns ...func(*secretsmanager.Options), - ) (*secretsmanager.GetSecretValueOutput, error) - } - - Resolver struct { - lookup EnvGetter - secretsManagerClient SecretsManagerClient - smCache map[string]string - err error - } +const ( + awsSecretsManagerRefPrefix = "awssm://" + awsSecretsManagerLegacyRefPrefix = "aws://" + awsParameterStoreRefPrefix = "awsps://" ) +type Resolver struct { + lookup EnvGetter + smClient *secretsmanager.Client + psClient *ssm.Client + smCache map[string]string + psCache map[string]string + err error +} + func NewResolver(lookup EnvGetter) *Resolver { if lookup == nil { lookup = os.Getenv @@ -133,17 +130,29 @@ func (r *Resolver) getEnvBoolOrDefault(key string, defaultValue bool) bool { func (r *Resolver) resolve(key string) (string, error) { raw := r.lookup(key) - secretID, ok := parseAWSSecretRef(raw) - if !ok { - return raw, nil + if prefix, empty := emptyAWSRefPrefix(raw); empty { + return "", fmt.Errorf("cannot resolve %s: empty AWS reference after %s", key, prefix) } - value, err := r.loadPlaintextSecret(secretID) - if err != nil { - return "", fmt.Errorf("cannot resolve %s: %w", key, err) + if secretID, ok := parseAWSSecretsManagerRef(raw); ok { + value, err := r.loadPlaintextSecret(secretID) + if err != nil { + return "", fmt.Errorf("cannot resolve %s: %w", key, err) + } + + return value, nil } - return value, nil + if paramName, ok := parseAWSRef(raw, awsParameterStoreRefPrefix); ok { + value, err := r.loadParameter(paramName) + if err != nil { + return "", fmt.Errorf("cannot resolve %s: %w", key, err) + } + + return value, nil + } + + return raw, nil } func (r *Resolver) loadPlaintextSecret(secretID string) (string, error) { @@ -153,17 +162,24 @@ func (r *Resolver) loadPlaintextSecret(secretID string) (string, error) { } } - value, err := fetchPlaintextSecret( - context.Background(), - secretsManagerOptions{ - SecretID: secretID, - Client: r.secretsManagerClient, - }, - ) + client, err := r.secretsManagerClient(context.Background()) if err != nil { return "", err } + out, err := client.GetSecretValue(context.Background(), &secretsmanager.GetSecretValueInput{ + SecretId: aws.String(secretID), + }) + if err != nil { + return "", fmt.Errorf("cannot load secret from AWS Secrets Manager: %w", err) + } + + if out.SecretString == nil || *out.SecretString == "" { + return "", fmt.Errorf("secret %q has an empty SecretString", secretID) + } + + value := *out.SecretString + if r.smCache == nil { r.smCache = make(map[string]string) } @@ -173,47 +189,44 @@ func (r *Resolver) loadPlaintextSecret(secretID string) (string, error) { return value, nil } -func parseAWSSecretRef(value string) (string, bool) { - if !strings.HasPrefix(value, awsSecretRefPrefix) { - return "", false +func (r *Resolver) loadParameter(name string) (string, error) { + if r.psCache != nil { + if value, ok := r.psCache[name]; ok { + return value, nil + } } - secretID := strings.TrimPrefix(value, awsSecretRefPrefix) - if secretID == "" { - return "", false - } - - return secretID, true -} - -type secretsManagerOptions struct { - SecretID string - Client SecretsManagerClient -} - -func fetchPlaintextSecret(ctx context.Context, opts secretsManagerOptions) (string, error) { - client, err := secretsManagerClient(ctx, opts) + client, err := r.parameterStoreClient(context.Background()) if err != nil { return "", err } - out, err := client.GetSecretValue(ctx, &secretsmanager.GetSecretValueInput{ - SecretId: aws.String(opts.SecretID), + out, err := client.GetParameter(context.Background(), &ssm.GetParameterInput{ + Name: aws.String(name), + WithDecryption: aws.Bool(true), }) if err != nil { - return "", fmt.Errorf("cannot load secret from AWS Secrets Manager: %w", err) + return "", fmt.Errorf("cannot load parameter from AWS Systems Manager Parameter Store: %w", err) } - if out.SecretString == nil || *out.SecretString == "" { - return "", fmt.Errorf("secret %q has an empty SecretString", opts.SecretID) + if out.Parameter == nil || out.Parameter.Value == nil || *out.Parameter.Value == "" { + return "", fmt.Errorf("parameter %q has an empty value", name) } - return *out.SecretString, nil + value := *out.Parameter.Value + + if r.psCache == nil { + r.psCache = make(map[string]string) + } + + r.psCache[name] = value + + return value, nil } -func secretsManagerClient(ctx context.Context, opts secretsManagerOptions) (SecretsManagerClient, error) { - if opts.Client != nil { - return opts.Client, nil +func (r *Resolver) secretsManagerClient(ctx context.Context) (*secretsmanager.Client, error) { + if r.smClient != nil { + return r.smClient, nil } cfg, err := config.LoadDefaultConfig(ctx) @@ -221,5 +234,61 @@ func secretsManagerClient(ctx context.Context, opts secretsManagerOptions) (Secr return nil, fmt.Errorf("cannot load AWS config: %w", err) } - return secretsmanager.NewFromConfig(cfg), nil + r.smClient = secretsmanager.NewFromConfig(cfg) + + return r.smClient, nil +} + +func (r *Resolver) parameterStoreClient(ctx context.Context) (*ssm.Client, error) { + if r.psClient != nil { + return r.psClient, nil + } + + cfg, err := config.LoadDefaultConfig(ctx) + if err != nil { + return nil, fmt.Errorf("cannot load AWS config: %w", err) + } + + r.psClient = ssm.NewFromConfig(cfg) + + return r.psClient, nil +} + +func parseAWSRef(value, prefix string) (string, bool) { + if !strings.HasPrefix(value, prefix) { + return "", false + } + + ref := strings.TrimPrefix(value, prefix) + if ref == "" { + return "", false + } + + return ref, true +} + +func emptyAWSRefPrefix(value string) (string, bool) { + for _, prefix := range []string{ + awsSecretsManagerRefPrefix, + awsSecretsManagerLegacyRefPrefix, + awsParameterStoreRefPrefix, + } { + if strings.HasPrefix(value, prefix) && strings.TrimPrefix(value, prefix) == "" { + return prefix, true + } + } + + return "", false +} + +func parseAWSSecretsManagerRef(value string) (string, bool) { + if secretID, ok := parseAWSRef(value, awsSecretsManagerRefPrefix); ok { + return secretID, true + } + + return parseAWSRef(value, awsSecretsManagerLegacyRefPrefix) +} + +func parseAWSParameterStoreRef(value string) (string, bool) { + return parseAWSRef(value, awsParameterStoreRefPrefix) } diff --git a/pkg/bootstrap/resolver_test.go b/pkg/bootstrap/resolver_test.go index 0a28f92fd..44b80e327 100644 --- a/pkg/bootstrap/resolver_test.go +++ b/pkg/bootstrap/resolver_test.go @@ -15,46 +15,12 @@ package bootstrap import ( - "context" - "fmt" "testing" - "github.com/aws/aws-sdk-go-v2/aws" - "github.com/aws/aws-sdk-go-v2/service/secretsmanager" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) -type mockSecretsManagerClient struct { - secrets map[string]string - called []string -} - -func (m *mockSecretsManagerClient) GetSecretValue( - _ context.Context, - params *secretsmanager.GetSecretValueInput, - _ ...func(*secretsmanager.Options), -) (*secretsmanager.GetSecretValueOutput, error) { - secretID := aws.ToString(params.SecretId) - m.called = append(m.called, secretID) - - value, ok := m.secrets[secretID] - if !ok { - return nil, fmt.Errorf("secret %q not found", secretID) - } - - return &secretsmanager.GetSecretValueOutput{ - SecretString: aws.String(value), - }, nil -} - -func testResolver(env map[string]string, client SecretsManagerClient) *Resolver { - r := NewResolver(mockEnv(env)) - r.secretsManagerClient = client - - return r -} - func TestResolver_ResolveLiteralEnv(t *testing.T) { t.Parallel() @@ -63,117 +29,79 @@ func TestResolver_ResolveLiteralEnv(t *testing.T) { require.NoError(t, r.Err()) } -func TestBuilder_Build_ResolvesAWSSecretRefs(t *testing.T) { +func TestResolver_ResolveEmptyAWSSecretsManagerRef(t *testing.T) { t.Parallel() - client := &mockSecretsManagerClient{ - secrets: map[string]string{ - "probo/sandbox/probod/encryption_key": "test-encryption-key-32-bytes-long", - "probo/sandbox/probod/cookie_secret": "test-cookie-secret-32-bytes-long!", - "probo/sandbox/probod/password_pepper": "test-password-pepper-32-bytes-lo", - "probo/sandbox/probod/oauth2_signing_key": "test-oauth2-signing-key", - }, + for _, value := range []string{"awssm://", "aws://"} { + t.Run(value, func(t *testing.T) { + t.Parallel() + + r := NewResolver(mockEnv(map[string]string{ + "PROBOD_ENCRYPTION_KEY": value, + })) + + assert.Empty(t, r.getEnv("PROBOD_ENCRYPTION_KEY")) + require.Error(t, r.Err()) + assert.Contains(t, r.Err().Error(), "cannot resolve PROBOD_ENCRYPTION_KEY") + assert.Contains(t, r.Err().Error(), "empty AWS reference") + }) } - - env := map[string]string{ - "PROBOD_ENCRYPTION_KEY": "aws://probo/sandbox/probod/encryption_key", - "PROBOD_AUTH_COOKIE_SECRET": "aws://probo/sandbox/probod/cookie_secret", - "PROBOD_AUTH_PASSWORD_PEPPER": "aws://probo/sandbox/probod/password_pepper", - "PROBOD_OAUTH2_SERVER_SIGNING_KEY": "aws://probo/sandbox/probod/oauth2_signing_key", - "PROBOD_BASE_URL": "https://app.example.com", - } - b := NewBuilder(testResolver(env, client)) - - cfg, err := b.Build() - require.NoError(t, err) - require.Len(t, client.called, 4) - - assert.Equal(t, "test-encryption-key-32-bytes-long", cfg.Probod.EncryptionKey) - assert.Equal(t, "https://app.example.com", cfg.Probod.BaseURL) } -func TestResolver_ResolveAWSSecretRefsCachesBySecretID(t *testing.T) { +func TestResolver_ResolveEmptyAWSParameterStoreRef(t *testing.T) { t.Parallel() - client := &mockSecretsManagerClient{ - secrets: map[string]string{ - "probo/sandbox/probod/encryption_key": "secret-value", - }, - } - r := testResolver(map[string]string{ - "PROBOD_ENCRYPTION_KEY": "aws://probo/sandbox/probod/encryption_key", - "PROBOD_AUTH_COOKIE_SECRET": "aws://probo/sandbox/probod/encryption_key", - "PROBOD_AUTH_PASSWORD_PEPPER": "plaintext-pepper", - "PROBOD_OAUTH2_SERVER_SIGNING_KEY": "plaintext-oauth2", - }, client) - - assert.Equal(t, "secret-value", r.getEnv("PROBOD_ENCRYPTION_KEY")) - assert.Equal(t, "secret-value", r.getEnv("PROBOD_AUTH_COOKIE_SECRET")) - require.NoError(t, r.Err()) - require.Len(t, client.called, 1) -} - -func TestBuilder_Build_MixedPlaintextAndAWSSecrets(t *testing.T) { - t.Parallel() - - client := &mockSecretsManagerClient{ - secrets: map[string]string{ - "probo/sandbox/probod/encryption_key": "test-encryption-key-32-bytes-long", - }, - } - b := NewBuilder(testResolver(map[string]string{ - "PROBOD_ENCRYPTION_KEY": "aws://probo/sandbox/probod/encryption_key", - "PROBOD_AUTH_COOKIE_SECRET": "test-cookie-secret-32-bytes-long!", - "PROBOD_AUTH_PASSWORD_PEPPER": "test-password-pepper-32-bytes-lo", - "PROBOD_OAUTH2_SERVER_SIGNING_KEY": "test-oauth2-signing-key", - }, client)) - - cfg, err := b.Build() - require.NoError(t, err) - assert.Equal(t, "test-encryption-key-32-bytes-long", cfg.Probod.EncryptionKey) - assert.Equal(t, "test-cookie-secret-32-bytes-long!", cfg.Probod.Auth.Cookie.Secret) -} - -func TestResolver_ResolveAWSSecretRefMissingSecret(t *testing.T) { - t.Parallel() - - client := &mockSecretsManagerClient{secrets: map[string]string{}} - r := testResolver(map[string]string{ - "PROBOD_ENCRYPTION_KEY": "aws://probo/sandbox/probod/encryption_key", - }, client) + r := NewResolver(mockEnv(map[string]string{ + "PROBOD_ENCRYPTION_KEY": "awsps://", + })) assert.Empty(t, r.getEnv("PROBOD_ENCRYPTION_KEY")) require.Error(t, r.Err()) assert.Contains(t, r.Err().Error(), "cannot resolve PROBOD_ENCRYPTION_KEY") + assert.Contains(t, r.Err().Error(), "empty AWS reference") } -func TestResolver_ResolveAWSSecretRefEmptySecretString(t *testing.T) { +func TestParseAWSSecretsManagerRef(t *testing.T) { t.Parallel() - client := &mockSecretsManagerClient{ - secrets: map[string]string{ - "probo/sandbox/probod/encryption_key": "", - }, - } - r := testResolver(map[string]string{ - "PROBOD_ENCRYPTION_KEY": "aws://probo/sandbox/probod/encryption_key", - }, client) - - assert.Empty(t, r.getEnv("PROBOD_ENCRYPTION_KEY")) - require.Error(t, r.Err()) - assert.Contains(t, r.Err().Error(), "empty SecretString") -} - -func TestParseAWSSecretRef(t *testing.T) { - t.Parallel() - - secretID, ok := parseAWSSecretRef("aws://probo/sandbox/probod/encryption_key") + secretID, ok := parseAWSSecretsManagerRef("awssm://probo/probod/encryption_key") require.True(t, ok) - assert.Equal(t, "probo/sandbox/probod/encryption_key", secretID) + assert.Equal(t, "probo/probod/encryption_key", secretID) - _, ok = parseAWSSecretRef("literal-value") + _, ok = parseAWSSecretsManagerRef("literal-value") assert.False(t, ok) - _, ok = parseAWSSecretRef("aws://") + _, ok = parseAWSSecretsManagerRef("awssm://") + assert.False(t, ok) + + legacySecretID, ok := parseAWSSecretsManagerRef("aws://probo/probod/encryption_key") + require.True(t, ok) + assert.Equal(t, "probo/probod/encryption_key", legacySecretID) +} + +func TestParseAWSParameterStoreRef(t *testing.T) { + t.Parallel() + + paramName, ok := parseAWSParameterStoreRef("awsps:///probo/probod/encryption_key") + require.True(t, ok) + assert.Equal(t, "/probo/probod/encryption_key", paramName) + + _, ok = parseAWSParameterStoreRef("literal-value") + assert.False(t, ok) + + _, ok = parseAWSParameterStoreRef("awsps://") assert.False(t, ok) } + +func TestEmptyAWSRefPrefix(t *testing.T) { + t.Parallel() + + for _, value := range []string{"awssm://", "aws://", "awsps://"} { + prefix, empty := emptyAWSRefPrefix(value) + require.True(t, empty) + assert.Equal(t, value, prefix) + } + + _, empty := emptyAWSRefPrefix("awssm://probo/probod/encryption_key") + assert.False(t, empty) +}