diff --git a/.env.example b/.env.example index df41d0f..17b7b77 100644 --- a/.env.example +++ b/.env.example @@ -6,6 +6,15 @@ # - node: 순수 Node 서버 (adapter-node). platform 바인딩 없음 → pg/mysql 은 # DATABASE_URL 로 직결, 설정값은 process.env 로 읽는다. (D1 은 Workers 전용) # 예) BUILD_TARGET=node DB_DIALECT=postgres bun run build && node build +# +# 레이트 리밋 저장소(RateLimitStore)는 배포 타깃에 따라 자동 선택된다: +# - cloudflare(Workers): 요청 간 상태를 공유할 수 없는 isolate 특성상 DB(rate_limits 테이블) +# 기반 공유 저장소를 사용한다. +# - node(adapter-node) : 프로세스 내 in-memory 저장소를 사용한다(핫패스 DB write 없음). +# ⚠️ in-memory 는 "단일 프로세스" 가정이다. 같은 앱을 여러 인스턴스로 수평 확장하면 +# 각 인스턴스가 자기 카운터만 보므로 실질 한도가 인스턴스 수만큼 완화된다. +# 다중 인스턴스 Node 배포에서 엄격한 전역 한도가 필요하면 공유 저장소(Redis 등)로 +# RateLimitStore 를 교체해야 한다(인터페이스는 열려 있으나 이번 버전엔 미포함). BUILD_TARGET="cloudflare" # DB_DIALECT: d1(기본) | sqlite | postgres | mysql — 배포 단위로 하나만 사용 @@ -71,11 +80,14 @@ IDP_DEFAULT_TENANT_NAME="My Organization" # 서명 키 암호화 KEK — 최소 32자 랜덤 문자열 # 생성: openssl rand -base64 32 +# ⚠️ 프로덕션 필수: 미설정 시 요청 초기에 오류로 차단(fail-fast). dev 에서는 생략 가능. # ⚠️ 프로덕션에서는 wrangler secret put IDP_SIGNING_KEY_SECRET 사용 IDP_SIGNING_KEY_SECRET="your-very-long-random-secret-at-least-32-chars" # OIDC/SAML issuer URL (배포 도메인과 일치시킬 것) -# 미설정 시 요청 origin으로 자동 대체 (로컬 개발 시 http://localhost:5173) +# ⚠️ 프로덕션 필수: 미설정 시 요청 초기에 503 으로 차단(fail-closed). Host 헤더 주입으로 +# iss 클레임/SAML Issuer 가 오염되는 것을 막는다. +# dev 에서만 미설정 시 요청 origin 으로 자동 대체(로컬 http://localhost:5173). IDP_ISSUER_URL="http://localhost:5173" SMTP_HOSTNAME="" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 87ebf77..9ce1403 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -82,6 +82,14 @@ jobs: - name: Test run: bun run test + - name: Migration drift check + run: | + bun run db:generate:all + if ! git diff --exit-code -- drizzle; then + echo "::error::스키마가 변경되었으나 마이그레이션이 생성되지 않았습니다. 'bun run db:generate:all' 실행 후 drizzle*/ 변경분을 커밋하세요." >&2 + exit 1 + fi + - name: Build run: bun run build diff --git a/.gitignore b/.gitignore index f316eca..490df1d 100644 --- a/.gitignore +++ b/.gitignore @@ -21,6 +21,12 @@ Thumbs.db # Vite vite.config.js.timestamp-* vite.config.ts.timestamp-* + +# Test coverage +/coverage + +# ESLint cache +.eslintcache # SQLite *.db diff --git a/.gitleaksignore b/.gitleaksignore index 1712e11..ba2ba26 100644 --- a/.gitleaksignore +++ b/.gitleaksignore @@ -1,2 +1,11 @@ # False positive: "pbkdf2/argon2id" 는 credentials.secret 컬럼이 어떤 해시 알고리즘 형식을 저장하는지 설명하는 문자열일 뿐, 실제 시크릿이 아님 02bc8b64f32a78d60cc39c46acfd6d49b384cbbd:src/lib/server/db/schema.ts:generic-api-key:62 + +# False positive: 테스트 시드용 고정 비밀번호("pw-strong-123") — 실제 시크릿 아님 +57e8e95ccc4038c7a631a7c3318efcb446c15c8e:test/integration/p12-logic.test.ts:generic-api-key:45 + +# False positive: AES-GCM 도메인 분리 라벨 문자열("idp-signing-key-wrap-v1") — 실제 시크릿 아님 +e83b290fba4a75c4c989434b024b589c1e256918:scripts/reencrypt-secrets.ts:generic-api-key:194 + +# False positive: 테스트 상수("wrap-secret-0123456789") — 실제 시크릿 아님 +d48992c3dd491706b9795d1e2ae3de3d234eadcf:test/unit/crypto-keys.test.ts:generic-api-key:204 diff --git a/README.md b/README.md index c52fd1a..9254c2a 100644 --- a/README.md +++ b/README.md @@ -197,16 +197,16 @@ wrangler secret put IDP_SIGNING_KEY_SECRET ## 환경변수 -| 변수 | 필수 | 설명 | -| ----------------------------------- | ---- | ---------------------------------------------------------------------------------------- | -| `IDP_ISSUER_URL` | ✅ | OIDC/SAML 발급자 URL (배포 도메인과 일치) | -| `IDP_SIGNING_KEY_SECRET` | ✅ | 서명 키 암호화 KEK (프로덕션은 반드시 Secret) | -| `DISPATCHER_SERVICE_TOKEN` | 선택 | stardust dispatcher 가 `/api/totp/*` 호출 시 사용할 Bearer 토큰. 미설정이면 해당 API 503 | -| `IDP_DEFAULT_TENANT_NAME` | 선택 | 기본 테넌트 이름 (기본: `My Organization`) | -| `CLOUDFLARE_ACCOUNT_ID` | 선택 | Cloudflare 계정 ID (마이그레이션 스크립트에서 사용) | -| `CLOUDFLARE_D1_DATABASE_ID` | 선택 | D1 데이터베이스 ID (마이그레이션 스크립트에서 사용) | -| `CLOUDFLARE_D1_PREVIEW_DATABASE_ID` | 선택 | 프리뷰용 D1 데이터베이스 ID | -| `CLOUDFLARE_D1_TOKEN` | 선택 | D1 API 토큰 (`db:migrate` 스크립트에서 사용) | +| 변수 | 필수 | 설명 | +| ----------------------------------- | ---- | ------------------------------------------------------------------------------------------------------------------------------------- | +| `IDP_ISSUER_URL` | ✅ | OIDC/SAML 발급자 URL (배포 도메인과 일치). **프로덕션 필수** — 미설정 시 요청 초기 503(fail-closed). dev 에서만 요청 origin 자동 대체 | +| `IDP_SIGNING_KEY_SECRET` | ✅ | 서명 키 암호화 KEK (프로덕션은 반드시 Secret). **프로덕션 필수** — 미설정 시 요청 초기에 오류로 차단(fail-fast) | +| `DISPATCHER_SERVICE_TOKEN` | 선택 | stardust dispatcher 가 `/api/totp/*` 호출 시 사용할 Bearer 토큰. 미설정이면 해당 API 503 | +| `IDP_DEFAULT_TENANT_NAME` | 선택 | 기본 테넌트 이름 (기본: `My Organization`) | +| `CLOUDFLARE_ACCOUNT_ID` | 선택 | Cloudflare 계정 ID (마이그레이션 스크립트에서 사용) | +| `CLOUDFLARE_D1_DATABASE_ID` | 선택 | D1 데이터베이스 ID (마이그레이션 스크립트에서 사용) | +| `CLOUDFLARE_D1_PREVIEW_DATABASE_ID` | 선택 | 프리뷰용 D1 데이터베이스 ID | +| `CLOUDFLARE_D1_TOKEN` | 선택 | D1 API 토큰 (`db:migrate` 스크립트에서 사용) | > **참고**: 초기 관리자 계정은 `bun run setup` 이 생성합니다. 수동/CI 시드가 필요하면 `IDP_BOOTSTRAP_ADMIN_USERNAME` / `IDP_BOOTSTRAP_ADMIN_EMAIL` / `IDP_BOOTSTRAP_ADMIN_PASSWORD` (+선택 `IDP_BOOTSTRAP_ADMIN_NAME`) 를 설정하고 `bun run db:seed`(방언별: `db:seed:pg` 등)를 실행하세요. 비대화 환경에서는 `SEED_RESET=0|1` 로 초기화 여부를 지정합니다. diff --git a/bun.lock b/bun.lock index 795d1ad..7e917f5 100644 --- a/bun.lock +++ b/bun.lock @@ -33,6 +33,7 @@ "@types/node": "^26.1.0", "@types/nodemailer": "^8.0.0", "@types/qrcode": "^1.5.6", + "@vitest/coverage-v8": "^4.1.9", "drizzle-kit": "^0.31.10", "drizzle-orm": "^0.45.2", "eslint": "^10.3.0", @@ -59,6 +60,16 @@ "ws": "^8.21.0", }, "packages": { + "@babel/helper-string-parser": ["@babel/helper-string-parser@7.29.7", "", {}, "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw=="], + + "@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.29.7", "", {}, "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="], + + "@babel/parser": ["@babel/parser@7.29.7", "", { "dependencies": { "@babel/types": "^7.29.7" }, "bin": "./bin/babel-parser.js" }, "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg=="], + + "@babel/types": ["@babel/types@7.29.7", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA=="], + + "@bcoe/v8-coverage": ["@bcoe/v8-coverage@1.0.2", "", {}, "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA=="], + "@cloudflare/kv-asset-handler": ["@cloudflare/kv-asset-handler@0.5.0", "", {}, "sha512-jxQYkj8dSIzc0cD6cMMNdOc1UVjqSqu8BZdor5s8cGjW2I8BjODt/kWPVdY+u9zj3ms75Q5qaZgnxUad83+eAg=="], "@cloudflare/unenv-preset": ["@cloudflare/unenv-preset@2.16.1", "", { "peerDependencies": { "unenv": "2.0.0-rc.24", "workerd": ">1.20260305.0 <2.0.0-0" }, "optionalPeers": ["workerd"] }, "sha512-ECxObrMfyTl5bhQf/lZCXwo5G6xX9IAUo+nDMKK4SZ8m4Jvvxp52vilxyySSWh2YTZz8+HQ07qGH/2rEom1vDw=="], @@ -503,6 +514,8 @@ "@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.59.3", "", { "dependencies": { "@typescript-eslint/types": "8.59.3", "eslint-visitor-keys": "^5.0.0" } }, "sha512-f1UQF7ggd42YiwI5wGrRaPsa+P0CINBlrkLPmGfpq/u/I/oVtecoEIfFR9ag/oa1sLOsRNZ6xehf6qMZhQGBDg=="], + "@vitest/coverage-v8": ["@vitest/coverage-v8@4.1.9", "", { "dependencies": { "@bcoe/v8-coverage": "^1.0.2", "@vitest/utils": "4.1.9", "ast-v8-to-istanbul": "^1.0.0", "istanbul-lib-coverage": "^3.2.2", "istanbul-lib-report": "^3.0.1", "istanbul-reports": "^3.2.0", "magicast": "^0.5.2", "obug": "^2.1.1", "std-env": "^4.0.0-rc.1", "tinyrainbow": "^3.1.0" }, "peerDependencies": { "@vitest/browser": "4.1.9", "vitest": "4.1.9" }, "optionalPeers": ["@vitest/browser"] }, "sha512-G9/lgqibheLVBDRuya45EbsEXTYcWoSG+TLg7i2axuzx0Eq62eXn+aWXyaVdV5vKvFSWd6ywcX8hA7la9Pvu8g=="], + "@vitest/expect": ["@vitest/expect@4.1.9", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.1.9", "@vitest/utils": "4.1.9", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" } }, "sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA=="], "@vitest/mocker": ["@vitest/mocker@4.1.9", "", { "dependencies": { "@vitest/spy": "4.1.9", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["msw", "vite"] }, "sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw=="], @@ -543,6 +556,8 @@ "assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="], + "ast-v8-to-istanbul": ["ast-v8-to-istanbul@1.0.4", "", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.31", "estree-walker": "^3.0.3", "js-tokens": "^10.0.0" } }, "sha512-0bC0/4bTSrnwdhU3IsZDwEdojvuPrSg59OYZfKsLRtJZ0u8VBx9DebfqqG8bRdCC0I7vjgxmPi41P0lpkhJHtA=="], + "aws-ssl-profiles": ["aws-ssl-profiles@1.1.2", "", {}, "sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g=="], "aws4fetch": ["aws4fetch@1.0.20", "", {}, "sha512-/djoAN709iY65ETD6LKCtyyEI04XIBP5xVvfmNxsEP0uJB5tyaGBztSryRr4HqMStr9R06PisQE7m9zDTXKu6g=="], @@ -685,8 +700,12 @@ "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], + "has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="], + "hasown": ["hasown@2.0.4", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A=="], + "html-escaper": ["html-escaper@2.0.2", "", {}, "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg=="], + "iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="], "ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="], @@ -709,10 +728,18 @@ "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], + "istanbul-lib-coverage": ["istanbul-lib-coverage@3.2.2", "", {}, "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg=="], + + "istanbul-lib-report": ["istanbul-lib-report@3.0.1", "", { "dependencies": { "istanbul-lib-coverage": "^3.0.0", "make-dir": "^4.0.0", "supports-color": "^7.1.0" } }, "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw=="], + + "istanbul-reports": ["istanbul-reports@3.2.0", "", { "dependencies": { "html-escaper": "^2.0.0", "istanbul-lib-report": "^3.0.0" } }, "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA=="], + "jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="], "js-base64": ["js-base64@3.7.8", "", {}, "sha512-hNngCeKxIUQiEUN3GPJOkz4wF/YvdUdbNL9hsBcMQTkKzboD7T/q3OYOuuPZLUE6dBxSGpwhk5mwuDud7JVAow=="], + "js-tokens": ["js-tokens@10.0.0", "", {}, "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q=="], + "json-buffer": ["json-buffer@3.0.1", "", {}, "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ=="], "json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="], @@ -787,6 +814,10 @@ "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], + "magicast": ["magicast@0.5.3", "", { "dependencies": { "@babel/parser": "^7.29.3", "@babel/types": "^7.29.0", "source-map-js": "^1.2.1" } }, "sha512-pVKE4UdSQ7DvHzivsCIFx2BJn1mHG6KsyrFcaxFx6tONdneEuThrDx0Cj3AMg58KyN4pzYT+LHOotxDQDjNvkw=="], + + "make-dir": ["make-dir@4.0.0", "", { "dependencies": { "semver": "^7.5.3" } }, "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw=="], + "miniflare": ["miniflare@4.20260630.0", "", { "dependencies": { "@cspotcode/source-map-support": "0.8.1", "sharp": "0.34.5", "undici": "7.28.0", "workerd": "1.20260630.1", "ws": "8.21.0", "youch": "4.1.0-beta.10" }, "bin": { "miniflare": "bootstrap.js" } }, "sha512-lyRplDrSJJWVpzSSQPBSQtNmUuxScCZyOOkXFs37uSbdTfWRDDmw6DyFKVS2s1eYtA/i4u2xR/0FyPIsTl/HJw=="], "minimatch": ["minimatch@10.2.5", "", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="], @@ -925,7 +956,7 @@ "strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], - "supports-color": ["supports-color@10.2.2", "", {}, "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g=="], + "supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], "supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="], @@ -1037,6 +1068,8 @@ "@ldapjs/controls/@ldapjs/asn1": ["@ldapjs/asn1@1.2.0", "", {}, "sha512-KX/qQJ2xxzvO2/WOvr1UdQ+8P5dVvuOLk/C9b1bIkXxZss8BaR28njXdPgFCpj5aHaf1t8PmuVnea+N9YG9YMw=="], + "@poppinss/dumper/supports-color": ["supports-color@10.2.2", "", {}, "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g=="], + "@rollup/plugin-commonjs/is-reference": ["is-reference@1.2.1", "", { "dependencies": { "@types/estree": "*" } }, "sha512-U82MsXXiFIrjCK4otLT+o2NA2Cd2g5MLoOVXUZjIOhLurrRxpEXzI8O0KZHr3IjLvlAH1kTPYSuqer5T9ZVBKQ=="], "@simplewebauthn/server/@peculiar/x509": ["@peculiar/x509@1.14.3", "", { "dependencies": { "@peculiar/asn1-cms": "^2.6.0", "@peculiar/asn1-csr": "^2.6.0", "@peculiar/asn1-ecc": "^2.6.0", "@peculiar/asn1-pkcs9": "^2.6.0", "@peculiar/asn1-rsa": "^2.6.0", "@peculiar/asn1-schema": "^2.6.0", "@peculiar/asn1-x509": "^2.6.0", "pvtsutils": "^1.3.6", "reflect-metadata": "^0.2.2", "tslib": "^2.8.1", "tsyringe": "^4.10.0" } }, "sha512-C2Xj8FZ0uHWeCXXqX5B4/gVFQmtSkiuOolzAgutjTfseNOHT3pUjljDZsTSxXFGgio54bCzVFqmEOUrIVk8RDA=="], @@ -1079,6 +1112,8 @@ "@vitest/mocker/estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="], + "ast-v8-to-istanbul/estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="], + "eslint-plugin-svelte/globals": ["globals@16.5.0", "", {}, "sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ=="], "libsql/detect-libc": ["detect-libc@2.0.2", "", {}, "sha512-UX6sGumvvqSaXgdKGUsgZWqcUyIXZ/vZTrlRT/iobiKhGL0zL4d3osHj3uqllWJK+i+sixDS/3COVEOFbupFyw=="], @@ -1157,6 +1192,8 @@ "@vitest/mocker/estree-walker/@types/estree": ["@types/estree@1.0.9", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="], + "ast-v8-to-istanbul/estree-walker/@types/estree": ["@types/estree@1.0.9", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="], + "tsx/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.7", "", { "os": "aix", "cpu": "ppc64" }, "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg=="], "tsx/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.27.7", "", { "os": "android", "cpu": "arm" }, "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ=="], diff --git a/docs/ADMIN_GUIDE.md b/docs/ADMIN_GUIDE.md new file mode 100644 index 0000000..4d53b4d --- /dev/null +++ b/docs/ADMIN_GUIDE.md @@ -0,0 +1,285 @@ +# KeyStone 관리자 운영 매뉴얼 + +KeyStone(멀티테넌트 IdP)의 관리 콘솔 운영 가이드입니다. 각 화면에서 관리자가 무엇을 클릭하고 어떤 일이 일어나는지를 실무 관점에서 정리했습니다. + +> 콘솔 UI 는 한국어/영어(ko/en)를 지원합니다. 모든 관리 작업은 현재 로그인한 테넌트 범위에서만 동작하며, 주요 변경은 감사 로그(`/admin/audit`)에 기록됩니다. + +--- + +## 1. 개요 / 접근 + +### 관리자 로그인 + +- 로그인 URL: **`/admin/login`** +- 인증 흐름(`/admin/login` → `/mfa`): + 1. 아이디/비밀번호 검증(로컬 계정). + 2. **`role === "admin"` 이 아니면 거부**(감사 로그에 `reason: not_admin`). + 3. **TOTP(MFA) 미등록 관리자는 로그인 불가**(`reason: mfa_not_configured`) — 관리자는 반드시 TOTP 를 등록해야 합니다. + 4. MFA pending 쿠키 발급 후 `/mfa` 로 이동해 TOTP 코드 확인 → 세션 생성. +- 레이트리밋: IP당 15분에 10회. +- `IDP_SIGNING_KEY_SECRET` 미설정 시 MFA 토큰 서명이 불가해 로그인이 503 으로 막힙니다. + +### 접근 제어 + +- `/admin/**` 전 구간은 레이아웃 가드(`+layout.server.ts`)가 보호합니다. + - 미로그인 → `/admin/login?redirectTo=...` 로 리다이렉트. + - **`role !== "admin"` → `/` 로 강제 이동**(일반 사용자는 콘솔 접근 불가). +- `/admin/login` 만 예외적으로 비인증 접근 허용. + +### 대시보드 + +- **`/admin`**: 테넌트 요약 카운트(사용자, OIDC 클라이언트, SAML SP, 서명키, 감사 이벤트, 부서/팀/직급) 표시. + +--- + +## 2. 조직 관리 (부서 / 팀 / 파트 / 직급) + +조직은 **부서(department) → 팀(team) → 파트(part)** 3단계 계층이며, **직급(position)** 은 별도 축입니다. + +| 화면 | 경로 | 상위 참조 | 계층 | +| ---- | -------------------- | ------------------------------------- | ------------- | +| 부서 | `/admin/departments` | 상위 부서(`parentId`, 자기 참조 트리) | 최상위 | +| 팀 | `/admin/teams` | 부서(`departmentId`) | 부서 하위 | +| 파트 | `/admin/parts` | 팀(`teamId`) | 팀 하위 | +| 직급 | `/admin/positions` | 없음 | 독립(레벨 축) | + +### 부서 (`/admin/departments`) + +- 필드: 이름(필수), 코드, 상위 부서, 설명, 표시순서(`displayOrder`, 빈값 0). 수정 시 상태(active/inactive 등) 지정. +- **부서 트리 검증**(등록·수정 공통): + - 최대 깊이 **8단계**. + - 자기 자신을 상위로 지정 불가. + - 상위 체인에 순환 참조가 생기면 차단(간접 순환 A→B→A 포함). + - 상위 부서 선택지는 **활성(active) 부서**만 노출. +- 부서 트리 변경은 권한 상속에 직결되므로 **모든 변경이 감사 로그**(`department_*`)에 기록됩니다. + +### 팀 (`/admin/teams`) + +- 필드: 이름(필수), 코드, 소속 부서, 설명. 수정 시 상태. +- 소속 부서 선택지는 **활성 부서**만. 지정한 부서가 같은 테넌트에 존재하는지 참조 무결성 검증. + +### 파트 (`/admin/parts`) + +- 필드: 이름(필수), 코드, 소속 팀, 설명. 수정 시 상태. +- 소속 팀 선택지는 **활성 팀**만(부서명 병기). 참조 무결성 검증. + +### 직급 (`/admin/positions`) + +- 필드: 이름(필수), 코드, **레벨(`level`, 정수)**. 레벨 오름차순으로 정렬 표시. + +### 사용자 소속 배정 & 주소속(primary) 의미 + +개별 사용자의 소속은 **`/admin/users/[id]`** 상세 화면에서 배정합니다(부서/팀/파트 각각 add/remove). + +- 배정 시 각 소속에 **직책(jobTitle)** 을 지정할 수 있고, 부서 배정에는 **직급(position)** 을 함께 지정합니다. +- **주소속(primary)**: 각 축(부서/팀/파트)마다 `isPrimary` 체크박스로 지정. 소속 해제는 하드 삭제가 아니라 **`endedAt` 설정(소프트 종료)** 으로 처리됩니다(이력 보존). +- **주소속 부서**의 직급/직책이 OIDC `organization` 클레임의 최상위 `position` / `job_title` 값이 됩니다(주소속이 없으면 현재 소속 목록의 첫 부서를 사용). + +--- + +## 3. OIDC 클라이언트 등록/관리 (`/admin/oidc-clients`) + +### 생성 + +- **client_id**: 자동 생성(무작위 20자). +- **client_secret**: `token_endpoint_auth_method` 가 `none`(public)이 아니면 자동 생성되어 **생성 직후 화면에 1회만 노출**됩니다. DB 에는 해시만 저장되므로 이때 반드시 복사해 두어야 합니다. +- **Redirect URIs**(필수): 줄바꿈/콤마로 여러 개. `https` 또는 loopback(`http://localhost` 등) 허용, 모바일용 **커스텀 스킴 허용**. `javascript:`/`data:`/`file:`/`blob:`/`vbscript:` 및 fragment(`#`) 포함 URI 는 거부. +- **Post-Logout Redirect URIs / Front-channel / Back-channel Logout URI**: 로그아웃 관련 URL(각 세션 요구 플래그 포함). 커스텀 스킴 불허(https/loopback 만). +- **token_endpoint_auth_method**: `client_secret_basic` / `client_secret_post` / `none` 중 선택. +- **PKCE(`requirePkce`)**: 체크로 강제. **public 클라이언트(`none`)는 PKCE 가 항상 강제**되며 수정 시에도 해제 불가. +- **Wildcard Redirect URI(`allowWildcardRedirectUri`)**: 보안상 기본 비활성. 와일드카드 매칭이 꼭 필요할 때만 **명시적 opt-in**(체크). +- **Scopes**(공백 구분): `openid`(필수) / `profile` / `email` / `address` / `phone` / `offline_access` / `organization` / `groups`. `openid` 누락 시 거부. + - `offline_access` 를 넣어야 refresh token(grant) 이 발급됩니다. + +### 관리 + +- **수정**: 이름/URI/scope/로그아웃 설정/PKCE/와일드카드/활성화(enabled) 변경. +- **시크릿 재발급(`regenerateSecret`)**: 새 시크릿 생성 후 **1회 노출**. 기존 시크릿은 즉시 무효화됩니다. +- **삭제**: 클라이언트 제거. +- 생성/수정/시크릿재발급/삭제 모두 감사 로그(`oidc_client_*`) 기록. 모든 폼은 CSRF 토큰으로 보호됩니다. + +--- + +## 4. 서비스 role/scope 설정 (`/admin/oidc-clients/[id]`) + +클라이언트 상세 화면에서 **서비스 role** 을 정의합니다(SAML SP 도 `/admin/saml-sps/[id]` 에서 동일 구조). + +- role 필드: + - **key**(필수): `^[A-Za-z0-9_.-]{1,64}$` 형식. 같은 서비스 내 중복 불가(중복 시 409). + - **label**(필수): 표시 이름. + - **description**: 설명(선택). + - **isDefault**: 기본 부여 role 표시. + - **displayOrder**: 정렬 순서(정수). +- role 추가/삭제는 감사 로그(`service_role_created` / `service_role_deleted`)에 기록됩니다. +- 정의한 role 은 `/admin/users/[id]` 에서 사용자에게 **서비스 권한(assignment)** 으로 부여합니다(만료/취소 관리 포함). + +--- + +## 5. organization 클레임 노출 설정 (`/admin/oidc-clients/[id]`) + +클라이언트 상세 화면 하단 **"조직 클레임 노출 설정"** 에서, `organization` scope 로 노출되는 조직 정보를 필드별로 on/off 합니다. + +### 노출되는 클레임 구조 + +`organization` scope 가 켜진 클라이언트의 **id_token 과 userinfo 응답에 동일하게** 아래 4개 최상위 키가 들어갑니다. + +| 클레임 키 | 내용 | +| ------------ | --------------------------------------------------------------------------------------------------------------------------- | +| `department` | 현재 소속 부서 배열. 각 원소: `id`, `name`, `code`, `is_primary`, `job_title`, `position`(`{id,name,code,level}` 또는 null) | +| `team` | 현재 소속 팀 배열. 각 원소: `id`, `name`, `code`, `department`(부서명), `is_primary`, `job_title` | +| `position` | 주소속 부서의 직급명(문자열) 또는 null | +| `job_title` | 주소속 부서의 직책(문자열) 또는 null | + +### 체크박스 4개와 저장 규칙 + +토글 필드는 **`department` / `team` / `position` / `jobTitle`** 4개입니다. + +- **모든 필드를 켜면 → `null`(미설정)로 저장**됩니다. 즉 "전량 노출"이며, DB 를 깨끗하게 유지하고 **기존 동작과 하위호환**을 보장합니다. +- **하나라도 끄면 → 명시적 JSON** 으로 저장됩니다. 예: + ```json + { "department": true, "team": true, "position": false, "jobTitle": true } + ``` + `false` 인 필드의 **최상위 클레임 키 자체가 응답에서 생략**됩니다. +- 저장 위치: `oidcClients.organizationClaimConfig`(JSON text). +- **id_token 과 userinfo 가 동일한 config 를 적용**하므로 두 응답의 조직 정보가 항상 일치합니다. + +### 하위호환 / 무회귀 + +- `organizationClaimConfig` 가 없는(=null) 기존 클라이언트는 **전량 노출**로 동작합니다. 이번 기능 도입으로 인한 기존 클라이언트 회귀는 없습니다. +- 저장값 파싱이 실패하거나 알 수 없는 값이면 안전하게 null(전량 노출)로 폴백합니다. +- 변경은 감사 로그(`oidc_client_updated`, `detail.organizationClaimConfig`)에 기록됩니다. + +--- + +## 6. SAML SP 등록/관리 (`/admin/saml-sps`) + +### 생성 / 수정 + +- 필드: **이름**(필수), **Entity ID**(필수, 테넌트 내 중복 불가 → 중복 시 409), **ACS URL**(필수), SLO URL, SP 인증서(`cert`), NameID Format. +- **ACS/SLO URL 검증**: `validateSamlUrl` 로 형식 검사. +- **NameID Format**: emailAddress / unspecified / persistent / transient(SAML 표준 URN) 중에서만 허용. +- 서명/암호화 옵션: + - **`signResponse` 는 항상 `true` 로 강제**됩니다(관리 UI 가 false 를 보내도 무시). XSW 계열 공격 방지를 위해 IdP 가 Response 자체를 항상 서명. + - `signAssertion`, `wantAuthnRequestsSigned` 는 토글. + - **`encryptAssertion` 을 켜려면 SP 공개키(cert)가 반드시 있어야** 합니다(없으면 400). +- **allowedAttributes**: 콤마 구분. 허용 키 화이트리스트(`email`, `username`, `displayName`, `givenName`, `familyName`, `surName`, `phoneNumber`, `department`, `team`, `jobTitle`, `position`, `Role`, `RoleLabel`)에 없는 값은 무시됩니다. +- 보안 설정 변경(특히 **cert / acsUrl / wantAuthnRequestsSigned**)은 ACS 하이재킹 포렌식을 위해 before/after diff 가 감사 로그(`saml_sp_updated`)에 상세 기록됩니다. + +### 상세 (`/admin/saml-sps/[id]`) + +- OIDC 클라이언트와 동일하게 **서비스 role** 을 정의(key/label/description/isDefault/displayOrder). 4장 참조. + +### 메타데이터 + +- IdP 측 SAML 메타데이터는 `/saml/metadata` 에서 제공됩니다(SP 설정 시 참조). + +--- + +## 7. 스킨(커스텀 로그인 UI) 등록 (`/admin/skins`) + +외부에 호스팅한 HTML 을 가져와 로그인/가입 등 인증 화면을 클라이언트별로 커스터마이즈합니다. 사용법 안내는 **`/admin/skins/guide`** 에서 확인할 수 있습니다. + +### 등록 필드 + +- **대상 클라이언트**: `clientType`(oidc/saml) + `clientRefId`. +- **스킨 타입(`skinType`)**: `login` / `signup` / `find_id` / `find_password` / `mfa` / `reset_password`. +- **Fetch URL**: 스킨 HTML 을 가져올 URL. **https 필수**, loopback/내부주소(127.x, link-local) 금지(SSRF 방지). +- **Fetch Secret**: 스킨 서버 인증용 시크릿. IdP 가 스킨 HTML 을 가져올 때 **`X-IDP-Token`** 헤더로 이 값을 전송하므로, 스킨 서버는 이 헤더를 검증해 접근을 통제할 수 있습니다(선택). +- **캐시 TTL(`cacheTtlSeconds`)**: 기본 3600초, 0 이상, **최대 86400초(1일)**. + +### 운영 + +- **수정 / 삭제 / 활성화 토글 / 캐시 무효화(`invalidateCache`)** 지원. URL·TTL 변경이나 삭제 시 캐시가 자동 무효화됩니다. +- 같은 (클라이언트, 스킨타입) 조합 중복 등록 시 409. + +### 치환자(placeholder) + +스킨 HTML 안에서 `{{...}}` 형태로 사용하며, IdP 가 렌더링 시 값을 채웁니다. **총 6개**이고, 스킨 타입별 적용 범위가 다릅니다. + +| 치환자 | 채워지는 값 | 적용 스킨 | +| ------------------------ | ---------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------- | +| `{{IDP_FORM_ACTION}}` | 항상 빈 문자열 `""`(비어 있으면 폼이 **현재 URL로 POST**) | 모든 스킨 공통 | +| `{{IDP_REDIRECT_TO}}` | `escapeHtml(redirectTo ?? "")` — hidden input 용 | **login / signup / reset_password** 에서 채워짐. find_id·find_password·mfa 는 `""` | +| `{{IDP_SKIN_HINT}}` | `escapeHtml(skinHint)` — hidden input 용(어떤 스킨을 쓸지 서버에 되전달) | 모든 스킨 공통 | +| `{{IDP_REGISTERED}}` | 회원가입 완료 직후 `"1"`, 그 외 `""`(가입 완료 안내 노출용) | **login 전용** | +| `{{IDP_PASSWORD_RESET}}` | 비밀번호 재설정 완료 직후 `"1"`, 그 외 `""`(재설정 완료 안내 노출용) | **login 전용** | +| `{{IDP_FLASH_MSG}}` | `escapeHtml(flashMsg)` — 서버가 채우는 플래시/오류 메시지(이미 HTML 이스케이프됨). 없으면 `""` | 모든 스킨 공통(폼 재제출 오류 표시) | + +> **필수 hidden input**: `login` 스킨의 `