Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Managing Secrets and Secure Access in Azure Applications #304

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions azure-ts-msi-keyvault-rbac/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
obj
10 changes: 10 additions & 0 deletions azure-ts-msi-keyvault-rbac/Pulumi.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
name: azure-msi-keyvault-rbac
runtime: nodejs
description: Example of managing the secrets and permissions via services and features like KeyVault, AD Managed Identity, AD RBAC
template:
config:
azure:environment:
description: The Azure environment to use (`public`, `usgovernment`, `german`, `china`)
default: public
azure:location:
description: The Azure location to use (e.g., `eastus` or `westeurope`)
70 changes: 70 additions & 0 deletions azure-ts-msi-keyvault-rbac/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
[![Deploy](https://get.pulumi.com/new/button.svg)](https://app.pulumi.com/new)

# Managing Secrets and Secure Access in Azure Applications

[Managed identities](https://docs.microsoft.com/en-us/azure/active-directory/managed-identities-azure-resources/) for Azure resources provides Azure services with an automatically managed identity in Azure Active Directory (Azure AD).

This example demostrates using a managed identity with Azure App Service to access Azure KeyVault, Azure Storage, and Azure SQL Database without passwords or secrets.

The application consists of several parts:

- An ASP.NET Application which reads data from a SQL Database and from a file in Blob Storage
- App Service which host the application. The application binaries are placed in Blob Storage, with Blob Url placed as a secret in Azure Key Vault
- App Service has a Managed Identity enabled
- The identify is granted access to the SQL Server, Blob Storage, and Key Vault
- No secret information is placed in App Service configuration: all access rights are derived from Active Directory

## Running the App

1. Create a new stack:

```
$ pulumi stack init dev
```

1. Login to Azure CLI (you will be prompted to do this during deployment if you forget this step):

```
$ az login
```

1. Restore NPM dependencies:

```
$ npm install
```

1. Build and publish the ASP.NET Core project:

```
$ dotnet publish webapp
```

1. Set an appropriate Azure location like:

```
$ pulumi config set azure:location westus
```

1. Run `pulumi up` to preview and deploy changes:

```
$ pulumi up
Previewing changes:
...

Performing changes:
...
info: 15 changes performed:
+ 15 resources created
Update duration: 4m16s
```

1. Check the deployed website endpoint:

```
$ pulumi stack output endpoint
https://app129968b8.azurewebsites.net/
$ curl "$(pulumi stack output endpoint)"
Hello 311378b3-16b7-4889-a8d7-2eb77478beba@50f73f6a-e8e3-46b6-969c-bf026712a650! Here is your...
```
170 changes: 170 additions & 0 deletions azure-ts-msi-keyvault-rbac/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
import * as pulumi from "@pulumi/pulumi";
import * as azure from "@pulumi/azure";
import * as random from "@pulumi/random";
import { execSync} from "child_process";

// Create a resource group
const resourceGroup = new azure.core.ResourceGroup("resourceGroup");

// Create a storage account for Blobs
const storageAccount = new azure.storage.Account("storage", {
resourceGroupName: resourceGroup.name,
accountReplicationType: "LRS",
accountTier: "Standard",
});

// The container to put our files into
const storageContainer = new azure.storage.Container("files", {
resourceGroupName: resourceGroup.name,
storageAccountName: storageAccount.name,
containerAccessType: "private",
});

// Azure SQL Server that we want to access from the application
const administratorLoginPassword = new random.RandomString("password", { length: 16 }).result;
const sqlServer = new azure.sql.SqlServer("sqlserver", {
resourceGroupName: resourceGroup.name,
// The login and password are required but won't be used in our application
administratorLogin: "manualadmin",
administratorLoginPassword,
version: "12.0",
});

// Azure SQL Database that we want to access from the application
const database = new azure.sql.Database("sqldb", {
resourceGroupName: resourceGroup.name,
serverName: sqlServer.name,
requestedServiceObjectiveName: "S0",
});

// The connection string that has no credentials in it: authertication will come through MSI
const connectionString = pulumi.interpolate`Server=tcp:${sqlServer.name}.database.windows.net;Database=${database.name};`;

// A file in Blob Storage that we want to access from the application
const textBlob = new azure.storage.Blob("text", {
resourceGroupName: resourceGroup.name,
storageAccountName: storageAccount.name,
storageContainerName: storageContainer.name,
type: "block",
source: "./README.md",
});

// A plan to host the App Service
const appServicePlan = new azure.appservice.Plan("asp", {
resourceGroupName: resourceGroup.name,
kind: "App",
sku: {
tier: "Basic",
size: "B1",
},
});

// ASP.NET deployment package
const blob = new azure.storage.ZipBlob("zip", {
resourceGroupName: resourceGroup.name,
storageAccountName: storageAccount.name,
storageContainerName: storageContainer.name,
type: "block",

content: new pulumi.asset.FileArchive("./webapp/bin/Debug/netcoreapp2.2/publish")
});

const clientConfig = pulumi.output(azure.core.getClientConfig({}));
const tenantId = clientConfig.apply(c => c.tenantId);

const currentPrincipal = clientConfig.apply(c =>
// Currently, only service principal ID is available in the context. If we are provided the principle in the config, then
// just use it. Otherwise, if logged in with a user, find their ID via Azure CLI.
// see https://github.com/terraform-providers/terraform-provider-azurerm/issues/3234
c.servicePrincipalObjectId !== ""
? c.servicePrincipalObjectId
: <string>JSON.parse(execSync("az ad signed-in-user show --query objectId").toString()));

// Key Vault to store secrets (e.g. Blob URL with SAS)
const vault = new azure.keyvault.KeyVault("vault", {
resourceGroupName: resourceGroup.name,
sku: {
name: "standard",
},
tenantId: tenantId,
accessPolicies: [{
tenantId,
// The current principal has to be granted permissions to Key Vault so that it can actually add and then remove
// secrets to/from the Key Vault. Otherwise, 'pulumi up' and 'pulumi destroy' operations will fail.
objectId: currentPrincipal,
secretPermissions: ["delete", "get", "list", "set"],
}]
});

// Put the URL of the zip Blob to KV
const secret = new azure.keyvault.Secret("deployment-zip", {
keyVaultId: vault.id,
value: azure.storage.signedBlobReadUrl(blob, storageAccount),
lukehoban marked this conversation as resolved.
Show resolved Hide resolved
});
const secretUri = pulumi.interpolate`${secret.vaultUri}secrets/${secret.name}/${secret.version}`;

// The application hosted in App Service
const app = new azure.appservice.AppService("app", {
resourceGroupName: resourceGroup.name,
appServicePlanId: appServicePlan.id,

// A system-assigned managed service identity to be used for authentication and authorization to the SQL Database and the Blob Storage
identity: {
type: "SystemAssigned"
},

appSettings: {
// Website is deployed from a URL read from the Key Vault
"WEBSITE_RUN_FROM_ZIP": pulumi.interpolate`@Microsoft.KeyVault(SecretUri=${secretUri})`,

// Note that we simply provide the URL without SAS or keys
"StorageBlobUrl": textBlob.url,
},

// A SQL connection string, still without secrets in it
connectionStrings: [{
name: "db",
value: connectionString,
type: "SQLAzure"
}]
});

// Work around a preview issue https://github.com/pulumi/pulumi-azure/issues/192
const principalId = app.identity.apply(id => id.principalId || "11111111-1111-1111-1111-111111111111");

// Grant App Service access to KV secrets
new azure.keyvault.AccessPolicy("app-policy", {
keyVaultId: vault.id,
tenantId: tenantId,
objectId: principalId,
secretPermissions: ["get"],
});

// Make the App Service the admin of the SQL Server (double check if you want a more fine-grained security model in your real app)
const sqlAdmin = new azure.sql.ActiveDirectoryAdministrator("adadmin", {
resourceGroupName: resourceGroup.name,
tenantId: tenantId,
objectId: principalId,
login: "adadmin",
serverName: sqlServer.name,
});

// Grant access from App Service to the container in the storage
const blobPermission = new azure.role.Assignment("readblob", {
principalId,
scope: pulumi.interpolate`${storageAccount.id}/blobServices/default/containers/${storageContainer.name}`,
roleDefinitionName: "Storage Blob Data Reader",
});

// Add SQL firewall exceptions
const firewallRules = app.outboundIpAddresses.apply(
ips => ips.split(',').map(
ip => new azure.sql.FirewallRule(`FR${ip}`, {
resourceGroupName: resourceGroup.name,
startIpAddress: ip,
endIpAddress: ip,
serverName: sqlServer.name,
})
));

export const endpoint = pulumi.interpolate `https://${app.defaultSiteHostname}`;
12 changes: 12 additions & 0 deletions azure-ts-msi-keyvault-rbac/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
{
"name": "azure-msi-keyvault-rbac",
"version": "1.0.0",
"devDependencies": {
"@types/node": "^10.3.3"
},
"dependencies": {
"@pulumi/azure": "latest",
"@pulumi/pulumi": "latest",
"@pulumi/random": "latest"
}
}
22 changes: 22 additions & 0 deletions azure-ts-msi-keyvault-rbac/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
{
"compilerOptions": {
"outDir": "bin",
"target": "es6",
"lib": [
"es6"
],
"module": "commonjs",
"moduleResolution": "node",
"sourceMap": true,
"experimentalDecorators": true,
"pretty": true,
"noFallthroughCasesInSwitch": true,
"noImplicitAny": true,
"noImplicitReturns": true,
"forceConsistentCasingInFileNames": true,
"strictNullChecks": true
},
"files": [
"index.ts"
]
}
23 changes: 23 additions & 0 deletions azure-ts-msi-keyvault-rbac/webapp/Program.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
using System;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

do we have copyright in our example files?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No *.cs files do, so far. Some *.ts have them, some not.

using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.AspNetCore;
using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.Logging;

namespace webapp
{
public class Program
{
public static void Main(string[] args)
{
CreateWebHostBuilder(args).Build().Run();
}

public static IWebHostBuilder CreateWebHostBuilder(string[] args) =>
WebHost.CreateDefaultBuilder(args)
.UseStartup<Startup>();
}
}
59 changes: 59 additions & 0 deletions azure-ts-msi-keyvault-rbac/webapp/Reader.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.Azure.Services.AppAuthentication;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using System.Data.SqlClient;
using Microsoft.Azure.Storage.Auth;
using System.IO;
using Microsoft.Azure.Storage.Blob;

namespace webapp
{
public class Reader
{
public Reader(IConfiguration configuration)
{
this.Configuration = configuration;
}

private IConfiguration Configuration { get; }

public async Task<string> GetSqlUser()
{
var connectionString = Configuration.GetConnectionString("db");
var token = await GetTokenAsync("database.windows.net");
using (var conn = new SqlConnection(connectionString))
{
conn.AccessToken = token;
await conn.OpenAsync();

using (var cmd = new SqlCommand("SELECT SUSER_SNAME()", conn))
{
var result = await cmd.ExecuteScalarAsync();
return result as string;
}
}
}

public async Task<string> GetBlobText()
{
string accessToken = await GetTokenAsync("storage.azure.com");
var tokenCredential = new TokenCredential(accessToken);
var storageCredentials = new StorageCredentials(tokenCredential);
// Define the blob to read
var url = Environment.GetEnvironmentVariable("StorageBlobUrl");
var blob = new CloudBlockBlob(new Uri(url), storageCredentials);
// Open a data stream to the blob
return await blob.DownloadTextAsync();
}

private static Task<String> GetTokenAsync(string service)
{
var provider = new AzureServiceTokenProvider();
return provider.GetAccessTokenAsync($"https://{service}/");
}
}
}
Loading