import * as ag from "@distilled.cloud/aws/api-gateway"; import type * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Stream from "effect/Stream"; import { deepEqual, isResolved } from "../../Diff.ts"; import type { Input } from "../../Input.ts"; import { createPhysicalName } from "../../PhysicalName.ts"; import * as Provider from "../../Provider.ts"; import { Resource } from "../../Resource.ts"; import { toWireSeconds } from "../../Util/Duration.ts"; import type { Providers } from "../Providers.ts"; export interface AuthorizerProps { /** * REST API identifier that owns the authorizer. */ restApiId: Input; /** * Authorizer name. * * If omitted, Alchemy generates a deterministic physical name. */ name?: string; /** * Authorizer type. */ type: ag.AuthorizerType; /** * Cognito user pool ARNs for `COGNITO_USER_POOLS` authorizers. */ providerARNs?: string[]; /** * Custom authorization type label. */ authType?: string; /** * Lambda invocation URI for `TOKEN` or `REQUEST` authorizers. */ authorizerUri?: string; /** * IAM role ARN used by API Gateway to invoke the authorizer. * * This is not secret key material; API Gateway stores the role ARN. */ authorizerCredentials?: string; /** * Identity source expression, e.g. `method.request.header.Authorization`. */ identitySource?: string; /** * Validation regex for token authorizers. */ identityValidationExpression?: string; /** * Cache TTL for authorizer results (e.g. `"5 minutes"` or * `Duration.seconds(300)`; a bare number is milliseconds). Sent to the * API as whole seconds (`authorizerResultTtlInSeconds`). */ authorizerResultTtl?: Duration.Input; } /** @resource */ export interface Authorizer extends Resource< "AWS.ApiGateway.Authorizer", AuthorizerProps, { authorizerId: string; restApiId: string; name: string; type: ag.AuthorizerType; }, never, Providers > {} /** * REST API Lambda, Cognito, or gateway authorizer. * * ### Authorizers * **Example:** Lambda TOKEN authorizer * ```typescript * const authorizer = yield* ApiGateway.Authorizer("Auth", { * restApiId: api.restApiId, * type: "TOKEN", * authorizerUri: authorizerInvokeArn, * identitySource: "method.request.header.Authorization", * }); * ``` */ const AuthorizerResource = Resource("AWS.ApiGateway.Authorizer"); export { AuthorizerResource as Authorizer }; const generatedName = (id: string, props: AuthorizerProps) => props.name ? Effect.succeed(props.name) : createPhysicalName({ id, maxLength: 128, }); export const AuthorizerProvider = () => Provider.effect( AuthorizerResource, Effect.gen(function* () { return { stables: ["authorizerId", "restApiId"] as const, diff: Effect.fn(function* ({ news: newsIn, olds }) { if (!isResolved(newsIn)) return; const news = newsIn as AuthorizerProps; if ( // These fields define the authorizer identity and kind; replacement // avoids patching a different authorizer shape in place. news.restApiId !== olds.restApiId || (news.name !== undefined && news.name !== olds.name) || news.type !== olds.type ) { return { action: "replace" } as const; } if (!deepEqual(news.providerARNs, olds.providerARNs)) { return { action: "replace" } as const; } if (news.authType !== olds.authType) { return { action: "replace" } as const; } }), read: Effect.fn(function* ({ output }) { if (!output?.authorizerId) return undefined; const a = yield* ag .getAuthorizer({ restApiId: output.restApiId, authorizerId: output.authorizerId, }) .pipe( Effect.catchTag("NotFoundException", () => Effect.succeed(undefined), ), ); if (!a?.id) return undefined; return { authorizerId: a.id, restApiId: output.restApiId, name: a.name!, type: a.type!, }; }), reconcile: Effect.fn(function* ({ id, news: newsIn, output, session }) { if (!isResolved(newsIn)) { return yield* Effect.die("Authorizer props were not resolved"); } const news = newsIn as Input.ResolveProps; const name = yield* generatedName(id, news); const restApiId = (output?.restApiId ?? news.restApiId) as string; // Observe — fetch the live authorizer if we have a cached id. // The cached id is the only way to find the resource; on a stale // delete out of band we re-create. let observed = output?.authorizerId ? yield* ag .getAuthorizer({ restApiId, authorizerId: output.authorizerId, }) .pipe( Effect.catchTag("NotFoundException", () => Effect.succeed(undefined), ), ) : undefined; // Ensure — create if missing. if (!observed?.id) { const created = yield* ag.createAuthorizer({ restApiId: news.restApiId as string, name, type: news.type, providerARNs: news.providerARNs, authType: news.authType, authorizerUri: news.authorizerUri, authorizerCredentials: news.authorizerCredentials, identitySource: news.identitySource, identityValidationExpression: news.identityValidationExpression, authorizerResultTtlInSeconds: toWireSeconds( news.authorizerResultTtl, ), }); if (!created.id) return yield* Effect.die("createAuthorizer missing id"); yield* session.note(`Created authorizer ${created.id}`); observed = yield* ag.getAuthorizer({ restApiId: news.restApiId as string, authorizerId: created.id, }); } const authorizerId = observed.id!; // Sync mutable fields — diff observed cloud state against desired. const patches: ag.PatchOperation[] = []; if (news.authorizerUri !== observed.authorizerUri) { patches.push({ op: news.authorizerUri === undefined ? "remove" : "replace", path: "/authorizerUri", value: news.authorizerUri, }); } if (news.identitySource !== observed.identitySource) { patches.push({ op: news.identitySource === undefined ? "remove" : "replace", path: "/identitySource", value: news.identitySource, }); } if (news.authorizerCredentials !== observed.authorizerCredentials) { patches.push({ op: news.authorizerCredentials === undefined ? "remove" : "replace", path: "/authorizerCredentials", value: news.authorizerCredentials, }); } if ( news.identityValidationExpression !== observed.identityValidationExpression ) { patches.push({ op: news.identityValidationExpression === undefined ? "remove" : "replace", path: "/identityValidationExpression", value: news.identityValidationExpression, }); } const desiredTtlSeconds = toWireSeconds(news.authorizerResultTtl); if (desiredTtlSeconds !== observed.authorizerResultTtlInSeconds) { patches.push({ op: desiredTtlSeconds === undefined ? "remove" : "replace", path: "/authorizerResultTtlInSeconds", value: desiredTtlSeconds === undefined ? undefined : String(desiredTtlSeconds), }); } if (patches.length > 0) { yield* ag.updateAuthorizer({ restApiId, authorizerId, patchOperations: patches, }); } yield* session.note(`Reconciled authorizer ${authorizerId}`); const final = yield* ag.getAuthorizer({ restApiId, authorizerId, }); return { authorizerId, restApiId, name: final.name!, type: final.type!, }; }), // Authorizers are sub-resources of a RestApi and can only be listed // per-parent (`GET /restapis/{restApiId}/authorizers`). Enumerate every // RestApi (paginated), then fan out the per-api authorizer list. list: () => Effect.gen(function* () { const restApiIds = yield* ag.getRestApis.pages({}).pipe( Stream.runCollect, Effect.map((chunk) => Array.from(chunk).flatMap((page) => (page.items ?? []) .map((api) => api.id) .filter((id): id is string => id != null), ), ), ); const rows = yield* Effect.forEach( restApiIds, (restApiId) => ag.getAuthorizers({ restApiId }).pipe( Effect.map((res) => (res.items ?? []) .filter( (a): a is ag.Authorizer & { id: string } => a.id != null, ) .map((a) => ({ authorizerId: a.id, restApiId, name: a.name!, type: a.type!, })), ), ), { concurrency: 10 }, ); return rows.flat(); }), delete: Effect.fn(function* ({ output, session }) { yield* ag .deleteAuthorizer({ restApiId: output.restApiId, authorizerId: output.authorizerId, }) .pipe(Effect.catchTag("NotFoundException", () => Effect.void)); yield* session.note(`Deleted authorizer ${output.authorizerId}`); }), }; }), );