mirror of
https://github.com/mulgadc/spinifex.git
synced 2026-10-15 17:11:30 +00:00
246 lines
9.5 KiB
Go
246 lines
9.5 KiB
Go
//go:build e2e
|
|
|
|
package rds
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/aws/aws-sdk-go/aws"
|
|
"github.com/aws/aws-sdk-go/service/iam"
|
|
"github.com/aws/aws-sdk-go/service/rds"
|
|
"github.com/mulgadc/spinifex/tests/e2e/harness"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
// TestAuthorization drives the four layers that stand between a caller and a DB
|
|
// instance, each with the narrowest principal that can prove it: an action-level
|
|
// grant, the internal-action principal gate, a resource-scoped grant, and tenant
|
|
// isolation.
|
|
//
|
|
// It needs its own principals because the suite's own credentials are an
|
|
// account administrator, against which every one of these checks passes.
|
|
func TestAuthorization(t *testing.T) {
|
|
f := requireRDSFixture(t)
|
|
t.Parallel()
|
|
// The instance under test, plus the second tenant's short-lived twin.
|
|
reserveDBVMs(t, dbClass, dbClass)
|
|
|
|
suffix := time.Now().Unix()
|
|
id := fmt.Sprintf("%s-authz-%d", dbInstancePfx, suffix)
|
|
|
|
harness.Phase(t, "Creating DB instance %q for the authorization matrix", id)
|
|
createDBInstance(t, f, id)
|
|
instance := waitForAvailable(t, f, id)
|
|
arn := aws.StringValue(instance.DBInstanceArn)
|
|
require.NotEmpty(t, arn, "an available instance must publish its ARN")
|
|
|
|
// The grant a read-only dashboard or a monitoring integration would carry.
|
|
t.Run("ADescribeGrantReadsButCannotCreate", func(t *testing.T) {
|
|
describer := scopedPrincipal(t, f, "rds-e2e-describer", policyStatement{
|
|
Action: []string{"rds:Describe*"}, Resource: []string{"*"},
|
|
})
|
|
|
|
listed, err := describer.RDS.DescribeDBInstances(&rds.DescribeDBInstancesInput{
|
|
DBInstanceIdentifier: aws.String(id),
|
|
})
|
|
require.NoError(t, err, "rds:Describe* must allow describe-db-instances")
|
|
require.Len(t, listed.DBInstances, 1)
|
|
|
|
expectCreateRefused(t, f, describer, "AccessDenied",
|
|
validCreateInput(fmt.Sprintf("%s-denied-%d", dbInstancePfx, suffix)))
|
|
})
|
|
|
|
// The internal actions are refused by principal class, before any policy is
|
|
// read: GetDBBootstrapConfig hands back the master password, so rds:* —
|
|
// a legitimate administrator grant — must not reach it. Asserted with a
|
|
// principal that holds rds:*, or the denial would only prove a missing grant.
|
|
t.Run("RDSStarCannotReachAnInternalAction", func(t *testing.T) {
|
|
operator := scopedPrincipal(t, f, "rds-e2e-operator", policyStatement{
|
|
Action: []string{"rds:*"}, Resource: []string{"*"},
|
|
})
|
|
|
|
_, err := operator.RDS.DescribeDBInstances(&rds.DescribeDBInstancesInput{
|
|
DBInstanceIdentifier: aws.String(id),
|
|
})
|
|
require.NoError(t, err, "rds:* must allow a customer action")
|
|
|
|
status, body, code := harness.PostRDSAction(t, f.Env, operator, "GetDBBootstrapConfig", map[string]string{
|
|
"DBInstanceIdentifier": id,
|
|
})
|
|
assert.Equal(t, 403, status, "body: %s", body)
|
|
assert.Equal(t, "AccessDenied", code, "body: %s", body)
|
|
})
|
|
|
|
// A tenant sees nothing of another tenant's instances, and the namespace is
|
|
// per-account rather than global: two tenants may hold the same identifier.
|
|
t.Run("AnotherTenantSeesNothing", func(t *testing.T) {
|
|
carousel := harness.NewAccountCarousel()
|
|
tenant := carousel.Add(t, f.Env, "rds-e2e-tenant",
|
|
harness.SpxAdminAccountCreate(t, fmt.Sprintf("RDS E2E Tenant %d", suffix), ""))
|
|
other := tenant.Client
|
|
|
|
// Not AccessDenied: the instance is not in this tenant's namespace at all,
|
|
// and an administrator of their own account is not being refused a grant.
|
|
for _, tc := range []struct {
|
|
name string
|
|
call func() error
|
|
}{
|
|
{"Describe", func() error {
|
|
_, err := harness.DescribeDBInstance(other, id)
|
|
return err
|
|
}},
|
|
{"Stop", func() error {
|
|
_, err := other.RDS.StopDBInstance(&rds.StopDBInstanceInput{DBInstanceIdentifier: aws.String(id)})
|
|
return err
|
|
}},
|
|
{"Snapshot", func() error {
|
|
_, err := other.RDS.CreateDBSnapshot(&rds.CreateDBSnapshotInput{
|
|
DBInstanceIdentifier: aws.String(id),
|
|
DBSnapshotIdentifier: aws.String(id + "-cross-account"),
|
|
})
|
|
return err
|
|
}},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
harness.ExpectError(t, "DBInstanceNotFound", tc.call)
|
|
})
|
|
}
|
|
|
|
// The same identifier in both accounts. Created and deleted without ever
|
|
// being waited for: what is under test is the record's namespace, not the
|
|
// engine behind it.
|
|
t.Run("TheSameIdentifierIsADifferentInstance", func(t *testing.T) {
|
|
twin, err := other.RDS.CreateDBInstance(validCreateInput(id)) //nolint:staticcheck // e2e:allow-create — the second tenant's own instance
|
|
require.NoError(t, err, "an identifier held by another tenant must still be available here")
|
|
t.Cleanup(func() { deleteInstanceAs(t, other, id) })
|
|
|
|
assert.Equal(t, tenant.AccountID, arnAccount(aws.StringValue(twin.DBInstance.DBInstanceArn)),
|
|
"the twin's ARN must name its own account")
|
|
assert.NotEqual(t, arn, aws.StringValue(twin.DBInstance.DBInstanceArn))
|
|
|
|
// The original is untouched by the twin's create, and still reports the
|
|
// state its own tenant left it in.
|
|
mine, err := harness.DescribeDBInstance(f.AWS, id)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, arn, aws.StringValue(mine.DBInstanceArn))
|
|
assert.Equal(t, harness.DBInstanceAvailable, aws.StringValue(mine.DBInstanceStatus))
|
|
})
|
|
})
|
|
|
|
// Last, because the grant it proves is exercised by actually stopping the
|
|
// instance: a stop that returns success and does nothing is the failure this
|
|
// subtest is here to catch.
|
|
t.Run("AResourceScopedGrantAppliesToOneInstanceOnly", func(t *testing.T) {
|
|
stopper := scopedPrincipal(t, f, "rds-e2e-stopper", policyStatement{
|
|
Action: []string{"rds:StopDBInstance"}, Resource: []string{arn},
|
|
})
|
|
|
|
// AccessDenied rather than DBInstanceNotFound: the resource check has to run
|
|
// ahead of existence, or a denial would tell a caller which identifiers are
|
|
// taken.
|
|
harness.ExpectError(t, "AccessDenied", func() error {
|
|
_, err := stopper.RDS.StopDBInstance(&rds.StopDBInstanceInput{
|
|
DBInstanceIdentifier: aws.String(id + "-elsewhere"),
|
|
})
|
|
return err
|
|
})
|
|
|
|
harness.Phase(t, "Stopping %q as the resource-scoped principal", id)
|
|
_, err := stopper.RDS.StopDBInstance(&rds.StopDBInstanceInput{DBInstanceIdentifier: aws.String(id)})
|
|
require.NoError(t, err, "the grant names this instance's ARN, so the stop must be allowed")
|
|
harness.WaitForDBInstanceStatus(t, f.AWS, id, harness.DBInstanceStopped)
|
|
})
|
|
}
|
|
|
|
// One Allow statement of an inline policy.
|
|
type policyStatement struct {
|
|
Effect string `json:"Effect"`
|
|
Action []string `json:"Action"`
|
|
Resource []string `json:"Resource"`
|
|
}
|
|
|
|
// scopedPrincipal returns a client for a fresh user in the suite's own account
|
|
// carrying exactly the statements given and nothing else, which is the only way
|
|
// to assert a grant: the suite's own principal is an administrator, so every
|
|
// denial has to be proven against a narrower one.
|
|
func scopedPrincipal(t *testing.T, f *Fixture, name string, statements ...policyStatement) *harness.AWSClient {
|
|
t.Helper()
|
|
// Suffixed so a failed run's leftovers cannot be mistaken for this run's
|
|
// principal, which would silently assert against the wrong grant.
|
|
userName := fmt.Sprintf("%s-%d", name, time.Now().UnixNano())
|
|
|
|
_, err := f.AWS.IAM.CreateUser(&iam.CreateUserInput{UserName: aws.String(userName)})
|
|
require.NoError(t, err, "create-user %s", userName)
|
|
t.Cleanup(func() {
|
|
if _, err := f.AWS.IAM.DeleteUser(&iam.DeleteUserInput{UserName: aws.String(userName)}); err != nil {
|
|
t.Logf("cleanup: delete-user %s: %v", userName, err)
|
|
}
|
|
})
|
|
|
|
for i := range statements {
|
|
statements[i].Effect = "Allow"
|
|
}
|
|
document, err := json.Marshal(map[string]any{
|
|
"Version": "2012-10-17",
|
|
"Statement": statements,
|
|
})
|
|
require.NoError(t, err, "marshal policy document")
|
|
|
|
const policyName = "rds-e2e-grant"
|
|
_, err = f.AWS.IAM.PutUserPolicy(&iam.PutUserPolicyInput{
|
|
UserName: aws.String(userName),
|
|
PolicyName: aws.String(policyName),
|
|
PolicyDocument: aws.String(string(document)),
|
|
})
|
|
require.NoError(t, err, "put-user-policy %s", userName)
|
|
t.Cleanup(func() {
|
|
if _, err := f.AWS.IAM.DeleteUserPolicy(&iam.DeleteUserPolicyInput{
|
|
UserName: aws.String(userName),
|
|
PolicyName: aws.String(policyName),
|
|
}); err != nil {
|
|
t.Logf("cleanup: delete-user-policy %s: %v", userName, err)
|
|
}
|
|
})
|
|
|
|
key, err := f.AWS.IAM.CreateAccessKey(&iam.CreateAccessKeyInput{UserName: aws.String(userName)})
|
|
require.NoError(t, err, "create-access-key %s", userName)
|
|
t.Cleanup(func() {
|
|
if _, err := f.AWS.IAM.DeleteAccessKey(&iam.DeleteAccessKeyInput{
|
|
UserName: aws.String(userName),
|
|
AccessKeyId: key.AccessKey.AccessKeyId,
|
|
}); err != nil {
|
|
t.Logf("cleanup: delete-access-key %s: %v", userName, err)
|
|
}
|
|
})
|
|
|
|
client := harness.NewAWSClientWithCreds(t, f.Env,
|
|
aws.StringValue(key.AccessKey.AccessKeyId), aws.StringValue(key.AccessKey.SecretAccessKey))
|
|
// A key is not necessarily live the instant CreateAccessKey returns, and a
|
|
// failure to authenticate is a different answer from a denial. Waiting for the
|
|
// key to authenticate keeps a propagation delay from reading as an
|
|
// authorization result.
|
|
harness.EventuallyErr(t, func() error {
|
|
_, err := client.RDS.DescribeDBInstances(&rds.DescribeDBInstancesInput{})
|
|
if err == nil || harness.ErrorCodeIs(err, "AccessDenied") {
|
|
return nil
|
|
}
|
|
return fmt.Errorf("credentials for %s are not live yet: %w", userName, err)
|
|
}, 60*time.Second, 2*time.Second)
|
|
return client
|
|
}
|
|
|
|
// The account segment of an ARN, which is what makes two identical identifiers
|
|
// in two accounts distinguishable.
|
|
func arnAccount(arn string) string {
|
|
parts := strings.Split(arn, ":")
|
|
if len(parts) < 5 {
|
|
return ""
|
|
}
|
|
return parts[4]
|
|
}
|