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 expandGroup,
11 findSsoAssignment,
12 getSsoAssignment,
13 lastSegment,
14 listOwnedGroups,
15 listSsoAssignments,
16 normalizeCustomer,
17 replaceOnIdentity,
18 sameJson,
19 sameText,
20 updateMaskOf,
21} from "./internal.ts";
22import {
23 resourceNameFromOperation,
24 waitForOperation,
25 waitUntilPresent,
26} from "./operations.ts";
27
28export type InboundSsoAssignmentSamlSsoInfo = {
29 /** `inboundSamlSsoProfiles/{id}` to use when `ssoMode` is `SAML_SSO`. */
30 inboundSamlSsoProfile?: string;
31};
32
33export type InboundSsoAssignmentOidcSsoInfo = {
34 /** `inboundOidcSsoProfiles/{id}` to use when `ssoMode` is `OIDC_SSO`. */
35 inboundOidcSsoProfile?: string;
36};
37
38export type InboundSsoAssignmentSignInBehavior = {
39 /** When to redirect sign-ins to the IdP. */
40 redirectCondition?:
41 | cloudidentity.SignInBehaviorRedirectConditionEnum
42 | (string & {});
43};
44
45export type InboundSsoAssignmentProps = {
46 /**
47 * Customer (`customers/C0123abc` or `customers/my_customer`).
48 * Immutable — changing it replaces the assignment.
49 * @default "customers/my_customer"
50 */
51 customer?: string;
52 /**
53 * Target group (`groups/{group}`). Immutable — changing it
54 * replaces the assignment. Rank must be ≥ 1 when set.
55 */
56 targetGroup?: string;
57 /**
58 * Target org unit (`orgUnits/{org_unit}`). Immutable — changing
59 * it replaces the assignment. Rank must be 0 (the default).
60 */
61 targetOrgUnit?: string;
62 /**
63 * Inbound SSO behavior.
64 */
65 ssoMode?: cloudidentity.InboundSsoAssignmentSsoModeEnum | (string & {});
66 /**
67 * Priority among group-targeted assignments. Zero for org units;
68 * ≥ 1 for groups.
69 */
70 rank?: number;
71 /**
72 * SAML profile, required when `ssoMode` is `SAML_SSO`.
73 */
74 samlSsoInfo?: InboundSsoAssignmentSamlSsoInfo;
75 /**
76 * OIDC profile, required when `ssoMode` is `OIDC_SSO`.
77 */
78 oidcSsoInfo?: InboundSsoAssignmentOidcSsoInfo;
79 /**
80 * Sign-in redirect behavior.
81 */
82 signInBehavior?: InboundSsoAssignmentSignInBehavior;
83};
84
85export type InboundSsoAssignment = Resource<
86 "GCP.CloudIdentity.InboundSsoAssignment",
87 InboundSsoAssignmentProps,
88 {
89 /** Resource name `inboundSsoAssignments/{assignment}`. */
90 name: string;
91 /** Assignment id (last path segment). */
92 assignmentId: string;
93 /** Customer. */
94 customer: string | undefined;
95 /** Target group, if any. */
96 targetGroup: string | undefined;
97 /** Target org unit, if any. */
98 targetOrgUnit: string | undefined;
99 /** SSO mode. */
100 ssoMode: string | undefined;
101 /** Rank. */
102 rank: number | undefined;
103 /** SAML profile, if any. */
104 samlSsoInfo: InboundSsoAssignmentSamlSsoInfo | undefined;
105 /** OIDC profile, if any. */
106 oidcSsoInfo: InboundSsoAssignmentOidcSsoInfo | undefined;
107 /** Sign-in behavior. */
108 signInBehavior: InboundSsoAssignmentSignInBehavior | undefined;
109 },
110 never,
111 Providers
112>;
113
114/**
115 * An inbound SSO assignment for a Cloud Identity group or org unit.
116 *
117 * Assignments have no labels or description, so Alchemy lists
118 * assignments whose `targetGroup` is an alchemy-owned group for
119 * `list` / nuke. Customer, target group, and target org unit are
120 * identity; mode, rank, and IdP details update in place.
121 *
122 * ### Creating an Assignment
123 * **Example:** SAML SSO for a group
124 * ```typescript
125 * const assignment = yield* GCP.CloudIdentity.InboundSsoAssignment(
126 * "EngSso",
127 * {
128 * targetGroup: group.name,
129 * ssoMode: "SAML_SSO",
130 * rank: 1,
131 * samlSsoInfo: { inboundSamlSsoProfile: profile.name },
132 * },
133 * );
134 * ```
135 *
136 * @resource
137 * @category CloudIdentity
138 */
139export const InboundSsoAssignment = Resource<InboundSsoAssignment>(
140 "GCP.CloudIdentity.InboundSsoAssignment",
141);
142
143export class InboundSsoAssignmentNotResolved extends Data.TaggedError(
144 "GCP.CloudIdentity.InboundSsoAssignmentNotResolved",
145)<{
146 name: string;
147}> {}
148
149const toAttrs = (assignment: cloudidentity.InboundSsoAssignment) => {
150 const name = assignment.name ?? "";
151 return {
152 name,
153 assignmentId: lastSegment(name),
154 customer: assignment.customer,
155 targetGroup: assignment.targetGroup,
156 targetOrgUnit: assignment.targetOrgUnit,
157 ssoMode: assignment.ssoMode,
158 rank: assignment.rank,
159 samlSsoInfo: assignment.samlSsoInfo
160 ? { inboundSamlSsoProfile: assignment.samlSsoInfo.inboundSamlSsoProfile }
161 : undefined,
162 oidcSsoInfo: assignment.oidcSsoInfo
163 ? { inboundOidcSsoProfile: assignment.oidcSsoInfo.inboundOidcSsoProfile }
164 : undefined,
165 signInBehavior: assignment.signInBehavior
166 ? { redirectCondition: assignment.signInBehavior.redirectCondition }
167 : undefined,
168 };
169};
170
171const expandTargetGroup = (value: string | undefined) =>
172 value !== undefined && value.length > 0 ? expandGroup(value) : undefined;
173
174const observeAssignment = (input: {
175 name?: string;
176 targetGroup?: string;
177 targetOrgUnit?: string;
178 ssoMode?: string;
179}) =>
180 findSsoAssignment({
181 name: input.name,
182 targetGroup: expandTargetGroup(input.targetGroup),
183 targetOrgUnit: input.targetOrgUnit,
184 ssoMode: input.ssoMode,
185 });
186
187export const InboundSsoAssignmentProvider = () =>
188 Provider.succeed(InboundSsoAssignment, {
189 stables: [
190 "name",
191 "assignmentId",
192 "customer",
193 "targetGroup",
194 "targetOrgUnit",
195 ],
196
197 diff: Effect.fn(function* ({ news, olds, output }) {
198 if (!isResolved(news)) return undefined;
199 const previousCustomer = olds?.customer ?? output?.customer;
200 const nextCustomer =
201 news.customer !== undefined
202 ? normalizeCustomer(news.customer)
203 : previousCustomer;
204 const previousGroup = olds?.targetGroup ?? output?.targetGroup;
205 const nextGroup = expandTargetGroup(news.targetGroup);
206 const previousOu = olds?.targetOrgUnit ?? output?.targetOrgUnit;
207 return replaceOnIdentity({
208 previousId: previousGroup,
209 nextId: nextGroup,
210 previousParent: previousCustomer,
211 nextParent: nextCustomer,
212 extra:
213 news.targetOrgUnit !== undefined &&
214 previousOu !== undefined &&
215 news.targetOrgUnit !== previousOu,
216 });
217 }),
218
219 read: Effect.fn(function* ({ olds, output }) {
220 const existing = yield* observeAssignment({
221 name: output?.name,
222 targetGroup: olds?.targetGroup ?? output?.targetGroup,
223 targetOrgUnit: olds?.targetOrgUnit ?? output?.targetOrgUnit,
224 ssoMode: olds?.ssoMode ?? output?.ssoMode,
225 });
226 if (existing === undefined) return undefined;
227 const attrs = toAttrs(existing);
228 return output !== undefined ? attrs : Unowned(attrs);
229 }),
230
231 list: () =>
232 Effect.gen(function* () {
233 const groups = yield* listOwnedGroups();
234 const names = new Set(
235 groups
236 .map((group) => group.name)
237 .filter((name): name is string => name !== undefined),
238 );
239 const assignments = yield* listSsoAssignments();
240 return assignments
241 .filter(
242 (assignment) =>
243 assignment.targetGroup !== undefined &&
244 names.has(assignment.targetGroup),
245 )
246 .map(toAttrs);
247 }),
248
249 reconcile: Effect.fn(function* ({ news, output }) {
250 const customer = normalizeCustomer(news.customer ?? output?.customer);
251 const targetGroup = expandTargetGroup(news.targetGroup);
252 const desired: cloudidentity.InboundSsoAssignment = {
253 customer,
254 targetGroup,
255 targetOrgUnit: news.targetOrgUnit,
256 ssoMode: news.ssoMode,
257 rank: news.rank,
258 samlSsoInfo: news.samlSsoInfo,
259 oidcSsoInfo: news.oidcSsoInfo,
260 signInBehavior: news.signInBehavior,
261 };
262
263 let current = yield* observeAssignment({
264 name: output?.name,
265 targetGroup,
266 targetOrgUnit: news.targetOrgUnit ?? output?.targetOrgUnit,
267 ssoMode: news.ssoMode ?? output?.ssoMode,
268 });
269
270 if (current === undefined) {
271 const created = yield* cloudidentity
272 .createInboundSsoAssignments({ body: desired })
273 .pipe(
274 Effect.catchTag("Conflict", () =>
275 Effect.succeed<cloudidentity.Operation | undefined>(undefined),
276 ),
277 );
278 if (created !== undefined) {
279 yield* waitForOperation(created).pipe(
280 Effect.catchTag(
281 "GCP.CloudIdentity.OperationPending",
282 () => Effect.void,
283 ),
284 );
285 const createdName = resourceNameFromOperation(created);
286 if (createdName !== undefined) {
287 current = yield* getSsoAssignment(createdName);
288 }
289 }
290 if (current === undefined) {
291 current = yield* waitUntilPresent(
292 observeAssignment({
293 name: output?.name,
294 targetGroup,
295 targetOrgUnit: news.targetOrgUnit,
296 ssoMode: news.ssoMode,
297 }),
298 targetGroup ?? news.targetOrgUnit ?? "",
299 ).pipe(
300 Effect.catchTag("GCP.CloudIdentity.OperationPending", () =>
301 observeAssignment({
302 targetGroup,
303 targetOrgUnit: news.targetOrgUnit,
304 ssoMode: news.ssoMode,
305 }),
306 ),
307 );
308 }
309 }
310
311 if (current === undefined) {
312 return yield* new InboundSsoAssignmentNotResolved({
313 name: output?.name ?? targetGroup ?? news.targetOrgUnit ?? "",
314 });
315 }
316
317 const name = current.name ?? output?.name ?? "";
318 const modeChanged =
319 news.ssoMode !== undefined && !sameText(current.ssoMode, news.ssoMode);
320 const rankChanged =
321 news.rank !== undefined && (current.rank ?? 0) !== news.rank;
322 const samlChanged =
323 news.samlSsoInfo !== undefined &&
324 !sameJson(current.samlSsoInfo, news.samlSsoInfo);
325 const oidcChanged =
326 news.oidcSsoInfo !== undefined &&
327 !sameJson(current.oidcSsoInfo, news.oidcSsoInfo);
328 const signInChanged =
329 news.signInBehavior !== undefined &&
330 !sameJson(current.signInBehavior, news.signInBehavior);
331 const updateMask = updateMaskOf(
332 modeChanged ? "sso_mode" : undefined,
333 rankChanged ? "rank" : undefined,
334 samlChanged ? "saml_sso_info" : undefined,
335 oidcChanged ? "oidc_sso_info" : undefined,
336 signInChanged ? "sign_in_behavior" : undefined,
337 );
338
339 if (updateMask.length > 0 && name.length > 0) {
340 const patched = yield* cloudidentity.patchInboundSsoAssignments({
341 name,
342 updateMask,
343 body: {
344 ssoMode: news.ssoMode,
345 rank: news.rank,
346 samlSsoInfo: news.samlSsoInfo,
347 oidcSsoInfo: news.oidcSsoInfo,
348 signInBehavior: news.signInBehavior,
349 },
350 });
351 yield* waitForOperation(patched).pipe(
352 Effect.catchTag(
353 "GCP.CloudIdentity.OperationPending",
354 () => Effect.void,
355 ),
356 );
357 current = (yield* getSsoAssignment(name)) ?? current;
358 }
359
360 return toAttrs(current);
361 }),
362
363 delete: Effect.fn(function* ({ output }) {
364 if (output.name.length === 0) return;
365 const deleted = yield* cloudidentity
366 .deleteInboundSsoAssignments({ name: output.name })
367 .pipe(
368 Effect.catchTag("NotFound", () =>
369 Effect.succeed<cloudidentity.Operation | undefined>(undefined),
370 ),
371 );
372 if (deleted !== undefined) {
373 yield* waitForOperation(deleted, { notFoundOk: true });
374 }
375 }),
376 });