1import * as iam from "@distilled.cloud/aws/iam";
2import * as Effect from "effect/Effect";
3import * as Redacted from "effect/Redacted";
4import * as Schedule from "effect/Schedule";
5import { isResolved } from "../../Diff.ts";
6import { createPhysicalName } from "../../PhysicalName.ts";
7import * as Provider from "../../Provider.ts";
8import { Resource } from "../../Resource.ts";
9import { createInternalTags, diffTags, hasTags } from "../../Tags.ts";
10import { AWSEnvironment } from "../Environment.ts";
11import type { Providers } from "../Providers.ts";
12import { toTagRecord, unwrapRedactedString } from "./common.ts";
13
14export 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
38export 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 */
74export 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.
82const transientWriteSchedule = Schedule.max([
83 Schedule.exponential(500).pipe(Schedule.jittered),
84 Schedule.recurs(6),
85]);
86const isEmptyUpdateError = (error: { _tag: string; message?: string }) =>
87 error._tag === "ValidationError" &&
88 (error.message?.includes("No updates are defined") ?? false);
89
90const isTransientWriteError = (error: {
91 _tag: "ValidationError" | "ConcurrentModificationException" | (string & {});
92 message?: string;
93}) =>
94 error._tag === "ConcurrentModificationException" ||
95 (error._tag === "ValidationError" && !isEmptyUpdateError(error));
96
97const toName = (id: string, props: { name?: string }) =>
98 props.name
99 ? Effect.succeed(props.name)
100 : createPhysicalName({ id, maxLength: 128 });
101
102export 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 });