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";
6
7import { deepEqual, isResolved } from "../../Diff.ts";
8import * as Output from "../../Output.ts";
9import { canonicalCidr } from "../../Utils/ip-address.ts";
10import * as Provider from "../../Provider.ts";
11import { Resource } from "../../Resource.ts";
12import { Stack } from "../../Stack.ts";
13import { State, isActionState } from "../../State/State.ts";
14import type { Providers } from "../Providers.ts";
15import { createInternalTags, createTagsList, diffTags } from "../../Tags.ts";
16import {
17 getDefaultVpcDefaultSecurityGroupId,
18 getDefaultVpcScope,
19} from "./defaultVpcScope.ts";
20import type {
21 SecurityGroupId,
22 SecurityGroupRuleData,
23} from "./SecurityGroup.ts";
24
25export type SecurityGroupRuleId<ID extends string = string> = `sgr-${ID}`;
26export const SecurityGroupRuleId = <ID extends string>(
27 id: ID,
28): ID & SecurityGroupRuleId<ID> => `sgr-${id}` as ID & SecurityGroupRuleId<ID>;
29
30export interface SecurityGroupRuleProps {
31 /**
32 * The SecurityGroup or DefaultSecurityGroup resource, or a { groupId } object.
33 * Pass the whole resource to wait for its inline-rule reconciliation before
34 * creating or updating this rule; an ID-only object does not retain that edge.
35 * Only groupId is consumed; other attributes do not change this rule.
36 * Specify exactly one of group or groupId.
37 */
38 group?: {
39 /** The security group's physical ID. */
40 groupId: SecurityGroupId;
41 };
42
43 /**
44 * The ID of an existing security group. Retained for ID-only callers; use
45 * group with the whole resource when composing inline and standalone rules.
46 * Specify exactly one of group or groupId.
47 */
48 groupId?: SecurityGroupId;
49
50 /**
51 * Whether this is an ingress (inbound) or egress (outbound) rule.
52 */
53 type: "ingress" | "egress";
54
55 /**
56 * The IP protocol name or number.
57 * Use -1 to specify all protocols.
58 */
59 ipProtocol: string;
60
61 /**
62 * The start of the port range.
63 * For ICMP, use the ICMP type number.
64 */
65 fromPort?: number;
66
67 /**
68 * The end of the port range.
69 * For ICMP, use the ICMP code.
70 */
71 toPort?: number;
72
73 /**
74 * IPv4 CIDR range to allow.
75 */
76 cidrIpv4?: string;
77
78 /**
79 * IPv6 CIDR range to allow.
80 */
81 cidrIpv6?: string;
82
83 /**
84 * ID of a security group to allow traffic from/to.
85 */
86 referencedGroupId?: SecurityGroupId;
87
88 /**
89 * ID of a prefix list.
90 */
91 prefixListId?: string;
92
93 /**
94 * Description for the rule.
95 */
96 description?: string;
97
98 /**
99 * Tags to assign to the security group rule.
100 */
101 tags?: Record<string, string>;
102}
103
104export interface SecurityGroupRule extends Resource<
105 "AWS.EC2.SecurityGroupRule",
106 SecurityGroupRuleProps,
107 {
108 /**
109 * The ID of the security group rule.
110 */
111 securityGroupRuleId: SecurityGroupRuleId;
112
113 /**
114 * The ID of the security group.
115 */
116 groupId: SecurityGroupId;
117
118 /**
119 * The ID of the AWS account that owns the security group.
120 */
121 groupOwnerId: string;
122
123 /**
124 * Whether this is an egress rule.
125 */
126 isEgress: boolean;
127
128 /**
129 * The IP protocol.
130 */
131 ipProtocol: string;
132
133 /**
134 * The start of the port range.
135 */
136 fromPort?: number;
137
138 /**
139 * The end of the port range.
140 */
141 toPort?: number;
142
143 /**
144 * The IPv4 CIDR range.
145 */
146 cidrIpv4?: string | undefined;
147
148 /**
149 * The IPv6 CIDR range.
150 */
151 cidrIpv6?: string | undefined;
152
153 /**
154 * The ID of the referenced security group.
155 */
156 referencedGroupId?: string;
157
158 /**
159 * The ID of the prefix list.
160 */
161 prefixListId?: string;
162
163 /**
164 * The description.
165 */
166 description?: string | undefined;
167 },
168 never,
169 Providers
170> {}
171/**
172 * A single ingress or egress rule attached to an existing security group,
173 * managed as a standalone resource. Use this when you want to manage a
174 * security group's rules independently of its inline rules, or to add rules
175 * to a group not managed by an Alchemy SecurityGroup resource.
176 *
177 * Declare the group and rule in the same stack and stage, passing the whole
178 * resource as `group` to order rule operations after inline reconciliation.
179 * `groupId` remains supported for ID-only callers, but its stable value alone
180 * does not order concurrent inline updates. Specify exactly one input form;
181 * switching forms with the same physical ID does not replace the rule.
182 * The group recognizes the current declaration and its persisted physical
183 * rule ID, not cloud tags. Rules in another stack or stage are not protected
184 * from the group's reconciliation;
185 * cross-stack ownership is unsupported.
186 *
187 * Changes to protocol, ports, source, or `type` props replace the rule.
188 * External edits to protocol, ports, source, description, and tags are repaired
189 * on unchanged deployment. Description and tag prop changes update in place.
190 *
191 * ### Ingress vs Egress
192 * The `type` field decides the direction: `"ingress"` for inbound rules and
193 * `"egress"` for outbound. Everything else (protocol, ports, source) is shared
194 * between the two directions.
195 *
196 * **Example:** Inbound HTTPS from anywhere
197 * ```typescript
198 * const httpsRule = yield* AWS.EC2.SecurityGroupRule("HttpsIngress", {
199 * group: sg,
200 * type: "ingress",
201 * ipProtocol: "tcp",
202 * fromPort: 443,
203 * toPort: 443,
204 * cidrIpv4: "0.0.0.0/0",
205 * description: "Allow HTTPS",
206 * });
207 * ```
208 *
209 * Opens TCP 443 inbound from the entire internet on the target group. A single
210 * port is expressed by setting `fromPort` and `toPort` to the same value.
211 *
212 * **Example:** Outbound to a database port
213 * ```typescript
214 * const egressRule = yield* AWS.EC2.SecurityGroupRule("DbEgress", {
215 * group: sg,
216 * type: "egress",
217 * ipProtocol: "tcp",
218 * fromPort: 5432,
219 * toPort: 5432,
220 * cidrIpv4: "10.0.0.0/16",
221 * description: "Allow PostgreSQL to the VPC",
222 * });
223 * ```
224 *
225 * This rule allows outbound PostgreSQL within the VPC CIDR. Set `egress: []`
226 * on the parent group to remove its default allow-all-outbound rule; adding a
227 * standalone egress rule does not remove other rules.
228 *
229 * ### Rule Sources
230 * A rule's source (for ingress) or destination (for egress) is exactly one of:
231 * an IPv4 CIDR (`cidrIpv4`), an IPv6 CIDR (`cidrIpv6`), another security group
232 * (`referencedGroupId`), or a managed prefix list (`prefixListId`).
233 *
234 * **Example:** Allow traffic from another security group
235 * ```typescript
236 * const dbFromWeb = yield* AWS.EC2.SecurityGroupRule("DbFromWeb", {
237 * group: dbSg,
238 * type: "ingress",
239 * ipProtocol: "tcp",
240 * fromPort: 5432,
241 * toPort: 5432,
242 * referencedGroupId: webSg.groupId,
243 * description: "Allow PostgreSQL from web tier",
244 * });
245 * ```
246 *
247 * Referencing `webSg` rather than a CIDR means any instance in the web tier can
248 * reach the database, even as the tier's IPs change. This is the preferred way
249 * to wire trust between tiers.
250 *
251 * **Example:** Allow an IPv6 range
252 * ```typescript
253 * const ipv6Rule = yield* AWS.EC2.SecurityGroupRule("HttpsIpv6", {
254 * group: sg,
255 * type: "ingress",
256 * ipProtocol: "tcp",
257 * fromPort: 443,
258 * toPort: 443,
259 * cidrIpv6: "::/0",
260 * description: "Allow HTTPS over IPv6",
261 * });
262 * ```
263 *
264 * Use `cidrIpv6` for dual-stack workloads; `::/0` is the IPv6 equivalent of
265 * `0.0.0.0/0`. IPv4 and IPv6 are separate rules — you'd pair this with a
266 * `cidrIpv4` rule to cover both.
267 *
268 * **Example:** Allow from a managed prefix list
269 * ```typescript
270 * const sshRule = yield* AWS.EC2.SecurityGroupRule("SshFromCorp", {
271 * group: sg,
272 * type: "ingress",
273 * ipProtocol: "tcp",
274 * fromPort: 22,
275 * toPort: 22,
276 * prefixListId: "pl-0123456789abcdef0",
277 * description: "Allow SSH from the corporate prefix list",
278 * });
279 * ```
280 *
281 * A `prefixListId` references a centrally-managed set of CIDRs by ID, so the
282 * rule's effective ranges update automatically whenever the prefix list does.
283 *
284 * ### Protocols & Ports
285 * `ipProtocol` accepts `tcp`, `udp`, `icmp`/`icmpv6`, a protocol number, or `-1`
286 * for all protocols. For ICMP, `fromPort` is the ICMP type and `toPort` is the
287 * ICMP code, with `-1` meaning "all".
288 *
289 * **Example:** Allow all traffic from a trusted CIDR
290 * ```typescript
291 * const allRule = yield* AWS.EC2.SecurityGroupRule("AllFromVpc", {
292 * group: sg,
293 * type: "ingress",
294 * ipProtocol: "-1",
295 * cidrIpv4: "10.0.0.0/16",
296 * description: "Allow all protocols from within the VPC",
297 * });
298 * ```
299 *
300 * With `ipProtocol: "-1"`, ports are ignored and every protocol is permitted —
301 * appropriate only for fully trusted sources such as your own VPC CIDR.
302 *
303 * **Example:** Allow ICMP echo (ping)
304 * ```typescript
305 * const icmpRule = yield* AWS.EC2.SecurityGroupRule("AllowPing", {
306 * group: sg,
307 * type: "ingress",
308 * ipProtocol: "icmp",
309 * fromPort: 8,
310 * toPort: 0,
311 * cidrIpv4: "10.0.0.0/16",
312 * description: "Allow ICMP echo request",
313 * });
314 * ```
315 *
316 * For ICMP the port fields carry the type and code: type `8` / code `0` is an
317 * echo request (ping). Use `fromPort: -1, toPort: -1` to allow every ICMP
318 * type/code instead.
319 *
320 * @resource
321 */
322export const SecurityGroupRule = Resource<SecurityGroupRule>(
323 "AWS.EC2.SecurityGroupRule",
324);
325
326class InvalidSecurityGroupRuleGroup extends Data.TaggedError(
327 "InvalidSecurityGroupRuleGroup",
328)<{ message: string }> {}
329
330const ruleGroupId = Effect.fn(function* (
331 props: Pick<SecurityGroupRuleProps, "group" | "groupId">,
332) {
333 if ((props.group === undefined) === (props.groupId === undefined)) {
334 return yield* new InvalidSecurityGroupRuleGroup({
335 message: "Specify exactly one of group or groupId.",
336 });
337 }
338 const groupId = props.group?.groupId ?? props.groupId;
339 if (typeof groupId !== "string") {
340 return yield* new InvalidSecurityGroupRuleGroup({
341 message: "The group must provide a groupId.",
342 });
343 }
344 return groupId;
345});
346
347class SecurityGroupRuleNotConverged extends Data.TaggedError(
348 "SecurityGroupRuleNotConverged",
349)<{
350 ruleId: string;
351}> {}
352
353export const SecurityGroupRuleProvider = () =>
354 Provider.effect(
355 SecurityGroupRule,
356 Effect.gen(function* () {
357 const createTags = Effect.fn(function* (
358 id: string,
359 tags?: Record<string, string>,
360 ) {
361 return {
362 Name: id,
363 ...(yield* createInternalTags(id)),
364 ...tags,
365 };
366 });
367
368 const describeRule = (ruleId: string) =>
369 ec2.describeSecurityGroupRules({ SecurityGroupRuleIds: [ruleId] }).pipe(
370 Effect.map((r) => r.SecurityGroupRules?.[0]),
371 Effect.flatMap((rule) =>
372 rule
373 ? Effect.succeed(rule)
374 : Effect.fail(
375 new Error(`Security Group Rule ${ruleId} not found`),
376 ),
377 ),
378 );
379
380 const toAttrs = (
381 rule: Awaited<
382 ReturnType<
383 typeof describeRule extends (
384 ...args: any
385 ) => Effect.Effect<infer R, any, any>
386 ? () => Promise<R>
387 : never
388 >
389 >,
390 ): SecurityGroupRule["Attributes"] => ({
391 securityGroupRuleId: rule.SecurityGroupRuleId as SecurityGroupRuleId,
392 groupId: rule.GroupId as SecurityGroupId,
393 groupOwnerId: rule.GroupOwnerId!,
394 isEgress: rule.IsEgress as boolean,
395 ipProtocol: rule.IpProtocol!,
396 fromPort: rule.FromPort,
397 toPort: rule.ToPort,
398 cidrIpv4: rule.CidrIpv4,
399 cidrIpv6: rule.CidrIpv6,
400 referencedGroupId: rule.ReferencedGroupInfo?.GroupId,
401 prefixListId: rule.PrefixListId,
402 description: rule.Description,
403 });
404
405 const tagsMatch = (
406 rule: ec2.SecurityGroupRule,
407 tags: Record<string, string>,
408 ) => {
409 const { removed, upsert } = diffTags(
410 Object.fromEntries(
411 (rule.Tags ?? []).map((tag) => [tag.Key!, tag.Value!]),
412 ),
413 tags,
414 );
415 return removed.length === 0 && upsert.length === 0;
416 };
417
418 return {
419 stables: ["securityGroupRuleId", "groupOwnerId"],
420
421 read: Effect.fn(function* ({ output }) {
422 if (!output) return undefined;
423 return yield* describeRule(output.securityGroupRuleId).pipe(
424 Effect.map(toAttrs),
425 Effect.catchTag("InvalidSecurityGroupRuleId.NotFound", () =>
426 Effect.succeed(undefined),
427 ),
428 );
429 }),
430
431 // Account/region-scoped: every rule is enumerable via
432 // describeSecurityGroupRules with no filter. Paginate exhaustively.
433 list: () =>
434 Effect.gen(function* () {
435 // Rules on the default VPC's "default" security group are
436 // furniture AWS provisions with the default VPC; never
437 // census/nuke them. Rules on user-created groups inside the
438 // default VPC are still listed.
439 const defaultVpc = yield* getDefaultVpcScope;
440 const defaultSgId = yield* getDefaultVpcDefaultSecurityGroupId(
441 defaultVpc.vpcId,
442 );
443 return yield* ec2.describeSecurityGroupRules.pages({}).pipe(
444 Stream.runCollect,
445 Effect.map((chunk) =>
446 Array.from(chunk).flatMap((page) =>
447 (page.SecurityGroupRules ?? [])
448 .filter(
449 (
450 rule,
451 ): rule is ec2.SecurityGroupRule & {
452 SecurityGroupRuleId: string;
453 } => rule.SecurityGroupRuleId != null,
454 )
455 .filter(
456 (rule) =>
457 defaultSgId === undefined ||
458 rule.GroupId !== defaultSgId,
459 )
460 .map((rule) => toAttrs(rule)),
461 ),
462 ),
463 );
464 }),
465
466 diff: Effect.fn(function* ({ id, news, olds, output }) {
467 if (!("type" in news)) return;
468 const { group, groupId, ...rule } = news;
469 if ((group === undefined) === (groupId === undefined)) {
470 return yield* new InvalidSecurityGroupRuleGroup({
471 message: "Specify exactly one of group or groupId.",
472 });
473 }
474 // Updating groups expose stable IDs; creating/replacing groups do not.
475 if (!isResolved(group)) return { action: "replace" };
476 const target = group?.groupId ?? groupId;
477 if (!isResolved(target)) return { action: "replace" };
478 const desiredGroupId = yield* ruleGroupId({ groupId: target });
479 const oldGroupId =
480 olds.group?.groupId ?? olds.groupId ?? output?.groupId;
481 if (desiredGroupId !== oldGroupId) return { action: "replace" };
482 for (const key of [
483 "type",
484 "ipProtocol",
485 "fromPort",
486 "toPort",
487 "cidrIpv4",
488 "cidrIpv6",
489 "referencedGroupId",
490 "prefixListId",
491 ] as const) {
492 if (rule[key] !== olds[key]) {
493 return { action: "replace" };
494 }
495 }
496 if (
497 !isResolved<Omit<SecurityGroupRuleProps, "group" | "groupId">>(rule)
498 )
499 return;
500 const desired = { ...rule, groupId: desiredGroupId };
501 const { group: _group, groupId: _groupId, ...oldRule } = olds;
502 if (output) {
503 const observed = yield* describeRule(
504 output.securityGroupRuleId,
505 ).pipe(
506 Effect.catchTag("InvalidSecurityGroupRuleId.NotFound", () =>
507 Effect.succeed(undefined),
508 ),
509 );
510 if (!observed) {
511 return { action: "update", stables: ["groupOwnerId"] };
512 }
513 if (
514 observed.GroupId !== desiredGroupId ||
515 observed.IsEgress !== (rule.type === "egress")
516 ) {
517 return { action: "replace" };
518 }
519 if (
520 observedSecurityGroupRuleKey(observed) !==
521 securityGroupRuleKey(rule) ||
522 !tagsMatch(observed, yield* createTags(id, rule.tags))
523 ) {
524 return { action: "update" };
525 }
526 // Ignore only the input form and unconsumed parent attributes.
527 if (deepEqual({ ...oldRule, groupId: oldGroupId }, desired)) {
528 return { action: "noop" };
529 }
530 }
531 return { action: "update" };
532 }),
533
534 reconcile: Effect.fn(function* ({ id, news, output, session }) {
535 const groupId = yield* ruleGroupId(news);
536 const desiredTags = yield* createTags(id, news.tags);
537
538 const ipPermission = {
539 IpProtocol: news.ipProtocol,
540 FromPort: news.fromPort,
541 ToPort: news.toPort,
542 IpRanges: news.cidrIpv4
543 ? [{ CidrIp: news.cidrIpv4, Description: news.description }]
544 : undefined,
545 Ipv6Ranges: news.cidrIpv6
546 ? [{ CidrIpv6: news.cidrIpv6, Description: news.description }]
547 : undefined,
548 UserIdGroupPairs: news.referencedGroupId
549 ? [
550 {
551 GroupId: news.referencedGroupId as string,
552 Description: news.description,
553 },
554 ]
555 : undefined,
556 PrefixListIds: news.prefixListId
557 ? [
558 {
559 PrefixListId: news.prefixListId as string,
560 Description: news.description,
561 },
562 ]
563 : undefined,
564 };
565
566 // Observe — find the rule via cached id, else fall through to
567 // create. SG rule identity is the SecurityGroupRuleId.
568 let observed: ec2.SecurityGroupRule | undefined;
569 if (output?.securityGroupRuleId) {
570 const lookup = yield* ec2
571 .describeSecurityGroupRules({
572 SecurityGroupRuleIds: [output.securityGroupRuleId],
573 })
574 .pipe(
575 Effect.catchTag("InvalidSecurityGroupRuleId.NotFound", () =>
576 Effect.succeed({ SecurityGroupRules: [] }),
577 ),
578 );
579 observed = lookup.SecurityGroupRules?.[0];
580 }
581
582 // Ensure — Authorize{Ingress,Egress} when missing.
583 if (observed === undefined) {
584 yield* session.note("Creating Security Group Rule...");
585 const tagSpec = [
586 {
587 ResourceType: "security-group-rule" as const,
588 Tags: createTagsList(desiredTags),
589 },
590 ];
591 const result =
592 news.type === "ingress"
593 ? yield* ec2.authorizeSecurityGroupIngress({
594 GroupId: groupId,
595 IpPermissions: [ipPermission],
596 TagSpecifications: tagSpec,
597 DryRun: false,
598 })
599 : yield* ec2.authorizeSecurityGroupEgress({
600 GroupId: groupId,
601 IpPermissions: [ipPermission],
602 TagSpecifications: tagSpec,
603 DryRun: false,
604 });
605 const newRuleId =
606 result.SecurityGroupRules?.[0]?.SecurityGroupRuleId!;
607 yield* session.note(`Security Group Rule created: ${newRuleId}`);
608 observed = yield* describeRule(newRuleId);
609 }
610
611 const ruleId = observed.SecurityGroupRuleId!;
612
613 const desiredKey = securityGroupRuleKey(news);
614 if (observedSecurityGroupRuleKey(observed) !== desiredKey) {
615 yield* ec2.modifySecurityGroupRules({
616 GroupId: groupId,
617 SecurityGroupRules: [
618 {
619 SecurityGroupRuleId: ruleId,
620 SecurityGroupRule: {
621 IpProtocol: news.ipProtocol,
622 FromPort: news.fromPort,
623 ToPort: news.toPort,
624 CidrIpv4: canonicalCidr(news.cidrIpv4),
625 CidrIpv6: canonicalCidr(news.cidrIpv6),
626 ReferencedGroupId: news.referencedGroupId as
627 | string
628 | undefined,
629 PrefixListId: news.prefixListId as string | undefined,
630 Description: news.description ?? "",
631 },
632 },
633 ],
634 });
635 }
636
637 // Sync tags — observed cloud tags vs desired.
638 const currentTags = Object.fromEntries(
639 (observed.Tags ?? []).map((t) => [t.Key!, t.Value!]),
640 ) as Record<string, string>;
641 const { removed, upsert } = diffTags(currentTags, desiredTags);
642 if (removed.length > 0) {
643 yield* ec2.deleteTags({
644 Resources: [ruleId],
645 Tags: removed.map((key) => ({ Key: key })),
646 DryRun: false,
647 });
648 }
649 if (upsert.length > 0) {
650 yield* ec2.createTags({
651 Resources: [ruleId],
652 Tags: upsert,
653 DryRun: false,
654 });
655 }
656
657 const matches = (rule: ec2.SecurityGroupRule) =>
658 observedSecurityGroupRuleKey(rule) === desiredKey &&
659 tagsMatch(rule, desiredTags);
660 const final = yield* describeRule(ruleId).pipe(
661 Effect.repeat({
662 until: matches,
663 schedule: Schedule.spaced("1 second"),
664 times: 8,
665 }),
666 );
667 if (!matches(final)) {
668 return yield* Effect.fail(
669 new SecurityGroupRuleNotConverged({ ruleId }),
670 );
671 }
672 return toAttrs(final);
673 }),
674
675 delete: Effect.fn(function* ({ olds, output, session }) {
676 const ruleId = output.securityGroupRuleId;
677 const groupId = (output.groupId ??
678 olds?.group?.groupId ??
679 olds?.groupId) as string;
680 // Prefer the observed attribute (`isEgress`) — `olds` may be the
681 // listed Attributes (e.g. during nuke) rather than the Props shape,
682 // in which case `olds.type` is undefined.
683 const isEgress = output.isEgress ?? olds?.type !== "ingress";
684
685 yield* session.note(`Deleting Security Group Rule: ${ruleId}`);
686
687 // The whole security group may already be gone, taking its rules
688 // with it (InvalidGroup.NotFound) — the rule is deleted either way.
689 if (isEgress) {
690 yield* ec2
691 .revokeSecurityGroupEgress({
692 GroupId: groupId,
693 SecurityGroupRuleIds: [ruleId],
694 DryRun: false,
695 })
696 .pipe(
697 Effect.catchTag(
698 [
699 "InvalidPermission.NotFound",
700 "InvalidSecurityGroupRuleId.NotFound",
701 ],
702 () => Effect.void,
703 ),
704 Effect.catchTag("InvalidGroup.NotFound", () => Effect.void),
705 );
706 } else {
707 yield* ec2
708 .revokeSecurityGroupIngress({
709 GroupId: groupId,
710 SecurityGroupRuleIds: [ruleId],
711 DryRun: false,
712 })
713 .pipe(
714 Effect.catchTag(
715 [
716 "InvalidPermission.NotFound",
717 "InvalidSecurityGroupRuleId.NotFound",
718 ],
719 () => Effect.void,
720 ),
721 Effect.catchTag("InvalidGroup.NotFound", () => Effect.void),
722 );
723 }
724
725 yield* session.note(`Security Group Rule ${ruleId} deleted`);
726 }),
727 };
728 }),
729 );
730
731// Only current declarations with persisted physical ownership are delegated.
732export const declaredSecurityGroupRuleIds = Effect.fn(function* (
733 groupId: string,
734) {
735 const stack = yield* Stack;
736 const state = yield* yield* State;
737 const ids = new Set<string>();
738 const resources = Object.values(stack.resources);
739 const upstream: Record<string, object> = {};
740 // flatMap can return a resource absent from the target's syntactic upstreams.
741 for (const source of resources) {
742 const sourceRow = yield* state.get({
743 stack: stack.name,
744 stage: stack.stage,
745 fqn: source.FQN,
746 });
747 if (
748 !sourceRow ||
749 isActionState(sourceRow) ||
750 sourceRow.resourceType !== source.Type
751 )
752 continue;
753 const sourceAttrs =
754 sourceRow.attr ?? ("old" in sourceRow ? sourceRow.old.attr : undefined);
755 if (sourceAttrs) upstream[source.FQN] = sourceAttrs;
756 }
757 for (const resource of resources) {
758 if (resource.Type !== "AWS.EC2.SecurityGroupRule" || !resource.Props)
759 continue;
760 const row = yield* state.get({
761 stack: stack.name,
762 stage: stack.stage,
763 fqn: resource.FQN,
764 });
765 if (
766 !row ||
767 isActionState(row) ||
768 row.resourceType !== "AWS.EC2.SecurityGroupRule"
769 )
770 continue;
771 const attrs = row.attr ?? ("old" in row ? row.old.attr : undefined);
772 if (
773 attrs?.groupId === groupId &&
774 typeof attrs.securityGroupRuleId === "string"
775 ) {
776 const { group, groupId: declaredId } = resource.Props;
777 if ((group === undefined) === (declaredId === undefined)) continue;
778 const target = group ?? declaredId;
779 // Evaluate only the declared target, never infer ownership from cloud tags.
780 const declared = yield* Output.evaluate(target, upstream).pipe(
781 Effect.catchTag(["MissingSourceError", "InvalidReferenceError"], () =>
782 Effect.succeed(undefined),
783 ),
784 );
785 const declaredGroupId =
786 typeof declared === "string" ? declared : declared?.groupId;
787 if (declaredGroupId === groupId) ids.add(attrs.securityGroupRuleId);
788 }
789 }
790 return ids;
791});
792
793export const resolveSecurityGroupRules = (
794 rules: SecurityGroupRuleData[] | undefined,
795 isEgress: boolean,
796): SecurityGroupRuleData[] =>
797 rules ?? (isEgress ? [{ ipProtocol: "-1", cidrIpv4: "0.0.0.0/0" }] : []);
798
799const protocols: Record<string, string> = {
800 "6": "tcp",
801 "17": "udp",
802 "1": "icmp",
803 "58": "icmpv6",
804};
805
806export const securityGroupRuleKey = (rule: SecurityGroupRuleData) => {
807 const protocol = protocols[rule.ipProtocol] ?? rule.ipProtocol;
808 const hasPorts = ["tcp", "udp", "icmp", "icmpv6"].includes(protocol);
809 return JSON.stringify({
810 protocol,
811 from: hasPorts
812 ? (rule.fromPort ?? (protocol === "icmpv6" ? -1 : undefined))
813 : undefined,
814 to: hasPorts
815 ? (rule.toPort ?? (protocol === "icmpv6" ? -1 : undefined))
816 : undefined,
817 ipv4: canonicalCidr(rule.cidrIpv4),
818 ipv6: canonicalCidr(rule.cidrIpv6),
819 group: rule.referencedGroupId,
820 prefix: rule.prefixListId,
821 description: rule.description ?? "",
822 });
823};
824
825export const observedSecurityGroupRuleKey = (rule: ec2.SecurityGroupRule) =>
826 securityGroupRuleKey({
827 ipProtocol: rule.IpProtocol!,
828 fromPort: rule.FromPort,
829 toPort: rule.ToPort,
830 cidrIpv4: rule.CidrIpv4,
831 cidrIpv6: rule.CidrIpv6,
832 referencedGroupId: rule.ReferencedGroupInfo?.GroupId as
833 | SecurityGroupId
834 | undefined,
835 prefixListId: rule.PrefixListId,
836 description: rule.Description,
837 });
838
839// EC2 creates one physical rule per source in an IpPermission.
840export const expandSecurityGroupRules = (rules: SecurityGroupRuleData[]) =>
841 rules.flatMap(
842 ({ cidrIpv4, cidrIpv6, referencedGroupId, prefixListId, ...rule }) => [
843 ...(cidrIpv4 === undefined ? [] : [{ ...rule, cidrIpv4 }]),
844 ...(cidrIpv6 === undefined ? [] : [{ ...rule, cidrIpv6 }]),
845 ...(referencedGroupId === undefined
846 ? []
847 : [{ ...rule, referencedGroupId }]),
848 ...(prefixListId === undefined ? [] : [{ ...rule, prefixListId }]),
849 ],
850 );