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,242 @@
1
+ """YAML configuration loader for deep linking.
2
+
3
+ Allows microsites to define their entity configurations in YAML files
4
+ instead of programmatic registration.
5
+
6
+ Example YAML file (deeplinks.yaml):
7
+
8
+ schema_version: "1.0"
9
+ microsite:
10
+ id: risk
11
+ name: Risk Dashboard
12
+ urls:
13
+ dev: http://localhost:5012
14
+ uat: https://risk-uat.ops.yirifi.com
15
+ prd: https://risk.ops.yirifi.com
16
+
17
+ entities:
18
+ risk_item:
19
+ path: /risk-management/collections/risk_items/{id}
20
+ description: Risk management item
21
+ risk_hierarchy:
22
+ path: /risk-management/collections/risk_hierarchies/{id}
23
+
24
+ Usage:
25
+ from yirifi_ops_auth.deeplink import load_from_yaml
26
+
27
+ # Load from file path
28
+ load_from_yaml("deeplinks.yaml")
29
+
30
+ # Or with Path object
31
+ from pathlib import Path
32
+ load_from_yaml(Path("config/deeplinks.yaml"))
33
+ """
34
+
35
+ import logging
36
+ from pathlib import Path
37
+ from typing import Union, Optional
38
+
39
+ logger = logging.getLogger(__name__)
40
+
41
+ # Supported schema versions
42
+ SUPPORTED_SCHEMA_VERSIONS = {"1.0"}
43
+
44
+
45
+ class YamlLoadError(Exception):
46
+ """Raised when YAML loading or validation fails."""
47
+ pass
48
+
49
+
50
+ def load_from_yaml(path: Union[str, Path]) -> None:
51
+ """Load entity definitions from a YAML file.
52
+
53
+ Args:
54
+ path: Path to the YAML configuration file
55
+
56
+ Raises:
57
+ YamlLoadError: If file not found, YAML invalid, or schema validation fails
58
+ ImportError: If PyYAML is not installed
59
+ """
60
+ try:
61
+ import yaml
62
+ except ImportError:
63
+ raise ImportError(
64
+ "PyYAML is required for YAML loading. Install with: pip install pyyaml"
65
+ )
66
+
67
+ path = Path(path)
68
+
69
+ if not path.exists():
70
+ raise YamlLoadError(f"Configuration file not found: {path}")
71
+
72
+ try:
73
+ with open(path) as f:
74
+ data = yaml.safe_load(f)
75
+ except yaml.YAMLError as e:
76
+ raise YamlLoadError(f"Invalid YAML in {path}: {e}")
77
+
78
+ if not data:
79
+ raise YamlLoadError(f"Empty configuration file: {path}")
80
+
81
+ _validate_and_register(data, source=str(path))
82
+ logger.info(f"Loaded deep link configuration from {path}")
83
+
84
+
85
+ def load_from_string(yaml_content: str, source: str = "<string>") -> None:
86
+ """Load entity definitions from a YAML string.
87
+
88
+ Useful for testing or when YAML is embedded/generated.
89
+
90
+ Args:
91
+ yaml_content: YAML content as a string
92
+ source: Description of source for error messages
93
+ """
94
+ try:
95
+ import yaml
96
+ except ImportError:
97
+ raise ImportError(
98
+ "PyYAML is required for YAML loading. Install with: pip install pyyaml"
99
+ )
100
+
101
+ try:
102
+ data = yaml.safe_load(yaml_content)
103
+ except yaml.YAMLError as e:
104
+ raise YamlLoadError(f"Invalid YAML from {source}: {e}")
105
+
106
+ if not data:
107
+ raise YamlLoadError(f"Empty configuration from {source}")
108
+
109
+ _validate_and_register(data, source=source)
110
+
111
+
112
+ def _validate_and_register(data: dict, source: str) -> None:
113
+ """Validate YAML data and register with the registry.
114
+
115
+ Args:
116
+ data: Parsed YAML data
117
+ source: Source description for error messages
118
+ """
119
+ from .registry import register_entities
120
+
121
+ # Check schema version
122
+ schema_version = data.get("schema_version", "1.0")
123
+ if schema_version not in SUPPORTED_SCHEMA_VERSIONS:
124
+ raise YamlLoadError(
125
+ f"Unsupported schema_version '{schema_version}' in {source}. "
126
+ f"Supported: {SUPPORTED_SCHEMA_VERSIONS}"
127
+ )
128
+
129
+ # Validate microsite section
130
+ microsite = data.get("microsite")
131
+ if not microsite:
132
+ raise YamlLoadError(f"Missing 'microsite' section in {source}")
133
+
134
+ microsite_id = microsite.get("id")
135
+ if not microsite_id:
136
+ raise YamlLoadError(f"Missing 'microsite.id' in {source}")
137
+
138
+ microsite_name = microsite.get("name")
139
+ if not microsite_name:
140
+ raise YamlLoadError(f"Missing 'microsite.name' in {source}")
141
+
142
+ urls = microsite.get("urls")
143
+ if not urls:
144
+ raise YamlLoadError(f"Missing 'microsite.urls' in {source}")
145
+
146
+ # Validate required URLs
147
+ required_envs = {"dev", "uat", "prd"}
148
+ missing_envs = required_envs - set(urls.keys())
149
+ if missing_envs:
150
+ raise YamlLoadError(
151
+ f"Missing URLs for environments {missing_envs} in {source}"
152
+ )
153
+
154
+ # Validate entities section
155
+ entities_data = data.get("entities", {})
156
+ if not entities_data:
157
+ logger.warning(f"No entities defined in {source}")
158
+
159
+ # Convert to list format expected by register_entities
160
+ entities = []
161
+ for entity_type, entity_config in entities_data.items():
162
+ if not isinstance(entity_config, dict):
163
+ raise YamlLoadError(
164
+ f"Entity '{entity_type}' must be a dict in {source}"
165
+ )
166
+
167
+ path = entity_config.get("path")
168
+ if not path:
169
+ raise YamlLoadError(
170
+ f"Entity '{entity_type}' missing 'path' in {source}"
171
+ )
172
+
173
+ if "{id}" not in path:
174
+ raise YamlLoadError(
175
+ f"Entity '{entity_type}' path must contain {{id}}: {path}"
176
+ )
177
+
178
+ entities.append({
179
+ "type": entity_type,
180
+ "path": path,
181
+ "description": entity_config.get("description"),
182
+ })
183
+
184
+ # Register everything
185
+ register_entities(
186
+ microsite_id=microsite_id,
187
+ name=microsite_name,
188
+ urls=urls,
189
+ entities=entities,
190
+ )
191
+
192
+ logger.debug(
193
+ f"Registered {len(entities)} entities for '{microsite_id}' from {source}"
194
+ )
195
+
196
+
197
+ def discover_and_load(
198
+ search_paths: Optional[list[Union[str, Path]]] = None,
199
+ filenames: Optional[list[str]] = None,
200
+ ) -> int:
201
+ """Discover and load YAML files from multiple locations.
202
+
203
+ Searches for configuration files in the given paths and loads them.
204
+
205
+ Args:
206
+ search_paths: Directories to search (default: current directory)
207
+ filenames: Filenames to look for (default: deeplinks.yaml, deeplinks.yml)
208
+
209
+ Returns:
210
+ Number of files loaded
211
+
212
+ Example:
213
+ # Load from current directory
214
+ discover_and_load()
215
+
216
+ # Load from specific directories
217
+ discover_and_load(search_paths=["config/", "deeplink_configs/"])
218
+ """
219
+ if search_paths is None:
220
+ search_paths = [Path.cwd()]
221
+
222
+ if filenames is None:
223
+ filenames = ["deeplinks.yaml", "deeplinks.yml"]
224
+
225
+ loaded_count = 0
226
+
227
+ for search_path in search_paths:
228
+ search_path = Path(search_path)
229
+ if not search_path.exists():
230
+ logger.debug(f"Search path does not exist: {search_path}")
231
+ continue
232
+
233
+ for filename in filenames:
234
+ filepath = search_path / filename
235
+ if filepath.exists():
236
+ try:
237
+ load_from_yaml(filepath)
238
+ loaded_count += 1
239
+ except YamlLoadError as e:
240
+ logger.error(f"Failed to load {filepath}: {e}")
241
+
242
+ return loaded_count
@@ -0,0 +1,32 @@
1
+ """Custom exceptions for auth client."""
2
+
3
+
4
+ class AuthClientError(Exception):
5
+ """Base exception for auth client errors."""
6
+
7
+ pass
8
+
9
+
10
+ class AuthenticationError(AuthClientError):
11
+ """Authentication failed - user not logged in or invalid credentials."""
12
+
13
+ def __init__(self, message: str = 'Authentication required', redirect_url: str = None):
14
+ super().__init__(message)
15
+ self.message = message
16
+ self.redirect_url = redirect_url
17
+
18
+
19
+ class AuthorizationError(AuthClientError):
20
+ """Authorization failed - user doesn't have required permissions."""
21
+
22
+ def __init__(self, message: str = 'Access denied'):
23
+ super().__init__(message)
24
+ self.message = message
25
+
26
+
27
+ class AuthServiceError(AuthClientError):
28
+ """Error communicating with auth service."""
29
+
30
+ def __init__(self, message: str = 'Auth service unavailable'):
31
+ super().__init__(message)
32
+ self.message = message
@@ -0,0 +1,124 @@
1
+ """
2
+ Local User Helpers
3
+
4
+ Provides utilities for microsites to manage local user tables that sync
5
+ from the central auth service.
6
+
7
+ Usage:
8
+ from yirifi_ops_auth.local_user import ensure_local_user, get_current_user_id
9
+
10
+ @app.before_request
11
+ def before_request():
12
+ ensure_local_user(db.session)
13
+
14
+ @app.route('/api/chat', methods=['POST'])
15
+ @require_auth
16
+ def create_chat():
17
+ chat = ChatHistory(user_id=get_current_user_id(), ...)
18
+ """
19
+
20
+ from flask import g
21
+ from sqlalchemy import text
22
+ from datetime import datetime, timezone
23
+
24
+ from yirifi_ops_auth.exceptions import AuthenticationError
25
+
26
+
27
+ def get_current_user_id() -> str:
28
+ """
29
+ Get the current user's immutable ID for storage in domain tables.
30
+
31
+ Returns:
32
+ str: UUID string of the current user
33
+
34
+ Raises:
35
+ AuthenticationError: If no user is authenticated
36
+ """
37
+ if not hasattr(g, 'current_user') or not g.current_user:
38
+ raise AuthenticationError("No authenticated user")
39
+ return g.current_user.user_id
40
+
41
+
42
+ def get_current_user_context() -> dict:
43
+ """
44
+ Get the current user's display info for API responses.
45
+
46
+ Returns:
47
+ dict: User info including user_id, display_name, email
48
+
49
+ Raises:
50
+ AuthenticationError: If no user is authenticated
51
+ """
52
+ if not hasattr(g, 'current_user') or not g.current_user:
53
+ raise AuthenticationError("No authenticated user")
54
+
55
+ user = g.current_user
56
+ return {
57
+ 'user_id': user.user_id,
58
+ 'display_name': user.display_name,
59
+ 'email': user.email
60
+ }
61
+
62
+
63
+ def ensure_local_user(db_session):
64
+ """
65
+ Ensure current user exists in local users table.
66
+
67
+ This should be called in a before_request hook after auth middleware
68
+ sets g.current_user. It handles the case where a new user logs in
69
+ before the daily sync has run.
70
+
71
+ The INSERT uses ON CONFLICT DO NOTHING because:
72
+ - If user exists, sync job keeps data fresh
73
+ - If user is new, this creates the record
74
+ - We don't update on login to avoid overwriting sync data
75
+
76
+ Args:
77
+ db_session: SQLAlchemy session (e.g., db.session)
78
+ """
79
+ if not hasattr(g, 'current_user') or not g.current_user:
80
+ return
81
+
82
+ user = g.current_user
83
+ try:
84
+ db_session.execute(text("""
85
+ INSERT INTO users (user_id, email, display_name, is_active, synced_at, created_at)
86
+ VALUES (:user_id, :email, :display_name, true, :synced_at, :created_at)
87
+ ON CONFLICT (user_id) DO NOTHING
88
+ """), {
89
+ 'user_id': user.user_id,
90
+ 'email': user.email,
91
+ 'display_name': user.display_name,
92
+ 'synced_at': datetime.now(timezone.utc),
93
+ 'created_at': datetime.now(timezone.utc)
94
+ })
95
+ db_session.commit()
96
+ except Exception:
97
+ db_session.rollback()
98
+ # Silently fail - sync job will handle it
99
+ pass
100
+
101
+
102
+ class LocalUserMixin:
103
+ """
104
+ Mixin for the local users table model.
105
+
106
+ Usage:
107
+ from yirifi_ops_auth.local_user import LocalUserMixin
108
+
109
+ class User(db.Model, LocalUserMixin):
110
+ __tablename__ = 'users'
111
+
112
+ # Additional fields specific to this microsite
113
+ preferences = db.Column(db.JSON, default={})
114
+ """
115
+ from sqlalchemy import Column, String, Boolean, DateTime
116
+ from sqlalchemy.dialects.postgresql import UUID
117
+ from sqlalchemy.sql import func
118
+
119
+ user_id = Column(UUID(as_uuid=True), primary_key=True)
120
+ email = Column(String(255), nullable=False)
121
+ display_name = Column(String(100))
122
+ is_active = Column(Boolean, default=True)
123
+ synced_at = Column(DateTime(timezone=True), default=func.now())
124
+ created_at = Column(DateTime(timezone=True), default=func.now())
@@ -0,0 +1,281 @@
1
+ """Flask middleware for authentication."""
2
+ from flask import Flask, request, redirect, g, current_app
3
+ from typing import Optional, Callable
4
+
5
+ from yirifi_ops_auth.client import YirifiOpsAuthClient
6
+ from yirifi_ops_auth.exceptions import AuthenticationError, AuthorizationError, AuthServiceError
7
+
8
+
9
+ class AuthMiddleware:
10
+ """Authentication middleware for Flask applications with RBAC support."""
11
+
12
+ def __init__(
13
+ self,
14
+ app: Flask,
15
+ auth_client: YirifiOpsAuthClient,
16
+ microsite_id: str = None,
17
+ app_id: str = None,
18
+ excluded_paths: Optional[list[str]] = None,
19
+ excluded_prefixes: Optional[list[str]] = None,
20
+ require_app_access: bool = True
21
+ ):
22
+ """
23
+ Initialize auth middleware.
24
+
25
+ Args:
26
+ app: Flask application
27
+ auth_client: YirifiOpsAuthClient instance
28
+ microsite_id: ID of this microsite (deprecated, use app_id)
29
+ app_id: Application ID for RBAC (e.g., 'sidebyside')
30
+ excluded_paths: Exact paths to exclude from auth (e.g., ['/health'])
31
+ excluded_prefixes: Path prefixes to exclude (e.g., ['/api/v1/health'])
32
+ require_app_access: If True (default), require user to have explicit role
33
+ assignment in this app. Set to False only during RBAC
34
+ migration to allow any authenticated user temporarily.
35
+ """
36
+ self.app = app
37
+ self.auth_client = auth_client
38
+ # Support both app_id (new) and microsite_id (legacy)
39
+ self.app_id = app_id or microsite_id
40
+ self.microsite_id = self.app_id # Keep for backward compatibility
41
+ self.require_app_access = require_app_access
42
+ self.excluded_paths = excluded_paths or []
43
+ # Default exclusions - always included
44
+ default_prefixes = [
45
+ '/api/v1/health',
46
+ '/static',
47
+ '/favicon.ico',
48
+ # API documentation - allow public access for tooling
49
+ '/api/v1/swagger.json',
50
+ '/api/v1/openapi.json',
51
+ '/api/docs',
52
+ '/api/v1/docs',
53
+ '/swagger.json',
54
+ '/openapi.json',
55
+ ]
56
+ # Merge app-specific prefixes with defaults (app prefixes take priority)
57
+ if excluded_prefixes:
58
+ self.excluded_prefixes = list(set(default_prefixes + excluded_prefixes))
59
+ else:
60
+ self.excluded_prefixes = default_prefixes
61
+
62
+ # Store on app for access in routes
63
+ app.auth_client = auth_client
64
+ app.microsite_id = self.app_id # Legacy
65
+ app.app_id = self.app_id # New
66
+
67
+ # Register middleware
68
+ app.before_request(self._authenticate_request)
69
+
70
+ # Register error handlers
71
+ self._register_error_handlers()
72
+
73
+ def _is_excluded(self, path: str) -> bool:
74
+ """Check if path should be excluded from authentication."""
75
+ if path in self.excluded_paths:
76
+ return True
77
+ for prefix in self.excluded_prefixes:
78
+ if path.startswith(prefix):
79
+ return True
80
+ return False
81
+
82
+ def _authenticate_request(self):
83
+ """Authenticate incoming request.
84
+
85
+ Note: Returns responses directly instead of raising exceptions because
86
+ Flask's @errorhandler doesn't catch exceptions from before_request hooks.
87
+ """
88
+ # Skip excluded paths
89
+ if self._is_excluded(request.path):
90
+ return None
91
+
92
+ # Get credentials
93
+ session_cookie = request.cookies.get('yirifi_ops_session')
94
+ api_key = request.headers.get('X-API-Key')
95
+
96
+ # No credentials
97
+ if not session_cookie and not api_key:
98
+ return self._handle_unauthenticated()
99
+
100
+ # Verify with auth service (pass app_id for RBAC)
101
+ try:
102
+ result = self.auth_client.verify(
103
+ session_cookie=session_cookie,
104
+ api_key=api_key,
105
+ app_id=self.app_id
106
+ )
107
+ except AuthServiceError as e:
108
+ # Auth service unavailable - fail open or closed based on config
109
+ if current_app.config.get('AUTH_FAIL_OPEN', False):
110
+ g.current_user = None
111
+ g.auth_method = None
112
+ return None
113
+ # Return error response directly
114
+ return self._handle_auth_service_error(e.message)
115
+
116
+ # Invalid credentials
117
+ if not result.valid:
118
+ if api_key:
119
+ # API request - return 401 directly
120
+ return self._make_auth_error_response(result.error or 'Invalid API key')
121
+ else:
122
+ # Browser request - redirect to login
123
+ return self._handle_unauthenticated(result.redirect_url)
124
+
125
+ # Check app access (only if require_app_access is enabled)
126
+ if self.require_app_access and not result.has_access:
127
+ return self._handle_access_denied(f'Access denied to {self.app_id}')
128
+
129
+ # Store user in request context
130
+ g.current_user = result.user
131
+ g.auth_method = 'api_key' if api_key else 'session'
132
+ g.has_app_access = result.has_access # Store for route-level checks
133
+
134
+ return None
135
+
136
+ def _is_api_request(self) -> bool:
137
+ """Detect if this is an API request (should get JSON errors, not redirects)."""
138
+ # Explicit API key header
139
+ if request.headers.get('X-API-Key'):
140
+ return True
141
+ # JSON content type
142
+ if request.is_json:
143
+ return True
144
+ # Accept header prefers JSON
145
+ accept = request.headers.get('Accept', '')
146
+ if 'application/json' in accept and 'text/html' not in accept:
147
+ return True
148
+ # API path patterns (swagger, openapi, etc.)
149
+ path = request.path.lower()
150
+ if any(pattern in path for pattern in ['/api/', 'swagger', 'openapi', '.json']):
151
+ return True
152
+ return False
153
+
154
+ def _handle_unauthenticated(self, redirect_url: str = None):
155
+ """Handle unauthenticated request."""
156
+ # Check if API request - return JSON error instead of redirect
157
+ if self._is_api_request():
158
+ return self._make_auth_error_response('Authentication required')
159
+
160
+ # Browser request - redirect to login
161
+ return_url = request.url
162
+ login_url = redirect_url or self.auth_client.get_login_url(return_url)
163
+ return redirect(login_url)
164
+
165
+ def _make_auth_error_response(self, message: str):
166
+ """Create a 401 JSON error response for API requests."""
167
+ from flask import jsonify, make_response
168
+ response = make_response(jsonify({
169
+ 'success': False,
170
+ 'error': {
171
+ 'code': 'AUTHENTICATION_REQUIRED',
172
+ 'message': message
173
+ }
174
+ }), 401)
175
+ return response
176
+
177
+ def _handle_access_denied(self, message: str):
178
+ """Handle access denied - user authenticated but lacks permission."""
179
+ if self._is_api_request():
180
+ from flask import jsonify, make_response
181
+ response = make_response(jsonify({
182
+ 'success': False,
183
+ 'error': {
184
+ 'code': 'ACCESS_DENIED',
185
+ 'message': message
186
+ }
187
+ }), 403)
188
+ return response
189
+ # Browser request - redirect to access denied page
190
+ access_denied_url = self.auth_client.get_access_denied_url(
191
+ app_id=self.app_id,
192
+ return_url=request.url
193
+ )
194
+ return redirect(access_denied_url)
195
+
196
+ def _handle_auth_service_error(self, message: str):
197
+ """Handle auth service unavailable error."""
198
+ from flask import jsonify, make_response, render_template_string
199
+ if self._is_api_request():
200
+ response = make_response(jsonify({
201
+ 'success': False,
202
+ 'error': {
203
+ 'code': 'AUTH_SERVICE_ERROR',
204
+ 'message': 'Authentication service unavailable'
205
+ }
206
+ }), 503)
207
+ return response
208
+ # Browser request - show error page
209
+ error_html = """
210
+ <!DOCTYPE html>
211
+ <html><head><title>Service Unavailable</title></head>
212
+ <body style="font-family: sans-serif; text-align: center; padding: 50px;">
213
+ <h1>Service Temporarily Unavailable</h1>
214
+ <p>The authentication service is currently unavailable. Please try again later.</p>
215
+ </body></html>
216
+ """
217
+ return make_response(render_template_string(error_html), 503)
218
+
219
+ def _register_error_handlers(self):
220
+ """Register error handlers for auth exceptions."""
221
+
222
+ @self.app.errorhandler(AuthenticationError)
223
+ def handle_auth_error(error):
224
+ if self._is_api_request():
225
+ return {
226
+ 'success': False,
227
+ 'error': {
228
+ 'code': 'AUTHENTICATION_REQUIRED',
229
+ 'message': error.message
230
+ }
231
+ }, 401
232
+ return redirect(error.redirect_url or self.auth_client.get_login_url(request.url))
233
+
234
+ @self.app.errorhandler(AuthorizationError)
235
+ def handle_authz_error(error):
236
+ if self._is_api_request():
237
+ return {
238
+ 'success': False,
239
+ 'error': {
240
+ 'code': 'ACCESS_DENIED',
241
+ 'message': error.message
242
+ }
243
+ }, 403
244
+ # Browser request - redirect to access denied page
245
+ access_denied_url = self.auth_client.get_access_denied_url(
246
+ app_id=self.app_id,
247
+ return_url=request.url
248
+ )
249
+ return redirect(access_denied_url)
250
+
251
+
252
+ def setup_auth_middleware(
253
+ app: Flask,
254
+ auth_client: YirifiOpsAuthClient,
255
+ microsite_id: str,
256
+ **kwargs
257
+ ) -> AuthMiddleware:
258
+ """
259
+ Set up authentication middleware for a Flask app.
260
+
261
+ Args:
262
+ app: Flask application
263
+ auth_client: YirifiOpsAuthClient instance
264
+ microsite_id: ID of this microsite
265
+ **kwargs: Additional options passed to AuthMiddleware
266
+
267
+ Returns:
268
+ Configured AuthMiddleware instance
269
+
270
+ Example:
271
+ from yirifi_ops_auth import YirifiOpsAuthClient, setup_auth_middleware
272
+
273
+ def create_app():
274
+ app = Flask(__name__)
275
+
276
+ auth_client = YirifiOpsAuthClient(app.config['AUTH_SERVICE_URL'])
277
+ setup_auth_middleware(app, auth_client, microsite_id='sidebyside')
278
+
279
+ return app
280
+ """
281
+ return AuthMiddleware(app, auth_client, microsite_id, **kwargs)