codetether 1.2.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.
- a2a_server/__init__.py +29 -0
- a2a_server/a2a_agent_card.py +365 -0
- a2a_server/a2a_errors.py +1133 -0
- a2a_server/a2a_executor.py +926 -0
- a2a_server/a2a_router.py +1033 -0
- a2a_server/a2a_types.py +344 -0
- a2a_server/agent_card.py +408 -0
- a2a_server/agents_server.py +271 -0
- a2a_server/auth_api.py +349 -0
- a2a_server/billing_api.py +638 -0
- a2a_server/billing_service.py +712 -0
- a2a_server/billing_webhooks.py +501 -0
- a2a_server/config.py +96 -0
- a2a_server/database.py +2165 -0
- a2a_server/email_inbound.py +398 -0
- a2a_server/email_notifications.py +486 -0
- a2a_server/enhanced_agents.py +919 -0
- a2a_server/enhanced_server.py +160 -0
- a2a_server/hosted_worker.py +1049 -0
- a2a_server/integrated_agents_server.py +347 -0
- a2a_server/keycloak_auth.py +750 -0
- a2a_server/livekit_bridge.py +439 -0
- a2a_server/marketing_tools.py +1364 -0
- a2a_server/mcp_client.py +196 -0
- a2a_server/mcp_http_server.py +2256 -0
- a2a_server/mcp_server.py +191 -0
- a2a_server/message_broker.py +725 -0
- a2a_server/mock_mcp.py +273 -0
- a2a_server/models.py +494 -0
- a2a_server/monitor_api.py +5904 -0
- a2a_server/opencode_bridge.py +1594 -0
- a2a_server/redis_task_manager.py +518 -0
- a2a_server/server.py +726 -0
- a2a_server/task_manager.py +668 -0
- a2a_server/task_queue.py +742 -0
- a2a_server/tenant_api.py +333 -0
- a2a_server/tenant_middleware.py +219 -0
- a2a_server/tenant_service.py +760 -0
- a2a_server/user_auth.py +721 -0
- a2a_server/vault_client.py +576 -0
- a2a_server/worker_sse.py +873 -0
- agent_worker/__init__.py +8 -0
- agent_worker/worker.py +4877 -0
- codetether/__init__.py +10 -0
- codetether/__main__.py +4 -0
- codetether/cli.py +112 -0
- codetether/worker_cli.py +57 -0
- codetether-1.2.2.dist-info/METADATA +570 -0
- codetether-1.2.2.dist-info/RECORD +66 -0
- codetether-1.2.2.dist-info/WHEEL +5 -0
- codetether-1.2.2.dist-info/entry_points.txt +4 -0
- codetether-1.2.2.dist-info/licenses/LICENSE +202 -0
- codetether-1.2.2.dist-info/top_level.txt +5 -0
- codetether_voice_agent/__init__.py +6 -0
- codetether_voice_agent/agent.py +445 -0
- codetether_voice_agent/codetether_mcp.py +345 -0
- codetether_voice_agent/config.py +16 -0
- codetether_voice_agent/functiongemma_caller.py +380 -0
- codetether_voice_agent/session_playback.py +247 -0
- codetether_voice_agent/tools/__init__.py +21 -0
- codetether_voice_agent/tools/definitions.py +135 -0
- codetether_voice_agent/tools/handlers.py +380 -0
- run_server.py +314 -0
- ui/monitor-tailwind.html +1790 -0
- ui/monitor.html +1775 -0
- ui/monitor.js +2662 -0
|
@@ -0,0 +1,760 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Keycloak Tenant Provisioning Service for A2A Server.
|
|
3
|
+
|
|
4
|
+
Provides multi-tenant provisioning capabilities:
|
|
5
|
+
- Create new realms for organizations
|
|
6
|
+
- Configure standard clients (SPA, API, Mobile)
|
|
7
|
+
- Manage admin users and roles
|
|
8
|
+
- Delete tenants
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import logging
|
|
12
|
+
import secrets
|
|
13
|
+
from typing import Dict, Any, List, Optional
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
|
|
16
|
+
import httpx
|
|
17
|
+
|
|
18
|
+
from .keycloak_auth import (
|
|
19
|
+
KEYCLOAK_URL,
|
|
20
|
+
KEYCLOAK_ADMIN_USERNAME,
|
|
21
|
+
KEYCLOAK_ADMIN_PASSWORD,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
logger = logging.getLogger(__name__)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass
|
|
28
|
+
class TenantInfo:
|
|
29
|
+
"""Information about a provisioned tenant."""
|
|
30
|
+
|
|
31
|
+
realm_name: str
|
|
32
|
+
org_slug: str
|
|
33
|
+
spa_client_id: str
|
|
34
|
+
api_client_id: str
|
|
35
|
+
api_client_secret: str
|
|
36
|
+
mobile_client_id: str
|
|
37
|
+
admin_user_id: str
|
|
38
|
+
admin_email: str
|
|
39
|
+
|
|
40
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
41
|
+
return {
|
|
42
|
+
'realm_name': self.realm_name,
|
|
43
|
+
'org_slug': self.org_slug,
|
|
44
|
+
'client_ids': {
|
|
45
|
+
'spa': self.spa_client_id,
|
|
46
|
+
'api': self.api_client_id,
|
|
47
|
+
'mobile': self.mobile_client_id,
|
|
48
|
+
},
|
|
49
|
+
'api_client_secret': self.api_client_secret,
|
|
50
|
+
'admin_user_id': self.admin_user_id,
|
|
51
|
+
'admin_email': self.admin_email,
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class KeycloakTenantServiceError(Exception):
|
|
56
|
+
"""Base exception for tenant service errors."""
|
|
57
|
+
|
|
58
|
+
pass
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class TenantAlreadyExistsError(KeycloakTenantServiceError):
|
|
62
|
+
"""Raised when attempting to create a tenant that already exists."""
|
|
63
|
+
|
|
64
|
+
pass
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class TenantNotFoundError(KeycloakTenantServiceError):
|
|
68
|
+
"""Raised when a tenant is not found."""
|
|
69
|
+
|
|
70
|
+
pass
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class KeycloakTenantService:
|
|
74
|
+
"""Manages Keycloak tenant (realm) provisioning."""
|
|
75
|
+
|
|
76
|
+
# Standard redirect URIs and web origins for clients
|
|
77
|
+
REDIRECT_URIS = [
|
|
78
|
+
'http://localhost:*',
|
|
79
|
+
'https://*.codetether.run/*',
|
|
80
|
+
'https://app.codetether.run/*',
|
|
81
|
+
]
|
|
82
|
+
WEB_ORIGINS = [
|
|
83
|
+
'http://localhost:3000',
|
|
84
|
+
'http://localhost:8080',
|
|
85
|
+
'https://app.codetether.run',
|
|
86
|
+
'https://*.codetether.run',
|
|
87
|
+
]
|
|
88
|
+
|
|
89
|
+
def __init__(
|
|
90
|
+
self,
|
|
91
|
+
keycloak_url: str = KEYCLOAK_URL,
|
|
92
|
+
admin_username: str = KEYCLOAK_ADMIN_USERNAME,
|
|
93
|
+
admin_password: str = KEYCLOAK_ADMIN_PASSWORD,
|
|
94
|
+
):
|
|
95
|
+
self.keycloak_url = keycloak_url.rstrip('/')
|
|
96
|
+
self.admin_username = admin_username
|
|
97
|
+
self.admin_password = admin_password
|
|
98
|
+
self._admin_token: Optional[str] = None
|
|
99
|
+
|
|
100
|
+
logger.info(
|
|
101
|
+
f'KeycloakTenantService initialized for {self.keycloak_url}'
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
async def _get_admin_token(self) -> str:
|
|
105
|
+
"""Get admin access token from master realm using admin-cli client."""
|
|
106
|
+
token_url = (
|
|
107
|
+
f'{self.keycloak_url}/realms/master/protocol/openid-connect/token'
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
async with httpx.AsyncClient() as client:
|
|
111
|
+
try:
|
|
112
|
+
response = await client.post(
|
|
113
|
+
token_url,
|
|
114
|
+
data={
|
|
115
|
+
'grant_type': 'password',
|
|
116
|
+
'client_id': 'admin-cli',
|
|
117
|
+
'username': self.admin_username,
|
|
118
|
+
'password': self.admin_password,
|
|
119
|
+
},
|
|
120
|
+
timeout=30.0,
|
|
121
|
+
)
|
|
122
|
+
response.raise_for_status()
|
|
123
|
+
token_data = response.json()
|
|
124
|
+
self._admin_token = token_data['access_token']
|
|
125
|
+
logger.debug('Successfully obtained Keycloak admin token')
|
|
126
|
+
return self._admin_token
|
|
127
|
+
except httpx.HTTPStatusError as e:
|
|
128
|
+
logger.error(
|
|
129
|
+
f'Failed to get admin token: {e.response.status_code}'
|
|
130
|
+
)
|
|
131
|
+
raise KeycloakTenantServiceError(
|
|
132
|
+
f'Failed to authenticate as admin: {e.response.text}'
|
|
133
|
+
)
|
|
134
|
+
except httpx.HTTPError as e:
|
|
135
|
+
logger.error(f'HTTP error getting admin token: {e}')
|
|
136
|
+
raise KeycloakTenantServiceError(
|
|
137
|
+
f'Failed to connect to Keycloak: {str(e)}'
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
def _get_auth_headers(self) -> Dict[str, str]:
|
|
141
|
+
"""Get authorization headers with admin token."""
|
|
142
|
+
if not self._admin_token:
|
|
143
|
+
raise KeycloakTenantServiceError('Admin token not available')
|
|
144
|
+
return {
|
|
145
|
+
'Authorization': f'Bearer {self._admin_token}',
|
|
146
|
+
'Content-Type': 'application/json',
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
async def create_tenant(
|
|
150
|
+
self,
|
|
151
|
+
org_slug: str,
|
|
152
|
+
admin_email: str,
|
|
153
|
+
admin_password: str,
|
|
154
|
+
admin_first_name: str = 'Admin',
|
|
155
|
+
admin_last_name: str = 'User',
|
|
156
|
+
) -> TenantInfo:
|
|
157
|
+
"""
|
|
158
|
+
Create a new tenant (realm) in Keycloak.
|
|
159
|
+
|
|
160
|
+
Args:
|
|
161
|
+
org_slug: Organization slug (e.g., "acme") - will create realm "acme.codetether.run"
|
|
162
|
+
admin_email: Email for the admin user
|
|
163
|
+
admin_password: Password for the admin user
|
|
164
|
+
admin_first_name: First name for the admin user
|
|
165
|
+
admin_last_name: Last name for the admin user
|
|
166
|
+
|
|
167
|
+
Returns:
|
|
168
|
+
TenantInfo with realm details and client credentials
|
|
169
|
+
"""
|
|
170
|
+
# Get fresh admin token
|
|
171
|
+
await self._get_admin_token()
|
|
172
|
+
|
|
173
|
+
realm_name = f'{org_slug}.codetether.run'
|
|
174
|
+
logger.info(f'Creating tenant realm: {realm_name}')
|
|
175
|
+
|
|
176
|
+
async with httpx.AsyncClient() as client:
|
|
177
|
+
# Step 1: Create the realm
|
|
178
|
+
await self._create_realm(client, realm_name, org_slug)
|
|
179
|
+
|
|
180
|
+
# Step 2: Create standard clients
|
|
181
|
+
spa_client_id = f'{org_slug}-spa'
|
|
182
|
+
api_client_id = f'{org_slug}-api'
|
|
183
|
+
mobile_client_id = f'{org_slug}-mobile'
|
|
184
|
+
|
|
185
|
+
await self._create_spa_client(client, realm_name, spa_client_id)
|
|
186
|
+
api_secret = await self._create_api_client(
|
|
187
|
+
client, realm_name, api_client_id
|
|
188
|
+
)
|
|
189
|
+
await self._create_mobile_client(
|
|
190
|
+
client, realm_name, mobile_client_id
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
# Step 3: Create admin role
|
|
194
|
+
await self._create_role(
|
|
195
|
+
client, realm_name, 'admin', 'Administrator role'
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
# Step 4: Create admin user
|
|
199
|
+
admin_user_id = await self._create_user(
|
|
200
|
+
client,
|
|
201
|
+
realm_name,
|
|
202
|
+
admin_email,
|
|
203
|
+
admin_password,
|
|
204
|
+
admin_first_name,
|
|
205
|
+
admin_last_name,
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
# Step 5: Assign admin role to user
|
|
209
|
+
await self._assign_role_to_user(
|
|
210
|
+
client, realm_name, admin_user_id, 'admin'
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
logger.info(f'Successfully created tenant: {realm_name}')
|
|
214
|
+
|
|
215
|
+
return TenantInfo(
|
|
216
|
+
realm_name=realm_name,
|
|
217
|
+
org_slug=org_slug,
|
|
218
|
+
spa_client_id=spa_client_id,
|
|
219
|
+
api_client_id=api_client_id,
|
|
220
|
+
api_client_secret=api_secret,
|
|
221
|
+
mobile_client_id=mobile_client_id,
|
|
222
|
+
admin_user_id=admin_user_id,
|
|
223
|
+
admin_email=admin_email,
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
async def _create_realm(
|
|
227
|
+
self, client: httpx.AsyncClient, realm_name: str, org_slug: str
|
|
228
|
+
) -> None:
|
|
229
|
+
"""Create a new Keycloak realm."""
|
|
230
|
+
realm_url = f'{self.keycloak_url}/admin/realms'
|
|
231
|
+
|
|
232
|
+
realm_config = {
|
|
233
|
+
'realm': realm_name,
|
|
234
|
+
'enabled': True,
|
|
235
|
+
'displayName': f'{org_slug.title()} - CodeTether',
|
|
236
|
+
'displayNameHtml': f'<b>{org_slug.title()}</b> - CodeTether',
|
|
237
|
+
'registrationAllowed': False,
|
|
238
|
+
'registrationEmailAsUsername': True,
|
|
239
|
+
'rememberMe': True,
|
|
240
|
+
'verifyEmail': False,
|
|
241
|
+
'loginWithEmailAllowed': True,
|
|
242
|
+
'duplicateEmailsAllowed': False,
|
|
243
|
+
'resetPasswordAllowed': True,
|
|
244
|
+
'editUsernameAllowed': False,
|
|
245
|
+
'bruteForceProtected': True,
|
|
246
|
+
'permanentLockout': False,
|
|
247
|
+
'maxFailureWaitSeconds': 900,
|
|
248
|
+
'minimumQuickLoginWaitSeconds': 60,
|
|
249
|
+
'waitIncrementSeconds': 60,
|
|
250
|
+
'quickLoginCheckMilliSeconds': 1000,
|
|
251
|
+
'maxDeltaTimeSeconds': 43200,
|
|
252
|
+
'failureFactor': 30,
|
|
253
|
+
'sslRequired': 'external',
|
|
254
|
+
'accessTokenLifespan': 300,
|
|
255
|
+
'accessTokenLifespanForImplicitFlow': 900,
|
|
256
|
+
'ssoSessionIdleTimeout': 1800,
|
|
257
|
+
'ssoSessionMaxLifespan': 36000,
|
|
258
|
+
'offlineSessionIdleTimeout': 2592000,
|
|
259
|
+
'accessCodeLifespan': 60,
|
|
260
|
+
'accessCodeLifespanUserAction': 300,
|
|
261
|
+
'accessCodeLifespanLogin': 1800,
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
try:
|
|
265
|
+
response = await client.post(
|
|
266
|
+
realm_url,
|
|
267
|
+
json=realm_config,
|
|
268
|
+
headers=self._get_auth_headers(),
|
|
269
|
+
timeout=30.0,
|
|
270
|
+
)
|
|
271
|
+
|
|
272
|
+
if response.status_code == 409:
|
|
273
|
+
raise TenantAlreadyExistsError(
|
|
274
|
+
f'Realm {realm_name} already exists'
|
|
275
|
+
)
|
|
276
|
+
|
|
277
|
+
response.raise_for_status()
|
|
278
|
+
logger.info(f'Created realm: {realm_name}')
|
|
279
|
+
|
|
280
|
+
except httpx.HTTPStatusError as e:
|
|
281
|
+
if e.response.status_code != 409:
|
|
282
|
+
logger.error(f'Failed to create realm: {e.response.text}')
|
|
283
|
+
raise KeycloakTenantServiceError(
|
|
284
|
+
f'Failed to create realm: {e.response.text}'
|
|
285
|
+
)
|
|
286
|
+
raise
|
|
287
|
+
|
|
288
|
+
async def _create_spa_client(
|
|
289
|
+
self, client: httpx.AsyncClient, realm_name: str, client_id: str
|
|
290
|
+
) -> None:
|
|
291
|
+
"""Create a public SPA client."""
|
|
292
|
+
clients_url = f'{self.keycloak_url}/admin/realms/{realm_name}/clients'
|
|
293
|
+
|
|
294
|
+
client_config = {
|
|
295
|
+
'clientId': client_id,
|
|
296
|
+
'name': f'{client_id} - Web Application',
|
|
297
|
+
'enabled': True,
|
|
298
|
+
'publicClient': True,
|
|
299
|
+
'standardFlowEnabled': True,
|
|
300
|
+
'directAccessGrantsEnabled': True,
|
|
301
|
+
'implicitFlowEnabled': False,
|
|
302
|
+
'serviceAccountsEnabled': False,
|
|
303
|
+
'authorizationServicesEnabled': False,
|
|
304
|
+
'redirectUris': self.REDIRECT_URIS,
|
|
305
|
+
'webOrigins': self.WEB_ORIGINS,
|
|
306
|
+
'protocol': 'openid-connect',
|
|
307
|
+
'attributes': {
|
|
308
|
+
'pkce.code.challenge.method': 'S256',
|
|
309
|
+
'post.logout.redirect.uris': '+',
|
|
310
|
+
},
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
try:
|
|
314
|
+
response = await client.post(
|
|
315
|
+
clients_url,
|
|
316
|
+
json=client_config,
|
|
317
|
+
headers=self._get_auth_headers(),
|
|
318
|
+
timeout=30.0,
|
|
319
|
+
)
|
|
320
|
+
response.raise_for_status()
|
|
321
|
+
logger.info(f'Created SPA client: {client_id}')
|
|
322
|
+
except httpx.HTTPStatusError as e:
|
|
323
|
+
logger.error(f'Failed to create SPA client: {e.response.text}')
|
|
324
|
+
raise KeycloakTenantServiceError(
|
|
325
|
+
f'Failed to create SPA client: {e.response.text}'
|
|
326
|
+
)
|
|
327
|
+
|
|
328
|
+
async def _create_api_client(
|
|
329
|
+
self, client: httpx.AsyncClient, realm_name: str, client_id: str
|
|
330
|
+
) -> str:
|
|
331
|
+
"""Create a confidential API client and return its secret."""
|
|
332
|
+
clients_url = f'{self.keycloak_url}/admin/realms/{realm_name}/clients'
|
|
333
|
+
|
|
334
|
+
# Generate a secure client secret
|
|
335
|
+
client_secret = secrets.token_urlsafe(32)
|
|
336
|
+
|
|
337
|
+
client_config = {
|
|
338
|
+
'clientId': client_id,
|
|
339
|
+
'name': f'{client_id} - Backend API',
|
|
340
|
+
'enabled': True,
|
|
341
|
+
'publicClient': False,
|
|
342
|
+
'standardFlowEnabled': False,
|
|
343
|
+
'directAccessGrantsEnabled': True,
|
|
344
|
+
'serviceAccountsEnabled': True,
|
|
345
|
+
'authorizationServicesEnabled': False,
|
|
346
|
+
'secret': client_secret,
|
|
347
|
+
'redirectUris': self.REDIRECT_URIS,
|
|
348
|
+
'webOrigins': self.WEB_ORIGINS,
|
|
349
|
+
'protocol': 'openid-connect',
|
|
350
|
+
'attributes': {
|
|
351
|
+
'client.secret.creation.time': '0',
|
|
352
|
+
},
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
try:
|
|
356
|
+
response = await client.post(
|
|
357
|
+
clients_url,
|
|
358
|
+
json=client_config,
|
|
359
|
+
headers=self._get_auth_headers(),
|
|
360
|
+
timeout=30.0,
|
|
361
|
+
)
|
|
362
|
+
response.raise_for_status()
|
|
363
|
+
logger.info(f'Created API client: {client_id}')
|
|
364
|
+
return client_secret
|
|
365
|
+
except httpx.HTTPStatusError as e:
|
|
366
|
+
logger.error(f'Failed to create API client: {e.response.text}')
|
|
367
|
+
raise KeycloakTenantServiceError(
|
|
368
|
+
f'Failed to create API client: {e.response.text}'
|
|
369
|
+
)
|
|
370
|
+
|
|
371
|
+
async def _create_mobile_client(
|
|
372
|
+
self, client: httpx.AsyncClient, realm_name: str, client_id: str
|
|
373
|
+
) -> None:
|
|
374
|
+
"""Create a public mobile client."""
|
|
375
|
+
clients_url = f'{self.keycloak_url}/admin/realms/{realm_name}/clients'
|
|
376
|
+
|
|
377
|
+
client_config = {
|
|
378
|
+
'clientId': client_id,
|
|
379
|
+
'name': f'{client_id} - Mobile Application',
|
|
380
|
+
'enabled': True,
|
|
381
|
+
'publicClient': True,
|
|
382
|
+
'standardFlowEnabled': True,
|
|
383
|
+
'directAccessGrantsEnabled': True,
|
|
384
|
+
'implicitFlowEnabled': False,
|
|
385
|
+
'serviceAccountsEnabled': False,
|
|
386
|
+
'authorizationServicesEnabled': False,
|
|
387
|
+
'redirectUris': [
|
|
388
|
+
'http://localhost:*',
|
|
389
|
+
f'codetether://{client_id}/*',
|
|
390
|
+
f'codetether.{client_id}://*',
|
|
391
|
+
],
|
|
392
|
+
'webOrigins': ['*'],
|
|
393
|
+
'protocol': 'openid-connect',
|
|
394
|
+
'attributes': {
|
|
395
|
+
'pkce.code.challenge.method': 'S256',
|
|
396
|
+
},
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
try:
|
|
400
|
+
response = await client.post(
|
|
401
|
+
clients_url,
|
|
402
|
+
json=client_config,
|
|
403
|
+
headers=self._get_auth_headers(),
|
|
404
|
+
timeout=30.0,
|
|
405
|
+
)
|
|
406
|
+
response.raise_for_status()
|
|
407
|
+
logger.info(f'Created mobile client: {client_id}')
|
|
408
|
+
except httpx.HTTPStatusError as e:
|
|
409
|
+
logger.error(f'Failed to create mobile client: {e.response.text}')
|
|
410
|
+
raise KeycloakTenantServiceError(
|
|
411
|
+
f'Failed to create mobile client: {e.response.text}'
|
|
412
|
+
)
|
|
413
|
+
|
|
414
|
+
async def _create_role(
|
|
415
|
+
self,
|
|
416
|
+
client: httpx.AsyncClient,
|
|
417
|
+
realm_name: str,
|
|
418
|
+
role_name: str,
|
|
419
|
+
description: str = '',
|
|
420
|
+
) -> None:
|
|
421
|
+
"""Create a realm role."""
|
|
422
|
+
roles_url = f'{self.keycloak_url}/admin/realms/{realm_name}/roles'
|
|
423
|
+
|
|
424
|
+
role_config = {
|
|
425
|
+
'name': role_name,
|
|
426
|
+
'description': description,
|
|
427
|
+
'composite': False,
|
|
428
|
+
'clientRole': False,
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
try:
|
|
432
|
+
response = await client.post(
|
|
433
|
+
roles_url,
|
|
434
|
+
json=role_config,
|
|
435
|
+
headers=self._get_auth_headers(),
|
|
436
|
+
timeout=30.0,
|
|
437
|
+
)
|
|
438
|
+
response.raise_for_status()
|
|
439
|
+
logger.info(f'Created role: {role_name} in realm {realm_name}')
|
|
440
|
+
except httpx.HTTPStatusError as e:
|
|
441
|
+
# Role might already exist
|
|
442
|
+
if e.response.status_code != 409:
|
|
443
|
+
logger.error(f'Failed to create role: {e.response.text}')
|
|
444
|
+
raise KeycloakTenantServiceError(
|
|
445
|
+
f'Failed to create role: {e.response.text}'
|
|
446
|
+
)
|
|
447
|
+
logger.info(f'Role {role_name} already exists')
|
|
448
|
+
|
|
449
|
+
async def _create_user(
|
|
450
|
+
self,
|
|
451
|
+
client: httpx.AsyncClient,
|
|
452
|
+
realm_name: str,
|
|
453
|
+
email: str,
|
|
454
|
+
password: str,
|
|
455
|
+
first_name: str,
|
|
456
|
+
last_name: str,
|
|
457
|
+
) -> str:
|
|
458
|
+
"""Create a user and return their ID."""
|
|
459
|
+
users_url = f'{self.keycloak_url}/admin/realms/{realm_name}/users'
|
|
460
|
+
|
|
461
|
+
user_config = {
|
|
462
|
+
'username': email,
|
|
463
|
+
'email': email,
|
|
464
|
+
'firstName': first_name,
|
|
465
|
+
'lastName': last_name,
|
|
466
|
+
'enabled': True,
|
|
467
|
+
'emailVerified': True,
|
|
468
|
+
'credentials': [
|
|
469
|
+
{
|
|
470
|
+
'type': 'password',
|
|
471
|
+
'value': password,
|
|
472
|
+
'temporary': False,
|
|
473
|
+
}
|
|
474
|
+
],
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
try:
|
|
478
|
+
response = await client.post(
|
|
479
|
+
users_url,
|
|
480
|
+
json=user_config,
|
|
481
|
+
headers=self._get_auth_headers(),
|
|
482
|
+
timeout=30.0,
|
|
483
|
+
)
|
|
484
|
+
response.raise_for_status()
|
|
485
|
+
|
|
486
|
+
# Get user ID from Location header
|
|
487
|
+
location = response.headers.get('Location', '')
|
|
488
|
+
user_id = location.split('/')[-1] if location else ''
|
|
489
|
+
|
|
490
|
+
if not user_id:
|
|
491
|
+
# Fetch user by email to get ID
|
|
492
|
+
search_response = await client.get(
|
|
493
|
+
users_url,
|
|
494
|
+
params={'email': email},
|
|
495
|
+
headers=self._get_auth_headers(),
|
|
496
|
+
timeout=30.0,
|
|
497
|
+
)
|
|
498
|
+
search_response.raise_for_status()
|
|
499
|
+
users = search_response.json()
|
|
500
|
+
if users:
|
|
501
|
+
user_id = users[0]['id']
|
|
502
|
+
|
|
503
|
+
logger.info(f'Created user: {email} with ID {user_id}')
|
|
504
|
+
return user_id
|
|
505
|
+
|
|
506
|
+
except httpx.HTTPStatusError as e:
|
|
507
|
+
logger.error(f'Failed to create user: {e.response.text}')
|
|
508
|
+
raise KeycloakTenantServiceError(
|
|
509
|
+
f'Failed to create user: {e.response.text}'
|
|
510
|
+
)
|
|
511
|
+
|
|
512
|
+
async def _assign_role_to_user(
|
|
513
|
+
self,
|
|
514
|
+
client: httpx.AsyncClient,
|
|
515
|
+
realm_name: str,
|
|
516
|
+
user_id: str,
|
|
517
|
+
role_name: str,
|
|
518
|
+
) -> None:
|
|
519
|
+
"""Assign a realm role to a user."""
|
|
520
|
+
# First, get the role representation
|
|
521
|
+
role_url = (
|
|
522
|
+
f'{self.keycloak_url}/admin/realms/{realm_name}/roles/{role_name}'
|
|
523
|
+
)
|
|
524
|
+
|
|
525
|
+
try:
|
|
526
|
+
role_response = await client.get(
|
|
527
|
+
role_url,
|
|
528
|
+
headers=self._get_auth_headers(),
|
|
529
|
+
timeout=30.0,
|
|
530
|
+
)
|
|
531
|
+
role_response.raise_for_status()
|
|
532
|
+
role = role_response.json()
|
|
533
|
+
|
|
534
|
+
# Assign role to user
|
|
535
|
+
user_roles_url = f'{self.keycloak_url}/admin/realms/{realm_name}/users/{user_id}/role-mappings/realm'
|
|
536
|
+
|
|
537
|
+
response = await client.post(
|
|
538
|
+
user_roles_url,
|
|
539
|
+
json=[role],
|
|
540
|
+
headers=self._get_auth_headers(),
|
|
541
|
+
timeout=30.0,
|
|
542
|
+
)
|
|
543
|
+
response.raise_for_status()
|
|
544
|
+
logger.info(f'Assigned role {role_name} to user {user_id}')
|
|
545
|
+
|
|
546
|
+
except httpx.HTTPStatusError as e:
|
|
547
|
+
logger.error(f'Failed to assign role: {e.response.text}')
|
|
548
|
+
raise KeycloakTenantServiceError(
|
|
549
|
+
f'Failed to assign role: {e.response.text}'
|
|
550
|
+
)
|
|
551
|
+
|
|
552
|
+
async def delete_tenant(self, realm_name: str) -> bool:
|
|
553
|
+
"""
|
|
554
|
+
Delete a tenant (realm) from Keycloak.
|
|
555
|
+
|
|
556
|
+
Args:
|
|
557
|
+
realm_name: The realm name to delete
|
|
558
|
+
|
|
559
|
+
Returns:
|
|
560
|
+
True if successfully deleted
|
|
561
|
+
"""
|
|
562
|
+
await self._get_admin_token()
|
|
563
|
+
|
|
564
|
+
realm_url = f'{self.keycloak_url}/admin/realms/{realm_name}'
|
|
565
|
+
|
|
566
|
+
async with httpx.AsyncClient() as client:
|
|
567
|
+
try:
|
|
568
|
+
response = await client.delete(
|
|
569
|
+
realm_url,
|
|
570
|
+
headers=self._get_auth_headers(),
|
|
571
|
+
timeout=30.0,
|
|
572
|
+
)
|
|
573
|
+
|
|
574
|
+
if response.status_code == 404:
|
|
575
|
+
raise TenantNotFoundError(f'Realm {realm_name} not found')
|
|
576
|
+
|
|
577
|
+
response.raise_for_status()
|
|
578
|
+
logger.info(f'Deleted tenant realm: {realm_name}')
|
|
579
|
+
return True
|
|
580
|
+
|
|
581
|
+
except httpx.HTTPStatusError as e:
|
|
582
|
+
if e.response.status_code == 404:
|
|
583
|
+
raise TenantNotFoundError(f'Realm {realm_name} not found')
|
|
584
|
+
logger.error(f'Failed to delete realm: {e.response.text}')
|
|
585
|
+
raise KeycloakTenantServiceError(
|
|
586
|
+
f'Failed to delete realm: {e.response.text}'
|
|
587
|
+
)
|
|
588
|
+
|
|
589
|
+
async def get_tenant_clients(self, realm_name: str) -> List[Dict[str, Any]]:
|
|
590
|
+
"""
|
|
591
|
+
Get all clients for a tenant realm.
|
|
592
|
+
|
|
593
|
+
Args:
|
|
594
|
+
realm_name: The realm name to fetch clients for
|
|
595
|
+
|
|
596
|
+
Returns:
|
|
597
|
+
List of client details
|
|
598
|
+
"""
|
|
599
|
+
await self._get_admin_token()
|
|
600
|
+
|
|
601
|
+
clients_url = f'{self.keycloak_url}/admin/realms/{realm_name}/clients'
|
|
602
|
+
|
|
603
|
+
async with httpx.AsyncClient() as client:
|
|
604
|
+
try:
|
|
605
|
+
response = await client.get(
|
|
606
|
+
clients_url,
|
|
607
|
+
headers=self._get_auth_headers(),
|
|
608
|
+
timeout=30.0,
|
|
609
|
+
)
|
|
610
|
+
|
|
611
|
+
if response.status_code == 404:
|
|
612
|
+
raise TenantNotFoundError(f'Realm {realm_name} not found')
|
|
613
|
+
|
|
614
|
+
response.raise_for_status()
|
|
615
|
+
clients = response.json()
|
|
616
|
+
|
|
617
|
+
# Filter out built-in Keycloak clients and format response
|
|
618
|
+
custom_clients = []
|
|
619
|
+
builtin_clients = {
|
|
620
|
+
'account',
|
|
621
|
+
'account-console',
|
|
622
|
+
'admin-cli',
|
|
623
|
+
'broker',
|
|
624
|
+
'realm-management',
|
|
625
|
+
'security-admin-console',
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
for c in clients:
|
|
629
|
+
client_id = c.get('clientId', '')
|
|
630
|
+
if client_id not in builtin_clients:
|
|
631
|
+
client_info = {
|
|
632
|
+
'id': c.get('id'),
|
|
633
|
+
'clientId': client_id,
|
|
634
|
+
'name': c.get('name'),
|
|
635
|
+
'enabled': c.get('enabled'),
|
|
636
|
+
'publicClient': c.get('publicClient'),
|
|
637
|
+
'standardFlowEnabled': c.get('standardFlowEnabled'),
|
|
638
|
+
'directAccessGrantsEnabled': c.get(
|
|
639
|
+
'directAccessGrantsEnabled'
|
|
640
|
+
),
|
|
641
|
+
'serviceAccountsEnabled': c.get(
|
|
642
|
+
'serviceAccountsEnabled'
|
|
643
|
+
),
|
|
644
|
+
'redirectUris': c.get('redirectUris', []),
|
|
645
|
+
'webOrigins': c.get('webOrigins', []),
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
# Get client secret for confidential clients
|
|
649
|
+
if not c.get('publicClient'):
|
|
650
|
+
secret = await self._get_client_secret(
|
|
651
|
+
client, realm_name, c.get('id')
|
|
652
|
+
)
|
|
653
|
+
client_info['secret'] = secret
|
|
654
|
+
|
|
655
|
+
custom_clients.append(client_info)
|
|
656
|
+
|
|
657
|
+
logger.info(
|
|
658
|
+
f'Retrieved {len(custom_clients)} clients for realm {realm_name}'
|
|
659
|
+
)
|
|
660
|
+
return custom_clients
|
|
661
|
+
|
|
662
|
+
except httpx.HTTPStatusError as e:
|
|
663
|
+
if e.response.status_code == 404:
|
|
664
|
+
raise TenantNotFoundError(f'Realm {realm_name} not found')
|
|
665
|
+
logger.error(f'Failed to get clients: {e.response.text}')
|
|
666
|
+
raise KeycloakTenantServiceError(
|
|
667
|
+
f'Failed to get clients: {e.response.text}'
|
|
668
|
+
)
|
|
669
|
+
|
|
670
|
+
async def _get_client_secret(
|
|
671
|
+
self, client: httpx.AsyncClient, realm_name: str, client_uuid: str
|
|
672
|
+
) -> Optional[str]:
|
|
673
|
+
"""Get the secret for a confidential client."""
|
|
674
|
+
secret_url = f'{self.keycloak_url}/admin/realms/{realm_name}/clients/{client_uuid}/client-secret'
|
|
675
|
+
|
|
676
|
+
try:
|
|
677
|
+
response = await client.get(
|
|
678
|
+
secret_url,
|
|
679
|
+
headers=self._get_auth_headers(),
|
|
680
|
+
timeout=30.0,
|
|
681
|
+
)
|
|
682
|
+
response.raise_for_status()
|
|
683
|
+
data = response.json()
|
|
684
|
+
return data.get('value')
|
|
685
|
+
except httpx.HTTPStatusError:
|
|
686
|
+
return None
|
|
687
|
+
|
|
688
|
+
async def tenant_exists(self, realm_name: str) -> bool:
|
|
689
|
+
"""
|
|
690
|
+
Check if a tenant realm exists.
|
|
691
|
+
|
|
692
|
+
Args:
|
|
693
|
+
realm_name: The realm name to check
|
|
694
|
+
|
|
695
|
+
Returns:
|
|
696
|
+
True if realm exists
|
|
697
|
+
"""
|
|
698
|
+
await self._get_admin_token()
|
|
699
|
+
|
|
700
|
+
realm_url = f'{self.keycloak_url}/admin/realms/{realm_name}'
|
|
701
|
+
|
|
702
|
+
async with httpx.AsyncClient() as client:
|
|
703
|
+
try:
|
|
704
|
+
response = await client.get(
|
|
705
|
+
realm_url,
|
|
706
|
+
headers=self._get_auth_headers(),
|
|
707
|
+
timeout=30.0,
|
|
708
|
+
)
|
|
709
|
+
return response.status_code == 200
|
|
710
|
+
except httpx.HTTPError:
|
|
711
|
+
return False
|
|
712
|
+
|
|
713
|
+
async def list_tenants(self) -> List[Dict[str, Any]]:
|
|
714
|
+
"""
|
|
715
|
+
List all tenant realms (excluding master and default realms).
|
|
716
|
+
|
|
717
|
+
Returns:
|
|
718
|
+
List of realm information
|
|
719
|
+
"""
|
|
720
|
+
await self._get_admin_token()
|
|
721
|
+
|
|
722
|
+
realms_url = f'{self.keycloak_url}/admin/realms'
|
|
723
|
+
|
|
724
|
+
async with httpx.AsyncClient() as client:
|
|
725
|
+
try:
|
|
726
|
+
response = await client.get(
|
|
727
|
+
realms_url,
|
|
728
|
+
headers=self._get_auth_headers(),
|
|
729
|
+
timeout=30.0,
|
|
730
|
+
)
|
|
731
|
+
response.raise_for_status()
|
|
732
|
+
realms = response.json()
|
|
733
|
+
|
|
734
|
+
# Filter to only codetether.run realms
|
|
735
|
+
tenant_realms = []
|
|
736
|
+
for realm in realms:
|
|
737
|
+
realm_name = realm.get('realm', '')
|
|
738
|
+
if realm_name.endswith('.codetether.run'):
|
|
739
|
+
tenant_realms.append(
|
|
740
|
+
{
|
|
741
|
+
'realm_name': realm_name,
|
|
742
|
+
'org_slug': realm_name.replace(
|
|
743
|
+
'.codetether.run', ''
|
|
744
|
+
),
|
|
745
|
+
'display_name': realm.get('displayName'),
|
|
746
|
+
'enabled': realm.get('enabled'),
|
|
747
|
+
}
|
|
748
|
+
)
|
|
749
|
+
|
|
750
|
+
return tenant_realms
|
|
751
|
+
|
|
752
|
+
except httpx.HTTPStatusError as e:
|
|
753
|
+
logger.error(f'Failed to list realms: {e.response.text}')
|
|
754
|
+
raise KeycloakTenantServiceError(
|
|
755
|
+
f'Failed to list realms: {e.response.text}'
|
|
756
|
+
)
|
|
757
|
+
|
|
758
|
+
|
|
759
|
+
# Global tenant service instance
|
|
760
|
+
keycloak_tenant_service = KeycloakTenantService()
|