kailash 0.6.1__py3-none-any.whl → 0.6.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.
Files changed (41) hide show
  1. kailash/__init__.py +1 -1
  2. kailash/core/actors/connection_actor.py +3 -3
  3. kailash/gateway/api.py +7 -5
  4. kailash/gateway/enhanced_gateway.py +1 -1
  5. kailash/{mcp → mcp_server}/__init__.py +12 -7
  6. kailash/{mcp → mcp_server}/ai_registry_server.py +2 -2
  7. kailash/{mcp/server_enhanced.py → mcp_server/server.py} +231 -48
  8. kailash/{mcp → mcp_server}/servers/ai_registry.py +2 -2
  9. kailash/{mcp → mcp_server}/utils/__init__.py +1 -6
  10. kailash/middleware/auth/access_control.py +5 -5
  11. kailash/middleware/gateway/checkpoint_manager.py +45 -8
  12. kailash/middleware/mcp/client_integration.py +1 -1
  13. kailash/middleware/mcp/enhanced_server.py +2 -2
  14. kailash/nodes/admin/permission_check.py +110 -30
  15. kailash/nodes/admin/schema.sql +387 -0
  16. kailash/nodes/admin/tenant_isolation.py +249 -0
  17. kailash/nodes/admin/transaction_utils.py +244 -0
  18. kailash/nodes/admin/user_management.py +37 -9
  19. kailash/nodes/ai/ai_providers.py +55 -3
  20. kailash/nodes/ai/iterative_llm_agent.py +1 -1
  21. kailash/nodes/ai/llm_agent.py +118 -16
  22. kailash/nodes/data/sql.py +24 -0
  23. kailash/resources/registry.py +6 -0
  24. kailash/runtime/async_local.py +7 -0
  25. kailash/utils/export.py +152 -0
  26. kailash/workflow/builder.py +42 -0
  27. kailash/workflow/graph.py +86 -17
  28. kailash/workflow/templates.py +4 -9
  29. {kailash-0.6.1.dist-info → kailash-0.6.3.dist-info}/METADATA +3 -2
  30. {kailash-0.6.1.dist-info → kailash-0.6.3.dist-info}/RECORD +40 -38
  31. kailash/mcp/server.py +0 -292
  32. /kailash/{mcp → mcp_server}/client.py +0 -0
  33. /kailash/{mcp → mcp_server}/client_new.py +0 -0
  34. /kailash/{mcp → mcp_server}/utils/cache.py +0 -0
  35. /kailash/{mcp → mcp_server}/utils/config.py +0 -0
  36. /kailash/{mcp → mcp_server}/utils/formatters.py +0 -0
  37. /kailash/{mcp → mcp_server}/utils/metrics.py +0 -0
  38. {kailash-0.6.1.dist-info → kailash-0.6.3.dist-info}/WHEEL +0 -0
  39. {kailash-0.6.1.dist-info → kailash-0.6.3.dist-info}/entry_points.txt +0 -0
  40. {kailash-0.6.1.dist-info → kailash-0.6.3.dist-info}/licenses/LICENSE +0 -0
  41. {kailash-0.6.1.dist-info → kailash-0.6.3.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,249 @@
1
+ """Enhanced tenant isolation utilities for admin nodes.
2
+
3
+ This module provides robust tenant isolation mechanisms to ensure that
4
+ multi-tenant operations properly enforce data boundaries and prevent
5
+ cross-tenant access.
6
+ """
7
+
8
+ import logging
9
+ from dataclasses import dataclass
10
+ from typing import Any, Dict, List, Optional, Set
11
+
12
+ from kailash.sdk_exceptions import NodeExecutionError, NodeValidationError
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+
17
+ @dataclass
18
+ class TenantContext:
19
+ """Represents the context for a specific tenant."""
20
+
21
+ tenant_id: str
22
+ permissions: Set[str]
23
+ user_ids: Set[str]
24
+ role_ids: Set[str]
25
+ resource_prefixes: Set[str]
26
+
27
+
28
+ class TenantIsolationManager:
29
+ """Manages tenant isolation for admin operations."""
30
+
31
+ def __init__(self, db_node):
32
+ """
33
+ Initialize tenant isolation manager.
34
+
35
+ Args:
36
+ db_node: Database node for tenant context queries
37
+ """
38
+ self.db_node = db_node
39
+ self._tenant_cache = {}
40
+
41
+ def get_tenant_context(self, tenant_id: str) -> TenantContext:
42
+ """
43
+ Get the context for a specific tenant.
44
+
45
+ Args:
46
+ tenant_id: The tenant ID
47
+
48
+ Returns:
49
+ TenantContext with tenant-specific data
50
+ """
51
+ if tenant_id not in self._tenant_cache:
52
+ self._tenant_cache[tenant_id] = self._load_tenant_context(tenant_id)
53
+
54
+ return self._tenant_cache[tenant_id]
55
+
56
+ def _load_tenant_context(self, tenant_id: str) -> TenantContext:
57
+ """Load tenant context from database."""
58
+ # Get all users for this tenant
59
+ users_query = """
60
+ SELECT user_id FROM users WHERE tenant_id = $1 AND status = 'active'
61
+ """
62
+ users_result = self.db_node.run(
63
+ query=users_query, parameters=[tenant_id], result_format="dict"
64
+ )
65
+ user_ids = {row["user_id"] for row in users_result.get("data", [])}
66
+
67
+ # Get all roles for this tenant
68
+ roles_query = """
69
+ SELECT role_id FROM roles WHERE tenant_id = $1 AND is_active = true
70
+ """
71
+ roles_result = self.db_node.run(
72
+ query=roles_query, parameters=[tenant_id], result_format="dict"
73
+ )
74
+ role_ids = {row["role_id"] for row in roles_result.get("data", [])}
75
+
76
+ # Get all permissions for this tenant (from roles)
77
+ permissions_query = """
78
+ SELECT DISTINCT unnest(
79
+ CASE
80
+ WHEN jsonb_typeof(permissions) = 'array'
81
+ THEN ARRAY(SELECT jsonb_array_elements_text(permissions))
82
+ ELSE ARRAY[]::text[]
83
+ END
84
+ ) as permission
85
+ FROM roles
86
+ WHERE tenant_id = $1 AND is_active = true
87
+ """
88
+ permissions_result = self.db_node.run(
89
+ query=permissions_query, parameters=[tenant_id], result_format="dict"
90
+ )
91
+ permissions = {row["permission"] for row in permissions_result.get("data", [])}
92
+
93
+ # Create resource prefixes for this tenant
94
+ resource_prefixes = {f"{tenant_id}:*", "*"}
95
+
96
+ return TenantContext(
97
+ tenant_id=tenant_id,
98
+ permissions=permissions,
99
+ user_ids=user_ids,
100
+ role_ids=role_ids,
101
+ resource_prefixes=resource_prefixes,
102
+ )
103
+
104
+ def validate_user_tenant_access(self, user_id: str, target_tenant_id: str) -> bool:
105
+ """
106
+ Validate that a user has access within a specific tenant.
107
+
108
+ Args:
109
+ user_id: The user ID to check
110
+ target_tenant_id: The tenant being accessed
111
+
112
+ Returns:
113
+ True if access is allowed, False otherwise
114
+ """
115
+ tenant_context = self.get_tenant_context(target_tenant_id)
116
+ return user_id in tenant_context.user_ids
117
+
118
+ def validate_role_tenant_access(self, role_id: str, target_tenant_id: str) -> bool:
119
+ """
120
+ Validate that a role belongs to a specific tenant.
121
+
122
+ Args:
123
+ role_id: The role ID to check
124
+ target_tenant_id: The tenant being accessed
125
+
126
+ Returns:
127
+ True if role belongs to tenant, False otherwise
128
+ """
129
+ tenant_context = self.get_tenant_context(target_tenant_id)
130
+ return role_id in tenant_context.role_ids
131
+
132
+ def check_cross_tenant_permission(
133
+ self,
134
+ user_id: str,
135
+ user_tenant_id: str,
136
+ resource_tenant_id: str,
137
+ permission: str,
138
+ ) -> bool:
139
+ """
140
+ Check if a user from one tenant can access resources in another tenant.
141
+
142
+ Args:
143
+ user_id: The user attempting access
144
+ user_tenant_id: The tenant the user belongs to
145
+ resource_tenant_id: The tenant of the resource being accessed
146
+ permission: The permission being requested
147
+
148
+ Returns:
149
+ True if cross-tenant access is allowed, False otherwise
150
+ """
151
+ # For now, enforce strict tenant isolation
152
+ # Users can only access resources in their own tenant
153
+ if user_tenant_id != resource_tenant_id:
154
+ logger.debug(
155
+ f"Cross-tenant access denied: user {user_id} from {user_tenant_id} "
156
+ f"attempting to access {resource_tenant_id}"
157
+ )
158
+ return False
159
+
160
+ # Same tenant access - check if user exists in tenant
161
+ return self.validate_user_tenant_access(user_id, resource_tenant_id)
162
+
163
+ def enforce_tenant_isolation(
164
+ self, user_id: str, user_tenant_id: str, operation_tenant_id: str
165
+ ) -> None:
166
+ """
167
+ Enforce tenant isolation for an operation.
168
+
169
+ Args:
170
+ user_id: The user performing the operation
171
+ user_tenant_id: The tenant the user belongs to
172
+ operation_tenant_id: The tenant context for the operation
173
+
174
+ Raises:
175
+ NodeValidationError: If tenant isolation is violated
176
+ """
177
+ if not self.check_cross_tenant_permission(
178
+ user_id, user_tenant_id, operation_tenant_id, "access"
179
+ ):
180
+ raise NodeValidationError(
181
+ f"Tenant isolation violation: user {user_id} from tenant "
182
+ f"{user_tenant_id} cannot access tenant {operation_tenant_id}"
183
+ )
184
+
185
+ def get_tenant_scoped_permission(
186
+ self, permission: str, tenant_id: str, resource_id: Optional[str] = None
187
+ ) -> str:
188
+ """
189
+ Create a tenant-scoped permission string.
190
+
191
+ Args:
192
+ permission: Base permission (e.g., "read", "write")
193
+ tenant_id: Tenant ID
194
+ resource_id: Optional resource ID
195
+
196
+ Returns:
197
+ Tenant-scoped permission string
198
+ """
199
+ if resource_id:
200
+ return f"{tenant_id}:{resource_id}:{permission}"
201
+ else:
202
+ return f"{tenant_id}:*:{permission}"
203
+
204
+ def clear_tenant_cache(self, tenant_id: Optional[str] = None) -> None:
205
+ """
206
+ Clear the tenant context cache.
207
+
208
+ Args:
209
+ tenant_id: Specific tenant to clear, or None to clear all
210
+ """
211
+ if tenant_id:
212
+ self._tenant_cache.pop(tenant_id, None)
213
+ else:
214
+ self._tenant_cache.clear()
215
+
216
+ logger.debug(f"Cleared tenant cache for {tenant_id or 'all tenants'}")
217
+
218
+
219
+ def enforce_tenant_boundary(tenant_id_param: str = "tenant_id"):
220
+ """
221
+ Decorator to enforce tenant boundaries on admin node methods.
222
+
223
+ Args:
224
+ tenant_id_param: Name of the parameter containing the tenant ID
225
+ """
226
+
227
+ def decorator(func):
228
+ def wrapper(self, *args, **kwargs):
229
+ # Extract tenant ID from parameters
230
+ tenant_id = kwargs.get(tenant_id_param)
231
+ if not tenant_id:
232
+ raise NodeValidationError(
233
+ f"Missing required parameter: {tenant_id_param}"
234
+ )
235
+
236
+ # Create tenant isolation manager if not exists
237
+ if not hasattr(self, "_tenant_isolation"):
238
+ self._tenant_isolation = TenantIsolationManager(self._db_node)
239
+
240
+ # Perform the operation within tenant context
241
+ try:
242
+ return func(self, *args, **kwargs)
243
+ except Exception as e:
244
+ logger.error(f"Tenant-scoped operation failed for {tenant_id}: {e}")
245
+ raise
246
+
247
+ return wrapper
248
+
249
+ return decorator
@@ -0,0 +1,244 @@
1
+ """Transaction utilities for admin nodes to handle timing and persistence issues.
2
+
3
+ This module provides utilities to handle common transaction and timing issues
4
+ encountered in admin node operations, particularly around user creation,
5
+ role assignment, and permission checks.
6
+ """
7
+
8
+ import logging
9
+ import time
10
+ from typing import Any, Callable, Dict, Optional, TypeVar
11
+
12
+ from kailash.sdk_exceptions import NodeExecutionError, NodeValidationError
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+ T = TypeVar("T")
17
+
18
+
19
+ class TransactionHelper:
20
+ """Helper class for handling database transaction timing and persistence issues."""
21
+
22
+ def __init__(self, db_node, max_retries: int = 3, retry_delay: float = 0.1):
23
+ """
24
+ Initialize transaction helper.
25
+
26
+ Args:
27
+ db_node: Database node instance (SQLDatabaseNode)
28
+ max_retries: Maximum number of retries for transient failures
29
+ retry_delay: Delay between retries in seconds
30
+ """
31
+ self.db_node = db_node
32
+ self.max_retries = max_retries
33
+ self.retry_delay = retry_delay
34
+
35
+ def execute_with_retry(self, operation: Callable[[], T], operation_name: str) -> T:
36
+ """
37
+ Execute a database operation with retry logic.
38
+
39
+ Args:
40
+ operation: Function that performs the database operation
41
+ operation_name: Description of the operation for logging
42
+
43
+ Returns:
44
+ Result of the operation
45
+
46
+ Raises:
47
+ NodeExecutionError: If operation fails after all retries
48
+ """
49
+ last_exception = None
50
+
51
+ for attempt in range(self.max_retries):
52
+ try:
53
+ result = operation()
54
+ if attempt > 0:
55
+ logger.info(f"{operation_name} succeeded on attempt {attempt + 1}")
56
+ return result
57
+ except Exception as e:
58
+ last_exception = e
59
+ if attempt < self.max_retries - 1:
60
+ logger.warning(
61
+ f"{operation_name} failed on attempt {attempt + 1}, retrying: {e}"
62
+ )
63
+ time.sleep(self.retry_delay * (2**attempt)) # Exponential backoff
64
+ else:
65
+ logger.error(
66
+ f"{operation_name} failed after {self.max_retries} attempts: {e}"
67
+ )
68
+
69
+ raise NodeExecutionError(
70
+ f"{operation_name} failed after {self.max_retries} attempts: {last_exception}"
71
+ )
72
+
73
+ def verify_operation_success(
74
+ self,
75
+ verification_query: str,
76
+ expected_result: Any,
77
+ operation_name: str,
78
+ timeout_seconds: float = 5.0,
79
+ ) -> bool:
80
+ """
81
+ Verify that a database operation was successful by checking the result.
82
+
83
+ Args:
84
+ verification_query: SQL query to verify the operation
85
+ expected_result: Expected result from the verification query
86
+ operation_name: Description of the operation for logging
87
+ timeout_seconds: Maximum time to wait for verification
88
+
89
+ Returns:
90
+ True if verification succeeds
91
+
92
+ Raises:
93
+ NodeValidationError: If verification fails after timeout
94
+ """
95
+ start_time = time.time()
96
+
97
+ while time.time() - start_time < timeout_seconds:
98
+ try:
99
+ result = self.db_node.run(
100
+ query=verification_query, result_format="dict"
101
+ )
102
+ data = result.get("data", [])
103
+
104
+ if data and len(data) > 0:
105
+ # Operation was successful
106
+ logger.debug(f"{operation_name} verification succeeded")
107
+ return True
108
+
109
+ except Exception as e:
110
+ logger.debug(f"{operation_name} verification error: {e}")
111
+
112
+ # Wait before retrying
113
+ time.sleep(0.05) # 50ms
114
+
115
+ raise NodeValidationError(
116
+ f"{operation_name} verification failed after {timeout_seconds}s"
117
+ )
118
+
119
+ def create_user_with_verification(
120
+ self, user_data: Dict[str, Any], tenant_id: str
121
+ ) -> Dict[str, Any]:
122
+ """
123
+ Create a user and verify the creation was successful.
124
+
125
+ Args:
126
+ user_data: User data dictionary
127
+ tenant_id: Tenant ID
128
+
129
+ Returns:
130
+ User creation result
131
+ """
132
+ user_id = user_data.get("user_id")
133
+
134
+ def create_operation():
135
+ # Perform the user creation
136
+ from .user_management import UserManagementNode
137
+
138
+ user_mgmt = UserManagementNode(database_url=self.db_node.connection_string)
139
+ return user_mgmt.run(
140
+ operation="create_user", user_data=user_data, tenant_id=tenant_id
141
+ )
142
+
143
+ # Execute creation with retry
144
+ result = self.execute_with_retry(
145
+ create_operation, f"User creation for {user_id}"
146
+ )
147
+
148
+ # Verify user was created
149
+ verification_query = """
150
+ SELECT user_id FROM users
151
+ WHERE user_id = $1 AND tenant_id = $2
152
+ """
153
+
154
+ self.verify_operation_success(
155
+ verification_query,
156
+ user_id,
157
+ f"User {user_id} creation verification",
158
+ timeout_seconds=2.0,
159
+ )
160
+
161
+ return result
162
+
163
+ def assign_role_with_verification(
164
+ self, user_id: str, role_id: str, tenant_id: str
165
+ ) -> Dict[str, Any]:
166
+ """
167
+ Assign a role to a user and verify the assignment was successful.
168
+
169
+ Args:
170
+ user_id: User ID
171
+ role_id: Role ID
172
+ tenant_id: Tenant ID
173
+
174
+ Returns:
175
+ Role assignment result
176
+ """
177
+
178
+ def assign_operation():
179
+ from .role_management import RoleManagementNode
180
+
181
+ role_mgmt = RoleManagementNode(database_url=self.db_node.connection_string)
182
+ return role_mgmt.run(
183
+ operation="assign_user",
184
+ user_id=user_id,
185
+ role_id=role_id,
186
+ tenant_id=tenant_id,
187
+ )
188
+
189
+ # Execute assignment with retry
190
+ result = self.execute_with_retry(
191
+ assign_operation, f"Role assignment {role_id} to {user_id}"
192
+ )
193
+
194
+ # Verify role was assigned
195
+ verification_query = """
196
+ SELECT user_id, role_id FROM user_role_assignments
197
+ WHERE user_id = $1 AND role_id = $2 AND tenant_id = $3 AND is_active = true
198
+ """
199
+
200
+ self.verify_operation_success(
201
+ verification_query,
202
+ {"user_id": user_id, "role_id": role_id},
203
+ f"Role assignment {role_id} to {user_id} verification",
204
+ timeout_seconds=2.0,
205
+ )
206
+
207
+ return result
208
+
209
+
210
+ def with_transaction_retry(max_retries: int = 3, retry_delay: float = 0.1):
211
+ """
212
+ Decorator to add retry logic to admin node operations.
213
+
214
+ Args:
215
+ max_retries: Maximum number of retries
216
+ retry_delay: Initial delay between retries
217
+ """
218
+
219
+ def decorator(func):
220
+ def wrapper(*args, **kwargs):
221
+ last_exception = None
222
+
223
+ for attempt in range(max_retries):
224
+ try:
225
+ return func(*args, **kwargs)
226
+ except Exception as e:
227
+ last_exception = e
228
+ if attempt < max_retries - 1:
229
+ logger.warning(
230
+ f"{func.__name__} failed on attempt {attempt + 1}, retrying: {e}"
231
+ )
232
+ time.sleep(retry_delay * (2**attempt))
233
+ else:
234
+ logger.error(
235
+ f"{func.__name__} failed after {max_retries} attempts: {e}"
236
+ )
237
+
238
+ raise NodeExecutionError(
239
+ f"{func.__name__} failed after {max_retries} attempts: {last_exception}"
240
+ )
241
+
242
+ return wrapper
243
+
244
+ return decorator
@@ -25,6 +25,8 @@ from enum import Enum
25
25
  from typing import Any, Dict, List, Optional, Set, Union
26
26
  from uuid import uuid4
27
27
 
28
+ import bcrypt
29
+
28
30
  from kailash.nodes.base import Node, NodeParameter, register_node
29
31
  from kailash.nodes.data import SQLDatabaseNode
30
32
  from kailash.sdk_exceptions import NodeExecutionError, NodeValidationError
@@ -32,6 +34,25 @@ from kailash.sdk_exceptions import NodeExecutionError, NodeValidationError
32
34
  from .schema_manager import AdminSchemaManager
33
35
 
34
36
 
37
+ def hash_password(password: str) -> str:
38
+ """Hash password using bcrypt with salt."""
39
+ if not password:
40
+ return ""
41
+ salt = bcrypt.gensalt()
42
+ hashed = bcrypt.hashpw(password.encode("utf-8"), salt)
43
+ return hashed.decode("utf-8")
44
+
45
+
46
+ def verify_password(password: str, hashed: str) -> bool:
47
+ """Verify password against bcrypt hash."""
48
+ if not password or not hashed:
49
+ return False
50
+ try:
51
+ return bcrypt.checkpw(password.encode("utf-8"), hashed.encode("utf-8"))
52
+ except Exception:
53
+ return False
54
+
55
+
35
56
  def parse_datetime(value: Union[str, datetime, None]) -> Optional[datetime]:
36
57
  """Parse datetime from various formats."""
37
58
  if value is None:
@@ -496,7 +517,7 @@ class UserManagementNode(Node):
496
517
  user.user_id,
497
518
  user.email,
498
519
  user.username,
499
- inputs.get("password_hash"),
520
+ hash_password(inputs.get("password", "")),
500
521
  user.first_name,
501
522
  user.last_name,
502
523
  user.display_name,
@@ -509,12 +530,15 @@ class UserManagementNode(Node):
509
530
  ],
510
531
  )
511
532
 
512
- # Get the created user to return complete data
513
- created_user = self._get_user_by_id(user.user_id, tenant_id)
533
+ # Return the user data that was successfully inserted
534
+ # Add timestamps that would be set by the database
535
+ user_dict = user.to_dict()
536
+ user_dict["created_at"] = datetime.now(UTC).isoformat()
537
+ user_dict["updated_at"] = datetime.now(UTC).isoformat()
514
538
 
515
539
  return {
516
540
  "result": {
517
- "user": created_user.to_dict(),
541
+ "user": user_dict,
518
542
  "operation": "create_user",
519
543
  "timestamp": datetime.now(UTC).isoformat(),
520
544
  }
@@ -918,7 +942,8 @@ class UserManagementNode(Node):
918
942
  """Set user password hash."""
919
943
  user_id = inputs["user_id"]
920
944
  tenant_id = inputs["tenant_id"]
921
- password_hash = inputs["password_hash"]
945
+ password = inputs.get("password", "")
946
+ password_hash = hash_password(password)
922
947
 
923
948
  update_query = """
924
949
  UPDATE users
@@ -964,9 +989,13 @@ class UserManagementNode(Node):
964
989
  for i, user_data in enumerate(users_data):
965
990
  try:
966
991
  # Create each user individually for better error handling
992
+ # Extract password from user_data if present
993
+ user_data_copy = user_data.copy()
994
+ password = user_data_copy.pop("password", "")
967
995
  create_inputs = {
968
996
  "operation": "create_user",
969
- "user_data": user_data,
997
+ "user_data": user_data_copy,
998
+ "password": password,
970
999
  "tenant_id": tenant_id,
971
1000
  "database_config": inputs["database_config"],
972
1001
  }
@@ -1370,7 +1399,7 @@ class UserManagementNode(Node):
1370
1399
  user_id = result["data"][0]["user_id"]
1371
1400
 
1372
1401
  # Update password
1373
- password_hash = hashlib.sha256(new_password.encode()).hexdigest()
1402
+ password_hash = hash_password(new_password)
1374
1403
  update_query = """
1375
1404
  UPDATE users
1376
1405
  SET password_hash = :password_hash,
@@ -1441,9 +1470,8 @@ class UserManagementNode(Node):
1441
1470
 
1442
1471
  user_data = result["data"][0]
1443
1472
  stored_hash = user_data["password_hash"]
1444
- provided_hash = hashlib.sha256(password.encode()).hexdigest()
1445
1473
 
1446
- if stored_hash != provided_hash:
1474
+ if not verify_password(password, stored_hash):
1447
1475
  return {"authenticated": False, "message": "Invalid password"}
1448
1476
 
1449
1477
  if user_data["status"] != "active":