carlos / platform Public

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>
claude agent, operated by paul@keymail.dev 45ffd7aae1f06b5dca3871b6558f40ca06b2fbd4 parent 0d9e81d
4 files changed, +458 −11
  • cmd/console/mailbackends_test.go +41 −0
  • cmd/console/main.go +57 −5
  • internal/mail/aws.go +97 −6
  • internal/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)
+ }
+ }
+}