import * as aoss from "@distilled.cloud/aws/opensearchserverless"; import type * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Stream from "effect/Stream"; import { isResolved } from "../../Diff.ts"; import { createPhysicalName } from "../../PhysicalName.ts"; import * as Provider from "../../Provider.ts"; import { Resource } from "../../Resource.ts"; import { toWireMinutes } from "../../Util/Duration.ts"; import { AWSEnvironment } from "../Environment.ts"; import type { Providers } from "../Providers.ts"; import { retryWhileConflict } from "./internal.ts"; /** * The kind of security configuration: * - `saml` — SAML federation with an external identity provider. * - `iamidentitycenter` — AWS IAM Identity Center integration. * - `iamfederation` — IAM federation via session attributes. */ export type SecurityConfigType = "saml" | "iamidentitycenter" | "iamfederation"; export interface SamlOptions { /** * The XML IdP metadata document generated by the identity provider. */ metadata: string; /** * A user attribute for the SAML assertion. */ userAttribute?: string; /** * A group attribute for the SAML assertion. */ groupAttribute?: string; /** * Custom entity ID used as the audience restriction of the SAML assertion. */ openSearchServerlessEntityId?: string; /** * How long a SAML session remains valid. Accepts any `Duration.Input` * (e.g. `"2 hours"`, `Duration.minutes(90)`; a bare number is milliseconds); * the wire unit is minutes. * @default 60 minutes (maximum 12 hours) */ sessionTimeout?: Duration.Input; } export interface IamIdentityCenterOptions { /** * ARN of the IAM Identity Center instance. */ instanceArn: string; /** * The user attribute to map (e.g. `UserId`, `UserName`, `Email`). */ userAttribute?: string; /** * The group attribute to map (e.g. `GroupId`, `GroupName`). */ groupAttribute?: string; } export interface IamFederationOptions { /** * The session attribute that carries the user identity. */ userAttribute?: string; /** * The session attribute that carries the user's groups. */ groupAttribute?: string; } export interface SecurityConfigProps { /** * Name of the security configuration (3-32 characters, lowercase). Changing * the name replaces the configuration. * @default a generated physical name */ configName?: string; /** * The configuration kind (`saml`, `iamidentitycenter`, or `iamfederation`). * Changing the type replaces the configuration. */ type: SecurityConfigType; /** * A human-readable description of the configuration. */ description?: string; /** * SAML options — required when `type` is `saml`. */ samlOptions?: SamlOptions; /** * IAM Identity Center options — required when `type` is * `iamidentitycenter`. `instanceArn` is create-only. */ iamIdentityCenterOptions?: IamIdentityCenterOptions; /** * IAM federation options — required when `type` is `iamfederation`. */ iamFederationOptions?: IamFederationOptions; } export interface SecurityConfig extends Resource< "AWS.OpenSearchServerless.SecurityConfig", SecurityConfigProps, { /** * Unique identifier of the security configuration, in the format * `{type}/{accountId}/{name}`. Use this as the `Principal` in a data * access policy to grant federated identities access. */ configId: string; /** * Name of the security configuration. */ configName: string; /** * Configuration type (`saml`, `iamidentitycenter`, or `iamfederation`). */ type: string; /** * Version of the configuration, used for optimistic-concurrency updates. */ configVersion: string; /** * Description of the security configuration. */ description?: string; }, {}, Providers > {} /** * An Amazon OpenSearch Serverless security configuration. Security * configurations federate OpenSearch Dashboards sign-in with SAML identity * providers, AWS IAM Identity Center, or IAM federation, so human users can * access collections without IAM credentials. * * The configuration's `configId` (format `saml/{accountId}/{name}`) is what a * data {@link AccessPolicy} references as a `Principal` to grant the federated * identities index- and collection-level permissions. * * ### SAML Authentication * **Example:** Federate Dashboards with a SAML Identity Provider * ```typescript * import * as AWS from "alchemy/AWS"; * * const saml = yield* AWS.OpenSearchServerless.SecurityConfig("Saml", { * configName: "my-idp", * type: "saml", * samlOptions: { * metadata: idpMetadataXml, * groupAttribute: "groups", * sessionTimeout: "4 hours", * }, * }); * // Reference saml.configId as a Principal in a data access policy * ``` * * ### IAM Federation * **Example:** Map Session Attributes to Identities * ```typescript * const federation = yield* AWS.OpenSearchServerless.SecurityConfig("Federation", { * configName: "my-federation", * type: "iamfederation", * iamFederationOptions: { * userAttribute: "user", * groupAttribute: "groups", * }, * }); * ``` * * @resource */ export const SecurityConfig = Resource( "AWS.OpenSearchServerless.SecurityConfig", ); export const SecurityConfigProvider = () => Provider.effect( SecurityConfig, Effect.gen(function* () { const createName = Effect.fn(function* ( id: string, props: { configName?: string | undefined }, ) { return ( props.configName ?? (yield* createPhysicalName({ id, maxLength: 32, lowercase: true })) ); }); // A security config's id is deterministic: {type}/{accountId}/{name}. const computeConfigId = Effect.fn(function* (type: string, name: string) { const { accountId } = yield* AWSEnvironment.current; return `${type}/${accountId}/${name}`; }); const toName = (configId: string) => configId.split("/").at(-1)!; const toAttributes = (detail: aoss.SecurityConfigDetail) => ({ configId: detail.id!, configName: toName(detail.id!), type: detail.type!, configVersion: detail.configVersion!, description: detail.description, }); const toWireSamlOptions = (saml: SamlOptions | undefined) => saml === undefined ? undefined : { metadata: saml.metadata, userAttribute: saml.userAttribute, groupAttribute: saml.groupAttribute, openSearchServerlessEntityId: saml.openSearchServerlessEntityId, sessionTimeout: toWireMinutes(saml.sessionTimeout), }; const observe = Effect.fn(function* (configId: string) { return yield* aoss.getSecurityConfig({ id: configId }).pipe( Effect.map((r) => r.securityConfigDetail), Effect.catchTag("ResourceNotFoundException", () => Effect.succeed(undefined), ), ); }); return SecurityConfig.Provider.of({ stables: ["configId", "configName", "type"], list: () => Effect.gen(function* () { const types: SecurityConfigType[] = [ "saml", "iamidentitycenter", "iamfederation", ]; const results: { configId: string; configName: string; type: string; configVersion: string; description?: string; }[] = []; for (const type of types) { const pages = yield* aoss.listSecurityConfigs .pages({ type }) .pipe(Stream.runCollect); for (const page of pages) { for (const s of page.securityConfigSummaries ?? []) { if ( s.id !== undefined && s.type !== undefined && s.configVersion !== undefined ) { results.push({ configId: s.id, configName: toName(s.id), type: s.type, configVersion: s.configVersion, description: s.description, }); } } } } return results; }), read: Effect.fn(function* ({ id, olds, output }) { const type = output?.type ?? olds?.type; if (type === undefined) { return undefined; } const configId = output?.configId ?? (yield* computeConfigId(type, yield* createName(id, olds ?? {}))); const detail = yield* observe(configId); if (detail?.id === undefined) { return undefined; } // Security configs carry no tags, so an existing same-name config is // adopted. return toAttributes(detail); }), diff: Effect.fn(function* ({ id, news, olds }) { if (!isResolved(news)) return undefined; if (olds.type !== news.type) { return { action: "replace" } as const; } const oldName = yield* createName(id, olds); const newName = yield* createName(id, news); if (oldName !== newName) { return { action: "replace" } as const; } // description/options fall through to the default update path }), reconcile: Effect.fn(function* ({ id, news, output, session }) { const type = news.type; const name = output?.configName ?? (yield* createName(id, news)); const configId = output?.configId ?? (yield* computeConfigId(type, name)); const desiredSaml = toWireSamlOptions(news.samlOptions); // 1. OBSERVE let detail = yield* observe(configId); // 2. ENSURE — create if missing; tolerate a concurrent create race if (detail?.id === undefined) { detail = yield* aoss .createSecurityConfig({ type, name, description: news.description, samlOptions: desiredSaml, iamIdentityCenterOptions: news.iamIdentityCenterOptions, iamFederationOptions: news.iamFederationOptions, }) .pipe( Effect.map((r) => r.securityConfigDetail), Effect.catchTag("ConflictException", () => observe(configId)), ); } else { // 3. SYNC — update when observed drifts from desired const samlDrift = desiredSaml !== undefined && (desiredSaml.metadata !== detail.samlOptions?.metadata || desiredSaml.userAttribute !== detail.samlOptions?.userAttribute || desiredSaml.groupAttribute !== detail.samlOptions?.groupAttribute || (desiredSaml.sessionTimeout !== undefined && desiredSaml.sessionTimeout !== detail.samlOptions?.sessionTimeout)); const federationDrift = news.iamFederationOptions !== undefined && (news.iamFederationOptions.userAttribute !== detail.iamFederationOptions?.userAttribute || news.iamFederationOptions.groupAttribute !== detail.iamFederationOptions?.groupAttribute); const identityCenterDrift = news.iamIdentityCenterOptions !== undefined && (news.iamIdentityCenterOptions.userAttribute !== detail.iamIdentityCenterOptions?.userAttribute || news.iamIdentityCenterOptions.groupAttribute !== detail.iamIdentityCenterOptions?.groupAttribute); const descriptionDrift = news.description !== undefined && news.description !== detail.description; if ( samlDrift || federationDrift || identityCenterDrift || descriptionDrift ) { detail = yield* aoss .updateSecurityConfig({ id: detail.id, configVersion: detail.configVersion!, description: descriptionDrift ? news.description : undefined, samlOptions: samlDrift ? desiredSaml : undefined, iamIdentityCenterOptionsUpdates: identityCenterDrift ? { userAttribute: news.iamIdentityCenterOptions?.userAttribute, groupAttribute: news.iamIdentityCenterOptions?.groupAttribute, } : undefined, iamFederationOptions: federationDrift ? news.iamFederationOptions : undefined, }) .pipe(Effect.map((r) => r.securityConfigDetail)); } } if (detail?.id === undefined) { return yield* Effect.fail( new aoss.ResourceNotFoundException({ message: `security config ${configId} not visible after reconcile`, }), ); } yield* session.note(detail.id); return toAttributes(detail); }), delete: Effect.fn(function* ({ output }) { yield* retryWhileConflict( aoss.deleteSecurityConfig({ id: output.configId }), ).pipe( Effect.catchTag("ResourceNotFoundException", () => Effect.void), ); }), }); }), );