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 { GcpEnvironment } from "../Environment.ts";
10import type { Providers } from "../Providers.ts";
11import {
12 lastSegment,
13 orgParent,
14 organizationFromName,
15 sameJson,
16 toResourceId,
17} from "./names.ts";
18
19const MAX_NAME_LENGTH = 59;
20const ALCHEMY_ID_PREFIX = "alc-";
21
22export type MonitoredResource = {
23 /** Resource id (API proxy id or API Hub deployment id). */
24 name: string;
25 /** Resource type. */
26 type:
27 | apigee.GoogleCloudApigeeV1BatchComputeSecurityAssessmentResultsRequestResourceArrayResourceTypeEnum
28 | (string & {});
29};
30
31export type SecurityMonitoringConditionProps = {
32 /**
33 * Apigee organization id. Defaults to the current GCP project id.
34 * Immutable — changing it replaces the condition.
35 */
36 organization?: string;
37 /**
38 * Condition id (the `{security_monitoring_condition}` segment of
39 * `organizations/{org}/securityMonitoringConditions/{id}`). If
40 * omitted, a unique `alc-` prefixed name is generated. 4–63
41 * characters, lowercase letters and hyphens. Immutable — changing it
42 * replaces the condition.
43 */
44 securityMonitoringConditionId?: string;
45 /**
46 * Security profile id or full name this condition scores against.
47 * Required.
48 */
49 profile: string;
50 /**
51 * Risk assessment type.
52 * @default "APIGEE"
53 */
54 riskAssessmentType?:
55 | apigee.GoogleCloudApigeeV1SecurityMonitoringConditionRiskAssessmentTypeEnum
56 | (string & {});
57 /**
58 * Environment scope when `riskAssessmentType` is `APIGEE`.
59 */
60 scope?: string;
61 /**
62 * API Hub gateway when `riskAssessmentType` is `API_HUB`
63 * (`projects/{project}/locations/{location}/plugins/{plugin}/instances/{instance}`).
64 */
65 apiHubGateway?: string;
66 /**
67 * When true, include every resource under `scope`. Mutually exclusive
68 * with `include`.
69 * @default false
70 */
71 includeAllResources?: boolean;
72 /**
73 * Explicit resources to include. Mutually exclusive with
74 * `includeAllResources`.
75 */
76 include?: MonitoredResource[];
77};
78
79export type SecurityMonitoringCondition = Resource<
80 "GCP.Apigee.SecurityMonitoringCondition",
81 SecurityMonitoringConditionProps,
82 {
83 /** Full resource name `organizations/{org}/securityMonitoringConditions/{id}`. */
84 name: string;
85 /** Condition id (last path segment). */
86 securityMonitoringConditionId: string;
87 /** Apigee organization id. */
88 organization: string;
89 /** Security profile id. */
90 profile: string;
91 /** Risk assessment type. */
92 riskAssessmentType: string | undefined;
93 /** Environment scope. */
94 scope: string | undefined;
95 /** API Hub gateway, if set. */
96 apiHubGateway: string | undefined;
97 /** Whether all resources under scope are included. */
98 includeAllResources: boolean;
99 /** Explicit included resources. */
100 include: MonitoredResource[];
101 /** Total deployed resources in scope. */
102 totalDeployedResources: number | undefined;
103 /** Total monitored resources. */
104 totalMonitoredResources: number | undefined;
105 /** RFC3339 creation timestamp. */
106 createTime: string | undefined;
107 /** RFC3339 last-update timestamp. */
108 updateTime: string | undefined;
109 },
110 never,
111 Providers
112>;
113
114/**
115 * An Advanced API Security monitoring condition (risk assessment v2).
116 *
117 * Conditions have no labels or description. Generated ids are prefixed
118 * `alc-` so `list` / nuke can distinguish Alchemy rows from
119 * Google-defined conditions. Organization and id are identity —
120 * changing them replaces the condition. Profile, scope, include, and
121 * include-all update in place.
122 *
123 * ### Creating a Condition
124 * **Example:** Monitor every proxy in an environment
125 * ```typescript
126 * const profile = yield* GCP.Apigee.SecurityProfilesV2("Default", {});
127 * const condition = yield* GCP.Apigee.SecurityMonitoringCondition("Eval", {
128 * profile: profile.name,
129 * scope: "eval",
130 * includeAllResources: true,
131 * });
132 * ```
133 *
134 * @resource
135 * @category Apigee
136 */
137export const SecurityMonitoringCondition =
138 Resource<SecurityMonitoringCondition>(
139 "GCP.Apigee.SecurityMonitoringCondition",
140 );
141
142export class SecurityMonitoringConditionNotResolved extends Data.TaggedError(
143 "GCP.Apigee.SecurityMonitoringConditionNotResolved",
144)<{
145 name: string;
146}> {}
147
148const resourceName = (organization: string, conditionId: string) =>
149 `${orgParent(organization)}/securityMonitoringConditions/${conditionId}`;
150
151const conditionIdOf = (
152 condition: apigee.GoogleCloudApigeeV1SecurityMonitoringCondition,
153) => lastSegment(condition.name ?? "");
154
155const toConditionId = (
156 id: string,
157 explicit: string | undefined,
158 existing: string | undefined,
159) =>
160 Effect.gen(function* () {
161 if (explicit !== undefined) return explicit;
162 if (existing !== undefined) return existing;
163 const generated = yield* toResourceId(
164 id,
165 undefined,
166 undefined,
167 MAX_NAME_LENGTH,
168 );
169 return generated.startsWith(ALCHEMY_ID_PREFIX)
170 ? generated
171 : `${ALCHEMY_ID_PREFIX}${generated}`.slice(0, 63);
172 });
173
174const isAlchemyId = (conditionId: string) =>
175 conditionId.startsWith(ALCHEMY_ID_PREFIX);
176
177const includeOf = (
178 include:
179 | apigee.GoogleCloudApigeeV1BatchComputeSecurityAssessmentResultsRequestResourceArray
180 | undefined,
181): MonitoredResource[] =>
182 (include?.resources ?? []).flatMap((resource) =>
183 resource.name !== undefined && resource.type !== undefined
184 ? [{ name: resource.name, type: resource.type }]
185 : [],
186 );
187
188const toAttrs = (
189 condition: apigee.GoogleCloudApigeeV1SecurityMonitoringCondition,
190 organization: string,
191) => {
192 const securityMonitoringConditionId = conditionIdOf(condition);
193 const name = condition.name?.includes("/")
194 ? condition.name
195 : resourceName(organization, securityMonitoringConditionId);
196 return {
197 name,
198 securityMonitoringConditionId,
199 organization: organizationFromName(name) ?? organization,
200 profile: condition.profile ?? "",
201 riskAssessmentType: condition.riskAssessmentType,
202 scope: condition.scope,
203 apiHubGateway: condition.apiHubGateway,
204 includeAllResources: condition.includeAllResources !== undefined,
205 include: includeOf(condition.include),
206 totalDeployedResources: condition.totalDeployedResources,
207 totalMonitoredResources: condition.totalMonitoredResources,
208 createTime: condition.createTime,
209 updateTime: condition.updateTime,
210 };
211};
212
213const getByName = (name: string) =>
214 apigee
215 .getOrganizationsSecurityMonitoringConditions({ name })
216 .pipe(
217 Effect.catchTag(["NotFound", "ApigeeResourceNotFound"], () =>
218 Effect.succeed(undefined),
219 ),
220 );
221
222const toBody = (
223 news: SecurityMonitoringConditionProps,
224): apigee.GoogleCloudApigeeV1SecurityMonitoringCondition => ({
225 profile: news.profile,
226 riskAssessmentType: news.riskAssessmentType,
227 scope: news.scope,
228 apiHubGateway: news.apiHubGateway,
229 includeAllResources: news.includeAllResources === true ? {} : undefined,
230 include:
231 news.includeAllResources === true
232 ? undefined
233 : news.include !== undefined
234 ? { resources: news.include }
235 : undefined,
236});
237
238export const SecurityMonitoringConditionProvider = () =>
239 Provider.succeed(SecurityMonitoringCondition, {
240 stables: [
241 "name",
242 "securityMonitoringConditionId",
243 "organization",
244 "createTime",
245 ],
246
247 diff: Effect.fn(function* ({ news, olds, output }) {
248 if (!isResolved(news)) return undefined;
249 const previousId =
250 olds?.securityMonitoringConditionId ??
251 output?.securityMonitoringConditionId;
252 const previousOrg = olds?.organization ?? output?.organization;
253 if (
254 (previousId !== undefined &&
255 news.securityMonitoringConditionId !== undefined &&
256 news.securityMonitoringConditionId !== previousId) ||
257 (previousOrg !== undefined &&
258 news.organization !== undefined &&
259 news.organization !== previousOrg)
260 ) {
261 return { action: "replace" as const, deleteFirst: true };
262 }
263 return undefined;
264 }),
265
266 read: Effect.fn(function* ({ id, olds, output }) {
267 const env = yield* GcpEnvironment.current;
268 const organization =
269 organizationFromName(output?.name) ?? olds?.organization ?? env.project;
270 const securityMonitoringConditionId = yield* toConditionId(
271 id,
272 olds?.securityMonitoringConditionId,
273 output?.securityMonitoringConditionId,
274 );
275 const name =
276 output?.name ??
277 resourceName(organization, securityMonitoringConditionId);
278 const existing = yield* getByName(name);
279 if (existing === undefined) return undefined;
280 const attrs = toAttrs(existing, organization);
281 return isAlchemyId(attrs.securityMonitoringConditionId) ||
282 (olds?.securityMonitoringConditionId !== undefined &&
283 olds.securityMonitoringConditionId ===
284 attrs.securityMonitoringConditionId)
285 ? attrs
286 : Unowned(attrs);
287 }),
288
289 list: () =>
290 Effect.gen(function* () {
291 const env = yield* GcpEnvironment.current;
292 return yield* apigee.listOrganizationsSecurityMonitoringConditions
293 .pages({
294 parent: orgParent(env.project),
295 pageSize: 1000,
296 })
297 .pipe(
298 Stream.flatMap((page) =>
299 Stream.fromIterable(page.securityMonitoringConditions ?? []),
300 ),
301 Stream.filter((condition) => isAlchemyId(conditionIdOf(condition))),
302 Stream.map((condition) => toAttrs(condition, env.project)),
303 Stream.runCollect,
304 Effect.map((chunk) => Array.from(chunk)),
305 Effect.catchTag(["NotFound", "ApigeeResourceNotFound"], () =>
306 Effect.succeed([] as SecurityMonitoringCondition["Attributes"][]),
307 ),
308 );
309 }),
310
311 reconcile: Effect.fn(function* ({ id, news, output }) {
312 const env = yield* GcpEnvironment.current;
313 const organization =
314 news.organization ?? output?.organization ?? env.project;
315 const securityMonitoringConditionId = yield* toConditionId(
316 id,
317 news.securityMonitoringConditionId,
318 output?.securityMonitoringConditionId,
319 );
320 const name = resourceName(organization, securityMonitoringConditionId);
321
322 let current = yield* getByName(output?.name ?? name);
323
324 if (current === undefined) {
325 const created = yield* apigee
326 .createOrganizationsSecurityMonitoringConditions({
327 parent: orgParent(organization),
328 securityMonitoringConditionId,
329 body: toBody(news),
330 })
331 .pipe(Effect.catchTag("Conflict", () => getByName(name)));
332 current = created ?? undefined;
333 }
334
335 if (current === undefined) {
336 return yield* new SecurityMonitoringConditionNotResolved({ name });
337 }
338
339 const desiredIncludeAll = news.includeAllResources === true;
340 const observedIncludeAll = current.includeAllResources !== undefined;
341 const profileChanged = (current.profile ?? "") !== news.profile;
342 const scopeChanged = (current.scope ?? "") !== (news.scope ?? "");
343 const gatewayChanged =
344 (current.apiHubGateway ?? "") !== (news.apiHubGateway ?? "");
345 const includeAllChanged = desiredIncludeAll !== observedIncludeAll;
346 const includeChanged = !sameJson(
347 includeOf(current.include),
348 news.include ?? [],
349 );
350 const typeChanged =
351 (current.riskAssessmentType ?? "") !==
352 (news.riskAssessmentType ?? current.riskAssessmentType ?? "");
353
354 const updateMask = [
355 profileChanged ? "profile" : undefined,
356 scopeChanged ? "scope" : undefined,
357 gatewayChanged ? "apiHubGateway" : undefined,
358 includeAllChanged ? "include_all_resources" : undefined,
359 includeChanged ? "include" : undefined,
360 typeChanged ? "riskAssessmentType" : undefined,
361 ].filter((field): field is string => field !== undefined);
362
363 if (updateMask.length > 0) {
364 current = yield* apigee.patchOrganizationsSecurityMonitoringConditions({
365 name: current.name?.includes("/") ? current.name : name,
366 updateMask: updateMask.join(","),
367 body: toBody(news),
368 });
369 }
370
371 return toAttrs(current, organization);
372 }),
373
374 delete: Effect.fn(function* ({ output }) {
375 yield* apigee
376 .deleteOrganizationsSecurityMonitoringConditions({
377 name: output.name,
378 })
379 .pipe(
380 Effect.catchTag(
381 ["NotFound", "ApigeeResourceNotFound"],
382 () => Effect.void,
383 ),
384 );
385 }),
386 });