1import * as acm from "@distilled.cloud/gcp/accesscontextmanager_v1";
2import * as Data from "effect/Data";
3import * as Effect from "effect/Effect";
4import { isResolved } from "../../Diff.ts";
5import * as Provider from "../../Provider.ts";
6import { Resource } from "../../Resource.ts";
7import type { Providers } from "../Providers.ts";
8import {
9 collectPages,
10 hasOwnershipMarker,
11 listOwnedPolicies,
12 parseName,
13 policyNameOf,
14 replaceOnIdentity,
15 resourceNameOf,
16 sameStringList,
17 toAcmId,
18 waitForOperation,
19 waitUntilExists,
20 waitUntilGone,
21} from "./internal.ts";
22
23export type AuthorizedOrgsDescDirection =
24 acm.AuthorizedOrgsDescAuthorizationDirectionEnum;
25export type AuthorizedOrgsDescAssetType = acm.AuthorizedOrgsDescAssetTypeEnum;
26export type AuthorizedOrgsDescAuthorizationType =
27 acm.AuthorizedOrgsDescAuthorizationTypeEnum;
28
29export type AccessPoliciesAuthorizedOrgsDescProps = {
30 /**
31 * Parent access policy (`accessPolicies/{policy}` or the policy id).
32 * Immutable — changing it replaces the descriptor.
33 */
34 policy: string;
35 /**
36 * Authorized-orgs descriptor id (the `{authorized_orgs_desc}`
37 * segment). If omitted, a unique name is generated from the stack,
38 * stage, and logical id. Must begin with a letter, then alphanumeric
39 * or `_`. Immutable — changing it replaces the descriptor.
40 */
41 authorizedOrgsDescId?: string;
42 /**
43 * Direction of the authorization relationship.
44 * `AUTHORIZATION_DIRECTION_FROM` lets this organization evaluate
45 * traffic in `orgs`. `AUTHORIZATION_DIRECTION_TO` lets `orgs` evaluate
46 * traffic in this organization. Immutable — changing it replaces the
47 * descriptor.
48 */
49 authorizationDirection: AuthorizedOrgsDescDirection | (string & {});
50 /**
51 * Asset type. Immutable — changing it replaces the descriptor.
52 */
53 assetType: AuthorizedOrgsDescAssetType | (string & {});
54 /**
55 * Authorization type. Currently only `AUTHORIZATION_TYPE_TRUST`.
56 * Immutable — changing it replaces the descriptor.
57 */
58 authorizationType: AuthorizedOrgsDescAuthorizationType | (string & {});
59 /**
60 * Organization ids in this descriptor. Format: `organizations/{id}`.
61 * This is the only mutable field.
62 */
63 orgs?: string[];
64};
65
66export type AccessPoliciesAuthorizedOrgsDesc = Resource<
67 "GCP.AccessContextManager.AccessPoliciesAuthorizedOrgsDesc",
68 AccessPoliciesAuthorizedOrgsDescProps,
69 {
70 /** Resource name `accessPolicies/{policy}/authorizedOrgsDescs/{id}`. */
71 name: string;
72 /** Descriptor id (last path segment). */
73 authorizedOrgsDescId: string;
74 /** Parent policy name `accessPolicies/{policy}`. */
75 policy: string;
76 /** Authorization direction. */
77 authorizationDirection: string | undefined;
78 /** Asset type. */
79 assetType: string | undefined;
80 /** Authorization type. */
81 authorizationType: string | undefined;
82 /** Organization ids (`organizations/{id}`). */
83 orgs: string[];
84 },
85 never,
86 Providers
87>;
88
89/**
90 * An Access Context Manager authorized-orgs descriptor.
91 *
92 * Authorized-orgs descriptors configure cross-organization authorization
93 * for device or credential-strength evaluation. They live under an
94 * {@link AccessPolicy}. The API has no labels or description field —
95 * Alchemy uses the generated resource id as identity, and `list` returns
96 * descriptors under Alchemy-owned access policies so `pnpm nuke:gcp`
97 * can find them.
98 *
99 * `policy`, `authorizedOrgsDescId`, `authorizationDirection`,
100 * `assetType`, and `authorizationType` are immutable. `orgs` updates in
101 * place.
102 *
103 * ### Creating an Authorized Orgs Descriptor
104 * **Example:** Trust devices from a partner org
105 * ```typescript
106 * const policy = yield* GCP.AccessContextManager.AccessPolicy("Corp", {
107 * scopes: ["projects/123456789"],
108 * });
109 * const desc = yield* GCP.AccessContextManager.AccessPoliciesAuthorizedOrgsDesc(
110 * "Partner",
111 * {
112 * policy: policy.name,
113 * authorizationDirection: "AUTHORIZATION_DIRECTION_FROM",
114 * assetType: "ASSET_TYPE_DEVICE",
115 * authorizationType: "AUTHORIZATION_TYPE_TRUST",
116 * orgs: ["organizations/987654321"],
117 * },
118 * );
119 * ```
120 *
121 * ### Updating an Authorized Orgs Descriptor
122 * **Example:** Change the trusted org list
123 * ```typescript
124 * const desc = yield* GCP.AccessContextManager.AccessPoliciesAuthorizedOrgsDesc(
125 * "Partner",
126 * {
127 * policy: policy.name,
128 * authorizationDirection: "AUTHORIZATION_DIRECTION_FROM",
129 * assetType: "ASSET_TYPE_DEVICE",
130 * authorizationType: "AUTHORIZATION_TYPE_TRUST",
131 * orgs: ["organizations/111", "organizations/222"],
132 * },
133 * );
134 * ```
135 *
136 * @resource
137 * @category AccessContextManager
138 */
139export const AccessPoliciesAuthorizedOrgsDesc =
140 Resource<AccessPoliciesAuthorizedOrgsDesc>(
141 "GCP.AccessContextManager.AccessPoliciesAuthorizedOrgsDesc",
142 );
143
144export class AccessPoliciesAuthorizedOrgsDescNotResolved extends Data.TaggedError(
145 "GCP.AccessContextManager.AccessPoliciesAuthorizedOrgsDescNotResolved",
146)<{
147 name: string;
148}> {}
149
150const toAttrs = (
151 desc: acm.AuthorizedOrgsDesc,
152): AccessPoliciesAuthorizedOrgsDesc["Attributes"] => {
153 const name = desc.name ?? "";
154 const parsed = parseName(name, "authorizedOrgsDescs");
155 return {
156 name,
157 authorizedOrgsDescId: parsed.id,
158 policy: parsed.parent,
159 authorizationDirection: desc.authorizationDirection,
160 assetType: desc.assetType,
161 authorizationType: desc.authorizationType,
162 orgs: desc.orgs ?? [],
163 };
164};
165
166const getByName = (name: string) =>
167 acm
168 .getAccessPoliciesAuthorizedOrgsDescs({ name })
169 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
170
171const listDescs = (policy: string) =>
172 collectPages(
173 acm.listAccessPoliciesAuthorizedOrgsDescs.pages({
174 parent: policyNameOf(policy),
175 pageSize: 100,
176 }),
177 (page) => page.authorizedOrgsDescs,
178 ).pipe(
179 Effect.catchTag("NotFound", () =>
180 Effect.succeed([] as acm.AuthorizedOrgsDesc[]),
181 ),
182 );
183
184export const AccessPoliciesAuthorizedOrgsDescProvider = () =>
185 Provider.succeed(AccessPoliciesAuthorizedOrgsDesc, {
186 stables: [
187 "name",
188 "authorizedOrgsDescId",
189 "policy",
190 "authorizationDirection",
191 "assetType",
192 "authorizationType",
193 ],
194
195 diff: Effect.fn(function* ({ news, olds, output }) {
196 if (!isResolved(news)) return undefined;
197 const previousId =
198 olds?.authorizedOrgsDescId ?? output?.authorizedOrgsDescId;
199 const idChanged =
200 previousId !== undefined &&
201 news.authorizedOrgsDescId !== undefined &&
202 news.authorizedOrgsDescId !== previousId;
203 const previousPolicy = olds?.policy ?? output?.policy;
204 const policyChanged =
205 previousPolicy !== undefined &&
206 policyNameOf(news.policy) !== policyNameOf(previousPolicy);
207 const directionChanged =
208 (olds?.authorizationDirection ?? output?.authorizationDirection) !==
209 undefined &&
210 news.authorizationDirection !==
211 (olds?.authorizationDirection ?? output?.authorizationDirection);
212 const assetChanged =
213 (olds?.assetType ?? output?.assetType) !== undefined &&
214 news.assetType !== (olds?.assetType ?? output?.assetType);
215 const typeChanged =
216 (olds?.authorizationType ?? output?.authorizationType) !== undefined &&
217 news.authorizationType !==
218 (olds?.authorizationType ?? output?.authorizationType);
219 return replaceOnIdentity(
220 idChanged ||
221 policyChanged ||
222 directionChanged ||
223 assetChanged ||
224 typeChanged,
225 );
226 }),
227
228 read: Effect.fn(function* ({ id, olds, output }) {
229 const authorizedOrgsDescId = yield* toAcmId(
230 id,
231 olds?.authorizedOrgsDescId,
232 output?.authorizedOrgsDescId,
233 );
234 const policy = olds?.policy ?? output?.policy;
235 if (policy === undefined) return undefined;
236 const name =
237 output?.name ??
238 resourceNameOf(policy, "authorizedOrgsDescs", authorizedOrgsDescId);
239 const existing = yield* getByName(name);
240 if (existing === undefined) return undefined;
241 return toAttrs(existing);
242 }),
243
244 list: () =>
245 Effect.gen(function* () {
246 const policies = yield* listOwnedPolicies();
247 const descs = yield* Effect.forEach(
248 policies,
249 (policy) =>
250 policy.name && hasOwnershipMarker(policy.title)
251 ? listDescs(policy.name)
252 : Effect.succeed([] as acm.AuthorizedOrgsDesc[]),
253 { concurrency: 4 },
254 );
255 return descs.flat().map(toAttrs);
256 }),
257
258 reconcile: Effect.fn(function* ({ id, news, output }) {
259 const policy = policyNameOf(news.policy);
260 const authorizedOrgsDescId = yield* toAcmId(
261 id,
262 news.authorizedOrgsDescId,
263 output?.authorizedOrgsDescId,
264 );
265 const name = resourceNameOf(
266 policy,
267 "authorizedOrgsDescs",
268 authorizedOrgsDescId,
269 );
270 const desiredOrgs = news.orgs ?? [];
271
272 let current = yield* getByName(output?.name ?? name);
273
274 if (current === undefined) {
275 const created = yield* acm
276 .createAccessPoliciesAuthorizedOrgsDescs({
277 parent: policy,
278 body: {
279 name,
280 authorizationDirection: news.authorizationDirection,
281 assetType: news.assetType,
282 authorizationType: news.authorizationType,
283 orgs: desiredOrgs.length > 0 ? desiredOrgs : undefined,
284 },
285 })
286 .pipe(Effect.catchTag("Conflict", () => Effect.succeed(undefined)));
287 if (created !== undefined) {
288 // ALREADY_EXISTS (6): a concurrent create won the race.
289 yield* waitForOperation(created).pipe(
290 Effect.catchTag("GCP.OperationFailed", (error) =>
291 error.code === 6 ? Effect.void : Effect.fail(error),
292 ),
293 );
294 }
295 current = yield* waitUntilExists(getByName(name), name);
296 }
297
298 if (current === undefined) {
299 return yield* new AccessPoliciesAuthorizedOrgsDescNotResolved({
300 name,
301 });
302 }
303
304 if (!sameStringList(current.orgs, desiredOrgs)) {
305 const operation = yield* acm.patchAccessPoliciesAuthorizedOrgsDescs({
306 name: current.name ?? name,
307 updateMask: "orgs",
308 body: {
309 name: current.name ?? name,
310 orgs: desiredOrgs,
311 },
312 });
313 yield* waitForOperation(operation);
314 current = yield* waitUntilExists(
315 getByName(current.name ?? name),
316 current.name ?? name,
317 );
318 }
319
320 if (current === undefined) {
321 return yield* new AccessPoliciesAuthorizedOrgsDescNotResolved({
322 name,
323 });
324 }
325
326 return toAttrs(current);
327 }),
328
329 delete: Effect.fn(function* ({ output }) {
330 const operation = yield* acm
331 .deleteAccessPoliciesAuthorizedOrgsDescs({ name: output.name })
332 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
333 if (operation !== undefined) {
334 // NOT_FOUND (5): already gone.
335 yield* waitForOperation(operation).pipe(
336 Effect.catchTag("GCP.OperationFailed", (error) =>
337 error.code === 5 ? Effect.void : Effect.fail(error),
338 ),
339 );
340 }
341 yield* waitUntilGone(getByName(output.name), output.name);
342 }),
343 });