packages/@aws-cdk/cloudformation-diff/lib/iam/statement.ts (220 lines of code) (raw):

import type { MaybeParsed } from '../diff/maybe-parsed'; import { mkParsed, mkUnparseable } from '../diff/maybe-parsed'; import { deepRemoveUndefined } from '../util'; // 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'); export class Statement { /** * Statement ID */ public readonly sid: string | undefined; /** * Statement effect */ public readonly effect: Effect; /** * Resources */ public readonly resources: Targets; /** * Principals */ public readonly principals: Targets; /** * Actions */ public readonly actions: Targets; /** * Object with conditions */ public readonly condition?: any; private readonly serializedIntrinsic: string | undefined; constructor(statement: UnknownMap | string) { if (typeof statement === 'string') { this.sid = undefined; this.effect = Effect.Unknown; this.resources = new Targets({}, '', ''); this.actions = new Targets({}, '', ''); this.principals = new Targets({}, '', ''); this.condition = undefined; this.serializedIntrinsic = statement; } else { this.sid = expectString(statement.Sid); this.effect = expectEffect(statement.Effect); this.resources = new Targets(statement, 'Resource', 'NotResource'); this.actions = new Targets(statement, 'Action', 'NotAction'); this.principals = new Targets(statement, 'Principal', 'NotPrincipal'); this.condition = statement.Condition; this.serializedIntrinsic = undefined; } } /** * Whether this statement is equal to the other statement */ public equal(other: Statement): boolean { return (this.sid === other.sid && this.effect === other.effect && this.serializedIntrinsic === other.serializedIntrinsic && this.resources.equal(other.resources) && this.actions.equal(other.actions) && this.principals.equal(other.principals) && deepEqual(this.condition, other.condition)); } public render(): RenderedStatement { return this.serializedIntrinsic ? { resource: this.serializedIntrinsic, effect: '', action: '', principal: this.principals.render(), // these will be replaced by the call to replaceEmpty() from IamChanges condition: '', } : { resource: this.resources.render(), effect: this.effect, action: this.actions.render(), principal: this.principals.render(), condition: renderCondition(this.condition), }; } /** * Return a machine-readable version of the changes. * This is only used in tests. * * @internal */ public _toJson(): MaybeParsed<StatementJson> { return this.serializedIntrinsic ? mkUnparseable(this.serializedIntrinsic) : mkParsed(deepRemoveUndefined({ sid: this.sid, effect: this.effect, resources: this.resources._toJson(), principals: this.principals._toJson(), actions: this.actions._toJson(), condition: this.condition, })); } /** * Whether this is a negative statement * * A statement is negative if any of its targets are negative, inverted * if the Effect is Deny. */ public get isNegativeStatement(): boolean { const notTarget = this.actions.not || this.principals.not || this.resources.not; return this.effect === Effect.Allow ? notTarget : !notTarget; } } export interface RenderedStatement { readonly resource: string; readonly effect: string; readonly action: string; readonly principal: string; readonly condition: string; } export interface StatementJson { sid?: string; effect: string; resources: TargetsJson; actions: TargetsJson; principals: TargetsJson; condition?: any; } export interface TargetsJson { not: boolean; values: string[]; } /** * Parse a list of statements from undefined, a Statement, or a list of statements */ export function parseStatements(x: any): Statement[] { if (x === undefined) { x = []; } if (!Array.isArray(x)) { x = [x]; } return x.map((s: any) => new Statement(s)); } /** * Parse a Statement from a Lambda::Permission object * * This is actually what Lambda adds to the policy document if you call AddPermission. */ export function parseLambdaPermission(x: any): Statement { // Construct a statement from const statement: any = { Effect: 'Allow', Action: x.Action, Resource: x.FunctionName, }; if (x.Principal !== undefined) { if (x.Principal === '*') { // * statement.Principal = '*'; } else if (/^\d{12}$/.test(x.Principal)) { // Account number // eslint-disable-next-line @cdklabs/no-literal-partition statement.Principal = { AWS: `arn:aws:iam::${x.Principal}:root` }; } else { // Assume it's a service principal // We might get this wrong vs. the previous one for tokens. Nothing to be done // about that. It's only for human readable consumption after all. statement.Principal = { Service: x.Principal }; } } if (x.SourceArn !== undefined) { if (statement.Condition === undefined) { statement.Condition = {}; } statement.Condition.ArnLike = { 'AWS:SourceArn': x.SourceArn }; } if (x.SourceAccount !== undefined) { if (statement.Condition === undefined) { statement.Condition = {}; } statement.Condition.StringEquals = { 'AWS:SourceAccount': x.SourceAccount }; } if (x.EventSourceToken !== undefined) { if (statement.Condition === undefined) { statement.Condition = {}; } statement.Condition.StringEquals = { 'lambda:EventSourceToken': x.EventSourceToken }; } return new Statement(statement); } /** * Targets for a field */ export class Targets { /** * The values of the targets */ public readonly values: string[]; /** * Whether positive or negative matchers */ public readonly not: boolean; constructor(statement: UnknownMap, positiveKey: string, negativeKey: string) { if (negativeKey in statement) { this.values = forceListOfStrings(statement[negativeKey]); this.not = true; } else { this.values = forceListOfStrings(statement[positiveKey]); this.not = false; } this.values.sort(); } public get empty() { return this.values.length === 0; } /** * Whether this set of targets is equal to the other set of targets */ public equal(other: Targets) { return this.not === other.not && deepEqual(this.values.sort(), other.values.sort()); } /** * If the current value set is empty, put this in it */ public replaceEmpty(replacement: string) { if (this.empty) { this.values.push(replacement); } } /** * If the actions contains a '*', replace with this string. */ public replaceStar(replacement: string) { for (let i = 0; i < this.values.length; i++) { if (this.values[i] === '*') { this.values[i] = replacement; } } this.values.sort(); } /** * Render into a summary table cell */ public render(): string { return this.not ? this.values.map(s => `NOT ${s}`).join('\n') : this.values.join('\n'); } /** * Return a machine-readable version of the changes. * This is only used in tests. * * @internal */ public _toJson(): TargetsJson { return { not: this.not, values: this.values }; } } type UnknownMap = {[key: string]: unknown}; export enum Effect { Unknown = 'Unknown', Allow = 'Allow', Deny = 'Deny', } function expectString(x: unknown): string | undefined { return typeof x === 'string' ? x : undefined; } function expectEffect(x: unknown): Effect { if (x === Effect.Allow || x === Effect.Deny) { return x as Effect; } return Effect.Unknown; } function forceListOfStrings(x: unknown): string[] { if (typeof x === 'string') { return [x]; } if (typeof x === 'undefined' || x === null) { return []; } if (Array.isArray(x)) { return x.map(e => forceListOfStrings(e).join(',')); } if (typeof x === 'object' && x !== null) { const ret: string[] = []; for (const [key, value] of Object.entries(x)) { ret.push(...forceListOfStrings(value).map(s => `${key}:${s}`)); } return ret; } return [`${x}`]; } /** * Render the Condition column */ export function renderCondition(condition: any): string { if (!condition || Object.keys(condition).length === 0) { return ''; } const jsonRepresentation = JSON.stringify(condition, undefined, 2); // The JSON representation looks like this: // // { // "ArnLike": { // "AWS:SourceArn": "${MyTopic86869434}" // } // } // // We can make it more compact without losing information by getting rid of the outermost braces // and the indentation. const lines = jsonRepresentation.split('\n'); return lines.slice(1, lines.length - 1).map(s => s.slice(2)).join('\n'); }