1import * as integrations from "@distilled.cloud/gcp/integrations_v1";
2import * as Data from "effect/Data";
3import * as Effect from "effect/Effect";
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 { GcpEnvironment } from "../Environment.ts";
10import { createInternalLabels } from "../Labels.ts";
11import type { Providers } from "../Providers.ts";
12import {
13 encodeOwnership,
14 hasOwnershipMarker,
15 lastSegment,
16 locationOf,
17 locationParent,
18 normalizeLocation,
19 ownedByAlchemy,
20 parseOwnership,
21 sameText,
22 toResourceId,
23 updateMaskOf,
24} from "./ownership.ts";
25import type {
26 AuthConfigVisibility,
27 CredentialType,
28 DecryptedCredential,
29} from "./shared.ts";
30import { credentialBody } from "./shared.ts";
31
32export type AuthConfigProps = {
33 /**
34 * Auth config id (the `{authConfig}` segment of
35 * `projects/{project}/locations/{location}/authConfigs/{authConfig}`).
36 * Server-assigned on create. Immutable — changing it replaces the
37 * config.
38 */
39 authConfigId?: string;
40 /**
41 * Location (`us-central1`, …). Immutable — changing it replaces the
42 * config. `US-CENTRAL1` is accepted and normalized to `us-central1`.
43 * @default the stack's GCP region (`GCP.Region`, else the profile
44 * region, else `us-central1`)
45 */
46 location?: string;
47 /**
48 * Unique display name within the client.
49 */
50 displayName?: string;
51 /**
52 * Human-readable description. Auth configs have no labels field, so
53 * Alchemy ownership is stored in a `[alchemy …]` prefix and stripped
54 * from attributes.
55 */
56 description?: string;
57 /**
58 * Credential type applied to `decryptedCredential` when it omits one.
59 * @default "USERNAME_AND_PASSWORD"
60 */
61 credentialType?: CredentialType;
62 /**
63 * Decrypted credential payload. Write-only — never returned on
64 * attributes.
65 */
66 decryptedCredential?: DecryptedCredential;
67 /**
68 * Certificate id used for client-certificate auth.
69 */
70 certificateId?: string;
71 /**
72 * Visibility of the auth config.
73 * @default "PRIVATE"
74 */
75 visibility?: AuthConfigVisibility;
76};
77
78export type AuthConfig = Resource<
79 "GCP.Integrations.AuthConfig",
80 AuthConfigProps,
81 {
82 /** Full resource name `projects/{project}/locations/{location}/authConfigs/{authConfig}`. */
83 name: string;
84 /** Auth config id (last path segment). */
85 authConfigId: string;
86 /** Location id. */
87 location: string;
88 /** Project id. */
89 project: string;
90 /** Display name. */
91 displayName: string | undefined;
92 /** User description with the Alchemy ownership prefix stripped. */
93 description: string | undefined;
94 /** Credential type. */
95 credentialType: string | undefined;
96 /** Certificate id, if any. */
97 certificateId: string | undefined;
98 /** Visibility. */
99 visibility: string | undefined;
100 /** Server-reported state. */
101 state: string | undefined;
102 /** RFC3339 creation timestamp. */
103 createTime: string | undefined;
104 /** RFC3339 last-update timestamp. */
105 updateTime: string | undefined;
106 },
107 never,
108 Providers
109>;
110
111/**
112 * An Application Integration auth config (username/password, token, JWT,
113 * service account, …).
114 *
115 * Auth configs have no labels field — Alchemy stamps ownership into the
116 * description so `list` / nuke can find them. Location and id are
117 * immutable. Display name, description, visibility, and credentials
118 * update in place.
119 *
120 * ### Creating an Auth Config
121 * **Example:** Username and password
122 * ```typescript
123 * const auth = yield* GCP.Integrations.AuthConfig("Salesforce", {
124 * displayName: "salesforce-basic",
125 * credentialType: "USERNAME_AND_PASSWORD",
126 * decryptedCredential: {
127 * credentialType: "USERNAME_AND_PASSWORD",
128 * usernameAndPassword: { username: "user", password: "secret" },
129 * },
130 * });
131 * ```
132 *
133 * @resource
134 * @category Integrations
135 */
136export const AuthConfig = Resource<AuthConfig>("GCP.Integrations.AuthConfig");
137
138export class AuthConfigNotResolved extends Data.TaggedError(
139 "GCP.Integrations.AuthConfigNotResolved",
140)<{
141 name: string;
142}> {}
143
144const DEFAULT_CREDENTIAL_TYPE: CredentialType = "USERNAME_AND_PASSWORD";
145const DEFAULT_VISIBILITY: AuthConfigVisibility = "PRIVATE";
146
147const resourceName = (
148 project: string,
149 location: string,
150 authConfigId: string,
151) => `${locationParent(project, location)}/authConfigs/${authConfigId}`;
152
153const toAttrs = (
154 config: integrations.GoogleCloudIntegrationsV1alphaAuthConfig,
155 project: string,
156 region: string,
157) => {
158 const name = config.name ?? "";
159 const parsed = parseOwnership(config.description);
160 return {
161 name,
162 authConfigId: lastSegment(name),
163 location: locationOf(name, region),
164 project,
165 displayName: config.displayName,
166 description: parsed.text,
167 credentialType:
168 config.credentialType ?? config.decryptedCredential?.credentialType,
169 certificateId: config.certificateId,
170 visibility: config.visibility,
171 state: config.state,
172 createTime: config.createTime,
173 updateTime: config.updateTime,
174 };
175};
176
177const getByName = (name: string) =>
178 name.length === 0
179 ? Effect.succeed(undefined)
180 : integrations
181 .getProjectsLocationsAuthConfigs({ name })
182 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
183
184const listAt = (parent: string, project: string, region: string) =>
185 integrations.listProjectsLocationsAuthConfigs
186 .pages({ parent, pageSize: 100 })
187 .pipe(
188 Stream.flatMap((page) => Stream.fromIterable(page.authConfigs ?? [])),
189 Stream.filter((config) => hasOwnershipMarker(config.description)),
190 Stream.map((config) => toAttrs(config, project, region)),
191 Stream.runCollect,
192 Effect.map((chunk) => Array.from(chunk)),
193 Effect.catchTag("NotFound", () => Effect.succeed([])),
194 );
195
196const findOwned = (parent: string, id: string) =>
197 integrations.listProjectsLocationsAuthConfigs
198 .pages({ parent, pageSize: 100 })
199 .pipe(
200 Stream.flatMap((page) => Stream.fromIterable(page.authConfigs ?? [])),
201 Stream.filterEffect((config) => ownedByAlchemy(id, config.description)),
202 Stream.runHead,
203 Effect.map((option) =>
204 option._tag === "Some" ? option.value : undefined,
205 ),
206 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
207 );
208
209export const AuthConfigProvider = () =>
210 Provider.succeed(AuthConfig, {
211 stables: ["name", "authConfigId", "location", "project", "createTime"],
212
213 diff: Effect.fn(function* ({ news, olds, output }) {
214 if (!isResolved(news)) return undefined;
215 const env = yield* GcpEnvironment.current;
216 const previousLocation = olds?.location ?? output?.location;
217 const nextLocation = normalizeLocation(
218 news.location,
219 previousLocation ?? env.region,
220 );
221 if (
222 previousLocation !== undefined &&
223 normalizeLocation(previousLocation, env.region) !== nextLocation
224 ) {
225 return { action: "replace" as const, deleteFirst: false };
226 }
227 const previousId = olds?.authConfigId ?? output?.authConfigId;
228 if (
229 previousId !== undefined &&
230 news.authConfigId !== undefined &&
231 news.authConfigId !== previousId
232 ) {
233 return { action: "replace" as const, deleteFirst: false };
234 }
235 return undefined;
236 }),
237
238 read: Effect.fn(function* ({ id, olds, output }) {
239 const env = yield* GcpEnvironment.current;
240 const authConfigId = yield* toResourceId(
241 id,
242 olds?.authConfigId,
243 output?.authConfigId,
244 );
245 const location = normalizeLocation(
246 olds?.location ?? output?.location,
247 env.region,
248 );
249 const name =
250 output?.name ?? resourceName(env.project, location, authConfigId);
251 let existing = yield* getByName(name);
252 if (existing === undefined && output?.name === undefined) {
253 existing = yield* findOwned(locationParent(env.project, location), id);
254 }
255 if (existing === undefined) return undefined;
256 const attrs = toAttrs(existing, env.project, env.region);
257 return (yield* ownedByAlchemy(id, existing.description))
258 ? attrs
259 : Unowned(attrs);
260 }),
261
262 list: () =>
263 Effect.gen(function* () {
264 const env = yield* GcpEnvironment.current;
265 return yield* listAt(
266 locationParent(env.project, env.region),
267 env.project,
268 env.region,
269 );
270 }),
271
272 reconcile: Effect.fn(function* ({ id, news, output }) {
273 const env = yield* GcpEnvironment.current;
274 const location = normalizeLocation(
275 news.location ?? output?.location,
276 env.region,
277 );
278 const parent = locationParent(env.project, location);
279 const authConfigId = yield* toResourceId(
280 id,
281 news.authConfigId,
282 output?.authConfigId,
283 );
284 const name =
285 output?.name ?? resourceName(env.project, location, authConfigId);
286 const ownership = yield* createInternalLabels(id);
287 const description = encodeOwnership(ownership, news.description);
288 const displayName = news.displayName ?? authConfigId;
289 const credentialType = news.credentialType ?? DEFAULT_CREDENTIAL_TYPE;
290 const visibility = news.visibility ?? DEFAULT_VISIBILITY;
291 const decryptedCredential = credentialBody(
292 news.decryptedCredential,
293 credentialType,
294 );
295
296 let current = yield* getByName(output?.name ?? name);
297 if (current === undefined) {
298 current = yield* findOwned(parent, id);
299 }
300
301 if (current === undefined) {
302 const created = yield* integrations
303 .createProjectsLocationsAuthConfigs({
304 parent,
305 body: {
306 displayName,
307 description,
308 decryptedCredential,
309 certificateId: news.certificateId,
310 visibility,
311 },
312 })
313 .pipe(Effect.catchTag("Conflict", () => findOwned(parent, id)));
314 current = created ?? (yield* findOwned(parent, id));
315 }
316
317 if (current === undefined) {
318 return yield* new AuthConfigNotResolved({ name });
319 }
320
321 const currentName = current.name ?? name;
322 const displayChanged = !sameText(current.displayName, displayName);
323 const descriptionChanged = (current.description ?? "") !== description;
324 const visibilityChanged = !sameText(current.visibility, visibility);
325 const certificateChanged = !sameText(
326 current.certificateId,
327 news.certificateId,
328 );
329 const credentialChanged = news.decryptedCredential !== undefined;
330
331 if (
332 displayChanged ||
333 descriptionChanged ||
334 visibilityChanged ||
335 certificateChanged ||
336 credentialChanged
337 ) {
338 current = yield* integrations.patchProjectsLocationsAuthConfigs({
339 name: currentName,
340 updateMask: updateMaskOf(
341 displayChanged ? "display_name" : undefined,
342 descriptionChanged ? "description" : undefined,
343 visibilityChanged ? "visibility" : undefined,
344 certificateChanged ? "certificate_id" : undefined,
345 credentialChanged ? "decrypted_credential" : undefined,
346 ),
347 body: {
348 name: currentName,
349 displayName,
350 description,
351 decryptedCredential,
352 certificateId: news.certificateId,
353 visibility,
354 },
355 });
356 }
357
358 return toAttrs(current, env.project, env.region);
359 }),
360
361 delete: Effect.fn(function* ({ output }) {
362 yield* integrations
363 .deleteProjectsLocationsAuthConfigs({ name: output.name })
364 .pipe(Effect.catchTag("NotFound", () => Effect.void));
365 }),
366 });