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_REGIONAL_LOCATION,
13 MAX_CONTENT_POLICY_DISPLAY_NAME_LENGTH,
14 collectPages,
15 encodeOwnershipLine,
16 hasOwnershipMarker,
17 jsonEqual,
18 lastSegment,
19 locationOf,
20 locationParent,
21 normalizeLocation,
22 ownedByAlchemy,
23 parseOwnership,
24 projectOf,
25 replaceOnIdentity,
26 toResourceId,
27 updateMaskOf,
28} from "./internal.ts";
29
30export type PolicyRule = dlp.GooglePrivacyDlpV2PolicyRule;
31export type PolicyAction = dlp.GooglePrivacyDlpV2PolicyAction;
32export type LoggingConfig = dlp.GooglePrivacyDlpV2LoggingConfig;
33
34export type ContentPolicyProps = {
35 /**
36 * Content policy id (the `{contentPolicy}` segment of
37 * `projects/{project}/locations/{location}/contentPolicies/{id}`). If
38 * omitted, a unique name is generated. Must match `[a-zA-Z0-9_-]+` and
39 * is at most 100 characters. Immutable — changing it replaces the
40 * policy.
41 */
42 contentPolicyId?: string;
43 /**
44 * Processing location (`us`, `us-central1`, …). Immutable — changing it
45 * replaces the policy.
46 * @default "us"
47 */
48 location?: string;
49 /**
50 * Display name (max 63 characters). Content policies have no labels or
51 * description field, so Alchemy ownership is stored in a `[alchemy …]`
52 * prefix and stripped from attributes.
53 */
54 displayName?: string;
55 /**
56 * Inspection configuration used to produce findings.
57 */
58 inspectConfig?: dlp.GooglePrivacyDlpV2InspectConfig;
59 /**
60 * Policy rules. The first matching rule applies.
61 */
62 rules?: PolicyRule[];
63 /**
64 * Action when no rule matches. Defaults to ALLOW.
65 */
66 defaultAction?: PolicyAction;
67 /**
68 * Action when the content is an unsupported file type.
69 */
70 unsupportedFileType?: PolicyAction;
71 /**
72 * Action when the content is a supported file type but too large.
73 */
74 inputTooLarge?: PolicyAction;
75 /**
76 * Action when a supported file fails to scan.
77 */
78 failedToScanSupportedFileType?: PolicyAction;
79 /**
80 * Optional logging configuration (for example BigQuery).
81 */
82 loggingConfigs?: LoggingConfig[];
83};
84
85export type ContentPolicy = Resource<
86 "GCP.DLP.ContentPolicy",
87 ContentPolicyProps,
88 {
89 /** Full resource name. */
90 name: string;
91 /** Content policy id (last path segment). */
92 contentPolicyId: string;
93 /** Location id. */
94 location: string;
95 /** Project id. */
96 project: string;
97 /** User display name with the Alchemy ownership prefix stripped. */
98 displayName: string | undefined;
99 /** Inspection configuration. */
100 inspectConfig: dlp.GooglePrivacyDlpV2InspectConfig | undefined;
101 /** Policy rules. */
102 rules: PolicyRule[];
103 /** Default action. */
104 defaultAction: PolicyAction | undefined;
105 /** Unsupported file type action. */
106 unsupportedFileType: PolicyAction | undefined;
107 /** Input too large action. */
108 inputTooLarge: PolicyAction | undefined;
109 /** Failed-to-scan action. */
110 failedToScanSupportedFileType: PolicyAction | undefined;
111 /** Logging configs. */
112 loggingConfigs: LoggingConfig[];
113 /** RFC3339 creation timestamp. */
114 createTime: string | undefined;
115 /** RFC3339 last-update timestamp. */
116 updateTime: string | undefined;
117 },
118 never,
119 Providers
120>;
121
122/**
123 * A location-scoped Cloud DLP content policy.
124 *
125 * Content policies have no labels field, so Alchemy stamps ownership
126 * into the display name for `list` / nuke. Location and id are identity
127 * — changing them replaces the policy. Display name, inspect config, and
128 * rules update in place.
129 *
130 * ### Creating a Content Policy
131 * **Example:** Block email addresses
132 * ```typescript
133 * const policy = yield* GCP.DLP.ContentPolicy("BlockEmail", {
134 * displayName: "block-email",
135 * inspectConfig: { infoTypes: [{ name: "EMAIL_ADDRESS" }] },
136 * rules: [
137 * {
138 * conditions: [
139 * {
140 * infoTypeCondition: {
141 * infoTypes: { infoTypeNames: ["EMAIL_ADDRESS"] },
142 * },
143 * },
144 * ],
145 * action: { returnVerdict: "BLOCK" },
146 * },
147 * ],
148 * defaultAction: { returnVerdict: "ALLOW" },
149 * });
150 * ```
151 *
152 * @resource
153 * @category DLP
154 */
155export const ContentPolicy = Resource<ContentPolicy>("GCP.DLP.ContentPolicy");
156
157export class ContentPolicyNotResolved extends Data.TaggedError(
158 "GCP.DLP.ContentPolicyNotResolved",
159)<{
160 name: string;
161}> {}
162
163const resourceName = (
164 project: string,
165 location: string,
166 contentPolicyId: string,
167) => `${locationParent(project, location)}/contentPolicies/${contentPolicyId}`;
168
169const toAttrs = (
170 policy: dlp.GooglePrivacyDlpV2ContentPolicy,
171 project: string,
172) => {
173 const name = policy.name ?? "";
174 const parsed = parseOwnership(policy.displayName);
175 return {
176 name,
177 contentPolicyId: lastSegment(name),
178 location: locationOf(name, DEFAULT_REGIONAL_LOCATION),
179 project: projectOf(name) || project,
180 displayName: parsed.text,
181 inspectConfig: policy.inspectConfig,
182 rules: policy.rules ?? [],
183 defaultAction: policy.defaultAction,
184 unsupportedFileType: policy.unsupportedFileType,
185 inputTooLarge: policy.inputTooLarge,
186 failedToScanSupportedFileType: policy.failedToScanSupportedFileType,
187 loggingConfigs: policy.loggingConfigs ?? [],
188 createTime: policy.createTime,
189 updateTime: policy.updateTime,
190 };
191};
192
193const getByName = (name: string) =>
194 name.length === 0
195 ? Effect.succeed(undefined)
196 : dlp
197 .getProjectsLocationsContentPolicies({ name })
198 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
199
200export const ContentPolicyProvider = () =>
201 Provider.succeed(ContentPolicy, {
202 stables: ["name", "contentPolicyId", "location", "project", "createTime"],
203
204 diff: Effect.fn(function* ({ news, olds, output }) {
205 if (!isResolved(news)) return undefined;
206 const previousId = olds?.contentPolicyId ?? output?.contentPolicyId;
207 const idChanged =
208 previousId !== undefined &&
209 news.contentPolicyId !== undefined &&
210 news.contentPolicyId !== previousId;
211 const previousLocation = olds?.location ?? output?.location;
212 const locationChanged =
213 previousLocation !== undefined &&
214 news.location !== undefined &&
215 normalizeLocation(news.location, DEFAULT_REGIONAL_LOCATION) !==
216 normalizeLocation(previousLocation, DEFAULT_REGIONAL_LOCATION);
217 return replaceOnIdentity(idChanged || locationChanged);
218 }),
219
220 read: Effect.fn(function* ({ id, olds, output }) {
221 const env = yield* GcpEnvironment.current;
222 const contentPolicyId = yield* toResourceId(
223 id,
224 olds?.contentPolicyId,
225 output?.contentPolicyId,
226 );
227 const location = normalizeLocation(
228 olds?.location ?? output?.location,
229 DEFAULT_REGIONAL_LOCATION,
230 );
231 const name =
232 output?.name ?? resourceName(env.project, location, contentPolicyId);
233 const existing = yield* getByName(name);
234 if (existing === undefined) return undefined;
235 const attrs = toAttrs(existing, env.project);
236 return (yield* ownedByAlchemy(id, existing.displayName))
237 ? attrs
238 : Unowned(attrs);
239 }),
240
241 list: () =>
242 Effect.gen(function* () {
243 const env = yield* GcpEnvironment.current;
244 const items = yield* collectPages(
245 dlp.listProjectsLocationsContentPolicies.pages({
246 parent: locationParent(env.project, DEFAULT_REGIONAL_LOCATION),
247 pageSize: 100,
248 }),
249 (page) => page.contentPolicies,
250 ).pipe(
251 Effect.catchTag("NotFound", () =>
252 Effect.succeed([] as dlp.GooglePrivacyDlpV2ContentPolicy[]),
253 ),
254 );
255 return items
256 .filter((policy) => hasOwnershipMarker(policy.displayName))
257 .map((policy) => toAttrs(policy, env.project));
258 }),
259
260 reconcile: Effect.fn(function* ({ id, news, output }) {
261 const env = yield* GcpEnvironment.current;
262 const location = normalizeLocation(
263 news.location ?? output?.location ?? DEFAULT_REGIONAL_LOCATION,
264 DEFAULT_REGIONAL_LOCATION,
265 );
266 const contentPolicyId = yield* toResourceId(
267 id,
268 news.contentPolicyId,
269 output?.contentPolicyId,
270 );
271 const name = resourceName(env.project, location, contentPolicyId);
272 const ownership = yield* createInternalLabels(id);
273 const displayName = encodeOwnershipLine(
274 ownership,
275 news.displayName,
276 MAX_CONTENT_POLICY_DISPLAY_NAME_LENGTH,
277 );
278 const body: dlp.GooglePrivacyDlpV2ContentPolicy = {
279 displayName,
280 inspectConfig: news.inspectConfig,
281 rules: news.rules,
282 defaultAction: news.defaultAction,
283 unsupportedFileType: news.unsupportedFileType,
284 inputTooLarge: news.inputTooLarge,
285 failedToScanSupportedFileType: news.failedToScanSupportedFileType,
286 loggingConfigs: news.loggingConfigs,
287 };
288
289 let current = yield* getByName(output?.name ?? name);
290
291 if (current === undefined) {
292 const created = yield* dlp
293 .createProjectsLocationsContentPolicies({
294 parent: locationParent(env.project, location),
295 body: {
296 contentPolicyId,
297 contentPolicy: body,
298 },
299 })
300 .pipe(Effect.catchTag("Conflict", () => getByName(name)));
301 current = created ?? undefined;
302 }
303
304 if (current === undefined) {
305 return yield* new ContentPolicyNotResolved({ name });
306 }
307
308 const displayChanged = (current.displayName ?? "") !== displayName;
309 const inspectChanged = !jsonEqual(
310 current.inspectConfig,
311 news.inspectConfig,
312 );
313 const rulesChanged = !jsonEqual(current.rules, news.rules);
314 const defaultChanged = !jsonEqual(
315 current.defaultAction,
316 news.defaultAction,
317 );
318 const unsupportedChanged = !jsonEqual(
319 current.unsupportedFileType,
320 news.unsupportedFileType,
321 );
322 const tooLargeChanged = !jsonEqual(
323 current.inputTooLarge,
324 news.inputTooLarge,
325 );
326 const failedChanged = !jsonEqual(
327 current.failedToScanSupportedFileType,
328 news.failedToScanSupportedFileType,
329 );
330 const loggingChanged = !jsonEqual(
331 current.loggingConfigs,
332 news.loggingConfigs,
333 );
334
335 if (
336 displayChanged ||
337 inspectChanged ||
338 rulesChanged ||
339 defaultChanged ||
340 unsupportedChanged ||
341 tooLargeChanged ||
342 failedChanged ||
343 loggingChanged
344 ) {
345 current = yield* dlp.patchProjectsLocationsContentPolicies({
346 name: current.name ?? name,
347 body: {
348 contentPolicy: body,
349 updateMask: updateMaskOf(
350 displayChanged ? "displayName" : undefined,
351 inspectChanged ? "inspectConfig" : undefined,
352 rulesChanged ? "rules" : undefined,
353 defaultChanged ? "defaultAction" : undefined,
354 unsupportedChanged ? "unsupportedFileType" : undefined,
355 tooLargeChanged ? "inputTooLarge" : undefined,
356 failedChanged ? "failedToScanSupportedFileType" : undefined,
357 loggingChanged ? "loggingConfigs" : undefined,
358 ),
359 },
360 });
361 }
362
363 return toAttrs(current, env.project);
364 }),
365
366 delete: Effect.fn(function* ({ output }) {
367 yield* dlp
368 .deleteProjectsLocationsContentPolicies({ name: output.name })
369 .pipe(Effect.catchTag("NotFound", () => Effect.void));
370 }),
371 });