Mail boundary sweep refuses under CARLOS_HOST_CLAIM
Astra round 2, P1. The startup sweep ran with the raw mail provisioner, outside the claim mail gate and the apps' permits. Like sweepLegacyMail, it now logs mail.ErrClaimMailPending and makes no backend call when host claims are on. TestSweepMailBoundariesRefusesUnderHostClaims proves zero calls.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2 files changed,
+39
−6
cmd/console/mailbackends_test.go+24 −3cmd/console/main.go+15 −3
diff --git a/cmd/console/mailbackends_test.go b/cmd/console/mailbackends_test.go| index 63c8b97ef..935b4e96e 100644 |
| --- a/cmd/console/mailbackends_test.go |
| +++ b/cmd/console/mailbackends_test.go |
| @@ -227,11 +227,15 @@ func TestMailUserBoundary(t *testing.T) { |
| // with a canned result, so sweepMailBoundaries' reporting can be read. |
| type fakeBoundarySweeper struct { |
| *mail.MemProvisioner |
| - res mail.BoundarySweep |
| - err error |
| + res mail.BoundarySweep |
| + err error |
| + calls *int |
| } |
| func (f fakeBoundarySweeper) SweepUserBoundaries(context.Context) (mail.BoundarySweep, error) { |
| + if f.calls != nil { |
| + *f.calls++ |
| + } |
| return f.res, f.err |
| } |
| @@ -241,7 +245,7 @@ func (f fakeBoundarySweeper) SweepUserBoundaries(context.Context) (mail.Boundary |
| 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...)) }) |
| + sweepMailBoundaries(context.Background(), false, 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] } |
| @@ -268,3 +272,20 @@ func TestSweepMailBoundariesReports(t *testing.T) { |
| t.Fatal("a nil provisioner must skip the same way") |
| } |
| } |
| + |
| +// TestSweepMailBoundariesRefusesUnderHostClaims: in claim mode the startup |
| +// sweep must not touch IAM at all (the raw provisioner bypasses the claim |
| +// mail gate and app permits), and must say why. |
| +func TestSweepMailBoundariesRefusesUnderHostClaims(t *testing.T) { |
| + calls := 0 |
| + mem := mail.NewMemProvisioner("1") |
| + p := fakeBoundarySweeper{MemProvisioner: mem, calls: &calls, res: mail.BoundarySweep{Total: 1, Fixed: []string{"carlos-mail-a-x"}}} |
| + var lines []string |
| + sweepMailBoundaries(context.Background(), true, p, func(f string, a ...any) { lines = append(lines, fmt.Sprintf(f, a...)) }) |
| + if calls != 0 { |
| + t.Fatalf("the sweep made %d backend calls under host claims, want 0", calls) |
| + } |
| + if len(lines) != 1 || !strings.Contains(lines[0], "refused while CARLOS_HOST_CLAIM is on") || !strings.Contains(lines[0], mail.ErrClaimMailPending.Error()) { |
| + t.Fatalf("lines = %q, want one refusal naming CARLOS_HOST_CLAIM", lines) |
| + } |
| +} |
diff --git a/cmd/console/main.go b/cmd/console/main.go| index 6472ff5b1..e3b30551f 100644 |
| --- a/cmd/console/main.go |
| +++ b/cmd/console/main.go |
| @@ -527,9 +527,11 @@ func serve() { |
| // 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. |
| + // one, and touches no bucket record. Refused under CARLOS_HOST_CLAIM, as |
| + // the legacy sweep is: it would act on IAM users outside the claim mail |
| + // gate and the apps' permits. |
| if os.Getenv("CARLOS_MAIL_BOUNDARY_SWEEP") == "1" { |
| - go sweepMailBoundaries(context.Background(), mailProvisioner, log.Printf) |
| + go sweepMailBoundaries(context.Background(), cfg.HostClaim, 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 |
| @@ -1666,7 +1668,17 @@ func sweepLegacyMail(claims bool, rel release.Store, st *console.Store) { |
| // 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)) { |
| +// |
| +// Under host claims it refuses before any backend call, exactly as |
| +// sweepLegacyMail does: p here is the raw provisioner, and every IAM change |
| +// to a sending user in claim mode has to go through the shared mail gate |
| +// and the owning app's permit (internal/console/claim_backends.go), which |
| +// a fleet-wide sweep has neither of. |
| +func sweepMailBoundaries(ctx context.Context, claims bool, p mail.Provisioner, logf func(string, ...any)) { |
| + if claims { |
| + logf("console: mail boundary sweep: refused while CARLOS_HOST_CLAIM is on: %v", mail.ErrClaimMailPending) |
| + return |
| + } |
| 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)") |