1import * as networksecurity from "@distilled.cloud/gcp/networksecurity_v1";
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 { Unowned } from "../../AdoptPolicy.ts";
7import { isResolved } from "../../Diff.ts";
8import * as Provider from "../../Provider.ts";
9import { Resource } from "../../Resource.ts";
10import { tagRecord } from "../../Tags.ts";
11import { GcpEnvironment } from "../Environment.ts";
12import {
13 createInternalLabels,
14 diffLabels,
15 hasAlchemyLabels,
16 toLabels,
17} from "../Labels.ts";
18import type { Providers } from "../Providers.ts";
19import {
20 DEFAULT_LOCATION,
21 normalizeLocation,
22 parentOf,
23 parseResourceName,
24 resourceName,
25 toId,
26 userLabels,
27} from "./names.ts";
28import { waitForOperation } from "./operations.ts";
29import { sameJson } from "./ownership.ts";
30
31const COLLECTION = "securityProfiles";
32const DEFAULT_TYPE = "THREAT_PREVENTION";
33
34export type SecurityProfileType =
35 | "THREAT_PREVENTION"
36 | "CUSTOM_MIRRORING"
37 | "CUSTOM_INTERCEPT"
38 | "URL_FILTERING"
39 | (string & {});
40
41export type AntivirusOverride = {
42 /** Protocol to match (`HTTP`, `SMTP`, `FTP`, …). */
43 protocol?: networksecurity.AntivirusOverrideProtocolEnum | (string & {});
44 /** Threat action override (`ALLOW`, `ALERT`, `DENY`, …). */
45 action?: networksecurity.AntivirusOverrideActionEnum | (string & {});
46};
47
48export type SeverityOverride = {
49 /** Severity level to match (`INFORMATIONAL`, `LOW`, `MEDIUM`, `HIGH`, `CRITICAL`). */
50 severity?: networksecurity.SeverityOverrideSeverityEnum | (string & {});
51 /** Threat action override (`ALLOW`, `ALERT`, `DENY`, …). */
52 action?: networksecurity.SeverityOverrideActionEnum | (string & {});
53};
54
55export type ThreatOverride = {
56 /** Vendor-specific threat id to override. */
57 threatId?: string;
58 /** Threat action override (`ALLOW`, `ALERT`, `DENY`, …). */
59 action?: networksecurity.ThreatOverrideActionEnum | (string & {});
60};
61
62export type ThreatPreventionProfile = {
63 /** Antivirus action overrides per protocol. */
64 antivirusOverrides?: AntivirusOverride[];
65 /** Action overrides by severity. */
66 severityOverrides?: SeverityOverride[];
67 /** Action overrides by threat id. Wins over a matching severity override. */
68 threatOverrides?: ThreatOverride[];
69};
70
71export type CustomMirroringProfile = {
72 /**
73 * Target MirroringEndpointGroup. Immutable — changing it replaces the
74 * profile.
75 */
76 mirroringEndpointGroup?: string;
77};
78
79export type CustomInterceptProfile = {
80 /**
81 * Target InterceptEndpointGroup. Immutable — changing it replaces the
82 * profile.
83 */
84 interceptEndpointGroup?: string;
85};
86
87export type UrlFilter = {
88 /** Action taken when this filter matches (`ALLOW` or `DENY`). */
89 filteringAction?:
90 | networksecurity.UrlFilterFilteringActionEnum
91 | (string & {});
92 /** URL strings that must match for this filter to apply. */
93 urls?: string[];
94 /**
95 * Priority within the profile. Lower integers are higher priority.
96 * Must be unique within a URL Filtering Profile.
97 */
98 priority?: number;
99};
100
101export type UrlFilteringProfile = {
102 /** Filtering configs, each defining an action for some URL match. */
103 urlFilters?: UrlFilter[];
104};
105
106export type SecurityProfileProps = {
107 /**
108 * SecurityProfile id (the `{securityProfile}` segment of
109 * `projects/{project}/locations/{location}/securityProfiles/{securityProfile}`).
110 * If omitted, a unique name is generated from the stack, stage, and
111 * logical id. Must be 1-63 characters, letters, numbers, hyphens, and
112 * underscores, and must not start with a number. Immutable — changing
113 * it replaces the profile.
114 */
115 securityProfileId?: string;
116 /**
117 * Location. Security profiles live in `global`. Immutable — changing
118 * it replaces the profile.
119 * @default "global"
120 */
121 location?: string;
122 /**
123 * Human-readable description (max 512 characters).
124 */
125 description?: string;
126 /**
127 * User labels. Alchemy ownership labels are merged in automatically.
128 */
129 labels?: Record<string, string>;
130 /**
131 * Single ProfileType this resource configures. Inferred from nested
132 * profile blocks when omitted. Immutable — changing it replaces the
133 * profile.
134 * @default "THREAT_PREVENTION"
135 */
136 type?: SecurityProfileType;
137 /**
138 * Threat-prevention configuration. Used when `type` is
139 * `THREAT_PREVENTION`.
140 */
141 threatPreventionProfile?: ThreatPreventionProfile;
142 /**
143 * Out-of-band packet mirroring configuration. Used when `type` is
144 * `CUSTOM_MIRRORING`. Immutable nested target — changing
145 * `mirroringEndpointGroup` replaces the profile.
146 */
147 customMirroringProfile?: CustomMirroringProfile;
148 /**
149 * In-band intercept configuration. Used when `type` is
150 * `CUSTOM_INTERCEPT`. Immutable nested target — changing
151 * `interceptEndpointGroup` replaces the profile.
152 */
153 customInterceptProfile?: CustomInterceptProfile;
154 /**
155 * URL filtering configuration. Used when `type` is `URL_FILTERING`.
156 */
157 urlFilteringProfile?: UrlFilteringProfile;
158};
159
160export type SecurityProfile = Resource<
161 "GCP.NetworkSecurity.SecurityProfile",
162 SecurityProfileProps,
163 {
164 /** Full resource name `projects/{project}/locations/{location}/securityProfiles/{securityProfile}`. */
165 name: string;
166 /** SecurityProfile id (last path segment). */
167 securityProfileId: string;
168 /** Project id. */
169 project: string;
170 /** Location id. Typically `"global"`. */
171 location: string;
172 /** Configured profile type. */
173 type: string | undefined;
174 /** User-provided description. */
175 description: string | undefined;
176 /** User labels (Alchemy ownership labels stripped). */
177 labels: Record<string, string>;
178 /** Threat-prevention configuration, if this is a THREAT_PREVENTION profile. */
179 threatPreventionProfile: ThreatPreventionProfile | undefined;
180 /** Custom mirroring configuration, if this is a CUSTOM_MIRRORING profile. */
181 customMirroringProfile: CustomMirroringProfile | undefined;
182 /** Custom intercept configuration, if this is a CUSTOM_INTERCEPT profile. */
183 customInterceptProfile: CustomInterceptProfile | undefined;
184 /** URL filtering configuration, if this is a URL_FILTERING profile. */
185 urlFilteringProfile: UrlFilteringProfile | undefined;
186 /** Server-computed checksum used on update and delete. */
187 etag: string | undefined;
188 /** RFC3339 creation timestamp. */
189 createTime: string | undefined;
190 /** RFC3339 last-update timestamp. */
191 updateTime: string | undefined;
192 },
193 never,
194 Providers
195>;
196
197/**
198 * A Network Security SecurityProfile — the behavior for one ProfileType
199 * (threat prevention, URL filtering, custom mirroring, or custom
200 * intercept).
201 *
202 * Changing `securityProfileId`, `location`, `type`, or an immutable
203 * nested target (`mirroringEndpointGroup` / `interceptEndpointGroup`)
204 * replaces the profile. Description, labels, and the mutable nested
205 * profile configuration update in place.
206 *
207 * ### Creating a SecurityProfile
208 * **Example:** Threat prevention with a severity override
209 * ```typescript
210 * const profile = yield* GCP.NetworkSecurity.SecurityProfile("Threat", {
211 * type: "THREAT_PREVENTION",
212 * threatPreventionProfile: {
213 * severityOverrides: [{ severity: "HIGH", action: "ALERT" }],
214 * },
215 * });
216 * ```
217 *
218 * **Example:** Named profile with labels
219 * ```typescript
220 * const profile = yield* GCP.NetworkSecurity.SecurityProfile("Threat", {
221 * securityProfileId: "app-threat",
222 * description: "prod threat prevention",
223 * labels: { env: "prod" },
224 * type: "THREAT_PREVENTION",
225 * });
226 * ```
227 *
228 * ### Updating a SecurityProfile
229 * **Example:** Change severity overrides
230 * ```typescript
231 * const profile = yield* GCP.NetworkSecurity.SecurityProfile("Threat", {
232 * securityProfileId: "app-threat",
233 * type: "THREAT_PREVENTION",
234 * description: "prod threat prevention v2",
235 * labels: { env: "prod", role: "ngfw" },
236 * threatPreventionProfile: {
237 * severityOverrides: [{ severity: "CRITICAL", action: "DENY" }],
238 * },
239 * });
240 * ```
241 *
242 * @resource
243 * @category NetworkSecurity
244 */
245export const SecurityProfile = Resource<SecurityProfile>(
246 "GCP.NetworkSecurity.SecurityProfile",
247);
248
249export class SecurityProfileNotResolved extends Data.TaggedError(
250 "GCP.NetworkSecurity.SecurityProfileNotResolved",
251)<{
252 name: string;
253}> {}
254
255export class SecurityProfileStillExists extends Data.TaggedError(
256 "GCP.NetworkSecurity.SecurityProfileStillExists",
257)<{
258 name: string;
259}> {}
260
261const typeOf = (
262 props: {
263 type?: string;
264 threatPreventionProfile?: ThreatPreventionProfile;
265 customMirroringProfile?: CustomMirroringProfile;
266 customInterceptProfile?: CustomInterceptProfile;
267 urlFilteringProfile?: UrlFilteringProfile;
268 },
269 fallback: string = DEFAULT_TYPE,
270): string => {
271 if (props.type !== undefined && props.type !== "PROFILE_TYPE_UNSPECIFIED") {
272 return props.type;
273 }
274 if ((props.customMirroringProfile?.mirroringEndpointGroup ?? "") !== "") {
275 return "CUSTOM_MIRRORING";
276 }
277 if ((props.customInterceptProfile?.interceptEndpointGroup ?? "") !== "") {
278 return "CUSTOM_INTERCEPT";
279 }
280 if ((props.urlFilteringProfile?.urlFilters?.length ?? 0) > 0) {
281 return "URL_FILTERING";
282 }
283 if (props.threatPreventionProfile !== undefined) {
284 return "THREAT_PREVENTION";
285 }
286 return fallback;
287};
288
289const toThreatPrevention = (
290 value:
291 | networksecurity.ThreatPreventionProfile
292 | ThreatPreventionProfile
293 | undefined,
294): ThreatPreventionProfile | undefined => {
295 if (value === undefined) return undefined;
296 return {
297 antivirusOverrides: (value.antivirusOverrides ?? []).map((entry) => ({
298 protocol: entry.protocol,
299 action: entry.action,
300 })),
301 severityOverrides: (value.severityOverrides ?? []).map((entry) => ({
302 severity: entry.severity,
303 action: entry.action,
304 })),
305 threatOverrides: (value.threatOverrides ?? []).map((entry) => ({
306 threatId: entry.threatId,
307 action: entry.action,
308 })),
309 };
310};
311
312const toUrlFiltering = (
313 value: networksecurity.UrlFilteringProfile | UrlFilteringProfile | undefined,
314): UrlFilteringProfile | undefined => {
315 if (value === undefined) return undefined;
316 return {
317 urlFilters: (value.urlFilters ?? []).map((entry) => ({
318 filteringAction: entry.filteringAction,
319 urls: entry.urls,
320 priority: entry.priority,
321 })),
322 };
323};
324
325const toAttrs = (profile: networksecurity.SecurityProfile, project: string) => {
326 const name = profile.name ?? "";
327 const parsed = parseResourceName(name, COLLECTION);
328 return {
329 name,
330 securityProfileId: parsed.id,
331 project: parsed.project || project,
332 location: parsed.location || DEFAULT_LOCATION,
333 type: profile.type,
334 description: profile.description,
335 labels: userLabels(profile.labels),
336 threatPreventionProfile: toThreatPrevention(
337 profile.threatPreventionProfile,
338 ),
339 customMirroringProfile: profile.customMirroringProfile
340 ? {
341 mirroringEndpointGroup:
342 profile.customMirroringProfile.mirroringEndpointGroup,
343 }
344 : undefined,
345 customInterceptProfile: profile.customInterceptProfile
346 ? {
347 interceptEndpointGroup:
348 profile.customInterceptProfile.interceptEndpointGroup,
349 }
350 : undefined,
351 urlFilteringProfile: toUrlFiltering(profile.urlFilteringProfile),
352 etag: profile.etag,
353 createTime: profile.createTime,
354 updateTime: profile.updateTime,
355 };
356};
357
358const getByName = (name: string) =>
359 networksecurity
360 .getProjectsLocationsSecurityProfiles({ name })
361 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
362
363const waitUntilExists = (name: string) =>
364 getByName(name).pipe(
365 Effect.flatMap((profile) =>
366 profile
367 ? Effect.succeed(profile)
368 : Effect.fail(new SecurityProfileNotResolved({ name })),
369 ),
370 Effect.retry({
371 while: (error) =>
372 error._tag === "GCP.NetworkSecurity.SecurityProfileNotResolved",
373 times: 8,
374 schedule: Schedule.spaced("1 second"),
375 }),
376 );
377
378const waitUntilGone = (name: string) =>
379 getByName(name).pipe(
380 Effect.flatMap((profile) =>
381 profile === undefined
382 ? Effect.void
383 : Effect.fail(new SecurityProfileStillExists({ name })),
384 ),
385 Effect.retry({
386 while: (error) =>
387 error._tag === "GCP.NetworkSecurity.SecurityProfileStillExists",
388 times: 10,
389 schedule: Schedule.spaced("2 seconds"),
390 }),
391 );
392
393const listOwned = (project: string) =>
394 networksecurity.listProjectsLocationsSecurityProfiles
395 .pages({
396 parent: parentOf(project, "-"),
397 pageSize: 1000,
398 })
399 .pipe(
400 Stream.flatMap((page) =>
401 Stream.fromIterable(page.securityProfiles ?? []),
402 ),
403 Stream.filter((profile) =>
404 Object.keys(profile.labels ?? {}).some((key) =>
405 key.startsWith("alchemy-"),
406 ),
407 ),
408 Stream.map((profile) => toAttrs(profile, project)),
409 Stream.runCollect,
410 Effect.map((chunk) => Array.from(chunk)),
411 Effect.catchTag("NotFound", () => Effect.succeed([])),
412 );
413
414const toCreateBody = (
415 news: SecurityProfileProps,
416 type: string,
417 desiredLabels: Record<string, string>,
418): networksecurity.SecurityProfile => {
419 const body: networksecurity.SecurityProfile = {
420 description: news.description,
421 labels: desiredLabels,
422 type,
423 };
424 if (type === "THREAT_PREVENTION") {
425 body.threatPreventionProfile =
426 toThreatPrevention(news.threatPreventionProfile) ?? {};
427 } else if (type === "CUSTOM_MIRRORING") {
428 body.customMirroringProfile = {
429 mirroringEndpointGroup:
430 news.customMirroringProfile?.mirroringEndpointGroup,
431 };
432 } else if (type === "CUSTOM_INTERCEPT") {
433 body.customInterceptProfile = {
434 interceptEndpointGroup:
435 news.customInterceptProfile?.interceptEndpointGroup,
436 };
437 } else if (type === "URL_FILTERING") {
438 body.urlFilteringProfile = toUrlFiltering(news.urlFilteringProfile) ?? {};
439 }
440 return body;
441};
442
443const immutableChanged = (
444 news: SecurityProfileProps,
445 olds: Partial<SecurityProfileProps> | undefined,
446 output: SecurityProfile["Attributes"] | undefined,
447) => {
448 const previousType = typeOf(
449 {
450 type: olds?.type ?? output?.type,
451 threatPreventionProfile:
452 olds?.threatPreventionProfile ?? output?.threatPreventionProfile,
453 customMirroringProfile:
454 olds?.customMirroringProfile ?? output?.customMirroringProfile,
455 customInterceptProfile:
456 olds?.customInterceptProfile ?? output?.customInterceptProfile,
457 urlFilteringProfile:
458 olds?.urlFilteringProfile ?? output?.urlFilteringProfile,
459 },
460 output?.type ?? DEFAULT_TYPE,
461 );
462 const nextType = typeOf(news, previousType);
463 if (nextType !== previousType) return true;
464 if (nextType === "CUSTOM_MIRRORING") {
465 const previous =
466 olds?.customMirroringProfile?.mirroringEndpointGroup ??
467 output?.customMirroringProfile?.mirroringEndpointGroup ??
468 "";
469 const next =
470 news.customMirroringProfile?.mirroringEndpointGroup ?? previous;
471 if (next !== previous) return true;
472 }
473 if (nextType === "CUSTOM_INTERCEPT") {
474 const previous =
475 olds?.customInterceptProfile?.interceptEndpointGroup ??
476 output?.customInterceptProfile?.interceptEndpointGroup ??
477 "";
478 const next =
479 news.customInterceptProfile?.interceptEndpointGroup ?? previous;
480 if (next !== previous) return true;
481 }
482 return false;
483};
484
485export const SecurityProfileProvider = () =>
486 Provider.succeed(SecurityProfile, {
487 stables: [
488 "name",
489 "securityProfileId",
490 "project",
491 "location",
492 "type",
493 "createTime",
494 ],
495
496 diff: Effect.fn(function* ({ news, olds, output }) {
497 if (!isResolved(news)) return undefined;
498 const previousId = olds?.securityProfileId ?? output?.securityProfileId;
499 const nextId = news.securityProfileId ?? previousId;
500 const previousLocation = normalizeLocation(
501 olds?.location ?? output?.location,
502 );
503 const nextLocation = normalizeLocation(
504 news.location ?? olds?.location ?? output?.location,
505 );
506 if (
507 (previousId !== undefined &&
508 nextId !== undefined &&
509 nextId !== previousId) ||
510 previousLocation !== nextLocation ||
511 immutableChanged(news, olds, output)
512 ) {
513 return { action: "replace" as const };
514 }
515 return undefined;
516 }),
517
518 read: Effect.fn(function* ({ id, olds, output }) {
519 const env = yield* GcpEnvironment.current;
520 const securityProfileId = yield* toId(
521 id,
522 olds?.securityProfileId,
523 output?.securityProfileId,
524 "secprofile",
525 );
526 const location = normalizeLocation(olds?.location ?? output?.location);
527 const name =
528 output?.name ??
529 resourceName(env.project, location, COLLECTION, securityProfileId);
530 const existing = yield* getByName(name);
531 if (existing === undefined) return undefined;
532 const attrs = toAttrs(existing, env.project);
533 return (yield* hasAlchemyLabels(id, tagRecord(existing.labels)))
534 ? attrs
535 : Unowned(attrs);
536 }),
537
538 list: () =>
539 Effect.gen(function* () {
540 const env = yield* GcpEnvironment.current;
541 return yield* listOwned(env.project);
542 }),
543
544 reconcile: Effect.fn(function* ({ id, news, output }) {
545 const env = yield* GcpEnvironment.current;
546 const securityProfileId = yield* toId(
547 id,
548 news.securityProfileId,
549 output?.securityProfileId,
550 "secprofile",
551 );
552 const location = normalizeLocation(news.location ?? output?.location);
553 const type = typeOf(news, output?.type ?? DEFAULT_TYPE);
554 const name = resourceName(
555 env.project,
556 location,
557 COLLECTION,
558 securityProfileId,
559 );
560 const desiredLabels = {
561 ...toLabels(news.labels),
562 ...(yield* createInternalLabels(id)),
563 };
564
565 let current = yield* getByName(output?.name ?? name);
566
567 if (current === undefined) {
568 const created = yield* networksecurity
569 .createProjectsLocationsSecurityProfiles({
570 parent: parentOf(env.project, location),
571 securityProfileId,
572 body: toCreateBody(news, type, desiredLabels),
573 })
574 .pipe(
575 Effect.retry({
576 while: (error) => error._tag === "Conflict",
577 times: 5,
578 schedule: Schedule.spaced("2 seconds"),
579 }),
580 Effect.catchTag("Conflict", () => Effect.succeed(undefined)),
581 );
582 if (created !== undefined) {
583 yield* waitForOperation(created);
584 }
585 current = yield* waitUntilExists(name);
586 }
587
588 if (current === undefined) {
589 return yield* new SecurityProfileNotResolved({ name });
590 }
591
592 const observedLabels = tagRecord(current.labels);
593 const { upsert, removed } = diffLabels(observedLabels, desiredLabels);
594 const labelsChanged = upsert.length > 0 || removed.length > 0;
595 const descriptionChanged =
596 (current.description ?? "") !== (news.description ?? "");
597 const threatChanged =
598 type === "THREAT_PREVENTION" &&
599 !sameJson(
600 toThreatPrevention(current.threatPreventionProfile) ?? {},
601 toThreatPrevention(news.threatPreventionProfile) ?? {},
602 );
603 const urlChanged =
604 type === "URL_FILTERING" &&
605 !sameJson(
606 toUrlFiltering(current.urlFilteringProfile) ?? {},
607 toUrlFiltering(news.urlFilteringProfile) ?? {},
608 );
609
610 if (labelsChanged || descriptionChanged || threatChanged || urlChanged) {
611 const updateMask = [
612 labelsChanged ? "labels" : undefined,
613 descriptionChanged ? "description" : undefined,
614 threatChanged ? "threatPreventionProfile" : undefined,
615 urlChanged ? "urlFilteringProfile" : undefined,
616 ].filter((field): field is string => field !== undefined);
617 const operation =
618 yield* networksecurity.patchProjectsLocationsSecurityProfiles({
619 name: current.name ?? name,
620 updateMask: updateMask.join(","),
621 body: {
622 name: current.name ?? name,
623 etag: current.etag,
624 labels: desiredLabels,
625 description: news.description,
626 threatPreventionProfile:
627 type === "THREAT_PREVENTION"
628 ? (toThreatPrevention(news.threatPreventionProfile) ?? {})
629 : undefined,
630 urlFilteringProfile:
631 type === "URL_FILTERING"
632 ? (toUrlFiltering(news.urlFilteringProfile) ?? {})
633 : undefined,
634 },
635 });
636 yield* waitForOperation(operation);
637 current = yield* waitUntilExists(current.name ?? name);
638 }
639
640 return toAttrs(current, env.project);
641 }),
642
643 delete: Effect.fn(function* ({ output }) {
644 const operation = yield* networksecurity
645 .deleteProjectsLocationsSecurityProfiles({
646 name: output.name,
647 etag: output.etag,
648 })
649 .pipe(
650 Effect.retry({
651 while: (error) => error._tag === "Conflict",
652 times: 8,
653 schedule: Schedule.spaced("2 seconds"),
654 }),
655 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
656 );
657 if (operation !== undefined) {
658 yield* waitForOperation(operation, { notFoundOk: true });
659 }
660 yield* waitUntilGone(output.name);
661 }),
662 });