1import * as orgpolicy from "@distilled.cloud/gcp/orgpolicy_v2";
2import * as Data from "effect/Data";
3import * as Effect from "effect/Effect";
4import * as Schedule from "effect/Schedule";
5import { isResolved } from "../../Diff.ts";
6import * as Provider from "../../Provider.ts";
7import { Resource } from "../../Resource.ts";
8import { GcpEnvironment } from "../Environment.ts";
9import type { Providers } from "../Providers.ts";
10
11export type PolicyParentKind = "projects" | "folders" | "organizations";
12
13export type PolicyRuleValues = {
14 /**
15 * Values allowed at this resource. List constraints only.
16 */
17 allowedValues?: string[];
18 /**
19 * Values denied at this resource. List constraints only.
20 */
21 deniedValues?: string[];
22};
23
24export type PolicyRuleCondition = {
25 /**
26 * CEL expression. Must use `resource.matchTag`, `resource.matchTagId`,
27 * `resource.hasTagKey`, or `resource.hasTagKeyId`.
28 */
29 expression?: string;
30 /**
31 * Short title shown in UIs.
32 */
33 title?: string;
34 /**
35 * Longer description of the condition.
36 */
37 description?: string;
38 /**
39 * Location string for error reporting.
40 */
41 location?: string;
42};
43
44export type PolicyRule = {
45 /**
46 * Allow every value. List constraints only.
47 */
48 allowAll?: boolean;
49 /**
50 * Deny every value. List constraints only.
51 */
52 denyAll?: boolean;
53 /**
54 * When true, the boolean/managed constraint is enforced. When false,
55 * any configuration is acceptable. Boolean constraints only.
56 */
57 enforce?: boolean;
58 /**
59 * Allowed and denied values. List constraints only.
60 */
61 values?: PolicyRuleValues;
62 /**
63 * Tag-based CEL condition that selects when this rule applies.
64 */
65 condition?: PolicyRuleCondition;
66 /**
67 * Parameter values for managed constraints.
68 */
69 parameters?: Record<string, unknown>;
70};
71
72export type PolicySpec = {
73 /**
74 * When true, list-constraint rules from ancestors are inherited.
75 * List constraints only.
76 */
77 inheritFromParent?: boolean;
78 /**
79 * Restore the constraint default at this resource. When true, `rules`
80 * must be empty and `inheritFromParent` must be false.
81 */
82 reset?: boolean;
83 /**
84 * Policy rules. Boolean constraints must have exactly one rule with no
85 * condition.
86 */
87 rules?: PolicyRule[];
88};
89
90export type PolicyProps = {
91 /**
92 * Constraint this policy configures, e.g. `compute.disableSerialPortAccess`
93 * or `constraints/compute.disableSerialPortAccess`. The `constraints/`
94 * prefix is stripped. Immutable — changing it replaces the policy.
95 */
96 constraint: string;
97 /**
98 * Parent resource: `projects/{project}`, `folders/{folder}`, or
99 * `organizations/{organization}`. Defaults to the stack's GCP project.
100 * Immutable — changing it replaces the policy. Responses may rewrite a
101 * project id to the equivalent project number.
102 */
103 parent?: string;
104 /**
105 * Enforcement spec. Omitted on a later update leaves the live spec
106 * unchanged.
107 */
108 spec?: PolicySpec;
109 /**
110 * Dry-run (audit-only) spec. Omitted on a later update leaves the live
111 * dry-run spec unchanged.
112 */
113 dryRunSpec?: PolicySpec;
114};
115
116export type Policy = Resource<
117 "GCP.OrgPolicy.Policy",
118 PolicyProps,
119 {
120 /** Full resource name `projects/{project}/policies/{constraint}`. */
121 name: string;
122 /** Constraint id (no `constraints/` prefix). */
123 constraint: string;
124 /** Parent `projects/{project}`, `folders/{folder}`, or `organizations/{organization}`. */
125 parent: string;
126 /** Project id of the deploying stack. */
127 project: string;
128 /** Server etag for optimistic concurrency. */
129 etag: string | undefined;
130 /** Live enforcement spec (`etag` / `updateTime` stripped). */
131 spec: PolicySpec | undefined;
132 /** Live dry-run spec (`etag` / `updateTime` stripped). */
133 dryRunSpec: PolicySpec | undefined;
134 /** RFC3339 last-update timestamp from the live spec. */
135 updateTime: string | undefined;
136 },
137 never,
138 Providers
139>;
140
141/**
142 * A Google Cloud Organization Policy on a project, folder, or organization.
143 *
144 * Identity is `(parent, constraint)` — the constraint must already exist
145 * (built-in or custom). Organization policies have no labels or description
146 * field; Alchemy treats existence at the computed name as ownership, and
147 * `list` returns every policy set on the current project so `pnpm nuke:gcp`
148 * can clean leaks.
149 *
150 * `constraint` and `parent` are immutable. `spec` and `dryRunSpec` update
151 * in place.
152 *
153 * ### Creating a Policy
154 * **Example:** Enforce a boolean constraint on the current project
155 * ```typescript
156 * const serial = yield* GCP.OrgPolicy.Policy("SerialPort", {
157 * constraint: "compute.disableSerialPortAccess",
158 * spec: {
159 * rules: [{ enforce: true }],
160 * },
161 * });
162 * ```
163 *
164 * **Example:** List constraint that allows every value
165 * ```typescript
166 * const locations = yield* GCP.OrgPolicy.Policy("Locations", {
167 * constraint: "gcp.resourceLocations",
168 * spec: {
169 * inheritFromParent: false,
170 * rules: [{ allowAll: true }],
171 * },
172 * });
173 * ```
174 *
175 * ### Dry-run
176 * **Example:** Audit-only dry-run spec
177 * ```typescript
178 * const serial = yield* GCP.OrgPolicy.Policy("SerialPort", {
179 * constraint: "compute.disableSerialPortAccess",
180 * spec: {
181 * rules: [{ enforce: true }],
182 * },
183 * dryRunSpec: {
184 * rules: [{ enforce: true }],
185 * },
186 * });
187 * ```
188 *
189 * ### Updating a Policy
190 * **Example:** Relax enforcement
191 * ```typescript
192 * const serial = yield* GCP.OrgPolicy.Policy("SerialPort", {
193 * constraint: "compute.disableSerialPortAccess",
194 * spec: {
195 * rules: [{ enforce: false }],
196 * },
197 * });
198 * ```
199 *
200 * @resource
201 * @category OrgPolicy
202 */
203export const Policy = Resource<Policy>("GCP.OrgPolicy.Policy");
204
205export class PolicyNotResolved extends Data.TaggedError(
206 "GCP.OrgPolicy.PolicyNotResolved",
207)<{
208 name: string;
209}> {}
210
211const CONSTRAINTS_PREFIX = "constraints/";
212const POLICIES_SEGMENT = "/policies/";
213
214const lastSegment = (value: string) => {
215 const trimmed = value.replace(/\/+$/, "");
216 const parts = trimmed.split("/");
217 return parts[parts.length - 1] || trimmed;
218};
219
220const normalizeConstraint = (constraint: string | undefined) => {
221 const raw = (constraint ?? "").trim();
222 return raw.startsWith(CONSTRAINTS_PREFIX)
223 ? raw.slice(CONSTRAINTS_PREFIX.length)
224 : raw;
225};
226
227const parentKindOf = (resource: string): PolicyParentKind => {
228 if (resource.startsWith("folders/")) return "folders";
229 if (resource.startsWith("organizations/")) return "organizations";
230 return "projects";
231};
232
233const defaultParent = (project: string) => `projects/${project}`;
234
235const parsePolicyName = (name: string) => {
236 const at = name.indexOf(POLICIES_SEGMENT);
237 if (at < 0) {
238 return { parent: "", constraint: normalizeConstraint(name) };
239 }
240 return {
241 parent: name.slice(0, at),
242 constraint: normalizeConstraint(name.slice(at + POLICIES_SEGMENT.length)),
243 };
244};
245
246const resourceName = (parent: string, constraint: string) =>
247 `${parent}${POLICIES_SEGMENT}${constraint}`;
248
249const sameParent = (desired: string, observed: string, project: string) => {
250 if (desired === observed) return true;
251 if (parentKindOf(desired) !== parentKindOf(observed)) return false;
252 if (lastSegment(desired) === lastSegment(observed)) return true;
253 return (
254 parentKindOf(desired) === "projects" && lastSegment(desired) === project
255 );
256};
257
258const toRule = (
259 rule: orgpolicy.GoogleCloudOrgpolicyV2PolicySpecPolicyRule | PolicyRule,
260): PolicyRule => ({
261 allowAll: rule.allowAll,
262 denyAll: rule.denyAll,
263 enforce: rule.enforce,
264 values: rule.values
265 ? {
266 allowedValues: rule.values.allowedValues,
267 deniedValues: rule.values.deniedValues,
268 }
269 : undefined,
270 condition: rule.condition
271 ? {
272 expression: rule.condition.expression,
273 title: rule.condition.title,
274 description: rule.condition.description,
275 location: rule.condition.location,
276 }
277 : undefined,
278 parameters: rule.parameters as Record<string, unknown> | undefined,
279});
280
281const toSpec = (
282 spec: orgpolicy.GoogleCloudOrgpolicyV2PolicySpec | PolicySpec | undefined,
283): PolicySpec | undefined => {
284 if (spec === undefined) return undefined;
285 const rules = (spec.rules ?? []).map(toRule);
286 const next: PolicySpec = {
287 inheritFromParent: spec.inheritFromParent,
288 reset: spec.reset,
289 rules: rules.length > 0 ? rules : undefined,
290 };
291 if (
292 next.inheritFromParent === undefined &&
293 next.reset === undefined &&
294 next.rules === undefined
295 ) {
296 return undefined;
297 }
298 return next;
299};
300
301const toApiSpec = (
302 spec: PolicySpec | undefined,
303): orgpolicy.GoogleCloudOrgpolicyV2PolicySpec | undefined => {
304 if (spec === undefined) return undefined;
305 return {
306 inheritFromParent: spec.inheritFromParent,
307 reset: spec.reset,
308 rules: spec.rules?.map((rule) => ({
309 allowAll: rule.allowAll,
310 denyAll: rule.denyAll,
311 enforce: rule.enforce,
312 values: rule.values
313 ? {
314 allowedValues: rule.values.allowedValues,
315 deniedValues: rule.values.deniedValues,
316 }
317 : undefined,
318 condition: rule.condition,
319 parameters: rule.parameters,
320 })),
321 };
322};
323
324const canonicalizeRule = (rule: PolicyRule) => ({
325 allowAll: rule.allowAll === true,
326 denyAll: rule.denyAll === true,
327 enforce: rule.enforce,
328 values: rule.values
329 ? {
330 allowedValues: [...(rule.values.allowedValues ?? [])].sort(),
331 deniedValues: [...(rule.values.deniedValues ?? [])].sort(),
332 }
333 : undefined,
334 condition: rule.condition
335 ? {
336 expression: rule.condition.expression ?? "",
337 title: rule.condition.title ?? "",
338 description: rule.condition.description ?? "",
339 location: rule.condition.location ?? "",
340 }
341 : undefined,
342 parameters:
343 rule.parameters && Object.keys(rule.parameters).length > 0
344 ? rule.parameters
345 : undefined,
346});
347
348const canonicalizeSpec = (spec: PolicySpec | undefined) => ({
349 inheritFromParent: spec?.inheritFromParent === true,
350 reset: spec?.reset === true,
351 rules: (spec?.rules ?? []).map(canonicalizeRule),
352});
353
354const specsEqual = (
355 left: PolicySpec | undefined,
356 right: PolicySpec | undefined,
357) =>
358 JSON.stringify(canonicalizeSpec(left)) ===
359 JSON.stringify(canonicalizeSpec(right));
360
361const toAttrs = (
362 policy: orgpolicy.GoogleCloudOrgpolicyV2Policy,
363 project: string,
364) => {
365 const name = policy.name ?? "";
366 const parsed = parsePolicyName(name);
367 return {
368 name,
369 constraint: parsed.constraint,
370 parent: parsed.parent,
371 project,
372 etag: policy.etag,
373 spec: toSpec(policy.spec),
374 dryRunSpec: toSpec(policy.dryRunSpec),
375 updateTime: policy.spec?.updateTime,
376 };
377};
378
379const getByName = (name: string) => {
380 const kind = parentKindOf(name);
381 const request = { name };
382 const get =
383 kind === "folders"
384 ? orgpolicy.getFoldersPolicies(request)
385 : kind === "organizations"
386 ? orgpolicy.getOrganizationsPolicies(request)
387 : orgpolicy.getProjectsPolicies(request);
388 return get.pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
389};
390
391const createPolicy = (
392 parent: string,
393 body: orgpolicy.GoogleCloudOrgpolicyV2Policy,
394) => {
395 const kind = parentKindOf(parent);
396 const request = { parent, body };
397 return kind === "folders"
398 ? orgpolicy.createFoldersPolicies(request)
399 : kind === "organizations"
400 ? orgpolicy.createOrganizationsPolicies(request)
401 : orgpolicy.createProjectsPolicies(request);
402};
403
404const patchPolicy = (
405 name: string,
406 body: orgpolicy.GoogleCloudOrgpolicyV2Policy,
407 updateMask: string,
408) => {
409 const kind = parentKindOf(name);
410 const request = { name, body, updateMask };
411 return kind === "folders"
412 ? orgpolicy.patchFoldersPolicies(request)
413 : kind === "organizations"
414 ? orgpolicy.patchOrganizationsPolicies(request)
415 : orgpolicy.patchProjectsPolicies(request);
416};
417
418const deletePolicy = (name: string) => {
419 const kind = parentKindOf(name);
420 const request = { name };
421 const del =
422 kind === "folders"
423 ? orgpolicy.deleteFoldersPolicies(request)
424 : kind === "organizations"
425 ? orgpolicy.deleteOrganizationsPolicies(request)
426 : orgpolicy.deleteProjectsPolicies(request);
427 return del.pipe(Effect.catchTag("NotFound", () => Effect.void));
428};
429
430const listPolicies = (parent: string) =>
431 Effect.gen(function* () {
432 const kind = parentKindOf(parent);
433 const found: orgpolicy.GoogleCloudOrgpolicyV2Policy[] = [];
434 let pageToken: string | undefined;
435 do {
436 const request = { parent, pageSize: 1000, pageToken };
437 const response =
438 kind === "folders"
439 ? yield* orgpolicy.listFoldersPolicies(request)
440 : kind === "organizations"
441 ? yield* orgpolicy.listOrganizationsPolicies(request)
442 : yield* orgpolicy.listProjectsPolicies(request);
443 found.push(...(response.policies ?? []));
444 pageToken = response.nextPageToken;
445 } while (pageToken !== undefined && pageToken !== "");
446 return found;
447 }).pipe(
448 Effect.catchTag("NotFound", () =>
449 Effect.succeed([] as orgpolicy.GoogleCloudOrgpolicyV2Policy[]),
450 ),
451 );
452
453export const PolicyProvider = () =>
454 Provider.succeed(Policy, {
455 stables: ["name", "constraint", "parent", "project"],
456
457 diff: Effect.fn(function* ({ news, olds, output }) {
458 if (!isResolved(news)) return undefined;
459 const env = yield* GcpEnvironment.current;
460 const previousConstraint = normalizeConstraint(
461 olds?.constraint ?? output?.constraint,
462 );
463 const nextConstraint = normalizeConstraint(
464 news.constraint ?? previousConstraint,
465 );
466 const constraintChanged =
467 previousConstraint.length > 0 &&
468 nextConstraint.length > 0 &&
469 previousConstraint !== nextConstraint;
470
471 const previousParent = olds?.parent ?? output?.parent;
472 const nextParent = news.parent ?? previousParent;
473 const parentChanged =
474 previousParent !== undefined &&
475 nextParent !== undefined &&
476 !sameParent(nextParent, previousParent, env.project);
477
478 if (constraintChanged || parentChanged) {
479 return { action: "replace" as const, deleteFirst: false };
480 }
481 return undefined;
482 }),
483
484 read: Effect.fn(function* ({ olds, output }) {
485 const env = yield* GcpEnvironment.current;
486 const constraint = normalizeConstraint(
487 olds?.constraint ?? output?.constraint,
488 );
489 if (constraint.length === 0) return undefined;
490 const parent =
491 olds?.parent ?? output?.parent ?? defaultParent(env.project);
492 const name = output?.name ?? resourceName(parent, constraint);
493 const existing = yield* getByName(name);
494 if (existing === undefined) return undefined;
495 // No labels/description — existence at (parent, constraint) is ownership.
496 return toAttrs(existing, env.project);
497 }),
498
499 list: () =>
500 Effect.gen(function* () {
501 const env = yield* GcpEnvironment.current;
502 const policies = yield* listPolicies(defaultParent(env.project));
503 return policies.map((policy) => toAttrs(policy, env.project));
504 }),
505
506 reconcile: Effect.fn(function* ({ news, output }) {
507 const env = yield* GcpEnvironment.current;
508 const constraint = normalizeConstraint(
509 news.constraint ?? output?.constraint,
510 );
511 const parent =
512 news.parent ?? output?.parent ?? defaultParent(env.project);
513 const name = output?.name ?? resourceName(parent, constraint);
514 const desiredSpec = toSpec(news.spec);
515 const desiredDryRun = toSpec(news.dryRunSpec);
516
517 let current = yield* getByName(name);
518
519 if (current === undefined) {
520 const created = yield* createPolicy(parent, {
521 name: resourceName(parent, constraint),
522 spec: toApiSpec(desiredSpec),
523 dryRunSpec: toApiSpec(desiredDryRun),
524 }).pipe(Effect.catchTag("Conflict", () => getByName(name)));
525 current = created ?? undefined;
526 }
527
528 if (current === undefined) {
529 return yield* new PolicyNotResolved({ name });
530 }
531
532 const specManaged = news.spec !== undefined;
533 const dryRunManaged = news.dryRunSpec !== undefined;
534 const specChanged =
535 specManaged && !specsEqual(toSpec(current.spec), desiredSpec);
536 const dryRunChanged =
537 dryRunManaged && !specsEqual(toSpec(current.dryRunSpec), desiredDryRun);
538
539 if (specChanged || dryRunChanged) {
540 const observed = current;
541 const updateMask = [
542 specChanged ? "spec" : undefined,
543 dryRunChanged ? "dryRunSpec" : undefined,
544 ]
545 .filter((field): field is string => field !== undefined)
546 .join(",");
547 current = yield* Effect.gen(function* () {
548 const latest = (yield* getByName(observed.name ?? name)) ?? observed;
549 return yield* patchPolicy(
550 latest.name ?? name,
551 {
552 name: latest.name ?? name,
553 etag: latest.etag,
554 spec: specManaged ? toApiSpec(desiredSpec) : latest.spec,
555 dryRunSpec: dryRunManaged
556 ? toApiSpec(desiredDryRun)
557 : latest.dryRunSpec,
558 },
559 updateMask,
560 );
561 }).pipe(
562 Effect.retry({
563 while: (error) => error._tag === "Conflict",
564 times: 5,
565 schedule: Schedule.exponential("200 millis"),
566 }),
567 );
568 }
569
570 return toAttrs(current, env.project);
571 }),
572
573 delete: Effect.fn(function* ({ output }) {
574 yield* deletePolicy(output.name);
575 }),
576 });