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.
@@ -0,0 +1,401 @@
1
+ """Dynamic entity registry for cross-site deep linking.
2
+
3
+ Microsites register their entity definitions at startup. The registry is
4
+ empty by default - no hardcoded definitions.
5
+
6
+ Usage:
7
+ # Option 1: Register entities programmatically
8
+ from yirifi_ops_auth.deeplink import register_entities
9
+
10
+ register_entities(
11
+ microsite_id="risk",
12
+ name="Risk Dashboard",
13
+ urls={"dev": "http://localhost:5012", "uat": "...", "prd": "..."},
14
+ entities=[
15
+ {"type": "risk_item", "path": "/risk-management/collections/risk_items/{id}"},
16
+ ]
17
+ )
18
+
19
+ # Option 2: Load from YAML file
20
+ from yirifi_ops_auth.deeplink import load_from_yaml
21
+ load_from_yaml("deeplinks.yaml")
22
+ """
23
+
24
+ from dataclasses import dataclass, field
25
+ from typing import Dict, List, Optional, Any
26
+ import logging
27
+
28
+ logger = logging.getLogger(__name__)
29
+
30
+
31
+ class RegistrationError(Exception):
32
+ """Raised when entity/microsite registration fails validation."""
33
+ pass
34
+
35
+
36
+ @dataclass(frozen=True)
37
+ class EntityDefinition:
38
+ """Definition of an entity type for deep linking."""
39
+ entity_type: str
40
+ microsite: str
41
+ path_template: str
42
+ description: Optional[str] = None
43
+
44
+ def __post_init__(self) -> None:
45
+ if "{id}" not in self.path_template:
46
+ raise ValueError(
47
+ f"path_template must contain {{id}} placeholder: {self.path_template}"
48
+ )
49
+
50
+
51
+ @dataclass(frozen=True)
52
+ class MicrositeConfig:
53
+ """Configuration for a microsite."""
54
+ id: str
55
+ name: str
56
+ urls: Dict[str, str] = field(default_factory=dict) # {"dev": "...", "uat": "...", "prd": "..."}
57
+
58
+ def get_url(self, env: str) -> Optional[str]:
59
+ """Get the URL for a specific environment."""
60
+ return self.urls.get(env)
61
+
62
+ # Backwards compatibility properties
63
+ @property
64
+ def dev_url(self) -> str:
65
+ return self.urls.get("dev", "")
66
+
67
+ @property
68
+ def uat_url(self) -> str:
69
+ return self.urls.get("uat", "")
70
+
71
+ @property
72
+ def prd_url(self) -> str:
73
+ return self.urls.get("prd", "")
74
+
75
+
76
+ class DeepLinkRegistry:
77
+ """Dynamic registry for entity definitions and microsite configurations.
78
+
79
+ Thread-safe registry that microsites populate at startup.
80
+ """
81
+
82
+ def __init__(self) -> None:
83
+ self._entities: Dict[str, EntityDefinition] = {}
84
+ self._microsites: Dict[str, MicrositeConfig] = {}
85
+
86
+ def register_microsite(
87
+ self,
88
+ id: str,
89
+ name: str,
90
+ urls: Dict[str, str],
91
+ ) -> None:
92
+ """Register a microsite configuration.
93
+
94
+ Args:
95
+ id: Microsite identifier (e.g., 'risk', 'reg')
96
+ name: Human-readable name (e.g., 'Risk Dashboard')
97
+ urls: Dict mapping env to URL (e.g., {"dev": "http://localhost:5012", ...})
98
+
99
+ Raises:
100
+ RegistrationError: If validation fails
101
+ """
102
+ # Validate URLs
103
+ required_envs = {"dev", "uat", "prd"}
104
+ missing = required_envs - set(urls.keys())
105
+ if missing:
106
+ raise RegistrationError(
107
+ f"Microsite '{id}' missing URLs for environments: {missing}"
108
+ )
109
+
110
+ config = MicrositeConfig(id=id, name=name, urls=urls)
111
+ self._microsites[id] = config
112
+ logger.debug(f"Registered microsite: {id}")
113
+
114
+ def register_entity(
115
+ self,
116
+ entity_type: str,
117
+ microsite: str,
118
+ path_template: str,
119
+ description: Optional[str] = None,
120
+ auto_create_microsite: bool = False,
121
+ ) -> None:
122
+ """Register a single entity definition.
123
+
124
+ Args:
125
+ entity_type: Entity type identifier (e.g., 'risk_item')
126
+ microsite: Microsite ID that owns this entity
127
+ path_template: URL path with {id} placeholder
128
+ description: Optional description
129
+ auto_create_microsite: If True, skip microsite existence check
130
+
131
+ Raises:
132
+ RegistrationError: If validation fails
133
+ """
134
+ # Validate microsite exists (unless auto_create is True)
135
+ if not auto_create_microsite and microsite not in self._microsites:
136
+ raise RegistrationError(
137
+ f"Cannot register entity '{entity_type}': microsite '{microsite}' not registered. "
138
+ f"Register the microsite first with register_microsite()."
139
+ )
140
+
141
+ # Validate path template
142
+ if "{id}" not in path_template:
143
+ raise RegistrationError(
144
+ f"Entity '{entity_type}' path_template must contain {{id}}: {path_template}"
145
+ )
146
+
147
+ # Warn on duplicate (but allow override)
148
+ if entity_type in self._entities:
149
+ logger.warning(f"Overwriting existing entity definition: {entity_type}")
150
+
151
+ defn = EntityDefinition(
152
+ entity_type=entity_type,
153
+ microsite=microsite,
154
+ path_template=path_template,
155
+ description=description,
156
+ )
157
+ self._entities[entity_type] = defn
158
+ logger.debug(f"Registered entity: {entity_type} -> {microsite}")
159
+
160
+ def register_entities(
161
+ self,
162
+ microsite_id: str,
163
+ entities: List[Dict[str, Any]],
164
+ urls: Dict[str, str],
165
+ name: Optional[str] = None,
166
+ ) -> None:
167
+ """Bulk register a microsite and its entities.
168
+
169
+ Convenience method for registering everything at once.
170
+
171
+ Args:
172
+ microsite_id: Microsite identifier
173
+ entities: List of dicts with 'type', 'path', and optional 'description'
174
+ urls: URL mapping for the microsite
175
+ name: Human-readable name (defaults to title-cased microsite_id)
176
+
177
+ Example:
178
+ register_entities(
179
+ microsite_id="risk",
180
+ name="Risk Dashboard",
181
+ urls={"dev": "http://localhost:5012", "uat": "...", "prd": "..."},
182
+ entities=[
183
+ {"type": "risk_item", "path": "/items/{id}", "description": "Risk item"},
184
+ {"type": "risk_category", "path": "/categories/{id}"},
185
+ ]
186
+ )
187
+ """
188
+ # Default name from ID
189
+ if name is None:
190
+ name = microsite_id.replace("_", " ").title()
191
+
192
+ # Register microsite first
193
+ self.register_microsite(id=microsite_id, name=name, urls=urls)
194
+
195
+ # Register entities
196
+ for entity in entities:
197
+ self.register_entity(
198
+ entity_type=entity["type"],
199
+ microsite=microsite_id,
200
+ path_template=entity["path"],
201
+ description=entity.get("description"),
202
+ )
203
+
204
+ def get_entity(self, entity_type: str) -> Optional[EntityDefinition]:
205
+ """Get entity definition by type."""
206
+ return self._entities.get(entity_type)
207
+
208
+ def get_microsite(self, microsite_id: str) -> Optional[MicrositeConfig]:
209
+ """Get microsite configuration by ID."""
210
+ return self._microsites.get(microsite_id)
211
+
212
+ def list_entities(self) -> List[str]:
213
+ """List all registered entity types."""
214
+ return list(self._entities.keys())
215
+
216
+ def list_microsites(self) -> List[str]:
217
+ """List all registered microsite IDs."""
218
+ return list(self._microsites.keys())
219
+
220
+ def list_entities_for_microsite(self, microsite: str) -> List[str]:
221
+ """List entity types for a specific microsite."""
222
+ return [
223
+ et for et, defn in self._entities.items()
224
+ if defn.microsite == microsite
225
+ ]
226
+
227
+ def clear(self) -> None:
228
+ """Clear all registrations. Useful for testing."""
229
+ self._entities.clear()
230
+ self._microsites.clear()
231
+ logger.debug("Registry cleared")
232
+
233
+ def is_empty(self) -> bool:
234
+ """Check if registry has no registrations."""
235
+ return len(self._entities) == 0 and len(self._microsites) == 0
236
+
237
+
238
+ # Module-level singleton
239
+ _registry = DeepLinkRegistry()
240
+
241
+
242
+ # Public API functions that delegate to the singleton
243
+ def register_microsite(
244
+ id: str,
245
+ name: str,
246
+ urls: Dict[str, str],
247
+ ) -> None:
248
+ """Register a microsite configuration."""
249
+ _registry.register_microsite(id=id, name=name, urls=urls)
250
+
251
+
252
+ def register_entity(
253
+ entity_type: str,
254
+ microsite: str,
255
+ path_template: str,
256
+ description: Optional[str] = None,
257
+ ) -> None:
258
+ """Register a single entity definition."""
259
+ _registry.register_entity(
260
+ entity_type=entity_type,
261
+ microsite=microsite,
262
+ path_template=path_template,
263
+ description=description,
264
+ )
265
+
266
+
267
+ def register_entities(
268
+ microsite_id: str,
269
+ entities: List[Dict[str, Any]],
270
+ urls: Dict[str, str],
271
+ name: Optional[str] = None,
272
+ ) -> None:
273
+ """Bulk register a microsite and its entities."""
274
+ _registry.register_entities(
275
+ microsite_id=microsite_id,
276
+ entities=entities,
277
+ urls=urls,
278
+ name=name,
279
+ )
280
+
281
+
282
+ def get_entity_definition(entity_type: str) -> Optional[EntityDefinition]:
283
+ """Get entity definition by type.
284
+
285
+ Checks local registry first, then federated sources if available.
286
+ Local definitions take priority over federated ones.
287
+
288
+ Args:
289
+ entity_type: The entity type to look up
290
+
291
+ Returns:
292
+ EntityDefinition if found, None otherwise
293
+ """
294
+ # 1. Check local registry first (local takes priority)
295
+ defn = _registry.get_entity(entity_type)
296
+ if defn:
297
+ return defn
298
+
299
+ # 2. Fallback to federation cache if available
300
+ try:
301
+ from .federation import get_federation_client
302
+
303
+ federation = get_federation_client()
304
+ if federation:
305
+ remote = federation.get_entity(entity_type)
306
+ if remote:
307
+ # Convert federation response to EntityDefinition
308
+ return EntityDefinition(
309
+ entity_type=remote["entity_type"],
310
+ microsite=remote["microsite"],
311
+ path_template=remote["path_template"],
312
+ description=remote.get("description"),
313
+ )
314
+ except ImportError:
315
+ # Federation module not available
316
+ pass
317
+ except Exception as e:
318
+ logger.debug(f"Federation lookup failed for {entity_type}: {e}")
319
+
320
+ return None
321
+
322
+
323
+ def get_microsite_for_entity(entity_type: str) -> Optional[str]:
324
+ """Get the microsite ID for an entity type."""
325
+ defn = _registry.get_entity(entity_type)
326
+ return defn.microsite if defn else None
327
+
328
+
329
+ def list_entity_types() -> List[str]:
330
+ """List all registered entity types."""
331
+ return _registry.list_entities()
332
+
333
+
334
+ def list_entity_types_for_microsite(microsite: str) -> List[str]:
335
+ """List entity types for a specific microsite."""
336
+ return _registry.list_entities_for_microsite(microsite)
337
+
338
+
339
+ def list_microsites() -> List[str]:
340
+ """List all registered microsite IDs."""
341
+ return _registry.list_microsites()
342
+
343
+
344
+ def get_microsite_config(microsite_id: str) -> Optional[MicrositeConfig]:
345
+ """Get microsite configuration by ID."""
346
+ return _registry.get_microsite(microsite_id)
347
+
348
+
349
+ def get_microsite_name(microsite_id: str) -> Optional[str]:
350
+ """Get human-readable name for a microsite."""
351
+ config = _registry.get_microsite(microsite_id)
352
+ return config.name if config else None
353
+
354
+
355
+ def get_microsite_base_url(microsite: str, env: Optional[str] = None) -> Optional[str]:
356
+ """Get base URL for a microsite in a specific environment.
357
+
358
+ Checks local registry first, then federated sources if available.
359
+
360
+ Args:
361
+ microsite: Microsite ID
362
+ env: Environment ('dev', 'uat', 'prd'). If None, uses Environment.get()
363
+
364
+ Returns:
365
+ Base URL string or None if microsite not found
366
+ """
367
+ from .environment import Environment
368
+
369
+ env = env or Environment.get()
370
+
371
+ # 1. Check local registry first
372
+ config = _registry.get_microsite(microsite)
373
+ if config:
374
+ return config.get_url(env)
375
+
376
+ # 2. Fallback to federation cache if available
377
+ try:
378
+ from .federation import get_federation_client
379
+
380
+ federation = get_federation_client()
381
+ if federation:
382
+ urls = federation.get_microsite_urls(microsite)
383
+ if urls:
384
+ return urls.get(env)
385
+ except ImportError:
386
+ # Federation module not available
387
+ pass
388
+ except Exception as e:
389
+ logger.debug(f"Federation URL lookup failed for {microsite}: {e}")
390
+
391
+ return None
392
+
393
+
394
+ def clear_registry() -> None:
395
+ """Clear all registrations. Useful for testing."""
396
+ _registry.clear()
397
+
398
+
399
+ def get_registry() -> DeepLinkRegistry:
400
+ """Get the registry singleton. Useful for advanced use cases."""
401
+ return _registry
@@ -0,0 +1,208 @@
1
+ """Deep link resolution - generates URLs to entities across microsites.
2
+
3
+ Core functions:
4
+ - resolve_link(): Generate a URL for an entity
5
+ - deep_link_info(): Get URL + accessibility info for permission-aware rendering
6
+ """
7
+
8
+ from dataclasses import dataclass
9
+ from typing import Optional, Any
10
+ from urllib.parse import urlencode, quote
11
+
12
+ from .registry import (
13
+ get_entity_definition,
14
+ get_microsite_base_url,
15
+ get_microsite_name,
16
+ EntityDefinition,
17
+ )
18
+
19
+
20
+ @dataclass
21
+ class DeepLinkInfo:
22
+ """Information about a deep link including accessibility.
23
+
24
+ Attributes:
25
+ url: The resolved URL to the entity
26
+ accessible: Whether the current user can access the target microsite
27
+ microsite: The target microsite ID
28
+ microsite_name: Human-readable name of the target microsite
29
+ entity_type: The entity type that was resolved
30
+ entity_id: The entity ID used in resolution
31
+ """
32
+ url: str
33
+ accessible: bool
34
+ microsite: str
35
+ microsite_name: str
36
+ entity_type: str
37
+ entity_id: str
38
+
39
+
40
+ class DeepLinkError(Exception):
41
+ """Raised when deep link resolution fails."""
42
+ pass
43
+
44
+
45
+ class UnknownEntityTypeError(DeepLinkError):
46
+ """Raised when an unknown entity type is provided."""
47
+ pass
48
+
49
+
50
+ class UnknownMicrositeError(DeepLinkError):
51
+ """Raised when a microsite URL cannot be resolved."""
52
+ pass
53
+
54
+
55
+ def resolve_link(
56
+ entity_type: str,
57
+ entity_id: Any,
58
+ env: Optional[str] = None,
59
+ query_params: Optional[dict] = None,
60
+ ) -> str:
61
+ """Resolve a deep link URL for an entity.
62
+
63
+ Args:
64
+ entity_type: The type of entity (e.g., 'risk_item', 'user')
65
+ entity_id: The entity's identifier (will be converted to string)
66
+ env: Environment ('dev', 'uat', 'prd'). If None, uses current environment.
67
+ query_params: Optional query parameters to append to URL
68
+
69
+ Returns:
70
+ Full URL to the entity
71
+
72
+ Raises:
73
+ UnknownEntityTypeError: If entity_type is not in the registry
74
+ UnknownMicrositeError: If microsite URL cannot be resolved
75
+
76
+ Example:
77
+ >>> resolve_link('risk_item', 'r_yid_123')
78
+ 'http://localhost:5012/risk-management/collections/risk_items/r_yid_123'
79
+
80
+ >>> resolve_link('user', 42, query_params={'tab': 'permissions'})
81
+ 'http://localhost:5013/users/42?tab=permissions'
82
+ """
83
+ # Get entity definition
84
+ defn = get_entity_definition(entity_type)
85
+ if not defn:
86
+ raise UnknownEntityTypeError(f"Unknown entity type: {entity_type}")
87
+
88
+ # Get base URL for the microsite
89
+ base_url = get_microsite_base_url(defn.microsite, env)
90
+ if not base_url:
91
+ raise UnknownMicrositeError(f"No URL configured for microsite: {defn.microsite}")
92
+
93
+ # Build the path with entity ID
94
+ entity_id_str = str(entity_id)
95
+ path = defn.path_template.format(id=quote(entity_id_str, safe=''))
96
+
97
+ # Construct full URL
98
+ url = f"{base_url.rstrip('/')}{path}"
99
+
100
+ # Add query parameters if provided
101
+ if query_params:
102
+ url = f"{url}?{urlencode(query_params)}"
103
+
104
+ return url
105
+
106
+
107
+ def deep_link_info(
108
+ entity_type: str,
109
+ entity_id: Any,
110
+ user: Optional[Any] = None,
111
+ env: Optional[str] = None,
112
+ query_params: Optional[dict] = None,
113
+ ) -> DeepLinkInfo:
114
+ """Get deep link information including accessibility status.
115
+
116
+ This is the preferred function for permission-aware rendering.
117
+ It returns all information needed to render either an active link
118
+ or a disabled placeholder.
119
+
120
+ Args:
121
+ entity_type: The type of entity (e.g., 'risk_item', 'user')
122
+ entity_id: The entity's identifier
123
+ user: AuthUser object (from g.current_user) for access checking.
124
+ If None, accessible defaults to True.
125
+ env: Environment. If None, uses current environment.
126
+ query_params: Optional query parameters
127
+
128
+ Returns:
129
+ DeepLinkInfo with url, accessibility, and metadata
130
+
131
+ Raises:
132
+ UnknownEntityTypeError: If entity_type is not in the registry
133
+
134
+ Example:
135
+ >>> info = deep_link_info('risk_item', 'r_yid_123', g.current_user)
136
+ >>> if info.accessible:
137
+ ... print(f'<a href="{info.url}">Open in {info.microsite_name}</a>')
138
+ ... else:
139
+ ... print(f'<span title="No access">{info.microsite_name}</span>')
140
+ """
141
+ # Get entity definition
142
+ defn = get_entity_definition(entity_type)
143
+ if not defn:
144
+ raise UnknownEntityTypeError(f"Unknown entity type: {entity_type}")
145
+
146
+ # Resolve the URL
147
+ url = resolve_link(entity_type, entity_id, env, query_params)
148
+
149
+ # Check accessibility
150
+ accessible = True
151
+ if user is not None:
152
+ # Use the auth client's has_access_to method
153
+ if hasattr(user, 'has_access_to'):
154
+ accessible = user.has_access_to(defn.microsite)
155
+ elif hasattr(user, 'microsites'):
156
+ # Fallback: check microsites list
157
+ accessible = defn.microsite in (user.microsites or [])
158
+
159
+ return DeepLinkInfo(
160
+ url=url,
161
+ accessible=accessible,
162
+ microsite=defn.microsite,
163
+ microsite_name=get_microsite_name(defn.microsite) or defn.microsite.title(),
164
+ entity_type=entity_type,
165
+ entity_id=str(entity_id),
166
+ )
167
+
168
+
169
+ def can_link_to(entity_type: str, user: Optional[Any] = None) -> bool:
170
+ """Check if a user can access the microsite for an entity type.
171
+
172
+ Useful for conditionally showing cross-link UI elements.
173
+
174
+ Args:
175
+ entity_type: The type of entity
176
+ user: AuthUser object for access checking
177
+
178
+ Returns:
179
+ True if user can access the target microsite, False otherwise
180
+ """
181
+ defn = get_entity_definition(entity_type)
182
+ if not defn:
183
+ return False
184
+
185
+ if user is None:
186
+ return True
187
+
188
+ if hasattr(user, 'has_access_to'):
189
+ return user.has_access_to(defn.microsite)
190
+ elif hasattr(user, 'microsites'):
191
+ return defn.microsite in (user.microsites or [])
192
+
193
+ return True
194
+
195
+
196
+ def get_entity_microsite_name(entity_type: str) -> Optional[str]:
197
+ """Get the display name of the microsite for an entity type.
198
+
199
+ Args:
200
+ entity_type: The type of entity
201
+
202
+ Returns:
203
+ Human-readable microsite name, or None if entity type unknown
204
+ """
205
+ defn = get_entity_definition(entity_type)
206
+ if not defn:
207
+ return None
208
+ return get_microsite_name(defn.microsite) or defn.microsite.title()