dpdpkit-fastapi 0.1.0a1__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.
- dpdpkit/fastapi/__init__.py +22 -0
- dpdpkit/fastapi/admin_static/index.html +23 -0
- dpdpkit/fastapi/app.py +286 -0
- dpdpkit/fastapi/cli.py +39 -0
- dpdpkit/fastapi/db.py +152 -0
- dpdpkit/fastapi/http.py +84 -0
- dpdpkit/fastapi/migrations/__init__.py +34 -0
- dpdpkit/fastapi/migrations/env.py +44 -0
- dpdpkit/fastapi/migrations/script.py.mako +26 -0
- dpdpkit/fastapi/migrations/versions/0001_initial_schema.py +158 -0
- dpdpkit/fastapi/py.typed +0 -0
- dpdpkit/fastapi/repository.py +424 -0
- dpdpkit/fastapi/router.py +250 -0
- dpdpkit_fastapi-0.1.0a1.dist-info/METADATA +122 -0
- dpdpkit_fastapi-0.1.0a1.dist-info/RECORD +18 -0
- dpdpkit_fastapi-0.1.0a1.dist-info/WHEEL +4 -0
- dpdpkit_fastapi-0.1.0a1.dist-info/entry_points.txt +2 -0
- dpdpkit_fastapi-0.1.0a1.dist-info/licenses/LICENSE +202 -0
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""FastAPI adapter for dpdpkit: router, SQLAlchemy repository, migrations, scheduler, admin mount.
|
|
2
|
+
|
|
3
|
+
dpdpkit helps you implement DPDP obligations; it does not provide legal advice or guarantee compliance.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from .app import ActivityMiddleware, DPDPKit, require_consent
|
|
9
|
+
from .http import ConsentRequiredHTTP
|
|
10
|
+
from .repository import SqlAlchemyRepository, make_engine
|
|
11
|
+
|
|
12
|
+
__version__ = "0.1.0a1"
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
"ActivityMiddleware",
|
|
16
|
+
"ConsentRequiredHTTP",
|
|
17
|
+
"DPDPKit",
|
|
18
|
+
"SqlAlchemyRepository",
|
|
19
|
+
"__version__",
|
|
20
|
+
"make_engine",
|
|
21
|
+
"require_consent",
|
|
22
|
+
]
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<title>dpdpkit admin</title>
|
|
7
|
+
<style>
|
|
8
|
+
body { font: 16px/1.5 system-ui, sans-serif; max-width: 42rem; margin: 3rem auto; padding: 0 1rem; color: #1f2328; }
|
|
9
|
+
code { background: #f3f4f6; padding: .1rem .3rem; border-radius: 4px; }
|
|
10
|
+
</style>
|
|
11
|
+
</head>
|
|
12
|
+
<body>
|
|
13
|
+
<h1>dpdpkit admin</h1>
|
|
14
|
+
<p>The admin dashboard ships in v0.4. Until then, use the admin API under this mount's parent path:</p>
|
|
15
|
+
<ul>
|
|
16
|
+
<li><code>GET ../requests</code> — request queue</li>
|
|
17
|
+
<li><code>GET ../retention/preview</code> — upcoming warnings and erasures</li>
|
|
18
|
+
<li><code>POST ../ledger/verify</code> — hash-chain check</li>
|
|
19
|
+
<li><code>GET ../audit/export</code> — evidence pack</li>
|
|
20
|
+
</ul>
|
|
21
|
+
<p>dpdpkit helps you implement DPDP obligations; it does not provide legal advice or guarantee compliance.</p>
|
|
22
|
+
</body>
|
|
23
|
+
</html>
|
dpdpkit/fastapi/app.py
ADDED
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
"""``DPDPKit`` — the object a FastAPI developer creates."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import inspect
|
|
6
|
+
import logging
|
|
7
|
+
import time
|
|
8
|
+
from collections.abc import Awaitable, Callable, Iterable, Mapping
|
|
9
|
+
from importlib import resources
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from fastapi import FastAPI, Request
|
|
14
|
+
from fastapi.staticfiles import StaticFiles
|
|
15
|
+
from starlette.concurrency import run_in_threadpool
|
|
16
|
+
from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint
|
|
17
|
+
from starlette.responses import Response
|
|
18
|
+
|
|
19
|
+
from dpdpkit import Kit, Registry
|
|
20
|
+
from dpdpkit.config import kit_from_config, load_config, sync_notice
|
|
21
|
+
from dpdpkit.errors import ConfigError, DpdpkitError
|
|
22
|
+
from dpdpkit.kit import ContactResolver
|
|
23
|
+
from dpdpkit.models import Role
|
|
24
|
+
from dpdpkit.notify import Notifier
|
|
25
|
+
from dpdpkit.policy import Policy
|
|
26
|
+
from dpdpkit.repository import Repository
|
|
27
|
+
from dpdpkit.rights import IdentityVerifier
|
|
28
|
+
|
|
29
|
+
from .http import ConsentRequiredHTTP, Forbidden, Unauthenticated, consent_required_handler, render_dpdpkit_error
|
|
30
|
+
from .repository import SqlAlchemyRepository
|
|
31
|
+
from .router import build_router
|
|
32
|
+
|
|
33
|
+
log = logging.getLogger("dpdpkit.fastapi")
|
|
34
|
+
|
|
35
|
+
PrincipalResolver = Callable[[Request], "str | Awaitable[str | None] | None"]
|
|
36
|
+
AdminGuard = Callable[[Request], "Role | str | bool | Awaitable[Role | str | bool | None] | None"]
|
|
37
|
+
|
|
38
|
+
_default: DPDPKit | None = None
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
async def _maybe_await(value: Any) -> Any:
|
|
42
|
+
return await value if inspect.isawaitable(value) else value
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class DPDPKit:
|
|
46
|
+
"""Wire dpdpkit into a FastAPI app.
|
|
47
|
+
|
|
48
|
+
``principal_resolver(request)`` returns a stable, non-personal reference for the signed-in user
|
|
49
|
+
(for example the user's primary key), or ``None``. ``admin_guard(request)`` returns a role
|
|
50
|
+
(``viewer``/``handler``/``admin``), ``True`` for admin, or ``None``/``False`` for no access.
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
def __init__(
|
|
54
|
+
self,
|
|
55
|
+
*,
|
|
56
|
+
principal_resolver: PrincipalResolver,
|
|
57
|
+
admin_guard: AdminGuard | None = None,
|
|
58
|
+
db_url: str | None = None,
|
|
59
|
+
repository: Repository | None = None,
|
|
60
|
+
config: str | Path | Mapping[str, Any] | None = None,
|
|
61
|
+
policy: str | Policy | None = None,
|
|
62
|
+
overlays: Iterable[str] = (),
|
|
63
|
+
registry: Registry | None = None,
|
|
64
|
+
notifier: Notifier | None = None,
|
|
65
|
+
contact: Mapping[str, str] | None = None,
|
|
66
|
+
contact_resolver: ContactResolver | None = None,
|
|
67
|
+
identity_verifier: IdentityVerifier | None = None,
|
|
68
|
+
signing_key: bytes | str | None = None,
|
|
69
|
+
tenant_id: str = "default",
|
|
70
|
+
migrate_on_startup: bool = False,
|
|
71
|
+
create_tables: bool = False,
|
|
72
|
+
**kit_kwargs: Any,
|
|
73
|
+
) -> None:
|
|
74
|
+
if repository is None:
|
|
75
|
+
if not db_url:
|
|
76
|
+
raise ConfigError("pass db_url or repository")
|
|
77
|
+
repository = SqlAlchemyRepository(db_url)
|
|
78
|
+
self.repository = repository
|
|
79
|
+
if migrate_on_startup:
|
|
80
|
+
self.migrate()
|
|
81
|
+
elif create_tables and isinstance(repository, SqlAlchemyRepository):
|
|
82
|
+
repository.create_all()
|
|
83
|
+
|
|
84
|
+
common: dict[str, Any] = {
|
|
85
|
+
"notifier": notifier,
|
|
86
|
+
"contact_resolver": contact_resolver,
|
|
87
|
+
"identity_verifier": identity_verifier,
|
|
88
|
+
"signing_key": signing_key,
|
|
89
|
+
"tenant_id": tenant_id,
|
|
90
|
+
**kit_kwargs,
|
|
91
|
+
}
|
|
92
|
+
self.config: dict[str, Any] | None = None
|
|
93
|
+
if config is not None:
|
|
94
|
+
self.config = load_config(config) if isinstance(config, (str, Path)) else dict(config)
|
|
95
|
+
self.kit = kit_from_config(self.config, repository, registry=registry, contact=contact, **common)
|
|
96
|
+
sync_notice(self.kit, self.config)
|
|
97
|
+
else:
|
|
98
|
+
if policy is None:
|
|
99
|
+
raise ConfigError("pass config= (a dpdpkit.yaml) or policy=")
|
|
100
|
+
self.kit = Kit(repository, policy, overlays=overlays, registry=registry, contact=contact, **common)
|
|
101
|
+
|
|
102
|
+
self.principal_resolver = principal_resolver
|
|
103
|
+
self.admin_guard = admin_guard
|
|
104
|
+
self.router = build_router(self)
|
|
105
|
+
self._scheduler: Any = None
|
|
106
|
+
|
|
107
|
+
global _default
|
|
108
|
+
_default = self
|
|
109
|
+
|
|
110
|
+
# ------------------------------------------------------------------ identity
|
|
111
|
+
|
|
112
|
+
async def resolve_principal(self, request: Request) -> str | None:
|
|
113
|
+
value = await _maybe_await(self.principal_resolver(request))
|
|
114
|
+
return str(value) if value else None
|
|
115
|
+
|
|
116
|
+
async def require_principal(self, request: Request) -> str:
|
|
117
|
+
principal = await self.resolve_principal(request)
|
|
118
|
+
if not principal:
|
|
119
|
+
raise Unauthenticated("sign in to use this endpoint")
|
|
120
|
+
return principal
|
|
121
|
+
|
|
122
|
+
async def resolve_role(self, request: Request) -> Role | None:
|
|
123
|
+
if self.admin_guard is None:
|
|
124
|
+
return None
|
|
125
|
+
value = await _maybe_await(self.admin_guard(request))
|
|
126
|
+
if value is True:
|
|
127
|
+
return Role.ADMIN
|
|
128
|
+
if not value:
|
|
129
|
+
return None
|
|
130
|
+
try:
|
|
131
|
+
return Role(value)
|
|
132
|
+
except ValueError:
|
|
133
|
+
log.warning("admin_guard returned unknown role %r; treating as no access", value)
|
|
134
|
+
return None
|
|
135
|
+
|
|
136
|
+
async def require_role(self, request: Request, needed: Role) -> str:
|
|
137
|
+
"""Check the caller's admin role; returns an actor label for the audit trail."""
|
|
138
|
+
role = await self.resolve_role(request)
|
|
139
|
+
if role is None:
|
|
140
|
+
principal = await self.resolve_principal(request)
|
|
141
|
+
if principal is None:
|
|
142
|
+
raise Unauthenticated("sign in to use this endpoint")
|
|
143
|
+
raise Forbidden("an admin role is required")
|
|
144
|
+
if not role.allows(needed):
|
|
145
|
+
raise Forbidden(f"the {needed.value} role is required", role=role.value)
|
|
146
|
+
principal = await self.resolve_principal(request)
|
|
147
|
+
return f"{role.value}:{principal}" if principal else role.value
|
|
148
|
+
|
|
149
|
+
@staticmethod
|
|
150
|
+
def request_metadata(request: Request) -> dict[str, Any]:
|
|
151
|
+
"""Evidence stored with consent events. No IP address or personal data by default."""
|
|
152
|
+
return {"channel": "web", "user_agent_present": "user-agent" in request.headers}
|
|
153
|
+
|
|
154
|
+
# ------------------------------------------------------------------ consent dependency
|
|
155
|
+
|
|
156
|
+
def require_consent(self, purpose: str) -> Callable[[Request], Awaitable[None]]:
|
|
157
|
+
async def dependency(request: Request) -> None:
|
|
158
|
+
principal = await self.require_principal(request)
|
|
159
|
+
allowed = await run_in_threadpool(self.kit.consent.check, principal, purpose)
|
|
160
|
+
if not allowed:
|
|
161
|
+
raise ConsentRequiredHTTP(purpose)
|
|
162
|
+
|
|
163
|
+
return dependency
|
|
164
|
+
|
|
165
|
+
# ------------------------------------------------------------------ app wiring
|
|
166
|
+
|
|
167
|
+
def install(
|
|
168
|
+
self,
|
|
169
|
+
app: FastAPI,
|
|
170
|
+
*,
|
|
171
|
+
prefix: str = "/dpdp",
|
|
172
|
+
admin_path: str | None = None,
|
|
173
|
+
track_activity: bool = True,
|
|
174
|
+
) -> None:
|
|
175
|
+
"""Include the router, error handlers, activity middleware and the admin dashboard."""
|
|
176
|
+
app.include_router(self.router, prefix=prefix)
|
|
177
|
+
self.install_error_handlers(app)
|
|
178
|
+
if track_activity:
|
|
179
|
+
app.add_middleware(ActivityMiddleware, dpdpkit=self)
|
|
180
|
+
self.mount_admin(app, path=admin_path or f"{prefix}/admin/ui")
|
|
181
|
+
|
|
182
|
+
def install_error_handlers(self, app: FastAPI) -> None:
|
|
183
|
+
app.state.dpdpkit = self
|
|
184
|
+
|
|
185
|
+
async def dpdpkit_error(_request: Request, exc: Exception) -> Response:
|
|
186
|
+
assert isinstance(exc, DpdpkitError)
|
|
187
|
+
return render_dpdpkit_error(exc)
|
|
188
|
+
|
|
189
|
+
app.add_exception_handler(DpdpkitError, dpdpkit_error)
|
|
190
|
+
app.add_exception_handler(ConsentRequiredHTTP, consent_required_handler)
|
|
191
|
+
|
|
192
|
+
def mount_admin(self, app: FastAPI, path: str = "/dpdp/admin/ui") -> None:
|
|
193
|
+
"""Serve the prebuilt admin dashboard bundle. Until it ships (v0.4) a placeholder page is served."""
|
|
194
|
+
if not any(getattr(r, "path", None) == path for r in app.routes):
|
|
195
|
+
directory = resources.files("dpdpkit.fastapi") / "admin_static"
|
|
196
|
+
app.mount(path, StaticFiles(directory=str(directory), html=True), name="dpdpkit-admin")
|
|
197
|
+
if not hasattr(app.state, "dpdpkit"):
|
|
198
|
+
self.install_error_handlers(app)
|
|
199
|
+
|
|
200
|
+
# ------------------------------------------------------------------ jobs and schema
|
|
201
|
+
|
|
202
|
+
def start_scheduler(self, *, retention_minutes: int = 15, verify_hour: int = 2) -> Any:
|
|
203
|
+
"""Run retention every ``retention_minutes`` and ledger verification daily (APScheduler)."""
|
|
204
|
+
try:
|
|
205
|
+
from apscheduler.schedulers.background import BackgroundScheduler # type: ignore[import-untyped]
|
|
206
|
+
except ImportError as exc: # pragma: no cover
|
|
207
|
+
raise ConfigError('install the scheduler extra: pip install "dpdpkit-fastapi[scheduler]"') from exc
|
|
208
|
+
scheduler = BackgroundScheduler(timezone="UTC")
|
|
209
|
+
scheduler.add_job(
|
|
210
|
+
self.kit.retention.run,
|
|
211
|
+
"interval",
|
|
212
|
+
minutes=retention_minutes,
|
|
213
|
+
id="dpdpkit-retention",
|
|
214
|
+
max_instances=1,
|
|
215
|
+
coalesce=True,
|
|
216
|
+
)
|
|
217
|
+
scheduler.add_job(
|
|
218
|
+
self.verify_ledger_job, "cron", hour=verify_hour, id="dpdpkit-ledger-verify", max_instances=1, coalesce=True
|
|
219
|
+
)
|
|
220
|
+
scheduler.start()
|
|
221
|
+
self._scheduler = scheduler
|
|
222
|
+
return scheduler
|
|
223
|
+
|
|
224
|
+
def verify_ledger_job(self) -> bool:
|
|
225
|
+
report = self.kit.ledger.verify_report()
|
|
226
|
+
if not report.ok:
|
|
227
|
+
log.error(
|
|
228
|
+
"dpdpkit ledger verification FAILED at row %s (%s): %s", report.failed_row, report.chain, report.reason
|
|
229
|
+
)
|
|
230
|
+
self.kit.events.publish("ledger.tampered", self.kit.tenant_id, report.model_dump(mode="json"))
|
|
231
|
+
return report.ok
|
|
232
|
+
|
|
233
|
+
def migrate(self, revision: str = "head") -> None:
|
|
234
|
+
from .migrations import upgrade
|
|
235
|
+
|
|
236
|
+
if not isinstance(self.repository, SqlAlchemyRepository):
|
|
237
|
+
raise ConfigError("migrate() needs the SQLAlchemy repository")
|
|
238
|
+
upgrade(self.repository.engine, revision)
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def default_kit(request: Request | None = None) -> DPDPKit:
|
|
242
|
+
if request is not None:
|
|
243
|
+
found = getattr(request.app.state, "dpdpkit", None)
|
|
244
|
+
if isinstance(found, DPDPKit):
|
|
245
|
+
return found
|
|
246
|
+
if _default is None:
|
|
247
|
+
raise ConfigError("create a DPDPKit before using require_consent()")
|
|
248
|
+
return _default
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def require_consent(purpose: str) -> Callable[[Request], Awaitable[None]]:
|
|
252
|
+
"""FastAPI dependency: 403 ``consent_required`` unless the caller has consented to ``purpose``."""
|
|
253
|
+
|
|
254
|
+
async def dependency(request: Request) -> None:
|
|
255
|
+
await default_kit(request).require_consent(purpose)(request)
|
|
256
|
+
|
|
257
|
+
return dependency
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
class ActivityMiddleware(BaseHTTPMiddleware):
|
|
261
|
+
"""Records principal activity (which restarts inactivity clocks and cancels warned erasures).
|
|
262
|
+
Throttled per principal so busy users do not write on every request."""
|
|
263
|
+
|
|
264
|
+
def __init__(self, app: Any, dpdpkit: DPDPKit, min_interval_seconds: float = 300) -> None:
|
|
265
|
+
super().__init__(app)
|
|
266
|
+
self.dpdpkit = dpdpkit
|
|
267
|
+
self.min_interval = min_interval_seconds
|
|
268
|
+
self._seen: dict[str, float] = {}
|
|
269
|
+
|
|
270
|
+
async def dispatch(self, request: Request, call_next: RequestResponseEndpoint) -> Response:
|
|
271
|
+
response = await call_next(request)
|
|
272
|
+
if response.status_code >= 400:
|
|
273
|
+
return response
|
|
274
|
+
try:
|
|
275
|
+
principal = await self.dpdpkit.resolve_principal(request)
|
|
276
|
+
except Exception:
|
|
277
|
+
return response
|
|
278
|
+
if principal:
|
|
279
|
+
now = time.monotonic()
|
|
280
|
+
if now - self._seen.get(principal, -1e12) >= self.min_interval:
|
|
281
|
+
self._seen[principal] = now
|
|
282
|
+
try:
|
|
283
|
+
await run_in_threadpool(self.dpdpkit.kit.retention.record_activity, principal)
|
|
284
|
+
except Exception:
|
|
285
|
+
log.exception("failed to record activity for a principal")
|
|
286
|
+
return response
|
dpdpkit/fastapi/cli.py
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""Adds ``dpdpkit migrate`` to the core CLI (entry point group ``dpdpkit.cli``)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def _migrate(args: argparse.Namespace) -> int:
|
|
10
|
+
from dpdpkit.cli import load_kit
|
|
11
|
+
|
|
12
|
+
from .migrations import alembic_config, upgrade
|
|
13
|
+
from .repository import SqlAlchemyRepository, make_engine
|
|
14
|
+
|
|
15
|
+
if args.sql:
|
|
16
|
+
from alembic import command
|
|
17
|
+
|
|
18
|
+
command.upgrade(alembic_config(url=args.db_url or "postgresql://"), args.revision, sql=True)
|
|
19
|
+
return 0
|
|
20
|
+
if args.db_url:
|
|
21
|
+
engine = make_engine(args.db_url)
|
|
22
|
+
else:
|
|
23
|
+
repo = load_kit(args.kit).repository
|
|
24
|
+
if not isinstance(repo, SqlAlchemyRepository):
|
|
25
|
+
print("error: the kit does not use the SQLAlchemy repository")
|
|
26
|
+
return 1
|
|
27
|
+
engine = repo.engine
|
|
28
|
+
upgrade(engine, args.revision)
|
|
29
|
+
print(f"database migrated to {args.revision}")
|
|
30
|
+
return 0
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def register(subparsers: Any) -> None:
|
|
34
|
+
p = subparsers.add_parser("migrate", help="apply dpdpkit-fastapi database migrations")
|
|
35
|
+
p.add_argument("--db-url", help="SQLAlchemy URL (or use --kit)")
|
|
36
|
+
p.add_argument("--kit", help="module:attribute of your DPDPKit or Kit")
|
|
37
|
+
p.add_argument("--revision", default="head")
|
|
38
|
+
p.add_argument("--sql", action="store_true", help="print SQL instead of running it")
|
|
39
|
+
p.set_defaults(func=_migrate)
|
dpdpkit/fastapi/db.py
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
"""SQLAlchemy 2.0 tables. Ledger and audit rows use real columns; mutable aggregates store their
|
|
2
|
+
model as a JSON document next to the indexed columns used for lookups."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import datetime as dt
|
|
7
|
+
from typing import Any, ClassVar
|
|
8
|
+
|
|
9
|
+
from sqlalchemy import JSON, BigInteger, Boolean, DateTime, Index, Integer, MetaData, String, UniqueConstraint
|
|
10
|
+
from sqlalchemy.dialects import mysql, postgresql
|
|
11
|
+
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
|
|
12
|
+
|
|
13
|
+
NAMING = {
|
|
14
|
+
"ix": "ix_%(column_0_label)s",
|
|
15
|
+
"uq": "uq_%(table_name)s_%(column_0_name)s",
|
|
16
|
+
"ck": "ck_%(table_name)s_%(constraint_name)s",
|
|
17
|
+
"fk": "fk_%(table_name)s_%(column_0_name)s_%(referred_table_name)s",
|
|
18
|
+
"pk": "pk_%(table_name)s",
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
# Microsecond precision everywhere: ledger hashes include timestamps and must survive a round trip.
|
|
22
|
+
Timestamp = DateTime(timezone=True).with_variant(mysql.DATETIME(fsp=6), "mysql")
|
|
23
|
+
Json = JSON().with_variant(postgresql.JSONB(), "postgresql")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class Base(DeclarativeBase):
|
|
27
|
+
metadata = MetaData(naming_convention=NAMING)
|
|
28
|
+
type_annotation_map: ClassVar[dict[Any, Any]] = {dt.datetime: Timestamp, dict[str, Any]: Json}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class ConsentEventRow(Base):
|
|
32
|
+
__tablename__ = "dpdp_consent_events"
|
|
33
|
+
__table_args__ = (
|
|
34
|
+
UniqueConstraint("tenant_id", "seq", name="uq_dpdp_consent_events_tenant_seq"),
|
|
35
|
+
Index("ix_dpdp_consent_events_lookup", "tenant_id", "principal", "purpose", "seq"),
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
id: Mapped[str] = mapped_column(String(64), primary_key=True)
|
|
39
|
+
tenant_id: Mapped[str] = mapped_column(String(64))
|
|
40
|
+
seq: Mapped[int] = mapped_column(BigInteger)
|
|
41
|
+
principal: Mapped[str] = mapped_column(String(255))
|
|
42
|
+
purpose: Mapped[str] = mapped_column(String(128))
|
|
43
|
+
action: Mapped[str] = mapped_column(String(16))
|
|
44
|
+
notice_version: Mapped[int | None] = mapped_column(Integer)
|
|
45
|
+
notice_hash: Mapped[str | None] = mapped_column(String(64))
|
|
46
|
+
locale: Mapped[str | None] = mapped_column(String(16))
|
|
47
|
+
source: Mapped[str] = mapped_column(String(64))
|
|
48
|
+
occurred_at: Mapped[dt.datetime]
|
|
49
|
+
meta: Mapped[dict[str, Any]]
|
|
50
|
+
prev_hash: Mapped[str] = mapped_column(String(64))
|
|
51
|
+
hash: Mapped[str] = mapped_column(String(64))
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class AuditRow(Base):
|
|
55
|
+
__tablename__ = "dpdp_audit"
|
|
56
|
+
__table_args__ = (
|
|
57
|
+
UniqueConstraint("tenant_id", "seq", name="uq_dpdp_audit_tenant_seq"),
|
|
58
|
+
Index("ix_dpdp_audit_subject", "tenant_id", "subject_id"),
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
id: Mapped[str] = mapped_column(String(64), primary_key=True)
|
|
62
|
+
tenant_id: Mapped[str] = mapped_column(String(64))
|
|
63
|
+
seq: Mapped[int] = mapped_column(BigInteger)
|
|
64
|
+
actor: Mapped[str] = mapped_column(String(255))
|
|
65
|
+
action: Mapped[str] = mapped_column(String(128))
|
|
66
|
+
subject_type: Mapped[str] = mapped_column(String(64))
|
|
67
|
+
subject_id: Mapped[str] = mapped_column(String(255))
|
|
68
|
+
occurred_at: Mapped[dt.datetime]
|
|
69
|
+
data: Mapped[dict[str, Any]]
|
|
70
|
+
prev_hash: Mapped[str] = mapped_column(String(64))
|
|
71
|
+
hash: Mapped[str] = mapped_column(String(64))
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class NoticeRow(Base):
|
|
75
|
+
__tablename__ = "dpdp_notices"
|
|
76
|
+
|
|
77
|
+
tenant_id: Mapped[str] = mapped_column(String(64), primary_key=True)
|
|
78
|
+
version: Mapped[int] = mapped_column(Integer, primary_key=True)
|
|
79
|
+
locale: Mapped[str] = mapped_column(String(16), primary_key=True)
|
|
80
|
+
content_hash: Mapped[str] = mapped_column(String(64))
|
|
81
|
+
published_at: Mapped[dt.datetime]
|
|
82
|
+
doc: Mapped[dict[str, Any]]
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class RequestRow(Base):
|
|
86
|
+
__tablename__ = "dpdp_requests"
|
|
87
|
+
__table_args__ = (
|
|
88
|
+
Index("ix_dpdp_requests_principal", "tenant_id", "principal"),
|
|
89
|
+
Index("ix_dpdp_requests_status", "tenant_id", "status"),
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
id: Mapped[str] = mapped_column(String(64), primary_key=True)
|
|
93
|
+
tenant_id: Mapped[str] = mapped_column(String(64))
|
|
94
|
+
principal: Mapped[str] = mapped_column(String(255))
|
|
95
|
+
status: Mapped[str] = mapped_column(String(32))
|
|
96
|
+
opened_at: Mapped[dt.datetime]
|
|
97
|
+
doc: Mapped[dict[str, Any]]
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class NomineeRow(Base):
|
|
101
|
+
__tablename__ = "dpdp_nominees"
|
|
102
|
+
__table_args__ = (Index("ix_dpdp_nominees_principal", "tenant_id", "principal"),)
|
|
103
|
+
|
|
104
|
+
id: Mapped[str] = mapped_column(String(64), primary_key=True)
|
|
105
|
+
tenant_id: Mapped[str] = mapped_column(String(64))
|
|
106
|
+
principal: Mapped[str] = mapped_column(String(255))
|
|
107
|
+
doc: Mapped[dict[str, Any]]
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
class ScheduleRow(Base):
|
|
111
|
+
__tablename__ = "dpdp_erasure_schedules"
|
|
112
|
+
__table_args__ = (
|
|
113
|
+
Index("ix_dpdp_schedules_due", "tenant_id", "status", "warn_at"),
|
|
114
|
+
Index("ix_dpdp_schedules_principal", "tenant_id", "principal"),
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
id: Mapped[str] = mapped_column(String(64), primary_key=True)
|
|
118
|
+
tenant_id: Mapped[str] = mapped_column(String(64))
|
|
119
|
+
principal: Mapped[str] = mapped_column(String(255))
|
|
120
|
+
purpose: Mapped[str] = mapped_column(String(128))
|
|
121
|
+
status: Mapped[str] = mapped_column(String(32))
|
|
122
|
+
warn_at: Mapped[dt.datetime]
|
|
123
|
+
erase_at: Mapped[dt.datetime]
|
|
124
|
+
doc: Mapped[dict[str, Any]]
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
class HoldRow(Base):
|
|
128
|
+
__tablename__ = "dpdp_legal_holds"
|
|
129
|
+
__table_args__ = (Index("ix_dpdp_holds_principal", "tenant_id", "principal", "active"),)
|
|
130
|
+
|
|
131
|
+
id: Mapped[str] = mapped_column(String(64), primary_key=True)
|
|
132
|
+
tenant_id: Mapped[str] = mapped_column(String(64))
|
|
133
|
+
principal: Mapped[str] = mapped_column(String(255))
|
|
134
|
+
active: Mapped[bool] = mapped_column(Boolean)
|
|
135
|
+
doc: Mapped[dict[str, Any]]
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
class ActivityRow(Base):
|
|
139
|
+
__tablename__ = "dpdp_activity"
|
|
140
|
+
|
|
141
|
+
tenant_id: Mapped[str] = mapped_column(String(64), primary_key=True)
|
|
142
|
+
principal: Mapped[str] = mapped_column(String(255), primary_key=True)
|
|
143
|
+
last_at: Mapped[dt.datetime]
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
class DeliveryRow(Base):
|
|
147
|
+
__tablename__ = "dpdp_deliveries"
|
|
148
|
+
|
|
149
|
+
id: Mapped[str] = mapped_column(String(64), primary_key=True)
|
|
150
|
+
tenant_id: Mapped[str] = mapped_column(String(64))
|
|
151
|
+
created_at: Mapped[dt.datetime]
|
|
152
|
+
doc: Mapped[dict[str, Any]]
|
dpdpkit/fastapi/http.py
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""Error rendering and the route class that turns dpdpkit errors into the contract's ``Error`` body."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Callable, Coroutine
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from fastapi import HTTPException, Request, Response
|
|
9
|
+
from fastapi.exceptions import RequestValidationError
|
|
10
|
+
from fastapi.responses import JSONResponse
|
|
11
|
+
from fastapi.routing import APIRoute
|
|
12
|
+
|
|
13
|
+
from dpdpkit.errors import DpdpkitError
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class Unauthenticated(DpdpkitError):
|
|
17
|
+
code = "unauthenticated"
|
|
18
|
+
http_status = 401
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class Forbidden(DpdpkitError):
|
|
22
|
+
code = "forbidden"
|
|
23
|
+
http_status = 403
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class NotImplementedYet(DpdpkitError):
|
|
27
|
+
code = "not_implemented"
|
|
28
|
+
http_status = 501
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class ConsentRequiredHTTP(HTTPException):
|
|
32
|
+
"""Raised by :func:`require_consent`. Rendered as the contract's Error body once the kit is installed
|
|
33
|
+
on the app; otherwise FastAPI renders it as ``{"detail": {...}}`` with the same code."""
|
|
34
|
+
|
|
35
|
+
def __init__(self, purpose: str) -> None:
|
|
36
|
+
super().__init__(
|
|
37
|
+
status_code=403,
|
|
38
|
+
detail={
|
|
39
|
+
"code": "consent_required",
|
|
40
|
+
"message": f"consent required for purpose {purpose!r}",
|
|
41
|
+
"details": {"purpose": purpose},
|
|
42
|
+
},
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def error_response(status: int, code: str, message: str, details: dict[str, Any] | None = None) -> JSONResponse:
|
|
47
|
+
return JSONResponse(
|
|
48
|
+
status_code=status, content={"error": {"code": code, "message": message, "details": details or {}}}
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def render_dpdpkit_error(exc: DpdpkitError) -> JSONResponse:
|
|
53
|
+
status = exc.http_status if exc.http_status != 500 else 400
|
|
54
|
+
return error_response(status, exc.code, exc.message, exc.details)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def render_validation_error(exc: RequestValidationError) -> JSONResponse:
|
|
58
|
+
errors = [
|
|
59
|
+
{"loc": [str(p) for p in e.get("loc", ())], "msg": str(e.get("msg", "")), "type": str(e.get("type", ""))}
|
|
60
|
+
for e in exc.errors()
|
|
61
|
+
]
|
|
62
|
+
return error_response(422, "invalid_request", "request body or parameters are invalid", {"errors": errors})
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
async def consent_required_handler(_request: Request, exc: Exception) -> Response:
|
|
66
|
+
assert isinstance(exc, ConsentRequiredHTTP)
|
|
67
|
+
return JSONResponse(status_code=exc.status_code, content={"error": exc.detail})
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class DpdpRoute(APIRoute):
|
|
71
|
+
"""Every dpdpkit endpoint answers errors with ``{"error": {code, message, details}}``."""
|
|
72
|
+
|
|
73
|
+
def get_route_handler(self) -> Callable[[Request], Coroutine[Any, Any, Response]]:
|
|
74
|
+
handler = super().get_route_handler()
|
|
75
|
+
|
|
76
|
+
async def route(request: Request) -> Response:
|
|
77
|
+
try:
|
|
78
|
+
return await handler(request)
|
|
79
|
+
except DpdpkitError as exc:
|
|
80
|
+
return render_dpdpkit_error(exc)
|
|
81
|
+
except RequestValidationError as exc:
|
|
82
|
+
return render_validation_error(exc)
|
|
83
|
+
|
|
84
|
+
return route
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""Alembic migrations shipped inside the package. Forward-only; they never drop consent or audit data."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from alembic import command
|
|
8
|
+
from alembic.config import Config
|
|
9
|
+
from sqlalchemy import Engine
|
|
10
|
+
|
|
11
|
+
HERE = Path(__file__).parent
|
|
12
|
+
VERSION_TABLE = "dpdp_alembic_version"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def include_name(name: str | None, type_: str, _parent: object) -> bool:
|
|
16
|
+
"""dpdpkit shares the host app's database: migrations only ever consider its own tables."""
|
|
17
|
+
if type_ == "table":
|
|
18
|
+
return bool(name) and str(name).startswith("dpdp_") and name != VERSION_TABLE
|
|
19
|
+
return True
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def alembic_config(engine: Engine | None = None, url: str | None = None) -> Config:
|
|
23
|
+
cfg = Config()
|
|
24
|
+
cfg.set_main_option("script_location", str(HERE))
|
|
25
|
+
cfg.set_main_option("version_table", VERSION_TABLE)
|
|
26
|
+
if url:
|
|
27
|
+
cfg.set_main_option("sqlalchemy.url", url.replace("%", "%%"))
|
|
28
|
+
if engine is not None:
|
|
29
|
+
cfg.attributes["engine"] = engine
|
|
30
|
+
return cfg
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def upgrade(engine: Engine, revision: str = "head") -> None:
|
|
34
|
+
command.upgrade(alembic_config(engine), revision)
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from alembic import context
|
|
4
|
+
from sqlalchemy import create_engine
|
|
5
|
+
|
|
6
|
+
from dpdpkit.fastapi.db import Base
|
|
7
|
+
from dpdpkit.fastapi.migrations import include_name
|
|
8
|
+
|
|
9
|
+
config = context.config
|
|
10
|
+
target_metadata = Base.metadata
|
|
11
|
+
version_table = config.get_main_option("version_table") or "dpdp_alembic_version"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def run_offline() -> None:
|
|
15
|
+
context.configure(
|
|
16
|
+
url=config.get_main_option("sqlalchemy.url"),
|
|
17
|
+
target_metadata=target_metadata,
|
|
18
|
+
literal_binds=True,
|
|
19
|
+
version_table=version_table,
|
|
20
|
+
include_name=include_name,
|
|
21
|
+
)
|
|
22
|
+
with context.begin_transaction():
|
|
23
|
+
context.run_migrations()
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def run_online() -> None:
|
|
27
|
+
engine = config.attributes.get("engine") or create_engine(str(config.get_main_option("sqlalchemy.url")))
|
|
28
|
+
with engine.connect() as connection:
|
|
29
|
+
context.configure(
|
|
30
|
+
connection=connection,
|
|
31
|
+
target_metadata=target_metadata,
|
|
32
|
+
version_table=version_table,
|
|
33
|
+
include_name=include_name,
|
|
34
|
+
render_as_batch=connection.dialect.name == "sqlite",
|
|
35
|
+
)
|
|
36
|
+
with context.begin_transaction():
|
|
37
|
+
context.run_migrations()
|
|
38
|
+
connection.commit()
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
if context.is_offline_mode():
|
|
42
|
+
run_offline()
|
|
43
|
+
else:
|
|
44
|
+
run_online()
|