saamfi-sdk 0.1.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.
Potentially problematic release.
This version of saamfi-sdk might be problematic. Click here for more details.
- saamfi_sdk/__init__.py +49 -0
- saamfi_sdk/client.py +311 -0
- saamfi_sdk/exceptions.py +70 -0
- saamfi_sdk/models.py +141 -0
- saamfi_sdk/py.typed +2 -0
- saamfi_sdk/services/__init__.py +18 -0
- saamfi_sdk/services/auth.py +90 -0
- saamfi_sdk/services/base.py +45 -0
- saamfi_sdk/services/institutions.py +113 -0
- saamfi_sdk/services/systems.py +62 -0
- saamfi_sdk/services/token.py +155 -0
- saamfi_sdk/services/users.py +147 -0
- saamfi_sdk-0.1.0.dist-info/METADATA +257 -0
- saamfi_sdk-0.1.0.dist-info/RECORD +17 -0
- saamfi_sdk-0.1.0.dist-info/WHEEL +5 -0
- saamfi_sdk-0.1.0.dist-info/licenses/LICENSE +21 -0
- saamfi_sdk-0.1.0.dist-info/top_level.txt +1 -0
saamfi_sdk/__init__.py
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Saamfi SDK for Python
|
|
3
|
+
|
|
4
|
+
Python SDK to connect to SAAMFI services - Authentication and Authorization.
|
|
5
|
+
|
|
6
|
+
This SDK is equivalent to saamfi-security (Java SDK) and provides the same
|
|
7
|
+
functionality for systems that require integration with the Saamfi service.
|
|
8
|
+
|
|
9
|
+
Basic usage:
|
|
10
|
+
>>> from saamfi_sdk import SaamfiClient
|
|
11
|
+
>>> client = SaamfiClient("https://saamfi.example.com", system_id=123)
|
|
12
|
+
>>> # Client is ready for authentication and token validation
|
|
13
|
+
|
|
14
|
+
Equivalent to: co.edu.icesi.dev.saamfi.saamfisecurity (Java package)
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
__version__ = "0.1.0"
|
|
18
|
+
|
|
19
|
+
from .client import SaamfiClient
|
|
20
|
+
from .exceptions import (
|
|
21
|
+
SaamfiAuthenticationError,
|
|
22
|
+
SaamfiConnectionError,
|
|
23
|
+
SaamfiException,
|
|
24
|
+
SaamfiInvalidSystemError,
|
|
25
|
+
SaamfiNotFoundError,
|
|
26
|
+
SaamfiTokenValidationError,
|
|
27
|
+
SaamfiUnauthorizedError,
|
|
28
|
+
)
|
|
29
|
+
from .models import LoginBody, LoginResponse, UserDetailToken, UserInfo
|
|
30
|
+
|
|
31
|
+
__all__ = [
|
|
32
|
+
# Main client
|
|
33
|
+
"SaamfiClient",
|
|
34
|
+
# Models
|
|
35
|
+
"LoginBody",
|
|
36
|
+
"LoginResponse",
|
|
37
|
+
"UserInfo",
|
|
38
|
+
"UserDetailToken",
|
|
39
|
+
# Exceptions
|
|
40
|
+
"SaamfiException",
|
|
41
|
+
"SaamfiAuthenticationError",
|
|
42
|
+
"SaamfiTokenValidationError",
|
|
43
|
+
"SaamfiConnectionError",
|
|
44
|
+
"SaamfiInvalidSystemError",
|
|
45
|
+
"SaamfiUnauthorizedError",
|
|
46
|
+
"SaamfiNotFoundError",
|
|
47
|
+
# Metadata
|
|
48
|
+
"__version__",
|
|
49
|
+
]
|
saamfi_sdk/client.py
ADDED
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Main client entry point for the Saamfi SDK.
|
|
3
|
+
|
|
4
|
+
This module now composes several service classes to keep responsibilities
|
|
5
|
+
focused and the public API unchanged.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import logging
|
|
11
|
+
import os
|
|
12
|
+
from typing import Any, Dict, List, Optional
|
|
13
|
+
|
|
14
|
+
import requests
|
|
15
|
+
from cryptography.hazmat.primitives.asymmetric import rsa
|
|
16
|
+
from dotenv import load_dotenv
|
|
17
|
+
|
|
18
|
+
from .models import LoginResponse, UserDetailToken, UserInfo
|
|
19
|
+
from .services import (
|
|
20
|
+
AuthenticationService,
|
|
21
|
+
InstitutionService,
|
|
22
|
+
SystemService,
|
|
23
|
+
TokenService,
|
|
24
|
+
UserService,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
# Load environment variables from .env file
|
|
28
|
+
load_dotenv()
|
|
29
|
+
|
|
30
|
+
logger = logging.getLogger(__name__)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class SaamfiClient:
|
|
34
|
+
"""
|
|
35
|
+
Main client for interacting with the Saamfi platform.
|
|
36
|
+
|
|
37
|
+
The class delegates most of the work to specialized service classes while
|
|
38
|
+
preserving the public interface exposed by previous versions.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
ROLE_CLAIM = TokenService.ROLE_CLAIM
|
|
42
|
+
SYSTEM_CLAIM = TokenService.SYSTEM_CLAIM
|
|
43
|
+
USERNAME_CLAIM = TokenService.USERNAME_CLAIM
|
|
44
|
+
ID_CLAIM = TokenService.ID_CLAIM
|
|
45
|
+
|
|
46
|
+
def __init__(
|
|
47
|
+
self,
|
|
48
|
+
saamfi_url: Optional[str] = None,
|
|
49
|
+
system_id: Optional[int] = None,
|
|
50
|
+
client_id: Optional[int] = None,
|
|
51
|
+
client_secret: Optional[str] = None,
|
|
52
|
+
tenant_id: Optional[int] = None,
|
|
53
|
+
):
|
|
54
|
+
"""
|
|
55
|
+
Create a Saamfi client instance.
|
|
56
|
+
|
|
57
|
+
Args:
|
|
58
|
+
saamfi_url: Base URL of the Saamfi server. Falls back to the
|
|
59
|
+
`SAAMFI_BASE_URL`/`SAAMFI_URL` environment variables.
|
|
60
|
+
system_id: Tenant/system identifier. Falls back to the
|
|
61
|
+
`SAAMFI_SYS_ID`/`SAAMFI_SYSTEM_ID` environment variables.
|
|
62
|
+
client_id: OAuth-style client identifier. Falls back to the
|
|
63
|
+
`SAAMFI_CLIENT_ID` environment variable.
|
|
64
|
+
client_secret: Client secret used for confidential flows.
|
|
65
|
+
Falls back to `SAAMFI_CLIENT_SECRET`.
|
|
66
|
+
tenant_id: Institution/tenant identifier for institution-specific
|
|
67
|
+
requests. Falls back to `SAAMFI_TENANT_ID`/`SAAMFI_INST_ID`.
|
|
68
|
+
|
|
69
|
+
Raises:
|
|
70
|
+
ValueError: Missing or invalid configuration values.
|
|
71
|
+
"""
|
|
72
|
+
resolved_url = saamfi_url or self._get_first_env("SAAMFI_BASE_URL", "SAAMFI_URL")
|
|
73
|
+
if not resolved_url:
|
|
74
|
+
raise ValueError(
|
|
75
|
+
"saamfi_url must be provided either as parameter or through "
|
|
76
|
+
"the SAAMFI_BASE_URL/SAAMFI_URL environment variables"
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
if system_id is None:
|
|
80
|
+
system_id_raw = self._get_first_env("SAAMFI_SYS_ID", "SAAMFI_SYSTEM_ID")
|
|
81
|
+
if system_id_raw is None:
|
|
82
|
+
raise ValueError(
|
|
83
|
+
"system_id must be provided either as parameter or through "
|
|
84
|
+
"the SAAMFI_SYS_ID/SAAMFI_SYSTEM_ID environment variables"
|
|
85
|
+
)
|
|
86
|
+
try:
|
|
87
|
+
system_id = int(system_id_raw)
|
|
88
|
+
except ValueError as exc:
|
|
89
|
+
raise ValueError(
|
|
90
|
+
"SAAMFI_SYS_ID/SAAMFI_SYSTEM_ID environment variables must contain a valid "
|
|
91
|
+
f"integer, got: {system_id_raw}"
|
|
92
|
+
) from exc
|
|
93
|
+
|
|
94
|
+
if client_id is None:
|
|
95
|
+
client_id_raw = self._get_first_env("SAAMFI_CLIENT_ID")
|
|
96
|
+
if client_id_raw is not None:
|
|
97
|
+
try:
|
|
98
|
+
client_id = int(client_id_raw)
|
|
99
|
+
except ValueError as exc:
|
|
100
|
+
raise ValueError(
|
|
101
|
+
"SAAMFI_CLIENT_ID environment variable must be a valid integer, "
|
|
102
|
+
f"got: {client_id_raw}"
|
|
103
|
+
) from exc
|
|
104
|
+
|
|
105
|
+
if tenant_id is None:
|
|
106
|
+
tenant_id_raw = self._get_first_env("SAAMFI_TENANT_ID", "SAAMFI_INST_ID")
|
|
107
|
+
if tenant_id_raw is not None:
|
|
108
|
+
try:
|
|
109
|
+
tenant_id = int(tenant_id_raw)
|
|
110
|
+
except ValueError as exc:
|
|
111
|
+
raise ValueError(
|
|
112
|
+
"SAAMFI_TENANT_ID/SAAMFI_INST_ID environment variables must contain a "
|
|
113
|
+
f"valid integer, got: {tenant_id_raw}"
|
|
114
|
+
) from exc
|
|
115
|
+
|
|
116
|
+
self._saamfi_url = resolved_url.rstrip("/")
|
|
117
|
+
self._system_id = system_id
|
|
118
|
+
self._client_id = client_id
|
|
119
|
+
self._client_secret = client_secret or self._get_first_env("SAAMFI_CLIENT_SECRET")
|
|
120
|
+
self._tenant_id = tenant_id
|
|
121
|
+
self._session = requests.Session()
|
|
122
|
+
|
|
123
|
+
self._token_service = TokenService(self._saamfi_url, self._session)
|
|
124
|
+
self._auth_service = AuthenticationService(
|
|
125
|
+
self._saamfi_url,
|
|
126
|
+
self._session,
|
|
127
|
+
self._system_id,
|
|
128
|
+
self._token_service,
|
|
129
|
+
)
|
|
130
|
+
self._user_service = UserService(self._saamfi_url, self._session)
|
|
131
|
+
self._institution_service = InstitutionService(self._saamfi_url, self._session)
|
|
132
|
+
self._system_service = SystemService(self._saamfi_url, self._session)
|
|
133
|
+
|
|
134
|
+
self._public_key: Optional[bytes] = None
|
|
135
|
+
|
|
136
|
+
try:
|
|
137
|
+
self._public_key = self._token_service.fetch_public_key_bytes()
|
|
138
|
+
except Exception as exc: # pragma: no cover - initialization is best-effort
|
|
139
|
+
logger.warning("Error obtaining public key during initialization: %s", exc)
|
|
140
|
+
|
|
141
|
+
@staticmethod
|
|
142
|
+
def _get_first_env(*names: str) -> Optional[str]:
|
|
143
|
+
"""Return the first non-empty environment variable among the provided names."""
|
|
144
|
+
for env_name in names:
|
|
145
|
+
value = os.getenv(env_name)
|
|
146
|
+
if value:
|
|
147
|
+
return value
|
|
148
|
+
return None
|
|
149
|
+
|
|
150
|
+
def _get_public_key(self) -> bytes:
|
|
151
|
+
"""
|
|
152
|
+
Retrieve the cached raw public key (DER encoded) or fetch it from Saamfi.
|
|
153
|
+
|
|
154
|
+
Returns:
|
|
155
|
+
bytes: DER-encoded public key.
|
|
156
|
+
|
|
157
|
+
Raises:
|
|
158
|
+
SaamfiConnectionError: If the Saamfi service cannot be reached.
|
|
159
|
+
"""
|
|
160
|
+
key_bytes = self._token_service.fetch_public_key_bytes()
|
|
161
|
+
self._public_key = key_bytes
|
|
162
|
+
return key_bytes
|
|
163
|
+
|
|
164
|
+
def get_public_key(self) -> rsa.RSAPublicKey:
|
|
165
|
+
"""
|
|
166
|
+
Return the RSA public key used to validate Saamfi JWT tokens.
|
|
167
|
+
|
|
168
|
+
Raises:
|
|
169
|
+
SaamfiConnectionError: If the public key cannot be fetched or parsed.
|
|
170
|
+
"""
|
|
171
|
+
public_key = self._token_service.get_rsa_public_key()
|
|
172
|
+
self._public_key = self._token_service.public_key_bytes
|
|
173
|
+
return public_key
|
|
174
|
+
|
|
175
|
+
@property
|
|
176
|
+
def saamfi_url(self) -> str:
|
|
177
|
+
"""Base URL of the Saamfi server."""
|
|
178
|
+
return self._saamfi_url
|
|
179
|
+
|
|
180
|
+
@property
|
|
181
|
+
def system_id(self) -> int:
|
|
182
|
+
"""Current tenant/system identifier."""
|
|
183
|
+
return self._system_id
|
|
184
|
+
|
|
185
|
+
@property
|
|
186
|
+
def client_id(self) -> Optional[int]:
|
|
187
|
+
"""Client identifier configured for this SDK instance."""
|
|
188
|
+
return self._client_id
|
|
189
|
+
|
|
190
|
+
@property
|
|
191
|
+
def client_secret(self) -> Optional[str]:
|
|
192
|
+
"""Client secret configured for this SDK instance."""
|
|
193
|
+
return self._client_secret
|
|
194
|
+
|
|
195
|
+
@property
|
|
196
|
+
def tenant_id(self) -> Optional[int]:
|
|
197
|
+
"""Tenant/Institution identifier configured for this SDK instance."""
|
|
198
|
+
return self._tenant_id
|
|
199
|
+
|
|
200
|
+
def login(self, username: str, password: str) -> Optional[LoginResponse]:
|
|
201
|
+
"""
|
|
202
|
+
Authenticate a user and return the login payload if successful.
|
|
203
|
+
|
|
204
|
+
Returns:
|
|
205
|
+
LoginResponse or None.
|
|
206
|
+
|
|
207
|
+
Raises:
|
|
208
|
+
SaamfiConnectionError: Request errors while contacting Saamfi.
|
|
209
|
+
"""
|
|
210
|
+
return self._auth_service.login(username, password)
|
|
211
|
+
|
|
212
|
+
def get_roles_from_jwt(self, auth_token: str) -> List[str]:
|
|
213
|
+
"""
|
|
214
|
+
Extract roles from a JWT token.
|
|
215
|
+
|
|
216
|
+
Returns:
|
|
217
|
+
List of role strings. Returns an empty list on any error.
|
|
218
|
+
"""
|
|
219
|
+
return self._auth_service.get_roles_from_jwt(auth_token)
|
|
220
|
+
|
|
221
|
+
def validate_token(self, auth_token: str) -> UserDetailToken:
|
|
222
|
+
"""
|
|
223
|
+
Validate a JWT token and return the associated user details.
|
|
224
|
+
|
|
225
|
+
Raises:
|
|
226
|
+
SaamfiTokenValidationError: Invalid or expired tokens.
|
|
227
|
+
"""
|
|
228
|
+
return self._auth_service.validate_token(auth_token)
|
|
229
|
+
|
|
230
|
+
def get_user_info(self, auth_token: str, user_id: int) -> Optional[UserInfo]:
|
|
231
|
+
"""
|
|
232
|
+
Retrieve complete user information by ID.
|
|
233
|
+
|
|
234
|
+
Returns:
|
|
235
|
+
UserInfo or None if the request fails or the user does not exist.
|
|
236
|
+
"""
|
|
237
|
+
return self._user_service.get_user_info(auth_token, user_id)
|
|
238
|
+
|
|
239
|
+
def get_user_by_username(self, auth_token: str, username: str) -> Optional[Dict[str, Any]]:
|
|
240
|
+
"""
|
|
241
|
+
Retrieve a user by username.
|
|
242
|
+
|
|
243
|
+
Raises:
|
|
244
|
+
SaamfiAuthenticationError: Invalid credentials (HTTP 401).
|
|
245
|
+
SaamfiConnectionError: Other request errors.
|
|
246
|
+
"""
|
|
247
|
+
return self._user_service.get_user_by_username(auth_token, username)
|
|
248
|
+
|
|
249
|
+
def get_users_by_document(self, auth_token: str, user_documents: List[str]) -> List[Dict[str, Any]]:
|
|
250
|
+
"""
|
|
251
|
+
Retrieve users by document numbers.
|
|
252
|
+
|
|
253
|
+
Raises:
|
|
254
|
+
SaamfiAuthenticationError: Invalid credentials (HTTP 401).
|
|
255
|
+
SaamfiConnectionError: Other request errors.
|
|
256
|
+
"""
|
|
257
|
+
return self._user_service.get_users_by_document(auth_token, user_documents)
|
|
258
|
+
|
|
259
|
+
def get_users_from_list(self, auth_token: str, user_ids: List[int]) -> Optional[str]:
|
|
260
|
+
"""Retrieve multiple users by their IDs."""
|
|
261
|
+
return self._user_service.get_users_from_list(auth_token, user_ids)
|
|
262
|
+
|
|
263
|
+
def get_users_by_param_and_value(self, auth_token: str, param: str, value: str) -> Optional[str]:
|
|
264
|
+
"""Retrieve users using the generic param/value endpoint."""
|
|
265
|
+
return self._user_service.get_users_by_param_and_value(auth_token, param, value)
|
|
266
|
+
|
|
267
|
+
def get_institution_by_nit(self, auth_token: str, nit: str) -> Optional[str]:
|
|
268
|
+
"""Retrieve institution metadata by NIT."""
|
|
269
|
+
return self._institution_service.get_institution_by_nit(auth_token, nit)
|
|
270
|
+
|
|
271
|
+
def get_institutions_by_ids(self, auth_token: str, institution_ids: List[int]) -> Optional[str]:
|
|
272
|
+
"""Retrieve multiple institutions by ID list."""
|
|
273
|
+
return self._institution_service.get_institutions_by_ids(auth_token, institution_ids)
|
|
274
|
+
|
|
275
|
+
def list_public_institutions(self) -> Optional[List[Dict[str, Any]]]:
|
|
276
|
+
"""Retrieve the list of public institutions exposed by Saamfi."""
|
|
277
|
+
return self._institution_service.list_public_institutions()
|
|
278
|
+
|
|
279
|
+
def get_institution_params(
|
|
280
|
+
self, auth_token: str, institution_id: Optional[int] = None
|
|
281
|
+
) -> Optional[Dict[str, Any]]:
|
|
282
|
+
"""Retrieve configuration parameters for an institution."""
|
|
283
|
+
target_institution = institution_id or self._tenant_id
|
|
284
|
+
if target_institution is None:
|
|
285
|
+
raise ValueError(
|
|
286
|
+
"institution_id must be provided either as parameter or configured via "
|
|
287
|
+
"SAAMFI_TENANT_ID/SAAMFI_INST_ID"
|
|
288
|
+
)
|
|
289
|
+
return self._institution_service.get_institution_params(auth_token, target_institution)
|
|
290
|
+
|
|
291
|
+
def list_public_systems(self) -> Optional[List[Dict[str, Any]]]:
|
|
292
|
+
"""Retrieve the list of systems available in Saamfi."""
|
|
293
|
+
return self._system_service.list_public_systems()
|
|
294
|
+
|
|
295
|
+
def get_system_roles(
|
|
296
|
+
self, auth_token: str, system_id: Optional[int] = None
|
|
297
|
+
) -> Optional[List[Dict[str, Any]]]:
|
|
298
|
+
"""Retrieve roles configured in a system."""
|
|
299
|
+
target_system = system_id or self._system_id
|
|
300
|
+
return self._system_service.get_system_roles(auth_token, target_system)
|
|
301
|
+
|
|
302
|
+
def __repr__(self) -> str:
|
|
303
|
+
"""Object representation for debugging."""
|
|
304
|
+
return (
|
|
305
|
+
"SaamfiClient("
|
|
306
|
+
f"saamfi_url='{self._saamfi_url}', "
|
|
307
|
+
f"system_id={self._system_id}, "
|
|
308
|
+
f"tenant_id={self._tenant_id}, "
|
|
309
|
+
f"client_id={self._client_id}"
|
|
310
|
+
")"
|
|
311
|
+
)
|
saamfi_sdk/exceptions.py
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
"""
|
|
4
|
+
Custom exceptions for the Saamfi SDK.
|
|
5
|
+
|
|
6
|
+
This module defines exceptions that can be raised by the SDK
|
|
7
|
+
during interaction with the Saamfi service.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class SaamfiException(Exception):
|
|
12
|
+
"""
|
|
13
|
+
Base exception for all Saamfi SDK exceptions.
|
|
14
|
+
|
|
15
|
+
Parameters
|
|
16
|
+
----------
|
|
17
|
+
message:
|
|
18
|
+
Optional custom error message. When omitted, a class-specific default
|
|
19
|
+
message is used.
|
|
20
|
+
details:
|
|
21
|
+
Optional dictionary with extra information to aid debugging or error
|
|
22
|
+
handling.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
default_message = "An unexpected Saamfi SDK error occurred."
|
|
26
|
+
|
|
27
|
+
def __init__(self, message: str | None = None, *, details: dict | None = None):
|
|
28
|
+
if message is None:
|
|
29
|
+
message = self.default_message
|
|
30
|
+
super().__init__(message)
|
|
31
|
+
self.details = details or {}
|
|
32
|
+
|
|
33
|
+
def __repr__(self) -> str: # pragma: no cover - debugging helper
|
|
34
|
+
return f"{self.__class__.__name__}({self.args[0]!r}, details={self.details!r})"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class SaamfiAuthenticationError(SaamfiException):
|
|
38
|
+
"""Exception raised when authentication with Saamfi fails."""
|
|
39
|
+
|
|
40
|
+
default_message = "Authentication with Saamfi failed due to invalid credentials."
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class SaamfiTokenValidationError(SaamfiException):
|
|
44
|
+
"""Exception raised when JWT token validation fails."""
|
|
45
|
+
|
|
46
|
+
default_message = "The provided Saamfi token is invalid or has expired."
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class SaamfiConnectionError(SaamfiException):
|
|
50
|
+
"""Exception raised when there are connection issues with the Saamfi server."""
|
|
51
|
+
|
|
52
|
+
default_message = "Unable to connect or communicate with the Saamfi service."
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class SaamfiUnauthorizedError(SaamfiException):
|
|
56
|
+
"""Exception raised when the caller lacks permissions for a Saamfi operation."""
|
|
57
|
+
|
|
58
|
+
default_message = "Operation not permitted: insufficient Saamfi permissions."
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class SaamfiNotFoundError(SaamfiException):
|
|
62
|
+
"""Exception raised when a requested Saamfi resource cannot be found."""
|
|
63
|
+
|
|
64
|
+
default_message = "The requested Saamfi resource could not be found."
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class SaamfiInvalidSystemError(SaamfiException):
|
|
68
|
+
"""Exception raised when the system_id in the token doesn't match the expected one."""
|
|
69
|
+
|
|
70
|
+
default_message = "The Saamfi token belongs to a different system than expected."
|
saamfi_sdk/models.py
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Data models for the Saamfi SDK.
|
|
3
|
+
|
|
4
|
+
This module defines the data classes used for communication with the Saamfi service.
|
|
5
|
+
All models use Pydantic for data validation.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from typing import List, Optional
|
|
9
|
+
|
|
10
|
+
from pydantic import AliasChoices, BaseModel, ConfigDict, Field
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class LoginBody(BaseModel):
|
|
14
|
+
"""
|
|
15
|
+
DTO for login requests.
|
|
16
|
+
|
|
17
|
+
Equivalent to: LoginBody.java
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
model_config = ConfigDict(populate_by_name=True, str_strip_whitespace=True, extra="forbid")
|
|
21
|
+
|
|
22
|
+
username: str = Field(..., description="Username")
|
|
23
|
+
password: str = Field(..., description="User password")
|
|
24
|
+
system_id: int = Field(
|
|
25
|
+
...,
|
|
26
|
+
description="System/tenant ID",
|
|
27
|
+
serialization_alias="sysid",
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class LoginResponse(BaseModel):
|
|
32
|
+
"""
|
|
33
|
+
DTO for successful authentication response.
|
|
34
|
+
|
|
35
|
+
Equivalent to: LoginResponse.java
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
model_config = ConfigDict(populate_by_name=True, extra="ignore")
|
|
39
|
+
|
|
40
|
+
id: int = Field(..., description="User ID")
|
|
41
|
+
username: str = Field(..., description="Username")
|
|
42
|
+
email: str = Field(..., description="Email address")
|
|
43
|
+
phone: Optional[str] = Field(None, description="Phone number")
|
|
44
|
+
name: str = Field(..., description="First name")
|
|
45
|
+
lastname: str = Field(..., description="Last name")
|
|
46
|
+
document_id: str = Field(
|
|
47
|
+
...,
|
|
48
|
+
description="Document ID or ID card",
|
|
49
|
+
validation_alias=AliasChoices("documentId", "document_id"),
|
|
50
|
+
serialization_alias="documentId",
|
|
51
|
+
)
|
|
52
|
+
token: str = Field(
|
|
53
|
+
...,
|
|
54
|
+
description="JWT access token",
|
|
55
|
+
validation_alias=AliasChoices("accessToken", "token"),
|
|
56
|
+
serialization_alias="accessToken",
|
|
57
|
+
)
|
|
58
|
+
token_type: str = Field(
|
|
59
|
+
...,
|
|
60
|
+
description="Token type (e.g., Bearer)",
|
|
61
|
+
validation_alias=AliasChoices("tokenType", "token_type"),
|
|
62
|
+
serialization_alias="tokenType",
|
|
63
|
+
)
|
|
64
|
+
system_home_page: str = Field(
|
|
65
|
+
...,
|
|
66
|
+
description="System home page URL",
|
|
67
|
+
validation_alias=AliasChoices("systemHomePage", "system_home_page"),
|
|
68
|
+
serialization_alias="systemHomePage",
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
@property
|
|
72
|
+
def access_token(self) -> str:
|
|
73
|
+
"""Backwards compatibility with previous attribute name."""
|
|
74
|
+
return self.token
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class UserInfo(BaseModel):
|
|
78
|
+
"""
|
|
79
|
+
DTO for detailed user information.
|
|
80
|
+
|
|
81
|
+
Equivalent to: UserInfo.java
|
|
82
|
+
"""
|
|
83
|
+
|
|
84
|
+
model_config = ConfigDict(populate_by_name=True, extra="ignore")
|
|
85
|
+
|
|
86
|
+
email: str = Field(..., description="Email address")
|
|
87
|
+
document_id: str = Field(
|
|
88
|
+
...,
|
|
89
|
+
description="Document ID or ID card",
|
|
90
|
+
validation_alias=AliasChoices("documentId", "document_id"),
|
|
91
|
+
serialization_alias="documentId",
|
|
92
|
+
)
|
|
93
|
+
username: str = Field(..., description="Username")
|
|
94
|
+
name: str = Field(..., description="First name")
|
|
95
|
+
lastname: str = Field(..., description="Last name")
|
|
96
|
+
phone: Optional[str] = Field(None, description="Phone number")
|
|
97
|
+
password: Optional[str] = Field(None, description="Password (usually not returned)")
|
|
98
|
+
institution: Optional[int] = Field(None, description="Institution ID")
|
|
99
|
+
institution_name: Optional[str] = Field(
|
|
100
|
+
None,
|
|
101
|
+
description="Institution name",
|
|
102
|
+
validation_alias=AliasChoices("institutionName", "institution_name"),
|
|
103
|
+
serialization_alias="institutionName",
|
|
104
|
+
)
|
|
105
|
+
system: Optional[int] = Field(None, description="System ID")
|
|
106
|
+
is_active: Optional[str] = Field(
|
|
107
|
+
None,
|
|
108
|
+
description="User active status",
|
|
109
|
+
validation_alias=AliasChoices("isActive", "is_active"),
|
|
110
|
+
serialization_alias="isActive",
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
class UserDetailToken(BaseModel):
|
|
115
|
+
"""
|
|
116
|
+
User information extracted from JWT token.
|
|
117
|
+
|
|
118
|
+
Equivalent to: UserDetailToken.java (Spring Security UserDetails implementation)
|
|
119
|
+
|
|
120
|
+
In Python we don't need to implement an interface like UserDetails,
|
|
121
|
+
but we maintain the same data structure.
|
|
122
|
+
"""
|
|
123
|
+
|
|
124
|
+
model_config = ConfigDict(populate_by_name=True, extra="ignore")
|
|
125
|
+
|
|
126
|
+
username: str = Field(..., description="Username")
|
|
127
|
+
system: int = Field(..., description="System ID")
|
|
128
|
+
pers_id: str = Field(
|
|
129
|
+
...,
|
|
130
|
+
description="Person ID",
|
|
131
|
+
validation_alias=AliasChoices("persId", "pers_id"),
|
|
132
|
+
serialization_alias="persId",
|
|
133
|
+
)
|
|
134
|
+
roles: List[str] = Field(default_factory=list, description="User roles and permissions")
|
|
135
|
+
|
|
136
|
+
def __str__(self) -> str:
|
|
137
|
+
"""String representation of the object, equivalent to Java's toString()."""
|
|
138
|
+
return (
|
|
139
|
+
f"UserDetailToken [persId={self.pers_id}, roles={self.roles}, "
|
|
140
|
+
f"system={self.system}, username={self.username}]"
|
|
141
|
+
)
|
saamfi_sdk/py.typed
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""Service-layer helpers used by the Saamfi client."""
|
|
2
|
+
|
|
3
|
+
from .auth import AuthenticationService
|
|
4
|
+
from .base import SaamfiServiceBase
|
|
5
|
+
from .institutions import InstitutionService
|
|
6
|
+
from .systems import SystemService
|
|
7
|
+
from .token import TokenService
|
|
8
|
+
from .users import UserService
|
|
9
|
+
|
|
10
|
+
__all__ = [
|
|
11
|
+
"AuthenticationService",
|
|
12
|
+
"InstitutionService",
|
|
13
|
+
"SystemService",
|
|
14
|
+
"SaamfiServiceBase",
|
|
15
|
+
"TokenService",
|
|
16
|
+
"UserService",
|
|
17
|
+
]
|
|
18
|
+
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from typing import Optional
|
|
5
|
+
|
|
6
|
+
from saamfi_sdk.exceptions import (
|
|
7
|
+
SaamfiAuthenticationError,
|
|
8
|
+
SaamfiConnectionError,
|
|
9
|
+
SaamfiTokenValidationError,
|
|
10
|
+
)
|
|
11
|
+
from saamfi_sdk.models import LoginBody, LoginResponse, UserDetailToken
|
|
12
|
+
|
|
13
|
+
from .base import SaamfiServiceBase
|
|
14
|
+
from .token import TokenService
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class AuthenticationService(SaamfiServiceBase):
|
|
20
|
+
"""Authentication-related operations."""
|
|
21
|
+
|
|
22
|
+
def __init__(
|
|
23
|
+
self,
|
|
24
|
+
base_url: str,
|
|
25
|
+
session,
|
|
26
|
+
system_id: int,
|
|
27
|
+
token_service: TokenService,
|
|
28
|
+
):
|
|
29
|
+
super().__init__(base_url, session)
|
|
30
|
+
self._system_id = system_id
|
|
31
|
+
self._token_service = token_service
|
|
32
|
+
|
|
33
|
+
def login(self, username: str, password: str) -> Optional[LoginResponse]:
|
|
34
|
+
"""Authenticate a user and return the login response."""
|
|
35
|
+
login_body = LoginBody(username=username, password=password, system_id=self._system_id)
|
|
36
|
+
|
|
37
|
+
try:
|
|
38
|
+
response = self._post(
|
|
39
|
+
"public/authentication/login",
|
|
40
|
+
json=login_body.model_dump(by_alias=True),
|
|
41
|
+
timeout=30,
|
|
42
|
+
)
|
|
43
|
+
except Exception as exc:
|
|
44
|
+
logger.error("Error in authentication request: %s", exc)
|
|
45
|
+
raise SaamfiConnectionError(f"Error during authentication: {exc}") from exc
|
|
46
|
+
|
|
47
|
+
if self._is_success(response.status_code):
|
|
48
|
+
try:
|
|
49
|
+
login_response = LoginResponse(**response.json())
|
|
50
|
+
except Exception as exc:
|
|
51
|
+
logger.warning("Unexpected login response payload: %s", exc)
|
|
52
|
+
return None
|
|
53
|
+
|
|
54
|
+
logger.info("User '%s' authenticated successfully", username)
|
|
55
|
+
return login_response
|
|
56
|
+
|
|
57
|
+
if response.status_code == 401:
|
|
58
|
+
logger.warning("Authentication failed for user '%s': 401", username)
|
|
59
|
+
return None
|
|
60
|
+
|
|
61
|
+
logger.warning("Authentication failed for user '%s': %s", username, response.status_code)
|
|
62
|
+
return None
|
|
63
|
+
|
|
64
|
+
def get_roles_from_jwt(self, auth_token: str) -> list[str]:
|
|
65
|
+
"""Return roles stored in the JWT token."""
|
|
66
|
+
return self._token_service.extract_roles(auth_token)
|
|
67
|
+
|
|
68
|
+
def validate_token(self, auth_token: str) -> UserDetailToken:
|
|
69
|
+
"""Validate a JWT token and return user details."""
|
|
70
|
+
decoded_token = self._token_service.validate_token(auth_token)
|
|
71
|
+
|
|
72
|
+
try:
|
|
73
|
+
username = str(decoded_token.get(TokenService.USERNAME_CLAIM))
|
|
74
|
+
system = int(decoded_token.get(TokenService.SYSTEM_CLAIM))
|
|
75
|
+
pers_id = str(decoded_token.get(TokenService.ID_CLAIM))
|
|
76
|
+
except (TypeError, ValueError, KeyError) as exc:
|
|
77
|
+
logger.error("Missing required claim in token: %s", exc)
|
|
78
|
+
raise SaamfiTokenValidationError(f"Missing required claim in token: {exc}") from exc
|
|
79
|
+
|
|
80
|
+
roles = self.get_roles_from_jwt(auth_token)
|
|
81
|
+
|
|
82
|
+
user_detail = UserDetailToken(
|
|
83
|
+
username=username,
|
|
84
|
+
system=system,
|
|
85
|
+
pers_id=pers_id,
|
|
86
|
+
roles=roles,
|
|
87
|
+
)
|
|
88
|
+
logger.info("Token validated successfully for user '%s'", username)
|
|
89
|
+
return user_detail
|
|
90
|
+
|