From 466fcc7b559b3e7dbe0e28ef63f64c488f04624b Mon Sep 17 00:00:00 2001 From: Dmitrii Creed Date: Mon, 10 Aug 2026 12:42:09 +0400 Subject: [PATCH 1/4] feat(aws): optional permissions boundary for workload IAM roles Add an optional permissionsBoundary field to the AWS AccountConfig. When set to an IAM policy ARN, it is applied as the PermissionsBoundary on every IAM role SC creates for workloads: ECS task/execution roles, Lambda execution roles, the alert Lambda roles, and the ECS init-task (pg-init / mysql-init) execution roles. Empty by default, so existing stacks are unaffected: permissionsBoundaryPtr returns nil for an empty or whitespace ARN, i.e. no boundary is set. This lets an operator cap what a deployed workload role can ever do, independent of the role's own policy. ConvertAuth carries only credential fields when it rebuilds an AccountConfig, so the non-credential boundary is explicitly preserved at the converters that rebuild it (lambda, cloudtrail alerts, pg-init and mysql-init exec tasks); the ecs-fargate path already reads the template's AccountConfig directly. Setting or adding a permissions boundary on an aws.iam.Role is an in-place update (PutRolePermissionsBoundary), not a replacement, so running tasks are not disrupted. Regenerated JSON schemas (field is optional, so it is not marked required). Added unit tests for the YAML round-trip and the nil-when-empty contract. Signed-off-by: Dmitrii Creed --- docs/schemas/aws/accountconfig.json | 3 + .../aws/cloudtrailsecurityalertsconfig.json | 3 + docs/schemas/aws/ecrrepository.json | 3 + docs/schemas/aws/mysqlconfig.json | 3 + docs/schemas/aws/postgresconfig.json | 3 + docs/schemas/aws/secretsconfig.json | 3 + docs/schemas/aws/secretsproviderconfig.json | 3 + docs/schemas/aws/statestorageconfig.json | 3 + docs/schemas/aws/templateconfig.json | 3 + pkg/clouds/aws/auth.go | 9 +- pkg/clouds/aws/auth_test.go | 21 ++++ pkg/clouds/aws/aws_lambda.go | 3 + pkg/clouds/pulumi/aws/alerts.go | 6 +- pkg/clouds/pulumi/aws/aws_lambda.go | 3 +- .../pulumi/aws/cloudtrail_security_alerts.go | 36 +++--- pkg/clouds/pulumi/aws/compute_proc.go | 6 + pkg/clouds/pulumi/aws/ecs_fargate.go | 116 +++++++++--------- pkg/clouds/pulumi/aws/exec_ecs_task.go | 5 +- pkg/clouds/pulumi/aws/permissions_boundary.go | 19 +++ .../pulumi/aws/permissions_boundary_test.go | 32 +++++ 20 files changed, 207 insertions(+), 76 deletions(-) create mode 100644 pkg/clouds/pulumi/aws/permissions_boundary.go create mode 100644 pkg/clouds/pulumi/aws/permissions_boundary_test.go diff --git a/docs/schemas/aws/accountconfig.json b/docs/schemas/aws/accountconfig.json index 4d40965f..7ea8d934 100644 --- a/docs/schemas/aws/accountconfig.json +++ b/docs/schemas/aws/accountconfig.json @@ -27,6 +27,9 @@ "account": { "type": "string" }, + "permissionsBoundary": { + "type": "string" + }, "region": { "type": "string" }, diff --git a/docs/schemas/aws/cloudtrailsecurityalertsconfig.json b/docs/schemas/aws/cloudtrailsecurityalertsconfig.json index 6ead1bb4..01ec0a02 100644 --- a/docs/schemas/aws/cloudtrailsecurityalertsconfig.json +++ b/docs/schemas/aws/cloudtrailsecurityalertsconfig.json @@ -30,6 +30,9 @@ "account": { "type": "string" }, + "permissionsBoundary": { + "type": "string" + }, "region": { "type": "string" }, diff --git a/docs/schemas/aws/ecrrepository.json b/docs/schemas/aws/ecrrepository.json index 87601d5f..8cc5d8d3 100644 --- a/docs/schemas/aws/ecrrepository.json +++ b/docs/schemas/aws/ecrrepository.json @@ -30,6 +30,9 @@ "account": { "type": "string" }, + "permissionsBoundary": { + "type": "string" + }, "region": { "type": "string" }, diff --git a/docs/schemas/aws/mysqlconfig.json b/docs/schemas/aws/mysqlconfig.json index 2b13993d..cc44c328 100644 --- a/docs/schemas/aws/mysqlconfig.json +++ b/docs/schemas/aws/mysqlconfig.json @@ -30,6 +30,9 @@ "account": { "type": "string" }, + "permissionsBoundary": { + "type": "string" + }, "region": { "type": "string" }, diff --git a/docs/schemas/aws/postgresconfig.json b/docs/schemas/aws/postgresconfig.json index 92de6d13..af04a3fc 100644 --- a/docs/schemas/aws/postgresconfig.json +++ b/docs/schemas/aws/postgresconfig.json @@ -30,6 +30,9 @@ "account": { "type": "string" }, + "permissionsBoundary": { + "type": "string" + }, "region": { "type": "string" }, diff --git a/docs/schemas/aws/secretsconfig.json b/docs/schemas/aws/secretsconfig.json index ef7610cf..cab2727c 100644 --- a/docs/schemas/aws/secretsconfig.json +++ b/docs/schemas/aws/secretsconfig.json @@ -30,6 +30,9 @@ "account": { "type": "string" }, + "permissionsBoundary": { + "type": "string" + }, "region": { "type": "string" }, diff --git a/docs/schemas/aws/secretsproviderconfig.json b/docs/schemas/aws/secretsproviderconfig.json index 35be81ae..1239e988 100644 --- a/docs/schemas/aws/secretsproviderconfig.json +++ b/docs/schemas/aws/secretsproviderconfig.json @@ -30,6 +30,9 @@ "account": { "type": "string" }, + "permissionsBoundary": { + "type": "string" + }, "region": { "type": "string" }, diff --git a/docs/schemas/aws/statestorageconfig.json b/docs/schemas/aws/statestorageconfig.json index b8ecab94..5aaa1177 100644 --- a/docs/schemas/aws/statestorageconfig.json +++ b/docs/schemas/aws/statestorageconfig.json @@ -30,6 +30,9 @@ "account": { "type": "string" }, + "permissionsBoundary": { + "type": "string" + }, "region": { "type": "string" }, diff --git a/docs/schemas/aws/templateconfig.json b/docs/schemas/aws/templateconfig.json index 63fd3b98..06376fa4 100644 --- a/docs/schemas/aws/templateconfig.json +++ b/docs/schemas/aws/templateconfig.json @@ -30,6 +30,9 @@ "account": { "type": "string" }, + "permissionsBoundary": { + "type": "string" + }, "region": { "type": "string" }, diff --git a/pkg/clouds/aws/auth.go b/pkg/clouds/aws/auth.go index fd432184..0f296d2e 100644 --- a/pkg/clouds/aws/auth.go +++ b/pkg/clouds/aws/auth.go @@ -24,7 +24,14 @@ type AccountConfig struct { AccessKey string `json:"accessKey" yaml:"accessKey"` SecretAccessKey string `json:"secretAccessKey" yaml:"secretAccessKey"` Region string `json:"region" yaml:"region"` - api.Credentials `json:",inline" yaml:",inline"` + // PermissionsBoundary, when set to an IAM policy ARN, is applied as the + // permissions boundary on every IAM role SC creates for workloads under + // this account (ECS task/execution roles, Lambda execution roles). Optional + // and empty by default, so it changes nothing unless a parent stack opts in + // per template. Lets an operator cap what a deployed workload role can ever + // do, independent of the role's own policy. + PermissionsBoundary string `json:"permissionsBoundary,omitempty" yaml:"permissionsBoundary,omitempty"` + api.Credentials `json:",inline" yaml:",inline"` } type SecretsConfig struct { diff --git a/pkg/clouds/aws/auth_test.go b/pkg/clouds/aws/auth_test.go index 52497bd6..48194568 100644 --- a/pkg/clouds/aws/auth_test.go +++ b/pkg/clouds/aws/auth_test.go @@ -8,10 +8,31 @@ import ( "testing" . "github.com/onsi/gomega" + "gopkg.in/yaml.v3" "github.com/simple-container-com/api/pkg/api" ) +// TestAccountConfig_PermissionsBoundaryYAML verifies the permissionsBoundary +// field parses from stack YAML (the load-bearing tag: parents set it per +// template) and round-trips, and that it defaults to empty when absent. +func TestAccountConfig_PermissionsBoundaryYAML(t *testing.T) { + RegisterTestingT(t) + + const arn = "arn:aws:iam::123456789012:policy/my-workload-boundary" + var ac AccountConfig + Expect(yaml.Unmarshal([]byte("account: \"123456789012\"\npermissionsBoundary: \""+arn+"\"\n"), &ac)).To(Succeed()) + Expect(ac.PermissionsBoundary).To(Equal(arn)) + + out, err := yaml.Marshal(&ac) + Expect(err).NotTo(HaveOccurred()) + Expect(string(out)).To(ContainSubstring("permissionsBoundary: " + arn)) + + var absent AccountConfig + Expect(yaml.Unmarshal([]byte("account: \"123456789012\"\n"), &absent)).To(Succeed()) + Expect(absent.PermissionsBoundary).To(BeEmpty()) +} + // ---- AccountConfig getters ---------------------------------------------- func TestAccountConfig_ProviderType(t *testing.T) { diff --git a/pkg/clouds/aws/aws_lambda.go b/pkg/clouds/aws/aws_lambda.go index 5a871367..35e69aae 100644 --- a/pkg/clouds/aws/aws_lambda.go +++ b/pkg/clouds/aws/aws_lambda.go @@ -51,6 +51,9 @@ func ToAwsLambdaConfig(tpl any, stackCfg *api.StackConfigSingleImage) (any, erro if err != nil { return nil, errors.Wrapf(err, "failed to convert aws account config") } + // ConvertAuth carries only credential fields; preserve the non-credential + // permissions boundary from the template so it reaches the execution role. + accountConfig.PermissionsBoundary = templateCfg.AccountConfig.PermissionsBoundary if stackCfg == nil { return nil, errors.Errorf("stack config cannot be nil") } diff --git a/pkg/clouds/pulumi/aws/alerts.go b/pkg/clouds/pulumi/aws/alerts.go index 32ea0ce5..062d4f3d 100644 --- a/pkg/clouds/pulumi/aws/alerts.go +++ b/pkg/clouds/pulumi/aws/alerts.go @@ -39,6 +39,9 @@ type alertCfg struct { metricAlarmArgs cloudwatch.MetricAlarmArgs helpersImage *docker.Image snsTopic *sns.Topic + // permissionsBoundary, when set (AccountConfig.PermissionsBoundary), is + // applied to the alert Lambda's execution role. Empty = none. + permissionsBoundary string // Optional — when all three are set, the Lambda will look up matching // CloudTrail events in the alarm's time window and include a summary // (event name, actor, source IP, timestamp) in the Slack/Discord/Telegram @@ -153,7 +156,8 @@ func pushHelpersImageToECR(ctx *sdk.Context, cfg helperCfg) (*docker.Image, erro func createAlert(ctx *sdk.Context, cfg alertCfg) error { // Create IAM Role for Lambda Function lambdaExecutionRole, err := iam.NewRole(ctx, fmt.Sprintf("%s-execution-role", cfg.name), &iam.RoleArgs{ - Tags: cfg.tags, + Tags: cfg.tags, + PermissionsBoundary: permissionsBoundaryPtr(cfg.permissionsBoundary), AssumeRolePolicy: pulumi.String(`{ "Version": "2012-10-17", "Statement": [{ diff --git a/pkg/clouds/pulumi/aws/aws_lambda.go b/pkg/clouds/pulumi/aws/aws_lambda.go index b631b190..79e33138 100644 --- a/pkg/clouds/pulumi/aws/aws_lambda.go +++ b/pkg/clouds/pulumi/aws/aws_lambda.go @@ -96,7 +96,8 @@ func Lambda(ctx *sdk.Context, stack api.Stack, input api.ResourceInput, params p lambdaExecutionRoleName := fmt.Sprintf("%s-execution-role", stack.Name) params.Log.Info(ctx.Context(), "configure lambda execution role %q for %q in %q...", lambdaExecutionRoleName, stack.Name, deployParams.Environment) lambdaExecutionRole, err := iam.NewRole(ctx, lambdaExecutionRoleName, &iam.RoleArgs{ - Tags: tags, + Tags: tags, + PermissionsBoundary: permissionsBoundaryPtr(crInput.AccountConfig.PermissionsBoundary), AssumeRolePolicy: sdk.String(`{ "Version": "2012-10-17", "Statement": [{ diff --git a/pkg/clouds/pulumi/aws/cloudtrail_security_alerts.go b/pkg/clouds/pulumi/aws/cloudtrail_security_alerts.go index 701d5019..6e818d19 100644 --- a/pkg/clouds/pulumi/aws/cloudtrail_security_alerts.go +++ b/pkg/clouds/pulumi/aws/cloudtrail_security_alerts.go @@ -556,6 +556,9 @@ func CloudTrailSecurityAlerts(ctx *sdk.Context, stack api.Stack, input api.Resou if err := api.ConvertAuth(&cfg.AccountConfig, accountConfig); err != nil { return nil, errors.Wrapf(err, "failed to convert aws account config") } + // ConvertAuth carries only credential fields; preserve the non-credential + // permissions boundary so it reaches the alert Lambda's execution role. + accountConfig.PermissionsBoundary = cfg.AccountConfig.PermissionsBoundary cfg.AccountConfig = *accountConfig if cfg.LogGroupName == "" { @@ -739,22 +742,23 @@ func CloudTrailSecurityAlerts(ctx *sdk.Context, stack api.Stack, input api.Resou alertRegion := cfg.LogGroupRegion ctLogGroupArn := cloudTrailLogGroupArn(cfg, alertRegion) if err := createAlert(ctx, alertCfg{ - name: alertBaseName, - description: alertDef.description, - slackConfig: cfg.Slack, - discordConfig: cfg.Discord, - telegramConfig: cfg.Telegram, - deployParams: *input.StackParams, - secretSuffix: resPrefix, - helpersImage: helpersImage, - snsTopic: snsTopic, - opts: opts, - tags: tags, - metricAlarmArgs: alarmArgs, - ctLogGroupName: cfg.LogGroupName, - ctLogGroupRegion: alertRegion, - ctFilterPattern: alertDef.filterPattern, - ctLogGroupArn: ctLogGroupArn, + permissionsBoundary: cfg.AccountConfig.PermissionsBoundary, + name: alertBaseName, + description: alertDef.description, + slackConfig: cfg.Slack, + discordConfig: cfg.Discord, + telegramConfig: cfg.Telegram, + deployParams: *input.StackParams, + secretSuffix: resPrefix, + helpersImage: helpersImage, + snsTopic: snsTopic, + opts: opts, + tags: tags, + metricAlarmArgs: alarmArgs, + ctLogGroupName: cfg.LogGroupName, + ctLogGroupRegion: alertRegion, + ctFilterPattern: alertDef.filterPattern, + ctLogGroupArn: ctLogGroupArn, }); err != nil { return nil, errors.Wrapf(err, "failed to create alert %q", alertDef.name) } diff --git a/pkg/clouds/pulumi/aws/compute_proc.go b/pkg/clouds/pulumi/aws/compute_proc.go index 177cfa2b..25543d82 100644 --- a/pkg/clouds/pulumi/aws/compute_proc.go +++ b/pkg/clouds/pulumi/aws/compute_proc.go @@ -48,6 +48,9 @@ func RdsPostgresComputeProcessor(ctx *sdk.Context, stack api.Stack, input api.Re if err != nil { return nil, errors.Wrapf(err, "failed to convert aws account config") } + // ConvertAuth carries only credential fields; preserve the non-credential + // permissions boundary so it reaches the pg-init exec task's role. + accountConfig.PermissionsBoundary = postgresCfg.AccountConfig.PermissionsBoundary postgresCfg.AccountConfig = *accountConfig postgresResName := lo.If(postgresCfg.Name == "", input.Descriptor.Name).Else(postgresCfg.Name) @@ -194,6 +197,9 @@ func RdsMysqlComputeProcessor(ctx *sdk.Context, stack api.Stack, input api.Resou if err != nil { return nil, errors.Wrapf(err, "failed to convert aws account config") } + // ConvertAuth carries only credential fields; preserve the non-credential + // permissions boundary so it reaches the mysql-init exec task's role. + accountConfig.PermissionsBoundary = mysqlCfg.AccountConfig.PermissionsBoundary mysqlCfg.AccountConfig = *accountConfig dbCfg := mysqlCfg diff --git a/pkg/clouds/pulumi/aws/ecs_fargate.go b/pkg/clouds/pulumi/aws/ecs_fargate.go index 57c3108d..60ff6deb 100644 --- a/pkg/clouds/pulumi/aws/ecs_fargate.go +++ b/pkg/clouds/pulumi/aws/ecs_fargate.go @@ -253,8 +253,9 @@ func createEcsFargateCluster(ctx *sdk.Context, stack api.Stack, params pApi.Prov // Create an ECS task execution IAM role roleName := fmt.Sprintf("%s-exec-role", ecsSimpleClusterName) taskExecRole, err := iam.NewRole(ctx, roleName, &iam.RoleArgs{ - Name: sdk.String(ecsSimpleClusterName), - Tags: tags, + Name: sdk.String(ecsSimpleClusterName), + Tags: tags, + PermissionsBoundary: permissionsBoundaryPtr(crInput.AccountConfig.PermissionsBoundary), AssumeRolePolicy: sdk.String(`{ "Version": "2012-10-17", "Statement": [{ @@ -753,16 +754,17 @@ func createEcsAlerts(ctx *sdk.Context, clusterName, serviceName string, stack ap if alerts.MaxCPU != nil { if err := createAlert(ctx, alertCfg{ - name: fmt.Sprintf("%s--%s", alerts.MaxCPU.AlertName, deployParams.Environment), - description: alerts.MaxCPU.Description, - telegramConfig: alerts.Telegram, - discordConfig: alerts.Discord, - slackConfig: alerts.Slack, - deployParams: deployParams, - helpersImage: helpersImage, - secretSuffix: crInput.Config.Version, - opts: opts, - tags: tags, + permissionsBoundary: crInput.AccountConfig.PermissionsBoundary, + name: fmt.Sprintf("%s--%s", alerts.MaxCPU.AlertName, deployParams.Environment), + description: alerts.MaxCPU.Description, + telegramConfig: alerts.Telegram, + discordConfig: alerts.Discord, + slackConfig: alerts.Slack, + deployParams: deployParams, + helpersImage: helpersImage, + secretSuffix: crInput.Config.Version, + opts: opts, + tags: tags, metricAlarmArgs: cloudwatch.MetricAlarmArgs{ ComparisonOperator: sdk.String("GreaterThanThreshold"), EvaluationPeriods: sdk.Int(1), @@ -784,16 +786,17 @@ func createEcsAlerts(ctx *sdk.Context, clusterName, serviceName string, stack ap } if alerts.MaxMemory != nil { if err := createAlert(ctx, alertCfg{ - name: fmt.Sprintf("%s--%s", alerts.MaxMemory.AlertName, deployParams.Environment), - description: alerts.MaxMemory.Description, - telegramConfig: alerts.Telegram, - discordConfig: alerts.Discord, - slackConfig: alerts.Slack, - deployParams: deployParams, - secretSuffix: crInput.Config.Version, - helpersImage: helpersImage, - opts: opts, - tags: tags, + permissionsBoundary: crInput.AccountConfig.PermissionsBoundary, + name: fmt.Sprintf("%s--%s", alerts.MaxMemory.AlertName, deployParams.Environment), + description: alerts.MaxMemory.Description, + telegramConfig: alerts.Telegram, + discordConfig: alerts.Discord, + slackConfig: alerts.Slack, + deployParams: deployParams, + secretSuffix: crInput.Config.Version, + helpersImage: helpersImage, + opts: opts, + tags: tags, metricAlarmArgs: cloudwatch.MetricAlarmArgs{ ComparisonOperator: sdk.String("GreaterThanThreshold"), EvaluationPeriods: sdk.Int(1), @@ -860,17 +863,18 @@ func createEcsAlerts(ctx *sdk.Context, clusterName, serviceName string, stack ap // Server Errors (5XX) Alert if alerts.ServerErrors != nil { if err := createAlert(ctx, alertCfg{ - name: fmt.Sprintf("%s--%s", alerts.ServerErrors.AlertName, deployParams.Environment), - description: alerts.ServerErrors.Description, - telegramConfig: alerts.Telegram, - discordConfig: alerts.Discord, - slackConfig: alerts.Slack, - deployParams: deployParams, - secretSuffix: crInput.Config.Version, - helpersImage: helpersImage, - snsTopic: snsTopic, - opts: opts, - tags: tags, + permissionsBoundary: crInput.AccountConfig.PermissionsBoundary, + name: fmt.Sprintf("%s--%s", alerts.ServerErrors.AlertName, deployParams.Environment), + description: alerts.ServerErrors.Description, + telegramConfig: alerts.Telegram, + discordConfig: alerts.Discord, + slackConfig: alerts.Slack, + deployParams: deployParams, + secretSuffix: crInput.Config.Version, + helpersImage: helpersImage, + snsTopic: snsTopic, + opts: opts, + tags: tags, metricAlarmArgs: cloudwatch.MetricAlarmArgs{ ComparisonOperator: sdk.String("GreaterThanThreshold"), EvaluationPeriods: sdk.Int(2), @@ -893,17 +897,18 @@ func createEcsAlerts(ctx *sdk.Context, clusterName, serviceName string, stack ap // Unhealthy Hosts Alert if alerts.UnhealthyHosts != nil { if err := createAlert(ctx, alertCfg{ - name: fmt.Sprintf("%s--%s", alerts.UnhealthyHosts.AlertName, deployParams.Environment), - description: alerts.UnhealthyHosts.Description, - telegramConfig: alerts.Telegram, - discordConfig: alerts.Discord, - slackConfig: alerts.Slack, - deployParams: deployParams, - secretSuffix: crInput.Config.Version, - helpersImage: helpersImage, - snsTopic: snsTopic, - opts: opts, - tags: tags, + permissionsBoundary: crInput.AccountConfig.PermissionsBoundary, + name: fmt.Sprintf("%s--%s", alerts.UnhealthyHosts.AlertName, deployParams.Environment), + description: alerts.UnhealthyHosts.Description, + telegramConfig: alerts.Telegram, + discordConfig: alerts.Discord, + slackConfig: alerts.Slack, + deployParams: deployParams, + secretSuffix: crInput.Config.Version, + helpersImage: helpersImage, + snsTopic: snsTopic, + opts: opts, + tags: tags, metricAlarmArgs: cloudwatch.MetricAlarmArgs{ ComparisonOperator: sdk.String("GreaterThanOrEqualToThreshold"), EvaluationPeriods: sdk.Int(2), @@ -927,17 +932,18 @@ func createEcsAlerts(ctx *sdk.Context, clusterName, serviceName string, stack ap // Target Response Time Alert if alerts.ResponseTime != nil { if err := createAlert(ctx, alertCfg{ - name: fmt.Sprintf("%s--%s", alerts.ResponseTime.AlertName, deployParams.Environment), - description: alerts.ResponseTime.Description, - telegramConfig: alerts.Telegram, - discordConfig: alerts.Discord, - slackConfig: alerts.Slack, - deployParams: deployParams, - secretSuffix: crInput.Config.Version, - helpersImage: helpersImage, - snsTopic: snsTopic, - opts: opts, - tags: tags, + permissionsBoundary: crInput.AccountConfig.PermissionsBoundary, + name: fmt.Sprintf("%s--%s", alerts.ResponseTime.AlertName, deployParams.Environment), + description: alerts.ResponseTime.Description, + telegramConfig: alerts.Telegram, + discordConfig: alerts.Discord, + slackConfig: alerts.Slack, + deployParams: deployParams, + secretSuffix: crInput.Config.Version, + helpersImage: helpersImage, + snsTopic: snsTopic, + opts: opts, + tags: tags, metricAlarmArgs: cloudwatch.MetricAlarmArgs{ ComparisonOperator: sdk.String("GreaterThanThreshold"), EvaluationPeriods: sdk.Int(3), diff --git a/pkg/clouds/pulumi/aws/exec_ecs_task.go b/pkg/clouds/pulumi/aws/exec_ecs_task.go index a239db68..8ac9d67d 100644 --- a/pkg/clouds/pulumi/aws/exec_ecs_task.go +++ b/pkg/clouds/pulumi/aws/exec_ecs_task.go @@ -49,8 +49,9 @@ func execEcsTask(ctx *sdk.Context, config ecsTaskConfig) error { params.Log.Info(ctx.Context(), "configure exec role for %q", name) execRoleName := fmt.Sprintf("%s-exec-role", name) taskExecRole, err := iam.NewRole(ctx, execRoleName, &iam.RoleArgs{ - Name: sdk.String(execRoleName), - Tags: config.tags, + Name: sdk.String(execRoleName), + Tags: config.tags, + PermissionsBoundary: permissionsBoundaryPtr(config.account.PermissionsBoundary), AssumeRolePolicy: sdk.String(`{ "Version": "2012-10-17", "Statement": [{ diff --git a/pkg/clouds/pulumi/aws/permissions_boundary.go b/pkg/clouds/pulumi/aws/permissions_boundary.go new file mode 100644 index 00000000..9076958c --- /dev/null +++ b/pkg/clouds/pulumi/aws/permissions_boundary.go @@ -0,0 +1,19 @@ +package aws + +import ( + "strings" + + sdk "github.com/pulumi/pulumi/sdk/v3/go/pulumi" +) + +// permissionsBoundaryPtr renders an AccountConfig.PermissionsBoundary ARN as +// the PermissionsBoundary input for an iam.RoleArgs. An empty ARN yields nil, +// i.e. no boundary is set on the role — the default for every stack that does +// not opt in, so existing deployments are unaffected. Setting/adding a +// boundary is an in-place role update (never a replacement). +func permissionsBoundaryPtr(arn string) sdk.StringPtrInput { + if strings.TrimSpace(arn) == "" { + return nil + } + return sdk.String(arn) +} diff --git a/pkg/clouds/pulumi/aws/permissions_boundary_test.go b/pkg/clouds/pulumi/aws/permissions_boundary_test.go new file mode 100644 index 00000000..5ecf7b3a --- /dev/null +++ b/pkg/clouds/pulumi/aws/permissions_boundary_test.go @@ -0,0 +1,32 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) Simple Container + +package aws + +import "testing" + +// permissionsBoundaryPtr's contract is the backward-compat guarantee: an empty +// or whitespace-only ARN must yield nil (no boundary set on the role), so every +// stack that does not opt in is unchanged. A real ARN must yield non-nil. +func TestPermissionsBoundaryPtr(t *testing.T) { + tests := []struct { + name string + arn string + wantNil bool + }{ + {name: "empty means no boundary", arn: "", wantNil: true}, + {name: "whitespace means no boundary", arn: " ", wantNil: true}, + {name: "arn sets a boundary", arn: "arn:aws:iam::123456789012:policy/my-workload-boundary", wantNil: false}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := permissionsBoundaryPtr(tc.arn) + if tc.wantNil && got != nil { + t.Fatalf("permissionsBoundaryPtr(%q) = non-nil, want nil (would attach an unintended boundary)", tc.arn) + } + if !tc.wantNil && got == nil { + t.Fatalf("permissionsBoundaryPtr(%q) = nil, want the boundary to be set", tc.arn) + } + }) + } +} From 666960f1792ec46be85d9086bdc9820a5f98c2f1 Mon Sep 17 00:00:00 2001 From: Dmitrii Creed Date: Mon, 10 Aug 2026 12:48:04 +0400 Subject: [PATCH 2/4] review: only override the preserved boundary when template-level is set The preserve-after-ConvertAuth lines were unconditional, which clobbered an auth-level permissionsBoundary (declared inside the resolved credentials) with the empty template-level sibling, silently no-opping it for the lambda, cloudtrail alert, and pg-init/mysql-init exec roles. Guard each assignment so a template-level value still wins but an auth-level value ConvertAuth already loaded is kept. Consistent behavior regardless of where the field is declared. Signed-off-by: Dmitrii Creed --- pkg/clouds/aws/aws_lambda.go | 9 ++++++--- .../pulumi/aws/cloudtrail_security_alerts.go | 9 ++++++--- pkg/clouds/pulumi/aws/compute_proc.go | 18 ++++++++++++------ 3 files changed, 24 insertions(+), 12 deletions(-) diff --git a/pkg/clouds/aws/aws_lambda.go b/pkg/clouds/aws/aws_lambda.go index 35e69aae..f009b479 100644 --- a/pkg/clouds/aws/aws_lambda.go +++ b/pkg/clouds/aws/aws_lambda.go @@ -51,9 +51,12 @@ func ToAwsLambdaConfig(tpl any, stackCfg *api.StackConfigSingleImage) (any, erro if err != nil { return nil, errors.Wrapf(err, "failed to convert aws account config") } - // ConvertAuth carries only credential fields; preserve the non-credential - // permissions boundary from the template so it reaches the execution role. - accountConfig.PermissionsBoundary = templateCfg.AccountConfig.PermissionsBoundary + // ConvertAuth carries only credential fields. Preserve the non-credential + // permissions boundary so it reaches the execution role: a template-level + // value wins; if unset, keep whatever ConvertAuth loaded (an auth-level one). + if templateCfg.AccountConfig.PermissionsBoundary != "" { + accountConfig.PermissionsBoundary = templateCfg.AccountConfig.PermissionsBoundary + } if stackCfg == nil { return nil, errors.Errorf("stack config cannot be nil") } diff --git a/pkg/clouds/pulumi/aws/cloudtrail_security_alerts.go b/pkg/clouds/pulumi/aws/cloudtrail_security_alerts.go index 6e818d19..714e94a1 100644 --- a/pkg/clouds/pulumi/aws/cloudtrail_security_alerts.go +++ b/pkg/clouds/pulumi/aws/cloudtrail_security_alerts.go @@ -556,9 +556,12 @@ func CloudTrailSecurityAlerts(ctx *sdk.Context, stack api.Stack, input api.Resou if err := api.ConvertAuth(&cfg.AccountConfig, accountConfig); err != nil { return nil, errors.Wrapf(err, "failed to convert aws account config") } - // ConvertAuth carries only credential fields; preserve the non-credential - // permissions boundary so it reaches the alert Lambda's execution role. - accountConfig.PermissionsBoundary = cfg.AccountConfig.PermissionsBoundary + // ConvertAuth carries only credential fields. Preserve the non-credential + // permissions boundary so it reaches the alert Lambda's execution role: a + // template-level value wins; if unset, keep the auth-level one ConvertAuth loaded. + if cfg.AccountConfig.PermissionsBoundary != "" { + accountConfig.PermissionsBoundary = cfg.AccountConfig.PermissionsBoundary + } cfg.AccountConfig = *accountConfig if cfg.LogGroupName == "" { diff --git a/pkg/clouds/pulumi/aws/compute_proc.go b/pkg/clouds/pulumi/aws/compute_proc.go index 25543d82..0d4c5793 100644 --- a/pkg/clouds/pulumi/aws/compute_proc.go +++ b/pkg/clouds/pulumi/aws/compute_proc.go @@ -48,9 +48,12 @@ func RdsPostgresComputeProcessor(ctx *sdk.Context, stack api.Stack, input api.Re if err != nil { return nil, errors.Wrapf(err, "failed to convert aws account config") } - // ConvertAuth carries only credential fields; preserve the non-credential - // permissions boundary so it reaches the pg-init exec task's role. - accountConfig.PermissionsBoundary = postgresCfg.AccountConfig.PermissionsBoundary + // ConvertAuth carries only credential fields. Preserve the non-credential + // permissions boundary so it reaches the pg-init exec task's role: a + // template-level value wins; if unset, keep the auth-level one ConvertAuth loaded. + if postgresCfg.AccountConfig.PermissionsBoundary != "" { + accountConfig.PermissionsBoundary = postgresCfg.AccountConfig.PermissionsBoundary + } postgresCfg.AccountConfig = *accountConfig postgresResName := lo.If(postgresCfg.Name == "", input.Descriptor.Name).Else(postgresCfg.Name) @@ -197,9 +200,12 @@ func RdsMysqlComputeProcessor(ctx *sdk.Context, stack api.Stack, input api.Resou if err != nil { return nil, errors.Wrapf(err, "failed to convert aws account config") } - // ConvertAuth carries only credential fields; preserve the non-credential - // permissions boundary so it reaches the mysql-init exec task's role. - accountConfig.PermissionsBoundary = mysqlCfg.AccountConfig.PermissionsBoundary + // ConvertAuth carries only credential fields. Preserve the non-credential + // permissions boundary so it reaches the mysql-init exec task's role: a + // template-level value wins; if unset, keep the auth-level one ConvertAuth loaded. + if mysqlCfg.AccountConfig.PermissionsBoundary != "" { + accountConfig.PermissionsBoundary = mysqlCfg.AccountConfig.PermissionsBoundary + } mysqlCfg.AccountConfig = *accountConfig dbCfg := mysqlCfg From c6706bae10065fbb6fd86ccb568f84cb52ac553b Mon Sep 17 00:00:00 2001 From: Dmitrii Creed Date: Mon, 10 Aug 2026 18:46:59 +0400 Subject: [PATCH 3/4] review: unify boundary precedence via KeepBoundary, trim ARN, add survival tests Multi-model review of the change surfaced a consistency bug and a coverage gap (no correctness P0). Address them: - Precedence: the two in-place ConvertAuth sites (ecs_fargate, aws_lambda pulumi layer) had no guard, so a boundary declared at BOTH template and auth level let the auth value overwrite the template one, contradicting the "template wins" the other two constructors enforce. Introduce AccountConfig.KeepBoundary and route all six ConvertAuth-into-workload-role sites through it, so precedence is defined once and ECS is explicit instead of correct-by-accident. - permissionsBoundaryPtr now trims the returned value, not just the emptiness check, so a padded ARN can't reach the API malformed. - Broaden the field doc to list all covered role kinds and warn that one boundary caps every workload role in the stack at once (too-tight = whole-stack outage). - Tests: add boundary-survival coverage (KeepBoundary precedence; in-place ConvertAuth; ToAwsLambdaConfig template-level/auth-level/unset) using a boundary-free credentials blob so the preserve logic is actually load-bearing (verified: deleting a KeepBoundary call fails the test). Assert the exact value in TestPermissionsBoundaryPtr, incl. a trimmed padded-ARN case. Signed-off-by: Dmitrii Creed --- pkg/clouds/aws/auth.go | 27 ++++-- pkg/clouds/aws/aws_lambda.go | 9 +- pkg/clouds/aws/boundary_survival_test.go | 84 +++++++++++++++++++ pkg/clouds/pulumi/aws/aws_lambda.go | 8 ++ .../pulumi/aws/cloudtrail_security_alerts.go | 9 +- pkg/clouds/pulumi/aws/compute_proc.go | 18 ++-- pkg/clouds/pulumi/aws/ecs_fargate.go | 6 ++ pkg/clouds/pulumi/aws/permissions_boundary.go | 3 +- .../pulumi/aws/permissions_boundary_test.go | 35 ++++++-- 9 files changed, 161 insertions(+), 38 deletions(-) create mode 100644 pkg/clouds/aws/boundary_survival_test.go diff --git a/pkg/clouds/aws/auth.go b/pkg/clouds/aws/auth.go index 0f296d2e..c0bab265 100644 --- a/pkg/clouds/aws/auth.go +++ b/pkg/clouds/aws/auth.go @@ -25,15 +25,32 @@ type AccountConfig struct { SecretAccessKey string `json:"secretAccessKey" yaml:"secretAccessKey"` Region string `json:"region" yaml:"region"` // PermissionsBoundary, when set to an IAM policy ARN, is applied as the - // permissions boundary on every IAM role SC creates for workloads under - // this account (ECS task/execution roles, Lambda execution roles). Optional - // and empty by default, so it changes nothing unless a parent stack opts in - // per template. Lets an operator cap what a deployed workload role can ever - // do, independent of the role's own policy. + // permissions boundary on every IAM role SC creates for workloads under this + // account: ECS task/execution roles, Lambda and alert-Lambda execution roles, + // and DB-init (pg-init/mysql-init) exec-task roles. Optional and empty by + // default, so it changes nothing unless a parent stack opts in per template. + // Lets an operator cap what a deployed workload role can ever do, independent + // of the role's own policy (effective perms become policy AND boundary). + // WARNING: one ARN caps ALL workload roles in the stack at once — a boundary + // too tight silently strips e.g. ECR pull / logs / SecretsManager from every + // service on the next deploy, so validate it in staging first. PermissionsBoundary string `json:"permissionsBoundary,omitempty" yaml:"permissionsBoundary,omitempty"` api.Credentials `json:",inline" yaml:",inline"` } +// KeepBoundary preserves a non-credential permissions-boundary value across an +// api.ConvertAuth call, which rehydrates an AccountConfig from the resolved +// ${auth:...} credentials blob and so carries only credential fields. Callers +// pass the boundary declared at template level; a non-empty template value wins +// (the documented precedence), while an empty one leaves whatever ConvertAuth +// loaded (an auth-level boundary). Centralizes the precedence so every +// ConvertAuth site that feeds a workload role behaves identically. +func (r *AccountConfig) KeepBoundary(templateBoundary string) { + if templateBoundary != "" { + r.PermissionsBoundary = templateBoundary + } +} + type SecretsConfig struct { AccountConfig `json:",inline" yaml:",inline"` } diff --git a/pkg/clouds/aws/aws_lambda.go b/pkg/clouds/aws/aws_lambda.go index f009b479..5d8568dc 100644 --- a/pkg/clouds/aws/aws_lambda.go +++ b/pkg/clouds/aws/aws_lambda.go @@ -51,12 +51,9 @@ func ToAwsLambdaConfig(tpl any, stackCfg *api.StackConfigSingleImage) (any, erro if err != nil { return nil, errors.Wrapf(err, "failed to convert aws account config") } - // ConvertAuth carries only credential fields. Preserve the non-credential - // permissions boundary so it reaches the execution role: a template-level - // value wins; if unset, keep whatever ConvertAuth loaded (an auth-level one). - if templateCfg.AccountConfig.PermissionsBoundary != "" { - accountConfig.PermissionsBoundary = templateCfg.AccountConfig.PermissionsBoundary - } + // Preserve the template-level boundary across ConvertAuth's credential-only + // rehydrate so it reaches the execution role (see AccountConfig.KeepBoundary). + accountConfig.KeepBoundary(templateCfg.AccountConfig.PermissionsBoundary) if stackCfg == nil { return nil, errors.Errorf("stack config cannot be nil") } diff --git a/pkg/clouds/aws/boundary_survival_test.go b/pkg/clouds/aws/boundary_survival_test.go new file mode 100644 index 00000000..09148269 --- /dev/null +++ b/pkg/clouds/aws/boundary_survival_test.go @@ -0,0 +1,84 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) Simple Container + +package aws + +import ( + "testing" + + . "github.com/onsi/gomega" + + "github.com/simple-container-com/api/pkg/api" +) + +const testBoundaryARN = "arn:aws:iam::123456789012:policy/my-workload-boundary" + +// authBlob is a resolved ${auth:...} credentials blob WITHOUT permissionsBoundary. +// Using it (rather than an empty Credentials.Credentials) is what makes these +// tests load-bearing: with empty creds, CredentialsValue marshals the whole +// struct so the boundary rides along even if the preserve logic is deleted. +const authBlob = `{"account":"123456789012","region":"us-east-1"}` + +// KeepBoundary centralizes the "template wins, else keep what ConvertAuth +// loaded" precedence. Pin both directions. +func TestAccountConfig_KeepBoundary(t *testing.T) { + RegisterTestingT(t) + + // A non-empty template value overrides whatever is already set. + ac := AccountConfig{PermissionsBoundary: "auth-level-arn"} + ac.KeepBoundary("template-level-arn") + Expect(ac.PermissionsBoundary).To(Equal("template-level-arn")) + + // An empty template value leaves the existing (auth-level) value intact. + ac2 := AccountConfig{PermissionsBoundary: "auth-level-arn"} + ac2.KeepBoundary("") + Expect(ac2.PermissionsBoundary).To(Equal("auth-level-arn")) +} + +// The in-place ConvertAuth path used by the ECS/Lambda pulumi constructors: +// a template-level boundary must survive the credential rehydrate. +func TestConvertAuth_InPlacePreservesTemplateBoundary(t *testing.T) { + RegisterTestingT(t) + ac := AccountConfig{ + PermissionsBoundary: testBoundaryARN, + Credentials: api.Credentials{Credentials: authBlob}, + } + tpl := ac.PermissionsBoundary + Expect(api.ConvertAuth(&ac, &ac)).To(Succeed()) + ac.KeepBoundary(tpl) + Expect(ac.PermissionsBoundary).To(Equal(testBoundaryARN)) // survived + Expect(ac.Account).To(Equal("123456789012")) // creds still loaded +} + +// The fresh-struct converter path (ToAwsLambdaConfig): boundary must reach the +// resulting *LambdaInput for both template-level and auth-level declaration, +// and stay empty when unset. +func TestToAwsLambdaConfig_PermissionsBoundary(t *testing.T) { + t.Run("template-level survives ConvertAuth", func(t *testing.T) { + RegisterTestingT(t) + tpl := &TemplateConfig{AccountConfig: AccountConfig{ + PermissionsBoundary: testBoundaryARN, + Credentials: api.Credentials{Credentials: authBlob}, + }} + out, err := ToAwsLambdaConfig(tpl, &api.StackConfigSingleImage{}) + Expect(err).ToNot(HaveOccurred()) + Expect(out.(*LambdaInput).PermissionsBoundary).To(Equal(testBoundaryARN)) + }) + + t.Run("auth-level survives ConvertAuth", func(t *testing.T) { + RegisterTestingT(t) + tpl := &TemplateConfig{AccountConfig: AccountConfig{ + Credentials: api.Credentials{Credentials: `{"account":"123456789012","region":"us-east-1","permissionsBoundary":"` + testBoundaryARN + `"}`}, + }} + out, err := ToAwsLambdaConfig(tpl, &api.StackConfigSingleImage{}) + Expect(err).ToNot(HaveOccurred()) + Expect(out.(*LambdaInput).PermissionsBoundary).To(Equal(testBoundaryARN)) + }) + + t.Run("unset stays empty (no-op contract)", func(t *testing.T) { + RegisterTestingT(t) + out, err := ToAwsLambdaConfig(validTemplateConfig(), &api.StackConfigSingleImage{}) + Expect(err).ToNot(HaveOccurred()) + Expect(out.(*LambdaInput).PermissionsBoundary).To(BeEmpty()) + }) +} diff --git a/pkg/clouds/pulumi/aws/aws_lambda.go b/pkg/clouds/pulumi/aws/aws_lambda.go index 79e33138..23df3baa 100644 --- a/pkg/clouds/pulumi/aws/aws_lambda.go +++ b/pkg/clouds/pulumi/aws/aws_lambda.go @@ -45,9 +45,17 @@ func Lambda(ctx *sdk.Context, stack api.Stack, input api.ResourceInput, params p if !ok { return output, errors.Errorf("failed to convert aws-lambda config for %q in stack %q in %q", input.Descriptor.Type, stack.Name, deployParams.Environment) } + // This in-place ConvertAuth re-reads the credentials blob over + // crInput.AccountConfig; capture the (template-level) boundary first and + // re-assert it after so "template wins" holds consistently with the other + // constructors, and so a future switch to the fresh-struct idiom can't + // silently drop it. json.Unmarshal already leaves absent fields untouched; + // this makes the intent explicit and survives a dual (template+auth) decl. + tplBoundary := crInput.AccountConfig.PermissionsBoundary if err := api.ConvertAuth(crInput, &crInput.AccountConfig); err != nil { return nil, errors.Wrapf(err, "failed to convert auth config to aws.AccountConfig") } + crInput.AccountConfig.KeepBoundary(tplBoundary) stackConfig := crInput.StackConfig awsCloudExtras := &aws.CloudExtras{} diff --git a/pkg/clouds/pulumi/aws/cloudtrail_security_alerts.go b/pkg/clouds/pulumi/aws/cloudtrail_security_alerts.go index 714e94a1..16e5dbfb 100644 --- a/pkg/clouds/pulumi/aws/cloudtrail_security_alerts.go +++ b/pkg/clouds/pulumi/aws/cloudtrail_security_alerts.go @@ -556,12 +556,9 @@ func CloudTrailSecurityAlerts(ctx *sdk.Context, stack api.Stack, input api.Resou if err := api.ConvertAuth(&cfg.AccountConfig, accountConfig); err != nil { return nil, errors.Wrapf(err, "failed to convert aws account config") } - // ConvertAuth carries only credential fields. Preserve the non-credential - // permissions boundary so it reaches the alert Lambda's execution role: a - // template-level value wins; if unset, keep the auth-level one ConvertAuth loaded. - if cfg.AccountConfig.PermissionsBoundary != "" { - accountConfig.PermissionsBoundary = cfg.AccountConfig.PermissionsBoundary - } + // Preserve the template-level boundary across ConvertAuth so it reaches the + // alert Lambda's execution role (see awsApi.AccountConfig.KeepBoundary). + accountConfig.KeepBoundary(cfg.AccountConfig.PermissionsBoundary) cfg.AccountConfig = *accountConfig if cfg.LogGroupName == "" { diff --git a/pkg/clouds/pulumi/aws/compute_proc.go b/pkg/clouds/pulumi/aws/compute_proc.go index 0d4c5793..0daf63c2 100644 --- a/pkg/clouds/pulumi/aws/compute_proc.go +++ b/pkg/clouds/pulumi/aws/compute_proc.go @@ -48,12 +48,9 @@ func RdsPostgresComputeProcessor(ctx *sdk.Context, stack api.Stack, input api.Re if err != nil { return nil, errors.Wrapf(err, "failed to convert aws account config") } - // ConvertAuth carries only credential fields. Preserve the non-credential - // permissions boundary so it reaches the pg-init exec task's role: a - // template-level value wins; if unset, keep the auth-level one ConvertAuth loaded. - if postgresCfg.AccountConfig.PermissionsBoundary != "" { - accountConfig.PermissionsBoundary = postgresCfg.AccountConfig.PermissionsBoundary - } + // Preserve the template-level boundary across ConvertAuth so it reaches the + // pg-init exec task's role (see aws.AccountConfig.KeepBoundary). + accountConfig.KeepBoundary(postgresCfg.AccountConfig.PermissionsBoundary) postgresCfg.AccountConfig = *accountConfig postgresResName := lo.If(postgresCfg.Name == "", input.Descriptor.Name).Else(postgresCfg.Name) @@ -200,12 +197,9 @@ func RdsMysqlComputeProcessor(ctx *sdk.Context, stack api.Stack, input api.Resou if err != nil { return nil, errors.Wrapf(err, "failed to convert aws account config") } - // ConvertAuth carries only credential fields. Preserve the non-credential - // permissions boundary so it reaches the mysql-init exec task's role: a - // template-level value wins; if unset, keep the auth-level one ConvertAuth loaded. - if mysqlCfg.AccountConfig.PermissionsBoundary != "" { - accountConfig.PermissionsBoundary = mysqlCfg.AccountConfig.PermissionsBoundary - } + // Preserve the template-level boundary across ConvertAuth so it reaches the + // mysql-init exec task's role (see aws.AccountConfig.KeepBoundary). + accountConfig.KeepBoundary(mysqlCfg.AccountConfig.PermissionsBoundary) mysqlCfg.AccountConfig = *accountConfig dbCfg := mysqlCfg diff --git a/pkg/clouds/pulumi/aws/ecs_fargate.go b/pkg/clouds/pulumi/aws/ecs_fargate.go index 60ff6deb..83ae1a7b 100644 --- a/pkg/clouds/pulumi/aws/ecs_fargate.go +++ b/pkg/clouds/pulumi/aws/ecs_fargate.go @@ -89,9 +89,15 @@ func EcsFargate(ctx *sdk.Context, stack api.Stack, input api.ResourceInput, para if !ok { return output, errors.Errorf("failed to convert ecs_fargate config for %q in stack %q in %q", input.Descriptor.Type, stack.Name, deployParams.Environment) } + // This in-place ConvertAuth re-reads the credentials blob over + // crInput.AccountConfig (which createEcsFargateCluster reads for the role's + // boundary). Capture the template-level boundary first and re-assert it so + // "template wins" holds and a future refactor can't silently drop it. + tplBoundary := crInput.AccountConfig.PermissionsBoundary if err := api.ConvertAuth(crInput, &crInput.AccountConfig); err != nil { return nil, errors.Wrapf(err, "failed to convert auth config to aws.AccountConfig") } + crInput.AccountConfig.KeepBoundary(tplBoundary) params.Log.Debug(ctx.Context(), "configure ECS Fargate for stack %q in %q: %+v...", stack.Name, deployParams.Environment, crInput) diff --git a/pkg/clouds/pulumi/aws/permissions_boundary.go b/pkg/clouds/pulumi/aws/permissions_boundary.go index 9076958c..1008a7ce 100644 --- a/pkg/clouds/pulumi/aws/permissions_boundary.go +++ b/pkg/clouds/pulumi/aws/permissions_boundary.go @@ -12,7 +12,8 @@ import ( // not opt in, so existing deployments are unaffected. Setting/adding a // boundary is an in-place role update (never a replacement). func permissionsBoundaryPtr(arn string) sdk.StringPtrInput { - if strings.TrimSpace(arn) == "" { + arn = strings.TrimSpace(arn) + if arn == "" { return nil } return sdk.String(arn) diff --git a/pkg/clouds/pulumi/aws/permissions_boundary_test.go b/pkg/clouds/pulumi/aws/permissions_boundary_test.go index 5ecf7b3a..0ebabf23 100644 --- a/pkg/clouds/pulumi/aws/permissions_boundary_test.go +++ b/pkg/clouds/pulumi/aws/permissions_boundary_test.go @@ -3,30 +3,49 @@ package aws -import "testing" +import ( + "testing" -// permissionsBoundaryPtr's contract is the backward-compat guarantee: an empty -// or whitespace-only ARN must yield nil (no boundary set on the role), so every -// stack that does not opt in is unchanged. A real ARN must yield non-nil. + sdk "github.com/pulumi/pulumi/sdk/v3/go/pulumi" +) + +// permissionsBoundaryPtr's contract: an empty/whitespace-only ARN yields nil +// (no boundary set — the backward-compat guarantee for stacks that don't opt +// in), and a real ARN yields the trimmed value (a downstream enforcement flip +// matches the boundary EXACTLY, so a wrong/padded value must not slip through). func TestPermissionsBoundaryPtr(t *testing.T) { tests := []struct { name string arn string wantNil bool + wantVal string // expected sdk.String value when !wantNil }{ {name: "empty means no boundary", arn: "", wantNil: true}, {name: "whitespace means no boundary", arn: " ", wantNil: true}, - {name: "arn sets a boundary", arn: "arn:aws:iam::123456789012:policy/my-workload-boundary", wantNil: false}, + {name: "arn sets a boundary", arn: "arn:aws:iam::123456789012:policy/my-workload-boundary", wantNil: false, wantVal: "arn:aws:iam::123456789012:policy/my-workload-boundary"}, + {name: "surrounding whitespace is trimmed", arn: " arn:aws:iam::123456789012:policy/my-workload-boundary ", wantNil: false, wantVal: "arn:aws:iam::123456789012:policy/my-workload-boundary"}, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { got := permissionsBoundaryPtr(tc.arn) - if tc.wantNil && got != nil { - t.Fatalf("permissionsBoundaryPtr(%q) = non-nil, want nil (would attach an unintended boundary)", tc.arn) + if tc.wantNil { + if got != nil { + t.Fatalf("permissionsBoundaryPtr(%q) = non-nil, want nil (would attach an unintended boundary)", tc.arn) + } + return } - if !tc.wantNil && got == nil { + if got == nil { t.Fatalf("permissionsBoundaryPtr(%q) = nil, want the boundary to be set", tc.arn) } + // Assert the exact value, not just non-nil: a bug returning a wrong or + // untrimmed ARN would break the exact-match enforcement flip. + s, ok := got.(sdk.String) + if !ok { + t.Fatalf("permissionsBoundaryPtr(%q) is %T, want sdk.String", tc.arn, got) + } + if string(s) != tc.wantVal { + t.Fatalf("permissionsBoundaryPtr(%q) = %q, want %q", tc.arn, string(s), tc.wantVal) + } }) } } From 7e59744c3d9c29a6bdcda481f2453730c1b545dc Mon Sep 17 00:00:00 2001 From: Dmitrii Creed Date: Tue, 11 Aug 2026 12:51:11 +0400 Subject: [PATCH 4/4] docs(aws): document permissionsBoundary on parent template config Adds a section to the ECS Fargate parent guide covering the optional permissionsBoundary field: which workload roles it caps, the ceiling/intersection semantics, empty-by-default compatibility, the in-place PutRolePermissionsBoundary update, template-vs-auth precedence, and a too-tight-boundary warning. Adds a commented example line to the multi-region parent server.yaml. Signed-off-by: Dmitrii Creed --- .../aws-multi-region/server.yaml | 4 +++ docs/docs/guides/parent-ecs-fargate.md | 28 +++++++++++++++++++ 2 files changed, 32 insertions(+) diff --git a/docs/docs/examples/parent-stacks/aws-multi-region/server.yaml b/docs/docs/examples/parent-stacks/aws-multi-region/server.yaml index 42701ee2..97d5ef7d 100644 --- a/docs/docs/examples/parent-stacks/aws-multi-region/server.yaml +++ b/docs/docs/examples/parent-stacks/aws-multi-region/server.yaml @@ -24,6 +24,10 @@ templates: config: &aws-eu-cfg credentials: "${auth:aws-eu}" # Required: AWS authentication account: "${auth:aws-eu.projectId}" # Required: AWS account/project ID + # Optional: cap every workload IAM role SC mints under this template with a + # permissions boundary (ECS task/exec, Lambda, DB-init roles). Uncomment and + # point at a real policy ARN; the boundary must exist or CreateRole fails. + # permissionsBoundary: "arn:aws:iam::123456789012:policy/sc-workload-boundary" stack-per-app-us: type: ecs-fargate config: &aws-us-cfg diff --git a/docs/docs/guides/parent-ecs-fargate.md b/docs/docs/guides/parent-ecs-fargate.md index 3521d603..a00afc30 100644 --- a/docs/docs/guides/parent-ecs-fargate.md +++ b/docs/docs/guides/parent-ecs-fargate.md @@ -146,6 +146,34 @@ resources: --- +## **Optional: Cap Workload Roles with a Permissions Boundary** + +Set `permissionsBoundary` on a template's `config` to an IAM policy ARN. Simple Container then attaches it as the **permissions boundary** on every IAM role it creates for workloads under that template: + +- ECS task and execution roles +- Lambda execution roles (including alert / CloudTrail-security-alert Lambdas) +- DB-init (`pg-init` / `mysql-init`) task roles + +```yaml +templates: + stack-per-app: + type: ecs-fargate + config: + credentials: "${auth:aws}" + account: "${auth:aws.projectId}" + permissionsBoundary: "arn:aws:iam::123456789012:policy/sc-workload-boundary" +``` + +A permissions boundary is a **ceiling**: a role's effective permissions become the intersection of its own policy and the boundary. This lets an operator provision SC workloads under a least-privilege deploy identity while guaranteeing a hard limit on what any minted role can ever do. + +**Defaults and compatibility.** The field is optional and empty by default — leave it unset and nothing changes. Adding or changing it later is an in-place role update (`PutRolePermissionsBoundary`), not a role replacement, so existing deployments are not recreated. + +**Precedence.** Declare it either at the template level (as above) or inside the `${auth:...}` credentials block. A non-empty template-level value wins. + +> **Warning:** one ARN caps **all** workload roles for the template at once. A boundary that is too tight silently strips permissions (ECR pull, CloudWatch Logs, Secrets Manager, …) from every service on the next deploy. Validate the boundary in staging before rolling it to production. + +--- + # **Provisioning the AWS & MongoDB Atlas Parent Stack** Once `server.yaml` is configured, **provision** the infrastructure: