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: 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..c0bab265 100644 --- a/pkg/clouds/aws/auth.go +++ b/pkg/clouds/aws/auth.go @@ -24,7 +24,31 @@ 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 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 { 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..5d8568dc 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") } + // 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/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..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{} @@ -96,7 +104,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..16e5dbfb 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") } + // 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 == "" { @@ -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..0daf63c2 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") } + // 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) @@ -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") } + // 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 57c3108d..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) @@ -253,8 +259,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 +760,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 +792,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 +869,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 +903,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 +938,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..1008a7ce --- /dev/null +++ b/pkg/clouds/pulumi/aws/permissions_boundary.go @@ -0,0 +1,20 @@ +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 { + 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 new file mode 100644 index 00000000..0ebabf23 --- /dev/null +++ b/pkg/clouds/pulumi/aws/permissions_boundary_test.go @@ -0,0 +1,51 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) Simple Container + +package aws + +import ( + "testing" + + 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, 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 { + if got != nil { + t.Fatalf("permissionsBoundaryPtr(%q) = non-nil, want nil (would attach an unintended boundary)", tc.arn) + } + return + } + 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) + } + }) + } +}