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