mirror of
https://github.com/mulgadc/spinifex.git
synced 2026-10-21 11:46:37 +00:00
feat: implement sts:GetAccessKeyInfo (#1023)
This commit is contained in:
@@ -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% |
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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"))
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user