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";
18import {
19 createInternalLabels,
20 encodeOwnership,
21 hasAlchemyLabels,
22 hasOwnershipMarker,
23 parseOwnership,
24} from "./ownership.ts";
25
26const MAX_NAME_LENGTH = 63;
27
28export type ProfileAssessmentConfig = {
29 /**
30 * Weight of this assessment.
31 */
32 weight?:
33 | apigee.GoogleCloudApigeeV1SecurityProfileV2ProfileAssessmentConfigWeightEnum
34 | (string & {});
35 /**
36 * API Hub gateway types to include.
37 */
38 include?: Array<
39 | apigee.GoogleCloudApigeeV1SecurityProfileV2ProfileAssessmentConfigApiHubGatewayTypeArrayGatewayTypesItemEnum
40 | (string & {})
41 >;
42};
43
44export type SecurityProfilesV2Props = {
45 /**
46 * Apigee organization id. Defaults to the current GCP project id.
47 * Immutable — changing it replaces the profile.
48 */
49 organization?: string;
50 /**
51 * Profile id (the `{profile}` segment of
52 * `organizations/{org}/securityProfilesV2/{profile}`). If omitted, a
53 * unique name is generated from the stack, stage, and logical id.
54 * Immutable — changing it replaces the profile.
55 */
56 securityProfileV2Id?: string;
57 /**
58 * Human-readable description. Alchemy ownership is stored in a
59 * `[alchemy …]` prefix and stripped from attributes. Profiles have no
60 * labels field.
61 */
62 description?: string;
63 /**
64 * Risk assessment type.
65 * @default "APIGEE"
66 */
67 riskAssessmentType?:
68 | apigee.GoogleCloudApigeeV1SecurityProfileV2RiskAssessmentTypeEnum
69 | (string & {});
70 /**
71 * Configuration for each assessment, keyed by assessment id.
72 * @default `{ authorization: { weight: "MODERATE" }, threat: { weight: "MODERATE" } }`
73 */
74 profileAssessmentConfigs?: Record<string, ProfileAssessmentConfig>;
75};
76
77export type SecurityProfilesV2 = Resource<
78 "GCP.Apigee.SecurityProfilesV2",
79 SecurityProfilesV2Props,
80 {
81 /** Full resource name `organizations/{org}/securityProfilesV2/{profile}`. */
82 name: string;
83 /** Profile id (last path segment). */
84 securityProfileV2Id: string;
85 /** Apigee organization id. */
86 organization: string;
87 /** User description with the Alchemy ownership prefix stripped. */
88 description: string | undefined;
89 /** Risk assessment type. */
90 riskAssessmentType: string | undefined;
91 /** Assessment configuration map. */
92 profileAssessmentConfigs: Record<string, ProfileAssessmentConfig>;
93 /** Whether this profile is Google-defined. */
94 googleDefined: boolean | undefined;
95 /** RFC3339 creation timestamp. */
96 createTime: string | undefined;
97 /** RFC3339 last-update timestamp. */
98 updateTime: string | undefined;
99 },
100 never,
101 Providers
102>;
103
104/**
105 * An Advanced API Security profile (risk assessment v2).
106 *
107 * Profiles have no labels field, so Alchemy stamps ownership into the
108 * description for `list` / nuke. Google-defined profiles are ignored by
109 * `list`. Name and organization are identity — changing them replaces
110 * the profile. Description and assessment configs update in place.
111 *
112 * ### Creating a Profile
113 * **Example:** Generated name with default assessments
114 * ```typescript
115 * const profile = yield* GCP.Apigee.SecurityProfilesV2("Baseline", {});
116 * ```
117 *
118 * **Example:** Named profile with explicit weights
119 * ```typescript
120 * const profile = yield* GCP.Apigee.SecurityProfilesV2("Baseline", {
121 * securityProfileV2Id: "app-baseline",
122 * description: "production scoring",
123 * profileAssessmentConfigs: {
124 * authorization: { weight: "MAJOR" },
125 * threat: { weight: "MODERATE" },
126 * cors: { weight: "MINOR" },
127 * },
128 * });
129 * ```
130 *
131 * @resource
132 * @category Apigee
133 */
134export const SecurityProfilesV2 = Resource<SecurityProfilesV2>(
135 "GCP.Apigee.SecurityProfilesV2",
136);
137
138export class SecurityProfilesV2NotResolved extends Data.TaggedError(
139 "GCP.Apigee.SecurityProfilesV2NotResolved",
140)<{
141 name: string;
142}> {}
143
144const DEFAULT_ASSESSMENTS: Record<string, ProfileAssessmentConfig> = {
145 authorization: { weight: "MODERATE" },
146 threat: { weight: "MODERATE" },
147};
148
149const resourceName = (organization: string, profileId: string) =>
150 `${orgParent(organization)}/securityProfilesV2/${profileId}`;
151
152const profileIdOf = (profile: apigee.GoogleCloudApigeeV1SecurityProfileV2) =>
153 lastSegment(profile.name ?? "");
154
155const configsOf = (
156 configs:
157 | apigee.GoogleCloudApigeeV1SecurityProfileV2ProfileAssessmentConfigMap
158 | Record<string, ProfileAssessmentConfig>
159 | undefined,
160): Record<string, ProfileAssessmentConfig> => {
161 const result: Record<string, ProfileAssessmentConfig> = {};
162 for (const [key, value] of Object.entries(configs ?? {})) {
163 if (value === undefined) continue;
164 result[key] = {
165 weight: value.weight,
166 include: value.include?.gatewayTypes
167 ? [...value.include.gatewayTypes]
168 : "include" in value && Array.isArray(value.include)
169 ? [...value.include]
170 : undefined,
171 };
172 }
173 return result;
174};
175
176const toApiConfigs = (
177 configs: Record<string, ProfileAssessmentConfig>,
178): apigee.GoogleCloudApigeeV1SecurityProfileV2ProfileAssessmentConfigMap => {
179 const result: apigee.GoogleCloudApigeeV1SecurityProfileV2ProfileAssessmentConfigMap =
180 {};
181 for (const [key, value] of Object.entries(configs)) {
182 result[key] = {
183 weight: value.weight,
184 include:
185 value.include !== undefined
186 ? { gatewayTypes: value.include }
187 : undefined,
188 };
189 }
190 return result;
191};
192
193const toAttrs = (
194 profile: apigee.GoogleCloudApigeeV1SecurityProfileV2,
195 organization: string,
196) => {
197 const securityProfileV2Id = profileIdOf(profile);
198 const name = profile.name?.includes("/")
199 ? profile.name
200 : resourceName(organization, securityProfileV2Id);
201 const parsed = parseOwnership(profile.description);
202 return {
203 name,
204 securityProfileV2Id,
205 organization: organizationFromName(name) ?? organization,
206 description: parsed.text,
207 riskAssessmentType: profile.riskAssessmentType,
208 profileAssessmentConfigs: configsOf(profile.profileAssessmentConfigs),
209 googleDefined: profile.googleDefined,
210 createTime: profile.createTime,
211 updateTime: profile.updateTime,
212 };
213};
214
215const getByName = (name: string) =>
216 apigee
217 .getOrganizationsSecurityProfilesV2({ name })
218 .pipe(
219 Effect.catchTag(["NotFound", "ApigeeResourceNotFound"], () =>
220 Effect.succeed(undefined),
221 ),
222 );
223
224const toBody = (
225 news: SecurityProfilesV2Props,
226 description: string,
227): apigee.GoogleCloudApigeeV1SecurityProfileV2 => ({
228 description,
229 riskAssessmentType: news.riskAssessmentType,
230 profileAssessmentConfigs: toApiConfigs(
231 news.profileAssessmentConfigs ?? DEFAULT_ASSESSMENTS,
232 ),
233});
234
235export const SecurityProfilesV2Provider = () =>
236 Provider.succeed(SecurityProfilesV2, {
237 stables: ["name", "securityProfileV2Id", "organization", "createTime"],
238
239 diff: Effect.fn(function* ({ news, olds, output }) {
240 if (!isResolved(news)) return undefined;
241 const previousId =
242 olds?.securityProfileV2Id ?? output?.securityProfileV2Id;
243 const previousOrg = olds?.organization ?? output?.organization;
244 if (
245 (previousId !== undefined &&
246 news.securityProfileV2Id !== undefined &&
247 news.securityProfileV2Id !== previousId) ||
248 (previousOrg !== undefined &&
249 news.organization !== undefined &&
250 news.organization !== previousOrg)
251 ) {
252 return { action: "replace" as const, deleteFirst: true };
253 }
254 return undefined;
255 }),
256
257 read: Effect.fn(function* ({ id, olds, output }) {
258 const env = yield* GcpEnvironment.current;
259 const organization =
260 organizationFromName(output?.name) ?? olds?.organization ?? env.project;
261 const securityProfileV2Id = yield* toResourceId(
262 id,
263 olds?.securityProfileV2Id,
264 output?.securityProfileV2Id,
265 MAX_NAME_LENGTH,
266 );
267 const name =
268 output?.name ?? resourceName(organization, securityProfileV2Id);
269 const existing = yield* getByName(name);
270 if (existing === undefined) return undefined;
271 const attrs = toAttrs(existing, organization);
272 const { labels } = parseOwnership(existing.description);
273 return (yield* hasAlchemyLabels(id, labels)) ? attrs : Unowned(attrs);
274 }),
275
276 list: () =>
277 Effect.gen(function* () {
278 const env = yield* GcpEnvironment.current;
279 return yield* apigee.listOrganizationsSecurityProfilesV2
280 .pages({
281 parent: orgParent(env.project),
282 pageSize: 1000,
283 })
284 .pipe(
285 Stream.flatMap((page) =>
286 Stream.fromIterable(page.securityProfilesV2 ?? []),
287 ),
288 Stream.filter(
289 (profile) =>
290 profile.googleDefined !== true &&
291 hasOwnershipMarker(profile.description),
292 ),
293 Stream.map((profile) => toAttrs(profile, env.project)),
294 Stream.runCollect,
295 Effect.map((chunk) => Array.from(chunk)),
296 Effect.catchTag(["NotFound", "ApigeeResourceNotFound"], () =>
297 Effect.succeed([] as SecurityProfilesV2["Attributes"][]),
298 ),
299 );
300 }),
301
302 reconcile: Effect.fn(function* ({ id, news, output }) {
303 const env = yield* GcpEnvironment.current;
304 const organization =
305 news.organization ?? output?.organization ?? env.project;
306 const securityProfileV2Id = yield* toResourceId(
307 id,
308 news.securityProfileV2Id,
309 output?.securityProfileV2Id,
310 MAX_NAME_LENGTH,
311 );
312 const name = resourceName(organization, securityProfileV2Id);
313 const ownership = yield* createInternalLabels(id);
314 const desiredDescription = encodeOwnership(ownership, news.description);
315 const desiredConfigs = configsOf(
316 news.profileAssessmentConfigs ?? DEFAULT_ASSESSMENTS,
317 );
318
319 let current = yield* getByName(output?.name ?? name);
320
321 if (current === undefined) {
322 const created = yield* apigee
323 .createOrganizationsSecurityProfilesV2({
324 parent: orgParent(organization),
325 securityProfileV2Id,
326 body: toBody(news, desiredDescription),
327 })
328 .pipe(Effect.catchTag("Conflict", () => getByName(name)));
329 current = created ?? undefined;
330 }
331
332 if (current === undefined) {
333 return yield* new SecurityProfilesV2NotResolved({ name });
334 }
335
336 const descriptionChanged =
337 (current.description ?? "") !== desiredDescription;
338 const configsChanged = !sameJson(
339 configsOf(current.profileAssessmentConfigs),
340 desiredConfigs,
341 );
342 const typeChanged =
343 news.riskAssessmentType !== undefined &&
344 (current.riskAssessmentType ?? "") !== news.riskAssessmentType;
345
346 const updateMask = [
347 descriptionChanged ? "description" : undefined,
348 configsChanged ? "profileAssessmentConfigs" : undefined,
349 typeChanged ? "riskAssessmentType" : undefined,
350 ].filter((field): field is string => field !== undefined);
351
352 if (updateMask.length > 0) {
353 current = yield* apigee.patchOrganizationsSecurityProfilesV2({
354 name: current.name?.includes("/") ? current.name : name,
355 updateMask: updateMask.join(","),
356 body: toBody(news, desiredDescription),
357 });
358 }
359
360 return toAttrs(current, organization);
361 }),
362
363 delete: Effect.fn(function* ({ output }) {
364 yield* apigee
365 .deleteOrganizationsSecurityProfilesV2({ name: output.name })
366 .pipe(
367 Effect.catchTag(
368 ["NotFound", "ApigeeResourceNotFound"],
369 () => Effect.void,
370 ),
371 );
372 }),
373 });