diff --git a/docs/coverage/README.md b/docs/coverage/README.md index 607fb0e9e..7e1b9be1d 100644 --- a/docs/coverage/README.md +++ b/docs/coverage/README.md @@ -29,4 +29,4 @@ Spinifex serves the AWS APIs below. Every page counts the operations in the pinn | [ELBv2](/coverage/elbv2) | 33 | 46 | 71.7% | | [IAM](/coverage/iam) | 75 | 159 | 47.2% | | [RDS](/coverage/rds) | 26 | 162 | 16.0% | -| [STS](/coverage/sts) | 4 | 8 | 50.0% | +| [STS](/coverage/sts) | 5 | 8 | 62.5% | diff --git a/docs/coverage/sts/README.md b/docs/coverage/sts/README.md index 287d3db76..364184c4c 100644 --- a/docs/coverage/sts/README.md +++ b/docs/coverage/sts/README.md @@ -18,7 +18,7 @@ tags: ## Overview -Spinifex implements **4 of the 8** operations (**50.0%**) in the STS `2011-06-15` API model. +Spinifex implements **5 of the 8** operations (**62.5%**) in the STS `2011-06-15` API model. ### Trust policies @@ -38,7 +38,7 @@ Several inputs the model describes are deliberately rejected rather than accepte | `AssumeRoleWithSAML` | ❌ Not implemented | | `AssumeRoleWithWebIdentity` | ✅ Implemented | | `DecodeAuthorizationMessage` | ❌ Not implemented | -| `GetAccessKeyInfo` | ❌ Not implemented | +| `GetAccessKeyInfo` | ✅ Implemented | | `GetCallerIdentity` | ✅ Implemented | | `GetFederationToken` | ❌ Not implemented | | `GetSessionToken` | ✅ Implemented | diff --git a/spinifex/gateway/sts.go b/spinifex/gateway/sts.go index 3efaacc36..66907ae04 100644 --- a/spinifex/gateway/sts.go +++ b/spinifex/gateway/sts.go @@ -64,6 +64,9 @@ var stsActions = map[string]STSHandler{ "AssumeRoleWithWebIdentity": stsHandler(func(_ stsCaller, input *sts.AssumeRoleWithWebIdentityInput, gw *GatewayConfig) (any, error) { return gateway_sts.AssumeRoleWithWebIdentity(input, gw.STSService) }), + "GetAccessKeyInfo": stsHandler(func(_ stsCaller, input *sts.GetAccessKeyInfoInput, gw *GatewayConfig) (any, error) { + return gateway_sts.GetAccessKeyInfo(input, gw.STSService) + }), "GetCallerIdentity": stsHandler(func(c stsCaller, input *sts.GetCallerIdentityInput, gw *GatewayConfig) (any, error) { return gateway_sts.GetCallerIdentity(c.accountID, c.arn, c.principalType, c.identity, c.assumedRoleID, input, gw.IAMService, gw.STSService) }), @@ -79,7 +82,7 @@ var anonymousSTSActions = map[string]bool{ } // stsPolicyGatedActions lists the actions requiring a pass on the caller's -// identity policy. GetCallerIdentity and GetSessionToken are authentication +// identity policy. GetCallerIdentity, GetSessionToken and GetAccessKeyInfo are // operations AWS requires no permission for, and AssumeRoleWithWebIdentity // arrives with no identity to evaluate. var stsPolicyGatedActions = map[string]bool{ diff --git a/spinifex/gateway/sts/GetAccessKeyInfo.go b/spinifex/gateway/sts/GetAccessKeyInfo.go new file mode 100644 index 000000000..ca7b5745d --- /dev/null +++ b/spinifex/gateway/sts/GetAccessKeyInfo.go @@ -0,0 +1,13 @@ +package gateway_sts + +import ( + "github.com/aws/aws-sdk-go/service/sts" + handlers_sts "github.com/mulgadc/spinifex/spinifex/handlers/sts" +) + +// GetAccessKeyInfo delegates to the STSService; not gated by caller IAM policy, +// as AWS requires no permission for this call. No caller fields are passed: the +// answer is not scoped to the caller, so a key owned by another account resolves. +func GetAccessKeyInfo(input *sts.GetAccessKeyInfoInput, svc handlers_sts.STSService) (*sts.GetAccessKeyInfoOutput, error) { + return svc.GetAccessKeyInfo(input) +} diff --git a/spinifex/handlers/sts/get_access_key_info.go b/spinifex/handlers/sts/get_access_key_info.go new file mode 100644 index 000000000..57eb192bd --- /dev/null +++ b/spinifex/handlers/sts/get_access_key_info.go @@ -0,0 +1,105 @@ +package handlers_sts + +import ( + "errors" + "log/slog" + + "github.com/aws/aws-sdk-go/aws" + "github.com/aws/aws-sdk-go/service/sts" + "github.com/mulgadc/spinifex/spinifex/awserrors" +) + +const ( + // Bounds from the STS 2011-06-15 model's AccessKeyIdType. A value outside + // them cannot name a stored key, so it is refused before the KV read. + minAccessKeyIDLength = 16 + maxAccessKeyIDLength = 128 +) + +// GetAccessKeyInfo resolves an access key ID to the account that owns it. +// AWS requires no permission for this call and does not scope it to the caller, +// so no caller identity is threaded in: a key owned by another account resolves +// normally. Long-lived (AKIA) keys resolve from the IAM access-key bucket and +// session (ASIA) keys from the session-credential bucket. +func (s *STSServiceImpl) GetAccessKeyInfo(input *sts.GetAccessKeyInfoInput) (*sts.GetAccessKeyInfoOutput, error) { + if input == nil { + return nil, errors.New(awserrors.ErrorMissingParameter) + } + accessKeyID := aws.StringValue(input.AccessKeyId) + if accessKeyID == "" { + return nil, errors.New(awserrors.ErrorMissingParameter) + } + if !isAccessKeyIDShaped(accessKeyID) { + return nil, errors.New(awserrors.ErrorValidationError) + } + + accountID, err := s.resolveAccessKeyAccount(accessKeyID) + if err != nil { + return nil, err + } + if accountID == "" { + // AWS reports an unresolvable key ID as an invalid token. A success with a + // blank Account would read to a caller as a mismatch rather than a failure. + slog.Debug("GetAccessKeyInfo: access key ID resolved to no account", "akid", accessKeyID) + return nil, errors.New(awserrors.ErrorInvalidClientTokenId) + } + + return &sts.GetAccessKeyInfoOutput{Account: aws.String(accountID)}, nil +} + +// resolveAccessKeyAccount returns the owning account of a long-lived or session +// access key ID, or empty when neither store holds it. A dependency fault is +// returned as an error so an outage cannot masquerade as a nonexistent key. +func (s *STSServiceImpl) resolveAccessKeyAccount(accessKeyID string) (string, error) { + ak, err := s.iamSvc.LookupAccessKey(accessKeyID) + switch { + case err == nil: + if ak == nil || ak.AccountID == "" { + slog.Error("GetAccessKeyInfo: access key record carries no account", "akid", accessKeyID) + return "", errors.New(awserrors.ErrorInternalError) + } + return ak.AccountID, nil + case isNoSuchEntity(err): + // Not a long-lived key; a session key lives in the other bucket. + default: + slog.Error("GetAccessKeyInfo: access key lookup failed", "akid", accessKeyID, "err", err) + return "", errors.New(awserrors.ErrorInternalError) + } + + cred, err := s.LookupSessionCredential(accessKeyID) + if err != nil { + slog.Error("GetAccessKeyInfo: session credential lookup failed", "akid", accessKeyID, "err", err) + return "", errors.New(awserrors.ErrorInternalError) + } + if cred == nil { + return "", nil + } + if cred.AccountID == "" { + slog.Error("GetAccessKeyInfo: session credential carries no account", "akid", accessKeyID) + return "", errors.New(awserrors.ErrorInternalError) + } + return cred.AccountID, nil +} + +// isNoSuchEntity reports whether err is IAM's not-found, as distinct from a +// dependency fault that happens to surface while reading the same bucket. +func isNoSuchEntity(err error) bool { + code, ok := awserrors.ResolveErrorCode(err) + return ok && code == awserrors.ErrorIAMNoSuchEntity +} + +// isAccessKeyIDShaped reports whether s satisfies the model's AccessKeyIdType: +// 16-128 characters, each an ASCII letter or digit. +func isAccessKeyIDShaped(s string) bool { + if len(s) < minAccessKeyIDLength || len(s) > maxAccessKeyIDLength { + return false + } + for _, r := range s { + switch { + case r >= 'A' && r <= 'Z', r >= 'a' && r <= 'z', r >= '0' && r <= '9': + default: + return false + } + } + return true +} diff --git a/spinifex/handlers/sts/get_access_key_info_test.go b/spinifex/handlers/sts/get_access_key_info_test.go new file mode 100644 index 000000000..2252fbd08 --- /dev/null +++ b/spinifex/handlers/sts/get_access_key_info_test.go @@ -0,0 +1,159 @@ +package handlers_sts + +import ( + "errors" + "strings" + "testing" + + "github.com/aws/aws-sdk-go/aws" + "github.com/aws/aws-sdk-go/service/sts" + "github.com/mulgadc/spinifex/spinifex/awserrors" + handlers_iam "github.com/mulgadc/spinifex/spinifex/handlers/iam" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// lookupStubIAMService records every LookupAccessKey call and returns a +// canned result, so a test can assert both the outcome and whether the KV +// bucket was reached at all. +type lookupStubIAMService struct { + handlers_iam.IAMService + + calls int + key *handlers_iam.AccessKey + err error +} + +func (s *lookupStubIAMService) LookupAccessKey(string) (*handlers_iam.AccessKey, error) { + s.calls++ + return s.key, s.err +} + +func TestGetAccessKeyInfo_ResolvesOwningAccount(t *testing.T) { + svc, _ := newTestSetup(t) + akid, _ := seedAccessKey(t, svc, testCallerAccountID, "alice") + + out, err := svc.GetAccessKeyInfo(&sts.GetAccessKeyInfoInput{AccessKeyId: aws.String(akid)}) + require.NoError(t, err) + require.NotNil(t, out) + assert.Equal(t, testCallerAccountID, aws.StringValue(out.Account)) +} + +// AWS does not scope this call to the caller, so a key owned by another account +// resolves normally rather than reporting not-found. +func TestGetAccessKeyInfo_ResolvesKeyFromAnotherAccount(t *testing.T) { + svc, _ := newTestSetup(t) + const otherAccount = "111122223333" + akid, _ := seedAccessKey(t, svc, otherAccount, "bob") + + out, err := svc.GetAccessKeyInfo(&sts.GetAccessKeyInfoInput{AccessKeyId: aws.String(akid)}) + require.NoError(t, err) + assert.Equal(t, otherAccount, aws.StringValue(out.Account)) +} + +// An ASIA key lives in the session bucket, which the IAM lookup never sees. It +// must resolve through the fallback instead of coming back as unknown. +func TestGetAccessKeyInfo_ResolvesSessionCredential(t *testing.T) { + svc, _ := newTestSetup(t) + seedUser(t, svc, testCallerAccountID, "carol") + + tok, err := svc.GetSessionToken(testCallerAccountID, "carol", principalTypeUser, "AKIA0123456789ABCDEF", &sts.GetSessionTokenInput{}) + require.NoError(t, err) + sessionAKID := aws.StringValue(tok.Credentials.AccessKeyId) + require.Contains(t, sessionAKID, SessionAccessKeyIDPrefix) + + out, err := svc.GetAccessKeyInfo(&sts.GetAccessKeyInfoInput{AccessKeyId: aws.String(sessionAKID)}) + require.NoError(t, err) + assert.Equal(t, testCallerAccountID, aws.StringValue(out.Account)) +} + +func TestGetAccessKeyInfo_UnknownKeyIsInvalidClientToken(t *testing.T) { + svc, _ := newTestSetup(t) + + cases := []struct { + name string + akid string + }{ + {"unknown long-lived", "AKIANOTAREALKEY00000"}, + {"unknown session", SessionAccessKeyIDPrefix + "NOTAREALKEY00000"}, + {"unknown prefix", "AROANOTAREALKEY00000"}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + out, err := svc.GetAccessKeyInfo(&sts.GetAccessKeyInfoInput{AccessKeyId: aws.String(tc.akid)}) + require.Error(t, err) + assert.Nil(t, out) + assert.Equal(t, awserrors.ErrorInvalidClientTokenId, err.Error()) + }) + } +} + +// A value that cannot name a stored key is refused on shape, before any KV read. +func TestGetAccessKeyInfo_MalformedKeyRejectedWithoutLookup(t *testing.T) { + svc, _ := newTestSetup(t) + stub := &lookupStubIAMService{err: errors.New(awserrors.ErrorIAMNoSuchEntity)} + svc.iamSvc = stub + + cases := []struct { + name string + akid string + want string + }{ + {"empty", "", awserrors.ErrorMissingParameter}, + {"too short", "AKIA0123", awserrors.ErrorValidationError}, + {"too long", strings.Repeat("A", maxAccessKeyIDLength+1), awserrors.ErrorValidationError}, + {"punctuation", "AKIA-0123456789ABCD", awserrors.ErrorValidationError}, + {"whitespace", "AKIA 0123456789ABCD", awserrors.ErrorValidationError}, + {"arn not a key id", "arn:aws:iam::000000000000:user/alice", awserrors.ErrorValidationError}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + out, err := svc.GetAccessKeyInfo(&sts.GetAccessKeyInfoInput{AccessKeyId: aws.String(tc.akid)}) + require.Error(t, err) + assert.Nil(t, out) + assert.Equal(t, tc.want, err.Error()) + }) + } + assert.Zero(t, stub.calls, "malformed access key ID must not reach the KV bucket") +} + +func TestGetAccessKeyInfo_NilInputIsMissingParameter(t *testing.T) { + svc, _ := newTestSetup(t) + _, err := svc.GetAccessKeyInfo(nil) + require.Error(t, err) + assert.Equal(t, awserrors.ErrorMissingParameter, err.Error()) +} + +// A dependency outage must not collapse into not-found, or every key would be +// reported as nonexistent for the duration of the outage. +func TestGetAccessKeyInfo_KVFaultIsInternalError(t *testing.T) { + svc, _ := newTestSetup(t) + svc.iamSvc = &lookupStubIAMService{err: errors.New("nats: connection closed")} + + out, err := svc.GetAccessKeyInfo(&sts.GetAccessKeyInfoInput{AccessKeyId: aws.String("AKIA0123456789ABCDEF")}) + require.Error(t, err) + assert.Nil(t, out) + assert.Equal(t, awserrors.ErrorInternalError, err.Error()) +} + +// A stored record with no account is a corrupt record, not an answer: reporting +// a blank account would read to a caller as a mismatch with their own. +func TestGetAccessKeyInfo_RecordWithoutAccountIsInternalError(t *testing.T) { + svc, _ := newTestSetup(t) + svc.iamSvc = &lookupStubIAMService{key: &handlers_iam.AccessKey{AccessKeyID: "AKIA0123456789ABCDEF"}} + + _, err := svc.GetAccessKeyInfo(&sts.GetAccessKeyInfoInput{AccessKeyId: aws.String("AKIA0123456789ABCDEF")}) + require.Error(t, err) + assert.Equal(t, awserrors.ErrorInternalError, err.Error()) +} + +func TestIsAccessKeyIDShaped(t *testing.T) { + assert.True(t, isAccessKeyIDShaped("AKIA0123456789ABCDEF")) + assert.True(t, isAccessKeyIDShaped(strings.Repeat("A", minAccessKeyIDLength))) + assert.True(t, isAccessKeyIDShaped(strings.Repeat("A", maxAccessKeyIDLength))) + assert.False(t, isAccessKeyIDShaped(strings.Repeat("A", minAccessKeyIDLength-1))) + assert.False(t, isAccessKeyIDShaped(strings.Repeat("A", maxAccessKeyIDLength+1))) + assert.False(t, isAccessKeyIDShaped("AKIA_123456789ABCDE")) +} diff --git a/spinifex/handlers/sts/service.go b/spinifex/handlers/sts/service.go index a18146311..b93890d16 100644 --- a/spinifex/handlers/sts/service.go +++ b/spinifex/handlers/sts/service.go @@ -20,6 +20,11 @@ type STSService interface { // bound to the target role. Anonymous — caller identity comes from JWT claims, not SigV4. AssumeRoleWithWebIdentity(input *sts.AssumeRoleWithWebIdentityInput) (*sts.AssumeRoleWithWebIdentityOutput, error) + // GetAccessKeyInfo resolves an access key ID to the ID of the account that owns it. + // Takes no caller identity: AWS requires no permission and does not scope the + // answer to the caller. An unresolvable ID is InvalidClientTokenId, not a blank account. + GetAccessKeyInfo(input *sts.GetAccessKeyInfoInput) (*sts.GetAccessKeyInfoOutput, error) + // GetCallerIdentity returns the authenticated principal's account, ARN, and UserId. // Allowed for every authenticated caller; not gated by checkPolicy. GetCallerIdentity(callerAccountID, callerARN, callerUserID string, input *sts.GetCallerIdentityInput) (*sts.GetCallerIdentityOutput, error)