1import * as acm from "@distilled.cloud/gcp/accesscontextmanager_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 { GcpEnvironment } from "../Environment.ts";
9import { createInternalLabels } from "../Labels.ts";
10import type { Providers } from "../Providers.ts";
11import {
12 encodeOwnershipLine,
13 lastSegment,
14 listAccessPolicies,
15 MAX_TITLE_LENGTH,
16 normalizeScopes,
17 ownedByAlchemy,
18 parseOwnership,
19 projectNumberOf,
20 replaceOnIdentity,
21 resolveOrganization,
22 resourceNameFromOperation,
23 sameStringList,
24 tryResolveOrganization,
25 waitForOperation,
26 waitUntilExists,
27 waitUntilGone,
28} from "./internal.ts";
29
30export type AccessPolicyProps = {
31 /**
32 * Organization parent (`organizations/{organization}` or the numeric
33 * organization id). If omitted, Alchemy uses `GOOGLE_ORGANIZATION_ID`
34 * or the project's Resource Manager parent. Immutable — changing it
35 * replaces the policy.
36 */
37 parent?: string;
38 /**
39 * Human-readable title. Access policies have no labels or description,
40 * so Alchemy ownership (`alchemy-stack` / `alchemy-stage` /
41 * `alchemy-id`) is stored in a `[alchemy …]` prefix and stripped from
42 * attributes.
43 */
44 title?: string;
45 /**
46 * Policy scopes. Format: `folders/{folder_number}` or
47 * `projects/{project_number}`. At most one scope. Empty or omitted
48 * creates the organization-wide default policy (one per organization).
49 * Immutable — changing scopes replaces the policy.
50 */
51 scopes?: string[];
52};
53
54export type AccessPolicy = Resource<
55 "GCP.AccessContextManager.AccessPolicy",
56 AccessPolicyProps,
57 {
58 /** Resource name `accessPolicies/{policy}`. */
59 name: string;
60 /** Policy id (last path segment). */
61 policyId: string;
62 /** Parent `organizations/{organization}`. */
63 parent: string;
64 /** User title with the Alchemy ownership prefix stripped. */
65 title: string | undefined;
66 /** Policy scopes (`projects/{number}` or `folders/{number}`). */
67 scopes: string[];
68 /** Server etag for optimistic concurrency. */
69 etag: string | undefined;
70 },
71 never,
72 Providers
73>;
74
75/**
76 * An Access Context Manager access policy.
77 *
78 * An access policy is the container for access levels, service
79 * perimeters, and authorized-orgs descriptors. One unscoped
80 * (organization-wide) policy is allowed per organization; additional
81 * policies must set `scopes` to a folder or project.
82 *
83 * Access policies have no labels. Alchemy stamps ownership into `title`
84 * so `list` / `pnpm nuke:gcp` can find them. `parent` and `scopes` are
85 * immutable — changing them replaces the policy. `title` updates in
86 * place. The policy id is assigned by the API.
87 *
88 * ### Creating an Access Policy
89 * **Example:** Scoped to the current project
90 * ```typescript
91 * const policy = yield* GCP.AccessContextManager.AccessPolicy("Corp", {
92 * title: "corp access policy",
93 * scopes: ["projects/123456789"],
94 * });
95 * ```
96 *
97 * **Example:** Organization-wide default policy
98 * ```typescript
99 * const policy = yield* GCP.AccessContextManager.AccessPolicy("Default", {
100 * parent: "organizations/123456789",
101 * title: "default policy",
102 * });
103 * ```
104 *
105 * ### Updating an Access Policy
106 * **Example:** Change the title
107 * ```typescript
108 * const policy = yield* GCP.AccessContextManager.AccessPolicy("Corp", {
109 * title: "corp access policy (prod)",
110 * scopes: ["projects/123456789"],
111 * });
112 * ```
113 *
114 * @resource
115 * @category AccessContextManager
116 */
117export const AccessPolicy = Resource<AccessPolicy>(
118 "GCP.AccessContextManager.AccessPolicy",
119);
120
121export class AccessPolicyNotResolved extends Data.TaggedError(
122 "GCP.AccessContextManager.AccessPolicyNotResolved",
123)<{
124 name: string;
125}> {}
126
127const toAttrs = (policy: acm.AccessPolicy): AccessPolicy["Attributes"] => {
128 const name = policy.name ?? "";
129 const parsed = parseOwnership(policy.title);
130 return {
131 name,
132 policyId: lastSegment(name),
133 parent: policy.parent ?? "",
134 title: parsed.text,
135 scopes: policy.scopes ?? [],
136 etag: policy.etag,
137 };
138};
139
140const getByName = (name: string) =>
141 acm
142 .getAccessPolicies({ name })
143 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
144
145const findOwned = (id: string, organization: string) =>
146 Effect.gen(function* () {
147 const policies = yield* listAccessPolicies(organization);
148 for (const policy of policies) {
149 if (yield* ownedByAlchemy(id, policy.title)) {
150 return policy;
151 }
152 }
153 return undefined;
154 });
155
156const observe = (id: string, name: string | undefined, organization: string) =>
157 Effect.gen(function* () {
158 if (name !== undefined && name.length > 0) {
159 const byName = yield* getByName(name);
160 if (byName !== undefined) return byName;
161 }
162 return yield* findOwned(id, organization);
163 });
164
165export const AccessPolicyProvider = () =>
166 Provider.succeed(AccessPolicy, {
167 stables: ["name", "policyId", "parent"],
168
169 diff: Effect.fn(function* ({ news, olds, output }) {
170 if (!isResolved(news)) return undefined;
171 const previousParent = olds?.parent ?? output?.parent;
172 const nextParent = news.parent ?? previousParent;
173 const parentChanged =
174 previousParent !== undefined &&
175 nextParent !== undefined &&
176 lastSegment(previousParent) !== lastSegment(nextParent);
177
178 const previousScopes = olds?.scopes ?? output?.scopes;
179 const scopesChanged =
180 news.scopes !== undefined &&
181 previousScopes !== undefined &&
182 !sameStringList(news.scopes, previousScopes);
183
184 return replaceOnIdentity(parentChanged || scopesChanged);
185 }),
186
187 read: Effect.fn(function* ({ id, olds, output }) {
188 const organization = yield* resolveOrganization(
189 olds?.parent,
190 output?.parent,
191 );
192 const existing = yield* observe(id, output?.name, organization);
193 if (existing === undefined) return undefined;
194 const attrs = toAttrs(existing);
195 return (yield* ownedByAlchemy(id, existing.title))
196 ? attrs
197 : Unowned(attrs);
198 }),
199
200 list: () =>
201 Effect.gen(function* () {
202 const organization = yield* tryResolveOrganization();
203 if (organization === undefined) return [];
204 const policies = yield* listAccessPolicies(organization);
205 return policies
206 .filter((policy) => parseOwnership(policy.title).labels["alchemy-id"])
207 .map(toAttrs);
208 }),
209
210 reconcile: Effect.fn(function* ({ id, news, output }) {
211 const env = yield* GcpEnvironment.current;
212 const organization = yield* resolveOrganization(
213 news.parent,
214 output?.parent,
215 );
216 const ownership = yield* createInternalLabels(id);
217 const desiredTitle = encodeOwnershipLine(
218 ownership,
219 news.title,
220 MAX_TITLE_LENGTH,
221 );
222 const projectNumber = yield* projectNumberOf(env.project);
223 const desiredScopes =
224 news.scopes !== undefined
225 ? normalizeScopes(news.scopes, projectNumber)
226 : (output?.scopes ?? []);
227
228 let current = yield* observe(id, output?.name, organization);
229
230 if (current === undefined) {
231 const created = yield* acm
232 .createAccessPolicies({
233 body: {
234 title: desiredTitle,
235 parent: organization,
236 scopes: desiredScopes.length > 0 ? desiredScopes : undefined,
237 },
238 })
239 .pipe(Effect.catchTag("Conflict", () => Effect.succeed(undefined)));
240 if (created !== undefined) {
241 // ALREADY_EXISTS (6): a concurrent create won the race.
242 const settled = yield* waitForOperation(created).pipe(
243 Effect.catchTag("GCP.OperationFailed", (error) =>
244 error.code === 6 ? Effect.succeed(created) : Effect.fail(error),
245 ),
246 );
247 const createdName =
248 resourceNameFromOperation(settled, "accessPolicies/") ??
249 resourceNameFromOperation(created, "accessPolicies/");
250 current =
251 createdName !== undefined
252 ? yield* waitUntilExists(getByName(createdName), createdName)
253 : yield* waitUntilExists(
254 findOwned(id, organization),
255 organization,
256 );
257 } else {
258 current = yield* waitUntilExists(
259 findOwned(id, organization),
260 organization,
261 );
262 }
263 }
264
265 if (current === undefined || current.name === undefined) {
266 return yield* new AccessPolicyNotResolved({
267 name: output?.name ?? organization,
268 });
269 }
270
271 if ((current.title ?? "") !== desiredTitle) {
272 const operation = yield* acm.patchAccessPolicies({
273 name: current.name,
274 updateMask: "title",
275 body: {
276 name: current.name,
277 title: desiredTitle,
278 etag: current.etag,
279 },
280 });
281 yield* waitForOperation(operation);
282 current = yield* waitUntilExists(getByName(current.name), current.name);
283 }
284
285 if (current === undefined) {
286 return yield* new AccessPolicyNotResolved({
287 name: output?.name ?? organization,
288 });
289 }
290
291 return toAttrs(current);
292 }),
293
294 delete: Effect.fn(function* ({ output }) {
295 const operation = yield* acm
296 .deleteAccessPolicies({ name: output.name })
297 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
298 if (operation !== undefined) {
299 // NOT_FOUND (5): already gone.
300 yield* waitForOperation(operation).pipe(
301 Effect.catchTag("GCP.OperationFailed", (error) =>
302 error.code === 5 ? Effect.void : Effect.fail(error),
303 ),
304 );
305 }
306 yield* waitUntilGone(getByName(output.name), output.name);
307 }),
308 });