1import * as networkservices from "@distilled.cloud/gcp/networkservices_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 { tagRecord } from "../../Tags.ts";
9import { GcpEnvironment } from "../Environment.ts";
10import {
11 createInternalLabels,
12 diffLabels,
13 hasAlchemyLabels,
14 toLabels,
15} from "../Labels.ts";
16import type { Providers } from "../Providers.ts";
17import {
18 changedFields,
19 collectPages,
20 hasAlchemyLabelKeys,
21 normalizeLocation,
22 parentOf,
23 parseName,
24 resourceName,
25 rfc1035,
26 sameJson,
27 sameStringList,
28 toPhysicalId,
29 userLabels,
30 waitForOperation,
31 waitUntilGone,
32 waitUntilPresent,
33} from "./internal.ts";
34
35const COLLECTION = "authzExtensions";
36const DEFAULT_SCHEME = "INTERNAL_MANAGED";
37const DEFAULT_TIMEOUT = "0.1s";
38
39export type AuthzExtensionLoadBalancingScheme =
40 | networkservices.AuthzExtensionLoadBalancingSchemeEnum
41 | (string & {});
42
43export type AuthzExtensionWireFormat =
44 | networkservices.AuthzExtensionWireFormatEnum
45 | (string & {});
46
47export type AuthzExtensionProps = {
48 /**
49 * AuthzExtension id (the `{authzExtension}` segment of
50 * `projects/{project}/locations/{location}/authzExtensions/{authzExtension}`).
51 * If omitted, a unique name is generated from the stack, stage, and
52 * logical id. Must be 1-63 characters and not start with a number.
53 * Immutable — changing it replaces the extension.
54 */
55 authzExtensionId?: string;
56 /**
57 * Location (`us-central1`, `global`, …). Immutable — changing it
58 * replaces the extension. `US-CENTRAL1` is accepted and normalized to
59 * `us-central1`.
60 * @default the stack's GCP region (`GCP.Region`, else the profile region, else `us-central1`)
61 */
62 location?: string;
63 /**
64 * Fully-qualified backend service URL that runs the callout, e.g.
65 * `https://www.googleapis.com/compute/v1/projects/{project}/regions/{region}/backendServices/{backendService}`.
66 */
67 service: string;
68 /**
69 * Timeout for each message on the stream. Must be between 10ms and
70 * 10000ms (for example `0.1s` or `1000ms`).
71 * @default "0.1s"
72 */
73 timeout?: string;
74 /**
75 * Load balancing scheme shared by referenced backend services and
76 * forwarding rules. Immutable — changing it replaces the extension.
77 * @default "INTERNAL_MANAGED"
78 */
79 loadBalancingScheme?: AuthzExtensionLoadBalancingScheme;
80 /**
81 * `:authority` header sent from Envoy to the extension service.
82 * Required when `service` points at a backend service or wasm plugin.
83 */
84 authority?: string;
85 /**
86 * Continue request processing if the callout fails or times out.
87 * @default false
88 */
89 failOpen?: boolean;
90 /**
91 * Metadata included under `com.google.authz_extension.` in the
92 * `ProcessingRequest`. Values may use `{forwarding_rule_id}`.
93 */
94 metadata?: Record<string, unknown>;
95 /**
96 * HTTP headers forwarded to the extension. Omitted sends every header.
97 */
98 forwardHeaders?: string[];
99 /**
100 * Envoy attributes forwarded to the extension. Omitted sends none.
101 */
102 forwardAttributes?: string[];
103 /**
104 * Callout wire format. Regional resources default to `EXT_PROC_GRPC`.
105 * Global resources always use `EXT_PROC_GRPC`.
106 */
107 wireFormat?: AuthzExtensionWireFormat;
108 /**
109 * Human-readable description.
110 */
111 description?: string;
112 /**
113 * User labels. Alchemy ownership labels are merged in automatically.
114 */
115 labels?: Record<string, string>;
116};
117
118export type AuthzExtension = Resource<
119 "GCP.NetworkServices.AuthzExtension",
120 AuthzExtensionProps,
121 {
122 /** Full resource name `projects/{project}/locations/{location}/authzExtensions/{authzExtension}`. */
123 name: string;
124 /** AuthzExtension id (last path segment). */
125 authzExtensionId: string;
126 /** Project id. */
127 project: string;
128 /** Location id (`us-central1`, `global`, …). */
129 location: string;
130 /** Backend service URL that runs the callout. */
131 service: string | undefined;
132 /** Per-message timeout. */
133 timeout: string | undefined;
134 /** Load balancing scheme. */
135 loadBalancingScheme: string | undefined;
136 /** `:authority` header, if set. */
137 authority: string | undefined;
138 /** Whether the proxy continues on callout failure. */
139 failOpen: boolean;
140 /** Callout metadata. */
141 metadata: Record<string, unknown> | undefined;
142 /** Forwarded HTTP headers. */
143 forwardHeaders: string[];
144 /** Forwarded Envoy attributes. */
145 forwardAttributes: string[];
146 /** Callout wire format, if set. */
147 wireFormat: string | undefined;
148 /** User-provided description. */
149 description: string | undefined;
150 /** User labels (Alchemy ownership labels stripped). */
151 labels: Record<string, string>;
152 /** RFC3339 creation timestamp. */
153 createTime: string | undefined;
154 /** RFC3339 last-update timestamp. */
155 updateTime: string | undefined;
156 },
157 never,
158 Providers
159>;
160
161/**
162 * A Network Services AuthzExtension — forwards requests to a callout
163 * backend that makes an authorization decision for a load balancer.
164 *
165 * Changing `authzExtensionId`, `location`, or `loadBalancingScheme`
166 * replaces the extension. Description, labels, service, timeout,
167 * authority, fail-open, metadata, headers, and wire format update in
168 * place.
169 *
170 * ### Creating an AuthzExtension
171 * **Example:** Regional callout
172 * ```typescript
173 * const ext = yield* GCP.NetworkServices.AuthzExtension("Authz", {
174 * service: backend.selfLink,
175 * authority: "authz.example.com",
176 * timeout: "0.1s",
177 * loadBalancingScheme: "INTERNAL_MANAGED",
178 * });
179 * ```
180 *
181 * **Example:** Named extension with labels
182 * ```typescript
183 * const ext = yield* GCP.NetworkServices.AuthzExtension("Authz", {
184 * authzExtensionId: "app-authz",
185 * location: "us-central1",
186 * description: "prod authz",
187 * labels: { env: "prod" },
188 * service: backend.selfLink,
189 * authority: "authz.example.com",
190 * timeout: "0.2s",
191 * failOpen: true,
192 * });
193 * ```
194 *
195 * @resource
196 * @category NetworkServices
197 */
198export const AuthzExtension = Resource<AuthzExtension>(
199 "GCP.NetworkServices.AuthzExtension",
200);
201
202const toAttrs = (
203 extension: networkservices.AuthzExtension,
204 project: string,
205 region: string,
206) => {
207 const name = extension.name ?? "";
208 const parsed = parseName(name, COLLECTION, region);
209 return {
210 name,
211 authzExtensionId: parsed.id,
212 project: parsed.project || project,
213 location: parsed.location || region,
214 service: extension.service,
215 timeout: extension.timeout,
216 loadBalancingScheme: extension.loadBalancingScheme,
217 authority: extension.authority,
218 failOpen: extension.failOpen === true,
219 metadata: extension.metadata,
220 forwardHeaders: extension.forwardHeaders ?? [],
221 forwardAttributes: extension.forwardAttributes ?? [],
222 wireFormat: extension.wireFormat,
223 description: extension.description,
224 labels: userLabels(extension.labels),
225 createTime: extension.createTime,
226 updateTime: extension.updateTime,
227 };
228};
229
230const getByName = (name: string) =>
231 networkservices
232 .getProjectsLocationsAuthzExtensions({ name })
233 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
234
235export const AuthzExtensionProvider = () =>
236 Provider.succeed(AuthzExtension, {
237 stables: ["name", "authzExtensionId", "project", "location", "createTime"],
238
239 diff: Effect.fn(function* ({ news, olds, output }) {
240 if (!isResolved(news)) return undefined;
241 const env = yield* GcpEnvironment.current;
242 const previousId = olds?.authzExtensionId ?? output?.authzExtensionId;
243 const nextId = news.authzExtensionId
244 ? rfc1035(news.authzExtensionId, "authz-extension")
245 : previousId;
246 const previousLocation = normalizeLocation(
247 olds?.location ?? output?.location,
248 env.region,
249 );
250 const nextLocation = normalizeLocation(
251 news.location ?? olds?.location ?? output?.location,
252 env.region,
253 );
254 const previousScheme =
255 olds?.loadBalancingScheme ??
256 output?.loadBalancingScheme ??
257 DEFAULT_SCHEME;
258 const nextScheme = news.loadBalancingScheme ?? previousScheme;
259 if (
260 (previousId !== undefined &&
261 nextId !== undefined &&
262 nextId !== previousId) ||
263 previousLocation !== nextLocation ||
264 previousScheme !== nextScheme
265 ) {
266 return { action: "replace" as const };
267 }
268 return undefined;
269 }),
270
271 read: Effect.fn(function* ({ id, olds, output }) {
272 const env = yield* GcpEnvironment.current;
273 const authzExtensionId = yield* toPhysicalId(
274 id,
275 olds?.authzExtensionId,
276 output?.authzExtensionId,
277 "authz-extension",
278 );
279 const location = normalizeLocation(
280 olds?.location ?? output?.location,
281 env.region,
282 );
283 const name =
284 output?.name ??
285 resourceName(env.project, location, COLLECTION, authzExtensionId);
286 const existing = yield* getByName(name);
287 if (existing === undefined) return undefined;
288 const attrs = toAttrs(existing, env.project, env.region);
289 return (yield* hasAlchemyLabels(id, tagRecord(existing.labels)))
290 ? attrs
291 : Unowned(attrs);
292 }),
293
294 list: () =>
295 Effect.gen(function* () {
296 const env = yield* GcpEnvironment.current;
297 const items = yield* collectPages(
298 networkservices.listProjectsLocationsAuthzExtensions.pages({
299 parent: parentOf(env.project, "-"),
300 pageSize: 1000,
301 }),
302 (page) => page.authzExtensions,
303 );
304 return items
305 .filter((item) => hasAlchemyLabelKeys(item.labels))
306 .map((item) => toAttrs(item, env.project, env.region));
307 }),
308
309 reconcile: Effect.fn(function* ({ id, news, output }) {
310 const env = yield* GcpEnvironment.current;
311 const authzExtensionId = yield* toPhysicalId(
312 id,
313 news.authzExtensionId,
314 output?.authzExtensionId,
315 "authz-extension",
316 );
317 const location = normalizeLocation(
318 news.location ?? output?.location,
319 env.region,
320 );
321 const name = resourceName(
322 env.project,
323 location,
324 COLLECTION,
325 authzExtensionId,
326 );
327 const desiredLabels = {
328 ...toLabels(news.labels),
329 ...(yield* createInternalLabels(id)),
330 };
331 const loadBalancingScheme = news.loadBalancingScheme ?? DEFAULT_SCHEME;
332 const timeout = news.timeout ?? DEFAULT_TIMEOUT;
333 const failOpen = news.failOpen === true;
334 const desiredHeaders = news.forwardHeaders ?? [];
335 const desiredAttributes = news.forwardAttributes ?? [];
336
337 let current = yield* getByName(name);
338
339 if (current === undefined) {
340 const created = yield* networkservices
341 .createProjectsLocationsAuthzExtensions({
342 parent: parentOf(env.project, location),
343 authzExtensionId,
344 body: {
345 labels: desiredLabels,
346 description: news.description,
347 service: news.service,
348 timeout,
349 loadBalancingScheme,
350 authority: news.authority,
351 failOpen,
352 metadata: news.metadata,
353 forwardHeaders: desiredHeaders,
354 forwardAttributes: desiredAttributes,
355 wireFormat: news.wireFormat,
356 },
357 })
358 .pipe(
359 Effect.retry({
360 while: (error) => error._tag === "Conflict",
361 times: 5,
362 schedule: Schedule.spaced("2 seconds"),
363 }),
364 Effect.catchTag("Conflict", () => Effect.succeed(undefined)),
365 );
366 if (created !== undefined) {
367 yield* waitForOperation(created);
368 }
369 current = yield* waitUntilPresent(getByName(name), name);
370 }
371
372 const observedLabels = tagRecord(current.labels);
373 const { upsert, removed } = diffLabels(observedLabels, desiredLabels);
374 const labelsChanged = upsert.length > 0 || removed.length > 0;
375 const descriptionChanged =
376 (current.description ?? "") !== (news.description ?? "");
377 const serviceChanged = (current.service ?? "") !== (news.service ?? "");
378 const timeoutChanged = (current.timeout ?? "") !== timeout;
379 const authorityChanged =
380 (current.authority ?? "") !== (news.authority ?? "");
381 const failOpenChanged = (current.failOpen === true) !== failOpen;
382 const metadataChanged = !sameJson(current.metadata, news.metadata);
383 const headersChanged = !sameStringList(
384 current.forwardHeaders,
385 desiredHeaders,
386 );
387 const attributesChanged = !sameStringList(
388 current.forwardAttributes,
389 desiredAttributes,
390 );
391 const wireChanged =
392 (current.wireFormat ?? "") !== (news.wireFormat ?? "");
393
394 const updateMask = changedFields([
395 ["labels", labelsChanged],
396 ["description", descriptionChanged],
397 ["service", serviceChanged],
398 ["timeout", timeoutChanged],
399 ["authority", authorityChanged],
400 ["failOpen", failOpenChanged],
401 ["metadata", metadataChanged],
402 ["forwardHeaders", headersChanged],
403 ["forwardAttributes", attributesChanged],
404 ["wireFormat", wireChanged],
405 ]);
406
407 if (updateMask.length > 0) {
408 const operation =
409 yield* networkservices.patchProjectsLocationsAuthzExtensions({
410 name: current.name ?? name,
411 updateMask: updateMask.join(","),
412 body: {
413 name: current.name ?? name,
414 labels: desiredLabels,
415 description: news.description,
416 service: news.service,
417 timeout,
418 loadBalancingScheme,
419 authority: news.authority,
420 failOpen,
421 metadata: news.metadata,
422 forwardHeaders: desiredHeaders,
423 forwardAttributes: desiredAttributes,
424 wireFormat: news.wireFormat,
425 },
426 });
427 yield* waitForOperation(operation);
428 current = yield* waitUntilPresent(
429 getByName(current.name ?? name),
430 current.name ?? name,
431 );
432 }
433
434 return toAttrs(current, env.project, env.region);
435 }),
436
437 delete: Effect.fn(function* ({ output }) {
438 const operation = yield* networkservices
439 .deleteProjectsLocationsAuthzExtensions({ name: output.name })
440 .pipe(
441 Effect.retry({
442 while: (error) => error._tag === "Conflict",
443 times: 8,
444 schedule: Schedule.spaced("2 seconds"),
445 }),
446 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
447 );
448 if (operation !== undefined) {
449 yield* waitForOperation(operation, { notFoundOk: true });
450 }
451 yield* waitUntilGone(getByName(output.name), output.name);
452 }),
453 });