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

feat(cdk-assets): externally-configured Docker credentials #15290

Merged
merged 6 commits into from
Jun 25, 2021
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
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;
njlynch marked this conversation as resolved.
Show resolved Hide resolved

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