1import * as apigee from "@distilled.cloud/gcp/apigee_v1";
2import * as Data from "effect/Data";
3import * as Effect from "effect/Effect";
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 { createInternalLabels } from "../Labels.ts";
12import type { Providers } from "../Providers.ts";
13import {
14 encodeDescription,
15 environmentIdOf,
16 environmentNameOf,
17 hasOwnershipMarker,
18 lastSegment,
19 listProjectEnvironments,
20 missingToUndefined,
21 organizationIdOf,
22 ownedById,
23 parseDescription,
24 parseOrgEnv,
25 sameList,
26 sameText,
27 toResourceId,
28} from "./common.ts";
29
30const MAX_NAME_LENGTH = 63;
31const DEFAULT_STATE = "ENABLED";
32
33export type SecurityActionConditionConfig = {
34 /** Developer emails. Limit 1000. */
35 developers?: string[];
36 /** IPv4 or IPv6 ranges. Limit 100. */
37 ipAddressRanges?: string[];
38 /** API keys. Limit 1000. */
39 apiKeys?: string[];
40 /** Developer app names. Limit 1000. */
41 developerApps?: string[];
42 /** Exact user-agent matches. Limit 50. */
43 userAgents?: string[];
44 /** API product names. Limit 1000. */
45 apiProducts?: string[];
46 /** Access tokens. Limit 1000. */
47 accessTokens?: string[];
48 /** ASN numbers. */
49 asns?: string[];
50 /** Bot reasons (`Flooder`, `Robot Abuser`, …). */
51 botReasons?: string[];
52 /** ISO 3166-1 alpha-2 region codes. */
53 regionCodes?: string[];
54 /** HTTP methods (`GET`, `POST`, …). */
55 httpMethods?: string[];
56};
57
58export type SecurityActionDeny = {
59 /** HTTP response code when denying. */
60 responseCode?: number;
61};
62
63export type SecurityActionFlagHeader = {
64 /** Header name sent to the target. */
65 name?: string;
66 /** Header value sent to the target. */
67 value?: string;
68};
69
70export type SecurityActionFlag = {
71 /** Headers added when flagging. At least one is required. */
72 headers?: SecurityActionFlagHeader[];
73};
74
75export type EnvironmentsSecurityActionProps = {
76 /**
77 * Apigee organization id or `organizations/{org}`. Defaults to the
78 * current GCP project id. Immutable — changing it replaces the action.
79 */
80 organization?: string;
81 /**
82 * Environment id or `organizations/{org}/environments/{env}`. Immutable —
83 * changing it replaces the action.
84 */
85 environment: string;
86 /**
87 * Security action id (rfc1035, 1-63 chars). If omitted, a unique name
88 * is generated. Immutable — changing it replaces the action.
89 */
90 securityActionId?: string;
91 /**
92 * Human-readable description. Alchemy stamps ownership into a
93 * `[alchemy …]` prefix because security actions have no labels field.
94 */
95 description?: string;
96 /**
97 * Enforcement state. Only `ENABLED` actions are enforced.
98 * @default "ENABLED"
99 */
100 state?: apigee.GoogleCloudApigeeV1SecurityActionStateEnum | (string & {});
101 /**
102 * Conditions that must match. At least one field is required.
103 */
104 conditionConfig: SecurityActionConditionConfig;
105 /**
106 * Deny matching requests. Mutually exclusive with `allow` and `flag`.
107 */
108 deny?: SecurityActionDeny;
109 /**
110 * Allow matching requests through.
111 */
112 allow?: boolean;
113 /**
114 * Flag matching requests by adding headers.
115 */
116 flag?: SecurityActionFlag;
117 /**
118 * Input-only TTL (e.g. `"3600s"`).
119 */
120 ttl?: string;
121 /**
122 * Expiration timestamp.
123 */
124 expireTime?: string;
125 /**
126 * Limit enforcement to these API proxies. Omitted applies to all.
127 */
128 apiProxies?: string[];
129};
130
131export type EnvironmentsSecurityAction = Resource<
132 "GCP.Apigee.EnvironmentsSecurityAction",
133 EnvironmentsSecurityActionProps,
134 {
135 /** Full resource name `organizations/{org}/environments/{env}/securityActions/{id}`. */
136 name: string;
137 /** Security action id. */
138 securityActionId: string;
139 /** Apigee organization id. */
140 organizationId: string;
141 /** Environment id. */
142 environmentId: string;
143 /** User description with the Alchemy ownership prefix stripped. */
144 description: string | undefined;
145 /** Enforcement state. */
146 state: string | undefined;
147 /** Condition configuration. */
148 conditionConfig: SecurityActionConditionConfig | undefined;
149 /** Deny action, if set. */
150 deny: SecurityActionDeny | undefined;
151 /** Whether this is an allow action. */
152 allow: boolean;
153 /** Flag action, if set. */
154 flag: SecurityActionFlag | undefined;
155 /** Expiration timestamp. */
156 expireTime: string | undefined;
157 /** API proxies this action applies to. */
158 apiProxies: string[];
159 /** RFC3339 create time. */
160 createTime: string | undefined;
161 /** RFC3339 update time. */
162 updateTime: string | undefined;
163 },
164 never,
165 Providers
166>;
167
168/**
169 * An environment-level Apigee SecurityAction — allow, deny, or flag
170 * requests that match a condition.
171 *
172 * Security actions have no labels, so Alchemy stamps ownership into
173 * `description` for `list` / nuke. Name is identity; description, state,
174 * conditions, and the allow/deny/flag payload update in place.
175 *
176 * ### Creating a Deny Action
177 * **Example:** Deny a TEST-NET address
178 * ```typescript
179 * const action = yield* GCP.Apigee.EnvironmentsSecurityAction("BlockProbe", {
180 * environment: "eval",
181 * conditionConfig: { ipAddressRanges: ["192.0.2.1"] },
182 * deny: { responseCode: 403 },
183 * });
184 * ```
185 *
186 * @resource
187 * @category Apigee
188 */
189export const EnvironmentsSecurityAction = Resource<EnvironmentsSecurityAction>(
190 "GCP.Apigee.EnvironmentsSecurityAction",
191);
192
193export class EnvironmentsSecurityActionNotResolved extends Data.TaggedError(
194 "GCP.Apigee.EnvironmentsSecurityActionNotResolved",
195)<{
196 name: string;
197}> {}
198
199const resourceName = (
200 organizationId: string,
201 environmentId: string,
202 securityActionId: string,
203) =>
204 `${environmentNameOf(organizationId, environmentId)}/securityActions/${securityActionId}`;
205
206const conditionOf = (
207 config: apigee.GoogleCloudApigeeV1SecurityActionConditionConfig | undefined,
208): SecurityActionConditionConfig | undefined => {
209 if (config === undefined) return undefined;
210 return {
211 developers: config.developers,
212 ipAddressRanges: config.ipAddressRanges,
213 apiKeys: config.apiKeys,
214 developerApps: config.developerApps,
215 userAgents: config.userAgents,
216 apiProducts: config.apiProducts,
217 accessTokens: config.accessTokens,
218 asns: config.asns,
219 botReasons: config.botReasons,
220 regionCodes: config.regionCodes,
221 httpMethods: config.httpMethods,
222 };
223};
224
225const toAttrs = (
226 action: apigee.GoogleCloudApigeeV1SecurityAction,
227 organizationId: string,
228 environmentId: string,
229) => {
230 const raw = action.name ?? "";
231 const parsed = parseOrgEnv(raw);
232 const securityActionId = lastSegment(raw);
233 const description = parseDescription(action.description);
234 return {
235 name: raw.includes("/")
236 ? raw
237 : resourceName(organizationId, environmentId, securityActionId || raw),
238 securityActionId: securityActionId || raw,
239 organizationId: parsed.organizationId || organizationId,
240 environmentId: parsed.environmentId || environmentId,
241 description: description.description,
242 state: action.state,
243 conditionConfig: conditionOf(action.conditionConfig),
244 deny:
245 action.deny?.responseCode !== undefined
246 ? { responseCode: action.deny.responseCode }
247 : action.deny
248 ? {}
249 : undefined,
250 allow: action.allow !== undefined,
251 flag:
252 action.flag?.headers !== undefined
253 ? {
254 headers: (action.flag.headers ?? []).map((header) => ({
255 name: header.name,
256 value: header.value,
257 })),
258 }
259 : undefined,
260 expireTime: action.expireTime,
261 apiProxies: action.apiProxies ?? [],
262 createTime: action.createTime,
263 updateTime: action.updateTime,
264 };
265};
266
267const getByName = (name: string) =>
268 missingToUndefined(
269 apigee.getOrganizationsEnvironmentsSecurityActions({ name }),
270 );
271
272const toBody = (
273 news: EnvironmentsSecurityActionProps,
274 description: string,
275 securityActionId: string,
276): apigee.GoogleCloudApigeeV1SecurityAction => ({
277 name: securityActionId,
278 description,
279 state: news.state ?? DEFAULT_STATE,
280 conditionConfig: news.conditionConfig,
281 deny: news.deny,
282 allow: news.allow === true ? {} : undefined,
283 flag: news.flag,
284 ttl: news.ttl,
285 expireTime: news.expireTime,
286 apiProxies: news.apiProxies,
287});
288
289const jsonOf = (value: unknown) => JSON.stringify(value ?? null);
290
291export const EnvironmentsSecurityActionProvider = () =>
292 Provider.succeed(EnvironmentsSecurityAction, {
293 stables: [
294 "name",
295 "securityActionId",
296 "organizationId",
297 "environmentId",
298 "createTime",
299 ],
300
301 diff: Effect.fn(function* ({ news, olds, output }) {
302 if (!isResolved(news)) return undefined;
303 const previousId = olds?.securityActionId ?? output?.securityActionId;
304 const previousOrg = olds?.organization ?? output?.organizationId;
305 const previousEnv = olds?.environment ?? output?.environmentId;
306 const idChanged =
307 previousId !== undefined &&
308 news.securityActionId !== undefined &&
309 news.securityActionId !== previousId;
310 const orgChanged =
311 previousOrg !== undefined &&
312 news.organization !== undefined &&
313 organizationIdOf(news.organization, "") !==
314 organizationIdOf(previousOrg, "");
315 const envChanged =
316 previousEnv !== undefined &&
317 environmentIdOf(news.environment) !== environmentIdOf(previousEnv);
318 if (idChanged || orgChanged || envChanged) {
319 return { action: "replace" as const, deleteFirst: false };
320 }
321 return undefined;
322 }),
323
324 read: Effect.fn(function* ({ id, olds, output }) {
325 const { project } = yield* GcpEnvironment.current;
326 const organizationId = organizationIdOf(
327 olds?.organization ?? output?.organizationId,
328 project,
329 );
330 const environmentId = environmentIdOf(
331 olds?.environment ?? output?.environmentId ?? "",
332 );
333 const securityActionId = yield* toResourceId(
334 id,
335 olds?.securityActionId,
336 output?.securityActionId,
337 { maxLength: MAX_NAME_LENGTH, rfc1035: true },
338 );
339 const name =
340 output?.name ??
341 resourceName(organizationId, environmentId, securityActionId);
342 const existing = yield* getByName(name);
343 if (existing === undefined) return undefined;
344 const attrs = toAttrs(existing, organizationId, environmentId);
345 const { labels } = parseDescription(existing.description);
346 return (yield* ownedById(id, tagRecord(labels))) ? attrs : Unowned(attrs);
347 }),
348
349 list: () =>
350 Effect.gen(function* () {
351 const environments = yield* listProjectEnvironments();
352 const found: EnvironmentsSecurityAction["Attributes"][] = [];
353 for (const item of environments) {
354 const actions =
355 yield* apigee.listOrganizationsEnvironmentsSecurityActions
356 .pages({ parent: item.parent, pageSize: 100 })
357 .pipe(
358 Stream.flatMap((page) =>
359 Stream.fromIterable(page.securityActions ?? []),
360 ),
361 Stream.filter((action) =>
362 hasOwnershipMarker(action.description),
363 ),
364 Stream.map((action) =>
365 toAttrs(action, item.organizationId, item.environmentId),
366 ),
367 Stream.runCollect,
368 Effect.map((chunk) => Array.from(chunk)),
369 Effect.catchTag(["NotFound", "ApigeeResourceNotFound"], () =>
370 Effect.succeed(
371 [] as EnvironmentsSecurityAction["Attributes"][],
372 ),
373 ),
374 );
375 found.push(...actions);
376 }
377 return found;
378 }),
379
380 reconcile: Effect.fn(function* ({ id, news, output }) {
381 const { project } = yield* GcpEnvironment.current;
382 const organizationId = organizationIdOf(news.organization, project);
383 const environmentId = environmentIdOf(news.environment);
384 const securityActionId = yield* toResourceId(
385 id,
386 news.securityActionId,
387 output?.securityActionId,
388 { maxLength: MAX_NAME_LENGTH, rfc1035: true },
389 );
390 const parent = environmentNameOf(organizationId, environmentId);
391 const name = resourceName(
392 organizationId,
393 environmentId,
394 securityActionId,
395 );
396 const ownership = yield* createInternalLabels(id);
397 const desiredDescription = encodeDescription(ownership, news.description);
398 const desiredState = news.state ?? DEFAULT_STATE;
399
400 let current = yield* getByName(output?.name ?? name);
401
402 if (current === undefined) {
403 const created = yield* apigee
404 .createOrganizationsEnvironmentsSecurityActions({
405 parent,
406 securityActionId,
407 body: toBody(news, desiredDescription, securityActionId),
408 })
409 .pipe(Effect.catchTag("Conflict", () => getByName(name)));
410 current = created ?? undefined;
411 }
412
413 if (current === undefined) {
414 return yield* new EnvironmentsSecurityActionNotResolved({ name });
415 }
416
417 const descriptionChanged = !sameText(
418 current.description,
419 desiredDescription,
420 );
421 const stateChanged = !sameText(current.state, desiredState);
422 const conditionChanged =
423 jsonOf(conditionOf(current.conditionConfig)) !==
424 jsonOf(news.conditionConfig);
425 const denyChanged = jsonOf(current.deny) !== jsonOf(news.deny);
426 const allowChanged =
427 (current.allow !== undefined) !== (news.allow === true);
428 const flagChanged = jsonOf(current.flag) !== jsonOf(news.flag);
429 const expireChanged = !sameText(current.expireTime, news.expireTime);
430 const proxiesChanged = !sameList(current.apiProxies, news.apiProxies);
431
432 if (
433 descriptionChanged ||
434 stateChanged ||
435 conditionChanged ||
436 denyChanged ||
437 allowChanged ||
438 flagChanged ||
439 expireChanged ||
440 proxiesChanged
441 ) {
442 current = yield* apigee.patchOrganizationsEnvironmentsSecurityActions({
443 name,
444 updateMask: [
445 descriptionChanged ? "description" : undefined,
446 stateChanged ? "state" : undefined,
447 conditionChanged ? "condition_config" : undefined,
448 denyChanged ? "deny" : undefined,
449 allowChanged ? "allow" : undefined,
450 flagChanged ? "flag" : undefined,
451 expireChanged ? "expire_time" : undefined,
452 proxiesChanged ? "api_proxies" : undefined,
453 ]
454 .filter((field): field is string => field !== undefined)
455 .join(","),
456 body: toBody(news, desiredDescription, securityActionId),
457 });
458 }
459
460 return toAttrs(current, organizationId, environmentId);
461 }),
462
463 delete: Effect.fn(function* ({ output }) {
464 yield* apigee
465 .deleteOrganizationsEnvironmentsSecurityActions({ name: output.name })
466 .pipe(
467 Effect.catchTag(
468 ["NotFound", "ApigeeResourceNotFound"],
469 () => Effect.void,
470 ),
471 );
472 }),
473 });