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 DEFAULT_ETD_TYPE,
15 defaultEtdConfig,
16 encodeDescription,
17 etdSettingsParent,
18 fingerprint,
19 hasOwnershipMarker,
20 lastSegment,
21 organizationIdOf,
22 organizationParent,
23 ownedByAlchemy,
24 parseName,
25 parseOwnership,
26 replaceOn,
27 resolveOrganization,
28 sameText,
29 SecuritycenterNotResolved,
30 tryResolveOrganization,
31 updateMaskOf,
32} from "./internal.ts";
33
34export type OrganizationEventThreatDetectionSettingsCustomModuleProps = {
35 /**
36 * Parent organization (`organizations/{organization}` or the numeric
37 * id). Defaults to the project's Resource
38 * Manager ancestor. Immutable — changing it replaces the module.
39 */
40 organization?: string;
41 /**
42 * Custom module type (for example `CONFIGURABLE_BAD_IP`). Immutable —
43 * changing it replaces the module.
44 * @default "CONFIGURABLE_BAD_IP"
45 */
46 type?: string;
47 /**
48 * Module configuration matching `type`.
49 */
50 config?: scc.DocumentMap;
51 /**
52 * Enablement state.
53 * @default "ENABLED"
54 */
55 enablementState?:
56 | scc.EventThreatDetectionCustomModuleEnablementStateEnum
57 | (string & {});
58 /**
59 * Cloud provider this module applies to.
60 * @default "GOOGLE_CLOUD_PLATFORM"
61 */
62 cloudProvider?:
63 | scc.EventThreatDetectionCustomModuleCloudProviderEnum
64 | (string & {});
65 /**
66 * Human-readable display name.
67 */
68 displayName?: string;
69 /**
70 * Human-readable description. Event Threat Detection custom modules
71 * have no labels field, so Alchemy ownership is stored in a
72 * `[alchemy …]` prefix and stripped from attributes.
73 */
74 description?: string;
75};
76
77export type OrganizationEventThreatDetectionSettingsCustomModule = Resource<
78 "GCP.SecurityCenter.OrganizationEventThreatDetectionSettingsCustomModule",
79 OrganizationEventThreatDetectionSettingsCustomModuleProps,
80 {
81 /** Full resource name `{organization}/eventThreatDetectionSettings/customModules/{module}`. */
82 name: string;
83 /** Module id (last path segment; server assigned). */
84 moduleId: string;
85 /** Organization resource name. */
86 organization: string;
87 /** Organization id. */
88 organizationId: string;
89 /** Project id of the deploying stack. */
90 project: string;
91 /** Custom module type. */
92 type: string | undefined;
93 /** Module configuration. */
94 config: scc.DocumentMap | undefined;
95 /** Enablement state. */
96 enablementState: string | undefined;
97 /** Cloud provider. */
98 cloudProvider: string | undefined;
99 /** Display name. */
100 displayName: string | undefined;
101 /** User description with the Alchemy ownership prefix stripped. */
102 description: string | undefined;
103 /** Ancestor module this one inherits from, if any. */
104 ancestorModule: string | undefined;
105 /** Last editor of the module. */
106 lastEditor: string | undefined;
107 /** RFC3339 last-update timestamp. */
108 updateTime: string | undefined;
109 },
110 never,
111 Providers
112>;
113
114/**
115 * An organization-scoped Event Threat Detection custom module.
116 *
117 * Custom modules have no labels field — Alchemy stamps ownership into the
118 * description so `list` / nuke can find them. The module id is assigned by
119 * the API. Organization and type are identity. Config, enablement, display
120 * name, and description update in place.
121 *
122 * ### Creating a Custom Module
123 * **Example:** Flag a test IP
124 * ```typescript
125 * const module = yield* GCP.SecurityCenter.OrganizationEventThreatDetectionSettingsCustomModule(
126 * "BadIp",
127 * {
128 * type: "CONFIGURABLE_BAD_IP",
129 * config: {
130 * metadata: { severity: "LOW" },
131 * ips: ["192.0.2.1"],
132 * },
133 * description: "test bad ip",
134 * },
135 * );
136 * ```
137 *
138 * @resource
139 * @category SecurityCenter
140 */
141export const OrganizationEventThreatDetectionSettingsCustomModule =
142 Resource<OrganizationEventThreatDetectionSettingsCustomModule>(
143 "GCP.SecurityCenter.OrganizationEventThreatDetectionSettingsCustomModule",
144 );
145
146const toAttrs = (
147 module: scc.EventThreatDetectionCustomModule,
148 organization: string,
149 project: string,
150) => {
151 const name = module.name ?? "";
152 const parsed = parseName(name, "customModules");
153 const ownership = parseOwnership(module.description);
154 return {
155 name,
156 moduleId: parsed.id || lastSegment(name),
157 organization,
158 organizationId: organizationIdOf(organization),
159 project,
160 type: module.type,
161 config: module.config,
162 enablementState: module.enablementState,
163 cloudProvider: module.cloudProvider,
164 displayName: module.displayName,
165 description: ownership.text,
166 ancestorModule: module.ancestorModule,
167 lastEditor: module.lastEditor,
168 updateTime: module.updateTime,
169 };
170};
171
172const getByName = (name: string) =>
173 name.length === 0
174 ? Effect.succeed(undefined)
175 : scc
176 .getOrganizationsEventThreatDetectionSettingsCustomModules({ name })
177 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
178
179const listAt = (parent: string) =>
180 scc.listOrganizationsEventThreatDetectionSettingsCustomModules
181 .pages({ parent, pageSize: 100 })
182 .pipe(
183 Stream.flatMap((page) =>
184 Stream.fromIterable(page.eventThreatDetectionCustomModules ?? []),
185 ),
186 Stream.runCollect,
187 Effect.map((chunk) => Array.from(chunk)),
188 Effect.catchTag("NotFound", () => Effect.succeed([])),
189 );
190
191const findOwned = (parent: string, id: string) =>
192 Effect.gen(function* () {
193 const modules = yield* listAt(parent);
194 for (const module of modules) {
195 if (yield* ownedByAlchemy(id, module.description)) return module;
196 }
197 return undefined;
198 });
199
200export const OrganizationEventThreatDetectionSettingsCustomModuleProvider =
201 () =>
202 Provider.succeed(OrganizationEventThreatDetectionSettingsCustomModule, {
203 stables: [
204 "name",
205 "moduleId",
206 "organization",
207 "organizationId",
208 "project",
209 "type",
210 ],
211
212 diff: Effect.fn(function* ({ news, olds, output }) {
213 if (!isResolved(news)) return undefined;
214 return (
215 replaceOn(
216 olds?.organization ?? output?.organization,
217 news.organization !== undefined
218 ? organizationParent(news.organization)
219 : undefined,
220 ) ?? replaceOn(olds?.type ?? output?.type, news.type)
221 );
222 }),
223
224 read: Effect.fn(function* ({ id, olds, output }) {
225 const env = yield* GcpEnvironment.current;
226 const organization = yield* resolveOrganization(
227 olds?.organization ?? output?.organization,
228 output?.organization,
229 );
230 let existing = yield* getByName(output?.name ?? "");
231 if (existing === undefined) {
232 existing = yield* findOwned(etdSettingsParent(organization), id);
233 }
234 if (existing === undefined) return undefined;
235 const attrs = toAttrs(existing, organization, env.project);
236 return (yield* ownedByAlchemy(id, existing.description))
237 ? attrs
238 : Unowned(attrs);
239 }),
240
241 list: () =>
242 Effect.gen(function* () {
243 const env = yield* GcpEnvironment.current;
244 const organization = yield* tryResolveOrganization();
245 if (organization === undefined) return [];
246 const modules = yield* listAt(etdSettingsParent(organization));
247 return modules
248 .filter((module) => hasOwnershipMarker(module.description))
249 .map((module) => toAttrs(module, organization, env.project));
250 }),
251
252 reconcile: Effect.fn(function* ({ id, news, output }) {
253 const env = yield* GcpEnvironment.current;
254 const organization = yield* resolveOrganization(
255 news.organization,
256 output?.organization,
257 );
258 const parent = etdSettingsParent(organization);
259 const ownership = yield* createInternalLabels(id);
260 const description = encodeDescription(ownership, news.description);
261 const type = news.type ?? DEFAULT_ETD_TYPE;
262 const config = news.config ?? defaultEtdConfig;
263 const enablementState = news.enablementState ?? DEFAULT_ENABLEMENT;
264 const cloudProvider = news.cloudProvider ?? DEFAULT_CLOUD_PROVIDER;
265 const displayName = news.displayName;
266
267 let current = yield* getByName(output?.name ?? "");
268 if (current === undefined) {
269 current = yield* findOwned(parent, id);
270 }
271
272 if (current === undefined) {
273 const created = yield* scc
274 .createOrganizationsEventThreatDetectionSettingsCustomModules({
275 parent,
276 body: {
277 type,
278 config,
279 enablementState,
280 cloudProvider,
281 displayName,
282 description,
283 },
284 })
285 .pipe(Effect.catchTag("Conflict", () => findOwned(parent, id)));
286 current = created ?? undefined;
287 }
288
289 if (current === undefined) {
290 return yield* new SecuritycenterNotResolved({
291 name: `${parent}/customModules`,
292 });
293 }
294
295 const currentName = current.name ?? "";
296 const updateMask = updateMaskOf(
297 fingerprint(current.config) !== fingerprint(config)
298 ? "config"
299 : undefined,
300 !sameText(current.enablementState, enablementState)
301 ? "enablementState"
302 : undefined,
303 !sameText(current.displayName, displayName)
304 ? "displayName"
305 : undefined,
306 !sameText(current.description, description)
307 ? "description"
308 : undefined,
309 );
310
311 if (updateMask.length > 0) {
312 current =
313 yield* scc.patchOrganizationsEventThreatDetectionSettingsCustomModules(
314 {
315 name: currentName,
316 updateMask,
317 body: { config, enablementState, displayName, description },
318 },
319 );
320 }
321
322 return toAttrs(current, organization, env.project);
323 }),
324
325 delete: Effect.fn(function* ({ output }) {
326 yield* scc
327 .deleteOrganizationsEventThreatDetectionSettingsCustomModules({
328 name: output.name,
329 })
330 .pipe(Effect.catchTag("NotFound", () => Effect.void));
331 }),
332 });