import type { Tag } from "@aws-sdk/client-iam"; import type { Context } from "../context.ts"; import { Resource } from "../resource.ts"; import { ignore } from "../util/ignore.ts"; import { logger } from "../util/logger.ts"; import { importPeer } from "../util/peer.ts"; import type { PolicyDocument } from "./policy.ts"; import { retry } from "./retry.ts"; /** * Properties for creating or updating an IAM role */ export interface RoleProps { /** * Name of the IAM role * * @default ${app}-${stage}-${id} */ roleName?: string; /** * Policy that defines which entities can assume this role */ assumeRolePolicy: PolicyDocument; /** * Optional description of the role's purpose */ description?: string; /** * Optional path prefix for the role */ path?: string; /** * Maximum session duration in seconds when assumed * Default: 3600 seconds (1 hour) */ maxSessionDuration?: number; /** * ARN of the policy used to set the permissions boundary */ permissionsBoundary?: string; /** * Inline policies to embed in the role * Each policy must have a unique name and policy document */ policies?: Array<{ policyName: string; policyDocument: PolicyDocument; }>; /** * List of managed policy ARNs to attach to the role */ managedPolicyArns?: string[]; /** * Resource tags for the role */ tags?: Record; } /** * Output returned after IAM role creation/update */ export interface Role extends RoleProps { /** * ARN of the role */ arn: string; /** * Name of the Role. */ roleName: string; /** * Unique identifier for the role */ uniqueId: string; /** * The stable and unique string identifying the role */ roleId: string; /** * When the role was created */ createDate: Date; } /** * AWS IAM Role Resource * * Creates and manages IAM roles with support for inline policies, managed policies, * and automatic cleanup of attached policies during deletion. * * @example * // Create a basic Lambda execution role with inline policy * const basicRole = await Role("lambda-role", { * roleName: "lambda-role", * assumeRolePolicy: { * Version: "2012-10-17", * Statement: [{ * Effect: "Allow", * Principal: { * Service: "lambda.amazonaws.com" * }, * Action: "sts:AssumeRole" * }] * }, * description: "Basic Lambda execution role", * tags: { * Environment: "production" * }, * policies: [{ * policyName: "logs", * policyDocument: { * Version: "2012-10-17", * Statement: [{ * Effect: "Allow", * Action: [ * "logs:CreateLogGroup", * "logs:CreateLogStream", * "logs:PutLogEvents" * ], * Resource: "*" * }] * } * }] * }); * * @example * // Create a role with AWS managed policies * const managedRole = await Role("readonly-role", { * roleName: "readonly-role", * assumeRolePolicy: { * Version: "2012-10-17", * Statement: [{ * Effect: "Allow", * Principal: { * Service: "lambda.amazonaws.com" * }, * Action: "sts:AssumeRole" * }] * }, * description: "Role with managed policies", * managedPolicyArns: [ * "arn:aws:iam::aws:policy/ReadOnlyAccess" * ], * tags: { * Environment: "production" * } * }); * * @example * // Create a role with multiple inline policies and custom session duration * const customRole = await Role("custom-role", { * roleName: "custom-role", * assumeRolePolicy: { * Version: "2012-10-17", * Statement: [{ * Effect: "Allow", * Principal: { * Service: "lambda.amazonaws.com" * }, * Action: "sts:AssumeRole" * }] * }, * description: "Role with multiple policies", * maxSessionDuration: 7200, * policies: [ * { * policyName: "logs", * policyDocument: { * Version: "2012-10-17", * Statement: [{ * Effect: "Allow", * Action: [ * "logs:CreateLogGroup", * "logs:CreateLogStream", * "logs:PutLogEvents" * ], * Resource: "*" * }] * } * }, * { * policyName: "s3", * policyDocument: { * Version: "2012-10-17", * Statement: [{ * Effect: "Allow", * Action: "s3:ListBucket", * Resource: "*" * }] * } * } * ], * tags: { * Environment: "production", * Updated: "true" * } * }); */ export const Role = Resource( "iam::Role", async function ( this: Context, id: string, props: RoleProps, ): Promise { const { AttachRolePolicyCommand, CreateRoleCommand, DeleteRoleCommand, DeleteRolePolicyCommand, DetachRolePolicyCommand, EntityAlreadyExistsException, GetRoleCommand, IAMClient, ListAttachedRolePoliciesCommand, NoSuchEntityException, PutRolePolicyCommand, TagRoleCommand, UpdateAssumeRolePolicyCommand, UpdateRoleCommand, } = await importPeer(import("@aws-sdk/client-iam"), "iam::Role"); const client = new IAMClient({}); const roleName = props.roleName ?? this.output?.roleName ?? this.scope.createPhysicalName(id); if (this.phase === "update" && this.output.roleName !== roleName) { this.replace(); } if (this.phase === "delete") { try { // Delete any inline policies first if (props.policies) { for (const policy of props.policies) { await ignore(NoSuchEntityException.name, () => retry(() => client.send( new DeleteRolePolicyCommand({ RoleName: props.roleName, PolicyName: policy.policyName, }), ), ), ); } } // We need to detach managed policies before deleting the role // First, get all attached policies try { const attachedPoliciesResponse = await retry(() => client.send( new ListAttachedRolePoliciesCommand({ RoleName: props.roleName, }), ), ); // Detach all managed policies const attachedPolicies = attachedPoliciesResponse.AttachedPolicies || []; for (const policy of attachedPolicies) { await ignore(NoSuchEntityException.name, () => retry(() => client.send( new DetachRolePolicyCommand({ RoleName: props.roleName, PolicyArn: policy.PolicyArn!, }), ), ), ); } } catch (error: any) { if (error.name !== NoSuchEntityException.name) { throw error; } // Role doesn't exist, no need to continue with detaching policies } // Try to delete the role, ignoring if it doesn't exist await ignore(NoSuchEntityException.name, () => retry(() => client.send( new DeleteRoleCommand({ RoleName: props.roleName, }), ), ), ); } catch (error: any) { // If we get any other error besides NoSuchEntityException, log it but don't fail // This ensures the resource is still marked as destroyed if (error.name !== NoSuchEntityException.name) { logger.error(`Error deleting role ${props.roleName}:`, error); } } // Always return destroyed state regardless of any errors return this.destroy(); } const assumeRolePolicyDocument = JSON.stringify(props.assumeRolePolicy); let role; try { if (this.phase === "create") { // Try to create the role await retry(() => client.send( new CreateRoleCommand({ RoleName: props.roleName, AssumeRolePolicyDocument: assumeRolePolicyDocument, Description: props.description, Path: props.path, MaxSessionDuration: props.maxSessionDuration, PermissionsBoundary: props.permissionsBoundary, Tags: [ ...Object.entries(props.tags || {}).map(([Key, Value]) => ({ Key, Value, })), { Key: "alchemy_stage", Value: this.stage, }, { Key: "alchemy_resource", Value: this.id, }, ], }), ), ); } } catch (error: any) { if ( error instanceof EntityAlreadyExistsException && this.phase === "create" ) { // Check if we were the ones who created it const existingRole = await retry(() => client.send( new GetRoleCommand({ RoleName: props.roleName, }), ), ); const roleTags = existingRole.Role?.Tags?.reduce( (acc, tag) => { acc[tag.Key!] = tag.Value!; return acc; }, {} as Record, ) || {}; if ( roleTags.alchemy_stage !== this.stage || roleTags.alchemy_resource !== this.id ) { throw error; } } else if (error.name !== NoSuchEntityException.name) { logger.error("Error creating/updating role:", error); throw error; } } // Get or update the role role = await retry(() => client.send( new GetRoleCommand({ RoleName: props.roleName, }), ), ); // Update assume role policy if it changed if (role.Role?.AssumeRolePolicyDocument !== assumeRolePolicyDocument) { await retry(() => client.send( new UpdateAssumeRolePolicyCommand({ RoleName: props.roleName, PolicyDocument: assumeRolePolicyDocument, }), ), ); } // Update role description and max session duration if they changed if ( role.Role?.Description !== props.description || role.Role?.MaxSessionDuration !== props.maxSessionDuration ) { await retry(() => client.send( new UpdateRoleCommand({ RoleName: props.roleName, Description: props.description, MaxSessionDuration: props.maxSessionDuration, }), ), ); } // Update tags const newTags = { ...props.tags, alchemy_stage: this.stage, alchemy_resource: this.id, }; const tags: Tag[] = Object.entries(newTags).map(([Key, Value]) => ({ Key, Value, })); await retry(() => client.send( new TagRoleCommand({ RoleName: props.roleName, Tags: tags, }), ), ); // Handle policy changes const previousPolicies = this.phase === "update" ? this.output!.policies || [] : []; const currentPolicies = props.policies || []; // Delete policies that were removed for (const oldPolicy of previousPolicies) { if ( !currentPolicies.some( (p: { policyName: string }) => p.policyName === oldPolicy.policyName, ) ) { await ignore(NoSuchEntityException.name, () => retry(() => client.send( new DeleteRolePolicyCommand({ RoleName: props.roleName, PolicyName: oldPolicy.policyName, }), ), ), ); } } // Update or create policies for (const policy of currentPolicies) { const oldPolicy = previousPolicies.find( (p) => p.policyName === policy.policyName, ); if ( !oldPolicy || JSON.stringify(oldPolicy.policyDocument) !== JSON.stringify(policy.policyDocument) ) { await retry(() => client.send( new PutRolePolicyCommand({ RoleName: props.roleName, PolicyName: policy.policyName, PolicyDocument: JSON.stringify(policy.policyDocument), }), ), ); } } // Handle managed policy attachments // Get currently attached policies const attachedPoliciesResponse = await retry(() => client.send( new ListAttachedRolePoliciesCommand({ RoleName: props.roleName, }), ), ); const currentAttachedPolicies = attachedPoliciesResponse.AttachedPolicies || []; const currentPolicyArns = currentAttachedPolicies.map((p) => p.PolicyArn!); // If we're updating, use an empty array as default when managedPolicyArns is undefined // to ensure we detach all managed policies const desiredPolicyArns = props.managedPolicyArns || []; // Detach policies that are no longer needed for (const policyArn of currentPolicyArns) { if (!desiredPolicyArns.includes(policyArn)) { await retry(() => client.send( new DetachRolePolicyCommand({ RoleName: props.roleName, PolicyArn: policyArn, }), ), ); } } // Attach new policies that weren't attached before for (const policyArn of desiredPolicyArns) { if (!currentPolicyArns.includes(policyArn)) { await retry(() => client.send( new AttachRolePolicyCommand({ RoleName: props.roleName, PolicyArn: policyArn, }), ), ); } } if (!role?.Role) { throw new Error(`Failed to create or update role ${props.roleName}`); } return { ...props, arn: role.Role.Arn!, uniqueId: role.Role.RoleId!, roleId: role.Role.RoleId!, roleName, createDate: role.Role.CreateDate!, }; }, );