1import * as scc from "@distilled.cloud/gcp/securitycenter_v1";
2import * as Effect from "effect/Effect";
3import * as Stream from "effect/Stream";
4import { Unowned } from "../../AdoptPolicy.ts";
5import { isResolved } from "../../Diff.ts";
6import * as Provider from "../../Provider.ts";
7import { Resource } from "../../Resource.ts";
8import { GcpEnvironment } from "../Environment.ts";
9import { createInternalLabels } from "../Labels.ts";
10import type { Providers } from "../Providers.ts";
11import {
12 DEFAULT_CLOUD_PROVIDER,
13 DEFAULT_ENABLEMENT,
14 defaultShaCustomConfig,
15 encodeDescription,
16 fingerprint,
17 folderIdOf,
18 folderParent,
19 hasOwnershipMarker,
20 lastSegment,
21 ownedByAlchemy,
22 parseName,
23 parseOwnership,
24 replaceOn,
25 resolveFolder,
26 sameText,
27 SecuritycenterNotResolved,
28 shaSettingsParent,
29 toShaDisplayName,
30 tryResolveFolder,
31 updateMaskOf,
32} from "./internal.ts";
33
34export type FolderSecurityHealthAnalyticsSettingsCustomModuleProps = {
35 /**
36 * Parent folder (`folders/{folder}` or the numeric id). Defaults to
37 * the project's Resource Manager folder ancestor.
38 * Immutable — changing it replaces the module.
39 */
40 folder?: string;
41 /**
42 * Display name. Must start with a lowercase letter and contain only
43 * letters, digits, and underscores (max 128). Unique under the parent.
44 * If omitted, a unique name is generated. Custom modules have no labels
45 * field, so Alchemy ownership is stored in `customConfig.description`.
46 */
47 displayName?: string;
48 /**
49 * Detector configuration. When omitted, Alchemy uses a never-matching
50 * CEL predicate so the module stays inert.
51 */
52 customConfig?: scc.GoogleCloudSecuritycenterV1CustomConfig;
53 /**
54 * Enablement state.
55 * @default "ENABLED"
56 */
57 enablementState?:
58 | scc.GoogleCloudSecuritycenterV1SecurityHealthAnalyticsCustomModuleEnablementStateEnum
59 | (string & {});
60 /**
61 * Cloud provider this module applies to.
62 * @default "GOOGLE_CLOUD_PLATFORM"
63 */
64 cloudProvider?:
65 | scc.GoogleCloudSecuritycenterV1SecurityHealthAnalyticsCustomModuleCloudProviderEnum
66 | (string & {});
67};
68
69export type FolderSecurityHealthAnalyticsSettingsCustomModule = Resource<
70 "GCP.SecurityCenter.FolderSecurityHealthAnalyticsSettingsCustomModule",
71 FolderSecurityHealthAnalyticsSettingsCustomModuleProps,
72 {
73 /** Full resource name `{folder}/securityHealthAnalyticsSettings/customModules/{module}`. */
74 name: string;
75 /** Module id (last path segment; server assigned). */
76 moduleId: string;
77 /** Folder resource name. */
78 folder: string;
79 /** Folder id. */
80 folderId: string;
81 /** Project id of the deploying stack. */
82 project: string;
83 /** Display name. */
84 displayName: string | undefined;
85 /** Detector configuration with the Alchemy ownership prefix stripped from description. */
86 customConfig: scc.GoogleCloudSecuritycenterV1CustomConfig | undefined;
87 /** Enablement state. */
88 enablementState: string | undefined;
89 /** Cloud provider. */
90 cloudProvider: string | undefined;
91 /** Ancestor module this one inherits from, if any. */
92 ancestorModule: string | undefined;
93 /** Last editor of the module. */
94 lastEditor: string | undefined;
95 /** RFC3339 last-update timestamp. */
96 updateTime: string | undefined;
97 },
98 never,
99 Providers
100>;
101
102/**
103 * A folder-scoped Security Health Analytics custom module.
104 *
105 * Custom modules have no labels field — Alchemy stamps ownership into
106 * `customConfig.description` so `list` / nuke can find them. The module id
107 * is assigned by the API. Folder is identity. Display name, custom config,
108 * and enablement update in place.
109 *
110 * ### Creating a Custom Module
111 * **Example:** Never-matching detector
112 * ```typescript
113 * const module = yield* GCP.SecurityCenter.FolderSecurityHealthAnalyticsSettingsCustomModule(
114 * "Unused",
115 * {
116 * customConfig: {
117 * predicate: { expression: 'resource.name == "alchemy-nonexistent"' },
118 * resourceSelector: {
119 * resourceTypes: ["compute.googleapis.com/Instance"],
120 * },
121 * severity: "LOW",
122 * recommendation: "No action required.",
123 * },
124 * },
125 * );
126 * ```
127 *
128 * @resource
129 * @category SecurityCenter
130 */
131export const FolderSecurityHealthAnalyticsSettingsCustomModule =
132 Resource<FolderSecurityHealthAnalyticsSettingsCustomModule>(
133 "GCP.SecurityCenter.FolderSecurityHealthAnalyticsSettingsCustomModule",
134 );
135
136const desiredCustomConfig = (
137 ownership: Record<string, string>,
138 customConfig: scc.GoogleCloudSecuritycenterV1CustomConfig | undefined,
139): scc.GoogleCloudSecuritycenterV1CustomConfig => {
140 const base = customConfig ?? defaultShaCustomConfig;
141 return {
142 ...base,
143 description: encodeDescription(ownership, base.description),
144 };
145};
146
147const toAttrs = (
148 module: scc.GoogleCloudSecuritycenterV1SecurityHealthAnalyticsCustomModule,
149 folder: string,
150 project: string,
151) => {
152 const name = module.name ?? "";
153 const parsed = parseName(name, "customModules");
154 const ownership = parseOwnership(module.customConfig?.description);
155 const customConfig = module.customConfig
156 ? { ...module.customConfig, description: ownership.text }
157 : undefined;
158 return {
159 name,
160 moduleId: parsed.id || lastSegment(name),
161 folder,
162 folderId: folderIdOf(folder),
163 project,
164 displayName: module.displayName,
165 customConfig,
166 enablementState: module.enablementState,
167 cloudProvider: module.cloudProvider,
168 ancestorModule: module.ancestorModule,
169 lastEditor: module.lastEditor,
170 updateTime: module.updateTime,
171 };
172};
173
174const getByName = (name: string) =>
175 name.length === 0
176 ? Effect.succeed(undefined)
177 : scc
178 .getFoldersSecurityHealthAnalyticsSettingsCustomModules({ name })
179 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
180
181const listAt = (parent: string) =>
182 scc.listFoldersSecurityHealthAnalyticsSettingsCustomModules
183 .pages({ parent, pageSize: 100 })
184 .pipe(
185 Stream.flatMap((page) =>
186 Stream.fromIterable(page.securityHealthAnalyticsCustomModules ?? []),
187 ),
188 Stream.runCollect,
189 Effect.map((chunk) => Array.from(chunk)),
190 Effect.catchTag("NotFound", () => Effect.succeed([])),
191 );
192
193const findOwned = (parent: string, id: string) =>
194 Effect.gen(function* () {
195 const modules = yield* listAt(parent);
196 for (const module of modules) {
197 if (yield* ownedByAlchemy(id, module.customConfig?.description)) {
198 return module;
199 }
200 }
201 return undefined;
202 });
203
204export const FolderSecurityHealthAnalyticsSettingsCustomModuleProvider = () =>
205 Provider.succeed(FolderSecurityHealthAnalyticsSettingsCustomModule, {
206 stables: ["name", "moduleId", "folder", "folderId", "project"],
207
208 diff: Effect.fn(function* ({ news, olds, output }) {
209 if (!isResolved(news)) return undefined;
210 return replaceOn(
211 olds?.folder ?? output?.folder,
212 news.folder !== undefined ? folderParent(news.folder) : undefined,
213 );
214 }),
215
216 read: Effect.fn(function* ({ id, olds, output }) {
217 const env = yield* GcpEnvironment.current;
218 const folder = yield* resolveFolder(
219 olds?.folder ?? output?.folder,
220 output?.folder,
221 );
222 let existing = yield* getByName(output?.name ?? "");
223 if (existing === undefined) {
224 existing = yield* findOwned(shaSettingsParent(folder), id);
225 }
226 if (existing === undefined) return undefined;
227 const attrs = toAttrs(existing, folder, env.project);
228 return (yield* ownedByAlchemy(id, existing.customConfig?.description))
229 ? attrs
230 : Unowned(attrs);
231 }),
232
233 list: () =>
234 Effect.gen(function* () {
235 const env = yield* GcpEnvironment.current;
236 const folder = yield* tryResolveFolder();
237 if (folder === undefined) return [];
238 const modules = yield* listAt(shaSettingsParent(folder));
239 return modules
240 .filter((module) =>
241 hasOwnershipMarker(module.customConfig?.description),
242 )
243 .map((module) => toAttrs(module, folder, env.project));
244 }),
245
246 reconcile: Effect.fn(function* ({ id, news, output }) {
247 const env = yield* GcpEnvironment.current;
248 const folder = yield* resolveFolder(news.folder, output?.folder);
249 const parent = shaSettingsParent(folder);
250 const ownership = yield* createInternalLabels(id);
251 const displayName = yield* toShaDisplayName(
252 id,
253 news.displayName,
254 output?.displayName,
255 );
256 const customConfig = desiredCustomConfig(ownership, news.customConfig);
257 const enablementState = news.enablementState ?? DEFAULT_ENABLEMENT;
258 const cloudProvider = news.cloudProvider ?? DEFAULT_CLOUD_PROVIDER;
259
260 let current = yield* getByName(output?.name ?? "");
261 if (current === undefined) {
262 current = yield* findOwned(parent, id);
263 }
264
265 if (current === undefined) {
266 const created = yield* scc
267 .createFoldersSecurityHealthAnalyticsSettingsCustomModules({
268 parent,
269 body: {
270 displayName,
271 customConfig,
272 enablementState,
273 cloudProvider,
274 },
275 })
276 .pipe(Effect.catchTag("Conflict", () => findOwned(parent, id)));
277 current = created ?? undefined;
278 }
279
280 if (current === undefined) {
281 return yield* new SecuritycenterNotResolved({
282 name: `${parent}/customModules`,
283 });
284 }
285
286 const currentName = current.name ?? "";
287 const updateMask = updateMaskOf(
288 !sameText(current.displayName, displayName) ? "displayName" : undefined,
289 fingerprint(current.customConfig) !== fingerprint(customConfig)
290 ? "customConfig"
291 : undefined,
292 !sameText(current.enablementState, enablementState)
293 ? "enablementState"
294 : undefined,
295 );
296
297 if (updateMask.length > 0) {
298 current =
299 yield* scc.patchFoldersSecurityHealthAnalyticsSettingsCustomModules({
300 name: currentName,
301 updateMask,
302 body: { displayName, customConfig, enablementState },
303 });
304 }
305
306 return toAttrs(current, folder, env.project);
307 }),
308
309 delete: Effect.fn(function* ({ output }) {
310 yield* scc
311 .deleteFoldersSecurityHealthAnalyticsSettingsCustomModules({
312 name: output.name,
313 })
314 .pipe(Effect.catchTag("NotFound", () => Effect.void));
315 }),
316 });