1import * as secretmanager from "@distilled.cloud/gcp/secretmanager_v1";
2import * as Effect from "effect/Effect";
3import { bindGcpHost } from "../Host.ts";
4import { grantFor, type BindingIam } from "../HttpBinding.ts";
5import type { SecretBindingTarget } from "./BindingHttp.ts";
6import type { ReadSecretClient } from "./ReadSecret.ts";
7import type { WriteSecretClient } from "./WriteSecret.ts";
8
9/**
10 * Shared HTTP scaffolding for the Secret Manager Read/Write/ReadWrite
11 * bindings. NOT exported from `index.ts`.
12 */
13
14export const readSecretGrants: BindingIam[] = [
15 { role: "roles/secretmanager.secretAccessor", on: "secretmanager.secret" },
16];
17
18export const writeSecretGrants: BindingIam[] = [
19 {
20 role: "roles/secretmanager.secretVersionManager",
21 on: "secretmanager.secret",
22 },
23];
24
25// No predefined role covers access + version management short of
26// secretmanager.admin, so ReadWrite grants both narrow roles.
27export const readWriteSecretGrants: BindingIam[] = [
28 ...readSecretGrants,
29 ...writeSecretGrants,
30];
31
32const versionName = (secretName: string, version: string) =>
33 version.includes("/versions/")
34 ? version
35 : `${secretName}/versions/${version}`;
36
37export const makeSecretHelpers = Effect.gen(function* () {
38 const access = yield* secretmanager.accessProjectsSecretsVersions;
39 const addVersion = yield* secretmanager.addVersionProjectsSecrets;
40 const disable = yield* secretmanager.disableProjectsSecretsVersions;
41 const destroy = yield* secretmanager.destroyProjectsSecretsVersions;
42
43 const makeRead = (secretName: Effect.Effect<string>): ReadSecretClient => {
44 const accessBytes = (version?: string) =>
45 Effect.gen(function* () {
46 const response = yield* access({
47 name: versionName(yield* secretName, version ?? "latest"),
48 });
49 const data = response.payload?.data ?? "";
50 return yield* Effect.sync(
51 () => new Uint8Array(Buffer.from(data, "base64")),
52 );
53 }).pipe(
54 // Missing, disabled, and destroyed versions have no readable payload.
55 Effect.catchTag(["NotFound", "SecretVersionNotEnabled"], () =>
56 Effect.succeed(undefined),
57 ),
58 );
59 return {
60 accessBytes,
61 access: (version) =>
62 accessBytes(version).pipe(
63 Effect.map((bytes) =>
64 bytes === undefined ? undefined : new TextDecoder().decode(bytes),
65 ),
66 ),
67 };
68 };
69
70 const makeWrite = (secretName: Effect.Effect<string>): WriteSecretClient => ({
71 addVersion: (value) =>
72 Effect.gen(function* () {
73 const data = yield* Effect.sync(() =>
74 (typeof value === "string"
75 ? Buffer.from(value, "utf8")
76 : Buffer.from(value.buffer, value.byteOffset, value.byteLength)
77 ).toString("base64"),
78 );
79 const version = yield* addVersion({
80 parent: yield* secretName,
81 body: { payload: { data } },
82 });
83 return version.name ?? "";
84 }),
85 disableVersion: (version) =>
86 Effect.gen(function* () {
87 yield* disable({ name: versionName(yield* secretName, version) });
88 }),
89 destroyVersion: (version) =>
90 Effect.gen(function* () {
91 yield* destroy({ name: versionName(yield* secretName, version) });
92 }).pipe(Effect.catchTag("NotFound", () => Effect.void)),
93 });
94
95 return { makeRead, makeWrite };
96});
97
98/** Build a secret binding that grants `grants` and returns `makeClient`'s client. */
99export const makeSecretAccessBinding = <Client>(options: {
100 tag: string;
101 grants: BindingIam[];
102 makeClient: (
103 helpers: Effect.Success<typeof makeSecretHelpers>,
104 secretName: Effect.Effect<string>,
105 ) => Client;
106}) =>
107 Effect.gen(function* () {
108 const helpers = yield* makeSecretHelpers;
109 return Effect.fn(function* (secret: SecretBindingTarget) {
110 // `name` is `projects/p/secrets/s` or, for regional secrets,
111 // `projects/p/locations/l/secrets/s`; both are secret IAM targets.
112 yield* bindGcpHost({
113 tag: options.tag,
114 resource: secret,
115 iam: options.grants.map((grant) => grantFor(grant, secret.name)),
116 });
117 const secretName = yield* secret.name;
118 return options.makeClient(helpers, secretName);
119 });
120 });