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 hasOwnershipMarker,
15 lastSegment,
16 organizationIdOf,
17 organizationParent,
18 ownedByAlchemy,
19 parseName,
20 parseOwnership,
21 replaceOn,
22 resolveOrganization,
23 sameText,
24 SecuritycenterNotResolved,
25 toPhysicalId,
26 tryResolveOrganization,
27 updateMaskOf,
28} from "./internal.ts";
29
30export type OrganizationMuteConfigProps = {
31 /**
32 * Mute config id (the `{muteConfig}` segment of
33 * `organizations/{organization}/muteConfigs/{muteConfig}`). If omitted,
34 * a unique id is generated from the stack, stage, and logical id.
35 * Letters, digits, and hyphens; max 63 characters. Immutable — changing
36 * it replaces the config.
37 */
38 muteConfigId?: string;
39 /**
40 * Parent organization (`organizations/{organization}` or the numeric
41 * id). Defaults to the project's Resource
42 * Manager ancestor. Immutable — changing it replaces the config.
43 */
44 organization?: 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 OrganizationMuteConfig = Resource<
71 "GCP.SecurityCenter.OrganizationMuteConfig",
72 OrganizationMuteConfigProps,
73 {
74 /** Full resource name `organizations/{organization}/muteConfigs/{muteConfig}`. */
75 name: string;
76 /** Mute config id (last path segment). */
77 muteConfigId: string;
78 /** Organization resource name. */
79 organization: string;
80 /** Organization id. */
81 organizationId: 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 * An organization-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, organization,
110 * and type are identity. Filter, description, display name, and expiry
111 * update in place.
112 *
113 * ### Creating a Mute Config
114 * **Example:** Mute low-severity findings
115 * ```typescript
116 * const mute = yield* GCP.SecurityCenter.OrganizationMuteConfig("Low", {
117 * filter: 'severity="LOW"',
118 * description: "mute low findings",
119 * });
120 * ```
121 *
122 * **Example:** Named config on an explicit organization
123 * ```typescript
124 * const mute = yield* GCP.SecurityCenter.OrganizationMuteConfig("Low", {
125 * organization: "organizations/123456789",
126 * muteConfigId: "mute-low",
127 * filter: 'severity="LOW"',
128 * type: "STATIC",
129 * });
130 * ```
131 *
132 * @resource
133 * @category SecurityCenter
134 */
135export const OrganizationMuteConfig = Resource<OrganizationMuteConfig>(
136 "GCP.SecurityCenter.OrganizationMuteConfig",
137);
138
139const resourceName = (organization: string, muteConfigId: string) =>
140 `${organization}/muteConfigs/${muteConfigId}`;
141
142const toAttrs = (
143 config: scc.GoogleCloudSecuritycenterV1MuteConfig,
144 organization: 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 organization,
154 organizationId: organizationIdOf(organization),
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 .getOrganizationsMuteConfigs({ name })
172 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
173
174export const OrganizationMuteConfigProvider = () =>
175 Provider.succeed(OrganizationMuteConfig, {
176 stables: [
177 "name",
178 "muteConfigId",
179 "organization",
180 "organizationId",
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?.organization ?? output?.organization,
194 news.organization !== undefined
195 ? organizationParent(news.organization)
196 : undefined,
197 ) ??
198 replaceOn(olds?.type ?? output?.type, news.type)
199 );
200 }),
201
202 read: Effect.fn(function* ({ id, olds, output }) {
203 const env = yield* GcpEnvironment.current;
204 const organization = yield* resolveOrganization(
205 olds?.organization ?? output?.organization,
206 output?.organization,
207 );
208 const muteConfigId = yield* toPhysicalId(
209 id,
210 olds?.muteConfigId,
211 output?.muteConfigId,
212 );
213 const name = output?.name ?? resourceName(organization, muteConfigId);
214 const existing = yield* getByName(name);
215 if (existing === undefined) return undefined;
216 const attrs = toAttrs(existing, organization, env.project);
217 return (yield* ownedByAlchemy(id, existing.description))
218 ? attrs
219 : Unowned(attrs);
220 }),
221
222 list: () =>
223 Effect.gen(function* () {
224 const env = yield* GcpEnvironment.current;
225 const organization = yield* tryResolveOrganization();
226 if (organization === undefined) return [];
227 return yield* scc.listOrganizationsMuteConfigs
228 .pages({ parent: organization, pageSize: 100 })
229 .pipe(
230 Stream.flatMap((page) =>
231 Stream.fromIterable(page.muteConfigs ?? []),
232 ),
233 Stream.filter((config) => hasOwnershipMarker(config.description)),
234 Stream.map((config) => toAttrs(config, organization, env.project)),
235 Stream.runCollect,
236 Effect.map((chunk) => Array.from(chunk)),
237 Effect.catchTag("NotFound", () => Effect.succeed([])),
238 );
239 }),
240
241 reconcile: Effect.fn(function* ({ id, news, output }) {
242 const env = yield* GcpEnvironment.current;
243 const organization = yield* resolveOrganization(
244 news.organization,
245 output?.organization,
246 );
247 const muteConfigId = yield* toPhysicalId(
248 id,
249 news.muteConfigId,
250 output?.muteConfigId,
251 );
252 const name = resourceName(organization, muteConfigId);
253 const ownership = yield* createInternalLabels(id);
254 const description = encodeDescription(ownership, news.description);
255 const filter = news.filter;
256 const type = news.type ?? DEFAULT_MUTE_TYPE;
257 const expiryTime = news.expiryTime;
258 const displayName = news.displayName;
259
260 let current = yield* getByName(output?.name ?? name);
261
262 if (current === undefined) {
263 const created = yield* scc
264 .createOrganizationsMuteConfigs({
265 parent: organization,
266 muteConfigId,
267 body: { filter, type, expiryTime, displayName, description },
268 })
269 .pipe(Effect.catchTag("Conflict", () => getByName(name)));
270 current = created ?? undefined;
271 }
272
273 if (current === undefined) {
274 return yield* new SecuritycenterNotResolved({ name });
275 }
276
277 const currentName = current.name ?? name;
278 const updateMask = updateMaskOf(
279 !sameText(current.filter, filter) ? "filter" : undefined,
280 !sameText(current.description, description) ? "description" : undefined,
281 !sameText(current.displayName, displayName) ? "displayName" : undefined,
282 !sameText(current.expiryTime, expiryTime) ? "expiryTime" : undefined,
283 );
284
285 if (updateMask.length > 0) {
286 current = yield* scc.patchOrganizationsMuteConfigs({
287 name: currentName,
288 updateMask,
289 body: { filter, expiryTime, displayName, description },
290 });
291 }
292
293 return toAttrs(current, organization, env.project);
294 }),
295
296 delete: Effect.fn(function* ({ output }) {
297 yield* scc
298 .deleteOrganizationsMuteConfigs({ name: output.name })
299 .pipe(Effect.catchTag("NotFound", () => Effect.void));
300 }),
301 });