@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/dist/erase-runner.js
CHANGED
|
@@ -7,11 +7,23 @@ class EraseRunner {
|
|
|
7
7
|
constructor(registry) {
|
|
8
8
|
this.registry = registry;
|
|
9
9
|
}
|
|
10
|
-
async run(subjectId, tenantId) {
|
|
10
|
+
async run(subjectId, tenantId, opts = {}) {
|
|
11
|
+
const entries = this.registry.list();
|
|
12
|
+
const preScan = [];
|
|
11
13
|
const entities = [];
|
|
12
14
|
const retained = [];
|
|
13
15
|
const verificationResidual = [];
|
|
14
|
-
|
|
16
|
+
const postScan = [];
|
|
17
|
+
const actions = [];
|
|
18
|
+
for (const entry of entries) {
|
|
19
|
+
const rowsBefore = await entry.executor.select(subjectId, tenantId);
|
|
20
|
+
preScan.push({
|
|
21
|
+
entityName: entry.policy.entityName,
|
|
22
|
+
count: rowsBefore.length,
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
await opts.afterPreScan?.(preScan);
|
|
26
|
+
for (const entry of entries) {
|
|
15
27
|
const outcome = classify(entry.policy);
|
|
16
28
|
let affected = 0;
|
|
17
29
|
if (outcome.rowStrategy === 'delete') {
|
|
@@ -28,11 +40,24 @@ class EraseRunner {
|
|
|
28
40
|
affected = rows.length;
|
|
29
41
|
}
|
|
30
42
|
const rowsAfter = await entry.executor.select(subjectId, tenantId);
|
|
43
|
+
postScan.push({
|
|
44
|
+
entityName: entry.policy.entityName,
|
|
45
|
+
count: rowsAfter.length,
|
|
46
|
+
});
|
|
47
|
+
const retainedFields = [];
|
|
31
48
|
for (const item of outcome.retained) {
|
|
32
|
-
|
|
49
|
+
const retainedItem = {
|
|
33
50
|
entityName: entry.policy.entityName,
|
|
34
51
|
field: item.field,
|
|
35
52
|
legalBasis: item.legalBasis,
|
|
53
|
+
...(item.until ? { until: item.until } : {}),
|
|
54
|
+
count: rowsAfter.length,
|
|
55
|
+
};
|
|
56
|
+
retained.push(retainedItem);
|
|
57
|
+
retainedFields.push({
|
|
58
|
+
field: item.field,
|
|
59
|
+
legalBasis: item.legalBasis,
|
|
60
|
+
...(item.until ? { until: item.until } : {}),
|
|
36
61
|
count: rowsAfter.length,
|
|
37
62
|
});
|
|
38
63
|
}
|
|
@@ -44,6 +69,22 @@ class EraseRunner {
|
|
|
44
69
|
count: rowsAfter.length,
|
|
45
70
|
});
|
|
46
71
|
}
|
|
72
|
+
const action = {
|
|
73
|
+
entityName: entry.policy.entityName,
|
|
74
|
+
affected,
|
|
75
|
+
strategy: outcome.summaryStrategy,
|
|
76
|
+
rowLevel: entry.policy.rowLevel,
|
|
77
|
+
};
|
|
78
|
+
if (outcome.deleteFields.length > 0) {
|
|
79
|
+
action.deleteFields = [...outcome.deleteFields];
|
|
80
|
+
}
|
|
81
|
+
if (outcome.anonymizedFields.length > 0) {
|
|
82
|
+
action.anonymizedFields = [...outcome.anonymizedFields];
|
|
83
|
+
}
|
|
84
|
+
if (retainedFields.length > 0) {
|
|
85
|
+
action.retainedFields = retainedFields;
|
|
86
|
+
}
|
|
87
|
+
actions.push(action);
|
|
47
88
|
entities.push({
|
|
48
89
|
entityName: entry.policy.entityName,
|
|
49
90
|
affected,
|
|
@@ -55,13 +96,23 @@ class EraseRunner {
|
|
|
55
96
|
.map((entry) => `${entry.entityName}(${entry.count})`)
|
|
56
97
|
.join(', ')}`);
|
|
57
98
|
}
|
|
58
|
-
return {
|
|
99
|
+
return {
|
|
100
|
+
stats: {
|
|
101
|
+
entities,
|
|
102
|
+
retained,
|
|
103
|
+
verificationResidual,
|
|
104
|
+
preScan,
|
|
105
|
+
postScan,
|
|
106
|
+
},
|
|
107
|
+
actions,
|
|
108
|
+
};
|
|
59
109
|
}
|
|
60
110
|
}
|
|
61
111
|
exports.EraseRunner = EraseRunner;
|
|
62
112
|
function classify(policy) {
|
|
63
113
|
const updateMap = {};
|
|
64
114
|
const deleteFields = [];
|
|
115
|
+
const anonymizedFields = [];
|
|
65
116
|
const retained = [];
|
|
66
117
|
const strategies = new Set();
|
|
67
118
|
for (const [field, entry] of Object.entries(policy.fields)) {
|
|
@@ -69,6 +120,7 @@ function classify(policy) {
|
|
|
69
120
|
strategies.add(normalized.strategy);
|
|
70
121
|
if (normalized.strategy === 'anonymize') {
|
|
71
122
|
updateMap[field] = normalized.replacement;
|
|
123
|
+
anonymizedFields.push(field);
|
|
72
124
|
}
|
|
73
125
|
if (normalized.strategy === 'delete') {
|
|
74
126
|
deleteFields.push(field);
|
|
@@ -78,12 +130,20 @@ function classify(policy) {
|
|
|
78
130
|
retained.push({
|
|
79
131
|
field,
|
|
80
132
|
legalBasis: normalized.legalBasis,
|
|
133
|
+
...(normalized.until ? { until: normalized.until } : {}),
|
|
81
134
|
});
|
|
82
135
|
}
|
|
83
136
|
}
|
|
84
137
|
const rowStrategy = chooseRowStrategy(strategies);
|
|
85
138
|
const summaryStrategy = strategies.size > 1 ? 'mixed' : rowStrategy;
|
|
86
|
-
return {
|
|
139
|
+
return {
|
|
140
|
+
rowStrategy,
|
|
141
|
+
summaryStrategy,
|
|
142
|
+
deleteFields,
|
|
143
|
+
updateMap,
|
|
144
|
+
anonymizedFields,
|
|
145
|
+
retained,
|
|
146
|
+
};
|
|
87
147
|
}
|
|
88
148
|
function chooseRowStrategy(strategies) {
|
|
89
149
|
if (strategies.size === 1 && strategies.has('delete')) {
|
|
@@ -108,6 +168,7 @@ function normalize(entry) {
|
|
|
108
168
|
return {
|
|
109
169
|
strategy: 'retain',
|
|
110
170
|
legalBasis: entry.legalBasis,
|
|
171
|
+
...(entry.until ? { until: entry.until } : {}),
|
|
111
172
|
};
|
|
112
173
|
}
|
|
113
174
|
return { strategy: 'delete' };
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { ErasureEvidenceAction, ErasureEvidenceArtifact, RequestStats } from './types';
|
|
2
|
+
export interface BuildErasureEvidenceInput {
|
|
3
|
+
requestId: string;
|
|
4
|
+
tenantId: string;
|
|
5
|
+
generatedAt: Date;
|
|
6
|
+
stats: RequestStats;
|
|
7
|
+
actions: ErasureEvidenceAction[];
|
|
8
|
+
}
|
|
9
|
+
export declare function buildErasureEvidenceArtifact(input: BuildErasureEvidenceInput): ErasureEvidenceArtifact;
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.buildErasureEvidenceArtifact = buildErasureEvidenceArtifact;
|
|
4
|
+
function buildErasureEvidenceArtifact(input) {
|
|
5
|
+
return {
|
|
6
|
+
schemaVersion: 'data-subject.erasure-evidence.v1',
|
|
7
|
+
requestId: input.requestId,
|
|
8
|
+
tenantId: input.tenantId,
|
|
9
|
+
requestType: 'erase',
|
|
10
|
+
generatedAt: input.generatedAt.toISOString(),
|
|
11
|
+
state: 'completed',
|
|
12
|
+
preScan: input.stats.preScan ?? [],
|
|
13
|
+
actions: input.actions,
|
|
14
|
+
postScan: input.stats.postScan ?? [],
|
|
15
|
+
verificationResidual: input.stats.verificationResidual ?? [],
|
|
16
|
+
artifactHashAlgorithm: 'sha256',
|
|
17
|
+
};
|
|
18
|
+
}
|
package/dist/errors.d.ts
CHANGED
|
@@ -7,6 +7,9 @@ export declare const DataSubjectErrorCode: {
|
|
|
7
7
|
readonly EntityAlreadyRegistered: "dsr_entity_already_registered";
|
|
8
8
|
readonly RequestConflict: "dsr_request_conflict";
|
|
9
9
|
readonly RequestNotFound: "dsr_request_not_found";
|
|
10
|
+
readonly ArtifactWriteFailed: "dsr_artifact_write_failed";
|
|
11
|
+
readonly InvalidStateTransition: "dsr_invalid_state_transition";
|
|
12
|
+
readonly EvidenceReportInvalid: "dsr_evidence_report_invalid";
|
|
10
13
|
};
|
|
11
14
|
export type DataSubjectErrorCode = (typeof DataSubjectErrorCode)[keyof typeof DataSubjectErrorCode];
|
|
12
15
|
export declare class DataSubjectError extends Error {
|
package/dist/errors.js
CHANGED
|
@@ -10,6 +10,9 @@ exports.DataSubjectErrorCode = {
|
|
|
10
10
|
EntityAlreadyRegistered: 'dsr_entity_already_registered',
|
|
11
11
|
RequestConflict: 'dsr_request_conflict',
|
|
12
12
|
RequestNotFound: 'dsr_request_not_found',
|
|
13
|
+
ArtifactWriteFailed: 'dsr_artifact_write_failed',
|
|
14
|
+
InvalidStateTransition: 'dsr_invalid_state_transition',
|
|
15
|
+
EvidenceReportInvalid: 'dsr_evidence_report_invalid',
|
|
13
16
|
};
|
|
14
17
|
const HTTP_STATUS = {
|
|
15
18
|
dsr_subject_not_found: 404,
|
|
@@ -20,6 +23,9 @@ const HTTP_STATUS = {
|
|
|
20
23
|
dsr_entity_already_registered: 500,
|
|
21
24
|
dsr_request_conflict: 409,
|
|
22
25
|
dsr_request_not_found: 404,
|
|
26
|
+
dsr_artifact_write_failed: 500,
|
|
27
|
+
dsr_invalid_state_transition: 500,
|
|
28
|
+
dsr_evidence_report_invalid: 500,
|
|
23
29
|
};
|
|
24
30
|
class DataSubjectError extends Error {
|
|
25
31
|
code;
|
package/dist/export-runner.js
CHANGED
|
@@ -4,8 +4,8 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
4
4
|
};
|
|
5
5
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
6
|
exports.ExportRunner = void 0;
|
|
7
|
-
const node_crypto_1 = require("node:crypto");
|
|
8
7
|
const jszip_1 = __importDefault(require("jszip"));
|
|
8
|
+
const artifacts_1 = require("./artifacts");
|
|
9
9
|
class ExportRunner {
|
|
10
10
|
registry;
|
|
11
11
|
artifacts;
|
|
@@ -26,8 +26,8 @@ class ExportRunner {
|
|
|
26
26
|
});
|
|
27
27
|
}
|
|
28
28
|
const body = await zip.generateAsync({ type: 'nodebuffer' });
|
|
29
|
-
const artifactHash = (0,
|
|
30
|
-
const artifactUrl = await this.artifacts.put(
|
|
29
|
+
const artifactHash = (0, artifacts_1.sha256Hex)(body);
|
|
30
|
+
const artifactUrl = await this.artifacts.put((0, artifacts_1.exportArtifactKey)(tenantId, requestId), body, 'application/zip');
|
|
31
31
|
return { artifactHash, artifactUrl, stats: { entities } };
|
|
32
32
|
}
|
|
33
33
|
}
|
package/dist/index.d.ts
CHANGED
|
@@ -12,7 +12,15 @@ export { DATA_SUBJECT_REGISTRY, DataSubjectModule, } from './data-subject.module
|
|
|
12
12
|
export type { DataSubjectModuleOptions } from './data-subject.module';
|
|
13
13
|
export type { RequestStorage } from './storage/request-storage.interface';
|
|
14
14
|
export { InMemoryRequestStorage } from './storage/in-memory-request-storage';
|
|
15
|
+
export { PrismaRequestStorage } from './storage/prisma-request-storage';
|
|
16
|
+
export type { PrismaDataSubjectRequestDelegate, PrismaRequestStorageOptions, } from './storage/prisma-request-storage';
|
|
15
17
|
export type { ArtifactStorage } from './storage/artifact-storage.interface';
|
|
16
18
|
export { InMemoryArtifactStorage } from './storage/in-memory-artifact-storage';
|
|
17
19
|
export { fromPrisma } from './prisma/from-prisma';
|
|
18
20
|
export type { FromPrismaOptions, PrismaDelegate } from './prisma/from-prisma';
|
|
21
|
+
export { exportArtifactKey, erasureEvidenceArtifactKey, sha256Hex, } from './artifacts';
|
|
22
|
+
export { buildErasureEvidenceArtifact } from './erasure-evidence';
|
|
23
|
+
export { validateRegistry } from './registry-validation';
|
|
24
|
+
export type { RegistryValidationReport } from './registry-validation';
|
|
25
|
+
export { formatLintReport, lintPrismaSchema, parsePrismaSchema, shouldFailLint, } from './lint';
|
|
26
|
+
export type { DataSubjectLintCode, DataSubjectLintConfig, DataSubjectLintFinding, DataSubjectLintRegistryEntry, DataSubjectLintReport, DataSubjectLintSeverity, DataSubjectLintSuppression, } from './lint';
|
package/dist/index.js
CHANGED
|
@@ -14,7 +14,7 @@ var __exportStar = (this && this.__exportStar) || function(m, exports) {
|
|
|
14
14
|
for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
|
|
15
15
|
};
|
|
16
16
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
17
|
-
exports.fromPrisma = exports.InMemoryArtifactStorage = exports.InMemoryRequestStorage = exports.DataSubjectModule = exports.DATA_SUBJECT_REGISTRY = exports.DataSubjectService = exports.Registry = exports.compilePolicy = exports.validateLegalBasis = void 0;
|
|
17
|
+
exports.shouldFailLint = exports.parsePrismaSchema = exports.lintPrismaSchema = exports.formatLintReport = exports.validateRegistry = exports.buildErasureEvidenceArtifact = exports.sha256Hex = exports.erasureEvidenceArtifactKey = exports.exportArtifactKey = exports.fromPrisma = exports.InMemoryArtifactStorage = exports.PrismaRequestStorage = exports.InMemoryRequestStorage = exports.DataSubjectModule = exports.DATA_SUBJECT_REGISTRY = exports.DataSubjectService = exports.Registry = exports.compilePolicy = exports.validateLegalBasis = void 0;
|
|
18
18
|
__exportStar(require("./types"), exports);
|
|
19
19
|
__exportStar(require("./errors"), exports);
|
|
20
20
|
var legal_basis_1 = require("./legal-basis");
|
|
@@ -30,7 +30,22 @@ Object.defineProperty(exports, "DATA_SUBJECT_REGISTRY", { enumerable: true, get:
|
|
|
30
30
|
Object.defineProperty(exports, "DataSubjectModule", { enumerable: true, get: function () { return data_subject_module_1.DataSubjectModule; } });
|
|
31
31
|
var in_memory_request_storage_1 = require("./storage/in-memory-request-storage");
|
|
32
32
|
Object.defineProperty(exports, "InMemoryRequestStorage", { enumerable: true, get: function () { return in_memory_request_storage_1.InMemoryRequestStorage; } });
|
|
33
|
+
var prisma_request_storage_1 = require("./storage/prisma-request-storage");
|
|
34
|
+
Object.defineProperty(exports, "PrismaRequestStorage", { enumerable: true, get: function () { return prisma_request_storage_1.PrismaRequestStorage; } });
|
|
33
35
|
var in_memory_artifact_storage_1 = require("./storage/in-memory-artifact-storage");
|
|
34
36
|
Object.defineProperty(exports, "InMemoryArtifactStorage", { enumerable: true, get: function () { return in_memory_artifact_storage_1.InMemoryArtifactStorage; } });
|
|
35
37
|
var from_prisma_1 = require("./prisma/from-prisma");
|
|
36
38
|
Object.defineProperty(exports, "fromPrisma", { enumerable: true, get: function () { return from_prisma_1.fromPrisma; } });
|
|
39
|
+
var artifacts_1 = require("./artifacts");
|
|
40
|
+
Object.defineProperty(exports, "exportArtifactKey", { enumerable: true, get: function () { return artifacts_1.exportArtifactKey; } });
|
|
41
|
+
Object.defineProperty(exports, "erasureEvidenceArtifactKey", { enumerable: true, get: function () { return artifacts_1.erasureEvidenceArtifactKey; } });
|
|
42
|
+
Object.defineProperty(exports, "sha256Hex", { enumerable: true, get: function () { return artifacts_1.sha256Hex; } });
|
|
43
|
+
var erasure_evidence_1 = require("./erasure-evidence");
|
|
44
|
+
Object.defineProperty(exports, "buildErasureEvidenceArtifact", { enumerable: true, get: function () { return erasure_evidence_1.buildErasureEvidenceArtifact; } });
|
|
45
|
+
var registry_validation_1 = require("./registry-validation");
|
|
46
|
+
Object.defineProperty(exports, "validateRegistry", { enumerable: true, get: function () { return registry_validation_1.validateRegistry; } });
|
|
47
|
+
var lint_1 = require("./lint");
|
|
48
|
+
Object.defineProperty(exports, "formatLintReport", { enumerable: true, get: function () { return lint_1.formatLintReport; } });
|
|
49
|
+
Object.defineProperty(exports, "lintPrismaSchema", { enumerable: true, get: function () { return lint_1.lintPrismaSchema; } });
|
|
50
|
+
Object.defineProperty(exports, "parsePrismaSchema", { enumerable: true, get: function () { return lint_1.parsePrismaSchema; } });
|
|
51
|
+
Object.defineProperty(exports, "shouldFailLint", { enumerable: true, get: function () { return lint_1.shouldFailLint; } });
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __exportStar = (this && this.__exportStar) || function(m, exports) {
|
|
14
|
+
for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
|
|
15
|
+
};
|
|
16
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
17
|
+
exports.shouldFailLint = exports.lintPrismaSchema = exports.formatLintReport = exports.parsePrismaSchema = void 0;
|
|
18
|
+
__exportStar(require("./types"), exports);
|
|
19
|
+
var prisma_schema_1 = require("./prisma-schema");
|
|
20
|
+
Object.defineProperty(exports, "parsePrismaSchema", { enumerable: true, get: function () { return prisma_schema_1.parsePrismaSchema; } });
|
|
21
|
+
var lint_1 = require("./lint");
|
|
22
|
+
Object.defineProperty(exports, "formatLintReport", { enumerable: true, get: function () { return lint_1.formatLintReport; } });
|
|
23
|
+
Object.defineProperty(exports, "lintPrismaSchema", { enumerable: true, get: function () { return lint_1.lintPrismaSchema; } });
|
|
24
|
+
Object.defineProperty(exports, "shouldFailLint", { enumerable: true, get: function () { return lint_1.shouldFailLint; } });
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { DataSubjectLintConfig, DataSubjectLintReport, DataSubjectLintSeverity } from './types';
|
|
2
|
+
export declare function lintPrismaSchema(schemaSource: string, config?: DataSubjectLintConfig): DataSubjectLintReport;
|
|
3
|
+
export declare function formatLintReport(report: DataSubjectLintReport): string;
|
|
4
|
+
export declare function shouldFailLint(report: DataSubjectLintReport, failOn: DataSubjectLintSeverity): boolean;
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.lintPrismaSchema = lintPrismaSchema;
|
|
4
|
+
exports.formatLintReport = formatLintReport;
|
|
5
|
+
exports.shouldFailLint = shouldFailLint;
|
|
6
|
+
const policy_compiler_1 = require("../policy-compiler");
|
|
7
|
+
const prisma_schema_1 = require("./prisma-schema");
|
|
8
|
+
const DEFAULT_PII_FIELD_PATTERNS = [
|
|
9
|
+
'email',
|
|
10
|
+
'phone',
|
|
11
|
+
'name',
|
|
12
|
+
'address',
|
|
13
|
+
'ip',
|
|
14
|
+
'birth',
|
|
15
|
+
'birthday',
|
|
16
|
+
'national',
|
|
17
|
+
'ssn',
|
|
18
|
+
'avatar',
|
|
19
|
+
];
|
|
20
|
+
function lintPrismaSchema(schemaSource, config = {}) {
|
|
21
|
+
const models = (0, prisma_schema_1.parsePrismaSchema)(schemaSource);
|
|
22
|
+
const findings = [];
|
|
23
|
+
const registry = new Map((config.registry ?? []).map((entry) => [entry.entityName, entry]));
|
|
24
|
+
const suppressions = config.suppressions ?? [];
|
|
25
|
+
const requireTenantField = config.requireTenantField ?? true;
|
|
26
|
+
for (const suppression of suppressions) {
|
|
27
|
+
if (!suppression.reason.trim()) {
|
|
28
|
+
findings.push({
|
|
29
|
+
severity: 'error',
|
|
30
|
+
code: 'dsr_lint_empty_suppression_reason',
|
|
31
|
+
model: suppression.model,
|
|
32
|
+
field: suppression.field,
|
|
33
|
+
message: `${formatModelField(suppression.model, suppression.field)} suppression requires a reason`,
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
for (const entry of registry.values()) {
|
|
38
|
+
const model = models.find((item) => item.name === entry.entityName);
|
|
39
|
+
try {
|
|
40
|
+
(0, policy_compiler_1.compilePolicy)({
|
|
41
|
+
entityName: entry.entityName,
|
|
42
|
+
subjectField: entry.subjectField,
|
|
43
|
+
rowLevel: entry.rowLevel,
|
|
44
|
+
fields: entry.fields,
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
catch (error) {
|
|
48
|
+
findings.push({
|
|
49
|
+
severity: 'error',
|
|
50
|
+
code: 'dsr_lint_invalid_policy',
|
|
51
|
+
model: entry.entityName,
|
|
52
|
+
message: error instanceof Error
|
|
53
|
+
? error.message
|
|
54
|
+
: `${entry.entityName} has an invalid data subject policy`,
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
if (!model) {
|
|
58
|
+
findings.push({
|
|
59
|
+
severity: 'error',
|
|
60
|
+
code: 'dsr_lint_unregistered_model',
|
|
61
|
+
model: entry.entityName,
|
|
62
|
+
message: `${entry.entityName} is registered but is not present in the Prisma schema`,
|
|
63
|
+
});
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
if (!hasField(model, entry.subjectField)) {
|
|
67
|
+
findings.push({
|
|
68
|
+
severity: 'error',
|
|
69
|
+
code: 'dsr_lint_subject_field_missing',
|
|
70
|
+
model: entry.entityName,
|
|
71
|
+
field: entry.subjectField,
|
|
72
|
+
message: `${entry.entityName}.${entry.subjectField} subjectField is missing from the Prisma schema`,
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
if (requireTenantField && !entry.tenantField) {
|
|
76
|
+
findings.push({
|
|
77
|
+
severity: 'warning',
|
|
78
|
+
code: 'dsr_lint_missing_tenant_field',
|
|
79
|
+
model: entry.entityName,
|
|
80
|
+
message: `${entry.entityName} has no tenantField in the data-subject lint config`,
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
for (const field of Object.keys(entry.fields)) {
|
|
84
|
+
if (!hasField(model, field)) {
|
|
85
|
+
findings.push({
|
|
86
|
+
severity: 'error',
|
|
87
|
+
code: 'dsr_lint_missing_policy_field',
|
|
88
|
+
model: entry.entityName,
|
|
89
|
+
field,
|
|
90
|
+
message: `${entry.entityName}.${field} is in the data subject policy but not in the Prisma schema`,
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
const patterns = config.piiFieldPatterns ?? DEFAULT_PII_FIELD_PATTERNS;
|
|
96
|
+
for (const model of models) {
|
|
97
|
+
const candidates = model.fields.filter((field) => isPiiCandidate(field.name, patterns));
|
|
98
|
+
if (candidates.length === 0 || isSuppressed(suppressions, model.name)) {
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
const entry = registry.get(model.name);
|
|
102
|
+
if (!entry) {
|
|
103
|
+
findings.push({
|
|
104
|
+
severity: 'error',
|
|
105
|
+
code: 'dsr_lint_unregistered_model',
|
|
106
|
+
model: model.name,
|
|
107
|
+
field: candidates[0]?.name,
|
|
108
|
+
message: `${model.name} has PII-like fields but is not registered or suppressed`,
|
|
109
|
+
});
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
for (const field of candidates) {
|
|
113
|
+
if (isSuppressed(suppressions, model.name, field.name)) {
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
if (!(field.name in entry.fields)) {
|
|
117
|
+
findings.push({
|
|
118
|
+
severity: 'error',
|
|
119
|
+
code: 'dsr_lint_missing_policy_field',
|
|
120
|
+
model: model.name,
|
|
121
|
+
field: field.name,
|
|
122
|
+
message: `${model.name}.${field.name} looks like PII but is missing from the data subject policy`,
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
return {
|
|
128
|
+
ok: !findings.some((finding) => finding.severity === 'error'),
|
|
129
|
+
findings,
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
function formatLintReport(report) {
|
|
133
|
+
if (report.findings.length === 0) {
|
|
134
|
+
return 'data-subject lint: ok';
|
|
135
|
+
}
|
|
136
|
+
return report.findings
|
|
137
|
+
.map((finding) => `[${finding.severity}] ${finding.code} ${formatModelField(finding.model, finding.field)}: ${finding.message}`)
|
|
138
|
+
.join('\n');
|
|
139
|
+
}
|
|
140
|
+
function shouldFailLint(report, failOn) {
|
|
141
|
+
if (failOn === 'warning') {
|
|
142
|
+
return report.findings.length > 0;
|
|
143
|
+
}
|
|
144
|
+
return report.findings.some((finding) => finding.severity === 'error');
|
|
145
|
+
}
|
|
146
|
+
function hasField(model, fieldName) {
|
|
147
|
+
return model.fields.some((field) => field.name === fieldName);
|
|
148
|
+
}
|
|
149
|
+
function isPiiCandidate(fieldName, patterns) {
|
|
150
|
+
const normalized = fieldName.toLowerCase().replace(/[_-]/g, '');
|
|
151
|
+
return patterns.some((pattern) => normalized.includes(pattern.toLowerCase().replace(/[_-]/g, '')));
|
|
152
|
+
}
|
|
153
|
+
function isSuppressed(suppressions, model, field) {
|
|
154
|
+
return suppressions.some((suppression) => suppression.model === model &&
|
|
155
|
+
suppression.reason.trim().length > 0 &&
|
|
156
|
+
(field ? suppression.field === field || !suppression.field : true));
|
|
157
|
+
}
|
|
158
|
+
function formatModelField(model, field) {
|
|
159
|
+
return field ? `${model}.${field}` : model;
|
|
160
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export interface PrismaSchemaField {
|
|
2
|
+
name: string;
|
|
3
|
+
type: string;
|
|
4
|
+
line: number;
|
|
5
|
+
}
|
|
6
|
+
export interface PrismaSchemaModel {
|
|
7
|
+
name: string;
|
|
8
|
+
fields: PrismaSchemaField[];
|
|
9
|
+
line: number;
|
|
10
|
+
}
|
|
11
|
+
export declare function parsePrismaSchema(source: string): PrismaSchemaModel[];
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.parsePrismaSchema = parsePrismaSchema;
|
|
4
|
+
function parsePrismaSchema(source) {
|
|
5
|
+
const models = [];
|
|
6
|
+
const lines = source.split(/\r?\n/);
|
|
7
|
+
let current = null;
|
|
8
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
9
|
+
const lineNumber = index + 1;
|
|
10
|
+
const line = stripLineComment(lines[index]).trim();
|
|
11
|
+
if (!line) {
|
|
12
|
+
continue;
|
|
13
|
+
}
|
|
14
|
+
if (!current) {
|
|
15
|
+
const match = /^model\s+([A-Za-z_][A-Za-z0-9_]*)\s*\{/.exec(line);
|
|
16
|
+
if (match) {
|
|
17
|
+
current = { name: match[1], fields: [], line: lineNumber };
|
|
18
|
+
}
|
|
19
|
+
continue;
|
|
20
|
+
}
|
|
21
|
+
if (line.startsWith('}')) {
|
|
22
|
+
models.push(current);
|
|
23
|
+
current = null;
|
|
24
|
+
continue;
|
|
25
|
+
}
|
|
26
|
+
if (line.startsWith('@@') || line.startsWith('@')) {
|
|
27
|
+
continue;
|
|
28
|
+
}
|
|
29
|
+
const fieldMatch = /^([A-Za-z_][A-Za-z0-9_]*)\s+([^\s]+)/.exec(line);
|
|
30
|
+
if (fieldMatch) {
|
|
31
|
+
current.fields.push({
|
|
32
|
+
name: fieldMatch[1],
|
|
33
|
+
type: fieldMatch[2],
|
|
34
|
+
line: lineNumber,
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return models;
|
|
39
|
+
}
|
|
40
|
+
function stripLineComment(line) {
|
|
41
|
+
const index = line.indexOf('//');
|
|
42
|
+
return index === -1 ? line : line.slice(0, index);
|
|
43
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { PolicyEntry } from '../types';
|
|
2
|
+
export type DataSubjectLintSeverity = 'warning' | 'error';
|
|
3
|
+
export type DataSubjectLintCode = 'dsr_lint_unregistered_model' | 'dsr_lint_missing_policy_field' | 'dsr_lint_missing_tenant_field' | 'dsr_lint_empty_suppression_reason' | 'dsr_lint_invalid_policy' | 'dsr_lint_subject_field_missing';
|
|
4
|
+
export interface DataSubjectLintFinding {
|
|
5
|
+
severity: DataSubjectLintSeverity;
|
|
6
|
+
code: DataSubjectLintCode;
|
|
7
|
+
model: string;
|
|
8
|
+
field?: string;
|
|
9
|
+
message: string;
|
|
10
|
+
}
|
|
11
|
+
export interface DataSubjectLintRegistryEntry {
|
|
12
|
+
entityName: string;
|
|
13
|
+
subjectField: string;
|
|
14
|
+
tenantField?: string;
|
|
15
|
+
rowLevel?: 'delete-row' | 'delete-fields';
|
|
16
|
+
fields: Record<string, PolicyEntry>;
|
|
17
|
+
}
|
|
18
|
+
export interface DataSubjectLintSuppression {
|
|
19
|
+
model: string;
|
|
20
|
+
field?: string;
|
|
21
|
+
reason: string;
|
|
22
|
+
}
|
|
23
|
+
export interface DataSubjectLintConfig {
|
|
24
|
+
registry?: DataSubjectLintRegistryEntry[];
|
|
25
|
+
piiFieldPatterns?: string[];
|
|
26
|
+
suppressions?: DataSubjectLintSuppression[];
|
|
27
|
+
requireTenantField?: boolean;
|
|
28
|
+
}
|
|
29
|
+
export interface DataSubjectLintReport {
|
|
30
|
+
ok: boolean;
|
|
31
|
+
findings: DataSubjectLintFinding[];
|
|
32
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { Registry } from './registry';
|
|
2
|
+
import type { DataSubjectLintFinding } from './lint/types';
|
|
3
|
+
export interface RegistryValidationReport {
|
|
4
|
+
ok: boolean;
|
|
5
|
+
findings: DataSubjectLintFinding[];
|
|
6
|
+
}
|
|
7
|
+
export declare function validateRegistry(registry: Registry, opts?: {
|
|
8
|
+
requireTenantField?: boolean;
|
|
9
|
+
}): RegistryValidationReport;
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.validateRegistry = validateRegistry;
|
|
4
|
+
function validateRegistry(registry, opts = {}) {
|
|
5
|
+
const findings = [];
|
|
6
|
+
for (const entry of registry.list()) {
|
|
7
|
+
const { policy } = entry;
|
|
8
|
+
if (!policy.subjectField.trim()) {
|
|
9
|
+
findings.push({
|
|
10
|
+
severity: 'error',
|
|
11
|
+
code: 'dsr_lint_subject_field_missing',
|
|
12
|
+
model: policy.entityName,
|
|
13
|
+
message: `${policy.entityName} has an empty subjectField`,
|
|
14
|
+
});
|
|
15
|
+
}
|
|
16
|
+
if (Object.keys(policy.fields).length === 0) {
|
|
17
|
+
findings.push({
|
|
18
|
+
severity: 'warning',
|
|
19
|
+
code: 'dsr_lint_missing_policy_field',
|
|
20
|
+
model: policy.entityName,
|
|
21
|
+
message: `${policy.entityName} has no data subject policy fields`,
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
if (opts.requireTenantField) {
|
|
25
|
+
findings.push({
|
|
26
|
+
severity: 'warning',
|
|
27
|
+
code: 'dsr_lint_missing_tenant_field',
|
|
28
|
+
model: policy.entityName,
|
|
29
|
+
message: `${policy.entityName} tenantField cannot be verified from runtime registry metadata`,
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return {
|
|
34
|
+
ok: !findings.some((finding) => finding.severity === 'error'),
|
|
35
|
+
findings,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function stableStringify(value: unknown): string;
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.stableStringify = stableStringify;
|
|
4
|
+
function stableStringify(value) {
|
|
5
|
+
return JSON.stringify(sortJsonValue(value));
|
|
6
|
+
}
|
|
7
|
+
function sortJsonValue(value) {
|
|
8
|
+
if (value instanceof Date) {
|
|
9
|
+
return value.toISOString();
|
|
10
|
+
}
|
|
11
|
+
if (Array.isArray(value)) {
|
|
12
|
+
return value.map((item) => sortJsonValue(item));
|
|
13
|
+
}
|
|
14
|
+
if (!isPlainObject(value)) {
|
|
15
|
+
return value;
|
|
16
|
+
}
|
|
17
|
+
const sorted = {};
|
|
18
|
+
for (const key of Object.keys(value).sort()) {
|
|
19
|
+
const item = value[key];
|
|
20
|
+
if (item !== undefined) {
|
|
21
|
+
sorted[key] = sortJsonValue(item);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
return sorted;
|
|
25
|
+
}
|
|
26
|
+
function isPlainObject(value) {
|
|
27
|
+
return (typeof value === 'object' &&
|
|
28
|
+
value !== null &&
|
|
29
|
+
Object.getPrototypeOf(value) === Object.prototype);
|
|
30
|
+
}
|