1import * as iap from "@distilled.cloud/gcp/iap_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 type { Providers } from "../Providers.ts";
10import {
11 brandNameOf,
12 encodeOwnershipLine,
13 findOwnedClient,
14 getClient,
15 listOwnedClients,
16 MAX_DISPLAY_NAME_LENGTH,
17 ownedByAlchemy,
18 ownershipLabels,
19 parseClientName,
20 parseOwnership,
21 replaceOnIdentity,
22 sameText,
23 toGeneratedDisplayName,
24 waitUntilGone,
25} from "./internal.ts";
26
27export type BrandsIdentityAwareProxyClientProps = {
28 /**
29 * Parent IAP OAuth brand. Full name
30 * `projects/{project}/brands/{brand}` or the brand id. Immutable —
31 * changing it replaces the client. The project must belong to a
32 * Google Workspace organization, and the brand must already exist.
33 */
34 brand: string;
35 /**
36 * Human-friendly OAuth client name. IAP clients have no labels field,
37 * so Alchemy stamps ownership into a `[alchemy …]` prefix and strips
38 * it from attributes. Immutable — the API has no update, so changing
39 * this replaces the client.
40 */
41 displayName?: string;
42 /**
43 * Server-assigned OAuth client id (the `{client_id}` segment of
44 * `projects/{project}/brands/{brand}/identityAwareProxyClients/{client_id}`).
45 * Leave blank on create. Immutable — changing it replaces the client.
46 */
47 identityAwareProxyClientId?: string;
48};
49
50export type BrandsIdentityAwareProxyClient = Resource<
51 "GCP.IAP.BrandsIdentityAwareProxyClient",
52 BrandsIdentityAwareProxyClientProps,
53 {
54 /** Full resource name `projects/{project}/brands/{brand}/identityAwareProxyClients/{client_id}`. */
55 name: string;
56 /** Server-assigned OAuth client id. */
57 identityAwareProxyClientId: string;
58 /** Parent brand resource name. */
59 brand: string;
60 /** Project id. */
61 project: string;
62 /** User display name with the Alchemy ownership prefix stripped. */
63 displayName: string | undefined;
64 /**
65 * OAuth client secret. Returned on create; later reads reuse the
66 * previously persisted value when the API omits it.
67 */
68 secret: string | undefined;
69 },
70 never,
71 Providers
72>;
73
74/**
75 * An Identity-Aware Proxy owned OAuth client.
76 *
77 * IAP OAuth clients have no labels field, so Alchemy stamps ownership
78 * into `displayName` for `list` / nuke. Brand, client id, and display
79 * name are identity — the API has no patch, so changing any of them
80 * replaces the client. Creating a client requires an existing
81 * internal-only OAuth brand on a Workspace project.
82 *
83 * ### Creating an IAP OAuth Client
84 * **Example:** Named client under an existing brand
85 * ```typescript
86 * const client = yield* GCP.IAP.BrandsIdentityAwareProxyClient("Console", {
87 * brand: "projects/my-project/brands/123456",
88 * displayName: "Alchemy console",
89 * });
90 * ```
91 *
92 * **Example:** Generated display name
93 * ```typescript
94 * const client = yield* GCP.IAP.BrandsIdentityAwareProxyClient("Console", {
95 * brand: existingBrandName,
96 * });
97 * ```
98 *
99 * @resource
100 * @category IAP
101 */
102export const BrandsIdentityAwareProxyClient =
103 Resource<BrandsIdentityAwareProxyClient>(
104 "GCP.IAP.BrandsIdentityAwareProxyClient",
105 );
106
107export class BrandsIdentityAwareProxyClientNotResolved extends Data.TaggedError(
108 "GCP.IAP.BrandsIdentityAwareProxyClientNotResolved",
109)<{
110 name: string;
111}> {}
112
113const toAttrs = (
114 client: iap.IdentityAwareProxyClient,
115 project: string,
116 previousSecret?: string,
117) => {
118 const name = client.name ?? "";
119 const parsed = parseClientName(name, project);
120 return {
121 name,
122 identityAwareProxyClientId: parsed.identityAwareProxyClientId,
123 brand: parsed.brand,
124 project,
125 displayName: parseOwnership(client.displayName).text,
126 secret: client.secret ?? previousSecret,
127 };
128};
129
130export const BrandsIdentityAwareProxyClientProvider = () =>
131 Provider.succeed(BrandsIdentityAwareProxyClient, {
132 stables: [
133 "name",
134 "identityAwareProxyClientId",
135 "brand",
136 "project",
137 "secret",
138 ],
139
140 diff: Effect.fn(function* ({ news, olds, output }) {
141 if (!isResolved(news)) return undefined;
142 return replaceOnIdentity({
143 previousId:
144 olds?.identityAwareProxyClientId ??
145 output?.identityAwareProxyClientId,
146 nextId: news.identityAwareProxyClientId,
147 previousParent: olds?.brand ?? output?.brand,
148 nextParent: news.brand,
149 extra:
150 news.displayName !== undefined &&
151 output?.displayName !== undefined &&
152 !sameText(news.displayName, output.displayName),
153 });
154 }),
155
156 read: Effect.fn(function* ({ id, olds, output }) {
157 const env = yield* GcpEnvironment.current;
158 const brand = brandNameOf(
159 env.project,
160 olds?.brand ?? output?.brand ?? "",
161 );
162 let existing = yield* getClient(output?.name ?? "");
163 if (existing === undefined) {
164 existing = yield* findOwnedClient(
165 env.project,
166 id,
167 brand,
168 olds?.displayName ?? output?.displayName,
169 );
170 }
171 if (existing === undefined) return undefined;
172 const attrs = toAttrs(existing, env.project, output?.secret);
173 return (yield* ownedByAlchemy(id, existing.displayName))
174 ? attrs
175 : Unowned(attrs);
176 }),
177
178 list: () =>
179 Effect.gen(function* () {
180 const env = yield* GcpEnvironment.current;
181 const clients = yield* listOwnedClients(env.project);
182 return clients.map((client) => toAttrs(client, env.project));
183 }),
184
185 reconcile: Effect.fn(function* ({ id, news, output }) {
186 const env = yield* GcpEnvironment.current;
187 const brand = brandNameOf(env.project, news.brand);
188 const ownership = yield* ownershipLabels(id);
189 const userDisplayName = yield* toGeneratedDisplayName(
190 id,
191 news.displayName,
192 output?.displayName,
193 );
194 const displayName = encodeOwnershipLine(
195 ownership,
196 userDisplayName,
197 MAX_DISPLAY_NAME_LENGTH,
198 );
199 const name =
200 output?.name ??
201 (news.identityAwareProxyClientId
202 ? `${brand}/identityAwareProxyClients/${news.identityAwareProxyClientId}`
203 : "");
204
205 let current = yield* getClient(name);
206 if (current === undefined) {
207 current = yield* findOwnedClient(
208 env.project,
209 id,
210 brand,
211 userDisplayName,
212 );
213 }
214
215 if (current === undefined) {
216 const created = yield* iap
217 .createProjectsBrandsIdentityAwareProxyClients({
218 parent: brand,
219 body: { displayName },
220 })
221 .pipe(
222 Effect.catchTag("Conflict", () =>
223 findOwnedClient(env.project, id, brand, userDisplayName),
224 ),
225 );
226 current = created ?? undefined;
227 }
228
229 if (current === undefined) {
230 return yield* new BrandsIdentityAwareProxyClientNotResolved({
231 name: name || `${brand}/identityAwareProxyClients`,
232 });
233 }
234
235 return toAttrs(current, env.project, output?.secret);
236 }),
237
238 delete: Effect.fn(function* ({ output }) {
239 if (!output.name) return;
240 yield* iap
241 .deleteProjectsBrandsIdentityAwareProxyClients({
242 name: output.name,
243 })
244 .pipe(Effect.catchTag("NotFound", () => Effect.void));
245 yield* waitUntilGone(getClient(output.name));
246 }),
247 });