1import * as dlp from "@distilled.cloud/gcp/dlp_v2";
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 DEFAULT_LOCATION,
13 collectPages,
14 encodeOwnership,
15 hasOwnershipMarker,
16 jsonEqual,
17 lastSegment,
18 locationOf,
19 locationParent,
20 normalizeLocation,
21 ownedByAlchemy,
22 parseOwnership,
23 projectOf,
24 replaceOnIdentity,
25 sameText,
26 toResourceId,
27 updateMaskOf,
28} from "./internal.ts";
29
30export type LocationsDeidentifyTemplateProps = {
31 /**
32 * Template id (the `{deidentifyTemplate}` segment of
33 * `projects/{project}/locations/{location}/deidentifyTemplates/{id}`).
34 * If omitted, a unique name is generated. Must match `[a-zA-Z0-9_-]+`
35 * and is at most 100 characters. Immutable — changing it replaces the
36 * template.
37 */
38 templateId?: string;
39 /**
40 * Processing location (`global`, `us`, `us-central1`, …). Immutable —
41 * changing it replaces the template.
42 * @default "global"
43 */
44 location?: string;
45 /**
46 * Display name (max 256 characters).
47 */
48 displayName?: string;
49 /**
50 * Human-readable description (max 256 characters). Deidentify templates
51 * have no labels field, so Alchemy ownership is stored in a
52 * `[alchemy …]` prefix and stripped from attributes.
53 */
54 description?: string;
55 /**
56 * De-identify configuration applied when this template is referenced.
57 */
58 deidentifyConfig?: dlp.GooglePrivacyDlpV2DeidentifyConfig;
59};
60
61export type LocationsDeidentifyTemplate = Resource<
62 "GCP.DLP.LocationsDeidentifyTemplate",
63 LocationsDeidentifyTemplateProps,
64 {
65 /** Full resource name. */
66 name: string;
67 /** Template id (last path segment). */
68 templateId: string;
69 /** Location id. */
70 location: string;
71 /** Project id. */
72 project: string;
73 /** Display name. */
74 displayName: string | undefined;
75 /** User description with the Alchemy ownership prefix stripped. */
76 description: string | undefined;
77 /** De-identify configuration. */
78 deidentifyConfig: dlp.GooglePrivacyDlpV2DeidentifyConfig | undefined;
79 /** RFC3339 creation timestamp. */
80 createTime: string | undefined;
81 /** RFC3339 last-update timestamp. */
82 updateTime: string | undefined;
83 },
84 never,
85 Providers
86>;
87
88/**
89 * A location-scoped Cloud DLP de-identify template.
90 *
91 * Templates have no labels field, so Alchemy stamps ownership into the
92 * description for `list` / nuke. Location and template id are identity —
93 * changing them replaces the template. Display name, description, and
94 * de-identify config update in place.
95 *
96 * ### Creating a Location Deidentify Template
97 * **Example:** Redact emails in `global`
98 * ```typescript
99 * const template = yield* GCP.DLP.LocationsDeidentifyTemplate("Emails", {
100 * location: "global",
101 * displayName: "redact emails",
102 * deidentifyConfig: {
103 * infoTypeTransformations: {
104 * transformations: [
105 * {
106 * infoTypes: [{ name: "EMAIL_ADDRESS" }],
107 * primitiveTransformation: { replaceWithInfoTypeConfig: {} },
108 * },
109 * ],
110 * },
111 * },
112 * });
113 * ```
114 *
115 * @resource
116 * @category DLP
117 */
118export const LocationsDeidentifyTemplate =
119 Resource<LocationsDeidentifyTemplate>("GCP.DLP.LocationsDeidentifyTemplate");
120
121export class LocationsDeidentifyTemplateNotResolved extends Data.TaggedError(
122 "GCP.DLP.LocationsDeidentifyTemplateNotResolved",
123)<{
124 name: string;
125}> {}
126
127const resourceName = (project: string, location: string, templateId: string) =>
128 `${locationParent(project, location)}/deidentifyTemplates/${templateId}`;
129
130const toAttrs = (
131 template: dlp.GooglePrivacyDlpV2DeidentifyTemplate,
132 project: string,
133) => {
134 const name = template.name ?? "";
135 const parsed = parseOwnership(template.description);
136 return {
137 name,
138 templateId: lastSegment(name),
139 location: locationOf(name),
140 project: projectOf(name) || project,
141 displayName: template.displayName,
142 description: parsed.text,
143 deidentifyConfig: template.deidentifyConfig,
144 createTime: template.createTime,
145 updateTime: template.updateTime,
146 };
147};
148
149const getByName = (name: string) =>
150 name.length === 0
151 ? Effect.succeed(undefined)
152 : dlp
153 .getProjectsLocationsDeidentifyTemplates({ name })
154 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
155
156export const LocationsDeidentifyTemplateProvider = () =>
157 Provider.succeed(LocationsDeidentifyTemplate, {
158 stables: ["name", "templateId", "location", "project", "createTime"],
159
160 diff: Effect.fn(function* ({ news, olds, output }) {
161 if (!isResolved(news)) return undefined;
162 const previousId = olds?.templateId ?? output?.templateId;
163 const idChanged =
164 previousId !== undefined &&
165 news.templateId !== undefined &&
166 news.templateId !== previousId;
167 const previousLocation = olds?.location ?? output?.location;
168 const locationChanged =
169 previousLocation !== undefined &&
170 news.location !== undefined &&
171 normalizeLocation(news.location) !==
172 normalizeLocation(previousLocation);
173 return replaceOnIdentity(idChanged || locationChanged);
174 }),
175
176 read: Effect.fn(function* ({ id, olds, output }) {
177 const env = yield* GcpEnvironment.current;
178 const templateId = yield* toResourceId(
179 id,
180 olds?.templateId,
181 output?.templateId,
182 );
183 const location = normalizeLocation(olds?.location ?? output?.location);
184 const name =
185 output?.name ?? resourceName(env.project, location, templateId);
186 const existing = yield* getByName(name);
187 if (existing === undefined) return undefined;
188 const attrs = toAttrs(existing, env.project);
189 return (yield* ownedByAlchemy(id, existing.description))
190 ? attrs
191 : Unowned(attrs);
192 }),
193
194 list: () =>
195 Effect.gen(function* () {
196 const env = yield* GcpEnvironment.current;
197 const items = yield* collectPages(
198 dlp.listProjectsLocationsDeidentifyTemplates.pages({
199 parent: locationParent(env.project, DEFAULT_LOCATION),
200 pageSize: 100,
201 }),
202 (page) => page.deidentifyTemplates,
203 ).pipe(
204 Effect.catchTag("NotFound", () =>
205 Effect.succeed([] as dlp.GooglePrivacyDlpV2DeidentifyTemplate[]),
206 ),
207 );
208 return items
209 .filter((template) => hasOwnershipMarker(template.description))
210 .map((template) => toAttrs(template, env.project));
211 }),
212
213 reconcile: Effect.fn(function* ({ id, news, output }) {
214 const env = yield* GcpEnvironment.current;
215 const location = normalizeLocation(
216 news.location ?? output?.location ?? DEFAULT_LOCATION,
217 );
218 const templateId = yield* toResourceId(
219 id,
220 news.templateId,
221 output?.templateId,
222 );
223 const name = resourceName(env.project, location, templateId);
224 const ownership = yield* createInternalLabels(id);
225 const description = encodeOwnership(ownership, news.description);
226 const body: dlp.GooglePrivacyDlpV2DeidentifyTemplate = {
227 displayName: news.displayName,
228 description,
229 deidentifyConfig: news.deidentifyConfig,
230 };
231
232 let current = yield* getByName(output?.name ?? name);
233
234 if (current === undefined) {
235 const created = yield* dlp
236 .createProjectsLocationsDeidentifyTemplates({
237 parent: locationParent(env.project, location),
238 body: {
239 templateId,
240 deidentifyTemplate: body,
241 },
242 })
243 .pipe(Effect.catchTag("Conflict", () => getByName(name)));
244 current = created ?? undefined;
245 }
246
247 if (current === undefined) {
248 return yield* new LocationsDeidentifyTemplateNotResolved({ name });
249 }
250
251 const displayChanged = !sameText(current.displayName, news.displayName);
252 const descriptionChanged = (current.description ?? "") !== description;
253 const configChanged = !jsonEqual(
254 current.deidentifyConfig,
255 news.deidentifyConfig,
256 );
257
258 if (displayChanged || descriptionChanged || configChanged) {
259 current = yield* dlp.patchProjectsLocationsDeidentifyTemplates({
260 name: current.name ?? name,
261 body: {
262 deidentifyTemplate: body,
263 updateMask: updateMaskOf(
264 displayChanged ? "displayName" : undefined,
265 descriptionChanged ? "description" : undefined,
266 configChanged ? "deidentifyConfig" : undefined,
267 ),
268 },
269 });
270 }
271
272 return toAttrs(current, env.project);
273 }),
274
275 delete: Effect.fn(function* ({ output }) {
276 yield* dlp
277 .deleteProjectsLocationsDeidentifyTemplates({ name: output.name })
278 .pipe(Effect.catchTag("NotFound", () => Effect.void));
279 }),
280 });