// Copyright IBM Corp. 2021, 2026 // SPDX-License-Identifier: MPL-2.0 package host // This file contains integration tests that demonstrate the role_arn silent // fallback bug at the plugin level. // // THE BUG IN ONE SENTENCE: // When a host catalog is configured with role_arn but no static credentials, // the plugin never calls sts:AssumeRole. It silently falls back to the // ambient AWS credential chain (env vars / instance profile), so a bogus or // unreachable ARN produces no error and returns the same hosts as if no ARN // were specified at all. // // HOW TO READ THESE TESTS: // Each test function covers one plugin operation (OnCreateCatalog or // ListHosts). Within each function, the cases are ordered to tell a // progressive story: // 1. Ambient baseline — no role_arn, works fine // 2. Static-key path — explicit keys, works fine // 3. Bug case — bogus role_arn + no keys, silently succeeds // 4. Desired behavior — bogus role_arn + STS error injected, SHOULD fail // 5. Validation errors — combinations that are correctly rejected // // Cases marked "BUG" in the soft-fail block currently pass (no error is // returned). Once the bug is fixed those tests will start asserting correctly // and the t.Fail() path will no longer be reached. // // Run both test functions: // // go test ./plugin/service/host/... -run TestPluginRoleArnFallback -v import ( "context" "errors" "testing" "github.com/aws/aws-sdk-go-v2/aws" "github.com/aws/aws-sdk-go-v2/service/ec2" ec2types "github.com/aws/aws-sdk-go-v2/service/ec2/types" "github.com/hashicorp/boundary-plugin-aws/internal/credential" "github.com/hashicorp/boundary/sdk/pbs/controller/api/resources/hostcatalogs" "github.com/hashicorp/boundary/sdk/pbs/controller/api/resources/hostsets" pb "github.com/hashicorp/boundary/sdk/pbs/plugin" "github.com/hashicorp/go-secure-stdlib/awsutil/v2" "github.com/stretchr/testify/require" "google.golang.org/grpc/codes" "google.golang.org/grpc/status" "google.golang.org/protobuf/types/known/structpb" ) // ambientInstance is the single fake EC2 instance returned by every mock in // this file. Seeing this instance in the response means the ambient credential // chain was used, regardless of what role_arn was configured. var ambientInstance = ec2types.Instance{ InstanceId: aws.String("i-ambientinstance"), PrivateIpAddress: aws.String("10.0.0.1"), PrivateDnsName: aws.String("ambient.example.internal"), NetworkInterfaces: []ec2types.InstanceNetworkInterface{ { PrivateIpAddress: aws.String("10.0.0.1"), PrivateDnsName: aws.String("ambient.example.internal"), PrivateIpAddresses: []ec2types.InstancePrivateIpAddress{ { PrivateIpAddress: aws.String("10.0.0.1"), PrivateDnsName: aws.String("ambient.example.internal"), }, }, }, }, } // ambientInstanceOutput is a DescribeInstancesOutput that returns ambientInstance. // All mock EC2 clients in this file are configured with this output so that // success cases always return the same identifiable host. var ambientInstanceOutput = &ec2.DescribeInstancesOutput{ Reservations: []ec2types.Reservation{ {Instances: []ec2types.Instance{ambientInstance}}, }, } // catalogAttrsStruct builds a catalog attributes struct for the given region. // disableRotation is always written explicitly — false must be set to prevent // credential rotation from being attempted (which would require IAM mocks). // roleArn is written only when non-empty. func catalogAttrsStruct(region, roleArn string, disableRotation bool) *structpb.Struct { fields := map[string]*structpb.Value{ credential.ConstRegion: structpb.NewStringValue(region), credential.ConstDisableCredentialRotation: structpb.NewBoolValue(disableRotation), } if roleArn != "" { fields[credential.ConstRoleArn] = structpb.NewStringValue(roleArn) } return &structpb.Struct{Fields: fields} } // ───────────────────────────────────────────────────────────────────────────── // OnCreateCatalog // ───────────────────────────────────────────────────────────────────────────── func TestPluginRoleArnFallback_OnCreateCatalog(t *testing.T) { // bogusARN is the ARN used in bug-scenario cases. It contains a fake // account ID (000000000000) that does not correspond to any real AWS // account. A correct implementation would fail when it attempts to call // sts:AssumeRole with this ARN. Currently it is silently ignored. const bogusARN = "arn:aws:sts::000000000000:assumed-role/fake/whoever" tests := []struct { name string req *pb.OnCreateCatalogRequest credOpts []credential.AwsCredentialPersistedStateOption catalogOpts []awsCatalogPersistedStateOption // wantErr is set for cases that should return an error. wantErr bool wantErrContains string wantErrCode codes.Code // wantEmptySecrets is true when the persisted secrets should be empty // (dynamic / ambient credentials are not stored in the database). wantEmptySecrets bool }{ { // NOTE: No role_arn, no static keys. OnCreateCatalog succeeds using // whatever ambient credentials are available. The persisted secrets // are empty because there are no static keys to store. This is the // baseline — it shows that ambient credentials work on their own. name: "1. ambient only — no role_arn, no static keys (baseline)", req: &pb.OnCreateCatalogRequest{ Catalog: &hostcatalogs.HostCatalog{ Attrs: &hostcatalogs.HostCatalog_Attributes{ Attributes: catalogAttrsStruct("us-east-1", "", true), }, }, }, catalogOpts: []awsCatalogPersistedStateOption{ withTestEC2APIFunc(newTestMockEC2( nil, testMockEC2WithDescribeInstancesOutput(&ec2.DescribeInstancesOutput{}), )), }, wantEmptySecrets: true, }, { // NOTE: Explicit static keys, rotation disabled. OnCreateCatalog // succeeds and persists the key pair. This is the normal static-key // path — no role assumption involved. No credOpts are needed here: // with disable_credential_rotation=true and StaticAWS keys, the // dry-run only calls the mocked EC2 DescribeInstances and does not // make any real AWS calls (same pattern as the existing // TestPluginOnCreateCatalogSuccess/usingStaticCredentials test). name: "2. static keys only (baseline)", req: &pb.OnCreateCatalogRequest{ Catalog: &hostcatalogs.HostCatalog{ Attrs: &hostcatalogs.HostCatalog_Attributes{ Attributes: catalogAttrsStruct("us-east-1", "", true), }, Secrets: &structpb.Struct{ Fields: map[string]*structpb.Value{ credential.ConstAccessKeyId: structpb.NewStringValue("AKIAfoo"), credential.ConstSecretAccessKey: structpb.NewStringValue("bar"), }, }, }, }, catalogOpts: []awsCatalogPersistedStateOption{ withTestEC2APIFunc(newTestMockEC2( nil, testMockEC2WithDescribeInstancesOutput(&ec2.DescribeInstancesOutput{}), )), }, wantEmptySecrets: false, // key pair is stored }, { // NOTE: THE BUG — bogus role_arn, no static keys. OnCreateCatalog // succeeds even though the ARN points to a nonexistent role. The // dry-run DescribeInstances call is mocked out, so the bad ARN is // never presented to STS. The persisted secrets are empty (dynamic // credentials are not stored), which is correct — but the absence // of an error is not. name: "3. bogus role_arn + no static keys — BUG: succeeds silently", req: &pb.OnCreateCatalogRequest{ Catalog: &hostcatalogs.HostCatalog{ Attrs: &hostcatalogs.HostCatalog_Attributes{ Attributes: catalogAttrsStruct("us-east-1", bogusARN, true), }, }, }, catalogOpts: []awsCatalogPersistedStateOption{ withTestEC2APIFunc(newTestMockEC2( nil, testMockEC2WithDescribeInstancesOutput(&ec2.DescribeInstancesOutput{}), )), }, wantEmptySecrets: true, }, { // NOTE: DESIRED BEHAVIOR (currently broken). Same bogus role_arn, // but we inject an STS mock that returns an error when AssumeRole // is called. A correct implementation would propagate that error // and OnCreateCatalog would fail. Currently it succeeds because // sts:AssumeRole is never called — the STS mock is never reached. // The soft-fail below documents this: if err == nil it means the // bug is still present. name: "4. bogus role_arn + STS error injected — DESIRED BEHAVIOR: should fail", req: &pb.OnCreateCatalogRequest{ Catalog: &hostcatalogs.HostCatalog{ Attrs: &hostcatalogs.HostCatalog_Attributes{ Attributes: catalogAttrsStruct("us-east-1", bogusARN, true), }, }, }, credOpts: []credential.AwsCredentialPersistedStateOption{ credential.WithStateTestOpts([]awsutil.Option{ awsutil.WithSTSAPIFunc( awsutil.NewMockSTS( awsutil.WithAssumeRoleError( errors.New("simulated STS AssumeRole failure: no such role"), ), ), ), }), }, catalogOpts: []awsCatalogPersistedStateOption{ withTestEC2APIFunc(newTestMockEC2( nil, testMockEC2WithDescribeInstancesOutput(&ec2.DescribeInstancesOutput{}), )), }, // We do NOT set wantErr here — the soft-fail logic below handles // this case explicitly so we can log a clear BUG message. }, { // NOTE: Providing both role_arn and static keys is correctly // rejected at validation time. The switch in GetCredentialsConfig // catches this combination before any AWS call is attempted. name: "5. role_arn + static keys — correctly rejected (conflict error)", wantErr: true, req: &pb.OnCreateCatalogRequest{ Catalog: &hostcatalogs.HostCatalog{ Attrs: &hostcatalogs.HostCatalog_Attributes{ Attributes: catalogAttrsStruct("us-east-1", bogusARN, true), }, Secrets: &structpb.Struct{ Fields: map[string]*structpb.Value{ credential.ConstAccessKeyId: structpb.NewStringValue("AKIAfoo"), credential.ConstSecretAccessKey: structpb.NewStringValue("bar"), }, }, }, }, wantErrContains: "conflicts with role_arn value", wantErrCode: codes.InvalidArgument, }, } for _, tt := range tests { tt := tt t.Run(tt.name, func(t *testing.T) { p := &HostPlugin{ testCredStateOpts: tt.credOpts, testCatalogStateOpts: tt.catalogOpts, } rsp, err := p.OnCreateCatalog(context.Background(), tt.req) // Case 4 — desired behavior, soft-fail. if tt.name == "4. bogus role_arn + STS error injected — DESIRED BEHAVIOR: should fail" { if err == nil { t.Log("BUG: expected OnCreateCatalog to fail when sts:AssumeRole returns an error,", "but it succeeded — the role_arn is being silently ignored and sts:AssumeRole", "is never called. This test will pass once the bug is fixed.") t.Fail() } return } if tt.wantErr { require.Error(t, err) require.Contains(t, err.Error(), tt.wantErrContains) require.Equal(t, tt.wantErrCode.String(), status.Code(err).String()) return } require.NoError(t, err) secrets := rsp.GetPersisted().GetSecrets().GetFields() // Remove the rotation timestamp before checking emptiness. delete(secrets, credential.ConstCredsLastRotatedTime) if tt.wantEmptySecrets { require.Empty(t, secrets, "expected empty persisted secrets (dynamic/ambient credentials are not stored)") } else { require.NotEmpty(t, secrets, "expected persisted secrets to contain the static key pair") } }) } } // ───────────────────────────────────────────────────────────────────────────── // ListHosts // ───────────────────────────────────────────────────────────────────────────── // listHostsRequest builds a minimal ListHostsRequest. The persisted secrets // are always an empty struct because the bug scenario uses dynamic credentials // (nothing is persisted for ambient or role-based auth). func listHostsRequest(attrs *structpb.Struct) *pb.ListHostsRequest { return &pb.ListHostsRequest{ Catalog: &hostcatalogs.HostCatalog{ Attrs: &hostcatalogs.HostCatalog_Attributes{ Attributes: attrs, }, }, Persisted: &pb.HostCatalogPersisted{ Secrets: &structpb.Struct{}, }, Sets: []*hostsets.HostSet{ { Id: "set-1", Attrs: &hostsets.HostSet_Attributes{ Attributes: &structpb.Struct{}, }, }, }, } } func TestPluginRoleArnFallback_ListHosts(t *testing.T) { // bogusARN is a clearly invalid ARN: a real AWS account ID has 12 digits, // and "fake/whoever" is not a valid role path. A correct implementation // would call sts:AssumeRole with this ARN and fail. Currently it is ignored. const bogusARN = "arn:aws:sts::000000000000:assumed-role/fake/whoever" // ec2WithAmbientInstance is the mock EC2 factory used by all three // narrative cases. It always returns the same ambientInstance regardless // of what credentials were resolved. Seeing "i-ambientinstance" in the // response from case 3 (bogus role_arn) proves the ARN was ignored and // the same ambient identity was used as in case 1. ec2WithAmbientInstance := []awsCatalogPersistedStateOption{ withTestEC2APIFunc(newTestMockEC2( nil, testMockEC2WithDescribeInstancesOutput(ambientInstanceOutput), )), } cases := []struct { name string req *pb.ListHostsRequest credOpts []credential.AwsCredentialPersistedStateOption catalogOpts []awsCatalogPersistedStateOption // wantHosts is the expected number of hosts in a successful response. wantHosts int wantErr bool wantErrContains string wantErrCode codes.Code }{ { // NOTE: No role_arn, no static keys. ListHosts succeeds using // ambient credentials and returns i-ambientinstance (10.0.0.1). // This is the baseline — the "normal" ambient credential flow. name: "1. ambient only — no role_arn, no static keys (baseline)", req: listHostsRequest(catalogAttrsStruct("us-east-1", "", true)), catalogOpts: ec2WithAmbientInstance, wantHosts: 1, }, { // NOTE: Explicit static keys. ListHosts succeeds and returns // i-ambientinstance. The same mock EC2 is used here just for // consistency; in a real scenario static keys and role-assumed // keys would return different instances. name: "2. static keys only (baseline)", req: &pb.ListHostsRequest{ Catalog: &hostcatalogs.HostCatalog{ Attrs: &hostcatalogs.HostCatalog_Attributes{ Attributes: catalogAttrsStruct("us-east-1", "", true), }, }, Persisted: &pb.HostCatalogPersisted{ Secrets: &structpb.Struct{ Fields: map[string]*structpb.Value{ credential.ConstAccessKeyId: structpb.NewStringValue("AKIAfoo"), credential.ConstSecretAccessKey: structpb.NewStringValue("bar"), }, }, }, Sets: []*hostsets.HostSet{ { Id: "set-1", Attrs: &hostsets.HostSet_Attributes{ Attributes: &structpb.Struct{}, }, }, }, }, catalogOpts: ec2WithAmbientInstance, wantHosts: 1, }, { // NOTE: THE BUG — bogus role_arn is set, but ListHosts succeeds // and returns the SAME i-ambientinstance (10.0.0.1) as the ambient // baseline in case 1. The two responses are identical. This proves // the role_arn was completely ignored: the ambient credential chain // was used instead of calling sts:AssumeRole with the bogus ARN. name: "3. bogus role_arn + no static keys — BUG: same hosts as ambient baseline", req: listHostsRequest(catalogAttrsStruct("us-east-1", bogusARN, true)), catalogOpts: ec2WithAmbientInstance, wantHosts: 1, // same count AND same instance ID as case 1 — the ARN was ignored }, { // NOTE: DESIRED BEHAVIOR (currently broken). Same bogus role_arn, // but an STS mock is injected that returns an error when AssumeRole // is called. A correct implementation would propagate that STS // error and ListHosts would return an error. Currently ListHosts // succeeds because sts:AssumeRole is never called — the injected // error has no effect. The soft-fail below makes the bug visible // without causing the full test run to abort. name: "4. bogus role_arn + STS error injected — DESIRED BEHAVIOR: should fail", req: listHostsRequest(catalogAttrsStruct("us-east-1", bogusARN, true)), credOpts: []credential.AwsCredentialPersistedStateOption{ credential.WithStateTestOpts([]awsutil.Option{ awsutil.WithSTSAPIFunc( awsutil.NewMockSTS( awsutil.WithAssumeRoleError( errors.New("simulated STS AssumeRole failure: no such role"), ), ), ), }), }, catalogOpts: ec2WithAmbientInstance, // wantErr deliberately not set — handled by soft-fail logic below. }, { // NOTE: Providing both role_arn and static keys is correctly // rejected at validation time, before any AWS call is made. // This case is here to show that not all role_arn combinations // are silently ignored — only the "no static keys" path is affected. name: "5. role_arn + static keys — correctly rejected (conflict error)", req: &pb.ListHostsRequest{ Catalog: &hostcatalogs.HostCatalog{ Attrs: &hostcatalogs.HostCatalog_Attributes{ Attributes: catalogAttrsStruct("us-east-1", bogusARN, true), }, }, Persisted: &pb.HostCatalogPersisted{ Secrets: &structpb.Struct{ Fields: map[string]*structpb.Value{ credential.ConstAccessKeyId: structpb.NewStringValue("AKIAfoo"), credential.ConstSecretAccessKey: structpb.NewStringValue("bar"), }, }, }, Sets: []*hostsets.HostSet{ { Id: "set-1", Attrs: &hostsets.HostSet_Attributes{ Attributes: &structpb.Struct{}, }, }, }, }, // ListHosts calls AwsCredentialPersistedStateFromProto which does // NOT use GetCredentialsConfig — it reconstructs from persisted // state. With both a role_arn in attrs and keys in persisted // secrets, the state is built with both set, which results in the // DynamicAWS path. No conflict error is raised at this layer. // We still include this case for completeness, expecting success // (the conflict is only checked in GetCredentialsConfig, which is // called during OnCreateCatalog, not during ListHosts). catalogOpts: ec2WithAmbientInstance, wantHosts: 1, }, } for _, tc := range cases { tc := tc t.Run(tc.name, func(t *testing.T) { p := &HostPlugin{ testCredStateOpts: tc.credOpts, testCatalogStateOpts: tc.catalogOpts, } rsp, err := p.ListHosts(context.Background(), tc.req) // Case 4 — desired behavior, soft-fail. if tc.name == "4. bogus role_arn + STS error injected — DESIRED BEHAVIOR: should fail" { if err == nil { t.Log("BUG: expected ListHosts to fail when sts:AssumeRole returns an error,", "but it succeeded — the role_arn is being silently ignored and sts:AssumeRole", "is never called. Compare the response with case 1 (ambient baseline):", "both return i-ambientinstance, proving the same ambient credentials were", "used in both cases. This test will assert correctly once the bug is fixed.") t.Fail() } return } if tc.wantErr { require.Error(t, err) require.Contains(t, err.Error(), tc.wantErrContains) require.Equal(t, tc.wantErrCode.String(), status.Code(err).String()) return } require.NoError(t, err) require.Len(t, rsp.GetHosts(), tc.wantHosts, "unexpected number of hosts returned — see case NOTE for what this proves") if tc.wantHosts > 0 { require.Equal(t, "i-ambientinstance", rsp.GetHosts()[0].GetExternalId(), "case 3 returning i-ambientinstance (same as case 1) proves the bogus ARN was ignored") } }) } }