1import * as licensing from "@distilled.cloud/gcp/licensing_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 { GcpEnvironment } from "../Environment.ts";
9import type { Providers } from "../Providers.ts";
10import {
11 deleteAssignment,
12 findAssignment,
13 getAssignment,
14 listAssignments,
15 listCustomerId,
16 listProductId,
17 listSkuId,
18 listUserId,
19 normalizeCustomerId,
20 replaceOnIdentity,
21 sameText,
22 sameUser,
23} from "./internal.ts";
24
25export type LicenseAssignmentProps = {
26 /**
27 * Product id (`Google-Apps`, `Google-Vault`, …). See Products and
28 * SKUs in the Enterprise License Manager docs. Immutable — changing
29 * it replaces the assignment.
30 */
31 productId: string;
32 /**
33 * Product SKU id (`Google-Apps-For-Business`, `1010020027`, …).
34 * Reassigning to another SKU of the same product updates in place.
35 */
36 skuId: string;
37 /**
38 * User's primary email. Immutable — changing it replaces the
39 * assignment. If the email changes in Admin Console, pass the new
40 * address; the previous id is not a stable key.
41 */
42 userId: string;
43 /**
44 * Workspace customer id (`C0123abc` or `my_customer`). Used to look
45 * up an existing assignment after a SKU change and by `list` when
46 * matching env vars are set. Not sent on insert.
47 */
48 customerId?: string;
49};
50
51export type LicenseAssignment = Resource<
52 "GCP.Licensing.LicenseAssignment",
53 LicenseAssignmentProps,
54 {
55 /** Product id. */
56 productId: string;
57 /** Product SKU id. */
58 skuId: string;
59 /** User's primary email. */
60 userId: string;
61 /** Workspace customer id used for list lookups, if any. */
62 customerId: string | undefined;
63 /** Project id used when the assignment was reconciled. */
64 project: string;
65 /** Display name of the product. */
66 productName: string | undefined;
67 /** Display name of the SKU. */
68 skuName: string | undefined;
69 /** API self link. */
70 selfLink: string | undefined;
71 /** Resource kind (`licensing#licenseAssignment`). */
72 kind: string | undefined;
73 /** ETag of the assignment. */
74 etags: string | undefined;
75 },
76 never,
77 Providers
78>;
79
80/**
81 * A Google Workspace Enterprise License Manager assignment.
82 *
83 * Assignments have no labels or description, so Alchemy cannot stamp
84 * ownership. `list` / nuke only returns rows when
85 * `GOOGLE_LICENSE_CUSTOMER_ID` (or `GOOGLE_WORKSPACE_CUSTOMER_ID`),
86 * `GOOGLE_LICENSE_PRODUCT_ID`, and `GOOGLE_LICENSE_USER_ID` are set, so
87 * a Workspace domain is never bulk-revoked. Product and user are
88 * identity — changing either replaces the assignment. SKU reassignment
89 * within the same product updates in place via `licenseAssignments.patch`.
90 *
91 * Creating an assignment requires the
92 * `https://www.googleapis.com/auth/apps.licensing` scope and a
93 * Workspace admin on the customer.
94 *
95 * ### Creating an Assignment
96 * **Example:** Assign a Workspace SKU
97 * ```typescript
98 * const seat = yield* GCP.Licensing.LicenseAssignment("Ada", {
99 * productId: "Google-Apps",
100 * skuId: "1010020027",
101 * userId: "ada@example.com",
102 * customerId: "my_customer",
103 * });
104 * ```
105 *
106 * ### Reassigning a SKU
107 * **Example:** Move the user to another SKU of the same product
108 * ```typescript
109 * const seat = yield* GCP.Licensing.LicenseAssignment("Ada", {
110 * productId: "Google-Apps",
111 * skuId: "1010020028",
112 * userId: "ada@example.com",
113 * customerId: "my_customer",
114 * });
115 * ```
116 *
117 * @resource
118 * @category Licensing
119 */
120export const LicenseAssignment = Resource<LicenseAssignment>(
121 "GCP.Licensing.LicenseAssignment",
122);
123
124export class LicenseAssignmentNotResolved extends Data.TaggedError(
125 "GCP.Licensing.LicenseAssignmentNotResolved",
126)<{
127 productId: string;
128 skuId: string;
129 userId: string;
130}> {}
131
132const toAttrs = (
133 assignment: licensing.LicenseAssignment,
134 project: string,
135 customerId: string | undefined,
136) => ({
137 productId: assignment.productId ?? "",
138 skuId: assignment.skuId ?? "",
139 userId: assignment.userId ?? "",
140 customerId,
141 project,
142 productName: assignment.productName,
143 skuName: assignment.skuName,
144 selfLink: assignment.selfLink,
145 kind: assignment.kind,
146 etags: assignment.etags,
147});
148
149const observeAssignment = (input: {
150 productId: string;
151 skuId: string;
152 userId: string;
153 customerId?: string;
154 previousSkuId?: string;
155}) =>
156 Effect.gen(function* () {
157 const bySku = yield* getAssignment(
158 input.productId,
159 input.skuId,
160 input.userId,
161 );
162 if (bySku !== undefined) return bySku;
163 if (
164 input.previousSkuId !== undefined &&
165 input.previousSkuId.length > 0 &&
166 !sameText(input.previousSkuId, input.skuId)
167 ) {
168 const previous = yield* getAssignment(
169 input.productId,
170 input.previousSkuId,
171 input.userId,
172 );
173 if (previous !== undefined) return previous;
174 }
175 return yield* findAssignment(
176 input.productId,
177 input.userId,
178 input.customerId,
179 );
180 });
181
182export const LicenseAssignmentProvider = () =>
183 Provider.succeed(LicenseAssignment, {
184 stables: ["productId", "userId", "project"],
185
186 diff: Effect.fn(function* ({ news, olds, output }) {
187 if (!isResolved(news)) return undefined;
188 return replaceOnIdentity({
189 previousProductId: olds?.productId ?? output?.productId,
190 nextProductId: news.productId,
191 previousUserId: olds?.userId ?? output?.userId,
192 nextUserId: news.userId,
193 });
194 }),
195
196 read: Effect.fn(function* ({ olds, output }) {
197 const env = yield* GcpEnvironment.current;
198 const productId = olds?.productId ?? output?.productId ?? "";
199 const skuId = olds?.skuId ?? output?.skuId ?? "";
200 const userId = olds?.userId ?? output?.userId ?? "";
201 const customerId = normalizeCustomerId(
202 olds?.customerId ?? output?.customerId ?? (yield* listCustomerId()),
203 );
204 const existing = yield* observeAssignment({
205 productId,
206 skuId,
207 userId,
208 customerId,
209 previousSkuId: output?.skuId,
210 });
211 if (existing === undefined) return undefined;
212 const attrs = toAttrs(existing, env.project, customerId);
213 return output !== undefined ? attrs : Unowned(attrs);
214 }),
215
216 list: () =>
217 Effect.gen(function* () {
218 const customerId = yield* listCustomerId();
219 const productId = yield* listProductId();
220 const userId = yield* listUserId();
221 if (
222 customerId === undefined ||
223 productId === undefined ||
224 userId === undefined
225 ) {
226 return [];
227 }
228 const env = yield* GcpEnvironment.current;
229 const items = yield* listAssignments(
230 productId,
231 customerId,
232 yield* listSkuId(),
233 );
234 return items
235 .filter((item) => sameUser(item.userId, userId))
236 .map((item) => toAttrs(item, env.project, customerId));
237 }),
238
239 reconcile: Effect.fn(function* ({ news, output }) {
240 const env = yield* GcpEnvironment.current;
241 const productId = news.productId;
242 const skuId = news.skuId;
243 const userId = news.userId;
244 const customerId = normalizeCustomerId(
245 news.customerId ?? output?.customerId ?? (yield* listCustomerId()),
246 );
247
248 let current = yield* observeAssignment({
249 productId,
250 skuId,
251 userId,
252 customerId,
253 previousSkuId: output?.skuId,
254 });
255
256 if (current !== undefined && !sameUser(current.userId, userId)) {
257 current = undefined;
258 }
259
260 if (current === undefined) {
261 const created = yield* licensing
262 .insertLicenseAssignments({
263 productId,
264 skuId,
265 body: { userId },
266 })
267 .pipe(
268 Effect.catchTag("Conflict", () =>
269 observeAssignment({
270 productId,
271 skuId,
272 userId,
273 customerId,
274 previousSkuId: output?.skuId,
275 }),
276 ),
277 );
278 current = created ?? undefined;
279 }
280
281 if (current === undefined) {
282 return yield* new LicenseAssignmentNotResolved({
283 productId,
284 skuId,
285 userId,
286 });
287 }
288
289 const observedSku = current.skuId ?? skuId;
290 if (!sameText(observedSku, skuId)) {
291 current = yield* licensing.patchLicenseAssignments({
292 productId,
293 skuId: observedSku,
294 userId: current.userId ?? userId,
295 body: { skuId },
296 });
297 }
298
299 return toAttrs(current, env.project, customerId);
300 }),
301
302 delete: Effect.fn(function* ({ output }) {
303 yield* deleteAssignment(output.productId, output.skuId, output.userId);
304 }),
305 });