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 collectPages,
13 encodeOwnership,
14 hasOwnershipMarker,
15 jsonEqual,
16 lastSegment,
17 ownedByAlchemy,
18 parseOwnership,
19 projectOf,
20 replaceOnIdentity,
21 sameText,
22 toResourceId,
23 updateMaskOf,
24} from "./internal.ts";
25
26type DeidentifyConfig = dlp.GooglePrivacyDlpV2DeidentifyConfig;
27
28export type DeidentifyTemplateProps = {
29 /**
30 * Template id (the `{deidentifyTemplate}` segment of
31 * `projects/{project}/deidentifyTemplates/{deidentifyTemplate}`). If
32 * omitted, a unique name is generated. Must match `[a-zA-Z0-9_-]+` and
33 * is at most 100 characters. Immutable — changing it replaces the
34 * template.
35 */
36 templateId?: string;
37 /**
38 * Display name (max 256 characters).
39 */
40 displayName?: string;
41 /**
42 * Human-readable description (max 256 characters). Deidentify templates
43 * have no labels field, so Alchemy ownership is stored in a
44 * `[alchemy …]` prefix and stripped from attributes.
45 */
46 description?: string;
47 /**
48 * De-identify configuration applied when this template is referenced.
49 */
50 deidentifyConfig?: DeidentifyConfig;
51};
52
53export type DeidentifyTemplate = Resource<
54 "GCP.DLP.DeidentifyTemplate",
55 DeidentifyTemplateProps,
56 {
57 /** Full resource name `projects/{project}/deidentifyTemplates/{id}`. */
58 name: string;
59 /** Template id (last path segment). */
60 templateId: string;
61 /** Project id. */
62 project: string;
63 /** Display name. */
64 displayName: string | undefined;
65 /** User description with the Alchemy ownership prefix stripped. */
66 description: string | undefined;
67 /** De-identify configuration. */
68 deidentifyConfig: DeidentifyConfig | undefined;
69 /** RFC3339 creation timestamp. */
70 createTime: string | undefined;
71 /** RFC3339 last-update timestamp. */
72 updateTime: string | undefined;
73 },
74 never,
75 Providers
76>;
77
78/**
79 * A project-scoped Cloud DLP de-identify template.
80 *
81 * Templates have no labels field, so Alchemy stamps ownership into the
82 * description for `list` / nuke. Template id is identity — changing it
83 * replaces the template. Display name, description, and de-identify
84 * config update in place.
85 *
86 * ### Creating a Deidentify Template
87 * **Example:** Redact email addresses
88 * ```typescript
89 * const template = yield* GCP.DLP.DeidentifyTemplate("Emails", {
90 * displayName: "redact emails",
91 * description: "replace email findings",
92 * deidentifyConfig: {
93 * infoTypeTransformations: {
94 * transformations: [
95 * {
96 * infoTypes: [{ name: "EMAIL_ADDRESS" }],
97 * primitiveTransformation: { replaceWithInfoTypeConfig: {} },
98 * },
99 * ],
100 * },
101 * },
102 * });
103 * ```
104 *
105 * ### Updating a Deidentify Template
106 * **Example:** Also redact phone numbers
107 * ```typescript
108 * const template = yield* GCP.DLP.DeidentifyTemplate("Emails", {
109 * displayName: "redact emails and phones",
110 * deidentifyConfig: {
111 * infoTypeTransformations: {
112 * transformations: [
113 * {
114 * infoTypes: [
115 * { name: "EMAIL_ADDRESS" },
116 * { name: "PHONE_NUMBER" },
117 * ],
118 * primitiveTransformation: { replaceWithInfoTypeConfig: {} },
119 * },
120 * ],
121 * },
122 * },
123 * });
124 * ```
125 *
126 * @resource
127 * @category DLP
128 */
129export const DeidentifyTemplate = Resource<DeidentifyTemplate>(
130 "GCP.DLP.DeidentifyTemplate",
131);
132
133export class DeidentifyTemplateNotResolved extends Data.TaggedError(
134 "GCP.DLP.DeidentifyTemplateNotResolved",
135)<{
136 name: string;
137}> {}
138
139const resourceName = (project: string, templateId: string) =>
140 `projects/${project}/deidentifyTemplates/${templateId}`;
141
142const toAttrs = (
143 template: dlp.GooglePrivacyDlpV2DeidentifyTemplate,
144 project: string,
145) => {
146 const name = template.name ?? "";
147 const parsed = parseOwnership(template.description);
148 return {
149 name,
150 templateId: lastSegment(name),
151 project: projectOf(name) || project,
152 displayName: template.displayName,
153 description: parsed.text,
154 deidentifyConfig: template.deidentifyConfig,
155 createTime: template.createTime,
156 updateTime: template.updateTime,
157 };
158};
159
160const getByName = (name: string) =>
161 name.length === 0
162 ? Effect.succeed(undefined)
163 : dlp
164 .getProjectsDeidentifyTemplates({ name })
165 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
166
167export const DeidentifyTemplateProvider = () =>
168 Provider.succeed(DeidentifyTemplate, {
169 stables: ["name", "templateId", "project", "createTime"],
170
171 diff: Effect.fn(function* ({ news, olds, output }) {
172 if (!isResolved(news)) return undefined;
173 const previous = olds?.templateId ?? output?.templateId;
174 return replaceOnIdentity(
175 previous !== undefined &&
176 news.templateId !== undefined &&
177 news.templateId !== previous,
178 );
179 }),
180
181 read: Effect.fn(function* ({ id, olds, output }) {
182 const env = yield* GcpEnvironment.current;
183 const templateId = yield* toResourceId(
184 id,
185 olds?.templateId,
186 output?.templateId,
187 );
188 const name = output?.name ?? resourceName(env.project, templateId);
189 const existing = yield* getByName(name);
190 if (existing === undefined) return undefined;
191 const attrs = toAttrs(existing, env.project);
192 return (yield* ownedByAlchemy(id, existing.description))
193 ? attrs
194 : Unowned(attrs);
195 }),
196
197 list: () =>
198 Effect.gen(function* () {
199 const env = yield* GcpEnvironment.current;
200 const items = yield* collectPages(
201 dlp.listProjectsDeidentifyTemplates.pages({
202 parent: `projects/${env.project}`,
203 pageSize: 100,
204 }),
205 (page) => page.deidentifyTemplates,
206 ).pipe(
207 Effect.catchTag("NotFound", () =>
208 Effect.succeed([] as dlp.GooglePrivacyDlpV2DeidentifyTemplate[]),
209 ),
210 );
211 return items
212 .filter((template) => hasOwnershipMarker(template.description))
213 .map((template) => toAttrs(template, env.project));
214 }),
215
216 reconcile: Effect.fn(function* ({ id, news, output }) {
217 const env = yield* GcpEnvironment.current;
218 const templateId = yield* toResourceId(
219 id,
220 news.templateId,
221 output?.templateId,
222 );
223 const name = resourceName(env.project, 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 .createProjectsDeidentifyTemplates({
237 parent: `projects/${env.project}`,
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 DeidentifyTemplateNotResolved({ 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.patchProjectsDeidentifyTemplates({
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 .deleteProjectsDeidentifyTemplates({ name: output.name })
278 .pipe(Effect.catchTag("NotFound", () => Effect.void));
279 }),
280 });