1import * as ci from "@distilled.cloud/aws/cognito-identity";
2import * as Effect from "effect/Effect";
3import * as Schedule from "effect/Schedule";
4import { isResolved } from "../../Diff.ts";
5import * as Provider from "../../Provider.ts";
6import { Resource } from "../../Resource.ts";
7import type { Providers } from "../Providers.ts";
8
9export interface IdentityPoolRoleAttachmentProps {
10 /**
11 * The ID of the identity pool. Changing this triggers a replacement.
12 */
13 identityPoolId: string;
14 /**
15 * The IAM role ARNs vended for authenticated and unauthenticated
16 * identities. The roles must trust `cognito-identity.amazonaws.com` via
17 * `sts:AssumeRoleWithWebIdentity`.
18 */
19 roles: {
20 /** Role assumed by authenticated identities. */
21 authenticated?: string;
22 /** Role assumed by unauthenticated (guest) identities. */
23 unauthenticated?: string;
24 };
25}
26
27export interface IdentityPoolRoleAttachment extends Resource<
28 "AWS.Cognito.IdentityPoolRoleAttachment",
29 IdentityPoolRoleAttachmentProps,
30 {
31 /** The ID of the identity pool the roles are attached to. */
32 identityPoolId: string;
33 /** The attached role ARNs by identity type. */
34 roles: { authenticated?: string; unauthenticated?: string };
35 },
36 never,
37 Providers
38> {}
39
40/**
41 * Attaches the authenticated/unauthenticated IAM roles to an Amazon Cognito
42 * identity pool. A singleton child of the pool — one attachment manages the
43 * pool's role configuration.
44 * ### Attaching Roles
45 * **Example:** Authenticated Role
46 * ```typescript
47 * import * as Cognito from "alchemy/AWS/Cognito";
48 *
49 * const identities = yield* Cognito.IdentityPool("Identities", {});
50 * const role = yield* IAM.Role("AuthenticatedRole", {
51 * assumeRolePolicyDocument: {
52 * Version: "2012-10-17",
53 * Statement: [
54 * {
55 * Effect: "Allow",
56 * Principal: { Federated: "cognito-identity.amazonaws.com" },
57 * Action: "sts:AssumeRoleWithWebIdentity",
58 * Condition: {
59 * StringEquals: {
60 * "cognito-identity.amazonaws.com:aud": identities.identityPoolId,
61 * },
62 * },
63 * },
64 * ],
65 * },
66 * });
67 * yield* Cognito.IdentityPoolRoleAttachment("Roles", {
68 * identityPoolId: identities.identityPoolId,
69 * roles: { authenticated: role.roleArn },
70 * });
71 * ```
72 *
73 * @resource
74 */
75export const IdentityPoolRoleAttachment = Resource<IdentityPoolRoleAttachment>(
76 "AWS.Cognito.IdentityPoolRoleAttachment",
77);
78
79/**
80 * Bounded retry over IAM eventual consistency: a freshly-created role's
81 * trust policy can take a few seconds to become visible to Cognito, which
82 * rejects the attachment with InvalidParameterException ("Cannot assume
83 * role"). Explicitly typed so `Retry.Return` never leaks into declaration
84 * emit.
85 */
86const retryThroughIamPropagation = <A, E extends { _tag: string }, R>(
87 self: Effect.Effect<A, E, R>,
88): Effect.Effect<A, E, R> =>
89 Effect.retry(self, {
90 while: (e) =>
91 e._tag === "InvalidParameterException" ||
92 e._tag === "ConcurrentModificationException",
93 schedule: Schedule.max([Schedule.fixed("2 seconds"), Schedule.recurs(10)]),
94 });
95
96const desiredRoles = (props: IdentityPoolRoleAttachmentProps) => {
97 const roles: Record<string, string> = {};
98 if (props.roles.authenticated !== undefined) {
99 roles.authenticated = props.roles.authenticated;
100 }
101 if (props.roles.unauthenticated !== undefined) {
102 roles.unauthenticated = props.roles.unauthenticated;
103 }
104 return roles;
105};
106
107export const IdentityPoolRoleAttachmentProvider = () =>
108 Provider.effect(
109 IdentityPoolRoleAttachment,
110 Effect.gen(function* () {
111 const getRoles = Effect.fn(function* (identityPoolId: string) {
112 return yield* ci
113 .getIdentityPoolRoles({ IdentityPoolId: identityPoolId })
114 .pipe(
115 Effect.catchTag("ResourceNotFoundException", () =>
116 Effect.succeed(undefined),
117 ),
118 );
119 });
120
121 return IdentityPoolRoleAttachment.Provider.of({
122 stables: ["identityPoolId"],
123
124 // Sub-resource keyed entirely by its identity pool (identityPoolId) with no global
125 // enumeration API of its own — nuke reaches it through the parent's
126 // deletion, so enumeration returns empty per the ProviderService
127 // doctrine.
128 list: () => Effect.succeed([]),
129
130 read: Effect.fn(function* ({ olds, output }) {
131 const identityPoolId = output?.identityPoolId ?? olds?.identityPoolId;
132 if (identityPoolId === undefined) return undefined;
133 const observed = yield* getRoles(identityPoolId);
134 const roles = observed?.Roles ?? {};
135 if (Object.keys(roles).length === 0) return undefined;
136 return {
137 identityPoolId,
138 roles: {
139 authenticated: roles.authenticated,
140 unauthenticated: roles.unauthenticated,
141 },
142 };
143 }),
144
145 diff: Effect.fn(function* ({ news, olds }) {
146 if (!isResolved(news)) return undefined;
147 if (olds?.identityPoolId !== news?.identityPoolId) {
148 return { action: "replace" } as const;
149 }
150 }),
151
152 reconcile: Effect.fn(function* ({ news, output, session }) {
153 const identityPoolId = news.identityPoolId;
154 const desired = desiredRoles(news);
155
156 // 1. OBSERVE — the attachment is a singleton child; observed state
157 // is the pool's current role map.
158 const observed = yield* getRoles(identityPoolId);
159 const observedRoles = Object.fromEntries(
160 Object.entries(observed?.Roles ?? {}).filter(
161 (entry): entry is [string, string] => entry[1] !== undefined,
162 ),
163 );
164
165 // 2/3. ENSURE + SYNC — SetIdentityPoolRoles is a full PUT; skip it
166 // when the observed map already matches.
167 const same =
168 JSON.stringify(
169 Object.entries(observedRoles).sort(([a], [b]) =>
170 a.localeCompare(b),
171 ),
172 ) ===
173 JSON.stringify(
174 Object.entries(desired).sort(([a], [b]) => a.localeCompare(b)),
175 );
176 if (!same) {
177 yield* retryThroughIamPropagation(
178 ci.setIdentityPoolRoles({
179 IdentityPoolId: identityPoolId,
180 Roles: desired,
181 }),
182 );
183 }
184
185 yield* session.note(identityPoolId);
186 return { identityPoolId, roles: news.roles };
187 }),
188
189 delete: Effect.fn(function* ({ output }) {
190 // Clearing the role map is the closest analogue to deletion for
191 // this singleton child; the pool itself is unaffected.
192 yield* ci
193 .setIdentityPoolRoles({
194 IdentityPoolId: output.identityPoolId,
195 Roles: {},
196 })
197 .pipe(
198 Effect.catchTag("ResourceNotFoundException", () => Effect.void),
199 );
200 }),
201 });
202 }),
203 );