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,58 @@
1
+ """Yirifi Ops Auth Client - Authentication library for Yirifi Ops microsites."""
2
+
3
+ from yirifi_ops_auth.client import YirifiOpsAuthClient
4
+ from yirifi_ops_auth.middleware import setup_auth_middleware, AuthMiddleware
5
+ from yirifi_ops_auth.decorators import (
6
+ require_auth,
7
+ require_admin,
8
+ require_access,
9
+ # RBAC decorators
10
+ require_permission,
11
+ require_any_permission,
12
+ require_all_permissions,
13
+ require_role,
14
+ # Helpers
15
+ get_current_user,
16
+ is_authenticated,
17
+ )
18
+ from yirifi_ops_auth.models import AuthUser, VerifyResult
19
+ from yirifi_ops_auth.exceptions import AuthenticationError, AuthorizationError
20
+ from yirifi_ops_auth.local_user import (
21
+ ensure_local_user,
22
+ get_current_user_id,
23
+ get_current_user_context,
24
+ LocalUserMixin,
25
+ )
26
+
27
+ __version__ = '3.2.1'
28
+
29
+ __all__ = [
30
+ # Client
31
+ 'YirifiOpsAuthClient',
32
+ # Middleware
33
+ 'setup_auth_middleware',
34
+ 'AuthMiddleware',
35
+ # Auth decorators
36
+ 'require_auth',
37
+ 'require_admin', # deprecated
38
+ 'require_access',
39
+ # RBAC decorators
40
+ 'require_permission',
41
+ 'require_any_permission',
42
+ 'require_all_permissions',
43
+ 'require_role',
44
+ # Helpers
45
+ 'get_current_user',
46
+ 'is_authenticated',
47
+ # Local user helpers
48
+ 'ensure_local_user',
49
+ 'get_current_user_id',
50
+ 'get_current_user_context',
51
+ 'LocalUserMixin',
52
+ # Models
53
+ 'AuthUser',
54
+ 'VerifyResult',
55
+ # Exceptions
56
+ 'AuthenticationError',
57
+ 'AuthorizationError',
58
+ ]
@@ -0,0 +1,154 @@
1
+ """HTTP client for auth service communication."""
2
+ import httpx
3
+ from typing import Optional
4
+
5
+ from yirifi_ops_auth.models import AuthUser, VerifyResult
6
+ from yirifi_ops_auth.exceptions import AuthServiceError
7
+
8
+
9
+ class YirifiOpsAuthClient:
10
+ """Client for communicating with the Yirifi Ops Auth Service."""
11
+
12
+ def __init__(
13
+ self,
14
+ auth_service_url: str,
15
+ timeout: float = 5.0,
16
+ verify_ssl: bool = True
17
+ ):
18
+ """
19
+ Initialize the auth client.
20
+
21
+ Args:
22
+ auth_service_url: Base URL of the auth service (e.g., 'http://localhost:5100')
23
+ timeout: Request timeout in seconds
24
+ verify_ssl: Whether to verify SSL certificates
25
+ """
26
+ self.auth_service_url = auth_service_url.rstrip('/')
27
+ self.timeout = timeout
28
+ self._client = httpx.Client(
29
+ timeout=timeout,
30
+ verify=verify_ssl
31
+ )
32
+
33
+ def verify(
34
+ self,
35
+ session_cookie: Optional[str] = None,
36
+ api_key: Optional[str] = None,
37
+ microsite_id: Optional[str] = None,
38
+ app_id: Optional[str] = None,
39
+ permission: Optional[str] = None
40
+ ) -> VerifyResult:
41
+ """
42
+ Verify session cookie or API key with auth service.
43
+
44
+ Args:
45
+ session_cookie: Session cookie value (yirifi_ops_session)
46
+ api_key: API key for programmatic access
47
+ microsite_id: Optional microsite ID to check access for (deprecated, use app_id)
48
+ app_id: Application ID to get app-specific roles/permissions
49
+ permission: Specific permission to verify
50
+
51
+ Returns:
52
+ VerifyResult with validation status, user info, and permissions
53
+ """
54
+ if not session_cookie and not api_key:
55
+ return VerifyResult(
56
+ valid=False,
57
+ error='no_credentials',
58
+ redirect_url=f'{self.auth_service_url}/auth/login'
59
+ )
60
+
61
+ headers = {}
62
+ if session_cookie:
63
+ headers['Cookie'] = f'yirifi_ops_session={session_cookie}'
64
+ if api_key:
65
+ headers['X-API-Key'] = api_key
66
+
67
+ payload = {}
68
+ # Support both app_id (new) and microsite_id (legacy)
69
+ if app_id:
70
+ payload['app_id'] = app_id
71
+ elif microsite_id:
72
+ payload['app_id'] = microsite_id
73
+ if permission:
74
+ payload['permission'] = permission
75
+
76
+ try:
77
+ response = self._client.post(
78
+ f'{self.auth_service_url}/api/v1/auth/verify',
79
+ headers=headers,
80
+ json=payload if payload else None
81
+ )
82
+
83
+ data = response.json()
84
+
85
+ if data.get('valid'):
86
+ user_data = data['user']
87
+ access_data = data.get('access', {})
88
+
89
+ return VerifyResult(
90
+ valid=True,
91
+ user=AuthUser(
92
+ user_id=user_data.get('user_id'),
93
+ id=user_data['id'],
94
+ email=user_data['email'],
95
+ display_name=user_data['display_name'],
96
+ is_admin=user_data.get('is_admin', False),
97
+ microsites=access_data.get('microsites', user_data.get('microsites', [])),
98
+ # RBAC fields
99
+ roles=access_data.get('roles', []),
100
+ permissions=access_data.get('permissions', []),
101
+ effective_role=access_data.get('effective_role')
102
+ ),
103
+ has_access=access_data.get('has_access', True),
104
+ role=access_data.get('effective_role', access_data.get('role'))
105
+ )
106
+ else:
107
+ # Ensure redirect_url is absolute (prepend auth_service_url if relative)
108
+ redirect_url = data.get('redirect_url', '/auth/login')
109
+ if redirect_url.startswith('/'):
110
+ redirect_url = f'{self.auth_service_url}{redirect_url}'
111
+ return VerifyResult(
112
+ valid=False,
113
+ error=data.get('error'),
114
+ redirect_url=redirect_url
115
+ )
116
+
117
+ except httpx.RequestError as e:
118
+ raise AuthServiceError(f'Failed to connect to auth service: {e}')
119
+ except Exception as e:
120
+ raise AuthServiceError(f'Auth verification failed: {e}')
121
+
122
+ def get_login_url(self, return_url: str = '') -> str:
123
+ """Get the login URL with optional return URL."""
124
+ if return_url:
125
+ return f'{self.auth_service_url}/auth/login?return_url={return_url}'
126
+ return f'{self.auth_service_url}/auth/login'
127
+
128
+ def get_access_denied_url(self, app_id: str = '', return_url: str = '') -> str:
129
+ """Get the access denied page URL with app context."""
130
+ from urllib.parse import urlencode
131
+ params = {}
132
+ if app_id:
133
+ params['app_id'] = app_id
134
+ if return_url:
135
+ params['return_url'] = return_url
136
+ if params:
137
+ return f'{self.auth_service_url}/auth/access-denied?{urlencode(params)}'
138
+ return f'{self.auth_service_url}/auth/access-denied'
139
+
140
+ def get_logout_url(self, return_url: str = '') -> str:
141
+ """Get the logout URL with optional return URL."""
142
+ if return_url:
143
+ return f'{self.auth_service_url}/auth/logout?return_url={return_url}'
144
+ return f'{self.auth_service_url}/auth/logout'
145
+
146
+ def close(self):
147
+ """Close the HTTP client."""
148
+ self._client.close()
149
+
150
+ def __enter__(self):
151
+ return self
152
+
153
+ def __exit__(self, *args):
154
+ self.close()
@@ -0,0 +1,213 @@
1
+ """Authentication decorators for Flask routes."""
2
+ from functools import wraps
3
+ from flask import g
4
+
5
+ from yirifi_ops_auth.exceptions import AuthenticationError, AuthorizationError
6
+
7
+
8
+ def require_auth(f):
9
+ """
10
+ Decorator to require authentication on a route.
11
+
12
+ The middleware already handles authentication, but this decorator
13
+ provides an explicit check for routes that must have a user.
14
+
15
+ Example:
16
+ @app.route('/profile')
17
+ @require_auth
18
+ def profile():
19
+ return f"Hello {g.current_user.display_name}"
20
+ """
21
+ @wraps(f)
22
+ def decorated(*args, **kwargs):
23
+ if not hasattr(g, 'current_user') or g.current_user is None:
24
+ raise AuthenticationError('Authentication required')
25
+ return f(*args, **kwargs)
26
+ return decorated
27
+
28
+
29
+ def require_admin(f):
30
+ """
31
+ Decorator to require admin privileges.
32
+
33
+ DEPRECATED: Use @require_permission('admin:access') or @require_role('super_admin') instead.
34
+
35
+ Example:
36
+ @app.route('/admin')
37
+ @require_admin
38
+ def admin_panel():
39
+ return "Admin only content"
40
+ """
41
+ @wraps(f)
42
+ def decorated(*args, **kwargs):
43
+ if not hasattr(g, 'current_user') or g.current_user is None:
44
+ raise AuthenticationError('Authentication required')
45
+ # Use role check instead of deprecated is_admin field
46
+ if not g.current_user.has_role('super_admin'):
47
+ raise AuthorizationError('Admin access required')
48
+ return f(*args, **kwargs)
49
+ return decorated
50
+
51
+
52
+ def require_permission(permission: str):
53
+ """
54
+ Decorator factory to require a specific permission.
55
+
56
+ Args:
57
+ permission: Permission code to require (e.g., 'report:create')
58
+
59
+ Example:
60
+ @app.route('/reports', methods=['POST'])
61
+ @require_permission('report:create')
62
+ def create_report():
63
+ # User has report:create permission
64
+ return create_new_report(request.json)
65
+ """
66
+ def decorator(f):
67
+ @wraps(f)
68
+ def decorated(*args, **kwargs):
69
+ if not hasattr(g, 'current_user') or g.current_user is None:
70
+ raise AuthenticationError('Authentication required')
71
+
72
+ if not g.current_user.has_permission(permission):
73
+ raise AuthorizationError(f'Permission denied: {permission}')
74
+
75
+ return f(*args, **kwargs)
76
+ return decorated
77
+ return decorator
78
+
79
+
80
+ def require_any_permission(*permissions: str):
81
+ """
82
+ Decorator factory to require at least one of the specified permissions.
83
+
84
+ Args:
85
+ *permissions: Permission codes to check
86
+
87
+ Example:
88
+ @require_any_permission('report:read', 'report:create')
89
+ def view_reports():
90
+ return get_reports()
91
+ """
92
+ def decorator(f):
93
+ @wraps(f)
94
+ def decorated(*args, **kwargs):
95
+ if not hasattr(g, 'current_user') or g.current_user is None:
96
+ raise AuthenticationError('Authentication required')
97
+
98
+ if not g.current_user.has_any_permission(*permissions):
99
+ raise AuthorizationError(
100
+ f'Permission denied. Required one of: {", ".join(permissions)}'
101
+ )
102
+
103
+ return f(*args, **kwargs)
104
+ return decorated
105
+ return decorator
106
+
107
+
108
+ def require_all_permissions(*permissions: str):
109
+ """
110
+ Decorator factory to require all of the specified permissions.
111
+
112
+ Args:
113
+ *permissions: Permission codes to check
114
+
115
+ Example:
116
+ @require_all_permissions('report:read', 'data:export')
117
+ def export_report():
118
+ return generate_export()
119
+ """
120
+ def decorator(f):
121
+ @wraps(f)
122
+ def decorated(*args, **kwargs):
123
+ if not hasattr(g, 'current_user') or g.current_user is None:
124
+ raise AuthenticationError('Authentication required')
125
+
126
+ if not g.current_user.has_all_permissions(*permissions):
127
+ missing = [p for p in permissions if not g.current_user.has_permission(p)]
128
+ raise AuthorizationError(
129
+ f'Permission denied. Missing: {", ".join(missing)}'
130
+ )
131
+
132
+ return f(*args, **kwargs)
133
+ return decorated
134
+ return decorator
135
+
136
+
137
+ def require_role(role: str):
138
+ """
139
+ Decorator factory to require a specific role.
140
+
141
+ Note: Prefer using @require_permission() over @require_role() as it
142
+ provides more granular access control.
143
+
144
+ Args:
145
+ role: Role code to require (e.g., 'admin', 'editor')
146
+
147
+ Example:
148
+ @require_role('admin')
149
+ def admin_dashboard():
150
+ return render_template('admin/dashboard.html')
151
+ """
152
+ def decorator(f):
153
+ @wraps(f)
154
+ def decorated(*args, **kwargs):
155
+ if not hasattr(g, 'current_user') or g.current_user is None:
156
+ raise AuthenticationError('Authentication required')
157
+
158
+ if not g.current_user.has_role(role):
159
+ raise AuthorizationError(f'Role required: {role}')
160
+
161
+ return f(*args, **kwargs)
162
+ return decorated
163
+ return decorator
164
+
165
+
166
+ def require_access(microsite_id: str):
167
+ """
168
+ Decorator factory to require access to a specific microsite.
169
+
170
+ This is useful when a route needs access to a different microsite
171
+ than the one the app is registered under.
172
+
173
+ Example:
174
+ @app.route('/cross-site-data')
175
+ @require_access('other-microsite')
176
+ def cross_site():
177
+ return "Data from other microsite"
178
+ """
179
+ def decorator(f):
180
+ @wraps(f)
181
+ def decorated(*args, **kwargs):
182
+ if not hasattr(g, 'current_user') or g.current_user is None:
183
+ raise AuthenticationError('Authentication required')
184
+ if not g.current_user.has_access_to(microsite_id):
185
+ raise AuthorizationError(f'Access denied to {microsite_id}')
186
+ return f(*args, **kwargs)
187
+ return decorated
188
+ return decorator
189
+
190
+
191
+ def get_current_user():
192
+ """
193
+ Get the current authenticated user.
194
+
195
+ Returns:
196
+ AuthUser or None if not authenticated
197
+
198
+ Example:
199
+ user = get_current_user()
200
+ if user:
201
+ print(f"Logged in as {user.email}")
202
+ """
203
+ return getattr(g, 'current_user', None)
204
+
205
+
206
+ def is_authenticated() -> bool:
207
+ """
208
+ Check if the current request is authenticated.
209
+
210
+ Returns:
211
+ True if user is authenticated
212
+ """
213
+ return hasattr(g, 'current_user') and g.current_user is not None
@@ -0,0 +1,210 @@
1
+ """Deep linking module for cross-microsite navigation.
2
+
3
+ This module provides tools to generate URLs to entities across different
4
+ Yirifi Ops microsites with permission-aware rendering.
5
+
6
+ Quick Start:
7
+ 1. Register your microsite's entities:
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": "/items/{id}"},
16
+ ]
17
+ )
18
+
19
+ 2. Set up in your Flask app:
20
+ from yirifi_ops_auth.deeplink import setup_deeplinks
21
+ setup_deeplinks(app, env='dev')
22
+
23
+ 3. Use in templates:
24
+ {{ cross_link('risk_item', item.r_yid, 'Open in Risk Dashboard') }}
25
+
26
+ 4. Or use in Python:
27
+ from yirifi_ops_auth.deeplink import resolve_link
28
+ url = resolve_link('risk_item', 'r_yid_123')
29
+
30
+ Federation (v3.1.0+):
31
+ Enable cross-microsite entity discovery without manual registration:
32
+
33
+ setup_deeplinks(
34
+ app,
35
+ env='dev',
36
+ enable_federation=True, # Queries auth service + microsites
37
+ expose_references=True, # Exposes /api/v1/references
38
+ )
39
+
40
+ Now templates can use entities from ANY microsite:
41
+ {{ cross_link('reg_link', link.id) }} # Works without registering!
42
+
43
+ Registration Functions:
44
+ - register_entities: Register a microsite and its entities (bulk)
45
+ - register_microsite: Register a microsite configuration
46
+ - register_entity: Register a single entity definition
47
+ - load_from_yaml: Load entity definitions from YAML file
48
+ - clear_registry: Clear all registrations (for testing)
49
+
50
+ Resolution Functions:
51
+ - setup_deeplinks: Initialize deep linking for a Flask app
52
+ - resolve_link: Generate a URL for an entity
53
+ - deep_link_info: Get URL + accessibility info
54
+ - can_link_to: Check if user can access an entity's microsite
55
+
56
+ Template Helpers (available after setup_deeplinks):
57
+ - {{ deep_link(entity_type, entity_id) }} - Returns URL string
58
+ - {{ deep_link_info(entity_type, entity_id) }} - Returns DeepLinkInfo object
59
+ - {{ cross_link(entity_type, entity_id, label) }} - Renders permission-aware link
60
+ - {{ cross_link_button(entity_type, entity_id, label) }} - Renders styled button
61
+
62
+ Registry Query Functions:
63
+ - get_entity_definition: Get definition for an entity type
64
+ - list_entity_types: List all registered entity types
65
+ - list_entity_types_for_microsite: List entity types for a microsite
66
+ - list_microsites: List all registered microsites
67
+
68
+ Federation Functions:
69
+ - configure_federation: Configure and start federation client
70
+ - get_federation_client: Get the federation client singleton
71
+ - stop_federation: Stop the federation client
72
+ - FederationConfig: Configuration dataclass for federation
73
+
74
+ Environment:
75
+ - Environment: Thread-safe environment class with get/set/override
76
+ - get_environment: Get current environment (dev/uat/prd)
77
+ - set_environment: Explicitly set environment
78
+ """
79
+
80
+ # Flask integration
81
+ from .jinja import setup_deeplinks
82
+
83
+ # Core resolution
84
+ from .resolver import (
85
+ resolve_link,
86
+ deep_link_info,
87
+ can_link_to,
88
+ get_entity_microsite_name,
89
+ DeepLinkInfo,
90
+ DeepLinkError,
91
+ UnknownEntityTypeError,
92
+ UnknownMicrositeError,
93
+ )
94
+
95
+ # Registry - registration functions
96
+ from .registry import (
97
+ register_entities,
98
+ register_microsite,
99
+ register_entity,
100
+ clear_registry,
101
+ get_registry,
102
+ # Query functions
103
+ get_entity_definition,
104
+ get_microsite_for_entity,
105
+ get_microsite_config,
106
+ get_microsite_name,
107
+ get_microsite_base_url,
108
+ list_entity_types,
109
+ list_entity_types_for_microsite,
110
+ list_microsites,
111
+ # Types
112
+ EntityDefinition,
113
+ MicrositeConfig,
114
+ RegistrationError,
115
+ DeepLinkRegistry,
116
+ )
117
+
118
+ # YAML loading
119
+ from .yaml_loader import (
120
+ load_from_yaml,
121
+ load_from_string,
122
+ discover_and_load,
123
+ YamlLoadError,
124
+ )
125
+
126
+ # Environment
127
+ from .environment import (
128
+ Environment,
129
+ get_environment,
130
+ set_environment,
131
+ VALID_ENVIRONMENTS,
132
+ )
133
+
134
+ # Federation
135
+ from .federation import (
136
+ configure_federation,
137
+ get_federation_client,
138
+ stop_federation,
139
+ clear_federation_cache,
140
+ FederationConfig,
141
+ FederationClient,
142
+ FederationError,
143
+ FederationTimeoutError,
144
+ FederationConnectionError,
145
+ )
146
+
147
+ # Blueprint for /api/v1/references
148
+ from .blueprint import references_bp
149
+
150
+ __all__ = [
151
+ # Flask integration
152
+ 'setup_deeplinks',
153
+
154
+ # Core resolution
155
+ 'resolve_link',
156
+ 'deep_link_info',
157
+ 'can_link_to',
158
+ 'get_entity_microsite_name',
159
+ 'DeepLinkInfo',
160
+ 'DeepLinkError',
161
+ 'UnknownEntityTypeError',
162
+ 'UnknownMicrositeError',
163
+
164
+ # Registration
165
+ 'register_entities',
166
+ 'register_microsite',
167
+ 'register_entity',
168
+ 'clear_registry',
169
+ 'get_registry',
170
+ 'load_from_yaml',
171
+ 'load_from_string',
172
+ 'discover_and_load',
173
+ 'RegistrationError',
174
+ 'YamlLoadError',
175
+
176
+ # Registry queries
177
+ 'get_entity_definition',
178
+ 'get_microsite_for_entity',
179
+ 'get_microsite_config',
180
+ 'get_microsite_name',
181
+ 'get_microsite_base_url',
182
+ 'list_entity_types',
183
+ 'list_entity_types_for_microsite',
184
+ 'list_microsites',
185
+
186
+ # Types
187
+ 'EntityDefinition',
188
+ 'MicrositeConfig',
189
+ 'DeepLinkRegistry',
190
+
191
+ # Environment
192
+ 'Environment',
193
+ 'get_environment',
194
+ 'set_environment',
195
+ 'VALID_ENVIRONMENTS',
196
+
197
+ # Federation
198
+ 'configure_federation',
199
+ 'get_federation_client',
200
+ 'stop_federation',
201
+ 'clear_federation_cache',
202
+ 'FederationConfig',
203
+ 'FederationClient',
204
+ 'FederationError',
205
+ 'FederationTimeoutError',
206
+ 'FederationConnectionError',
207
+
208
+ # Blueprint
209
+ 'references_bp',
210
+ ]