sentinel-python-client 2.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.
sentinel/__init__.py ADDED
@@ -0,0 +1,69 @@
1
+ """Sentinel Python Client - Official client for the Sentinel v2 API."""
2
+
3
+ from sentinel._async_client import AsyncSentinelClient
4
+ from sentinel._client import SentinelClient
5
+ from sentinel._exceptions import (
6
+ LicenseValidationError,
7
+ ReplayDetectedError,
8
+ SentinelApiError,
9
+ SentinelConnectionError,
10
+ SentinelError,
11
+ SignatureVerificationError,
12
+ )
13
+ from sentinel.models.license import (
14
+ BlacklistInfo,
15
+ License,
16
+ LicenseIssuer,
17
+ LicenseProduct,
18
+ LicenseTier,
19
+ SubUser,
20
+ )
21
+ from sentinel.models.page import Page
22
+ from sentinel.models.requests import (
23
+ CLEAR,
24
+ CLEAR_EXPIRATION,
25
+ CreateLicenseRequest,
26
+ ListLicensesRequest,
27
+ UpdateLicenseRequest,
28
+ ValidationRequest,
29
+ )
30
+ from sentinel.models.validation import (
31
+ BlacklistFailureDetails,
32
+ ExcessiveIpsFailureDetails,
33
+ ExcessiveServersFailureDetails,
34
+ FailureDetails,
35
+ ValidationDetails,
36
+ ValidationResult,
37
+ ValidationResultType,
38
+ )
39
+
40
+ __all__ = [
41
+ "SentinelClient",
42
+ "AsyncSentinelClient",
43
+ "SentinelError",
44
+ "SentinelApiError",
45
+ "SentinelConnectionError",
46
+ "LicenseValidationError",
47
+ "SignatureVerificationError",
48
+ "ReplayDetectedError",
49
+ "License",
50
+ "LicenseProduct",
51
+ "LicenseTier",
52
+ "LicenseIssuer",
53
+ "SubUser",
54
+ "BlacklistInfo",
55
+ "ValidationResult",
56
+ "ValidationResultType",
57
+ "ValidationDetails",
58
+ "FailureDetails",
59
+ "BlacklistFailureDetails",
60
+ "ExcessiveServersFailureDetails",
61
+ "ExcessiveIpsFailureDetails",
62
+ "CreateLicenseRequest",
63
+ "UpdateLicenseRequest",
64
+ "ListLicensesRequest",
65
+ "ValidationRequest",
66
+ "CLEAR",
67
+ "CLEAR_EXPIRATION",
68
+ "Page",
69
+ ]
@@ -0,0 +1,56 @@
1
+ from __future__ import annotations
2
+
3
+ from datetime import timedelta
4
+
5
+ from sentinel._http import AsyncSentinelHttpClient
6
+ from sentinel._replay import ReplayProtector
7
+ from sentinel._signature import SignatureVerifier
8
+ from sentinel.services._async_license import AsyncLicenseService
9
+
10
+
11
+ class AsyncSentinelClient:
12
+ def __init__(
13
+ self,
14
+ base_url: str,
15
+ api_key: str,
16
+ public_key: str | None = None,
17
+ connect_timeout: float = 5.0,
18
+ read_timeout: float = 10.0,
19
+ replay_protection_window: timedelta = timedelta(seconds=30),
20
+ nonce_cache_size: int = 1000,
21
+ ) -> None:
22
+ if not base_url:
23
+ raise ValueError("base_url is required")
24
+ if not api_key:
25
+ raise ValueError("api_key is required")
26
+
27
+ self._http = AsyncSentinelHttpClient(
28
+ base_url=base_url,
29
+ api_key=api_key,
30
+ connect_timeout=connect_timeout,
31
+ read_timeout=read_timeout,
32
+ )
33
+
34
+ sig: SignatureVerifier | None = None
35
+ replay: ReplayProtector | None = None
36
+ if public_key is not None:
37
+ sig = SignatureVerifier(public_key)
38
+ replay = ReplayProtector(
39
+ window_seconds=replay_protection_window.total_seconds(),
40
+ max_size=nonce_cache_size,
41
+ )
42
+
43
+ self.licenses = AsyncLicenseService(
44
+ http_client=self._http,
45
+ signature_verifier=sig,
46
+ replay_protector=replay,
47
+ )
48
+
49
+ async def aclose(self) -> None:
50
+ await self._http.aclose()
51
+
52
+ async def __aenter__(self) -> AsyncSentinelClient:
53
+ return self
54
+
55
+ async def __aexit__(self, *args: object) -> None:
56
+ await self.aclose()
sentinel/_client.py ADDED
@@ -0,0 +1,56 @@
1
+ from __future__ import annotations
2
+
3
+ from datetime import timedelta
4
+
5
+ from sentinel._http import SentinelHttpClient
6
+ from sentinel._replay import ReplayProtector
7
+ from sentinel._signature import SignatureVerifier
8
+ from sentinel.services._license import LicenseService
9
+
10
+
11
+ class SentinelClient:
12
+ def __init__(
13
+ self,
14
+ base_url: str,
15
+ api_key: str,
16
+ public_key: str | None = None,
17
+ connect_timeout: float = 5.0,
18
+ read_timeout: float = 10.0,
19
+ replay_protection_window: timedelta = timedelta(seconds=30),
20
+ nonce_cache_size: int = 1000,
21
+ ) -> None:
22
+ if not base_url:
23
+ raise ValueError("base_url is required")
24
+ if not api_key:
25
+ raise ValueError("api_key is required")
26
+
27
+ self._http = SentinelHttpClient(
28
+ base_url=base_url,
29
+ api_key=api_key,
30
+ connect_timeout=connect_timeout,
31
+ read_timeout=read_timeout,
32
+ )
33
+
34
+ sig: SignatureVerifier | None = None
35
+ replay: ReplayProtector | None = None
36
+ if public_key is not None:
37
+ sig = SignatureVerifier(public_key)
38
+ replay = ReplayProtector(
39
+ window_seconds=replay_protection_window.total_seconds(),
40
+ max_size=nonce_cache_size,
41
+ )
42
+
43
+ self.licenses = LicenseService(
44
+ http_client=self._http,
45
+ signature_verifier=sig,
46
+ replay_protector=replay,
47
+ )
48
+
49
+ def close(self) -> None:
50
+ self._http.close()
51
+
52
+ def __enter__(self) -> SentinelClient:
53
+ return self
54
+
55
+ def __exit__(self, *args: object) -> None:
56
+ self.close()
@@ -0,0 +1,54 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING
4
+
5
+ if TYPE_CHECKING:
6
+ from sentinel.models.validation import ValidationResultType
7
+
8
+
9
+ class SentinelError(Exception):
10
+ pass
11
+
12
+
13
+ class SentinelApiError(SentinelError):
14
+ def __init__(
15
+ self,
16
+ http_status: int,
17
+ type: str | None,
18
+ message: str,
19
+ retry_after_seconds: float | None = None,
20
+ ) -> None:
21
+ super().__init__(message)
22
+ self.message = message
23
+ self.http_status = http_status
24
+ self.type = type
25
+ self.retry_after_seconds = retry_after_seconds
26
+
27
+
28
+ class SentinelConnectionError(SentinelError):
29
+ def __init__(self, message: str, cause: Exception | None = None) -> None:
30
+ super().__init__(message)
31
+ self.message = message
32
+ if cause is not None:
33
+ self.__cause__ = cause
34
+
35
+
36
+ class LicenseValidationError(SentinelError):
37
+ def __init__(
38
+ self,
39
+ type: ValidationResultType,
40
+ message: str,
41
+ failure_details: object | None = None,
42
+ ) -> None:
43
+ super().__init__(message)
44
+ self.message = message
45
+ self.type = type
46
+ self.failure_details = failure_details
47
+
48
+
49
+ class SignatureVerificationError(SentinelError):
50
+ pass
51
+
52
+
53
+ class ReplayDetectedError(SentinelError):
54
+ pass
sentinel/_http.py ADDED
@@ -0,0 +1,192 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from dataclasses import dataclass
5
+ from typing import Any
6
+ from urllib.parse import urlencode
7
+
8
+ import httpx
9
+
10
+ from sentinel._exceptions import SentinelApiError, SentinelConnectionError
11
+
12
+
13
+ @dataclass
14
+ class ApiResponse:
15
+ http_status: int
16
+ type: str | None
17
+ message: str | None
18
+ result: dict[str, Any] | None
19
+
20
+ def require_result(self) -> dict[str, Any]:
21
+ if self.result is None:
22
+ raise SentinelApiError(
23
+ http_status=self.http_status,
24
+ type=self.type,
25
+ message="Response contained no result",
26
+ )
27
+ return self.result
28
+
29
+
30
+ def _parse_response(
31
+ status_code: int,
32
+ body: str,
33
+ headers: httpx.Headers,
34
+ allowed_statuses: set[int] | None,
35
+ ) -> ApiResponse:
36
+ if status_code == 204:
37
+ return ApiResponse(http_status=204, type=None, message=None, result=None)
38
+
39
+ try:
40
+ data = json.loads(body)
41
+ except (json.JSONDecodeError, ValueError) as e:
42
+ raise SentinelApiError(
43
+ http_status=status_code, type=None, message="Failed to parse response body"
44
+ ) from e
45
+
46
+ resp_type = data.get("type")
47
+ message = data.get("message")
48
+ result = data.get("result") if isinstance(data.get("result"), dict) else None
49
+
50
+ if 200 <= status_code < 300:
51
+ return ApiResponse(http_status=status_code, type=resp_type, message=message, result=result)
52
+
53
+ if allowed_statuses and status_code in allowed_statuses:
54
+ return ApiResponse(http_status=status_code, type=resp_type, message=message, result=result)
55
+
56
+ retry_after: float | None = None
57
+ if status_code == 429:
58
+ raw = headers.get("X-Rate-Limit-Retry-After-Seconds")
59
+ if raw is not None:
60
+ try:
61
+ retry_after = int(raw)
62
+ except ValueError:
63
+ pass
64
+
65
+ raise SentinelApiError(
66
+ http_status=status_code,
67
+ type=resp_type,
68
+ message=message or "Unknown error",
69
+ retry_after_seconds=retry_after,
70
+ )
71
+
72
+
73
+ def _build_url(
74
+ base_url: str,
75
+ path: str,
76
+ query_params: dict[str, str] | None = None,
77
+ multi_query_params: dict[str, list[str]] | None = None,
78
+ ) -> str:
79
+ url = base_url + path
80
+ if multi_query_params:
81
+ parts: list[str] = []
82
+ for key in sorted(multi_query_params):
83
+ for val in sorted(multi_query_params[key]):
84
+ parts.append(f"{urlencode({key: val})}")
85
+ url += "?" + "&".join(parts)
86
+ return url
87
+ if query_params:
88
+ sorted_params = sorted(query_params.items())
89
+ url += "?" + urlencode(sorted_params)
90
+ return url
91
+
92
+
93
+ class SentinelHttpClient:
94
+ def __init__(
95
+ self,
96
+ base_url: str,
97
+ api_key: str,
98
+ connect_timeout: float = 5.0,
99
+ read_timeout: float = 10.0,
100
+ http_client: httpx.Client | None = None,
101
+ ) -> None:
102
+ self._base_url = base_url.rstrip("/")
103
+ self._api_key = api_key
104
+ if http_client is not None:
105
+ self._client = http_client
106
+ else:
107
+ self._client = httpx.Client(
108
+ timeout=httpx.Timeout(
109
+ connect=connect_timeout, read=read_timeout, write=5.0, pool=5.0
110
+ ),
111
+ )
112
+
113
+ def request(
114
+ self,
115
+ method: str,
116
+ path: str,
117
+ json_body: dict[str, Any] | list[Any] | None = None,
118
+ query_params: dict[str, str] | None = None,
119
+ multi_query_params: dict[str, list[str]] | None = None,
120
+ allowed_statuses: set[int] | None = None,
121
+ ) -> ApiResponse:
122
+ url = _build_url(self._base_url, path, query_params, multi_query_params)
123
+ headers = {"Authorization": f"Bearer {self._api_key}"}
124
+ kwargs: dict[str, Any] = {"headers": headers}
125
+
126
+ if json_body is not None:
127
+ headers["Content-Type"] = "application/json"
128
+ kwargs["content"] = json.dumps(json_body)
129
+ try:
130
+ response = self._client.request(method, url, **kwargs)
131
+ except httpx.TimeoutException as e:
132
+ raise SentinelConnectionError("Failed to connect to Sentinel API", e) from e
133
+ except httpx.NetworkError as e:
134
+ raise SentinelConnectionError("Failed to connect to Sentinel API", e) from e
135
+
136
+ return _parse_response(
137
+ response.status_code, response.text, response.headers, allowed_statuses
138
+ )
139
+
140
+ def close(self) -> None:
141
+ self._client.close()
142
+
143
+
144
+ class AsyncSentinelHttpClient:
145
+ def __init__(
146
+ self,
147
+ base_url: str,
148
+ api_key: str,
149
+ connect_timeout: float = 5.0,
150
+ read_timeout: float = 10.0,
151
+ http_client: httpx.AsyncClient | None = None,
152
+ ) -> None:
153
+ self._base_url = base_url.rstrip("/")
154
+ self._api_key = api_key
155
+ if http_client is not None:
156
+ self._client = http_client
157
+ else:
158
+ self._client = httpx.AsyncClient(
159
+ timeout=httpx.Timeout(
160
+ connect=connect_timeout, read=read_timeout, write=5.0, pool=5.0
161
+ ),
162
+ )
163
+
164
+ async def request(
165
+ self,
166
+ method: str,
167
+ path: str,
168
+ json_body: dict[str, Any] | list[Any] | None = None,
169
+ query_params: dict[str, str] | None = None,
170
+ multi_query_params: dict[str, list[str]] | None = None,
171
+ allowed_statuses: set[int] | None = None,
172
+ ) -> ApiResponse:
173
+ url = _build_url(self._base_url, path, query_params, multi_query_params)
174
+ headers = {"Authorization": f"Bearer {self._api_key}"}
175
+ kwargs: dict[str, Any] = {"headers": headers}
176
+
177
+ if json_body is not None:
178
+ headers["Content-Type"] = "application/json"
179
+ kwargs["content"] = json.dumps(json_body)
180
+ try:
181
+ response = await self._client.request(method, url, **kwargs)
182
+ except httpx.TimeoutException as e:
183
+ raise SentinelConnectionError("Failed to connect to Sentinel API", e) from e
184
+ except httpx.NetworkError as e:
185
+ raise SentinelConnectionError("Failed to connect to Sentinel API", e) from e
186
+
187
+ return _parse_response(
188
+ response.status_code, response.text, response.headers, allowed_statuses
189
+ )
190
+
191
+ async def aclose(self) -> None:
192
+ await self._client.aclose()
sentinel/_replay.py ADDED
@@ -0,0 +1,32 @@
1
+ from __future__ import annotations
2
+
3
+ import threading
4
+ import time
5
+ from collections import OrderedDict
6
+
7
+ from sentinel._exceptions import ReplayDetectedError
8
+
9
+
10
+ class ReplayProtector:
11
+ def __init__(self, window_seconds: float, max_size: int) -> None:
12
+ self._window_ms = window_seconds * 1000
13
+ self._max_size = max_size
14
+ self._nonce_cache: OrderedDict[str, bool] = OrderedDict()
15
+ self._lock = threading.Lock()
16
+
17
+ def check(self, nonce: str, response_timestamp_ms: int) -> None:
18
+ now = time.time() * 1000
19
+ drift = abs(now - response_timestamp_ms)
20
+
21
+ if drift > self._window_ms:
22
+ raise ReplayDetectedError(
23
+ f"Response timestamp is outside the acceptable window: "
24
+ f"drift={int(drift)}ms, window={int(self._window_ms)}ms"
25
+ )
26
+
27
+ with self._lock:
28
+ if nonce in self._nonce_cache:
29
+ raise ReplayDetectedError(f"Duplicate nonce detected: {nonce}")
30
+ self._nonce_cache[nonce] = True
31
+ while len(self._nonce_cache) > self._max_size:
32
+ self._nonce_cache.popitem(last=False)
sentinel/_signature.py ADDED
@@ -0,0 +1,119 @@
1
+ from __future__ import annotations
2
+
3
+ import base64
4
+
5
+ from nacl.exceptions import BadSignatureError
6
+ from nacl.signing import VerifyKey
7
+
8
+ from sentinel._exceptions import SignatureVerificationError
9
+
10
+
11
+ class SignatureVerifier:
12
+ # X.509 SubjectPublicKeyInfo prefix for Ed25519 (OID 1.3.101.112)
13
+ _SPKI_PREFIX = bytes.fromhex("302a300506032b6570032100")
14
+
15
+ def __init__(self, base64_public_key: str) -> None:
16
+ try:
17
+ key_bytes = base64.b64decode(base64_public_key)
18
+ if key_bytes[:12] == self._SPKI_PREFIX:
19
+ raw_key = key_bytes[12:]
20
+ else:
21
+ raw_key = key_bytes
22
+ self._verify_key = VerifyKey(raw_key)
23
+ except Exception as e:
24
+ raise ValueError(f"Invalid Ed25519 public key: {e}") from e
25
+
26
+ def verify(
27
+ self,
28
+ signature_base64: str | None,
29
+ nonce: str,
30
+ timestamp: int,
31
+ expiration: str | None,
32
+ server_count: int,
33
+ max_servers: int,
34
+ ip_count: int,
35
+ max_ips: int,
36
+ tier: str | None,
37
+ entitlements: list[str] | None,
38
+ ) -> None:
39
+ if signature_base64 is None:
40
+ raise SignatureVerificationError(
41
+ "Response signature is null but signature verification is enabled"
42
+ )
43
+
44
+ canonical = self.build_canonical_payload(
45
+ nonce=nonce,
46
+ timestamp=timestamp,
47
+ expiration=expiration,
48
+ server_count=server_count,
49
+ max_servers=max_servers,
50
+ ip_count=ip_count,
51
+ max_ips=max_ips,
52
+ tier=tier,
53
+ entitlements=entitlements,
54
+ )
55
+
56
+ try:
57
+ sig_bytes = base64.b64decode(signature_base64)
58
+ self._verify_key.verify(canonical.encode("utf-8"), sig_bytes)
59
+ except BadSignatureError:
60
+ raise SignatureVerificationError("Signature verification failed")
61
+ except SignatureVerificationError:
62
+ raise
63
+ except Exception as e:
64
+ raise SignatureVerificationError(f"Signature verification error: {e}") from e
65
+
66
+ def build_canonical_payload(
67
+ self,
68
+ nonce: str,
69
+ timestamp: int,
70
+ expiration: str | None,
71
+ server_count: int,
72
+ max_servers: int,
73
+ ip_count: int,
74
+ max_ips: int,
75
+ tier: str | None,
76
+ entitlements: list[str] | None,
77
+ ) -> str:
78
+ parts: list[str] = ["{"]
79
+ parts.append('"entitlements":')
80
+ if entitlements is None:
81
+ parts.append("null")
82
+ else:
83
+ sorted_ent = sorted(entitlements)
84
+ parts.append("[")
85
+ parts.append(",".join(_json_string(e) for e in sorted_ent))
86
+ parts.append("]")
87
+ parts.append(f',"expiration":{_json_string(expiration)}')
88
+ parts.append(f',"ipCount":{ip_count}')
89
+ parts.append(f',"maxIps":{max_ips}')
90
+ parts.append(f',"maxServers":{max_servers}')
91
+ parts.append(f',"nonce":{_json_string(nonce)}')
92
+ parts.append(f',"serverCount":{server_count}')
93
+ parts.append(f',"tier":{_json_string(tier)}')
94
+ parts.append(f',"timestamp":{timestamp}')
95
+ parts.append("}")
96
+ return "".join(parts)
97
+
98
+
99
+ def _json_string(value: str | None) -> str:
100
+ if value is None:
101
+ return "null"
102
+ result = ['"']
103
+ for ch in value:
104
+ if ch == '"':
105
+ result.append('\\"')
106
+ elif ch == "\\":
107
+ result.append("\\\\")
108
+ elif ch == "\n":
109
+ result.append("\\n")
110
+ elif ch == "\r":
111
+ result.append("\\r")
112
+ elif ch == "\t":
113
+ result.append("\\t")
114
+ elif ord(ch) < 0x20:
115
+ result.append(f"\\u{ord(ch):04x}")
116
+ else:
117
+ result.append(ch)
118
+ result.append('"')
119
+ return "".join(result)
@@ -0,0 +1,24 @@
1
+ """Sentinel API models."""
2
+
3
+ from sentinel.models.license import BlacklistInfo as BlacklistInfo
4
+ from sentinel.models.license import License as License
5
+ from sentinel.models.license import LicenseIssuer as LicenseIssuer
6
+ from sentinel.models.license import LicenseProduct as LicenseProduct
7
+ from sentinel.models.license import LicenseTier as LicenseTier
8
+ from sentinel.models.license import SubUser as SubUser
9
+ from sentinel.models.page import Page as Page
10
+ from sentinel.models.requests import CLEAR as CLEAR
11
+ from sentinel.models.requests import CLEAR_EXPIRATION as CLEAR_EXPIRATION
12
+ from sentinel.models.requests import CreateLicenseRequest as CreateLicenseRequest
13
+ from sentinel.models.requests import ListLicensesRequest as ListLicensesRequest
14
+ from sentinel.models.requests import UpdateLicenseRequest as UpdateLicenseRequest
15
+ from sentinel.models.requests import ValidationRequest as ValidationRequest
16
+ from sentinel.models.validation import BlacklistFailureDetails as BlacklistFailureDetails
17
+ from sentinel.models.validation import ExcessiveIpsFailureDetails as ExcessiveIpsFailureDetails
18
+ from sentinel.models.validation import (
19
+ ExcessiveServersFailureDetails as ExcessiveServersFailureDetails,
20
+ )
21
+ from sentinel.models.validation import FailureDetails as FailureDetails
22
+ from sentinel.models.validation import ValidationDetails as ValidationDetails
23
+ from sentinel.models.validation import ValidationResult as ValidationResult
24
+ from sentinel.models.validation import ValidationResultType as ValidationResultType
@@ -0,0 +1,63 @@
1
+ from __future__ import annotations
2
+
3
+ from datetime import datetime
4
+
5
+ from pydantic import BaseModel, ConfigDict
6
+ from pydantic.alias_generators import to_camel
7
+
8
+
9
+ class _CamelModel(BaseModel):
10
+ model_config = ConfigDict(
11
+ alias_generator=to_camel,
12
+ populate_by_name=True,
13
+ frozen=True,
14
+ )
15
+
16
+
17
+ class LicenseProduct(_CamelModel):
18
+ id: str
19
+ name: str
20
+ description: str | None = None
21
+ logo_url: str | None = None
22
+
23
+
24
+ class LicenseTier(_CamelModel):
25
+ id: str
26
+ name: str | None = None
27
+ entitlements: set[str] = set()
28
+
29
+
30
+ class LicenseIssuer(_CamelModel):
31
+ type: str
32
+ id: str
33
+ display_name: str
34
+
35
+
36
+ class SubUser(_CamelModel):
37
+ platform: str
38
+ value: str
39
+
40
+
41
+ class BlacklistInfo(_CamelModel):
42
+ timestamp: datetime
43
+ reason: str | None = None
44
+
45
+
46
+ class License(_CamelModel):
47
+ id: str
48
+ key: str
49
+ product: LicenseProduct
50
+ tier: LicenseTier | None = None
51
+ issuer: LicenseIssuer
52
+ created_at: datetime
53
+ expiration: datetime | None = None
54
+ max_servers: int
55
+ max_ips: int
56
+ note: str | None = None
57
+ connections: dict[str, str] = {}
58
+ sub_users: list[SubUser] = []
59
+ servers: dict[str, datetime] = {}
60
+ ips: dict[str, datetime] = {}
61
+ additional_entitlements: set[str] = set()
62
+ entitlements: set[str] = set()
63
+ blacklist: BlacklistInfo | None = None
@@ -0,0 +1,15 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from typing import Generic, TypeVar
5
+
6
+ T = TypeVar("T")
7
+
8
+
9
+ @dataclass(frozen=True)
10
+ class Page(Generic[T]):
11
+ content: list[T]
12
+ size: int
13
+ number: int
14
+ total_elements: int
15
+ total_pages: int