ipulse-shared-core-ftredge 22.1.1__tar.gz → 24.1.1__tar.gz
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.
Potentially problematic release.
This version of ipulse-shared-core-ftredge might be problematic. Click here for more details.
- {ipulse_shared_core_ftredge-22.1.1/src/ipulse_shared_core_ftredge.egg-info → ipulse_shared_core_ftredge-24.1.1}/PKG-INFO +1 -1
- {ipulse_shared_core_ftredge-22.1.1 → ipulse_shared_core_ftredge-24.1.1}/setup.py +1 -1
- ipulse_shared_core_ftredge-24.1.1/src/ipulse_shared_core_ftredge/dependencies/auth_firebase_token_validation.py +96 -0
- {ipulse_shared_core_ftredge-22.1.1 → ipulse_shared_core_ftredge-24.1.1}/src/ipulse_shared_core_ftredge/dependencies/authz_for_apis.py +128 -157
- {ipulse_shared_core_ftredge-22.1.1 → ipulse_shared_core_ftredge-24.1.1}/src/ipulse_shared_core_ftredge/exceptions/base_exceptions.py +12 -4
- {ipulse_shared_core_ftredge-22.1.1 → ipulse_shared_core_ftredge-24.1.1}/src/ipulse_shared_core_ftredge/models/__init__.py +1 -1
- {ipulse_shared_core_ftredge-22.1.1 → ipulse_shared_core_ftredge-24.1.1}/src/ipulse_shared_core_ftredge/models/catalog/subscriptionplan.py +4 -3
- {ipulse_shared_core_ftredge-22.1.1 → ipulse_shared_core_ftredge-24.1.1}/src/ipulse_shared_core_ftredge/models/catalog/usertype.py +8 -1
- {ipulse_shared_core_ftredge-22.1.1 → ipulse_shared_core_ftredge-24.1.1}/src/ipulse_shared_core_ftredge/models/user/__init__.py +1 -1
- {ipulse_shared_core_ftredge-22.1.1 → ipulse_shared_core_ftredge-24.1.1}/src/ipulse_shared_core_ftredge/models/user/user_permissions.py +4 -8
- {ipulse_shared_core_ftredge-22.1.1 → ipulse_shared_core_ftredge-24.1.1}/src/ipulse_shared_core_ftredge/models/user/user_subscription.py +142 -30
- {ipulse_shared_core_ftredge-22.1.1 → ipulse_shared_core_ftredge-24.1.1}/src/ipulse_shared_core_ftredge/models/user/userauth.py +46 -3
- {ipulse_shared_core_ftredge-22.1.1 → ipulse_shared_core_ftredge-24.1.1}/src/ipulse_shared_core_ftredge/models/user/userstatus.py +65 -16
- {ipulse_shared_core_ftredge-22.1.1 → ipulse_shared_core_ftredge-24.1.1}/src/ipulse_shared_core_ftredge/services/base/base_firestore_service.py +5 -3
- {ipulse_shared_core_ftredge-22.1.1 → ipulse_shared_core_ftredge-24.1.1}/src/ipulse_shared_core_ftredge/services/catalog/catalog_subscriptionplan_service.py +27 -23
- {ipulse_shared_core_ftredge-22.1.1 → ipulse_shared_core_ftredge-24.1.1}/src/ipulse_shared_core_ftredge/services/catalog/catalog_usertype_service.py +94 -25
- {ipulse_shared_core_ftredge-22.1.1 → ipulse_shared_core_ftredge-24.1.1}/src/ipulse_shared_core_ftredge/services/user/user_core_service.py +146 -28
- {ipulse_shared_core_ftredge-22.1.1 → ipulse_shared_core_ftredge-24.1.1}/src/ipulse_shared_core_ftredge/services/user/user_multistep_operations.py +151 -81
- {ipulse_shared_core_ftredge-22.1.1 → ipulse_shared_core_ftredge-24.1.1}/src/ipulse_shared_core_ftredge/services/user/user_subscription_operations.py +24 -20
- {ipulse_shared_core_ftredge-22.1.1 → ipulse_shared_core_ftredge-24.1.1}/src/ipulse_shared_core_ftredge/services/user/userauth_operations.py +9 -4
- ipulse_shared_core_ftredge-24.1.1/src/ipulse_shared_core_ftredge/services/user/userstatus_operations.py +476 -0
- {ipulse_shared_core_ftredge-22.1.1 → ipulse_shared_core_ftredge-24.1.1/src/ipulse_shared_core_ftredge.egg-info}/PKG-INFO +1 -1
- {ipulse_shared_core_ftredge-22.1.1 → ipulse_shared_core_ftredge-24.1.1}/src/ipulse_shared_core_ftredge.egg-info/SOURCES.txt +0 -1
- ipulse_shared_core_ftredge-22.1.1/src/ipulse_shared_core_ftredge/dependencies/auth_firebase_token_validation.py +0 -59
- ipulse_shared_core_ftredge-22.1.1/src/ipulse_shared_core_ftredge/services/user/firebase_auth_admin_helpers.py +0 -160
- ipulse_shared_core_ftredge-22.1.1/src/ipulse_shared_core_ftredge/services/user/userstatus_operations.py +0 -212
- {ipulse_shared_core_ftredge-22.1.1 → ipulse_shared_core_ftredge-24.1.1}/LICENCE +0 -0
- {ipulse_shared_core_ftredge-22.1.1 → ipulse_shared_core_ftredge-24.1.1}/README.md +0 -0
- {ipulse_shared_core_ftredge-22.1.1 → ipulse_shared_core_ftredge-24.1.1}/pyproject.toml +0 -0
- {ipulse_shared_core_ftredge-22.1.1 → ipulse_shared_core_ftredge-24.1.1}/setup.cfg +0 -0
- {ipulse_shared_core_ftredge-22.1.1 → ipulse_shared_core_ftredge-24.1.1}/src/ipulse_shared_core_ftredge/__init__.py +0 -0
- {ipulse_shared_core_ftredge-22.1.1 → ipulse_shared_core_ftredge-24.1.1}/src/ipulse_shared_core_ftredge/cache/__init__.py +0 -0
- {ipulse_shared_core_ftredge-22.1.1 → ipulse_shared_core_ftredge-24.1.1}/src/ipulse_shared_core_ftredge/cache/shared_cache.py +0 -0
- {ipulse_shared_core_ftredge-22.1.1 → ipulse_shared_core_ftredge-24.1.1}/src/ipulse_shared_core_ftredge/dependencies/__init__.py +0 -0
- {ipulse_shared_core_ftredge-22.1.1 → ipulse_shared_core_ftredge-24.1.1}/src/ipulse_shared_core_ftredge/dependencies/auth_protected_router.py +0 -0
- {ipulse_shared_core_ftredge-22.1.1 → ipulse_shared_core_ftredge-24.1.1}/src/ipulse_shared_core_ftredge/dependencies/firestore_client.py +0 -0
- {ipulse_shared_core_ftredge-22.1.1 → ipulse_shared_core_ftredge-24.1.1}/src/ipulse_shared_core_ftredge/exceptions/__init__.py +0 -0
- {ipulse_shared_core_ftredge-22.1.1 → ipulse_shared_core_ftredge-24.1.1}/src/ipulse_shared_core_ftredge/exceptions/user_exceptions.py +0 -0
- {ipulse_shared_core_ftredge-22.1.1 → ipulse_shared_core_ftredge-24.1.1}/src/ipulse_shared_core_ftredge/models/base_api_response.py +0 -0
- {ipulse_shared_core_ftredge-22.1.1 → ipulse_shared_core_ftredge-24.1.1}/src/ipulse_shared_core_ftredge/models/base_data_model.py +0 -0
- {ipulse_shared_core_ftredge-22.1.1 → ipulse_shared_core_ftredge-24.1.1}/src/ipulse_shared_core_ftredge/models/catalog/__init__.py +0 -0
- {ipulse_shared_core_ftredge-22.1.1 → ipulse_shared_core_ftredge-24.1.1}/src/ipulse_shared_core_ftredge/models/user/userprofile.py +0 -0
- {ipulse_shared_core_ftredge-22.1.1 → ipulse_shared_core_ftredge-24.1.1}/src/ipulse_shared_core_ftredge/monitoring/__init__.py +0 -0
- {ipulse_shared_core_ftredge-22.1.1 → ipulse_shared_core_ftredge-24.1.1}/src/ipulse_shared_core_ftredge/monitoring/tracemon.py +0 -0
- {ipulse_shared_core_ftredge-22.1.1 → ipulse_shared_core_ftredge-24.1.1}/src/ipulse_shared_core_ftredge/services/__init__.py +0 -0
- {ipulse_shared_core_ftredge-22.1.1 → ipulse_shared_core_ftredge-24.1.1}/src/ipulse_shared_core_ftredge/services/base/__init__.py +0 -0
- {ipulse_shared_core_ftredge-22.1.1 → ipulse_shared_core_ftredge-24.1.1}/src/ipulse_shared_core_ftredge/services/base/cache_aware_firestore_service.py +0 -0
- {ipulse_shared_core_ftredge-22.1.1 → ipulse_shared_core_ftredge-24.1.1}/src/ipulse_shared_core_ftredge/services/catalog/__init__.py +0 -0
- {ipulse_shared_core_ftredge-22.1.1 → ipulse_shared_core_ftredge-24.1.1}/src/ipulse_shared_core_ftredge/services/charging_processors.py +0 -0
- {ipulse_shared_core_ftredge-22.1.1 → ipulse_shared_core_ftredge-24.1.1}/src/ipulse_shared_core_ftredge/services/user/__init__.py +0 -0
- {ipulse_shared_core_ftredge-22.1.1 → ipulse_shared_core_ftredge-24.1.1}/src/ipulse_shared_core_ftredge/services/user/user_permissions_operations.py +0 -0
- {ipulse_shared_core_ftredge-22.1.1 → ipulse_shared_core_ftredge-24.1.1}/src/ipulse_shared_core_ftredge/services/user/userprofile_operations.py +0 -0
- {ipulse_shared_core_ftredge-22.1.1 → ipulse_shared_core_ftredge-24.1.1}/src/ipulse_shared_core_ftredge/services/user_charging_service.py +0 -0
- {ipulse_shared_core_ftredge-22.1.1 → ipulse_shared_core_ftredge-24.1.1}/src/ipulse_shared_core_ftredge/utils/__init__.py +0 -0
- {ipulse_shared_core_ftredge-22.1.1 → ipulse_shared_core_ftredge-24.1.1}/src/ipulse_shared_core_ftredge/utils/custom_json_encoder.py +0 -0
- {ipulse_shared_core_ftredge-22.1.1 → ipulse_shared_core_ftredge-24.1.1}/src/ipulse_shared_core_ftredge/utils/json_encoder.py +0 -0
- {ipulse_shared_core_ftredge-22.1.1 → ipulse_shared_core_ftredge-24.1.1}/src/ipulse_shared_core_ftredge.egg-info/dependency_links.txt +0 -0
- {ipulse_shared_core_ftredge-22.1.1 → ipulse_shared_core_ftredge-24.1.1}/src/ipulse_shared_core_ftredge.egg-info/requires.txt +0 -0
- {ipulse_shared_core_ftredge-22.1.1 → ipulse_shared_core_ftredge-24.1.1}/src/ipulse_shared_core_ftredge.egg-info/top_level.txt +0 -0
- {ipulse_shared_core_ftredge-22.1.1 → ipulse_shared_core_ftredge-24.1.1}/tests/test_shared_cache.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: ipulse_shared_core_ftredge
|
|
3
|
-
Version:
|
|
3
|
+
Version: 24.1.1
|
|
4
4
|
Summary: Shared Core models and Logger util for the Pulse platform project. Using AI for financial advisory and investment management.
|
|
5
5
|
Home-page: https://github.com/TheFutureEdge/ipulse_shared_core
|
|
6
6
|
Author: Russlan Ramdowar
|
|
@@ -3,7 +3,7 @@ from setuptools import setup, find_packages
|
|
|
3
3
|
|
|
4
4
|
setup(
|
|
5
5
|
name='ipulse_shared_core_ftredge',
|
|
6
|
-
version='
|
|
6
|
+
version='24.1.1',
|
|
7
7
|
package_dir={'': 'src'}, # Specify the source directory
|
|
8
8
|
packages=find_packages(where='src'), # Look for packages in 'src'
|
|
9
9
|
install_requires=[
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
from typing import Optional, Annotated
|
|
2
|
+
from fastapi import Request, HTTPException, Depends, Header
|
|
3
|
+
from firebase_admin import auth
|
|
4
|
+
from ..models.user.userauth import UserAuth
|
|
5
|
+
|
|
6
|
+
async def verify_firebase_token(
|
|
7
|
+
request: Request,
|
|
8
|
+
x_forwarded_authorization: Optional[str] = Header(None),
|
|
9
|
+
authorization: Optional[str] = Header(None)
|
|
10
|
+
) -> UserAuth:
|
|
11
|
+
"""
|
|
12
|
+
Verify Firebase ID token and return a UserAuth instance with authenticated user data.
|
|
13
|
+
|
|
14
|
+
Args:
|
|
15
|
+
request: FastAPI request object
|
|
16
|
+
x_forwarded_authorization: Authorization header from proxy/load balancer
|
|
17
|
+
authorization: Standard authorization header
|
|
18
|
+
|
|
19
|
+
Returns:
|
|
20
|
+
UserAuth instance with verified user data
|
|
21
|
+
|
|
22
|
+
Raises:
|
|
23
|
+
HTTPException: 401 if token is missing or invalid
|
|
24
|
+
"""
|
|
25
|
+
# Get token from either x-forwarded-authorization or authorization header
|
|
26
|
+
token = x_forwarded_authorization or authorization
|
|
27
|
+
|
|
28
|
+
if not token or token == "None" or not token.strip():
|
|
29
|
+
raise HTTPException(
|
|
30
|
+
status_code=401,
|
|
31
|
+
detail="Authorization token is missing"
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
try:
|
|
35
|
+
# Handle potential Header object or string
|
|
36
|
+
if hasattr(token, '__str__') and not isinstance(token, str):
|
|
37
|
+
# If it's a Header object or similar, convert to string
|
|
38
|
+
token_str = str(token)
|
|
39
|
+
# Check if the string representation looks like a Header object's repr
|
|
40
|
+
if 'annotation=' in token_str or 'required=' in token_str:
|
|
41
|
+
# This means we got the internal representation of a Header object
|
|
42
|
+
# In this case, we need to extract the actual value
|
|
43
|
+
if hasattr(token, 'default'):
|
|
44
|
+
token_str = str(token.default) if token.default is not None else ""
|
|
45
|
+
else:
|
|
46
|
+
token_str = ""
|
|
47
|
+
elif token_str == "None":
|
|
48
|
+
token_str = ""
|
|
49
|
+
else:
|
|
50
|
+
# It's already a string
|
|
51
|
+
token_str = token if isinstance(token, str) else str(token)
|
|
52
|
+
|
|
53
|
+
# Check if we still have an empty or None token
|
|
54
|
+
if not token_str or token_str == "None":
|
|
55
|
+
raise HTTPException(
|
|
56
|
+
status_code=401,
|
|
57
|
+
detail="Authorization token is missing"
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
# Remove 'Bearer ' prefix if present
|
|
61
|
+
token_str = token_str.replace("Bearer ", "")
|
|
62
|
+
|
|
63
|
+
# Verify the token
|
|
64
|
+
decoded_token = auth.verify_id_token(token_str)
|
|
65
|
+
|
|
66
|
+
# Create UserAuth instance from decoded token
|
|
67
|
+
email = decoded_token.get('email')
|
|
68
|
+
if not email:
|
|
69
|
+
raise ValueError("Token must contain user email")
|
|
70
|
+
|
|
71
|
+
user_auth = UserAuth(
|
|
72
|
+
email=email,
|
|
73
|
+
password=None, # No password for token-based auth
|
|
74
|
+
display_name=decoded_token.get('name'),
|
|
75
|
+
user_uid=decoded_token.get('uid'),
|
|
76
|
+
email_verified=decoded_token.get('email_verified', False),
|
|
77
|
+
custom_claims=decoded_token.get('custom_claims', {}),
|
|
78
|
+
phone_number=decoded_token.get('phone_number'),
|
|
79
|
+
# Note: provider_data, metadata, and timestamps would come from getUserRecord() if needed
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
# Store the full decoded token in request state for use in authorization middleware
|
|
83
|
+
# This maintains backward compatibility with existing authorization code
|
|
84
|
+
request.state.user = decoded_token
|
|
85
|
+
|
|
86
|
+
return user_auth
|
|
87
|
+
|
|
88
|
+
except Exception as e:
|
|
89
|
+
raise HTTPException(
|
|
90
|
+
status_code=401,
|
|
91
|
+
detail=f"Invalid token: {str(e)}"
|
|
92
|
+
) from e
|
|
93
|
+
|
|
94
|
+
# Type alias for dependency injection
|
|
95
|
+
AuthUser = Annotated[UserAuth, Depends(verify_firebase_token)]
|
|
96
|
+
|
|
@@ -1,55 +1,52 @@
|
|
|
1
1
|
import os
|
|
2
2
|
import logging
|
|
3
|
-
import json
|
|
4
3
|
import asyncio
|
|
5
|
-
from concurrent.futures import ThreadPoolExecutor
|
|
6
4
|
from typing import Optional, Iterable, Dict, Any, List
|
|
7
5
|
from datetime import datetime, timedelta, timezone
|
|
8
|
-
import json
|
|
9
6
|
import httpx
|
|
10
7
|
from fastapi import HTTPException, Request
|
|
11
|
-
from
|
|
12
|
-
from ipulse_shared_core_ftredge.exceptions import ServiceError, AuthorizationError, ResourceNotFoundError
|
|
8
|
+
from ipulse_shared_core_ftredge.exceptions import ServiceError, ResourceNotFoundError
|
|
13
9
|
from ipulse_shared_core_ftredge.models import UserStatus
|
|
14
|
-
from ipulse_shared_core_ftredge.
|
|
10
|
+
from ipulse_shared_core_ftredge.services import UserCoreService
|
|
11
|
+
from ipulse_shared_base_ftredge import ApprovalStatus
|
|
15
12
|
|
|
16
|
-
#
|
|
17
|
-
USERS_STATUS_COLLECTION_NAME = UserStatus.COLLECTION_NAME
|
|
18
|
-
USERS_STATUS_DOC_REF = f"{UserStatus.OBJ_REF}_" # Use OBJ_REF and append underscore
|
|
13
|
+
# Cache TTL constant
|
|
19
14
|
USERSTATUS_CACHE_TTL = 60 # 60 seconds
|
|
20
15
|
|
|
21
16
|
class UserStatusCache:
|
|
22
17
|
"""Manages user status caching with dynamic invalidation"""
|
|
23
18
|
def __init__(self):
|
|
24
|
-
self._cache: Dict[str,
|
|
19
|
+
self._cache: Dict[str, UserStatus] = {}
|
|
25
20
|
self._timestamps: Dict[str, datetime] = {}
|
|
26
21
|
|
|
27
|
-
def get(self, user_uid: str) -> Optional[
|
|
22
|
+
def get(self, user_uid: str) -> Optional[UserStatus]:
|
|
28
23
|
"""
|
|
29
24
|
Retrieves user status from cache if available and valid.
|
|
30
25
|
|
|
31
26
|
Args:
|
|
32
27
|
user_uid (str): The user ID.
|
|
33
28
|
|
|
29
|
+
Returns:
|
|
30
|
+
UserStatus object if cached and valid, None otherwise
|
|
34
31
|
"""
|
|
35
32
|
if user_uid in self._cache:
|
|
36
|
-
|
|
33
|
+
status_obj = self._cache[user_uid]
|
|
37
34
|
# Force refresh for credit-consuming or sensitive operations
|
|
38
35
|
# Check TTL for normal operations
|
|
39
36
|
if datetime.now() - self._timestamps[user_uid] < timedelta(seconds=USERSTATUS_CACHE_TTL):
|
|
40
|
-
return
|
|
37
|
+
return status_obj
|
|
41
38
|
self.invalidate(user_uid)
|
|
42
39
|
return None
|
|
43
40
|
|
|
44
|
-
def set(self, user_uid: str,
|
|
41
|
+
def set(self, user_uid: str, status: UserStatus) -> None:
|
|
45
42
|
"""
|
|
46
|
-
Sets user status
|
|
43
|
+
Sets user status object in the cache.
|
|
47
44
|
|
|
48
45
|
Args:
|
|
49
46
|
user_uid (str): The user ID.
|
|
50
|
-
|
|
47
|
+
status (UserStatus): The user status object to cache.
|
|
51
48
|
"""
|
|
52
|
-
self._cache[user_uid] =
|
|
49
|
+
self._cache[user_uid] = status
|
|
53
50
|
self._timestamps[user_uid] = datetime.now()
|
|
54
51
|
|
|
55
52
|
def invalidate(self, user_uid: str) -> None:
|
|
@@ -68,61 +65,26 @@ userstatus_cache = UserStatusCache()
|
|
|
68
65
|
# Replace the logger dependency with a standard logger
|
|
69
66
|
logger = logging.getLogger(__name__)
|
|
70
67
|
|
|
71
|
-
# Create a custom FirestoreTimeoutError class that can be identified in middlewares
|
|
72
|
-
class FirestoreTimeoutError(TimeoutError):
|
|
73
|
-
"""Custom exception for Firestore timeout errors to make them more identifiable."""
|
|
74
|
-
pass
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
# Define a function to get a Firestore document with a strict timeout
|
|
78
|
-
async def get_with_strict_timeout(doc_ref, timeout_seconds: float):
|
|
79
|
-
"""
|
|
80
|
-
Get a Firestore document with a strictly enforced timeout.
|
|
81
|
-
|
|
82
|
-
Args:
|
|
83
|
-
doc_ref: Firestore document reference
|
|
84
|
-
timeout_seconds: Maximum time to wait in seconds
|
|
85
|
-
|
|
86
|
-
Returns:
|
|
87
|
-
Document snapshot
|
|
88
|
-
|
|
89
|
-
Raises:
|
|
90
|
-
FirestoreTimeoutError: If the operation takes longer than timeout_seconds
|
|
91
|
-
"""
|
|
92
|
-
loop = asyncio.get_running_loop()
|
|
93
|
-
with ThreadPoolExecutor() as executor:
|
|
94
|
-
try:
|
|
95
|
-
# Run the blocking Firestore get() operation in a thread and apply a strict timeout
|
|
96
|
-
logger.debug(f"Starting Firestore get with strict timeout of {timeout_seconds}s")
|
|
97
|
-
return await asyncio.wait_for(
|
|
98
|
-
loop.run_in_executor(executor, doc_ref.get),
|
|
99
|
-
timeout=timeout_seconds
|
|
100
|
-
)
|
|
101
|
-
except asyncio.TimeoutError:
|
|
102
|
-
error_message = f"User Status fetching for Authz timed out after {timeout_seconds} seconds, perhaps issue with Firestore Connectivity"
|
|
103
|
-
logger.error(error_message)
|
|
104
|
-
raise FirestoreTimeoutError(error_message)
|
|
105
|
-
|
|
106
|
-
# Update get_userstatus to use our new strict timeout function
|
|
107
68
|
async def get_userstatus(
|
|
108
69
|
user_uid: str,
|
|
109
|
-
|
|
110
|
-
force_fresh: bool = False
|
|
111
|
-
|
|
112
|
-
) -> tuple[Dict[str, Any], bool]:
|
|
70
|
+
user_core_service: UserCoreService,
|
|
71
|
+
force_fresh: bool = False
|
|
72
|
+
) -> tuple[UserStatus, bool]:
|
|
113
73
|
"""
|
|
114
|
-
|
|
74
|
+
Lightweight fetch of user status with caching.
|
|
75
|
+
Returns UserStatus objects directly for better performance.
|
|
115
76
|
|
|
116
77
|
Args:
|
|
117
78
|
user_uid: User ID to fetch status for
|
|
118
|
-
|
|
79
|
+
user_core_service: UserCoreService for data retrieval
|
|
119
80
|
force_fresh: Whether to bypass cache
|
|
120
|
-
timeout: Timeout for Firestore operations in seconds
|
|
121
81
|
|
|
122
82
|
Returns:
|
|
123
|
-
Tuple of (
|
|
83
|
+
Tuple of (UserStatus object, whether cache was used)
|
|
124
84
|
"""
|
|
125
85
|
cache_used = False
|
|
86
|
+
|
|
87
|
+
# Check cache first unless forced fresh
|
|
126
88
|
if not force_fresh:
|
|
127
89
|
cached_status = userstatus_cache.get(user_uid)
|
|
128
90
|
if cached_status:
|
|
@@ -130,60 +92,37 @@ async def get_userstatus(
|
|
|
130
92
|
return cached_status, cache_used
|
|
131
93
|
|
|
132
94
|
try:
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
user_ref = db.collection(USERS_STATUS_COLLECTION_NAME).document(userstatus_id)
|
|
136
|
-
|
|
137
|
-
logger.debug(f"Fetching user status for {user_uid} with strict timeout {timeout}s")
|
|
138
|
-
|
|
139
|
-
# Use our strict timeout wrapper instead of the native timeout parameter
|
|
140
|
-
snapshot = await get_with_strict_timeout(user_ref, timeout)
|
|
141
|
-
|
|
142
|
-
if not snapshot.exists:
|
|
143
|
-
# Log at DEBUG level since this might be expected for new users
|
|
144
|
-
logger.debug(f"User status document not found for user {user_uid} (document: {userstatus_id})")
|
|
95
|
+
status_obj = await user_core_service.get_userstatus(user_uid)
|
|
96
|
+
if not status_obj:
|
|
145
97
|
raise ResourceNotFoundError(
|
|
146
|
-
resource_type="
|
|
147
|
-
resource_id=
|
|
148
|
-
additional_info={"
|
|
98
|
+
resource_type="UserStatus",
|
|
99
|
+
resource_id=user_uid,
|
|
100
|
+
additional_info={"message": "User status not found"}
|
|
149
101
|
)
|
|
150
102
|
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
103
|
+
# Ensure we have a UserStatus object
|
|
104
|
+
if not isinstance(status_obj, UserStatus):
|
|
105
|
+
# If it's a dict, convert to UserStatus
|
|
106
|
+
if isinstance(status_obj, dict):
|
|
107
|
+
status_obj = UserStatus(**status_obj)
|
|
108
|
+
else:
|
|
109
|
+
raise ValueError(f"Expected UserStatus object or dict, got {type(status_obj)}")
|
|
157
110
|
|
|
158
|
-
except ResourceNotFoundError:
|
|
159
|
-
# Re-raise ResourceNotFoundError as-is - don't wrap in ServiceError
|
|
160
|
-
raise
|
|
161
|
-
except (TimeoutError, FirestoreTimeoutError) as e:
|
|
162
|
-
logger.error(f"Timeout while fetching user status for {user_uid}: {str(e)}")
|
|
163
|
-
raise ServiceError(
|
|
164
|
-
operation="fetching user status for authz",
|
|
165
|
-
error=e,
|
|
166
|
-
resource_type="userstatus",
|
|
167
|
-
resource_id=user_uid,
|
|
168
|
-
additional_info={
|
|
169
|
-
"force_fresh": force_fresh,
|
|
170
|
-
"collection": USERS_STATUS_COLLECTION_NAME,
|
|
171
|
-
"timeout_seconds": timeout
|
|
172
|
-
}
|
|
173
|
-
)
|
|
174
111
|
except Exception as e:
|
|
175
|
-
logger.error(f"Error fetching user status
|
|
112
|
+
logger.error(f"Error fetching user status via UserCoreService: {str(e)}")
|
|
176
113
|
raise ServiceError(
|
|
177
|
-
operation=
|
|
114
|
+
operation="fetching user status for authz via UserCoreService",
|
|
178
115
|
error=e,
|
|
179
|
-
resource_type="
|
|
180
|
-
resource_id=user_uid
|
|
181
|
-
additional_info={
|
|
182
|
-
"force_fresh": force_fresh,
|
|
183
|
-
"collection": USERS_STATUS_COLLECTION_NAME
|
|
184
|
-
}
|
|
116
|
+
resource_type="UserStatus",
|
|
117
|
+
resource_id=user_uid
|
|
185
118
|
) from e
|
|
186
119
|
|
|
120
|
+
# Cache the UserStatus object
|
|
121
|
+
if not force_fresh:
|
|
122
|
+
userstatus_cache.set(user_uid, status_obj)
|
|
123
|
+
|
|
124
|
+
return status_obj, cache_used
|
|
125
|
+
|
|
187
126
|
def _validate_resource_fields(fields: Dict[str, Any]) -> List[str]:
|
|
188
127
|
"""
|
|
189
128
|
Filter out invalid fields similar to BaseFirestoreService validation.
|
|
@@ -228,19 +167,18 @@ async def extract_request_fields(request: Request) -> Optional[List[str]]:
|
|
|
228
167
|
# Main authorization function with configurable timeout
|
|
229
168
|
async def authorizeAPIRequest(
|
|
230
169
|
request: Request,
|
|
231
|
-
|
|
170
|
+
user_core_service: UserCoreService, # UserCoreService instance for better integration
|
|
232
171
|
request_resource_fields: Optional[Iterable[str]] = None,
|
|
233
|
-
|
|
172
|
+
|
|
234
173
|
) -> Dict[str, Any]:
|
|
235
174
|
"""
|
|
236
175
|
Authorize API request based on user status and OPA policies.
|
|
237
|
-
Enhanced
|
|
176
|
+
Enhanced to use UserCoreService when available and fetch usertype from Firebase custom claims.
|
|
238
177
|
|
|
239
178
|
Args:
|
|
240
179
|
request: The incoming request
|
|
241
|
-
|
|
180
|
+
user_core_service: UserCoreService instance for better integration
|
|
242
181
|
request_resource_fields: Fields being accessed/modified in the request
|
|
243
|
-
firestore_timeout: Timeout for Firestore operations in seconds
|
|
244
182
|
|
|
245
183
|
Returns:
|
|
246
184
|
Authorization result containing decision details
|
|
@@ -254,34 +192,76 @@ async def authorizeAPIRequest(
|
|
|
254
192
|
if not request_resource_fields:
|
|
255
193
|
request_resource_fields = await extract_request_fields(request)
|
|
256
194
|
|
|
257
|
-
# Extract request context
|
|
258
|
-
|
|
195
|
+
# Extract request context and Firebase user claims
|
|
196
|
+
firebase_user = request.state.user
|
|
197
|
+
user_uid = firebase_user.get('uid')
|
|
259
198
|
if not user_uid:
|
|
260
|
-
# Log authorization failures at DEBUG level, not ERROR
|
|
261
199
|
logger.debug(f"Authorization denied for {request.method} {request.url.path}: No user UID found")
|
|
262
200
|
raise HTTPException(
|
|
263
201
|
status_code=403,
|
|
264
202
|
detail="Not authorized to access this resource"
|
|
265
203
|
)
|
|
266
204
|
|
|
267
|
-
#
|
|
205
|
+
# Get usertype information from Firebase custom claims (primary source)
|
|
206
|
+
custom_claims = firebase_user.get('custom_claims', {}) or firebase_user.get('claims', {})
|
|
207
|
+
primary_usertype = custom_claims.get('primary_usertype')
|
|
208
|
+
secondary_usertypes = custom_claims.get('secondary_usertypes', [])
|
|
209
|
+
user_approval_status = custom_claims.get('user_approval_status', str(ApprovalStatus.UNKNOWN))
|
|
210
|
+
|
|
211
|
+
# Determine if we need fresh status for permissions and credits
|
|
268
212
|
force_fresh = _should_force_fresh_status(request)
|
|
269
|
-
|
|
270
|
-
user_uid,
|
|
271
|
-
|
|
213
|
+
user_status_obj, cache_used = await get_userstatus(
|
|
214
|
+
user_uid=user_uid,
|
|
215
|
+
user_core_service=user_core_service,
|
|
272
216
|
force_fresh=force_fresh,
|
|
273
|
-
timeout=firestore_timeout # Pass the specified timeout
|
|
274
217
|
)
|
|
275
218
|
|
|
276
|
-
#
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
219
|
+
# Perform comprehensive review and cleanup synchronously to ensure accurate auth data
|
|
220
|
+
if user_core_service:
|
|
221
|
+
try:
|
|
222
|
+
review_result = await user_core_service.review_and_clean_active_subscription_credits_and_permissions(
|
|
223
|
+
user_uid=user_uid,
|
|
224
|
+
updater_uid="Auto-AuthzMiddlewareDependency",
|
|
225
|
+
review_auto_renewal=True,
|
|
226
|
+
apply_fallback=True,
|
|
227
|
+
clean_expired_permissions=True,
|
|
228
|
+
review_credits=True
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
# Refresh user status after comprehensive review if any actions were taken
|
|
232
|
+
if review_result.get('actions_taken'):
|
|
233
|
+
logger.info(f"Auth middleware performed comprehensive review for user {user_uid}: {review_result['actions_taken']}")
|
|
234
|
+
# Use the updated UserStatus returned from the review function
|
|
235
|
+
if 'updated_userstatus' in review_result:
|
|
236
|
+
user_status_obj = review_result['updated_userstatus']
|
|
237
|
+
# Invalidate cache since we updated the user status
|
|
238
|
+
userstatus_cache.invalidate(user_uid)
|
|
239
|
+
# Update cache with the new status
|
|
240
|
+
userstatus_cache.set(user_uid, user_status_obj)
|
|
241
|
+
|
|
242
|
+
except Exception as e:
|
|
243
|
+
logger.warning(f"Comprehensive review failed for user {user_uid} during auth: {str(e)}")
|
|
244
|
+
# Continue with existing status if review fails
|
|
245
|
+
|
|
246
|
+
# Get valid permissions after comprehensive review
|
|
247
|
+
valid_permissions_objs = user_status_obj.get_valid_permissions()
|
|
248
|
+
|
|
249
|
+
# Convert UserPermission objects to minimal serializable format for OPA
|
|
250
|
+
valid_permissions = [
|
|
251
|
+
{
|
|
252
|
+
"domain": perm.domain,
|
|
253
|
+
"iam_unit_type": str(perm.iam_unit_type), # Convert enum to string
|
|
254
|
+
"permission_ref": perm.permission_ref
|
|
255
|
+
}
|
|
256
|
+
for perm in valid_permissions_objs
|
|
257
|
+
]
|
|
280
258
|
|
|
281
|
-
# Extract
|
|
282
|
-
|
|
259
|
+
# Extract active subscription plan ID
|
|
260
|
+
active_subscription_plan_id = None
|
|
261
|
+
if user_status_obj.active_subscription is not None:
|
|
262
|
+
active_subscription_plan_id = user_status_obj.active_subscription.plan_id
|
|
283
263
|
|
|
284
|
-
# Format the authz_input
|
|
264
|
+
# Format the authz_input for OPA (optimized for speed)
|
|
285
265
|
authz_input = {
|
|
286
266
|
"api_url": request.url.path,
|
|
287
267
|
"requestor": {
|
|
@@ -289,39 +269,34 @@ async def authorizeAPIRequest(
|
|
|
289
269
|
"primary_usertype": primary_usertype,
|
|
290
270
|
"secondary_usertypes": secondary_usertypes,
|
|
291
271
|
"usertypes": [primary_usertype] + secondary_usertypes if primary_usertype else secondary_usertypes,
|
|
292
|
-
"email_verified":
|
|
293
|
-
"
|
|
294
|
-
"
|
|
295
|
-
"
|
|
272
|
+
"email_verified": firebase_user.get("email_verified", False),
|
|
273
|
+
"user_approval_status": user_approval_status,
|
|
274
|
+
"iam_permissions": valid_permissions,
|
|
275
|
+
"sbscrptn_based_insight_credits": user_status_obj.sbscrptn_based_insight_credits or 0,
|
|
276
|
+
"extra_insight_credits": user_status_obj.extra_insight_credits or 0,
|
|
277
|
+
"active_subscription_plan_id": active_subscription_plan_id
|
|
296
278
|
},
|
|
297
279
|
"method": request.method.lower(),
|
|
298
280
|
"request_resource_fields": request_resource_fields
|
|
299
281
|
}
|
|
300
282
|
|
|
301
|
-
#
|
|
302
|
-
#
|
|
303
|
-
|
|
283
|
+
# PERFORMANCE OPTIMIZATION: Skip convert_to_json_serializable() and json.dumps()
|
|
284
|
+
# The authz_input structure above contains only JSON-safe types:
|
|
285
|
+
# - strings, integers, booleans, lists of strings, and simple dicts
|
|
286
|
+
# - No datetime objects, enums, or complex Pydantic models
|
|
287
|
+
# - httpx.post(json=...) handles serialization efficiently
|
|
288
|
+
# This saves ~2-5ms per request on a high-frequency auth endpoint
|
|
304
289
|
|
|
305
|
-
# Query OPA
|
|
290
|
+
# Query OPA (optimized for speed - no unnecessary serialization)
|
|
306
291
|
opa_url = f"{os.getenv('OPA_SERVER_URL', 'http://localhost:8181')}{os.getenv('OPA_DECISION_PATH', '/v1/data/http/authz/ingress/decision')}"
|
|
307
|
-
logger.debug(f"Attempting to connect to OPA at: {opa_url}")
|
|
308
|
-
|
|
309
|
-
# Debug: Print raw JSON payload to identify any potential issues
|
|
310
|
-
try:
|
|
311
|
-
payload_json = json.dumps({"input": json_safe_authz_input})
|
|
312
|
-
logger.debug(f"OPA Request JSON payload: {payload_json}")
|
|
313
|
-
except Exception as json_err:
|
|
314
|
-
logger.error(f"Error serializing OPA request payload: {json_err}")
|
|
315
292
|
|
|
316
293
|
async with httpx.AsyncClient() as client:
|
|
317
294
|
try:
|
|
318
295
|
response = await client.post(
|
|
319
296
|
opa_url,
|
|
320
|
-
json={"input":
|
|
321
|
-
timeout=5.0
|
|
297
|
+
json={"input": authz_input},
|
|
298
|
+
timeout=5.0
|
|
322
299
|
)
|
|
323
|
-
logger.debug(f"OPA Response Status: {response.status_code}")
|
|
324
|
-
# logger.debug(f"OPA Response Body: {response.text}")
|
|
325
300
|
|
|
326
301
|
if response.status_code != 200:
|
|
327
302
|
logger.error(f"OPA authorization failed: {response.text}")
|
|
@@ -331,36 +306,32 @@ async def authorizeAPIRequest(
|
|
|
331
306
|
)
|
|
332
307
|
|
|
333
308
|
result = response.json()
|
|
334
|
-
logger.debug(f"Parsed OPA response: {result}")
|
|
335
309
|
|
|
336
|
-
# Handle
|
|
337
|
-
if "result" in result:
|
|
338
|
-
|
|
339
|
-
else:
|
|
340
|
-
logger.warning(f"OPA response missing 'result' field, using default")
|
|
310
|
+
# Handle OPA response format
|
|
311
|
+
if "result" not in result:
|
|
312
|
+
logger.warning("OPA response missing 'result' field")
|
|
341
313
|
raise HTTPException(
|
|
342
314
|
status_code=500,
|
|
343
315
|
detail="Authorization service error: OPA response format unexpected"
|
|
344
316
|
)
|
|
345
317
|
|
|
346
|
-
|
|
318
|
+
opa_decision = result["result"]
|
|
347
319
|
allow = opa_decision.get("allow", False)
|
|
348
320
|
|
|
349
|
-
# Handle authorization denial
|
|
321
|
+
# Handle authorization denial
|
|
350
322
|
if not allow:
|
|
351
|
-
logger.debug(f"Authorization denied for {request.method} {request.url.path}
|
|
323
|
+
logger.debug(f"Authorization denied for {request.method} {request.url.path}")
|
|
352
324
|
raise HTTPException(
|
|
353
325
|
status_code=403,
|
|
354
326
|
detail=f"Not authorized to {request.method} {request.url.path}"
|
|
355
327
|
)
|
|
356
328
|
|
|
357
329
|
except httpx.RequestError as e:
|
|
358
|
-
# Only log actual system errors at ERROR level
|
|
359
330
|
logger.error(f"Failed to connect to OPA: {str(e)}")
|
|
360
331
|
raise HTTPException(
|
|
361
332
|
status_code=500,
|
|
362
333
|
detail="Authorization service temporarily unavailable"
|
|
363
|
-
)
|
|
334
|
+
) from e
|
|
364
335
|
|
|
365
336
|
# More descriptive metadata about the data freshness
|
|
366
337
|
return {
|
|
@@ -21,7 +21,7 @@ class BaseServiceException(HTTPException):
|
|
|
21
21
|
self.original_error = original_error
|
|
22
22
|
|
|
23
23
|
# Get full traceback if there's an original error
|
|
24
|
-
if original_error:
|
|
24
|
+
if original_error and hasattr(original_error, '__traceback__'):
|
|
25
25
|
self.traceback = ''.join(traceback.format_exception(
|
|
26
26
|
type(original_error),
|
|
27
27
|
original_error,
|
|
@@ -69,18 +69,26 @@ class ServiceError(BaseServiceException):
|
|
|
69
69
|
def __init__(
|
|
70
70
|
self,
|
|
71
71
|
operation: str,
|
|
72
|
-
error:
|
|
72
|
+
error: Any, # Allow string or exception
|
|
73
73
|
resource_type: str,
|
|
74
74
|
resource_id: Optional[str] = None,
|
|
75
75
|
additional_info: Optional[Dict[str, Any]] = None
|
|
76
76
|
):
|
|
77
|
+
# If a string is passed as an error, wrap it in a generic Exception
|
|
78
|
+
if isinstance(error, str):
|
|
79
|
+
original_error = Exception(error)
|
|
80
|
+
error_detail = error
|
|
81
|
+
else:
|
|
82
|
+
original_error = error
|
|
83
|
+
error_detail = str(error)
|
|
84
|
+
|
|
77
85
|
super().__init__(
|
|
78
86
|
status_code=500,
|
|
79
|
-
detail=f"Error during {operation}: {
|
|
87
|
+
detail=f"Error during {operation}: {error_detail}",
|
|
80
88
|
resource_type=resource_type,
|
|
81
89
|
resource_id=resource_id,
|
|
82
90
|
additional_info=additional_info,
|
|
83
|
-
original_error=
|
|
91
|
+
original_error=original_error
|
|
84
92
|
)
|
|
85
93
|
|
|
86
94
|
|
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
from .base_data_model import BaseDataModel
|
|
2
2
|
from .base_api_response import BaseAPIResponse , CustomJSONResponse, CreditChargeableAPIResponse, UserCreditBalance, UpdatedUserCreditInfo
|
|
3
|
-
from .user import UserProfile, UserSubscription, UserStatus, UserAuth, UserPermission
|
|
3
|
+
from .user import UserProfile, UserSubscription, UserStatus, UserAuth, UserAuthCreateNew, UserPermission
|
|
4
4
|
from .catalog import SubscriptionPlan, ProrationMethod, PlanUpgradePath, UserType
|
|
@@ -7,6 +7,7 @@ These templates are used to create actual user subscriptions with consistent set
|
|
|
7
7
|
|
|
8
8
|
from typing import Dict, Any, Optional, ClassVar, List
|
|
9
9
|
from enum import StrEnum
|
|
10
|
+
from datetime import datetime, timezone, timedelta
|
|
10
11
|
from pydantic import Field, ConfigDict, field_validator,model_validator, BaseModel
|
|
11
12
|
from ipulse_shared_base_ftredge import (Layer, Module, list_enums_as_lower_strings,
|
|
12
13
|
Subject, SubscriptionPlanName,ObjectOverallStatus,
|
|
@@ -187,8 +188,8 @@ class SubscriptionPlan(BaseDataModel):
|
|
|
187
188
|
)
|
|
188
189
|
|
|
189
190
|
# Default settings
|
|
190
|
-
|
|
191
|
-
|
|
191
|
+
plan_default_auto_renewal_end: Optional[datetime] = Field(
|
|
192
|
+
default=None,
|
|
192
193
|
description="Default auto-renewal setting for new subscriptions",
|
|
193
194
|
frozen=True
|
|
194
195
|
)
|
|
@@ -201,7 +202,7 @@ class SubscriptionPlan(BaseDataModel):
|
|
|
201
202
|
|
|
202
203
|
# Fallback configuration
|
|
203
204
|
fallback_plan_id_if_current_plan_expired: Optional[str] = Field(
|
|
204
|
-
|
|
205
|
+
None,
|
|
205
206
|
description="Plan to fall back to when this plan expires (None for no fallback)",
|
|
206
207
|
frozen=True
|
|
207
208
|
)
|
|
@@ -7,6 +7,7 @@ based on their user type (superadmin, admin, internal, authenticated, anonymous)
|
|
|
7
7
|
"""
|
|
8
8
|
|
|
9
9
|
from typing import Dict, Any, Optional, ClassVar, List
|
|
10
|
+
from datetime import datetime
|
|
10
11
|
from pydantic import Field, ConfigDict, field_validator, model_validator
|
|
11
12
|
from ipulse_shared_base_ftredge import Layer, Module, list_enums_as_lower_strings, Subject, ObjectOverallStatus
|
|
12
13
|
from ipulse_shared_base_ftredge.enums.enums_iam import IAMUserType
|
|
@@ -97,12 +98,18 @@ class UserType(BaseDataModel):
|
|
|
97
98
|
)
|
|
98
99
|
|
|
99
100
|
# Subscription defaults
|
|
100
|
-
|
|
101
|
+
default_subscriptionplan_if_unpaid: Optional[str] = Field(
|
|
101
102
|
default=None,
|
|
102
103
|
description="Default subscription plan ID to assign if user has no active subscription",
|
|
103
104
|
frozen=True
|
|
104
105
|
)
|
|
105
106
|
|
|
107
|
+
default_subscriptionplan_auto_renewal_end: Optional[datetime] = Field(
|
|
108
|
+
default=None,
|
|
109
|
+
description="Default auto-renewal end date to apply when assigning default_subscriptionplan_if_unpaid",
|
|
110
|
+
frozen=True
|
|
111
|
+
)
|
|
112
|
+
|
|
106
113
|
# Additional metadata
|
|
107
114
|
metadata: Dict[str, Any] = Field(
|
|
108
115
|
default_factory=dict,
|