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 RuleGroupProps {
27 /**
28 * Name of the rule group. Must match `^[\w\-]+$` and be 1-128 characters.
29 * Changing the name replaces the rule group.
30 * @default a physical name derived from the app, stage and logical ID
31 */
32 ruleGroupName?: string;
33 /**
34 * Scope of the rule group — `REGIONAL` (ambient region) or `CLOUDFRONT`
35 * (pinned to `us-east-1`). Must match the scope of the web ACLs that
36 * reference it. Changing the scope replaces the rule group.
37 * @default "REGIONAL"
38 */
39 scope?: WafScope;
40 /**
41 * Web ACL capacity units (WCU) reserved for this rule group. Immutable —
42 * changing the capacity replaces the rule group.
43 */
44 capacity: number;
45 /**
46 * Rules to evaluate, in `Priority` order. Raw WAFv2 API shapes.
47 */
48 rules?: WAFV2.Rule[];
49 /**
50 * CloudWatch metrics and sampled-request settings for the rule group.
51 * @default sampled requests + metrics enabled, MetricName = the rule group name
52 */
53 visibilityConfig?: WAFV2.VisibilityConfig;
54 /**
55 * Description of the rule group.
56 */
57 description?: string;
58 /**
59 * Custom response bodies referenced by rule actions in this rule group.
60 */
61 customResponseBodies?: {
62 [key: string]: WAFV2.CustomResponseBody | undefined;
63 };
64 /**
65 * User-defined tags to apply to the rule group.
66 */
67 tags?: Record<string, string>;
68}
69
70export interface RuleGroup extends Resource<
71 "AWS.WAFv2.RuleGroup",
72 RuleGroupProps,
73 {
74 /**
75 * Name of the rule group.
76 */
77 ruleGroupName: string;
78 /**
79 * WAF-assigned unique ID of the rule group.
80 */
81 ruleGroupId: string;
82 /**
83 * ARN of the rule group — reference it from a web ACL rule's
84 * `RuleGroupReferenceStatement`.
85 */
86 ruleGroupArn: string;
87 /**
88 * Scope the rule group was created in.
89 */
90 scope: WafScope;
91 /**
92 * Immutable WCU capacity of the rule group.
93 */
94 capacity: number;
95 },
96 never,
97 Providers
98> {}
99
100/**
101 * An AWS WAFv2 rule group — a reusable, capacity-bounded collection of rules
102 * referenced from web ACLs via `RuleGroupReferenceStatement`.
103 *
104 * The `capacity` (web ACL capacity units, WCU) is fixed at creation;
105 * changing it replaces the rule group.
106 *
107 * ### Creating Rule Groups
108 * **Example:** Rule Group with a Byte-Match Rule
109 * ```typescript
110 * const group = yield* AWS.WAFv2.RuleGroup("BlockAdminPaths", {
111 * capacity: 50,
112 * rules: [
113 * {
114 * Name: "block-admin",
115 * Priority: 0,
116 * Statement: {
117 * ByteMatchStatement: {
118 * SearchString: new TextEncoder().encode("/admin"),
119 * FieldToMatch: { UriPath: {} },
120 * TextTransformations: [{ Priority: 0, Type: "LOWERCASE" }],
121 * PositionalConstraint: "STARTS_WITH",
122 * },
123 * },
124 * Action: { Block: {} },
125 * VisibilityConfig: {
126 * SampledRequestsEnabled: true,
127 * CloudWatchMetricsEnabled: true,
128 * MetricName: "block-admin",
129 * },
130 * },
131 * ],
132 * });
133 * ```
134 *
135 * **Example:** Reference from a Web ACL
136 * ```typescript
137 * const acl = yield* AWS.WAFv2.WebACL("Firewall", {
138 * rules: [
139 * {
140 * Name: "custom-rules",
141 * Priority: 0,
142 * Statement: {
143 * RuleGroupReferenceStatement: { ARN: group.ruleGroupArn },
144 * },
145 * OverrideAction: { None: {} },
146 * VisibilityConfig: {
147 * SampledRequestsEnabled: true,
148 * CloudWatchMetricsEnabled: true,
149 * MetricName: "custom-rules",
150 * },
151 * },
152 * ],
153 * });
154 * ```
155 *
156 * @resource
157 */
158export const RuleGroup = Resource<RuleGroup>("AWS.WAFv2.RuleGroup");
159
160const defaultScope: WafScope = "REGIONAL";
161
162const defaultVisibilityConfig = (name: string): WAFV2.VisibilityConfig => ({
163 SampledRequestsEnabled: true,
164 CloudWatchMetricsEnabled: true,
165 MetricName: name,
166});
167
168const toAttrs = (group: WAFV2.RuleGroup, scope: WafScope) => ({
169 ruleGroupName: group.Name,
170 ruleGroupId: group.Id,
171 ruleGroupArn: group.ARN,
172 scope,
173 capacity: group.Capacity,
174});
175
176export const RuleGroupProvider = () =>
177 Provider.effect(
178 RuleGroup,
179 Effect.gen(function* () {
180 const createName = Effect.fn(function* (
181 id: string,
182 props: Partial<RuleGroupProps>,
183 ) {
184 return (
185 props.ruleGroupName ??
186 (yield* createPhysicalName({ id, maxLength: 128 }))
187 );
188 });
189
190 const findRuleGroup = Effect.fn(function* (
191 scope: WafScope,
192 name: string,
193 cachedId: string | undefined,
194 ) {
195 if (cachedId !== undefined) {
196 const byId = yield* withWafScope(
197 scope,
198 wafv2
199 .getRuleGroup({ Name: name, Scope: scope, Id: cachedId })
200 .pipe(
201 Effect.catchTag("WAFNonexistentItemException", () =>
202 Effect.succeed(undefined),
203 ),
204 ),
205 );
206 if (byId?.RuleGroup) {
207 return byId;
208 }
209 }
210 let marker: string | undefined;
211 for (let page = 0; page < 20; page++) {
212 const listed = yield* withWafScope(
213 scope,
214 wafv2.listRuleGroups({
215 Scope: scope,
216 NextMarker: marker,
217 Limit: 100,
218 }),
219 );
220 const summary = listed.RuleGroups?.find((s) => s.Name === name);
221 if (summary?.Id !== undefined) {
222 return yield* withWafScope(
223 scope,
224 wafv2
225 .getRuleGroup({ Name: name, Scope: scope, Id: summary.Id })
226 .pipe(
227 Effect.catchTag("WAFNonexistentItemException", () =>
228 Effect.succeed(undefined),
229 ),
230 ),
231 );
232 }
233 if (!listed.NextMarker || (listed.RuleGroups?.length ?? 0) === 0) {
234 break;
235 }
236 marker = listed.NextMarker;
237 }
238 return undefined;
239 });
240
241 const listScope = Effect.fn(function* (scope: WafScope) {
242 const rows: ReturnType<typeof toAttrs>[] = [];
243 let marker: string | undefined;
244 for (let page = 0; page < 50; page++) {
245 const listed = yield* withWafScope(
246 scope,
247 wafv2.listRuleGroups({
248 Scope: scope,
249 NextMarker: marker,
250 Limit: 100,
251 }),
252 );
253 const summaries = listed.RuleGroups ?? [];
254 const details = yield* Effect.forEach(
255 summaries,
256 (summary) =>
257 summary.Name !== undefined && summary.Id !== undefined
258 ? withWafScope(
259 scope,
260 wafv2
261 .getRuleGroup({
262 Name: summary.Name,
263 Scope: scope,
264 Id: summary.Id,
265 })
266 .pipe(
267 Effect.catchTag("WAFNonexistentItemException", () =>
268 Effect.succeed(undefined),
269 ),
270 ),
271 )
272 : Effect.succeed(undefined),
273 { concurrency: 5 },
274 );
275 for (const detail of details) {
276 if (detail?.RuleGroup) {
277 rows.push(toAttrs(detail.RuleGroup, scope));
278 }
279 }
280 if (!listed.NextMarker || summaries.length === 0) {
281 break;
282 }
283 marker = listed.NextMarker;
284 }
285 return rows;
286 });
287
288 return {
289 stables: [
290 "ruleGroupName",
291 "ruleGroupId",
292 "ruleGroupArn",
293 "scope",
294 "capacity",
295 ],
296
297 list: () =>
298 Effect.gen(function* () {
299 const regional = yield* listScope("REGIONAL");
300 const cloudfront = yield* listScope("CLOUDFRONT");
301 return [...regional, ...cloudfront];
302 }),
303
304 diff: Effect.fn(function* ({ id, news, olds }) {
305 if (!isResolved(news)) return undefined;
306 const oldName = yield* createName(id, olds ?? {});
307 const newName = yield* createName(id, news);
308 if (
309 oldName !== newName ||
310 (olds?.scope ?? defaultScope) !== (news.scope ?? defaultScope) ||
311 olds?.capacity !== news.capacity
312 ) {
313 return { action: "replace" } as const;
314 }
315 }),
316
317 read: Effect.fn(function* ({ id, olds, output }) {
318 const scope = output?.scope ?? olds?.scope ?? defaultScope;
319 const name =
320 output?.ruleGroupName ?? (yield* createName(id, olds ?? {}));
321 const found = yield* findRuleGroup(scope, name, output?.ruleGroupId);
322 if (!found?.RuleGroup) {
323 return undefined;
324 }
325 const attrs = toAttrs(found.RuleGroup, scope);
326 const tags = yield* fetchWafTags(scope, found.RuleGroup.ARN);
327 return (yield* hasAlchemyTags(id, tags)) ? attrs : Unowned(attrs);
328 }),
329
330 reconcile: Effect.fn(function* ({ id, news, output, session }) {
331 const scope = news.scope ?? defaultScope;
332 const name = output?.ruleGroupName ?? (yield* createName(id, news));
333 const internalTags = yield* createInternalTags(id);
334 const desiredTags = { ...news.tags, ...internalTags };
335 const desiredVisibility =
336 news.visibilityConfig ?? defaultVisibilityConfig(name);
337 // props survive engine serialization as plain JSON — restore
338 // ByteMatchStatement SearchString blobs to Uint8Array
339 const desiredRules = normalizeWafRules(news.rules);
340
341 // 1. Observe.
342 let observed = yield* findRuleGroup(scope, name, output?.ruleGroupId);
343
344 // 2. Ensure — tolerate a duplicate-create race and re-read.
345 if (!observed?.RuleGroup) {
346 yield* retryUnavailableEntity(
347 withWafScope(
348 scope,
349 wafv2
350 .createRuleGroup({
351 Name: name,
352 Scope: scope,
353 Capacity: news.capacity,
354 Rules: desiredRules,
355 VisibilityConfig: desiredVisibility,
356 Description: news.description,
357 CustomResponseBodies: news.customResponseBodies,
358 Tags: createTagsList(desiredTags),
359 })
360 .pipe(
361 Effect.catchTag("WAFDuplicateItemException", () =>
362 Effect.succeed(undefined),
363 ),
364 ),
365 ),
366 );
367 observed = yield* findRuleGroup(scope, name, undefined);
368 }
369
370 if (!observed?.RuleGroup) {
371 return yield* Effect.fail(
372 new Error(`Failed to observe RuleGroup '${name}' after create`),
373 );
374 }
375
376 const group = observed.RuleGroup;
377 yield* session.note(group.ARN);
378
379 // 3. Sync rules, visibility and description against OBSERVED state.
380 const observedAspects: Record<string, unknown> = {
381 Rules: group.Rules ?? [],
382 VisibilityConfig: group.VisibilityConfig,
383 Description: group.Description,
384 };
385 const desiredAspects: Record<string, unknown> = {
386 Rules: desiredRules,
387 VisibilityConfig: desiredVisibility,
388 Description: news.description,
389 };
390 if (news.customResponseBodies !== undefined) {
391 observedAspects.CustomResponseBodies = group.CustomResponseBodies;
392 desiredAspects.CustomResponseBodies = news.customResponseBodies;
393 }
394 if (
395 !deepEqual(observedAspects, desiredAspects, { stripNullish: true })
396 ) {
397 yield* retryOptimisticLock(
398 Effect.gen(function* () {
399 const fresh = yield* findRuleGroup(scope, name, group.Id);
400 if (!fresh?.RuleGroup || fresh.LockToken === undefined) {
401 return;
402 }
403 yield* retryUnavailableEntity(
404 withWafScope(
405 scope,
406 wafv2.updateRuleGroup({
407 Name: name,
408 Scope: scope,
409 Id: fresh.RuleGroup.Id,
410 Rules: desiredRules,
411 VisibilityConfig: desiredVisibility,
412 Description: news.description,
413 CustomResponseBodies: news.customResponseBodies,
414 LockToken: fresh.LockToken,
415 }),
416 ),
417 );
418 }),
419 );
420 }
421
422 // 3b. Sync tags against OBSERVED cloud tags.
423 yield* syncWafTags(scope, group.ARN, desiredTags);
424
425 // 4. Return fresh attributes.
426 return toAttrs(group, scope);
427 }),
428
429 delete: Effect.fn(function* ({ output }) {
430 const scope = output.scope;
431 // A rule group still referenced by a web ACL (deletion propagation)
432 // surfaces WAFAssociatedItemException — retry through it.
433 yield* retryAssociatedItem(
434 retryOptimisticLock(
435 Effect.gen(function* () {
436 const found = yield* withWafScope(
437 scope,
438 wafv2
439 .getRuleGroup({
440 Name: output.ruleGroupName,
441 Scope: scope,
442 Id: output.ruleGroupId,
443 })
444 .pipe(
445 Effect.catchTag("WAFNonexistentItemException", () =>
446 Effect.succeed(undefined),
447 ),
448 ),
449 );
450 if (!found?.RuleGroup || found.LockToken === undefined) {
451 return;
452 }
453 yield* withWafScope(
454 scope,
455 wafv2
456 .deleteRuleGroup({
457 Name: output.ruleGroupName,
458 Scope: scope,
459 Id: output.ruleGroupId,
460 LockToken: found.LockToken,
461 })
462 .pipe(
463 Effect.catchTag(
464 "WAFNonexistentItemException",
465 () => Effect.void,
466 ),
467 ),
468 );
469 }),
470 ),
471 );
472 }),
473 };
474 }),
475 );