nestjs-tenant-shield 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (167) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +612 -0
  3. package/dist/cache/cache.registry.d.ts +19 -0
  4. package/dist/cache/cache.registry.d.ts.map +1 -0
  5. package/dist/cache/cache.registry.js +51 -0
  6. package/dist/cache/cache.registry.js.map +1 -0
  7. package/dist/cache/cache.service.d.ts +44 -0
  8. package/dist/cache/cache.service.d.ts.map +1 -0
  9. package/dist/cache/cache.service.js +64 -0
  10. package/dist/cache/cache.service.js.map +1 -0
  11. package/dist/cache/index.d.ts +3 -0
  12. package/dist/cache/index.d.ts.map +1 -0
  13. package/dist/cache/index.js +19 -0
  14. package/dist/cache/index.js.map +1 -0
  15. package/dist/constants/index.d.ts +64 -0
  16. package/dist/constants/index.d.ts.map +1 -0
  17. package/dist/constants/index.js +67 -0
  18. package/dist/constants/index.js.map +1 -0
  19. package/dist/context/get-current-tenant-id.d.ts +30 -0
  20. package/dist/context/get-current-tenant-id.d.ts.map +1 -0
  21. package/dist/context/get-current-tenant-id.js +40 -0
  22. package/dist/context/get-current-tenant-id.js.map +1 -0
  23. package/dist/context/index.d.ts +7 -0
  24. package/dist/context/index.d.ts.map +1 -0
  25. package/dist/context/index.js +23 -0
  26. package/dist/context/index.js.map +1 -0
  27. package/dist/context/run-with-tenant.d.ts +84 -0
  28. package/dist/context/run-with-tenant.d.ts.map +1 -0
  29. package/dist/context/run-with-tenant.js +95 -0
  30. package/dist/context/run-with-tenant.js.map +1 -0
  31. package/dist/context/tenant-context.storage.d.ts +43 -0
  32. package/dist/context/tenant-context.storage.d.ts.map +1 -0
  33. package/dist/context/tenant-context.storage.js +45 -0
  34. package/dist/context/tenant-context.storage.js.map +1 -0
  35. package/dist/decorators/cacheable.decorator.d.ts +27 -0
  36. package/dist/decorators/cacheable.decorator.d.ts.map +1 -0
  37. package/dist/decorators/cacheable.decorator.js +108 -0
  38. package/dist/decorators/cacheable.decorator.js.map +1 -0
  39. package/dist/decorators/index.d.ts +13 -0
  40. package/dist/decorators/index.d.ts.map +1 -0
  41. package/dist/decorators/index.js +29 -0
  42. package/dist/decorators/index.js.map +1 -0
  43. package/dist/decorators/require-tenant.decorator.d.ts +41 -0
  44. package/dist/decorators/require-tenant.decorator.d.ts.map +1 -0
  45. package/dist/decorators/require-tenant.decorator.js +125 -0
  46. package/dist/decorators/require-tenant.decorator.js.map +1 -0
  47. package/dist/decorators/system-action.decorator.d.ts +39 -0
  48. package/dist/decorators/system-action.decorator.d.ts.map +1 -0
  49. package/dist/decorators/system-action.decorator.js +50 -0
  50. package/dist/decorators/system-action.decorator.js.map +1 -0
  51. package/dist/decorators/tenant-context.decorator.d.ts +33 -0
  52. package/dist/decorators/tenant-context.decorator.d.ts.map +1 -0
  53. package/dist/decorators/tenant-context.decorator.js +54 -0
  54. package/dist/decorators/tenant-context.decorator.js.map +1 -0
  55. package/dist/errors/cross-tenant-access.error.d.ts +29 -0
  56. package/dist/errors/cross-tenant-access.error.d.ts.map +1 -0
  57. package/dist/errors/cross-tenant-access.error.js +37 -0
  58. package/dist/errors/cross-tenant-access.error.js.map +1 -0
  59. package/dist/errors/index.d.ts +10 -0
  60. package/dist/errors/index.d.ts.map +1 -0
  61. package/dist/errors/index.js +26 -0
  62. package/dist/errors/index.js.map +1 -0
  63. package/dist/errors/invalid-tenant-source.error.d.ts +20 -0
  64. package/dist/errors/invalid-tenant-source.error.d.ts.map +1 -0
  65. package/dist/errors/invalid-tenant-source.error.js +28 -0
  66. package/dist/errors/invalid-tenant-source.error.js.map +1 -0
  67. package/dist/errors/missing-tenant-context.error.d.ts +22 -0
  68. package/dist/errors/missing-tenant-context.error.d.ts.map +1 -0
  69. package/dist/errors/missing-tenant-context.error.js +32 -0
  70. package/dist/errors/missing-tenant-context.error.js.map +1 -0
  71. package/dist/index.d.ts +36 -0
  72. package/dist/index.d.ts.map +1 -0
  73. package/dist/index.js +56 -0
  74. package/dist/index.js.map +1 -0
  75. package/dist/interfaces/cacheable-options.interface.d.ts +37 -0
  76. package/dist/interfaces/cacheable-options.interface.d.ts.map +1 -0
  77. package/dist/interfaces/cacheable-options.interface.js +3 -0
  78. package/dist/interfaces/cacheable-options.interface.js.map +1 -0
  79. package/dist/interfaces/index.d.ts +13 -0
  80. package/dist/interfaces/index.d.ts.map +1 -0
  81. package/dist/interfaces/index.js +30 -0
  82. package/dist/interfaces/index.js.map +1 -0
  83. package/dist/interfaces/require-tenant-options.interface.d.ts +23 -0
  84. package/dist/interfaces/require-tenant-options.interface.d.ts.map +1 -0
  85. package/dist/interfaces/require-tenant-options.interface.js +3 -0
  86. package/dist/interfaces/require-tenant-options.interface.js.map +1 -0
  87. package/dist/interfaces/tenant-context-options.interface.d.ts +19 -0
  88. package/dist/interfaces/tenant-context-options.interface.d.ts.map +1 -0
  89. package/dist/interfaces/tenant-context-options.interface.js +3 -0
  90. package/dist/interfaces/tenant-context-options.interface.js.map +1 -0
  91. package/dist/interfaces/tenant-context.interface.d.ts +26 -0
  92. package/dist/interfaces/tenant-context.interface.d.ts.map +1 -0
  93. package/dist/interfaces/tenant-context.interface.js +3 -0
  94. package/dist/interfaces/tenant-context.interface.js.map +1 -0
  95. package/dist/interfaces/tenant-shield-options.interface.d.ts +141 -0
  96. package/dist/interfaces/tenant-shield-options.interface.d.ts.map +1 -0
  97. package/dist/interfaces/tenant-shield-options.interface.js +11 -0
  98. package/dist/interfaces/tenant-shield-options.interface.js.map +1 -0
  99. package/dist/middleware/index.d.ts +2 -0
  100. package/dist/middleware/index.d.ts.map +1 -0
  101. package/dist/middleware/index.js +18 -0
  102. package/dist/middleware/index.js.map +1 -0
  103. package/dist/middleware/tenant-context.middleware.d.ts +30 -0
  104. package/dist/middleware/tenant-context.middleware.d.ts.map +1 -0
  105. package/dist/middleware/tenant-context.middleware.js +68 -0
  106. package/dist/middleware/tenant-context.middleware.js.map +1 -0
  107. package/dist/options/index.d.ts +2 -0
  108. package/dist/options/index.d.ts.map +1 -0
  109. package/dist/options/index.js +18 -0
  110. package/dist/options/index.js.map +1 -0
  111. package/dist/options/options.registry.d.ts +8 -0
  112. package/dist/options/options.registry.d.ts.map +1 -0
  113. package/dist/options/options.registry.js +36 -0
  114. package/dist/options/options.registry.js.map +1 -0
  115. package/dist/resolvers/custom.resolver.d.ts +29 -0
  116. package/dist/resolvers/custom.resolver.d.ts.map +1 -0
  117. package/dist/resolvers/custom.resolver.js +47 -0
  118. package/dist/resolvers/custom.resolver.js.map +1 -0
  119. package/dist/resolvers/header.resolver.d.ts +22 -0
  120. package/dist/resolvers/header.resolver.d.ts.map +1 -0
  121. package/dist/resolvers/header.resolver.js +39 -0
  122. package/dist/resolvers/header.resolver.js.map +1 -0
  123. package/dist/resolvers/index.d.ts +13 -0
  124. package/dist/resolvers/index.d.ts.map +1 -0
  125. package/dist/resolvers/index.js +29 -0
  126. package/dist/resolvers/index.js.map +1 -0
  127. package/dist/resolvers/jwt.resolver.d.ts +35 -0
  128. package/dist/resolvers/jwt.resolver.d.ts.map +1 -0
  129. package/dist/resolvers/jwt.resolver.js +51 -0
  130. package/dist/resolvers/jwt.resolver.js.map +1 -0
  131. package/dist/resolvers/resolver.factory.d.ts +12 -0
  132. package/dist/resolvers/resolver.factory.d.ts.map +1 -0
  133. package/dist/resolvers/resolver.factory.js +43 -0
  134. package/dist/resolvers/resolver.factory.js.map +1 -0
  135. package/dist/resolvers/subdomain.resolver.d.ts +37 -0
  136. package/dist/resolvers/subdomain.resolver.d.ts.map +1 -0
  137. package/dist/resolvers/subdomain.resolver.js +57 -0
  138. package/dist/resolvers/subdomain.resolver.js.map +1 -0
  139. package/dist/resolvers/tenant-resolver.interface.d.ts +22 -0
  140. package/dist/resolvers/tenant-resolver.interface.d.ts.map +1 -0
  141. package/dist/resolvers/tenant-resolver.interface.js +3 -0
  142. package/dist/resolvers/tenant-resolver.interface.js.map +1 -0
  143. package/dist/tenant-shield.module.d.ts +88 -0
  144. package/dist/tenant-shield.module.d.ts.map +1 -0
  145. package/dist/tenant-shield.module.js +263 -0
  146. package/dist/tenant-shield.module.js.map +1 -0
  147. package/dist/testing/index.d.ts +12 -0
  148. package/dist/testing/index.d.ts.map +1 -0
  149. package/dist/testing/index.js +28 -0
  150. package/dist/testing/index.js.map +1 -0
  151. package/dist/testing/test-helpers.d.ts +52 -0
  152. package/dist/testing/test-helpers.d.ts.map +1 -0
  153. package/dist/testing/test-helpers.js +72 -0
  154. package/dist/testing/test-helpers.js.map +1 -0
  155. package/dist/typeorm/index.d.ts +10 -0
  156. package/dist/typeorm/index.d.ts.map +1 -0
  157. package/dist/typeorm/index.js +26 -0
  158. package/dist/typeorm/index.js.map +1 -0
  159. package/dist/typeorm/raw-sql.helper.d.ts +35 -0
  160. package/dist/typeorm/raw-sql.helper.d.ts.map +1 -0
  161. package/dist/typeorm/raw-sql.helper.js +24 -0
  162. package/dist/typeorm/raw-sql.helper.js.map +1 -0
  163. package/dist/typeorm/tenant.subscriber.d.ts +61 -0
  164. package/dist/typeorm/tenant.subscriber.d.ts.map +1 -0
  165. package/dist/typeorm/tenant.subscriber.js +487 -0
  166. package/dist/typeorm/tenant.subscriber.js.map +1 -0
  167. package/package.json +109 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Jinyeong Jung
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,612 @@
1
+ # nestjs-tenant-shield
2
+
3
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
4
+ [![NestJS](https://img.shields.io/badge/NestJS-v10%2B-red)](https://nestjs.com/)
5
+
6
+ > NestJS B2B SaaS 백엔드의 데이터 격리를 데코레이터 한 줄로 자동화하는 라이브러리. Cross-tenant 데이터 누출 사고를 사전 차단하고, 코드를 깨끗하게 유지합니다.
7
+
8
+ ## ⚠️ 현재 상태 (솔직한 안내)
9
+
10
+ - v0.1이 배포되었습니다.
11
+ - v0.1은 TypeORM + discriminator 패턴 중심이며, v0.2 이후 기능(Bull/BullMQ, Prisma, RLS)은 계획 단계입니다.
12
+ - README 예시는 v0.1 기준이며, v0.2 기능은 별도 표기합니다.
13
+
14
+ ## 🎯 왜 만들었나요?
15
+
16
+ ### B2B SaaS의 가장 무서운 사고: 데이터 누출
17
+
18
+ ```
19
+ 하나의 학원 관리 SaaS = 여러 학원이 입주한 빌딩
20
+ - A학원: 강남 지점
21
+ - B학원: 종로 지점
22
+ - C학원: 부산 지점
23
+
24
+ ❌ 사고: A학원 사용자가 B학원 학생 명단을 봄 → 회사 망함
25
+ ```
26
+
27
+ ### 현재의 일반적 NestJS 코드 (지저분하고 위험)
28
+
29
+ ```typescript
30
+ // 호출자가 tenantId를 직접 넘겨야 해서 누락 위험이 큼
31
+ async findAll(tenantId: string) {
32
+ // where에 tenantId를 직접 넣어야만 같은 테넌트 데이터만 조회됨
33
+ return this.repo.find({ where: { tenantId } });
34
+ }
35
+
36
+ // 단건 조회도 id + tenantId를 매번 함께 조건으로 넣어야 안전함
37
+ async findOne(tenantId: string, id: string) {
38
+ // tenantId를 빼먹으면 다른 테넌트의 같은 id를 조회할 수 있음
39
+ return this.repo.findOne({ where: { id, tenantId } });
40
+ }
41
+
42
+ // 캐시 조회 시에도 tenantId를 키 prefix에 수동 반영해야 함
43
+ async getCachedRoster(tenantId: string) {
44
+ // prefix 누락 시 캐시가 테넌트 간 섞일 수 있음
45
+ return this.cache.get(`roster:${tenantId}`);
46
+ }
47
+ ```
48
+
49
+ 문제:
50
+ - 모든 메서드에 `tenantId` 수동 명시 → 빠뜨리면 데이터 누출
51
+ - 캐시 키 prefix 빠뜨리면 다른 tenant 데이터가 보임
52
+ - 백그라운드 작업에서 컨텍스트 잃기 쉬움
53
+ - 코드 리뷰에서 100% 잡아내기 어려움
54
+
55
+ ### nestjs-tenant-shield의 솔루션
56
+
57
+ ```typescript
58
+ // Nest DI에 서비스로 등록
59
+ @Injectable()
60
+ // 이 클래스의 메서드 실행 시 tenant 컨텍스트가 필수
61
+ @RequireTenant()
62
+ export class StudentsService {
63
+
64
+ // 호출부에서 tenantId를 전달하지 않아도 자동 격리됨
65
+ async findAll() {
66
+ // 내부적으로 현재 tenant 기준 WHERE가 주입됨
67
+ return this.repo.find();
68
+ }
69
+
70
+ // id만 받아도 tenant 범위 안에서만 조회됨
71
+ async findOne(id: string) {
72
+ // where에 tenantId를 직접 쓰지 않아도 안전하게 동작
73
+ return this.repo.findOne({ where: { id } });
74
+ }
75
+
76
+ // 캐시 사용 시에도 tenant 단위로 키 분리
77
+ @Cacheable({ ttl: 300, tenantScoped: true })
78
+ async getRoster() {
79
+ // 같은 key라도 tenant마다 별도 캐시 엔트리로 저장됨
80
+ return this.repo.find();
81
+ }
82
+ }
83
+ ```
84
+
85
+ 개선:
86
+ - tenant_id 명시 0번
87
+ - 빠뜨릴 가능성 0
88
+ - 캐시 자동 분리
89
+ - 실수로 다른 tenant 접근 시 즉시 throw
90
+
91
+ ## ✨ 핵심 차별화
92
+
93
+ | 기능 | nestjs-cls | nestjs-tenancy류 | nestjs-tenant-shield |
94
+ |---|---|---|---|
95
+ | AsyncLocalStorage | ✅ | 부분 | ✅ |
96
+ | Auto WHERE 주입 | ❌ | ❌ | ✅ 핵심 |
97
+ | Database/Schema 분리 | ❌ | ✅ | ✅ (v0.2) |
98
+ | 캐시 키 자동 분리 | ❌ | ❌ | ✅ |
99
+ | 백그라운드 작업 컨텍스트 | 부분 | ❌ | ✅ |
100
+ | 테스트 시 cross-tenant 자동 차단 | ❌ | ❌ | ✅ 핵심 |
101
+ | Postgres RLS 통합 | ❌ | ❌ | ✅ (v0.3) |
102
+
103
+ 카테고리: 기존은 "도구". nestjs-tenant-shield는 **"멀티테넌시 안전망"**.
104
+
105
+ ## ✅ 차별성 판단 (현재 기준)
106
+
107
+ - 아이디어 차별성: **있음**
108
+ - 구현 차별성: **아직 없음**
109
+
110
+ 왜냐하면 시장에서 평가하는 것은 문서가 아니라 아래 증거이기 때문입니다.
111
+
112
+ - 동작하는 패키지
113
+ - 신뢰 가능한 통합 테스트
114
+ - 실제 누출 시나리오 재현/차단 데모
115
+ - 외부 도입 사례 또는 파일럿
116
+
117
+ 이 프로젝트는 위 증거를 만드는 단계로 진입해야 본격적인 시장 경쟁력을 갖습니다.
118
+
119
+ ## 📦 설치
120
+
121
+ ```bash
122
+ pnpm add nestjs-tenant-shield # 라이브러리 본체 설치
123
+
124
+ # TypeORM 사용 시 추가 설치
125
+ pnpm add typeorm # TypeORM 연동 기능 사용
126
+
127
+ # Prisma 사용 시 추가 설치 (v0.2)
128
+ pnpm add @prisma/client # Prisma Client 연동 준비
129
+ ```
130
+
131
+ 필수 환경:
132
+ - NestJS 10.0+
133
+ - TypeScript 4.7+
134
+ - Node.js 18+
135
+ - TypeORM 0.3+ (v0.1)
136
+
137
+ 현재는 설치보다 아래 문서를 우선 확인하세요.
138
+
139
+ - `docs/tenant-shield-PRD.md`
140
+ - `docs/tenant-shield-api-spec.md`
141
+ - `claude/IMPLEMENTATION_PLAN.md`
142
+
143
+ ## 🚀 빠른 시작
144
+
145
+ 아래는 v0.1 기준 API 예시입니다. v0.2 이상 기능은 별도 표기합니다.
146
+
147
+ ### 1단계: 모듈 설정
148
+
149
+ ```typescript
150
+ // Nest의 모듈 데코레이터
151
+ import { Module } from '@nestjs/common';
152
+ // 멀티테넌시 보호 모듈
153
+ import { TenantShieldModule } from 'nestjs-tenant-shield';
154
+
155
+ // 루트 모듈 정의
156
+ @Module({
157
+ imports: [
158
+ // 전역 설정 등록
159
+ TenantShieldModule.forRoot({
160
+ strategy: 'discriminator', // 단일 테이블 + tenantId 컬럼 전략
161
+ tenantIdField: 'tenantId', // 테넌트 컬럼명 (서비스에 맞게 변경 가능)
162
+ tenantSource: 'header', // 요청 헤더에서 tenant 식별
163
+ headerName: 'x-tenant-id', // 사용할 헤더 이름
164
+ strictMode: true, // tenant 없으면 즉시 예외 발생
165
+ }),
166
+ ],
167
+ })
168
+ // 앱 시작 모듈
169
+ export class AppModule {}
170
+ ```
171
+
172
+ ### 2단계: Entity에 tenant_id 추가
173
+
174
+ ```typescript
175
+ import { Entity, Column, PrimaryGeneratedColumn } from 'typeorm';
176
+
177
+ @Entity()
178
+ export class Student {
179
+ @PrimaryGeneratedColumn()
180
+ id: number;
181
+
182
+ @Column()
183
+ tenantId: string;
184
+
185
+ @Column()
186
+ name: string;
187
+
188
+ @Column()
189
+ grade: number;
190
+ }
191
+ ```
192
+
193
+ ### 3단계: 서비스에 데코레이터
194
+
195
+ ```typescript
196
+ // 서비스 데코레이터 import
197
+ import { Injectable } from '@nestjs/common';
198
+ // tenant 강제 + 캐시 데코레이터 import
199
+ import { RequireTenant, Cacheable } from 'nestjs-tenant-shield';
200
+
201
+ // DI 등록
202
+ @Injectable()
203
+ // 이 서비스의 메서드는 tenant 컨텍스트 없으면 실행 불가
204
+ @RequireTenant()
205
+ export class StudentsService {
206
+ // 저장소 주입
207
+ constructor(private readonly repo: StudentsRepository) {}
208
+
209
+ // 현재 tenant 범위의 학생 목록 조회
210
+ async findAll() {
211
+ return this.repo.find();
212
+ }
213
+
214
+ // 현재 tenant 범위에서 id로 단건 조회
215
+ async findOne(id: string) {
216
+ return this.repo.findOne({ where: { id } });
217
+ }
218
+
219
+ // 현재 tenant 범위에서 학생 정보 수정
220
+ async update(id: string, dto: UpdateStudentDto) {
221
+ return this.repo.update(id, dto);
222
+ }
223
+
224
+ // tenant별 캐시 분리 + 300초 TTL
225
+ @Cacheable({ ttl: 300, tenantScoped: true })
226
+ async getStatistics() {
227
+ // count도 tenant 범위로 자동 제한됨
228
+ return { total: await this.repo.count() };
229
+ }
230
+ }
231
+ ```
232
+
233
+ ### 4단계: 백그라운드 작업 (v0.2)
234
+
235
+ ```typescript
236
+ // Bull 큐 프로세서 데코레이터 import
237
+ import { Processor, Process } from '@nestjs/bull';
238
+ // 잡 실행 시 tenant 컨텍스트 복원 데코레이터
239
+ import { TenantContext } from 'nestjs-tenant-shield';
240
+
241
+ // reports 큐를 처리하는 워커
242
+ @Processor('reports')
243
+ export class ReportProcessor {
244
+
245
+ // monthly 잡 타입 핸들러
246
+ @Process('monthly')
247
+ // 잡 payload의 tenant 정보로 컨텍스트를 설정
248
+ @TenantContext()
249
+ async generate(job: Job<{ tenantId: string }>) {
250
+ // 백그라운드에서도 tenant 격리 유지
251
+ return this.studentsService.findAll();
252
+ }
253
+ }
254
+ ```
255
+
256
+ ### 5단계: 테스트
257
+
258
+ ```typescript
259
+ // 테스트 헬퍼 및 에러 타입 import
260
+ import { runWithTenant, CrossTenantAccessError } from 'nestjs-tenant-shield';
261
+
262
+ // StudentsService 동작 검증
263
+ describe('StudentsService', () => {
264
+ // 같은 tenant 데이터만 보이는지 확인
265
+ it('A 학원 컨텍스트에서는 A 학생만 조회', async () => {
266
+ // 테스트 실행 컨텍스트를 academy-A로 고정
267
+ await runWithTenant('academy-A', async () => {
268
+ // 서비스 호출
269
+ const students = await service.findAll();
270
+ // 결과 전체가 academy-A 소속인지 검증
271
+ expect(students.every(s => s.tenantId === 'academy-A')).toBe(true);
272
+ });
273
+ });
274
+
275
+ // 다른 tenant 데이터 접근 차단 검증
276
+ it('cross-tenant 시도 시 자동 throw', async () => {
277
+ // 실행 컨텍스트는 academy-A
278
+ await runWithTenant('academy-A', async () => {
279
+ // academy-B 학생을 조회하면 예외가 발생해야 함
280
+ await expect(
281
+ service.findOne('student-from-academy-B')
282
+ ).rejects.toThrow(CrossTenantAccessError);
283
+ });
284
+ });
285
+ });
286
+ ```
287
+
288
+ ## 📖 핵심 개념
289
+
290
+ ### AsyncLocalStorage
291
+
292
+ 비유: 사무실에 여러 손님이 동시에 와도 각 손님 요청을 안 섞이게 처리.
293
+
294
+ 요청 진입 시 미들웨어가 "이 요청은 학원 A" 표를 붙이고, 비동기 호출 체인 끝까지 그 표가 따라옴.
295
+
296
+ ### Discriminator 패턴
297
+
298
+ 같은 테이블에 `tenant_id` 컬럼:
299
+
300
+ ```sql
301
+ | id | tenantId | name | grade |
302
+ |----|------------|---------|-------|
303
+ | 1 | academy-A | 김민수 | 90 |
304
+ | 2 | academy-A | 이지연 | 85 |
305
+ | 3 | academy-B | 박철수 | 88 | ← 다른 학원
306
+ ```
307
+
308
+ 쿼리 시 `WHERE tenantId = 'academy-A'` 빠뜨리면 사고. 라이브러리가 자동 주입.
309
+
310
+ ```sql
311
+ -- tenant_id 조건을 누락한 위험한 조회 (금지)
312
+ SELECT id, tenantId, name, grade
313
+ FROM student;
314
+
315
+ -- tenant_id 조건을 포함한 안전한 조회 (권장)
316
+ SELECT id, tenantId, name, grade
317
+ FROM student
318
+ WHERE tenantId = 'academy-A';
319
+ ```
320
+
321
+ ### 다층 격리 (Defense in Depth)
322
+
323
+ ```
324
+ 1층: ORM 쿼리 (자동 WHERE) ← v0.1
325
+ 2층: 캐시 키 (자동 prefix) ← v0.1
326
+ 3층: 백그라운드 큐 (컨텍스트 전파) ← v0.2
327
+ 4층: DB Row-Level Security ← v0.3
328
+ ```
329
+
330
+ 한 층 뚫려도 다음 층에서 차단.
331
+
332
+ ## 🔧 API 레퍼런스
333
+
334
+ 상세 API 명세는 [`docs/tenant-shield-api-spec.md`](./docs/tenant-shield-api-spec.md) 참조. 핵심만 요약:
335
+
336
+ ```typescript
337
+ // 모듈 설정: 앱 시작 시 tenant-shield 전역 옵션 등록
338
+ TenantShieldModule.forRoot({
339
+ strategy: 'discriminator', // 단일 DB/테이블 + tenant 컬럼 전략
340
+ tenantIdField: 'tenantId', // tenant 식별 컬럼명
341
+ tenantSource: 'header' | 'jwt' | 'subdomain' | 'custom', // tenant 추출 소스 타입
342
+ // ... source별 옵션 // 소스마다 추가 설정 가능
343
+ strictMode: true, // tenant 누락 요청 즉시 차단
344
+ })
345
+
346
+ // 데코레이터: 메서드/클래스에 보안 정책 부착
347
+ @RequireTenant({ allowSystem?: boolean }) // tenant 컨텍스트 강제
348
+ @SystemAction() // 시스템 작업(예외 허용 경로) 표시
349
+ @Cacheable({ ttl: number, tenantScoped?: boolean }) // 캐시 TTL + tenant 분리 여부
350
+ @TenantContext({ extractFrom?: (job) => string }) // v0.2, 큐 잡에서 tenant 추출
351
+
352
+ // 헬퍼: 코드에서 컨텍스트를 직접 제어할 때 사용
353
+ const tenantId = getCurrentTenantId(); // 현재 요청 tenant 읽기
354
+ await runWithTenant('A', async () => {}); // 특정 tenant 컨텍스트로 실행
355
+ await runWithoutTenant(async () => {}); // tenant 없이 시스템 작업 실행
356
+
357
+ // 에러: tenant 관련 예외 타입
358
+ class MissingTenantContextError extends Error {} // tenant가 없을 때
359
+ class CrossTenantAccessError extends Error {} // 타 tenant 접근 감지 시
360
+ class InvalidTenantSourceError extends Error {} // tenant 추출 소스 설정 오류
361
+ ```
362
+
363
+ ## 🛡️ 보안 가이드
364
+
365
+ ### nestjs-tenant-shield가 자동 보호하는 것
366
+
367
+ - ✅ 표준 Repository 메서드 (find, findOne, save, update, delete)
368
+ - ✅ QueryBuilder의 단일 entity 쿼리
369
+ - ✅ 캐시 키 분리
370
+ - ✅ 백그라운드 작업 컨텍스트 (v0.2)
371
+
372
+ ### nestjs-tenant-shield가 자동 보호 **못하는** 것 ⚠️
373
+
374
+ - ❌ Raw SQL (`repo.query`, `dataSource.query`)
375
+ - ❌ JOIN의 다른 테이블 (ON 절 명시 필요)
376
+ - ❌ Subquery 내부 (명시 필요)
377
+ - ❌ TypeORM CLI/Migration
378
+ - ❌ 멀티 DataSource 트랜잭션 (v0.1)
379
+ - ❌ 인증 미들웨어 외 tenant_id 변조
380
+
381
+ 상세는 [docs/SECURITY.md](./docs/SECURITY.md) 참조.
382
+
383
+ 현재 `docs/SECURITY.md`는 아직 생성 전이며, v0.1.0 공개 전 필수 산출물로 작성 예정입니다.
384
+
385
+ ### 진실의 한 문장
386
+
387
+ > **nestjs-tenant-shield는 "흔한 사고의 90%를 자동 차단"하는 도구입니다.**
388
+ > **나머지 10%는 위 한계를 인지하고 수동 처리해야 합니다.**
389
+ > **만능 보안이 아니며, 다층 방어(defense in depth)의 한 층입니다.**
390
+
391
+ ### 🔬 v0.1 자동 보호 정밀 범위
392
+
393
+ 라이브러리가 "어디까지" 자동으로 보호하는지 정확히 알아두면 사고를 막을 수 있습니다.
394
+
395
+ #### ✅ 자동 보호되는 TypeORM API
396
+
397
+ | API | 동작 | 비고 |
398
+ |---|---|---|
399
+ | `Repository.find(options)` | `options.where`에 `tenantId` 자동 머지 | 배열(OR)도 각 절에 머지 |
400
+ | `Repository.findOne(options)` | 동일 | |
401
+ | `Repository.findBy(where)` | `where`에 `tenantId` 머지 | |
402
+ | `Repository.findOneBy(where)` | 동일 | |
403
+ | `Repository.count(options)` | 동일 | |
404
+ | `Repository.save(entity)` | INSERT 시 `tenantId` 자동 주입 / cross-tenant insert 차단 | 배열도 처리 |
405
+ | `SelectQueryBuilder.getMany/One/ManyAndCount/Count/RawMany/RawOne` | 실행 직전 `andWhere('alias.tenantId = :id')` 자동 추가 | 이미 있으면 중복 추가 X |
406
+ | `UpdateQueryBuilder.execute()` | 동일하게 `andWhere` 자동 추가 | |
407
+ | `DeleteQueryBuilder.execute()` | 동일 | |
408
+ | Subscriber `beforeInsert` | `tenantId` 비어 있으면 자동 주입, 다른 tenant면 즉시 throw | |
409
+ | Subscriber `afterLoad` | 로드된 row의 `tenantId`가 컨텍스트와 다르면 즉시 throw | 최후의 안전망 |
410
+
411
+ #### ⚠️ 자동 보호 안 되는 API (수동 보호 필요)
412
+
413
+ | API | 권장 대응 |
414
+ |---|---|
415
+ | `Repository.query(rawSql)` | `withTenantWhere(sql, 'tenant_id')` 헬퍼로 SQL 변환 후 실행 |
416
+ | `dataSource.createQueryRunner().query()` | 동일하게 수동 헬퍼 사용 |
417
+ | `Repository.update(criteria, partial)` (옵션 없는 단순 형태) | QueryBuilder 사용 또는 `criteria`에 `tenantId` 명시 |
418
+ | `Repository.delete(criteria)` (옵션 없는 단순 형태) | 동일 |
419
+ | `Repository.insert(values)` | `save()` 사용 권장 (subscriber hook이 더 잘 동작) |
420
+ | Entity Listener (`@AfterLoad` 등 사용자 정의) | 사용자가 직접 `getCurrentTenantId()`로 검증 |
421
+
422
+ #### 🚪 의도적인 우회 경로
423
+
424
+ 다음은 의도된 cross-tenant 접근을 위한 "안전한 우회 경로"입니다:
425
+
426
+ 1. **`runWithoutTenant(fn)`** — 시스템 작업용. 컨텍스트에 `isSystemAction: true` 플래그가 박혀, Subscriber와 데코레이터가 자동 검사를 건너뜁니다.
427
+ 2. **`@SystemAction()` 데코레이터** — `@RequireTenant()` 클래스 안의 특정 메서드만 우회 표시. `forRoot.allowSystemActions: true` 필요.
428
+ 3. **`runWithTenant(otherTenantId, fn)`** — 명시적으로 다른 tenant 컨텍스트에 진입. cron이 모든 tenant를 순회할 때 사용.
429
+
430
+ ⚠️ 이 우회 경로들은 강력한 만큼 위험합니다. 보안 로그에 호출 위치를 남기고, 가능하면 `@SystemAction()` + `runWithTenant()` 조합으로 명확하게 표시하세요.
431
+
432
+ ### 권장 다층 보안 (Defense in Depth)
433
+
434
+ ```
435
+ 1. nestjs-tenant-shield (애플리케이션 자동, 90% 케이스) ← 본 라이브러리
436
+ 2. + 인증 미들웨어 (tenant_id 검증)
437
+ 3. + 코드 리뷰 가이드 (raw SQL/JOIN/subquery 점검)
438
+ 4. + Postgres RLS (DB 레이어, v0.3 통합)
439
+ 5. + DB 접근 권한 최소화
440
+ 6. + 정기 침투 테스트 (분기별)
441
+ ```
442
+
443
+ ## 🔄 기존 코드에서 마이그레이션
444
+
445
+ 기존에 수동으로 `WHERE tenantId` 적던 코드를 라이브러리로 전환하는 권장 절차.
446
+
447
+ ### Step 1: 한 모듈만 먼저
448
+ StudentsModule 같은 작은 모듈 하나만 먼저 적용. 나머지 모듈은 그대로.
449
+
450
+ ### Step 2: @RequireTenant 부착
451
+ 서비스 클래스에 `@RequireTenant()` 추가.
452
+
453
+ ```typescript
454
+ // Before
455
+ @Injectable()
456
+ export class StudentsService { /* ... */ }
457
+
458
+ // After
459
+ @Injectable()
460
+ @RequireTenant()
461
+ export class StudentsService { /* ... */ }
462
+ ```
463
+
464
+ ### Step 3: 수동 WHERE 제거
465
+ `where: { tenantId, ... }` → `where: { ... }`
466
+
467
+ ```typescript
468
+ // Before
469
+ return this.repo.find({ where: { tenantId, classId } });
470
+
471
+ // After
472
+ return this.repo.find({ where: { classId } }); // tenant 자동 주입
473
+ ```
474
+
475
+ ### Step 4: 통합 테스트로 회귀 확인
476
+ `runWithTenant`로 테스트 작성. 누출 자동 감지(`CrossTenantAccessError`).
477
+
478
+ ```typescript
479
+ await runWithTenant('A', async () => {
480
+ const list = await service.findAll();
481
+ expect(list.every(s => s.tenantId === 'A')).toBe(true);
482
+ });
483
+ ```
484
+
485
+ ### Step 5: 다음 모듈로 확장
486
+ 한 모듈씩 점진적으로. 일괄 변경 금지 — 한 번에 너무 많이 바꾸면 회귀 추적이 어려움.
487
+
488
+ ## 🗺️ 로드맵
489
+
490
+ 실행 판단 기준(Go/No-Go):
491
+
492
+ - 4주 내: 동작하는 최소 버전 + 통합 테스트 확보
493
+ - 6~8주 내: 외부 사용자 피드백 3건 이상 또는 파일럿 1건 이상
494
+ - 기준 미달 시: 범위 축소 또는 중단
495
+
496
+ ### v0.1 (개발 중)
497
+ - [ ] AsyncLocalStorage 컨텍스트 매니저
498
+ - [ ] @RequireTenant() 데코레이터
499
+ - [ ] TypeORM Subscriber (auto WHERE)
500
+ - [ ] @Cacheable({ tenantScoped })
501
+ - [ ] runWithTenant() 테스트 헬퍼
502
+
503
+ ### v0.2
504
+ - [ ] Prisma 어댑터
505
+ - [ ] BullMQ/Bull 통합 (@TenantContext)
506
+ - [ ] Schema/Database 격리 패턴
507
+ - [ ] Tenant purge 헬퍼 (GDPR)
508
+
509
+ ### v0.3
510
+ - [ ] Mongoose 어댑터
511
+ - [ ] Postgres RLS 자동 설정
512
+ - [ ] GraphQL DataLoader 통합
513
+
514
+ ### v1.0
515
+ - [ ] 성능 벤치마크 (오버헤드 < 1ms)
516
+ - [ ] 마이그레이션 도구 (수동 → 자동)
517
+ - [ ] 보안 감사
518
+
519
+ ## 💡 어떤 컬럼명이든 OK — 다양한 도메인 예시
520
+
521
+ `tenantIdField`는 회사/도메인의 관습에 맞게 자유롭게 설정 가능.
522
+
523
+ ### 학원 관리 SaaS
524
+
525
+ ```typescript
526
+ TenantShieldModule.forRoot({
527
+ strategy: 'discriminator',
528
+ tenantIdField: 'spaceId', // ← 학원 ID
529
+ tenantSource: 'jwt',
530
+ jwtClaim: 'spaceId',
531
+ })
532
+
533
+ @Entity()
534
+ export class Student {
535
+ @Column()
536
+ spaceId: string; // 학원 ID
537
+
538
+ @Column()
539
+ name: string;
540
+ }
541
+
542
+ @Injectable()
543
+ @RequireTenant()
544
+ export class StudentsService {
545
+ async findAll() {
546
+ return this.repo.find(); // 자동 WHERE space_id = ?
547
+ }
548
+ }
549
+ ```
550
+
551
+ ### 병원 관리 SaaS
552
+
553
+ ```typescript
554
+ // 병원 도메인에 맞게 tenant 컬럼명을 hospitalId로 지정
555
+ TenantShieldModule.forRoot({
556
+ tenantIdField: 'hospitalId', // 테넌트 식별 필드명 (병원 ID)
557
+ tenantSource: 'jwt', // JWT에서 tenant 추출
558
+ jwtClaim: 'hospital_id', // JWT claim 키 이름
559
+ })
560
+
561
+ @Entity()
562
+ export class Patient {
563
+ @Column()
564
+ hospitalId: string;
565
+ }
566
+ ```
567
+
568
+ ### Slack 같은 팀 협업
569
+
570
+ ```typescript
571
+ // 워크스페이스 단위 SaaS 구성 예시
572
+ TenantShieldModule.forRoot({
573
+ tenantIdField: 'workspaceId', // 워크스페이스 식별 필드
574
+ tenantSource: 'subdomain', // 서브도메인에서 tenant 추출
575
+ subdomainPattern: '*.yourapp.com', // tenant 추출 대상 도메인 패턴
576
+ })
577
+ ```
578
+
579
+ ### CRM (Salesforce식)
580
+
581
+ ```typescript
582
+ TenantShieldModule.forRoot({
583
+ tenantIdField: 'orgId', // ← 조직 ID
584
+ tenantSource: 'header',
585
+ headerName: 'x-org-id',
586
+ })
587
+ ```
588
+
589
+ ### 일반 B2B SaaS
590
+
591
+ ```typescript
592
+ TenantShieldModule.forRoot({
593
+ tenantIdField: 'tenantId', // ← 가장 일반적
594
+ })
595
+ ```
596
+
597
+ 핵심: **회사 도메인 관습 그대로 사용 가능.** `spaceId`, `hospitalId`, `workspaceId`, `orgId`, `accountId`, `companyId`, `clientId`, `customerId` 등 어떤 이름이든 OK.
598
+
599
+ ## 🤝 기여
600
+
601
+ 이슈와 PR 환영. v0.x 단계에서 적극적인 피드백 수용.
602
+
603
+ ## 📜 라이선스
604
+
605
+ MIT © Jinyeong Jung
606
+
607
+ ## 📚 문서 안내
608
+
609
+ - README: 사용법/예제/빠른 시작 (이 문서)
610
+ - docs/tenant-shield-api-spec.md: API 상세 명세
611
+ - docs/tenant-shield-PRD.md: 설계/로드맵/배경
612
+ - multi-tenant-explainer.md: 멀티테넌시 개념 요약
@@ -0,0 +1,19 @@
1
+ import { TenantAwareCacheService } from './cache.service';
2
+ /**
3
+ * 모듈 bootstrap 단계에서 1회 호출.
4
+ * 라이브러리 외부에서 직접 호출할 일은 없습니다.
5
+ */
6
+ export declare function setGlobalCache(cache: TenantAwareCacheService): void;
7
+ /**
8
+ * @Cacheable 데코레이터가 fallback으로 사용하는 lookup 함수.
9
+ *
10
+ * 반환값:
11
+ * - TenantAwareCacheService 인스턴스 (모듈 bootstrap 후 정상 상태)
12
+ * - undefined (모듈이 아직 init되지 않았거나, 라이브러리 미사용 환경)
13
+ */
14
+ export declare function getGlobalCache(): TenantAwareCacheService | undefined;
15
+ /**
16
+ * 테스트 격리용. 각 테스트 케이스 후 호출해서 다음 테스트에 영향을 안 미치게.
17
+ */
18
+ export declare function resetGlobalCache(): void;
19
+ //# sourceMappingURL=cache.registry.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"cache.registry.d.ts","sourceRoot":"","sources":["../../src/cache/cache.registry.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,uBAAuB,EAAE,MAAM,iBAAiB,CAAC;AA0B1D;;;GAGG;AACH,wBAAgB,cAAc,CAAC,KAAK,EAAE,uBAAuB,GAAG,IAAI,CAEnE;AAED;;;;;;GAMG;AACH,wBAAgB,cAAc,IAAI,uBAAuB,GAAG,SAAS,CAEpE;AAED;;GAEG;AACH,wBAAgB,gBAAgB,IAAI,IAAI,CAEvC"}