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 CHANGED
@@ -52,7 +52,7 @@ except ImportError:
52
52
  # For backward compatibility
53
53
  WorkflowGraph = Workflow
54
54
 
55
- __version__ = "0.7.0"
55
+ __version__ = "0.8.0"
56
56
 
57
57
  __all__ = [
58
58
  # Core workflow components
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, workflow_id: str, permission: NodePermission
498
- ) -> set[str]:
499
- """Get all nodes user can access in a workflow"""
500
- accessible = set()
501
-
502
- # Get all node rules for this workflow
503
- node_rules = [
504
- r
505
- for r in self.rules
506
- if r.resource_type == "node" and r.permission == permission
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
- conditional_node_id: str,
522
- true_path_nodes: list[str],
523
- false_path_nodes: list[str],
524
- ) -> list[str]:
525
- """Determine which path user should take based on permissions"""
526
- # Check if user has access to nodes in true path
527
- true_path_accessible = all(
528
- self.check_node_access(user, node_id, NodePermission.EXECUTE).allowed
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
- if true_path_accessible:
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
- if false_path_accessible:
542
- return false_path_nodes
543
- else:
544
- # User can't access either path
545
- return []
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
- decision = self.check_node_access(user, node_id, NodePermission.READ_OUTPUT)
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
- # Completely mask output
555
- return {"_masked": True, "reason": "Access denied"}
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
- reason = f"Permission {permission.value} {'granted' if allowed else 'denied'} for {resource_type} {resource_id}"
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,
@@ -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(..., description="Input data for workflow nodes")
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, self.workflow_graph, parameters=request.inputs
227
+ self.runtime.execute,
228
+ self.workflow_graph,
229
+ parameters=request.get_inputs(),
199
230
  )
200
231
 
201
232
  # Handle tuple return from runtime
@@ -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, current_user: Dict[str, Any] = None
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
- available_nodes = self.node_registry.get_all_nodes()
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.get_node(node_type)
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.nodes.items():
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(