Writing to AWS S3 from Fly.io (in Go)

Featured image for sharing metadata for article

I'm currently in the process of moving my site's media (under media.jvt.me) from Netlify to AWS S3.

The underlying infrastructure for my site that writes this media is hosted on Fly.io, so needs a way to write to S3.

I really wanted to avoid long-lived AWS credentials, and fortunately Fly supports OpenID Connect which integrates with AWS' OpenID Connect Federation to allow you to use short-lived OpenID Connect tokens to authenticate as a given IAM role, and then perform any necessary AWS actions.

Although Fly has an example with curl, I wanted to blogument the "how to" with Go, as it wasn't super straightforward to do so.

I've written this up in a sample repo on GitLab.com.

Go code

The core pieces are:

// via https://gist.github.com/teknoraver/5ffacb8757330715bcbcc90e6d46ac74
var unixClient = http.Client{
	Transport: &http.Transport{
		DialContext: func(_ context.Context, _, _ string) (net.Conn, error) {
			return net.Dial("unix", "/.fly/api")
		},
	},
}

func getOIDCToken(unixClient http.Client) (string, error) {
	s3Body := bytes.NewReader([]byte(`{"aud":"sts.amazonaws.com"}`)) // or could be `{"aud":"https://sts.amazonaws.com"}` as long as it matches your Trust Policy

	// get the OIDC token
	resp, err := unixClient.Post("http://localhost/v1/tokens/oidc", "application/json", s3Body)
	if err != nil {
		slog.Error("Failed to call Fly OIDC tokens endpoint", "err", err)
		return "", err
	}

	defer resp.Body.Close()
	body, err := io.ReadAll(resp.Body)
	if err != nil {
		slog.Error("Failed to read body", "err", err)
		return "", err
	}

	return string(body), nil
}

func newAWSClient(oidcToken string) (*s3.Client, error) {
	cfg, err := config.LoadDefaultConfig(context.TODO(), config.WithRegion("eu-west-1"))
	if err != nil {
		return nil, err
	}

	stsClient := sts.NewFromConfig(cfg)

	assumedRoleInput := &sts.AssumeRoleWithWebIdentityInput{
		RoleArn:          aws.String(os.Getenv("AWS_ASSUME_ROLE_ARN")),
		RoleSessionName:  aws.String("..."),
		WebIdentityToken: aws.String(oidcToken),
	}

	resp, err := stsClient.AssumeRoleWithWebIdentity(context.TODO(), assumedRoleInput)
	if err != nil {
		return nil, err
	}

	sessionCreds := aws.NewCredentialsCache(credentials.NewStaticCredentialsProvider(
		*resp.Credentials.AccessKeyId,
		*resp.Credentials.SecretAccessKey,
		*resp.Credentials.SessionToken,
	))
	s3Client := s3.NewFromConfig(cfg, func(o *s3.Options) {
		o.Credentials = sessionCreds
	})

	return s3Client, nil
}

In this case we need to:

  • Retrieve the OpenID Connect ID token via a Unix pipe (thanks to this Gist)
  • Perform an AssumeRoleWithWebIdentity with that given token
  • Use the temporary credentials to seed the AWS client(s) we then use to interact i.e. with S3

You can then use this like so:

http.HandleFunc("POST /do-something-with-s3", func(w http.ResponseWriter, r *http.Request) {
	userBody := r.URL.Query().Get("body")
	if userBody == "" {
		return
	}

	token, err := getOIDCToken(unixClient)
	if err != nil {
		w.WriteHeader(http.StatusInternalServerError)
		return
	}

	s3Client, err := newAWSClient(token)
	if err != nil {
		slog.Error("Failed to create S3 client", "err", err)
		w.WriteHeader(http.StatusInternalServerError)
		return
	}

	inp := s3.PutObjectInput{
		Bucket: aws.String(os.Getenv("AWS_S3_BUCKET")),
		Key:    aws.String("fly-s3/" + randSeq(10)),
		Body:   bytes.NewReader([]byte(userBody)),
	}

	_, err = s3Client.PutObject(r.Context(), &inp)
	if err != nil {
		slog.Error("Failed to PUT", "err", err)
		w.WriteHeader(http.StatusInternalServerError)
		return
	}

	slog.Info("Created path", "key", inp.Key)

	w.WriteHeader(http.StatusAccepted)
})

AWS IAM/S3 polices

Once I'd created the OpenID Connect provider, I then I created a new IAM role example-fly-s3-access (remembering to make sure it had Trusted entity type: Web identity), with the following policy:

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Allow",
            "Principal": {
                "Federated": "arn:aws:iam::5..........5:oidc-provider/oidc.fly.io/jamie-tanna"
            },
            "Action": "sts:AssumeRoleWithWebIdentity",
            "Condition": {
              "StringEquals": {
                    "oidc.fly.io/jamie-tanna:aud": "sts.amazonaws.com"
                },
                "StringLike": {
                    "oidc.fly.io/jamie-tanna:sub": "jamie-tanna:fly-s3:*"
                }
            }
        }
    ]
}

I found that trying to be a little more restrictive in the Trust Policy resulted in i.e.

ERROR Failed to create S3 client err="operation error STS: AssumeRoleWithWebIdentity, https response error StatusCode: 403, RequestID: cd047481-ae53-40a8-b353-ab1d74ee582d, api error AccessDenied: Not authorized to perform sts:AssumeRoleWithWebIdentity"

It's worthwhile keeping to the standard claims like aud and sub, but would be nice if AWS flagged that themselves.

Then, for the S3 bucket policy, on one of my old test buckets me.jvt.nottsdev.workshop:

{
    "Version": "2012-10-17",
    "Id": "...",
    "Statement": [
        {
            "Sid": "ListBucket",
            "Effect": "Allow",
            "Principal": {
                "AWS": "arn:aws:iam::5..........5:role/example-fly-s3-access"
            },
            "Action": "s3:ListBucket",
            "Resource": "arn:aws:s3:::me.jvt.nottsdev.workshop"
        },
        {
            "Sid": "ListBucketObjects",
            "Effect": "Allow",
            "Principal": {
                "AWS": "arn:aws:iam::5..........5:role/example-fly-s3-access"
            },
            "Action": "s3:GetObject",
            "Resource": "arn:aws:s3:::me.jvt.nottsdev.workshop/*"
        },
        {
            "Sid": "ModifyObjectsInBucket",
            "Effect": "Allow",
            "Principal": {
                "AWS": "arn:aws:iam::5..........5:role/example-fly-s3-access"
            },
            "Action": [
                "s3:GetObject",
                "s3:PutObject",
                "s3:DeleteObject"
            ],
            "Resource": "arn:aws:s3:::me.jvt.nottsdev.workshop/*"
        }
    ]
}

Written by Jamie Tanna's profile image Jamie Tanna on , and last updated on .

Content for this article is shared under the terms of the Creative Commons Attribution Non Commercial Share Alike 4.0 International, and code is shared under the Apache License 2.0.

#blogumentation #go #fly.io #aws #oidc.

This post was filed under articles.

Interactions with this post

Interactions with this post

Below you can find the interactions that this page has had using WebMention.

Have you written a response to this post? Let me know the URL:

Do you not have a website set up with WebMention capabilities? You can use Comment Parade.