@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/README.md +58 -18
- package/dist/artifacts.d.ts +3 -0
- package/dist/artifacts.js +21 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +155 -0
- package/dist/data-subject.service.js +84 -13
- package/dist/erase-runner.d.ts +6 -2
- package/dist/erase-runner.js +66 -5
- package/dist/erasure-evidence.d.ts +9 -0
- package/dist/erasure-evidence.js +18 -0
- package/dist/errors.d.ts +3 -0
- package/dist/errors.js +6 -0
- package/dist/export-runner.js +3 -3
- package/dist/index.d.ts +8 -0
- package/dist/index.js +16 -1
- package/dist/lint/index.d.ts +3 -0
- package/dist/lint/index.js +24 -0
- package/dist/lint/lint.d.ts +4 -0
- package/dist/lint/lint.js +160 -0
- package/dist/lint/prisma-schema.d.ts +11 -0
- package/dist/lint/prisma-schema.js +43 -0
- package/dist/lint/types.d.ts +32 -0
- package/dist/lint/types.js +2 -0
- package/dist/registry-validation.d.ts +9 -0
- package/dist/registry-validation.js +37 -0
- package/dist/stable-json.d.ts +1 -0
- package/dist/stable-json.js +30 -0
- package/dist/storage/prisma-request-storage.d.ts +33 -0
- package/dist/storage/prisma-request-storage.js +156 -0
- package/dist/types.d.ts +84 -0
- package/docs/code-review-src.md +293 -0
- package/docs/compliance.md +178 -0
- package/docs/data-subject-0.2.0-feature-proposal.md +565 -0
- package/docs/data-subject-0.2.0-spec.md +679 -0
- package/docs/prd.md +164 -0
- package/docs/spec.md +282 -0
- package/package.json +24 -1
- package/prisma/schema.example.prisma +31 -0
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.
|
|
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
|
+
}
|