dpdpguard-sdk 1.0.0__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.
- dpdpguard/__init__.py +24 -0
- dpdpguard/_data/__init__.py +0 -0
- dpdpguard/_data/audit-hash-vectors.json +62 -0
- dpdpguard/_data/error-catalog.json +45 -0
- dpdpguard/audit_hash.py +51 -0
- dpdpguard/client.py +221 -0
- dpdpguard/consent_gate.py +22 -0
- dpdpguard/errors.py +51 -0
- dpdpguard/models.py +96 -0
- dpdpguard/py.typed +1 -0
- dpdpguard/webhook_verifier.py +35 -0
- dpdpguard_sdk-1.0.0.dist-info/METADATA +139 -0
- dpdpguard_sdk-1.0.0.dist-info/RECORD +15 -0
- dpdpguard_sdk-1.0.0.dist-info/WHEEL +4 -0
- dpdpguard_sdk-1.0.0.dist-info/licenses/LICENSE +21 -0
dpdpguard/__init__.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""DPDP Guard Server SDK (Python) - typed API client, consent gate, token broker, webhook verifier"""
|
|
2
|
+
|
|
3
|
+
from .audit_hash import AuditHashInput, canonicalize_audit_event, compute_audit_hash
|
|
4
|
+
from .client import DpdpGuardClient
|
|
5
|
+
from .consent_gate import ConsentRecord, has_consent
|
|
6
|
+
from .errors import ERROR_CATALOG, ApiErrorCode, DpdpGuardApiError, ErrorCatalogEntry
|
|
7
|
+
from .webhook_verifier import verify_webhook_signature
|
|
8
|
+
|
|
9
|
+
__version__ = "1.0.0"
|
|
10
|
+
|
|
11
|
+
__all__ = [
|
|
12
|
+
"ERROR_CATALOG",
|
|
13
|
+
"ApiErrorCode",
|
|
14
|
+
"AuditHashInput",
|
|
15
|
+
"ConsentRecord",
|
|
16
|
+
"DpdpGuardApiError",
|
|
17
|
+
"DpdpGuardClient",
|
|
18
|
+
"ErrorCatalogEntry",
|
|
19
|
+
"__version__",
|
|
20
|
+
"canonicalize_audit_event",
|
|
21
|
+
"compute_audit_hash",
|
|
22
|
+
"has_consent",
|
|
23
|
+
"verify_webhook_signature",
|
|
24
|
+
]
|
|
File without changes
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
{
|
|
2
|
+
"_description": "Golden vectors for convex/lib/auditHash.ts (ADR-002 D5). HMAC-SHA256 per DPO sign-off decision (conformance/dpo-sign-off-request.md item 1) — the platform's key, not the fixed test secret used to derive these vectors, is the production key. Do not hand-edit expectedCanonical/expectedHash without re-deriving them from the spec's canonicalization rules and the same test secret documented in audit-hash-spec.md.",
|
|
3
|
+
"testSecret": "test-only-audit-hash-hmac-secret-do-not-use-in-production",
|
|
4
|
+
"vectors": [
|
|
5
|
+
{
|
|
6
|
+
"name": "simple direct grant",
|
|
7
|
+
"input": {
|
|
8
|
+
"organizationId": "org_abc123",
|
|
9
|
+
"noticeId": "notice_v1",
|
|
10
|
+
"noticeVersion": 1,
|
|
11
|
+
"purpose": "Newsletter",
|
|
12
|
+
"dataTypes": ["email"],
|
|
13
|
+
"givenAt": 1700000000000,
|
|
14
|
+
"source": "direct"
|
|
15
|
+
},
|
|
16
|
+
"expectedCanonical": "org_abc123|notice_v1|1|Newsletter|email|1700000000000|direct",
|
|
17
|
+
"expectedHash": "9b72403de808d11a55b473e268407cac809e9c63604f9a14a9d8cd3128990cdd"
|
|
18
|
+
},
|
|
19
|
+
{
|
|
20
|
+
"name": "dataTypes sorted regardless of input order",
|
|
21
|
+
"input": {
|
|
22
|
+
"organizationId": "org_abc123",
|
|
23
|
+
"noticeId": "notice_v1",
|
|
24
|
+
"noticeVersion": 1,
|
|
25
|
+
"purpose": "Marketing",
|
|
26
|
+
"dataTypes": ["phone", "email"],
|
|
27
|
+
"givenAt": 1700000001000,
|
|
28
|
+
"source": "brokered"
|
|
29
|
+
},
|
|
30
|
+
"expectedCanonical": "org_abc123|notice_v1|1|Marketing|email,phone|1700000001000|brokered",
|
|
31
|
+
"expectedHash": "d8d67169d5b11319783e1791d1f191b076d1c9c79c664a6a5ab85ae5500da3ce"
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
"name": "anonymous source, later notice version",
|
|
35
|
+
"input": {
|
|
36
|
+
"organizationId": "org_abc123",
|
|
37
|
+
"noticeId": "notice_v2",
|
|
38
|
+
"noticeVersion": 2,
|
|
39
|
+
"purpose": "Analytics",
|
|
40
|
+
"dataTypes": ["deviceId"],
|
|
41
|
+
"givenAt": 1700000002000,
|
|
42
|
+
"source": "anonymous"
|
|
43
|
+
},
|
|
44
|
+
"expectedCanonical": "org_abc123|notice_v2|2|Analytics|deviceId|1700000002000|anonymous",
|
|
45
|
+
"expectedHash": "deda978c9479971fad39d3a5b7d385bd603e5497b6a99d988ec8cc865cf72dc0"
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
"name": "null source serializes as the literal string \"null\"",
|
|
49
|
+
"input": {
|
|
50
|
+
"organizationId": "org_abc123",
|
|
51
|
+
"noticeId": "notice_v1",
|
|
52
|
+
"noticeVersion": 1,
|
|
53
|
+
"purpose": "Newsletter",
|
|
54
|
+
"dataTypes": ["email"],
|
|
55
|
+
"givenAt": 1700000003000,
|
|
56
|
+
"source": null
|
|
57
|
+
},
|
|
58
|
+
"expectedCanonical": "org_abc123|notice_v1|1|Newsletter|email|1700000003000|null",
|
|
59
|
+
"expectedHash": "687ad4b322efb6ccc620d2d3ef7b8e115f06e74725819e72dd79bf1250d802e8"
|
|
60
|
+
}
|
|
61
|
+
]
|
|
62
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$comment": "ADR-002 D2: the machine-readable error catalog for /api/v1. Mirrors convex/lib/apiErrors.ts's API_ERROR_CODES 1:1 — convex/lib/apiErrors.test.ts enforces this file cannot drift out of sync with the source. Adding a code here is additive (minor); changing an existing code's meaning is breaking (major) under ADR-002 D4.",
|
|
3
|
+
"codes": [
|
|
4
|
+
{
|
|
5
|
+
"code": "MINOR_TRACKING_BLOCKED",
|
|
6
|
+
"description": "Tracking/analytics consent is disabled for a verified minor and cannot be granted."
|
|
7
|
+
},
|
|
8
|
+
{
|
|
9
|
+
"code": "NOTICE_NOT_PUBLISHED",
|
|
10
|
+
"description": "The referenced notice exists but is not in a published state."
|
|
11
|
+
},
|
|
12
|
+
{
|
|
13
|
+
"code": "ALREADY_CONSENTED",
|
|
14
|
+
"description": "A consent decision already exists for this (user, notice, purpose) and the request would duplicate it."
|
|
15
|
+
},
|
|
16
|
+
{
|
|
17
|
+
"code": "NOT_ASSOCIATED_WITH_ORG",
|
|
18
|
+
"description": "The authenticated principal does not belong to the organization the request targets."
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
"code": "INVALID_STATUS_TRANSITION",
|
|
22
|
+
"description": "The requested status change is not a valid transition from the resource's current status."
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
"code": "SDK_VERSION_UNSUPPORTED",
|
|
26
|
+
"description": "The calling SDK's version is below the server's configured minimum-supported floor (ADR-002 D6)."
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
"code": "NOT_FOUND",
|
|
30
|
+
"description": "The requested resource does not exist or is not visible to the authenticated principal."
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
"code": "UNAUTHORIZED",
|
|
34
|
+
"description": "The request is missing valid authentication or the credential does not grant access to this resource."
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
"code": "RATE_LIMITED",
|
|
38
|
+
"description": "The request was rejected because the caller exceeded a rate limit."
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
"code": "VALIDATION_ERROR",
|
|
42
|
+
"description": "The request failed validation and no more specific code applies; the human-readable error message has details."
|
|
43
|
+
}
|
|
44
|
+
]
|
|
45
|
+
}
|
dpdpguard/audit_hash.py
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""Server-side reimplementation of ``convex/lib/auditHash.ts``'s canonicalization
|
|
2
|
+
(conformance/audit-hash-spec.md), so a holder of ``DPDP_AUDIT_HASH_HMAC_SECRET``
|
|
3
|
+
can independently verify a ``consentAuditTrail`` row's ``auditHash`` rather than
|
|
4
|
+
trusting it blindly.
|
|
5
|
+
|
|
6
|
+
Verified against the vendored golden vectors in ``_data/audit-hash-vectors.json``
|
|
7
|
+
(ADR-002 D5) — see ``tests/test_audit_hash.py``. Any change to this
|
|
8
|
+
canonicalization is a **major** version bump, since it breaks comparability of
|
|
9
|
+
the immutable, long-retained audit trail.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import hashlib
|
|
15
|
+
import hmac
|
|
16
|
+
from dataclasses import dataclass
|
|
17
|
+
from typing import Literal, Optional
|
|
18
|
+
|
|
19
|
+
AuditSource = Optional[Literal["brokered", "direct", "anonymous", "linked"]]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass(frozen=True)
|
|
23
|
+
class AuditHashInput:
|
|
24
|
+
organization_id: str
|
|
25
|
+
notice_id: str
|
|
26
|
+
notice_version: int
|
|
27
|
+
purpose: str
|
|
28
|
+
data_types: list[str]
|
|
29
|
+
given_at: int
|
|
30
|
+
source: AuditSource
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def canonicalize_audit_event(input: AuditHashInput) -> str:
|
|
34
|
+
sorted_data_types = ",".join(sorted(input.data_types))
|
|
35
|
+
source = input.source if input.source is not None else "null"
|
|
36
|
+
return "|".join(
|
|
37
|
+
[
|
|
38
|
+
input.organization_id,
|
|
39
|
+
input.notice_id,
|
|
40
|
+
str(input.notice_version),
|
|
41
|
+
input.purpose,
|
|
42
|
+
sorted_data_types,
|
|
43
|
+
str(input.given_at),
|
|
44
|
+
source,
|
|
45
|
+
]
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def compute_audit_hash(input: AuditHashInput, secret: str) -> str:
|
|
50
|
+
canonical = canonicalize_audit_event(input)
|
|
51
|
+
return hmac.new(secret.encode("utf-8"), canonical.encode("utf-8"), hashlib.sha256).hexdigest()
|
dpdpguard/client.py
ADDED
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
"""Thin typed client over DPDP Guard's public ``/api/v1`` (spec §4.2).
|
|
2
|
+
|
|
3
|
+
Mirrors ``@dpdpguard/server`` (the Node/TypeScript reference SDK) method for
|
|
4
|
+
method, so behavior — auth modes, idempotency headers, error mapping — stays
|
|
5
|
+
identical across ecosystems per ADR-001's audit-uniformity goal.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import Literal
|
|
11
|
+
from urllib.parse import quote, urlencode
|
|
12
|
+
|
|
13
|
+
import httpx
|
|
14
|
+
|
|
15
|
+
from .errors import DpdpGuardApiError
|
|
16
|
+
from .models import (
|
|
17
|
+
BannerConfig,
|
|
18
|
+
BrokerTokenResult,
|
|
19
|
+
DsrRequest,
|
|
20
|
+
DsrRequestsResponse,
|
|
21
|
+
DsrType,
|
|
22
|
+
Grievance,
|
|
23
|
+
GrievancesResponse,
|
|
24
|
+
LinkAnonymousConsentResult,
|
|
25
|
+
Nomination,
|
|
26
|
+
Notice,
|
|
27
|
+
NoticesResponse,
|
|
28
|
+
OrgSummary,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
AuthMode = Literal["none", "apiKey", "bearer"]
|
|
32
|
+
|
|
33
|
+
_SDK_VERSION = "1.0.0"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class DpdpGuardClient:
|
|
37
|
+
"""Typed client over ``/api/v1``.
|
|
38
|
+
|
|
39
|
+
Parameters
|
|
40
|
+
----------
|
|
41
|
+
base_url:
|
|
42
|
+
e.g. ``"https://trustworthy-alligator-303.convex.site"``.
|
|
43
|
+
api_key:
|
|
44
|
+
Service API key (``convex/apiKeys.ts``) — required for :meth:`broker_token`.
|
|
45
|
+
access_token:
|
|
46
|
+
A brokered principal access token, if you already minted one.
|
|
47
|
+
http_client:
|
|
48
|
+
Override for testing/transport customization; defaults to a new
|
|
49
|
+
:class:`httpx.Client`. The client owns and closes it unless one was
|
|
50
|
+
passed in.
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
def __init__(
|
|
54
|
+
self,
|
|
55
|
+
base_url: str,
|
|
56
|
+
*,
|
|
57
|
+
api_key: str | None = None,
|
|
58
|
+
access_token: str | None = None,
|
|
59
|
+
http_client: httpx.Client | None = None,
|
|
60
|
+
) -> None:
|
|
61
|
+
self._base_url = base_url.rstrip("/")
|
|
62
|
+
self._api_key = api_key
|
|
63
|
+
self._access_token = access_token
|
|
64
|
+
self._owns_http_client = http_client is None
|
|
65
|
+
self._http = http_client if http_client is not None else httpx.Client()
|
|
66
|
+
|
|
67
|
+
def close(self) -> None:
|
|
68
|
+
if self._owns_http_client:
|
|
69
|
+
self._http.close()
|
|
70
|
+
|
|
71
|
+
def __enter__(self) -> DpdpGuardClient: # noqa: PYI034 (avoid a typing_extensions dep for Self on py3.9-3.10)
|
|
72
|
+
return self
|
|
73
|
+
|
|
74
|
+
def __exit__(self, *exc_info: object) -> None:
|
|
75
|
+
self.close()
|
|
76
|
+
|
|
77
|
+
def set_access_token(self, token: str) -> None:
|
|
78
|
+
"""Set/replace the brokered principal access token (e.g. after re-brokering on expiry)."""
|
|
79
|
+
self._access_token = token
|
|
80
|
+
|
|
81
|
+
def _request(
|
|
82
|
+
self,
|
|
83
|
+
method: str,
|
|
84
|
+
path: str,
|
|
85
|
+
*,
|
|
86
|
+
json: dict[str, object] | None = None,
|
|
87
|
+
auth: AuthMode = "bearer",
|
|
88
|
+
headers: dict[str, str] | None = None,
|
|
89
|
+
) -> object:
|
|
90
|
+
request_headers = {"X-DPDP-SDK": f"python/{_SDK_VERSION}"}
|
|
91
|
+
if headers:
|
|
92
|
+
request_headers.update(headers)
|
|
93
|
+
|
|
94
|
+
if auth == "apiKey":
|
|
95
|
+
if not self._api_key:
|
|
96
|
+
raise RuntimeError(
|
|
97
|
+
"DpdpGuardClient: this call requires `api_key` to be set in the constructor."
|
|
98
|
+
)
|
|
99
|
+
request_headers["Authorization"] = f"Bearer {self._api_key}"
|
|
100
|
+
elif auth == "bearer":
|
|
101
|
+
if not self._access_token:
|
|
102
|
+
raise RuntimeError(
|
|
103
|
+
"DpdpGuardClient: this call requires an access token — call "
|
|
104
|
+
"broker_token() first, or pass `access_token` in the constructor."
|
|
105
|
+
)
|
|
106
|
+
request_headers["Authorization"] = f"Bearer {self._access_token}"
|
|
107
|
+
|
|
108
|
+
response = self._http.request(
|
|
109
|
+
method,
|
|
110
|
+
f"{self._base_url}{path}",
|
|
111
|
+
json=json,
|
|
112
|
+
headers=request_headers,
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
body = response.text
|
|
116
|
+
data = response.json() if body else None
|
|
117
|
+
|
|
118
|
+
if not response.is_success:
|
|
119
|
+
code = "VALIDATION_ERROR"
|
|
120
|
+
message = f"Request failed with status {response.status_code}"
|
|
121
|
+
if isinstance(data, dict):
|
|
122
|
+
code = str(data.get("code", code))
|
|
123
|
+
message = str(data.get("error", message))
|
|
124
|
+
raise DpdpGuardApiError(code, message, response.status_code)
|
|
125
|
+
|
|
126
|
+
return data
|
|
127
|
+
|
|
128
|
+
# --- Public reads (no auth) ---
|
|
129
|
+
|
|
130
|
+
def get_organization(self, slug: str) -> OrgSummary:
|
|
131
|
+
return self._request("GET", f"/api/v1/org/{quote(slug)}", auth="none") # type: ignore[return-value]
|
|
132
|
+
|
|
133
|
+
def get_notices(self, org_id: str) -> NoticesResponse:
|
|
134
|
+
return self._request( # type: ignore[return-value]
|
|
135
|
+
"GET", f"/api/v1/org/{quote(org_id)}/notices", auth="none"
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
def get_notice(self, notice_id: str) -> Notice:
|
|
139
|
+
return self._request("GET", f"/api/v1/notices/{quote(notice_id)}", auth="none") # type: ignore[return-value]
|
|
140
|
+
|
|
141
|
+
def get_banner_config(
|
|
142
|
+
self,
|
|
143
|
+
org_id: str,
|
|
144
|
+
*,
|
|
145
|
+
domain: str | None = None,
|
|
146
|
+
app_id: str | None = None,
|
|
147
|
+
) -> BannerConfig:
|
|
148
|
+
query = {}
|
|
149
|
+
if domain:
|
|
150
|
+
query["domain"] = domain
|
|
151
|
+
if app_id:
|
|
152
|
+
query["appId"] = app_id
|
|
153
|
+
qs = f"?{urlencode(query)}" if query else ""
|
|
154
|
+
return self._request( # type: ignore[return-value]
|
|
155
|
+
"GET", f"/api/v1/org/{quote(org_id)}/banner-config{qs}", auth="none"
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
# --- Auth (ADR-004) ---
|
|
159
|
+
|
|
160
|
+
def broker_token(self, external_id: str) -> BrokerTokenResult:
|
|
161
|
+
"""Mints a brokered principal access token using the service API key,
|
|
162
|
+
and stores it for subsequent calls."""
|
|
163
|
+
result: BrokerTokenResult = self._request( # type: ignore[assignment]
|
|
164
|
+
"POST",
|
|
165
|
+
"/api/v1/auth/broker-token",
|
|
166
|
+
json={"externalId": external_id},
|
|
167
|
+
auth="apiKey",
|
|
168
|
+
)
|
|
169
|
+
self._access_token = result["accessToken"]
|
|
170
|
+
return result
|
|
171
|
+
|
|
172
|
+
def link_anonymous_consent(self, anonymous_id: str) -> LinkAnonymousConsentResult:
|
|
173
|
+
return self._request( # type: ignore[return-value]
|
|
174
|
+
"POST", "/api/v1/link-anonymous-consent", json={"anonymousId": anonymous_id}
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
# --- DSR (spec §4.2) ---
|
|
178
|
+
|
|
179
|
+
def list_dsr_requests(self) -> DsrRequestsResponse:
|
|
180
|
+
return self._request("GET", "/api/v1/dsr") # type: ignore[return-value]
|
|
181
|
+
|
|
182
|
+
def create_dsr_request(
|
|
183
|
+
self,
|
|
184
|
+
*,
|
|
185
|
+
organization_id: str,
|
|
186
|
+
type: DsrType,
|
|
187
|
+
details: str | None = None,
|
|
188
|
+
idempotency_key: str | None = None,
|
|
189
|
+
) -> DsrRequest:
|
|
190
|
+
body: dict[str, object] = {"organizationId": organization_id, "type": type}
|
|
191
|
+
if details is not None:
|
|
192
|
+
body["details"] = details
|
|
193
|
+
headers = {"Idempotency-Key": idempotency_key} if idempotency_key else None
|
|
194
|
+
return self._request("POST", "/api/v1/dsr", json=body, headers=headers) # type: ignore[return-value]
|
|
195
|
+
|
|
196
|
+
# --- Grievances (spec §4.2) ---
|
|
197
|
+
|
|
198
|
+
def list_grievances(self) -> GrievancesResponse:
|
|
199
|
+
return self._request("GET", "/api/v1/grievances") # type: ignore[return-value]
|
|
200
|
+
|
|
201
|
+
def create_grievance(self, *, organization_id: str, subject: str, description: str) -> Grievance:
|
|
202
|
+
return self._request( # type: ignore[return-value]
|
|
203
|
+
"POST",
|
|
204
|
+
"/api/v1/grievances",
|
|
205
|
+
json={"organizationId": organization_id, "subject": subject, "description": description},
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
# --- Nomination (spec §4.2) ---
|
|
209
|
+
|
|
210
|
+
def get_nomination(self) -> Nomination | None:
|
|
211
|
+
return self._request("GET", "/api/v1/nomination") # type: ignore[return-value]
|
|
212
|
+
|
|
213
|
+
def upsert_nomination(self, *, nominee_name: str, nominee_contact: str) -> Nomination:
|
|
214
|
+
return self._request( # type: ignore[return-value]
|
|
215
|
+
"PUT",
|
|
216
|
+
"/api/v1/nomination",
|
|
217
|
+
json={"nomineeName": nominee_name, "nomineeContact": nominee_contact},
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
def revoke_nomination(self) -> dict[str, object]:
|
|
221
|
+
return self._request("DELETE", "/api/v1/nomination") # type: ignore[return-value]
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""Pure helper for gating a feature/tracker on an existing consent decision —
|
|
2
|
+
e.g. ``if not has_consent(consents, "Analytics"): return`` before firing an
|
|
3
|
+
analytics event. Does not call the network; combine with
|
|
4
|
+
:meth:`~dpdpguard.client.DpdpGuardClient.list_dsr_requests`-style reads or
|
|
5
|
+
your own cached consent state.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from collections.abc import Sequence
|
|
11
|
+
from typing import Protocol
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class ConsentRecord(Protocol):
|
|
15
|
+
"""Anything with a ``purpose`` and an optional ``withdrawn_at``."""
|
|
16
|
+
|
|
17
|
+
purpose: str
|
|
18
|
+
withdrawn_at: int | None
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def has_consent(consents: Sequence[ConsentRecord], purpose: str) -> bool:
|
|
22
|
+
return any(c.purpose == purpose and c.withdrawn_at is None for c in consents)
|
dpdpguard/errors.py
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""ADR-002 D2's typed error-code catalog for ``/api/v1``.
|
|
2
|
+
|
|
3
|
+
Vendored from ``@dpdpguard/contract/conformance/error-catalog.json`` (see
|
|
4
|
+
``_data/error-catalog.json``) rather than hand-copied, so drift from the
|
|
5
|
+
platform's canonical catalog is a file diff, not a silent mismatch.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
from importlib import resources
|
|
12
|
+
from typing import Literal, TypedDict
|
|
13
|
+
|
|
14
|
+
ApiErrorCode = Literal[
|
|
15
|
+
"MINOR_TRACKING_BLOCKED",
|
|
16
|
+
"NOTICE_NOT_PUBLISHED",
|
|
17
|
+
"ALREADY_CONSENTED",
|
|
18
|
+
"NOT_ASSOCIATED_WITH_ORG",
|
|
19
|
+
"INVALID_STATUS_TRANSITION",
|
|
20
|
+
"SDK_VERSION_UNSUPPORTED",
|
|
21
|
+
"NOT_FOUND",
|
|
22
|
+
"UNAUTHORIZED",
|
|
23
|
+
"RATE_LIMITED",
|
|
24
|
+
"VALIDATION_ERROR",
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class ErrorCatalogEntry(TypedDict):
|
|
29
|
+
code: str
|
|
30
|
+
description: str
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _load_catalog() -> list[ErrorCatalogEntry]:
|
|
34
|
+
raw = resources.files("dpdpguard._data").joinpath("error-catalog.json").read_text("utf-8")
|
|
35
|
+
codes: list[ErrorCatalogEntry] = json.loads(raw)["codes"]
|
|
36
|
+
return codes
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
ERROR_CATALOG: list[ErrorCatalogEntry] = _load_catalog()
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class DpdpGuardApiError(Exception):
|
|
43
|
+
"""Raised by :class:`~dpdpguard.client.DpdpGuardClient` for any non-2xx response."""
|
|
44
|
+
|
|
45
|
+
def __init__(self, code: str, message: str, status: int) -> None:
|
|
46
|
+
super().__init__(message)
|
|
47
|
+
self.code = code
|
|
48
|
+
self.status = status
|
|
49
|
+
|
|
50
|
+
def __repr__(self) -> str: # pragma: no cover - debugging aid
|
|
51
|
+
return f"DpdpGuardApiError(code={self.code!r}, status={self.status!r}, message={str(self)!r})"
|
dpdpguard/models.py
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""Typed wire shapes for DPDP Guard's public ``/api/v1`` (spec §4.2), hand-kept
|
|
2
|
+
in sync with ``openapi/v1.yaml``'s ``components.schemas``. Field names are the
|
|
3
|
+
exact JSON keys (camelCase) the API sends/expects — no snake_case remapping —
|
|
4
|
+
so what you see here is what's on the wire.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typing import TypedDict
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class OrgSummary(TypedDict):
|
|
13
|
+
orgId: str
|
|
14
|
+
name: str
|
|
15
|
+
slug: str
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class NoticeItemized(TypedDict):
|
|
19
|
+
purpose: str
|
|
20
|
+
personalDataDescription: str
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class Notice(TypedDict):
|
|
24
|
+
_id: str
|
|
25
|
+
organizationId: str
|
|
26
|
+
version: int
|
|
27
|
+
itemized: list[NoticeItemized]
|
|
28
|
+
content: dict[str, object]
|
|
29
|
+
status: str # "draft" | "published"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class _DsrRequestRequired(TypedDict):
|
|
33
|
+
requestId: str
|
|
34
|
+
type: str # "summary" | "processors" | "correction" | "erasure"
|
|
35
|
+
status: str # "pending" | "in_progress" | "completed" | "rejected"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class DsrRequest(_DsrRequestRequired, total=False):
|
|
39
|
+
dueAt: int
|
|
40
|
+
requestDetails: str
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class _GrievanceRequired(TypedDict):
|
|
44
|
+
grievanceId: str
|
|
45
|
+
subject: str
|
|
46
|
+
description: str
|
|
47
|
+
status: str # "open" | "in_progress" | "resolved"
|
|
48
|
+
dueAt: int
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class Grievance(_GrievanceRequired, total=False):
|
|
52
|
+
respondedAt: int
|
|
53
|
+
response: str
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class Nomination(TypedDict):
|
|
57
|
+
nominationId: str
|
|
58
|
+
nomineeName: str
|
|
59
|
+
nomineeContact: str
|
|
60
|
+
status: str # "active" | "revoked"
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class BannerConfig(TypedDict, total=False):
|
|
64
|
+
bannerSettings: dict[str, object]
|
|
65
|
+
configVersion: int
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class BrokerTokenResult(TypedDict):
|
|
69
|
+
accessToken: str
|
|
70
|
+
expiresAt: int
|
|
71
|
+
tokenType: str
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class NeedsReconsentEntry(TypedDict):
|
|
75
|
+
noticeId: str
|
|
76
|
+
purpose: str
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class LinkAnonymousConsentResult(TypedDict):
|
|
80
|
+
linkedCount: int
|
|
81
|
+
needsReconsent: list[NeedsReconsentEntry]
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class NoticesResponse(TypedDict):
|
|
85
|
+
notices: list[Notice]
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class DsrRequestsResponse(TypedDict):
|
|
89
|
+
requests: list[DsrRequest]
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class GrievancesResponse(TypedDict):
|
|
93
|
+
grievances: list[Grievance]
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
DsrType = str # "summary" | "processors" | "correction" | "erasure"
|
dpdpguard/py.typed
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""Verifies the ``X-DPDP-Signature`` header set by ``convex/webhooks.ts``'s
|
|
2
|
+
``deliverToEndpoint`` (HMAC-SHA256 over the raw JSON body, hex-encoded, using
|
|
3
|
+
the webhook endpoint's own secret — not the audit-hash secret).
|
|
4
|
+
|
|
5
|
+
``raw_body`` must be the exact bytes/string received on the wire, before any
|
|
6
|
+
``json.loads`` — HMACs are sensitive to whitespace/key-order, so
|
|
7
|
+
re-serializing a parsed object and hashing that will not match.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import hashlib
|
|
13
|
+
import hmac
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def verify_webhook_signature(
|
|
17
|
+
secret: str,
|
|
18
|
+
raw_body: str | bytes,
|
|
19
|
+
signature_header: str | None,
|
|
20
|
+
) -> bool:
|
|
21
|
+
if not signature_header:
|
|
22
|
+
return False
|
|
23
|
+
|
|
24
|
+
body_bytes = raw_body.encode("utf-8") if isinstance(raw_body, str) else raw_body
|
|
25
|
+
expected = hmac.new(secret.encode("utf-8"), body_bytes, hashlib.sha256).hexdigest()
|
|
26
|
+
|
|
27
|
+
try:
|
|
28
|
+
expected_bytes = bytes.fromhex(expected)
|
|
29
|
+
actual_bytes = bytes.fromhex(signature_header)
|
|
30
|
+
except ValueError:
|
|
31
|
+
return False
|
|
32
|
+
|
|
33
|
+
if len(expected_bytes) != len(actual_bytes):
|
|
34
|
+
return False
|
|
35
|
+
return hmac.compare_digest(expected_bytes, actual_bytes)
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: dpdpguard-sdk
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: DPDP Guard Server SDK (Python) - typed API client, consent gate, token broker, webhook verifier
|
|
5
|
+
Project-URL: Homepage, https://github.com/dpdp-guard-ai/dpdpguard-python-sdk
|
|
6
|
+
Project-URL: Spec, https://github.com/chintans/dpdpbot/blob/main/docs/specs/mobile-server-sdk.md
|
|
7
|
+
Author: DPDP Guard
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
19
|
+
Classifier: Typing :: Typed
|
|
20
|
+
Requires-Python: >=3.9
|
|
21
|
+
Requires-Dist: httpx>=0.27
|
|
22
|
+
Provides-Extra: dev
|
|
23
|
+
Requires-Dist: mypy>=1.10; extra == 'dev'
|
|
24
|
+
Requires-Dist: pytest-cov>=5; extra == 'dev'
|
|
25
|
+
Requires-Dist: pytest>=8; extra == 'dev'
|
|
26
|
+
Requires-Dist: ruff>=0.5; extra == 'dev'
|
|
27
|
+
Description-Content-Type: text/markdown
|
|
28
|
+
|
|
29
|
+
# dpdpguard-python-sdk
|
|
30
|
+
|
|
31
|
+
DPDP Guard Server SDK (Python) — typed API client, consent gate, token broker
|
|
32
|
+
helper, audit-hash verifier, and webhook signature verifier over DPDP Guard's
|
|
33
|
+
public `/api/v1` (spec §4.2).
|
|
34
|
+
|
|
35
|
+
PyPI distribution: `dpdpguard-sdk` · Import as: `dpdpguard`
|
|
36
|
+
|
|
37
|
+
Part of the DPDP Guard SDK family. See the design spec:
|
|
38
|
+
https://github.com/chintans/dpdpbot/blob/main/docs/specs/mobile-server-sdk.md
|
|
39
|
+
|
|
40
|
+
This SDK mirrors [`@dpdpguard/server`](https://www.npmjs.com/package/@dpdpguard/server)
|
|
41
|
+
(the Node/TypeScript reference implementation) method for method, so behavior —
|
|
42
|
+
auth modes, idempotency headers, error codes, audit-hash canonicalization — stays
|
|
43
|
+
identical across ecosystems (ADR-001's audit-uniformity goal).
|
|
44
|
+
|
|
45
|
+
## Install
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
pip install dpdpguard-sdk
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## Usage
|
|
52
|
+
|
|
53
|
+
```python
|
|
54
|
+
import os
|
|
55
|
+
from dpdpguard import DpdpGuardClient, has_consent, verify_webhook_signature
|
|
56
|
+
|
|
57
|
+
client = DpdpGuardClient(
|
|
58
|
+
"https://<your-deployment>.convex.site",
|
|
59
|
+
api_key=os.environ["DPDP_SERVICE_API_KEY"], # convex/apiKeys.ts, for broker_token() only
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
# Mint a brokered principal access token (ADR-004 D1/D2) for a known user.
|
|
63
|
+
client.broker_token(external_id)
|
|
64
|
+
|
|
65
|
+
# Now authenticated calls use that token automatically.
|
|
66
|
+
result = client.list_dsr_requests()
|
|
67
|
+
client.create_dsr_request(organization_id=org_id, type="erasure")
|
|
68
|
+
|
|
69
|
+
# Public reads need no auth at all.
|
|
70
|
+
org = client.get_organization("acme")
|
|
71
|
+
notices = client.get_notices(org["orgId"])
|
|
72
|
+
|
|
73
|
+
# Verify an inbound webhook (convex/webhooks.ts's X-DPDP-Signature header).
|
|
74
|
+
ok = verify_webhook_signature(webhook_secret, raw_body, request.headers.get("x-dpdp-signature"))
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
Use it as a context manager to close the underlying `httpx.Client` automatically:
|
|
78
|
+
|
|
79
|
+
```python
|
|
80
|
+
with DpdpGuardClient(base_url) as client:
|
|
81
|
+
org = client.get_organization("acme")
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
Every non-2xx response raises a `DpdpGuardApiError` with a `code` from the
|
|
85
|
+
ADR-002 error catalog (`err.code`, e.g. `"NOT_FOUND"`) and the HTTP `status`.
|
|
86
|
+
|
|
87
|
+
### Consent gate
|
|
88
|
+
|
|
89
|
+
```python
|
|
90
|
+
from dpdpguard import has_consent
|
|
91
|
+
|
|
92
|
+
if not has_consent(consents, "Marketing"):
|
|
93
|
+
return # don't send the campaign
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
### Audit-hash verification
|
|
97
|
+
|
|
98
|
+
A holder of the platform's `DPDP_AUDIT_HASH_HMAC_SECRET` can independently
|
|
99
|
+
verify a `consentAuditTrail` row's `auditHash`:
|
|
100
|
+
|
|
101
|
+
```python
|
|
102
|
+
from dpdpguard import AuditHashInput, compute_audit_hash
|
|
103
|
+
|
|
104
|
+
input = AuditHashInput(
|
|
105
|
+
organization_id="org_abc123",
|
|
106
|
+
notice_id="notice_v1",
|
|
107
|
+
notice_version=1,
|
|
108
|
+
purpose="Newsletter",
|
|
109
|
+
data_types=["email"],
|
|
110
|
+
given_at=1700000000000,
|
|
111
|
+
source="direct",
|
|
112
|
+
)
|
|
113
|
+
assert compute_audit_hash(input, secret) == stored_audit_hash
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
## Contract
|
|
117
|
+
|
|
118
|
+
This package vendors its wire contract from `dpdpbot`'s single source of
|
|
119
|
+
truth (ADR-001/002) rather than depending on a published `@dpdpguard/contract`
|
|
120
|
+
Python package (none exists yet):
|
|
121
|
+
|
|
122
|
+
- `src/dpdpguard/models.py` is hand-kept in sync with `openapi/v1.yaml`'s
|
|
123
|
+
`components.schemas`.
|
|
124
|
+
- `src/dpdpguard/errors.py` loads `_data/error-catalog.json`, vendored
|
|
125
|
+
verbatim from `@dpdpguard/contract/conformance/error-catalog.json`
|
|
126
|
+
(ADR-002 D2).
|
|
127
|
+
- `tests/test_audit_hash.py` runs `_data/audit-hash-vectors.json` (vendored
|
|
128
|
+
from `@dpdpguard/contract/conformance/audit-hash-vectors.json`) as a
|
|
129
|
+
required CI gate (ADR-002 D5) — a failure means this SDK's `audit_hash.py`
|
|
130
|
+
has drifted from `convex/lib/auditHash.ts`.
|
|
131
|
+
|
|
132
|
+
## Development
|
|
133
|
+
|
|
134
|
+
```bash
|
|
135
|
+
pip install -e ".[dev]"
|
|
136
|
+
ruff check .
|
|
137
|
+
mypy src
|
|
138
|
+
pytest --cov=src --cov-report=term-missing
|
|
139
|
+
```
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
dpdpguard/__init__.py,sha256=HKgBmFPNYJr3tJ_32zWu9dUzpSjbQ6uePwAmIbfX6RQ,741
|
|
2
|
+
dpdpguard/audit_hash.py,sha256=qZ0rLynkdd9Ir7WySdKPh-yb6f_bbH5KA28elQlf5MA,1621
|
|
3
|
+
dpdpguard/client.py,sha256=o3mnXZEn-oFNImfssWtvlKJUnLYcuw-ueXB86ZwgUH8,7659
|
|
4
|
+
dpdpguard/consent_gate.py,sha256=hbXjkUh4M4akgS9pxctvi8Ppfqee_KEcl3y3JVCDLik,735
|
|
5
|
+
dpdpguard/errors.py,sha256=2fjPFCPVrZ8P3VlTPiokw8cQ0h7U4zKDEhcvZG5vcpc,1496
|
|
6
|
+
dpdpguard/models.py,sha256=mQmtG20hykGRqrijb1tFpzkg8Vqw1gYIjZpjPpXAFes,2061
|
|
7
|
+
dpdpguard/py.typed,sha256=AbpHGcgLb-kRsJGnwFEktk7uzpZOCcBY74-YBdrKVGs,1
|
|
8
|
+
dpdpguard/webhook_verifier.py,sha256=MMi-nGtDlq51RbQBhYE9gfXLXVSlDyNCtgjH90vvT5g,1132
|
|
9
|
+
dpdpguard/_data/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
10
|
+
dpdpguard/_data/audit-hash-vectors.json,sha256=pYWH7mrlj4bzxSbMtoE5oGCpnK1BBCzrIdUEHQbWMvg,2371
|
|
11
|
+
dpdpguard/_data/error-catalog.json,sha256=WdHQT9pvtEOlJsIc2mUihwndEy6oK9zZLX6D35u-SPo,1966
|
|
12
|
+
dpdpguard_sdk-1.0.0.dist-info/METADATA,sha256=jKiK7TFK2NVHIdZIJpu4ZhUngVQyAH4ic8TABvSc5Zo,4609
|
|
13
|
+
dpdpguard_sdk-1.0.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
|
|
14
|
+
dpdpguard_sdk-1.0.0.dist-info/licenses/LICENSE,sha256=tIblXvn9CRrr69XHSSfcMqRvnjP9g9bdfArbTQRCVs8,1098
|
|
15
|
+
dpdpguard_sdk-1.0.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License Copyright (c) 2026 Chintan Shah
|
|
2
|
+
|
|
3
|
+
Permission is hereby granted, free
|
|
4
|
+
of charge, to any person obtaining a copy of this software and associated
|
|
5
|
+
documentation files (the "Software"), to deal in the Software without
|
|
6
|
+
restriction, including without limitation the rights to use, copy, modify, merge,
|
|
7
|
+
publish, distribute, sublicense, and/or sell copies of the Software, and to
|
|
8
|
+
permit persons to whom the Software is furnished to do so, subject to the
|
|
9
|
+
following conditions:
|
|
10
|
+
|
|
11
|
+
The above copyright notice and this permission notice
|
|
12
|
+
(including the next paragraph) shall be included in all copies or substantial
|
|
13
|
+
portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF
|
|
16
|
+
ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
|
17
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO
|
|
18
|
+
EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR
|
|
19
|
+
OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
|
20
|
+
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
21
|
+
THE SOFTWARE.
|