1import * as cloudidentity from "@distilled.cloud/gcp/cloudidentity_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 type { Providers } from "../Providers.ts";
9import {
10 encodeOwnershipLine,
11 findOwnedOidcProfile,
12 getOidcProfile,
13 hasOwnershipMarker,
14 lastSegment,
15 listOidcProfiles,
16 MAX_DISPLAY_NAME_LENGTH,
17 normalizeCustomer,
18 ownedByAlchemy,
19 ownershipLabels,
20 parseOwnership,
21 replaceOnIdentity,
22 sameJson,
23 sameText,
24 toPhysicalId,
25 updateMaskOf,
26} from "./internal.ts";
27import {
28 resourceNameFromOperation,
29 waitForOperation,
30 waitUntilPresent,
31} from "./operations.ts";
32
33export type InboundOidcSsoProfileIdpConfig = {
34 /** IdP issuer URL. Discovery is derived from this URI. */
35 issuerUri?: string;
36 /** HTTPS change-password URL. */
37 changePasswordUri?: string;
38};
39
40export type InboundOidcSsoProfileRpConfig = {
41 /** OAuth2 client id. */
42 clientId?: string;
43 /** OAuth2 client secret (input only). */
44 clientSecret?: string;
45};
46
47export type InboundOidcSsoProfileProps = {
48 /**
49 * Customer (`customers/C0123abc` or `customers/my_customer`).
50 * Immutable — changing it replaces the profile.
51 * @default "customers/my_customer"
52 */
53 customer?: string;
54 /**
55 * Human-readable name. Profiles have no labels field, so Alchemy
56 * stamps ownership into `displayName` for `list` / nuke.
57 */
58 displayName?: string;
59 /**
60 * OIDC identity provider configuration.
61 */
62 idpConfig?: InboundOidcSsoProfileIdpConfig;
63 /**
64 * OIDC relying-party configuration. `clientSecret` is input-only.
65 */
66 rpConfig?: InboundOidcSsoProfileRpConfig;
67};
68
69export type InboundOidcSsoProfile = Resource<
70 "GCP.CloudIdentity.InboundOidcSsoProfile",
71 InboundOidcSsoProfileProps,
72 {
73 /** Resource name `inboundOidcSsoProfiles/{id}`. */
74 name: string;
75 /** Profile id (last path segment). */
76 profileId: string;
77 /** Customer. */
78 customer: string | undefined;
79 /** Display name with the Alchemy ownership prefix stripped. */
80 displayName: string | undefined;
81 /** IdP configuration. */
82 idpConfig: InboundOidcSsoProfileIdpConfig | undefined;
83 /** RP configuration (secret omitted). */
84 rpConfig:
85 | {
86 clientId?: string;
87 redirectUris?: string[];
88 }
89 | undefined;
90 },
91 never,
92 Providers
93>;
94
95/**
96 * An inbound OIDC SSO profile for a Google enterprise customer.
97 *
98 * Profiles have no labels field, so Alchemy stamps ownership into
99 * `displayName` for `list` / nuke. `customer` is identity; display
100 * name and IdP / RP config update in place.
101 *
102 * ### Creating a Profile
103 * **Example:** Basic OIDC IdP
104 * ```typescript
105 * const profile = yield* GCP.CloudIdentity.InboundOidcSsoProfile("Okta", {
106 * displayName: "Okta",
107 * idpConfig: { issuerUri: "https://idp.example.com" },
108 * rpConfig: { clientId: "google-rp", clientSecret: "secret" },
109 * });
110 * ```
111 *
112 * @resource
113 * @category CloudIdentity
114 */
115export const InboundOidcSsoProfile = Resource<InboundOidcSsoProfile>(
116 "GCP.CloudIdentity.InboundOidcSsoProfile",
117);
118
119export class InboundOidcSsoProfileNotResolved extends Data.TaggedError(
120 "GCP.CloudIdentity.InboundOidcSsoProfileNotResolved",
121)<{
122 name: string;
123}> {}
124
125const rpOf = (rp: cloudidentity.OidcRpConfig | undefined) => {
126 if (rp === undefined) return undefined;
127 return {
128 clientId: rp.clientId,
129 redirectUris: rp.redirectUris,
130 };
131};
132
133const toAttrs = (profile: cloudidentity.InboundOidcSsoProfile) => {
134 const name = profile.name ?? "";
135 return {
136 name,
137 profileId: lastSegment(name),
138 customer: profile.customer,
139 displayName: parseOwnership(profile.displayName).text,
140 idpConfig: profile.idpConfig
141 ? {
142 issuerUri: profile.idpConfig.issuerUri,
143 changePasswordUri: profile.idpConfig.changePasswordUri,
144 }
145 : undefined,
146 rpConfig: rpOf(profile.rpConfig),
147 };
148};
149
150const observeProfile = (input: { id: string; name?: string }) =>
151 Effect.gen(function* () {
152 const byName = yield* getOidcProfile(input.name ?? "");
153 if (byName !== undefined) return byName;
154 return yield* findOwnedOidcProfile(input.id);
155 });
156
157export const InboundOidcSsoProfileProvider = () =>
158 Provider.succeed(InboundOidcSsoProfile, {
159 stables: ["name", "profileId", "customer"],
160
161 diff: Effect.fn(function* ({ news, olds, output }) {
162 if (!isResolved(news)) return undefined;
163 const previous = olds?.customer ?? output?.customer;
164 const next =
165 news.customer !== undefined
166 ? normalizeCustomer(news.customer)
167 : previous;
168 return replaceOnIdentity({
169 previousParent: previous,
170 nextParent: next,
171 });
172 }),
173
174 read: Effect.fn(function* ({ id, output }) {
175 const existing = yield* observeProfile({ id, name: output?.name });
176 if (existing === undefined) return undefined;
177 const attrs = toAttrs(existing);
178 return (yield* ownedByAlchemy(id, existing.displayName))
179 ? attrs
180 : Unowned(attrs);
181 }),
182
183 list: () =>
184 Effect.gen(function* () {
185 const profiles = yield* listOidcProfiles();
186 return profiles
187 .filter((profile) => hasOwnershipMarker(profile.displayName))
188 .map(toAttrs);
189 }),
190
191 reconcile: Effect.fn(function* ({ id, news, output }) {
192 const ownership = yield* ownershipLabels(id);
193 const customer = normalizeCustomer(news.customer ?? output?.customer);
194 const displayName = encodeOwnershipLine(
195 ownership,
196 yield* toPhysicalId(
197 id,
198 news.displayName,
199 output?.displayName,
200 MAX_DISPLAY_NAME_LENGTH,
201 ),
202 MAX_DISPLAY_NAME_LENGTH,
203 );
204 const desired: cloudidentity.InboundOidcSsoProfile = {
205 customer,
206 displayName,
207 idpConfig: news.idpConfig,
208 rpConfig: news.rpConfig,
209 };
210
211 let current = yield* observeProfile({ id, name: output?.name });
212
213 if (current === undefined) {
214 const created = yield* cloudidentity
215 .createInboundOidcSsoProfiles({ body: desired })
216 .pipe(
217 Effect.catchTag("Conflict", () =>
218 Effect.succeed<cloudidentity.Operation | undefined>(undefined),
219 ),
220 );
221 if (created !== undefined) {
222 yield* waitForOperation(created).pipe(
223 Effect.catchTag(
224 "GCP.CloudIdentity.OperationPending",
225 () => Effect.void,
226 ),
227 );
228 const createdName = resourceNameFromOperation(created);
229 if (createdName !== undefined) {
230 current = yield* getOidcProfile(createdName);
231 }
232 }
233 if (current === undefined) {
234 current = yield* waitUntilPresent(
235 observeProfile({ id, name: output?.name }),
236 displayName,
237 ).pipe(
238 Effect.catchTag("GCP.CloudIdentity.OperationPending", () =>
239 observeProfile({ id }),
240 ),
241 );
242 }
243 }
244
245 if (current === undefined) {
246 return yield* new InboundOidcSsoProfileNotResolved({
247 name: output?.name ?? displayName,
248 });
249 }
250
251 const name = current.name ?? output?.name ?? "";
252 const displayChanged = !sameText(current.displayName, displayName);
253 const idpChanged = !sameJson(current.idpConfig, news.idpConfig ?? {});
254 const rpChanged =
255 news.rpConfig !== undefined &&
256 (!sameText(current.rpConfig?.clientId, news.rpConfig.clientId) ||
257 news.rpConfig.clientSecret !== undefined);
258 const updateMask = updateMaskOf(
259 displayChanged ? "display_name" : undefined,
260 idpChanged ? "idp_config" : undefined,
261 rpChanged ? "rp_config" : undefined,
262 );
263
264 if (updateMask.length > 0 && name.length > 0) {
265 const patched = yield* cloudidentity.patchInboundOidcSsoProfiles({
266 name,
267 updateMask,
268 body: {
269 displayName,
270 idpConfig: news.idpConfig,
271 rpConfig: news.rpConfig,
272 },
273 });
274 yield* waitForOperation(patched).pipe(
275 Effect.catchTag(
276 "GCP.CloudIdentity.OperationPending",
277 () => Effect.void,
278 ),
279 );
280 current = (yield* getOidcProfile(name)) ?? current;
281 }
282
283 return toAttrs(current);
284 }),
285
286 delete: Effect.fn(function* ({ output }) {
287 if (output.name.length === 0) return;
288 const deleted = yield* cloudidentity
289 .deleteInboundOidcSsoProfiles({ name: output.name })
290 .pipe(
291 Effect.catchTag("NotFound", () =>
292 Effect.succeed<cloudidentity.Operation | undefined>(undefined),
293 ),
294 );
295 if (deleted !== undefined) {
296 yield* waitForOperation(deleted, { notFoundOk: true });
297 }
298 }),
299 });