From 1069827a59c07e64c28064fae74b59e912355ad0 Mon Sep 17 00:00:00 2001 From: Christoph Gysin Date: Mon, 8 Mar 2021 15:06:56 +0200 Subject: [PATCH] feat(neptune): Support IAM authentication (#13462) fixes #13461 ---- *By submitting this pull request, I confirm that my contribution is made under the terms of the Apache-2.0 license* --- packages/@aws-cdk/aws-docdb/lib/cluster.ts | 2 +- packages/@aws-cdk/aws-neptune/README.md | 18 +++ packages/@aws-cdk/aws-neptune/lib/cluster.ts | 91 +++++++++--- .../@aws-cdk/aws-neptune/test/cluster.test.ts | 140 +++++++++++++----- 4 files changed, 196 insertions(+), 55 deletions(-) diff --git a/packages/@aws-cdk/aws-docdb/lib/cluster.ts b/packages/@aws-cdk/aws-docdb/lib/cluster.ts index 529f446c265b9..f60a332d1b77f 100644 --- a/packages/@aws-cdk/aws-docdb/lib/cluster.ts +++ b/packages/@aws-cdk/aws-docdb/lib/cluster.ts @@ -238,7 +238,7 @@ export class DatabaseCluster extends DatabaseClusterBase { public readonly clusterResourceIdentifier: string; /** - * The connections object to implement IConectable + * The connections object to implement IConnectable */ public readonly connections: ec2.Connections; diff --git a/packages/@aws-cdk/aws-neptune/README.md b/packages/@aws-cdk/aws-neptune/README.md index fc542acf1b3da..1be41b88a1022 100644 --- a/packages/@aws-cdk/aws-neptune/README.md +++ b/packages/@aws-cdk/aws-neptune/README.md @@ -58,6 +58,24 @@ attributes: const writeAddress = cluster.clusterEndpoint.socketAddress; // "HOSTNAME:PORT" ``` +## IAM Authentication + +You can also authenticate to a database cluster using AWS Identity and Access Management (IAM) database authentication; +See for more information and a list of supported +versions and limitations. + +The following example shows enabling IAM authentication for a database cluster and granting connection access to an IAM role. + +```ts +const cluster = new rds.DatabaseCluster(stack, 'Cluster', { + vpc, + instanceType: neptune.InstanceType.R5_LARGE, + iamAuthentication: true, // Optional - will be automatically set if you call grantConnect(). +}); +const role = new Role(stack, 'DBRole', { assumedBy: new AccountPrincipal(stack.account) }); +instance.grantConnect(role); // Grant the role connection access to the DB. +``` + ## Customizing parameters Neptune allows configuring database behavior by supplying custom parameter groups. For more details, refer to the diff --git a/packages/@aws-cdk/aws-neptune/lib/cluster.ts b/packages/@aws-cdk/aws-neptune/lib/cluster.ts index 4adbe2ce8ea04..316795c23a491 100644 --- a/packages/@aws-cdk/aws-neptune/lib/cluster.ts +++ b/packages/@aws-cdk/aws-neptune/lib/cluster.ts @@ -1,7 +1,7 @@ import * as ec2 from '@aws-cdk/aws-ec2'; import * as iam from '@aws-cdk/aws-iam'; import * as kms from '@aws-cdk/aws-kms'; -import { Duration, IResource, RemovalPolicy, Resource, Token } from '@aws-cdk/core'; +import { Aws, Duration, IResource, Lazy, RemovalPolicy, Resource, Token } from '@aws-cdk/core'; import { Construct } from 'constructs'; import { Endpoint } from './endpoint'; import { InstanceType } from './instance'; @@ -119,6 +119,13 @@ export interface DatabaseClusterProps { */ readonly dbClusterName?: string; + /** + * Map AWS Identity and Access Management (IAM) accounts to database accounts + * + * @default - `false` + */ + readonly iamAuthentication?: boolean; + /** * Base identifier for instances * @@ -233,6 +240,11 @@ export interface IDatabaseCluster extends IResource, ec2.IConnectable { * @attribute ReadEndpoint */ readonly clusterReadEndpoint: Endpoint; + + /** + * Grant the given identity connection access to the database. + */ + grantConnect(grantee: iam.IGrantable): iam.Grant; } /** @@ -266,23 +278,15 @@ export interface DatabaseClusterAttributes { } /** - * Create a clustered database with a given number of instances. - * - * @resource AWS::Neptune::DBCluster + * A new or imported database cluster. */ -export class DatabaseCluster extends Resource implements IDatabaseCluster { - - /** - * The default number of instances in the Neptune cluster if none are - * specified - */ - public static readonly DEFAULT_NUM_INSTANCES = 1; +export abstract class DatabaseClusterBase extends Resource implements IDatabaseCluster { /** * Import an existing DatabaseCluster from properties */ public static fromDatabaseClusterAttributes(scope: Construct, id: string, attrs: DatabaseClusterAttributes): IDatabaseCluster { - class Import extends Resource implements IDatabaseCluster { + class Import extends DatabaseClusterBase implements IDatabaseCluster { public readonly defaultPort = ec2.Port.tcp(attrs.port); public readonly connections = new ec2.Connections({ securityGroups: [attrs.securityGroup], @@ -291,6 +295,7 @@ export class DatabaseCluster extends Resource implements IDatabaseCluster { public readonly clusterIdentifier = attrs.clusterIdentifier; public readonly clusterEndpoint = new Endpoint(attrs.clusterEndpointAddress, attrs.port); public readonly clusterReadEndpoint = new Endpoint(attrs.readerEndpointAddress, attrs.port); + protected enableIamAuthentication = true; } return new Import(scope, id); @@ -299,17 +304,65 @@ export class DatabaseCluster extends Resource implements IDatabaseCluster { /** * Identifier of the cluster */ - public readonly clusterIdentifier: string; + public abstract readonly clusterIdentifier: string; /** * The endpoint to use for read/write operations */ - public readonly clusterEndpoint: Endpoint; + public abstract readonly clusterEndpoint: Endpoint; /** * Endpoint to use for load-balanced read-only operations. */ + public abstract readonly clusterReadEndpoint: Endpoint; + + /** + * The connections object to implement IConnectable + */ + public abstract readonly connections: ec2.Connections; + + protected abstract enableIamAuthentication?: boolean; + + public grantConnect(grantee: iam.IGrantable): iam.Grant { + if (this.enableIamAuthentication === false) { + throw new Error('Cannot grant connect when IAM authentication is disabled'); + } + + this.enableIamAuthentication = true; + return iam.Grant.addToPrincipal({ + grantee, + actions: ['neptune-db:*'], + resourceArns: [ + [ + 'arn', + Aws.PARTITION, + 'neptune-db', + Aws.REGION, + Aws.ACCOUNT_ID, + `${this.clusterIdentifier}/*`, + ].join(':'), + ], + }); + } +} + +/** + * Create a clustered database with a given number of instances. + * + * @resource AWS::Neptune::DBCluster + */ +export class DatabaseCluster extends DatabaseClusterBase implements IDatabaseCluster { + + /** + * The default number of instances in the Neptune cluster if none are + * specified + */ + public static readonly DEFAULT_NUM_INSTANCES = 1; + + public readonly clusterIdentifier: string; + public readonly clusterEndpoint: Endpoint; public readonly clusterReadEndpoint: Endpoint; + public readonly connections: ec2.Connections; /** * The resource id for the cluster; for example: cluster-ABCD1234EFGH5678IJKL90MNOP. The cluster ID uniquely @@ -318,11 +371,6 @@ export class DatabaseCluster extends Resource implements IDatabaseCluster { */ public readonly clusterResourceIdentifier: string; - /** - * The connections object to implement IConectable - */ - public readonly connections: ec2.Connections; - /** * The VPC where the DB subnet group is created. */ @@ -348,6 +396,8 @@ export class DatabaseCluster extends Resource implements IDatabaseCluster { */ public readonly instanceEndpoints: Endpoint[] = []; + protected enableIamAuthentication?: boolean; + constructor(scope: Construct, id: string, props: DatabaseClusterProps) { super(scope, id); @@ -385,6 +435,8 @@ export class DatabaseCluster extends Resource implements IDatabaseCluster { const deletionProtection = props.deletionProtection ?? (props.removalPolicy === RemovalPolicy.RETAIN ? true : undefined); + this.enableIamAuthentication = props.iamAuthentication; + // Create the Neptune cluster const cluster = new CfnDBCluster(this, 'Resource', { // Basic @@ -396,6 +448,7 @@ export class DatabaseCluster extends Resource implements IDatabaseCluster { dbClusterParameterGroupName: props.clusterParameterGroup?.clusterParameterGroupName, deletionProtection: deletionProtection, associatedRoles: props.associatedRoles ? props.associatedRoles.map(role => ({ roleArn: role.roleArn })) : undefined, + iamAuthEnabled: Lazy.any({ produce: () => this.enableIamAuthentication }), // Backup backupRetentionPeriod: props.backupRetention?.toDays(), preferredBackupWindow: props.preferredBackupWindow, diff --git a/packages/@aws-cdk/aws-neptune/test/cluster.test.ts b/packages/@aws-cdk/aws-neptune/test/cluster.test.ts index d2c5ff4b6c1ef..6bb933e34dab5 100644 --- a/packages/@aws-cdk/aws-neptune/test/cluster.test.ts +++ b/packages/@aws-cdk/aws-neptune/test/cluster.test.ts @@ -1,4 +1,5 @@ -import { expect as expectCDK, haveResource, ResourcePart } from '@aws-cdk/assert'; +import '@aws-cdk/assert/jest'; +import { ABSENT, ResourcePart } from '@aws-cdk/assert'; import * as ec2 from '@aws-cdk/aws-ec2'; import * as iam from '@aws-cdk/aws-iam'; import * as kms from '@aws-cdk/aws-kms'; @@ -20,7 +21,7 @@ describe('DatabaseCluster', () => { }); // THEN - expectCDK(stack).to(haveResource('AWS::Neptune::DBCluster', { + expect(stack).toHaveResource('AWS::Neptune::DBCluster', { Properties: { DBSubnetGroupName: { Ref: 'DatabaseSubnets3C9252C9' }, VpcSecurityGroupIds: [{ 'Fn::GetAtt': ['DatabaseSecurityGroup5C91FDCB', 'GroupId'] }], @@ -28,20 +29,20 @@ describe('DatabaseCluster', () => { }, DeletionPolicy: 'Retain', UpdateReplacePolicy: 'Retain', - }, ResourcePart.CompleteDefinition)); + }, ResourcePart.CompleteDefinition); - expectCDK(stack).to(haveResource('AWS::Neptune::DBInstance', { + expect(stack).toHaveResource('AWS::Neptune::DBInstance', { DeletionPolicy: 'Retain', UpdateReplacePolicy: 'Retain', - }, ResourcePart.CompleteDefinition)); + }, ResourcePart.CompleteDefinition); - expectCDK(stack).to(haveResource('AWS::Neptune::DBSubnetGroup', { + expect(stack).toHaveResource('AWS::Neptune::DBSubnetGroup', { SubnetIds: [ { Ref: 'VPCPrivateSubnet1Subnet8BCA10E0' }, { Ref: 'VPCPrivateSubnet2SubnetCFCDAA7A' }, { Ref: 'VPCPrivateSubnet3Subnet3EDCD457' }, ], - })); + }); }); test('can create a cluster with a single instance', () => { @@ -57,10 +58,10 @@ describe('DatabaseCluster', () => { }); // THEN - expectCDK(stack).to(haveResource('AWS::Neptune::DBCluster', { + expect(stack).toHaveResource('AWS::Neptune::DBCluster', { DBSubnetGroupName: { Ref: 'DatabaseSubnets3C9252C9' }, VpcSecurityGroupIds: [{ 'Fn::GetAtt': ['DatabaseSecurityGroup5C91FDCB', 'GroupId'] }], - })); + }); }); test('errors when less than one instance is specified', () => { @@ -111,11 +112,11 @@ describe('DatabaseCluster', () => { }); // THEN - expectCDK(stack).to(haveResource('AWS::Neptune::DBCluster', { + expect(stack).toHaveResource('AWS::Neptune::DBCluster', { EngineVersion: '1.0.4.1', DBSubnetGroupName: { Ref: 'DatabaseSubnets3C9252C9' }, VpcSecurityGroupIds: [{ 'Fn::GetAtt': ['DatabaseSecurityGroup5C91FDCB', 'GroupId'] }], - })); + }); }); test('can create a cluster with imported vpc and security group', () => { @@ -135,10 +136,10 @@ describe('DatabaseCluster', () => { }); // THEN - expectCDK(stack).to(haveResource('AWS::Neptune::DBCluster', { + expect(stack).toHaveResource('AWS::Neptune::DBCluster', { DBSubnetGroupName: { Ref: 'DatabaseSubnets3C9252C9' }, VpcSecurityGroupIds: ['SecurityGroupId12345'], - })); + }); }); test('cluster with parameter group', () => { @@ -160,9 +161,9 @@ describe('DatabaseCluster', () => { }); // THEN - expectCDK(stack).to(haveResource('AWS::Neptune::DBCluster', { + expect(stack).toHaveResource('AWS::Neptune::DBCluster', { DBClusterParameterGroupName: { Ref: 'ParamsA8366201' }, - })); + }); }); test('cluster with associated role', () => { @@ -183,7 +184,7 @@ describe('DatabaseCluster', () => { }); // THEN - expectCDK(stack).to(haveResource('AWS::Neptune::DBCluster', { + expect(stack).toHaveResource('AWS::Neptune::DBCluster', { AssociatedRoles: [ { RoleArn: { @@ -194,7 +195,7 @@ describe('DatabaseCluster', () => { }, }, ], - })); + }); }); test('cluster with imported parameter group', () => { @@ -212,9 +213,9 @@ describe('DatabaseCluster', () => { }); // THEN - expectCDK(stack).to(haveResource('AWS::Neptune::DBCluster', { + expect(stack).toHaveResource('AWS::Neptune::DBCluster', { DBClusterParameterGroupName: 'ParamGroupName', - })); + }); }); test('create an encrypted cluster with custom KMS key', () => { @@ -230,7 +231,7 @@ describe('DatabaseCluster', () => { }); // THEN - expectCDK(stack).to(haveResource('AWS::Neptune::DBCluster', { + expect(stack).toHaveResource('AWS::Neptune::DBCluster', { KmsKeyId: { 'Fn::GetAtt': [ 'Key961B73FD', @@ -238,7 +239,7 @@ describe('DatabaseCluster', () => { ], }, StorageEncrypted: true, - })); + }); }); test('creating a cluster defaults to using encryption', () => { @@ -253,9 +254,9 @@ describe('DatabaseCluster', () => { }); // THEN - expectCDK(stack).to(haveResource('AWS::Neptune::DBCluster', { + expect(stack).toHaveResource('AWS::Neptune::DBCluster', { StorageEncrypted: true, - })); + }); }); test('supplying a KMS key with storageEncryption false throws an error', () => { @@ -306,9 +307,9 @@ describe('DatabaseCluster', () => { }); // THEN - expectCDK(stack).to(haveResource('AWS::Neptune::DBInstance', { + expect(stack).toHaveResource('AWS::Neptune::DBInstance', { DBInstanceIdentifier: `${instanceIdentifierBase}1`, - })); + }); }); test('cluster identifier used', () => { @@ -325,9 +326,9 @@ describe('DatabaseCluster', () => { }); // THEN - expectCDK(stack).to(haveResource('AWS::Neptune::DBInstance', { + expect(stack).toHaveResource('AWS::Neptune::DBInstance', { DBInstanceIdentifier: `${clusterIdentifier}instance1`, - })); + }); }); test('imported cluster has supplied attributes', () => { @@ -370,9 +371,9 @@ describe('DatabaseCluster', () => { cluster.connections.allowToAnyIpv4(ec2.Port.tcp(443)); // THEN - expectCDK(stack).to(haveResource('AWS::EC2::SecurityGroupEgress', { + expect(stack).toHaveResource('AWS::EC2::SecurityGroupEgress', { GroupId: 'sg-123456789', - })); + }); }); test('backup retention period respected', () => { @@ -388,9 +389,9 @@ describe('DatabaseCluster', () => { }); // THEN - expectCDK(stack).to(haveResource('AWS::Neptune::DBCluster', { + expect(stack).toHaveResource('AWS::Neptune::DBCluster', { BackupRetentionPeriod: 20, - })); + }); }); test('backup maintenance window respected', () => { @@ -407,10 +408,10 @@ describe('DatabaseCluster', () => { }); // THEN - expectCDK(stack).to(haveResource('AWS::Neptune::DBCluster', { + expect(stack).toHaveResource('AWS::Neptune::DBCluster', { BackupRetentionPeriod: 20, PreferredBackupWindow: '07:34-08:04', - })); + }); }); test('regular maintenance window respected', () => { @@ -426,9 +427,78 @@ describe('DatabaseCluster', () => { }); // THEN - expectCDK(stack).to(haveResource('AWS::Neptune::DBCluster', { + expect(stack).toHaveResource('AWS::Neptune::DBCluster', { PreferredMaintenanceWindow: '07:34-08:04', - })); + }); + }); + + test('iam authentication - off by default', () => { + // GIVEN + const stack = testStack(); + const vpc = new ec2.Vpc(stack, 'VPC'); + + // WHEN + new DatabaseCluster(stack, 'Cluster', { + vpc, + instanceType: InstanceType.R5_LARGE, + }); + + // THEN + expect(stack).toHaveResourceLike('AWS::Neptune::DBCluster', { + IamAuthEnabled: ABSENT, + }); + }); + + test('createGrant - creates IAM policy and enables IAM auth', () => { + // GIVEN + const stack = testStack(); + const vpc = new ec2.Vpc(stack, 'VPC'); + + // WHEN + const cluster = new DatabaseCluster(stack, 'Cluster', { + vpc, + instanceType: InstanceType.R5_LARGE, + }); + const role = new iam.Role(stack, 'DBRole', { + assumedBy: new iam.AccountPrincipal(stack.account), + }); + cluster.grantConnect(role); + + // THEN + expect(stack).toHaveResourceLike('AWS::Neptune::DBCluster', { + IamAuthEnabled: true, + }); + expect(stack).toHaveResource('AWS::IAM::Policy', { + PolicyDocument: { + Statement: [{ + Effect: 'Allow', + Action: 'neptune-db:*', + Resource: { + 'Fn::Join': ['', ['arn:', { Ref: 'AWS::Partition' }, ':neptune-db:', { Ref: 'AWS::Region' }, ':', { Ref: 'AWS::AccountId' }, ':', { Ref: 'ClusterEB0386A7' }, '/*']], + }, + }], + Version: '2012-10-17', + }, + }); + }); + + test('createGrant - throws if IAM auth disabled', () => { + // GIVEN + const stack = testStack(); + const vpc = new ec2.Vpc(stack, 'VPC'); + + // WHEN + const cluster = new DatabaseCluster(stack, 'Cluster', { + vpc, + instanceType: InstanceType.R5_LARGE, + iamAuthentication: false, + }); + const role = new iam.Role(stack, 'DBRole', { + assumedBy: new iam.AccountPrincipal(stack.account), + }); + + // THEN + expect(() => { cluster.grantConnect(role); }).toThrow(/Cannot grant connect when IAM authentication is disabled/); }); });