/* * 뮤테이션 게이트 가드 — **트윈을 바꾸는 문은 전부 잠겨 있다.** * * ── 계기 (2026-08-06) ────────────────────────────────────────────────────── * 권한 모델을 손대며 세어 보니 트윈 뮤테이션 24개 중 **22개에 게이트가 없었다.** 인스턴스 삭제와 * 레퍼런스 자격정보 저장까지 로그인만 하면 누구나 부를 수 있었다. 발견은 사람 눈이었다 — 즉 * 25번째 뮤테이션이 게이트 없이 들어오면 아무것도 잡지 못한다. 그래서 기계가 센다. * * ── 왜 문자열을 그대로 비교하나 ───────────────────────────────────────────── * "게이트가 있나" 만 보면 `domainOwnerGranted: false` 로 약해지거나 category 가 다른 값으로 갈라져도 * 통과한다. 게이트는 **한 문장이어야** 서버·클라·관리 화면이 같은 권한을 말한다. 그래서 정본 문장과 * 글자까지 같은지 본다. * * ── 조회는 왜 검사하지 않나 (ADR-0027) ────────────────────────────────────── * 조회에는 게이트를 두지 않기로 했다 — `twinKpi` 가 같은 판정을 게이트 없이 이미 내주므로, 일부 * 조회만 잠그면 보안이 아니라 화면 고장이 된다. 이 결정이 바뀌면 아래 "정본 문장" 검사가 먼저 * 깨진다. 그때는 테스트를 고치지 말고 **ADR 을 먼저 고친다.** */ import { test } from 'node:test' import assert from 'node:assert/strict' import { readdirSync, readFileSync, statSync } from 'node:fs' import { join } from 'node:path' /** 정본 게이트 — 트윈을 **바꾸는** 모든 작업이 요구하는 단 하나의 권한. */ const GATE = `@Directive('@privilege(category: "twin", privilege: "mutation", ` + `domainOwnerGranted: true, superUserGranted: true)')` /** * 두 번째 게이트 — **테넌트를 넘어 읽는 운영 계기판** (ADR-0027 예외, 2026-08-07). * * ADR-0027 의 전제는 "읽기의 경계는 테넌트 소속" 이라 조회에는 게이트를 붙이지 않는다. 그런데 프로세스 * 부하 계기판(`twinLoad`)은 이벤트 루프가 프로세스 공유이므로 **일부러 전 테넌트를 합산한다** — 소속이 * 경계가 되지 못한다. 그래서 시스템 소유만 본다. **권한 이름을 만들지 않았다**(category·privilege 없음). * * 늘리지 말 것. 조건은 ADR-0027 의 예외 절에 있고, 셋 중 하나라도 아니면 조회 무게이트가 그대로다. */ const OPS_GATE = `@Directive('@privilege(superUserGranted: true)')` const ALLOWED = [GATE, OPS_GATE] const SERVER = new URL('../server', import.meta.url).pathname function walk(dir: string, out: string[] = []): string[] { let entries: string[] try { entries = readdirSync(dir) } catch { return out } for (const e of entries) { const p = join(dir, e) if (statSync(p).isDirectory()) walk(p, out) else if (p.endsWith('.ts') && !p.endsWith('.d.ts')) out.push(p) } return out } /** * `@Mutation` 위에 붙은 데코레이터·주석 덩이를 거꾸로 모은다. * * 데코레이터가 바로 붙어 있다고 가정하지 않는다 — 나중에 다른 데코레이터가 끼어들어도 게이트를 * 찾아야 한다. 덩이의 끝은 코드(다른 메서드의 `}` 등)를 만나는 자리다. */ function decoratorBlockAbove(lines: string[], at: number): string[] { const block: string[] = [] for (let i = at - 1; i >= 0; i--) { const t = lines[i].trim() if (!t) continue /* 주석과 데코레이터 사이의 빈 줄은 덩이를 끊지 않는다 */ const isBlockPart = t.startsWith('@') || t.startsWith('*') || t.startsWith('/*') || t.startsWith('//') if (!isBlockPart) break block.push(t) } return block } const files = walk(SERVER) test('트윈을 바꾸는 뮤테이션은 전부 정본 게이트를 달고 있다', () => { const ungated: string[] = [] let mutations = 0 for (const f of files) { const lines = readFileSync(f, 'utf-8').split('\n') lines.forEach((line, i) => { if (!line.trim().startsWith('@Mutation')) return mutations++ if (!decoratorBlockAbove(lines, i).some(l => l === GATE)) { /* 어느 뮤테이션인지 이름으로 말한다 — 줄번호만 주면 고치는 사람이 다시 찾아야 한다. */ const name = (lines.slice(i, i + 6).join(' ').match(/async\s+(\w+)/) ?? [, '?'])[1] ungated.push(`${f.split('/').slice(-2).join('/')}:${i + 1} ${name}`) } }) } /* 훑기가 망가져 0건이 되면 "위반 없음" 으로 보인다 — 그 거짓 통과를 막는다. */ assert.ok(mutations >= 20, `뮤테이션을 ${mutations}개만 찾았다 — 소스 훑기가 망가졌다`) assert.deepEqual( ungated, [], `게이트 없는 뮤테이션 ${ungated.length}건 — 로그인만 하면 누구나 부를 수 있다:\n${ungated.join('\n')}` ) }) test('권한 문장이 하나로 통일되어 있다 — 약해지거나 서로 어긋나지 않았다', () => { const odd: string[] = [] for (const f of files) { readFileSync(f, 'utf-8') .split('\n') .forEach((line, i) => { const t = line.trim() if (!t.startsWith("@Directive('@privilege")) return if (!ALLOWED.includes(t)) odd.push(`${f.split('/').slice(-2).join('/')}:${i + 1} ${t}`) }) } assert.deepEqual( odd, [], `정본과 다른 권한 문장 ${odd.length}건. 쓰기는 GATE 하나, 테넌트를 넘어 읽는 운영 계기판만 ` + `OPS_GATE. 그 밖으로 늘리는 것이 의도라면 ADR-0027 을 먼저 고치고 이 검사의 정본을 함께 옮긴다:` + `\n${odd.join('\n')}` ) })