From a978c6b702b001459921435ffef83a474740f0a4 Mon Sep 17 00:00:00 2001 From: Suryansh Prajapati <58465650+drk1rd@users.noreply.github.com> Date: Wed, 14 Oct 2026 23:16:15 +0530 Subject: [PATCH 1/2] IAM password policy and account alias, KMS ListRetirableGrants, EventBridge UpdateEventBus, DynamoDB Contributor Insights IAM: UpdateAccountPasswordPolicy and DeleteAccountPasswordPolicy with AWS defaults and range checks; GetAccountPasswordPolicy returns the stored policy. CreateAccountAlias and DeleteAccountAlias with AWS's alias rules; ListAccountAliases returns it. Both are kept in the state file. KMS: ListRetirableGrants across all keys, paginated, authorized on *. EventBridge: UpdateEventBus (description, dead-letter queue with the sqs:SendMessage check, KMS key), including the default bus. CloudWatch Logs: query definitions validate maxResults; tests cover update, pagination and persistence. DynamoDB: UpdateContributorInsights and ListContributorInsights; DescribeContributorInsights reports the stored status per table and GSI. Settings go with the table or index when it is deleted. Docs and changelog updated. --- CHANGELOG.md | 8 + cli/internal/svc/cloudwatch/aws_test.go | 29 +++ cli/internal/svc/cloudwatch/logs_querydefs.go | 3 + cli/internal/svc/dynamodb/aws.go | 21 +- cli/internal/svc/dynamodb/aws_cli_test.go | 93 ++++++++ .../svc/dynamodb/contributor_insights.go | 207 ++++++++++++++++++ cli/internal/svc/dynamodb/table.go | 3 + cli/internal/svc/events/aws_test.go | 54 +++++ cli/internal/svc/events/events.go | 1 + cli/internal/svc/events/events_aws.go | 102 ++++++++- cli/internal/svc/events/native.go | 4 +- cli/internal/svc/iam/aws_test.go | 86 ++++++++ cli/internal/svc/iam/iam_aws.go | 3 +- cli/internal/svc/iam/iam_aws_account.go | 194 ++++++++++++++++ cli/internal/svc/iam/iam_aws_roles.go | 14 -- cli/internal/svc/kms/aws.go | 44 ++++ cli/internal/svc/kms/aws_test.go | 56 +++++ docs/aws-compat.md | 10 +- 18 files changed, 885 insertions(+), 47 deletions(-) create mode 100644 cli/internal/svc/dynamodb/contributor_insights.go create mode 100644 cli/internal/svc/iam/iam_aws_account.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 4c754238..8b89a900 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## Unreleased + +- IAM: `UpdateAccountPasswordPolicy`, `DeleteAccountPasswordPolicy` and a real `GetAccountPasswordPolicy` (the policy is stored and reported; console passwords are not yet checked against it), and `CreateAccountAlias`/`DeleteAccountAlias` with `ListAccountAliases` returning the alias ([#91](https://github.com/solinode/homecloud/issues/91), [#92](https://github.com/solinode/homecloud/issues/92)). +- KMS: `ListRetirableGrants` lists the grants a principal can retire across all keys ([#93](https://github.com/solinode/homecloud/issues/93)). +- EventBridge: `UpdateEventBus` changes a bus's description, dead-letter queue (checked for `sqs:SendMessage`) or KMS key, the default bus's included ([#94](https://github.com/solinode/homecloud/issues/94)). +- CloudWatch Logs: saved Logs Insights queries (`PutQueryDefinition`, `DescribeQueryDefinitions`, `DeleteQueryDefinition`) validate `maxResults` and are covered by update, pagination and restart tests ([#95](https://github.com/solinode/homecloud/issues/95)). +- DynamoDB: `UpdateContributorInsights` and `ListContributorInsights`; `DescribeContributorInsights` reports the stored setting of a table or GSI. No rules or metrics are produced ([#96](https://github.com/solinode/homecloud/issues/96)). + ## 0.5.0 (2026-10-14) Trust and operations: signed releases, safe upgrades with rollback, backups that restore everything, Prometheus metrics and a status page, and Testcontainers for Java and Node. diff --git a/cli/internal/svc/cloudwatch/aws_test.go b/cli/internal/svc/cloudwatch/aws_test.go index d01ed4f5..495b1022 100644 --- a/cli/internal/svc/cloudwatch/aws_test.go +++ b/cli/internal/svc/cloudwatch/aws_test.go @@ -6,6 +6,7 @@ import ( "net/http" "os" "os/exec" + "path/filepath" "strconv" "strings" "sync" @@ -14,6 +15,7 @@ import ( "github.com/homecloudhq/homecloud/cli/internal/awsapi" "github.com/homecloudhq/homecloud/cli/internal/awsapi/awstest" + "github.com/homecloudhq/homecloud/cli/internal/store" ) func newAWS(t *testing.T) (*awstest.Harness, *Service) { @@ -269,6 +271,33 @@ func TestLogsQueryDefinitions(t *testing.T) { if again := h.AWSJSON(t, "logs", "put-query-definition", "--query-definition-id", id, "--name", "app/errors", "--query-string", "fields @message")["queryDefinitionId"]; again != id { t.Fatalf("update returned %v", again) } + defs = h.AWSJSON(t, "logs", "describe-query-definitions", "--query-definition-name-prefix", "app/")["queryDefinitions"].([]any) + if d := defs[0].(map[string]any); len(defs) != 1 || d["queryString"] != "fields @message" || d["logGroupNames"] != nil || d["lastModified"].(float64) <= 0 { + t.Fatalf("after update: %v", defs) + } + if o, err := h.AWSErr(t, "logs", "put-query-definition", "--query-definition-id", "00000000-0000-0000-0000-000000000000", "--name", "x", "--query-string", "fields @message"); err == nil || !strings.Contains(o, "ResourceNotFoundException") { + t.Fatalf("update unknown id: %v %s", err, o) + } + + // Pagination. + p1 := h.AWSJSON(t, "logs", "describe-query-definitions", "--max-results", "1") + if len(p1["queryDefinitions"].([]any)) != 1 || p1["nextToken"] == nil { + t.Fatalf("page 1: %v", p1) + } + p2 := h.AWSJSON(t, "logs", "describe-query-definitions", "--max-results", "1", "--next-token", p1["nextToken"].(string)) + if l := p2["queryDefinitions"].([]any); len(l) != 1 || l[0].(map[string]any)["name"] != "other" { + t.Fatalf("page 2: %v", p2) + } + + // Definitions are stored, so they survive a restart. + st, err := store.Open(filepath.Join(h.Env.Cfg.DataDir, "state.json")) + if err != nil { + t.Fatal(err) + } + if defs := store.List[queryDef](st, cQueryDefs); len(defs) != 2 { + t.Fatalf("stored definitions: %v", defs) + } + if d := h.AWSJSON(t, "logs", "delete-query-definition", "--query-definition-id", id); d["success"] != true { t.Fatalf("delete: %v", d) } diff --git a/cli/internal/svc/cloudwatch/logs_querydefs.go b/cli/internal/svc/cloudwatch/logs_querydefs.go index 2e532f6d..2dbfed9d 100644 --- a/cli/internal/svc/cloudwatch/logs_querydefs.go +++ b/cli/internal/svc/cloudwatch/logs_querydefs.go @@ -95,6 +95,9 @@ func (s *Service) awsDescribeQueryDefinitions(q *awsapi.Req) (any, error) { if err := q.Authorize("logs:DescribeQueryDefinitions", "*"); err != nil { return nil, err } + if in.MaxResults < 0 || in.MaxResults > 1000 { + return nil, logsErr("InvalidParameterException", "maxResults must be 1-1000") + } all := store.List[queryDef](s.env.Store, cQueryDefs) slices.SortFunc(all, func(a, b queryDef) int { return strings.Compare(a.Name+a.ID, b.Name+b.ID) }) var out []map[string]any diff --git a/cli/internal/svc/dynamodb/aws.go b/cli/internal/svc/dynamodb/aws.go index 76570bf5..030f89da 100644 --- a/cli/internal/svc/dynamodb/aws.go +++ b/cli/internal/svc/dynamodb/aws.go @@ -41,6 +41,8 @@ func (s *Service) RegisterAWS() { "TransactGetItems": s.awsTransactGet, "DescribeKinesisStreamingDestination": s.awsDescribeKinesis, "DescribeContributorInsights": s.awsDescribeContributorInsights, + "UpdateContributorInsights": s.awsUpdateContributorInsights, + "ListContributorInsights": s.awsListContributorInsights, "ExecuteStatement": s.awsExecuteStatement, "BatchExecuteStatement": s.awsBatchExecuteStatement, "ExecuteTransaction": s.awsExecuteTransaction, @@ -685,6 +687,7 @@ func (s *Service) removeIndex(t *Table, tx *bolt.Tx, name string) error { if !found { return errf("ResourceNotFoundException", "Requested resource not found: Index: %s not found", name) } + delete(t.ContributorInsights, name) // Drop attribute definitions no key uses any more. cp := *t cp.Attributes = nil @@ -987,24 +990,6 @@ func (s *Service) awsDescribeKinesis(q *awsapi.Req) (any, error) { return map[string]any{"TableName": in.TableName, "KinesisDataStreamDestinations": []any{}}, nil } -func (s *Service) awsDescribeContributorInsights(q *awsapi.Req) (any, error) { - var in struct{ TableName, IndexName string } - if err := q.Bind(&in); err != nil { - return nil, err - } - if err := q.Authorize("dynamodb:DescribeContributorInsights", s.tableARN(in.TableName)); err != nil { - return nil, err - } - if _, err := s.getTable(in.TableName); err != nil { - return nil, err - } - res := map[string]any{"TableName": in.TableName, "ContributorInsightsStatus": "DISABLED"} - if in.IndexName != "" { - res["IndexName"] = in.IndexName - } - return res, nil -} - // ---- consumed capacity ---- func capacityUnits(bytes int, write bool, consistent bool) float64 { diff --git a/cli/internal/svc/dynamodb/aws_cli_test.go b/cli/internal/svc/dynamodb/aws_cli_test.go index 52c2c437..a996b0c4 100644 --- a/cli/internal/svc/dynamodb/aws_cli_test.go +++ b/cli/internal/svc/dynamodb/aws_cli_test.go @@ -2,10 +2,12 @@ package dynamodb import ( "encoding/json" + "path/filepath" "strings" "testing" "github.com/homecloudhq/homecloud/cli/internal/awsapi/awstest" + "github.com/homecloudhq/homecloud/cli/internal/store" ) func harness(t *testing.T) (*awstest.Harness, *Service) { @@ -52,6 +54,97 @@ func TestAWSWarmThroughput(t *testing.T) { } } +// Contributor Insights settings are stored and reported (aws_dynamodb_contributor_insights). +func TestAWSContributorInsights(t *testing.T) { + h, _ := harness(t) + for _, name := range []string{"one", "two"} { + h.AWS(t, "dynamodb", "create-table", "--table-name", name, + "--attribute-definitions", "AttributeName=id,AttributeType=S", "AttributeName=g,AttributeType=S", + "--key-schema", "AttributeName=id,KeyType=HASH", "--billing-mode", "PAY_PER_REQUEST", + "--global-secondary-indexes", `[{"IndexName":"ByG","KeySchema":[{"AttributeName":"g","KeyType":"HASH"}],"Projection":{"ProjectionType":"ALL"}}]`) + } + if d := h.AWSJSON(t, "dynamodb", "describe-contributor-insights", "--table-name", "one"); d["ContributorInsightsStatus"] != "DISABLED" { + t.Fatalf("initial describe: %v", d) + } + if l := h.AWSJSON(t, "dynamodb", "list-contributor-insights")["ContributorInsightsSummaries"].([]any); len(l) != 0 { + t.Fatalf("initial list: %v", l) + } + + up := h.AWSJSON(t, "dynamodb", "update-contributor-insights", "--table-name", "one", "--contributor-insights-action", "ENABLE") + if up["TableName"] != "one" || up["ContributorInsightsStatus"] != "ENABLED" || up["IndexName"] != nil { + t.Fatalf("update: %v", up) + } + d := h.AWSJSON(t, "dynamodb", "describe-contributor-insights", "--table-name", "one") + if d["ContributorInsightsStatus"] != "ENABLED" || d["LastUpdateDateTime"] == nil { + t.Fatalf("describe: %v", d) + } + up = h.AWSJSON(t, "dynamodb", "update-contributor-insights", "--table-name", "one", "--index-name", "ByG", "--contributor-insights-action", "ENABLE") + if up["IndexName"] != "ByG" || up["ContributorInsightsStatus"] != "ENABLED" { + t.Fatalf("update index: %v", up) + } + if d := h.AWSJSON(t, "dynamodb", "describe-contributor-insights", "--table-name", "one", "--index-name", "ByG"); d["ContributorInsightsStatus"] != "ENABLED" || d["IndexName"] != "ByG" { + t.Fatalf("describe index: %v", d) + } + h.AWS(t, "dynamodb", "update-contributor-insights", "--table-name", "two", "--contributor-insights-action", "ENABLE") + h.AWS(t, "dynamodb", "update-contributor-insights", "--table-name", "two", "--contributor-insights-action", "DISABLE") + + summary := func(m any) string { + s := m.(map[string]any) + ix, _ := s["IndexName"].(string) + return s["TableName"].(string) + "/" + ix + "=" + s["ContributorInsightsStatus"].(string) + } + l := h.AWSJSON(t, "dynamodb", "list-contributor-insights")["ContributorInsightsSummaries"].([]any) + if len(l) != 3 || summary(l[0]) != "one/=ENABLED" || summary(l[1]) != "one/ByG=ENABLED" || summary(l[2]) != "two/=DISABLED" { + t.Fatalf("list: %v", l) + } + if l := h.AWSJSON(t, "dynamodb", "list-contributor-insights", "--table-name", "two")["ContributorInsightsSummaries"].([]any); len(l) != 1 || summary(l[0]) != "two/=DISABLED" { + t.Fatalf("list u: %v", l) + } + p1 := h.AWSJSON(t, "dynamodb", "list-contributor-insights", "--max-results", "2") + if len(p1["ContributorInsightsSummaries"].([]any)) != 2 || p1["NextToken"] == nil { + t.Fatalf("page 1: %v", p1) + } + p2 := h.AWSJSON(t, "dynamodb", "list-contributor-insights", "--max-results", "2", "--next-token", p1["NextToken"].(string)) + if l := p2["ContributorInsightsSummaries"].([]any); len(l) != 1 || summary(l[0]) != "two/=DISABLED" || p2["NextToken"] != nil { + t.Fatalf("page 2: %v", p2) + } + + for _, args := range [][]string{ + {"--table-name", "nope", "--contributor-insights-action", "ENABLE"}, + {"--table-name", "one", "--index-name", "Nope", "--contributor-insights-action", "ENABLE"}, + } { + if out, err := h.AWSErr(t, append([]string{"dynamodb", "update-contributor-insights"}, args...)...); err == nil || !strings.Contains(out, "ResourceNotFoundException") { + t.Fatalf("%v: %v %s", args, err, out) + } + } + + // The setting survives a restart. + st, err := store.Open(filepath.Join(h.Env.Cfg.DataDir, "state.json")) + if err != nil { + t.Fatal(err) + } + if tb, err := store.Get[Table](st, cTables, "one"); err != nil || tb.ContributorInsights[""].Status != "ENABLED" || tb.ContributorInsights["ByG"].Status != "ENABLED" { + t.Fatalf("stored table: %+v %v", tb.ContributorInsights, err) + } + + // Deleting an index or the table removes its setting. + h.AWS(t, "dynamodb", "update-table", "--table-name", "one", "--global-secondary-index-updates", `[{"Delete":{"IndexName":"ByG"}}]`) + h.AWS(t, "dynamodb", "delete-table", "--table-name", "two") + l = h.AWSJSON(t, "dynamodb", "list-contributor-insights")["ContributorInsightsSummaries"].([]any) + if len(l) != 1 || summary(l[0]) != "one/=ENABLED" { + t.Fatalf("list after deletes: %v", l) + } + + // Authorized on the table (or index) ARN. + akid, secret := h.User(t, "ro", "ReadOnlyAccess") + if out, err := h.AWSAs(t, akid, secret, "", "dynamodb", "update-contributor-insights", "--table-name", "one", "--contributor-insights-action", "DISABLE"); err == nil || !strings.Contains(out, "AccessDenied") { + t.Fatalf("read-only update: %v %s", err, out) + } + if out, err := h.AWSAs(t, akid, secret, "", "dynamodb", "list-contributor-insights"); err != nil { + t.Fatalf("read-only list: %v %s", err, out) + } +} + func TestAWSCLI(t *testing.T) { h, _ := harness(t) out := h.AWSJSON(t, "dynamodb", "create-table", "--table-name", "Music", diff --git a/cli/internal/svc/dynamodb/contributor_insights.go b/cli/internal/svc/dynamodb/contributor_insights.go new file mode 100644 index 00000000..d59bd88a --- /dev/null +++ b/cli/internal/svc/dynamodb/contributor_insights.go @@ -0,0 +1,207 @@ +package dynamodb + +import ( + "slices" + "sort" + "strconv" + "time" + + "github.com/homecloudhq/homecloud/cli/internal/awsapi" + "github.com/homecloudhq/homecloud/cli/internal/store" + bolt "go.etcd.io/bbolt" +) + +// CloudWatch Contributor Insights for DynamoDB: UpdateContributorInsights, +// DescribeContributorInsights and ListContributorInsights. HomeCloud stores +// and reports the setting per table and per global secondary index; it +// creates no Contributor Insights rules and publishes no metrics. + +// ContributorInsights is the stored setting of a table or one of its indexes. +type ContributorInsights struct { + Status string `json:"status"` // ENABLED | DISABLED + Mode string `json:"mode,omitempty"` // ACCESSED_AND_THROTTLED_KEYS | THROTTLED_KEYS + UpdatedAt time.Time `json:"updated_at"` +} + +func (t *Table) hasGSI(name string) bool { + return slices.ContainsFunc(t.Indexes, func(ix Index) bool { return ix.Name == name }) +} + +// insights returns the stored setting of the table (index "") or an index. +func (t *Table) insights(index string) (ContributorInsights, bool) { + ci, ok := t.ContributorInsights[index] + if ok && index != "" && !t.hasGSI(index) { + return ContributorInsights{}, false // the index was deleted + } + return ci, ok +} + +func (s *Service) insightsResource(table, index string) string { + if index != "" { + return s.tableARN(table) + "/index/" + index + } + return s.tableARN(table) +} + +func indexNotFound(table, index string) error { + return errf("ResourceNotFoundException", "Requested resource not found: Index: %s not found for table %s", index, table) +} + +func (s *Service) awsUpdateContributorInsights(q *awsapi.Req) (any, error) { + var in struct { + TableName, IndexName, ContributorInsightsAction, ContributorInsightsMode string + } + if err := q.Bind(&in); err != nil { + return nil, err + } + if err := q.Authorize("dynamodb:UpdateContributorInsights", s.insightsResource(in.TableName, in.IndexName)); err != nil { + return nil, err + } + status := map[string]string{"ENABLE": "ENABLED", "DISABLE": "DISABLED"}[in.ContributorInsightsAction] + if status == "" { + return nil, validation("1 validation error detected: Value '%s' at 'contributorInsightsAction' failed to satisfy constraint: Member must satisfy enum value set: [ENABLE, DISABLE]", in.ContributorInsightsAction) + } + switch in.ContributorInsightsMode { + case "", "ACCESSED_AND_THROTTLED_KEYS", "THROTTLED_KEYS": + default: + return nil, validation("1 validation error detected: Value '%s' at 'contributorInsightsMode' failed to satisfy constraint: Member must satisfy enum value set: [ACCESSED_AND_THROTTLED_KEYS, THROTTLED_KEYS]", in.ContributorInsightsMode) + } + var ci ContributorInsights + _, err := s.alterTable(in.TableName, func(t *Table, _ *bolt.Tx) error { + if in.IndexName != "" && !t.hasGSI(in.IndexName) { + return indexNotFound(in.TableName, in.IndexName) + } + if t.ContributorInsights == nil { + t.ContributorInsights = map[string]ContributorInsights{} + } + ci, _ = t.insights(in.IndexName) + ci.Status, ci.UpdatedAt = status, s.now() + if in.ContributorInsightsMode != "" { + ci.Mode = in.ContributorInsightsMode + } else if ci.Mode == "" && status == "ENABLED" { + ci.Mode = "ACCESSED_AND_THROTTLED_KEYS" + } + t.ContributorInsights[in.IndexName] = ci + return nil + }) + if err != nil { + return nil, err + } + res := map[string]any{"TableName": in.TableName, "ContributorInsightsStatus": ci.Status} + if in.IndexName != "" { + res["IndexName"] = in.IndexName + } + if ci.Mode != "" { + res["ContributorInsightsMode"] = ci.Mode + } + return res, nil +} + +func (s *Service) awsDescribeContributorInsights(q *awsapi.Req) (any, error) { + var in struct{ TableName, IndexName string } + if err := q.Bind(&in); err != nil { + return nil, err + } + if err := q.Authorize("dynamodb:DescribeContributorInsights", s.insightsResource(in.TableName, in.IndexName)); err != nil { + return nil, err + } + t, err := s.getTable(in.TableName) + if err != nil { + return nil, err + } + if in.IndexName != "" && !t.hasGSI(in.IndexName) { + return nil, indexNotFound(in.TableName, in.IndexName) + } + res := map[string]any{"TableName": in.TableName, "ContributorInsightsStatus": "DISABLED"} + if in.IndexName != "" { + res["IndexName"] = in.IndexName + } + if ci, ok := t.insights(in.IndexName); ok { + res["ContributorInsightsStatus"] = ci.Status + res["LastUpdateDateTime"] = awsapi.Epoch(ci.UpdatedAt) + res["ContributorInsightsRuleList"] = []string{} + if ci.Mode != "" { + res["ContributorInsightsMode"] = ci.Mode + } + } + return res, nil +} + +func (s *Service) awsListContributorInsights(q *awsapi.Req) (any, error) { + var in struct { + TableName, NextToken string + MaxResults *int + } + if err := q.Bind(&in); err != nil { + return nil, err + } + resource := s.tableARN("*") + if in.TableName != "" { + resource = s.tableARN(in.TableName) + } + if err := q.Authorize("dynamodb:ListContributorInsights", resource); err != nil { + return nil, err + } + limit := 100 + if in.MaxResults != nil { + if *in.MaxResults < 0 || *in.MaxResults > 100 { + return nil, validation("1 validation error detected: Value '%d' at 'maxResults' failed to satisfy constraint: Member must have value less than or equal to 100", *in.MaxResults) + } + if *in.MaxResults > 0 { + limit = *in.MaxResults + } + } + start := 0 + if in.NextToken != "" { + n, err := strconv.Atoi(in.NextToken) + if err != nil || n < 0 { + return nil, validation("Invalid NextToken") + } + start = n + } + var tables []Table + if in.TableName != "" { + t, err := s.getTable(in.TableName) + if err != nil { + return nil, err + } + tables = []Table{*t} + } else { + tables = store.List[Table](s.env.Store, cTables) + sort.Slice(tables, func(i, j int) bool { return tables[i].Name < tables[j].Name }) + } + all := []map[string]any{} + for i := range tables { + t := &tables[i] + indexes := make([]string, 0, len(t.ContributorInsights)) + for ix := range t.ContributorInsights { + indexes = append(indexes, ix) + } + sort.Strings(indexes) // the table ("") first + for _, ix := range indexes { + ci, ok := t.insights(ix) + if !ok { + continue + } + m := map[string]any{"TableName": t.Name, "ContributorInsightsStatus": ci.Status} + if ix != "" { + m["IndexName"] = ix + } + if ci.Mode != "" { + m["ContributorInsightsMode"] = ci.Mode + } + all = append(all, m) + } + } + res := map[string]any{} + if start > len(all) { + start = len(all) + } + page := all[start:] + if len(page) > limit { + page = page[:limit] + res["NextToken"] = strconv.Itoa(start + limit) + } + res["ContributorInsightsSummaries"] = page + return res, nil +} diff --git a/cli/internal/svc/dynamodb/table.go b/cli/internal/svc/dynamodb/table.go index d7a10a4b..77ba9f43 100644 --- a/cli/internal/svc/dynamodb/table.go +++ b/cli/internal/svc/dynamodb/table.go @@ -81,6 +81,9 @@ type Table struct { // Warm is the table's warm throughput (recorded; DescribeTable reports // AWS's defaults for new tables when it was never set). Warm *WarmThroughput `json:"warm_throughput,omitempty"` + // ContributorInsights is the Contributor Insights setting of the table + // (key "") and of its global secondary indexes (key: index name). + ContributorInsights map[string]ContributorInsights `json:"contributor_insights,omitempty"` } // WarmThroughput is the read and write units per second a table is ready for. diff --git a/cli/internal/svc/events/aws_test.go b/cli/internal/svc/events/aws_test.go index f3e59c2b..fe434188 100644 --- a/cli/internal/svc/events/aws_test.go +++ b/cli/internal/svc/events/aws_test.go @@ -209,6 +209,60 @@ func TestEventBridgePermissions(t *testing.T) { } } +func TestEventBridgeUpdateEventBus(t *testing.T) { + h, _, _ := newAWS(t) + acct := h.Env.AccountID + dlq := "arn:aws:sqs:us-east-1:" + acct + ":orders-dlq" + h.AWS(t, "events", "create-event-bus", "--name", "orders") + + up := h.AWSJSON(t, "events", "update-event-bus", "--name", "orders", "--description", "Order events") + if up["Arn"] != "arn:aws:events:us-east-1:"+acct+":event-bus/orders" || up["Name"] != "orders" || up["Description"] != "Order events" { + t.Fatalf("update-event-bus: %v", up) + } + d := h.AWSJSON(t, "events", "describe-event-bus", "--name", "orders") + if d["Description"] != "Order events" || d["LastModifiedTime"] == nil { + t.Fatalf("describe-event-bus: %v", d) + } + + // Set a dead-letter queue (the description is kept), then clear it. + up = h.AWSJSON(t, "events", "update-event-bus", "--name", "orders", "--dead-letter-config", `{"Arn":"`+dlq+`"}`) + if up["DeadLetterConfig"].(map[string]any)["Arn"] != dlq || up["Description"] != "Order events" { + t.Fatalf("set DLQ: %v", up) + } + if d := h.AWSJSON(t, "events", "describe-event-bus", "--name", "orders"); d["DeadLetterConfig"].(map[string]any)["Arn"] != dlq { + t.Fatalf("describe with DLQ: %v", d) + } + h.AWS(t, "events", "update-event-bus", "--name", "orders", "--dead-letter-config", `{}`, "--description", "") + if d := h.AWSJSON(t, "events", "describe-event-bus", "--name", "orders"); d["DeadLetterConfig"] != nil || d["Description"] != nil { + t.Fatalf("describe after clearing: %v", d) + } + + // The default bus can be updated, and is still listed once. + h.AWS(t, "events", "update-event-bus", "--description", "main bus") + if d := h.AWSJSON(t, "events", "describe-event-bus"); d["Description"] != "main bus" || d["Name"] != "default" { + t.Fatalf("default bus: %v", d) + } + if l := h.AWSJSON(t, "events", "list-event-buses")["EventBuses"].([]any); len(l) != 2 || l[0].(map[string]any)["Description"] != "main bus" { + t.Fatalf("list-event-buses: %v", l) + } + + if out, err := h.AWSErr(t, "events", "update-event-bus", "--name", "nope", "--description", "x"); err == nil || !strings.Contains(out, "ResourceNotFoundException") { + t.Fatalf("unknown bus: %v %s", err, out) + } + + // A DLQ the caller cannot send to is refused. + h.Native(t, "POST", "/api/v1/iam/policies", map[string]any{"name": "events-only", "document": map[string]any{"Version": "2012-10-17", + "Statement": []any{map[string]any{"Effect": "Allow", "Action": "events:*", "Resource": "*"}}}}) + akid, secret := h.User(t, "ev", "events-only") + out, err := h.AWSAs(t, akid, secret, "", "events", "update-event-bus", "--name", "orders", "--dead-letter-config", `{"Arn":"`+dlq+`"}`) + if err == nil || !strings.Contains(out, "AccessDenied") || !strings.Contains(out, "sqs:SendMessage") { + t.Fatalf("DLQ without sqs permission: %v %s", err, out) + } + if out, err := h.AWSAs(t, akid, secret, "", "events", "update-event-bus", "--name", "orders", "--description", "ok"); err != nil { + t.Fatalf("update as events user: %v %s", err, out) + } +} + func TestSchedulerCLI(t *testing.T) { h, s, k := newAWS(t) acct := h.Env.AccountID diff --git a/cli/internal/svc/events/events.go b/cli/internal/svc/events/events.go index b6949ad0..685c2998 100644 --- a/cli/internal/svc/events/events.go +++ b/cli/internal/svc/events/events.go @@ -74,6 +74,7 @@ type Bus struct { ARN string `json:"arn"` Description string `json:"description,omitempty"` DeadLetterARN string `json:"dead_letter_arn,omitempty"` + KMSKeyID string `json:"kms_key_identifier,omitempty"` // stored and reported; events are not encrypted with it CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` Tags core.Tags `json:"tags,omitempty"` diff --git a/cli/internal/svc/events/events_aws.go b/cli/internal/svc/events/events_aws.go index 161c1d5c..ef34d36b 100644 --- a/cli/internal/svc/events/events_aws.go +++ b/cli/internal/svc/events/events_aws.go @@ -3,6 +3,7 @@ package events import ( "context" "encoding/json" + "errors" "net/http" "regexp" "sort" @@ -43,6 +44,7 @@ func (s *Service) RegisterAWS() { "DescribeEventBus": s.awsDescribeEventBus, "ListEventBuses": s.awsListEventBuses, "DeleteEventBus": s.awsDeleteEventBus, + "UpdateEventBus": s.awsUpdateEventBus, "TestEventPattern": s.awsTestEventPattern, "TagResource": s.awsTagResource, "UntagResource": s.awsUntagResource, @@ -609,20 +611,36 @@ type awsBus struct { Arn string Description string `json:",omitempty"` DeadLetterConfig *struct{ Arn string } `json:",omitempty"` + KmsKeyIdentifier string `json:",omitempty"` CreationTime *awsapi.Time `json:",omitempty"` LastModifiedTime *awsapi.Time `json:",omitempty"` } +// bus returns an event bus. The default bus always exists; it is stored only +// once it has been updated (UpdateEventBus). func (s *Service) bus(name string) (Bus, bool) { - if name == "default" { + b, err := store.Get[Bus](s.env.Store, cBuses, name) + if err != nil && name == "default" { return Bus{Name: "default", ARN: s.busARN("default")}, true } - b, err := store.Get[Bus](s.env.Store, cBuses, name) return b, err == nil } +// allBuses lists every event bus, the default one included, by name. +func (s *Service) allBuses() []Bus { + def, _ := s.bus("default") + all := []Bus{def} + for _, b := range store.List[Bus](s.env.Store, cBuses) { + if b.Name != "default" { + all = append(all, b) + } + } + sort.Slice(all, func(i, j int) bool { return all[i].Name < all[j].Name }) + return all +} + func toAWSBus(b Bus) awsBus { - out := awsBus{Name: b.Name, Arn: b.ARN, Description: b.Description, CreationTime: awsapi.T(b.CreatedAt), LastModifiedTime: awsapi.T(b.UpdatedAt)} + out := awsBus{Name: b.Name, Arn: b.ARN, Description: b.Description, KmsKeyIdentifier: b.KMSKeyID, CreationTime: awsapi.T(b.CreatedAt), LastModifiedTime: awsapi.T(b.UpdatedAt)} if b.DeadLetterARN != "" { out.DeadLetterConfig = &struct{ Arn string }{b.DeadLetterARN} } @@ -657,7 +675,7 @@ func (s *Service) createBus(q authorizer, in createBusIn) (any, error) { if err != nil { return nil, err } - b := Bus{Name: in.Name, ARN: s.busARN(in.Name), Description: in.Description, CreatedAt: core.Now(), UpdatedAt: core.Now(), Tags: tags} + b := Bus{Name: in.Name, ARN: s.busARN(in.Name), Description: in.Description, KMSKeyID: in.KmsKeyIdentifier, CreatedAt: core.Now(), UpdatedAt: core.Now(), Tags: tags} if in.DeadLetterConfig != nil { if err := q.Check("sqs:SendMessage", in.DeadLetterConfig.Arn); err != nil { return nil, err @@ -708,8 +726,7 @@ func (s *Service) awsListEventBuses(q *awsapi.Req) (any, error) { if err != nil { return nil, err } - all := append([]Bus{{Name: "default", ARN: s.busARN("default")}}, store.List[Bus](s.env.Store, cBuses)...) - sort.Slice(all, func(i, j int) bool { return all[i].Name < all[j].Name }) + all := s.allBuses() out := struct { EventBuses []awsBus NextToken string `json:",omitempty"` @@ -732,6 +749,79 @@ func (s *Service) awsListEventBuses(q *awsapi.Req) (any, error) { return out, nil } +type updateBusIn struct { + Name, KmsKeyIdentifier string + Description *string + // DeadLetterConfig with an empty Arn removes the dead-letter queue. + DeadLetterConfig *struct{ Arn string } +} + +func (s *Service) awsUpdateEventBus(q *awsapi.Req) (any, error) { + var in updateBusIn + if err := q.Bind(&in); err != nil { + return nil, err + } + return s.updateBus(q, in) +} + +// updateBus changes a bus's description, dead-letter queue or KMS key; the +// fields left out keep their values. The default bus can be updated too. +func (s *Service) updateBus(q authorizer, in updateBusIn) (any, error) { + name := busName(in.Name) + if err := q.Authorize("events:UpdateEventBus", s.busARN(name)); err != nil { + return nil, err + } + if in.Description != nil && len(*in.Description) > 512 { + return nil, evErr("ValidationException", "Description must be at most 512 characters.") + } + b, ok := s.bus(name) + if !ok { + return nil, evErr("ResourceNotFoundException", "Event bus %s does not exist.", name) + } + if in.DeadLetterConfig != nil && in.DeadLetterConfig.Arn != "" && in.DeadLetterConfig.Arn != b.DeadLetterARN { + if err := q.Check("sqs:SendMessage", in.DeadLetterConfig.Arn); err != nil { + return nil, err + } + } + update := func(b *Bus) error { + if in.Description != nil { + b.Description = *in.Description + } + if in.DeadLetterConfig != nil { + b.DeadLetterARN = in.DeadLetterConfig.Arn + } + if in.KmsKeyIdentifier != "" { + b.KMSKeyID = in.KmsKeyIdentifier + } + b.UpdatedAt = core.Now() + return nil + } + var err error + if name == "default" && !store.Has(s.env.Store, cBuses, name) { + _ = update(&b) + err = store.Put(s.env.Store, cBuses, name, b) + } else { + b, err = store.Update(s.env.Store, cBuses, name, update) + } + if errors.Is(err, store.ErrNotFound) { // deleted meanwhile + return nil, evErr("ResourceNotFoundException", "Event bus %s does not exist.", name) + } + if err != nil { + return nil, err + } + out := map[string]any{"Arn": b.ARN, "Name": b.Name} + if b.Description != "" { + out["Description"] = b.Description + } + if b.DeadLetterARN != "" { + out["DeadLetterConfig"] = map[string]string{"Arn": b.DeadLetterARN} + } + if b.KMSKeyID != "" { + out["KmsKeyIdentifier"] = b.KMSKeyID + } + return out, nil +} + func (s *Service) awsDeleteEventBus(q *awsapi.Req) (any, error) { var in struct{ Name string } if err := q.Bind(&in); err != nil { diff --git a/cli/internal/svc/events/native.go b/cli/internal/svc/events/native.go index db5a55f3..5f0c96d9 100644 --- a/cli/internal/svc/events/native.go +++ b/cli/internal/svc/events/native.go @@ -37,9 +37,7 @@ func (s *Service) nativeBusRoutes(r *httpx.Router) { } func (s *Service) listBuses(c *httpx.Ctx) (any, error) { - all := append([]Bus{{Name: "default", ARN: s.busARN("default")}}, store.List[Bus](s.env.Store, cBuses)...) - sort.Slice(all, func(i, j int) bool { return all[i].Name < all[j].Name }) - return all, nil + return s.allBuses(), nil } func (s *Service) createBusRoute(c *httpx.Ctx) (any, error) { diff --git a/cli/internal/svc/iam/aws_test.go b/cli/internal/svc/iam/aws_test.go index 2d77f560..b5fd4ea4 100644 --- a/cli/internal/svc/iam/aws_test.go +++ b/cli/internal/svc/iam/aws_test.go @@ -5,10 +5,14 @@ package iam_test import ( "encoding/json" + "net/http" + "path/filepath" "strings" "testing" "github.com/homecloudhq/homecloud/cli/internal/awsapi/awstest" + "github.com/homecloudhq/homecloud/cli/internal/store" + "github.com/homecloudhq/homecloud/cli/internal/svc/iam" ) func wantErr(t *testing.T, out string, err error, code string) { @@ -514,3 +518,85 @@ func TestNativeAndAWSShareModel(t *testing.T) { t.Fatalf("profile roles %v", roles) } } + +// wantQueryErr sends a raw IAM Query request as root and expects an error code. +func wantQueryErr(t *testing.T, h *awstest.Harness, form, code string) { + t.Helper() + status, _, body := h.Signed(t, "iam", "POST", "/", http.Header{"Content-Type": {"application/x-www-form-urlencoded"}}, []byte(form)) + if status != http.StatusBadRequest || !strings.Contains(string(body), ""+code+"") { + t.Fatalf("%s: want %s, got %d %s", form, code, status, body) + } +} + +func TestAWSAccountPasswordPolicyAndAlias(t *testing.T) { + h := awstest.New(t) + out, err := h.AWSErr(t, "iam", "get-account-password-policy") + wantErr(t, out, err, "NoSuchEntity") + out, err = h.AWSErr(t, "iam", "delete-account-password-policy") + wantErr(t, out, err, "NoSuchEntity") + // The AWS CLI checks ranges itself, so invalid values go out as raw Query requests. + for _, bad := range []string{"MinimumPasswordLength=3", "MinimumPasswordLength=129", "MaxPasswordAge=1096", "PasswordReusePrevention=25", "MaxPasswordAge=x"} { + wantQueryErr(t, h, "Action=UpdateAccountPasswordPolicy&Version=2010-05-08&"+bad, "ValidationError") + } + + h.AWS(t, "iam", "update-account-password-policy", "--minimum-password-length", "12", "--require-symbols") + p := h.AWSJSON(t, "iam", "get-account-password-policy")["PasswordPolicy"].(map[string]any) + if p["MinimumPasswordLength"] != 12.0 || p["RequireSymbols"] != true || p["RequireNumbers"] != false || + p["ExpirePasswords"] != false || p["AllowUsersToChangePassword"] != false || p["MaxPasswordAge"] != nil { + t.Fatalf("policy %v", p) + } + // Omitted parameters take their defaults again. + h.AWS(t, "iam", "update-account-password-policy", "--max-password-age", "90", "--password-reuse-prevention", "5", + "--hard-expiry", "--allow-users-to-change-password", "--require-numbers", "--require-uppercase-characters", "--require-lowercase-characters") + p = h.AWSJSON(t, "iam", "get-account-password-policy")["PasswordPolicy"].(map[string]any) + if p["MinimumPasswordLength"] != 8.0 || p["RequireSymbols"] != false || p["ExpirePasswords"] != true || p["MaxPasswordAge"] != 90.0 || + p["PasswordReusePrevention"] != 5.0 || p["HardExpiry"] != true || p["AllowUsersToChangePassword"] != true || + p["RequireNumbers"] != true || p["RequireUppercaseCharacters"] != true || p["RequireLowercaseCharacters"] != true { + t.Fatalf("policy %v", p) + } + + // Account alias: one per account, a new one replaces it. + if a := h.AWSJSON(t, "iam", "list-account-aliases")["AccountAliases"].([]any); len(a) != 0 { + t.Fatalf("aliases %v", a) + } + for _, bad := range []string{"ab", "-lab", "lab-", "My-Lab", "my--lab", "my_lab", strings.Repeat("a", 64)} { + wantQueryErr(t, h, "Action=CreateAccountAlias&Version=2010-05-08&AccountAlias="+bad, "ValidationError") + } + h.AWS(t, "iam", "create-account-alias", "--account-alias", "old-lab") + h.AWS(t, "iam", "create-account-alias", "--account-alias", "my-lab") + if a := h.AWSJSON(t, "iam", "list-account-aliases")["AccountAliases"].([]any); len(a) != 1 || a[0] != "my-lab" { + t.Fatalf("aliases %v", a) + } + out, err = h.AWSErr(t, "iam", "delete-account-alias", "--account-alias", "old-lab") + wantErr(t, out, err, "NoSuchEntity") + + // A user without the permissions is refused. + akid, secret := userKeys(t, h, "nobody", "") + out, err = h.AWSAs(t, akid, secret, "", "iam", "update-account-password-policy") + wantErr(t, out, err, "AccessDenied") + out, err = h.AWSAs(t, akid, secret, "", "iam", "create-account-alias", "--account-alias", "evil") + wantErr(t, out, err, "AccessDenied") + + // Both survive a restart: a new service over the reopened state file sees them. + st, err := store.Open(filepath.Join(h.Env.Cfg.DataDir, "state.json")) + if err != nil { + t.Fatal(err) + } + env := *h.Env + env.Store = st + re := iam.New(&env) + if pp, err := re.GetPasswordPolicy(); err != nil || pp.MaxPasswordAge != 90 || pp.PasswordReusePrevention != 5 { + t.Fatalf("after restart: %+v %v", pp, err) + } + if a := re.AccountAlias(); a != "my-lab" { + t.Fatalf("alias after restart %q", a) + } + + h.AWS(t, "iam", "delete-account-alias", "--account-alias", "my-lab") + if a := h.AWSJSON(t, "iam", "list-account-aliases")["AccountAliases"].([]any); len(a) != 0 { + t.Fatalf("aliases %v", a) + } + h.AWS(t, "iam", "delete-account-password-policy") + out, err = h.AWSErr(t, "iam", "get-account-password-policy") + wantErr(t, out, err, "NoSuchEntity") +} diff --git a/cli/internal/svc/iam/iam_aws.go b/cli/internal/svc/iam/iam_aws.go index 7a57ef00..d4af529b 100644 --- a/cli/internal/svc/iam/iam_aws.go +++ b/cli/internal/svc/iam/iam_aws.go @@ -63,7 +63,8 @@ func (s *Service) registerIAM() { "TagInstanceProfile": s.awsTagInstanceProfile, "UntagInstanceProfile": s.awsUntagInstanceProfile, "ListInstanceProfileTags": s.awsListInstanceProfileTags, // account "GetAccountSummary": s.awsGetAccountSummary, "GetAccountAuthorizationDetails": s.awsGetAccountAuthorizationDetails, - "ListAccountAliases": s.awsListAccountAliases, "GetAccountPasswordPolicy": s.awsGetAccountPasswordPolicy, + "ListAccountAliases": s.awsListAccountAliases, "CreateAccountAlias": s.awsCreateAccountAlias, "DeleteAccountAlias": s.awsDeleteAccountAlias, + "GetAccountPasswordPolicy": s.awsGetAccountPasswordPolicy, "UpdateAccountPasswordPolicy": s.awsUpdateAccountPasswordPolicy, "DeleteAccountPasswordPolicy": s.awsDeleteAccountPasswordPolicy, "SimulatePrincipalPolicy": s.awsSimulatePrincipalPolicy, "SimulateCustomPolicy": s.awsSimulateCustomPolicy, "GetContextKeysForPrincipalPolicy": s.awsGetContextKeysForPrincipalPolicy, "GetContextKeysForCustomPolicy": s.awsGetContextKeysForCustomPolicy, // credentials HomeCloud does not have: always empty, so tools that clean them up work diff --git a/cli/internal/svc/iam/iam_aws_account.go b/cli/internal/svc/iam/iam_aws_account.go new file mode 100644 index 00000000..90be0c58 --- /dev/null +++ b/cli/internal/svc/iam/iam_aws_account.go @@ -0,0 +1,194 @@ +package iam + +import ( + "net/http" + "regexp" + "strconv" + + "github.com/homecloudhq/homecloud/cli/internal/awsapi" + "github.com/homecloudhq/homecloud/cli/internal/store" +) + +// Account-level IAM settings: the password policy and the account alias. +// Both are single records in cAccountSettings. + +const ( + cAccountSettings = "iam_account_settings" + idPasswordPolicy = "password_policy" + idAccountAlias = "alias" +) + +// PasswordPolicy is the account password policy (UpdateAccountPasswordPolicy). +// HomeCloud stores and reports it; console passwords are not yet checked against it. +type PasswordPolicy struct { + MinimumPasswordLength int `json:"minimum_password_length"` + RequireSymbols bool `json:"require_symbols"` + RequireNumbers bool `json:"require_numbers"` + RequireUppercaseCharacters bool `json:"require_uppercase_characters"` + RequireLowercaseCharacters bool `json:"require_lowercase_characters"` + AllowUsersToChangePassword bool `json:"allow_users_to_change_password"` + MaxPasswordAge int `json:"max_password_age,omitempty"` // days; 0 = passwords do not expire + PasswordReusePrevention int `json:"password_reuse_prevention,omitempty"` // 0 = not set + HardExpiry bool `json:"hard_expiry,omitempty"` +} + +type accountAlias struct { + Alias string `json:"alias"` +} + +// GetPasswordPolicy returns the account password policy, or NoSuchEntity. +func (s *Service) GetPasswordPolicy() (PasswordPolicy, error) { + p, err := store.Get[PasswordPolicy](s.env.Store, cAccountSettings, idPasswordPolicy) + if err != nil { + return p, noSuchEntity("The Password Policy with domain name %s cannot be found.", s.env.AccountID) + } + return p, nil +} + +// AccountAlias returns the account alias, or "" when none is set. +func (s *Service) AccountAlias() string { + a, _ := store.Get[accountAlias](s.env.Store, cAccountSettings, idAccountAlias) + return a.Alias +} + +func rangeError(field string, v, lo, hi int) error { + if v < lo { + return awsapi.Errorf(http.StatusBadRequest, "ValidationError", + "1 validation error detected: Value '%d' at '%s' failed to satisfy constraint: Member must have value greater than or equal to %d", v, field, lo) + } + return awsapi.Errorf(http.StatusBadRequest, "ValidationError", + "1 validation error detected: Value '%d' at '%s' failed to satisfy constraint: Member must have value less than or equal to %d", v, field, hi) +} + +// intParam reads an optional integer parameter within [lo, hi]. +func intParam(q *awsapi.Req, name string, def, lo, hi int) (int, error) { + raw := q.Param(name) + if raw == "" { + return def, nil + } + v, err := strconv.Atoi(raw) + if err != nil { + return 0, awsapi.Errorf(http.StatusBadRequest, "ValidationError", "%s must be an integer", name) + } + if v < lo || v > hi { + return 0, rangeError(lowerFirst(name), v, lo, hi) + } + return v, nil +} + +func (s *Service) awsUpdateAccountPasswordPolicy(q *awsapi.Req) (any, error) { + if err := q.Authorize("iam:UpdateAccountPasswordPolicy", "*"); err != nil { + return nil, err + } + // Like AWS, parameters left out take their defaults (they do not keep + // the previous policy's values). + p := PasswordPolicy{ + RequireSymbols: q.ParamBool("RequireSymbols", false), + RequireNumbers: q.ParamBool("RequireNumbers", false), + RequireUppercaseCharacters: q.ParamBool("RequireUppercaseCharacters", false), + RequireLowercaseCharacters: q.ParamBool("RequireLowercaseCharacters", false), + AllowUsersToChangePassword: q.ParamBool("AllowUsersToChangePassword", false), + HardExpiry: q.ParamBool("HardExpiry", false), + } + var err error + if p.MinimumPasswordLength, err = intParam(q, "MinimumPasswordLength", 8, 6, 128); err != nil { + return nil, err + } + if p.MaxPasswordAge, err = intParam(q, "MaxPasswordAge", 0, 1, 1095); err != nil { + return nil, err + } + if p.PasswordReusePrevention, err = intParam(q, "PasswordReusePrevention", 0, 1, 24); err != nil { + return nil, err + } + return awsapi.NoResult{}, store.Put(s.env.Store, cAccountSettings, idPasswordPolicy, p) +} + +func (s *Service) awsGetAccountPasswordPolicy(q *awsapi.Req) (any, error) { + if err := q.Authorize("iam:GetAccountPasswordPolicy", "*"); err != nil { + return nil, err + } + p, err := s.GetPasswordPolicy() + if err != nil { + return nil, err + } + out := awsapi.Ordered{ + {K: "MinimumPasswordLength", V: p.MinimumPasswordLength}, + {K: "RequireSymbols", V: p.RequireSymbols}, + {K: "RequireNumbers", V: p.RequireNumbers}, + {K: "RequireUppercaseCharacters", V: p.RequireUppercaseCharacters}, + {K: "RequireLowercaseCharacters", V: p.RequireLowercaseCharacters}, + {K: "AllowUsersToChangePassword", V: p.AllowUsersToChangePassword}, + {K: "ExpirePasswords", V: p.MaxPasswordAge > 0}, + } + if p.MaxPasswordAge > 0 { + out = append(out, awsapi.KV{K: "MaxPasswordAge", V: p.MaxPasswordAge}) + } + if p.PasswordReusePrevention > 0 { + out = append(out, awsapi.KV{K: "PasswordReusePrevention", V: p.PasswordReusePrevention}) + } + if p.HardExpiry { + out = append(out, awsapi.KV{K: "HardExpiry", V: true}) + } + return map[string]any{"PasswordPolicy": out}, nil +} + +func (s *Service) awsDeleteAccountPasswordPolicy(q *awsapi.Req) (any, error) { + if err := q.Authorize("iam:DeleteAccountPasswordPolicy", "*"); err != nil { + return nil, err + } + if _, err := s.GetPasswordPolicy(); err != nil { + return nil, err + } + return awsapi.NoResult{}, store.Delete(s.env.Store, cAccountSettings, idPasswordPolicy) +} + +// AWS: 3-63 lowercase letters, digits and single hyphens, not at either end. +var aliasRe = regexp.MustCompile(`^[a-z0-9]([a-z0-9]|-[a-z0-9])*$`) + +func validAlias(a string) error { + if len(a) < 3 || len(a) > 63 || !aliasRe.MatchString(a) { + return awsapi.Errorf(http.StatusBadRequest, "ValidationError", + "1 validation error detected: Value '%s' at 'accountAlias' failed to satisfy constraint: Member must have length between 3 and 63 and match ^[a-z0-9]([a-z0-9]|-(?!-)){1,61}[a-z0-9]$", a) + } + return nil +} + +func (s *Service) awsCreateAccountAlias(q *awsapi.Req) (any, error) { + if err := required(q, "AccountAlias"); err != nil { + return nil, err + } + alias := q.Param("AccountAlias") + if err := validAlias(alias); err != nil { + return nil, err + } + if err := q.Authorize("iam:CreateAccountAlias", "*"); err != nil { + return nil, err + } + // An account has one alias; a new one replaces it, as in AWS. + return awsapi.NoResult{}, store.Put(s.env.Store, cAccountSettings, idAccountAlias, accountAlias{Alias: alias}) +} + +func (s *Service) awsDeleteAccountAlias(q *awsapi.Req) (any, error) { + if err := required(q, "AccountAlias"); err != nil { + return nil, err + } + alias := q.Param("AccountAlias") + if err := q.Authorize("iam:DeleteAccountAlias", "*"); err != nil { + return nil, err + } + if cur := s.AccountAlias(); cur == "" || cur != alias { + return nil, noSuchEntity("The account alias %s cannot be found.", alias) + } + return awsapi.NoResult{}, store.Delete(s.env.Store, cAccountSettings, idAccountAlias) +} + +func (s *Service) awsListAccountAliases(q *awsapi.Req) (any, error) { + if err := q.Authorize("iam:ListAccountAliases", "*"); err != nil { + return nil, err + } + aliases := awsapi.Members{} + if a := s.AccountAlias(); a != "" { + aliases = append(aliases, a) + } + return map[string]any{"AccountAliases": aliases, "IsTruncated": false}, nil +} diff --git a/cli/internal/svc/iam/iam_aws_roles.go b/cli/internal/svc/iam/iam_aws_roles.go index 9fdc28d7..3bb38f95 100644 --- a/cli/internal/svc/iam/iam_aws_roles.go +++ b/cli/internal/svc/iam/iam_aws_roles.go @@ -467,20 +467,6 @@ func (s *Service) awsGetAccountAuthorizationDetails(q *awsapi.Req) (any, error) return res, nil } -func (s *Service) awsListAccountAliases(q *awsapi.Req) (any, error) { - if err := q.Authorize("iam:ListAccountAliases", "*"); err != nil { - return nil, err - } - return map[string]any{"AccountAliases": awsapi.Members{}, "IsTruncated": false}, nil -} - -func (s *Service) awsGetAccountPasswordPolicy(q *awsapi.Req) (any, error) { - if err := q.Authorize("iam:GetAccountPasswordPolicy", "*"); err != nil { - return nil, err - } - return nil, noSuchEntity("The Password Policy with domain name %s cannot be found.", s.env.AccountID) -} - // contextParam reads ContextEntries (SimulatePrincipalPolicy/SimulateCustomPolicy). func contextParam(q *awsapi.Req) CondContext { ctx := CondContext{} diff --git a/cli/internal/svc/kms/aws.go b/cli/internal/svc/kms/aws.go index f0d49659..f380a4fd 100644 --- a/cli/internal/svc/kms/aws.go +++ b/cli/internal/svc/kms/aws.go @@ -60,6 +60,7 @@ func (s *Service) RegisterAWS() { "ListResourceTags": s.awsListResourceTags, "CreateGrant": s.awsCreateGrant, "ListGrants": s.awsListGrants, + "ListRetirableGrants": s.awsListRetirableGrants, "RevokeGrant": s.awsRevokeGrant, "RetireGrant": s.awsRetireGrant, "GetParametersForImport": s.awsGetParametersForImport, @@ -941,6 +942,49 @@ func (s *Service) awsListGrants(q *awsapi.Req) (any, error) { return out, nil } +// awsListRetirableGrants lists the grants, on every key, that the given +// principal may retire. AWS authorizes it on "*", not per key. +func (s *Service) awsListRetirableGrants(q *awsapi.Req) (any, error) { + var in struct { + RetiringPrincipal, Marker string + Limit int + } + if err := q.Bind(&in); err != nil { + return nil, err + } + if in.RetiringPrincipal == "" { + return nil, awsapi.Errorf(http.StatusBadRequest, "ValidationException", + "1 validation error detected: Value null at 'retiringPrincipal' failed to satisfy constraint: Member must not be null") + } + if err := q.Authorize("kms:ListRetirableGrants", "*"); err != nil { + return nil, err + } + type keyGrant struct { + k Key + g Grant + } + keys := store.List[Key](s.env.Store, cKeys) + sort.SliceStable(keys, func(i, j int) bool { return keys[i].CreatedAt.Before(keys[j].CreatedAt) }) + var gs []keyGrant + for _, k := range keys { + for _, g := range k.Grants { + if g.RetiringPrincipal == in.RetiringPrincipal { + gs = append(gs, keyGrant{k, g}) + } + } + } + list, out, err := page(gs, in.Marker, in.Limit) + if err != nil { + return nil, err + } + items := []any{} + for _, kg := range list { + items = append(items, grantOut(kg.k, kg.g)) + } + out["Grants"] = items + return out, nil +} + func (s *Service) awsRevokeGrant(q *awsapi.Req) (any, error) { var in struct { KeyId, GrantId string diff --git a/cli/internal/svc/kms/aws_test.go b/cli/internal/svc/kms/aws_test.go index 45edbcef..e164e404 100644 --- a/cli/internal/svc/kms/aws_test.go +++ b/cli/internal/svc/kms/aws_test.go @@ -8,6 +8,7 @@ import ( "crypto/sha256" "crypto/x509" "encoding/base64" + "net/http" "strings" "sync" "testing" @@ -189,6 +190,61 @@ func TestKMSKeyLifecycleCLI(t *testing.T) { expectErr(t, h, "NotFoundException", "kms", "describe-key", "--key-id", "alias/b1") } +func TestKMSListRetirableGrants(t *testing.T) { + h, _, _ := setup(t) + acct := h.Env.AccountID + retirer, other := "arn:aws:iam::"+acct+":role/r", "arn:aws:iam::"+acct+":role/other" + k1 := meta(t, h, "kms", "create-key")["Arn"].(string) + k2 := meta(t, h, "kms", "create-key")["Arn"].(string) + grant := func(key, retiring string) string { + return h.AWSJSON(t, "kms", "create-grant", "--key-id", key, "--grantee-principal", "arn:aws:iam::"+acct+":role/app", + "--retiring-principal", retiring, "--operations", "Decrypt")["GrantId"].(string) + } + g1, g2 := grant(k1, retirer), grant(k2, retirer) + grant(k2, other) + + gs := h.AWSJSON(t, "kms", "list-retirable-grants", "--retiring-principal", retirer)["Grants"].([]any) + got := map[string]string{} + for _, g := range gs { + m := g.(map[string]any) + if m["RetiringPrincipal"] != retirer { + t.Fatalf("grant %v", m) + } + got[m["GrantId"].(string)] = m["KeyId"].(string) + } + if len(got) != 2 || got[g1] != k1 || got[g2] != k2 { + t.Fatalf("list-retirable-grants: %v", gs) + } + + // Pagination. + p1 := h.AWSJSON(t, "kms", "list-retirable-grants", "--retiring-principal", retirer, "--limit", "1", "--no-paginate") + if p1["Truncated"] != true || p1["NextMarker"] == nil || len(p1["Grants"].([]any)) != 1 { + t.Fatalf("page 1: %v", p1) + } + p2 := h.AWSJSON(t, "kms", "list-retirable-grants", "--retiring-principal", retirer, "--limit", "1", "--no-paginate", + "--marker", p1["NextMarker"].(string)) + if p2["Truncated"] == true || len(p2["Grants"].([]any)) != 1 || + p2["Grants"].([]any)[0].(map[string]any)["GrantId"] == p1["Grants"].([]any)[0].(map[string]any)["GrantId"] { + t.Fatalf("page 2: %v", p2) + } + if gs := h.AWSJSON(t, "kms", "list-retirable-grants", "--retiring-principal", "arn:aws:iam::"+acct+":role/none")["Grants"].([]any); len(gs) != 0 { + t.Fatalf("unknown principal: %v", gs) + } + + // The AWS CLI requires RetiringPrincipal itself, so send the request raw. + status, _, body := h.Signed(t, "kms", "POST", "/", http.Header{"X-Amz-Target": {"TrentService.ListRetirableGrants"}, + "Content-Type": {"application/x-amz-json-1.1"}}, []byte(`{}`)) + if status != http.StatusBadRequest || !strings.Contains(string(body), "ValidationException") { + t.Fatalf("missing RetiringPrincipal: %d %s", status, body) + } + + // Authorized as kms:ListRetirableGrants on "*". + akid, secret := h.User(t, "nokms") + if out, err := h.AWSAs(t, akid, secret, "", "kms", "list-retirable-grants", "--retiring-principal", retirer); err == nil || !strings.Contains(out, "AccessDeniedException") { + t.Fatalf("without permission: %v %s", err, out) + } +} + func TestKMSCryptoBoto3(t *testing.T) { h, _, _ := setup(t) out := h.Python(t, ` diff --git a/docs/aws-compat.md b/docs/aws-compat.md index f47ba982..faf92ce8 100644 --- a/docs/aws-compat.md +++ b/docs/aws-compat.md @@ -82,18 +82,18 @@ that is not itself a trusted proxy is the source address, and `X-Forwarded-Proto | Service | Signing name | Protocol | Notes | |---|---|---|---| | STS | sts | awsQuery | GetCallerIdentity, AssumeRole, GetSessionToken | -| IAM | iam | awsQuery | Users, groups, roles, managed policies (with versions, `arn:aws:iam::aws:policy/...`), inline policies, access keys, instance profiles, permissions boundaries, conditions, simulation | +| IAM | iam | awsQuery | Users, groups, roles, managed policies (with versions, `arn:aws:iam::aws:policy/...`), inline policies, access keys, instance profiles, permissions boundaries, conditions, simulation, the account password policy (stored and reported; console passwords are not yet checked against it) and account alias | | S3 | s3 | restXml | Objects, multipart, presigned URLs (SigV4 and SigV2), chunked/trailer uploads, versioning, lifecycle, bucket policies with condition keys, tagging, website; path and virtual-host addressing | | Lambda | lambda | restJson1 | Functions (zip, S3, container image), invoke (sync/async/tail), versions, aliases, layers, permissions, concurrency, destinations, event source mappings (SQS, DynamoDB streams), function URLs | -| DynamoDB | dynamodb | awsJson 1.0 | Tables, GSIs/LSIs, all item ops with expressions, batch, transactions, PartiQL, TTL, streams | +| DynamoDB | dynamodb | awsJson 1.0 | Tables, GSIs/LSIs, all item ops with expressions, batch, transactions, PartiQL, TTL, streams, Contributor Insights settings per table and GSI (stored and reported by `Update`/`Describe`/`ListContributorInsights`; no rules or metrics are produced) | | SQS | sqs | awsJson 1.0 + awsQuery | Standard and FIFO queues, DLQs and redrive, batch ops, long polling, queue policies | | SNS | sns | awsQuery | Topics (incl. FIFO), subscriptions (sqs, lambda, http/s, email), filter policies, topic policies, signed messages | | Secrets Manager | secretsmanager | awsJson 1.1 | Versions and staging labels, rotation via Lambda, resource policies. Replication to other regions is not available: `AddReplicaRegions` and `ReplicateSecretToRegions` fail with `InvalidRequestException` because HomeCloud serves a single region | | SSM Parameter Store | ssm | awsJson 1.1 | Parameters, hierarchies, versions and labels, SecureString | -| KMS | kms | awsJson 1.1 | Symmetric, RSA, ECC and HMAC keys, aliases, rotation, data keys, key policies, grants (stored), imported key material (`Origin=EXTERNAL`: `GetParametersForImport`, `ImportKeyMaterial` with RSAES_OAEP and RSA_AES_KEY_WRAP, expiry, `DeleteImportedKeyMaterial`; re-import needs the same material), multi-Region primary keys (`mrk-` ids). HomeCloud is one region, so `ReplicateKey` and `UpdatePrimaryRegion` return `UnsupportedOperationException` | +| KMS | kms | awsJson 1.1 | Symmetric, RSA, ECC and HMAC keys, aliases, rotation, data keys, key policies, grants (stored; `ListGrants`, `ListRetirableGrants`, `RetireGrant`, `RevokeGrant`), imported key material (`Origin=EXTERNAL`: `GetParametersForImport`, `ImportKeyMaterial` with RSAES_OAEP and RSA_AES_KEY_WRAP, expiry, `DeleteImportedKeyMaterial`; re-import needs the same material), multi-Region primary keys (`mrk-` ids). HomeCloud is one region, so `ReplicateKey` and `UpdatePrimaryRegion` return `UnsupportedOperationException` | | CloudWatch | monitoring | rpcv2Cbor + awsJson 1.0 + awsQuery | Metrics, GetMetricData with math (including `FILL(m, value \| REPEAT \| LINEAR)` and `ANOMALY_DETECTION_BAND`), alarms including anomaly detection alarms (`ThresholdMetricId`), anomaly detectors (`PutAnomalyDetector`, `DescribeAnomalyDetectors`, `DeleteAnomalyDetector`; single-metric and metric-math, `ExcludedTimeRanges`, `MetricTimezone`), dashboards | -| CloudWatch Logs | logs | awsJson 1.1 | Groups, streams, events, filter patterns, Logs Insights and saved query definitions, metric and subscription filters, vended log delivery sources, destinations and deliveries (records: nothing is delivered through them) | -| EventBridge | events | awsJson 1.1 | Buses, rules with full pattern syntax, targets with transforms | +| CloudWatch Logs | logs | awsJson 1.1 | Groups, streams, events, filter patterns, Logs Insights and saved query definitions (`Put`/`Describe`/`DeleteQueryDefinition`), metric and subscription filters, vended log delivery sources, destinations and deliveries (records: nothing is delivered through them) | +| EventBridge | events | awsJson 1.1 | Buses (create, describe, update including the default bus, delete; a bus's dead-letter queue and KMS key are stored), rules with full pattern syntax, targets with transforms | | EventBridge Scheduler | scheduler | restJson1 | Schedules and groups | | Elastic Load Balancing v2 | elasticloadbalancing | awsQuery | Application load balancers, target groups (instance and ip), listeners (HTTP/HTTPS with ACM certificates), rules (path and host conditions; forward, redirect and fixed-response actions), attributes, tags | | EC2 | ec2 | ec2 Query | Instances (containers, or virtual machines from the `(VM)` AMIs), security groups, key pairs, volumes and snapshots, VPC networking, launch templates, Elastic IPs, NAT gateways and network ACLs (see notes) | From 0ce6fe3707c0b91d46e91836766fe45e9eaad6ec Mon Sep 17 00:00:00 2001 From: Suryansh Prajapati <58465650+drk1rd@users.noreply.github.com> Date: Thu, 15 Oct 2026 00:45:20 +0530 Subject: [PATCH 2/2] CloudFormation infra tests: read the status from the describe-stacks just made wait and deleted described the stack a second time to read its status; a stack deleted in between made that second call fail the test. --- cli/internal/svc/cfn/awstypes_infra_test.go | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/cli/internal/svc/cfn/awstypes_infra_test.go b/cli/internal/svc/cfn/awstypes_infra_test.go index f0d13b32..81f37cde 100644 --- a/cli/internal/svc/cfn/awstypes_infra_test.go +++ b/cli/internal/svc/cfn/awstypes_infra_test.go @@ -112,6 +112,17 @@ func (e *infraEnv) deploy(t *testing.T, verb, name, tmpl string, params ...strin e.AWS(t, args...) } +// stackFrom parses describe-stacks output. The helpers below use it rather +// than describing the stack again, which races with the stack's deletion. +func stackFrom(t *testing.T, out string) map[string]any { + t.Helper() + var d struct{ Stacks []map[string]any } + if err := json.Unmarshal([]byte(out), &d); err != nil || len(d.Stacks) == 0 { + t.Fatalf("describe-stacks: %v: %s", err, out) + } + return d.Stacks[0] +} + // wait polls a stack for one of the statuses, with time for containers. func (e *infraEnv) wait(t *testing.T, name string, want ...string) map[string]any { t.Helper() @@ -120,7 +131,7 @@ func (e *infraEnv) wait(t *testing.T, name string, want ...string) map[string]an for time.Now().Before(deadline) { out, err := e.AWSErr(t, "cloudformation", "describe-stacks", "--stack-name", name) if err == nil { - st := e.stack(t, name) + st := stackFrom(t, out) last = str(st, "StackStatus") for _, w := range want { if last == w { @@ -156,7 +167,7 @@ func (e *infraEnv) deleted(t *testing.T, name string) { if err != nil && strings.Contains(out, "does not exist") { return } - if err == nil && str(e.stack(t, name), "StackStatus") == "DELETE_FAILED" { + if err == nil && str(stackFrom(t, out), "StackStatus") == "DELETE_FAILED" { t.Fatalf("delete of %s failed: %v", name, e.eventLines(t, name)) } time.Sleep(200 * time.Millisecond)