Skip to content

Commit 84e20f8

Browse files
authored
fix(ecr): grants for cross-account principals result in failed deployments (#16376)
When performing grants in ECR's Repository class for principals from other accounts, we put the ARN of the principal inside the Resource Policy of the Repository. However, ECR validates that all principals included in its Policy exist at the time of deploying the Repository, so if this cross-account principal was not created before the Repository, its deployment would fail. Detect that situation in the Repository class, and trust the entiure account of the principal if this situation happens. This was spotted by a customer when using the `TagParameterContainerImage` class. Fixes #15070 ---- *By submitting this pull request, I confirm that my contribution is made under the terms of the Apache-2.0 license*
1 parent def2971 commit 84e20f8

File tree

15 files changed

+341
-79
lines changed

15 files changed

+341
-79
lines changed

packages/@aws-cdk/aws-ecr/jest.config.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ module.exports = {
55
global: {
66
...baseConfig.coverageThreshold.global,
77
branches: 70,
8+
statements: 78,
89
},
910
},
1011
};

packages/@aws-cdk/aws-ecr/lib/repository.ts

Lines changed: 77 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { EOL } from 'os';
22
import * as events from '@aws-cdk/aws-events';
33
import * as iam from '@aws-cdk/aws-iam';
44
import * as kms from '@aws-cdk/aws-kms';
5-
import { ArnFormat, IResource, Lazy, RemovalPolicy, Resource, Stack, Token } from '@aws-cdk/core';
5+
import { ArnFormat, IResource, Lazy, RemovalPolicy, Resource, Stack, Tags, Token, TokenComparison } from '@aws-cdk/core';
66
import { IConstruct, Construct } from 'constructs';
77
import { CfnRepository } from './ecr.generated';
88
import { LifecycleRule, TagStatus } from './lifecycle';
@@ -279,17 +279,49 @@ export abstract class RepositoryBase extends Resource implements IRepository {
279279
rule.addTarget(options.target);
280280
return rule;
281281
}
282+
282283
/**
283284
* Grant the given principal identity permissions to perform the actions on this repository
284285
*/
285286
public grant(grantee: iam.IGrantable, ...actions: string[]) {
286-
return iam.Grant.addToPrincipalOrResource({
287-
grantee,
288-
actions,
289-
resourceArns: [this.repositoryArn],
290-
resourceSelfArns: [],
291-
resource: this,
292-
});
287+
const crossAccountPrincipal = this.unsafeCrossAccountResourcePolicyPrincipal(grantee);
288+
if (crossAccountPrincipal) {
289+
// If the principal is from a different account,
290+
// that means addToPrincipalOrResource() will update the Resource Policy of this repo to trust that principal.
291+
// However, ECR verifies that the principal used in the Policy exists,
292+
// and will error out if it doesn't.
293+
// Because of that, if the principal is a newly created resource,
294+
// and there is not a dependency relationship between the Stacks of this repo and the principal,
295+
// trust the entire account of the principal instead
296+
// (otherwise, deploying this repo will fail).
297+
// To scope down the permissions as much as possible,
298+
// only trust principals from that account with a specific tag
299+
const crossAccountPrincipalStack = Stack.of(crossAccountPrincipal);
300+
const roleTag = `${crossAccountPrincipalStack.stackName}_${crossAccountPrincipal.node.addr}`;
301+
Tags.of(crossAccountPrincipal).add('aws-cdk:id', roleTag);
302+
this.addToResourcePolicy(new iam.PolicyStatement({
303+
actions,
304+
principals: [new iam.AccountPrincipal(crossAccountPrincipalStack.account)],
305+
conditions: {
306+
StringEquals: { 'aws:PrincipalTag/aws-cdk:id': roleTag },
307+
},
308+
}));
309+
310+
return iam.Grant.addToPrincipal({
311+
grantee,
312+
actions,
313+
resourceArns: [this.repositoryArn],
314+
scope: this,
315+
});
316+
} else {
317+
return iam.Grant.addToPrincipalOrResource({
318+
grantee,
319+
actions,
320+
resourceArns: [this.repositoryArn],
321+
resourceSelfArns: [],
322+
resource: this,
323+
});
324+
}
293325
}
294326

295327
/**
@@ -319,6 +351,43 @@ export abstract class RepositoryBase extends Resource implements IRepository {
319351
'ecr:UploadLayerPart',
320352
'ecr:CompleteLayerUpload');
321353
}
354+
355+
/**
356+
* Returns the resource that backs the given IAM grantee if we cannot put a direct reference
357+
* to the grantee in the resource policy of this ECR repository,
358+
* and 'undefined' in case we can.
359+
*/
360+
private unsafeCrossAccountResourcePolicyPrincipal(grantee: iam.IGrantable): IConstruct | undefined {
361+
// A principal cannot be safely added to the Resource Policy of this ECR repository, if:
362+
// 1. The principal is from a different account, and
363+
// 2. The principal is a new resource (meaning, not just referenced), and
364+
// 3. The Stack this repo belongs to doesn't depend on the Stack the principal belongs to.
365+
366+
// condition #1
367+
const principal = grantee.grantPrincipal;
368+
const principalAccount = principal.principalAccount;
369+
if (!principalAccount) {
370+
return undefined;
371+
}
372+
const repoAndPrincipalAccountCompare = Token.compareStrings(this.env.account, principalAccount);
373+
if (repoAndPrincipalAccountCompare === TokenComparison.BOTH_UNRESOLVED ||
374+
repoAndPrincipalAccountCompare === TokenComparison.SAME) {
375+
return undefined;
376+
}
377+
378+
// condition #2
379+
if (!iam.principalIsOwnedResource(principal)) {
380+
return undefined;
381+
}
382+
383+
// condition #3
384+
const principalStack = Stack.of(principal);
385+
if (this.stack.dependencies.includes(principalStack)) {
386+
return undefined;
387+
}
388+
389+
return principal;
390+
}
322391
}
323392

324393
/**
@@ -491,7 +560,6 @@ export class Repository extends RepositoryBase {
491560
});
492561
}
493562

494-
495563
private static validateRepositoryName(physicalName: string) {
496564
const repositoryName = physicalName;
497565
if (!repositoryName || Token.isUnresolved(repositoryName)) {

packages/@aws-cdk/aws-ecr/test/repository.test.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -444,6 +444,36 @@ describe('repository', () => {
444444
}).toThrow('encryptionKey is specified, so \'encryption\' must be set to KMS (value: AES256)');
445445
});
446446

447+
test('removal policy is "Retain" by default', () => {
448+
// GIVEN
449+
const stack = new cdk.Stack();
450+
451+
// WHEN
452+
new ecr.Repository(stack, 'Repo');
453+
454+
// THEN
455+
Template.fromStack(stack).hasResource('AWS::ECR::Repository', {
456+
'Type': 'AWS::ECR::Repository',
457+
'DeletionPolicy': 'Retain',
458+
});
459+
});
460+
461+
test('"Delete" removal policy can be set explicitly', () => {
462+
// GIVEN
463+
const stack = new cdk.Stack();
464+
465+
// WHEN
466+
new ecr.Repository(stack, 'Repo', {
467+
removalPolicy: cdk.RemovalPolicy.DESTROY,
468+
});
469+
470+
// THEN
471+
Template.fromStack(stack).hasResource('AWS::ECR::Repository', {
472+
'Type': 'AWS::ECR::Repository',
473+
'DeletionPolicy': 'Delete',
474+
});
475+
});
476+
447477
describe('events', () => {
448478
test('onImagePushed without imageTag creates the correct event', () => {
449479
const stack = new cdk.Stack();

packages/@aws-cdk/aws-ecs/test/images/tag-parameter-container-image.test.ts

Lines changed: 39 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -77,51 +77,52 @@ describe('tag parameter container image', () => {
7777
'Fn::Join': ['', [
7878
'arn:',
7979
{ Ref: 'AWS::Partition' },
80-
':iam::service-account:role/servicestackionexecutionrolee7e2d9a783a54eb795f4',
80+
':iam::service-account:root',
8181
]],
8282
},
8383
},
84+
Condition: {
85+
StringEquals: {
86+
'aws:PrincipalTag/aws-cdk:id': 'ServiceStack_c8a38b9d3ed0e8d960dd0d679c0bab1612dafa96f5',
87+
},
88+
},
8489
}],
8590
},
8691
});
87-
Template.fromStack(serviceStack).hasResourceProperties('AWS::IAM::Role', {
88-
RoleName: 'servicestackionexecutionrolee7e2d9a783a54eb795f4',
92+
93+
Template.fromStack(serviceStack).hasResourceProperties('AWS::IAM::Policy', {
94+
PolicyDocument: Match.objectLike({
95+
Statement: Match.arrayWith([
96+
Match.objectLike({
97+
Action: [
98+
'ecr:BatchCheckLayerAvailability',
99+
'ecr:GetDownloadUrlForLayer',
100+
'ecr:BatchGetImage',
101+
],
102+
Effect: 'Allow',
103+
Resource: {
104+
'Fn::Join': ['', [
105+
'arn:',
106+
{ Ref: 'AWS::Partition' },
107+
`:ecr:us-west-1:pipeline-account:repository/${repositoryName}`,
108+
]],
109+
},
110+
}),
111+
Match.objectLike({
112+
Action: 'ecr:GetAuthorizationToken',
113+
Effect: 'Allow',
114+
Resource: '*',
115+
}),
116+
]),
117+
}),
89118
});
90-
Template.fromStack(serviceStack).hasResourceProperties('AWS::ECS::TaskDefinition', {
91-
ContainerDefinitions: [
92-
Match.objectLike({
93-
Image: {
94-
'Fn::Join': ['', [
95-
{
96-
'Fn::Select': [4, {
97-
'Fn::Split': [':', {
98-
'Fn::Join': ['', [
99-
'arn:',
100-
{ Ref: 'AWS::Partition' },
101-
`:ecr:us-west-1:pipeline-account:repository/${repositoryName}`,
102-
]],
103-
}],
104-
}],
105-
},
106-
'.dkr.ecr.',
107-
{
108-
'Fn::Select': [3, {
109-
'Fn::Split': [':', {
110-
'Fn::Join': ['', [
111-
'arn:',
112-
{ Ref: 'AWS::Partition' },
113-
`:ecr:us-west-1:pipeline-account:repository/${repositoryName}`,
114-
]],
115-
}],
116-
}],
117-
},
118-
'.',
119-
{ Ref: 'AWS::URLSuffix' },
120-
`/${repositoryName}:`,
121-
{ Ref: 'ServiceTaskDefinitionContainerImageTagParamCEC9D0BA' },
122-
]],
123-
},
124-
}),
119+
120+
Template.fromStack(serviceStack).hasResourceProperties('AWS::IAM::Role', {
121+
Tags: [
122+
{
123+
Key: 'aws-cdk:id',
124+
Value: 'ServiceStack_c8a38b9d3ed0e8d960dd0d679c0bab1612dafa96f5',
125+
},
125126
],
126127
});
127128
});

packages/@aws-cdk/aws-iam/lib/group.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,8 @@ import { IManagedPolicy } from './managed-policy';
66
import { Policy } from './policy';
77
import { PolicyStatement } from './policy-statement';
88
import { AddToPrincipalPolicyResult, ArnPrincipal, IPrincipal, PrincipalPolicyFragment } from './principals';
9+
import { AttachedPolicies } from './private/util';
910
import { IUser } from './user';
10-
import { AttachedPolicies } from './util';
1111

1212
/**
1313
* Represents an IAM Group.

packages/@aws-cdk/aws-iam/lib/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ export * from './oidc-provider';
1414
export * from './permissions-boundary';
1515
export * from './saml-provider';
1616
export * from './access-key';
17+
export * from './utils';
1718

1819
// AWS::IAM CloudFormation Resources:
1920
export * from './iam.generated';

packages/@aws-cdk/aws-iam/lib/managed-policy.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,9 @@ import { IGroup } from './group';
55
import { CfnManagedPolicy } from './iam.generated';
66
import { PolicyDocument } from './policy-document';
77
import { PolicyStatement } from './policy-statement';
8+
import { undefinedIfEmpty } from './private/util';
89
import { IRole } from './role';
910
import { IUser } from './user';
10-
import { undefinedIfEmpty } from './util';
1111

1212
/**
1313
* A managed policy

packages/@aws-cdk/aws-iam/lib/policy-statement.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import {
66
FederatedPrincipal, IPrincipal, PrincipalBase, PrincipalPolicyFragment, ServicePrincipal, ServicePrincipalOpts, validateConditionObject,
77
} from './principals';
88
import { normalizeStatement } from './private/postprocess-policy-document';
9-
import { LITERAL_STRING_KEY, mergePrincipal, sum } from './util';
9+
import { LITERAL_STRING_KEY, mergePrincipal, sum } from './private/util';
1010

1111
const ensureArrayOrUndefined = (field: any) => {
1212
if (field === undefined) {

packages/@aws-cdk/aws-iam/lib/policy.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,9 @@ import { IGroup } from './group';
44
import { CfnPolicy } from './iam.generated';
55
import { PolicyDocument } from './policy-document';
66
import { PolicyStatement } from './policy-statement';
7+
import { generatePolicyName, undefinedIfEmpty } from './private/util';
78
import { IRole } from './role';
89
import { IUser } from './user';
9-
import { generatePolicyName, undefinedIfEmpty } from './util';
1010

1111
/**
1212
* Represents an IAM Policy

packages/@aws-cdk/aws-iam/lib/principals.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,8 @@ import { IOpenIdConnectProvider } from './oidc-provider';
66
import { PolicyDocument } from './policy-document';
77
import { Condition, Conditions, PolicyStatement } from './policy-statement';
88
import { defaultAddPrincipalToAssumeRole } from './private/assume-role-policy';
9+
import { LITERAL_STRING_KEY, mergePrincipal } from './private/util';
910
import { ISamlProvider } from './saml-provider';
10-
import { LITERAL_STRING_KEY, mergePrincipal } from './util';
1111

1212
/**
1313
* Any object that has an associated principal that a permission can be granted to

0 commit comments

Comments
 (0)