Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 16 additions & 2 deletions contrib/server/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -218,8 +218,22 @@ actually run the request, so neither the SigV4 signing scope nor a forged
`cloudfront:TagResource`, checked on `distribution/*` since the id is not
known yet. The `aws:RequestTag`, `aws:TagKeys` and `aws:ResourceTag` keys are
set.
- The other REST services (API Gateway, EKS and the rest) are checked at
service level for now. A request passes only when the
- EKS is checked per operation. Cluster operations, and creating or listing
nodegroups, Fargate profiles, add-ons and access entries, act on the cluster
ARN; nodegroup, Fargate profile and add-on operations on
`arn:aws:eks:<region>:<account>:<kind>/<cluster>/<name>` (cloudemu's ARNs
have no trailing id); access entry operations on the entry's ARN, or
`access-entry/<cluster>/*` when it does not exist. `ListUpdates` and
`DescribeUpdate` act on the nodegroup or add-on named by `nodegroupName` or
`addonName`, else the cluster. The `eks:` keys the requests carry are set
(`eks:kubernetesVersion`, `eks:endpointPublicAccess`,
`eks:endpointPrivateAccess`, `eks:authenticationMode`,
`eks:bootstrapClusterCreatorAdminPermissions`, `eks:loggingType/<type>`, the
access entry and access policy keys), with the tag keys. `iam:PassRole` for
the cluster and node roles is not checked yet. The Kubernetes API of a
cluster is not IAM-authorized here.
- The other REST services (API Gateway and the rest) are checked at service
level for now. A request passes only when the
caller's policies allow every action of that service on every resource, such
as `eks:*` on `*` or `AdministratorAccess`. **A fine-grained or
resource-scoped policy on one of these services is denied until that service
Expand Down
83 changes: 83 additions & 0 deletions contrib/server/enforce_authz_eks_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
package main

import (
"context"
"strings"
"testing"

"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/service/eks"
ekstypes "github.com/aws/aws-sdk-go-v2/service/eks/types"
)

func eksClient(t *testing.T, endpoint string, c aws.Credentials) *eks.Client {
t.Helper()

return eks.NewFromConfig(credsConfig(t, c), func(o *eks.Options) { o.BaseEndpoint = aws.String(endpoint) })
}

// TestEnforceAuthEKS drives the real EKS SDK against cloudemu serve with
// --enforce-auth: cluster and nodegroup operations are authorized on their
// ARNs, a missing nodegroup in scope still answers ResourceNotFoundException,
// and a nodegroup update is read only through its nodegroup.
func TestEnforceAuthEKS(t *testing.T) {
endpoint, stop := enforceAuthServer(t)
defer stop()

ctx := context.Background()
boot := clientsFor(t, endpoint, seedBootUser(t, endpoint))
admin := eksClient(t, endpoint, boot.cred)

for _, name := range []string{"team", "other"} {
_, err := admin.CreateCluster(ctx, &eks.CreateClusterInput{
Name: aws.String(name), RoleArn: aws.String("arn:aws:iam::000000000000:role/eks"),
ResourcesVpcConfig: &ekstypes.VpcConfigRequest{SubnetIds: []string{"subnet-1"}},
})
wantOK(t, "CreateCluster "+name+" as boot", err)
}

cl, err := admin.DescribeCluster(ctx, &eks.DescribeClusterInput{Name: aws.String("team")})
wantOK(t, "DescribeCluster as boot", err)

arn := aws.ToString(cl.Cluster.Arn)
ngARN := strings.Replace(arn, ":cluster/team", ":nodegroup/team/*", 1)
user := eksClient(t, endpoint, boot.newUser(t, "teamops", `{"Version":"2012-10-17","Statement":[`+
`{"Effect":"Allow","Action":["eks:DescribeCluster","eks:CreateNodegroup","eks:ListNodegroups"],"Resource":"`+arn+`"},`+
`{"Effect":"Allow","Action":["eks:DescribeNodegroup","eks:UpdateNodegroupConfig","eks:DescribeUpdate"],"Resource":"`+ngARN+`"}]}`))

_, err = user.DescribeCluster(ctx, &eks.DescribeClusterInput{Name: aws.String("team")})
wantOK(t, "DescribeCluster", err)

_, err = user.DescribeCluster(ctx, &eks.DescribeClusterInput{Name: aws.String("other")})
wantCode(t, "DescribeCluster of another cluster", err, "AccessDeniedException")

_, err = user.CreateNodegroup(ctx, &eks.CreateNodegroupInput{
ClusterName: aws.String("team"), NodegroupName: aws.String("ng"),
NodeRole: aws.String("arn:aws:iam::000000000000:role/node"), Subnets: []string{"subnet-1"},
})
wantOK(t, "CreateNodegroup", err)

_, err = user.DeleteNodegroup(ctx, &eks.DeleteNodegroupInput{ClusterName: aws.String("team"), NodegroupName: aws.String("ng")})
wantCode(t, "DeleteNodegroup", err, "AccessDeniedException")

if !strings.Contains(err.Error(), "eks:DeleteNodegroup on resource: "+strings.TrimSuffix(ngARN, "*")+"ng") {
t.Fatalf("deny message = %v", err)
}

_, err = user.DescribeNodegroup(ctx, &eks.DescribeNodegroupInput{ClusterName: aws.String("team"), NodegroupName: aws.String("gone")})
wantCode(t, "DescribeNodegroup of a missing nodegroup", err, "ResourceNotFoundException")

upd, err := user.UpdateNodegroupConfig(ctx, &eks.UpdateNodegroupConfigInput{
ClusterName: aws.String("team"), NodegroupName: aws.String("ng"),
Labels: &ekstypes.UpdateLabelsPayload{AddOrUpdateLabels: map[string]string{"k": "v"}},
})
wantOK(t, "UpdateNodegroupConfig", err)

_, err = user.DescribeUpdate(ctx, &eks.DescribeUpdateInput{
Name: aws.String("team"), UpdateId: upd.Update.Id, NodegroupName: aws.String("ng"),
})
wantOK(t, "DescribeUpdate with its nodegroup", err)

_, err = admin.DescribeUpdate(ctx, &eks.DescribeUpdateInput{Name: aws.String("team"), UpdateId: upd.Update.Id})
wantCode(t, "DescribeUpdate of a nodegroup update without its nodegroup", err, "ResourceNotFoundException")
}
1 change: 1 addition & 0 deletions contrib/server/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ require (
github.com/aws/aws-sdk-go-v2/service/cloudfront v1.64.0
github.com/aws/aws-sdk-go-v2/service/dynamodb v1.57.1
github.com/aws/aws-sdk-go-v2/service/ec2 v1.322.0
github.com/aws/aws-sdk-go-v2/service/eks v1.83.0
github.com/aws/aws-sdk-go-v2/service/elasticache v1.55.0
github.com/aws/aws-sdk-go-v2/service/iam v1.53.10
github.com/aws/aws-sdk-go-v2/service/lambda v1.90.1
Expand Down
12 changes: 12 additions & 0 deletions server/aws/authz_authoff_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,18 @@ func TestAuthOffResponsesUnchanged(t *testing.T) {
{"cloudfront unknown path", sreq{method: http.MethodGet, path: cfPath + "/ENONE/other", service: cfSigned}},
{"cloudfront wrong method", sreq{method: http.MethodPost, path: cfPath + "/ENONE/config", service: cfSigned}},
{"cloudfront bad tagging op", sreq{path: "/2020-05-31/tagging?Operation=X", service: cfSigned}},
{"eks ListClusters", sreq{method: http.MethodGet, path: eksPath, service: eksSigned}},
{"eks DescribeCluster missing", sreq{method: http.MethodGet, path: eksPath + "/none", service: eksSigned}},
{"eks wrong method", sreq{method: http.MethodPut, path: eksPath + "/none", service: eksSigned}},
{"eks unknown sub-resource", sreq{method: http.MethodGet, path: eksPath + "/none/other", service: eksSigned}},
{"eks unknown child", sreq{method: http.MethodGet, path: eksPath + "/none/other/x", service: eksSigned}},
{"eks unknown child action", sreq{path: eksPath + "/none/node-groups/ng/other", service: eksSigned}},
{"eks child action GET", sreq{method: http.MethodGet, path: eksPath + "/none/addons/a/update", service: eksSigned}},
{"eks too deep", sreq{method: http.MethodGet, path: eksPath + "/a/b/c/d/e/f", service: eksSigned}},
{"eks bad create body", sreq{path: eksPath, ctype: jsonCT, body: "{", service: eksSigned}},
{"eks DescribeNodegroup missing", sreq{method: http.MethodGet, path: eksPath + "/none/node-groups/ng", service: eksSigned}},
{"eks tags wrong method", sreq{method: http.MethodPut, path: "/tags/" + eksARN + "cluster/none", service: eksSigned}},
{"eks ListAccessPolicies", sreq{method: http.MethodGet, path: "/access-policies", service: eksSigned}},
{"unknown target", sreq{path: "/", ctype: amzJSON11, body: `{}`, service: "x",
header: map[string]string{"X-Amz-Target": "NoSuchService_2020.Op"}}},
}
Expand Down
5 changes: 3 additions & 2 deletions server/aws/authz_completeness_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -75,9 +75,9 @@ func TestHandlerIAMServicesMatchTable(t *testing.T) {
"*elasticache.Handler": "elasticache", "*elbv2.Handler": "elasticloadbalancing", "*sns.Handler": "sns",
"*cloudformation.Handler": "cloudformation", "*cloudwatch.Handler": "cloudwatch", "*ec2.Handler": "ec2",
"*sagemaker.Handler": "sagemaker", "*s3.Handler": "s3", "*lambda.Handler": "lambda",
"*route53.Handler": "route53", "*cloudfront.Handler": "cloudfront",
"*route53.Handler": "route53", "*cloudfront.Handler": "cloudfront", "*eks.Handler": "eks",
// Tier 0: REST, service level.
"*apigateway.Handler": "apigateway", "*apigatewayv2.Handler": "apigateway", "*eks.Handler": "eks",
"*apigateway.Handler": "apigateway", "*apigatewayv2.Handler": "apigateway",
"*efs.Handler": "elasticfilesystem", "*batch.Handler": "batch",
"*sesv2.Handler": "ses", "*opensearch.Handler": "es", "*appsync.Handler": "appsync", "*appflow.Handler": "appflow",
"*mwaa.Handler": "airflow", "*mq.Handler": "mq", "*codeartifact.Handler": "codeartifact", "*backup.Handler": "backup",
Expand Down Expand Up @@ -155,6 +155,7 @@ func TestHandlerIAMServicesMatchTable(t *testing.T) {
func TestOpLevelRESTHandlersAreResolvers(t *testing.T) {
want := map[string]bool{
"*s3.Handler": true, "*lambda.Handler": true, "*route53.Handler": true, "*cloudfront.Handler": true,
"*eks.Handler": true,
}

srv, _ := fullServer(t)
Expand Down
139 changes: 139 additions & 0 deletions server/aws/authz_matrix_eks_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
package aws

import (
"context"
"net/http"
"testing"

awsprovider "github.com/stackshy/cloudemu/v2/providers/aws"
eksdriver "github.com/stackshy/cloudemu/v2/providers/aws/eks/driver"
)

const (
eksPath = "/clusters"
eksARN = "arn:aws:eks:us-east-1:123456789012:"
eksSigned = "eks"
jsonCT = "application/json"
)

func newEKSCluster(t *testing.T, cloud *awsprovider.Provider, name string, tags map[string]string) {
t.Helper()

ctx := context.Background()

if _, err := cloud.EKS.CreateCluster(ctx, eksdriver.ClusterConfig{
Name: name, RoleArn: "arn:aws:iam::123456789012:role/eks", Tags: tags,
AccessConfig: eksdriver.AccessConfigRequest{AuthenticationMode: "API"},
}); err != nil {
t.Fatalf("CreateCluster: %v", err)
}

if _, err := cloud.EKS.CreateNodegroup(ctx, eksdriver.NodegroupConfig{
ClusterName: name, NodegroupName: "ng", NodeRole: "arn:aws:iam::123456789012:role/node", Subnets: []string{"subnet-1"},
}); err != nil {
t.Fatalf("CreateNodegroup: %v", err)
}
}

func nodegroupExists(cloud *awsprovider.Provider, cluster, name string) bool {
_, err := cloud.EKS.DescribeNodegroup(context.Background(), cluster, name)
return err == nil
}

// TestAuthzMatrixEKS covers cluster and nodegroup ARNs, a single Deny, a
// missing nodegroup reaching its 404, tag and version condition keys, and
// DescribeUpdate authorized on the nodegroup it names.
func TestAuthzMatrixEKS(t *testing.T) {
ts, cloud := matrixServer(t, nil)
newEKSCluster(t, cloud, "prod", map[string]string{"env": "prod"})
newEKSCluster(t, cloud, "dev", map[string]string{"env": "dev"})

get := func(path string) sreq { return sreq{method: http.MethodGet, path: eksPath + path, service: eksSigned} }

t.Run("one cluster in scope", func(t *testing.T) {
u := userWithPolicy(t, cloud, "devops", policyDoc(
stmt("Allow", "eks:DescribeCluster", eksARN+"cluster/dev"),
stmt("Allow", "eks:*Nodegroup*", eksARN+"nodegroup/dev/*")))

status, body := doSigned(t, ts, u, get("/dev"))
wantNotDenied(t, status, body)

status, body = doSigned(t, ts, u, get("/prod"))
wantDenied(t, status, body, "eks:DescribeCluster on resource: "+eksARN+"cluster/prod")

status, body = doSigned(t, ts, u, get("/dev/node-groups/ng"))
wantNotDenied(t, status, body)

status, body = doSigned(t, ts, u, get("/prod/node-groups/ng"))
wantDenied(t, status, body, accessDeny)

if status, body = doSigned(t, ts, u, get("/dev/node-groups/gone")); status != http.StatusNotFound {
t.Fatalf("missing nodegroup in scope: %d %s, want 404", status, body)
}
})

t.Run("deny one nodegroup delete", func(t *testing.T) {
u := userWithPolicy(t, cloud, "denyng", policyDoc(stmt("Allow", "*", "*"),
stmt("Deny", "eks:DeleteNodegroup", eksARN+"nodegroup/prod/*")))

status, body := doSigned(t, ts, u, sreq{method: http.MethodDelete, path: eksPath + "/prod/node-groups/ng", service: eksSigned})
wantDenied(t, status, body, "with an explicit deny")

if !nodegroupExists(cloud, "prod", "ng") {
t.Fatal("a denied DeleteNodegroup removed the nodegroup")
}

status, body = doSigned(t, ts, u, sreq{method: http.MethodDelete, path: eksPath + "/dev/node-groups/ng", service: eksSigned})
wantNotDenied(t, status, body)
})

t.Run("resource tag and version conditions", func(t *testing.T) {
u := userWithPolicy(t, cloud, "ekstags", policyDoc(
stmtCond("Allow", "eks:*", "*", `{"StringEquals":{"aws:ResourceTag/env":"dev"}}`),
stmtCond("Allow", "eks:CreateCluster", "*", `{"StringEquals":{"eks:kubernetesVersion":"1.35"}}`)))

status, body := doSigned(t, ts, u, get("/dev"))
wantNotDenied(t, status, body)

status, body = doSigned(t, ts, u, get("/prod"))
wantDenied(t, status, body, accessDeny)

create := func(name, version string) sreq {
return sreq{path: eksPath, ctype: jsonCT, service: eksSigned, body: `{"name":"` + name + `","version":"` + version +
`","roleArn":"arn:aws:iam::123456789012:role/eks","resourcesVpcConfig":{"subnetIds":["subnet-1"]}}`}
}

status, body = doSigned(t, ts, u, create("v135", "1.35"))
wantNotDenied(t, status, body)

status, body = doSigned(t, ts, u, create("v134", "1.34"))
wantDenied(t, status, body, "eks:CreateCluster")
})

t.Run("DescribeUpdate is checked on the nodegroup it names", func(t *testing.T) {
upd, err := cloud.EKS.UpdateNodegroupVersion(context.Background(), "prod", "ng", eksdriver.NodegroupVersionUpdate{})
if err != nil {
t.Fatalf("UpdateNodegroupVersion: %v", err)
}

clusterOnly := userWithPolicy(t, cloud, "clusteronly", policyDoc(stmt("Allow", "eks:DescribeUpdate", eksARN+"cluster/prod")))

status, body := doSigned(t, ts, clusterOnly, get("/prod/updates/"+upd.ID+"?nodegroupName=ng"))
wantDenied(t, status, body, "eks:DescribeUpdate on resource: "+eksARN+"nodegroup/prod/ng")

if status, body = doSigned(t, ts, clusterOnly, get("/prod/updates/"+upd.ID)); status != http.StatusNotFound {
t.Fatalf("nodegroup update read through the cluster: %d %s, want 404", status, body)
}
})

t.Run("tagging ARN of another account", func(t *testing.T) {
u := userWithPolicy(t, cloud, "ekstagger", allow("eks:ListTagsForResource"))

status, body := doSigned(t, ts, u, sreq{method: http.MethodGet, service: eksSigned,
path: "/tags/arn:aws:eks:us-east-1:999999999999:cluster/prod"})
wantDenied(t, status, body, accessDeny)

status, body = doSigned(t, ts, u, sreq{method: http.MethodGet, service: eksSigned, path: "/tags/" + eksARN + "cluster/prod"})
wantNotDenied(t, status, body)
})
}
10 changes: 9 additions & 1 deletion server/aws/authz_unknown_op_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,7 @@ func TestUnknownRESTOpHasNoSideEffect(t *testing.T) {
defer ts.Close()

boot := userWithPolicy(t, cloud, "boot", "")
limited := userWithPolicy(t, cloud, "limited", allow("route53:GetHostedZone", "cloudfront:GetDistribution"))
limited := userWithPolicy(t, cloud, "limited", allow("route53:GetHostedZone", "cloudfront:GetDistribution", "eks:DescribeCluster"))

for _, rq := range []sreq{
{method: http.MethodGet, path: r53Path + "/Z1/other", service: r53Signed},
Expand All @@ -150,6 +150,14 @@ func TestUnknownRESTOpHasNoSideEffect(t *testing.T) {
{method: http.MethodPut, path: "/2020-05-31/tagging?Resource=x", service: cfSigned},
{method: http.MethodPost, path: "/2020-05-31/tagging?Operation=Tag&Resource=arn:aws:cloudfront::999999999999:distribution/E1",
body: "<Tags/>", service: cfSigned},
{method: http.MethodGet, path: eksPath + "/c1/other", service: eksSigned},
{method: http.MethodPut, path: eksPath + "/c1", service: eksSigned},
{method: http.MethodGet, path: eksPath + "/c1/node-groups/ng/update-config", service: eksSigned},
{method: http.MethodPost, path: eksPath, ctype: jsonCT, body: "{", service: eksSigned},
{method: http.MethodPut, path: "/tags/" + eksARN + "cluster/c1", service: eksSigned},
{method: http.MethodPost, path: "/tags/arn:aws:eks:us-east-1:999999999999:cluster/c1", ctype: jsonCT,
body: `{"tags":{"k":"v"}}`, service: eksSigned},
{method: http.MethodPost, path: "/tags/c1", ctype: jsonCT, body: `{"tags":{"k":"v"}}`, service: eksSigned},
} {
t.Run(rq.method+" "+rq.path, func(t *testing.T) {
before := providerState(t, cloud)
Expand Down
5 changes: 3 additions & 2 deletions server/aws/aws.go
Original file line number Diff line number Diff line change
Expand Up @@ -450,7 +450,8 @@ type Drivers struct {
// only when that policy grants public access. Route 53 is checked per
// operation on hosted zone, health check and change ARNs, with the record
// set and VPC condition keys; CloudFront per operation on distribution
// ARNs. The other REST services are checked at service level for now: only a grant covering
// ARNs; EKS per operation on cluster, nodegroup, Fargate profile, add-on and
// access entry ARNs. The other REST services are checked at service level for now: only a grant covering
// every action of the service (such as s3:* or AdministratorAccess) lets a
// request through, so a fine-grained or resource-scoped REST policy fails
// closed until that service gets per-operation checks. The account root and
Expand Down Expand Up @@ -1219,7 +1220,7 @@ func newServer(d Drivers) (*server.Server, authzSets) {
// otherwise claim the same path. EKS's Matches predicate is rooted
// at /clusters specifically so it doesn't shadow other REST URLs.
if d.EKS != nil {
eksHandler := eks.New(d.EKS)
eksHandler := eks.New(d.EKS, eks.WithScope(d.AccountID, d.Region))
eksHandler.SetIdentities(identities)
srv.Register(eksHandler)
}
Expand Down
Loading
Loading