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 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
+ }
@@ -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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.31.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -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.