@nestarc/data-subject 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.
- package/LICENSE +21 -0
- package/README.md +351 -0
- package/dist/data-subject.module.d.ts +19 -0
- package/dist/data-subject.module.js +55 -0
- package/dist/data-subject.service.d.ts +35 -0
- package/dist/data-subject.service.js +162 -0
- package/dist/erase-runner.d.ts +10 -0
- package/dist/erase-runner.js +114 -0
- package/dist/errors.d.ts +16 -0
- package/dist/errors.js +34 -0
- package/dist/export-runner.d.ts +14 -0
- package/dist/export-runner.js +34 -0
- package/dist/index.d.ts +18 -0
- package/dist/index.js +36 -0
- package/dist/legal-basis.d.ts +4 -0
- package/dist/legal-basis.js +22 -0
- package/dist/policy-compiler.d.ts +11 -0
- package/dist/policy-compiler.js +50 -0
- package/dist/prisma/from-prisma.d.ts +23 -0
- package/dist/prisma/from-prisma.js +42 -0
- package/dist/registry.d.ts +14 -0
- package/dist/registry.js +27 -0
- package/dist/storage/artifact-storage.interface.d.ts +7 -0
- package/dist/storage/artifact-storage.interface.js +2 -0
- package/dist/storage/in-memory-artifact-storage.d.ts +9 -0
- package/dist/storage/in-memory-artifact-storage.js +14 -0
- package/dist/storage/in-memory-request-storage.d.ts +12 -0
- package/dist/storage/in-memory-request-storage.js +38 -0
- package/dist/storage/request-storage.interface.d.ts +10 -0
- package/dist/storage/request-storage.interface.js +2 -0
- package/dist/types.d.ts +68 -0
- package/dist/types.js +2 -0
- package/package.json +59 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 nestarc
|
|
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,351 @@
|
|
|
1
|
+
# @nestarc/data-subject
|
|
2
|
+
|
|
3
|
+
`@nestarc/data-subject` is a small NestJS-oriented toolkit for handling data-subject export and erasure requests against subject-scoped data.
|
|
4
|
+
|
|
5
|
+
Today the package ships:
|
|
6
|
+
|
|
7
|
+
- a programmatic entity registry
|
|
8
|
+
- a `DataSubjectService` for `export`, `erase`, and request lookup
|
|
9
|
+
- a `DataSubjectModule.forRoot(...)` integration for NestJS
|
|
10
|
+
- a lightweight Prisma adapter built on `findMany`, `deleteMany`, and `updateMany`
|
|
11
|
+
- in-memory request and artifact stores for tests and local development
|
|
12
|
+
- typed policy validation and typed runtime errors
|
|
13
|
+
|
|
14
|
+
## Current Scope
|
|
15
|
+
|
|
16
|
+
Package version: `0.1.0`
|
|
17
|
+
|
|
18
|
+
This repository currently focuses on the execution core. It does **not** currently ship:
|
|
19
|
+
|
|
20
|
+
- decorators or automatic entity discovery
|
|
21
|
+
- a CLI or schema linter
|
|
22
|
+
- persistent request storage adapters
|
|
23
|
+
- persistent artifact storage adapters beyond the in-memory implementation
|
|
24
|
+
- schema-aware Prisma field deletion beyond `null` assignment
|
|
25
|
+
|
|
26
|
+
If you need database-specific behavior, you can plug in your own `EntityExecutor`, `RequestStorage`, or `ArtifactStorage`.
|
|
27
|
+
|
|
28
|
+
## Installation
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
npm install @nestarc/data-subject
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
Peer dependencies used by this package:
|
|
35
|
+
|
|
36
|
+
- `@nestjs/common`
|
|
37
|
+
- `@nestjs/core`
|
|
38
|
+
- `reflect-metadata`
|
|
39
|
+
- `rxjs`
|
|
40
|
+
- `@prisma/client` if you use `fromPrisma(...)`
|
|
41
|
+
|
|
42
|
+
## Quick Start
|
|
43
|
+
|
|
44
|
+
```ts
|
|
45
|
+
import { Module } from '@nestjs/common';
|
|
46
|
+
import { PrismaClient } from '@prisma/client';
|
|
47
|
+
import {
|
|
48
|
+
DataSubjectModule,
|
|
49
|
+
InMemoryArtifactStorage,
|
|
50
|
+
InMemoryRequestStorage,
|
|
51
|
+
fromPrisma,
|
|
52
|
+
} from '@nestarc/data-subject';
|
|
53
|
+
|
|
54
|
+
const prisma = new PrismaClient();
|
|
55
|
+
|
|
56
|
+
@Module({
|
|
57
|
+
imports: [
|
|
58
|
+
DataSubjectModule.forRoot({
|
|
59
|
+
requestStorage: new InMemoryRequestStorage(),
|
|
60
|
+
artifactStorage: new InMemoryArtifactStorage(),
|
|
61
|
+
slaDays: 30,
|
|
62
|
+
strictLegalBasis: true,
|
|
63
|
+
entities: [
|
|
64
|
+
{
|
|
65
|
+
policy: {
|
|
66
|
+
entityName: 'User',
|
|
67
|
+
subjectField: 'userId',
|
|
68
|
+
rowLevel: 'delete-row',
|
|
69
|
+
fields: {
|
|
70
|
+
email: 'delete',
|
|
71
|
+
name: 'delete',
|
|
72
|
+
},
|
|
73
|
+
},
|
|
74
|
+
executor: fromPrisma({
|
|
75
|
+
delegate: prisma.user,
|
|
76
|
+
subjectField: 'userId',
|
|
77
|
+
tenantField: 'tenantId',
|
|
78
|
+
}),
|
|
79
|
+
},
|
|
80
|
+
{
|
|
81
|
+
policy: {
|
|
82
|
+
entityName: 'Invoice',
|
|
83
|
+
subjectField: 'customerId',
|
|
84
|
+
fields: {
|
|
85
|
+
customerName: {
|
|
86
|
+
strategy: 'retain',
|
|
87
|
+
legalBasis: 'tax:KR-basic-law-sec85',
|
|
88
|
+
until: '+7y',
|
|
89
|
+
},
|
|
90
|
+
amount: {
|
|
91
|
+
strategy: 'retain',
|
|
92
|
+
legalBasis: 'tax:KR-basic-law-sec85',
|
|
93
|
+
},
|
|
94
|
+
customerEmail: {
|
|
95
|
+
strategy: 'anonymize',
|
|
96
|
+
replacement: '[REDACTED]',
|
|
97
|
+
},
|
|
98
|
+
},
|
|
99
|
+
},
|
|
100
|
+
executor: fromPrisma({
|
|
101
|
+
delegate: prisma.invoice,
|
|
102
|
+
subjectField: 'customerId',
|
|
103
|
+
tenantField: 'tenantId',
|
|
104
|
+
}),
|
|
105
|
+
},
|
|
106
|
+
],
|
|
107
|
+
publishOutbox: async (type, payload) => {
|
|
108
|
+
// forward to your outbox publisher
|
|
109
|
+
},
|
|
110
|
+
publishAudit: async (event, data) => {
|
|
111
|
+
// optional hook
|
|
112
|
+
},
|
|
113
|
+
}),
|
|
114
|
+
],
|
|
115
|
+
})
|
|
116
|
+
export class AppModule {}
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
Usage:
|
|
120
|
+
|
|
121
|
+
```ts
|
|
122
|
+
const exportRequest = await dataSubject.export('user_123', 'tenant_abc');
|
|
123
|
+
const eraseRequest = await dataSubject.erase('user_123', 'tenant_abc');
|
|
124
|
+
|
|
125
|
+
const sameRequest = await dataSubject.getRequest(exportRequest.id);
|
|
126
|
+
const tenantRequests = await dataSubject.listByTenant('tenant_abc');
|
|
127
|
+
const overdue = await dataSubject.listOverdue();
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
## Policy Model
|
|
131
|
+
|
|
132
|
+
Policies are registered per entity and compiled before execution.
|
|
133
|
+
|
|
134
|
+
### `delete`
|
|
135
|
+
|
|
136
|
+
```ts
|
|
137
|
+
fields: {
|
|
138
|
+
email: 'delete',
|
|
139
|
+
}
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
- shorthand `'delete'` is normalized to `{ strategy: 'delete' }`
|
|
143
|
+
- entity `rowLevel` defaults to `'delete-fields'`
|
|
144
|
+
- with the default Prisma adapter:
|
|
145
|
+
- `'delete-row'` calls `deleteMany`
|
|
146
|
+
- `'delete-fields'` calls `updateMany` and writes `null` into the configured delete fields
|
|
147
|
+
|
|
148
|
+
### `anonymize`
|
|
149
|
+
|
|
150
|
+
```ts
|
|
151
|
+
fields: {
|
|
152
|
+
email: { strategy: 'anonymize', replacement: '[REDACTED]' },
|
|
153
|
+
}
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
- replacements must be static
|
|
157
|
+
- function replacements are rejected during policy compilation
|
|
158
|
+
|
|
159
|
+
### `retain`
|
|
160
|
+
|
|
161
|
+
```ts
|
|
162
|
+
fields: {
|
|
163
|
+
amount: {
|
|
164
|
+
strategy: 'retain',
|
|
165
|
+
legalBasis: 'tax:KR-basic-law-sec85',
|
|
166
|
+
until: '+7y',
|
|
167
|
+
},
|
|
168
|
+
}
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
- `legalBasis` is required
|
|
172
|
+
- `strictLegalBasis: true` enables `scheme:reference` validation
|
|
173
|
+
- `pseudonymize` is part of the type model, but this package does not perform pseudonymization by itself
|
|
174
|
+
|
|
175
|
+
### Mixed Strategies
|
|
176
|
+
|
|
177
|
+
When an entity mixes `delete`, `anonymize`, and `retain`, execution is intentionally conservative:
|
|
178
|
+
|
|
179
|
+
- `retain` fields are preserved
|
|
180
|
+
- delete fields are downgraded to field-level updates instead of row deletion
|
|
181
|
+
- mixed entities are reported as `strategy: 'mixed'` in erase stats
|
|
182
|
+
- retained fields are recorded in `stats.retained`
|
|
183
|
+
|
|
184
|
+
This prevents `retain` fields from being dropped just because some other fields on the same row are deletable.
|
|
185
|
+
|
|
186
|
+
## Export Behavior
|
|
187
|
+
|
|
188
|
+
`DataSubjectService.export(subjectId, tenantId)` does the following:
|
|
189
|
+
|
|
190
|
+
1. creates a request record
|
|
191
|
+
2. reads matching rows from every registered entity
|
|
192
|
+
3. writes one JSON file per entity into a ZIP archive
|
|
193
|
+
4. stores the ZIP through `ArtifactStorage.put(...)`
|
|
194
|
+
5. records:
|
|
195
|
+
- `artifactHash` as a SHA-256 digest of the ZIP bytes
|
|
196
|
+
- `artifactUrl` returned by the artifact storage
|
|
197
|
+
- `stats.entities[]` with `strategy: 'export'`
|
|
198
|
+
|
|
199
|
+
Current export artifact shape:
|
|
200
|
+
|
|
201
|
+
- key: `<requestId>.zip`
|
|
202
|
+
- contents: `<EntityName>.json` files
|
|
203
|
+
|
|
204
|
+
## Erase Behavior
|
|
205
|
+
|
|
206
|
+
`DataSubjectService.erase(subjectId, tenantId)` does the following:
|
|
207
|
+
|
|
208
|
+
1. creates a request record
|
|
209
|
+
2. publishes `data_subject.erasure_requested`
|
|
210
|
+
3. executes each registered entity according to its compiled policy
|
|
211
|
+
4. records:
|
|
212
|
+
- `stats.entities[]`
|
|
213
|
+
- `stats.retained[]`
|
|
214
|
+
- `stats.verificationResidual[]`
|
|
215
|
+
- `artifactHash` as a SHA-256 digest of the erase report JSON
|
|
216
|
+
|
|
217
|
+
Important details:
|
|
218
|
+
|
|
219
|
+
- erase uses `ArtifactStorage` for exports, but **not** for erase reports
|
|
220
|
+
- erase verification currently only fails on residual rows after `delete-row`
|
|
221
|
+
- field-level delete and anonymize operations keep rows in place by design
|
|
222
|
+
|
|
223
|
+
## NestJS Integration
|
|
224
|
+
|
|
225
|
+
`DataSubjectModule.forRoot(...)` accepts:
|
|
226
|
+
|
|
227
|
+
- `requestStorage`
|
|
228
|
+
- `artifactStorage`
|
|
229
|
+
- `slaDays`
|
|
230
|
+
- `strictLegalBasis`
|
|
231
|
+
- `entities`
|
|
232
|
+
- `publishOutbox`
|
|
233
|
+
- `publishAudit`
|
|
234
|
+
- `runInTransaction`
|
|
235
|
+
|
|
236
|
+
The module exports:
|
|
237
|
+
|
|
238
|
+
- `DataSubjectService`
|
|
239
|
+
- `DATA_SUBJECT_REGISTRY`
|
|
240
|
+
|
|
241
|
+
## Public API
|
|
242
|
+
|
|
243
|
+
The package currently exports:
|
|
244
|
+
|
|
245
|
+
- `DataSubjectService`
|
|
246
|
+
- `DataSubjectModule`
|
|
247
|
+
- `Registry`
|
|
248
|
+
- `compilePolicy`
|
|
249
|
+
- `validateLegalBasis`
|
|
250
|
+
- `fromPrisma`
|
|
251
|
+
- `InMemoryRequestStorage`
|
|
252
|
+
- `InMemoryArtifactStorage`
|
|
253
|
+
- all public types from `src/types.ts`
|
|
254
|
+
- typed errors from `src/errors.ts`
|
|
255
|
+
|
|
256
|
+
## Events and Hooks
|
|
257
|
+
|
|
258
|
+
### Outbox Hook
|
|
259
|
+
|
|
260
|
+
If `publishOutbox` is provided, the built-in service emits:
|
|
261
|
+
|
|
262
|
+
- `data_subject.request_created`
|
|
263
|
+
- `data_subject.erasure_requested`
|
|
264
|
+
- `data_subject.request_completed`
|
|
265
|
+
- `data_subject.request_failed`
|
|
266
|
+
|
|
267
|
+
`request_completed` and `request_failed` are emitted for both export and erase requests. `erasure_requested` is erase-only.
|
|
268
|
+
|
|
269
|
+
### Audit Hook
|
|
270
|
+
|
|
271
|
+
If `publishAudit` is provided, the built-in service currently emits:
|
|
272
|
+
|
|
273
|
+
- `data_subject.request_created`
|
|
274
|
+
|
|
275
|
+
No additional audit lifecycle events are emitted by the current implementation.
|
|
276
|
+
|
|
277
|
+
## Typed Errors
|
|
278
|
+
|
|
279
|
+
The package exposes `DataSubjectError` with stable error codes.
|
|
280
|
+
|
|
281
|
+
Currently used codes include:
|
|
282
|
+
|
|
283
|
+
- `dsr_invalid_policy`
|
|
284
|
+
- `dsr_anonymize_dynamic_replacement`
|
|
285
|
+
- `dsr_verification_failed`
|
|
286
|
+
- `dsr_entity_already_registered`
|
|
287
|
+
- `dsr_request_conflict`
|
|
288
|
+
- `dsr_request_not_found`
|
|
289
|
+
|
|
290
|
+
Some additional codes exist in the public enum for future or adapter-specific use.
|
|
291
|
+
|
|
292
|
+
## Transaction Boundaries
|
|
293
|
+
|
|
294
|
+
`runInTransaction` is an integration hook, not an automatic rollback guarantee.
|
|
295
|
+
|
|
296
|
+
```ts
|
|
297
|
+
new DataSubjectService({
|
|
298
|
+
// ...
|
|
299
|
+
runInTransaction: async (work) => myUnitOfWork.run(work),
|
|
300
|
+
});
|
|
301
|
+
```
|
|
302
|
+
|
|
303
|
+
Use it when your erase flow can run inside a real unit-of-work that also covers:
|
|
304
|
+
|
|
305
|
+
- the entity executors
|
|
306
|
+
- request storage writes
|
|
307
|
+
- outbox publishing
|
|
308
|
+
|
|
309
|
+
If those components do not participate in the same transaction boundary, rollback remains best-effort.
|
|
310
|
+
|
|
311
|
+
## Practical Limitations
|
|
312
|
+
|
|
313
|
+
The current implementation is intentionally small. A few things are important to know up front:
|
|
314
|
+
|
|
315
|
+
- `fromPrisma(...)` only depends on `findMany`, `deleteMany`, and `updateMany`
|
|
316
|
+
- default Prisma field deletion writes `null`; it does not inspect schema nullability
|
|
317
|
+
- request states include `validating`, but the built-in service currently transitions through `created -> processing -> completed|failed`
|
|
318
|
+
- there is no built-in subject existence check before export or erase
|
|
319
|
+
- only in-memory request and artifact adapters are included in this repository
|
|
320
|
+
|
|
321
|
+
## Development
|
|
322
|
+
|
|
323
|
+
```bash
|
|
324
|
+
npm test
|
|
325
|
+
npm run build
|
|
326
|
+
```
|
|
327
|
+
|
|
328
|
+
## CI and Release
|
|
329
|
+
|
|
330
|
+
GitHub Actions is configured with two workflows:
|
|
331
|
+
|
|
332
|
+
- `CI`: runs `npm ci`, `npm run lint`, `npm test -- --runInBand`, and `npm run build` on pushes, pull requests, and manual runs
|
|
333
|
+
- `Release`: runs the same validation suite and then publishes to npm when a GitHub Release is published
|
|
334
|
+
|
|
335
|
+
Release expectations:
|
|
336
|
+
|
|
337
|
+
- configure repository secret `NPM_TOKEN`
|
|
338
|
+
- publish a GitHub Release from a tag that matches `v<package.json version>`
|
|
339
|
+
- prerelease versions publish with npm dist-tag `next`
|
|
340
|
+
- stable versions such as `0.1.0` publish with npm dist-tag `latest`
|
|
341
|
+
|
|
342
|
+
## Related Docs
|
|
343
|
+
|
|
344
|
+
- [docs/prd.md](docs/prd.md)
|
|
345
|
+
- [docs/spec.md](docs/spec.md)
|
|
346
|
+
- [docs/compliance.md](docs/compliance.md)
|
|
347
|
+
- [CHANGELOG.md](CHANGELOG.md)
|
|
348
|
+
|
|
349
|
+
## License
|
|
350
|
+
|
|
351
|
+
MIT
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { DynamicModule } from '@nestjs/common';
|
|
2
|
+
import { type DataSubjectServiceDeps } from './data-subject.service';
|
|
3
|
+
import { type RegisterInput } from './registry';
|
|
4
|
+
import type { ArtifactStorage } from './storage/artifact-storage.interface';
|
|
5
|
+
import type { RequestStorage } from './storage/request-storage.interface';
|
|
6
|
+
export declare const DATA_SUBJECT_REGISTRY: unique symbol;
|
|
7
|
+
export interface DataSubjectModuleOptions {
|
|
8
|
+
requestStorage: RequestStorage;
|
|
9
|
+
artifactStorage: ArtifactStorage;
|
|
10
|
+
slaDays?: number;
|
|
11
|
+
strictLegalBasis?: boolean;
|
|
12
|
+
entities?: RegisterInput[];
|
|
13
|
+
publishOutbox?: DataSubjectServiceDeps['publishOutbox'];
|
|
14
|
+
publishAudit?: DataSubjectServiceDeps['publishAudit'];
|
|
15
|
+
runInTransaction?: DataSubjectServiceDeps['runInTransaction'];
|
|
16
|
+
}
|
|
17
|
+
export declare class DataSubjectModule {
|
|
18
|
+
static forRoot(options: DataSubjectModuleOptions): DynamicModule;
|
|
19
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
|
|
3
|
+
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
|
|
4
|
+
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
|
|
5
|
+
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
|
|
6
|
+
return c > 3 && r && Object.defineProperty(target, key, r), r;
|
|
7
|
+
};
|
|
8
|
+
var DataSubjectModule_1;
|
|
9
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
10
|
+
exports.DataSubjectModule = exports.DATA_SUBJECT_REGISTRY = void 0;
|
|
11
|
+
const common_1 = require("@nestjs/common");
|
|
12
|
+
const data_subject_service_1 = require("./data-subject.service");
|
|
13
|
+
const registry_1 = require("./registry");
|
|
14
|
+
exports.DATA_SUBJECT_REGISTRY = Symbol('DATA_SUBJECT_REGISTRY');
|
|
15
|
+
let DataSubjectModule = DataSubjectModule_1 = class DataSubjectModule {
|
|
16
|
+
static forRoot(options) {
|
|
17
|
+
const providers = [
|
|
18
|
+
{
|
|
19
|
+
provide: exports.DATA_SUBJECT_REGISTRY,
|
|
20
|
+
useFactory: () => {
|
|
21
|
+
const registry = new registry_1.Registry({
|
|
22
|
+
strictLegalBasis: options.strictLegalBasis,
|
|
23
|
+
});
|
|
24
|
+
for (const entity of options.entities ?? []) {
|
|
25
|
+
registry.register(entity);
|
|
26
|
+
}
|
|
27
|
+
return registry;
|
|
28
|
+
},
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
provide: data_subject_service_1.DataSubjectService,
|
|
32
|
+
useFactory: (registry) => new data_subject_service_1.DataSubjectService({
|
|
33
|
+
registry,
|
|
34
|
+
requestStorage: options.requestStorage,
|
|
35
|
+
artifactStorage: options.artifactStorage,
|
|
36
|
+
slaDays: options.slaDays ?? 30,
|
|
37
|
+
publishOutbox: options.publishOutbox,
|
|
38
|
+
publishAudit: options.publishAudit,
|
|
39
|
+
runInTransaction: options.runInTransaction,
|
|
40
|
+
}),
|
|
41
|
+
inject: [exports.DATA_SUBJECT_REGISTRY],
|
|
42
|
+
},
|
|
43
|
+
];
|
|
44
|
+
return {
|
|
45
|
+
module: DataSubjectModule_1,
|
|
46
|
+
providers,
|
|
47
|
+
exports: [data_subject_service_1.DataSubjectService, exports.DATA_SUBJECT_REGISTRY],
|
|
48
|
+
global: true,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
};
|
|
52
|
+
exports.DataSubjectModule = DataSubjectModule;
|
|
53
|
+
exports.DataSubjectModule = DataSubjectModule = DataSubjectModule_1 = __decorate([
|
|
54
|
+
(0, common_1.Module)({})
|
|
55
|
+
], DataSubjectModule);
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { Registry } from './registry';
|
|
2
|
+
import type { ArtifactStorage } from './storage/artifact-storage.interface';
|
|
3
|
+
import type { RequestStorage } from './storage/request-storage.interface';
|
|
4
|
+
import type { DataSubjectRequest, RequestState } from './types';
|
|
5
|
+
export interface DataSubjectServiceDeps {
|
|
6
|
+
registry: Registry;
|
|
7
|
+
requestStorage: RequestStorage;
|
|
8
|
+
artifactStorage: ArtifactStorage;
|
|
9
|
+
slaDays: number;
|
|
10
|
+
idFactory?: () => string;
|
|
11
|
+
clock?: () => Date;
|
|
12
|
+
publishOutbox?: (type: string, payload: unknown) => Promise<void>;
|
|
13
|
+
publishAudit?: (event: string, data: Record<string, unknown>) => Promise<void>;
|
|
14
|
+
runInTransaction?: <T>(work: () => Promise<T>) => Promise<T>;
|
|
15
|
+
}
|
|
16
|
+
export declare class DataSubjectService {
|
|
17
|
+
private readonly deps;
|
|
18
|
+
private readonly idFactory;
|
|
19
|
+
private readonly clock;
|
|
20
|
+
private readonly publishOutbox;
|
|
21
|
+
private readonly publishAudit;
|
|
22
|
+
private readonly runInTransaction;
|
|
23
|
+
constructor(deps: DataSubjectServiceDeps);
|
|
24
|
+
export(subjectId: string, tenantId: string): Promise<DataSubjectRequest>;
|
|
25
|
+
erase(subjectId: string, tenantId: string): Promise<DataSubjectRequest>;
|
|
26
|
+
getRequest(id: string): Promise<DataSubjectRequest>;
|
|
27
|
+
listByTenant(tenantId: string, opts?: {
|
|
28
|
+
state?: RequestState;
|
|
29
|
+
}): Promise<DataSubjectRequest[]>;
|
|
30
|
+
listOverdue(): Promise<DataSubjectRequest[]>;
|
|
31
|
+
private createRequest;
|
|
32
|
+
private setState;
|
|
33
|
+
private markFailed;
|
|
34
|
+
private mustLoad;
|
|
35
|
+
}
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.DataSubjectService = void 0;
|
|
4
|
+
const node_crypto_1 = require("node:crypto");
|
|
5
|
+
const erase_runner_1 = require("./erase-runner");
|
|
6
|
+
const errors_1 = require("./errors");
|
|
7
|
+
const export_runner_1 = require("./export-runner");
|
|
8
|
+
class DataSubjectService {
|
|
9
|
+
deps;
|
|
10
|
+
idFactory;
|
|
11
|
+
clock;
|
|
12
|
+
publishOutbox;
|
|
13
|
+
publishAudit;
|
|
14
|
+
runInTransaction;
|
|
15
|
+
constructor(deps) {
|
|
16
|
+
this.deps = deps;
|
|
17
|
+
this.idFactory = deps.idFactory ?? (() => (0, node_crypto_1.randomUUID)());
|
|
18
|
+
this.clock = deps.clock ?? (() => new Date());
|
|
19
|
+
this.publishOutbox = deps.publishOutbox ?? (async () => { });
|
|
20
|
+
this.publishAudit = deps.publishAudit ?? (async () => { });
|
|
21
|
+
this.runInTransaction = deps.runInTransaction ?? (async (work) => work());
|
|
22
|
+
}
|
|
23
|
+
async export(subjectId, tenantId) {
|
|
24
|
+
const request = await this.createRequest('export', subjectId, tenantId);
|
|
25
|
+
try {
|
|
26
|
+
await this.setState(request.id, 'processing');
|
|
27
|
+
const runner = new export_runner_1.ExportRunner(this.deps.registry, this.deps.artifactStorage);
|
|
28
|
+
const result = await runner.run(request.id, subjectId, tenantId);
|
|
29
|
+
await this.deps.requestStorage.update(request.id, {
|
|
30
|
+
state: 'completed',
|
|
31
|
+
completedAt: this.clock(),
|
|
32
|
+
artifactHash: result.artifactHash,
|
|
33
|
+
artifactUrl: result.artifactUrl,
|
|
34
|
+
stats: result.stats,
|
|
35
|
+
});
|
|
36
|
+
await this.publishOutbox('data_subject.request_completed', {
|
|
37
|
+
requestId: request.id,
|
|
38
|
+
state: 'completed',
|
|
39
|
+
artifactHash: result.artifactHash,
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
catch (error) {
|
|
43
|
+
await this.markFailed(request.id, error);
|
|
44
|
+
await this.publishOutbox('data_subject.request_failed', {
|
|
45
|
+
requestId: request.id,
|
|
46
|
+
failureReason: messageFromError(error),
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
return this.mustLoad(request.id);
|
|
50
|
+
}
|
|
51
|
+
async erase(subjectId, tenantId) {
|
|
52
|
+
const request = await this.createRequest('erase', subjectId, tenantId);
|
|
53
|
+
try {
|
|
54
|
+
await this.runInTransaction(async () => {
|
|
55
|
+
await this.publishOutbox('data_subject.erasure_requested', {
|
|
56
|
+
requestId: request.id,
|
|
57
|
+
subjectId,
|
|
58
|
+
tenantId,
|
|
59
|
+
requestedAt: this.clock().toISOString(),
|
|
60
|
+
});
|
|
61
|
+
await this.setState(request.id, 'processing');
|
|
62
|
+
const runner = new erase_runner_1.EraseRunner(this.deps.registry);
|
|
63
|
+
const result = await runner.run(subjectId, tenantId);
|
|
64
|
+
const report = JSON.stringify({ requestId: request.id, stats: result.stats });
|
|
65
|
+
const artifactHash = (0, node_crypto_1.createHash)('sha256').update(report).digest('hex');
|
|
66
|
+
await this.deps.requestStorage.update(request.id, {
|
|
67
|
+
state: 'completed',
|
|
68
|
+
completedAt: this.clock(),
|
|
69
|
+
stats: result.stats,
|
|
70
|
+
artifactHash,
|
|
71
|
+
});
|
|
72
|
+
await this.publishOutbox('data_subject.request_completed', {
|
|
73
|
+
requestId: request.id,
|
|
74
|
+
state: 'completed',
|
|
75
|
+
artifactHash,
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
catch (error) {
|
|
80
|
+
await this.markFailed(request.id, error);
|
|
81
|
+
await this.publishOutbox('data_subject.request_failed', {
|
|
82
|
+
requestId: request.id,
|
|
83
|
+
failureReason: messageFromError(error),
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
return this.mustLoad(request.id);
|
|
87
|
+
}
|
|
88
|
+
async getRequest(id) {
|
|
89
|
+
return this.mustLoad(id);
|
|
90
|
+
}
|
|
91
|
+
async listByTenant(tenantId, opts = {}) {
|
|
92
|
+
return this.deps.requestStorage.listByTenant(tenantId, opts);
|
|
93
|
+
}
|
|
94
|
+
async listOverdue() {
|
|
95
|
+
return this.deps.requestStorage.listOverdue(this.clock());
|
|
96
|
+
}
|
|
97
|
+
async createRequest(type, subjectId, tenantId) {
|
|
98
|
+
const now = this.clock();
|
|
99
|
+
const dueAt = new Date(now.getTime() + this.deps.slaDays * 86_400_000);
|
|
100
|
+
const request = {
|
|
101
|
+
id: this.idFactory(),
|
|
102
|
+
tenantId,
|
|
103
|
+
subjectId,
|
|
104
|
+
type,
|
|
105
|
+
state: 'created',
|
|
106
|
+
createdAt: now,
|
|
107
|
+
dueAt,
|
|
108
|
+
completedAt: null,
|
|
109
|
+
failedAt: null,
|
|
110
|
+
failureReason: null,
|
|
111
|
+
artifactHash: null,
|
|
112
|
+
artifactUrl: null,
|
|
113
|
+
stats: null,
|
|
114
|
+
requestedBy: null,
|
|
115
|
+
};
|
|
116
|
+
await this.deps.requestStorage.insert(request);
|
|
117
|
+
await this.publishOutbox('data_subject.request_created', {
|
|
118
|
+
requestId: request.id,
|
|
119
|
+
type,
|
|
120
|
+
subjectId,
|
|
121
|
+
tenantId,
|
|
122
|
+
});
|
|
123
|
+
await this.publishAudit('data_subject.request_created', {
|
|
124
|
+
requestId: request.id,
|
|
125
|
+
type,
|
|
126
|
+
tenantId,
|
|
127
|
+
});
|
|
128
|
+
return request;
|
|
129
|
+
}
|
|
130
|
+
async setState(id, state) {
|
|
131
|
+
await this.deps.requestStorage.update(id, { state });
|
|
132
|
+
}
|
|
133
|
+
async markFailed(id, error) {
|
|
134
|
+
await this.deps.requestStorage.update(id, {
|
|
135
|
+
state: 'failed',
|
|
136
|
+
failedAt: this.clock(),
|
|
137
|
+
failureReason: messageFromError(error),
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
async mustLoad(id) {
|
|
141
|
+
const request = await this.deps.requestStorage.findById(id);
|
|
142
|
+
if (!request) {
|
|
143
|
+
throw new errors_1.DataSubjectError(errors_1.DataSubjectErrorCode.RequestNotFound, `request ${id} not found`);
|
|
144
|
+
}
|
|
145
|
+
return request;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
exports.DataSubjectService = DataSubjectService;
|
|
149
|
+
function messageFromError(error) {
|
|
150
|
+
if (error instanceof Error && error.message) {
|
|
151
|
+
return error.message;
|
|
152
|
+
}
|
|
153
|
+
if (typeof error === 'string') {
|
|
154
|
+
return error;
|
|
155
|
+
}
|
|
156
|
+
try {
|
|
157
|
+
return JSON.stringify(error);
|
|
158
|
+
}
|
|
159
|
+
catch {
|
|
160
|
+
return String(error);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { Registry } from './registry';
|
|
2
|
+
import type { RequestStats } from './types';
|
|
3
|
+
export interface EraseResult {
|
|
4
|
+
stats: RequestStats;
|
|
5
|
+
}
|
|
6
|
+
export declare class EraseRunner {
|
|
7
|
+
private readonly registry;
|
|
8
|
+
constructor(registry: Registry);
|
|
9
|
+
run(subjectId: string, tenantId: string): Promise<EraseResult>;
|
|
10
|
+
}
|