sec-mail-user-boundary
Squashed from sec-mail-user-boundary into main.
10 files changed,
+918
−11
README.md+16 −0cmd/console/mailbackends_test.go+113 −0cmd/console/main.go+113 −5docs/superpowers/notes/census-proposal-sec-mail-user-boundary.md+13 −0internal/console/testdata/claim-authority-sites.txt+1 −0internal/mail/aws.go+129 −6internal/mail/aws_iam_test.go+434 −0internal/mail/boundarysweep.go+92 −0internal/provision/testdata/plan14-root-reads-chain.tsv+1 −0internal/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 |