fleet/server/datastore/redis/aws_iam_auth.go
Scott Gress 602f5a470b
Feat 1817 add iam auth to mysql and redis (#32488)
for #1817 

# Details

This PR gives Fleet servers the ability to connect to RDS MySQL and
Elasticache Redis via AWS [Identity and Access Management
(IAM)](https://aws.amazon.com/iam/). It is based almost entirely on the
work of @titanous, branched from his [original pull
request](https://github.com/fleetdm/fleet/pull/31075). The main
differences between his branch and this are:

1. Removal of auto-detection of AWS region (and cache name for
Elasticache) in favor of specifying these values in configuration. The
auto-detection is admittedly handy but parsing AWS host URLs is not
considered a best practice.
2. Relying on the existence of these new configs to determine whether or
not to connect via IAM. This sidesteps a thorny issue of whether to try
an IAM-based Elasticache connection when a password is not supplied,
since this is technically a valid setup.

# Checklist for submitter

If some of the following don't apply, delete the relevant line.

- [X] Changes file added for user-visible changes in `changes/`,
`orbit/changes/` or `ee/fleetd-chrome/changes`.
See [Changes
files](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/guides/committing-changes.md#changes-files)
for more information.

## Testing

- [X] Added/updated automated tests
- [X] QA'd all new/changed functionality manually - besides using
@titanous's excellent test tool, I verified the following end-to-end:
  - [X] regular (non RDS) MySQL connection
  - [X] RDS MySQL connection using username/password
  - [X] RDS MySQL connection using IAM (no role)
  - [X] RDS MySQL connection using IAM (assuming role)
  - [X] regular (non Elasticache) Redis connection
  - [X] Elasticache Redis connection using username/password
  - [X] Elasticache Redis connection using NO password (without IAM)
  - [X] Elasticache Redis connection using IAM (no role)
  - [X] Elasticache Redis connection using IAM (assuming role)

---------

Co-authored-by: Jonathan Rudenberg <jonathan@titanous.com>
Co-authored-by: Noah Talerman <47070608+noahtalerman@users.noreply.github.com>
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2025-09-04 10:08:47 -05:00

92 lines
2.7 KiB
Go

package redis
//go:generate go run gen_aws_region_map.go
import (
"context"
"fmt"
"net/http"
"strings"
"time"
"github.com/aws/aws-sdk-go-v2/aws"
v4 "github.com/aws/aws-sdk-go-v2/aws/signer/v4"
"github.com/fleetdm/fleet/v4/server/aws_common"
)
const (
// emptySHA256 is the SHA256 hash of an empty payload (for GET requests)
emptySHA256 = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
elastiCacheServiceName = "elasticache"
)
// awsIAMAuthTokenGenerator generates AWS IAM authentication tokens for ElastiCache
type awsIAMAuthTokenGenerator struct {
credentials aws.CredentialsProvider
signer *v4.Signer
region string
clusterName string
userName string
tokenManager *aws_common.IAMAuthTokenManager
}
// newAWSIAMAuthTokenGenerator creates a new AWS IAM authentication token generator
func newAWSIAMAuthTokenGenerator(clusterName, userName, region, assumeRoleArn, stsExternalID string) (*awsIAMAuthTokenGenerator, error) {
// Load AWS configuration
cfg, err := aws_common.LoadAWSConfig(context.Background(), region, assumeRoleArn, stsExternalID)
if err != nil {
return nil, err
}
g := &awsIAMAuthTokenGenerator{
credentials: cfg.Credentials,
signer: v4.NewSigner(),
region: region,
clusterName: clusterName,
userName: userName,
}
// Create token manager with the generator's token generation function
g.tokenManager = aws_common.NewIAMAuthTokenManager(g.generateNewToken)
return g, nil
}
// generateAuthToken generates an IAM authentication token for ElastiCache
// It uses a cache to avoid generating new tokens for every connection
func (g *awsIAMAuthTokenGenerator) generateAuthToken(ctx context.Context) (string, error) {
return g.tokenManager.GetToken(ctx)
}
// generateNewToken creates a new IAM authentication token
func (g *awsIAMAuthTokenGenerator) generateNewToken(ctx context.Context) (string, error) {
req, err := http.NewRequestWithContext(ctx, "GET", fmt.Sprintf("https://%s/", g.clusterName), nil)
if err != nil {
return "", fmt.Errorf("failed to create request: %w", err)
}
values := req.URL.Query()
values.Set("Action", "connect")
values.Set("User", g.userName)
req.URL.RawQuery = values.Encode()
creds, err := g.credentials.Retrieve(ctx)
if err != nil {
return "", fmt.Errorf("failed to retrieve AWS credentials: %w", err)
}
// Set expiry time (15 minutes)
query := req.URL.Query()
query.Set("X-Amz-Expires", "900")
req.URL.RawQuery = query.Encode()
presignedURL, _, err := g.signer.PresignHTTP(ctx, creds, req, emptySHA256, elastiCacheServiceName, g.region, time.Now().UTC())
if err != nil {
return "", fmt.Errorf("failed to presign request: %w", err)
}
authToken := strings.TrimPrefix(presignedURL, "https://")
return authToken, nil
}