geek-cafe-saas-sdk 0.6.0__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.
Potentially problematic release.
This version of geek-cafe-saas-sdk might be problematic. Click here for more details.
- geek_cafe_saas_sdk/__init__.py +9 -0
- geek_cafe_saas_sdk/core/__init__.py +11 -0
- geek_cafe_saas_sdk/core/audit_mixin.py +33 -0
- geek_cafe_saas_sdk/core/error_codes.py +132 -0
- geek_cafe_saas_sdk/core/service_errors.py +19 -0
- geek_cafe_saas_sdk/core/service_result.py +121 -0
- geek_cafe_saas_sdk/decorators/__init__.py +64 -0
- geek_cafe_saas_sdk/decorators/auth.py +373 -0
- geek_cafe_saas_sdk/decorators/core.py +358 -0
- geek_cafe_saas_sdk/domains/__init__.py +0 -0
- geek_cafe_saas_sdk/domains/analytics/__init__.py +0 -0
- geek_cafe_saas_sdk/domains/analytics/handlers/__init__.py +0 -0
- geek_cafe_saas_sdk/domains/analytics/models/__init__.py +9 -0
- geek_cafe_saas_sdk/domains/analytics/models/website_analytics.py +219 -0
- geek_cafe_saas_sdk/domains/analytics/models/website_analytics_summary.py +220 -0
- geek_cafe_saas_sdk/domains/analytics/services/__init__.py +11 -0
- geek_cafe_saas_sdk/domains/analytics/services/website_analytics_service.py +232 -0
- geek_cafe_saas_sdk/domains/analytics/services/website_analytics_summary_service.py +212 -0
- geek_cafe_saas_sdk/domains/analytics/services/website_analytics_tally_service.py +610 -0
- geek_cafe_saas_sdk/domains/auth/__init__.py +0 -0
- geek_cafe_saas_sdk/domains/auth/handlers/__init__.py +0 -0
- geek_cafe_saas_sdk/domains/auth/handlers/users/create/app.py +41 -0
- geek_cafe_saas_sdk/domains/auth/handlers/users/delete/app.py +41 -0
- geek_cafe_saas_sdk/domains/auth/handlers/users/get/app.py +39 -0
- geek_cafe_saas_sdk/domains/auth/handlers/users/list/app.py +36 -0
- geek_cafe_saas_sdk/domains/auth/handlers/users/update/app.py +44 -0
- geek_cafe_saas_sdk/domains/auth/models/__init__.py +13 -0
- geek_cafe_saas_sdk/domains/auth/models/permission.py +134 -0
- geek_cafe_saas_sdk/domains/auth/models/resource_permission.py +245 -0
- geek_cafe_saas_sdk/domains/auth/models/role.py +213 -0
- geek_cafe_saas_sdk/domains/auth/models/user.py +285 -0
- geek_cafe_saas_sdk/domains/auth/services/__init__.py +16 -0
- geek_cafe_saas_sdk/domains/auth/services/authorization_service.py +376 -0
- geek_cafe_saas_sdk/domains/auth/services/permission_registry.py +464 -0
- geek_cafe_saas_sdk/domains/auth/services/resource_permission_service.py +408 -0
- geek_cafe_saas_sdk/domains/auth/services/user_service.py +274 -0
- geek_cafe_saas_sdk/domains/communities/__init__.py +0 -0
- geek_cafe_saas_sdk/domains/communities/handlers/__init__.py +0 -0
- geek_cafe_saas_sdk/domains/communities/handlers/communities/create/app.py +41 -0
- geek_cafe_saas_sdk/domains/communities/handlers/communities/delete/app.py +41 -0
- geek_cafe_saas_sdk/domains/communities/handlers/communities/get/app.py +39 -0
- geek_cafe_saas_sdk/domains/communities/handlers/communities/list/app.py +36 -0
- geek_cafe_saas_sdk/domains/communities/handlers/communities/update/app.py +44 -0
- geek_cafe_saas_sdk/domains/communities/models/__init__.py +6 -0
- geek_cafe_saas_sdk/domains/communities/models/community.py +326 -0
- geek_cafe_saas_sdk/domains/communities/models/community_member.py +227 -0
- geek_cafe_saas_sdk/domains/communities/services/__init__.py +6 -0
- geek_cafe_saas_sdk/domains/communities/services/community_member_service.py +412 -0
- geek_cafe_saas_sdk/domains/communities/services/community_service.py +479 -0
- geek_cafe_saas_sdk/domains/events/__init__.py +0 -0
- geek_cafe_saas_sdk/domains/events/handlers/__init__.py +0 -0
- geek_cafe_saas_sdk/domains/events/handlers/attendees/app.py +67 -0
- geek_cafe_saas_sdk/domains/events/handlers/cancel/app.py +66 -0
- geek_cafe_saas_sdk/domains/events/handlers/check_in/app.py +60 -0
- geek_cafe_saas_sdk/domains/events/handlers/create/app.py +93 -0
- geek_cafe_saas_sdk/domains/events/handlers/delete/app.py +42 -0
- geek_cafe_saas_sdk/domains/events/handlers/get/app.py +39 -0
- geek_cafe_saas_sdk/domains/events/handlers/invite/app.py +98 -0
- geek_cafe_saas_sdk/domains/events/handlers/list/app.py +125 -0
- geek_cafe_saas_sdk/domains/events/handlers/publish/app.py +49 -0
- geek_cafe_saas_sdk/domains/events/handlers/rsvp/app.py +83 -0
- geek_cafe_saas_sdk/domains/events/handlers/update/app.py +44 -0
- geek_cafe_saas_sdk/domains/events/models/__init__.py +3 -0
- geek_cafe_saas_sdk/domains/events/models/event.py +681 -0
- geek_cafe_saas_sdk/domains/events/models/event_attendee.py +324 -0
- geek_cafe_saas_sdk/domains/events/services/__init__.py +9 -0
- geek_cafe_saas_sdk/domains/events/services/event_attendee_service.py +571 -0
- geek_cafe_saas_sdk/domains/events/services/event_service.py +684 -0
- geek_cafe_saas_sdk/domains/files/__init__.py +0 -0
- geek_cafe_saas_sdk/domains/files/models/__init__.py +0 -0
- geek_cafe_saas_sdk/domains/files/models/directory.py +258 -0
- geek_cafe_saas_sdk/domains/files/models/file.py +312 -0
- geek_cafe_saas_sdk/domains/files/models/file_share.py +268 -0
- geek_cafe_saas_sdk/domains/files/models/file_version.py +216 -0
- geek_cafe_saas_sdk/domains/files/services/__init__.py +0 -0
- geek_cafe_saas_sdk/domains/files/services/directory_service.py +701 -0
- geek_cafe_saas_sdk/domains/files/services/file_share_service.py +663 -0
- geek_cafe_saas_sdk/domains/files/services/file_system_service.py +575 -0
- geek_cafe_saas_sdk/domains/files/services/file_version_service.py +739 -0
- geek_cafe_saas_sdk/domains/files/services/s3_file_service.py +501 -0
- geek_cafe_saas_sdk/domains/messaging/__init__.py +0 -0
- geek_cafe_saas_sdk/domains/messaging/handlers/__init__.py +0 -0
- geek_cafe_saas_sdk/domains/messaging/handlers/chat_channels/create/app.py +86 -0
- geek_cafe_saas_sdk/domains/messaging/handlers/chat_channels/delete/app.py +65 -0
- geek_cafe_saas_sdk/domains/messaging/handlers/chat_channels/get/app.py +64 -0
- geek_cafe_saas_sdk/domains/messaging/handlers/chat_channels/list/app.py +97 -0
- geek_cafe_saas_sdk/domains/messaging/handlers/chat_channels/update/app.py +149 -0
- geek_cafe_saas_sdk/domains/messaging/handlers/chat_messages/create/app.py +67 -0
- geek_cafe_saas_sdk/domains/messaging/handlers/chat_messages/delete/app.py +65 -0
- geek_cafe_saas_sdk/domains/messaging/handlers/chat_messages/get/app.py +64 -0
- geek_cafe_saas_sdk/domains/messaging/handlers/chat_messages/list/app.py +102 -0
- geek_cafe_saas_sdk/domains/messaging/handlers/chat_messages/update/app.py +127 -0
- geek_cafe_saas_sdk/domains/messaging/handlers/contact_threads/create/app.py +94 -0
- geek_cafe_saas_sdk/domains/messaging/handlers/contact_threads/delete/app.py +66 -0
- geek_cafe_saas_sdk/domains/messaging/handlers/contact_threads/get/app.py +67 -0
- geek_cafe_saas_sdk/domains/messaging/handlers/contact_threads/list/app.py +95 -0
- geek_cafe_saas_sdk/domains/messaging/handlers/contact_threads/update/app.py +156 -0
- geek_cafe_saas_sdk/domains/messaging/models/__init__.py +13 -0
- geek_cafe_saas_sdk/domains/messaging/models/chat_channel.py +337 -0
- geek_cafe_saas_sdk/domains/messaging/models/chat_channel_member.py +180 -0
- geek_cafe_saas_sdk/domains/messaging/models/chat_message.py +426 -0
- geek_cafe_saas_sdk/domains/messaging/models/contact_thread.py +392 -0
- geek_cafe_saas_sdk/domains/messaging/services/__init__.py +11 -0
- geek_cafe_saas_sdk/domains/messaging/services/chat_channel_service.py +700 -0
- geek_cafe_saas_sdk/domains/messaging/services/chat_message_service.py +491 -0
- geek_cafe_saas_sdk/domains/messaging/services/contact_thread_service.py +497 -0
- geek_cafe_saas_sdk/domains/tenancy/__init__.py +0 -0
- geek_cafe_saas_sdk/domains/tenancy/handlers/__init__.py +0 -0
- geek_cafe_saas_sdk/domains/tenancy/handlers/subscriptions/activate/app.py +52 -0
- geek_cafe_saas_sdk/domains/tenancy/handlers/subscriptions/active/app.py +37 -0
- geek_cafe_saas_sdk/domains/tenancy/handlers/subscriptions/cancel/app.py +55 -0
- geek_cafe_saas_sdk/domains/tenancy/handlers/subscriptions/get/app.py +39 -0
- geek_cafe_saas_sdk/domains/tenancy/handlers/subscriptions/list/app.py +44 -0
- geek_cafe_saas_sdk/domains/tenancy/handlers/subscriptions/record_payment/app.py +56 -0
- geek_cafe_saas_sdk/domains/tenancy/handlers/tenants/get/app.py +39 -0
- geek_cafe_saas_sdk/domains/tenancy/handlers/tenants/me/app.py +37 -0
- geek_cafe_saas_sdk/domains/tenancy/handlers/tenants/signup/app.py +61 -0
- geek_cafe_saas_sdk/domains/tenancy/handlers/tenants/update/app.py +44 -0
- geek_cafe_saas_sdk/domains/tenancy/models/__init__.py +6 -0
- geek_cafe_saas_sdk/domains/tenancy/models/subscription.py +440 -0
- geek_cafe_saas_sdk/domains/tenancy/models/tenant.py +258 -0
- geek_cafe_saas_sdk/domains/tenancy/services/__init__.py +6 -0
- geek_cafe_saas_sdk/domains/tenancy/services/subscription_service.py +557 -0
- geek_cafe_saas_sdk/domains/tenancy/services/tenant_service.py +575 -0
- geek_cafe_saas_sdk/domains/voting/__init__.py +0 -0
- geek_cafe_saas_sdk/domains/voting/handlers/__init__.py +0 -0
- geek_cafe_saas_sdk/domains/voting/handlers/votes/create/app.py +128 -0
- geek_cafe_saas_sdk/domains/voting/handlers/votes/delete/app.py +41 -0
- geek_cafe_saas_sdk/domains/voting/handlers/votes/get/app.py +39 -0
- geek_cafe_saas_sdk/domains/voting/handlers/votes/list/app.py +38 -0
- geek_cafe_saas_sdk/domains/voting/handlers/votes/summerize/README.md +3 -0
- geek_cafe_saas_sdk/domains/voting/handlers/votes/update/app.py +44 -0
- geek_cafe_saas_sdk/domains/voting/models/__init__.py +9 -0
- geek_cafe_saas_sdk/domains/voting/models/vote.py +231 -0
- geek_cafe_saas_sdk/domains/voting/models/vote_summary.py +193 -0
- geek_cafe_saas_sdk/domains/voting/services/__init__.py +11 -0
- geek_cafe_saas_sdk/domains/voting/services/vote_service.py +264 -0
- geek_cafe_saas_sdk/domains/voting/services/vote_summary_service.py +198 -0
- geek_cafe_saas_sdk/domains/voting/services/vote_tally_service.py +533 -0
- geek_cafe_saas_sdk/lambda_handlers/README.md +404 -0
- geek_cafe_saas_sdk/lambda_handlers/__init__.py +67 -0
- geek_cafe_saas_sdk/lambda_handlers/_base/__init__.py +25 -0
- geek_cafe_saas_sdk/lambda_handlers/_base/api_key_handler.py +129 -0
- geek_cafe_saas_sdk/lambda_handlers/_base/authorized_secure_handler.py +218 -0
- geek_cafe_saas_sdk/lambda_handlers/_base/base_handler.py +185 -0
- geek_cafe_saas_sdk/lambda_handlers/_base/handler_factory.py +256 -0
- geek_cafe_saas_sdk/lambda_handlers/_base/public_handler.py +53 -0
- geek_cafe_saas_sdk/lambda_handlers/_base/secure_handler.py +89 -0
- geek_cafe_saas_sdk/lambda_handlers/_base/service_pool.py +94 -0
- geek_cafe_saas_sdk/lambda_handlers/directories/create/app.py +79 -0
- geek_cafe_saas_sdk/lambda_handlers/directories/delete/app.py +76 -0
- geek_cafe_saas_sdk/lambda_handlers/directories/get/app.py +74 -0
- geek_cafe_saas_sdk/lambda_handlers/directories/list/app.py +75 -0
- geek_cafe_saas_sdk/lambda_handlers/directories/move/app.py +79 -0
- geek_cafe_saas_sdk/lambda_handlers/files/delete/app.py +121 -0
- geek_cafe_saas_sdk/lambda_handlers/files/download/app.py +187 -0
- geek_cafe_saas_sdk/lambda_handlers/files/get/app.py +127 -0
- geek_cafe_saas_sdk/lambda_handlers/files/list/app.py +108 -0
- geek_cafe_saas_sdk/lambda_handlers/files/share/app.py +83 -0
- geek_cafe_saas_sdk/lambda_handlers/files/shares/list/app.py +84 -0
- geek_cafe_saas_sdk/lambda_handlers/files/shares/revoke/app.py +76 -0
- geek_cafe_saas_sdk/lambda_handlers/files/update/app.py +143 -0
- geek_cafe_saas_sdk/lambda_handlers/files/upload/app.py +151 -0
- geek_cafe_saas_sdk/middleware/__init__.py +36 -0
- geek_cafe_saas_sdk/middleware/auth.py +85 -0
- geek_cafe_saas_sdk/middleware/authorization.py +523 -0
- geek_cafe_saas_sdk/middleware/cors.py +63 -0
- geek_cafe_saas_sdk/middleware/error_handling.py +114 -0
- geek_cafe_saas_sdk/middleware/validation.py +80 -0
- geek_cafe_saas_sdk/models/__init__.py +20 -0
- geek_cafe_saas_sdk/models/base_model.py +233 -0
- geek_cafe_saas_sdk/services/__init__.py +18 -0
- geek_cafe_saas_sdk/services/database_service.py +441 -0
- geek_cafe_saas_sdk/utilities/__init__.py +88 -0
- geek_cafe_saas_sdk/utilities/cognito_utility.py +568 -0
- geek_cafe_saas_sdk/utilities/custom_exceptions.py +183 -0
- geek_cafe_saas_sdk/utilities/datetime_utility.py +410 -0
- geek_cafe_saas_sdk/utilities/dictionary_utility.py +78 -0
- geek_cafe_saas_sdk/utilities/dynamodb_utils.py +151 -0
- geek_cafe_saas_sdk/utilities/environment_loader.py +149 -0
- geek_cafe_saas_sdk/utilities/environment_variables.py +228 -0
- geek_cafe_saas_sdk/utilities/http_body_parameters.py +44 -0
- geek_cafe_saas_sdk/utilities/http_path_parameters.py +60 -0
- geek_cafe_saas_sdk/utilities/http_status_code.py +63 -0
- geek_cafe_saas_sdk/utilities/jwt_utility.py +234 -0
- geek_cafe_saas_sdk/utilities/lambda_event_utility.py +776 -0
- geek_cafe_saas_sdk/utilities/logging_utility.py +64 -0
- geek_cafe_saas_sdk/utilities/message_query_helper.py +340 -0
- geek_cafe_saas_sdk/utilities/response.py +209 -0
- geek_cafe_saas_sdk/utilities/string_functions.py +180 -0
- geek_cafe_saas_sdk-0.6.0.dist-info/METADATA +397 -0
- geek_cafe_saas_sdk-0.6.0.dist-info/RECORD +194 -0
- geek_cafe_saas_sdk-0.6.0.dist-info/WHEEL +4 -0
- geek_cafe_saas_sdk-0.6.0.dist-info/licenses/LICENSE +47 -0
|
@@ -0,0 +1,523 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Authorization middleware for hierarchical routing with tenant sharing support.
|
|
3
|
+
|
|
4
|
+
This module provides centralized authorization logic that supports:
|
|
5
|
+
- Multi-tenant access control
|
|
6
|
+
- Tenant-to-tenant resource sharing
|
|
7
|
+
- Role-based permissions (global admin, tenant admin, user)
|
|
8
|
+
- Operation-level access control (read, write, delete)
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import functools
|
|
13
|
+
from enum import Enum
|
|
14
|
+
from typing import Dict, Any, List, Optional, Tuple, Callable
|
|
15
|
+
from dataclasses import dataclass, field
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class Permission(Enum):
|
|
19
|
+
"""System-wide permissions."""
|
|
20
|
+
|
|
21
|
+
# Platform permissions
|
|
22
|
+
PLATFORM_ADMIN = "platform_admin"
|
|
23
|
+
PLATFORM_READ = "platform_read"
|
|
24
|
+
|
|
25
|
+
# Tenant permissions
|
|
26
|
+
TENANT_ADMIN = "tenant_admin"
|
|
27
|
+
TENANT_READ = "tenant_read"
|
|
28
|
+
TENANT_WRITE = "tenant_write"
|
|
29
|
+
|
|
30
|
+
# User permissions
|
|
31
|
+
USER_READ_OWN = "user_read_own"
|
|
32
|
+
USER_WRITE_OWN = "user_write_own"
|
|
33
|
+
USER_READ_OTHERS = "user_read_others"
|
|
34
|
+
USER_WRITE_OTHERS = "user_write_others"
|
|
35
|
+
|
|
36
|
+
# Shared resource permissions
|
|
37
|
+
USER_READ_SHARED = "user_read_shared"
|
|
38
|
+
USER_WRITE_SHARED = "user_write_shared"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class Operation(Enum):
|
|
42
|
+
"""Resource operations."""
|
|
43
|
+
READ = "read"
|
|
44
|
+
WRITE = "write"
|
|
45
|
+
DELETE = "delete"
|
|
46
|
+
CREATE = "create"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass
|
|
50
|
+
class AuthContext:
|
|
51
|
+
"""
|
|
52
|
+
Actor information from JWT.
|
|
53
|
+
|
|
54
|
+
Represents WHO is making the request and their permissions.
|
|
55
|
+
"""
|
|
56
|
+
user_id: str
|
|
57
|
+
tenant_id: str
|
|
58
|
+
roles: List[str] = field(default_factory=list)
|
|
59
|
+
permissions: List[str] = field(default_factory=list)
|
|
60
|
+
shared_tenants: List[str] = field(default_factory=list)
|
|
61
|
+
email: Optional[str] = None
|
|
62
|
+
name: Optional[str] = None
|
|
63
|
+
|
|
64
|
+
def has_permission(self, permission: Permission) -> bool:
|
|
65
|
+
"""Check if actor has a specific permission."""
|
|
66
|
+
return permission.value in self.permissions
|
|
67
|
+
|
|
68
|
+
def has_role(self, role: str) -> bool:
|
|
69
|
+
"""Check if actor has a specific role."""
|
|
70
|
+
return role in self.roles
|
|
71
|
+
|
|
72
|
+
def can_access_tenant(self, tenant_id: str) -> bool:
|
|
73
|
+
"""
|
|
74
|
+
Check if actor can access a specific tenant.
|
|
75
|
+
|
|
76
|
+
Returns True if:
|
|
77
|
+
- It's the actor's own tenant
|
|
78
|
+
- The tenant is in the actor's shared_tenants list
|
|
79
|
+
- The actor has global admin permission
|
|
80
|
+
"""
|
|
81
|
+
# Own tenant
|
|
82
|
+
if self.tenant_id == tenant_id:
|
|
83
|
+
return True
|
|
84
|
+
|
|
85
|
+
# Shared tenant
|
|
86
|
+
if tenant_id in self.shared_tenants:
|
|
87
|
+
return True
|
|
88
|
+
|
|
89
|
+
# Global admin can access any tenant
|
|
90
|
+
if self.has_permission(Permission.PLATFORM_ADMIN):
|
|
91
|
+
return True
|
|
92
|
+
|
|
93
|
+
return False
|
|
94
|
+
|
|
95
|
+
def is_own_tenant(self, tenant_id: str) -> bool:
|
|
96
|
+
"""Check if tenant_id is actor's own tenant."""
|
|
97
|
+
return self.tenant_id == tenant_id
|
|
98
|
+
|
|
99
|
+
def is_shared_tenant(self, tenant_id: str) -> bool:
|
|
100
|
+
"""Check if tenant_id is a shared tenant."""
|
|
101
|
+
return tenant_id in self.shared_tenants
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
@dataclass
|
|
105
|
+
class ResourceContext:
|
|
106
|
+
"""
|
|
107
|
+
Target resource information from path parameters.
|
|
108
|
+
|
|
109
|
+
Represents WHAT resource is being accessed.
|
|
110
|
+
"""
|
|
111
|
+
tenant_id: str
|
|
112
|
+
user_id: Optional[str] = None
|
|
113
|
+
resource_id: Optional[str] = None
|
|
114
|
+
resource_type: Optional[str] = None
|
|
115
|
+
|
|
116
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
117
|
+
"""Convert to dictionary."""
|
|
118
|
+
return {
|
|
119
|
+
'tenant_id': self.tenant_id,
|
|
120
|
+
'user_id': self.user_id,
|
|
121
|
+
'resource_id': self.resource_id,
|
|
122
|
+
'resource_type': self.resource_type
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
@dataclass
|
|
127
|
+
class AuthorizationResult:
|
|
128
|
+
"""Result of an authorization check."""
|
|
129
|
+
allowed: bool
|
|
130
|
+
reason: str
|
|
131
|
+
context: Optional[Dict[str, Any]] = None
|
|
132
|
+
|
|
133
|
+
@staticmethod
|
|
134
|
+
def allow(reason: str, context: Optional[Dict[str, Any]] = None) -> 'AuthorizationResult':
|
|
135
|
+
"""Create an allow result."""
|
|
136
|
+
return AuthorizationResult(allowed=True, reason=reason, context=context)
|
|
137
|
+
|
|
138
|
+
@staticmethod
|
|
139
|
+
def deny(reason: str, context: Optional[Dict[str, Any]] = None) -> 'AuthorizationResult':
|
|
140
|
+
"""Create a deny result."""
|
|
141
|
+
return AuthorizationResult(allowed=False, reason=reason, context=context)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
class AuthorizationMiddleware:
|
|
145
|
+
"""
|
|
146
|
+
Centralized authorization logic with support for:
|
|
147
|
+
- Multi-tenant access control
|
|
148
|
+
- Tenant sharing
|
|
149
|
+
- Role-based permissions
|
|
150
|
+
- Operation-level access control
|
|
151
|
+
"""
|
|
152
|
+
|
|
153
|
+
@staticmethod
|
|
154
|
+
def can_access_tenant(actor: AuthContext, target_tenant_id: str) -> AuthorizationResult:
|
|
155
|
+
"""
|
|
156
|
+
Check if actor can access target tenant.
|
|
157
|
+
|
|
158
|
+
Args:
|
|
159
|
+
actor: The actor making the request
|
|
160
|
+
target_tenant_id: The tenant being accessed
|
|
161
|
+
|
|
162
|
+
Returns:
|
|
163
|
+
AuthorizationResult with allowed status and reason
|
|
164
|
+
"""
|
|
165
|
+
# Platform admins can access any tenant
|
|
166
|
+
if actor.has_permission(Permission.PLATFORM_ADMIN):
|
|
167
|
+
return AuthorizationResult.allow("platform_admin")
|
|
168
|
+
|
|
169
|
+
# Own tenant access
|
|
170
|
+
if actor.is_own_tenant(target_tenant_id):
|
|
171
|
+
return AuthorizationResult.allow("own_tenant")
|
|
172
|
+
|
|
173
|
+
# Shared tenant access
|
|
174
|
+
if actor.is_shared_tenant(target_tenant_id):
|
|
175
|
+
return AuthorizationResult.allow("shared_tenant")
|
|
176
|
+
|
|
177
|
+
return AuthorizationResult.deny("tenant_access_denied")
|
|
178
|
+
|
|
179
|
+
@staticmethod
|
|
180
|
+
def can_access_user(
|
|
181
|
+
actor: AuthContext,
|
|
182
|
+
target_tenant_id: str,
|
|
183
|
+
target_user_id: str
|
|
184
|
+
) -> AuthorizationResult:
|
|
185
|
+
"""
|
|
186
|
+
Check if actor can access target user's data.
|
|
187
|
+
|
|
188
|
+
Args:
|
|
189
|
+
actor: The actor making the request
|
|
190
|
+
target_tenant_id: The tenant the user belongs to
|
|
191
|
+
target_user_id: The user whose data is being accessed
|
|
192
|
+
|
|
193
|
+
Returns:
|
|
194
|
+
AuthorizationResult with allowed status and reason
|
|
195
|
+
"""
|
|
196
|
+
# Platform admins can access any user
|
|
197
|
+
if actor.has_permission(Permission.PLATFORM_ADMIN):
|
|
198
|
+
return AuthorizationResult.allow("platform_admin")
|
|
199
|
+
|
|
200
|
+
# Must be able to access the tenant first
|
|
201
|
+
tenant_result = AuthorizationMiddleware.can_access_tenant(actor, target_tenant_id)
|
|
202
|
+
if not tenant_result.allowed:
|
|
203
|
+
return tenant_result
|
|
204
|
+
|
|
205
|
+
# Own tenant - check additional permissions
|
|
206
|
+
if actor.is_own_tenant(target_tenant_id):
|
|
207
|
+
# Tenant admins can access any user in their tenant
|
|
208
|
+
if actor.has_permission(Permission.TENANT_ADMIN):
|
|
209
|
+
return AuthorizationResult.allow("tenant_admin")
|
|
210
|
+
|
|
211
|
+
# User accessing their own data
|
|
212
|
+
if actor.user_id == target_user_id:
|
|
213
|
+
return AuthorizationResult.allow("own_user")
|
|
214
|
+
|
|
215
|
+
# User accessing another user's data (requires special permission)
|
|
216
|
+
if actor.has_permission(Permission.USER_READ_OTHERS):
|
|
217
|
+
return AuthorizationResult.allow("user_read_others")
|
|
218
|
+
|
|
219
|
+
return AuthorizationResult.deny("user_access_denied")
|
|
220
|
+
|
|
221
|
+
# Shared tenant - can access if they have shared read permission
|
|
222
|
+
if actor.is_shared_tenant(target_tenant_id):
|
|
223
|
+
if actor.has_permission(Permission.USER_READ_SHARED):
|
|
224
|
+
return AuthorizationResult.allow("shared_tenant_user_access")
|
|
225
|
+
return AuthorizationResult.deny("shared_tenant_permission_required")
|
|
226
|
+
|
|
227
|
+
return AuthorizationResult.deny("user_access_denied")
|
|
228
|
+
|
|
229
|
+
@staticmethod
|
|
230
|
+
def can_perform_operation(
|
|
231
|
+
actor: AuthContext,
|
|
232
|
+
resource: ResourceContext,
|
|
233
|
+
operation: Operation
|
|
234
|
+
) -> AuthorizationResult:
|
|
235
|
+
"""
|
|
236
|
+
Check if actor can perform operation on resource.
|
|
237
|
+
|
|
238
|
+
Args:
|
|
239
|
+
actor: The actor making the request
|
|
240
|
+
resource: The target resource
|
|
241
|
+
operation: The operation to perform
|
|
242
|
+
|
|
243
|
+
Returns:
|
|
244
|
+
AuthorizationResult with allowed status and reason
|
|
245
|
+
"""
|
|
246
|
+
# Platform admins can do anything
|
|
247
|
+
if actor.has_permission(Permission.PLATFORM_ADMIN):
|
|
248
|
+
return AuthorizationResult.allow("platform_admin", {
|
|
249
|
+
'operation': operation.value,
|
|
250
|
+
'resource': resource.to_dict()
|
|
251
|
+
})
|
|
252
|
+
|
|
253
|
+
# Check tenant-level access
|
|
254
|
+
tenant_result = AuthorizationMiddleware.can_access_tenant(actor, resource.tenant_id)
|
|
255
|
+
if not tenant_result.allowed:
|
|
256
|
+
return tenant_result
|
|
257
|
+
|
|
258
|
+
# Check user-level access if user_id is specified
|
|
259
|
+
if resource.user_id:
|
|
260
|
+
user_result = AuthorizationMiddleware.can_access_user(
|
|
261
|
+
actor, resource.tenant_id, resource.user_id
|
|
262
|
+
)
|
|
263
|
+
if not user_result.allowed:
|
|
264
|
+
return user_result
|
|
265
|
+
|
|
266
|
+
# Determine if this is own tenant or shared tenant
|
|
267
|
+
is_own_tenant = actor.is_own_tenant(resource.tenant_id)
|
|
268
|
+
is_shared_tenant = actor.is_shared_tenant(resource.tenant_id)
|
|
269
|
+
|
|
270
|
+
# Own tenant access rules
|
|
271
|
+
if is_own_tenant:
|
|
272
|
+
# Tenant admin can do anything in their tenant
|
|
273
|
+
if actor.has_permission(Permission.TENANT_ADMIN):
|
|
274
|
+
return AuthorizationResult.allow("tenant_admin", {
|
|
275
|
+
'operation': operation.value
|
|
276
|
+
})
|
|
277
|
+
|
|
278
|
+
# User accessing their own resource
|
|
279
|
+
if resource.user_id and resource.user_id == actor.user_id:
|
|
280
|
+
if operation == Operation.READ:
|
|
281
|
+
if actor.has_permission(Permission.USER_READ_OWN):
|
|
282
|
+
return AuthorizationResult.allow("user_read_own")
|
|
283
|
+
elif operation in [Operation.WRITE, Operation.DELETE, Operation.CREATE]:
|
|
284
|
+
if actor.has_permission(Permission.USER_WRITE_OWN):
|
|
285
|
+
return AuthorizationResult.allow("user_write_own")
|
|
286
|
+
|
|
287
|
+
# User accessing another user's resource
|
|
288
|
+
if resource.user_id and resource.user_id != actor.user_id:
|
|
289
|
+
if operation == Operation.READ:
|
|
290
|
+
if actor.has_permission(Permission.USER_READ_OTHERS):
|
|
291
|
+
return AuthorizationResult.allow("user_read_others")
|
|
292
|
+
elif operation in [Operation.WRITE, Operation.DELETE]:
|
|
293
|
+
if actor.has_permission(Permission.USER_WRITE_OTHERS):
|
|
294
|
+
return AuthorizationResult.allow("user_write_others")
|
|
295
|
+
|
|
296
|
+
# Tenant-level resource (no user_id)
|
|
297
|
+
if not resource.user_id:
|
|
298
|
+
if operation == Operation.READ:
|
|
299
|
+
if actor.has_permission(Permission.TENANT_READ):
|
|
300
|
+
return AuthorizationResult.allow("tenant_read")
|
|
301
|
+
elif operation in [Operation.WRITE, Operation.DELETE, Operation.CREATE]:
|
|
302
|
+
if actor.has_permission(Permission.TENANT_WRITE):
|
|
303
|
+
return AuthorizationResult.allow("tenant_write")
|
|
304
|
+
|
|
305
|
+
# Shared tenant access rules
|
|
306
|
+
if is_shared_tenant:
|
|
307
|
+
# Read access to shared resources
|
|
308
|
+
if operation == Operation.READ:
|
|
309
|
+
if actor.has_permission(Permission.USER_READ_SHARED):
|
|
310
|
+
return AuthorizationResult.allow("shared_read")
|
|
311
|
+
return AuthorizationResult.deny("shared_read_permission_required")
|
|
312
|
+
|
|
313
|
+
# Write access to shared resources (typically not allowed)
|
|
314
|
+
if operation in [Operation.WRITE, Operation.DELETE, Operation.CREATE]:
|
|
315
|
+
if actor.has_permission(Permission.USER_WRITE_SHARED):
|
|
316
|
+
return AuthorizationResult.allow("shared_write")
|
|
317
|
+
return AuthorizationResult.deny("shared_resources_readonly")
|
|
318
|
+
|
|
319
|
+
# Default deny
|
|
320
|
+
return AuthorizationResult.deny("no_permission", {
|
|
321
|
+
'required_operation': operation.value,
|
|
322
|
+
'resource': resource.to_dict()
|
|
323
|
+
})
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def extract_auth_context(event: Dict[str, Any]) -> AuthContext:
|
|
327
|
+
"""
|
|
328
|
+
Extract AuthContext from API Gateway event.
|
|
329
|
+
|
|
330
|
+
Args:
|
|
331
|
+
event: API Gateway Lambda event
|
|
332
|
+
|
|
333
|
+
Returns:
|
|
334
|
+
AuthContext with actor information from JWT
|
|
335
|
+
"""
|
|
336
|
+
from .auth import extract_user_context
|
|
337
|
+
|
|
338
|
+
user_context = extract_user_context(event)
|
|
339
|
+
|
|
340
|
+
return AuthContext(
|
|
341
|
+
user_id=user_context.get('user_id', ''),
|
|
342
|
+
tenant_id=user_context.get('tenant_id', ''),
|
|
343
|
+
roles=user_context.get('roles', []),
|
|
344
|
+
permissions=user_context.get('permissions', []),
|
|
345
|
+
shared_tenants=user_context.get('shared_tenants', []),
|
|
346
|
+
email=user_context.get('email'),
|
|
347
|
+
name=user_context.get('name')
|
|
348
|
+
)
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def extract_resource_context(
|
|
352
|
+
event: Dict[str, Any],
|
|
353
|
+
resource_type: Optional[str] = None
|
|
354
|
+
) -> ResourceContext:
|
|
355
|
+
"""
|
|
356
|
+
Extract ResourceContext from API Gateway path parameters.
|
|
357
|
+
|
|
358
|
+
Args:
|
|
359
|
+
event: API Gateway Lambda event
|
|
360
|
+
resource_type: Optional resource type override
|
|
361
|
+
|
|
362
|
+
Returns:
|
|
363
|
+
ResourceContext with target resource information
|
|
364
|
+
"""
|
|
365
|
+
path_params = event.get('pathParameters', {})
|
|
366
|
+
|
|
367
|
+
# Extract resource_id from various possible parameter names
|
|
368
|
+
resource_id = (
|
|
369
|
+
path_params.get('id') or
|
|
370
|
+
path_params.get('message_id') or
|
|
371
|
+
path_params.get('thread_id') or
|
|
372
|
+
path_params.get('channel_id') or
|
|
373
|
+
path_params.get('resource_id')
|
|
374
|
+
)
|
|
375
|
+
|
|
376
|
+
return ResourceContext(
|
|
377
|
+
tenant_id=path_params.get('tenant_id', ''),
|
|
378
|
+
user_id=path_params.get('user_id'),
|
|
379
|
+
resource_id=resource_id,
|
|
380
|
+
resource_type=resource_type
|
|
381
|
+
)
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
def infer_operation(event: Dict[str, Any]) -> Operation:
|
|
385
|
+
"""
|
|
386
|
+
Infer operation from HTTP method.
|
|
387
|
+
|
|
388
|
+
Args:
|
|
389
|
+
event: API Gateway Lambda event
|
|
390
|
+
|
|
391
|
+
Returns:
|
|
392
|
+
Operation enum value
|
|
393
|
+
"""
|
|
394
|
+
method = event.get('httpMethod', 'GET').upper()
|
|
395
|
+
|
|
396
|
+
if method == 'GET':
|
|
397
|
+
return Operation.READ
|
|
398
|
+
elif method == 'POST':
|
|
399
|
+
return Operation.CREATE
|
|
400
|
+
elif method in ['PUT', 'PATCH']:
|
|
401
|
+
return Operation.WRITE
|
|
402
|
+
elif method == 'DELETE':
|
|
403
|
+
return Operation.DELETE
|
|
404
|
+
else:
|
|
405
|
+
return Operation.READ # Default to read
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
def require_authorization(
|
|
409
|
+
operation: Optional[Operation] = None,
|
|
410
|
+
resource_type: Optional[str] = None,
|
|
411
|
+
extract_resource: Optional[Callable[[Dict[str, Any]], ResourceContext]] = None
|
|
412
|
+
) -> Callable:
|
|
413
|
+
"""
|
|
414
|
+
Decorator for automatic authorization checks on Lambda handlers.
|
|
415
|
+
|
|
416
|
+
This decorator:
|
|
417
|
+
1. Extracts actor context from JWT
|
|
418
|
+
2. Extracts resource context from path parameters
|
|
419
|
+
3. Checks if actor can perform operation on resource
|
|
420
|
+
4. Returns 403 if unauthorized
|
|
421
|
+
5. Adds authorization context to event for handler use
|
|
422
|
+
|
|
423
|
+
Args:
|
|
424
|
+
operation: Operation to check (if None, inferred from HTTP method)
|
|
425
|
+
resource_type: Type of resource being accessed
|
|
426
|
+
extract_resource: Custom function to extract ResourceContext from event
|
|
427
|
+
|
|
428
|
+
Usage:
|
|
429
|
+
@require_authorization(operation=Operation.READ, resource_type="message")
|
|
430
|
+
def handler(event, context):
|
|
431
|
+
# Authorization already checked
|
|
432
|
+
# Access auth info via event['authorization_context']
|
|
433
|
+
pass
|
|
434
|
+
"""
|
|
435
|
+
def decorator(handler_func: Callable) -> Callable:
|
|
436
|
+
@functools.wraps(handler_func)
|
|
437
|
+
def wrapper(event: Dict[str, Any], context: Any, *args, **kwargs) -> Dict[str, Any]:
|
|
438
|
+
try:
|
|
439
|
+
# Extract actor from JWT
|
|
440
|
+
actor = extract_auth_context(event)
|
|
441
|
+
|
|
442
|
+
# Extract resource from path
|
|
443
|
+
if extract_resource:
|
|
444
|
+
resource = extract_resource(event)
|
|
445
|
+
else:
|
|
446
|
+
resource = extract_resource_context(event, resource_type)
|
|
447
|
+
|
|
448
|
+
# Validate tenant_id is provided in path
|
|
449
|
+
if not resource.tenant_id:
|
|
450
|
+
return {
|
|
451
|
+
'statusCode': 400,
|
|
452
|
+
'headers': {
|
|
453
|
+
'Content-Type': 'application/json',
|
|
454
|
+
'Access-Control-Allow-Origin': '*'
|
|
455
|
+
},
|
|
456
|
+
'body': json.dumps({
|
|
457
|
+
'error': 'Bad Request',
|
|
458
|
+
'message': 'tenant_id is required in path parameters'
|
|
459
|
+
})
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
# Determine operation
|
|
463
|
+
op = operation if operation else infer_operation(event)
|
|
464
|
+
|
|
465
|
+
# Check authorization
|
|
466
|
+
result = AuthorizationMiddleware.can_perform_operation(actor, resource, op)
|
|
467
|
+
|
|
468
|
+
if not result.allowed:
|
|
469
|
+
return {
|
|
470
|
+
'statusCode': 403,
|
|
471
|
+
'headers': {
|
|
472
|
+
'Content-Type': 'application/json',
|
|
473
|
+
'Access-Control-Allow-Origin': '*'
|
|
474
|
+
},
|
|
475
|
+
'body': json.dumps({
|
|
476
|
+
'error': 'Forbidden',
|
|
477
|
+
'message': 'You do not have permission to access this resource',
|
|
478
|
+
'reason': result.reason
|
|
479
|
+
})
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
# Add authorization context to event for handler use
|
|
483
|
+
event['authorization_context'] = {
|
|
484
|
+
'actor': actor,
|
|
485
|
+
'resource': resource,
|
|
486
|
+
'operation': op.value,
|
|
487
|
+
'reason': result.reason,
|
|
488
|
+
'context': result.context
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
# Authorization passed - call handler
|
|
492
|
+
return handler_func(event, context, *args, **kwargs)
|
|
493
|
+
|
|
494
|
+
except KeyError as e:
|
|
495
|
+
# Missing required JWT claim
|
|
496
|
+
return {
|
|
497
|
+
'statusCode': 401,
|
|
498
|
+
'headers': {
|
|
499
|
+
'Content-Type': 'application/json',
|
|
500
|
+
'Access-Control-Allow-Origin': '*'
|
|
501
|
+
},
|
|
502
|
+
'body': json.dumps({
|
|
503
|
+
'error': 'Unauthorized',
|
|
504
|
+
'message': f'Missing required authentication claim: {str(e)}'
|
|
505
|
+
})
|
|
506
|
+
}
|
|
507
|
+
except Exception as e:
|
|
508
|
+
# Unexpected error during authorization
|
|
509
|
+
return {
|
|
510
|
+
'statusCode': 500,
|
|
511
|
+
'headers': {
|
|
512
|
+
'Content-Type': 'application/json',
|
|
513
|
+
'Access-Control-Allow-Origin': '*'
|
|
514
|
+
},
|
|
515
|
+
'body': json.dumps({
|
|
516
|
+
'error': 'Internal Server Error',
|
|
517
|
+
'message': 'Authorization check failed',
|
|
518
|
+
'detail': str(e)
|
|
519
|
+
})
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
return wrapper
|
|
523
|
+
return decorator
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""
|
|
2
|
+
CORS middleware for Lambda handlers.
|
|
3
|
+
"""
|
|
4
|
+
import functools
|
|
5
|
+
from typing import Dict, Any, Callable
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def add_cors_headers(handler: Callable) -> Callable:
|
|
9
|
+
"""
|
|
10
|
+
Decorator that adds CORS headers to Lambda response.
|
|
11
|
+
"""
|
|
12
|
+
@functools.wraps(handler)
|
|
13
|
+
def wrapper(event: Dict[str, Any], context: Any, *args, **kwargs) -> Dict[str, Any]:
|
|
14
|
+
response = handler(event, context, *args, **kwargs)
|
|
15
|
+
|
|
16
|
+
# Ensure headers exist
|
|
17
|
+
if 'headers' not in response:
|
|
18
|
+
response['headers'] = {}
|
|
19
|
+
|
|
20
|
+
# Add CORS headers
|
|
21
|
+
cors_headers = {
|
|
22
|
+
'Access-Control-Allow-Origin': '*',
|
|
23
|
+
'Access-Control-Allow-Headers': 'Content-Type,X-Amz-Date,Authorization,X-Api-Key,X-Amz-Security-Token',
|
|
24
|
+
'Access-Control-Allow-Methods': 'GET,POST,PUT,DELETE,OPTIONS',
|
|
25
|
+
'Content-Type': 'application/json'
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
response['headers'].update(cors_headers)
|
|
29
|
+
return response
|
|
30
|
+
|
|
31
|
+
return wrapper
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def handle_preflight(handler: Callable) -> Callable:
|
|
35
|
+
"""
|
|
36
|
+
Decorator that handles OPTIONS preflight requests.
|
|
37
|
+
"""
|
|
38
|
+
@functools.wraps(handler)
|
|
39
|
+
def wrapper(event: Dict[str, Any], context: Any, *args, **kwargs) -> Dict[str, Any]:
|
|
40
|
+
# Handle OPTIONS request
|
|
41
|
+
if event.get('httpMethod') == 'OPTIONS':
|
|
42
|
+
return {
|
|
43
|
+
'statusCode': 200,
|
|
44
|
+
'headers': {
|
|
45
|
+
'Access-Control-Allow-Origin': '*',
|
|
46
|
+
'Access-Control-Allow-Headers': 'Content-Type,X-Amz-Date,Authorization,X-Api-Key,X-Amz-Security-Token',
|
|
47
|
+
'Access-Control-Allow-Methods': 'GET,POST,PUT,DELETE,OPTIONS',
|
|
48
|
+
'Content-Type': 'application/json'
|
|
49
|
+
},
|
|
50
|
+
'body': ''
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return handler(event, context, *args, **kwargs)
|
|
54
|
+
|
|
55
|
+
return wrapper
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
# Convenience decorator that combines both CORS functionalities
|
|
59
|
+
def handle_cors(handler: Callable) -> Callable:
|
|
60
|
+
"""
|
|
61
|
+
Decorator that handles both preflight requests and adds CORS headers.
|
|
62
|
+
"""
|
|
63
|
+
return add_cors_headers(handle_preflight(handler))
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Error handling middleware for Lambda handlers.
|
|
3
|
+
"""
|
|
4
|
+
import json
|
|
5
|
+
import logging
|
|
6
|
+
import traceback
|
|
7
|
+
import functools
|
|
8
|
+
from typing import Dict, Any, Callable
|
|
9
|
+
from ..core.service_errors import ValidationError, NotFoundError, AccessDeniedError
|
|
10
|
+
logger = logging.getLogger(__name__)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def handle_errors(handler: Callable) -> Callable:
|
|
14
|
+
"""
|
|
15
|
+
Decorator that converts service errors to appropriate HTTP responses.
|
|
16
|
+
"""
|
|
17
|
+
@functools.wraps(handler)
|
|
18
|
+
def wrapper(event: Dict[str, Any], context: Any, *args, **kwargs) -> Dict[str, Any]:
|
|
19
|
+
try:
|
|
20
|
+
return handler(event, context, *args, **kwargs)
|
|
21
|
+
except ValidationError as e:
|
|
22
|
+
logger.warning(f"Validation error: {str(e)}")
|
|
23
|
+
return {
|
|
24
|
+
'statusCode': 400,
|
|
25
|
+
'headers': {'Content-Type': 'application/json'},
|
|
26
|
+
'body': json.dumps({
|
|
27
|
+
'error': str(e),
|
|
28
|
+
'error_code': 'VALIDATION_ERROR'
|
|
29
|
+
})
|
|
30
|
+
}
|
|
31
|
+
except AccessDeniedError as e:
|
|
32
|
+
logger.warning(f"Access denied: {str(e)}")
|
|
33
|
+
return {
|
|
34
|
+
'statusCode': 403,
|
|
35
|
+
'headers': {'Content-Type': 'application/json'},
|
|
36
|
+
'body': json.dumps({
|
|
37
|
+
'error': str(e),
|
|
38
|
+
'error_code': 'ACCESS_DENIED'
|
|
39
|
+
})
|
|
40
|
+
}
|
|
41
|
+
except NotFoundError as e:
|
|
42
|
+
logger.info(f"Resource not found: {str(e)}")
|
|
43
|
+
return {
|
|
44
|
+
'statusCode': 404,
|
|
45
|
+
'headers': {'Content-Type': 'application/json'},
|
|
46
|
+
'body': json.dumps({
|
|
47
|
+
'error': str(e),
|
|
48
|
+
'error_code': 'NOT_FOUND'
|
|
49
|
+
})
|
|
50
|
+
}
|
|
51
|
+
except Exception as e:
|
|
52
|
+
logger.error(f"Unexpected error in {handler.__name__}: {str(e)}")
|
|
53
|
+
logger.error(traceback.format_exc())
|
|
54
|
+
return {
|
|
55
|
+
'statusCode': 500,
|
|
56
|
+
'headers': {'Content-Type': 'application/json'},
|
|
57
|
+
'body': json.dumps({
|
|
58
|
+
'error': 'Internal server error',
|
|
59
|
+
'error_code': 'INTERNAL_ERROR'
|
|
60
|
+
})
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return wrapper
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def validate_request_body(required_fields: list = None, optional_fields: list = None):
|
|
67
|
+
"""
|
|
68
|
+
Decorator that validates request body fields.
|
|
69
|
+
|
|
70
|
+
Args:
|
|
71
|
+
required_fields: List of required field names
|
|
72
|
+
optional_fields: List of optional field names (for documentation)
|
|
73
|
+
"""
|
|
74
|
+
def decorator(handler: Callable) -> Callable:
|
|
75
|
+
@functools.wraps(handler)
|
|
76
|
+
def wrapper(event: Dict[str, Any], context: Any, *args, **kwargs) -> Dict[str, Any]:
|
|
77
|
+
# Parse request body
|
|
78
|
+
body_str = event.get('body', '{}')
|
|
79
|
+
try:
|
|
80
|
+
body = json.loads(body_str) if isinstance(body_str, str) else body_str
|
|
81
|
+
if body is None:
|
|
82
|
+
raise json.JSONDecodeError("Body is None", "", 0)
|
|
83
|
+
except json.JSONDecodeError:
|
|
84
|
+
return {
|
|
85
|
+
'statusCode': 400,
|
|
86
|
+
'headers': {'Content-Type': 'application/json'},
|
|
87
|
+
'body': json.dumps({
|
|
88
|
+
'error': 'Invalid JSON in request body',
|
|
89
|
+
'error_code': 'INVALID_JSON'
|
|
90
|
+
})
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
# Check required fields
|
|
94
|
+
if required_fields:
|
|
95
|
+
missing_fields = [field for field in required_fields if field not in body]
|
|
96
|
+
if missing_fields:
|
|
97
|
+
return {
|
|
98
|
+
'statusCode': 400,
|
|
99
|
+
'headers': {'Content-Type': 'application/json'},
|
|
100
|
+
'body': json.dumps({
|
|
101
|
+
'error': f'Missing required fields: {", ".join(missing_fields)}',
|
|
102
|
+
'error_code': 'MISSING_FIELDS'
|
|
103
|
+
})
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
# Add parsed body to event for handler
|
|
107
|
+
event['parsed_body'] = body
|
|
108
|
+
return handler(event, context, *args, **kwargs)
|
|
109
|
+
|
|
110
|
+
return wrapper
|
|
111
|
+
return decorator
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
# Custom exception classes are imported from core module
|