Skip to content

fix(auth): authorize query and REST requests under --enforce-auth - #1383

Merged
NitinKumar004 merged 8 commits into
developmentfrom
fix/auth-query-rest-authz
Oct 4, 2026
Merged

NitinKumar004 merged 8 commits into
developmentfrom
fix/auth-query-rest-authz

Conversation

@NitinKumar004

Copy link
Copy Markdown
Collaborator

Closes tracker row AUTHZ-X1 (critical). This is AUTHZ-X1b from the approved plan.

Under --enforce-auth, only requests with an X-Amz-Target header were authorized. Query and REST requests were authenticated and then let through. So a user whose only policy was dynamodb:* could run iam create-user, s3 mb, ec2 run-instances and create Lambda functions, API Gateway APIs and EKS clusters. AssumeRole was never authorized at all.

What changed

The gate now finds the handler that dispatch will run (one shared probeRoute, also used by the #1375 public exemption) and builds its plan from that handler:

plan when restricted caller
checks the handler is a Resolver and names its IAM checks each check is evaluated (Required / DenyOnly / ResourcePolicy)
unknown op a Resolver cannot name the operation 403
JSON-RPC the handler is registered as a JSON-RPC handler and the target's table service equals its own IAMService() per-operation check
JSON deny a JSON-RPC handler with an unmapped target, or a target naming another service 403 (root too, unchanged)
service-wide any other handler; it names its IAM service allowed only with a grant covering every action of that service
authn only the Kubernetes data plane not IAM-authorized
unmapped no service, or the query string or form body does not parse 403
no handler nothing serves it dispatch answers 501

Root and users with no policies keep the bootstrap shortcut on every plan except JSON deny. Role sessions stay strict.

  • New leaf package server/wire/awsauthz: Check, CheckMode, Scope, Resolver, ServiceNamer, DenyWriter, Evaluation, QueryChecks, ConditionContext. server.Server.Handlers() returns a copy, for the completeness test.
  • X-Amz-Target is read only for the handlers wrapped with rpc(...) at registration in aws.New. A forged header on any other handler cannot steer the authorized action.
  • Tier 1 (per operation):
    • The 7 single-protocol query handlers (iam, rds, redshift, elasticache, elbv2, sns, cloudformation) use QueryChecks, which reads the same r.Form.Get("Action") their switch reads.
    • EC2's autoscaling switch is now the autoScalingRoutes table. Dispatch and IAMChecks both read it, so those actions are autoscaling: and everything else is ec2:.
    • STS follows the plan's §3.1 table. The AssumeRole family is checked on the stored ARN of the role that will actually be assumed. STS resolves the role from the last segment of RoleArn, so checking the raw RoleArn would let a different path or account borrow another role's grant.
  • Multi-protocol handlers, found by grepping every Matches for more than one of X-Amz-Target, the smithy-protocol header and a form Action:
    • CloudWatch (rpc-v2-cbor, awsJson1_0 and query): one cloudwatchOp(r) used by both ServeHTTP and IAMChecks, after the same gzip decode.
    • SageMaker (JSON-RPC control plane, plus the REST runtime and feature-store paths, which win over the target in dispatch): one classify(r) used by both.
    • No other handler matches on more than one of these signals.
  • Tier 0: IAMService() on every other handler, using the plan's prefix table. The JSON-RPC handlers use their table prefix.
  • The three missing table prefixes: AWSInsightsIndexService. → ce, ServiceQuotasV20190624. → servicequotas, ElasticMapReduce. → elasticmapreduce. Before this, all three were unusable under --enforce-auth, even for root.
  • DenyWriters:
    • EC2 returns UnauthorizedOperation, and AccessDenied for autoscaling actions.
    • S3 uses <Error><Code>AccessDenied; Route 53 and CloudFront use <ErrorResponse><Error><Code>AccessDenied.
    • CloudWatch answers in the request's own protocol.
    • Everything else follows the request shape: XML AccessDenied for query, JSON AccessDeniedException otherwise.
  • EvaluateServiceWide now rejects anything that is not an IAM service prefix ("", a:b, s3:*, *, …) as an implicit deny (the X1a review note). The gate builds a service-wide plan only for a name matching ^[a-z0-9][a-z0-9-]*$.
  • On the legacy JSON-RPC path, an underivable resource is now evaluated as unknown rather than "*". A resource-scoped Deny (for example on one queue) is no longer silently skipped (plan §2).
  • coveragegen follows New into the same-file function it hands its Drivers to. New now delegates to newServer, which also returns the authz sets for the tests. docs/coverage is unchanged.
  • Docs: the aws.go EnforceAuth comment, the --enforce-auth flag help, and a new section in contrib/server/README.md. Until a REST service reaches tier 1, a fine-grained or resource-scoped policy on it (for example s3:GetObject on one bucket) is denied, fail closed. AdministratorAccess and <svc>:* users keep working.

Unchanged, each with a regression test: the /_cloudemu/* admin endpoints (unsigned health, snapshot and reset under --enforce-auth, in contrib), bootstrap users, and the #1375 public operations. TestEnforcedGateBypassAttempts and TestEnforcedGateAdmitsUnsignedPublicOps pass unchanged. TestAuthzSkipsPublicOps now also covers a signed execute-api invoke from a caller with a policy.

Unknown-op safety, per handler

authz_unknown_op_test.go calls each query handler's ServeHTTP with an unknown Action, then sends the same request through the gate as a bootstrap user. It asserts a 4xx and an unchanged provider snapshot. The snapshot is taken with persist.Export and leaves out only CloudTrail's management-event log, which records every call. The default branches it exercises:

  • server/aws/iam/handler.go:382 (InvalidAction)
  • server/aws/sts/handler.go:172
  • server/aws/rds/handler.go:380
  • server/aws/redshift/handler.go:257
  • server/aws/elasticache/handler.go:237
  • server/aws/elbv2/handler.go:186
  • server/aws/sns/handler.go:205
  • server/aws/cloudformation/handler.go:157
  • server/aws/cloudwatch/query.go:114 (query) and server/aws/cloudwatch/handler.go:236 (cbor/json)
  • server/aws/ec2/handler.go:200

Each one writes the error and returns, and none of them touches a driver.

Where this differs from the plan text

  • Forged X-Amz-Target on S3, IAM and RDS. With DynamoDB registered, that request is served by DynamoDB itself, because it registers first and matches on the target. It is authorized as dynamodb:PutItem, which is what runs, and fails there with a 400. The S3, IAM and RDS handlers refuse any request that carries a target. So the test asserts a non-2xx response and unchanged S3, IAM and RDS state, not a 403. The real bypass is a REST handler that ignores the header. That case is tested with Lambda on a server without DynamoDB: it is a 403 now and created the function on the old code.
  • Unknown target. A target no handler serves now reaches dispatch and gets a 501. The gate used to answer 403 before matching.

Verification

  • Tests written first, run on the old code. 19 subtests in 6 tests of authz_matrix_test.go failed there, plus the two state checks: dynOnly created IAM users, buckets, Lambda functions, REST APIs and EKS clusters; AssumeRole passed without permission; CBOR PutMetricData with ?Action=DescribeAlarms stored a metric for a DescribeAlarms-only caller; ce, servicequotas and EMR returned 403 for an unrestricted caller. All pass now.
  • Auth-off matrix. TestAuthOffResponsesUnchanged replays 35 requests over the edited handlers (CloudWatch cbor/json/query/gzip, every autoscaling route, SageMaker runtime and feature store, EC2 edge cases) with EnforceAuth off. Each response is compared with a golden file recorded on the old code, masking only generated ids and CBOR key order.
  • Completeness and drift. TestEveryHandlerDeclaresAuthorization and TestHandlerIAMServicesMatchTable walk every handler of a full server: each must declare a valid IAM service, each table prefix must route to a handler with the same service, and only Kubernetes may be authn-only. A mutation check (removing one IAMService) fails both.
  • Scoped gates (GOMAXPROCS=4, -p 4):
    • go build ./..., and go vet on server/..., providers/aws/iam/... and services/iam/....
    • go test -race -timeout 600s on server/... (including server/wire/...), providers/aws/iam/... and services/iam/..., plus the contrib/server enforce-auth tests.
    • golangci-lint --new-from-rev=origin/development: 0 issues in the root module and in contrib/server.
    • go run ./internal/coveragegen: no diff.
  • Contrib e2e (contrib/server/enforce_authz_test.go, real SDKs against serve with --enforce-auth, bootstrapped through the unsigned admin snapshot API):
    • IAM: limited (dynamodb:*) gets AccessDenied on CreateUser, ListUsers and CreateAccessKey, while ListTables passes. After iam:ListUsers is added, ListUsers passes and CreateUser is still denied.
    • EC2: a ec2:Describe* user can DescribeInstances, gets UnauthorizedOperation on RunInstances, and AccessDenied on CreateAutoScalingGroup.
    • SQS: a sqs:SendMessage user can send, gets AccessDeniedException on CreateQueue, and a hand-signed query-form SendMessage is a 403 ec2:SendMessage that never reaches SQS (the queue still holds one message).
  • CLI against cloudemu serve --enforce-auth on :65166:
    • As limited, these are denied: iam create-user (AccessDenied), s3 mb (AccessDenied, s3:*), sts assume-role (AccessDenied on the role ARN), ec2 run-instances (UnauthorizedOperation) and autoscaling create-auto-scaling-group (AccessDenied). No user or bucket was created.
    • As limited, dynamodb list-tables, sts get-caller-identity and sts get-session-token work.
    • An AdministratorAccess user passes on iam, s3, sts assume-role, ec2, dynamodb, sqs, lambda, cloudwatch, autoscaling and route53.
  • Terraform as the AdministratorAccess user: an S3 bucket, an IAM role and an SQS queue apply, the re-plan is empty, and destroy succeeds (AWS provider 5.100, the ~> 5.0 the repo fixtures pin).

Size

Production code is +1459/−322, about the plan's estimate. 300 of those lines are the one-line IAMService() methods, at 4 lines each with their comment. Tests are +1918, including a 177-line golden file. I did not split the PR. The plan has no seam inside X1b that is both safe and compatible: shipping the gate without tier 0 would lock out every REST user, and shipping without the gate leaves the bypass open. The only standalone pieces (awsauthz, Handlers(), the coveragegen change) come to about 300 lines and change no behaviour on their own.

Found, not fixed here

  • S3 Control endpoint. contrib/terraform/cloudemu-tf has no s3control endpoint. AWS provider 6.67 reads bucket tags through S3 Control ListTagsForResource, so that call goes to real AWS and fails. This is unrelated to auth and is why the TF run used the pinned 5.x provider.
  • Partial-form quirk (auth off). A form body that fails to parse is still served by EC2 with the values that did parse: a query handler's Matches makes the first ParseForm call, and Go reports the error only on that call. Under --enforce-auth this is now unmapped, which fails closed.
  • Qualified CloudTrail targets. com.amazonaws.cloudtrail.v20131101.CloudTrail_20131101.* is not in the target table, so it stays a JSON deny under --enforce-auth, as it was before this change.

Not in this PR: S3 op-level checks (X1c), trust evaluation with the real caller and the session-kind restrictions (X1d), and resource ARNs for IAM, SQS, SNS and DynamoDB (X1e).

Every signed AWS request is now authorized against the caller's IAM policies, bound to the handler dispatch will run. Query services, CloudWatch and SageMaker are checked per operation, JSON-RPC through X-Amz-Target only for handlers that route on it, and REST services at service level until each gets per-operation checks.
…als neutral

Matches peeks now hand the whole body back (wire.PeekBody), and the probe body is no longer reset after Match, so IAMChecks and dispatch read the same bytes. An AssumeRole deny names the RoleArn as sent.
…-rest-authz

# Conflicts:
#	server/gcp/filestore/handler.go
#	server/gcp/managedkafka/handler.go
#	server/gcp/securesourcemanager/handler.go
#	server/gcp/spanner/handler.go
@NitinKumar004
NitinKumar004 marked this pull request as ready for review October 4, 2026 09:33
@NitinKumar004
NitinKumar004 merged commit 0c1fcef into development Oct 4, 2026
22 of 23 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant