| 1 | import * as iam from "@distilled.cloud/aws/iam"; |
| 2 | import * as Effect from "effect/Effect"; |
| 3 | import * as Redacted from "effect/Redacted"; |
| 4 | import * as Schedule from "effect/Schedule"; |
| 5 | import { isResolved } from "../../Diff.ts"; |
| 6 | import { createPhysicalName } from "../../PhysicalName.ts"; |
| 7 | import * as Provider from "../../Provider.ts"; |
| 8 | import { Resource } from "../../Resource.ts"; |
| 9 | import { createInternalTags, diffTags, hasTags } from "../../Tags.ts"; |
| 10 | import { AWSEnvironment } from "../Environment.ts"; |
| 11 | import type { Providers } from "../Providers.ts"; |
| 12 | import { toTagRecord, unwrapRedactedString } from "./common.ts"; |
| 13 | |
| 14 | export interface SAMLProviderProps { |
| 15 | /** |
| 16 | * The friendly SAML provider name. If omitted, a unique name is |
| 17 | * generated from the stack, stage, and logical id. |
| 18 | */ |
| 19 | name?: string; |
| 20 | /** |
| 21 | * The provider metadata document. |
| 22 | */ |
| 23 | samlMetadataDocument: string; |
| 24 | /** |
| 25 | * Optional assertion encryption mode. |
| 26 | */ |
| 27 | assertionEncryptionMode?: iam.AssertionEncryptionModeType; |
| 28 | /** |
| 29 | * Optional private key added during creation/update. |
| 30 | */ |
| 31 | addPrivateKey?: Redacted.Redacted<string> | string; |
| 32 | /** |
| 33 | * User-defined tags. |
| 34 | */ |
| 35 | tags?: Record<string, string>; |
| 36 | } |
| 37 | |
| 38 | export interface SAMLProvider extends Resource< |
| 39 | "AWS.IAM.SAMLProvider", |
| 40 | SAMLProviderProps, |
| 41 | { |
| 42 | /** The ARN of the SAML provider. */ |
| 43 | samlProviderArn: string; |
| 44 | /** The name of the SAML provider. */ |
| 45 | name: string; |
| 46 | /** The unique ID AWS assigns to the provider. */ |
| 47 | samlProviderUUID: string | undefined; |
| 48 | /** The SAML metadata document from the identity provider. */ |
| 49 | samlMetadataDocument: string | undefined; |
| 50 | /** Whether SAML assertions must be encrypted (`Required` / `Allowed`). */ |
| 51 | assertionEncryptionMode: iam.AssertionEncryptionModeType | undefined; |
| 52 | /** The tags applied to the provider. */ |
| 53 | tags: Record<string, string>; |
| 54 | }, |
| 55 | never, |
| 56 | Providers |
| 57 | > {} |
| 58 | |
| 59 | /** |
| 60 | * An IAM SAML identity provider. |
| 61 | * |
| 62 | * `SAMLProvider` registers a SAML metadata document so IAM roles can trust an |
| 63 | * external workforce or application identity provider. |
| 64 | * ### Federating with SAML |
| 65 | * **Example:** Create a SAML Identity Provider |
| 66 | * ```typescript |
| 67 | * const provider = yield* SAMLProvider("WorkforceSaml", { |
| 68 | * samlMetadataDocument: "<EntityDescriptor>...</EntityDescriptor>", |
| 69 | * }); |
| 70 | * ``` |
| 71 | * |
| 72 | * @resource |
| 73 | */ |
| 74 | export const SAMLProvider = Resource<SAMLProvider>("AWS.IAM.SAMLProvider"); |
| 75 | |
| 76 | // IAM is eventually consistent. Recreating a SAML provider immediately after a |
| 77 | // same-named one was deleted (the destroy-then-deploy test flow) can transiently |
| 78 | // surface a content-less `ValidationError` (or `ConcurrentModificationException`) |
| 79 | // until the prior delete settles. A bounded retry rides out that window; a |
| 80 | // well-formed metadata document never fails persistently, so this never masks a |
| 81 | // genuine bad-input error for longer than the small budget. |
| 82 | const transientWriteSchedule = Schedule.max([ |
| 83 | Schedule.exponential(500).pipe(Schedule.jittered), |
| 84 | Schedule.recurs(6), |
| 85 | ]); |
| 86 | const isEmptyUpdateError = (error: { _tag: string; message?: string }) => |
| 87 | error._tag === "ValidationError" && |
| 88 | (error.message?.includes("No updates are defined") ?? false); |
| 89 | |
| 90 | const isTransientWriteError = (error: { |
| 91 | _tag: "ValidationError" | "ConcurrentModificationException" | (string & {}); |
| 92 | message?: string; |
| 93 | }) => |
| 94 | error._tag === "ConcurrentModificationException" || |
| 95 | (error._tag === "ValidationError" && !isEmptyUpdateError(error)); |
| 96 | |
| 97 | const toName = (id: string, props: { name?: string }) => |
| 98 | props.name |
| 99 | ? Effect.succeed(props.name) |
| 100 | : createPhysicalName({ id, maxLength: 128 }); |
| 101 | |
| 102 | export const SAMLProviderProvider = () => |
| 103 | Provider.succeed(SAMLProvider, { |
| 104 | stables: ["samlProviderArn", "name"], |
| 105 | diff: Effect.fn(function* ({ id, olds, news }) { |
| 106 | if (!isResolved(news)) return; |
| 107 | if ((yield* toName(id, olds ?? {})) !== (yield* toName(id, news))) { |
| 108 | return { action: "replace" } as const; |
| 109 | } |
| 110 | }), |
| 111 | read: Effect.fn(function* ({ output }) { |
| 112 | if (!output) { |
| 113 | return undefined; |
| 114 | } |
| 115 | const response = yield* iam |
| 116 | .getSAMLProvider({ |
| 117 | SAMLProviderArn: output.samlProviderArn, |
| 118 | }) |
| 119 | .pipe( |
| 120 | Effect.catchTag("NoSuchEntityException", () => |
| 121 | Effect.succeed(undefined), |
| 122 | ), |
| 123 | ); |
| 124 | if (!response) { |
| 125 | return undefined; |
| 126 | } |
| 127 | const tags = yield* iam.listSAMLProviderTags({ |
| 128 | SAMLProviderArn: output.samlProviderArn, |
| 129 | }); |
| 130 | return { |
| 131 | samlProviderArn: output.samlProviderArn, |
| 132 | name: output.name, |
| 133 | samlProviderUUID: response.SAMLProviderUUID, |
| 134 | samlMetadataDocument: response.SAMLMetadataDocument, |
| 135 | assertionEncryptionMode: response.AssertionEncryptionMode, |
| 136 | tags: toTagRecord(tags.Tags), |
| 137 | }; |
| 138 | }), |
| 139 | list: Effect.fn(function* () { |
| 140 | // IAM is global; `listSAMLProviders` enumerates every provider in the |
| 141 | // account but only returns the ARN, so hydrate each via |
| 142 | // `getSAMLProvider` + `listSAMLProviderTags` for the full Attributes. |
| 143 | const { SAMLProviderList } = yield* iam.listSAMLProviders({}); |
| 144 | const arns = (SAMLProviderList ?? []).flatMap((entry) => |
| 145 | entry.Arn ? [entry.Arn] : [], |
| 146 | ); |
| 147 | const rows = yield* Effect.forEach( |
| 148 | arns, |
| 149 | (samlProviderArn) => |
| 150 | Effect.gen(function* () { |
| 151 | const response = yield* iam.getSAMLProvider({ |
| 152 | SAMLProviderArn: samlProviderArn, |
| 153 | }); |
| 154 | const tags = yield* iam.listSAMLProviderTags({ |
| 155 | SAMLProviderArn: samlProviderArn, |
| 156 | }); |
| 157 | return { |
| 158 | samlProviderArn, |
| 159 | name: samlProviderArn.split("saml-provider/").pop() ?? "", |
| 160 | samlProviderUUID: response.SAMLProviderUUID, |
| 161 | samlMetadataDocument: response.SAMLMetadataDocument, |
| 162 | assertionEncryptionMode: response.AssertionEncryptionMode, |
| 163 | tags: toTagRecord(tags.Tags), |
| 164 | }; |
| 165 | }).pipe( |
| 166 | // A provider can disappear between listing, reading and tag lookup. |
| 167 | Effect.catchTag("NoSuchEntityException", () => |
| 168 | Effect.succeed(undefined), |
| 169 | ), |
| 170 | ), |
| 171 | { concurrency: 10 }, |
| 172 | ); |
| 173 | return rows.filter((row) => row !== undefined); |
| 174 | }), |
| 175 | reconcile: Effect.fn(function* ({ id, news, output, session }) { |
| 176 | const internalTags = yield* createInternalTags(id); |
| 177 | const desiredTags = { |
| 178 | ...internalTags, |
| 179 | ...news.tags, |
| 180 | }; |
| 181 | const name = output?.name ?? (yield* toName(id, news)); |
| 182 | const accountId = (yield* AWSEnvironment.current).accountId; |
| 183 | const samlProviderArn = |
| 184 | output?.samlProviderArn ?? |
| 185 | `arn:aws:iam::${accountId}:saml-provider/${name}`; |
| 186 | |
| 187 | // Observe — `getSAMLProvider` returns the metadata, encryption |
| 188 | // mode, and UUID; absence is `NoSuchEntityException`. |
| 189 | let observed = yield* iam |
| 190 | .getSAMLProvider({ SAMLProviderArn: samlProviderArn }) |
| 191 | .pipe( |
| 192 | Effect.catchTag("NoSuchEntityException", () => |
| 193 | Effect.succeed(undefined), |
| 194 | ), |
| 195 | ); |
| 196 | |
| 197 | // Ensure — create when missing. Race with a peer is recovered by |
| 198 | // verifying alchemy ownership tags on the existing provider. |
| 199 | if (!observed) { |
| 200 | const created = yield* iam |
| 201 | .createSAMLProvider({ |
| 202 | Name: name, |
| 203 | SAMLMetadataDocument: news.samlMetadataDocument, |
| 204 | AssertionEncryptionMode: news.assertionEncryptionMode, |
| 205 | AddPrivateKey: news.addPrivateKey |
| 206 | ? unwrapRedactedString(news.addPrivateKey) |
| 207 | : undefined, |
| 208 | Tags: Object.entries(desiredTags).map(([Key, Value]) => ({ |
| 209 | Key, |
| 210 | Value, |
| 211 | })), |
| 212 | }) |
| 213 | .pipe( |
| 214 | Effect.retry({ |
| 215 | while: isTransientWriteError, |
| 216 | schedule: transientWriteSchedule, |
| 217 | }), |
| 218 | Effect.catchTag("EntityAlreadyExistsException", () => |
| 219 | Effect.gen(function* () { |
| 220 | const existingTags = yield* iam.listSAMLProviderTags({ |
| 221 | SAMLProviderArn: samlProviderArn, |
| 222 | }); |
| 223 | if (!hasTags(internalTags, existingTags.Tags)) { |
| 224 | return yield* Effect.fail( |
| 225 | new Error( |
| 226 | `SAML provider '${name}' already exists and is not managed by alchemy`, |
| 227 | ), |
| 228 | ); |
| 229 | } |
| 230 | return { SAMLProviderArn: samlProviderArn }; |
| 231 | }), |
| 232 | ), |
| 233 | ); |
| 234 | observed = yield* iam.getSAMLProvider({ |
| 235 | SAMLProviderArn: created.SAMLProviderArn ?? samlProviderArn, |
| 236 | }); |
| 237 | } else { |
| 238 | // Sync metadata / encryption mode — `updateSAMLProvider` rejects |
| 239 | // a call with no fields set ("No updates are defined…"). Only |
| 240 | // send fields that actually changed; skip the API on a no-op |
| 241 | // (greenfield adopt of a leftover provider, or a tags-only |
| 242 | // reconcile). |
| 243 | const metadataChanged = |
| 244 | (observed.SAMLMetadataDocument ?? undefined) !== |
| 245 | news.samlMetadataDocument; |
| 246 | const encryptionChanged = |
| 247 | news.assertionEncryptionMode !== undefined && |
| 248 | observed.AssertionEncryptionMode !== news.assertionEncryptionMode; |
| 249 | const addPrivateKey = news.addPrivateKey |
| 250 | ? unwrapRedactedString(news.addPrivateKey) |
| 251 | : undefined; |
| 252 | if ( |
| 253 | metadataChanged || |
| 254 | encryptionChanged || |
| 255 | addPrivateKey !== undefined |
| 256 | ) { |
| 257 | yield* iam |
| 258 | .updateSAMLProvider({ |
| 259 | SAMLProviderArn: samlProviderArn, |
| 260 | SAMLMetadataDocument: metadataChanged |
| 261 | ? news.samlMetadataDocument |
| 262 | : undefined, |
| 263 | AssertionEncryptionMode: encryptionChanged |
| 264 | ? news.assertionEncryptionMode |
| 265 | : undefined, |
| 266 | AddPrivateKey: addPrivateKey, |
| 267 | }) |
| 268 | .pipe( |
| 269 | Effect.retry({ |
| 270 | while: isTransientWriteError, |
| 271 | schedule: transientWriteSchedule, |
| 272 | }), |
| 273 | Effect.catchIf(isEmptyUpdateError, () => Effect.void), |
| 274 | ); |
| 275 | } |
| 276 | } |
| 277 | |
| 278 | // Sync tags against the cloud's actual tags. |
| 279 | const observedTagsResp = yield* iam.listSAMLProviderTags({ |
| 280 | SAMLProviderArn: samlProviderArn, |
| 281 | }); |
| 282 | const observedTags = toTagRecord(observedTagsResp.Tags); |
| 283 | const { removed, upsert } = diffTags(observedTags, desiredTags); |
| 284 | if (upsert.length > 0) { |
| 285 | yield* iam.tagSAMLProvider({ |
| 286 | SAMLProviderArn: samlProviderArn, |
| 287 | Tags: upsert, |
| 288 | }); |
| 289 | } |
| 290 | if (removed.length > 0) { |
| 291 | yield* iam.untagSAMLProvider({ |
| 292 | SAMLProviderArn: samlProviderArn, |
| 293 | TagKeys: removed, |
| 294 | }); |
| 295 | } |
| 296 | |
| 297 | yield* session.note(samlProviderArn); |
| 298 | return { |
| 299 | samlProviderArn, |
| 300 | name, |
| 301 | samlProviderUUID: |
| 302 | observed?.SAMLProviderUUID ?? output?.samlProviderUUID, |
| 303 | samlMetadataDocument: news.samlMetadataDocument, |
| 304 | assertionEncryptionMode: news.assertionEncryptionMode, |
| 305 | tags: desiredTags, |
| 306 | }; |
| 307 | }), |
| 308 | delete: Effect.fn(function* ({ output }) { |
| 309 | yield* iam |
| 310 | .deleteSAMLProvider({ |
| 311 | SAMLProviderArn: output.samlProviderArn, |
| 312 | }) |
| 313 | .pipe(Effect.catchTag("NoSuchEntityException", () => Effect.void)); |
| 314 | }), |
| 315 | }); |