diff --git a/packages/@aws-cdk-testing/cli-integ/lib/aws.ts b/packages/@aws-cdk-testing/cli-integ/lib/aws.ts index 73f8c9bf0782a..bcf19f4a47e3f 100644 --- a/packages/@aws-cdk-testing/cli-integ/lib/aws.ts +++ b/packages/@aws-cdk-testing/cli-integ/lib/aws.ts @@ -19,6 +19,7 @@ export class AwsClients { public readonly s3: AwsCaller; public readonly ecr: AwsCaller; public readonly ecs: AwsCaller; + public readonly sso: AwsCaller; public readonly sns: AwsCaller; public readonly iam: AwsCaller; public readonly lambda: AwsCaller; @@ -36,6 +37,7 @@ export class AwsClients { this.s3 = makeAwsCaller(AWS.S3, this.config); this.ecr = makeAwsCaller(AWS.ECR, this.config); this.ecs = makeAwsCaller(AWS.ECS, this.config); + this.sso = makeAwsCaller(AWS.SSO, this.config); this.sns = makeAwsCaller(AWS.SNS, this.config); this.iam = makeAwsCaller(AWS.IAM, this.config); this.lambda = makeAwsCaller(AWS.Lambda, this.config); diff --git a/packages/@aws-cdk-testing/cli-integ/resources/cdk-apps/app/app.js b/packages/@aws-cdk-testing/cli-integ/resources/cdk-apps/app/app.js index bdcc4457fbee9..5683951c2a832 100755 --- a/packages/@aws-cdk-testing/cli-integ/resources/cdk-apps/app/app.js +++ b/packages/@aws-cdk-testing/cli-integ/resources/cdk-apps/app/app.js @@ -11,6 +11,7 @@ if (process.env.PACKAGE_LAYOUT_VERSION === '1') { var sns = require('@aws-cdk/aws-sns'); var sqs = require('@aws-cdk/aws-sqs'); var lambda = require('@aws-cdk/aws-lambda'); + var sso = require('@aws-cdk/aws-sso'); var docker = require('@aws-cdk/aws-ecr-assets'); } else { var cdk = require('aws-cdk-lib'); @@ -19,6 +20,7 @@ if (process.env.PACKAGE_LAYOUT_VERSION === '1') { LegacyStackSynthesizer, aws_ec2: ec2, aws_ecs: ecs, + aws_sso: sso, aws_s3: s3, aws_ssm: ssm, aws_iam: iam, @@ -68,6 +70,62 @@ class YourStack extends cdk.Stack { } } +class SsoPermissionSetNoPolicy extends Stack { + constructor(scope, id) { + super(scope, id); + + new sso.CfnPermissionSet(this, "permission-set-without-managed-policy", { + instanceArn: 'arn:aws:sso:::instance/testvalue', + name: 'testName', + permissionsBoundary: { customerManagedPolicyReference: { name: 'why', path: '/how/' }}, + }) + } +} + +class SsoPermissionSetManagedPolicy extends Stack { + constructor(scope, id) { + super(scope, id); + new sso.CfnPermissionSet(this, "permission-set-with-managed-policy", { + managedPolicies: ['arn:aws:iam::aws:policy/administratoraccess'], + customerManagedPolicyReferences: [{ name: 'forSSO' }], + permissionsBoundary: { managedPolicyArn: 'arn:aws:iam::aws:policy/AdministratorAccess' }, + instanceArn: 'arn:aws:sso:::instance/testvalue', + name: 'niceWork', + }) + } +} + +class SsoAssignment extends Stack { + constructor(scope, id) { + super(scope, id); + new sso.CfnAssignment(this, "assignment", { + instanceArn: 'arn:aws:sso:::instance/testvalue', + permissionSetArn: 'arn:aws:sso:::testvalue', + principalId: '11111111-2222-3333-4444-test', + principalType: 'USER', + targetId: '111111111111', + targetType: 'AWS_ACCOUNT' + }); + } +} + +class SsoInstanceAccessControlConfig extends Stack { + constructor(scope, id) { + super(scope, id); + new sso.CfnInstanceAccessControlAttributeConfiguration(this, 'instanceAccessControlConfig', { + instanceArn: 'arn:aws:sso:::instance/testvalue', + accessControlAttributes: [ + { key: 'first', value: { source: ['a'] } }, + { key: 'second', value: { source: ['b'] } }, + { key: 'third', value: { source: ['c'] } }, + { key: 'fourth', value: { source: ['d'] } }, + { key: 'fifth', value: { source: ['e'] } }, + { key: 'sixth', value: { source: ['f'] } }, + ] + }) + } +} + class ListMultipleDependentStack extends Stack { constructor(scope, id) { super(scope, id); @@ -591,6 +649,13 @@ switch (stackSet) { new EcsHotswapStack(app, `${stackPrefix}-ecs-hotswap`); new DockerStack(app, `${stackPrefix}-docker`); new DockerStackWithCustomFile(app, `${stackPrefix}-docker-with-custom-file`); + + // SSO stacks + new SsoInstanceAccessControlConfig(app, `${stackPrefix}-sso-access-control`); + new SsoAssignment(app, `${stackPrefix}-sso-assignment`); + new SsoPermissionSetManagedPolicy(app, `${stackPrefix}-sso-perm-set-with-managed-policy`); + new SsoPermissionSetNoPolicy(app, `${stackPrefix}-sso-perm-set-without-managed-policy`); + const failed = new FailedStack(app, `${stackPrefix}-failed`) // A stack that depends on the failed stack -- used to test that '-e' does not deploy the failing stack diff --git a/packages/@aws-cdk-testing/cli-integ/tests/cli-integ-tests/cli.integtest.ts b/packages/@aws-cdk-testing/cli-integ/tests/cli-integ-tests/cli.integtest.ts index 86350567a11af..b68c2c2f90cbe 100644 --- a/packages/@aws-cdk-testing/cli-integ/tests/cli-integ-tests/cli.integtest.ts +++ b/packages/@aws-cdk-testing/cli-integ/tests/cli-integ-tests/cli.integtest.ts @@ -775,6 +775,158 @@ integTest('cdk diff --fail with multiple stack exits with if any of the stacks c await expect(fixture.cdk(['diff', '--fail', fixture.fullStackName('test-1'), fixture.fullStackName('test-2')])).rejects.toThrow('exited with error'); })); +integTest('cdk diff --security-only successfully outputs sso-permission-set-without-managed-policy information', withDefaultFixture(async (fixture) => { + const diff = await fixture.cdk( + ['diff', '--security-only', fixture.fullStackName('sso-perm-set-without-managed-policy')], + ); + `┌───┬──────────────────────────────────────────┬──────────────────────────────────┬────────────────────┬───────────────────────────────────┬─────────────────────────────────┐ + │ │ Resource │ InstanceArn │ PermissionSet name │ PermissionsBoundary │ CustomerManagedPolicyReferences │ + ├───┼──────────────────────────────────────────┼──────────────────────────────────┼────────────────────┼───────────────────────────────────┼─────────────────────────────────┤ + │ + │\${permission-set-without-managed-policy} │ arn:aws:sso:::instance/testvalue │ testName │ CustomerManagedPolicyReference: { │ │ + │ │ │ │ │ Name: why, Path: /how/ │ │ + │ │ │ │ │ } │ │ +`; + expect(diff).toContain('Resource'); + expect(diff).toContain('permission-set-without-managed-policy'); + + expect(diff).toContain('InstanceArn'); + expect(diff).toContain('arn:aws:sso:::instance/testvalue'); + + expect(diff).toContain('PermissionSet name'); + expect(diff).toContain('testName'); + + expect(diff).toContain('PermissionsBoundary'); + expect(diff).toContain('CustomerManagedPolicyReference: {'); + expect(diff).toContain('Name: why, Path: /how/'); + expect(diff).toContain('}'); + + expect(diff).toContain('CustomerManagedPolicyReferences'); +})); + +integTest('cdk diff --security-only successfully outputs sso-permission-set-with-managed-policy information', withDefaultFixture(async (fixture) => { + const diff = await fixture.cdk( + ['diff', '--security-only', fixture.fullStackName('sso-perm-set-with-managed-policy')], + ); + `┌───┬──────────────────────────────────────────┬──────────────────────────────────┬────────────────────┬───────────────────────────────────────────────────────────────┬─────────────────────────────────┐ + │ │ Resource │ InstanceArn │ PermissionSet name │ PermissionsBoundary │ CustomerManagedPolicyReferences │ + ├───┼──────────────────────────────────────────┼──────────────────────────────────┼────────────────────┼───────────────────────────────────────────────────────────────┼─────────────────────────────────┤ + │ + │\${permission-set-with-managed-policy} │ arn:aws:sso:::instance/testvalue │ niceWork │ ManagedPolicyArn: arn:aws:iam::aws:policy/AdministratorAccess │ Name: forSSO, Path: │ +`; + + expect(diff).toContain('Resource'); + expect(diff).toContain('permission-set-with-managed-policy'); + + expect(diff).toContain('InstanceArn'); + expect(diff).toContain('arn:aws:sso:::instance/testvalue'); + + expect(diff).toContain('PermissionSet name'); + expect(diff).toContain('niceWork'); + + expect(diff).toContain('PermissionsBoundary'); + expect(diff).toContain('ManagedPolicyArn: arn:aws:iam::aws:policy/AdministratorAccess'); + + expect(diff).toContain('CustomerManagedPolicyReferences'); + expect(diff).toContain('Name: forSSO, Path:'); +})); + +integTest('cdk diff --security-only successfully outputs sso-assignment information', withDefaultFixture(async (fixture) => { + const diff = await fixture.cdk( + ['diff', '--security-only', fixture.fullStackName('sso-assignment')], + ); + `┌───┬───────────────┬──────────────────────────────────┬─────────────────────────┬──────────────────────────────┬───────────────┬──────────────┬─────────────┐ + │ │ Resource │ InstanceArn │ PermissionSetArn │ PrincipalId │ PrincipalType │ TargetId │ TargetType │ + ├───┼───────────────┼──────────────────────────────────┼─────────────────────────┼──────────────────────────────┼───────────────┼──────────────┼─────────────┤ + │ + │\${assignment} │ arn:aws:sso:::instance/testvalue │ arn:aws:sso:::testvalue │ 11111111-2222-3333-4444-test │ USER │ 111111111111 │ AWS_ACCOUNT │ + └───┴───────────────┴──────────────────────────────────┴─────────────────────────┴──────────────────────────────┴───────────────┴──────────────┴─────────────┘ +`; + expect(diff).toContain('Resource'); + expect(diff).toContain('assignment'); + + expect(diff).toContain('InstanceArn'); + expect(diff).toContain('arn:aws:sso:::instance/testvalue'); + + expect(diff).toContain('PermissionSetArn'); + expect(diff).toContain('arn:aws:sso:::testvalue'); + + expect(diff).toContain('PrincipalId'); + expect(diff).toContain('11111111-2222-3333-4444-test'); + + expect(diff).toContain('PrincipalType'); + expect(diff).toContain('USER'); + + expect(diff).toContain('TargetId'); + expect(diff).toContain('111111111111'); + + expect(diff).toContain('TargetType'); + expect(diff).toContain('AWS_ACCOUNT'); +})); + +integTest('cdk diff --security-only successfully outputs sso-access-control information', withDefaultFixture(async (fixture) => { + const diff = await fixture.cdk( + ['diff', '--security-only', fixture.fullStackName('sso-access-control')], + ); + `┌───┬────────────────────────────────┬────────────────────────┬─────────────────────────────────┐ + │ │ Resource │ InstanceArn │ AccessControlAttributes │ + ├───┼────────────────────────────────┼────────────────────────┼─────────────────────────────────┤ + │ + │ \${instanceAccessControlConfig} │ arn:aws:test:testvalue │ Key: first, Values: [a] │ + │ │ │ │ Key: second, Values: [b] │ + │ │ │ │ Key: third, Values: [c] │ + │ │ │ │ Key: fourth, Values: [d] │ + │ │ │ │ Key: fifth, Values: [e] │ + │ │ │ │ Key: sixth, Values: [f] │ + └───┴────────────────────────────────┴────────────────────────┴─────────────────────────────────┘ +`; + expect(diff).toContain('Resource'); + expect(diff).toContain('instanceAccessControlConfig'); + + expect(diff).toContain('InstanceArn'); + expect(diff).toContain('arn:aws:sso:::instance/testvalue'); + + expect(diff).toContain('AccessControlAttributes'); + expect(diff).toContain('Key: first, Values: [a]'); + expect(diff).toContain('Key: second, Values: [b]'); + expect(diff).toContain('Key: third, Values: [c]'); + expect(diff).toContain('Key: fourth, Values: [d]'); + expect(diff).toContain('Key: fifth, Values: [e]'); + expect(diff).toContain('Key: sixth, Values: [f]'); +})); + +integTest('cdk diff --security-only --fail exits when security diff for sso access control config', withDefaultFixture(async (fixture) => { + await expect( + fixture.cdk( + ['diff', '--security-only', '--fail', fixture.fullStackName('sso-access-control')], + ), + ).rejects + .toThrow('exited with error'); +})); + +integTest('cdk diff --security-only --fail exits when security diff for sso-perm-set-without-managed-policy', withDefaultFixture(async (fixture) => { + await expect( + fixture.cdk( + ['diff', '--security-only', '--fail', fixture.fullStackName('sso-perm-set-without-managed-policy')], + ), + ).rejects + .toThrow('exited with error'); +})); + +integTest('cdk diff --security-only --fail exits when security diff for sso-perm-set-with-managed-policy', withDefaultFixture(async (fixture) => { + await expect( + fixture.cdk( + ['diff', '--security-only', '--fail', fixture.fullStackName('sso-perm-set-with-managed-policy')], + ), + ).rejects + .toThrow('exited with error'); +})); + +integTest('cdk diff --security-only --fail exits when security diff for sso-assignment', withDefaultFixture(async (fixture) => { + await expect( + fixture.cdk( + ['diff', '--security-only', '--fail', fixture.fullStackName('sso-assignment')], + ), + ).rejects + .toThrow('exited with error'); +})); + integTest('cdk diff --security-only --fail exits when security changes are present', withDefaultFixture(async (fixture) => { const stackName = 'iam-test'; await expect(fixture.cdk(['diff', '--security-only', '--fail', fixture.fullStackName(stackName)])).rejects.toThrow('exited with error'); diff --git a/packages/@aws-cdk/cloudformation-diff/lib/diff/types.ts b/packages/@aws-cdk/cloudformation-diff/lib/diff/types.ts index b8e78e4281f6c..4b563f434a562 100644 --- a/packages/@aws-cdk/cloudformation-diff/lib/diff/types.ts +++ b/packages/@aws-cdk/cloudformation-diff/lib/diff/types.ts @@ -128,11 +128,11 @@ export class TemplateDiff implements ITemplateDiff { continue; } - if (!resourceChange.newResourceType) { + if (!resourceChange.resourceType) { continue; } - const newTypeProps = loadResourceModel(resourceChange.newResourceType)?.properties || {}; + const newTypeProps = loadResourceModel(resourceChange.resourceType)?.properties || {}; for (const [propertyName, prop] of Object.entries(newTypeProps)) { const propScrutinyType = prop.scrutinizable || PropertyScrutinyType.None; if (scrutinyTypes.includes(propScrutinyType)) { diff --git a/packages/@aws-cdk/cloudformation-diff/lib/format.ts b/packages/@aws-cdk/cloudformation-diff/lib/format.ts index 724af468c2f45..45341b24c0730 100644 --- a/packages/@aws-cdk/cloudformation-diff/lib/format.ts +++ b/packages/@aws-cdk/cloudformation-diff/lib/format.ts @@ -382,6 +382,19 @@ class Formatter { this.printSectionHeader('IAM Policy Changes'); this.print(formatTable(this.deepSubstituteBracedLogicalIds(changes.summarizeManagedPolicies()), this.stream.columns)); } + + if (changes.ssoPermissionSets.hasChanges || changes.ssoInstanceACAConfigs.hasChanges || changes.ssoAssignments.hasChanges) { + this.printSectionHeader('IAM Identity Center Changes'); + if (changes.ssoPermissionSets.hasChanges) { + this.print(formatTable(this.deepSubstituteBracedLogicalIds(changes.summarizeSsoPermissionSets()), this.stream.columns)); + } + if (changes.ssoInstanceACAConfigs.hasChanges) { + this.print(formatTable(this.deepSubstituteBracedLogicalIds(changes.summarizeSsoInstanceACAConfigs()), this.stream.columns)); + } + if (changes.ssoAssignments.hasChanges) { + this.print(formatTable(this.deepSubstituteBracedLogicalIds(changes.summarizeSsoAssignments()), this.stream.columns)); + } + } } public formatSecurityGroupChanges(changes: SecurityGroupChanges) { diff --git a/packages/@aws-cdk/cloudformation-diff/lib/iam/iam-changes.ts b/packages/@aws-cdk/cloudformation-diff/lib/iam/iam-changes.ts index 017ed6bcd904f..9f0f0fae69498 100644 --- a/packages/@aws-cdk/cloudformation-diff/lib/iam/iam-changes.ts +++ b/packages/@aws-cdk/cloudformation-diff/lib/iam/iam-changes.ts @@ -1,5 +1,6 @@ import { PropertyScrutinyType, ResourceScrutinyType } from '@aws-cdk/service-spec-types'; import * as chalk from 'chalk'; +import { ISsoInstanceACAConfig, ISsoPermissionSet, SsoAssignment, SsoInstanceACAConfig, SsoPermissionSet } from './iam-identity-center'; import { ManagedPolicyAttachment, ManagedPolicyJson } from './managed-policy'; import { parseLambdaPermission, parseStatements, Statement, StatementJson } from './statement'; import { MaybeParsed } from '../diff/maybe-parsed'; @@ -14,7 +15,7 @@ export interface IamChangesProps { } /** - * Changes to IAM statements + * Changes to IAM statements and IAM identity center */ export class IamChanges { public static IamPropertyScrutinies = [ @@ -27,10 +28,17 @@ export class IamChanges { ResourceScrutinyType.ResourcePolicyResource, ResourceScrutinyType.IdentityPolicyResource, ResourceScrutinyType.LambdaPermission, + ResourceScrutinyType.SsoAssignmentResource, + ResourceScrutinyType.SsoInstanceACAConfigResource, + ResourceScrutinyType.SsoPermissionSet, ]; + // each entry in a DiffableCollection is used to generate a single row of the security changes table that is presented for cdk diff and cdk deploy. public readonly statements = new DiffableCollection(); public readonly managedPolicies = new DiffableCollection(); + public readonly ssoPermissionSets = new DiffableCollection(); + public readonly ssoAssignments = new DiffableCollection(); + public readonly ssoInstanceACAConfigs = new DiffableCollection(); constructor(props: IamChangesProps) { for (const propertyChange of props.propertyChanges) { @@ -42,10 +50,17 @@ export class IamChanges { this.statements.calculateDiff(); this.managedPolicies.calculateDiff(); + this.ssoPermissionSets.calculateDiff(); + this.ssoAssignments.calculateDiff(); + this.ssoInstanceACAConfigs.calculateDiff(); } public get hasChanges() { - return this.statements.hasChanges || this.managedPolicies.hasChanges; + return (this.statements.hasChanges + || this.managedPolicies.hasChanges + || this.ssoPermissionSets.hasChanges + || this.ssoAssignments.hasChanges + || this.ssoInstanceACAConfigs.hasChanges); } /** @@ -57,7 +72,10 @@ export class IamChanges { public get permissionsBroadened(): boolean { return this.statements.additions.some(s => !s.isNegativeStatement) || this.statements.removals.some(s => s.isNegativeStatement) - || this.managedPolicies.hasAdditions; + || this.managedPolicies.hasAdditions + || this.ssoPermissionSets.hasAdditions + || this.ssoAssignments.hasAdditions + || this.ssoInstanceACAConfigs.hasAdditions; } /** @@ -83,7 +101,7 @@ export class IamChanges { for (const statement of this.statements.removals) { const renderedStatement = statement.render(); ret.push([ - chalk.red('-'), + '-', renderedStatement.resource, renderedStatement.effect, renderedStatement.action, @@ -127,6 +145,121 @@ export class IamChanges { return ret; } + public summarizeSsoAssignments(): string[][] { + const ret: string[][] = []; + const header = ['', 'Resource', 'InstanceArn', 'PermissionSetArn', 'PrincipalId', 'PrincipalType', 'TargetId', 'TargetType']; + + for (const att of this.ssoAssignments.additions) { + ret.push([ + '+', + att.cfnLogicalId || '', + att.ssoInstanceArn || '', + att.permissionSetArn || '', + att.principalId || '', + att.principalType || '', + att.targetId || '', + att.targetType || '', + ].map(s => chalk.green(s))); + } + for (const att of this.ssoAssignments.removals) { + ret.push([ + '-', + att.cfnLogicalId || '', + att.ssoInstanceArn || '', + att.permissionSetArn || '', + att.principalId || '', + att.principalType || '', + att.targetId || '', + att.targetType || '', + ].map(s => chalk.red(s))); + } + + // Sort by resource name to ensure a unique value is used for sorting + ret.sort(makeComparator((row: string[]) => [row[1]])); + ret.splice(0, 0, header); + + return ret; + } + + public summarizeSsoInstanceACAConfigs(): string[][] { + const ret: string[][] = []; + const header = ['', 'Resource', 'InstanceArn', 'AccessControlAttributes']; + + function formatAccessControlAttribute(aca: ISsoInstanceACAConfig.AccessControlAttribute): string { + return `Key: ${aca?.Key}, Values: [${aca?.Value?.Source.join(', ')}]`; + } + + for (const att of this.ssoInstanceACAConfigs.additions) { + ret.push([ + '+', + att.cfnLogicalId || '', + att.ssoInstanceArn || '', + att.accessControlAttributes?.map(formatAccessControlAttribute).join('\n') || '', + ].map(s => chalk.green(s))); + } + for (const att of this.ssoInstanceACAConfigs.removals) { + ret.push([ + '-', + att.cfnLogicalId || '', + att.ssoInstanceArn || '', + att.accessControlAttributes?.map(formatAccessControlAttribute).join('\n') || '', + ].map(s => chalk.red(s))); + } + + // Sort by resource name to ensure a unique value is used for sorting + ret.sort(makeComparator((row: string[]) => [row[1]])); + ret.splice(0, 0, header); + + return ret; + } + + public summarizeSsoPermissionSets(): string[][] { + const ret: string[][] = []; + const header = ['', 'Resource', 'InstanceArn', 'PermissionSet name', 'PermissionsBoundary', 'CustomerManagedPolicyReferences']; + + function formatManagedPolicyRef(s: ISsoPermissionSet.CustomerManagedPolicyReference | undefined): string { + return `Name: ${s?.Name || ''}, Path: ${s?.Path || ''}`; + } + + function formatSsoPermissionsBoundary(ssoPb: ISsoPermissionSet.PermissionsBoundary | undefined): string { + // ManagedPolicyArn OR CustomerManagedPolicyReference can be specified -- but not both. + if (ssoPb?.ManagedPolicyArn !== undefined) { + return `ManagedPolicyArn: ${ssoPb?.ManagedPolicyArn || ''}`; + } else if (ssoPb?.CustomerManagedPolicyReference !== undefined) { + return `CustomerManagedPolicyReference: {\n ${formatManagedPolicyRef(ssoPb?.CustomerManagedPolicyReference)}\n}`; + } else { + return ''; + } + } + + for (const att of this.ssoPermissionSets.additions) { + ret.push([ + '+', + att.cfnLogicalId || '', + att.ssoInstanceArn || '', + att.name || '', + formatSsoPermissionsBoundary(att.ssoPermissionsBoundary), + att.ssoCustomerManagedPolicyReferences?.map(formatManagedPolicyRef).join('\n') || '', + ].map(s => chalk.green(s))); + } + for (const att of this.ssoPermissionSets.removals) { + ret.push([ + '-', + att.cfnLogicalId || '', + att.ssoInstanceArn || '', + att.name || '', + formatSsoPermissionsBoundary(att.ssoPermissionsBoundary), + att.ssoCustomerManagedPolicyReferences?.map(formatManagedPolicyRef).join('\n') || '', + ].map(s => chalk.red(s))); + } + + // Sort by resource name to ensure a unique value is used for sorting + ret.sort(makeComparator((row: string[]) => [row[1]])); + ret.splice(0, 0, header); + + return ret; + } + /** * Return a machine-readable version of the changes. * This is only used in tests. @@ -178,6 +311,18 @@ export class IamChanges { this.statements.addOld(...this.readLambdaStatements(resourceChange.oldProperties)); this.statements.addNew(...this.readLambdaStatements(resourceChange.newProperties)); break; + case ResourceScrutinyType.SsoPermissionSet: + this.ssoPermissionSets.addOld(...this.readSsoPermissionSet(resourceChange.oldProperties, resourceChange.resourceLogicalId)); + this.ssoPermissionSets.addNew(...this.readSsoPermissionSet(resourceChange.newProperties, resourceChange.resourceLogicalId)); + break; + case ResourceScrutinyType.SsoAssignmentResource: + this.ssoAssignments.addOld(...this.readSsoAssignments(resourceChange.oldProperties, resourceChange.resourceLogicalId)); + this.ssoAssignments.addNew(...this.readSsoAssignments(resourceChange.newProperties, resourceChange.resourceLogicalId)); + break; + case ResourceScrutinyType.SsoInstanceACAConfigResource: + this.ssoInstanceACAConfigs.addOld(...this.readSsoInstanceACAConfigs(resourceChange.oldProperties, resourceChange.resourceLogicalId)); + this.ssoInstanceACAConfigs.addNew(...this.readSsoInstanceACAConfigs(resourceChange.newProperties, resourceChange.resourceLogicalId)); + break; } } @@ -213,6 +358,48 @@ export class IamChanges { }); } + private readSsoInstanceACAConfigs(properties: any, logicalId: string): SsoInstanceACAConfig[] { + if (properties === undefined) { return []; } + + properties = renderIntrinsics(properties); + + return [new SsoInstanceACAConfig({ + cfnLogicalId: '${' + logicalId + '}', + ssoInstanceArn: properties.InstanceArn, + accessControlAttributes: properties.AccessControlAttributes, + })]; + } + + private readSsoAssignments(properties: any, logicalId: string): SsoAssignment[] { + if (properties === undefined) { return []; } + + properties = renderIntrinsics(properties); + + return [new SsoAssignment({ + cfnLogicalId: '${' + logicalId + '}', + ssoInstanceArn: properties.InstanceArn, + permissionSetArn: properties.PermissionSetArn, + principalId: properties.PrincipalId, + principalType: properties.PrincipalType, + targetId: properties.TargetId, + targetType: properties.TargetType, + })]; + } + + private readSsoPermissionSet(properties: any, logicalId: string): SsoPermissionSet[] { + if (properties === undefined) { return []; } + + properties = renderIntrinsics(properties); + + return [new SsoPermissionSet({ + cfnLogicalId: '${' + logicalId + '}', + name: properties.Name, + ssoInstanceArn: properties.InstanceArn, + ssoCustomerManagedPolicyReferences: properties.CustomerManagedPolicyReferences, + ssoPermissionsBoundary: properties.PermissionsBoundary, + })]; + } + private readResourceStatements(policy: any, logicalId: string): Statement[] { if (policy === undefined) { return []; } diff --git a/packages/@aws-cdk/cloudformation-diff/lib/iam/iam-identity-center.ts b/packages/@aws-cdk/cloudformation-diff/lib/iam/iam-identity-center.ts new file mode 100644 index 0000000000000..4818d277865ca --- /dev/null +++ b/packages/@aws-cdk/cloudformation-diff/lib/iam/iam-identity-center.ts @@ -0,0 +1,116 @@ +// namespace object imports won't work in the bundle for function exports +// eslint-disable-next-line @typescript-eslint/no-require-imports +const deepEqual = require('fast-deep-equal'); + +/** + * This namespace should be a subset of the L1 CfnPermissionSet, other than + * capitalization, since the values come from from a parsed CFN template. + */ +export namespace ISsoPermissionSet { + export interface Props { + readonly name: string | undefined; + readonly cfnLogicalId: string | undefined; + readonly ssoInstanceArn: string | undefined; + readonly ssoPermissionsBoundary: ISsoPermissionSet.PermissionsBoundary | undefined; + readonly ssoCustomerManagedPolicyReferences: ISsoPermissionSet.CustomerManagedPolicyReference[] | undefined; + } + export interface PermissionsBoundary { + readonly CustomerManagedPolicyReference?: CustomerManagedPolicyReference; + readonly ManagedPolicyArn?: string; + } + export interface CustomerManagedPolicyReference { + readonly Name: string | undefined; + readonly Path: string | undefined; + } +} + +export class SsoPermissionSet implements ISsoPermissionSet.Props { + public readonly name: string | undefined; + public readonly cfnLogicalId: string | undefined; + public readonly ssoInstanceArn: string | undefined; + public readonly ssoPermissionsBoundary: ISsoPermissionSet.PermissionsBoundary | undefined; + public readonly ssoCustomerManagedPolicyReferences: ISsoPermissionSet.CustomerManagedPolicyReference[] | undefined; + + constructor(props: ISsoPermissionSet.Props) { + this.cfnLogicalId = props.cfnLogicalId; + this.name = props.name; + this.ssoInstanceArn = props.ssoInstanceArn; + this.ssoPermissionsBoundary = props.ssoPermissionsBoundary; + this.ssoCustomerManagedPolicyReferences = props.ssoCustomerManagedPolicyReferences; + } + + public equal(other: SsoPermissionSet): boolean { + return deepEqual(this, other); + } +} + +export namespace ISsoAssignment { + export interface Props { + readonly ssoInstanceArn: string | undefined; + readonly cfnLogicalId: string | undefined; + readonly permissionSetArn: string | undefined; + readonly principalId: string | undefined; + readonly principalType: string | undefined; + readonly targetId: string | undefined; + readonly targetType: string | undefined; + } +} + +export class SsoAssignment implements ISsoAssignment.Props { + public readonly cfnLogicalId: string | undefined; + public readonly ssoInstanceArn: string | undefined; + public readonly permissionSetArn: string | undefined; + public readonly principalId: string | undefined; + public readonly principalType: string | undefined; + public readonly targetId: string | undefined; + public readonly targetType: string | undefined; + + constructor(props: ISsoAssignment.Props) { + this.cfnLogicalId = props.cfnLogicalId; + this.ssoInstanceArn = props.ssoInstanceArn; + this.permissionSetArn = props.permissionSetArn; + this.principalId = props.principalId; + this.principalType = props.principalType; + this.targetId = props.targetId; + this.targetType = props.targetType; + } + + public equal(other: SsoAssignment): boolean { + return deepEqual(this, other); + } +} + +/** + * AWS::SSO::InstanceAccessControlAttributeConfiguration + */ +export interface ISsoInstanceACAConfigProps { + ssoInstanceArn: string; +} +export namespace ISsoInstanceACAConfig { + export type AccessControlAttribute = { + Key: string | undefined; + Value: { Source: string[] } | undefined; + } | undefined; + + export interface Props { + readonly ssoInstanceArn: string | undefined; + readonly cfnLogicalId: string | undefined; + readonly accessControlAttributes?: AccessControlAttribute[] | undefined; + } +} + +export class SsoInstanceACAConfig implements ISsoInstanceACAConfig.Props { + public readonly cfnLogicalId: string | undefined; + public readonly ssoInstanceArn: string | undefined; + public readonly accessControlAttributes?: ISsoInstanceACAConfig.AccessControlAttribute[] | undefined; + + constructor(props: ISsoInstanceACAConfig.Props) { + this.cfnLogicalId = props.cfnLogicalId; + this.ssoInstanceArn = props.ssoInstanceArn; + this.accessControlAttributes = props.accessControlAttributes; + } + + public equal(other: SsoInstanceACAConfig): boolean { + return deepEqual(this, other); + } +} \ No newline at end of file diff --git a/packages/@aws-cdk/cloudformation-diff/test/iam/detect-changes.test.ts b/packages/@aws-cdk/cloudformation-diff/test/iam/detect-changes.test.ts index bc2638029ad90..4d0ff06c3b8ed 100644 --- a/packages/@aws-cdk/cloudformation-diff/test/iam/detect-changes.test.ts +++ b/packages/@aws-cdk/cloudformation-diff/test/iam/detect-changes.test.ts @@ -1,8 +1,9 @@ +import * as chalk from 'chalk'; import { fullDiff } from '../../lib'; import { MaybeParsed } from '../../lib/diff/maybe-parsed'; import { IamChangesJson } from '../../lib/iam/iam-changes'; import { deepRemoveUndefined } from '../../lib/util'; -import { poldoc, policy, resource, role, template } from '../util'; +import { largeSsoPermissionSet, poldoc, policy, resource, role, template } from '../util'; test('shows new AssumeRolePolicyDocument', () => { // WHEN @@ -457,6 +458,347 @@ test('supports Fn::If in the elements of an array-typed property of Role', () => expect(changedPolicies[principalColumn]).toContain('AWS:${MyRole}'); }); +test('removal of managedPolicies is detected', () => { + // WHEN + const diff = fullDiff(template({ + SomeRole: resource('AWS::IAM::Role', { + ManagedPolicyArns: ['arn:policy'], + }), + }), {}); + + // THEN + + const managedPolicySummary = diff.iamChanges.summarizeManagedPolicies(); + expect(managedPolicySummary).toEqual( + [ + ['', 'Resource', 'Managed Policy ARN'], + [ + '-', + '${SomeRole}', + 'arn:policy', + ].map(s => chalk.red(s)), + ], + ); +}); + +test('can summarize ssoPermissionSet changes with PermissionsBoundary.ManagedPolicyArn', () => { + // WHEN + const diff = fullDiff({}, template({ + MySsoPermissionSet: resource( + 'AWS::SSO::PermissionSet', + { + Name: 'BestName', + InstanceArn: 'arn:aws:sso:::instance/ssoins-1111111111111111', + ManagedPolicies: ['arn:aws:iam::aws:policy/AlwaysBeManaging'], + PermissionsBoundary: { ManagedPolicyArn: 'arn:aws:iam::aws:policy/GreatAtManaging' }, + CustomerManagedPolicyReferences: [], + InlinePolicy: {}, + }, + ), + })); + + // THEN + expect(diff.iamChanges.summarizeSsoPermissionSets()).toEqual( + [ + ['', 'Resource', 'InstanceArn', 'PermissionSet name', 'PermissionsBoundary', 'CustomerManagedPolicyReferences'], + [ + '+', + '${MySsoPermissionSet}', + 'arn:aws:sso:::instance/ssoins-1111111111111111', + 'BestName', + 'ManagedPolicyArn: arn:aws:iam::aws:policy/GreatAtManaging', + '', + ].map(s => chalk.green(s)), + ], + ); + expect(diff.iamChanges.summarizeManagedPolicies()).toEqual( + [ + ['', 'Resource', 'Managed Policy ARN'], + [ + '+', + '${MySsoPermissionSet}', + 'arn:aws:iam::aws:policy/AlwaysBeManaging', + ].map(s => chalk.green(s)), + ], + ); +}); + +test('can summarize negative ssoPermissionSet changes with PermissionsBoundary.CustomerManagedPolicyReference', () => { + // WHEN + const diff = fullDiff(largeSsoPermissionSet(), {}); + + // THEN + const ssoPermSetSummary = diff.iamChanges.summarizeSsoPermissionSets(); + expect(ssoPermSetSummary).toEqual( + [ + ['', 'Resource', 'InstanceArn', 'PermissionSet name', 'PermissionsBoundary', 'CustomerManagedPolicyReferences'], + [ + '-', + '${MySsoPermissionSet}', + 'arn:aws:sso:::instance/ssoins-1111111111111111', + 'PleaseWork', + 'CustomerManagedPolicyReference: {\n Name: why, Path: {"Fn::If":["SomeCondition","/how","/work"]}\n}', + 'Name: arn:aws:iam::aws:role/Silly, Path: /my\nName: LIFE, Path: ', + ].map(s => chalk.red(s)), + ], + ); + + const managedPolicySummary = diff.iamChanges.summarizeManagedPolicies(); + expect(managedPolicySummary).toEqual( + [ + ['', 'Resource', 'Managed Policy ARN'], + [ + '-', + '${MySsoPermissionSet}', + '{"Fn::If":["SomeCondition",["then-managed-policy-arn"],["else-managed-policy-arn"]]}', + ].map(s => chalk.red(s)), + ], + ); + + const iamStatementSummary = diff.iamChanges.summarizeStatements(); + expect(iamStatementSummary).toEqual( + [ + ['', 'Resource', 'Effect', 'Action', 'Principal', 'Condition'], + [ + '-', + '${MySsoPermissionSet.Arn}', + 'Allow', + 'iam:CreateServiceLinkedRole', + '', + '', + ].map(s => chalk.red(s)), + ], + ); +}); + +test('can summarize ssoPermissionSet changes with PermissionsBoundary.CustomerManagedPolicyReference', () => { + // WHEN + const diff = fullDiff({}, largeSsoPermissionSet()); + + // THEN + expect(diff.iamChanges.summarizeSsoPermissionSets()).toEqual( + [ + ['', 'Resource', 'InstanceArn', 'PermissionSet name', 'PermissionsBoundary', 'CustomerManagedPolicyReferences'], + [ + '+', + '${MySsoPermissionSet}', + 'arn:aws:sso:::instance/ssoins-1111111111111111', + 'PleaseWork', + 'CustomerManagedPolicyReference: {\n Name: why, Path: {"Fn::If":["SomeCondition","/how","/work"]}\n}', + 'Name: arn:aws:iam::aws:role/Silly, Path: /my\nName: LIFE, Path: ', + ].map(s => chalk.green(s)), + ], + ); + expect(diff.iamChanges.summarizeManagedPolicies()).toEqual( + [ + ['', 'Resource', 'Managed Policy ARN'], + [ + '+', + '${MySsoPermissionSet}', + '{"Fn::If":["SomeCondition",["then-managed-policy-arn"],["else-managed-policy-arn"]]}', + ].map(s => chalk.green(s)), + ], + ); + + const iamStatementSummary = diff.iamChanges.summarizeStatements(); + expect(iamStatementSummary).toEqual( + [ + ['', 'Resource', 'Effect', 'Action', 'Principal', 'Condition'], + [ + '+', + '${MySsoPermissionSet.Arn}', + 'Allow', + 'iam:CreateServiceLinkedRole', + '', + '', + ].map(s => chalk.green(s)), + ], + ); +}); + +test('can summarize addition of ssoAssignment', () => { + // WHEN + const diff = fullDiff( + template(resource('', {})), + template({ + MyAssignment: resource('AWS::SSO::Assignment', + { + InstanceArn: 'arn:aws:sso:::instance/ssoins-1111111111111111', + PermissionSetArn: { + 'Fn::GetAtt': [ + 'MyOtherCfnPermissionSet', + 'PermissionSetArn', + ], + }, + PrincipalId: '33333333-3333-4444-5555-777777777777', + PrincipalType: 'USER', + TargetId: '222222222222', + TargetType: 'AWS_ACCOUNT', + }), + }), + ); + + // THEN + expect(diff.iamChanges.summarizeManagedPolicies()).toEqual( + [['', 'Resource', 'Managed Policy ARN']], + ); + expect(diff.iamChanges.summarizeStatements()).toEqual( + [['', 'Resource', 'Effect', 'Action', 'Principal', 'Condition']], + ); + + const ssoAssignmentSummary = diff.iamChanges.summarizeSsoAssignments(); + expect(ssoAssignmentSummary).toEqual( + [ + ['', 'Resource', 'InstanceArn', 'PermissionSetArn', 'PrincipalId', 'PrincipalType', 'TargetId', 'TargetType'], + [ + '+', + '${MyAssignment}', + 'arn:aws:sso:::instance/ssoins-1111111111111111', + '${MyOtherCfnPermissionSet.PermissionSetArn}', + '33333333-3333-4444-5555-777777777777', + 'USER', + '222222222222', + 'AWS_ACCOUNT', + ].map(s => chalk.green(s)), + ], + ); + +}); + +test('can summarize addition of SsoInstanceACAConfigs', () => { + // WHEN + const diff = fullDiff( + template(resource('', {})), + template({ + MyIACAConfiguration: resource('AWS::SSO::InstanceAccessControlAttributeConfiguration', + { + AccessControlAttributes: [ + { Key: 'first', Value: { Source: ['a'] } }, + { Key: 'second', Value: { Source: ['b'] } }, + { Key: 'third', Value: { Source: ['c'] } }, + { Key: 'fourth', Value: { Source: ['d'] } }, + { Key: 'fifth', Value: { Source: ['e'] } }, + { Key: 'sixth', Value: { Source: ['f'] } }, + ], + InstanceArn: 'arn:aws:sso:::instance/ssoins-72234e1d20e1e68d', + }), + }), + ); + + // THEN + expect(diff.iamChanges.summarizeManagedPolicies()).toEqual( + [['', 'Resource', 'Managed Policy ARN']], + ); + expect(diff.iamChanges.summarizeStatements()).toEqual( + [['', 'Resource', 'Effect', 'Action', 'Principal', 'Condition']], + ); + + const ssoIACAConfig = diff.iamChanges.summarizeSsoInstanceACAConfigs(); + expect(ssoIACAConfig).toEqual( + [ + ['', 'Resource', 'InstanceArn', 'AccessControlAttributes'], + [ + '+', + '${MyIACAConfiguration}', + 'arn:aws:sso:::instance/ssoins-72234e1d20e1e68d', + 'Key: first, Values: [a]\nKey: second, Values: [b]\nKey: third, Values: [c]\nKey: fourth, Values: [d]\nKey: fifth, Values: [e]\nKey: sixth, Values: [f]', + ].map(s => chalk.green(s)), + ], + ); + +}); + +test('can summarize negation of SsoInstanceACAConfigs', () => { + // WHEN + const diff = fullDiff( + template({ + MyIACAConfiguration: resource('AWS::SSO::InstanceAccessControlAttributeConfiguration', + { + AccessControlAttributes: [ + { Key: 'first', Value: { Source: ['a'] } }, + { Key: 'second', Value: { Source: ['b'] } }, + { Key: 'third', Value: { Source: ['c'] } }, + { Key: 'fourth', Value: { Source: ['d'] } }, + { Key: 'fifth', Value: { Source: ['e'] } }, + { Key: 'sixth', Value: { Source: ['f'] } }, + ], + InstanceArn: 'arn:aws:sso:::instance/ssoins-72234e1d20e1e68d', + }), + }), + template(resource('', {})), + ); + + // THEN + expect(diff.iamChanges.summarizeManagedPolicies()).toEqual( + [['', 'Resource', 'Managed Policy ARN']], + ); + expect(diff.iamChanges.summarizeStatements()).toEqual( + [['', 'Resource', 'Effect', 'Action', 'Principal', 'Condition']], + ); + + const ssoIACAConfig = diff.iamChanges.summarizeSsoInstanceACAConfigs(); + expect(ssoIACAConfig).toEqual( + [ + ['', 'Resource', 'InstanceArn', 'AccessControlAttributes'], + [ + '-', + '${MyIACAConfiguration}', + 'arn:aws:sso:::instance/ssoins-72234e1d20e1e68d', + 'Key: first, Values: [a]\nKey: second, Values: [b]\nKey: third, Values: [c]\nKey: fourth, Values: [d]\nKey: fifth, Values: [e]\nKey: sixth, Values: [f]', + ].map(s => chalk.red(s)), + ], + ); + +}); + +test('can summarize negation of ssoAssignment', () => { + // WHEN + const diff = fullDiff( + template({ + MyAssignment: resource('AWS::SSO::Assignment', + { + InstanceArn: 'arn:aws:sso:::instance/ssoins-1111111111111111', + PermissionSetArn: { + 'Fn::GetAtt': [ + 'MyOtherCfnPermissionSet', + 'PermissionSetArn', + ], + }, + PrincipalId: '33333333-3333-4444-5555-777777777777', + PrincipalType: 'USER', + TargetId: '222222222222', + TargetType: 'AWS_ACCOUNT', + }), + }), + template(resource('', {})), + ); + + // THEN + expect(diff.iamChanges.summarizeManagedPolicies()).toEqual( + [['', 'Resource', 'Managed Policy ARN']], + ); + expect(diff.iamChanges.summarizeStatements()).toEqual( + [['', 'Resource', 'Effect', 'Action', 'Principal', 'Condition']], + ); + + const ssoAssignmentSummary = diff.iamChanges.summarizeSsoAssignments(); + expect(ssoAssignmentSummary).toEqual( + [ + ['', 'Resource', 'InstanceArn', 'PermissionSetArn', 'PrincipalId', 'PrincipalType', 'TargetId', 'TargetType'], + [ + '-', + '${MyAssignment}', + 'arn:aws:sso:::instance/ssoins-1111111111111111', + '${MyOtherCfnPermissionSet.PermissionSetArn}', + '33333333-3333-4444-5555-777777777777', + 'USER', + '222222222222', + 'AWS_ACCOUNT', + ].map(s => chalk.red(s)), + ], + ); +}); + /** * Assume that all types are parsed, and unwrap them */ diff --git a/packages/@aws-cdk/cloudformation-diff/test/util.ts b/packages/@aws-cdk/cloudformation-diff/test/util.ts index f94720435834b..bce0b48b214eb 100644 --- a/packages/@aws-cdk/cloudformation-diff/test/util.ts +++ b/packages/@aws-cdk/cloudformation-diff/test/util.ts @@ -20,3 +20,56 @@ export function poldoc(...statements: any[]) { Statement: statements, }; } + +export function largeSsoPermissionSet() { + return template({ + MySsoPermissionSet: resource( + 'AWS::SSO::PermissionSet', + { + CustomerManagedPolicyReferences: [ + { + Name: 'arn:aws:iam::aws:role/Silly', + Path: '/my', + }, + { + Name: 'LIFE', + }, + ], + InlinePolicy: { + Version: '2012-10-17', + Statement: [ + { + Sid: 'VisualEditor0', + Effect: 'Allow', + Action: 'iam:CreateServiceLinkedRole', + Resource: [ + '*', + ], + }, + ], + }, + InstanceArn: 'arn:aws:sso:::instance/ssoins-1111111111111111', + ManagedPolicies: { + 'Fn::If': [ + 'SomeCondition', + ['then-managed-policy-arn'], + ['else-managed-policy-arn'], + ], + }, + Name: 'PleaseWork', + PermissionsBoundary: { + CustomerManagedPolicyReference: { + Name: 'why', + Path: { + 'Fn::If': [ + 'SomeCondition', + '/how', + '/work', + ], + }, + }, + }, + }, + ), + }); +}