blackant-sdk 1.0.2__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.
- blackant/__init__.py +31 -0
- blackant/auth/__init__.py +10 -0
- blackant/auth/blackant_auth.py +518 -0
- blackant/auth/keycloak_manager.py +363 -0
- blackant/auth/request_id.py +52 -0
- blackant/auth/role_assignment.py +443 -0
- blackant/auth/tokens.py +57 -0
- blackant/client.py +400 -0
- blackant/config/__init__.py +0 -0
- blackant/config/docker_config.py +457 -0
- blackant/config/keycloak_admin_config.py +107 -0
- blackant/docker/__init__.py +12 -0
- blackant/docker/builder.py +616 -0
- blackant/docker/client.py +983 -0
- blackant/docker/dao.py +462 -0
- blackant/docker/registry.py +172 -0
- blackant/exceptions.py +111 -0
- blackant/http/__init__.py +8 -0
- blackant/http/client.py +125 -0
- blackant/patterns/__init__.py +1 -0
- blackant/patterns/singleton.py +20 -0
- blackant/services/__init__.py +10 -0
- blackant/services/dao.py +414 -0
- blackant/services/registry.py +635 -0
- blackant/utils/__init__.py +8 -0
- blackant/utils/initialization.py +32 -0
- blackant/utils/logging.py +337 -0
- blackant/utils/request_id.py +13 -0
- blackant/utils/store.py +50 -0
- blackant_sdk-1.0.2.dist-info/METADATA +117 -0
- blackant_sdk-1.0.2.dist-info/RECORD +70 -0
- blackant_sdk-1.0.2.dist-info/WHEEL +5 -0
- blackant_sdk-1.0.2.dist-info/top_level.txt +5 -0
- calculation/__init__.py +0 -0
- calculation/base.py +26 -0
- calculation/errors.py +2 -0
- calculation/impl/__init__.py +0 -0
- calculation/impl/my_calculation.py +144 -0
- calculation/impl/simple_calc.py +53 -0
- calculation/impl/test.py +1 -0
- calculation/impl/test_calc.py +36 -0
- calculation/loader.py +227 -0
- notifinations/__init__.py +8 -0
- notifinations/mail_sender.py +212 -0
- storage/__init__.py +0 -0
- storage/errors.py +10 -0
- storage/factory.py +26 -0
- storage/interface.py +19 -0
- storage/minio.py +106 -0
- task/__init__.py +0 -0
- task/dao.py +38 -0
- task/errors.py +10 -0
- task/log_adapter.py +11 -0
- task/parsers/__init__.py +0 -0
- task/parsers/base.py +13 -0
- task/parsers/callback.py +40 -0
- task/parsers/cmd_args.py +52 -0
- task/parsers/freetext.py +19 -0
- task/parsers/objects.py +50 -0
- task/parsers/request.py +56 -0
- task/resource.py +84 -0
- task/states/__init__.py +0 -0
- task/states/base.py +14 -0
- task/states/error.py +47 -0
- task/states/idle.py +12 -0
- task/states/ready.py +51 -0
- task/states/running.py +21 -0
- task/states/set_up.py +40 -0
- task/states/tear_down.py +29 -0
- task/task.py +358 -0
blackant/__init__.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""BlackAnt SDK package.
|
|
2
|
+
|
|
3
|
+
Main entry point for BlackAnt SDK providing Docker operations
|
|
4
|
+
with automatic authentication through BlackAnt platform.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from .client import BlackAntClient
|
|
8
|
+
from .auth import BlackAntAuth
|
|
9
|
+
from .docker import BlackAntDockerClient
|
|
10
|
+
from .services import ServiceRegistry
|
|
11
|
+
from .exceptions import (
|
|
12
|
+
BlackAntException,
|
|
13
|
+
BlackAntAuthenticationError,
|
|
14
|
+
BlackAntDockerError,
|
|
15
|
+
BlackAntConnectionError,
|
|
16
|
+
BlackAntConfigurationError
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
__version__ = "1.0.0"
|
|
20
|
+
|
|
21
|
+
__all__ = [
|
|
22
|
+
"BlackAntClient",
|
|
23
|
+
"BlackAntAuth",
|
|
24
|
+
"BlackAntDockerClient",
|
|
25
|
+
"ServiceRegistry",
|
|
26
|
+
"BlackAntException",
|
|
27
|
+
"BlackAntAuthenticationError",
|
|
28
|
+
"BlackAntDockerError",
|
|
29
|
+
"BlackAntConnectionError",
|
|
30
|
+
"BlackAntConfigurationError"
|
|
31
|
+
]
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""BlackAnt SDK authentication module.
|
|
2
|
+
|
|
3
|
+
Provides authentication token management and request ID tracking.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from .blackant_auth import BlackAntAuth
|
|
7
|
+
from .tokens import AuthTokenStore
|
|
8
|
+
from .request_id import RequestIdStore
|
|
9
|
+
|
|
10
|
+
__all__ = ["BlackAntAuth", "AuthTokenStore", "RequestIdStore"]
|
|
@@ -0,0 +1,518 @@
|
|
|
1
|
+
"""BlackAnt authentication module.
|
|
2
|
+
|
|
3
|
+
Provides username/password authentication to obtain Bearer tokens
|
|
4
|
+
from Keycloak for BlackAnt platform services.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import os
|
|
8
|
+
from typing import Optional, Dict, Any, List
|
|
9
|
+
|
|
10
|
+
import requests
|
|
11
|
+
|
|
12
|
+
from .tokens import AuthTokenStore
|
|
13
|
+
from ..exceptions import BlackAntAuthenticationError
|
|
14
|
+
from ..utils.logging import get_logger
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class BlackAntAuth:
|
|
18
|
+
"""BlackAnt authentication handler.
|
|
19
|
+
|
|
20
|
+
Manages username/password authentication with Keycloak to obtain
|
|
21
|
+
Bearer tokens. All BlackAnt services require this token.
|
|
22
|
+
|
|
23
|
+
The token is automatically injected into all HTTP requests through
|
|
24
|
+
Nginx proxy which validates it with Keycloak.
|
|
25
|
+
|
|
26
|
+
Args:
|
|
27
|
+
user (str): Username for authentication.
|
|
28
|
+
password (str): Password for authentication.
|
|
29
|
+
login_url (str, optional): Keycloak login endpoint URL.
|
|
30
|
+
|
|
31
|
+
Examples:
|
|
32
|
+
>>> auth = BlackAntAuth(user="my_name", password="xxx")
|
|
33
|
+
>>> token = auth.get_token()
|
|
34
|
+
>>> # Token is automatically used in subsequent API calls
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
def __init__(self, user: str = None, password: str = None,
|
|
38
|
+
client_id: str = None, client_secret: str = None,
|
|
39
|
+
login_url: Optional[str] = None):
|
|
40
|
+
"""Initialize authentication with credentials.
|
|
41
|
+
|
|
42
|
+
Supports two authentication modes:
|
|
43
|
+
1. Username/Password (OAuth2 Password Flow)
|
|
44
|
+
2. Client ID/Secret (OAuth2 Client Credentials Flow)
|
|
45
|
+
|
|
46
|
+
Args:
|
|
47
|
+
user: Username for authentication (password flow).
|
|
48
|
+
password: Password for authentication (password flow).
|
|
49
|
+
client_id: Client ID for service account authentication (client credentials flow).
|
|
50
|
+
client_secret: Client secret for service account authentication (client credentials flow).
|
|
51
|
+
login_url: Optional login endpoint URL, defaults to environment variable.
|
|
52
|
+
|
|
53
|
+
Raises:
|
|
54
|
+
ValueError: If neither (user+password) nor (client_id+client_secret) provided.
|
|
55
|
+
"""
|
|
56
|
+
# Validate that we have either username/password OR client_id/client_secret
|
|
57
|
+
has_user_pass = user and password
|
|
58
|
+
has_client_creds = client_id and client_secret
|
|
59
|
+
|
|
60
|
+
if not has_user_pass and not has_client_creds:
|
|
61
|
+
raise ValueError(
|
|
62
|
+
"Either (user+password) or (client_id+client_secret) must be provided"
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
if has_user_pass and has_client_creds:
|
|
66
|
+
raise ValueError(
|
|
67
|
+
"Provide either (user+password) OR (client_id+client_secret), not both"
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
self.user = user
|
|
71
|
+
self.password = password
|
|
72
|
+
self.client_id = client_id or "blackant-app"
|
|
73
|
+
self.client_secret = client_secret
|
|
74
|
+
self.login_url = login_url or os.getenv(
|
|
75
|
+
"BLACKANT_LOGIN_URL",
|
|
76
|
+
"https://dev.blackant.app/api/auth/login"
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
# Initialize attributes that are referenced later
|
|
80
|
+
self.realm = "master"
|
|
81
|
+
|
|
82
|
+
# Use existing AuthTokenStore for thread-safe token storage
|
|
83
|
+
self.token_store = AuthTokenStore()
|
|
84
|
+
self.request_timeout = 30.0 # Default timeout for requests
|
|
85
|
+
self.logger = get_logger("auth")
|
|
86
|
+
|
|
87
|
+
# Token will be stored after successful authentication
|
|
88
|
+
self._authenticated = False
|
|
89
|
+
self._auth_mode = "client_credentials" if has_client_creds else "password"
|
|
90
|
+
|
|
91
|
+
def authenticate(self) -> str:
|
|
92
|
+
"""Authenticate with Keycloak and store access token.
|
|
93
|
+
|
|
94
|
+
Automatically selects authentication mode based on initialization:
|
|
95
|
+
- Username/Password flow if (user+password) provided
|
|
96
|
+
- Client Credentials flow if (client_id+client_secret) provided
|
|
97
|
+
|
|
98
|
+
Returns:
|
|
99
|
+
str: The access token obtained from Keycloak.
|
|
100
|
+
|
|
101
|
+
Raises:
|
|
102
|
+
BlackAntAuthenticationError: If login fails or token not received.
|
|
103
|
+
"""
|
|
104
|
+
if self._auth_mode == "client_credentials":
|
|
105
|
+
return self.authenticate_with_client_credentials()
|
|
106
|
+
else:
|
|
107
|
+
return self.authenticate_with_password()
|
|
108
|
+
|
|
109
|
+
def authenticate_with_password(self) -> str:
|
|
110
|
+
"""Authenticate using OAuth2 Password Flow (username/password).
|
|
111
|
+
|
|
112
|
+
Sends username/password to Keycloak login endpoint and
|
|
113
|
+
stores the returned Bearer token for future API calls.
|
|
114
|
+
|
|
115
|
+
Returns:
|
|
116
|
+
str: The access token obtained from Keycloak.
|
|
117
|
+
|
|
118
|
+
Raises:
|
|
119
|
+
BlackAntAuthenticationError: If login fails or token not received.
|
|
120
|
+
"""
|
|
121
|
+
self.logger.info(f"Authenticating user with password flow: {self.user}")
|
|
122
|
+
|
|
123
|
+
data = {
|
|
124
|
+
"username": self.user,
|
|
125
|
+
"password": self.password
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
try:
|
|
129
|
+
# Send login request to Keycloak
|
|
130
|
+
# Determine if we should verify SSL (skip for localhost/dev)
|
|
131
|
+
verify_ssl = not (
|
|
132
|
+
"localhost" in self.login_url or
|
|
133
|
+
"127.0.0.1" in self.login_url or
|
|
134
|
+
"http://" in self.login_url or # HTTP doesn't need SSL verification
|
|
135
|
+
"dev.blackant.app" in self.login_url # Dev environment self-signed cert
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
response = requests.post(
|
|
139
|
+
self.login_url,
|
|
140
|
+
data=data,
|
|
141
|
+
verify=verify_ssl,
|
|
142
|
+
timeout=30
|
|
143
|
+
)
|
|
144
|
+
response.raise_for_status()
|
|
145
|
+
|
|
146
|
+
# Extract access token from response
|
|
147
|
+
response_data = response.json()
|
|
148
|
+
if "access_token" not in response_data:
|
|
149
|
+
raise BlackAntAuthenticationError(
|
|
150
|
+
f"No access_token in login response: {response_data}"
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
access_token = response_data["access_token"]
|
|
154
|
+
|
|
155
|
+
# Store token using thread-safe AuthTokenStore
|
|
156
|
+
self.token_store.user_token = access_token
|
|
157
|
+
self._authenticated = True
|
|
158
|
+
|
|
159
|
+
self.logger.info("Password flow authentication successful")
|
|
160
|
+
return access_token
|
|
161
|
+
|
|
162
|
+
except requests.exceptions.RequestException as error:
|
|
163
|
+
self.logger.error(f"Authentication request failed: {error}")
|
|
164
|
+
raise BlackAntAuthenticationError(f"Login request failed: {error}") from error
|
|
165
|
+
except (KeyError, ValueError) as error:
|
|
166
|
+
self.logger.error(f"Invalid login response: {error}")
|
|
167
|
+
raise BlackAntAuthenticationError(f"Invalid login response: {error}") from error
|
|
168
|
+
|
|
169
|
+
def authenticate_with_client_credentials(self) -> str:
|
|
170
|
+
"""Authenticate using OAuth2 Client Credentials Flow.
|
|
171
|
+
|
|
172
|
+
Uses client_id and client_secret to obtain a service account token
|
|
173
|
+
from Keycloak. This is ideal for automation, CI/CD pipelines, and
|
|
174
|
+
server-to-server communication where no user context is needed.
|
|
175
|
+
|
|
176
|
+
Returns:
|
|
177
|
+
str: The access token obtained from Keycloak.
|
|
178
|
+
|
|
179
|
+
Raises:
|
|
180
|
+
BlackAntAuthenticationError: If authentication fails or token not received.
|
|
181
|
+
|
|
182
|
+
Example:
|
|
183
|
+
>>> auth = BlackAntAuth(
|
|
184
|
+
... client_id="my-service-account",
|
|
185
|
+
... client_secret="xyz123..."
|
|
186
|
+
... )
|
|
187
|
+
>>> token = auth.authenticate_with_client_credentials()
|
|
188
|
+
>>> print(f"Service account authenticated: {token[:20]}...")
|
|
189
|
+
"""
|
|
190
|
+
self.logger.info(f"Authenticating with client credentials flow: {self.client_id}")
|
|
191
|
+
|
|
192
|
+
# OAuth2 Client Credentials Flow
|
|
193
|
+
data = {
|
|
194
|
+
"grant_type": "client_credentials",
|
|
195
|
+
"client_id": self.client_id,
|
|
196
|
+
"client_secret": self.client_secret,
|
|
197
|
+
"scope": "openid profile email"
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
try:
|
|
201
|
+
# Determine if we should verify SSL (skip for localhost/dev)
|
|
202
|
+
verify_ssl = not (
|
|
203
|
+
"localhost" in self.login_url or
|
|
204
|
+
"127.0.0.1" in self.login_url or
|
|
205
|
+
"http://" in self.login_url or
|
|
206
|
+
"dev.blackant.app" in self.login_url
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
# Send client credentials request to Keycloak token endpoint
|
|
210
|
+
response = requests.post(
|
|
211
|
+
self.login_url,
|
|
212
|
+
data=data,
|
|
213
|
+
verify=verify_ssl,
|
|
214
|
+
timeout=self.request_timeout,
|
|
215
|
+
headers={"Content-Type": "application/x-www-form-urlencoded"}
|
|
216
|
+
)
|
|
217
|
+
response.raise_for_status()
|
|
218
|
+
|
|
219
|
+
# Extract access token from response
|
|
220
|
+
response_data = response.json()
|
|
221
|
+
if "access_token" not in response_data:
|
|
222
|
+
raise BlackAntAuthenticationError(
|
|
223
|
+
f"No access_token in client credentials response: {response_data}"
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
access_token = response_data["access_token"]
|
|
227
|
+
|
|
228
|
+
# Store token using thread-safe AuthTokenStore
|
|
229
|
+
self.token_store.user_token = access_token
|
|
230
|
+
self._authenticated = True
|
|
231
|
+
|
|
232
|
+
self.logger.info("Client credentials authentication successful")
|
|
233
|
+
return access_token
|
|
234
|
+
|
|
235
|
+
except requests.exceptions.RequestException as error:
|
|
236
|
+
self.logger.error(f"Client credentials authentication failed: {error}")
|
|
237
|
+
raise BlackAntAuthenticationError(
|
|
238
|
+
f"Client credentials authentication failed: {error}"
|
|
239
|
+
) from error
|
|
240
|
+
except (KeyError, ValueError) as error:
|
|
241
|
+
self.logger.error(f"Invalid client credentials response: {error}")
|
|
242
|
+
raise BlackAntAuthenticationError(
|
|
243
|
+
f"Invalid client credentials response: {error}"
|
|
244
|
+
) from error
|
|
245
|
+
|
|
246
|
+
def get_token(self) -> str:
|
|
247
|
+
"""Get current access token, authenticate if needed.
|
|
248
|
+
|
|
249
|
+
Returns the stored Bearer token. If not authenticated yet,
|
|
250
|
+
performs authentication first.
|
|
251
|
+
|
|
252
|
+
Returns:
|
|
253
|
+
str: The current Bearer access token.
|
|
254
|
+
|
|
255
|
+
Raises:
|
|
256
|
+
BlackAntAuthenticationError: If authentication fails.
|
|
257
|
+
"""
|
|
258
|
+
if not self._authenticated or not self.token_store.user_token:
|
|
259
|
+
self.authenticate()
|
|
260
|
+
|
|
261
|
+
token = self.token_store.user_token
|
|
262
|
+
if not token:
|
|
263
|
+
raise BlackAntAuthenticationError("No token available after authentication")
|
|
264
|
+
|
|
265
|
+
return token
|
|
266
|
+
|
|
267
|
+
def is_authenticated(self) -> bool:
|
|
268
|
+
"""Check if currently authenticated.
|
|
269
|
+
|
|
270
|
+
Returns:
|
|
271
|
+
bool: True if authenticated and token is available.
|
|
272
|
+
"""
|
|
273
|
+
return self._authenticated and self.token_store.user_token is not None
|
|
274
|
+
|
|
275
|
+
def refresh_token(self, refresh_token: str) -> str:
|
|
276
|
+
"""Refresh access token using refresh token.
|
|
277
|
+
|
|
278
|
+
Args:
|
|
279
|
+
refresh_token: The refresh token from initial login.
|
|
280
|
+
|
|
281
|
+
Returns:
|
|
282
|
+
New access token.
|
|
283
|
+
|
|
284
|
+
Raises:
|
|
285
|
+
BlackAntAuthenticationError: If token refresh fails.
|
|
286
|
+
"""
|
|
287
|
+
try:
|
|
288
|
+
self.logger.info("Refreshing access token")
|
|
289
|
+
|
|
290
|
+
refresh_data = {
|
|
291
|
+
"grant_type": "refresh_token",
|
|
292
|
+
"refresh_token": refresh_token,
|
|
293
|
+
"client_id": self.client_id
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
response = requests.post(
|
|
297
|
+
f"{self.login_url.replace('/token', '/token')}", # Ensure correct endpoint
|
|
298
|
+
data=refresh_data,
|
|
299
|
+
timeout=self.request_timeout,
|
|
300
|
+
headers={"Content-Type": "application/x-www-form-urlencoded"}
|
|
301
|
+
)
|
|
302
|
+
|
|
303
|
+
response.raise_for_status()
|
|
304
|
+
response_data = response.json()
|
|
305
|
+
|
|
306
|
+
new_access_token = response_data["access_token"]
|
|
307
|
+
self.token_store.user_token = new_access_token
|
|
308
|
+
|
|
309
|
+
self.logger.info("Token refresh successful")
|
|
310
|
+
return new_access_token
|
|
311
|
+
|
|
312
|
+
except requests.exceptions.RequestException as error:
|
|
313
|
+
self.logger.error(f"Token refresh failed: {error}")
|
|
314
|
+
raise BlackAntAuthenticationError(f"Token refresh failed: {error}") from error
|
|
315
|
+
except (KeyError, ValueError) as error:
|
|
316
|
+
self.logger.error(f"Invalid refresh response: {error}")
|
|
317
|
+
raise BlackAntAuthenticationError(f"Invalid refresh response: {error}") from error
|
|
318
|
+
|
|
319
|
+
def verify_token(self, token: Optional[str] = None) -> Dict[str, Any]:
|
|
320
|
+
"""Verify token with Keycloak userinfo endpoint.
|
|
321
|
+
|
|
322
|
+
Args:
|
|
323
|
+
token: Token to verify, uses stored token if None.
|
|
324
|
+
|
|
325
|
+
Returns:
|
|
326
|
+
User information from token.
|
|
327
|
+
|
|
328
|
+
Raises:
|
|
329
|
+
BlackAntAuthenticationError: If token verification fails.
|
|
330
|
+
"""
|
|
331
|
+
try:
|
|
332
|
+
verify_token = token or self.token_store.user_token
|
|
333
|
+
if not verify_token:
|
|
334
|
+
raise BlackAntAuthenticationError("No token available for verification")
|
|
335
|
+
|
|
336
|
+
if "/api/auth/login" in self.login_url:
|
|
337
|
+
base_url = self.login_url.replace("/api/auth/login", "")
|
|
338
|
+
userinfo_url = f"{base_url}/api/auth/verify"
|
|
339
|
+
else:
|
|
340
|
+
userinfo_url = self.login_url.replace(
|
|
341
|
+
"/protocol/openid-connect/token",
|
|
342
|
+
"/protocol/openid-connect/userinfo"
|
|
343
|
+
)
|
|
344
|
+
|
|
345
|
+
# Determine if we should verify SSL (skip for localhost/dev)
|
|
346
|
+
verify_ssl = not (
|
|
347
|
+
"localhost" in userinfo_url or
|
|
348
|
+
"127.0.0.1" in userinfo_url or
|
|
349
|
+
"http://" in userinfo_url # HTTP doesn't need SSL verification
|
|
350
|
+
)
|
|
351
|
+
|
|
352
|
+
response = requests.post( # POST for /api/auth/verify endpoint
|
|
353
|
+
userinfo_url,
|
|
354
|
+
headers={"Authorization": f"Bearer {verify_token}"},
|
|
355
|
+
timeout=self.request_timeout,
|
|
356
|
+
verify=verify_ssl
|
|
357
|
+
)
|
|
358
|
+
|
|
359
|
+
response.raise_for_status()
|
|
360
|
+
user_info = response.json()
|
|
361
|
+
|
|
362
|
+
self.logger.debug("Token verification successful")
|
|
363
|
+
return user_info
|
|
364
|
+
|
|
365
|
+
except requests.exceptions.RequestException as error:
|
|
366
|
+
self.logger.error(f"Token verification failed: {error}")
|
|
367
|
+
raise BlackAntAuthenticationError(f"Token verification failed: {error}") from error
|
|
368
|
+
|
|
369
|
+
def get_admin_token(self, admin_user: str, admin_password: str) -> str:
|
|
370
|
+
"""Get admin token for Keycloak Admin API operations.
|
|
371
|
+
|
|
372
|
+
Args:
|
|
373
|
+
admin_user: Admin username.
|
|
374
|
+
admin_password: Admin password.
|
|
375
|
+
|
|
376
|
+
Returns:
|
|
377
|
+
Admin access token.
|
|
378
|
+
|
|
379
|
+
Raises:
|
|
380
|
+
BlackAntAuthenticationError: If admin authentication fails.
|
|
381
|
+
"""
|
|
382
|
+
try:
|
|
383
|
+
self.logger.info(f"Getting admin token for user: {admin_user}")
|
|
384
|
+
|
|
385
|
+
# Admin login uses master realm
|
|
386
|
+
admin_login_url = self.login_url.replace(
|
|
387
|
+
f"/realms/{self.realm}/",
|
|
388
|
+
"/realms/master/"
|
|
389
|
+
)
|
|
390
|
+
|
|
391
|
+
admin_data = {
|
|
392
|
+
"username": admin_user,
|
|
393
|
+
"password": admin_password,
|
|
394
|
+
"grant_type": "password",
|
|
395
|
+
"client_id": "admin-cli" # Keycloak admin CLI client
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
response = requests.post(
|
|
399
|
+
admin_login_url,
|
|
400
|
+
data=admin_data,
|
|
401
|
+
timeout=self.request_timeout,
|
|
402
|
+
headers={"Content-Type": "application/x-www-form-urlencoded"}
|
|
403
|
+
)
|
|
404
|
+
|
|
405
|
+
response.raise_for_status()
|
|
406
|
+
response_data = response.json()
|
|
407
|
+
|
|
408
|
+
admin_token = response_data["access_token"]
|
|
409
|
+
self.token_store.admin_token = admin_token
|
|
410
|
+
|
|
411
|
+
self.logger.info("Admin authentication successful")
|
|
412
|
+
return admin_token
|
|
413
|
+
|
|
414
|
+
except requests.exceptions.RequestException as error:
|
|
415
|
+
self.logger.error(f"Admin authentication failed: {error}")
|
|
416
|
+
raise BlackAntAuthenticationError(f"Admin login failed: {error}") from error
|
|
417
|
+
except (KeyError, ValueError) as error:
|
|
418
|
+
self.logger.error(f"Invalid admin login response: {error}")
|
|
419
|
+
raise BlackAntAuthenticationError(f"Invalid admin response: {error}") from error
|
|
420
|
+
|
|
421
|
+
def create_user(self, user_data: Dict[str, Any], admin_token: Optional[str] = None) -> str:
|
|
422
|
+
"""Create new user using Keycloak Admin API.
|
|
423
|
+
|
|
424
|
+
Args:
|
|
425
|
+
user_data: User creation data (username, email, etc.).
|
|
426
|
+
admin_token: Admin token, uses stored if None.
|
|
427
|
+
|
|
428
|
+
Returns:
|
|
429
|
+
Created user ID.
|
|
430
|
+
|
|
431
|
+
Raises:
|
|
432
|
+
BlackAntAuthenticationError: If user creation fails.
|
|
433
|
+
"""
|
|
434
|
+
try:
|
|
435
|
+
token = admin_token or self.token_store.admin_token
|
|
436
|
+
if not token:
|
|
437
|
+
raise BlackAntAuthenticationError("Admin token required for user creation")
|
|
438
|
+
|
|
439
|
+
admin_api_url = self.login_url.replace(
|
|
440
|
+
"/protocol/openid-connect/token",
|
|
441
|
+
f"/admin/realms/{self.realm}/users"
|
|
442
|
+
)
|
|
443
|
+
|
|
444
|
+
response = requests.post(
|
|
445
|
+
admin_api_url,
|
|
446
|
+
json=user_data,
|
|
447
|
+
headers={
|
|
448
|
+
"Authorization": f"Bearer {token}",
|
|
449
|
+
"Content-Type": "application/json"
|
|
450
|
+
},
|
|
451
|
+
timeout=self.request_timeout
|
|
452
|
+
)
|
|
453
|
+
|
|
454
|
+
response.raise_for_status()
|
|
455
|
+
|
|
456
|
+
# User ID is in Location header
|
|
457
|
+
location = response.headers.get("Location", "")
|
|
458
|
+
user_id = location.split("/")[-1] if location else ""
|
|
459
|
+
|
|
460
|
+
self.logger.info(f"User created: {user_data.get('username', 'unknown')}")
|
|
461
|
+
return user_id
|
|
462
|
+
|
|
463
|
+
except requests.exceptions.RequestException as error:
|
|
464
|
+
self.logger.error("User creation failed: %s", error)
|
|
465
|
+
raise BlackAntAuthenticationError(f"User creation failed: {error}") from error
|
|
466
|
+
|
|
467
|
+
def get_users(self, admin_token: Optional[str] = None, search: Optional[str] = None) -> List[Dict[str, Any]]:
|
|
468
|
+
"""List users using Keycloak Admin API.
|
|
469
|
+
|
|
470
|
+
Args:
|
|
471
|
+
admin_token: Admin token, uses stored if None.
|
|
472
|
+
search: Optional search filter.
|
|
473
|
+
|
|
474
|
+
Returns:
|
|
475
|
+
List of user data.
|
|
476
|
+
|
|
477
|
+
Raises:
|
|
478
|
+
BlackAntAuthenticationError: If user listing fails.
|
|
479
|
+
"""
|
|
480
|
+
try:
|
|
481
|
+
token = admin_token or self.token_store.admin_token
|
|
482
|
+
if not token:
|
|
483
|
+
raise BlackAntAuthenticationError("Admin token required for user listing")
|
|
484
|
+
|
|
485
|
+
admin_api_url = self.login_url.replace(
|
|
486
|
+
"/protocol/openid-connect/token",
|
|
487
|
+
f"/admin/realms/{self.realm}/users"
|
|
488
|
+
)
|
|
489
|
+
|
|
490
|
+
params = {}
|
|
491
|
+
if search:
|
|
492
|
+
params["search"] = search
|
|
493
|
+
|
|
494
|
+
response = requests.get(
|
|
495
|
+
admin_api_url,
|
|
496
|
+
params=params,
|
|
497
|
+
headers={"Authorization": f"Bearer {token}"},
|
|
498
|
+
timeout=self.request_timeout
|
|
499
|
+
)
|
|
500
|
+
|
|
501
|
+
response.raise_for_status()
|
|
502
|
+
users = response.json()
|
|
503
|
+
|
|
504
|
+
self.logger.debug(f"Retrieved {len(users)} users")
|
|
505
|
+
return users
|
|
506
|
+
|
|
507
|
+
except requests.exceptions.RequestException as error:
|
|
508
|
+
self.logger.error(f"User listing failed: {error}")
|
|
509
|
+
raise BlackAntAuthenticationError(f"User listing failed: {error}") from error
|
|
510
|
+
|
|
511
|
+
def logout(self):
|
|
512
|
+
"""Clear stored authentication token.
|
|
513
|
+
|
|
514
|
+
Removes the stored Bearer token from token store.
|
|
515
|
+
"""
|
|
516
|
+
self.logger.info("Logging out user")
|
|
517
|
+
self.token_store.user_token = None
|
|
518
|
+
self._authenticated = False
|