1import * as networksecurity from "@distilled.cloud/gcp/networksecurity_v1";
2import * as Effect from "effect/Effect";
3import * as Schedule from "effect/Schedule";
4import { Unowned } from "../../AdoptPolicy.ts";
5import { isResolved } from "../../Diff.ts";
6import * as Provider from "../../Provider.ts";
7import { Resource } from "../../Resource.ts";
8import { GcpEnvironment } from "../Environment.ts";
9import { createInternalLabels, hasAlchemyLabels } from "../Labels.ts";
10import type { Providers } from "../Providers.ts";
11import {
12 NetworksecurityNotResolved,
13 canonicalizeLink,
14 changedFields,
15 collectPages,
16 encodeOwnership,
17 hasOwnershipMarker,
18 linkKey,
19 normalizeLocation,
20 parentOf,
21 parseName,
22 parseOwnership,
23 rfc1035,
24 toPhysicalId,
25 waitForOperation,
26 waitUntilGone,
27 waitUntilPresent,
28} from "./internal.ts";
29
30const COLLECTION = "rules";
31
32export type GatewaySecurityPolicyRuleBasicProfile =
33 | networksecurity.GatewaySecurityPolicyRuleBasicProfileEnum
34 | (string & {});
35
36export type GatewaySecurityPoliciesRuleProps = {
37 /**
38 * Parent GatewaySecurityPolicy resource name
39 * (`projects/{project}/locations/{location}/gatewaySecurityPolicies/{gatewaySecurityPolicy}`)
40 * or the policy id. Immutable — changing it replaces the rule.
41 */
42 gatewaySecurityPolicy: string;
43 /**
44 * Rule id (the `{rule}` segment of
45 * `.../gatewaySecurityPolicies/{gatewaySecurityPolicy}/rules/{rule}`).
46 * If omitted, a unique RFC1035 name is generated. Must be 4-63
47 * characters matching `[a-z]([a-z0-9-]{0,61}[a-z0-9])?`. Immutable —
48 * changing it replaces the rule.
49 */
50 gatewaySecurityPolicyRuleId?: string;
51 /**
52 * Region of the parent policy. Inferred from
53 * `gatewaySecurityPolicy` when that value is a full resource name.
54 * Immutable — changing it replaces the rule.
55 * @default the stack's GCP region (`GCP.Region`, profile region, `us-central1`)
56 */
57 location?: string;
58 /**
59 * Primitive action (`ALLOW` or `DENY`).
60 */
61 basicProfile: GatewaySecurityPolicyRuleBasicProfile;
62 /**
63 * Rule priority. Lower numbers take precedence.
64 */
65 priority: number;
66 /**
67 * Whether the rule is enforced.
68 * @default true
69 */
70 enabled?: boolean;
71 /**
72 * CEL expression matching session criteria (L4).
73 */
74 sessionMatcher: string;
75 /**
76 * Optional CEL expression matching L7 / application criteria.
77 */
78 applicationMatcher?: string;
79 /**
80 * Enable TLS inspection for matching traffic. The parent policy must
81 * reference a TlsInspectionPolicy.
82 * @default false
83 */
84 tlsInspectionEnabled?: boolean;
85 /**
86 * Human-readable description. Rules have no labels field, so Alchemy
87 * stamps ownership into a `[alchemy …]` prefix and strips it from
88 * attributes.
89 */
90 description?: string;
91};
92
93export type GatewaySecurityPoliciesRule = Resource<
94 "GCP.NetworkSecurity.GatewaySecurityPoliciesRule",
95 GatewaySecurityPoliciesRuleProps,
96 {
97 /** Full resource name `projects/{project}/locations/{location}/gatewaySecurityPolicies/{gatewaySecurityPolicy}/rules/{rule}`. */
98 name: string;
99 /** Rule id (last path segment). */
100 gatewaySecurityPolicyRuleId: string;
101 /** Parent GatewaySecurityPolicy resource name. */
102 gatewaySecurityPolicy: string;
103 /** Project id. */
104 project: string;
105 /** Location id (`us-central1`). */
106 location: string;
107 /** Primitive action (`ALLOW` or `DENY`). */
108 basicProfile: string | undefined;
109 /** Rule priority. */
110 priority: number | undefined;
111 /** Whether the rule is enforced. */
112 enabled: boolean;
113 /** Session-level CEL matcher. */
114 sessionMatcher: string | undefined;
115 /** Application-level CEL matcher, if set. */
116 applicationMatcher: string | undefined;
117 /** Whether TLS inspection is enabled. */
118 tlsInspectionEnabled: boolean;
119 /** User description with the Alchemy ownership prefix stripped. */
120 description: string | undefined;
121 /** RFC3339 creation timestamp. */
122 createTime: string | undefined;
123 /** RFC3339 last-update timestamp. */
124 updateTime: string | undefined;
125 },
126 never,
127 Providers
128>;
129
130/**
131 * A GatewaySecurityPolicyRule nested under a GatewaySecurityPolicy.
132 * Each rule matches traffic with CEL and applies ALLOW or DENY.
133 *
134 * The API has no labels field, so Alchemy stamps ownership into the
135 * description for `list` / nuke. Changing the parent policy, rule id,
136 * or location replaces the rule. Matchers, priority, profile, enabled
137 * flag, TLS inspection, and description update in place.
138 *
139 * ### Creating a GatewaySecurityPoliciesRule
140 * **Example:** Allow all sessions
141 * ```typescript
142 * const rule = yield* GCP.NetworkSecurity.GatewaySecurityPoliciesRule("Allow", {
143 * gatewaySecurityPolicy: policy.name,
144 * basicProfile: "ALLOW",
145 * priority: 1000,
146 * sessionMatcher: "true",
147 * });
148 * ```
149 *
150 * **Example:** Deny by host
151 * ```typescript
152 * const rule = yield* GCP.NetworkSecurity.GatewaySecurityPoliciesRule("Block", {
153 * gatewaySecurityPolicy: policy.name,
154 * basicProfile: "DENY",
155 * priority: 100,
156 * enabled: true,
157 * sessionMatcher: "host() == 'blocked.example.com'",
158 * description: "block listed host",
159 * });
160 * ```
161 *
162 * @resource
163 * @category NetworkSecurity
164 */
165export const GatewaySecurityPoliciesRule =
166 Resource<GatewaySecurityPoliciesRule>(
167 "GCP.NetworkSecurity.GatewaySecurityPoliciesRule",
168 );
169
170const parentPolicyName = (
171 project: string,
172 location: string,
173 gatewaySecurityPolicy: string,
174) => {
175 const canonical = canonicalizeLink(gatewaySecurityPolicy);
176 if (canonical.includes("/gatewaySecurityPolicies/")) return canonical;
177 return `projects/${project}/locations/${location}/gatewaySecurityPolicies/${canonical}`;
178};
179
180const resourceNameOf = (parent: string, ruleId: string) =>
181 `${parent}/rules/${ruleId}`;
182
183const parentFromRuleName = (name: string) => {
184 const index = name.lastIndexOf("/rules/");
185 return index >= 0 ? name.slice(0, index) : name;
186};
187
188const locationFromParent = (
189 gatewaySecurityPolicy: string,
190 fallback: string,
191) => {
192 const canonical = canonicalizeLink(gatewaySecurityPolicy);
193 if (!canonical.includes("/locations/")) return fallback;
194 return parseName(canonical, "gatewaySecurityPolicies", fallback).location;
195};
196
197const toAttrs = (
198 rule: networksecurity.GatewaySecurityPolicyRule,
199 project: string,
200 region: string,
201) => {
202 const name = rule.name ?? "";
203 const parsed = parseName(name, COLLECTION, region);
204 const ownership = parseOwnership(rule.description);
205 const parent = parentFromRuleName(name);
206 return {
207 name,
208 gatewaySecurityPolicyRuleId: parsed.id,
209 gatewaySecurityPolicy: parent,
210 project: parsed.project || project,
211 location: parsed.location || region,
212 basicProfile: rule.basicProfile,
213 priority: rule.priority,
214 enabled: rule.enabled !== false,
215 sessionMatcher: rule.sessionMatcher,
216 applicationMatcher: rule.applicationMatcher,
217 tlsInspectionEnabled: rule.tlsInspectionEnabled === true,
218 description: ownership.text,
219 createTime: rule.createTime,
220 updateTime: rule.updateTime,
221 };
222};
223
224const getByName = (name: string) =>
225 networksecurity
226 .getProjectsLocationsGatewaySecurityPoliciesRules({ name })
227 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
228
229export const GatewaySecurityPoliciesRuleProvider = () =>
230 Provider.succeed(GatewaySecurityPoliciesRule, {
231 stables: [
232 "name",
233 "gatewaySecurityPolicyRuleId",
234 "gatewaySecurityPolicy",
235 "project",
236 "location",
237 "createTime",
238 ],
239
240 diff: Effect.fn(function* ({ news, olds, output }) {
241 if (!isResolved(news)) return undefined;
242 const env = yield* GcpEnvironment.current;
243 const previousId =
244 olds?.gatewaySecurityPolicyRuleId ??
245 output?.gatewaySecurityPolicyRuleId;
246 const nextId = news.gatewaySecurityPolicyRuleId
247 ? rfc1035(news.gatewaySecurityPolicyRuleId, "rule")
248 : previousId;
249 const previousLocation = normalizeLocation(
250 olds?.location ?? output?.location,
251 env.region,
252 );
253 const nextLocation = normalizeLocation(
254 news.location ??
255 locationFromParent(news.gatewaySecurityPolicy, previousLocation),
256 env.region,
257 );
258 const previousParent = linkKey(
259 olds?.gatewaySecurityPolicy ?? output?.gatewaySecurityPolicy,
260 );
261 const nextParent = linkKey(news.gatewaySecurityPolicy);
262 if (
263 (previousId !== undefined &&
264 nextId !== undefined &&
265 nextId !== previousId) ||
266 previousLocation !== nextLocation ||
267 (previousParent.length > 0 && previousParent !== nextParent)
268 ) {
269 return { action: "replace" as const };
270 }
271 return undefined;
272 }),
273
274 read: Effect.fn(function* ({ id, olds, output }) {
275 const env = yield* GcpEnvironment.current;
276 const location = normalizeLocation(
277 olds?.location ??
278 output?.location ??
279 (olds?.gatewaySecurityPolicy
280 ? locationFromParent(olds.gatewaySecurityPolicy, env.region)
281 : undefined),
282 env.region,
283 );
284 const parent = parentPolicyName(
285 env.project,
286 location,
287 olds?.gatewaySecurityPolicy ?? output?.gatewaySecurityPolicy ?? "",
288 );
289 const ruleId = yield* toPhysicalId(
290 id,
291 olds?.gatewaySecurityPolicyRuleId,
292 output?.gatewaySecurityPolicyRuleId,
293 "rule",
294 );
295 const name = output?.name ?? resourceNameOf(parent, ruleId);
296 const existing = yield* getByName(name);
297 if (existing === undefined) return undefined;
298 const attrs = toAttrs(existing, env.project, env.region);
299 const { labels } = parseOwnership(existing.description);
300 return (yield* hasAlchemyLabels(id, labels)) ? attrs : Unowned(attrs);
301 }),
302
303 list: () =>
304 Effect.gen(function* () {
305 const env = yield* GcpEnvironment.current;
306 const policies = yield* collectPages(
307 networksecurity.listProjectsLocationsGatewaySecurityPolicies.pages({
308 parent: parentOf(env.project, "-"),
309 pageSize: 1000,
310 }),
311 (page) => page.gatewaySecurityPolicies,
312 );
313 const nested = yield* Effect.forEach(
314 policies.filter((policy) => (policy.name ?? "").length > 0),
315 (policy) =>
316 collectPages(
317 networksecurity.listProjectsLocationsGatewaySecurityPoliciesRules.pages(
318 {
319 parent: policy.name ?? "",
320 pageSize: 1000,
321 },
322 ),
323 (page) => page.gatewaySecurityPolicyRules,
324 ),
325 { concurrency: 4 },
326 );
327 return nested
328 .flat()
329 .filter((item) => hasOwnershipMarker(item.description))
330 .map((item) => toAttrs(item, env.project, env.region));
331 }),
332
333 reconcile: Effect.fn(function* ({ id, news, output }) {
334 const env = yield* GcpEnvironment.current;
335 const location = normalizeLocation(
336 news.location ??
337 output?.location ??
338 locationFromParent(news.gatewaySecurityPolicy, env.region),
339 env.region,
340 );
341 const parent = parentPolicyName(
342 env.project,
343 location,
344 news.gatewaySecurityPolicy,
345 );
346 const ruleId = yield* toPhysicalId(
347 id,
348 news.gatewaySecurityPolicyRuleId,
349 output?.gatewaySecurityPolicyRuleId,
350 "rule",
351 );
352 const name = resourceNameOf(parent, ruleId);
353 const ownership = yield* createInternalLabels(id);
354 const desiredDescription = encodeOwnership(ownership, news.description);
355 const enabled = news.enabled !== false;
356 const tlsInspectionEnabled = news.tlsInspectionEnabled === true;
357
358 let current = yield* getByName(name);
359
360 if (current === undefined) {
361 const created = yield* networksecurity
362 .createProjectsLocationsGatewaySecurityPoliciesRules({
363 parent,
364 gatewaySecurityPolicyRuleId: ruleId,
365 body: {
366 basicProfile: news.basicProfile,
367 priority: news.priority,
368 enabled,
369 sessionMatcher: news.sessionMatcher,
370 applicationMatcher: news.applicationMatcher,
371 tlsInspectionEnabled,
372 description: desiredDescription,
373 },
374 })
375 .pipe(
376 Effect.retry({
377 while: (error) => error._tag === "Conflict",
378 times: 5,
379 schedule: Schedule.spaced("2 seconds"),
380 }),
381 Effect.catchTag("Conflict", () => Effect.succeed(undefined)),
382 );
383 if (created !== undefined) {
384 yield* waitForOperation(created);
385 }
386 current = yield* waitUntilPresent(getByName(name), name);
387 }
388
389 if (current === undefined) {
390 return yield* new NetworksecurityNotResolved({ name });
391 }
392
393 const descriptionChanged =
394 (current.description ?? "") !== desiredDescription;
395 const profileChanged = (current.basicProfile ?? "") !== news.basicProfile;
396 const priorityChanged = (current.priority ?? 0) !== news.priority;
397 const enabledChanged = (current.enabled !== false) !== enabled;
398 const sessionChanged =
399 (current.sessionMatcher ?? "") !== news.sessionMatcher;
400 const applicationChanged =
401 (current.applicationMatcher ?? "") !== (news.applicationMatcher ?? "");
402 const tlsChanged =
403 (current.tlsInspectionEnabled === true) !== tlsInspectionEnabled;
404 const updateMask = changedFields([
405 ["description", descriptionChanged],
406 ["basicProfile", profileChanged],
407 ["priority", priorityChanged],
408 ["enabled", enabledChanged],
409 ["sessionMatcher", sessionChanged],
410 ["applicationMatcher", applicationChanged],
411 ["tlsInspectionEnabled", tlsChanged],
412 ]);
413
414 if (updateMask.length > 0) {
415 const operation =
416 yield* networksecurity.patchProjectsLocationsGatewaySecurityPoliciesRules(
417 {
418 name: current.name ?? name,
419 updateMask: updateMask.join(","),
420 body: {
421 name: current.name ?? name,
422 description: desiredDescription,
423 basicProfile: news.basicProfile,
424 priority: news.priority,
425 enabled,
426 sessionMatcher: news.sessionMatcher,
427 applicationMatcher: news.applicationMatcher,
428 tlsInspectionEnabled,
429 },
430 },
431 );
432 yield* waitForOperation(operation);
433 current = yield* waitUntilPresent(
434 getByName(current.name ?? name),
435 current.name ?? name,
436 );
437 }
438
439 return toAttrs(current, env.project, env.region);
440 }),
441
442 delete: Effect.fn(function* ({ output }) {
443 const operation = yield* networksecurity
444 .deleteProjectsLocationsGatewaySecurityPoliciesRules({
445 name: output.name,
446 })
447 .pipe(
448 Effect.retry({
449 while: (error) => error._tag === "Conflict",
450 times: 8,
451 schedule: Schedule.spaced("2 seconds"),
452 }),
453 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
454 );
455 if (operation !== undefined) {
456 yield* waitForOperation(operation, { notFoundOk: true });
457 }
458 yield* waitUntilGone(getByName(output.name), output.name);
459 }),
460 });