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 compactStringMap,
11 getGroup,
12 getGroupByKey,
13 lastSegment,
14 MAX_DISPLAY_NAME_LENGTH,
15 normalizeCustomer,
16 replaceOnIdentity,
17 sameJson,
18 sameText,
19 toGroupKeyId,
20 toPhysicalId,
21 typeLabels,
22 updateMaskOf,
23} from "./internal.ts";
24import {
25 resourceNameFromOperation,
26 waitForOperation,
27 waitUntilPresent,
28} from "./operations.ts";
29
30export type GroupEntityKey = {
31 /** Email address (Google-managed) or identity-source id. */
32 id?: string;
33 /** `identitysources/{identity_source}` for external-mapped groups. */
34 namespace?: string;
35};
36
37export type GroupDynamicGroupQuery = {
38 /** Resource type for the query. Currently `USER`. */
39 resourceType?:
40 | cloudidentity.DynamicGroupQueryResourceTypeEnum
41 | (string & {});
42 /** CEL membership query. */
43 query?: string;
44};
45
46export type GroupDynamicGroupMetadata = {
47 /** Membership queries. Only one `USER` query is supported. */
48 queries?: GroupDynamicGroupQuery[];
49};
50
51export type GroupProps = {
52 /**
53 * Cloud Identity customer or identity source, as
54 * `customers/{customer}` (the id must start with `C`) or
55 * `identitysources/{identity_source}`. `customers/my_customer`
56 * refers to the caller's customer. Immutable — changing it replaces
57 * the group.
58 * @default "customers/my_customer"
59 */
60 parent?: string;
61 /**
62 * Group email (`group@example.com`) or identity-source id. If
63 * omitted, a unique local part is generated and combined with
64 * `domain` when set. Immutable — changing it replaces the group.
65 */
66 groupKeyId?: string;
67 /**
68 * Entity-key namespace for external-identity-mapped groups
69 * (`identitysources/{identity_source}`).
70 */
71 groupKeyNamespace?: string;
72 /**
73 * Domain used to build `groupKeyId` when that prop is omitted
74 * (`{generated}@{domain}`).
75 */
76 domain?: string;
77 /**
78 * Display name shown to members.
79 */
80 displayName?: string;
81 /**
82 * Extended description shown to members (max 4,096 characters).
83 */
84 description?: string;
85 /**
86 * Cloud Identity type labels (empty values). Google Groups need
87 * `cloudidentity.googleapis.com/groups.discussion_forum`. Adding
88 * `cloudidentity.googleapis.com/groups.security` is immutable.
89 */
90 labels?: Record<string, string>;
91 /**
92 * Initial membership config on create.
93 * @default "EMPTY"
94 */
95 initialGroupConfig?:
96 | cloudidentity.CreateGroupsInitialGroupConfigEnum
97 | (string & {});
98 /**
99 * Dynamic group queries. Sets the dynamic type label.
100 */
101 dynamicGroupMetadata?: GroupDynamicGroupMetadata;
102};
103
104export type Group = Resource<
105 "GCP.CloudIdentity.Group",
106 GroupProps,
107 {
108 /** Resource name `groups/{group}`. */
109 name: string;
110 /** Group id (last path segment). */
111 groupId: string;
112 /** Customer or identity-source parent. */
113 parent: string;
114 /** Group entity key id (email). */
115 groupKeyId: string;
116 /** Entity-key namespace, if any. */
117 groupKeyNamespace: string | undefined;
118 /** Display name. */
119 displayName: string | undefined;
120 /** Description. */
121 description: string | undefined;
122 /** Cloud Identity type labels. */
123 labels: Record<string, string>;
124 /** Additional group keys. */
125 additionalGroupKeys: GroupEntityKey[] | undefined;
126 /** RFC3339 creation timestamp. */
127 createTime: string | undefined;
128 /** RFC3339 last-update timestamp. */
129 updateTime: string | undefined;
130 },
131 never,
132 Providers
133>;
134
135/**
136 * A Cloud Identity / Google Group.
137 *
138 * Groups have no user labels, so Alchemy identifies a group by its key
139 * (email): a group found under the generated key is adopted, one found
140 * under an explicit `groupKeyId` without state is reported as unowned.
141 * `parent` and `groupKeyId` are identity; display name, description, and labels update in place.
142 *
143 * ### Creating a Group
144 * **Example:** Generated email on a Workspace domain
145 * ```typescript
146 * const group = yield* GCP.CloudIdentity.Group("Eng", {
147 * parent: "customers/my_customer",
148 * domain: "example.com",
149 * displayName: "Engineering",
150 * });
151 * ```
152 *
153 * **Example:** Explicit group key
154 * ```typescript
155 * const group = yield* GCP.CloudIdentity.Group("Eng", {
156 * parent: "customers/C046psxkn",
157 * groupKeyId: "eng@example.com",
158 * displayName: "Engineering",
159 * description: "product engineering",
160 * });
161 * ```
162 *
163 * ### Updating a Group
164 * **Example:** Rename
165 * ```typescript
166 * const group = yield* GCP.CloudIdentity.Group("Eng", {
167 * groupKeyId: "eng@example.com",
168 * displayName: "Engineering 2026",
169 * });
170 * ```
171 *
172 * @resource
173 * @category CloudIdentity
174 */
175export const Group = Resource<Group>("GCP.CloudIdentity.Group");
176
177export class GroupNotResolved extends Data.TaggedError(
178 "GCP.CloudIdentity.GroupNotResolved",
179)<{
180 name: string;
181}> {}
182
183const toAttrs = (group: cloudidentity.Group) => {
184 const name = group.name ?? "";
185 return {
186 name,
187 groupId: lastSegment(name),
188 parent: group.parent ?? "",
189 groupKeyId: group.groupKey?.id ?? "",
190 groupKeyNamespace: group.groupKey?.namespace,
191 displayName: group.displayName,
192 description: group.description,
193 labels: compactStringMap(group.labels),
194 additionalGroupKeys: group.additionalGroupKeys?.map((key) => ({
195 id: key.id,
196 namespace: key.namespace,
197 })),
198 createTime: group.createTime,
199 updateTime: group.updateTime,
200 };
201};
202
203const observeGroup = (input: {
204 id: string;
205 name?: string;
206 groupKeyId?: string;
207 groupKeyNamespace?: string;
208 parent?: string;
209}) =>
210 Effect.gen(function* () {
211 const byName = yield* getGroup(input.name ?? "");
212 if (byName !== undefined) return byName;
213 const byKey = yield* getGroupByKey(
214 input.groupKeyId ?? "",
215 input.groupKeyNamespace,
216 );
217 return byKey;
218 });
219
220export const GroupProvider = () =>
221 Provider.succeed(Group, {
222 stables: ["name", "groupId", "parent", "groupKeyId", "createTime"],
223
224 diff: Effect.fn(function* ({ news, olds, output }) {
225 if (!isResolved(news)) return undefined;
226 const previousParent = olds?.parent ?? output?.parent;
227 const nextParent =
228 news.parent !== undefined
229 ? normalizeCustomer(news.parent)
230 : previousParent;
231 const previousKey = olds?.groupKeyId ?? output?.groupKeyId;
232 return replaceOnIdentity({
233 previousId: previousKey,
234 nextId: news.groupKeyId,
235 previousParent,
236 nextParent,
237 extra:
238 news.groupKeyNamespace !== undefined &&
239 output?.groupKeyNamespace !== undefined &&
240 news.groupKeyNamespace !== output.groupKeyNamespace,
241 });
242 }),
243
244 read: Effect.fn(function* ({ id, olds, output }) {
245 const groupKeyId = yield* toGroupKeyId(
246 id,
247 olds?.groupKeyId,
248 output?.groupKeyId,
249 olds?.domain,
250 );
251 const existing = yield* observeGroup({
252 id,
253 name: output?.name,
254 groupKeyId,
255 groupKeyNamespace: olds?.groupKeyNamespace ?? output?.groupKeyNamespace,
256 parent: olds?.parent ?? output?.parent,
257 });
258 if (existing === undefined) return undefined;
259 const attrs = toAttrs(existing);
260 // State or the generated group key proves ownership.
261 return output !== undefined || olds?.groupKeyId === undefined
262 ? attrs
263 : Unowned(attrs);
264 }),
265
266 reconcile: Effect.fn(function* ({ id, news, output }) {
267 const parent = normalizeCustomer(news.parent ?? output?.parent);
268 const groupKeyId = yield* toGroupKeyId(
269 id,
270 news.groupKeyId,
271 output?.groupKeyId,
272 news.domain,
273 );
274 const groupKeyNamespace =
275 news.groupKeyNamespace ?? output?.groupKeyNamespace;
276 const displayName = yield* toPhysicalId(
277 id,
278 news.displayName,
279 output?.displayName,
280 MAX_DISPLAY_NAME_LENGTH,
281 );
282 const description = news.description;
283 const labels = typeLabels(news.labels);
284 const desired: cloudidentity.Group = {
285 parent,
286 groupKey: {
287 id: groupKeyId,
288 namespace: groupKeyNamespace,
289 },
290 displayName,
291 description,
292 labels,
293 dynamicGroupMetadata: news.dynamicGroupMetadata,
294 };
295
296 let current = yield* observeGroup({
297 id,
298 name: output?.name,
299 groupKeyId,
300 groupKeyNamespace,
301 parent,
302 });
303
304 if (current === undefined) {
305 const created = yield* cloudidentity
306 .createGroups({
307 initialGroupConfig: news.initialGroupConfig ?? "EMPTY",
308 body: desired,
309 })
310 .pipe(
311 Effect.catchTag("Conflict", () =>
312 Effect.succeed<cloudidentity.Operation | undefined>(undefined),
313 ),
314 );
315 if (created !== undefined) {
316 yield* waitForOperation(created).pipe(
317 Effect.catchTag(
318 "GCP.CloudIdentity.OperationPending",
319 () => Effect.void,
320 ),
321 );
322 const createdName = resourceNameFromOperation(created);
323 if (createdName !== undefined) {
324 current = yield* getGroup(createdName);
325 }
326 }
327 if (current === undefined) {
328 current = yield* waitUntilPresent(
329 observeGroup({
330 id,
331 name: output?.name,
332 groupKeyId,
333 groupKeyNamespace,
334 parent,
335 }),
336 groupKeyId,
337 ).pipe(
338 Effect.catchTag("GCP.CloudIdentity.OperationPending", () =>
339 observeGroup({
340 id,
341 groupKeyId,
342 groupKeyNamespace,
343 parent,
344 }),
345 ),
346 );
347 }
348 }
349
350 if (current === undefined) {
351 return yield* new GroupNotResolved({
352 name: output?.name ?? groupKeyId,
353 });
354 }
355
356 const name = current.name ?? output?.name ?? "";
357 const displayChanged = !sameText(current.displayName, displayName);
358 const descriptionChanged = !sameText(current.description, description);
359 const labelsChanged = !sameJson(compactStringMap(current.labels), labels);
360
361 const updateMask = updateMaskOf(
362 displayChanged ? "display_name" : undefined,
363 descriptionChanged ? "description" : undefined,
364 labelsChanged ? "labels" : undefined,
365 );
366
367 if (updateMask.length > 0 && name.length > 0) {
368 const patched = yield* cloudidentity.patchGroups({
369 name,
370 updateMask,
371 body: {
372 displayName,
373 description,
374 labels,
375 },
376 });
377 yield* waitForOperation(patched).pipe(
378 Effect.catchTag(
379 "GCP.CloudIdentity.OperationPending",
380 () => Effect.void,
381 ),
382 );
383 current = (yield* getGroup(name)) ?? current;
384 }
385
386 return toAttrs(current);
387 }),
388
389 delete: Effect.fn(function* ({ output }) {
390 if (output.name.length === 0) return;
391 const deleted = yield* cloudidentity
392 .deleteGroups({ name: output.name })
393 .pipe(
394 Effect.catchTag("NotFound", () =>
395 Effect.succeed<cloudidentity.Operation | undefined>(undefined),
396 ),
397 );
398 if (deleted !== undefined) {
399 yield* waitForOperation(deleted, { notFoundOk: true });
400 }
401 }),
402 });