| 1 | import * as ci from "@distilled.cloud/aws/cognito-identity"; |
| 2 | import * as Effect from "effect/Effect"; |
| 3 | import * as Schedule from "effect/Schedule"; |
| 4 | import { isResolved } from "../../Diff.ts"; |
| 5 | import * as Provider from "../../Provider.ts"; |
| 6 | import { Resource } from "../../Resource.ts"; |
| 7 | import type { Providers } from "../Providers.ts"; |
| 8 | |
| 9 | export 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 | |
| 27 | export 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 | */ |
| 75 | export 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 | */ |
| 86 | const 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 | |
| 96 | const 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 | |
| 107 | export 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 | ); |