1import * as shield from "@distilled.cloud/aws/shield";
2import * as Effect from "effect/Effect";
3import * as Stream from "effect/Stream";
4import { Unowned } from "../../AdoptPolicy.ts";
5import { isResolved } from "../../Diff.ts";
6import { createPhysicalName } from "../../PhysicalName.ts";
7import * as Provider from "../../Provider.ts";
8import { Resource } from "../../Resource.ts";
9import {
10 createInternalTags,
11 diffTags,
12 hasAlchemyTags,
13 tagRecord,
14} from "../../Tags.ts";
15import type { Providers } from "../Providers.ts";
16
17/** Route 53 health check ARNs look like `arn:aws:route53:::healthcheck/{id}`. */
18const healthCheckIdFromArn = (arn: string) => arn.split("/").pop() ?? arn;
19const healthCheckArnFromId = (id: string) =>
20 `arn:aws:route53:::healthcheck/${id}`;
21
22/**
23 * How Shield Advanced responds automatically to application-layer (layer 7)
24 * DDoS attacks against the protected resource: `BLOCK` the attacking traffic
25 * with the associated AWS WAF web ACL, or `COUNT` it for visibility only.
26 */
27export type ApplicationLayerAutomaticResponseAction = "BLOCK" | "COUNT";
28
29export interface ProtectionProps {
30 /**
31 * Friendly name for the protection. Immutable — changing it replaces the
32 * protection. If omitted, a unique name is generated.
33 */
34 name?: string;
35 /**
36 * ARN of the resource to protect (CloudFront distribution, Route 53 hosted
37 * zone, Global Accelerator, ALB, CLB, or Elastic IP allocation). Immutable —
38 * changing it replaces the protection.
39 */
40 resourceArn: string;
41 /**
42 * Route 53 health check ARNs to associate with the protection for
43 * health-based DDoS detection. Mutable.
44 */
45 healthCheckArns?: string[];
46 /**
47 * Shield Advanced automatic application-layer DDoS mitigation: `BLOCK`
48 * attacking traffic with the associated AWS WAF web ACL, or `COUNT` it for
49 * visibility only. Omit to leave the feature disabled. Only supported for
50 * CloudFront distributions and Application Load Balancers that have an
51 * associated web ACL. Mutable.
52 */
53 applicationLayerAutomaticResponse?: ApplicationLayerAutomaticResponseAction;
54 /**
55 * User-defined tags. Alchemy ownership tags are merged in automatically.
56 */
57 tags?: Record<string, string>;
58}
59
60export interface Protection extends Resource<
61 "AWS.Shield.Protection",
62 ProtectionProps,
63 {
64 /** Unique identifier of the protection. */
65 protectionId: string;
66 /** ARN of the protection. */
67 protectionArn: string;
68 /** Name of the protection. */
69 name: string;
70 /** ARN of the protected resource. */
71 resourceArn: string;
72 /** IDs of the associated Route 53 health checks. */
73 healthCheckIds: string[];
74 /**
75 * The automatic application-layer DDoS mitigation action, or `undefined`
76 * when the feature is disabled.
77 */
78 applicationLayerAutomaticResponse:
79 | ApplicationLayerAutomaticResponseAction
80 | undefined;
81 /** Tags on the protection (including Alchemy ownership tags). */
82 tags: Record<string, string>;
83 },
84 never,
85 Providers
86> {}
87
88/**
89 * An AWS Shield Advanced Protection for a single resource (CloudFront
90 * distribution, Route 53 hosted zone, Global Accelerator, Application/Classic
91 * Load Balancer, or Elastic IP).
92 *
93 * Requires an active Shield Advanced subscription ($3,000/month with a 1-year
94 * commitment); without one every call fails with the typed
95 * `SubscriptionNotFound` error.
96 *
97 * ### Protecting Resources
98 * **Example:** Protect a CloudFront Distribution
99 * ```typescript
100 * const protection = yield* Shield.Protection("SiteProtection", {
101 * resourceArn: distribution.distributionArn,
102 * });
103 * ```
104 *
105 * **Example:** Protection with Health-Based Detection
106 * ```typescript
107 * const protection = yield* Shield.Protection("ApiProtection", {
108 * name: "api-protection",
109 * resourceArn: loadBalancer.loadBalancerArn,
110 * healthCheckArns: ["arn:aws:route53:::healthcheck/11111111-2222-3333-4444-555555555555"],
111 * tags: { team: "platform" },
112 * });
113 * ```
114 *
115 * ### Automatic Application-Layer Mitigation
116 * **Example:** Block Layer-7 Attacks Automatically
117 * ```typescript
118 * // Requires an AWS WAF web ACL associated with the CloudFront distribution
119 * // or Application Load Balancer.
120 * const protection = yield* Shield.Protection("SiteProtection", {
121 * resourceArn: distribution.distributionArn,
122 * applicationLayerAutomaticResponse: "BLOCK",
123 * });
124 * ```
125 */
126export const Protection = Resource<Protection>("AWS.Shield.Protection");
127
128const observeByResourceArn = (resourceArn: string) =>
129 shield.describeProtection({ ResourceArn: resourceArn }).pipe(
130 Effect.map((r) => r.Protection),
131 Effect.catchTag(["ResourceNotFoundException", "SubscriptionNotFound"], () =>
132 Effect.succeed(undefined),
133 ),
134 );
135
136const toTagRecord = (tags: shield.Tag[] | undefined): Record<string, string> =>
137 tagRecord(
138 (tags ?? []).flatMap((t) =>
139 t.Key !== undefined && t.Value !== undefined
140 ? [{ Key: t.Key, Value: t.Value }]
141 : [],
142 ),
143 );
144
145const readProtectionTags = (protectionArn: string) =>
146 shield.listTagsForResource({ ResourceARN: protectionArn }).pipe(
147 Effect.map((r) => toTagRecord(r.Tags)),
148 Effect.catch(() => Effect.succeed<Record<string, string>>({})),
149 );
150
151/**
152 * Collapse the observed ALAR configuration to the prop shape: the action when
153 * enabled, `undefined` when disabled or never configured.
154 */
155const observedAlarAction = (
156 protection: shield.Protection,
157): ApplicationLayerAutomaticResponseAction | undefined => {
158 const config = protection.ApplicationLayerAutomaticResponseConfiguration;
159 if (config?.Status !== "ENABLED") return undefined;
160 return config.Action.Block !== undefined ? "BLOCK" : "COUNT";
161};
162
163const buildAttrs = (
164 protection: shield.Protection,
165 tags: Record<string, string>,
166) => ({
167 protectionId: protection.Id!,
168 protectionArn: protection.ProtectionArn!,
169 name: protection.Name!,
170 resourceArn: protection.ResourceArn!,
171 healthCheckIds: [...(protection.HealthCheckIds ?? [])],
172 applicationLayerAutomaticResponse: observedAlarAction(protection),
173 tags,
174});
175
176export const ProtectionProvider = () =>
177 Provider.effect(
178 Protection,
179 Effect.gen(function* () {
180 const toName = (id: string, props: { name?: string }) =>
181 props.name
182 ? Effect.succeed(props.name)
183 : createPhysicalName({ id, maxLength: 128 });
184
185 const syncTags = Effect.fn(function* (
186 protectionArn: string,
187 desiredTags: Record<string, string>,
188 ) {
189 const observedTags = yield* readProtectionTags(protectionArn);
190 const { upsert, removed } = diffTags(observedTags, desiredTags);
191 if (upsert.length > 0) {
192 yield* shield.tagResource({
193 ResourceARN: protectionArn,
194 Tags: upsert,
195 });
196 }
197 if (removed.length > 0) {
198 yield* shield.untagResource({
199 ResourceARN: protectionArn,
200 TagKeys: removed,
201 });
202 }
203 });
204
205 // Automatic application-layer DDoS mitigation: enable, update, or
206 // disable based on the delta between OBSERVED and desired state.
207 const syncApplicationLayerAutomaticResponse = Effect.fn(function* (
208 resourceArn: string,
209 observed: ApplicationLayerAutomaticResponseAction | undefined,
210 desired: ApplicationLayerAutomaticResponseAction | undefined,
211 ) {
212 if (observed === desired) return;
213 if (desired === undefined) {
214 yield* shield.disableApplicationLayerAutomaticResponse({
215 ResourceArn: resourceArn,
216 });
217 return;
218 }
219 const Action = desired === "BLOCK" ? { Block: {} } : { Count: {} };
220 if (observed === undefined) {
221 yield* shield.enableApplicationLayerAutomaticResponse({
222 ResourceArn: resourceArn,
223 Action,
224 });
225 } else {
226 yield* shield.updateApplicationLayerAutomaticResponse({
227 ResourceArn: resourceArn,
228 Action,
229 });
230 }
231 });
232
233 const syncHealthChecks = Effect.fn(function* (
234 protectionId: string,
235 observedIds: readonly string[],
236 desiredArns: readonly string[],
237 ) {
238 const desiredIds = desiredArns.map(healthCheckIdFromArn);
239 for (const arn of desiredArns) {
240 if (!observedIds.includes(healthCheckIdFromArn(arn))) {
241 yield* shield.associateHealthCheck({
242 ProtectionId: protectionId,
243 HealthCheckArn: arn,
244 });
245 }
246 }
247 for (const observedId of observedIds) {
248 if (!desiredIds.includes(observedId)) {
249 yield* shield.disassociateHealthCheck({
250 ProtectionId: protectionId,
251 HealthCheckArn: healthCheckArnFromId(observedId),
252 });
253 }
254 }
255 });
256
257 return {
258 stables: ["protectionId", "protectionArn", "name", "resourceArn"],
259
260 diff: Effect.fn(function* ({ id, olds, news }) {
261 if (!isResolved(news)) return;
262 if (olds === undefined) return;
263 if (olds.resourceArn !== news.resourceArn) {
264 return { action: "replace" } as const;
265 }
266 if ((yield* toName(id, olds)) !== (yield* toName(id, news))) {
267 return { action: "replace" } as const;
268 }
269 }),
270
271 read: Effect.fn(function* ({ id, olds, output }) {
272 const resourceArn = output?.resourceArn ?? olds?.resourceArn;
273 if (resourceArn === undefined) return undefined;
274 const protection = yield* observeByResourceArn(resourceArn);
275 if (!protection?.Id || !protection.ProtectionArn) return undefined;
276 const tags = yield* readProtectionTags(protection.ProtectionArn);
277 const attrs = buildAttrs(protection, tags);
278 return (yield* hasAlchemyTags(id, tags)) ? attrs : Unowned(attrs);
279 }),
280
281 reconcile: Effect.fn(function* ({ id, news, session }) {
282 const name = yield* toName(id, news);
283 const internalTags = yield* createInternalTags(id);
284 const desiredTags = { ...news.tags, ...internalTags };
285
286 // 1. OBSERVE — cloud state is authoritative; a protection is keyed
287 // by the resource it protects.
288 let protection = yield* observeByResourceArn(news.resourceArn);
289
290 // 2. ENSURE — create if missing; tolerate the AlreadyExists race.
291 if (!protection?.Id) {
292 yield* shield
293 .createProtection({
294 Name: name,
295 ResourceArn: news.resourceArn,
296 Tags: Object.entries(desiredTags).map(([Key, Value]) => ({
297 Key,
298 Value,
299 })),
300 })
301 .pipe(
302 Effect.catchTag(
303 "ResourceAlreadyExistsException",
304 () => Effect.void,
305 ),
306 );
307 protection = yield* observeByResourceArn(news.resourceArn);
308 if (!protection?.Id || !protection.ProtectionArn) {
309 return yield* Effect.fail(
310 new Error(
311 `Failed to create or read Shield protection for ${news.resourceArn}`,
312 ),
313 );
314 }
315 }
316
317 // 3. SYNC health check associations — diff observed against desired.
318 yield* syncHealthChecks(
319 protection.Id,
320 protection.HealthCheckIds ?? [],
321 news.healthCheckArns ?? [],
322 );
323
324 // 3b. SYNC automatic application-layer DDoS mitigation.
325 yield* syncApplicationLayerAutomaticResponse(
326 news.resourceArn,
327 observedAlarAction(protection),
328 news.applicationLayerAutomaticResponse,
329 );
330
331 // 3c. SYNC tags — diff against OBSERVED cloud tags.
332 yield* syncTags(protection.ProtectionArn!, desiredTags);
333
334 // 4. RETURN fresh attributes.
335 const final = yield* observeByResourceArn(news.resourceArn);
336 yield* session.note(protection.ProtectionArn!);
337 return buildAttrs(final ?? protection, desiredTags);
338 }),
339
340 // Enumerate every protection in the account. Without a Shield Advanced
341 // subscription the account cannot have any protections.
342 list: () =>
343 Effect.gen(function* () {
344 const protections = yield* shield.listProtections.pages({}).pipe(
345 Stream.runCollect,
346 Effect.map((chunk) =>
347 Array.from(chunk).flatMap((page) => page.Protections ?? []),
348 ),
349 Effect.catchTag("SubscriptionNotFound", () => Effect.succeed([])),
350 );
351 return yield* Effect.forEach(
352 protections.filter(
353 (p) => p.Id != null && p.ProtectionArn != null,
354 ),
355 (protection) =>
356 Effect.gen(function* () {
357 const tags = yield* readProtectionTags(
358 protection.ProtectionArn!,
359 );
360 return buildAttrs(protection, tags);
361 }),
362 { concurrency: 5 },
363 );
364 }),
365
366 delete: Effect.fn(function* ({ output }) {
367 yield* shield
368 .deleteProtection({ ProtectionId: output.protectionId })
369 .pipe(
370 Effect.catchTag(
371 ["ResourceNotFoundException", "SubscriptionNotFound"],
372 () => Effect.void,
373 ),
374 );
375 }),
376 };
377 }),
378 );