1import type * as NFW from "@distilled.cloud/aws/network-firewall";
2import * as nfw from "@distilled.cloud/aws/network-firewall";
3import * as Effect from "effect/Effect";
4import * as Stream from "effect/Stream";
5import { Unowned } from "../../AdoptPolicy.ts";
6import { deepEqual, isResolved } from "../../Diff.ts";
7import { createPhysicalName } from "../../PhysicalName.ts";
8import * as Provider from "../../Provider.ts";
9import { Resource } from "../../Resource.ts";
10import { createInternalTags, diffTags, hasAlchemyTags } from "../../Tags.ts";
11import type { Providers } from "../Providers.ts";
12import {
13 nfwTagsToRecord,
14 recordToNfwTags,
15 retryWhileNfwInUse,
16 retryWhileNfwNotFound,
17} from "./internal.ts";
18
19export interface RuleGroupProps {
20 /**
21 * Name of the rule group. Must be 1-128 alphanumeric characters or
22 * hyphens. If omitted, a deterministic physical name is generated.
23 * Changing the name replaces the rule group.
24 */
25 ruleGroupName?: string;
26 /**
27 * Whether the rule group inspects packets on their own (`STATELESS`) or
28 * in the context of their traffic flow (`STATEFUL`). Changing the type
29 * replaces the rule group.
30 */
31 type: "STATELESS" | "STATEFUL";
32 /**
33 * Maximum operating capacity the rule group can consume when applied.
34 * Capacity cannot be changed after creation — changing it replaces the
35 * rule group.
36 */
37 capacity: number;
38 /**
39 * The rule group definition (rule variables, reference sets, and the
40 * rules source). Provide either `ruleGroup` or `rules`, not both.
41 * Uses raw Network Firewall API structures.
42 */
43 ruleGroup?: NFW.RuleGroup;
44 /**
45 * A string containing stateful rules in Suricata format. Provide either
46 * `rules` or `ruleGroup`, not both. Only valid for `STATEFUL` groups.
47 */
48 rules?: string;
49 /**
50 * Which rule attributes (`SID`, `MSG`, `METADATA`) DescribeRuleGroupSummary
51 * includes in per-rule summaries. Only valid for `STATEFUL` groups; when
52 * omitted, summaries are not generated.
53 */
54 summaryConfiguration?: NFW.SummaryConfiguration;
55 /**
56 * Human-readable description of the rule group.
57 */
58 description?: string;
59 /**
60 * Tags to apply to the rule group. Merged with internal Alchemy tags.
61 */
62 tags?: Record<string, string>;
63}
64
65export interface RuleGroup extends Resource<
66 "AWS.NetworkFirewall.RuleGroup",
67 RuleGroupProps,
68 {
69 /** Name of the rule group. */
70 ruleGroupName: string;
71 /** ARN of the rule group. */
72 ruleGroupArn: string;
73 /** Server-assigned unique id of the rule group. */
74 ruleGroupId: string;
75 /** Rule group type (`STATELESS` or `STATEFUL`). */
76 type: string;
77 /** Reserved rule capacity of the group. */
78 capacity: number;
79 },
80 never,
81 Providers
82> {}
83
84/**
85 * An AWS Network Firewall rule group — a reusable collection of stateless
86 * or stateful network traffic inspection rules referenced by
87 * {@link FirewallPolicy | firewall policies}.
88 * ### Creating Rule Groups
89 * **Example:** Stateless Rule Group
90 * ```typescript
91 * import * as NetworkFirewall from "alchemy/AWS/NetworkFirewall";
92 *
93 * const stateless = yield* NetworkFirewall.RuleGroup("AllowHttp", {
94 * type: "STATELESS",
95 * capacity: 10,
96 * ruleGroup: {
97 * RulesSource: {
98 * StatelessRulesAndCustomActions: {
99 * StatelessRules: [
100 * {
101 * Priority: 1,
102 * RuleDefinition: {
103 * Actions: ["aws:pass"],
104 * MatchAttributes: {
105 * Protocols: [6],
106 * DestinationPorts: [{ FromPort: 80, ToPort: 80 }],
107 * },
108 * },
109 * },
110 * ],
111 * },
112 * },
113 * },
114 * });
115 * ```
116 *
117 * **Example:** Stateful Rule Group (Suricata rules)
118 * ```typescript
119 * const stateful = yield* NetworkFirewall.RuleGroup("BlockDomains", {
120 * type: "STATEFUL",
121 * capacity: 100,
122 * rules: 'drop tcp any any -> any any (msg:"drop all tcp"; sid:1; rev:1;)',
123 * });
124 * ```
125 *
126 * **Example:** Stateful Domain List
127 * ```typescript
128 * const domains = yield* NetworkFirewall.RuleGroup("DenyList", {
129 * type: "STATEFUL",
130 * capacity: 100,
131 * ruleGroup: {
132 * RulesSource: {
133 * RulesSourceList: {
134 * Targets: [".example.com"],
135 * TargetTypes: ["TLS_SNI", "HTTP_HOST"],
136 * GeneratedRulesType: "DENYLIST",
137 * },
138 * },
139 * },
140 * });
141 * ```
142 *
143 * @resource
144 */
145export const RuleGroup = Resource<RuleGroup>("AWS.NetworkFirewall.RuleGroup");
146
147export const RuleGroupProvider = () =>
148 Provider.effect(
149 RuleGroup,
150 Effect.gen(function* () {
151 const createName = Effect.fn(function* (
152 id: string,
153 props: { ruleGroupName?: string },
154 ) {
155 return (
156 props.ruleGroupName ??
157 (yield* createPhysicalName({ id, maxLength: 128 }))
158 );
159 });
160
161 const toAttrs = (
162 response: NFW.RuleGroupResponse,
163 ): RuleGroup["Attributes"] => ({
164 ruleGroupName: response.RuleGroupName,
165 ruleGroupArn: response.RuleGroupArn,
166 ruleGroupId: response.RuleGroupId,
167 type: response.Type ?? "STATEFUL",
168 capacity: response.Capacity ?? 0,
169 });
170
171 return RuleGroup.Provider.of({
172 stables: ["ruleGroupName", "ruleGroupArn", "ruleGroupId", "type"],
173
174 list: () =>
175 Effect.gen(function* () {
176 const pages = yield* nfw.listRuleGroups
177 .pages({ Scope: "ACCOUNT" })
178 .pipe(Stream.runCollect);
179 const metas = Array.from(pages).flatMap(
180 (page) => page.RuleGroups ?? [],
181 );
182 const items = yield* Effect.forEach(
183 metas,
184 (meta) =>
185 nfw.describeRuleGroup({ RuleGroupArn: meta.Arn }).pipe(
186 Effect.map((r) => toAttrs(r.RuleGroupResponse)),
187 Effect.catchTag("ResourceNotFoundException", () =>
188 Effect.succeed(undefined),
189 ),
190 ),
191 { concurrency: 5 },
192 );
193 return items.filter(
194 (item): item is RuleGroup["Attributes"] => item !== undefined,
195 );
196 }),
197
198 read: Effect.fn(function* ({ id, olds, output }) {
199 const found =
200 output?.ruleGroupArn !== undefined
201 ? yield* nfw
202 .describeRuleGroup({ RuleGroupArn: output.ruleGroupArn })
203 .pipe(
204 Effect.catchTag("ResourceNotFoundException", () =>
205 Effect.succeed(undefined),
206 ),
207 )
208 : yield* nfw
209 .describeRuleGroup({
210 RuleGroupName: yield* createName(id, olds ?? {}),
211 Type: olds?.type,
212 })
213 .pipe(
214 Effect.catchTag("ResourceNotFoundException", () =>
215 Effect.succeed(undefined),
216 ),
217 );
218 if (found === undefined) return undefined;
219 const attrs = toAttrs(found.RuleGroupResponse);
220 const tags = nfwTagsToRecord(found.RuleGroupResponse.Tags);
221 return (yield* hasAlchemyTags(id, tags)) ? attrs : Unowned(attrs);
222 }),
223
224 diff: Effect.fn(function* ({ id, news, olds }) {
225 if (!isResolved(news)) return undefined;
226 const oldName = yield* createName(id, olds);
227 const newName = yield* createName(id, news);
228 if (
229 oldName !== newName ||
230 olds.type !== news.type ||
231 olds.capacity !== news.capacity
232 ) {
233 return { action: "replace" } as const;
234 }
235 }),
236
237 reconcile: Effect.fn(function* ({ id, news, output, session }) {
238 const name = output?.ruleGroupName ?? (yield* createName(id, news));
239 const internalTags = yield* createInternalTags(id);
240 const desiredTags: Record<string, string> = {
241 ...news.tags,
242 ...internalTags,
243 };
244
245 // 1. Observe — cloud state is authoritative.
246 let observed = yield* nfw
247 .describeRuleGroup({ RuleGroupName: name, Type: news.type })
248 .pipe(
249 Effect.catchTag("ResourceNotFoundException", () =>
250 Effect.succeed(undefined),
251 ),
252 );
253
254 // 2. Ensure — create if missing.
255 if (observed === undefined) {
256 yield* nfw.createRuleGroup({
257 RuleGroupName: name,
258 Type: news.type,
259 Capacity: news.capacity,
260 RuleGroup: news.ruleGroup,
261 Rules: news.rules,
262 Description: news.description,
263 SummaryConfiguration: news.summaryConfiguration,
264 Tags: recordToNfwTags(desiredTags),
265 });
266 observed = yield* nfw
267 .describeRuleGroup({ RuleGroupName: name, Type: news.type })
268 .pipe(retryWhileNfwNotFound);
269 }
270
271 // 3. Sync definition — compare desired against OBSERVED state and
272 // apply only when it differs.
273 const observedDescription = observed.RuleGroupResponse.Description;
274 const definitionDiffers =
275 (news.rules !== undefined &&
276 news.rules !== observed.RuleGroup?.RulesSource?.RulesString) ||
277 (news.ruleGroup !== undefined &&
278 !deepEqual(news.ruleGroup, observed.RuleGroup)) ||
279 (news.summaryConfiguration !== undefined &&
280 !deepEqual(
281 news.summaryConfiguration,
282 observed.RuleGroupResponse.SummaryConfiguration,
283 )) ||
284 (news.description ?? undefined) !== observedDescription;
285 if (definitionDiffers) {
286 yield* nfw.updateRuleGroup({
287 UpdateToken: observed.UpdateToken,
288 RuleGroupArn: observed.RuleGroupResponse.RuleGroupArn,
289 RuleGroup: news.ruleGroup,
290 Rules: news.rules,
291 Type: news.type,
292 Description: news.description,
293 SummaryConfiguration: news.summaryConfiguration,
294 });
295 }
296
297 // 3b. Sync tags — diff against OBSERVED cloud tags.
298 const observedTags = nfwTagsToRecord(observed.RuleGroupResponse.Tags);
299 const { upsert, removed } = diffTags(observedTags, desiredTags);
300 const arn = observed.RuleGroupResponse.RuleGroupArn;
301 if (upsert.length > 0) {
302 yield* nfw.tagResource({ ResourceArn: arn, Tags: upsert });
303 }
304 if (removed.length > 0) {
305 yield* nfw.untagResource({ ResourceArn: arn, TagKeys: removed });
306 }
307
308 yield* session.note(name);
309 return toAttrs(observed.RuleGroupResponse);
310 }),
311
312 delete: Effect.fn(function* ({ output }) {
313 // A rule group still referenced by a firewall policy (deleted
314 // moments ago by the engine) rejects deletion with
315 // InvalidOperationException — retry through the release window.
316 yield* nfw
317 .deleteRuleGroup({ RuleGroupArn: output.ruleGroupArn })
318 .pipe(
319 retryWhileNfwInUse,
320 Effect.catchTag("ResourceNotFoundException", () => Effect.void),
321 );
322 }),
323 });
324 }),
325 );