packages/aws-cdk-lib/aws-iam/lib/policy-statement.ts (400 lines of code) (raw):

import { IConstruct } from 'constructs'; import { Group } from './group'; import { AccountPrincipal, AccountRootPrincipal, AnyPrincipal, ArnPrincipal, CanonicalUserPrincipal, FederatedPrincipal, IPrincipal, PrincipalBase, PrincipalPolicyFragment, ServicePrincipal, ServicePrincipalOpts, validateConditionObject, } from './principals'; import { normalizeStatement } from './private/postprocess-policy-document'; import { LITERAL_STRING_KEY, mergePrincipal, sum } from './private/util'; import * as cdk from '../../core'; const ensureArrayOrUndefined = (field: any) => { if (field === undefined) { return undefined; } if (typeof (field) !== 'string' && !Array.isArray(field)) { throw new Error('Fields must be either a string or an array of strings'); } if (Array.isArray(field) && !!field.find((f: any) => typeof (f) !== 'string')) { throw new Error('Fields must be either a string or an array of strings'); } return Array.isArray(field) ? field : [field]; }; /** * An estimate on how long ARNs typically are * * This is used to decide when to start splitting statements into new Managed Policies. * Because we often can't know the length of an ARN (it may be a token and only * available at deployment time) we'll have to estimate it. * * The estimate can be overridden by setting the `@aws-cdk/aws-iam.arnSizeEstimate` context key. */ const DEFAULT_ARN_SIZE_ESTIMATE = 150; /** * Context key which can be used to override the estimated length of unresolved ARNs. */ const ARN_SIZE_ESTIMATE_CONTEXT_KEY = '@aws-cdk/aws-iam.arnSizeEstimate'; /** * Represents a statement in an IAM policy document. */ export class PolicyStatement { /** * Creates a new PolicyStatement based on the object provided. * This will accept an object created from the `.toJSON()` call * @param obj the PolicyStatement in object form. */ public static fromJson(obj: any) { const ret = new PolicyStatement({ sid: obj.Sid, actions: ensureArrayOrUndefined(obj.Action), resources: ensureArrayOrUndefined(obj.Resource), conditions: obj.Condition, effect: obj.Effect, notActions: ensureArrayOrUndefined(obj.NotAction), notResources: ensureArrayOrUndefined(obj.NotResource), principals: obj.Principal ? [new JsonPrincipal(obj.Principal)] : undefined, notPrincipals: obj.NotPrincipal ? [new JsonPrincipal(obj.NotPrincipal)] : undefined, }); // validate that the PolicyStatement has the correct shape const errors = ret.validateForAnyPolicy(); if (errors.length > 0) { throw new Error('Incorrect Policy Statement: ' + errors.join('\n')); } return ret; } private readonly _action = new OrderedSet<string>(); private readonly _notAction = new OrderedSet<string>(); private readonly _principal: { [key: string]: any[] } = {}; private readonly _notPrincipal: { [key: string]: any[] } = {}; private readonly _resource = new OrderedSet<string>(); private readonly _notResource = new OrderedSet<string>(); private readonly _condition: { [key: string]: any } = { }; private _sid?: string; private _effect: Effect; private principalConditionsJson?: string; // Hold on to those principals private readonly _principals = new OrderedSet<IPrincipal>(); private readonly _notPrincipals = new OrderedSet<IPrincipal>(); private _frozen = false; constructor(props: PolicyStatementProps = {}) { this._sid = props.sid; this._effect = props.effect || Effect.ALLOW; this.addActions(...props.actions || []); this.addNotActions(...props.notActions || []); this.addPrincipals(...props.principals || []); this.addNotPrincipals(...props.notPrincipals || []); this.addResources(...props.resources || []); this.addNotResources(...props.notResources || []); if (props.conditions !== undefined) { this.addConditions(props.conditions); } } /** * Statement ID for this statement */ public get sid(): string | undefined { return this._sid; } /** * Set Statement ID for this statement */ public set sid(sid: string | undefined) { this.assertNotFrozen('sid'); this._sid = sid; } /** * Whether to allow or deny the actions in this statement */ public get effect(): Effect { return this._effect; } /** * Set effect for this statement */ public set effect(effect: Effect) { this.assertNotFrozen('effect'); this._effect = effect; } // // Actions // /** * Specify allowed actions into the "Action" section of the policy statement. * * @see https://docs.aws.amazon.com/IAM/latest/UserGuide/reference_policies_elements_action.html * * @param actions actions that will be allowed. */ public addActions(...actions: string[]) { this.assertNotFrozen('addActions'); if (actions.length > 0 && this._notAction.length > 0) { throw new Error('Cannot add \'Actions\' to policy statement if \'NotActions\' have been added'); } this.validatePolicyActions(actions); this._action.push(...actions); } /** * Explicitly allow all actions except the specified list of actions into the "NotAction" section * of the policy document. * * @see https://docs.aws.amazon.com/IAM/latest/UserGuide/reference_policies_elements_notaction.html * * @param notActions actions that will be denied. All other actions will be permitted. */ public addNotActions(...notActions: string[]) { this.assertNotFrozen('addNotActions'); if (notActions.length > 0 && this._action.length > 0) { throw new Error('Cannot add \'NotActions\' to policy statement if \'Actions\' have been added'); } this.validatePolicyActions(notActions); this._notAction.push(...notActions); } // // Principal // /** * Indicates if this permission has a "Principal" section. */ public get hasPrincipal() { return this._principals.length + this._notPrincipals.length > 0; } /** * Adds principals to the "Principal" section of a policy statement. * * @see https://docs.aws.amazon.com/IAM/latest/UserGuide/reference_policies_elements_principal.html * * @param principals IAM principals that will be added */ public addPrincipals(...principals: IPrincipal[]) { this.assertNotFrozen('addPrincipals'); if (principals.length > 0 && this._notPrincipals.length > 0) { throw new Error('Cannot add \'Principals\' to policy statement if \'NotPrincipals\' have been added'); } for (const principal of principals) { this.validatePolicyPrincipal(principal); } const added = this._principals.push(...principals); for (const principal of added) { const fragment = principal.policyFragment; mergePrincipal(this._principal, fragment.principalJson); this.addPrincipalConditions(fragment.conditions); } } /** * Specify principals that is not allowed or denied access to the "NotPrincipal" section of * a policy statement. * * @see https://docs.aws.amazon.com/IAM/latest/UserGuide/reference_policies_elements_notprincipal.html * * @param notPrincipals IAM principals that will be denied access */ public addNotPrincipals(...notPrincipals: IPrincipal[]) { this.assertNotFrozen('addNotPrincipals'); if (notPrincipals.length > 0 && this._principals.length > 0) { throw new Error('Cannot add \'NotPrincipals\' to policy statement if \'Principals\' have been added'); } for (const notPrincipal of notPrincipals) { this.validatePolicyPrincipal(notPrincipal); } const added = this._notPrincipals.push(...notPrincipals); for (const notPrincipal of added) { const fragment = notPrincipal.policyFragment; mergePrincipal(this._notPrincipal, fragment.principalJson); this.addPrincipalConditions(fragment.conditions); } } private validatePolicyActions(actions: string[]) { // In case of an unresolved list of actions return early if (cdk.Token.isUnresolved(actions)) return; for (const action of actions || []) { if (!cdk.Token.isUnresolved(action) && !/^(\*|[a-zA-Z0-9-]+:[a-zA-Z0-9*]+)$/.test(action)) { throw new Error(`Action '${action}' is invalid. An action string consists of a service namespace, a colon, and the name of an action. Action names can include wildcards.`); } } } private validatePolicyPrincipal(principal: IPrincipal) { if (principal instanceof Group) { throw new Error('Cannot use an IAM Group as the \'Principal\' or \'NotPrincipal\' in an IAM Policy'); } } /** * Specify AWS account ID as the principal entity to the "Principal" section of a policy statement. */ public addAwsAccountPrincipal(accountId: string) { this.addPrincipals(new AccountPrincipal(accountId)); } /** * Specify a principal using the ARN identifier of the principal. * You cannot specify IAM groups and instance profiles as principals. * * @param arn ARN identifier of AWS account, IAM user, or IAM role (i.e. arn:aws:iam::123456789012:user/user-name) */ public addArnPrincipal(arn: string) { this.addPrincipals(new ArnPrincipal(arn)); } /** * Adds a service principal to this policy statement. * * @param service the service name for which a service principal is requested (e.g: `s3.amazonaws.com`). * @param opts options for adding the service principal (such as specifying a principal in a different region) */ public addServicePrincipal(service: string, opts?: ServicePrincipalOpts) { this.addPrincipals(new ServicePrincipal(service, opts)); } /** * Adds a federated identity provider such as Amazon Cognito to this policy statement. * * @param federated federated identity provider (i.e. 'cognito-identity.amazonaws.com') * @param conditions The conditions under which the policy is in effect. * See [the IAM documentation](https://docs.aws.amazon.com/IAM/latest/UserGuide/reference_policies_elements_condition.html). */ public addFederatedPrincipal(federated: any, conditions: Conditions) { this.addPrincipals(new FederatedPrincipal(federated, conditions)); } /** * Adds an AWS account root user principal to this policy statement */ public addAccountRootPrincipal() { this.addPrincipals(new AccountRootPrincipal()); } /** * Adds a canonical user ID principal to this policy document * * @param canonicalUserId unique identifier assigned by AWS for every account */ public addCanonicalUserPrincipal(canonicalUserId: string) { this.addPrincipals(new CanonicalUserPrincipal(canonicalUserId)); } /** * Adds all identities in all accounts ("*") to this policy statement */ public addAnyPrincipal() { this.addPrincipals(new AnyPrincipal()); } // // Resources // /** * Specify resources that this policy statement applies into the "Resource" section of * this policy statement. * * @see https://docs.aws.amazon.com/IAM/latest/UserGuide/reference_policies_elements_resource.html * * @param arns Amazon Resource Names (ARNs) of the resources that this policy statement applies to */ public addResources(...arns: string[]) { this.assertNotFrozen('addResources'); if (arns.length > 0 && this._notResource.length > 0) { throw new Error('Cannot add \'Resources\' to policy statement if \'NotResources\' have been added'); } this._resource.push(...arns); } /** * Specify resources that this policy statement will not apply to in the "NotResource" section * of this policy statement. All resources except the specified list will be matched. * * @see https://docs.aws.amazon.com/IAM/latest/UserGuide/reference_policies_elements_notresource.html * * @param arns Amazon Resource Names (ARNs) of the resources that this policy statement does not apply to */ public addNotResources(...arns: string[]) { this.assertNotFrozen('addNotResources'); if (arns.length > 0 && this._resource.length > 0) { throw new Error('Cannot add \'NotResources\' to policy statement if \'Resources\' have been added'); } this._notResource.push(...arns); } /** * Adds a ``"*"`` resource to this statement. */ public addAllResources() { this.addResources('*'); } /** * Indicates if this permission has at least one resource associated with it. */ public get hasResource() { return this._resource && this._resource.length > 0; } // // Condition // /** * Add a condition to the Policy * * If multiple calls are made to add a condition with the same operator and field, only * the last one wins. For example: * * ```ts * declare const stmt: iam.PolicyStatement; * * stmt.addCondition('StringEquals', { 'aws:SomeField': '1' }); * stmt.addCondition('StringEquals', { 'aws:SomeField': '2' }); * ``` * * Will end up with the single condition `StringEquals: { 'aws:SomeField': '2' }`. * * If you meant to add a condition to say that the field can be *either* `1` or `2`, write * this: * * ```ts * declare const stmt: iam.PolicyStatement; * * stmt.addCondition('StringEquals', { 'aws:SomeField': ['1', '2'] }); * ``` */ public addCondition(key: string, value: Condition) { this.assertNotFrozen('addCondition'); validateConditionObject(value); const existingValue = this._condition[key]; this._condition[key] = existingValue ? { ...existingValue, ...value } : value; } /** * Add multiple conditions to the Policy * * See the `addCondition` function for a caveat on calling this method multiple times. */ public addConditions(conditions: Conditions) { Object.keys(conditions).map(key => { this.addCondition(key, conditions[key]); }); } /** * Add a `StringEquals` condition that limits to a given account from `sts:ExternalId`. * * This method can only be called once: subsequent calls will overwrite earlier calls. * * @see https://docs.aws.amazon.com/IAM/latest/UserGuide/id_roles_create_for-user_externalid.html */ public addAccountCondition(accountId: string) { this.addCondition('StringEquals', { 'sts:ExternalId': accountId }); } /** * Add an `StringEquals` condition that limits to a given account from `aws:SourceAccount`. * * This method can only be called once: subsequent calls will overwrite earlier calls. * * @see https://docs.aws.amazon.com/IAM/latest/UserGuide/reference_policies_condition-keys.html#condition-keys-sourceaccount */ public addSourceAccountCondition(accountId: string) { this.addCondition('StringEquals', { 'aws:SourceAccount': accountId }); } /** * Add an `ArnEquals` condition that limits to a given resource arn from `aws:SourceArn`. * * This method can only be called once: subsequent calls will overwrite earlier calls. * * @see https://docs.aws.amazon.com/IAM/latest/UserGuide/reference_policies_condition-keys.html#condition-keys-sourcearn */ public addSourceArnCondition(arn: string) { this.addCondition('ArnEquals', { 'aws:SourceArn': arn }); } /** * Create a new `PolicyStatement` with the same exact properties * as this one, except for the overrides */ public copy(overrides: PolicyStatementProps = {}) { return new PolicyStatement({ sid: overrides.sid ?? this.sid, effect: overrides.effect ?? this.effect, actions: overrides.actions ?? this.actions, notActions: overrides.notActions ?? this.notActions, principals: overrides.principals ?? this.principals, notPrincipals: overrides.notPrincipals ?? this.notPrincipals, resources: overrides.resources ?? this.resources, notResources: overrides.notResources ?? this.notResources, conditions: overrides.conditions ?? this.conditions, }); } /** * JSON-ify the policy statement * * Used when JSON.stringify() is called */ public toStatementJson(): any { return normalizeStatement({ Action: this._action.direct(), NotAction: this._notAction.direct(), Condition: this._condition, Effect: this.effect, Principal: this._principal, NotPrincipal: this._notPrincipal, Resource: this._resource.direct(), NotResource: this._notResource.direct(), Sid: this.sid, }); } /** * String representation of this policy statement */ public toString() { return cdk.Token.asString(this, { displayHint: 'PolicyStatement', }); } /** * JSON-ify the statement * * Used when JSON.stringify() is called */ public toJSON() { return this.toStatementJson(); } /** * Add a principal's conditions * * For convenience, principals have been modeled as both a principal * and a set of conditions. This makes it possible to have a single * object represent e.g. an "SNS Topic" (SNS service principal + aws:SourcArn * condition) or an Organization member (* + aws:OrgId condition). * * However, when using multiple principals in the same policy statement, * they must all have the same conditions or the OR samentics * implied by a list of principals cannot be guaranteed (user needs to * add multiple statements in that case). */ private addPrincipalConditions(conditions: Conditions) { // Stringifying the conditions is an easy way to do deep equality const theseConditions = JSON.stringify(conditions); if (this.principalConditionsJson === undefined) { // First principal, anything goes this.principalConditionsJson = theseConditions; } else { if (this.principalConditionsJson !== theseConditions) { throw new Error(`All principals in a PolicyStatement must have the same Conditions (got '${this.principalConditionsJson}' and '${theseConditions}'). Use multiple statements instead.`); } } this.addConditions(conditions); } /** * Validate that the policy statement satisfies base requirements for a policy. * * @returns An array of validation error messages, or an empty array if the statement is valid. */ public validateForAnyPolicy(): string[] { const errors = new Array<string>(); if (this._action.length === 0 && this._notAction.length === 0) { errors.push('A PolicyStatement must specify at least one \'action\' or \'notAction\'.'); } return errors; } /** * Validate that the policy statement satisfies all requirements for a resource-based policy. * * @returns An array of validation error messages, or an empty array if the statement is valid. */ public validateForResourcePolicy(): string[] { const errors = this.validateForAnyPolicy(); if (this._principals.length === 0 && this._notPrincipals.length === 0) { errors.push('A PolicyStatement used in a resource-based policy must specify at least one IAM principal.'); } return errors; } /** * Validate that the policy statement satisfies all requirements for an identity-based policy. * * @returns An array of validation error messages, or an empty array if the statement is valid. */ public validateForIdentityPolicy(): string[] { const errors = this.validateForAnyPolicy(); if (this._principals.length > 0 || this._notPrincipals.length > 0) { errors.push('A PolicyStatement used in an identity-based policy cannot specify any IAM principals.'); } if (this._resource.length === 0 && this._notResource.length === 0) { errors.push('A PolicyStatement used in an identity-based policy must specify at least one resource.'); } return errors; } /** * The Actions added to this statement */ public get actions() { return this._action.copy(); } /** * The NotActions added to this statement */ public get notActions() { return this._notAction.copy(); } /** * The Principals added to this statement */ public get principals(): IPrincipal[] { return this._principals.copy(); } /** * The NotPrincipals added to this statement */ public get notPrincipals(): IPrincipal[] { return this._notPrincipals.copy(); } /** * The Resources added to this statement */ public get resources() { return this._resource.copy(); } /** * The NotResources added to this statement */ public get notResources() { return this._notResource.copy(); } /** * The conditions added to this statement */ public get conditions(): any { return { ...this._condition }; } /** * Make the PolicyStatement immutable * * After calling this, any of the `addXxx()` methods will throw an exception. * * Libraries that lazily generate statement bodies can override this method to * fill the actual PolicyStatement fields. Be aware that this method may be called * multiple times. */ public freeze(): PolicyStatement { this._frozen = true; return this; } /** * Whether the PolicyStatement has been frozen * * The statement object is frozen when `freeze()` is called. */ public get frozen(): boolean { return this._frozen; } /** * Estimate the size of this policy statement * * By necessity, this will not be accurate. We'll do our best to overestimate * so we won't have nasty surprises. * * @internal */ public _estimateSize(options: EstimateSizeOptions): number { let ret = 0; const { actionEstimate, arnEstimate } = options; ret += `"Effect": "${this.effect}",`.length; count('Action', this.actions, actionEstimate); count('NotAction', this.notActions, actionEstimate); count('Resource', this.resources, arnEstimate); count('NotResource', this.notResources, arnEstimate); ret += this.principals.length * arnEstimate; ret += this.notPrincipals.length * arnEstimate; ret += JSON.stringify(this.conditions).length; return ret; function count(key: string, values: string[], tokenSize: number) { if (values.length > 0) { ret += key.length + 5 /* quotes, colon, brackets */ + sum(values.map(v => (cdk.Token.isUnresolved(v) ? tokenSize : v.length) + 3 /* quotes, separator */)); } } } /** * Throw an exception when the object is frozen */ private assertNotFrozen(method: string) { if (this._frozen) { throw new Error(`${method}: freeze() has been called on this PolicyStatement previously, so it can no longer be modified`); } } } /** * The Effect element of an IAM policy * * @see https://docs.aws.amazon.com/IAM/latest/UserGuide/reference_policies_elements_effect.html */ export enum Effect { /** * Allows access to a resource in an IAM policy statement. By default, access to resources are denied. */ ALLOW = 'Allow', /** * Explicitly deny access to a resource. By default, all requests are denied implicitly. * * @see https://docs.aws.amazon.com/IAM/latest/UserGuide/reference_policies_evaluation-logic.html */ DENY = 'Deny', } /** * Condition for when an IAM policy is in effect. Maps from the keys in a request's context to * a string value or array of string values. See the Conditions interface for more details. */ export type Condition = unknown; // NOTE! We would have liked to have typed this as `Record<string, unknown>`, but in some places // of the code we are assuming we can pass a `CfnJson` object into where a `Condition` is expected, // and that wouldn't typecheck anymore. // // Needs to be `unknown` instead of `any` so that the type of `Conditions` is // `Record<string, unknown>`; if it had been `Record<string, any>`, TypeScript would have allowed // passing an array into `conditions` arguments (where it needs to be a map). /** * Conditions for when an IAM Policy is in effect, specified in the following structure: * * `{ "Operator": { "keyInRequestContext": "value" } }` * * The value can be either a single string value or an array of string values. * * For more information, including which operators are supported, see [the IAM * documentation](https://docs.aws.amazon.com/IAM/latest/UserGuide/reference_policies_elements_condition.html). */ export type Conditions = Record<string, Condition>; /** * Interface for creating a policy statement */ export interface PolicyStatementProps { /** * The Sid (statement ID) is an optional identifier that you provide for the * policy statement. You can assign a Sid value to each statement in a * statement array. In services that let you specify an ID element, such as * SQS and SNS, the Sid value is just a sub-ID of the policy document's ID. In * IAM, the Sid value must be unique within a JSON policy. * * @default - no sid */ readonly sid?: string; /** * List of actions to add to the statement * * @default - no actions */ readonly actions?: string[]; /** * List of not actions to add to the statement * * @default - no not-actions */ readonly notActions?: string[]; /** * List of principals to add to the statement * * @default - no principals */ readonly principals?: IPrincipal[]; /** * List of not principals to add to the statement * * @default - no not principals */ readonly notPrincipals?: IPrincipal[]; /** * Resource ARNs to add to the statement * * @default - no resources */ readonly resources?: string[]; /** * NotResource ARNs to add to the statement * * @default - no not-resources */ readonly notResources?: string[]; /** * Conditions to add to the statement * * @default - no condition */ readonly conditions?: {[key: string]: any}; /** * Whether to allow or deny the actions in this statement * * @default Effect.ALLOW */ readonly effect?: Effect; } class JsonPrincipal extends PrincipalBase { public readonly policyFragment: PrincipalPolicyFragment; constructor(json: any = { }) { super(); // special case: if principal is a string, turn it into a "LiteralString" principal, // so we render the exact same string back out. if (typeof(json) === 'string') { json = { [LITERAL_STRING_KEY]: [json] }; } if (typeof(json) !== 'object') { throw new Error(`JSON IAM principal should be an object, got ${JSON.stringify(json)}`); } this.policyFragment = { principalJson: json, conditions: {}, }; } public dedupeString(): string | undefined { return JSON.stringify(this.policyFragment); } } /** * Options for _estimateSize * * These can optionally come from context, but it's too expensive to look * them up every time so we bundle them into a struct first. * * @internal */ export interface EstimateSizeOptions { /** * Estimated size of an unresolved ARN */ readonly arnEstimate: number; /** * Estimated size of an unresolved action */ readonly actionEstimate: number; } /** * Derive the size estimation options from context * * @internal */ export function deriveEstimateSizeOptions(scope: IConstruct): EstimateSizeOptions { const actionEstimate = 20; const arnEstimate = scope.node.tryGetContext(ARN_SIZE_ESTIMATE_CONTEXT_KEY) ?? DEFAULT_ARN_SIZE_ESTIMATE; if (typeof arnEstimate !== 'number') { throw new Error(`Context value ${ARN_SIZE_ESTIMATE_CONTEXT_KEY} should be a number, got ${JSON.stringify(arnEstimate)}`); } return { actionEstimate, arnEstimate }; } /** * A class that behaves both as a set and an array * * Used for the elements of a PolicyStatement. In practice they behave as sets, * but we have thousands of snapshot tests in existence that will rely on a * particular order so we can't just change the type to `Set<>` wholesale without * causing a lot of churn. */ class OrderedSet<A> { private readonly set = new Set<A>(); private readonly array = new Array<A>(); /** * Add new elements to the set * * @param xs the elements to be added * * @returns the elements actually added */ public push(...xs: readonly A[]): A[] { const ret = new Array<A>(); for (const x of xs) { if (this.set.has(x)) { continue; } this.set.add(x); this.array.push(x); ret.push(x); } return ret; } public get length() { return this.array.length; } public copy(): A[] { return [...this.array]; } /** * Direct (read-only) access to the underlying array * * (Saves a copy) */ public direct(): readonly A[] { return this.array; } }