stapel-gdpr 0.3.1__tar.gz
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.
- stapel_gdpr-0.3.1/LICENSE +21 -0
- stapel_gdpr-0.3.1/PKG-INFO +55 -0
- stapel_gdpr-0.3.1/README.md +34 -0
- stapel_gdpr-0.3.1/__init__.py +53 -0
- stapel_gdpr-0.3.1/actions.py +38 -0
- stapel_gdpr-0.3.1/admin.py +44 -0
- stapel_gdpr-0.3.1/apps.py +33 -0
- stapel_gdpr-0.3.1/conf.py +29 -0
- stapel_gdpr-0.3.1/conftest.py +132 -0
- stapel_gdpr-0.3.1/dto.py +50 -0
- stapel_gdpr-0.3.1/errors.py +20 -0
- stapel_gdpr-0.3.1/management/__init__.py +0 -0
- stapel_gdpr-0.3.1/management/commands/__init__.py +0 -0
- stapel_gdpr-0.3.1/management/commands/consume_gdpr_completions.py +45 -0
- stapel_gdpr-0.3.1/migrations/0001_initial.py +106 -0
- stapel_gdpr-0.3.1/migrations/__init__.py +0 -0
- stapel_gdpr-0.3.1/models.py +203 -0
- stapel_gdpr-0.3.1/orchestrator.py +524 -0
- stapel_gdpr-0.3.1/py.typed +0 -0
- stapel_gdpr-0.3.1/pyproject.toml +46 -0
- stapel_gdpr-0.3.1/reregistration.py +89 -0
- stapel_gdpr-0.3.1/schemas/emits/user.deleted.json +14 -0
- stapel_gdpr-0.3.1/schemas/emits/user.deletion_initiated.json +13 -0
- stapel_gdpr-0.3.1/schemas/emits/user.export_ready.json +13 -0
- stapel_gdpr-0.3.1/serializers.py +17 -0
- stapel_gdpr-0.3.1/setup.cfg +4 -0
- stapel_gdpr-0.3.1/stapel_gdpr.egg-info/PKG-INFO +55 -0
- stapel_gdpr-0.3.1/stapel_gdpr.egg-info/SOURCES.txt +64 -0
- stapel_gdpr-0.3.1/stapel_gdpr.egg-info/dependency_links.txt +1 -0
- stapel_gdpr-0.3.1/stapel_gdpr.egg-info/requires.txt +8 -0
- stapel_gdpr-0.3.1/stapel_gdpr.egg-info/top_level.txt +1 -0
- stapel_gdpr-0.3.1/tasks.py +216 -0
- stapel_gdpr-0.3.1/tests/test_api.py +179 -0
- stapel_gdpr-0.3.1/tests/test_closure.py +253 -0
- stapel_gdpr-0.3.1/tests/test_consumer.py +92 -0
- stapel_gdpr-0.3.1/tests/test_export.py +140 -0
- stapel_gdpr-0.3.1/tests/test_orchestrator_edge.py +304 -0
- stapel_gdpr-0.3.1/tests/test_public_api.py +60 -0
- stapel_gdpr-0.3.1/tests/test_tasks.py +212 -0
- stapel_gdpr-0.3.1/tests/test_views_edge.py +292 -0
- stapel_gdpr-0.3.1/urls.py +31 -0
- stapel_gdpr-0.3.1/views.py +343 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 usestapel
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: stapel-gdpr
|
|
3
|
+
Version: 0.3.1
|
|
4
|
+
Summary: GDPR data export and account deletion Django app for the Stapel framework
|
|
5
|
+
License: MIT
|
|
6
|
+
Keywords: django,stapel,gdpr,privacy,data-export,right-to-erasure
|
|
7
|
+
Classifier: Framework :: Django
|
|
8
|
+
Classifier: Framework :: Django :: 5.2
|
|
9
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
10
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
11
|
+
Requires-Python: >=3.11
|
|
12
|
+
Description-Content-Type: text/markdown
|
|
13
|
+
License-File: LICENSE
|
|
14
|
+
Requires-Dist: stapel-core<0.4,>=0.3.0
|
|
15
|
+
Requires-Dist: celery>=5.3
|
|
16
|
+
Provides-Extra: s3
|
|
17
|
+
Requires-Dist: boto3>=1.34; extra == "s3"
|
|
18
|
+
Provides-Extra: all
|
|
19
|
+
Requires-Dist: stapel-gdpr[s3]; extra == "all"
|
|
20
|
+
Dynamic: license-file
|
|
21
|
+
|
|
22
|
+
# stapel-gdpr
|
|
23
|
+
|
|
24
|
+
[](https://github.com/usestapel/stapel-gdpr/actions/workflows/ci.yml)
|
|
25
|
+
|
|
26
|
+
> GDPR compliance — data export (Art. 15/20), account deletion with grace period (Art. 17), inactivity closure, retention cleanup
|
|
27
|
+
|
|
28
|
+
Part of the [Stapel framework](https://github.com/usestapel) — composable Django apps for building production-grade platforms.
|
|
29
|
+
|
|
30
|
+
## Installation
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
pip install stapel-gdpr
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## Quick start
|
|
37
|
+
|
|
38
|
+
```python
|
|
39
|
+
# settings.py
|
|
40
|
+
INSTALLED_APPS = [
|
|
41
|
+
...
|
|
42
|
+
'stapel_gdpr',
|
|
43
|
+
]
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## Bus events
|
|
47
|
+
|
|
48
|
+
### Emits
|
|
49
|
+
| `user.deleted` | [schema](schemas/emits/user.deleted.json) | All user PII permanently deleted after grace period. Every package storing user |
|
|
50
|
+
| `user.deletion_initiated` | [schema](schemas/emits/user.deletion_initiated.json) | Account closure started. 30-day grace period begins; account is deactivated. |
|
|
51
|
+
| `user.export_ready` | [schema](schemas/emits/user.export_ready.json) | Data export archive is ready for download. |
|
|
52
|
+
|
|
53
|
+
## License
|
|
54
|
+
|
|
55
|
+
MIT — see [LICENSE](LICENSE)
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# stapel-gdpr
|
|
2
|
+
|
|
3
|
+
[](https://github.com/usestapel/stapel-gdpr/actions/workflows/ci.yml)
|
|
4
|
+
|
|
5
|
+
> GDPR compliance — data export (Art. 15/20), account deletion with grace period (Art. 17), inactivity closure, retention cleanup
|
|
6
|
+
|
|
7
|
+
Part of the [Stapel framework](https://github.com/usestapel) — composable Django apps for building production-grade platforms.
|
|
8
|
+
|
|
9
|
+
## Installation
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
pip install stapel-gdpr
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
## Quick start
|
|
16
|
+
|
|
17
|
+
```python
|
|
18
|
+
# settings.py
|
|
19
|
+
INSTALLED_APPS = [
|
|
20
|
+
...
|
|
21
|
+
'stapel_gdpr',
|
|
22
|
+
]
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## Bus events
|
|
26
|
+
|
|
27
|
+
### Emits
|
|
28
|
+
| `user.deleted` | [schema](schemas/emits/user.deleted.json) | All user PII permanently deleted after grace period. Every package storing user |
|
|
29
|
+
| `user.deletion_initiated` | [schema](schemas/emits/user.deletion_initiated.json) | Account closure started. 30-day grace period begins; account is deactivated. |
|
|
30
|
+
| `user.export_ready` | [schema](schemas/emits/user.export_ready.json) | Data export archive is ready for download. |
|
|
31
|
+
|
|
32
|
+
## License
|
|
33
|
+
|
|
34
|
+
MIT — see [LICENSE](LICENSE)
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""Stapel GDPR — data export (Art. 15/20) and account deletion (Art. 17) for Django.
|
|
2
|
+
|
|
3
|
+
Public API (all attributes are lazily imported via PEP 562, so importing
|
|
4
|
+
``stapel_gdpr`` itself never touches Django):
|
|
5
|
+
|
|
6
|
+
- ``gdpr_settings`` — the ``STAPEL_GDPR`` settings namespace (:mod:`stapel_gdpr.conf`).
|
|
7
|
+
- ``gdpr_orchestrator`` — the :class:`~stapel_gdpr.orchestrator.GDPROrchestrator`
|
|
8
|
+
singleton coordinating export and deletion across providers/services.
|
|
9
|
+
- ``is_reregistration`` — signup-time check: does an email/phone belong to a
|
|
10
|
+
previously deleted account? (:mod:`stapel_gdpr.reregistration`)
|
|
11
|
+
- ``store_hashes`` — persist salted identifier hashes before erasure
|
|
12
|
+
(:mod:`stapel_gdpr.reregistration`).
|
|
13
|
+
- ``LegalHold`` — Django model blocking closure/deletion while data must
|
|
14
|
+
be preserved (GDPR Art. 17(3)). Requires configured Django settings with
|
|
15
|
+
``stapel_gdpr`` in ``INSTALLED_APPS``::
|
|
16
|
+
|
|
17
|
+
from stapel_gdpr import LegalHold
|
|
18
|
+
LegalHold.objects.create(user_id=user.pk, reason="litigation", created_by="legal")
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
__all__ = [
|
|
22
|
+
"LegalHold",
|
|
23
|
+
"gdpr_orchestrator",
|
|
24
|
+
"gdpr_settings",
|
|
25
|
+
"is_reregistration",
|
|
26
|
+
"store_hashes",
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
# name -> (module, attribute); resolved on first access so that plain
|
|
30
|
+
# `import stapel_gdpr` stays free of Django (and Django-settings) imports.
|
|
31
|
+
_LAZY_EXPORTS = {
|
|
32
|
+
"gdpr_settings": ("stapel_gdpr.conf", "gdpr_settings"),
|
|
33
|
+
"gdpr_orchestrator": ("stapel_gdpr.orchestrator", "gdpr_orchestrator"),
|
|
34
|
+
"is_reregistration": ("stapel_gdpr.reregistration", "is_reregistration"),
|
|
35
|
+
"store_hashes": ("stapel_gdpr.reregistration", "store_hashes"),
|
|
36
|
+
"LegalHold": ("stapel_gdpr.models", "LegalHold"),
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def __getattr__(name): # PEP 562
|
|
41
|
+
try:
|
|
42
|
+
module_path, attr = _LAZY_EXPORTS[name]
|
|
43
|
+
except KeyError:
|
|
44
|
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}") from None
|
|
45
|
+
from importlib import import_module
|
|
46
|
+
|
|
47
|
+
value = getattr(import_module(module_path), attr)
|
|
48
|
+
globals()[name] = value # cache so subsequent lookups skip __getattr__
|
|
49
|
+
return value
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def __dir__():
|
|
53
|
+
return sorted(set(globals()) | set(__all__))
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""Action subscriptions of the GDPR module.
|
|
2
|
+
|
|
3
|
+
Remote services confirm erasure of their data slice by emitting
|
|
4
|
+
``gdpr.section.erased`` with the correlation_id they received in
|
|
5
|
+
``user.deleted``. Handlers must be idempotent — delivery is at-least-once.
|
|
6
|
+
"""
|
|
7
|
+
import logging
|
|
8
|
+
|
|
9
|
+
from stapel_core.comm import on_action
|
|
10
|
+
|
|
11
|
+
logger = logging.getLogger(__name__)
|
|
12
|
+
|
|
13
|
+
SECTION_ERASED_SCHEMA = {
|
|
14
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
15
|
+
"title": "gdpr.section.erased",
|
|
16
|
+
"type": "object",
|
|
17
|
+
"required": ["user_id", "correlation_id", "service"],
|
|
18
|
+
"properties": {
|
|
19
|
+
"user_id": {"type": "string"},
|
|
20
|
+
"correlation_id": {"type": "string"},
|
|
21
|
+
"service": {"type": "string"},
|
|
22
|
+
},
|
|
23
|
+
"additionalProperties": False,
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@on_action("gdpr.section.erased", schema=SECTION_ERASED_SCHEMA)
|
|
28
|
+
def handle_section_erased(event):
|
|
29
|
+
"""Mark the remote deletion part done; finalize the closure when complete."""
|
|
30
|
+
correlation_id = event.payload.get("correlation_id")
|
|
31
|
+
service = event.payload.get("service") or event.service
|
|
32
|
+
if not correlation_id or not service:
|
|
33
|
+
logger.error("Malformed gdpr.section.erased event: %s", getattr(event, "event_id", "?"))
|
|
34
|
+
return
|
|
35
|
+
|
|
36
|
+
from .orchestrator import gdpr_orchestrator
|
|
37
|
+
|
|
38
|
+
gdpr_orchestrator.mark_section_erased(correlation_id, service)
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
from django.contrib import admin
|
|
2
|
+
|
|
3
|
+
from .models import (
|
|
4
|
+
AccountClosureRequest,
|
|
5
|
+
AccountDeletionPart,
|
|
6
|
+
DataExportRequest,
|
|
7
|
+
LegalHold,
|
|
8
|
+
ReRegistrationHash,
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@admin.register(LegalHold)
|
|
13
|
+
class LegalHoldAdmin(admin.ModelAdmin):
|
|
14
|
+
list_display = ('user_id', 'reason', 'created_by', 'created_at', 'released_at')
|
|
15
|
+
list_filter = ('released_at',)
|
|
16
|
+
search_fields = ('user_id', 'reason', 'created_by')
|
|
17
|
+
readonly_fields = ('created_at',)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class AccountDeletionPartInline(admin.TabularInline):
|
|
21
|
+
model = AccountDeletionPart
|
|
22
|
+
extra = 0
|
|
23
|
+
readonly_fields = ('service', 'status', 'completed_at', 'error')
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@admin.register(AccountClosureRequest)
|
|
27
|
+
class AccountClosureRequestAdmin(admin.ModelAdmin):
|
|
28
|
+
list_display = ('user_id', 'trigger', 'status', 'initiated_at', 'grace_ends_at', 'deleted_at')
|
|
29
|
+
list_filter = ('status', 'trigger')
|
|
30
|
+
search_fields = ('user_id', 'correlation_id')
|
|
31
|
+
inlines = [AccountDeletionPartInline]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@admin.register(DataExportRequest)
|
|
35
|
+
class DataExportRequestAdmin(admin.ModelAdmin):
|
|
36
|
+
list_display = ('user_id', 'status', 'created_at', 'deadline', 'download_expires_at')
|
|
37
|
+
list_filter = ('status',)
|
|
38
|
+
search_fields = ('user_id', 'correlation_id')
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@admin.register(ReRegistrationHash)
|
|
42
|
+
class ReRegistrationHashAdmin(admin.ModelAdmin):
|
|
43
|
+
list_display = ('hash_type', 'user_id_was', 'created_at', 'expires_at')
|
|
44
|
+
list_filter = ('hash_type',)
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
from django.apps import AppConfig
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class StapelGDPRConfig(AppConfig):
|
|
5
|
+
default_auto_field = 'django.db.models.BigAutoField'
|
|
6
|
+
name = 'stapel_gdpr'
|
|
7
|
+
label = 'gdpr'
|
|
8
|
+
verbose_name = 'Stapel GDPR'
|
|
9
|
+
|
|
10
|
+
def ready(self):
|
|
11
|
+
from . import actions # noqa: F401 — register comm subscribers
|
|
12
|
+
self._register_gdpr_providers()
|
|
13
|
+
|
|
14
|
+
def _register_gdpr_providers(self):
|
|
15
|
+
"""Load and register GDPR providers declared in settings.
|
|
16
|
+
|
|
17
|
+
Configure in Django settings which services this deployment collects data from:
|
|
18
|
+
|
|
19
|
+
GDPR_PROVIDERS = [
|
|
20
|
+
'stapel_auth.gdpr.AuthGDPRProvider',
|
|
21
|
+
'stapel_cdn.gdpr.CdnGDPRProvider',
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
The providers are loaded dynamically — stapel_gdpr has no compile-time
|
|
25
|
+
dependency on any of the service packages.
|
|
26
|
+
"""
|
|
27
|
+
from django.conf import settings
|
|
28
|
+
from django.utils.module_loading import import_string
|
|
29
|
+
from stapel_core.gdpr import gdpr_registry
|
|
30
|
+
|
|
31
|
+
for cls_path in getattr(settings, 'GDPR_PROVIDERS', []):
|
|
32
|
+
provider_cls = import_string(cls_path)
|
|
33
|
+
gdpr_registry.register(provider_cls())
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""STAPEL_GDPR settings namespace.
|
|
2
|
+
|
|
3
|
+
Configure in Django settings:
|
|
4
|
+
|
|
5
|
+
STAPEL_GDPR = {
|
|
6
|
+
# Remote services (comm consumers) that must confirm erasure with a
|
|
7
|
+
# gdpr.section.erased action before a closure is marked DELETED.
|
|
8
|
+
"REMOTE_DELETION_SERVICES": ["profiles", "cdn"],
|
|
9
|
+
# Salt for re-registration hashes. Defaults to SECRET_KEY.
|
|
10
|
+
"REREG_SALT": "",
|
|
11
|
+
# Filesystem roots for export staging / final archives.
|
|
12
|
+
# Default: MEDIA_ROOT/gdpr/staging and MEDIA_ROOT/gdpr/exports.
|
|
13
|
+
"STAGING_ROOT": "",
|
|
14
|
+
"ARCHIVE_ROOT": "",
|
|
15
|
+
}
|
|
16
|
+
"""
|
|
17
|
+
from stapel_core.conf import AppSettings
|
|
18
|
+
|
|
19
|
+
gdpr_settings = AppSettings(
|
|
20
|
+
"STAPEL_GDPR",
|
|
21
|
+
defaults={
|
|
22
|
+
"REMOTE_DELETION_SERVICES": [],
|
|
23
|
+
"REREG_SALT": "",
|
|
24
|
+
"STAGING_ROOT": "",
|
|
25
|
+
"ARCHIVE_ROOT": "",
|
|
26
|
+
},
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
__all__ = ["gdpr_settings"]
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import tempfile
|
|
2
|
+
import uuid
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def pytest_configure(config):
|
|
6
|
+
from django.conf import settings
|
|
7
|
+
|
|
8
|
+
if not settings.configured:
|
|
9
|
+
settings.configure(
|
|
10
|
+
SECRET_KEY="test-secret-key-not-for-production",
|
|
11
|
+
INSTALLED_APPS=[
|
|
12
|
+
"django.contrib.contenttypes",
|
|
13
|
+
"django.contrib.auth",
|
|
14
|
+
"django.contrib.sessions",
|
|
15
|
+
"django.contrib.messages",
|
|
16
|
+
"django.contrib.admin", # so stapel_gdpr.admin is importable/testable
|
|
17
|
+
"stapel_core.django.users",
|
|
18
|
+
"rest_framework",
|
|
19
|
+
"stapel_gdpr",
|
|
20
|
+
],
|
|
21
|
+
AUTH_USER_MODEL="users.User",
|
|
22
|
+
DATABASES={
|
|
23
|
+
"default": {
|
|
24
|
+
"ENGINE": "django.db.backends.sqlite3",
|
|
25
|
+
"NAME": ":memory:",
|
|
26
|
+
}
|
|
27
|
+
},
|
|
28
|
+
DEFAULT_AUTO_FIELD="django.db.models.BigAutoField",
|
|
29
|
+
USE_TZ=True,
|
|
30
|
+
APPEND_SLASH=False,
|
|
31
|
+
ROOT_URLCONF="tests.urls",
|
|
32
|
+
MEDIA_ROOT=tempfile.mkdtemp(prefix="gdpr-tests-"),
|
|
33
|
+
CACHES={
|
|
34
|
+
"default": {
|
|
35
|
+
"BACKEND": "django.core.cache.backends.locmem.LocMemCache",
|
|
36
|
+
}
|
|
37
|
+
},
|
|
38
|
+
# In-memory bus — no Kafka/Redis broker needed
|
|
39
|
+
STAPEL_BUS_BACKEND="stapel_core.bus.backends.memory.MemoryBus",
|
|
40
|
+
# Synchronous in-process comm — no outbox tables / relay needed
|
|
41
|
+
STAPEL_COMM={
|
|
42
|
+
"OUTBOX_ENABLED": False,
|
|
43
|
+
"ACTION_TRANSPORT": "inprocess",
|
|
44
|
+
},
|
|
45
|
+
MIDDLEWARE=[
|
|
46
|
+
"django.middleware.common.CommonMiddleware",
|
|
47
|
+
"stapel_core.django.jwt.middleware.ServiceAPIKeyMiddleware",
|
|
48
|
+
],
|
|
49
|
+
SERVICE_API_KEY="test-service-key",
|
|
50
|
+
FRONTEND_URL="https://app.example.com",
|
|
51
|
+
# Skip migrations — create tables directly from models
|
|
52
|
+
MIGRATION_MODULES={
|
|
53
|
+
"users": None,
|
|
54
|
+
"gdpr": None,
|
|
55
|
+
},
|
|
56
|
+
)
|
|
57
|
+
import django
|
|
58
|
+
|
|
59
|
+
django.setup()
|
|
60
|
+
|
|
61
|
+
# Run celery tasks inline (.delay executes synchronously)
|
|
62
|
+
from celery import Celery
|
|
63
|
+
|
|
64
|
+
celery_app = Celery("stapel-gdpr-tests")
|
|
65
|
+
celery_app.conf.update(
|
|
66
|
+
task_always_eager=True,
|
|
67
|
+
task_eager_propagates=True,
|
|
68
|
+
broker_url="memory://",
|
|
69
|
+
result_backend="cache+memory://",
|
|
70
|
+
)
|
|
71
|
+
celery_app.set_default()
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
import pytest # noqa: E402
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@pytest.fixture
|
|
78
|
+
def user(db):
|
|
79
|
+
from django.contrib.auth import get_user_model
|
|
80
|
+
|
|
81
|
+
User = get_user_model()
|
|
82
|
+
u = User.objects.create_user(
|
|
83
|
+
username=f"u-{uuid.uuid4().hex[:8]}",
|
|
84
|
+
email=f"{uuid.uuid4().hex[:8]}@example.com",
|
|
85
|
+
password="testpass-1234",
|
|
86
|
+
)
|
|
87
|
+
return u
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
@pytest.fixture
|
|
91
|
+
def api_client():
|
|
92
|
+
from rest_framework.test import APIClient
|
|
93
|
+
|
|
94
|
+
return APIClient()
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
@pytest.fixture
|
|
98
|
+
def authed_client(api_client, user):
|
|
99
|
+
api_client.force_authenticate(user=user)
|
|
100
|
+
return api_client
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
@pytest.fixture
|
|
104
|
+
def fake_provider():
|
|
105
|
+
"""Register an in-process GDPR provider for the duration of a test."""
|
|
106
|
+
from stapel_core.gdpr import GDPRProvider, gdpr_registry
|
|
107
|
+
|
|
108
|
+
class FakeProvider(GDPRProvider):
|
|
109
|
+
section = "fake"
|
|
110
|
+
|
|
111
|
+
def __init__(self):
|
|
112
|
+
self.exported = []
|
|
113
|
+
self.deleted = []
|
|
114
|
+
self.anonymized = []
|
|
115
|
+
self.fail_delete = False
|
|
116
|
+
|
|
117
|
+
def export(self, user_id):
|
|
118
|
+
self.exported.append(str(user_id))
|
|
119
|
+
return {"user_id": str(user_id), "items": [1, 2, 3]}
|
|
120
|
+
|
|
121
|
+
def delete(self, user_id):
|
|
122
|
+
if self.fail_delete:
|
|
123
|
+
raise RuntimeError("boom")
|
|
124
|
+
self.deleted.append(str(user_id))
|
|
125
|
+
|
|
126
|
+
def anonymize(self, user_id):
|
|
127
|
+
self.anonymized.append(str(user_id))
|
|
128
|
+
|
|
129
|
+
provider = FakeProvider()
|
|
130
|
+
gdpr_registry.register(provider)
|
|
131
|
+
yield provider
|
|
132
|
+
gdpr_registry._providers.remove(provider)
|
stapel_gdpr-0.3.1/dto.py
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
from typing import Optional
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
@dataclass
|
|
6
|
+
class ExportRequestDTO:
|
|
7
|
+
"""Response after initiating a data export request.
|
|
8
|
+
|
|
9
|
+
Attributes:
|
|
10
|
+
request_id: Unique export request ID. Example: 42
|
|
11
|
+
status: Current status. Example: pending
|
|
12
|
+
message: Human-readable status message. Example: Your archive will be ready within 48 hours.
|
|
13
|
+
"""
|
|
14
|
+
request_id: int
|
|
15
|
+
status: str
|
|
16
|
+
message: str
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass
|
|
20
|
+
class ExportStatusDTO:
|
|
21
|
+
"""Status of a data export request.
|
|
22
|
+
|
|
23
|
+
Attributes:
|
|
24
|
+
request_id: Export request ID. Example: 42
|
|
25
|
+
status: One of pending, processing, ready, failed, expired. Example: ready
|
|
26
|
+
parts_done: Number of sections completed. Example: 4
|
|
27
|
+
parts_total: Total sections expected. Example: 5
|
|
28
|
+
download_available: Whether archive is ready to download. Example: true
|
|
29
|
+
expires_at: ISO datetime when download link expires, null if not ready. Example: 2026-07-01T12:00:00Z
|
|
30
|
+
"""
|
|
31
|
+
request_id: int
|
|
32
|
+
status: str
|
|
33
|
+
parts_done: int
|
|
34
|
+
parts_total: int
|
|
35
|
+
download_available: bool
|
|
36
|
+
expires_at: Optional[str]
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass
|
|
40
|
+
class ClosureStatusDTO:
|
|
41
|
+
"""Status of an account closure request.
|
|
42
|
+
|
|
43
|
+
Attributes:
|
|
44
|
+
status: One of grace, deleting, deleted, cancelled. Example: grace
|
|
45
|
+
grace_ends_at: ISO datetime when grace period ends. Example: 2026-07-24T10:00:00Z
|
|
46
|
+
can_cancel: Whether the closure can still be cancelled. Example: true
|
|
47
|
+
"""
|
|
48
|
+
status: str
|
|
49
|
+
grace_ends_at: str
|
|
50
|
+
can_cancel: bool
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
from stapel_core.django.api.errors import register_service_errors
|
|
2
|
+
|
|
3
|
+
ERR_409_EXPORT_COOLDOWN = 'error.409.gdpr.export_cooldown'
|
|
4
|
+
ERR_409_CLOSURE_PENDING = 'error.409.gdpr.closure_already_pending'
|
|
5
|
+
ERR_409_LEGAL_HOLD = 'error.409.gdpr.legal_hold'
|
|
6
|
+
ERR_404_NO_ACTIVE_CLOSURE = 'error.404.gdpr.no_active_closure'
|
|
7
|
+
ERR_404_EXPORT_NOT_FOUND = 'error.404.gdpr.export_not_found'
|
|
8
|
+
ERR_410_DOWNLOAD_EXPIRED = 'error.410.gdpr.download_expired'
|
|
9
|
+
ERR_425_EXPORT_NOT_READY = 'error.425.gdpr.export_not_ready'
|
|
10
|
+
|
|
11
|
+
_ERRORS = {
|
|
12
|
+
ERR_409_EXPORT_COOLDOWN: 'A data export was already requested in the last 30 days.',
|
|
13
|
+
ERR_409_CLOSURE_PENDING: 'Account closure is already in progress.',
|
|
14
|
+
ERR_409_LEGAL_HOLD: 'Account data is under a legal hold and cannot be deleted.',
|
|
15
|
+
ERR_404_NO_ACTIVE_CLOSURE: 'No pending account closure found.',
|
|
16
|
+
ERR_404_EXPORT_NOT_FOUND: 'Export request not found.',
|
|
17
|
+
ERR_410_DOWNLOAD_EXPIRED: 'Download link has expired.',
|
|
18
|
+
ERR_425_EXPORT_NOT_READY: 'Export is still being prepared.',
|
|
19
|
+
}
|
|
20
|
+
register_service_errors(_ERRORS)
|
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Bus consumer for GDPR completion events published by individual services.
|
|
3
|
+
|
|
4
|
+
Run one instance per GDPR service deployment:
|
|
5
|
+
|
|
6
|
+
python manage.py consume_gdpr_completions
|
|
7
|
+
"""
|
|
8
|
+
from stapel_core.bus.consumer import BaseBusConsumerCommand
|
|
9
|
+
from stapel_core.bus.event import Event
|
|
10
|
+
from stapel_core.gdpr import GDPR_DELETE_COMPLETED, GDPR_EXPORT_COMPLETED
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class Command(BaseBusConsumerCommand):
|
|
14
|
+
help = 'Consume GDPR export/delete completion events from services'
|
|
15
|
+
|
|
16
|
+
topics = [GDPR_EXPORT_COMPLETED, GDPR_DELETE_COMPLETED]
|
|
17
|
+
consumer_group = 'gdpr-orchestrator'
|
|
18
|
+
|
|
19
|
+
def handle_event(self, event: Event) -> None:
|
|
20
|
+
if event.event_type == GDPR_EXPORT_COMPLETED:
|
|
21
|
+
self._on_export_completed(event)
|
|
22
|
+
elif event.event_type == GDPR_DELETE_COMPLETED:
|
|
23
|
+
self._on_delete_completed(event)
|
|
24
|
+
|
|
25
|
+
def _on_export_completed(self, event: Event) -> None:
|
|
26
|
+
from stapel_gdpr.orchestrator import gdpr_orchestrator
|
|
27
|
+
correlation_id = event.payload.get('correlation_id')
|
|
28
|
+
service = event.service
|
|
29
|
+
bucket_path = event.payload.get('bucket_path', '')
|
|
30
|
+
|
|
31
|
+
if not correlation_id or not service:
|
|
32
|
+
self.stderr.write(f'Malformed gdpr.export.completed event: {event.event_id}')
|
|
33
|
+
return
|
|
34
|
+
|
|
35
|
+
gdpr_orchestrator.mark_part_ready(correlation_id, service, bucket_path)
|
|
36
|
+
|
|
37
|
+
def _on_delete_completed(self, event: Event) -> None:
|
|
38
|
+
# Deletion is fire-and-forget — just log. Closure status is updated by sweep task
|
|
39
|
+
# once all services have confirmed (or deadline passes).
|
|
40
|
+
user_id = event.payload.get('user_id')
|
|
41
|
+
correlation_id = event.payload.get('correlation_id')
|
|
42
|
+
self.stdout.write(
|
|
43
|
+
f'GDPR delete completed: service={event.service} user={user_id} '
|
|
44
|
+
f'correlation={correlation_id}'
|
|
45
|
+
)
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
# Generated by Django 6.0.6 on 2026-07-02 10:26
|
|
2
|
+
|
|
3
|
+
import django.db.models.deletion
|
|
4
|
+
from django.db import migrations, models
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class Migration(migrations.Migration):
|
|
8
|
+
|
|
9
|
+
initial = True
|
|
10
|
+
|
|
11
|
+
dependencies = [
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
operations = [
|
|
15
|
+
migrations.CreateModel(
|
|
16
|
+
name='AccountClosureRequest',
|
|
17
|
+
fields=[
|
|
18
|
+
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
|
19
|
+
('user_id', models.UUIDField(db_index=True)),
|
|
20
|
+
('trigger', models.CharField(choices=[('manual', 'Manual'), ('inactivity', 'Inactivity'), ('platform', 'Platform')], max_length=20)),
|
|
21
|
+
('status', models.CharField(choices=[('grace', 'Grace Period'), ('deleting', 'Deleting'), ('deleted', 'Deleted'), ('cancelled', 'Cancelled')], default='grace', max_length=20)),
|
|
22
|
+
('correlation_id', models.CharField(blank=True, db_index=True, max_length=36, null=True, unique=True)),
|
|
23
|
+
('local_erasure_done', models.BooleanField(default=False)),
|
|
24
|
+
('initiated_at', models.DateTimeField(auto_now_add=True)),
|
|
25
|
+
('grace_ends_at', models.DateTimeField()),
|
|
26
|
+
('deleted_at', models.DateTimeField(blank=True, null=True)),
|
|
27
|
+
('cancelled_at', models.DateTimeField(blank=True, null=True)),
|
|
28
|
+
],
|
|
29
|
+
),
|
|
30
|
+
migrations.CreateModel(
|
|
31
|
+
name='DataExportRequest',
|
|
32
|
+
fields=[
|
|
33
|
+
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
|
34
|
+
('user_id', models.UUIDField(db_index=True)),
|
|
35
|
+
('status', models.CharField(choices=[('pending', 'Pending'), ('processing', 'Processing'), ('assembling', 'Assembling'), ('ready', 'Ready'), ('failed', 'Failed'), ('expired', 'Expired')], default='pending', max_length=20)),
|
|
36
|
+
('correlation_id', models.CharField(blank=True, db_index=True, max_length=36, null=True, unique=True)),
|
|
37
|
+
('expected_services', models.JSONField(default=list)),
|
|
38
|
+
('archive_path', models.CharField(blank=True, max_length=500, null=True)),
|
|
39
|
+
('download_token', models.CharField(blank=True, max_length=64, null=True, unique=True)),
|
|
40
|
+
('created_at', models.DateTimeField(auto_now_add=True)),
|
|
41
|
+
('deadline', models.DateTimeField()),
|
|
42
|
+
('download_expires_at', models.DateTimeField(blank=True, null=True)),
|
|
43
|
+
('error', models.TextField(blank=True, null=True)),
|
|
44
|
+
],
|
|
45
|
+
options={
|
|
46
|
+
'ordering': ['-created_at'],
|
|
47
|
+
},
|
|
48
|
+
),
|
|
49
|
+
migrations.CreateModel(
|
|
50
|
+
name='LegalHold',
|
|
51
|
+
fields=[
|
|
52
|
+
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
|
53
|
+
('user_id', models.UUIDField(db_index=True)),
|
|
54
|
+
('reason', models.TextField()),
|
|
55
|
+
('created_by', models.CharField(blank=True, default='', max_length=150)),
|
|
56
|
+
('created_at', models.DateTimeField(auto_now_add=True)),
|
|
57
|
+
('released_at', models.DateTimeField(blank=True, null=True)),
|
|
58
|
+
],
|
|
59
|
+
options={
|
|
60
|
+
'ordering': ['-created_at'],
|
|
61
|
+
},
|
|
62
|
+
),
|
|
63
|
+
migrations.CreateModel(
|
|
64
|
+
name='ReRegistrationHash',
|
|
65
|
+
fields=[
|
|
66
|
+
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
|
67
|
+
('hash_type', models.CharField(choices=[('email', 'Email'), ('phone', 'Phone')], max_length=10)),
|
|
68
|
+
('hash_value', models.CharField(db_index=True, max_length=128)),
|
|
69
|
+
('user_id_was', models.CharField(max_length=64)),
|
|
70
|
+
('created_at', models.DateTimeField(auto_now_add=True)),
|
|
71
|
+
('expires_at', models.DateTimeField()),
|
|
72
|
+
],
|
|
73
|
+
options={
|
|
74
|
+
'unique_together': {('hash_type', 'hash_value')},
|
|
75
|
+
},
|
|
76
|
+
),
|
|
77
|
+
migrations.CreateModel(
|
|
78
|
+
name='AccountDeletionPart',
|
|
79
|
+
fields=[
|
|
80
|
+
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
|
81
|
+
('service', models.CharField(max_length=50)),
|
|
82
|
+
('status', models.CharField(choices=[('pending', 'Pending'), ('done', 'Done'), ('failed', 'Failed')], default='pending', max_length=20)),
|
|
83
|
+
('completed_at', models.DateTimeField(blank=True, null=True)),
|
|
84
|
+
('error', models.TextField(blank=True, null=True)),
|
|
85
|
+
('closure', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='parts', to='gdpr.accountclosurerequest')),
|
|
86
|
+
],
|
|
87
|
+
options={
|
|
88
|
+
'unique_together': {('closure', 'service')},
|
|
89
|
+
},
|
|
90
|
+
),
|
|
91
|
+
migrations.CreateModel(
|
|
92
|
+
name='DataExportPart',
|
|
93
|
+
fields=[
|
|
94
|
+
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
|
95
|
+
('service', models.CharField(max_length=50)),
|
|
96
|
+
('status', models.CharField(choices=[('pending', 'Pending'), ('done', 'Done'), ('failed', 'Failed')], default='pending', max_length=20)),
|
|
97
|
+
('bucket_path', models.CharField(blank=True, max_length=500, null=True)),
|
|
98
|
+
('completed_at', models.DateTimeField(blank=True, null=True)),
|
|
99
|
+
('error', models.TextField(blank=True, null=True)),
|
|
100
|
+
('request', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='parts', to='gdpr.dataexportrequest')),
|
|
101
|
+
],
|
|
102
|
+
options={
|
|
103
|
+
'unique_together': {('request', 'service')},
|
|
104
|
+
},
|
|
105
|
+
),
|
|
106
|
+
]
|