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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
28 changes: 28 additions & 0 deletions docs/docs/guides/parent-ecs-fargate.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down
3 changes: 3 additions & 0 deletions docs/schemas/aws/accountconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,9 @@
"account": {
"type": "string"
},
"permissionsBoundary": {
"type": "string"
},
"region": {
"type": "string"
},
Expand Down
3 changes: 3 additions & 0 deletions docs/schemas/aws/cloudtrailsecurityalertsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,9 @@
"account": {
"type": "string"
},
"permissionsBoundary": {
"type": "string"
},
"region": {
"type": "string"
},
Expand Down
3 changes: 3 additions & 0 deletions docs/schemas/aws/ecrrepository.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,9 @@
"account": {
"type": "string"
},
"permissionsBoundary": {
"type": "string"
},
"region": {
"type": "string"
},
Expand Down
3 changes: 3 additions & 0 deletions docs/schemas/aws/mysqlconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,9 @@
"account": {
"type": "string"
},
"permissionsBoundary": {
"type": "string"
},
"region": {
"type": "string"
},
Expand Down
3 changes: 3 additions & 0 deletions docs/schemas/aws/postgresconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,9 @@
"account": {
"type": "string"
},
"permissionsBoundary": {
"type": "string"
},
"region": {
"type": "string"
},
Expand Down
3 changes: 3 additions & 0 deletions docs/schemas/aws/secretsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,9 @@
"account": {
"type": "string"
},
"permissionsBoundary": {
"type": "string"
},
"region": {
"type": "string"
},
Expand Down
3 changes: 3 additions & 0 deletions docs/schemas/aws/secretsproviderconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,9 @@
"account": {
"type": "string"
},
"permissionsBoundary": {
"type": "string"
},
"region": {
"type": "string"
},
Expand Down
3 changes: 3 additions & 0 deletions docs/schemas/aws/statestorageconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,9 @@
"account": {
"type": "string"
},
"permissionsBoundary": {
"type": "string"
},
"region": {
"type": "string"
},
Expand Down
3 changes: 3 additions & 0 deletions docs/schemas/aws/templateconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,9 @@
"account": {
"type": "string"
},
"permissionsBoundary": {
"type": "string"
},
"region": {
"type": "string"
},
Expand Down
26 changes: 25 additions & 1 deletion pkg/clouds/aws/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
21 changes: 21 additions & 0 deletions pkg/clouds/aws/auth_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
3 changes: 3 additions & 0 deletions pkg/clouds/aws/aws_lambda.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
Expand Down
84 changes: 84 additions & 0 deletions pkg/clouds/aws/boundary_survival_test.go
Original file line number Diff line number Diff line change
@@ -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())
})
}
6 changes: 5 additions & 1 deletion pkg/clouds/pulumi/aws/alerts.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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": [{
Expand Down
11 changes: 10 additions & 1 deletion pkg/clouds/pulumi/aws/aws_lambda.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{}
Expand Down Expand Up @@ -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": [{
Expand Down
36 changes: 20 additions & 16 deletions pkg/clouds/pulumi/aws/cloudtrail_security_alerts.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 == "" {
Expand Down Expand Up @@ -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)
}
Expand Down
Loading
Loading