carlos / platform Public

Mail user boundary: backfill on rotation, a one-shot sweep, a strict ARN

Astra's review of sec-mail-user-boundary.

- MintKey reads the user and backfills the boundary before
  CreateAccessKey. Rotation mints without EnsureUser, so before this a
  rotated key could go to an unbounded user. A failed backfill mints
  nothing.
- SweepUserBoundaries (internal/mail/boundarysweep.go, behind the
  UserBoundarySweeper interface so the Provisioner seam stays
  provider-neutral) lists every user under /carlos-mail/, reads each with
  GetUser (ListUsers does not return PermissionsBoundary), and sets the
  boundary where it is missing or different. One user's failure is
  recorded and the sweep goes on; a failed listing is an error.
  CARLOS_MAIL_BOUNDARY_SWEEP=1 runs it once at console start
  (sweepMailBoundaries), logging each user fixed and a last line: "all N
  mail users bounded" or the users that are not. IAM: iam:ListUsers
  (account-wide, Resource "*"), iam:GetUser and
  iam:PutUserPermissionsBoundary on user/carlos-mail/*.
- The boundary must be arn:<partition>:iam::<12 digits>:policy/<path><name>
  in IAM's name characters: no wildcards, spaces or control characters,
  and no AWS managed policy.

Tests go through the real provisioner with the stub IAM client (rotation
backfill, sweep across pages, failures reported, list failure an error,
the new bad ARNs) and the console's startup check and sweep report.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev fedae5a7d95f9dd4548327add966a2f5f7aaee45 parent 7aad0c1
5 files changed, +412 −22
  • cmd/console/mailbackends_test.go +51 −0
  • cmd/console/main.go +55 −11
  • internal/mail/aws.go +41 −9
  • internal/mail/aws_iam_test.go +173 −2
  • internal/mail/boundarysweep.go +92 −0
diff --git a/cmd/console/mailbackends_test.go b/cmd/console/mailbackends_test.go
index a23713093..63c8b97ef 100644
--- a/cmd/console/mailbackends_test.go
+++ b/cmd/console/mailbackends_test.go
@@ -3,6 +3,7 @@ package main
import (
"context"
"errors"
+ "fmt"
"strings"
"testing"
@@ -195,6 +196,10 @@ func TestMailUserBoundary(t *testing.T) {
{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, account not 12 digits", region: "eu-west-1", raw: "arn:aws:iam::not-an-account:policy/example", wantErr: true},
+ {name: "region, tab inside", region: "eu-west-1", raw: "arn:aws:iam::123456789012:policy/exa\tmple", wantErr: true},
+ {name: "region, newline inside", region: "eu-west-1", raw: "arn:aws:iam::123456789012:policy/exa\nmple", wantErr: true},
+ {name: "region, AWS managed policy", region: "eu-west-1", raw: "arn:aws:iam::aws:policy/AdministratorAccess", wantErr: true},
{name: "region, boundary", region: "eu-west-1", raw: " " + arn + "\n", want: arn},
} {
t.Run(tc.name, func(t *testing.T) {
@@ -217,3 +222,49 @@ func TestMailUserBoundary(t *testing.T) {
})
}
}
+
+// fakeBoundarySweeper is a mail.Provisioner that also sweeps, answering
+// with a canned result, so sweepMailBoundaries' reporting can be read.
+type fakeBoundarySweeper struct {
+ *mail.MemProvisioner
+ res mail.BoundarySweep
+ err error
+}
+
+func (f fakeBoundarySweeper) SweepUserBoundaries(context.Context) (mail.BoundarySweep, error) {
+ return f.res, f.err
+}
+
+// TestSweepMailBoundariesReports: the operator's last line says either
+// that every mail user is bounded or which are not; a failure is never
+// reported as success.
+func TestSweepMailBoundariesReports(t *testing.T) {
+ run := func(p mail.Provisioner) []string {
+ var lines []string
+ sweepMailBoundaries(context.Background(), p, func(f string, a ...any) { lines = append(lines, fmt.Sprintf(f, a...)) })
+ return lines
+ }
+ last := func(lines []string) string { return lines[len(lines)-1] }
+ mem := mail.NewMemProvisioner("1")
+
+ ok := run(fakeBoundarySweeper{MemProvisioner: mem, res: mail.BoundarySweep{Total: 3, Bounded: 1, Fixed: []string{"carlos-mail-a-x", "carlos-mail-a-y"}}})
+ if len(ok) != 3 || !strings.Contains(ok[0], "carlos-mail-a-x") || last(ok) != "console: mail boundary sweep: all 3 mail users bounded (1 already, 2 fixed)" {
+ t.Fatalf("success lines = %q", ok)
+ }
+ bad := run(fakeBoundarySweeper{MemProvisioner: mem, res: mail.BoundarySweep{Total: 2, Bounded: 1,
+ Failed: []mail.BoundaryFailure{{User: "carlos-mail-a-z", Err: errors.New("AccessDenied")}}}})
+ if !strings.Contains(last(bad), "1 of 2 mail users NOT bounded: carlos-mail-a-z") {
+ t.Fatalf("failure lines = %q", bad)
+ }
+ stopped := run(fakeBoundarySweeper{MemProvisioner: mem, res: mail.BoundarySweep{Total: 5}, err: errors.New("list failed")})
+ if !strings.Contains(last(stopped), "stopped after 5 mail users") || strings.Contains(strings.Join(stopped, "\n"), "all ") {
+ t.Fatalf("stopped lines = %q", stopped)
+ }
+ none := run(mem)
+ if len(none) != 1 || !strings.Contains(none[0], "skipped") {
+ t.Fatalf("no sweeper lines = %q", none)
+ }
+ if run(nil)[0] != none[0] {
+ t.Fatal("a nil provisioner must skip the same way")
+ }
+}
diff --git a/cmd/console/main.go b/cmd/console/main.go
index 921bc524b..6472ff5b1 100644
--- a/cmd/console/main.go
+++ b/cmd/console/main.go
@@ -76,17 +76,21 @@
// 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.
+// existed is given it the next time EnsureUser or MintKey runs for it
+// (`carlos email enable`, a standalone credential, a domain change
+// refreshing the app's policies, or a key rotation), and
+// CARLOS_MAIL_BOUNDARY_SWEEP=1 sweeps every /carlos-mail/ user once at
+// start (sweepMailBoundaries) for the ones no walk reaches. So the console
+// role needs iam:PutUserPermissionsBoundary on user/carlos-mail/* beside
+// CreateUser, GetUser, PutUserPolicy and the key calls, and iam:ListUsers
+// (account-wide, Resource "*") for the sweep. 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 the sweep reports every
+// user bounded, 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. The rollout order
+// is in README.md.
// Never grant iam:DeleteUserPermissionsBoundary; nothing here calls it.
//
// The console's own email (the invite fallback when Keymail cannot deliver)
@@ -518,6 +522,15 @@ func serve() {
if os.Getenv("CARLOS_MAIL_LEGACY_SWEEP") == "1" {
go sweepLegacyMail(cfg.HostClaim, rel, st)
}
+ // The mail user boundary backfill (CARLOS_MAIL_USER_BOUNDARY): an
+ // operator sets CARLOS_MAIL_BOUNDARY_SWEEP=1 and the next start puts the
+ // boundary on every /carlos-mail/ user that lacks it, including users no
+ // walk will reach again, and logs whether all of them now carry it. Off
+ // by default. It only ever sets the configured boundary, never removes
+ // one, and touches no bucket record.
+ if os.Getenv("CARLOS_MAIL_BOUNDARY_SWEEP") == "1" {
+ go sweepMailBoundaries(context.Background(), mailProvisioner, log.Printf)
+ }
// The S3 change index's reader (CARLOS_CHANGE_INDEX=read, change-index
// design §4.6): the run and seed sweeps read their records through a
// view over the deployment store, rebuilt at start and every backstop.
@@ -1648,6 +1661,37 @@ func sweepLegacyMail(claims bool, rel release.Store, st *console.Store) {
}
}
+// sweepMailBoundaries runs the mail provisioner's user boundary sweep once
+// and logs what it did: each user it set the boundary on, each it could not,
+// and a last line saying either that all N mail users are bounded or which
+// are not. The last line is what an operator reads before requiring the
+// boundary on iam:CreateUser and iam:PutUserPolicy.
+func sweepMailBoundaries(ctx context.Context, p mail.Provisioner, logf func(string, ...any)) {
+ sw, ok := p.(mail.UserBoundarySweeper)
+ if !ok {
+ logf("console: mail boundary sweep: skipped, no mail provisioner with user boundaries (set CARLOS_MAIL_REGION and CARLOS_MAIL_USER_BOUNDARY)")
+ return
+ }
+ res, err := sw.SweepUserBoundaries(ctx)
+ for _, u := range res.Fixed {
+ logf("console: mail boundary sweep: set the boundary on %s", u)
+ }
+ var failed []string
+ for _, f := range res.Failed {
+ failed = append(failed, f.User)
+ logf("console: mail boundary sweep: %s NOT bounded: %v", f.User, f.Err)
+ }
+ if err != nil {
+ logf("console: mail boundary sweep: stopped after %d mail users (%d fixed, %d failed), the rest were not checked: %v", res.Total, len(res.Fixed), len(res.Failed), err)
+ return
+ }
+ if len(failed) > 0 {
+ logf("console: mail boundary sweep: %d of %d mail users NOT bounded: %s", len(failed), res.Total, strings.Join(failed, ", "))
+ return
+ }
+ logf("console: mail boundary sweep: all %d mail users bounded (%d already, %d fixed)", res.Total, res.Bounded, len(res.Fixed))
+}
+
// regionHAEnabled is CARLOS_REGION_HA as strconv.ParseBool reads it: on
// for "1", "t", "true" in any case, off for "", "0", "false" and anything
// unparseable. The same rule as the box's (internal/host/boxrecord.go).
diff --git a/internal/mail/aws.go b/internal/mail/aws.go
index c6444e73d..d624c7bab 100644
--- a/internal/mail/aws.go
+++ b/internal/mail/aws.go
@@ -31,7 +31,7 @@ import (
"context"
"errors"
"fmt"
- "strings"
+ "regexp"
"github.com/aws/aws-sdk-go-v2/aws"
awsconfig "github.com/aws/aws-sdk-go-v2/config"
@@ -83,6 +83,9 @@ type iamAPI interface {
UpdateAccessKey(context.Context, *iam.UpdateAccessKeyInput, ...func(*iam.Options)) (*iam.UpdateAccessKeyOutput, error)
DeleteAccessKey(context.Context, *iam.DeleteAccessKeyInput, ...func(*iam.Options)) (*iam.DeleteAccessKeyOutput, error)
ListAccessKeys(context.Context, *iam.ListAccessKeysInput, ...func(*iam.Options)) (*iam.ListAccessKeysOutput, error)
+ // ListUsers is the boundary sweep's listing (SweepUserBoundaries), always
+ // with PathPrefix UserPath.
+ ListUsers(context.Context, *iam.ListUsersInput, ...func(*iam.Options)) (*iam.ListUsersOutput, error)
DeleteUser(context.Context, *iam.DeleteUserInput, ...func(*iam.Options)) (*iam.DeleteUserOutput, error)
}
@@ -152,18 +155,24 @@ var ErrNoUserBoundary = errors.New("mail: sending users need a permissions bound
// 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.
+// userBoundaryARN is the only shape a boundary may take: a customer managed
+// policy, arn:<partition>:iam::<12-digit account>:policy/<path><name>. The
+// path is IAM's /-separated segments and the name IAM's policy-name
+// characters (letters, digits and + = , . @ _ -, at most 128). Anything else,
+// including wildcards, spaces and control characters, is refused. An AWS
+// managed policy (account "aws") is refused too: none of them is a sensible
+// ceiling for a sending user.
+var userBoundaryARN = regexp.MustCompile(`^arn:aws(-[a-z]+)*:iam::[0-9]{12}:policy/([A-Za-z0-9+=,.@_-]+/)*[A-Za-z0-9+=,.@_-]{1,128}$`)
+
+// checkUserBoundary refuses an empty boundary, and anything not shaped like
+// userBoundaryARN. 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)
+ if len(boundary) > 2048 || !userBoundaryARN.MatchString(boundary) {
+ return fmt.Errorf("%w: %q is not an IAM customer managed policy ARN (arn:aws:iam::<12-digit account>:policy/<name>)", ErrNoUserBoundary, boundary)
}
return nil
}
@@ -503,6 +512,21 @@ func (p *awsProvisioner) EnsureUser(ctx context.Context, name, path string, iden
return arn, nil
}
+// ensureUserBoundary reads user and backfills the boundary onto it.
+func (p *awsProvisioner) ensureUserBoundary(ctx context.Context, user string) error {
+ if err := checkUserBoundary(p.boundary); err != nil {
+ return err
+ }
+ got, err := p.iam.GetUser(ctx, &iam.GetUserInput{UserName: aws.String(user)})
+ if err != nil {
+ return fmt.Errorf("mail: get user %q: %w", user, err)
+ }
+ if got.User == nil {
+ return fmt.Errorf("mail: get user %q: no user in the response", user)
+ }
+ return p.backfillBoundary(ctx, user, got.User)
+}
+
// 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 {
@@ -518,7 +542,15 @@ func (p *awsProvisioner) backfillBoundary(ctx context.Context, name string, u *i
return nil
}
+// MintKey creates a fresh access key for user, after making sure the user
+// carries the boundary. Every walk that mints calls this, and rotation
+// (Rotate) reaches it WITHOUT EnsureUser, so this is the check that no key
+// is ever minted for an unbounded user: one GetUser, and a
+// PutUserPermissionsBoundary only when the boundary is missing or different.
func (p *awsProvisioner) MintKey(ctx context.Context, user string) (string, string, error) {
+ if err := p.ensureUserBoundary(ctx, user); err != nil {
+ return "", "", err
+ }
out, err := p.iam.CreateAccessKey(ctx, &iam.CreateAccessKeyInput{UserName: aws.String(user)})
if err != nil {
return "", "", fmt.Errorf("mail: create access key for %q: %w", user, err)
diff --git a/internal/mail/aws_iam_test.go b/internal/mail/aws_iam_test.go
index 71792027a..ac431ae6a 100644
--- a/internal/mail/aws_iam_test.go
+++ b/internal/mail/aws_iam_test.go
@@ -17,8 +17,11 @@ import (
type stubIAM struct {
calls []string
- createUser func(*iam.CreateUserInput) (*iam.CreateUserOutput, error)
- getUser func(*iam.GetUserInput) (*iam.GetUserOutput, error)
+ createUser func(*iam.CreateUserInput) (*iam.CreateUserOutput, error)
+ getUser func(*iam.GetUserInput) (*iam.GetUserOutput, error)
+ listUsers func(*iam.ListUsersInput) (*iam.ListUsersOutput, error)
+ putBoundary func(*iam.PutUserPermissionsBoundaryInput) error
+ boundariesOn []string
createIn *iam.CreateUserInput
boundaryIn *iam.PutUserPermissionsBoundaryInput
@@ -51,6 +54,12 @@ func (s *stubIAM) PutUserPolicy(_ context.Context, in *iam.PutUserPolicyInput, _
func (s *stubIAM) PutUserPermissionsBoundary(_ context.Context, in *iam.PutUserPermissionsBoundaryInput, _ ...func(*iam.Options)) (*iam.PutUserPermissionsBoundaryOutput, error) {
s.calls = append(s.calls, "PutUserPermissionsBoundary")
s.boundaryIn = in
+ if s.putBoundary != nil {
+ if err := s.putBoundary(in); err != nil {
+ return nil, err
+ }
+ }
+ s.boundariesOn = append(s.boundariesOn, aws.ToString(in.UserName))
return &iam.PutUserPermissionsBoundaryOutput{}, nil
}
@@ -79,6 +88,14 @@ func (s *stubIAM) ListAccessKeys(context.Context, *iam.ListAccessKeysInput, ...f
return &iam.ListAccessKeysOutput{}, nil
}
+func (s *stubIAM) ListUsers(_ context.Context, in *iam.ListUsersInput, _ ...func(*iam.Options)) (*iam.ListUsersOutput, error) {
+ s.calls = append(s.calls, "ListUsers")
+ if s.listUsers != nil {
+ return s.listUsers(in)
+ }
+ return &iam.ListUsersOutput{}, 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
@@ -239,6 +256,18 @@ func TestNewAWSProvisionerRefusesABadBoundary(t *testing.T) {
"arn:aws:iam::123456789012:policy/*",
"arn:aws:s3:::policy/x",
"arn:aws:iam:eu-west-1:123456789012:policy/x",
+ "arn:aws:iam::not-an-account:policy/example",
+ "arn:aws:iam::12345678901:policy/example",
+ "arn:aws:iam::1234567890123:policy/example",
+ "arn:aws:iam::aws:policy/AdministratorAccess",
+ "arn:aws:iam::123456789012:policy/exa\tmple",
+ "arn:aws:iam::123456789012:policy/example\n",
+ "arn:aws:iam::123456789012:policy/exa mple",
+ "arn:aws:iam::123456789012:policy/exa?mple",
+ "arn:aws:iam::123456789012:policy//example",
+ "arn:aws:iam::123456789012:policy/carlos/",
+ "arn:AWS:iam::123456789012:policy/example",
+ "\x00arn:aws:iam::123456789012:policy/example",
} {
p, err := NewAWSProvisioner(context.Background(), "eu-west-1", b)
if !errors.Is(err, ErrNoUserBoundary) {
@@ -255,9 +284,151 @@ func TestCheckUserBoundaryAcceptsPolicyARNs(t *testing.T) {
testUserBoundary,
"arn:aws:iam::123456789012:policy/carlos/mail-boundary",
"arn:aws-us-gov:iam::123456789012:policy/carlos-mail-boundary",
+ "arn:aws-cn:iam::123456789012:policy/a/b/c+=,.@_-name",
} {
if err := CheckUserBoundary(b); err != nil {
t.Errorf("CheckUserBoundary(%q) = %v, want nil", b, err)
}
}
}
+
+// Rotation reaches MintKey without EnsureUser, so MintKey itself backfills:
+// no key is minted for a user without the boundary.
+func TestMintKeyBackfillsTheBoundary(t *testing.T) {
+ for _, tc := range []struct {
+ name string
+ pb *iamtypes.AttachedPermissionsBoundary
+ want []string
+ }{
+ {"unbounded", nil, []string{"GetUser", "PutUserPermissionsBoundary", "CreateAccessKey"}},
+ {"another boundary", &iamtypes.AttachedPermissionsBoundary{PermissionsBoundaryArn: aws.String(otherUserBoundary)},
+ []string{"GetUser", "PutUserPermissionsBoundary", "CreateAccessKey"}},
+ {"bounded", &iamtypes.AttachedPermissionsBoundary{PermissionsBoundaryArn: aws.String(testUserBoundary)},
+ []string{"GetUser", "CreateAccessKey"}},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ stub := &stubIAM{}
+ existingUser(stub, tc.pb)
+ p := &awsProvisioner{iam: stub, account: "123456789012", region: "eu-west-1", boundary: testUserBoundary}
+ if _, _, err := p.MintKey(context.Background(), "carlos-mail-bag-hm"); err != nil {
+ t.Fatalf("MintKey: %v", err)
+ }
+ if !reflect.DeepEqual(stub.calls, tc.want) {
+ t.Fatalf("calls = %v, want %v", stub.calls, tc.want)
+ }
+ if tc.pb == nil && aws.ToString(stub.boundaryIn.PermissionsBoundary) != testUserBoundary {
+ t.Fatalf("boundary set = %q, want %q", aws.ToString(stub.boundaryIn.PermissionsBoundary), testUserBoundary)
+ }
+ })
+ }
+}
+
+func TestMintKeyRefusesWhenTheBackfillFails(t *testing.T) {
+ stub := &stubIAM{putBoundary: func(*iam.PutUserPermissionsBoundaryInput) error { return errors.New("AccessDenied") }}
+ existingUser(stub, nil)
+ p := &awsProvisioner{iam: stub, account: "123456789012", region: "eu-west-1", boundary: testUserBoundary}
+ if _, _, err := p.MintKey(context.Background(), "carlos-mail-bag-hm"); err == nil {
+ t.Fatal("MintKey succeeded although the boundary could not be set")
+ }
+ for _, c := range stub.calls {
+ if c == "CreateAccessKey" {
+ t.Fatalf("a key was minted for an unbounded user: calls = %v", stub.calls)
+ }
+ }
+}
+
+// sweepStub serves users over two ListUsers pages and answers GetUser from
+// boundaries (absent = no boundary).
+func sweepStub(t *testing.T, pages [][]string, boundaries map[string]string) *stubIAM {
+ t.Helper()
+ stub := &stubIAM{}
+ stub.listUsers = func(in *iam.ListUsersInput) (*iam.ListUsersOutput, error) {
+ if aws.ToString(in.PathPrefix) != UserPath {
+ t.Fatalf("ListUsers PathPrefix = %q, want %q", aws.ToString(in.PathPrefix), UserPath)
+ }
+ page := 0
+ if in.Marker != nil {
+ page = 1
+ }
+ out := &iam.ListUsersOutput{}
+ for _, n := range pages[page] {
+ out.Users = append(out.Users, iamtypes.User{UserName: aws.String(n)})
+ }
+ if page+1 < len(pages) {
+ out.IsTruncated, out.Marker = true, aws.String("next")
+ }
+ return out, nil
+ }
+ stub.getUser = func(in *iam.GetUserInput) (*iam.GetUserOutput, error) {
+ u := &iamtypes.User{UserName: in.UserName, Arn: aws.String("arn:aws:iam::123456789012:user/carlos-mail/" + aws.ToString(in.UserName))}
+ if b, ok := boundaries[aws.ToString(in.UserName)]; ok {
+ u.PermissionsBoundary = &iamtypes.AttachedPermissionsBoundary{PermissionsBoundaryArn: aws.String(b)}
+ }
+ return &iam.GetUserOutput{User: u}, nil
+ }
+ return stub
+}
+
+// The sweep bounds every unbounded or wrongly bounded user under the path,
+// across pages, and leaves bounded ones alone.
+func TestSweepUserBoundariesBoundsEveryUser(t *testing.T) {
+ stub := sweepStub(t,
+ [][]string{{"carlos-mail-bag-a", "carlos-mail-bag-b"}, {"carlos-mail-bag-c", "carlos-mail-bag-d+s+laptop"}},
+ map[string]string{"carlos-mail-bag-b": testUserBoundary, "carlos-mail-bag-c": otherUserBoundary})
+ p := &awsProvisioner{iam: stub, account: "123456789012", region: "eu-west-1", boundary: testUserBoundary}
+ res, err := p.SweepUserBoundaries(context.Background())
+ if err != nil {
+ t.Fatalf("SweepUserBoundaries: %v", err)
+ }
+ if res.Total != 4 || res.Bounded != 1 || len(res.Failed) != 0 {
+ t.Fatalf("result = %+v, want Total 4, Bounded 1, no failures", res)
+ }
+ want := []string{"carlos-mail-bag-a", "carlos-mail-bag-c", "carlos-mail-bag-d+s+laptop"}
+ if !reflect.DeepEqual(res.Fixed, want) || !reflect.DeepEqual(stub.boundariesOn, want) {
+ t.Fatalf("fixed = %v, boundaries set on %v, want %v", res.Fixed, stub.boundariesOn, want)
+ }
+ if aws.ToString(stub.boundaryIn.PermissionsBoundary) != testUserBoundary {
+ t.Fatalf("boundary set = %q", aws.ToString(stub.boundaryIn.PermissionsBoundary))
+ }
+}
+
+// A user the sweep cannot fix or read is reported, not swallowed, and the
+// sweep goes on to the rest.
+func TestSweepUserBoundariesReportsFailures(t *testing.T) {
+ stub := sweepStub(t, [][]string{{"carlos-mail-bag-a", "carlos-mail-bag-b", "carlos-mail-bag-c"}}, nil)
+ stub.putBoundary = func(in *iam.PutUserPermissionsBoundaryInput) error {
+ if aws.ToString(in.UserName) == "carlos-mail-bag-a" {
+ return errors.New("AccessDenied")
+ }
+ return nil
+ }
+ read := stub.getUser
+ stub.getUser = func(in *iam.GetUserInput) (*iam.GetUserOutput, error) {
+ if aws.ToString(in.UserName) == "carlos-mail-bag-b" {
+ return nil, errors.New("Throttling")
+ }
+ return read(in)
+ }
+ p := &awsProvisioner{iam: stub, account: "123456789012", region: "eu-west-1", boundary: testUserBoundary}
+ res, err := p.SweepUserBoundaries(context.Background())
+ if err != nil {
+ t.Fatalf("SweepUserBoundaries: %v", err)
+ }
+ if res.Total != 3 || !reflect.DeepEqual(res.Fixed, []string{"carlos-mail-bag-c"}) || len(res.Failed) != 2 {
+ t.Fatalf("result = %+v, want 3 total, c fixed, a and b failed", res)
+ }
+ if res.Failed[0].User != "carlos-mail-bag-a" || res.Failed[1].User != "carlos-mail-bag-b" || res.Failed[0].Err == nil || res.Failed[1].Err == nil {
+ t.Fatalf("failed = %+v", res.Failed)
+ }
+}
+
+// A failed listing is an error: the counts cannot claim every user.
+func TestSweepUserBoundariesListFailureIsAnError(t *testing.T) {
+ stub := &stubIAM{listUsers: func(*iam.ListUsersInput) (*iam.ListUsersOutput, error) {
+ return nil, errors.New("AccessDenied: iam:ListUsers")
+ }}
+ p := &awsProvisioner{iam: stub, account: "123456789012", region: "eu-west-1", boundary: testUserBoundary}
+ if _, err := p.SweepUserBoundaries(context.Background()); err == nil {
+ t.Fatal("SweepUserBoundaries succeeded although ListUsers failed")
+ }
+}
diff --git a/internal/mail/boundarysweep.go b/internal/mail/boundarysweep.go
new file mode 100644
index 000000000..8bc09d740
--- /dev/null
+++ b/internal/mail/boundarysweep.go
@@ -0,0 +1,92 @@
+// The one-shot sweep that puts the permissions boundary on every sending
+// user, including ones no walk will ever touch again.
+package mail
+
+import (
+ "context"
+ "fmt"
+
+ "github.com/aws/aws-sdk-go-v2/aws"
+ "github.com/aws/aws-sdk-go-v2/service/iam"
+)
+
+// UserBoundarySweeper is implemented by a Provisioner whose sending users
+// carry a permissions ceiling that has to be backfilled (the AWS one). It is
+// a separate interface, not a Provisioner method, because a ceiling on the
+// principal is an IAM concept: a provider without one has nothing to sweep.
+type UserBoundarySweeper interface {
+ SweepUserBoundaries(ctx context.Context) (BoundarySweep, error)
+}
+
+var _ UserBoundarySweeper = (*awsProvisioner)(nil)
+
+// BoundarySweep is what one sweep did. Total counts every sending user
+// listed; Bounded those already carrying the boundary; Fixed the users it
+// was set on; Failed the users it could not check or fix, with why.
+type BoundarySweep struct {
+ Total int
+ Bounded int
+ Fixed []string
+ Failed []BoundaryFailure
+}
+
+// BoundaryFailure is one user the sweep could not check or fix.
+type BoundaryFailure struct {
+ User string
+ Err error
+}
+
+// SweepUserBoundaries lists every user under UserPath and sets the
+// configured boundary on each that lacks it, or carries another one.
+//
+// It exists because EnsureUser and MintKey only backfill a user when a walk
+// reaches it. An app nobody re-enables, rotates or re-domains keeps an
+// unbounded user forever, and an orphaned user (its record gone) is never
+// reached at all. Requiring the boundary on CreateUser and PutUserPolicy
+// is only safe once this sweep reports every user bounded.
+//
+// ListUsers does not return PermissionsBoundary, so each user is read with
+// GetUser. One user's failure is recorded and the sweep goes on; a failed
+// listing stops it, with the counts so far, because then Total is not every
+// user. IAM needed: iam:ListUsers (which AWS scopes to an account, not a
+// path, so it is granted on Resource "*"; the call itself asks only for
+// /carlos-mail/), and iam:GetUser and iam:PutUserPermissionsBoundary on
+// user/carlos-mail/*.
+func (p *awsProvisioner) SweepUserBoundaries(ctx context.Context) (BoundarySweep, error) {
+ var res BoundarySweep
+ if err := checkUserBoundary(p.boundary); err != nil {
+ return res, err
+ }
+ var marker *string
+ for {
+ out, err := p.iam.ListUsers(ctx, &iam.ListUsersInput{PathPrefix: aws.String(UserPath), Marker: marker})
+ if err != nil {
+ return res, fmt.Errorf("mail: list users under %s: %w", UserPath, err)
+ }
+ for _, u := range out.Users {
+ name := aws.ToString(u.UserName)
+ res.Total++
+ got, err := p.iam.GetUser(ctx, &iam.GetUserInput{UserName: aws.String(name)})
+ if err == nil && got.User == nil {
+ err = fmt.Errorf("no user in the response")
+ }
+ if err != nil {
+ res.Failed = append(res.Failed, BoundaryFailure{User: name, Err: fmt.Errorf("mail: get user %q: %w", name, err)})
+ continue
+ }
+ if got.User.PermissionsBoundary != nil && aws.ToString(got.User.PermissionsBoundary.PermissionsBoundaryArn) == p.boundary {
+ res.Bounded++
+ continue
+ }
+ if err := p.backfillBoundary(ctx, name, got.User); err != nil {
+ res.Failed = append(res.Failed, BoundaryFailure{User: name, Err: err})
+ continue
+ }
+ res.Fixed = append(res.Fixed, name)
+ }
+ if !out.IsTruncated || out.Marker == nil {
+ return res, nil
+ }
+ marker = out.Marker
+ }
+}