import * as iam from "@distilled.cloud/aws/iam"; import * as Effect from "effect/Effect"; import * as Stream from "effect/Stream"; import { isResolved } from "../../Diff.ts"; import { createPhysicalName } from "../../PhysicalName.ts"; import * as Provider from "../../Provider.ts"; import { Resource } from "../../Resource.ts"; import type { Providers } from "../Providers.ts"; import type { PolicyDocument } from "./Policy.ts"; import { parsePolicyDocument, stringifyPolicyDocument } from "./common.ts"; export interface GroupProps { /** * Group name. If omitted, a deterministic name is generated. */ groupName?: string; /** * Optional IAM path prefix. * @default "/" */ path?: string; /** * Managed policy ARNs attached to the group. */ managedPolicyArns?: string[]; /** * Inline policies embedded in the group. */ inlinePolicies?: Record; } export interface Group extends Resource< "AWS.IAM.Group", GroupProps, { /** The ARN of the group. */ groupArn: string; /** The name of the group. */ groupName: string; /** The stable unique ID of the group. */ groupId: string | undefined; /** The IAM path of the group. */ path: string | undefined; /** Managed policy ARNs attached to the group. */ managedPolicyArns: string[]; /** Inline policies embedded in the group, keyed by policy name. */ inlinePolicies: Record; }, never, Providers > {} /** * An IAM group that can own managed and inline policies. * * `Group` manages a shared authorization container for IAM users, including * attached managed policies and embedded inline policies. * ### Creating IAM Groups * **Example:** Group with an Inline Policy * ```typescript * const group = yield* Group("SupportGroup", { * groupName: "support", * inlinePolicies: { * SupportReadOnly: { * Version: "2012-10-17", * Statement: [{ * Effect: "Allow", * Action: ["cloudwatch:Get*", "cloudwatch:List*"], * Resource: ["*"], * }], * }, * }, * }); * ``` * * @resource */ export const Group = Resource("AWS.IAM.Group"); export const GroupProvider = () => Provider.effect( Group, Effect.gen(function* () { const toName = (id: string, props: GroupProps) => props.groupName ? Effect.succeed(props.groupName) : createPhysicalName({ id, maxLength: 128 }); const readInlinePolicies = Effect.fn(function* (groupName: string) { const policyNames = yield* iam.listGroupPolicies .items({ GroupName: groupName }) .pipe( Stream.runCollect, Effect.map((chunk) => Array.from(chunk)), ); const entries = yield* Effect.all( policyNames.map((policyName) => iam .getGroupPolicy({ GroupName: groupName, PolicyName: policyName, }) .pipe( Effect.map( (response) => [ policyName, parsePolicyDocument(response.PolicyDocument), ] as const, ), Effect.catchTag("NoSuchEntityException", () => Effect.succeed([policyName, undefined] as const), ), ), ), ); return Object.fromEntries( entries.filter( (entry): entry is [string, PolicyDocument] => entry[1] !== undefined, ), ); }); const readManagedPolicies = Effect.fn(function* (groupName: string) { const attached = yield* iam.listAttachedGroupPolicies .items({ GroupName: groupName }) .pipe( Stream.runCollect, Effect.map((chunk) => Array.from(chunk)), ); return attached .map((policy) => policy.PolicyArn) .filter( (policyArn): policyArn is string => typeof policyArn === "string", ); }); const syncManagedPolicies = Effect.fn(function* ({ groupName, olds, news, }: { groupName: string; olds: string[]; news: string[]; }) { const oldSet = new Set(olds); const newSet = new Set(news); for (const policyArn of news) { if (!oldSet.has(policyArn)) { yield* iam.attachGroupPolicy({ GroupName: groupName, PolicyArn: policyArn, }); } } for (const policyArn of olds) { if (!newSet.has(policyArn)) { yield* iam .detachGroupPolicy({ GroupName: groupName, PolicyArn: policyArn, }) .pipe( Effect.catchTag("NoSuchEntityException", () => Effect.void), ); } } }); const syncInlinePolicies = Effect.fn(function* ({ groupName, olds, news, }: { groupName: string; olds: Record; news: Record; }) { for (const [policyName, document] of Object.entries(news)) { if ( JSON.stringify(olds[policyName] ?? null) !== JSON.stringify(document) ) { yield* iam.putGroupPolicy({ GroupName: groupName, PolicyName: policyName, PolicyDocument: stringifyPolicyDocument(document), }); } } for (const policyName of Object.keys(olds)) { if (!(policyName in news)) { yield* iam .deleteGroupPolicy({ GroupName: groupName, PolicyName: policyName, }) .pipe( Effect.catchTag("NoSuchEntityException", () => Effect.void), ); } } }); return { stables: ["groupArn", "groupName", "groupId"], // IAM is global (no region). Enumerate every group via the paginated // `listGroups`, then hydrate each into the full `read` Attributes shape // by reading its attached managed policies and embedded inline policies. list: () => Effect.gen(function* () { const groups = yield* iam.listGroups.pages({}).pipe( Stream.runCollect, Effect.map((chunk) => Array.from(chunk).flatMap((page) => page.Groups ?? []), ), ); const hydrated = yield* Effect.forEach( groups, (group) => Effect.gen(function* () { const [managedPolicyArns, inlinePolicies] = yield* Effect.all( [ readManagedPolicies(group.GroupName), readInlinePolicies(group.GroupName), ], ); return { groupArn: group.Arn, groupName: group.GroupName, groupId: group.GroupId, path: group.Path, managedPolicyArns, inlinePolicies, }; }).pipe( // A group can be deleted between `listGroups` and this // per-group hydration (e.g. a sibling test tearing its group // down) — `NoSuchEntityException` here just means it's gone, // so drop it rather than failing the whole enumeration. Effect.catchTag("NoSuchEntityException", () => Effect.succeed(undefined), ), ), { concurrency: 10 }, ); return hydrated.filter((g) => g !== undefined); }), diff: Effect.fn(function* ({ id, olds, news }) { if (!isResolved(news)) return; if ( (yield* toName(id, olds ?? ({} as GroupProps))) !== (yield* toName(id, news)) ) { return { action: "replace" } as const; } if ((olds?.path ?? "/") !== (news.path ?? "/")) { return { action: "replace" } as const; } }), read: Effect.fn(function* ({ id, olds, output }) { const groupName = output?.groupName ?? (yield* toName(id, olds ?? ({} as GroupProps))); const response = yield* iam .getGroup({ GroupName: groupName, }) .pipe( Effect.catchTag("NoSuchEntityException", () => Effect.succeed(undefined), ), ); if (!response?.Group?.Arn) { return undefined; } const [managedPolicyArns, inlinePolicies] = yield* Effect.all([ readManagedPolicies(groupName), readInlinePolicies(groupName), ]); return { groupArn: response.Group.Arn, groupName: response.Group.GroupName, groupId: response.Group.GroupId, path: response.Group.Path, managedPolicyArns, inlinePolicies, }; }), reconcile: Effect.fn(function* ({ id, news, output, session }) { const groupName = output?.groupName ?? (yield* toName(id, news)); // Observe — fetch the live group. `getGroup` returns // `NoSuchEntityException` when the group has not been created or // was deleted out-of-band, in which case we fall through to the // ensure step below. let observed = yield* iam .getGroup({ GroupName: groupName }) .pipe( Effect.catchTag("NoSuchEntityException", () => Effect.succeed(undefined), ), ); // Ensure — create the group when it is missing. A concurrent // peer reconciler may win the race, so tolerate // `EntityAlreadyExistsException` and reload. if (!observed?.Group?.Arn) { observed = yield* iam .createGroup({ GroupName: groupName, Path: news.path, }) .pipe( Effect.catchTag("EntityAlreadyExistsException", () => iam.getGroup({ GroupName: groupName }), ), Effect.map((response) => ({ Group: response.Group, Users: [], IsTruncated: false, })), ); } // Sync — for each mutable aspect, diff observed against desired // and apply only the delta. We trust the cloud as the source of // truth instead of relying on `olds`. const [observedManagedPolicies, observedInlinePolicies] = yield* Effect.all([ readManagedPolicies(groupName), readInlinePolicies(groupName), ]); yield* syncManagedPolicies({ groupName, olds: observedManagedPolicies, news: news.managedPolicyArns ?? [], }); yield* syncInlinePolicies({ groupName, olds: observedInlinePolicies, news: news.inlinePolicies ?? {}, }); const groupArn = observed.Group?.Arn ?? groupName; yield* session.note(groupArn); return { groupArn, groupName, groupId: observed.Group?.GroupId, path: observed.Group?.Path ?? news.path ?? "/", managedPolicyArns: news.managedPolicyArns ?? [], inlinePolicies: news.inlinePolicies ?? {}, }; }), delete: Effect.fn(function* ({ output }) { yield* iam.getGroup.items({ GroupName: output.groupName }).pipe( Stream.mapEffect((user) => user.UserName ? iam .removeUserFromGroup({ GroupName: output.groupName, UserName: user.UserName, }) .pipe( Effect.catchTag( "NoSuchEntityException", () => Effect.void, ), ) : Effect.void, ), Stream.runDrain, // The group itself may already be gone. Effect.catchTag("NoSuchEntityException", () => Effect.void), ); yield* iam.listGroupPolicies .items({ GroupName: output.groupName }) .pipe( Stream.mapEffect((policyName) => iam .deleteGroupPolicy({ GroupName: output.groupName, PolicyName: policyName, }) .pipe( Effect.catchTag("NoSuchEntityException", () => Effect.void), ), ), Stream.runDrain, Effect.catchTag("NoSuchEntityException", () => Effect.void), ); yield* iam.listAttachedGroupPolicies .items({ GroupName: output.groupName }) .pipe( Stream.mapEffect((policy) => policy.PolicyArn ? iam .detachGroupPolicy({ GroupName: output.groupName, PolicyArn: policy.PolicyArn, }) .pipe( Effect.catchTag( "NoSuchEntityException", () => Effect.void, ), ) : Effect.void, ), Stream.runDrain, Effect.catchTag("NoSuchEntityException", () => Effect.void), ); yield* iam .deleteGroup({ GroupName: output.groupName, }) .pipe(Effect.catchTag("NoSuchEntityException", () => Effect.void)); }), }; }), );