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
a2a_server/tenant_api.py
ADDED
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Tenant Management REST API for A2A Server.
|
|
3
|
+
|
|
4
|
+
Provides endpoints for tenant provisioning, management, and administration:
|
|
5
|
+
- Public signup for new tenants
|
|
6
|
+
- Tenant details retrieval
|
|
7
|
+
- Admin management of tenants
|
|
8
|
+
- Super-admin listing of all tenants
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import logging
|
|
12
|
+
import re
|
|
13
|
+
import uuid
|
|
14
|
+
from typing import List, Optional
|
|
15
|
+
|
|
16
|
+
from fastapi import APIRouter, Depends, HTTPException, Query
|
|
17
|
+
from pydantic import BaseModel, Field, field_validator
|
|
18
|
+
|
|
19
|
+
from .database import (
|
|
20
|
+
create_tenant,
|
|
21
|
+
get_tenant_by_id,
|
|
22
|
+
get_tenant_by_realm,
|
|
23
|
+
list_tenants,
|
|
24
|
+
update_tenant,
|
|
25
|
+
)
|
|
26
|
+
from .keycloak_auth import require_admin, require_auth, UserSession
|
|
27
|
+
from .tenant_service import KeycloakTenantService, TenantAlreadyExistsError
|
|
28
|
+
|
|
29
|
+
logger = logging.getLogger(__name__)
|
|
30
|
+
|
|
31
|
+
router = APIRouter(prefix='/v1/tenants', tags=['tenants'])
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
# ========================================
|
|
35
|
+
# Request/Response Models
|
|
36
|
+
# ========================================
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class TenantSignupRequest(BaseModel):
|
|
40
|
+
"""Request model for tenant signup."""
|
|
41
|
+
|
|
42
|
+
org_name: str = Field(
|
|
43
|
+
...,
|
|
44
|
+
min_length=3,
|
|
45
|
+
max_length=50,
|
|
46
|
+
description='Organization name (3-50 chars, alphanumeric and spaces)',
|
|
47
|
+
)
|
|
48
|
+
admin_email: str = Field(..., description='Admin user email address')
|
|
49
|
+
admin_password: str = Field(
|
|
50
|
+
..., min_length=8, description='Admin user password (min 8 chars)'
|
|
51
|
+
)
|
|
52
|
+
plan: Optional[str] = Field(
|
|
53
|
+
default='free', description='Subscription plan (free, pro, enterprise)'
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
@field_validator('org_name')
|
|
57
|
+
@classmethod
|
|
58
|
+
def validate_org_name(cls, v: str) -> str:
|
|
59
|
+
"""Validate org_name contains only alphanumeric characters and spaces."""
|
|
60
|
+
if not re.match(r'^[a-zA-Z0-9 ]+$', v):
|
|
61
|
+
raise ValueError(
|
|
62
|
+
'Organization name must contain only alphanumeric characters and spaces'
|
|
63
|
+
)
|
|
64
|
+
return v
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class TenantSignupResponse(BaseModel):
|
|
68
|
+
"""Response model for successful tenant signup."""
|
|
69
|
+
|
|
70
|
+
tenant_id: str = Field(..., description='Unique tenant identifier')
|
|
71
|
+
realm_name: str = Field(..., description='Keycloak realm name')
|
|
72
|
+
login_url: str = Field(..., description='URL for user authentication')
|
|
73
|
+
spa_client_id: str = Field(
|
|
74
|
+
..., description='Client ID for SPA applications'
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class TenantResponse(BaseModel):
|
|
79
|
+
"""Response model for tenant details."""
|
|
80
|
+
|
|
81
|
+
id: str
|
|
82
|
+
realm_name: str
|
|
83
|
+
display_name: Optional[str]
|
|
84
|
+
plan: str
|
|
85
|
+
stripe_customer_id: Optional[str]
|
|
86
|
+
stripe_subscription_id: Optional[str]
|
|
87
|
+
created_at: Optional[str]
|
|
88
|
+
updated_at: Optional[str]
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class TenantUpdateRequest(BaseModel):
|
|
92
|
+
"""Request model for tenant updates."""
|
|
93
|
+
|
|
94
|
+
display_name: Optional[str] = Field(
|
|
95
|
+
default=None, description='Human-readable tenant name'
|
|
96
|
+
)
|
|
97
|
+
plan: Optional[str] = Field(
|
|
98
|
+
default=None, description='Subscription plan (free, pro, enterprise)'
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class TenantListResponse(BaseModel):
|
|
103
|
+
"""Response model for tenant listing."""
|
|
104
|
+
|
|
105
|
+
tenants: List[TenantResponse]
|
|
106
|
+
total: int
|
|
107
|
+
limit: int
|
|
108
|
+
offset: int
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
# ========================================
|
|
112
|
+
# Helper Functions
|
|
113
|
+
# ========================================
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def generate_slug(org_name: str) -> str:
|
|
117
|
+
"""
|
|
118
|
+
Generate a URL-safe slug from organization name.
|
|
119
|
+
|
|
120
|
+
- Converts to lowercase
|
|
121
|
+
- Replaces spaces with hyphens
|
|
122
|
+
- Removes special characters
|
|
123
|
+
"""
|
|
124
|
+
slug = org_name.lower()
|
|
125
|
+
slug = slug.replace(' ', '-')
|
|
126
|
+
slug = re.sub(r'[^a-z0-9-]', '', slug)
|
|
127
|
+
# Remove consecutive hyphens
|
|
128
|
+
slug = re.sub(r'-+', '-', slug)
|
|
129
|
+
# Remove leading/trailing hyphens
|
|
130
|
+
slug = slug.strip('-')
|
|
131
|
+
return slug
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
# ========================================
|
|
135
|
+
# Endpoints
|
|
136
|
+
# ========================================
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
@router.post('/signup', response_model=TenantSignupResponse, status_code=201)
|
|
140
|
+
async def signup_tenant(request: TenantSignupRequest):
|
|
141
|
+
"""
|
|
142
|
+
Create a new tenant (public endpoint, no auth required).
|
|
143
|
+
|
|
144
|
+
This endpoint provisions:
|
|
145
|
+
- A new Keycloak realm for the organization
|
|
146
|
+
- Standard OAuth clients (SPA, API, Mobile)
|
|
147
|
+
- An admin user with the provided credentials
|
|
148
|
+
- A database record for the tenant
|
|
149
|
+
|
|
150
|
+
Returns login URL and client credentials for the new tenant.
|
|
151
|
+
"""
|
|
152
|
+
# Generate slug from org name
|
|
153
|
+
slug = generate_slug(request.org_name)
|
|
154
|
+
|
|
155
|
+
if not slug:
|
|
156
|
+
raise HTTPException(
|
|
157
|
+
status_code=400,
|
|
158
|
+
detail='Invalid organization name - could not generate slug',
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
logger.info(f'Tenant signup request for: {request.org_name} (slug: {slug})')
|
|
162
|
+
|
|
163
|
+
try:
|
|
164
|
+
# Create tenant in Keycloak
|
|
165
|
+
keycloak_service = KeycloakTenantService()
|
|
166
|
+
tenant_info = await keycloak_service.create_tenant(
|
|
167
|
+
org_slug=slug,
|
|
168
|
+
admin_email=request.admin_email,
|
|
169
|
+
admin_password=request.admin_password,
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
# Store tenant in database
|
|
173
|
+
db_tenant = await create_tenant(
|
|
174
|
+
realm_name=tenant_info.realm_name,
|
|
175
|
+
display_name=request.org_name,
|
|
176
|
+
plan=request.plan or 'free',
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
# Build login URL
|
|
180
|
+
from .keycloak_auth import KEYCLOAK_URL
|
|
181
|
+
|
|
182
|
+
login_url = f'{KEYCLOAK_URL}/realms/{tenant_info.realm_name}/protocol/openid-connect/auth'
|
|
183
|
+
|
|
184
|
+
logger.info(
|
|
185
|
+
f'Tenant created successfully: {tenant_info.realm_name} (id: {db_tenant["id"]})'
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
return TenantSignupResponse(
|
|
189
|
+
tenant_id=db_tenant['id'],
|
|
190
|
+
realm_name=tenant_info.realm_name,
|
|
191
|
+
login_url=login_url,
|
|
192
|
+
spa_client_id=tenant_info.spa_client_id,
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
except TenantAlreadyExistsError as e:
|
|
196
|
+
logger.warning(f'Tenant already exists: {slug}')
|
|
197
|
+
raise HTTPException(
|
|
198
|
+
status_code=409,
|
|
199
|
+
detail=f'A tenant with this organization name already exists: {str(e)}',
|
|
200
|
+
)
|
|
201
|
+
except Exception as e:
|
|
202
|
+
logger.error(f'Failed to create tenant: {e}')
|
|
203
|
+
raise HTTPException(
|
|
204
|
+
status_code=500,
|
|
205
|
+
detail=f'Failed to create tenant: {str(e)}',
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
@router.get('/me', response_model=TenantResponse)
|
|
210
|
+
async def get_my_tenant(user: UserSession = Depends(require_auth)):
|
|
211
|
+
"""
|
|
212
|
+
Get the current user's tenant details.
|
|
213
|
+
|
|
214
|
+
Requires authentication. Returns the tenant associated with the
|
|
215
|
+
authenticated user's tenant_id.
|
|
216
|
+
"""
|
|
217
|
+
# Get tenant_id from user session
|
|
218
|
+
tenant_id = getattr(user, 'tenant_id', None)
|
|
219
|
+
|
|
220
|
+
if not tenant_id:
|
|
221
|
+
# Try to find tenant by realm from the token issuer
|
|
222
|
+
# The realm is typically in the format: org-slug.codetether.run
|
|
223
|
+
raise HTTPException(
|
|
224
|
+
status_code=404,
|
|
225
|
+
detail='No tenant associated with this user',
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
tenant = await get_tenant_by_id(tenant_id)
|
|
229
|
+
|
|
230
|
+
if not tenant:
|
|
231
|
+
raise HTTPException(
|
|
232
|
+
status_code=404,
|
|
233
|
+
detail='Tenant not found',
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
return TenantResponse(**tenant)
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
@router.get('/{tenant_id}', response_model=TenantResponse)
|
|
240
|
+
async def get_tenant(
|
|
241
|
+
tenant_id: str,
|
|
242
|
+
user: UserSession = Depends(require_admin),
|
|
243
|
+
):
|
|
244
|
+
"""
|
|
245
|
+
Get tenant details by ID.
|
|
246
|
+
|
|
247
|
+
Requires admin role.
|
|
248
|
+
"""
|
|
249
|
+
tenant = await get_tenant_by_id(tenant_id)
|
|
250
|
+
|
|
251
|
+
if not tenant:
|
|
252
|
+
raise HTTPException(
|
|
253
|
+
status_code=404,
|
|
254
|
+
detail=f'Tenant {tenant_id} not found',
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
return TenantResponse(**tenant)
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
@router.patch('/{tenant_id}', response_model=TenantResponse)
|
|
261
|
+
async def update_tenant_details(
|
|
262
|
+
tenant_id: str,
|
|
263
|
+
request: TenantUpdateRequest,
|
|
264
|
+
user: UserSession = Depends(require_admin),
|
|
265
|
+
):
|
|
266
|
+
"""
|
|
267
|
+
Update tenant details.
|
|
268
|
+
|
|
269
|
+
Requires admin role. Only display_name and plan can be updated.
|
|
270
|
+
"""
|
|
271
|
+
# Check if tenant exists
|
|
272
|
+
existing = await get_tenant_by_id(tenant_id)
|
|
273
|
+
if not existing:
|
|
274
|
+
raise HTTPException(
|
|
275
|
+
status_code=404,
|
|
276
|
+
detail=f'Tenant {tenant_id} not found',
|
|
277
|
+
)
|
|
278
|
+
|
|
279
|
+
# Build update kwargs (only include non-None values)
|
|
280
|
+
update_kwargs = {}
|
|
281
|
+
if request.display_name is not None:
|
|
282
|
+
update_kwargs['display_name'] = request.display_name
|
|
283
|
+
if request.plan is not None:
|
|
284
|
+
update_kwargs['plan'] = request.plan
|
|
285
|
+
|
|
286
|
+
if not update_kwargs:
|
|
287
|
+
# Nothing to update, return current tenant
|
|
288
|
+
return TenantResponse(**existing)
|
|
289
|
+
|
|
290
|
+
try:
|
|
291
|
+
updated_tenant = await update_tenant(tenant_id, **update_kwargs)
|
|
292
|
+
logger.info(f'Tenant {tenant_id} updated: {update_kwargs}')
|
|
293
|
+
return TenantResponse(**updated_tenant)
|
|
294
|
+
except ValueError as e:
|
|
295
|
+
raise HTTPException(status_code=404, detail=str(e))
|
|
296
|
+
except Exception as e:
|
|
297
|
+
logger.error(f'Failed to update tenant {tenant_id}: {e}')
|
|
298
|
+
raise HTTPException(
|
|
299
|
+
status_code=500,
|
|
300
|
+
detail=f'Failed to update tenant: {str(e)}',
|
|
301
|
+
)
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
@router.get('', response_model=TenantListResponse)
|
|
305
|
+
async def list_all_tenants(
|
|
306
|
+
limit: int = Query(
|
|
307
|
+
default=100, ge=1, le=1000, description='Maximum results'
|
|
308
|
+
),
|
|
309
|
+
offset: int = Query(default=0, ge=0, description='Results offset'),
|
|
310
|
+
user: UserSession = Depends(require_auth),
|
|
311
|
+
):
|
|
312
|
+
"""
|
|
313
|
+
List all tenants.
|
|
314
|
+
|
|
315
|
+
Requires super-admin role (user must have 'super-admin' in their roles).
|
|
316
|
+
"""
|
|
317
|
+
# Check for super-admin role
|
|
318
|
+
if 'super-admin' not in user.roles:
|
|
319
|
+
raise HTTPException(
|
|
320
|
+
status_code=403,
|
|
321
|
+
detail='Super-admin access required to list all tenants',
|
|
322
|
+
)
|
|
323
|
+
|
|
324
|
+
tenants = await list_tenants(limit=limit, offset=offset)
|
|
325
|
+
|
|
326
|
+
return TenantListResponse(
|
|
327
|
+
tenants=[TenantResponse(**t) for t in tenants],
|
|
328
|
+
total=len(
|
|
329
|
+
tenants
|
|
330
|
+
), # Note: This is approximate; full count would require additional query
|
|
331
|
+
limit=limit,
|
|
332
|
+
offset=offset,
|
|
333
|
+
)
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Tenant Context Middleware for A2A Server.
|
|
3
|
+
|
|
4
|
+
Extracts tenant context from JWT tokens and makes it available
|
|
5
|
+
in request.state for downstream handlers.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import logging
|
|
9
|
+
from typing import Optional
|
|
10
|
+
|
|
11
|
+
from starlette.middleware.base import BaseHTTPMiddleware
|
|
12
|
+
from starlette.requests import Request
|
|
13
|
+
|
|
14
|
+
try:
|
|
15
|
+
from jose import jwt
|
|
16
|
+
except ImportError:
|
|
17
|
+
jwt = None # type: ignore
|
|
18
|
+
|
|
19
|
+
from . import database
|
|
20
|
+
|
|
21
|
+
logger = logging.getLogger(__name__)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class TenantContextMiddleware(BaseHTTPMiddleware):
|
|
25
|
+
"""
|
|
26
|
+
Middleware that extracts tenant context from JWT tokens.
|
|
27
|
+
|
|
28
|
+
Extracts the realm from the 'iss' claim in the JWT and looks up
|
|
29
|
+
the corresponding tenant_id from the database. Stores tenant
|
|
30
|
+
information in request.state for use by downstream handlers.
|
|
31
|
+
|
|
32
|
+
Attributes stored in request.state:
|
|
33
|
+
- tenant_id: The database tenant ID (or None if not found)
|
|
34
|
+
- realm_name: The Keycloak realm name (or None if not found)
|
|
35
|
+
- tenant_plan: The tenant's subscription plan (or None if not found)
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
async def dispatch(self, request: Request, call_next):
|
|
39
|
+
"""Process the request and extract tenant context."""
|
|
40
|
+
# Initialize tenant context as None
|
|
41
|
+
request.state.tenant_id = None
|
|
42
|
+
request.state.realm_name = None
|
|
43
|
+
request.state.tenant_plan = None
|
|
44
|
+
|
|
45
|
+
# Try to extract tenant from Authorization header
|
|
46
|
+
try:
|
|
47
|
+
tenant_info = await self._extract_tenant_from_request(request)
|
|
48
|
+
if tenant_info:
|
|
49
|
+
request.state.tenant_id = tenant_info.get('id')
|
|
50
|
+
request.state.realm_name = tenant_info.get('realm_name')
|
|
51
|
+
request.state.tenant_plan = tenant_info.get('plan')
|
|
52
|
+
logger.debug(
|
|
53
|
+
f'Tenant context set: {request.state.realm_name} '
|
|
54
|
+
f'(id={request.state.tenant_id}, plan={request.state.tenant_plan})'
|
|
55
|
+
)
|
|
56
|
+
except Exception as e:
|
|
57
|
+
# Log but don't fail - allow unauthenticated requests
|
|
58
|
+
logger.debug(f'Could not extract tenant context: {e}')
|
|
59
|
+
|
|
60
|
+
# Continue processing the request
|
|
61
|
+
response = await call_next(request)
|
|
62
|
+
return response
|
|
63
|
+
|
|
64
|
+
async def _extract_tenant_from_request(
|
|
65
|
+
self, request: Request
|
|
66
|
+
) -> Optional[dict]:
|
|
67
|
+
"""
|
|
68
|
+
Extract tenant information from the request's JWT token.
|
|
69
|
+
|
|
70
|
+
Args:
|
|
71
|
+
request: The incoming request
|
|
72
|
+
|
|
73
|
+
Returns:
|
|
74
|
+
Tenant dict if found, None otherwise
|
|
75
|
+
"""
|
|
76
|
+
# Get Authorization header
|
|
77
|
+
auth_header = request.headers.get('Authorization')
|
|
78
|
+
if not auth_header:
|
|
79
|
+
return None
|
|
80
|
+
|
|
81
|
+
# Extract Bearer token
|
|
82
|
+
if not auth_header.startswith('Bearer '):
|
|
83
|
+
return None
|
|
84
|
+
|
|
85
|
+
token = auth_header[7:] # Remove 'Bearer ' prefix
|
|
86
|
+
if not token:
|
|
87
|
+
return None
|
|
88
|
+
|
|
89
|
+
# Extract realm from token
|
|
90
|
+
realm_name = self._extract_realm_from_token(token)
|
|
91
|
+
if not realm_name:
|
|
92
|
+
return None
|
|
93
|
+
|
|
94
|
+
# Look up tenant from database
|
|
95
|
+
tenant = await database.get_tenant_by_realm(realm_name)
|
|
96
|
+
return tenant
|
|
97
|
+
|
|
98
|
+
def _extract_realm_from_token(self, token: str) -> Optional[str]:
|
|
99
|
+
"""
|
|
100
|
+
Extract the realm name from a JWT token's 'iss' claim.
|
|
101
|
+
|
|
102
|
+
Uses unverified claims extraction for performance - full
|
|
103
|
+
validation should be done by the auth layer.
|
|
104
|
+
|
|
105
|
+
Args:
|
|
106
|
+
token: The JWT token string
|
|
107
|
+
|
|
108
|
+
Returns:
|
|
109
|
+
The realm name or None if extraction fails
|
|
110
|
+
"""
|
|
111
|
+
if jwt is None:
|
|
112
|
+
logger.warning(
|
|
113
|
+
'python-jose not installed, cannot extract realm from JWT'
|
|
114
|
+
)
|
|
115
|
+
return None
|
|
116
|
+
|
|
117
|
+
try:
|
|
118
|
+
# Get unverified claims for fast extraction
|
|
119
|
+
# Full validation is done by the auth layer
|
|
120
|
+
claims = jwt.get_unverified_claims(token)
|
|
121
|
+
|
|
122
|
+
# Extract issuer claim
|
|
123
|
+
issuer = claims.get('iss')
|
|
124
|
+
if not issuer:
|
|
125
|
+
return None
|
|
126
|
+
|
|
127
|
+
# Parse realm from issuer URL
|
|
128
|
+
# Format: https://keycloak.example.com/realms/realm-name
|
|
129
|
+
# or: https://keycloak.example.com/auth/realms/realm-name
|
|
130
|
+
realm_name = self._parse_realm_from_issuer(issuer)
|
|
131
|
+
return realm_name
|
|
132
|
+
|
|
133
|
+
except Exception as e:
|
|
134
|
+
logger.debug(f'Failed to extract realm from token: {e}')
|
|
135
|
+
return None
|
|
136
|
+
|
|
137
|
+
def _parse_realm_from_issuer(self, issuer: str) -> Optional[str]:
|
|
138
|
+
"""
|
|
139
|
+
Parse the realm name from a Keycloak issuer URL.
|
|
140
|
+
|
|
141
|
+
Args:
|
|
142
|
+
issuer: The issuer URL from the JWT
|
|
143
|
+
|
|
144
|
+
Returns:
|
|
145
|
+
The realm name or None if parsing fails
|
|
146
|
+
"""
|
|
147
|
+
if not issuer:
|
|
148
|
+
return None
|
|
149
|
+
|
|
150
|
+
# Handle both /realms/ and /auth/realms/ formats
|
|
151
|
+
if '/realms/' in issuer:
|
|
152
|
+
# Extract everything after /realms/
|
|
153
|
+
parts = issuer.split('/realms/')
|
|
154
|
+
if len(parts) >= 2:
|
|
155
|
+
# Get the realm part, removing any trailing path
|
|
156
|
+
realm_part = parts[1]
|
|
157
|
+
# Remove any trailing path segments
|
|
158
|
+
realm_name = realm_part.split('/')[0]
|
|
159
|
+
return realm_name if realm_name else None
|
|
160
|
+
|
|
161
|
+
return None
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def get_tenant_id(request: Request) -> Optional[str]:
|
|
165
|
+
"""
|
|
166
|
+
Helper function to get tenant_id from request state.
|
|
167
|
+
|
|
168
|
+
Args:
|
|
169
|
+
request: The request object
|
|
170
|
+
|
|
171
|
+
Returns:
|
|
172
|
+
The tenant_id or None if not set
|
|
173
|
+
"""
|
|
174
|
+
return getattr(request.state, 'tenant_id', None)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def get_realm_name(request: Request) -> Optional[str]:
|
|
178
|
+
"""
|
|
179
|
+
Helper function to get realm_name from request state.
|
|
180
|
+
|
|
181
|
+
Args:
|
|
182
|
+
request: The request object
|
|
183
|
+
|
|
184
|
+
Returns:
|
|
185
|
+
The realm_name or None if not set
|
|
186
|
+
"""
|
|
187
|
+
return getattr(request.state, 'realm_name', None)
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def get_tenant_plan(request: Request) -> Optional[str]:
|
|
191
|
+
"""
|
|
192
|
+
Helper function to get tenant_plan from request state.
|
|
193
|
+
|
|
194
|
+
Args:
|
|
195
|
+
request: The request object
|
|
196
|
+
|
|
197
|
+
Returns:
|
|
198
|
+
The tenant_plan or None if not set
|
|
199
|
+
"""
|
|
200
|
+
return getattr(request.state, 'tenant_plan', None)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def require_tenant(request: Request) -> str:
|
|
204
|
+
"""
|
|
205
|
+
Helper function that requires a tenant_id to be present.
|
|
206
|
+
|
|
207
|
+
Args:
|
|
208
|
+
request: The request object
|
|
209
|
+
|
|
210
|
+
Returns:
|
|
211
|
+
The tenant_id
|
|
212
|
+
|
|
213
|
+
Raises:
|
|
214
|
+
ValueError: If no tenant_id is present
|
|
215
|
+
"""
|
|
216
|
+
tenant_id = get_tenant_id(request)
|
|
217
|
+
if not tenant_id:
|
|
218
|
+
raise ValueError('Tenant context required but not found')
|
|
219
|
+
return tenant_id
|