1import * as aoss from "@distilled.cloud/aws/opensearchserverless";
2import type * as Duration from "effect/Duration";
3import * as Effect from "effect/Effect";
4import * as Stream from "effect/Stream";
5import { isResolved } from "../../Diff.ts";
6import { createPhysicalName } from "../../PhysicalName.ts";
7import * as Provider from "../../Provider.ts";
8import { Resource } from "../../Resource.ts";
9import { toWireMinutes } from "../../Util/Duration.ts";
10import { AWSEnvironment } from "../Environment.ts";
11import type { Providers } from "../Providers.ts";
12import { retryWhileConflict } from "./internal.ts";
13
14/**
15 * The kind of security configuration:
16 * - `saml` — SAML federation with an external identity provider.
17 * - `iamidentitycenter` — AWS IAM Identity Center integration.
18 * - `iamfederation` — IAM federation via session attributes.
19 */
20export type SecurityConfigType = "saml" | "iamidentitycenter" | "iamfederation";
21
22export interface SamlOptions {
23 /**
24 * The XML IdP metadata document generated by the identity provider.
25 */
26 metadata: string;
27 /**
28 * A user attribute for the SAML assertion.
29 */
30 userAttribute?: string;
31 /**
32 * A group attribute for the SAML assertion.
33 */
34 groupAttribute?: string;
35 /**
36 * Custom entity ID used as the audience restriction of the SAML assertion.
37 */
38 openSearchServerlessEntityId?: string;
39 /**
40 * How long a SAML session remains valid. Accepts any `Duration.Input`
41 * (e.g. `"2 hours"`, `Duration.minutes(90)`; a bare number is milliseconds);
42 * the wire unit is minutes.
43 * @default 60 minutes (maximum 12 hours)
44 */
45 sessionTimeout?: Duration.Input;
46}
47
48export interface IamIdentityCenterOptions {
49 /**
50 * ARN of the IAM Identity Center instance.
51 */
52 instanceArn: string;
53 /**
54 * The user attribute to map (e.g. `UserId`, `UserName`, `Email`).
55 */
56 userAttribute?: string;
57 /**
58 * The group attribute to map (e.g. `GroupId`, `GroupName`).
59 */
60 groupAttribute?: string;
61}
62
63export interface IamFederationOptions {
64 /**
65 * The session attribute that carries the user identity.
66 */
67 userAttribute?: string;
68 /**
69 * The session attribute that carries the user's groups.
70 */
71 groupAttribute?: string;
72}
73
74export interface SecurityConfigProps {
75 /**
76 * Name of the security configuration (3-32 characters, lowercase). Changing
77 * the name replaces the configuration.
78 * @default a generated physical name
79 */
80 configName?: string;
81 /**
82 * The configuration kind (`saml`, `iamidentitycenter`, or `iamfederation`).
83 * Changing the type replaces the configuration.
84 */
85 type: SecurityConfigType;
86 /**
87 * A human-readable description of the configuration.
88 */
89 description?: string;
90 /**
91 * SAML options — required when `type` is `saml`.
92 */
93 samlOptions?: SamlOptions;
94 /**
95 * IAM Identity Center options — required when `type` is
96 * `iamidentitycenter`. `instanceArn` is create-only.
97 */
98 iamIdentityCenterOptions?: IamIdentityCenterOptions;
99 /**
100 * IAM federation options — required when `type` is `iamfederation`.
101 */
102 iamFederationOptions?: IamFederationOptions;
103}
104
105export interface SecurityConfig extends Resource<
106 "AWS.OpenSearchServerless.SecurityConfig",
107 SecurityConfigProps,
108 {
109 /**
110 * Unique identifier of the security configuration, in the format
111 * `{type}/{accountId}/{name}`. Use this as the `Principal` in a data
112 * access policy to grant federated identities access.
113 */
114 configId: string;
115 /**
116 * Name of the security configuration.
117 */
118 configName: string;
119 /**
120 * Configuration type (`saml`, `iamidentitycenter`, or `iamfederation`).
121 */
122 type: string;
123 /**
124 * Version of the configuration, used for optimistic-concurrency updates.
125 */
126 configVersion: string;
127 /**
128 * Description of the security configuration.
129 */
130 description?: string;
131 },
132 {},
133 Providers
134> {}
135
136/**
137 * An Amazon OpenSearch Serverless security configuration. Security
138 * configurations federate OpenSearch Dashboards sign-in with SAML identity
139 * providers, AWS IAM Identity Center, or IAM federation, so human users can
140 * access collections without IAM credentials.
141 *
142 * The configuration's `configId` (format `saml/{accountId}/{name}`) is what a
143 * data {@link AccessPolicy} references as a `Principal` to grant the federated
144 * identities index- and collection-level permissions.
145 *
146 * ### SAML Authentication
147 * **Example:** Federate Dashboards with a SAML Identity Provider
148 * ```typescript
149 * import * as AWS from "alchemy/AWS";
150 *
151 * const saml = yield* AWS.OpenSearchServerless.SecurityConfig("Saml", {
152 * configName: "my-idp",
153 * type: "saml",
154 * samlOptions: {
155 * metadata: idpMetadataXml,
156 * groupAttribute: "groups",
157 * sessionTimeout: "4 hours",
158 * },
159 * });
160 * // Reference saml.configId as a Principal in a data access policy
161 * ```
162 *
163 * ### IAM Federation
164 * **Example:** Map Session Attributes to Identities
165 * ```typescript
166 * const federation = yield* AWS.OpenSearchServerless.SecurityConfig("Federation", {
167 * configName: "my-federation",
168 * type: "iamfederation",
169 * iamFederationOptions: {
170 * userAttribute: "user",
171 * groupAttribute: "groups",
172 * },
173 * });
174 * ```
175 *
176 * @resource
177 */
178export const SecurityConfig = Resource<SecurityConfig>(
179 "AWS.OpenSearchServerless.SecurityConfig",
180);
181
182export const SecurityConfigProvider = () =>
183 Provider.effect(
184 SecurityConfig,
185 Effect.gen(function* () {
186 const createName = Effect.fn(function* (
187 id: string,
188 props: { configName?: string | undefined },
189 ) {
190 return (
191 props.configName ??
192 (yield* createPhysicalName({ id, maxLength: 32, lowercase: true }))
193 );
194 });
195
196 // A security config's id is deterministic: {type}/{accountId}/{name}.
197 const computeConfigId = Effect.fn(function* (type: string, name: string) {
198 const { accountId } = yield* AWSEnvironment.current;
199 return `${type}/${accountId}/${name}`;
200 });
201
202 const toName = (configId: string) => configId.split("/").at(-1)!;
203
204 const toAttributes = (detail: aoss.SecurityConfigDetail) => ({
205 configId: detail.id!,
206 configName: toName(detail.id!),
207 type: detail.type!,
208 configVersion: detail.configVersion!,
209 description: detail.description,
210 });
211
212 const toWireSamlOptions = (saml: SamlOptions | undefined) =>
213 saml === undefined
214 ? undefined
215 : {
216 metadata: saml.metadata,
217 userAttribute: saml.userAttribute,
218 groupAttribute: saml.groupAttribute,
219 openSearchServerlessEntityId: saml.openSearchServerlessEntityId,
220 sessionTimeout: toWireMinutes(saml.sessionTimeout),
221 };
222
223 const observe = Effect.fn(function* (configId: string) {
224 return yield* aoss.getSecurityConfig({ id: configId }).pipe(
225 Effect.map((r) => r.securityConfigDetail),
226 Effect.catchTag("ResourceNotFoundException", () =>
227 Effect.succeed(undefined),
228 ),
229 );
230 });
231
232 return SecurityConfig.Provider.of({
233 stables: ["configId", "configName", "type"],
234
235 list: () =>
236 Effect.gen(function* () {
237 const types: SecurityConfigType[] = [
238 "saml",
239 "iamidentitycenter",
240 "iamfederation",
241 ];
242 const results: {
243 configId: string;
244 configName: string;
245 type: string;
246 configVersion: string;
247 description?: string;
248 }[] = [];
249 for (const type of types) {
250 const pages = yield* aoss.listSecurityConfigs
251 .pages({ type })
252 .pipe(Stream.runCollect);
253 for (const page of pages) {
254 for (const s of page.securityConfigSummaries ?? []) {
255 if (
256 s.id !== undefined &&
257 s.type !== undefined &&
258 s.configVersion !== undefined
259 ) {
260 results.push({
261 configId: s.id,
262 configName: toName(s.id),
263 type: s.type,
264 configVersion: s.configVersion,
265 description: s.description,
266 });
267 }
268 }
269 }
270 }
271 return results;
272 }),
273
274 read: Effect.fn(function* ({ id, olds, output }) {
275 const type = output?.type ?? olds?.type;
276 if (type === undefined) {
277 return undefined;
278 }
279 const configId =
280 output?.configId ??
281 (yield* computeConfigId(type, yield* createName(id, olds ?? {})));
282 const detail = yield* observe(configId);
283 if (detail?.id === undefined) {
284 return undefined;
285 }
286 // Security configs carry no tags, so an existing same-name config is
287 // adopted.
288 return toAttributes(detail);
289 }),
290
291 diff: Effect.fn(function* ({ id, news, olds }) {
292 if (!isResolved(news)) return undefined;
293 if (olds.type !== news.type) {
294 return { action: "replace" } as const;
295 }
296 const oldName = yield* createName(id, olds);
297 const newName = yield* createName(id, news);
298 if (oldName !== newName) {
299 return { action: "replace" } as const;
300 }
301 // description/options fall through to the default update path
302 }),
303
304 reconcile: Effect.fn(function* ({ id, news, output, session }) {
305 const type = news.type;
306 const name = output?.configName ?? (yield* createName(id, news));
307 const configId =
308 output?.configId ?? (yield* computeConfigId(type, name));
309 const desiredSaml = toWireSamlOptions(news.samlOptions);
310
311 // 1. OBSERVE
312 let detail = yield* observe(configId);
313
314 // 2. ENSURE — create if missing; tolerate a concurrent create race
315 if (detail?.id === undefined) {
316 detail = yield* aoss
317 .createSecurityConfig({
318 type,
319 name,
320 description: news.description,
321 samlOptions: desiredSaml,
322 iamIdentityCenterOptions: news.iamIdentityCenterOptions,
323 iamFederationOptions: news.iamFederationOptions,
324 })
325 .pipe(
326 Effect.map((r) => r.securityConfigDetail),
327 Effect.catchTag("ConflictException", () => observe(configId)),
328 );
329 } else {
330 // 3. SYNC — update when observed drifts from desired
331 const samlDrift =
332 desiredSaml !== undefined &&
333 (desiredSaml.metadata !== detail.samlOptions?.metadata ||
334 desiredSaml.userAttribute !==
335 detail.samlOptions?.userAttribute ||
336 desiredSaml.groupAttribute !==
337 detail.samlOptions?.groupAttribute ||
338 (desiredSaml.sessionTimeout !== undefined &&
339 desiredSaml.sessionTimeout !==
340 detail.samlOptions?.sessionTimeout));
341 const federationDrift =
342 news.iamFederationOptions !== undefined &&
343 (news.iamFederationOptions.userAttribute !==
344 detail.iamFederationOptions?.userAttribute ||
345 news.iamFederationOptions.groupAttribute !==
346 detail.iamFederationOptions?.groupAttribute);
347 const identityCenterDrift =
348 news.iamIdentityCenterOptions !== undefined &&
349 (news.iamIdentityCenterOptions.userAttribute !==
350 detail.iamIdentityCenterOptions?.userAttribute ||
351 news.iamIdentityCenterOptions.groupAttribute !==
352 detail.iamIdentityCenterOptions?.groupAttribute);
353 const descriptionDrift =
354 news.description !== undefined &&
355 news.description !== detail.description;
356 if (
357 samlDrift ||
358 federationDrift ||
359 identityCenterDrift ||
360 descriptionDrift
361 ) {
362 detail = yield* aoss
363 .updateSecurityConfig({
364 id: detail.id,
365 configVersion: detail.configVersion!,
366 description: descriptionDrift ? news.description : undefined,
367 samlOptions: samlDrift ? desiredSaml : undefined,
368 iamIdentityCenterOptionsUpdates: identityCenterDrift
369 ? {
370 userAttribute:
371 news.iamIdentityCenterOptions?.userAttribute,
372 groupAttribute:
373 news.iamIdentityCenterOptions?.groupAttribute,
374 }
375 : undefined,
376 iamFederationOptions: federationDrift
377 ? news.iamFederationOptions
378 : undefined,
379 })
380 .pipe(Effect.map((r) => r.securityConfigDetail));
381 }
382 }
383
384 if (detail?.id === undefined) {
385 return yield* Effect.fail(
386 new aoss.ResourceNotFoundException({
387 message: `security config ${configId} not visible after reconcile`,
388 }),
389 );
390 }
391 yield* session.note(detail.id);
392 return toAttributes(detail);
393 }),
394
395 delete: Effect.fn(function* ({ output }) {
396 yield* retryWhileConflict(
397 aoss.deleteSecurityConfig({ id: output.configId }),
398 ).pipe(
399 Effect.catchTag("ResourceNotFoundException", () => Effect.void),
400 );
401 }),
402 });
403 }),
404 );