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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 47 additions & 15 deletions compat/aws/auth_authz_compat_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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)
}
}

Expand Down
30 changes: 29 additions & 1 deletion contrib/server/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |

Expand All @@ -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
Expand Down
Loading
Loading