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 parseResourceName,
20 projectParent,
21 ResourceNotResolved,
22 sameJson,
23 toPhysicalId,
24 userLabels,
25 waitForOperation,
26 waitUntilExists,
27 waitUntilGone,
28} from "./operations.ts";
29
30const DEFAULT_ACTION = "ALLOW" satisfies networksecurity.AuthzPolicyActionEnum;
31const DEFAULT_PROFILE =
32 "REQUEST_AUTHZ" satisfies networksecurity.AuthzPolicyPolicyProfileEnum;
33
34export type AuthzPolicyAction =
35 | networksecurity.AuthzPolicyActionEnum
36 | (string & {});
37
38export type AuthzPolicyProfile =
39 | networksecurity.AuthzPolicyPolicyProfileEnum
40 | (string & {});
41
42export type AuthzPolicyLoadBalancingScheme =
43 | networksecurity.AuthzPolicyTargetLoadBalancingSchemeEnum
44 | (string & {});
45
46export type AuthzPolicyStringMatch = {
47 exact?: string;
48 prefix?: string;
49 suffix?: string;
50 contains?: string;
51 ignoreCase?: boolean;
52};
53
54export type AuthzPolicyHeaderMatch = {
55 name?: string;
56 value?: AuthzPolicyStringMatch;
57};
58
59export type AuthzPolicyRequestOperation = {
60 methods?: string[];
61 hosts?: AuthzPolicyStringMatch[];
62 paths?: AuthzPolicyStringMatch[];
63 snis?: AuthzPolicyStringMatch[];
64 headerSet?: { headers?: AuthzPolicyHeaderMatch[] };
65 mcp?: networksecurity.AuthzPolicyAuthzRuleToRequestOperationMCP;
66};
67
68export type AuthzPolicyRuleTo = {
69 operations?: AuthzPolicyRequestOperation[];
70 notOperations?: AuthzPolicyRequestOperation[];
71};
72
73export type AuthzPolicyPrincipal = {
74 principal?: AuthzPolicyStringMatch;
75 principalSelector?: string;
76};
77
78export type AuthzPolicyIpBlock = {
79 prefix?: string;
80 length?: number;
81};
82
83export type AuthzPolicyRequestResource = {
84 tagValueIdSet?: { ids?: string[] };
85 iamServiceAccount?: AuthzPolicyStringMatch;
86};
87
88export type AuthzPolicyRequestSource = {
89 principals?: AuthzPolicyPrincipal[];
90 ipBlocks?: AuthzPolicyIpBlock[];
91 resources?: AuthzPolicyRequestResource[];
92};
93
94export type AuthzPolicyRuleFrom = {
95 sources?: AuthzPolicyRequestSource[];
96 notSources?: AuthzPolicyRequestSource[];
97};
98
99export type AuthzPolicyRule = {
100 from?: AuthzPolicyRuleFrom;
101 to?: AuthzPolicyRuleTo;
102 when?: string;
103};
104
105export type AuthzPolicyTarget = {
106 /**
107 * Forwarding rules, Secure Web Proxy gateways, or Agent Gateways.
108 */
109 resources?: string[];
110 /**
111 * Load balancing scheme shared by every targeted forwarding rule.
112 */
113 loadBalancingScheme?: AuthzPolicyLoadBalancingScheme;
114};
115
116export type AuthzPolicyCustomProvider = {
117 /** Empty object enables Cloud IAP. Mutually exclusive with `authzExtension`. */
118 cloudIap?: Record<string, never>;
119 /** Service-extension resources (limited to 1). */
120 authzExtension?: { resources?: string[] };
121};
122
123export type AuthzPolicyProps = {
124 /**
125 * Authz policy id. If omitted, a unique RFC1035 name is generated from
126 * the stack, stage, and logical id. Immutable — changing it replaces
127 * the policy.
128 */
129 authzPolicyId?: string;
130 /**
131 * Location (`us-central1`, `global`, …). Must match the targeted
132 * forwarding rules. Immutable — changing it replaces the policy.
133 * @default the stack's GCP region (`GCP.Region`, profile region, `us-central1`)
134 */
135 location?: string;
136 /**
137 * Action (`ALLOW`, `DENY`, `CUSTOM`). `CUSTOM` requires
138 * `customProvider`.
139 * @default "ALLOW"
140 */
141 action?: AuthzPolicyAction;
142 /**
143 * Resources this policy applies to. Required.
144 */
145 target: AuthzPolicyTarget;
146 /**
147 * Authorization HTTP rules. At least one rule is required for ALLOW or
148 * DENY unless `networkRules` is set. Limited to 5.
149 */
150 httpRules?: AuthzPolicyRule[];
151 /**
152 * Authorization network rules. Mutually exclusive with `httpRules`.
153 * Limited to 5.
154 */
155 networkRules?: AuthzPolicyRule[];
156 /**
157 * Authorization profile. Immutable — changing it replaces the policy.
158 * @default "REQUEST_AUTHZ"
159 */
160 policyProfile?: AuthzPolicyProfile;
161 /**
162 * Custom authorization provider. Required when `action` is `CUSTOM`.
163 */
164 customProvider?: AuthzPolicyCustomProvider;
165 /**
166 * Human-readable description.
167 */
168 description?: string;
169 /**
170 * User labels. Alchemy ownership labels are merged in automatically.
171 */
172 labels?: Record<string, string>;
173};
174
175export type AuthzPolicy = Resource<
176 "GCP.NetworkSecurity.AuthzPolicy",
177 AuthzPolicyProps,
178 {
179 /** Full resource name `projects/{project}/locations/{location}/authzPolicies/{authzPolicy}`. */
180 name: string;
181 /** Authz policy id (last path segment). */
182 authzPolicyId: string;
183 /** Project id. */
184 project: string;
185 /** Location id. */
186 location: string;
187 /** Action (`ALLOW`, `DENY`, `CUSTOM`). */
188 action: string;
189 /** Targeted forwarding rules / gateways. */
190 target: AuthzPolicyTarget | undefined;
191 /** HTTP match rules. */
192 httpRules: AuthzPolicyRule[];
193 /** Network match rules. */
194 networkRules: AuthzPolicyRule[];
195 /** Authorization profile. */
196 policyProfile: string | undefined;
197 /** Custom provider, if `action` is `CUSTOM`. */
198 customProvider: AuthzPolicyCustomProvider | undefined;
199 /** User-provided description. */
200 description: string | undefined;
201 /** User labels (Alchemy ownership labels stripped). */
202 labels: Record<string, string>;
203 /** RFC3339 creation timestamp. */
204 createTime: string | undefined;
205 /** RFC3339 last-update timestamp. */
206 updateTime: string | undefined;
207 },
208 never,
209 Providers
210>;
211
212/**
213 * A Network Security AuthzPolicy for Application Load Balancers, Secure
214 * Web Proxy, and Agent Gateway.
215 *
216 * Changing `authzPolicyId`, `location`, or `policyProfile` replaces the
217 * policy. Action, target, rules, custom provider, description, and
218 * labels update in place. `target.resources` must reference forwarding
219 * rules or gateways that share `loadBalancingScheme`.
220 *
221 * ### Creating an Authz Policy
222 * **Example:** Allow matching HTTP paths
223 * ```typescript
224 * const policy = yield* GCP.NetworkSecurity.AuthzPolicy("AllowAdmin", {
225 * location: "us-central1",
226 * action: "ALLOW",
227 * target: {
228 * loadBalancingScheme: "INTERNAL_MANAGED",
229 * resources: [forwardingRule.selfLink],
230 * },
231 * httpRules: [
232 * {
233 * to: {
234 * operations: [
235 * { methods: ["GET"], paths: [{ prefix: "/admin" }] },
236 * ],
237 * },
238 * },
239 * ],
240 * });
241 * ```
242 *
243 * @resource
244 * @category NetworkSecurity
245 */
246export const AuthzPolicy = Resource<AuthzPolicy>(
247 "GCP.NetworkSecurity.AuthzPolicy",
248);
249
250const resourceName = (
251 project: string,
252 location: string,
253 authzPolicyId: string,
254) => `projects/${project}/locations/${location}/authzPolicies/${authzPolicyId}`;
255
256const actionOf = (value: string | undefined) =>
257 (value ?? DEFAULT_ACTION).toUpperCase();
258
259const profileOf = (value: string | undefined) =>
260 (value ?? DEFAULT_PROFILE).toUpperCase();
261
262const locationOf = (value: string | undefined, fallback: string) =>
263 (value ?? fallback).toLowerCase();
264
265const toCustomProvider = (
266 provider: networksecurity.AuthzPolicyCustomProvider | undefined,
267): AuthzPolicyCustomProvider | undefined => {
268 if (provider === undefined) return undefined;
269 return {
270 cloudIap: provider.cloudIap !== undefined ? {} : undefined,
271 authzExtension: provider.authzExtension,
272 };
273};
274
275const toAttrs = (policy: networksecurity.AuthzPolicy, project: string) => {
276 const name = policy.name ?? "";
277 const parsed = parseResourceName(name);
278 return {
279 name,
280 authzPolicyId: parsed.id,
281 project: parsed.parentId || project,
282 location: parsed.location,
283 action: policy.action ?? DEFAULT_ACTION,
284 target: policy.target,
285 httpRules: (policy.httpRules ?? []) as AuthzPolicyRule[],
286 networkRules: (policy.networkRules ?? []) as AuthzPolicyRule[],
287 policyProfile: policy.policyProfile,
288 customProvider: toCustomProvider(policy.customProvider),
289 description: policy.description,
290 labels: userLabels(policy.labels),
291 createTime: policy.createTime,
292 updateTime: policy.updateTime,
293 };
294};
295
296const getByName = (name: string) =>
297 networksecurity
298 .getProjectsLocationsAuthzPolicies({ name })
299 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
300
301const listOwned = (project: string) =>
302 networksecurity.listProjectsLocationsAuthzPolicies
303 .pages({
304 parent: projectParent(project, "-"),
305 pageSize: 1000,
306 })
307 .pipe(
308 Stream.flatMap((page) => Stream.fromIterable(page.authzPolicies ?? [])),
309 Stream.filter((policy) =>
310 Object.keys(policy.labels ?? {}).some((key) =>
311 key.startsWith("alchemy-"),
312 ),
313 ),
314 Stream.map((policy) => toAttrs(policy, project)),
315 Stream.runCollect,
316 Effect.map((chunk) => Array.from(chunk)),
317 Effect.catchTag("NotFound", () => Effect.succeed([])),
318 );
319
320const toCustomProviderBody = (
321 provider: AuthzPolicyCustomProvider | undefined,
322): networksecurity.AuthzPolicyCustomProvider | undefined => {
323 if (provider === undefined) return undefined;
324 return {
325 cloudIap: provider.cloudIap !== undefined ? {} : undefined,
326 authzExtension: provider.authzExtension,
327 };
328};
329
330export const AuthzPolicyProvider = () =>
331 Provider.succeed(AuthzPolicy, {
332 stables: [
333 "name",
334 "authzPolicyId",
335 "project",
336 "location",
337 "policyProfile",
338 "createTime",
339 ],
340
341 diff: Effect.fn(function* ({ news, olds, output }) {
342 if (!isResolved(news)) return undefined;
343 const env = yield* GcpEnvironment.current;
344 const previousId = olds?.authzPolicyId ?? output?.authzPolicyId;
345 const nextId = news.authzPolicyId ?? previousId;
346 const previousLocation = locationOf(
347 olds?.location ?? output?.location,
348 env.region,
349 );
350 const nextLocation = locationOf(
351 news.location ?? olds?.location ?? output?.location,
352 env.region,
353 );
354 const previousProfile = profileOf(
355 olds?.policyProfile ?? output?.policyProfile,
356 );
357 const nextProfile = profileOf(
358 news.policyProfile ?? olds?.policyProfile ?? output?.policyProfile,
359 );
360 const replace =
361 (previousId !== undefined &&
362 nextId !== undefined &&
363 nextId !== previousId) ||
364 previousLocation !== nextLocation ||
365 previousProfile !== nextProfile;
366 if (!replace) return undefined;
367 return {
368 action: "replace" as const,
369 deleteFirst:
370 previousLocation === nextLocation &&
371 previousId !== undefined &&
372 nextId === previousId,
373 };
374 }),
375
376 read: Effect.fn(function* ({ id, olds, output }) {
377 const env = yield* GcpEnvironment.current;
378 const authzPolicyId = yield* toPhysicalId(
379 id,
380 olds?.authzPolicyId,
381 output?.authzPolicyId,
382 "authzpolicy",
383 );
384 const location = locationOf(
385 olds?.location ?? output?.location,
386 env.region,
387 );
388 const name =
389 output?.name ?? resourceName(env.project, location, authzPolicyId);
390 const existing = yield* getByName(name);
391 if (existing === undefined) return undefined;
392 const attrs = toAttrs(existing, env.project);
393 return (yield* hasAlchemyLabels(id, tagRecord(existing.labels)))
394 ? attrs
395 : Unowned(attrs);
396 }),
397
398 list: () =>
399 Effect.gen(function* () {
400 const env = yield* GcpEnvironment.current;
401 return yield* listOwned(env.project);
402 }),
403
404 reconcile: Effect.fn(function* ({ id, news, output }) {
405 const env = yield* GcpEnvironment.current;
406 const authzPolicyId = yield* toPhysicalId(
407 id,
408 news.authzPolicyId,
409 output?.authzPolicyId,
410 "authzpolicy",
411 );
412 const location = locationOf(
413 news.location ?? output?.location,
414 env.region,
415 );
416 const name = resourceName(env.project, location, authzPolicyId);
417 const action = actionOf(news.action);
418 const policyProfile = profileOf(news.policyProfile);
419 const customProvider = toCustomProviderBody(news.customProvider);
420 const desiredLabels = {
421 ...toLabels(news.labels),
422 ...(yield* createInternalLabels(id)),
423 };
424
425 let current = yield* getByName(name);
426
427 if (current === undefined) {
428 const created = yield* networksecurity
429 .createProjectsLocationsAuthzPolicies({
430 parent: projectParent(env.project, location),
431 authzPolicyId,
432 body: {
433 action,
434 target: news.target,
435 httpRules: news.httpRules,
436 networkRules: news.networkRules,
437 policyProfile,
438 customProvider,
439 description: news.description,
440 labels: desiredLabels,
441 },
442 })
443 .pipe(
444 Effect.retry({
445 while: (error) => error._tag === "Conflict",
446 times: 5,
447 schedule: Schedule.spaced("2 seconds"),
448 }),
449 Effect.catchTag("Conflict", () => Effect.succeed(undefined)),
450 );
451 if (created !== undefined) {
452 yield* waitForOperation(created);
453 }
454 current = yield* waitUntilExists(getByName(name), name);
455 }
456
457 if (current === undefined) {
458 return yield* new ResourceNotResolved({ name });
459 }
460
461 const observedLabels = tagRecord(current.labels);
462 const { upsert, removed } = diffLabels(observedLabels, desiredLabels);
463 const labelsChanged = upsert.length > 0 || removed.length > 0;
464 const descriptionChanged =
465 (current.description ?? "") !== (news.description ?? "");
466 const actionChanged = actionOf(current.action) !== action;
467 const targetChanged = !sameJson(current.target, news.target);
468 const httpChanged = !sameJson(
469 current.httpRules ?? [],
470 news.httpRules ?? [],
471 );
472 const networkChanged = !sameJson(
473 current.networkRules ?? [],
474 news.networkRules ?? [],
475 );
476 const providerChanged = !sameJson(
477 toCustomProvider(current.customProvider),
478 news.customProvider,
479 );
480
481 if (
482 labelsChanged ||
483 descriptionChanged ||
484 actionChanged ||
485 targetChanged ||
486 httpChanged ||
487 networkChanged ||
488 providerChanged
489 ) {
490 const updateMask = [
491 labelsChanged ? "labels" : undefined,
492 descriptionChanged ? "description" : undefined,
493 actionChanged ? "action" : undefined,
494 targetChanged ? "target" : undefined,
495 httpChanged ? "httpRules" : undefined,
496 networkChanged ? "networkRules" : undefined,
497 providerChanged ? "customProvider" : undefined,
498 ].filter((field): field is string => field !== undefined);
499
500 const operation =
501 yield* networksecurity.patchProjectsLocationsAuthzPolicies({
502 name: current.name ?? name,
503 updateMask: updateMask.join(","),
504 body: {
505 name: current.name ?? name,
506 labels: desiredLabels,
507 description: news.description,
508 action,
509 target: news.target,
510 httpRules: news.httpRules,
511 networkRules: news.networkRules,
512 customProvider,
513 },
514 });
515 yield* waitForOperation(operation);
516 current = yield* waitUntilExists(
517 getByName(current.name ?? name),
518 current.name ?? name,
519 );
520 }
521
522 return toAttrs(current, env.project);
523 }),
524
525 delete: Effect.fn(function* ({ output }) {
526 const operation = yield* networksecurity
527 .deleteProjectsLocationsAuthzPolicies({ name: output.name })
528 .pipe(
529 Effect.retry({
530 while: (error) => error._tag === "Conflict",
531 times: 8,
532 schedule: Schedule.spaced("2 seconds"),
533 }),
534 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
535 );
536 if (operation !== undefined) {
537 yield* waitForOperation(operation, { notFoundOk: true });
538 }
539 yield* waitUntilGone(getByName(output.name), output.name);
540 }),
541 });