From 0d0be22f9ae924a9e3d359610c2771f6b9ba047e Mon Sep 17 00:00:00 2001 From: Nitin Kumar Date: Sun, 4 Oct 2026 19:39:33 +0530 Subject: [PATCH] feat(auth): resource ARNs for IAM, SQS, SNS and DynamoDB (AUTHZ-X1e) IAM, SQS, SNS and DynamoDB now name the resource each request acts on, so resource-scoped Allow and Deny statements apply under --enforce-auth. SQS and DynamoDB move from the gate's target table to their own IAMChecks; the gate's DynamoDB-only deriveResource is gone. --- contrib/server/enforce_authz_test.go | 27 ++ server/aws/authgate.go | 2 +- server/aws/authz_resource_arns_test.go | 309 +++++++++++++++++++ server/aws/authzgate.go | 42 +-- server/aws/aws.go | 8 +- server/aws/dynamodb/authz.go | 339 +++++++++++++++++++++ server/aws/dynamodb/authz_dispatch_test.go | 28 ++ server/aws/dynamodb/authz_test.go | 192 ++++++++++++ server/aws/iam/authz.go | 171 +++++++++++ server/aws/iam/authz_test.go | 159 ++++++++++ server/aws/iam/handler.go | 186 ++++++----- server/aws/sns/authz.go | 105 +++++++ server/aws/sns/authz_test.go | 82 +++++ server/aws/sns/handler.go | 57 ++-- server/aws/sns/operations.go | 5 +- server/aws/sns/subscription_attributes.go | 5 +- server/aws/sqs/authz.go | 146 +++++++++ server/aws/sqs/authz_test.go | 98 ++++++ server/aws/sqs/handler.go | 5 +- server/wire/awsauthz/resource.go | 40 +++ server/wire/awsauthz/resource_test.go | 39 +++ 21 files changed, 1870 insertions(+), 175 deletions(-) create mode 100644 server/aws/authz_resource_arns_test.go create mode 100644 server/aws/dynamodb/authz.go create mode 100644 server/aws/dynamodb/authz_dispatch_test.go create mode 100644 server/aws/dynamodb/authz_test.go create mode 100644 server/aws/iam/authz.go create mode 100644 server/aws/iam/authz_test.go create mode 100644 server/aws/sns/authz.go create mode 100644 server/aws/sns/authz_test.go create mode 100644 server/aws/sqs/authz.go create mode 100644 server/aws/sqs/authz_test.go create mode 100644 server/wire/awsauthz/resource.go create mode 100644 server/wire/awsauthz/resource_test.go diff --git a/contrib/server/enforce_authz_test.go b/contrib/server/enforce_authz_test.go index 93717ca8f..b395fcc63 100644 --- a/contrib/server/enforce_authz_test.go +++ b/contrib/server/enforce_authz_test.go @@ -318,6 +318,33 @@ func TestEnforceAuthAuthorizesQueryAndREST(t *testing.T) { } }) + t.Run("sqs queue scope", func(t *testing.T) { + q1, err := boot.sqs.CreateQueue(ctx, &sqs.CreateQueueInput{QueueName: aws.String("scoped-q1")}) + wantOK(t, "CreateQueue scoped-q1", err) + q2, err := boot.sqs.CreateQueue(ctx, &sqs.CreateQueueInput{QueueName: aws.String("scoped-q2")}) + wantOK(t, "CreateQueue scoped-q2", err) + + attrs, err := boot.sqs.GetQueueAttributes(ctx, &sqs.GetQueueAttributesInput{ + QueueUrl: q1.QueueUrl, AttributeNames: []sqstypes.QueueAttributeName{sqstypes.QueueAttributeNameQueueArn}, + }) + wantOK(t, "GetQueueAttributes QueueArn", err) + + q1ARN := attrs.Attributes[string(sqstypes.QueueAttributeNameQueueArn)] + doc := `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"sqs:SendMessage","Resource":"` + q1ARN + `"}]}` + sender := clientsFor(t, endpoint, boot.newUser(t, "q1sender", doc)) + + _, err = sender.sqs.SendMessage(ctx, &sqs.SendMessageInput{QueueUrl: q1.QueueUrl, MessageBody: aws.String("hi")}) + wantOK(t, "SendMessage q1", err) + _, err = sender.sqs.SendMessageBatch(ctx, &sqs.SendMessageBatchInput{ + QueueUrl: q1.QueueUrl, Entries: []sqstypes.SendMessageBatchRequestEntry{{Id: aws.String("a"), MessageBody: aws.String("b")}}, + }) + wantOK(t, "SendMessageBatch q1", err) + _, err = sender.sqs.SendMessage(ctx, &sqs.SendMessageInput{QueueUrl: q2.QueueUrl, MessageBody: aws.String("hi")}) + wantCode(t, "SendMessage q2", err, "AccessDeniedException") + _, err = sender.sqs.ReceiveMessage(ctx, &sqs.ReceiveMessageInput{QueueUrl: q1.QueueUrl}) + wantCode(t, "ReceiveMessage q1", err, "AccessDeniedException") + }) + t.Run("admin reset with the token", func(t *testing.T) { adminCall(t, http.MethodPost, endpoint+"/_cloudemu/reset", nil) }) diff --git a/server/aws/authgate.go b/server/aws/authgate.go index 1c8929490..6f2dbd653 100644 --- a/server/aws/authgate.go +++ b/server/aws/authgate.go @@ -110,7 +110,7 @@ func newAuthGate(g *gateConfig) func(http.ResponseWriter, *http.Request) (*http. return r, false } - plan := g.resolvePlan(probe, h, probed, body) + plan := g.resolvePlan(probe, h, probed) return g.authorize(w, r, h, plan, &principal, roleSession) } diff --git a/server/aws/authz_resource_arns_test.go b/server/aws/authz_resource_arns_test.go new file mode 100644 index 000000000..65e357bdf --- /dev/null +++ b/server/aws/authz_resource_arns_test.go @@ -0,0 +1,309 @@ +package aws + +import ( + "context" + "net/http" + "net/url" + "testing" + + awsprovider "github.com/stackshy/cloudemu/v2/providers/aws" + dbdriver "github.com/stackshy/cloudemu/v2/services/database/driver" + iamdriver "github.com/stackshy/cloudemu/v2/services/iam/driver" + mqdriver "github.com/stackshy/cloudemu/v2/services/messagequeue/driver" + notifdriver "github.com/stackshy/cloudemu/v2/services/notification/driver" +) + +const ( + arnNS = "arn:aws:iam::123456789012:" + ddbNS = "arn:aws:dynamodb:us-east-1:123456789012:table/" + amzJSON10 = "application/x-amz-json-1.0" + t1ARN = "arn:aws:sns:us-east-1:123456789012:t1" + t2ARN = "arn:aws:sns:us-east-1:123456789012:t2" +) + +func scoped(effect, action, resource string) string { + return `{"Effect":"` + effect + `","Action":"` + action + `","Resource":"` + resource + `"}` +} + +func policyDoc(statements ...string) string { + doc := `{"Version":"2012-10-17","Statement":[` + + for i, s := range statements { + if i > 0 { + doc += "," + } + + doc += s + } + + return doc + `]}` +} + +func rpcReq(service, target, body string) sreq { + return sreq{path: "/", ctype: amzJSON10, body: body, service: service, header: map[string]string{"X-Amz-Target": target}} +} + +func queueMessages(t *testing.T, cloud *awsprovider.Provider, url string) int { + t.Helper() + + info, err := cloud.SQS.GetQueueInfo(context.Background(), url) + if err != nil { + t.Fatalf("GetQueueInfo: %v", err) + } + + return info.ApproxMessageCount +} + +// TestAuthzResourceScopedSQS: an Allow scoped to one queue covers that queue +// only, for single and batch operations alike. +func TestAuthzResourceScopedSQS(t *testing.T) { + ts, cloud := matrixServer(t, nil) + ctx := context.Background() + + q1, err := cloud.SQS.CreateQueue(ctx, mqdriver.QueueConfig{Name: "q1"}) + if err != nil { + t.Fatalf("CreateQueue q1: %v", err) + } + + q2, err := cloud.SQS.CreateQueue(ctx, mqdriver.QueueConfig{Name: "q2"}) + if err != nil { + t.Fatalf("CreateQueue q2: %v", err) + } + + sender := userWithPolicy(t, cloud, "q1sender", policyDoc(scoped("Allow", "sqs:SendMessage", q1.ARN))) + + send := func(url string) sreq { + return rpcReq("sqs", "AmazonSQS.SendMessage", `{"QueueUrl":"`+url+`","MessageBody":"hi"}`) + } + batch := func(url string) sreq { + return rpcReq("sqs", "AmazonSQS.SendMessageBatch", + `{"QueueUrl":"`+url+`","Entries":[{"Id":"a","MessageBody":"x"}]}`) + } + + status, body := doSigned(t, ts, sender, send(q1.URL)) + if status != http.StatusOK { + t.Fatalf("SendMessage q1: %d %s", status, body) + } + + status, body = doSigned(t, ts, sender, batch(q1.URL)) + if status != http.StatusOK { + t.Fatalf("SendMessageBatch q1 (authorized as sqs:SendMessage): %d %s", status, body) + } + + status, body = doSigned(t, ts, sender, send(q2.URL)) + wantDenied(t, status, body, "sqs:SendMessage on resource: "+q2.ARN) + + status, body = doSigned(t, ts, sender, batch(q2.URL)) + wantDenied(t, status, body, "sqs:SendMessage on resource: "+q2.ARN) + + status, body = doSigned(t, ts, sender, rpcReq("sqs", "AmazonSQS.PurgeQueue", `{"QueueUrl":"`+q1.URL+`"}`)) + wantDenied(t, status, body, "sqs:PurgeQueue") + + if n := queueMessages(t, cloud, q1.URL); n != 2 { + t.Errorf("q1 holds %d messages, want 2", n) + } + + if n := queueMessages(t, cloud, q2.URL); n != 0 { + t.Errorf("q2 holds %d messages, want 0", n) + } +} + +// TestAuthzResourceScopedSNS: Publish and Subscribe are authorized on the +// topic, and subscription operations on the subscription's topic. +func TestAuthzResourceScopedSNS(t *testing.T) { + ts, cloud := matrixServer(t, nil) + ctx := context.Background() + + _, err := cloud.SNS.CreateTopic(ctx, notifdriver.TopicConfig{Name: "t1"}) + if err != nil { + t.Fatalf("CreateTopic: %v", err) + } + + _, err = cloud.SNS.CreateTopic(ctx, notifdriver.TopicConfig{Name: "t2"}) + if err != nil { + t.Fatalf("CreateTopic: %v", err) + } + + pub := userWithPolicy(t, cloud, "t1pub", policyDoc(scoped("Allow", "sns:*", t1ARN))) + + publish := func(param, arn string) sreq { + return form("sns", url.Values{"Action": {"Publish"}, param: {arn}, "Message": {"m"}}.Encode()) + } + + for _, param := range []string{"TopicArn", "TargetArn"} { + status, body := doSigned(t, ts, pub, publish(param, t1ARN)) + if status != http.StatusOK { + t.Fatalf("Publish %s t1: %d %s", param, status, body) + } + + status, body = doSigned(t, ts, pub, publish(param, t2ARN)) + wantDenied(t, status, body, "sns:Publish on resource: "+t2ARN) + } + + status, body := doSigned(t, ts, pub, form("sns", url.Values{ + "Action": {"Subscribe"}, "TopicArn": {t2ARN}, "Protocol": {"sqs"}, "Endpoint": {"arn:aws:sqs:us-east-1:123456789012:q"}, + }.Encode())) + wantDenied(t, status, body, "sns:Subscribe") + + sub, err := cloud.SNS.Subscribe(ctx, notifdriver.SubscriptionConfig{TopicID: "t2", Protocol: "sqs", Endpoint: "arn:aws:sqs:us-east-1:123456789012:q"}) + if err != nil { + t.Fatalf("Subscribe: %v", err) + } + + status, body = doSigned(t, ts, pub, form("sns", url.Values{"Action": {"Unsubscribe"}, "SubscriptionArn": {sub.ID}}.Encode())) + wantDenied(t, status, body, "sns:Unsubscribe on resource: "+t2ARN) + + status, body = doSigned(t, ts, pub, form("sns", url.Values{"Action": {"ListTopics"}}.Encode())) + if status != http.StatusForbidden { + t.Fatalf("ListTopics on * with a topic-scoped Allow: %d %s", status, body) + } +} + +// TestAuthzResourceScopedDynamoDB: a Deny on one table blocks that table +// only, including through the batch and transaction operations, and an index +// is its own resource. +func TestAuthzResourceScopedDynamoDB(t *testing.T) { + ts, cloud := matrixServer(t, nil) + ctx := context.Background() + + for _, name := range []string{"dev", "prod"} { + if err := cloud.DynamoDB.CreateTable(ctx, dbdriver.TableConfig{ + Name: name, PartitionKey: "id", + GSIs: []dbdriver.GSIConfig{{Name: "byOwner", PartitionKey: "owner"}}, + }); err != nil { + t.Fatalf("CreateTable %s: %v", name, err) + } + } + + allButProd := userWithPolicy(t, cloud, "notprod", policyDoc( + scoped("Allow", "dynamodb:*", "*"), + scoped("Deny", "dynamodb:*", ddbNS+"prod"), + )) + + put := func(table string) sreq { + return rpcReq("dynamodb", "DynamoDB_20120810.PutItem", `{"TableName":"`+table+`","Item":{"id":{"S":"1"}}}`) + } + batchWrite := func(tables ...string) sreq { + items := "" + for i, tb := range tables { + if i > 0 { + items += "," + } + + items += `"` + tb + `":[{"PutRequest":{"Item":{"id":{"S":"b"}}}}]` + } + + return rpcReq("dynamodb", "DynamoDB_20120810.BatchWriteItem", `{"RequestItems":{`+items+`}}`) + } + transact := func(tables ...string) sreq { + items := "" + for i, tb := range tables { + if i > 0 { + items += "," + } + + items += `{"Put":{"TableName":"` + tb + `","Item":{"id":{"S":"t` + tb + `"}}}}` + } + + return rpcReq("dynamodb", "DynamoDB_20120810.TransactWriteItems", `{"TransactItems":[`+items+`]}`) + } + + for name, rq := range map[string]sreq{ + "PutItem dev": put("dev"), + "BatchWriteItem dev": batchWrite("dev"), + "TransactWriteItems dev": transact("dev"), + } { + status, body := doSigned(t, ts, allButProd, rq) + if status != http.StatusOK { + t.Fatalf("%s: %d %s", name, status, body) + } + } + + for name, rq := range map[string]sreq{ + "PutItem prod": put("prod"), + "BatchWriteItem dev+prod": batchWrite("dev", "prod"), + "TransactWriteItems dev+prod": transact("dev", "prod"), + } { + t.Run(name, func(t *testing.T) { + status, body := doSigned(t, ts, allButProd, rq) + wantDenied(t, status, body, "on resource: "+ddbNS+"prod with an explicit deny") + }) + } + + if items, _ := cloud.DynamoDB.Scan(ctx, dbdriver.ScanInput{Table: "prod"}); items != nil && len(items.Items) != 0 { + t.Errorf("prod holds %d items after denied writes", len(items.Items)) + } + + indexOnly := userWithPolicy(t, cloud, "indexreader", policyDoc(scoped("Allow", "dynamodb:Query", ddbNS+"dev/index/byOwner"))) + query := func(index string) sreq { + body := `{"TableName":"dev","KeyConditionExpression":"#k = :v",` + + `"ExpressionAttributeNames":{"#k":"owner"},"ExpressionAttributeValues":{":v":{"S":"x"}}` + if index != "" { + body += `,"IndexName":"` + index + `"` + } + + return rpcReq("dynamodb", "DynamoDB_20120810.Query", body+`}`) + } + + status, body := doSigned(t, ts, indexOnly, query("byOwner")) + if status != http.StatusOK { + t.Fatalf("Query on the allowed index: %d %s", status, body) + } + + status, body = doSigned(t, ts, indexOnly, query("")) + wantDenied(t, status, body, "dynamodb:Query on resource: "+ddbNS+"dev ") +} + +// TestAuthzResourceScopedIAM: an Allow on a user path covers the users under +// that path, found by name through their stored ARN. +func TestAuthzResourceScopedIAM(t *testing.T) { + ts, cloud := matrixServer(t, nil) + ctx := context.Background() + + if _, err := cloud.IAM.CreateUser(ctx, iamdriver.UserConfig{Name: "alice", Path: "/dev/"}); err != nil { + t.Fatalf("CreateUser: %v", err) + } + + if _, err := cloud.IAM.CreateUser(ctx, iamdriver.UserConfig{Name: "bob"}); err != nil { + t.Fatalf("CreateUser: %v", err) + } + + devAdmin := userWithPolicy(t, cloud, "devadmin", policyDoc(scoped("Allow", "iam:*", arnNS+"user/dev/*"))) + + iamForm := func(kv ...string) sreq { + v := url.Values{"Version": {"2010-05-08"}} + for i := 0; i+1 < len(kv); i += 2 { + v.Set(kv[i], kv[i+1]) + } + + return form("iam", v.Encode()) + } + + for name, rq := range map[string]sreq{ + "GetUser alice": iamForm("Action", "GetUser", "UserName", "alice"), + "TagUser alice": iamForm("Action", "TagUser", "UserName", "alice", "Tags.member.1.Key", "k", "Tags.member.1.Value", "v"), + "CreateUser /dev/carol": iamForm("Action", "CreateUser", "UserName", "carol", "Path", "/dev/"), + } { + status, body := doSigned(t, ts, devAdmin, rq) + if status != http.StatusOK { + t.Fatalf("%s: %d %s", name, status, body) + } + } + + for name, rq := range map[string]sreq{ + "GetUser bob": iamForm("Action", "GetUser", "UserName", "bob"), + "DeleteUser bob": iamForm("Action", "DeleteUser", "UserName", "bob"), + "CreateUser /dave": iamForm("Action", "CreateUser", "UserName", "dave"), + "CreateRole /dev/r": iamForm("Action", "CreateRole", "RoleName", "r", "Path", "/dev/", "AssumeRolePolicyDocument", "{}"), + "ListUsers": iamForm("Action", "ListUsers"), + } { + t.Run(name, func(t *testing.T) { + status, body := doSigned(t, ts, devAdmin, rq) + wantDenied(t, status, body, xmlAccessDenied) + }) + } + + if userCount(t, cloud, "dave") != 0 || userCount(t, cloud, "bob") != 1 || userCount(t, cloud, "carol") != 1 { + t.Error("IAM state does not match the authorized requests") + } +} diff --git a/server/aws/authzgate.go b/server/aws/authzgate.go index d09ed6dbb..0637b16a0 100644 --- a/server/aws/authzgate.go +++ b/server/aws/authzgate.go @@ -1,7 +1,6 @@ package aws import ( - "encoding/json" "net/http" "regexp" "strings" @@ -106,7 +105,7 @@ type authzPlan struct { // 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 { +func (g *gateConfig) resolvePlan(probe *http.Request, h server.Handler, probed bool) authzPlan { if !probed { return authzPlan{kind: planUnmapped} } @@ -125,7 +124,7 @@ func (g *gateConfig) resolvePlan(probe *http.Request, h server.Handler, probed b } if g.jsonRPC[h] { - return g.jsonRPCPlan(probe, h, body) + return g.jsonRPCPlan(probe, h) } if svc := iamService(h); servicePrefix.MatchString(svc) { @@ -141,14 +140,15 @@ func (g *gateConfig) resolvePlan(probe *http.Request, h server.Handler, probed b // 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 { +// request fails closed. The resource is unknown: a JSON-RPC handler that can +// name its resources does so as a Resolver. +func (*gateConfig) jsonRPCPlan(probe *http.Request, h server.Handler) authzPlan { service, op, ok := jsonRPCTarget(probe) if !ok || service != iamService(h) { return authzPlan{kind: planJSONDeny, req: probe, action: service + ":" + op} } - return authzPlan{kind: planChecks, req: probe, checks: awsauthz.Single(service+":"+op, deriveResource(service, body, g.scope))} + return authzPlan{kind: planChecks, req: probe, checks: awsauthz.Single(service+":"+op, "")} } // denyTarget is the request a deny is rendered from: the probe when there is @@ -331,36 +331,6 @@ func checkPermission( 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:" + s.Partition + ":dynamodb:" + s.Region + ":" + s.AccountID + ":table/" + name - } - } - - return "" -} - -// jsonField extracts a single top-level string field from a JSON-RPC request -// body without fully modeling the operation. It returns "" when the body is not -// an object or the field is absent or non-string. -func jsonField(body []byte, field string) string { - var m map[string]any - if err := json.Unmarshal(body, &m); err != nil { - return "" - } - - if v, ok := m[field].(string); ok { - return v - } - - return "" -} - // 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. diff --git a/server/aws/aws.go b/server/aws/aws.go index d7bda7a0b..b2d50b8c2 100644 --- a/server/aws/aws.go +++ b/server/aws/aws.go @@ -628,19 +628,21 @@ func newServer(d Drivers) (*server.Server, authzSets) { srv.Register(cw) } + // DynamoDB, DynamoDB Streams and SQS route on X-Amz-Target but name their + // own IAM checks with resource ARNs (Resolvers), so they skip rpc. if d.DynamoDB != nil { - srv.Register(rpc(dynamodb.New(d.DynamoDB))) + srv.Register(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(rpc(dynamodb.NewStreams(d.DynamoDB))) + srv.Register(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(rpc(sqs.New(d.SQS))) + srv.Register(sqs.New(d.SQS)) } // Resource Groups Tagging API: X-Amz-Target prefix diff --git a/server/aws/dynamodb/authz.go b/server/aws/dynamodb/authz.go new file mode 100644 index 000000000..3e0e6ab8b --- /dev/null +++ b/server/aws/dynamodb/authz.go @@ -0,0 +1,339 @@ +package dynamodb + +import ( + "encoding/json" + "net/http" + "regexp" + "sort" + "strings" + + "github.com/stackshy/cloudemu/v2/server/wire/awsauthz" +) + +// tableRef says where an operation names the resource it is authorized on. +type tableRef int + +const ( + refNone tableRef = iota // account-level, Resource "*" + refTable // TableName + refTableOrIndex // TableName, or its index when IndexName is set + refResourceArn // ResourceArn, resolved to its table as dispatch does + refBackup // BackupArn + refRestoreBackup // BackupArn and TargetTableName + refRestorePITR // SourceTableName and TargetTableName + refGlobalTable // GlobalTableName + refBatch // the tables keying RequestItems + refTransactWrite // each TransactItems element's table, per kind + refTransactGet // each TransactItems Get's table +) + +// tableOps maps every operation ServeHTTP dispatches to where it names its +// resource, as listed under "Actions defined by Amazon DynamoDB". The IAM +// action is the operation name except for the transactions, which AWS +// authorizes as the item action of each element. +// +//nolint:gochecknoglobals,goconst // static lookup table of the operation names the dispatch routes list +var tableOps = map[string]tableRef{ + "CreateTable": refTable, "DeleteTable": refTable, "DescribeTable": refTable, "UpdateTable": refTable, + "DescribeContinuousBackups": refTable, "UpdateContinuousBackups": refTable, "ListTables": refNone, + "PutItem": refTable, "GetItem": refTable, "DeleteItem": refTable, "UpdateItem": refTable, + "Query": refTableOrIndex, "Scan": refTableOrIndex, + "BatchWriteItem": refBatch, "BatchGetItem": refBatch, + "TransactWriteItems": refTransactWrite, "TransactGetItems": refTransactGet, + "TagResource": refResourceArn, "UntagResource": refResourceArn, "ListTagsOfResource": refResourceArn, + "DescribeTimeToLive": refTable, "UpdateTimeToLive": refTable, + "CreateBackup": refTable, "DescribeBackup": refBackup, "DeleteBackup": refBackup, "ListBackups": refNone, + "RestoreTableFromBackup": refRestoreBackup, "RestoreTableToPointInTime": refRestorePITR, + "CreateGlobalTable": refGlobalTable, "DescribeGlobalTable": refGlobalTable, "UpdateGlobalTable": refGlobalTable, + "ListGlobalTables": refNone, + "DescribeKinesisStreamingDestination": refTable, "EnableKinesisStreamingDestination": refTable, + "DisableKinesisStreamingDestination": refTable, "UpdateKinesisStreamingDestination": refTable, + "DescribeContributorInsights": refTableOrIndex, "UpdateContributorInsights": refTableOrIndex, + "ListContributorInsights": refNone, + "DescribeLimits": refNone, "DescribeEndpoints": refNone, +} + +// transactWriteActions is the IAM action of each TransactWriteItems kind. +// +//nolint:gochecknoglobals // static lookup table +var transactWriteActions = map[string]string{ + "Put": "PutItem", "Update": "UpdateItem", "Delete": "DeleteItem", "ConditionCheck": "ConditionCheckItem", +} + +var ( + // resourceName is the shape of a DynamoDB table or index name. + resourceName = regexp.MustCompile(`^[A-Za-z0-9_.-]{1,255}$`) + // subResource is the table-relative part of a backup or stream ARN. + subResource = regexp.MustCompile(`^table/[A-Za-z0-9_.-]{1,255}/(backup|stream)/[A-Za-z0-9:._-]{1,128}$`) +) + +// IAMChecks names the IAM checks of a request from the X-Amz-Target ServeHTTP +// dispatches on, each on the table, index, backup or global table it runs +// on, in the server's account and region. Batch operations need the action +// on every table they touch and a transaction the item action of each +// element, one check per table. A resource the request does not name +// cleanly is evaluated as unknown. An operation the handler does not serve +// returns ok=false; ServeHTTP answers it with UnknownOperationException. +func (*Handler) IAMChecks(r *http.Request, s awsauthz.Scope) ([]awsauthz.Check, bool) { + op := strings.TrimPrefix(r.Header.Get("X-Amz-Target"), targetPrefix) + + ref, ok := tableOps[op] + if !ok { + return nil, false + } + + action := iamServicePrefix + ":" + op + + switch ref { + case refNone: + return awsauthz.Single(action, "*"), true + case refBatch: + return batchChecks(r, s, action), true + case refTransactWrite: + return transactWriteChecks(r, s), true + case refTransactGet: + return transactGetChecks(r, s), true + case refTable, refTableOrIndex, refResourceArn, refBackup, refRestoreBackup, refRestorePITR, refGlobalTable: + } + + return namedChecks(r, s, ref, action), true +} + +// namedChecks is the check of an operation that names its resources in +// top-level request fields. +func namedChecks(r *http.Request, s awsauthz.Scope, ref tableRef, action string) []awsauthz.Check { + var req struct { + TableName string `json:"TableName"` + IndexName string `json:"IndexName"` + ResourceArn string `json:"ResourceArn"` + BackupArn string `json:"BackupArn"` + SourceTableName string `json:"SourceTableName"` + TargetTableName string `json:"TargetTableName"` + GlobalTableName string `json:"GlobalTableName"` + } + + if !awsauthz.JSONBody(r, &req) { + return awsauthz.Single(action, "") + } + + switch ref { + case refTableOrIndex: + return awsauthz.Single(action, indexARN(s, req.TableName, req.IndexName)) + case refResourceArn: + return awsauthz.Single(action, tableARN(s, tableFromARN(req.ResourceArn))) + case refBackup: + return awsauthz.Single(action, subResourceARN(s, req.BackupArn)) + case refRestoreBackup: + return withAction(action, subResourceARN(s, req.BackupArn), tableARN(s, req.TargetTableName)) + case refRestorePITR: + return withAction(action, tableARN(s, req.SourceTableName), tableARN(s, req.TargetTableName)) + case refGlobalTable: + return awsauthz.Single(action, globalTableARN(s, req.GlobalTableName)) + case refNone, refTable, refBatch, refTransactWrite, refTransactGet: + } + + return awsauthz.Single(action, tableARN(s, req.TableName)) +} + +// batchChecks is one check of action per table keying RequestItems, in name +// order. An empty or unreadable request is one check on an unknown resource. +func batchChecks(r *http.Request, s awsauthz.Scope, action string) []awsauthz.Check { + var req struct { + RequestItems map[string]json.RawMessage `json:"RequestItems"` + } + + if !awsauthz.JSONBody(r, &req) || len(req.RequestItems) == 0 { + return awsauthz.Single(action, "") + } + + tables := make([]string, 0, len(req.RequestItems)) + for t := range req.RequestItems { + tables = append(tables, tableARN(s, t)) + } + + sort.Strings(tables) + + return withAction(action, tables...) +} + +// transactWriteChecks is the item action of each element on its table, as +// toTxOp picks the element's kind for dispatch, without repeats. +func transactWriteChecks(r *http.Request, s awsauthz.Scope) []awsauthz.Check { + fallback := awsauthz.Single(iamServicePrefix+":PutItem", "") + + var req struct { + TransactItems []transactWriteJSON `json:"TransactItems"` + } + + if !awsauthz.JSONBody(r, &req) { + return fallback + } + + var checks []awsauthz.Check + + for _, op := range normalizeTransactItems(req.TransactItems) { + checks = appendCheck(checks, iamServicePrefix+":"+transactWriteActions[op.kind], tableARN(s, op.table)) + } + + if len(checks) == 0 { + return fallback + } + + return checks +} + +// transactGetChecks is dynamodb:GetItem on the table of each Get, without +// repeats. +func transactGetChecks(r *http.Request, s awsauthz.Scope) []awsauthz.Check { + action := iamServicePrefix + ":GetItem" + + var req struct { + TransactItems []struct { + Get *struct { + TableName string `json:"TableName"` + } `json:"Get,omitempty"` + } `json:"TransactItems"` + } + + if !awsauthz.JSONBody(r, &req) { + return awsauthz.Single(action, "") + } + + var checks []awsauthz.Check + + for _, t := range req.TransactItems { + if t.Get != nil { + checks = appendCheck(checks, action, tableARN(s, t.Get.TableName)) + } + } + + if len(checks) == 0 { + return awsauthz.Single(action, "") + } + + return checks +} + +// appendCheck adds action on resource unless checks already holds it. +func appendCheck(checks []awsauthz.Check, action, resource string) []awsauthz.Check { + c := awsauthz.Check{Action: action, Resource: resource} + + for _, have := range checks { + if have == c { + return checks + } + } + + return append(checks, c) +} + +// withAction is one check of action per resource. +func withAction(action string, resources ...string) []awsauthz.Check { + checks := make([]awsauthz.Check, 0, len(resources)) + for _, res := range resources { + checks = append(checks, awsauthz.Check{Action: action, Resource: res}) + } + + return checks +} + +// tableARN is the ARN of table name, or "" when name is not a table name. +func tableARN(s awsauthz.Scope, name string) string { + if !resourceName.MatchString(name) { + return "" + } + + return s.ARN(iamServicePrefix, "table/"+name) +} + +// indexARN is the ARN of the index when one is named, else of the table. +func indexARN(s awsauthz.Scope, table, index string) string { + t := tableARN(s, table) + if index == "" || t == "" { + return t + } + + if !resourceName.MatchString(index) { + return "" + } + + return t + "/index/" + index +} + +// globalTableARN is the regionless ARN of global table name, or "". +func globalTableARN(s awsauthz.Scope, name string) string { + if !resourceName.MatchString(name) { + return "" + } + + return s.GlobalARN(iamServicePrefix, "global-table/"+name) +} + +// subResourceARN rebuilds a backup or stream ARN in the server's account and +// region from its table-relative part, or returns "" when arn is not one. +func subResourceARN(s awsauthz.Scope, arn string) string { + const marker = ":table/" + + i := strings.Index(arn, marker) + if !strings.HasPrefix(arn, "arn:") || i < 0 { + return "" + } + + res := arn[i+1:] + if !subResource.MatchString(res) { + return "" + } + + return s.ARN(iamServicePrefix, res) +} + +// IAMChecks names the IAM check of a DynamoDB Streams request from the +// X-Amz-Target ServeHTTP dispatches on. DescribeStream and GetShardIterator +// run on the stream they name; GetRecords on the stream of the table its +// shard iterator names, which is what the handler reads. ListStreams takes +// no resource. An operation the handler does not serve returns ok=false. +func (h *StreamsHandler) IAMChecks(r *http.Request, s awsauthz.Scope) ([]awsauthz.Check, bool) { + op := strings.TrimPrefix(r.Header.Get("X-Amz-Target"), streamsTargetPrefix) + action := iamServicePrefix + ":" + op + + var req struct { + StreamArn string `json:"StreamArn"` + ShardIterator string `json:"ShardIterator"` + } + + //nolint:goconst // operation names, which the dispatch switch also lists + switch op { + case "ListStreams": + return awsauthz.Single(action, "*"), true + case "DescribeStream", "GetShardIterator": + if !awsauthz.JSONBody(r, &req) { + return awsauthz.Single(action, ""), true + } + + return awsauthz.Single(action, subResourceARN(s, req.StreamArn)), true + case "GetRecords": + if !awsauthz.JSONBody(r, &req) { + return awsauthz.Single(action, ""), true + } + + return awsauthz.Single(action, h.iteratorStream(r, s, req.ShardIterator)), true + default: + return nil, false + } +} + +// iteratorStream is the stream ARN of the table a shard iterator names, or "" +// when the iterator does not decode or the table has no stream. +func (h *StreamsHandler) iteratorStream(r *http.Request, s awsauthz.Scope, iterator string) string { + cur, err := decodeIterator(iterator) + if err != nil { + return "" + } + + cfg, err := h.db.DescribeTable(r.Context(), cur.Table) + if err != nil || !cfg.StreamEnabled { + return "" + } + + return subResourceARN(s, cfg.StreamArn) +} diff --git a/server/aws/dynamodb/authz_dispatch_test.go b/server/aws/dynamodb/authz_dispatch_test.go new file mode 100644 index 000000000..0f9b8bb3c --- /dev/null +++ b/server/aws/dynamodb/authz_dispatch_test.go @@ -0,0 +1,28 @@ +package dynamodb + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + + cloudemu "github.com/stackshy/cloudemu/v2" +) + +// TestIAMOperationsAreServed fails when the IAM table names an operation the +// handler does not serve, so the table and dispatch agree. +func TestIAMOperationsAreServed(t *testing.T) { + h := New(cloudemu.NewAWS().DynamoDB) + + for op := range tableOps { + r := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(`{}`)) + r.Header.Set("X-Amz-Target", targetPrefix+op) + + w := httptest.NewRecorder() + h.ServeHTTP(w, r) + + if strings.Contains(w.Body.String(), "UnknownOperationException") { + t.Errorf("%s is in the IAM table but the handler does not serve it", op) + } + } +} diff --git a/server/aws/dynamodb/authz_test.go b/server/aws/dynamodb/authz_test.go new file mode 100644 index 000000000..7cad027d5 --- /dev/null +++ b/server/aws/dynamodb/authz_test.go @@ -0,0 +1,192 @@ +package dynamodb_test + +import ( + "context" + "encoding/base64" + "net/http" + "net/http/httptest" + "reflect" + "strings" + "testing" + + cloudemu "github.com/stackshy/cloudemu/v2" + "github.com/stackshy/cloudemu/v2/server/aws/dynamodb" + "github.com/stackshy/cloudemu/v2/server/wire/awsauthz" + dbdriver "github.com/stackshy/cloudemu/v2/services/database/driver" +) + +const ( + tableT1 = "arn:aws:dynamodb:us-east-1:123456789012:table/t1" + tableT2 = "arn:aws:dynamodb:us-east-1:123456789012:table/t2" +) + +var ddbScope = awsauthz.Scope{AccountID: "123456789012", Region: "us-east-1", Partition: "aws"} //nolint:gochecknoglobals // test fixture + +func ddbRequest(prefix, op, body string) *http.Request { + r := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(body)) + r.Header.Set("X-Amz-Target", prefix+op) + r.Header.Set("Content-Type", "application/x-amz-json-1.0") + + return r +} + +type wantCheck struct{ action, resource string } + +func checksOf(checks []awsauthz.Check) []wantCheck { + out := make([]wantCheck, 0, len(checks)) + for _, c := range checks { + out = append(out, wantCheck{c.Action, c.Resource}) + } + + return out +} + +func one(action, resource string) []wantCheck { return []wantCheck{{action, resource}} } + +func TestIAMChecksTableARNs(t *testing.T) { + onT1 := `{"TableName":"t1"}` + backup := tableT1 + "/backup/01700000000000-abcdef12" + + cases := []struct { + op, body string + want []wantCheck + }{ + {"CreateTable", onT1, one("dynamodb:CreateTable", tableT1)}, + {"DeleteTable", onT1, one("dynamodb:DeleteTable", tableT1)}, + {"DescribeTable", onT1, one("dynamodb:DescribeTable", tableT1)}, + {"UpdateTable", onT1, one("dynamodb:UpdateTable", tableT1)}, + {"DescribeContinuousBackups", onT1, one("dynamodb:DescribeContinuousBackups", tableT1)}, + {"UpdateContinuousBackups", onT1, one("dynamodb:UpdateContinuousBackups", tableT1)}, + {"ListTables", `{}`, one("dynamodb:ListTables", "*")}, + {"PutItem", onT1, one("dynamodb:PutItem", tableT1)}, + {"GetItem", onT1, one("dynamodb:GetItem", tableT1)}, + {"DeleteItem", onT1, one("dynamodb:DeleteItem", tableT1)}, + {"UpdateItem", onT1, one("dynamodb:UpdateItem", tableT1)}, + {"Query", onT1, one("dynamodb:Query", tableT1)}, + {"Query", `{"TableName":"t1","IndexName":"byOwner"}`, one("dynamodb:Query", tableT1+"/index/byOwner")}, + {"Scan", onT1, one("dynamodb:Scan", tableT1)}, + {"Scan", `{"TableName":"t1","IndexName":"byOwner"}`, one("dynamodb:Scan", tableT1+"/index/byOwner")}, + {"BatchWriteItem", `{"RequestItems":{"t2":[],"t1":[]}}`, + []wantCheck{{"dynamodb:BatchWriteItem", tableT1}, {"dynamodb:BatchWriteItem", tableT2}}}, + {"BatchGetItem", `{"RequestItems":{"t2":{},"t1":{}}}`, + []wantCheck{{"dynamodb:BatchGetItem", tableT1}, {"dynamodb:BatchGetItem", tableT2}}}, + {"TransactWriteItems", `{"TransactItems":[{"Put":{"TableName":"t1"}},{"Update":{"TableName":"t2"}},` + + `{"Delete":{"TableName":"t1"}},{"ConditionCheck":{"TableName":"t2"}},{"Put":{"TableName":"t1"}}]}`, + []wantCheck{ + {"dynamodb:PutItem", tableT1}, {"dynamodb:UpdateItem", tableT2}, + {"dynamodb:DeleteItem", tableT1}, {"dynamodb:ConditionCheckItem", tableT2}, + }}, + // Dispatch runs the first kind set on an element (Put before Delete), so + // that is the one authorized. + {"TransactWriteItems", `{"TransactItems":[{"Delete":{"TableName":"t2"},"Put":{"TableName":"t1"}}]}`, + one("dynamodb:PutItem", tableT1)}, + {"TransactGetItems", `{"TransactItems":[{"Get":{"TableName":"t2"}},{"Get":{"TableName":"t1"}},{"Get":{"TableName":"t2"}}]}`, + []wantCheck{{"dynamodb:GetItem", tableT2}, {"dynamodb:GetItem", tableT1}}}, + {"TagResource", `{"ResourceArn":"` + tableT1 + `"}`, one("dynamodb:TagResource", tableT1)}, + {"UntagResource", `{"ResourceArn":"` + tableT1 + `"}`, one("dynamodb:UntagResource", tableT1)}, + {"ListTagsOfResource", `{"ResourceArn":"` + tableT1 + `/index/i"}`, one("dynamodb:ListTagsOfResource", tableT1)}, + {"DescribeTimeToLive", onT1, one("dynamodb:DescribeTimeToLive", tableT1)}, + {"UpdateTimeToLive", onT1, one("dynamodb:UpdateTimeToLive", tableT1)}, + {"CreateBackup", onT1, one("dynamodb:CreateBackup", tableT1)}, + {"DescribeBackup", `{"BackupArn":"` + backup + `"}`, one("dynamodb:DescribeBackup", backup)}, + {"DeleteBackup", `{"BackupArn":"` + backup + `"}`, one("dynamodb:DeleteBackup", backup)}, + {"ListBackups", onT1, one("dynamodb:ListBackups", "*")}, + {"RestoreTableFromBackup", `{"BackupArn":"` + backup + `","TargetTableName":"t2"}`, + []wantCheck{{"dynamodb:RestoreTableFromBackup", backup}, {"dynamodb:RestoreTableFromBackup", tableT2}}}, + {"RestoreTableToPointInTime", `{"SourceTableName":"t1","TargetTableName":"t2"}`, + []wantCheck{{"dynamodb:RestoreTableToPointInTime", tableT1}, {"dynamodb:RestoreTableToPointInTime", tableT2}}}, + {"CreateGlobalTable", `{"GlobalTableName":"g"}`, one("dynamodb:CreateGlobalTable", "arn:aws:dynamodb::123456789012:global-table/g")}, + {"DescribeGlobalTable", `{"GlobalTableName":"g"}`, one("dynamodb:DescribeGlobalTable", "arn:aws:dynamodb::123456789012:global-table/g")}, + {"UpdateGlobalTable", `{"GlobalTableName":"g"}`, one("dynamodb:UpdateGlobalTable", "arn:aws:dynamodb::123456789012:global-table/g")}, + {"ListGlobalTables", `{}`, one("dynamodb:ListGlobalTables", "*")}, + {"DescribeKinesisStreamingDestination", onT1, one("dynamodb:DescribeKinesisStreamingDestination", tableT1)}, + {"EnableKinesisStreamingDestination", onT1, one("dynamodb:EnableKinesisStreamingDestination", tableT1)}, + {"DisableKinesisStreamingDestination", onT1, one("dynamodb:DisableKinesisStreamingDestination", tableT1)}, + {"UpdateKinesisStreamingDestination", onT1, one("dynamodb:UpdateKinesisStreamingDestination", tableT1)}, + {"DescribeContributorInsights", onT1, one("dynamodb:DescribeContributorInsights", tableT1)}, + {"UpdateContributorInsights", `{"TableName":"t1","IndexName":"i"}`, one("dynamodb:UpdateContributorInsights", tableT1+"/index/i")}, + {"ListContributorInsights", onT1, one("dynamodb:ListContributorInsights", "*")}, + {"DescribeLimits", `{}`, one("dynamodb:DescribeLimits", "*")}, + {"DescribeEndpoints", `{}`, one("dynamodb:DescribeEndpoints", "*")}, + // The account and region come from the server, not the ARN. + {"TagResource", `{"ResourceArn":"arn:aws:dynamodb:eu-west-1:999999999999:table/t1"}`, one("dynamodb:TagResource", tableT1)}, + // Anything the request does not name cleanly stays unknown. + {"PutItem", `{}`, one("dynamodb:PutItem", "")}, + {"PutItem", `{"TableName":"t*"}`, one("dynamodb:PutItem", "")}, + {"PutItem", `{"TableName":`, one("dynamodb:PutItem", "")}, + {"Query", `{"TableName":"t1","IndexName":"a/b"}`, one("dynamodb:Query", "")}, + {"BatchWriteItem", `{"RequestItems":{}}`, one("dynamodb:BatchWriteItem", "")}, + {"BatchWriteItem", `{"RequestItems":{"t1":[],"t*":[]}}`, + []wantCheck{{"dynamodb:BatchWriteItem", ""}, {"dynamodb:BatchWriteItem", tableT1}}}, + {"TransactWriteItems", `{"TransactItems":[]}`, one("dynamodb:PutItem", "")}, + {"TransactGetItems", `{"TransactItems":[{}]}`, one("dynamodb:GetItem", "")}, + {"DescribeBackup", `{"BackupArn":"nope"}`, one("dynamodb:DescribeBackup", "")}, + {"TagResource", `{"ResourceArn":"t1"}`, one("dynamodb:TagResource", tableT1)}, + } + + h := dynamodb.New(nil) + + for _, tc := range cases { + checks, ok := h.IAMChecks(ddbRequest("DynamoDB_20120810.", tc.op, tc.body), ddbScope) + if !ok || !reflect.DeepEqual(checksOf(checks), tc.want) { + t.Errorf("%s %s: got %+v ok=%v, want %+v", tc.op, tc.body, checksOf(checks), ok, tc.want) + } + } + + for _, op := range []string{"", "Bogus", "putitem", "ExecuteStatement"} { + if checks, ok := h.IAMChecks(ddbRequest("DynamoDB_20120810.", op, `{}`), ddbScope); ok { + t.Errorf("%q: got %+v, want ok=false", op, checks) + } + } +} + +func TestStreamsIAMChecks(t *testing.T) { + ctx := context.Background() + cloud := cloudemu.NewAWS() + + if err := cloud.DynamoDB.CreateTable(ctx, dbdriver.TableConfig{ + Name: "t1", PartitionKey: "id", StreamEnabled: true, StreamViewType: "NEW_IMAGE", + }); err != nil { + t.Fatalf("CreateTable: %v", err) + } + + cfg, err := cloud.DynamoDB.DescribeTable(ctx, "t1") + if err != nil || cfg.StreamArn == "" { + t.Fatalf("DescribeTable: %+v %v", cfg, err) + } + + label := cfg.StreamArn[strings.LastIndex(cfg.StreamArn, "/")+1:] + stream := tableT1 + "/stream/" + label + iterator := base64.URLEncoding.EncodeToString([]byte(`{"t":"t1","a":"","s":"shard"}`)) + noStream := base64.URLEncoding.EncodeToString([]byte(`{"t":"t2"}`)) + + cases := []struct { + op, body string + want []wantCheck + }{ + {"ListStreams", onTable("t1"), one("dynamodb:ListStreams", "*")}, + {"DescribeStream", `{"StreamArn":"` + cfg.StreamArn + `"}`, one("dynamodb:DescribeStream", stream)}, + {"GetShardIterator", `{"StreamArn":"` + cfg.StreamArn + `"}`, one("dynamodb:GetShardIterator", stream)}, + // GetRecords runs on the table the iterator names, so its stream is + // the resource. + {"GetRecords", `{"ShardIterator":"` + iterator + `"}`, one("dynamodb:GetRecords", stream)}, + {"GetRecords", `{"ShardIterator":"` + noStream + `"}`, one("dynamodb:GetRecords", "")}, + {"GetRecords", `{"ShardIterator":"%%"}`, one("dynamodb:GetRecords", "")}, + {"DescribeStream", `{"StreamArn":"nope"}`, one("dynamodb:DescribeStream", "")}, + } + + h := dynamodb.NewStreams(cloud.DynamoDB) + + for _, tc := range cases { + checks, ok := h.IAMChecks(ddbRequest("DynamoDBStreams_20120810.", tc.op, tc.body), ddbScope) + if !ok || !reflect.DeepEqual(checksOf(checks), tc.want) { + t.Errorf("%s %s: got %+v ok=%v, want %+v", tc.op, tc.body, checksOf(checks), ok, tc.want) + } + } + + if checks, ok := h.IAMChecks(ddbRequest("DynamoDBStreams_20120810.", "Bogus", `{}`), ddbScope); ok { + t.Errorf("unknown streams op: got %+v, want ok=false", checks) + } +} + +func onTable(name string) string { return `{"TableName":"` + name + `"}` } diff --git a/server/aws/iam/authz.go b/server/aws/iam/authz.go new file mode 100644 index 000000000..f1e0a05b3 --- /dev/null +++ b/server/aws/iam/authz.go @@ -0,0 +1,171 @@ +package iam + +import ( + "context" + "net/http" + "regexp" + "strings" + + "github.com/stackshy/cloudemu/v2/server/wire/awsauthz" +) + +// entityKind is the IAM resource type an operation is authorized on. +type entityKind int + +const ( + kindAccount entityKind = iota // no resource, Resource "*" + kindUser // UserName + kindRole // RoleName + kindGroup // GroupName + kindInstanceProfile // InstanceProfileName + kindPolicyName // PolicyName and Path (CreatePolicy) + kindPolicyARN // PolicyArn + kindMFAName // VirtualMFADeviceName (CreateVirtualMFADevice) + kindMFASerial // SerialNumber (DeleteVirtualMFADevice) + kindPolicySource // PolicySourceArn (SimulatePrincipalPolicy) + kindUnnamed // the request does not name the resource (CreateServiceLinkedRole) +) + +// entityType is the ARN resource type and name parameter of each named kind. +// +//nolint:gochecknoglobals // static lookup table +var entityType = map[entityKind]struct{ arnType, param string }{ + kindUser: {"user", "UserName"}, + kindRole: {"role", "RoleName"}, + kindGroup: {"group", "GroupName"}, + kindInstanceProfile: {"instance-profile", "InstanceProfileName"}, + kindPolicyName: {"policy", "PolicyName"}, + kindMFAName: {"mfa", "VirtualMFADeviceName"}, +} + +var ( + // entityName is the shape of an IAM user, role, group, policy, instance + // profile or MFA device name. + entityName = regexp.MustCompile(`^[\w+=,.@-]{1,128}$`) + // entityPath is the shape of an IAM path: "/" or "/segments/". + entityPath = regexp.MustCompile(`^/([\x21-\x7E]{0,510}/)?$`) +) + +// IAMChecks names the IAM action of a request from the form Action that +// ServeHTTP dispatches on, and the user, role, group, policy, instance +// profile or MFA device it acts on. An entity that exists is named by its +// stored ARN, path included; otherwise the ARN is built from the request's +// Path and name, the way the backend builds it on create. The deny message +// names only what the request sent. List and account operations take +// Resource "*", and a request that names no well-formed resource is +// evaluated on an unknown one. 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, s awsauthz.Scope) ([]awsauthz.Check, bool) { + checks, ok := awsauthz.QueryChecks(r, h.IAMService()) + if !ok { + return nil, false + } + + kind, known := iamActions[r.Form.Get("Action")] + if !known { + return checks, true + } + + checks[0].Resource, checks[0].MessageResource = h.entityResource(r, kind, s) + + return checks, true +} + +// entityResource is the resource a request acts on and, when it differs, the +// resource its deny message names. +func (h *Handler) entityResource(r *http.Request, kind entityKind, s awsauthz.Scope) (resource, message string) { + switch kind { + case kindAccount: + return "*", "" + case kindPolicyARN: + return h.accountARN(r.Form.Get("PolicyArn"), s, "policy/"), "" + case kindMFASerial: + return h.accountARN(r.Form.Get("SerialNumber"), s, "mfa/"), "" + case kindPolicySource: + return h.accountARN(r.Form.Get("PolicySourceArn"), s, "user/", "role/", "group/"), "" + case kindUser, kindRole, kindGroup, kindInstanceProfile, kindPolicyName, kindMFAName: + return h.namedEntity(r, kind, s) + case kindUnnamed: + } + + return "", "" +} + +// namedEntity resolves an entity named by a name parameter and optional Path. +func (h *Handler) namedEntity(r *http.Request, kind entityKind, s awsauthz.Scope) (resource, message string) { + t := entityType[kind] + name := r.Form.Get(t.param) + + if !entityName.MatchString(name) { + return "", "" + } + + // The backend files an MFA device by name alone, whatever its Path. + path := "/" + if p := r.Form.Get("Path"); p != "" && kind != kindMFAName { + path = p + } + + if !entityPath.MatchString(path) { + return "", "" + } + + built := s.GlobalARN("iam", t.arnType+"/"+strings.TrimPrefix(path, "/")+name) + + stored := h.storedARN(r.Context(), kind, name) + if stored == "" || stored == built { + return built, "" + } + + return stored, s.GlobalARN("iam", t.arnType+"/"+name) +} + +// storedARN is the ARN of an existing user, role, group or instance profile, +// or "" when there is none. +func (h *Handler) storedARN(ctx context.Context, kind entityKind, name string) string { + switch kind { + case kindUser: + if u, err := h.iam.GetUser(ctx, name); err == nil { + return u.ARN + } + case kindRole: + if ro, err := h.iam.GetRole(ctx, name); err == nil { + return ro.ARN + } + case kindGroup: + if g, err := h.iam.GetGroup(ctx, name); err == nil { + return g.ARN + } + case kindInstanceProfile: + if p, err := h.iam.GetInstanceProfile(ctx, name); err == nil { + return p.ARN + } + case kindAccount, kindPolicyName, kindPolicyARN, kindMFAName, kindMFASerial, kindPolicySource, kindUnnamed: + } + + return "" +} + +// accountARN returns arn when it is an IAM ARN in the server's partition, of +// one of the given resource types, owned by the server's account (or, for a +// managed policy, by AWS). Anything else is "" (unknown): the handler looks +// these ARNs up exactly, so a foreign one names nothing here. +func (*Handler) accountARN(arn string, s awsauthz.Scope, types ...string) string { + prefix := "arn:" + s.Partition + ":iam::" + if !strings.HasPrefix(arn, prefix) { + return "" + } + + account, res, ok := strings.Cut(strings.TrimPrefix(arn, prefix), ":") + if !ok || (account != s.AccountID && (account != "aws" || !strings.HasPrefix(res, "policy/"))) { + return "" + } + + for _, t := range types { + if strings.HasPrefix(res, t) && len(res) > len(t) { + return arn + } + } + + return "" +} diff --git a/server/aws/iam/authz_test.go b/server/aws/iam/authz_test.go new file mode 100644 index 000000000..ddd105c47 --- /dev/null +++ b/server/aws/iam/authz_test.go @@ -0,0 +1,159 @@ +package iam_test + +import ( + "context" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + + cloudemu "github.com/stackshy/cloudemu/v2" + iamsrv "github.com/stackshy/cloudemu/v2/server/aws/iam" + "github.com/stackshy/cloudemu/v2/server/wire/awsauthz" + iamdriver "github.com/stackshy/cloudemu/v2/services/iam/driver" +) + +const ( + acct = "123456789012" + iamNS = "arn:aws:iam::" + acct + ":" +) + +func iamForm(values url.Values) *http.Request { + r := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(values.Encode())) + r.Header.Set("Content-Type", "application/x-www-form-urlencoded") + + return r +} + +// iamCheckHandler returns a handler over an IAM backend holding one entity of +// each kind under the /dev/ path. +func iamCheckHandler(t *testing.T) *iamsrv.Handler { + t.Helper() + + ctx := context.Background() + cloud := cloudemu.NewAWS() + drv := cloud.IAM + + must := func(what string, err error) { + t.Helper() + + if err != nil { + t.Fatalf("%s: %v", what, err) + } + } + + _, err := drv.CreateUser(ctx, iamdriver.UserConfig{Name: "alice", Path: "/dev/"}) + must("CreateUser", err) + _, err = drv.CreateRole(ctx, iamdriver.RoleConfig{Name: "app", Path: "/dev/", AssumeRolePolicyDoc: `{"Version":"2012-10-17","Statement":[]}`}) + must("CreateRole", err) + _, err = drv.CreateGroup(ctx, iamdriver.GroupConfig{Name: "devs", Path: "/dev/"}) + must("CreateGroup", err) + _, err = drv.CreateInstanceProfile(ctx, iamdriver.InstanceProfileConfig{Name: "ip", Path: "/dev/"}) + must("CreateInstanceProfile", err) + + return iamsrv.New(drv, acct) +} + +func TestIAMChecksEntityARNs(t *testing.T) { + scope := awsauthz.Scope{AccountID: acct, Region: "us-east-1", Partition: "aws"} + h := iamCheckHandler(t) + + p := func(kv ...string) url.Values { + v := url.Values{} + for i := 0; i+1 < len(kv); i += 2 { + v.Set(kv[i], kv[i+1]) + } + + return v + } + + cases := []struct { + action string + params url.Values + want string // evaluated resource + message string // resource the deny message names; "" means want + }{ + // Existing entities: the stored ARN, path included. The message names + // only what the request sent. + {"GetUser", p("UserName", "alice"), iamNS + "user/dev/alice", iamNS + "user/alice"}, + {"DeleteUser", p("UserName", "alice"), iamNS + "user/dev/alice", iamNS + "user/alice"}, + {"CreateAccessKey", p("UserName", "alice"), iamNS + "user/dev/alice", iamNS + "user/alice"}, + {"PutUserPolicy", p("UserName", "alice"), iamNS + "user/dev/alice", iamNS + "user/alice"}, + {"ListGroupsForUser", p("UserName", "alice"), iamNS + "user/dev/alice", iamNS + "user/alice"}, + {"EnableMFADevice", p("UserName", "alice"), iamNS + "user/dev/alice", iamNS + "user/alice"}, + {"GetRole", p("RoleName", "app"), iamNS + "role/dev/app", iamNS + "role/app"}, + {"UpdateAssumeRolePolicy", p("RoleName", "app"), iamNS + "role/dev/app", iamNS + "role/app"}, + {"ListInstanceProfilesForRole", p("RoleName", "app"), iamNS + "role/dev/app", iamNS + "role/app"}, + {"TagRole", p("RoleName", "app"), iamNS + "role/dev/app", iamNS + "role/app"}, + {"GetGroup", p("GroupName", "devs"), iamNS + "group/dev/devs", iamNS + "group/devs"}, + {"AddUserToGroup", p("GroupName", "devs", "UserName", "alice"), iamNS + "group/dev/devs", iamNS + "group/devs"}, + {"PutGroupPolicy", p("GroupName", "devs"), iamNS + "group/dev/devs", iamNS + "group/devs"}, + {"GetInstanceProfile", p("InstanceProfileName", "ip"), iamNS + "instance-profile/dev/ip", iamNS + "instance-profile/ip"}, + {"AddRoleToInstanceProfile", p("InstanceProfileName", "ip", "RoleName", "app"), + iamNS + "instance-profile/dev/ip", iamNS + "instance-profile/ip"}, + // Missing entities: built from the request's Path and name. + {"CreateUser", p("UserName", "carol", "Path", "/ops/"), iamNS + "user/ops/carol", ""}, + {"CreateUser", p("UserName", "carol"), iamNS + "user/carol", ""}, + {"DeleteUser", p("UserName", "nobody"), iamNS + "user/nobody", ""}, + {"CreateRole", p("RoleName", "r2", "Path", "/svc/"), iamNS + "role/svc/r2", ""}, + {"CreateGroup", p("GroupName", "g2"), iamNS + "group/g2", ""}, + {"CreateInstanceProfile", p("InstanceProfileName", "ip2", "Path", "/a/b/"), iamNS + "instance-profile/a/b/ip2", ""}, + {"CreatePolicy", p("PolicyName", "pol", "Path", "/team/"), iamNS + "policy/team/pol", ""}, + {"CreateVirtualMFADevice", p("VirtualMFADeviceName", "m1"), iamNS + "mfa/m1", ""}, + // ARN parameters, accepted only in this account (or AWS managed). + {"GetPolicy", p("PolicyArn", iamNS+"policy/team/pol"), iamNS + "policy/team/pol", ""}, + {"DeletePolicyVersion", p("PolicyArn", iamNS+"policy/pol"), iamNS + "policy/pol", ""}, + {"ListEntitiesForPolicy", p("PolicyArn", "arn:aws:iam::aws:policy/ReadOnlyAccess"), "arn:aws:iam::aws:policy/ReadOnlyAccess", ""}, + {"GetPolicy", p("PolicyArn", "arn:aws:iam::999999999999:policy/pol"), "", ""}, + {"GetPolicy", p("PolicyArn", iamNS+"user/alice"), "", ""}, + {"DeleteVirtualMFADevice", p("SerialNumber", iamNS+"mfa/m1"), iamNS + "mfa/m1", ""}, + {"SimulatePrincipalPolicy", p("PolicySourceArn", iamNS+"user/dev/alice"), iamNS + "user/dev/alice", ""}, + {"SimulatePrincipalPolicy", p("PolicySourceArn", iamNS+"policy/x"), "", ""}, + // Account-level and list operations. + {"ListUsers", nil, "*", ""}, + {"ListRoles", nil, "*", ""}, + {"ListPolicies", nil, "*", ""}, + {"GetAccountAuthorizationDetails", nil, "*", ""}, + {"GetAccountSummary", nil, "*", ""}, + {"UpdateAccountPasswordPolicy", nil, "*", ""}, + {"ListVirtualMFADevices", nil, "*", ""}, + {"SimulateCustomPolicy", nil, "*", ""}, + // Anything the request does not name cleanly stays unknown. + {"GetUser", nil, "", ""}, + {"ListAccessKeys", nil, "", ""}, + {"CreateUser", p("UserName", "bad name"), "", ""}, + {"CreateUser", p("UserName", "carol", "Path", "ops"), "", ""}, + {"CreateServiceLinkedRole", p("AWSServiceName", "elasticache.amazonaws.com"), "", ""}, + } + + for _, tc := range cases { + v := url.Values{"Action": {tc.action}} + for k, vals := range tc.params { + v[k] = vals + } + + checks, ok := h.IAMChecks(iamForm(v), scope) + if !ok || len(checks) != 1 { + t.Errorf("%s %v: got %+v ok=%v", tc.action, tc.params, checks, ok) + continue + } + + c := checks[0] + if c.Action != "iam:"+tc.action || c.Resource != tc.want || c.MessageResource != tc.message { + t.Errorf("%s %v: got %s on %q (message %q), want %q (message %q)", + tc.action, tc.params, c.Action, c.Resource, c.MessageResource, tc.want, tc.message) + } + } + + // An Action the handler does not serve is still named, with an unknown + // resource; the handler answers it with InvalidAction. + checks, ok := h.IAMChecks(iamForm(url.Values{"Action": {"CreateLoginProfile"}, "UserName": {"alice"}}), scope) + if !ok || len(checks) != 1 || checks[0].Action != "iam:CreateLoginProfile" || checks[0].Resource != "" { + t.Errorf("unserved action: got %+v ok=%v", checks, ok) + } + + if checks, ok := h.IAMChecks(iamForm(url.Values{"Version": {"2010-05-08"}}), scope); ok { + t.Errorf("no Action: got %+v, want ok=false", checks) + } +} diff --git a/server/aws/iam/handler.go b/server/aws/iam/handler.go index d9e864721..285df3190 100644 --- a/server/aws/iam/handler.go +++ b/server/aws/iam/handler.go @@ -16,7 +16,6 @@ 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" ) @@ -31,93 +30,97 @@ const ( maxFormBodyBytes = 1 << 20 ) -// iamActions enumerates every Action this handler recognizes. Used by Matches -// to decide whether to claim a request. -var iamActions = map[string]struct{}{ //nolint:gochecknoglobals // static lookup table - "CreateUser": {}, - "DeleteUser": {}, - "GetUser": {}, - "ListUsers": {}, - "CreateRole": {}, - "DeleteRole": {}, - "GetRole": {}, - "ListRoles": {}, - "UpdateRole": {}, - "UpdateRoleDescription": {}, - "UpdateAssumeRolePolicy": {}, - "CreatePolicy": {}, - "DeletePolicy": {}, - "GetPolicy": {}, - "ListPolicies": {}, - "CreatePolicyVersion": {}, - "GetPolicyVersion": {}, - "ListPolicyVersions": {}, - "DeletePolicyVersion": {}, - "SetDefaultPolicyVersion": {}, - "AttachUserPolicy": {}, - "DetachUserPolicy": {}, - "AttachRolePolicy": {}, - "DetachRolePolicy": {}, - "ListAttachedUserPolicies": {}, - "ListAttachedRolePolicies": {}, - "ListEntitiesForPolicy": {}, - "CreateGroup": {}, - "DeleteGroup": {}, - "GetGroup": {}, - "ListGroups": {}, - "AddUserToGroup": {}, - "RemoveUserFromGroup": {}, - "ListGroupsForUser": {}, - "CreateAccessKey": {}, - "DeleteAccessKey": {}, - "ListAccessKeys": {}, - "UpdateAccessKey": {}, - "CreateInstanceProfile": {}, - "DeleteInstanceProfile": {}, - "GetInstanceProfile": {}, - "ListInstanceProfiles": {}, - "ListInstanceProfilesForRole": {}, - "AddRoleToInstanceProfile": {}, - "RemoveRoleFromInstanceProfile": {}, - "PutRolePolicy": {}, - "GetRolePolicy": {}, - "DeleteRolePolicy": {}, - "ListRolePolicies": {}, - "AttachGroupPolicy": {}, - "DetachGroupPolicy": {}, - "ListAttachedGroupPolicies": {}, - "PutGroupPolicy": {}, - "GetGroupPolicy": {}, - "DeleteGroupPolicy": {}, - "ListGroupPolicies": {}, - "PutUserPolicy": {}, - "GetUserPolicy": {}, - "DeleteUserPolicy": {}, - "ListUserPolicies": {}, - "TagRole": {}, - "UntagRole": {}, - "ListRoleTags": {}, - "TagUser": {}, - "UntagUser": {}, - "ListUserTags": {}, - "GetAccountAuthorizationDetails": {}, - "CreateServiceLinkedRole": {}, - "PutRolePermissionsBoundary": {}, - "DeleteRolePermissionsBoundary": {}, - "PutUserPermissionsBoundary": {}, - "DeleteUserPermissionsBoundary": {}, - "SimulatePrincipalPolicy": {}, - "SimulateCustomPolicy": {}, - "GetAccountSummary": {}, - "GetAccountPasswordPolicy": {}, - "UpdateAccountPasswordPolicy": {}, - "DeleteAccountPasswordPolicy": {}, - "CreateVirtualMFADevice": {}, - "ListMFADevices": {}, - "EnableMFADevice": {}, - "DeactivateMFADevice": {}, - "DeleteVirtualMFADevice": {}, - "ListVirtualMFADevices": {}, +// iamActions maps every Action this handler serves to the IAM resource type +// it is authorized on, as listed under "Actions defined by AWS Identity and +// Access Management (IAM)". Matches uses it to decide whether to claim a +// request. +// +//nolint:gochecknoglobals,goconst // static lookup table of the operation names the dispatch switch lists +var iamActions = map[string]entityKind{ + "CreateUser": kindUser, + "DeleteUser": kindUser, + "GetUser": kindUser, + "ListUsers": kindAccount, + "CreateRole": kindRole, + "DeleteRole": kindRole, + "GetRole": kindRole, + "ListRoles": kindAccount, + "UpdateRole": kindRole, + "UpdateRoleDescription": kindRole, + "UpdateAssumeRolePolicy": kindRole, + "CreatePolicy": kindPolicyName, + "DeletePolicy": kindPolicyARN, + "GetPolicy": kindPolicyARN, + "ListPolicies": kindAccount, + "CreatePolicyVersion": kindPolicyARN, + "GetPolicyVersion": kindPolicyARN, + "ListPolicyVersions": kindPolicyARN, + "DeletePolicyVersion": kindPolicyARN, + "SetDefaultPolicyVersion": kindPolicyARN, + "AttachUserPolicy": kindUser, + "DetachUserPolicy": kindUser, + "AttachRolePolicy": kindRole, + "DetachRolePolicy": kindRole, + "ListAttachedUserPolicies": kindUser, + "ListAttachedRolePolicies": kindRole, + "ListEntitiesForPolicy": kindPolicyARN, + "CreateGroup": kindGroup, + "DeleteGroup": kindGroup, + "GetGroup": kindGroup, + "ListGroups": kindAccount, + "AddUserToGroup": kindGroup, + "RemoveUserFromGroup": kindGroup, + "ListGroupsForUser": kindUser, + "CreateAccessKey": kindUser, + "DeleteAccessKey": kindUser, + "ListAccessKeys": kindUser, + "UpdateAccessKey": kindUser, + "CreateInstanceProfile": kindInstanceProfile, + "DeleteInstanceProfile": kindInstanceProfile, + "GetInstanceProfile": kindInstanceProfile, + "ListInstanceProfiles": kindAccount, + "ListInstanceProfilesForRole": kindRole, + "AddRoleToInstanceProfile": kindInstanceProfile, + "RemoveRoleFromInstanceProfile": kindInstanceProfile, + "PutRolePolicy": kindRole, + "GetRolePolicy": kindRole, + "DeleteRolePolicy": kindRole, + "ListRolePolicies": kindRole, + "AttachGroupPolicy": kindGroup, + "DetachGroupPolicy": kindGroup, + "ListAttachedGroupPolicies": kindGroup, + "PutGroupPolicy": kindGroup, + "GetGroupPolicy": kindGroup, + "DeleteGroupPolicy": kindGroup, + "ListGroupPolicies": kindGroup, + "PutUserPolicy": kindUser, + "GetUserPolicy": kindUser, + "DeleteUserPolicy": kindUser, + "ListUserPolicies": kindUser, + "TagRole": kindRole, + "UntagRole": kindRole, + "ListRoleTags": kindRole, + "TagUser": kindUser, + "UntagUser": kindUser, + "ListUserTags": kindUser, + "GetAccountAuthorizationDetails": kindAccount, + "CreateServiceLinkedRole": kindUnnamed, + "PutRolePermissionsBoundary": kindRole, + "DeleteRolePermissionsBoundary": kindRole, + "PutUserPermissionsBoundary": kindUser, + "DeleteUserPermissionsBoundary": kindUser, + "SimulatePrincipalPolicy": kindPolicySource, + "SimulateCustomPolicy": kindAccount, + "GetAccountSummary": kindAccount, + "GetAccountPasswordPolicy": kindAccount, + "UpdateAccountPasswordPolicy": kindAccount, + "DeleteAccountPasswordPolicy": kindAccount, + "CreateVirtualMFADevice": kindMFAName, + "ListMFADevices": kindUser, + "EnableMFADevice": kindUser, + "DeactivateMFADevice": kindUser, + "DeleteVirtualMFADevice": kindMFASerial, + "ListVirtualMFADevices": kindAccount, } // roleTagManager is the AWS-specific role-tagging surface, asserted against the @@ -408,10 +411,3 @@ func writeErr(w http.ResponseWriter, err error) { // 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/sns/authz.go b/server/aws/sns/authz.go new file mode 100644 index 000000000..e542867e8 --- /dev/null +++ b/server/aws/sns/authz.go @@ -0,0 +1,105 @@ +package sns + +import ( + "net/http" + "regexp" + "strings" + + "github.com/stackshy/cloudemu/v2/server/wire/awsauthz" +) + +// topicRef says where an operation names the topic it is authorized on. +type topicRef int + +const ( + refNoTopic topicRef = iota // account-level, Resource "*" + refTopicName // Name (CreateTopic) + refTopicARN // TopicArn + refPublishTarget // TopicArn, else TargetArn + refResourceARN // ResourceArn (tagging) + refSubscriptionARN // SubscriptionArn: the subscription's topic +) + +// topicName is the shape of an SNS topic name. +var topicName = regexp.MustCompile(`^[A-Za-z0-9_-]{1,256}(\.fifo)?$`) + +// subscriptionARNFields is the field count of a subscription ARN +// (arn:aws:sns:region:account:topic:id), whose topic is field 5. +const ( + subscriptionARNFields = 7 + subscriptionTopicIdx = 5 +) + +// IAMChecks names the IAM action of a request from the form Action that +// ServeHTTP dispatches on, and the topic it runs on. The topic is the one the +// handler resolves (the last field of TopicArn, TargetArn or ResourceArn, or +// the topic field of a SubscriptionArn), in the server's account and region. +// PublishBatch is authorized as sns:Publish, the action AWS checks for it. A +// request that names no well-formed topic is evaluated on an unknown +// resource. 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, s awsauthz.Scope) ([]awsauthz.Check, bool) { + checks, ok := awsauthz.QueryChecks(r, h.IAMService()) + if !ok { + return nil, false + } + + op := r.Form.Get("Action") + + ref, known := snsActions[op] + if !known { + return checks, true + } + + checks[0].Action = h.IAMService() + ":" + strings.TrimSuffix(op, "Batch") + checks[0].Resource = topicResource(r, ref, s) + + return checks, true +} + +// topicResource is the topic ARN an operation runs on, "*" for an operation +// on no topic, or "" when the request names none. +func topicResource(r *http.Request, ref topicRef, s awsauthz.Scope) string { + var name string + + switch ref { + case refNoTopic: + return "*" + case refTopicName: + name = r.Form.Get("Name") + case refTopicARN: + name = topicNameFromARN(r.Form.Get("TopicArn")) + case refPublishTarget: + name = topicNameFromARN(publishTarget(r)) + case refResourceARN: + name = topicNameFromARN(r.Form.Get("ResourceArn")) + case refSubscriptionARN: + name = subscriptionTopic(r.Form.Get("SubscriptionArn")) + } + + if !topicName.MatchString(name) { + return "" + } + + return s.ARN("sns", name) +} + +// publishTarget is the ARN Publish and PublishBatch address, read as the +// handler reads it: TopicArn, else TargetArn. +func publishTarget(r *http.Request) string { + if arn := r.Form.Get("TopicArn"); arn != "" { + return arn + } + + return r.Form.Get("TargetArn") +} + +// subscriptionTopic is the topic field of a subscription ARN, or "". +func subscriptionTopic(arn string) string { + parts := strings.Split(arn, ":") + if len(parts) != subscriptionARNFields { + return "" + } + + return parts[subscriptionTopicIdx] +} diff --git a/server/aws/sns/authz_test.go b/server/aws/sns/authz_test.go new file mode 100644 index 000000000..b1804f437 --- /dev/null +++ b/server/aws/sns/authz_test.go @@ -0,0 +1,82 @@ +package sns_test + +import ( + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + + "github.com/stackshy/cloudemu/v2/server/aws/sns" + "github.com/stackshy/cloudemu/v2/server/wire/awsauthz" +) + +const ( + t1ARN = "arn:aws:sns:us-east-1:123456789012:t1" + t1Sub = t1ARN + ":sub-abc" + formCT = "application/x-www-form-urlencoded" +) + +func snsForm(values url.Values) *http.Request { + r := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(values.Encode())) + r.Header.Set("Content-Type", formCT) + + return r +} + +func TestIAMChecksTopicARNs(t *testing.T) { + scope := awsauthz.Scope{AccountID: "123456789012", Region: "us-east-1", Partition: "aws"} + + cases := []struct { + action, param, value, wantAction, wantResource string + }{ + {"CreateTopic", "Name", "t1", "sns:CreateTopic", t1ARN}, + {"DeleteTopic", "TopicArn", t1ARN, "sns:DeleteTopic", t1ARN}, + {"GetTopicAttributes", "TopicArn", t1ARN, "sns:GetTopicAttributes", t1ARN}, + {"SetTopicAttributes", "TopicArn", t1ARN, "sns:SetTopicAttributes", t1ARN}, + {"ListTopics", "", "", "sns:ListTopics", "*"}, + {"Subscribe", "TopicArn", t1ARN, "sns:Subscribe", t1ARN}, + {"ConfirmSubscription", "TopicArn", t1ARN, "sns:ConfirmSubscription", t1ARN}, + {"ListSubscriptions", "", "", "sns:ListSubscriptions", "*"}, + {"ListSubscriptionsByTopic", "TopicArn", t1ARN, "sns:ListSubscriptionsByTopic", t1ARN}, + {"Publish", "TopicArn", t1ARN, "sns:Publish", t1ARN}, + {"Publish", "TargetArn", t1ARN, "sns:Publish", t1ARN}, + {"PublishBatch", "TopicArn", t1ARN, "sns:Publish", t1ARN}, + {"PublishBatch", "TargetArn", t1ARN, "sns:Publish", t1ARN}, + {"AddPermission", "TopicArn", t1ARN, "sns:AddPermission", t1ARN}, + {"RemovePermission", "TopicArn", t1ARN, "sns:RemovePermission", t1ARN}, + {"TagResource", "ResourceArn", t1ARN, "sns:TagResource", t1ARN}, + {"UntagResource", "ResourceArn", t1ARN, "sns:UntagResource", t1ARN}, + {"ListTagsForResource", "ResourceArn", t1ARN, "sns:ListTagsForResource", t1ARN}, + // Subscription operations are authorized on the subscription's topic. + {"Unsubscribe", "SubscriptionArn", t1Sub, "sns:Unsubscribe", t1ARN}, + {"GetSubscriptionAttributes", "SubscriptionArn", t1Sub, "sns:GetSubscriptionAttributes", t1ARN}, + {"SetSubscriptionAttributes", "SubscriptionArn", t1Sub, "sns:SetSubscriptionAttributes", t1ARN}, + // The handler runs on the topic named by the last ARN field, so that is + // the topic authorized, in the server's own account and region. + {"Publish", "TopicArn", "arn:aws:sns:eu-west-1:999999999999:t1", "sns:Publish", t1ARN}, + // Nothing named: unknown. + {"Publish", "", "", "sns:Publish", ""}, + {"DeleteTopic", "TopicArn", "arn:aws:sns:us-east-1:123456789012:", "sns:DeleteTopic", ""}, + {"Unsubscribe", "SubscriptionArn", "sub-abc", "sns:Unsubscribe", ""}, + {"CreateTopic", "Name", "bad name", "sns:CreateTopic", ""}, + } + + h := sns.New(nil) + + for _, tc := range cases { + v := url.Values{"Action": {tc.action}} + if tc.param != "" { + v.Set(tc.param, tc.value) + } + + checks, ok := h.IAMChecks(snsForm(v), scope) + if !ok || len(checks) != 1 || checks[0].Action != tc.wantAction || checks[0].Resource != tc.wantResource { + t.Errorf("%s %s=%s: got %+v ok=%v, want %s on %q", tc.action, tc.param, tc.value, checks, ok, tc.wantAction, tc.wantResource) + } + } + + if checks, ok := h.IAMChecks(snsForm(url.Values{"Version": {"1"}}), scope); ok { + t.Errorf("no Action: got %+v, want ok=false", checks) + } +} diff --git a/server/aws/sns/handler.go b/server/aws/sns/handler.go index 973840767..3db50e1f6 100644 --- a/server/aws/sns/handler.go +++ b/server/aws/sns/handler.go @@ -35,7 +35,6 @@ 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" ) @@ -55,29 +54,32 @@ const ( attrFalse = "false" ) -// snsActions is the set of Action values this handler recognizes. Matches uses -// it to decide whether to claim a request. Disjoint from RDS / Redshift / IAM / -// EC2 / ElastiCache action sets. -var snsActions = map[string]struct{}{ //nolint:gochecknoglobals // static lookup table - "CreateTopic": {}, - "DeleteTopic": {}, - "GetTopicAttributes": {}, - "SetTopicAttributes": {}, - "ListTopics": {}, - "Subscribe": {}, - "Unsubscribe": {}, - "ConfirmSubscription": {}, - "GetSubscriptionAttributes": {}, - "SetSubscriptionAttributes": {}, - "ListSubscriptions": {}, - "ListSubscriptionsByTopic": {}, - "Publish": {}, - "PublishBatch": {}, - "AddPermission": {}, - "RemovePermission": {}, - "TagResource": {}, - "UntagResource": {}, - actionListTagsForResource: {}, +// snsActions maps every Action this handler serves to where the request +// names the topic it is authorized on, as listed under "Actions defined by +// Amazon SNS". Matches uses it to decide whether to claim a request. Disjoint +// from RDS / Redshift / IAM / EC2 / ElastiCache action sets. +// +//nolint:gochecknoglobals,goconst // static lookup table of the operation names the dispatch switch lists +var snsActions = map[string]topicRef{ + "CreateTopic": refTopicName, + "DeleteTopic": refTopicARN, + "GetTopicAttributes": refTopicARN, + "SetTopicAttributes": refTopicARN, + "ListTopics": refNoTopic, + "Subscribe": refTopicARN, + "Unsubscribe": refSubscriptionARN, + "ConfirmSubscription": refTopicARN, + "GetSubscriptionAttributes": refSubscriptionARN, + "SetSubscriptionAttributes": refSubscriptionARN, + "ListSubscriptions": refNoTopic, + "ListSubscriptionsByTopic": refTopicARN, + "Publish": refPublishTarget, + "PublishBatch": refPublishTarget, + "AddPermission": refTopicARN, + "RemovePermission": refTopicARN, + "TagResource": refResourceARN, + "UntagResource": refResourceARN, + actionListTagsForResource: refResourceARN, } // actionListTagsForResource is the generic tag-read verb SNS shares with other @@ -271,10 +273,3 @@ func defaultTopicPolicy(arn, owner string) string { // 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/sns/operations.go b/server/aws/sns/operations.go index 5afad9606..5649e4637 100644 --- a/server/aws/sns/operations.go +++ b/server/aws/sns/operations.go @@ -645,10 +645,7 @@ func (h *Handler) listSubscriptionsByTopic(w http.ResponseWriter, r *http.Reques func (h *Handler) publish(w http.ResponseWriter, r *http.Request) { // SNS accepts either TopicArn or TargetArn to address the destination. - arn := r.Form.Get("TopicArn") - if arn == "" { - arn = r.Form.Get("TargetArn") - } + arn := publishTarget(r) attrs := parseMessageAttributes(r.Form) diff --git a/server/aws/sns/subscription_attributes.go b/server/aws/sns/subscription_attributes.go index 47747feca..54099bf0e 100644 --- a/server/aws/sns/subscription_attributes.go +++ b/server/aws/sns/subscription_attributes.go @@ -240,10 +240,7 @@ func (h *Handler) removePermission(w http.ResponseWriter, r *http.Request) { // publishBatch fans PublishBatchRequestEntries out to individual driver // Publish calls, collecting per-entry success/failure results. func (h *Handler) publishBatch(w http.ResponseWriter, r *http.Request) { - arn := r.Form.Get("TopicArn") - if arn == "" { - arn = r.Form.Get("TargetArn") - } + arn := publishTarget(r) topicID := topicNameFromARN(arn) diff --git a/server/aws/sqs/authz.go b/server/aws/sqs/authz.go new file mode 100644 index 000000000..daac1c47e --- /dev/null +++ b/server/aws/sqs/authz.go @@ -0,0 +1,146 @@ +package sqs + +import ( + "encoding/base64" + "encoding/json" + "net/http" + "regexp" + "strings" + + "github.com/stackshy/cloudemu/v2/server/wire/awsauthz" +) + +// queueRef says where an operation names its queue. +type queueRef int + +const ( + refQueueURL queueRef = iota // QueueUrl + refQueueName // QueueName (CreateQueue, GetQueueUrl) + refSourceArn // SourceArn (message move tasks) + refTaskHandle // TaskHandle, which carries the source ARN + refNoResource // account-level, Resource "*" +) + +// queueOps maps every operation ServeHTTP dispatches to where it names its +// queue, as listed under "Actions defined by Amazon SQS". The IAM action is +// the operation name, except that a batch operation has no action of its +// own: AWS authorizes SendMessageBatch as sqs:SendMessage, and so on. +// +//nolint:gochecknoglobals,goconst // static lookup table of the operation names the dispatch switch lists +var queueOps = map[string]queueRef{ + "CreateQueue": refQueueName, + "GetQueueUrl": refQueueName, + "ListQueues": refNoResource, + "DeleteQueue": refQueueURL, + "SendMessage": refQueueURL, + "SendMessageBatch": refQueueURL, + "ReceiveMessage": refQueueURL, + "DeleteMessage": refQueueURL, + "DeleteMessageBatch": refQueueURL, + "ChangeMessageVisibility": refQueueURL, + "ChangeMessageVisibilityBatch": refQueueURL, + "ListDeadLetterSourceQueues": refQueueURL, + "GetQueueAttributes": refQueueURL, + "SetQueueAttributes": refQueueURL, + "PurgeQueue": refQueueURL, + "TagQueue": refQueueURL, + "UntagQueue": refQueueURL, + "ListQueueTags": refQueueURL, + "AddPermission": refQueueURL, + "RemovePermission": refQueueURL, + "StartMessageMoveTask": refSourceArn, + "CancelMessageMoveTask": refTaskHandle, + "ListMessageMoveTasks": refSourceArn, +} + +// arnFields is the field count of an SQS queue ARN +// (arn:partition:sqs:region:account:name). +const arnFields = 6 + +// queueName is the shape of an SQS queue name. +var queueName = regexp.MustCompile(`^[A-Za-z0-9_-]{1,80}(\.fifo)?$`) + +// IAMChecks names the IAM action and queue of a request from the +// X-Amz-Target ServeHTTP dispatches on. The queue ARN is built in the +// server's account and region from the queue name the request carries; a +// request that names no well-formed queue is evaluated on an unknown +// resource. An operation the handler does not serve returns ok=false, and +// ServeHTTP answers it with UnknownOperationException. +func (*Handler) IAMChecks(r *http.Request, s awsauthz.Scope) ([]awsauthz.Check, bool) { + op := strings.TrimPrefix(r.Header.Get("X-Amz-Target"), targetPrefix) + + ref, ok := queueOps[op] + if !ok { + return nil, false + } + + action := iamService + ":" + strings.TrimSuffix(op, "Batch") + if ref == refNoResource { + return awsauthz.Single(action, "*"), true + } + + var req struct { + QueueURL string `json:"QueueUrl"` + QueueName string `json:"QueueName"` + SourceArn string `json:"SourceArn"` + TaskHandle string `json:"TaskHandle"` + } + + if !awsauthz.JSONBody(r, &req) { + return awsauthz.Single(action, ""), true + } + + var name string + + switch ref { + case refQueueURL: + name = lastField(req.QueueURL, "/") + case refQueueName: + name = req.QueueName + case refSourceArn: + name = queueFromARN(req.SourceArn) + case refTaskHandle: + name = queueFromARN(taskSource(req.TaskHandle)) + case refNoResource: + } + + if !queueName.MatchString(name) { + return awsauthz.Single(action, ""), true + } + + return awsauthz.Single(action, s.ARN(iamService, name)), true +} + +// lastField is the part of s after the last sep. +func lastField(s, sep string) string { + return s[strings.LastIndex(s, sep)+1:] +} + +// queueFromARN is the queue name of an SQS queue ARN, or "". +func queueFromARN(arn string) string { + parts := strings.Split(arn, ":") + if len(parts) != arnFields || parts[0] != "arn" || parts[2] != iamService { + return "" + } + + return parts[arnFields-1] +} + +// taskSource is the source queue ARN a message move task handle carries +// (base64 JSON, as the provider issues it), or "". +func taskSource(handle string) string { + raw, err := base64.StdEncoding.DecodeString(handle) + if err != nil { + return "" + } + + var h struct { + SourceArn string `json:"sourceArn"` + } + + if json.Unmarshal(raw, &h) != nil { + return "" + } + + return h.SourceArn +} diff --git a/server/aws/sqs/authz_test.go b/server/aws/sqs/authz_test.go new file mode 100644 index 000000000..39ccb690b --- /dev/null +++ b/server/aws/sqs/authz_test.go @@ -0,0 +1,98 @@ +package sqs_test + +import ( + "encoding/base64" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/stackshy/cloudemu/v2/server/aws/sqs" + "github.com/stackshy/cloudemu/v2/server/wire/awsauthz" +) + +var testScope = awsauthz.Scope{AccountID: "123456789012", Region: "us-east-1", Partition: "aws"} //nolint:gochecknoglobals // test fixture + +const ( + q1ARN = "arn:aws:sqs:us-east-1:123456789012:q1" + q1URL = "https://sqs.us-east-1.amazonaws.com/123456789012/q1" +) + +func sqsRequest(op, body string) *http.Request { + r := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(body)) + r.Header.Set("X-Amz-Target", "AmazonSQS."+op) + r.Header.Set("Content-Type", "application/x-amz-json-1.0") + + return r +} + +func TestIAMChecksQueueARNs(t *testing.T) { + onQ1 := `{"QueueUrl":"` + q1URL + `"}` + handle := base64.StdEncoding.EncodeToString([]byte(`{"taskId":"mmt-1","sourceArn":"` + q1ARN + `"}`)) + + cases := []struct { + op, body, action, resource string + }{ + {"CreateQueue", `{"QueueName":"q1"}`, "sqs:CreateQueue", q1ARN}, + {"CreateQueue", `{"QueueName":"q1.fifo"}`, "sqs:CreateQueue", q1ARN + ".fifo"}, + {"GetQueueUrl", `{"QueueName":"q1"}`, "sqs:GetQueueUrl", q1ARN}, + {"ListQueues", `{}`, "sqs:ListQueues", "*"}, + {"DeleteQueue", onQ1, "sqs:DeleteQueue", q1ARN}, + {"SendMessage", onQ1, "sqs:SendMessage", q1ARN}, + {"SendMessageBatch", onQ1, "sqs:SendMessage", q1ARN}, + {"ReceiveMessage", onQ1, "sqs:ReceiveMessage", q1ARN}, + {"DeleteMessage", onQ1, "sqs:DeleteMessage", q1ARN}, + {"DeleteMessageBatch", onQ1, "sqs:DeleteMessage", q1ARN}, + {"ChangeMessageVisibility", onQ1, "sqs:ChangeMessageVisibility", q1ARN}, + {"ChangeMessageVisibilityBatch", onQ1, "sqs:ChangeMessageVisibility", q1ARN}, + {"ListDeadLetterSourceQueues", onQ1, "sqs:ListDeadLetterSourceQueues", q1ARN}, + {"GetQueueAttributes", onQ1, "sqs:GetQueueAttributes", q1ARN}, + {"SetQueueAttributes", onQ1, "sqs:SetQueueAttributes", q1ARN}, + {"PurgeQueue", onQ1, "sqs:PurgeQueue", q1ARN}, + {"TagQueue", onQ1, "sqs:TagQueue", q1ARN}, + {"UntagQueue", onQ1, "sqs:UntagQueue", q1ARN}, + {"ListQueueTags", onQ1, "sqs:ListQueueTags", q1ARN}, + {"AddPermission", onQ1, "sqs:AddPermission", q1ARN}, + {"RemovePermission", onQ1, "sqs:RemovePermission", q1ARN}, + {"StartMessageMoveTask", `{"SourceArn":"` + q1ARN + `"}`, "sqs:StartMessageMoveTask", q1ARN}, + {"ListMessageMoveTasks", `{"SourceArn":"` + q1ARN + `"}`, "sqs:ListMessageMoveTasks", q1ARN}, + {"CancelMessageMoveTask", `{"TaskHandle":"` + handle + `"}`, "sqs:CancelMessageMoveTask", q1ARN}, + // The account and region always come from the server, never the URL. + {"SendMessage", `{"QueueUrl":"https://sqs.eu-west-1.amazonaws.com/999999999999/q1"}`, "sqs:SendMessage", q1ARN}, + {"StartMessageMoveTask", `{"SourceArn":"arn:aws:sqs:eu-west-1:999999999999:q1"}`, "sqs:StartMessageMoveTask", q1ARN}, + // A queue the request does not name cleanly stays unknown. + {"SendMessage", `{}`, "sqs:SendMessage", ""}, + {"SendMessage", `{"QueueUrl":"https://sqs.us-east-1.amazonaws.com/123456789012/"}`, "sqs:SendMessage", ""}, + {"SendMessage", `{"QueueUrl":"https://sqs.us-east-1.amazonaws.com/123456789012/q*"}`, "sqs:SendMessage", ""}, + {"CreateQueue", `{"QueueName":"bad name"}`, "sqs:CreateQueue", ""}, + {"StartMessageMoveTask", `{"SourceArn":"q1"}`, "sqs:StartMessageMoveTask", ""}, + {"CancelMessageMoveTask", `{"TaskHandle":"not-base64!"}`, "sqs:CancelMessageMoveTask", ""}, + {"SendMessage", `{"QueueUrl":`, "sqs:SendMessage", ""}, + } + + h := sqs.New(nil) + + for _, tc := range cases { + r := sqsRequest(tc.op, tc.body) + + checks, ok := h.IAMChecks(r, testScope) + if !ok || len(checks) != 1 || checks[0].Action != tc.action || checks[0].Resource != tc.resource { + t.Errorf("%s %s: got %+v ok=%v, want %s on %q", tc.op, tc.body, checks, ok, tc.action, tc.resource) + } + + if rest, _ := io.ReadAll(r.Body); string(rest) != tc.body { + t.Errorf("%s: body not restored: %q", tc.op, rest) + } + } +} + +func TestIAMChecksUnknownOperation(t *testing.T) { + h := sqs.New(nil) + + for _, op := range []string{"", "Bogus", "sendmessage"} { + if checks, ok := h.IAMChecks(sqsRequest(op, `{}`), testScope); ok { + t.Errorf("%q: got %+v, want ok=false", op, checks) + } + } +} diff --git a/server/aws/sqs/handler.go b/server/aws/sqs/handler.go index 87073938e..81a1b4b14 100644 --- a/server/aws/sqs/handler.go +++ b/server/aws/sqs/handler.go @@ -23,6 +23,9 @@ import ( const targetPrefix = "AmazonSQS." +// iamService is the IAM service prefix of SQS actions. +const iamService = "sqs" + // errNonExistentQueue is the __type value for a missing queue: "QueueDoesNotExist" // is the modeled AwsJson1_0 shape name, letting the SDK deserialize the body into // the typed sqs types.QueueDoesNotExist exception. SQS additionally carries the @@ -1211,4 +1214,4 @@ func writeErr(w http.ResponseWriter, err error) { // IAMService returns the IAM service prefix of the operations this handler // serves. -func (*Handler) IAMService() string { return "sqs" } +func (*Handler) IAMService() string { return iamService } diff --git a/server/wire/awsauthz/resource.go b/server/wire/awsauthz/resource.go new file mode 100644 index 000000000..93e051c7e --- /dev/null +++ b/server/wire/awsauthz/resource.go @@ -0,0 +1,40 @@ +package awsauthz + +import ( + "bytes" + "encoding/json" + "io" + "net/http" +) + +// ARN returns the ARN of resource in service, in the scope's partition, +// region and account. +func (s Scope) ARN(service, resource string) string { + return "arn:" + s.Partition + ":" + service + ":" + s.Region + ":" + s.AccountID + ":" + resource +} + +// GlobalARN returns the ARN of resource in service for a resource with no +// region (IAM entities, DynamoDB global tables). +func (s Scope) GlobalARN(service, resource string) string { + return "arn:" + s.Partition + ":" + service + "::" + s.AccountID + ":" + resource +} + +// JSONBody decodes the request body into v the way the JSON-RPC handlers' +// dispatch does (encoding/json, first value only), and puts the body back so +// the handler reads the same bytes. It returns false when the body does not +// decode; the handler then answers with a serialization error and runs +// nothing. +func JSONBody(r *http.Request, v any) bool { + if r.Body == nil { + return false + } + + raw, err := io.ReadAll(r.Body) + r.Body = io.NopCloser(bytes.NewReader(raw)) + + if err != nil { + return false + } + + return json.NewDecoder(bytes.NewReader(raw)).Decode(v) == nil +} diff --git a/server/wire/awsauthz/resource_test.go b/server/wire/awsauthz/resource_test.go new file mode 100644 index 000000000..75d829a86 --- /dev/null +++ b/server/wire/awsauthz/resource_test.go @@ -0,0 +1,39 @@ +package awsauthz + +import ( + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestScopeARNs(t *testing.T) { + s := Scope{AccountID: "123456789012", Region: "us-east-1", Partition: "aws"} + + assert.Equal(t, "arn:aws:sqs:us-east-1:123456789012:q1", s.ARN("sqs", "q1")) + assert.Equal(t, "arn:aws:iam::123456789012:user/a", s.GlobalARN("iam", "user/a")) +} + +func TestJSONBody(t *testing.T) { + var v struct { + TableName string `json:"TableName"` + } + + r := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(`{"TableName":"t1"} trailing`)) + require.True(t, JSONBody(r, &v)) + assert.Equal(t, "t1", v.TableName) + + rest, err := io.ReadAll(r.Body) + require.NoError(t, err) + assert.Equal(t, `{"TableName":"t1"} trailing`, string(rest), "the body is put back whole") + + assert.False(t, JSONBody(httptest.NewRequest(http.MethodPost, "/", strings.NewReader(`{`)), &v)) + + r = httptest.NewRequest(http.MethodPost, "/", nil) + r.Body = nil + assert.False(t, JSONBody(r, &v)) +}