Mail sending users are created under a permissions boundary
The console's role may call iam:CreateUser, iam:PutUserPolicy and iam:CreateAccessKey on user/carlos-mail/*, and the mail provisioner created those users with no permissions boundary. Anyone able to act as the role could make a user there, give it an administrator inline policy and mint it a key: the whole AWS account. Object storage closed the same gap with CARLOS_STORE_USER_BOUNDARY; this mirrors it. - mail.NewAWSProvisioner takes the boundary ARN and refuses an empty or malformed one before any AWS call. EnsureUser passes it to CreateUser and refuses to act without one. - An existing user (CreateUser AlreadyExists) is checked against the GetUser the code already made. A missing or different boundary is set with iam:PutUserPermissionsBoundary BEFORE PutUserPolicy, so existing unbounded users are fixed on their next EnsureUser (enable, standalone create, policy refresh) and a PutUserPolicy conditioned on iam:PermissionsBoundary does not block the backfill. Nothing calls DeleteUserPermissionsBoundary; it is not on the iamAPI interface. - cmd/console reads CARLOS_MAIL_USER_BOUNDARY and log.Fatals when CARLOS_MAIL_REGION is set and the boundary is empty or not a policy ARN (mailUserBoundary). Documented in main.go's header, with the IAM the role now needs. Tests drive the real awsProvisioner against a stub IAM client: the boundary on CreateUser, the backfill (none, empty, different), no extra call when already set, no policy write after a failed backfill, refusal with no boundary, and constructor refusal. TestMailUserBoundary covers the console's startup check.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
4 files changed,
+458
−11
cmd/console/mailbackends_test.go+41 −0cmd/console/main.go+57 −5internal/mail/aws.go+97 −6internal/mail/aws_iam_test.go+263 −0
diff --git a/cmd/console/mailbackends_test.go b/cmd/console/mailbackends_test.go| index 2ffd0ed32..a23713093 100644 |
| --- a/cmd/console/mailbackends_test.go |
| +++ b/cmd/console/mailbackends_test.go |
| @@ -3,6 +3,7 @@ package main |
| import ( |
| "context" |
| "errors" |
| + "strings" |
| "testing" |
| "github.com/aws/aws-sdk-go-v2/service/sesv2" |
| @@ -176,3 +177,43 @@ type fakeSESClient struct{} |
| func (fakeSESClient) SendEmail(context.Context, *sesv2.SendEmailInput, ...func(*sesv2.Options)) (*sesv2.SendEmailOutput, error) { |
| return &sesv2.SendEmailOutput{}, nil |
| } |
| + |
| +// TestMailUserBoundary is the console's startup check on |
| +// CARLOS_MAIL_USER_BOUNDARY, the mail twin of storeBackend's |
| +// CARLOS_STORE_USER_BOUNDARY: with CARLOS_MAIL_REGION set (the mail |
| +// provisioner on), a missing or malformed boundary is refused, because the |
| +// provisioner would otherwise create sending users with no ceiling. With no |
| +// region there is no provisioner, so nothing is required. |
| +func TestMailUserBoundary(t *testing.T) { |
| + const arn = "arn:aws:iam::123456789012:policy/carlos-mail-boundary" |
| + for _, tc := range []struct { |
| + name, region, raw, want string |
| + wantErr bool |
| + }{ |
| + {name: "no region, no boundary", region: "", raw: ""}, |
| + {name: "no region, boundary ignored", region: "", raw: arn}, |
| + {name: "region, no boundary", region: "eu-west-1", raw: "", wantErr: true}, |
| + {name: "region, blank boundary", region: "eu-west-1", raw: " ", wantErr: true}, |
| + {name: "region, not a policy ARN", region: "eu-west-1", raw: "carlos-mail-boundary", wantErr: true}, |
| + {name: "region, boundary", region: "eu-west-1", raw: " " + arn + "\n", want: arn}, |
| + } { |
| + t.Run(tc.name, func(t *testing.T) { |
| + got, err := mailUserBoundary(tc.region, tc.raw) |
| + if tc.wantErr { |
| + if err == nil { |
| + t.Fatalf("mailUserBoundary(%q, %q) = %q, want an error", tc.region, tc.raw, got) |
| + } |
| + if !strings.Contains(err.Error(), "CARLOS_MAIL_USER_BOUNDARY") { |
| + t.Fatalf("error %q does not name CARLOS_MAIL_USER_BOUNDARY", err) |
| + } |
| + return |
| + } |
| + if err != nil { |
| + t.Fatalf("mailUserBoundary(%q, %q): %v", tc.region, tc.raw, err) |
| + } |
| + if got != tc.want { |
| + t.Fatalf("mailUserBoundary(%q, %q) = %q, want %q", tc.region, tc.raw, got, tc.want) |
| + } |
| + }) |
| + } |
| +} |
diff --git a/cmd/console/main.go b/cmd/console/main.go| index c95cfa05f..921bc524b 100644 |
| --- a/cmd/console/main.go |
| +++ b/cmd/console/main.go |
| @@ -54,9 +54,10 @@ |
| // Tenant email (task 17) reads CARLOS_MAIL_OPERATORS (comma-separated |
| // addresses; narrows the fleet-operator bit further for the pause/cap |
| // door only — see mailOperators' doc comment), CARLOS_MAIL_REGION, |
| -// CARLOS_MAIL_PLATFORM_SUFFIX, CARLOS_MAIL_DMARC_RUA and |
| -// CARLOS_MAIL_SNS_TOPIC_ARN. All five are read regardless of whether a mail |
| -// Provisioner is wired. CARLOS_MAIL_REGION set turns it on: mailBackends |
| +// CARLOS_MAIL_PLATFORM_SUFFIX, CARLOS_MAIL_DMARC_RUA, |
| +// CARLOS_MAIL_SNS_TOPIC_ARN and CARLOS_MAIL_USER_BOUNDARY (below). All six |
| +// are read regardless of whether a mail Provisioner is wired. |
| +// CARLOS_MAIL_REGION set turns it on: mailBackends |
| // constructs a real AWS SES/IAM Provisioner, and every email door stops |
| // 404ing (mailProvisionerGate); unset leaves the Provisioner nil, same |
| // "config costs nothing to hold ahead of the backend" reasoning as |
| @@ -68,6 +69,26 @@ |
| // field nil rather than stopping the console from booting — see |
| // mailBackends' doc comment. |
| // |
| +// CARLOS_MAIL_USER_BOUNDARY is the one mail setting that IS fatal. With |
| +// CARLOS_MAIL_REGION set it is required: the ARN of the IAM managed policy |
| +// every sending user (/carlos-mail/ path) is created under, as |
| +// CARLOS_STORE_USER_BOUNDARY is for store users (mailUserBoundary). The |
| +// boundary should allow ses:SendRawEmail (the one action the inline policy, |
| +// mail.SendPolicyJSON, grants) and nothing else; each user's inline policy |
| +// narrows it to that app's identities. A user created before the boundary |
| +// existed is given it the next time EnsureUser runs for it (`carlos email |
| +// enable`, a standalone credential, or a domain change refreshing the |
| +// app's policies), so the console role needs |
| +// iam:PutUserPermissionsBoundary on user/carlos-mail/* beside CreateUser, |
| +// PutUserPolicy and the key calls. Condition PutUserPermissionsBoundary on |
| +// iam:PermissionsBoundary equal to this ARN from the start, or the role can |
| +// set any boundary at all. Once every console runs a build that sets the |
| +// boundary and every existing user carries it, condition CreateUser and |
| +// PutUserPolicy on it as well. EnsureUser sets the boundary before it |
| +// writes the policy, so a conditioned PutUserPolicy does not block the |
| +// backfill. |
| +// Never grant iam:DeleteUserPermissionsBoundary; nothing here calls it. |
| +// |
| // The console's own email (the invite fallback when Keymail cannot deliver) |
| // sends From CARLOS_CONSOLE_MAIL_FROM, defaulting to |
| // noreply@console-mail.bab.<CARLOS_MAIL_PLATFORM_SUFFIX> when the suffix is set. |
| @@ -89,6 +110,7 @@ package main |
| import ( |
| "context" |
| "crypto/ed25519" |
| + "errors" |
| "flag" |
| "fmt" |
| "log" |
| @@ -311,7 +333,14 @@ func serve() { |
| } |
| } |
| mailDep := mailDeployment() |
| - mailProvisioner, dnsWriter := mailBackends(mailDep, mail.NewAWSProvisioner, dnsrec.NewRoute53Writer) |
| + mailBoundary, err := mailUserBoundary(mailDep.Region, os.Getenv("CARLOS_MAIL_USER_BOUNDARY")) |
| + if err != nil { |
| + log.Fatal(err) |
| + } |
| + newMailProvisioner := func(ctx context.Context, region string) (mail.Provisioner, error) { |
| + return mail.NewAWSProvisioner(ctx, region, mailBoundary) |
| + } |
| + mailProvisioner, dnsWriter := mailBackends(mailDep, newMailProvisioner, dnsrec.NewRoute53Writer) |
| // One release.Store, named, because the senders sweep below needs the |
| // same one the server got. |
| rel := openStore() |
| @@ -937,7 +966,9 @@ func mailOperators() []string { |
| // under would repeat the exact class of bug #167 already cost this |
| // codebase once, just for a new pair of variables. |
| // |
| -// NOTHING HERE IS FATAL, unlike storeBackend's coordinates, and — unlike |
| +// NOTHING HERE IS FATAL, unlike storeBackend's coordinates (the user |
| +// boundary, CARLOS_MAIL_USER_BOUNDARY, is read and checked separately by |
| +// mailUserBoundary, and that one is fatal), and — unlike |
| // the reasoning that used to sit in this comment — that stays true even |
| // now that mailBackends actually constructs a Provisioner and DNS writer |
| // from Region and PlatformSuffix. A store deployment booted with the wrong |
| @@ -966,6 +997,27 @@ func mailDeployment() mail.Deployment { |
| } |
| } |
| +// mailUserBoundary checks CARLOS_MAIL_USER_BOUNDARY (raw) against the mail |
| +// region and returns it trimmed. It is the one mail setting that IS fatal, |
| +// for storeBackend's CARLOS_STORE_USER_BOUNDARY reason: the mail provisioner |
| +// calls CreateUser and PutUserPolicy, and a role holding those without a |
| +// boundary can make a user wider than itself, which turns the console's |
| +// credentials into a route to the whole AWS account. Without a region there |
| +// is no provisioner, so the boundary is not needed and not checked. |
| +func mailUserBoundary(region, raw string) (string, error) { |
| + if region == "" { |
| + return "", nil |
| + } |
| + boundary := strings.TrimSpace(raw) |
| + if boundary == "" { |
| + return "", errors.New("console: CARLOS_MAIL_REGION is set, so also set CARLOS_MAIL_USER_BOUNDARY (the ARN of the permissions-boundary policy that email sending users are created under; leave CARLOS_MAIL_REGION unset for a console with no tenant email)") |
| + } |
| + if err := mail.CheckUserBoundary(boundary); err != nil { |
| + return "", fmt.Errorf("console: CARLOS_MAIL_USER_BOUNDARY: %w", err) |
| + } |
| + return boundary, nil |
| +} |
| + |
| // mailBackends constructs the tenant-email Provisioner and DNS Writer from |
| // dep's coordinates — the wiring storeBackend has had since issue #55, now |
| // extended to mail (task 17, "the last code step before the feature can be |
diff --git a/internal/mail/aws.go b/internal/mail/aws.go| index f57133c8a..c6444e73d 100644 |
| --- a/internal/mail/aws.go |
| +++ b/internal/mail/aws.go |
| @@ -31,6 +31,7 @@ import ( |
| "context" |
| "errors" |
| "fmt" |
| + "strings" |
| "github.com/aws/aws-sdk-go-v2/aws" |
| awsconfig "github.com/aws/aws-sdk-go-v2/config" |
| @@ -72,6 +73,11 @@ type iamAPI interface { |
| CreateUser(context.Context, *iam.CreateUserInput, ...func(*iam.Options)) (*iam.CreateUserOutput, error) |
| GetUser(context.Context, *iam.GetUserInput, ...func(*iam.Options)) (*iam.GetUserOutput, error) |
| PutUserPolicy(context.Context, *iam.PutUserPolicyInput, ...func(*iam.Options)) (*iam.PutUserPolicyOutput, error) |
| + // PutUserPermissionsBoundary backfills the boundary onto a user that |
| + // already exists without it (EnsureUser). Its inverse, |
| + // DeleteUserPermissionsBoundary, is deliberately NOT on this interface: |
| + // nothing in this package may ever take a ceiling off. |
| + PutUserPermissionsBoundary(context.Context, *iam.PutUserPermissionsBoundaryInput, ...func(*iam.Options)) (*iam.PutUserPermissionsBoundaryOutput, error) |
| DeleteUserPolicy(context.Context, *iam.DeleteUserPolicyInput, ...func(*iam.Options)) (*iam.DeleteUserPolicyOutput, error) |
| CreateAccessKey(context.Context, *iam.CreateAccessKeyInput, ...func(*iam.Options)) (*iam.CreateAccessKeyOutput, error) |
| UpdateAccessKey(context.Context, *iam.UpdateAccessKeyInput, ...func(*iam.Options)) (*iam.UpdateAccessKeyOutput, error) |
| @@ -91,6 +97,9 @@ type awsProvisioner struct { |
| iam iamAPI |
| region string |
| account string |
| + // boundary is the permissions-boundary policy ARN every sending user |
| + // carries. See EnsureUser for why it is not optional. |
| + boundary string |
| } |
| var _ Provisioner = (*awsProvisioner)(nil) |
| @@ -106,7 +115,15 @@ var _ Provisioner = (*awsProvisioner)(nil) |
| // SESv2 does not return an identity ARN, so EnsureIdentity must compose one, |
| // and composing it needs the account id. Resolving it once at construction |
| // keeps every walk free of an extra round trip. |
| -func NewAWSProvisioner(ctx context.Context, region string) (Provisioner, error) { |
| +// |
| +// boundary is the ARN of the IAM managed policy every sending user is created |
| +// under (cmd/console reads it from CARLOS_MAIL_USER_BOUNDARY). It is required, |
| +// and checked before any AWS call: a provisioner without one would create |
| +// users with no ceiling at all (see EnsureUser). |
| +func NewAWSProvisioner(ctx context.Context, region, boundary string) (Provisioner, error) { |
| + if err := checkUserBoundary(boundary); err != nil { |
| + return nil, err |
| + } |
| cfg, err := rootaws.LoadDefaultConfig(ctx, awsconfig.WithRegion(region)) |
| if err != nil { |
| return nil, fmt.Errorf("mail: load aws config: %w", err) |
| @@ -117,13 +134,40 @@ func NewAWSProvisioner(ctx context.Context, region string) (Provisioner, error) |
| return nil, fmt.Errorf("mail: resolve caller identity: %w", err) |
| } |
| return &awsProvisioner{ |
| - ses: sesv2.NewFromConfig(cfg), |
| - iam: iam.NewFromConfig(cfg), |
| - region: region, |
| - account: aws.ToString(ident.Account), |
| + ses: sesv2.NewFromConfig(cfg), |
| + iam: iam.NewFromConfig(cfg), |
| + region: region, |
| + account: aws.ToString(ident.Account), |
| + boundary: boundary, |
| }, nil |
| } |
| +// ErrNoUserBoundary is what NewAWSProvisioner, EnsureUser and |
| +// CheckUserBoundary wrap when the permissions boundary is missing or is not |
| +// an IAM policy ARN. |
| +var ErrNoUserBoundary = errors.New("mail: sending users need a permissions boundary") |
| + |
| +// CheckUserBoundary is checkUserBoundary for the console's startup check, |
| +// so a bad CARLOS_MAIL_USER_BOUNDARY stops the console booting rather than |
| +// only failing NewAWSProvisioner. |
| +func CheckUserBoundary(boundary string) error { return checkUserBoundary(boundary) } |
| + |
| +// checkUserBoundary refuses an empty boundary, and anything that is not |
| +// shaped like an IAM managed-policy ARN (arn:<partition>:iam::<account>:policy/<name>). |
| +// The shape check is there so a typo fails at boot, not at the first |
| +// CreateUser against a live account. |
| +func checkUserBoundary(boundary string) error { |
| + if boundary == "" { |
| + return fmt.Errorf("%w: none configured", ErrNoUserBoundary) |
| + } |
| + parts := strings.SplitN(boundary, ":", 6) |
| + if len(parts) != 6 || parts[0] != "arn" || parts[1] == "" || parts[2] != "iam" || parts[3] != "" || parts[4] == "" || |
| + !strings.HasPrefix(parts[5], "policy/") || len(parts[5]) == len("policy/") || strings.ContainsAny(boundary, "*? ") { |
| + return fmt.Errorf("%w: %q is not an IAM policy ARN", ErrNoUserBoundary, boundary) |
| + } |
| + return nil |
| +} |
| + |
| // checkRegion refuses a call naming a region other than the one this |
| // provisioner is bound to. The SES client here is bound once at |
| // construction (NewAWSProvisioner), but every SES-facing Provisioner method |
| @@ -393,7 +437,31 @@ func (p *awsProvisioner) DeleteIdentity(ctx context.Context, region, domain stri |
| // ---- IAM -------------------------------------------------------------- |
| +// EnsureUser creates the sending user at path if absent, under the |
| +// permissions boundary, and writes its inline policy either way. |
| +// |
| +// THE BOUNDARY IS THE LOAD-BEARING HALF, not a hardening extra. The console's |
| +// role may call CreateUser, PutUserPolicy and CreateAccessKey on the mail |
| +// user path. Without a boundary, anyone acting as that role can create a |
| +// user there, give it an administrator inline policy and mint it a key: the |
| +// whole AWS account. The path says which user is made, never what it may do. |
| +// The boundary is a ceiling that holds whatever inline policy is written, |
| +// including a wrong one from this code. objstore's EnsureUser closed the |
| +// same gap for store users. |
| +// |
| +// A user that already exists (a re-run after a crash, a policy refresh, or a |
| +// user created before this boundary existed) is read back with GetUser. If |
| +// it carries no boundary, or a different one, PutUserPermissionsBoundary sets |
| +// the configured one BEFORE PutUserPolicy runs. That is how users created |
| +// unbounded get fixed, and the order matters: once the operator conditions |
| +// PutUserPolicy on iam:PermissionsBoundary, the policy write on a user still |
| +// unbounded is refused. This never removes a boundary. |
| func (p *awsProvisioner) EnsureUser(ctx context.Context, name, path string, identityARNs, configSetARNs, domains []string) (string, error) { |
| + // NewAWSProvisioner already refused an empty boundary. Checked again |
| + // here because this is the call that would act without one. |
| + if err := checkUserBoundary(p.boundary); err != nil { |
| + return "", err |
| + } |
| // Render the policy FIRST. SendPolicyJSON refuses an empty grant, and |
| // refusing before CreateUser is what stops a principal existing with no |
| // policy at all — an unscoped sending user, briefly, is still one. |
| @@ -403,7 +471,9 @@ func (p *awsProvisioner) EnsureUser(ctx context.Context, name, path string, iden |
| } |
| var arn string |
| out, err := p.iam.CreateUser(ctx, &iam.CreateUserInput{ |
| - UserName: aws.String(name), Path: aws.String(path), |
| + UserName: aws.String(name), |
| + Path: aws.String(path), |
| + PermissionsBoundary: aws.String(p.boundary), |
| }) |
| if err != nil { |
| if !isAlreadyExists(err) { |
| @@ -413,6 +483,12 @@ func (p *awsProvisioner) EnsureUser(ctx context.Context, name, path string, iden |
| if gerr != nil { |
| return "", fmt.Errorf("mail: get existing user %q: %w", name, gerr) |
| } |
| + if got.User == nil { |
| + return "", fmt.Errorf("mail: get existing user %q: no user in the response", name) |
| + } |
| + if err := p.backfillBoundary(ctx, name, got.User); err != nil { |
| + return "", err |
| + } |
| arn = aws.ToString(got.User.Arn) |
| } else { |
| arn = aws.ToString(out.User.Arn) |
| @@ -427,6 +503,21 @@ func (p *awsProvisioner) EnsureUser(ctx context.Context, name, path string, iden |
| return arn, nil |
| } |
| +// backfillBoundary sets p.boundary on an existing user that lacks it or |
| +// carries another one. A user already carrying it costs no call. |
| +func (p *awsProvisioner) backfillBoundary(ctx context.Context, name string, u *iamtypes.User) error { |
| + if u.PermissionsBoundary != nil && aws.ToString(u.PermissionsBoundary.PermissionsBoundaryArn) == p.boundary { |
| + return nil |
| + } |
| + if _, err := p.iam.PutUserPermissionsBoundary(ctx, &iam.PutUserPermissionsBoundaryInput{ |
| + UserName: aws.String(name), |
| + PermissionsBoundary: aws.String(p.boundary), |
| + }); err != nil { |
| + return fmt.Errorf("mail: set permissions boundary on existing user %q: %w", name, err) |
| + } |
| + return nil |
| +} |
| + |
| func (p *awsProvisioner) MintKey(ctx context.Context, user string) (string, string, error) { |
| out, err := p.iam.CreateAccessKey(ctx, &iam.CreateAccessKeyInput{UserName: aws.String(user)}) |
| if err != nil { |
diff --git a/internal/mail/aws_iam_test.go b/internal/mail/aws_iam_test.go| new file mode 100644 |
| index 000000000..71792027a |
| --- /dev/null |
| +++ b/internal/mail/aws_iam_test.go |
| @@ -0,0 +1,263 @@ |
| +package mail |
| + |
| +import ( |
| + "context" |
| + "errors" |
| + "reflect" |
| + "testing" |
| + |
| + "github.com/aws/aws-sdk-go-v2/aws" |
| + "github.com/aws/aws-sdk-go-v2/service/iam" |
| + iamtypes "github.com/aws/aws-sdk-go-v2/service/iam/types" |
| +) |
| + |
| +// stubIAM is an iamAPI double that records every call by name, in order, |
| +// and lets a test script CreateUser and GetUser. Every other method |
| +// answers success. |
| +type stubIAM struct { |
| + calls []string |
| + |
| + createUser func(*iam.CreateUserInput) (*iam.CreateUserOutput, error) |
| + getUser func(*iam.GetUserInput) (*iam.GetUserOutput, error) |
| + |
| + createIn *iam.CreateUserInput |
| + boundaryIn *iam.PutUserPermissionsBoundaryInput |
| + policyIn *iam.PutUserPolicyInput |
| +} |
| + |
| +func (s *stubIAM) CreateUser(_ context.Context, in *iam.CreateUserInput, _ ...func(*iam.Options)) (*iam.CreateUserOutput, error) { |
| + s.calls = append(s.calls, "CreateUser") |
| + s.createIn = in |
| + if s.createUser != nil { |
| + return s.createUser(in) |
| + } |
| + return &iam.CreateUserOutput{User: &iamtypes.User{Arn: aws.String("arn:aws:iam::123456789012:user/carlos-mail/" + aws.ToString(in.UserName))}}, nil |
| +} |
| + |
| +func (s *stubIAM) GetUser(_ context.Context, in *iam.GetUserInput, _ ...func(*iam.Options)) (*iam.GetUserOutput, error) { |
| + s.calls = append(s.calls, "GetUser") |
| + if s.getUser != nil { |
| + return s.getUser(in) |
| + } |
| + return &iam.GetUserOutput{User: &iamtypes.User{Arn: aws.String("arn:aws:iam::123456789012:user/carlos-mail/" + aws.ToString(in.UserName))}}, nil |
| +} |
| + |
| +func (s *stubIAM) PutUserPolicy(_ context.Context, in *iam.PutUserPolicyInput, _ ...func(*iam.Options)) (*iam.PutUserPolicyOutput, error) { |
| + s.calls = append(s.calls, "PutUserPolicy") |
| + s.policyIn = in |
| + return &iam.PutUserPolicyOutput{}, nil |
| +} |
| + |
| +func (s *stubIAM) PutUserPermissionsBoundary(_ context.Context, in *iam.PutUserPermissionsBoundaryInput, _ ...func(*iam.Options)) (*iam.PutUserPermissionsBoundaryOutput, error) { |
| + s.calls = append(s.calls, "PutUserPermissionsBoundary") |
| + s.boundaryIn = in |
| + return &iam.PutUserPermissionsBoundaryOutput{}, nil |
| +} |
| + |
| +func (s *stubIAM) DeleteUserPolicy(context.Context, *iam.DeleteUserPolicyInput, ...func(*iam.Options)) (*iam.DeleteUserPolicyOutput, error) { |
| + s.calls = append(s.calls, "DeleteUserPolicy") |
| + return &iam.DeleteUserPolicyOutput{}, nil |
| +} |
| + |
| +func (s *stubIAM) CreateAccessKey(context.Context, *iam.CreateAccessKeyInput, ...func(*iam.Options)) (*iam.CreateAccessKeyOutput, error) { |
| + s.calls = append(s.calls, "CreateAccessKey") |
| + return &iam.CreateAccessKeyOutput{AccessKey: &iamtypes.AccessKey{}}, nil |
| +} |
| + |
| +func (s *stubIAM) UpdateAccessKey(context.Context, *iam.UpdateAccessKeyInput, ...func(*iam.Options)) (*iam.UpdateAccessKeyOutput, error) { |
| + s.calls = append(s.calls, "UpdateAccessKey") |
| + return &iam.UpdateAccessKeyOutput{}, nil |
| +} |
| + |
| +func (s *stubIAM) DeleteAccessKey(context.Context, *iam.DeleteAccessKeyInput, ...func(*iam.Options)) (*iam.DeleteAccessKeyOutput, error) { |
| + s.calls = append(s.calls, "DeleteAccessKey") |
| + return &iam.DeleteAccessKeyOutput{}, nil |
| +} |
| + |
| +func (s *stubIAM) ListAccessKeys(context.Context, *iam.ListAccessKeysInput, ...func(*iam.Options)) (*iam.ListAccessKeysOutput, error) { |
| + s.calls = append(s.calls, "ListAccessKeys") |
| + return &iam.ListAccessKeysOutput{}, nil |
| +} |
| + |
| +func (s *stubIAM) DeleteUser(context.Context, *iam.DeleteUserInput, ...func(*iam.Options)) (*iam.DeleteUserOutput, error) { |
| + s.calls = append(s.calls, "DeleteUser") |
| + return &iam.DeleteUserOutput{}, nil |
| +} |
| + |
| +const ( |
| + testUserBoundary = "arn:aws:iam::123456789012:policy/carlos-mail-boundary" |
| + otherUserBoundary = "arn:aws:iam::123456789012:policy/something-else" |
| +) |
| + |
| +// ensureTestUser runs the real awsProvisioner.EnsureUser against stub with |
| +// a grant SendPolicyJSON accepts. |
| +func ensureTestUser(t *testing.T, stub *stubIAM, boundary string) (string, error) { |
| + t.Helper() |
| + p := &awsProvisioner{iam: stub, account: "123456789012", region: "eu-west-1", boundary: boundary} |
| + return p.EnsureUser(context.Background(), "carlos-mail-bag-hm", UserPath, |
| + []string{"arn:aws:ses:eu-west-1:123456789012:identity/example.com"}, nil, []string{"example.com"}) |
| +} |
| + |
| +// existingUser makes CreateUser report the user already exists, and GetUser |
| +// return it carrying pb (nil for a user with no boundary). |
| +func existingUser(stub *stubIAM, pb *iamtypes.AttachedPermissionsBoundary) { |
| + stub.createUser = func(*iam.CreateUserInput) (*iam.CreateUserOutput, error) { |
| + return nil, &iamtypes.EntityAlreadyExistsException{Message: aws.String("exists")} |
| + } |
| + stub.getUser = func(in *iam.GetUserInput) (*iam.GetUserOutput, error) { |
| + return &iam.GetUserOutput{User: &iamtypes.User{ |
| + UserName: in.UserName, |
| + Arn: aws.String("arn:aws:iam::123456789012:user/carlos-mail/" + aws.ToString(in.UserName)), |
| + PermissionsBoundary: pb, |
| + }}, nil |
| + } |
| +} |
| + |
| +// (a) A new user is created under the configured boundary. Without it the |
| +// console's role can make a /carlos-mail/ user with an admin policy. |
| +func TestEnsureUserCreatesUnderTheBoundary(t *testing.T) { |
| + stub := &stubIAM{} |
| + if _, err := ensureTestUser(t, stub, testUserBoundary); err != nil { |
| + t.Fatalf("EnsureUser: %v", err) |
| + } |
| + if stub.createIn == nil || stub.createIn.PermissionsBoundary == nil { |
| + t.Fatal("CreateUser was called with NO permissions boundary") |
| + } |
| + if got := aws.ToString(stub.createIn.PermissionsBoundary); got != testUserBoundary { |
| + t.Fatalf("CreateUser PermissionsBoundary = %q, want %q", got, testUserBoundary) |
| + } |
| + if got := aws.ToString(stub.createIn.Path); got != UserPath { |
| + t.Fatalf("CreateUser Path = %q, want %q", got, UserPath) |
| + } |
| + if want := []string{"CreateUser", "PutUserPolicy"}; !reflect.DeepEqual(stub.calls, want) { |
| + t.Fatalf("calls = %v, want %v", stub.calls, want) |
| + } |
| +} |
| + |
| +// (b) An existing user with no boundary, or with a different one, gets the |
| +// configured boundary set, and set BEFORE its policy is written: once the |
| +// operator conditions PutUserPolicy on iam:PermissionsBoundary, a policy |
| +// write on an unbounded user is refused. |
| +func TestEnsureUserBackfillsTheBoundary(t *testing.T) { |
| + for _, tc := range []struct { |
| + name string |
| + pb *iamtypes.AttachedPermissionsBoundary |
| + }{ |
| + {"no boundary", nil}, |
| + {"empty boundary struct", &iamtypes.AttachedPermissionsBoundary{}}, |
| + {"another boundary", &iamtypes.AttachedPermissionsBoundary{ |
| + PermissionsBoundaryArn: aws.String(otherUserBoundary), |
| + PermissionsBoundaryType: iamtypes.PermissionsBoundaryAttachmentTypePolicy, |
| + }}, |
| + } { |
| + t.Run(tc.name, func(t *testing.T) { |
| + stub := &stubIAM{} |
| + existingUser(stub, tc.pb) |
| + arn, err := ensureTestUser(t, stub, testUserBoundary) |
| + if err != nil { |
| + t.Fatalf("EnsureUser: %v", err) |
| + } |
| + if arn != "arn:aws:iam::123456789012:user/carlos-mail/carlos-mail-bag-hm" { |
| + t.Fatalf("arn = %q", arn) |
| + } |
| + if want := []string{"CreateUser", "GetUser", "PutUserPermissionsBoundary", "PutUserPolicy"}; !reflect.DeepEqual(stub.calls, want) { |
| + t.Fatalf("calls = %v, want %v", stub.calls, want) |
| + } |
| + if got := aws.ToString(stub.boundaryIn.PermissionsBoundary); got != testUserBoundary { |
| + t.Fatalf("PutUserPermissionsBoundary PermissionsBoundary = %q, want %q", got, testUserBoundary) |
| + } |
| + if got := aws.ToString(stub.boundaryIn.UserName); got != "carlos-mail-bag-hm" { |
| + t.Fatalf("PutUserPermissionsBoundary UserName = %q", got) |
| + } |
| + }) |
| + } |
| +} |
| + |
| +// (c) An existing user that already carries the boundary costs no extra |
| +// call. |
| +func TestEnsureUserLeavesACorrectBoundaryAlone(t *testing.T) { |
| + stub := &stubIAM{} |
| + existingUser(stub, &iamtypes.AttachedPermissionsBoundary{ |
| + PermissionsBoundaryArn: aws.String(testUserBoundary), |
| + PermissionsBoundaryType: iamtypes.PermissionsBoundaryAttachmentTypePolicy, |
| + }) |
| + if _, err := ensureTestUser(t, stub, testUserBoundary); err != nil { |
| + t.Fatalf("EnsureUser: %v", err) |
| + } |
| + if want := []string{"CreateUser", "GetUser", "PutUserPolicy"}; !reflect.DeepEqual(stub.calls, want) { |
| + t.Fatalf("calls = %v, want %v", stub.calls, want) |
| + } |
| +} |
| + |
| +// A failed backfill stops the walk before the policy write: the user must |
| +// never get a fresh policy while it still has no ceiling. |
| +func TestEnsureUserBackfillFailureWritesNoPolicy(t *testing.T) { |
| + stub := &stubIAM{} |
| + existingUser(stub, nil) |
| + p := &awsProvisioner{iam: &failingBoundaryIAM{stubIAM: stub}, account: "123456789012", region: "eu-west-1", boundary: testUserBoundary} |
| + _, err := p.EnsureUser(context.Background(), "carlos-mail-bag-hm", UserPath, |
| + []string{"arn:aws:ses:eu-west-1:123456789012:identity/example.com"}, nil, []string{"example.com"}) |
| + if err == nil { |
| + t.Fatal("EnsureUser succeeded although PutUserPermissionsBoundary failed") |
| + } |
| + for _, c := range stub.calls { |
| + if c == "PutUserPolicy" { |
| + t.Fatalf("PutUserPolicy ran after a failed backfill: calls = %v", stub.calls) |
| + } |
| + } |
| +} |
| + |
| +type failingBoundaryIAM struct{ *stubIAM } |
| + |
| +func (f *failingBoundaryIAM) PutUserPermissionsBoundary(context.Context, *iam.PutUserPermissionsBoundaryInput, ...func(*iam.Options)) (*iam.PutUserPermissionsBoundaryOutput, error) { |
| + f.calls = append(f.calls, "PutUserPermissionsBoundary") |
| + return nil, errors.New("AccessDenied") |
| +} |
| + |
| +// Every path that creates or refreshes a sending user goes through |
| +// EnsureUser, so the boundary has to hold there even for a provisioner |
| +// built without the constructor: no IAM call at all. |
| +func TestEnsureUserRefusesWithoutABoundary(t *testing.T) { |
| + stub := &stubIAM{} |
| + _, err := ensureTestUser(t, stub, "") |
| + if !errors.Is(err, ErrNoUserBoundary) { |
| + t.Fatalf("err = %v, want ErrNoUserBoundary", err) |
| + } |
| + if len(stub.calls) != 0 { |
| + t.Fatalf("IAM was called without a boundary: %v", stub.calls) |
| + } |
| +} |
| + |
| +// (d) The constructor refuses a missing or malformed boundary before it |
| +// loads AWS config or calls STS, so this test makes no network call. |
| +func TestNewAWSProvisionerRefusesABadBoundary(t *testing.T) { |
| + for _, b := range []string{ |
| + "", |
| + "carlos-mail-boundary", |
| + "arn:aws:iam::123456789012:role/carlos-mail-boundary", |
| + "arn:aws:iam::123456789012:policy/", |
| + "arn:aws:iam::123456789012:policy/*", |
| + "arn:aws:s3:::policy/x", |
| + "arn:aws:iam:eu-west-1:123456789012:policy/x", |
| + } { |
| + p, err := NewAWSProvisioner(context.Background(), "eu-west-1", b) |
| + if !errors.Is(err, ErrNoUserBoundary) { |
| + t.Errorf("NewAWSProvisioner(boundary %q) err = %v, want ErrNoUserBoundary", b, err) |
| + } |
| + if p != nil { |
| + t.Errorf("NewAWSProvisioner(boundary %q) returned a provisioner", b) |
| + } |
| + } |
| +} |
| + |
| +func TestCheckUserBoundaryAcceptsPolicyARNs(t *testing.T) { |
| + for _, b := range []string{ |
| + testUserBoundary, |
| + "arn:aws:iam::123456789012:policy/carlos/mail-boundary", |
| + "arn:aws-us-gov:iam::123456789012:policy/carlos-mail-boundary", |
| + } { |
| + if err := CheckUserBoundary(b); err != nil { |
| + t.Errorf("CheckUserBoundary(%q) = %v, want nil", b, err) |
| + } |
| + } |
| +} |