Repository navigation
refactor: Secret Manager SDK 제거하고 env 주입 방식으로 전환 #51
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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 |
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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는 "컨테이너"만 선언하고, 실제 값(버전)은 | ||
|
|
@@ -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, | ||
| }); | ||
| 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 프로덕션 시크릿 쓰기 권한을 개발자 서비스 계정에서 분리하세요.
로컬 실행에는 🤖 Prompt for AI Agents |
||
| 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win 로컬
🧰 Tools🪛 ast-grep (0.45.0)[warning] 48-48: Avoid logging sensitive data (log-sensitive-data-typescript) 📍 Affects 2 files
🤖 Prompt for AI Agents |
||
|
|
||
| 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); | ||
| }); | ||
| 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로 인코딩). | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. '여러 줄 값은 base64로 인코딩' 이라고 되어있는데 나중에 여러 줄짜리 환경변수가 생겼을 때는 어떻게 하면 좋을까요? 👀
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 아직 별 고민이 없긴 했는데, |
||
| * 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; | ||
|
|
@@ -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]; | ||
| } | ||
There was a problem hiding this comment.
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:
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