cmp-consent 0.1.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- cmp_consent/__init__.py +36 -0
- cmp_consent/aio.py +118 -0
- cmp_consent/client.py +166 -0
- cmp_consent/errors.py +32 -0
- cmp_consent/models.py +78 -0
- cmp_consent/webhooks.py +48 -0
- cmp_consent-0.1.1.dist-info/METADATA +129 -0
- cmp_consent-0.1.1.dist-info/RECORD +9 -0
- cmp_consent-0.1.1.dist-info/WHEEL +4 -0
cmp_consent/__init__.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""cmp-consent — Python SDK for the CMP officer-assisted deferred consent flow.
|
|
2
|
+
|
|
3
|
+
Server-to-server: raise consent requests, gate PII processing on verify(), and
|
|
4
|
+
validate CMP's signed webhooks. The application API key stays on your backend.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from .aio import AsyncCMPClient
|
|
10
|
+
from .client import CMPClient
|
|
11
|
+
from .errors import (
|
|
12
|
+
CMPError,
|
|
13
|
+
ConsentDeclined,
|
|
14
|
+
ConsentExpired,
|
|
15
|
+
ConsentPending,
|
|
16
|
+
SignatureError,
|
|
17
|
+
)
|
|
18
|
+
from .models import ConsentRequest, RequestStatus, VerifyResult, WebhookEvent
|
|
19
|
+
from .webhooks import verify_signature
|
|
20
|
+
|
|
21
|
+
__version__ = "0.1.1"
|
|
22
|
+
|
|
23
|
+
__all__ = [
|
|
24
|
+
"CMPClient",
|
|
25
|
+
"AsyncCMPClient",
|
|
26
|
+
"ConsentRequest",
|
|
27
|
+
"RequestStatus",
|
|
28
|
+
"VerifyResult",
|
|
29
|
+
"WebhookEvent",
|
|
30
|
+
"verify_signature",
|
|
31
|
+
"CMPError",
|
|
32
|
+
"ConsentPending",
|
|
33
|
+
"ConsentDeclined",
|
|
34
|
+
"ConsentExpired",
|
|
35
|
+
"SignatureError",
|
|
36
|
+
]
|
cmp_consent/aio.py
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
"""Asynchronous CMP consent client — the awaitable mirror of CMPClient."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from typing import Any, Mapping
|
|
7
|
+
|
|
8
|
+
import httpx
|
|
9
|
+
|
|
10
|
+
from .client import _envelope_error, _raise_not_allowed
|
|
11
|
+
from .errors import CMPError, SignatureError
|
|
12
|
+
from .models import ConsentRequest, RequestStatus, VerifyResult, WebhookEvent
|
|
13
|
+
from .webhooks import verify_signature
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class AsyncCMPClient:
|
|
17
|
+
def __init__(
|
|
18
|
+
self,
|
|
19
|
+
api_base: str,
|
|
20
|
+
api_key: str,
|
|
21
|
+
*,
|
|
22
|
+
webhook_secret: str | None = None,
|
|
23
|
+
timeout: float = 10.0,
|
|
24
|
+
retries: int = 2,
|
|
25
|
+
):
|
|
26
|
+
self.api_base = api_base.rstrip("/")
|
|
27
|
+
self.api_key = api_key
|
|
28
|
+
self.webhook_secret = webhook_secret
|
|
29
|
+
self._http = httpx.AsyncClient(
|
|
30
|
+
base_url=self.api_base,
|
|
31
|
+
timeout=timeout,
|
|
32
|
+
headers={"X-API-Key": api_key},
|
|
33
|
+
transport=httpx.AsyncHTTPTransport(retries=retries),
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
async def aclose(self) -> None:
|
|
37
|
+
await self._http.aclose()
|
|
38
|
+
|
|
39
|
+
async def __aenter__(self) -> "AsyncCMPClient":
|
|
40
|
+
return self
|
|
41
|
+
|
|
42
|
+
async def __aexit__(self, *_: Any) -> None:
|
|
43
|
+
await self.aclose()
|
|
44
|
+
|
|
45
|
+
async def _request(self, method: str, path: str, **kw: Any) -> Any:
|
|
46
|
+
try:
|
|
47
|
+
resp = await self._http.request(method, path, **kw)
|
|
48
|
+
except httpx.HTTPError as e:
|
|
49
|
+
raise CMPError(f"request failed: {e}") from e
|
|
50
|
+
if resp.status_code >= 400:
|
|
51
|
+
raise _envelope_error(resp)
|
|
52
|
+
return resp.json() if resp.content else {}
|
|
53
|
+
|
|
54
|
+
async def create_consent_request(
|
|
55
|
+
self,
|
|
56
|
+
*,
|
|
57
|
+
email: str,
|
|
58
|
+
notice: str,
|
|
59
|
+
requested_by: str,
|
|
60
|
+
idempotency_key: str | None = None,
|
|
61
|
+
link_expire_seconds: int | None = None,
|
|
62
|
+
) -> ConsentRequest:
|
|
63
|
+
"""See CMPClient.create_consent_request. One request covers one notice."""
|
|
64
|
+
body: dict[str, Any] = {
|
|
65
|
+
"email": email,
|
|
66
|
+
"notice_key": notice,
|
|
67
|
+
"requested_by": requested_by,
|
|
68
|
+
}
|
|
69
|
+
if idempotency_key:
|
|
70
|
+
body["idempotency_key"] = idempotency_key
|
|
71
|
+
if link_expire_seconds:
|
|
72
|
+
body["link_expire_seconds"] = link_expire_seconds
|
|
73
|
+
return ConsentRequest(
|
|
74
|
+
**await self._request("POST", "/api/v1/consent/request/s2s", json=body)
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
async def get_request(self, request_id: str) -> RequestStatus:
|
|
78
|
+
return RequestStatus(
|
|
79
|
+
**await self._request("GET", "/api/v1/consent/request/status",
|
|
80
|
+
params={"request_id": request_id})
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
async def cancel(self, request_id: str) -> dict[str, Any]:
|
|
84
|
+
return await self._request("POST", f"/api/v1/consent/request/{request_id}/cancel")
|
|
85
|
+
|
|
86
|
+
async def resend(self, request_id: str) -> dict[str, Any]:
|
|
87
|
+
return await self._request("POST", f"/api/v1/consent/request/{request_id}/resend")
|
|
88
|
+
|
|
89
|
+
async def verify(self, *, consent_id: str, purpose: str) -> VerifyResult:
|
|
90
|
+
"""Fail-closed gate (see CMPClient.verify)."""
|
|
91
|
+
try:
|
|
92
|
+
data = await self._request(
|
|
93
|
+
"POST", "/api/v1/consent/verify",
|
|
94
|
+
json={"consent_id": consent_id, "purpose": purpose},
|
|
95
|
+
)
|
|
96
|
+
except CMPError:
|
|
97
|
+
return VerifyResult(
|
|
98
|
+
allowed=False, status="UNKNOWN", purpose=purpose,
|
|
99
|
+
purpose_state="NOT_PRESENTED", reason="VERIFY_UNAVAILABLE",
|
|
100
|
+
)
|
|
101
|
+
return VerifyResult(**data)
|
|
102
|
+
|
|
103
|
+
async def require_consent(self, *, consent_id: str, purpose: str) -> VerifyResult:
|
|
104
|
+
res = await self.verify(consent_id=consent_id, purpose=purpose)
|
|
105
|
+
if not res.allowed:
|
|
106
|
+
_raise_not_allowed(res)
|
|
107
|
+
return res
|
|
108
|
+
|
|
109
|
+
def verify_webhook(self, headers: Mapping[str, str], body: bytes | str) -> WebhookEvent:
|
|
110
|
+
"""Local (no I/O) — same as the sync client."""
|
|
111
|
+
if not self.webhook_secret:
|
|
112
|
+
raise CMPError("verify_webhook requires webhook_secret at construction")
|
|
113
|
+
sig = headers.get("X-CMP-Signature") or headers.get("x-cmp-signature")
|
|
114
|
+
if not sig:
|
|
115
|
+
raise SignatureError("missing X-CMP-Signature header")
|
|
116
|
+
raw = body.encode() if isinstance(body, str) else body
|
|
117
|
+
verify_signature(self.webhook_secret, raw, sig)
|
|
118
|
+
return WebhookEvent(**json.loads(raw))
|
cmp_consent/client.py
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
"""Synchronous CMP consent client.
|
|
2
|
+
|
|
3
|
+
Server-to-server only — holds the application API key. Never ship the key to a
|
|
4
|
+
browser. See AsyncCMPClient for the awaitable variant.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
from typing import Any, Mapping
|
|
11
|
+
|
|
12
|
+
import httpx
|
|
13
|
+
|
|
14
|
+
from .errors import CMPError, ConsentDeclined, ConsentExpired, ConsentPending, SignatureError
|
|
15
|
+
from .models import ConsentRequest, RequestStatus, VerifyResult, WebhookEvent
|
|
16
|
+
from .webhooks import verify_signature
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _envelope_error(resp: httpx.Response) -> CMPError:
|
|
20
|
+
code, message = "error", resp.text or f"HTTP {resp.status_code}"
|
|
21
|
+
try:
|
|
22
|
+
body = resp.json()
|
|
23
|
+
err = body.get("error") or body.get("detail") or {}
|
|
24
|
+
if isinstance(err, dict):
|
|
25
|
+
code = err.get("code", code)
|
|
26
|
+
message = err.get("message", message)
|
|
27
|
+
elif isinstance(err, str):
|
|
28
|
+
message = err
|
|
29
|
+
except Exception: # noqa: BLE001 — non-JSON error body
|
|
30
|
+
pass
|
|
31
|
+
return CMPError(message, status=resp.status_code, code=code)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _raise_not_allowed(res: VerifyResult) -> None:
|
|
35
|
+
"""Map a not-allowed VerifyResult onto a typed exception for gate flow."""
|
|
36
|
+
st = res.status
|
|
37
|
+
if st in ("EXPIRED", "CONSENT_EXPIRED"):
|
|
38
|
+
raise ConsentExpired(res.reason, code=res.reason)
|
|
39
|
+
if st in ("DECLINED", "WITHDRAWN", "CANCELLED"):
|
|
40
|
+
raise ConsentDeclined(res.reason, code=res.reason)
|
|
41
|
+
# PENDING, NOT_FOUND, PARTIALLY_GRANTED(opt-out), VERIFY_UNAVAILABLE, …
|
|
42
|
+
raise ConsentPending(res.reason, code=res.reason)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class CMPClient:
|
|
46
|
+
"""
|
|
47
|
+
cmp = CMPClient(api_base="https://cmp-api…", api_key="cmpk_live_…",
|
|
48
|
+
webhook_secret="whsec_…")
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
def __init__(
|
|
52
|
+
self,
|
|
53
|
+
api_base: str,
|
|
54
|
+
api_key: str,
|
|
55
|
+
*,
|
|
56
|
+
webhook_secret: str | None = None,
|
|
57
|
+
timeout: float = 10.0,
|
|
58
|
+
retries: int = 2,
|
|
59
|
+
):
|
|
60
|
+
self.api_base = api_base.rstrip("/")
|
|
61
|
+
self.api_key = api_key
|
|
62
|
+
self.webhook_secret = webhook_secret
|
|
63
|
+
self._http = httpx.Client(
|
|
64
|
+
base_url=self.api_base,
|
|
65
|
+
timeout=timeout,
|
|
66
|
+
headers={"X-API-Key": api_key},
|
|
67
|
+
transport=httpx.HTTPTransport(retries=retries),
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
# ── lifecycle ────────────────────────────────────────────────────────────
|
|
71
|
+
def close(self) -> None:
|
|
72
|
+
self._http.close()
|
|
73
|
+
|
|
74
|
+
def __enter__(self) -> "CMPClient":
|
|
75
|
+
return self
|
|
76
|
+
|
|
77
|
+
def __exit__(self, *_: Any) -> None:
|
|
78
|
+
self.close()
|
|
79
|
+
|
|
80
|
+
def _request(self, method: str, path: str, **kw: Any) -> Any:
|
|
81
|
+
try:
|
|
82
|
+
resp = self._http.request(method, path, **kw)
|
|
83
|
+
except httpx.HTTPError as e:
|
|
84
|
+
raise CMPError(f"request failed: {e}") from e
|
|
85
|
+
if resp.status_code >= 400:
|
|
86
|
+
raise _envelope_error(resp)
|
|
87
|
+
return resp.json() if resp.content else {}
|
|
88
|
+
|
|
89
|
+
# ── request lifecycle (officer-raised) ─────────────────────────────────────
|
|
90
|
+
def create_consent_request(
|
|
91
|
+
self,
|
|
92
|
+
*,
|
|
93
|
+
email: str,
|
|
94
|
+
notice: str,
|
|
95
|
+
requested_by: str,
|
|
96
|
+
idempotency_key: str | None = None,
|
|
97
|
+
link_expire_seconds: int | None = None,
|
|
98
|
+
) -> ConsentRequest:
|
|
99
|
+
"""Raise a PENDING request; CMP emails the magic link and returns consent_id
|
|
100
|
+
at t=0. Store consent_id against your record immediately.
|
|
101
|
+
|
|
102
|
+
One request covers one notice. For two notices, make two calls — each
|
|
103
|
+
returns its own consent_id and its own link."""
|
|
104
|
+
body: dict[str, Any] = {
|
|
105
|
+
"email": email,
|
|
106
|
+
"notice_key": notice,
|
|
107
|
+
"requested_by": requested_by,
|
|
108
|
+
}
|
|
109
|
+
if idempotency_key:
|
|
110
|
+
body["idempotency_key"] = idempotency_key
|
|
111
|
+
if link_expire_seconds:
|
|
112
|
+
body["link_expire_seconds"] = link_expire_seconds
|
|
113
|
+
return ConsentRequest(**self._request("POST", "/api/v1/consent/request/s2s", json=body))
|
|
114
|
+
|
|
115
|
+
def get_request(self, request_id: str) -> RequestStatus:
|
|
116
|
+
"""Poll a request's status (fallback if a webhook is missed)."""
|
|
117
|
+
return RequestStatus(
|
|
118
|
+
**self._request("GET", "/api/v1/consent/request/status",
|
|
119
|
+
params={"request_id": request_id})
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
def cancel(self, request_id: str) -> dict[str, Any]:
|
|
123
|
+
"""Cancel a pending request (e.g. cancel-on-save-failure). Idempotent."""
|
|
124
|
+
return self._request("POST", f"/api/v1/consent/request/{request_id}/cancel")
|
|
125
|
+
|
|
126
|
+
def resend(self, request_id: str) -> dict[str, Any]:
|
|
127
|
+
"""Re-mint a fresh magic link for a still-pending request."""
|
|
128
|
+
return self._request("POST", f"/api/v1/consent/request/{request_id}/resend")
|
|
129
|
+
|
|
130
|
+
# ── the enforcement gate ───────────────────────────────────────────────────
|
|
131
|
+
def verify(self, *, consent_id: str, purpose: str) -> VerifyResult:
|
|
132
|
+
"""'May I process this consent for this purpose?' FAIL-CLOSED — any error or
|
|
133
|
+
timeout returns allowed=False (never raises), so the PII is never processed
|
|
134
|
+
on an ambiguous result."""
|
|
135
|
+
try:
|
|
136
|
+
data = self._request(
|
|
137
|
+
"POST", "/api/v1/consent/verify",
|
|
138
|
+
json={"consent_id": consent_id, "purpose": purpose},
|
|
139
|
+
)
|
|
140
|
+
except CMPError:
|
|
141
|
+
return VerifyResult(
|
|
142
|
+
allowed=False, status="UNKNOWN", purpose=purpose,
|
|
143
|
+
purpose_state="NOT_PRESENTED", reason="VERIFY_UNAVAILABLE",
|
|
144
|
+
)
|
|
145
|
+
return VerifyResult(**data)
|
|
146
|
+
|
|
147
|
+
def require_consent(self, *, consent_id: str, purpose: str) -> VerifyResult:
|
|
148
|
+
"""Like verify() but RAISES a typed exception when not allowed — so gated
|
|
149
|
+
code simply can't run without consent."""
|
|
150
|
+
res = self.verify(consent_id=consent_id, purpose=purpose)
|
|
151
|
+
if not res.allowed:
|
|
152
|
+
_raise_not_allowed(res)
|
|
153
|
+
return res
|
|
154
|
+
|
|
155
|
+
# ── inbound webhooks ────────────────────────────────────────────────────────
|
|
156
|
+
def verify_webhook(self, headers: Mapping[str, str], body: bytes | str) -> WebhookEvent:
|
|
157
|
+
"""Validate the X-CMP-Signature over the RAW body and return the parsed event.
|
|
158
|
+
Dedupe by event.event_id, order by event.consent_seq."""
|
|
159
|
+
if not self.webhook_secret:
|
|
160
|
+
raise CMPError("verify_webhook requires webhook_secret at construction")
|
|
161
|
+
sig = headers.get("X-CMP-Signature") or headers.get("x-cmp-signature")
|
|
162
|
+
if not sig:
|
|
163
|
+
raise SignatureError("missing X-CMP-Signature header")
|
|
164
|
+
raw = body.encode() if isinstance(body, str) else body
|
|
165
|
+
verify_signature(self.webhook_secret, raw, sig)
|
|
166
|
+
return WebhookEvent(**json.loads(raw))
|
cmp_consent/errors.py
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""Typed exceptions for the CMP consent SDK.
|
|
2
|
+
|
|
3
|
+
Callers map these to control flow: a gate that raises ConsentPending/Declined/Expired
|
|
4
|
+
means "do not process the PII"; CMPError is any other failure (network, 4xx/5xx).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class CMPError(Exception):
|
|
11
|
+
"""Base error. Carries the backend envelope code + HTTP status when available."""
|
|
12
|
+
|
|
13
|
+
def __init__(self, message: str, *, status: int | None = None, code: str | None = None):
|
|
14
|
+
super().__init__(message)
|
|
15
|
+
self.status = status
|
|
16
|
+
self.code = code
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class ConsentPending(CMPError):
|
|
20
|
+
"""The consent has been requested but not yet granted — processing must wait."""
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class ConsentDeclined(CMPError):
|
|
24
|
+
"""The principal declined (or withdrew) — processing is not permitted."""
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class ConsentExpired(CMPError):
|
|
28
|
+
"""The consent/request has expired — a fresh request or re-consent is needed."""
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class SignatureError(CMPError):
|
|
32
|
+
"""An inbound webhook's X-CMP-Signature failed verification (forged/replayed)."""
|
cmp_consent/models.py
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""Typed request/response models (Pydantic v2)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, Dict, Optional
|
|
6
|
+
|
|
7
|
+
from pydantic import BaseModel
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class ConsentRequest(BaseModel):
|
|
11
|
+
"""Returned by create_consent_request — `consent_id` is the durable key to store
|
|
12
|
+
against the PII from t=0."""
|
|
13
|
+
consent_id: str
|
|
14
|
+
request_id: str
|
|
15
|
+
status: str
|
|
16
|
+
request_ref: str
|
|
17
|
+
magic_link: Optional[str] = None # only when email mode = return_link
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class RequestStatus(BaseModel):
|
|
21
|
+
request_id: str
|
|
22
|
+
consent_id: str
|
|
23
|
+
status: str # PENDING|GRANTED|PARTIALLY_GRANTED|DECLINED|EXPIRED|CANCELLED|SUPERSEDED
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class VerifyResult(BaseModel):
|
|
27
|
+
"""The enforcement gate result. `allowed` is the only thing to branch on for
|
|
28
|
+
processing; the rest is for auditing/telemetry."""
|
|
29
|
+
allowed: bool
|
|
30
|
+
status: str
|
|
31
|
+
purpose: str
|
|
32
|
+
purpose_state: str
|
|
33
|
+
reason: str
|
|
34
|
+
consent_record_id: Optional[str] = None # set only when allowed
|
|
35
|
+
integrity_hash: Optional[str] = None # audit proof, set only when allowed
|
|
36
|
+
consent_seq: Optional[int] = None
|
|
37
|
+
needs_reconsent: bool = False
|
|
38
|
+
expires_at: Optional[str] = None
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class WebhookEvent(BaseModel):
|
|
42
|
+
"""A verified inbound webhook (envelope). Use `type`, `consent_id`, `consent_seq`
|
|
43
|
+
to route + dedupe (by `event_id`)."""
|
|
44
|
+
event_id: str
|
|
45
|
+
event_type: str
|
|
46
|
+
application_id: Optional[str] = None
|
|
47
|
+
occurred_at: str
|
|
48
|
+
data: Dict[str, Any] = {} # Dict (not dict[...]) so the model builds on Python 3.8
|
|
49
|
+
|
|
50
|
+
@property
|
|
51
|
+
def type(self) -> str:
|
|
52
|
+
return self.event_type
|
|
53
|
+
|
|
54
|
+
@property
|
|
55
|
+
def consent_id(self) -> Optional[str]:
|
|
56
|
+
return self.data.get("consent_id")
|
|
57
|
+
|
|
58
|
+
@property
|
|
59
|
+
def request_id(self) -> Optional[str]:
|
|
60
|
+
return self.data.get("request_id")
|
|
61
|
+
|
|
62
|
+
@property
|
|
63
|
+
def consent_seq(self) -> Optional[int]:
|
|
64
|
+
return self.data.get("consent_seq")
|
|
65
|
+
|
|
66
|
+
@property
|
|
67
|
+
def decision(self) -> Optional[str]:
|
|
68
|
+
"""FULL | PARTIAL | DECLINED (grant/decline events)."""
|
|
69
|
+
return self.data.get("decision")
|
|
70
|
+
|
|
71
|
+
@property
|
|
72
|
+
def granted_purposes(self) -> list[str]:
|
|
73
|
+
"""Purpose CODES the principal opted in to — for a direct membership check."""
|
|
74
|
+
return self.data.get("granted_purposes", [])
|
|
75
|
+
|
|
76
|
+
@property
|
|
77
|
+
def denied_purposes(self) -> list[str]:
|
|
78
|
+
return self.data.get("denied_purposes", [])
|
cmp_consent/webhooks.py
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""Inbound webhook signature verification.
|
|
2
|
+
|
|
3
|
+
CMP signs each delivery Stripe/GitHub-style:
|
|
4
|
+
header X-CMP-Signature: t=<unix_ts>,v1=<hex hmac_sha256>
|
|
5
|
+
signed "<ts>." + <raw body bytes> (key = the subscription's whsec_ secret)
|
|
6
|
+
|
|
7
|
+
Verifying the raw body (not a re-serialized dict) is essential — the signature is
|
|
8
|
+
over the exact bytes CMP sent.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import hashlib
|
|
14
|
+
import hmac
|
|
15
|
+
import time
|
|
16
|
+
|
|
17
|
+
from .errors import SignatureError
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _parse_header(header: str) -> tuple[str, str]:
|
|
21
|
+
parts = dict(
|
|
22
|
+
p.split("=", 1) for p in header.split(",") if "=" in p
|
|
23
|
+
)
|
|
24
|
+
ts, sig = parts.get("t"), parts.get("v1")
|
|
25
|
+
if not ts or not sig:
|
|
26
|
+
raise SignatureError("malformed X-CMP-Signature header")
|
|
27
|
+
return ts, sig
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def verify_signature(
|
|
31
|
+
secret: str, payload: bytes, header: str, *, tolerance_seconds: int = 300
|
|
32
|
+
) -> None:
|
|
33
|
+
"""Raise SignatureError unless `header` is a valid signature of `payload`.
|
|
34
|
+
|
|
35
|
+
tolerance_seconds guards against replay (0 disables the timestamp check).
|
|
36
|
+
"""
|
|
37
|
+
ts, sig = _parse_header(header)
|
|
38
|
+
signed = ts.encode() + b"." + payload
|
|
39
|
+
expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
|
|
40
|
+
if not hmac.compare_digest(expected, sig):
|
|
41
|
+
raise SignatureError("webhook signature mismatch")
|
|
42
|
+
if tolerance_seconds:
|
|
43
|
+
try:
|
|
44
|
+
drift = abs(time.time() - int(ts))
|
|
45
|
+
except ValueError as e:
|
|
46
|
+
raise SignatureError("bad signature timestamp") from e
|
|
47
|
+
if drift > tolerance_seconds:
|
|
48
|
+
raise SignatureError("webhook timestamp outside tolerance")
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: cmp-consent
|
|
3
|
+
Version: 0.1.1
|
|
4
|
+
Summary: Python SDK for the CMP officer-assisted deferred consent flow (raise requests, gate on verify, verify webhooks).
|
|
5
|
+
Author: Pentafox
|
|
6
|
+
License: Proprietary
|
|
7
|
+
Keywords: cmp,compliance,consent,dpdp,privacy
|
|
8
|
+
Classifier: Development Status :: 4 - Beta
|
|
9
|
+
Classifier: Intended Audience :: Developers
|
|
10
|
+
Classifier: License :: Other/Proprietary License
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.8
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
18
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
19
|
+
Requires-Python: >=3.8
|
|
20
|
+
Requires-Dist: httpx>=0.24
|
|
21
|
+
Requires-Dist: pydantic>=2.0
|
|
22
|
+
Provides-Extra: dev
|
|
23
|
+
Requires-Dist: pytest>=7.0; extra == 'dev'
|
|
24
|
+
Description-Content-Type: text/markdown
|
|
25
|
+
|
|
26
|
+
# cmp-consent
|
|
27
|
+
|
|
28
|
+
Python SDK for the CMP **officer-assisted deferred consent** flow. Runs in your
|
|
29
|
+
backend (server-to-server) and holds the application API key — never ship the key to
|
|
30
|
+
a browser.
|
|
31
|
+
|
|
32
|
+
Three jobs: **raise** consent requests, **gate** PII processing on `verify()`, and
|
|
33
|
+
**verify** CMP's signed webhooks.
|
|
34
|
+
|
|
35
|
+
## Install
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
pip install cmp-consent
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## Authentication
|
|
42
|
+
|
|
43
|
+
| Credential | Direction | Header |
|
|
44
|
+
|---|---|---|
|
|
45
|
+
| API key `cmpk_live_…` | SDK → CMP | `X-API-Key` (application-scoped) |
|
|
46
|
+
| Webhook secret `whsec_…` | CMP → SDK | HMAC `X-CMP-Signature` (for `verify_webhook`) |
|
|
47
|
+
|
|
48
|
+
```python
|
|
49
|
+
from cmp_consent import CMPClient
|
|
50
|
+
|
|
51
|
+
cmp = CMPClient(
|
|
52
|
+
api_base="https://cmp-api.yourdomain",
|
|
53
|
+
api_key="cmpk_live_…",
|
|
54
|
+
webhook_secret="whsec_…", # only needed for verify_webhook
|
|
55
|
+
timeout=10, retries=2,
|
|
56
|
+
)
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
## 1) Raise a request (officer submitted)
|
|
60
|
+
|
|
61
|
+
```python
|
|
62
|
+
req = cmp.create_consent_request(
|
|
63
|
+
email="user@example.com",
|
|
64
|
+
notice="kyc-notice", # the notice key for the application
|
|
65
|
+
requested_by=officer.id,
|
|
66
|
+
idempotency_key=submit_id, # a retried submit won't double-send the email
|
|
67
|
+
)
|
|
68
|
+
save(applicant_id, consent_id=req.consent_id) # store consent_id at t=0
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
CMP creates a PENDING request, emails the principal a single-use magic link, and
|
|
72
|
+
returns `consent_id` immediately.
|
|
73
|
+
|
|
74
|
+
## 2) Gate every use of the PII
|
|
75
|
+
|
|
76
|
+
```python
|
|
77
|
+
from cmp_consent import ConsentPending
|
|
78
|
+
|
|
79
|
+
res = cmp.verify(consent_id=req.consent_id, purpose="bureau_pull")
|
|
80
|
+
if not res.allowed:
|
|
81
|
+
raise ConsentPending(res.reason) # PENDING/DECLINED/EXPIRED → do not process
|
|
82
|
+
proceed(proof=res.integrity_hash) # allowed → consent-backed
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
`verify()` is **fail-closed**: any network error or timeout returns `allowed=False`
|
|
86
|
+
(it never raises), so PII is never processed on an ambiguous result. Prefer
|
|
87
|
+
`require_consent(...)` when you want it to raise instead:
|
|
88
|
+
|
|
89
|
+
```python
|
|
90
|
+
cmp.require_consent(consent_id=cid, purpose="bureau_pull") # raises if not granted
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
## 3) Receive the callback
|
|
94
|
+
|
|
95
|
+
```python
|
|
96
|
+
# in your webhook route — pass the RAW body bytes
|
|
97
|
+
event = cmp.verify_webhook(request.headers, request.body)
|
|
98
|
+
if event.type == "CONSENT_GRANTED" and is_new(event.event_id): # dedupe by event_id
|
|
99
|
+
unblock(event.consent_id)
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
Webhooks are at-least-once and unordered: **dedupe by `event.event_id`, order by
|
|
103
|
+
`event.consent_seq`**, and treat `verify()` as the source of truth on any ambiguity.
|
|
104
|
+
|
|
105
|
+
## Manage
|
|
106
|
+
|
|
107
|
+
```python
|
|
108
|
+
cmp.get_request(req.request_id) # poll status (fallback if a webhook is missed)
|
|
109
|
+
cmp.resend(req.request_id) # re-mint an expired magic link
|
|
110
|
+
cmp.cancel(req.request_id) # cancel-on-save-failure
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
## Async
|
|
114
|
+
|
|
115
|
+
`AsyncCMPClient` mirrors the same surface with `await`:
|
|
116
|
+
|
|
117
|
+
```python
|
|
118
|
+
from cmp_consent import AsyncCMPClient
|
|
119
|
+
|
|
120
|
+
async with AsyncCMPClient(api_base=…, api_key=…) as cmp:
|
|
121
|
+
req = await cmp.create_consent_request(email=e, notice="kyc-notice", requested_by=o)
|
|
122
|
+
res = await cmp.verify(consent_id=req.consent_id, purpose="bureau_pull")
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
## Exceptions
|
|
126
|
+
|
|
127
|
+
`ConsentPending` · `ConsentDeclined` · `ConsentExpired` · `SignatureError` ·
|
|
128
|
+
`CMPError` (base). Each carries `.status` (HTTP) and `.code` (envelope code) when
|
|
129
|
+
available.
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
cmp_consent/__init__.py,sha256=JGwZynmXqyJUbeeJUooyu1znCAzCJzny_-xL_rMDxgI,905
|
|
2
|
+
cmp_consent/aio.py,sha256=1sZjHagewU_SWgGoH0DFT8Jr6tFiw_axA5b5i7FD9MQ,4426
|
|
3
|
+
cmp_consent/client.py,sha256=ltP-q0lXpMqPUuvQgTHZkBefVWj0S46eTdeZtW7q2yg,7147
|
|
4
|
+
cmp_consent/errors.py,sha256=S_r96YYvT7vXBnpxLpeUJkf6XbuwOkOt_4oNKYKLoOY,1060
|
|
5
|
+
cmp_consent/models.py,sha256=JVoptfHzrm4TevpE9_WVyPIk8_LeHhGwDTUrXM_RvQU,2435
|
|
6
|
+
cmp_consent/webhooks.py,sha256=JqxO97fFrb3XpoqMZlWoEWVwrQj9qQ33G62RywYyF4g,1644
|
|
7
|
+
cmp_consent-0.1.1.dist-info/METADATA,sha256=3MRnmLKEcsR2CTXL8NWvjFdVJu6tqzXO1wwvRyqOAhw,4196
|
|
8
|
+
cmp_consent-0.1.1.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
|
|
9
|
+
cmp_consent-0.1.1.dist-info/RECORD,,
|