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 hasOwnershipMarker,
14 lastSegment,
15 organizationIdOf,
16 organizationParent,
17 ownedByAlchemy,
18 parseName,
19 parseOwnership,
20 replaceOn,
21 resolveOrganization,
22 sameText,
23 SecuritycenterNotResolved,
24 toPhysicalId,
25 tryResolveOrganization,
26 updateMaskOf,
27} from "./internal.ts";
28
29export type OrganizationBigQueryExportProps = {
30 /**
31 * Export id (the `{export}` segment of
32 * `organizations/{organization}/bigQueryExports/{export}`). If omitted,
33 * a unique id is generated from the stack, stage, and logical id.
34 * Letters, digits, and hyphens; max 63 characters. Immutable — changing
35 * it replaces the export.
36 */
37 exportId?: string;
38 /**
39 * Parent organization (`organizations/{organization}` or the numeric
40 * id). Defaults to the project's Resource
41 * Manager ancestor. Immutable — changing it replaces the export.
42 */
43 organization?: 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 OrganizationBigQueryExport = Resource<
63 "GCP.SecurityCenter.OrganizationBigQueryExport",
64 OrganizationBigQueryExportProps,
65 {
66 /** Full resource name `organizations/{organization}/bigQueryExports/{export}`. */
67 name: string;
68 /** Export id (last path segment). */
69 exportId: string;
70 /** Organization resource name. */
71 organization: string;
72 /** Organization id. */
73 organizationId: 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 * An organization-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 organization
100 * are 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.OrganizationBigQueryExport(
110 * "Findings",
111 * {
112 * dataset: `projects/${dataset.project}/datasets/${dataset.datasetId}`,
113 * filter: 'state="ACTIVE"',
114 * description: "active findings",
115 * },
116 * );
117 * ```
118 *
119 * **Example:** Named export on an explicit organization
120 * ```typescript
121 * const exp = yield* GCP.SecurityCenter.OrganizationBigQueryExport(
122 * "Findings",
123 * {
124 * organization: "organizations/123456789",
125 * exportId: "active-findings",
126 * dataset: "projects/my-project/datasets/scc",
127 * },
128 * );
129 * ```
130 *
131 * @resource
132 * @category SecurityCenter
133 */
134export const OrganizationBigQueryExport = Resource<OrganizationBigQueryExport>(
135 "GCP.SecurityCenter.OrganizationBigQueryExport",
136);
137
138const resourceName = (organization: string, exportId: string) =>
139 `${organization}/bigQueryExports/${exportId}`;
140
141const toAttrs = (
142 exp: scc.GoogleCloudSecuritycenterV1BigQueryExport,
143 organization: string,
144 project: string,
145) => {
146 const name = exp.name ?? "";
147 const parsed = parseName(name, "bigQueryExports");
148 const ownership = parseOwnership(exp.description);
149 return {
150 name,
151 exportId: parsed.id || lastSegment(name),
152 organization,
153 organizationId: organizationIdOf(organization),
154 project,
155 dataset: exp.dataset ?? "",
156 filter: exp.filter,
157 description: ownership.text,
158 principal: exp.principal,
159 mostRecentEditor: exp.mostRecentEditor,
160 createTime: exp.createTime,
161 updateTime: exp.updateTime,
162 };
163};
164
165const getByName = (name: string) =>
166 name.length === 0
167 ? Effect.succeed(undefined)
168 : scc
169 .getOrganizationsBigQueryExports({ name })
170 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
171
172export const OrganizationBigQueryExportProvider = () =>
173 Provider.succeed(OrganizationBigQueryExport, {
174 stables: [
175 "name",
176 "exportId",
177 "organization",
178 "organizationId",
179 "project",
180 "createTime",
181 ],
182
183 diff: Effect.fn(function* ({ news, olds, output }) {
184 if (!isResolved(news)) return undefined;
185 return (
186 replaceOn(olds?.exportId ?? output?.exportId, news.exportId) ??
187 replaceOn(
188 olds?.organization ?? output?.organization,
189 news.organization !== undefined
190 ? organizationParent(news.organization)
191 : undefined,
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 exportId = yield* toPhysicalId(
203 id,
204 olds?.exportId,
205 output?.exportId,
206 );
207 const name = output?.name ?? resourceName(organization, exportId);
208 const existing = yield* getByName(name);
209 if (existing === undefined) return undefined;
210 const attrs = toAttrs(existing, organization, env.project);
211 return (yield* ownedByAlchemy(id, existing.description))
212 ? attrs
213 : Unowned(attrs);
214 }),
215
216 list: () =>
217 Effect.gen(function* () {
218 const env = yield* GcpEnvironment.current;
219 const organization = yield* tryResolveOrganization();
220 if (organization === undefined) return [];
221 return yield* scc.listOrganizationsBigQueryExports
222 .pages({ parent: organization, pageSize: 100 })
223 .pipe(
224 Stream.flatMap((page) =>
225 Stream.fromIterable(page.bigQueryExports ?? []),
226 ),
227 Stream.filter((exp) => hasOwnershipMarker(exp.description)),
228 Stream.map((exp) => toAttrs(exp, organization, env.project)),
229 Stream.runCollect,
230 Effect.map((chunk) => Array.from(chunk)),
231 Effect.catchTag("NotFound", () => Effect.succeed([])),
232 );
233 }),
234
235 reconcile: Effect.fn(function* ({ id, news, output }) {
236 const env = yield* GcpEnvironment.current;
237 const organization = yield* resolveOrganization(
238 news.organization,
239 output?.organization,
240 );
241 const exportId = yield* toPhysicalId(id, news.exportId, output?.exportId);
242 const name = resourceName(organization, exportId);
243 const ownership = yield* createInternalLabels(id);
244 const description = encodeDescription(ownership, news.description);
245 const dataset = news.dataset;
246 const filter = news.filter;
247
248 let current = yield* getByName(output?.name ?? name);
249
250 if (current === undefined) {
251 const created = yield* scc
252 .createOrganizationsBigQueryExports({
253 parent: organization,
254 bigQueryExportId: exportId,
255 body: { dataset, filter, description },
256 })
257 .pipe(Effect.catchTag("Conflict", () => getByName(name)));
258 current = created ?? undefined;
259 }
260
261 if (current === undefined) {
262 return yield* new SecuritycenterNotResolved({ name });
263 }
264
265 const currentName = current.name ?? name;
266 const updateMask = updateMaskOf(
267 !sameText(current.dataset, dataset) ? "dataset" : undefined,
268 !sameText(current.filter, filter) ? "filter" : undefined,
269 !sameText(current.description, description) ? "description" : undefined,
270 );
271
272 if (updateMask.length > 0) {
273 current = yield* scc.patchOrganizationsBigQueryExports({
274 name: currentName,
275 updateMask,
276 body: { dataset, filter, description },
277 });
278 }
279
280 return toAttrs(current, organization, env.project);
281 }),
282
283 delete: Effect.fn(function* ({ output }) {
284 yield* scc
285 .deleteOrganizationsBigQueryExports({ name: output.name })
286 .pipe(Effect.catchTag("NotFound", () => Effect.void));
287 }),
288 });