1import * as recaptchaenterprise from "@distilled.cloud/gcp/recaptchaenterprise_v1";
2import * as Data from "effect/Data";
3import * as Effect from "effect/Effect";
4import { Unowned } from "../../AdoptPolicy.ts";
5import { isResolved } from "../../Diff.ts";
6import * as Provider from "../../Provider.ts";
7import { Resource } from "../../Resource.ts";
8import { GcpEnvironment } from "../Environment.ts";
9import type { Providers } from "../Providers.ts";
10import {
11 firewallNameOf,
12 lastSegment,
13 sameText,
14 toGeneratedPath,
15 updateMaskOf,
16 getFirewallPolicy,
17} from "./internal.ts";
18
19export type FirewallActionSetHeader = {
20 /**
21 * Header name to set on the request.
22 */
23 key?: string;
24 /**
25 * Header value to set on the request.
26 */
27 value?: string;
28};
29
30export type FirewallActionSubstitute = {
31 /**
32 * Relative path to serve instead of the original request path.
33 */
34 path?: string;
35};
36
37export type FirewallAction = {
38 /**
39 * Allow the request. Terminal.
40 */
41 allow?: Record<string, never> | {};
42 /**
43 * Deny the request. Terminal.
44 */
45 block?: Record<string, never> | {};
46 /**
47 * Include the reCAPTCHA JavaScript on the page. Non-terminal.
48 */
49 includeRecaptchaScript?: Record<string, never> | {};
50 /**
51 * Redirect to a reCAPTCHA challenge page. Terminal.
52 */
53 redirect?: Record<string, never> | {};
54 /**
55 * Attach a request header. Non-terminal.
56 */
57 setHeader?: FirewallActionSetHeader;
58 /**
59 * Serve a different path. Terminal.
60 */
61 substitute?: FirewallActionSubstitute;
62};
63
64export type FirewallpolicyProps = {
65 /**
66 * Server-assigned policy id (the `{firewallpolicy}` segment of
67 * `projects/{project}/firewallpolicies/{firewallpolicy}`). Immutable —
68 * changing it replaces the policy.
69 */
70 firewallpolicyId?: string;
71 /**
72 * Request path glob this policy matches (max 200 characters). If
73 * omitted, a unique `/alc/{name}` path is generated.
74 */
75 path?: string;
76 /**
77 * Human-readable description (max 256 characters).
78 */
79 description?: string;
80 /**
81 * CEL condition. The policy applies when this evaluates to true and
82 * the path matches. Max 500 characters.
83 */
84 condition?: string;
85 /**
86 * Actions to take. At most one terminal action (`allow`, `block`,
87 * `redirect`, `substitute`). Up to 16 actions.
88 * @default [{ allow: {} }]
89 */
90 actions?: FirewallAction[];
91};
92
93export type Firewallpolicy = Resource<
94 "GCP.RecaptchaEnterprise.Firewallpolicy",
95 FirewallpolicyProps,
96 {
97 /** Full resource name `projects/{project}/firewallpolicies/{id}`. */
98 name: string;
99 /** Server-assigned policy id (last path segment). */
100 firewallpolicyId: string;
101 /** Project id used when the policy was reconciled. */
102 project: string;
103 /** Path glob this policy matches. */
104 path: string | undefined;
105 /** Policy description. */
106 description: string | undefined;
107 /** CEL condition, if set. */
108 condition: string | undefined;
109 /** Configured actions. */
110 actions: FirewallAction[];
111 },
112 never,
113 Providers
114>;
115
116/**
117 * A reCAPTCHA Enterprise firewall policy.
118 *
119 * Policies have no labels field and a server-assigned id, so only a
120 * recorded policy can be read back. The policy id is assigned by Google —
121 * changing `firewallpolicyId` replaces the policy. Path, description,
122 * condition, and actions update in place.
123 *
124 * ### Creating a Policy
125 * **Example:** Allow a login path
126 * ```typescript
127 * const policy = yield* GCP.RecaptchaEnterprise.Firewallpolicy("Login", {
128 * path: "/login",
129 * description: "allow login",
130 * actions: [{ allow: {} }],
131 * });
132 * ```
133 *
134 * **Example:** Block with a CEL condition
135 * ```typescript
136 * const policy = yield* GCP.RecaptchaEnterprise.Firewallpolicy("Bot", {
137 * path: "/checkout",
138 * condition: "true",
139 * actions: [{ block: {} }],
140 * });
141 * ```
142 *
143 * ### Updating a Policy
144 * **Example:** Change the path and action
145 * ```typescript
146 * // Same logical id as before; only the changed props differ.
147 * const policy = yield* GCP.RecaptchaEnterprise.Firewallpolicy("Login", {
148 * path: "/signin",
149 * description: "allow sign-in",
150 * actions: [{ allow: {} }],
151 * });
152 * ```
153 *
154 * @resource
155 * @category RecaptchaEnterprise
156 */
157export const Firewallpolicy = Resource<Firewallpolicy>(
158 "GCP.RecaptchaEnterprise.Firewallpolicy",
159);
160
161export class FirewallpolicyNotResolved extends Data.TaggedError(
162 "GCP.RecaptchaEnterprise.FirewallpolicyNotResolved",
163)<{
164 name: string;
165}> {}
166
167const DEFAULT_ACTIONS: FirewallAction[] = [{ allow: {} }];
168
169const emptyAction = (value: unknown) => (value === undefined ? undefined : {});
170
171const toAction = (
172 action:
173 | recaptchaenterprise.GoogleCloudRecaptchaenterpriseV1FirewallAction
174 | FirewallAction,
175): FirewallAction => ({
176 allow: emptyAction(action.allow),
177 block: emptyAction(action.block),
178 includeRecaptchaScript: emptyAction(action.includeRecaptchaScript),
179 redirect: emptyAction(action.redirect),
180 setHeader:
181 action.setHeader === undefined
182 ? undefined
183 : {
184 key: action.setHeader.key,
185 value: action.setHeader.value,
186 },
187 substitute:
188 action.substitute === undefined
189 ? undefined
190 : { path: action.substitute.path },
191});
192
193const compactAction = (action: FirewallAction): FirewallAction => {
194 const next: FirewallAction = {};
195 if (action.allow !== undefined) next.allow = {};
196 if (action.block !== undefined) next.block = {};
197 if (action.includeRecaptchaScript !== undefined) {
198 next.includeRecaptchaScript = {};
199 }
200 if (action.redirect !== undefined) next.redirect = {};
201 if (action.setHeader !== undefined) next.setHeader = action.setHeader;
202 if (action.substitute !== undefined) next.substitute = action.substitute;
203 return next;
204};
205
206const toActions = (
207 actions:
208 | readonly (
209 | recaptchaenterprise.GoogleCloudRecaptchaenterpriseV1FirewallAction
210 | FirewallAction
211 )[]
212 | undefined,
213): FirewallAction[] => (actions ?? []).map((action) => toAction(action));
214
215const desiredActions = (news: FirewallpolicyProps): FirewallAction[] =>
216 (news.actions ?? DEFAULT_ACTIONS).map(compactAction);
217
218const actionsFingerprint = (actions: readonly FirewallAction[]) =>
219 JSON.stringify(actions.map(compactAction));
220
221const toAttrs = (
222 policy: recaptchaenterprise.GoogleCloudRecaptchaenterpriseV1FirewallPolicy,
223 project: string,
224) => {
225 const name = policy.name ?? "";
226 return {
227 name,
228 firewallpolicyId: lastSegment(name),
229 project,
230 path: policy.path,
231 description: policy.description,
232 condition: policy.condition,
233 actions: toActions(policy.actions),
234 };
235};
236
237const toBody = (
238 path: string,
239 description: string | undefined,
240 condition: string | undefined,
241 actions: FirewallAction[],
242): recaptchaenterprise.GoogleCloudRecaptchaenterpriseV1FirewallPolicy => ({
243 path,
244 description,
245 condition,
246 actions,
247});
248
249export const FirewallpolicyProvider = () =>
250 Provider.succeed(Firewallpolicy, {
251 stables: ["name", "firewallpolicyId", "project"],
252
253 diff: Effect.fn(function* ({ news, olds, output }) {
254 if (!isResolved(news)) return undefined;
255 const previousId = olds?.firewallpolicyId ?? output?.firewallpolicyId;
256 if (
257 news.firewallpolicyId !== undefined &&
258 previousId !== undefined &&
259 news.firewallpolicyId !== previousId
260 ) {
261 return { action: "replace" as const, deleteFirst: false };
262 }
263 return undefined;
264 }),
265
266 read: Effect.fn(function* ({ olds, output }) {
267 const env = yield* GcpEnvironment.current;
268 // Server-assigned id: only a recorded policy can be observed.
269 const name =
270 output?.name ??
271 (olds?.firewallpolicyId !== undefined
272 ? firewallNameOf(env.project, olds.firewallpolicyId)
273 : "");
274 const existing = yield* getFirewallPolicy(name);
275 if (existing === undefined) return undefined;
276 const attrs = toAttrs(existing, env.project);
277 return output !== undefined ? attrs : Unowned(attrs);
278 }),
279
280 reconcile: Effect.fn(function* ({ id, news, output }) {
281 const env = yield* GcpEnvironment.current;
282 const description = news.description;
283 const path = yield* toGeneratedPath(id, news.path);
284 const actions = desiredActions(news);
285 const condition = news.condition;
286 const lookupName =
287 output?.name ??
288 (news.firewallpolicyId !== undefined
289 ? firewallNameOf(env.project, news.firewallpolicyId)
290 : output?.firewallpolicyId !== undefined
291 ? firewallNameOf(env.project, output.firewallpolicyId)
292 : "");
293
294 let current = yield* getFirewallPolicy(lookupName);
295
296 if (current === undefined) {
297 const created = yield* recaptchaenterprise
298 .createProjectsFirewallpolicies({
299 parent: `projects/${env.project}`,
300 body: toBody(path, description, condition, actions),
301 })
302 .pipe(
303 Effect.catchTag("Conflict", () => getFirewallPolicy(lookupName)),
304 );
305 current = created ?? undefined;
306 }
307
308 if (current === undefined) {
309 return yield* new FirewallpolicyNotResolved({
310 name: lookupName.length > 0 ? lookupName : path,
311 });
312 }
313
314 const name = current.name ?? lookupName;
315 const pathChanged = !sameText(current.path, path);
316 const descriptionChanged = !sameText(current.description, description);
317 const conditionChanged = !sameText(current.condition, condition);
318 const actionsChanged =
319 actionsFingerprint(toActions(current.actions)) !==
320 actionsFingerprint(actions);
321
322 const updateMask = updateMaskOf(
323 pathChanged ? "path" : undefined,
324 descriptionChanged ? "description" : undefined,
325 conditionChanged ? "condition" : undefined,
326 actionsChanged ? "actions" : undefined,
327 );
328
329 if (updateMask.length > 0) {
330 current = yield* recaptchaenterprise.patchProjectsFirewallpolicies({
331 name,
332 updateMask,
333 body: { ...toBody(path, description, condition, actions), name },
334 });
335 }
336
337 return toAttrs(current, env.project);
338 }),
339
340 delete: Effect.fn(function* ({ output }) {
341 if (output.name.length === 0) return;
342 yield* recaptchaenterprise
343 .deleteProjectsFirewallpolicies({ name: output.name })
344 .pipe(Effect.catchTag("NotFound", () => Effect.void));
345 }),
346 });