feat: implement sts:GetAccessKeyInfo (#1023)

This commit is contained in:
Julian Sommer
2026-09-14 15:35:29 +10:00
committed by GitHub
parent d5a648bded
commit 6f44883e99
7 changed files with 289 additions and 4 deletions
+1 -1
View File
@@ -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% |
+2 -2
View File
@@ -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 |
+4 -1
View File
@@ -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{
+13
View File
@@ -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)
}
@@ -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
}
@@ -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"))
}
+5
View File
@@ -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)