1import * as ec2 from "@distilled.cloud/aws/ec2";
2import * as Data from "effect/Data";
3import * as Effect from "effect/Effect";
4import * as Stream from "effect/Stream";
5import { isResolved } from "../../Diff.ts";
6import * as Provider from "../../Provider.ts";
7import { Resource } from "../../Resource.ts";
8import { canonicalCidr } from "../../Utils/ip-address.ts";
9import type { Providers } from "../Providers.ts";
10import type { ClientVpnEndpointId } from "./ClientVpnEndpoint.ts";
11import { retryClientVpn } from "./ClientVpnWait.ts";
12
13/** Immutable settings for a Client VPN ingress authorization rule. */
14export interface ClientVpnAuthorizationRuleProps {
15 /** The Client VPN endpoint. Changing this replaces the rule. */
16 clientVpnEndpointId: ClientVpnEndpointId;
17 /** The destination IPv4 CIDR to authorize. Changing this replaces the rule. */
18 targetNetworkCidr: string;
19 /**
20 * The directory or federated identity group allowed to access the network.
21 * Required unless authorizeAllGroups is true. Changing this replaces the rule.
22 */
23 accessGroupId?: string;
24 /**
25 * Authorize every authenticated client instead of one access group.
26 * Cannot be combined with accessGroupId. Changing this replaces the rule.
27 * @default false
28 */
29 authorizeAllGroups?: boolean;
30 /** A description of the rule. Changing this replaces the rule. */
31 description?: string;
32}
33
34/** An ingress authorization rule owned by a Client VPN endpoint. */
35export interface ClientVpnAuthorizationRule extends Resource<
36 "AWS.EC2.ClientVpnAuthorizationRule",
37 ClientVpnAuthorizationRuleProps,
38 {
39 /** The Client VPN endpoint containing the rule. */
40 clientVpnEndpointId: ClientVpnEndpointId;
41 /** The canonical destination network CIDR. */
42 targetNetworkCidr: string;
43 /** The authorized group, absent for an all-groups rule. */
44 accessGroupId: string | undefined;
45 /** Whether all authenticated clients are authorized. */
46 authorizeAllGroups: boolean;
47 /** The observed description. */
48 description: string | undefined;
49 /** The current authorization state. */
50 status: ec2.ClientVpnAuthorizationRuleStatusCode;
51 /** Additional information about the authorization state. */
52 statusMessage: string | undefined;
53 },
54 never,
55 Providers
56> {}
57
58/**
59 * Authorizes Client VPN clients to access a destination network. Authorization
60 * does not create a route: configure a target association and any required routes
61 * separately. Specify either accessGroupId or authorizeAllGroups: true.
62 *
63 * Every property is immutable, including description. Description-only changes
64 * revoke the old rule before creating its replacement. This resource manages the
65 * declared endpoint/CIDR/group identity, including an existing rule discovered
66 * without cached state. Rules have no independent ownership markers. Revoking an
67 * all-groups rule leaves group-specific rules untouched.
68 *
69 * Readiness waits default to 30 minutes. Set `AWS_CLIENT_VPN_TIMEOUT` to a
70 * positive finite duration, such as `45 minutes`, to override this deadline.
71 *
72 * ### Authorizing All Clients
73 * **Example:** Permit authenticated clients to reach the VPC
74 * ```typescript
75 * const rule = yield* AWS.EC2.ClientVpnAuthorizationRule("VpnVpcAccess", {
76 * clientVpnEndpointId: endpoint.clientVpnEndpointId,
77 * targetNetworkCidr: "10.0.0.0/16",
78 * authorizeAllGroups: true,
79 * description: "Access to the VPC",
80 * });
81 * ```
82 *
83 * ### Authorizing One Group
84 * **Example:** Restrict a destination to a directory group
85 * ```typescript
86 * const rule = yield* AWS.EC2.ClientVpnAuthorizationRule("VpnAdminAccess", {
87 * clientVpnEndpointId: endpoint.clientVpnEndpointId,
88 * targetNetworkCidr: "10.0.1.0/24",
89 * accessGroupId: "S-1-5-21-123456789-123456789-123456789-1234",
90 * });
91 * ```
92 *
93 * @resource
94 */
95export const ClientVpnAuthorizationRule = Resource<ClientVpnAuthorizationRule>(
96 "AWS.EC2.ClientVpnAuthorizationRule",
97);
98
99class ClientVpnAuthorizationPending extends Data.TaggedError(
100 "ClientVpnAuthorizationPending",
101)<{ message: string }> {}
102
103class ClientVpnAuthorizationFailed extends Data.TaggedError(
104 "ClientVpnAuthorizationFailed",
105)<{ message: string }> {}
106
107const rules = (clientVpnEndpointId: ClientVpnEndpointId) =>
108 ec2.describeClientVpnAuthorizationRules
109 .items({ ClientVpnEndpointId: clientVpnEndpointId })
110 .pipe(
111 Stream.runCollect,
112 Effect.map((items) =>
113 items.filter((rule) => rule.Status?.Code !== "revoked"),
114 ),
115 Effect.catchTag("InvalidClientVpnEndpointId.NotFound", () =>
116 Effect.succeed([] as ec2.AuthorizationRule[]),
117 ),
118 );
119
120const findRule = (props: ClientVpnAuthorizationRuleProps) =>
121 rules(props.clientVpnEndpointId).pipe(
122 Effect.map((items) =>
123 items.find(
124 (rule) =>
125 canonicalCidr(rule.DestinationCidr) ===
126 canonicalCidr(props.targetNetworkCidr) &&
127 (rule.AccessAll ?? false) === (props.authorizeAllGroups ?? false) &&
128 (props.authorizeAllGroups === true ||
129 rule.GroupId === props.accessGroupId),
130 ),
131 ),
132 );
133
134const toAttributes = (
135 clientVpnEndpointId: ClientVpnEndpointId,
136 rule: ec2.AuthorizationRule,
137): ClientVpnAuthorizationRule["Attributes"] => ({
138 clientVpnEndpointId,
139 targetNetworkCidr: rule.DestinationCidr!,
140 accessGroupId: rule.AccessAll ? undefined : rule.GroupId,
141 authorizeAllGroups: rule.AccessAll ?? false,
142 description: rule.Description,
143 status: rule.Status?.Code ?? "authorizing",
144 statusMessage: rule.Status?.Message,
145});
146
147const waitForRule = (
148 props: ClientVpnAuthorizationRuleProps,
149 deleted: boolean,
150) =>
151 Effect.gen(function* () {
152 const rule = yield* findRule(props);
153 if (deleted && !rule) return undefined;
154 if (!deleted && rule?.Status?.Code === "active") return rule;
155 if (!deleted && rule?.Status?.Code === "failed") {
156 return yield* new ClientVpnAuthorizationFailed({
157 message: rule.Status.Message ?? "Client VPN authorization failed",
158 });
159 }
160 return yield* new ClientVpnAuthorizationPending({
161 message: `Client VPN authorization for ${props.targetNetworkCidr} is ${rule?.Status?.Code ?? "not visible"}; waiting for ${deleted ? "revocation" : "active"}`,
162 });
163 }).pipe((effect) =>
164 retryClientVpn(
165 effect,
166 (error) => error._tag === "ClientVpnAuthorizationPending",
167 ),
168 );
169
170const removeRule = Effect.fn(function* (
171 props: ClientVpnAuthorizationRuleProps,
172) {
173 const rule = yield* findRule(props);
174 if (!rule) return;
175 if (rule.Status?.Code !== "revoking") {
176 yield* ec2
177 .revokeClientVpnIngress({
178 ClientVpnEndpointId: props.clientVpnEndpointId,
179 TargetNetworkCidr: canonicalCidr(props.targetNetworkCidr),
180 AccessGroupId: props.authorizeAllGroups
181 ? undefined
182 : props.accessGroupId,
183 RevokeAllGroups: props.authorizeAllGroups ?? false,
184 })
185 .pipe(
186 Effect.catchTag(
187 [
188 "InvalidClientVpnEndpointId.NotFound",
189 "InvalidClientVpnEndpointAuthorizationRuleNotFound",
190 ],
191 () => Effect.void,
192 ),
193 (effect) =>
194 retryClientVpn(effect, (error) => error._tag === "IncorrectState"),
195 );
196 }
197 yield* waitForRule(props, true);
198});
199
200/** Live AWS provider for ClientVpnAuthorizationRule. */
201export const ClientVpnAuthorizationRuleProvider = () =>
202 Provider.effect(
203 ClientVpnAuthorizationRule,
204 Effect.gen(function* () {
205 return {
206 stables: [
207 "clientVpnEndpointId",
208 "targetNetworkCidr",
209 "accessGroupId",
210 "authorizeAllGroups",
211 ],
212 nuke: { dependsOn: ["AWS.EC2.ClientVpnEndpoint"] },
213 list: Effect.fn(function* () {
214 const endpoints = yield* ec2.describeClientVpnEndpoints
215 .items({})
216 .pipe(Stream.runCollect);
217 const items = yield* Effect.forEach(endpoints, (endpoint) =>
218 rules(endpoint.ClientVpnEndpointId as ClientVpnEndpointId).pipe(
219 Effect.map((items) =>
220 items.map((rule) =>
221 toAttributes(
222 endpoint.ClientVpnEndpointId as ClientVpnEndpointId,
223 rule,
224 ),
225 ),
226 ),
227 ),
228 );
229 return items.flat();
230 }),
231 read: Effect.fn(function* ({ olds, output }) {
232 const props = output ?? olds;
233 const rule = yield* findRule(props);
234 if (!rule) return undefined;
235 return toAttributes(props.clientVpnEndpointId, rule);
236 }),
237 diff: Effect.fn(function* ({ olds, news }) {
238 if (!isResolved(news)) {
239 return { action: "replace", deleteFirst: true };
240 }
241 const sameKey =
242 news.clientVpnEndpointId === olds.clientVpnEndpointId &&
243 canonicalCidr(news.targetNetworkCidr) ===
244 canonicalCidr(olds.targetNetworkCidr) &&
245 news.accessGroupId === olds.accessGroupId &&
246 (news.authorizeAllGroups ?? false) ===
247 (olds.authorizeAllGroups ?? false);
248 if (
249 !sameKey ||
250 news.targetNetworkCidr !== olds.targetNetworkCidr ||
251 news.authorizeAllGroups !== olds.authorizeAllGroups ||
252 news.description !== olds.description
253 ) {
254 return { action: "replace", deleteFirst: sameKey };
255 }
256 const rule = yield* findRule(news);
257 if (!rule) return { action: "update" };
258 if (
259 (rule.Description ?? "") !== (news.description ?? "") ||
260 rule.Status?.Code === "failed"
261 ) {
262 return { action: "replace", deleteFirst: true };
263 }
264 if (rule.Status?.Code !== "active") return { action: "update" };
265 }),
266 reconcile: Effect.fn(function* ({ news }) {
267 if (
268 (news.authorizeAllGroups === true) ===
269 (news.accessGroupId !== undefined) ||
270 news.accessGroupId === ""
271 ) {
272 return yield* new ClientVpnAuthorizationFailed({
273 message:
274 "Specify exactly one of accessGroupId or authorizeAllGroups: true.",
275 });
276 }
277 let rule = yield* findRule(news);
278 if (
279 rule &&
280 (rule.Status?.Code === "revoking" ||
281 (rule.Description ?? "") !== (news.description ?? ""))
282 ) {
283 yield* removeRule(news);
284 rule = undefined;
285 }
286 if (!rule) {
287 // Do not replay a completed create token after out-of-band deletion.
288 const clientToken = yield* Effect.sync(() => crypto.randomUUID());
289 yield* ec2
290 .authorizeClientVpnIngress({
291 ClientVpnEndpointId: news.clientVpnEndpointId,
292 TargetNetworkCidr: canonicalCidr(news.targetNetworkCidr),
293 AccessGroupId: news.accessGroupId,
294 AuthorizeAllGroups: news.authorizeAllGroups ?? false,
295 Description: news.description,
296 ClientToken: clientToken,
297 })
298 .pipe(
299 Effect.catchTag(
300 "InvalidClientVpnDuplicateAuthorizationRule",
301 () => Effect.void,
302 ),
303 (effect) =>
304 retryClientVpn(
305 effect,
306 (error) => error._tag === "IncorrectState",
307 ),
308 );
309 }
310 const active = yield* waitForRule(news, false);
311 return toAttributes(news.clientVpnEndpointId, active!);
312 }),
313 delete: Effect.fn(function* ({ output }) {
314 yield* removeRule(output);
315 }),
316 };
317 }),
318 );