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 organizationIdOf,
17 organizationParent,
18 ownedByAlchemy,
19 parseOwnership,
20 replaceOn,
21 resolveOrganization,
22 sameText,
23 toResourceId,
24 tryResolveOrganization,
25 updateMaskOf,
26} from "./internal.ts";
27import type { StreamingConfig } from "./NotificationConfig.ts";
28
29export type OrganizationsNotificationConfigProps = {
30 /**
31 * Config id (the `{config}` segment of
32 * `organizations/{organization}/notificationConfigs/{config}`). If omitted,
33 * a unique id is generated. Letters, digits, and hyphens; must start with
34 * a letter; max 63 characters. Immutable — changing it replaces the config.
35 */
36 configId?: string;
37 /**
38 * Parent organization (`organizations/{organization}` or the numeric id).
39 * Defaults to the project's Resource Manager
40 * ancestor. Immutable — changing it replaces the config.
41 */
42 organization?: string;
43 /**
44 * Pub/Sub topic that receives findings, as
45 * `projects/{project}/topics/{topic}`.
46 */
47 pubsubTopic: string;
48 /**
49 * Human-readable description (max 1024 characters). Notification configs
50 * have no labels field, so Alchemy ownership is stored in a
51 * `[alchemy …]` prefix and stripped from attributes.
52 */
53 description?: string;
54 /**
55 * Streaming config. `filter` selects which findings are published.
56 */
57 streamingConfig?: StreamingConfig;
58};
59
60export type OrganizationsNotificationConfig = Resource<
61 "GCP.SecurityCenter.OrganizationsNotificationConfig",
62 OrganizationsNotificationConfigProps,
63 {
64 /** Full resource name `organizations/{organization}/notificationConfigs/{config}`. */
65 name: string;
66 /** Config id (last path segment). */
67 configId: string;
68 /** Organization resource name. */
69 organization: string;
70 /** Organization id. */
71 organizationId: string;
72 /** Project id of the deploying stack. */
73 project: string;
74 /** Pub/Sub topic findings are published to. */
75 pubsubTopic: string | undefined;
76 /** User description with the Alchemy ownership prefix stripped. */
77 description: string | undefined;
78 /** Streaming config. */
79 streamingConfig: StreamingConfig | undefined;
80 /** Service account SCC uses to publish. */
81 serviceAccount: string | undefined;
82 },
83 never,
84 Providers
85>;
86
87/**
88 * An organization-scoped Security Command Center notification config that
89 * publishes findings to a Pub/Sub topic.
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 * organization are identity. Description, Pub/Sub topic, and streaming
94 * filter update in place.
95 *
96 * ### Creating a Notification Config
97 * **Example:** Publish high-severity findings
98 * ```typescript
99 * const topic = yield* GCP.PubSub.Topic("OrgFindings", {});
100 * const config = yield* GCP.SecurityCenter.OrganizationsNotificationConfig(
101 * "High",
102 * {
103 * pubsubTopic: topic.name,
104 * description: "high severity",
105 * streamingConfig: { filter: 'severity="HIGH"' },
106 * },
107 * );
108 * ```
109 *
110 * **Example:** Named config on an explicit organization
111 * ```typescript
112 * const config = yield* GCP.SecurityCenter.OrganizationsNotificationConfig(
113 * "High",
114 * {
115 * organization: "organizations/123456789",
116 * configId: "high-findings",
117 * pubsubTopic: topic.name,
118 * streamingConfig: { filter: 'severity="HIGH"' },
119 * },
120 * );
121 * ```
122 *
123 * @resource
124 * @category SecurityCenter
125 */
126export const OrganizationsNotificationConfig =
127 Resource<OrganizationsNotificationConfig>(
128 "GCP.SecurityCenter.OrganizationsNotificationConfig",
129 );
130
131export class OrganizationsNotificationConfigNotResolved extends Data.TaggedError(
132 "GCP.SecurityCenter.OrganizationsNotificationConfigNotResolved",
133)<{
134 name: string;
135}> {}
136
137const resourceName = (organization: string, configId: string) =>
138 `${organization}/notificationConfigs/${configId}`;
139
140const streamingOf = (
141 config: scc.StreamingConfig | StreamingConfig | undefined,
142): StreamingConfig | undefined =>
143 config === undefined ? undefined : { filter: config.filter };
144
145const toAttrs = (
146 config: scc.NotificationConfig,
147 organization: string,
148 project: string,
149) => {
150 const name = config.name ?? "";
151 const parsed = parseOwnership(config.description);
152 return {
153 name,
154 configId: lastSegment(name),
155 organization,
156 organizationId: organizationIdOf(organization),
157 project,
158 pubsubTopic: config.pubsubTopic,
159 description: parsed.text,
160 streamingConfig: streamingOf(config.streamingConfig),
161 serviceAccount: config.serviceAccount,
162 };
163};
164
165const getByName = (name: string) =>
166 name.length === 0
167 ? Effect.succeed(undefined)
168 : scc
169 .getOrganizationsNotificationConfigs({ name })
170 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
171
172export const OrganizationsNotificationConfigProvider = () =>
173 Provider.succeed(OrganizationsNotificationConfig, {
174 stables: [
175 "name",
176 "configId",
177 "organization",
178 "organizationId",
179 "project",
180 "serviceAccount",
181 ],
182
183 diff: Effect.fn(function* ({ news, olds, output }) {
184 if (!isResolved(news)) return undefined;
185 return (
186 replaceOn(olds?.configId ?? output?.configId, news.configId) ??
187 replaceOn(
188 olds?.organization ?? output?.organization,
189 news.organization === undefined
190 ? undefined
191 : organizationParent(news.organization),
192 )
193 );
194 }),
195
196 read: Effect.fn(function* ({ id, olds, output }) {
197 const env = yield* GcpEnvironment.current;
198 const organization = yield* resolveOrganization(
199 olds?.organization ?? output?.organization,
200 output?.organization,
201 );
202 const configId = yield* toResourceId(
203 id,
204 olds?.configId,
205 output?.configId,
206 "n",
207 );
208 const name = output?.name ?? resourceName(organization, configId);
209 const existing = yield* getByName(name);
210 if (existing === undefined) return undefined;
211 const attrs = toAttrs(existing, organization, env.project);
212 return (yield* ownedByAlchemy(id, existing.description))
213 ? attrs
214 : Unowned(attrs);
215 }),
216
217 list: () =>
218 Effect.gen(function* () {
219 const env = yield* GcpEnvironment.current;
220 const organization = yield* tryResolveOrganization();
221 if (organization === undefined) return [];
222 const items = yield* collectPages(
223 scc.listOrganizationsNotificationConfigs.pages({
224 parent: organization,
225 pageSize: 100,
226 }),
227 (page) => page.notificationConfigs,
228 ).pipe(
229 Effect.catchTag("NotFound", () =>
230 Effect.succeed([] as scc.NotificationConfig[]),
231 ),
232 );
233 return items
234 .filter((config) => hasOwnershipMarker(config.description))
235 .map((config) => toAttrs(config, organization, env.project));
236 }),
237
238 reconcile: Effect.fn(function* ({ id, news, output }) {
239 const env = yield* GcpEnvironment.current;
240 const organization = yield* resolveOrganization(
241 news.organization,
242 output?.organization,
243 );
244 const configId = yield* toResourceId(
245 id,
246 news.configId,
247 output?.configId,
248 "n",
249 );
250 const name = resourceName(organization, configId);
251 const ownership = yield* createInternalLabels(id);
252 const description = encodeOwnership(ownership, news.description);
253 const filter = news.streamingConfig?.filter ?? "";
254 const body: scc.NotificationConfig = {
255 pubsubTopic: news.pubsubTopic,
256 description,
257 streamingConfig: { filter },
258 };
259
260 let current = yield* getByName(output?.name ?? name);
261
262 if (current === undefined) {
263 const created = yield* scc
264 .createOrganizationsNotificationConfigs({
265 parent: organization,
266 configId,
267 body,
268 })
269 .pipe(Effect.catchTag("Conflict", () => getByName(name)));
270 current = created ?? undefined;
271 }
272
273 if (current === undefined) {
274 return yield* new OrganizationsNotificationConfigNotResolved({
275 name,
276 });
277 }
278
279 const currentName = current.name ?? name;
280 const descriptionChanged = !sameText(current.description, description);
281 const topicChanged = !sameText(current.pubsubTopic, news.pubsubTopic);
282 const filterChanged = !sameText(current.streamingConfig?.filter, filter);
283 const updateMask = updateMaskOf(
284 descriptionChanged ? "description" : undefined,
285 topicChanged ? "pubsub_topic" : undefined,
286 filterChanged ? "streaming_config.filter" : undefined,
287 );
288
289 if (updateMask.length > 0) {
290 current = yield* scc.patchOrganizationsNotificationConfigs({
291 name: currentName,
292 updateMask,
293 body: {
294 ...body,
295 name: currentName,
296 },
297 });
298 }
299
300 return toAttrs(current, organization, env.project);
301 }),
302
303 delete: Effect.fn(function* ({ output }) {
304 yield* scc
305 .deleteOrganizationsNotificationConfigs({ name: output.name })
306 .pipe(Effect.catchTag("NotFound", () => Effect.void));
307 }),
308 });