1import * as bqdp from "@distilled.cloud/gcp/bigquerydatapolicy_v2";
2import * as Data from "effect/Data";
3import * as Effect from "effect/Effect";
4import * as Schedule from "effect/Schedule";
5import * as Stream from "effect/Stream";
6import { Unowned } from "../../AdoptPolicy.ts";
7import { isResolved } from "../../Diff.ts";
8import * as Provider from "../../Provider.ts";
9import { Resource } from "../../Resource.ts";
10import { GcpEnvironment } from "../Environment.ts";
11import type { Providers } from "../Providers.ts";
12import {
13 LIST_LOCATIONS,
14 compact,
15 hasOwnershipMarker,
16 lastSegment,
17 normalizeLocation,
18 normalizePolicyType,
19 ownedByAlchemy,
20 parentOf,
21 parseName,
22 resourceNameOf,
23 sameJson,
24 sameStringList,
25 sameText,
26 sortedStrings,
27 toDataPolicyId,
28 updateMaskOf,
29} from "./internal.ts";
30
31export type DataPolicyType = bqdp.DataPolicyDataPolicyTypeEnum | (string & {});
32
33export type DataMaskingPolicyPredefinedExpression =
34 | bqdp.DataMaskingPolicyPredefinedExpressionEnum
35 | (string & {});
36
37export type DataMaskingPolicy = {
38 /**
39 * Predefined masking expression (`SHA256`, `ALWAYS_NULL`,
40 * `DEFAULT_MASKING_VALUE`, `LAST_FOUR_CHARACTERS`,
41 * `FIRST_FOUR_CHARACTERS`, `EMAIL_MASK`, `DATE_YEAR_MASK`,
42 * `RANDOM_HASH`). Mutually exclusive with `routine`.
43 */
44 predefinedExpression?: DataMaskingPolicyPredefinedExpression;
45 /**
46 * BigQuery routine that implements a custom masking function, as
47 * `projects/{project}/datasets/{dataset}/routines/{routine}`. Mutually
48 * exclusive with `predefinedExpression`.
49 */
50 routine?: string;
51};
52
53export type DataGovernanceTag = {
54 /**
55 * Namespaced tag key (`parent-id/pii`). Tag keys are globally unique
56 * Resource Manager keys.
57 */
58 key?: string;
59 /**
60 * Short tag value (`sensitive`).
61 */
62 value?: string;
63};
64
65export type DataPolicyProps = {
66 /**
67 * Data policy id (the `{dataPolicy}` segment of
68 * `projects/{project}/locations/{location}/dataPolicies/{dataPolicy}`).
69 * If omitted, a unique name is generated from the stack, stage, and
70 * logical id. Letters, digits, and underscores; max 200 characters.
71 * Data policies have no labels field, so Alchemy stamps ownership into
72 * this id (`alch___…`) for `list` / nuke. Immutable — changing it
73 * replaces the policy.
74 */
75 dataPolicyId?: string;
76 /**
77 * BigQuery location (`us-central1`, `US`, `EU`, …). Immutable —
78 * changing it replaces the policy. Multi-regions `US` and `EU` stay
79 * uppercase; regional ids are lowercased (`US-CENTRAL1` becomes
80 * `us-central1`).
81 * @default the stack's GCP region (`GCP.Region`, else the profile region, else `us-central1`)
82 */
83 location?: string;
84 /**
85 * Type of data policy. `COLUMN_LEVEL_SECURITY_POLICY` is deprecated in
86 * the v2 API (GET/LIST of v1 policies only).
87 * @default "RAW_DATA_ACCESS_POLICY"
88 */
89 dataPolicyType?: DataPolicyType;
90 /**
91 * Masking rule. Required when `dataPolicyType` is
92 * `DATA_MASKING_POLICY`.
93 */
94 dataMaskingPolicy?: DataMaskingPolicy;
95 /**
96 * IAM V2 principals with fine-grained access to the governed data
97 * (`principal://goog/subject/user@example.com`). V2 policies only.
98 */
99 grantees?: string[];
100 /**
101 * Resource Manager data-governance tag bound to this policy.
102 */
103 dataGovernanceTag?: DataGovernanceTag;
104};
105
106export type DataPolicy = Resource<
107 "GCP.BigQueryDataPolicy.DataPolicy",
108 DataPolicyProps,
109 {
110 /** Full resource name `projects/{project}/locations/{location}/dataPolicies/{dataPolicy}`. */
111 name: string;
112 /** Data policy id (last path segment), including the Alchemy prefix. */
113 dataPolicyId: string;
114 /** Project id used when the policy was reconciled. */
115 project: string;
116 /** Location id (`us-central1`, `US`, …). */
117 location: string;
118 /** Policy type. */
119 dataPolicyType: string | undefined;
120 /** Masking rule, if this is a data-masking policy. */
121 dataMaskingPolicy: DataMaskingPolicy | undefined;
122 /** Fine-grained access principals. */
123 grantees: string[];
124 /** Bound data-governance tag, if any. */
125 dataGovernanceTag: DataGovernanceTag | undefined;
126 /** Policy-tag resource name (v1 policies only). */
127 policyTag: string | undefined;
128 /** Server-reported policy version (`V1`, `V2`). */
129 version: string | undefined;
130 /** Server etag used for concurrent updates. */
131 etag: string | undefined;
132 },
133 never,
134 Providers
135>;
136
137/**
138 * A BigQuery Data Policy (v2) — raw-data access or column data masking.
139 *
140 * Data policies have no labels, so Alchemy stamps ownership into the
141 * policy id (`alch___…`) so `list` / `pnpm nuke:gcp` can find them.
142 * `dataPolicyId` and `location` are identity — changing either replaces
143 * the policy. Type, masking rule, grantees, and the data-governance tag
144 * update in place. Patches send the observed etag.
145 *
146 * ### Creating a Data Policy
147 * **Example:** Raw-data access
148 * ```typescript
149 * const policy = yield* GCP.BigQueryDataPolicy.DataPolicy("Raw", {
150 * dataPolicyType: "RAW_DATA_ACCESS_POLICY",
151 * });
152 * ```
153 *
154 * **Example:** SHA256 masking
155 * ```typescript
156 * const policy = yield* GCP.BigQueryDataPolicy.DataPolicy("Mask", {
157 * location: "us-central1",
158 * dataPolicyType: "DATA_MASKING_POLICY",
159 * dataMaskingPolicy: { predefinedExpression: "SHA256" },
160 * });
161 * ```
162 *
163 * ### Updating a Data Policy
164 * Change props on the same logical id; the engine keeps the physical id.
165 *
166 * **Example:** Change the masking expression
167 * ```typescript
168 * const policy = yield* GCP.BigQueryDataPolicy.DataPolicy("Mask", {
169 * location: "us-central1",
170 * dataPolicyType: "DATA_MASKING_POLICY",
171 * dataMaskingPolicy: { predefinedExpression: "ALWAYS_NULL" },
172 * });
173 * ```
174 *
175 * @resource
176 * @category BigQueryDataPolicy
177 */
178export const DataPolicy = Resource<DataPolicy>(
179 "GCP.BigQueryDataPolicy.DataPolicy",
180);
181
182export class DataPolicyNotResolved extends Data.TaggedError(
183 "GCP.BigQueryDataPolicy.DataPolicyNotResolved",
184)<{
185 name: string;
186}> {}
187
188const toMasking = (
189 policy: bqdp.DataMaskingPolicy | undefined,
190): DataMaskingPolicy | undefined => {
191 if (policy === undefined) return undefined;
192 const next = compact({
193 predefinedExpression: policy.predefinedExpression,
194 routine: policy.routine,
195 });
196 return Object.keys(next).length > 0 ? next : undefined;
197};
198
199const toGovernanceTag = (
200 tag: bqdp.DataGovernanceTag | undefined,
201): DataGovernanceTag | undefined => {
202 if (tag === undefined) return undefined;
203 const next = compact({ key: tag.key, value: tag.value });
204 return Object.keys(next).length > 0 ? next : undefined;
205};
206
207const toAttrs = (policy: bqdp.DataPolicy, project: string, region: string) => {
208 const name = policy.name ?? "";
209 const parsed = parseName(name, project, region);
210 return {
211 name,
212 dataPolicyId: policy.dataPolicyId ?? parsed.dataPolicyId,
213 project,
214 location: parsed.location,
215 dataPolicyType: policy.dataPolicyType,
216 dataMaskingPolicy: toMasking(policy.dataMaskingPolicy),
217 grantees: sortedStrings(policy.grantees),
218 dataGovernanceTag: toGovernanceTag(policy.dataGovernanceTag),
219 policyTag: policy.policyTag,
220 version: policy.version,
221 etag: policy.etag,
222 };
223};
224
225const getByName = (name: string) =>
226 name.length === 0
227 ? Effect.succeed(undefined)
228 : bqdp
229 .getProjectsLocationsDataPolicies({ name })
230 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
231
232const listAt = (project: string, location: string) =>
233 bqdp.listProjectsLocationsDataPolicies
234 .pages({
235 parent: parentOf(project, location),
236 pageSize: 1000,
237 })
238 .pipe(
239 Stream.flatMap((page) => Stream.fromIterable(page.dataPolicies ?? [])),
240 Stream.runCollect,
241 Effect.map((chunk) => Array.from(chunk)),
242 Effect.catchTag("NotFound", () =>
243 Effect.succeed([] as bqdp.DataPolicy[]),
244 ),
245 );
246
247const findOwned = (
248 items: readonly bqdp.DataPolicy[],
249 id: string,
250 name?: string,
251) =>
252 Effect.gen(function* () {
253 if (name !== undefined && name.length > 0) {
254 const match = items.find((item) => item.name === name);
255 if (match !== undefined) return match;
256 }
257 for (const item of items) {
258 const resourceId = item.dataPolicyId ?? lastSegment(item.name ?? "");
259 if (yield* ownedByAlchemy(id, resourceId)) return item;
260 }
261 return undefined;
262 });
263
264const observe = (
265 id: string,
266 name: string | undefined,
267 project: string,
268 location: string,
269) =>
270 Effect.gen(function* () {
271 if (name !== undefined && name.length > 0) {
272 const existing = yield* getByName(name);
273 if (existing !== undefined) return existing;
274 }
275 const items = yield* listAt(project, location);
276 return yield* findOwned(items, id, name);
277 });
278
279const toCreateBody = (news: DataPolicyProps, dataPolicyType: string) =>
280 compact({
281 dataPolicyType,
282 dataMaskingPolicy: news.dataMaskingPolicy,
283 grantees: news.grantees,
284 dataGovernanceTag: news.dataGovernanceTag,
285 });
286
287const patchPolicy = (name: string, updateMask: string, body: bqdp.DataPolicy) =>
288 Effect.gen(function* () {
289 const latest = yield* getByName(name);
290 if (latest === undefined) {
291 return yield* new DataPolicyNotResolved({ name });
292 }
293 return yield* bqdp.patchProjectsLocationsDataPolicies({
294 name,
295 updateMask,
296 body: compact({
297 ...body,
298 etag: latest.etag,
299 }),
300 });
301 }).pipe(
302 Effect.retry({
303 while: (error) => error._tag === "Conflict",
304 times: 8,
305 schedule: Schedule.spaced("1 second"),
306 }),
307 );
308
309export const DataPolicyProvider = () =>
310 Provider.succeed(DataPolicy, {
311 stables: ["name", "dataPolicyId", "project", "location"],
312
313 diff: Effect.fn(function* ({ id, news, olds, output }) {
314 if (!isResolved(news)) return undefined;
315 const env = yield* GcpEnvironment.current;
316 const previousId = olds?.dataPolicyId ?? output?.dataPolicyId;
317 const nextId = yield* toDataPolicyId(id, news.dataPolicyId, previousId);
318 const previousLocation = normalizeLocation(
319 olds?.location ?? output?.location,
320 env.region,
321 );
322 const nextLocation = normalizeLocation(
323 news.location ?? output?.location,
324 env.region,
325 );
326 if (
327 (previousId === undefined || nextId === previousId) &&
328 previousLocation === nextLocation
329 ) {
330 return undefined;
331 }
332 return {
333 action: "replace" as const,
334 deleteFirst: false,
335 };
336 }),
337
338 read: Effect.fn(function* ({ id, olds, output }) {
339 const env = yield* GcpEnvironment.current;
340 const dataPolicyId = yield* toDataPolicyId(
341 id,
342 olds?.dataPolicyId,
343 output?.dataPolicyId,
344 );
345 const location = normalizeLocation(
346 olds?.location ?? output?.location,
347 env.region,
348 );
349 const name =
350 output?.name ?? resourceNameOf(env.project, location, dataPolicyId);
351 const existing = yield* observe(id, name, env.project, location);
352 if (existing === undefined) return undefined;
353 const attrs = toAttrs(existing, env.project, env.region);
354 const resourceId =
355 existing.dataPolicyId ?? lastSegment(existing.name ?? "");
356 return (yield* ownedByAlchemy(id, resourceId)) ? attrs : Unowned(attrs);
357 }),
358
359 list: () =>
360 Effect.gen(function* () {
361 const env = yield* GcpEnvironment.current;
362 const pages = yield* Effect.forEach(
363 Array.from(new Set<string>([...LIST_LOCATIONS, env.region])),
364 (location) => listAt(env.project, location),
365 { concurrency: 4 },
366 );
367 const byName = new Map<string, ReturnType<typeof toAttrs>>();
368 for (const item of pages.flat()) {
369 const resourceId = item.dataPolicyId ?? lastSegment(item.name ?? "");
370 if (!hasOwnershipMarker(resourceId)) continue;
371 const attrs = toAttrs(item, env.project, env.region);
372 if (attrs.name.length > 0) byName.set(attrs.name, attrs);
373 }
374 return Array.from(byName.values());
375 }),
376
377 reconcile: Effect.fn(function* ({ id, news, output }) {
378 const env = yield* GcpEnvironment.current;
379 const dataPolicyId = yield* toDataPolicyId(
380 id,
381 news.dataPolicyId,
382 output?.dataPolicyId,
383 );
384 const location = normalizeLocation(
385 news.location ?? output?.location,
386 env.region,
387 );
388 const parent = parentOf(env.project, location);
389 const name =
390 output?.name ?? resourceNameOf(env.project, location, dataPolicyId);
391 const dataPolicyType = normalizePolicyType(
392 news.dataPolicyType ?? output?.dataPolicyType,
393 );
394
395 let current = yield* observe(id, name, env.project, location);
396
397 if (current === undefined) {
398 const created = yield* bqdp
399 .createProjectsLocationsDataPolicies({
400 parent,
401 body: {
402 dataPolicyId,
403 dataPolicy: toCreateBody(news, dataPolicyType),
404 },
405 })
406 .pipe(
407 Effect.catchTag("Conflict", () =>
408 observe(id, name, env.project, location),
409 ),
410 );
411 current = created ?? undefined;
412 }
413
414 if (current === undefined) {
415 return yield* new DataPolicyNotResolved({ name });
416 }
417
418 const currentName = current.name ?? name;
419 const desiredType = normalizePolicyType(
420 news.dataPolicyType ?? current.dataPolicyType,
421 );
422 const observedType = normalizePolicyType(current.dataPolicyType);
423 const typeChanged = !sameText(observedType, desiredType);
424 const maskingChanged =
425 news.dataMaskingPolicy !== undefined &&
426 !sameJson(toMasking(current.dataMaskingPolicy), news.dataMaskingPolicy);
427 const granteesChanged =
428 news.grantees !== undefined &&
429 !sameStringList(current.grantees, news.grantees);
430 const tagChanged =
431 news.dataGovernanceTag !== undefined &&
432 !sameJson(
433 toGovernanceTag(current.dataGovernanceTag),
434 news.dataGovernanceTag,
435 );
436
437 const updateMask = updateMaskOf(
438 typeChanged ? "dataPolicyType" : undefined,
439 maskingChanged ? "dataMaskingPolicy" : undefined,
440 granteesChanged ? "grantees" : undefined,
441 tagChanged ? "dataGovernanceTag" : undefined,
442 );
443
444 if (updateMask.length > 0) {
445 current = yield* patchPolicy(
446 currentName,
447 updateMask,
448 compact({
449 dataPolicyType: typeChanged ? desiredType : undefined,
450 dataMaskingPolicy: maskingChanged
451 ? news.dataMaskingPolicy
452 : undefined,
453 grantees: granteesChanged ? news.grantees : undefined,
454 dataGovernanceTag: tagChanged ? news.dataGovernanceTag : undefined,
455 }),
456 );
457 }
458
459 return toAttrs(current, env.project, env.region);
460 }),
461
462 delete: Effect.fn(function* ({ output }) {
463 yield* bqdp
464 .deleteProjectsLocationsDataPolicies({ name: output.name })
465 .pipe(Effect.catchTag("NotFound", () => Effect.void));
466 }),
467 });