1import * as wafv2 from "@distilled.cloud/aws/wafv2";
2import * as Effect from "effect/Effect";
3import { isResolved } from "../../Diff.ts";
4import * as Provider from "../../Provider.ts";
5import { Resource } from "../../Resource.ts";
6import type { Providers } from "../Providers.ts";
7import {
8 retryUnavailableEntity,
9 retryUnavailableEntityLong,
10} from "./internal.ts";
11
12export interface WebACLAssociationProps {
13 /**
14 * ARN of the `REGIONAL` web ACL to associate.
15 *
16 * Changing the web ACL re-associates the resource in place
17 * (`AssociateWebACL` overwrites any existing association).
18 */
19 webAclArn: string;
20 /**
21 * ARN of the regional resource to protect — an Application Load
22 * Balancer, API Gateway REST API stage, AppSync GraphQL API, Cognito
23 * user pool, App Runner service, Amplify application or Verified Access
24 * instance. Changing the resource replaces the association.
25 *
26 * CloudFront distributions are NOT associated this way — set the
27 * distribution's `webAclId` property to the web ACL's ARN instead.
28 */
29 resourceArn: string;
30}
31
32export interface WebACLAssociation extends Resource<
33 "AWS.WAFv2.WebACLAssociation",
34 WebACLAssociationProps,
35 {
36 /**
37 * ARN of the associated web ACL.
38 */
39 webAclArn: string;
40 /**
41 * ARN of the protected resource.
42 */
43 resourceArn: string;
44 },
45 never,
46 Providers
47> {}
48
49/**
50 * Associates a `REGIONAL` AWS WAFv2 {@link WebACL} with a regional resource
51 * (ALB, API Gateway stage, AppSync API, Cognito user pool, App Runner
52 * service, Amplify app, Verified Access instance) to protect it.
53 *
54 * A resource can have at most one web ACL association; associating a
55 * different web ACL overwrites the previous association in place.
56 * CloudFront distributions are protected by setting
57 * `Distribution.webAclId` instead — never through this resource.
58 *
59 * ### Associating Web ACLs
60 * **Example:** Protect a Cognito User Pool
61 * ```typescript
62 * const pool = yield* AWS.Cognito.UserPool("Users", {});
63 *
64 * const acl = yield* AWS.WAFv2.WebACL("PoolFirewall", {
65 * defaultAction: { Allow: {} },
66 * });
67 *
68 * const association = yield* AWS.WAFv2.WebACLAssociation("PoolAssociation", {
69 * webAclArn: acl.webAclArn,
70 * resourceArn: pool.userPoolArn,
71 * });
72 * ```
73 *
74 * @resource
75 */
76export const WebACLAssociation = Resource<WebACLAssociation>(
77 "AWS.WAFv2.WebACLAssociation",
78);
79
80export const WebACLAssociationProvider = () =>
81 Provider.effect(
82 WebACLAssociation,
83 Effect.gen(function* () {
84 // Read the ARN of the web ACL currently protecting a resource
85 // (undefined when the resource has no association or is gone). A
86 // freshly created protected resource (e.g. a Cognito user pool) can
87 // surface WAFUnavailableEntityException until WAF can "retrieve" it —
88 // retry through propagation.
89 const observeAssociation = Effect.fn(function* (resourceArn: string) {
90 const response = yield* retryUnavailableEntity(
91 wafv2.getWebACLForResource({ ResourceArn: resourceArn }),
92 ).pipe(
93 Effect.catchTag("WAFNonexistentItemException", () =>
94 Effect.succeed({ WebACL: undefined }),
95 ),
96 );
97 return response.WebACL?.ARN;
98 });
99
100 return {
101 stables: ["webAclArn", "resourceArn"],
102
103 // Associations have no enumeration API of their own (they are read
104 // per-resource via getWebACLForResource), so nuke can't list them.
105 // They dissolve with either endpoint anyway.
106 list: () => Effect.succeed([]),
107
108 diff: Effect.fn(function* ({ news, olds }) {
109 if (!isResolved(news)) return undefined;
110 if (olds.resourceArn !== news.resourceArn) {
111 return { action: "replace" } as const;
112 }
113 // webAclArn changes apply in place — associateWebACL overwrites.
114 }),
115
116 read: Effect.fn(function* ({ olds, output }) {
117 const resourceArn = output?.resourceArn ?? olds?.resourceArn;
118 const webAclArn = output?.webAclArn ?? olds?.webAclArn;
119 if (resourceArn === undefined) {
120 return undefined;
121 }
122 const observed = yield* observeAssociation(resourceArn);
123 if (observed === undefined || observed !== webAclArn) {
124 // Nothing associated, or a foreign web ACL owns the slot.
125 return undefined;
126 }
127 return { webAclArn: observed, resourceArn };
128 }),
129
130 reconcile: Effect.fn(function* ({ news, session }) {
131 // 1. Observe — what protects the resource right now?
132 const observed = yield* observeAssociation(news.resourceArn);
133
134 // 2. Ensure — associate when missing or pointing elsewhere.
135 // associateWebACL is an upsert; a freshly created web ACL or
136 // protected resource (e.g. a new Cognito user pool) can be
137 // unavailable well past 90s, so retry on the long budget.
138 if (observed !== news.webAclArn) {
139 yield* retryUnavailableEntityLong(
140 wafv2.associateWebACL({
141 WebACLArn: news.webAclArn,
142 ResourceArn: news.resourceArn,
143 }),
144 );
145 }
146
147 yield* session.note(
148 `Associated ${news.webAclArn} with ${news.resourceArn}`,
149 );
150 return {
151 webAclArn: news.webAclArn,
152 resourceArn: news.resourceArn,
153 };
154 }),
155
156 delete: Effect.fn(function* ({ output }) {
157 // Idempotent: a missing resource or absent association is fine.
158 // Retry transient unavailability so the web ACL can be deleted
159 // right after (deleteWebACL fails while still associated).
160 yield* retryUnavailableEntity(
161 wafv2.disassociateWebACL({ ResourceArn: output.resourceArn }),
162 ).pipe(
163 Effect.catchTag(
164 ["WAFNonexistentItemException", "WAFUnavailableEntityException"],
165 () => Effect.void,
166 ),
167 );
168 }),
169 };
170 }),
171 );