1import * as iam from "@distilled.cloud/gcp/iam_v2";
2import * as Data from "effect/Data";
3import * as Effect from "effect/Effect";
4import * as Stream from "effect/Stream";
5import { Unowned } from "../../AdoptPolicy.ts";
6import { isResolved } from "../../Diff.ts";
7import * as Provider from "../../Provider.ts";
8import { Resource } from "../../Resource.ts";
9import { tagRecord } from "../../Tags.ts";
10import { GcpEnvironment } from "../Environment.ts";
11import {
12 createInternalLabels,
13 diffLabels,
14 stripInternalLabels,
15 toLabels,
16} from "../Labels.ts";
17import type { Providers } from "../Providers.ts";
18import {
19 attachmentPointOf,
20 denypoliciesParent,
21 hasOwnershipAnnotations,
22 ownedByAlchemy,
23 parsePolicyName,
24 policyName,
25 ResourceNotResolved,
26 toPolicyId,
27 waitForDeleteOperation,
28 waitForOperation,
29} from "./internal.ts";
30
31export type DenyRuleCondition = {
32 /** CEL expression. Resource-tag functions only. */
33 expression?: string;
34 /** Short title. */
35 title?: string;
36 /** Longer description. */
37 description?: string;
38 /** Location string for error reporting. */
39 location?: string;
40};
41
42export type DenyRule = {
43 /**
44 * Permissions denied (`{service_fqdn}/{resource}.{verb}`).
45 */
46 deniedPermissions?: string[];
47 /**
48 * Permissions excluded from `deniedPermissions`.
49 */
50 exceptionPermissions?: string[];
51 /**
52 * Identities denied the permissions.
53 */
54 deniedPrincipals?: string[];
55 /**
56 * Identities excluded from `deniedPrincipals`.
57 */
58 exceptionPrincipals?: string[];
59 /**
60 * CEL condition that selects when this rule applies.
61 */
62 denialCondition?: DenyRuleCondition;
63};
64
65export type PolicyRule = {
66 /**
67 * User-specified description (max 256 characters).
68 */
69 description?: string;
70 /**
71 * Deny rule.
72 */
73 denyRule?: DenyRule;
74};
75
76export type PolicyProps = {
77 /**
78 * Attachment point, URL-encoded (`cloudresourcemanager.googleapis.com%2Fprojects%2Fmy-project`)
79 * or raw (`cloudresourcemanager.googleapis.com/projects/my-project`).
80 * Defaults to the current GCP project. Immutable — changing it
81 * replaces the policy.
82 */
83 attachmentPoint?: string;
84 /**
85 * Policy id (3-63 chars, lowercase letters, numbers, dashes, periods;
86 * must start with a letter). If omitted, a unique name is generated.
87 * Immutable — changing it replaces the policy.
88 */
89 policyId?: string;
90 /**
91 * Display name (max 63 characters).
92 */
93 displayName?: string;
94 /**
95 * User annotations. Alchemy ownership annotations are merged in
96 * automatically (`alchemy-stack` / `alchemy-stage` / `alchemy-id`).
97 */
98 annotations?: Record<string, string>;
99 /**
100 * Deny rules. Defaults to a no-op deny of a non-existent principal so
101 * create is valid without locking anyone out.
102 */
103 rules?: PolicyRule[];
104};
105
106export type Policy = Resource<
107 "GCP.IAM.Policy",
108 PolicyProps,
109 {
110 /** Full resource name `policies/{attachment}/denypolicies/{policy}`. */
111 name: string;
112 /** Policy id. */
113 policyId: string;
114 /** URL-encoded attachment point. */
115 attachmentPoint: string;
116 /** Display name. */
117 displayName: string | undefined;
118 /** User annotations (Alchemy ownership keys stripped). */
119 annotations: Record<string, string>;
120 /** Kind (`DenyPolicy`). */
121 kind: string | undefined;
122 /** Globally unique uid. */
123 uid: string | undefined;
124 /** Etag. */
125 etag: string | undefined;
126 /** Rules. */
127 rules: PolicyRule[] | undefined;
128 /** RFC3339 create time. */
129 createTime: string | undefined;
130 /** RFC3339 update time. */
131 updateTime: string | undefined;
132 /** RFC3339 delete time when deleted. */
133 deleteTime: string | undefined;
134 },
135 never,
136 Providers
137>;
138
139/**
140 * An IAM v2 deny policy attached to a project, folder, or organization.
141 *
142 * This is not {@link GCP.OrgPolicy.Policy} (organization policy
143 * constraints). Deny policies use annotations for Alchemy ownership.
144 * Create, update, and delete are long-running operations.
145 *
146 * ### Creating a Deny Policy
147 * **Example:** Generated id on this project
148 * ```typescript
149 * const policy = yield* GCP.IAM.Policy("Probe", {
150 * displayName: "alchemy-probe",
151 * rules: [{
152 * denyRule: {
153 * deniedPermissions: ["iam.googleapis.com/roles.list"],
154 * deniedPrincipals: [
155 * "principal://goog/subject/alchemy-deny-probe@example.invalid",
156 * ],
157 * },
158 * }],
159 * });
160 * ```
161 *
162 * @resource
163 * @category IAM
164 */
165export const Policy = Resource<Policy>("GCP.IAM.Policy");
166
167export class PolicyNotResolved extends Data.TaggedError(
168 "GCP.IAM.PolicyNotResolved",
169)<{
170 name: string;
171}> {}
172
173const DEFAULT_RULE: PolicyRule = {
174 description: "alchemy ownership probe",
175 denyRule: {
176 deniedPermissions: ["iam.googleapis.com/roles.list"],
177 deniedPrincipals: [
178 "principal://goog/subject/alchemy-deny-probe@example.invalid",
179 ],
180 },
181};
182
183const ruleOf = (rule: iam.GoogleIamV2PolicyRule | PolicyRule): PolicyRule => ({
184 description: rule.description,
185 denyRule: rule.denyRule
186 ? {
187 deniedPermissions: rule.denyRule.deniedPermissions,
188 exceptionPermissions: rule.denyRule.exceptionPermissions,
189 deniedPrincipals: rule.denyRule.deniedPrincipals,
190 exceptionPrincipals: rule.denyRule.exceptionPrincipals,
191 denialCondition: rule.denyRule.denialCondition,
192 }
193 : undefined,
194});
195
196const jsonOf = (value: unknown) => JSON.stringify(value ?? null);
197
198const toAttrs = (policy: iam.GoogleIamV2Policy) => {
199 const name = policy.name ?? "";
200 const parsed = parsePolicyName(name);
201 return {
202 name,
203 policyId: parsed.policyId,
204 attachmentPoint: parsed.attachmentPoint,
205 displayName: policy.displayName,
206 annotations: stripInternalLabels(tagRecord(policy.annotations)),
207 kind: policy.kind,
208 uid: policy.uid,
209 etag: policy.etag,
210 rules: policy.rules?.map(ruleOf),
211 createTime: policy.createTime,
212 updateTime: policy.updateTime,
213 deleteTime: policy.deleteTime,
214 };
215};
216
217const getByName = (name: string) =>
218 name.length === 0
219 ? Effect.succeed(undefined)
220 : iam.getPolicies({ name }).pipe(
221 Effect.map((policy) => (policy.deleteTime ? undefined : policy)),
222 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
223 );
224
225const listAt = (parent: string) =>
226 iam.listPoliciesPolicies.pages({ parent, pageSize: 1000 }).pipe(
227 Stream.flatMap((page) => Stream.fromIterable(page.policies ?? [])),
228 Stream.runCollect,
229 Effect.map((chunk) => Array.from(chunk)),
230 Effect.catchTag("NotFound", () =>
231 Effect.succeed([] as iam.GoogleIamV2Policy[]),
232 ),
233 );
234
235export const PolicyProvider = () =>
236 Provider.succeed(Policy, {
237 stables: ["name", "policyId", "attachmentPoint", "uid", "createTime"],
238
239 diff: Effect.fn(function* ({ news, olds, output }) {
240 if (!isResolved(news)) return undefined;
241 const previousPoint = olds?.attachmentPoint ?? output?.attachmentPoint;
242 const previousId = olds?.policyId ?? output?.policyId;
243 if (
244 (previousPoint !== undefined &&
245 news.attachmentPoint !== undefined &&
246 attachmentPointOf("x", news.attachmentPoint) !==
247 attachmentPointOf("x", previousPoint)) ||
248 (previousId !== undefined &&
249 news.policyId !== undefined &&
250 news.policyId !== previousId)
251 ) {
252 return { action: "replace" as const, deleteFirst: true };
253 }
254 return undefined;
255 }),
256
257 read: Effect.fn(function* ({ id, olds, output }) {
258 const env = yield* GcpEnvironment.current;
259 const attachmentPoint = attachmentPointOf(
260 env.project,
261 olds?.attachmentPoint ?? output?.attachmentPoint,
262 );
263 const policyId = yield* toPolicyId(id, olds?.policyId, output?.policyId);
264 const name = output?.name ?? policyName(attachmentPoint, policyId);
265 const existing = yield* getByName(name);
266 if (existing === undefined) return undefined;
267 const attrs = toAttrs(existing);
268 return (yield* ownedByAlchemy(id, existing.annotations))
269 ? attrs
270 : Unowned(attrs);
271 }),
272
273 list: () =>
274 Effect.gen(function* () {
275 const env = yield* GcpEnvironment.current;
276 const parent = denypoliciesParent(attachmentPointOf(env.project));
277 const policies = yield* listAt(parent);
278 const named = yield* Effect.forEach(
279 policies,
280 (policy) =>
281 policy.name ? getByName(policy.name) : Effect.succeed(undefined),
282 { concurrency: 4 },
283 );
284 return named
285 .filter(
286 (policy): policy is iam.GoogleIamV2Policy =>
287 policy !== undefined &&
288 hasOwnershipAnnotations(policy.annotations),
289 )
290 .map(toAttrs);
291 }),
292
293 reconcile: Effect.fn(function* ({ id, news, output }) {
294 const env = yield* GcpEnvironment.current;
295 const attachmentPoint = attachmentPointOf(
296 env.project,
297 news.attachmentPoint ?? output?.attachmentPoint,
298 );
299 const policyId = yield* toPolicyId(id, news.policyId, output?.policyId);
300 const name = policyName(attachmentPoint, policyId);
301 const parent = denypoliciesParent(attachmentPoint);
302 const desiredAnnotations = {
303 ...toLabels(news.annotations),
304 ...(yield* createInternalLabels(id)),
305 };
306 const rules = (news.rules ?? [DEFAULT_RULE]).map(ruleOf);
307
308 let current = yield* getByName(output?.name ?? name);
309
310 if (current === undefined) {
311 const created = yield* iam
312 .createPolicyPolicies({
313 parent,
314 policyId,
315 body: {
316 displayName: news.displayName,
317 annotations: desiredAnnotations,
318 rules,
319 },
320 })
321 .pipe(
322 Effect.catchTag("Conflict", () =>
323 getByName(name).pipe(
324 Effect.map((policy) =>
325 policy
326 ? ({
327 done: true,
328 response: { name: policy.name },
329 } satisfies iam.GoogleLongrunningOperation)
330 : undefined,
331 ),
332 ),
333 ),
334 );
335 if (created) {
336 yield* waitForOperation(created);
337 }
338 current = yield* getByName(name);
339 }
340
341 if (current === undefined) {
342 return yield* new ResourceNotResolved({ name });
343 }
344
345 const observed = tagRecord(current.annotations);
346 const { upsert, removed } = diffLabels(observed, desiredAnnotations);
347 const annotationsChanged = upsert.length > 0 || removed.length > 0;
348 const displayChanged =
349 news.displayName !== undefined &&
350 (current.displayName ?? "") !== news.displayName;
351 const rulesChanged = jsonOf(current.rules?.map(ruleOf)) !== jsonOf(rules);
352
353 if (annotationsChanged || displayChanged || rulesChanged) {
354 const updated = yield* iam.updatePolicies({
355 name: current.name ?? name,
356 body: {
357 etag: current.etag,
358 displayName: news.displayName ?? current.displayName,
359 annotations: desiredAnnotations,
360 rules,
361 },
362 });
363 yield* waitForOperation(updated);
364 current = (yield* getByName(current.name ?? name)) ?? current;
365 }
366
367 return toAttrs(current);
368 }),
369
370 delete: Effect.fn(function* ({ output }) {
371 if (!output.name) return;
372 const operation = yield* iam
373 .deletePolicies({ name: output.name, etag: output.etag })
374 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
375 if (operation) {
376 yield* waitForDeleteOperation(operation);
377 }
378 }),
379 });