1import * as dlp from "@distilled.cloud/gcp/dlp_v2";
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_LOCATION,
13 DlpNotResolved,
14 encodeDisplayName,
15 fingerprint,
16 hasOwnershipMarker,
17 lastSegment,
18 locationParentsOf,
19 organizationLocationParent,
20 normalizeLocation,
21 organizationIdOf,
22 ownedByAlchemy,
23 parseName,
24 parseOwnership,
25 replaceOn,
26 resolveOrganization,
27 sameText,
28 toPhysicalId,
29 tryResolveOrganization,
30 updateMaskOf,
31} from "./internal.ts";
32
33export type DiscoveryTarget = dlp.GooglePrivacyDlpV2DiscoveryTarget;
34export type OrgConfig = dlp.GooglePrivacyDlpV2OrgConfig;
35export type ProcessingLocation = dlp.GooglePrivacyDlpV2ProcessingLocation;
36export type OtherCloudDiscoveryStartingLocation =
37 dlp.GooglePrivacyDlpV2OtherCloudDiscoveryStartingLocation;
38export type DiscoveryConfigStatus =
39 | dlp.GooglePrivacyDlpV2DiscoveryConfigStatusEnum
40 | (string & {});
41
42export type OrganizationsLocationsDiscoveryConfigProps = {
43 /**
44 * Config id (the `{discoveryConfig}` segment of
45 * `organizations/{organization}/locations/{location}/discoveryConfigs/{discoveryConfig}`).
46 * If omitted, a unique id is generated. Letters, digits, hyphens, and
47 * underscores; max 100 characters. Immutable — changing it replaces
48 * the config.
49 */
50 configId?: string;
51 /**
52 * Parent organization (`organizations/{organization}` or the numeric
53 * id). Defaults to `GOOGLE_ORGANIZATION_ID` or the project's Resource
54 * Manager ancestor. Immutable — changing it replaces the config.
55 */
56 organization?: string;
57 /**
58 * Processing location (`us-central1`, `global`, `us`, …). Immutable —
59 * changing it replaces the config.
60 * @default "global"
61 */
62 location?: string;
63 /**
64 * Display name (max 100 characters). Discovery configs have no labels
65 * field, so Alchemy ownership is stored in a `[alchemy …]` prefix and
66 * stripped from attributes.
67 */
68 displayName?: string;
69 /**
70 * Config status. Prefer `PAUSED` unless the org should be scanned.
71 * @default "PAUSED"
72 */
73 status?: DiscoveryConfigStatus;
74 /**
75 * Organization scan location and the project that runs discovery.
76 * Required for organization-parented configs.
77 */
78 orgConfig: OrgConfig;
79 /**
80 * Targets that decide what to scan and how often.
81 */
82 targets: DiscoveryTarget[];
83 /**
84 * Inspect templates used for profile generation.
85 */
86 inspectTemplates?: string[];
87 /**
88 * Processing location configuration.
89 */
90 processingLocation?: ProcessingLocation;
91 /**
92 * Starting location when scanning other clouds.
93 */
94 otherCloudStartingLocation?: OtherCloudDiscoveryStartingLocation;
95};
96
97export type OrganizationsLocationsDiscoveryConfig = Resource<
98 "GCP.DLP.OrganizationsLocationsDiscoveryConfig",
99 OrganizationsLocationsDiscoveryConfigProps,
100 {
101 /** Full resource name. */
102 name: string;
103 /** Config id (last path segment). */
104 configId: string;
105 /** Organization resource name. */
106 organization: string;
107 /** Organization id. */
108 organizationId: string;
109 /** Location id. */
110 location: string;
111 /** Project id of the deploying stack. */
112 project: string;
113 /** User display name with the Alchemy ownership prefix stripped. */
114 displayName: string | undefined;
115 /** Config status. */
116 status: string;
117 /** Organization scan configuration. */
118 orgConfig: OrgConfig | undefined;
119 /** Discovery targets. */
120 targets: DiscoveryTarget[];
121 /** Inspect templates. */
122 inspectTemplates: string[];
123 /** Processing location, if set. */
124 processingLocation: ProcessingLocation | undefined;
125 /** Other-cloud starting location, if set. */
126 otherCloudStartingLocation: OtherCloudDiscoveryStartingLocation | undefined;
127 /** RFC3339 creation timestamp. */
128 createTime: string | undefined;
129 /** RFC3339 last-update timestamp. */
130 updateTime: string | undefined;
131 /** RFC3339 last-run timestamp. */
132 lastRunTime: string | undefined;
133 },
134 never,
135 Providers
136>;
137
138/**
139 * An organization-scoped Sensitive Data Protection discovery config that
140 * scans storage and builds data profiles.
141 *
142 * Discovery configs have no labels field — Alchemy stamps ownership into
143 * the display name so `list` / nuke can find them. Config id, organization,
144 * and location are identity. Status, targets, and org config update in
145 * place. Keep `status` as `PAUSED` unless the organization should be
146 * scanned.
147 *
148 * ### Creating a Discovery Config
149 * **Example:** Paused BigQuery catch-all
150 * ```typescript
151 * const config = yield* GCP.DLP.OrganizationsLocationsDiscoveryConfig(
152 * "OrgProfiles",
153 * {
154 * status: "PAUSED",
155 * orgConfig: {
156 * projectId: "my-project",
157 * location: { organizationId: "123456789" },
158 * },
159 * targets: [
160 * {
161 * bigQueryTarget: {
162 * filter: { otherTables: {} },
163 * disabled: {},
164 * },
165 * },
166 * ],
167 * },
168 * );
169 * ```
170 *
171 * @resource
172 * @category DLP
173 */
174export const OrganizationsLocationsDiscoveryConfig =
175 Resource<OrganizationsLocationsDiscoveryConfig>(
176 "GCP.DLP.OrganizationsLocationsDiscoveryConfig",
177 );
178
179const DEFAULT_STATUS = "PAUSED" satisfies DiscoveryConfigStatus;
180
181const resourceName = (
182 organization: string,
183 location: string,
184 configId: string,
185) =>
186 `${organizationLocationParent(organization, location)}/discoveryConfigs/${configId}`;
187
188const toAttrs = (
189 config: dlp.GooglePrivacyDlpV2DiscoveryConfig,
190 organization: string,
191 project: string,
192) => {
193 const name = config.name ?? "";
194 const parsed = parseName(name, "discoveryConfigs");
195 const ownership = parseOwnership(config.displayName);
196 return {
197 name,
198 configId: parsed.id || lastSegment(name),
199 organization,
200 organizationId: organizationIdOf(organization),
201 location: parsed.location || DEFAULT_LOCATION,
202 project,
203 displayName: ownership.text,
204 status: config.status ?? DEFAULT_STATUS,
205 orgConfig: config.orgConfig,
206 targets: config.targets ?? [],
207 inspectTemplates: config.inspectTemplates ?? [],
208 processingLocation: config.processingLocation,
209 otherCloudStartingLocation: config.otherCloudStartingLocation,
210 createTime: config.createTime,
211 updateTime: config.updateTime,
212 lastRunTime: config.lastRunTime,
213 };
214};
215
216const getByName = (name: string) =>
217 name.length === 0
218 ? Effect.succeed(undefined)
219 : dlp
220 .getOrganizationsLocationsDiscoveryConfigs({ name })
221 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
222
223const listAt = (parent: string, organization: string, project: string) =>
224 dlp.listOrganizationsLocationsDiscoveryConfigs
225 .pages({ parent, pageSize: 100 })
226 .pipe(
227 Stream.flatMap((page) =>
228 Stream.fromIterable(page.discoveryConfigs ?? []),
229 ),
230 Stream.filter((config) => hasOwnershipMarker(config.displayName)),
231 Stream.map((config) => toAttrs(config, organization, project)),
232 Stream.runCollect,
233 Effect.map((chunk) => Array.from(chunk)),
234 Effect.catchTag("NotFound", () => Effect.succeed([])),
235 );
236
237const statusOf = (value: string | undefined) => value ?? DEFAULT_STATUS;
238
239export const OrganizationsLocationsDiscoveryConfigProvider = () =>
240 Provider.succeed(OrganizationsLocationsDiscoveryConfig, {
241 stables: [
242 "name",
243 "configId",
244 "organization",
245 "organizationId",
246 "location",
247 "project",
248 "createTime",
249 ],
250
251 diff: Effect.fn(function* ({ news, olds, output }) {
252 if (!isResolved(news)) return undefined;
253 const previousLocation = normalizeLocation(
254 olds?.location ?? output?.location,
255 );
256 const nextLocation = normalizeLocation(
257 news.location ?? olds?.location ?? output?.location,
258 );
259 return (
260 replaceOn(olds?.configId ?? output?.configId, news.configId) ??
261 replaceOn(
262 olds?.organization ?? output?.organization,
263 news.organization,
264 ) ??
265 replaceOn(previousLocation, nextLocation)
266 );
267 }),
268
269 read: Effect.fn(function* ({ id, olds, output }) {
270 const env = yield* GcpEnvironment.current;
271 const organization = yield* resolveOrganization(
272 olds?.organization ?? output?.organization,
273 output?.organization,
274 );
275 const location = normalizeLocation(olds?.location ?? output?.location);
276 const configId = yield* toPhysicalId(
277 id,
278 olds?.configId,
279 output?.configId,
280 );
281 const name =
282 output?.name ?? resourceName(organization, location, configId);
283 const existing = yield* getByName(name);
284 if (existing === undefined) return undefined;
285 const attrs = toAttrs(existing, organization, env.project);
286 return (yield* ownedByAlchemy(id, existing.displayName))
287 ? attrs
288 : Unowned(attrs);
289 }),
290
291 list: () =>
292 Effect.gen(function* () {
293 const env = yield* GcpEnvironment.current;
294 const organization = yield* tryResolveOrganization();
295 if (organization === undefined) return [];
296 const pages = yield* Effect.forEach(
297 locationParentsOf(organization, env.region),
298 (parent) => listAt(parent, organization, env.project),
299 { concurrency: 3 },
300 );
301 return pages.flat();
302 }),
303
304 reconcile: Effect.fn(function* ({ id, news, output }) {
305 const env = yield* GcpEnvironment.current;
306 const organization = yield* resolveOrganization(
307 news.organization,
308 output?.organization,
309 );
310 const location = normalizeLocation(news.location ?? output?.location);
311 const configId = yield* toPhysicalId(id, news.configId, output?.configId);
312 const parent = organizationLocationParent(organization, location);
313 const name = resourceName(organization, location, configId);
314 const ownership = yield* createInternalLabels(id);
315 const displayName = encodeDisplayName(ownership, news.displayName);
316 const status = statusOf(news.status);
317 const orgConfig = news.orgConfig;
318 const targets = news.targets;
319 const inspectTemplates = news.inspectTemplates;
320 const processingLocation = news.processingLocation;
321 const otherCloudStartingLocation = news.otherCloudStartingLocation;
322
323 let current = yield* getByName(output?.name ?? name);
324
325 if (current === undefined) {
326 const created = yield* dlp
327 .createOrganizationsLocationsDiscoveryConfigs({
328 parent,
329 body: {
330 configId,
331 discoveryConfig: {
332 displayName,
333 status,
334 orgConfig,
335 targets,
336 inspectTemplates,
337 processingLocation,
338 otherCloudStartingLocation,
339 },
340 },
341 })
342 .pipe(Effect.catchTag("Conflict", () => getByName(name)));
343 current = created ?? undefined;
344 }
345
346 if (current === undefined) {
347 return yield* new DlpNotResolved({ name });
348 }
349
350 const currentName = current.name ?? name;
351 const displayChanged = !sameText(current.displayName, displayName);
352 const statusChanged = !sameText(current.status, status);
353 const orgChanged =
354 fingerprint(current.orgConfig) !== fingerprint(orgConfig);
355 const targetsChanged =
356 fingerprint(current.targets) !== fingerprint(targets);
357 const templatesChanged =
358 fingerprint(current.inspectTemplates) !== fingerprint(inspectTemplates);
359 const processingChanged =
360 fingerprint(current.processingLocation) !==
361 fingerprint(processingLocation);
362 const otherChanged =
363 fingerprint(current.otherCloudStartingLocation) !==
364 fingerprint(otherCloudStartingLocation);
365 const updateMask = updateMaskOf(
366 displayChanged ? "displayName" : undefined,
367 statusChanged ? "status" : undefined,
368 orgChanged ? "orgConfig" : undefined,
369 targetsChanged ? "targets" : undefined,
370 templatesChanged ? "inspectTemplates" : undefined,
371 processingChanged ? "processingLocation" : undefined,
372 otherChanged ? "otherCloudStartingLocation" : undefined,
373 );
374
375 if (updateMask.length > 0) {
376 current = yield* dlp.patchOrganizationsLocationsDiscoveryConfigs({
377 name: currentName,
378 body: {
379 updateMask,
380 discoveryConfig: {
381 displayName,
382 status,
383 orgConfig,
384 targets,
385 inspectTemplates,
386 processingLocation,
387 otherCloudStartingLocation,
388 },
389 },
390 });
391 }
392
393 return toAttrs(current, organization, env.project);
394 }),
395
396 delete: Effect.fn(function* ({ output }) {
397 yield* dlp
398 .deleteOrganizationsLocationsDiscoveryConfigs({ name: output.name })
399 .pipe(Effect.catchTag("NotFound", () => Effect.void));
400 }),
401 });