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 findOwnedSamlProfile,
12 getSamlProfile,
13 hasOwnershipMarker,
14 lastSegment,
15 listSamlProfiles,
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 InboundSamlSsoProfileIdpConfig = {
34 /** HTTPS SingleSignOnService URL (HTTP-Redirect binding). */
35 singleSignOnServiceUri?: string;
36 /** SAML Entity ID of the identity provider. */
37 entityId?: string;
38 /** HTTPS logout redirect URL. */
39 logoutRedirectUri?: string;
40 /** HTTPS change-password URL. */
41 changePasswordUri?: string;
42};
43
44export type InboundSamlSsoProfileProps = {
45 /**
46 * Customer (`customers/C0123abc` or `customers/my_customer`).
47 * Immutable — changing it replaces the profile.
48 * @default "customers/my_customer"
49 */
50 customer?: string;
51 /**
52 * Human-readable name. Profiles have no labels field, so Alchemy
53 * stamps ownership into `displayName` for `list` / nuke.
54 */
55 displayName?: string;
56 /**
57 * SAML identity provider configuration.
58 */
59 idpConfig?: InboundSamlSsoProfileIdpConfig;
60};
61
62export type InboundSamlSsoProfile = Resource<
63 "GCP.CloudIdentity.InboundSamlSsoProfile",
64 InboundSamlSsoProfileProps,
65 {
66 /** Resource name `inboundSamlSsoProfiles/{id}`. */
67 name: string;
68 /** Profile id (last path segment). */
69 profileId: string;
70 /** Customer. */
71 customer: string | undefined;
72 /** Display name with the Alchemy ownership prefix stripped. */
73 displayName: string | undefined;
74 /** IdP configuration. */
75 idpConfig: InboundSamlSsoProfileIdpConfig | undefined;
76 /** Google-provided SP ACS URL. */
77 assertionConsumerServiceUri: string | undefined;
78 /** Google-provided SP Entity ID. */
79 spEntityId: string | undefined;
80 },
81 never,
82 Providers
83>;
84
85/**
86 * An inbound SAML 2.0 SSO profile for a Google enterprise customer.
87 *
88 * Profiles have no labels field, so Alchemy stamps ownership into
89 * `displayName` for `list` / nuke. `customer` is identity; display
90 * name and IdP config update in place.
91 *
92 * ### Creating a Profile
93 * **Example:** Basic SAML IdP
94 * ```typescript
95 * const profile = yield* GCP.CloudIdentity.InboundSamlSsoProfile("Okta", {
96 * displayName: "Okta",
97 * idpConfig: {
98 * entityId: "https://idp.example.com/metadata",
99 * singleSignOnServiceUri: "https://idp.example.com/sso",
100 * },
101 * });
102 * ```
103 *
104 * @resource
105 * @category CloudIdentity
106 */
107export const InboundSamlSsoProfile = Resource<InboundSamlSsoProfile>(
108 "GCP.CloudIdentity.InboundSamlSsoProfile",
109);
110
111export class InboundSamlSsoProfileNotResolved extends Data.TaggedError(
112 "GCP.CloudIdentity.InboundSamlSsoProfileNotResolved",
113)<{
114 name: string;
115}> {}
116
117const toAttrs = (profile: cloudidentity.InboundSamlSsoProfile) => {
118 const name = profile.name ?? "";
119 return {
120 name,
121 profileId: lastSegment(name),
122 customer: profile.customer,
123 displayName: parseOwnership(profile.displayName).text,
124 idpConfig: profile.idpConfig
125 ? {
126 singleSignOnServiceUri: profile.idpConfig.singleSignOnServiceUri,
127 entityId: profile.idpConfig.entityId,
128 logoutRedirectUri: profile.idpConfig.logoutRedirectUri,
129 changePasswordUri: profile.idpConfig.changePasswordUri,
130 }
131 : undefined,
132 assertionConsumerServiceUri: profile.spConfig?.assertionConsumerServiceUri,
133 spEntityId: profile.spConfig?.entityId,
134 };
135};
136
137const observeProfile = (input: { id: string; name?: string }) =>
138 Effect.gen(function* () {
139 const byName = yield* getSamlProfile(input.name ?? "");
140 if (byName !== undefined) return byName;
141 return yield* findOwnedSamlProfile(input.id);
142 });
143
144export const InboundSamlSsoProfileProvider = () =>
145 Provider.succeed(InboundSamlSsoProfile, {
146 stables: ["name", "profileId", "customer"],
147
148 diff: Effect.fn(function* ({ news, olds, output }) {
149 if (!isResolved(news)) return undefined;
150 const previous = olds?.customer ?? output?.customer;
151 const next =
152 news.customer !== undefined
153 ? normalizeCustomer(news.customer)
154 : previous;
155 return replaceOnIdentity({
156 previousParent: previous,
157 nextParent: next,
158 });
159 }),
160
161 read: Effect.fn(function* ({ id, output }) {
162 const existing = yield* observeProfile({ id, name: output?.name });
163 if (existing === undefined) return undefined;
164 const attrs = toAttrs(existing);
165 return (yield* ownedByAlchemy(id, existing.displayName))
166 ? attrs
167 : Unowned(attrs);
168 }),
169
170 list: () =>
171 Effect.gen(function* () {
172 const profiles = yield* listSamlProfiles();
173 return profiles
174 .filter((profile) => hasOwnershipMarker(profile.displayName))
175 .map(toAttrs);
176 }),
177
178 reconcile: Effect.fn(function* ({ id, news, output }) {
179 const ownership = yield* ownershipLabels(id);
180 const customer = normalizeCustomer(news.customer ?? output?.customer);
181 const displayName = encodeOwnershipLine(
182 ownership,
183 yield* toPhysicalId(
184 id,
185 news.displayName,
186 output?.displayName,
187 MAX_DISPLAY_NAME_LENGTH,
188 ),
189 MAX_DISPLAY_NAME_LENGTH,
190 );
191 const desired: cloudidentity.InboundSamlSsoProfile = {
192 customer,
193 displayName,
194 idpConfig: news.idpConfig,
195 };
196
197 let current = yield* observeProfile({ id, name: output?.name });
198
199 if (current === undefined) {
200 const created = yield* cloudidentity
201 .createInboundSamlSsoProfiles({ body: desired })
202 .pipe(
203 Effect.catchTag("Conflict", () =>
204 Effect.succeed<cloudidentity.Operation | undefined>(undefined),
205 ),
206 );
207 if (created !== undefined) {
208 yield* waitForOperation(created).pipe(
209 Effect.catchTag(
210 "GCP.CloudIdentity.OperationPending",
211 () => Effect.void,
212 ),
213 );
214 const createdName = resourceNameFromOperation(created);
215 if (createdName !== undefined) {
216 current = yield* getSamlProfile(createdName);
217 }
218 }
219 if (current === undefined) {
220 current = yield* waitUntilPresent(
221 observeProfile({ id, name: output?.name }),
222 displayName,
223 ).pipe(
224 Effect.catchTag("GCP.CloudIdentity.OperationPending", () =>
225 observeProfile({ id }),
226 ),
227 );
228 }
229 }
230
231 if (current === undefined) {
232 return yield* new InboundSamlSsoProfileNotResolved({
233 name: output?.name ?? displayName,
234 });
235 }
236
237 const name = current.name ?? output?.name ?? "";
238 const displayChanged = !sameText(current.displayName, displayName);
239 const idpChanged = !sameJson(current.idpConfig, news.idpConfig ?? {});
240 const updateMask = updateMaskOf(
241 displayChanged ? "display_name" : undefined,
242 idpChanged ? "idp_config" : undefined,
243 );
244
245 if (updateMask.length > 0 && name.length > 0) {
246 const patched = yield* cloudidentity.patchInboundSamlSsoProfiles({
247 name,
248 updateMask,
249 body: {
250 displayName,
251 idpConfig: news.idpConfig,
252 },
253 });
254 yield* waitForOperation(patched).pipe(
255 Effect.catchTag(
256 "GCP.CloudIdentity.OperationPending",
257 () => Effect.void,
258 ),
259 );
260 current = (yield* getSamlProfile(name)) ?? current;
261 }
262
263 return toAttrs(current);
264 }),
265
266 delete: Effect.fn(function* ({ output }) {
267 if (output.name.length === 0) return;
268 const deleted = yield* cloudidentity
269 .deleteInboundSamlSsoProfiles({ name: output.name })
270 .pipe(
271 Effect.catchTag("NotFound", () =>
272 Effect.succeed<cloudidentity.Operation | undefined>(undefined),
273 ),
274 );
275 if (deleted !== undefined) {
276 yield* waitForOperation(deleted, { notFoundOk: true });
277 }
278 }),
279 });