1import type * as WAFV2 from "@distilled.cloud/aws/wafv2";
2import * as wafv2 from "@distilled.cloud/aws/wafv2";
3import * as Effect from "effect/Effect";
4import { Unowned } from "../../AdoptPolicy.ts";
5import { deepEqual, isResolved } from "../../Diff.ts";
6import { createPhysicalName } from "../../PhysicalName.ts";
7import * as Provider from "../../Provider.ts";
8import { Resource } from "../../Resource.ts";
9import {
10 createInternalTags,
11 createTagsList,
12 hasAlchemyTags,
13} from "../../Tags.ts";
14import type { Providers } from "../Providers.ts";
15import {
16 fetchWafTags,
17 normalizeWafRules,
18 retryAssociatedItem,
19 retryOptimisticLock,
20 retryUnavailableEntity,
21 syncWafTags,
22 type WafScope,
23 withWafScope,
24} from "./internal.ts";
25
26export interface WebACLProps {
27 /**
28 * Name of the web ACL. Must match `^[\w\-]+$` and be 1-128 characters.
29 * Changing the name replaces the web ACL.
30 * @default a physical name derived from the app, stage and logical ID
31 */
32 webAclName?: string;
33 /**
34 * Scope of the web ACL. `REGIONAL` protects regional resources (ALB,
35 * API Gateway, AppSync, Cognito user pool, App Runner, Verified Access)
36 * in the ambient region; `CLOUDFRONT` protects CloudFront distributions
37 * and is always provisioned in `us-east-1` (the provider pins the region
38 * automatically). Changing the scope replaces the web ACL.
39 * @default "REGIONAL"
40 */
41 scope?: WafScope;
42 /**
43 * Action to take on a request that matches none of the rules.
44 * @default { Allow: {} }
45 */
46 defaultAction?: WAFV2.DefaultAction;
47 /**
48 * Description of the web ACL.
49 */
50 description?: string;
51 /**
52 * Rules to evaluate, in `Priority` order. Supports custom statements
53 * (byte match, rate-based, geo, IP set references, ...) and managed rule
54 * groups (e.g. `AWSManagedRulesCommonRuleSet`). Raw WAFv2 API shapes.
55 */
56 rules?: WAFV2.Rule[];
57 /**
58 * CloudWatch metrics and sampled-request settings for the web ACL itself.
59 * @default sampled requests + metrics enabled, MetricName = the web ACL name
60 */
61 visibilityConfig?: WAFV2.VisibilityConfig;
62 /**
63 * Custom response bodies referenced by rule actions in this web ACL.
64 */
65 customResponseBodies?: {
66 [key: string]: WAFV2.CustomResponseBody | undefined;
67 };
68 /**
69 * Default CAPTCHA immunity-time configuration.
70 */
71 captchaConfig?: WAFV2.CaptchaConfig;
72 /**
73 * Default challenge immunity-time configuration.
74 */
75 challengeConfig?: WAFV2.ChallengeConfig;
76 /**
77 * Domains that WAF accepts for CAPTCHA/challenge tokens.
78 */
79 tokenDomains?: string[];
80 /**
81 * Association-level configuration (e.g. request body size limits).
82 */
83 associationConfig?: WAFV2.AssociationConfig;
84 /**
85 * User-defined tags to apply to the web ACL.
86 */
87 tags?: Record<string, string>;
88}
89
90export interface WebACL extends Resource<
91 "AWS.WAFv2.WebACL",
92 WebACLProps,
93 {
94 /**
95 * Name of the web ACL.
96 */
97 webAclName: string;
98 /**
99 * WAF-assigned unique ID of the web ACL.
100 */
101 webAclId: string;
102 /**
103 * ARN of the web ACL. Use this to associate regional resources
104 * (via {@link WebACLAssociation}) or as CloudFront's `webAclId`.
105 */
106 webAclArn: string;
107 /**
108 * Scope the web ACL was created in.
109 */
110 scope: WafScope;
111 },
112 never,
113 Providers
114> {}
115
116/**
117 * An AWS WAFv2 Web ACL — a collection of rules that inspect and control web
118 * requests for the AWS resources it is associated with.
119 *
120 * `REGIONAL` web ACLs protect regional resources (Application Load Balancer,
121 * API Gateway, AppSync, Cognito user pools, App Runner, Verified Access) via
122 * {@link WebACLAssociation}. `CLOUDFRONT` web ACLs protect CloudFront
123 * distributions (set `Distribution.webAclId` to the web ACL's ARN) and are
124 * always provisioned in `us-east-1` — the provider pins the region for you.
125 *
126 * ### Creating Web ACLs
127 * **Example:** Allow-by-Default Web ACL with a Managed Rule Group
128 * ```typescript
129 * const acl = yield* AWS.WAFv2.WebACL("ApiFirewall", {
130 * rules: [
131 * {
132 * Name: "common-rule-set",
133 * Priority: 0,
134 * Statement: {
135 * ManagedRuleGroupStatement: {
136 * VendorName: "AWS",
137 * Name: "AWSManagedRulesCommonRuleSet",
138 * },
139 * },
140 * OverrideAction: { None: {} },
141 * VisibilityConfig: {
142 * SampledRequestsEnabled: true,
143 * CloudWatchMetricsEnabled: true,
144 * MetricName: "common-rule-set",
145 * },
146 * },
147 * ],
148 * });
149 * ```
150 *
151 * **Example:** Rate Limiting Requests per IP
152 * ```typescript
153 * const acl = yield* AWS.WAFv2.WebACL("RateLimited", {
154 * defaultAction: { Allow: {} },
155 * rules: [
156 * {
157 * Name: "rate-limit",
158 * Priority: 0,
159 * Statement: {
160 * RateBasedStatement: { Limit: 100, AggregateKeyType: "IP" },
161 * },
162 * Action: { Block: {} },
163 * VisibilityConfig: {
164 * SampledRequestsEnabled: true,
165 * CloudWatchMetricsEnabled: true,
166 * MetricName: "rate-limit",
167 * },
168 * },
169 * ],
170 * });
171 * ```
172 *
173 * ### CloudFront Scope
174 * **Example:** Web ACL for a CloudFront Distribution
175 * ```typescript
176 * const acl = yield* AWS.WAFv2.WebACL("EdgeFirewall", {
177 * scope: "CLOUDFRONT", // provisioned in us-east-1 automatically
178 * defaultAction: { Allow: {} },
179 * });
180 *
181 * const distribution = yield* AWS.CloudFront.Distribution("Site", {
182 * // ...
183 * webAclId: acl.webAclArn,
184 * });
185 * ```
186 *
187 * ### Protecting Regional Resources
188 * **Example:** Associate with a Cognito User Pool
189 * ```typescript
190 * const association = yield* AWS.WAFv2.WebACLAssociation("PoolFirewall", {
191 * webAclArn: acl.webAclArn,
192 * resourceArn: userPool.userPoolArn,
193 * });
194 * ```
195 *
196 * @resource
197 */
198export const WebACL = Resource<WebACL>("AWS.WAFv2.WebACL");
199
200const defaultScope: WafScope = "REGIONAL";
201
202const defaultVisibilityConfig = (name: string): WAFV2.VisibilityConfig => ({
203 SampledRequestsEnabled: true,
204 CloudWatchMetricsEnabled: true,
205 MetricName: name,
206});
207
208const toAttrs = (acl: WAFV2.WebACL, scope: WafScope) => ({
209 webAclName: acl.Name,
210 webAclId: acl.Id,
211 webAclArn: acl.ARN,
212 scope,
213});
214
215export const WebACLProvider = () =>
216 Provider.effect(
217 WebACL,
218 Effect.gen(function* () {
219 const createName = Effect.fn(function* (id: string, props: WebACLProps) {
220 return (
221 props.webAclName ??
222 (yield* createPhysicalName({ id, maxLength: 128 }))
223 );
224 });
225
226 // Resolve a web ACL to its full detail + LockToken. Prefers the
227 // cached Id (fast path); falls back to searching list pages by name.
228 const findWebACL = Effect.fn(function* (
229 scope: WafScope,
230 name: string,
231 cachedId: string | undefined,
232 ) {
233 if (cachedId !== undefined) {
234 const byId = yield* withWafScope(
235 scope,
236 wafv2
237 .getWebACL({ Name: name, Scope: scope, Id: cachedId })
238 .pipe(
239 Effect.catchTag("WAFNonexistentItemException", () =>
240 Effect.succeed(undefined),
241 ),
242 ),
243 );
244 if (byId?.WebACL) {
245 return byId;
246 }
247 }
248 let marker: string | undefined;
249 for (let page = 0; page < 20; page++) {
250 const listed = yield* withWafScope(
251 scope,
252 wafv2.listWebACLs({ Scope: scope, NextMarker: marker, Limit: 100 }),
253 );
254 const summary = listed.WebACLs?.find((s) => s.Name === name);
255 if (summary?.Id !== undefined) {
256 return yield* withWafScope(
257 scope,
258 wafv2
259 .getWebACL({ Name: name, Scope: scope, Id: summary.Id })
260 .pipe(
261 Effect.catchTag("WAFNonexistentItemException", () =>
262 Effect.succeed(undefined),
263 ),
264 ),
265 );
266 }
267 if (!listed.NextMarker || (listed.WebACLs?.length ?? 0) === 0) {
268 break;
269 }
270 marker = listed.NextMarker;
271 }
272 return undefined;
273 });
274
275 const buildDesired = (name: string, props: WebACLProps) => ({
276 DefaultAction: props.defaultAction ?? { Allow: {} },
277 Description: props.description,
278 // props survive engine serialization as plain JSON — restore
279 // ByteMatchStatement SearchString blobs to Uint8Array
280 Rules: normalizeWafRules(props.rules),
281 VisibilityConfig:
282 props.visibilityConfig ?? defaultVisibilityConfig(name),
283 CustomResponseBodies: props.customResponseBodies,
284 CaptchaConfig: props.captchaConfig,
285 ChallengeConfig: props.challengeConfig,
286 TokenDomains: props.tokenDomains,
287 AssociationConfig: props.associationConfig,
288 });
289
290 const listScope = Effect.fn(function* (scope: WafScope) {
291 const rows: ReturnType<typeof toAttrs>[] = [];
292 let marker: string | undefined;
293 for (let page = 0; page < 50; page++) {
294 const listed = yield* withWafScope(
295 scope,
296 wafv2.listWebACLs({ Scope: scope, NextMarker: marker, Limit: 100 }),
297 );
298 for (const summary of listed.WebACLs ?? []) {
299 if (summary.Name && summary.Id && summary.ARN) {
300 rows.push({
301 webAclName: summary.Name,
302 webAclId: summary.Id,
303 webAclArn: summary.ARN,
304 scope,
305 });
306 }
307 }
308 if (!listed.NextMarker || (listed.WebACLs?.length ?? 0) === 0) {
309 break;
310 }
311 marker = listed.NextMarker;
312 }
313 return rows;
314 });
315
316 return {
317 stables: ["webAclName", "webAclId", "webAclArn", "scope"],
318
319 list: () =>
320 Effect.gen(function* () {
321 const regional = yield* listScope("REGIONAL");
322 const cloudfront = yield* listScope("CLOUDFRONT");
323 return [...regional, ...cloudfront];
324 }),
325
326 diff: Effect.fn(function* ({ id, news, olds }) {
327 if (!isResolved(news)) return undefined;
328 const oldName = yield* createName(id, olds ?? {});
329 const newName = yield* createName(id, news);
330 if (oldName !== newName) {
331 return { action: "replace" } as const;
332 }
333 if ((olds?.scope ?? defaultScope) !== (news.scope ?? defaultScope)) {
334 return { action: "replace" } as const;
335 }
336 }),
337
338 read: Effect.fn(function* ({ id, olds, output }) {
339 const scope = output?.scope ?? olds?.scope ?? defaultScope;
340 const name =
341 output?.webAclName ?? (yield* createName(id, olds ?? {}));
342 const found = yield* findWebACL(scope, name, output?.webAclId);
343 if (!found?.WebACL) {
344 return undefined;
345 }
346 const attrs = toAttrs(found.WebACL, scope);
347 const tags = yield* fetchWafTags(scope, found.WebACL.ARN);
348 return (yield* hasAlchemyTags(id, tags)) ? attrs : Unowned(attrs);
349 }),
350
351 reconcile: Effect.fn(function* ({ id, news, output, session }) {
352 const scope = news.scope ?? defaultScope;
353 const name = output?.webAclName ?? (yield* createName(id, news));
354 const internalTags = yield* createInternalTags(id);
355 const desiredTags = { ...news.tags, ...internalTags };
356 const desired = buildDesired(name, news);
357
358 // 1. Observe — cloud state is authoritative; output only caches Id.
359 let observed = yield* findWebACL(scope, name, output?.webAclId);
360
361 // 2. Ensure — create if missing; a WAFDuplicateItemException is a
362 // race with a concurrent creator, so fall through to re-read.
363 if (!observed?.WebACL) {
364 yield* retryUnavailableEntity(
365 withWafScope(
366 scope,
367 wafv2
368 .createWebACL({
369 Name: name,
370 Scope: scope,
371 ...desired,
372 Tags: createTagsList(desiredTags),
373 })
374 .pipe(
375 Effect.catchTag("WAFDuplicateItemException", () =>
376 Effect.succeed(undefined),
377 ),
378 ),
379 ),
380 );
381 observed = yield* findWebACL(scope, name, undefined);
382 }
383
384 if (!observed?.WebACL) {
385 return yield* Effect.fail(
386 new Error(`Failed to observe WebACL '${name}' after create`),
387 );
388 }
389
390 const acl = observed.WebACL;
391 yield* session.note(acl.ARN);
392
393 // 3. Sync — diff the OBSERVED mutable aspects against desired and
394 // apply a single updateWebACL when drifted. The update re-reads
395 // the LockToken inside the retry so optimistic-lock conflicts
396 // converge.
397 const observedAspects: Record<string, unknown> = {
398 DefaultAction: acl.DefaultAction,
399 Description: acl.Description,
400 Rules: acl.Rules ?? [],
401 VisibilityConfig: acl.VisibilityConfig,
402 TokenDomains: acl.TokenDomains,
403 };
404 const desiredAspects: Record<string, unknown> = {
405 DefaultAction: desired.DefaultAction,
406 Description: desired.Description,
407 Rules: desired.Rules,
408 VisibilityConfig: desired.VisibilityConfig,
409 TokenDomains: desired.TokenDomains,
410 };
411 // WAF materializes defaults for aspects the user never set (e.g.
412 // CaptchaConfig immunity time), so only compare the optional
413 // aspects the user actually declared.
414 if (news.customResponseBodies !== undefined) {
415 observedAspects.CustomResponseBodies = acl.CustomResponseBodies;
416 desiredAspects.CustomResponseBodies = desired.CustomResponseBodies;
417 }
418 if (news.captchaConfig !== undefined) {
419 observedAspects.CaptchaConfig = acl.CaptchaConfig;
420 desiredAspects.CaptchaConfig = desired.CaptchaConfig;
421 }
422 if (news.challengeConfig !== undefined) {
423 observedAspects.ChallengeConfig = acl.ChallengeConfig;
424 desiredAspects.ChallengeConfig = desired.ChallengeConfig;
425 }
426 if (news.associationConfig !== undefined) {
427 observedAspects.AssociationConfig = acl.AssociationConfig;
428 desiredAspects.AssociationConfig = desired.AssociationConfig;
429 }
430 if (
431 !deepEqual(observedAspects, desiredAspects, { stripNullish: true })
432 ) {
433 yield* retryOptimisticLock(
434 Effect.gen(function* () {
435 const fresh = yield* findWebACL(scope, name, acl.Id);
436 if (!fresh?.WebACL || fresh.LockToken === undefined) {
437 return;
438 }
439 yield* retryUnavailableEntity(
440 withWafScope(
441 scope,
442 wafv2.updateWebACL({
443 Name: name,
444 Scope: scope,
445 Id: fresh.WebACL.Id,
446 LockToken: fresh.LockToken,
447 ...desired,
448 }),
449 ),
450 );
451 }),
452 );
453 }
454
455 // 3b. Sync tags against OBSERVED cloud tags.
456 yield* syncWafTags(scope, acl.ARN, desiredTags);
457
458 // 4. Return fresh attributes.
459 return toAttrs(acl, scope);
460 }),
461
462 delete: Effect.fn(function* ({ output }) {
463 const scope = output.scope;
464 // Re-read for a fresh LockToken on every attempt; tolerate the
465 // ACL already being gone, a stale lock, or a still-propagating
466 // disassociation.
467 yield* retryAssociatedItem(
468 retryOptimisticLock(
469 Effect.gen(function* () {
470 const found = yield* withWafScope(
471 scope,
472 wafv2
473 .getWebACL({
474 Name: output.webAclName,
475 Scope: scope,
476 Id: output.webAclId,
477 })
478 .pipe(
479 Effect.catchTag("WAFNonexistentItemException", () =>
480 Effect.succeed(undefined),
481 ),
482 ),
483 );
484 if (!found?.WebACL || found.LockToken === undefined) {
485 return;
486 }
487 yield* withWafScope(
488 scope,
489 wafv2
490 .deleteWebACL({
491 Name: output.webAclName,
492 Scope: scope,
493 Id: output.webAclId,
494 LockToken: found.LockToken,
495 })
496 .pipe(
497 Effect.catchTag(
498 "WAFNonexistentItemException",
499 () => Effect.void,
500 ),
501 ),
502 );
503 }),
504 ),
505 );
506 }),
507 };
508 }),
509 );