1import * as cloudasset from "@distilled.cloud/gcp/cloudasset_v1";
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 { tagRecord } from "../../Tags.ts";
9import { GcpEnvironment } from "../Environment.ts";
10import {
11 createInternalLabels,
12 diffLabels,
13 hasAlchemyLabels,
14 stripInternalLabels,
15 toLabels,
16} from "../Labels.ts";
17import type { Providers } from "../Providers.ts";
18import {
19 lastSegment,
20 listSavedQueries,
21 MAX_SAVED_QUERY_DESCRIPTION,
22 parentOf,
23 projectParent,
24 replaceOn,
25 scopeParent,
26 sameJson,
27 sameText,
28 toPhysicalId,
29 updateMaskOf,
30} from "./internal.ts";
31
32export type IamPolicyAnalysisQuery = cloudasset.IamPolicyAnalysisQuery;
33export type QueryContent = cloudasset.QueryContent;
34
35export type SavedQueryProps = {
36 /**
37 * Saved query id (the `{savedQuery}` segment of
38 * `projects/{project}/savedQueries/{savedQuery}`). If omitted, a
39 * unique RFC1035 name is generated (4-63 characters, `a-z0-9-`).
40 * Immutable — changing it replaces the query.
41 */
42 savedQueryId?: string;
43 /**
44 * Parent project, folder, or organization
45 * (`projects/{project}`, `folders/{folder}`,
46 * `organizations/{organization}`). Defaults to the current project.
47 * Immutable — changing it replaces the query.
48 */
49 parent?: string;
50 /**
51 * Human-readable description (max 255 characters).
52 */
53 description?: string;
54 /**
55 * User labels. Alchemy ownership labels are merged in automatically.
56 * At most 10 entries including ownership labels; keys and values are
57 * under 64 characters.
58 */
59 labels?: Record<string, string>;
60 /**
61 * Query payload. Defaults to an IAM policy analysis of the current
62 * project.
63 */
64 content?: QueryContent;
65};
66
67export type SavedQuery = Resource<
68 "GCP.CloudAsset.SavedQuery",
69 SavedQueryProps,
70 {
71 /** Full resource name `projects/{project}/savedQueries/{savedQuery}`. */
72 name: string;
73 /** Saved query id (last path segment). */
74 savedQueryId: string;
75 /** Parent project, folder, or organization. */
76 parent: string;
77 /** Project id used when the query was reconciled. */
78 project: string;
79 /** User description. */
80 description: string | undefined;
81 /** User labels (Alchemy ownership labels stripped). */
82 labels: Record<string, string>;
83 /** Query payload. */
84 content: QueryContent | undefined;
85 /** RFC3339 creation timestamp. */
86 createTime: string | undefined;
87 /** Account that created the query. */
88 creator: string | undefined;
89 /** RFC3339 last-update timestamp. */
90 lastUpdateTime: string | undefined;
91 /** Account that last updated the query. */
92 lastUpdater: string | undefined;
93 },
94 never,
95 Providers
96>;
97
98/**
99 * A Cloud Asset Inventory saved query, typically an IAM policy analysis
100 * that can be rerun later.
101 *
102 * Saved query id and parent are identity. Description, labels, and
103 * content update in place. Alchemy ownership is stored in labels so
104 * `list` / nuke can find the query.
105 *
106 * ### Creating a Saved Query
107 * **Example:** Analyze IAM in the current project
108 * ```typescript
109 * const query = yield* GCP.CloudAsset.SavedQuery("IamAudit", {
110 * description: "who can act as service accounts",
111 * content: {
112 * iamPolicyAnalysisQuery: {
113 * scope: `projects/${project}`,
114 * accessSelector: {
115 * permissions: ["iam.serviceAccounts.actAs"],
116 * },
117 * },
118 * },
119 * });
120 * ```
121 *
122 * **Example:** Generated id with labels
123 * ```typescript
124 * const query = yield* GCP.CloudAsset.SavedQuery("IamAudit", {
125 * labels: { env: "dev" },
126 * });
127 * ```
128 *
129 * ### Updating a Saved Query
130 * **Example:** Change the description and selector
131 * ```typescript
132 * // Same logical id, changed props: the query is patched in place.
133 * const query = yield* GCP.CloudAsset.SavedQuery("IamAudit", {
134 * description: "roles and permissions",
135 * labels: { env: "prod" },
136 * content: {
137 * iamPolicyAnalysisQuery: {
138 * scope: `projects/${project}`,
139 * accessSelector: {
140 * roles: ["roles/owner"],
141 * },
142 * },
143 * },
144 * });
145 * ```
146 *
147 * @resource
148 * @category CloudAsset
149 */
150export const SavedQuery = Resource<SavedQuery>("GCP.CloudAsset.SavedQuery");
151
152export class SavedQueryNotResolved extends Data.TaggedError(
153 "GCP.CloudAsset.SavedQueryNotResolved",
154)<{
155 name: string;
156}> {}
157
158const resourceName = (parent: string, savedQueryId: string) =>
159 `${parent}/savedQueries/${savedQueryId}`;
160
161const userLabels = (
162 labels: Record<string, string | undefined> | null | undefined,
163): Record<string, string> => stripInternalLabels(tagRecord(labels));
164
165const desiredContent = (
166 project: string,
167 content: QueryContent | undefined,
168): QueryContent => ({
169 iamPolicyAnalysisQuery: {
170 ...content?.iamPolicyAnalysisQuery,
171 scope: content?.iamPolicyAnalysisQuery?.scope ?? projectParent(project),
172 },
173});
174
175const toAttrs = (query: cloudasset.SavedQuery, project: string) => {
176 const name = query.name ?? "";
177 return {
178 name,
179 savedQueryId: lastSegment(name),
180 parent: parentOf(name, "savedQueries"),
181 project,
182 description: query.description,
183 labels: userLabels(query.labels),
184 content: query.content,
185 createTime: query.createTime,
186 creator: query.creator,
187 lastUpdateTime: query.lastUpdateTime,
188 lastUpdater: query.lastUpdater,
189 };
190};
191
192const getByName = (name: string) =>
193 name.length === 0
194 ? Effect.succeed(undefined)
195 : cloudasset
196 .getSavedQueries({ name })
197 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
198
199const observe = (project: string, savedQueryId: string, outputName?: string) =>
200 Effect.gen(function* () {
201 const parent = yield* scopeParent(project);
202 const candidates = [outputName, resourceName(parent, savedQueryId)].filter(
203 (name): name is string => name !== undefined && name.length > 0,
204 );
205 for (const name of candidates) {
206 const found = yield* getByName(name);
207 if (found !== undefined) return found;
208 }
209 const queries = yield* listSavedQueries(parent);
210 return queries.find(
211 (query) => lastSegment(query.name ?? "") === savedQueryId,
212 );
213 });
214
215export const SavedQueryProvider = () =>
216 Provider.succeed(SavedQuery, {
217 stables: ["name", "savedQueryId", "parent", "project", "createTime"],
218
219 diff: Effect.fn(function* ({ news, olds, output }) {
220 if (!isResolved(news)) return undefined;
221 return (
222 replaceOn(
223 olds?.savedQueryId ?? output?.savedQueryId,
224 news.savedQueryId,
225 ) ?? replaceOn(olds?.parent, news.parent)
226 );
227 }),
228
229 read: Effect.fn(function* ({ id, olds, output }) {
230 const env = yield* GcpEnvironment.current;
231 const savedQueryId = yield* toPhysicalId(
232 id,
233 olds?.savedQueryId,
234 output?.savedQueryId,
235 );
236 const existing = yield* observe(env.project, savedQueryId, output?.name);
237 if (existing === undefined) return undefined;
238 const attrs = toAttrs(existing, env.project);
239 return (yield* hasAlchemyLabels(id, tagRecord(existing.labels)))
240 ? attrs
241 : Unowned(attrs);
242 }),
243
244 list: () =>
245 Effect.gen(function* () {
246 const env = yield* GcpEnvironment.current;
247 const parent = yield* scopeParent(env.project);
248 const queries = yield* listSavedQueries(parent);
249 return queries
250 .filter((query) =>
251 Object.keys(query.labels ?? {}).some((key) =>
252 key.startsWith("alchemy-"),
253 ),
254 )
255 .map((query) => toAttrs(query, env.project));
256 }),
257
258 reconcile: Effect.fn(function* ({ id, news, output }) {
259 const env = yield* GcpEnvironment.current;
260 const savedQueryId = yield* toPhysicalId(
261 id,
262 news.savedQueryId,
263 output?.savedQueryId,
264 );
265 const parent = yield* scopeParent(env.project, news.parent);
266 const name = resourceName(parent, savedQueryId);
267 const desiredLabels = {
268 ...toLabels(news.labels),
269 ...(yield* createInternalLabels(id)),
270 };
271 const description = news.description?.slice(
272 0,
273 MAX_SAVED_QUERY_DESCRIPTION,
274 );
275 const content = desiredContent(env.project, news.content);
276 const body: cloudasset.SavedQuery = {
277 description,
278 labels: desiredLabels,
279 content,
280 };
281
282 let current = yield* observe(env.project, savedQueryId, output?.name);
283
284 if (current === undefined) {
285 const created = yield* cloudasset
286 .createSavedQueries({
287 parent,
288 savedQueryId,
289 body,
290 })
291 .pipe(
292 Effect.catchTag("Conflict", () =>
293 observe(env.project, savedQueryId, output?.name),
294 ),
295 );
296 current = created ?? undefined;
297 }
298
299 if (current === undefined) {
300 return yield* new SavedQueryNotResolved({ name });
301 }
302
303 const currentName = current.name ?? name;
304 const observedLabels = tagRecord(current.labels);
305 const { upsert, removed } = diffLabels(observedLabels, desiredLabels);
306 const labelsChanged = upsert.length > 0 || removed.length > 0;
307 const descriptionChanged = !sameText(current.description, description);
308 const contentChanged = !sameJson(current.content, content);
309 const updateMask = updateMaskOf(
310 descriptionChanged ? "description" : undefined,
311 labelsChanged ? "labels" : undefined,
312 contentChanged ? "content" : undefined,
313 );
314
315 if (updateMask.length > 0) {
316 current = yield* cloudasset.patchSavedQueries({
317 name: currentName,
318 updateMask,
319 body: { ...body, name: currentName },
320 });
321 }
322
323 return toAttrs(current, env.project);
324 }),
325
326 delete: Effect.fn(function* ({ output }) {
327 if (!output.name) return;
328 yield* cloudasset
329 .deleteSavedQueries({ name: output.name })
330 .pipe(Effect.catchTag(["NotFound", "BadRequest"], () => Effect.void));
331 }),
332 });