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) } } 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..8ca5770e0 --- /dev/null +++ b/server/aws/authz_matrix_test.go @@ -0,0 +1,573 @@ +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("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 { + 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) + } + }) + } +} + +// 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/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..d09ed6dbb 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,292 @@ 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, messageResource(c), true) + case d == awsauthz.ImplicitDeny && c.Mode == awsauthz.Required: + return nil, denyMessage(p, c.Action, messageResource(c), 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 { +// 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( + 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 +361,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 + return "", "", false } - prefix := target[:strings.LastIndexByte(target, '.')+1] - op := jsonRPCTargetOperation(r) + dot := strings.LastIndexByte(target, '.') + op = target[dot+1:] + service, ok = jsonRPCServiceByTarget[target[:dot+1]] - svc, ok := jsonRPCServiceByTarget[prefix] - if !ok || op == "" { - return svc, "", authzDeny - } - - return svc, svc + ":" + op, authzEnforce -} - -// 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 +399,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 4c6e4ed29..065065372 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..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 "" @@ -168,3 +164,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..15837eb37 100644 --- a/server/aws/publicauth.go +++ b/server/aws/publicauth.go @@ -10,44 +10,54 @@ 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 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) { 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 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 pub.PublicRequest(probe) + 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..7abf06e9f 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 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 +// 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..aa3371289 --- /dev/null +++ b/server/aws/sts/authz.go @@ -0,0 +1,79 @@ +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. 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) { + 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.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: + 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..6a8cedb70 --- /dev/null +++ b/server/aws/sts/authz_test.go @@ -0,0 +1,79 @@ +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" + 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 + 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, 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. + // 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}, + {"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/gcp/cloudids/shared.go b/server/gcp/cloudids/shared.go index 6ddd43a55..dea014947 100644 --- a/server/gcp/cloudids/shared.go +++ b/server/gcp/cloudids/shared.go @@ -1,12 +1,11 @@ package cloudids import ( - "bytes" "encoding/json" - "io" "net/http" "github.com/stackshy/cloudemu/v2/server/gcp/sharedpath" + "github.com/stackshy/cloudemu/v2/server/wire" ) // Vertex AI serves the same /v1/projects/{p}/locations/{l}/endpoints paths. @@ -61,9 +60,7 @@ func bodyLooksLikeEndpoint(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/gcp/dataform/shared.go b/server/gcp/dataform/shared.go index 517510777..8b7f650d7 100644 --- a/server/gcp/dataform/shared.go +++ b/server/gcp/dataform/shared.go @@ -1,12 +1,11 @@ package dataform import ( - "bytes" "encoding/json" - "io" "net/http" "github.com/stackshy/cloudemu/v2/server/gcp/sharedpath" + "github.com/stackshy/cloudemu/v2/server/wire" ) // Artifact Registry (and Secure Source Manager) serve the same @@ -57,9 +56,7 @@ func bodyLooksLikeDataform(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/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 5c5f5eaf1..8f6f118c3 100644 --- a/server/gcp/filestore/handler.go +++ b/server/gcp/filestore/handler.go @@ -53,15 +53,14 @@ 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/gcp/sharedpath" + "github.com/stackshy/cloudemu/v2/server/wire" "github.com/stackshy/cloudemu/v2/server/wire/gcprest" ) @@ -193,9 +192,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/filestore/shared.go b/server/gcp/filestore/shared.go index 5fa46ebc7..d3c332ab0 100644 --- a/server/gcp/filestore/shared.go +++ b/server/gcp/filestore/shared.go @@ -1,12 +1,11 @@ package filestore import ( - "bytes" "encoding/json" - "io" "net/http" "github.com/stackshy/cloudemu/v2/server/gcp/sharedpath" + "github.com/stackshy/cloudemu/v2/server/wire" ) // Memorystore for Redis serves the same .../locations/{l}/instances paths. @@ -37,9 +36,7 @@ func bodyHasFilestoreTier(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/gkebackup/shared.go b/server/gcp/gkebackup/shared.go index a7a26344e..ff6ac41ae 100644 --- a/server/gcp/gkebackup/shared.go +++ b/server/gcp/gkebackup/shared.go @@ -1,12 +1,11 @@ package gkebackup import ( - "bytes" "encoding/json" - "io" "net/http" "github.com/stackshy/cloudemu/v2/server/gcp/sharedpath" + "github.com/stackshy/cloudemu/v2/server/wire" ) // Backup and DR serves the same .../locations/{l}/backupPlans paths. When both @@ -37,9 +36,7 @@ func bodyLooksLikeBackupDR(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/gcp/managedkafka/handler.go b/server/gcp/managedkafka/handler.go index 3313a575b..9bb48eedb 100644 --- a/server/gcp/managedkafka/handler.go +++ b/server/gcp/managedkafka/handler.go @@ -50,16 +50,15 @@ 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/gcp/sharedpath" + "github.com/stackshy/cloudemu/v2/server/wire" "github.com/stackshy/cloudemu/v2/server/wire/gcprest" mkdriver "github.com/stackshy/cloudemu/v2/services/managedkafka/driver" ) @@ -284,9 +283,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 bc256caf4..69eaa521c 100644 --- a/server/gcp/securesourcemanager/handler.go +++ b/server/gcp/securesourcemanager/handler.go @@ -59,15 +59,14 @@ 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/gcp/sharedpath" + "github.com/stackshy/cloudemu/v2/server/wire" "github.com/stackshy/cloudemu/v2/server/wire/gcprest" ssmdriver "github.com/stackshy/cloudemu/v2/services/securesourcemanager/driver" ) @@ -272,9 +271,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 @@ -336,9 +333,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 9eaa7500e..fe1fc7ace 100644 --- a/server/gcp/spanner/handler.go +++ b/server/gcp/spanner/handler.go @@ -38,9 +38,7 @@ package spanner import ( - "bytes" "encoding/json" - "io" "net/http" "strings" @@ -49,6 +47,7 @@ import ( "github.com/stackshy/cloudemu/v2/providers/gcp/resourceiam" "github.com/stackshy/cloudemu/v2/server/gcp/sharedpath" + "github.com/stackshy/cloudemu/v2/server/wire" "github.com/stackshy/cloudemu/v2/server/wire/gcpiam" "github.com/stackshy/cloudemu/v2/server/wire/gcprest" spdriver "github.com/stackshy/cloudemu/v2/services/spanner/driver" @@ -162,9 +161,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/gcp/spanner/shared.go b/server/gcp/spanner/shared.go index 7c63de304..a0626e5f0 100644 --- a/server/gcp/spanner/shared.go +++ b/server/gcp/spanner/shared.go @@ -1,13 +1,12 @@ package spanner import ( - "bytes" "encoding/json" - "io" "net/http" "strings" "github.com/stackshy/cloudemu/v2/server/gcp/sharedpath" + "github.com/stackshy/cloudemu/v2/server/wire" ) // Cloud SQL Admin v1 serves the same /v1/projects/{p}/instances[/{i}[/databases]] @@ -79,9 +78,7 @@ func bodyHasCreateStatement(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/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 6395d3ddf..d4e54cccf 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..d5d880391 --- /dev/null +++ b/server/serverkit/matches_body_test.go @@ -0,0 +1,75 @@ +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 8 MiB), and valid JSON so a + // peek that parses it keeps going. + body := []byte(`{"pad":"` + strings.Repeat("a", 9<<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.MethodPost, "/v1/projects/p/locations/us-central1-a/instances", "application/json"}, + {http.MethodPost, "/v1/projects/p/locations/l/backupPlans", "application/json"}, + {http.MethodPost, "/v1/projects/p/instances/i/databases", "application/json"}, + {http.MethodPost, "/v1/projects/p/locations/l/endpoints", "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 new file mode 100644 index 000000000..91bc8aaaa --- /dev/null +++ b/server/wire/awsauthz/awsauthz.go @@ -0,0 +1,189 @@ +// 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 + // 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 +// 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) + } +} 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 +}