From 72e3414ecb50ace65606785ab249f31d8c78742a Mon Sep 17 00:00:00 2001 From: Nitin Kumar Date: Fri, 2 Oct 2026 16:11:00 +0530 Subject: [PATCH 1/3] fix(auth): authorize query and REST requests under --enforce-auth Every signed AWS request is now authorized against the caller's IAM policies, bound to the handler dispatch will run. Query services, CloudWatch and SageMaker are checked per operation, JSON-RPC through X-Amz-Target only for handlers that route on it, and REST services at service level until each gets per-operation checks. --- contrib/server/README.md | 30 +- contrib/server/enforce_authz_test.go | 283 ++++++++++ contrib/server/go.mod | 8 +- internal/coveragegen/native.go | 28 +- internal/coveragegen/registration.go | 80 ++- providers/aws/iam/evaluate_modes_test.go | 15 + providers/aws/iam/simulate.go | 22 + server/aws/acm/handler.go | 4 + server/aws/aoss/handler.go | 4 + server/aws/apigateway/handler.go | 4 + server/aws/apigatewayv2/handler.go | 4 + server/aws/appflow/handler.go | 4 + server/aws/apprunner/handler.go | 4 + server/aws/appsync/handler.go | 4 + server/aws/aps/handler.go | 4 + server/aws/athena/handler.go | 4 + server/aws/authgate.go | 70 +-- server/aws/authgate_test.go | 7 + server/aws/authz_authoff_test.go | 189 +++++++ server/aws/authz_completeness_test.go | 150 +++++ server/aws/authz_matrix_test.go | 520 ++++++++++++++++++ server/aws/authz_unknown_op_test.go | 118 ++++ server/aws/authzgate.go | 453 ++++++++++----- server/aws/authzgate_cloudwatch_test.go | 17 +- server/aws/authzgate_test.go | 23 +- server/aws/aws.go | 146 +++-- server/aws/backup/handler.go | 4 + server/aws/batch/handler.go | 4 + server/aws/bedrock/handler.go | 4 + server/aws/bedrockagent/handler.go | 7 + server/aws/bedrockagentruntime/handler.go | 4 + server/aws/cloudformation/handler.go | 12 + server/aws/cloudfront/handler.go | 10 + server/aws/cloudtrail/handler.go | 4 + server/aws/cloudwatch/authz.go | 39 ++ server/aws/cloudwatch/authz_test.go | 103 ++++ server/aws/cloudwatch/handler.go | 50 +- server/aws/cloudwatch/json_protocol.go | 4 +- server/aws/cloudwatch/query.go | 15 +- server/aws/cloudwatchlogs/handler.go | 4 + server/aws/codeartifact/handler.go | 4 + server/aws/cognito/handler.go | 4 + server/aws/configservice/handler.go | 4 + server/aws/costexplorer/handler.go | 4 + server/aws/dynamodb/handler.go | 8 + server/aws/dynamodb/streams.go | 4 + server/aws/ec2/authz.go | 43 ++ server/aws/ec2/authz_test.go | 70 +++ server/aws/ec2/handler.go | 48 +- server/aws/ecr/handler.go | 4 + server/aws/ecs/handler.go | 4 + server/aws/efs/handler.go | 4 + server/aws/eks/handler.go | 4 + server/aws/elasticache/handler.go | 12 + server/aws/elbv2/handler.go | 12 + server/aws/emr/handler.go | 4 + server/aws/eventbridge/handler.go | 4 + server/aws/eventbridgescheduler/handler.go | 4 + server/aws/fis/handler.go | 4 + server/aws/globalaccelerator/handler.go | 4 + server/aws/glue/handler.go | 4 + server/aws/grafana/handler.go | 4 + server/aws/guardduty/handler.go | 4 + server/aws/healthlake/handler.go | 4 + server/aws/iam/handler.go | 12 + server/aws/kafka/handler.go | 4 + server/aws/kendra/handler.go | 4 + server/aws/keyspaces/handler.go | 4 + server/aws/kinesis/handler.go | 4 + server/aws/kinesisvideo/handler.go | 4 + server/aws/kms/handler.go | 4 + server/aws/lambda/handler.go | 4 + server/aws/location/handler.go | 4 + server/aws/memorydb/handler.go | 4 + server/aws/mq/handler.go | 4 + server/aws/mwaa/handler.go | 4 + server/aws/networkfirewall/handler.go | 4 + server/aws/opensearch/handler.go | 4 + server/aws/publicauth.go | 48 +- server/aws/publicauth_test.go | 12 + server/aws/rds/handler.go | 12 + server/aws/redshift/handler.go | 12 + server/aws/resourceexplorer2/handler.go | 4 + .../aws/resourcegroupstaggingapi/handler.go | 4 + server/aws/route53/handler.go | 10 + server/aws/route53resolver/handler.go | 4 + server/aws/s3/handler.go | 10 + server/aws/sagemaker/authz.go | 69 +++ server/aws/sagemaker/authz_test.go | 51 ++ server/aws/sagemaker/handler.go | 12 +- server/aws/savingsplans/handler.go | 4 + server/aws/secretsmanager/handler.go | 4 + server/aws/servicequotas/handler.go | 4 + server/aws/sesv2/handler.go | 4 + server/aws/sfn/handler.go | 4 + server/aws/sns/handler.go | 12 + server/aws/sqs/handler.go | 4 + server/aws/ssm/handler.go | 4 + server/aws/sts/authz.go | 78 +++ server/aws/sts/authz_test.go | 72 +++ server/aws/sts/handler.go | 11 + server/aws/testdata/authz_authoff_golden.json | 177 ++++++ server/aws/timestreamwrite/handler.go | 4 + server/aws/transfer/handler.go | 4 + server/aws/vpclattice/handler.go | 4 + server/aws/wafv2/handler.go | 4 + server/handlers_test.go | 28 + server/serveflags/serveflags.go | 6 +- server/server.go | 6 + server/wire/awsauthz/awsauthz.go | 184 +++++++ server/wire/awsauthz/awsauthz_test.go | 115 ++++ 111 files changed, 3413 insertions(+), 356 deletions(-) create mode 100644 contrib/server/enforce_authz_test.go create mode 100644 server/aws/authz_authoff_test.go create mode 100644 server/aws/authz_completeness_test.go create mode 100644 server/aws/authz_matrix_test.go create mode 100644 server/aws/authz_unknown_op_test.go create mode 100644 server/aws/cloudwatch/authz.go create mode 100644 server/aws/cloudwatch/authz_test.go create mode 100644 server/aws/ec2/authz.go create mode 100644 server/aws/ec2/authz_test.go create mode 100644 server/aws/sagemaker/authz.go create mode 100644 server/aws/sagemaker/authz_test.go create mode 100644 server/aws/sts/authz.go create mode 100644 server/aws/sts/authz_test.go create mode 100644 server/aws/testdata/authz_authoff_golden.json create mode 100644 server/handlers_test.go create mode 100644 server/wire/awsauthz/awsauthz.go create mode 100644 server/wire/awsauthz/awsauthz_test.go diff --git a/contrib/server/README.md b/contrib/server/README.md index c1aebe2e2..e1c0f3b07 100644 --- a/contrib/server/README.md +++ b/contrib/server/README.md @@ -153,7 +153,7 @@ Flag names and defaults mirror `cloudemu serve`. | `--tls-host` | — | extra SAN host/IP for the self-signed cert (repeatable) | | `--log-requests` | `false` | log every HTTP request (method, path, status, duration) | | `--quiet` | `false` | suppress the startup banner | -| `--enforce-auth` | `false` | require authentication on each request (AWS SigV4 → 403 on an unregistered key; Azure Bearer-claims) | +| `--enforce-auth` | `false` | require authentication on each request (AWS SigV4 → 403 on an unregistered key; Azure Bearer-claims), then IAM authorization for AWS (see below) | | `--endpoints-file` | *(none)* | write the resolved endpoints as JSON to this path | | `--shutdown-timeout` | `10s` | grace period for in-flight requests | @@ -162,6 +162,34 @@ data-plane (`--k8s-port`) are wired through the same `server/serverkit` assembly as `cloudemu serve`. The Kubernetes port serves HTTPS with its own self-signed serving certificate (`--tls-cert`/`--tls-key` apply only to the Azure endpoint). +### IAM authorization under `--enforce-auth` + +With `--enforce-auth`, every signed AWS request is also checked against the +caller's IAM policies. The check is bound to the service handler that will +actually run the request, so neither the SigV4 signing scope nor a forged +`X-Amz-Target` header can change which action is checked. + +- Query services (IAM, STS, EC2 and Auto Scaling, RDS, Redshift, ElastiCache, + ELBv2, SNS, CloudFormation, CloudWatch), SageMaker, and the JSON-RPC + services are checked per operation, for example `iam:CreateUser` or + `autoscaling:CreateAutoScalingGroup`. A denied EC2 call returns + `UnauthorizedOperation`, and other query services return `AccessDenied`. + `sts:GetCallerIdentity` needs no permission, and `sts:GetSessionToken` is + blocked only by an explicit `Deny`. +- REST services (S3, Lambda, API Gateway, EKS, Route 53, CloudFront 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 `s3:*` on `*` or `AdministratorAccess`. **A fine-grained or + resource-scoped REST policy (for example `s3:GetObject` on one bucket) is + denied until that service gets per-operation checks.** A `Deny` that touches + the service also denies the request. +- The account root and IAM users with no policies are unrestricted, so a + freshly created user can bootstrap others. Role sessions are always + evaluated on the role's policies. +- `/_cloudemu/*` admin endpoints, operations AWS serves without credentials + (Cognito sign-in, API Gateway invoke), and the Kubernetes data plane are not + IAM-authorized. + ## Admin, persistence & seeding At parity with `cloudemu serve`, these flags are threaded through to the shared diff --git a/contrib/server/enforce_authz_test.go b/contrib/server/enforce_authz_test.go new file mode 100644 index 000000000..d43026edb --- /dev/null +++ b/contrib/server/enforce_authz_test.go @@ -0,0 +1,283 @@ +package main + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/hex" + "errors" + "io" + "net/http" + "net/url" + "strings" + "testing" + "time" + + "github.com/aws/aws-sdk-go-v2/aws" + v4 "github.com/aws/aws-sdk-go-v2/aws/signer/v4" + awsconfig "github.com/aws/aws-sdk-go-v2/config" + "github.com/aws/aws-sdk-go-v2/credentials" + "github.com/aws/aws-sdk-go-v2/service/autoscaling" + "github.com/aws/aws-sdk-go-v2/service/dynamodb" + "github.com/aws/aws-sdk-go-v2/service/ec2" + ec2types "github.com/aws/aws-sdk-go-v2/service/ec2/types" + "github.com/aws/aws-sdk-go-v2/service/iam" + "github.com/aws/aws-sdk-go-v2/service/sqs" + sqstypes "github.com/aws/aws-sdk-go-v2/service/sqs/types" + "github.com/aws/smithy-go" +) + +func credsConfig(t *testing.T, c aws.Credentials) aws.Config { + t.Helper() + + cfg, err := awsconfig.LoadDefaultConfig(context.Background(), + awsconfig.WithRegion("us-east-1"), + awsconfig.WithCredentialsProvider(credentials.NewStaticCredentialsProvider(c.AccessKeyID, c.SecretAccessKey, "")), + awsconfig.WithRetryMaxAttempts(1), + ) + if err != nil { + t.Fatalf("aws config: %v", err) + } + + return cfg +} + +type awsClients struct { + iam *iam.Client + ddb *dynamodb.Client + ec2 *ec2.Client + as *autoscaling.Client + sqs *sqs.Client + cred aws.Credentials +} + +func clientsFor(t *testing.T, endpoint string, c aws.Credentials) awsClients { + t.Helper() + + cfg := credsConfig(t, c) + ep := aws.String(endpoint) + + return awsClients{ + iam: iam.NewFromConfig(cfg, func(o *iam.Options) { o.BaseEndpoint = ep }), + ddb: dynamodb.NewFromConfig(cfg, func(o *dynamodb.Options) { o.BaseEndpoint = ep }), + ec2: ec2.NewFromConfig(cfg, func(o *ec2.Options) { o.BaseEndpoint = ep }), + as: autoscaling.NewFromConfig(cfg, func(o *autoscaling.Options) { o.BaseEndpoint = ep }), + sqs: sqs.NewFromConfig(cfg, func(o *sqs.Options) { o.BaseEndpoint = ep }), + cred: c, + } +} + +// newUser creates an IAM user with one inline policy and returns its key. +func (c awsClients) newUser(t *testing.T, name, doc string) aws.Credentials { + t.Helper() + + ctx := context.Background() + + if _, err := c.iam.CreateUser(ctx, &iam.CreateUserInput{UserName: aws.String(name)}); err != nil { + t.Fatalf("CreateUser %s: %v", name, err) + } + + if doc != "" { + if _, err := c.iam.PutUserPolicy(ctx, &iam.PutUserPolicyInput{ + UserName: aws.String(name), PolicyName: aws.String(name + "-p"), PolicyDocument: aws.String(doc), + }); err != nil { + t.Fatalf("PutUserPolicy %s: %v", name, err) + } + } + + out, err := c.iam.CreateAccessKey(ctx, &iam.CreateAccessKeyInput{UserName: aws.String(name)}) + if err != nil { + t.Fatalf("CreateAccessKey %s: %v", name, err) + } + + return aws.Credentials{AccessKeyID: aws.ToString(out.AccessKey.AccessKeyId), SecretAccessKey: aws.ToString(out.AccessKey.SecretAccessKey)} +} + +func allowDoc(actions ...string) string { + return `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":["` + strings.Join(actions, `","`) + `"],"Resource":"*"}]}` +} + +// wantCode asserts err is the AWS API error code. +func wantCode(t *testing.T, what string, err error, code string) { + t.Helper() + + var apiErr smithy.APIError + if !errors.As(err, &apiErr) || apiErr.ErrorCode() != code { + t.Fatalf("%s: err = %v, want %s", what, err, code) + } +} + +func wantOK(t *testing.T, what string, err error) { + t.Helper() + + if err != nil { + t.Fatalf("%s: %v", what, err) + } +} + +func adminCall(t *testing.T, method, endpoint string, body []byte) []byte { + t.Helper() + + req, err := http.NewRequestWithContext(context.Background(), method, endpoint, bytes.NewReader(body)) + if err != nil { + t.Fatalf("new request: %v", err) + } + + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("%s %s: %v", method, endpoint, err) + } + defer resp.Body.Close() + + raw, _ := io.ReadAll(resp.Body) + if resp.StatusCode != http.StatusOK { + t.Fatalf("unsigned %s %s under --enforce-auth: %d %s", method, endpoint, resp.StatusCode, raw) + } + + return raw +} + +// bootstrapUser starts a server with auth off, creates a policy-less "boot" +// user with a key, and returns the whole-emulator snapshot holding it. +func bootstrapUser(t *testing.T) ([]byte, aws.Credentials) { + t.Helper() + + cfg := testConfig(t, allEnginesOff()) + cfg.Admin = true + + url, stop := startAWS(t, cfg, mustOptions(t, &cfg)) + defer stop() + + boot := clientsFor(t, url, aws.Credentials{AccessKeyID: "test", SecretAccessKey: "test"}).newUser(t, "boot", "") + + return adminCall(t, http.MethodGet, url+"/_cloudemu/snapshot", nil), boot +} + +// TestEnforceAuthAuthorizesQueryAndREST drives real SDK clients against +// cloudemu serve with --enforce-auth: IAM, EC2, Auto Scaling and SQS calls are +// authorized against the caller's policies, while the admin endpoints stay +// unsigned. +func TestEnforceAuthAuthorizesQueryAndREST(t *testing.T) { + snapshot, bootCreds := bootstrapUser(t) + + cfg := testConfig(t, allEnginesOff()) + cfg.Admin = true + cfg.EnforceAuth = true + + endpoint, stop := startAWS(t, cfg, mustOptions(t, &cfg)) + defer stop() + + adminCall(t, http.MethodGet, endpoint+"/_cloudemu/health", nil) + adminCall(t, http.MethodPost, endpoint+"/_cloudemu/snapshot", snapshot) + + ctx := context.Background() + boot := clientsFor(t, endpoint, bootCreds) + + t.Run("iam", func(t *testing.T) { + limited := clientsFor(t, endpoint, boot.newUser(t, "limited", allowDoc("dynamodb:*"))) + + _, err := limited.iam.CreateUser(ctx, &iam.CreateUserInput{UserName: aws.String("evil")}) + wantCode(t, "CreateUser", err, "AccessDenied") + _, err = limited.iam.ListUsers(ctx, &iam.ListUsersInput{}) + wantCode(t, "ListUsers", err, "AccessDenied") + _, err = limited.iam.CreateAccessKey(ctx, &iam.CreateAccessKeyInput{UserName: aws.String("limited")}) + wantCode(t, "CreateAccessKey", err, "AccessDenied") + _, err = limited.ddb.ListTables(ctx, &dynamodb.ListTablesInput{}) + wantOK(t, "ListTables", err) + + _, err = boot.iam.PutUserPolicy(ctx, &iam.PutUserPolicyInput{ + UserName: aws.String("limited"), PolicyName: aws.String("list"), PolicyDocument: aws.String(allowDoc("iam:ListUsers")), + }) + wantOK(t, "PutUserPolicy", err) + + _, err = limited.iam.ListUsers(ctx, &iam.ListUsersInput{}) + wantOK(t, "ListUsers after the grant", err) + _, err = limited.iam.CreateUser(ctx, &iam.CreateUserInput{UserName: aws.String("evil")}) + wantCode(t, "CreateUser after the grant", err, "AccessDenied") + + if _, err := boot.iam.GetUser(ctx, &iam.GetUserInput{UserName: aws.String("evil")}); err == nil { + t.Fatal("a denied CreateUser created the user") + } + }) + + t.Run("ec2", func(t *testing.T) { + viewer := clientsFor(t, endpoint, boot.newUser(t, "ec2viewer", allowDoc("ec2:Describe*"))) + + _, err := viewer.ec2.DescribeInstances(ctx, &ec2.DescribeInstancesInput{}) + wantOK(t, "DescribeInstances", err) + _, err = viewer.ec2.RunInstances(ctx, &ec2.RunInstancesInput{ + ImageId: aws.String("ami-12345678"), InstanceType: ec2types.InstanceTypeT3Micro, + MinCount: aws.Int32(1), MaxCount: aws.Int32(1), + }) + wantCode(t, "RunInstances", err, "UnauthorizedOperation") + _, err = viewer.as.CreateAutoScalingGroup(ctx, &autoscaling.CreateAutoScalingGroupInput{ + AutoScalingGroupName: aws.String("g"), MinSize: aws.Int32(0), MaxSize: aws.Int32(1), + LaunchConfigurationName: aws.String("lc"), AvailabilityZones: []string{"us-east-1a"}, + }) + wantCode(t, "CreateAutoScalingGroup", err, "AccessDenied") + }) + + t.Run("sqs", func(t *testing.T) { + q, err := boot.sqs.CreateQueue(ctx, &sqs.CreateQueueInput{QueueName: aws.String("q1")}) + wantOK(t, "CreateQueue as boot", err) + + sender := clientsFor(t, endpoint, boot.newUser(t, "sender", allowDoc("sqs:SendMessage"))) + + _, err = sender.sqs.SendMessage(ctx, &sqs.SendMessageInput{QueueUrl: q.QueueUrl, MessageBody: aws.String("hi")}) + wantOK(t, "SendMessage", err) + _, err = sender.sqs.CreateQueue(ctx, &sqs.CreateQueueInput{QueueName: aws.String("q2")}) + wantCode(t, "CreateQueue", err, "AccessDeniedException") + + // A query-form SendMessage is served by EC2's catch-all, never by SQS, + // and is authorized as ec2:SendMessage. + status, body := signedForm(t, endpoint, sender.cred, "sqs", url.Values{ + "Action": {"SendMessage"}, "QueueUrl": {aws.ToString(q.QueueUrl)}, "MessageBody": {"forged"}, + }) + if status != http.StatusForbidden || !strings.Contains(body, "ec2:SendMessage") { + t.Fatalf("query-form SendMessage: %d %s", status, body) + } + + attrs, err := boot.sqs.GetQueueAttributes(ctx, &sqs.GetQueueAttributesInput{ + QueueUrl: q.QueueUrl, AttributeNames: []sqstypes.QueueAttributeName{sqstypes.QueueAttributeNameApproximateNumberOfMessages}, + }) + wantOK(t, "GetQueueAttributes", err) + + if n := attrs.Attributes[string(sqstypes.QueueAttributeNameApproximateNumberOfMessages)]; n != "1" { + t.Fatalf("queue holds %s messages, want 1", n) + } + }) + + t.Run("admin reset stays unsigned", func(t *testing.T) { + adminCall(t, http.MethodPost, endpoint+"/_cloudemu/reset", nil) + }) +} + +// signedForm sends a SigV4-signed query-protocol POST and returns the status +// and body. +func signedForm(t *testing.T, endpoint string, c aws.Credentials, service string, form url.Values) (int, string) { + t.Helper() + + body := form.Encode() + + req, err := http.NewRequestWithContext(context.Background(), http.MethodPost, endpoint+"/", strings.NewReader(body)) + if err != nil { + t.Fatalf("new request: %v", err) + } + + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + + sum := sha256.Sum256([]byte(body)) + if err := v4.NewSigner().SignHTTP(context.Background(), c, req, hex.EncodeToString(sum[:]), service, "us-east-1", time.Now()); err != nil { + t.Fatalf("sign: %v", err) + } + + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("do: %v", err) + } + defer resp.Body.Close() + + raw, _ := io.ReadAll(resp.Body) + + return resp.StatusCode, string(raw) +} diff --git a/contrib/server/go.mod b/contrib/server/go.mod index 444caf235..932ca042c 100644 --- a/contrib/server/go.mod +++ b/contrib/server/go.mod @@ -12,8 +12,14 @@ require ( github.com/aws/aws-sdk-go-v2 v1.46.0 github.com/aws/aws-sdk-go-v2/config v1.32.38 github.com/aws/aws-sdk-go-v2/credentials v1.19.37 + github.com/aws/aws-sdk-go-v2/service/autoscaling v1.66.1 + 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/elasticache v1.55.0 + github.com/aws/aws-sdk-go-v2/service/iam v1.53.10 github.com/aws/aws-sdk-go-v2/service/rds v1.124.4 + github.com/aws/aws-sdk-go-v2/service/sqs v1.42.27 + github.com/aws/smithy-go v1.28.1 github.com/lib/pq v1.10.9 github.com/redis/go-redis/v9 v9.22.0 github.com/stackshy/cloudemu/v2 v2.5.0 @@ -36,6 +42,7 @@ require ( github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.8.2 // indirect github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.39 // indirect github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.17 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/endpoint-discovery v1.13.2 // indirect github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.38 // indirect github.com/aws/aws-sdk-go-v2/service/keyspaces v1.28.2 // indirect github.com/aws/aws-sdk-go-v2/service/memorydb v1.37.2 // indirect @@ -43,7 +50,6 @@ require ( github.com/aws/aws-sdk-go-v2/service/sso v1.33.7 // indirect github.com/aws/aws-sdk-go-v2/service/ssooidc v1.38.7 // indirect github.com/aws/aws-sdk-go-v2/service/sts v1.45.7 // indirect - github.com/aws/smithy-go v1.28.1 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/felixge/httpsnoop v1.0.4 // indirect diff --git a/internal/coveragegen/native.go b/internal/coveragegen/native.go index 2a84e9779..de2922da6 100644 --- a/internal/coveragegen/native.go +++ b/internal/coveragegen/native.go @@ -218,10 +218,11 @@ func providerHandlerIndex( } // registeredHandlerPkgs returns the top-level handler packages under -// server// that the provider's server New() constructs (any pkg.NewXxx -// call). Handlers registered inside helper functions are intentionally omitted: -// they are sub-components of a service already counted (e.g. Databricks -// data-plane handlers), not distinct services. +// server// that the provider's server factory constructs (any +// pkg.NewXxx call). The factory is New plus the functions it hands its whole +// Drivers to (see factoryFuncs). Handlers registered inside other helper +// functions are intentionally omitted: they are sub-components of a service +// already counted (e.g. Databricks data-plane handlers), not distinct services. func registeredHandlerPkgs(root, prov string) (map[string]bool, error) { factory := filepath.Join(root, "server", prov, prov+".go") @@ -235,20 +236,17 @@ func registeredHandlerPkgs(root, prov string) (map[string]bool, error) { aliases := importAliases(file) out := map[string]bool{} - fn := findFunc(file, "New") - if fn == nil || fn.Body == nil { - return out, nil - } - marker := "/server/" + prov + "/" - ast.Inspect(fn.Body, func(n ast.Node) bool { - if pkg := handlerCallPkg(n, aliases, marker); pkg != "" { - out[pkg] = true - } + for _, fn := range factoryFuncs(file) { + ast.Inspect(fn.Body, func(n ast.Node) bool { + if pkg := handlerCallPkg(n, aliases, marker); pkg != "" { + out[pkg] = true + } - return true - }) + return true + }) + } return out, nil } diff --git a/internal/coveragegen/registration.go b/internal/coveragegen/registration.go index 24b77035e..29f9316f5 100644 --- a/internal/coveragegen/registration.go +++ b/internal/coveragegen/registration.go @@ -91,38 +91,82 @@ func registeredServices(root, prov string) (map[string]bool, error) { return out, nil } -// usedDriversFields returns the field names referenced as `.Field` -// anywhere in New's body, where is New's by-value Drivers parameter. -// A field only declared on Drivers but never read in New backs no handler. +// usedDriversFields returns the field names referenced as `.Field` in +// the server factory (see factoryFuncs), where is each function's +// by-value Drivers parameter. A field only declared on Drivers but never read +// backs no handler. func usedDriversFields(file *ast.File) map[string]bool { out := map[string]bool{} - fn := findFunc(file, "New") - if fn == nil || fn.Body == nil { - return out - } + for _, fn := range factoryFuncs(file) { + param := driversParamName(fn) - param := driversParamName(fn) - if param == "" { - return out - } + ast.Inspect(fn.Body, func(n ast.Node) bool { + if sel, ok := n.(*ast.SelectorExpr); ok { + if id, isIdent := sel.X.(*ast.Ident); isIdent && id.Name == param { + out[sel.Sel.Name] = true + } + } - ast.Inspect(fn.Body, func(n ast.Node) bool { - sel, ok := n.(*ast.SelectorExpr) - if !ok { return true + }) + } + + return out +} + +// factoryFuncs returns the functions that build a provider's wire server: New, +// plus any top-level function of the same file New hands its Drivers to (for +// example `newServer(d)`), followed transitively. Each has a body and a +// Drivers parameter. +func factoryFuncs(file *ast.File) []*ast.FuncDecl { + var out []*ast.FuncDecl + + seen := map[string]bool{} + + var visit func(fn *ast.FuncDecl) + + visit = func(fn *ast.FuncDecl) { + if fn == nil || fn.Body == nil || seen[fn.Name.Name] { + return } - if id, isIdent := sel.X.(*ast.Ident); isIdent && id.Name == param { - out[sel.Sel.Name] = true + seen[fn.Name.Name] = true + + param := driversParamName(fn) + if param == "" { + return } - return true - }) + out = append(out, fn) + + ast.Inspect(fn.Body, func(n ast.Node) bool { + if call, ok := n.(*ast.CallExpr); ok { + if callee, isIdent := call.Fun.(*ast.Ident); isIdent && passesIdent(call, param) { + visit(findFunc(file, callee.Name)) + } + } + + return true + }) + } + + visit(findFunc(file, "New")) return out } +// passesIdent reports whether call passes the identifier name as an argument. +func passesIdent(call *ast.CallExpr, name string) bool { + for _, arg := range call.Args { + if id, ok := arg.(*ast.Ident); ok && id.Name == name { + return true + } + } + + return false +} + // findFunc returns the top-level, non-method function declaration named // name, or nil. func findFunc(file *ast.File, name string) *ast.FuncDecl { diff --git a/providers/aws/iam/evaluate_modes_test.go b/providers/aws/iam/evaluate_modes_test.go index f722981ca..7d915602b 100644 --- a/providers/aws/iam/evaluate_modes_test.go +++ b/providers/aws/iam/evaluate_modes_test.go @@ -476,3 +476,18 @@ func TestCouldMatchService(t *testing.T) { } } } + +func TestServiceWideRejectsMalformedService(t *testing.T) { + m := newTestMock() + ctx := context.Background() + + userWithDocs(t, m, "root-like", polDoc(polStmt("Allow", map[string]any{"Action": "*", "Resource": "*"}))) + + for _, svc := range []string{"", "a:b", "s3:*", "*", "s*", "S3", "-s3", "s3 "} { + assertEqual(t, driver.DecisionImplicitDeny, m.EvaluateServiceWide(ctx, "root-like", svc, nil)) + } + + for _, svc := range []string{"s3", "resource-explorer-2", "ec2"} { + assertEqual(t, driver.DecisionAllowed, m.EvaluateServiceWide(ctx, "root-like", svc, nil)) + } +} diff --git a/providers/aws/iam/simulate.go b/providers/aws/iam/simulate.go index f7c25b6d1..d6881dbbb 100644 --- a/providers/aws/iam/simulate.go +++ b/providers/aws/iam/simulate.go @@ -291,11 +291,33 @@ func (m *Mock) EvaluatePermission(_ context.Context, req driver.EvalRequest) dri func (m *Mock) EvaluateServiceWide( _ context.Context, principal, service string, condCtx map[string]string, ) driver.Decision { + // A name that is not a service prefix ("", "a:b", "s*") would let a + // wildcard Allow match it, so it is never allowed. + if !isServicePrefix(service) { + return driver.DecisionImplicitDeny + } + q := evalRequest{service: service, cctx: ConditionContext(condCtx)} return driver.Decision(m.evaluatePrincipal(principal, q, evalServiceWide)) } +// isServicePrefix reports whether s has the shape of an IAM service prefix: +// lower-case letters, digits and hyphens, starting with a letter or digit. +func isServicePrefix(s string) bool { + if s == "" || s[0] == '-' { + return false + } + + for _, c := range s { + if (c < 'a' || c > 'z') && (c < '0' || c > '9') && c != '-' { + return false + } + } + + return true +} + // evaluatePrincipal combines a principal's identity policies with its // permissions boundary, both evaluated in the same mode. An explicit Deny in // either wins; otherwise both must allow. diff --git a/server/aws/acm/handler.go b/server/aws/acm/handler.go index f0e3200a9..ff944efb5 100644 --- a/server/aws/acm/handler.go +++ b/server/aws/acm/handler.go @@ -118,3 +118,7 @@ func writeErr(w http.ResponseWriter, err error) { wire.WriteJSONError(w, http.StatusInternalServerError, "InternalException", msg) } } + +// IAMService returns the IAM service prefix of the operations this handler +// serves. +func (*Handler) IAMService() string { return "acm" } diff --git a/server/aws/aoss/handler.go b/server/aws/aoss/handler.go index 24415ceb4..826d45337 100644 --- a/server/aws/aoss/handler.go +++ b/server/aws/aoss/handler.go @@ -120,3 +120,7 @@ func statusFor(exception string) int { return http.StatusBadRequest } + +// IAMService returns the IAM service prefix of the operations this handler +// serves. +func (*Handler) IAMService() string { return "aoss" } diff --git a/server/aws/apigateway/handler.go b/server/aws/apigateway/handler.go index f08058447..d8021cd58 100644 --- a/server/aws/apigateway/handler.go +++ b/server/aws/apigateway/handler.go @@ -697,3 +697,7 @@ func writeErr(w http.ResponseWriter, err error) { writeError(w, http.StatusInternalServerError, "ApiGatewayException", msg) } } + +// IAMService returns the IAM service prefix of the operations this handler +// serves. +func (*Handler) IAMService() string { return "apigateway" } diff --git a/server/aws/apigatewayv2/handler.go b/server/aws/apigatewayv2/handler.go index fd6163366..b4aa34c43 100644 --- a/server/aws/apigatewayv2/handler.go +++ b/server/aws/apigatewayv2/handler.go @@ -306,3 +306,7 @@ func writeErr(w http.ResponseWriter, err error) { writeError(w, http.StatusInternalServerError, "InternalServerErrorException", msg) } } + +// IAMService returns the IAM service prefix of the operations this handler +// serves. +func (*Handler) IAMService() string { return "apigateway" } diff --git a/server/aws/appflow/handler.go b/server/aws/appflow/handler.go index 470455639..e8d25a015 100644 --- a/server/aws/appflow/handler.go +++ b/server/aws/appflow/handler.go @@ -172,3 +172,7 @@ func splitPath(p string) []string { return out } + +// IAMService returns the IAM service prefix of the operations this handler +// serves. +func (*Handler) IAMService() string { return "appflow" } diff --git a/server/aws/apprunner/handler.go b/server/aws/apprunner/handler.go index 3df3084e4..afe017a50 100644 --- a/server/aws/apprunner/handler.go +++ b/server/aws/apprunner/handler.go @@ -139,3 +139,7 @@ func mapWire[T any, W any](items []T, conv func(T) W) []W { return out } + +// IAMService returns the IAM service prefix of the operations this handler +// serves. +func (*Handler) IAMService() string { return "apprunner" } diff --git a/server/aws/appsync/handler.go b/server/aws/appsync/handler.go index 04d284a8d..5d601d9f5 100644 --- a/server/aws/appsync/handler.go +++ b/server/aws/appsync/handler.go @@ -206,3 +206,7 @@ func atoiDefault(s string, def int32) int32 { return int32(n) } + +// IAMService returns the IAM service prefix of the operations this handler +// serves. +func (*Handler) IAMService() string { return "appsync" } diff --git a/server/aws/aps/handler.go b/server/aws/aps/handler.go index 204030738..5c99bcc7b 100644 --- a/server/aws/aps/handler.go +++ b/server/aws/aps/handler.go @@ -229,3 +229,7 @@ func splitPath(p string) []string { return out } + +// IAMService returns the IAM service prefix of the operations this handler +// serves. +func (*Handler) IAMService() string { return "aps" } diff --git a/server/aws/athena/handler.go b/server/aws/athena/handler.go index 67217e98a..892b65e31 100644 --- a/server/aws/athena/handler.go +++ b/server/aws/athena/handler.go @@ -127,3 +127,7 @@ func writeErr(w http.ResponseWriter, err error) { wire.WriteJSONError(w, http.StatusInternalServerError, athenadriver.ExInternalServer, msg) } } + +// IAMService returns the IAM service prefix of the operations this handler +// serves. +func (*Handler) IAMService() string { return "athena" } diff --git a/server/aws/authgate.go b/server/aws/authgate.go index 9ed13192e..f9bb671af 100644 --- a/server/aws/authgate.go +++ b/server/aws/authgate.go @@ -11,6 +11,7 @@ import ( "github.com/stackshy/cloudemu/v2/server/authctx" stssrv "github.com/stackshy/cloudemu/v2/server/aws/sts" "github.com/stackshy/cloudemu/v2/server/wire" + "github.com/stackshy/cloudemu/v2/server/wire/awsauthz" "github.com/stackshy/cloudemu/v2/server/wire/awsquery" "github.com/stackshy/cloudemu/v2/server/wire/sigv4" iamdriver "github.com/stackshy/cloudemu/v2/services/iam/driver" @@ -23,45 +24,54 @@ import ( // is rejected. const tempCredentialPrefix = "ASIA" -// newAuthGate builds the SigV4 authentication pre-dispatch hook. It buffers and -// restores the request body (downstream Matches/ParseForm read it), resolves -// the caller's secret via the IAM access-key resolver (long-term AKIA keys) or -// the STS session store (temporary ASIA credentials), verifies the signature, -// and either attaches the resolved principal to the request context (proceed) -// or writes a 403 AWS error (stop). clock drives timestamp-expiry evaluation. -// match is the dispatcher's handler lookup. It binds the public-operation -// exemption (see exemptPublic) to the handler that will actually serve the -// request. -func newAuthGate( - iamDriver iamdriver.IAM, accountID string, sessions *stssrv.SessionStore, clock config.Clock, - match func(*http.Request) server.Handler, -) func(http.ResponseWriter, *http.Request) (*http.Request, bool) { - resolver, _ := iamDriver.(iamdriver.AccessKeyResolver) - - if clock == nil { - clock = config.RealClock{} +// gateConfig is what the auth gate needs from the server it guards. +type gateConfig struct { + iam iamdriver.IAM + scope awsauthz.Scope + sessions *stssrv.SessionStore + clock config.Clock + // match is the dispatcher's handler lookup. The public exemption and the + // authorization plan are bound to the handler it returns. + match func(*http.Request) server.Handler + // jsonRPC holds the handlers whose operation is named by X-Amz-Target + // (see jsonRPCServiceByTarget). The header is never read for any other + // handler, so a forged one cannot steer the authorized action. + jsonRPC map[server.Handler]bool + // authnOnly holds the handlers IAM does not govern (the Kubernetes data + // plane, which has its own RBAC). Signed requests to them are only + // authenticated. + authnOnly map[server.Handler]bool +} + +// newAuthGate builds the SigV4 pre-dispatch hook. It buffers and restores the +// request body (downstream Matches/ParseForm read it), finds the handler +// dispatch will pick, lets that handler's public (noAuth) operations through, +// resolves the caller's secret via the IAM access-key resolver (long-term +// AKIA keys) or the STS session store (temporary ASIA credentials), verifies +// the signature, and then authorizes the request against the caller's IAM +// policies. It either attaches the principal to the request context +// (proceed) or writes a 403 AWS error (stop). +func newAuthGate(g *gateConfig) func(http.ResponseWriter, *http.Request) (*http.Request, bool) { + resolver, _ := g.iam.(iamdriver.AccessKeyResolver) + + if g.clock == nil { + g.clock = config.RealClock{} } return func(w http.ResponseWriter, r *http.Request) (*http.Request, bool) { body := drainBody(r) restore := func() { r.Body = io.NopCloser(bytes.NewReader(body)) } - // Operations AWS serves without SigV4 (noAuth) skip authentication and - // authorization. The handler lookup may read the body, so restore it - // before and after. restore() - public := exemptPublic(r, body, match) - - restore() + probe, h, probed := probeRoute(r, body, g.match) - if public { + if probed && servedPublicly(probe, h) { return r, true } akid := sigv4.AccessKeyID(r) if akid == "" { - restore() writeAuthError(w, r, &sigv4.AuthError{ Code: "MissingAuthenticationToken", Message: "Request is missing Authentication Token", @@ -83,9 +93,9 @@ func newAuthGate( ) if strings.HasPrefix(akid, tempCredentialPrefix) { - principal, roleSession, aerr = verifyTempCredential(r, body, akid, accountID, sessions, clock) + principal, roleSession, aerr = verifyTempCredential(r, body, akid, g.scope.AccountID, g.sessions, g.clock) } else { - principal, aerr = sigv4.Verify(r, body, resolverLookup(r, resolver), clock) + principal, aerr = sigv4.Verify(r, body, resolverLookup(r, resolver), g.clock) } restore() @@ -95,11 +105,9 @@ func newAuthGate( return r, false } - if !authorize(w, r, principal, iamDriver, body, accountID, roleSession) { - return r, false - } + plan := g.resolvePlan(probe, h, probed, body) - return withPrincipal(r, principal), true + return g.authorize(w, r, h, plan, &principal, roleSession) } } diff --git a/server/aws/authgate_test.go b/server/aws/authgate_test.go index 9d9a43457..6d7397f22 100644 --- a/server/aws/authgate_test.go +++ b/server/aws/authgate_test.go @@ -16,6 +16,7 @@ import ( cloudemu "github.com/stackshy/cloudemu/v2" "github.com/stackshy/cloudemu/v2/server/authctx" + "github.com/stackshy/cloudemu/v2/server/wire/awsauthz" iamdriver "github.com/stackshy/cloudemu/v2/services/iam/driver" ) @@ -26,6 +27,12 @@ type principalProbe struct{} func (principalProbe) Matches(r *http.Request) bool { return r.URL.Path == "/_whoami" } +// IAMChecks makes the probe a whoami call: like sts:GetCallerIdentity it +// needs no permission, so the tests exercise authentication alone. +func (principalProbe) IAMChecks(*http.Request, awsauthz.Scope) ([]awsauthz.Check, bool) { + return []awsauthz.Check{}, true +} + func (principalProbe) ServeHTTP(w http.ResponseWriter, r *http.Request) { p, ok := authctx.PrincipalFrom(r.Context()) if !ok { diff --git a/server/aws/authz_authoff_test.go b/server/aws/authz_authoff_test.go new file mode 100644 index 000000000..423930b4b --- /dev/null +++ b/server/aws/authz_authoff_test.go @@ -0,0 +1,189 @@ +package aws + +import ( + "bytes" + "compress/gzip" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "reflect" + "regexp" + "testing" + "time" + "unicode/utf8" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/fxamacker/cbor/v2" + + cloudemu "github.com/stackshy/cloudemu/v2" + "github.com/stackshy/cloudemu/v2/config" +) + +// authOffGolden holds the responses the wire server gave this request set +// before IAM authorization covered the query and REST protocols. With +// EnforceAuth off, every response must stay byte-for-byte the same. +const authOffGolden = "testdata/authz_authoff_golden.json" + +// fixedAuthOffTime pins the emulator clock so timestamps in responses repeat. +var fixedAuthOffTime = time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC) //nolint:gochecknoglobals // fixed test instant + +// volatile matches the per-call parts of a response (generated ids, request +// ids, timestamps) that differ between runs. +var volatile = regexp.MustCompile( //nolint:gochecknoglobals // compiled once for the golden comparison + `[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}|` + + `\b(sg|i|vol|asg|lt|eni|r|ami|subnet|vpc)-[0-9a-f]{8,17}\b|"id":"[a-z0-9]{10}"|"rootResourceId":"[a-z0-9]{10}"`) + +type authOffCase struct { + Name string `json:"name"` + Status int `json:"status"` + Body string `json:"body"` +} + +// normalizeBody masks per-call values and renders a CBOR body as JSON, whose +// map keys are sorted, so the comparison is stable. +func normalizeBody(t *testing.T, body string) string { + t.Helper() + + if !utf8.ValidString(body) { + dm, err := cbor.DecOptions{DefaultMapType: reflect.TypeOf(map[string]any{})}.DecMode() + if err != nil { + t.Fatalf("cbor mode: %v", err) + } + + var v any + if err := dm.Unmarshal([]byte(body), &v); err != nil { + t.Fatalf("cbor body: %v", err) + } + + raw, _ := json.Marshal(v) + body = "cbor:" + string(raw) + } + + return volatile.ReplaceAllString(body, "") +} + +func gzipped(t *testing.T, s string) string { + t.Helper() + + var buf bytes.Buffer + + zw := gzip.NewWriter(&buf) + _, _ = zw.Write([]byte(s)) + _ = zw.Close() + + return buf.String() +} + +// TestAuthOffResponsesUnchanged replays a request set touching every handler +// this change edits (the CloudWatch op selection, the EC2 autoscaling table, +// the SageMaker runtime split) and the gate's protocol edges, with +// EnforceAuth off, and compares each response with the recorded one. +func TestAuthOffResponsesUnchanged(t *testing.T) { + clock := config.NewFakeClock(fixedAuthOffTime) + cloud := cloudemu.NewAWS(config.WithClock(clock)) + d := DriversFrom(cloud) + d.Clock = clock + + ts := httptest.NewServer(New(d)) + defer ts.Close() + + creds := aws.Credentials{AccessKeyID: "AKIAANY", SecretAccessKey: "any"} + cborBody, _ := cbor.Marshal(map[string]any{"Namespace": "Off", "MetricData": []any{map[string]any{"MetricName": "M", "Value": 1.0}}}) + cwQuery := "Action=PutMetricData&Version=2010-08-01&Namespace=Off&MetricData.member.1.MetricName=Q&MetricData.member.1.Value=2" + smithy := map[string]string{"Smithy-Protocol": "rpc-v2-cbor"} + + reqs := []struct { + name string + rq sreq + }{ + {"cw cbor PutMetricData", sreq{path: "/service/GraniteServiceVersion20100801/operation/PutMetricData", ctype: "application/cbor", + body: string(cborBody), service: "monitoring", header: smithy}}, + {"cw cbor with query Action", sreq{path: "/service/GraniteServiceVersion20100801/operation/ListMetrics?Action=DescribeAlarms", + ctype: "application/cbor", body: string(cborBody), service: "monitoring", header: smithy}}, + {"cw cbor unknown op", sreq{path: "/service/GraniteServiceVersion20100801/operation/Nope", ctype: "application/cbor", + service: "monitoring", header: smithy}}, + {"cw cbor no op", sreq{path: "/service/GraniteServiceVersion20100801/operation/", ctype: "application/cbor", + service: "monitoring", header: smithy}}, + {"cw json DescribeAlarms", sreq{path: "/", ctype: "application/x-amz-json-1.0", body: `{}`, service: "monitoring", + header: map[string]string{"X-Amz-Target": "GraniteServiceVersion20100801.DescribeAlarms"}}}, + {"cw json unknown op", sreq{path: "/", ctype: "application/x-amz-json-1.0", body: `{}`, service: "monitoring", + header: map[string]string{"X-Amz-Target": "GraniteServiceVersion20100801.Nope"}}}, + {"cw query PutMetricData", form("monitoring", cwQuery)}, + {"cw query gzip PutMetricData", sreq{path: "/", ctype: formCT, body: gzipped(t, cwQuery), service: "monitoring", + header: map[string]string{"Content-Encoding": "gzip"}}}, + {"cw query ListMetrics", form("monitoring", "Action=ListMetrics&Version=2010-08-01&Namespace=Off")}, + {"cw query unknown", form("monitoring", "Action=Nope&Version=2010-08-01")}, + {"cw query GET DescribeAlarms", sreq{method: http.MethodGet, path: "/?Action=DescribeAlarms&Version=2010-08-01", service: "monitoring"}}, + {"ec2 DescribeInstances", form("ec2", "Action=DescribeInstances&Version=2016-11-15")}, + {"ec2 unknown", form("ec2", "Action=Nope&Version=2016-11-15")}, + {"ec2 bad body", form("ec2", "Action=DescribeInstances&x=%zz")}, + {"as CreateAutoScalingGroup", form("autoscaling", + "Action=CreateAutoScalingGroup&AutoScalingGroupName=g&MinSize=0&MaxSize=1&DesiredCapacity=1&LaunchConfigurationName=lc&AvailabilityZones.member.1=us-east-1a")}, + {"as DescribeAutoScalingGroups", form("autoscaling", "Action=DescribeAutoScalingGroups")}, + {"as UpdateAutoScalingGroup", form("autoscaling", "Action=UpdateAutoScalingGroup&AutoScalingGroupName=g&MaxSize=2")}, + {"as SetDesiredCapacity", form("autoscaling", "Action=SetDesiredCapacity&AutoScalingGroupName=g&DesiredCapacity=1")}, + {"as PutScalingPolicy", form("autoscaling", + "Action=PutScalingPolicy&AutoScalingGroupName=g&PolicyName=p&ScalingAdjustment=1&AdjustmentType=ChangeInCapacity")}, + {"as ExecutePolicy", form("autoscaling", "Action=ExecutePolicy&AutoScalingGroupName=g&PolicyName=p")}, + {"as DeletePolicy (served by IAM)", form("autoscaling", "Action=DeletePolicy&AutoScalingGroupName=g&PolicyName=p")}, + {"as DeleteAutoScalingGroup", form("autoscaling", "Action=DeleteAutoScalingGroup&AutoScalingGroupName=g")}, + {"iam ListUsers", form("iam", "Action=ListUsers&Version=2010-05-08")}, + {"sts GetCallerIdentity", form("sts", "Action=GetCallerIdentity&Version=2011-06-15")}, + {"sns ListTopics", form("sns", "Action=ListTopics")}, + {"sagemaker runtime GET", sreq{method: http.MethodGet, path: "/endpoints/e/invocations", service: "sagemaker"}}, + {"sagemaker runtime missing endpoint", sreq{path: "/endpoints/e/invocations", ctype: "application/json", body: `{}`, + service: "sagemaker"}}, + {"sagemaker feature store DELETE", sreq{method: http.MethodDelete, path: "/FeatureGroup/g?RecordIdentifierValueAsString=1", + service: "sagemaker"}}, + {"sagemaker feature store POST", sreq{path: "/FeatureGroup/g", service: "sagemaker"}}, + {"sagemaker ListModels", sreq{path: "/", ctype: amzJSON11, body: `{}`, service: "sagemaker", + header: map[string]string{"X-Amz-Target": "SageMaker.ListModels"}}}, + {"sagemaker unknown op", sreq{path: "/", ctype: amzJSON11, body: `{}`, service: "sagemaker", + header: map[string]string{"X-Amz-Target": "SageMaker.Nope"}}}, + {"s3 mb", sreq{method: http.MethodPut, path: "/off-bucket", service: "s3"}}, + {"s3 ListBuckets", sreq{method: http.MethodGet, path: "/", service: "s3"}}, + {"lambda ListFunctions", sreq{method: http.MethodGet, path: lambdaPath, service: "lambda"}}, + {"unknown target", sreq{path: "/", ctype: amzJSON11, body: `{}`, service: "x", + header: map[string]string{"X-Amz-Target": "NoSuchService_2020.Op"}}}, + } + + got := make([]authOffCase, 0, len(reqs)) + + for _, r := range reqs { + status, body := doSigned(t, ts, creds, r.rq) + got = append(got, authOffCase{Name: r.name, Status: status, Body: normalizeBody(t, body)}) + } + + if os.Getenv("CLOUDEMU_UPDATE_AUTHOFF_GOLDEN") != "" { + raw, _ := json.MarshalIndent(got, "", " ") + _ = os.MkdirAll(filepath.Dir(authOffGolden), 0o755) + + if err := os.WriteFile(authOffGolden, append(raw, '\n'), 0o600); err != nil { + t.Fatalf("write golden: %v", err) + } + + return + } + + raw, err := os.ReadFile(authOffGolden) + if err != nil { + t.Fatalf("read golden: %v", err) + } + + var want []authOffCase + if err := json.Unmarshal(raw, &want); err != nil { + t.Fatalf("golden: %v", err) + } + + if len(want) != len(got) { + t.Fatalf("golden has %d cases, test sends %d", len(want), len(got)) + } + + for i := range got { + if got[i] != want[i] { + t.Errorf("%s:\n got %d %q\nwant %d %q", got[i].Name, got[i].Status, got[i].Body, want[i].Status, want[i].Body) + } + } +} diff --git a/server/aws/authz_completeness_test.go b/server/aws/authz_completeness_test.go new file mode 100644 index 000000000..6d20f7648 --- /dev/null +++ b/server/aws/authz_completeness_test.go @@ -0,0 +1,150 @@ +package aws + +import ( + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" + + cloudemu "github.com/stackshy/cloudemu/v2" + "github.com/stackshy/cloudemu/v2/server" + "github.com/stackshy/cloudemu/v2/server/wire/awsauthz" + "github.com/stackshy/cloudemu/v2/services/kubernetes" +) + +// fullServer builds the AWS server with every handler registered. +func fullServer(t *testing.T) (*server.Server, authzSets) { + t.Helper() + + d := DriversFrom(cloudemu.NewAWS()) + d.EnforceAuth = true + d.K8sAPI = kubernetes.NewAPIServer() + + return newServer(d) +} + +// TestEveryHandlerDeclaresAuthorization fails when a handler is registered +// without telling the gate how IAM authorizes it. Every handler must name its +// IAM service; a JSON-RPC handler's service must be in the target table; only +// the Kubernetes data plane is left to authentication alone. +func TestEveryHandlerDeclaresAuthorization(t *testing.T) { + srv, sets := fullServer(t) + + tableServices := map[string]bool{} + for _, svc := range jsonRPCServiceByTarget { + tableServices[svc] = true + } + + handlers := srv.Handlers() + if len(handlers) < 70 { + t.Fatalf("only %d handlers registered; the full server should have every service", len(handlers)) + } + + for _, h := range handlers { + name := fmt.Sprintf("%T", h) + svc := iamService(h) + _, resolver := h.(awsauthz.Resolver) + + switch { + case sets.authnOnly[h]: + if name != "*kubernetes.APIServer" { + t.Errorf("%s is authn-only; only the Kubernetes data plane may be", name) + } + case !servicePrefix.MatchString(svc): + t.Errorf("%s declares no valid IAM service (%q)", name, svc) + case sets.jsonRPC[h] && resolver: + t.Errorf("%s is both a JSON-RPC handler and a Resolver", name) + case sets.jsonRPC[h] && !tableServices[svc]: + t.Errorf("JSON-RPC handler %s serves %q, which no X-Amz-Target prefix maps to", name, svc) + } + } + + if len(sets.authnOnly) != 1 { + t.Errorf("authn-only handlers = %d, want 1 (Kubernetes)", len(sets.authnOnly)) + } +} + +// TestHandlerIAMServicesMatchTable pins every handler's IAM service prefix, +// and checks the JSON-RPC target table routes each prefix to a handler that +// declares the same service. +func TestHandlerIAMServicesMatchTable(t *testing.T) { + want := map[string]string{ + // Tier 1: op-level checks. + "*iam.Handler": "iam", "*sts.Handler": "sts", "*rds.Handler": "rds", "*redshift.Handler": "redshift", + "*elasticache.Handler": "elasticache", "*elbv2.Handler": "elasticloadbalancing", "*sns.Handler": "sns", + "*cloudformation.Handler": "cloudformation", "*cloudwatch.Handler": "cloudwatch", "*ec2.Handler": "ec2", + "*sagemaker.Handler": "sagemaker", + // Tier 0: REST, service level. + "*lambda.Handler": "lambda", "*s3.Handler": "s3", "*apigateway.Handler": "apigateway", + "*apigatewayv2.Handler": "apigateway", "*eks.Handler": "eks", "*route53.Handler": "route53", + "*cloudfront.Handler": "cloudfront", "*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", + "*fis.Handler": "fis", "*grafana.Handler": "grafana", "*eventbridgescheduler.Handler": "scheduler", + "*aps.Handler": "aps", "*kafka.Handler": "kafka", "*guardduty.Handler": "guardduty", "*bedrock.Handler": "bedrock", + "*bedrockagent.Handler": "bedrock", "*bedrockagentruntime.Handler": "bedrock", + "*resourceexplorer2.Handler": "resource-explorer-2", "*kinesisvideo.Handler": "kinesisvideo", + "*location.Handler": "geo", "*vpclattice.Handler": "vpc-lattice", "*savingsplans.Handler": "savingsplans", + // JSON-RPC. + "*dynamodb.Handler": "dynamodb", "*dynamodb.StreamsHandler": "dynamodb", "*sqs.Handler": "sqs", "*ssm.Handler": "ssm", + "*kms.Handler": "kms", "*acm.Handler": "acm", "*sfn.Handler": "states", "*kinesis.Handler": "kinesis", + "*cloudtrail.Handler": "cloudtrail", "*glue.Handler": "glue", "*aoss.Handler": "aoss", "*kendra.Handler": "kendra", + "*athena.Handler": "athena", "*cognito.Handler": "cognito-idp", "*configservice.Handler": "config", + "*wafv2.Handler": "wafv2", "*ecs.Handler": "ecs", "*ecr.Handler": "ecr", "*route53resolver.Handler": "route53resolver", + "*eventbridge.Handler": "events", "*cloudwatchlogs.Handler": "logs", "*secretsmanager.Handler": "secretsmanager", + "*keyspaces.Handler": "cassandra", "*memorydb.Handler": "memorydb", "*networkfirewall.Handler": "network-firewall", + "*resourcegroupstaggingapi.Handler": "tag", "*transfer.Handler": "transfer", "*timestreamwrite.Handler": "timestream", + "*healthlake.Handler": "healthlake", "*apprunner.Handler": "apprunner", "*globalaccelerator.Handler": "globalaccelerator", + "*costexplorer.Handler": "ce", "*servicequotas.Handler": "servicequotas", "*emr.Handler": "elasticmapreduce", + // Kubernetes RBAC, not IAM. + "*kubernetes.APIServer": "", + } + + srv, sets := fullServer(t) + + for _, h := range srv.Handlers() { + name := fmt.Sprintf("%T", h) + + svc, ok := want[name] + if !ok { + t.Errorf("%s is not in this table; add its IAM service prefix", name) + continue + } + + if got := iamService(h); got != svc { + t.Errorf("%s IAMService = %q, want %q", name, got, svc) + } + } + + matched := map[server.Handler]bool{} + + for prefix, svc := range jsonRPCServiceByTarget { + req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader("{}")) + req.Header.Set("X-Amz-Target", prefix+"Probe") + req.Header.Set("Content-Type", amzJSON11) + + h := srv.Match(req) + if h == nil { + t.Errorf("target prefix %s routes to no handler", prefix) + continue + } + + _, resolver := h.(awsauthz.Resolver) + if !sets.jsonRPC[h] && !resolver { + t.Errorf("target prefix %s routes to %T, which is neither a JSON-RPC handler nor a Resolver", prefix, h) + } + + if got := iamService(h); got != svc { + t.Errorf("target prefix %s maps to %q but routes to %T serving %q", prefix, svc, h, got) + } + + matched[h] = true + } + + for h := range sets.jsonRPC { + if !matched[h] { + t.Errorf("JSON-RPC handler %T has no X-Amz-Target prefix in the table", h) + } + } +} diff --git a/server/aws/authz_matrix_test.go b/server/aws/authz_matrix_test.go new file mode 100644 index 000000000..580536b01 --- /dev/null +++ b/server/aws/authz_matrix_test.go @@ -0,0 +1,520 @@ +package aws + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/aws/aws-sdk-go-v2/aws" + v4 "github.com/aws/aws-sdk-go-v2/aws/signer/v4" + "github.com/fxamacker/cbor/v2" + + cloudemu "github.com/stackshy/cloudemu/v2" + awsprovider "github.com/stackshy/cloudemu/v2/providers/aws" + iamdriver "github.com/stackshy/cloudemu/v2/services/iam/driver" + "github.com/stackshy/cloudemu/v2/services/kubernetes" +) + +// sreq is one signed request in the authorization matrix. +type sreq struct { + method, path, ctype, body, service, host string + header map[string]string +} + +func doSigned(t *testing.T, ts *httptest.Server, creds aws.Credentials, rq sreq) (int, string) { + t.Helper() + + ctx := context.Background() + + method := rq.method + if method == "" { + method = http.MethodPost + } + + req, err := http.NewRequestWithContext(ctx, method, ts.URL+rq.path, strings.NewReader(rq.body)) + if err != nil { + t.Fatalf("new request: %v", err) + } + + if rq.host != "" { + req.Host = rq.host + } + + if rq.ctype != "" { + req.Header.Set("Content-Type", rq.ctype) + } + + for k, v := range rq.header { + req.Header.Set(k, v) + } + + sum := sha256.Sum256([]byte(rq.body)) + if err := v4.NewSigner().SignHTTP(ctx, creds, req, hex.EncodeToString(sum[:]), rq.service, "us-east-1", time.Now()); err != nil { + t.Fatalf("sign: %v", err) + } + + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("do: %v", err) + } + defer resp.Body.Close() + + b, _ := io.ReadAll(resp.Body) + + return resp.StatusCode, string(b) +} + +func form(service, body string) sreq { + return sreq{path: "/", ctype: formCT, body: body, service: service} +} + +func allow(actions ...string) string { + return `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":["` + + strings.Join(actions, `","`) + `"],"Resource":"*"}]}` +} + +const ( + allowAllDenyBucket = `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"*","Resource":"*"},` + + `{"Effect":"Deny","Action":"s3:DeleteBucket","Resource":"arn:aws:s3:::prod"}]}` + xmlAccessDenied = "AccessDenied" + createEvilUser = "Action=CreateUser&Version=2010-05-08&UserName=evil" + lambdaCreate = `{"FunctionName":"f1","Runtime":"python3.12","Role":"arn:aws:iam::123456789012:role/r",` + + `"Handler":"index.handler","Code":{"ZipFile":"UEsFBgAAAAAAAAAAAAAAAAAAAAAAAA=="}}` +) + +// matrixServer is the full AWS wire server under --enforce-auth, plus the +// shared Kubernetes data plane so the authn-only plan is reachable. +func matrixServer(t *testing.T, mutate func(*Drivers)) (*httptest.Server, *awsprovider.Provider) { + t.Helper() + + cloud := cloudemu.NewAWS() + d := DriversFrom(cloud) + d.EnforceAuth = true + d.K8sAPI = kubernetes.NewAPIServer() + + if mutate != nil { + mutate(&d) + } + + ts := httptest.NewServer(New(d)) + t.Cleanup(ts.Close) + + return ts, cloud +} + +func userCount(t *testing.T, cloud *awsprovider.Provider, name string) int { + t.Helper() + + users, err := cloud.IAM.ListUsers(context.Background()) + if err != nil { + t.Fatalf("ListUsers: %v", err) + } + + n := 0 + + for _, u := range users { + if u.Name == name { + n++ + } + } + + return n +} + +func bucketExists(t *testing.T, cloud *awsprovider.Provider, name string) bool { + t.Helper() + + buckets, err := cloud.S3.ListBuckets(context.Background()) + if err != nil { + t.Fatalf("ListBuckets: %v", err) + } + + for _, b := range buckets { + if b.Name == name { + return true + } + } + + return false +} + +func wantDenied(t *testing.T, status int, body, shape string) { + t.Helper() + + if status != http.StatusForbidden || !strings.Contains(body, shape) { + t.Fatalf("status %d, body %s; want 403 with %s", status, body, shape) + } +} + +func wantNotDenied(t *testing.T, status int, body string) { + t.Helper() + + if status == http.StatusForbidden { + t.Fatalf("denied: %s", body) + } +} + +// TestAuthzMatrixQuery covers the query protocol: the IAM action comes from the +// handler that dispatch picks and the form Action it reads, never from the +// SigV4 scope. +func TestAuthzMatrixQuery(t *testing.T) { + ts, cloud := matrixServer(t, nil) + dyn := userWithPolicy(t, cloud, "dynonly", allowDynamo) + lister := userWithPolicy(t, cloud, "lister", allow("iam:ListUsers")) + + t.Run("iam CreateUser by a dynamodb-only user", func(t *testing.T) { + status, body := doSigned(t, ts, dyn, form("iam", createEvilUser)) + wantDenied(t, status, body, xmlAccessDenied) + }) + + t.Run("iam CreateUser signed with a dynamodb scope", func(t *testing.T) { + status, body := doSigned(t, ts, dyn, form("dynamodb", createEvilUser)) + wantDenied(t, status, body, xmlAccessDenied) + }) + + t.Run("GET ?Action=CreateUser is ec2:CreateUser", func(t *testing.T) { + status, body := doSigned(t, ts, dyn, sreq{method: http.MethodGet, path: "/?" + createEvilUser, service: "iam"}) + wantDenied(t, status, body, "ec2:CreateUser") + }) + + t.Run("duplicate Action: the first value is authorized and run", func(t *testing.T) { + status, body := doSigned(t, ts, lister, form("iam", "Action=ListUsers&Action=CreateUser&Version=2010-05-08&UserName=evil")) + if status != http.StatusOK { + t.Fatalf("ListUsers-first: %d %s", status, body) + } + + status, body = doSigned(t, ts, lister, form("iam", "Action=CreateUser&Action=ListUsers&Version=2010-05-08&UserName=evil")) + wantDenied(t, status, body, "iam:CreateUser") + }) + + t.Run("body Action wins over the query-string Action", func(t *testing.T) { + rq := form("iam", createEvilUser) + rq.path = "/?Action=ListUsers" + status, body := doSigned(t, ts, lister, rq) + wantDenied(t, status, body, "iam:CreateUser") + }) + + t.Run("lower-case Action falls through to EC2", func(t *testing.T) { + status, body := doSigned(t, ts, lister, form("iam", "Action=createuser&Version=2010-05-08&UserName=evil")) + wantDenied(t, status, body, "ec2:createuser") + }) + + t.Run("unparseable body", func(t *testing.T) { + status, body := doSigned(t, ts, lister, form("iam", "Action=CreateUser&UserName=evil&x=%zz")) + if status < http.StatusBadRequest { + t.Fatalf("unparseable body served: %d %s", status, body) + } + }) + + if n := userCount(t, cloud, "evil"); n != 0 { + t.Fatalf("a denied request created user evil") + } + + t.Run("ListTagsForResource with an sns scope is sns", func(t *testing.T) { + rds := userWithPolicy(t, cloud, "rdsonly", allow("rds:*")) + status, body := doSigned(t, ts, rds, form("sns", "Action=ListTagsForResource&ResourceArn=arn:aws:sns:us-east-1:123456789012:t")) + wantDenied(t, status, body, "sns:ListTagsForResource") + }) + + t.Run("autoscaling DeletePolicy is served by IAM", func(t *testing.T) { + as := userWithPolicy(t, cloud, "asonly", allow("autoscaling:*")) + status, body := doSigned(t, ts, as, form("autoscaling", "Action=DeletePolicy&PolicyName=p&AutoScalingGroupName=g")) + wantDenied(t, status, body, "iam:DeletePolicy") + }) + + t.Run("autoscaling CreateAutoScalingGroup needs autoscaling", func(t *testing.T) { + ec2u := userWithPolicy(t, cloud, "ec2only", allow("ec2:*")) + status, body := doSigned(t, ts, ec2u, form("autoscaling", + "Action=CreateAutoScalingGroup&AutoScalingGroupName=g1&MinSize=0&MaxSize=1&LaunchConfigurationName=lc")) + wantDenied(t, status, body, "autoscaling:CreateAutoScalingGroup") + + groups, err := cloud.EC2.ListAutoScalingGroups(context.Background()) + if err != nil || len(groups) != 0 { + t.Fatalf("groups %v err %v, want none", groups, err) + } + }) + + t.Run("query-form SQS is ec2 and never reaches SQS", func(t *testing.T) { + sq := userWithPolicy(t, cloud, "sqsonly", allowSQS) + status, body := doSigned(t, ts, sq, form("sqs", "Action=CreateQueue&QueueName=q1")) + wantDenied(t, status, body, "ec2:CreateQueue") + + qs, _ := cloud.SQS.ListQueues(context.Background(), "") + if len(qs) != 0 { + t.Fatalf("queues %v, want none", qs) + } + }) + + t.Run("cloudwatch query PutMetricData with a fine-grained policy", func(t *testing.T) { + cw := userWithPolicy(t, cloud, "cwput", allow("cloudwatch:PutMetricData")) + status, body := doSigned(t, ts, cw, form("monitoring", + "Action=PutMetricData&Version=2010-08-01&Namespace=App&MetricData.member.1.MetricName=Hits&MetricData.member.1.Value=1")) + if status != http.StatusOK { + t.Fatalf("PutMetricData: %d %s", status, body) + } + }) +} + +// TestAuthzMatrixProtocolMixing sends one protocol's operation with another +// protocol's operation marker. The gate must authorize what dispatch runs. +func TestAuthzMatrixProtocolMixing(t *testing.T) { + ts, cloud := matrixServer(t, nil) + describer := userWithPolicy(t, cloud, "describer", allow("cloudwatch:DescribeAlarms")) + + payload, err := cbor.Marshal(map[string]any{ + "Namespace": "Mixed", "MetricData": []any{map[string]any{"MetricName": "M", "Value": 1.0}}, + }) + if err != nil { + t.Fatalf("cbor: %v", err) + } + + t.Run("cbor PutMetricData with ?Action=DescribeAlarms", func(t *testing.T) { + status, body := doSigned(t, ts, describer, sreq{ + path: "/service/GraniteServiceVersion20100801/operation/PutMetricData?Action=DescribeAlarms", + ctype: "application/cbor", body: string(payload), service: "monitoring", + header: map[string]string{"Smithy-Protocol": "rpc-v2-cbor"}, + }) + if status != http.StatusForbidden { + t.Fatalf("status %d %q, want 403", status, body) + } + }) + + t.Run("json 1.0 PutMetricData with ?Action=DescribeAlarms", func(t *testing.T) { + status, body := doSigned(t, ts, describer, sreq{ + path: "/?Action=DescribeAlarms", ctype: "application/x-amz-json-1.0", + body: `{"Namespace":"Mixed","MetricData":[{"MetricName":"M","Value":1}]}`, service: "monitoring", + header: map[string]string{"X-Amz-Target": "GraniteServiceVersion20100801.PutMetricData"}, + }) + wantDenied(t, status, body, accessDeny) + }) + + if names, _ := cloud.CloudWatch.ListMetrics(context.Background(), "Mixed"); len(names) != 0 { + t.Fatalf("a denied PutMetricData stored %v", names) + } + + t.Run("json 1.0 DescribeAlarms is allowed", func(t *testing.T) { + status, body := doSigned(t, ts, describer, sreq{ + path: "/", ctype: "application/x-amz-json-1.0", body: `{}`, service: "monitoring", + header: map[string]string{"X-Amz-Target": "GraniteServiceVersion20100801.DescribeAlarms"}, + }) + if status != http.StatusOK { + t.Fatalf("DescribeAlarms: %d %s", status, body) + } + }) +} + +// TestAuthzMatrixForgedTarget sends REST and query requests carrying a forged +// X-Amz-Target from a caller allowed only dynamodb:PutItem. The header must not +// steer the authorized action away from the handler that runs. +func TestAuthzMatrixForgedTarget(t *testing.T) { + forged := map[string]string{"X-Amz-Target": "DynamoDB_20120810.PutItem"} + + t.Run("s3, iam and rds keep their state", func(t *testing.T) { + ts, cloud := matrixServer(t, nil) + put := userWithPolicy(t, cloud, "putonly", allow("dynamodb:PutItem")) + + cases := []sreq{ + {method: http.MethodPut, path: "/forged-bucket", service: "s3", header: forged}, + {path: "/", ctype: formCT, body: createEvilUser, service: "iam", header: forged}, + {path: "/", ctype: formCT, body: "Action=CreateDBSubnetGroup&DBSubnetGroupName=g&DBSubnetGroupDescription=d" + + "&SubnetIds.member.1=subnet-1", service: "rds", header: forged}, + } + + for _, rq := range cases { + if status, body := doSigned(t, ts, put, rq); status < http.StatusBadRequest { + t.Fatalf("%s %s: forged target served %d %s", rq.method, rq.path, status, body) + } + } + + if bucketExists(t, cloud, "forged-bucket") || userCount(t, cloud, "evil") != 0 { + t.Fatal("a forged-target request changed S3 or IAM state") + } + }) + + t.Run("a REST handler that ignores the header", func(t *testing.T) { + // Without DynamoDB registered, the forged target reaches Lambda, which + // routes on its path. It must be authorized as lambda, not dynamodb. + ts, cloud := matrixServer(t, func(d *Drivers) { d.DynamoDB = nil }) + put := userWithPolicy(t, cloud, "putonly", allow("dynamodb:PutItem")) + + status, body := doSigned(t, ts, put, sreq{ + path: lambdaPath, ctype: "application/json", body: lambdaCreate, service: "lambda", header: forged, + }) + wantDenied(t, status, body, accessDeny) + + if fns, _ := cloud.Lambda.ListFunctions(context.Background()); len(fns) != 0 { + t.Fatalf("functions %v, want none", fns) + } + }) +} + +// TestAuthzMatrixJSONRPCPrefixes checks the JSON-RPC services that had no +// entry in the target table are usable by an unrestricted caller. +func TestAuthzMatrixJSONRPCPrefixes(t *testing.T) { + ts, cloud := matrixServer(t, nil) + boot := userWithPolicy(t, cloud, "boot", "") + + for _, tc := range []struct{ service, target string }{ + {"ce", "AWSInsightsIndexService.GetCostAndUsage"}, + {"servicequotas", "ServiceQuotasV20190624.ListServices"}, + {"elasticmapreduce", "ElasticMapReduce.ListClusters"}, + } { + t.Run(tc.service, func(t *testing.T) { + if status, typ := signedJSONRPC(t, ts, boot, tc.service, tc.target); status == http.StatusForbidden { + t.Fatalf("%s denied: %s", tc.target, typ) + } + }) + } +} + +// TestAuthzMatrixREST covers REST services, which stay service-level until +// each moves to op-level checks. +func TestAuthzMatrixREST(t *testing.T) { + ts, cloud := matrixServer(t, nil) + dyn := userWithPolicy(t, cloud, "dynonly", allowDynamo) + + t.Run("s3 mb by a dynamodb-only user", func(t *testing.T) { + status, body := doSigned(t, ts, dyn, sreq{method: http.MethodPut, path: "/dyn-bucket", service: "s3"}) + wantDenied(t, status, body, "AccessDenied") + + if bucketExists(t, cloud, "dyn-bucket") { + t.Fatal("bucket created") + } + }) + + t.Run("s3 request signed with a dynamodb scope", func(t *testing.T) { + _, _ = doSigned(t, ts, dyn, sreq{method: http.MethodPut, path: "/scoped-bucket", service: "dynamodb"}) + + if bucketExists(t, cloud, "scoped-bucket") { + t.Fatal("bucket created") + } + }) + + for _, tc := range []struct { + name string + rq sreq + }{ + {"lambda CreateFunction", sreq{path: lambdaPath, ctype: "application/json", body: lambdaCreate, service: "lambda"}}, + {"apigateway CreateRestApi", sreq{path: "/restapis", ctype: "application/json", body: `{"name":"a"}`, service: "apigateway"}}, + {"eks CreateCluster", sreq{path: "/clusters", ctype: "application/json", service: "eks", + body: `{"name":"c1","roleArn":"arn:aws:iam::123456789012:role/r","resourcesVpcConfig":{}}`}}, + } { + t.Run(tc.name+" by a dynamodb-only user", func(t *testing.T) { + status, body := doSigned(t, ts, dyn, tc.rq) + wantDenied(t, status, body, accessDeny) + }) + } + + t.Run("service-wide grant is allowed", func(t *testing.T) { + s3all := userWithPolicy(t, cloud, "s3all", allow("s3:*")) + status, body := doSigned(t, ts, s3all, sreq{method: http.MethodPut, path: "/wide-bucket", service: "s3"}) + wantNotDenied(t, status, body) + + if !bucketExists(t, cloud, "wide-bucket") { + t.Fatal("bucket not created") + } + }) + + t.Run("allow-all with one deny on the service", func(t *testing.T) { + u := userWithPolicy(t, cloud, "denyone", allowAllDenyBucket) + status, body := doSigned(t, ts, u, sreq{method: http.MethodPut, path: "/denyone-bucket", service: "s3"}) + wantDenied(t, status, body, xmlAccessDenied) + }) + + t.Run("unknown REST path is 501", func(t *testing.T) { + status, body := doSigned(t, ts, dyn, sreq{method: http.MethodGet, path: "/", service: "dynamodb", + header: map[string]string{"X-Amz-Target": "NoSuchService_2020.Op"}}) + if status != http.StatusNotImplemented { + t.Fatalf("status %d %s, want 501", status, body) + } + }) + + t.Run("kubernetes data plane is authn-only", func(t *testing.T) { + status, body := doSigned(t, ts, dyn, sreq{method: http.MethodGet, path: "/k8s/none/api", service: "eks"}) + wantNotDenied(t, status, body) + }) +} + +// TestAuthzMatrixSTS covers the basic STS checks. +func TestAuthzMatrixSTS(t *testing.T) { + ts, cloud := matrixServer(t, nil) + ctx := context.Background() + dyn := userWithPolicy(t, cloud, "dynonly", allowDynamo) + + trust := `{"Statement":[{"Effect":"Allow","Principal":{"AWS":"arn:aws:iam::` + defaultTestAccount + + `:root"},"Action":"sts:AssumeRole"}]}` + if _, err := cloud.IAM.CreateRole(ctx, iamdriver.RoleConfig{Name: "target", AssumeRolePolicyDoc: trust}); err != nil { + t.Fatalf("CreateRole: %v", err) + } + + roleArn := "arn:aws:iam::" + defaultTestAccount + ":role/target" + assume := "Action=AssumeRole&Version=2011-06-15&RoleSessionName=s&RoleArn=" + roleArn + + t.Run("AssumeRole without sts:AssumeRole", func(t *testing.T) { + status, body := doSigned(t, ts, dyn, form("sts", assume)) + wantDenied(t, status, body, "sts:AssumeRole") + }) + + t.Run("AssumeRole with sts:AssumeRole on the role", func(t *testing.T) { + u := userWithPolicy(t, cloud, "assumer", `{"Statement":[{"Effect":"Allow","Action":"sts:AssumeRole","Resource":"`+roleArn+`"}]}`) + status, body := doSigned(t, ts, u, form("sts", assume)) + if status != http.StatusOK { + t.Fatalf("AssumeRole: %d %s", status, body) + } + }) + + t.Run("GetCallerIdentity needs no permission", func(t *testing.T) { + status, body := doSigned(t, ts, dyn, form("sts", "Action=GetCallerIdentity&Version=2011-06-15")) + if status != http.StatusOK { + t.Fatalf("GetCallerIdentity: %d %s", status, body) + } + }) + + t.Run("GetSessionToken is only blocked by an explicit deny", func(t *testing.T) { + status, body := doSigned(t, ts, dyn, form("sts", "Action=GetSessionToken&Version=2011-06-15")) + if status != http.StatusOK { + t.Fatalf("GetSessionToken: %d %s", status, body) + } + + denied := userWithPolicy(t, cloud, "nosession", `{"Statement":[{"Effect":"Allow","Action":"*","Resource":"*"},`+ + `{"Effect":"Deny","Action":"sts:GetSessionToken","Resource":"*"}]}`) + status, body = doSigned(t, ts, denied, form("sts", "Action=GetSessionToken&Version=2011-06-15")) + wantDenied(t, status, body, xmlAccessDenied) + }) +} + +// TestAuthzMatrixBootstrap checks the shortcut principals are unrestricted on +// every plan except an unmapped JSON-RPC target. +func TestAuthzMatrixBootstrap(t *testing.T) { + ts, cloud := matrixServer(t, nil) + boot := userWithPolicy(t, cloud, "boot", "") + root := userWithPolicy(t, cloud, "root", allowDynamo) + + for name, creds := range map[string]aws.Credentials{"boot": boot, "root": root} { + t.Run(name, func(t *testing.T) { + for _, rq := range []sreq{ + form("iam", "Action=CreateUser&Version=2010-05-08&UserName=made-by-"+name), + {method: http.MethodPut, path: "/" + name + "-bucket", service: "s3"}, + form("ec2", "Action=DescribeInstances&Version=2016-11-15"), + form("sts", "Action=GetCallerIdentity&Version=2011-06-15"), + {path: lambdaPath, ctype: "application/json", body: strings.Replace(lambdaCreate, "f1", "fn-"+name, 1), service: "lambda"}, + {method: http.MethodGet, path: "/k8s/none/api", service: "eks"}, + } { + if status, body := doSigned(t, ts, creds, rq); status == http.StatusForbidden { + t.Fatalf("%s %s denied: %s", rq.method, rq.path, body) + } + } + + status, _ := signedJSONRPC(t, ts, creds, "cloudtrail", "com.amazonaws.cloudtrail.v20131101.CloudTrail_20131101.DescribeTrails") + if status != http.StatusForbidden { + t.Fatalf("unmapped JSON-RPC target: status %d, want 403", status) + } + }) + } +} diff --git a/server/aws/authz_unknown_op_test.go b/server/aws/authz_unknown_op_test.go new file mode 100644 index 000000000..6e9703c80 --- /dev/null +++ b/server/aws/authz_unknown_op_test.go @@ -0,0 +1,118 @@ +package aws + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" + + cloudemu "github.com/stackshy/cloudemu/v2" + "github.com/stackshy/cloudemu/v2/persist" + awsprovider "github.com/stackshy/cloudemu/v2/providers/aws" + "github.com/stackshy/cloudemu/v2/server" +) + +// providerState is every service's resource state, without CloudTrail's +// management-event log, which records each call (failed ones too) like real +// CloudTrail does. +func providerState(t *testing.T, cloud *awsprovider.Provider) []byte { + t.Helper() + + ps, err := persist.Export(context.Background(), cloud.SnapshotServices(), persist.Options{IncludeAssets: true}) + if err != nil { + t.Fatalf("export: %v", err) + } + + delete(ps.Services, "cloudtrail") + + raw, err := json.Marshal(ps) + if err != nil { + t.Fatalf("marshal: %v", err) + } + + return raw +} + +// TestUnknownQueryActionHasNoSideEffect backs the unknown-operation plan: an +// Action a query handler does not know is let through for unrestricted +// callers only because the handler then answers with an error and changes +// nothing. It checks that for each of the ten query handlers, both on the +// handler's own default branch and through the gate as a shortcut principal. +func TestUnknownQueryActionHasNoSideEffect(t *testing.T) { + cloud := cloudemu.NewAWS() + d := DriversFrom(cloud) + d.EnforceAuth = true + + srv, _ := newServer(d) + + ts := httptest.NewServer(srv) + defer ts.Close() + + boot := userWithPolicy(t, cloud, "boot", "") + + byType := map[string]server.Handler{} + for _, h := range srv.Handlers() { + byType[fmt.Sprintf("%T", h)] = h + } + + const unknown = "Action=NoSuchAction&Version=2010-05-08" + + for _, tc := range []struct{ handler, scope string }{ + {"*iam.Handler", "iam"}, + {"*sts.Handler", "sts"}, + {"*rds.Handler", "rds"}, + {"*redshift.Handler", "redshift"}, + {"*elasticache.Handler", "elasticache"}, + {"*elbv2.Handler", "elasticloadbalancing"}, + {"*sns.Handler", "sns"}, + {"*cloudformation.Handler", "cloudformation"}, + {"*cloudwatch.Handler", "monitoring"}, + {"*ec2.Handler", "ec2"}, + } { + t.Run(tc.handler, func(t *testing.T) { + h, ok := byType[tc.handler] + if !ok { + t.Fatalf("%s not registered", tc.handler) + } + + before := providerState(t, cloud) + + req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(unknown)) + req.Header.Set("Content-Type", formCT) + req.Header.Set("Authorization", "AWS4-HMAC-SHA256 Credential=AKIDEXAMPLE/20260101/us-east-1/"+tc.scope+ + "/aws4_request, SignedHeaders=host, Signature=0") + + if err := req.ParseForm(); err != nil { + t.Fatalf("parse: %v", err) + } + + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + + if rec.Code < http.StatusBadRequest || rec.Code >= http.StatusInternalServerError { + t.Fatalf("default branch answered %d: %s", rec.Code, rec.Body) + } + + for _, body := range []string{unknown, "Version=2010-05-08"} { + if status, resp := doSigned(t, ts, boot, form(tc.scope, body)); status < http.StatusBadRequest || + status >= http.StatusInternalServerError { + t.Fatalf("through the gate %q answered %d: %s", body, status, resp) + } + } + + if after := providerState(t, cloud); !bytes.Equal(before, after) { + t.Fatal("an unknown Action changed backend state") + } + }) + } + + t.Run("a restricted caller is denied an Action the handler cannot name", func(t *testing.T) { + dyn := userWithPolicy(t, cloud, "dynonly", allowDynamo) + status, body := doSigned(t, ts, dyn, form("ec2", "Version=2016-11-15")) + wantDenied(t, status, body, "ec2:UnknownOperation") + }) +} diff --git a/server/aws/authzgate.go b/server/aws/authzgate.go index c5e115bd4..236513cd3 100644 --- a/server/aws/authzgate.go +++ b/server/aws/authzgate.go @@ -2,42 +2,25 @@ package aws import ( "encoding/json" - "net" "net/http" - "strconv" + "regexp" "strings" - "time" + "github.com/stackshy/cloudemu/v2/server" "github.com/stackshy/cloudemu/v2/server/authctx" "github.com/stackshy/cloudemu/v2/server/wire" - "github.com/stackshy/cloudemu/v2/server/wire/sigv4" + "github.com/stackshy/cloudemu/v2/server/wire/awsauthz" + "github.com/stackshy/cloudemu/v2/server/wire/awsquery" iamdriver "github.com/stackshy/cloudemu/v2/services/iam/driver" ) -// authzDecision is the outcome of deriving an IAM action for a request. -type authzDecision int - -const ( - // authzSkip: the request is authenticated only (REST and other protocols - // whose executed operation is not bound to a signal the gate can read before - // dispatch). No authorization decision is made. - authzSkip authzDecision = iota - // authzEnforce: the IAM action was derived from the dispatch key; gate it - // through CheckPermission. - authzEnforce - // authzDeny: the request is a JSON-RPC call whose target does not map to a - // known served service, so the executed operation cannot be bound to an IAM - // action. Fail closed rather than authorize on an unverifiable service. - authzDeny -) - // jsonRPCServiceByTarget maps a JSON-RPC X-Amz-Target prefix (the part before // the operation, e.g. "DynamoDB_20120810." or "TrentService.") to the IAM -// service the operation belongs to. The X-Amz-Target header is the value the -// dispatcher itself routes on, so a service derived from it is bound to the -// handler that actually runs, unlike the SigV4 credential scope, which the -// client controls independently of the operation. Every JSON-RPC service the -// wire server serves must appear here; an unmapped target fails closed. +// service the operation belongs to. It is read only for handlers registered +// as JSON-RPC handlers (gateConfig.jsonRPC), which route on that header, and +// the service it gives must equal the handler's own IAMService. Every +// JSON-RPC service the wire server serves must appear here; an unmapped +// target fails closed. // //nolint:gochecknoglobals // static protocol lookup table var jsonRPCServiceByTarget = map[string]string{ @@ -74,139 +57,283 @@ var jsonRPCServiceByTarget = map[string]string{ "HealthLake.": "healthlake", "AppRunner.": "apprunner", "GlobalAccelerator_V20180706.": "globalaccelerator", + "AWSInsightsIndexService.": "ce", + "ServiceQuotasV20190624.": "servicequotas", + "ElasticMapReduce.": "elasticmapreduce", } -// authorize is the authorization step layered on top of the SigV4 authentication -// gate. It runs only when EnforceAuth is on and after a request has been -// authenticated, so p is a verified IAM principal. It derives the IAM action for -// the request from the dispatch key and, for a real IAM principal that has -// policies defined, gates the action through CheckPermission. It returns -// proceed=false only when the action is denied, having already written the 403. -// -// strict is set for an STS role session. Its principal is the role, which is -// evaluated on its policies alone: the root/admin and no-policies bootstrap -// shortcuts that apply to IAM users do not apply, so a role with no allowing -// policy, or a role that does not exist, is denied. -// -// Authorization is enforced for the JSON-RPC protocol, where the X-Amz-Target -// header both routes the request and names the service, so the service the gate -// authorizes is the one the handler runs. The query and REST protocols are -// authenticated only: there the executed operation's IAM service is not bound to -// any pre-dispatch signal the gate can trust. Query dispatch routes on the -// action name (a single handler, e.g. EC2, serves several IAM services such as -// ec2/vpc/autoscaling), and the SigV4 credential scope is client-controlled and -// decoupled from the operation. Authorizing query/REST on that scope would let a -// caller scoped to service A run an operation that executes under service B, so -// action+resource authorization bound to the routed operation is a follow-up. -func authorize( - w http.ResponseWriter, r *http.Request, p authctx.Principal, iamDriver iamdriver.IAM, body []byte, accountID string, - strict bool, -) bool { - service, action, decision := deriveAction(r) +// servicePrefix is the shape of an IAM service prefix ("s3", +// "resource-explorer-2"). A service-wide plan is only built for a name of +// this shape, so an empty or malformed IAMService can never be evaluated as +// a wildcard. +var servicePrefix = regexp.MustCompile(`^[a-z0-9][a-z0-9-]*$`) + +// planKind is how the gate authorizes one request, chosen from the handler +// dispatch will run. +type planKind int + +const ( + // planNoHandler: no handler serves the request; dispatch answers 501. + planNoHandler planKind = iota + // planChecks: the handler (or the JSON-RPC table) named the IAM checks. + planChecks + // planUnknownOp: the handler cannot name the operation and will answer + // with an error, so only unrestricted callers are let through to it. + planUnknownOp + // planJSONDeny: a JSON-RPC target the table does not bind to the handler. + planJSONDeny + // planServiceWide: a handler that only names its IAM service. Only a + // grant covering every action of the service allows it. + planServiceWide + // planAuthnOnly: a handler IAM does not govern. + planAuthnOnly + // planUnmapped: anything else, including a request whose query string or + // form body does not parse. Fails closed for restricted callers. + planUnmapped +) + +// authzPlan is the resolved authorization for one request. +type authzPlan struct { + kind planKind + // req is the probe the plan was resolved on, or nil when the request did + // not parse. A deny is rendered from it, since its form is already parsed. + req *http.Request + checks []awsauthz.Check + // action names the operation in a deny message for the plans without + // checks (the service-wide action for planServiceWide). + action string +} - if decision == authzSkip { - return true +// resolvePlan picks the plan from the handler dispatch will run (h, found on +// probe by probeRoute). probed=false means the request did not parse. +func (g *gateConfig) resolvePlan(probe *http.Request, h server.Handler, probed bool, body []byte) authzPlan { + if !probed { + return authzPlan{kind: planUnmapped} } - if decision == authzDeny { - // JSON-RPC target that maps to no known service: fail closed. - writeAuthzDenied(w, p, service+":"+jsonRPCTargetOperation(r)) - return false + if h == nil { + return authzPlan{kind: planNoHandler} } - if !strict && isAdminPrincipal(p) { - return true // account root / bootstrap admin identity: full access. + if res, ok := h.(awsauthz.Resolver); ok { + checks, known := res.IAMChecks(probe, g.scope) + if !known { + return authzPlan{kind: planUnknownOp, req: probe, action: iamService(h) + ":" + rawOperation(probe)} + } + + return authzPlan{kind: planChecks, req: probe, checks: checks} } - if !strict && !principalHasPolicies(r, p, iamDriver) { - return true // no policies defined: unrestricted (dev-friendly bootstrap). + if g.jsonRPC[h] { + return g.jsonRPCPlan(probe, h, body) } - resource := deriveResource(service, body, sigv4.Region(r), accountID) + if svc := iamService(h); servicePrefix.MatchString(svc) { + return authzPlan{kind: planServiceWide, req: probe, action: svc + ":*"} + } - if checkPermission(r, p, iamDriver, action, resource) { - return true + if g.authnOnly[h] { + return authzPlan{kind: planAuthnOnly} } - writeAuthzDenied(w, p, action) + return authzPlan{kind: planUnmapped, req: probe} +} + +// jsonRPCPlan binds a JSON-RPC request to its action through the target +// table. The service the header names must be the handler's own, or the +// request fails closed. +func (g *gateConfig) jsonRPCPlan(probe *http.Request, h server.Handler, body []byte) authzPlan { + service, op, ok := jsonRPCTarget(probe) + if !ok || service != iamService(h) { + return authzPlan{kind: planJSONDeny, req: probe, action: service + ":" + op} + } - return false + return authzPlan{kind: planChecks, req: probe, checks: awsauthz.Single(service+":"+op, deriveResource(service, body, g.scope))} } -// checkPermission evaluates the action against the derived resource for the -// caller. When the IAM driver supports the ContextualAuthorizer capability, the -// request condition context (source IP, region, principal, secure transport, -// current time) is threaded so Condition-guarded statements are honored; -// otherwise it falls back to the resource-only CheckPermission. -func checkPermission( - r *http.Request, p authctx.Principal, iamDriver iamdriver.IAM, action, resource string, -) bool { - if ca, ok := iamDriver.(iamdriver.ContextualAuthorizer); ok { - allowed, err := ca.CheckPermissionWithContext(r.Context(), p.UserName, action, resource, requestConditionContext(r, p)) - return err == nil && allowed +// denyTarget is the request a deny is rendered from: the probe when there is +// one, else the original request. +func (p authzPlan) denyTarget(r *http.Request) *http.Request { + if p.req != nil { + return p.req } - allowed, err := iamDriver.CheckPermission(r.Context(), p.UserName, action, resource) + return r +} - return err == nil && allowed +// iamService returns the IAM service prefix a handler declares, or "". +func iamService(h server.Handler) string { + if n, ok := h.(awsauthz.ServiceNamer); ok { + return n.IAMService() + } + + return "" } -// requestConditionContext gathers the AWS global condition keys the gate can -// derive from the request and the verified principal. Keys that cannot be -// determined are omitted, so a policy that references an absent key evaluates -// per IAM's absent-key rules (plain → no match, ...IfExists → match). -func requestConditionContext(r *http.Request, p authctx.Principal) map[string]string { - ctx := map[string]string{ - "aws:CurrentTime": time.Now().UTC().Format(time.RFC3339), - "aws:SecureTransport": strconv.FormatBool(r.TLS != nil), +// rawOperation is the form Action of a request, for the deny message of an +// operation the handler cannot name. +func rawOperation(probe *http.Request) string { + if probe.Form != nil { + if a := probe.Form.Get("Action"); a != "" { + return a + } } - if ip := clientIP(r); ip != "" { - ctx["aws:SourceIp"] = ip + return "UnknownOperation" +} + +// authorize applies plan to the authenticated caller p. On allow it returns +// the request carrying the principal and the gate's evaluation; on deny it +// has written the 403. +// +// strict is set for an STS role session. Its principal is the role, which is +// evaluated on its policies alone: the root and no-policies bootstrap +// shortcuts that apply to IAM users do not apply. +func (g *gateConfig) authorize( + w http.ResponseWriter, r *http.Request, h server.Handler, plan authzPlan, p *authctx.Principal, strict bool, +) (*http.Request, bool) { + if plan.kind == planNoHandler || plan.kind == planAuthnOnly { + return withPrincipal(r, *p), true } - if p.ARN != "" { - ctx["aws:PrincipalArn"] = p.ARN + if plan.kind == planJSONDeny { + writeAccessDenied(w, plan.denyTarget(r), h, denyMessage(p, plan.action, "", false)) + return r, false } - if p.UserName != "" { - ctx["aws:username"] = p.UserName + ev := awsauthz.Evaluation{Principal: *p, CondCtx: awsauthz.ConditionContext(r, p, g.scope)} + shortcut := !strict && (isAdminPrincipal(*p) || !principalHasPolicies(r, *p, g.iam)) + + if msg := g.decide(r, p, plan, &ev, shortcut); msg != "" { + writeAccessDenied(w, plan.denyTarget(r), h, msg) + return r, false } - if region := sigv4.Region(r); region != "" { - ctx["aws:RequestedRegion"] = region + r = withPrincipal(r, *p) + + return r.WithContext(awsauthz.WithEvaluation(r.Context(), &ev)), true +} + +// decide evaluates the plans that depend on the caller's policies. It +// returns a deny message, or "" to allow, and records the ResourcePolicy +// decisions in ev. +func (g *gateConfig) decide(r *http.Request, p *authctx.Principal, plan authzPlan, ev *awsauthz.Evaluation, shortcut bool) string { + var msg string + + switch plan.kind { + case planChecks: + ev.Decisions, msg = g.evaluateChecks(r, p, plan.checks, ev.CondCtx, shortcut) + case planServiceWide: + msg = g.evaluateServiceWide(r, p, plan.action, ev.CondCtx, shortcut) + case planUnknownOp, planUnmapped: + if !shortcut { + msg = denyMessage(p, plan.action, "", false) + } + case planNoHandler, planAuthnOnly, planJSONDeny: // decided before the policies are read } - return ctx + return msg } -// clientIP extracts the caller's source IP for the aws:SourceIp condition key. -// It uses ONLY the connection's RemoteAddr (port stripped), never the -// caller-controlled X-Forwarded-For header. The wire server has no trusted -// reverse proxy in front of it, so honoring X-Forwarded-For would let any -// client spoof its source IP and defeat the IpAddress/NotIpAddress conditions. -func clientIP(r *http.Request) string { - if host, _, err := net.SplitHostPort(r.RemoteAddr); err == nil { - return host +// evaluateChecks evaluates each check against the caller's identity policies. +// It returns the decisions recorded for ResourcePolicy checks, and a deny +// message when a check denies the request. +func (g *gateConfig) evaluateChecks( + r *http.Request, p *authctx.Principal, checks []awsauthz.Check, cctx map[string]string, shortcut bool, +) (decisions map[awsauthz.Check]awsauthz.Decision, denied string) { + decisions = map[awsauthz.Check]awsauthz.Decision{} + + for _, c := range checks { + d := awsauthz.Allowed + if !shortcut { + d = g.evaluate(r, p, c, cctx) + } + + switch { + case d == awsauthz.ExplicitDeny: + return nil, denyMessage(p, c.Action, c.Resource, true) + case d == awsauthz.ImplicitDeny && c.Mode == awsauthz.Required: + return nil, denyMessage(p, c.Action, c.Resource, false) + } + + if c.Mode == awsauthz.ResourcePolicy { + decisions[c] = d + } } - return r.RemoteAddr + return decisions, "" } -// deriveResource derives the target resource ARN for an authorized action from -// the request, so resource-scoped Allow/Deny policies apply. It covers the -// services whose JSON-RPC body names a single primary resource; where the -// resource cannot be derived it falls back to "*", which matches any -// resource-scoped statement's "*" and leaves resource-scoped statements for -// other resources non-binding (the pre-existing behavior). -func deriveResource(service string, body []byte, region, accountID string) string { +// evaluateServiceWide allows the caller only when its policies grant every +// action of the service on every resource. action is ":*". +func (g *gateConfig) evaluateServiceWide( + r *http.Request, p *authctx.Principal, action string, cctx map[string]string, shortcut bool, +) string { + if shortcut { + return "" + } + + pe, ok := g.iam.(iamdriver.PermissionEvaluator) + if !ok { + return denyMessage(p, action, "*", false) + } + + d := pe.EvaluateServiceWide(r.Context(), p.UserName, strings.TrimSuffix(action, ":*"), cctx) + if d == iamdriver.DecisionAllowed { + return "" + } + + return denyMessage(p, action, "*", d == iamdriver.DecisionExplicitDeny) +} + +// evaluate returns the identity decision for one check. An empty Resource is +// evaluated as an unknown resource. +func (g *gateConfig) evaluate(r *http.Request, p *authctx.Principal, c awsauthz.Check, cctx map[string]string) awsauthz.Decision { + if pe, ok := g.iam.(iamdriver.PermissionEvaluator); ok { + return awsauthz.Decision(pe.EvaluatePermission(r.Context(), iamdriver.EvalRequest{ + Principal: p.UserName, Action: c.Action, Resource: c.Resource, ResourceKnown: c.Resource != "", Context: cctx, + })) + } + + resource := c.Resource + if resource == "" { + resource = "*" + } + + if checkPermission(r, p, g.iam, c.Action, resource, cctx) { + return awsauthz.Allowed + } + + return awsauthz.ImplicitDeny +} + +// checkPermission is the fallback for an IAM driver without the tri-state +// PermissionEvaluator: a plain allow/deny for one action and resource. +func checkPermission( + r *http.Request, p *authctx.Principal, iamDriver iamdriver.IAM, action, resource string, cctx map[string]string, +) bool { + if ca, ok := iamDriver.(iamdriver.ContextualAuthorizer); ok { + allowed, err := ca.CheckPermissionWithContext(r.Context(), p.UserName, action, resource, cctx) + return err == nil && allowed + } + + allowed, err := iamDriver.CheckPermission(r.Context(), p.UserName, action, resource) + + return err == nil && allowed +} + +// deriveResource names the target resource of a JSON-RPC request for the +// services whose body carries one primary resource. Elsewhere it returns "" +// (unknown), which is evaluated conservatively so a resource-scoped Deny +// still applies. +func deriveResource(service string, body []byte, s awsauthz.Scope) string { if service == "dynamodb" { if name := jsonField(body, "TableName"); name != "" { - return "arn:aws:dynamodb:" + region + ":" + accountID + ":table/" + name + return "arn:" + s.Partition + ":dynamodb:" + s.Region + ":" + s.AccountID + ":table/" + name } } - return "*" + return "" } // jsonField extracts a single top-level string field from a JSON-RPC request @@ -225,46 +352,27 @@ func jsonField(body []byte, field string) string { return "" } -// deriveAction maps an incoming request to its IAM action (e.g. dynamodb:PutItem) -// using only signals bound to how the request is dispatched. For JSON-RPC it uses -// the X-Amz-Target header: the prefix selects the service via jsonRPCServiceByTarget -// (the same header the dispatcher routes on) and the suffix is the operation. A -// JSON-RPC request whose target prefix is not mapped returns authzDeny (fail -// closed). All other protocols (query, REST) return authzSkip: authenticate only. -func deriveAction(r *http.Request) (service, action string, decision authzDecision) { +// jsonRPCTarget splits X-Amz-Target into the IAM service (through +// jsonRPCServiceByTarget) and the operation. ok=false when the header is +// missing, names no operation, or its prefix is not in the table. +func jsonRPCTarget(r *http.Request) (service, op string, ok bool) { target := r.Header.Get("X-Amz-Target") if target == "" { - return "", "", authzSkip - } - - prefix := target[:strings.LastIndexByte(target, '.')+1] - op := jsonRPCTargetOperation(r) - - svc, ok := jsonRPCServiceByTarget[prefix] - if !ok || op == "" { - return svc, "", authzDeny + return "", "", false } - return svc, svc + ":" + op, authzEnforce -} + dot := strings.LastIndexByte(target, '.') + op = target[dot+1:] + service, ok = jsonRPCServiceByTarget[target[:dot+1]] -// jsonRPCTargetOperation returns the operation suffix of the X-Amz-Target header -// (the part after the last "."), or "" when absent. -func jsonRPCTargetOperation(r *http.Request) string { - target := r.Header.Get("X-Amz-Target") - if target == "" { - return "" - } - - return target[strings.LastIndexByte(target, '.')+1:] + return service, op, ok && op != "" } // isAdminPrincipal reports whether p is the account-root / bootstrap admin // identity, which is always allowed (mirroring real IAM, where root has full // access). A verified long-term key always resolves to a named IAM user, so in -// practice this guards only an explicit root identity and the defensive -// empty-name case; STS temporary (ASIA) credentials are allowed earlier, in the -// authentication gate, without reaching here. +// practice this guards an explicit root identity and the defensive empty-name +// case. func isAdminPrincipal(p authctx.Principal) bool { return p.UserName == "" || p.UserName == "root" || strings.HasSuffix(p.ARN, ":root") } @@ -282,17 +390,58 @@ func principalHasPolicies(r *http.Request, p authctx.Principal, iamDriver iamdri return inspector.PrincipalHasPolicies(r.Context(), p.UserName) } -// writeAuthzDenied renders a 403 authorization failure. Enforced authorization is -// JSON-RPC only, so the response is always AccessDeniedException in the JSON-RPC -// error shape. -func writeAuthzDenied(w http.ResponseWriter, p authctx.Principal, action string) { - msg := "User: " + principalARN(p) + " is not authorized to perform: " + action +// denyMessage is the AWS AccessDenied message for action on resource. An +// unknown resource is shown as "*"; with no action (a request the gate could +// not bind to any IAM service) the message names none. +func denyMessage(p *authctx.Principal, action, resource string, explicit bool) string { + if action == "" { + return "User: " + principalARN(p) + " is not authorized to perform this request" + } + + if resource == "" { + resource = "*" + } + + msg := "User: " + principalARN(p) + " is not authorized to perform: " + action + " on resource: " + resource + + if explicit { + return msg + " with an explicit deny in an identity-based policy" + } + + return msg + " because no identity-based policy allows the " + action + " action" +} + +// writeAccessDenied renders an authorization 403. A handler with its own 403 +// shape writes it; otherwise the shape follows the request: the query +// protocol gets the XML AccessDenied error, everything else the JSON +// AccessDeniedException (with X-Amzn-Errortype). +func writeAccessDenied(w http.ResponseWriter, r *http.Request, h server.Handler, msg string) { + if dw, ok := h.(awsauthz.DenyWriter); ok { + dw.WriteAccessDenied(w, r, msg) + return + } + + if isQueryShaped(r) { + awsquery.WriteXMLError(w, http.StatusForbidden, "AccessDenied", msg) + return + } + wire.WriteJSONError(w, http.StatusForbidden, "AccessDeniedException", msg) } +// isQueryShaped reports whether r is an AWS query-protocol request: no +// X-Amz-Target, and a form body or an Action in the query string. +func isQueryShaped(r *http.Request) bool { + if r.Header.Get("X-Amz-Target") != "" { + return false + } + + return strings.HasPrefix(r.Header.Get("Content-Type"), urlEncodedForm) || r.URL.Query().Get("Action") != "" +} + // principalARN returns a stable identifier for the caller in an error message, // preferring the resolved ARN and falling back to the user name. -func principalARN(p authctx.Principal) string { +func principalARN(p *authctx.Principal) string { if p.ARN != "" { return p.ARN } diff --git a/server/aws/authzgate_cloudwatch_test.go b/server/aws/authzgate_cloudwatch_test.go index f16044ac6..16b25fc16 100644 --- a/server/aws/authzgate_cloudwatch_test.go +++ b/server/aws/authzgate_cloudwatch_test.go @@ -18,17 +18,6 @@ import ( iamdriver "github.com/stackshy/cloudemu/v2/services/iam/driver" ) -// cloudWatchJSONProbe answers any CloudWatch awsJson1_0 request with 200. -type cloudWatchJSONProbe struct{} - -func (cloudWatchJSONProbe) Matches(r *http.Request) bool { - return strings.HasPrefix(r.Header.Get("X-Amz-Target"), "GraniteServiceVersion20100801.") -} - -func (cloudWatchJSONProbe) ServeHTTP(w http.ResponseWriter, _ *http.Request) { - _, _ = io.WriteString(w, "{}") -} - func signedCloudWatchJSONRequest(t *testing.T, url, op string, creds aws.Credentials) *http.Request { t.Helper() @@ -82,17 +71,17 @@ func TestAuthzGateCloudWatchJSON(t *testing.T) { t.Fatalf("CreateAccessKey: %v", err) } - srv := New(Drivers{IAM: cloud.IAM, AccountID: "123456789012", Region: "us-east-1", EnforceAuth: true}) - srv.Register(cloudWatchJSONProbe{}) + srv := New(Drivers{IAM: cloud.IAM, CloudWatch: cloud.CloudWatch, AccountID: "123456789012", Region: "us-east-1", EnforceAuth: true}) ts := httptest.NewServer(srv) defer ts.Close() creds := aws.Credentials{AccessKeyID: ak.AccessKeyID, SecretAccessKey: ak.SecretAccessKey} + // PutMetricData with an empty body reaches the handler, which rejects it. for op, want := range map[string]int{ "DescribeAlarms": http.StatusOK, - "PutMetricData": http.StatusOK, + "PutMetricData": http.StatusBadRequest, "DeleteAlarms": http.StatusForbidden, } { resp, err := http.DefaultClient.Do(signedCloudWatchJSONRequest(t, ts.URL+"/", op, creds)) diff --git a/server/aws/authzgate_test.go b/server/aws/authzgate_test.go index dfdd2ef8e..a0faa50aa 100644 --- a/server/aws/authzgate_test.go +++ b/server/aws/authzgate_test.go @@ -4,7 +4,6 @@ import ( "context" "crypto/sha256" "encoding/hex" - "io" "net/http" "net/http/httptest" "strings" @@ -18,18 +17,6 @@ import ( iamdriver "github.com/stackshy/cloudemu/v2/services/iam/driver" ) -// dynamoProbe answers any DynamoDB JSON-RPC request with 200, so a test can -// observe whether the authorization gate let the request through to dispatch. -type dynamoProbe struct{} - -func (dynamoProbe) Matches(r *http.Request) bool { - return strings.HasPrefix(r.Header.Get("X-Amz-Target"), "DynamoDB_20120810.") -} - -func (dynamoProbe) ServeHTTP(w http.ResponseWriter, _ *http.Request) { - _, _ = io.WriteString(w, "ok") -} - // signedDynamoRequest builds and SigV4-signs a DynamoDB PutItem request whose // body targets the given table. func signedDynamoRequest(t *testing.T, url, table string, creds aws.Credentials) *http.Request { @@ -83,23 +70,23 @@ func TestAuthzGateResourceScoped(t *testing.T) { t.Fatalf("CreateAccessKey: %v", err) } - srv := New(Drivers{IAM: cloud.IAM, AccountID: "123456789012", Region: "us-east-1", EnforceAuth: true}) - srv.Register(dynamoProbe{}) + srv := New(Drivers{IAM: cloud.IAM, DynamoDB: cloud.DynamoDB, AccountID: "123456789012", Region: "us-east-1", EnforceAuth: true}) ts := httptest.NewServer(srv) defer ts.Close() creds := aws.Credentials{AccessKeyID: ak.AccessKeyID, SecretAccessKey: ak.SecretAccessKey} - // Matching table: the resource-scoped Allow applies, so the request proceeds. + // Matching table: the resource-scoped Allow applies, so the request reaches + // DynamoDB (which answers that the table does not exist). resp, err := http.DefaultClient.Do(signedDynamoRequest(t, ts.URL+"/", "allowed", creds)) if err != nil { t.Fatalf("do allowed: %v", err) } resp.Body.Close() - if resp.StatusCode != http.StatusOK { - t.Fatalf("matching table: want 200, got %d", resp.StatusCode) + if resp.StatusCode == http.StatusForbidden { + t.Fatalf("matching table: denied") } // Non-matching table: the Allow no longer applies, so the gate denies (403). diff --git a/server/aws/aws.go b/server/aws/aws.go index b9a6bd637..b2c1e42f4 100644 --- a/server/aws/aws.go +++ b/server/aws/aws.go @@ -89,6 +89,7 @@ import ( transfersrv "github.com/stackshy/cloudemu/v2/server/aws/transfer" vpclatticesrv "github.com/stackshy/cloudemu/v2/server/aws/vpclattice" wafv2srv "github.com/stackshy/cloudemu/v2/server/aws/wafv2" + "github.com/stackshy/cloudemu/v2/server/wire/awsauthz" "github.com/stackshy/cloudemu/v2/server/wire/awsidentity" acmdriver "github.com/stackshy/cloudemu/v2/services/acm/driver" aossdriver "github.com/stackshy/cloudemu/v2/services/aoss/driver" @@ -437,12 +438,18 @@ type Drivers struct { // verified: ASIA against the secret STS minted for that session (rejected // if forged or expired). // - // It also enforces IAM authorization for JSON-RPC services (the operation is - // bound to the X-Amz-Target header the dispatcher routes on); query and REST - // services are authenticated only, because their executed operation cannot be - // soundly bound to an IAM service before dispatch. Authorization applies only - // to principals that have IAM policies defined; a policy-less user/role and - // the account-admin/root and ASIA identities are left unrestricted. + // Every authenticated request is then authorized against the caller's IAM + // policies, bound to the handler that dispatch will run. Query services + // (IAM, STS, EC2 and Auto Scaling, RDS, Redshift, ElastiCache, ELBv2, SNS, + // CloudFormation, CloudWatch) and SageMaker are checked per operation. + // JSON-RPC services are checked per operation through X-Amz-Target. 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 + // IAM users with no policies are unrestricted (bootstrap); role sessions are + // always evaluated on the role's policies. Operations AWS serves without + // credentials, and the Kubernetes data plane, are not IAM-authorized. EnforceAuth bool // Clock drives SigV4 timestamp-expiry evaluation and STS temporary-credential // expiry when EnforceAuth is on. Nil uses the real clock; tests inject a @@ -583,10 +590,35 @@ func NewFromProvider(p *awsprovider.Provider) *server.Server { // // keeps the caller API ergonomic (awsserver.New(Drivers{...})). // -//nolint:gocritic,gocyclo,funlen,gocognit // by-value Drivers for ergonomics; one if-per-driver dispatch grows with the bundle. +//nolint:gocritic // by-value Drivers for ergonomics. func New(d Drivers) *server.Server { + srv, _ := newServer(d) + + return srv +} + +// authzSets are the handler sets the auth gate needs beyond the handlers' +// own declarations: which handlers route on X-Amz-Target, and which IAM does +// not govern. +type authzSets struct { + jsonRPC, authnOnly map[server.Handler]bool +} + +// newServer builds the AWS server and returns it with its authzSets. +// +//nolint:gocritic,gocyclo,funlen,gocognit // by-value Drivers for ergonomics; one if-per-driver dispatch grows with the bundle. +func newServer(d Drivers) (*server.Server, authzSets) { srv := server.New() + // The auth gate reads X-Amz-Target only for the handlers registered + // through rpc, which route on that header. + jsonRPC := map[server.Handler]bool{} + rpc := func(h server.Handler) server.Handler { + jsonRPC[h] = true + + return h + } + if d.CloudWatch != nil { // The VPC driver optionally supplies derived AWS/IPAM metrics; surface // them through CloudWatch when it implements the capability. @@ -597,24 +629,24 @@ func New(d Drivers) *server.Server { } if d.DynamoDB != nil { - srv.Register(dynamodb.New(d.DynamoDB)) + srv.Register(rpc(dynamodb.New(d.DynamoDB))) // DynamoDB Streams shares the DynamoDB host but uses the disjoint // X-Amz-Target prefix DynamoDBStreams_20120810.* (vs DynamoDB_20120810.* // and AmazonSQS.*), so its Matches predicate never collides. - srv.Register(dynamodb.NewStreams(d.DynamoDB)) + srv.Register(rpc(dynamodb.NewStreams(d.DynamoDB))) } // SQS shares the X-Amz-Target header with DynamoDB but uses a different // prefix (AmazonSQS.* vs DynamoDB_20120810.*); their Matches predicates // are mutually exclusive. if d.SQS != nil { - srv.Register(sqs.New(d.SQS)) + srv.Register(rpc(sqs.New(d.SQS))) } // Resource Groups Tagging API: X-Amz-Target prefix // ResourceGroupsTaggingAPI_20170126.*, disjoint from DynamoDB/SQS. if d.ResourceDiscovery != nil { - srv.Register(resourcegroupstaggingapi.New(d.ResourceDiscovery)) + srv.Register(rpc(resourcegroupstaggingapi.New(d.ResourceDiscovery))) } // RDS must be registered before EC2: both speak AWS query-protocol on @@ -632,138 +664,138 @@ func New(d Drivers) *server.Server { } if d.ECR != nil { - srv.Register(ecr.New(d.ECR)) + srv.Register(rpc(ecr.New(d.ECR))) } // Secrets Manager matches the X-Amz-Target prefix "secretsmanager.", // disjoint from DynamoDB, SQS, ECR, SageMaker, and the tagging API. if d.SecretsManager != nil { - srv.Register(secretsmanagersrv.New(d.SecretsManager)) + srv.Register(rpc(secretsmanagersrv.New(d.SecretsManager))) } // KMS matches the X-Amz-Target prefix "TrentService.", disjoint from // DynamoDB, SQS, ECR, SageMaker, Secrets Manager, and the tagging API. if d.KMS != nil { - srv.Register(kmssrv.New(d.KMS)) + srv.Register(rpc(kmssrv.New(d.KMS))) } // ACM matches the X-Amz-Target prefix "CertificateManager.", disjoint // from the other JSON 1.1 services. if d.ACM != nil { - srv.Register(acmsrv.New(d.ACM)) + srv.Register(rpc(acmsrv.New(d.ACM))) } // Step Functions matches the X-Amz-Target prefix "AWSStepFunctions.", // disjoint from every other JSON-RPC service, so registration order is free. if d.SFN != nil { - srv.Register(sfnsrv.New(d.SFN)) + srv.Register(rpc(sfnsrv.New(d.SFN))) } // Kinesis matches the X-Amz-Target prefix "Kinesis_20131202.", disjoint // from the other JSON 1.1 services, so registration order is unconstrained. if d.Kinesis != nil { - srv.Register(kinesissrv.New(d.Kinesis)) + srv.Register(rpc(kinesissrv.New(d.Kinesis))) } // CloudTrail matches the X-Amz-Target prefix "CloudTrail_20131101.", // disjoint from the other JSON 1.1 services, so registration order is free. if d.CloudTrail != nil { - srv.Register(cloudtrailsrv.New(d.CloudTrail)) + srv.Register(rpc(cloudtrailsrv.New(d.CloudTrail))) } // Glue matches the X-Amz-Target prefix "AWSGlue.", disjoint from the other // JSON 1.1 services, so registration order is unconstrained. if d.Glue != nil { - srv.Register(gluesrv.New(d.Glue)) + srv.Register(rpc(gluesrv.New(d.Glue))) } // AOSS (OpenSearch Serverless) matches the X-Amz-Target prefix // "OpenSearchServerless.", disjoint from the other JSON-RPC services, so // registration order is unconstrained. if d.AOSS != nil { - srv.Register(aosssrv.New(d.AOSS)) + srv.Register(rpc(aosssrv.New(d.AOSS))) } // Kendra matches the X-Amz-Target prefix "AWSKendraFrontendService.", // disjoint from the other JSON 1.1 services, so registration order is // unconstrained. if d.Kendra != nil { - srv.Register(kendrasrv.New(d.Kendra)) + srv.Register(rpc(kendrasrv.New(d.Kendra))) } // Athena matches the X-Amz-Target prefix "AmazonAthena.", disjoint from the // other JSON 1.1 services, so registration order is unconstrained. if d.Athena != nil { - srv.Register(athenasrv.New(d.Athena)) + srv.Register(rpc(athenasrv.New(d.Athena))) } // TimestreamWrite matches the X-Amz-Target prefix "Timestream_20181101.", // disjoint from the other JSON-RPC services, so registration order is // unconstrained. if d.TimestreamWrite != nil { - srv.Register(timestreamwritesrv.New(d.TimestreamWrite)) + srv.Register(rpc(timestreamwritesrv.New(d.TimestreamWrite))) } // HealthLake matches the X-Amz-Target prefix "HealthLake.", disjoint from // the other JSON-RPC services, so registration order is unconstrained. if d.HealthLake != nil { - srv.Register(healthlakesrv.New(d.HealthLake)) + srv.Register(rpc(healthlakesrv.New(d.HealthLake))) } // GlobalAccelerator matches the X-Amz-Target prefix // "GlobalAccelerator_V20180706.", disjoint from the other JSON-RPC targets. if d.GlobalAccelerator != nil { - srv.Register(globalacceleratorsrv.New(d.GlobalAccelerator)) + srv.Register(rpc(globalacceleratorsrv.New(d.GlobalAccelerator))) } // AppRunner matches the X-Amz-Target prefix "AppRunner.", disjoint from the // other JSON-RPC services, so registration order is unconstrained. if d.AppRunner != nil { - srv.Register(apprunnersrv.New(d.AppRunner)) + srv.Register(rpc(apprunnersrv.New(d.AppRunner))) } // Transfer matches the X-Amz-Target prefix "TransferService.", disjoint from // the other JSON 1.1 services, so registration order is unconstrained. if d.Transfer != nil { - srv.Register(transfersrv.New(d.Transfer)) + srv.Register(rpc(transfersrv.New(d.Transfer))) } // Cognito matches the X-Amz-Target prefix // "AWSCognitoIdentityProviderService.", disjoint from the other JSON 1.1 // services, so registration order is unconstrained. if d.Cognito != nil { - srv.Register(cognitosrv.New(d.Cognito)) + srv.Register(rpc(cognitosrv.New(d.Cognito))) } // AWS Config matches the X-Amz-Target prefix "StarlingDoveService.", // disjoint from the other JSON 1.1 services, so registration order is free. if d.Config != nil { - srv.Register(configservicesrv.New(d.Config, d.AccountID, d.Region)) + srv.Register(rpc(configservicesrv.New(d.Config, d.AccountID, d.Region))) } // WAFv2 matches the X-Amz-Target prefix "AWSWAF_20190729.", disjoint from // the other JSON 1.1 services, so registration order is unconstrained. if d.WAFv2 != nil { - srv.Register(wafv2srv.New(d.WAFv2)) + srv.Register(rpc(wafv2srv.New(d.WAFv2))) } // Cost Explorer matches the X-Amz-Target prefix "AWSInsightsIndexService.", // disjoint from the other JSON 1.1 services, so registration order is free. if d.CostExplorer != nil { - srv.Register(costexplorersrv.New(d.CostExplorer)) + srv.Register(rpc(costexplorersrv.New(d.CostExplorer))) } // Service Quotas matches the X-Amz-Target prefix "ServiceQuotasV20190624.", // disjoint from the other JSON 1.1 services, so registration order is free. if d.ServiceQuotas != nil { - srv.Register(servicequotassrv.New(d.ServiceQuotas, d.AccountID, d.Region)) + srv.Register(rpc(servicequotassrv.New(d.ServiceQuotas, d.AccountID, d.Region))) } // EMR matches the X-Amz-Target prefix "ElasticMapReduce.", disjoint from the // other JSON 1.1 services, so registration order is free. It carries its own // in-memory cluster/step store (no backing driver). if d.EMR { - srv.Register(emrsrv.New(d.AccountID, d.Region, d.Clock)) + srv.Register(rpc(emrsrv.New(d.AccountID, d.Region, d.Clock))) } // Kinesis Video Streams is a REST-JSON service dispatched on POST to @@ -803,7 +835,7 @@ func New(d Drivers) *server.Server { // which is disjoint from DynamoDB, SQS, ECR, SageMaker, Secrets Manager, SSM, // EventBridge, and the tagging API, so registration order is unconstrained. if d.ECS != nil { - srv.Register(ecssrv.New(d.ECS)) + srv.Register(rpc(ecssrv.New(d.ECS))) } // VPC Lattice is a REST/JSON service rooted at path prefixes like @@ -966,27 +998,27 @@ func New(d Drivers) *server.Server { // Route53Resolver matches the X-Amz-Target prefix "Route53Resolver.", // disjoint from the other JSON 1.1 services, so registration order is free. if d.Route53Resolver != nil { - srv.Register(route53resolversrv.New(d.Route53Resolver)) + srv.Register(rpc(route53resolversrv.New(d.Route53Resolver))) } // SSM Parameter Store matches the X-Amz-Target prefix "AmazonSSM.", // disjoint from DynamoDB, SQS, ECR, SageMaker, Secrets Manager, EventBridge, // CloudWatch Logs, and the tagging API. if d.SSM != nil { - srv.Register(ssmsrv.New(d.SSM)) + srv.Register(rpc(ssmsrv.New(d.SSM))) } // EventBridge matches the X-Amz-Target prefix "AWSEvents.", disjoint from // DynamoDB, SQS, ECR, SageMaker, Secrets Manager, and the tagging API. if d.EventBridge != nil { - srv.Register(eventbridge.New(d.EventBridge, d.AccountID, d.Region)) + srv.Register(rpc(eventbridge.New(d.EventBridge, d.AccountID, d.Region))) } // CloudWatch Logs matches the X-Amz-Target prefix "Logs_20140328.", // disjoint from DynamoDB, SQS, Secrets Manager, ECR, SageMaker, and the // tagging API, so registration order relative to them is unconstrained. if d.CloudWatchLogs != nil { - srv.Register(cloudwatchlogssrv.New(d.CloudWatchLogs)) + srv.Register(rpc(cloudwatchlogssrv.New(d.CloudWatchLogs))) } // Redshift sits with the other query-protocol handlers before the EC2 @@ -1017,20 +1049,20 @@ func New(d Drivers) *server.Server { // prefix, so its dispatch is disjoint from every query-protocol handler and // registration order relative to the EC2 catch-all does not matter. if d.MemoryDB != nil { - srv.Register(memorydbsrv.New(d.MemoryDB)) + srv.Register(rpc(memorydbsrv.New(d.MemoryDB))) } // Network Firewall speaks AWS JSON 1.0 and matches on the // "NetworkFirewall_20201112." target prefix, so its dispatch is disjoint // from every other handler. if d.NetworkFirewall != nil { - srv.Register(networkfirewallsrv.New(d.NetworkFirewall)) + srv.Register(rpc(networkfirewallsrv.New(d.NetworkFirewall))) } // Keyspaces speaks AWS JSON 1.0 and matches on the "KeyspacesService." target // prefix, so its dispatch is disjoint from every other handler. if d.Keyspaces != nil { - srv.Register(keyspacessrv.New(d.Keyspaces)) + srv.Register(rpc(keyspacessrv.New(d.Keyspaces))) } // SNS also speaks the AWS query protocol; its action set (CreateTopic, @@ -1187,8 +1219,13 @@ func New(d Drivers) *server.Server { // Kubernetes data-plane API. Matches /k8s/{uid}/..., disjoint from // every other AWS path. Registered before S3's REST fallback. + authnOnly := map[server.Handler]bool{} + if d.K8sAPI != nil { + // The Kubernetes data plane is governed by Kubernetes RBAC, not IAM. srv.Register(d.K8sAPI) + + authnOnly[d.K8sAPI] = true } // Resource Explorer 2 uses REST-JSON with fixed top-level paths @@ -1239,14 +1276,37 @@ func New(d Drivers) *server.Server { // on, and adds no request-path change beyond a context value. var authGate func(http.ResponseWriter, *http.Request) (*http.Request, bool) if d.EnforceAuth { - authGate = newAuthGate(d.IAM, d.AccountID, stsSessions, authClock, srv.Match) + authGate = newAuthGate(&gateConfig{ + iam: d.IAM, + scope: serverScope(&d), + sessions: stsSessions, + clock: authClock, + match: srv.Match, + jsonRPC: jsonRPC, + authnOnly: authnOnly, + }) } srv.SetPreDispatch(composePreDispatch(newRegionStamp(), authGate)) - return srv + return srv, authzSets{jsonRPC: jsonRPC, authnOnly: authnOnly} } +// serverScope is the account, region and partition the gate authorizes in. +// It comes from the server's own configuration, never from the request. +func serverScope(d *Drivers) awsauthz.Scope { + region := d.Region + if region == "" { + region = defaultScopeRegion + } + + return awsauthz.Scope{AccountID: d.AccountID, Region: region, Partition: "aws"} +} + +// defaultScopeRegion is the region assumed when Drivers names none, matching +// the STS handler's default. +const defaultScopeRegion = "us-east-1" + // composePreDispatch chains pre-dispatch hooks left to right: each may rewrite // the request (the next hook sees the rewrite) and any may stop dispatch. Nil // hooks are skipped, so an absent auth gate adds nothing. diff --git a/server/aws/backup/handler.go b/server/aws/backup/handler.go index e451400c2..14a82063a 100644 --- a/server/aws/backup/handler.go +++ b/server/aws/backup/handler.go @@ -154,3 +154,7 @@ func atoiDefault(s string) int32 { return int32(n) //nolint:gosec // explicitly range-checked against math.MaxInt32 above; gosec's G109 can't see the guard. } + +// IAMService returns the IAM service prefix of the operations this handler +// serves. +func (*Handler) IAMService() string { return "backup" } diff --git a/server/aws/batch/handler.go b/server/aws/batch/handler.go index 9c1ce3bba..fcced33a4 100644 --- a/server/aws/batch/handler.go +++ b/server/aws/batch/handler.go @@ -189,3 +189,7 @@ func splitPath(p string) []string { return strings.Split(p, "/") } + +// IAMService returns the IAM service prefix of the operations this handler +// serves. +func (*Handler) IAMService() string { return "batch" } diff --git a/server/aws/bedrock/handler.go b/server/aws/bedrock/handler.go index 7a47b38a6..19d1460b0 100644 --- a/server/aws/bedrock/handler.go +++ b/server/aws/bedrock/handler.go @@ -469,3 +469,7 @@ func (h *Handler) serveRuntime(w http.ResponseWriter, r *http.Request, rest stri writeError(w, http.StatusNotFound, "ResourceNotFoundException", "unknown runtime action: "+action) } } + +// IAMService returns the IAM service prefix of the operations this handler +// serves. +func (*Handler) IAMService() string { return "bedrock" } diff --git a/server/aws/bedrockagent/handler.go b/server/aws/bedrockagent/handler.go index c06a93c94..62c647fdb 100644 --- a/server/aws/bedrockagent/handler.go +++ b/server/aws/bedrockagent/handler.go @@ -176,3 +176,10 @@ func writeJSON(w http.ResponseWriter, v any) { w.WriteHeader(http.StatusOK) _ = json.NewEncoder(w).Encode(v) } + +// IAMService returns the IAM service prefix of the operations this handler +// serves. +func (*Handler) IAMService() string { return iamServicePrefix } + +// iamServicePrefix is the IAM namespace of the Agents for Bedrock actions. +const iamServicePrefix = "bedrock" diff --git a/server/aws/bedrockagentruntime/handler.go b/server/aws/bedrockagentruntime/handler.go index b38eac399..ba2a599fb 100644 --- a/server/aws/bedrockagentruntime/handler.go +++ b/server/aws/bedrockagentruntime/handler.go @@ -118,3 +118,7 @@ func (h *Handler) serveRetrieve(w http.ResponseWriter, r *http.Request, p string h.retrieve(w, r, parts[1]) } + +// IAMService returns the IAM service prefix of the operations this handler +// serves. +func (*Handler) IAMService() string { return "bedrock" } diff --git a/server/aws/cloudformation/handler.go b/server/aws/cloudformation/handler.go index ad66e396f..26e495d1c 100644 --- a/server/aws/cloudformation/handler.go +++ b/server/aws/cloudformation/handler.go @@ -46,6 +46,7 @@ import ( "strings" cerrors "github.com/stackshy/cloudemu/v2/errors" + "github.com/stackshy/cloudemu/v2/server/wire/awsauthz" "github.com/stackshy/cloudemu/v2/server/wire/awsquery" cfn "github.com/stackshy/cloudemu/v2/services/cloudformation" ) @@ -185,3 +186,14 @@ func writeErr(w http.ResponseWriter, err error) { awsquery.WriteXMLError(w, http.StatusInternalServerError, "InternalFailure", msg) } } + +// IAMService returns the IAM service prefix of the operations this handler +// serves. +func (*Handler) IAMService() string { return "cloudformation" } + +// IAMChecks names the IAM action of a request from the form Action that +// ServeHTTP dispatches on. An Action the handler does not know is authorized +// as such and then answered with InvalidAction, so nothing runs. +func (h *Handler) IAMChecks(r *http.Request, _ awsauthz.Scope) ([]awsauthz.Check, bool) { + return awsauthz.QueryChecks(r, h.IAMService()) +} diff --git a/server/aws/cloudfront/handler.go b/server/aws/cloudfront/handler.go index 8c9a9eafb..12dfd701c 100644 --- a/server/aws/cloudfront/handler.go +++ b/server/aws/cloudfront/handler.go @@ -140,3 +140,13 @@ func (h *Handler) serveDistributionConfig(w http.ResponseWriter, r *http.Request writeError(w, http.StatusMethodNotAllowed, "MethodNotAllowed", "method not allowed") } } + +// IAMService returns the IAM service prefix of the operations this handler +// serves. +func (*Handler) IAMService() string { return "cloudfront" } + +// WriteAccessDenied writes the 403 this service returns when IAM denies a +// call, in its own XML error shape. +func (*Handler) WriteAccessDenied(w http.ResponseWriter, _ *http.Request, msg string) { + writeError(w, http.StatusForbidden, "AccessDenied", msg) +} diff --git a/server/aws/cloudtrail/handler.go b/server/aws/cloudtrail/handler.go index 80894ff2c..069e3228a 100644 --- a/server/aws/cloudtrail/handler.go +++ b/server/aws/cloudtrail/handler.go @@ -192,3 +192,7 @@ func writeErr(w http.ResponseWriter, err error) { wire.WriteJSONError(w, http.StatusInternalServerError, "InternalFailureException", msg) } } + +// IAMService returns the IAM service prefix of the operations this handler +// serves. +func (*Handler) IAMService() string { return "cloudtrail" } diff --git a/server/aws/cloudwatch/authz.go b/server/aws/cloudwatch/authz.go new file mode 100644 index 000000000..3b5d56269 --- /dev/null +++ b/server/aws/cloudwatch/authz.go @@ -0,0 +1,39 @@ +package cloudwatch + +import ( + "net/http" + + "github.com/stackshy/cloudemu/v2/server/wire/awsauthz" +) + +// IAMChecks names the IAM action of a request from the operation dispatch +// will run, picked by the same cloudwatchOp ServeHTTP uses for all three +// protocols. A CBOR request with a query-string Action, or a JSON target +// alongside a form Action, is therefore authorized as the operation that +// actually executes. An unknown operation is authorized as such and then +// answered with an error, so nothing runs. +func (h *Handler) IAMChecks(r *http.Request, _ awsauthz.Scope) ([]awsauthz.Check, bool) { + decodeRequestBody(r) + + op, _, err := cloudwatchOp(r) + if err != nil || op == "" { + return nil, false + } + + return awsauthz.Single(h.IAMService()+":"+op, ""), true +} + +// WriteAccessDenied writes the 403 in the protocol of the request: the query +// XML error, the awsJson1_0 error, or the rpc-v2-cbor error. +func (*Handler) WriteAccessDenied(w http.ResponseWriter, r *http.Request, msg string) { + _, proto, _ := cloudwatchOp(r) + + switch proto { + case protoQuery: + writeQueryError(w, http.StatusForbidden, "AccessDenied", msg) + case protoJSON: + (&jsonWriter{w: w}).writeError(http.StatusForbidden, "AccessDeniedException", msg) + case protoCBOR: + writeCBORError(w, http.StatusForbidden, "AccessDeniedException", msg) + } +} diff --git a/server/aws/cloudwatch/authz_test.go b/server/aws/cloudwatch/authz_test.go new file mode 100644 index 000000000..7d5a4ee8d --- /dev/null +++ b/server/aws/cloudwatch/authz_test.go @@ -0,0 +1,103 @@ +package cloudwatch + +import ( + "bytes" + "compress/gzip" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/stackshy/cloudemu/v2/server/wire/awsauthz" +) + +const monitoringAuth = "AWS4-HMAC-SHA256 Credential=AKID/20260101/us-east-1/monitoring/aws4_request, SignedHeaders=host, Signature=0" + +func cwRequest(path, ctype, body string, header map[string]string) *http.Request { + r := httptest.NewRequest(http.MethodPost, path, strings.NewReader(body)) + r.Header.Set("Content-Type", ctype) + r.Header.Set("Authorization", monitoringAuth) + + for k, v := range header { + r.Header.Set(k, v) + } + + return r +} + +func gzipBytes(s string) string { + var buf bytes.Buffer + + zw := gzip.NewWriter(&buf) + _, _ = zw.Write([]byte(s)) + _ = zw.Close() + + return buf.String() +} + +func TestIAMChecksFollowsDispatch(t *testing.T) { + h := New(nil) + cbor := map[string]string{protocolHeader: protocolValue} + target := map[string]string{jsonTargetHeader: jsonTargetPrefix + "PutMetricData"} + + cases := []struct { + name string + req *http.Request + want string + }{ + {"cbor", cwRequest(pathPrefix+"GraniteServiceVersion20100801/operation/PutMetricData", "application/cbor", "", cbor), + "cloudwatch:PutMetricData"}, + {"cbor with a query Action", cwRequest(pathPrefix+"GraniteServiceVersion20100801/operation/PutMetricData?Action=DescribeAlarms", + "application/cbor", "", cbor), "cloudwatch:PutMetricData"}, + {"json target with a query Action", cwRequest("/?Action=DescribeAlarms", "application/x-amz-json-1.0", "{}", target), + "cloudwatch:PutMetricData"}, + {"json target with a form body", cwRequest("/", formContentTypeForTest, "Action=DescribeAlarms", target), + "cloudwatch:PutMetricData"}, + {"query", cwRequest("/", formContentTypeForTest, "Action=DescribeAlarms", nil), "cloudwatch:DescribeAlarms"}, + {"gzip query", cwRequest("/", formContentTypeForTest, gzipBytes("Action=PutMetricData"), + map[string]string{"Content-Encoding": "gzip"}), "cloudwatch:PutMetricData"}, + } + + for _, tc := range cases { + checks, ok := h.IAMChecks(tc.req, awsauthz.Scope{}) + if !ok || len(checks) != 1 || checks[0].Action != tc.want { + t.Errorf("%s: got %+v ok=%v, want %s", tc.name, checks, ok, tc.want) + } + } + + for name, req := range map[string]*http.Request{ + "cbor without an op": cwRequest(pathPrefix+"GraniteServiceVersion20100801/operation/", "application/cbor", "", cbor), + "query without an Action": cwRequest("/", formContentTypeForTest, "Version=1", nil), + "query that does not parse": cwRequest("/", formContentTypeForTest, "Action=X&y=%zz", nil), + } { + if checks, ok := h.IAMChecks(req, awsauthz.Scope{}); ok { + t.Errorf("%s: got %+v, want ok=false", name, checks) + } + } +} + +func TestWriteAccessDeniedPerProtocol(t *testing.T) { + h := New(nil) + + for name, tc := range map[string]struct { + req *http.Request + ctype string + code string + }{ + "query": {cwRequest("/", formContentTypeForTest, "Action=PutMetricData", nil), "text/xml", "AccessDenied"}, + "json": {cwRequest("/", "application/x-amz-json-1.0", "{}", map[string]string{jsonTargetHeader: jsonTargetPrefix + "PutMetricData"}), + jsonContentType, "AccessDeniedException"}, + "cbor": {cwRequest(pathPrefix+"GraniteServiceVersion20100801/operation/PutMetricData", "application/cbor", "", + map[string]string{protocolHeader: protocolValue}), "application/cbor", "AccessDeniedException"}, + } { + rec := httptest.NewRecorder() + h.WriteAccessDenied(rec, tc.req, "denied") + + if rec.Code != http.StatusForbidden || !strings.Contains(rec.Body.String(), tc.code) || + !strings.HasPrefix(rec.Header().Get("Content-Type"), tc.ctype) { + t.Errorf("%s: %d %q %q", name, rec.Code, rec.Header().Get("Content-Type"), rec.Body) + } + } +} + +const formContentTypeForTest = "application/x-www-form-urlencoded" diff --git a/server/aws/cloudwatch/handler.go b/server/aws/cloudwatch/handler.go index 58e835f9d..586f96b0b 100644 --- a/server/aws/cloudwatch/handler.go +++ b/server/aws/cloudwatch/handler.go @@ -103,21 +103,55 @@ func (*Handler) Matches(r *http.Request) bool { return isJSONRequest(r) || isQueryRequest(r) } -// ServeHTTP parses the URL path for the operation name and dispatches. +// ServeHTTP picks the protocol and operation with cloudwatchOp and dispatches. func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { decodeRequestBody(r) + op, proto, err := cloudwatchOp(r) + + switch proto { + case protoJSON: + h.serveJSON(w, r, op) + case protoQuery: + h.serveQuery(w, r, op, err) + case protoCBOR: + h.serveCBOR(w, r, op) + } +} + +// protocol is the wire protocol of one CloudWatch request. +type protocol int + +const ( + protoCBOR protocol = iota + protoJSON + protoQuery +) + +// cloudwatchOp returns the operation and protocol of r, in the order +// ServeHTTP has always used: awsJson1_0 (X-Amz-Target) unless the request is +// rpc-v2-cbor, then the query protocol (form Action), then rpc-v2-cbor (the +// operation in the URL path). Both dispatch and IAMChecks call it, so the +// action the gate authorizes is the one that runs. err is the form parse +// error of a query request. Call it after decodeRequestBody. +func cloudwatchOp(r *http.Request) (op string, proto protocol, err error) { if r.Header.Get(protocolHeader) != protocolValue && isJSONRequest(r) { - h.serveJSON(w, r) - return + return jsonOperation(r), protoJSON, nil } if isQueryRequest(r) { - h.serveQuery(w, r) - return + if err := r.ParseForm(); err != nil { + return "", protoQuery, err + } + + return r.Form.Get("Action"), protoQuery, nil } - op := extractOperation(r.URL.Path) + return extractOperation(r.URL.Path), protoCBOR, nil +} + +// serveCBOR handles a CloudWatch rpc-v2-cbor request. +func (h *Handler) serveCBOR(w http.ResponseWriter, r *http.Request, op string) { if op == "" { writeCBORError(w, http.StatusBadRequest, "InvalidRequest", "missing operation in path") return @@ -287,3 +321,7 @@ func writeDriverErr(w http.ResponseWriter, err error) { writeCBORError(w, http.StatusInternalServerError, "InternalError", err.Error()) } } + +// IAMService returns the IAM service prefix of the operations this handler +// serves. +func (*Handler) IAMService() string { return "cloudwatch" } diff --git a/server/aws/cloudwatch/json_protocol.go b/server/aws/cloudwatch/json_protocol.go index 843412985..0813d4e3a 100644 --- a/server/aws/cloudwatch/json_protocol.go +++ b/server/aws/cloudwatch/json_protocol.go @@ -78,7 +78,7 @@ func jsonOperation(r *http.Request) string { } // serveJSON handles a CloudWatch awsJson1_0 request. -func (h *Handler) serveJSON(w http.ResponseWriter, r *http.Request) { +func (h *Handler) serveJSON(w http.ResponseWriter, r *http.Request, op string) { jw := &jsonWriter{w: w} r.Body = http.MaxBytesReader(w, r.Body, maxBodyBytes) @@ -95,7 +95,7 @@ func (h *Handler) serveJSON(w http.ResponseWriter, r *http.Request) { return } - h.dispatch(jw, r, jsonOperation(r), body) + h.dispatch(jw, r, op, body) } // jsonWriter is the http.ResponseWriter the per-op handlers get for a JSON diff --git a/server/aws/cloudwatch/query.go b/server/aws/cloudwatch/query.go index ebfb16ab0..8c2571a87 100644 --- a/server/aws/cloudwatch/query.go +++ b/server/aws/cloudwatch/query.go @@ -41,16 +41,17 @@ func isQueryRequest(r *http.Request) bool { return awsquery.CredentialScopeService(r.Header.Get("Authorization")) == sigV4Service } -// serveQuery handles a CloudWatch query-protocol request. +// serveQuery handles a CloudWatch query-protocol request whose form Action +// is action. parseErr is the form parse error cloudwatchOp hit, if any. // -//nolint:gocyclo // first-match dispatch over many CloudWatch query actions. -func (h *Handler) serveQuery(w http.ResponseWriter, r *http.Request) { - if err := r.ParseForm(); err != nil { - writeQueryError(w, http.StatusBadRequest, "MalformedQueryString", err.Error()) +//nolint:gocyclo,funlen // first-match dispatch over many CloudWatch query actions. +func (h *Handler) serveQuery(w http.ResponseWriter, r *http.Request, action string, parseErr error) { + if parseErr != nil { + writeQueryError(w, http.StatusBadRequest, "MalformedQueryString", parseErr.Error()) return } - switch r.Form.Get("Action") { + switch action { case opPutMetricData: h.queryPutMetricData(w, r) case opListMetrics: @@ -110,7 +111,7 @@ func (h *Handler) serveQuery(w http.ResponseWriter, r *http.Request) { case opDeleteAnomalyDetector: h.queryDeleteAnomalyDetector(w, r) default: - writeQueryError(w, http.StatusBadRequest, "InvalidAction", "unsupported CloudWatch action: "+r.Form.Get("Action")) + writeQueryError(w, http.StatusBadRequest, "InvalidAction", "unsupported CloudWatch action: "+action) } } diff --git a/server/aws/cloudwatchlogs/handler.go b/server/aws/cloudwatchlogs/handler.go index 04390620c..555eeb050 100644 --- a/server/aws/cloudwatchlogs/handler.go +++ b/server/aws/cloudwatchlogs/handler.go @@ -180,3 +180,7 @@ func writeErr(w http.ResponseWriter, err error) { wire.WriteJSONError(w, http.StatusInternalServerError, "ServiceUnavailableException", msg) } } + +// IAMService returns the IAM service prefix of the operations this handler +// serves. +func (*Handler) IAMService() string { return "logs" } diff --git a/server/aws/codeartifact/handler.go b/server/aws/codeartifact/handler.go index 1fe508613..a7ca23b1f 100644 --- a/server/aws/codeartifact/handler.go +++ b/server/aws/codeartifact/handler.go @@ -233,3 +233,7 @@ func splitPath(p string) []string { return strings.Split(p, "/") } + +// IAMService returns the IAM service prefix of the operations this handler +// serves. +func (*Handler) IAMService() string { return "codeartifact" } diff --git a/server/aws/cognito/handler.go b/server/aws/cognito/handler.go index eb9b11a75..84ac5d441 100644 --- a/server/aws/cognito/handler.go +++ b/server/aws/cognito/handler.go @@ -133,3 +133,7 @@ func writeErr(w http.ResponseWriter, err error) { wire.WriteJSONError(w, http.StatusInternalServerError, cognitodriver.ExInternalError, msg) } } + +// IAMService returns the IAM service prefix of the operations this handler +// serves. +func (*Handler) IAMService() string { return "cognito-idp" } diff --git a/server/aws/configservice/handler.go b/server/aws/configservice/handler.go index 62edab8a6..24b5a41ff 100644 --- a/server/aws/configservice/handler.go +++ b/server/aws/configservice/handler.go @@ -120,3 +120,7 @@ func writeErr(w http.ResponseWriter, err error) { wire.WriteJSONError(w, http.StatusInternalServerError, "InternalFailure", err.Error()) } } + +// IAMService returns the IAM service prefix of the operations this handler +// serves. +func (*Handler) IAMService() string { return "config" } diff --git a/server/aws/costexplorer/handler.go b/server/aws/costexplorer/handler.go index 00075cf38..e4994f928 100644 --- a/server/aws/costexplorer/handler.go +++ b/server/aws/costexplorer/handler.go @@ -98,3 +98,7 @@ func writeErr(w http.ResponseWriter, err error) { wire.WriteJSONError(w, http.StatusInternalServerError, "InternalFailure", msg) } } + +// IAMService returns the IAM service prefix of the operations this handler +// serves. +func (*Handler) IAMService() string { return "ce" } diff --git a/server/aws/dynamodb/handler.go b/server/aws/dynamodb/handler.go index 55653bddb..efc06c878 100644 --- a/server/aws/dynamodb/handler.go +++ b/server/aws/dynamodb/handler.go @@ -1220,3 +1220,11 @@ func errMessage(err error) string { return err.Error() } + +// IAMService returns the IAM service prefix of the operations this handler +// serves. +func (*Handler) IAMService() string { return iamServicePrefix } + +// iamServicePrefix is the IAM namespace of the DynamoDB and DynamoDB Streams +// actions. +const iamServicePrefix = "dynamodb" diff --git a/server/aws/dynamodb/streams.go b/server/aws/dynamodb/streams.go index cacfff1f8..79ce006b5 100644 --- a/server/aws/dynamodb/streams.go +++ b/server/aws/dynamodb/streams.go @@ -473,3 +473,7 @@ func writeStreamsErr(w http.ResponseWriter, err error) { wire.WriteJSONError(w, http.StatusInternalServerError, "InternalServerError", msg) } } + +// IAMService returns the IAM service prefix of the operations this handler +// serves. DynamoDB Streams actions are in the dynamodb namespace. +func (*StreamsHandler) IAMService() string { return iamServicePrefix } diff --git a/server/aws/ec2/authz.go b/server/aws/ec2/authz.go new file mode 100644 index 000000000..fe6d63b0f --- /dev/null +++ b/server/aws/ec2/authz.go @@ -0,0 +1,43 @@ +package ec2 + +import ( + "net/http" + + "github.com/stackshy/cloudemu/v2/server/wire/awsauthz" + "github.com/stackshy/cloudemu/v2/server/wire/awsquery" +) + +// autoScalingService is the IAM prefix of the Auto Scaling actions this +// handler also serves. +const autoScalingService = "autoscaling" + +// IAMChecks names the IAM action from the form Action that ServeHTTP +// dispatches on. Actions in autoScalingRoutes are autoscaling:, the rest +// ec2:. An Action the handler does not know is authorized as ec2: and +// then answered with InvalidAction, so nothing runs. +func (h *Handler) IAMChecks(r *http.Request, _ awsauthz.Scope) ([]awsauthz.Check, bool) { + checks, ok := awsauthz.QueryChecks(r, h.IAMService()) + if !ok { + return nil, false + } + + if action := r.Form.Get("Action"); autoScalingRoutes[action] != nil { + return awsauthz.Single(autoScalingService+":"+action, ""), true + } + + return checks, true +} + +// WriteAccessDenied writes the 403 for a call IAM denies: EC2's +// UnauthorizedOperation, or AccessDenied for an Auto Scaling action, in the +// query error envelope. +func (*Handler) WriteAccessDenied(w http.ResponseWriter, r *http.Request, msg string) { + // The gate hands over the request IAMChecks already parsed. + if r.Form != nil && autoScalingRoutes[r.Form.Get("Action")] != nil { + awsquery.WriteXMLError(w, http.StatusForbidden, "AccessDenied", msg) + return + } + + awsquery.WriteXMLError(w, http.StatusForbidden, "UnauthorizedOperation", + "You are not authorized to perform this operation. "+msg) +} diff --git a/server/aws/ec2/authz_test.go b/server/aws/ec2/authz_test.go new file mode 100644 index 000000000..8c1ab2eda --- /dev/null +++ b/server/aws/ec2/authz_test.go @@ -0,0 +1,70 @@ +package ec2 + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/stackshy/cloudemu/v2/server/wire/awsauthz" +) + +func queryRequest(body string) *http.Request { + r := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(body)) + r.Header.Set("Content-Type", formContentType) + + return r +} + +func TestIAMChecksSplitsAutoScaling(t *testing.T) { + h := New(nil, nil, "123456789012") + + cases := map[string]string{ + "Action=RunInstances": "ec2:RunInstances", + "Action=DescribeVpcs": "ec2:DescribeVpcs", + "Action=NoSuchAction": "ec2:NoSuchAction", + "Action=CreateUser": "ec2:CreateUser", + "Action=CreateOrUpdateTags": "ec2:CreateOrUpdateTags", + "Action=CreateAutoScalingGroup": "autoscaling:CreateAutoScalingGroup", + } + + for action := range autoScalingRoutes { + cases["Action="+action] = "autoscaling:" + action + } + + for body, want := range cases { + checks, ok := h.IAMChecks(queryRequest(body), awsauthz.Scope{}) + if !ok || len(checks) != 1 || checks[0].Action != want || checks[0].Resource != "" { + t.Errorf("%s: got %+v ok=%v, want %s on an unknown resource", body, checks, ok, want) + } + } + + for _, body := range []string{"Version=2016-11-15", "Action=RunInstances&x=%zz"} { + if checks, ok := h.IAMChecks(queryRequest(body), awsauthz.Scope{}); ok { + t.Errorf("%s: got %+v, want ok=false", body, checks) + } + } +} + +func TestWriteAccessDeniedShape(t *testing.T) { + h := New(nil, nil, "123456789012") + + for body, code := range map[string]string{ + "Action=RunInstances": "UnauthorizedOperation", + "Action=CreateAutoScalingGroup": "AccessDenied", + } { + // The gate passes the request IAMChecks already parsed. + req := queryRequest(body) + if err := req.ParseForm(); err != nil { + t.Fatalf("parse: %v", err) + } + + rec := httptest.NewRecorder() + h.WriteAccessDenied(rec, req, "User: u is not authorized") + + if rec.Code != http.StatusForbidden || !strings.Contains(rec.Body.String(), code) || + !strings.Contains(rec.Body.String(), "") { + t.Errorf("%s: %d %s, want 403 %s in the EC2 envelope", body, rec.Code, rec.Body, code) + } + } +} diff --git a/server/aws/ec2/handler.go b/server/aws/ec2/handler.go index abb943ff7..327749233 100644 --- a/server/aws/ec2/handler.go +++ b/server/aws/ec2/handler.go @@ -354,30 +354,32 @@ func (h *Handler) routeLaunchTemplates(w http.ResponseWriter, r *http.Request, a return true } -//nolint:dupl // action-dispatch switch; every route* function has this shape by design +// autoScalingRoutes maps each Auto Scaling action the EC2 handler serves to +// its implementation. It is the one list of those actions: routeAutoScaling +// dispatches from it and IAMChecks uses it to authorize them as autoscaling: +// rather than ec2:. +// +//nolint:gochecknoglobals // static dispatch table +var autoScalingRoutes = map[string]func(*Handler, http.ResponseWriter, *http.Request){ + "CreateAutoScalingGroup": (*Handler).createAutoScalingGroup, + "UpdateAutoScalingGroup": (*Handler).updateAutoScalingGroup, + "DeleteAutoScalingGroup": (*Handler).deleteAutoScalingGroup, + "DescribeAutoScalingGroups": (*Handler).describeAutoScalingGroups, + "SetDesiredCapacity": (*Handler).setDesiredCapacity, + "PutScalingPolicy": (*Handler).putScalingPolicy, + "DeletePolicy": (*Handler).deleteScalingPolicy, + "ExecutePolicy": (*Handler).executePolicy, +} + +// routeAutoScaling dispatches the Auto Scaling actions. Returns true if the +// action was handled. func (h *Handler) routeAutoScaling(w http.ResponseWriter, r *http.Request, action string) bool { - switch action { - case "CreateAutoScalingGroup": - h.createAutoScalingGroup(w, r) - case "UpdateAutoScalingGroup": - h.updateAutoScalingGroup(w, r) - case "DeleteAutoScalingGroup": - h.deleteAutoScalingGroup(w, r) - case "DescribeAutoScalingGroups": - h.describeAutoScalingGroups(w, r) - case "SetDesiredCapacity": - h.setDesiredCapacity(w, r) - case "PutScalingPolicy": - h.putScalingPolicy(w, r) - case "DeletePolicy": - h.deleteScalingPolicy(w, r) - case "ExecutePolicy": - h.executePolicy(w, r) - default: - return false + route, ok := autoScalingRoutes[action] + if ok { + route(h, w, r) } - return true + return ok } // routeInstances dispatches instance-lifecycle actions backed by the compute @@ -684,3 +686,7 @@ func writeErrWithNotFound(w http.ResponseWriter, err error, notFoundCode, precon "InternalError", msg) } } + +// IAMService returns the IAM service prefix of the operations this handler +// serves. +func (*Handler) IAMService() string { return "ec2" } diff --git a/server/aws/ecr/handler.go b/server/aws/ecr/handler.go index 3252ab5c2..5f3da28a3 100644 --- a/server/aws/ecr/handler.go +++ b/server/aws/ecr/handler.go @@ -244,3 +244,7 @@ func writeErr(w http.ResponseWriter, err error) { wire.WriteJSONError(w, http.StatusInternalServerError, "ServerException", msg) } } + +// IAMService returns the IAM service prefix of the operations this handler +// serves. +func (*Handler) IAMService() string { return "ecr" } diff --git a/server/aws/ecs/handler.go b/server/aws/ecs/handler.go index ddb08330e..c57835155 100644 --- a/server/aws/ecs/handler.go +++ b/server/aws/ecs/handler.go @@ -66,3 +66,7 @@ func epoch(iso string) float64 { return float64(t.Unix()) } + +// IAMService returns the IAM service prefix of the operations this handler +// serves. +func (*Handler) IAMService() string { return "ecs" } diff --git a/server/aws/efs/handler.go b/server/aws/efs/handler.go index 59c17a6fa..0cfb60719 100644 --- a/server/aws/efs/handler.go +++ b/server/aws/efs/handler.go @@ -99,3 +99,7 @@ func splitPath(p string) []string { return strings.Split(p, "/") } + +// IAMService returns the IAM service prefix of the operations this handler +// serves. +func (*Handler) IAMService() string { return "elasticfilesystem" } diff --git a/server/aws/eks/handler.go b/server/aws/eks/handler.go index ab893a5cd..3ccbff308 100644 --- a/server/aws/eks/handler.go +++ b/server/aws/eks/handler.go @@ -393,3 +393,7 @@ func writeJSON(w http.ResponseWriter, v any) { w.WriteHeader(http.StatusOK) _ = json.NewEncoder(w).Encode(v) } + +// IAMService returns the IAM service prefix of the operations this handler +// serves. +func (*Handler) IAMService() string { return "eks" } diff --git a/server/aws/elasticache/handler.go b/server/aws/elasticache/handler.go index 483d491b9..0e97812f9 100644 --- a/server/aws/elasticache/handler.go +++ b/server/aws/elasticache/handler.go @@ -24,6 +24,7 @@ import ( "strings" cerrors "github.com/stackshy/cloudemu/v2/errors" + "github.com/stackshy/cloudemu/v2/server/wire/awsauthz" "github.com/stackshy/cloudemu/v2/server/wire/awsquery" cachedriver "github.com/stackshy/cloudemu/v2/services/cache/driver" ) @@ -318,3 +319,14 @@ func alreadyExistsCode(err error) string { return "CacheClusterAlreadyExists" } } + +// IAMService returns the IAM service prefix of the operations this handler +// serves. +func (*Handler) IAMService() string { return "elasticache" } + +// IAMChecks names the IAM action of a request from the form Action that +// ServeHTTP dispatches on. An Action the handler does not know is authorized +// as such and then answered with InvalidAction, so nothing runs. +func (h *Handler) IAMChecks(r *http.Request, _ awsauthz.Scope) ([]awsauthz.Check, bool) { + return awsauthz.QueryChecks(r, h.IAMService()) +} diff --git a/server/aws/elbv2/handler.go b/server/aws/elbv2/handler.go index 65779fa10..1477954d1 100644 --- a/server/aws/elbv2/handler.go +++ b/server/aws/elbv2/handler.go @@ -18,6 +18,7 @@ import ( "strings" cerrors "github.com/stackshy/cloudemu/v2/errors" + "github.com/stackshy/cloudemu/v2/server/wire/awsauthz" "github.com/stackshy/cloudemu/v2/server/wire/awsquery" lbdriver "github.com/stackshy/cloudemu/v2/services/loadbalancer/driver" ) @@ -260,3 +261,14 @@ func notFoundCode(err error) string { return "ResourceNotFound" } } + +// IAMService returns the IAM service prefix of the operations this handler +// serves. +func (*Handler) IAMService() string { return "elasticloadbalancing" } + +// IAMChecks names the IAM action of a request from the form Action that +// ServeHTTP dispatches on. An Action the handler does not know is authorized +// as such and then answered with InvalidAction, so nothing runs. +func (h *Handler) IAMChecks(r *http.Request, _ awsauthz.Scope) ([]awsauthz.Check, bool) { + return awsauthz.QueryChecks(r, h.IAMService()) +} diff --git a/server/aws/emr/handler.go b/server/aws/emr/handler.go index 609c4b393..29e32092b 100644 --- a/server/aws/emr/handler.go +++ b/server/aws/emr/handler.go @@ -121,3 +121,7 @@ func writeErr(w http.ResponseWriter, err error) { wire.WriteJSONError(w, http.StatusInternalServerError, "InternalFailure", msg) } } + +// IAMService returns the IAM service prefix of the operations this handler +// serves. +func (*Handler) IAMService() string { return "elasticmapreduce" } diff --git a/server/aws/eventbridge/handler.go b/server/aws/eventbridge/handler.go index a03649a04..c68c50e5d 100644 --- a/server/aws/eventbridge/handler.go +++ b/server/aws/eventbridge/handler.go @@ -116,3 +116,7 @@ func writeErr(w http.ResponseWriter, err error) { wire.WriteJSONError(w, http.StatusInternalServerError, "InternalException", msg) } } + +// IAMService returns the IAM service prefix of the operations this handler +// serves. +func (*Handler) IAMService() string { return "events" } diff --git a/server/aws/eventbridgescheduler/handler.go b/server/aws/eventbridgescheduler/handler.go index fc77ee3bc..87be3e813 100644 --- a/server/aws/eventbridgescheduler/handler.go +++ b/server/aws/eventbridgescheduler/handler.go @@ -185,3 +185,7 @@ func splitPath(p string) []string { return out } + +// IAMService returns the IAM service prefix of the operations this handler +// serves. +func (*Handler) IAMService() string { return "scheduler" } diff --git a/server/aws/fis/handler.go b/server/aws/fis/handler.go index 5f75f5d1e..7bfb9637a 100644 --- a/server/aws/fis/handler.go +++ b/server/aws/fis/handler.go @@ -173,3 +173,7 @@ func splitPath(p string) []string { return strings.Split(p, "/") } + +// IAMService returns the IAM service prefix of the operations this handler +// serves. +func (*Handler) IAMService() string { return "fis" } diff --git a/server/aws/globalaccelerator/handler.go b/server/aws/globalaccelerator/handler.go index 831b13934..d947945be 100644 --- a/server/aws/globalaccelerator/handler.go +++ b/server/aws/globalaccelerator/handler.go @@ -86,3 +86,7 @@ func dispatch[Req any]( writeJSON(w, out) } + +// IAMService returns the IAM service prefix of the operations this handler +// serves. +func (*Handler) IAMService() string { return "globalaccelerator" } diff --git a/server/aws/glue/handler.go b/server/aws/glue/handler.go index fe891d743..608083234 100644 --- a/server/aws/glue/handler.go +++ b/server/aws/glue/handler.go @@ -110,3 +110,7 @@ func writeErr(w http.ResponseWriter, err error) { wire.WriteJSONError(w, http.StatusInternalServerError, gluedriver.ExInternalService, msg) } } + +// IAMService returns the IAM service prefix of the operations this handler +// serves. +func (*Handler) IAMService() string { return "glue" } diff --git a/server/aws/grafana/handler.go b/server/aws/grafana/handler.go index 9f9ada875..d700739f1 100644 --- a/server/aws/grafana/handler.go +++ b/server/aws/grafana/handler.go @@ -203,3 +203,7 @@ func splitPath(p string) []string { return out } + +// IAMService returns the IAM service prefix of the operations this handler +// serves. +func (*Handler) IAMService() string { return "grafana" } diff --git a/server/aws/guardduty/handler.go b/server/aws/guardduty/handler.go index daab8aea4..26a36529c 100644 --- a/server/aws/guardduty/handler.go +++ b/server/aws/guardduty/handler.go @@ -192,3 +192,7 @@ func atoiDefault(s string, def int32) int32 { return int32(n) //nolint:gosec // bounded by request query length; overflow not reachable in practice. } + +// IAMService returns the IAM service prefix of the operations this handler +// serves. +func (*Handler) IAMService() string { return "guardduty" } diff --git a/server/aws/healthlake/handler.go b/server/aws/healthlake/handler.go index 273bfa7bd..7f64b1186 100644 --- a/server/aws/healthlake/handler.go +++ b/server/aws/healthlake/handler.go @@ -118,3 +118,7 @@ func statusFor(exception string) int { return http.StatusBadRequest } + +// IAMService returns the IAM service prefix of the operations this handler +// serves. +func (*Handler) IAMService() string { return "healthlake" } diff --git a/server/aws/iam/handler.go b/server/aws/iam/handler.go index 0944f3ab5..d9e864721 100644 --- a/server/aws/iam/handler.go +++ b/server/aws/iam/handler.go @@ -16,6 +16,7 @@ import ( "strings" cerrors "github.com/stackshy/cloudemu/v2/errors" + "github.com/stackshy/cloudemu/v2/server/wire/awsauthz" "github.com/stackshy/cloudemu/v2/server/wire/awsquery" iamdriver "github.com/stackshy/cloudemu/v2/services/iam/driver" ) @@ -403,3 +404,14 @@ func writeErr(w http.ResponseWriter, err error) { awsquery.WriteXMLError(w, http.StatusInternalServerError, "InternalFailure", msg) } } + +// IAMService returns the IAM service prefix of the operations this handler +// serves. +func (*Handler) IAMService() string { return "iam" } + +// IAMChecks names the IAM action of a request from the form Action that +// ServeHTTP dispatches on. An Action the handler does not know is authorized +// as such and then answered with InvalidAction, so nothing runs. +func (h *Handler) IAMChecks(r *http.Request, _ awsauthz.Scope) ([]awsauthz.Check, bool) { + return awsauthz.QueryChecks(r, h.IAMService()) +} diff --git a/server/aws/kafka/handler.go b/server/aws/kafka/handler.go index 5337e1129..21a8f2636 100644 --- a/server/aws/kafka/handler.go +++ b/server/aws/kafka/handler.go @@ -181,3 +181,7 @@ func atoiDefault(s string, def int32) int32 { return int32(n) //nolint:gosec // bounded by request query length; overflow not reachable. } + +// IAMService returns the IAM service prefix of the operations this handler +// serves. +func (*Handler) IAMService() string { return "kafka" } diff --git a/server/aws/kendra/handler.go b/server/aws/kendra/handler.go index a1dbaee7a..f28e52bbf 100644 --- a/server/aws/kendra/handler.go +++ b/server/aws/kendra/handler.go @@ -118,3 +118,7 @@ func statusFor(exception string) int { return http.StatusBadRequest } + +// IAMService returns the IAM service prefix of the operations this handler +// serves. +func (*Handler) IAMService() string { return "kendra" } diff --git a/server/aws/keyspaces/handler.go b/server/aws/keyspaces/handler.go index 18a3be9b8..449912229 100644 --- a/server/aws/keyspaces/handler.go +++ b/server/aws/keyspaces/handler.go @@ -107,3 +107,7 @@ func wireMessage(err error) string { return err.Error() } + +// IAMService returns the IAM service prefix of the operations this handler +// serves. +func (*Handler) IAMService() string { return "cassandra" } diff --git a/server/aws/kinesis/handler.go b/server/aws/kinesis/handler.go index 036f63257..16ab488b6 100644 --- a/server/aws/kinesis/handler.go +++ b/server/aws/kinesis/handler.go @@ -152,3 +152,7 @@ func writeErr(w http.ResponseWriter, err error) { wire.WriteJSONError(w, http.StatusInternalServerError, "InternalFailure", msg) } } + +// IAMService returns the IAM service prefix of the operations this handler +// serves. +func (*Handler) IAMService() string { return "kinesis" } diff --git a/server/aws/kinesisvideo/handler.go b/server/aws/kinesisvideo/handler.go index 233c75ed9..1ad37d3ff 100644 --- a/server/aws/kinesisvideo/handler.go +++ b/server/aws/kinesisvideo/handler.go @@ -168,3 +168,7 @@ func peekResourceARN(r *http.Request) string { return probe.ResourceARN } + +// IAMService returns the IAM service prefix of the operations this handler +// serves. +func (*Handler) IAMService() string { return "kinesisvideo" } diff --git a/server/aws/kms/handler.go b/server/aws/kms/handler.go index b53b9de54..2ebacf7e7 100644 --- a/server/aws/kms/handler.go +++ b/server/aws/kms/handler.go @@ -177,3 +177,7 @@ func writeErr(w http.ResponseWriter, err error) { wire.WriteJSONError(w, http.StatusInternalServerError, "KMSInternalException", msg) } } + +// IAMService returns the IAM service prefix of the operations this handler +// serves. +func (*Handler) IAMService() string { return "kms" } diff --git a/server/aws/lambda/handler.go b/server/aws/lambda/handler.go index 00459d4e2..89b031d28 100644 --- a/server/aws/lambda/handler.go +++ b/server/aws/lambda/handler.go @@ -1735,3 +1735,7 @@ func writeThrottle(w http.ResponseWriter, msg string) { "Reason": reservedConcurrencyReason, }) } + +// IAMService returns the IAM service prefix of the operations this handler +// serves. +func (*Handler) IAMService() string { return "lambda" } diff --git a/server/aws/location/handler.go b/server/aws/location/handler.go index 663dcf37b..6735c0c29 100644 --- a/server/aws/location/handler.go +++ b/server/aws/location/handler.go @@ -132,3 +132,7 @@ func splitPath(p string) []string { return strings.Split(p, "/") } + +// IAMService returns the IAM service prefix of the operations this handler +// serves. +func (*Handler) IAMService() string { return "geo" } diff --git a/server/aws/memorydb/handler.go b/server/aws/memorydb/handler.go index 8d8fd8815..d2e1fc2f1 100644 --- a/server/aws/memorydb/handler.go +++ b/server/aws/memorydb/handler.go @@ -147,3 +147,7 @@ func wireMessage(err error) string { return err.Error() } + +// IAMService returns the IAM service prefix of the operations this handler +// serves. +func (*Handler) IAMService() string { return "memorydb" } diff --git a/server/aws/mq/handler.go b/server/aws/mq/handler.go index acffdea49..6f129b0bb 100644 --- a/server/aws/mq/handler.go +++ b/server/aws/mq/handler.go @@ -262,3 +262,7 @@ func splitPath(p string) []string { return out } + +// IAMService returns the IAM service prefix of the operations this handler +// serves. +func (*Handler) IAMService() string { return "mq" } diff --git a/server/aws/mwaa/handler.go b/server/aws/mwaa/handler.go index e8041b66d..a0ca83cd3 100644 --- a/server/aws/mwaa/handler.go +++ b/server/aws/mwaa/handler.go @@ -173,3 +173,7 @@ func splitPath(p string) []string { return out } + +// IAMService returns the IAM service prefix of the operations this handler +// serves. +func (*Handler) IAMService() string { return "airflow" } diff --git a/server/aws/networkfirewall/handler.go b/server/aws/networkfirewall/handler.go index 618f1c28b..555c996f0 100644 --- a/server/aws/networkfirewall/handler.go +++ b/server/aws/networkfirewall/handler.go @@ -104,3 +104,7 @@ func writeErr(w http.ResponseWriter, err error) { wire.WriteJSONError(w, http.StatusInternalServerError, "InternalServerError", msg) } } + +// IAMService returns the IAM service prefix of the operations this handler +// serves. +func (*Handler) IAMService() string { return "network-firewall" } diff --git a/server/aws/opensearch/handler.go b/server/aws/opensearch/handler.go index 40628441e..741341bb1 100644 --- a/server/aws/opensearch/handler.go +++ b/server/aws/opensearch/handler.go @@ -155,3 +155,7 @@ func atoiDefault(s string, def int32) int32 { return int32(n) //nolint:gosec // bounded by request query length; overflow not reachable in practice. } + +// IAMService returns the IAM service prefix of the operations this handler +// serves. +func (*Handler) IAMService() string { return "es" } diff --git a/server/aws/publicauth.go b/server/aws/publicauth.go index 6e0eb4fbf..917b2234c 100644 --- a/server/aws/publicauth.go +++ b/server/aws/publicauth.go @@ -10,44 +10,50 @@ import ( "github.com/stackshy/cloudemu/v2/server" ) -// exemptPublic reports whether r may skip the SigV4 gate because it is an -// operation AWS serves without credentials (a noAuth operation). -// -// The decision belongs to the handler that dispatch will pick for this exact -// request: the gate runs the dispatcher's own first-match lookup (match) on a -// probe copy of r and asks that handler, through server.PublicRequester, -// whether it serves r as a public operation. A handler answers true only for -// the public routes it really serves, never for its private ones, so a -// request cannot borrow a public marker (a Host, a path, a target) while being -// served as something else. When no handler would serve r, it is not exempt. +// probeRoute finds the handler that dispatch will pick for r, by running the +// dispatcher's own first-match lookup (match) on a copy of r. Both the public +// exemption and the authorization plan are bound to that handler, so a +// request cannot borrow a marker (a Host, a path, a target) of one service +// while being served by another. // // The probe gets fresh form state and the same body bytes that dispatch will // read, so the lookup and the real dispatch see identical input. A form body -// or query string that does not parse fails closed, since handlers that parse -// forms could otherwise disagree about what the request is. -func exemptPublic(r *http.Request, body []byte, match func(*http.Request) server.Handler) bool { +// or query string that does not parse returns ok=false: handlers that parse +// forms could otherwise disagree about what the request is, so callers treat +// it as fail-closed. The returned probe's body is reset for the next reader. +func probeRoute( + r *http.Request, body []byte, match func(*http.Request) server.Handler, +) (probe *http.Request, h server.Handler, ok bool) { if _, err := url.ParseQuery(r.URL.RawQuery); err != nil { - return false + return nil, nil, false } if strings.HasPrefix(r.Header.Get("Content-Type"), urlEncodedForm) { if _, err := url.ParseQuery(string(body)); err != nil { - return false + return nil, nil, false } } - probe := r.Clone(r.Context()) + probe = r.Clone(r.Context()) probe.Form, probe.PostForm, probe.MultipartForm = nil, nil, nil probe.Body = io.NopCloser(bytes.NewReader(body)) - pub, ok := match(probe).(server.PublicRequester) - if !ok { - return false - } + h = match(probe) probe.Body = io.NopCloser(bytes.NewReader(body)) - return pub.PublicRequest(probe) + return probe, h, true +} + +// servedPublicly reports whether h serves the probed request as an operation +// AWS serves without credentials (a noAuth operation), which skips both +// authentication and authorization. A handler answers true only for the +// public routes it really serves, never for its private ones, and a request +// no handler would serve is not public. +func servedPublicly(probe *http.Request, h server.Handler) bool { + pub, ok := h.(server.PublicRequester) + + return ok && pub.PublicRequest(probe) } const urlEncodedForm = "application/x-www-form-urlencoded" diff --git a/server/aws/publicauth_test.go b/server/aws/publicauth_test.go index 7bfc71b7f..cc880eb2c 100644 --- a/server/aws/publicauth_test.go +++ b/server/aws/publicauth_test.go @@ -218,6 +218,18 @@ func TestAuthzSkipsPublicOps(t *testing.T) { typ != accessDeny { t.Fatalf("private ListUserPools: %d %s, want 403 %s", status, typ, accessDeny) } + + // A signed execute-api invoke stays public: the data plane answers it + // (here: no such API), never the IAM gate. + for _, rq := range []sreq{ + {method: http.MethodGet, path: "/prod/pets", service: "execute-api", host: execHost}, + {method: http.MethodGet, path: "/restapis/abc123/prod/_user_request_/pets", service: "execute-api"}, + } { + status, body := doSigned(t, ts, creds, rq) + if strings.Contains(body, "is not authorized") || strings.Contains(body, accessDeny) { + t.Fatalf("signed execute-api %s denied by the gate: %d %s", rq.path, status, body) + } + } } func stsClient(ts *httptest.Server, creds aws.Credentials) *awssts.Client { diff --git a/server/aws/rds/handler.go b/server/aws/rds/handler.go index e07f10ebe..1f85f2255 100644 --- a/server/aws/rds/handler.go +++ b/server/aws/rds/handler.go @@ -16,6 +16,7 @@ import ( "strings" cerrors "github.com/stackshy/cloudemu/v2/errors" + "github.com/stackshy/cloudemu/v2/server/wire/awsauthz" "github.com/stackshy/cloudemu/v2/server/wire/awsquery" rdsdriver "github.com/stackshy/cloudemu/v2/services/relationaldb/driver" ) @@ -504,3 +505,14 @@ func invalidStateCode(err error) string { func alreadyExistsCode(err error) string { return matchFault(err.Error(), alreadyExistsFaults, "ResourceAlreadyExistsFault") } + +// IAMService returns the IAM service prefix of the operations this handler +// serves. +func (*Handler) IAMService() string { return "rds" } + +// IAMChecks names the IAM action of a request from the form Action that +// ServeHTTP dispatches on. An Action the handler does not know is authorized +// as such and then answered with InvalidAction, so nothing runs. +func (h *Handler) IAMChecks(r *http.Request, _ awsauthz.Scope) ([]awsauthz.Check, bool) { + return awsauthz.QueryChecks(r, h.IAMService()) +} diff --git a/server/aws/redshift/handler.go b/server/aws/redshift/handler.go index bbff2ea6c..443af7b50 100644 --- a/server/aws/redshift/handler.go +++ b/server/aws/redshift/handler.go @@ -19,6 +19,7 @@ import ( cerrors "github.com/stackshy/cloudemu/v2/errors" redshiftprovider "github.com/stackshy/cloudemu/v2/providers/aws/redshift" + "github.com/stackshy/cloudemu/v2/server/wire/awsauthz" "github.com/stackshy/cloudemu/v2/server/wire/awsquery" rdbdriver "github.com/stackshy/cloudemu/v2/services/relationaldb/driver" ) @@ -347,3 +348,14 @@ func alreadyExistsCode(err error) string { return "ResourceAlreadyExistsFault" } } + +// IAMService returns the IAM service prefix of the operations this handler +// serves. +func (*Handler) IAMService() string { return "redshift" } + +// IAMChecks names the IAM action of a request from the form Action that +// ServeHTTP dispatches on. An Action the handler does not know is authorized +// as such and then answered with InvalidAction, so nothing runs. +func (h *Handler) IAMChecks(r *http.Request, _ awsauthz.Scope) ([]awsauthz.Check, bool) { + return awsauthz.QueryChecks(r, h.IAMService()) +} diff --git a/server/aws/resourceexplorer2/handler.go b/server/aws/resourceexplorer2/handler.go index b585b122d..80519fb33 100644 --- a/server/aws/resourceexplorer2/handler.go +++ b/server/aws/resourceexplorer2/handler.go @@ -548,3 +548,7 @@ func writeErr(w http.ResponseWriter, err error) { wire.WriteJSONError(w, http.StatusInternalServerError, "InternalServerException", err.Error()) } } + +// IAMService returns the IAM service prefix of the operations this handler +// serves. +func (*Handler) IAMService() string { return "resource-explorer-2" } diff --git a/server/aws/resourcegroupstaggingapi/handler.go b/server/aws/resourcegroupstaggingapi/handler.go index 3d2128e2d..a02f9891b 100644 --- a/server/aws/resourcegroupstaggingapi/handler.go +++ b/server/aws/resourcegroupstaggingapi/handler.go @@ -358,3 +358,7 @@ func writeErr(w http.ResponseWriter, err error) { wire.WriteJSONError(w, http.StatusInternalServerError, "InternalServiceException", err.Error()) } } + +// IAMService returns the IAM service prefix of the operations this handler +// serves. +func (*Handler) IAMService() string { return "tag" } diff --git a/server/aws/route53/handler.go b/server/aws/route53/handler.go index 93176adba..6547512fb 100644 --- a/server/aws/route53/handler.go +++ b/server/aws/route53/handler.go @@ -213,3 +213,13 @@ func (h *Handler) serveRRSet(w http.ResponseWriter, r *http.Request, id string) func writeMethodNotAllowed(w http.ResponseWriter) { writeError(w, http.StatusMethodNotAllowed, "InvalidInput", "method not allowed") } + +// IAMService returns the IAM service prefix of the operations this handler +// serves. +func (*Handler) IAMService() string { return "route53" } + +// WriteAccessDenied writes the 403 this service returns when IAM denies a +// call, in its own XML error shape. +func (*Handler) WriteAccessDenied(w http.ResponseWriter, _ *http.Request, msg string) { + writeError(w, http.StatusForbidden, "AccessDenied", msg) +} diff --git a/server/aws/route53resolver/handler.go b/server/aws/route53resolver/handler.go index 3fcecf97d..c5d391f43 100644 --- a/server/aws/route53resolver/handler.go +++ b/server/aws/route53resolver/handler.go @@ -137,3 +137,7 @@ func (h *Handler) routes() map[string]func(http.ResponseWriter, *http.Request) { "ListTagsForResource": h.listTagsForResource, } } + +// IAMService returns the IAM service prefix of the operations this handler +// serves. +func (*Handler) IAMService() string { return "route53resolver" } diff --git a/server/aws/s3/handler.go b/server/aws/s3/handler.go index 06244b512..72ca88768 100644 --- a/server/aws/s3/handler.go +++ b/server/aws/s3/handler.go @@ -2532,3 +2532,13 @@ func writeErr(w http.ResponseWriter, err error) { writeError(w, http.StatusInternalServerError, "InternalError", msg) } } + +// IAMService returns the IAM service prefix of the operations this handler +// serves. +func (*Handler) IAMService() string { return "s3" } + +// WriteAccessDenied writes the 403 this service returns when IAM denies a +// call, in its own XML error shape. +func (*Handler) WriteAccessDenied(w http.ResponseWriter, _ *http.Request, msg string) { + writeError(w, http.StatusForbidden, "AccessDenied", msg) +} diff --git a/server/aws/sagemaker/authz.go b/server/aws/sagemaker/authz.go new file mode 100644 index 000000000..ce1ccf95f --- /dev/null +++ b/server/aws/sagemaker/authz.go @@ -0,0 +1,69 @@ +package sagemaker + +import ( + "net/http" + "strings" + + "github.com/stackshy/cloudemu/v2/server/wire/awsauthz" +) + +// route is which of the handler's three surfaces serves a request. +type route int + +const ( + routeControl route = iota + routeRuntime + routeFeatureStore +) + +// classify picks the surface the way ServeHTTP dispatches: the runtime and +// feature-store paths win over X-Amz-Target, so a control-plane target sent +// to a runtime path still runs the runtime operation. +func classify(r *http.Request) route { + switch { + case isRuntimePath(r.URL.Path): + return routeRuntime + case isFeatureStorePath(r.URL.Path): + return routeFeatureStore + default: + return routeControl + } +} + +// featureStoreActions maps a feature-store record method to its IAM action. +// +//nolint:gochecknoglobals // static lookup table +var featureStoreActions = map[string]string{ + http.MethodPut: "PutRecord", + http.MethodGet: "GetRecord", + http.MethodDelete: "DeleteRecord", +} + +// IAMChecks names the IAM action of a request from the surface classify +// picks, which is the one ServeHTTP runs. A method the runtime or feature +// store does not serve returns ok=false; the handler answers it with 405. +func (h *Handler) IAMChecks(r *http.Request, _ awsauthz.Scope) ([]awsauthz.Check, bool) { + var op string + + switch classify(r) { + case routeRuntime: + if r.Method != http.MethodPost { + return nil, false + } + + op = "InvokeEndpoint" + if strings.HasSuffix(r.URL.Path, "/async-invocations") { + op = "InvokeEndpointAsync" + } + case routeFeatureStore: + op = featureStoreActions[r.Method] + case routeControl: + op = strings.TrimPrefix(r.Header.Get("X-Amz-Target"), targetPrefix) + } + + if op == "" { + return nil, false + } + + return awsauthz.Single(h.IAMService()+":"+op, ""), true +} diff --git a/server/aws/sagemaker/authz_test.go b/server/aws/sagemaker/authz_test.go new file mode 100644 index 000000000..5196ed4da --- /dev/null +++ b/server/aws/sagemaker/authz_test.go @@ -0,0 +1,51 @@ +package sagemaker + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/stackshy/cloudemu/v2/server/wire/awsauthz" +) + +func TestIAMChecksFollowsDispatch(t *testing.T) { + h := New(nil) + + target := func(r *http.Request, op string) *http.Request { + r.Header.Set("X-Amz-Target", targetPrefix+op) + return r + } + + cases := []struct { + name string + req *http.Request + want string + }{ + {"control plane", target(httptest.NewRequest(http.MethodPost, "/", nil), "ListModels"), "sagemaker:ListModels"}, + {"invoke", httptest.NewRequest(http.MethodPost, "/endpoints/e/invocations", nil), "sagemaker:InvokeEndpoint"}, + {"invoke async", httptest.NewRequest(http.MethodPost, "/endpoints/e/async-invocations", nil), "sagemaker:InvokeEndpointAsync"}, + // The runtime path wins over the target in dispatch, so it does here. + {"target on a runtime path", target(httptest.NewRequest(http.MethodPost, "/endpoints/e/invocations", nil), "ListModels"), + "sagemaker:InvokeEndpoint"}, + {"put record", httptest.NewRequest(http.MethodPut, "/FeatureGroup/g", nil), "sagemaker:PutRecord"}, + {"get record", httptest.NewRequest(http.MethodGet, "/FeatureGroup/g", nil), "sagemaker:GetRecord"}, + {"delete record", httptest.NewRequest(http.MethodDelete, "/FeatureGroup/g", nil), "sagemaker:DeleteRecord"}, + } + + for _, tc := range cases { + checks, ok := h.IAMChecks(tc.req, awsauthz.Scope{}) + if !ok || len(checks) != 1 || checks[0].Action != tc.want { + t.Errorf("%s: got %+v ok=%v, want %s", tc.name, checks, ok, tc.want) + } + } + + for name, req := range map[string]*http.Request{ + "runtime GET": httptest.NewRequest(http.MethodGet, "/endpoints/e/invocations", nil), + "feature store POST": httptest.NewRequest(http.MethodPost, "/FeatureGroup/g", nil), + "no target": httptest.NewRequest(http.MethodPost, "/", nil), + } { + if checks, ok := h.IAMChecks(req, awsauthz.Scope{}); ok { + t.Errorf("%s: got %+v, want ok=false", name, checks) + } + } +} diff --git a/server/aws/sagemaker/handler.go b/server/aws/sagemaker/handler.go index 2d6af33e8..e3d2326b0 100644 --- a/server/aws/sagemaker/handler.go +++ b/server/aws/sagemaker/handler.go @@ -56,16 +56,16 @@ func isFeatureStorePath(p string) bool { // ServeHTTP dispatches by X-Amz-Target for the control plane, falling back to // the runtime and feature-store REST paths. func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { - if isRuntimePath(r.URL.Path) { + switch classify(r) { + case routeRuntime: h.serveRuntime(w, r) return - } - - if isFeatureStorePath(r.URL.Path) { + case routeFeatureStore: h.serveFeatureStoreRuntime(w, r) return + case routeControl: } op := strings.TrimPrefix(r.Header.Get("X-Amz-Target"), targetPrefix) @@ -234,3 +234,7 @@ type wireTag struct { Key string `json:"Key"` Value string `json:"Value"` } + +// IAMService returns the IAM service prefix of the operations this handler +// serves. +func (*Handler) IAMService() string { return "sagemaker" } diff --git a/server/aws/savingsplans/handler.go b/server/aws/savingsplans/handler.go index 2596f15c6..f10e76306 100644 --- a/server/aws/savingsplans/handler.go +++ b/server/aws/savingsplans/handler.go @@ -337,3 +337,7 @@ func writeErr(w http.ResponseWriter, err error) { wire.WriteJSONError(w, http.StatusInternalServerError, "InternalServerException", msg) } } + +// IAMService returns the IAM service prefix of the operations this handler +// serves. +func (*Handler) IAMService() string { return "savingsplans" } diff --git a/server/aws/secretsmanager/handler.go b/server/aws/secretsmanager/handler.go index 535314afa..64ad37402 100644 --- a/server/aws/secretsmanager/handler.go +++ b/server/aws/secretsmanager/handler.go @@ -159,3 +159,7 @@ func writeErr(w http.ResponseWriter, err error) { wire.WriteJSONError(w, http.StatusInternalServerError, "InternalServiceError", msg) } } + +// IAMService returns the IAM service prefix of the operations this handler +// serves. +func (*Handler) IAMService() string { return "secretsmanager" } diff --git a/server/aws/servicequotas/handler.go b/server/aws/servicequotas/handler.go index aee22559f..b75a0e786 100644 --- a/server/aws/servicequotas/handler.go +++ b/server/aws/servicequotas/handler.go @@ -105,3 +105,7 @@ func writeErr(w http.ResponseWriter, err error) { wire.WriteJSONError(w, http.StatusInternalServerError, "ServiceException", msg) } } + +// IAMService returns the IAM service prefix of the operations this handler +// serves. +func (*Handler) IAMService() string { return "servicequotas" } diff --git a/server/aws/sesv2/handler.go b/server/aws/sesv2/handler.go index b5a9edde0..c96827aab 100644 --- a/server/aws/sesv2/handler.go +++ b/server/aws/sesv2/handler.go @@ -177,3 +177,7 @@ func splitPath(p string) []string { return out } + +// IAMService returns the IAM service prefix of the operations this handler +// serves. +func (*Handler) IAMService() string { return "ses" } diff --git a/server/aws/sfn/handler.go b/server/aws/sfn/handler.go index ec8555b3c..e0b0fd5cd 100644 --- a/server/aws/sfn/handler.go +++ b/server/aws/sfn/handler.go @@ -141,3 +141,7 @@ func writeErr(w http.ResponseWriter, err error) { wire.WriteJSONError(w, http.StatusInternalServerError, "InternalError", msg) } } + +// IAMService returns the IAM service prefix of the operations this handler +// serves. +func (*Handler) IAMService() string { return "states" } diff --git a/server/aws/sns/handler.go b/server/aws/sns/handler.go index 2558fe9d4..973840767 100644 --- a/server/aws/sns/handler.go +++ b/server/aws/sns/handler.go @@ -35,6 +35,7 @@ import ( "strings" cerrors "github.com/stackshy/cloudemu/v2/errors" + "github.com/stackshy/cloudemu/v2/server/wire/awsauthz" "github.com/stackshy/cloudemu/v2/server/wire/awsquery" notifdriver "github.com/stackshy/cloudemu/v2/services/notification/driver" ) @@ -266,3 +267,14 @@ func defaultTopicPolicy(arn, owner string) string { `"SNS:Publish","SNS:Receive"],"Resource":"` + arn + `",` + `"Condition":{"StringEquals":{"AWS:SourceOwner":"` + owner + `"}}}]}` } + +// IAMService returns the IAM service prefix of the operations this handler +// serves. +func (*Handler) IAMService() string { return "sns" } + +// IAMChecks names the IAM action of a request from the form Action that +// ServeHTTP dispatches on. An Action the handler does not know is authorized +// as such and then answered with InvalidAction, so nothing runs. +func (h *Handler) IAMChecks(r *http.Request, _ awsauthz.Scope) ([]awsauthz.Check, bool) { + return awsauthz.QueryChecks(r, h.IAMService()) +} diff --git a/server/aws/sqs/handler.go b/server/aws/sqs/handler.go index abdd3713a..87073938e 100644 --- a/server/aws/sqs/handler.go +++ b/server/aws/sqs/handler.go @@ -1208,3 +1208,7 @@ func writeErr(w http.ResponseWriter, err error) { wire.WriteJSONError(w, http.StatusInternalServerError, "InternalError", cerrors.Message(err)) } } + +// IAMService returns the IAM service prefix of the operations this handler +// serves. +func (*Handler) IAMService() string { return "sqs" } diff --git a/server/aws/ssm/handler.go b/server/aws/ssm/handler.go index 94077b623..096b1b32d 100644 --- a/server/aws/ssm/handler.go +++ b/server/aws/ssm/handler.go @@ -118,3 +118,7 @@ func writeErr(w http.ResponseWriter, err error) { wire.WriteJSONError(w, http.StatusInternalServerError, "InternalServerError", msg) } } + +// IAMService returns the IAM service prefix of the operations this handler +// serves. +func (*Handler) IAMService() string { return "ssm" } diff --git a/server/aws/sts/authz.go b/server/aws/sts/authz.go new file mode 100644 index 000000000..405526cb9 --- /dev/null +++ b/server/aws/sts/authz.go @@ -0,0 +1,78 @@ +package sts + +import ( + "context" + "net/http" + + "github.com/stackshy/cloudemu/v2/server/wire/awsauthz" + iamdriver "github.com/stackshy/cloudemu/v2/services/iam/driver" +) + +// roleGetter looks up the role an AssumeRole-family call names. +type roleGetter interface { + GetRole(ctx context.Context, name string) (*iamdriver.RoleInfo, error) +} + +// IAMChecks names the IAM permission each STS action needs, reading the form +// Action that ServeHTTP dispatches on. +// +// - GetCallerIdentity needs none: AWS never authorizes it. +// - GetSessionToken needs no permission, but an explicit Deny on +// sts:GetSessionToken still blocks it (DenyOnly). +// - The AssumeRole family needs sts: on the role that will be +// assumed. The role is resolved the way the operation resolves it (by the +// last path segment of RoleArn), and its stored ARN is the resource, so a +// RoleArn with a different path or account cannot borrow another role's +// grant. An unknown role leaves the resource unknown. +// - GetFederationToken needs sts:GetFederationToken on the federated user. +// - GetAccessKeyInfo and DecodeAuthorizationMessage take no resource. +func (h *Handler) IAMChecks(r *http.Request, _ awsauthz.Scope) ([]awsauthz.Check, bool) { + checks, ok := awsauthz.QueryChecks(r, h.IAMService()) + if !ok { + return nil, false + } + + action := checks[0].Action + + switch r.Form.Get("Action") { + case actionGetCallerIdentity: + return []awsauthz.Check{}, true + case actionGetSessionToken: + return []awsauthz.Check{{Action: action, Resource: "*", Mode: awsauthz.DenyOnly}}, true + case actionAssumeRole, actionAssumeRoleWithWebIdentity, actionAssumeRoleWithSAML: + return awsauthz.Single(action, h.assumedRoleARN(r)), true + case actionGetFederationToken: + return awsauthz.Single(action, "arn:aws:sts::"+h.accountID+":federated-user/"+r.Form.Get("Name")), true + case actionGetAccessKeyInfo, actionDecodeAuthorizationMessage: + return awsauthz.Single(action, "*"), true + default: + return checks, true + } +} + +// The STS actions IAMChecks names a permission for. +const ( + actionGetCallerIdentity = "GetCallerIdentity" + actionGetSessionToken = "GetSessionToken" + actionAssumeRole = "AssumeRole" + actionAssumeRoleWithWebIdentity = "AssumeRoleWithWebIdentity" + actionAssumeRoleWithSAML = "AssumeRoleWithSAML" + actionGetFederationToken = "GetFederationToken" + actionGetAccessKeyInfo = "GetAccessKeyInfo" + actionDecodeAuthorizationMessage = "DecodeAuthorizationMessage" +) + +// assumedRoleARN is the stored ARN of the role named by the request's +// RoleArn, or "" when that role does not exist. +func (h *Handler) assumedRoleARN(r *http.Request) string { + if h.roles == nil { + return "" + } + + role, err := h.roles.GetRole(r.Context(), roleNameFromArn(r.Form.Get("RoleArn"))) + if err != nil || role == nil { + return "" + } + + return role.ARN +} diff --git a/server/aws/sts/authz_test.go b/server/aws/sts/authz_test.go new file mode 100644 index 000000000..2209d2429 --- /dev/null +++ b/server/aws/sts/authz_test.go @@ -0,0 +1,72 @@ +package sts + +import ( + "context" + "net/http" + "net/http/httptest" + "strings" + "testing" + + cloudemu "github.com/stackshy/cloudemu/v2" + "github.com/stackshy/cloudemu/v2/server/wire/awsauthz" + iamdriver "github.com/stackshy/cloudemu/v2/services/iam/driver" +) + +func TestIAMChecks(t *testing.T) { + cloud := cloudemu.NewAWS() + if _, err := cloud.IAM.CreateRole(context.Background(), iamdriver.RoleConfig{ + Name: "deploy", Path: "/team/", AssumeRolePolicyDoc: `{"Statement":[]}`, + }); err != nil { + t.Fatalf("CreateRole: %v", err) + } + + h := New("123456789012", "us-east-1", cloud.IAM) + role := "arn:aws:iam::123456789012:role/team/deploy" + + cases := []struct { + body string + want []awsauthz.Check + known bool + }{ + {"Action=GetCallerIdentity", []awsauthz.Check{}, true}, + {"Action=GetSessionToken", []awsauthz.Check{{Action: "sts:GetSessionToken", Resource: "*", Mode: awsauthz.DenyOnly}}, true}, + {"Action=AssumeRole&RoleArn=" + role, awsauthz.Single("sts:AssumeRole", role), true}, + // The operation assumes the role by its last path segment, so the + // resource is the stored ARN, whatever path or account was sent. + {"Action=AssumeRole&RoleArn=arn:aws:iam::999999999999:role/other/deploy", awsauthz.Single("sts:AssumeRole", role), true}, + {"Action=AssumeRole&RoleArn=arn:aws:iam::123456789012:role/missing", awsauthz.Single("sts:AssumeRole", ""), true}, + {"Action=AssumeRoleWithWebIdentity&RoleArn=" + role, awsauthz.Single("sts:AssumeRoleWithWebIdentity", role), true}, + {"Action=AssumeRoleWithSAML&RoleArn=" + role, awsauthz.Single("sts:AssumeRoleWithSAML", role), true}, + {"Action=GetFederationToken&Name=bob", awsauthz.Single("sts:GetFederationToken", + "arn:aws:sts::123456789012:federated-user/bob"), true}, + {"Action=GetAccessKeyInfo", awsauthz.Single("sts:GetAccessKeyInfo", "*"), true}, + {"Action=DecodeAuthorizationMessage", awsauthz.Single("sts:DecodeAuthorizationMessage", "*"), true}, + {"Action=NoSuchAction", awsauthz.Single("sts:NoSuchAction", ""), true}, + {"Version=2011-06-15", nil, false}, + } + + for _, tc := range cases { + r := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(tc.body)) + r.Header.Set("Content-Type", formContentType) + + got, ok := h.IAMChecks(r, awsauthz.Scope{}) + if ok != tc.known || len(got) != len(tc.want) { + t.Errorf("%s: got %+v ok=%v, want %+v ok=%v", tc.body, got, ok, tc.want, tc.known) + continue + } + + for i := range got { + if got[i] != tc.want[i] { + t.Errorf("%s: check %d = %+v, want %+v", tc.body, i, got[i], tc.want[i]) + } + } + } + + // Without IAM there is no role to resolve, so the resource is unknown. + r := httptest.NewRequest(http.MethodPost, "/", strings.NewReader("Action=AssumeRole&RoleArn="+role)) + r.Header.Set("Content-Type", formContentType) + + if got, _ := New("", "", nil).IAMChecks(r, awsauthz.Scope{}); len(got) != 1 || got[0].Resource != "" { + t.Errorf("no IAM: got %+v, want an unknown resource", got) + } +} diff --git a/server/aws/sts/handler.go b/server/aws/sts/handler.go index 86708c32a..d9d53a1d2 100644 --- a/server/aws/sts/handler.go +++ b/server/aws/sts/handler.go @@ -65,6 +65,9 @@ type Handler struct { accountID string region string trust roleTrustEvaluator + // roles resolves the role an AssumeRole-family call will assume, so its + // stored ARN is the resource the gate authorizes. Nil without IAM. + roles roleGetter // sessions, when set, records the temporary credentials this handler mints // so the SigV4 authentication gate can verify signatures made with them. It // is wired only when EnforceAuth is on; left nil the handler returns the @@ -104,6 +107,10 @@ func New(accountID, region string, iam iamdriver.IAM) *Handler { h.trust = te } + if iam != nil { + h.roles = iam + } + return h } @@ -167,3 +174,7 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { "InvalidAction", "unknown STS action: "+r.Form.Get("Action")) } } + +// IAMService returns the IAM service prefix of the operations this handler +// serves. +func (*Handler) IAMService() string { return "sts" } diff --git a/server/aws/testdata/authz_authoff_golden.json b/server/aws/testdata/authz_authoff_golden.json new file mode 100644 index 000000000..17791a414 --- /dev/null +++ b/server/aws/testdata/authz_authoff_golden.json @@ -0,0 +1,177 @@ +[ + { + "name": "cw cbor PutMetricData", + "status": 200, + "body": "cbor:{}" + }, + { + "name": "cw cbor with query Action", + "status": 200, + "body": "cbor:{\"Metrics\":[{\"MetricName\":\"M\",\"Namespace\":\"Off\"}]}" + }, + { + "name": "cw cbor unknown op", + "status": 400, + "body": "cbor:{\"__type\":\"UnknownOperationException\",\"message\":\"unknown operation: Nope\"}" + }, + { + "name": "cw cbor no op", + "status": 400, + "body": "cbor:{\"__type\":\"InvalidRequest\",\"message\":\"missing operation in path\"}" + }, + { + "name": "cw json DescribeAlarms", + "status": 200, + "body": "{\"MetricAlarms\":[]}" + }, + { + "name": "cw json unknown op", + "status": 400, + "body": "{\"__type\":\"UnknownOperationException\",\"message\":\"unknown operation: Nope\"}" + }, + { + "name": "cw query PutMetricData", + "status": 200, + "body": "\u003c?xml version=\"1.0\"?\u003e\u003cPutMetricDataResponse xmlns=\"http://monitoring.amazonaws.com/doc/2010-08-01/\"\u003e\u003c/PutMetricDataResponse\u003e" + }, + { + "name": "cw query gzip PutMetricData", + "status": 200, + "body": "\u003c?xml version=\"1.0\"?\u003e\u003cPutMetricDataResponse xmlns=\"http://monitoring.amazonaws.com/doc/2010-08-01/\"\u003e\u003c/PutMetricDataResponse\u003e" + }, + { + "name": "cw query ListMetrics", + "status": 200, + "body": "\u003c?xml version=\"1.0\"?\u003e\u003cListMetricsResponse xmlns=\"http://monitoring.amazonaws.com/doc/2010-08-01/\"\u003e\u003cListMetricsResult\u003e\u003cMetrics\u003e\u003cmember\u003e\u003cNamespace\u003eOff\u003c/Namespace\u003e\u003cMetricName\u003eM\u003c/MetricName\u003e\u003cDimensions\u003e\u003c/Dimensions\u003e\u003c/member\u003e\u003cmember\u003e\u003cNamespace\u003eOff\u003c/Namespace\u003e\u003cMetricName\u003eQ\u003c/MetricName\u003e\u003cDimensions\u003e\u003c/Dimensions\u003e\u003c/member\u003e\u003c/Metrics\u003e\u003c/ListMetricsResult\u003e\u003c/ListMetricsResponse\u003e" + }, + { + "name": "cw query unknown", + "status": 400, + "body": "\u003c?xml version=\"1.0\"?\u003e\u003cErrorResponse xmlns=\"http://monitoring.amazonaws.com/doc/2010-08-01/\"\u003e\u003cError\u003e\u003cType\u003eSender\u003c/Type\u003e\u003cCode\u003eInvalidAction\u003c/Code\u003e\u003cMessage\u003eunsupported CloudWatch action: Nope\u003c/Message\u003e\u003c/Error\u003e\u003cRequestId\u003e\u003cv\u003e\u003c/RequestId\u003e\u003c/ErrorResponse\u003e" + }, + { + "name": "cw query GET DescribeAlarms", + "status": 200, + "body": "\u003c?xml version=\"1.0\"?\u003e\u003cDescribeAlarmsResponse xmlns=\"http://monitoring.amazonaws.com/doc/2010-08-01/\"\u003e\u003cDescribeAlarmsResult\u003e\u003cMetricAlarms\u003e\u003c/MetricAlarms\u003e\u003cCompositeAlarms\u003e\u003c/CompositeAlarms\u003e\u003c/DescribeAlarmsResult\u003e\u003c/DescribeAlarmsResponse\u003e" + }, + { + "name": "ec2 DescribeInstances", + "status": 200, + "body": "\u003c?xml version=\"1.0\" encoding=\"UTF-8\"?\u003e\n\u003cDescribeInstancesResponse xmlns=\"http://ec2.amazonaws.com/doc/2016-11-15/\"\u003e\u003crequestId\u003e\u003cv\u003e\u003c/requestId\u003e\u003creservationSet\u003e\u003c/reservationSet\u003e\u003c/DescribeInstancesResponse\u003e" + }, + { + "name": "ec2 unknown", + "status": 400, + "body": "\u003c?xml version=\"1.0\" encoding=\"UTF-8\"?\u003e\n\u003cResponse\u003e\u003cErrors\u003e\u003cError\u003e\u003cCode\u003eInvalidAction\u003c/Code\u003e\u003cMessage\u003eunknown action: Nope\u003c/Message\u003e\u003c/Error\u003e\u003c/Errors\u003e\u003cError\u003e\u003cCode\u003eInvalidAction\u003c/Code\u003e\u003cMessage\u003eunknown action: Nope\u003c/Message\u003e\u003c/Error\u003e\u003cRequestID\u003e\u003cv\u003e\u003c/RequestID\u003e\u003cRequestId\u003e\u003cv\u003e\u003c/RequestId\u003e\u003c/Response\u003e" + }, + { + "name": "ec2 bad body", + "status": 200, + "body": "\u003c?xml version=\"1.0\" encoding=\"UTF-8\"?\u003e\n\u003cDescribeInstancesResponse xmlns=\"http://ec2.amazonaws.com/doc/2016-11-15/\"\u003e\u003crequestId\u003e\u003cv\u003e\u003c/requestId\u003e\u003creservationSet\u003e\u003c/reservationSet\u003e\u003c/DescribeInstancesResponse\u003e" + }, + { + "name": "as CreateAutoScalingGroup", + "status": 200, + "body": "\u003c?xml version=\"1.0\" encoding=\"UTF-8\"?\u003e\n\u003cCreateAutoScalingGroupResponse xmlns=\"http://autoscaling.amazonaws.com/doc/2011-01-01/\"\u003e\u003cResponseMetadata\u003e\u003cRequestId\u003e\u003cv\u003e\u003c/RequestId\u003e\u003c/ResponseMetadata\u003e\u003c/CreateAutoScalingGroupResponse\u003e" + }, + { + "name": "as DescribeAutoScalingGroups", + "status": 200, + "body": "\u003c?xml version=\"1.0\" encoding=\"UTF-8\"?\u003e\n\u003cDescribeAutoScalingGroupsResponse xmlns=\"http://autoscaling.amazonaws.com/doc/2011-01-01/\"\u003e\u003cDescribeAutoScalingGroupsResult\u003e\u003cAutoScalingGroups\u003e\u003cmember\u003e\u003cAutoScalingGroupName\u003eg\u003c/AutoScalingGroupName\u003e\u003cMinSize\u003e0\u003c/MinSize\u003e\u003cMaxSize\u003e1\u003c/MaxSize\u003e\u003cDesiredCapacity\u003e1\u003c/DesiredCapacity\u003e\u003cStatus\u003eactive\u003c/Status\u003e\u003cCreatedTime\u003e2026-01-02T03:04:05Z\u003c/CreatedTime\u003e\u003cLaunchConfigurationName\u003elc\u003c/LaunchConfigurationName\u003e\u003cInstances\u003e\u003cmember\u003e\u003cInstanceId\u003e\u003cv\u003e\u003c/InstanceId\u003e\u003cAvailabilityZone\u003eus-east-1a\u003c/AvailabilityZone\u003e\u003cLifecycleState\u003eInService\u003c/LifecycleState\u003e\u003cHealthStatus\u003eHealthy\u003c/HealthStatus\u003e\u003cProtectedFromScaleIn\u003efalse\u003c/ProtectedFromScaleIn\u003e\u003c/member\u003e\u003c/Instances\u003e\u003cAvailabilityZones\u003e\u003cmember\u003eus-east-1a\u003c/member\u003e\u003c/AvailabilityZones\u003e\u003cTags\u003e\u003c/Tags\u003e\u003c/member\u003e\u003c/AutoScalingGroups\u003e\u003c/DescribeAutoScalingGroupsResult\u003e\u003cResponseMetadata\u003e\u003cRequestId\u003e\u003cv\u003e\u003c/RequestId\u003e\u003c/ResponseMetadata\u003e\u003c/DescribeAutoScalingGroupsResponse\u003e" + }, + { + "name": "as UpdateAutoScalingGroup", + "status": 200, + "body": "\u003c?xml version=\"1.0\" encoding=\"UTF-8\"?\u003e\n\u003cUpdateAutoScalingGroupResponse xmlns=\"http://autoscaling.amazonaws.com/doc/2011-01-01/\"\u003e\u003cResponseMetadata\u003e\u003cRequestId\u003e\u003cv\u003e\u003c/RequestId\u003e\u003c/ResponseMetadata\u003e\u003c/UpdateAutoScalingGroupResponse\u003e" + }, + { + "name": "as SetDesiredCapacity", + "status": 200, + "body": "\u003c?xml version=\"1.0\" encoding=\"UTF-8\"?\u003e\n\u003cSetDesiredCapacityResponse xmlns=\"http://autoscaling.amazonaws.com/doc/2011-01-01/\"\u003e\u003cResponseMetadata\u003e\u003cRequestId\u003e\u003cv\u003e\u003c/RequestId\u003e\u003c/ResponseMetadata\u003e\u003c/SetDesiredCapacityResponse\u003e" + }, + { + "name": "as PutScalingPolicy", + "status": 200, + "body": "\u003c?xml version=\"1.0\" encoding=\"UTF-8\"?\u003e\n\u003cPutScalingPolicyResponse xmlns=\"http://autoscaling.amazonaws.com/doc/2011-01-01/\"\u003e\u003cPutScalingPolicyResult\u003e\u003cPolicyARN\u003earn:aws:autoscaling:::p\u003c/PolicyARN\u003e\u003c/PutScalingPolicyResult\u003e\u003cResponseMetadata\u003e\u003cRequestId\u003e\u003cv\u003e\u003c/RequestId\u003e\u003c/ResponseMetadata\u003e\u003c/PutScalingPolicyResponse\u003e" + }, + { + "name": "as ExecutePolicy", + "status": 200, + "body": "\u003c?xml version=\"1.0\" encoding=\"UTF-8\"?\u003e\n\u003cExecutePolicyResponse xmlns=\"http://autoscaling.amazonaws.com/doc/2011-01-01/\"\u003e\u003cResponseMetadata\u003e\u003cRequestId\u003e\u003cv\u003e\u003c/RequestId\u003e\u003c/ResponseMetadata\u003e\u003c/ExecutePolicyResponse\u003e" + }, + { + "name": "as DeletePolicy (served by IAM)", + "status": 404, + "body": "\u003c?xml version=\"1.0\" encoding=\"UTF-8\"?\u003e\n\u003cResponse\u003e\u003cErrors\u003e\u003cError\u003e\u003cCode\u003eNoSuchEntity\u003c/Code\u003e\u003cMessage\u003epolicy \u0026#34;\u0026#34; not found\u003c/Message\u003e\u003c/Error\u003e\u003c/Errors\u003e\u003cError\u003e\u003cCode\u003eNoSuchEntity\u003c/Code\u003e\u003cMessage\u003epolicy \u0026#34;\u0026#34; not found\u003c/Message\u003e\u003c/Error\u003e\u003cRequestID\u003e\u003cv\u003e\u003c/RequestID\u003e\u003cRequestId\u003e\u003cv\u003e\u003c/RequestId\u003e\u003c/Response\u003e" + }, + { + "name": "as DeleteAutoScalingGroup", + "status": 400, + "body": "\u003c?xml version=\"1.0\" encoding=\"UTF-8\"?\u003e\n\u003cResponse\u003e\u003cErrors\u003e\u003cError\u003e\u003cCode\u003eResourceInUse\u003c/Code\u003e\u003cMessage\u003eASG \u0026#34;g\u0026#34; has instances; use forceDelete\u003c/Message\u003e\u003c/Error\u003e\u003c/Errors\u003e\u003cError\u003e\u003cCode\u003eResourceInUse\u003c/Code\u003e\u003cMessage\u003eASG \u0026#34;g\u0026#34; has instances; use forceDelete\u003c/Message\u003e\u003c/Error\u003e\u003cRequestID\u003e\u003cv\u003e\u003c/RequestID\u003e\u003cRequestId\u003e\u003cv\u003e\u003c/RequestId\u003e\u003c/Response\u003e" + }, + { + "name": "iam ListUsers", + "status": 200, + "body": "\u003c?xml version=\"1.0\" encoding=\"UTF-8\"?\u003e\n\u003cListUsersResponse xmlns=\"https://iam.amazonaws.com/doc/2010-05-08/\"\u003e\u003cListUsersResult\u003e\u003cUsers\u003e\u003c/Users\u003e\u003cIsTruncated\u003efalse\u003c/IsTruncated\u003e\u003c/ListUsersResult\u003e\u003cResponseMetadata\u003e\u003cRequestId\u003e\u003cv\u003e\u003c/RequestId\u003e\u003c/ResponseMetadata\u003e\u003c/ListUsersResponse\u003e" + }, + { + "name": "sts GetCallerIdentity", + "status": 200, + "body": "\u003c?xml version=\"1.0\" encoding=\"UTF-8\"?\u003e\n\u003cGetCallerIdentityResponse xmlns=\"https://sts.amazonaws.com/doc/2011-06-15/\"\u003e\u003cGetCallerIdentityResult\u003e\u003cArn\u003earn:aws:iam::123456789012:user/AKIAANY\u003c/Arn\u003e\u003cUserId\u003eAIDA14031713B346D219\u003c/UserId\u003e\u003cAccount\u003e123456789012\u003c/Account\u003e\u003c/GetCallerIdentityResult\u003e\u003cResponseMetadata\u003e\u003cRequestId\u003e\u003cv\u003e\u003c/RequestId\u003e\u003c/ResponseMetadata\u003e\u003c/GetCallerIdentityResponse\u003e" + }, + { + "name": "sns ListTopics", + "status": 200, + "body": "\u003c?xml version=\"1.0\" encoding=\"UTF-8\"?\u003e\n\u003cListTopicsResponse xmlns=\"http://sns.amazonaws.com/doc/2010-03-31/\"\u003e\u003cListTopicsResult\u003e\u003cTopics\u003e\u003c/Topics\u003e\u003c/ListTopicsResult\u003e\u003cResponseMetadata\u003e\u003cRequestId\u003e\u003cv\u003e\u003c/RequestId\u003e\u003c/ResponseMetadata\u003e\u003c/ListTopicsResponse\u003e" + }, + { + "name": "sagemaker runtime GET", + "status": 405, + "body": "{\"Message\":\"method not allowed\",\"__type\":\"MethodNotAllowed\"}\n" + }, + { + "name": "sagemaker runtime missing endpoint", + "status": 404, + "body": "{\"Message\":\"NotFound: endpoint \\\"e\\\" not found\",\"__type\":\"ValidationError\"}\n" + }, + { + "name": "sagemaker feature store DELETE", + "status": 400, + "body": "{\"Message\":\"NotFound: feature group \\\"g\\\" not found\",\"__type\":\"ResourceNotFound\"}\n" + }, + { + "name": "sagemaker feature store POST", + "status": 405, + "body": "{\"Message\":\"method not allowed\",\"__type\":\"MethodNotAllowed\"}\n" + }, + { + "name": "sagemaker ListModels", + "status": 200, + "body": "{\"Models\":[]}\n" + }, + { + "name": "sagemaker unknown op", + "status": 400, + "body": "{\"Message\":\"unknown operation: Nope\",\"__type\":\"UnknownOperationException\"}\n" + }, + { + "name": "s3 mb", + "status": 200, + "body": "" + }, + { + "name": "s3 ListBuckets", + "status": 200, + "body": "\u003c?xml version=\"1.0\" encoding=\"UTF-8\"?\u003e\n\u003cListAllMyBucketsResult xmlns=\"http://s3.amazonaws.com/doc/2006-03-01/\"\u003e\u003cOwner\u003e\u003cID\u003ecloudemu00000000000000000000000000000000000000000000000000000000\u003c/ID\u003e\u003cDisplayName\u003ecloudemu\u003c/DisplayName\u003e\u003c/Owner\u003e\u003cBuckets\u003e\u003cBucket\u003e\u003cName\u003eoff-bucket\u003c/Name\u003e\u003cCreationDate\u003e2026-01-02T03:04:05Z\u003c/CreationDate\u003e\u003c/Bucket\u003e\u003c/Buckets\u003e\u003c/ListAllMyBucketsResult\u003e" + }, + { + "name": "lambda ListFunctions", + "status": 200, + "body": "{\"Functions\":[]}\n" + }, + { + "name": "unknown target", + "status": 501, + "body": "no handler registered for this request\n" + } +] diff --git a/server/aws/timestreamwrite/handler.go b/server/aws/timestreamwrite/handler.go index 6311ca03e..ab17d96d7 100644 --- a/server/aws/timestreamwrite/handler.go +++ b/server/aws/timestreamwrite/handler.go @@ -131,3 +131,7 @@ func statusFor(exception string) int { return http.StatusBadRequest } + +// IAMService returns the IAM service prefix of the operations this handler +// serves. +func (*Handler) IAMService() string { return "timestream" } diff --git a/server/aws/transfer/handler.go b/server/aws/transfer/handler.go index 7bb185272..a0cdce963 100644 --- a/server/aws/transfer/handler.go +++ b/server/aws/transfer/handler.go @@ -122,3 +122,7 @@ func writeErr(w http.ResponseWriter, err error) { wire.WriteJSONError(w, http.StatusInternalServerError, transferdriver.ExInternalService, msg) } } + +// IAMService returns the IAM service prefix of the operations this handler +// serves. +func (*Handler) IAMService() string { return "transfer" } diff --git a/server/aws/vpclattice/handler.go b/server/aws/vpclattice/handler.go index a146fedb7..8ecc82e26 100644 --- a/server/aws/vpclattice/handler.go +++ b/server/aws/vpclattice/handler.go @@ -206,3 +206,7 @@ func splitPath(p string) []string { return strings.Split(p, "/") } + +// IAMService returns the IAM service prefix of the operations this handler +// serves. +func (*Handler) IAMService() string { return "vpc-lattice" } diff --git a/server/aws/wafv2/handler.go b/server/aws/wafv2/handler.go index 26e0e89e4..a21c1b6cb 100644 --- a/server/aws/wafv2/handler.go +++ b/server/aws/wafv2/handler.go @@ -176,3 +176,7 @@ func writeErr(w http.ResponseWriter, err error) { wire.WriteJSONError(w, http.StatusInternalServerError, "WAFInternalErrorException", msg) } } + +// IAMService returns the IAM service prefix of the operations this handler +// serves. +func (*Handler) IAMService() string { return "wafv2" } diff --git a/server/handlers_test.go b/server/handlers_test.go new file mode 100644 index 000000000..2f7693bac --- /dev/null +++ b/server/handlers_test.go @@ -0,0 +1,28 @@ +package server + +import ( + "net/http" + "testing" +) + +type pathHandler string + +func (p pathHandler) Matches(r *http.Request) bool { return r.URL.Path == string(p) } + +func (pathHandler) ServeHTTP(http.ResponseWriter, *http.Request) {} + +func TestHandlersReturnsACopyInOrder(t *testing.T) { + s := New(pathHandler("/a")) + s.Register(pathHandler("/b")) + + got := s.Handlers() + if len(got) != 2 || got[0] != pathHandler("/a") || got[1] != pathHandler("/b") { + t.Fatalf("Handlers() = %v, want [/a /b]", got) + } + + got[0] = pathHandler("/x") + + if s.Handlers()[0] != pathHandler("/a") { + t.Fatal("changing the returned slice changed the server") + } +} diff --git a/server/serveflags/serveflags.go b/server/serveflags/serveflags.go index a0e60352c..b079e6eb3 100644 --- a/server/serveflags/serveflags.go +++ b/server/serveflags/serveflags.go @@ -208,8 +208,10 @@ func registerEnforceAuthFlag(fs *flag.FlagSet, c *CommonConfig) { fs.BoolVar(&c.EnforceAuth, "enforce-auth", false, "require authentication on each request; off by default. AWS: verify the SigV4 signature against a registered IAM access "+ "key or an STS temporary (ASIA) credential (403 on failure). ASIA credentials are verified against the secret STS "+ - "recorded when it issued them, and unknown or expired sessions are rejected. IAM authorization is then enforced for "+ - "long-term (AKIA) keys on JSON-RPC services only, and applies only to principals that have IAM policies. Azure: "+ + "recorded when it issued them, and unknown or expired sessions are rejected. Each request is then authorized against "+ + "the caller's IAM policies: per operation for query and JSON-RPC services, and at service level for REST services "+ + "(only a service-wide grant such as s3:* passes, so fine-grained REST policies are denied until per-operation "+ + "checks land). Root and users with no policies are unrestricted. Azure: "+ "validate each request's Bearer token claims (accepted audience, expiry, a principal claim) and reject "+ "missing/malformed/expired/wrong-audience tokens with 401. The token signature is not verified (no Azure AD signing "+ "key), so this is claims-based authentication only; RBAC authorization is a follow-up") diff --git a/server/server.go b/server/server.go index ff950d7ff..b2da7bc41 100644 --- a/server/server.go +++ b/server/server.go @@ -94,6 +94,12 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { http.Error(w, "no handler registered for this request", http.StatusNotImplemented) } +// Handlers returns the registered handlers in dispatch order. The slice is a +// copy, so changing it does not change the server. +func (s *Server) Handlers() []Handler { + return append([]Handler(nil), s.handlers...) +} + // Match returns the handler that would serve r (the first registered handler // whose Matches returns true), or nil when none would. A pre-dispatch hook uses // it to bind a decision to the handler that actually runs. Matches may read the diff --git a/server/wire/awsauthz/awsauthz.go b/server/wire/awsauthz/awsauthz.go new file mode 100644 index 000000000..daae1ce39 --- /dev/null +++ b/server/wire/awsauthz/awsauthz.go @@ -0,0 +1,184 @@ +// Package awsauthz is the contract between the AWS auth gate and the service +// handlers for IAM authorization. A handler says which IAM actions a request +// needs (Resolver), or at least which IAM service it belongs to +// (ServiceNamer), and may render the 403 in its own wire format (DenyWriter). +// The gate does the policy evaluation; this package holds only the shared +// types and helpers, so handlers can depend on it without importing the gate. +package awsauthz + +import ( + "context" + "net" + "net/http" + "strconv" + "strings" + "time" + + "github.com/stackshy/cloudemu/v2/server/authctx" +) + +// CheckMode says how an implicit deny on a Check is treated. +type CheckMode int + +const ( + // Required makes an implicit deny final. It is the default. + Required CheckMode = iota + // DenyOnly lets the request through unless a policy explicitly denies it, + // for operations AWS documents as needing no permission but still subject + // to an explicit Deny (sts:GetSessionToken). + DenyOnly + // ResourcePolicy leaves an implicit deny to the handler, which finishes the + // decision with the resource's own policy (for example a role trust policy). + // The gate records the identity decision in the Evaluation. + ResourcePolicy +) + +// Check is one IAM permission a request needs. +type Check struct { + // Action is the IAM action, such as "s3:PutObject". + Action string + // Resource is the full ARN, "*" for actions that take no resource, or "" + // when the handler cannot name it yet. An unknown resource is evaluated + // conservatively: never more permissive than any concrete resource. + Resource string + Mode CheckMode +} + +// Scope is the account, region and partition the server runs in. It is +// fixed by the server, never taken from the request's signing scope. +type Scope struct { + AccountID, Region, Partition string +} + +// Resolver is implemented by handlers that can name the IAM actions of a +// request (tier 1). ok=false means the handler cannot name the operation, and +// it then guarantees ServeHTTP writes an error without side effects. The +// checks must be derived from the same signal dispatch uses to pick the +// operation, never from a separate parse. +type Resolver interface { + IAMChecks(r *http.Request, s Scope) (checks []Check, ok bool) +} + +// ServiceNamer is implemented by every AWS handler. It returns the IAM +// service prefix (such as "s3" or "elasticfilesystem") of the operations the +// handler serves. A handler that is not a Resolver is authorized at service +// level: only a grant covering every action of the service lets it through. +type ServiceNamer interface { + IAMService() string +} + +// DenyWriter is implemented by handlers whose 403 has a service-specific +// shape (EC2 UnauthorizedOperation, the S3 and Route 53 XML errors). +type DenyWriter interface { + WriteAccessDenied(w http.ResponseWriter, r *http.Request, msg string) +} + +// Decision is the outcome of evaluating one Check against the caller's +// identity policies. +type Decision string + +// The identity decisions, matching the IAM simulator's strings. +const ( + Allowed Decision = "allowed" + ImplicitDeny Decision = "implicitDeny" + ExplicitDeny Decision = "explicitDeny" +) + +// Evaluation carries the gate's identity decisions to the handler, so a +// handler can finish a ResourcePolicy check with the resource's own policy. +type Evaluation struct { + Principal authctx.Principal + CondCtx map[string]string + Decisions map[Check]Decision +} + +type evaluationKey struct{} + +// WithEvaluation returns ctx carrying e. +func WithEvaluation(ctx context.Context, e *Evaluation) context.Context { + return context.WithValue(ctx, evaluationKey{}, *e) +} + +// EvaluationFrom returns the Evaluation the gate attached, and ok=false when +// the request was not authorized by the gate (EnforceAuth off). +func EvaluationFrom(ctx context.Context) (Evaluation, bool) { + e, ok := ctx.Value(evaluationKey{}).(Evaluation) + + return e, ok +} + +// Single returns the one-check list for action on resource. +func Single(action, resource string) []Check { + return []Check{{Action: action, Resource: resource}} +} + +// QueryChecks names the IAM action of a query-protocol request as +// ":", with an unknown resource. It reads the form Action +// exactly as the query handlers' dispatch does (r.Form.Get, so the first body +// value wins over the query string). A form that does not parse, or a +// missing Action, returns ok=false. +func QueryChecks(r *http.Request, service string) ([]Check, bool) { + if err := r.ParseForm(); err != nil { + return nil, false + } + + action := r.Form.Get("Action") + if action == "" { + return nil, false + } + + return Single(service+":"+action, ""), true +} + +// ConditionContext gathers the AWS global condition keys known from the +// request and the verified caller. Keys that cannot be known are left out, +// so a policy that references one follows IAM's missing-key rules. +func ConditionContext(r *http.Request, p *authctx.Principal, s Scope) map[string]string { + ctx := map[string]string{ + "aws:CurrentTime": time.Now().UTC().Format(time.RFC3339), + "aws:SecureTransport": strconv.FormatBool(r.TLS != nil), + } + + set := func(key, value string) { + if value != "" { + ctx[key] = value + } + } + + set("aws:SourceIp", clientIP(r)) + set("aws:PrincipalArn", p.ARN) + set("aws:username", p.UserName) + set("aws:userid", p.UserID) + set("aws:PrincipalAccount", p.AccountID) + set("aws:PrincipalType", principalType(p.ARN)) + set("aws:RequestedRegion", s.Region) + + return ctx +} + +// principalType maps a caller ARN to the aws:PrincipalType value. +func principalType(arn string) string { + switch { + case arn == "": + return "" + case strings.HasSuffix(arn, ":root"): + return "Account" + case strings.Contains(arn, ":assumed-role/"): + return "AssumedRole" + case strings.Contains(arn, ":federated-user/"): + return "FederatedUser" + default: + return "User" + } +} + +// clientIP is the caller's address for aws:SourceIp. It uses only the +// connection's RemoteAddr, never X-Forwarded-For: no trusted proxy sits in +// front of the server, so that header would let a client pick its own IP. +func clientIP(r *http.Request) string { + if host, _, err := net.SplitHostPort(r.RemoteAddr); err == nil { + return host + } + + return r.RemoteAddr +} diff --git a/server/wire/awsauthz/awsauthz_test.go b/server/wire/awsauthz/awsauthz_test.go new file mode 100644 index 000000000..a850221ec --- /dev/null +++ b/server/wire/awsauthz/awsauthz_test.go @@ -0,0 +1,115 @@ +package awsauthz + +import ( + "context" + "crypto/tls" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/stackshy/cloudemu/v2/server/authctx" +) + +func formRequest(path, body string) *http.Request { + r := httptest.NewRequest(http.MethodPost, path, strings.NewReader(body)) + r.Header.Set("Content-Type", "application/x-www-form-urlencoded") + + return r +} + +func TestQueryChecks(t *testing.T) { + tests := []struct { + name string + req *http.Request + wantAction string + wantOK bool + }{ + {"body Action", formRequest("/", "Action=CreateUser"), "iam:CreateUser", true}, + {"first of duplicated Actions", formRequest("/", "Action=ListUsers&Action=CreateUser"), "iam:ListUsers", true}, + {"body wins over query string", formRequest("/?Action=ListUsers", "Action=CreateUser"), "iam:CreateUser", true}, + {"query string only", httptest.NewRequest(http.MethodGet, "/?Action=GetUser", nil), "iam:GetUser", true}, + {"case is kept", formRequest("/", "Action=createuser"), "iam:createuser", true}, + {"no Action", formRequest("/", "Version=1"), "", false}, + {"body does not parse", formRequest("/", "Action=CreateUser&x=%zz"), "", false}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + checks, ok := QueryChecks(tc.req, "iam") + require.Equal(t, tc.wantOK, ok) + + if !ok { + assert.Nil(t, checks) + return + } + + assert.Equal(t, []Check{{Action: tc.wantAction, Resource: "", Mode: Required}}, checks) + }) + } +} + +func TestSingle(t *testing.T) { + assert.Equal(t, []Check{{Action: "s3:GetObject", Resource: "arn:aws:s3:::b/k"}}, Single("s3:GetObject", "arn:aws:s3:::b/k")) +} + +func TestEvaluationRoundTrip(t *testing.T) { + _, ok := EvaluationFrom(context.Background()) + assert.False(t, ok) + + c := Check{Action: "sts:AssumeRole", Resource: "arn:aws:iam::1:role/r", Mode: ResourcePolicy} + e := Evaluation{Principal: authctx.Principal{UserName: "u"}, Decisions: map[Check]Decision{c: ImplicitDeny}} + + got, ok := EvaluationFrom(WithEvaluation(context.Background(), &e)) + require.True(t, ok) + assert.Equal(t, "u", got.Principal.UserName) + assert.Equal(t, ImplicitDeny, got.Decisions[c]) +} + +func TestConditionContext(t *testing.T) { + r := httptest.NewRequest(http.MethodGet, "/", nil) + r.RemoteAddr = "10.1.2.3:5555" + r.Header.Set("X-Forwarded-For", "1.1.1.1") + + p := authctx.Principal{ + UserName: "ann", ARN: "arn:aws:iam::123456789012:user/ann", AccountID: "123456789012", UserID: "AIDAANN", + } + + ctx := ConditionContext(r, &p, Scope{AccountID: "123456789012", Region: "eu-west-1", Partition: "aws"}) + + assert.Equal(t, "10.1.2.3", ctx["aws:SourceIp"], "X-Forwarded-For is never trusted") + assert.Equal(t, "false", ctx["aws:SecureTransport"]) + assert.Equal(t, p.ARN, ctx["aws:PrincipalArn"]) + assert.Equal(t, "ann", ctx["aws:username"]) + assert.Equal(t, "AIDAANN", ctx["aws:userid"]) + assert.Equal(t, "123456789012", ctx["aws:PrincipalAccount"]) + assert.Equal(t, "User", ctx["aws:PrincipalType"]) + assert.Equal(t, "eu-west-1", ctx["aws:RequestedRegion"], "the region comes from the server, not the signature") + assert.NotEmpty(t, ctx["aws:CurrentTime"]) + + r.TLS = &tls.ConnectionState{} + r.RemoteAddr = "no-port" + + ctx = ConditionContext(r, &authctx.Principal{}, Scope{}) + assert.Equal(t, "true", ctx["aws:SecureTransport"]) + assert.Equal(t, "no-port", ctx["aws:SourceIp"]) + + for _, k := range []string{"aws:PrincipalArn", "aws:username", "aws:userid", "aws:PrincipalType", "aws:RequestedRegion"} { + assert.NotContains(t, ctx, k, "unknown keys are left out") + } +} + +func TestPrincipalType(t *testing.T) { + for arn, want := range map[string]string{ + "": "", + "arn:aws:iam::1:root": "Account", + "arn:aws:sts::1:assumed-role/r/s": "AssumedRole", + "arn:aws:sts::1:federated-user/bob": "FederatedUser", + "arn:aws:iam::1:user/path/ann": "User", + } { + assert.Equal(t, want, principalType(arn), arn) + } +} From d1dbd5cf2388a45ef15503c5a3cf45e550a18860 Mon Sep 17 00:00:00 2001 From: Nitin Kumar Date: Fri, 2 Oct 2026 19:28:54 +0530 Subject: [PATCH 2/3] fix(auth): authorize the body dispatch reads and keep AssumeRole denials neutral Matches peeks now hand the whole body back (wire.PeekBody), and the probe body is no longer reset after Match, so IAMChecks and dispatch read the same bytes. An AssumeRole deny names the RoleArn as sent. --- server/aws/authz_matrix_test.go | 57 +++++++++++++++++- server/aws/authzgate.go | 13 ++++- server/aws/kinesisvideo/handler.go | 8 +-- server/aws/publicauth.go | 10 +++- server/aws/redshift/handler.go | 2 +- server/aws/sts/authz.go | 5 +- server/aws/sts/authz_test.go | 17 ++++-- server/gcp/datafusion/wire.go | 6 +- server/gcp/filestore/handler.go | 7 +-- server/gcp/managedkafka/handler.go | 7 +-- server/gcp/securesourcemanager/handler.go | 11 +--- server/gcp/spanner/handler.go | 7 +-- server/server.go | 4 +- server/serverkit/matches_body_test.go | 71 +++++++++++++++++++++++ server/wire/awsauthz/awsauthz.go | 5 ++ server/wire/peek.go | 29 +++++++++ 16 files changed, 210 insertions(+), 49 deletions(-) create mode 100644 server/serverkit/matches_body_test.go create mode 100644 server/wire/peek.go diff --git a/server/aws/authz_matrix_test.go b/server/aws/authz_matrix_test.go index 580536b01..8ca5770e0 100644 --- a/server/aws/authz_matrix_test.go +++ b/server/aws/authz_matrix_test.go @@ -24,7 +24,7 @@ import ( // sreq is one signed request in the authorization matrix. type sreq struct { method, path, ctype, body, service, host string - header map[string]string + header map[string]string } func doSigned(t *testing.T, ts *httptest.Server, creds aws.Credentials, rq sreq) (int, string) { @@ -276,7 +276,7 @@ func TestAuthzMatrixProtocolMixing(t *testing.T) { t.Run("cbor PutMetricData with ?Action=DescribeAlarms", func(t *testing.T) { status, body := doSigned(t, ts, describer, sreq{ - path: "/service/GraniteServiceVersion20100801/operation/PutMetricData?Action=DescribeAlarms", + path: "/service/GraniteServiceVersion20100801/operation/PutMetricData?Action=DescribeAlarms", ctype: "application/cbor", body: string(payload), service: "monitoring", header: map[string]string{"Smithy-Protocol": "rpc-v2-cbor"}, }) @@ -469,6 +469,30 @@ func TestAuthzMatrixSTS(t *testing.T) { } }) + t.Run("the deny message does not reveal whether the role exists", func(t *testing.T) { + if _, err := cloud.IAM.CreateRole(ctx, iamdriver.RoleConfig{ + Name: "pathed", Path: "/team/", AssumeRolePolicyDoc: trust, + }); err != nil { + t.Fatalf("CreateRole: %v", err) + } + + send := func(name string) string { + _, body := doSigned(t, ts, dyn, form("sts", "Action=AssumeRole&Version=2011-06-15&RoleSessionName=s"+ + "&RoleArn=arn:aws:iam::"+defaultTestAccount+":role/"+name)) + + return body[strings.Index(body, ""):strings.Index(body, "")] + } + + existing, missing := send("pathed"), send("absent") + if strings.ReplaceAll(missing, "role/absent", "role/pathed") != existing { + t.Fatalf("messages differ:\n existing: %s\n missing: %s", existing, missing) + } + + if !strings.Contains(existing, "on resource: arn:aws:iam::"+defaultTestAccount+":role/pathed because") { + t.Fatalf("the message must name the RoleArn as sent: %s", existing) + } + }) + t.Run("GetCallerIdentity needs no permission", func(t *testing.T) { status, body := doSigned(t, ts, dyn, form("sts", "Action=GetCallerIdentity&Version=2011-06-15")) if status != http.StatusOK { @@ -518,3 +542,32 @@ func TestAuthzMatrixBootstrap(t *testing.T) { }) } } + +// TestAuthzMatrixTruncatingPeek covers a Matches that peeks at the body. The +// gate must authorize the bytes dispatch reads: Kinesis Video peeks at +// /TagResource bodies, and if it handed back only the first 64 KiB, EC2 +// would run the query-string CreateVpc while the gate authorized the +// body's DescribeVpcs. +func TestAuthzMatrixTruncatingPeek(t *testing.T) { + ts, cloud := matrixServer(t, func(d *Drivers) { d.SavingsPlans = false }) + viewer := userWithPolicy(t, cloud, "vpcviewer", allow("ec2:DescribeVpcs")) + + before, err := cloud.VPC.DescribeVPCs(context.Background(), nil) + if err != nil { + t.Fatalf("DescribeVPCs: %v", err) + } + + status, body := doSigned(t, ts, viewer, sreq{ + path: "/TagResource?Action=CreateVpc&CidrBlock=10.9.0.0/16", ctype: "Application/x-www-form-urlencoded", + body: "Pad=" + strings.Repeat("a", 70000) + "&Action=DescribeVpcs", service: "ec2", + }) + + after, err := cloud.VPC.DescribeVPCs(context.Background(), nil) + if err != nil { + t.Fatalf("DescribeVPCs: %v", err) + } + + if len(after) != len(before) { + t.Fatalf("a VPC was created (%d -> %d); response %d %.200s", len(before), len(after), status, body) + } +} diff --git a/server/aws/authzgate.go b/server/aws/authzgate.go index 236513cd3..d09ed6dbb 100644 --- a/server/aws/authzgate.go +++ b/server/aws/authzgate.go @@ -251,9 +251,9 @@ func (g *gateConfig) evaluateChecks( switch { case d == awsauthz.ExplicitDeny: - return nil, denyMessage(p, c.Action, c.Resource, true) + return nil, denyMessage(p, c.Action, messageResource(c), true) case d == awsauthz.ImplicitDeny && c.Mode == awsauthz.Required: - return nil, denyMessage(p, c.Action, c.Resource, false) + return nil, denyMessage(p, c.Action, messageResource(c), false) } if c.Mode == awsauthz.ResourcePolicy { @@ -264,6 +264,15 @@ func (g *gateConfig) evaluateChecks( return decisions, "" } +// messageResource is the resource a deny message names for c. +func messageResource(c awsauthz.Check) string { + if c.MessageResource != "" { + return c.MessageResource + } + + return c.Resource +} + // evaluateServiceWide allows the caller only when its policies grant every // action of the service on every resource. action is ":*". func (g *gateConfig) evaluateServiceWide( diff --git a/server/aws/kinesisvideo/handler.go b/server/aws/kinesisvideo/handler.go index 1ad37d3ff..a2ee2a46f 100644 --- a/server/aws/kinesisvideo/handler.go +++ b/server/aws/kinesisvideo/handler.go @@ -20,12 +20,11 @@ package kinesisvideo import ( - "bytes" "encoding/json" - "io" "net/http" "strings" + "github.com/stackshy/cloudemu/v2/server/wire" "github.com/stackshy/cloudemu/v2/services/kinesisvideo/driver" ) @@ -149,10 +148,7 @@ func peekResourceARN(r *http.Request) string { return "" } - body, err := io.ReadAll(io.LimitReader(r.Body, peekBodyBytes)) - _ = r.Body.Close() - - r.Body = io.NopCloser(bytes.NewReader(body)) + body, err := wire.PeekBody(r, peekBodyBytes) if err != nil { return "" diff --git a/server/aws/publicauth.go b/server/aws/publicauth.go index 917b2234c..15837eb37 100644 --- a/server/aws/publicauth.go +++ b/server/aws/publicauth.go @@ -20,7 +20,13 @@ import ( // read, so the lookup and the real dispatch see identical input. A form body // or query string that does not parse returns ok=false: handlers that parse // forms could otherwise disagree about what the request is, so callers treat -// it as fail-closed. The returned probe's body is reset for the next reader. +// it as fail-closed. +// +// The probe's body is left exactly as the Matches calls left it, because the +// real request's body will be in that same state when the handler serves it. +// A Resolver reading the probe therefore sees the bytes dispatch sees. That +// holds only if every Matches that peeks at the body puts it back whole (see +// wire.PeekBody); TestMatchesLeaveBodyIntact checks it for every handler. func probeRoute( r *http.Request, body []byte, match func(*http.Request) server.Handler, ) (probe *http.Request, h server.Handler, ok bool) { @@ -40,8 +46,6 @@ func probeRoute( h = match(probe) - probe.Body = io.NopCloser(bytes.NewReader(body)) - return probe, h, true } diff --git a/server/aws/redshift/handler.go b/server/aws/redshift/handler.go index 443af7b50..7abf06e9f 100644 --- a/server/aws/redshift/handler.go +++ b/server/aws/redshift/handler.go @@ -351,7 +351,7 @@ func alreadyExistsCode(err error) string { // IAMService returns the IAM service prefix of the operations this handler // serves. -func (*Handler) IAMService() string { return "redshift" } +func (*Handler) IAMService() string { return scopeRedshift } // IAMChecks names the IAM action of a request from the form Action that // ServeHTTP dispatches on. An Action the handler does not know is authorized diff --git a/server/aws/sts/authz.go b/server/aws/sts/authz.go index 405526cb9..aa3371289 100644 --- a/server/aws/sts/authz.go +++ b/server/aws/sts/authz.go @@ -23,7 +23,8 @@ type roleGetter interface { // assumed. The role is resolved the way the operation resolves it (by the // last path segment of RoleArn), and its stored ARN is the resource, so a // RoleArn with a different path or account cannot borrow another role's -// grant. An unknown role leaves the resource unknown. +// grant. An unknown role leaves the resource unknown. A deny names the +// RoleArn as sent, like AWS, so it does not reveal whether the role exists. // - GetFederationToken needs sts:GetFederationToken on the federated user. // - GetAccessKeyInfo and DecodeAuthorizationMessage take no resource. func (h *Handler) IAMChecks(r *http.Request, _ awsauthz.Scope) ([]awsauthz.Check, bool) { @@ -40,7 +41,7 @@ func (h *Handler) IAMChecks(r *http.Request, _ awsauthz.Scope) ([]awsauthz.Check case actionGetSessionToken: return []awsauthz.Check{{Action: action, Resource: "*", Mode: awsauthz.DenyOnly}}, true case actionAssumeRole, actionAssumeRoleWithWebIdentity, actionAssumeRoleWithSAML: - return awsauthz.Single(action, h.assumedRoleARN(r)), true + return []awsauthz.Check{{Action: action, Resource: h.assumedRoleARN(r), MessageResource: r.Form.Get("RoleArn")}}, true case actionGetFederationToken: return awsauthz.Single(action, "arn:aws:sts::"+h.accountID+":federated-user/"+r.Form.Get("Name")), true case actionGetAccessKeyInfo, actionDecodeAuthorizationMessage: diff --git a/server/aws/sts/authz_test.go b/server/aws/sts/authz_test.go index 2209d2429..6a8cedb70 100644 --- a/server/aws/sts/authz_test.go +++ b/server/aws/sts/authz_test.go @@ -22,6 +22,12 @@ func TestIAMChecks(t *testing.T) { h := New("123456789012", "us-east-1", cloud.IAM) role := "arn:aws:iam::123456789012:role/team/deploy" + other := "arn:aws:iam::999999999999:role/other/deploy" + missing := "arn:aws:iam::123456789012:role/missing" + + assume := func(action, resource, requested string) []awsauthz.Check { + return []awsauthz.Check{{Action: action, Resource: resource, MessageResource: requested}} + } cases := []struct { body string @@ -30,13 +36,14 @@ func TestIAMChecks(t *testing.T) { }{ {"Action=GetCallerIdentity", []awsauthz.Check{}, true}, {"Action=GetSessionToken", []awsauthz.Check{{Action: "sts:GetSessionToken", Resource: "*", Mode: awsauthz.DenyOnly}}, true}, - {"Action=AssumeRole&RoleArn=" + role, awsauthz.Single("sts:AssumeRole", role), true}, + {"Action=AssumeRole&RoleArn=" + role, assume("sts:AssumeRole", role, role), true}, // The operation assumes the role by its last path segment, so the // resource is the stored ARN, whatever path or account was sent. - {"Action=AssumeRole&RoleArn=arn:aws:iam::999999999999:role/other/deploy", awsauthz.Single("sts:AssumeRole", role), true}, - {"Action=AssumeRole&RoleArn=arn:aws:iam::123456789012:role/missing", awsauthz.Single("sts:AssumeRole", ""), true}, - {"Action=AssumeRoleWithWebIdentity&RoleArn=" + role, awsauthz.Single("sts:AssumeRoleWithWebIdentity", role), true}, - {"Action=AssumeRoleWithSAML&RoleArn=" + role, awsauthz.Single("sts:AssumeRoleWithSAML", role), true}, + // A deny still names the RoleArn as sent. + {"Action=AssumeRole&RoleArn=" + other, assume("sts:AssumeRole", role, other), true}, + {"Action=AssumeRole&RoleArn=" + missing, assume("sts:AssumeRole", "", missing), true}, + {"Action=AssumeRoleWithWebIdentity&RoleArn=" + role, assume("sts:AssumeRoleWithWebIdentity", role, role), true}, + {"Action=AssumeRoleWithSAML&RoleArn=" + role, assume("sts:AssumeRoleWithSAML", role, role), true}, {"Action=GetFederationToken&Name=bob", awsauthz.Single("sts:GetFederationToken", "arn:aws:sts::123456789012:federated-user/bob"), true}, {"Action=GetAccessKeyInfo", awsauthz.Single("sts:GetAccessKeyInfo", "*"), true}, diff --git a/server/gcp/datafusion/wire.go b/server/gcp/datafusion/wire.go index 7248ddac6..d160560b3 100644 --- a/server/gcp/datafusion/wire.go +++ b/server/gcp/datafusion/wire.go @@ -1,13 +1,13 @@ package datafusion import ( - "bytes" "encoding/json" "io" "net/http" "strings" "time" + "github.com/stackshy/cloudemu/v2/server/wire" "github.com/stackshy/cloudemu/v2/server/wire/gcprest" dfdriver "github.com/stackshy/cloudemu/v2/services/datafusion/driver" ) @@ -58,9 +58,7 @@ func bodyLooksLikeDataFusion(r *http.Request) bool { return false } - raw, err := io.ReadAll(io.LimitReader(r.Body, maxProbeBytes)) - _ = r.Body.Close() - r.Body = io.NopCloser(bytes.NewReader(raw)) + raw, err := wire.PeekBody(r, maxProbeBytes) if err != nil { return false diff --git a/server/gcp/filestore/handler.go b/server/gcp/filestore/handler.go index 60e7b0c09..e2effd11d 100644 --- a/server/gcp/filestore/handler.go +++ b/server/gcp/filestore/handler.go @@ -53,14 +53,13 @@ package filestore import ( - "bytes" "encoding/json" - "io" "net/http" "strings" "github.com/stackshy/cloudemu/v2/config" "github.com/stackshy/cloudemu/v2/server/gcp/lro" + "github.com/stackshy/cloudemu/v2/server/wire" "github.com/stackshy/cloudemu/v2/server/wire/gcprest" ) @@ -180,9 +179,7 @@ func bodyLooksLikeFilestore(r *http.Request) bool { return false } - raw, err := io.ReadAll(io.LimitReader(r.Body, maxProbeBytes)) - _ = r.Body.Close() - r.Body = io.NopCloser(bytes.NewReader(raw)) + raw, err := wire.PeekBody(r, maxProbeBytes) if err != nil { return false diff --git a/server/gcp/managedkafka/handler.go b/server/gcp/managedkafka/handler.go index 598ed7222..7f7fa0f89 100644 --- a/server/gcp/managedkafka/handler.go +++ b/server/gcp/managedkafka/handler.go @@ -50,15 +50,14 @@ package managedkafka import ( - "bytes" "context" "encoding/json" - "io" "net/http" "slices" "strings" "github.com/stackshy/cloudemu/v2/server/gcp/lro" + "github.com/stackshy/cloudemu/v2/server/wire" "github.com/stackshy/cloudemu/v2/server/wire/gcprest" mkdriver "github.com/stackshy/cloudemu/v2/services/managedkafka/driver" ) @@ -281,9 +280,7 @@ func probeBody(r *http.Request) map[string]json.RawMessage { return nil } - raw, err := io.ReadAll(io.LimitReader(r.Body, maxProbeBytes)) - _ = r.Body.Close() - r.Body = io.NopCloser(bytes.NewReader(raw)) + raw, err := wire.PeekBody(r, maxProbeBytes) if err != nil { return nil diff --git a/server/gcp/securesourcemanager/handler.go b/server/gcp/securesourcemanager/handler.go index 5449e5d66..2ba3f6fa0 100644 --- a/server/gcp/securesourcemanager/handler.go +++ b/server/gcp/securesourcemanager/handler.go @@ -59,14 +59,13 @@ package securesourcemanager import ( - "bytes" "context" "encoding/json" - "io" "net/http" "strings" "github.com/stackshy/cloudemu/v2/server/gcp/lro" + "github.com/stackshy/cloudemu/v2/server/wire" "github.com/stackshy/cloudemu/v2/server/wire/gcprest" ssmdriver "github.com/stackshy/cloudemu/v2/services/securesourcemanager/driver" ) @@ -263,9 +262,7 @@ func bodyHasInstanceRef(r *http.Request) bool { return false } - raw, err := io.ReadAll(io.LimitReader(r.Body, maxProbeBytes)) - _ = r.Body.Close() - r.Body = io.NopCloser(bytes.NewReader(raw)) + raw, err := wire.PeekBody(r, maxProbeBytes) if err != nil { return false @@ -327,9 +324,7 @@ func bodyLooksLikeSSMInstance(r *http.Request) bool { return true } - raw, err := io.ReadAll(io.LimitReader(r.Body, maxProbeBytes)) - _ = r.Body.Close() - r.Body = io.NopCloser(bytes.NewReader(raw)) + raw, err := wire.PeekBody(r, maxProbeBytes) if err != nil { return false diff --git a/server/gcp/spanner/handler.go b/server/gcp/spanner/handler.go index 110bdcd11..551c0460a 100644 --- a/server/gcp/spanner/handler.go +++ b/server/gcp/spanner/handler.go @@ -38,14 +38,13 @@ package spanner import ( - "bytes" "encoding/json" - "io" "net/http" "strings" sp "google.golang.org/api/spanner/v1" + "github.com/stackshy/cloudemu/v2/server/wire" "github.com/stackshy/cloudemu/v2/server/wire/gcprest" spdriver "github.com/stackshy/cloudemu/v2/services/spanner/driver" ) @@ -144,9 +143,7 @@ func bodyLooksLikeSpanner(r *http.Request) bool { return false } - raw, err := io.ReadAll(io.LimitReader(r.Body, maxBodyBytes)) - _ = r.Body.Close() - r.Body = io.NopCloser(bytes.NewReader(raw)) + raw, err := wire.PeekBody(r, maxBodyBytes) if err != nil { return false diff --git a/server/server.go b/server/server.go index b2da7bc41..611ac5af4 100644 --- a/server/server.go +++ b/server/server.go @@ -23,7 +23,9 @@ type Handler interface { // InitiateAuth, or an API Gateway invoke). PublicRequest reports whether the // handler serves r as one of those public operations. It must answer true only // for the exact public routes the handler itself serves, since an -// authentication hook lets such requests through unsigned. +// authentication hook lets such requests through unsigned. It must decide from +// the method, URL and headers alone and leave the body unread, because the +// same request is then authorized from its body. type PublicRequester interface { PublicRequest(r *http.Request) bool } diff --git a/server/serverkit/matches_body_test.go b/server/serverkit/matches_body_test.go new file mode 100644 index 000000000..8a56a89d4 --- /dev/null +++ b/server/serverkit/matches_body_test.go @@ -0,0 +1,71 @@ +package serverkit + +import ( + "bytes" + "fmt" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + cloudemu "github.com/stackshy/cloudemu/v2" + "github.com/stackshy/cloudemu/v2/server" + awsserver "github.com/stackshy/cloudemu/v2/server/aws" + gcpserver "github.com/stackshy/cloudemu/v2/server/gcp" + ociserver "github.com/stackshy/cloudemu/v2/server/oci" + "github.com/stackshy/cloudemu/v2/services/kubernetes" +) + +// TestMatchesLeaveBodyIntact sends a body larger than any Matches peek limit +// through every handler's Matches and checks the body still reads back whole. +// The AWS auth gate authorizes a request from the body as Matches left it, and +// the handler later serves the same request, so a Matches that truncates the +// body would let the gate and dispatch see different requests. +// +// Form-encoded bodies are left out on purpose: a query handler's Matches +// parses them into r.Form, which both the gate and dispatch read from. +func TestMatchesLeaveBodyIntact(t *testing.T) { + aws := awsserver.DriversFrom(cloudemu.NewAWS()) + aws.K8sAPI = kubernetes.NewAPIServer() + + servers := map[string]*server.Server{ + "aws": awsserver.New(aws), + "gcp": gcpserver.New(gcpserver.DriversFrom(cloudemu.NewGCP())), + "oci": ociserver.New(ociserver.DriversFrom(cloudemu.NewOCI())), + } + + // Bigger than every peek limit (the largest is 1 MiB), and valid JSON so a + // peek that parses it keeps going. + body := []byte(`{"pad":"` + strings.Repeat("a", 3<<20) + `"}`) + + // Request shapes that reach each body-peeking Matches. + shapes := []struct{ method, path, ctype string }{ + {http.MethodPost, "/TagResource", "application/json"}, + {http.MethodPost, "/UntagResource", "application/json"}, + {http.MethodPost, "/ListTagsForResource", "application/json"}, + {http.MethodPost, "/", "application/x-amz-json-1.1"}, + {http.MethodPost, "/v1/projects/p/locations/l/instances", "application/json"}, + {http.MethodPost, "/v1/projects/p/instances", "application/json"}, + {http.MethodPost, "/v1/projects/p/locations/l/clusters", "application/json"}, + {http.MethodPost, "/v1/projects/p/locations/l/repositories", "application/json"}, + {http.MethodPut, "/bucket/key", "application/octet-stream"}, + } + + for cloud, srv := range servers { + for _, h := range srv.Handlers() { + for _, sh := range shapes { + req := httptest.NewRequest(sh.method, sh.path, bytes.NewReader(body)) + req.Header.Set("Content-Type", sh.ctype) + + h.Matches(req) + + got, err := io.ReadAll(req.Body) + if err != nil || !bytes.Equal(got, body) { + t.Errorf("%s %s: %s %s left %d of %d body bytes (err %v)", + cloud, fmt.Sprintf("%T", h), sh.method, sh.path, len(got), len(body), err) + } + } + } + } +} diff --git a/server/wire/awsauthz/awsauthz.go b/server/wire/awsauthz/awsauthz.go index daae1ce39..91bc8aaaa 100644 --- a/server/wire/awsauthz/awsauthz.go +++ b/server/wire/awsauthz/awsauthz.go @@ -42,6 +42,11 @@ type Check struct { // conservatively: never more permissive than any concrete resource. Resource string Mode CheckMode + // MessageResource, when set, is the resource a deny message names instead + // of Resource. AWS names the resource the caller asked for, so a handler + // that evaluates a resolved ARN keeps the message free of anything the + // caller did not send (such as whether that resource exists). + MessageResource string } // Scope is the account, region and partition the server runs in. It is diff --git a/server/wire/peek.go b/server/wire/peek.go new file mode 100644 index 000000000..caaefa291 --- /dev/null +++ b/server/wire/peek.go @@ -0,0 +1,29 @@ +package wire + +import ( + "bytes" + "io" + "net/http" +) + +// PeekBody reads up to limit bytes of r's body for a Matches predicate to +// inspect, then puts them back in front of the unread rest. The handler that +// serves r, and the auth gate that authorizes it, therefore read the whole +// body byte for byte, however large it is. Closing the body closes the +// original. +func PeekBody(r *http.Request, limit int64) ([]byte, error) { + if r.Body == nil { + return nil, nil + } + + peeked, err := io.ReadAll(io.LimitReader(r.Body, limit)) + r.Body = peekedBody{Reader: io.MultiReader(bytes.NewReader(peeked), r.Body), Closer: r.Body} + + return peeked, err +} + +// peekedBody is a request body whose first bytes were already read. +type peekedBody struct { + io.Reader + io.Closer +} From e75411d07102379164bd9a019b8628777eb34563 Mon Sep 17 00:00:00 2001 From: Nitin Kumar Date: Sun, 4 Oct 2026 14:11:27 +0530 Subject: [PATCH 3/3] test(compat): check query calls are authorized under --enforce-auth --- compat/aws/auth_authz_compat_test.go | 62 +++++++++++++++++++++------- 1 file changed, 47 insertions(+), 15 deletions(-) diff --git a/compat/aws/auth_authz_compat_test.go b/compat/aws/auth_authz_compat_test.go index ed631ebc1..c31e7c235 100644 --- a/compat/aws/auth_authz_compat_test.go +++ b/compat/aws/auth_authz_compat_test.go @@ -6,18 +6,21 @@ import ( "crypto/sha256" "encoding/hex" "encoding/json" + "errors" "io" "net/http" "testing" "time" "github.com/aws/aws-sdk-go-v2/aws" + v4 "github.com/aws/aws-sdk-go-v2/aws/signer/v4" + awshttp "github.com/aws/aws-sdk-go-v2/aws/transport/http" awsconfig "github.com/aws/aws-sdk-go-v2/config" "github.com/aws/aws-sdk-go-v2/credentials" - v4 "github.com/aws/aws-sdk-go-v2/aws/signer/v4" "github.com/aws/aws-sdk-go-v2/service/dynamodb" ddbtypes "github.com/aws/aws-sdk-go-v2/service/dynamodb/types" "github.com/aws/aws-sdk-go-v2/service/ec2" + "github.com/aws/smithy-go" cloudemu "github.com/stackshy/cloudemu/v2" "github.com/stackshy/cloudemu/v2/internal/compat" @@ -266,25 +269,54 @@ func TestCompatAWSAuthorizationCrossServiceBypassClosed(t *testing.T) { } } -// TestCompatAWSAuthorizationQueryAuthenticatedOnly pins the documented limitation -// that the query protocol is authenticated but NOT authorization-enforced in this -// revision: a user whose policy grants only dynamodb:GetItem (no EC2 permission) -// can still make an authenticated EC2 query call, because query authorization -// cannot be soundly bound to the executed operation before dispatch and is a -// follow-up. -func TestCompatAWSAuthorizationQueryAuthenticatedOnly(t *testing.T) { +// TestCompatAWSAuthorizationQueryEnforced checks that query-protocol calls are +// authorized against the caller's policies under EnforceAuth, bound to the +// operation dispatch runs. A user whose policy does not cover the call gets EC2's +// 403 UnauthorizedOperation, and a user granted ec2:DescribeInstances succeeds. A +// user with no policies at all stays unrestricted: that is the bootstrap rule +// that lets a freshly created key-only user set up the others. +func TestCompatAWSAuthorizationQueryEnforced(t *testing.T) { cloud := cloudemu.NewAWS() - akid, secret := registerUserWithKey(t, cloud.IAM, "queryuser") - attachInlinePolicy(t, cloud.IAM, "queryuser", "ddb-get-only", + + nonMatching, nonMatchingSecret := registerUserWithKey(t, cloud.IAM, "ddbuser") + attachInlinePolicy(t, cloud.IAM, "ddbuser", "ddb-get-only", `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"dynamodb:GetItem","Resource":"*"}]}`) + describer, describerSecret := registerUserWithKey(t, cloud.IAM, "ec2describer") + attachInlinePolicy(t, cloud.IAM, "ec2describer", "ec2-describe", + `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"ec2:DescribeInstances","Resource":"*"}]}`) + + bootstrap, bootstrapSecret := registerUserWithKey(t, cloud.IAM, "bootstrap") + sess := compat.BootAWS(t, awsserver.Drivers{IAM: cloud.IAM, EC2: cloud.EC2, EnforceAuth: true}) - client := ec2.NewFromConfig(staticConfig(t, akid, secret), func(o *ec2.Options) { - o.BaseEndpoint = aws.String(sess.Endpoint()) - }) + describe := func(akid, secret string) error { + client := ec2.NewFromConfig(staticConfig(t, akid, secret), func(o *ec2.Options) { + o.BaseEndpoint = aws.String(sess.Endpoint()) + }) + + _, err := client.DescribeInstances(context.Background(), &ec2.DescribeInstancesInput{}) + + return err + } + + err := describe(nonMatching, nonMatchingSecret) + + var apiErr smithy.APIError + if !errors.As(err, &apiErr) || apiErr.ErrorCode() != "UnauthorizedOperation" { + t.Fatalf("user without ec2:DescribeInstances: want UnauthorizedOperation, got %v", err) + } + + var respErr *awshttp.ResponseError + if !errors.As(err, &respErr) || respErr.HTTPStatusCode() != http.StatusForbidden { + t.Fatalf("user without ec2:DescribeInstances: want HTTP 403, got %v", err) + } + + if err := describe(describer, describerSecret); err != nil { + t.Fatalf("user granted ec2:DescribeInstances: %v", err) + } - if _, err := client.DescribeInstances(context.Background(), &ec2.DescribeInstancesInput{}); err != nil { - t.Fatalf("query call is authenticated-only and should succeed, got: %v", err) + if err := describe(bootstrap, bootstrapSecret); err != nil { + t.Fatalf("user with no policies (bootstrap): %v", err) } }