kailash 0.1.5__py3-none-any.whl → 0.2.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 +740 -0
- kailash/api/__main__.py +6 -0
- kailash/api/auth.py +668 -0
- kailash/api/custom_nodes.py +285 -0
- kailash/api/custom_nodes_secure.py +377 -0
- kailash/api/database.py +620 -0
- kailash/api/studio.py +915 -0
- kailash/api/studio_secure.py +893 -0
- kailash/mcp/__init__.py +53 -0
- kailash/mcp/__main__.py +13 -0
- kailash/mcp/ai_registry_server.py +712 -0
- kailash/mcp/client.py +447 -0
- kailash/mcp/client_new.py +334 -0
- kailash/mcp/server.py +293 -0
- kailash/mcp/server_new.py +336 -0
- kailash/mcp/servers/__init__.py +12 -0
- kailash/mcp/servers/ai_registry.py +289 -0
- kailash/nodes/__init__.py +4 -2
- kailash/nodes/ai/__init__.py +2 -0
- kailash/nodes/ai/a2a.py +714 -67
- kailash/nodes/ai/intelligent_agent_orchestrator.py +31 -37
- kailash/nodes/ai/iterative_llm_agent.py +1280 -0
- kailash/nodes/ai/llm_agent.py +324 -1
- kailash/nodes/ai/self_organizing.py +5 -6
- kailash/nodes/base.py +15 -2
- kailash/nodes/base_async.py +45 -0
- kailash/nodes/base_cycle_aware.py +374 -0
- kailash/nodes/base_with_acl.py +338 -0
- kailash/nodes/code/python.py +135 -27
- kailash/nodes/data/readers.py +16 -6
- kailash/nodes/data/writers.py +16 -6
- kailash/nodes/logic/__init__.py +8 -0
- kailash/nodes/logic/convergence.py +642 -0
- kailash/nodes/logic/loop.py +153 -0
- kailash/nodes/logic/operations.py +187 -27
- kailash/nodes/mixins/__init__.py +11 -0
- kailash/nodes/mixins/mcp.py +228 -0
- kailash/nodes/mixins.py +387 -0
- kailash/runtime/__init__.py +2 -1
- kailash/runtime/access_controlled.py +458 -0
- kailash/runtime/local.py +106 -33
- kailash/runtime/parallel_cyclic.py +529 -0
- kailash/sdk_exceptions.py +90 -5
- kailash/security.py +845 -0
- kailash/tracking/manager.py +38 -15
- kailash/tracking/models.py +1 -1
- kailash/tracking/storage/filesystem.py +30 -2
- kailash/utils/__init__.py +8 -0
- kailash/workflow/__init__.py +18 -0
- kailash/workflow/convergence.py +270 -0
- kailash/workflow/cycle_analyzer.py +768 -0
- kailash/workflow/cycle_builder.py +573 -0
- kailash/workflow/cycle_config.py +709 -0
- kailash/workflow/cycle_debugger.py +760 -0
- kailash/workflow/cycle_exceptions.py +601 -0
- kailash/workflow/cycle_profiler.py +671 -0
- kailash/workflow/cycle_state.py +338 -0
- kailash/workflow/cyclic_runner.py +985 -0
- kailash/workflow/graph.py +500 -39
- kailash/workflow/migration.py +768 -0
- kailash/workflow/safety.py +365 -0
- kailash/workflow/templates.py +744 -0
- kailash/workflow/validation.py +693 -0
- {kailash-0.1.5.dist-info → kailash-0.2.0.dist-info}/METADATA +256 -12
- kailash-0.2.0.dist-info/RECORD +125 -0
- kailash/nodes/mcp/__init__.py +0 -11
- kailash/nodes/mcp/client.py +0 -554
- kailash/nodes/mcp/resource.py +0 -682
- kailash/nodes/mcp/server.py +0 -577
- kailash-0.1.5.dist-info/RECORD +0 -88
- {kailash-0.1.5.dist-info → kailash-0.2.0.dist-info}/WHEEL +0 -0
- {kailash-0.1.5.dist-info → kailash-0.2.0.dist-info}/entry_points.txt +0 -0
- {kailash-0.1.5.dist-info → kailash-0.2.0.dist-info}/licenses/LICENSE +0 -0
- {kailash-0.1.5.dist-info → kailash-0.2.0.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,740 @@
|
|
1
|
+
"""
|
2
|
+
Access Control System for Kailash SDK
|
3
|
+
|
4
|
+
This module provides fine-grained access control at the workflow and node level,
|
5
|
+
enabling permission-based execution paths and data access restrictions.
|
6
|
+
|
7
|
+
Key Features:
|
8
|
+
- Node-level permissions (execute, read_output, write_input)
|
9
|
+
- Workflow-level permissions (view, execute, modify)
|
10
|
+
- Permission-based conditional routing
|
11
|
+
- Data masking for restricted nodes
|
12
|
+
- Audit logging of access attempts
|
13
|
+
- Integration with JWT authentication
|
14
|
+
|
15
|
+
Design Philosophy:
|
16
|
+
- Fail-safe defaults (deny by default)
|
17
|
+
- Minimal performance overhead
|
18
|
+
- Transparent to existing workflows
|
19
|
+
- Flexible permission models
|
20
|
+
|
21
|
+
Usage:
|
22
|
+
>>> from kailash.access_control import AccessControlManager, NodePermission, UserContext
|
23
|
+
>>> acm = AccessControlManager()
|
24
|
+
>>> user_context = UserContext(user_id="test", tenant_id="test", email="test@test.com")
|
25
|
+
>>> node_id = "test_node"
|
26
|
+
>>> decision = acm.check_node_access(user_context, node_id, NodePermission.EXECUTE)
|
27
|
+
>>> decision.allowed
|
28
|
+
False
|
29
|
+
|
30
|
+
Implementation:
|
31
|
+
Access control is enforced at multiple levels:
|
32
|
+
1. Workflow level - Can user execute/view the workflow?
|
33
|
+
2. Node level - Can user execute specific nodes?
|
34
|
+
3. Data level - Can user see outputs from specific nodes?
|
35
|
+
4. Routing level - Which path should user take based on permissions?
|
36
|
+
|
37
|
+
Security Considerations:
|
38
|
+
- Permissions are cached per execution for performance
|
39
|
+
- Access denied by default for unknown users/nodes
|
40
|
+
- All access attempts are logged for audit
|
41
|
+
- Sensitive data is masked, not just hidden
|
42
|
+
|
43
|
+
Testing:
|
44
|
+
See tests/test_access_control.py for comprehensive tests
|
45
|
+
|
46
|
+
Future Enhancements:
|
47
|
+
- Dynamic permission evaluation based on data
|
48
|
+
- Time-based access restrictions
|
49
|
+
- Delegation and impersonation support
|
50
|
+
"""
|
51
|
+
|
52
|
+
import logging
|
53
|
+
import threading
|
54
|
+
from dataclasses import dataclass, field
|
55
|
+
from datetime import datetime, timezone
|
56
|
+
from enum import Enum
|
57
|
+
from typing import Any, Callable, Dict, List, Optional, Set, Union
|
58
|
+
|
59
|
+
logger = logging.getLogger(__name__)
|
60
|
+
|
61
|
+
|
62
|
+
class WorkflowPermission(Enum):
|
63
|
+
"""Workflow-level permissions"""
|
64
|
+
|
65
|
+
VIEW = "view" # Can see workflow exists
|
66
|
+
EXECUTE = "execute" # Can run workflow
|
67
|
+
MODIFY = "modify" # Can edit workflow
|
68
|
+
DELETE = "delete" # Can delete workflow
|
69
|
+
SHARE = "share" # Can share with others
|
70
|
+
ADMIN = "admin" # Full control
|
71
|
+
|
72
|
+
|
73
|
+
class NodePermission(Enum):
|
74
|
+
"""Node-level permissions"""
|
75
|
+
|
76
|
+
EXECUTE = "execute" # Can execute node
|
77
|
+
READ_OUTPUT = "read_output" # Can see node outputs
|
78
|
+
WRITE_INPUT = "write_input" # Can provide inputs
|
79
|
+
SKIP = "skip" # Node is skipped for user
|
80
|
+
MASK_OUTPUT = "mask_output" # Output is masked/redacted
|
81
|
+
|
82
|
+
|
83
|
+
class PermissionEffect(Enum):
|
84
|
+
"""Effect of a permission rule"""
|
85
|
+
|
86
|
+
ALLOW = "allow"
|
87
|
+
DENY = "deny"
|
88
|
+
CONDITIONAL = "conditional" # Depends on runtime evaluation
|
89
|
+
|
90
|
+
|
91
|
+
@dataclass
|
92
|
+
class UserContext:
|
93
|
+
"""
|
94
|
+
User context for access control decisions.
|
95
|
+
|
96
|
+
Contains all information needed to make access control decisions for a user,
|
97
|
+
including identity, tenant membership, roles, and session information.
|
98
|
+
|
99
|
+
Design Purpose:
|
100
|
+
Provides a standardized way to represent user identity and permissions
|
101
|
+
across the entire access control system. Enables fine-grained access
|
102
|
+
control based on user attributes, roles, and context.
|
103
|
+
|
104
|
+
Upstream Dependencies:
|
105
|
+
- Authentication systems (JWT, API keys)
|
106
|
+
- User management systems
|
107
|
+
- Tenant management systems
|
108
|
+
|
109
|
+
Downstream Consumers:
|
110
|
+
- AccessControlManager for permission checking
|
111
|
+
- AccessControlledRuntime for workflow execution
|
112
|
+
- Audit logging systems for tracking access
|
113
|
+
|
114
|
+
Usage Patterns:
|
115
|
+
- Created during authentication/login process
|
116
|
+
- Passed to all access control functions
|
117
|
+
- Used in workflow and node execution contexts
|
118
|
+
- Logged for audit and compliance purposes
|
119
|
+
|
120
|
+
Implementation Details:
|
121
|
+
Uses dataclass for efficient attribute access and comparison.
|
122
|
+
Immutable once created to prevent privilege escalation.
|
123
|
+
Supports custom attributes for extensible authorization.
|
124
|
+
|
125
|
+
Example:
|
126
|
+
>>> user = UserContext(
|
127
|
+
... user_id="user123",
|
128
|
+
... tenant_id="tenant001",
|
129
|
+
... email="user@example.com",
|
130
|
+
... roles=["analyst", "viewer"]
|
131
|
+
... )
|
132
|
+
>>> print(user.user_id)
|
133
|
+
user123
|
134
|
+
"""
|
135
|
+
|
136
|
+
user_id: str
|
137
|
+
tenant_id: str
|
138
|
+
email: str
|
139
|
+
roles: List[str] = field(default_factory=list)
|
140
|
+
permissions: List[str] = field(default_factory=list)
|
141
|
+
attributes: Dict[str, Any] = field(default_factory=dict) # Custom attributes
|
142
|
+
session_id: Optional[str] = None
|
143
|
+
ip_address: Optional[str] = None
|
144
|
+
|
145
|
+
|
146
|
+
@dataclass
|
147
|
+
class PermissionRule:
|
148
|
+
"""
|
149
|
+
A single permission rule defining access control policies.
|
150
|
+
|
151
|
+
Represents a single access control rule that grants or denies permissions
|
152
|
+
to users for specific resources (workflows or nodes) based on their
|
153
|
+
identity, roles, and contextual conditions.
|
154
|
+
|
155
|
+
Design Purpose:
|
156
|
+
Provides a flexible, declarative way to define access control policies.
|
157
|
+
Supports role-based access control (RBAC), attribute-based access control (ABAC),
|
158
|
+
and conditional permissions based on runtime context.
|
159
|
+
|
160
|
+
Upstream Dependencies:
|
161
|
+
- Administrative interfaces for rule creation
|
162
|
+
- Policy management systems
|
163
|
+
- Configuration files or databases
|
164
|
+
|
165
|
+
Downstream Consumers:
|
166
|
+
- AccessControlManager for rule evaluation
|
167
|
+
- Audit systems for logging policy decisions
|
168
|
+
- Policy analysis tools for rule validation
|
169
|
+
|
170
|
+
Usage Patterns:
|
171
|
+
- Created by administrators to define access policies
|
172
|
+
- Evaluated during workflow and node execution
|
173
|
+
- Cached for performance optimization
|
174
|
+
- Updated when policies change
|
175
|
+
|
176
|
+
Implementation Details:
|
177
|
+
Uses dataclass for efficient serialization and comparison.
|
178
|
+
Supports priority-based rule ordering for conflict resolution.
|
179
|
+
Includes expiration for time-limited permissions.
|
180
|
+
Conditions enable complex policy logic.
|
181
|
+
|
182
|
+
Example:
|
183
|
+
>>> rule = PermissionRule(
|
184
|
+
... id="allow_analysts_read",
|
185
|
+
... resource_type="node",
|
186
|
+
... resource_id="sensitive_data",
|
187
|
+
... permission=NodePermission.READ_OUTPUT,
|
188
|
+
... effect=PermissionEffect.ALLOW,
|
189
|
+
... role="analyst"
|
190
|
+
... )
|
191
|
+
>>> print(rule.id)
|
192
|
+
allow_analysts_read
|
193
|
+
"""
|
194
|
+
|
195
|
+
id: str
|
196
|
+
resource_type: str # "workflow" or "node"
|
197
|
+
resource_id: str # workflow_id or node_id
|
198
|
+
permission: Union[WorkflowPermission, NodePermission]
|
199
|
+
effect: PermissionEffect
|
200
|
+
|
201
|
+
# Who does this apply to?
|
202
|
+
user_id: Optional[str] = None # Specific user
|
203
|
+
role: Optional[str] = None # Any user with role
|
204
|
+
tenant_id: Optional[str] = None # All users in tenant
|
205
|
+
|
206
|
+
# Conditions
|
207
|
+
conditions: Dict[str, Any] = field(default_factory=dict)
|
208
|
+
|
209
|
+
# Metadata
|
210
|
+
created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
|
211
|
+
created_by: Optional[str] = None
|
212
|
+
expires_at: Optional[datetime] = None
|
213
|
+
priority: int = 0 # Higher priority rules evaluated first
|
214
|
+
|
215
|
+
|
216
|
+
@dataclass
|
217
|
+
class AccessDecision:
|
218
|
+
"""
|
219
|
+
Result of an access control decision.
|
220
|
+
|
221
|
+
Contains the outcome of evaluating access control rules for a specific
|
222
|
+
user and resource, including whether access is allowed, the reasoning,
|
223
|
+
and any additional actions required (like data masking).
|
224
|
+
|
225
|
+
Design Purpose:
|
226
|
+
Provides a comprehensive result object that captures not just the
|
227
|
+
allow/deny decision, but also the context and reasoning behind it.
|
228
|
+
Enables audit logging, debugging, and conditional execution.
|
229
|
+
|
230
|
+
Upstream Dependencies:
|
231
|
+
- AccessControlManager rule evaluation
|
232
|
+
- Permission rule matching logic
|
233
|
+
- Conditional evaluation systems
|
234
|
+
|
235
|
+
Downstream Consumers:
|
236
|
+
- AccessControlledRuntime for execution decisions
|
237
|
+
- Audit logging systems for compliance
|
238
|
+
- Error handling for access denial messages
|
239
|
+
- Data masking systems for output filtering
|
240
|
+
|
241
|
+
Usage Patterns:
|
242
|
+
- Returned by all access control check methods
|
243
|
+
- Logged for audit and debugging purposes
|
244
|
+
- Used to determine execution flow
|
245
|
+
- Provides user-friendly error messages
|
246
|
+
|
247
|
+
Implementation Details:
|
248
|
+
Immutable after creation to ensure decision integrity.
|
249
|
+
Includes applied rules for transparency and debugging.
|
250
|
+
Supports conditional decisions for complex scenarios.
|
251
|
+
Contains masking information for data protection.
|
252
|
+
|
253
|
+
Example:
|
254
|
+
>>> decision = AccessDecision(
|
255
|
+
... allowed=True,
|
256
|
+
... reason="User has analyst role",
|
257
|
+
... masked_fields=["ssn", "phone"]
|
258
|
+
... )
|
259
|
+
>>> print(decision.allowed)
|
260
|
+
True
|
261
|
+
"""
|
262
|
+
|
263
|
+
allowed: bool
|
264
|
+
reason: str
|
265
|
+
applied_rules: List[PermissionRule] = field(default_factory=list)
|
266
|
+
conditions_met: Dict[str, bool] = field(default_factory=dict)
|
267
|
+
masked_fields: List[str] = field(default_factory=list) # Fields to mask in output
|
268
|
+
redirect_node: Optional[str] = None # Alternative node to execute
|
269
|
+
|
270
|
+
|
271
|
+
class ConditionEvaluator:
|
272
|
+
"""Evaluates conditions for conditional permissions"""
|
273
|
+
|
274
|
+
def __init__(self):
|
275
|
+
self.evaluators: Dict[str, Callable] = {
|
276
|
+
"time_range": self._eval_time_range,
|
277
|
+
"data_contains": self._eval_data_contains,
|
278
|
+
"user_attribute": self._eval_user_attribute,
|
279
|
+
"ip_range": self._eval_ip_range,
|
280
|
+
"custom": self._eval_custom,
|
281
|
+
}
|
282
|
+
|
283
|
+
def evaluate(
|
284
|
+
self, condition_type: str, condition_value: Any, context: Dict[str, Any]
|
285
|
+
) -> bool:
|
286
|
+
"""Evaluate a condition"""
|
287
|
+
evaluator = self.evaluators.get(condition_type)
|
288
|
+
if not evaluator:
|
289
|
+
logger.warning(f"Unknown condition type: {condition_type}")
|
290
|
+
return False
|
291
|
+
|
292
|
+
try:
|
293
|
+
return evaluator(condition_value, context)
|
294
|
+
except Exception as e:
|
295
|
+
logger.error(f"Error evaluating condition {condition_type}: {e}")
|
296
|
+
return False
|
297
|
+
|
298
|
+
def _eval_time_range(self, value: Dict[str, str], context: Dict[str, Any]) -> bool:
|
299
|
+
"""Check if current time is within range"""
|
300
|
+
from datetime import datetime, time
|
301
|
+
|
302
|
+
now = datetime.now().time()
|
303
|
+
start = time.fromisoformat(value.get("start", "00:00"))
|
304
|
+
end = time.fromisoformat(value.get("end", "23:59"))
|
305
|
+
return start <= now <= end
|
306
|
+
|
307
|
+
def _eval_data_contains(
|
308
|
+
self, value: Dict[str, Any], context: Dict[str, Any]
|
309
|
+
) -> bool:
|
310
|
+
"""Check if data contains specific values"""
|
311
|
+
data = context.get("data", {})
|
312
|
+
field = value.get("field")
|
313
|
+
expected = value.get("value")
|
314
|
+
|
315
|
+
if field and field in data:
|
316
|
+
return data[field] == expected
|
317
|
+
return False
|
318
|
+
|
319
|
+
def _eval_user_attribute(
|
320
|
+
self, value: Dict[str, Any], context: Dict[str, Any]
|
321
|
+
) -> bool:
|
322
|
+
"""Check user attributes"""
|
323
|
+
user = context.get("user")
|
324
|
+
if not user:
|
325
|
+
return False
|
326
|
+
|
327
|
+
attr_name = value.get("attribute")
|
328
|
+
expected = value.get("value")
|
329
|
+
|
330
|
+
return user.attributes.get(attr_name) == expected
|
331
|
+
|
332
|
+
def _eval_ip_range(self, value: Dict[str, Any], context: Dict[str, Any]) -> bool:
|
333
|
+
"""Check if IP is in allowed range"""
|
334
|
+
# Simplified IP check - in production use ipaddress module
|
335
|
+
allowed_ips = value.get("allowed", [])
|
336
|
+
user_ip = context.get("user", {}).get("ip_address")
|
337
|
+
|
338
|
+
return user_ip in allowed_ips
|
339
|
+
|
340
|
+
def _eval_custom(self, value: Dict[str, Any], context: Dict[str, Any]) -> bool:
|
341
|
+
"""Evaluate custom condition"""
|
342
|
+
# This would call a custom function registered by the user
|
343
|
+
return True
|
344
|
+
|
345
|
+
|
346
|
+
class AccessControlManager:
|
347
|
+
"""
|
348
|
+
Main access control manager for the Kailash SDK.
|
349
|
+
|
350
|
+
Centralized manager for evaluating access control policies, managing permission
|
351
|
+
rules, and making authorization decisions for workflows and nodes. Provides
|
352
|
+
caching, audit logging, and conditional permission evaluation.
|
353
|
+
|
354
|
+
Design Purpose:
|
355
|
+
Serves as the single source of truth for access control decisions.
|
356
|
+
Separates policy definition from policy enforcement, enabling
|
357
|
+
flexible and maintainable security policies.
|
358
|
+
|
359
|
+
Upstream Dependencies:
|
360
|
+
- Administrative interfaces for rule management
|
361
|
+
- Configuration systems for policy definition
|
362
|
+
- Authentication systems for user context
|
363
|
+
|
364
|
+
Downstream Consumers:
|
365
|
+
- AccessControlledRuntime for workflow execution
|
366
|
+
- Node execution systems for permission checks
|
367
|
+
- Audit and monitoring systems for compliance
|
368
|
+
- Administrative tools for policy analysis
|
369
|
+
|
370
|
+
Usage Patterns:
|
371
|
+
- Created once per application/service instance
|
372
|
+
- Rules added during startup or configuration
|
373
|
+
- Permission checks made during execution
|
374
|
+
- Cache managed automatically for performance
|
375
|
+
|
376
|
+
Implementation Details:
|
377
|
+
Thread-safe with locking for cache management.
|
378
|
+
Rules evaluated in priority order with fail-safe defaults.
|
379
|
+
Conditional evaluation supports complex policies.
|
380
|
+
Audit logging for all access decisions.
|
381
|
+
|
382
|
+
Error Handling:
|
383
|
+
- Invalid rules are logged and ignored
|
384
|
+
- Missing permissions default to deny
|
385
|
+
- Evaluation errors are logged and treated as deny
|
386
|
+
- Cache errors fall back to direct evaluation
|
387
|
+
|
388
|
+
Side Effects:
|
389
|
+
- Logs audit events for all access decisions
|
390
|
+
- Caches results for performance optimization
|
391
|
+
- May trigger security alerts on repeated denials
|
392
|
+
|
393
|
+
Example:
|
394
|
+
>>> acm = AccessControlManager(enabled=True)
|
395
|
+
>>> rule = PermissionRule(
|
396
|
+
... id="allow_admin",
|
397
|
+
... resource_type="workflow",
|
398
|
+
... resource_id="sensitive_workflow",
|
399
|
+
... permission=WorkflowPermission.EXECUTE,
|
400
|
+
... effect=PermissionEffect.ALLOW,
|
401
|
+
... role="admin"
|
402
|
+
... )
|
403
|
+
>>> acm.add_rule(rule)
|
404
|
+
>>> user = UserContext(user_id="admin1", tenant_id="test", email="admin@test.com", roles=["admin"])
|
405
|
+
>>> decision = acm.check_workflow_access(user, "sensitive_workflow", WorkflowPermission.EXECUTE)
|
406
|
+
>>> decision.allowed
|
407
|
+
True
|
408
|
+
"""
|
409
|
+
|
410
|
+
def __init__(self, enabled: bool = False):
|
411
|
+
self.enabled = enabled # Disabled by default
|
412
|
+
self.rules: List[PermissionRule] = []
|
413
|
+
self.condition_evaluator = ConditionEvaluator()
|
414
|
+
self._cache = {} # Cache access decisions
|
415
|
+
self._cache_lock = threading.Lock()
|
416
|
+
|
417
|
+
# Audit logger
|
418
|
+
self.audit_logger = logging.getLogger("kailash.access_control.audit")
|
419
|
+
|
420
|
+
def add_rule(self, rule: PermissionRule):
|
421
|
+
"""Add a permission rule"""
|
422
|
+
self.rules.append(rule)
|
423
|
+
# Clear cache when rules change
|
424
|
+
with self._cache_lock:
|
425
|
+
self._cache.clear()
|
426
|
+
|
427
|
+
def remove_rule(self, rule_id: str):
|
428
|
+
"""Remove a permission rule"""
|
429
|
+
self.rules = [r for r in self.rules if r.id != rule_id]
|
430
|
+
with self._cache_lock:
|
431
|
+
self._cache.clear()
|
432
|
+
|
433
|
+
def check_workflow_access(
|
434
|
+
self, user: UserContext, workflow_id: str, permission: WorkflowPermission
|
435
|
+
) -> AccessDecision:
|
436
|
+
"""Check if user has permission on workflow"""
|
437
|
+
cache_key = f"workflow:{workflow_id}:{user.user_id}:{permission.value}"
|
438
|
+
|
439
|
+
# Check cache
|
440
|
+
with self._cache_lock:
|
441
|
+
if cache_key in self._cache:
|
442
|
+
return self._cache[cache_key]
|
443
|
+
|
444
|
+
# Evaluate rules
|
445
|
+
decision = self._evaluate_rules(user, "workflow", workflow_id, permission, {})
|
446
|
+
|
447
|
+
# Cache decision
|
448
|
+
with self._cache_lock:
|
449
|
+
self._cache[cache_key] = decision
|
450
|
+
|
451
|
+
# Audit log
|
452
|
+
self._audit_log(user, "workflow", workflow_id, permission, decision)
|
453
|
+
|
454
|
+
return decision
|
455
|
+
|
456
|
+
def check_node_access(
|
457
|
+
self,
|
458
|
+
user: UserContext,
|
459
|
+
node_id: str,
|
460
|
+
permission: NodePermission,
|
461
|
+
runtime_context: Dict[str, Any] = None,
|
462
|
+
) -> AccessDecision:
|
463
|
+
"""Check if user has permission on node"""
|
464
|
+
cache_key = f"node:{node_id}:{user.user_id}:{permission.value}"
|
465
|
+
|
466
|
+
# For runtime-dependent permissions, don't use cache
|
467
|
+
if runtime_context and any(
|
468
|
+
r.effect == PermissionEffect.CONDITIONAL
|
469
|
+
for r in self.rules
|
470
|
+
if r.resource_id == node_id
|
471
|
+
):
|
472
|
+
return self._evaluate_rules(
|
473
|
+
user, "node", node_id, permission, runtime_context or {}
|
474
|
+
)
|
475
|
+
|
476
|
+
# Check cache
|
477
|
+
with self._cache_lock:
|
478
|
+
if cache_key in self._cache:
|
479
|
+
return self._cache[cache_key]
|
480
|
+
|
481
|
+
# Evaluate rules
|
482
|
+
decision = self._evaluate_rules(
|
483
|
+
user, "node", node_id, permission, runtime_context or {}
|
484
|
+
)
|
485
|
+
|
486
|
+
# Cache decision
|
487
|
+
with self._cache_lock:
|
488
|
+
self._cache[cache_key] = decision
|
489
|
+
|
490
|
+
# Audit log
|
491
|
+
self._audit_log(user, "node", node_id, permission, decision)
|
492
|
+
|
493
|
+
return decision
|
494
|
+
|
495
|
+
def get_accessible_nodes(
|
496
|
+
self, user: UserContext, workflow_id: str, permission: NodePermission
|
497
|
+
) -> Set[str]:
|
498
|
+
"""Get all nodes user can access in a workflow"""
|
499
|
+
accessible = set()
|
500
|
+
|
501
|
+
# Get all node rules for this workflow
|
502
|
+
node_rules = [
|
503
|
+
r
|
504
|
+
for r in self.rules
|
505
|
+
if r.resource_type == "node" and r.permission == permission
|
506
|
+
]
|
507
|
+
|
508
|
+
for rule in node_rules:
|
509
|
+
if self._rule_applies_to_user(rule, user):
|
510
|
+
if rule.effect == PermissionEffect.ALLOW:
|
511
|
+
accessible.add(rule.resource_id)
|
512
|
+
elif rule.effect == PermissionEffect.DENY:
|
513
|
+
accessible.discard(rule.resource_id)
|
514
|
+
|
515
|
+
return accessible
|
516
|
+
|
517
|
+
def get_permission_based_route(
|
518
|
+
self,
|
519
|
+
user: UserContext,
|
520
|
+
conditional_node_id: str,
|
521
|
+
true_path_nodes: List[str],
|
522
|
+
false_path_nodes: List[str],
|
523
|
+
) -> List[str]:
|
524
|
+
"""Determine which path user should take based on permissions"""
|
525
|
+
# Check if user has access to nodes in true path
|
526
|
+
true_path_accessible = all(
|
527
|
+
self.check_node_access(user, node_id, NodePermission.EXECUTE).allowed
|
528
|
+
for node_id in true_path_nodes
|
529
|
+
)
|
530
|
+
|
531
|
+
if true_path_accessible:
|
532
|
+
return true_path_nodes
|
533
|
+
else:
|
534
|
+
# Check false path
|
535
|
+
false_path_accessible = all(
|
536
|
+
self.check_node_access(user, node_id, NodePermission.EXECUTE).allowed
|
537
|
+
for node_id in false_path_nodes
|
538
|
+
)
|
539
|
+
|
540
|
+
if false_path_accessible:
|
541
|
+
return false_path_nodes
|
542
|
+
else:
|
543
|
+
# User can't access either path
|
544
|
+
return []
|
545
|
+
|
546
|
+
def mask_node_output(
|
547
|
+
self, user: UserContext, node_id: str, output: Dict[str, Any]
|
548
|
+
) -> Dict[str, Any]:
|
549
|
+
"""Mask sensitive fields in node output"""
|
550
|
+
decision = self.check_node_access(user, node_id, NodePermission.READ_OUTPUT)
|
551
|
+
|
552
|
+
if not decision.allowed:
|
553
|
+
# Completely mask output
|
554
|
+
return {"_masked": True, "reason": "Access denied"}
|
555
|
+
|
556
|
+
if decision.masked_fields:
|
557
|
+
# Mask specific fields
|
558
|
+
masked_output = output.copy()
|
559
|
+
for field in decision.masked_fields:
|
560
|
+
if field in masked_output:
|
561
|
+
masked_output[field] = "***MASKED***"
|
562
|
+
return masked_output
|
563
|
+
|
564
|
+
return output
|
565
|
+
|
566
|
+
def _evaluate_rules(
|
567
|
+
self,
|
568
|
+
user: UserContext,
|
569
|
+
resource_type: str,
|
570
|
+
resource_id: str,
|
571
|
+
permission: Union[WorkflowPermission, NodePermission],
|
572
|
+
runtime_context: Dict[str, Any],
|
573
|
+
) -> AccessDecision:
|
574
|
+
"""Evaluate all applicable rules"""
|
575
|
+
applicable_rules = []
|
576
|
+
|
577
|
+
# Find applicable rules
|
578
|
+
for rule in sorted(self.rules, key=lambda r: r.priority, reverse=True):
|
579
|
+
if (
|
580
|
+
rule.resource_type == resource_type
|
581
|
+
and rule.resource_id == resource_id
|
582
|
+
and rule.permission == permission
|
583
|
+
and self._rule_applies_to_user(rule, user)
|
584
|
+
):
|
585
|
+
|
586
|
+
# Check expiration
|
587
|
+
if rule.expires_at and rule.expires_at < datetime.now(timezone.utc):
|
588
|
+
continue
|
589
|
+
|
590
|
+
applicable_rules.append(rule)
|
591
|
+
|
592
|
+
# Evaluate rules
|
593
|
+
context = {
|
594
|
+
"user": user,
|
595
|
+
"runtime": runtime_context,
|
596
|
+
"timestamp": datetime.now(timezone.utc),
|
597
|
+
}
|
598
|
+
|
599
|
+
final_effect = PermissionEffect.DENY # Default deny
|
600
|
+
conditions_met = {}
|
601
|
+
masked_fields = []
|
602
|
+
|
603
|
+
for rule in applicable_rules:
|
604
|
+
if rule.effect == PermissionEffect.CONDITIONAL:
|
605
|
+
# Evaluate conditions
|
606
|
+
all_conditions_met = True
|
607
|
+
for cond_type, cond_value in rule.conditions.items():
|
608
|
+
met = self.condition_evaluator.evaluate(
|
609
|
+
cond_type, cond_value, context
|
610
|
+
)
|
611
|
+
conditions_met[f"{rule.id}:{cond_type}"] = met
|
612
|
+
if not met:
|
613
|
+
all_conditions_met = False
|
614
|
+
break
|
615
|
+
|
616
|
+
if all_conditions_met:
|
617
|
+
final_effect = PermissionEffect.ALLOW
|
618
|
+
if "masked_fields" in rule.conditions:
|
619
|
+
masked_fields.extend(rule.conditions["masked_fields"])
|
620
|
+
else:
|
621
|
+
final_effect = rule.effect
|
622
|
+
|
623
|
+
# Explicit deny takes precedence
|
624
|
+
if final_effect == PermissionEffect.DENY:
|
625
|
+
break
|
626
|
+
|
627
|
+
allowed = final_effect == PermissionEffect.ALLOW
|
628
|
+
reason = f"Permission {permission.value} {'granted' if allowed else 'denied'} for {resource_type} {resource_id}"
|
629
|
+
|
630
|
+
return AccessDecision(
|
631
|
+
allowed=allowed,
|
632
|
+
reason=reason,
|
633
|
+
applied_rules=applicable_rules,
|
634
|
+
conditions_met=conditions_met,
|
635
|
+
masked_fields=masked_fields,
|
636
|
+
)
|
637
|
+
|
638
|
+
def _rule_applies_to_user(self, rule: PermissionRule, user: UserContext) -> bool:
|
639
|
+
"""Check if a rule applies to a user"""
|
640
|
+
# Specific user
|
641
|
+
if rule.user_id and rule.user_id == user.user_id:
|
642
|
+
return True
|
643
|
+
|
644
|
+
# Role-based
|
645
|
+
if rule.role and rule.role in user.roles:
|
646
|
+
return True
|
647
|
+
|
648
|
+
# Tenant-based
|
649
|
+
if rule.tenant_id and rule.tenant_id == user.tenant_id:
|
650
|
+
return True
|
651
|
+
|
652
|
+
# No restrictions means it applies to all
|
653
|
+
if not rule.user_id and not rule.role and not rule.tenant_id:
|
654
|
+
return True
|
655
|
+
|
656
|
+
return False
|
657
|
+
|
658
|
+
def _audit_log(
|
659
|
+
self,
|
660
|
+
user: UserContext,
|
661
|
+
resource_type: str,
|
662
|
+
resource_id: str,
|
663
|
+
permission: Union[WorkflowPermission, NodePermission],
|
664
|
+
decision: AccessDecision,
|
665
|
+
):
|
666
|
+
"""Log access attempt for audit"""
|
667
|
+
self.audit_logger.info(
|
668
|
+
f"Access {'granted' if decision.allowed else 'denied'}: "
|
669
|
+
f"user={user.user_id}, resource={resource_type}:{resource_id}, "
|
670
|
+
f"permission={permission.value}, reason={decision.reason}"
|
671
|
+
)
|
672
|
+
|
673
|
+
|
674
|
+
# Global access control manager
|
675
|
+
_access_control_manager = AccessControlManager()
|
676
|
+
|
677
|
+
|
678
|
+
def get_access_control_manager() -> AccessControlManager:
|
679
|
+
"""Get the global access control manager"""
|
680
|
+
return _access_control_manager
|
681
|
+
|
682
|
+
|
683
|
+
def set_access_control_manager(manager: AccessControlManager):
|
684
|
+
"""Set a custom access control manager"""
|
685
|
+
global _access_control_manager
|
686
|
+
_access_control_manager = manager
|
687
|
+
|
688
|
+
|
689
|
+
# Decorators for easy integration
|
690
|
+
def require_workflow_permission(permission: WorkflowPermission):
|
691
|
+
"""Decorator to require workflow permission"""
|
692
|
+
|
693
|
+
def decorator(func):
|
694
|
+
def wrapper(self, *args, **kwargs):
|
695
|
+
# Extract user context and workflow ID from self
|
696
|
+
user = getattr(self, "user_context", None)
|
697
|
+
workflow_id = getattr(self, "workflow_id", None)
|
698
|
+
|
699
|
+
if user and workflow_id:
|
700
|
+
acm = get_access_control_manager()
|
701
|
+
decision = acm.check_workflow_access(user, workflow_id, permission)
|
702
|
+
|
703
|
+
if not decision.allowed:
|
704
|
+
raise PermissionError(f"Access denied: {decision.reason}")
|
705
|
+
|
706
|
+
return func(self, *args, **kwargs)
|
707
|
+
|
708
|
+
return wrapper
|
709
|
+
|
710
|
+
return decorator
|
711
|
+
|
712
|
+
|
713
|
+
def require_node_permission(permission: NodePermission):
|
714
|
+
"""Decorator to require node permission"""
|
715
|
+
|
716
|
+
def decorator(func):
|
717
|
+
def wrapper(self, *args, **kwargs):
|
718
|
+
# Extract user context and node ID from self
|
719
|
+
user = getattr(self, "user_context", None)
|
720
|
+
node_id = getattr(self, "node_id", self.__class__.__name__)
|
721
|
+
|
722
|
+
if user:
|
723
|
+
acm = get_access_control_manager()
|
724
|
+
runtime_context = kwargs.get("_runtime_context", {})
|
725
|
+
decision = acm.check_node_access(
|
726
|
+
user, node_id, permission, runtime_context
|
727
|
+
)
|
728
|
+
|
729
|
+
if not decision.allowed:
|
730
|
+
if permission == NodePermission.EXECUTE and decision.redirect_node:
|
731
|
+
# Redirect to alternative node
|
732
|
+
kwargs["_redirect_to"] = decision.redirect_node
|
733
|
+
else:
|
734
|
+
raise PermissionError(f"Access denied: {decision.reason}")
|
735
|
+
|
736
|
+
return func(self, *args, **kwargs)
|
737
|
+
|
738
|
+
return wrapper
|
739
|
+
|
740
|
+
return decorator
|