1import * as scc from "@distilled.cloud/gcp/securitycenter_v1";
2import * as Data from "effect/Data";
3import * as Effect from "effect/Effect";
4import { Unowned } from "../../AdoptPolicy.ts";
5import { isResolved } from "../../Diff.ts";
6import { createPhysicalName } from "../../PhysicalName.ts";
7import * as Provider from "../../Provider.ts";
8import { Resource } from "../../Resource.ts";
9import { GcpEnvironment } from "../Environment.ts";
10import { createInternalLabels } from "../Labels.ts";
11import type { Providers } from "../Providers.ts";
12import {
13 collectPages,
14 encodeOwnership,
15 findOwned,
16 hasOwnershipMarker,
17 jsonEqual,
18 lastSegment,
19 organizationIdOf,
20 organizationParent,
21 ownedByAlchemy,
22 parseOwnership,
23 replaceOn,
24 resolveOrganization,
25 sameText,
26 shaDisplayNameOf,
27 shaSettingsParent,
28 tryResolveOrganization,
29 updateMaskOf,
30} from "./internal.ts";
31import type {
32 CustomConfig,
33 SecurityHealthAnalyticsCloudProvider,
34 SecurityHealthAnalyticsEnablementState,
35 SecurityHealthAnalyticsSeverity,
36} from "./SecurityHealthAnalyticsSettingsCustomModule.ts";
37
38export type OrganizationsSecurityHealthAnalyticsSettingsCustomModuleProps = {
39 /**
40 * Module id (the `{customModule}` segment of
41 * `organizations/{organization}/securityHealthAnalyticsSettings/customModules/{customModule}`).
42 * Server-assigned on create. Supply it to target an existing module.
43 * Immutable — changing it replaces the module.
44 */
45 moduleId?: string;
46 /**
47 * Parent organization (`organizations/{organization}` or the numeric id).
48 * Defaults to the project's Resource Manager
49 * ancestor. Immutable — changing it replaces the module.
50 */
51 organization?: string;
52 /**
53 * Display name. Becomes the finding category. Must start with an
54 * uppercase letter, contain only letters, digits, or underscores, and
55 * be at most 128 characters. Immutable — changing it replaces the
56 * module.
57 */
58 displayName?: string;
59 /**
60 * Enablement state at this parent.
61 * @default "ENABLED"
62 */
63 enablementState?: SecurityHealthAnalyticsEnablementState;
64 /**
65 * Detection configuration. Required on create.
66 */
67 customConfig: CustomConfig;
68 /**
69 * Cloud provider this module applies to.
70 */
71 cloudProvider?: SecurityHealthAnalyticsCloudProvider;
72};
73
74export type OrganizationsSecurityHealthAnalyticsSettingsCustomModule = Resource<
75 "GCP.SecurityCenter.OrganizationsSecurityHealthAnalyticsSettingsCustomModule",
76 OrganizationsSecurityHealthAnalyticsSettingsCustomModuleProps,
77 {
78 /** Full resource name `organizations/{organization}/securityHealthAnalyticsSettings/customModules/{customModule}`. */
79 name: string;
80 /** Module id (last path segment). */
81 moduleId: string;
82 /** Organization resource name. */
83 organization: string;
84 /** Organization id. */
85 organizationId: string;
86 /** Project id of the deploying stack. */
87 project: string;
88 /** Display name. */
89 displayName: string | undefined;
90 /** Enablement state. */
91 enablementState: string | undefined;
92 /** Detection configuration with the Alchemy ownership prefix stripped. */
93 customConfig: CustomConfig | undefined;
94 /** Cloud provider. */
95 cloudProvider: string | undefined;
96 /** Ancestor module this one inherits from, if any. */
97 ancestorModule: string | undefined;
98 /** RFC3339 last-update timestamp. */
99 updateTime: string | undefined;
100 /** Last editor email. */
101 lastEditor: string | undefined;
102 },
103 never,
104 Providers
105>;
106
107/**
108 * An organization-scoped Security Health Analytics custom module.
109 *
110 * Custom modules have no labels field — Alchemy stamps ownership into
111 * `customConfig.description` so `list` / nuke can find them. Module id,
112 * organization, and display name are identity (the id is server-assigned).
113 * Enablement and custom config update in place.
114 *
115 * ### Creating a Custom Module
116 * **Example:** Always-true compute instance check
117 * ```typescript
118 * const module = yield* GCP.SecurityCenter.OrganizationsSecurityHealthAnalyticsSettingsCustomModule(
119 * "AlwaysTrue",
120 * {
121 * displayName: "AlchemyAlwaysTrue",
122 * customConfig: {
123 * predicate: { expression: 'resource.name == "alchemy-nonexistent"' },
124 * resourceSelector: {
125 * resourceTypes: ["compute.googleapis.com/Instance"],
126 * },
127 * severity: "LOW",
128 * description: "always true",
129 * recommendation: "n/a",
130 * },
131 * },
132 * );
133 * ```
134 *
135 * **Example:** Named module on an explicit organization
136 * ```typescript
137 * const module = yield* GCP.SecurityCenter.OrganizationsSecurityHealthAnalyticsSettingsCustomModule(
138 * "AlwaysTrue",
139 * {
140 * organization: "organizations/123456789",
141 * displayName: "AlchemyAlwaysTrue",
142 * customConfig: {
143 * predicate: { expression: 'resource.name == "alchemy-nonexistent"' },
144 * resourceSelector: {
145 * resourceTypes: ["compute.googleapis.com/Instance"],
146 * },
147 * severity: "LOW",
148 * },
149 * },
150 * );
151 * ```
152 *
153 * @resource
154 * @category SecurityCenter
155 */
156export const OrganizationsSecurityHealthAnalyticsSettingsCustomModule =
157 Resource<OrganizationsSecurityHealthAnalyticsSettingsCustomModule>(
158 "GCP.SecurityCenter.OrganizationsSecurityHealthAnalyticsSettingsCustomModule",
159 );
160
161export class OrganizationsSecurityHealthAnalyticsSettingsCustomModuleNotResolved extends Data.TaggedError(
162 "GCP.SecurityCenter.OrganizationsSecurityHealthAnalyticsSettingsCustomModuleNotResolved",
163)<{
164 name: string;
165}> {}
166
167const resourceName = (organization: string, moduleId: string) =>
168 `${organization}/securityHealthAnalyticsSettings/customModules/${moduleId}`;
169
170const customConfigOf = (
171 config: scc.GoogleCloudSecuritycenterV1CustomConfig | undefined,
172): CustomConfig | undefined => {
173 if (config === undefined) return undefined;
174 return {
175 predicate: config.predicate,
176 resourceSelector: config.resourceSelector,
177 severity: config.severity as SecurityHealthAnalyticsSeverity | undefined,
178 description: parseOwnership(config.description).text,
179 recommendation: config.recommendation,
180 customOutput: config.customOutput,
181 };
182};
183
184const desiredCustomConfig = (
185 config: CustomConfig,
186 ownership: Record<string, string>,
187): scc.GoogleCloudSecuritycenterV1CustomConfig => ({
188 ...config,
189 description: encodeOwnership(ownership, config.description),
190});
191
192const toAttrs = (
193 module: scc.GoogleCloudSecuritycenterV1SecurityHealthAnalyticsCustomModule,
194 organization: string,
195 project: string,
196) => {
197 const name = module.name ?? "";
198 return {
199 name,
200 moduleId: lastSegment(name),
201 organization,
202 organizationId: organizationIdOf(organization),
203 project,
204 displayName: module.displayName,
205 enablementState: module.enablementState,
206 customConfig: customConfigOf(module.customConfig),
207 cloudProvider: module.cloudProvider,
208 ancestorModule: module.ancestorModule,
209 updateTime: module.updateTime,
210 lastEditor: module.lastEditor,
211 };
212};
213
214const getByName = (name: string) =>
215 name.length === 0
216 ? Effect.succeed(undefined)
217 : scc
218 .getOrganizationsSecurityHealthAnalyticsSettingsCustomModules({
219 name,
220 })
221 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
222
223const listModules = (organization: string) =>
224 collectPages(
225 scc.listOrganizationsSecurityHealthAnalyticsSettingsCustomModules.pages({
226 parent: shaSettingsParent(organization),
227 pageSize: 100,
228 }),
229 (page) => page.securityHealthAnalyticsCustomModules,
230 ).pipe(
231 Effect.catchTag("NotFound", () =>
232 Effect.succeed(
233 [] as scc.GoogleCloudSecuritycenterV1SecurityHealthAnalyticsCustomModule[],
234 ),
235 ),
236 );
237
238const observe = (organization: string, id: string, name: string) =>
239 Effect.gen(function* () {
240 const existing = yield* getByName(name);
241 if (existing !== undefined) return existing;
242 const items = yield* listModules(organization);
243 return yield* findOwned(
244 items,
245 (item) => item.customConfig?.description,
246 id,
247 );
248 });
249
250export const OrganizationsSecurityHealthAnalyticsSettingsCustomModuleProvider =
251 () =>
252 Provider.succeed(OrganizationsSecurityHealthAnalyticsSettingsCustomModule, {
253 stables: [
254 "name",
255 "moduleId",
256 "organization",
257 "organizationId",
258 "project",
259 "displayName",
260 "ancestorModule",
261 ],
262
263 diff: Effect.fn(function* ({ news, olds, output }) {
264 if (!isResolved(news)) return undefined;
265 return (
266 replaceOn(olds?.moduleId ?? output?.moduleId, news.moduleId) ??
267 replaceOn(
268 olds?.organization ?? output?.organization,
269 news.organization === undefined
270 ? undefined
271 : organizationParent(news.organization),
272 ) ??
273 replaceOn(olds?.displayName ?? output?.displayName, news.displayName)
274 );
275 }),
276
277 read: Effect.fn(function* ({ id, olds, output }) {
278 const env = yield* GcpEnvironment.current;
279 const organization = yield* resolveOrganization(
280 olds?.organization ?? output?.organization,
281 output?.organization,
282 );
283 const moduleId = olds?.moduleId ?? output?.moduleId;
284 const name =
285 output?.name ??
286 (moduleId ? resourceName(organization, moduleId) : "");
287 const existing = yield* observe(organization, id, name);
288 if (existing === undefined) return undefined;
289 const attrs = toAttrs(existing, organization, env.project);
290 return (yield* ownedByAlchemy(id, existing.customConfig?.description))
291 ? attrs
292 : Unowned(attrs);
293 }),
294
295 list: () =>
296 Effect.gen(function* () {
297 const env = yield* GcpEnvironment.current;
298 const organization = yield* tryResolveOrganization();
299 if (organization === undefined) return [];
300 const items = yield* listModules(organization);
301 return items
302 .filter((module) =>
303 hasOwnershipMarker(module.customConfig?.description),
304 )
305 .map((module) => toAttrs(module, organization, env.project));
306 }),
307
308 reconcile: Effect.fn(function* ({ id, news, output }) {
309 const env = yield* GcpEnvironment.current;
310 const organization = yield* resolveOrganization(
311 news.organization,
312 output?.organization,
313 );
314 const parent = shaSettingsParent(organization);
315 const moduleId = news.moduleId ?? output?.moduleId;
316 const name =
317 output?.name ??
318 (moduleId ? resourceName(organization, moduleId) : "");
319 const ownership = yield* createInternalLabels(id);
320 const customConfig = desiredCustomConfig(news.customConfig, ownership);
321 const enablementState = news.enablementState ?? "ENABLED";
322 const displayName =
323 news.displayName ??
324 shaDisplayNameOf(
325 yield* createPhysicalName({
326 id,
327 maxLength: 63,
328 lowercase: true,
329 }),
330 );
331 const body: scc.GoogleCloudSecuritycenterV1SecurityHealthAnalyticsCustomModule =
332 {
333 displayName,
334 enablementState,
335 customConfig,
336 cloudProvider: news.cloudProvider,
337 };
338
339 let current = yield* observe(organization, id, name);
340
341 if (current === undefined) {
342 current =
343 yield* scc.createOrganizationsSecurityHealthAnalyticsSettingsCustomModules(
344 {
345 parent,
346 body,
347 },
348 );
349 }
350
351 if (current === undefined) {
352 return yield* new OrganizationsSecurityHealthAnalyticsSettingsCustomModuleNotResolved(
353 {
354 name: name || parent,
355 },
356 );
357 }
358
359 const currentName = current.name ?? name;
360 const enablementChanged = !sameText(
361 current.enablementState,
362 enablementState,
363 );
364 const configChanged = !jsonEqual(current.customConfig, customConfig);
365 const updateMask = updateMaskOf(
366 enablementChanged ? "enablement_state" : undefined,
367 configChanged ? "custom_config" : undefined,
368 );
369
370 if (updateMask.length > 0) {
371 current =
372 yield* scc.patchOrganizationsSecurityHealthAnalyticsSettingsCustomModules(
373 {
374 name: currentName,
375 updateMask,
376 body: {
377 enablementState,
378 customConfig,
379 },
380 },
381 );
382 }
383
384 return toAttrs(current, organization, env.project);
385 }),
386
387 delete: Effect.fn(function* ({ output }) {
388 yield* scc
389 .deleteOrganizationsSecurityHealthAnalyticsSettingsCustomModules({
390 name: output.name,
391 })
392 .pipe(Effect.catchTag("NotFound", () => Effect.void));
393 }),
394 });