1import * as apigee from "@distilled.cloud/gcp/apigee_v1";
2import * as Data from "effect/Data";
3import * as Effect from "effect/Effect";
4import { Unowned } from "../../AdoptPolicy.ts";
5import { isResolved } from "../../Diff.ts";
6import { createPhysicalName } from "../../PhysicalName.ts";
7import * as Provider from "../../Provider.ts";
8import { Resource } from "../../Resource.ts";
9import { GcpEnvironment } from "../Environment.ts";
10import type { Providers } from "../Providers.ts";
11import {
12 attributesToRecord,
13 childName,
14 collectPages,
15 createOwnership,
16 defaultOrgName,
17 desiredAttributes,
18 lastSegment,
19 listOrgNames,
20 orgIdOf,
21 orgNameOf,
22 ownedBy,
23 recordToAttributes,
24 sameRecord,
25 sameStringList,
26 userAttributes,
27} from "./operations.ts";
28
29export type AppgroupsAppsKeyProps = {
30 /**
31 * Apigee organization id or `organizations/{org}`. Defaults to the
32 * current GCP project id. Immutable — changing it replaces the key.
33 */
34 organization?: string;
35 /**
36 * Parent AppGroup name or `organizations/{org}/appgroups/{appgroup}`.
37 * Immutable — changing it replaces the key.
38 */
39 appGroup: string;
40 /**
41 * Parent AppGroup app name or full resource name. Immutable — changing
42 * it replaces the key.
43 */
44 app: string;
45 /**
46 * Consumer key. If omitted, a unique key is generated. Immutable —
47 * changing it replaces the key.
48 */
49 consumerKey?: string;
50 /**
51 * Consumer secret. If omitted, a unique secret is generated. Immutable —
52 * changing it replaces the key.
53 */
54 consumerSecret?: string;
55 /**
56 * Scopes to apply to the key.
57 */
58 scopes?: string[];
59 /**
60 * API products associated with the key. Applied after create via
61 * UpdateAppGroupAppKey.
62 */
63 apiProducts?: string[];
64 /**
65 * Credential status (`approved` or `revoked`).
66 * @default "approved"
67 */
68 status?: string;
69 /**
70 * Expiration time in seconds. `-1` (default) never expires. Immutable
71 * after create.
72 */
73 expiresInSeconds?: string;
74 /**
75 * Custom attributes. Alchemy ownership attributes are merged in
76 * automatically.
77 */
78 attributes?: Record<string, string>;
79};
80
81export type AppgroupsAppsKey = Resource<
82 "GCP.Apigee.AppgroupsAppsKey",
83 AppgroupsAppsKeyProps,
84 {
85 /** Full resource name `organizations/{org}/appgroups/{appgroup}/apps/{app}/keys/{key}`. */
86 name: string;
87 /** Consumer key. */
88 consumerKey: string;
89 /** Consumer secret. */
90 consumerSecret: string | undefined;
91 /** Parent AppGroup id. */
92 appGroup: string;
93 /** Parent app name. */
94 app: string;
95 /** Apigee organization id. */
96 organization: string;
97 /** Scopes. */
98 scopes: string[];
99 /** Associated API products. */
100 apiProducts: apigee.GoogleCloudApigeeV1APIProductAssociation[];
101 /** Credential status. */
102 status: string | undefined;
103 /** User attributes (Alchemy ownership attributes stripped). */
104 attributes: Record<string, string>;
105 /** Issued-at time in milliseconds since epoch. */
106 issuedAt: string | undefined;
107 /** Expiration time in milliseconds since epoch. */
108 expiresAt: string | undefined;
109 },
110 never,
111 Providers
112>;
113
114/**
115 * A custom consumer key and secret for an Apigee AppGroup app.
116 *
117 * Apigee keys have no labels field, so Alchemy stamps ownership into
118 * custom attributes for `list` / nuke. The consumer key is identity —
119 * changing it replaces the key. Scopes, API products, status, and
120 * attributes update in place.
121 *
122 * ### Creating an AppGroup App Key
123 * **Example:** Generated key under an AppGroup app
124 * ```typescript
125 * const key = yield* GCP.Apigee.AppgroupsAppsKey("GroupKey", {
126 * appGroup: "partners",
127 * app: "partner-portal",
128 * });
129 * ```
130 *
131 * **Example:** Custom key and secret
132 * ```typescript
133 * const key = yield* GCP.Apigee.AppgroupsAppsKey("GroupKey", {
134 * appGroup: "partners",
135 * app: "partner-portal",
136 * consumerKey: "partner-key",
137 * consumerSecret: "partner-secret",
138 * });
139 * ```
140 *
141 * @resource
142 * @category Apigee
143 */
144export const AppgroupsAppsKey = Resource<AppgroupsAppsKey>(
145 "GCP.Apigee.AppgroupsAppsKey",
146);
147
148export class AppgroupsAppsKeyNotResolved extends Data.TaggedError(
149 "GCP.Apigee.AppgroupsAppsKeyNotResolved",
150)<{
151 name: string;
152}> {}
153
154const appGroupIdOf = (appGroup: string) =>
155 appGroup.includes("/appgroups/") ? lastSegment(appGroup) : appGroup;
156
157const appNameOf = (app: string) =>
158 app.includes("/apps/") ? lastSegment(app) : app;
159
160const appParentOf = (organization: string, appGroup: string, app: string) =>
161 `${childName(orgNameOf(organization), "appgroups", appGroupIdOf(appGroup))}/apps/${appNameOf(app)}`;
162
163const resourceName = (
164 organization: string,
165 appGroup: string,
166 app: string,
167 consumerKey: string,
168) => `${appParentOf(organization, appGroup, app)}/keys/${consumerKey}`;
169
170const toKeyId = (id: string, explicit: string | undefined, existing?: string) =>
171 Effect.gen(function* () {
172 if (explicit !== undefined) return explicit;
173 if (existing !== undefined) return existing;
174 return yield* createPhysicalName({ id, maxLength: 64, lowercase: true });
175 });
176
177const toSecret = (id: string, explicit: string | undefined) =>
178 Effect.gen(function* () {
179 if (explicit !== undefined) return explicit;
180 return yield* createPhysicalName({
181 id: `${id}-secret`,
182 maxLength: 64,
183 lowercase: true,
184 });
185 });
186
187const toAttrs = (
188 key: apigee.GoogleCloudApigeeV1AppGroupAppKey,
189 organization: string,
190 appGroup: string,
191 app: string,
192) => {
193 const consumerKey = key.consumerKey ?? "";
194 return {
195 name: resourceName(organization, appGroup, app, consumerKey),
196 consumerKey,
197 consumerSecret: key.consumerSecret,
198 appGroup: appGroupIdOf(appGroup),
199 app: appNameOf(app),
200 organization: orgIdOf(organization),
201 scopes: key.scopes ?? [],
202 apiProducts: key.apiProducts ?? [],
203 status: key.status,
204 attributes: userAttributes(attributesToRecord(key.attributes)),
205 issuedAt: key.issuedAt,
206 expiresAt: key.expiresAt,
207 };
208};
209
210const getByName = (name: string) =>
211 apigee
212 .getOrganizationsAppgroupsAppsKeys({ name })
213 .pipe(
214 Effect.catchTag(["NotFound", "ApigeeResourceNotFound"], () =>
215 Effect.succeed(undefined),
216 ),
217 );
218
219const listAppGroups = (organization: string) =>
220 collectPages(
221 apigee.listOrganizationsAppgroups.pages({
222 parent: organization,
223 pageSize: 1000,
224 }),
225 (page) => page.appGroups,
226 ).pipe(
227 Effect.catchTag(["NotFound", "ApigeeResourceNotFound"], () =>
228 Effect.succeed([] as apigee.GoogleCloudApigeeV1AppGroup[]),
229 ),
230 );
231
232const listApps = (parent: string) =>
233 collectPages(
234 apigee.listOrganizationsAppgroupsApps.pages({
235 parent,
236 pageSize: 1000,
237 }),
238 (page) => page.appGroupApps,
239 ).pipe(
240 Effect.catchTag(["NotFound", "ApigeeResourceNotFound"], () =>
241 Effect.succeed([] as apigee.GoogleCloudApigeeV1AppGroupApp[]),
242 ),
243 );
244
245export const AppgroupsAppsKeyProvider = () =>
246 Provider.succeed(AppgroupsAppsKey, {
247 stables: [
248 "name",
249 "consumerKey",
250 "appGroup",
251 "app",
252 "organization",
253 "issuedAt",
254 ],
255
256 diff: Effect.fn(function* ({ news, olds, output }) {
257 if (!isResolved(news)) return undefined;
258 const previousKey = olds?.consumerKey ?? output?.consumerKey;
259 const previousSecret = olds?.consumerSecret ?? output?.consumerSecret;
260 const previousGroup = olds?.appGroup ?? output?.appGroup;
261 const previousApp = olds?.app ?? output?.app;
262 const previousOrg = olds?.organization ?? output?.organization;
263 const keyChanged =
264 previousKey !== undefined &&
265 news.consumerKey !== undefined &&
266 news.consumerKey !== previousKey;
267 const secretChanged =
268 previousSecret !== undefined &&
269 news.consumerSecret !== undefined &&
270 news.consumerSecret !== previousSecret;
271 const groupChanged =
272 previousGroup !== undefined &&
273 appGroupIdOf(news.appGroup) !== appGroupIdOf(previousGroup);
274 const appChanged =
275 previousApp !== undefined &&
276 appNameOf(news.app) !== appNameOf(previousApp);
277 const orgChanged =
278 previousOrg !== undefined &&
279 news.organization !== undefined &&
280 orgIdOf(news.organization) !== orgIdOf(previousOrg);
281 if (
282 keyChanged ||
283 secretChanged ||
284 groupChanged ||
285 appChanged ||
286 orgChanged
287 ) {
288 return {
289 action: "replace" as const,
290 deleteFirst:
291 keyChanged && !groupChanged && !appChanged && !orgChanged,
292 };
293 }
294 return undefined;
295 }),
296
297 read: Effect.fn(function* ({ id, olds, output }) {
298 const env = yield* GcpEnvironment.current;
299 const organization = defaultOrgName(
300 env.project,
301 olds?.organization ?? output?.organization,
302 );
303 const appGroup = olds?.appGroup ?? output?.appGroup;
304 const app = olds?.app ?? output?.app;
305 if (appGroup === undefined || app === undefined) return undefined;
306 const consumerKey = yield* toKeyId(
307 id,
308 olds?.consumerKey,
309 output?.consumerKey,
310 );
311 const name =
312 output?.name ?? resourceName(organization, appGroup, app, consumerKey);
313 const existing = yield* getByName(name);
314 if (existing === undefined) return undefined;
315 const attrs = toAttrs(existing, organization, appGroup, app);
316 return (yield* ownedBy(id, attributesToRecord(existing.attributes)))
317 ? attrs
318 : Unowned(attrs);
319 }),
320
321 list: () =>
322 Effect.gen(function* () {
323 const orgs = yield* listOrgNames();
324 const rows: AppgroupsAppsKey["Attributes"][] = [];
325 for (const organization of orgs) {
326 const groups = yield* listAppGroups(organization);
327 for (const group of groups) {
328 const groupId = group.name ?? "";
329 if (groupId.length === 0) continue;
330 const parent = childName(organization, "appgroups", groupId);
331 const apps = yield* listApps(parent);
332 for (const app of apps) {
333 const appName = app.name ?? "";
334 const fetched =
335 (app.credentials?.length ?? 0) > 0
336 ? app
337 : yield* apigee
338 .getOrganizationsAppgroupsApps({
339 name: `${parent}/apps/${appName}`,
340 })
341 .pipe(
342 Effect.catchTag(
343 ["NotFound", "ApigeeResourceNotFound"],
344 () => Effect.succeed(app),
345 ),
346 );
347 for (const credential of fetched.credentials ?? []) {
348 const labels = attributesToRecord(credential.attributes);
349 if (
350 !Object.keys(labels).some((key) => key.startsWith("alchemy-"))
351 ) {
352 continue;
353 }
354 rows.push(
355 toAttrs(
356 {
357 consumerKey: credential.consumerKey,
358 consumerSecret: credential.consumerSecret,
359 scopes: credential.scopes,
360 status: credential.status,
361 attributes: credential.attributes,
362 issuedAt: credential.issuedAt,
363 expiresAt: credential.expiresAt,
364 apiProducts: (credential.apiProducts ?? []).map(
365 (product) => ({
366 apiproduct: product.apiproduct,
367 status: product.status,
368 }),
369 ),
370 },
371 organization,
372 groupId,
373 appName,
374 ),
375 );
376 }
377 }
378 }
379 }
380 return rows;
381 }),
382
383 reconcile: Effect.fn(function* ({ id, news, output }) {
384 const env = yield* GcpEnvironment.current;
385 const organization = defaultOrgName(env.project, news.organization);
386 const appGroup = appGroupIdOf(news.appGroup);
387 const app = appNameOf(news.app);
388 const consumerKey = yield* toKeyId(
389 id,
390 news.consumerKey,
391 output?.consumerKey,
392 );
393 const consumerSecret = yield* toSecret(
394 id,
395 news.consumerSecret ?? output?.consumerSecret,
396 );
397 const parent = appParentOf(organization, appGroup, app);
398 const name = `${parent}/keys/${consumerKey}`;
399 const ownership = yield* createOwnership(id);
400 const attributes = desiredAttributes(news.attributes, ownership);
401 const status = news.status ?? "approved";
402
403 let current = yield* getByName(output?.name ?? name);
404
405 if (current === undefined) {
406 const created = yield* apigee
407 .createOrganizationsAppgroupsAppsKeys({
408 parent,
409 body: {
410 consumerKey,
411 consumerSecret,
412 expiresInSeconds: news.expiresInSeconds,
413 attributes: recordToAttributes(attributes),
414 },
415 })
416 .pipe(Effect.catchTag("Conflict", () => getByName(name)));
417 current = created ?? undefined;
418 }
419
420 if (current === undefined) {
421 return yield* new AppgroupsAppsKeyNotResolved({ name });
422 }
423
424 const observedAttributes = attributesToRecord(current.attributes);
425 const scopesChanged = !sameStringList(current.scopes, news.scopes);
426 const productsChanged = !sameStringList(
427 (current.apiProducts ?? []).map((product) => product.apiproduct ?? ""),
428 news.apiProducts ?? [],
429 );
430 const statusChanged = (current.status ?? "") !== status;
431 const attributesChanged = !sameRecord(observedAttributes, attributes);
432
433 if (
434 scopesChanged ||
435 productsChanged ||
436 statusChanged ||
437 attributesChanged
438 ) {
439 current =
440 yield* apigee.updateAppGroupAppKeyOrganizationsAppgroupsAppsKeys({
441 name,
442 body: {
443 action: statusChanged ? status : undefined,
444 apiProducts: news.apiProducts,
445 appGroupAppKey: {
446 consumerKey,
447 scopes: news.scopes,
448 attributes: recordToAttributes(attributes),
449 },
450 },
451 });
452 }
453
454 return toAttrs(current, organization, appGroup, app);
455 }),
456
457 delete: Effect.fn(function* ({ output }) {
458 yield* apigee
459 .deleteOrganizationsAppgroupsAppsKeys({ name: output.name })
460 .pipe(
461 Effect.catchTag(
462 ["NotFound", "ApigeeResourceNotFound"],
463 () => Effect.void,
464 ),
465 );
466 }),
467 });