1import * as networksecurity from "@distilled.cloud/gcp/networksecurity_v1";
2import * as Effect from "effect/Effect";
3import * as Schedule from "effect/Schedule";
4import * as Stream from "effect/Stream";
5import { Unowned } from "../../AdoptPolicy.ts";
6import { isResolved } from "../../Diff.ts";
7import * as Provider from "../../Provider.ts";
8import { Resource } from "../../Resource.ts";
9import { tagRecord } from "../../Tags.ts";
10import { GcpEnvironment } from "../Environment.ts";
11import {
12 createInternalLabels,
13 diffLabels,
14 hasAlchemyLabels,
15 toLabels,
16} from "../Labels.ts";
17import type { Providers } from "../Providers.ts";
18import {
19 listOrganizations,
20 normalizeLocation,
21 organizationParent,
22 parseResourceName,
23 ResourceNotResolved,
24 resolveOrganization,
25 sameJson,
26 toPhysicalId,
27 userLabels,
28 waitForOperation,
29 waitUntilExists,
30 waitUntilGone,
31} from "./operations.ts";
32
33const DEFAULT_TYPE =
34 "THREAT_PREVENTION" satisfies networksecurity.SecurityProfileTypeEnum;
35
36export type OrganizationsSecurityProfileType =
37 | networksecurity.SecurityProfileTypeEnum
38 | (string & {});
39
40export type OrganizationsAntivirusOverride = {
41 /** Protocol to match (`HTTP`, `SMTP`, …). */
42 protocol?: string;
43 /** Threat action (`ALLOW`, `ALERT`, `DENY`, `DEFAULT_ACTION`). */
44 action?: string;
45};
46
47export type OrganizationsSeverityOverride = {
48 /** Severity (`INFORMATIONAL`, `LOW`, `MEDIUM`, `HIGH`, `CRITICAL`). */
49 severity?: string;
50 /** Threat action (`ALLOW`, `ALERT`, `DENY`, `DEFAULT_ACTION`). */
51 action?: string;
52};
53
54export type OrganizationsThreatOverride = {
55 /** Vendor-specific threat id. */
56 threatId?: string;
57 /** Threat type (output-only on the wire). */
58 type?: string;
59 /** Threat action (`ALLOW`, `ALERT`, `DENY`, `DEFAULT_ACTION`). */
60 action?: string;
61};
62
63export type OrganizationsThreatPreventionProfile = {
64 /** Antivirus action overrides per protocol. */
65 antivirusOverrides?: OrganizationsAntivirusOverride[];
66 /** Action overrides by severity. */
67 severityOverrides?: OrganizationsSeverityOverride[];
68 /** Action overrides by threat id. */
69 threatOverrides?: OrganizationsThreatOverride[];
70};
71
72export type OrganizationsCustomMirroringProfile = {
73 /** Target MirroringEndpointGroup resource name. Immutable. */
74 mirroringEndpointGroup?: string;
75};
76
77export type OrganizationsUrlFilter = {
78 /** Action (`ALLOW` or `DENY`). */
79 filteringAction?: string;
80 /** URL match strings. */
81 urls?: string[];
82 /** Unique priority within the profile. Lower is higher priority. */
83 priority?: number;
84};
85
86export type OrganizationsUrlFilteringProfile = {
87 /** URL filters. */
88 urlFilters?: OrganizationsUrlFilter[];
89};
90
91export type OrganizationsCustomInterceptProfile = {
92 /** Target InterceptEndpointGroup resource name. Immutable. */
93 interceptEndpointGroup?: string;
94};
95
96export type OrganizationsSecurityProfileProps = {
97 /**
98 * Security profile id. If omitted, a unique RFC1035 name is generated
99 * from the stack, stage, and logical id. Immutable — changing it
100 * replaces the profile.
101 */
102 securityProfileId?: string;
103 /**
104 * Organization id or `organizations/{organization}`. If omitted,
105 * Alchemy uses `GOOGLE_ORGANIZATION_ID` or the project's Resource
106 * Manager parent. Immutable — changing it replaces the profile.
107 */
108 organization?: string;
109 /**
110 * Location. Security profiles live in `global`. Immutable — changing
111 * it replaces the profile.
112 * @default "global"
113 */
114 location?: string;
115 /**
116 * Profile type. Immutable — changing it replaces the profile.
117 * @default "THREAT_PREVENTION"
118 */
119 type?: OrganizationsSecurityProfileType;
120 /**
121 * Threat prevention configuration. Used when `type` is
122 * `THREAT_PREVENTION`.
123 */
124 threatPreventionProfile?: OrganizationsThreatPreventionProfile;
125 /**
126 * Packet mirroring configuration. Used when `type` is
127 * `CUSTOM_MIRRORING`. Immutable.
128 */
129 customMirroringProfile?: OrganizationsCustomMirroringProfile;
130 /**
131 * URL filtering configuration. Used when `type` is `URL_FILTERING`.
132 */
133 urlFilteringProfile?: OrganizationsUrlFilteringProfile;
134 /**
135 * Packet intercept configuration. Used when `type` is
136 * `CUSTOM_INTERCEPT`. Immutable.
137 */
138 customInterceptProfile?: OrganizationsCustomInterceptProfile;
139 /**
140 * Human-readable description (max 512 characters).
141 */
142 description?: string;
143 /**
144 * User labels. Alchemy ownership labels are merged in automatically.
145 */
146 labels?: Record<string, string>;
147};
148
149export type OrganizationsSecurityProfile = Resource<
150 "GCP.NetworkSecurity.OrganizationsSecurityProfile",
151 OrganizationsSecurityProfileProps,
152 {
153 /** Full resource name `organizations/{organization}/locations/{location}/securityProfiles/{securityProfile}`. */
154 name: string;
155 /** Security profile id (last path segment). */
156 securityProfileId: string;
157 /** Organization id. */
158 organization: string;
159 /** Location id. */
160 location: string;
161 /** Profile type. */
162 type: string;
163 /** Threat prevention configuration, if set. */
164 threatPreventionProfile: OrganizationsThreatPreventionProfile | undefined;
165 /** Packet mirroring configuration, if set. */
166 customMirroringProfile: OrganizationsCustomMirroringProfile | undefined;
167 /** URL filtering configuration, if set. */
168 urlFilteringProfile: OrganizationsUrlFilteringProfile | undefined;
169 /** Packet intercept configuration, if set. */
170 customInterceptProfile: OrganizationsCustomInterceptProfile | undefined;
171 /** User-provided description. */
172 description: string | undefined;
173 /** User labels (Alchemy ownership labels stripped). */
174 labels: Record<string, string>;
175 /** Server-computed checksum. */
176 etag: string | undefined;
177 /** RFC3339 creation timestamp. */
178 createTime: string | undefined;
179 /** RFC3339 last-update timestamp. */
180 updateTime: string | undefined;
181 },
182 never,
183 Providers
184>;
185
186/**
187 * An organization-scoped Network Security profile (threat prevention,
188 * URL filtering, custom mirroring, or custom intercept).
189 *
190 * Changing `securityProfileId`, `organization`, `location`, `type`, or
191 * the immutable nested mirroring/intercept targets replaces the profile.
192 * Description, labels, and threat-prevention or URL-filter config update
193 * in place.
194 *
195 * ### Creating a Security Profile
196 * **Example:** Threat prevention
197 * ```typescript
198 * const profile = yield* GCP.NetworkSecurity.OrganizationsSecurityProfile(
199 * "Threats",
200 * {
201 * type: "THREAT_PREVENTION",
202 * threatPreventionProfile: {
203 * severityOverrides: [{ severity: "INFORMATIONAL", action: "ALERT" }],
204 * },
205 * },
206 * );
207 * ```
208 *
209 * @resource
210 * @category NetworkSecurity
211 */
212export const OrganizationsSecurityProfile =
213 Resource<OrganizationsSecurityProfile>(
214 "GCP.NetworkSecurity.OrganizationsSecurityProfile",
215 );
216
217const resourceName = (
218 organization: string,
219 location: string,
220 securityProfileId: string,
221) =>
222 `organizations/${organization}/locations/${location}/securityProfiles/${securityProfileId}`;
223
224const typeOf = (value: string | undefined) =>
225 (value ?? DEFAULT_TYPE).toUpperCase();
226
227const toThreatPrevention = (
228 profile: networksecurity.ThreatPreventionProfile | undefined,
229): OrganizationsThreatPreventionProfile | undefined => {
230 if (profile === undefined) return undefined;
231 return {
232 antivirusOverrides: profile.antivirusOverrides,
233 severityOverrides: profile.severityOverrides,
234 threatOverrides: profile.threatOverrides,
235 };
236};
237
238const toAttrs = (profile: networksecurity.SecurityProfile) => {
239 const name = profile.name ?? "";
240 const parsed = parseResourceName(name);
241 return {
242 name,
243 securityProfileId: parsed.id,
244 organization: parsed.parentId,
245 location: parsed.location,
246 type: profile.type ?? DEFAULT_TYPE,
247 threatPreventionProfile: toThreatPrevention(
248 profile.threatPreventionProfile,
249 ),
250 customMirroringProfile: profile.customMirroringProfile,
251 urlFilteringProfile: profile.urlFilteringProfile,
252 customInterceptProfile: profile.customInterceptProfile,
253 description: profile.description,
254 labels: userLabels(profile.labels),
255 etag: profile.etag,
256 createTime: profile.createTime,
257 updateTime: profile.updateTime,
258 };
259};
260
261const getByName = (name: string) =>
262 networksecurity
263 .getOrganizationsLocationsSecurityProfiles({ name })
264 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
265
266const listOwned = (organization: string) =>
267 networksecurity.listOrganizationsLocationsSecurityProfiles
268 .pages({
269 parent: organizationParent(organization, "-"),
270 pageSize: 1000,
271 })
272 .pipe(
273 Stream.flatMap((page) =>
274 Stream.fromIterable(page.securityProfiles ?? []),
275 ),
276 Stream.filter((profile) =>
277 Object.keys(profile.labels ?? {}).some((key) =>
278 key.startsWith("alchemy-"),
279 ),
280 ),
281 Stream.map(toAttrs),
282 Stream.runCollect,
283 Effect.map((chunk) => Array.from(chunk)),
284 Effect.catchTag("NotFound", () => Effect.succeed([])),
285 );
286
287const desiredThreatPrevention = (
288 news: OrganizationsSecurityProfileProps,
289 type: string,
290): OrganizationsThreatPreventionProfile | undefined => {
291 if (type !== "THREAT_PREVENTION") return undefined;
292 return news.threatPreventionProfile ?? {};
293};
294
295export const OrganizationsSecurityProfileProvider = () =>
296 Provider.succeed(OrganizationsSecurityProfile, {
297 stables: [
298 "name",
299 "securityProfileId",
300 "organization",
301 "location",
302 "type",
303 "createTime",
304 ],
305
306 diff: Effect.fn(function* ({ news, olds, output }) {
307 if (!isResolved(news)) return undefined;
308 const previousId = olds?.securityProfileId ?? output?.securityProfileId;
309 const nextId = news.securityProfileId ?? previousId;
310 const previousOrg = olds?.organization ?? output?.organization;
311 const nextOrg = news.organization ?? previousOrg;
312 const previousLocation = normalizeLocation(
313 olds?.location ?? output?.location,
314 );
315 const nextLocation = normalizeLocation(
316 news.location ?? olds?.location ?? output?.location,
317 );
318 const previousType = typeOf(olds?.type ?? output?.type);
319 const nextType = typeOf(news.type ?? olds?.type ?? output?.type);
320 const previousMirror =
321 olds?.customMirroringProfile?.mirroringEndpointGroup ??
322 output?.customMirroringProfile?.mirroringEndpointGroup;
323 const nextMirror =
324 news.customMirroringProfile?.mirroringEndpointGroup ?? previousMirror;
325 const previousIntercept =
326 olds?.customInterceptProfile?.interceptEndpointGroup ??
327 output?.customInterceptProfile?.interceptEndpointGroup;
328 const nextIntercept =
329 news.customInterceptProfile?.interceptEndpointGroup ??
330 previousIntercept;
331 const replace =
332 (previousId !== undefined &&
333 nextId !== undefined &&
334 nextId !== previousId) ||
335 (previousOrg !== undefined &&
336 nextOrg !== undefined &&
337 nextOrg !== previousOrg) ||
338 previousLocation !== nextLocation ||
339 previousType !== nextType ||
340 previousMirror !== nextMirror ||
341 previousIntercept !== nextIntercept;
342 if (!replace) return undefined;
343 return {
344 action: "replace" as const,
345 deleteFirst:
346 previousLocation === nextLocation &&
347 previousOrg === nextOrg &&
348 previousId !== undefined &&
349 nextId === previousId,
350 };
351 }),
352
353 read: Effect.fn(function* ({ id, olds, output }) {
354 const securityProfileId = yield* toPhysicalId(
355 id,
356 olds?.securityProfileId,
357 output?.securityProfileId,
358 "secprofile",
359 );
360 const organization = yield* resolveOrganization(
361 olds?.organization ?? output?.organization,
362 output?.organization,
363 ).pipe(
364 Effect.catchTag("GCP.NetworkSecurity.OrganizationRequired", () =>
365 Effect.succeed(output?.organization ?? ""),
366 ),
367 );
368 const location = normalizeLocation(olds?.location ?? output?.location);
369 const name =
370 output?.name ??
371 (organization.length > 0
372 ? resourceName(organization, location, securityProfileId)
373 : "");
374 if (name.length === 0) return undefined;
375 const existing = yield* getByName(name);
376 if (existing === undefined) return undefined;
377 const attrs = toAttrs(existing);
378 return (yield* hasAlchemyLabels(id, tagRecord(existing.labels)))
379 ? attrs
380 : Unowned(attrs);
381 }),
382
383 list: () =>
384 Effect.gen(function* () {
385 const env = yield* GcpEnvironment.current;
386 const orgs = yield* listOrganizations(env.project);
387 const listed: OrganizationsSecurityProfile["Attributes"][] = [];
388 for (const organization of orgs) {
389 listed.push(...(yield* listOwned(organization)));
390 }
391 return listed;
392 }),
393
394 reconcile: Effect.fn(function* ({ id, news, output }) {
395 const securityProfileId = yield* toPhysicalId(
396 id,
397 news.securityProfileId,
398 output?.securityProfileId,
399 "secprofile",
400 );
401 const organization = yield* resolveOrganization(
402 news.organization ?? output?.organization,
403 output?.organization,
404 );
405 const location = normalizeLocation(news.location ?? output?.location);
406 const name = resourceName(organization, location, securityProfileId);
407 const type = typeOf(news.type);
408 const threatPreventionProfile = desiredThreatPrevention(news, type);
409 const desiredLabels = {
410 ...toLabels(news.labels),
411 ...(yield* createInternalLabels(id)),
412 };
413
414 let current = yield* getByName(name);
415
416 if (current === undefined) {
417 const created = yield* networksecurity
418 .createOrganizationsLocationsSecurityProfiles({
419 parent: organizationParent(organization, location),
420 securityProfileId,
421 body: {
422 type,
423 description: news.description,
424 labels: desiredLabels,
425 threatPreventionProfile,
426 customMirroringProfile: news.customMirroringProfile,
427 urlFilteringProfile: news.urlFilteringProfile,
428 customInterceptProfile: news.customInterceptProfile,
429 },
430 })
431 .pipe(
432 Effect.retry({
433 while: (error) => error._tag === "Conflict",
434 times: 5,
435 schedule: Schedule.spaced("2 seconds"),
436 }),
437 Effect.catchTag("Conflict", () => Effect.succeed(undefined)),
438 );
439 if (created !== undefined) {
440 yield* waitForOperation(created);
441 }
442 current = yield* waitUntilExists(getByName(name), name);
443 }
444
445 if (current === undefined) {
446 return yield* new ResourceNotResolved({ name });
447 }
448
449 const observedLabels = tagRecord(current.labels);
450 const { upsert, removed } = diffLabels(observedLabels, desiredLabels);
451 const labelsChanged = upsert.length > 0 || removed.length > 0;
452 const descriptionChanged =
453 (current.description ?? "") !== (news.description ?? "");
454 const threatChanged =
455 type === "THREAT_PREVENTION" &&
456 !sameJson(
457 toThreatPrevention(current.threatPreventionProfile) ?? {},
458 threatPreventionProfile ?? {},
459 );
460 const urlChanged =
461 type === "URL_FILTERING" &&
462 !sameJson(current.urlFilteringProfile, news.urlFilteringProfile);
463
464 if (labelsChanged || descriptionChanged || threatChanged || urlChanged) {
465 const updateMask = [
466 labelsChanged ? "labels" : undefined,
467 descriptionChanged ? "description" : undefined,
468 threatChanged ? "threatPreventionProfile" : undefined,
469 urlChanged ? "urlFilteringProfile" : undefined,
470 ].filter((field): field is string => field !== undefined);
471
472 const operation =
473 yield* networksecurity.patchOrganizationsLocationsSecurityProfiles({
474 name: current.name ?? name,
475 updateMask: updateMask.join(","),
476 body: {
477 name: current.name ?? name,
478 labels: desiredLabels,
479 description: news.description,
480 threatPreventionProfile,
481 urlFilteringProfile: news.urlFilteringProfile,
482 },
483 });
484 yield* waitForOperation(operation);
485 current = yield* waitUntilExists(
486 getByName(current.name ?? name),
487 current.name ?? name,
488 );
489 }
490
491 return toAttrs(current);
492 }),
493
494 delete: Effect.fn(function* ({ output }) {
495 const operation = yield* networksecurity
496 .deleteOrganizationsLocationsSecurityProfiles({
497 name: output.name,
498 etag: output.etag,
499 })
500 .pipe(
501 Effect.retry({
502 while: (error) => error._tag === "Conflict",
503 times: 8,
504 schedule: Schedule.spaced("2 seconds"),
505 }),
506 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
507 );
508 if (operation !== undefined) {
509 yield* waitForOperation(operation, { notFoundOk: true });
510 }
511 yield* waitUntilGone(getByName(output.name), output.name);
512 }),
513 });