1import * as resourcemanager from "@distilled.cloud/gcp/cloudresourcemanager_v3";
2import * as securityposture from "@distilled.cloud/gcp/securityposture_v1";
3import * as Data from "effect/Data";
4import type { GcpOpContext } from "@distilled.cloud/gcp/Protocol";
5import * as Config from "effect/Config";
6import * as Effect from "effect/Effect";
7import * as Option from "effect/Option";
8import * as Schedule from "effect/Schedule";
9import { createPhysicalName } from "../../PhysicalName.ts";
10import { tagRecord } from "../../Tags.ts";
11import { GcpEnvironment } from "../Environment.ts";
12import { stripInternalLabels } from "../Labels.ts";
13import { waitForOperation as waitForLongRunningOperation } from "../Operation.ts";
14
15export const DEFAULT_LOCATION = "global";
16export const DEFAULT_STATE = "DRAFT";
17export const MAX_ID_LENGTH = 63;
18
19export class SecuritypostureNotResolved extends Data.TaggedError(
20 "GCP.SecurityPosture.NotResolved",
21)<{
22 name: string;
23}> {}
24
25export class SecuritypostureStillExists extends Data.TaggedError(
26 "GCP.SecurityPosture.StillExists",
27)<{
28 name: string;
29}> {}
30
31export class OrganizationNotResolved extends Data.TaggedError(
32 "GCP.SecurityPosture.OrganizationNotResolved",
33)<{
34 project: string;
35}> {}
36
37export const lastSegment = (value: string) => {
38 const trimmed = value.replace(/\/+$/, "");
39 const parts = trimmed.split("/");
40 return parts[parts.length - 1] || trimmed;
41};
42
43export const sanitizeId = (name: string, fallback = "posture"): string => {
44 let next = name
45 .toLowerCase()
46 .replace(/[^a-z0-9_-]/g, "-")
47 .replace(/-+/g, "-")
48 .replace(/^-+|-+$/g, "");
49 if (!/^[a-z]/.test(next)) next = `${fallback[0] ?? "p"}${next}`;
50 next = next.slice(0, MAX_ID_LENGTH).replace(/-+$/g, "");
51 if (next.length === 0) next = fallback;
52 if (!/[a-z0-9]$/.test(next)) {
53 next = `${next.slice(0, MAX_ID_LENGTH - 1)}0`;
54 }
55 return next.slice(0, MAX_ID_LENGTH);
56};
57
58export const toPhysicalId = (
59 id: string,
60 explicit: string | undefined,
61 existing: string | undefined,
62 fallback = "posture",
63) =>
64 Effect.gen(function* () {
65 if (explicit !== undefined) return sanitizeId(explicit, fallback);
66 if (existing !== undefined) return existing;
67 return sanitizeId(
68 yield* createPhysicalName({
69 id,
70 maxLength: MAX_ID_LENGTH,
71 lowercase: true,
72 }),
73 fallback,
74 );
75 });
76
77export const organizationParent = (value: string) =>
78 value.startsWith("organizations/")
79 ? value
80 : `organizations/${lastSegment(value)}`;
81
82export const organizationIdOf = (value: string) => lastSegment(value);
83
84export const locationParent = (organization: string, location: string) =>
85 `${organizationParent(organization)}/locations/${location}`;
86
87export const parseName = (name: string, collection: string) => {
88 const parts = name.split("/").filter((part) => part.length > 0);
89 const collectionAt = parts.lastIndexOf(collection);
90 const locationsAt = parts.lastIndexOf("locations");
91 const orgsAt = parts.lastIndexOf("organizations");
92 return {
93 organization: orgsAt >= 0 && parts[orgsAt + 1] ? parts[orgsAt + 1]! : "",
94 location:
95 locationsAt >= 0 && parts[locationsAt + 1]
96 ? parts[locationsAt + 1]!
97 : DEFAULT_LOCATION,
98 id:
99 collectionAt >= 0 && parts[collectionAt + 1]
100 ? parts[collectionAt + 1]!
101 : lastSegment(name),
102 parent:
103 collectionAt > 0
104 ? parts.slice(0, collectionAt).join("/")
105 : parts.slice(0, Math.max(0, parts.length - 1)).join("/"),
106 };
107};
108
109export const userAnnotations = (
110 annotations: Record<string, string | undefined> | null | undefined,
111): Record<string, string> => stripInternalLabels(tagRecord(annotations));
112
113export const hasAlchemyAnnotationMap = (
114 annotations: Record<string, string | undefined> | null | undefined,
115) => Object.keys(annotations ?? {}).some((key) => key.startsWith("alchemy-"));
116
117export const sameText = (left: string | undefined, right: string | undefined) =>
118 (left ?? "") === (right ?? "");
119
120export const canonical = (value: unknown): unknown => {
121 if (value === undefined || value === null) return undefined;
122 if (typeof value === "boolean" || typeof value === "number") return value;
123 if (typeof value === "string") {
124 return value.length === 0 ? undefined : value;
125 }
126 if (Array.isArray(value)) {
127 const items = value.map(canonical).filter((item) => item !== undefined);
128 return items.length === 0 ? undefined : items;
129 }
130 if (typeof value === "object") {
131 const entries = Object.entries(value as Record<string, unknown>)
132 .map(([key, item]) => [key, canonical(item)] as const)
133 .filter(([, item]) => item !== undefined)
134 .sort(([left], [right]) => left.localeCompare(right));
135 if (entries.length === 0) return undefined;
136 return Object.fromEntries(entries);
137 }
138 return undefined;
139};
140
141export const fingerprint = (value: unknown): string =>
142 JSON.stringify(canonical(value) ?? null);
143
144export const fieldMask = (fields: Array<string | false | undefined>) =>
145 fields
146 .filter((field): field is string => typeof field === "string")
147 .join(",");
148
149export const defaultPolicySets = (): securityposture.PolicySetList => [
150 {
151 policySetId: "alchemy",
152 description: "alchemy default policy set",
153 policies: [
154 {
155 policyId: "alchemy-sha",
156 constraint: {
157 securityHealthAnalyticsModule: {
158 moduleName: "API_KEY_EXISTS",
159 moduleEnablementState: "DISABLED",
160 },
161 },
162 },
163 ],
164 },
165];
166
167export const desiredPolicySets = (
168 policySets: securityposture.PolicySetList | undefined,
169): securityposture.PolicySetList =>
170 policySets && policySets.length > 0 ? policySets : defaultPolicySets();
171
172/**
173 * Wait on a Security Posture long-running operation (control-plane, usually under a minute).
174 * `ALREADY_EXISTS` (a create race) counts as success; so does `NOT_FOUND`
175 * when `notFoundOk` (deletes). Returns the final operation.
176 */
177export const waitForOperation = (
178 operation: securityposture.Operation,
179 options?: { notFoundOk?: boolean },
180) =>
181 Effect.suspend(() => {
182 let latest = operation;
183 return waitForLongRunningOperation(
184 operation,
185 (name) => {
186 const get = securityposture
187 .getOrganizationsLocationsOperations({ name })
188 .pipe(
189 Effect.tap((current) =>
190 Effect.sync(() => {
191 latest = current;
192 }),
193 ),
194 );
195 const observe: Effect.Effect<
196 securityposture.Operation,
197 securityposture.GetOrganizationsLocationsOperationsError,
198 GcpOpContext
199 > =
200 options?.notFoundOk === true
201 ? get.pipe(
202 Effect.catchTag("NotFound", () =>
203 Effect.succeed<securityposture.Operation>({
204 name,
205 done: true,
206 }),
207 ),
208 )
209 : get.pipe(
210 // A just-returned operation can briefly 404 on read.
211 Effect.retry({
212 while: (error) => error._tag === "NotFound",
213 times: 5,
214 schedule: Schedule.exponential("250 millis"),
215 }),
216 );
217 return observe;
218 },
219 { budget: "10 minutes" },
220 ).pipe(
221 Effect.map(() => latest),
222 // google.rpc.Code ALREADY_EXISTS = 6, NOT_FOUND = 5.
223 Effect.catchTag("GCP.OperationFailed", (error) =>
224 error.code === 6 || (options?.notFoundOk === true && error.code === 5)
225 ? Effect.succeed(latest)
226 : Effect.fail(error),
227 ),
228 );
229 });
230
231export const waitUntilExists = <A, E, R>(
232 get: Effect.Effect<A | undefined, E, R>,
233 name: string,
234): Effect.Effect<NonNullable<A>, E | SecuritypostureNotResolved, R> =>
235 get.pipe(
236 Effect.filterOrFail(
237 (value): value is NonNullable<A> => value != null,
238 () => new SecuritypostureNotResolved({ name }),
239 ),
240 Effect.retry({
241 while: (error) => error instanceof SecuritypostureNotResolved,
242 times: 8,
243 schedule: Schedule.spaced("1 second"),
244 }),
245 );
246
247export const waitUntilGone = <A, E, R>(
248 get: Effect.Effect<A | undefined, E, R>,
249 name: string,
250): Effect.Effect<void, E | SecuritypostureStillExists, R> =>
251 get.pipe(
252 Effect.filterOrFail(
253 (value) => value === undefined,
254 () => new SecuritypostureStillExists({ name }),
255 ),
256 Effect.retry({
257 while: (error) => error instanceof SecuritypostureStillExists,
258 times: 10,
259 schedule: Schedule.spaced("2 seconds"),
260 }),
261 Effect.asVoid,
262 );
263
264const ancestryParent = (name: string) =>
265 name.startsWith("projects/")
266 ? resourcemanager.getProjects({ name }).pipe(
267 Effect.map((resource) => resource.parent),
268 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
269 )
270 : name.startsWith("folders/")
271 ? resourcemanager.getFolders({ name }).pipe(
272 Effect.map((folder) => folder.parent),
273 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
274 )
275 : Effect.succeed(undefined);
276
277export const tryResolveOrganization = () =>
278 Effect.gen(function* () {
279 const configured = Option.getOrUndefined(
280 yield* Config.option(Config.String("GOOGLE_ORGANIZATION_ID")),
281 )?.trim();
282 if (configured) return organizationParent(configured);
283 const env = yield* GcpEnvironment.current;
284 let current: string | undefined = `projects/${env.project}`;
285 for (let i = 0; i < 8; i++) {
286 if (current === undefined) return undefined;
287 if (current.startsWith("organizations/")) return current;
288 current = yield* ancestryParent(current);
289 }
290 return undefined;
291 });
292
293export const resolveOrganization = (
294 explicit: string | undefined,
295 existing: string | undefined,
296) =>
297 Effect.gen(function* () {
298 if (explicit !== undefined) return organizationParent(explicit);
299 if (existing !== undefined) return organizationParent(existing);
300 const resolved = yield* tryResolveOrganization();
301 if (resolved === undefined) {
302 const env = yield* GcpEnvironment.current;
303 return yield* new OrganizationNotResolved({ project: env.project });
304 }
305 return resolved;
306 });
307
308export const projectNumberOf = (project: string) =>
309 resourcemanager
310 .getProjects({ name: `projects/${lastSegment(project)}` })
311 .pipe(
312 Effect.map((resource) => {
313 const number = lastSegment(resource.name ?? "");
314 return /^\d+$/.test(number) ? number : lastSegment(project);
315 }),
316 Effect.catchTag("NotFound", () => Effect.succeed(lastSegment(project))),
317 );
318
319export const normalizeTargetResource = (value: string) =>
320 Effect.gen(function* () {
321 if (value.startsWith("organizations/") || value.startsWith("folders/")) {
322 return value;
323 }
324 if (value.startsWith("projects/")) {
325 const id = lastSegment(value);
326 if (/^\d+$/.test(id)) return value;
327 const number = yield* projectNumberOf(id);
328 return `projects/${number}`;
329 }
330 if (/^\d+$/.test(value)) return `projects/${value}`;
331 const number = yield* projectNumberOf(value);
332 return `projects/${number}`;
333 });
334
335export const defaultTargetResource = () =>
336 Effect.gen(function* () {
337 const env = yield* GcpEnvironment.current;
338 const number = yield* projectNumberOf(env.project);
339 return `projects/${number}`;
340 });
341
342export const replaceOnIdentity = (changed: boolean, deleteFirst: boolean) =>
343 changed ? ({ action: "replace" as const, deleteFirst } as const) : undefined;