1import * as securityposture from "@distilled.cloud/gcp/securityposture_v1";
2import * as Effect from "effect/Effect";
3import * as Schedule from "effect/Schedule";
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 { createInternalLabels, hasAlchemyLabels, toLabels } from "../Labels.ts";
12import type { Providers } from "../Providers.ts";
13import {
14 DEFAULT_LOCATION,
15 DEFAULT_STATE,
16 desiredPolicySets,
17 fieldMask,
18 fingerprint,
19 hasAlchemyAnnotationMap,
20 lastSegment,
21 locationParent,
22 organizationIdOf,
23 organizationParent,
24 parseName,
25 replaceOnIdentity,
26 resolveOrganization,
27 sameText,
28 SecuritypostureNotResolved,
29 toPhysicalId,
30 tryResolveOrganization,
31 userAnnotations,
32 waitForOperation,
33 waitUntilExists,
34 waitUntilGone,
35} from "./internal.ts";
36
37export type PostureState = securityposture.PostureStateEnum | (string & {});
38export type PolicySet = securityposture.PolicySet;
39export type Policy = securityposture.Policy;
40export type Constraint = securityposture.Constraint;
41
42export type PostureProps = {
43 /**
44 * Posture id (the `{posture}` segment of
45 * `organizations/{organization}/locations/global/postures/{posture}`).
46 * If omitted, a unique id is generated from the stack, stage, and
47 * logical id. Letters, digits, hyphens, and underscores; max 63
48 * characters. Immutable — changing it replaces the posture.
49 */
50 postureId?: string;
51 /**
52 * Parent organization (`organizations/{organization}` or the numeric
53 * id). Defaults to `GOOGLE_ORGANIZATION_ID` or the project's Resource
54 * Manager ancestor. Immutable — changing it replaces the posture.
55 */
56 organization?: string;
57 /**
58 * Location. Security postures live in `global`.
59 * @default "global"
60 */
61 location?: string;
62 /**
63 * Posture state. `DRAFT` is not deployable; `ACTIVE` can be deployed;
64 * `DEPRECATED` cannot be deployed until reactivated.
65 * @default "DRAFT"
66 */
67 state?: PostureState;
68 /**
69 * Human-readable description.
70 */
71 description?: string;
72 /**
73 * User annotations. Postures have no labels field — Alchemy ownership
74 * (`alchemy-stack` / `alchemy-stage` / `alchemy-id`) is stored here
75 * so `list` / `pnpm nuke:gcp` can find owned postures. Annotations are
76 * set at create time.
77 */
78 annotations?: Record<string, string>;
79 /**
80 * Policy sets the posture includes. When omitted, a disabled Security
81 * Health Analytics detector is used so the posture is valid.
82 */
83 policySets?: PolicySet[];
84};
85
86export type Posture = Resource<
87 "GCP.SecurityPosture.Posture",
88 PostureProps,
89 {
90 /** Full resource name `organizations/{organization}/locations/global/postures/{posture}`. */
91 name: string;
92 /** Posture id (last path segment). */
93 postureId: string;
94 /** Organization resource name. */
95 organization: string;
96 /** Organization id. */
97 organizationId: string;
98 /** Location id (`global`). */
99 location: string;
100 /** Project id of the deploying stack. */
101 project: string;
102 /** Posture state (`DRAFT`, `ACTIVE`, or `DEPRECATED`). */
103 state: string | undefined;
104 /** Opaque eight-character revision id used when deploying. */
105 revisionId: string | undefined;
106 /** User description. */
107 description: string | undefined;
108 /** User annotations (Alchemy ownership keys stripped). */
109 annotations: Record<string, string>;
110 /** Policy sets currently configured. */
111 policySets: PolicySet[];
112 /** Categories assigned by the Security Posture API. */
113 categories: string[] | undefined;
114 /** Whether the posture is being updated. */
115 reconciling: boolean | undefined;
116 /** Server checksum used on update and delete. */
117 etag: string | undefined;
118 /** RFC3339 creation timestamp. */
119 createTime: string | undefined;
120 /** RFC3339 last-update timestamp. */
121 updateTime: string | undefined;
122 },
123 never,
124 Providers
125>;
126
127/**
128 * An organization-scoped Security Command Center security posture.
129 *
130 * A posture groups organization-policy constraints and Security Health
131 * Analytics detectors into policy sets that can be deployed to a
132 * project, folder, or organization. Postures live under
133 * `organizations/{organization}/locations/global`. They have no labels
134 * field — Alchemy stamps ownership into `annotations`. `postureId` and
135 * `organization` are identity. Description, policy sets, and state
136 * update in place. State cannot be patched in the same request as other
137 * fields.
138 *
139 * Creating a posture requires the Security Posture API and
140 * organization-level Security Command Center Premium or Enterprise.
141 *
142 * ### Creating a Posture
143 * **Example:** Generated id with the default detector policy set
144 * ```typescript
145 * const posture = yield* GCP.SecurityPosture.Posture("Baseline", {
146 * description: "staging baseline",
147 * });
148 * ```
149 *
150 * **Example:** Named ACTIVE posture with a Security Health Analytics module
151 * ```typescript
152 * const posture = yield* GCP.SecurityPosture.Posture("Baseline", {
153 * organization: "organizations/123456789",
154 * postureId: "staging-baseline",
155 * state: "ACTIVE",
156 * policySets: [
157 * {
158 * policySetId: "sha",
159 * policies: [
160 * {
161 * policyId: "api-key-exists",
162 * constraint: {
163 * securityHealthAnalyticsModule: {
164 * moduleName: "API_KEY_EXISTS",
165 * moduleEnablementState: "DISABLED",
166 * },
167 * },
168 * },
169 * ],
170 * },
171 * ],
172 * });
173 * ```
174 *
175 * @resource
176 * @category SecurityPosture
177 */
178export const Posture = Resource<Posture>("GCP.SecurityPosture.Posture");
179
180const resourceName = (
181 organization: string,
182 location: string,
183 postureId: string,
184) => `${locationParent(organization, location)}/postures/${postureId}`;
185
186const waitPostureOperation = (operation: securityposture.Operation) =>
187 waitForOperation(operation);
188
189const waitPostureOperationGone = (operation: securityposture.Operation) =>
190 waitForOperation(operation, { notFoundOk: true });
191
192const toAttrs = (posture: securityposture.Posture, project: string) => {
193 const name = posture.name ?? "";
194 const parsed = parseName(name, "postures");
195 const organization = parsed.organization
196 ? organizationParent(parsed.organization)
197 : "";
198 return {
199 name,
200 postureId: parsed.id || lastSegment(name),
201 organization,
202 organizationId: organizationIdOf(organization),
203 location: parsed.location,
204 project,
205 state: posture.state,
206 revisionId: posture.revisionId,
207 description: posture.description,
208 annotations: userAnnotations(posture.annotations),
209 policySets: posture.policySets ?? [],
210 categories: posture.categories,
211 reconciling: posture.reconciling,
212 etag: posture.etag,
213 createTime: posture.createTime,
214 updateTime: posture.updateTime,
215 };
216};
217
218const getByName = (name: string) =>
219 name.length === 0
220 ? Effect.succeed(undefined)
221 : securityposture
222 .getOrganizationsLocationsPostures({ name })
223 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
224
225const desiredState = (news: PostureProps) => news.state ?? DEFAULT_STATE;
226
227const desiredBody = (
228 news: PostureProps,
229 annotations: Record<string, string>,
230): securityposture.Posture => ({
231 state: desiredState(news),
232 description: news.description,
233 annotations,
234 policySets: desiredPolicySets(news.policySets),
235});
236
237export const PostureProvider = () =>
238 Provider.succeed(Posture, {
239 stables: [
240 "name",
241 "postureId",
242 "organization",
243 "organizationId",
244 "location",
245 "project",
246 "createTime",
247 ],
248
249 diff: Effect.fn(function* ({ news, olds, output }) {
250 if (!isResolved(news)) return undefined;
251 const previousId = olds?.postureId ?? output?.postureId;
252 const nextId = news.postureId ?? previousId;
253 const previousOrg = olds?.organization ?? output?.organization;
254 const nextOrg =
255 news.organization !== undefined
256 ? organizationParent(news.organization)
257 : previousOrg;
258 return replaceOnIdentity(
259 (previousId !== undefined &&
260 nextId !== undefined &&
261 nextId !== previousId) ||
262 (previousOrg !== undefined &&
263 nextOrg !== undefined &&
264 organizationParent(previousOrg) !== organizationParent(nextOrg)),
265 false,
266 );
267 }),
268
269 read: Effect.fn(function* ({ id, olds, output }) {
270 const env = yield* GcpEnvironment.current;
271 const postureId = yield* toPhysicalId(
272 id,
273 olds?.postureId,
274 output?.postureId,
275 "posture",
276 );
277 const organization = yield* resolveOrganization(
278 olds?.organization ?? output?.organization,
279 output?.organization,
280 ).pipe(
281 Effect.catchTag("GCP.SecurityPosture.OrganizationNotResolved", () =>
282 Effect.succeed(output?.organization ?? ""),
283 ),
284 );
285 const location = lastSegment(
286 olds?.location ?? output?.location ?? DEFAULT_LOCATION,
287 );
288 const name =
289 output?.name ??
290 (organization.length > 0
291 ? resourceName(organization, location, postureId)
292 : "");
293 const existing = yield* getByName(name);
294 if (existing === undefined) return undefined;
295 const attrs = toAttrs(existing, env.project);
296 return (yield* hasAlchemyLabels(id, tagRecord(existing.annotations)))
297 ? attrs
298 : Unowned(attrs);
299 }),
300
301 list: () =>
302 Effect.gen(function* () {
303 const env = yield* GcpEnvironment.current;
304 const organization = yield* tryResolveOrganization();
305 if (organization === undefined) return [];
306 const parent = locationParent(organization, DEFAULT_LOCATION);
307 return yield* securityposture.listOrganizationsLocationsPostures
308 .pages({ parent, pageSize: 1000 })
309 .pipe(
310 Stream.flatMap((page) => Stream.fromIterable(page.postures ?? [])),
311 Stream.filter((posture) =>
312 hasAlchemyAnnotationMap(posture.annotations),
313 ),
314 Stream.map((posture) => toAttrs(posture, env.project)),
315 Stream.runCollect,
316 Effect.map((chunk) => Array.from(chunk)),
317 Effect.catchTag("NotFound", () => Effect.succeed([])),
318 );
319 }),
320
321 reconcile: Effect.fn(function* ({ id, news, output }) {
322 const env = yield* GcpEnvironment.current;
323 const postureId = yield* toPhysicalId(
324 id,
325 news.postureId,
326 output?.postureId,
327 "posture",
328 );
329 const organization = yield* resolveOrganization(
330 news.organization ?? output?.organization,
331 output?.organization,
332 );
333 const location = lastSegment(
334 news.location ?? output?.location ?? DEFAULT_LOCATION,
335 );
336 const parent = locationParent(organization, location);
337 const name = resourceName(organization, location, postureId);
338 const desiredAnnotations = {
339 ...toLabels(news.annotations),
340 ...(yield* createInternalLabels(id)),
341 };
342 const desired = desiredBody(news, desiredAnnotations);
343
344 let current = yield* getByName(output?.name ?? name);
345
346 if (current === undefined) {
347 const created = yield* securityposture
348 .createOrganizationsLocationsPostures({
349 parent,
350 postureId,
351 body: desired,
352 })
353 .pipe(Effect.catchTag("Conflict", () => Effect.succeed(undefined)));
354 if (created !== undefined) {
355 yield* waitPostureOperation(created);
356 }
357 current = yield* waitUntilExists(getByName(name), name);
358 current = yield* waitUntilExists(
359 getByName(name).pipe(
360 Effect.map((value) =>
361 value &&
362 value.reconciling !== true &&
363 (value.revisionId ?? "").length > 0
364 ? value
365 : undefined,
366 ),
367 ),
368 name,
369 );
370 }
371
372 if (current === undefined) {
373 return yield* new SecuritypostureNotResolved({ name });
374 }
375
376 const descriptionChanged = !sameText(
377 current.description,
378 desired.description,
379 );
380 const policySetsChanged =
381 fingerprint(current.policySets) !== fingerprint(desired.policySets);
382 const stateChanged = !sameText(
383 current.state ?? DEFAULT_STATE,
384 desired.state,
385 );
386 const contentMask = fieldMask([
387 descriptionChanged && "description",
388 policySetsChanged && "policy_sets",
389 ]);
390
391 if (contentMask.length > 0) {
392 const operation =
393 yield* securityposture.patchOrganizationsLocationsPostures({
394 name: current.name ?? name,
395 revisionId: current.revisionId,
396 updateMask: contentMask,
397 body: {
398 name: current.name ?? name,
399 description: desired.description,
400 policySets: desired.policySets,
401 etag: current.etag,
402 },
403 });
404 yield* waitPostureOperation(operation);
405 current = yield* waitUntilExists(
406 getByName(current.name ?? name).pipe(
407 Effect.map((value) =>
408 value && value.reconciling !== true ? value : undefined,
409 ),
410 ),
411 current.name ?? name,
412 );
413 }
414
415 if (stateChanged) {
416 const operation =
417 yield* securityposture.patchOrganizationsLocationsPostures({
418 name: current.name ?? name,
419 revisionId: current.revisionId,
420 updateMask: "state",
421 body: {
422 name: current.name ?? name,
423 state: desired.state,
424 etag: current.etag,
425 },
426 });
427 yield* waitPostureOperation(operation);
428 current = yield* waitUntilExists(
429 getByName(current.name ?? name).pipe(
430 Effect.map((value) =>
431 value && value.reconciling !== true ? value : undefined,
432 ),
433 ),
434 current.name ?? name,
435 );
436 }
437
438 return toAttrs(current, env.project);
439 }),
440
441 delete: Effect.fn(function* ({ output }) {
442 if (!output.name) return;
443 const operation = yield* securityposture
444 .deleteOrganizationsLocationsPostures({
445 name: output.name,
446 etag: output.etag,
447 })
448 .pipe(
449 Effect.retry({
450 while: (error) => error._tag === "Conflict",
451 times: 8,
452 schedule: Schedule.spaced("2 seconds"),
453 }),
454 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
455 );
456 if (operation !== undefined) {
457 yield* waitPostureOperationGone(operation);
458 }
459 yield* waitUntilGone(getByName(output.name), output.name);
460 }),
461 });