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 DeidentifyConfig = dlp.GooglePrivacyDlpV2DeidentifyConfig;
34
35export type OrganizationsLocationsDeidentifyTemplateProps = {
36 /**
37 * Template id (the `{deidentifyTemplate}` segment of
38 * `organizations/{organization}/locations/{location}/deidentifyTemplates/{deidentifyTemplate}`).
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). Deidentify 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 * De-identification configuration applied when this template is used.
68 */
69 deidentifyConfig: DeidentifyConfig;
70};
71
72export type OrganizationsLocationsDeidentifyTemplate = Resource<
73 "GCP.DLP.OrganizationsLocationsDeidentifyTemplate",
74 OrganizationsLocationsDeidentifyTemplateProps,
75 {
76 /** Full resource name. */
77 name: string;
78 /** Template id (last path segment). */
79 templateId: string;
80 /** Organization resource name. */
81 organization: string;
82 /** Organization id. */
83 organizationId: string;
84 /** Location id. */
85 location: string;
86 /** Project id of the deploying stack. */
87 project: string;
88 /** User display name. */
89 displayName: string | undefined;
90 /** User description with the Alchemy ownership prefix stripped. */
91 description: string | undefined;
92 /** De-identification configuration. */
93 deidentifyConfig: DeidentifyConfig | undefined;
94 /** RFC3339 creation timestamp. */
95 createTime: string | undefined;
96 /** RFC3339 last-update timestamp. */
97 updateTime: string | undefined;
98 },
99 never,
100 Providers
101>;
102
103/**
104 * A location-scoped Sensitive Data Protection de-identify template on an
105 * organization.
106 *
107 * Templates have no labels field — Alchemy stamps ownership into the
108 * description so `list` / nuke can find them. Template id, organization,
109 * and location are identity. Display name, description, and de-identify
110 * config update in place.
111 *
112 * ### Creating a Location Deidentify Template
113 * **Example:** Redact matched infoTypes in us-central1
114 * ```typescript
115 * const template = yield* GCP.DLP.OrganizationsLocationsDeidentifyTemplate(
116 * "RedactPhone",
117 * {
118 * location: "us-central1",
119 * deidentifyConfig: {
120 * infoTypeTransformations: {
121 * transformations: [
122 * {
123 * primitiveTransformation: { replaceWithInfoTypeConfig: {} },
124 * },
125 * ],
126 * },
127 * },
128 * },
129 * );
130 * ```
131 *
132 * @resource
133 * @category DLP
134 */
135export const OrganizationsLocationsDeidentifyTemplate =
136 Resource<OrganizationsLocationsDeidentifyTemplate>(
137 "GCP.DLP.OrganizationsLocationsDeidentifyTemplate",
138 );
139
140const resourceName = (
141 organization: string,
142 location: string,
143 templateId: string,
144) =>
145 `${organizationLocationParent(organization, location)}/deidentifyTemplates/${templateId}`;
146
147const toAttrs = (
148 template: dlp.GooglePrivacyDlpV2DeidentifyTemplate,
149 organization: string,
150 project: string,
151) => {
152 const name = template.name ?? "";
153 const parsed = parseName(name, "deidentifyTemplates");
154 const ownership = parseOwnership(template.description);
155 return {
156 name,
157 templateId: parsed.id || lastSegment(name),
158 organization,
159 organizationId: organizationIdOf(organization),
160 location: parsed.location || DEFAULT_LOCATION,
161 project,
162 displayName: template.displayName,
163 description: ownership.text,
164 deidentifyConfig: template.deidentifyConfig,
165 createTime: template.createTime,
166 updateTime: template.updateTime,
167 };
168};
169
170const getByName = (name: string) =>
171 name.length === 0
172 ? Effect.succeed(undefined)
173 : dlp
174 .getOrganizationsLocationsDeidentifyTemplates({ name })
175 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
176
177const listAt = (parent: string, organization: string, project: string) =>
178 dlp.listOrganizationsLocationsDeidentifyTemplates
179 .pages({ parent, pageSize: 100 })
180 .pipe(
181 Stream.flatMap((page) =>
182 Stream.fromIterable(page.deidentifyTemplates ?? []),
183 ),
184 Stream.filter((template) => hasOwnershipMarker(template.description)),
185 Stream.map((template) => toAttrs(template, organization, project)),
186 Stream.runCollect,
187 Effect.map((chunk) => Array.from(chunk)),
188 Effect.catchTag("NotFound", () => Effect.succeed([])),
189 );
190
191export const OrganizationsLocationsDeidentifyTemplateProvider = () =>
192 Provider.succeed(OrganizationsLocationsDeidentifyTemplate, {
193 stables: [
194 "name",
195 "templateId",
196 "organization",
197 "organizationId",
198 "location",
199 "project",
200 "createTime",
201 ],
202
203 diff: Effect.fn(function* ({ news, olds, output }) {
204 if (!isResolved(news)) return undefined;
205 const previousLocation = normalizeLocation(
206 olds?.location ?? output?.location,
207 );
208 const nextLocation = normalizeLocation(
209 news.location ?? olds?.location ?? output?.location,
210 );
211 return (
212 replaceOn(olds?.templateId ?? output?.templateId, news.templateId) ??
213 replaceOn(
214 olds?.organization ?? output?.organization,
215 news.organization,
216 ) ??
217 replaceOn(previousLocation, nextLocation)
218 );
219 }),
220
221 read: Effect.fn(function* ({ id, olds, output }) {
222 const env = yield* GcpEnvironment.current;
223 const organization = yield* resolveOrganization(
224 olds?.organization ?? output?.organization,
225 output?.organization,
226 );
227 const location = normalizeLocation(olds?.location ?? output?.location);
228 const templateId = yield* toPhysicalId(
229 id,
230 olds?.templateId,
231 output?.templateId,
232 );
233 const name =
234 output?.name ?? resourceName(organization, location, templateId);
235 const existing = yield* getByName(name);
236 if (existing === undefined) return undefined;
237 const attrs = toAttrs(existing, organization, env.project);
238 return (yield* ownedByAlchemy(id, existing.description))
239 ? attrs
240 : Unowned(attrs);
241 }),
242
243 list: () =>
244 Effect.gen(function* () {
245 const env = yield* GcpEnvironment.current;
246 const organization = yield* tryResolveOrganization();
247 if (organization === undefined) return [];
248 const pages = yield* Effect.forEach(
249 locationParentsOf(organization, env.region),
250 (parent) => listAt(parent, organization, env.project),
251 { concurrency: 3 },
252 );
253 return pages.flat();
254 }),
255
256 reconcile: Effect.fn(function* ({ id, news, output }) {
257 const env = yield* GcpEnvironment.current;
258 const organization = yield* resolveOrganization(
259 news.organization,
260 output?.organization,
261 );
262 const location = normalizeLocation(news.location ?? output?.location);
263 const templateId = yield* toPhysicalId(
264 id,
265 news.templateId,
266 output?.templateId,
267 );
268 const parent = organizationLocationParent(organization, location);
269 const name = resourceName(organization, location, templateId);
270 const ownership = yield* createInternalLabels(id);
271 const description = encodeDescription(ownership, news.description);
272 const displayName = news.displayName;
273 const deidentifyConfig = news.deidentifyConfig;
274
275 let current = yield* getByName(output?.name ?? name);
276
277 if (current === undefined) {
278 const created = yield* dlp
279 .createOrganizationsLocationsDeidentifyTemplates({
280 parent,
281 body: {
282 templateId,
283 deidentifyTemplate: {
284 displayName,
285 description,
286 deidentifyConfig,
287 },
288 },
289 })
290 .pipe(Effect.catchTag("Conflict", () => getByName(name)));
291 current = created ?? undefined;
292 }
293
294 if (current === undefined) {
295 return yield* new DlpNotResolved({ name });
296 }
297
298 const currentName = current.name ?? name;
299 const displayChanged = !sameText(current.displayName, displayName);
300 const descriptionChanged = !sameText(current.description, description);
301 const configChanged =
302 fingerprint(current.deidentifyConfig) !== fingerprint(deidentifyConfig);
303 const updateMask = updateMaskOf(
304 displayChanged ? "displayName" : undefined,
305 descriptionChanged ? "description" : undefined,
306 configChanged ? "deidentifyConfig" : undefined,
307 );
308
309 if (updateMask.length > 0) {
310 current = yield* dlp.patchOrganizationsLocationsDeidentifyTemplates({
311 name: currentName,
312 body: {
313 updateMask,
314 deidentifyTemplate: {
315 displayName,
316 description,
317 deidentifyConfig,
318 },
319 },
320 });
321 }
322
323 return toAttrs(current, organization, env.project);
324 }),
325
326 delete: Effect.fn(function* ({ output }) {
327 yield* dlp
328 .deleteOrganizationsLocationsDeidentifyTemplates({ name: output.name })
329 .pipe(Effect.catchTag("NotFound", () => Effect.void));
330 }),
331 });