yirifi-ops-auth-client 3.2.3__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.
- yirifi_ops_auth/__init__.py +58 -0
- yirifi_ops_auth/client.py +154 -0
- yirifi_ops_auth/decorators.py +213 -0
- yirifi_ops_auth/deeplink/__init__.py +210 -0
- yirifi_ops_auth/deeplink/blueprint.py +155 -0
- yirifi_ops_auth/deeplink/environment.py +156 -0
- yirifi_ops_auth/deeplink/federation.py +409 -0
- yirifi_ops_auth/deeplink/jinja.py +316 -0
- yirifi_ops_auth/deeplink/registry.py +401 -0
- yirifi_ops_auth/deeplink/resolver.py +208 -0
- yirifi_ops_auth/deeplink/yaml_loader.py +242 -0
- yirifi_ops_auth/exceptions.py +32 -0
- yirifi_ops_auth/local_user.py +124 -0
- yirifi_ops_auth/middleware.py +281 -0
- yirifi_ops_auth/models.py +80 -0
- yirifi_ops_auth_client-3.2.3.dist-info/METADATA +15 -0
- yirifi_ops_auth_client-3.2.3.dist-info/RECORD +19 -0
- yirifi_ops_auth_client-3.2.3.dist-info/WHEEL +5 -0
- yirifi_ops_auth_client-3.2.3.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,409 @@
|
|
|
1
|
+
"""Federation client for discovering entity definitions from remote microsites.
|
|
2
|
+
|
|
3
|
+
Enables cross-microsite deep linking by:
|
|
4
|
+
1. Querying auth service for list of microsites and their URLs
|
|
5
|
+
2. Querying each microsite's /api/v1/references for entity definitions
|
|
6
|
+
3. Caching results for fast lookup
|
|
7
|
+
|
|
8
|
+
Usage:
|
|
9
|
+
from yirifi_ops_auth.deeplink import configure_federation, FederationConfig
|
|
10
|
+
|
|
11
|
+
# Configure at app startup
|
|
12
|
+
configure_federation(FederationConfig(
|
|
13
|
+
auth_service_url="https://auth.ops.yirifi.ai",
|
|
14
|
+
))
|
|
15
|
+
|
|
16
|
+
# Entity lookup automatically checks federation cache
|
|
17
|
+
resolve_link('risk_item', 'r_yid_123') # Works even if not locally registered
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from dataclasses import dataclass, field
|
|
21
|
+
from typing import Dict, List, Optional, Any
|
|
22
|
+
from datetime import datetime, timedelta
|
|
23
|
+
import logging
|
|
24
|
+
import threading
|
|
25
|
+
import os
|
|
26
|
+
|
|
27
|
+
import httpx
|
|
28
|
+
|
|
29
|
+
from .environment import Environment
|
|
30
|
+
|
|
31
|
+
logger = logging.getLogger(__name__)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class FederationError(Exception):
|
|
35
|
+
"""Base exception for federation errors."""
|
|
36
|
+
pass
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class FederationTimeoutError(FederationError):
|
|
40
|
+
"""Remote microsite did not respond in time."""
|
|
41
|
+
pass
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class FederationConnectionError(FederationError):
|
|
45
|
+
"""Could not connect to remote microsite."""
|
|
46
|
+
pass
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass
|
|
50
|
+
class FederationConfig:
|
|
51
|
+
"""Configuration for federation behavior."""
|
|
52
|
+
|
|
53
|
+
# Auth service URL (required for discovery)
|
|
54
|
+
auth_service_url: str = ""
|
|
55
|
+
|
|
56
|
+
# Timing
|
|
57
|
+
refresh_interval_seconds: int = 300 # 5 minutes
|
|
58
|
+
request_timeout_seconds: float = 5.0
|
|
59
|
+
startup_fetch: bool = True
|
|
60
|
+
|
|
61
|
+
# Caching
|
|
62
|
+
cache_ttl_seconds: int = 300
|
|
63
|
+
|
|
64
|
+
# Error handling
|
|
65
|
+
max_retries: int = 2
|
|
66
|
+
retry_delay_seconds: float = 1.0
|
|
67
|
+
fail_open: bool = True # Continue if remote unavailable
|
|
68
|
+
|
|
69
|
+
# Skip self (don't query your own microsite)
|
|
70
|
+
self_microsite_id: Optional[str] = None
|
|
71
|
+
|
|
72
|
+
def __post_init__(self) -> None:
|
|
73
|
+
if not self.auth_service_url:
|
|
74
|
+
# Try to get from environment
|
|
75
|
+
self.auth_service_url = os.getenv("AUTH_SERVICE_URL", "")
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@dataclass
|
|
79
|
+
class CachedMicrosite:
|
|
80
|
+
"""Cached microsite information from auth service."""
|
|
81
|
+
id: str
|
|
82
|
+
name: str
|
|
83
|
+
url: Optional[str] # Production URL
|
|
84
|
+
is_active: bool
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@dataclass
|
|
88
|
+
class CachedDefinitions:
|
|
89
|
+
"""Cached entity definitions from a microsite."""
|
|
90
|
+
microsite_id: str
|
|
91
|
+
microsite_name: str
|
|
92
|
+
urls: Dict[str, str] # {"dev": "...", "uat": "...", "prd": "..."}
|
|
93
|
+
entities: Dict[str, Dict[str, Any]] # entity_type -> {path, description}
|
|
94
|
+
fetched_at: datetime
|
|
95
|
+
etag: Optional[str] = None
|
|
96
|
+
|
|
97
|
+
def is_stale(self, ttl_seconds: int) -> bool:
|
|
98
|
+
return datetime.utcnow() - self.fetched_at > timedelta(seconds=ttl_seconds)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class FederationClient:
|
|
102
|
+
"""Client for fetching entity definitions from remote microsites.
|
|
103
|
+
|
|
104
|
+
Thread-safe client that:
|
|
105
|
+
1. Discovers microsites from auth service
|
|
106
|
+
2. Fetches entity definitions from each microsite's /api/v1/references
|
|
107
|
+
3. Caches results with configurable TTL
|
|
108
|
+
4. Supports background refresh
|
|
109
|
+
"""
|
|
110
|
+
|
|
111
|
+
def __init__(self, config: FederationConfig):
|
|
112
|
+
self.config = config
|
|
113
|
+
self._microsites: Dict[str, CachedMicrosite] = {}
|
|
114
|
+
self._cache: Dict[str, CachedDefinitions] = {}
|
|
115
|
+
self._lock = threading.RLock()
|
|
116
|
+
self._http_client: Optional[httpx.Client] = None
|
|
117
|
+
self._refresh_thread: Optional[threading.Thread] = None
|
|
118
|
+
self._running = False
|
|
119
|
+
self._started = False
|
|
120
|
+
|
|
121
|
+
def start(self) -> None:
|
|
122
|
+
"""Start the federation client with initial fetch and background refresh."""
|
|
123
|
+
if self._started:
|
|
124
|
+
return
|
|
125
|
+
|
|
126
|
+
self._http_client = httpx.Client(
|
|
127
|
+
timeout=self.config.request_timeout_seconds,
|
|
128
|
+
follow_redirects=True,
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
self._started = True
|
|
132
|
+
self._running = True
|
|
133
|
+
|
|
134
|
+
if self.config.startup_fetch:
|
|
135
|
+
self._fetch_all()
|
|
136
|
+
|
|
137
|
+
if self.config.refresh_interval_seconds > 0:
|
|
138
|
+
self._start_background_refresh()
|
|
139
|
+
|
|
140
|
+
def stop(self) -> None:
|
|
141
|
+
"""Stop the federation client and cleanup resources."""
|
|
142
|
+
self._running = False
|
|
143
|
+
self._started = False
|
|
144
|
+
|
|
145
|
+
if self._http_client:
|
|
146
|
+
self._http_client.close()
|
|
147
|
+
self._http_client = None
|
|
148
|
+
|
|
149
|
+
def get_entity(self, entity_type: str) -> Optional[Dict[str, Any]]:
|
|
150
|
+
"""Get entity definition from federated sources.
|
|
151
|
+
|
|
152
|
+
Returns dict with: entity_type, microsite, microsite_name, urls, path_template, description
|
|
153
|
+
"""
|
|
154
|
+
with self._lock:
|
|
155
|
+
for microsite_id, cached in self._cache.items():
|
|
156
|
+
if entity_type in cached.entities:
|
|
157
|
+
entity = cached.entities[entity_type]
|
|
158
|
+
return {
|
|
159
|
+
"entity_type": entity_type,
|
|
160
|
+
"microsite": microsite_id,
|
|
161
|
+
"microsite_name": cached.microsite_name,
|
|
162
|
+
"urls": cached.urls,
|
|
163
|
+
"path_template": entity["path"],
|
|
164
|
+
"description": entity.get("description"),
|
|
165
|
+
}
|
|
166
|
+
return None
|
|
167
|
+
|
|
168
|
+
def get_microsite_urls(self, microsite_id: str) -> Optional[Dict[str, str]]:
|
|
169
|
+
"""Get URLs for a microsite from federation cache."""
|
|
170
|
+
with self._lock:
|
|
171
|
+
cached = self._cache.get(microsite_id)
|
|
172
|
+
if cached:
|
|
173
|
+
return cached.urls
|
|
174
|
+
return None
|
|
175
|
+
|
|
176
|
+
def list_remote_entities(self) -> List[str]:
|
|
177
|
+
"""List all entity types available from federated sources."""
|
|
178
|
+
with self._lock:
|
|
179
|
+
entities = []
|
|
180
|
+
for cached in self._cache.values():
|
|
181
|
+
entities.extend(cached.entities.keys())
|
|
182
|
+
return entities
|
|
183
|
+
|
|
184
|
+
def list_remote_microsites(self) -> List[str]:
|
|
185
|
+
"""List all microsite IDs in federation cache."""
|
|
186
|
+
with self._lock:
|
|
187
|
+
return list(self._cache.keys())
|
|
188
|
+
|
|
189
|
+
def _fetch_all(self) -> None:
|
|
190
|
+
"""Fetch from auth service, then all microsites."""
|
|
191
|
+
# 1. Get microsite list from auth service
|
|
192
|
+
if not self._fetch_microsites():
|
|
193
|
+
logger.warning("Federation: failed to fetch microsites from auth service")
|
|
194
|
+
return
|
|
195
|
+
|
|
196
|
+
# 2. Fetch references from each active microsite
|
|
197
|
+
current_env = Environment.get()
|
|
198
|
+
|
|
199
|
+
with self._lock:
|
|
200
|
+
microsites_to_query = [
|
|
201
|
+
m for m in self._microsites.values()
|
|
202
|
+
if m.is_active and m.url and m.id != self.config.self_microsite_id
|
|
203
|
+
]
|
|
204
|
+
|
|
205
|
+
for microsite in microsites_to_query:
|
|
206
|
+
try:
|
|
207
|
+
self._fetch_references(microsite.id, microsite.url)
|
|
208
|
+
except Exception as e:
|
|
209
|
+
logger.warning(f"Federation: failed to fetch from {microsite.id}: {e}")
|
|
210
|
+
|
|
211
|
+
def _fetch_microsites(self) -> bool:
|
|
212
|
+
"""Fetch microsite list from auth service."""
|
|
213
|
+
if not self.config.auth_service_url:
|
|
214
|
+
logger.warning("Federation: auth_service_url not configured")
|
|
215
|
+
return False
|
|
216
|
+
|
|
217
|
+
url = f"{self.config.auth_service_url.rstrip('/')}/api/v1/microsites/"
|
|
218
|
+
|
|
219
|
+
try:
|
|
220
|
+
response = self._http_client.get(url)
|
|
221
|
+
|
|
222
|
+
if response.status_code != 200:
|
|
223
|
+
logger.warning(
|
|
224
|
+
f"Federation: auth service returned {response.status_code}"
|
|
225
|
+
)
|
|
226
|
+
return False
|
|
227
|
+
|
|
228
|
+
data = response.json()
|
|
229
|
+
|
|
230
|
+
if not data.get("success"):
|
|
231
|
+
logger.warning("Federation: auth service returned success=false")
|
|
232
|
+
return False
|
|
233
|
+
|
|
234
|
+
microsites_data = data.get("data", {}).get("microsites", [])
|
|
235
|
+
|
|
236
|
+
with self._lock:
|
|
237
|
+
self._microsites.clear()
|
|
238
|
+
for ms in microsites_data:
|
|
239
|
+
self._microsites[ms["id"]] = CachedMicrosite(
|
|
240
|
+
id=ms["id"],
|
|
241
|
+
name=ms["name"],
|
|
242
|
+
url=ms.get("url"),
|
|
243
|
+
is_active=ms.get("is_active", True),
|
|
244
|
+
)
|
|
245
|
+
|
|
246
|
+
logger.info(
|
|
247
|
+
f"Federation: discovered {len(self._microsites)} microsites from auth service"
|
|
248
|
+
)
|
|
249
|
+
return True
|
|
250
|
+
|
|
251
|
+
except httpx.TimeoutException as e:
|
|
252
|
+
logger.warning(f"Federation: auth service timeout: {e}")
|
|
253
|
+
if not self.config.fail_open:
|
|
254
|
+
raise FederationTimeoutError(str(e))
|
|
255
|
+
return False
|
|
256
|
+
except httpx.RequestError as e:
|
|
257
|
+
logger.warning(f"Federation: auth service connection error: {e}")
|
|
258
|
+
if not self.config.fail_open:
|
|
259
|
+
raise FederationConnectionError(str(e))
|
|
260
|
+
return False
|
|
261
|
+
except Exception as e:
|
|
262
|
+
logger.error(f"Federation: error fetching microsites: {e}")
|
|
263
|
+
return False
|
|
264
|
+
|
|
265
|
+
def _fetch_references(
|
|
266
|
+
self, microsite_id: str, base_url: str
|
|
267
|
+
) -> Optional[CachedDefinitions]:
|
|
268
|
+
"""Fetch entity definitions from a single microsite."""
|
|
269
|
+
url = f"{base_url.rstrip('/')}/api/v1/references"
|
|
270
|
+
headers = {}
|
|
271
|
+
|
|
272
|
+
# Conditional request if we have cached data
|
|
273
|
+
with self._lock:
|
|
274
|
+
cached = self._cache.get(microsite_id)
|
|
275
|
+
if cached and cached.etag:
|
|
276
|
+
headers["If-None-Match"] = cached.etag
|
|
277
|
+
|
|
278
|
+
try:
|
|
279
|
+
response = self._http_client.get(url, headers=headers)
|
|
280
|
+
|
|
281
|
+
if response.status_code == 304:
|
|
282
|
+
# Not modified, update timestamp
|
|
283
|
+
logger.debug(f"Federation: {microsite_id} not modified")
|
|
284
|
+
with self._lock:
|
|
285
|
+
if cached:
|
|
286
|
+
cached.fetched_at = datetime.utcnow()
|
|
287
|
+
return cached
|
|
288
|
+
|
|
289
|
+
if response.status_code == 404:
|
|
290
|
+
# Microsite doesn't expose references endpoint yet
|
|
291
|
+
logger.debug(f"Federation: {microsite_id} has no /api/v1/references")
|
|
292
|
+
return None
|
|
293
|
+
|
|
294
|
+
if response.status_code != 200:
|
|
295
|
+
logger.warning(
|
|
296
|
+
f"Federation: {microsite_id} returned {response.status_code}"
|
|
297
|
+
)
|
|
298
|
+
return None
|
|
299
|
+
|
|
300
|
+
data = response.json()
|
|
301
|
+
|
|
302
|
+
# Validate response structure
|
|
303
|
+
if "microsite" not in data or "entities" not in data:
|
|
304
|
+
logger.warning(f"Federation: {microsite_id} invalid response structure")
|
|
305
|
+
return None
|
|
306
|
+
|
|
307
|
+
# Parse response
|
|
308
|
+
microsite_data = data["microsite"]
|
|
309
|
+
definitions = CachedDefinitions(
|
|
310
|
+
microsite_id=microsite_data.get("id", microsite_id),
|
|
311
|
+
microsite_name=microsite_data.get("name", microsite_id.title()),
|
|
312
|
+
urls=microsite_data.get("urls", {}),
|
|
313
|
+
entities={e["type"]: e for e in data["entities"]},
|
|
314
|
+
fetched_at=datetime.utcnow(),
|
|
315
|
+
etag=response.headers.get("ETag"),
|
|
316
|
+
)
|
|
317
|
+
|
|
318
|
+
with self._lock:
|
|
319
|
+
self._cache[microsite_id] = definitions
|
|
320
|
+
|
|
321
|
+
logger.info(
|
|
322
|
+
f"Federation: fetched {len(data['entities'])} entities from {microsite_id}"
|
|
323
|
+
)
|
|
324
|
+
return definitions
|
|
325
|
+
|
|
326
|
+
except httpx.TimeoutException as e:
|
|
327
|
+
logger.warning(f"Federation: {microsite_id} timeout: {e}")
|
|
328
|
+
if not self.config.fail_open:
|
|
329
|
+
raise FederationTimeoutError(str(e))
|
|
330
|
+
return None
|
|
331
|
+
except httpx.RequestError as e:
|
|
332
|
+
logger.warning(f"Federation: failed to reach {microsite_id}: {e}")
|
|
333
|
+
if not self.config.fail_open:
|
|
334
|
+
raise FederationConnectionError(str(e))
|
|
335
|
+
return None
|
|
336
|
+
except Exception as e:
|
|
337
|
+
logger.error(f"Federation: error processing {microsite_id}: {e}")
|
|
338
|
+
return None
|
|
339
|
+
|
|
340
|
+
def _start_background_refresh(self) -> None:
|
|
341
|
+
"""Start background thread for periodic refresh."""
|
|
342
|
+
self._refresh_thread = threading.Thread(
|
|
343
|
+
target=self._refresh_loop,
|
|
344
|
+
daemon=True,
|
|
345
|
+
name="deeplink-federation-refresh",
|
|
346
|
+
)
|
|
347
|
+
self._refresh_thread.start()
|
|
348
|
+
|
|
349
|
+
def _refresh_loop(self) -> None:
|
|
350
|
+
"""Background refresh loop."""
|
|
351
|
+
import time
|
|
352
|
+
|
|
353
|
+
while self._running:
|
|
354
|
+
time.sleep(self.config.refresh_interval_seconds)
|
|
355
|
+
if self._running:
|
|
356
|
+
try:
|
|
357
|
+
self._fetch_all()
|
|
358
|
+
except Exception as e:
|
|
359
|
+
logger.error(f"Federation: background refresh error: {e}")
|
|
360
|
+
|
|
361
|
+
def clear_cache(self) -> None:
|
|
362
|
+
"""Clear all cached data. Useful for testing."""
|
|
363
|
+
with self._lock:
|
|
364
|
+
self._microsites.clear()
|
|
365
|
+
self._cache.clear()
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
# Module-level singleton
|
|
369
|
+
_federation_client: Optional[FederationClient] = None
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
def get_federation_client() -> Optional[FederationClient]:
|
|
373
|
+
"""Get the federation client singleton."""
|
|
374
|
+
return _federation_client
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
def configure_federation(config: FederationConfig) -> FederationClient:
|
|
378
|
+
"""Configure and start the federation client.
|
|
379
|
+
|
|
380
|
+
Args:
|
|
381
|
+
config: FederationConfig with auth_service_url and other settings
|
|
382
|
+
|
|
383
|
+
Returns:
|
|
384
|
+
The configured FederationClient instance
|
|
385
|
+
"""
|
|
386
|
+
global _federation_client
|
|
387
|
+
|
|
388
|
+
if _federation_client:
|
|
389
|
+
_federation_client.stop()
|
|
390
|
+
|
|
391
|
+
_federation_client = FederationClient(config)
|
|
392
|
+
_federation_client.start()
|
|
393
|
+
|
|
394
|
+
return _federation_client
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
def stop_federation() -> None:
|
|
398
|
+
"""Stop the federation client."""
|
|
399
|
+
global _federation_client
|
|
400
|
+
|
|
401
|
+
if _federation_client:
|
|
402
|
+
_federation_client.stop()
|
|
403
|
+
_federation_client = None
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
def clear_federation_cache() -> None:
|
|
407
|
+
"""Clear federation cache. Useful for testing."""
|
|
408
|
+
if _federation_client:
|
|
409
|
+
_federation_client.clear_cache()
|