Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions .env.example
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
# env 단일 출처는 GCP Secret Manager. 평상시 로컬은 `bun run start:local`로 SM에서 받아옵니다.
# (최초 1회: gcloud auth application-default login)
# env 단일 출처는 GCP Secret Manager. 앱이 직접 가져오지 않고 APP_ENV_DOTENV로 주입받습니다.
# 로컬: bun run start:local (scripts/with-env.ts 가 ADC로 조회)
# prod: Cloud Run --set-secrets
# 최초 1회: gcloud auth application-default login
#
# 아래는 SM을 건너뛰는 탈출구(오프라인/전환기)입니다. APP_CONFIG_SOURCE=env 로 켜면 사용됩니다.
# 아래는 주입 없이 로컬 파일만으로 띄울 때 쓰는 탈출구입니다.

# APP_CONFIG_SOURCE=env

Expand Down
11 changes: 11 additions & 0 deletions .env.test
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
# 테스트 전용 더미 값

NODE_ENV=test

GOOGLE_CLOUD_PROJECT=team-mino-test
KAKAO_REST_API_KEY=test

INSTAGRAM_GRAPHQL_ENDPOINT=https://www.instagram.com/api/graphql
INSTAGRAM_DOC_ID=test
INSTAGRAM_APP_ID=test
INSTAGRAM_USER_AGENT=test
1 change: 1 addition & 0 deletions .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -181,5 +181,6 @@ jobs:
gcloud run services update team-mino-prod-api \
--image "$IMAGE" \
--set-env-vars "APP_ENV=prod,SENTRY_RELEASE=$SENTRY_RELEASE" \
--set-secrets "APP_ENV_DOTENV=team-mino-env-prod:latest" \
--region "asia-northeast3" \
--project "team-mino-prod"
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ skills-lock.json
.env.*
!.env.example
!.env.*.example
!.env.test
[._]*.s[a-v][a-z]
[._]*.sw[a-p]
[._]s[a-rt-v][a-z]
Expand Down
10 changes: 5 additions & 5 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

19 changes: 6 additions & 13 deletions e2e/setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ const probe = Bun.serve({
});
const port = probe.port;
const url = `postgres://postgres:postgres@127.0.0.1:${port}/team_mino`;
const schema = "develop";
const migrations = join(process.cwd(), "drizzle");
const pg = new EmbeddedPostgres({
databaseDir: join(tmpdir(), `team-mino-postgres-${randomUUID()}`),
Expand All @@ -27,21 +28,13 @@ const db = drizzle({
connection: {
url,
max: 1,
connection: { search_path: "develop" },
connection: { search_path: schema },
},
});

Object.assign(process.env, {
DATABASE_SCHEMA: "develop",
DATABASE_URL: url,
GOOGLE_CLOUD_PROJECT: "team-mino-test",
INSTAGRAM_APP_ID: "test",
INSTAGRAM_DOC_ID: "test",
INSTAGRAM_GRAPHQL_ENDPOINT: "https://www.instagram.com/api/graphql",
INSTAGRAM_USER_AGENT: "test",
KAKAO_REST_API_KEY: "test",
NODE_ENV: "test",
});
// DB 설정은 임베디드 인스턴스에 묶여 있어 여기서 주입합니다(나머지는 .env.test).
process.env.DATABASE_URL = url;
process.env.DATABASE_SCHEMA = schema;

beforeAll(async () => {
try {
Expand All @@ -56,7 +49,7 @@ beforeAll(async () => {
if (existsSync(migrations)) {
await migrate(db, {
migrationsFolder: migrations,
migrationsSchema: "develop",
migrationsSchema: schema,
});
}
}, 30_000);
Expand Down
30 changes: 29 additions & 1 deletion infra/src/resources/secrets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import * as gcp from "@pulumi/gcp";
import * as pulumi from "@pulumi/pulumi";
import { project } from "@/config";
import { enabledServices } from "@/resources/apis";
import { serverServiceAccount } from "@/resources/identity";
import { developer, serverServiceAccount } from "@/resources/identity";

/**
* 앱 env를 담는 시크릿(local·prod). Pulumi는 "컨테이너"만 선언하고, 실제 값(버전)은
Expand Down Expand Up @@ -37,3 +37,31 @@ new gcp.secretmanager.SecretIamMember("team-mino-env-prod-runtime", {
role: "roles/secretmanager.secretAccessor",
member: pulumi.interpolate`serviceAccount:${serverServiceAccount.email}`,
});

// 개발자 SA가 로컬 실행 시 두 env를 읽고(secretAccessor)·갱신(secretVersionManager)할 수 있도록.
const developerMember = pulumi.interpolate`serviceAccount:${developer.email}`;

new gcp.secretmanager.SecretIamMember("team-mino-env-local-developer-read", {
secretId: localEnvSecret.secretId,
project,
role: "roles/secretmanager.secretAccessor",
member: developerMember,
});
new gcp.secretmanager.SecretIamMember("team-mino-env-local-developer-write", {
secretId: localEnvSecret.secretId,
project,
role: "roles/secretmanager.secretVersionManager",
member: developerMember,
});
Comment on lines +42 to +55

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/usr/bin/env bash
set -euo pipefail

rg -n -C 6 \
  'gcloud auth application-default login|GOOGLE_APPLICATION_CREDENTIALS|impersonat|fetchSecret|developer\.email|serviceAccount:' \
  --glob '*.ts' --glob '*.yml' --glob '*.yaml' .

Repository: mash-up-kr/Team-MINO-Node

Length of output: 13948


🌐 Web query:

Google Cloud SDK gcloud auth application-default login User credentials Service Account impersonation

💡 Result:

To use service account impersonation with Application Default Credentials (ADC) in a local development environment, you can use the gcloud auth application-default login command with the --impersonate-service-account flag [1][2][3]. This command generates a local ADC file that allows client libraries to authenticate using the impersonated service account's identity [1][2][4]. Usage: gcloud auth application-default login --impersonate-service-account=SERVICE_ACCT_EMAIL [1][3] Key requirements and considerations: - Permissions: The user account executing the command must have the Service Account Token Creator (roles/iam.serviceAccountTokenCreator) IAM role on the target service account [2][3]. This role provides the required iam.serviceAccounts.getAccessToken permission [3]. - Language Support: While ADC impersonation works for many use cases, support for credentials generated this way is not universal across all Google Cloud client libraries [2]. It is officially supported for Go, Java, Node.js, and Python client libraries [3]. - Purpose: This approach is intended for local development environments where you need your code to test permissions assigned to a specific service account without using long-lived service account keys [2][5][3]. - Distinction from gcloud CLI Config: Note that there is a difference between using this flag with gcloud auth application-default login (which creates an ADC file for client libraries) and setting the gcloud config property auth/impersonate_service_account [1][3]. The latter configures the gcloud CLI tool itself to impersonate a service account for its own commands, but it does not necessarily create the ADC file used by your application code [1][6].

Citations:


로컬 ADC가 developer 서비스 계정과 같은 주체가 되도록 맞춰 주세요.

gcloud auth application-default login만 실행하면 로그인한 사용자 계정의 ADC가 생성되며, 현재 Secret Manager의 roles/secretmanager.secretAccessor 권한은 serviceAccount:${developer.email}에만 있습니다. 이런 상태에서는 fetchSecret이 호출 주체와 권한이 달라 PERMISSION_DENIED에 실패할 수 있습니다. 로컬 실행 시 --impersonate-service-account=${developer.email} ADC를 사용하거나, 승인된 사용자/그룹에 최소 권한을 부여해 주체를 일치시켜 주세요.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@infra/src/resources/secrets.ts` around lines 42 - 55, Align local ADC
authentication with the developer service account used by fetchSecret: configure
local setup to obtain ADC with impersonation of developer.email, or grant the
approved local user/group the minimum secretAccessor permission on
localEnvSecret. Ensure the resulting ADC principal matches an authorized member
while preserving the existing developer write permission.

new gcp.secretmanager.SecretIamMember("team-mino-env-prod-developer-read", {
secretId: prodEnvSecret.secretId,
project,
role: "roles/secretmanager.secretAccessor",
member: developerMember,
});
new gcp.secretmanager.SecretIamMember("team-mino-env-prod-developer-write", {
secretId: prodEnvSecret.secretId,
project,
role: "roles/secretmanager.secretVersionManager",
member: developerMember,
});
Comment on lines +56 to +67

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/usr/bin/env bash
set -euo pipefail

rg -n -C 8 \
  'team-mino-env-prod|secretVersionManager|addSecretVersion|accessSecretVersion|gcloud secrets' \
  --glob '*.ts' --glob '*.yml' --glob '*.yaml' .

Repository: mash-up-kr/Team-MINO-Node

Length of output: 5851


🏁 Script executed:

#!/usr/bin/env bash
set -euo pipefail

echo "== files =="
git ls-files | sed -n '1,200p'

echo "== secrets/identity config =="
for f in infra/src/resources/secrets.ts infra/src/resources/identity.ts infra/src/config.ts scripts/with-env.ts; do
  if [ -f "$f" ]; then
    echo "--- $f ($(wc -l < "$f") lines) ---"
    cat -n "$f"
  fi
done

echo "== broad secret/cloud run references =="
rg -n -C 4 \
  'with-env|with-env\.ts|envDotenv|ENV_DOTENV_VAR|secretEnv|secrets\.env|DATABASE_URL|secretManager|SecretIamMember|secretVersionManager|secretAccessor|gcloud secrets' .

Repository: mash-up-kr/Team-MINO-Node

Length of output: 27991


프로덕션 시크릿 쓰기 권한을 개발자 서비스 계정에서 분리하세요.

infra/src/resources/secrets.ts:56-67에서 developerMember가 team-mino-env-prod의 roles/secretmanager.secretAccessor와 roles/secretmanager.secretVersionManager를 모두 갖습니다. 이 권한으로 개발자가 프로덕션 시크릿을 읽고 버전을 추가·관리할 수 있습니다.

로컬 실행에는 team-mino-env-local의 접근 권한만 사용하세요. 프로덕션 값 갱신이 필요하면 dedicated release/rotation 서비스 계정을 부여하고, 개발자 권한이 없으면 배포가 실패하지 않는지 확인하세요.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@infra/src/resources/secrets.ts` around lines 56 - 67, Update the production
secret IAM bindings for prodEnvSecret so developerMember retains only the
required read access and no longer receives
roles/secretmanager.secretVersionManager. Grant production
write/version-management access exclusively to the existing dedicated release or
rotation service-account member, while keeping developerMember’s local-secret
access unchanged and ensuring deployments still succeed without developer
permissions.

18 changes: 9 additions & 9 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,19 +7,19 @@
"build": "bun run scripts/build.ts",
"check": "biome check .",
"check:staged": "nano-staged --bail",
"db:generate": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate",
"db:schema-init": "bun run scripts/db-schema-init.ts",
"db:studio": "drizzle-kit studio",
"db:generate": "bun run scripts/with-env.ts -- drizzle-kit generate",
"db:migrate": "bun run scripts/with-env.ts -- drizzle-kit migrate",
"db:schema-init": "bun run scripts/with-env.ts -- bun run scripts/db-schema-init.ts",
"db:studio": "bun run scripts/with-env.ts -- drizzle-kit studio",
"format": "biome format --write .",
"lint": "biome lint .",
"postinstall": "simple-git-hooks",
"start:local": "APP_ENV=local nest start --watch --exec bun",
"start:local": "bun run scripts/with-env.ts -- nest start --watch --exec bun",
"start:prod": "./dist/server",
"test": "bun run test:unit && bun run test:e2e",
"test:e2e": "bun test --preload ./e2e/setup.ts e2e/",
"test:unit": "bun test src/",
"test:watch": "bun test --watch src/",
"test:e2e": "bun test --env-file=.env.test --preload ./e2e/setup.ts e2e/",
"test:unit": "bun test --env-file=.env.test src/",
"test:watch": "bun test --env-file=.env.test --watch src/",
"typecheck": "tsgo --noEmit",
"typecheck:tsc": "tsc --noEmit"
},
Expand All @@ -30,7 +30,6 @@
"dependencies": {
"@ai-sdk/google-vertex": "^4.0.147",
"@ai-sdk/valibot": "^2.0.31",
"@google-cloud/secret-manager": "^6.2.0",
"@google-cloud/storage": "^7.21.0",
"@kiyasov/platform-hono": "^2.0.3",
"@nestjs/common": "^11.1.24",
Expand All @@ -50,6 +49,7 @@
},
"devDependencies": {
"@biomejs/biome": "^2.4.15",
"@google-cloud/secret-manager": "^6.3.0",
"@nestjs/cli": "^11.0.21",
"@nestjs/schematics": "^11.1.0",
"@nestjs/testing": "^11.1.24",
Expand Down
2 changes: 1 addition & 1 deletion scripts/db-schema-init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import { resolveDbSchema } from "../src/infrastructures/db/db.env";

async function main() {
// 기본은 Secret Manager에서 env를 가져옵니다. APP_CONFIG_SOURCE=env 일 때만 건너뜀.
await loadSecretEnv();
loadSecretEnv();

const url = process.env.DATABASE_URL;
if (!url) {
Expand Down
73 changes: 73 additions & 0 deletions scripts/with-env.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
#!/usr/bin/env bun

/**
* 로컬 실행 래퍼. Secret Manager의 dotenv 원문을 받아 주입하고 자식 명령을 실행합니다.
*
* bun run scripts/with-env.ts -- nest start --watch --exec bun
*
* drizzle-kit처럼 process.env를 직접 읽는 도구를 위해 개별 키까지 함께 주입합니다.
* SDK는 devDependency라 프로덕션 번들에는 포함되지 않습니다.
*/

import { SecretManagerServiceClient } from "@google-cloud/secret-manager";
import { ENV_DOTENV_VAR, parseDotenv } from "../src/config/secret-env";

const SECRET_FETCH_TIMEOUT_MS = 10_000;

async function fetchSecret(project: string, secret: string): Promise<string> {
// fallback: true → gRPC 네이티브 의존성 대신 REST 사용.
const client = new SecretManagerServiceClient({ fallback: true });
const [version] = await client.accessSecretVersion(
{ name: `projects/${project}/secrets/${secret}/versions/latest` },
{ timeout: SECRET_FETCH_TIMEOUT_MS },
);
return version.payload?.data?.toString() ?? "";
}

async function main(): Promise<void> {
// `bun run x.ts -- cmd`는 bun이 `--`를 떼고 넘기지만, 직접 실행 시엔 남는다.
const args = process.argv.slice(2);
const command = args[0] === "--" ? args.slice(1) : args;
if (command.length === 0) {
console.error("usage: bun run scripts/with-env.ts -- <command> [args...]");
process.exit(1);
}

const env: Record<string, string> = {
...(process.env as Record<string, string>),
};
env.APP_ENV ??= "local";

if (env.APP_CONFIG_SOURCE !== "env") {
const project = env.GCP_PROJECT ?? "team-mino-prod";
const secret = env.GCP_ENV_SECRET ?? `team-mino-env-${env.APP_ENV}`;

let payload: string;
try {
payload = await fetchSecret(project, secret);
} catch (error) {
console.error(`시크릿을 가져오지 못했습니다: ${secret} (${project})`);
console.error(error instanceof Error ? error.message : error);
console.error(
"ADC 로그인(gcloud auth application-default login)을 확인하거나, APP_CONFIG_SOURCE=env 로 우회하세요.",
);
process.exit(1);
}

env[ENV_DOTENV_VAR] = payload;
for (const [key, value] of Object.entries(parseDotenv(payload))) {
if (env[key] === undefined) env[key] = value;
}
}
Comment on lines +41 to +61

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

로컬 .env 대체 경로를 실제로 동작하게 해야 합니다.

APP_ENV는 Line 39에서 기본값 local을 사용합니다. 그러나 APP_CONFIG_SOURCE=env가 없으면 Line 41이 Secret Manager 조회를 시작합니다. ADC가 없으면 Line 48-54가 종료하므로, 기존 로컬 .env만으로는 start:local을 실행할 수 없습니다.

  • scripts/with-env.ts#L41-L61: 로컬 Secret Manager 조회가 실패하면 기존 로컬 환경으로 자식 명령을 실행하거나, 로컬 파일 모드를 명시적으로 기본 선택해야 합니다.
  • .env.example#L1-L6: 로컬 파일 모드가 APP_CONFIG_SOURCE=env를 요구한다면 그 값을 포함하고 실행 절차를 정확히 문서화해야 합니다.
🧰 Tools
🪛 ast-grep (0.45.0)

[warning] 48-48: Avoid logging sensitive data
Context: console.error(시크릿을 가져오지 못했습니다: ${secret} (${project}))
Note: [CWE-532] Insertion of Sensitive Information into Log File.

(log-sensitive-data-typescript)

📍 Affects 2 files
  • scripts/with-env.ts#L41-L61 (this comment)
  • .env.example#L1-L6
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@scripts/with-env.ts` around lines 41 - 61, Update scripts/with-env.ts lines
41-61 so local execution can fall back to the existing local environment when
fetchSecret fails, or make local file mode the default while preserving Secret
Manager behavior for explicitly configured environments. Update .env.example
lines 1-6 to include the required APP_CONFIG_SOURCE=env setting and accurately
document the local startup procedure.


const child = Bun.spawn(command, {
env,
stdio: ["inherit", "inherit", "inherit"],
});
process.exit(await child.exited);
}

main().catch((error: unknown) => {
console.error(error);
process.exit(1);
});
43 changes: 13 additions & 30 deletions src/config/secret-env.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,14 @@
import { SecretManagerServiceClient } from "@google-cloud/secret-manager";

/**
* 부팅 시 GCP Secret Manager에서 env 묶음을 가져와 process.env에 주입합니다.
*
* env의 단일 출처를 Secret Manager로 두고, 로컬·Cloud Run 모두 부팅 시 fetch합니다.
* 환경은 APP_ENV(`local` | `prod`)로 고르며 시크릿 `team-mino-env-${APP_ENV}`에 매핑됩니다.
* APP_CONFIG_SOURCE=env 이면 SM을 건너뛰고 기존 process.env/.env 를 씁니다(오프라인/전환기용).
* 주입된 dotenv 원문(APP_ENV_DOTENV)을 파싱해 process.env에 채웁니다.
* prod는 Cloud Run `--set-secrets`, 로컬은 `scripts/with-env.ts`가 주입합니다.
*
* 부팅 최초 단계(ConfigModule 검증 이전)라 ConfigService가 없으므로,
* "env는 ConfigService로만" 규칙의 의도적 예외로 process.env를 직접 다룹니다.
* 시크릿 포맷은 한 줄 = 한 키(여러 줄 값은 base64로 인코딩).

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

'여러 줄 값은 base64로 인코딩' 이라고 되어있는데 parseDotenv 를 봤을 때 어떤 환경변수가 base64로 인코딩된 것인지 알 수 없겠다는 생각이 들었어요.

나중에 여러 줄짜리 환경변수가 생겼을 때는 어떻게 하면 좋을까요? 👀

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

아직 별 고민이 없긴 했는데, parseDotenv에서의 처리는 아니고 사용처에서 여러 줄짜리 환경 변수면 decode 해서 쓰면 되지 않을까 했어요. 구체적인 사용 사례를 떠올려보면 pem key 같은 것이긴 합니다. (그 key를 사용하는 쪽에서 그 key만 따로 encode 해서 Secrets Manager에 넣고, 사용할 때에는 decode 해서 사용)

* ConfigModule 검증 이전에 도는 부팅 최초 단계라 process.env를 직접 다룹니다.
*/

const SECRET_FETCH_TIMEOUT_MS = 5000;
export const ENV_DOTENV_VAR = "APP_ENV_DOTENV";

function parseDotenv(src: string): Record<string, string> {
export function parseDotenv(src: string): Record<string, string> {
const result: Record<string, string> = {};
for (const line of src.split("\n")) {
if (/^\s*(#|$)/.test(line)) continue;
Expand All @@ -26,27 +21,15 @@ function parseDotenv(src: string): Record<string, string> {
return result;
}

function resolveSecretName(): string {
if (process.env.GCP_ENV_SECRET) return process.env.GCP_ENV_SECRET;
return `team-mino-env-${process.env.APP_ENV ?? "local"}`;
}

export async function loadSecretEnv(): Promise<void> {
if (process.env.APP_CONFIG_SOURCE === "env") return;
export function loadSecretEnv(): void {
const payload = process.env[ENV_DOTENV_VAR];
if (!payload) return;

const project = process.env.GCP_PROJECT ?? "team-mino-prod";
const secret = resolveSecretName();

// fallback: true → gRPC 네이티브 의존성 대신 REST 사용(bun --compile 단일 바이너리 번들 안전).
const client = new SecretManagerServiceClient({ fallback: true });
const [version] = await client.accessSecretVersion(
{ name: `projects/${project}/secrets/${secret}/versions/latest` },
{ timeout: SECRET_FETCH_TIMEOUT_MS },
);

const payload = version.payload?.data?.toString() ?? "";
for (const [key, value] of Object.entries(parseDotenv(payload))) {
// 이미 설정된 값은 보존 → OS env로 개별 오버라이드 허용.
// 배포·셸이 넣은 값이 주입값보다 우선.
if (process.env[key] === undefined) process.env[key] = value;
}

// 원문이 env에 통째로 남지 않도록 정리.
delete process.env[ENV_DOTENV_VAR];
}
2 changes: 1 addition & 1 deletion src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import {
} from "./config/sentry.config";

async function main(): Promise<void> {
await loadSecretEnv();
loadSecretEnv();
initializeSentry(process.env, Sentry.init);
const { bootstrap } = await import("./bootstrap");
await bootstrap();
Expand Down
Loading