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 encodeDescription,
13 folderIdOf,
14 folderParent,
15 hasOwnershipMarker,
16 lastSegment,
17 ownedByAlchemy,
18 parseName,
19 parseOwnership,
20 replaceOn,
21 resolveFolder,
22 sameText,
23 SecuritycenterNotResolved,
24 toPhysicalId,
25 tryResolveFolder,
26 updateMaskOf,
27} from "./internal.ts";
28
29export type FolderNotificationConfigProps = {
30 /**
31 * Config id (the `{config}` segment of
32 * `folders/{folder}/notificationConfigs/{config}`). If omitted, a unique
33 * id is generated from the stack, stage, and logical id. Letters,
34 * digits, and hyphens; max 63 characters. Immutable — changing it
35 * replaces the config.
36 */
37 configId?: string;
38 /**
39 * Parent folder (`folders/{folder}` or the numeric id). Defaults to
40 * the project's Resource Manager folder ancestor.
41 * Immutable — changing it replaces the config.
42 */
43 folder?: string;
44 /**
45 * Pub/Sub topic that receives findings
46 * (`projects/{project}/topics/{topic}`).
47 */
48 pubsubTopic: string;
49 /**
50 * Finding filter. Empty exports every finding under the parent.
51 */
52 filter?: string;
53 /**
54 * Human-readable description. Notification configs have no labels field,
55 * so Alchemy ownership is stored in a `[alchemy …]` prefix and stripped
56 * from attributes.
57 */
58 description?: string;
59};
60
61export type FolderNotificationConfig = Resource<
62 "GCP.SecurityCenter.FolderNotificationConfig",
63 FolderNotificationConfigProps,
64 {
65 /** Full resource name `folders/{folder}/notificationConfigs/{config}`. */
66 name: string;
67 /** Config id (last path segment). */
68 configId: string;
69 /** Folder resource name. */
70 folder: string;
71 /** Folder id. */
72 folderId: string;
73 /** Project id of the deploying stack. */
74 project: string;
75 /** Pub/Sub topic that receives findings. */
76 pubsubTopic: string;
77 /** Finding filter. */
78 filter: string | undefined;
79 /** User description with the Alchemy ownership prefix stripped. */
80 description: string | undefined;
81 /** Service account Security Command Center uses to publish. */
82 serviceAccount: string | undefined;
83 },
84 never,
85 Providers
86>;
87
88/**
89 * A folder-scoped Security Command Center notification config.
90 *
91 * Notification configs have no labels field — Alchemy stamps ownership
92 * into the description so `list` / nuke can find them. Config id and
93 * folder are identity. Pub/Sub topic, filter, and description update in
94 * place.
95 *
96 * ### Creating a Notification Config
97 * **Example:** Stream findings to Pub/Sub
98 * ```typescript
99 * const topic = yield* GCP.PubSub.Topic("SccFindings", {});
100 * const config = yield* GCP.SecurityCenter.FolderNotificationConfig(
101 * "Findings",
102 * {
103 * pubsubTopic: topic.name,
104 * filter: 'state="ACTIVE"',
105 * description: "active findings",
106 * },
107 * );
108 * ```
109 *
110 * **Example:** Named config on an explicit folder
111 * ```typescript
112 * const config = yield* GCP.SecurityCenter.FolderNotificationConfig(
113 * "Findings",
114 * {
115 * folder: "folders/123456789",
116 * configId: "active-findings",
117 * pubsubTopic: "projects/my-project/topics/scc",
118 * },
119 * );
120 * ```
121 *
122 * @resource
123 * @category SecurityCenter
124 */
125export const FolderNotificationConfig = Resource<FolderNotificationConfig>(
126 "GCP.SecurityCenter.FolderNotificationConfig",
127);
128
129const resourceName = (folder: string, configId: string) =>
130 `${folder}/notificationConfigs/${configId}`;
131
132const toAttrs = (
133 config: scc.NotificationConfig,
134 folder: string,
135 project: string,
136) => {
137 const name = config.name ?? "";
138 const parsed = parseName(name, "notificationConfigs");
139 const ownership = parseOwnership(config.description);
140 return {
141 name,
142 configId: parsed.id || lastSegment(name),
143 folder,
144 folderId: folderIdOf(folder),
145 project,
146 pubsubTopic: config.pubsubTopic ?? "",
147 filter: config.streamingConfig?.filter,
148 description: ownership.text,
149 serviceAccount: config.serviceAccount,
150 };
151};
152
153const getByName = (name: string) =>
154 name.length === 0
155 ? Effect.succeed(undefined)
156 : scc
157 .getFoldersNotificationConfigs({ name })
158 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
159
160export const FolderNotificationConfigProvider = () =>
161 Provider.succeed(FolderNotificationConfig, {
162 stables: ["name", "configId", "folder", "folderId", "project"],
163
164 diff: Effect.fn(function* ({ news, olds, output }) {
165 if (!isResolved(news)) return undefined;
166 return (
167 replaceOn(olds?.configId ?? output?.configId, news.configId) ??
168 replaceOn(
169 olds?.folder ?? output?.folder,
170 news.folder !== undefined ? folderParent(news.folder) : undefined,
171 )
172 );
173 }),
174
175 read: Effect.fn(function* ({ id, olds, output }) {
176 const env = yield* GcpEnvironment.current;
177 const folder = yield* resolveFolder(
178 olds?.folder ?? output?.folder,
179 output?.folder,
180 );
181 const configId = yield* toPhysicalId(
182 id,
183 olds?.configId,
184 output?.configId,
185 );
186 const name = output?.name ?? resourceName(folder, configId);
187 const existing = yield* getByName(name);
188 if (existing === undefined) return undefined;
189 const attrs = toAttrs(existing, folder, env.project);
190 return (yield* ownedByAlchemy(id, existing.description))
191 ? attrs
192 : Unowned(attrs);
193 }),
194
195 list: () =>
196 Effect.gen(function* () {
197 const env = yield* GcpEnvironment.current;
198 const folder = yield* tryResolveFolder();
199 if (folder === undefined) return [];
200 return yield* scc.listFoldersNotificationConfigs
201 .pages({ parent: folder, pageSize: 100 })
202 .pipe(
203 Stream.flatMap((page) =>
204 Stream.fromIterable(page.notificationConfigs ?? []),
205 ),
206 Stream.filter((config) => hasOwnershipMarker(config.description)),
207 Stream.map((config) => toAttrs(config, folder, env.project)),
208 Stream.runCollect,
209 Effect.map((chunk) => Array.from(chunk)),
210 Effect.catchTag("NotFound", () => Effect.succeed([])),
211 );
212 }),
213
214 reconcile: Effect.fn(function* ({ id, news, output }) {
215 const env = yield* GcpEnvironment.current;
216 const folder = yield* resolveFolder(news.folder, output?.folder);
217 const configId = yield* toPhysicalId(id, news.configId, output?.configId);
218 const name = resourceName(folder, configId);
219 const ownership = yield* createInternalLabels(id);
220 const description = encodeDescription(ownership, news.description);
221 const pubsubTopic = news.pubsubTopic;
222 const filter = news.filter ?? "";
223 const streamingConfig = { filter };
224
225 let current = yield* getByName(output?.name ?? name);
226
227 if (current === undefined) {
228 const created = yield* scc
229 .createFoldersNotificationConfigs({
230 parent: folder,
231 configId,
232 body: { pubsubTopic, description, streamingConfig },
233 })
234 .pipe(Effect.catchTag("Conflict", () => getByName(name)));
235 current = created ?? undefined;
236 }
237
238 if (current === undefined) {
239 return yield* new SecuritycenterNotResolved({ name });
240 }
241
242 const currentName = current.name ?? name;
243 const updateMask = updateMaskOf(
244 !sameText(current.pubsubTopic, pubsubTopic) ? "pubsubTopic" : undefined,
245 !sameText(current.description, description) ? "description" : undefined,
246 !sameText(current.streamingConfig?.filter, filter)
247 ? "streamingConfig.filter"
248 : undefined,
249 );
250
251 if (updateMask.length > 0) {
252 current = yield* scc.patchFoldersNotificationConfigs({
253 name: currentName,
254 updateMask,
255 body: { pubsubTopic, description, streamingConfig },
256 });
257 }
258
259 return toAttrs(current, folder, env.project);
260 }),
261
262 delete: Effect.fn(function* ({ output }) {
263 yield* scc
264 .deleteFoldersNotificationConfigs({ name: output.name })
265 .pipe(Effect.catchTag("NotFound", () => Effect.void));
266 }),
267 });