| 1 | import * as iamcredentials from "@distilled.cloud/gcp/iamcredentials_v1"; |
| 2 | import * as Clock from "effect/Clock"; |
| 3 | import * as Effect from "effect/Effect"; |
| 4 | import * as Layer from "effect/Layer"; |
| 5 | import * as HttpClient from "effect/http/HttpClient"; |
| 6 | import * as HttpClientRequest from "effect/http/HttpClientRequest"; |
| 7 | import { createHash } from "node:crypto"; |
| 8 | import { bindGcpHost, HOST_SERVICE_ACCOUNT } from "../Host.ts"; |
| 9 | import { grantFor } from "../HttpBinding.ts"; |
| 10 | import type { Bucket } from "./Bucket.ts"; |
| 11 | import { |
| 12 | SignedUrlFailed, |
| 13 | SignGetObjectUrl, |
| 14 | type SignGetObjectUrlRequest, |
| 15 | } from "./SignGetObjectUrl.ts"; |
| 16 | |
| 17 | const EMAIL_ENDPOINT = |
| 18 | "http://metadata.google.internal/computeMetadata/v1/instance/service-accounts/default/email"; |
| 19 | const STORAGE_HOST = "storage.googleapis.com"; |
| 20 | const DEFAULT_EXPIRES_IN = 900; |
| 21 | const MAX_EXPIRES_IN = 604_800; |
| 22 | |
| 23 | /** RFC 3986 encoding, as V4 signing requires. */ |
| 24 | const encode = (value: string) => |
| 25 | encodeURIComponent(value).replace( |
| 26 | /[!'()*]/g, |
| 27 | (char) => `%${char.charCodeAt(0).toString(16).toUpperCase()}`, |
| 28 | ); |
| 29 | |
| 30 | const sha256Hex = (value: string) => |
| 31 | createHash("sha256").update(value, "utf8").digest("hex"); |
| 32 | |
| 33 | /** `20260102T030405Z` and `20260102` for a Unix time in millis. */ |
| 34 | const timestamps = (millis: number) => { |
| 35 | const iso = new Date(millis).toISOString().replace(/[-:]/g, ""); |
| 36 | const datetime = `${iso.slice(0, 15)}Z`; |
| 37 | return { datetime, date: datetime.slice(0, 8) }; |
| 38 | }; |
| 39 | |
| 40 | /** |
| 41 | * HTTP implementation of {@link SignGetObjectUrl}: V4 signing where the |
| 42 | * signature comes from IAM Credentials `signBlob` as the runtime's own |
| 43 | * service account (read from the metadata server). |
| 44 | * |
| 45 | * @layer |
| 46 | * @provides GCP.Storage.SignGetObjectUrl |
| 47 | * @category Storage |
| 48 | */ |
| 49 | export const SignGetObjectUrlHttp = Layer.effect( |
| 50 | SignGetObjectUrl, |
| 51 | Effect.gen(function* () { |
| 52 | const http = yield* HttpClient.HttpClient; |
| 53 | const signBlob = yield* iamcredentials.signBlobProjectsServiceAccounts; |
| 54 | let ownEmail: string | undefined; |
| 55 | |
| 56 | const serviceAccountEmail = Effect.gen(function* () { |
| 57 | if (ownEmail !== undefined) return ownEmail; |
| 58 | const response = yield* http.execute( |
| 59 | HttpClientRequest.get(EMAIL_ENDPOINT).pipe( |
| 60 | HttpClientRequest.setHeader("Metadata-Flavor", "Google"), |
| 61 | ), |
| 62 | ); |
| 63 | const text = (yield* response.text).trim(); |
| 64 | if (response.status !== 200 || !text.includes("@")) { |
| 65 | return yield* new SignedUrlFailed({ |
| 66 | message: `GCE metadata email endpoint returned HTTP ${response.status}`, |
| 67 | }); |
| 68 | } |
| 69 | ownEmail = text; |
| 70 | return text; |
| 71 | }).pipe( |
| 72 | Effect.mapError((cause) => |
| 73 | cause instanceof SignedUrlFailed |
| 74 | ? cause |
| 75 | : new SignedUrlFailed({ |
| 76 | message: `GCE metadata email endpoint is unreachable: ${String(cause)}`, |
| 77 | cause, |
| 78 | }), |
| 79 | ), |
| 80 | ); |
| 81 | |
| 82 | return Effect.fn(function* (bucket: Bucket) { |
| 83 | yield* bindGcpHost({ |
| 84 | tag: "GCP.Storage.SignGetObjectUrl", |
| 85 | resource: bucket, |
| 86 | iam: [ |
| 87 | // A signed URL carries the signer's own object permissions. |
| 88 | grantFor( |
| 89 | { role: "roles/storage.objectViewer", on: "storage.bucket" }, |
| 90 | bucket.bucketName, |
| 91 | ), |
| 92 | // signBlob as itself, and on no other account. |
| 93 | { |
| 94 | role: "roles/iam.serviceAccountTokenCreator", |
| 95 | resource: { |
| 96 | kind: "iam.serviceAccount", |
| 97 | name: HOST_SERVICE_ACCOUNT, |
| 98 | }, |
| 99 | }, |
| 100 | ], |
| 101 | }); |
| 102 | const bucketName = yield* bucket.bucketName; |
| 103 | return Effect.fn(`GCP.Storage.SignGetObjectUrl(${bucket.LogicalId})`)( |
| 104 | function* (request: SignGetObjectUrlRequest) { |
| 105 | const email = yield* serviceAccountEmail; |
| 106 | const name = yield* bucketName; |
| 107 | const now = yield* Clock.currentTimeMillis; |
| 108 | const { datetime, date } = timestamps(now); |
| 109 | const scope = `${date}/auto/storage/goog4_request`; |
| 110 | const expiresIn = Math.min( |
| 111 | Math.max(Math.floor(request.expiresIn ?? DEFAULT_EXPIRES_IN), 1), |
| 112 | MAX_EXPIRES_IN, |
| 113 | ); |
| 114 | const params: Record<string, string> = { |
| 115 | "X-Goog-Algorithm": "GOOG4-RSA-SHA256", |
| 116 | "X-Goog-Credential": `${email}/${scope}`, |
| 117 | "X-Goog-Date": datetime, |
| 118 | "X-Goog-Expires": String(expiresIn), |
| 119 | "X-Goog-SignedHeaders": "host", |
| 120 | ...(request.generation !== undefined |
| 121 | ? { generation: request.generation } |
| 122 | : {}), |
| 123 | ...(request.contentType !== undefined |
| 124 | ? { "response-content-type": request.contentType } |
| 125 | : {}), |
| 126 | }; |
| 127 | const query = Object.keys(params) |
| 128 | .sort() |
| 129 | .map((key) => `${encode(key)}=${encode(params[key]!)}`) |
| 130 | .join("&"); |
| 131 | const path = `/${name}/${request.object |
| 132 | .split("/") |
| 133 | .map(encode) |
| 134 | .join("/")}`; |
| 135 | const canonicalRequest = [ |
| 136 | "GET", |
| 137 | path, |
| 138 | query, |
| 139 | `host:${STORAGE_HOST}`, |
| 140 | "", |
| 141 | "host", |
| 142 | "UNSIGNED-PAYLOAD", |
| 143 | ].join("\n"); |
| 144 | const stringToSign = [ |
| 145 | "GOOG4-RSA-SHA256", |
| 146 | datetime, |
| 147 | scope, |
| 148 | yield* Effect.sync(() => sha256Hex(canonicalRequest)), |
| 149 | ].join("\n"); |
| 150 | const signed = yield* signBlob({ |
| 151 | name: `projects/-/serviceAccounts/${email}`, |
| 152 | body: { |
| 153 | payload: yield* Effect.sync(() => |
| 154 | Buffer.from(stringToSign, "utf8").toString("base64"), |
| 155 | ), |
| 156 | }, |
| 157 | }); |
| 158 | if (signed.signedBlob === undefined) { |
| 159 | return yield* new SignedUrlFailed({ |
| 160 | message: "IAM Credentials signBlob returned no signature", |
| 161 | }); |
| 162 | } |
| 163 | const signature = yield* Effect.sync(() => |
| 164 | Buffer.from(signed.signedBlob!, "base64").toString("hex"), |
| 165 | ); |
| 166 | return `https://${STORAGE_HOST}${path}?${query}&X-Goog-Signature=${signature}`; |
| 167 | }, |
| 168 | ); |
| 169 | }); |
| 170 | }), |
| 171 | ); |