1import * as agentidentity from "@distilled.cloud/gcp/agentidentity_v1";
2import * as Effect from "effect/Effect";
3import { Unowned } from "../../AdoptPolicy.ts";
4import { isResolved } from "../../Diff.ts";
5import * as Provider from "../../Provider.ts";
6import { Resource } from "../../Resource.ts";
7import { tagRecord } from "../../Tags.ts";
8import { GcpEnvironment } from "../Environment.ts";
9import {
10 createInternalLabels,
11 diffLabels,
12 hasAlchemyLabels,
13 toLabels,
14} from "../Labels.ts";
15import type { Providers } from "../Providers.ts";
16import {
17 AuthProviderNotResolved,
18 clipDescription,
19 fieldMask,
20 fingerprint,
21 getByName,
22 hasSecretUpdate,
23 listOwned,
24 normalizeLocation,
25 parseName,
26 publicTypeParams,
27 resourceName,
28 sameStringList,
29 sameText,
30 toPhysicalId,
31 toTypeParams,
32 typeParamsBody,
33 userLabels,
34 type ApiKeyParams,
35 type AuthProviderTypeParams,
36 type ThreeLeggedOAuthParams,
37 type TwoLeggedOAuthParams,
38} from "./internal.ts";
39
40export type {
41 ApiKeyParams,
42 AuthProviderTypeParams,
43 ThreeLeggedOAuthParams,
44 TwoLeggedOAuthParams,
45};
46
47export type AuthProviderProps = {
48 /**
49 * Auth provider id (the `{auth_provider}` segment of
50 * `projects/{project}/locations/{location}/authProviders/{auth_provider}`).
51 * If omitted, a unique RFC1035 name is generated. Must be 1-63
52 * characters, start with a lowercase letter, and use only `a-z` and
53 * `-`. Immutable — changing it replaces the auth provider.
54 */
55 authProviderId?: string;
56 /**
57 * Location of the auth provider. Immutable — changing it replaces the
58 * auth provider.
59 * @default the stack's GCP region (`GCP.Region`, profile region, `us-central1`)
60 */
61 location?: string;
62 /**
63 * Human-readable description (max 256 characters).
64 */
65 description?: string;
66 /**
67 * Scopes that may be requested. Empty or omitted allows every scope
68 * except those in `blockedScopes`. At most 200 entries.
69 */
70 allowedScopes?: string[];
71 /**
72 * Scopes that must not be requested. Takes precedence over
73 * `allowedScopes`. At most 200 entries.
74 */
75 blockedScopes?: string[];
76 /**
77 * Workload identities (`principal://…`) of the agents that use this
78 * auth provider. Input-only — omitted from attributes.
79 */
80 workloadIds?: string[];
81 /**
82 * When true, the auth provider is disabled via `:disable`. Defaults to
83 * enabled.
84 * @default false
85 */
86 disabled?: boolean;
87 /**
88 * Auth method parameters. Required on create. Exactly one of
89 * `threeLeggedOauth`, `twoLeggedOauth`, `apiKey`, or `geAuthProvider`
90 * should be set. Client secrets and API keys are input-only.
91 */
92 authProviderTypeParams: AuthProviderTypeParams;
93 /**
94 * User labels. Alchemy ownership labels are merged in automatically.
95 */
96 labels?: Record<string, string>;
97};
98
99export type AuthProvider = Resource<
100 "GCP.AgentIdentity.AuthProvider",
101 AuthProviderProps,
102 {
103 /** Full resource name `projects/{project}/locations/{location}/authProviders/{auth_provider}`. */
104 name: string;
105 /** Auth provider id (last path segment). */
106 authProviderId: string;
107 /** Project id. */
108 project: string;
109 /** Location id (`us-central1`, …). */
110 location: string;
111 /** Human-readable description. */
112 description: string | undefined;
113 /** Allowed OAuth scopes. */
114 allowedScopes: string[];
115 /** Blocked OAuth scopes. */
116 blockedScopes: string[];
117 /** User labels (Alchemy ownership labels stripped). */
118 labels: Record<string, string>;
119 /** Public auth method parameters (secrets stripped). */
120 authProviderTypeParams: AuthProviderTypeParams | undefined;
121 /** Server-reported state (`ENABLED`, `DISABLED`, …). */
122 state: string | undefined;
123 /** Whether the auth provider is disabled. */
124 disabled: boolean;
125 /** RFC3339 time the auth provider expires, if any. */
126 expireTime: string | undefined;
127 /** RFC3339 creation timestamp. */
128 createTime: string | undefined;
129 /** RFC3339 last-update timestamp. */
130 updateTime: string | undefined;
131 },
132 never,
133 Providers
134>;
135
136/**
137 * An Agent Identity auth provider — the credential configuration agents
138 * use to call third-party APIs (API keys, 2LO, or 3LO OAuth).
139 *
140 * `authProviderId` and `location` replace the resource. Description,
141 * labels, scopes, type parameters, and enabled/disabled update in place.
142 *
143 * ### Creating an Auth Provider
144 * **Example:** API key
145 * ```typescript
146 * const auth = yield* GCP.AgentIdentity.AuthProvider("Maps", {
147 * authProviderTypeParams: {
148 * apiKey: { apiKey: "maps-api-key" },
149 * },
150 * labels: { env: "prod" },
151 * });
152 * ```
153 *
154 * **Example:** Three-legged OAuth
155 * ```typescript
156 * const auth = yield* GCP.AgentIdentity.AuthProvider("Atlassian", {
157 * authProviderTypeParams: {
158 * threeLeggedOauth: {
159 * clientId: "jira-client",
160 * clientSecret: "jira-secret",
161 * authorizationUrl: "https://auth.atlassian.com/authorize",
162 * tokenUrl: "https://auth.atlassian.com/oauth/token",
163 * enablePkce: true,
164 * },
165 * },
166 * allowedScopes: ["read:jira-work"],
167 * labels: { env: "prod" },
168 * });
169 * ```
170 *
171 * ### Updating an Auth Provider
172 * **Example:** Description and labels
173 * ```typescript
174 * const auth = yield* GCP.AgentIdentity.AuthProvider("Maps", {
175 * authProviderId: "maps",
176 * authProviderTypeParams: {
177 * apiKey: { apiKey: "maps-api-key" },
178 * },
179 * description: "Maps API key for agents",
180 * labels: { env: "prod", role: "maps" },
181 * });
182 * ```
183 *
184 * @resource
185 * @category AgentIdentity
186 */
187export const AuthProvider = Resource<AuthProvider>(
188 "GCP.AgentIdentity.AuthProvider",
189);
190
191export { AuthProviderNotResolved };
192
193const toAttrs = (
194 item: agentidentity.AuthProvider,
195 project: string,
196 region: string,
197) => {
198 const name = item.name ?? "";
199 const parsed = parseName(name, region);
200 const state = item.state;
201 return {
202 name,
203 authProviderId: parsed.authProviderId,
204 project: parsed.project || project,
205 location: parsed.location,
206 description: item.description,
207 allowedScopes: item.allowedScopes ?? [],
208 blockedScopes: item.blockedScopes ?? [],
209 labels: userLabels(item.labels),
210 authProviderTypeParams: toTypeParams(item.authProviderTypeParams),
211 state,
212 disabled: state === "DISABLED",
213 expireTime: item.expireTime,
214 createTime: item.createTime,
215 updateTime: item.updateTime,
216 };
217};
218
219const replaceOnIdentity = (input: {
220 previousId: string | undefined;
221 nextId: string | undefined;
222 previousLocation: string;
223 nextLocation: string;
224}) => {
225 const idChanged =
226 input.previousId !== undefined &&
227 input.nextId !== undefined &&
228 input.nextId !== input.previousId;
229 const locationChanged = input.previousLocation !== input.nextLocation;
230 if (!idChanged && !locationChanged) return undefined;
231 return {
232 action: "replace" as const,
233 deleteFirst:
234 locationChanged &&
235 !idChanged &&
236 input.previousId !== undefined &&
237 input.nextId === input.previousId,
238 };
239};
240
241export const AuthProviderProvider = () =>
242 Provider.succeed(AuthProvider, {
243 stables: ["name", "authProviderId", "project", "location", "createTime"],
244
245 diff: Effect.fn(function* ({ news, olds, output }) {
246 if (!isResolved(news)) return undefined;
247 const env = yield* GcpEnvironment.current;
248 return replaceOnIdentity({
249 previousId: olds?.authProviderId ?? output?.authProviderId,
250 nextId:
251 news.authProviderId ?? olds?.authProviderId ?? output?.authProviderId,
252 previousLocation: normalizeLocation(
253 olds?.location ?? output?.location,
254 env.region,
255 ),
256 nextLocation: normalizeLocation(
257 news.location ?? olds?.location ?? output?.location,
258 env.region,
259 ),
260 });
261 }),
262
263 read: Effect.fn(function* ({ id, olds, output }) {
264 const env = yield* GcpEnvironment.current;
265 const authProviderId = yield* toPhysicalId(
266 id,
267 olds?.authProviderId,
268 output?.authProviderId,
269 );
270 const location = normalizeLocation(
271 olds?.location ?? output?.location,
272 env.region,
273 );
274 const name =
275 output?.name ?? resourceName(env.project, location, authProviderId);
276 const existing = yield* getByName(name);
277 if (existing === undefined || existing.deleted === true) {
278 return undefined;
279 }
280 const attrs = toAttrs(existing, env.project, env.region);
281 return (yield* hasAlchemyLabels(id, tagRecord(existing.labels)))
282 ? attrs
283 : Unowned(attrs);
284 }),
285
286 list: () =>
287 Effect.gen(function* () {
288 const env = yield* GcpEnvironment.current;
289 const items = yield* listOwned(env.project, env.region);
290 return items.map((item) => toAttrs(item, env.project, env.region));
291 }),
292
293 reconcile: Effect.fn(function* ({ id, news, olds, output }) {
294 const env = yield* GcpEnvironment.current;
295 const authProviderId = yield* toPhysicalId(
296 id,
297 news.authProviderId,
298 output?.authProviderId,
299 );
300 const location = normalizeLocation(
301 news.location ?? output?.location,
302 env.region,
303 );
304 const name = resourceName(env.project, location, authProviderId);
305 const desiredLabels = {
306 ...toLabels(news.labels),
307 ...(yield* createInternalLabels(id)),
308 };
309 const description = clipDescription(news.description);
310 const allowedScopes = news.allowedScopes ?? [];
311 const blockedScopes = news.blockedScopes ?? [];
312 const workloadIds = news.workloadIds;
313 const typeParams = typeParamsBody(news.authProviderTypeParams);
314
315 let current = yield* getByName(output?.name ?? name);
316
317 if (current !== undefined && current.deleted === true) {
318 current = yield* agentidentity
319 .undeleteProjectsLocationsAuthProviders({
320 name: current.name ?? name,
321 body: {},
322 })
323 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
324 }
325
326 if (current === undefined) {
327 const created = yield* agentidentity
328 .createProjectsLocationsAuthProviders({
329 parent: `projects/${env.project}/locations/${location}`,
330 authProviderId,
331 body: {
332 labels: desiredLabels,
333 description,
334 allowedScopes:
335 allowedScopes.length > 0 ? allowedScopes : undefined,
336 blockedScopes:
337 blockedScopes.length > 0 ? blockedScopes : undefined,
338 workloadIds:
339 workloadIds !== undefined && workloadIds.length > 0
340 ? workloadIds
341 : undefined,
342 authProviderTypeParams: typeParams,
343 },
344 })
345 .pipe(
346 Effect.catchTag("Conflict", () => getByName(output?.name ?? name)),
347 );
348 current = created ?? undefined;
349 }
350
351 if (current === undefined) {
352 return yield* new AuthProviderNotResolved({ name });
353 }
354
355 const resource = current.name ?? name;
356 if (current.deleted === true) {
357 current = yield* agentidentity.undeleteProjectsLocationsAuthProviders({
358 name: resource,
359 body: {},
360 });
361 }
362
363 const observedLabels = tagRecord(current.labels);
364 const { upsert, removed } = diffLabels(observedLabels, desiredLabels);
365 const labelsChanged = upsert.length > 0 || removed.length > 0;
366 const descriptionChanged = !sameText(current.description, description);
367 const allowedChanged = !sameStringList(
368 current.allowedScopes,
369 allowedScopes,
370 );
371 const blockedChanged = !sameStringList(
372 current.blockedScopes,
373 blockedScopes,
374 );
375 const observedTypeParams = publicTypeParams(
376 toTypeParams(current.authProviderTypeParams),
377 );
378 const desiredTypeParams = publicTypeParams(news.authProviderTypeParams);
379 const typeParamsChanged =
380 (observedTypeParams !== undefined &&
381 fingerprint(observedTypeParams) !== fingerprint(desiredTypeParams)) ||
382 hasSecretUpdate(
383 news.authProviderTypeParams,
384 olds?.authProviderTypeParams,
385 );
386 const workloadChanged =
387 workloadIds !== undefined &&
388 olds !== undefined &&
389 !sameStringList(olds.workloadIds, workloadIds);
390
391 const mask = fieldMask([
392 labelsChanged && "labels",
393 descriptionChanged && "description",
394 allowedChanged && "allowedScopes",
395 blockedChanged && "blockedScopes",
396 typeParamsChanged && "authProviderTypeParams",
397 workloadChanged && "workloadIds",
398 ]);
399
400 if (mask.length > 0) {
401 current = yield* agentidentity.patchProjectsLocationsAuthProviders({
402 name: resource,
403 updateMask: mask,
404 body: {
405 name: resource,
406 labels: desiredLabels,
407 description,
408 allowedScopes,
409 blockedScopes,
410 workloadIds,
411 authProviderTypeParams: typeParams,
412 },
413 });
414 }
415
416 const desiredDisabled = news.disabled === true;
417 const observedDisabled = current.state === "DISABLED";
418 if (desiredDisabled && !observedDisabled) {
419 current = yield* agentidentity.disableProjectsLocationsAuthProviders({
420 name: current.name ?? resource,
421 body: {},
422 });
423 } else if (!desiredDisabled && observedDisabled) {
424 current = yield* agentidentity.enableProjectsLocationsAuthProviders({
425 name: current.name ?? resource,
426 body: {},
427 });
428 }
429
430 return toAttrs(current, env.project, env.region);
431 }),
432
433 delete: Effect.fn(function* ({ output }) {
434 if (output.name.length === 0) return;
435 yield* agentidentity
436 .deleteProjectsLocationsAuthProviders({ name: output.name })
437 .pipe(Effect.catchTag("NotFound", () => Effect.void));
438 }),
439 });