1import * as cloudidentity from "@distilled.cloud/gcp/cloudidentity_v1";
2import * as Effect from "effect/Effect";
3import * as Stream from "effect/Stream";
4import { createPhysicalName } from "../../PhysicalName.ts";
5import {
6 alchemyLabelKeys,
7 createInternalLabels,
8 hasAlchemyLabels,
9} from "../Labels.ts";
10
11export const DEFAULT_CUSTOMER = "customers/my_customer";
12export const DISCUSSION_FORUM_LABEL =
13 "cloudidentity.googleapis.com/groups.discussion_forum";
14export const MAX_DISPLAY_NAME_LENGTH = 128;
15export const MAX_DESCRIPTION_LENGTH = 4096;
16export const MAX_ASSET_TAG_LENGTH = 100;
17export const MAX_GROUP_KEY_LOCAL_LENGTH = 32;
18
19export const lastSegment = (value: string) => {
20 const trimmed = value.replace(/\/+$/, "");
21 const parts = trimmed.split("/");
22 return parts[parts.length - 1] || trimmed;
23};
24
25export const sameText = (left: string | undefined, right: string | undefined) =>
26 (left ?? "") === (right ?? "");
27
28export const sameJson = (left: unknown, right: unknown) =>
29 JSON.stringify(left ?? null) === JSON.stringify(right ?? null);
30
31export const updateMaskOf = (...fields: Array<string | undefined>) =>
32 fields.filter((field): field is string => field !== undefined).join(",");
33
34export const normalizeCustomer = (value: string | undefined) => {
35 const trimmed = value?.trim();
36 if (trimmed === undefined || trimmed.length === 0) {
37 return DEFAULT_CUSTOMER;
38 }
39 if (
40 trimmed.startsWith("customers/") ||
41 trimmed.startsWith("identitysources/")
42 ) {
43 return trimmed;
44 }
45 return `customers/${trimmed}`;
46};
47
48export const expandName = (value: string, collection: string) => {
49 const trimmed = value.replace(/\/+$/, "");
50 if (trimmed.includes("/")) return trimmed;
51 return `${collection}/${trimmed}`;
52};
53
54export const expandGroup = (value: string) => expandName(value, "groups");
55
56export const replaceOnIdentity = (input: {
57 previousId?: string;
58 nextId?: string;
59 previousParent?: string;
60 nextParent?: string;
61 extra?: boolean;
62}) => {
63 if (input.extra === true) {
64 return { action: "replace" as const, deleteFirst: false };
65 }
66 if (
67 input.previousId !== undefined &&
68 input.nextId !== undefined &&
69 input.previousId !== input.nextId
70 ) {
71 return { action: "replace" as const, deleteFirst: false };
72 }
73 if (
74 input.previousParent !== undefined &&
75 input.nextParent !== undefined &&
76 input.previousParent !== input.nextParent
77 ) {
78 return { action: "replace" as const, deleteFirst: false };
79 }
80 return undefined;
81};
82
83const markerOf = (
84 _labels: Record<string, string>,
85 stack: string,
86 stage: string,
87 id: string,
88) =>
89 `[alchemy ${alchemyLabelKeys.stack}=${stack} ${alchemyLabelKeys.stage}=${stage} ${alchemyLabelKeys.id}=${id}]`;
90
91const fitMarker = (labels: Record<string, string>, maxLength: number) => {
92 let stack = labels[alchemyLabelKeys.stack] ?? "x";
93 let stage = labels[alchemyLabelKeys.stage] ?? "x";
94 let id = labels[alchemyLabelKeys.id] ?? "x";
95 let marker = markerOf(labels, stack, stage, id);
96 while (
97 marker.length > maxLength &&
98 (stack.length > 1 || stage.length > 1 || id.length > 1)
99 ) {
100 if (stack.length >= stage.length && stack.length >= id.length) {
101 stack = stack.slice(0, -1);
102 } else if (stage.length >= id.length) {
103 stage = stage.slice(0, -1);
104 } else {
105 id = id.slice(0, -1);
106 }
107 marker = markerOf(labels, stack, stage, id);
108 }
109 return marker.slice(0, maxLength);
110};
111
112export const encodeOwnership = (
113 labels: Record<string, string>,
114 text: string | undefined,
115): string => {
116 const marker = fitMarker(labels, Math.min(8000, MAX_DESCRIPTION_LENGTH));
117 const trimmed = text?.trim();
118 const combined =
119 trimmed && trimmed.length > 0 ? `${marker}\n${trimmed}` : marker;
120 return combined.slice(0, MAX_DESCRIPTION_LENGTH);
121};
122
123export const encodeOwnershipLine = (
124 labels: Record<string, string>,
125 text: string | undefined,
126 maxLength = MAX_DISPLAY_NAME_LENGTH,
127): string => {
128 const trimmed = text?.replace(/[\r\n]+/g, " ").trim();
129 if (!trimmed) return fitMarker(labels, maxLength);
130 const minMarker = 24;
131 const reserved = Math.min(
132 trimmed.length + 1,
133 Math.max(0, maxLength - minMarker),
134 );
135 const marker = fitMarker(labels, maxLength - reserved);
136 return `${marker} ${trimmed}`.slice(0, maxLength);
137};
138
139export const parseOwnership = (
140 text: string | undefined,
141): {
142 labels: Record<string, string>;
143 text: string | undefined;
144} => {
145 if (!text?.startsWith("[alchemy ")) {
146 return { labels: {}, text };
147 }
148 const end = text.indexOf("]");
149 if (end < 0) return { labels: {}, text };
150 const labels: Record<string, string> = {};
151 for (const part of text.slice("[alchemy ".length, end).split(/\s+/)) {
152 const eq = part.indexOf("=");
153 if (eq > 0) {
154 labels[part.slice(0, eq)] = part.slice(eq + 1);
155 }
156 }
157 const rest = text.slice(end + 1).replace(/^[\s\n]+/, "");
158 return { labels, text: rest.length > 0 ? rest : undefined };
159};
160
161export const hasOwnershipMarker = (text: string | undefined) => {
162 if (
163 Object.keys(parseOwnership(text).labels).some((key) =>
164 key.startsWith("alchemy-"),
165 )
166 ) {
167 return true;
168 }
169 return (text ?? "").toLowerCase().includes("alchemy-");
170};
171
172const prefixMatch = (expected: string, observed: string) =>
173 expected === observed ||
174 expected.startsWith(observed) ||
175 observed.startsWith(expected);
176
177export const ownedByAlchemy = (id: string, text: string | undefined) =>
178 Effect.gen(function* () {
179 const expected = yield* createInternalLabels(id);
180 const { labels } = parseOwnership(text);
181 if (!hasOwnershipMarker(text)) return false;
182 const exact = yield* hasAlchemyLabels(id, labels);
183 if (exact) return true;
184 return (
185 prefixMatch(
186 expected[alchemyLabelKeys.stack] ?? "",
187 labels[alchemyLabelKeys.stack] ?? "",
188 ) &&
189 prefixMatch(
190 expected[alchemyLabelKeys.stage] ?? "",
191 labels[alchemyLabelKeys.stage] ?? "",
192 ) &&
193 prefixMatch(
194 expected[alchemyLabelKeys.id] ?? "",
195 labels[alchemyLabelKeys.id] ?? "",
196 )
197 );
198 });
199
200export const ownershipLabels = (id: string) => createInternalLabels(id);
201
202export const toPhysicalId = (
203 id: string,
204 requested: string | undefined,
205 existing: string | undefined,
206 maxLength = MAX_GROUP_KEY_LOCAL_LENGTH,
207) =>
208 Effect.gen(function* () {
209 if (requested !== undefined && requested.length > 0) return requested;
210 if (existing !== undefined && existing.length > 0) return existing;
211 return yield* createPhysicalName({
212 id,
213 maxLength,
214 lowercase: true,
215 });
216 });
217
218export const toGroupKeyId = (
219 id: string,
220 requested: string | undefined,
221 existing: string | undefined,
222 domain: string | undefined,
223) =>
224 Effect.gen(function* () {
225 if (requested !== undefined && requested.length > 0) return requested;
226 if (existing !== undefined && existing.length > 0) return existing;
227 const local = yield* createPhysicalName({
228 id,
229 maxLength: MAX_GROUP_KEY_LOCAL_LENGTH,
230 lowercase: true,
231 });
232 const host = domain?.trim();
233 return host && host.length > 0 ? `${local}@${host}` : local;
234 });
235
236export const typeLabels = (
237 labels: Record<string, string> | undefined,
238): Record<string, string> => {
239 const next = { ...(labels ?? {}) };
240 if (Object.keys(next).length === 0) {
241 next[DISCUSSION_FORUM_LABEL] = "";
242 }
243 return next;
244};
245
246export const compactStringMap = (
247 labels: Record<string, string | undefined> | null | undefined,
248): Record<string, string> =>
249 Object.fromEntries(
250 Object.entries(labels ?? {}).filter(
251 (entry): entry is [string, string] =>
252 typeof entry[1] === "string" && entry[1] !== undefined,
253 ),
254 );
255
256const emptyList = <A>() => Effect.succeed([] as A[]);
257
258export const getGroup = (name: string) =>
259 name.length === 0
260 ? Effect.succeed(undefined)
261 : cloudidentity
262 .getGroups({ name: expandGroup(name) })
263 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
264
265export const lookupGroupName = (
266 groupKeyId: string,
267 namespace: string | undefined,
268) =>
269 groupKeyId.length === 0
270 ? Effect.succeed(undefined)
271 : cloudidentity
272 .lookupGroups({
273 "groupKey.id": groupKeyId,
274 "groupKey.namespace": namespace,
275 })
276 .pipe(
277 Effect.map((result) => result.name),
278 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
279 );
280
281export const getGroupByKey = (
282 groupKeyId: string,
283 namespace: string | undefined,
284) =>
285 Effect.gen(function* () {
286 const name = yield* lookupGroupName(groupKeyId, namespace);
287 if (name === undefined) return undefined;
288 return yield* getGroup(name);
289 });
290
291export const listGroups = (parent?: string) =>
292 cloudidentity.listGroups
293 .pages({
294 parent: normalizeCustomer(parent),
295 view: "FULL",
296 pageSize: 200,
297 })
298 .pipe(
299 Stream.flatMap((page) => Stream.fromIterable(page.groups ?? [])),
300 Stream.runCollect,
301 Effect.map((chunk) => Array.from(chunk)),
302 Effect.catchTag("NotFound", () => emptyList<cloudidentity.Group>()),
303 );
304
305export const isOwnedGroup = (group: cloudidentity.Group) =>
306 hasOwnershipMarker(group.description) ||
307 hasOwnershipMarker(group.displayName);
308
309export const listOwnedGroups = (parent?: string) =>
310 listGroups(parent).pipe(Effect.map((groups) => groups.filter(isOwnedGroup)));
311
312export const findOwnedGroup = (id: string, parent?: string) =>
313 Effect.gen(function* () {
314 const groups = yield* listGroups(parent);
315 for (const group of groups) {
316 if (
317 (yield* ownedByAlchemy(id, group.description)) ||
318 (yield* ownedByAlchemy(id, group.displayName))
319 ) {
320 return group;
321 }
322 }
323 return undefined;
324 });
325
326export const getMembership = (name: string) =>
327 name.length === 0
328 ? Effect.succeed(undefined)
329 : cloudidentity
330 .getGroupsMemberships({ name })
331 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
332
333export const lookupMembershipName = (
334 parent: string,
335 memberKeyId: string,
336 namespace: string | undefined,
337) =>
338 parent.length === 0 || memberKeyId.length === 0
339 ? Effect.succeed(undefined)
340 : cloudidentity
341 .lookupGroupsMemberships({
342 parent: expandGroup(parent),
343 "memberKey.id": memberKeyId,
344 "memberKey.namespace": namespace,
345 })
346 .pipe(
347 Effect.map((result) => result.name),
348 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
349 );
350
351export const getMembershipByKey = (
352 parent: string,
353 memberKeyId: string,
354 namespace: string | undefined,
355) =>
356 Effect.gen(function* () {
357 const name = yield* lookupMembershipName(parent, memberKeyId, namespace);
358 if (name === undefined) return undefined;
359 return yield* getMembership(name);
360 });
361
362export const listMemberships = (parent: string) =>
363 parent.length === 0
364 ? emptyList<cloudidentity.Membership>()
365 : cloudidentity.listGroupsMemberships
366 .pages({
367 parent: expandGroup(parent),
368 view: "FULL",
369 pageSize: 200,
370 })
371 .pipe(
372 Stream.flatMap((page) => Stream.fromIterable(page.memberships ?? [])),
373 Stream.runCollect,
374 Effect.map((chunk) => Array.from(chunk)),
375 Effect.catchTag("NotFound", () =>
376 emptyList<cloudidentity.Membership>(),
377 ),
378 );
379
380export const getDevice = (name: string, customer?: string) =>
381 name.length === 0
382 ? Effect.succeed(undefined)
383 : cloudidentity
384 .getDevices({
385 name: expandName(name, "devices"),
386 customer: customer ? normalizeCustomer(customer) : undefined,
387 })
388 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
389
390export const listDevices = (customer?: string) =>
391 cloudidentity.listDevices
392 .pages({
393 customer: normalizeCustomer(customer),
394 view: "COMPANY_INVENTORY",
395 pageSize: 100,
396 })
397 .pipe(
398 Stream.flatMap((page) => Stream.fromIterable(page.devices ?? [])),
399 Stream.runCollect,
400 Effect.map((chunk) => Array.from(chunk)),
401 Effect.catchTag("NotFound", () =>
402 emptyList<cloudidentity.GoogleAppsCloudidentityDevicesV1Device>(),
403 ),
404 );
405
406export const findOwnedDevice = (
407 id: string,
408 serialNumber: string | undefined,
409 customer?: string,
410) =>
411 Effect.gen(function* () {
412 const devices = yield* listDevices(customer);
413 for (const device of devices) {
414 if (
415 serialNumber !== undefined &&
416 serialNumber.length > 0 &&
417 sameText(device.serialNumber, serialNumber)
418 ) {
419 return device;
420 }
421 if (yield* ownedByAlchemy(id, device.assetTag)) {
422 return device;
423 }
424 }
425 return undefined;
426 });
427
428export const getOidcProfile = (name: string) =>
429 name.length === 0
430 ? Effect.succeed(undefined)
431 : cloudidentity
432 .getInboundOidcSsoProfiles({
433 name: expandName(name, "inboundOidcSsoProfiles"),
434 })
435 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
436
437export const listOidcProfiles = () =>
438 cloudidentity.listInboundOidcSsoProfiles.pages({ pageSize: 100 }).pipe(
439 Stream.flatMap((page) =>
440 Stream.fromIterable(page.inboundOidcSsoProfiles ?? []),
441 ),
442 Stream.runCollect,
443 Effect.map((chunk) => Array.from(chunk)),
444 Effect.catchTag("NotFound", () =>
445 emptyList<cloudidentity.InboundOidcSsoProfile>(),
446 ),
447 );
448
449export const findOwnedOidcProfile = (id: string) =>
450 Effect.gen(function* () {
451 const profiles = yield* listOidcProfiles();
452 for (const profile of profiles) {
453 if (yield* ownedByAlchemy(id, profile.displayName)) {
454 return profile;
455 }
456 }
457 return undefined;
458 });
459
460export const getSamlProfile = (name: string) =>
461 name.length === 0
462 ? Effect.succeed(undefined)
463 : cloudidentity
464 .getInboundSamlSsoProfiles({
465 name: expandName(name, "inboundSamlSsoProfiles"),
466 })
467 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
468
469export const listSamlProfiles = () =>
470 cloudidentity.listInboundSamlSsoProfiles.pages({ pageSize: 100 }).pipe(
471 Stream.flatMap((page) =>
472 Stream.fromIterable(page.inboundSamlSsoProfiles ?? []),
473 ),
474 Stream.runCollect,
475 Effect.map((chunk) => Array.from(chunk)),
476 Effect.catchTag("NotFound", () =>
477 emptyList<cloudidentity.InboundSamlSsoProfile>(),
478 ),
479 );
480
481export const findOwnedSamlProfile = (id: string) =>
482 Effect.gen(function* () {
483 const profiles = yield* listSamlProfiles();
484 for (const profile of profiles) {
485 if (yield* ownedByAlchemy(id, profile.displayName)) {
486 return profile;
487 }
488 }
489 return undefined;
490 });
491
492export const getSsoAssignment = (name: string) =>
493 name.length === 0
494 ? Effect.succeed(undefined)
495 : cloudidentity
496 .getInboundSsoAssignments({
497 name: expandName(name, "inboundSsoAssignments"),
498 })
499 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
500
501export const listSsoAssignments = () =>
502 cloudidentity.listInboundSsoAssignments.pages({ pageSize: 100 }).pipe(
503 Stream.flatMap((page) =>
504 Stream.fromIterable(page.inboundSsoAssignments ?? []),
505 ),
506 Stream.runCollect,
507 Effect.map((chunk) => Array.from(chunk)),
508 Effect.catchTag("NotFound", () =>
509 emptyList<cloudidentity.InboundSsoAssignment>(),
510 ),
511 );
512
513export const findSsoAssignment = (input: {
514 name?: string;
515 targetGroup?: string;
516 targetOrgUnit?: string;
517 ssoMode?: string;
518}) =>
519 Effect.gen(function* () {
520 if (input.name !== undefined && input.name.length > 0) {
521 const existing = yield* getSsoAssignment(input.name);
522 if (existing !== undefined) return existing;
523 }
524 const assignments = yield* listSsoAssignments();
525 return assignments.find((assignment) => {
526 if (
527 input.targetGroup !== undefined &&
528 !sameText(assignment.targetGroup, expandGroup(input.targetGroup))
529 ) {
530 return false;
531 }
532 if (
533 input.targetOrgUnit !== undefined &&
534 !sameText(assignment.targetOrgUnit, input.targetOrgUnit)
535 ) {
536 return false;
537 }
538 if (
539 input.ssoMode !== undefined &&
540 !sameText(assignment.ssoMode, input.ssoMode)
541 ) {
542 return false;
543 }
544 return (
545 input.targetGroup !== undefined || input.targetOrgUnit !== undefined
546 );
547 });
548 });