From 03a9e6467f8b8b33c979d6c1a30650acf4fecff6 Mon Sep 17 00:00:00 2001 From: Khris Richardson Date: Thu, 1 Oct 2026 10:54:09 -0700 Subject: [PATCH] feat(credentials): add an explicit ambient auth type for AWS Secrets Manager AWSSecretManager gains an auth_type enum. AUTH_TYPE_AMBIENT resolves credentials through the AWS SDK default chain (EKS Pod Identity, IRSA, instance role), so the control plane and CAS need no stored keys. AUTH_TYPE_CREDENTIALS keeps the static keys, and AUTH_TYPE_UNSPECIFIED is treated as AUTH_TYPE_CREDENTIALS, so existing configurations do not change. The operator must select ambient authentication explicitly. NewManager rejects AUTH_TYPE_CREDENTIALS without both keys, so forgotten keys fail at startup instead of silently using whatever the default chain finds, and rejects AUTH_TYPE_AMBIENT with either key set. The chart exposes secretsBackend.awsSecretManager.authType and renders the creds block only for AUTH_TYPE_CREDENTIALS. The tests isolate the default chain from the host (AWS config files, AWS_PROFILE, IMDS). Assisted-by: Claude Code Signed-off-by: Khris Richardson --- deployment/chainloop/Chart.yaml | 2 +- deployment/chainloop/README.md | 15 ++- deployment/chainloop/templates/_helpers.tpl | 10 ++ deployment/chainloop/values.yaml | 6 +- .../api/credentials/v1/config.pb.go | 122 ++++++++++++++---- .../api/credentials/v1/config.proto | 14 +- pkg/credentials/aws/secretmanager.go | 57 ++++++-- pkg/credentials/aws/secretmanager_test.go | 47 ++++++- pkg/credentials/manager/manager.go | 6 + pkg/credentials/manager/manager_test.go | 71 ++++++++++ 10 files changed, 308 insertions(+), 42 deletions(-) diff --git a/deployment/chainloop/Chart.yaml b/deployment/chainloop/Chart.yaml index adead4697..8b355dbfd 100644 --- a/deployment/chainloop/Chart.yaml +++ b/deployment/chainloop/Chart.yaml @@ -7,7 +7,7 @@ description: Chainloop is an open source software supply chain control plane, a type: application # Bump the patch (not minor, not major) version on each change in the Chart Source code -version: 1.451.0 +version: 1.451.1 # Do not update appVersion, this is handled automatically by the release process appVersion: v1.113.0 diff --git a/deployment/chainloop/README.md b/deployment/chainloop/README.md index 4847257ed..49824c4d0 100644 --- a/deployment/chainloop/README.md +++ b/deployment/chainloop/README.md @@ -361,6 +361,16 @@ secretsBackend: region: [REGION] ``` +To authenticate without stored keys (EKS Pod Identity, IRSA or an instance role), select the AWS SDK default credential chain explicitly and leave the keys unset + +```yaml +secretsBackend: + backend: awsSecretManager + awsSecretManager: + authType: AUTH_TYPE_AMBIENT + region: [REGION] +``` + ### Use GCP secret manager Or [Google Cloud Secret Manager](https://cloud.google.com/secret-manager) with the following settings @@ -540,8 +550,9 @@ Once done, you can access with [two predefined users](https://github.com/chainlo | `secretsBackend.secretPrefix` | Prefix that will be pre-pended to all secrets in the storage backend | `chainloop` | | `secretsBackend.vault.address` | Vault address | | | `secretsBackend.vault.token` | Vault authentication token | | -| `secretsBackend.awsSecretManager.accessKey` | AWS Access KEY ID | | -| `secretsBackend.awsSecretManager.secretKey` | AWS Secret Key | | +| `secretsBackend.awsSecretManager.authType` | AUTH_TYPE_CREDENTIALS (default, static keys) or AUTH_TYPE_AMBIENT (default chain) | | +| `secretsBackend.awsSecretManager.accessKey` | AWS Access KEY ID (AUTH_TYPE_CREDENTIALS only) | | +| `secretsBackend.awsSecretManager.secretKey` | AWS Secret Key (AUTH_TYPE_CREDENTIALS only) | | | `secretsBackend.awsSecretManager.region` | AWS Secrets Manager Region | | | `secretsBackend.gcpSecretManager.projectId` | GCP Project ID | | | `secretsBackend.gcpSecretManager.serviceAccountKey` | GCP Auth Key | | diff --git a/deployment/chainloop/templates/_helpers.tpl b/deployment/chainloop/templates/_helpers.tpl index e530766fd..28e1826b6 100644 --- a/deployment/chainloop/templates/_helpers.tpl +++ b/deployment/chainloop/templates/_helpers.tpl @@ -81,11 +81,21 @@ vault: {{- end }} {{- else if eq .backend "awsSecretManager" }} +{{- $authType := .awsSecretManager.authType | default "AUTH_TYPE_CREDENTIALS" }} awsSecretManager: region: {{ required "region required" .awsSecretManager.region | quote }} + authType: {{ $authType | quote }} + {{- if eq $authType "AUTH_TYPE_CREDENTIALS" }} creds: accessKey: {{ required "access key required" .awsSecretManager.accessKey | quote }} secretKey: {{ required "secret key required" .awsSecretManager.secretKey | quote }} + {{- else if eq $authType "AUTH_TYPE_AMBIENT" }} + {{- if or .awsSecretManager.accessKey .awsSecretManager.secretKey }} + {{- fail "secretsBackend.awsSecretManager: accessKey and secretKey must not be set with authType AUTH_TYPE_AMBIENT" }} + {{- end }} + {{- else }} + {{- fail (printf "secretsBackend.awsSecretManager.authType %q is not one of AUTH_TYPE_CREDENTIALS, AUTH_TYPE_AMBIENT" $authType) }} + {{- end }} {{- else if eq .backend "gcpSecretManager" }} gcpSecretManager: diff --git a/deployment/chainloop/values.yaml b/deployment/chainloop/values.yaml index 94cde726f..cdccd65ed 100644 --- a/deployment/chainloop/values.yaml +++ b/deployment/chainloop/values.yaml @@ -70,11 +70,13 @@ secretsBackend: # address: "" # token: "" - ## @extra secretsBackend.awsSecretManager.accessKey AWS Access KEY ID - ## @extra secretsBackend.awsSecretManager.secretKey AWS Secret Key + ## @extra secretsBackend.awsSecretManager.authType AUTH_TYPE_CREDENTIALS (default, static keys) or AUTH_TYPE_AMBIENT (default chain) + ## @extra secretsBackend.awsSecretManager.accessKey AWS Access KEY ID (AUTH_TYPE_CREDENTIALS only) + ## @extra secretsBackend.awsSecretManager.secretKey AWS Secret Key (AUTH_TYPE_CREDENTIALS only) ## @extra secretsBackend.awsSecretManager.region AWS Secrets Manager Region ## # awsSecretManager: + # authType: AUTH_TYPE_CREDENTIALS # accessKey: "" # secretKey: "" # region: "" diff --git a/pkg/credentials/api/credentials/v1/config.pb.go b/pkg/credentials/api/credentials/v1/config.pb.go index 3b1ec870d..ffd65ff49 100644 --- a/pkg/credentials/api/credentials/v1/config.pb.go +++ b/pkg/credentials/api/credentials/v1/config.pb.go @@ -37,6 +37,57 @@ const ( _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) ) +type Credentials_AWSSecretManager_AuthType int32 + +const ( + Credentials_AWSSecretManager_AUTH_TYPE_UNSPECIFIED Credentials_AWSSecretManager_AuthType = 0 + // Use the static keys in creds. + Credentials_AWSSecretManager_AUTH_TYPE_CREDENTIALS Credentials_AWSSecretManager_AuthType = 1 + // Use the AWS SDK default credential chain (EKS Pod Identity, IRSA, instance role). + Credentials_AWSSecretManager_AUTH_TYPE_AMBIENT Credentials_AWSSecretManager_AuthType = 2 +) + +// Enum value maps for Credentials_AWSSecretManager_AuthType. +var ( + Credentials_AWSSecretManager_AuthType_name = map[int32]string{ + 0: "AUTH_TYPE_UNSPECIFIED", + 1: "AUTH_TYPE_CREDENTIALS", + 2: "AUTH_TYPE_AMBIENT", + } + Credentials_AWSSecretManager_AuthType_value = map[string]int32{ + "AUTH_TYPE_UNSPECIFIED": 0, + "AUTH_TYPE_CREDENTIALS": 1, + "AUTH_TYPE_AMBIENT": 2, + } +) + +func (x Credentials_AWSSecretManager_AuthType) Enum() *Credentials_AWSSecretManager_AuthType { + p := new(Credentials_AWSSecretManager_AuthType) + *p = x + return p +} + +func (x Credentials_AWSSecretManager_AuthType) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (Credentials_AWSSecretManager_AuthType) Descriptor() protoreflect.EnumDescriptor { + return file_credentials_v1_config_proto_enumTypes[0].Descriptor() +} + +func (Credentials_AWSSecretManager_AuthType) Type() protoreflect.EnumType { + return &file_credentials_v1_config_proto_enumTypes[0] +} + +func (x Credentials_AWSSecretManager_AuthType) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use Credentials_AWSSecretManager_AuthType.Descriptor instead. +func (Credentials_AWSSecretManager_AuthType) EnumDescriptor() ([]byte, []int) { + return file_credentials_v1_config_proto_rawDescGZIP(), []int{0, 0, 0} +} + // Where the credentials to access the backends are stored type Credentials struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -163,9 +214,12 @@ func (*Credentials_AzureKeyVault_) isCredentials_Backend() {} // Top level is deprecated now type Credentials_AWSSecretManager struct { - state protoimpl.MessageState `protogen:"open.v1"` - Creds *Credentials_AWSSecretManager_Creds `protobuf:"bytes,1,opt,name=creds,proto3" json:"creds,omitempty"` - Region string `protobuf:"bytes,2,opt,name=region,proto3" json:"region,omitempty"` + state protoimpl.MessageState `protogen:"open.v1"` + // Required for AUTH_TYPE_CREDENTIALS, rejected for AUTH_TYPE_AMBIENT. + Creds *Credentials_AWSSecretManager_Creds `protobuf:"bytes,1,opt,name=creds,proto3" json:"creds,omitempty"` + Region string `protobuf:"bytes,2,opt,name=region,proto3" json:"region,omitempty"` + // How to authenticate. AUTH_TYPE_UNSPECIFIED is treated as AUTH_TYPE_CREDENTIALS. + AuthType Credentials_AWSSecretManager_AuthType `protobuf:"varint,3,opt,name=auth_type,json=authType,proto3,enum=credentials.v1.Credentials_AWSSecretManager_AuthType" json:"auth_type,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -214,6 +268,13 @@ func (x *Credentials_AWSSecretManager) GetRegion() string { return "" } +func (x *Credentials_AWSSecretManager) GetAuthType() Credentials_AWSSecretManager_AuthType { + if x != nil { + return x.AuthType + } + return Credentials_AWSSecretManager_AUTH_TYPE_UNSPECIFIED +} + type Credentials_Vault struct { state protoimpl.MessageState `protogen:"open.v1"` // TODO: Use application role auth instead @@ -460,21 +521,26 @@ var File_credentials_v1_config_proto protoreflect.FileDescriptor const file_credentials_v1_config_proto_rawDesc = "" + "\n" + - "\x1bcredentials/v1/config.proto\x12\x0ecredentials.v1\x1a\x1bbuf/validate/validate.proto\"\xfa\a\n" + + "\x1bcredentials/v1/config.proto\x12\x0ecredentials.v1\x1a\x1bbuf/validate/validate.proto\"\xa9\t\n" + "\vCredentials\x12\\\n" + "\x12aws_secret_manager\x18\x01 \x01(\v2,.credentials.v1.Credentials.AWSSecretManagerH\x00R\x10awsSecretManager\x129\n" + "\x05vault\x18\x02 \x01(\v2!.credentials.v1.Credentials.VaultH\x00R\x05vault\x12\\\n" + "\x12gcp_secret_manager\x18\x03 \x01(\v2,.credentials.v1.Credentials.GCPSecretManagerH\x00R\x10gcpSecretManager\x12S\n" + "\x0fazure_key_vault\x18\x05 \x01(\v2).credentials.v1.Credentials.AzureKeyVaultH\x00R\razureKeyVault\x12#\n" + - "\rsecret_prefix\x18\x04 \x01(\tR\fsecretPrefix\x1a\xde\x01\n" + - "\x10AWSSecretManager\x12P\n" + - "\x05creds\x18\x01 \x01(\v22.credentials.v1.Credentials.AWSSecretManager.CredsB\x06\xbaH\x03\xc8\x01\x01R\x05creds\x12\x1f\n" + - "\x06region\x18\x02 \x01(\tB\a\xbaH\x04r\x02\x10\x01R\x06region\x1aW\n" + + "\rsecret_prefix\x18\x04 \x01(\tR\fsecretPrefix\x1a\x8d\x03\n" + + "\x10AWSSecretManager\x12H\n" + + "\x05creds\x18\x01 \x01(\v22.credentials.v1.Credentials.AWSSecretManager.CredsR\x05creds\x12\x1f\n" + + "\x06region\x18\x02 \x01(\tB\a\xbaH\x04r\x02\x10\x01R\x06region\x12\\\n" + + "\tauth_type\x18\x03 \x01(\x0e25.credentials.v1.Credentials.AWSSecretManager.AuthTypeB\b\xbaH\x05\x82\x01\x02\x10\x01R\bauthType\x1aW\n" + "\x05Creds\x12&\n" + "\n" + "access_key\x18\x01 \x01(\tB\a\xbaH\x04r\x02\x10\x01R\taccessKey\x12&\n" + "\n" + - "secret_key\x18\x02 \x01(\tB\a\xbaH\x04r\x02\x10\x01R\tsecretKey\x1ah\n" + + "secret_key\x18\x02 \x01(\tB\a\xbaH\x04r\x02\x10\x01R\tsecretKey\"W\n" + + "\bAuthType\x12\x19\n" + + "\x15AUTH_TYPE_UNSPECIFIED\x10\x00\x12\x19\n" + + "\x15AUTH_TYPE_CREDENTIALS\x10\x01\x12\x15\n" + + "\x11AUTH_TYPE_AMBIENT\x10\x02\x1ah\n" + "\x05Vault\x12\x1d\n" + "\x05token\x18\x01 \x01(\tB\a\xbaH\x04r\x02\x10\x01R\x05token\x12!\n" + "\aaddress\x18\x02 \x01(\tB\a\xbaH\x04r\x02\x10\x01R\aaddress\x12\x1d\n" + @@ -503,26 +569,29 @@ func file_credentials_v1_config_proto_rawDescGZIP() []byte { return file_credentials_v1_config_proto_rawDescData } +var file_credentials_v1_config_proto_enumTypes = make([]protoimpl.EnumInfo, 1) var file_credentials_v1_config_proto_msgTypes = make([]protoimpl.MessageInfo, 6) var file_credentials_v1_config_proto_goTypes = []any{ - (*Credentials)(nil), // 0: credentials.v1.Credentials - (*Credentials_AWSSecretManager)(nil), // 1: credentials.v1.Credentials.AWSSecretManager - (*Credentials_Vault)(nil), // 2: credentials.v1.Credentials.Vault - (*Credentials_GCPSecretManager)(nil), // 3: credentials.v1.Credentials.GCPSecretManager - (*Credentials_AzureKeyVault)(nil), // 4: credentials.v1.Credentials.AzureKeyVault - (*Credentials_AWSSecretManager_Creds)(nil), // 5: credentials.v1.Credentials.AWSSecretManager.Creds + (Credentials_AWSSecretManager_AuthType)(0), // 0: credentials.v1.Credentials.AWSSecretManager.AuthType + (*Credentials)(nil), // 1: credentials.v1.Credentials + (*Credentials_AWSSecretManager)(nil), // 2: credentials.v1.Credentials.AWSSecretManager + (*Credentials_Vault)(nil), // 3: credentials.v1.Credentials.Vault + (*Credentials_GCPSecretManager)(nil), // 4: credentials.v1.Credentials.GCPSecretManager + (*Credentials_AzureKeyVault)(nil), // 5: credentials.v1.Credentials.AzureKeyVault + (*Credentials_AWSSecretManager_Creds)(nil), // 6: credentials.v1.Credentials.AWSSecretManager.Creds } var file_credentials_v1_config_proto_depIdxs = []int32{ - 1, // 0: credentials.v1.Credentials.aws_secret_manager:type_name -> credentials.v1.Credentials.AWSSecretManager - 2, // 1: credentials.v1.Credentials.vault:type_name -> credentials.v1.Credentials.Vault - 3, // 2: credentials.v1.Credentials.gcp_secret_manager:type_name -> credentials.v1.Credentials.GCPSecretManager - 4, // 3: credentials.v1.Credentials.azure_key_vault:type_name -> credentials.v1.Credentials.AzureKeyVault - 5, // 4: credentials.v1.Credentials.AWSSecretManager.creds:type_name -> credentials.v1.Credentials.AWSSecretManager.Creds - 5, // [5:5] is the sub-list for method output_type - 5, // [5:5] is the sub-list for method input_type - 5, // [5:5] is the sub-list for extension type_name - 5, // [5:5] is the sub-list for extension extendee - 0, // [0:5] is the sub-list for field type_name + 2, // 0: credentials.v1.Credentials.aws_secret_manager:type_name -> credentials.v1.Credentials.AWSSecretManager + 3, // 1: credentials.v1.Credentials.vault:type_name -> credentials.v1.Credentials.Vault + 4, // 2: credentials.v1.Credentials.gcp_secret_manager:type_name -> credentials.v1.Credentials.GCPSecretManager + 5, // 3: credentials.v1.Credentials.azure_key_vault:type_name -> credentials.v1.Credentials.AzureKeyVault + 6, // 4: credentials.v1.Credentials.AWSSecretManager.creds:type_name -> credentials.v1.Credentials.AWSSecretManager.Creds + 0, // 5: credentials.v1.Credentials.AWSSecretManager.auth_type:type_name -> credentials.v1.Credentials.AWSSecretManager.AuthType + 6, // [6:6] is the sub-list for method output_type + 6, // [6:6] is the sub-list for method input_type + 6, // [6:6] is the sub-list for extension type_name + 6, // [6:6] is the sub-list for extension extendee + 0, // [0:6] is the sub-list for field type_name } func init() { file_credentials_v1_config_proto_init() } @@ -541,13 +610,14 @@ func file_credentials_v1_config_proto_init() { File: protoimpl.DescBuilder{ GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_credentials_v1_config_proto_rawDesc), len(file_credentials_v1_config_proto_rawDesc)), - NumEnums: 0, + NumEnums: 1, NumMessages: 6, NumExtensions: 0, NumServices: 0, }, GoTypes: file_credentials_v1_config_proto_goTypes, DependencyIndexes: file_credentials_v1_config_proto_depIdxs, + EnumInfos: file_credentials_v1_config_proto_enumTypes, MessageInfos: file_credentials_v1_config_proto_msgTypes, }.Build() File_credentials_v1_config_proto = out.File diff --git a/pkg/credentials/api/credentials/v1/config.proto b/pkg/credentials/api/credentials/v1/config.proto index 557d1654d..962fa1a5b 100644 --- a/pkg/credentials/api/credentials/v1/config.proto +++ b/pkg/credentials/api/credentials/v1/config.proto @@ -36,9 +36,21 @@ message Credentials { // Top level is deprecated now message AWSSecretManager { - Creds creds = 1 [(buf.validate.field).required = true]; + // Required for AUTH_TYPE_CREDENTIALS, rejected for AUTH_TYPE_AMBIENT. + Creds creds = 1; string region = 2 [(buf.validate.field).string.min_len = 1]; + enum AuthType { + AUTH_TYPE_UNSPECIFIED = 0; + // Use the static keys in creds. + AUTH_TYPE_CREDENTIALS = 1; + // Use the AWS SDK default credential chain (EKS Pod Identity, IRSA, instance role). + AUTH_TYPE_AMBIENT = 2; + } + + // How to authenticate. AUTH_TYPE_UNSPECIFIED is treated as AUTH_TYPE_CREDENTIALS. + AuthType auth_type = 3 [(buf.validate.field).enum.defined_only = true]; + message Creds { string access_key = 1 [(buf.validate.field).string.min_len = 1]; string secret_key = 2 [(buf.validate.field).string.min_len = 1]; diff --git a/pkg/credentials/aws/secretmanager.go b/pkg/credentials/aws/secretmanager.go index 6e0f6f6a4..c4cc1c2a6 100644 --- a/pkg/credentials/aws/secretmanager.go +++ b/pkg/credentials/aws/secretmanager.go @@ -24,6 +24,7 @@ import ( "strings" "github.com/aws/aws-sdk-go-v2/aws" + awsconfig "github.com/aws/aws-sdk-go-v2/config" awscreds "github.com/aws/aws-sdk-go-v2/credentials" "github.com/aws/aws-sdk-go-v2/service/secretsmanager" smtypes "github.com/aws/aws-sdk-go-v2/service/secretsmanager/types" @@ -49,15 +50,41 @@ type Manager struct { logger *log.Helper } +// AuthType selects how the manager authenticates to AWS. The zero value is AuthTypeCredentials, so a configuration +// that does not choose keeps using static keys. +type AuthType int + +const ( + // AuthTypeCredentials uses the static AccessKey and SecretKey. + AuthTypeCredentials AuthType = iota + // AuthTypeAmbient uses the AWS SDK default credential chain (EKS Pod Identity, IRSA, instance role). + AuthTypeAmbient +) + type NewManagerOpts struct { Region, AccessKey, SecretKey, SecretPrefix string + AuthType AuthType Logger log.Logger Role credentials.Role } func NewManager(opts *NewManagerOpts) (*Manager, error) { - if opts.Region == "" || opts.AccessKey == "" || opts.SecretKey == "" { - return nil, errors.New("region, accessKey and the secretKey are required") + if opts.Region == "" { + return nil, errors.New("region is required") + } + // The operator chooses ambient authentication explicitly, so forgotten keys fail here instead of silently + // falling through to whatever the default chain finds (for example the node's instance role). + switch opts.AuthType { + case AuthTypeCredentials: + if opts.AccessKey == "" || opts.SecretKey == "" { + return nil, errors.New("accessKey and secretKey are required for the credentials auth type") + } + case AuthTypeAmbient: + if opts.AccessKey != "" || opts.SecretKey != "" { + return nil, errors.New("accessKey and secretKey must not be set for the ambient auth type") + } + default: + return nil, fmt.Errorf("unknown auth type %d", opts.AuthType) } l := opts.Logger @@ -66,21 +93,33 @@ func NewManager(opts *NewManagerOpts) (*Manager, error) { } logger := servicelogger.ScopedHelper(l, "credentials/aws-secrets-manager") - logger.Infow("msg", "configuring secrets-manager", "region", opts.Region, "role", opts.Role, "prefix", opts.SecretPrefix) + logger.Infow("msg", "configuring secrets-manager", "region", opts.Region, "role", opts.Role, "prefix", opts.SecretPrefix, "ambient", opts.AuthType == AuthTypeAmbient) - // Using AWS config directly instead of using config.LoadDefaultConfig - // to avoid the default credential chain and use only the static credentials - config := aws.Config{ - Region: opts.Region, - Credentials: awscreds.NewStaticCredentialsProvider(opts.AccessKey, opts.SecretKey, ""), + cfg, err := loadConfig(opts) + if err != nil { + return nil, fmt.Errorf("loading AWS configuration: %w", err) } return &Manager{ - client: secretsmanager.NewFromConfig(config), + client: secretsmanager.NewFromConfig(cfg), secretPrefix: opts.SecretPrefix, logger: logger, }, nil } +// loadConfig uses only the static keys for AuthTypeCredentials, never falling through to ambient credentials. For +// AuthTypeAmbient it resolves credentials through the SDK's default chain, so no long-lived key has to be stored for +// the control plane or CAS. +func loadConfig(opts *NewManagerOpts) (aws.Config, error) { + if opts.AuthType == AuthTypeCredentials { + return aws.Config{ + Region: opts.Region, + Credentials: awscreds.NewStaticCredentialsProvider(opts.AccessKey, opts.SecretKey, ""), + }, nil + } + + return awsconfig.LoadDefaultConfig(context.Background(), awsconfig.WithRegion(opts.Region)) +} + // SaveCredentials saves credentials. If opts includes WithExistingSecret, upserts at the given path. func (m *Manager) SaveCredentials(ctx context.Context, orgID string, creds any, opts ...credentials.SaveOption) (string, error) { o := credentials.ApplySaveOptions(opts...) diff --git a/pkg/credentials/aws/secretmanager_test.go b/pkg/credentials/aws/secretmanager_test.go index b5770aef4..b01d197ee 100644 --- a/pkg/credentials/aws/secretmanager_test.go +++ b/pkg/credentials/aws/secretmanager_test.go @@ -19,6 +19,8 @@ import ( "context" "encoding/json" "errors" + "os" + "path/filepath" "reflect" "testing" @@ -35,14 +37,31 @@ import ( "github.com/stretchr/testify/suite" ) +// isolateAWSEnv keeps the default credential chain off the developer's machine and the network: no ~/.aws files, no +// profile, no IMDS (the pkg/blobmanager/s3accesspoint tests' pattern). +func isolateAWSEnv(t *testing.T) { + t.Helper() + dir := t.TempDir() + for _, f := range []string{"config", "credentials"} { + require.NoError(t, os.WriteFile(filepath.Join(dir, f), nil, 0o600)) + } + t.Setenv("AWS_CONFIG_FILE", filepath.Join(dir, "config")) + t.Setenv("AWS_SHARED_CREDENTIALS_FILE", filepath.Join(dir, "credentials")) + t.Setenv("AWS_EC2_METADATA_DISABLED", "true") + t.Setenv("AWS_PROFILE", "") + require.NoError(t, os.Unsetenv("AWS_PROFILE")) +} + func (s *testSuite) TestNewManager() { assert := assert.New(s.T()) + isolateAWSEnv(s.T()) testCases := []struct { name string region string accessKey string secretKey string + authType AuthType path string expectedError bool }{ @@ -50,11 +69,16 @@ func (s *testSuite) TestNewManager() { {name: "missing accessKey", region: "r", accessKey: "", secretKey: "sk", expectedError: true}, {name: "missing secretKey", region: "r", accessKey: "ak", secretKey: "", expectedError: true}, {name: "valid manager", region: "r", accessKey: "ak", secretKey: "sk", path: "foo"}, + {name: "credentials auth type without keys", region: "r", path: "foo", expectedError: true}, + {name: "ambient auth type uses the default credential chain", region: "r", authType: AuthTypeAmbient, path: "foo"}, + {name: "ambient auth type with an access key", region: "r", accessKey: "ak", authType: AuthTypeAmbient, expectedError: true}, + {name: "ambient auth type with a secret key", region: "r", secretKey: "sk", authType: AuthTypeAmbient, expectedError: true}, + {name: "unknown auth type", region: "r", accessKey: "ak", secretKey: "sk", authType: AuthType(99), expectedError: true}, } for _, tc := range testCases { s.Run(tc.name, func() { - opts := &NewManagerOpts{Region: tc.region, AccessKey: tc.accessKey, SecretKey: tc.secretKey, SecretPrefix: tc.path} + opts := &NewManagerOpts{Region: tc.region, AccessKey: tc.accessKey, SecretKey: tc.secretKey, AuthType: tc.authType, SecretPrefix: tc.path} _, err := NewManager(opts) if tc.expectedError { assert.Error(err) @@ -65,6 +89,27 @@ func (s *testSuite) TestNewManager() { } } +// The credentials auth type uses only the static keys; the ambient one resolves credentials through the default +// chain — shown here through the environment, the chain's first source, so no cloud metadata endpoint is contacted. +func TestLoadConfig(t *testing.T) { + isolateAWSEnv(t) + t.Setenv("AWS_ACCESS_KEY_ID", "env-access-key") + t.Setenv("AWS_SECRET_ACCESS_KEY", "env-secret-key") + + static, err := loadConfig(&NewManagerOpts{Region: "r", AccessKey: "ak", SecretKey: "sk"}) + require.NoError(t, err) + got, err := static.Credentials.Retrieve(context.Background()) + require.NoError(t, err) + assert.Equal(t, "ak", got.AccessKeyID, "explicit keys win over ambient credentials") + + chain, err := loadConfig(&NewManagerOpts{Region: "r", AuthType: AuthTypeAmbient}) + require.NoError(t, err) + assert.Equal(t, "r", chain.Region) + got, err = chain.Credentials.Retrieve(context.Background()) + require.NoError(t, err) + assert.Equal(t, "env-access-key", got.AccessKeyID) +} + const orgID = "test-org" const defaultRegion = "default-region" const defaultAccessKey = "access-key-not-a-real-key" diff --git a/pkg/credentials/manager/manager.go b/pkg/credentials/manager/manager.go index c13cc2016..b0147a5b9 100644 --- a/pkg/credentials/manager/manager.go +++ b/pkg/credentials/manager/manager.go @@ -97,9 +97,15 @@ func newAWSCredentialsManager(conf *api.Credentials_AWSSecretManager, prefix str return nil, fmt.Errorf("uncompleted configuration for AWS secret manager: %w", err) } + authType := aws.AuthTypeCredentials + if conf.GetAuthType() == api.Credentials_AWSSecretManager_AUTH_TYPE_AMBIENT { + authType = aws.AuthTypeAmbient + } + opts := &aws.NewManagerOpts{ Region: conf.Region, AccessKey: conf.GetCreds().GetAccessKey(), SecretKey: conf.GetCreds().GetSecretKey(), + AuthType: authType, Logger: l, SecretPrefix: prefix, Role: r, diff --git a/pkg/credentials/manager/manager_test.go b/pkg/credentials/manager/manager_test.go index 98f524187..eb3f0eda2 100644 --- a/pkg/credentials/manager/manager_test.go +++ b/pkg/credentials/manager/manager_test.go @@ -19,6 +19,7 @@ import ( "context" "fmt" "os" + "path/filepath" "testing" "time" @@ -97,7 +98,25 @@ func (s *testSuite) TestNewAzureManagerFromConfig() { } } +// isolateAWSEnv keeps the default credential chain off the developer's machine and the network: no ~/.aws files, no +// profile, no IMDS (the same isolation as pkg/credentials/aws's tests). +func isolateAWSEnv(t *testing.T) { + t.Helper() + dir := t.TempDir() + for _, f := range []string{"config", "credentials"} { + require.NoError(t, os.WriteFile(filepath.Join(dir, f), nil, 0o600)) + } + t.Setenv("AWS_CONFIG_FILE", filepath.Join(dir, "config")) + t.Setenv("AWS_SHARED_CREDENTIALS_FILE", filepath.Join(dir, "credentials")) + t.Setenv("AWS_EC2_METADATA_DISABLED", "true") + // Setenv first so the variable is restored afterwards; an empty value would still count as set. + t.Setenv("AWS_PROFILE", "") + require.NoError(t, os.Unsetenv("AWS_PROFILE")) +} + func (s *testSuite) TestNewFromConfig() { + // The ambient AWS case loads the default credential chain. + isolateAWSEnv(s.T()) testCases := []struct { name string conf *v1.Credentials @@ -130,6 +149,7 @@ func (s *testSuite) TestNewFromConfig() { wantErr: true, }, { + // Forgotten keys must fail, not fall through to ambient credentials. name: "[AWS] missing credentials", conf: &v1.Credentials{ Backend: &v1.Credentials_AwsSecretManager{ @@ -140,6 +160,57 @@ func (s *testSuite) TestNewFromConfig() { }, wantErr: true, }, + { + name: "[AWS] explicit credentials auth type", + conf: &v1.Credentials{ + Backend: &v1.Credentials_AwsSecretManager{ + AwsSecretManager: &v1.Credentials_AWSSecretManager{ + Region: "us-east-1", + AuthType: v1.Credentials_AWSSecretManager_AUTH_TYPE_CREDENTIALS, + Creds: &v1.Credentials_AWSSecretManager_Creds{AccessKey: "ak", SecretKey: "sk"}, + }, + }, + }, + wantErr: false, + }, + { + name: "[AWS] ambient auth type", + conf: &v1.Credentials{ + Backend: &v1.Credentials_AwsSecretManager{ + AwsSecretManager: &v1.Credentials_AWSSecretManager{ + Region: "us-east-1", + AuthType: v1.Credentials_AWSSecretManager_AUTH_TYPE_AMBIENT, + }, + }, + }, + wantErr: false, + }, + { + name: "[AWS] ambient auth type with static credentials", + conf: &v1.Credentials{ + Backend: &v1.Credentials_AwsSecretManager{ + AwsSecretManager: &v1.Credentials_AWSSecretManager{ + Region: "us-east-1", + AuthType: v1.Credentials_AWSSecretManager_AUTH_TYPE_AMBIENT, + Creds: &v1.Credentials_AWSSecretManager_Creds{AccessKey: "ak", SecretKey: "sk"}, + }, + }, + }, + wantErr: true, + }, + { + name: "[AWS] undefined auth type", + conf: &v1.Credentials{ + Backend: &v1.Credentials_AwsSecretManager{ + AwsSecretManager: &v1.Credentials_AWSSecretManager{ + Region: "us-east-1", + AuthType: 99, + Creds: &v1.Credentials_AWSSecretManager_Creds{AccessKey: "ak", SecretKey: "sk"}, + }, + }, + }, + wantErr: true, + }, { name: "[AWS] missing AWS access key", conf: &v1.Credentials{