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