sso-nebus 0.1.3__py3-none-any.whl → 0.1.6__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.
sso_nebus/base.py CHANGED
@@ -1,6 +1,6 @@
1
1
  """Базовый класс для SSO клиентов"""
2
2
 
3
- from typing import Optional, Dict, Any
3
+ from typing import Optional, Dict, Any, List
4
4
  from urllib.parse import urljoin
5
5
  from abc import ABC, abstractmethod
6
6
 
@@ -174,49 +174,54 @@ class BaseClient(ABC):
174
174
  if form_data:
175
175
  headers.pop("Content-Type", None)
176
176
 
177
- async with self.session.request(
178
- method=method,
179
- url=url,
180
- headers=headers,
181
- json=json_data,
182
- data=form_data,
183
- params=params,
184
- ) as response:
185
- # Если получили 401 и включен авто-рефреш, пытаемся обновить токен
186
- if (
187
- response.status == 401
188
- and self.auto_refresh_token
189
- and retry_on_401
190
- and not self._refreshing
191
- and access_token
192
- ):
193
- try:
194
- # Пытаемся обновить токен
195
- self._refreshing = True
196
- await self._refresh_token()
197
- # Получаем новый токен
198
- new_token = self._get_access_token()
199
- if new_token and new_token != access_token:
200
- # Повторяем запрос с новым токеном
201
- headers = self._get_headers(new_token)
202
- if form_data:
203
- headers.pop("Content-Type", None)
204
- async with self.session.request(
205
- method=method,
206
- url=url,
207
- headers=headers,
208
- json=json_data,
209
- data=form_data,
210
- params=params,
211
- ) as retry_response:
212
- return await self._handle_response(retry_response)
213
- except Exception:
214
- # Если обновление не удалось, пробрасываем оригинальную ошибку
215
- pass
216
- finally:
217
- self._refreshing = False
218
-
219
- return await self._handle_response(response)
177
+ try:
178
+ async with self.session.request(
179
+ method=method,
180
+ url=url,
181
+ headers=headers,
182
+ json=json_data,
183
+ data=form_data,
184
+ params=params,
185
+ ) as response:
186
+ # Если получили 401 и включен авто-рефреш, пытаемся обновить токен
187
+ if (
188
+ response.status == 401
189
+ and self.auto_refresh_token
190
+ and retry_on_401
191
+ and not self._refreshing
192
+ and access_token
193
+ ):
194
+ try:
195
+ # Пытаемся обновить токен
196
+ self._refreshing = True
197
+ await self._refresh_token()
198
+ # Получаем новый токен
199
+ new_token = self._get_access_token()
200
+ if new_token and new_token != access_token:
201
+ # Повторяем запрос с новым токеном
202
+ headers = self._get_headers(new_token)
203
+ if form_data:
204
+ headers.pop("Content-Type", None)
205
+ async with self.session.request(
206
+ method=method,
207
+ url=url,
208
+ headers=headers,
209
+ json=json_data,
210
+ data=form_data,
211
+ params=params,
212
+ ) as retry_response:
213
+ return await self._handle_response(retry_response)
214
+ except Exception:
215
+ # Если обновление не удалось, пробрасываем оригинальную ошибку
216
+ pass
217
+ finally:
218
+ self._refreshing = False
219
+
220
+ return await self._handle_response(response)
221
+ finally:
222
+ # Закрываем сессию после взаимодействия (если она наша), чтобы избежать "Unclosed client session"
223
+ if self._own_session and self._session and not self._session.closed:
224
+ await self._session.close()
220
225
 
221
226
  async def get(
222
227
  self,
@@ -394,18 +399,17 @@ class BaseClient(ABC):
394
399
  async def create_scope(
395
400
  self,
396
401
  name: str,
397
- service_name: str,
398
- resource: str,
402
+ client: str,
403
+ role: str,
399
404
  action: str,
400
405
  description: Optional[str] = None,
401
406
  access_token: Optional[str] = None,
402
407
  ) -> Dict[str, Any]:
403
408
  """Создать новое разрешение (требует sso.admin.create)"""
404
409
  json_data = {
405
- "name": name,
406
- "service_name": service_name,
407
- "resource": resource,
408
- "action": action,
410
+ "client": client,
411
+ "role": role,
412
+ "action": action
409
413
  }
410
414
  if description:
411
415
  json_data["description"] = description
@@ -479,16 +483,29 @@ class BaseClient(ABC):
479
483
  async def assign_scopes_to_client(
480
484
  self,
481
485
  client_id: str,
482
- scope_ids: list[int],
486
+ scope_ids: List[int],
483
487
  access_token: Optional[str] = None,
484
488
  ) -> Dict[str, Any]:
485
- """Назначить разрешения клиенту (требует sso.admin.edit)"""
489
+ """Назначить разрешения (scopes) клиенту (требует sso.admin.edit)"""
486
490
  return await self.post(
487
491
  f"admin/clients/{client_id}/scopes",
488
492
  access_token=access_token,
489
493
  json_data={"scope_ids": scope_ids},
490
494
  )
491
495
 
496
+ async def assign_roles_to_client(
497
+ self,
498
+ client_id: str,
499
+ role_ids: List[int],
500
+ access_token: Optional[str] = None,
501
+ ) -> Dict[str, Any]:
502
+ """Привязать роли к микросервису (клиенту). POST admin/clients/{client_id}/roles, body: role_ids (требует sso.admin.edit)"""
503
+ return await self.post(
504
+ f"admin/clients/{client_id}/roles",
505
+ access_token=access_token,
506
+ json_data={"role_ids": role_ids},
507
+ )
508
+
492
509
  async def rotate_client_secret(
493
510
  self,
494
511
  client_id: str,
@@ -1,147 +1,197 @@
1
- """Клиент для микросервисного взаимодействия (Client Credentials)"""
2
-
3
- from typing import Optional
4
-
5
- from .base import BaseClient
6
- from .models import TokenResponse, UserInfo
7
- from .exceptions import TokenError
8
-
9
-
10
- class ServiceClient(BaseClient):
11
- """Клиент для микросервисного взаимодействия с Client Credentials Grant"""
12
-
13
- def __init__(
14
- self,
15
- base_url: str,
16
- client_id: str,
17
- client_secret: str,
18
- api_version: str = "v1",
19
- timeout: int = 30,
20
- session=None,
21
- auto_refresh_token: bool = True,
22
- default_scope: Optional[str] = None,
23
- ):
24
- """
25
- Инициализация клиента для микросервисов
26
-
27
- Args:
28
- base_url: Базовый URL API
29
- client_id: ID микросервиса
30
- client_secret: Секрет микросервиса
31
- api_version: Версия API
32
- timeout: Таймаут запросов
33
- session: Опциональная aiohttp сессия
34
- auto_refresh_token: Автоматически обновлять токен при получении 401 ошибки
35
- default_scope: Scope по умолчанию для автоматического получения токена
36
- """
37
- super().__init__(base_url, api_version, timeout, session, auto_refresh_token)
38
- self.client_id = client_id
39
- self.client_secret = client_secret
40
- self._access_token: Optional[str] = None
41
- self.default_scope = default_scope
42
-
43
- async def get_access_token(self, scope: Optional[str] = None) -> TokenResponse:
44
- """
45
- Получить access token используя Client Credentials Grant
46
-
47
- Args:
48
- scope: Запрашиваемые разрешения (разделенные пробелом)
49
-
50
- Returns:
51
- TokenResponse с access_token
52
- """
53
- form_data = {
54
- "grant_type": "client_credentials",
55
- "client_id": self.client_id,
56
- "client_secret": self.client_secret,
57
- }
58
-
59
- if scope:
60
- form_data["scope"] = scope
61
-
62
- data = await self.post("token", form_data=form_data)
63
- token_response = TokenResponse(**data)
64
-
65
- # Сохраняем токен
66
- self._access_token = token_response.access_token
67
-
68
- return token_response
69
-
70
- async def get_current_user(self, access_token: Optional[str] = None) -> UserInfo:
71
- """
72
- Получить информацию о текущем пользователе/сервисе
73
-
74
- Args:
75
- access_token: Access token (если не указан, используется сохраненный)
76
-
77
- Returns:
78
- UserInfo с информацией (для микросервисов может быть ограниченная информация)
79
- """
80
- access_token = access_token or self._access_token
81
-
82
- if not access_token:
83
- raise TokenError(
84
- "Access token не найден. Вызовите get_access_token() сначала.")
85
-
86
- data = await self.get("me", access_token=access_token)
87
- return UserInfo(**data)
88
-
89
- def set_access_token(self, access_token: str):
90
- """
91
- Установить access token вручную
92
-
93
- Args:
94
- access_token: Access token
95
- """
96
- self._access_token = access_token
97
-
98
- def _get_access_token(self) -> Optional[str]:
99
- """Получить текущий access token (для BaseClient)"""
100
- return self._access_token
101
-
102
- async def _refresh_token(self) -> None:
103
- """Обновить access token (для авто-рефреша)"""
104
- await self.get_access_token(self.default_scope)
105
-
106
- def get_token(self) -> Optional[str]:
107
- """Получить текущий access token"""
108
- return self._access_token
109
-
110
- async def request_with_auth(
111
- self,
112
- method: str,
113
- endpoint: str,
114
- json_data: Optional[dict] = None,
115
- params: Optional[dict] = None,
116
- auto_refresh: bool = True,
117
- ) -> dict:
118
- """
119
- Выполнить запрос с автоматической авторизацией
120
-
121
- Args:
122
- method: HTTP метод (GET, POST, PUT, DELETE)
123
- endpoint: API endpoint
124
- json_data: JSON данные для тела запроса
125
- params: Query параметры
126
- auto_refresh: Автоматически получать токен, если его нет
127
-
128
- Returns:
129
- Распарсенный JSON ответ
130
- """
131
- access_token = self._access_token
132
-
133
- if not access_token and auto_refresh:
134
- await self.get_access_token()
135
- access_token = self._access_token
136
-
137
- if not access_token:
138
- raise TokenError(
139
- "Access token не найден. Вызовите get_access_token() сначала.")
140
-
141
- return await self._request(
142
- method=method,
143
- endpoint=endpoint,
144
- access_token=access_token,
145
- json_data=json_data,
146
- params=params,
147
- )
1
+ """Клиент для микросервисного взаимодействия (Client Credentials)"""
2
+
3
+ import base64
4
+ import json
5
+ from typing import Any, Dict, Optional
6
+
7
+ from .base import BaseClient
8
+ from .models import TokenResponse, UserInfo
9
+ from .exceptions import TokenError
10
+
11
+
12
+ def _decode_jwt_payload(token: str) -> Dict[str, Any]:
13
+ """
14
+ Декодирует payload JWT без проверки подписи (только чтение полей).
15
+ Токен получен от SSO, используется для извлечения claim service_name.
16
+ """
17
+ try:
18
+ parts = token.split(".")
19
+ if len(parts) != 3:
20
+ return {}
21
+ payload_b64 = parts[1]
22
+ padding = 4 - len(payload_b64) % 4
23
+ if padding != 4:
24
+ payload_b64 += "=" * padding
25
+ payload_bytes = base64.urlsafe_b64decode(payload_b64)
26
+ return json.loads(payload_bytes.decode("utf-8"))
27
+ except Exception:
28
+ return {}
29
+
30
+
31
+ class ServiceClient(BaseClient):
32
+ """Клиент для микросервисного взаимодействия с Client Credentials Grant"""
33
+
34
+ def __init__(
35
+ self,
36
+ base_url: str,
37
+ client_id: str,
38
+ client_secret: str,
39
+ api_version: str = "v1",
40
+ timeout: int = 30,
41
+ session=None,
42
+ auto_refresh_token: bool = True,
43
+ default_scope: Optional[str] = None,
44
+ ):
45
+ """
46
+ Инициализация клиента для микросервисов
47
+
48
+ Args:
49
+ base_url: Базовый URL API
50
+ client_id: ID микросервиса
51
+ client_secret: Секрет микросервиса
52
+ api_version: Версия API
53
+ timeout: Таймаут запросов
54
+ session: Опциональная aiohttp сессия
55
+ auto_refresh_token: Автоматически обновлять токен при получении 401 ошибки
56
+ default_scope: Scope по умолчанию для автоматического получения токена
57
+ """
58
+ super().__init__(base_url, api_version, timeout, session, auto_refresh_token)
59
+ self.client_id = client_id
60
+ self.client_secret = client_secret
61
+ self._access_token: Optional[str] = None
62
+ self.default_scope = default_scope
63
+
64
+ async def get_access_token(self, scope: Optional[str] = None) -> TokenResponse:
65
+ """
66
+ Получить access token используя Client Credentials Grant
67
+
68
+ Args:
69
+ scope: Запрашиваемые разрешения (разделенные пробелом)
70
+
71
+ Returns:
72
+ TokenResponse с access_token
73
+ """
74
+ form_data = {
75
+ "grant_type": "client_credentials",
76
+ "client_id": self.client_id,
77
+ "client_secret": self.client_secret,
78
+ }
79
+
80
+ if scope:
81
+ form_data["scope"] = scope
82
+
83
+ data = await self.post("token", form_data=form_data)
84
+ token_response = TokenResponse(**data)
85
+
86
+ # Сохраняем токен
87
+ self._access_token = token_response.access_token
88
+
89
+ return token_response
90
+
91
+ async def get_current_user(self, access_token: Optional[str] = None) -> UserInfo:
92
+ """
93
+ Получить информацию о текущем пользователе/сервисе
94
+
95
+ Args:
96
+ access_token: Access token (если не указан, используется сохраненный)
97
+
98
+ Returns:
99
+ UserInfo с информацией (для микросервисов может быть ограниченная информация)
100
+ """
101
+ access_token = access_token or self._access_token
102
+
103
+ if not access_token:
104
+ raise TokenError(
105
+ "Access token не найден. Вызовите get_access_token() сначала.")
106
+
107
+ data = await self.get("me", access_token=access_token)
108
+ return UserInfo(**data)
109
+
110
+ def set_access_token(self, access_token: str):
111
+ """
112
+ Установить access token вручную
113
+
114
+ Args:
115
+ access_token: Access token
116
+ """
117
+ self._access_token = access_token
118
+
119
+ def _get_access_token(self) -> Optional[str]:
120
+ """Получить текущий access token (для BaseClient)"""
121
+ return self._access_token
122
+
123
+ async def _refresh_token(self) -> None:
124
+ """Обновить access token (для авто-рефреша)"""
125
+ await self.get_access_token(self.default_scope)
126
+
127
+ def get_token(self) -> Optional[str]:
128
+ """Получить текущий access token"""
129
+ return self._access_token
130
+
131
+ def get_token_payload(self, access_token: Optional[str] = None) -> Dict[str, Any]:
132
+ """
133
+ Получить payload текущего JWT токена (без проверки подписи).
134
+ Удобно для чтения полей, вшитых SSO (например service_name).
135
+
136
+ Args:
137
+ access_token: Токен (если не указан, используется сохранённый)
138
+
139
+ Returns:
140
+ Словарь с полями payload или пустой словарь при ошибке
141
+ """
142
+ token = access_token or self._access_token
143
+ if not token:
144
+ return {}
145
+ return _decode_jwt_payload(token)
146
+
147
+ def get_service_name(self, access_token: Optional[str] = None) -> Optional[str]:
148
+ """
149
+ Получить название сервиса из JWT токена (поле service_name, вшитое SSO).
150
+
151
+ Args:
152
+ access_token: Токен (если не указан, используется сохранённый)
153
+
154
+ Returns:
155
+ Название сервиса или None, если токена нет или поле отсутствует
156
+ """
157
+ payload = self.get_token_payload(access_token)
158
+ return payload.get("service_name")
159
+
160
+ async def request_with_auth(
161
+ self,
162
+ method: str,
163
+ endpoint: str,
164
+ json_data: Optional[dict] = None,
165
+ params: Optional[dict] = None,
166
+ auto_refresh: bool = True,
167
+ ) -> dict:
168
+ """
169
+ Выполнить запрос с автоматической авторизацией
170
+
171
+ Args:
172
+ method: HTTP метод (GET, POST, PUT, DELETE)
173
+ endpoint: API endpoint
174
+ json_data: JSON данные для тела запроса
175
+ params: Query параметры
176
+ auto_refresh: Автоматически получать токен, если его нет
177
+
178
+ Returns:
179
+ Распарсенный JSON ответ
180
+ """
181
+ access_token = self._access_token
182
+
183
+ if not access_token and auto_refresh:
184
+ await self.get_access_token()
185
+ access_token = self._access_token
186
+
187
+ if not access_token:
188
+ raise TokenError(
189
+ "Access token не найден. Вызовите get_access_token() сначала.")
190
+
191
+ return await self._request(
192
+ method=method,
193
+ endpoint=endpoint,
194
+ access_token=access_token,
195
+ json_data=json_data,
196
+ params=params,
197
+ )
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sso-nebus
3
- Version: 0.1.3
3
+ Version: 0.1.6
4
4
  Summary: Python клиент для взаимодействия с MS Auth Service API
5
5
  License: LICENSE
6
6
  License-File: LICENSE
@@ -12,8 +12,12 @@ Classifier: Programming Language :: Python :: 3
12
12
  Classifier: Programming Language :: Python :: 3.12
13
13
  Classifier: Programming Language :: Python :: 3.13
14
14
  Classifier: Programming Language :: Python :: 3.14
15
+ Provides-Extra: dev
15
16
  Requires-Dist: aiohttp (>=3.13.2,<4.0.0)
16
17
  Requires-Dist: pydantic (>=2.12.5,<3.0.0)
18
+ Requires-Dist: pytest (>=8.0.0) ; extra == "dev"
19
+ Requires-Dist: pytest-asyncio (>=0.24.0) ; extra == "dev"
20
+ Requires-Dist: python-dotenv (>=1.0.0) ; extra == "dev"
17
21
  Description-Content-Type: text/markdown
18
22
 
19
23
  # SSO Nebus Client
@@ -1,13 +1,13 @@
1
1
  sso_nebus/__init__.py,sha256=B7X9F074caljXhvE8Fpq1SwO-gVxzFUurOdbIkNLxp0,872
2
- sso_nebus/base.py,sha256=4LQ7-qxxonGILktmX3uQCraolrVnWZ_kqRfKO8EOcmk,24213
2
+ sso_nebus/base.py,sha256=daTvDeQdFkVdzvzJ_zCkq6XSP-KISpeAC7h1ETfQda0,25202
3
3
  sso_nebus/exceptions.py,sha256=XHrvfGGvs3eKMzkpwRxXyDngjpMSp7X7E8EUXwv26NA,1262
4
4
  sso_nebus/exmples/example_admin.py,sha256=eAqkS4J5YyJneP4EPUrBlvNbGlK7rHEO39kCklt_WQM,1822
5
5
  sso_nebus/exmples/example_service.py,sha256=19NNTmbaRSaWLpDQffGWNDmXkDJXdLOgD2WkV32xUu8,1778
6
6
  sso_nebus/exmples/example_user.py,sha256=c_Uf36FDHIXvv_vdqsO5XvXkvu1YOUsqaTO44_8DqVM,1878
7
7
  sso_nebus/models.py,sha256=0xIE5RvnxV2sAxO7_GTCes6HlHbdzGwHIjWVe1X7-ks,3024
8
- sso_nebus/service_client.py,sha256=7A1QSbANebXHpy5k7eJj3tXuwl70TAs61SAv0UEnuwk,5460
8
+ sso_nebus/service_client.py,sha256=jMvY_6GLmyb-vkQYIy7Bnh4PVD55aG3TLJiYgCbnBjA,7361
9
9
  sso_nebus/user_client.py,sha256=-3pJmvZxJgyEo3moGQvZVAOhJMOIC6NuE8NgJ33Fyfs,12976
10
- sso_nebus-0.1.3.dist-info/licenses/LICENSE,sha256=dCbOm3zpH8T7vLDC2K7QJLu-LEl2zqaSuyARbqfGsEY,1863
11
- sso_nebus-0.1.3.dist-info/METADATA,sha256=RcoakbPB37v7orhugBtjyGnDoYRh2PJGTMdn7dLFAwg,8406
12
- sso_nebus-0.1.3.dist-info/WHEEL,sha256=zp0Cn7JsFoX2ATtOhtaFYIiE2rmFAD4OcMhtUki8W3U,88
13
- sso_nebus-0.1.3.dist-info/RECORD,,
10
+ sso_nebus-0.1.6.dist-info/licenses/LICENSE,sha256=dCbOm3zpH8T7vLDC2K7QJLu-LEl2zqaSuyARbqfGsEY,1863
11
+ sso_nebus-0.1.6.dist-info/METADATA,sha256=EDtGOh3UzlRIALXPB7m-NhVQ-KetX4j0_TaI92hcu9U,8589
12
+ sso_nebus-0.1.6.dist-info/WHEEL,sha256=3ny-bZhpXrU6vSQ1UPG34FoxZBp3lVcvK0LkgUz6VLk,88
13
+ sso_nebus-0.1.6.dist-info/RECORD,,
@@ -1,4 +1,4 @@
1
1
  Wheel-Version: 1.0
2
- Generator: poetry-core 2.2.1
2
+ Generator: poetry-core 2.3.0
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any