| 1 | import * as GCP from "@/GCP"; |
| 2 | import * as Test from "@/Test/Alchemy"; |
| 3 | import * as Core from "@/Test/Core"; |
| 4 | import * as secretmanager from "@distilled.cloud/gcp/secretmanager_v1"; |
| 5 | import { describe, expect } from "alchemy-test"; |
| 6 | import * as Effect from "effect/Effect"; |
| 7 | import { dockerAvailable, expectProbe } from "../bindingHost.ts"; |
| 8 | import SecretManagerBindingsHost, { |
| 9 | OpsSecret, |
| 10 | ReadOnlySecret, |
| 11 | ReadWriteOnlySecret, |
| 12 | RegionalSecret, |
| 13 | SEEDED, |
| 14 | WriteOnlySecret, |
| 15 | } from "./fixtures/bindings-host.ts"; |
| 16 | |
| 17 | const testOptions = { providers: GCP.providers() }; |
| 18 | const { test, beforeAll, afterAll } = Test.make(testOptions); |
| 19 | const sharedStack = Core.scratchStack(testOptions, "SecretManagerBindings"); |
| 20 | |
| 21 | let baseUrl: string; |
| 22 | let hostAccount: string; |
| 23 | let secrets: { |
| 24 | ops: string; |
| 25 | regional: string; |
| 26 | readOnly: string; |
| 27 | writeOnly: string; |
| 28 | readWrite: string; |
| 29 | }; |
| 30 | |
| 31 | const hostRoles = (policy: secretmanager.Policy) => |
| 32 | (policy.bindings ?? []) |
| 33 | .filter((binding) => |
| 34 | (binding.members ?? []).includes(`serviceAccount:${hostAccount}`), |
| 35 | ) |
| 36 | .map((binding) => binding.role) |
| 37 | .sort(); |
| 38 | |
| 39 | /** Roles the host's service account holds on a global secret. */ |
| 40 | const secretRoles = (name: string) => |
| 41 | secretmanager |
| 42 | .getIamPolicyProjectsSecrets({ resource: name }) |
| 43 | .pipe(Effect.map(hostRoles)); |
| 44 | |
| 45 | /** Roles the host's service account holds on a regional secret. */ |
| 46 | const regionalSecretRoles = (name: string) => |
| 47 | secretmanager |
| 48 | .getIamPolicyProjectsLocationsSecrets({ resource: name }) |
| 49 | .pipe(Effect.map(hostRoles)); |
| 50 | |
| 51 | /** Every version of a secret with its state, read out of band. */ |
| 52 | const versionStates = (parent: string) => |
| 53 | Effect.gen(function* () { |
| 54 | const states: Record<string, string | undefined> = {}; |
| 55 | let pageToken: string | undefined; |
| 56 | do { |
| 57 | const page = yield* secretmanager.listProjectsSecretsVersions({ |
| 58 | parent, |
| 59 | pageToken, |
| 60 | }); |
| 61 | for (const version of page.versions ?? []) { |
| 62 | if (version.name) states[version.name] = version.state; |
| 63 | } |
| 64 | pageToken = page.nextPageToken; |
| 65 | } while (pageToken); |
| 66 | return states; |
| 67 | }); |
| 68 | |
| 69 | describe.skipIf(!dockerAvailable)( |
| 70 | "SecretManager Bindings", |
| 71 | { |
| 72 | tags: [ |
| 73 | "provider:gcp", |
| 74 | "provider:gcp:secretmanager", |
| 75 | "provider:gcp:run", |
| 76 | "live", |
| 77 | ], |
| 78 | }, |
| 79 | () => { |
| 80 | beforeAll( |
| 81 | Effect.gen(function* () { |
| 82 | yield* sharedStack.destroy(); |
| 83 | const out = yield* sharedStack.deploy( |
| 84 | Effect.gen(function* () { |
| 85 | const host = yield* SecretManagerBindingsHost; |
| 86 | return { |
| 87 | uri: host.uri, |
| 88 | serviceAccount: host.serviceAccount, |
| 89 | ops: (yield* OpsSecret).name, |
| 90 | regional: (yield* RegionalSecret).name, |
| 91 | readOnly: (yield* ReadOnlySecret).name, |
| 92 | writeOnly: (yield* WriteOnlySecret).name, |
| 93 | readWrite: (yield* ReadWriteOnlySecret).name, |
| 94 | }; |
| 95 | }), |
| 96 | ); |
| 97 | baseUrl = out.uri!; |
| 98 | hostAccount = out.serviceAccount!; |
| 99 | secrets = { |
| 100 | ops: out.ops, |
| 101 | regional: out.regional, |
| 102 | readOnly: out.readOnly, |
| 103 | writeOnly: out.writeOnly, |
| 104 | readWrite: out.readWrite, |
| 105 | }; |
| 106 | }), |
| 107 | { timeout: 900_000 }, |
| 108 | ); |
| 109 | |
| 110 | afterAll(sharedStack.destroy(), { timeout: 600_000 }); |
| 111 | |
| 112 | describe("AddSecretVersion / AccessSecretVersion", () => { |
| 113 | test.provider( |
| 114 | "round-trip a global secret as the host's service account, granted on the secret only", |
| 115 | (_stack) => |
| 116 | Effect.gen(function* () { |
| 117 | const out = yield* expectProbe<{ |
| 118 | version: string; |
| 119 | accessedName: string; |
| 120 | data: string; |
| 121 | payload: string; |
| 122 | }>(baseUrl, "ops"); |
| 123 | expect(out.version.startsWith(`${secrets.ops}/versions/`)).toBe( |
| 124 | true, |
| 125 | ); |
| 126 | expect(out.accessedName).toEqual(out.version); |
| 127 | expect(out.data).toEqual(out.payload); |
| 128 | |
| 129 | const states = yield* versionStates(secrets.ops); |
| 130 | expect(states[out.version]).toEqual("ENABLED"); |
| 131 | |
| 132 | expect(yield* secretRoles(secrets.ops)).toEqual([ |
| 133 | "roles/secretmanager.secretAccessor", |
| 134 | "roles/secretmanager.secretVersionAdder", |
| 135 | ]); |
| 136 | }), |
| 137 | { |
| 138 | tags: ["provider:gcp", "provider:gcp:secretmanager", "live"], |
| 139 | timeout: 600_000, |
| 140 | }, |
| 141 | ); |
| 142 | |
| 143 | test.provider( |
| 144 | "round-trip a regional secret as the host's service account, granted on the secret only", |
| 145 | (_stack) => |
| 146 | Effect.gen(function* () { |
| 147 | const out = yield* expectProbe<{ |
| 148 | version: string; |
| 149 | accessedName: string; |
| 150 | data: string; |
| 151 | payload: string; |
| 152 | }>(baseUrl, "opsRegional"); |
| 153 | expect( |
| 154 | out.version.startsWith(`${secrets.regional}/versions/`), |
| 155 | ).toBe(true); |
| 156 | expect(out.version).toContain("/locations/us-central1/secrets/"); |
| 157 | expect(out.accessedName).toEqual(out.version); |
| 158 | expect(out.data).toEqual(out.payload); |
| 159 | |
| 160 | expect(yield* regionalSecretRoles(secrets.regional)).toEqual([ |
| 161 | "roles/secretmanager.secretAccessor", |
| 162 | "roles/secretmanager.secretVersionAdder", |
| 163 | ]); |
| 164 | }), |
| 165 | { |
| 166 | tags: ["provider:gcp", "provider:gcp:secretmanager", "live"], |
| 167 | timeout: 600_000, |
| 168 | }, |
| 169 | ); |
| 170 | }); |
| 171 | |
| 172 | describe("ReadSecret", () => { |
| 173 | test.provider( |
| 174 | "reads a seeded version as the host's service account, granted secretAccessor on the secret only", |
| 175 | (_stack) => |
| 176 | Effect.gen(function* () { |
| 177 | yield* secretmanager.addVersionProjectsSecrets({ |
| 178 | parent: secrets.readOnly, |
| 179 | body: { |
| 180 | payload: { data: btoa(SEEDED) }, |
| 181 | }, |
| 182 | }); |
| 183 | const out = yield* expectProbe<{ |
| 184 | latest: string; |
| 185 | bytes: string; |
| 186 | missing: boolean; |
| 187 | }>(baseUrl, "read"); |
| 188 | expect(out).toEqual({ |
| 189 | latest: SEEDED, |
| 190 | bytes: SEEDED, |
| 191 | missing: true, |
| 192 | }); |
| 193 | |
| 194 | expect(yield* secretRoles(secrets.readOnly)).toEqual([ |
| 195 | "roles/secretmanager.secretAccessor", |
| 196 | ]); |
| 197 | }), |
| 198 | { |
| 199 | tags: ["provider:gcp", "provider:gcp:secretmanager", "live"], |
| 200 | timeout: 600_000, |
| 201 | }, |
| 202 | ); |
| 203 | }); |
| 204 | |
| 205 | describe("WriteSecret", () => { |
| 206 | test.provider( |
| 207 | "adds, disables and destroys versions as the host's service account, granted secretVersionManager on the secret only", |
| 208 | (_stack) => |
| 209 | Effect.gen(function* () { |
| 210 | const out = yield* expectProbe<{ v1: string; v2: string }>( |
| 211 | baseUrl, |
| 212 | "write", |
| 213 | ); |
| 214 | expect(out.v1.startsWith(`${secrets.writeOnly}/versions/`)).toBe( |
| 215 | true, |
| 216 | ); |
| 217 | expect(out.v2.startsWith(`${secrets.writeOnly}/versions/`)).toBe( |
| 218 | true, |
| 219 | ); |
| 220 | |
| 221 | const states = yield* versionStates(secrets.writeOnly); |
| 222 | expect(states[out.v1]).toEqual("DESTROYED"); |
| 223 | expect(states[out.v2]).toEqual("DISABLED"); |
| 224 | |
| 225 | expect(yield* secretRoles(secrets.writeOnly)).toEqual([ |
| 226 | "roles/secretmanager.secretVersionManager", |
| 227 | ]); |
| 228 | }), |
| 229 | { |
| 230 | tags: ["provider:gcp", "provider:gcp:secretmanager", "live"], |
| 231 | timeout: 600_000, |
| 232 | }, |
| 233 | ); |
| 234 | }); |
| 235 | |
| 236 | describe("ReadWriteSecret", () => { |
| 237 | test.provider( |
| 238 | "rotates and reads back as the host's service account, granted accessor + versionManager on the secret only", |
| 239 | (_stack) => |
| 240 | Effect.gen(function* () { |
| 241 | const out = yield* expectProbe<{ |
| 242 | v1: string; |
| 243 | v2: string; |
| 244 | latest: string; |
| 245 | first: string; |
| 246 | disabledReadsUndefined: boolean; |
| 247 | destroyedReadsUndefined: boolean; |
| 248 | }>(baseUrl, "readWrite"); |
| 249 | expect(out).toEqual({ |
| 250 | v1: expect.stringContaining(`${secrets.readWrite}/versions/`), |
| 251 | v2: expect.stringContaining(`${secrets.readWrite}/versions/`), |
| 252 | latest: "two", |
| 253 | first: "one", |
| 254 | disabledReadsUndefined: true, |
| 255 | destroyedReadsUndefined: true, |
| 256 | }); |
| 257 | |
| 258 | const states = yield* versionStates(secrets.readWrite); |
| 259 | expect(states[out.v1]).toEqual("DESTROYED"); |
| 260 | expect(states[out.v2]).toEqual("DISABLED"); |
| 261 | |
| 262 | expect(yield* secretRoles(secrets.readWrite)).toEqual([ |
| 263 | "roles/secretmanager.secretAccessor", |
| 264 | "roles/secretmanager.secretVersionManager", |
| 265 | ]); |
| 266 | }), |
| 267 | { |
| 268 | tags: ["provider:gcp", "provider:gcp:secretmanager", "live"], |
| 269 | timeout: 600_000, |
| 270 | }, |
| 271 | ); |
| 272 | }); |
| 273 | }, |
| 274 | ); |