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