Configuring an Amazon EKS Kubernetes service account to assume an IAM role with Pulumi

Configuring an Amazon EKS Kubernetes service account to assume an IAM role with Pulumi

A core feature of Amazon EKS is the ability to assume an AWS IAM role with a Kubernetes service account. This requires that the service account is annotated with the IAM role's ARN and a trust policy attached to the IAM role that contains the service account's name as well as information about the Kubernetes cluster's OIDC provider.

With Pulumi, we can delegate the tedious work of creating the trust policy (specified in the AssumeRolePolicyDocument and thus also known as the AssumeRolePolicy) and the service account annotation to a utility function, keeping the main program focused on the core resources.

Here's a complete best practice example for setting up a utility function to associate an Amazon EKS Kubernetes service account with an AWS IAM role.

Since we need to specify the trust policy when creating the IAM role, which in turn contains information about our service account, we first create the service account in our main program. Then, we pass this resource object to the utility function:

import pulumi_kubernetes as k8s

k8s_provider = k8s.provider(
    "k8s-provider",
    enable_server_side_apply=True,  # required for patching

service_account = k8s.core.v1.ServiceAccount(
    "my-service-account",
    metadata=k8s.meta.v1.ObjectMetaArgs(
        name="my-sa",
        namespace="my-namespace",
    ),
    opts=pulumi.ResourceOptions(provider=k8s_provider),
)

iam_role, sa_annotation = create_iam_role_for_service_account(
    "my-service-account-iam-role",
    service_account=service_account,
    oidc_provider_arn=...,
    k8s_opts=pulumi.ResourceOptions(provider=k8s_provider),
    tags={"managedBy": "Pulumi"},
)

The create_iam_role_for_service_account function looks as follows:

import pulumi
import pulumi_aws as aws
import pulumi_kubernetes as k8s


def create_iam_role_for_service_account(
    resource_name: str,
    *,
    service_account: k8s.core.v1.ServiceAccount,
    oidc_provider_arn: str | pulumi.Output[str],
    k8s_opts: pulumi.ResourceOptions,
    **iam_role_kwargs,
) -> tuple[aws.iam.Role, k8s.core.v1.ServiceAccountPatch]:
    # ensure that oidc_provider_arn is a pulumi.Output
    oidc_provider_arn = pulumi.Output.from_input(oidc_provider_arn)

    iam_policy_document = aws.iam.get_policy_document_output(
        statements=[
            aws.iam.GetPolicyDocumentStatementArgs(
                actions=["sts:AssumeRoleWithWebIdentity"],
                conditions=[
                    aws.iam.GetPolicyDocumentStatementConditionArgs(
                        test="StringEquals",
                        values=["sts.amazonaws.com"],
                        variable=oidc_provider_arn.apply(
                            lambda arn: arn.split("/", 1)[1] + ":aud"
                        ),
                    ),
                    aws.iam.GetPolicyDocumentStatementConditionArgs(
                        test="StringEquals",
                        variable=oidc_provider_arn.apply(
                            lambda arn: arn.split("/", 1)[1] + ":sub"
                        ),
                        values=[
                            pulumi.Output.concat(
                                "system:serviceaccount:",
                                service_account.metadata.namespace,
                                ":",
                                service_account.metadata.name,
                            )
                        ],
                    ),
                ],
                principals=[
                    aws.iam.GetPolicyDocumentStatementPrincipalArgs(
                        type="Federated",
                        identifiers=[oidc_provider_arn],
                    )
                ],
            ),
        ]
    )

    # prevent the user from accidentally overwriting args
    iam_role_kwargs.pop("resource_name", None)
    iam_role_kwargs.pop("assume_role_policy", None)

    iam_role = aws.iam.Role(
        resource_name,
        assume_role_policy=iam_policy_document.json,
        name=pulumi.Output.concat(
            service_account.metadata.name, "-role"
        ),
        **iam_role_kwargs,
    )

    service_account_annotation = k8s.core.v1.ServiceAccountPatch(
        f"{resource_name}-sa-annotation",
        metadata=k8s.meta.v1.ObjectMetaPatchArgs(
            name=service_account.metadata.name,
            namespace=service_account.metadata.namespace,
            annotations={
                "eks.amazonaws.com/role-arn": iam_role.arn,
            },
        ),
        opts=k8s_opts,
    )

    return iam_role, service_account_annotation

Instead of the catch-all iam_role_kwargs you can explicitly specify (parts of) the arguments of aws.iam.Role in the signature of create_iam_role_for_service_account .

I generally find it preferable to not use inline_policy and managed_policy_arns on the aws.iam.Role but to work with aws.iam.RolePolicy and aws.iam.RolePolicyAttachment in the main program. This provides greater transparency and makes it easier to reason about permissions.

Resources that rely on assuming the AWS IAM role through the service account have to not only depend on any relevant policy attachments, but also on the service_account_annotation returned by the create_iam_role_for_service_account() function:

service_account = k8s.core.v1.ServiceAccount(
    "my-service-account",
    # ...
)

iam_role, sa_annotation = create_iam_role_for_service_account(
    "my-role",
    service_account=service_account,
    # ...
)

some_policy = aws.iam.RolePolicy(
    "my-policy",
    role=iam_role.id,
    # ...
)

other_resource = k8s.apps.v1.Deployment(
    "my-deployment",
    # ...
    opts=pulumi.ResourceOptions(
        depends_on=[sa_annotation, some_policy]
    ),
)