carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

1// The AWS provisioner: SESv2 + IAM behind the Provisioner seam.
2//
3// Every idempotence mapping here is load-bearing. The walks in walk.go are
4// re-runnable after a half-failure ONLY because each verb below treats
5// "already exists" and "already gone" as success rather than error.
6//
7// TENANT ATTRIBUTION: verified against the live SESv2 API reference and
8// Developer Guide on 2026-08-19 (docs.aws.amazon.com/ses/latest/dg/tenants.html
9// and docs.aws.amazon.com/ses/latest/APIReference-V2/API_CreateTenant.html).
10// SESv2 DOES have tenant management: CreateTenant, CreateTenantResourceAssociation,
11// GetTenant, DeleteTenant, ListTenants, ListTenantResources are all present in
12// aws-sdk-go-v2/service/sesv2 v1.66.6, and CreateTenant's documented error set
13// includes AlreadyExistsException, so it fits the same idempotence mapping as
14// everything else here. SMTP sends ARE attributable to a tenant, via a plain
15// header on the outgoing message: `X-SES-TENANT: <tenant-name>` (the guide
16// gives that exact spelling, NOT the `X-SES-TENANT-NAME` this package's spec
17// guessed at). That header, plus which config set and identity a send uses,
18// is a concern of the code that composes an outgoing message, not of this
19// provisioner. EnsureTenant below therefore does the one thing the Provisioner
20// interface actually asks of it — create the account's named tenant container,
21// idempotently — and stops there: it does not call
22// CreateTenantResourceAssociation itself, because the interface gives
23// EnsureTenant no identity or config-set to associate; that association is
24// wired separately, by walk.go's EnsureDomain, once it has both (plan 2,
25// task 7). Isolation is unaffected either way: the tenant buys REPUTATION
26// isolation, and AUTHORITY isolation is the IAM policy in policy.go (spec
27// §4).
28package mail
29
30import (
31 "context"
32 "errors"
33 "fmt"
34 "regexp"
35
36 "github.com/aws/aws-sdk-go-v2/aws"
37 awsconfig "github.com/aws/aws-sdk-go-v2/config"
38 "github.com/aws/aws-sdk-go-v2/service/iam"
39 iamtypes "github.com/aws/aws-sdk-go-v2/service/iam/types"
40 "github.com/aws/aws-sdk-go-v2/service/sesv2"
41 sesv2types "github.com/aws/aws-sdk-go-v2/service/sesv2/types"
42 "github.com/aws/aws-sdk-go-v2/service/sts"
43 "github.com/carlosframework/platform/internal/rootaws"
44)
45
46// mailFromBehaviour is REJECT_MESSAGE and must stay that way.
47//
48// The alternative, USE_DEFAULT_VALUE, silently falls back to amazonses.com
49// when the custom MAIL FROM MX is absent: the tenant keeps sending, from an
50// unaligned envelope domain, failing DMARC, with nothing to notice. Rejecting
51// turns a DNS mistake into a visible error instead of a slow deliverability
52// leak.
53const mailFromBehaviour = sesv2types.BehaviorOnMxFailureRejectMessage
54
55// sesAPI is the subset of *sesv2.Client awsProvisioner calls, narrowed so a
56// future test can stand a stub in for a live client without a network or
57// credentials — exactly as internal/objstore/aws.go narrows s3API and iamAPI.
58type sesAPI interface {
59 CreateTenant(context.Context, *sesv2.CreateTenantInput, ...func(*sesv2.Options)) (*sesv2.CreateTenantOutput, error)
60 CreateEmailIdentity(context.Context, *sesv2.CreateEmailIdentityInput, ...func(*sesv2.Options)) (*sesv2.CreateEmailIdentityOutput, error)
61 GetEmailIdentity(context.Context, *sesv2.GetEmailIdentityInput, ...func(*sesv2.Options)) (*sesv2.GetEmailIdentityOutput, error)
62 PutEmailIdentityMailFromAttributes(context.Context, *sesv2.PutEmailIdentityMailFromAttributesInput, ...func(*sesv2.Options)) (*sesv2.PutEmailIdentityMailFromAttributesOutput, error)
63 DeleteEmailIdentity(context.Context, *sesv2.DeleteEmailIdentityInput, ...func(*sesv2.Options)) (*sesv2.DeleteEmailIdentityOutput, error)
64 CreateConfigurationSet(context.Context, *sesv2.CreateConfigurationSetInput, ...func(*sesv2.Options)) (*sesv2.CreateConfigurationSetOutput, error)
65 CreateConfigurationSetEventDestination(context.Context, *sesv2.CreateConfigurationSetEventDestinationInput, ...func(*sesv2.Options)) (*sesv2.CreateConfigurationSetEventDestinationOutput, error)
66 PutEmailIdentityConfigurationSetAttributes(context.Context, *sesv2.PutEmailIdentityConfigurationSetAttributesInput, ...func(*sesv2.Options)) (*sesv2.PutEmailIdentityConfigurationSetAttributesOutput, error)
67 CreateTenantResourceAssociation(context.Context, *sesv2.CreateTenantResourceAssociationInput, ...func(*sesv2.Options)) (*sesv2.CreateTenantResourceAssociationOutput, error)
68 DeleteTenantResourceAssociation(context.Context, *sesv2.DeleteTenantResourceAssociationInput, ...func(*sesv2.Options)) (*sesv2.DeleteTenantResourceAssociationOutput, error)
69}
70
71// iamAPI is the subset of *iam.Client awsProvisioner calls.
72type iamAPI interface {
73 CreateUser(context.Context, *iam.CreateUserInput, ...func(*iam.Options)) (*iam.CreateUserOutput, error)
74 GetUser(context.Context, *iam.GetUserInput, ...func(*iam.Options)) (*iam.GetUserOutput, error)
75 PutUserPolicy(context.Context, *iam.PutUserPolicyInput, ...func(*iam.Options)) (*iam.PutUserPolicyOutput, error)
76 // PutUserPermissionsBoundary backfills the boundary onto a user that
77 // already exists without it (EnsureUser). Its inverse,
78 // DeleteUserPermissionsBoundary, is deliberately NOT on this interface:
79 // nothing in this package may ever take a ceiling off.
80 PutUserPermissionsBoundary(context.Context, *iam.PutUserPermissionsBoundaryInput, ...func(*iam.Options)) (*iam.PutUserPermissionsBoundaryOutput, error)
81 DeleteUserPolicy(context.Context, *iam.DeleteUserPolicyInput, ...func(*iam.Options)) (*iam.DeleteUserPolicyOutput, error)
82 CreateAccessKey(context.Context, *iam.CreateAccessKeyInput, ...func(*iam.Options)) (*iam.CreateAccessKeyOutput, error)
83 UpdateAccessKey(context.Context, *iam.UpdateAccessKeyInput, ...func(*iam.Options)) (*iam.UpdateAccessKeyOutput, error)
84 DeleteAccessKey(context.Context, *iam.DeleteAccessKeyInput, ...func(*iam.Options)) (*iam.DeleteAccessKeyOutput, error)
85 ListAccessKeys(context.Context, *iam.ListAccessKeysInput, ...func(*iam.Options)) (*iam.ListAccessKeysOutput, error)
86 // ListUsers is the boundary sweep's listing (SweepUserBoundaries), always
87 // with PathPrefix UserPath.
88 ListUsers(context.Context, *iam.ListUsersInput, ...func(*iam.Options)) (*iam.ListUsersOutput, error)
89 DeleteUser(context.Context, *iam.DeleteUserInput, ...func(*iam.Options)) (*iam.DeleteUserOutput, error)
90}
91
92// stsAPI is the subset of *sts.Client awsProvisioner's constructor calls,
93// narrowed the same way as sesAPI and iamAPI.
94type stsAPI interface {
95 GetCallerIdentity(context.Context, *sts.GetCallerIdentityInput, ...func(*sts.Options)) (*sts.GetCallerIdentityOutput, error)
96}
97
98type awsProvisioner struct {
99 ses sesAPI
100 iam iamAPI
101 region string
102 account string
103 // boundary is the permissions-boundary policy ARN every sending user
104 // carries. See EnsureUser for why it is not optional.
105 boundary string
106}
107
108var _ Provisioner = (*awsProvisioner)(nil)
109
110// NewAWSProvisioner builds a Provisioner against SESv2 and IAM.
111//
112// The SES client is bound to region because SES is regional in every
113// dimension that matters. IAM takes no region: users and keys are global,
114// which is what lets one credential span regions (spec §8).
115//
116// The account id is resolved ONCE here via STS GetCallerIdentity and stored
117// on the provisioner (controller ruling F3), rather than looked up per call:
118// SESv2 does not return an identity ARN, so EnsureIdentity must compose one,
119// and composing it needs the account id. Resolving it once at construction
120// keeps every walk free of an extra round trip.
121//
122// boundary is the ARN of the IAM managed policy every sending user is created
123// under (cmd/console reads it from CARLOS_MAIL_USER_BOUNDARY). It is required,
124// and checked before any AWS call: a provisioner without one would create
125// users with no ceiling at all (see EnsureUser).
126func NewAWSProvisioner(ctx context.Context, region, boundary string) (Provisioner, error) {
127 if err := checkUserBoundary(boundary); err != nil {
128 return nil, err
129 }
130 cfg, err := rootaws.LoadDefaultConfig(ctx, awsconfig.WithRegion(region))
131 if err != nil {
132 return nil, fmt.Errorf("mail: load aws config: %w", err)
133 }
134 var stsClient stsAPI = sts.NewFromConfig(cfg)
135 ident, err := stsClient.GetCallerIdentity(ctx, &sts.GetCallerIdentityInput{})
136 if err != nil {
137 return nil, fmt.Errorf("mail: resolve caller identity: %w", err)
138 }
139 return &awsProvisioner{
140 ses: sesv2.NewFromConfig(cfg),
141 iam: iam.NewFromConfig(cfg),
142 region: region,
143 account: aws.ToString(ident.Account),
144 boundary: boundary,
145 }, nil
146}
147
148// ErrNoUserBoundary is what NewAWSProvisioner, EnsureUser and
149// CheckUserBoundary wrap when the permissions boundary is missing or is not
150// an IAM policy ARN.
151var ErrNoUserBoundary = errors.New("mail: sending users need a permissions boundary")
152
153// CheckUserBoundary is checkUserBoundary for the console's startup check,
154// so a bad CARLOS_MAIL_USER_BOUNDARY stops the console booting rather than
155// only failing NewAWSProvisioner.
156func CheckUserBoundary(boundary string) error { return checkUserBoundary(boundary) }
157
158// userBoundaryARN is the only shape a boundary may take: a customer managed
159// policy, arn:<partition>:iam::<12-digit account>:policy/<path><name>. The
160// path is IAM's /-separated segments and the name IAM's policy-name
161// characters (letters, digits and + = , . @ _ -, at most 128). Anything else,
162// including wildcards, spaces and control characters, is refused. An AWS
163// managed policy (account "aws") is refused too: none of them is a sensible
164// ceiling for a sending user.
165var userBoundaryARN = regexp.MustCompile(`^arn:aws(-[a-z]+)*:iam::[0-9]{12}:policy/([A-Za-z0-9+=,.@_-]+/)*[A-Za-z0-9+=,.@_-]{1,128}$`)
166
167// checkUserBoundary refuses an empty boundary, and anything not shaped like
168// userBoundaryARN. The shape check is there so a typo fails at boot, not at
169// the first CreateUser against a live account.
170func checkUserBoundary(boundary string) error {
171 if boundary == "" {
172 return fmt.Errorf("%w: none configured", ErrNoUserBoundary)
173 }
174 if len(boundary) > 2048 || !userBoundaryARN.MatchString(boundary) {
175 return fmt.Errorf("%w: %q is not an IAM customer managed policy ARN (arn:aws:iam::<12-digit account>:policy/<name>)", ErrNoUserBoundary, boundary)
176 }
177 return nil
178}
179
180// checkRegion refuses a call naming a region other than the one this
181// provisioner is bound to. The SES client here is bound once at
182// construction (NewAWSProvisioner), but every SES-facing Provisioner method
183// still takes a region parameter — because the spec's multi-region story is
184// "capable, not deployed" and MemProvisioner genuinely models N regions. An
185// awsProvisioner silently routing every region through its one client would
186// let a walk test built against the fake pass while production quietly acts
187// on the wrong region (or, for EnsureIdentity, composes an ARN naming the
188// CALLER's region while the identity itself was created in a different
189// one). An empty region is accepted as "caller doesn't care" and treated as
190// this provisioner's own region.
191// errRegionMismatch is the sentinel checkRegion wraps, so a test (or a
192// future caller) can check errors.Is(err, errRegionMismatch) rather than
193// matching against the formatted message.
194var errRegionMismatch = errors.New("mail: region mismatch")
195
196func (p *awsProvisioner) checkRegion(region string) error {
197 if region != "" && region != p.region {
198 return fmt.Errorf("mail: provisioner is bound to region %s, cannot act on %s: %w", p.region, region, errRegionMismatch)
199 }
200 return nil
201}
202
203// ---- error classification -------------------------------------------------
204
205func isAlreadyExists(err error) bool {
206 var ses *sesv2types.AlreadyExistsException
207 var im *iamtypes.EntityAlreadyExistsException
208 return errors.As(err, &ses) || errors.As(err, &im)
209}
210
211func isNotFound(err error) bool {
212 var ses *sesv2types.NotFoundException
213 var im *iamtypes.NoSuchEntityException
214 return errors.As(err, &ses) || errors.As(err, &im)
215}
216
217// ---- SES ------------------------------------------------------------------
218
219// EnsureTenant creates the account's SES tenant if absent.
220//
221// See the TENANT ATTRIBUTION note at the top of this file: CreateTenant is a
222// real, present SESv2 API, verified against the live docs on 2026-08-19. This
223// creates ONLY the named container — it does not call
224// CreateTenantResourceAssociation, because EnsureTenant's signature carries
225// no identity ARN or configuration-set name to associate. Isolation is
226// unaffected by that gap: the tenant buys reputation isolation, and
227// authority isolation is the IAM policy (spec §4), which this does not touch.
228//
229// PRECONDITION FOR SENDING, NOW MET BY EnsureDomain, NOT HERE: per
230// docs.aws.amazon.com/ses/latest/dg/tenants.html, a tenant needs at least
231// one verified identity AND one configuration set associated via
232// CreateTenantResourceAssociation before SES will accept a send attributed
233// to it — SES validates that association on every send. walk.go's
234// EnsureDomain wires both associations now, after PutDomain, using
235// AssociateTenantResource below (plan 2, task 7). This still does not make
236// any send carry the `X-SES-TENANT: <name>` header: nothing in step 2
237// composes an outgoing message or writes a tenant-conditioned policy, so
238// that header waits for step 3. The spike's answer (design §4.1, confirmed
239// against the live API): an association is inert for the identity's owner
240// in every respect except deletion, and `ses:TenantName` IS enforced over
241// SMTP.
242func (p *awsProvisioner) EnsureTenant(ctx context.Context, name string) error {
243 _, err := p.ses.CreateTenant(ctx, &sesv2.CreateTenantInput{
244 TenantName: aws.String(name),
245 })
246 if err != nil && !isAlreadyExists(err) {
247 return fmt.Errorf("mail: create tenant %q: %w", name, err)
248 }
249 return nil
250}
251
252// AssociateTenantResource associates an identity or a configuration set with
253// a tenant. This is the call the file header said nothing here made yet: SES
254// validates the association on every tenant-attributed send, so it must exist
255// before any send carries X-SES-TENANT. Associating changes nothing about the
256// resource itself, so it is safe on an identity another product owns.
257func (p *awsProvisioner) AssociateTenantResource(ctx context.Context, tenant, resourceARN string) error {
258 _, err := p.ses.CreateTenantResourceAssociation(ctx, &sesv2.CreateTenantResourceAssociationInput{
259 TenantName: aws.String(tenant),
260 ResourceArn: aws.String(resourceARN),
261 })
262 if err != nil && !isAlreadyExists(err) {
263 return fmt.Errorf("mail: associate %s with tenant %q: %w", resourceARN, tenant, err)
264 }
265 return nil
266}
267
268// DisassociateTenantResource removes one association. It has to run before
269// DeleteEmailIdentity: SES refuses to delete an identity while an
270// association exists, which is also the cross-team contract for a borrowed
271// identity (design §7) — while CARLOS's association stands, the owner cannot
272// delete their own identity.
273func (p *awsProvisioner) DisassociateTenantResource(ctx context.Context, tenant, resourceARN string) error {
274 _, err := p.ses.DeleteTenantResourceAssociation(ctx, &sesv2.DeleteTenantResourceAssociationInput{
275 TenantName: aws.String(tenant),
276 ResourceArn: aws.String(resourceARN),
277 })
278 if err != nil && !isNotFound(err) {
279 return fmt.Errorf("mail: disassociate %s from tenant %q: %w", resourceARN, tenant, err)
280 }
281 return nil
282}
283
284func (p *awsProvisioner) EnsureConfigSet(ctx context.Context, region, name, snsTopicARN string) error {
285 if err := p.checkRegion(region); err != nil {
286 return err
287 }
288 _, err := p.ses.CreateConfigurationSet(ctx, &sesv2.CreateConfigurationSetInput{
289 ConfigurationSetName: aws.String(name),
290 })
291 if err != nil && !isAlreadyExists(err) {
292 return fmt.Errorf("mail: create configuration set %q: %w", name, err)
293 }
294 _, err = p.ses.CreateConfigurationSetEventDestination(ctx, &sesv2.CreateConfigurationSetEventDestinationInput{
295 ConfigurationSetName: aws.String(name),
296 EventDestinationName: aws.String("carlos-events"),
297 EventDestination: &sesv2types.EventDestinationDefinition{
298 Enabled: true,
299 // Send is here as well as the failure events because it is the
300 // only source of the per-tenant send count that enforces a cap
301 // (spec §9) — SES has no per-identity rate limit to read.
302 MatchingEventTypes: []sesv2types.EventType{
303 sesv2types.EventTypeSend,
304 sesv2types.EventTypeBounce,
305 sesv2types.EventTypeComplaint,
306 sesv2types.EventTypeReject,
307 sesv2types.EventTypeDelivery,
308 },
309 SnsDestination: &sesv2types.SnsDestination{TopicArn: aws.String(snsTopicARN)},
310 },
311 })
312 if err != nil && !isAlreadyExists(err) {
313 return fmt.Errorf("mail: create event destination on %q: %w", name, err)
314 }
315 return nil
316}
317
318// SetIdentityConfigSet binds domain's identity to configSet — see the
319// Provisioner interface's doc comment on why this call, separate from
320// EnsureConfigSet, is what actually makes SES emit an event for a send
321// (C1, review follow-up).
322func (p *awsProvisioner) SetIdentityConfigSet(ctx context.Context, region, domain, configSet string) error {
323 if err := p.checkRegion(region); err != nil {
324 return err
325 }
326 _, err := p.ses.PutEmailIdentityConfigurationSetAttributes(ctx, &sesv2.PutEmailIdentityConfigurationSetAttributesInput{
327 EmailIdentity: aws.String(domain),
328 ConfigurationSetName: aws.String(configSet),
329 })
330 if err != nil {
331 return fmt.Errorf("mail: set configuration set %q on identity %q: %w", configSet, domain, err)
332 }
333 return nil
334}
335
336// EnsureIdentity creates the identity with Easy DKIM and sets the custom
337// MAIL FROM, idempotently.
338//
339// On "already exists" it READS the identity back rather than failing,
340// because the DKIM tokens it must return are the ones already published in
341// DNS. Minting fresh tokens on a re-run would orphan every CNAME already
342// out there — and for a custom domain, CNAMEs somebody published by hand.
343//
344// SESv2 does not return an identity ARN, so it is composed here from region,
345// domain and p.account (resolved once at construction — controller ruling
346// F3), in the exact shape SendPolicyJSON validates:
347// arn:aws:ses:<region>:<account-id>:identity/<domain>.
348//
349// With adopt false, "already exists" is ErrIdentityExists, returned before
350// the identity is read or its MAIL FROM touched: the identity may belong to
351// another product in this AWS account (#216).
352func (p *awsProvisioner) EnsureIdentity(ctx context.Context, region, domain, mailFrom string, adopt bool) (Identity, error) {
353 if err := p.checkRegion(region); err != nil {
354 return Identity{}, err
355 }
356 var tokens []string
357 created := false
358 out, err := p.ses.CreateEmailIdentity(ctx, &sesv2.CreateEmailIdentityInput{
359 EmailIdentity: aws.String(domain),
360 })
361 switch {
362 case err == nil:
363 created = true
364 if out.DkimAttributes != nil {
365 tokens = out.DkimAttributes.Tokens
366 }
367 case isAlreadyExists(err) && !adopt:
368 return Identity{}, fmt.Errorf("mail: identity %q in %s: %w", domain, region, ErrIdentityExists)
369 case isAlreadyExists(err):
370 got, gerr := p.ses.GetEmailIdentity(ctx, &sesv2.GetEmailIdentityInput{
371 EmailIdentity: aws.String(domain),
372 })
373 if gerr != nil {
374 return Identity{}, fmt.Errorf("mail: get existing identity %q: %w", domain, gerr)
375 }
376 if got.DkimAttributes != nil {
377 tokens = got.DkimAttributes.Tokens
378 }
379 default:
380 return Identity{}, fmt.Errorf("mail: create identity %q in %s: %w", domain, region, err)
381 }
382
383 // No tokens is not a partial success: with none to publish as CNAMEs,
384 // the domain can never converge to DKIM SUCCESS, and nothing else in
385 // the walk would ever surface why. This also catches an identity that
386 // exists but was created BYODKIM or as an email-address identity rather
387 // than Easy DKIM — GetEmailIdentity succeeds but has no Tokens either.
388 if len(tokens) == 0 {
389 return Identity{}, fmt.Errorf("mail: identity %q in %s has no Easy DKIM tokens available", domain, region)
390 }
391
392 if _, err := p.ses.PutEmailIdentityMailFromAttributes(ctx, &sesv2.PutEmailIdentityMailFromAttributesInput{
393 EmailIdentity: aws.String(domain),
394 MailFromDomain: aws.String(mailFrom),
395 BehaviorOnMxFailure: mailFromBehaviour,
396 }); err != nil {
397 return Identity{}, fmt.Errorf("mail: set MAIL FROM %q on %q: %w", mailFrom, domain, err)
398 }
399
400 return Identity{
401 ARN: fmt.Sprintf("arn:aws:ses:%s:%s:identity/%s", region, p.account, domain),
402 DKIMTokens: tokens,
403 Created: created,
404 }, nil
405}
406
407func (p *awsProvisioner) IdentityStatus(ctx context.Context, region, domain string) (Status, error) {
408 if err := p.checkRegion(region); err != nil {
409 return Status{}, err
410 }
411 got, err := p.ses.GetEmailIdentity(ctx, &sesv2.GetEmailIdentityInput{
412 EmailIdentity: aws.String(domain),
413 })
414 if err != nil {
415 if isNotFound(err) {
416 return Status{DKIM: "NOT_STARTED", MailFrom: "NOT_STARTED", Sending: "NOT_STARTED"}, nil
417 }
418 return Status{}, fmt.Errorf("mail: get identity %q: %w", domain, err)
419 }
420 st := Status{Sending: "PENDING"}
421 if got.VerifiedForSendingStatus {
422 st.Sending = "SUCCESS"
423 }
424 if got.DkimAttributes != nil {
425 st.DKIM = string(got.DkimAttributes.Status)
426 }
427 if got.MailFromAttributes != nil {
428 st.MailFrom = string(got.MailFromAttributes.MailFromDomainStatus)
429 }
430 st.ConfigSet = aws.ToString(got.ConfigurationSetName)
431 return st, nil
432}
433
434func (p *awsProvisioner) DeleteIdentity(ctx context.Context, region, domain string) error {
435 if err := p.checkRegion(region); err != nil {
436 return err
437 }
438 _, err := p.ses.DeleteEmailIdentity(ctx, &sesv2.DeleteEmailIdentityInput{
439 EmailIdentity: aws.String(domain),
440 })
441 if err != nil && !isNotFound(err) {
442 return fmt.Errorf("mail: delete identity %q: %w", domain, err)
443 }
444 return nil
445}
446
447// ---- IAM --------------------------------------------------------------
448
449// EnsureUser creates the sending user at path if absent, under the
450// permissions boundary, and writes its inline policy either way.
451//
452// THE BOUNDARY IS THE LOAD-BEARING HALF, not a hardening extra. The console's
453// role may call CreateUser, PutUserPolicy and CreateAccessKey on the mail
454// user path. Without a boundary, anyone acting as that role can create a
455// user there, give it an administrator inline policy and mint it a key: the
456// whole AWS account. The path says which user is made, never what it may do.
457// The boundary is a ceiling that holds whatever inline policy is written,
458// including a wrong one from this code. objstore's EnsureUser closed the
459// same gap for store users.
460//
461// A user that already exists (a re-run after a crash, a policy refresh, or a
462// user created before this boundary existed) is read back with GetUser. If
463// it carries no boundary, or a different one, PutUserPermissionsBoundary sets
464// the configured one BEFORE PutUserPolicy runs. That is how users created
465// unbounded get fixed, and the order matters: once the operator conditions
466// PutUserPolicy on iam:PermissionsBoundary, the policy write on a user still
467// unbounded is refused. This never removes a boundary.
468func (p *awsProvisioner) EnsureUser(ctx context.Context, name, path string, identityARNs, configSetARNs, domains []string) (string, error) {
469 // NewAWSProvisioner already refused an empty boundary. Checked again
470 // here because this is the call that would act without one.
471 if err := checkUserBoundary(p.boundary); err != nil {
472 return "", err
473 }
474 // Render the policy FIRST. SendPolicyJSON refuses an empty grant, and
475 // refusing before CreateUser is what stops a principal existing with no
476 // policy at all — an unscoped sending user, briefly, is still one.
477 doc, err := SendPolicyJSON(identityARNs, configSetARNs, domains)
478 if err != nil {
479 return "", err
480 }
481 var arn string
482 out, err := p.iam.CreateUser(ctx, &iam.CreateUserInput{
483 UserName: aws.String(name),
484 Path: aws.String(path),
485 PermissionsBoundary: aws.String(p.boundary),
486 })
487 if err != nil {
488 if !isAlreadyExists(err) {
489 return "", fmt.Errorf("mail: create user %q: %w", name, err)
490 }
491 got, gerr := p.iam.GetUser(ctx, &iam.GetUserInput{UserName: aws.String(name)})
492 if gerr != nil {
493 return "", fmt.Errorf("mail: get existing user %q: %w", name, gerr)
494 }
495 if got.User == nil {
496 return "", fmt.Errorf("mail: get existing user %q: no user in the response", name)
497 }
498 if err := p.backfillBoundary(ctx, name, got.User); err != nil {
499 return "", err
500 }
501 arn = aws.ToString(got.User.Arn)
502 } else {
503 arn = aws.ToString(out.User.Arn)
504 }
505 if _, err := p.iam.PutUserPolicy(ctx, &iam.PutUserPolicyInput{
506 UserName: aws.String(name),
507 PolicyName: aws.String(PolicyName),
508 PolicyDocument: aws.String(doc),
509 }); err != nil {
510 return "", fmt.Errorf("mail: put policy for user %q: %w", name, err)
511 }
512 return arn, nil
513}
514
515// ensureUserBoundary reads user and backfills the boundary onto it.
516func (p *awsProvisioner) ensureUserBoundary(ctx context.Context, user string) error {
517 if err := checkUserBoundary(p.boundary); err != nil {
518 return err
519 }
520 got, err := p.iam.GetUser(ctx, &iam.GetUserInput{UserName: aws.String(user)})
521 if err != nil {
522 return fmt.Errorf("mail: get user %q: %w", user, err)
523 }
524 if got.User == nil {
525 return fmt.Errorf("mail: get user %q: no user in the response", user)
526 }
527 return p.backfillBoundary(ctx, user, got.User)
528}
529
530// backfillBoundary sets p.boundary on an existing user that lacks it or
531// carries another one. A user already carrying it costs no call.
532func (p *awsProvisioner) backfillBoundary(ctx context.Context, name string, u *iamtypes.User) error {
533 if u.PermissionsBoundary != nil && aws.ToString(u.PermissionsBoundary.PermissionsBoundaryArn) == p.boundary {
534 return nil
535 }
536 if _, err := p.iam.PutUserPermissionsBoundary(ctx, &iam.PutUserPermissionsBoundaryInput{
537 UserName: aws.String(name),
538 PermissionsBoundary: aws.String(p.boundary),
539 }); err != nil {
540 return fmt.Errorf("mail: set permissions boundary on existing user %q: %w", name, err)
541 }
542 return nil
543}
544
545// MintKey creates a fresh access key for user, after making sure the user
546// carries the boundary. Every walk that mints calls this, and rotation
547// (Rotate) reaches it WITHOUT EnsureUser, so this is the check that no key
548// is ever minted for an unbounded user: one GetUser, and a
549// PutUserPermissionsBoundary only when the boundary is missing or different.
550func (p *awsProvisioner) MintKey(ctx context.Context, user string) (string, string, error) {
551 if err := p.ensureUserBoundary(ctx, user); err != nil {
552 return "", "", err
553 }
554 out, err := p.iam.CreateAccessKey(ctx, &iam.CreateAccessKeyInput{UserName: aws.String(user)})
555 if err != nil {
556 return "", "", fmt.Errorf("mail: create access key for %q: %w", user, err)
557 }
558 return aws.ToString(out.AccessKey.AccessKeyId), aws.ToString(out.AccessKey.SecretAccessKey), nil
559}
560
561// SetKeyActive is the pause. Instant, reversible, and it rewrites no policy —
562// which matters because a policy rewrite would have to be undone correctly to
563// resume, and this cannot half-fail into a broken grant.
564func (p *awsProvisioner) SetKeyActive(ctx context.Context, user, keyID string, active bool) error {
565 st := iamtypes.StatusTypeInactive
566 if active {
567 st = iamtypes.StatusTypeActive
568 }
569 _, err := p.iam.UpdateAccessKey(ctx, &iam.UpdateAccessKeyInput{
570 UserName: aws.String(user), AccessKeyId: aws.String(keyID), Status: st,
571 })
572 if err != nil && !isNotFound(err) {
573 return fmt.Errorf("mail: set key %s active=%v: %w", keyID, active, err)
574 }
575 return nil
576}
577
578func (p *awsProvisioner) DeleteKey(ctx context.Context, user, keyID string) error {
579 _, err := p.iam.DeleteAccessKey(ctx, &iam.DeleteAccessKeyInput{
580 UserName: aws.String(user), AccessKeyId: aws.String(keyID),
581 })
582 if err != nil && !isNotFound(err) {
583 return fmt.Errorf("mail: delete key %s: %w", keyID, err)
584 }
585 return nil
586}
587
588func (p *awsProvisioner) ReapKeys(ctx context.Context, user string, keep []string) error {
589 keepSet := make(map[string]bool, len(keep))
590 for _, id := range keep {
591 keepSet[id] = true
592 }
593 var marker *string
594 for {
595 out, err := p.iam.ListAccessKeys(ctx, &iam.ListAccessKeysInput{
596 UserName: aws.String(user), Marker: marker,
597 })
598 if err != nil {
599 if isNotFound(err) {
600 return nil
601 }
602 return fmt.Errorf("mail: list access keys for %q: %w", user, err)
603 }
604 for _, k := range out.AccessKeyMetadata {
605 id := aws.ToString(k.AccessKeyId)
606 if keepSet[id] {
607 continue
608 }
609 if err := p.DeleteKey(ctx, user, id); err != nil {
610 return err
611 }
612 }
613 if !out.IsTruncated {
614 return nil
615 }
616 if out.Marker == nil {
617 // IsTruncated=true with no Marker to page from would otherwise
618 // spin forever re-issuing the same request. AWS is not observed
619 // to do this, but this loop runs inside a console door, and the
620 // guard is one line while the file is already open.
621 return nil
622 }
623 marker = out.Marker
624 }
625}
626
627// DeleteUser removes keys and the inline policy first — IAM refuses to
628// delete a user that still has either.
629func (p *awsProvisioner) DeleteUser(ctx context.Context, user string) error {
630 if err := p.ReapKeys(ctx, user, nil); err != nil {
631 return err
632 }
633 if _, err := p.iam.DeleteUserPolicy(ctx, &iam.DeleteUserPolicyInput{
634 UserName: aws.String(user), PolicyName: aws.String(PolicyName),
635 }); err != nil && !isNotFound(err) {
636 return fmt.Errorf("mail: delete policy for %q: %w", user, err)
637 }
638 if _, err := p.iam.DeleteUser(ctx, &iam.DeleteUserInput{UserName: aws.String(user)}); err != nil && !isNotFound(err) {
639 return fmt.Errorf("mail: delete user %q: %w", user, err)
640 }
641 return nil
642}
643