1import * as storage from "@distilled.cloud/gcp/storage_v1";
2import * as Data from "effect/Data";
3import * as Effect from "effect/Effect";
4import * as Redacted from "effect/Redacted";
5import * as Schedule from "effect/Schedule";
6import * as Stream from "effect/Stream";
7import { isResolved } from "../../Diff.ts";
8import * as Provider from "../../Provider.ts";
9import { Resource } from "../../Resource.ts";
10import { GcpEnvironment } from "../Environment.ts";
11import type { Providers } from "../Providers.ts";
12
13export type HmacKeyState = "ACTIVE" | "INACTIVE";
14
15const DEFAULT_STATE: HmacKeyState = "ACTIVE";
16
17export type HmacKeyProps = {
18 /**
19 * Email of the service account this HMAC key authenticates. Immutable —
20 * changing it replaces the key. The service account must belong to the
21 * same project.
22 */
23 serviceAccountEmail: string;
24 /**
25 * Desired key state. Newly created keys are always `ACTIVE`; this value
26 * is applied after create. An HMAC key must be `INACTIVE` before it can
27 * be deleted.
28 * @default "ACTIVE"
29 */
30 state?: HmacKeyState;
31};
32
33export type HmacKey = Resource<
34 "GCP.Storage.HmacKey",
35 HmacKeyProps,
36 {
37 /** Access id assigned by Cloud Storage; the API id for get/update/delete. */
38 accessId: string;
39 /**
40 * Composite HMAC key id (`{projectId}/{accessId}`), when the API
41 * returns one.
42 */
43 id: string | undefined;
44 /** Project id that owns the service account. */
45 projectId: string;
46 /** Email of the service account this key authenticates. */
47 serviceAccountEmail: string;
48 /** Key state (`ACTIVE` or `INACTIVE`). */
49 state: HmacKeyState;
50 /** RFC3339 creation timestamp. */
51 timeCreated: string | undefined;
52 /** RFC3339 last-update timestamp. */
53 updated: string | undefined;
54 /** GCS self-link. */
55 selfLink: string | undefined;
56 /** HTTP etag used for concurrent updates. */
57 etag: string | undefined;
58 /**
59 * HMAC secret. Cloud Storage returns it only at create time; later
60 * reads preserve the originally stored redacted value. Never logged.
61 */
62 secret: Redacted.Redacted<string> | undefined;
63 },
64 never,
65 Providers
66>;
67
68/**
69 * A Cloud Storage HMAC key for a service account.
70 *
71 * HMAC keys authenticate S3-compatible interoperable requests. Cloud
72 * Storage returns the secret once at create time; Alchemy stores it
73 * redacted and never re-reads it. Keys have no labels field, so `list`
74 * enumerates every HMAC key in the project (excluding `DELETED`) for
75 * `pnpm nuke:gcp`. A service account may have at most ten keys in
76 * `ACTIVE` or `INACTIVE` state. Changing `serviceAccountEmail` replaces
77 * the key. Destroy deactivates the key, then deletes it.
78 *
79 * ### Creating an HMAC Key
80 * **Example:** Active key for a service account
81 * ```typescript
82 * const key = yield* GCP.Storage.HmacKey("interop", {
83 * serviceAccountEmail: "app@project.iam.gserviceaccount.com",
84 * });
85 * ```
86 *
87 * **Example:** Inactive key
88 * ```typescript
89 * const key = yield* GCP.Storage.HmacKey("interop", {
90 * serviceAccountEmail: "app@project.iam.gserviceaccount.com",
91 * state: "INACTIVE",
92 * });
93 * ```
94 *
95 * @resource
96 * @category Storage
97 */
98export const HmacKey = Resource<HmacKey>("GCP.Storage.HmacKey");
99
100export class HmacKeyNotResolved extends Data.TaggedError(
101 "GCP.Storage.HmacKeyNotResolved",
102)<{
103 projectId: string;
104 serviceAccountEmail: string;
105 accessId?: string;
106}> {}
107
108export class HmacKeyStillExists extends Data.TaggedError(
109 "GCP.Storage.HmacKeyStillExists",
110)<{
111 projectId: string;
112 accessId: string;
113}> {}
114
115const sameEmail = (left: string, right: string) =>
116 left.trim().toLowerCase() === right.trim().toLowerCase();
117
118const normalizeState = (state: string | undefined): HmacKeyState =>
119 state?.toUpperCase() === "INACTIVE" ? "INACTIVE" : DEFAULT_STATE;
120
121const toSecret = (
122 value: string | Redacted.Redacted<string> | undefined,
123): Redacted.Redacted<string> | undefined =>
124 value === undefined
125 ? undefined
126 : typeof value === "string"
127 ? Redacted.make(value)
128 : value;
129
130const toAttrs = (
131 metadata: storage.HmacKeyMetadata,
132 projectId: string,
133 secret?: Redacted.Redacted<string>,
134): HmacKey["Attributes"] => ({
135 accessId: metadata.accessId ?? "",
136 id: metadata.id,
137 projectId: metadata.projectId ?? projectId,
138 serviceAccountEmail: metadata.serviceAccountEmail ?? "",
139 state: normalizeState(metadata.state),
140 timeCreated: metadata.timeCreated,
141 updated: metadata.updated,
142 selfLink: metadata.selfLink,
143 etag: metadata.etag,
144 secret,
145});
146
147const isDeleted = (metadata: storage.HmacKeyMetadata | undefined) =>
148 metadata === undefined || metadata.state?.toUpperCase() === "DELETED";
149
150const getByAccessId = (projectId: string, accessId: string) =>
151 storage.getProjectsHmacKeys({ projectId, accessId }).pipe(
152 Effect.map((metadata) => (isDeleted(metadata) ? undefined : metadata)),
153 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
154 );
155
156const waitUntilGone = (projectId: string, accessId: string) =>
157 getByAccessId(projectId, accessId).pipe(
158 Effect.flatMap((existing) =>
159 existing === undefined
160 ? Effect.void
161 : Effect.fail(new HmacKeyStillExists({ projectId, accessId })),
162 ),
163 Effect.retry({
164 while: (error) => error._tag === "GCP.Storage.HmacKeyStillExists",
165 times: 10,
166 schedule: Schedule.spaced("1 second"),
167 }),
168 );
169
170const deactivateThenDelete = (projectId: string, accessId: string) =>
171 Effect.gen(function* () {
172 const current = yield* getByAccessId(projectId, accessId);
173 if (current === undefined) return;
174 if (normalizeState(current.state) === "ACTIVE") {
175 yield* storage
176 .updateProjectsHmacKeys({
177 projectId,
178 accessId,
179 body: {
180 accessId,
181 state: "INACTIVE",
182 etag: current.etag,
183 },
184 })
185 .pipe(
186 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
187 Effect.catchTag("Conflict", () => Effect.succeed(undefined)),
188 );
189 }
190 yield* storage
191 .deleteProjectsHmacKeys({ projectId, accessId })
192 .pipe(Effect.catchTag("NotFound", () => Effect.void));
193 }).pipe(
194 Effect.retry({
195 while: (error) =>
196 error._tag === "BadRequest" || error._tag === "Conflict",
197 times: 8,
198 schedule: Schedule.spaced("1 second"),
199 }),
200 );
201
202export const HmacKeyProvider = () =>
203 Provider.succeed(HmacKey, {
204 stables: [
205 "accessId",
206 "id",
207 "projectId",
208 "serviceAccountEmail",
209 "timeCreated",
210 "selfLink",
211 "secret",
212 ],
213
214 diff: Effect.fn(function* ({ news, olds, output }) {
215 if (!isResolved(news)) return undefined;
216 const previous = olds?.serviceAccountEmail ?? output?.serviceAccountEmail;
217 if (
218 previous !== undefined &&
219 !sameEmail(previous, news.serviceAccountEmail)
220 ) {
221 return { action: "replace" as const, deleteFirst: false };
222 }
223 return undefined;
224 }),
225
226 read: Effect.fn(function* ({ output }) {
227 if (output?.accessId === undefined || output.accessId.length === 0) {
228 return undefined;
229 }
230 const env = yield* GcpEnvironment.current;
231 const projectId = output.projectId || env.project;
232 const existing = yield* getByAccessId(projectId, output.accessId);
233 if (existing === undefined) return undefined;
234 return toAttrs(existing, projectId, output.secret);
235 }),
236
237 list: () =>
238 Effect.gen(function* () {
239 const env = yield* GcpEnvironment.current;
240 return yield* storage.listProjectsHmacKeys
241 .items({
242 projectId: env.project,
243 maxResults: 250,
244 })
245 .pipe(
246 Stream.filter(
247 (item) =>
248 !!item.accessId && item.state?.toUpperCase() !== "DELETED",
249 ),
250 Stream.map((item) => toAttrs(item, env.project)),
251 Stream.runCollect,
252 Effect.map((chunk) => Array.from(chunk)),
253 );
254 }),
255
256 reconcile: Effect.fn(function* ({ news, output }) {
257 const env = yield* GcpEnvironment.current;
258 const projectId = env.project;
259 const serviceAccountEmail = news.serviceAccountEmail;
260 const desiredState = news.state ?? DEFAULT_STATE;
261
262 let current =
263 output?.accessId !== undefined && output.accessId.length > 0
264 ? yield* getByAccessId(projectId, output.accessId)
265 : undefined;
266 let secret = output?.secret;
267
268 if (current === undefined) {
269 const created = yield* storage.createProjectsHmacKeys({
270 projectId,
271 serviceAccountEmail,
272 });
273 current = created.metadata;
274 secret = toSecret(created.secret) ?? secret;
275 }
276
277 if (current === undefined || !current.accessId) {
278 return yield* new HmacKeyNotResolved({
279 projectId,
280 serviceAccountEmail,
281 accessId: output?.accessId,
282 });
283 }
284
285 if (normalizeState(current.state) !== desiredState) {
286 const accessId = current.accessId;
287 current = yield* Effect.gen(function* () {
288 const latest = yield* getByAccessId(projectId, accessId);
289 if (latest === undefined) {
290 return yield* new HmacKeyNotResolved({
291 projectId,
292 serviceAccountEmail,
293 accessId,
294 });
295 }
296 if (normalizeState(latest.state) === desiredState) {
297 return latest;
298 }
299 return yield* storage.updateProjectsHmacKeys({
300 projectId,
301 accessId,
302 body: {
303 accessId,
304 state: desiredState,
305 etag: latest.etag,
306 },
307 });
308 }).pipe(
309 Effect.retry({
310 while: (error) => error._tag === "Conflict",
311 times: 8,
312 schedule: Schedule.spaced("1 second"),
313 }),
314 );
315 }
316
317 return toAttrs(current, projectId, secret);
318 }),
319
320 delete: Effect.fn(function* ({ output }) {
321 const projectId = output.projectId;
322 const accessId = output.accessId;
323 if (!projectId || !accessId) return;
324 yield* deactivateThenDelete(projectId, accessId);
325 yield* waitUntilGone(projectId, accessId);
326 }),
327 });