@nestarc/data-subject 0.1.0 → 0.2.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.
package/docs/prd.md ADDED
@@ -0,0 +1,164 @@
1
+ # @nestarc/data-subject — PRD
2
+
3
+ ## 1. 문제 정의
4
+
5
+ B2B SaaS가 엔터프라이즈 계약 실사에서 막히는 항목 TOP 3 안에 **GDPR/CCPA 대응**이 포함된다. 대부분의 팀은 요청이 들어온 후 엔지니어가 수작업 SQL을 돌린다. 그 결과:
6
+
7
+ - 여러 테이블 중 한두 개를 빠뜨려 "완전 삭제" 진술이 거짓이 된다
8
+ - Stripe·Intercom·분석 툴 등 외부 시스템은 손도 못 댄다
9
+ - 세금/회계 기록 같은 법적 보존 의무 테이블을 실수로 지운다
10
+ - 감사 로그가 PII를 보관해 "삭제" 약속과 충돌한다
11
+ - 30일 deadline 추적이 없다
12
+ - DPA 실사 때 증빙을 제출하지 못한다
13
+
14
+ 상용 SaaS(Transcend, DataGrail, Osano)는 월 수천 달러로 초기~중기 SaaS에 과하다. OSS 영역은 abandoned 수준. 공백이 크다.
15
+
16
+ ### 흔한 오해들 (이 패키지가 피해야 할 함정)
17
+
18
+ - **"Hashing PII satisfies erasure"** — 틀림. Pseudonymized data는 GDPR상 여전히 개인정보.
19
+ - **"Anonymization과 pseudonymization은 같다"** — 틀림. 재식별 불가능해야 진짜 익명화.
20
+ - **"Soft delete가 GDPR을 만족한다"** — 틀림. `deletedAt`만 찍는 건 어떤 법적 기준도 만족하지 않는다.
21
+
22
+ ## 2. 해결 방향
23
+
24
+ **정책 엔진 + 요청 라이프사이클 + 외부 fan-out**을 Prisma 기반으로 패키징한다. 기존 nestarc 패키지 네 개가 이 패키지에서 비로소 한 세트가 된다:
25
+
26
+ - `@nestarc/tenancy` → 요청 격리
27
+ - `@nestarc/audit-log` → 요청 이력
28
+ - `@nestarc/outbox` → 외부 전파(tombstone event)
29
+ - `@nestarc/soft-delete` → anonymization hook
30
+
31
+ 한 줄 포지셔닝: **"DPA-ready GDPR/CCPA toolkit for NestJS + Prisma."**
32
+
33
+ ## 3. 타깃 사용자
34
+
35
+ - 유럽/미국 고객과 계약 중인 B2B SaaS (DPA 첨부 요구받는 단계)
36
+ - Series A~B에서 SOC2·보안 실사를 준비하는 팀
37
+ - 이미 `@nestarc/tenancy`를 쓰는 사용자 (가장 자연스러운 upsell)
38
+ - 한국·일본 등 현지 개인정보법 대응이 필요한 팀 (data subject는 PIPA·APPI도 공통 용어)
39
+
40
+ ## 4. 성공 기준
41
+
42
+ - **도입 마찰**: 엔티티 5개 등록 → export/erase API 노출까지 1시간 이내
43
+ - **법적 방어**: 모든 요청에 타임스탬프 + 해시 아티팩트 → DPA 실사 시 증빙
44
+ - **완전성**: PII 의심 컬럼(email/name/address)에 정책 미지정 시 **빌드타임 경고**
45
+ - **외부 fan-out**: Stripe/Intercom/분석 도구 정리가 outbox 리스너 하나로
46
+
47
+ ## 5. 범위
48
+
49
+ ### 포함 (v0.1)
50
+ - 엔티티 레지스트리 (데코레이터 + 프로그래밍 API)
51
+ - 필드 단위 전략: `delete`(기본), `anonymize`, `retain`
52
+ - Export: JSON 묶음 + sha256 증빙
53
+ - Erase: 트랜잭션 배치 + 완료 후 검증 스캔
54
+ - `DataSubjectRequest` 테이블 (라이프사이클 + 증빙)
55
+ - Outbox 이벤트 발행 (PII 없는 tombstone 페이로드)
56
+ - 30일 SLA 추적, overdue 조회 API
57
+ - 빌드타임 PII 컬럼 린트
58
+
59
+ ### 제외
60
+ - 외부 시스템 직접 통합(Stripe/Intercom 커넥터) — consumer가 outbox 리스너로 구현
61
+ - 백업·스냅샷 삭제 — 인프라 영역, 문서로만 가이드
62
+ - 관리 UI — headless 유지
63
+ - Consent 관리, cookie banner — 별도 도메인
64
+ - ML 모델에서 개인정보 unlearning — v1.x 이후 검토
65
+
66
+ ## 6. 경쟁/비교
67
+
68
+ | 항목 | Transcend | DataGrail | 수작업 SQL | `@nestarc/data-subject` |
69
+ |---|---|---|---|---|
70
+ | 가격 | $$$$ | $$$ | 인력 비용 | OSS |
71
+ | 셀프호스트 | ✗ | ✗ | ✓ | ✓ |
72
+ | Prisma/NestJS 네이티브 | ✗ | ✗ | 수동 | ✓ |
73
+ | 법적 보존 모델링 | ✓ | ✓ | ✗ | ✓ (legalBasis 필수) |
74
+ | 외부 fan-out | ✓ | ✓ | ✗ | ✓ (outbox) |
75
+ | 증빙 아티팩트 | ✓ | ✓ | ✗ | ✓ (sha256) |
76
+ | 빌드타임 완전성 검증 | ✗ | ✗ | ✗ | ✓ |
77
+
78
+ ## 7. 핵심 개념
79
+
80
+ ### 세 가지 전략
81
+
82
+ | 전략 | 의미 | 사용처 |
83
+ |---|---|---|
84
+ | `delete` (기본) | 행 또는 PII 필드 물리 삭제 | 대부분의 operational PII |
85
+ | `anonymize` | 정적 값(`[REDACTED]`)으로 덮어쓰기. 매핑 저장 금지 → 진짜 익명화 | 행 구조가 분석·통계에 필요할 때 |
86
+ | `retain` | 보존. `legalBasis` + 기간 필수 | 세금·회계·분쟁 기록 등 |
87
+
88
+ ### 요청 라이프사이클
89
+
90
+ ```
91
+ created ─▶ validating ─▶ processing ─▶ completed
92
+ │ │
93
+ └──▶ failed ◀─┘
94
+ ```
95
+
96
+ 모든 전환은 `DataSubjectRequest` 테이블에 기록. 완료 시:
97
+ - **Export**: ZIP의 sha256, 엔티티 목록, 행 개수
98
+ - **Erase**: 삭제·익명화된 행 개수, 보존 행의 legalBasis 목록, 검증 스캔 결과
99
+
100
+ ### 오해 방지 설계
101
+
102
+ - `retain` 사용 시 `legalBasis` 문자열 필수. 빈 문자열 허용 안 함
103
+ - `anonymize` 선언 시 정적 치환값 필수. 동적 생성(`crypto.randomUUID()` 등) 금지
104
+ - pseudonymization(해시)는 별도 옵션(`pseudonymize: 'hmac'`)으로만 제공되며, README에 **"이는 erasure를 만족하지 않으며 security measure일 뿐"** 명시
105
+
106
+ ## 8. 다른 nestarc 패키지와의 결합
107
+
108
+ | 패키지 | 역할 |
109
+ |---|---|
110
+ | `@nestarc/tenancy` | `subjectId` 조회를 tenant scope 안으로 제한. RLS로 자동 보호 |
111
+ | `@nestarc/audit-log` | 요청/처리/완료 이벤트 자동 기록. audit 엔티티 자체도 `retain + legalBasis='accountability'`로 등록 |
112
+ | `@nestarc/soft-delete` | `deletedAt`만 찍지 말고 PII 컬럼까지 익명화하도록 hook |
113
+ | `@nestarc/outbox` | `data_subject.erasure_requested` tombstone event 발행 (subjectId + tenantId + requestId만) |
114
+
115
+ ## 9. 잠금된 설계 결정 (리서치 검증 완료)
116
+
117
+ - **기본 erase 전략**: `delete`. `anonymize`는 명시 opt-in.
118
+ - **외부 fan-out**: outbox 전용, tombstone event(PII 없는 최소 페이로드). Bull adapter는 v0.2.
119
+ - **Audit paradox**: audit log는 `retain + legalBasis='accountability:gdpr-art-5-2'`. 해시 옵션은 defense-in-depth이지 erasure가 아님을 문서화.
120
+
121
+ 리서치 근거는 `docs/compliance.md`(후속 작성)에 소스와 함께 정리.
122
+
123
+ ## 10. 비기능 요건
124
+
125
+ - **정확성**: 등록된 엔티티는 하나도 빠뜨리지 않고 export/erase 파이프라인을 통과해야 함
126
+ - **감사성**: 모든 요청에 immutable audit trail (타임스탬프, 처리자, 아티팩트 해시)
127
+ - **성능**: 1000개 엔티티·100만 행 규모 tenant에서 export 10분 이내 (v0.2 목표, v0.1은 기능 우선)
128
+ - **확장성**: Prisma 외 ORM 지원은 v1.x. v0.1은 Prisma 전용
129
+ - **관찰성**: `request.state_changed`, `request.overdue`, `entity.scan_mismatch` 이벤트 publish
130
+
131
+ ## 11. 로드맵
132
+
133
+ **v0.1** (MVP, 3~4주)
134
+ - 엔티티 레지스트리 + 데코레이터
135
+ - Export (JSON zip + sha256)
136
+ - Erase (delete 기본, anonymize 옵션, retain+legalBasis)
137
+ - `DataSubjectRequest` 테이블 + 라이프사이클
138
+ - tenancy/audit-log/outbox 기본 통합
139
+ - 완료 후 검증 스캔
140
+ - 빌드타임 PII 컬럼 린트
141
+
142
+ **v0.2**
143
+ - Retention DSL (`until: '+7y'`, `until: 'end-of-fiscal-year'`)
144
+ - CSV/NDJSON 포맷
145
+ - 30일 SLA 스케줄러 + overdue 알림
146
+ - Bull adapter
147
+ - `soft-delete` 패키지 hook 통합
148
+
149
+ **v0.3**
150
+ - Encrypted export (consumer 공개키)
151
+ - Scheduled re-scan (미등록 PII 컬럼 자동 탐지)
152
+ - DPA sub-processor 트래킹 테이블
153
+ - 다국어 대응(한국 PIPA, 일본 APPI legal basis 템플릿)
154
+
155
+ ## 12. 리스크와 대응
156
+
157
+ | 리스크 | 대응 |
158
+ |---|---|
159
+ | consumer가 엔티티 등록을 빠뜨림 | 빌드타임 린트 (PII 의심 컬럼 자동 탐지) + 요청 처리 시 unregistered_entity 경고 |
160
+ | 법적 보존 기간 설정 오류 | `legalBasis` 문자열 필수. 런타임 validator로 형식 검증 (e.g. `scheme:jurisdiction-reference`) |
161
+ | Erase 도중 실패 → 일부만 삭제 | 트랜잭션 + 배치 단위 idempotency key. resume 지원 |
162
+ | 외부 시스템 처리 실패 | outbox 재시도 + dead letter. consumer 책임 경계는 README에 명시 |
163
+ | "해시도 삭제"라고 오해한 consumer가 compliance 주장 후 분쟁 | 문서 전면에 **"pseudonymization is not erasure"** 경고. DPA 템플릿 제공 |
164
+ | 빌드타임 린트가 false positive 과다 → 사용자가 무시 | `@DataSubjectIgnore` 데코레이터로 명시적 제외 경로 제공 |
package/docs/spec.md ADDED
@@ -0,0 +1,282 @@
1
+ # @nestarc/data-subject — v0.1 Technical Spec
2
+
3
+ Status: Historical v0.1 planning spec. For the current 0.2.0 implementation target, see [data-subject-0.2.0-spec.md](./data-subject-0.2.0-spec.md).
4
+
5
+ 본 문서는 v0.1에서 고정되는 기술 결정을 기록한다. 변경은 RFC 수준의 논의를 거친다.
6
+
7
+ ## 1. 엔티티 레지스트리
8
+
9
+ ### 1.1 데코레이터 경로
10
+
11
+ ```ts
12
+ @DataSubjectEntity({
13
+ subjectField: 'userId',
14
+ policy: {
15
+ email: 'delete',
16
+ name: 'delete',
17
+ avatarUrl: 'delete',
18
+ lastLoginAt: 'delete',
19
+ },
20
+ })
21
+ export class User {
22
+ id: string;
23
+ userId: string;
24
+ email: string;
25
+ // ...
26
+ }
27
+ ```
28
+
29
+ ### 1.2 프로그래매틱 경로 (외부 스키마용)
30
+
31
+ ```ts
32
+ dataSubject.register({
33
+ entity: 'Invoice',
34
+ subjectField: 'customerId',
35
+ policy: {
36
+ customerName: {
37
+ strategy: 'retain',
38
+ legalBasis: 'tax:KR-basic-law-§85',
39
+ until: '+7y',
40
+ },
41
+ amount: { strategy: 'retain', legalBasis: 'tax:KR-basic-law-§85', until: '+7y' },
42
+ customerEmail: { strategy: 'anonymize', replacement: 'redacted@example.com' },
43
+ internalNote: 'delete',
44
+ },
45
+ });
46
+ ```
47
+
48
+ ### 1.3 제외 선언
49
+
50
+ ```ts
51
+ @DataSubjectIgnore('no PII, system-owned')
52
+ export class SystemMigrationLog { /* ... */ }
53
+ ```
54
+
55
+ 빌드타임 린트는 이 클래스를 건너뛰되, 이유 문자열을 필수로 받아 기록한다.
56
+
57
+ ## 2. 전략 세부
58
+
59
+ ### 2.1 `delete` (기본)
60
+
61
+ - 필드 단위: `UPDATE ... SET col = NULL` (nullable) 또는 `UPDATE ... SET col = ''` (non-null)
62
+ - 행 전체: `DELETE FROM ... WHERE subjectField = ?`
63
+ - 행 전체 삭제 여부는 엔티티 레벨 옵션 `rowLevel: 'delete-row' | 'delete-fields'`로 결정. 기본은 `delete-fields`(FK 안정).
64
+
65
+ ### 2.2 `anonymize`
66
+
67
+ - 정적 치환값 필수. `replacement` 필드로 지정.
68
+ - 동적 생성 금지(`() => randomUUID()` 등). 런타임 타입 가드로 거부.
69
+ - 여러 행에 같은 값이 들어가 집계가 망가질 수 있음을 문서에 명시.
70
+
71
+ ### 2.3 `retain`
72
+
73
+ - `legalBasis: string` 필수. 비어 있으면 `InvalidPolicyError`.
74
+ - 형식 권장: `<scheme>:<jurisdiction>-<reference>` (예: `tax:KR-basic-law-§85`).
75
+ - 엄격 모드(`strictLegalBasis: true`)에서는 위 regex를 강제.
76
+ - 기간: `until: '+7y' | '+N{y|m|d}' | ISO8601` (v0.2에서 확장).
77
+ - 선택 필드 `pseudonymize: 'hmac' | 'none'` (기본 `none`). 활성화 시 subjectField를 `HMAC-SHA256(subjectId, pepper)`로 저장. README에 **"pseudonymization is not erasure"** 명시 경고.
78
+
79
+ ## 3. `DataSubjectRequest` 테이블
80
+
81
+ ```prisma
82
+ model DataSubjectRequest {
83
+ id String @id @default(cuid())
84
+ tenantId String
85
+ subjectId String
86
+ type String // "export" | "erase"
87
+ state String // "created" | "validating" | "processing" | "completed" | "failed"
88
+ createdAt DateTime @default(now())
89
+ dueAt DateTime // createdAt + 30 days (ISO + EU/US standard)
90
+ completedAt DateTime?
91
+ failedAt DateTime?
92
+ failureReason String?
93
+ artifactHash String? // sha256 of export ZIP or erase report
94
+ artifactUrl String? // consumer-managed storage reference
95
+ stats Json? // { entities: [...], rowCounts: {...} }
96
+ requestedBy String?
97
+
98
+ @@index([tenantId, subjectId])
99
+ @@index([state, dueAt])
100
+ }
101
+ ```
102
+
103
+ **Note**: `subjectId`는 이 테이블에 **평문으로 유지**된다. 이는 `retain + legalBasis: 'accountability:gdpr-art-5-2'` 정책에 의해 정당화되며, 법적 방어의 핵심 증빙이다.
104
+
105
+ ## 4. 라이프사이클
106
+
107
+ ```
108
+ created
109
+ │ (동기: subject 존재 검증, tenant scope 확인)
110
+
111
+ validating
112
+ │ (비동기: 정책 컴파일, 영향 범위 pre-scan)
113
+
114
+ processing
115
+ │ (비동기: 배치 실행, 트랜잭션 단위 커밋, outbox 발행)
116
+
117
+ completed ◀── (verification scan: residual 0)
118
+ or
119
+ failed (with reason)
120
+ ```
121
+
122
+ 각 전환은 `audit-log`에 기록되며, `DataSubjectRequest.state`도 함께 업데이트된다.
123
+
124
+ ## 5. Export 플로우
125
+
126
+ 1. `dataSubject.export(subjectId, tenantId)` → `DataSubjectRequest` INSERT (state=`created`)
127
+ 2. 레지스트리의 모든 엔티티를 순회:
128
+ - `SELECT * FROM <entity> WHERE <subjectField> = ? AND tenantId = ?`
129
+ - 결과를 `{ entity, rows }` 구조로 누적
130
+ 3. 누적 결과를 JSON으로 직렬화 → ZIP 압축
131
+ 4. `sha256(zipBytes)`를 `artifactHash`에 저장
132
+ 5. consumer가 지정한 storage에 ZIP 업로드, URL을 `artifactUrl`에 저장
133
+ 6. `state=completed`, `stats` 업데이트
134
+
135
+ Storage adapter는 interface로 정의되며, v0.1은 `InMemoryStorageAdapter`(테스트용)와 `S3StorageAdapter`(선택 peer dep) 제공.
136
+
137
+ ## 6. Erase 플로우
138
+
139
+ 1. `dataSubject.erase(subjectId, tenantId, opts?)` → `DataSubjectRequest` INSERT
140
+ 2. **pre-scan**: 레지스트리 전 엔티티의 영향 행 개수 집계 → `stats.preCount`
141
+ 3. 트랜잭션 배치로 정책 적용:
142
+ - `delete`: `DELETE` 또는 필드 NULL
143
+ - `anonymize`: `UPDATE ... SET col = replacement`
144
+ - `retain`: 건너뜀. `legalBasis`를 `stats.retained[]`에 기록
145
+ 4. **outbox tombstone publish** (같은 트랜잭션 안에서 outbox 테이블에 INSERT):
146
+ ```json
147
+ {
148
+ "type": "data_subject.erasure_requested",
149
+ "subjectId": "user_123",
150
+ "tenantId": "tenant_1",
151
+ "requestId": "dsr_abc",
152
+ "requestedAt": "2026-04-14T12:34:56Z"
153
+ }
154
+ ```
155
+ PII 절대 포함 금지. subjectId만.
156
+ 5. **verification scan**: 전 엔티티에서 `WHERE <subjectField> = ?` 재조회.
157
+ - `delete` 전략 엔티티에서 행 발견 시 → `state=failed`, `failureReason`에 엔티티명 기록
158
+ - `retain` 전략 엔티티는 법적 근거와 함께 `stats.retained`에 기록
159
+ 6. `state=completed`, `artifactHash`에 erase report JSON의 sha256 저장
160
+
161
+ **Implementation note (v0.1):** 기본 구현은 선택적 `runInTransaction` 훅을 노출한다. 실제 롤백 가능 여부는 executor, request storage, outbox가 같은 트랜잭션 경계에 참여하는지에 달려 있다. 그렇지 않으면 erase는 best-effort다.
162
+
163
+ ## 7. 공개 API 표면
164
+
165
+ ```ts
166
+ interface DataSubjectService {
167
+ export(subjectId: string, tenantId: string): Promise<DataSubjectRequest>;
168
+ erase(
169
+ subjectId: string,
170
+ tenantId: string,
171
+ opts?: { rowLevel?: 'delete-row' | 'delete-fields' },
172
+ ): Promise<DataSubjectRequest>;
173
+
174
+ getRequest(requestId: string): Promise<DataSubjectRequest>;
175
+ listByTenant(tenantId: string, opts?: { state?: RequestState }): Promise<DataSubjectRequest[]>;
176
+ listOverdue(): Promise<DataSubjectRequest[]>;
177
+
178
+ // Registry introspection
179
+ describeEntity(name: string): EntityPolicy;
180
+ validateRegistry(): ValidationReport; // 빌드타임/CI에서 호출
181
+ }
182
+
183
+ // Decorators
184
+ @DataSubjectEntity({ ... })
185
+ @DataSubjectIgnore(reason: string)
186
+ ```
187
+
188
+ ### 모듈 등록
189
+
190
+ ```ts
191
+ DataSubjectModule.forRoot({
192
+ prisma: prismaClient,
193
+ outbox: outboxService, // from @nestarc/outbox
194
+ auditLog: auditLogService, // from @nestarc/audit-log
195
+ storage: new S3StorageAdapter({ ... }),
196
+ runInTransaction: async (work) => prismaClient.$transaction(async () => work()),
197
+ pepper: process.env.DATA_SUBJECT_PEPPER, // for optional pseudonymization
198
+ slaDays: 30,
199
+ strictLegalBasis: true,
200
+ })
201
+ ```
202
+
203
+ ## 8. 빌드타임 린트
204
+
205
+ CI에서 실행할 CLI:
206
+
207
+ ```bash
208
+ npx @nestarc/data-subject lint
209
+ ```
210
+
211
+ 동작:
212
+ 1. Prisma 스키마 파싱
213
+ 2. 모든 모델의 컬럼 중 PII 의심 패턴 매칭 (`email`, `name`, `phone`, `address`, `ip_address`, `birthday`, `national_id` 등 + 사용자 확장 리스트)
214
+ 3. 해당 컬럼이 등록된 엔티티의 `policy`에 포함되어 있는지 확인
215
+ 4. 누락 시 경고 출력 + non-zero exit code
216
+
217
+ Suppression:
218
+ ```ts
219
+ @DataSubjectIgnore('no PII — internal metadata only')
220
+ ```
221
+
222
+ ## 9. Outbox 이벤트 스펙
223
+
224
+ | 이벤트 | 페이로드 | 발행 시점 |
225
+ |---|---|---|
226
+ | `data_subject.request_created` | `{ requestId, type, subjectId, tenantId }` | 요청 INSERT 동시 |
227
+ | `data_subject.erasure_requested` | tombstone: `{ requestId, subjectId, tenantId, requestedAt }` | Erase 트랜잭션 내 |
228
+ | `data_subject.request_completed` | `{ requestId, state, artifactHash }` | 완료 직후 |
229
+ | `data_subject.request_failed` | `{ requestId, failureReason }` | 실패 직후 |
230
+ | `data_subject.request_overdue` | `{ requestId, subjectId, daysOverdue }` | v0.2 스케줄러 |
231
+
232
+ 모든 페이로드는 **subjectId 외 PII를 포함하지 않는다.**
233
+
234
+ ## 10. 에러 코드
235
+
236
+ | 코드 | HTTP | 의미 |
237
+ |---|---|---|
238
+ | `dsr_subject_not_found` | 404 | subjectId가 tenant scope에 존재하지 않음 |
239
+ | `dsr_unregistered_entity` | 500 | 레지스트리에 없는 엔티티가 실행 중 발견 |
240
+ | `dsr_invalid_policy` | 500 | `retain`에 `legalBasis` 누락 등 정책 유효성 오류 |
241
+ | `dsr_verification_failed` | 500 | erase 후 검증 스캔에서 잔여 행 발견 |
242
+ | `dsr_anonymize_dynamic_replacement` | 500 | `replacement`가 함수/undefined |
243
+ | `dsr_entity_already_registered` | 500 | 같은 엔티티 이름이 중복 등록됨 |
244
+ | `dsr_request_conflict` | 409 | 요청 id 충돌 또는 중복 insert |
245
+ | `dsr_request_not_found` | 404 | 요청 조회/갱신 대상이 존재하지 않음 |
246
+ | `dsr_overdue_threshold` | 경고 이벤트 | SLA 초과 (30일) |
247
+
248
+ ## 11. 보안 체크리스트
249
+
250
+ - [x] Export ZIP은 consumer 측 암호화 저장소에 업로드. 라이브러리는 평문 전송 금지
251
+ - [x] Outbox 이벤트 페이로드에 PII 금지 (subjectId + tenantId + requestId만)
252
+ - [x] Pseudonymization pepper는 env only, rotatable
253
+ - [x] `DataSubjectRequest.subjectId`는 평문 유지 (accountability 법적 근거)
254
+ - [x] 에러 메시지에 PII 노출 금지 (subjectId는 로깅 허용, 다른 필드 금지)
255
+ - [x] Prisma 쿼리는 모두 parameterized. raw SQL 금지
256
+
257
+ ## 12. 다른 nestarc 패키지와의 결합
258
+
259
+ ### @nestarc/tenancy
260
+ - 모든 API는 `tenantId` 필수 파라미터. RLS가 이미 적용되어 있으면 추가 필터 생략 가능
261
+ - `export`/`erase`에서 subjectId 조회는 tenant scope 내로 자동 제한
262
+
263
+ ### @nestarc/audit-log
264
+ - 요청 라이프사이클 이벤트 4종(`created/validating/processing/completed|failed`) 자동 기록
265
+ - `AuditLogEntry` 엔티티 자체를 `retain + legalBasis='accountability:gdpr-art-5-2'`로 등록
266
+ - 선택: `pseudonymize: 'hmac'` 활성화 시 audit log의 `actorId`를 HMAC 저장
267
+
268
+ ### @nestarc/outbox
269
+ - §9의 5개 이벤트를 발행
270
+ - 같은 트랜잭션에서 INSERT → at-least-once 보장
271
+
272
+ ### @nestarc/soft-delete (v0.2)
273
+ - 기존에 `deletedAt`만 찍힌 행에 대해 PII 필드 익명화 hook 제공
274
+ - `onSoftDelete: 'anonymize-pii'` 옵션으로 자동 처리
275
+
276
+ ## 13. 테스트 전략
277
+
278
+ - **단위**: 정책 컴파일, 레지스트리 유효성, export ZIP 직렬화 — pure functions
279
+ - **통합**: InMemoryPrismaLike로 전체 export/erase 플로우 (실제 Prisma는 consumer 검증)
280
+ - **Contract**: Storage adapter 인터페이스는 contract 테스트로 InMemory/S3 공통 검증
281
+ - **Compliance test**: 빌드타임 린트가 의도한 PII 컬럼을 모두 잡는지 fixture로 확인
282
+ - **Failure scenarios**: 검증 스캔 실패, 트랜잭션 롤백, outbox 실패
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nestarc/data-subject",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "DPA-ready GDPR/CCPA toolkit for NestJS + Prisma. Entity registry, export/erase lifecycle, legal retention, outbox fan-out.",
5
5
  "license": "MIT",
6
6
  "author": "nestarc",
@@ -10,8 +10,30 @@
10
10
  },
11
11
  "main": "dist/index.js",
12
12
  "types": "dist/index.d.ts",
13
+ "exports": {
14
+ ".": {
15
+ "types": "./dist/index.d.ts",
16
+ "require": "./dist/index.js",
17
+ "default": "./dist/index.js"
18
+ },
19
+ "./lint": {
20
+ "types": "./dist/lint/index.d.ts",
21
+ "require": "./dist/lint/index.js",
22
+ "default": "./dist/lint/index.js"
23
+ }
24
+ },
25
+ "bin": {
26
+ "data-subject": "dist/cli.js"
27
+ },
13
28
  "files": [
14
29
  "dist",
30
+ "docs/code-review-src.md",
31
+ "docs/compliance.md",
32
+ "docs/data-subject-0.2.0-feature-proposal.md",
33
+ "docs/data-subject-0.2.0-spec.md",
34
+ "docs/prd.md",
35
+ "docs/spec.md",
36
+ "prisma",
15
37
  "README.md",
16
38
  "LICENSE"
17
39
  ],
@@ -19,6 +41,7 @@
19
41
  "build": "tsc -p tsconfig.build.json",
20
42
  "test": "jest",
21
43
  "test:watch": "jest --watch",
44
+ "bench": "ts-node bench/data-subject.bench.ts",
22
45
  "lint": "eslint \"src/**/*.ts\" \"test/**/*.ts\"",
23
46
  "format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
24
47
  "prepublishOnly": "npm run build"
@@ -0,0 +1,31 @@
1
+ // Example schema for @nestarc/data-subject.
2
+ // Consumers copy the DataSubjectRequest model into their own schema.
3
+
4
+ datasource db {
5
+ provider = "postgresql"
6
+ url = env("DATABASE_URL")
7
+ }
8
+
9
+ generator client {
10
+ provider = "prisma-client-js"
11
+ }
12
+
13
+ model DataSubjectRequest {
14
+ id String @id @default(cuid())
15
+ tenantId String
16
+ subjectId String
17
+ type String
18
+ state String
19
+ createdAt DateTime @default(now())
20
+ dueAt DateTime
21
+ completedAt DateTime?
22
+ failedAt DateTime?
23
+ failureReason String?
24
+ artifactHash String?
25
+ artifactUrl String?
26
+ stats Json?
27
+ requestedBy String?
28
+
29
+ @@index([tenantId, subjectId])
30
+ @@index([state, dueAt])
31
+ }