1import type * as WAFV2 from "@distilled.cloud/aws/wafv2";
2import * as wafv2 from "@distilled.cloud/aws/wafv2";
3import * as Effect from "effect/Effect";
4import * as Schedule from "effect/Schedule";
5import { deepEqual, isResolved } from "../../Diff.ts";
6import * as Provider from "../../Provider.ts";
7import { Resource } from "../../Resource.ts";
8import type { Providers } from "../Providers.ts";
9import { type WafScope, withWafScope } from "./internal.ts";
10
11export interface LoggingConfigurationProps {
12 /**
13 * ARN of the {@link WebACL} to enable logging for. Changing the web ACL
14 * replaces the logging configuration.
15 */
16 resourceArn: string;
17 /**
18 * ARNs of the log destinations — an Amazon Kinesis Data Firehose delivery
19 * stream, a CloudWatch Logs log group, or an S3 bucket. The destination
20 * name must begin with `aws-waf-logs-`. A trailing `:*` on a CloudWatch
21 * Logs log group ARN is stripped automatically (WAF rejects it).
22 */
23 logDestinationConfigs: string[];
24 /**
25 * Parts of each logged request to redact (e.g. a specific header or the
26 * query string). Only `SingleHeader`, `UriPath`, `QueryString`, and
27 * `Method` are supported by WAF. Raw WAFv2 API shapes.
28 */
29 redactedFields?: WAFV2.FieldToMatch[];
30 /**
31 * Filter which requests are logged based on rule action and labels.
32 */
33 loggingFilter?: WAFV2.LoggingFilter;
34}
35
36export interface LoggingConfiguration extends Resource<
37 "AWS.WAFv2.LoggingConfiguration",
38 LoggingConfigurationProps,
39 {
40 /**
41 * ARN of the web ACL the logging configuration applies to.
42 */
43 resourceArn: string;
44 /**
45 * Scope of the web ACL (derived from its ARN).
46 */
47 scope: WafScope;
48 /**
49 * ARNs of the configured log destinations.
50 */
51 logDestinationConfigs: string[];
52 },
53 never,
54 Providers
55> {}
56
57/**
58 * The logging configuration of an AWS WAFv2 {@link WebACL} — streams full
59 * web request logs to a Kinesis Data Firehose delivery stream, a CloudWatch
60 * Logs log group, or an S3 bucket.
61 *
62 * The destination must be named with the `aws-waf-logs-` prefix. A web ACL
63 * has at most one logging configuration; deleting this resource disables
64 * logging.
65 *
66 * ### Configuring Logging
67 * **Example:** Log to CloudWatch Logs
68 * ```typescript
69 * const logGroup = yield* AWS.Logs.LogGroup("WafLogs", {
70 * logGroupName: "aws-waf-logs-my-firewall",
71 * });
72 *
73 * yield* AWS.WAFv2.LoggingConfiguration("Logging", {
74 * resourceArn: acl.webAclArn,
75 * logDestinationConfigs: [logGroup.logGroupArn],
76 * });
77 * ```
78 *
79 * **Example:** Redact Headers and Filter to Blocked Requests
80 * ```typescript
81 * yield* AWS.WAFv2.LoggingConfiguration("Logging", {
82 * resourceArn: acl.webAclArn,
83 * logDestinationConfigs: [logGroup.logGroupArn],
84 * redactedFields: [{ SingleHeader: { Name: "authorization" } }],
85 * loggingFilter: {
86 * DefaultBehavior: "DROP",
87 * Filters: [
88 * {
89 * Behavior: "KEEP",
90 * Requirement: "MEETS_ANY",
91 * Conditions: [{ ActionCondition: { Action: "BLOCK" } }],
92 * },
93 * ],
94 * },
95 * });
96 * ```
97 *
98 * @resource
99 */
100export const LoggingConfiguration = Resource<LoggingConfiguration>(
101 "AWS.WAFv2.LoggingConfiguration",
102);
103
104/**
105 * Derive the scope from a WAFv2 entity ARN. CLOUDFRONT-scoped ARNs carry a
106 * `global/` resource prefix (`arn:aws:wafv2:us-east-1:acct:global/webacl/…`);
107 * REGIONAL ARNs carry `regional/`.
108 */
109const scopeOfArn = (arn: string): WafScope =>
110 arn.split(":")[5]?.startsWith("global/") ? "CLOUDFRONT" : "REGIONAL";
111
112/**
113 * WAF rejects CloudWatch Logs log group ARNs with the `:*` suffix that IAM
114 * policies (and the Logs API) use — strip it.
115 */
116const normalizeDestination = (arn: string): string =>
117 arn.endsWith(":*") ? arn.slice(0, -2) : arn;
118
119/**
120 * The first PutLoggingConfiguration in an account creates the WAF
121 * service-linked role; until that completes the API surfaces
122 * `WAFServiceLinkedRoleErrorException` ("Retry your request"). Bounded
123 * retry (~30s).
124 */
125const retryServiceLinkedRole = <A, E extends { _tag: string }, R>(
126 self: Effect.Effect<A, E, R>,
127): Effect.Effect<A, E, R> =>
128 Effect.retry(self, {
129 while: (e) => e._tag === "WAFServiceLinkedRoleErrorException",
130 schedule: Schedule.max([Schedule.fixed("3 seconds"), Schedule.recurs(10)]),
131 });
132
133export const LoggingConfigurationProvider = () =>
134 Provider.effect(
135 LoggingConfiguration,
136 Effect.gen(function* () {
137 const readConfig = Effect.fn(function* (resourceArn: string) {
138 const scope = scopeOfArn(resourceArn);
139 return yield* withWafScope(
140 scope,
141 wafv2
142 .getLoggingConfiguration({ ResourceArn: resourceArn })
143 .pipe(
144 Effect.catchTag("WAFNonexistentItemException", () =>
145 Effect.succeed(undefined),
146 ),
147 ),
148 );
149 });
150
151 const toAttrs = (
152 resourceArn: string,
153 destinations: readonly string[],
154 ) => ({
155 resourceArn,
156 scope: scopeOfArn(resourceArn),
157 logDestinationConfigs: [...destinations],
158 });
159
160 return LoggingConfiguration.Provider.of({
161 stables: ["resourceArn", "scope"],
162
163 // A logging configuration is a singleton setting keyed by its web
164 // ACL; enumerating them standalone is not useful for orphan scans
165 // (the web ACL itself is the discoverable resource).
166 list: () => Effect.succeed([]),
167
168 diff: Effect.fn(function* ({ news, olds }) {
169 if (!isResolved(news)) return undefined;
170 if (olds.resourceArn !== news.resourceArn) {
171 return { action: "replace" } as const;
172 }
173 }),
174
175 read: Effect.fn(function* ({ olds, output }) {
176 const resourceArn = output?.resourceArn ?? olds?.resourceArn;
177 if (resourceArn === undefined) return undefined;
178 const found = yield* readConfig(resourceArn);
179 const config = found?.LoggingConfiguration;
180 if (!config) return undefined;
181 return toAttrs(resourceArn, config.LogDestinationConfigs ?? []);
182 }),
183
184 reconcile: Effect.fn(function* ({ news, session }) {
185 const scope = scopeOfArn(news.resourceArn);
186 const desired: WAFV2.LoggingConfiguration = {
187 ResourceArn: news.resourceArn,
188 LogDestinationConfigs:
189 news.logDestinationConfigs.map(normalizeDestination),
190 RedactedFields: news.redactedFields,
191 LoggingFilter: news.loggingFilter,
192 };
193
194 // Observe — PutLoggingConfiguration is a true upsert, so only
195 // call it when the observed configuration drifts from desired.
196 const observed = yield* readConfig(news.resourceArn);
197 const current = observed?.LoggingConfiguration;
198 const drifted = !deepEqual(
199 {
200 LogDestinationConfigs: current?.LogDestinationConfigs ?? [],
201 RedactedFields: current?.RedactedFields,
202 LoggingFilter: current?.LoggingFilter,
203 },
204 {
205 LogDestinationConfigs: desired.LogDestinationConfigs,
206 RedactedFields: desired.RedactedFields,
207 LoggingFilter: desired.LoggingFilter,
208 },
209 { stripNullish: true },
210 );
211 if (current === undefined || drifted) {
212 yield* retryServiceLinkedRole(
213 withWafScope(
214 scope,
215 wafv2.putLoggingConfiguration({
216 LoggingConfiguration: desired,
217 }),
218 ),
219 );
220 }
221
222 yield* session.note(news.resourceArn);
223 return toAttrs(news.resourceArn, desired.LogDestinationConfigs);
224 }),
225
226 delete: Effect.fn(function* ({ output }) {
227 const scope = scopeOfArn(output.resourceArn);
228 // Idempotent — the configuration (or the web ACL itself) may
229 // already be gone.
230 yield* withWafScope(
231 scope,
232 wafv2
233 .deleteLoggingConfiguration({
234 ResourceArn: output.resourceArn,
235 })
236 .pipe(
237 Effect.catchTag(
238 "WAFNonexistentItemException",
239 () => Effect.void,
240 ),
241 ),
242 );
243 }),
244 });
245 }),
246 );