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 <ludovic@probo.com>
This commit is contained in:
Ludovic Vielle
2026-06-29 11:41:18 +02:00
parent ba1c9fccfa
commit 5cff72ce5b
9 changed files with 255 additions and 215 deletions

View File

@@ -31,14 +31,17 @@
# PROBOD_AUTH_COOKIE_SECRET=this-is-a-secure-secret-for-cookie-signing-at-least-32-bytes # 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 # 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 # Optional: load secrets from AWS by setting env vars to awssm://, aws://, or awsps:// refs.
# aws://<secret-id> (e.g. PROBOD_ENCRYPTION_KEY=aws://probo/sandbox/probod/encryption_key). # awssm://<secret-id> or aws://<secret-id> — Secrets Manager
# The path after aws:// is the Secrets Manager secret name or ARN; the # (e.g. PROBOD_ENCRYPTION_KEY=awssm://probo/probod/encryption_key).
# plaintext SecretString is used directly. Plain env values are also supported. # The path after the prefix is the secret name or ARN; the plaintext SecretString is used directly.
# Requires AWS credentials for Secrets Manager resolution (IAM role, AWS_ACCESS_KEY_ID, # awsps://<parameter-name> — SSM Parameter Store (e.g. PROBOD_ENCRYPTION_KEY=awsps:///probo/probod/encryption_key).
# or AWS_PROFILE) and AWS_REGION. These use the standard AWS SDK default chain, not # The path after awsps:// is the parameter name; String and SecureString values are supported.
# PROBOD_AWS_*. # Plain env values are also supported. Requires AWS credentials (IAM role, AWS_ACCESS_KEY_ID,
# PROBOD_ENCRYPTION_KEY=aws://probo/sandbox/probod/encryption_key # 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 ──────────────────────────────────────────────────────────── # ── Cookie ────────────────────────────────────────────────────────────
# PROBOD_AUTH_COOKIE_DOMAIN=localhost # PROBOD_AUTH_COOKIE_DOMAIN=localhost

View File

@@ -4,6 +4,11 @@ All notable changes to `probod-bootstrap` will be documented in this file.
## Unreleased ## Unreleased
### Added
- AWS Systems Manager Parameter Store resolution: env values prefixed with `awsps://<parameter-name>` are fetched at startup using the standard AWS SDK credential chain and cached per run
- Secrets Manager prefix `awssm://<secret-id>` as an explicit alias alongside the existing `aws://<secret-id>` prefix
## [0.2.0] - 2026-06-24 ## [0.2.0] - 2026-06-24
### Breaking Changes ### Breaking Changes

View File

@@ -27,7 +27,7 @@ Go struct (pkg/probod/)
├─► bootstrap builder.go (env var → struct) ├─► 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) │ ├─► GNUmakefile dev-config (env vars → probod-bootstrap → cfg/dev.yaml)
│ ├─► e2e/internal/testutil/ (env map → bootstrap.Build, tests) │ ├─► e2e/internal/testutil/ (env map → bootstrap.Build, tests)
│ ├─► contrib/lima/provision.sh (env vars → probod-bootstrap) │ ├─► contrib/lima/provision.sh (env vars → probod-bootstrap)

View File

@@ -322,15 +322,19 @@ spec:
key: probo/db-password 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 Alternatively, `probod-bootstrap` can fetch secrets directly from AWS without
Manager without External Secrets Operator. Point env vars at individual secrets External Secrets Operator. Point env vars at individual secrets or parameters
with the `aws://<secret-id>` prefix (e.g. using `awssm://` or `aws://` (Secrets Manager) or `awsps://` (Parameter Store). Plain env
`PROBOD_ENCRYPTION_KEY=aws://probo/sandbox/probod/encryption_key`). The path values are also supported for non-sensitive config.
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 #### Secrets Manager (`awssm://` or `aws://`)
also supported for non-sensitive config.
Use the `awssm://<secret-id>` or `aws://<secret-id>` 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 Grant the caller `secretsmanager:GetSecretValue` on each secret (EKS IRSA
example): example):
@@ -342,20 +346,47 @@ env:
- name: PROBOD_BASE_URL - name: PROBOD_BASE_URL
value: "https://app.example.com" value: "https://app.example.com"
- name: PROBOD_ENCRYPTION_KEY - name: PROBOD_ENCRYPTION_KEY
value: "aws://probo/sandbox/probod/encryption_key" value: "awssm://probo/probod/encryption_key"
- name: PROBOD_AUTH_COOKIE_SECRET - name: PROBOD_AUTH_COOKIE_SECRET
value: "aws://probo/sandbox/probod/cookie_secret" value: "awssm://probo/probod/cookie_secret"
- name: PROBOD_AUTH_PASSWORD_PEPPER - name: PROBOD_AUTH_PASSWORD_PEPPER
value: "aws://probo/sandbox/probod/password_pepper" value: "awssm://probo/probod/password_pepper"
- name: PROBOD_OAUTH2_SERVER_SIGNING_KEY - 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 Each secret in AWS Secrets Manager stores a single plaintext value (for
example a base64 key, password, or PEM). example a base64 key, password, or PEM).
When `PROBOD_ENCRYPTION_KEY` or another bootstrap env var is set (including #### Parameter Store (`awsps://`)
`aws://` references), the container entrypoint runs `probod-bootstrap`.
Use the `awsps://<parameter-name>` 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 ## Full Values

View File

@@ -4,18 +4,19 @@ set -e
# Configuration file path # Configuration file path
CONFIG_FILE="${CONFIG_FILE:-/etc/probod/config.yml}" CONFIG_FILE="${CONFIG_FILE:-/etc/probod/config.yml}"
# If bootstrap env vars are set, always (re)generate the config from them. # When PROBOD_ENCRYPTION_KEY is set, always (re)generate the config from env vars.
# This includes literal values and aws:// Secrets Manager references. # This includes literal values and aws:// / awssm:// / awsps:// AWS references.
# This ensures that updated env vars take effect even when a stale config # probod-bootstrap reads every PROBOD_* var; the entrypoint only checks this one
# file exists on a persistent volume. When no env vars are present, fall # to decide whether to run it, including when a stale config file exists on a
# back to an existing config file (e.g., mounted from a ConfigMap). # 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 if [ -n "$PROBOD_ENCRYPTION_KEY" ]; then
echo "Generating configuration file from environment variables at: $CONFIG_FILE" echo "Generating configuration file from environment variables at: $CONFIG_FILE"
probod-bootstrap -output "$CONFIG_FILE" probod-bootstrap -output "$CONFIG_FILE"
elif [ -f "$CONFIG_FILE" ]; then elif [ -f "$CONFIG_FILE" ]; then
echo "Using existing configuration file at: $CONFIG_FILE" echo "Using existing configuration file at: $CONFIG_FILE"
else 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 exit 1
fi fi

1
go.mod
View File

@@ -60,6 +60,7 @@ require (
github.com/atotto/clipboard v0.1.4 // indirect 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/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/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/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/ssooidc v1.36.5 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.43.2 // indirect github.com/aws/aws-sdk-go-v2/service/sts v1.43.2 // indirect

2
go.sum
View File

@@ -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/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 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/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 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/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= github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.5 h1:KSzGGqfk39O+WU3OEyYbx6F7sLDQCqxlOJ+2IksfK6U=

View File

@@ -24,27 +24,24 @@ import (
"github.com/aws/aws-sdk-go-v2/aws" "github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/config" "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/secretsmanager"
"github.com/aws/aws-sdk-go-v2/service/ssm"
) )
const awsSecretRefPrefix = "aws://" const (
awsSecretsManagerRefPrefix = "awssm://"
type ( awsSecretsManagerLegacyRefPrefix = "aws://"
SecretsManagerClient interface { awsParameterStoreRefPrefix = "awsps://"
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
}
) )
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 { func NewResolver(lookup EnvGetter) *Resolver {
if lookup == nil { if lookup == nil {
lookup = os.Getenv lookup = os.Getenv
@@ -133,17 +130,29 @@ func (r *Resolver) getEnvBoolOrDefault(key string, defaultValue bool) bool {
func (r *Resolver) resolve(key string) (string, error) { func (r *Resolver) resolve(key string) (string, error) {
raw := r.lookup(key) raw := r.lookup(key)
secretID, ok := parseAWSSecretRef(raw) if prefix, empty := emptyAWSRefPrefix(raw); empty {
if !ok { return "", fmt.Errorf("cannot resolve %s: empty AWS reference after %s", key, prefix)
return raw, nil
} }
value, err := r.loadPlaintextSecret(secretID) if secretID, ok := parseAWSSecretsManagerRef(raw); ok {
if err != nil { value, err := r.loadPlaintextSecret(secretID)
return "", fmt.Errorf("cannot resolve %s: %w", key, err) 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) { func (r *Resolver) loadPlaintextSecret(secretID string) (string, error) {
@@ -153,17 +162,24 @@ func (r *Resolver) loadPlaintextSecret(secretID string) (string, error) {
} }
} }
value, err := fetchPlaintextSecret( client, err := r.secretsManagerClient(context.Background())
context.Background(),
secretsManagerOptions{
SecretID: secretID,
Client: r.secretsManagerClient,
},
)
if err != nil { if err != nil {
return "", err 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 { if r.smCache == nil {
r.smCache = make(map[string]string) r.smCache = make(map[string]string)
} }
@@ -173,47 +189,44 @@ func (r *Resolver) loadPlaintextSecret(secretID string) (string, error) {
return value, nil return value, nil
} }
func parseAWSSecretRef(value string) (string, bool) { func (r *Resolver) loadParameter(name string) (string, error) {
if !strings.HasPrefix(value, awsSecretRefPrefix) { if r.psCache != nil {
return "", false if value, ok := r.psCache[name]; ok {
return value, nil
}
} }
secretID := strings.TrimPrefix(value, awsSecretRefPrefix) client, err := r.parameterStoreClient(context.Background())
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)
if err != nil { if err != nil {
return "", err return "", err
} }
out, err := client.GetSecretValue(ctx, &secretsmanager.GetSecretValueInput{ out, err := client.GetParameter(context.Background(), &ssm.GetParameterInput{
SecretId: aws.String(opts.SecretID), Name: aws.String(name),
WithDecryption: aws.Bool(true),
}) })
if err != nil { 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 == "" { if out.Parameter == nil || out.Parameter.Value == nil || *out.Parameter.Value == "" {
return "", fmt.Errorf("secret %q has an empty SecretString", opts.SecretID) 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) { func (r *Resolver) secretsManagerClient(ctx context.Context) (*secretsmanager.Client, error) {
if opts.Client != nil { if r.smClient != nil {
return opts.Client, nil return r.smClient, nil
} }
cfg, err := config.LoadDefaultConfig(ctx) 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 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)
} }

View File

@@ -15,46 +15,12 @@
package bootstrap package bootstrap
import ( import (
"context"
"fmt"
"testing" "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/assert"
"github.com/stretchr/testify/require" "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) { func TestResolver_ResolveLiteralEnv(t *testing.T) {
t.Parallel() t.Parallel()
@@ -63,117 +29,79 @@ func TestResolver_ResolveLiteralEnv(t *testing.T) {
require.NoError(t, r.Err()) require.NoError(t, r.Err())
} }
func TestBuilder_Build_ResolvesAWSSecretRefs(t *testing.T) { func TestResolver_ResolveEmptyAWSSecretsManagerRef(t *testing.T) {
t.Parallel() t.Parallel()
client := &mockSecretsManagerClient{ for _, value := range []string{"awssm://", "aws://"} {
secrets: map[string]string{ t.Run(value, func(t *testing.T) {
"probo/sandbox/probod/encryption_key": "test-encryption-key-32-bytes-long", t.Parallel()
"probo/sandbox/probod/cookie_secret": "test-cookie-secret-32-bytes-long!",
"probo/sandbox/probod/password_pepper": "test-password-pepper-32-bytes-lo", r := NewResolver(mockEnv(map[string]string{
"probo/sandbox/probod/oauth2_signing_key": "test-oauth2-signing-key", "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() t.Parallel()
client := &mockSecretsManagerClient{ r := NewResolver(mockEnv(map[string]string{
secrets: map[string]string{ "PROBOD_ENCRYPTION_KEY": "awsps://",
"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)
assert.Empty(t, r.getEnv("PROBOD_ENCRYPTION_KEY")) assert.Empty(t, r.getEnv("PROBOD_ENCRYPTION_KEY"))
require.Error(t, r.Err()) require.Error(t, r.Err())
assert.Contains(t, r.Err().Error(), "cannot resolve PROBOD_ENCRYPTION_KEY") 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() t.Parallel()
client := &mockSecretsManagerClient{ secretID, ok := parseAWSSecretsManagerRef("awssm://probo/probod/encryption_key")
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")
require.True(t, ok) 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) 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) 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)
}