mirror of
https://github.com/mulgadc/spinifex.git
synced 2026-10-15 17:11:30 +00:00
596 lines
24 KiB
Go
596 lines
24 KiB
Go
//go:build e2e
|
|
|
|
package iam
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"net"
|
|
"net/netip"
|
|
"net/url"
|
|
"os"
|
|
"strconv"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/aws/aws-sdk-go/aws"
|
|
"github.com/aws/aws-sdk-go/service/ec2"
|
|
"github.com/aws/aws-sdk-go/service/iam"
|
|
"github.com/aws/aws-sdk-go/service/sts"
|
|
"github.com/mulgadc/spinifex/tests/e2e/harness"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
// The address range the "somewhere else" aws:SourceIp case grants from. It is
|
|
// reserved for documentation, so no runner can legitimately be inside it.
|
|
const authzForeignCIDR = "203.0.113.0/24"
|
|
|
|
// runIAMAuthorization drives identity-policy enforcement against a live stack:
|
|
// the resolution path every subtest exercises runs over NATS-backed IAM state,
|
|
// which the in-process tiers substitute out.
|
|
//
|
|
// Key pairs are the resource under test because ec2:CreateKeyPair is scoped by
|
|
// the caller-supplied KeyName, so a grant can name one key pair and a sibling
|
|
// request is denied without booting anything.
|
|
func runIAMAuthorization(t *testing.T, fix *Fixture) {
|
|
account := harness.IAMAccountID(t, fix.AWS)
|
|
run := fmt.Sprintf("iam-authz-%d", time.Now().UnixNano())
|
|
|
|
t.Run("AResourceScopedGrantNamesOneKeyPair", func(t *testing.T) {
|
|
granted, sibling := run+"-granted", run+"-sibling"
|
|
principal := newAuthzPrincipal(t, fix, "scoped", authzStatement{
|
|
Effect: "Allow",
|
|
Action: []string{"ec2:CreateKeyPair"},
|
|
Resource: []string{keyPairARN(account, granted)},
|
|
})
|
|
|
|
createKeyPair(t, fix, principal, granted)
|
|
|
|
harness.ExpectError(t, "UnauthorizedOperation", func() error {
|
|
_, err := principal.Client.EC2.CreateKeyPair(&ec2.CreateKeyPairInput{KeyName: aws.String(sibling)}) // e2e:allow-create — the create is the authorization probe
|
|
return err
|
|
})
|
|
// The denial has to land ahead of dispatch, or a refused create still
|
|
// consumed the name.
|
|
harness.ExpectError(t, "InvalidKeyPair.NotFound", func() error {
|
|
_, err := fix.AWS.EC2.DescribeKeyPairs(&ec2.DescribeKeyPairsInput{
|
|
KeyNames: []*string{aws.String(sibling)},
|
|
})
|
|
return err
|
|
})
|
|
})
|
|
|
|
t.Run("ADenyAppliesToTheNextRequestWithoutANewCredential", func(t *testing.T) {
|
|
principal := newAuthzPrincipal(t, fix, "deny", authzStatement{
|
|
Effect: "Allow",
|
|
Action: []string{"ec2:CreateKeyPair"},
|
|
Resource: []string{keyPairARN(account, run+"-deny-*")},
|
|
})
|
|
createKeyPair(t, fix, principal, run+"-deny-1")
|
|
|
|
denyARN := newManagedPolicy(t, fix, "deny", authzStatement{
|
|
Effect: "Deny",
|
|
Action: []string{"ec2:CreateKeyPair"},
|
|
Resource: []string{"*"},
|
|
})
|
|
_, err := fix.AWS.IAM.AttachUserPolicy(&iam.AttachUserPolicyInput{
|
|
UserName: aws.String(principal.UserName), PolicyArn: aws.String(denyARN),
|
|
})
|
|
require.NoError(t, err, "attach-user-policy %s", denyARN)
|
|
|
|
// Same client, same key: the credential is a pointer into live IAM state,
|
|
// not a capability minted when the key was issued.
|
|
harness.ExpectError(t, "UnauthorizedOperation", func() error {
|
|
_, err := principal.Client.EC2.CreateKeyPair(&ec2.CreateKeyPairInput{ // e2e:allow-create — the create is the authorization probe
|
|
KeyName: aws.String(run + "-deny-2"),
|
|
})
|
|
return err
|
|
})
|
|
|
|
_, err = fix.AWS.IAM.DetachUserPolicy(&iam.DetachUserPolicyInput{
|
|
UserName: aws.String(principal.UserName), PolicyArn: aws.String(denyARN),
|
|
})
|
|
require.NoError(t, err, "detach-user-policy %s", denyARN)
|
|
createKeyPair(t, fix, principal, run+"-deny-3")
|
|
})
|
|
|
|
t.Run("DeletingTheGrantingPolicyRevokesTheNextRequest", func(t *testing.T) {
|
|
principal := newAuthzPrincipal(t, fix, "attached")
|
|
grantARN := newManagedPolicy(t, fix, "attached", authzStatement{
|
|
Effect: "Allow",
|
|
Action: []string{"ec2:CreateKeyPair"},
|
|
Resource: []string{keyPairARN(account, run+"-attached-*")},
|
|
})
|
|
_, err := fix.AWS.IAM.AttachUserPolicy(&iam.AttachUserPolicyInput{
|
|
UserName: aws.String(principal.UserName), PolicyArn: aws.String(grantARN),
|
|
})
|
|
require.NoError(t, err, "attach-user-policy %s", grantARN)
|
|
|
|
// The attach is the principal's only grant, so it has to be live before
|
|
// its removal proves anything.
|
|
harness.EventuallyErr(t, func() error {
|
|
_, err := principal.Client.EC2.CreateKeyPair(&ec2.CreateKeyPairInput{ // e2e:allow-create — the create is the authorization probe
|
|
KeyName: aws.String(run + "-attached-1"),
|
|
})
|
|
return err
|
|
}, 30*time.Second, 2*time.Second)
|
|
t.Cleanup(func() { deleteKeyPairBestEffort(t, fix, run+"-attached-1") })
|
|
|
|
_, err = fix.AWS.IAM.DetachUserPolicy(&iam.DetachUserPolicyInput{
|
|
UserName: aws.String(principal.UserName), PolicyArn: aws.String(grantARN),
|
|
})
|
|
require.NoError(t, err, "detach-user-policy %s", grantARN)
|
|
_, err = fix.AWS.IAM.DeletePolicy(&iam.DeletePolicyInput{PolicyArn: aws.String(grantARN)})
|
|
require.NoError(t, err, "delete-policy %s", grantARN)
|
|
|
|
harness.ExpectError(t, "UnauthorizedOperation", func() error {
|
|
_, err := principal.Client.EC2.CreateKeyPair(&ec2.CreateKeyPairInput{ // e2e:allow-create — the create is the authorization probe
|
|
KeyName: aws.String(run + "-attached-2"),
|
|
})
|
|
return err
|
|
})
|
|
})
|
|
|
|
t.Run("ANewDefaultPolicyVersionAppliesToTheNextRequest", func(t *testing.T) {
|
|
policyARN := newManagedPolicy(t, fix, "version", authzStatement{
|
|
Effect: "Allow",
|
|
Action: []string{"ec2:CreateKeyPair"},
|
|
Resource: []string{keyPairARN(account, run+"-version-*")},
|
|
})
|
|
principal := newAuthzPrincipal(t, fix, "version")
|
|
_, err := fix.AWS.IAM.AttachUserPolicy(&iam.AttachUserPolicyInput{
|
|
UserName: aws.String(principal.UserName), PolicyArn: aws.String(policyARN),
|
|
})
|
|
require.NoError(t, err, "attach-user-policy %s", policyARN)
|
|
|
|
// v1 is the principal's only grant, so it has to be live before a new
|
|
// default version withdrawing it proves anything.
|
|
harness.EventuallyErr(t, func() error {
|
|
_, err := principal.Client.EC2.CreateKeyPair(&ec2.CreateKeyPairInput{ // e2e:allow-create — the create is the authorization probe
|
|
KeyName: aws.String(run + "-version-1"),
|
|
})
|
|
return err
|
|
}, 30*time.Second, 2*time.Second)
|
|
t.Cleanup(func() { deleteKeyPairBestEffort(t, fix, run+"-version-1") })
|
|
|
|
_, err = fix.AWS.IAM.CreatePolicyVersion(&iam.CreatePolicyVersionInput{
|
|
PolicyArn: aws.String(policyARN),
|
|
PolicyDocument: aws.String(authzDocument(t, authzStatement{
|
|
Effect: "Allow", Action: []string{"ec2:DescribeKeyPairs"}, Resource: []string{"*"},
|
|
})),
|
|
SetAsDefault: aws.Bool(true),
|
|
})
|
|
require.NoError(t, err, "create-policy-version %s", policyARN)
|
|
t.Cleanup(func() { deleteSecondPolicyVersionBestEffort(t, fix, policyARN) })
|
|
|
|
harness.ExpectError(t, "UnauthorizedOperation", func() error {
|
|
_, err := principal.Client.EC2.CreateKeyPair(&ec2.CreateKeyPairInput{ // e2e:allow-create — the create is the authorization probe
|
|
KeyName: aws.String(run + "-version-2"),
|
|
})
|
|
return err
|
|
})
|
|
|
|
_, err = fix.AWS.IAM.SetDefaultPolicyVersion(&iam.SetDefaultPolicyVersionInput{
|
|
PolicyArn: aws.String(policyARN), VersionId: aws.String("v1"),
|
|
})
|
|
require.NoError(t, err, "set-default-policy-version %s v1", policyARN)
|
|
createKeyPair(t, fix, principal, run+"-version-3")
|
|
})
|
|
|
|
t.Run("RevokingACredentialStopsThatKeyAlone", func(t *testing.T) {
|
|
principal := newAuthzPrincipal(t, fix, "revoke", authzStatement{
|
|
Effect: "Allow",
|
|
Action: []string{"ec2:CreateKeyPair"},
|
|
Resource: []string{keyPairARN(account, run+"-revoke-*")},
|
|
})
|
|
second := addAccessKey(t, fix, principal)
|
|
|
|
_, err := fix.AWS.IAM.DeleteAccessKey(&iam.DeleteAccessKeyInput{
|
|
UserName: aws.String(principal.UserName), AccessKeyId: aws.String(principal.KeyID),
|
|
})
|
|
require.NoError(t, err, "delete-access-key %s", principal.KeyID)
|
|
|
|
// Not UnauthorizedOperation: a deleted key no longer authenticates, so the request
|
|
// never reaches policy evaluation.
|
|
harness.ExpectError(t, "InvalidClientTokenId", func() error {
|
|
_, err := principal.Client.EC2.CreateKeyPair(&ec2.CreateKeyPairInput{ // e2e:allow-create — the create is the authorization probe
|
|
KeyName: aws.String(run + "-revoke-1"),
|
|
})
|
|
return err
|
|
})
|
|
createKeyPair(t, fix, second, run+"-revoke-2")
|
|
|
|
// A user holding keys cannot be deleted, so deleting the user is observed
|
|
// as the tail of this sequence: no credential of a deleted user survives it.
|
|
_, err = fix.AWS.IAM.DeleteAccessKey(&iam.DeleteAccessKeyInput{
|
|
UserName: aws.String(principal.UserName), AccessKeyId: aws.String(second.KeyID),
|
|
})
|
|
require.NoError(t, err, "delete-access-key %s", second.KeyID)
|
|
deleteAuthzUser(t, fix, principal.UserName)
|
|
|
|
harness.ExpectError(t, "InvalidClientTokenId", func() error {
|
|
_, err := second.Client.EC2.CreateKeyPair(&ec2.CreateKeyPairInput{ // e2e:allow-create — the create is the authorization probe
|
|
KeyName: aws.String(run + "-revoke-3"),
|
|
})
|
|
return err
|
|
})
|
|
harness.ExpectError(t, "NoSuchEntity", func() error {
|
|
_, err := fix.AWS.IAM.GetUser(&iam.GetUserInput{UserName: aws.String(principal.UserName)})
|
|
return err
|
|
})
|
|
})
|
|
|
|
t.Run("AnAssumedRoleSessionCarriesTheRolePolicies", func(t *testing.T) {
|
|
roleName := uniqueAuthzName("role")
|
|
roleARN := harness.IAMRoleARN(account, roleName)
|
|
createAuthzRole(t, fix, roleName, authzStatement{
|
|
Effect: "Allow",
|
|
Action: []string{"ec2:CreateKeyPair"},
|
|
Resource: []string{keyPairARN(account, run+"-role-*")},
|
|
})
|
|
|
|
principal := newAuthzPrincipal(t, fix, "assume",
|
|
authzStatement{
|
|
Effect: "Allow",
|
|
Action: []string{"ec2:CreateKeyPair"},
|
|
Resource: []string{keyPairARN(account, run+"-user-*")},
|
|
},
|
|
authzStatement{
|
|
Effect: "Allow",
|
|
Action: []string{"sts:AssumeRole"},
|
|
Resource: []string{roleARN},
|
|
})
|
|
|
|
createKeyPair(t, fix, principal, run+"-user-1")
|
|
harness.ExpectError(t, "UnauthorizedOperation", func() error {
|
|
_, err := principal.Client.EC2.CreateKeyPair(&ec2.CreateKeyPairInput{ // e2e:allow-create — the create is the authorization probe
|
|
KeyName: aws.String(run + "-role-1"),
|
|
})
|
|
return err
|
|
})
|
|
|
|
assumed, err := principal.Client.STS.AssumeRole(&sts.AssumeRoleInput{
|
|
RoleArn: aws.String(roleARN),
|
|
RoleSessionName: aws.String(uniqueAuthzName("session")),
|
|
})
|
|
require.NoError(t, err, "assume-role %s", roleARN)
|
|
require.NotNil(t, assumed.Credentials, "assume-role returned no credentials")
|
|
session := harness.NewAWSClientWithSessionCreds(t, fix.Env,
|
|
aws.StringValue(assumed.Credentials.AccessKeyId),
|
|
aws.StringValue(assumed.Credentials.SecretAccessKey),
|
|
aws.StringValue(assumed.Credentials.SessionToken))
|
|
|
|
createKeyPair(t, fix, &authzPrincipal{Client: session}, run+"-role-2")
|
|
// The grant that carried the assuming user does not follow the session:
|
|
// the role's policies are the whole of what it holds.
|
|
harness.ExpectError(t, "UnauthorizedOperation", func() error {
|
|
_, err := session.EC2.CreateKeyPair(&ec2.CreateKeyPairInput{ // e2e:allow-create — the create is the authorization probe
|
|
KeyName: aws.String(run + "-user-2"),
|
|
})
|
|
return err
|
|
})
|
|
})
|
|
|
|
t.Run("ASourceIPConditionSeesTheRealClientAddress", func(t *testing.T) {
|
|
here := observedClientCIDR(t, fix.Env)
|
|
|
|
granted := newAuthzPrincipal(t, fix, "sourceip-here", authzStatement{
|
|
Effect: "Allow",
|
|
Action: []string{"ec2:CreateKeyPair"},
|
|
Resource: []string{keyPairARN(account, run+"-ip-*")},
|
|
Condition: sourceIPCondition(here),
|
|
})
|
|
createKeyPair(t, fix, granted, run+"-ip-1")
|
|
|
|
elsewhere := newAuthzPrincipal(t, fix, "sourceip-elsewhere", authzStatement{
|
|
Effect: "Allow",
|
|
Action: []string{"ec2:CreateKeyPair"},
|
|
Resource: []string{keyPairARN(account, run+"-ip-*")},
|
|
Condition: sourceIPCondition(authzForeignCIDR),
|
|
})
|
|
harness.ExpectError(t, "UnauthorizedOperation", func() error {
|
|
_, err := elsewhere.Client.EC2.CreateKeyPair(&ec2.CreateKeyPairInput{ // e2e:allow-create — the create is the authorization probe
|
|
KeyName: aws.String(run + "-ip-2"),
|
|
})
|
|
return err
|
|
})
|
|
|
|
// The Deny arm: an unconditional Allow alongside it, so a Deny that failed
|
|
// to fire is a create that succeeds rather than a missing grant.
|
|
fenced := newAuthzPrincipal(t, fix, "sourceip-deny",
|
|
authzStatement{
|
|
Effect: "Allow",
|
|
Action: []string{"ec2:CreateKeyPair"},
|
|
Resource: []string{keyPairARN(account, run+"-ip-*")},
|
|
},
|
|
authzStatement{
|
|
Effect: "Deny",
|
|
Action: []string{"ec2:CreateKeyPair"},
|
|
Resource: []string{"*"},
|
|
Condition: sourceIPCondition(here),
|
|
})
|
|
harness.ExpectError(t, "UnauthorizedOperation", func() error {
|
|
_, err := fenced.Client.EC2.CreateKeyPair(&ec2.CreateKeyPairInput{ // e2e:allow-create — the create is the authorization probe
|
|
KeyName: aws.String(run + "-ip-3"),
|
|
})
|
|
return err
|
|
})
|
|
})
|
|
}
|
|
|
|
// One statement of a test principal's policy document.
|
|
type authzStatement struct {
|
|
Effect string `json:"Effect"`
|
|
Action []string `json:"Action"`
|
|
Resource []string `json:"Resource"`
|
|
Condition map[string]map[string][]string `json:"Condition,omitempty"`
|
|
}
|
|
|
|
// A test user plus a client signing as it. Client alone is populated for the
|
|
// session and second-key clients, which own no user of their own.
|
|
type authzPrincipal struct {
|
|
UserName string
|
|
KeyID string
|
|
Client *harness.AWSClient
|
|
}
|
|
|
|
// newAuthzPrincipal creates a user carrying exactly statements and nothing else,
|
|
// with one access key, and returns a client signing as it. The suite's own
|
|
// credentials are an administrator, so every denial needs a narrower principal.
|
|
func newAuthzPrincipal(t *testing.T, fix *Fixture, name string, statements ...authzStatement) *authzPrincipal {
|
|
t.Helper()
|
|
userName := uniqueAuthzName(name)
|
|
|
|
_, err := fix.AWS.IAM.CreateUser(&iam.CreateUserInput{UserName: aws.String(userName)})
|
|
require.NoError(t, err, "create-user %s", userName)
|
|
t.Cleanup(func() { deleteAuthzUserBestEffort(t, fix, userName) })
|
|
|
|
const policyName = "iam-e2e-grant"
|
|
if len(statements) > 0 {
|
|
_, err = fix.AWS.IAM.PutUserPolicy(&iam.PutUserPolicyInput{
|
|
UserName: aws.String(userName),
|
|
PolicyName: aws.String(policyName),
|
|
PolicyDocument: aws.String(authzDocument(t, statements...)),
|
|
})
|
|
require.NoError(t, err, "put-user-policy %s", userName)
|
|
}
|
|
|
|
principal := &authzPrincipal{UserName: userName}
|
|
key := addAccessKey(t, fix, principal)
|
|
principal.KeyID, principal.Client = key.KeyID, key.Client
|
|
return principal
|
|
}
|
|
|
|
// addAccessKey mints an additional key for principal's user and returns a client
|
|
// signing with it, waiting for the key to authenticate: a credential that is not
|
|
// live yet fails differently from one that is denied.
|
|
func addAccessKey(t *testing.T, fix *Fixture, principal *authzPrincipal) *authzPrincipal {
|
|
t.Helper()
|
|
out, err := fix.AWS.IAM.CreateAccessKey(&iam.CreateAccessKeyInput{
|
|
UserName: aws.String(principal.UserName),
|
|
})
|
|
require.NoError(t, err, "create-access-key %s", principal.UserName)
|
|
keyID := aws.StringValue(out.AccessKey.AccessKeyId)
|
|
t.Cleanup(func() {
|
|
if _, err := fix.AWS.IAM.DeleteAccessKey(&iam.DeleteAccessKeyInput{
|
|
UserName: aws.String(principal.UserName), AccessKeyId: aws.String(keyID),
|
|
}); err != nil {
|
|
t.Logf("cleanup: delete-access-key %s: %v", keyID, err)
|
|
}
|
|
})
|
|
|
|
client := harness.NewAWSClientWithCreds(t, fix.Env, keyID, aws.StringValue(out.AccessKey.SecretAccessKey))
|
|
harness.EventuallyErr(t, func() error {
|
|
_, err := client.EC2.DescribeKeyPairs(&ec2.DescribeKeyPairsInput{})
|
|
if err == nil || harness.ErrorCodeIs(err, "UnauthorizedOperation") {
|
|
return nil
|
|
}
|
|
return fmt.Errorf("credentials for %s are not live yet: %w", principal.UserName, err)
|
|
}, 60*time.Second, 2*time.Second)
|
|
return &authzPrincipal{UserName: principal.UserName, KeyID: keyID, Client: client}
|
|
}
|
|
|
|
// newManagedPolicy creates a customer-managed policy and returns its ARN. The
|
|
// caller owns the delete where the test asserts on it; otherwise cleanup does.
|
|
func newManagedPolicy(t *testing.T, fix *Fixture, name string, statements ...authzStatement) string {
|
|
t.Helper()
|
|
policyName := uniqueAuthzName(name)
|
|
out, err := fix.AWS.IAM.CreatePolicy(&iam.CreatePolicyInput{
|
|
PolicyName: aws.String(policyName),
|
|
PolicyDocument: aws.String(authzDocument(t, statements...)),
|
|
})
|
|
require.NoError(t, err, "create-policy %s", policyName)
|
|
arn := aws.StringValue(out.Policy.Arn)
|
|
t.Cleanup(func() {
|
|
if _, err := fix.AWS.IAM.DeletePolicy(&iam.DeletePolicyInput{PolicyArn: aws.String(arn)}); err != nil {
|
|
t.Logf("cleanup: delete-policy %s: %v", arn, err)
|
|
}
|
|
})
|
|
return arn
|
|
}
|
|
|
|
// deleteSecondPolicyVersionBestEffort restores v1 as the default and deletes v2,
|
|
// which DeletePolicy otherwise refuses with DeleteConflict.
|
|
func deleteSecondPolicyVersionBestEffort(t *testing.T, fix *Fixture, policyARN string) {
|
|
t.Helper()
|
|
if _, err := fix.AWS.IAM.SetDefaultPolicyVersion(&iam.SetDefaultPolicyVersionInput{
|
|
PolicyArn: aws.String(policyARN), VersionId: aws.String("v1"),
|
|
}); err != nil {
|
|
t.Logf("cleanup: set-default-policy-version %s v1: %v", policyARN, err)
|
|
}
|
|
if _, err := fix.AWS.IAM.DeletePolicyVersion(&iam.DeletePolicyVersionInput{
|
|
PolicyArn: aws.String(policyARN), VersionId: aws.String("v2"),
|
|
}); err != nil {
|
|
t.Logf("cleanup: delete-policy-version %s v2: %v", policyARN, err)
|
|
}
|
|
}
|
|
|
|
// createAuthzRole creates a role assumable by any principal in the account,
|
|
// carrying statements as its only inline policy. The trust policy is wide on
|
|
// purpose: what is under test is the identity policy the session resolves.
|
|
func createAuthzRole(t *testing.T, fix *Fixture, roleName string, statements ...authzStatement) {
|
|
t.Helper()
|
|
const trustPolicy = `{"Version":"2012-10-17","Statement":[` +
|
|
`{"Effect":"Allow","Principal":{"AWS":"*"},"Action":"sts:AssumeRole"}]}`
|
|
|
|
_, err := fix.AWS.IAM.CreateRole(&iam.CreateRoleInput{
|
|
RoleName: aws.String(roleName),
|
|
AssumeRolePolicyDocument: aws.String(trustPolicy),
|
|
})
|
|
require.NoError(t, err, "create-role %s", roleName)
|
|
|
|
const policyName = "iam-e2e-role-grant"
|
|
_, err = fix.AWS.IAM.PutRolePolicy(&iam.PutRolePolicyInput{
|
|
RoleName: aws.String(roleName),
|
|
PolicyName: aws.String(policyName),
|
|
PolicyDocument: aws.String(authzDocument(t, statements...)),
|
|
})
|
|
require.NoError(t, err, "put-role-policy %s", roleName)
|
|
|
|
t.Cleanup(func() {
|
|
if _, err := fix.AWS.IAM.DeleteRolePolicy(&iam.DeleteRolePolicyInput{
|
|
RoleName: aws.String(roleName), PolicyName: aws.String(policyName),
|
|
}); err != nil {
|
|
t.Logf("cleanup: delete-role-policy %s: %v", roleName, err)
|
|
}
|
|
harness.IAMDeleteRoleAndProfilesBestEffort(fix.AWS, roleName, nil)
|
|
})
|
|
}
|
|
|
|
// createKeyPair asserts principal may create name, and registers its delete.
|
|
func createKeyPair(t *testing.T, fix *Fixture, principal *authzPrincipal, name string) {
|
|
t.Helper()
|
|
_, err := principal.Client.EC2.CreateKeyPair(&ec2.CreateKeyPairInput{KeyName: aws.String(name)}) // e2e:allow-create — the create is the authorization probe
|
|
require.NoError(t, err, "create-key-pair %s must be allowed by the principal's grant", name)
|
|
t.Cleanup(func() { deleteKeyPairBestEffort(t, fix, name) })
|
|
}
|
|
|
|
// deleteKeyPairBestEffort removes a key pair as the suite administrator.
|
|
func deleteKeyPairBestEffort(t *testing.T, fix *Fixture, name string) {
|
|
t.Helper()
|
|
if _, err := fix.AWS.EC2.DeleteKeyPair(&ec2.DeleteKeyPairInput{KeyName: aws.String(name)}); err != nil {
|
|
t.Logf("cleanup: delete-key-pair %s: %v", name, err)
|
|
}
|
|
}
|
|
|
|
// deleteAuthzUser drops a user's inline policies and attachments, then the user
|
|
// itself, failing the test on error: a subtest that deletes a user asserts on it.
|
|
func deleteAuthzUser(t *testing.T, fix *Fixture, userName string) {
|
|
t.Helper()
|
|
require.NoError(t, detachAuthzUser(fix, userName), "detach policies from %s", userName)
|
|
_, err := fix.AWS.IAM.DeleteUser(&iam.DeleteUserInput{UserName: aws.String(userName)})
|
|
require.NoError(t, err, "delete-user %s", userName)
|
|
}
|
|
|
|
// deleteAuthzUserBestEffort is the cleanup form, tolerating a user a subtest
|
|
// already deleted or never fully created.
|
|
func deleteAuthzUserBestEffort(t *testing.T, fix *Fixture, userName string) {
|
|
t.Helper()
|
|
if err := detachAuthzUser(fix, userName); err != nil {
|
|
t.Logf("cleanup: detach policies from %s: %v", userName, err)
|
|
}
|
|
if _, err := fix.AWS.IAM.DeleteUser(&iam.DeleteUserInput{UserName: aws.String(userName)}); err != nil {
|
|
t.Logf("cleanup: delete-user %s: %v", userName, err)
|
|
}
|
|
}
|
|
|
|
// detachAuthzUser empties a user of the attachments that block its delete. Keys
|
|
// are owned by the cleanup addAccessKey registers, so they are not touched here.
|
|
func detachAuthzUser(fix *Fixture, userName string) error {
|
|
inline, err := fix.AWS.IAM.ListUserPolicies(&iam.ListUserPoliciesInput{UserName: aws.String(userName)})
|
|
if err != nil {
|
|
return fmt.Errorf("list-user-policies %s: %w", userName, err)
|
|
}
|
|
for _, name := range inline.PolicyNames {
|
|
if _, err := fix.AWS.IAM.DeleteUserPolicy(&iam.DeleteUserPolicyInput{
|
|
UserName: aws.String(userName), PolicyName: name,
|
|
}); err != nil {
|
|
return fmt.Errorf("delete-user-policy %s/%s: %w", userName, aws.StringValue(name), err)
|
|
}
|
|
}
|
|
attached, err := fix.AWS.IAM.ListAttachedUserPolicies(&iam.ListAttachedUserPoliciesInput{
|
|
UserName: aws.String(userName),
|
|
})
|
|
if err != nil {
|
|
return fmt.Errorf("list-attached-user-policies %s: %w", userName, err)
|
|
}
|
|
for _, p := range attached.AttachedPolicies {
|
|
if _, err := fix.AWS.IAM.DetachUserPolicy(&iam.DetachUserPolicyInput{
|
|
UserName: aws.String(userName), PolicyArn: p.PolicyArn,
|
|
}); err != nil {
|
|
return fmt.Errorf("detach-user-policy %s/%s: %w", userName, aws.StringValue(p.PolicyArn), err)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// authzDocument renders statements as a policy document.
|
|
func authzDocument(t *testing.T, statements ...authzStatement) string {
|
|
t.Helper()
|
|
document, err := json.Marshal(map[string]any{
|
|
"Version": "2012-10-17",
|
|
"Statement": statements,
|
|
})
|
|
require.NoError(t, err, "marshal policy document")
|
|
return string(document)
|
|
}
|
|
|
|
// sourceIPCondition builds the one condition block the gateway resolves from the
|
|
// request itself rather than from the principal.
|
|
func sourceIPCondition(cidr string) map[string]map[string][]string {
|
|
return map[string]map[string][]string{"IpAddress": {"aws:SourceIp": {cidr}}}
|
|
}
|
|
|
|
// keyPairARN names one key pair, or a prefix of them, in any region: the suite
|
|
// asserts on the resource segment, and the gateway anchors the region itself.
|
|
func keyPairARN(account, name string) string {
|
|
return "arn:aws:ec2:*:" + account + ":key-pair/" + name
|
|
}
|
|
|
|
// uniqueAuthzName suffixes a name so a failed run's leftovers cannot be mistaken
|
|
// for this run's principal, which would assert against the wrong grant.
|
|
func uniqueAuthzName(name string) string {
|
|
return fmt.Sprintf("iam-e2e-%s-%d", name, time.Now().UnixNano())
|
|
}
|
|
|
|
// observedClientCIDR returns the host-sized prefix of the address this process
|
|
// reaches the gateway from, read off a real connection to it. A hardcoded
|
|
// loopback would pass on a runner the gateway never sees as one.
|
|
func observedClientCIDR(t *testing.T, env *harness.Env) string {
|
|
t.Helper()
|
|
conn, err := net.DialTimeout("tcp", gatewayHostPort(t, env), 10*time.Second)
|
|
require.NoError(t, err, "dial the gateway to observe this client's address")
|
|
defer func() { _ = conn.Close() }()
|
|
|
|
local, err := netip.ParseAddrPort(conn.LocalAddr().String())
|
|
require.NoError(t, err, "parse local address %q", conn.LocalAddr())
|
|
addr := local.Addr().Unmap()
|
|
|
|
bits := addr.BitLen()
|
|
prefix := netip.PrefixFrom(addr, bits)
|
|
require.False(t, prefix.Contains(netip.MustParseAddr("203.0.113.1")),
|
|
"this runner sits inside %s, which the foreign-address case assumes is unreachable", authzForeignCIDR)
|
|
return prefix.String()
|
|
}
|
|
|
|
// gatewayHostPort resolves the address the SDK clients dial, mirroring the
|
|
// harness's own endpoint resolution.
|
|
func gatewayHostPort(t *testing.T, env *harness.Env) string {
|
|
t.Helper()
|
|
endpoint := os.Getenv("SPINIFEX_AWS_ENDPOINT")
|
|
if endpoint == "" {
|
|
host := "127.0.0.1"
|
|
if len(env.ServiceIPs) > 0 {
|
|
host = env.ServiceIPs[0]
|
|
}
|
|
return net.JoinHostPort(host, strconv.Itoa(env.AWSGWPort))
|
|
}
|
|
u, err := url.Parse(endpoint)
|
|
require.NoError(t, err, "parse SPINIFEX_AWS_ENDPOINT %q", endpoint)
|
|
port := u.Port()
|
|
if port == "" {
|
|
port = "443"
|
|
}
|
|
return net.JoinHostPort(u.Hostname(), port)
|
|
}
|