kailash 0.7.0__py3-none-any.whl → 0.8.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.
- kailash/__init__.py +1 -1
- kailash/access_control.py +64 -46
- kailash/api/workflow_api.py +34 -3
- kailash/mcp_server/discovery.py +56 -17
- kailash/middleware/communication/api_gateway.py +23 -3
- kailash/middleware/communication/realtime.py +83 -0
- kailash/middleware/core/agent_ui.py +1 -1
- kailash/middleware/gateway/storage_backends.py +393 -0
- kailash/nexus/cli/__init__.py +5 -0
- kailash/nexus/cli/__main__.py +6 -0
- kailash/nexus/cli/main.py +176 -0
- kailash/nodes/__init__.py +6 -5
- kailash/nodes/base.py +29 -5
- kailash/nodes/code/python.py +50 -6
- kailash/nodes/data/async_sql.py +90 -0
- kailash/nodes/security/behavior_analysis.py +414 -0
- kailash/runtime/access_controlled.py +9 -7
- kailash/runtime/runner.py +6 -4
- kailash/runtime/testing.py +1 -1
- kailash/security.py +6 -2
- kailash/servers/enterprise_workflow_server.py +58 -2
- kailash/servers/workflow_server.py +3 -0
- kailash/workflow/builder.py +102 -14
- kailash/workflow/cyclic_runner.py +102 -10
- kailash/workflow/visualization.py +99 -27
- {kailash-0.7.0.dist-info → kailash-0.8.0.dist-info}/METADATA +3 -2
- {kailash-0.7.0.dist-info → kailash-0.8.0.dist-info}/RECORD +31 -28
- kailash/workflow/builder_improvements.py +0 -207
- {kailash-0.7.0.dist-info → kailash-0.8.0.dist-info}/WHEEL +0 -0
- {kailash-0.7.0.dist-info → kailash-0.8.0.dist-info}/entry_points.txt +0 -0
- {kailash-0.7.0.dist-info → kailash-0.8.0.dist-info}/licenses/LICENSE +0 -0
- {kailash-0.7.0.dist-info → kailash-0.8.0.dist-info}/top_level.txt +0 -0
kailash/__init__.py
CHANGED
kailash/access_control.py
CHANGED
@@ -435,6 +435,17 @@ class AccessControlManager:
|
|
435
435
|
self, user: UserContext, workflow_id: str, permission: WorkflowPermission
|
436
436
|
) -> AccessDecision:
|
437
437
|
"""Check if user has permission on workflow"""
|
438
|
+
# If access control is disabled, allow all access
|
439
|
+
if not self.enabled:
|
440
|
+
return AccessDecision(
|
441
|
+
allowed=True,
|
442
|
+
reason="Access control disabled",
|
443
|
+
applied_rules=[],
|
444
|
+
conditions_met={},
|
445
|
+
masked_fields=[],
|
446
|
+
redirect_node=None,
|
447
|
+
)
|
448
|
+
|
438
449
|
cache_key = f"workflow:{workflow_id}:{user.user_id}:{permission.value}"
|
439
450
|
|
440
451
|
# Check cache
|
@@ -462,6 +473,17 @@ class AccessControlManager:
|
|
462
473
|
runtime_context: dict[str, Any] = None,
|
463
474
|
) -> AccessDecision:
|
464
475
|
"""Check if user has permission on node"""
|
476
|
+
# If access control is disabled, allow all access
|
477
|
+
if not self.enabled:
|
478
|
+
return AccessDecision(
|
479
|
+
allowed=True,
|
480
|
+
reason="Access control disabled",
|
481
|
+
applied_rules=[],
|
482
|
+
conditions_met={},
|
483
|
+
masked_fields=[],
|
484
|
+
redirect_node=None,
|
485
|
+
)
|
486
|
+
|
465
487
|
cache_key = f"node:{node_id}:{user.user_id}:{permission.value}"
|
466
488
|
|
467
489
|
# For runtime-dependent permissions, don't use cache
|
@@ -494,65 +516,56 @@ class AccessControlManager:
|
|
494
516
|
return decision
|
495
517
|
|
496
518
|
def get_accessible_nodes(
|
497
|
-
self, user: UserContext,
|
498
|
-
) ->
|
499
|
-
"""Get all nodes user can access
|
500
|
-
|
501
|
-
|
502
|
-
|
503
|
-
|
504
|
-
|
505
|
-
|
506
|
-
|
507
|
-
|
508
|
-
|
509
|
-
for rule in node_rules:
|
510
|
-
if self._rule_applies_to_user(rule, user):
|
511
|
-
if rule.effect == PermissionEffect.ALLOW:
|
512
|
-
accessible.add(rule.resource_id)
|
513
|
-
elif rule.effect == PermissionEffect.DENY:
|
514
|
-
accessible.discard(rule.resource_id)
|
515
|
-
|
519
|
+
self, user: UserContext, nodes: list[str], permission: NodePermission
|
520
|
+
) -> list[str]:
|
521
|
+
"""Get all nodes user can access from a list of nodes"""
|
522
|
+
# If access control is disabled, allow access to all nodes
|
523
|
+
if not self.enabled:
|
524
|
+
return nodes
|
525
|
+
|
526
|
+
accessible = []
|
527
|
+
for node_id in nodes:
|
528
|
+
decision = self.check_node_access(user, node_id, permission)
|
529
|
+
if decision.allowed:
|
530
|
+
accessible.append(node_id)
|
516
531
|
return accessible
|
517
532
|
|
518
533
|
def get_permission_based_route(
|
519
534
|
self,
|
520
535
|
user: UserContext,
|
521
|
-
|
522
|
-
|
523
|
-
|
524
|
-
) ->
|
525
|
-
"""
|
526
|
-
#
|
527
|
-
|
528
|
-
|
529
|
-
for node_id in true_path_nodes
|
530
|
-
)
|
536
|
+
node_id: str,
|
537
|
+
permission: NodePermission,
|
538
|
+
alternatives: dict[str, str] = None,
|
539
|
+
) -> str | None:
|
540
|
+
"""Get alternative route if user doesn't have access to node"""
|
541
|
+
# If access control is disabled, allow access to requested node
|
542
|
+
if not self.enabled:
|
543
|
+
return None
|
531
544
|
|
532
|
-
|
533
|
-
return true_path_nodes
|
534
|
-
else:
|
535
|
-
# Check false path
|
536
|
-
false_path_accessible = all(
|
537
|
-
self.check_node_access(user, node_id, NodePermission.EXECUTE).allowed
|
538
|
-
for node_id in false_path_nodes
|
539
|
-
)
|
545
|
+
decision = self.check_node_access(user, node_id, permission)
|
540
546
|
|
541
|
-
|
542
|
-
|
543
|
-
|
544
|
-
|
545
|
-
|
547
|
+
if decision.allowed:
|
548
|
+
return None # No alternative needed
|
549
|
+
|
550
|
+
# If access denied and alternatives provided, return alternative
|
551
|
+
if alternatives and node_id in alternatives:
|
552
|
+
return alternatives[node_id]
|
553
|
+
|
554
|
+
return None
|
546
555
|
|
547
556
|
def mask_node_output(
|
548
557
|
self, user: UserContext, node_id: str, output: dict[str, Any]
|
549
558
|
) -> dict[str, Any]:
|
550
559
|
"""Mask sensitive fields in node output"""
|
551
|
-
|
560
|
+
# If access control is disabled, don't mask anything
|
561
|
+
if not self.enabled:
|
562
|
+
return output
|
563
|
+
|
564
|
+
decision = self.check_node_access(user, node_id, NodePermission.MASK_OUTPUT)
|
552
565
|
|
553
566
|
if not decision.allowed:
|
554
|
-
#
|
555
|
-
return
|
567
|
+
# No masking rules found, return original output
|
568
|
+
return output
|
556
569
|
|
557
570
|
if decision.masked_fields:
|
558
571
|
# Mask specific fields
|
@@ -626,7 +639,12 @@ class AccessControlManager:
|
|
626
639
|
break
|
627
640
|
|
628
641
|
allowed = final_effect == PermissionEffect.ALLOW
|
629
|
-
|
642
|
+
|
643
|
+
# Set reason based on rules found
|
644
|
+
if not applicable_rules:
|
645
|
+
reason = f"No matching rules found for {resource_type} {resource_id}"
|
646
|
+
else:
|
647
|
+
reason = f"Permission {permission.value} {'granted' if allowed else 'denied'} for {resource_type} {resource_id}"
|
630
648
|
|
631
649
|
return AccessDecision(
|
632
650
|
allowed=allowed,
|
kailash/api/workflow_api.py
CHANGED
@@ -11,7 +11,7 @@ from enum import Enum
|
|
11
11
|
from typing import Any
|
12
12
|
|
13
13
|
import uvicorn
|
14
|
-
from fastapi import BackgroundTasks, FastAPI, HTTPException
|
14
|
+
from fastapi import BackgroundTasks, FastAPI, HTTPException, Request
|
15
15
|
from fastapi.responses import StreamingResponse
|
16
16
|
from pydantic import BaseModel, Field
|
17
17
|
|
@@ -31,12 +31,26 @@ class ExecutionMode(str, Enum):
|
|
31
31
|
class WorkflowRequest(BaseModel):
|
32
32
|
"""Base request model for workflow execution."""
|
33
33
|
|
34
|
-
inputs: dict[str, Any] = Field(
|
34
|
+
inputs: dict[str, Any] | None = Field(
|
35
|
+
None, description="Input data for workflow nodes"
|
36
|
+
)
|
37
|
+
parameters: dict[str, Any] | None = Field(
|
38
|
+
None, description="Legacy: parameters for workflow execution"
|
39
|
+
)
|
35
40
|
config: dict[str, Any] | None = Field(
|
36
41
|
None, description="Node configuration overrides"
|
37
42
|
)
|
38
43
|
mode: ExecutionMode = Field(ExecutionMode.SYNC, description="Execution mode")
|
39
44
|
|
45
|
+
def get_inputs(self) -> dict[str, Any]:
|
46
|
+
"""Get inputs, supporting both 'inputs' and 'parameters' format."""
|
47
|
+
if self.inputs is not None:
|
48
|
+
return self.inputs
|
49
|
+
elif self.parameters is not None:
|
50
|
+
return self.parameters
|
51
|
+
else:
|
52
|
+
return {}
|
53
|
+
|
40
54
|
|
41
55
|
class WorkflowResponse(BaseModel):
|
42
56
|
"""Base response model for workflow execution."""
|
@@ -115,6 +129,21 @@ class WorkflowAPI:
|
|
115
129
|
def _setup_routes(self):
|
116
130
|
"""Setup API routes dynamically based on workflow."""
|
117
131
|
|
132
|
+
# Root execution endpoint (convenience for direct workflow execution)
|
133
|
+
@self.app.post("/")
|
134
|
+
async def execute_workflow_root(
|
135
|
+
request: Request, background_tasks: BackgroundTasks
|
136
|
+
):
|
137
|
+
"""Execute the workflow with provided inputs (root endpoint)."""
|
138
|
+
try:
|
139
|
+
# Try to parse JSON body
|
140
|
+
json_data = await request.json()
|
141
|
+
workflow_request = WorkflowRequest(**json_data)
|
142
|
+
except:
|
143
|
+
# If no JSON or invalid JSON, create empty request
|
144
|
+
workflow_request = WorkflowRequest()
|
145
|
+
return await execute_workflow(workflow_request, background_tasks)
|
146
|
+
|
118
147
|
# Main execution endpoint
|
119
148
|
@self.app.post("/execute")
|
120
149
|
async def execute_workflow(
|
@@ -195,7 +224,9 @@ class WorkflowAPI:
|
|
195
224
|
|
196
225
|
# Execute workflow with inputs
|
197
226
|
results = await asyncio.to_thread(
|
198
|
-
self.runtime.execute,
|
227
|
+
self.runtime.execute,
|
228
|
+
self.workflow_graph,
|
229
|
+
parameters=request.get_inputs(),
|
199
230
|
)
|
200
231
|
|
201
232
|
# Handle tuple return from runtime
|
kailash/mcp_server/discovery.py
CHANGED
@@ -486,7 +486,7 @@ class FileBasedDiscovery(DiscoveryBackend):
|
|
486
486
|
raise
|
487
487
|
|
488
488
|
|
489
|
-
class NetworkDiscovery:
|
489
|
+
class NetworkDiscovery(asyncio.DatagramProtocol):
|
490
490
|
"""Network-based discovery using UDP broadcast/multicast."""
|
491
491
|
|
492
492
|
DISCOVERY_PORT = 8765
|
@@ -535,6 +535,40 @@ class NetworkDiscovery:
|
|
535
535
|
self._transport = None
|
536
536
|
self._protocol = None
|
537
537
|
|
538
|
+
# AsyncIO DatagramProtocol methods
|
539
|
+
def connection_made(self, transport):
|
540
|
+
"""Called when a connection is made."""
|
541
|
+
self._transport = transport
|
542
|
+
logger.info(f"Network discovery protocol connected on port {self.port}")
|
543
|
+
|
544
|
+
def datagram_received(self, data, addr):
|
545
|
+
"""Called when a datagram is received."""
|
546
|
+
try:
|
547
|
+
message = json.loads(data.decode())
|
548
|
+
# Try to get current event loop
|
549
|
+
try:
|
550
|
+
asyncio.get_running_loop()
|
551
|
+
asyncio.create_task(self._handle_discovery_message(message, addr))
|
552
|
+
except RuntimeError:
|
553
|
+
# No event loop, run synchronously
|
554
|
+
asyncio.run(self._handle_discovery_message(message, addr))
|
555
|
+
except json.JSONDecodeError:
|
556
|
+
logger.warning(f"Invalid JSON received from {addr}")
|
557
|
+
except Exception as e:
|
558
|
+
logger.error(f"Error handling datagram from {addr}: {e}")
|
559
|
+
|
560
|
+
def error_received(self, exc):
|
561
|
+
"""Called when an error is received."""
|
562
|
+
logger.error(f"Network discovery protocol error: {exc}")
|
563
|
+
|
564
|
+
def connection_lost(self, exc):
|
565
|
+
"""Called when the connection is lost."""
|
566
|
+
if exc:
|
567
|
+
logger.error(f"Network discovery connection lost: {exc}")
|
568
|
+
else:
|
569
|
+
logger.info("Network discovery connection closed")
|
570
|
+
self.running = False
|
571
|
+
|
538
572
|
async def start_discovery_listener(self):
|
539
573
|
"""Start listening for server announcements."""
|
540
574
|
await self.start()
|
@@ -590,6 +624,27 @@ class NetworkDiscovery:
|
|
590
624
|
except (json.JSONDecodeError, KeyError) as e:
|
591
625
|
logger.debug(f"Invalid announcement from {addr[0]}: {e}")
|
592
626
|
|
627
|
+
async def _is_port_open(self, host: str, port: int, timeout: float = 1.0) -> bool:
|
628
|
+
"""Check if a port is open on a host.
|
629
|
+
|
630
|
+
Args:
|
631
|
+
host: Host to check
|
632
|
+
port: Port to check
|
633
|
+
timeout: Connection timeout
|
634
|
+
|
635
|
+
Returns:
|
636
|
+
True if port is open, False otherwise
|
637
|
+
"""
|
638
|
+
try:
|
639
|
+
# Create socket connection with timeout
|
640
|
+
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
641
|
+
sock.settimeout(timeout)
|
642
|
+
result = sock.connect_ex((host, port))
|
643
|
+
sock.close()
|
644
|
+
return result == 0
|
645
|
+
except (OSError, socket.error, socket.timeout):
|
646
|
+
return False
|
647
|
+
|
593
648
|
async def scan_network(
|
594
649
|
self, network: str = "192.168.1.0/24", timeout: float = 5.0
|
595
650
|
) -> List[ServerInfo]:
|
@@ -756,22 +811,6 @@ class NetworkDiscovery:
|
|
756
811
|
else:
|
757
812
|
logger.debug(f"Unknown message type: {msg_type}")
|
758
813
|
|
759
|
-
def datagram_received(self, data: bytes, addr: tuple):
|
760
|
-
"""Handle received datagram (part of asyncio protocol)."""
|
761
|
-
try:
|
762
|
-
message = json.loads(data.decode())
|
763
|
-
# Try to get current event loop
|
764
|
-
try:
|
765
|
-
loop = asyncio.get_running_loop()
|
766
|
-
asyncio.create_task(self._handle_discovery_message(message, addr))
|
767
|
-
except RuntimeError:
|
768
|
-
# No event loop, run synchronously
|
769
|
-
asyncio.run(self._handle_discovery_message(message, addr))
|
770
|
-
except json.JSONDecodeError:
|
771
|
-
logger.warning(f"Invalid JSON received from {addr}")
|
772
|
-
except Exception as e:
|
773
|
-
logger.error(f"Error handling datagram from {addr}: {e}")
|
774
|
-
|
775
814
|
|
776
815
|
class ServiceRegistry:
|
777
816
|
"""Main service registry coordinating multiple discovery backends."""
|
@@ -331,9 +331,23 @@ class APIGateway:
|
|
331
331
|
def _setup_session_routes(self):
|
332
332
|
"""Setup session management routes."""
|
333
333
|
|
334
|
+
# Create auth dependency
|
335
|
+
async def get_optional_current_user():
|
336
|
+
"""Optional auth dependency - returns None if auth is disabled."""
|
337
|
+
if self.enable_auth and self.auth_manager:
|
338
|
+
# Use auth manager's dependency if available
|
339
|
+
try:
|
340
|
+
# This would normally use the auth manager's get_current_user_dependency
|
341
|
+
# For now, return None to avoid complex auth setup
|
342
|
+
return None
|
343
|
+
except:
|
344
|
+
return None
|
345
|
+
return None
|
346
|
+
|
334
347
|
@self.app.post("/api/sessions", response_model=SessionResponse)
|
335
348
|
async def create_session(
|
336
|
-
request: SessionCreateRequest,
|
349
|
+
request: SessionCreateRequest,
|
350
|
+
current_user: Dict[str, Any] = Depends(get_optional_current_user),
|
337
351
|
):
|
338
352
|
"""Create a new session for a frontend client."""
|
339
353
|
try:
|
@@ -583,7 +597,13 @@ class APIGateway:
|
|
583
597
|
"""Get schemas for available node types."""
|
584
598
|
try:
|
585
599
|
# Get all registered nodes
|
586
|
-
|
600
|
+
# NodeRegistry doesn't have get_all_nodes, need to use _nodes directly
|
601
|
+
available_nodes = {}
|
602
|
+
if hasattr(self.node_registry, "_nodes"):
|
603
|
+
available_nodes = self.node_registry._nodes.copy()
|
604
|
+
else:
|
605
|
+
# Fallback - return empty dict
|
606
|
+
available_nodes = {}
|
587
607
|
|
588
608
|
# Filter by requested types if specified
|
589
609
|
if request.node_types:
|
@@ -611,7 +631,7 @@ class APIGateway:
|
|
611
631
|
@self.app.get("/api/schemas/nodes/{node_type}")
|
612
632
|
async def get_node_schema(node_type: str):
|
613
633
|
"""Get schema for a specific node type."""
|
614
|
-
node_class = self.node_registry.
|
634
|
+
node_class = self.node_registry.get(node_type)
|
615
635
|
if not node_class:
|
616
636
|
raise HTTPException(status_code=404, detail="Node type not found")
|
617
637
|
|
@@ -177,6 +177,89 @@ class ConnectionManager:
|
|
177
177
|
"active_users": len(self.user_connections),
|
178
178
|
"total_messages_sent": total_messages,
|
179
179
|
}
|
180
|
+
|
181
|
+
def filter_events(self, events: List[BaseEvent], event_filter: EventFilter = None) -> List[BaseEvent]:
|
182
|
+
"""Filter events based on event filter criteria."""
|
183
|
+
if not event_filter:
|
184
|
+
return events
|
185
|
+
|
186
|
+
filtered = []
|
187
|
+
for event in events:
|
188
|
+
# Apply session filter
|
189
|
+
if event_filter.session_id and hasattr(event, 'session_id'):
|
190
|
+
if event.session_id != event_filter.session_id:
|
191
|
+
continue
|
192
|
+
|
193
|
+
# Apply user filter
|
194
|
+
if event_filter.user_id and hasattr(event, 'user_id'):
|
195
|
+
if event.user_id != event_filter.user_id:
|
196
|
+
continue
|
197
|
+
|
198
|
+
# Apply event type filter
|
199
|
+
if event_filter.event_types and event.event_type not in event_filter.event_types:
|
200
|
+
continue
|
201
|
+
|
202
|
+
filtered.append(event)
|
203
|
+
|
204
|
+
return filtered
|
205
|
+
|
206
|
+
def set_event_filter(self, connection_id: str, event_filter: EventFilter):
|
207
|
+
"""Set event filter for a specific connection."""
|
208
|
+
if connection_id in self.connections:
|
209
|
+
self.connections[connection_id]["event_filter"] = event_filter
|
210
|
+
|
211
|
+
def get_event_filter(self, connection_id: str) -> Optional[EventFilter]:
|
212
|
+
"""Get event filter for a specific connection."""
|
213
|
+
if connection_id in self.connections:
|
214
|
+
return self.connections[connection_id].get("event_filter")
|
215
|
+
return None
|
216
|
+
|
217
|
+
# Alias methods for compatibility
|
218
|
+
def event_filter(self, events: List[BaseEvent], filter_criteria: EventFilter = None) -> List[BaseEvent]:
|
219
|
+
"""Alias for filter_events method."""
|
220
|
+
return self.filter_events(events, filter_criteria)
|
221
|
+
|
222
|
+
async def on_event(self, event: BaseEvent):
|
223
|
+
"""Handle incoming event - route to appropriate connections."""
|
224
|
+
await self.handle_event(event)
|
225
|
+
|
226
|
+
async def handle_event(self, event: BaseEvent):
|
227
|
+
"""Handle and route event to matching connections."""
|
228
|
+
await self.process_event(event)
|
229
|
+
|
230
|
+
async def process_event(self, event: BaseEvent):
|
231
|
+
"""Process event and broadcast to matching connections."""
|
232
|
+
message = {
|
233
|
+
"type": "event",
|
234
|
+
"event_type": event.event_type.value if hasattr(event.event_type, 'value') else str(event.event_type),
|
235
|
+
"data": event.data,
|
236
|
+
"timestamp": event.timestamp.isoformat() if hasattr(event, 'timestamp') else datetime.now(timezone.utc).isoformat(),
|
237
|
+
"session_id": getattr(event, 'session_id', None),
|
238
|
+
"user_id": getattr(event, 'user_id', None),
|
239
|
+
}
|
240
|
+
|
241
|
+
# Broadcast to all matching connections
|
242
|
+
for connection_id, connection in self.connections.items():
|
243
|
+
event_filter = connection.get("event_filter")
|
244
|
+
|
245
|
+
# Check if this connection should receive this event
|
246
|
+
should_send = True
|
247
|
+
if event_filter:
|
248
|
+
# Apply session filter
|
249
|
+
if event_filter.session_id and connection["session_id"] != event_filter.session_id:
|
250
|
+
should_send = False
|
251
|
+
|
252
|
+
# Apply user filter
|
253
|
+
if event_filter.user_id and connection["user_id"] != event_filter.user_id:
|
254
|
+
should_send = False
|
255
|
+
|
256
|
+
# Apply event type filter
|
257
|
+
if hasattr(event_filter, 'event_types') and event_filter.event_types:
|
258
|
+
if event.event_type not in event_filter.event_types:
|
259
|
+
should_send = False
|
260
|
+
|
261
|
+
if should_send:
|
262
|
+
await self.send_to_connection(connection_id, message)
|
180
263
|
|
181
264
|
|
182
265
|
class SSEManager:
|
@@ -944,7 +944,7 @@ class AgentUIMiddleware:
|
|
944
944
|
async def get_available_nodes(self) -> List[Dict[str, Any]]:
|
945
945
|
"""Get all available node types with their schemas."""
|
946
946
|
nodes = []
|
947
|
-
for node_name, node_class in self.node_registry.
|
947
|
+
for node_name, node_class in self.node_registry._nodes.items():
|
948
948
|
# Get node schema (would be implemented in schema.py)
|
949
949
|
schema = await self._get_node_schema(node_class)
|
950
950
|
nodes.append(
|