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,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)
|