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 DlpNotResolved,
13 encodeDescription,
14 fingerprint,
15 hasOwnershipMarker,
16 lastSegment,
17 organizationIdOf,
18 ownedByAlchemy,
19 parseName,
20 parseOwnership,
21 replaceOn,
22 resolveOrganization,
23 sameText,
24 toPhysicalId,
25 tryResolveOrganization,
26 updateMaskOf,
27} from "./internal.ts";
28
29export type DeidentifyConfig = dlp.GooglePrivacyDlpV2DeidentifyConfig;
30
31export type OrganizationsDeidentifyTemplateProps = {
32 /**
33 * Template id (the `{deidentifyTemplate}` segment of
34 * `organizations/{organization}/deidentifyTemplates/{deidentifyTemplate}`).
35 * If omitted, a unique id is generated from the stack, stage, and
36 * logical id. Letters, digits, hyphens, and underscores; max 100
37 * characters. Immutable — changing it replaces the template.
38 */
39 templateId?: string;
40 /**
41 * Parent organization (`organizations/{organization}` or the numeric
42 * id). Defaults to `GOOGLE_ORGANIZATION_ID` or the project's Resource
43 * Manager ancestor. Immutable — changing it replaces the template.
44 */
45 organization?: string;
46 /**
47 * Human-readable display name (max 256 characters).
48 */
49 displayName?: string;
50 /**
51 * Human-readable description (max 256 characters). Deidentify templates
52 * have no labels field, so Alchemy ownership is stored in a
53 * `[alchemy …]` prefix and stripped from attributes.
54 */
55 description?: string;
56 /**
57 * De-identification configuration applied when this template is used.
58 */
59 deidentifyConfig: DeidentifyConfig;
60};
61
62export type OrganizationsDeidentifyTemplate = Resource<
63 "GCP.DLP.OrganizationsDeidentifyTemplate",
64 OrganizationsDeidentifyTemplateProps,
65 {
66 /** Full resource name `organizations/{organization}/deidentifyTemplates/{deidentifyTemplate}`. */
67 name: string;
68 /** Template id (last path segment). */
69 templateId: string;
70 /** Organization resource name. */
71 organization: string;
72 /** Organization id. */
73 organizationId: string;
74 /** Project id of the deploying stack. */
75 project: string;
76 /** User display name. */
77 displayName: string | undefined;
78 /** User description with the Alchemy ownership prefix stripped. */
79 description: string | undefined;
80 /** De-identification configuration. */
81 deidentifyConfig: DeidentifyConfig | undefined;
82 /** RFC3339 creation timestamp. */
83 createTime: string | undefined;
84 /** RFC3339 last-update timestamp. */
85 updateTime: string | undefined;
86 },
87 never,
88 Providers
89>;
90
91/**
92 * An organization-scoped Sensitive Data Protection de-identify template.
93 *
94 * Templates have no labels field — Alchemy stamps ownership into the
95 * description so `list` / nuke can find them. Template id and organization
96 * are identity. Display name, description, and de-identify config update
97 * in place.
98 *
99 * ### Creating a Deidentify Template
100 * **Example:** Redact matched infoTypes
101 * ```typescript
102 * const template = yield* GCP.DLP.OrganizationsDeidentifyTemplate(
103 * "RedactEmail",
104 * {
105 * displayName: "redact-email",
106 * deidentifyConfig: {
107 * infoTypeTransformations: {
108 * transformations: [
109 * {
110 * primitiveTransformation: { replaceWithInfoTypeConfig: {} },
111 * },
112 * ],
113 * },
114 * },
115 * },
116 * );
117 * ```
118 *
119 * **Example:** Named template on an explicit organization
120 * ```typescript
121 * const template = yield* GCP.DLP.OrganizationsDeidentifyTemplate(
122 * "RedactEmail",
123 * {
124 * organization: "organizations/123456789",
125 * templateId: "redact-email",
126 * deidentifyConfig: {
127 * infoTypeTransformations: {
128 * transformations: [
129 * {
130 * primitiveTransformation: { replaceWithInfoTypeConfig: {} },
131 * },
132 * ],
133 * },
134 * },
135 * },
136 * );
137 * ```
138 *
139 * @resource
140 * @category DLP
141 */
142export const OrganizationsDeidentifyTemplate =
143 Resource<OrganizationsDeidentifyTemplate>(
144 "GCP.DLP.OrganizationsDeidentifyTemplate",
145 );
146
147const resourceName = (organization: string, templateId: string) =>
148 `${organization}/deidentifyTemplates/${templateId}`;
149
150const toAttrs = (
151 template: dlp.GooglePrivacyDlpV2DeidentifyTemplate,
152 organization: string,
153 project: string,
154) => {
155 const name = template.name ?? "";
156 const parsed = parseName(name, "deidentifyTemplates");
157 const ownership = parseOwnership(template.description);
158 return {
159 name,
160 templateId: parsed.id || lastSegment(name),
161 organization,
162 organizationId: organizationIdOf(organization),
163 project,
164 displayName: template.displayName,
165 description: ownership.text,
166 deidentifyConfig: template.deidentifyConfig,
167 createTime: template.createTime,
168 updateTime: template.updateTime,
169 };
170};
171
172const getByName = (name: string) =>
173 name.length === 0
174 ? Effect.succeed(undefined)
175 : dlp
176 .getOrganizationsDeidentifyTemplates({ name })
177 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
178
179export const OrganizationsDeidentifyTemplateProvider = () =>
180 Provider.succeed(OrganizationsDeidentifyTemplate, {
181 stables: [
182 "name",
183 "templateId",
184 "organization",
185 "organizationId",
186 "project",
187 "createTime",
188 ],
189
190 diff: Effect.fn(function* ({ news, olds, output }) {
191 if (!isResolved(news)) return undefined;
192 return (
193 replaceOn(olds?.templateId ?? output?.templateId, news.templateId) ??
194 replaceOn(olds?.organization ?? output?.organization, news.organization)
195 );
196 }),
197
198 read: Effect.fn(function* ({ id, olds, output }) {
199 const env = yield* GcpEnvironment.current;
200 const organization = yield* resolveOrganization(
201 olds?.organization ?? output?.organization,
202 output?.organization,
203 );
204 const templateId = yield* toPhysicalId(
205 id,
206 olds?.templateId,
207 output?.templateId,
208 );
209 const name = output?.name ?? resourceName(organization, templateId);
210 const existing = yield* getByName(name);
211 if (existing === undefined) return undefined;
212 const attrs = toAttrs(existing, organization, env.project);
213 return (yield* ownedByAlchemy(id, existing.description))
214 ? attrs
215 : Unowned(attrs);
216 }),
217
218 list: () =>
219 Effect.gen(function* () {
220 const env = yield* GcpEnvironment.current;
221 const organization = yield* tryResolveOrganization();
222 if (organization === undefined) return [];
223 return yield* dlp.listOrganizationsDeidentifyTemplates
224 .pages({ parent: organization, pageSize: 100 })
225 .pipe(
226 Stream.flatMap((page) =>
227 Stream.fromIterable(page.deidentifyTemplates ?? []),
228 ),
229 Stream.filter((template) =>
230 hasOwnershipMarker(template.description),
231 ),
232 Stream.map((template) =>
233 toAttrs(template, organization, env.project),
234 ),
235 Stream.runCollect,
236 Effect.map((chunk) => Array.from(chunk)),
237 Effect.catchTag("NotFound", () => Effect.succeed([])),
238 );
239 }),
240
241 reconcile: Effect.fn(function* ({ id, news, output }) {
242 const env = yield* GcpEnvironment.current;
243 const organization = yield* resolveOrganization(
244 news.organization,
245 output?.organization,
246 );
247 const templateId = yield* toPhysicalId(
248 id,
249 news.templateId,
250 output?.templateId,
251 );
252 const name = resourceName(organization, templateId);
253 const ownership = yield* createInternalLabels(id);
254 const description = encodeDescription(ownership, news.description);
255 const displayName = news.displayName;
256 const deidentifyConfig = news.deidentifyConfig;
257
258 let current = yield* getByName(output?.name ?? name);
259
260 if (current === undefined) {
261 const created = yield* dlp
262 .createOrganizationsDeidentifyTemplates({
263 parent: organization,
264 body: {
265 templateId,
266 deidentifyTemplate: {
267 displayName,
268 description,
269 deidentifyConfig,
270 },
271 },
272 })
273 .pipe(Effect.catchTag("Conflict", () => getByName(name)));
274 current = created ?? undefined;
275 }
276
277 if (current === undefined) {
278 return yield* new DlpNotResolved({ name });
279 }
280
281 const currentName = current.name ?? name;
282 const displayChanged = !sameText(current.displayName, displayName);
283 const descriptionChanged = !sameText(current.description, description);
284 const configChanged =
285 fingerprint(current.deidentifyConfig) !== fingerprint(deidentifyConfig);
286 const updateMask = updateMaskOf(
287 displayChanged ? "displayName" : undefined,
288 descriptionChanged ? "description" : undefined,
289 configChanged ? "deidentifyConfig" : undefined,
290 );
291
292 if (updateMask.length > 0) {
293 current = yield* dlp.patchOrganizationsDeidentifyTemplates({
294 name: currentName,
295 body: {
296 updateMask,
297 deidentifyTemplate: {
298 displayName,
299 description,
300 deidentifyConfig,
301 },
302 },
303 });
304 }
305
306 return toAttrs(current, organization, env.project);
307 }),
308
309 delete: Effect.fn(function* ({ output }) {
310 yield* dlp
311 .deleteOrganizationsDeidentifyTemplates({ name: output.name })
312 .pipe(Effect.catchTag("NotFound", () => Effect.void));
313 }),
314 });