// The AWS provisioner: SESv2 + IAM behind the Provisioner seam. // // Every idempotence mapping here is load-bearing. The walks in walk.go are // re-runnable after a half-failure ONLY because each verb below treats // "already exists" and "already gone" as success rather than error. // // TENANT ATTRIBUTION: verified against the live SESv2 API reference and // Developer Guide on 2026-08-19 (docs.aws.amazon.com/ses/latest/dg/tenants.html // and docs.aws.amazon.com/ses/latest/APIReference-V2/API_CreateTenant.html). // SESv2 DOES have tenant management: CreateTenant, CreateTenantResourceAssociation, // GetTenant, DeleteTenant, ListTenants, ListTenantResources are all present in // aws-sdk-go-v2/service/sesv2 v1.66.6, and CreateTenant's documented error set // includes AlreadyExistsException, so it fits the same idempotence mapping as // everything else here. SMTP sends ARE attributable to a tenant, via a plain // header on the outgoing message: `X-SES-TENANT: ` (the guide // gives that exact spelling, NOT the `X-SES-TENANT-NAME` this package's spec // guessed at). That header, plus which config set and identity a send uses, // is a concern of the code that composes an outgoing message, not of this // provisioner. EnsureTenant below therefore does the one thing the Provisioner // interface actually asks of it — create the account's named tenant container, // idempotently — and stops there: it does not call // CreateTenantResourceAssociation itself, because the interface gives // EnsureTenant no identity or config-set to associate; that association is // wired separately, by walk.go's EnsureDomain, once it has both (plan 2, // task 7). Isolation is unaffected either way: the tenant buys REPUTATION // isolation, and AUTHORITY isolation is the IAM policy in policy.go (spec // §4). package mail import ( "context" "errors" "fmt" "regexp" "github.com/aws/aws-sdk-go-v2/aws" awsconfig "github.com/aws/aws-sdk-go-v2/config" "github.com/aws/aws-sdk-go-v2/service/iam" iamtypes "github.com/aws/aws-sdk-go-v2/service/iam/types" "github.com/aws/aws-sdk-go-v2/service/sesv2" sesv2types "github.com/aws/aws-sdk-go-v2/service/sesv2/types" "github.com/aws/aws-sdk-go-v2/service/sts" "github.com/carlosframework/platform/internal/rootaws" ) // mailFromBehaviour is REJECT_MESSAGE and must stay that way. // // The alternative, USE_DEFAULT_VALUE, silently falls back to amazonses.com // when the custom MAIL FROM MX is absent: the tenant keeps sending, from an // unaligned envelope domain, failing DMARC, with nothing to notice. Rejecting // turns a DNS mistake into a visible error instead of a slow deliverability // leak. const mailFromBehaviour = sesv2types.BehaviorOnMxFailureRejectMessage // sesAPI is the subset of *sesv2.Client awsProvisioner calls, narrowed so a // future test can stand a stub in for a live client without a network or // credentials — exactly as internal/objstore/aws.go narrows s3API and iamAPI. type sesAPI interface { CreateTenant(context.Context, *sesv2.CreateTenantInput, ...func(*sesv2.Options)) (*sesv2.CreateTenantOutput, error) CreateEmailIdentity(context.Context, *sesv2.CreateEmailIdentityInput, ...func(*sesv2.Options)) (*sesv2.CreateEmailIdentityOutput, error) GetEmailIdentity(context.Context, *sesv2.GetEmailIdentityInput, ...func(*sesv2.Options)) (*sesv2.GetEmailIdentityOutput, error) PutEmailIdentityMailFromAttributes(context.Context, *sesv2.PutEmailIdentityMailFromAttributesInput, ...func(*sesv2.Options)) (*sesv2.PutEmailIdentityMailFromAttributesOutput, error) DeleteEmailIdentity(context.Context, *sesv2.DeleteEmailIdentityInput, ...func(*sesv2.Options)) (*sesv2.DeleteEmailIdentityOutput, error) CreateConfigurationSet(context.Context, *sesv2.CreateConfigurationSetInput, ...func(*sesv2.Options)) (*sesv2.CreateConfigurationSetOutput, error) CreateConfigurationSetEventDestination(context.Context, *sesv2.CreateConfigurationSetEventDestinationInput, ...func(*sesv2.Options)) (*sesv2.CreateConfigurationSetEventDestinationOutput, error) PutEmailIdentityConfigurationSetAttributes(context.Context, *sesv2.PutEmailIdentityConfigurationSetAttributesInput, ...func(*sesv2.Options)) (*sesv2.PutEmailIdentityConfigurationSetAttributesOutput, error) CreateTenantResourceAssociation(context.Context, *sesv2.CreateTenantResourceAssociationInput, ...func(*sesv2.Options)) (*sesv2.CreateTenantResourceAssociationOutput, error) DeleteTenantResourceAssociation(context.Context, *sesv2.DeleteTenantResourceAssociationInput, ...func(*sesv2.Options)) (*sesv2.DeleteTenantResourceAssociationOutput, error) } // iamAPI is the subset of *iam.Client awsProvisioner calls. 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) } // stsAPI is the subset of *sts.Client awsProvisioner's constructor calls, // narrowed the same way as sesAPI and iamAPI. type stsAPI interface { GetCallerIdentity(context.Context, *sts.GetCallerIdentityInput, ...func(*sts.Options)) (*sts.GetCallerIdentityOutput, error) } type awsProvisioner struct { ses sesAPI 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) // NewAWSProvisioner builds a Provisioner against SESv2 and IAM. // // The SES client is bound to region because SES is regional in every // dimension that matters. IAM takes no region: users and keys are global, // which is what lets one credential span regions (spec §8). // // The account id is resolved ONCE here via STS GetCallerIdentity and stored // on the provisioner (controller ruling F3), rather than looked up per call: // 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. // // 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) } var stsClient stsAPI = sts.NewFromConfig(cfg) ident, err := stsClient.GetCallerIdentity(ctx, &sts.GetCallerIdentityInput{}) if err != nil { 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), 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::iam::<12-digit account>:policy/. 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/)", 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 // still takes a region parameter — because the spec's multi-region story is // "capable, not deployed" and MemProvisioner genuinely models N regions. An // awsProvisioner silently routing every region through its one client would // let a walk test built against the fake pass while production quietly acts // on the wrong region (or, for EnsureIdentity, composes an ARN naming the // CALLER's region while the identity itself was created in a different // one). An empty region is accepted as "caller doesn't care" and treated as // this provisioner's own region. // errRegionMismatch is the sentinel checkRegion wraps, so a test (or a // future caller) can check errors.Is(err, errRegionMismatch) rather than // matching against the formatted message. var errRegionMismatch = errors.New("mail: region mismatch") func (p *awsProvisioner) checkRegion(region string) error { if region != "" && region != p.region { return fmt.Errorf("mail: provisioner is bound to region %s, cannot act on %s: %w", p.region, region, errRegionMismatch) } return nil } // ---- error classification ------------------------------------------------- func isAlreadyExists(err error) bool { var ses *sesv2types.AlreadyExistsException var im *iamtypes.EntityAlreadyExistsException return errors.As(err, &ses) || errors.As(err, &im) } func isNotFound(err error) bool { var ses *sesv2types.NotFoundException var im *iamtypes.NoSuchEntityException return errors.As(err, &ses) || errors.As(err, &im) } // ---- SES ------------------------------------------------------------------ // EnsureTenant creates the account's SES tenant if absent. // // See the TENANT ATTRIBUTION note at the top of this file: CreateTenant is a // real, present SESv2 API, verified against the live docs on 2026-08-19. This // creates ONLY the named container — it does not call // CreateTenantResourceAssociation, because EnsureTenant's signature carries // no identity ARN or configuration-set name to associate. Isolation is // unaffected by that gap: the tenant buys reputation isolation, and // authority isolation is the IAM policy (spec §4), which this does not touch. // // PRECONDITION FOR SENDING, NOW MET BY EnsureDomain, NOT HERE: per // docs.aws.amazon.com/ses/latest/dg/tenants.html, a tenant needs at least // one verified identity AND one configuration set associated via // CreateTenantResourceAssociation before SES will accept a send attributed // to it — SES validates that association on every send. walk.go's // EnsureDomain wires both associations now, after PutDomain, using // AssociateTenantResource below (plan 2, task 7). This still does not make // any send carry the `X-SES-TENANT: ` header: nothing in step 2 // composes an outgoing message or writes a tenant-conditioned policy, so // that header waits for step 3. The spike's answer (design §4.1, confirmed // against the live API): an association is inert for the identity's owner // in every respect except deletion, and `ses:TenantName` IS enforced over // SMTP. func (p *awsProvisioner) EnsureTenant(ctx context.Context, name string) error { _, err := p.ses.CreateTenant(ctx, &sesv2.CreateTenantInput{ TenantName: aws.String(name), }) if err != nil && !isAlreadyExists(err) { return fmt.Errorf("mail: create tenant %q: %w", name, err) } return nil } // AssociateTenantResource associates an identity or a configuration set with // a tenant. This is the call the file header said nothing here made yet: SES // validates the association on every tenant-attributed send, so it must exist // before any send carries X-SES-TENANT. Associating changes nothing about the // resource itself, so it is safe on an identity another product owns. func (p *awsProvisioner) AssociateTenantResource(ctx context.Context, tenant, resourceARN string) error { _, err := p.ses.CreateTenantResourceAssociation(ctx, &sesv2.CreateTenantResourceAssociationInput{ TenantName: aws.String(tenant), ResourceArn: aws.String(resourceARN), }) if err != nil && !isAlreadyExists(err) { return fmt.Errorf("mail: associate %s with tenant %q: %w", resourceARN, tenant, err) } return nil } // DisassociateTenantResource removes one association. It has to run before // DeleteEmailIdentity: SES refuses to delete an identity while an // association exists, which is also the cross-team contract for a borrowed // identity (design §7) — while CARLOS's association stands, the owner cannot // delete their own identity. func (p *awsProvisioner) DisassociateTenantResource(ctx context.Context, tenant, resourceARN string) error { _, err := p.ses.DeleteTenantResourceAssociation(ctx, &sesv2.DeleteTenantResourceAssociationInput{ TenantName: aws.String(tenant), ResourceArn: aws.String(resourceARN), }) if err != nil && !isNotFound(err) { return fmt.Errorf("mail: disassociate %s from tenant %q: %w", resourceARN, tenant, err) } return nil } func (p *awsProvisioner) EnsureConfigSet(ctx context.Context, region, name, snsTopicARN string) error { if err := p.checkRegion(region); err != nil { return err } _, err := p.ses.CreateConfigurationSet(ctx, &sesv2.CreateConfigurationSetInput{ ConfigurationSetName: aws.String(name), }) if err != nil && !isAlreadyExists(err) { return fmt.Errorf("mail: create configuration set %q: %w", name, err) } _, err = p.ses.CreateConfigurationSetEventDestination(ctx, &sesv2.CreateConfigurationSetEventDestinationInput{ ConfigurationSetName: aws.String(name), EventDestinationName: aws.String("carlos-events"), EventDestination: &sesv2types.EventDestinationDefinition{ Enabled: true, // Send is here as well as the failure events because it is the // only source of the per-tenant send count that enforces a cap // (spec §9) — SES has no per-identity rate limit to read. MatchingEventTypes: []sesv2types.EventType{ sesv2types.EventTypeSend, sesv2types.EventTypeBounce, sesv2types.EventTypeComplaint, sesv2types.EventTypeReject, sesv2types.EventTypeDelivery, }, SnsDestination: &sesv2types.SnsDestination{TopicArn: aws.String(snsTopicARN)}, }, }) if err != nil && !isAlreadyExists(err) { return fmt.Errorf("mail: create event destination on %q: %w", name, err) } return nil } // SetIdentityConfigSet binds domain's identity to configSet — see the // Provisioner interface's doc comment on why this call, separate from // EnsureConfigSet, is what actually makes SES emit an event for a send // (C1, review follow-up). func (p *awsProvisioner) SetIdentityConfigSet(ctx context.Context, region, domain, configSet string) error { if err := p.checkRegion(region); err != nil { return err } _, err := p.ses.PutEmailIdentityConfigurationSetAttributes(ctx, &sesv2.PutEmailIdentityConfigurationSetAttributesInput{ EmailIdentity: aws.String(domain), ConfigurationSetName: aws.String(configSet), }) if err != nil { return fmt.Errorf("mail: set configuration set %q on identity %q: %w", configSet, domain, err) } return nil } // EnsureIdentity creates the identity with Easy DKIM and sets the custom // MAIL FROM, idempotently. // // On "already exists" it READS the identity back rather than failing, // because the DKIM tokens it must return are the ones already published in // DNS. Minting fresh tokens on a re-run would orphan every CNAME already // out there — and for a custom domain, CNAMEs somebody published by hand. // // SESv2 does not return an identity ARN, so it is composed here from region, // domain and p.account (resolved once at construction — controller ruling // F3), in the exact shape SendPolicyJSON validates: // arn:aws:ses:::identity/. // // With adopt false, "already exists" is ErrIdentityExists, returned before // the identity is read or its MAIL FROM touched: the identity may belong to // another product in this AWS account (#216). func (p *awsProvisioner) EnsureIdentity(ctx context.Context, region, domain, mailFrom string, adopt bool) (Identity, error) { if err := p.checkRegion(region); err != nil { return Identity{}, err } var tokens []string created := false out, err := p.ses.CreateEmailIdentity(ctx, &sesv2.CreateEmailIdentityInput{ EmailIdentity: aws.String(domain), }) switch { case err == nil: created = true if out.DkimAttributes != nil { tokens = out.DkimAttributes.Tokens } case isAlreadyExists(err) && !adopt: return Identity{}, fmt.Errorf("mail: identity %q in %s: %w", domain, region, ErrIdentityExists) case isAlreadyExists(err): got, gerr := p.ses.GetEmailIdentity(ctx, &sesv2.GetEmailIdentityInput{ EmailIdentity: aws.String(domain), }) if gerr != nil { return Identity{}, fmt.Errorf("mail: get existing identity %q: %w", domain, gerr) } if got.DkimAttributes != nil { tokens = got.DkimAttributes.Tokens } default: return Identity{}, fmt.Errorf("mail: create identity %q in %s: %w", domain, region, err) } // No tokens is not a partial success: with none to publish as CNAMEs, // the domain can never converge to DKIM SUCCESS, and nothing else in // the walk would ever surface why. This also catches an identity that // exists but was created BYODKIM or as an email-address identity rather // than Easy DKIM — GetEmailIdentity succeeds but has no Tokens either. if len(tokens) == 0 { return Identity{}, fmt.Errorf("mail: identity %q in %s has no Easy DKIM tokens available", domain, region) } if _, err := p.ses.PutEmailIdentityMailFromAttributes(ctx, &sesv2.PutEmailIdentityMailFromAttributesInput{ EmailIdentity: aws.String(domain), MailFromDomain: aws.String(mailFrom), BehaviorOnMxFailure: mailFromBehaviour, }); err != nil { return Identity{}, fmt.Errorf("mail: set MAIL FROM %q on %q: %w", mailFrom, domain, err) } return Identity{ ARN: fmt.Sprintf("arn:aws:ses:%s:%s:identity/%s", region, p.account, domain), DKIMTokens: tokens, Created: created, }, nil } func (p *awsProvisioner) IdentityStatus(ctx context.Context, region, domain string) (Status, error) { if err := p.checkRegion(region); err != nil { return Status{}, err } got, err := p.ses.GetEmailIdentity(ctx, &sesv2.GetEmailIdentityInput{ EmailIdentity: aws.String(domain), }) if err != nil { if isNotFound(err) { return Status{DKIM: "NOT_STARTED", MailFrom: "NOT_STARTED", Sending: "NOT_STARTED"}, nil } return Status{}, fmt.Errorf("mail: get identity %q: %w", domain, err) } st := Status{Sending: "PENDING"} if got.VerifiedForSendingStatus { st.Sending = "SUCCESS" } if got.DkimAttributes != nil { st.DKIM = string(got.DkimAttributes.Status) } if got.MailFromAttributes != nil { st.MailFrom = string(got.MailFromAttributes.MailFromDomainStatus) } st.ConfigSet = aws.ToString(got.ConfigurationSetName) return st, nil } func (p *awsProvisioner) DeleteIdentity(ctx context.Context, region, domain string) error { if err := p.checkRegion(region); err != nil { return err } _, err := p.ses.DeleteEmailIdentity(ctx, &sesv2.DeleteEmailIdentityInput{ EmailIdentity: aws.String(domain), }) if err != nil && !isNotFound(err) { return fmt.Errorf("mail: delete identity %q: %w", domain, err) } return nil } // ---- 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. doc, err := SendPolicyJSON(identityARNs, configSetARNs, domains) if err != nil { return "", err } var arn string out, err := p.iam.CreateUser(ctx, &iam.CreateUserInput{ UserName: aws.String(name), Path: aws.String(path), PermissionsBoundary: aws.String(p.boundary), }) if err != nil { if !isAlreadyExists(err) { return "", fmt.Errorf("mail: create user %q: %w", name, err) } got, gerr := p.iam.GetUser(ctx, &iam.GetUserInput{UserName: aws.String(name)}) 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) } if _, err := p.iam.PutUserPolicy(ctx, &iam.PutUserPolicyInput{ UserName: aws.String(name), PolicyName: aws.String(PolicyName), PolicyDocument: aws.String(doc), }); err != nil { return "", fmt.Errorf("mail: put policy for user %q: %w", name, err) } 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) } return aws.ToString(out.AccessKey.AccessKeyId), aws.ToString(out.AccessKey.SecretAccessKey), nil } // SetKeyActive is the pause. Instant, reversible, and it rewrites no policy — // which matters because a policy rewrite would have to be undone correctly to // resume, and this cannot half-fail into a broken grant. func (p *awsProvisioner) SetKeyActive(ctx context.Context, user, keyID string, active bool) error { st := iamtypes.StatusTypeInactive if active { st = iamtypes.StatusTypeActive } _, err := p.iam.UpdateAccessKey(ctx, &iam.UpdateAccessKeyInput{ UserName: aws.String(user), AccessKeyId: aws.String(keyID), Status: st, }) if err != nil && !isNotFound(err) { return fmt.Errorf("mail: set key %s active=%v: %w", keyID, active, err) } return nil } func (p *awsProvisioner) DeleteKey(ctx context.Context, user, keyID string) error { _, err := p.iam.DeleteAccessKey(ctx, &iam.DeleteAccessKeyInput{ UserName: aws.String(user), AccessKeyId: aws.String(keyID), }) if err != nil && !isNotFound(err) { return fmt.Errorf("mail: delete key %s: %w", keyID, err) } return nil } func (p *awsProvisioner) ReapKeys(ctx context.Context, user string, keep []string) error { keepSet := make(map[string]bool, len(keep)) for _, id := range keep { keepSet[id] = true } var marker *string for { out, err := p.iam.ListAccessKeys(ctx, &iam.ListAccessKeysInput{ UserName: aws.String(user), Marker: marker, }) if err != nil { if isNotFound(err) { return nil } return fmt.Errorf("mail: list access keys for %q: %w", user, err) } for _, k := range out.AccessKeyMetadata { id := aws.ToString(k.AccessKeyId) if keepSet[id] { continue } if err := p.DeleteKey(ctx, user, id); err != nil { return err } } if !out.IsTruncated { return nil } if out.Marker == nil { // IsTruncated=true with no Marker to page from would otherwise // spin forever re-issuing the same request. AWS is not observed // to do this, but this loop runs inside a console door, and the // guard is one line while the file is already open. return nil } marker = out.Marker } } // DeleteUser removes keys and the inline policy first — IAM refuses to // delete a user that still has either. func (p *awsProvisioner) DeleteUser(ctx context.Context, user string) error { if err := p.ReapKeys(ctx, user, nil); err != nil { return err } if _, err := p.iam.DeleteUserPolicy(ctx, &iam.DeleteUserPolicyInput{ UserName: aws.String(user), PolicyName: aws.String(PolicyName), }); err != nil && !isNotFound(err) { return fmt.Errorf("mail: delete policy for %q: %w", user, err) } if _, err := p.iam.DeleteUser(ctx, &iam.DeleteUserInput{UserName: aws.String(user)}); err != nil && !isNotFound(err) { return fmt.Errorf("mail: delete user %q: %w", user, err) } return nil }