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