diff --git a/go.mod b/go.mod index 21f87824b..ce82fe6b1 100644 --- a/go.mod +++ b/go.mod @@ -22,7 +22,7 @@ require ( github.com/klauspost/cpuid/v2 v2.4.0 github.com/miekg/dns v1.1.73 github.com/minio/highwayhash v1.0.4 - github.com/mulgadc/bluebottle v1.20.0 + github.com/mulgadc/bluebottle v1.20.1-0.20260916040751-5a5ca6f0fb33 github.com/mulgadc/northstar v1.20.0 github.com/mulgadc/predastore v1.20.0 github.com/mulgadc/viperblock v1.20.0 diff --git a/go.sum b/go.sum index 869702be1..eee62b8da 100644 --- a/go.sum +++ b/go.sum @@ -1329,6 +1329,8 @@ github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= github.com/mulgadc/bluebottle v1.20.0 h1:Udi+aOA14CgDLfhanw4Rsa5HqkVt+/9e6mxoQWTRCNs= github.com/mulgadc/bluebottle v1.20.0/go.mod h1:4rjLL3B0xZBPmxacaXPkw6sPOZZgyuCDBFmPSOC81yE= +github.com/mulgadc/bluebottle v1.20.1-0.20260916040751-5a5ca6f0fb33 h1:Rw3mnRF2YrhVZgHpCsOi6La8cOu/V3xnhB000iWcdr8= +github.com/mulgadc/bluebottle v1.20.1-0.20260916040751-5a5ca6f0fb33/go.mod h1:4rjLL3B0xZBPmxacaXPkw6sPOZZgyuCDBFmPSOC81yE= github.com/mulgadc/northstar v1.20.0 h1:nkWTuJv1aagrJUQiZHruo7xzu9RBtLHEzrQGABElwls= github.com/mulgadc/northstar v1.20.0/go.mod h1:3GB49VUE9rA2idp88se5jAMv3ql1Oq9kIcT29WAZwDU= github.com/mulgadc/predastore v1.20.0 h1:BBk02ZWWbCGqy0PkYEXh5Yk5lbOUuDEcMS++voxSc6E= diff --git a/spinifex/gateway/conditionkeys_completeness_test.go b/spinifex/gateway/conditionkeys_completeness_test.go index 9b4875425..8e65fb99f 100644 --- a/spinifex/gateway/conditionkeys_completeness_test.go +++ b/spinifex/gateway/conditionkeys_completeness_test.go @@ -25,6 +25,7 @@ var gatewayDoorKeys = []string{ iampolicy.KeyUserID, iampolicy.KeyPrincipalAccount, iampolicy.KeySourceIP, + iampolicy.KeyPrincipalType, } // Every condition key the evaluator names, so the write-path gate below covers @@ -36,6 +37,7 @@ var allConditionKeys = []string{ iampolicy.KeyUsername, iampolicy.KeyPrincipalAccount, iampolicy.KeyUserID, + iampolicy.KeyPrincipalType, // Deliberately outside the allowlist: there is no MFA in the stack, so the // key could never be true. It is here to prove the validator says no. "aws:MultiFactorAuthPresent", diff --git a/spinifex/gateway/conditionkeys_test.go b/spinifex/gateway/conditionkeys_test.go index 471978704..1e328ef5f 100644 --- a/spinifex/gateway/conditionkeys_test.go +++ b/spinifex/gateway/conditionkeys_test.go @@ -40,9 +40,37 @@ func TestRequestConditionKeys_PopulatesAvailableKeys(t *testing.T) { iampolicy.KeyUsername: "alice", iampolicy.KeyUserID: "AIDAALICE", iampolicy.KeyPrincipalAccount: "000000000001", + iampolicy.KeyPrincipalType: iampolicy.PrincipalTypeUser, }, keys) } +// Every principal type this door recognizes maps to the canonical AWS spelling, +// and an unrecognized one omits the key rather than supplying an empty string. +func TestRequestConditionKeys_PrincipalType(t *testing.T) { + r := httptest.NewRequest(http.MethodPost, "/", nil) + + tests := []struct { + principalType string + want string + omitted bool + }{ + {principalType: principalTypeUser, want: iampolicy.PrincipalTypeUser}, + {principalType: principalTypeAssumedRole, want: iampolicy.PrincipalTypeAssumedRole}, + {principalType: principalTypeRoot, want: iampolicy.PrincipalTypeAccount}, + {principalType: "", omitted: true}, + } + for _, tt := range tests { + keys := requestConditionKeys(r, principalContext{ + identity: "alice", accountID: "000000000001", principalType: tt.principalType, + }) + if tt.omitted { + assert.NotContains(t, keys, iampolicy.KeyPrincipalType, "principalType %q", tt.principalType) + continue + } + assert.Equal(t, tt.want, keys[iampolicy.KeyPrincipalType], "principalType %q", tt.principalType) + } +} + // A role session's aws:userid is the ID STS minted for it. Both halves come from // the resolved role, so unlike aws:username it can carry a decision. func TestRequestConditionKeys_UserIDForAssumedRole(t *testing.T) { diff --git a/spinifex/gateway/gateway.go b/spinifex/gateway/gateway.go index 92ca5dad4..35a5addde 100644 --- a/spinifex/gateway/gateway.go +++ b/spinifex/gateway/gateway.go @@ -651,9 +651,32 @@ func requestConditionKeys(r *http.Request, principal principalContext) iampolicy if ip := utils.ClientIP(r.RemoteAddr); ip != "" { keys[iampolicy.KeySourceIP] = ip } + // Resolved from the credential record, never from anything the caller + // supplies, so unlike aws:username it is safe for a role session too. + if v, ok := principalTypeCondition(principal.principalType); ok { + keys[iampolicy.KeyPrincipalType] = v + } return keys } +// principalTypeCondition maps this gateway's internal principal-type spelling +// to the canonical aws:PrincipalType value AWS documents, so a policy written +// against the documented value applies here and at predastore identically. An +// empty or unrecognized principal type reports false so the door omits the +// key rather than supplying an empty string. +func principalTypeCondition(principalType string) (string, bool) { + switch principalType { + case principalTypeUser: + return iampolicy.PrincipalTypeUser, true + case principalTypeAssumedRole: + return iampolicy.PrincipalTypeAssumedRole, true + case principalTypeRoot: + return iampolicy.PrincipalTypeAccount, true + default: + return "", false + } +} + // principalUserID 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