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 encodeDescription,
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
33type InspectConfig = dlp.GooglePrivacyDlpV2InspectConfig;
34
35export type OrganizationsLocationsInspectTemplateProps = {
36 /**
37 * Template id (the `{inspectTemplate}` segment of
38 * `organizations/{organization}/locations/{location}/inspectTemplates/{inspectTemplate}`).
39 * If omitted, a unique id is generated. Letters, digits, hyphens, and
40 * underscores; max 100 characters. Immutable — changing it replaces
41 * the template.
42 */
43 templateId?: string;
44 /**
45 * Parent organization (`organizations/{organization}` or the numeric
46 * id). Defaults to `GOOGLE_ORGANIZATION_ID` or the project's Resource
47 * Manager ancestor. Immutable — changing it replaces the template.
48 */
49 organization?: string;
50 /**
51 * Processing location (`us-central1`, `global`, `us`, …). Immutable —
52 * changing it replaces the template.
53 * @default "global"
54 */
55 location?: string;
56 /**
57 * Human-readable display name (max 256 characters).
58 */
59 displayName?: string;
60 /**
61 * Human-readable description (max 256 characters). Inspect templates
62 * have no labels field, so Alchemy ownership is stored in a
63 * `[alchemy …]` prefix and stripped from attributes.
64 */
65 description?: string;
66 /**
67 * Inspection configuration applied when this template is used.
68 */
69 inspectConfig: InspectConfig;
70 /**
71 * Allow limited-availability built-in infoTypes in `inspectConfig`.
72 * @default false
73 */
74 allowLimitedAvailabilityInfoTypes?: boolean;
75};
76
77export type OrganizationsLocationsInspectTemplate = Resource<
78 "GCP.DLP.OrganizationsLocationsInspectTemplate",
79 OrganizationsLocationsInspectTemplateProps,
80 {
81 /** Full resource name. */
82 name: string;
83 /** Template id (last path segment). */
84 templateId: string;
85 /** Organization resource name. */
86 organization: string;
87 /** Organization id. */
88 organizationId: string;
89 /** Location id. */
90 location: string;
91 /** Project id of the deploying stack. */
92 project: string;
93 /** User display name. */
94 displayName: string | undefined;
95 /** User description with the Alchemy ownership prefix stripped. */
96 description: string | undefined;
97 /** Inspection configuration. */
98 inspectConfig: InspectConfig | undefined;
99 /** Whether limited-availability infoTypes are allowed. */
100 allowLimitedAvailabilityInfoTypes: boolean;
101 /** RFC3339 creation timestamp. */
102 createTime: string | undefined;
103 /** RFC3339 last-update timestamp. */
104 updateTime: string | undefined;
105 },
106 never,
107 Providers
108>;
109
110/**
111 * A location-scoped Sensitive Data Protection inspect template on an
112 * organization.
113 *
114 * Templates have no labels field — Alchemy stamps ownership into the
115 * description so `list` / nuke can find them. Template id, organization,
116 * and location are identity. Display name, description, and inspect
117 * config update in place.
118 *
119 * ### Creating a Location Inspect Template
120 * **Example:** Detect phone numbers in us-central1
121 * ```typescript
122 * const template = yield* GCP.DLP.OrganizationsLocationsInspectTemplate(
123 * "Phones",
124 * {
125 * location: "us-central1",
126 * inspectConfig: {
127 * infoTypes: [{ name: "PHONE_NUMBER" }],
128 * includeQuote: true,
129 * },
130 * },
131 * );
132 * ```
133 *
134 * @resource
135 * @category DLP
136 */
137export const OrganizationsLocationsInspectTemplate =
138 Resource<OrganizationsLocationsInspectTemplate>(
139 "GCP.DLP.OrganizationsLocationsInspectTemplate",
140 );
141
142const resourceName = (
143 organization: string,
144 location: string,
145 templateId: string,
146) =>
147 `${organizationLocationParent(organization, location)}/inspectTemplates/${templateId}`;
148
149const toAttrs = (
150 template: dlp.GooglePrivacyDlpV2InspectTemplate,
151 organization: string,
152 project: string,
153) => {
154 const name = template.name ?? "";
155 const parsed = parseName(name, "inspectTemplates");
156 const ownership = parseOwnership(template.description);
157 return {
158 name,
159 templateId: parsed.id || lastSegment(name),
160 organization,
161 organizationId: organizationIdOf(organization),
162 location: parsed.location || DEFAULT_LOCATION,
163 project,
164 displayName: template.displayName,
165 description: ownership.text,
166 inspectConfig: template.inspectConfig,
167 allowLimitedAvailabilityInfoTypes:
168 template.allowLimitedAvailabilityInfoTypes === true,
169 createTime: template.createTime,
170 updateTime: template.updateTime,
171 };
172};
173
174const getByName = (name: string) =>
175 name.length === 0
176 ? Effect.succeed(undefined)
177 : dlp
178 .getOrganizationsLocationsInspectTemplates({ name })
179 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
180
181const listAt = (parent: string, organization: string, project: string) =>
182 dlp.listOrganizationsLocationsInspectTemplates
183 .pages({ parent, pageSize: 100 })
184 .pipe(
185 Stream.flatMap((page) =>
186 Stream.fromIterable(page.inspectTemplates ?? []),
187 ),
188 Stream.filter((template) => hasOwnershipMarker(template.description)),
189 Stream.map((template) => toAttrs(template, organization, project)),
190 Stream.runCollect,
191 Effect.map((chunk) => Array.from(chunk)),
192 Effect.catchTag("NotFound", () => Effect.succeed([])),
193 );
194
195export const OrganizationsLocationsInspectTemplateProvider = () =>
196 Provider.succeed(OrganizationsLocationsInspectTemplate, {
197 stables: [
198 "name",
199 "templateId",
200 "organization",
201 "organizationId",
202 "location",
203 "project",
204 "createTime",
205 ],
206
207 diff: Effect.fn(function* ({ news, olds, output }) {
208 if (!isResolved(news)) return undefined;
209 const previousLocation = normalizeLocation(
210 olds?.location ?? output?.location,
211 );
212 const nextLocation = normalizeLocation(
213 news.location ?? olds?.location ?? output?.location,
214 );
215 return (
216 replaceOn(olds?.templateId ?? output?.templateId, news.templateId) ??
217 replaceOn(
218 olds?.organization ?? output?.organization,
219 news.organization,
220 ) ??
221 replaceOn(previousLocation, nextLocation)
222 );
223 }),
224
225 read: Effect.fn(function* ({ id, olds, output }) {
226 const env = yield* GcpEnvironment.current;
227 const organization = yield* resolveOrganization(
228 olds?.organization ?? output?.organization,
229 output?.organization,
230 );
231 const location = normalizeLocation(olds?.location ?? output?.location);
232 const templateId = yield* toPhysicalId(
233 id,
234 olds?.templateId,
235 output?.templateId,
236 );
237 const name =
238 output?.name ?? resourceName(organization, location, templateId);
239 const existing = yield* getByName(name);
240 if (existing === undefined) return undefined;
241 const attrs = toAttrs(existing, organization, env.project);
242 return (yield* ownedByAlchemy(id, existing.description))
243 ? attrs
244 : Unowned(attrs);
245 }),
246
247 list: () =>
248 Effect.gen(function* () {
249 const env = yield* GcpEnvironment.current;
250 const organization = yield* tryResolveOrganization();
251 if (organization === undefined) return [];
252 const pages = yield* Effect.forEach(
253 locationParentsOf(organization, env.region),
254 (parent) => listAt(parent, organization, env.project),
255 { concurrency: 3 },
256 );
257 return pages.flat();
258 }),
259
260 reconcile: Effect.fn(function* ({ id, news, output }) {
261 const env = yield* GcpEnvironment.current;
262 const organization = yield* resolveOrganization(
263 news.organization,
264 output?.organization,
265 );
266 const location = normalizeLocation(news.location ?? output?.location);
267 const templateId = yield* toPhysicalId(
268 id,
269 news.templateId,
270 output?.templateId,
271 );
272 const parent = organizationLocationParent(organization, location);
273 const name = resourceName(organization, location, templateId);
274 const ownership = yield* createInternalLabels(id);
275 const description = encodeDescription(ownership, news.description);
276 const displayName = news.displayName;
277 const inspectConfig = news.inspectConfig;
278 const allowLimitedAvailabilityInfoTypes =
279 news.allowLimitedAvailabilityInfoTypes === true;
280
281 let current = yield* getByName(output?.name ?? name);
282
283 if (current === undefined) {
284 const created = yield* dlp
285 .createOrganizationsLocationsInspectTemplates({
286 parent,
287 body: {
288 templateId,
289 inspectTemplate: {
290 displayName,
291 description,
292 inspectConfig,
293 allowLimitedAvailabilityInfoTypes,
294 },
295 },
296 })
297 .pipe(Effect.catchTag("Conflict", () => getByName(name)));
298 current = created ?? undefined;
299 }
300
301 if (current === undefined) {
302 return yield* new DlpNotResolved({ name });
303 }
304
305 const currentName = current.name ?? name;
306 const displayChanged = !sameText(current.displayName, displayName);
307 const descriptionChanged = !sameText(current.description, description);
308 const configChanged =
309 fingerprint(current.inspectConfig) !== fingerprint(inspectConfig);
310 const limitedChanged =
311 (current.allowLimitedAvailabilityInfoTypes === true) !==
312 allowLimitedAvailabilityInfoTypes;
313 const updateMask = updateMaskOf(
314 displayChanged ? "displayName" : undefined,
315 descriptionChanged ? "description" : undefined,
316 configChanged ? "inspectConfig" : undefined,
317 limitedChanged ? "allowLimitedAvailabilityInfoTypes" : undefined,
318 );
319
320 if (updateMask.length > 0) {
321 current = yield* dlp.patchOrganizationsLocationsInspectTemplates({
322 name: currentName,
323 body: {
324 updateMask,
325 inspectTemplate: {
326 displayName,
327 description,
328 inspectConfig,
329 allowLimitedAvailabilityInfoTypes,
330 },
331 },
332 });
333 }
334
335 return toAttrs(current, organization, env.project);
336 }),
337
338 delete: Effect.fn(function* ({ output }) {
339 yield* dlp
340 .deleteOrganizationsLocationsInspectTemplates({ name: output.name })
341 .pipe(Effect.catchTag("NotFound", () => Effect.void));
342 }),
343 });