vilopa 0.0.1__py3-none-any.whl
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.
- mita/__init__.py +5 -0
- mita/cli.py +220 -0
- mita/connectors/__init__.py +0 -0
- mita/connectors/postgres.py +101 -0
- mita/core/__init__.py +0 -0
- mita/core/catalog.py +21 -0
- mita/core/datamap.py +321 -0
- mita/core/detect.py +336 -0
- mita/core/lint.py +136 -0
- vilopa-0.0.1.dist-info/METADATA +180 -0
- vilopa-0.0.1.dist-info/RECORD +14 -0
- vilopa-0.0.1.dist-info/WHEEL +4 -0
- vilopa-0.0.1.dist-info/entry_points.txt +2 -0
- vilopa-0.0.1.dist-info/licenses/LICENSE +202 -0
mita/__init__.py
ADDED
mita/cli.py
ADDED
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
"""mita command-line interface."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
from collections.abc import Sequence
|
|
8
|
+
from enum import StrEnum
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Annotated, Any
|
|
11
|
+
|
|
12
|
+
import psycopg
|
|
13
|
+
import typer
|
|
14
|
+
|
|
15
|
+
from mita import __version__
|
|
16
|
+
from mita.connectors import postgres
|
|
17
|
+
from mita.core import datamap
|
|
18
|
+
from mita.core.detect import ColumnReport, Confidence
|
|
19
|
+
from mita.core.lint import LintResult, lint
|
|
20
|
+
|
|
21
|
+
app = typer.Typer(
|
|
22
|
+
no_args_is_help=True,
|
|
23
|
+
help="Execute and verify data-erasure and access requests.",
|
|
24
|
+
)
|
|
25
|
+
datamap_app = typer.Typer(no_args_is_help=True, help="Validate and inspect data maps.")
|
|
26
|
+
app.add_typer(datamap_app, name="datamap")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _print_version(value: bool) -> None:
|
|
30
|
+
if value:
|
|
31
|
+
typer.echo(f"mita {__version__}")
|
|
32
|
+
raise typer.Exit
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@app.callback()
|
|
36
|
+
def main(
|
|
37
|
+
version: Annotated[
|
|
38
|
+
bool,
|
|
39
|
+
typer.Option("--version", callback=_print_version, is_eager=True, help="Show version."),
|
|
40
|
+
] = False,
|
|
41
|
+
) -> None:
|
|
42
|
+
"""mita: delete a person's data from every system you mapped, and prove it."""
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@datamap_app.command("validate")
|
|
46
|
+
def validate(
|
|
47
|
+
path: Annotated[Path, typer.Argument(exists=True, dir_okay=False, readable=True)],
|
|
48
|
+
) -> None:
|
|
49
|
+
"""Check a data map for structural and cross-reference problems."""
|
|
50
|
+
try:
|
|
51
|
+
dm = datamap.load(path)
|
|
52
|
+
except datamap.DataMapError as exc:
|
|
53
|
+
for problem in exc.problems:
|
|
54
|
+
typer.echo(f"{path}: {problem}", err=True)
|
|
55
|
+
typer.echo(f"{path}: {len(exc.problems)} problem(s)", err=True)
|
|
56
|
+
raise typer.Exit(1) from exc
|
|
57
|
+
typer.echo(f"{path}: ok ({len(dm.tables)} tables, {len(dm.connectors)} connector(s))")
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@datamap_app.command("schema")
|
|
61
|
+
def schema() -> None:
|
|
62
|
+
"""Print the data map JSON Schema."""
|
|
63
|
+
typer.echo(json.dumps(datamap.json_schema(), indent=2))
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class Threshold(StrEnum):
|
|
67
|
+
LOW = "low"
|
|
68
|
+
MEDIUM = "medium"
|
|
69
|
+
HIGH = "high"
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
SampleRows = Annotated[
|
|
73
|
+
int, typer.Option(min=0, help="Rows sampled per table. 0 classifies by name and type only.")
|
|
74
|
+
]
|
|
75
|
+
AsJson = Annotated[bool, typer.Option("--json", help="Print JSON instead of text.")]
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@app.command("scan")
|
|
79
|
+
def scan(
|
|
80
|
+
dsn_env: Annotated[
|
|
81
|
+
str, typer.Option(help="Environment variable holding the Postgres connection string.")
|
|
82
|
+
] = "DATABASE_URL",
|
|
83
|
+
connector: Annotated[
|
|
84
|
+
str, typer.Option(help="Connector name to prefix refs with, as in your data map.")
|
|
85
|
+
] = "db",
|
|
86
|
+
schemas: Annotated[
|
|
87
|
+
list[str] | None, typer.Option("--schema", help="Only scan this schema. Repeatable.")
|
|
88
|
+
] = None,
|
|
89
|
+
sample_rows: SampleRows = 200,
|
|
90
|
+
show_all: Annotated[
|
|
91
|
+
bool, typer.Option("--all", help="Also list columns with no finding.")
|
|
92
|
+
] = False,
|
|
93
|
+
as_json: AsJson = False,
|
|
94
|
+
) -> None:
|
|
95
|
+
"""Find columns that look like personal data in a live Postgres database.
|
|
96
|
+
|
|
97
|
+
Reads the schema and samples values in a read-only transaction. Values are never
|
|
98
|
+
printed: the report shows categories, confidences and counts only.
|
|
99
|
+
"""
|
|
100
|
+
reports = _scan(dsn_env, schemas, sample_rows)
|
|
101
|
+
shown = [r for r in reports if show_all or r.finding is not None]
|
|
102
|
+
if as_json:
|
|
103
|
+
typer.echo(json.dumps([_report_json(r, connector) for r in shown], indent=2))
|
|
104
|
+
return
|
|
105
|
+
rows = [
|
|
106
|
+
(
|
|
107
|
+
r.column.ref(connector),
|
|
108
|
+
str(r.finding.category) if r.finding else "-",
|
|
109
|
+
str(r.finding.confidence) if r.finding else "-",
|
|
110
|
+
"; ".join(r.finding.reasons) if r.finding else "",
|
|
111
|
+
)
|
|
112
|
+
for r in shown
|
|
113
|
+
]
|
|
114
|
+
_echo_table(("COLUMN", "CATEGORY", "CONFIDENCE", "WHY"), rows)
|
|
115
|
+
found = [r.finding for r in reports if r.finding is not None]
|
|
116
|
+
by_level = ", ".join(
|
|
117
|
+
f"{sum(1 for f in found if f.confidence is level)} {level}"
|
|
118
|
+
for level in reversed(Confidence)
|
|
119
|
+
)
|
|
120
|
+
typer.echo(
|
|
121
|
+
f"\n{len(reports)} columns scanned, {len(found)} look like personal data ({by_level})"
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
@app.command("lint")
|
|
126
|
+
def lint_command(
|
|
127
|
+
path: Annotated[Path, typer.Argument(exists=True, dir_okay=False, readable=True)],
|
|
128
|
+
connectors: Annotated[
|
|
129
|
+
list[str] | None, typer.Option("--connector", help="Only lint this connector. Repeatable.")
|
|
130
|
+
] = None,
|
|
131
|
+
fail_on: Annotated[
|
|
132
|
+
Threshold, typer.Option(help="Lowest confidence that fails the lint when unmapped.")
|
|
133
|
+
] = Threshold.MEDIUM,
|
|
134
|
+
sample_rows: SampleRows = 200,
|
|
135
|
+
as_json: AsJson = False,
|
|
136
|
+
) -> None:
|
|
137
|
+
"""Fail when the database has personal data the data map does not cover, or the map
|
|
138
|
+
names tables and columns the database no longer has."""
|
|
139
|
+
try:
|
|
140
|
+
dm = datamap.load(path)
|
|
141
|
+
except datamap.DataMapError as exc:
|
|
142
|
+
for problem in exc.problems:
|
|
143
|
+
typer.echo(f"{path}: {problem}", err=True)
|
|
144
|
+
raise typer.Exit(1) from exc
|
|
145
|
+
|
|
146
|
+
declared = {c.name: c for c in dm.connectors}
|
|
147
|
+
for name in connectors or ():
|
|
148
|
+
if name not in declared:
|
|
149
|
+
typer.echo(f"{path}: no connector named {name!r}", err=True)
|
|
150
|
+
raise typer.Exit(2)
|
|
151
|
+
selected = [declared[name] for name in connectors] if connectors else list(dm.connectors)
|
|
152
|
+
|
|
153
|
+
threshold = Confidence[fail_on.name]
|
|
154
|
+
results: dict[str, LintResult] = {}
|
|
155
|
+
for connector in selected:
|
|
156
|
+
reports = _scan(connector.dsn_env, None, sample_rows)
|
|
157
|
+
results[connector.name] = lint(dm, connector.name, reports, fail_on=threshold)
|
|
158
|
+
|
|
159
|
+
failed = any(result.errors for result in results.values())
|
|
160
|
+
if as_json:
|
|
161
|
+
out: dict[str, Any] = {
|
|
162
|
+
"ok": not failed,
|
|
163
|
+
"connectors": {
|
|
164
|
+
name: {
|
|
165
|
+
"issues": [
|
|
166
|
+
{"severity": str(i.severity), "ref": i.ref, "message": i.message}
|
|
167
|
+
for i in result.issues
|
|
168
|
+
],
|
|
169
|
+
"below_threshold": result.below_threshold,
|
|
170
|
+
}
|
|
171
|
+
for name, result in results.items()
|
|
172
|
+
},
|
|
173
|
+
}
|
|
174
|
+
typer.echo(json.dumps(out, indent=2))
|
|
175
|
+
else:
|
|
176
|
+
for name, result in results.items():
|
|
177
|
+
for issue in result.issues:
|
|
178
|
+
typer.echo(f"{path}: {issue}", err=True)
|
|
179
|
+
warnings = len(result.issues) - len(result.errors)
|
|
180
|
+
summary = f"{path}: {name}: {len(result.errors)} error(s), {warnings} warning(s)"
|
|
181
|
+
if result.below_threshold:
|
|
182
|
+
summary += (
|
|
183
|
+
f"; {result.below_threshold} finding(s) below {fail_on} confidence"
|
|
184
|
+
" not checked (see mita scan)"
|
|
185
|
+
)
|
|
186
|
+
typer.echo(summary)
|
|
187
|
+
if failed:
|
|
188
|
+
raise typer.Exit(1)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _scan(dsn_env: str, schemas: Sequence[str] | None, sample_rows: int) -> list[ColumnReport]:
|
|
192
|
+
dsn = os.environ.get(dsn_env)
|
|
193
|
+
if not dsn:
|
|
194
|
+
typer.echo(f"environment variable {dsn_env} is not set", err=True)
|
|
195
|
+
raise typer.Exit(2)
|
|
196
|
+
try:
|
|
197
|
+
with postgres.connect(dsn) as conn:
|
|
198
|
+
return postgres.scan(conn, schemas=schemas, sample_rows=sample_rows)
|
|
199
|
+
except psycopg.Error as exc:
|
|
200
|
+
typer.echo(f"cannot scan the database in ${dsn_env}: {exc}", err=True)
|
|
201
|
+
raise typer.Exit(2) from exc
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _report_json(report: ColumnReport, connector: str) -> dict[str, Any]:
|
|
205
|
+
finding = report.finding
|
|
206
|
+
return {
|
|
207
|
+
"ref": report.column.ref(connector),
|
|
208
|
+
"data_type": report.column.data_type,
|
|
209
|
+
"sampled": report.sampled,
|
|
210
|
+
"category": str(finding.category) if finding else None,
|
|
211
|
+
"confidence": str(finding.confidence) if finding else None,
|
|
212
|
+
"reasons": list(finding.reasons) if finding else [],
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _echo_table(header: tuple[str, ...], rows: Sequence[tuple[str, ...]]) -> None:
|
|
217
|
+
widths = [max(len(row[i]) for row in (header, *rows)) for i in range(len(header) - 1)]
|
|
218
|
+
for row in (header, *rows):
|
|
219
|
+
cells = [cell.ljust(width) for cell, width in zip(row, widths, strict=False)]
|
|
220
|
+
typer.echo(" ".join([*cells, row[-1]]).rstrip())
|
|
File without changes
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""Read a live Postgres schema and sample column values for detection.
|
|
2
|
+
|
|
3
|
+
`connect` opens a read-only session with a statement timeout, so a scan can be pointed at a
|
|
4
|
+
production replica without risk of writes or runaway queries. Identifiers only ever reach
|
|
5
|
+
the database through `psycopg.sql.Identifier`. Sampled values go straight to detection and
|
|
6
|
+
are dropped; they are never returned.
|
|
7
|
+
|
|
8
|
+
Sampling reads the first rows the table returns (no ORDER BY), which is cheap on large
|
|
9
|
+
tables but not random. Columns that sampling misses are still classified by name.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from collections.abc import Sequence
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
import psycopg
|
|
18
|
+
from psycopg import sql
|
|
19
|
+
|
|
20
|
+
from mita.core import detect
|
|
21
|
+
from mita.core.catalog import ColumnInfo
|
|
22
|
+
|
|
23
|
+
Connection = psycopg.Connection[tuple[Any, ...]]
|
|
24
|
+
|
|
25
|
+
SAMPLED_TYPES = detect.TEXT_TYPES | {"bigint", "numeric"}
|
|
26
|
+
"""Types whose values can hold an identifier detection recognises (phones fit in bigint)."""
|
|
27
|
+
MAX_VALUE_CHARS = 2000
|
|
28
|
+
"""Longer values are truncated in the query, so one huge document cannot exhaust memory."""
|
|
29
|
+
|
|
30
|
+
_COLUMNS_SQL = r"""
|
|
31
|
+
SELECT c.table_schema, c.table_name, c.column_name,
|
|
32
|
+
CASE WHEN c.data_type = 'USER-DEFINED' THEN c.udt_name ELSE c.data_type END
|
|
33
|
+
FROM information_schema.columns c
|
|
34
|
+
JOIN information_schema.tables t
|
|
35
|
+
ON t.table_schema = c.table_schema AND t.table_name = c.table_name
|
|
36
|
+
WHERE t.table_type = 'BASE TABLE'
|
|
37
|
+
AND c.table_schema NOT IN ('pg_catalog', 'information_schema')
|
|
38
|
+
AND c.table_schema NOT LIKE 'pg\_%%'
|
|
39
|
+
AND (%(schemas)s::text[] IS NULL OR c.table_schema = ANY (%(schemas)s::text[]))
|
|
40
|
+
ORDER BY c.table_schema, c.table_name, c.ordinal_position
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def connect(dsn: str, *, statement_timeout_ms: int = 30_000) -> Connection:
|
|
45
|
+
conn = psycopg.connect(
|
|
46
|
+
dsn,
|
|
47
|
+
application_name="mita",
|
|
48
|
+
options=f"-c statement_timeout={int(statement_timeout_ms)}",
|
|
49
|
+
)
|
|
50
|
+
conn.read_only = True
|
|
51
|
+
return conn
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def columns(conn: Connection, schemas: Sequence[str] | None = None) -> list[ColumnInfo]:
|
|
55
|
+
"""Every column of every ordinary table, outside the system schemas."""
|
|
56
|
+
rows = conn.execute(_COLUMNS_SQL, {"schemas": list(schemas) if schemas else None})
|
|
57
|
+
return [ColumnInfo(str(s), str(t), str(c), str(d)) for s, t, c, d in rows.fetchall()]
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def scan(
|
|
61
|
+
conn: Connection, *, schemas: Sequence[str] | None = None, sample_rows: int = 200
|
|
62
|
+
) -> list[detect.ColumnReport]:
|
|
63
|
+
"""Classify every column. `sample_rows=0` classifies by name and type only."""
|
|
64
|
+
by_table: dict[tuple[str, str], list[ColumnInfo]] = {}
|
|
65
|
+
for column in columns(conn, schemas):
|
|
66
|
+
by_table.setdefault((column.schema, column.table), []).append(column)
|
|
67
|
+
|
|
68
|
+
reports: list[detect.ColumnReport] = []
|
|
69
|
+
for (schema, table), table_columns in by_table.items():
|
|
70
|
+
names = [c.name for c in table_columns if c.data_type in SAMPLED_TYPES]
|
|
71
|
+
samples = _sample(conn, schema, table, names, sample_rows) if names and sample_rows else {}
|
|
72
|
+
for column in table_columns:
|
|
73
|
+
values = samples.get(column.name, [])
|
|
74
|
+
reports.append(
|
|
75
|
+
detect.ColumnReport(column, len(values), detect.classify(column, values))
|
|
76
|
+
)
|
|
77
|
+
return reports
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _sample(
|
|
81
|
+
conn: Connection, schema: str, table: str, names: Sequence[str], limit: int
|
|
82
|
+
) -> dict[str, list[str]]:
|
|
83
|
+
query = sql.SQL("SELECT {} FROM {} LIMIT {}").format(
|
|
84
|
+
sql.SQL(", ").join(
|
|
85
|
+
sql.SQL("left({}::text, {})").format(sql.Identifier(name), sql.Literal(MAX_VALUE_CHARS))
|
|
86
|
+
for name in names
|
|
87
|
+
),
|
|
88
|
+
sql.Identifier(schema, table),
|
|
89
|
+
sql.Literal(limit),
|
|
90
|
+
)
|
|
91
|
+
out: dict[str, list[str]] = {name: [] for name in names}
|
|
92
|
+
try:
|
|
93
|
+
# A savepoint, so one unreadable table does not abort the whole scan.
|
|
94
|
+
with conn.transaction():
|
|
95
|
+
for row in conn.execute(query):
|
|
96
|
+
for name, value in zip(names, row, strict=True):
|
|
97
|
+
if value is not None:
|
|
98
|
+
out[name].append(value)
|
|
99
|
+
except psycopg.errors.InsufficientPrivilege:
|
|
100
|
+
return {}
|
|
101
|
+
return out
|
mita/core/__init__.py
ADDED
|
File without changes
|
mita/core/catalog.py
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""What a connector's live schema looks like, independent of how it was read."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass(frozen=True)
|
|
9
|
+
class ColumnInfo:
|
|
10
|
+
"""One column of one table, as the database reports it."""
|
|
11
|
+
|
|
12
|
+
schema: str
|
|
13
|
+
table: str
|
|
14
|
+
name: str
|
|
15
|
+
data_type: str
|
|
16
|
+
|
|
17
|
+
def table_ref(self, connector: str) -> str:
|
|
18
|
+
return f"{connector}.{self.schema}.{self.table}"
|
|
19
|
+
|
|
20
|
+
def ref(self, connector: str) -> str:
|
|
21
|
+
return f"{connector}.{self.schema}.{self.table}.{self.name}"
|
mita/core/datamap.py
ADDED
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
"""The data map: where a person's data lives and what erasure does to it.
|
|
2
|
+
|
|
3
|
+
The map is a YAML file kept in the user's repository and reviewed in pull requests.
|
|
4
|
+
Loading happens in two stages:
|
|
5
|
+
|
|
6
|
+
1. Structure (pydantic): shapes, enums and required fields; unknown keys are rejected.
|
|
7
|
+
2. Semantics (`check`): cross-references a JSON Schema cannot express. Every problem is
|
|
8
|
+
reported at once, so a user can fix their map in a single pass.
|
|
9
|
+
|
|
10
|
+
Names are addressed as `connector.schema.table` and `connector.schema.table.column`.
|
|
11
|
+
Schema, table and column names may hold any character except `.` and whitespace; they
|
|
12
|
+
are only ever sent to a database through `psycopg.sql.Identifier`.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from dataclasses import dataclass
|
|
18
|
+
from enum import StrEnum
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import Annotated, Any, Literal
|
|
21
|
+
|
|
22
|
+
import yaml
|
|
23
|
+
from pydantic import BaseModel, ConfigDict, Field, StringConstraints, ValidationError
|
|
24
|
+
|
|
25
|
+
_PART = r"[^.\s]+"
|
|
26
|
+
NAME_PATTERN = r"^[a-z][a-z0-9_]{0,62}$"
|
|
27
|
+
TABLE_REF_PATTERN = rf"^[a-z][a-z0-9_]{{0,62}}\.{_PART}\.{_PART}$"
|
|
28
|
+
COLUMN_REF_PATTERN = rf"^[a-z][a-z0-9_]{{0,62}}\.{_PART}\.{_PART}\.{_PART}$"
|
|
29
|
+
|
|
30
|
+
Name = Annotated[str, StringConstraints(pattern=NAME_PATTERN)]
|
|
31
|
+
TableRef = Annotated[str, StringConstraints(pattern=TABLE_REF_PATTERN)]
|
|
32
|
+
ColumnRef = Annotated[str, StringConstraints(pattern=COLUMN_REF_PATTERN)]
|
|
33
|
+
NonEmpty = Annotated[str, StringConstraints(strip_whitespace=True, min_length=1)]
|
|
34
|
+
# ISO 8601 date period with at least one part (P6Y, P18M, P1Y6M, P30D). No lookahead:
|
|
35
|
+
# pydantic's regex engine does not support it.
|
|
36
|
+
IsoPeriod = Annotated[str, StringConstraints(pattern=r"^P(\d+Y(\d+M)?(\d+D)?|\d+M(\d+D)?|\d+D)$")]
|
|
37
|
+
EnvVar = Annotated[str, StringConstraints(pattern=r"^[A-Z][A-Z0-9_]*$")]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class PiiCategory(StrEnum):
|
|
41
|
+
"""What kind of personal data a column holds. `none` marks a reviewed, non-PII column."""
|
|
42
|
+
|
|
43
|
+
EMAIL = "email"
|
|
44
|
+
PHONE = "phone"
|
|
45
|
+
NAME = "name"
|
|
46
|
+
ADDRESS = "address"
|
|
47
|
+
PIN_CODE = "pin_code"
|
|
48
|
+
PAN = "pan"
|
|
49
|
+
AADHAAR = "aadhaar"
|
|
50
|
+
DOB = "dob"
|
|
51
|
+
IP_ADDRESS = "ip_address"
|
|
52
|
+
GSTIN = "gstin"
|
|
53
|
+
UPI_ID = "upi_id"
|
|
54
|
+
PAYMENT_REF = "payment_ref"
|
|
55
|
+
FREE_TEXT = "free_text"
|
|
56
|
+
OTHER = "other"
|
|
57
|
+
NONE = "none"
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class Normalizer(StrEnum):
|
|
61
|
+
"""How an identifier is normalised before it is matched or hashed."""
|
|
62
|
+
|
|
63
|
+
EXACT = "exact"
|
|
64
|
+
EMAIL = "email"
|
|
65
|
+
IN_MOBILE = "in_mobile"
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class TableErase(StrEnum):
|
|
69
|
+
"""What erasure does to a matching row."""
|
|
70
|
+
|
|
71
|
+
DELETE_ROW = "delete_row"
|
|
72
|
+
UPDATE_COLUMNS = "update_columns"
|
|
73
|
+
RETAIN = "retain"
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class ColumnStrategy(StrEnum):
|
|
77
|
+
"""What erasure does to one column of a row that is kept (`update_columns`)."""
|
|
78
|
+
|
|
79
|
+
NULLIFY = "nullify"
|
|
80
|
+
PSEUDONYMIZE = "pseudonymize"
|
|
81
|
+
RETAIN = "retain"
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class _Model(BaseModel):
|
|
85
|
+
model_config = ConfigDict(extra="forbid", frozen=True, populate_by_name=True)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class Retention(_Model):
|
|
89
|
+
"""Why data is kept despite an erasure request, and for how long."""
|
|
90
|
+
|
|
91
|
+
reason: NonEmpty = Field(description="Plain-language reason. Shown in evidence records.")
|
|
92
|
+
period: IsoPeriod = Field(description="ISO 8601 period, e.g. P6Y or P18M.")
|
|
93
|
+
from_column: str | None = Field(
|
|
94
|
+
default=None, description="Timestamp column the period counts from."
|
|
95
|
+
)
|
|
96
|
+
rule_ref: str | None = Field(
|
|
97
|
+
default=None, description="The law or policy clause this relies on."
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class Column(_Model):
|
|
102
|
+
pii: PiiCategory
|
|
103
|
+
strategy: ColumnStrategy | None = Field(
|
|
104
|
+
default=None, description="Required for PII columns in `update_columns` tables."
|
|
105
|
+
)
|
|
106
|
+
retain: Retention | None = Field(default=None, description="Required when strategy is retain.")
|
|
107
|
+
note: str | None = None
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
class Match(_Model):
|
|
111
|
+
"""A column that identifies the principal directly, without a foreign-key path."""
|
|
112
|
+
|
|
113
|
+
key: Name = Field(description="A key declared under `principal.keys`.")
|
|
114
|
+
column: str
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
class Table(_Model):
|
|
118
|
+
erase: TableErase
|
|
119
|
+
retain: Retention | None = Field(
|
|
120
|
+
default=None, description="Required when erase is retain; the whole row is kept."
|
|
121
|
+
)
|
|
122
|
+
match: tuple[Match, ...] = ()
|
|
123
|
+
columns: dict[str, Column] = Field(default_factory=dict)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
class PrincipalKey(_Model):
|
|
127
|
+
column: str = Field(description="Column of the root table holding this identifier.")
|
|
128
|
+
normalize: Normalizer = Normalizer.EXACT
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
class Principal(_Model):
|
|
132
|
+
root: TableRef = Field(description="The table whose row is the person.")
|
|
133
|
+
keys: dict[Name, PrincipalKey] = Field(min_length=1)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
class Connector(_Model):
|
|
137
|
+
name: Name
|
|
138
|
+
kind: Literal["postgres"]
|
|
139
|
+
dsn_env: EnvVar = Field(description="Environment variable holding the connection string.")
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
class Relation(_Model):
|
|
143
|
+
"""A link the database does not enforce as a foreign key."""
|
|
144
|
+
|
|
145
|
+
from_: ColumnRef = Field(alias="from")
|
|
146
|
+
to: ColumnRef
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
class DataMap(_Model):
|
|
150
|
+
version: Literal[0]
|
|
151
|
+
connectors: tuple[Connector, ...] = Field(min_length=1)
|
|
152
|
+
principal: Principal
|
|
153
|
+
tables: dict[TableRef, Table] = Field(min_length=1)
|
|
154
|
+
relations: tuple[Relation, ...] = ()
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
@dataclass(frozen=True)
|
|
158
|
+
class Problem:
|
|
159
|
+
path: str
|
|
160
|
+
message: str
|
|
161
|
+
|
|
162
|
+
def __str__(self) -> str:
|
|
163
|
+
return f"{self.path}: {self.message}"
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
class DataMapError(Exception):
|
|
167
|
+
def __init__(self, problems: list[Problem]) -> None:
|
|
168
|
+
self.problems = problems
|
|
169
|
+
super().__init__(f"{len(problems)} problem(s) in data map")
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def load(path: Path) -> DataMap:
|
|
173
|
+
"""Read, parse and validate a data map. Raises DataMapError listing every problem."""
|
|
174
|
+
try:
|
|
175
|
+
raw = yaml.load(path.read_text(encoding="utf-8"), Loader=_StrictLoader) # noqa: S506
|
|
176
|
+
except yaml.YAMLError as exc:
|
|
177
|
+
raise DataMapError([Problem("$", f"invalid YAML: {exc}")]) from exc
|
|
178
|
+
return from_obj(raw)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def from_obj(raw: object) -> DataMap:
|
|
182
|
+
"""Validate an already-parsed object. Raises DataMapError listing every problem."""
|
|
183
|
+
try:
|
|
184
|
+
datamap = DataMap.model_validate(raw)
|
|
185
|
+
except ValidationError as exc:
|
|
186
|
+
raise DataMapError(
|
|
187
|
+
[Problem(_format_loc(err["loc"]), err["msg"]) for err in exc.errors()]
|
|
188
|
+
) from exc
|
|
189
|
+
problems = check(datamap)
|
|
190
|
+
if problems:
|
|
191
|
+
raise DataMapError(problems)
|
|
192
|
+
return datamap
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def check(dm: DataMap) -> list[Problem]:
|
|
196
|
+
"""Cross-reference checks. Returns every problem found, in document order."""
|
|
197
|
+
problems: list[Problem] = []
|
|
198
|
+
|
|
199
|
+
def add(path: str, message: str) -> None:
|
|
200
|
+
problems.append(Problem(path, message))
|
|
201
|
+
|
|
202
|
+
connector_names: set[str] = set()
|
|
203
|
+
for i, connector in enumerate(dm.connectors):
|
|
204
|
+
if connector.name in connector_names:
|
|
205
|
+
add(f"connectors[{i}].name", f"duplicate connector {connector.name!r}")
|
|
206
|
+
connector_names.add(connector.name)
|
|
207
|
+
|
|
208
|
+
root = dm.tables.get(dm.principal.root)
|
|
209
|
+
if root is None:
|
|
210
|
+
add("principal.root", f"table {dm.principal.root!r} is not declared under tables")
|
|
211
|
+
else:
|
|
212
|
+
if root.erase is TableErase.RETAIN:
|
|
213
|
+
add("principal.root", "the root table cannot use erase: retain")
|
|
214
|
+
for key, spec in dm.principal.keys.items():
|
|
215
|
+
if spec.column not in root.columns:
|
|
216
|
+
add(
|
|
217
|
+
f"principal.keys.{key}.column",
|
|
218
|
+
f"column {spec.column!r} is not listed under the root table's columns",
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
for ref, table in dm.tables.items():
|
|
222
|
+
base = f"tables[{ref!r}]"
|
|
223
|
+
connector_name = ref.split(".", 1)[0]
|
|
224
|
+
if connector_name not in connector_names:
|
|
225
|
+
add(base, f"connector {connector_name!r} is not declared")
|
|
226
|
+
problems.extend(_check_table(base, table))
|
|
227
|
+
for i, match in enumerate(table.match):
|
|
228
|
+
if match.key not in dm.principal.keys:
|
|
229
|
+
add(
|
|
230
|
+
f"{base}.match[{i}].key", f"key {match.key!r} is not declared in principal.keys"
|
|
231
|
+
)
|
|
232
|
+
if match.column not in table.columns:
|
|
233
|
+
add(
|
|
234
|
+
f"{base}.match[{i}].column",
|
|
235
|
+
f"column {match.column!r} is not listed under this table's columns",
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
for i, relation in enumerate(dm.relations):
|
|
239
|
+
if relation.from_ == relation.to:
|
|
240
|
+
add(f"relations[{i}]", "a relation cannot point at itself")
|
|
241
|
+
for side, column_ref in (("from", relation.from_), ("to", relation.to)):
|
|
242
|
+
table_ref = column_ref.rsplit(".", 1)[0]
|
|
243
|
+
if table_ref not in dm.tables:
|
|
244
|
+
add(f"relations[{i}].{side}", f"table {table_ref!r} is not declared under tables")
|
|
245
|
+
|
|
246
|
+
return problems
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def _check_table(base: str, table: Table) -> list[Problem]:
|
|
250
|
+
problems: list[Problem] = []
|
|
251
|
+
|
|
252
|
+
if table.erase is TableErase.RETAIN and table.retain is None:
|
|
253
|
+
problems.append(Problem(base, "erase: retain needs a retain block with a reason"))
|
|
254
|
+
if table.erase is not TableErase.RETAIN and table.retain is not None:
|
|
255
|
+
problems.append(Problem(f"{base}.retain", "a table retain block needs erase: retain"))
|
|
256
|
+
|
|
257
|
+
has_strategy = False
|
|
258
|
+
for name, column in table.columns.items():
|
|
259
|
+
path = f"{base}.columns.{name}"
|
|
260
|
+
if column.strategy is not None:
|
|
261
|
+
has_strategy = True
|
|
262
|
+
if table.erase is not TableErase.UPDATE_COLUMNS:
|
|
263
|
+
problems.append(
|
|
264
|
+
Problem(f"{path}.strategy", "strategy only applies to update_columns tables")
|
|
265
|
+
)
|
|
266
|
+
elif table.erase is TableErase.UPDATE_COLUMNS and column.pii is not PiiCategory.NONE:
|
|
267
|
+
problems.append(Problem(path, f"{column.pii} column needs a strategy"))
|
|
268
|
+
|
|
269
|
+
if column.strategy is ColumnStrategy.RETAIN and column.retain is None:
|
|
270
|
+
problems.append(Problem(path, "strategy: retain needs a retain block with a reason"))
|
|
271
|
+
if column.strategy is not ColumnStrategy.RETAIN and column.retain is not None:
|
|
272
|
+
problems.append(Problem(f"{path}.retain", "a retain block needs strategy: retain"))
|
|
273
|
+
|
|
274
|
+
if table.erase is TableErase.UPDATE_COLUMNS and not has_strategy:
|
|
275
|
+
problems.append(Problem(base, "update_columns table has no column with a strategy"))
|
|
276
|
+
|
|
277
|
+
return problems
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def json_schema() -> dict[str, Any]:
|
|
281
|
+
"""The JSON Schema for data map files, as committed in schemas/datamap.schema.json."""
|
|
282
|
+
schema = DataMap.model_json_schema(by_alias=True)
|
|
283
|
+
return {
|
|
284
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
285
|
+
"title": "mita data map",
|
|
286
|
+
**{k: v for k, v in schema.items() if k != "title"},
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def _format_loc(loc: tuple[int | str, ...]) -> str:
|
|
291
|
+
out = ""
|
|
292
|
+
for part in loc:
|
|
293
|
+
if isinstance(part, int):
|
|
294
|
+
out += f"[{part}]"
|
|
295
|
+
elif "." in part:
|
|
296
|
+
out += f"[{part!r}]"
|
|
297
|
+
else:
|
|
298
|
+
out += f".{part}" if out else part
|
|
299
|
+
return out or "$"
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
class _StrictLoader(yaml.SafeLoader):
|
|
303
|
+
"""SafeLoader that rejects duplicate keys, which PyYAML otherwise silently overwrites.
|
|
304
|
+
|
|
305
|
+
A duplicated table key would make a whole table vanish from the map without warning.
|
|
306
|
+
"""
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def _construct_mapping(loader: yaml.SafeLoader, node: yaml.MappingNode) -> dict[Any, Any]:
|
|
310
|
+
seen: set[object] = set()
|
|
311
|
+
for key_node, _ in node.value:
|
|
312
|
+
key = loader.construct_object(key_node)
|
|
313
|
+
if key in seen:
|
|
314
|
+
raise yaml.constructor.ConstructorError(
|
|
315
|
+
None, None, f"duplicate key {key!r}", key_node.start_mark
|
|
316
|
+
)
|
|
317
|
+
seen.add(key)
|
|
318
|
+
return loader.construct_mapping(node)
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
_StrictLoader.add_constructor(yaml.resolver.BaseResolver.DEFAULT_MAPPING_TAG, _construct_mapping)
|