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 InspectConfig = dlp.GooglePrivacyDlpV2InspectConfig;
30
31export type OrganizationsInspectTemplateProps = {
32 /**
33 * Template id (the `{inspectTemplate}` segment of
34 * `organizations/{organization}/inspectTemplates/{inspectTemplate}`).
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). Inspect 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 * Inspection configuration applied when this template is used.
58 */
59 inspectConfig: InspectConfig;
60 /**
61 * Allow limited-availability built-in infoTypes in `inspectConfig`.
62 * @default false
63 */
64 allowLimitedAvailabilityInfoTypes?: boolean;
65};
66
67export type OrganizationsInspectTemplate = Resource<
68 "GCP.DLP.OrganizationsInspectTemplate",
69 OrganizationsInspectTemplateProps,
70 {
71 /** Full resource name `organizations/{organization}/inspectTemplates/{inspectTemplate}`. */
72 name: string;
73 /** Template id (last path segment). */
74 templateId: string;
75 /** Organization resource name. */
76 organization: string;
77 /** Organization id. */
78 organizationId: string;
79 /** Project id of the deploying stack. */
80 project: string;
81 /** User display name. */
82 displayName: string | undefined;
83 /** User description with the Alchemy ownership prefix stripped. */
84 description: string | undefined;
85 /** Inspection configuration. */
86 inspectConfig: InspectConfig | undefined;
87 /** Whether limited-availability infoTypes are allowed. */
88 allowLimitedAvailabilityInfoTypes: boolean;
89 /** RFC3339 creation timestamp. */
90 createTime: string | undefined;
91 /** RFC3339 last-update timestamp. */
92 updateTime: string | undefined;
93 },
94 never,
95 Providers
96>;
97
98/**
99 * An organization-scoped Sensitive Data Protection inspect template.
100 *
101 * Templates have no labels field — Alchemy stamps ownership into the
102 * description so `list` / nuke can find them. Template id and organization
103 * are identity. Display name, description, and inspect config update in
104 * place.
105 *
106 * ### Creating an Inspect Template
107 * **Example:** Detect email addresses
108 * ```typescript
109 * const template = yield* GCP.DLP.OrganizationsInspectTemplate("Emails", {
110 * displayName: "emails",
111 * inspectConfig: {
112 * infoTypes: [{ name: "EMAIL_ADDRESS" }],
113 * includeQuote: true,
114 * },
115 * });
116 * ```
117 *
118 * **Example:** Named template on an explicit organization
119 * ```typescript
120 * const template = yield* GCP.DLP.OrganizationsInspectTemplate("Emails", {
121 * organization: "organizations/123456789",
122 * templateId: "emails",
123 * inspectConfig: {
124 * infoTypes: [{ name: "EMAIL_ADDRESS" }],
125 * },
126 * });
127 * ```
128 *
129 * @resource
130 * @category DLP
131 */
132export const OrganizationsInspectTemplate =
133 Resource<OrganizationsInspectTemplate>(
134 "GCP.DLP.OrganizationsInspectTemplate",
135 );
136
137const resourceName = (organization: string, templateId: string) =>
138 `${organization}/inspectTemplates/${templateId}`;
139
140const toAttrs = (
141 template: dlp.GooglePrivacyDlpV2InspectTemplate,
142 organization: string,
143 project: string,
144) => {
145 const name = template.name ?? "";
146 const parsed = parseName(name, "inspectTemplates");
147 const ownership = parseOwnership(template.description);
148 return {
149 name,
150 templateId: parsed.id || lastSegment(name),
151 organization,
152 organizationId: organizationIdOf(organization),
153 project,
154 displayName: template.displayName,
155 description: ownership.text,
156 inspectConfig: template.inspectConfig,
157 allowLimitedAvailabilityInfoTypes:
158 template.allowLimitedAvailabilityInfoTypes === true,
159 createTime: template.createTime,
160 updateTime: template.updateTime,
161 };
162};
163
164const getByName = (name: string) =>
165 name.length === 0
166 ? Effect.succeed(undefined)
167 : dlp
168 .getOrganizationsInspectTemplates({ name })
169 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
170
171export const OrganizationsInspectTemplateProvider = () =>
172 Provider.succeed(OrganizationsInspectTemplate, {
173 stables: [
174 "name",
175 "templateId",
176 "organization",
177 "organizationId",
178 "project",
179 "createTime",
180 ],
181
182 diff: Effect.fn(function* ({ news, olds, output }) {
183 if (!isResolved(news)) return undefined;
184 return (
185 replaceOn(olds?.templateId ?? output?.templateId, news.templateId) ??
186 replaceOn(olds?.organization ?? output?.organization, news.organization)
187 );
188 }),
189
190 read: Effect.fn(function* ({ id, olds, output }) {
191 const env = yield* GcpEnvironment.current;
192 const organization = yield* resolveOrganization(
193 olds?.organization ?? output?.organization,
194 output?.organization,
195 );
196 const templateId = yield* toPhysicalId(
197 id,
198 olds?.templateId,
199 output?.templateId,
200 );
201 const name = output?.name ?? resourceName(organization, templateId);
202 const existing = yield* getByName(name);
203 if (existing === undefined) return undefined;
204 const attrs = toAttrs(existing, organization, env.project);
205 return (yield* ownedByAlchemy(id, existing.description))
206 ? attrs
207 : Unowned(attrs);
208 }),
209
210 list: () =>
211 Effect.gen(function* () {
212 const env = yield* GcpEnvironment.current;
213 const organization = yield* tryResolveOrganization();
214 if (organization === undefined) return [];
215 return yield* dlp.listOrganizationsInspectTemplates
216 .pages({ parent: organization, pageSize: 100 })
217 .pipe(
218 Stream.flatMap((page) =>
219 Stream.fromIterable(page.inspectTemplates ?? []),
220 ),
221 Stream.filter((template) =>
222 hasOwnershipMarker(template.description),
223 ),
224 Stream.map((template) =>
225 toAttrs(template, organization, env.project),
226 ),
227 Stream.runCollect,
228 Effect.map((chunk) => Array.from(chunk)),
229 Effect.catchTag("NotFound", () => Effect.succeed([])),
230 );
231 }),
232
233 reconcile: Effect.fn(function* ({ id, news, output }) {
234 const env = yield* GcpEnvironment.current;
235 const organization = yield* resolveOrganization(
236 news.organization,
237 output?.organization,
238 );
239 const templateId = yield* toPhysicalId(
240 id,
241 news.templateId,
242 output?.templateId,
243 );
244 const name = resourceName(organization, templateId);
245 const ownership = yield* createInternalLabels(id);
246 const description = encodeDescription(ownership, news.description);
247 const displayName = news.displayName;
248 const inspectConfig = news.inspectConfig;
249 const allowLimitedAvailabilityInfoTypes =
250 news.allowLimitedAvailabilityInfoTypes === true;
251
252 let current = yield* getByName(output?.name ?? name);
253
254 if (current === undefined) {
255 const created = yield* dlp
256 .createOrganizationsInspectTemplates({
257 parent: organization,
258 body: {
259 templateId,
260 inspectTemplate: {
261 displayName,
262 description,
263 inspectConfig,
264 allowLimitedAvailabilityInfoTypes,
265 },
266 },
267 })
268 .pipe(Effect.catchTag("Conflict", () => getByName(name)));
269 current = created ?? undefined;
270 }
271
272 if (current === undefined) {
273 return yield* new DlpNotResolved({ name });
274 }
275
276 const currentName = current.name ?? name;
277 const displayChanged = !sameText(current.displayName, displayName);
278 const descriptionChanged = !sameText(current.description, description);
279 const configChanged =
280 fingerprint(current.inspectConfig) !== fingerprint(inspectConfig);
281 const limitedChanged =
282 (current.allowLimitedAvailabilityInfoTypes === true) !==
283 allowLimitedAvailabilityInfoTypes;
284 const updateMask = updateMaskOf(
285 displayChanged ? "displayName" : undefined,
286 descriptionChanged ? "description" : undefined,
287 configChanged ? "inspectConfig" : undefined,
288 limitedChanged ? "allowLimitedAvailabilityInfoTypes" : undefined,
289 );
290
291 if (updateMask.length > 0) {
292 current = yield* dlp.patchOrganizationsInspectTemplates({
293 name: currentName,
294 body: {
295 updateMask,
296 inspectTemplate: {
297 displayName,
298 description,
299 inspectConfig,
300 allowLimitedAvailabilityInfoTypes,
301 },
302 },
303 });
304 }
305
306 return toAttrs(current, organization, env.project);
307 }),
308
309 delete: Effect.fn(function* ({ output }) {
310 yield* dlp
311 .deleteOrganizationsInspectTemplates({ name: output.name })
312 .pipe(Effect.catchTag("NotFound", () => Effect.void));
313 }),
314 });