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
6 changes: 6 additions & 0 deletions deploy/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 구현과 엔진은 다르지만 운영 증거는 다음 의미로 맞춘다.
Expand Down
6 changes: 6 additions & 0 deletions deploy/RUNBOOK.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`를 먼저 실행한다.
Expand Down
121 changes: 121 additions & 0 deletions deploy/tests/test_legacy_customer_write_guard.py
Original file line number Diff line number Diff line change
@@ -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()
25 changes: 24 additions & 1 deletion proxy.ts
Original file line number Diff line number Diff line change
@@ -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)만 접근 가능
Expand All @@ -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'
Expand Down Expand Up @@ -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<unknown[]>`
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
Expand Down
Loading