diff --git a/packages/@aws-cdk-testing/framework-integ/test/aws-route53/test/integ.cross-account-zone-delegation.js.snapshot/integ.json b/packages/@aws-cdk-testing/framework-integ/test/aws-route53/test/integ.cross-account-zone-delegation.js.snapshot/integ.json index 17250dee22fb8..d254885e1748d 100644 --- a/packages/@aws-cdk-testing/framework-integ/test/aws-route53/test/integ.cross-account-zone-delegation.js.snapshot/integ.json +++ b/packages/@aws-cdk-testing/framework-integ/test/aws-route53/test/integ.cross-account-zone-delegation.js.snapshot/integ.json @@ -4,7 +4,8 @@ "Route53CrossAccountInteg/DefaultTest": { "stacks": [ "child-stack", - "child-opt-in-stack" + "child-opt-in-stack", + "parent-stack" ], "diffAssets": true, "assertionStack": "Route53CrossAccountInteg/DefaultTest/DeployAssert", diff --git a/packages/@aws-cdk-testing/framework-integ/test/aws-route53/test/integ.cross-account-zone-delegation.js.snapshot/parent-stack.template.json b/packages/@aws-cdk-testing/framework-integ/test/aws-route53/test/integ.cross-account-zone-delegation.js.snapshot/parent-stack.template.json index b3e0aa3918c48..9f68b681cd632 100644 --- a/packages/@aws-cdk-testing/framework-integ/test/aws-route53/test/integ.cross-account-zone-delegation.js.snapshot/parent-stack.template.json +++ b/packages/@aws-cdk-testing/framework-integ/test/aws-route53/test/integ.cross-account-zone-delegation.js.snapshot/parent-stack.template.json @@ -39,6 +39,9 @@ "route53:ChangeResourceRecordSetsActions": [ "UPSERT", "DELETE" + ], + "route53:ChangeResourceRecordSetsNormalizedRecordNames": [ + "sub.uniqueexample.com" ] } }, diff --git a/packages/@aws-cdk-testing/framework-integ/test/aws-route53/test/integ.cross-account-zone-delegation.ts b/packages/@aws-cdk-testing/framework-integ/test/aws-route53/test/integ.cross-account-zone-delegation.ts index 54ac97f2df5ca..bb7e10288b04a 100644 --- a/packages/@aws-cdk-testing/framework-integ/test/aws-route53/test/integ.cross-account-zone-delegation.ts +++ b/packages/@aws-cdk-testing/framework-integ/test/aws-route53/test/integ.cross-account-zone-delegation.ts @@ -52,7 +52,7 @@ class ParentStack extends cdk.Stack { roleName: delegationRoleName, assumedBy: new iam.AccountPrincipal(crossAccount), }); - parentZone.grantDelegation(crossAccountRole); + parentZone.grantDelegation(crossAccountRole, route53.DelegationGrantNames.ofEquals(subZoneName)); } } @@ -106,6 +106,6 @@ childStack.addDependency(parentStack); childOptInStack.addDependency(parentStack); new IntegTest(app, 'Route53CrossAccountInteg', { - testCases: [childStack, childOptInStack], + testCases: [childStack, childOptInStack, parentStack], diffAssets: true, }); \ No newline at end of file diff --git a/packages/aws-cdk-lib/aws-route53/README.md b/packages/aws-cdk-lib/aws-route53/README.md index ce92008765691..752007e6c9892 100644 --- a/packages/aws-cdk-lib/aws-route53/README.md +++ b/packages/aws-cdk-lib/aws-route53/README.md @@ -255,6 +255,25 @@ new route53.CrossAccountZoneDelegationRecord(this, 'delegate', { }); ``` +To restrict the domain names that can be delegated with the IAM role use the `DelegationGrantNames` class, +which enforces the `route53:ChangeResourceRecordSetsNormalizedRecordNames` condition key. + +This allows you to follow the minimum privilege principle: + +```ts +const parentZone = new route53.PublicHostedZone(this, 'HostedZone', { + zoneName: 'someexample.com', +}); + +declare const betaCrossAccountRole: iam.Role; +parentZone.grantDelegation(betaCrossAccountRole, route53.DelegationGrantNames.ofEquals('beta.someexample.com')); + +declare const prodCrossAccountRole: iam.Role; +parentZone.grantDelegation(prodCrossAccountRole, route53.DelegationGrantNames.ofEquals('prod.someexample.com')); +``` + +> Visit [Using IAM policy conditions for fine-grained access control to manage resource record sets](https://docs.aws.amazon.com/Route53/latest/DeveloperGuide/specifying-rrset-conditions.html) for more details. + ### Add Trailing Dot to Domain Names In order to continue managing existing domain names with trailing dots using CDK, you can set `addTrailingDot: false` to prevent the Construct from adding a dot at the end of the domain name. diff --git a/packages/aws-cdk-lib/aws-route53/lib/delegation-grant-names.ts b/packages/aws-cdk-lib/aws-route53/lib/delegation-grant-names.ts new file mode 100644 index 0000000000000..bec8e86af2c54 --- /dev/null +++ b/packages/aws-cdk-lib/aws-route53/lib/delegation-grant-names.ts @@ -0,0 +1,45 @@ +/** + * Limit the delegation grant to a set of domain names using the IAM + * `route53:ChangeResourceRecordSetsNormalizedRecordNames` context key. + */ +export abstract class DelegationGrantNames { + /** + * Match the domain names using the IAM `StringEquals` condition. + * + * @param names List of allowed record names. + */ + public static ofEquals(...names: string[]): DelegationGrantNames { + return new (class extends DelegationGrantNames { + public _equals() { + return names; + } + })(); + } + + /** + * Match the domain names using the IAM `StringLike` condition. + * + * @param names List of allowed record names. + */ + public static ofLike(...names: string[]): DelegationGrantNames { + return new (class extends DelegationGrantNames { + public _like() { + return names; + } + })(); + } + + /** + * @internal + */ + public _equals(): string[] | null { + return null; + } + + /** + * @internal + */ + public _like(): string[] | null { + return null; + } +} diff --git a/packages/aws-cdk-lib/aws-route53/lib/hosted-zone-ref.ts b/packages/aws-cdk-lib/aws-route53/lib/hosted-zone-ref.ts index 8672c3f7460fc..c7e750ed0b2ce 100644 --- a/packages/aws-cdk-lib/aws-route53/lib/hosted-zone-ref.ts +++ b/packages/aws-cdk-lib/aws-route53/lib/hosted-zone-ref.ts @@ -1,3 +1,4 @@ +import { DelegationGrantNames } from './delegation-grant-names'; import * as iam from '../../aws-iam'; import { IResource } from '../../core'; @@ -36,8 +37,11 @@ export interface IHostedZone extends IResource { /** * Grant permissions to add delegation records to this zone + * + * @param grantee grantee to receive the permissions + * @param names specify to restrict the delegation to a specific set of names */ - grantDelegation(grantee: iam.IGrantable): iam.Grant; + grantDelegation(grantee: iam.IGrantable, names?: DelegationGrantNames): iam.Grant; } /** diff --git a/packages/aws-cdk-lib/aws-route53/lib/hosted-zone.ts b/packages/aws-cdk-lib/aws-route53/lib/hosted-zone.ts index d9361ee2b2d08..bf84c819ca712 100644 --- a/packages/aws-cdk-lib/aws-route53/lib/hosted-zone.ts +++ b/packages/aws-cdk-lib/aws-route53/lib/hosted-zone.ts @@ -1,4 +1,5 @@ import { Construct } from 'constructs'; +import { DelegationGrantNames } from './delegation-grant-names'; import { HostedZoneProviderProps } from './hosted-zone-provider'; import { HostedZoneAttributes, IHostedZone, PublicHostedZoneAttributes } from './hosted-zone-ref'; import { CaaAmazonRecord, ZoneDelegationRecord } from './record-set'; @@ -84,8 +85,8 @@ export class HostedZone extends Resource implements IHostedZone { public get hostedZoneArn(): string { return makeHostedZoneArn(this, this.hostedZoneId); } - public grantDelegation(grantee: iam.IGrantable): iam.Grant { - return makeGrantDelegation(grantee, this.hostedZoneArn); + public grantDelegation(grantee: iam.IGrantable, names?: DelegationGrantNames): iam.Grant { + return makeGrantDelegation(grantee, this.hostedZoneArn, names); } } @@ -108,8 +109,8 @@ export class HostedZone extends Resource implements IHostedZone { public get hostedZoneArn(): string { return makeHostedZoneArn(this, this.hostedZoneId); } - public grantDelegation(grantee: iam.IGrantable): iam.Grant { - return makeGrantDelegation(grantee, this.hostedZoneArn); + public grantDelegation(grantee: iam.IGrantable, names?: DelegationGrantNames): iam.Grant { + return makeGrantDelegation(grantee, this.hostedZoneArn, names); } } @@ -199,8 +200,8 @@ export class HostedZone extends Resource implements IHostedZone { this.vpcs.push({ vpcId: vpc.vpcId, vpcRegion: vpc.env.region ?? Stack.of(vpc).region }); } - public grantDelegation(grantee: iam.IGrantable): iam.Grant { - return makeGrantDelegation(grantee, this.hostedZoneArn); + public grantDelegation(grantee: iam.IGrantable, names?: DelegationGrantNames): iam.Grant { + return makeGrantDelegation(grantee, this.hostedZoneArn, names); } } @@ -274,8 +275,8 @@ export class PublicHostedZone extends HostedZone implements IPublicHostedZone { public get hostedZoneArn(): string { return makeHostedZoneArn(this, this.hostedZoneId); } - public grantDelegation(grantee: iam.IGrantable): iam.Grant { - return makeGrantDelegation(grantee, this.hostedZoneArn); + public grantDelegation(grantee: iam.IGrantable, names?: DelegationGrantNames): iam.Grant { + return makeGrantDelegation(grantee, this.hostedZoneArn, names); } } return new Import(scope, id); @@ -297,8 +298,8 @@ export class PublicHostedZone extends HostedZone implements IPublicHostedZone { public get hostedZoneArn(): string { return makeHostedZoneArn(this, this.hostedZoneId); } - public grantDelegation(grantee: iam.IGrantable): iam.Grant { - return makeGrantDelegation(grantee, this.hostedZoneArn); + public grantDelegation(grantee: iam.IGrantable, names?: DelegationGrantNames): iam.Grant { + return makeGrantDelegation(grantee, this.hostedZoneArn, names); } } return new Import(scope, id); @@ -435,8 +436,8 @@ export class PrivateHostedZone extends HostedZone implements IPrivateHostedZone public get hostedZoneArn(): string { return makeHostedZoneArn(this, this.hostedZoneId); } - public grantDelegation(grantee: iam.IGrantable): iam.Grant { - return makeGrantDelegation(grantee, this.hostedZoneArn); + public grantDelegation(grantee: iam.IGrantable, names?: DelegationGrantNames): iam.Grant { + return makeGrantDelegation(grantee, this.hostedZoneArn, names); } } return new Import(scope, id); diff --git a/packages/aws-cdk-lib/aws-route53/lib/index.ts b/packages/aws-cdk-lib/aws-route53/lib/index.ts index 2a6128e50c2bd..c61aaea9dc75c 100644 --- a/packages/aws-cdk-lib/aws-route53/lib/index.ts +++ b/packages/aws-cdk-lib/aws-route53/lib/index.ts @@ -1,4 +1,5 @@ export * from './alias-record-target'; +export * from './delegation-grant-names'; export * from './hosted-zone'; export * from './hosted-zone-provider'; export * from './hosted-zone-ref'; diff --git a/packages/aws-cdk-lib/aws-route53/lib/util.ts b/packages/aws-cdk-lib/aws-route53/lib/util.ts index 6f8f832289a98..d292f57955bb5 100644 --- a/packages/aws-cdk-lib/aws-route53/lib/util.ts +++ b/packages/aws-cdk-lib/aws-route53/lib/util.ts @@ -1,4 +1,5 @@ import { Construct } from 'constructs'; +import { DelegationGrantNames } from './delegation-grant-names'; import { IHostedZone } from './hosted-zone-ref'; import * as iam from '../../aws-iam'; import { Stack } from '../../core'; @@ -71,7 +72,7 @@ export function makeHostedZoneArn(construct: Construct, hostedZoneId: string): s }); } -export function makeGrantDelegation(grantee: iam.IGrantable, hostedZoneArn: string): iam.Grant { +export function makeGrantDelegation(grantee: iam.IGrantable, hostedZoneArn: string, names?: DelegationGrantNames): iam.Grant { const g1 = iam.Grant.addToPrincipal({ grantee, actions: ['route53:ChangeResourceRecordSets'], @@ -80,7 +81,15 @@ export function makeGrantDelegation(grantee: iam.IGrantable, hostedZoneArn: stri 'ForAllValues:StringEquals': { 'route53:ChangeResourceRecordSetsRecordTypes': ['NS'], 'route53:ChangeResourceRecordSetsActions': ['UPSERT', 'DELETE'], + ...(names?._equals() ? { + 'route53:ChangeResourceRecordSetsNormalizedRecordNames': names._equals(), + } : {}), }, + ...(names?._like() ? { + 'ForAllValues:StringLike': { + 'route53:ChangeResourceRecordSetsNormalizedRecordNames': names._like(), + }, + } : {}), }, }); const g2 = iam.Grant.addToPrincipal({ diff --git a/packages/aws-cdk-lib/aws-route53/test/delegation-grant-names.test.ts b/packages/aws-cdk-lib/aws-route53/test/delegation-grant-names.test.ts new file mode 100644 index 0000000000000..b84efa7f6f81e --- /dev/null +++ b/packages/aws-cdk-lib/aws-route53/test/delegation-grant-names.test.ts @@ -0,0 +1,23 @@ +import { DelegationGrantNames } from '../lib/delegation-grant-names'; + +describe('delegation-grant-names', () => { + const NAMES = ['name-1', 'name-2']; + + test('ofEquals() creates instance whose _equals() is not null', () => { + // WHEN + const actual = DelegationGrantNames.ofEquals(...NAMES); + + // THEN + expect(actual._equals()).toStrictEqual(NAMES); + expect(actual._like()).toBeNull(); + }); + + test('ofLike() creates instance whose _like() is not null', () => { + // WHEN + const actual = DelegationGrantNames.ofLike(...NAMES); + + // THEN + expect(actual._equals()).toBeNull(); + expect(actual._like()).toStrictEqual(NAMES); + }); +}); diff --git a/packages/aws-cdk-lib/aws-route53/test/util.test.ts b/packages/aws-cdk-lib/aws-route53/test/util.test.ts index e6e248ef2ba70..c4346ea0f6f05 100644 --- a/packages/aws-cdk-lib/aws-route53/test/util.test.ts +++ b/packages/aws-cdk-lib/aws-route53/test/util.test.ts @@ -1,5 +1,7 @@ +import * as iam from '../../aws-iam'; import * as cdk from '../../core'; import { HostedZone } from '../lib'; +import { DelegationGrantNames } from '../lib/delegation-grant-names'; import * as util from '../lib/util'; describe('util', () => { @@ -67,4 +69,65 @@ describe('util', () => { // THEN expect(qualified).toEqual('test.domain.com.'); }); + + test('grant delegation without names returns ChangeResourceRecordSets statement with only two condition keys', () => { + // GIVEN + const stack = new cdk.Stack(); + const grantee = new iam.User(stack, 'Grantee'); + + // WHEN + const actual = util.makeGrantDelegation(grantee, 'hosted-zone'); + + // WHEN + const statement = actual.principalStatements.find(x => x.actions.includes('route53:ChangeResourceRecordSets')); + expect(statement).not.toBeUndefined(); + expect(statement?.conditions).toEqual({ + 'ForAllValues:StringEquals': { + 'route53:ChangeResourceRecordSetsRecordTypes': ['NS'], + 'route53:ChangeResourceRecordSetsActions': ['UPSERT', 'DELETE'], + }, + }); + }); + + test('grant delegation with equals names returns ChangeResourceRecordSets statement with normalized record names condition', () => { + // GIVEN + const stack = new cdk.Stack(); + const grantee = new iam.User(stack, 'Grantee'); + + // WHEN + const actual = util.makeGrantDelegation(grantee, 'hosted-zone', DelegationGrantNames.ofEquals('name-1', 'name-2')); + + // WHEN + const statement = actual.principalStatements.find(x => x.actions.includes('route53:ChangeResourceRecordSets')); + expect(statement).not.toBeUndefined(); + expect(statement?.conditions).toEqual({ + 'ForAllValues:StringEquals': { + 'route53:ChangeResourceRecordSetsRecordTypes': ['NS'], + 'route53:ChangeResourceRecordSetsActions': ['UPSERT', 'DELETE'], + 'route53:ChangeResourceRecordSetsNormalizedRecordNames': ['name-1', 'name-2'], + }, + }); + }); + + test('grant delegation with like names returns ChangeResourceRecordSets statement with normalized record names condition', () => { + // GIVEN + const stack = new cdk.Stack(); + const grantee = new iam.User(stack, 'Grantee'); + + // WHEN + const actual = util.makeGrantDelegation(grantee, 'hosted-zone', DelegationGrantNames.ofLike('name-1', 'name-2')); + + // WHEN + const statement = actual.principalStatements.find(x => x.actions.includes('route53:ChangeResourceRecordSets')); + expect(statement).not.toBeUndefined(); + expect(statement?.conditions).toEqual({ + 'ForAllValues:StringEquals': { + 'route53:ChangeResourceRecordSetsRecordTypes': ['NS'], + 'route53:ChangeResourceRecordSetsActions': ['UPSERT', 'DELETE'], + }, + 'ForAllValues:StringLike': { + 'route53:ChangeResourceRecordSetsNormalizedRecordNames': ['name-1', 'name-2'], + }, + }); + }); });