keyrunes-python-sdk 0.0.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.
@@ -0,0 +1,10 @@
1
+ keyrunes_sdk/__init__.py,sha256=YS_9hZGg-EgFJagZHkOox5d4SoLRCEr8AaxUaNdWyis,1085
2
+ keyrunes_sdk/client.py,sha256=eNIj7iS1WuePzXsZUq06f7PDxrsDgG8nY_CGKH5AVzk,17922
3
+ keyrunes_sdk/config.py,sha256=KkkFKkBOx7wlK393f7o90yTOQQlG81qYaapCsia8sFs,5577
4
+ keyrunes_sdk/decorators.py,sha256=b_AYuGbcGYa_RXtN6O32-HY6jiyOIw-xjgvHg01ZEWY,8197
5
+ keyrunes_sdk/exceptions.py,sha256=LU9I1jcMoIQtupBeoSCGwozaYgPtSW9LEF5AVR0qdFI,721
6
+ keyrunes_sdk/models.py,sha256=v5-JbEuPmgoSRK9i3wY9YrXcTeMfQ2n-bJCnmrXVP6Y,3211
7
+ keyrunes_python_sdk-0.0.1.dist-info/LICENSE,sha256=hIahDEOTzuHCU5J2nd07LWwkLW7Hko4UFO__ffsvB-8,34523
8
+ keyrunes_python_sdk-0.0.1.dist-info/METADATA,sha256=dd4O6IEkxR6vv2G4Sxb8lpppLugkgo_OGSTayBUeCO4,23272
9
+ keyrunes_python_sdk-0.0.1.dist-info/WHEEL,sha256=b4K_helf-jlQoXBBETfwnf4B04YC67LOev0jo4fX5m8,88
10
+ keyrunes_python_sdk-0.0.1.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: poetry-core 2.1.3
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,52 @@
1
+ """Keyrunes SDK - Python client for Keyrunes Authorization System."""
2
+
3
+ __version__ = "0.1.0"
4
+
5
+ from keyrunes_sdk.client import KeyrunesClient
6
+ from keyrunes_sdk.config import (
7
+ clear_global_client,
8
+ configure,
9
+ get_config,
10
+ get_global_client,
11
+ )
12
+ from keyrunes_sdk.decorators import require_admin, require_group
13
+ from keyrunes_sdk.exceptions import (
14
+ AuthenticationError,
15
+ AuthorizationError,
16
+ GroupNotFoundError,
17
+ KeyrunesError,
18
+ NetworkError,
19
+ UserNotFoundError,
20
+ )
21
+ from keyrunes_sdk.models import (
22
+ AdminRegistration,
23
+ Group,
24
+ GroupCheck,
25
+ LoginCredentials,
26
+ Token,
27
+ User,
28
+ UserRegistration,
29
+ )
30
+
31
+ __all__ = [
32
+ "KeyrunesClient",
33
+ "configure",
34
+ "get_global_client",
35
+ "clear_global_client",
36
+ "get_config",
37
+ "require_group",
38
+ "require_admin",
39
+ "KeyrunesError",
40
+ "AuthenticationError",
41
+ "AuthorizationError",
42
+ "GroupNotFoundError",
43
+ "UserNotFoundError",
44
+ "NetworkError",
45
+ "User",
46
+ "Group",
47
+ "Token",
48
+ "UserRegistration",
49
+ "AdminRegistration",
50
+ "LoginCredentials",
51
+ "GroupCheck",
52
+ ]
keyrunes_sdk/client.py ADDED
@@ -0,0 +1,565 @@
1
+ """Keyrunes API Client."""
2
+
3
+ from typing import Any, Dict, List, Optional
4
+ from urllib.parse import urljoin
5
+
6
+ import httpx
7
+ import jwt
8
+
9
+ from keyrunes_sdk.exceptions import (
10
+ AuthenticationError,
11
+ AuthorizationError,
12
+ GroupNotFoundError,
13
+ NetworkError,
14
+ UserNotFoundError,
15
+ )
16
+ from keyrunes_sdk.models import (
17
+ AdminRegistration,
18
+ GroupCheck,
19
+ LoginCredentials,
20
+ Token,
21
+ User,
22
+ UserRegistration,
23
+ )
24
+
25
+
26
+ class KeyrunesClient:
27
+ """
28
+ Client for interacting with Keyrunes Authorization System.
29
+
30
+ This client provides methods for:
31
+ - User authentication (login)
32
+ - User registration
33
+ - Admin registration
34
+ - Group membership verification
35
+ - Authorization checks
36
+
37
+ Args:
38
+ base_url: Base URL of the Keyrunes API
39
+ (e.g., "https://keyrunes.example.com")
40
+ api_key: Optional API key for authentication
41
+ timeout: Request timeout in seconds (default: 30)
42
+
43
+ Example:
44
+ >>> client = KeyrunesClient("https://keyrunes.example.com")
45
+ >>> token = client.login("user@example.com", "password123")
46
+ >>> print(token.access_token)
47
+ """
48
+
49
+ def __init__(
50
+ self,
51
+ base_url: str,
52
+ api_key: Optional[str] = None,
53
+ timeout: int = 30,
54
+ ) -> None:
55
+ """Initialize Keyrunes client."""
56
+ self.base_url = base_url.rstrip("/")
57
+ self.api_key = api_key
58
+ self.timeout = timeout
59
+ self._token: Optional[str] = None
60
+ self._token_data: Optional[Dict[str, Any]] = None
61
+ self._client = httpx.Client(timeout=timeout)
62
+
63
+ if api_key:
64
+ self._client.headers.update({"X-API-Key": api_key})
65
+
66
+ def _make_request(
67
+ self,
68
+ method: str,
69
+ endpoint: str,
70
+ data: Optional[Dict[str, Any]] = None,
71
+ params: Optional[Dict[str, Any]] = None,
72
+ use_auth: bool = True,
73
+ ) -> Dict[str, Any]:
74
+ """
75
+ Make HTTP request to Keyrunes API.
76
+
77
+ Args:
78
+ method: HTTP method (GET, POST, etc.)
79
+ endpoint: API endpoint
80
+ data: Request body data
81
+ params: Query parameters
82
+ use_auth: Whether to include authentication header
83
+
84
+ Returns:
85
+ Response JSON data
86
+
87
+ Raises:
88
+ NetworkError: If request fails
89
+ AuthenticationError: If authentication fails (401)
90
+ AuthorizationError: If authorization fails (403)
91
+ """
92
+ url = urljoin(self.base_url + "/", endpoint.lstrip("/"))
93
+ headers = {}
94
+
95
+ if use_auth and self._token:
96
+ headers["Authorization"] = f"Bearer {self._token}"
97
+
98
+ try:
99
+ response = self._client.request(
100
+ method=method,
101
+ url=url,
102
+ json=data,
103
+ params=params,
104
+ headers=headers,
105
+ )
106
+
107
+ if response.status_code == 401:
108
+ raise AuthenticationError(
109
+ "Authentication failed. Invalid credentials or token."
110
+ )
111
+ elif response.status_code == 403:
112
+ raise AuthorizationError(
113
+ "Authorization denied. Insufficient permissions."
114
+ )
115
+ elif response.status_code == 404:
116
+ raise UserNotFoundError("Resource not found.")
117
+ elif response.status_code >= 400:
118
+ try:
119
+ error_data = response.json()
120
+ error_msg = error_data.get("error", response.text)
121
+ except (ValueError, TypeError):
122
+ error_msg = (
123
+ response.text or f"HTTP {response.status_code} error"
124
+ )
125
+ raise NetworkError(f"Request failed: {error_msg}")
126
+
127
+ result: Dict[str, Any] = response.json()
128
+ return result
129
+
130
+ except httpx.RequestError as e:
131
+ raise NetworkError(f"Network request failed: {str(e)}")
132
+
133
+ @staticmethod
134
+ def _normalize_user(data: Dict[str, Any]) -> User:
135
+ """Convert API user payload to SDK User model."""
136
+ if not data:
137
+ raise NetworkError("Empty user payload received from server.")
138
+
139
+ normalized: Dict[str, Any] = {}
140
+ normalized["id"] = str(
141
+ data.get("id") or data.get("user_id") or data.get("external_id")
142
+ )
143
+ normalized["username"] = data.get("username", "")
144
+ normalized["email"] = data.get("email", "")
145
+ normalized["groups"] = data.get("groups", []) or []
146
+ normalized["attributes"] = data.get("attributes", {})
147
+ normalized["is_active"] = data.get("is_active", True)
148
+ groups = normalized["groups"]
149
+ is_admin_flag = data.get("is_admin", False)
150
+ has_admin_group = "admins" in groups or "superadmin" in groups
151
+ has_admin_in_name = any("admin" in str(g).lower() for g in groups)
152
+ normalized["is_admin"] = (
153
+ is_admin_flag or has_admin_group or has_admin_in_name
154
+ )
155
+ return User(**normalized)
156
+
157
+ def _parse_token_response(self, payload: Dict[str, Any]) -> Token:
158
+ """
159
+ Accept both legacy and current API token responses.
160
+
161
+ - New API: {"token": "...", "user": {...},
162
+ "requires_password_change": false}
163
+ - Legacy: {access_token, token_type, expires_in,
164
+ refresh_token, user}
165
+ """
166
+ if "access_token" in payload:
167
+ user = payload.get("user")
168
+ if isinstance(user, dict):
169
+ payload["user"] = self._normalize_user(user)
170
+ return Token(**payload)
171
+
172
+ token_value = payload.get("token")
173
+ if not token_value:
174
+ raise AuthenticationError(
175
+ "No token returned by authentication endpoint."
176
+ )
177
+
178
+ user_payload = payload.get("user")
179
+ user_model = (
180
+ self._normalize_user(user_payload)
181
+ if isinstance(user_payload, dict)
182
+ else None
183
+ )
184
+
185
+ return Token(
186
+ access_token=token_value,
187
+ token_type="bearer",
188
+ expires_in=payload.get("expires_in"),
189
+ refresh_token=payload.get("refresh_token"),
190
+ user=user_model,
191
+ )
192
+
193
+ def login(self, username: str, password: str) -> Token:
194
+ """
195
+ Authenticate user and obtain access token.
196
+
197
+ Args:
198
+ username: Username or email
199
+ password: User password
200
+
201
+ Returns:
202
+ Token object containing access token and user info
203
+
204
+ Raises:
205
+ AuthenticationError: If login fails
206
+
207
+ Example:
208
+ >>> client = KeyrunesClient("https://keyrunes.example.com")
209
+ >>> token = client.login("user@example.com", "password123")
210
+ >>> client.set_token(token.access_token)
211
+ """
212
+ credentials = LoginCredentials(identity=username, password=password)
213
+ response = self._make_request(
214
+ "POST",
215
+ "/api/login",
216
+ data=credentials.model_dump(),
217
+ use_auth=False,
218
+ )
219
+
220
+ token = self._parse_token_response(response)
221
+ self._token = token.access_token
222
+ try:
223
+ self._token_data = jwt.decode(
224
+ token.access_token, options={"verify_signature": False}
225
+ )
226
+ except Exception:
227
+ self._token_data = None
228
+ return token
229
+
230
+ def register_user(
231
+ self, username: str, email: str, password: str, **attributes: Any
232
+ ) -> User:
233
+ """
234
+ Register a new user.
235
+
236
+ Args:
237
+ username: Username (3-50 characters)
238
+ email: User email address
239
+ password: Password (minimum 8 characters)
240
+ **attributes: Additional user attributes
241
+
242
+ Returns:
243
+ Created User object
244
+
245
+ Raises:
246
+ AuthenticationError: If registration fails
247
+
248
+ Example:
249
+ >>> client = KeyrunesClient("https://keyrunes.example.com")
250
+ >>> user = client.register_user(
251
+ ... username="newuser",
252
+ ... email="newuser@example.com",
253
+ ... password="securepass123",
254
+ ... department="Engineering"
255
+ ... )
256
+ """
257
+ registration = UserRegistration(
258
+ username=username,
259
+ email=email,
260
+ password=password,
261
+ attributes=attributes,
262
+ )
263
+ response = self._make_request(
264
+ "POST",
265
+ "/api/register",
266
+ data=registration.model_dump(),
267
+ use_auth=False,
268
+ )
269
+
270
+ user_payload = (
271
+ response.get("user") if isinstance(response, dict) else None
272
+ )
273
+ if not user_payload:
274
+ raise NetworkError(
275
+ "Unexpected response format for user registration."
276
+ )
277
+
278
+ return self._normalize_user(user_payload)
279
+
280
+ def register_admin(
281
+ self,
282
+ username: str,
283
+ email: str,
284
+ password: str,
285
+ admin_key: str,
286
+ **attributes: Any,
287
+ ) -> User:
288
+ """
289
+ Register a new admin user.
290
+
291
+ Args:
292
+ username: Username (3-50 characters)
293
+ email: Admin email address
294
+ password: Password (minimum 8 characters)
295
+ admin_key: Admin registration key
296
+ **attributes: Additional user attributes
297
+
298
+ Returns:
299
+ Created User object with admin privileges
300
+
301
+ Raises:
302
+ AuthenticationError: If registration fails
303
+ AuthorizationError: If admin key is invalid
304
+
305
+ Example:
306
+ >>> client = KeyrunesClient("https://keyrunes.example.com")
307
+ >>> admin = client.register_admin(
308
+ ... username="adminuser",
309
+ ... email="admin@example.com",
310
+ ... password="securepass123",
311
+ ... admin_key="secret-admin-key"
312
+ ... )
313
+ """
314
+ registration = AdminRegistration(
315
+ username=username,
316
+ email=email,
317
+ password=password,
318
+ admin_key=admin_key,
319
+ attributes=attributes,
320
+ )
321
+ response = self._make_request(
322
+ "POST",
323
+ "/api/register",
324
+ data=registration.model_dump(),
325
+ use_auth=False,
326
+ )
327
+
328
+ user_payload = (
329
+ response.get("user") if isinstance(response, dict) else None
330
+ )
331
+ if not user_payload:
332
+ raise NetworkError(
333
+ "Unexpected response format for admin registration."
334
+ )
335
+
336
+ return self._normalize_user(user_payload)
337
+
338
+ def has_group(self, user_id: str, group_id: str) -> bool:
339
+ """
340
+ Check if a user belongs to a specific group.
341
+
342
+ Args:
343
+ user_id: User ID to check
344
+ group_id: Group ID to verify membership
345
+
346
+ Returns:
347
+ True if user belongs to the group, False otherwise
348
+
349
+ Raises:
350
+ AuthenticationError: If not authenticated
351
+ GroupNotFoundError: If group doesn't exist
352
+
353
+ Example:
354
+ >>> client = KeyrunesClient("https://keyrunes.example.com")
355
+ >>> client.login("user@example.com", "password")
356
+ >>> has_access = client.has_group("user123", "admins")
357
+ >>> if has_access:
358
+ ... print("User has admin access")
359
+ """
360
+ if not self._token:
361
+ raise AuthenticationError("Not authenticated. Please login first.")
362
+
363
+ token_user_id = (
364
+ str(self._token_data.get("sub", "")) if self._token_data else None
365
+ )
366
+
367
+ if token_user_id and str(user_id) == token_user_id:
368
+ groups = (
369
+ self._token_data.get("groups", []) if self._token_data else []
370
+ )
371
+ return group_id in groups
372
+
373
+ try:
374
+ response = self._make_request(
375
+ "GET",
376
+ f"/api/users/{user_id}/groups/{group_id}",
377
+ )
378
+ check = GroupCheck(**response)
379
+ return check.has_access
380
+ except UserNotFoundError:
381
+ if token_user_id and str(user_id) == token_user_id:
382
+ groups = (
383
+ self._token_data.get("groups", [])
384
+ if self._token_data
385
+ else []
386
+ )
387
+ return group_id in groups
388
+ raise GroupNotFoundError(
389
+ f"Group '{group_id}' not found or user not in group"
390
+ )
391
+
392
+ def get_user(self, user_id: str) -> User:
393
+ """
394
+ Get user information by ID.
395
+
396
+ Args:
397
+ user_id: User ID
398
+
399
+ Returns:
400
+ User object
401
+
402
+ Raises:
403
+ AuthenticationError: If not authenticated
404
+ UserNotFoundError: If user doesn't exist
405
+
406
+ Example:
407
+ >>> client = KeyrunesClient("https://keyrunes.example.com")
408
+ >>> client.login("user@example.com", "password")
409
+ >>> user = client.get_user("user123")
410
+ >>> print(user.username)
411
+ """
412
+ if not self._token:
413
+ raise AuthenticationError("Not authenticated. Please login first.")
414
+
415
+ token_user_id = (
416
+ str(self._token_data.get("sub", "")) if self._token_data else None
417
+ )
418
+
419
+ if token_user_id and str(user_id) == token_user_id and self._token_data:
420
+ token_data = self._token_data
421
+ user_data = {
422
+ "id": str(token_data.get("sub", "")),
423
+ "username": token_data.get("username", ""),
424
+ "email": token_data.get("email", ""),
425
+ "groups": token_data.get("groups", []),
426
+ }
427
+ return self._normalize_user(user_data)
428
+
429
+ try:
430
+ response = self._make_request("GET", f"/api/users/{user_id}")
431
+ return self._normalize_user(response)
432
+ except UserNotFoundError:
433
+ if (
434
+ token_user_id
435
+ and str(user_id) == token_user_id
436
+ and self._token_data
437
+ ):
438
+ token_data = self._token_data
439
+ user_data = {
440
+ "id": str(token_data.get("sub", "")),
441
+ "username": token_data.get("username", ""),
442
+ "email": token_data.get("email", ""),
443
+ "groups": token_data.get("groups", []),
444
+ }
445
+ return self._normalize_user(user_data)
446
+ raise
447
+
448
+ def get_current_user(self) -> User:
449
+ """
450
+ Get currently authenticated user information.
451
+
452
+ Returns:
453
+ User object for authenticated user
454
+
455
+ Raises:
456
+ AuthenticationError: If not authenticated
457
+
458
+ Example:
459
+ >>> client = KeyrunesClient("https://keyrunes.example.com")
460
+ >>> client.login("user@example.com", "password")
461
+ >>> me = client.get_current_user()
462
+ >>> print(f"Logged in as: {me.username}")
463
+ """
464
+ if not self._token:
465
+ raise AuthenticationError("Not authenticated. Please login first.")
466
+
467
+ if self._token_data:
468
+ token_data = self._token_data
469
+ user_data = {
470
+ "id": str(token_data.get("sub", "")),
471
+ "username": token_data.get("username", ""),
472
+ "email": token_data.get("email", ""),
473
+ "groups": token_data.get("groups", []),
474
+ }
475
+ return self._normalize_user(user_data)
476
+
477
+ try:
478
+ response = self._make_request("GET", "/api/users/me")
479
+ return self._normalize_user(response)
480
+ except UserNotFoundError:
481
+ if self._token_data:
482
+ token_data = self._token_data
483
+ user_data = {
484
+ "id": str(token_data.get("sub", "")),
485
+ "username": token_data.get("username", ""),
486
+ "email": token_data.get("email", ""),
487
+ "groups": token_data.get("groups", []),
488
+ }
489
+ return self._normalize_user(user_data)
490
+ raise
491
+
492
+ def get_user_groups(self, user_id: Optional[str] = None) -> List[str]:
493
+ """
494
+ Get list of groups for a user.
495
+
496
+ Args:
497
+ user_id: User ID (if None, uses current user)
498
+
499
+ Returns:
500
+ List of group IDs
501
+
502
+ Raises:
503
+ AuthenticationError: If not authenticated
504
+
505
+ Example:
506
+ >>> client = KeyrunesClient("https://keyrunes.example.com")
507
+ >>> client.login("user@example.com", "password")
508
+ >>> groups = client.get_user_groups()
509
+ >>> print(f"User belongs to: {groups}")
510
+ """
511
+ if user_id:
512
+ user = self.get_user(user_id)
513
+ else:
514
+ user = self.get_current_user()
515
+
516
+ return user.groups
517
+
518
+ def set_token(self, token: str) -> None:
519
+ """
520
+ Set authentication token for subsequent requests.
521
+
522
+ Args:
523
+ token: JWT access token
524
+
525
+ Example:
526
+ >>> client = KeyrunesClient("https://keyrunes.example.com")
527
+ >>> client.set_token("eyJhbGciOiJIUzI1NiIs...")
528
+ """
529
+ self._token = token
530
+ try:
531
+ self._token_data = jwt.decode(
532
+ token, options={"verify_signature": False}
533
+ )
534
+ except Exception:
535
+ self._token_data = None
536
+
537
+ def clear_token(self) -> None:
538
+ """
539
+ Clear authentication token.
540
+
541
+ Example:
542
+ >>> client = KeyrunesClient("https://keyrunes.example.com")
543
+ >>> client.clear_token()
544
+ """
545
+ self._token = None
546
+ self._token_data = None
547
+
548
+ def close(self) -> None:
549
+ """
550
+ Close HTTP client.
551
+
552
+ Example:
553
+ >>> client = KeyrunesClient("https://keyrunes.example.com")
554
+ >>> # ... use client ...
555
+ >>> client.close()
556
+ """
557
+ self._client.close()
558
+
559
+ def __enter__(self) -> "KeyrunesClient":
560
+ """Context manager entry."""
561
+ return self
562
+
563
+ def __exit__(self, *args: Any) -> None:
564
+ """Context manager exit."""
565
+ self.close()