| 1 | import * as apigee from "@distilled.cloud/gcp/apigee_v1"; |
| 2 | import * as Data from "effect/Data"; |
| 3 | import * as Effect from "effect/Effect"; |
| 4 | import * as Stream from "effect/Stream"; |
| 5 | import { Unowned } from "../../AdoptPolicy.ts"; |
| 6 | import { isResolved } from "../../Diff.ts"; |
| 7 | import * as Provider from "../../Provider.ts"; |
| 8 | import { Resource } from "../../Resource.ts"; |
| 9 | import { tagRecord } from "../../Tags.ts"; |
| 10 | import { GcpEnvironment } from "../Environment.ts"; |
| 11 | import { createInternalLabels } from "../Labels.ts"; |
| 12 | import type { Providers } from "../Providers.ts"; |
| 13 | import { |
| 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 | |
| 30 | const MAX_NAME_LENGTH = 63; |
| 31 | const DEFAULT_STATE = "ENABLED"; |
| 32 | |
| 33 | export 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 | |
| 58 | export type SecurityActionDeny = { |
| 59 | /** HTTP response code when denying. */ |
| 60 | responseCode?: number; |
| 61 | }; |
| 62 | |
| 63 | export type SecurityActionFlagHeader = { |
| 64 | /** Header name sent to the target. */ |
| 65 | name?: string; |
| 66 | /** Header value sent to the target. */ |
| 67 | value?: string; |
| 68 | }; |
| 69 | |
| 70 | export type SecurityActionFlag = { |
| 71 | /** Headers added when flagging. At least one is required. */ |
| 72 | headers?: SecurityActionFlagHeader[]; |
| 73 | }; |
| 74 | |
| 75 | export 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 | |
| 131 | export 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 | */ |
| 189 | export const EnvironmentsSecurityAction = Resource<EnvironmentsSecurityAction>( |
| 190 | "GCP.Apigee.EnvironmentsSecurityAction", |
| 191 | ); |
| 192 | |
| 193 | export class EnvironmentsSecurityActionNotResolved extends Data.TaggedError( |
| 194 | "GCP.Apigee.EnvironmentsSecurityActionNotResolved", |
| 195 | )<{ |
| 196 | name: string; |
| 197 | }> {} |
| 198 | |
| 199 | const resourceName = ( |
| 200 | organizationId: string, |
| 201 | environmentId: string, |
| 202 | securityActionId: string, |
| 203 | ) => |
| 204 | `${environmentNameOf(organizationId, environmentId)}/securityActions/${securityActionId}`; |
| 205 | |
| 206 | const 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 | |
| 225 | const 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 | |
| 267 | const getByName = (name: string) => |
| 268 | missingToUndefined( |
| 269 | apigee.getOrganizationsEnvironmentsSecurityActions({ name }), |
| 270 | ); |
| 271 | |
| 272 | const 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 | |
| 289 | const jsonOf = (value: unknown) => JSON.stringify(value ?? null); |
| 290 | |
| 291 | export 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 | }); |