carlos / platform Public

sec-mail-user-boundary

Squashed from sec-mail-user-boundary into main.
claude agent, operated by paul@keymail.dev 6c75ad81897e083a7b364c3020eb9ca63460e423 parent b612e71
10 files changed, +918 −11
  • README.md +16 −0
  • cmd/console/mailbackends_test.go +113 −0
  • cmd/console/main.go +113 −5
  • docs/superpowers/notes/census-proposal-sec-mail-user-boundary.md +13 −0
  • internal/console/testdata/claim-authority-sites.txt +1 −0
  • internal/mail/aws.go +129 −6
  • internal/mail/aws_iam_test.go +434 −0
  • internal/mail/boundarysweep.go +92 −0
  • internal/provision/testdata/plan14-root-reads-chain.tsv +1 −0
  • internal/provision/testdata/plan14-root-reads-sec-mail-user-boundary.tsv +6 −0

CI passed — run details

CI log
=== gate ===
.amadan/test-gate.sh
go: downloading github.com/aws/aws-sdk-go-v2/service/cloudwatch v1.68.0
go: downloading github.com/aws/aws-sdk-go-v2 v1.44.0
go: downloading github.com/aws/aws-sdk-go-v2/service/costexplorer v1.67.5
go: downloading github.com/aws/aws-sdk-go-v2/service/s3 v1.105.0
go: downloading github.com/aws/smithy-go v1.28.1
go: downloading github.com/superfly/ltx v0.5.1
go: downloading golang.org/x/net v0.60.0
go: downloading modernc.org/sqlite v1.55.0
go: downloading github.com/aws/aws-sdk-go-v2/config v1.32.6
go: downloading github.com/aws/aws-sdk-go-v2/service/route53 v1.64.1
go: downloading github.com/benbjohnson/litestream v0.5.14
go: downloading golang.org/x/crypto v0.57.0
go: downloading github.com/oschwald/maxminddb-golang v1.13.1
go: downloading github.com/aws/aws-sdk-go-v2/service/ec2 v1.324.0
go: downloading github.com/aws/aws-sdk-go-v2/service/autoscaling v1.74.0
go: downloading github.com/aws/aws-sdk-go-v2/service/iam v1.58.2
go: downloading github.com/aws/aws-sdk-go-v2/service/sesv2 v1.66.6
go: downloading github.com/aws/aws-sdk-go-v2/service/sts v1.45.6
go: downloading github.com/aws/aws-sdk-go-v2/service/resourcegroupstaggingapi v1.37.0
go: downloading github.com/aws/aws-sdk-go-v2/service/acm v1.45.0
go: downloading github.com/aws/aws-sdk-go-v2/service/sns v1.43.0
go: downloading github.com/aws/aws-sdk-go-v2/credentials v1.19.6
go: downloading golang.org/x/sys v0.48.0
go: downloading github.com/pion/logging v0.2.4
go: downloading github.com/pion/stun/v3 v3.1.5
go: downloading github.com/pion/turn/v4 v4.1.4
go: downloading github.com/pierrec/lz4/v4 v4.1.22
go: downloading golang.org/x/sync v0.23.0
go: downloading github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.20.18
go: downloading github.com/prometheus/client_golang v1.17.0
go: downloading github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.16
go: downloading github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4
go: downloading github.com/aws/aws-sdk-go-v2/service/signin v1.0.4
go: downloading github.com/aws/aws-sdk-go-v2/service/sso v1.30.8
go: downloading github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.12
go: downloading github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.40
go: downloading github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.38
go: downloading github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.19
go: downloading github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.40
go: downloading github.com/lmittmann/tint v1.1.3
go: downloading github.com/mattn/go-isatty v0.0.20
go: downloading github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.40
go: downloading golang.org/x/text v0.42.0
go: downloading github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.14
go: downloading github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.23
go: downloading github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.31
go: downloading github.com/pion/dtls/v3 v3.1.4
go: downloading github.com/pion/transport/v4 v4.0.2
go: downloading modernc.org/sqlite v1.55.0
go: downloading github.com/pion/randutil v0.1.0
go: downloading github.com/aws/aws-sdk-go-v2/service/ec2 v1.324.0
go: downloading github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.14
go: downloading github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.40
go: downloading github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.23
go: downloading github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.38
go: downloading github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.40
go: downloading github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.31
go: downloading github.com/prometheus/client_golang v1.17.0
go: downloading github.com/pierrec/lz4/v4 v4.1.22
go: downloading github.com/aws/aws-sdk-go-v2/service/ssm v1.74.0
go: downloading github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.40
go: downloading github.com/klauspost/compress v1.18.0
go: downloading github.com/lmittmann/tint v1.1.3
go: downloading github.com/pion/dtls/v3 v3.1.4
go: downloading github.com/pion/transport/v4 v4.0.2
go: downloading golang.org/x/text v0.42.0
go: downloading github.com/wlynxg/anet v0.0.5
go: downloading github.com/wlynxg/anet v0.0.5
go: downloading github.com/beorn7/perks v1.0.1
go: downloading github.com/cespare/xxhash/v2 v2.3.0
go: downloading github.com/prometheus/common v0.45.0
go: downloading google.golang.org/protobuf v1.36.10
go: downloading github.com/prometheus/client_model v0.5.0
go: downloading github.com/prometheus/procfs v0.12.0
go: downloading github.com/prometheus/common v0.45.0
go: downloading github.com/cespare/xxhash/v2 v2.3.0
go: downloading github.com/prometheus/procfs v0.12.0
go: downloading google.golang.org/protobuf v1.36.10
go: downloading github.com/prometheus/client_model v0.5.0
go: downloading github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0
go: downloading github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0
go: downloading modernc.org/libc v1.74.1
go: downloading modernc.org/libc v1.74.1
go: downloading modernc.org/memory v1.11.0
go: downloading github.com/dustin/go-humanize v1.0.1
go: downloading modernc.org/mathutil v1.7.1
go: downloading github.com/google/uuid v1.6.0
go: downloading github.com/dustin/go-humanize v1.0.1
go: downloading github.com/google/uuid v1.6.0
go: downloading modernc.org/mathutil v1.7.1
go: downloading modernc.org/memory v1.11.0
go: downloading github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec
go: downloading github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec
=== launchctx (3 s)
ok  	amadan.net/carlos/platform.git/launchctx	0.012s
?   	amadan.net/carlos/platform.git/launchctx/controlname	[no test files]
?   	amadan.net/carlos/platform.git/launchctx/wireid	[no test files]
=== vet (45 s)
go: downloading github.com/aws/aws-sdk-go-v2/service/ec2 v1.324.0
go: downloading modernc.org/sqlite v1.55.0
go: downloading github.com/aws/aws-sdk-go-v2/service/ssm v1.74.0
go: downloading github.com/klauspost/compress v1.18.0
go: downloading golang.org/x/tools v0.49.0
go: downloading github.com/prometheus/client_golang v1.17.0
go: downloading github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.40
go: downloading github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.14
go: downloading github.com/pierrec/lz4/v4 v4.1.22
go: downloading github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.38
go: downloading github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.40
go: downloading github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.23
go: downloading github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.31
go: downloading github.com/pion/dtls/v3 v3.1.4
go: downloading github.com/pion/transport/v4 v4.0.2
go: downloading golang.org/x/text v0.42.0
go: downloading github.com/lmittmann/tint v1.1.3
go: downloading github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.40
go: downloading github.com/wlynxg/anet v0.0.5
go: downloading github.com/beorn7/perks v1.0.1
go: downloading github.com/prometheus/common v0.45.0
go: downloading github.com/cespare/xxhash/v2 v2.3.0
go: downloading github.com/prometheus/procfs v0.12.0
go: downloading google.golang.org/protobuf v1.36.10
go: downloading github.com/prometheus/client_model v0.5.0
go: downloading github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0
go: downloading golang.org/x/mod v0.41.0
go: downloading modernc.org/libc v1.74.1
go: downloading modernc.org/memory v1.11.0
go: downloading github.com/dustin/go-humanize v1.0.1
go: downloading modernc.org/mathutil v1.7.1
go: downloading github.com/google/uuid v1.6.0
go: downloading github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec
=== gofmt (0 s)
gofmt -l .: clean
=== rest (199 s)
?   	github.com/carlosframework/platform/cmd/changelog	[no test files]
ok  	github.com/carlosframework/platform/cmd/console	3.436s
ok  	github.com/carlosframework/platform/cmd/moveprobe	11.751s
ok  	github.com/carlosframework/platform/cmd/zddprobe	0.730s
ok  	github.com/carlosframework/platform/internal/activator	58.779s
ok  	github.com/carlosframework/platform/internal/agentsock	0.026s
ok  	github.com/carlosframework/platform/internal/alerting	0.010s
ok  	github.com/carlosframework/platform/internal/analytics	0.027s
ok  	github.com/carlosframework/platform/internal/appconfig	2.810s
ok  	github.com/carlosframework/platform/internal/apperr	0.119s
ok  	github.com/carlosframework/platform/internal/applog	0.225s
ok  	github.com/carlosframework/platform/internal/awsinv	0.010s
ok  	github.com/carlosframework/platform/internal/boxchoice	0.007s
ok  	github.com/carlosframework/platform/internal/boxid	0.623s
?   	github.com/carlosframework/platform/internal/boxid/boxidtest	[no test files]
?   	github.com/carlosframework/platform/internal/boxid/internal/signtest	[no test files]
ok  	github.com/carlosframework/platform/internal/boxvolumes	0.011s
ok  	github.com/carlosframework/platform/internal/caps	0.015s
ok  	github.com/carlosframework/platform/internal/certwarm	0.002s
ok  	github.com/carlosframework/platform/internal/cgroup	0.719s
ok  	github.com/carlosframework/platform/internal/changelog	0.434s
ok  	github.com/carlosframework/platform/internal/claimactivation	1.074s
ok  	github.com/carlosframework/platform/internal/clamd	0.056s
?   	github.com/carlosframework/platform/internal/cliflag	[no test files]
?   	github.com/carlosframework/platform/internal/controlname	[no test files]
?   	github.com/carlosframework/platform/internal/controlsock	[no test files]
ok  	github.com/carlosframework/platform/internal/dbdoor	7.598s
ok  	github.com/carlosframework/platform/internal/dbdoor/dbdoortest	0.015s
ok  	github.com/carlosframework/platform/internal/dbquery	0.007s
ok  	github.com/carlosframework/platform/internal/dbreads	2.119s
?   	github.com/carlosframework/platform/internal/dbreads/dbreadstest	[no test files]
ok  	github.com/carlosframework/platform/internal/dbverify	0.010s
ok  	github.com/carlosframework/platform/internal/dnsrec	0.004s
ok  	github.com/carlosframework/platform/internal/docsite	0.008s
ok  	github.com/carlosframework/platform/internal/domains	2.670s
ok  	github.com/carlosframework/platform/internal/doorkey	0.007s
ok  	github.com/carlosframework/platform/internal/economics	0.007s
ok  	github.com/carlosframework/platform/internal/edge	49.753s
?   	github.com/carlosframework/platform/internal/edgeproof	[no test files]
ok  	github.com/carlosframework/platform/internal/edgesfile	0.012s
ok  	github.com/carlosframework/platform/internal/fleetapi	0.202s
ok  	github.com/carlosframework/platform/internal/gate	1.686s
ok  	github.com/carlosframework/platform/internal/genselect	0.038s
ok  	github.com/carlosframework/platform/internal/geo	0.027s
ok  	github.com/carlosframework/platform/internal/geoip	0.067s
ok  	github.com/carlosframework/platform/internal/historyreadback	0.255s
ok  	github.com/carlosframework/platform/internal/host	147.558s
ok  	github.com/carlosframework/platform/internal/hostactors	6.227s
ok  	github.com/carlosframework/platform/internal/hostactors/awsmembership	0.006s
ok  	github.com/carlosframework/platform/internal/hostbox	3.421s
ok  	github.com/carlosframework/platform/internal/hostclaim	0.800s
ok  	github.com/carlosframework/platform/internal/hostenv	0.006s
ok  	github.com/carlosframework/platform/internal/hostevidence	0.006s
ok  	github.com/carlosframework/platform/internal/hostexec	5.743s
ok  	github.com/carlosframework/platform/internal/hostfence	0.051s
ok  	github.com/carlosframework/platform/internal/hostfiles	10.528s
ok  	github.com/carlosframework/platform/internal/hostfloor	0.016s
ok  	github.com/carlosframework/platform/internal/hostguard	1.066s
ok  	github.com/carlosframework/platform/internal/hostops	2.139s
?   	github.com/carlosframework/platform/internal/hostops/fencerelease	[no test files]
ok  	github.com/carlosframework/platform/internal/hostops/internal/fencerelease	0.004s
ok  	github.com/carlosframework/platform/internal/hostquarantine	0.004s
ok  	github.com/carlosframework/platform/internal/hostrelocate	0.610s
ok  	github.com/carlosframework/platform/internal/hostresolve	0.176s
ok  	github.com/carlosframework/platform/internal/hostwrite	0.002s
ok  	github.com/carlosframework/platform/internal/infraview	0.005s
ok  	github.com/carlosframework/platform/internal/instancectl	5.263s
ok  	github.com/carlosframework/platform/internal/instanceexec	2.843s
ok  	github.com/carlosframework/platform/internal/instancefs	0.007s
ok  	github.com/carlosframework/platform/internal/instancekey	0.018s
ok  	github.com/carlosframework/platform/internal/instanceupdates	0.022s
ok  	github.com/carlosframework/platform/internal/layout	1.822s
ok  	github.com/carlosframework/platform/internal/lbhealth	0.015s
ok  	github.com/carlosframework/platform/internal/lease	10.094s
ok  	github.com/carlosframework/platform/internal/lease/leasetest	0.609s
ok  	github.com/carlosframework/platform/internal/ledger	0.039s
ok  	github.com/carlosframework/platform/internal/mail	0.213s
ok  	github.com/carlosframework/platform/internal/maintenance	0.084s
ok  	github.com/carlosframework/platform/internal/maintenanceexec	5.594s
ok  	github.com/carlosframework/platform/internal/maintenanceguard	0.079s
ok  	github.com/carlosframework/platform/internal/metrics	0.010s
ok  	github.com/carlosframework/platform/internal/moves	30.116s
ok  	github.com/carlosframework/platform/internal/moves/archive	1.034s
ok  	github.com/carlosframework/platform/internal/objstore	0.293s
ok  	github.com/carlosframework/platform/internal/ops	0.221s
ok  	github.com/carlosframework/platform/internal/park	0.285s
ok  	github.com/carlosframework/platform/internal/plans	0.006s
ok  	github.com/carlosframework/platform/internal/privatefile	0.007s
ok  	github.com/carlosframework/platform/internal/provision	54.358s
ok  	github.com/carlosframework/platform/internal/publicrelease	0.014s
?   	github.com/carlosframework/platform/internal/regionname	[no test files]
ok  	github.com/carlosframework/platform/internal/registry	5.918s
ok  	github.com/carlosframework/platform/internal/relay	7.210s
ok  	github.com/carlosframework/platform/internal/release	30.044s
ok  	github.com/carlosframework/platform/internal/rollout	4.186s
ok  	github.com/carlosframework/platform/internal/rolloutwords	0.003s
ok  	github.com/carlosframework/platform/internal/rootaws	0.007s
ok  	github.com/carlosframework/platform/internal/rootparticipation	0.062s
ok  	github.com/carlosframework/platform/internal/rootstate	0.030s
ok  	github.com/carlosframework/platform/internal/runs	0.547s
ok  	github.com/carlosframework/platform/internal/s3compat	0.009s
ok  	github.com/carlosframework/platform/internal/safefile	0.012s
ok  	github.com/carlosframework/platform/internal/schedule	2.834s
ok  	github.com/carlosframework/platform/internal/seeds	0.012s
ok  	github.com/carlosframework/platform/internal/sim/engines	3.081s
ok  	github.com/carlosframework/platform/internal/sim/hostlife	11.392s
ok  	github.com/carlosframework/platform/internal/sim/litestreamfacts	4.009s
ok  	github.com/carlosframework/platform/internal/status	0.071s
ok  	github.com/carlosframework/platform/internal/steering	0.006s
ok  	github.com/carlosframework/platform/internal/stopevidence	0.304s
ok  	github.com/carlosframework/platform/internal/svcrole	0.004s
ok  	github.com/carlosframework/platform/internal/telemetry	0.009s
ok  	github.com/carlosframework/platform/internal/termfence	0.004s
ok  	github.com/carlosframework/platform/internal/tunnelclient	0.005s
ok  	github.com/carlosframework/platform/internal/unixdial	0.006s
ok  	github.com/carlosframework/platform/internal/updatewords	0.004s
ok  	github.com/carlosframework/platform/internal/usage	0.007s
ok  	github.com/carlosframework/platform/internal/version	0.003s
ok  	github.com/carlosframework/platform/internal/vet	1.676s
=== internal_console@0 (89 s)
ok  	github.com/carlosframework/platform/internal/console	87.080s
=== internal_console@1 (50 s)
ok  	github.com/carlosframework/platform/internal/console	47.790s
=== internal_console@2 (44 s)
ok  	github.com/carlosframework/platform/internal/console	41.980s
=== internal_console@3 (49 s)
ok  	github.com/carlosframework/platform/internal/console	47.331s
=== internal_console@4 (47 s)
ok  	github.com/carlosframework/platform/internal/console	45.231s
=== internal_console@5 (48 s)
ok  	github.com/carlosframework/platform/internal/console	46.390s
=== internal_console@6 (46 s)
ok  	github.com/carlosframework/platform/internal/console	44.714s
=== internal_console@7 (52 s)
ok  	github.com/carlosframework/platform/internal/console	50.578s
=== root@0 (114 s)
ok  	github.com/carlosframework/platform	111.967s
=== root@1 (85 s)
ok  	github.com/carlosframework/platform	83.485s
=== root@2 (102 s)
ok  	github.com/carlosframework/platform	99.929s
=== cross ===
--- shard 0 of 5 ---
cross-compiling darwin/arm64
--- shard 1 of 5 ---
cross-compiling darwin/amd64
--- shard 2 of 5 ---
cross-compiling linux/arm64
linux/arm64 carlos binary: 50659490 bytes (ceiling 52000000)
--- shard 3 of 5 ---
cross-compiling windows/amd64
--- shard 4 of 5 ---
cross-compiling windows/arm64
=== minio-contract ===
minio contract: arm64, minio RELEASE.2025-09-07T16-13-09Z, mc RELEASE.2025-08-13T08-35-41Z
  minio: cached, digest verified
  mc: cached, digest verified
  minio healthy on 127.0.0.1:20999
Added `local` successfully.
Bucket created successfully `local/contract-bucket`.
Bucket created successfully `local/denied-bucket`.
Created policy `contract-only` successfully.
Added user `scopeduser` successfully.
Attached Policies: [contract-only]
To User: scopeduser
  buckets and scoped user ready
=== RUN   TestDeliveredContractMinio
=== RUN   TestDeliveredContractMinio/put
=== RUN   TestDeliveredContractMinio/get_round-trips_the_body
=== RUN   TestDeliveredContractMinio/list_shows_the_key
=== RUN   TestDeliveredContractMinio/head_and_list_agree_on_the_version
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_its_own_object
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_its_own_object/not_by_PutObjectTagging
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_its_own_object/and_not_by_x-amz-tagging_on_upload
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/not_by_CreateMultipartUpload
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/not_by_CopyObject_with_a_REPLACE_tagging_directive
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/but_multipart_itself_is_allowed
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/and_copy_itself_is_allowed
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/a_POST-policy_forged_tag_with_a_matching_version_is_still_not_a_verdict
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/and_an_ordinary_tagged-with-something-else_upload_is_allowed
=== RUN   TestDeliveredContractMinio/delete_removes_it
=== RUN   TestDeliveredContractMinio/cross-bucket_denial
--- PASS: TestDeliveredContractMinio (0.02s)
    --- PASS: TestDeliveredContractMinio/put (0.00s)
    --- PASS: TestDeliveredContractMinio/get_round-trips_the_body (0.00s)
    --- PASS: TestDeliveredContractMinio/list_shows_the_key (0.00s)
    --- PASS: TestDeliveredContractMinio/head_and_list_agree_on_the_version (0.00s)
    --- PASS: TestDeliveredContractMinio/the_app_cannot_tag_its_own_object (0.00s)
        --- PASS: TestDeliveredContractMinio/the_app_cannot_tag_its_own_object/not_by_PutObjectTagging (0.00s)
        --- PASS: TestDeliveredContractMinio/the_app_cannot_tag_its_own_object/and_not_by_x-amz-tagging_on_upload (0.00s)
    --- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route (0.01s)
        --- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/not_by_CreateMultipartUpload (0.00s)
        --- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/not_by_CopyObject_with_a_REPLACE_tagging_directive (0.00s)
        --- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/but_multipart_itself_is_allowed (0.00s)
        --- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/and_copy_itself_is_allowed (0.00s)
        --- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/a_POST-policy_forged_tag_with_a_matching_version_is_still_not_a_verdict (0.00s)
        --- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/and_an_ordinary_tagged-with-something-else_upload_is_allowed (0.00s)
    --- PASS: TestDeliveredContractMinio/delete_removes_it (0.00s)
    --- PASS: TestDeliveredContractMinio/cross-bucket_denial (0.00s)
PASS
ok  	github.com/carlosframework/platform/internal/objstore	0.024s
=== RUN   TestT4StorageProbeMinio
2026/10/24 21:26:07 moves: T4: STORE: list: operation error S3: ListObjectsV2, https response error StatusCode: 403, RequestID: 18E19448D9C17558, HostID: 7c420053e0fbcd7f41653fdd92ffa2802c903d05303b1aa15adc2e8bb65d183f, api error SignatureDoesNotMatch: The request signature we calculated does not match the signature you provided. Check your key and signing method.
--- PASS: TestT4StorageProbeMinio (0.01s)
PASS
ok  	github.com/carlosframework/platform/internal/host	0.023s
=== RUN   TestS3StoreMinioContract
=== RUN   TestS3StoreMinioContract/conditional_get_and_etag_listing
=== RUN   TestS3StoreMinioContract/bump_through_every_mutation_path
--- PASS: TestS3StoreMinioContract (0.03s)
    --- PASS: TestS3StoreMinioContract/conditional_get_and_etag_listing (0.01s)
    --- PASS: TestS3StoreMinioContract/bump_through_every_mutation_path (0.02s)
PASS
ok  	github.com/carlosframework/platform/internal/release	0.030s
Bucket created successfully `local/dbf-deploy`.
Bucket created successfully `local/dbf-data-ie`.
Bucket created successfully `local/dbf-data-uk`.
local/dbf-deploy versioning is enabled
Created policy `dbf-console` successfully.
Created policy `dbf-ukbox` successfully.
Added user `dbfconsole` successfully.
Added user `dbfukbox` successfully.
Attached Policies: [dbf-console]
To User: dbfconsole
Attached Policies: [dbf-ukbox]
To User: dbfukbox
=== RUN   TestDBForwardMinio
2026/10/24 21:26:09 INFO replica sync system=store db=source.db replica=file txid.replica=0000000000000000 txid.db=0000000000000001
2026/10/24 21:26:09 INFO ltx file uploaded system=store db=source.db replica=file level=0 minTXID=0000000000000001 maxTXID=0000000000000001 size=720
2026/10/24 21:26:09 INFO replica sync system=store db=source.db replica=file txid.replica=0000000000000001 txid.db=0000000000000001
2026/10/24 21:26:09 INFO replica sync system=store db=source.db replica=file txid.replica=0000000000000001 txid.db=0000000000000002
2026/10/24 21:26:09 INFO ltx file uploaded system=store db=source.db replica=file level=0 minTXID=0000000000000002 maxTXID=0000000000000002 size=370
2026/10/24 21:26:09 INFO replica sync system=store db=source.db replica=file txid.replica=0000000000000002 txid.db=0000000000000002
2026/10/24 21:26:09 INFO replica sync system=store db=source.db replica=file txid.replica=0000000000000002 txid.db=0000000000000003
2026/10/24 21:26:09 INFO ltx file uploaded system=store db=source.db replica=file level=0 minTXID=0000000000000003 maxTXID=0000000000000003 size=454
2026/10/24 21:26:09 INFO replica sync system=store db=source.db replica=file txid.replica=0000000000000003 txid.db=0000000000000003
2026/10/24 21:26:09 INFO replica sync system=store db=source.db replica=file txid.replica=0000000000000000 txid.db=0000000000000001
2026/10/24 21:26:09 INFO ltx file uploaded system=store db=source.db replica=file level=0 minTXID=0000000000000001 maxTXID=0000000000000001 size=720
2026/10/24 21:26:09 INFO replica sync system=store db=source.db replica=file txid.replica=0000000000000001 txid.db=0000000000000001
2026/10/24 21:26:09 console: CARLOS_SECRETS_PUBKEY is unset, so no region outside the home can be chosen automatically
2026/10/24 21:26:09 console: could not notify member@example.com of a CLI token issue: keymail: lookup member@example.com: keymail: GET /api/federation/lookup?addr=member%40example.com&from=console%40127.0.0.1%3A38579: could not determine target mailbox
    agent.go:135: dbreads: dbr_7rpa6sztoahhwzhybeqjzhkbrq: planned 3 files (1544 bytes) and restored to 3 in 9ms
    agent.go:135: dbreads: dbr_7rpa6sztoahhwzhybeqjzhkbrq: output 5 bytes in 1 confirmed chunks in 1ms
2026/10/24 21:26:10 console: db read dbr_7rpa6sztoahhwzhybeqjzhkbrq: relayed 1 chunks (102 bytes) in 1.004s, ended done
    agent.go:135: dbreads: dbr_khgw4iljamqvoccdyljnwtdtja: planned 1 files (720 bytes) and restored to 1 in 9ms
    agent.go:135: dbreads: dbr_khgw4iljamqvoccdyljnwtdtja: output 5 bytes in 1 confirmed chunks in 1ms
2026/10/24 21:26:11 console: db read dbr_khgw4iljamqvoccdyljnwtdtja: relayed 1 chunks (102 bytes) in 1.003s, ended done
    agent.go:135: dbreads: dbr_yhmrw34faitlvpmf6nlf6cutxm: planned 3 files (1544 bytes) and held through 3 in 9ms
    agent.go:135: dbreads: dbr_yhmrw34faitlvpmf6nlf6cutxm: output 1544 bytes in 1 confirmed chunks in 1ms
2026/10/24 21:26:12 console: db read dbr_yhmrw34faitlvpmf6nlf6cutxm: relayed 1 chunks (1641 bytes) in 1.003s, ended done
=== RUN   TestDBForwardMinio/by_disconnect
=== NAME  TestDBForwardMinio
    agent.go:135: dbreads: dbr_czawirn7pgtbtocami3p6wss3e: planned 3 files (1544 bytes) and restored to 3 in 9ms
2026/10/24 21:26:12 console: db read dbr_czawirn7pgtbtocami3p6wss3e: relayed 0 chunks (0 bytes) in 32ms, ended client gone
    agent.go:135: dbreads: dbr_czawirn7pgtbtocami3p6wss3e: output 5 bytes in 1 confirmed chunks in 29ms
=== RUN   TestDBForwardMinio/by_timeout
=== NAME  TestDBForwardMinio
    agent.go:135: dbreads: dbr_c3fbfbeq4veggleuj7ciitibrm: planned 3 files (1544 bytes) and restored to 3 in 9ms
2026/10/24 21:26:32 console: db read dbr_c3fbfbeq4veggleuj7ciitibrm: relayed 0 chunks (0 bytes) in 19.053s, ended no-result
    agent.go:135: dbreads: dbr_c3fbfbeq4veggleuj7ciitibrm: output 5 bytes in 1 confirmed chunks in 19.04s
--- PASS: TestDBForwardMinio (22.42s)
    --- PASS: TestDBForwardMinio/by_disconnect (0.17s)
    --- PASS: TestDBForwardMinio/by_timeout (19.16s)
PASS
ok  	github.com/carlosframework/platform/internal/console	22.443s
minio contract: PASS

landed from sec-mail-user-boundary

diff --git a/README.md b/README.md
index c6ec2a3ac..3cf801394 100644
--- a/README.md
+++ b/README.md
@@ -496,6 +496,22 @@ This release stops every box and the console from reading the whole deployment b
- customer fleet boxes keep reading the way they do today.
- to see the effect, look at daily S3 ListBucket and GetObject requests for the deployment bucket in Cost Explorer. they should fall within a day of arming.
+### Email sending users get a permissions boundary (2026-10-23)
+
+This release creates every email sending user (IAM path `/carlos-mail/`) under a permissions boundary, the way store users already are. Without one, anything that can act as the console's role can create a user on that path, give it an administrator policy and mint it a key.
+
+A console with `CARLOS_MAIL_REGION` set refuses to start on this release without `CARLOS_MAIL_USER_BOUNDARY`. Its value is the ARN of a customer managed policy in the same account, `arn:aws:iam::<12-digit account>:policy/<name>`, that allows `ses:SendRawEmail` and nothing else. A console without `CARLOS_MAIL_REGION` needs nothing.
+
+Roll it in this order:
+
+1. create the boundary policy. set `CARLOS_MAIL_USER_BOUNDARY` to its ARN in every console's environment before any console gets this release. an older console ignores the variable. this release refuses to start without it.
+2. apply IAM step 1 to the console role. add `iam:PutUserPermissionsBoundary` on `user/carlos-mail/*`, conditioned on `iam:PermissionsBoundary` equal to the boundary ARN. without that condition the role can set any boundary it likes. add `iam:ListUsers` for the sweep in step 4. AWS does not scope `ListUsers` to a path, so it is granted on `Resource "*"`. it only reveals user names and paths, and the sweep asks only for `/carlos-mail/`. the role already has `iam:GetUser` on the path. never grant `iam:DeleteUserPermissionsBoundary`. nothing calls it.
+3. roll the console. from now on every new sending user gets the boundary. an existing user gets it the next time the console touches it: enable, a standalone credential, a domain change, and key rotation too, since no key is minted for a user without the boundary.
+4. run the sweep, which also reaches users nothing will touch again. set `CARLOS_MAIL_BOUNDARY_SWEEP=1` on one console and restart it. it logs `set the boundary on <user>` for each user it fixes. its last line is either `all N mail users bounded` or the users that are not, with why. fix any failure and run it again. then remove the variable.
+5. check it yourself. for every user in `aws iam list-users --path-prefix /carlos-mail/`, `aws iam get-user --user-name <user>` must show `PermissionsBoundary` with the boundary ARN.
+6. apply IAM step 2. condition the role's `iam:CreateUser` and `iam:PutUserPolicy` on `iam:PermissionsBoundary` equal to the boundary ARN. do this only after every console runs this release, because an older console creates users without the boundary and step 2 refuses them.
+7. wait a few minutes for IAM to propagate, then run the check in step 5 again with your own operator credentials, not the console role. `iam:CreateAccessKey` cannot be conditioned on the boundary, so anyone holding the console role could have made an unbounded user between step 5 and step 2. delete any user without the boundary, or set the boundary on it. the gap is closed only when this second check is clean.
+
## App config & secrets
Per-app config lives in the deployment bucket
diff --git a/cmd/console/mailbackends_test.go b/cmd/console/mailbackends_test.go
index 2ffd0ed32..935b4e96e 100644
--- a/cmd/console/mailbackends_test.go
+++ b/cmd/console/mailbackends_test.go
@@ -3,6 +3,8 @@ package main
import (
"context"
"errors"
+ "fmt"
+ "strings"
"testing"
"github.com/aws/aws-sdk-go-v2/service/sesv2"
@@ -176,3 +178,114 @@ 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, 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) {
+ 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)
+ }
+ })
+ }
+}
+
+// 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
+ calls *int
+}
+
+func (f fakeBoundarySweeper) SweepUserBoundaries(context.Context) (mail.BoundarySweep, error) {
+ if f.calls != nil {
+ *f.calls++
+ }
+ 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(), 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] }
+ 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")
+ }
+}
+
+// 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 067f138c9..d09b991b3 100644
--- a/cmd/console/main.go
+++ b/cmd/console/main.go
@@ -62,9 +62,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
@@ -76,6 +77,30 @@
// 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 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)
// sends From CARLOS_CONSOLE_MAIL_FROM, defaulting to
// noreply@console-mail.bab.<CARLOS_MAIL_PLATFORM_SUFFIX> when the suffix is set.
@@ -97,6 +122,7 @@ package main
import (
"context"
"crypto/ed25519"
+ "errors"
"flag"
"fmt"
"log"
@@ -326,7 +352,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()
@@ -507,6 +540,17 @@ 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. 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(), 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
// view over the deployment store, rebuilt at start and every backstop.
@@ -991,7 +1035,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
@@ -1020,6 +1066,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
@@ -1650,6 +1717,47 @@ 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.
+//
+// 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)")
+ 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/docs/superpowers/notes/census-proposal-sec-mail-user-boundary.md b/docs/superpowers/notes/census-proposal-sec-mail-user-boundary.md
new file mode 100644
index 000000000..733a8f229
--- /dev/null
+++ b/docs/superpowers/notes/census-proposal-sec-mail-user-boundary.md
@@ -0,0 +1,13 @@
+# Census approval: sec-mail-user-boundary
+
+Ruling `census-ruling-sec-mail-user-boundary`. Every change below keeps the disposition it already has, so it needs an approval, not a decision.
+
+| unit | change | read sites | what changed |
+|---|---|---|---|
+| `cmd/console/main.go` `serve` | body | unchanged (3) | reads CARLOS_MAIL_USER_BOUNDARY and passes it to mail.NewAWSProvisioner; runs the one-shot boundary sweep behind CARLOS_MAIL_BOUNDARY_SWEEP=1, refused under CARLOS_HOST_CLAIM |
+
+Envelopes re-pinned: `cmd/console`, `internal/mail`.
+
+Seal: `27b020590845320cd37b94fdeddf61b7c6f90e12ba7841850f2e7bbd6836ecd8` → `68ca9635da386f34ef483c790af19e954122c11edc406d64cde8d55d99d12de5`.
+
+Overlay: `internal/provision/testdata/plan14-root-reads-sec-mail-user-boundary.tsv`, appended to `internal/provision/testdata/plan14-root-reads-chain.tsv`.
diff --git a/internal/console/testdata/claim-authority-sites.txt b/internal/console/testdata/claim-authority-sites.txt
index e4c8047d5..f3d26689e 100644
--- a/internal/console/testdata/claim-authority-sites.txt
+++ b/internal/console/testdata/claim-authority-sites.txt
@@ -3,6 +3,7 @@
../../cmd/console/main.go|openStore|release.NewDeploymentS3Store(context.Background(), b)
../../cmd/console/main.go|serve|lease.NewVersionedS3(context.Background(), buckets)
../../cmd/console/main.go|serve|release.NewS3StoreIn(context.Background(), dbBucket, dbRegion)
+../../cmd/console/main.go|serve|mail.NewAWSProvisioner(ctx, region, mailBoundary)
../../cmd/console/main.go|storeBackend|objstore.NewAWSProvisioner(context.Background(), region, endpoint, boundary)
../../cmd/console/main.go|storeScannerFor|objstore.NewClamAVScanner(context.Background(), d.Region, d.Endpoint, addr, maxBytes, signer)
../hostops/app_mutation_runner.go|RunAppMutation|b.control.BeginExclusive(ctx, account, app, a.Value.AppID, AppOperation{ID: id, Kind: OpAppMutation, AppID: a.Value.AppID, Phase: "issued", Intent: intent}, owner)
diff --git a/internal/mail/aws.go b/internal/mail/aws.go
index f57133c8a..d624c7bab 100644
--- a/internal/mail/aws.go
+++ b/internal/mail/aws.go
@@ -31,6 +31,7 @@ import (
"context"
"errors"
"fmt"
+ "regexp"
"github.com/aws/aws-sdk-go-v2/aws"
awsconfig "github.com/aws/aws-sdk-go-v2/config"
@@ -72,11 +73,19 @@ 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)
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)
}
@@ -91,6 +100,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 +118,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 +137,46 @@ 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) }
+
+// 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)
+ }
+ 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
+}
+
// 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 +446,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 +480,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 +492,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,7 +512,45 @@ 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 {
+ 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
+}
+
+// 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
new file mode 100644
index 000000000..ac431ae6a
--- /dev/null
+++ b/internal/mail/aws_iam_test.go
@@ -0,0 +1,434 @@
+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)
+ listUsers func(*iam.ListUsersInput) (*iam.ListUsersOutput, error)
+ putBoundary func(*iam.PutUserPermissionsBoundaryInput) error
+ boundariesOn []string
+
+ 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
+ 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
+}
+
+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) 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
+}
+
+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",
+ "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) {
+ 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",
+ "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
+ }
+}
diff --git a/internal/provision/testdata/plan14-root-reads-chain.tsv b/internal/provision/testdata/plan14-root-reads-chain.tsv
index 283a2117c..56acc4c12 100644
--- a/internal/provision/testdata/plan14-root-reads-chain.tsv
+++ b/internal/provision/testdata/plan14-root-reads-chain.tsv
@@ -5,3 +5,4 @@ plan14-root-reads-sec-host-imdsv2-module.tsv 945934f5978ace14dc5a9bae0ed69dfabf2
plan14-root-reads-sec-deps-dos-bumps.tsv 945934f5978ace14dc5a9bae0ed69dfabf29b994504fc843c03d4a633ecd6c6a
plan14-root-reads-sec-sidecar-create-gate.tsv 0ca3baafa26f59c0b7d8c6d6a39237d074afb71f885b7fbd50debebab5bf89f2
plan14-root-reads-wake-latency-2.tsv 27b020590845320cd37b94fdeddf61b7c6f90e12ba7841850f2e7bbd6836ecd8
+plan14-root-reads-sec-mail-user-boundary.tsv 68ca9635da386f34ef483c790af19e954122c11edc406d64cde8d55d99d12de5
diff --git a/internal/provision/testdata/plan14-root-reads-sec-mail-user-boundary.tsv b/internal/provision/testdata/plan14-root-reads-sec-mail-user-boundary.tsv
new file mode 100644
index 000000000..10cbe805c
--- /dev/null
+++ b/internal/provision/testdata/plan14-root-reads-sec-mail-user-boundary.tsv
@@ -0,0 +1,6 @@
+# Census overlay sec-mail-user-boundary, written by census-propose (#369). census-ruling-sec-mail-user-boundary, approved by the census owner before the merge; the approval card is docs/superpowers/notes/census-proposal-sec-mail-user-boundary.md.
+# 1 body, 0 sites, 2 envelope: each changed unit re-frozen at its existing disposition with its claim appended.
+@legacy 9ad86a176bcc0bd04d4d50ac38ad2d0922023da5ecf35b7fe635dd8049daf21e cmd/console/main.go serve legacy accepted-legacy accepted-legacy;rev3=PROPOSED-scan-demand-revival;basis=Console service wires the optional callback signer and starts the claim-guarded queue worker. The same three data path literals move; it does not add a privileged reader.;rev3=PROPOSED-email-signin;identity=the console (cmd/console serve under console.service, user carlos-console);basis=passes CARLOS_CONSOLE_NAME to NewServer as Config.ConsoleName, the name the console's emails use; the same three /data literals at the same offsets; no read added;rev3=PROPOSED-s3-change-index;identity=the console (cmd/console serve under console.service, user carlos-console);basis=arms the record view when CARLOS_CHANGE_INDEX=read (armChangeIndex) for the run and seed sweeps and starts its backstop loop; the same three /data literals at the same offsets; no read added;rev3=census-ruling-db-region-forward-2;identity=the console (cmd/console serve under console.service, user carlos-console);basis=parses CARLOS_DB_FORWARD (dbForward) into Config.DBForward and builds its http.Server with console.NewHTTPServer, the same two timeouts; the same three /data literals at the same offsets; no read added;rev3=census-ruling-sec-sidecar-create-gate;basis=serve now parses CARLOS_SIDECAR_ACCOUNTS (sidecarAccounts, a pure env parse with log.Fatalf on a bad or empty-list value) before opening anything and passes it into console.Config.SidecarAccounts. No read was added: the same 3 root read sites.;rev3=census-ruling-sec-mail-user-boundary;basis=reads CARLOS_MAIL_USER_BOUNDARY and passes it to mail.NewAWSProvisioner; runs the one-shot boundary sweep behind CARLOS_MAIL_BOUNDARY_SWEEP=1, refused under CARLOS_HOST_CLAIM data-literal@4601,data-literal@5702,data-literal@5939 d42709a70b4089043a22fc32db306af83d595eb734f1ce050802ba8e85d413ff
+cmd/console/main.go serve legacy accepted-legacy accepted-legacy;rev3=PROPOSED-scan-demand-revival;basis=Console service wires the optional callback signer and starts the claim-guarded queue worker. The same three data path literals move; it does not add a privileged reader.;rev3=PROPOSED-email-signin;identity=the console (cmd/console serve under console.service, user carlos-console);basis=passes CARLOS_CONSOLE_NAME to NewServer as Config.ConsoleName, the name the console's emails use; the same three /data literals at the same offsets; no read added;rev3=PROPOSED-s3-change-index;identity=the console (cmd/console serve under console.service, user carlos-console);basis=arms the record view when CARLOS_CHANGE_INDEX=read (armChangeIndex) for the run and seed sweeps and starts its backstop loop; the same three /data literals at the same offsets; no read added;rev3=census-ruling-db-region-forward-2;identity=the console (cmd/console serve under console.service, user carlos-console);basis=parses CARLOS_DB_FORWARD (dbForward) into Config.DBForward and builds its http.Server with console.NewHTTPServer, the same two timeouts; the same three /data literals at the same offsets; no read added;rev3=census-ruling-sec-sidecar-create-gate;basis=serve now parses CARLOS_SIDECAR_ACCOUNTS (sidecarAccounts, a pure env parse with log.Fatalf on a bad or empty-list value) before opening anything and passes it into console.Config.SidecarAccounts. No read was added: the same 3 root read sites.;rev3=census-ruling-sec-mail-user-boundary;basis=reads CARLOS_MAIL_USER_BOUNDARY and passes it to mail.NewAWSProvisioner; runs the one-shot boundary sweep behind CARLOS_MAIL_BOUNDARY_SWEEP=1, refused under CARLOS_HOST_CLAIM data-literal@4601,data-literal@5702,data-literal@5939 d42709a70b4089043a22fc32db306af83d595eb734f1ce050802ba8e85d413ff
+@coverage cmd/console 72c886448386811edaf505aebf56e3455b01b8cf3e88909592c56dc7267b84a5
+@coverage internal/mail 82a7f2590ced5c1ad6a72b0e62305b4cc157eeade4e8a3159366a41bc3eaca88