mirror of
https://github.com/mulgadc/spinifex.git
synced 2026-10-20 19:25:52 +00:00
fix: bind STS session credentials to immutable principal IDs (#1014)
This commit is contained in:
@@ -14,6 +14,7 @@ import (
|
||||
"github.com/mulgadc/bluebottle/pkg/sigv4"
|
||||
"github.com/mulgadc/spinifex/spinifex/awserrors"
|
||||
handlers_iam "github.com/mulgadc/spinifex/spinifex/handlers/iam"
|
||||
handlers_sts "github.com/mulgadc/spinifex/spinifex/handlers/sts"
|
||||
"github.com/mulgadc/spinifex/spinifex/otelsetup"
|
||||
"github.com/mulgadc/spinifex/spinifex/utils"
|
||||
)
|
||||
@@ -163,6 +164,14 @@ func (gw *GatewayConfig) SigV4AuthMiddleware() func(http.Handler) http.Handler {
|
||||
return
|
||||
}
|
||||
|
||||
// Principal state, same class as the account check above and deferred for
|
||||
// the same reason: a session name resolves to whichever principal holds it
|
||||
// now, so it has to be checked against the ID the session was minted for.
|
||||
if errCode := gw.checkSessionPrincipal(principal, sig.Credential.AccessKeyID, clientIP); errCode != "" {
|
||||
gw.writeSigV4Error(w, r, errCode)
|
||||
return
|
||||
}
|
||||
|
||||
// Resolved here, once, rather than per policy check: a fault must fail
|
||||
// the request rather than authorize it against a context missing the key.
|
||||
userID, err := gw.principalUserID(principal)
|
||||
@@ -282,6 +291,9 @@ type principalContext struct {
|
||||
// userID is aws:userid, resolved where the principal is so the value cannot
|
||||
// differ between two policy checks in the same request.
|
||||
userID string
|
||||
// sessionCred is the rehydrated STS record on the ASIA path, nil on the
|
||||
// long-lived one. It carries the continuity check past sig.Verify.
|
||||
sessionCred *handlers_sts.SessionCredential
|
||||
}
|
||||
|
||||
// resolveLongLivedAKID handles the AKIA path: IAM lookup, status check, secret decrypt.
|
||||
@@ -368,6 +380,7 @@ func (gw *GatewayConfig) resolveSessionAKID(r *http.Request, accessKeyID, client
|
||||
identity: cred.SessionName,
|
||||
accountID: cred.AccountID,
|
||||
principalType: principalTypeUser,
|
||||
sessionCred: cred,
|
||||
}, ""
|
||||
}
|
||||
|
||||
@@ -379,9 +392,51 @@ func (gw *GatewayConfig) resolveSessionAKID(r *http.Request, accessKeyID, client
|
||||
assumedRoleARN: cred.AssumedRoleARN,
|
||||
assumedRoleID: cred.AssumedRoleID,
|
||||
underlyingRoleARN: cred.UnderlyingRoleARN,
|
||||
sessionCred: cred,
|
||||
}, ""
|
||||
}
|
||||
|
||||
// checkSessionPrincipal re-verifies that a session's backing principal still
|
||||
// exists with the immutable ID the session was minted for, returning an AWS
|
||||
// error code to fail with or "" to proceed. A nil sessionCred (long-lived
|
||||
// credential) is a no-op.
|
||||
//
|
||||
// Deliberately not folded into resolveSessionAKID: that runs pre-signature
|
||||
// because it must produce the secret, whereas this reads the IAM users and
|
||||
// roles buckets. An AKID travels in cleartext, so running it there would let
|
||||
// anyone holding a copied one drive IAM reads with garbage signatures.
|
||||
func (gw *GatewayConfig) checkSessionPrincipal(principal principalContext, accessKeyID, clientIP string) string {
|
||||
if principal.sessionCred == nil {
|
||||
return ""
|
||||
}
|
||||
if gw.STSService == nil {
|
||||
slog.Error("SigV4 auth: STS service not initialized", "accessKeyID", accessKeyID)
|
||||
return awserrors.ErrorInternalError
|
||||
}
|
||||
|
||||
if _, err := gw.STSService.VerifySessionPrincipal(principal.sessionCred); err != nil {
|
||||
if !handlers_sts.IsSessionPrincipalVerdict(err) {
|
||||
slog.Error("Session principal continuity check faulted, refusing to authorize",
|
||||
"accessKeyID", accessKeyID, "err", err)
|
||||
return awserrors.ErrorInternalError
|
||||
}
|
||||
// InvalidClientTokenId, not ExpiredToken: the credential has not expired,
|
||||
// the principal behind it is no longer the one it was minted for.
|
||||
slog.Warn("Auth failure: session principal continuity check failed",
|
||||
"accessKeyID", accessKeyID, "sourceIP", clientIP,
|
||||
"identity", principal.identity, "principalType", principal.principalType,
|
||||
"reason", err)
|
||||
// Every legacy record fails at once on the deploy that ships this check, so
|
||||
// counting them would lock out a shared egress address on distinct-attempt
|
||||
// volume alone and answer "retry later" to a fleet that cannot act on it.
|
||||
if !errors.Is(err, handlers_sts.ErrSessionPrincipalLegacy) {
|
||||
gw.RateLimiter.RecordFailure(clientIP, failureFingerprint("session-principal", accessKeyID))
|
||||
}
|
||||
return awserrors.ErrorInvalidClientTokenId
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// writeSigV4Error writes an auth-failure error in the service-appropriate format.
|
||||
// Every auth rejection funnels through here, a rate-limit lockout included, so
|
||||
// this is the one place that has to record the verdict onto the request.
|
||||
|
||||
@@ -1622,6 +1622,9 @@ type mockSTSService struct {
|
||||
tokens map[string]string // AKID → plaintext wire token for HMAC equivalence
|
||||
lookupErr error
|
||||
lookups atomic.Int32 // counts LookupSessionCredential calls for negative-side-effect assertions
|
||||
|
||||
principalErr error // verdict or fault the continuity check returns
|
||||
principalChecks atomic.Int32 // counts VerifySessionPrincipal calls, to pin its ordering
|
||||
}
|
||||
|
||||
func (m *mockSTSService) LookupSessionCredential(accessKeyID string) (*handlers_sts.SessionCredential, error) {
|
||||
@@ -1652,6 +1655,36 @@ func (m *mockSTSService) VerifySessionToken(cred *handlers_sts.SessionCredential
|
||||
return want == wireToken
|
||||
}
|
||||
|
||||
// VerifySessionPrincipal defaults to "still the same principal"; principalErr
|
||||
// drives the rejection-verdict and dependency-fault branches.
|
||||
func (m *mockSTSService) VerifySessionPrincipal(cred *handlers_sts.SessionCredential) (*handlers_sts.SessionPrincipal, error) {
|
||||
m.principalChecks.Add(1)
|
||||
if m.principalErr != nil {
|
||||
return nil, m.principalErr
|
||||
}
|
||||
return stubSessionPrincipal(cred)
|
||||
}
|
||||
|
||||
// stubSessionPrincipal is the shared "principal unchanged" answer for both
|
||||
// package doubles. It derives RoleName from the underlying role ARN rather than
|
||||
// the session name, because that is what the real implementation returns: a
|
||||
// stub echoing SessionName would let a door read the wrong field and stay green.
|
||||
func stubSessionPrincipal(cred *handlers_sts.SessionCredential) (*handlers_sts.SessionPrincipal, error) {
|
||||
if cred == nil {
|
||||
return nil, errors.New("nil session credential")
|
||||
}
|
||||
roleName := cred.UnderlyingRoleARN
|
||||
if idx := strings.LastIndex(roleName, "/"); idx >= 0 {
|
||||
roleName = roleName[idx+1:]
|
||||
}
|
||||
return &handlers_sts.SessionPrincipal{
|
||||
UserID: cred.UserID,
|
||||
RoleID: cred.RoleID,
|
||||
RoleName: roleName,
|
||||
RoleARN: cred.UnderlyingRoleARN,
|
||||
}, nil
|
||||
}
|
||||
|
||||
const (
|
||||
testSessionAKID = "ASIATESTSESSIONAAAAA"
|
||||
testSessionToken = "wire-session-token-plain"
|
||||
@@ -1881,6 +1914,149 @@ func TestSigV4Auth_Session_AKIDNotInBucket(t *testing.T) {
|
||||
assert.Contains(t, string(body), "InvalidClientTokenId")
|
||||
}
|
||||
|
||||
// A session whose backing principal was deleted, or recreated under the same
|
||||
// name with a different immutable ID, must not authenticate. InvalidClientTokenId
|
||||
// rather than ExpiredToken: the credential itself has not expired.
|
||||
func TestSigV4Auth_Session_StalePrincipalRejected(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
err error
|
||||
}{
|
||||
{"gone", handlers_sts.ErrSessionPrincipalGone},
|
||||
{"replaced", handlers_sts.ErrSessionPrincipalReplaced},
|
||||
{"legacy", handlers_sts.ErrSessionPrincipalLegacy},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
handler, stsMock := setupSessionTestApp(t, time.Now().UTC().Add(time.Hour))
|
||||
stsMock.principalErr = tc.err
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
req.Host = "localhost:9999"
|
||||
signSessionRequest(t, req, nil, testSessionAKID, testSecretKey, testSessionToken)
|
||||
|
||||
resp := doRequest(handler, req)
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusForbidden, resp.StatusCode)
|
||||
assert.Contains(t, string(body), awserrors.ErrorInvalidClientTokenId)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// A fault is not a verdict: an IAM outage must surface as InternalError so the
|
||||
// client retries rather than discarding a credential that is still good.
|
||||
func TestSigV4Auth_Session_PrincipalCheckFault_IsInternalError(t *testing.T) {
|
||||
handler, stsMock := setupSessionTestApp(t, time.Now().UTC().Add(time.Hour))
|
||||
stsMock.principalErr = errors.New("jetstream unavailable")
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
req.Host = "localhost:9999"
|
||||
signSessionRequest(t, req, nil, testSessionAKID, testSecretKey, testSessionToken)
|
||||
|
||||
resp := doRequest(handler, req)
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusInternalServerError, resp.StatusCode)
|
||||
assert.Contains(t, string(body), awserrors.ErrorInternalError)
|
||||
}
|
||||
|
||||
// Ordering pin: the continuity check reads the IAM users and roles buckets, and
|
||||
// an AKID travels in the Authorization header in cleartext. It must stay behind
|
||||
// sig.Verify, so a bad signature is answered without the check running at all.
|
||||
func TestSigV4Auth_Session_StalePrincipal_BadSignatureWinsFirst(t *testing.T) {
|
||||
handler, stsMock := setupSessionTestApp(t, time.Now().UTC().Add(time.Hour))
|
||||
stsMock.principalErr = handlers_sts.ErrSessionPrincipalReplaced
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
req.Host = "localhost:9999"
|
||||
signSessionRequest(t, req, nil, testSessionAKID, "wrong-secret-key", testSessionToken)
|
||||
|
||||
resp := doRequest(handler, req)
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
|
||||
assert.Contains(t, string(body), awserrors.ErrorSignatureDoesNotMatch)
|
||||
assert.Zero(t, stsMock.principalChecks.Load(),
|
||||
"continuity check must not run before the signature verifies")
|
||||
}
|
||||
|
||||
// The other half of the ordering pin: a good signature does reach the check.
|
||||
func TestSigV4Auth_Session_ValidSignature_RunsPrincipalCheck(t *testing.T) {
|
||||
handler, stsMock := setupSessionTestApp(t, time.Now().UTC().Add(time.Hour))
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
req.Host = "localhost:9999"
|
||||
signSessionRequest(t, req, nil, testSessionAKID, testSecretKey, testSessionToken)
|
||||
|
||||
resp := doRequest(handler, req)
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
|
||||
require.Equalf(t, http.StatusOK, resp.StatusCode, "body: %s", string(body))
|
||||
assert.Equal(t, int32(1), stsMock.principalChecks.Load())
|
||||
}
|
||||
|
||||
// The check runs past sig.Verify, so everything reaching it holds a real
|
||||
// secret. Gone and Replaced still count toward the lockout: an attacker
|
||||
// working through a dump of leaked session credentials looking for one whose
|
||||
// principal survives presents a distinct AKID each time. Legacy must not, and
|
||||
// that is not symmetry for its own sake — every record predating the field
|
||||
// fails on the deploy that ships this check, so a shared egress address would
|
||||
// cross maxFailures on distinct-attempt volume alone and be told to retry
|
||||
// later by a fleet that has nothing to retry with.
|
||||
func TestSigV4Auth_SessionPrincipalVerdict_RateLimitRecording(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
err error
|
||||
recorded bool
|
||||
}{
|
||||
{"gone", handlers_sts.ErrSessionPrincipalGone, true},
|
||||
{"replaced", handlers_sts.ErrSessionPrincipalReplaced, true},
|
||||
{"legacy", handlers_sts.ErrSessionPrincipalLegacy, false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
rl := NewAuthRateLimiter()
|
||||
defer rl.Stop()
|
||||
gw := &GatewayConfig{
|
||||
DisableLogging: true,
|
||||
RateLimiter: rl,
|
||||
STSService: &mockSTSService{principalErr: tc.err},
|
||||
}
|
||||
principal := principalContext{
|
||||
identity: "test-session",
|
||||
principalType: principalTypeAssumedRole,
|
||||
sessionCred: &handlers_sts.SessionCredential{AccessKeyID: testSessionAKID},
|
||||
}
|
||||
|
||||
const ip = "10.15.8.11"
|
||||
code := gw.checkSessionPrincipal(principal, testSessionAKID, ip)
|
||||
require.Equal(t, awserrors.ErrorInvalidClientTokenId, code)
|
||||
|
||||
rl.mu.RLock()
|
||||
defer rl.mu.RUnlock()
|
||||
rec := rl.records[ip]
|
||||
if !tc.recorded {
|
||||
assert.Nil(t, rec, "a legacy verdict must not count toward the lockout")
|
||||
return
|
||||
}
|
||||
require.NotNil(t, rec)
|
||||
assert.Len(t, rec.failures, 1)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// A long-lived credential has no session record to check, so the door skips it.
|
||||
func TestSigV4Auth_LongLived_SkipsPrincipalCheck(t *testing.T) {
|
||||
handler, stsMock := setupSessionTestApp(t, time.Now().UTC().Add(time.Hour))
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
req.Host = "localhost:9999"
|
||||
signTestRequest(t, req, nil, testAccessKey, testSecretKey)
|
||||
|
||||
doRequest(handler, req)
|
||||
assert.Zero(t, stsMock.principalChecks.Load())
|
||||
}
|
||||
|
||||
func TestSigV4Auth_UnknownAKIDPrefix_NoLookup(t *testing.T) {
|
||||
handler, stsMock := setupSessionTestApp(t, time.Now().UTC().Add(time.Hour))
|
||||
|
||||
|
||||
@@ -8,10 +8,10 @@ import (
|
||||
|
||||
"github.com/aws/aws-sdk-go/aws"
|
||||
"github.com/aws/aws-sdk-go/service/iam"
|
||||
"github.com/mulgadc/bluebottle/pkg/auth"
|
||||
"github.com/mulgadc/spinifex/spinifex/awserrors"
|
||||
gateway_ecrauth "github.com/mulgadc/spinifex/spinifex/gateway/ecrauth"
|
||||
handlers_iam "github.com/mulgadc/spinifex/spinifex/handlers/iam"
|
||||
handlers_sts "github.com/mulgadc/spinifex/spinifex/handlers/sts"
|
||||
)
|
||||
|
||||
// An ECR registry token is a signed pointer to an IAM/STS record, not a
|
||||
@@ -65,7 +65,7 @@ func isECRDependencyFailure(err error) bool {
|
||||
// record is gone (invalid, 401); anything else is a dependency failure (503),
|
||||
// since it says nothing about whether the identity is still valid.
|
||||
func classifyIAMLookupErr(err error, what string) error {
|
||||
if strings.Contains(err.Error(), awserrors.ErrorIAMNoSuchEntity) {
|
||||
if awserrors.IsErrorCode(err, awserrors.ErrorIAMNoSuchEntity) {
|
||||
return ecrInvalidPrincipal("%s not found: %w", what, err)
|
||||
}
|
||||
return ecrDependencyFailure("%s lookup failed: %w", what, err)
|
||||
@@ -139,16 +139,17 @@ func (gw *GatewayConfig) resolveECRLongLivedPrincipal(claims *gateway_ecrauth.Cl
|
||||
}, nil
|
||||
}
|
||||
|
||||
// resolveECRSessionPrincipal rehydrates an ASIA (STS session) claim. A
|
||||
// GetSessionToken user session resolves like resolveECRLongLivedPrincipal's
|
||||
// user branch; an assumed-role session additionally re-resolves the
|
||||
// underlying role and rejects the session if the role was deleted and
|
||||
// recreated (its RoleID changed) even though the role name and ARN are
|
||||
// unchanged.
|
||||
// resolveECRSessionPrincipal rehydrates an ASIA (STS session) claim. Both a
|
||||
// GetSessionToken user session and an assumed-role session are re-resolved
|
||||
// against their principal's live IAM record and rejected if it was deleted, or
|
||||
// recreated under the same name with a different immutable ID.
|
||||
func (gw *GatewayConfig) resolveECRSessionPrincipal(claims *gateway_ecrauth.Claims) (principalContext, error) {
|
||||
if gw.STSService == nil {
|
||||
return principalContext{}, ecrDependencyFailure("STS service not available")
|
||||
}
|
||||
if gw.IAMService == nil {
|
||||
return principalContext{}, ecrDependencyFailure("IAM service not available")
|
||||
}
|
||||
|
||||
cred, err := gw.STSService.LookupSessionCredential(claims.AccessKeyID)
|
||||
if err != nil {
|
||||
@@ -168,23 +169,24 @@ func (gw *GatewayConfig) resolveECRSessionPrincipal(claims *gateway_ecrauth.Clai
|
||||
return principalContext{}, err
|
||||
}
|
||||
|
||||
if gw.IAMService == nil {
|
||||
return principalContext{}, ecrDependencyFailure("IAM service not available")
|
||||
// One read of the live principal, shared by both branches: it both rejects a
|
||||
// session whose principal was deleted or recreated under the same name and
|
||||
// supplies the resolved record the branches below would otherwise re-read.
|
||||
live, err := gw.STSService.VerifySessionPrincipal(cred)
|
||||
if err != nil {
|
||||
if handlers_sts.IsSessionPrincipalVerdict(err) {
|
||||
return principalContext{}, ecrInvalidPrincipal("session principal is no longer valid: %w", err)
|
||||
}
|
||||
return principalContext{}, ecrDependencyFailure("session principal check failed: %w", err)
|
||||
}
|
||||
|
||||
if cred.PrincipalType == principalTypeUser {
|
||||
// GetSessionToken: the session resolves to the same user identity as a
|
||||
// long-lived key, so it is authorized as that user.
|
||||
userOut, err := gw.IAMService.GetUser(cred.AccountID, &iam.GetUserInput{UserName: aws.String(cred.SessionName)})
|
||||
if err != nil {
|
||||
return principalContext{}, classifyIAMLookupErr(err, "user")
|
||||
}
|
||||
identity := aws.StringValue(userOut.User.UserName)
|
||||
|
||||
if claims.PrincipalType != principalTypeUser {
|
||||
return principalContext{}, ecrInvalidPrincipal("principalType claim %q does not match resolved session", claims.PrincipalType)
|
||||
}
|
||||
canonicalARN, err := buildCallerARN(cred.AccountID, identity, principalTypeUser, "")
|
||||
canonicalARN, err := buildCallerARN(cred.AccountID, cred.SessionName, principalTypeUser, "")
|
||||
if err != nil {
|
||||
return principalContext{}, ecrInvalidPrincipal("cannot build canonical ARN: %w", err)
|
||||
}
|
||||
@@ -193,10 +195,10 @@ func (gw *GatewayConfig) resolveECRSessionPrincipal(claims *gateway_ecrauth.Clai
|
||||
}
|
||||
|
||||
return principalContext{
|
||||
identity: identity,
|
||||
identity: cred.SessionName,
|
||||
accountID: cred.AccountID,
|
||||
principalType: principalTypeUser,
|
||||
userID: aws.StringValue(userOut.User.UserId),
|
||||
userID: live.UserID,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -206,27 +208,6 @@ func (gw *GatewayConfig) resolveECRSessionPrincipal(claims *gateway_ecrauth.Clai
|
||||
return principalContext{}, ecrInvalidPrincipal("principalType claim %q does not match resolved session", claims.PrincipalType)
|
||||
}
|
||||
|
||||
// Resolve by the session's underlying role, never by SessionName — the
|
||||
// caller controls RoleSessionName at AssumeRole time, so trusting it here
|
||||
// would let an attacker rename their way into another role's ARN shape.
|
||||
roleAcct, roleName, perr := auth.ParseRoleARN(cred.UnderlyingRoleARN)
|
||||
if perr != nil || roleAcct != cred.AccountID {
|
||||
return principalContext{}, ecrInvalidPrincipal("session underlying role ARN is unresolvable or cross-account: %w", perr)
|
||||
}
|
||||
roleOut, err := gw.IAMService.GetRole(cred.AccountID, &iam.GetRoleInput{RoleName: aws.String(roleName)})
|
||||
if err != nil {
|
||||
return principalContext{}, classifyIAMLookupErr(err, "role")
|
||||
}
|
||||
currentRoleARN := aws.StringValue(roleOut.Role.Arn)
|
||||
currentRoleID := aws.StringValue(roleOut.Role.RoleId)
|
||||
if currentRoleARN != cred.UnderlyingRoleARN {
|
||||
return principalContext{}, ecrInvalidPrincipal("role ARN has changed since the session was minted")
|
||||
}
|
||||
if cred.RoleID != "" && currentRoleID != cred.RoleID {
|
||||
// The role name was deleted and recreated: same ARN, different identity.
|
||||
return principalContext{}, ecrInvalidPrincipal("role was replaced since the session was minted")
|
||||
}
|
||||
|
||||
canonicalARN, err := buildCallerARN(cred.AccountID, cred.SessionName, principalTypeAssumedRole, cred.AssumedRoleARN)
|
||||
if err != nil {
|
||||
return principalContext{}, ecrInvalidPrincipal("cannot build canonical ARN: %w", err)
|
||||
|
||||
@@ -108,11 +108,17 @@ func (m *ecrMockIAMService) GetRolePolicies(accountID, roleName string) ([]handl
|
||||
}
|
||||
|
||||
// ecrMockSTSService implements handlers_sts.STSService for ECR principal
|
||||
// rehydration tests. Only LookupSessionCredential is wired.
|
||||
// rehydration tests. Only LookupSessionCredential and VerifySessionPrincipal
|
||||
// are wired.
|
||||
type ecrMockSTSService struct {
|
||||
handlers_sts.STSService
|
||||
|
||||
sessions map[string]*handlers_sts.SessionCredential
|
||||
|
||||
// principalErr is the continuity verdict (or dependency fault) the check
|
||||
// returns. Detection itself is exercised against real IAM/STS services in
|
||||
// handlers/sts; what these tests pin is how this door maps the answer.
|
||||
principalErr error
|
||||
}
|
||||
|
||||
func newECRMockSTSService() *ecrMockSTSService {
|
||||
@@ -123,6 +129,13 @@ func (m *ecrMockSTSService) LookupSessionCredential(accessKeyID string) (*handle
|
||||
return m.sessions[accessKeyID], nil
|
||||
}
|
||||
|
||||
func (m *ecrMockSTSService) VerifySessionPrincipal(cred *handlers_sts.SessionCredential) (*handlers_sts.SessionPrincipal, error) {
|
||||
if m.principalErr != nil {
|
||||
return nil, m.principalErr
|
||||
}
|
||||
return stubSessionPrincipal(cred)
|
||||
}
|
||||
|
||||
const (
|
||||
ecrPrincipalTestAccount = "000000000042"
|
||||
ecrPrincipalTestAKID = "AKIATESTLONGLIVED0001"
|
||||
@@ -287,6 +300,7 @@ func seedECRSessionUser(iamSvc *ecrMockIAMService, stsSvc *ecrMockSTSService, ac
|
||||
AccountID: accountID,
|
||||
PrincipalType: principalTypeUser,
|
||||
SessionName: userName,
|
||||
UserID: "AIDA" + strings.ToUpper(userName),
|
||||
ExpiresAt: expiresAt,
|
||||
}
|
||||
}
|
||||
@@ -310,6 +324,39 @@ func TestResolveECRPrincipal_SessionToken_User(t *testing.T) {
|
||||
principalType: principalTypeUser, userID: "AIDADEV"}, got)
|
||||
}
|
||||
|
||||
// The user branch had no continuity check at all before: a GetSessionToken
|
||||
// session survived its user being deleted and recreated under the same name.
|
||||
func TestResolveECRPrincipal_SessionToken_User_StalePrincipalRejected(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
err error
|
||||
}{
|
||||
{"user deleted", handlers_sts.ErrSessionPrincipalGone},
|
||||
{"user recreated same name", handlers_sts.ErrSessionPrincipalReplaced},
|
||||
{"legacy record with no immutable UserID", handlers_sts.ErrSessionPrincipalLegacy},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
iamSvc := newECRMockIAMService()
|
||||
stsSvc := newECRMockSTSService()
|
||||
seedECRSessionUser(iamSvc, stsSvc, ecrPrincipalTestAccount, "dev", ecrPrincipalTestASID, time.Now().Add(time.Hour))
|
||||
stsSvc.principalErr = tc.err
|
||||
gw := &GatewayConfig{IAMService: iamSvc, STSService: stsSvc}
|
||||
|
||||
claims := &gateway_ecrauth.Claims{
|
||||
AccountID: ecrPrincipalTestAccount,
|
||||
PrincipalType: principalTypeUser,
|
||||
AccessKeyID: ecrPrincipalTestASID,
|
||||
}
|
||||
claims.Subject = "arn:aws:iam::" + ecrPrincipalTestAccount + ":user/dev"
|
||||
|
||||
_, err := gw.resolveECRPrincipal(claims)
|
||||
require.Error(t, err)
|
||||
assert.False(t, isECRDependencyFailure(err))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveECRPrincipal_SessionToken_Expired(t *testing.T) {
|
||||
iamSvc := newECRMockIAMService()
|
||||
stsSvc := newECRMockSTSService()
|
||||
@@ -417,26 +464,38 @@ func TestResolveECRPrincipal_AssumedRole_Rejections(t *testing.T) {
|
||||
return c
|
||||
}
|
||||
|
||||
t.Run("role deleted", func(t *testing.T) {
|
||||
iamSvc := newECRMockIAMService()
|
||||
stsSvc := newECRMockSTSService()
|
||||
assumedARN := seedECRAssumedRole(iamSvc, stsSvc, ecrPrincipalTestAccount, "deploy", "AROATESTROLE0001", ecrPrincipalTestASID, "session-1", time.Now().Add(time.Hour))
|
||||
delete(iamSvc.roles, ecrPrincipalTestAccount+"|deploy")
|
||||
gw := &GatewayConfig{IAMService: iamSvc, STSService: stsSvc}
|
||||
_, err := gw.resolveECRPrincipal(baseClaims(assumedARN))
|
||||
require.Error(t, err)
|
||||
assert.False(t, isECRDependencyFailure(err))
|
||||
// Every continuity verdict is client-visible (401 + re-auth challenge), never
|
||||
// a 503: the identity really is invalid, and answering 503 would tell a
|
||||
// client to retry a credential that will never work again.
|
||||
t.Run("continuity verdicts are invalid-principal, not dependency failures", func(t *testing.T) {
|
||||
verdicts := map[string]error{
|
||||
"role deleted": handlers_sts.ErrSessionPrincipalGone,
|
||||
"role replaced (RoleId changed)": handlers_sts.ErrSessionPrincipalReplaced,
|
||||
"legacy record with no immutable RoleID": handlers_sts.ErrSessionPrincipalLegacy,
|
||||
}
|
||||
for name, verdict := range verdicts {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
iamSvc := newECRMockIAMService()
|
||||
stsSvc := newECRMockSTSService()
|
||||
assumedARN := seedECRAssumedRole(iamSvc, stsSvc, ecrPrincipalTestAccount, "deploy", "AROATESTROLE0001", ecrPrincipalTestASID, "session-1", time.Now().Add(time.Hour))
|
||||
stsSvc.principalErr = verdict
|
||||
gw := &GatewayConfig{IAMService: iamSvc, STSService: stsSvc}
|
||||
_, err := gw.resolveECRPrincipal(baseClaims(assumedARN))
|
||||
require.Error(t, err)
|
||||
assert.False(t, isECRDependencyFailure(err))
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("role replaced (RoleId changed, ARN unchanged)", func(t *testing.T) {
|
||||
t.Run("continuity check fault is a dependency failure", func(t *testing.T) {
|
||||
iamSvc := newECRMockIAMService()
|
||||
stsSvc := newECRMockSTSService()
|
||||
assumedARN := seedECRAssumedRole(iamSvc, stsSvc, ecrPrincipalTestAccount, "deploy", "AROATESTROLE0001", ecrPrincipalTestASID, "session-1", time.Now().Add(time.Hour))
|
||||
iamSvc.roles[ecrPrincipalTestAccount+"|deploy"].RoleId = aws.String("AROADIFFERENTROLE002")
|
||||
stsSvc.principalErr = errors.New("nats: no responders")
|
||||
gw := &GatewayConfig{IAMService: iamSvc, STSService: stsSvc}
|
||||
_, err := gw.resolveECRPrincipal(baseClaims(assumedARN))
|
||||
require.Error(t, err)
|
||||
assert.False(t, isECRDependencyFailure(err))
|
||||
assert.True(t, isECRDependencyFailure(err))
|
||||
})
|
||||
|
||||
t.Run("expired session", func(t *testing.T) {
|
||||
@@ -465,16 +524,6 @@ func TestResolveECRPrincipal_AssumedRole_Rejections(t *testing.T) {
|
||||
require.Error(t, err)
|
||||
})
|
||||
|
||||
t.Run("cross-account underlying role ARN rejected", func(t *testing.T) {
|
||||
iamSvc := newECRMockIAMService()
|
||||
stsSvc := newECRMockSTSService()
|
||||
assumedARN := seedECRAssumedRole(iamSvc, stsSvc, ecrPrincipalTestAccount, "deploy", "AROATESTROLE0001", ecrPrincipalTestASID, "session-1", time.Now().Add(time.Hour))
|
||||
stsSvc.sessions[ecrPrincipalTestASID].UnderlyingRoleARN = "arn:aws:iam::000000000099:role/deploy"
|
||||
gw := &GatewayConfig{IAMService: iamSvc, STSService: stsSvc}
|
||||
_, err := gw.resolveECRPrincipal(baseClaims(assumedARN))
|
||||
require.Error(t, err)
|
||||
})
|
||||
|
||||
t.Run("nil STS service is a dependency failure", func(t *testing.T) {
|
||||
gw := &GatewayConfig{IAMService: newECRMockIAMService()}
|
||||
_, err := gw.resolveECRPrincipal(baseClaims("arn:aws:sts::" + ecrPrincipalTestAccount + ":assumed-role/deploy/session-1"))
|
||||
|
||||
@@ -435,6 +435,11 @@ type sessionEnvelope struct {
|
||||
SessionName string
|
||||
SourceIdentity string
|
||||
|
||||
// UserID is the immutable IAM UserId; set only on a user envelope, as RoleID
|
||||
// is set only on a role one. Both are what a session is bound to, so a
|
||||
// same-name replacement of the principal cannot inherit it.
|
||||
UserID string
|
||||
|
||||
// Assumed-role-only; empty for a user (GetSessionToken) envelope.
|
||||
AssumedRoleARN string
|
||||
UnderlyingRoleARN string
|
||||
@@ -496,6 +501,7 @@ func (s *STSServiceImpl) mintSession(ctx context.Context, env sessionEnvelope, d
|
||||
RoleID: env.RoleID,
|
||||
AssumedRoleID: env.AssumedRoleID,
|
||||
SessionName: env.SessionName,
|
||||
UserID: env.UserID,
|
||||
SourceIdentity: env.SourceIdentity,
|
||||
ExpiresAt: expiresAt,
|
||||
CreatedAt: now,
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/aws/aws-sdk-go/aws"
|
||||
"github.com/aws/aws-sdk-go/service/iam"
|
||||
"github.com/aws/aws-sdk-go/service/sts"
|
||||
"github.com/mulgadc/spinifex/spinifex/awserrors"
|
||||
)
|
||||
@@ -58,7 +59,25 @@ func (s *STSServiceImpl) GetSessionToken(callerAccountID, callerUserName, caller
|
||||
duration = clampGetSessionTokenDuration(*input.DurationSeconds)
|
||||
}
|
||||
|
||||
cred, plainSecret, plainToken, err := s.mintSession(ctx, userEnvelope(callerAccountID, callerUserName), duration)
|
||||
// Resolved here rather than threaded from the gateway: the gateway's value is
|
||||
// aws:userid, which reports the account ID for root. Both mint and verify read
|
||||
// the record's own UserId, so the two ends compare like with like.
|
||||
userOut, err := s.iamSvc.GetUser(callerAccountID, &iam.GetUserInput{UserName: aws.String(callerUserName)})
|
||||
if err != nil || userOut == nil || userOut.User == nil {
|
||||
slog.Error("GetSessionToken: cannot resolve caller user record",
|
||||
"account_id", callerAccountID, "user_name", callerUserName, "err", err)
|
||||
return nil, errors.New(awserrors.ErrorInternalError)
|
||||
}
|
||||
userID := aws.StringValue(userOut.User.UserId)
|
||||
if userID == "" {
|
||||
// Same fail-loud treatment as a caller missing an account or name: a session
|
||||
// with no immutable ID cannot be verified against the live record later.
|
||||
slog.Error("GetSessionToken: caller user record carries no UserId",
|
||||
"account_id", callerAccountID, "user_name", callerUserName)
|
||||
return nil, errors.New(awserrors.ErrorInternalError)
|
||||
}
|
||||
|
||||
cred, plainSecret, plainToken, err := s.mintSession(ctx, userEnvelope(callerAccountID, callerUserName, userID), duration)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -94,10 +113,11 @@ func clampGetSessionTokenDuration(requested int64) int64 {
|
||||
|
||||
// userEnvelope is the session envelope for a GetSessionToken user session:
|
||||
// PrincipalType "user", SessionName = the IAM user name, no assumed-role fields.
|
||||
func userEnvelope(accountID, userName string) sessionEnvelope {
|
||||
func userEnvelope(accountID, userName, userID string) sessionEnvelope {
|
||||
return sessionEnvelope{
|
||||
PrincipalType: principalTypeUser,
|
||||
AccountID: accountID,
|
||||
SessionName: userName,
|
||||
UserID: userID,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ import (
|
||||
|
||||
func TestGetSessionToken_HappyPath_MintsUserSession(t *testing.T) {
|
||||
svc, _ := newTestSetup(t)
|
||||
userID := seedUser(t, svc, testCallerAccountID, testCallerUserName)
|
||||
|
||||
out, err := svc.GetSessionToken(testCallerAccountID, testCallerUserName, principalTypeUser, testCallerAccessKeyID,
|
||||
&sts.GetSessionTokenInput{})
|
||||
@@ -45,6 +46,9 @@ func TestGetSessionToken_HappyPath_MintsUserSession(t *testing.T) {
|
||||
assert.Equal(t, principalTypeUser, stored.PrincipalType)
|
||||
assert.Equal(t, testCallerUserName, stored.SessionName)
|
||||
assert.Equal(t, testCallerAccountID, stored.AccountID)
|
||||
// The name alone does not identify the principal: the immutable ID must be
|
||||
// persisted, or a same-name replacement inherits the session.
|
||||
assert.Equal(t, userID, stored.UserID)
|
||||
assert.Empty(t, stored.AssumedRoleARN)
|
||||
assert.Empty(t, stored.UnderlyingRoleARN)
|
||||
assert.Empty(t, stored.RoleID)
|
||||
@@ -66,6 +70,7 @@ func TestGetSessionToken_HappyPath_MintsUserSession(t *testing.T) {
|
||||
|
||||
func TestGetSessionToken_NilInput_DefaultsToTwelveHours(t *testing.T) {
|
||||
svc, _ := newTestSetup(t)
|
||||
seedUser(t, svc, testCallerAccountID, testCallerUserName)
|
||||
|
||||
out, err := svc.GetSessionToken(testCallerAccountID, testCallerUserName, principalTypeUser, testCallerAccessKeyID, nil)
|
||||
require.NoError(t, err)
|
||||
@@ -76,6 +81,7 @@ func TestGetSessionToken_NilInput_DefaultsToTwelveHours(t *testing.T) {
|
||||
|
||||
func TestGetSessionToken_DurationClamp(t *testing.T) {
|
||||
svc, _ := newTestSetup(t)
|
||||
seedUser(t, svc, testCallerAccountID, testCallerUserName)
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
@@ -155,6 +161,18 @@ func TestGetSessionToken_RejectsMissingUserName(t *testing.T) {
|
||||
assert.Nil(t, out)
|
||||
}
|
||||
|
||||
// A session that cannot be bound to an immutable ID is worse than no session:
|
||||
// it would be indistinguishable from a legacy record and rejected at first use.
|
||||
func TestGetSessionToken_UnresolvableCaller_FailsLoud(t *testing.T) {
|
||||
svc, _ := newTestSetup(t)
|
||||
|
||||
out, err := svc.GetSessionToken(testCallerAccountID, "no-such-user", principalTypeUser, testCallerAccessKeyID,
|
||||
&sts.GetSessionTokenInput{})
|
||||
require.Error(t, err)
|
||||
assert.Equal(t, awserrors.ErrorInternalError, err.Error())
|
||||
assert.Nil(t, out)
|
||||
}
|
||||
|
||||
// assertStoredDuration looks up the minted credential and asserts the persisted
|
||||
// lifetime (ExpiresAt - CreatedAt) equals the expected number of seconds. This
|
||||
// reads the clamp through the stored record rather than re-deriving it.
|
||||
|
||||
@@ -76,7 +76,7 @@ func (s *STSServiceImpl) VerifyPresignedGetCallerIdentity(presignedURL, expected
|
||||
return nil, errors.New(awserrors.ErrorInvalidIdentityToken)
|
||||
}
|
||||
|
||||
principal, secret, err := s.resolvePrincipalForVerify(req.Credential.AccessKeyID)
|
||||
principal, secret, sessionCred, err := s.resolvePrincipalForVerify(req.Credential.AccessKeyID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -92,10 +92,42 @@ func (s *STSServiceImpl) VerifyPresignedGetCallerIdentity(presignedURL, expected
|
||||
return nil, errors.New(awserrors.ErrorInvalidIdentityToken)
|
||||
}
|
||||
|
||||
// Deferred past req.Verify for the reason the SigV4 door defers it: the check
|
||||
// reads the IAM users and roles buckets, and an AKID travels in cleartext, so
|
||||
// running it earlier lets anyone holding a copy drive IAM reads unauthenticated.
|
||||
if err := s.checkVerifySessionPrincipal(sessionCred, req.Credential.AccessKeyID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
principal.XK8sAwsID = expectedClusterName
|
||||
return principal, nil
|
||||
}
|
||||
|
||||
// checkVerifySessionPrincipal maps a continuity verdict onto this door's
|
||||
// vocabulary. A nil cred is the long-lived path, which has no session to check.
|
||||
func (s *STSServiceImpl) checkVerifySessionPrincipal(cred *SessionCredential, accessKeyID string) error {
|
||||
if cred == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Without this the session branch performs no IAM lookup at all, so even a
|
||||
// deleted principal leaves a working path to its cluster identity mapping.
|
||||
if _, err := s.VerifySessionPrincipal(cred); err != nil {
|
||||
if IsSessionPrincipalVerdict(err) {
|
||||
slog.Warn("VerifyPresignedGetCallerIdentity: session principal continuity check failed",
|
||||
"akid", accessKeyID, "reason", err)
|
||||
return errors.New(awserrors.ErrorInvalidIdentityToken)
|
||||
}
|
||||
// The NATS responder logs every verify failure at Debug, on the assumption
|
||||
// that a rejection is the expected case. A fault is not, and would otherwise
|
||||
// take the fleet down with nothing written at production log level.
|
||||
slog.Error("VerifyPresignedGetCallerIdentity: session principal check faulted, refusing to authorize",
|
||||
"akid", accessKeyID, "err", err)
|
||||
return fmt.Errorf("verify session principal: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// mapSigv4Err maps sigv4 sentinel errors onto AWS error codes. Time-window failures
|
||||
// surface as ExpiredToken; every other structural failure is an invalid token.
|
||||
func mapSigv4Err(err error) error {
|
||||
@@ -109,47 +141,48 @@ func mapSigv4Err(err error) error {
|
||||
|
||||
// resolvePrincipalForVerify resolves an access key to its plaintext secret and a
|
||||
// PresignedCallerIdentity skeleton. Branches on AKID prefix (session vs long-lived).
|
||||
func (s *STSServiceImpl) resolvePrincipalForVerify(accessKeyID string) (*PresignedCallerIdentity, string, error) {
|
||||
// The session record comes back so the caller can check continuity past the signature.
|
||||
func (s *STSServiceImpl) resolvePrincipalForVerify(accessKeyID string) (*PresignedCallerIdentity, string, *SessionCredential, error) {
|
||||
switch {
|
||||
case strings.HasPrefix(accessKeyID, SessionAccessKeyIDPrefix):
|
||||
cred, err := s.LookupSessionCredential(accessKeyID)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
return nil, "", nil, err
|
||||
}
|
||||
if cred == nil {
|
||||
return nil, "", errors.New(awserrors.ErrorInvalidIdentityToken)
|
||||
return nil, "", nil, errors.New(awserrors.ErrorInvalidIdentityToken)
|
||||
}
|
||||
if presignedTimeNow().After(cred.ExpiresAt) {
|
||||
return nil, "", errors.New(awserrors.ErrorExpiredToken)
|
||||
return nil, "", nil, errors.New(awserrors.ErrorExpiredToken)
|
||||
}
|
||||
secret, err := handlers_iam.DecryptSecret(cred.SecretEncrypted, s.masterKey)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("decrypt session secret: %w", err)
|
||||
return nil, "", nil, fmt.Errorf("decrypt session secret: %w", err)
|
||||
}
|
||||
return &PresignedCallerIdentity{
|
||||
AccountID: cred.AccountID,
|
||||
ARN: cred.AssumedRoleARN,
|
||||
UserID: cred.AssumedRoleID,
|
||||
PrincipalType: principalTypeAssumedRolePresigned,
|
||||
}, secret, nil
|
||||
}, secret, cred, nil
|
||||
case strings.HasPrefix(accessKeyID, longLivedAccessKeyIDPrefix):
|
||||
ak, err := s.iamSvc.LookupAccessKey(accessKeyID)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), awserrors.ErrorIAMNoSuchEntity) {
|
||||
return nil, "", errors.New(awserrors.ErrorInvalidIdentityToken)
|
||||
if awserrors.IsErrorCode(err, awserrors.ErrorIAMNoSuchEntity) {
|
||||
return nil, "", nil, errors.New(awserrors.ErrorInvalidIdentityToken)
|
||||
}
|
||||
return nil, "", err
|
||||
return nil, "", nil, err
|
||||
}
|
||||
if ak.Status != handlers_iam.AccessKeyStatusActive {
|
||||
return nil, "", errors.New(awserrors.ErrorInvalidIdentityToken)
|
||||
return nil, "", nil, errors.New(awserrors.ErrorInvalidIdentityToken)
|
||||
}
|
||||
secret, err := s.iamSvc.DecryptSecret(ak.SecretAccessKey)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("decrypt IAM secret: %w", err)
|
||||
return nil, "", nil, fmt.Errorf("decrypt IAM secret: %w", err)
|
||||
}
|
||||
userOut, err := s.iamSvc.GetUser(ak.AccountID, &iam.GetUserInput{UserName: aws.String(ak.UserName)})
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
return nil, "", nil, err
|
||||
}
|
||||
userARN := aws.StringValue(userOut.User.Arn)
|
||||
userID := aws.StringValue(userOut.User.UserId)
|
||||
@@ -161,9 +194,9 @@ func (s *STSServiceImpl) resolvePrincipalForVerify(accessKeyID string) (*Presign
|
||||
ARN: userARN,
|
||||
UserID: userID,
|
||||
PrincipalType: principalTypeUserPresigned,
|
||||
}, secret, nil
|
||||
}, secret, nil, nil
|
||||
default:
|
||||
return nil, "", errors.New(awserrors.ErrorInvalidIdentityToken)
|
||||
return nil, "", nil, errors.New(awserrors.ErrorInvalidIdentityToken)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package handlers_sts
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
@@ -44,8 +45,7 @@ func presignTestURL(t *testing.T, accessKeyID, secret, clusterName string, signe
|
||||
// CreateAccessKey path (returns the plaintext once).
|
||||
func seedAccessKey(t *testing.T, svc *STSServiceImpl, accountID, userName string) (akid, secret string) {
|
||||
t.Helper()
|
||||
_, err := svc.iamSvc.CreateUser(accountID, &iam.CreateUserInput{UserName: aws.String(userName)})
|
||||
require.NoError(t, err)
|
||||
seedUser(t, svc, accountID, userName)
|
||||
out, err := svc.iamSvc.CreateAccessKey(accountID, &iam.CreateAccessKeyInput{UserName: aws.String(userName)})
|
||||
require.NoError(t, err)
|
||||
return aws.StringValue(out.AccessKey.AccessKeyId), aws.StringValue(out.AccessKey.SecretAccessKey)
|
||||
@@ -107,6 +107,74 @@ func TestVerifyPresignedGetCallerIdentity_HappyPath_SessionCred(t *testing.T) {
|
||||
assert.Equal(t, principalTypeAssumedRolePresigned, got.PrincipalType)
|
||||
}
|
||||
|
||||
// Before the continuity check the session branch performed no IAM lookup at
|
||||
// all, so a stale session left a working path to a cluster identity mapping
|
||||
// keyed on an ARN whose role no longer exists.
|
||||
func TestVerifyPresignedGetCallerIdentity_StaleSessionRejected(t *testing.T) {
|
||||
svc, _ := newTestSetup(t)
|
||||
role := createRoleInAccount(t, svc, testCallerAccountID, "irsa-stale",
|
||||
trustPolicyAllowingUser(testCallerARN()))
|
||||
|
||||
assumeOut, err := svc.AssumeRole(testCallerAccountID, testCallerARN(), testCallerUserName,
|
||||
basicAssumeRoleInput(*role.Arn, "sess-stale"))
|
||||
require.NoError(t, err)
|
||||
akid := aws.StringValue(assumeOut.Credentials.AccessKeyId)
|
||||
secret := aws.StringValue(assumeOut.Credentials.SecretAccessKey)
|
||||
|
||||
deleteRole(t, svc, testCallerAccountID, "irsa-stale")
|
||||
createRoleInAccount(t, svc, testCallerAccountID, "irsa-stale", trustPolicyAllowingWildcard())
|
||||
|
||||
signedAt := time.Now().UTC().Truncate(time.Second)
|
||||
withFrozenTime(t, signedAt)
|
||||
|
||||
const cluster = "stale-cluster"
|
||||
u := presignTestURL(t, akid, secret, cluster, signedAt, 900)
|
||||
|
||||
got, err := svc.VerifyPresignedGetCallerIdentity(u, cluster)
|
||||
require.Error(t, err)
|
||||
assert.Equal(t, awserrors.ErrorInvalidIdentityToken, err.Error())
|
||||
assert.Nil(t, got)
|
||||
}
|
||||
|
||||
// Ordering pin, the counterpart of the SigV4 door's: the continuity check reads
|
||||
// the IAM users and roles buckets, and a presigned URL carries its AKID in the
|
||||
// clear, so it must stay behind the signature. A faulting IAM backend makes the
|
||||
// check's presence observable — its error is distinct from a signature rejection.
|
||||
func TestVerifyPresignedGetCallerIdentity_BadSignature_SkipsPrincipalCheck(t *testing.T) {
|
||||
svc, _ := newTestSetup(t)
|
||||
role := createRoleInAccount(t, svc, testCallerAccountID, "irsa-order",
|
||||
trustPolicyAllowingUser(testCallerARN()))
|
||||
|
||||
assumeOut, err := svc.AssumeRole(testCallerAccountID, testCallerARN(), testCallerUserName,
|
||||
basicAssumeRoleInput(*role.Arn, "sess-order"))
|
||||
require.NoError(t, err)
|
||||
akid := aws.StringValue(assumeOut.Credentials.AccessKeyId)
|
||||
goodSecret := aws.StringValue(assumeOut.Credentials.SecretAccessKey)
|
||||
|
||||
signedAt := time.Now().UTC().Truncate(time.Second)
|
||||
withFrozenTime(t, signedAt)
|
||||
const cluster = "order-cluster"
|
||||
|
||||
boom := errors.New("jetstream unavailable")
|
||||
svc.iamSvc = faultingIAMService{err: boom}
|
||||
|
||||
// Wrong secret: the signature must be rejected without the check ever running.
|
||||
got, err := svc.VerifyPresignedGetCallerIdentity(
|
||||
presignTestURL(t, akid, "wrong-secret-key", cluster, signedAt, 900), cluster)
|
||||
require.Error(t, err)
|
||||
assert.Nil(t, got)
|
||||
assert.Equal(t, awserrors.ErrorInvalidIdentityToken, err.Error())
|
||||
assert.NotErrorIs(t, err, boom, "continuity check must not run before the signature verifies")
|
||||
|
||||
// The other half: a good signature does reach the check, and its fault is not
|
||||
// laundered into a token rejection.
|
||||
got, err = svc.VerifyPresignedGetCallerIdentity(
|
||||
presignTestURL(t, akid, goodSecret, cluster, signedAt, 900), cluster)
|
||||
require.Error(t, err)
|
||||
assert.Nil(t, got)
|
||||
assert.ErrorIs(t, err, boom)
|
||||
}
|
||||
|
||||
// ----- Cross-cluster anti-replay (Q10 mandatory) --------------------------
|
||||
|
||||
func TestVerifyPresignedGetCallerIdentity_CrossClusterReplay(t *testing.T) {
|
||||
|
||||
@@ -43,7 +43,10 @@ func ResolveRoleByARN(svc RoleGetter, roleARN string) (string, *iam.Role, error)
|
||||
case errors.Is(err, auth.ErrRoleARNMismatch):
|
||||
return "", nil, ErrRoleUnresolved
|
||||
case err != nil:
|
||||
if err.Error() == awserrors.ErrorIAMNoSuchEntity {
|
||||
// Matched the same way the user side matches it, so a wrapped NoSuchEntity
|
||||
// cannot read as a deleted role on one branch and a dependency fault on the
|
||||
// other. Both directions fail closed; only the answer's shape differs.
|
||||
if awserrors.IsErrorCode(err, awserrors.ErrorIAMNoSuchEntity) {
|
||||
return "", nil, ErrRoleUnresolved
|
||||
}
|
||||
return "", nil, err
|
||||
|
||||
@@ -40,4 +40,10 @@ type STSService interface {
|
||||
// VerifySessionToken constant-time-compares the wire token against the stored HMAC.
|
||||
// Keeping the HMAC inside STSService prevents the master key from crossing the gateway surface.
|
||||
VerifySessionToken(cred *SessionCredential, wireToken string) bool
|
||||
|
||||
// VerifySessionPrincipal re-resolves the principal a session was minted for and
|
||||
// returns its live IAM record. Fails closed with ErrSessionPrincipalGone,
|
||||
// ErrSessionPrincipalReplaced or ErrSessionPrincipalLegacy; any other error is a
|
||||
// dependency fault.
|
||||
VerifySessionPrincipal(cred *SessionCredential) (*SessionPrincipal, error)
|
||||
}
|
||||
|
||||
@@ -47,14 +47,20 @@ type SessionCredential struct {
|
||||
// for backward compatibility with in-flight records minted before this field existed.
|
||||
PrincipalType string `json:"principal_type,omitempty"`
|
||||
|
||||
AssumedRoleARN string `json:"assumed_role_arn"`
|
||||
UnderlyingRoleARN string `json:"underlying_role_arn"`
|
||||
RoleID string `json:"role_id"`
|
||||
AssumedRoleID string `json:"assumed_role_id"`
|
||||
SessionName string `json:"session_name"`
|
||||
SourceIdentity string `json:"source_identity,omitempty"`
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
AssumedRoleARN string `json:"assumed_role_arn"`
|
||||
UnderlyingRoleARN string `json:"underlying_role_arn"`
|
||||
RoleID string `json:"role_id"`
|
||||
AssumedRoleID string `json:"assumed_role_id"`
|
||||
SessionName string `json:"session_name"`
|
||||
|
||||
// UserID is the immutable IAM UserId a "user" session was minted for, the
|
||||
// counterpart of RoleID. Empty on role sessions and on records minted before
|
||||
// the field existed; VerifySessionPrincipal rejects the latter.
|
||||
UserID string `json:"user_id,omitempty"`
|
||||
|
||||
SourceIdentity string `json:"source_identity,omitempty"`
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// initSessionCredentialsBucket opens (or creates) the session-credentials KV bucket.
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
package handlers_sts
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/aws/aws-sdk-go/aws"
|
||||
"github.com/aws/aws-sdk-go/service/iam"
|
||||
|
||||
"github.com/mulgadc/spinifex/spinifex/awserrors"
|
||||
)
|
||||
|
||||
// A session credential names its principal, and a name is not an identity: an
|
||||
// administrator who deletes an IAM user or role and recreates it under the same
|
||||
// name leaves every unexpired session for the original resolving to the
|
||||
// replacement. Binding the session to the principal's immutable ID at mint and
|
||||
// re-checking it against the live record at every door that rehydrates a session
|
||||
// is what makes the two tell apart.
|
||||
var (
|
||||
// ErrSessionPrincipalGone reports that the principal no longer exists.
|
||||
ErrSessionPrincipalGone = errors.New("session principal no longer exists")
|
||||
|
||||
// ErrSessionPrincipalReplaced reports that a principal of that name exists but
|
||||
// carries a different immutable ID than the session was minted for.
|
||||
ErrSessionPrincipalReplaced = errors.New("session principal was replaced since the session was minted")
|
||||
|
||||
// ErrSessionPrincipalLegacy reports that the record holds no immutable ID to
|
||||
// check. Rejected rather than waved through: accepting it preserves exactly
|
||||
// the gap the check closes, and there is nothing honest to backfill from.
|
||||
ErrSessionPrincipalLegacy = errors.New("session record carries no immutable principal ID")
|
||||
)
|
||||
|
||||
// IsSessionPrincipalVerdict reports whether err is one of the three continuity
|
||||
// verdicts rather than a dependency fault. Doors answer a token error for a
|
||||
// verdict and InternalError for anything else.
|
||||
func IsSessionPrincipalVerdict(err error) bool {
|
||||
return errors.Is(err, ErrSessionPrincipalGone) ||
|
||||
errors.Is(err, ErrSessionPrincipalReplaced) ||
|
||||
errors.Is(err, ErrSessionPrincipalLegacy)
|
||||
}
|
||||
|
||||
// SessionPrincipal is the live IAM record a session credential still resolves to.
|
||||
type SessionPrincipal struct {
|
||||
UserID string // user sessions
|
||||
RoleID string // role sessions
|
||||
RoleName string
|
||||
RoleARN string
|
||||
}
|
||||
|
||||
// VerifySessionPrincipal re-resolves the principal a session was minted for and
|
||||
// reports whether it is still the same one. The three verdicts above all fail
|
||||
// closed; an IAM dependency fault wraps through unwrapped, so a caller can tell
|
||||
// a fault from a verdict and answer InternalError rather than a token error.
|
||||
func (s *STSServiceImpl) VerifySessionPrincipal(cred *SessionCredential) (*SessionPrincipal, error) {
|
||||
if cred == nil {
|
||||
return nil, errors.New("nil session credential")
|
||||
}
|
||||
if cred.PrincipalType == principalTypeUser {
|
||||
return s.verifySessionUser(cred)
|
||||
}
|
||||
// "assumed-role", or empty for a record predating PrincipalType.
|
||||
return s.verifySessionRole(cred)
|
||||
}
|
||||
|
||||
// verifySessionUser compares the live user's UserId against the one stored at
|
||||
// mint. There is no ARN comparison to make: the session persists only the user
|
||||
// name, and no IAM operation can rename or re-path a user.
|
||||
func (s *STSServiceImpl) verifySessionUser(cred *SessionCredential) (*SessionPrincipal, error) {
|
||||
if cred.UserID == "" {
|
||||
return nil, ErrSessionPrincipalLegacy
|
||||
}
|
||||
|
||||
out, err := s.iamSvc.GetUser(cred.AccountID, &iam.GetUserInput{UserName: aws.String(cred.SessionName)})
|
||||
if err != nil {
|
||||
if awserrors.IsErrorCode(err, awserrors.ErrorIAMNoSuchEntity) {
|
||||
return nil, ErrSessionPrincipalGone
|
||||
}
|
||||
return nil, fmt.Errorf("resolve session user: %w", err)
|
||||
}
|
||||
if out == nil || out.User == nil {
|
||||
return nil, ErrSessionPrincipalGone
|
||||
}
|
||||
|
||||
liveUserID := aws.StringValue(out.User.UserId)
|
||||
if liveUserID != cred.UserID {
|
||||
return nil, ErrSessionPrincipalReplaced
|
||||
}
|
||||
return &SessionPrincipal{UserID: liveUserID}, nil
|
||||
}
|
||||
|
||||
// verifySessionRole resolves the session's underlying role ARN and compares the
|
||||
// live RoleId against the stored one. ResolveRoleByARN also compares the stored
|
||||
// ARN back, so an invented path cannot resolve to a role its ARN does not name.
|
||||
func (s *STSServiceImpl) verifySessionRole(cred *SessionCredential) (*SessionPrincipal, error) {
|
||||
if cred.RoleID == "" {
|
||||
return nil, ErrSessionPrincipalLegacy
|
||||
}
|
||||
|
||||
roleAccountID, role, err := ResolveRoleByARN(s.iamSvc, cred.UnderlyingRoleARN)
|
||||
if err != nil {
|
||||
// A stored ARN that does not parse names no role, which is a verdict on the
|
||||
// session rather than a fault: answering InternalError would tell a client
|
||||
// to retry a credential that can never resolve.
|
||||
if errors.Is(err, ErrRoleUnresolved) || awserrors.IsErrorCode(err, awserrors.ErrorValidationError) {
|
||||
return nil, ErrSessionPrincipalGone
|
||||
}
|
||||
return nil, fmt.Errorf("resolve session role: %w", err)
|
||||
}
|
||||
if role == nil {
|
||||
return nil, ErrSessionPrincipalGone
|
||||
}
|
||||
|
||||
// The role's account was the session's account at mint, so a disagreement
|
||||
// means the record no longer describes the principal it was minted for.
|
||||
if roleAccountID != cred.AccountID {
|
||||
return nil, ErrSessionPrincipalReplaced
|
||||
}
|
||||
if aws.StringValue(role.RoleId) != cred.RoleID {
|
||||
return nil, ErrSessionPrincipalReplaced
|
||||
}
|
||||
|
||||
return &SessionPrincipal{
|
||||
RoleID: aws.StringValue(role.RoleId),
|
||||
RoleName: aws.StringValue(role.RoleName),
|
||||
RoleARN: aws.StringValue(role.Arn),
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,330 @@
|
||||
// behind the unexported STSServiceImpl.iamSvc, which the fault cases swap out,
|
||||
// and the credential fixtures set unexported principal-type constants.
|
||||
//
|
||||
//test:in-package — the verdicts are asserted against the real IAM backend
|
||||
package handlers_sts
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/aws/aws-sdk-go/aws"
|
||||
"github.com/aws/aws-sdk-go/service/iam"
|
||||
"github.com/aws/aws-sdk-go/service/sts"
|
||||
handlers_iam "github.com/mulgadc/spinifex/spinifex/handlers/iam"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// seedUser creates an IAM user and returns its immutable UserId.
|
||||
func seedUser(t *testing.T, svc *STSServiceImpl, accountID, userName string) string {
|
||||
t.Helper()
|
||||
out, err := svc.iamSvc.CreateUser(accountID, &iam.CreateUserInput{UserName: aws.String(userName)})
|
||||
require.NoError(t, err)
|
||||
userID := aws.StringValue(out.User.UserId)
|
||||
require.NotEmpty(t, userID)
|
||||
return userID
|
||||
}
|
||||
|
||||
func deleteUser(t *testing.T, svc *STSServiceImpl, accountID, userName string) {
|
||||
t.Helper()
|
||||
_, err := svc.iamSvc.DeleteUser(accountID, &iam.DeleteUserInput{UserName: aws.String(userName)})
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
func deleteRole(t *testing.T, svc *STSServiceImpl, accountID, roleName string) {
|
||||
t.Helper()
|
||||
_, err := svc.iamSvc.DeleteRole(accountID, &iam.DeleteRoleInput{RoleName: aws.String(roleName)})
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
// mintUserSession runs the real GetSessionToken path and returns the persisted record.
|
||||
func mintUserSession(t *testing.T, svc *STSServiceImpl, accountID, userName string) *SessionCredential {
|
||||
t.Helper()
|
||||
out, err := svc.GetSessionToken(accountID, userName, principalTypeUser, testCallerAccessKeyID,
|
||||
&sts.GetSessionTokenInput{})
|
||||
require.NoError(t, err)
|
||||
cred, err := svc.LookupSessionCredential(aws.StringValue(out.Credentials.AccessKeyId))
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, cred)
|
||||
return cred
|
||||
}
|
||||
|
||||
// mintRoleSession runs the real AssumeRole path and returns the persisted record.
|
||||
func mintRoleSession(t *testing.T, svc *STSServiceImpl, accountID, roleARN, sessionName string) *SessionCredential {
|
||||
t.Helper()
|
||||
out, err := svc.AssumeRole(accountID, testCallerARN(), testCallerUserName,
|
||||
basicAssumeRoleInput(roleARN, sessionName))
|
||||
require.NoError(t, err)
|
||||
cred, err := svc.LookupSessionCredential(aws.StringValue(out.Credentials.AccessKeyId))
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, cred)
|
||||
return cred
|
||||
}
|
||||
|
||||
// ----- Unchanged principals verify ---------------------------------------
|
||||
|
||||
func TestVerifySessionPrincipal_UnchangedUser_ReturnsLiveID(t *testing.T) {
|
||||
svc, _ := newTestSetup(t)
|
||||
userID := seedUser(t, svc, testCallerAccountID, testCallerUserName)
|
||||
cred := mintUserSession(t, svc, testCallerAccountID, testCallerUserName)
|
||||
|
||||
got, err := svc.VerifySessionPrincipal(cred)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, got)
|
||||
assert.Equal(t, userID, got.UserID)
|
||||
assert.Empty(t, got.RoleID)
|
||||
}
|
||||
|
||||
func TestVerifySessionPrincipal_UnchangedRole_ReturnsLiveRecord(t *testing.T) {
|
||||
svc, _ := newTestSetup(t)
|
||||
role := createRoleInAccount(t, svc, testCallerAccountID, "app", trustPolicyAllowingUser(testCallerARN()))
|
||||
cred := mintRoleSession(t, svc, testCallerAccountID, aws.StringValue(role.Arn), "session-1")
|
||||
|
||||
got, err := svc.VerifySessionPrincipal(cred)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, got)
|
||||
assert.Equal(t, aws.StringValue(role.RoleId), got.RoleID)
|
||||
assert.Equal(t, "app", got.RoleName)
|
||||
assert.Equal(t, aws.StringValue(role.Arn), got.RoleARN)
|
||||
}
|
||||
|
||||
// ----- Same-name recreation: the defect this check closes -----------------
|
||||
|
||||
// The reported attack: an administrator deletes a user and recreates it under
|
||||
// the same name, and the deleted user's unexpired session is authorized with
|
||||
// the replacement's permissions. The replacement's UserId differs, so the
|
||||
// session must die on that.
|
||||
func TestVerifySessionPrincipal_UserRecreatedSameName_Rejected(t *testing.T) {
|
||||
svc, _ := newTestSetup(t)
|
||||
originalID := seedUser(t, svc, testCallerAccountID, testCallerUserName)
|
||||
cred := mintUserSession(t, svc, testCallerAccountID, testCallerUserName)
|
||||
|
||||
deleteUser(t, svc, testCallerAccountID, testCallerUserName)
|
||||
replacementID := seedUser(t, svc, testCallerAccountID, testCallerUserName)
|
||||
require.NotEqual(t, originalID, replacementID, "recreation must change the immutable ID")
|
||||
|
||||
// The replacement is granted a permission the original never had; the old
|
||||
// session must not reach the policy evaluator at all.
|
||||
_, err := svc.iamSvc.PutUserPolicy(testCallerAccountID, &iam.PutUserPolicyInput{
|
||||
UserName: aws.String(testCallerUserName),
|
||||
PolicyName: aws.String("list-users"),
|
||||
PolicyDocument: aws.String(`{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"iam:ListUsers","Resource":"*"}]}`),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
got, err := svc.VerifySessionPrincipal(cred)
|
||||
require.Error(t, err)
|
||||
assert.ErrorIs(t, err, ErrSessionPrincipalReplaced)
|
||||
assert.Nil(t, got)
|
||||
}
|
||||
|
||||
func TestVerifySessionPrincipal_RoleRecreatedSameName_Rejected(t *testing.T) {
|
||||
svc, _ := newTestSetup(t)
|
||||
caller := testCallerARN()
|
||||
original := createRoleInAccount(t, svc, testCallerAccountID, "app", trustPolicyAllowingUser(caller))
|
||||
cred := mintRoleSession(t, svc, testCallerAccountID, aws.StringValue(original.Arn), "session-1")
|
||||
|
||||
deleteRole(t, svc, testCallerAccountID, "app")
|
||||
replacement := createRoleInAccount(t, svc, testCallerAccountID, "app", trustPolicyAllowingWildcard())
|
||||
require.NotEqual(t, aws.StringValue(original.RoleId), aws.StringValue(replacement.RoleId))
|
||||
// Delete-and-recreate at the same name and path reproduces the ARN byte for
|
||||
// byte, so the ARN comparison alone would not catch this.
|
||||
require.Equal(t, aws.StringValue(original.Arn), aws.StringValue(replacement.Arn))
|
||||
|
||||
_, err := svc.iamSvc.PutRolePolicy(testCallerAccountID, &iam.PutRolePolicyInput{
|
||||
RoleName: aws.String("app"),
|
||||
PolicyName: aws.String("admin"),
|
||||
PolicyDocument: aws.String(`{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"*","Resource":"*"}]}`),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
got, err := svc.VerifySessionPrincipal(cred)
|
||||
require.Error(t, err)
|
||||
assert.ErrorIs(t, err, ErrSessionPrincipalReplaced)
|
||||
assert.Nil(t, got)
|
||||
}
|
||||
|
||||
// A replacement role whose trust policy excludes the original caller must not
|
||||
// keep serving that caller's session. Trust is evaluated once, at AssumeRole,
|
||||
// so the ID mismatch is the only thing standing between the two.
|
||||
func TestVerifySessionPrincipal_RoleReplacedWithExcludingTrustPolicy_Rejected(t *testing.T) {
|
||||
svc, _ := newTestSetup(t)
|
||||
original := createRoleInAccount(t, svc, testCallerAccountID, "app", trustPolicyAllowingUser(testCallerARN()))
|
||||
cred := mintRoleSession(t, svc, testCallerAccountID, aws.StringValue(original.Arn), "session-1")
|
||||
|
||||
deleteRole(t, svc, testCallerAccountID, "app")
|
||||
createRoleInAccount(t, svc, testCallerAccountID, "app",
|
||||
trustPolicyAllowingUser("arn:aws:iam::"+testCallerAccountID+":user/someone-else"))
|
||||
|
||||
_, err := svc.VerifySessionPrincipal(cred)
|
||||
assert.ErrorIs(t, err, ErrSessionPrincipalReplaced)
|
||||
}
|
||||
|
||||
// ----- Deleted and not recreated -----------------------------------------
|
||||
|
||||
func TestVerifySessionPrincipal_UserDeleted_Gone(t *testing.T) {
|
||||
svc, _ := newTestSetup(t)
|
||||
seedUser(t, svc, testCallerAccountID, testCallerUserName)
|
||||
cred := mintUserSession(t, svc, testCallerAccountID, testCallerUserName)
|
||||
|
||||
deleteUser(t, svc, testCallerAccountID, testCallerUserName)
|
||||
|
||||
_, err := svc.VerifySessionPrincipal(cred)
|
||||
assert.ErrorIs(t, err, ErrSessionPrincipalGone)
|
||||
}
|
||||
|
||||
func TestVerifySessionPrincipal_RoleDeleted_Gone(t *testing.T) {
|
||||
svc, _ := newTestSetup(t)
|
||||
role := createRoleInAccount(t, svc, testCallerAccountID, "app", trustPolicyAllowingUser(testCallerARN()))
|
||||
cred := mintRoleSession(t, svc, testCallerAccountID, aws.StringValue(role.Arn), "session-1")
|
||||
|
||||
deleteRole(t, svc, testCallerAccountID, "app")
|
||||
|
||||
_, err := svc.VerifySessionPrincipal(cred)
|
||||
assert.ErrorIs(t, err, ErrSessionPrincipalGone)
|
||||
}
|
||||
|
||||
// A session whose stored underlying role ARN names another account resolves to
|
||||
// no role this account can vouch for, so it must not verify.
|
||||
func TestVerifySessionPrincipal_CrossAccountRoleARN_Rejected(t *testing.T) {
|
||||
svc, _ := newTestSetup(t)
|
||||
role := createRoleInAccount(t, svc, testCallerAccountID, "app", trustPolicyAllowingUser(testCallerARN()))
|
||||
cred := mintRoleSession(t, svc, testCallerAccountID, aws.StringValue(role.Arn), "session-1")
|
||||
cred.UnderlyingRoleARN = "arn:aws:iam::" + testCrossAccountID + ":role/app"
|
||||
|
||||
_, err := svc.VerifySessionPrincipal(cred)
|
||||
require.Error(t, err)
|
||||
assert.True(t, IsSessionPrincipalVerdict(err))
|
||||
}
|
||||
|
||||
// A stored ARN that does not parse names no principal, so it is a verdict on
|
||||
// the session rather than a dependency fault. Answering InternalError here
|
||||
// would tell the client to retry a credential that can never resolve, and on
|
||||
// the SigV4 door that answer also skips the rate limiter.
|
||||
func TestVerifySessionPrincipal_MalformedRoleARN_Gone(t *testing.T) {
|
||||
svc, _ := newTestSetup(t)
|
||||
role := createRoleInAccount(t, svc, testCallerAccountID, "app", trustPolicyAllowingUser(testCallerARN()))
|
||||
cred := mintRoleSession(t, svc, testCallerAccountID, aws.StringValue(role.Arn), "session-1")
|
||||
cred.UnderlyingRoleARN = "not-an-arn"
|
||||
|
||||
_, err := svc.VerifySessionPrincipal(cred)
|
||||
assert.ErrorIs(t, err, ErrSessionPrincipalGone)
|
||||
assert.True(t, IsSessionPrincipalVerdict(err))
|
||||
}
|
||||
|
||||
// ----- Legacy records fail closed ----------------------------------------
|
||||
|
||||
// Records minted before the immutable ID was persisted carry nothing to compare,
|
||||
// and are rejected rather than waved through: "absent evidence passes" preserves
|
||||
// exactly the gap this check closes, and would swallow any future mint path that
|
||||
// failed to populate the field.
|
||||
func TestVerifySessionPrincipal_LegacyRecord_Rejected(t *testing.T) {
|
||||
svc, _ := newTestSetup(t)
|
||||
seedUser(t, svc, testCallerAccountID, testCallerUserName)
|
||||
role := createRoleInAccount(t, svc, testCallerAccountID, "app", trustPolicyAllowingUser(testCallerARN()))
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
cred *SessionCredential
|
||||
}{
|
||||
{"user session with no UserID", &SessionCredential{
|
||||
AccessKeyID: "ASIALEGACYUSER000001",
|
||||
AccountID: testCallerAccountID,
|
||||
PrincipalType: principalTypeUser,
|
||||
SessionName: testCallerUserName,
|
||||
}},
|
||||
{"role session with no RoleID", &SessionCredential{
|
||||
AccessKeyID: "ASIALEGACYROLE000001",
|
||||
AccountID: testCallerAccountID,
|
||||
PrincipalType: principalTypeAssumedRole,
|
||||
SessionName: "session-1",
|
||||
UnderlyingRoleARN: aws.StringValue(role.Arn),
|
||||
}},
|
||||
{"pre-PrincipalType role record", &SessionCredential{
|
||||
AccessKeyID: "ASIALEGACYEMPTY00001",
|
||||
AccountID: testCallerAccountID,
|
||||
SessionName: "session-1",
|
||||
UnderlyingRoleARN: aws.StringValue(role.Arn),
|
||||
}},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got, err := svc.VerifySessionPrincipal(tc.cred)
|
||||
assert.ErrorIs(t, err, ErrSessionPrincipalLegacy)
|
||||
assert.Nil(t, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ----- Root -------------------------------------------------------------
|
||||
|
||||
// Root sessions skip policy evaluation entirely, so continuity is the only gate
|
||||
// on one. No special case is needed: the seeded record's constant ID matches
|
||||
// while root exists, and its removal fails closed like any other principal.
|
||||
func TestVerifySessionPrincipal_Root(t *testing.T) {
|
||||
svc, _ := newTestSetup(t)
|
||||
rootID := seedUser(t, svc, testCallerAccountID, "root")
|
||||
cred := mintUserSession(t, svc, testCallerAccountID, "root")
|
||||
|
||||
got, err := svc.VerifySessionPrincipal(cred)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, rootID, got.UserID)
|
||||
|
||||
deleteUser(t, svc, testCallerAccountID, "root")
|
||||
|
||||
_, err = svc.VerifySessionPrincipal(cred)
|
||||
assert.ErrorIs(t, err, ErrSessionPrincipalGone)
|
||||
}
|
||||
|
||||
// ----- Faults are distinguishable from verdicts --------------------------
|
||||
|
||||
// A dependency fault must not read as a rejection verdict: the doors answer
|
||||
// InternalError for the first and a token error for the second, and collapsing
|
||||
// them would let an IAM outage look like a revoked principal.
|
||||
func TestVerifySessionPrincipal_DependencyFault_NotAVerdict(t *testing.T) {
|
||||
svc, _ := newTestSetup(t)
|
||||
boom := errors.New("jetstream unavailable")
|
||||
svc.iamSvc = faultingIAMService{err: boom}
|
||||
|
||||
cred := &SessionCredential{
|
||||
AccessKeyID: "ASIAFAULTUSER0000001",
|
||||
AccountID: testCallerAccountID,
|
||||
PrincipalType: principalTypeUser,
|
||||
SessionName: testCallerUserName,
|
||||
UserID: "AIDAEXAMPLEAAAAAAAAA",
|
||||
ExpiresAt: time.Now().UTC().Add(time.Hour),
|
||||
}
|
||||
|
||||
got, err := svc.VerifySessionPrincipal(cred)
|
||||
require.Error(t, err)
|
||||
assert.Nil(t, got)
|
||||
assert.ErrorIs(t, err, boom)
|
||||
assert.False(t, IsSessionPrincipalVerdict(err), "a fault must not read as a rejection verdict")
|
||||
}
|
||||
|
||||
func TestVerifySessionPrincipal_NilCredential_IsAFault(t *testing.T) {
|
||||
svc, _ := newTestSetup(t)
|
||||
|
||||
got, err := svc.VerifySessionPrincipal(nil)
|
||||
require.Error(t, err)
|
||||
assert.Nil(t, got)
|
||||
assert.False(t, IsSessionPrincipalVerdict(err))
|
||||
}
|
||||
|
||||
// faultingIAMService fails every lookup with a non-NoSuchEntity error, standing
|
||||
// in for an unreachable IAM backend.
|
||||
type faultingIAMService struct {
|
||||
handlers_iam.IAMService
|
||||
|
||||
err error
|
||||
}
|
||||
|
||||
func (f faultingIAMService) GetUser(string, *iam.GetUserInput) (*iam.GetUserOutput, error) {
|
||||
return nil, f.err
|
||||
}
|
||||
|
||||
func (f faultingIAMService) GetRole(string, *iam.GetRoleInput) (*iam.GetRoleOutput, error) {
|
||||
return nil, f.err
|
||||
}
|
||||
Reference in New Issue
Block a user