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 getMembership,
12 getMembershipByKey,
13 lastSegment,
14 listMemberships,
15 listOwnedGroups,
16 replaceOnIdentity,
17 sameJson,
18 sameText,
19} from "./internal.ts";
20import {
21 resourceNameFromOperation,
22 waitForOperation,
23 waitUntilPresent,
24} from "./operations.ts";
25
26export type GroupsMembershipRole = {
27 /** Role name: `OWNER`, `MANAGER`, or `MEMBER`. */
28 name?: string;
29 /** Expiry, only valid when `name` is `MEMBER`. */
30 expiryDetail?: {
31 expireTime?: string;
32 };
33};
34
35export type GroupsMembershipProps = {
36 /**
37 * Parent group resource name (`groups/{group}`) or group id.
38 * Immutable — changing it replaces the membership.
39 */
40 parent: string;
41 /**
42 * Member entity key id (user or group email). Immutable —
43 * changing it replaces the membership.
44 */
45 memberKeyId: string;
46 /**
47 * Member entity-key namespace for external identities.
48 */
49 memberKeyNamespace?: string;
50 /**
51 * Roles for the membership. Defaults to a single `MEMBER` role.
52 * `MEMBER` cannot be removed; drop `MANAGER` / `OWNER` in place.
53 */
54 roles?: GroupsMembershipRole[];
55};
56
57export type GroupsMembership = Resource<
58 "GCP.CloudIdentity.GroupsMembership",
59 GroupsMembershipProps,
60 {
61 /** Resource name `groups/{group}/memberships/{membership}`. */
62 name: string;
63 /** Membership id (last path segment). */
64 membershipId: string;
65 /** Parent group name. */
66 parent: string;
67 /** Member entity key id. */
68 memberKeyId: string;
69 /** Member entity-key namespace, if any. */
70 memberKeyNamespace: string | undefined;
71 /** Membership roles. */
72 roles: GroupsMembershipRole[];
73 /** Member type (`USER`, `GROUP`, …). */
74 type: string | undefined;
75 /** Delivery setting. */
76 deliverySetting: string | undefined;
77 /** RFC3339 creation timestamp. */
78 createTime: string | undefined;
79 /** RFC3339 last-update timestamp. */
80 updateTime: string | undefined;
81 },
82 never,
83 Providers
84>;
85
86/**
87 * A Cloud Identity group membership.
88 *
89 * Memberships have no labels or description, so Alchemy lists
90 * memberships of alchemy-owned groups (ownership stamped on the parent
91 * group description) for `list` / nuke. Parent group and member key
92 * are identity; roles update in place via `modifyMembershipRoles`.
93 *
94 * ### Creating a Membership
95 * **Example:** Add a member
96 * ```typescript
97 * const membership = yield* GCP.CloudIdentity.GroupsMembership("Ada", {
98 * parent: group.name,
99 * memberKeyId: "ada@example.com",
100 * });
101 * ```
102 *
103 * **Example:** Manager role
104 * ```typescript
105 * const membership = yield* GCP.CloudIdentity.GroupsMembership("Ada", {
106 * parent: group.name,
107 * memberKeyId: "ada@example.com",
108 * roles: [{ name: "MEMBER" }, { name: "MANAGER" }],
109 * });
110 * ```
111 *
112 * @resource
113 * @category CloudIdentity
114 */
115export const GroupsMembership = Resource<GroupsMembership>(
116 "GCP.CloudIdentity.GroupsMembership",
117);
118
119export class GroupsMembershipNotResolved extends Data.TaggedError(
120 "GCP.CloudIdentity.GroupsMembershipNotResolved",
121)<{
122 parent: string;
123 memberKeyId: string;
124}> {}
125
126const DEFAULT_ROLES: GroupsMembershipRole[] = [{ name: "MEMBER" }];
127
128const rolesOf = (
129 roles: cloudidentity.MembershipRoleList | undefined,
130): GroupsMembershipRole[] =>
131 (roles ?? DEFAULT_ROLES).map((role) => ({
132 name: role.name,
133 expiryDetail: role.expiryDetail
134 ? { expireTime: role.expiryDetail.expireTime }
135 : undefined,
136 }));
137
138const desiredRoles = (roles: GroupsMembershipRole[] | undefined) => {
139 const next = roles !== undefined && roles.length > 0 ? roles : DEFAULT_ROLES;
140 return next.map((role) => ({
141 name: role.name ?? "MEMBER",
142 expiryDetail: role.expiryDetail,
143 }));
144};
145
146const roleNames = (roles: GroupsMembershipRole[]) =>
147 [...roles]
148 .map((role) => role.name ?? "MEMBER")
149 .sort((left, right) => left.localeCompare(right));
150
151const parentOfName = (name: string) => {
152 const parts = name.split("/").filter((part) => part.length > 0);
153 const index = parts.lastIndexOf("memberships");
154 return index > 0 ? parts.slice(0, index).join("/") : expandGroup(name);
155};
156
157const toAttrs = (membership: cloudidentity.Membership, parent: string) => {
158 const name = membership.name ?? "";
159 return {
160 name,
161 membershipId: lastSegment(name),
162 parent: parentOfName(name) || parent,
163 memberKeyId: membership.preferredMemberKey?.id ?? "",
164 memberKeyNamespace: membership.preferredMemberKey?.namespace,
165 roles: rolesOf(membership.roles),
166 type: membership.type,
167 deliverySetting: membership.deliverySetting,
168 createTime: membership.createTime,
169 updateTime: membership.updateTime,
170 };
171};
172
173const observeMembership = (input: {
174 name?: string;
175 parent: string;
176 memberKeyId: string;
177 memberKeyNamespace?: string;
178}) =>
179 Effect.gen(function* () {
180 const byName = yield* getMembership(input.name ?? "");
181 if (byName !== undefined) return byName;
182 return yield* getMembershipByKey(
183 input.parent,
184 input.memberKeyId,
185 input.memberKeyNamespace,
186 );
187 });
188
189const syncRoles = (
190 name: string,
191 current: cloudidentity.Membership,
192 desired: GroupsMembershipRole[],
193) =>
194 Effect.gen(function* () {
195 const observed = rolesOf(current.roles);
196 if (sameJson(observed, desired)) return current;
197
198 const currentNames = new Set(roleNames(observed));
199 const desiredNames = new Set(roleNames(desired));
200 const addRoles = desired.filter(
201 (role) => !currentNames.has(role.name ?? "MEMBER"),
202 );
203 const removeRoles = roleNames(observed).filter(
204 (role) => role !== "MEMBER" && !desiredNames.has(role),
205 );
206
207 let next = current;
208 if (addRoles.length > 0 || removeRoles.length > 0) {
209 const modified =
210 yield* cloudidentity.modifyMembershipRolesGroupsMemberships({
211 name,
212 body: {
213 addRoles: addRoles.length > 0 ? addRoles : undefined,
214 removeRoles: removeRoles.length > 0 ? removeRoles : undefined,
215 },
216 });
217 next = modified.membership ?? next;
218 }
219
220 const memberExpiry = desired.find((role) => role.name === "MEMBER")
221 ?.expiryDetail?.expireTime;
222 const observedExpiry = rolesOf(next.roles).find(
223 (role) => role.name === "MEMBER",
224 )?.expiryDetail?.expireTime;
225 if (!sameText(memberExpiry, observedExpiry) && memberExpiry !== undefined) {
226 const modified =
227 yield* cloudidentity.modifyMembershipRolesGroupsMemberships({
228 name,
229 body: {
230 updateRolesParams: [
231 {
232 fieldMask: "expiry_detail.expire_time",
233 membershipRole: {
234 name: "MEMBER",
235 expiryDetail: { expireTime: memberExpiry },
236 },
237 },
238 ],
239 },
240 });
241 next = modified.membership ?? next;
242 }
243
244 return next;
245 });
246
247export const GroupsMembershipProvider = () =>
248 Provider.succeed(GroupsMembership, {
249 stables: ["name", "membershipId", "parent", "memberKeyId", "createTime"],
250
251 diff: Effect.fn(function* ({ news, olds, output }) {
252 if (!isResolved(news)) return undefined;
253 return replaceOnIdentity({
254 previousId: olds?.memberKeyId ?? output?.memberKeyId,
255 nextId: news.memberKeyId,
256 previousParent: olds?.parent ?? output?.parent,
257 nextParent: expandGroup(news.parent),
258 extra:
259 news.memberKeyNamespace !== undefined &&
260 output?.memberKeyNamespace !== undefined &&
261 news.memberKeyNamespace !== output.memberKeyNamespace,
262 });
263 }),
264
265 read: Effect.fn(function* ({ olds, output }) {
266 const parent = expandGroup(olds?.parent ?? output?.parent ?? "");
267 const memberKeyId = olds?.memberKeyId ?? output?.memberKeyId ?? "";
268 const existing = yield* observeMembership({
269 name: output?.name,
270 parent,
271 memberKeyId,
272 memberKeyNamespace:
273 olds?.memberKeyNamespace ?? output?.memberKeyNamespace,
274 });
275 if (existing === undefined) return undefined;
276 const attrs = toAttrs(existing, parent);
277 return output !== undefined ? attrs : Unowned(attrs);
278 }),
279
280 list: () =>
281 Effect.gen(function* () {
282 const groups = yield* listOwnedGroups();
283 const pages = yield* Effect.forEach(
284 groups,
285 (group) =>
286 group.name
287 ? listMemberships(group.name).pipe(
288 Effect.map((memberships) =>
289 memberships.map((membership) =>
290 toAttrs(membership, group.name ?? ""),
291 ),
292 ),
293 )
294 : Effect.succeed([]),
295 { concurrency: 4 },
296 );
297 return pages.flat();
298 }),
299
300 reconcile: Effect.fn(function* ({ news, output }) {
301 const parent = expandGroup(news.parent);
302 const memberKeyId = news.memberKeyId;
303 const memberKeyNamespace = news.memberKeyNamespace;
304 const roles = desiredRoles(news.roles);
305
306 let current = yield* observeMembership({
307 name: output?.name,
308 parent,
309 memberKeyId,
310 memberKeyNamespace,
311 });
312
313 if (current === undefined) {
314 const created = yield* cloudidentity
315 .createGroupsMemberships({
316 parent,
317 body: {
318 preferredMemberKey: {
319 id: memberKeyId,
320 namespace: memberKeyNamespace,
321 },
322 roles,
323 },
324 })
325 .pipe(
326 Effect.catchTag("Conflict", () =>
327 Effect.succeed<cloudidentity.Operation | undefined>(undefined),
328 ),
329 );
330 if (created !== undefined) {
331 yield* waitForOperation(created).pipe(
332 Effect.catchTag(
333 "GCP.CloudIdentity.OperationPending",
334 () => Effect.void,
335 ),
336 );
337 const createdName = resourceNameFromOperation(created);
338 if (createdName !== undefined) {
339 current = yield* getMembership(createdName);
340 }
341 }
342 if (current === undefined) {
343 current = yield* waitUntilPresent(
344 observeMembership({
345 name: output?.name,
346 parent,
347 memberKeyId,
348 memberKeyNamespace,
349 }),
350 memberKeyId,
351 ).pipe(
352 Effect.catchTag("GCP.CloudIdentity.OperationPending", () =>
353 observeMembership({
354 parent,
355 memberKeyId,
356 memberKeyNamespace,
357 }),
358 ),
359 );
360 }
361 }
362
363 if (current === undefined) {
364 return yield* new GroupsMembershipNotResolved({
365 parent,
366 memberKeyId,
367 });
368 }
369
370 const name = current.name ?? output?.name ?? "";
371 if (name.length > 0) {
372 current = yield* syncRoles(name, current, roles);
373 }
374
375 return toAttrs(current, parent);
376 }),
377
378 delete: Effect.fn(function* ({ output }) {
379 if (output.name.length === 0) return;
380 const deleted = yield* cloudidentity
381 .deleteGroupsMemberships({ name: output.name })
382 .pipe(
383 Effect.catchTag("NotFound", () =>
384 Effect.succeed<cloudidentity.Operation | undefined>(undefined),
385 ),
386 );
387 if (deleted !== undefined) {
388 yield* waitForOperation(deleted, { notFoundOk: true });
389 }
390 }),
391 });