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

fix(ecr): grants for cross-account principals result in failed deployments #16376

Merged
merged 8 commits into from Dec 9, 2022
1 change: 1 addition & 0 deletions packages/@aws-cdk/aws-ecr/jest.config.js
Expand Up @@ -5,6 +5,7 @@ module.exports = {
global: {
...baseConfig.coverageThreshold.global,
branches: 70,
statements: 79,
},
},
};
78 changes: 69 additions & 9 deletions packages/@aws-cdk/aws-ecr/lib/repository.ts
Expand Up @@ -2,7 +2,7 @@ import { EOL } from 'os';
import * as events from '@aws-cdk/aws-events';
import * as iam from '@aws-cdk/aws-iam';
import * as kms from '@aws-cdk/aws-kms';
import { ArnFormat, IResource, Lazy, RemovalPolicy, Resource, Stack, Token } from '@aws-cdk/core';
import { ArnFormat, IResource, Lazy, RemovalPolicy, Resource, Stack, Token, TokenComparison } from '@aws-cdk/core';
import { IConstruct, Construct } from 'constructs';
import { CfnRepository } from './ecr.generated';
import { LifecycleRule, TagStatus } from './lifecycle';
Expand Down Expand Up @@ -279,17 +279,41 @@ export abstract class RepositoryBase extends Resource implements IRepository {
rule.addTarget(options.target);
return rule;
}

/**
* Grant the given principal identity permissions to perform the actions on this repository
*/
public grant(grantee: iam.IGrantable, ...actions: string[]) {
return iam.Grant.addToPrincipalOrResource({
grantee,
actions,
resourceArns: [this.repositoryArn],
resourceSelfArns: [],
resource: this,
});
const accountToTrust = this.principalCannotBeSafelyAddedToResourcePolicy(grantee);
if (accountToTrust) {
// If the principal is from a different account,
// that means addToPrincipalOrResource() will update the Resource Policy of this repo to trust that principal.
// However, ECR verifies that the principal used in the Policy exists,
// and will error out if it doesn't.
// Because of that, if the principal is a newly created resource,
// and there is not a dependency relationship between the Stacks of this repo and the principal,
// trust the entire account of the principal instead
// (otherwise, deploying this repo will fail)
this.addToResourcePolicy(new iam.PolicyStatement({
actions,
principals: [new iam.AccountPrincipal(accountToTrust)],
}));

return iam.Grant.addToPrincipal({
grantee,
actions,
resourceArns: [this.repositoryArn],
scope: this,
});
} else {
return iam.Grant.addToPrincipalOrResource({
grantee,
actions,
resourceArns: [this.repositoryArn],
resourceSelfArns: [],
resource: this,
});
}
}

/**
Expand Down Expand Up @@ -319,6 +343,43 @@ export abstract class RepositoryBase extends Resource implements IRepository {
'ecr:UploadLayerPart',
'ecr:CompleteLayerUpload');
}

/**
* Returns the account ID of the account to put into the Resource Policy
* if we cannot put a reference to the principal itself there,
* and 'undefined' in case we can.
*/
private principalCannotBeSafelyAddedToResourcePolicy(grantee: iam.IGrantable): string | undefined {
Copy link
Contributor

@rix0rrr rix0rrr Aug 23, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can't this return the correct IGrantable instead?

private correctGrantable(grantee: IGrantable): IGrantable {
  if (isRoleInDifferentStack(grantee)) {
    return grantee.crossAccountSafeGrantable();
  }

  return grantee;
}

Copy link
Contributor Author

@skinny85 skinny85 Nov 7, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unfortunately, the code you suggested:

    return new AccountRootPrincipal(Stack.of(this.account), {
      conditions: {
        StringEquals: { 'aws:PrincipalTag/aws-cdk:id', this.roleTag },
      },
    });

doesn't work (conditions are attached to Policy Statements, not to Principals).

However, added the tag to the Role in a different way, let me know if that works!

// A principal cannot be safely added to the Resource Policy of this repo if:
// 1. The principal is from a different account.
// 2. The principal is a new resource (meaning, not imported).
// 3. The Stack this repo belongs to doesn't depend on the Stack the principal belongs to.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do these combine with AND or OR?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

And. Clarified in the comments.


// condition #1
const principal = grantee.grantPrincipal;
const principalAccount = principal.principalAccount;
if (!principalAccount) {
return undefined;
}
const repoAndPrincipalAccountCompare = Token.compareStrings(this.env.account, principalAccount);
if (repoAndPrincipalAccountCompare === TokenComparison.BOTH_UNRESOLVED ||
repoAndPrincipalAccountCompare === TokenComparison.SAME) {
return undefined;
}

// condition #2
if (!iam.principalIsOwnedResource(principal)) {
return undefined;
}

// condition #3
const principalStack = Stack.of(principal);
if (this.stack.dependencies.includes(principalStack)) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think this is safe or even likely to be true. Might as well leave this out.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actually, we already do this for KMS Keys, so I think it's not the worst idea.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You can't really know this. Dependencies probably get resolve really late, especially implicit ones.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I still think it's better to make this check than not (even if it doesn't work 100% of the time, it might work some of the time).

return undefined;
}

return principalAccount;
}
}

/**
Expand Down Expand Up @@ -491,7 +552,6 @@ export class Repository extends RepositoryBase {
});
}


private static validateRepositoryName(physicalName: string) {
const repositoryName = physicalName;
if (!repositoryName || Token.isUnresolved(repositoryName)) {
Expand Down
30 changes: 30 additions & 0 deletions packages/@aws-cdk/aws-ecr/test/repository.test.ts
Expand Up @@ -444,6 +444,36 @@ describe('repository', () => {
}).toThrow('encryptionKey is specified, so \'encryption\' must be set to KMS (value: AES256)');
});

test('removal policy is "Retain" by default', () => {
// GIVEN
const stack = new cdk.Stack();

// WHEN
new ecr.Repository(stack, 'Repo');

// THEN
Template.fromStack(stack).hasResource('AWS::ECR::Repository', {
'Type': 'AWS::ECR::Repository',
'DeletionPolicy': 'Retain',
});
});

test('"Delete" removal policy can be set explicitly', () => {
// GIVEN
const stack = new cdk.Stack();

// WHEN
new ecr.Repository(stack, 'Repo', {
removalPolicy: cdk.RemovalPolicy.DESTROY,
});

// THEN
Template.fromStack(stack).hasResource('AWS::ECR::Repository', {
'Type': 'AWS::ECR::Repository',
'DeletionPolicy': 'Delete',
});
});

describe('events', () => {
test('onImagePushed without imageTag creates the correct event', () => {
const stack = new cdk.Stack();
Expand Down
Expand Up @@ -77,52 +77,39 @@ describe('tag parameter container image', () => {
'Fn::Join': ['', [
'arn:',
{ Ref: 'AWS::Partition' },
':iam::service-account:role/servicestackionexecutionrolee7e2d9a783a54eb795f4',
':iam::service-account:root',
]],
},
},
}],
},
});
Template.fromStack(serviceStack).hasResourceProperties('AWS::IAM::Role', {
RoleName: 'servicestackionexecutionrolee7e2d9a783a54eb795f4',
});
Template.fromStack(serviceStack).hasResourceProperties('AWS::ECS::TaskDefinition', {
ContainerDefinitions: [
Match.objectLike({
Image: {
'Fn::Join': ['', [
{
'Fn::Select': [4, {
'Fn::Split': [':', {
'Fn::Join': ['', [
'arn:',
{ Ref: 'AWS::Partition' },
`:ecr:us-west-1:pipeline-account:repository/${repositoryName}`,
]],
}],
}],
},
'.dkr.ecr.',
{
'Fn::Select': [3, {
'Fn::Split': [':', {
'Fn::Join': ['', [
'arn:',
{ Ref: 'AWS::Partition' },
`:ecr:us-west-1:pipeline-account:repository/${repositoryName}`,
]],
}],
}],
},
'.',
{ Ref: 'AWS::URLSuffix' },
`/${repositoryName}:`,
{ Ref: 'ServiceTaskDefinitionContainerImageTagParamCEC9D0BA' },
]],
},
}),
],

Template.fromStack(serviceStack).hasResourceProperties('AWS::IAM::Policy', {
PolicyDocument: Match.objectLike({
Statement: Match.arrayWith([
Match.objectLike({
Action: [
'ecr:BatchCheckLayerAvailability',
'ecr:GetDownloadUrlForLayer',
'ecr:BatchGetImage',
],
Effect: 'Allow',
Resource: {
'Fn::Join': ['', [
'arn:',
{ Ref: 'AWS::Partition' },
`:ecr:us-west-1:pipeline-account:repository/${repositoryName}`,
]],
},
}),
Match.objectLike({
Action: 'ecr:GetAuthorizationToken',
Effect: 'Allow',
Resource: '*',
}),
]),
}),
});
});
});
Expand Down
2 changes: 1 addition & 1 deletion packages/@aws-cdk/aws-iam/lib/group.ts
Expand Up @@ -6,8 +6,8 @@ import { IManagedPolicy } from './managed-policy';
import { Policy } from './policy';
import { PolicyStatement } from './policy-statement';
import { AddToPrincipalPolicyResult, ArnPrincipal, IPrincipal, PrincipalPolicyFragment } from './principals';
import { AttachedPolicies } from './private/util';
import { IUser } from './user';
import { AttachedPolicies } from './util';

/**
* Represents an IAM Group.
Expand Down
1 change: 1 addition & 0 deletions packages/@aws-cdk/aws-iam/lib/index.ts
Expand Up @@ -14,6 +14,7 @@ export * from './oidc-provider';
export * from './permissions-boundary';
export * from './saml-provider';
export * from './access-key';
export * from './utils';

// AWS::IAM CloudFormation Resources:
export * from './iam.generated';
2 changes: 1 addition & 1 deletion packages/@aws-cdk/aws-iam/lib/managed-policy.ts
Expand Up @@ -4,9 +4,9 @@ import { IGroup } from './group';
import { CfnManagedPolicy } from './iam.generated';
import { PolicyDocument } from './policy-document';
import { PolicyStatement } from './policy-statement';
import { undefinedIfEmpty } from './private/util';
import { IRole } from './role';
import { IUser } from './user';
import { undefinedIfEmpty } from './util';

/**
* A managed policy
Expand Down
2 changes: 1 addition & 1 deletion packages/@aws-cdk/aws-iam/lib/policy-statement.ts
Expand Up @@ -6,7 +6,7 @@ import {
FederatedPrincipal, IPrincipal, PrincipalBase, PrincipalPolicyFragment, ServicePrincipal, ServicePrincipalOpts, validateConditionObject,
} from './principals';
import { normalizeStatement } from './private/postprocess-policy-document';
import { LITERAL_STRING_KEY, mergePrincipal, sum } from './util';
import { LITERAL_STRING_KEY, mergePrincipal, sum } from './private/util';

const ensureArrayOrUndefined = (field: any) => {
if (field === undefined) {
Expand Down
2 changes: 1 addition & 1 deletion packages/@aws-cdk/aws-iam/lib/policy.ts
Expand Up @@ -4,9 +4,9 @@ import { IGroup } from './group';
import { CfnPolicy } from './iam.generated';
import { PolicyDocument } from './policy-document';
import { PolicyStatement } from './policy-statement';
import { generatePolicyName, undefinedIfEmpty } from './private/util';
import { IRole } from './role';
import { IUser } from './user';
import { generatePolicyName, undefinedIfEmpty } from './util';

/**
* Represents an IAM Policy
Expand Down
2 changes: 1 addition & 1 deletion packages/@aws-cdk/aws-iam/lib/principals.ts
Expand Up @@ -5,8 +5,8 @@ import { IOpenIdConnectProvider } from './oidc-provider';
import { PolicyDocument } from './policy-document';
import { Condition, Conditions, PolicyStatement } from './policy-statement';
import { defaultAddPrincipalToAssumeRole } from './private/assume-role-policy';
import { LITERAL_STRING_KEY, mergePrincipal } from './private/util';
import { ISamlProvider } from './saml-provider';
import { LITERAL_STRING_KEY, mergePrincipal } from './util';

/**
* Any object that has an associated principal that a permission can be granted to
Expand Down