1import * as ec2 from "@distilled.cloud/aws/ec2";
2import * as Data from "effect/Data";
3import * as Effect from "effect/Effect";
4import * as Schedule from "effect/Schedule";
5import * as Stream from "effect/Stream";
6import { isResolved } from "../../Diff.ts";
7import * as Provider from "../../Provider.ts";
8import { Resource } from "../../Resource.ts";
9import { AWSEnvironment } from "../Environment.ts";
10import type { Providers } from "../Providers.ts";
11import type {
12 SecurityGroupArn,
13 SecurityGroupId,
14 SecurityGroupRuleData,
15} from "./SecurityGroup.ts";
16import {
17 declaredSecurityGroupRuleIds,
18 expandSecurityGroupRules,
19 observedSecurityGroupRuleKey,
20 resolveSecurityGroupRules,
21 securityGroupRuleKey,
22} from "./SecurityGroupRule.ts";
23import type { VpcId } from "./Vpc.ts";
24
25class DefaultSecurityGroupNotFound extends Data.TaggedError(
26 "DefaultSecurityGroupNotFound",
27)<{ vpcId: VpcId }> {}
28
29class InvalidDefaultSecurityGroupRules extends Data.TaggedError(
30 "InvalidDefaultSecurityGroupRules",
31)<{ message: string }> {}
32
33class DefaultSecurityGroupRulesNotConverged extends Data.TaggedError(
34 "DefaultSecurityGroupRulesNotConverged",
35)<{ groupId: SecurityGroupId }> {}
36
37/** An observed rule on the AWS-created default security group. */
38export interface DefaultSecurityGroupRuleAttributes<IsEgress extends boolean> {
39 /** The physical rule ID, retained when only its description changes. */
40 securityGroupRuleId: string;
41 /** The IP protocol name or number returned by AWS. */
42 ipProtocol: string;
43 /** The first port, or ICMP type. */
44 fromPort?: number;
45 /** The last port, or ICMP code. */
46 toPort?: number;
47 /** The canonical IPv4 source or destination CIDR. */
48 cidrIpv4?: string;
49 /** The canonical IPv6 source or destination CIDR. */
50 cidrIpv6?: string;
51 /** The source or destination security group ID. */
52 referencedGroupId?: string;
53 /** The source or destination managed prefix list ID. */
54 prefixListId?: string;
55 /** The description observed in AWS. */
56 description?: string;
57 /** Whether the rule controls outbound rather than inbound traffic. */
58 isEgress: IsEgress;
59}
60
61/** Properties for a VPC's AWS-created `default` security group. */
62export interface DefaultSecurityGroupProps {
63 /** The VPC whose AWS-created `default` security group is managed. */
64 vpcId: VpcId;
65
66 /**
67 * Desired inline inbound rules. Omitted, undefined, and [] all remove inline
68 * inbound rules, including AWS's initial self-reference rule. Standalone
69 * rules owned by current declarations in this stack and stage are preserved.
70 * @default []
71 */
72 ingress?: SecurityGroupRuleData[];
73
74 /**
75 * Desired inline outbound rules. Omitted or undefined restores IPv4
76 * allow-all outbound; [] removes inline outbound rules. Standalone rules
77 * owned by current declarations in this stack and stage are preserved.
78 * @default [{ ipProtocol: "-1", cidrIpv4: "0.0.0.0/0" }]
79 */
80 egress?: SecurityGroupRuleData[];
81}
82
83export interface DefaultSecurityGroup extends Resource<
84 "AWS.EC2.DefaultSecurityGroup",
85 DefaultSecurityGroupProps,
86 {
87 /** The ID of the AWS-created default security group. */
88 groupId: SecurityGroupId;
89 /** The ARN of the default security group. */
90 groupArn: SecurityGroupArn;
91 /** The AWS-assigned group name, always `default`. */
92 groupName: string;
93 /** The AWS-assigned description of the group. */
94 description: string;
95 /** The VPC that owns this default security group. */
96 vpcId: VpcId;
97 /** The AWS account that owns the group. */
98 ownerId: string;
99 /** The complete inbound rule set observed after reconciliation. */
100 ingressRules: DefaultSecurityGroupRuleAttributes<false>[];
101 /** The complete outbound rule set observed after reconciliation. */
102 egressRules: DefaultSecurityGroupRuleAttributes<true>[];
103 },
104 never,
105 Providers
106> {}
107
108/**
109 * Declaratively manages the rules of the AWS-created `default` security group
110 * in one VPC. AWS creates this group named `default` whenever it creates a
111 * VPC; Alchemy looks it up by VPC ID and name, never creates it, and never
112 * deletes it.
113 *
114 * Inline rules use the same defaults as `SecurityGroup`: omitted or undefined
115 * `ingress` means no inline inbound rules, and omitted or undefined `egress`
116 * means IPv4 allow-all outbound. Passing `[]` means no inline rules in that
117 * direction, not unmanaged rules. Defaults apply on initial management,
118 * property removal, adoption, and drift repair; AWS's initial self-ingress
119 * rule is removed, not restored.
120 *
121 * Current standalone `SecurityGroupRule` declarations in the same stack and
122 * stage retain ownership of their persisted physical rule IDs. Other rules
123 * are removed even if they carry Alchemy tags. This resource manages rules,
124 * not ownership of the AWS-created group; do not also manage this group with
125 * a `SecurityGroup` resource or a second `DefaultSecurityGroup` manager.
126 *
127 * Removing this Alchemy resource leaves both the default group and its last
128 * applied rules unchanged. Deleting its VPC removes the group as part of AWS's
129 * VPC lifecycle.
130 *
131 * ### Default Inline Rules
132 * **Example:** Remove AWS self-ingress and allow outbound IPv4
133 * ```typescript
134 * const vpc = yield* AWS.EC2.Vpc("Vpc", { cidrBlock: "10.0.0.0/16" });
135 * const group = yield* AWS.EC2.DefaultSecurityGroup("DefaultSecurityGroup", {
136 * vpcId: vpc.vpcId,
137 * });
138 * ```
139 *
140 * **Example:** Explicit undefined has the same meaning as omission
141 * ```typescript
142 * yield* AWS.EC2.DefaultSecurityGroup("DefaultSecurityGroup", {
143 * vpcId: vpc.vpcId,
144 * ingress: undefined,
145 * egress: undefined,
146 * });
147 * ```
148 *
149 * ### Closing the Default Security Group
150 * **Example:** Deny all traffic when there are no owned standalone rules
151 * ```typescript
152 * const vpc = yield* AWS.EC2.Vpc("Vpc", { cidrBlock: "10.0.0.0/16" });
153 * yield* AWS.EC2.DefaultSecurityGroup("DefaultSecurityGroup", {
154 * vpcId: vpc.vpcId,
155 * ingress: [],
156 * egress: [],
157 * });
158 * ```
159 *
160 * ### Updating Rules
161 * **Example:** Allow only internal HTTPS
162 * ```typescript
163 * yield* AWS.EC2.DefaultSecurityGroup("DefaultSecurityGroup", {
164 * vpcId: vpc.vpcId,
165 * ingress: [{
166 * ipProtocol: "tcp",
167 * fromPort: 443,
168 * toPort: 443,
169 * cidrIpv4: "10.0.0.0/16",
170 * description: "Internal HTTPS",
171 * }],
172 * egress: [],
173 * });
174 * ```
175 *
176 * Unchanged deployments repair missing rules and remove undeclared rules.
177 * Updates leave unrelated rule IDs untouched; description changes update in
178 * place, and removing a description clears it. Equivalent protocol names,
179 * canonical CIDRs, rule order, and duplicate rules do not cause rule churn.
180 * Duplicates with conflicting descriptions are rejected before any writes.
181 * A rule with several source fields expands into one AWS rule per source.
182 *
183 * ### Restoring Default Inline Rules
184 * **Example:** Remove explicit rule lists to restore defaults
185 * ```diff lang="typescript"
186 * yield* AWS.EC2.DefaultSecurityGroup("DefaultSecurityGroup", {
187 * vpcId: vpc.vpcId,
188 * - ingress: [],
189 * - egress: [],
190 * });
191 * ```
192 *
193 * This still denies inline inbound access but restores IPv4 allow-all outbound.
194 * Removing these properties is different from removing the manager itself:
195 * deleting the manager leaves its last-applied rules, with no baseline restore.
196 *
197 * ### Composing Standalone Rules
198 * **Example:** Restrict outbound traffic with a standalone rule
199 * ```typescript
200 * const group = yield* AWS.EC2.DefaultSecurityGroup("DefaultSecurityGroup", {
201 * vpcId: vpc.vpcId,
202 * ingress: [],
203 * egress: [],
204 * });
205 * yield* AWS.EC2.SecurityGroupRule("HttpsEgress", {
206 * group: group,
207 * type: "egress",
208 * ipProtocol: "tcp",
209 * fromPort: 443,
210 * toPort: 443,
211 * cidrIpv4: "10.0.0.0/16",
212 * });
213 * ```
214 *
215 * Pass the whole manager as `group` so rule creation and updates wait for its
216 * inline reconciliation. Only `group.groupId` is consumed by the rule provider;
217 * unrelated manager attributes do not trigger rule updates. The stable ID is
218 * still available for replacement planning. The ID-only `groupId` form cannot
219 * enforce this ordering when the manager updates without changing its ID.
220 * Standalone ingress composes the same way. Omitting `egress` instead would
221 * retain the default allow-all rule alongside this standalone rule. A current
222 * declaration and its persisted physical ID establish ownership, not cloud
223 * tags. Removing a declaration ends that protection; its provider handles
224 * deletion. Cross-stack or cross-stage rule ownership is unsupported.
225 * Inline and standalone rules must have distinct identities. If a standalone
226 * rule owns IPv4 allow-all egress, set `egress: []` to disable the inline default.
227 *
228 * ### Changing VPCs
229 * **Example:** Manage another VPC's default group
230 * ```typescript
231 * yield* AWS.EC2.DefaultSecurityGroup("DefaultSecurityGroup", {
232 * vpcId: otherVpc.vpcId,
233 * ingress: [],
234 * egress: [],
235 * });
236 * ```
237 *
238 * Changing `vpcId` replaces the Alchemy resource, not either AWS-owned group,
239 * including when the destination VPC is created or replaced in the same deploy.
240 * The previous group's last-applied rules remain unchanged. Keep the old VPC
241 * declared until replacement finishes, then remove it separately if desired.
242 *
243 * @resource
244 */
245export const DefaultSecurityGroup = Resource<DefaultSecurityGroup>(
246 "AWS.EC2.DefaultSecurityGroup",
247);
248
249export const DefaultSecurityGroupProvider = () =>
250 Provider.effect(
251 DefaultSecurityGroup,
252 Effect.gen(function* () {
253 const describeGroup = (vpcId: VpcId) =>
254 ec2
255 .describeSecurityGroups({
256 Filters: [
257 { Name: "vpc-id", Values: [vpcId] },
258 { Name: "group-name", Values: ["default"] },
259 ],
260 })
261 .pipe(
262 // Filtering by a deleted VPC returns an empty collection.
263 Effect.map((result) => result.SecurityGroups?.[0]),
264 );
265
266 const describeRules = (groupId: SecurityGroupId) =>
267 ec2.describeSecurityGroupRules
268 .items({ Filters: [{ Name: "group-id", Values: [groupId] }] })
269 .pipe(
270 Stream.runCollect,
271 Effect.map((rules) => Array.from(rules)),
272 );
273
274 const toAttrs = Effect.fn(function* (
275 group: ec2.SecurityGroup,
276 rules: ec2.SecurityGroupRule[],
277 ) {
278 const { accountId, region } = yield* AWSEnvironment.current;
279 const toRule =
280 <IsEgress extends boolean>(isEgress: IsEgress) =>
281 (rule: ec2.SecurityGroupRule) => ({
282 securityGroupRuleId: rule.SecurityGroupRuleId!,
283 ipProtocol: rule.IpProtocol!,
284 fromPort: rule.FromPort,
285 toPort: rule.ToPort,
286 cidrIpv4: rule.CidrIpv4,
287 cidrIpv6: rule.CidrIpv6,
288 referencedGroupId: rule.ReferencedGroupInfo?.GroupId,
289 prefixListId: rule.PrefixListId,
290 description: rule.Description,
291 isEgress,
292 });
293 return {
294 groupId: group.GroupId as SecurityGroupId,
295 groupArn:
296 `arn:aws:ec2:${region}:${accountId}:security-group/${group.GroupId}` as SecurityGroupArn,
297 groupName: group.GroupName!,
298 description: group.Description!,
299 vpcId: group.VpcId as VpcId,
300 ownerId: group.OwnerId!,
301 ingressRules: rules
302 .filter((rule) => !rule.IsEgress)
303 .map(toRule(false)),
304 egressRules: rules.filter((rule) => rule.IsEgress).map(toRule(true)),
305 } satisfies DefaultSecurityGroup["Attributes"];
306 });
307
308 const toPermission = (rule: SecurityGroupRuleData): ec2.IpPermission => ({
309 IpProtocol: rule.ipProtocol,
310 FromPort: rule.fromPort,
311 ToPort: rule.toPort,
312 IpRanges: rule.cidrIpv4
313 ? [{ CidrIp: rule.cidrIpv4, Description: rule.description }]
314 : undefined,
315 Ipv6Ranges: rule.cidrIpv6
316 ? [{ CidrIpv6: rule.cidrIpv6, Description: rule.description }]
317 : undefined,
318 UserIdGroupPairs: rule.referencedGroupId
319 ? [{ GroupId: rule.referencedGroupId, Description: rule.description }]
320 : undefined,
321 PrefixListIds: rule.prefixListId
322 ? [{ PrefixListId: rule.prefixListId, Description: rule.description }]
323 : undefined,
324 });
325
326 const desiredRules = Effect.fn(function* (
327 rules: SecurityGroupRuleData[],
328 ) {
329 if (
330 rules.some(
331 (rule) =>
332 !rule.ipProtocol ||
333 ![
334 rule.cidrIpv4,
335 rule.cidrIpv6,
336 rule.referencedGroupId,
337 rule.prefixListId,
338 ].some(Boolean),
339 )
340 ) {
341 return yield* new InvalidDefaultSecurityGroupRules({
342 message: "Every rule must specify a protocol and a source.",
343 });
344 }
345 const desired = new Map<string, SecurityGroupRuleData>();
346 for (const rule of expandSecurityGroupRules(rules)) {
347 const key = securityGroupRuleKey({ ...rule, description: undefined });
348 const previous = desired.get(key);
349 if (
350 previous &&
351 securityGroupRuleKey(previous) !== securityGroupRuleKey(rule)
352 ) {
353 return yield* new InvalidDefaultSecurityGroupRules({
354 message: "Duplicate rules must have the same description.",
355 });
356 }
357 desired.set(key, rule);
358 }
359 return desired;
360 });
361
362 const rulesMatch = (
363 rules: ec2.SecurityGroupRule[],
364 ingress: Map<string, SecurityGroupRuleData>,
365 egress: Map<string, SecurityGroupRuleData>,
366 ) =>
367 [false, true].every((isEgress) => {
368 const observed = rules.filter((rule) => !!rule.IsEgress === isEgress);
369 const desired = [...(isEgress ? egress : ingress).values()];
370 return (
371 JSON.stringify(
372 observed.map(observedSecurityGroupRuleKey).sort(),
373 ) === JSON.stringify(desired.map(securityGroupRuleKey).sort())
374 );
375 });
376
377 const syncRules = Effect.fn(function* (
378 groupId: SecurityGroupId,
379 isEgress: boolean,
380 desired: Map<string, SecurityGroupRuleData>,
381 observed: ec2.SecurityGroupRule[],
382 ) {
383 const current = new Map(
384 observed.map((rule) => [
385 observedSecurityGroupRuleKey({ ...rule, Description: undefined }),
386 rule,
387 ]),
388 );
389 for (const [key, rule] of current) {
390 const wanted = desired.get(key);
391 if (!wanted) {
392 const request = {
393 GroupId: groupId,
394 SecurityGroupRuleIds: [rule.SecurityGroupRuleId!],
395 DryRun: false,
396 };
397 yield* Effect.gen(function* () {
398 if (isEgress) yield* ec2.revokeSecurityGroupEgress(request);
399 else yield* ec2.revokeSecurityGroupIngress(request);
400 }).pipe(
401 Effect.catchTag(
402 [
403 "InvalidPermission.NotFound",
404 "InvalidSecurityGroupRuleId.NotFound",
405 ],
406 () => Effect.void,
407 ),
408 );
409 } else if ((wanted.description ?? "") !== (rule.Description ?? "")) {
410 yield* ec2
411 .modifySecurityGroupRules({
412 GroupId: groupId,
413 SecurityGroupRules: [
414 {
415 SecurityGroupRuleId: rule.SecurityGroupRuleId!,
416 SecurityGroupRule: {
417 IpProtocol: rule.IpProtocol,
418 FromPort: rule.FromPort,
419 ToPort: rule.ToPort,
420 CidrIpv4: rule.CidrIpv4,
421 CidrIpv6: rule.CidrIpv6,
422 ReferencedGroupId: rule.ReferencedGroupInfo?.GroupId,
423 PrefixListId: rule.PrefixListId,
424 Description: wanted.description ?? "",
425 },
426 },
427 ],
428 })
429 .pipe(
430 Effect.catchTag("InvalidSecurityGroupRuleId.NotFound", () =>
431 Effect.fail(
432 new DefaultSecurityGroupRulesNotConverged({ groupId }),
433 ),
434 ),
435 );
436 }
437 }
438 for (const [key, rule] of desired) {
439 if (current.has(key)) continue;
440 const request = {
441 GroupId: groupId,
442 IpPermissions: [toPermission(rule)],
443 DryRun: false,
444 };
445 yield* (
446 isEgress
447 ? ec2.authorizeSecurityGroupEgress(request)
448 : ec2.authorizeSecurityGroupIngress(request)
449 ).pipe(
450 // Re-observe a racing authorization rather than assuming its description.
451 Effect.catchTag("InvalidPermission.Duplicate", () =>
452 Effect.fail(
453 new DefaultSecurityGroupRulesNotConverged({ groupId }),
454 ),
455 ),
456 );
457 }
458 });
459
460 return {
461 stables: ["groupId", "groupArn", "groupName", "ownerId"],
462
463 read: Effect.fn(function* ({ olds, output }) {
464 const vpcId = output?.vpcId ?? olds?.vpcId;
465 if (!vpcId) return undefined;
466 const group = yield* describeGroup(vpcId);
467 if (!group?.GroupId) return undefined;
468 return yield* toAttrs(
469 group,
470 yield* describeRules(group.GroupId as SecurityGroupId),
471 );
472 }),
473
474 diff: Effect.fn(function* ({ news, olds }) {
475 // An unresolved VPC must not expose the previous group's stable identity.
476 if (
477 !("vpcId" in news) ||
478 !isResolved(news.vpcId) ||
479 news.vpcId !== olds.vpcId
480 ) {
481 return { action: "replace" };
482 }
483 if (!isResolved(news.ingress) || !isResolved(news.egress)) return;
484 const ingress = yield* desiredRules(
485 resolveSecurityGroupRules(news.ingress, false),
486 );
487 const egress = yield* desiredRules(
488 resolveSecurityGroupRules(news.egress, true),
489 );
490 const group = yield* describeGroup(news.vpcId);
491 if (!group?.GroupId) return { action: "update", stables: [] };
492 const groupId = group.GroupId as SecurityGroupId;
493 const owned = yield* declaredSecurityGroupRuleIds(groupId);
494 const observed = (yield* describeRules(groupId)).filter(
495 (rule) => !owned.has(rule.SecurityGroupRuleId!),
496 );
497 if (!rulesMatch(observed, ingress, egress))
498 return { action: "update" };
499 }),
500
501 reconcile: Effect.fn(function* ({ news, session }) {
502 // Validate both directions before changing either one.
503 const ingress = yield* desiredRules(
504 resolveSecurityGroupRules(news.ingress, false),
505 );
506 const egress = yield* desiredRules(
507 resolveSecurityGroupRules(news.egress, true),
508 );
509 const group = yield* describeGroup(news.vpcId).pipe(
510 Effect.flatMap((group) =>
511 group?.GroupId
512 ? Effect.succeed(group)
513 : Effect.fail(
514 new DefaultSecurityGroupNotFound({ vpcId: news.vpcId }),
515 ),
516 ),
517 // AWS may expose the VPC before its default group is visible.
518 Effect.retry({
519 while: (error) => error._tag === "DefaultSecurityGroupNotFound",
520 schedule: Schedule.spaced("1 second"),
521 times: 8,
522 }),
523 );
524 const groupId = group.GroupId as SecurityGroupId;
525 const owned = yield* declaredSecurityGroupRuleIds(groupId);
526 const inlineRules = (rules: ec2.SecurityGroupRule[]) =>
527 rules.filter((rule) => !owned.has(rule.SecurityGroupRuleId!));
528 const finalRules = yield* Effect.gen(function* () {
529 const observed = yield* describeRules(groupId);
530 const inline = inlineRules(observed);
531 if (rulesMatch(inline, ingress, egress)) return observed;
532 yield* syncRules(
533 groupId,
534 false,
535 ingress,
536 inline.filter((rule) => !rule.IsEgress),
537 );
538 yield* syncRules(
539 groupId,
540 true,
541 egress,
542 inline.filter((rule) => rule.IsEgress),
543 );
544 const final = yield* describeRules(groupId);
545 if (!rulesMatch(inlineRules(final), ingress, egress)) {
546 return yield* new DefaultSecurityGroupRulesNotConverged({
547 groupId,
548 });
549 }
550 return final;
551 }).pipe(
552 Effect.retry({
553 while: (error) =>
554 error._tag === "DefaultSecurityGroupRulesNotConverged",
555 schedule: Schedule.spaced("1 second"),
556 times: 8,
557 }),
558 );
559 yield* session.note(`Reconciled default security group: ${groupId}`);
560 return yield* toAttrs(group, finalRules);
561 }),
562
563 // AWS owns the default group. Intentionally leave it and its rules as-is.
564 delete: () => Effect.void,
565 };
566 }),
567 );