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 normalizeLocation,
20 projectParent,
21 parseResourceName,
22 ResourceNotResolved,
23 sameJson,
24 toPhysicalId,
25 userLabels,
26 waitForOperation,
27 waitUntilExists,
28 waitUntilGone,
29} from "./operations.ts";
30
31const DEFAULT_ACTION =
32 "ALLOW" satisfies networksecurity.AuthorizationPolicyActionEnum;
33
34export type AuthorizationPolicyAction =
35 | networksecurity.AuthorizationPolicyActionEnum
36 | (string & {});
37
38export type AuthorizationPolicySource = {
39 /** Peer identities to match (exact, prefix, suffix, or `*`). */
40 principals?: string[];
41 /** Source IP CIDR ranges. */
42 ipBlocks?: string[];
43};
44
45export type AuthorizationPolicyHttpHeaderMatch = {
46 /** Regular expression matched against the header value. */
47 regexMatch?: string;
48 /** HTTP header name (`:authority`, `:method`, `Host`, …). */
49 headerName?: string;
50};
51
52export type AuthorizationPolicyDestination = {
53 /** Host names matched against the `:authority` header. */
54 hosts?: string[];
55 /** Destination ports. */
56 ports?: number[];
57 /** HTTP methods. Must be omitted for gRPC. */
58 methods?: string[];
59 /** Optional HTTP header match. */
60 httpHeaderMatch?: AuthorizationPolicyHttpHeaderMatch;
61};
62
63export type AuthorizationPolicyRule = {
64 /** Source matchers. All sources must match. */
65 sources?: AuthorizationPolicySource[];
66 /** Destination matchers. All destinations must match. */
67 destinations?: AuthorizationPolicyDestination[];
68};
69
70export type AuthorizationPolicyProps = {
71 /**
72 * Authorization policy id. If omitted, a unique RFC1035 name is
73 * generated from the stack, stage, and logical id. Immutable —
74 * changing it replaces the policy.
75 */
76 authorizationPolicyId?: string;
77 /**
78 * Location (`global`, `us-central1`, …). Immutable — changing it
79 * replaces the policy.
80 * @default "global"
81 */
82 location?: string;
83 /**
84 * Action taken when a rule matches.
85 * @default "ALLOW"
86 */
87 action?: AuthorizationPolicyAction;
88 /**
89 * Match rules. At least one rule must match for `action` to apply. An
90 * empty list applies `action` to every request.
91 */
92 rules?: AuthorizationPolicyRule[];
93 /**
94 * Human-readable description.
95 */
96 description?: string;
97 /**
98 * User labels. Alchemy ownership labels are merged in automatically.
99 */
100 labels?: Record<string, string>;
101};
102
103export type AuthorizationPolicy = Resource<
104 "GCP.NetworkSecurity.AuthorizationPolicy",
105 AuthorizationPolicyProps,
106 {
107 /** Full resource name `projects/{project}/locations/{location}/authorizationPolicies/{authorizationPolicy}`. */
108 name: string;
109 /** Authorization policy id (last path segment). */
110 authorizationPolicyId: string;
111 /** Project id. */
112 project: string;
113 /** Location id. */
114 location: string;
115 /** Action (`ALLOW` or `DENY`). */
116 action: string;
117 /** Match rules. */
118 rules: AuthorizationPolicyRule[];
119 /** User-provided description. */
120 description: string | undefined;
121 /** User labels (Alchemy ownership labels stripped). */
122 labels: Record<string, string>;
123 /** RFC3339 creation timestamp. */
124 createTime: string | undefined;
125 /** RFC3339 last-update timestamp. */
126 updateTime: string | undefined;
127 },
128 never,
129 Providers
130>;
131
132/**
133 * A Network Security authorization policy for Traffic Director /
134 * endpoint-config selectors.
135 *
136 * Changing `authorizationPolicyId` or `location` replaces the policy.
137 * Action, rules, description, and labels update in place. Attach the
138 * policy to a target HTTPS proxy or endpoint config selector to enforce
139 * it.
140 *
141 * ### Creating an Authorization Policy
142 * **Example:** Allow all
143 * ```typescript
144 * const policy = yield* GCP.NetworkSecurity.AuthorizationPolicy("Allow", {
145 * action: "ALLOW",
146 * });
147 * ```
148 *
149 * **Example:** Allow specific hosts
150 * ```typescript
151 * const policy = yield* GCP.NetworkSecurity.AuthorizationPolicy("Allow", {
152 * action: "ALLOW",
153 * rules: [
154 * {
155 * destinations: [{ hosts: ["api.example.com"], ports: [443] }],
156 * },
157 * ],
158 * labels: { env: "prod" },
159 * });
160 * ```
161 *
162 * @resource
163 * @category NetworkSecurity
164 */
165export const AuthorizationPolicy = Resource<AuthorizationPolicy>(
166 "GCP.NetworkSecurity.AuthorizationPolicy",
167);
168
169const resourceName = (
170 project: string,
171 location: string,
172 authorizationPolicyId: string,
173) =>
174 `projects/${project}/locations/${location}/authorizationPolicies/${authorizationPolicyId}`;
175
176const actionOf = (value: string | undefined) =>
177 (value ?? DEFAULT_ACTION).toUpperCase();
178
179const toRules = (
180 rules: networksecurity.RuleList | AuthorizationPolicyRule[] | undefined,
181): AuthorizationPolicyRule[] =>
182 (rules ?? []).map((rule) => ({
183 sources: rule.sources,
184 destinations: rule.destinations,
185 }));
186
187const toAttrs = (
188 policy: networksecurity.AuthorizationPolicy,
189 project: string,
190) => {
191 const name = policy.name ?? "";
192 const parsed = parseResourceName(name);
193 return {
194 name,
195 authorizationPolicyId: parsed.id,
196 project: parsed.parentId || project,
197 location: parsed.location,
198 action: policy.action ?? DEFAULT_ACTION,
199 rules: toRules(policy.rules),
200 description: policy.description,
201 labels: userLabels(policy.labels),
202 createTime: policy.createTime,
203 updateTime: policy.updateTime,
204 };
205};
206
207const getByName = (name: string) =>
208 networksecurity
209 .getProjectsLocationsAuthorizationPolicies({ name })
210 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
211
212const listOwned = (project: string) =>
213 networksecurity.listProjectsLocationsAuthorizationPolicies
214 .pages({
215 parent: projectParent(project, "-"),
216 pageSize: 1000,
217 })
218 .pipe(
219 Stream.flatMap((page) =>
220 Stream.fromIterable(page.authorizationPolicies ?? []),
221 ),
222 Stream.filter((policy) =>
223 Object.keys(policy.labels ?? {}).some((key) =>
224 key.startsWith("alchemy-"),
225 ),
226 ),
227 Stream.map((policy) => toAttrs(policy, project)),
228 Stream.runCollect,
229 Effect.map((chunk) => Array.from(chunk)),
230 Effect.catchTag("NotFound", () => Effect.succeed([])),
231 );
232
233export const AuthorizationPolicyProvider = () =>
234 Provider.succeed(AuthorizationPolicy, {
235 stables: [
236 "name",
237 "authorizationPolicyId",
238 "project",
239 "location",
240 "createTime",
241 ],
242
243 diff: Effect.fn(function* ({ news, olds, output }) {
244 if (!isResolved(news)) return undefined;
245 const previousId =
246 olds?.authorizationPolicyId ?? output?.authorizationPolicyId;
247 const nextId = news.authorizationPolicyId ?? previousId;
248 const previousLocation = normalizeLocation(
249 olds?.location ?? output?.location,
250 );
251 const nextLocation = normalizeLocation(
252 news.location ?? olds?.location ?? output?.location,
253 );
254 const replace =
255 (previousId !== undefined &&
256 nextId !== undefined &&
257 nextId !== previousId) ||
258 previousLocation !== nextLocation;
259 if (!replace) return undefined;
260 return {
261 action: "replace" as const,
262 deleteFirst:
263 previousLocation === nextLocation &&
264 previousId !== undefined &&
265 nextId === previousId,
266 };
267 }),
268
269 read: Effect.fn(function* ({ id, olds, output }) {
270 const env = yield* GcpEnvironment.current;
271 const authorizationPolicyId = yield* toPhysicalId(
272 id,
273 olds?.authorizationPolicyId,
274 output?.authorizationPolicyId,
275 "authzpolicy",
276 );
277 const location = normalizeLocation(olds?.location ?? output?.location);
278 const name =
279 output?.name ??
280 resourceName(env.project, location, authorizationPolicyId);
281 const existing = yield* getByName(name);
282 if (existing === undefined) return undefined;
283 const attrs = toAttrs(existing, env.project);
284 return (yield* hasAlchemyLabels(id, tagRecord(existing.labels)))
285 ? attrs
286 : Unowned(attrs);
287 }),
288
289 list: () =>
290 Effect.gen(function* () {
291 const env = yield* GcpEnvironment.current;
292 return yield* listOwned(env.project);
293 }),
294
295 reconcile: Effect.fn(function* ({ id, news, output }) {
296 const env = yield* GcpEnvironment.current;
297 const authorizationPolicyId = yield* toPhysicalId(
298 id,
299 news.authorizationPolicyId,
300 output?.authorizationPolicyId,
301 "authzpolicy",
302 );
303 const location = normalizeLocation(news.location ?? output?.location);
304 const name = resourceName(env.project, location, authorizationPolicyId);
305 const action = actionOf(news.action);
306 const rules = news.rules;
307 const desiredLabels = {
308 ...toLabels(news.labels),
309 ...(yield* createInternalLabels(id)),
310 };
311
312 let current = yield* getByName(name);
313
314 if (current === undefined) {
315 const created = yield* networksecurity
316 .createProjectsLocationsAuthorizationPolicies({
317 parent: projectParent(env.project, location),
318 authorizationPolicyId,
319 body: {
320 action,
321 rules,
322 description: news.description,
323 labels: desiredLabels,
324 },
325 })
326 .pipe(
327 Effect.retry({
328 while: (error) => error._tag === "Conflict",
329 times: 5,
330 schedule: Schedule.spaced("2 seconds"),
331 }),
332 Effect.catchTag("Conflict", () => Effect.succeed(undefined)),
333 );
334 if (created !== undefined) {
335 yield* waitForOperation(created);
336 }
337 current = yield* waitUntilExists(getByName(name), name);
338 }
339
340 if (current === undefined) {
341 return yield* new ResourceNotResolved({ name });
342 }
343
344 const observedLabels = tagRecord(current.labels);
345 const { upsert, removed } = diffLabels(observedLabels, desiredLabels);
346 const labelsChanged = upsert.length > 0 || removed.length > 0;
347 const descriptionChanged =
348 (current.description ?? "") !== (news.description ?? "");
349 const actionChanged = actionOf(current.action) !== action;
350 const rulesChanged = !sameJson(toRules(current.rules), toRules(rules));
351
352 if (
353 labelsChanged ||
354 descriptionChanged ||
355 actionChanged ||
356 rulesChanged
357 ) {
358 const updateMask = [
359 labelsChanged ? "labels" : undefined,
360 descriptionChanged ? "description" : undefined,
361 actionChanged ? "action" : undefined,
362 rulesChanged ? "rules" : undefined,
363 ].filter((field): field is string => field !== undefined);
364
365 const operation =
366 yield* networksecurity.patchProjectsLocationsAuthorizationPolicies({
367 name: current.name ?? name,
368 updateMask: updateMask.join(","),
369 body: {
370 name: current.name ?? name,
371 labels: desiredLabels,
372 description: news.description,
373 action,
374 rules,
375 },
376 });
377 yield* waitForOperation(operation);
378 current = yield* waitUntilExists(
379 getByName(current.name ?? name),
380 current.name ?? name,
381 );
382 }
383
384 return toAttrs(current, env.project);
385 }),
386
387 delete: Effect.fn(function* ({ output }) {
388 const operation = yield* networksecurity
389 .deleteProjectsLocationsAuthorizationPolicies({ name: output.name })
390 .pipe(
391 Effect.retry({
392 while: (error) => error._tag === "Conflict",
393 times: 8,
394 schedule: Schedule.spaced("2 seconds"),
395 }),
396 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
397 );
398 if (operation !== undefined) {
399 yield* waitForOperation(operation, { notFoundOk: true });
400 }
401 yield* waitUntilGone(getByName(output.name), output.name);
402 }),
403 });