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 ProfileConfigCategory = {
29 /** Include mediation scoring. */
30 mediation?: boolean;
31 /** Include abuse scoring. */
32 abuse?: boolean;
33 /** Include authorization scoring. */
34 authorization?: boolean;
35 /** Include mTLS scoring. */
36 mtls?: boolean;
37 /** Include CORS scoring. */
38 cors?: boolean;
39 /** Include threat-protection scoring. */
40 threat?: boolean;
41};
42
43export type ProfileConfig = {
44 /** Scoring categories enabled on this profile. */
45 categories?: ProfileConfigCategory[];
46};
47
48export type ScoringConfig = {
49 /** Description of the scoring config. */
50 description?: string;
51 /** Path of the component config used for scoring. */
52 scorePath?: string;
53 /** Title of the config. */
54 title?: string;
55};
56
57export type SecurityProfileProps = {
58 /**
59 * Apigee organization id. Defaults to the current GCP project id.
60 * Immutable — changing it replaces the profile.
61 */
62 organization?: string;
63 /**
64 * Profile id (the `{profile}` segment of
65 * `organizations/{org}/securityProfiles/{profile}`). If omitted, a
66 * unique name is generated from the stack, stage, and logical id.
67 * 1–63 characters matching `^[a-z]([a-z0-9-]{0,61}[a-z0-9])?$`.
68 * Immutable — changing it replaces the profile.
69 */
70 securityProfileId?: string;
71 /**
72 * Human-readable description. Alchemy ownership is stored in a
73 * `[alchemy …]` prefix and stripped from attributes. Profiles have no
74 * labels field.
75 */
76 description?: string;
77 /**
78 * Deprecated display name.
79 */
80 displayName?: string;
81 /**
82 * Profile configuration used to compute the security score.
83 * @default `{ categories: [{ authorization: true, threat: true }] }`
84 */
85 profileConfig?: ProfileConfig;
86 /**
87 * Scoring configs in this revision.
88 */
89 scoringConfigs?: ScoringConfig[];
90};
91
92export type SecurityProfile = Resource<
93 "GCP.Apigee.SecurityProfile",
94 SecurityProfileProps,
95 {
96 /** Full resource name `organizations/{org}/securityProfiles/{profile}`. */
97 name: string;
98 /** Profile id (last path segment). */
99 securityProfileId: string;
100 /** Apigee organization id. */
101 organization: string;
102 /** User description with the Alchemy ownership prefix stripped. */
103 description: string | undefined;
104 /** Deprecated display name. */
105 displayName: string | undefined;
106 /** Profile configuration. */
107 profileConfig: ProfileConfig | undefined;
108 /** Scoring configs. */
109 scoringConfigs: ScoringConfig[];
110 /** Attached environments. */
111 environments: Array<{
112 environment: string | undefined;
113 attachTime: string | undefined;
114 }>;
115 /** Minimum score this profile can generate. */
116 minScore: number | undefined;
117 /** Maximum score this profile can generate. */
118 maxScore: number | undefined;
119 /** Revision id. */
120 revisionId: string | undefined;
121 /** Revision create time. */
122 revisionCreateTime: string | undefined;
123 /** Revision update time. */
124 revisionUpdateTime: string | undefined;
125 },
126 never,
127 Providers
128>;
129
130/**
131 * An Advanced API Security profile (v1).
132 *
133 * Profiles have no labels field, so Alchemy stamps ownership into the
134 * description for `list` / nuke. Name and organization are identity —
135 * changing them replaces the profile. Description and profile config
136 * update in place (each update creates a new revision).
137 *
138 * ### Creating a Profile
139 * **Example:** Generated name with authorization and threat categories
140 * ```typescript
141 * const profile = yield* GCP.Apigee.SecurityProfile("Baseline", {
142 * profileConfig: {
143 * categories: [{ authorization: true, threat: true }],
144 * },
145 * });
146 * ```
147 *
148 * **Example:** Named profile
149 * ```typescript
150 * const profile = yield* GCP.Apigee.SecurityProfile("Baseline", {
151 * securityProfileId: "app-baseline",
152 * description: "default scoring",
153 * profileConfig: {
154 * categories: [{ authorization: true, cors: true, threat: true }],
155 * },
156 * });
157 * ```
158 *
159 * @resource
160 * @category Apigee
161 */
162export const SecurityProfile = Resource<SecurityProfile>(
163 "GCP.Apigee.SecurityProfile",
164);
165
166export class SecurityProfileNotResolved extends Data.TaggedError(
167 "GCP.Apigee.SecurityProfileNotResolved",
168)<{
169 name: string;
170}> {}
171
172const DEFAULT_PROFILE_CONFIG: ProfileConfig = {
173 categories: [{ authorization: true, threat: true }],
174};
175
176const resourceName = (organization: string, profileId: string) =>
177 `${orgParent(organization)}/securityProfiles/${profileId}`;
178
179const profileIdOf = (profile: apigee.GoogleCloudApigeeV1SecurityProfile) =>
180 lastSegment(profile.name ?? "");
181
182const flag = (value: unknown) => value !== undefined && value !== false;
183
184const categoryOf = (
185 category:
186 | apigee.GoogleCloudApigeeV1ProfileConfigCategory
187 | ProfileConfigCategory,
188): ProfileConfigCategory => ({
189 mediation: flag(category.mediation) ? true : undefined,
190 abuse: flag(category.abuse) ? true : undefined,
191 authorization: flag(category.authorization) ? true : undefined,
192 mtls: flag(category.mtls) ? true : undefined,
193 cors: flag(category.cors) ? true : undefined,
194 threat: flag(category.threat) ? true : undefined,
195});
196
197const profileConfigOf = (
198 config: apigee.GoogleCloudApigeeV1ProfileConfig | ProfileConfig | undefined,
199): ProfileConfig => ({
200 categories: (
201 config?.categories ??
202 DEFAULT_PROFILE_CONFIG.categories ??
203 []
204 ).map(categoryOf),
205});
206
207const toApiCategory = (
208 category: ProfileConfigCategory,
209): apigee.GoogleCloudApigeeV1ProfileConfigCategory => ({
210 mediation: category.mediation === true ? {} : undefined,
211 abuse: category.abuse === true ? {} : undefined,
212 authorization: category.authorization === true ? {} : undefined,
213 mtls: category.mtls === true ? {} : undefined,
214 cors: category.cors === true ? {} : undefined,
215 threat: category.threat === true ? {} : undefined,
216});
217
218const toAttrs = (
219 profile: apigee.GoogleCloudApigeeV1SecurityProfile,
220 organization: string,
221) => {
222 const securityProfileId = profileIdOf(profile);
223 const name = profile.name?.includes("/")
224 ? profile.name
225 : resourceName(organization, securityProfileId);
226 const parsed = parseOwnership(profile.description);
227 return {
228 name,
229 securityProfileId,
230 organization: organizationFromName(name) ?? organization,
231 description: parsed.text,
232 displayName: profile.displayName,
233 profileConfig: profile.profileConfig
234 ? profileConfigOf(profile.profileConfig)
235 : undefined,
236 scoringConfigs: (profile.scoringConfigs ?? []).map((config) => ({
237 description: config.description,
238 scorePath: config.scorePath,
239 title: config.title,
240 })),
241 environments: (profile.environments ?? []).map((environment) => ({
242 environment: environment.environment,
243 attachTime: environment.attachTime,
244 })),
245 minScore: profile.minScore,
246 maxScore: profile.maxScore,
247 revisionId: profile.revisionId,
248 revisionCreateTime: profile.revisionCreateTime,
249 revisionUpdateTime: profile.revisionUpdateTime,
250 };
251};
252
253const getByName = (name: string) =>
254 apigee
255 .getOrganizationsSecurityProfiles({ name })
256 .pipe(
257 Effect.catchTag(["NotFound", "ApigeeResourceNotFound"], () =>
258 Effect.succeed(undefined),
259 ),
260 );
261
262const toBody = (
263 news: SecurityProfileProps,
264 description: string,
265): apigee.GoogleCloudApigeeV1SecurityProfile => {
266 const config = profileConfigOf(news.profileConfig ?? DEFAULT_PROFILE_CONFIG);
267 return {
268 description,
269 displayName: news.displayName,
270 profileConfig: {
271 categories: (config.categories ?? []).map(toApiCategory),
272 },
273 scoringConfigs: news.scoringConfigs,
274 };
275};
276
277export const SecurityProfileProvider = () =>
278 Provider.succeed(SecurityProfile, {
279 stables: ["name", "securityProfileId", "organization"],
280
281 diff: Effect.fn(function* ({ news, olds, output }) {
282 if (!isResolved(news)) return undefined;
283 const previousId = olds?.securityProfileId ?? output?.securityProfileId;
284 const previousOrg = olds?.organization ?? output?.organization;
285 if (
286 (previousId !== undefined &&
287 news.securityProfileId !== undefined &&
288 news.securityProfileId !== previousId) ||
289 (previousOrg !== undefined &&
290 news.organization !== undefined &&
291 news.organization !== previousOrg)
292 ) {
293 return { action: "replace" as const, deleteFirst: true };
294 }
295 return undefined;
296 }),
297
298 read: Effect.fn(function* ({ id, olds, output }) {
299 const env = yield* GcpEnvironment.current;
300 const organization =
301 organizationFromName(output?.name) ?? olds?.organization ?? env.project;
302 const securityProfileId = yield* toResourceId(
303 id,
304 olds?.securityProfileId,
305 output?.securityProfileId,
306 MAX_NAME_LENGTH,
307 );
308 const name =
309 output?.name ?? resourceName(organization, securityProfileId);
310 const existing = yield* getByName(name);
311 if (existing === undefined) return undefined;
312 const attrs = toAttrs(existing, organization);
313 const { labels } = parseOwnership(existing.description);
314 return (yield* hasAlchemyLabels(id, labels)) ? attrs : Unowned(attrs);
315 }),
316
317 list: () =>
318 Effect.gen(function* () {
319 const env = yield* GcpEnvironment.current;
320 return yield* apigee.listOrganizationsSecurityProfiles
321 .pages({
322 parent: orgParent(env.project),
323 pageSize: 50,
324 })
325 .pipe(
326 Stream.flatMap((page) =>
327 Stream.fromIterable(page.securityProfiles ?? []),
328 ),
329 Stream.filter((profile) => hasOwnershipMarker(profile.description)),
330 Stream.map((profile) => toAttrs(profile, env.project)),
331 Stream.runCollect,
332 Effect.map((chunk) => Array.from(chunk)),
333 Effect.catchTag(["NotFound", "ApigeeResourceNotFound"], () =>
334 Effect.succeed([] as SecurityProfile["Attributes"][]),
335 ),
336 );
337 }),
338
339 reconcile: Effect.fn(function* ({ id, news, output }) {
340 const env = yield* GcpEnvironment.current;
341 const organization =
342 news.organization ?? output?.organization ?? env.project;
343 const securityProfileId = yield* toResourceId(
344 id,
345 news.securityProfileId,
346 output?.securityProfileId,
347 MAX_NAME_LENGTH,
348 );
349 const name = resourceName(organization, securityProfileId);
350 const ownership = yield* createInternalLabels(id);
351 const desiredDescription = encodeOwnership(ownership, news.description);
352 const desiredConfig = profileConfigOf(
353 news.profileConfig ?? DEFAULT_PROFILE_CONFIG,
354 );
355
356 let current = yield* getByName(output?.name ?? name);
357
358 if (current === undefined) {
359 const created = yield* apigee
360 .createOrganizationsSecurityProfiles({
361 parent: orgParent(organization),
362 securityProfileId,
363 body: toBody(news, desiredDescription),
364 })
365 .pipe(Effect.catchTag("Conflict", () => getByName(name)));
366 current = created ?? undefined;
367 }
368
369 if (current === undefined) {
370 return yield* new SecurityProfileNotResolved({ name });
371 }
372
373 const descriptionChanged =
374 (current.description ?? "") !== desiredDescription;
375 const displayChanged =
376 (current.displayName ?? "") !== (news.displayName ?? "");
377 const configChanged = !sameJson(
378 profileConfigOf(current.profileConfig),
379 desiredConfig,
380 );
381 const scoringChanged = !sameJson(
382 current.scoringConfigs ?? [],
383 news.scoringConfigs ?? current.scoringConfigs ?? [],
384 );
385
386 const updateMask = [
387 descriptionChanged ? "description" : undefined,
388 displayChanged ? "displayName" : undefined,
389 configChanged ? "profileConfig" : undefined,
390 scoringChanged ? "scoringConfigs" : undefined,
391 ].filter((field): field is string => field !== undefined);
392
393 if (updateMask.length > 0) {
394 current = yield* apigee.patchOrganizationsSecurityProfiles({
395 name: current.name?.includes("/") ? current.name : name,
396 updateMask: updateMask.join(","),
397 body: toBody(news, desiredDescription),
398 });
399 }
400
401 return toAttrs(current, organization);
402 }),
403
404 delete: Effect.fn(function* ({ output }) {
405 yield* apigee
406 .deleteOrganizationsSecurityProfiles({ name: output.name })
407 .pipe(
408 Effect.catchTag(
409 ["NotFound", "ApigeeResourceNotFound"],
410 () => Effect.void,
411 ),
412 );
413 }),
414 });