mirror of
https://github.com/mulgadc/spinifex.git
synced 2026-10-15 09:01:31 +00:00
fix(iam): report a pathed IAM user's stored ARN as its caller ARN (#1149)
This commit is contained in:
@@ -184,12 +184,13 @@ func (gw *GatewayConfig) SigV4AuthMiddleware() func(http.Handler) http.Handler {
|
||||
|
||||
// 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)
|
||||
userID, userARN, err := gw.principalUser(principal)
|
||||
if err != nil {
|
||||
gw.writeSigV4Error(w, r, err.Error(), "")
|
||||
return
|
||||
}
|
||||
principal.userID = userID
|
||||
principal.userARN = userARN
|
||||
|
||||
// Parse rewound the body; re-read it for query-arg parsing, then rewind
|
||||
// again for the downstream handler.
|
||||
@@ -222,6 +223,9 @@ func (gw *GatewayConfig) SigV4AuthMiddleware() func(http.Handler) http.Handler {
|
||||
if principal.userID != "" {
|
||||
ctx = context.WithValue(ctx, ctxUserID, principal.userID)
|
||||
}
|
||||
if principal.userARN != "" {
|
||||
ctx = context.WithValue(ctx, ctxUserARN, principal.userARN)
|
||||
}
|
||||
|
||||
// Parse once; dispatchers reuse via ctxQueryArgs. On error the
|
||||
// dispatcher re-parses and returns MalformedQueryString.
|
||||
@@ -348,6 +352,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
|
||||
// userARN is an IAM user's stored ARN, path included, from the same read as
|
||||
// userID. Empty for every other principal type.
|
||||
userARN 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
|
||||
|
||||
@@ -126,7 +126,7 @@ func emittedKeys(t *testing.T) map[string]string {
|
||||
r.RemoteAddr = "10.4.1.9:52344"
|
||||
// Resolved the way the middleware resolves it, so the gate cannot pass on
|
||||
// a value only the test supplies.
|
||||
userID, err := gw.principalUserID(principal)
|
||||
userID, _, err := gw.principalUser(principal)
|
||||
require.NoError(t, err)
|
||||
principal.userID = userID
|
||||
for key := range requestConditionKeys(r, principal) {
|
||||
|
||||
@@ -109,19 +109,19 @@ func TestRequestConditionKeys_OmitsUnresolvableUserID(t *testing.T) {
|
||||
assert.NotContains(t, keys, iampolicy.KeyUserID)
|
||||
}
|
||||
|
||||
// principalUserID resolves the ID once per request, at the point the principal
|
||||
// principalUser resolves the ID once per request, at the point the principal
|
||||
// is resolved. A user with no record and a session minted before the ID was
|
||||
// recorded both yield an omitted key, not a failed request.
|
||||
func TestPrincipalUserID_ResolvesAndOmits(t *testing.T) {
|
||||
gw := &GatewayConfig{DisableLogging: true, IAMService: &mockIAMService{}}
|
||||
|
||||
userID, err := gw.principalUserID(principalContext{
|
||||
userID, _, err := gw.principalUser(principalContext{
|
||||
identity: "alice", accountID: "000000000001", principalType: principalTypeUser,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "AIDAALICE", userID)
|
||||
|
||||
userID, err = gw.principalUserID(principalContext{
|
||||
userID, _, err = gw.principalUser(principalContext{
|
||||
identity: "session", accountID: "000000000001", principalType: principalTypeAssumedRole,
|
||||
assumedRoleID: "AROASHAREDOPS:session",
|
||||
})
|
||||
@@ -130,7 +130,7 @@ func TestPrincipalUserID_ResolvesAndOmits(t *testing.T) {
|
||||
|
||||
// Root's aws:userid is the account ID, matching what GetCallerIdentity
|
||||
// reports for the same principal.
|
||||
userID, err = gw.principalUserID(principalContext{
|
||||
userID, _, err = gw.principalUser(principalContext{
|
||||
identity: "root", accountID: "000000000000", principalType: principalTypeUser,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
@@ -141,7 +141,7 @@ func TestPrincipalUserID_ResolvesAndOmits(t *testing.T) {
|
||||
return nil, errors.New(awserrors.ErrorIAMNoSuchEntity)
|
||||
},
|
||||
}}
|
||||
userID, err = missing.principalUserID(principalContext{
|
||||
userID, _, err = missing.principalUser(principalContext{
|
||||
identity: "alice", accountID: "000000000001", principalType: principalTypeUser,
|
||||
})
|
||||
require.NoError(t, err, "a deleted user omits the key rather than failing the request")
|
||||
@@ -153,14 +153,14 @@ func TestPrincipalUserID_ResolvesAndOmits(t *testing.T) {
|
||||
return &iam.GetUserOutput{User: &iam.User{UserName: aws.String("alice")}}, nil
|
||||
},
|
||||
}}
|
||||
userID, err = legacy.principalUserID(principalContext{
|
||||
userID, _, err = legacy.principalUser(principalContext{
|
||||
identity: "alice", accountID: "000000000001", principalType: principalTypeUser,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, userID)
|
||||
|
||||
// A session minted before assumed_role_id was recorded takes the same arm.
|
||||
userID, err = gw.principalUserID(principalContext{
|
||||
userID, _, err = gw.principalUser(principalContext{
|
||||
identity: "session", accountID: "000000000001", principalType: principalTypeAssumedRole,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
@@ -176,13 +176,47 @@ func TestPrincipalUserID_DependencyFaultFailsClosed(t *testing.T) {
|
||||
},
|
||||
}}
|
||||
|
||||
userID, err := faulty.principalUserID(principalContext{
|
||||
userID, userARN, err := faulty.principalUser(principalContext{
|
||||
identity: "alice", accountID: "000000000001", principalType: principalTypeUser,
|
||||
})
|
||||
|
||||
require.Error(t, err)
|
||||
assert.Equal(t, awserrors.ErrorInternalError, err.Error())
|
||||
assert.Empty(t, userID)
|
||||
assert.Empty(t, userARN)
|
||||
}
|
||||
|
||||
// An IAM user's ARN comes from its record, path included, in the same read as
|
||||
// its ID. Root and role sessions carry no user ARN.
|
||||
func TestPrincipalUser_ResolvesStoredUserARN(t *testing.T) {
|
||||
const pathed = "arn:aws:iam::000000000001:user/eng/alice"
|
||||
gw := &GatewayConfig{DisableLogging: true, IAMService: &mockIAMService{
|
||||
getUserFn: func(string, *iam.GetUserInput) (*iam.GetUserOutput, error) {
|
||||
return &iam.GetUserOutput{User: &iam.User{
|
||||
UserName: aws.String("alice"), UserId: aws.String("AIDAALICE"), Arn: aws.String(pathed),
|
||||
}}, nil
|
||||
},
|
||||
}}
|
||||
|
||||
userID, userARN, err := gw.principalUser(principalContext{
|
||||
identity: "alice", accountID: "000000000001", principalType: principalTypeUser,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "AIDAALICE", userID)
|
||||
assert.Equal(t, pathed, userARN)
|
||||
|
||||
_, userARN, err = gw.principalUser(principalContext{
|
||||
identity: "root", accountID: "000000000000", principalType: principalTypeUser,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, userARN)
|
||||
|
||||
_, userARN, err = gw.principalUser(principalContext{
|
||||
identity: "session", accountID: "000000000001", principalType: principalTypeAssumedRole,
|
||||
assumedRoleID: "AROASHAREDOPS:session",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, userARN)
|
||||
}
|
||||
|
||||
// RoleSessionName is chosen by the caller of AssumeRole, so aws:username must
|
||||
|
||||
@@ -31,12 +31,13 @@ func (gw *GatewayConfig) handleGetAuthorizationToken(w http.ResponseWriter, r *h
|
||||
identity, _ := ctx.Value(ctxIdentity).(string)
|
||||
principalType, _ := ctx.Value(ctxPrincipalType).(string)
|
||||
assumedRoleARN, _ := ctx.Value(ctxAssumedRoleARN).(string)
|
||||
userARN, _ := ctx.Value(ctxUserARN).(string)
|
||||
|
||||
// The minted token names the exact IAM/STS record every /v2/* request will
|
||||
// later rehydrate against, so its subject must be the same canonical ARN
|
||||
// buildCallerARN produces everywhere else in the gateway (STS included) —
|
||||
// not a best-effort approximation that could omit an IAM path.
|
||||
callerARN, err := buildCallerARN(accountID, identity, principalType, assumedRoleARN)
|
||||
callerARN, err := buildCallerARN(accountID, identity, principalType, assumedRoleARN, userARN)
|
||||
if err != nil {
|
||||
slog.Error("GetAuthorizationToken: cannot build canonical caller ARN", "err", err)
|
||||
return errors.New(awserrors.ErrorServerInternal)
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/aws/aws-sdk-go/aws"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
@@ -57,6 +58,48 @@ func TestHandleGetAuthorizationToken_MintsUsableToken(t *testing.T) {
|
||||
assert.Equal(t, ecrTestAccount, claims.AccountID)
|
||||
}
|
||||
|
||||
// The mint and verify halves agree on a pathed user: the token is minted for the
|
||||
// stored ARN the middleware resolved, and verifies against the same record.
|
||||
func TestHandleGetAuthorizationToken_PathedUserRoundTrips(t *testing.T) {
|
||||
iamSvc := newECRMockIAMService()
|
||||
seedECRTestUser(iamSvc, ecrPrincipalTestAccount, "dev", ecrPrincipalTestAKID)
|
||||
iamSvc.users[ecrPrincipalTestAccount+"|dev"].Arn = aws.String(ecrPathedUserARN)
|
||||
iss, verify := newECRAuth(t)
|
||||
gw := &GatewayConfig{
|
||||
Region: ecrTestRegion, InternalSuffix: ecrTestSuffix,
|
||||
ECRTokenIssuer: iss, ECRTokenVerifier: verify, DisableLogging: true,
|
||||
IAMService: iamSvc,
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader("{}"))
|
||||
ctx := context.WithValue(req.Context(), ctxAccountID, ecrPrincipalTestAccount)
|
||||
ctx = context.WithValue(ctx, ctxPrincipalType, principalTypeUser)
|
||||
ctx = context.WithValue(ctx, ctxIdentity, "dev")
|
||||
ctx = context.WithValue(ctx, ctxUserARN, ecrPathedUserARN)
|
||||
ctx = context.WithValue(ctx, ctxAccessKey, ecrPrincipalTestAKID)
|
||||
w := httptest.NewRecorder()
|
||||
require.NoError(t, gw.handleGetAuthorizationToken(w, req.WithContext(ctx)))
|
||||
require.Equal(t, http.StatusOK, w.Code)
|
||||
|
||||
var out struct {
|
||||
AuthorizationData []struct {
|
||||
AuthorizationToken string `json:"authorizationToken"`
|
||||
} `json:"authorizationData"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &out))
|
||||
require.Len(t, out.AuthorizationData, 1)
|
||||
decoded, err := base64.StdEncoding.DecodeString(out.AuthorizationData[0].AuthorizationToken)
|
||||
require.NoError(t, err)
|
||||
_, jwtStr, _ := strings.Cut(string(decoded), ":")
|
||||
|
||||
claims, err := verify.Verify(jwtStr)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, ecrPathedUserARN, claims.Subject)
|
||||
got, err := gw.resolveECRPrincipal(claims)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, ecrPathedUserARN, got.userARN)
|
||||
}
|
||||
|
||||
func TestHandleGetAuthorizationToken_ProxyEndpointCarriesPort(t *testing.T) {
|
||||
iss, verify := newECRAuth(t)
|
||||
gw := &GatewayConfig{
|
||||
|
||||
@@ -119,11 +119,12 @@ func (gw *GatewayConfig) resolveECRLongLivedPrincipal(claims *gateway_ecrauth.Cl
|
||||
return principalContext{}, classifyIAMLookupErr(err, "user")
|
||||
}
|
||||
identity := aws.StringValue(userOut.User.UserName)
|
||||
userARN := aws.StringValue(userOut.User.Arn)
|
||||
|
||||
if claims.PrincipalType != principalTypeUser {
|
||||
return principalContext{}, ecrInvalidPrincipal("principalType claim %q does not match resolved user", claims.PrincipalType)
|
||||
}
|
||||
canonicalARN, err := buildCallerARN(ak.AccountID, identity, principalTypeUser, "")
|
||||
canonicalARN, err := buildCallerARN(ak.AccountID, identity, principalTypeUser, "", userARN)
|
||||
if err != nil {
|
||||
return principalContext{}, ecrInvalidPrincipal("cannot build canonical ARN: %w", err)
|
||||
}
|
||||
@@ -136,6 +137,7 @@ func (gw *GatewayConfig) resolveECRLongLivedPrincipal(claims *gateway_ecrauth.Cl
|
||||
accountID: ak.AccountID,
|
||||
principalType: principalTypeUser,
|
||||
userID: aws.StringValue(userOut.User.UserId),
|
||||
userARN: userARN,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -186,7 +188,7 @@ func (gw *GatewayConfig) resolveECRSessionPrincipal(claims *gateway_ecrauth.Clai
|
||||
if claims.PrincipalType != principalTypeUser {
|
||||
return principalContext{}, ecrInvalidPrincipal("principalType claim %q does not match resolved session", claims.PrincipalType)
|
||||
}
|
||||
canonicalARN, err := buildCallerARN(cred.AccountID, cred.SessionName, principalTypeUser, "")
|
||||
canonicalARN, err := buildCallerARN(cred.AccountID, cred.SessionName, principalTypeUser, "", live.UserARN)
|
||||
if err != nil {
|
||||
return principalContext{}, ecrInvalidPrincipal("cannot build canonical ARN: %w", err)
|
||||
}
|
||||
@@ -199,6 +201,7 @@ func (gw *GatewayConfig) resolveECRSessionPrincipal(claims *gateway_ecrauth.Clai
|
||||
accountID: cred.AccountID,
|
||||
principalType: principalTypeUser,
|
||||
userID: live.UserID,
|
||||
userARN: live.UserARN,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -208,7 +211,7 @@ func (gw *GatewayConfig) resolveECRSessionPrincipal(claims *gateway_ecrauth.Clai
|
||||
return principalContext{}, ecrInvalidPrincipal("principalType claim %q does not match resolved session", claims.PrincipalType)
|
||||
}
|
||||
|
||||
canonicalARN, err := buildCallerARN(cred.AccountID, cred.SessionName, principalTypeAssumedRole, cred.AssumedRoleARN)
|
||||
canonicalARN, err := buildCallerARN(cred.AccountID, cred.SessionName, principalTypeAssumedRole, cred.AssumedRoleARN, "")
|
||||
if err != nil {
|
||||
return principalContext{}, ecrInvalidPrincipal("cannot build canonical ARN: %w", err)
|
||||
}
|
||||
|
||||
@@ -119,6 +119,9 @@ type ecrMockSTSService struct {
|
||||
// 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
|
||||
|
||||
// liveUserARN is the stored ARN the continuity read returns for a user session.
|
||||
liveUserARN string
|
||||
}
|
||||
|
||||
func newECRMockSTSService() *ecrMockSTSService {
|
||||
@@ -133,7 +136,11 @@ func (m *ecrMockSTSService) VerifySessionPrincipal(cred *handlers_sts.SessionCre
|
||||
if m.principalErr != nil {
|
||||
return nil, m.principalErr
|
||||
}
|
||||
return stubSessionPrincipal(cred)
|
||||
live, err := stubSessionPrincipal(cred)
|
||||
if err == nil && cred.PrincipalType == principalTypeUser {
|
||||
live.UserARN = m.liveUserARN
|
||||
}
|
||||
return live, err
|
||||
}
|
||||
|
||||
const (
|
||||
@@ -172,7 +179,34 @@ func TestResolveECRPrincipal_LongLivedUser(t *testing.T) {
|
||||
got, err := gw.resolveECRPrincipal(claims)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, principalContext{identity: "dev", accountID: ecrPrincipalTestAccount,
|
||||
principalType: principalTypeUser, userID: "AIDADEV"}, got)
|
||||
principalType: principalTypeUser, userID: "AIDADEV", userARN: claims.Subject}, got)
|
||||
}
|
||||
|
||||
const ecrPathedUserARN = "arn:aws:iam::" + ecrPrincipalTestAccount + ":user/eng/dev"
|
||||
|
||||
// A user at a non-root path verifies against its stored ARN, path included. A
|
||||
// token whose subject drops the path, as one minted before the path was carried
|
||||
// would, no longer names the principal and is rejected.
|
||||
func TestResolveECRPrincipal_LongLivedUser_Pathed(t *testing.T) {
|
||||
iamSvc := newECRMockIAMService()
|
||||
seedECRTestUser(iamSvc, ecrPrincipalTestAccount, "dev", ecrPrincipalTestAKID)
|
||||
iamSvc.users[ecrPrincipalTestAccount+"|dev"].Arn = aws.String(ecrPathedUserARN)
|
||||
gw := &GatewayConfig{IAMService: iamSvc}
|
||||
|
||||
claims := &gateway_ecrauth.Claims{
|
||||
AccountID: ecrPrincipalTestAccount,
|
||||
PrincipalType: principalTypeUser,
|
||||
AccessKeyID: ecrPrincipalTestAKID,
|
||||
}
|
||||
claims.Subject = ecrPathedUserARN
|
||||
got, err := gw.resolveECRPrincipal(claims)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, ecrPathedUserARN, got.userARN)
|
||||
|
||||
claims.Subject = "arn:aws:iam::" + ecrPrincipalTestAccount + ":user/dev"
|
||||
_, err = gw.resolveECRPrincipal(claims)
|
||||
require.Error(t, err)
|
||||
assert.False(t, isECRDependencyFailure(err))
|
||||
}
|
||||
|
||||
func TestResolveECRPrincipal_GlobalRoot(t *testing.T) {
|
||||
@@ -324,6 +358,29 @@ func TestResolveECRPrincipal_SessionToken_User(t *testing.T) {
|
||||
principalType: principalTypeUser, userID: "AIDADEV"}, got)
|
||||
}
|
||||
|
||||
func TestResolveECRPrincipal_SessionToken_User_Pathed(t *testing.T) {
|
||||
iamSvc := newECRMockIAMService()
|
||||
stsSvc := newECRMockSTSService()
|
||||
seedECRSessionUser(iamSvc, stsSvc, ecrPrincipalTestAccount, "dev", ecrPrincipalTestASID, time.Now().Add(time.Hour))
|
||||
stsSvc.liveUserARN = ecrPathedUserARN
|
||||
gw := &GatewayConfig{IAMService: iamSvc, STSService: stsSvc}
|
||||
|
||||
claims := &gateway_ecrauth.Claims{
|
||||
AccountID: ecrPrincipalTestAccount,
|
||||
PrincipalType: principalTypeUser,
|
||||
AccessKeyID: ecrPrincipalTestASID,
|
||||
}
|
||||
claims.Subject = ecrPathedUserARN
|
||||
got, err := gw.resolveECRPrincipal(claims)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, ecrPathedUserARN, got.userARN)
|
||||
|
||||
claims.Subject = "arn:aws:iam::" + ecrPrincipalTestAccount + ":user/dev"
|
||||
_, err = gw.resolveECRPrincipal(claims)
|
||||
require.Error(t, err)
|
||||
assert.False(t, isECRDependencyFailure(err))
|
||||
}
|
||||
|
||||
// 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) {
|
||||
|
||||
@@ -82,7 +82,7 @@ func (gw *GatewayConfig) handleECRToken(w http.ResponseWriter, r *http.Request)
|
||||
return
|
||||
}
|
||||
|
||||
callerARN, err := buildCallerARN(principal.accountID, principal.identity, principal.principalType, principal.assumedRoleARN)
|
||||
callerARN, err := buildCallerARN(principal.accountID, principal.identity, principal.principalType, principal.assumedRoleARN, principal.userARN)
|
||||
if err != nil {
|
||||
slog.Error("ECR token endpoint: cannot build canonical caller ARN", "err", err)
|
||||
gateway_ecr.WriteError(w, http.StatusInternalServerError, "SERVER_ERROR", "token mint failed")
|
||||
|
||||
@@ -380,7 +380,8 @@ func eksCallerPrincipalARN(r *http.Request) string {
|
||||
identity, _ := ctx.Value(ctxIdentity).(string)
|
||||
principalType, _ := ctx.Value(ctxPrincipalType).(string)
|
||||
assumedRoleARN, _ := ctx.Value(ctxAssumedRoleARN).(string)
|
||||
arn, err := buildCallerARN(accountID, identity, principalType, assumedRoleARN)
|
||||
userARN, _ := ctx.Value(ctxUserARN).(string)
|
||||
arn, err := buildCallerARN(accountID, identity, principalType, assumedRoleARN, userARN)
|
||||
if err != nil {
|
||||
slog.DebugContext(r.Context(), "EKS_Request: could not resolve caller principal ARN", "err", err)
|
||||
return ""
|
||||
|
||||
@@ -64,6 +64,8 @@ const (
|
||||
// ctxUserID carries aws:userid, resolved once by the SigV4 middleware so
|
||||
// every policy check in a request evaluates the same value.
|
||||
ctxUserID contextKey = "sigv4.userID"
|
||||
// ctxUserARN carries an IAM user's stored ARN, path included, from the same read.
|
||||
ctxUserARN contextKey = "sigv4.userARN"
|
||||
// ctxUnderlyingRoleARN carries the IAM role ARN backing an assumed-role session.
|
||||
// Policy enforcement resolves the role name from this, never from ctxIdentity
|
||||
// (attacker-influenced RoleSessionName).
|
||||
@@ -637,6 +639,7 @@ func (gw *GatewayConfig) checkPolicyResourcesWithKeys(r *http.Request, service,
|
||||
assumedRoleID: mustCtxString(r, ctxAssumedRoleID),
|
||||
underlyingRoleARN: mustCtxString(r, ctxUnderlyingRoleARN),
|
||||
userID: mustCtxString(r, ctxUserID),
|
||||
userARN: mustCtxString(r, ctxUserARN),
|
||||
}
|
||||
keys := requestConditionKeys(r, principal)
|
||||
maps.Copy(keys, actionKeys)
|
||||
@@ -702,19 +705,19 @@ func principalTypeCondition(principalType string) (string, bool) {
|
||||
}
|
||||
}
|
||||
|
||||
// principalUserID resolves aws:userid: an IAM user's unique ID, the role ID and
|
||||
// principalUser resolves aws:userid: an IAM user's unique ID, the role ID and
|
||||
// session name STS minted for a role session, or the account ID for root. Both
|
||||
// halves of a session's ID come from the resolved role, so unlike aws:username
|
||||
// it is not caller-chosen.
|
||||
// it is not caller-chosen. An IAM user's stored ARN comes from the same read.
|
||||
//
|
||||
// A principal with no ID on record returns empty and the door omits the key. A
|
||||
// dependency fault returns InternalError instead: authorizing against a context
|
||||
// missing the key silently narrows an Allow and widens a Deny.
|
||||
func (gw *GatewayConfig) principalUserID(principal principalContext) (string, error) {
|
||||
func (gw *GatewayConfig) principalUser(principal principalContext) (userID, userARN string, err error) {
|
||||
if principal.identity == "" || principal.accountID == "" {
|
||||
return "", nil
|
||||
return "", "", nil
|
||||
}
|
||||
userID, err := gateway_sts.ResolveCallerUserID(principal.accountID, principal.principalType,
|
||||
userID, userARN, err = gateway_sts.ResolveCallerUser(principal.accountID, principal.principalType,
|
||||
principal.identity, principal.assumedRoleID, gw.IAMService)
|
||||
switch {
|
||||
case err == nil:
|
||||
@@ -723,15 +726,15 @@ func (gw *GatewayConfig) principalUserID(principal principalContext) (string, er
|
||||
"accountID", principal.accountID, "identity", principal.identity,
|
||||
"principalType", principal.principalType)
|
||||
}
|
||||
return userID, nil
|
||||
return userID, userARN, nil
|
||||
case strings.Contains(err.Error(), awserrors.ErrorIAMNoSuchEntity):
|
||||
slog.Warn("aws:userid unavailable: no such IAM user",
|
||||
"accountID", principal.accountID, "user", principal.identity)
|
||||
return "", nil
|
||||
return "", "", nil
|
||||
default:
|
||||
slog.Error("aws:userid: IAM dependency fault, refusing to authorize on a degraded context",
|
||||
"accountID", principal.accountID, "identity", principal.identity, "err", err)
|
||||
return "", errors.New(awserrors.ErrorInternalError)
|
||||
return "", "", errors.New(awserrors.ErrorInternalError)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -837,7 +840,7 @@ func (d *identityPolicyDenialError) Error() string {
|
||||
|
||||
func (d *identityPolicyDenialError) detailedError() error {
|
||||
callerARN, err := buildCallerARN(d.principal.accountID, d.principal.identity,
|
||||
d.principal.principalType, d.principal.assumedRoleARN)
|
||||
d.principal.principalType, d.principal.assumedRoleARN, d.principal.userARN)
|
||||
if err != nil {
|
||||
callerARN = d.logIdentity
|
||||
}
|
||||
|
||||
@@ -195,8 +195,9 @@ func (gw *GatewayConfig) resolveSTSCaller(r *http.Request) (stsCaller, error) {
|
||||
assumedRoleARN, _ := ctx.Value(ctxAssumedRoleARN).(string)
|
||||
assumedRoleID, _ := ctx.Value(ctxAssumedRoleID).(string)
|
||||
accessKey, _ := ctx.Value(ctxAccessKey).(string)
|
||||
userARN, _ := ctx.Value(ctxUserARN).(string)
|
||||
|
||||
arn, err := buildCallerARN(accountID, identity, principalType, assumedRoleARN)
|
||||
arn, err := buildCallerARN(accountID, identity, principalType, assumedRoleARN, userARN)
|
||||
if err != nil {
|
||||
return stsCaller{}, err
|
||||
}
|
||||
@@ -212,8 +213,9 @@ func (gw *GatewayConfig) resolveSTSCaller(r *http.Request) (stsCaller, error) {
|
||||
}
|
||||
|
||||
// buildCallerARN composes the caller ARN: assumed-role uses ctxAssumedRoleARN,
|
||||
// root uses arn:aws:iam::{aid}:root, user uses arn:aws:iam::{aid}:user/{identity}.
|
||||
func buildCallerARN(accountID, identity, principalType, assumedRoleARN string) (string, error) {
|
||||
// root uses arn:aws:iam::{aid}:root, user uses its stored userARN, path included,
|
||||
// or arn:aws:iam::{aid}:user/{identity} when there is no record to read it from.
|
||||
func buildCallerARN(accountID, identity, principalType, assumedRoleARN, userARN string) (string, error) {
|
||||
switch principalType {
|
||||
case principalTypeAssumedRole:
|
||||
if assumedRoleARN == "" {
|
||||
@@ -231,6 +233,9 @@ func buildCallerARN(accountID, identity, principalType, assumedRoleARN string) (
|
||||
slog.Error("STS_Request: user principal without identity")
|
||||
return "", errors.New(awserrors.ErrorInternalError)
|
||||
}
|
||||
if userARN != "" {
|
||||
return userARN, nil
|
||||
}
|
||||
return spxarn.FormatIAMPath(spxarn.IAMUser, accountID, "/", identity), nil
|
||||
default:
|
||||
slog.Error("STS_Request: unknown principal type", "principalType", principalType)
|
||||
|
||||
@@ -35,7 +35,7 @@ func GetCallerIdentity(
|
||||
iamSvc handlers_iam.IAMService,
|
||||
stsSvc handlers_sts.STSService,
|
||||
) (*sts.GetCallerIdentityOutput, error) {
|
||||
userID, err := ResolveCallerUserID(accountID, callerPrincipalType, identity, assumedRoleID, iamSvc)
|
||||
userID, _, err := ResolveCallerUser(accountID, callerPrincipalType, identity, assumedRoleID, iamSvc)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -52,38 +52,37 @@ func GetCallerIdentity(
|
||||
return stsSvc.GetCallerIdentity(accountID, callerARN, userID, input)
|
||||
}
|
||||
|
||||
// ResolveCallerUserID resolves the UserId of an authenticated principal, which
|
||||
// is also aws:userid at the authorization door. An error is a dependency fault;
|
||||
// a principal with no ID to report returns empty, which each caller reads on its
|
||||
// own terms.
|
||||
func ResolveCallerUserID(
|
||||
// ResolveCallerUser resolves the UserId of an authenticated principal, which is
|
||||
// also aws:userid at the authorization door, and for an IAM user its stored ARN.
|
||||
// An error is a dependency fault; a missing value returns empty.
|
||||
func ResolveCallerUser(
|
||||
accountID, callerPrincipalType, identity, assumedRoleID string,
|
||||
iamSvc handlers_iam.IAMService,
|
||||
) (string, error) {
|
||||
) (userID, userARN string, err error) {
|
||||
switch callerPrincipalType {
|
||||
case PrincipalTypeRoot:
|
||||
return accountID, nil
|
||||
return accountID, "", nil
|
||||
case PrincipalTypeUser:
|
||||
// Root is encoded as principalType=user + identity="root"; UserId is the account ID.
|
||||
if identity == "root" {
|
||||
return accountID, nil
|
||||
return accountID, "", nil
|
||||
}
|
||||
if iamSvc == nil {
|
||||
slog.Error("ResolveCallerUserID: IAM service not initialized")
|
||||
return "", errors.New(awserrors.ErrorInternalError)
|
||||
slog.Error("ResolveCallerUser: IAM service not initialized")
|
||||
return "", "", errors.New(awserrors.ErrorInternalError)
|
||||
}
|
||||
out, err := iamSvc.GetUser(accountID, &iam.GetUserInput{UserName: aws.String(identity)})
|
||||
if err != nil {
|
||||
return "", err
|
||||
return "", "", err
|
||||
}
|
||||
if out == nil || out.User == nil {
|
||||
return "", nil
|
||||
return "", "", nil
|
||||
}
|
||||
return aws.StringValue(out.User.UserId), nil
|
||||
return aws.StringValue(out.User.UserId), aws.StringValue(out.User.Arn), nil
|
||||
case PrincipalTypeAssumedRole:
|
||||
return assumedRoleID, nil
|
||||
return assumedRoleID, "", nil
|
||||
default:
|
||||
slog.Error("ResolveCallerUserID: unknown principal type", "principalType", callerPrincipalType)
|
||||
return "", errors.New(awserrors.ErrorInternalError)
|
||||
slog.Error("ResolveCallerUser: unknown principal type", "principalType", callerPrincipalType)
|
||||
return "", "", errors.New(awserrors.ErrorInternalError)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -442,3 +442,68 @@ func TestSTSRequest_GetCallerIdentity_User_LookupIAM(t *testing.T) {
|
||||
assert.Contains(t, xmlStr, "<Arn>arn:aws:iam::000000000000:user/alice</Arn>")
|
||||
assert.Contains(t, xmlStr, "<UserId>AIDAALICE000</UserId>")
|
||||
}
|
||||
|
||||
// A user at a non-root path is reported with its stored ARN, as AWS does,
|
||||
// resolved by the SigV4 middleware rather than reformatted at "/".
|
||||
func TestSTSRequest_GetCallerIdentity_PathedUserThroughMiddleware(t *testing.T) {
|
||||
const pathed = "arn:aws:iam::123456789012:user/eng/alice"
|
||||
encryptedSecret, err := handlers_iam.EncryptSecret(testSecretKey, testMasterKey)
|
||||
require.NoError(t, err)
|
||||
gw := &GatewayConfig{
|
||||
DisableLogging: true,
|
||||
Region: testRegion,
|
||||
STSService: &flexMockSTSService{},
|
||||
IAMService: &mockIAMService{
|
||||
masterKey: testMasterKey,
|
||||
accessKeys: map[string]*handlers_iam.AccessKey{testAccessKey: {
|
||||
AccessKeyID: testAccessKey, SecretAccessKey: encryptedSecret,
|
||||
UserName: "alice", AccountID: "123456789012", Status: "Active",
|
||||
}},
|
||||
getUserFn: func(string, *iam.GetUserInput) (*iam.GetUserOutput, error) {
|
||||
return &iam.GetUserOutput{User: &iam.User{
|
||||
UserName: aws.String("alice"), UserId: aws.String("AIDAALICE"), Arn: aws.String(pathed),
|
||||
}}, nil
|
||||
},
|
||||
},
|
||||
}
|
||||
handler := gw.SigV4AuthMiddleware()(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if err := gw.STS_Request(w, r); err != nil {
|
||||
gw.ErrorHandler(w, r, err)
|
||||
}
|
||||
}))
|
||||
|
||||
body := []byte("Action=GetCallerIdentity")
|
||||
req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(string(body)))
|
||||
req.Host = "localhost:9999"
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
signTestRequest(t, req, body, testAccessKey, testSecretKey)
|
||||
|
||||
resp := doRequest(handler, req)
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
require.Equalf(t, http.StatusOK, resp.StatusCode, "body: %s", string(b))
|
||||
assert.Contains(t, string(b), "<Arn>"+pathed+"</Arn>")
|
||||
}
|
||||
|
||||
func TestBuildCallerARN(t *testing.T) {
|
||||
const acct = "123456789012"
|
||||
cases := []struct {
|
||||
name, accountID, identity, principalType, assumedRoleARN, userARN, want string
|
||||
}{
|
||||
{"pathed user takes its stored ARN", acct, "alice", principalTypeUser, "",
|
||||
"arn:aws:iam::" + acct + ":user/eng/alice", "arn:aws:iam::" + acct + ":user/eng/alice"},
|
||||
{"user with no record formats at the root path", acct, "alice", principalTypeUser, "", "",
|
||||
"arn:aws:iam::" + acct + ":user/alice"},
|
||||
{"global root ignores a user ARN", utils.GlobalAccountID, "root", principalTypeUser, "",
|
||||
"arn:aws:iam::" + utils.GlobalAccountID + ":user/root", "arn:aws:iam::" + utils.GlobalAccountID + ":root"},
|
||||
{"root principal", acct, "", principalTypeRoot, "", "", "arn:aws:iam::" + acct + ":root"},
|
||||
{"assumed role keeps its session ARN", acct, "s", principalTypeAssumedRole,
|
||||
"arn:aws:sts::" + acct + ":assumed-role/Ops/s", "", "arn:aws:sts::" + acct + ":assumed-role/Ops/s"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got, err := buildCallerARN(tc.accountID, tc.identity, tc.principalType, tc.assumedRoleARN, tc.userARN)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tc.want, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,6 +42,7 @@ func IsSessionPrincipalVerdict(err error) bool {
|
||||
// SessionPrincipal is the live IAM record a session credential still resolves to.
|
||||
type SessionPrincipal struct {
|
||||
UserID string // user sessions
|
||||
UserARN string // user sessions
|
||||
RoleID string // role sessions
|
||||
RoleName string
|
||||
RoleARN string
|
||||
@@ -85,7 +86,7 @@ func (s *STSServiceImpl) verifySessionUser(cred *SessionCredential) (*SessionPri
|
||||
if liveUserID != cred.UserID {
|
||||
return nil, ErrSessionPrincipalReplaced
|
||||
}
|
||||
return &SessionPrincipal{UserID: liveUserID}, nil
|
||||
return &SessionPrincipal{UserID: liveUserID, UserARN: aws.StringValue(out.User.Arn)}, nil
|
||||
}
|
||||
|
||||
// verifySessionRole resolves the session's underlying role ARN and compares the
|
||||
|
||||
@@ -77,6 +77,21 @@ func TestVerifySessionPrincipal_UnchangedUser_ReturnsLiveID(t *testing.T) {
|
||||
assert.Empty(t, got.RoleID)
|
||||
}
|
||||
|
||||
// A user session reports the stored ARN, path included, so a door naming the
|
||||
// session's principal agrees with the IAM record.
|
||||
func TestVerifySessionPrincipal_PathedUser_ReturnsStoredARN(t *testing.T) {
|
||||
svc, _ := newTestSetup(t)
|
||||
_, err := svc.iamSvc.CreateUser(testCallerAccountID, &iam.CreateUserInput{
|
||||
UserName: aws.String(testCallerUserName), Path: aws.String("/eng/"),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
cred := mintUserSession(t, svc, testCallerAccountID, testCallerUserName)
|
||||
|
||||
got, err := svc.VerifySessionPrincipal(cred)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "arn:aws:iam::"+testCallerAccountID+":user/eng/"+testCallerUserName, got.UserARN)
|
||||
}
|
||||
|
||||
func TestVerifySessionPrincipal_UnchangedRole_ReturnsLiveRecord(t *testing.T) {
|
||||
svc, _ := newTestSetup(t)
|
||||
role := createRoleInAccount(t, svc, testCallerAccountID, "app", trustPolicyAllowingUser(testCallerARN()))
|
||||
|
||||
Reference in New Issue
Block a user