Skip to content

Commit 311c6cc

Browse files
Ekaterina BulatovaEkaterina Bulatova
authored andcommitted
perf(webapp): fetch and decrypt only non-secret env var values
The Environment Variables page presenter loaded the entire project secret store via a prefix scan and decrypted every value on each render — including secret values that are immediately masked in the UI — then matched rows with nested O(N×M²) `.find()` lookups. - Collect only the non-secret (environmentId, key) pairs and fetch them with a targeted `key IN (...)` query; decrypt only those. - Add `getSecretsByKeys` to the secret store and `getVariableValuesForKeys` to the repository for this access path. - Replace the nested `.find()` lookups with O(1) Map lookups keyed by `${environmentId}:${key}`. Cuts per-render decryption and server CPU for projects with many variables and environments; secret values stay masked as before.
1 parent d1f4302 commit 311c6cc

7 files changed

Lines changed: 485 additions & 48 deletions

File tree

apps/webapp/app/presenters/v3/EnvironmentVariablesPresenter.server.ts

Lines changed: 25 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -136,7 +136,21 @@ export class EnvironmentVariablesPresenter {
136136
);
137137

138138
const repository = new EnvironmentVariablesRepository(this.#prismaClient);
139-
const variables = await repository.getProject(project.id);
139+
140+
const nonSecretItems: Array<{ environmentId: string; key: string }> = [];
141+
for (const environmentVariable of environmentVariables) {
142+
for (const env of sortedEnvironments) {
143+
const valueRecord = environmentVariable.values.find((v) => v.environmentId === env.id);
144+
if (valueRecord && !valueRecord.isSecret) {
145+
nonSecretItems.push({ environmentId: env.id, key: environmentVariable.key });
146+
}
147+
}
148+
}
149+
150+
const variableValuesByEnvAndKey = await repository.getVariableValuesForKeys(
151+
project.id,
152+
nonSecretItems
153+
);
140154

141155
// Get Vercel integration data if it exists
142156
const vercelService = new VercelIntegrationService(this.#prismaClient);
@@ -153,14 +167,19 @@ export class EnvironmentVariablesPresenter {
153167
return {
154168
environmentVariables: environmentVariables
155169
.flatMap((environmentVariable) => {
156-
const variable = variables.find((v) => v.key === environmentVariable.key);
157-
158170
return sortedEnvironments.flatMap((env) => {
159-
const val = variable?.values.find((v) => v.environment.id === env.id);
160171
const valueRecord = environmentVariable.values.find((v) => v.environmentId === env.id);
161172
const isSecret = valueRecord?.isSecret ?? false;
162173

163-
if (!val || !valueRecord) {
174+
if (!valueRecord) {
175+
return [];
176+
}
177+
178+
const val = isSecret
179+
? undefined
180+
: variableValuesByEnvAndKey.get(`${env.id}:${environmentVariable.key}`);
181+
182+
if (!isSecret && val === undefined) {
164183
return [];
165184
}
166185

@@ -185,7 +204,7 @@ export class EnvironmentVariablesPresenter {
185204
id: environmentVariable.id,
186205
key: environmentVariable.key,
187206
environment: { type: env.type, id: env.id, branchName: env.branchName },
188-
value: isSecret ? "" : val.value,
207+
value: isSecret ? "" : val!,
189208
isSecret,
190209
version: valueRecord.version,
191210
lastUpdatedBy,

apps/webapp/app/services/secrets/secretStore.server.ts

Lines changed: 62 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ type ProviderInitializationOptions = {
1818
export interface SecretStoreProvider {
1919
getSecret<T>(schema: z.Schema<T>, key: string): Promise<T | undefined>;
2020
getSecrets<T>(schema: z.Schema<T>, keyPrefix: string): Promise<{ key: string; value: T }[]>;
21+
getSecretsByKeys<T>(schema: z.Schema<T>, keys: string[]): Promise<{ key: string; value: T }[]>;
2122
setSecret<T extends object>(key: string, value: T): Promise<void>;
2223
deleteSecret(key: string): Promise<void>;
2324
}
@@ -48,6 +49,10 @@ export class SecretStore {
4849
return this.provider.getSecrets(schema, keyPrefix);
4950
}
5051

52+
getSecretsByKeys<T>(schema: z.Schema<T>, keys: string[]): Promise<{ key: string; value: T }[]> {
53+
return this.provider.getSecretsByKeys(schema, keys);
54+
}
55+
5156
deleteSecret(key: string): Promise<void> {
5257
return this.provider.deleteSecret(key);
5358
}
@@ -83,29 +88,7 @@ class PrismaSecretStore implements SecretStoreProvider {
8388
return undefined;
8489
}
8590

86-
if (secret.version === "1") {
87-
return schema.parse(secret.value);
88-
}
89-
90-
const encryptedData = EncryptedSecretValueSchema.safeParse(secret.value);
91-
92-
if (!encryptedData.success) {
93-
throw new Error(`Unable to parse encrypted secret ${key}: ${encryptedData.error.message}`);
94-
}
95-
96-
const decrypted = await this.#decrypt(
97-
encryptedData.data.nonce,
98-
encryptedData.data.ciphertext,
99-
encryptedData.data.tag
100-
);
101-
102-
const parsedDecrypted = safeJsonParse(decrypted);
103-
104-
if (!parsedDecrypted) {
105-
return;
106-
}
107-
108-
return schema.parse(parsedDecrypted);
91+
return this.#parseStoredSecret(schema, secret);
10992
}
11093

11194
async getSecrets<T>(
@@ -120,37 +103,74 @@ class PrismaSecretStore implements SecretStoreProvider {
120103
},
121104
});
122105

106+
return this.#parseStoredSecrets(schema, secrets);
107+
}
108+
109+
async getSecretsByKeys<T>(
110+
schema: z.Schema<T>,
111+
keys: string[]
112+
): Promise<{ key: string; value: T }[]> {
113+
if (keys.length === 0) {
114+
return [];
115+
}
116+
117+
const secrets = await this.#prismaClient.secretStore.findMany({
118+
where: {
119+
key: {
120+
in: keys,
121+
},
122+
},
123+
});
124+
125+
return this.#parseStoredSecrets(schema, secrets);
126+
}
127+
128+
async #parseStoredSecrets<T>(
129+
schema: z.Schema<T>,
130+
secrets: Array<{ key: string; value: unknown; version: string }>
131+
): Promise<{ key: string; value: T }[]> {
123132
const results = [] as { key: string; value: T }[];
124133

125134
for (const secret of secrets) {
126-
if (secret.version === "1") {
127-
results.push({ key: secret.key, value: schema.parse(secret.value) });
135+
const value = await this.#parseStoredSecret(schema, secret);
136+
if (value !== undefined) {
137+
results.push({ key: secret.key, value });
128138
}
139+
}
129140

130-
const encryptedData = EncryptedSecretValueSchema.safeParse(secret.value);
141+
return results;
142+
}
131143

132-
if (!encryptedData.success) {
133-
throw new Error(
134-
`Unable to parse encrypted secret ${secret.key}: ${encryptedData.error.message}`
135-
);
136-
}
144+
async #parseStoredSecret<T>(
145+
schema: z.Schema<T>,
146+
secret: { key: string; value: unknown; version: string }
147+
): Promise<T | undefined> {
148+
if (secret.version === "1") {
149+
return schema.parse(secret.value);
150+
}
137151

138-
const decrypted = await this.#decrypt(
139-
encryptedData.data.nonce,
140-
encryptedData.data.ciphertext,
141-
encryptedData.data.tag
152+
const encryptedData = EncryptedSecretValueSchema.safeParse(secret.value);
153+
154+
if (!encryptedData.success) {
155+
throw new Error(
156+
`Unable to parse encrypted secret ${secret.key}: ${encryptedData.error.message}`
142157
);
158+
}
143159

144-
const parsedDecrypted = safeJsonParse(decrypted);
145-
if (!parsedDecrypted) {
146-
logger.error(`Secret isn't JSON ${secret.key}`);
147-
continue;
148-
}
160+
const decrypted = await this.#decrypt(
161+
encryptedData.data.nonce,
162+
encryptedData.data.ciphertext,
163+
encryptedData.data.tag
164+
);
165+
166+
const parsedDecrypted = safeJsonParse(decrypted);
149167

150-
results.push({ key: secret.key, value: schema.parse(parsedDecrypted) });
168+
if (!parsedDecrypted) {
169+
logger.error(`Secret isn't JSON ${secret.key}`);
170+
return undefined;
151171
}
152172

153-
return results;
173+
return schema.parse(parsedDecrypted);
154174
}
155175

156176
async setSecret<T extends object>(key: string, value: T): Promise<void> {

apps/webapp/app/v3/environmentVariables/environmentVariablesRepository.server.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -577,6 +577,42 @@ export class EnvironmentVariablesRepository implements Repository {
577577
return results;
578578
}
579579

580+
async getVariableValuesForKeys(
581+
projectId: string,
582+
items: Array<{ environmentId: string; key: string }>
583+
): Promise<Map<string, string>> {
584+
if (items.length === 0) {
585+
return new Map();
586+
}
587+
588+
const uniqueItems = new Map<string, { environmentId: string; key: string }>();
589+
for (const item of items) {
590+
uniqueItems.set(`${item.environmentId}:${item.key}`, item);
591+
}
592+
593+
const secretStore = getSecretStore("DATABASE", {
594+
prismaClient: this.prismaClient,
595+
});
596+
597+
const storeKeys = Array.from(uniqueItems.values()).map((item) =>
598+
secretKey(projectId, item.environmentId, item.key)
599+
);
600+
601+
const secrets = await secretStore.getSecretsByKeys(SecretValue, storeKeys);
602+
const secretsByStoreKey = new Map(secrets.map((secret) => [secret.key, secret.value.secret]));
603+
604+
const values = new Map<string, string>();
605+
for (const item of uniqueItems.values()) {
606+
const storeKey = secretKey(projectId, item.environmentId, item.key);
607+
const value = secretsByStoreKey.get(storeKey);
608+
if (value !== undefined) {
609+
values.set(`${item.environmentId}:${item.key}`, value);
610+
}
611+
}
612+
613+
return values;
614+
}
615+
580616
async getEnvironmentWithRedactedSecrets(
581617
projectId: string,
582618
environmentId: string,

apps/webapp/app/v3/environmentVariables/repository.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,14 @@ export interface Repository {
106106
edit(projectId: string, options: EditEnvironmentVariable): Promise<Result>;
107107
editValue(projectId: string, options: EditEnvironmentVariableValue): Promise<Result>;
108108
getProject(projectId: string): Promise<ProjectEnvironmentVariable[]>;
109+
/**
110+
* Fetch and decrypt only the given env var values (for dashboard display of non-secret rows).
111+
* Map keys are `${environmentId}:${variableKey}`.
112+
*/
113+
getVariableValuesForKeys(
114+
projectId: string,
115+
items: Array<{ environmentId: string; key: string }>
116+
): Promise<Map<string, string>>;
109117
/**
110118
* Get the environment variables for a given environment, it does NOT return values for secret variables
111119
*/
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import { describe, expect, vi } from "vitest";
2+
3+
vi.mock("~/db.server", () => ({
4+
prisma: {},
5+
$replica: {},
6+
$transaction: async (
7+
prismaClient: {
8+
$transaction: (fn: (tx: unknown) => Promise<unknown>) => Promise<unknown>;
9+
},
10+
nameOrFn: string | ((tx: unknown) => Promise<unknown>),
11+
fnOrOptions?: ((tx: unknown) => Promise<unknown>) | unknown
12+
) => {
13+
const fn =
14+
typeof nameOrFn === "string"
15+
? (fnOrOptions as (tx: unknown) => Promise<unknown>)
16+
: nameOrFn;
17+
18+
return prismaClient.$transaction(fn);
19+
},
20+
}));
21+
22+
import { postgresTest } from "@internal/testcontainers";
23+
import { EnvironmentVariablesPresenter } from "~/presenters/v3/EnvironmentVariablesPresenter.server";
24+
import { EnvironmentVariablesRepository } from "~/v3/environmentVariables/environmentVariablesRepository.server";
25+
import {
26+
createEnvironmentVariable,
27+
createRuntimeEnvironment,
28+
createTestOrgProjectWithMember,
29+
} from "./fixtures/environmentVariablesFixtures";
30+
31+
vi.setConfig({ testTimeout: 60_000 });
32+
33+
describe("EnvironmentVariablesPresenter", () => {
34+
postgresTest("keeps secret values redacted while returning non-secret values", async ({ prisma }) => {
35+
const { user, organization, project, projectSlug } = await createTestOrgProjectWithMember(prisma);
36+
const production = await createRuntimeEnvironment(prisma, {
37+
projectId: project.id,
38+
organizationId: organization.id,
39+
type: "PRODUCTION",
40+
});
41+
42+
const repository = new EnvironmentVariablesRepository(prisma, prisma);
43+
44+
await createEnvironmentVariable(repository, project.id, {
45+
environmentId: production.id,
46+
key: "SECRET_VAR",
47+
value: "super-secret",
48+
isSecret: true,
49+
userId: user.id,
50+
});
51+
await createEnvironmentVariable(repository, project.id, {
52+
environmentId: production.id,
53+
key: "PLAIN_VAR",
54+
value: "plain-value",
55+
isSecret: false,
56+
userId: user.id,
57+
});
58+
59+
const result = await new EnvironmentVariablesPresenter(prisma).call({
60+
userId: user.id,
61+
projectSlug,
62+
});
63+
64+
const secretVariable = result.environmentVariables.find((variable) => variable.key === "SECRET_VAR");
65+
const nonSecretVariable = result.environmentVariables.find((variable) => variable.key === "PLAIN_VAR");
66+
67+
expect(secretVariable).toBeDefined();
68+
expect(nonSecretVariable).toBeDefined();
69+
expect(secretVariable!.value).toBe("");
70+
expect(nonSecretVariable!.value).toBe("plain-value");
71+
});
72+
});

0 commit comments

Comments
 (0)