Developing secure .NET applications using IAM roles

12 minute read

When developing an application that interacts with AWS resources, it’s important to ensure that the application has the necessary permissions to perform its intended tasks.

However, in general, developer machines have broader permissions than those granted to the application in the production environment. This can lead to the frustrating scenario where an application works correctly on the developer machine but fails to work correctly once deployed.

In this blog post, we’ll explore how to create a ASP.NET Core application that uses the same IAM role across development, testing, and production environments to reduce this risk and avoid the temptation to give the application too many permissions once deployed.

Another advantage of the technique shown in the post is the increased security deriving from scoped permissions for the application, especially when executed on a developer machine.

We will build a simple web application that posts a message to an SNS topic. First, we will use Terraform to create the infrastructure needed for our sample. Then we will see how we can instrument an ASP.NET Core application to assume a role when consuming AWS resources. Finally, we are going to perform some tests to make sure that the permission are correctly working.

For convenience, you can also check the GitHub repository that I used while preparing this post.

This post will assume that you have some confidence with Terraform, the .NET SDK and the AWS SDK for .NET. Also, I will be using PowerShell for the console code.

Setting up the infrastructure

To begin, we’ll use Terraform to create the necessary infrastructure for our sample application. Terraform is an infrastructure-as-code tool that enables you to define, provision, and manage your infrastructure using a high-level configuration language. By using Terraform, we can automate the creation of the necessary AWS resources and ensure that they are configured consistently across development, testing, and production environments.

Initialization and basic setup

Let’s start by introducing our providers and some basic variables and data sources.

variable "aws_profile" {
  type    = string
  default = null
}

variable "aws_region" {
  type    = string
  default = "eu-north-1"
}

provider "aws" {
  region  = var.aws_region
  profile = var.aws_profile
}

provider "random" { }

data "aws_region" "current" {}

data "aws_caller_identity" "current" {}

We can add the snippet above in tf file of our choice, like initialization.tf.

For this example, I have created a profile called MyDevProfile that will be used by Terraform to create resources and by ASP.NET to assume the role. We can specify a value for the aws_profile variable in a terraform.tfvars file. This file is used for local configuration that can’t be shared with the rest of the team.

The MyDevProfile profile uses the keys of my IAM user, hence it is granted permissions over the whole account and set of AWS services.

aws_profile = "MyDevProfile"

We can now use the Terraform CLI to initialize the directory we are in.

$ terraform init

This command will take care of downloading all the providers and make sure that all their dependencies are satisfied.

Resources

Now that the Terraform application is initialized, we can create our resources.

Let’s start by creating the SNS topic that will be used to check if our application is granted the correct permissions. Since we are creating a test resource, we let Terraform take care of generating a random name for the topic.

resource "aws_sns_topic" "this" { }

Next, let’s create a SSM parameter where we store the ARN of the SNS topic we just created. We will use this parameter to inject the ARN of the topic into the web application configuration system.

resource "aws_ssm_parameter" "this" {
  name = "/samples/iam-role/TopicArn"
  value = aws_sns_topic.this.arn
  type = "String"
}

Next, let’s create a test user. This user will have no permissions but the ones needed to assume the roles that will be used in production. Unlike SNS topics, Terraform doesn’t generate a random name for users, so we will use the random_string resource to generate one. Also, we make sure that the user we just created has a valid access key/secret key pair. We will use them later. Finally, we attach a user policy that grants to the new user the permission to assume roles that are located in the /samples/iamroles/ directory.

resource "random_string" "this" {
  length = 8
  special = false
}

resource "aws_iam_user" "this" {
  name = random_string.this.result
  path = "/samples/iamroles/"
}

resource "aws_iam_access_key" "this" {
  user = aws_iam_user.this.name
}

resource "aws_iam_user_policy" "this" {
  user = aws_iam_user.this.name
  
  policy = jsonencode({
    Statement = [
      {
        Action = ["sts:AssumeRole"]
        Effect = "Allow"
        Resource = [
          "arn:aws:iam::${data.aws_caller_identity.current.account_id}:role/samples/iamroles/*"
        ]
      }
    ]
  })
}

The access keys that we have generated for the user are not encrypted and will be saved in clear text in the terraform state file. This is extremely dangerous and it should be avoided for anything but code samples like this one.

Now that we have created the user, we can define the IAM roles that will be assumed by the ASP.NET Core application when accessing the AWS resources. We will be creating two roles, one with the proper permissions, and one without. Other than the policies used to define the list of resources that these roles have access to, we need a policy that specifies who can assume the role. This is generally called a Assume Role policy.

data "aws_iam_policy_document" "assume_role_policy" {
  statement {
    actions = ["sts:AssumeRole"]

    principals {
      type = "AWS"
      identifiers = ["*"]
    }
  }
}

The policy above allows any AWS entity to assume the roles adopting it.

The policy above is extremely unsafe to deploy and it should be avoided for anything but code samples like this one.

Next, we need a policy to grant access to the SSM parameter. The SystemsManager configuration provider fetches all parameters within a certain path, so we need to grant access to the ssm:GetParametersByPath API. Here is the policy document.

data "aws_iam_policy_document" "ssm_parameters" {
    statement {
        effect = "Allow"
        actions = ["ssm:DescribeParameters"]
        resources = ["*"]
    }

    statement {
        effect = "Allow"
        actions = ["ssm:GetParametersByPath"]
        resources = ["arn:aws:ssm:${data.aws_region.current.name}:${data.aws_caller_identity.current.account_id}:parameter/samples/iam-role/"]
    }
}

Now we can create the two roles. The first role, role_with_permission, will be granted permission to publish a message to the SNS topic we created. Both roles will use the Assume Role policy defined in the snippet above and the policy that grants access to the SSM parameter.

resource "aws_iam_role" "role_with_permission" {
  name = "role_with_permission"
  path = "/samples/iamroles/"

  assume_role_policy = data.aws_iam_policy_document.assume_role_policy.json

  inline_policy {
    name = "sns"
    policy = jsonencode({
      Version = "2012-10-17"
      Statement = [
        {
          Action   = ["sns:Publish"]
          Effect   = "Allow"
          Resource = aws_sns_topic.this.arn
        },
      ]
    })
  }

  inline_policy {
    name = "ssm"
    policy = data.aws_iam_policy_document.ssm_parameters.json
  }
}

resource "aws_iam_role" "role_without_permission" {
  name = "role_without_permission"
  path = "/samples/iamroles/"

  assume_role_policy = data.aws_iam_policy_document.assume_role_policy.json

  inline_policy {
    name = "ssm"
    policy = data.aws_iam_policy_document.ssm_parameters.json
  }
}

Notice that both policies have their property set to /samples/iamroles/.

Outputs

Now that we have defined our resources, we need to extract some data that will be used by the ASP.NET Core application. To do so, we will use Terraform outputs.

output "topic_arn" {
    value = aws_sns_topic.this.arn
}

output "role_with_permission_arn" {
    value = aws_iam_role.role_with_permission.arn
}

output "role_without_permission_arn" {
    value = aws_iam_role.role_without_permission.arn
}

output "user_secret_key" {
    value = aws_iam_access_key.this.secret
    sensitive = true
}

output "user_access_key" {
    value = aws_iam_access_key.this.id
}

Deploy the infrastructure

To deploy an infrastructure, Terraform uses a two-steps approach. It first generates a plan and then it applies it.

$ terraform plan -out tfplan
...
$ terraform apply tfplan
...
Apply complete! Resources: 7 added, 0 changed, 0 destroyed.

Outputs:

role_with_permission_arn = "arn:aws:iam::<REDACTED>:role/samples/iamroles/role_with_permission"
role_without_permission_arn = "arn:aws:iam::<REDACTED>:role/samples/iamroles/role_without_permission"
topic_arn = "arn:aws:sns:eu-north-1:<REDACTED>:terraform-<RANDOM_NUMBER>"
user_access_key = "<REDACTED>"
user_secret_key = "<sensitive>"

We will be using the values of the outputs to configure the web application.

Creating the web application

Now that our infrastructure is deployed, we can move our focus to the ASP.NET Core web application.

Let’s start creating the web application by using the template part of the .NET SDK and add it to an empty solution.

$ mkdir src
$ dotnet new sln
$ dotnet new web -n SampleApplication -o src/SampleApplication
$ dotnet sln add ./src/SampleApplication
$ dotnet build

Next, we add the package from the AWS SDK for .NET used for interacting with SNS.

$ dotnet add ./src/SampleApplication/ package AWSSDK.SimpleNotificationService

Then, we install the latest version of the package used to augment the AWS SDK with support for the .NET dependency injection system.

$ dotnet add ./src/SampleApplication/ package AWSSDK.Extensions.NETCore.Setup

Finally, we install the configuration provider that fetches values from SSM parameters.

$ dotnet add ./src/SampleApplication/ package Amazon.Extensions.Configuration.SystemsManager

Make sure that the installed version of this package is later than 3.7.4. This is the first release to include the changes from this pull request.

Next, let’s modify the Program.cs file to customize the setup of the web application.

First, let’s declare a record that will be used for getting the ARN of the SNS topic.

public class SNSOptions
{
    public required string TopicArn { get; init; }
}

Then, let’s register the SNS client and its configuration in the dependency injection system

builder.Services.AddAWSService<IAmazonSimpleNotificationService>();

builder.Services.Configure<SNSOptions>(builder.Configuration);

Similarly, let’s configure the AWS options based on the content of the AWS section of the configuration data.

var aws = builder.Configuration.GetAWSOptions();
builder.Services.AddDefaultAWSOptions(aws);

Next, we add the SSM parameter provider to the configuration system.

builder.Configuration.AddSystemsManager("/samples/iam-role/", aws);

Finally, let’s add a POST endpoint that retrieves a string from the body of the HTTP request and publishes it on the SNS topic. The resulting message identifier will be then returned as payload of the HTTP response.

app.MapPost("/send-message", async (
    IAmazonSimpleNotificationService sns,
    IOptions<SNSOptions> options,
    [FromBody] string message
) =>
{
    var response = await sns.PublishAsync(options.Value.TopicArn, message);

    return Results.Ok(response.MessageId);
});

Here is a snippet with the final version of the Program.cs file after all the additions explained earlier.

using Amazon.SimpleNotificationService;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Options;

var builder = WebApplication.CreateBuilder(args);

var aws = builder.Configuration.GetAWSOptions();

builder.Configuration.AddSystemsManager("/samples/iam-role/", aws);

builder.Services.AddDefaultAWSOptions(aws);

builder.Services.AddAWSService<IAmazonSimpleNotificationService>();

builder.Services.Configure<SNSOptions>(builder.Configuration);

var app = builder.Build();

app.MapGet("/", () => "Hello World!");

app.MapPost("/send-message", async (
    IAmazonSimpleNotificationService sns,
    IOptions<SNSOptions> options,
    [FromBody] string message
) =>
{
    var response = await sns.PublishAsync(options.Value.TopicArn, message);

    return Results.Ok(response.MessageId);
});

app.Run();

public class SNSOptions
{
    public required string TopicArn { get; init; }
}

Now we can run the application and invoke a simple HTTP request towards the default endpoint.

$ dotnet run
info: Microsoft.Hosting.Lifetime[14]
      Now listening on: http://localhost:5235
info: Microsoft.Hosting.Lifetime[0]
      Application started. Press Ctrl+C to shut down.
...

In another terminal, we can test that the application is running correctly.

$ Invoke-WebRequest -Uri http://localhost:5235 | Select-Object -Expand Content
Hello World!

Once we’re sure that the web application is running correctly, let’s stop it.

Configuring the web application

The web application comes with two JSON files that can be used to configure the application.

The appsettings.json file contains the configuration values that are common to all environments while the appsettings.Development.json contains the values that are specific to the Development environment.

Let’s expand the first file so that our application is pinned to use the eu-north-1 region of AWS.

{
  "Logging": {
    ...
  },
  "AllowedHosts": "*",
  "AWS": {
    "Region": "eu-north-1"
  }
}

Similarly, let’s expand the Development file so that we can instruct the SDK to use the MyDevProfile profile.

{
  "Logging": {
    ...
  },
  "AWS": {
    "Profile": "MyDevProfile"
  }
}

Now the application is fully configured and the configuration doesn’t differ from what’s usually suggested by the AWS documentation.

Let’s start the application and send a HTTP request to the send-message endpoint. Assuming you have created a profile called MyDevProfile, your request should go through without any issue.

$ Invoke-WebRequest -Uri http://localhost:5235/send-message -Method Post -Body '"Hello world"' -ContentType "application/json" | Select-Object -Expand Content
"fa541d53-a680-5cc7-8ed1-41360ad78109"

What’s important to notice is that the application is using your own credentials. If you are like me, your development profile is granted quite a wide set of permissions.

By leveraging the changes introduced by the PR 1743, we can make sure that the application only is granted a limited set of permissions even when running on a developer machine.

Using the IAM roles with the developer account

Let’s start by assuming the two roles we created with our developer account.

To do so, we can extract the desired role ARN from the Terraform outputs.

$ $env:AWS__SessionRoleArn="$(terraform output -raw role_with_permission_arn)"

We can now start the application and send the same test request. Unexpectedly, the request times out and fails.

$ Invoke-WebRequest -Uri http://localhost:5235/send-message -Method Post -Body '"Hello world"' -ContentType "application/json" | Select-Object -Expand Content
Invoke-WebRequest: System.InvalidOperationException: Assembly AWSSDK.SecurityToken could not 
be found or loaded. This assembly must be available at runtime to use 
Amazon.Runtime.AssumeRoleAWSCredentials.
...

Apparently, using the using the IAM role pipeline requires an assembly that is not packaged and loaded by default. We can fix this by adding the package to the web application project.

dotnet add ./src/SampleApplication/ package AWSSDK.SecurityToken

We can now start the application again and send the test request. The request will succeed.

$ Invoke-WebRequest -Uri http://localhost:5235/send-message -Method Post -Body '"Hello world"' -ContentType "application/json" | Select-Object -Expand Content
"0aea35cb-dab8-5be8-80fa-69ae6839a962"

Now, let’s change the role with the one without permissions to publish to the SNS topic.

$ $env:AWS__SessionRoleArn="$(terraform output -raw role_without_permission_arn)"

Let’s start the application and send the test request. This time, the request will fail because the application doesn’t have enough permission.

$ Invoke-WebRequest -Uri http://localhost:5235/send-message -Method Post -Body '"Hello world"' -ContentType "application/json" | Select-Object -Expand Content
Invoke-WebRequest: Amazon.SimpleNotificationService.Model.AuthorizationErrorException: 
User: arn:aws:sts::<REDACTED>:assumed-role/role_without_permission/DefaultSessionName 
is not authorized to perform: SNS:Publish on resource: 
arn:aws:sns:eu-north-1:<REDACTED>:terraform-<RANDOM_NUMBER> because no identity-based 
policy allows the SNS:Publish action
...

Perfect!

Using the IAM roles with the test account

Instead of using keys connected to our account, we can use the test account we created with Terraform.

We can achieve this in two ways:

  • we can create a new entry in the credentials file using the AWS CLI,
  • we can use environment variables to let the SDK pick those credentials.

I’ll show the second method.

Like for the ARN of the role to be assumed, we can use terraform output and save its results into environment variables.

$ $env:AWS_ACCESS_KEY_ID="$(terraform output -raw user_access_key)"
$ $env:AWS_SECRET_ACCESS_KEY="$(terraform output -raw user_secret_key)"

Now, we can run the same tests as before:

  • Without specifying the role: it should fail
  • Using the role with the needed permissions: it should succeed
  • Using the role without the needed permissions: it should fail

These tests show that we can use this technique to augment the permission scope granted to a developer. This could be very useful when working with external contractors or junior developers who can’t be granted full access to the AWS profile.

Final thoughts

In this post we’ve seen how this technique can make development more robust and secure by making sure that the application has all and only the needed permissions already at development time.

Since this technique is very new (literally less than a week, at the time of writing), there are some hurdles that should be addressed.

First of all, there should be a way to avoid the issues related to the AWSSDK.SecurityToken assembly not being available at development time.

For now, I just move the package reference to a conditional <ItemGroup />

<ItemGroup Condition="'$(Configuration)' == 'Debug'">
  <PackageReference Include="AWSSDK.SecurityToken" Version="3.7.101.7" />
</ItemGroup>

Furthermore, using this technique at scale will need some iterations to make comfortable working with many applications deployed over many environments.

Nonetheless, I believe that the advantages in the long term are worth the additional work needed when setting up the solution and I’m very curious to see how this technique will evolve.

Support this blog

If you liked this article, consider supporting this blog by buying me a pizza!