diff --git a/deploy/README.md b/deploy/README.md index 6dc2b01..c3088cc 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -41,6 +41,12 @@ GitHub Secrets는 `AWS_REGION`, `AWS_ROLE_ARN`, `EC2_INSTANCE_ID`만 사용한 - ECR `evn-warp-buildcache`는 실행·배포하지 않으며 EC2 instance role에 pull 권한을 부여하지 않는다. - 활성 운영 workflow는 `.github/workflows/deploy-ec2-ssm.yml` 하나다. 과거 EC2 시작, DB count, A3/KPI import workflow는 실행 경로로 복원하지 않는다. +## 고객 통합 이전 baseline의 롤백 보호 + +이 baseline은 인증된 고객 쓰기 요청마다 `sqlite_master`에서 `CustomerMerge` 테이블 존재 여부만 확인한다. 고객 행은 조회하지 않는다. 테이블이 없으면 기존 고객 쓰기를 허용하며, 테이블이 생기면 `/api/customers` 및 하위 경로의 GET·HEAD·OPTIONS 이외 요청을 handler 실행 전에 409로 차단한다. 고객 생성·수정·삭제, 문서·연락처·활동 변경이 모두 포함된다. 메타데이터 조회 실패 시에는 503으로 쓰기를 차단한다. + +따라서 새 schema가 공유 DB에 적용된 순간부터, traffic 전환 전이나 이 baseline으로 rollback한 동안에는 고객 기능이 임시 읽기 전용이다. 고객 조회와 다른 WARP 경로는 유지된다. 고객 수정은 통합 alias와 버전 검증을 지원하는 최신 앱을 복구한 뒤 재개한다. 이 제한은 baseline 이미지에 보존하며, 최신 앱 통합 시에는 baseline 전용 guard를 제거한다. 기존 `/api/migrate/merge-customers`는 schema와 무관하게 계속 409를 반환한다. + ## Migration evidence contract WARP는 SQLite custom runner와 `_WarpSchemaMigration` ledger를 사용한다. BUILDUP-EV의 PostgreSQL Prisma Migrate 구현과 엔진은 다르지만 운영 증거는 다음 의미로 맞춘다. diff --git a/deploy/RUNBOOK.md b/deploy/RUNBOOK.md index 1cd28e3..11fe01e 100644 --- a/deploy/RUNBOOK.md +++ b/deploy/RUNBOOK.md @@ -85,6 +85,12 @@ DB schema migration은 별도 Issue와 양 Revision 호환성 검증을 거쳐 ` privacy preflight 차단은 제거 대상 데이터가 남았거나 query 계약이 잘못된 상태다. 운영 DB 행을 출력해 조사하지 말고 해당 migration Issue에서 승인된 read-only 집계 query와 데이터 정리 절차를 수정한 뒤 같은 revision을 다시 검증한다. 이미 적용된 migration의 audit는 재실행하지 않는다. +### 고객 통합 schema 적용 후 baseline으로 복구할 때 + +공유 DB에 `CustomerMerge` 테이블이 만들어지면 이 baseline의 고객 쓰기 보호가 즉시 적용된다. candidate 준비·전환 실패로 기존 baseline에 머무르거나 `rollback`으로 돌아온 경우에도 동일하다. 고객 생성·수정·삭제와 문서·연락처·활동 변경 등 `/api/customers` 및 모든 하위 경로의 GET·HEAD·OPTIONS 이외 요청은 handler 실행 전에 409와 “복구 버전에서는 고객 정보 수정이 중단됩니다. 최신 버전 복구 후 진행해 주세요.”를 반환한다. 메타데이터 확인 자체가 실패하면 503으로 차단한다. 테이블이 아직 없으면 기존 고객 쓰기는 정상 동작한다. + +고객 조회와 다른 WARP 경로는 계속 사용할 수 있다. 409는 롤백 중 고객 데이터 손실을 막기 위한 임시 읽기 전용 상태다. 통합 alias와 버전 검증을 지원하는 검증된 최신 앱을 공식 `release`로 복구하고 공개 `/api/readyz`의 exact Revision·digest를 확인한 뒤 고객 수정을 재개한다. 쓰기를 재개하려고 guard를 우회하거나 테이블 삭제·DB restore를 수행하지 않는다. 최신 앱에서는 baseline 전용 guard를 제거하되, 안전한 직전 slot 이미지에는 이 보호를 유지한다. + ## 4. ENV 변경 운영 ENV는 SSM `/evn-warp/app-env`만 수정한다. 변경 전후 Parameter version을 기록하고 값은 터미널·Issue·PR·Actions에 출력하지 않는다. 수정 후 `validate`를 먼저 실행한다. diff --git a/deploy/tests/test_legacy_customer_write_guard.py b/deploy/tests/test_legacy_customer_write_guard.py new file mode 100644 index 0000000..d820e18 --- /dev/null +++ b/deploy/tests/test_legacy_customer_write_guard.py @@ -0,0 +1,121 @@ +import subprocess +import textwrap +import unittest +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[2] + + +class LegacyCustomerWriteGuardTests(unittest.TestCase): + def test_proxy_blocks_customer_writes_only_after_merge_schema_exists(self): + # Run the real proxy with synthetic auth, an in-memory SQLite schema and no app DB imports. + program = textwrap.dedent(r""" + const assert = require('node:assert/strict') + const fs = require('node:fs') + const vm = require('node:vm') + globalThis.AsyncLocalStorage = require('node:async_hooks').AsyncLocalStorage + const ts = require('typescript') + const { createClient } = require('@libsql/client') + const { NextRequest, NextResponse } = require('next/server') + const { unstable_doesMiddlewareMatch } = require('next/experimental/testing/server') + const db = createClient({ url: 'file::memory:' }) + let queries = 0, forwards = 0, failQuery = false + const imports = { + '@/auth': { auth: handler => handler }, + '@/lib/db': { prisma: { $queryRaw: async (strings, ...values) => { + queries++ + const sql = strings.join('?') + assert.match(sql, /^\s*SELECT\s+1\s+FROM\s+sqlite_master\b/i) + assert.doesNotMatch(sql, /\bJOIN\b|;/i) + assert.deepEqual(values, []) + if (failQuery) throw new Error('private-database-diagnostic') + return (await db.execute(sql)).rows + } } }, + '@/lib/account-control/boundary': { + AccountBoundaryError: class extends Error {}, + buildForwardedAccountHeaders: (headers, subject) => { + const forwarded = new Headers(headers) + forwarded.set('x-account-subject', subject) + return { headers: forwarded, context: { correlationId: 'test-correlation' } } + }, + }, + 'next/server': { NextResponse: { + json: (...args) => NextResponse.json(...args), + redirect: (...args) => NextResponse.redirect(...args), + next: (...args) => { forwards++; return NextResponse.next(...args) }, + } }, + } + const exports = {} + vm.runInNewContext(ts.transpileModule(fs.readFileSync('proxy.ts', 'utf8'), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 }, + }).outputText, { + exports, URL, + require: id => { assert.ok(Object.hasOwn(imports, id), `Unexpected import: ${id}`); return imports[id] }, + }, { filename: 'proxy.ts' }) + const proxy = exports.default + const message = '복구 버전에서는 고객 정보 수정이 중단됩니다. 최신 버전 복구 후 진행해 주세요.' + const paths = ['/api/customers', '/api/customers/', '/api/customers/customer', + '/api/customers/customer/documents', '/api/customers/customer/contact-card', + '/api/customers/customer/activities?activityId=activity', + '/api/%63ustomers/id', '/%61pi/customers/id/documents', '/api/customers%2Fid'] + async function request(method, path, status, checked, forwarded, auth = { user: { id: 'actor' } }) { + assert.equal(unstable_doesMiddlewareMatch({ config: exports.config, nextConfig: {}, url: path }), true) + const req = new NextRequest(new URL(path, 'https://warp.test'), { method }) + req.auth = auth + const beforeQueries = queries, beforeForwards = forwards + const response = await proxy(req) + assert.equal(response.status, status, `${method} ${path}`) + assert.equal(queries - beforeQueries, checked, `${method} ${path}: schema lookup`) + assert.equal(forwards - beforeForwards, forwarded, `${method} ${path}: handler forwarding`) + if (forwarded) { + assert.equal(response.headers.get('x-middleware-next'), '1') + assert.equal(response.headers.get('x-correlation-id'), 'test-correlation') + } else { + assert.equal(response.headers.get('x-middleware-next'), null) + } + if (status === 409 || status === 503) { + assert.equal(response.headers.get('cache-control'), 'no-store') + const body = await response.json() + assert.ok(body.error) + assert.doesNotMatch(body.error, /private-database-diagnostic|sqlite|SELECT/i) + if (status === 409) assert.equal(body.error, message) + } + } + ;(async () => { + try { + // Same proxy instance: absence must not be cached across a forward migration. + await db.execute('CREATE TABLE CustomerMergeArchive (id TEXT)') + await db.execute('CREATE VIEW CustomerMerge AS SELECT 1') + for (const path of paths) for (const method of ['POST', 'PUT', 'PATCH', 'DELETE']) { + await request(method, path, 200, 1, 1) + } + await db.execute('DROP VIEW CustomerMerge') + await db.execute('CREATE TABLE CustomerMerge (sourceId TEXT PRIMARY KEY)') + for (const path of paths) for (const method of ['POST', 'PUT', 'PATCH', 'DELETE']) { + await request(method, path, 409, 1, 0) + } + // A missing session/subject still follows the existing auth boundary without a DB read. + await request('PUT', paths[2], 307, 0, 0, null) + await request('PUT', paths[2], 401, 0, 0, { user: {} }) + failQuery = true + for (const path of paths) await request('DELETE', path, 503, 1, 0) + // Reads and unrelated writes must not depend on metadata availability. + for (const path of paths) for (const method of ['GET', 'HEAD', 'OPTIONS']) { + await request(method, path, 200, 0, 1) + } + for (const path of ['/api/deals/deal', '/api/activities/activity', '/api/customers-export', + '/api/%63ustomers-export', '/api/deals/%ZZ', '/funnel']) { + for (const method of ['GET', 'POST', 'PUT', 'DELETE']) await request(method, path, 200, 0, 1) + } + } finally { db.close() } + })().catch(error => { console.error(error); process.exitCode = 1 }) + """) + result = subprocess.run( + ["node", "-e", program], cwd=ROOT, capture_output=True, text=True, timeout=30, + ) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + + +if __name__ == "__main__": + unittest.main() diff --git a/proxy.ts b/proxy.ts index c59a853..ea7977c 100644 --- a/proxy.ts +++ b/proxy.ts @@ -1,5 +1,6 @@ import { auth } from '@/auth' import { AccountBoundaryError, buildForwardedAccountHeaders } from '@/lib/account-control/boundary' +import { prisma } from '@/lib/db' import { NextResponse } from 'next/server' // 사외 계정은 영업 파이프라인 · 고객관리(CRM)만 접근 가능 @@ -13,7 +14,7 @@ const MOBILE_RE = /Android.*Mobile|webOS|iPhone|iPod|BlackBerry|IEMobile|Opera M * Tracking: EVNSolution/EVN-WARP#2 * Security: protected account-bound actions must not bypass this boundary. */ -export default auth((req) => { +export default auth(async (req) => { const isLoggedIn = !!req.auth const pathname = req.nextUrl.pathname const isLoginPage = pathname === '/login' @@ -54,6 +55,28 @@ export default auth((req) => { } try { const forwarded = buildForwardedAccountHeaders(req.headers, subject) + let customerPathname = pathname + try { customerPathname = decodeURIComponent(pathname) } catch { /* Keep malformed paths unchanged. */ } + if ((customerPathname === '/api/customers' || customerPathname.startsWith('/api/customers/')) + && !['GET', 'HEAD', 'OPTIONS'].includes(req.method)) { + // Baseline-only guard: recheck metadata on every write, including after a live schema migration. + try { + const tables = await prisma.$queryRaw` + SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'CustomerMerge' LIMIT 1 + ` + if (tables.length > 0) { + return NextResponse.json( + { error: '복구 버전에서는 고객 정보 수정이 중단됩니다. 최신 버전 복구 후 진행해 주세요.' }, + { status: 409, headers: { 'Cache-Control': 'no-store' } }, + ) + } + } catch { + return NextResponse.json( + { error: '고객 정보 수정 가능 여부를 확인할 수 없습니다. 잠시 후 다시 시도해 주세요.' }, + { status: 503, headers: { 'Cache-Control': 'no-store' } }, + ) + } + } const response = NextResponse.next({ request: { headers: forwarded.headers } }) response.headers.set('X-Correlation-ID', forwarded.context.correlationId) return response