Skip to content

Commit

Permalink
feat(cdk-assets): externally-configured Docker credentials (#15290)
Browse files Browse the repository at this point in the history
Currently, `cdk-assets` does a single `docker login` with credentials fetched
from ECR's `getAuthorizationToken` API. This enables access to (typically) the
assets in the environment's ECR repo (`*--container-assets-*`).

A pain point for users today is throttling when using images from other sources,
especially from DockerHub when using unauthenticated calls.

This change introduces a new configuration file at a well-known location (and
overridable via the CDK_DOCKER_CREDS_FILE environment variable), which allows
specifying per-domain login credentials via either the default ECR auth tokens
or via a secret in SecretsManager.

If the credentials file is present, a Docker credential helper
(docker-credential-cdk-assets) will be set up for each of the configured
domains, and used for the `docker build` commands to enable fetching images from
both DockerHub or configured ECR repos. Then the "normal" credentials will be
assumed for the final publishing step. For backwards compatibility, if no
credentials file is present, the existing `docker login` will be done prior to
the build step as usual.

This PR will be shortly followed by a corresponding PR for the cdk pipelines
library to enable users to specify registries and credentials to be fed into
this credentials file during various stages of the pipeline (e.g., build/synth,
self-update, and asset publishing).

Two refactorings here:
- Moved obtainEcrCredentials from docker.ts to docker-credentials-ts.
- Moved DefaultAwsClient from bin/publish.ts to lib/aws.ts

related #10999
related #11774

----

*By submitting this pull request, I confirm that my contribution is made under the terms of the Apache-2.0 license*
  • Loading branch information
njlynch committed Jun 25, 2021
1 parent 94eb3a8 commit e530195
Show file tree
Hide file tree
Showing 15 changed files with 605 additions and 134 deletions.
5 changes: 5 additions & 0 deletions packages/aws-cdk/lib/api/aws-auth/sdk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ export interface ISDK {
route53(): AWS.Route53;
ecr(): AWS.ECR;
elbv2(): AWS.ELBv2;
secretsManager(): AWS.SecretsManager;
}

/**
Expand Down Expand Up @@ -113,6 +114,10 @@ export class SDK implements ISDK {
return this.wrapServiceErrorHandling(new AWS.ELBv2(this.config));
}

public secretsManager(): AWS.SecretsManager {
return this.wrapServiceErrorHandling(new AWS.SecretsManager(this.config));
}

public async currentAccount(): Promise<Account> {
// Get/refresh if necessary before we can access `accessKeyId`
await this.forceCredentialRetrieval();
Expand Down
4 changes: 4 additions & 0 deletions packages/aws-cdk/lib/util/asset-publishing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,10 @@ class PublishingAws implements cdk_assets.IAws {
return (await this.sdk(options)).ecr();
}

public async secretsManagerClient(options: cdk_assets.ClientOptions): Promise<AWS.SecretsManager> {
return (await this.sdk(options)).secretsManager();
}

/**
* Get an SDK appropriate for the given client options
*/
Expand Down
1 change: 1 addition & 0 deletions packages/aws-cdk/test/util/mock-sdk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,7 @@ export class MockSdk implements ISDK {
public readonly route53 = jest.fn();
public readonly ecr = jest.fn();
public readonly elbv2 = jest.fn();
public readonly secretsManager = jest.fn();

public currentAccount(): Promise<Account> {
return Promise.resolve({ accountId: '123456789012', partition: 'aws' });
Expand Down
35 changes: 34 additions & 1 deletion packages/cdk-assets/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ itself in the following behaviors:
image in the local Docker cache) already exists named after the asset's ID, it
will not be packaged, but will be uploaded directly to the destination
location.

For assets build by external utilities, the contract is such that cdk-assets
expects the utility to manage dedupe detection as well as path/image tag generation.
This means that cdk-assets will call the external utility every time generation
Expand Down Expand Up @@ -153,3 +153,36 @@ on the AWS SDK (through environment variables or `~/.aws/...` config files).
* If `${AWS::Region}` is used, it will principally be replaced with the value
in the `region` key. If the default region is intended, leave the `region`
key out of the manifest at all.

## Docker image credentials

For Docker image asset publishing, `cdk-assets` will `docker login` with
credentials from ECR GetAuthorizationToken prior to building and publishing, so
that the Dockerfile can reference images in the account's ECR repo.

`cdk-assets` can also be configured to read credentials from both ECR and
SecretsManager prior to build by creating a credential configuration at
'~/.cdk/cdk-docker-creds.json' (override this location by setting the
CDK_DOCKER_CREDS_FILE environment variable). The credentials file has the
following format:

```json
{
"version": "1.0",
"domainCredentials": {
"domain1.example.com": {
"secretsManagerSecretId": "mySecret", // Can be the secret ID or full ARN
"roleArn": "arn:aws:iam::0123456789012:role/my-role" // (Optional) role with permissions to the secret
},
"domain2.example.com": {
"ecrRepository": true,
"roleArn": "arn:aws:iam::0123456789012:role/my-role" // (Optional) role with permissions to the repo
}
}
}
```

If the credentials file is present, `docker` will be configured to use the
`docker-credential-cdk-assets` credential helper for each of the domains listed
in the file. This helper will assume the role provided (if present), and then fetch
the login credentials from either SecretsManager or ECR.
2 changes: 2 additions & 0 deletions packages/cdk-assets/bin/docker-credential-cdk-assets
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
#!/usr/bin/env node
require('./docker-credential-cdk-assets.js');
48 changes: 48 additions & 0 deletions packages/cdk-assets/bin/docker-credential-cdk-assets.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
/**
* Docker Credential Helper to retrieve credentials based on an external configuration file.
* Supports loading credentials from ECR repositories and from Secrets Manager,
* optionally via an assumed role.
*
* The only operation currently supported by this credential helper at this time is the `get`
* command, which receives a domain name as input on stdin and returns a Username/Secret in
* JSON format on stdout.
*
* IMPORTANT - The credential helper must not output anything else besides the final credentials
* in any success case; doing so breaks docker's parsing of the output and causes the login to fail.
*/

import * as fs from 'fs';
import { DefaultAwsClient } from '../lib';

import { cdkCredentialsConfig, cdkCredentialsConfigFile, fetchDockerLoginCredentials } from '../lib/private/docker-credentials';

async function main() {
// Expected invocation is [node, docker-credential-cdk-assets, get] with input fed via STDIN
// For other valid docker commands (store, list, erase), we no-op.
if (process.argv.length !== 3 || process.argv[2] !== 'get') {
process.exit(0);
}

const config = cdkCredentialsConfig();
if (!config) {
throw new Error(`unable to find CDK Docker credentials at: ${cdkCredentialsConfigFile()}`);
}

// Read the domain to fetch from stdin
let rawDomain = fs.readFileSync(0, { encoding: 'utf-8' }).trim();
// Paranoid handling to ensure new URL() doesn't throw if the schema is missing.
// Not convinced docker will ever pass in a url like 'index.docker.io/v1', but just in case...
rawDomain = rawDomain.includes('://') ? rawDomain : `https://${rawDomain}`;
const domain = new URL(rawDomain).hostname;

const credentials = await fetchDockerLoginCredentials(new DefaultAwsClient(), config, domain);

// Write the credentials back to stdout
fs.writeFileSync(1, JSON.stringify(credentials));
}

main().catch(e => {
// eslint-disable-next-line no-console
console.error(e.stack);
process.exitCode = 1;
});
112 changes: 2 additions & 110 deletions packages/cdk-assets/bin/publish.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,8 @@
import * as os from 'os';
import {
AssetManifest, AssetPublishing, ClientOptions, DestinationPattern, EventType, IAws,
AssetManifest, AssetPublishing, DefaultAwsClient, DestinationPattern, EventType,
IPublishProgress, IPublishProgressListener,
} from '../lib';
import { Account } from '../lib/aws';
import { log, LogLevel, VERSION } from './logging';
import { log, LogLevel } from './logging';

export async function publish(args: {
path: string;
Expand Down Expand Up @@ -56,109 +54,3 @@ class ConsoleProgress implements IPublishProgressListener {
log(EVENT_TO_LEVEL[type], `[${event.percentComplete}%] ${type}: ${event.message}`);
}
}

/**
* AWS client using the AWS SDK for JS with no special configuration
*/
class DefaultAwsClient implements IAws {
private readonly AWS: typeof import('aws-sdk');
private account?: Account;

constructor(profile?: string) {
// Force AWS SDK to look in ~/.aws/credentials and potentially use the configured profile.
process.env.AWS_SDK_LOAD_CONFIG = '1';
process.env.AWS_STS_REGIONAL_ENDPOINTS = 'regional';
process.env.AWS_NODEJS_CONNECTION_REUSE_ENABLED = '1';
if (profile) {
process.env.AWS_PROFILE = profile;
}

// We need to set the environment before we load this library for the first time.
// eslint-disable-next-line @typescript-eslint/no-require-imports
this.AWS = require('aws-sdk');
}

public async s3Client(options: ClientOptions) {
return new this.AWS.S3(await this.awsOptions(options));
}

public async ecrClient(options: ClientOptions) {
return new this.AWS.ECR(await this.awsOptions(options));
}

public async discoverPartition(): Promise<string> {
return (await this.discoverCurrentAccount()).partition;
}

public async discoverDefaultRegion(): Promise<string> {
return this.AWS.config.region || 'us-east-1';
}

public async discoverCurrentAccount(): Promise<Account> {
if (this.account === undefined) {
const sts = new this.AWS.STS();
const response = await sts.getCallerIdentity().promise();
if (!response.Account || !response.Arn) {
log('error', `Unrecognized reponse from STS: '${JSON.stringify(response)}'`);
throw new Error('Unrecognized reponse from STS');
}
this.account = {
accountId: response.Account!,
partition: response.Arn!.split(':')[1],
};
}

return this.account;
}

private async awsOptions(options: ClientOptions) {
let credentials;

if (options.assumeRoleArn) {
credentials = await this.assumeRole(options.region, options.assumeRoleArn, options.assumeRoleExternalId);
}

return {
region: options.region,
customUserAgent: `cdk-assets/${VERSION}`,
credentials,
};
}

/**
* Explicit manual AssumeRole call
*
* Necessary since I can't seem to get the built-in support for ChainableTemporaryCredentials to work.
*
* It needs an explicit configuration of `masterCredentials`, we need to put
* a `DefaultCredentialProverChain()` in there but that is not possible.
*/
private async assumeRole(region: string | undefined, roleArn: string, externalId?: string): Promise<AWS.Credentials> {
const msg = [
`Assume ${roleArn}`,
...externalId ? [`(ExternalId ${externalId})`] : [],
];
log('verbose', msg.join(' '));

return new this.AWS.ChainableTemporaryCredentials({
params: {
RoleArn: roleArn,
ExternalId: externalId,
RoleSessionName: `cdk-assets-${safeUsername()}`,
},
stsConfig: {
region,
customUserAgent: `cdk-assets/${VERSION}`,
},
});
}
}

/**
* Return the username with characters invalid for a RoleSessionName removed
*
* @see https://docs.aws.amazon.com/STS/latest/APIReference/API_AssumeRole.html#API_AssumeRole_RequestParameters
*/
function safeUsername() {
return os.userInfo().username.replace(/[^\w+=,.@-]/g, '@');
}
107 changes: 106 additions & 1 deletion packages/cdk-assets/lib/aws.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import * as AWS from 'aws-sdk';
import * as os from 'os';

/**
* AWS SDK operations required by Asset Publishing
Expand All @@ -10,6 +10,7 @@ export interface IAws {

s3Client(options: ClientOptions): Promise<AWS.S3>;
ecrClient(options: ClientOptions): Promise<AWS.ECR>;
secretsManagerClient(options: ClientOptions): Promise<AWS.SecretsManager>;
}

export interface ClientOptions {
Expand All @@ -35,3 +36,107 @@ export interface Account {
*/
readonly partition: string;
}

/**
* AWS client using the AWS SDK for JS with no special configuration
*/
export class DefaultAwsClient implements IAws {
private readonly AWS: typeof import('aws-sdk');
private account?: Account;

constructor(profile?: string) {
// Force AWS SDK to look in ~/.aws/credentials and potentially use the configured profile.
process.env.AWS_SDK_LOAD_CONFIG = '1';
process.env.AWS_STS_REGIONAL_ENDPOINTS = 'regional';
process.env.AWS_NODEJS_CONNECTION_REUSE_ENABLED = '1';
if (profile) {
process.env.AWS_PROFILE = profile;
}

// We need to set the environment before we load this library for the first time.
// eslint-disable-next-line @typescript-eslint/no-require-imports
this.AWS = require('aws-sdk');
}

public async s3Client(options: ClientOptions) {
return new this.AWS.S3(await this.awsOptions(options));
}

public async ecrClient(options: ClientOptions) {
return new this.AWS.ECR(await this.awsOptions(options));
}

public async secretsManagerClient(options: ClientOptions) {
return new this.AWS.SecretsManager(await this.awsOptions(options));
}

public async discoverPartition(): Promise<string> {
return (await this.discoverCurrentAccount()).partition;
}

public async discoverDefaultRegion(): Promise<string> {
return this.AWS.config.region || 'us-east-1';
}

public async discoverCurrentAccount(): Promise<Account> {
if (this.account === undefined) {
const sts = new this.AWS.STS();
const response = await sts.getCallerIdentity().promise();
if (!response.Account || !response.Arn) {
throw new Error(`Unrecognized reponse from STS: '${JSON.stringify(response)}'`);
}
this.account = {
accountId: response.Account!,
partition: response.Arn!.split(':')[1],
};
}

return this.account;
}

private async awsOptions(options: ClientOptions) {
let credentials;

if (options.assumeRoleArn) {
credentials = await this.assumeRole(options.region, options.assumeRoleArn, options.assumeRoleExternalId);
}

return {
region: options.region,
customUserAgent: 'cdk-assets',
credentials,
};
}

/**
* Explicit manual AssumeRole call
*
* Necessary since I can't seem to get the built-in support for ChainableTemporaryCredentials to work.
*
* It needs an explicit configuration of `masterCredentials`, we need to put
* a `DefaultCredentialProverChain()` in there but that is not possible.
*/
private async assumeRole(region: string | undefined, roleArn: string, externalId?: string): Promise<AWS.Credentials> {
return new this.AWS.ChainableTemporaryCredentials({
params: {
RoleArn: roleArn,
ExternalId: externalId,
RoleSessionName: `cdk-assets-${safeUsername()}`,
},
stsConfig: {
region,
customUserAgent: 'cdk-assets',
},
});
}
}

/**
* Return the username with characters invalid for a RoleSessionName removed
*
* @see https://docs.aws.amazon.com/STS/latest/APIReference/API_AssumeRole.html#API_AssumeRole_RequestParameters
*/
function safeUsername() {
return os.userInfo().username.replace(/[^\w+=,.@-]/g, '@');
}

Loading

0 comments on commit e530195

Please sign in to comment.