kailash 0.5.0__py3-none-any.whl → 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.
- kailash/__init__.py +1 -1
- kailash/client/__init__.py +12 -0
- kailash/client/enhanced_client.py +306 -0
- kailash/core/actors/__init__.py +16 -0
- kailash/core/actors/connection_actor.py +566 -0
- kailash/core/actors/supervisor.py +364 -0
- kailash/edge/__init__.py +16 -0
- kailash/edge/compliance.py +834 -0
- kailash/edge/discovery.py +659 -0
- kailash/edge/location.py +582 -0
- kailash/gateway/__init__.py +33 -0
- kailash/gateway/api.py +289 -0
- kailash/gateway/enhanced_gateway.py +357 -0
- kailash/gateway/resource_resolver.py +217 -0
- kailash/gateway/security.py +227 -0
- kailash/middleware/auth/models.py +2 -2
- kailash/middleware/database/base_models.py +1 -7
- kailash/middleware/gateway/__init__.py +22 -0
- kailash/middleware/gateway/checkpoint_manager.py +398 -0
- kailash/middleware/gateway/deduplicator.py +382 -0
- kailash/middleware/gateway/durable_gateway.py +417 -0
- kailash/middleware/gateway/durable_request.py +498 -0
- kailash/middleware/gateway/event_store.py +459 -0
- kailash/nodes/admin/permission_check.py +817 -33
- kailash/nodes/admin/role_management.py +1242 -108
- kailash/nodes/admin/schema_manager.py +438 -0
- kailash/nodes/admin/user_management.py +1124 -1582
- kailash/nodes/code/__init__.py +8 -1
- kailash/nodes/code/async_python.py +1035 -0
- kailash/nodes/code/python.py +1 -0
- kailash/nodes/data/async_sql.py +9 -3
- kailash/nodes/data/sql.py +20 -11
- kailash/nodes/data/workflow_connection_pool.py +643 -0
- kailash/nodes/rag/__init__.py +1 -4
- kailash/resources/__init__.py +40 -0
- kailash/resources/factory.py +533 -0
- kailash/resources/health.py +319 -0
- kailash/resources/reference.py +288 -0
- kailash/resources/registry.py +392 -0
- kailash/runtime/async_local.py +711 -302
- kailash/testing/__init__.py +34 -0
- kailash/testing/async_test_case.py +353 -0
- kailash/testing/async_utils.py +345 -0
- kailash/testing/fixtures.py +458 -0
- kailash/testing/mock_registry.py +495 -0
- kailash/workflow/__init__.py +8 -0
- kailash/workflow/async_builder.py +621 -0
- kailash/workflow/async_patterns.py +766 -0
- kailash/workflow/cyclic_runner.py +107 -16
- kailash/workflow/graph.py +7 -2
- kailash/workflow/resilience.py +11 -1
- {kailash-0.5.0.dist-info → kailash-0.6.0.dist-info}/METADATA +7 -4
- {kailash-0.5.0.dist-info → kailash-0.6.0.dist-info}/RECORD +57 -22
- {kailash-0.5.0.dist-info → kailash-0.6.0.dist-info}/WHEEL +0 -0
- {kailash-0.5.0.dist-info → kailash-0.6.0.dist-info}/entry_points.txt +0 -0
- {kailash-0.5.0.dist-info → kailash-0.6.0.dist-info}/licenses/LICENSE +0 -0
- {kailash-0.5.0.dist-info → kailash-0.6.0.dist-info}/top_level.txt +0 -0
@@ -15,10 +15,11 @@ Features:
|
|
15
15
|
- Comprehensive audit logging
|
16
16
|
"""
|
17
17
|
|
18
|
+
import json
|
18
19
|
from dataclasses import dataclass
|
19
20
|
from datetime import UTC, datetime
|
20
21
|
from enum import Enum
|
21
|
-
from typing import Any, Dict, List, Optional, Set
|
22
|
+
from typing import Any, Dict, List, Optional, Set, Union
|
22
23
|
|
23
24
|
from kailash.access_control import (
|
24
25
|
AccessControlManager,
|
@@ -28,10 +29,48 @@ from kailash.access_control import (
|
|
28
29
|
WorkflowPermission,
|
29
30
|
)
|
30
31
|
from kailash.nodes.base import Node, NodeParameter, register_node
|
31
|
-
from kailash.nodes.data import
|
32
|
+
from kailash.nodes.data import SQLDatabaseNode
|
32
33
|
from kailash.sdk_exceptions import NodeExecutionError, NodeValidationError
|
33
34
|
|
34
35
|
|
36
|
+
def parse_datetime(value: Union[str, datetime, None]) -> Optional[datetime]:
|
37
|
+
"""Parse datetime from various formats."""
|
38
|
+
if value is None:
|
39
|
+
return None
|
40
|
+
if isinstance(value, datetime):
|
41
|
+
return value
|
42
|
+
if isinstance(value, str):
|
43
|
+
try:
|
44
|
+
# Try ISO format first
|
45
|
+
return datetime.fromisoformat(value.replace("Z", "+00:00"))
|
46
|
+
except ValueError:
|
47
|
+
# Try other common formats
|
48
|
+
for fmt in [
|
49
|
+
"%Y-%m-%d %H:%M:%S.%f",
|
50
|
+
"%Y-%m-%d %H:%M:%S",
|
51
|
+
"%Y-%m-%dT%H:%M:%S.%f",
|
52
|
+
"%Y-%m-%dT%H:%M:%S",
|
53
|
+
]:
|
54
|
+
try:
|
55
|
+
return datetime.strptime(value, fmt)
|
56
|
+
except ValueError:
|
57
|
+
continue
|
58
|
+
return None
|
59
|
+
|
60
|
+
|
61
|
+
def format_datetime(dt: Union[datetime, str, None]) -> Optional[str]:
|
62
|
+
"""Format datetime handling both datetime objects and strings."""
|
63
|
+
if dt is None:
|
64
|
+
return None
|
65
|
+
if isinstance(dt, datetime):
|
66
|
+
return dt.isoformat()
|
67
|
+
if isinstance(dt, str):
|
68
|
+
# Already a string, return as-is or try to parse and format
|
69
|
+
parsed = parse_datetime(dt)
|
70
|
+
return parsed.isoformat() if parsed else dt
|
71
|
+
return None
|
72
|
+
|
73
|
+
|
35
74
|
class RoleOperation(Enum):
|
36
75
|
"""Supported role management operations."""
|
37
76
|
|
@@ -253,6 +292,79 @@ class RoleManagementNode(Node):
|
|
253
292
|
required=False,
|
254
293
|
description="Search query for roles",
|
255
294
|
),
|
295
|
+
# Pagination
|
296
|
+
NodeParameter(
|
297
|
+
name="limit",
|
298
|
+
type=int,
|
299
|
+
required=False,
|
300
|
+
default=50,
|
301
|
+
description="Maximum number of results to return",
|
302
|
+
),
|
303
|
+
NodeParameter(
|
304
|
+
name="offset",
|
305
|
+
type=int,
|
306
|
+
required=False,
|
307
|
+
default=0,
|
308
|
+
description="Number of results to skip",
|
309
|
+
),
|
310
|
+
# Additional options
|
311
|
+
NodeParameter(
|
312
|
+
name="include_users",
|
313
|
+
type=bool,
|
314
|
+
required=False,
|
315
|
+
default=False,
|
316
|
+
description="Include user assignments in role details",
|
317
|
+
),
|
318
|
+
NodeParameter(
|
319
|
+
name="include_user_details",
|
320
|
+
type=bool,
|
321
|
+
required=False,
|
322
|
+
default=True,
|
323
|
+
description="Include detailed user information",
|
324
|
+
),
|
325
|
+
NodeParameter(
|
326
|
+
name="include_inactive",
|
327
|
+
type=bool,
|
328
|
+
required=False,
|
329
|
+
default=False,
|
330
|
+
description="Include inactive roles/users",
|
331
|
+
),
|
332
|
+
NodeParameter(
|
333
|
+
name="force",
|
334
|
+
type=bool,
|
335
|
+
required=False,
|
336
|
+
default=False,
|
337
|
+
description="Force operation even with dependencies",
|
338
|
+
),
|
339
|
+
NodeParameter(
|
340
|
+
name="fix_issues",
|
341
|
+
type=bool,
|
342
|
+
required=False,
|
343
|
+
default=False,
|
344
|
+
description="Automatically fix validation issues",
|
345
|
+
),
|
346
|
+
# Audit fields
|
347
|
+
NodeParameter(
|
348
|
+
name="created_by",
|
349
|
+
type=str,
|
350
|
+
required=False,
|
351
|
+
default="system",
|
352
|
+
description="User who created the role",
|
353
|
+
),
|
354
|
+
NodeParameter(
|
355
|
+
name="assigned_by",
|
356
|
+
type=str,
|
357
|
+
required=False,
|
358
|
+
default="system",
|
359
|
+
description="User who assigned the role",
|
360
|
+
),
|
361
|
+
NodeParameter(
|
362
|
+
name="unassigned_by",
|
363
|
+
type=str,
|
364
|
+
required=False,
|
365
|
+
default="system",
|
366
|
+
description="User who unassigned the role",
|
367
|
+
),
|
256
368
|
]
|
257
369
|
}
|
258
370
|
|
@@ -303,6 +415,10 @@ class RoleManagementNode(Node):
|
|
303
415
|
|
304
416
|
def _init_dependencies(self, inputs: Dict[str, Any]):
|
305
417
|
"""Initialize database and access manager dependencies."""
|
418
|
+
# Skip initialization if already initialized (for testing)
|
419
|
+
if hasattr(self, "_db_node") and self._db_node is not None:
|
420
|
+
return
|
421
|
+
|
306
422
|
# Get database config
|
307
423
|
db_config = inputs.get(
|
308
424
|
"database_config",
|
@@ -316,8 +432,8 @@ class RoleManagementNode(Node):
|
|
316
432
|
},
|
317
433
|
)
|
318
434
|
|
319
|
-
# Initialize
|
320
|
-
self._db_node =
|
435
|
+
# Initialize database node
|
436
|
+
self._db_node = SQLDatabaseNode(name="role_management_db", **db_config)
|
321
437
|
|
322
438
|
# Initialize enhanced access manager
|
323
439
|
self._access_manager = AccessControlManager(strategy="abac")
|
@@ -363,7 +479,7 @@ class RoleManagementNode(Node):
|
|
363
479
|
"created_by": inputs.get("created_by", "system"),
|
364
480
|
}
|
365
481
|
|
366
|
-
# Insert role into database
|
482
|
+
# Insert role into database with conflict resolution
|
367
483
|
insert_query = """
|
368
484
|
INSERT INTO roles (
|
369
485
|
role_id, name, description, role_type, permissions, parent_roles,
|
@@ -371,31 +487,37 @@ class RoleManagementNode(Node):
|
|
371
487
|
) VALUES (
|
372
488
|
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12
|
373
489
|
)
|
490
|
+
ON CONFLICT (role_id) DO UPDATE SET
|
491
|
+
name = EXCLUDED.name,
|
492
|
+
description = EXCLUDED.description,
|
493
|
+
role_type = EXCLUDED.role_type,
|
494
|
+
permissions = EXCLUDED.permissions,
|
495
|
+
parent_roles = EXCLUDED.parent_roles,
|
496
|
+
attributes = EXCLUDED.attributes,
|
497
|
+
is_active = EXCLUDED.is_active,
|
498
|
+
updated_at = EXCLUDED.updated_at,
|
499
|
+
created_by = EXCLUDED.created_by
|
374
500
|
"""
|
375
501
|
|
376
502
|
# Execute database insert
|
377
|
-
self._db_node.
|
378
|
-
|
379
|
-
|
380
|
-
"
|
381
|
-
|
382
|
-
|
383
|
-
|
384
|
-
|
385
|
-
|
386
|
-
|
387
|
-
|
388
|
-
|
389
|
-
|
390
|
-
|
391
|
-
|
392
|
-
|
393
|
-
],
|
394
|
-
}
|
503
|
+
db_result = self._db_node.run(
|
504
|
+
query=insert_query,
|
505
|
+
parameters=[
|
506
|
+
role_record["role_id"],
|
507
|
+
role_record["name"],
|
508
|
+
role_record["description"],
|
509
|
+
role_record["role_type"],
|
510
|
+
json.dumps(role_record["permissions"]), # Serialize list to JSON
|
511
|
+
json.dumps(role_record["parent_roles"]), # Serialize list to JSON
|
512
|
+
json.dumps(role_record["attributes"]), # Serialize dict to JSON
|
513
|
+
role_record["is_active"],
|
514
|
+
role_record["tenant_id"],
|
515
|
+
role_record["created_at"],
|
516
|
+
role_record["updated_at"],
|
517
|
+
role_record["created_by"],
|
518
|
+
],
|
395
519
|
)
|
396
520
|
|
397
|
-
db_result = self._db_node.run()
|
398
|
-
|
399
521
|
# Update child_roles for parent roles
|
400
522
|
if parent_roles:
|
401
523
|
self._update_child_roles(parent_roles, role_id, tenant_id, "add")
|
@@ -412,7 +534,7 @@ class RoleManagementNode(Node):
|
|
412
534
|
"attributes": role_record["attributes"],
|
413
535
|
"is_active": role_record["is_active"],
|
414
536
|
"tenant_id": role_record["tenant_id"],
|
415
|
-
"created_at": role_record["created_at"]
|
537
|
+
"created_at": format_datetime(role_record["created_at"]),
|
416
538
|
},
|
417
539
|
"operation": "create_role",
|
418
540
|
"success": True,
|
@@ -437,21 +559,17 @@ class RoleManagementNode(Node):
|
|
437
559
|
|
438
560
|
# Check if assignment already exists
|
439
561
|
existing_query = """
|
440
|
-
SELECT 1 FROM
|
441
|
-
WHERE user_id = $1 AND role_id = $2 AND tenant_id = $3
|
562
|
+
SELECT 1 FROM user_role_assignments
|
563
|
+
WHERE user_id = $1 AND role_id = $2 AND tenant_id = $3 AND is_active = true
|
442
564
|
"""
|
443
565
|
|
444
|
-
self._db_node.
|
445
|
-
|
446
|
-
|
447
|
-
|
448
|
-
"fetch_mode": "one",
|
449
|
-
}
|
566
|
+
existing = self._db_node.run(
|
567
|
+
query=existing_query,
|
568
|
+
parameters=[user_id, role_id, tenant_id],
|
569
|
+
result_format="dict",
|
450
570
|
)
|
451
571
|
|
452
|
-
existing
|
453
|
-
|
454
|
-
if existing.get("result", {}).get("data"):
|
572
|
+
if existing.get("data"):
|
455
573
|
return {
|
456
574
|
"result": {
|
457
575
|
"assignment": {
|
@@ -465,28 +583,28 @@ class RoleManagementNode(Node):
|
|
465
583
|
}
|
466
584
|
}
|
467
585
|
|
468
|
-
# Create assignment
|
586
|
+
# Create assignment with conflict resolution
|
469
587
|
now = datetime.now(UTC)
|
470
588
|
insert_query = """
|
471
|
-
INSERT INTO
|
589
|
+
INSERT INTO user_role_assignments (user_id, role_id, tenant_id, assigned_at, assigned_by)
|
472
590
|
VALUES ($1, $2, $3, $4, $5)
|
591
|
+
ON CONFLICT (user_id, role_id, tenant_id) DO UPDATE SET
|
592
|
+
assigned_at = EXCLUDED.assigned_at,
|
593
|
+
assigned_by = EXCLUDED.assigned_by,
|
594
|
+
is_active = true
|
473
595
|
"""
|
474
596
|
|
475
|
-
self._db_node.
|
476
|
-
|
477
|
-
|
478
|
-
|
479
|
-
|
480
|
-
|
481
|
-
|
482
|
-
|
483
|
-
|
484
|
-
],
|
485
|
-
}
|
597
|
+
db_result = self._db_node.run(
|
598
|
+
query=insert_query,
|
599
|
+
parameters=[
|
600
|
+
user_id,
|
601
|
+
role_id,
|
602
|
+
tenant_id,
|
603
|
+
now,
|
604
|
+
inputs.get("assigned_by", "system"),
|
605
|
+
],
|
486
606
|
)
|
487
607
|
|
488
|
-
db_result = self._db_node.run()
|
489
|
-
|
490
608
|
return {
|
491
609
|
"result": {
|
492
610
|
"assignment": {
|
@@ -622,14 +740,8 @@ class RoleManagementNode(Node):
|
|
622
740
|
|
623
741
|
params = [tenant_id] + list(parent_roles)
|
624
742
|
|
625
|
-
self._db_node.
|
626
|
-
|
627
|
-
)
|
628
|
-
|
629
|
-
result = self._db_node.run()
|
630
|
-
existing_roles = {
|
631
|
-
row["role_id"] for row in result.get("result", {}).get("data", [])
|
632
|
-
}
|
743
|
+
result = self._db_node.run(query=query, parameters=params, result_format="dict")
|
744
|
+
existing_roles = {row["role_id"] for row in result.get("data", [])}
|
633
745
|
|
634
746
|
missing_roles = parent_roles - existing_roles
|
635
747
|
if missing_roles:
|
@@ -686,12 +798,8 @@ class RoleManagementNode(Node):
|
|
686
798
|
query += " AND role_id != $2"
|
687
799
|
params.append(exclude_role)
|
688
800
|
|
689
|
-
self._db_node.
|
690
|
-
|
691
|
-
)
|
692
|
-
|
693
|
-
result = self._db_node.run()
|
694
|
-
roles_data = result.get("result", {}).get("data", [])
|
801
|
+
result = self._db_node.run(query=query, parameters=params, fetch_mode="all")
|
802
|
+
roles_data = result.get("data", [])
|
695
803
|
|
696
804
|
# Convert to hierarchy dict
|
697
805
|
hierarchy = {}
|
@@ -733,12 +841,11 @@ class RoleManagementNode(Node):
|
|
733
841
|
WHERE role_id = $1 AND tenant_id = $2
|
734
842
|
"""
|
735
843
|
|
736
|
-
self._db_node.
|
737
|
-
|
844
|
+
result = self._db_node.run(
|
845
|
+
query=query, parameters=[role_id, tenant_id], result_format="dict"
|
738
846
|
)
|
739
|
-
|
740
|
-
|
741
|
-
return result.get("result", {}).get("data")
|
847
|
+
data = result.get("data", [])
|
848
|
+
return data[0] if data else None
|
742
849
|
|
743
850
|
def _update_child_roles(
|
744
851
|
self,
|
@@ -747,77 +854,1104 @@ class RoleManagementNode(Node):
|
|
747
854
|
tenant_id: str,
|
748
855
|
operation: str,
|
749
856
|
):
|
750
|
-
"""Update child_roles arrays for parent roles."""
|
751
|
-
|
752
|
-
|
753
|
-
|
754
|
-
|
755
|
-
|
756
|
-
|
757
|
-
|
758
|
-
|
759
|
-
query = """
|
760
|
-
UPDATE roles
|
761
|
-
SET child_roles = array_remove(child_roles, $1),
|
762
|
-
updated_at = $2
|
763
|
-
WHERE role_id = ANY($3) AND tenant_id = $4
|
857
|
+
"""Update child_roles JSONB arrays for parent roles."""
|
858
|
+
# For now, let's just read the current child_roles, modify them in Python, and update
|
859
|
+
# This is simpler and more reliable than complex JSONB operations
|
860
|
+
|
861
|
+
for parent_role_id in parent_role_ids:
|
862
|
+
# Get current child roles
|
863
|
+
get_query = """
|
864
|
+
SELECT child_roles FROM roles
|
865
|
+
WHERE role_id = $1 AND tenant_id = $2
|
764
866
|
"""
|
765
867
|
|
766
|
-
|
767
|
-
|
768
|
-
|
769
|
-
"
|
770
|
-
|
771
|
-
datetime.now(UTC),
|
772
|
-
list(parent_role_ids),
|
773
|
-
tenant_id,
|
774
|
-
],
|
775
|
-
}
|
776
|
-
)
|
868
|
+
result = self._db_node.run(
|
869
|
+
query=get_query,
|
870
|
+
parameters=[parent_role_id, tenant_id],
|
871
|
+
result_format="dict",
|
872
|
+
)
|
777
873
|
|
778
|
-
|
874
|
+
if result.get("data"):
|
875
|
+
current_child_roles = result["data"][0].get("child_roles", [])
|
876
|
+
if isinstance(current_child_roles, str):
|
877
|
+
current_child_roles = json.loads(current_child_roles)
|
878
|
+
elif current_child_roles is None:
|
879
|
+
current_child_roles = []
|
880
|
+
|
881
|
+
# Modify the list
|
882
|
+
if operation == "add":
|
883
|
+
if child_role_id not in current_child_roles:
|
884
|
+
current_child_roles.append(child_role_id)
|
885
|
+
else: # remove
|
886
|
+
if child_role_id in current_child_roles:
|
887
|
+
current_child_roles.remove(child_role_id)
|
888
|
+
|
889
|
+
# Update the database
|
890
|
+
update_query = """
|
891
|
+
UPDATE roles
|
892
|
+
SET child_roles = $1, updated_at = $2
|
893
|
+
WHERE role_id = $3 AND tenant_id = $4
|
894
|
+
"""
|
895
|
+
|
896
|
+
self._db_node.run(
|
897
|
+
query=update_query,
|
898
|
+
parameters=[
|
899
|
+
json.dumps(current_child_roles),
|
900
|
+
datetime.now(UTC),
|
901
|
+
parent_role_id,
|
902
|
+
tenant_id,
|
903
|
+
],
|
904
|
+
)
|
779
905
|
|
780
906
|
# Additional operations would follow similar patterns
|
781
907
|
def _update_role(self, inputs: Dict[str, Any]) -> Dict[str, Any]:
|
782
908
|
"""Update role information."""
|
783
|
-
|
909
|
+
role_id = inputs["role_id"]
|
910
|
+
role_data = inputs["role_data"]
|
911
|
+
tenant_id = inputs.get("tenant_id", "default")
|
912
|
+
validate_hierarchy = inputs.get("validate_hierarchy", True)
|
913
|
+
|
914
|
+
# Validate role exists
|
915
|
+
existing_role = self._get_role_by_id(role_id, tenant_id)
|
916
|
+
if not existing_role:
|
917
|
+
raise NodeValidationError(f"Role not found: {role_id}")
|
918
|
+
|
919
|
+
# Validate parent roles if being updated
|
920
|
+
if "parent_roles" in role_data and validate_hierarchy:
|
921
|
+
parent_roles = set(role_data["parent_roles"])
|
922
|
+
self._validate_parent_roles_exist(parent_roles, tenant_id)
|
923
|
+
self._validate_no_circular_dependency(role_id, parent_roles, tenant_id)
|
924
|
+
|
925
|
+
# Build update fields
|
926
|
+
update_fields = []
|
927
|
+
params = []
|
928
|
+
param_count = 1
|
929
|
+
|
930
|
+
updatable_fields = {
|
931
|
+
"name": "name",
|
932
|
+
"description": "description",
|
933
|
+
"permissions": "permissions",
|
934
|
+
"parent_roles": "parent_roles",
|
935
|
+
"attributes": "attributes",
|
936
|
+
"is_active": "is_active",
|
937
|
+
}
|
938
|
+
|
939
|
+
for field, db_field in updatable_fields.items():
|
940
|
+
if field in role_data:
|
941
|
+
update_fields.append(f"{db_field} = ${param_count}")
|
942
|
+
# Serialize JSONB fields to JSON
|
943
|
+
if field in ["permissions", "parent_roles", "attributes"]:
|
944
|
+
params.append(json.dumps(role_data[field]))
|
945
|
+
else:
|
946
|
+
params.append(role_data[field])
|
947
|
+
param_count += 1
|
948
|
+
|
949
|
+
if not update_fields:
|
950
|
+
raise NodeValidationError("No valid fields provided for update")
|
951
|
+
|
952
|
+
# Add updated_at timestamp
|
953
|
+
update_fields.append(f"updated_at = ${param_count}")
|
954
|
+
params.append(datetime.now(UTC))
|
955
|
+
param_count += 1
|
956
|
+
|
957
|
+
# Add WHERE conditions
|
958
|
+
params.extend([role_id, tenant_id])
|
959
|
+
|
960
|
+
update_query = f"""
|
961
|
+
UPDATE roles
|
962
|
+
SET {', '.join(update_fields)}
|
963
|
+
WHERE role_id = ${param_count} AND tenant_id = ${param_count + 1}
|
964
|
+
RETURNING role_id, name, description, permissions, parent_roles, attributes, is_active, updated_at
|
965
|
+
"""
|
966
|
+
|
967
|
+
result = self._db_node.run(
|
968
|
+
query=update_query, parameters=params, fetch_mode="one"
|
969
|
+
)
|
970
|
+
updated_role = result.get("data", [])
|
971
|
+
updated_role = updated_role[0] if updated_role else None
|
972
|
+
|
973
|
+
if not updated_role:
|
974
|
+
raise NodeExecutionError(f"Failed to update role: {role_id}")
|
975
|
+
|
976
|
+
# Update child roles if parent_roles changed
|
977
|
+
if "parent_roles" in role_data:
|
978
|
+
old_parents = set(existing_role.get("parent_roles", []))
|
979
|
+
new_parents = set(role_data["parent_roles"])
|
980
|
+
|
981
|
+
# Remove from old parents
|
982
|
+
removed_parents = old_parents - new_parents
|
983
|
+
if removed_parents:
|
984
|
+
self._update_child_roles(removed_parents, role_id, tenant_id, "remove")
|
985
|
+
|
986
|
+
# Add to new parents
|
987
|
+
added_parents = new_parents - old_parents
|
988
|
+
if added_parents:
|
989
|
+
self._update_child_roles(added_parents, role_id, tenant_id, "add")
|
990
|
+
|
991
|
+
return {
|
992
|
+
"result": {
|
993
|
+
"role": {
|
994
|
+
"role_id": updated_role["role_id"],
|
995
|
+
"name": updated_role["name"],
|
996
|
+
"description": updated_role["description"],
|
997
|
+
"permissions": updated_role["permissions"],
|
998
|
+
"parent_roles": updated_role["parent_roles"],
|
999
|
+
"attributes": updated_role["attributes"],
|
1000
|
+
"is_active": updated_role["is_active"],
|
1001
|
+
"updated_at": format_datetime(updated_role["updated_at"]),
|
1002
|
+
},
|
1003
|
+
"operation": "update_role",
|
1004
|
+
"success": True,
|
1005
|
+
"timestamp": datetime.now(UTC).isoformat(),
|
1006
|
+
}
|
1007
|
+
}
|
784
1008
|
|
785
1009
|
def _delete_role(self, inputs: Dict[str, Any]) -> Dict[str, Any]:
|
786
1010
|
"""Delete role with dependency checking."""
|
787
|
-
|
1011
|
+
role_id = inputs["role_id"]
|
1012
|
+
tenant_id = inputs.get("tenant_id", "default")
|
1013
|
+
force = inputs.get("force", False)
|
1014
|
+
|
1015
|
+
# Validate role exists
|
1016
|
+
existing_role = self._get_role_by_id(role_id, tenant_id)
|
1017
|
+
if not existing_role:
|
1018
|
+
raise NodeValidationError(f"Role not found: {role_id}")
|
1019
|
+
|
1020
|
+
# Check for dependencies unless force delete
|
1021
|
+
if not force:
|
1022
|
+
# Check for child roles
|
1023
|
+
child_roles_query = """
|
1024
|
+
SELECT role_id FROM roles
|
1025
|
+
WHERE $1 = ANY(
|
1026
|
+
SELECT jsonb_array_elements_text(parent_roles)
|
1027
|
+
) AND tenant_id = $2 AND is_active = true
|
1028
|
+
"""
|
1029
|
+
|
1030
|
+
child_result = self._db_node.run(
|
1031
|
+
query=child_roles_query,
|
1032
|
+
parameters=[role_id, tenant_id],
|
1033
|
+
fetch_mode="all",
|
1034
|
+
)
|
1035
|
+
child_roles = child_result.get("data", [])
|
1036
|
+
|
1037
|
+
if child_roles:
|
1038
|
+
child_role_ids = [r["role_id"] for r in child_roles]
|
1039
|
+
raise NodeValidationError(
|
1040
|
+
f"Cannot delete role {role_id}: has child roles {child_role_ids}. Use force=True to override."
|
1041
|
+
)
|
1042
|
+
|
1043
|
+
# Check for user assignments
|
1044
|
+
user_assignments_query = """
|
1045
|
+
SELECT COUNT(*) as user_count FROM user_role_assignments
|
1046
|
+
WHERE role_id = $1 AND tenant_id = $2
|
1047
|
+
"""
|
1048
|
+
|
1049
|
+
user_result = self._db_node.run(
|
1050
|
+
query=user_assignments_query,
|
1051
|
+
parameters=[role_id, tenant_id],
|
1052
|
+
fetch_mode="one",
|
1053
|
+
)
|
1054
|
+
user_count = user_result.get("data", [{}])[0].get("user_count", 0)
|
1055
|
+
|
1056
|
+
if user_count > 0:
|
1057
|
+
raise NodeValidationError(
|
1058
|
+
f"Cannot delete role {role_id}: assigned to {user_count} users. Use force=True to override."
|
1059
|
+
)
|
1060
|
+
|
1061
|
+
# Store parent roles for cleanup
|
1062
|
+
parent_roles = set(existing_role.get("parent_roles", []))
|
1063
|
+
|
1064
|
+
# Delete user assignments if force
|
1065
|
+
if force:
|
1066
|
+
delete_assignments_query = """
|
1067
|
+
DELETE FROM user_role_assignments WHERE role_id = $1 AND tenant_id = $2
|
1068
|
+
"""
|
1069
|
+
|
1070
|
+
self._db_node.run(
|
1071
|
+
query=delete_assignments_query, parameters=[role_id, tenant_id]
|
1072
|
+
)
|
1073
|
+
|
1074
|
+
# Remove from child roles of other roles
|
1075
|
+
if parent_roles:
|
1076
|
+
self._update_child_roles(parent_roles, role_id, tenant_id, "remove")
|
1077
|
+
|
1078
|
+
# Update child roles to remove this as parent
|
1079
|
+
update_children_query = """
|
1080
|
+
UPDATE roles
|
1081
|
+
SET parent_roles = (
|
1082
|
+
SELECT COALESCE(
|
1083
|
+
json_agg(value),
|
1084
|
+
'[]'::json
|
1085
|
+
)
|
1086
|
+
FROM jsonb_array_elements_text(parent_roles) AS value
|
1087
|
+
WHERE value != $1
|
1088
|
+
)::jsonb,
|
1089
|
+
updated_at = $2
|
1090
|
+
WHERE $1 = ANY(
|
1091
|
+
SELECT jsonb_array_elements_text(parent_roles)
|
1092
|
+
) AND tenant_id = $3
|
1093
|
+
"""
|
1094
|
+
|
1095
|
+
self._db_node.run(
|
1096
|
+
query=update_children_query,
|
1097
|
+
parameters=[role_id, datetime.now(UTC), tenant_id],
|
1098
|
+
)
|
1099
|
+
|
1100
|
+
# Delete the role
|
1101
|
+
delete_query = """
|
1102
|
+
DELETE FROM roles WHERE role_id = $1 AND tenant_id = $2
|
1103
|
+
"""
|
1104
|
+
|
1105
|
+
self._db_node.run(query=delete_query, parameters=[role_id, tenant_id])
|
1106
|
+
|
1107
|
+
return {
|
1108
|
+
"result": {
|
1109
|
+
"role_id": role_id,
|
1110
|
+
"deleted": True,
|
1111
|
+
"force_used": force,
|
1112
|
+
"operation": "delete_role",
|
1113
|
+
"success": True,
|
1114
|
+
"timestamp": datetime.now(UTC).isoformat(),
|
1115
|
+
}
|
1116
|
+
}
|
788
1117
|
|
789
1118
|
def _list_roles(self, inputs: Dict[str, Any]) -> Dict[str, Any]:
|
790
1119
|
"""List roles with filtering and pagination."""
|
791
|
-
|
1120
|
+
tenant_id = inputs.get("tenant_id", "default")
|
1121
|
+
filters = inputs.get("filters", {})
|
1122
|
+
search_query = inputs.get("search_query", "")
|
1123
|
+
limit = inputs.get("limit", 50)
|
1124
|
+
offset = inputs.get("offset", 0)
|
1125
|
+
include_inherited = inputs.get("include_inherited", False)
|
1126
|
+
|
1127
|
+
# Build WHERE conditions
|
1128
|
+
where_conditions = ["tenant_id = $1"]
|
1129
|
+
params = [tenant_id]
|
1130
|
+
param_count = 2
|
1131
|
+
|
1132
|
+
# Add search filter
|
1133
|
+
if search_query:
|
1134
|
+
where_conditions.append(
|
1135
|
+
f"(name ILIKE ${param_count} OR description ILIKE ${param_count})"
|
1136
|
+
)
|
1137
|
+
params.append(f"%{search_query}%")
|
1138
|
+
param_count += 1
|
1139
|
+
|
1140
|
+
# Add field filters
|
1141
|
+
if "role_type" in filters:
|
1142
|
+
where_conditions.append(f"role_type = ${param_count}")
|
1143
|
+
params.append(filters["role_type"])
|
1144
|
+
param_count += 1
|
1145
|
+
|
1146
|
+
if "is_active" in filters:
|
1147
|
+
where_conditions.append(f"is_active = ${param_count}")
|
1148
|
+
params.append(filters["is_active"])
|
1149
|
+
param_count += 1
|
1150
|
+
|
1151
|
+
if "has_permissions" in filters:
|
1152
|
+
where_conditions.append("array_length(permissions, 1) > 0")
|
1153
|
+
|
1154
|
+
# Build query
|
1155
|
+
base_query = f"""
|
1156
|
+
SELECT role_id, name, description, role_type, permissions, parent_roles,
|
1157
|
+
child_roles, attributes, is_active, created_at, updated_at, created_by
|
1158
|
+
FROM roles
|
1159
|
+
WHERE {' AND '.join(where_conditions)}
|
1160
|
+
ORDER BY created_at DESC
|
1161
|
+
"""
|
1162
|
+
|
1163
|
+
# Add pagination
|
1164
|
+
if limit > 0:
|
1165
|
+
base_query += f" LIMIT ${param_count}"
|
1166
|
+
params.append(limit)
|
1167
|
+
param_count += 1
|
1168
|
+
|
1169
|
+
if offset > 0:
|
1170
|
+
base_query += f" OFFSET ${param_count}"
|
1171
|
+
params.append(offset)
|
1172
|
+
|
1173
|
+
result = self._db_node.run(
|
1174
|
+
query=base_query, parameters=params, fetch_mode="all"
|
1175
|
+
)
|
1176
|
+
roles_data = result.get("data", [])
|
1177
|
+
|
1178
|
+
# Get total count
|
1179
|
+
count_query = f"""
|
1180
|
+
SELECT COUNT(*) as total
|
1181
|
+
FROM roles
|
1182
|
+
WHERE {' AND '.join(where_conditions)}
|
1183
|
+
"""
|
1184
|
+
|
1185
|
+
count_result = self._db_node.run(
|
1186
|
+
query=count_query,
|
1187
|
+
parameters=(
|
1188
|
+
params[: param_count - 2] if limit > 0 else params
|
1189
|
+
), # Exclude LIMIT/OFFSET
|
1190
|
+
fetch_mode="one",
|
1191
|
+
)
|
1192
|
+
total_count = count_result.get("data", [{}])[0].get("total", 0)
|
1193
|
+
|
1194
|
+
# Enhance roles with inherited permissions if requested
|
1195
|
+
enhanced_roles = []
|
1196
|
+
for role_data in roles_data:
|
1197
|
+
enhanced_role = {
|
1198
|
+
"role_id": role_data["role_id"],
|
1199
|
+
"name": role_data["name"],
|
1200
|
+
"description": role_data["description"],
|
1201
|
+
"role_type": role_data["role_type"],
|
1202
|
+
"permissions": role_data["permissions"],
|
1203
|
+
"parent_roles": role_data["parent_roles"],
|
1204
|
+
"child_roles": role_data["child_roles"],
|
1205
|
+
"attributes": role_data["attributes"],
|
1206
|
+
"is_active": role_data["is_active"],
|
1207
|
+
"created_at": format_datetime(role_data["created_at"]),
|
1208
|
+
"updated_at": format_datetime(role_data["updated_at"]),
|
1209
|
+
"created_by": role_data["created_by"],
|
1210
|
+
}
|
1211
|
+
|
1212
|
+
if include_inherited:
|
1213
|
+
# Get inherited permissions
|
1214
|
+
role_hierarchy = self._build_role_hierarchy(tenant_id)
|
1215
|
+
inherited_perms = self._get_inherited_permissions(
|
1216
|
+
role_data["role_id"], role_hierarchy
|
1217
|
+
)
|
1218
|
+
all_perms = set(role_data["permissions"]) | inherited_perms
|
1219
|
+
|
1220
|
+
enhanced_role["inherited_permissions"] = list(inherited_perms)
|
1221
|
+
enhanced_role["all_permissions"] = list(all_perms)
|
1222
|
+
enhanced_role["permission_count"] = {
|
1223
|
+
"direct": len(role_data["permissions"]),
|
1224
|
+
"inherited": len(inherited_perms),
|
1225
|
+
"total": len(all_perms),
|
1226
|
+
}
|
1227
|
+
|
1228
|
+
enhanced_roles.append(enhanced_role)
|
1229
|
+
|
1230
|
+
return {
|
1231
|
+
"result": {
|
1232
|
+
"roles": enhanced_roles,
|
1233
|
+
"pagination": {
|
1234
|
+
"total": total_count,
|
1235
|
+
"limit": limit,
|
1236
|
+
"offset": offset,
|
1237
|
+
"returned": len(enhanced_roles),
|
1238
|
+
},
|
1239
|
+
"filters_applied": {
|
1240
|
+
"search_query": search_query,
|
1241
|
+
"filters": filters,
|
1242
|
+
"include_inherited": include_inherited,
|
1243
|
+
},
|
1244
|
+
"operation": "list_roles",
|
1245
|
+
"timestamp": datetime.now(UTC).isoformat(),
|
1246
|
+
}
|
1247
|
+
}
|
792
1248
|
|
793
1249
|
def _get_role(self, inputs: Dict[str, Any]) -> Dict[str, Any]:
|
794
1250
|
"""Get detailed role information."""
|
795
|
-
|
1251
|
+
role_id = inputs["role_id"]
|
1252
|
+
tenant_id = inputs.get("tenant_id", "default")
|
1253
|
+
include_inherited = inputs.get("include_inherited", True)
|
1254
|
+
include_users = inputs.get("include_users", False)
|
1255
|
+
|
1256
|
+
# Get basic role information
|
1257
|
+
role_data = self._get_role_by_id(role_id, tenant_id)
|
1258
|
+
if not role_data:
|
1259
|
+
raise NodeValidationError(f"Role not found: {role_id}")
|
1260
|
+
|
1261
|
+
# Build enhanced role info
|
1262
|
+
enhanced_role = {
|
1263
|
+
"role_id": role_data["role_id"],
|
1264
|
+
"name": role_data["name"],
|
1265
|
+
"description": role_data["description"],
|
1266
|
+
"role_type": role_data["role_type"],
|
1267
|
+
"permissions": role_data["permissions"],
|
1268
|
+
"parent_roles": role_data["parent_roles"],
|
1269
|
+
"attributes": role_data["attributes"],
|
1270
|
+
"is_active": role_data["is_active"],
|
1271
|
+
"created_at": format_datetime(role_data["created_at"]),
|
1272
|
+
"updated_at": format_datetime(role_data["updated_at"]),
|
1273
|
+
}
|
1274
|
+
|
1275
|
+
# Add inherited permissions if requested
|
1276
|
+
if include_inherited:
|
1277
|
+
role_hierarchy = self._build_role_hierarchy(tenant_id)
|
1278
|
+
inherited_perms = self._get_inherited_permissions(role_id, role_hierarchy)
|
1279
|
+
all_perms = set(role_data["permissions"]) | inherited_perms
|
1280
|
+
|
1281
|
+
enhanced_role["inherited_permissions"] = list(inherited_perms)
|
1282
|
+
enhanced_role["all_permissions"] = list(all_perms)
|
1283
|
+
enhanced_role["permission_count"] = {
|
1284
|
+
"direct": len(role_data["permissions"]),
|
1285
|
+
"inherited": len(inherited_perms),
|
1286
|
+
"total": len(all_perms),
|
1287
|
+
}
|
1288
|
+
|
1289
|
+
# Get child roles from hierarchy
|
1290
|
+
child_roles = []
|
1291
|
+
for role_info in role_hierarchy.values():
|
1292
|
+
if role_id in role_info.get("parent_roles", []):
|
1293
|
+
child_roles.append(
|
1294
|
+
{
|
1295
|
+
"role_id": role_info["role_id"],
|
1296
|
+
"name": role_info["name"],
|
1297
|
+
"is_active": role_info["is_active"],
|
1298
|
+
}
|
1299
|
+
)
|
1300
|
+
enhanced_role["child_roles_detailed"] = child_roles
|
1301
|
+
|
1302
|
+
# Add user assignments if requested
|
1303
|
+
if include_users:
|
1304
|
+
users_query = """
|
1305
|
+
SELECT ur.user_id, ur.assigned_at, ur.assigned_by, u.email, u.status
|
1306
|
+
FROM user_role_assignments ur
|
1307
|
+
LEFT JOIN users u ON ur.user_id = u.user_id AND ur.tenant_id = u.tenant_id
|
1308
|
+
WHERE ur.role_id = $1 AND ur.tenant_id = $2
|
1309
|
+
ORDER BY ur.assigned_at DESC
|
1310
|
+
"""
|
1311
|
+
|
1312
|
+
users_result = self._db_node.run(
|
1313
|
+
query=users_query, parameters=[role_id, tenant_id], fetch_mode="all"
|
1314
|
+
)
|
1315
|
+
users_data = users_result.get("data", [])
|
1316
|
+
|
1317
|
+
enhanced_role["assigned_users"] = [
|
1318
|
+
{
|
1319
|
+
"user_id": user["user_id"],
|
1320
|
+
"email": user.get("email"),
|
1321
|
+
"status": user.get("status"),
|
1322
|
+
"assigned_at": (
|
1323
|
+
user["assigned_at"].isoformat() if user["assigned_at"] else None
|
1324
|
+
),
|
1325
|
+
"assigned_by": user["assigned_by"],
|
1326
|
+
}
|
1327
|
+
for user in users_data
|
1328
|
+
]
|
1329
|
+
enhanced_role["user_count"] = len(users_data)
|
1330
|
+
|
1331
|
+
return {
|
1332
|
+
"result": {
|
1333
|
+
"role": enhanced_role,
|
1334
|
+
"operation": "get_role",
|
1335
|
+
"timestamp": datetime.now(UTC).isoformat(),
|
1336
|
+
}
|
1337
|
+
}
|
796
1338
|
|
797
1339
|
def _unassign_user(self, inputs: Dict[str, Any]) -> Dict[str, Any]:
|
798
1340
|
"""Unassign role from user."""
|
799
|
-
|
1341
|
+
user_id = inputs["user_id"]
|
1342
|
+
role_id = inputs["role_id"]
|
1343
|
+
tenant_id = inputs.get("tenant_id", "default")
|
1344
|
+
|
1345
|
+
# Check if assignment exists
|
1346
|
+
check_query = """
|
1347
|
+
SELECT user_id, role_id, assigned_at, assigned_by
|
1348
|
+
FROM user_role_assignments
|
1349
|
+
WHERE user_id = $1 AND role_id = $2 AND tenant_id = $3
|
1350
|
+
"""
|
1351
|
+
|
1352
|
+
existing = self._db_node.run(
|
1353
|
+
query=check_query,
|
1354
|
+
parameters=[user_id, role_id, tenant_id],
|
1355
|
+
fetch_mode="one",
|
1356
|
+
)
|
1357
|
+
assignment = existing.get("result", {}).get("data")
|
1358
|
+
|
1359
|
+
if not assignment:
|
1360
|
+
return {
|
1361
|
+
"result": {
|
1362
|
+
"unassignment": {
|
1363
|
+
"user_id": user_id,
|
1364
|
+
"role_id": role_id,
|
1365
|
+
"was_assigned": False,
|
1366
|
+
"message": "User was not assigned to this role",
|
1367
|
+
},
|
1368
|
+
"operation": "unassign_user",
|
1369
|
+
"success": True,
|
1370
|
+
"timestamp": datetime.now(UTC).isoformat(),
|
1371
|
+
}
|
1372
|
+
}
|
1373
|
+
|
1374
|
+
# Remove assignment
|
1375
|
+
delete_query = """
|
1376
|
+
DELETE FROM user_role_assignments
|
1377
|
+
WHERE user_id = $1 AND role_id = $2 AND tenant_id = $3
|
1378
|
+
"""
|
1379
|
+
|
1380
|
+
self._db_node.run(query=delete_query, parameters=[user_id, role_id, tenant_id])
|
1381
|
+
|
1382
|
+
return {
|
1383
|
+
"result": {
|
1384
|
+
"unassignment": {
|
1385
|
+
"user_id": user_id,
|
1386
|
+
"role_id": role_id,
|
1387
|
+
"was_assigned": True,
|
1388
|
+
"previously_assigned_at": (
|
1389
|
+
assignment["assigned_at"].isoformat()
|
1390
|
+
if assignment["assigned_at"]
|
1391
|
+
else None
|
1392
|
+
),
|
1393
|
+
"previously_assigned_by": assignment["assigned_by"],
|
1394
|
+
"unassigned_at": datetime.now(UTC).isoformat(),
|
1395
|
+
"unassigned_by": inputs.get("unassigned_by", "system"),
|
1396
|
+
},
|
1397
|
+
"operation": "unassign_user",
|
1398
|
+
"success": True,
|
1399
|
+
"timestamp": datetime.now(UTC).isoformat(),
|
1400
|
+
}
|
1401
|
+
}
|
800
1402
|
|
801
1403
|
def _add_permission(self, inputs: Dict[str, Any]) -> Dict[str, Any]:
|
802
1404
|
"""Add permission to role."""
|
803
|
-
|
1405
|
+
role_id = inputs["role_id"]
|
1406
|
+
permission = inputs["permission"]
|
1407
|
+
tenant_id = inputs.get("tenant_id", "default")
|
1408
|
+
|
1409
|
+
# Validate role exists
|
1410
|
+
role_data = self._get_role_by_id(role_id, tenant_id)
|
1411
|
+
if not role_data:
|
1412
|
+
raise NodeValidationError(f"Role not found: {role_id}")
|
1413
|
+
|
1414
|
+
current_permissions = set(role_data.get("permissions", []))
|
1415
|
+
|
1416
|
+
# Check if permission already exists
|
1417
|
+
if permission in current_permissions:
|
1418
|
+
return {
|
1419
|
+
"result": {
|
1420
|
+
"permission_added": False,
|
1421
|
+
"role_id": role_id,
|
1422
|
+
"permission": permission,
|
1423
|
+
"message": "Permission already exists on role",
|
1424
|
+
"operation": "add_permission",
|
1425
|
+
"timestamp": datetime.now(UTC).isoformat(),
|
1426
|
+
}
|
1427
|
+
}
|
1428
|
+
|
1429
|
+
# Add permission
|
1430
|
+
new_permissions = list(current_permissions | {permission})
|
1431
|
+
|
1432
|
+
update_query = """
|
1433
|
+
UPDATE roles
|
1434
|
+
SET permissions = $1, updated_at = $2
|
1435
|
+
WHERE role_id = $3 AND tenant_id = $4
|
1436
|
+
RETURNING permissions
|
1437
|
+
"""
|
1438
|
+
|
1439
|
+
result = self._db_node.run(
|
1440
|
+
query=update_query,
|
1441
|
+
parameters=[
|
1442
|
+
json.dumps(new_permissions),
|
1443
|
+
datetime.now(UTC),
|
1444
|
+
role_id,
|
1445
|
+
tenant_id,
|
1446
|
+
],
|
1447
|
+
fetch_mode="one",
|
1448
|
+
)
|
1449
|
+
updated_permissions = result.get("data", [{}])[0].get("permissions", [])
|
1450
|
+
|
1451
|
+
return {
|
1452
|
+
"result": {
|
1453
|
+
"permission_added": True,
|
1454
|
+
"role_id": role_id,
|
1455
|
+
"permission": permission,
|
1456
|
+
"permissions_count": len(updated_permissions),
|
1457
|
+
"all_permissions": updated_permissions,
|
1458
|
+
"operation": "add_permission",
|
1459
|
+
"timestamp": datetime.now(UTC).isoformat(),
|
1460
|
+
}
|
1461
|
+
}
|
804
1462
|
|
805
1463
|
def _remove_permission(self, inputs: Dict[str, Any]) -> Dict[str, Any]:
|
806
1464
|
"""Remove permission from role."""
|
807
|
-
|
1465
|
+
role_id = inputs["role_id"]
|
1466
|
+
permission = inputs["permission"]
|
1467
|
+
tenant_id = inputs.get("tenant_id", "default")
|
1468
|
+
|
1469
|
+
# Validate role exists
|
1470
|
+
role_data = self._get_role_by_id(role_id, tenant_id)
|
1471
|
+
if not role_data:
|
1472
|
+
raise NodeValidationError(f"Role not found: {role_id}")
|
1473
|
+
|
1474
|
+
current_permissions = set(role_data.get("permissions", []))
|
1475
|
+
|
1476
|
+
# Check if permission exists
|
1477
|
+
if permission not in current_permissions:
|
1478
|
+
return {
|
1479
|
+
"result": {
|
1480
|
+
"permission_removed": False,
|
1481
|
+
"role_id": role_id,
|
1482
|
+
"permission": permission,
|
1483
|
+
"message": "Permission does not exist on role",
|
1484
|
+
"operation": "remove_permission",
|
1485
|
+
"timestamp": datetime.now(UTC).isoformat(),
|
1486
|
+
}
|
1487
|
+
}
|
1488
|
+
|
1489
|
+
# Remove permission
|
1490
|
+
new_permissions = list(current_permissions - {permission})
|
1491
|
+
|
1492
|
+
update_query = """
|
1493
|
+
UPDATE roles
|
1494
|
+
SET permissions = $1, updated_at = $2
|
1495
|
+
WHERE role_id = $3 AND tenant_id = $4
|
1496
|
+
RETURNING permissions
|
1497
|
+
"""
|
1498
|
+
|
1499
|
+
result = self._db_node.run(
|
1500
|
+
query=update_query,
|
1501
|
+
parameters=[
|
1502
|
+
json.dumps(new_permissions),
|
1503
|
+
datetime.now(UTC),
|
1504
|
+
role_id,
|
1505
|
+
tenant_id,
|
1506
|
+
],
|
1507
|
+
fetch_mode="one",
|
1508
|
+
)
|
1509
|
+
updated_permissions = result.get("data", [{}])[0].get("permissions", [])
|
1510
|
+
|
1511
|
+
return {
|
1512
|
+
"result": {
|
1513
|
+
"permission_removed": True,
|
1514
|
+
"role_id": role_id,
|
1515
|
+
"permission": permission,
|
1516
|
+
"permissions_count": len(updated_permissions),
|
1517
|
+
"all_permissions": updated_permissions,
|
1518
|
+
"operation": "remove_permission",
|
1519
|
+
"timestamp": datetime.now(UTC).isoformat(),
|
1520
|
+
}
|
1521
|
+
}
|
808
1522
|
|
809
1523
|
def _bulk_unassign(self, inputs: Dict[str, Any]) -> Dict[str, Any]:
|
810
1524
|
"""Bulk unassign role from multiple users."""
|
811
|
-
|
1525
|
+
role_id = inputs["role_id"]
|
1526
|
+
user_ids = inputs["user_ids"]
|
1527
|
+
tenant_id = inputs.get("tenant_id", "default")
|
1528
|
+
|
1529
|
+
if not isinstance(user_ids, list):
|
1530
|
+
raise NodeValidationError("user_ids must be a list for bulk operations")
|
1531
|
+
|
1532
|
+
results = {
|
1533
|
+
"unassigned": [],
|
1534
|
+
"not_assigned": [],
|
1535
|
+
"failed": [],
|
1536
|
+
"stats": {"unassigned": 0, "not_assigned": 0, "failed": 0},
|
1537
|
+
}
|
1538
|
+
|
1539
|
+
for user_id in user_ids:
|
1540
|
+
try:
|
1541
|
+
unassign_inputs = {
|
1542
|
+
"operation": "unassign_user",
|
1543
|
+
"user_id": user_id,
|
1544
|
+
"role_id": role_id,
|
1545
|
+
"tenant_id": tenant_id,
|
1546
|
+
"unassigned_by": inputs.get("unassigned_by", "system"),
|
1547
|
+
}
|
1548
|
+
|
1549
|
+
result = self._unassign_user(unassign_inputs)
|
1550
|
+
unassignment = result["result"]["unassignment"]
|
1551
|
+
|
1552
|
+
if unassignment["was_assigned"]:
|
1553
|
+
results["unassigned"].append(
|
1554
|
+
{
|
1555
|
+
"user_id": user_id,
|
1556
|
+
"previously_assigned_at": unassignment[
|
1557
|
+
"previously_assigned_at"
|
1558
|
+
],
|
1559
|
+
}
|
1560
|
+
)
|
1561
|
+
results["stats"]["unassigned"] += 1
|
1562
|
+
else:
|
1563
|
+
results["not_assigned"].append({"user_id": user_id})
|
1564
|
+
results["stats"]["not_assigned"] += 1
|
1565
|
+
|
1566
|
+
except Exception as e:
|
1567
|
+
results["failed"].append({"user_id": user_id, "error": str(e)})
|
1568
|
+
results["stats"]["failed"] += 1
|
1569
|
+
|
1570
|
+
return {
|
1571
|
+
"result": {
|
1572
|
+
"operation": "bulk_unassign",
|
1573
|
+
"role_id": role_id,
|
1574
|
+
"results": results,
|
1575
|
+
"timestamp": datetime.now(UTC).isoformat(),
|
1576
|
+
}
|
1577
|
+
}
|
812
1578
|
|
813
1579
|
def _get_user_roles(self, inputs: Dict[str, Any]) -> Dict[str, Any]:
|
814
1580
|
"""Get all roles for a user."""
|
815
|
-
|
1581
|
+
user_id = inputs["user_id"]
|
1582
|
+
tenant_id = inputs.get("tenant_id", "default")
|
1583
|
+
include_inherited = inputs.get("include_inherited", True)
|
1584
|
+
include_inactive = inputs.get("include_inactive", False)
|
1585
|
+
|
1586
|
+
# Get user role assignments
|
1587
|
+
roles_query = """
|
1588
|
+
SELECT ur.role_id, ur.assigned_at, ur.assigned_by,
|
1589
|
+
r.name, r.description, r.role_type, r.permissions,
|
1590
|
+
r.parent_roles, r.attributes, r.is_active
|
1591
|
+
FROM user_role_assignments ur
|
1592
|
+
JOIN roles r ON ur.role_id = r.role_id AND ur.tenant_id = r.tenant_id
|
1593
|
+
WHERE ur.user_id = $1 AND ur.tenant_id = $2
|
1594
|
+
"""
|
1595
|
+
|
1596
|
+
params = [user_id, tenant_id]
|
1597
|
+
|
1598
|
+
if not include_inactive:
|
1599
|
+
roles_query += " AND r.is_active = true"
|
1600
|
+
|
1601
|
+
roles_query += " ORDER BY ur.assigned_at DESC"
|
1602
|
+
|
1603
|
+
result = self._db_node.run(
|
1604
|
+
query=roles_query, parameters=params, fetch_mode="all"
|
1605
|
+
)
|
1606
|
+
roles_data = result.get("data", [])
|
1607
|
+
|
1608
|
+
user_roles = []
|
1609
|
+
all_permissions = set()
|
1610
|
+
role_hierarchy = (
|
1611
|
+
self._build_role_hierarchy(tenant_id) if include_inherited else {}
|
1612
|
+
)
|
1613
|
+
|
1614
|
+
for role_data in roles_data:
|
1615
|
+
role_info = {
|
1616
|
+
"role_id": role_data["role_id"],
|
1617
|
+
"name": role_data["name"],
|
1618
|
+
"description": role_data["description"],
|
1619
|
+
"role_type": role_data["role_type"],
|
1620
|
+
"permissions": role_data["permissions"],
|
1621
|
+
"parent_roles": role_data["parent_roles"],
|
1622
|
+
"attributes": role_data["attributes"],
|
1623
|
+
"is_active": role_data["is_active"],
|
1624
|
+
"assigned_at": format_datetime(role_data["assigned_at"]),
|
1625
|
+
"assigned_by": role_data["assigned_by"],
|
1626
|
+
}
|
1627
|
+
|
1628
|
+
# Add permissions from this role
|
1629
|
+
direct_permissions = set(role_data["permissions"])
|
1630
|
+
all_permissions.update(direct_permissions)
|
1631
|
+
|
1632
|
+
if include_inherited:
|
1633
|
+
inherited_perms = self._get_inherited_permissions(
|
1634
|
+
role_data["role_id"], role_hierarchy
|
1635
|
+
)
|
1636
|
+
all_permissions.update(inherited_perms)
|
1637
|
+
|
1638
|
+
role_info["inherited_permissions"] = list(inherited_perms)
|
1639
|
+
role_info["all_permissions"] = list(
|
1640
|
+
direct_permissions | inherited_perms
|
1641
|
+
)
|
1642
|
+
role_info["permission_count"] = {
|
1643
|
+
"direct": len(direct_permissions),
|
1644
|
+
"inherited": len(inherited_perms),
|
1645
|
+
"total": len(direct_permissions | inherited_perms),
|
1646
|
+
}
|
1647
|
+
|
1648
|
+
user_roles.append(role_info)
|
1649
|
+
|
1650
|
+
return {
|
1651
|
+
"result": {
|
1652
|
+
"user_id": user_id,
|
1653
|
+
"roles": user_roles,
|
1654
|
+
"summary": {
|
1655
|
+
"role_count": len(user_roles),
|
1656
|
+
"active_roles": len([r for r in user_roles if r["is_active"]]),
|
1657
|
+
"total_permissions": len(all_permissions),
|
1658
|
+
"unique_permissions": list(all_permissions),
|
1659
|
+
},
|
1660
|
+
"options": {
|
1661
|
+
"include_inherited": include_inherited,
|
1662
|
+
"include_inactive": include_inactive,
|
1663
|
+
},
|
1664
|
+
"operation": "get_user_roles",
|
1665
|
+
"timestamp": datetime.now(UTC).isoformat(),
|
1666
|
+
}
|
1667
|
+
}
|
816
1668
|
|
817
1669
|
def _get_role_users(self, inputs: Dict[str, Any]) -> Dict[str, Any]:
|
818
1670
|
"""Get all users assigned to a role."""
|
819
|
-
|
1671
|
+
role_id = inputs["role_id"]
|
1672
|
+
tenant_id = inputs.get("tenant_id", "default")
|
1673
|
+
include_user_details = inputs.get("include_user_details", True)
|
1674
|
+
limit = inputs.get("limit", 100)
|
1675
|
+
offset = inputs.get("offset", 0)
|
1676
|
+
|
1677
|
+
# Validate role exists
|
1678
|
+
role_data = self._get_role_by_id(role_id, tenant_id)
|
1679
|
+
if not role_data:
|
1680
|
+
raise NodeValidationError(f"Role not found: {role_id}")
|
1681
|
+
|
1682
|
+
# Get user assignments
|
1683
|
+
if include_user_details:
|
1684
|
+
users_query = """
|
1685
|
+
SELECT ur.user_id, ur.assigned_at, ur.assigned_by,
|
1686
|
+
u.email, u.first_name, u.last_name, u.status, u.created_at as user_created_at
|
1687
|
+
FROM user_role_assignments ur
|
1688
|
+
LEFT JOIN users u ON ur.user_id = u.user_id AND ur.tenant_id = u.tenant_id
|
1689
|
+
WHERE ur.role_id = $1 AND ur.tenant_id = $2
|
1690
|
+
ORDER BY ur.assigned_at DESC
|
1691
|
+
LIMIT $3 OFFSET $4
|
1692
|
+
"""
|
1693
|
+
else:
|
1694
|
+
users_query = """
|
1695
|
+
SELECT ur.user_id, ur.assigned_at, ur.assigned_by
|
1696
|
+
FROM user_role_assignments ur
|
1697
|
+
WHERE ur.role_id = $1 AND ur.tenant_id = $2
|
1698
|
+
ORDER BY ur.assigned_at DESC
|
1699
|
+
LIMIT $3 OFFSET $4
|
1700
|
+
"""
|
1701
|
+
|
1702
|
+
result = self._db_node.run(
|
1703
|
+
query=users_query,
|
1704
|
+
parameters=[role_id, tenant_id, limit, offset],
|
1705
|
+
fetch_mode="all",
|
1706
|
+
)
|
1707
|
+
users_data = result.get("result", {}).get("data", [])
|
1708
|
+
|
1709
|
+
# Get total count
|
1710
|
+
count_query = """
|
1711
|
+
SELECT COUNT(*) as total
|
1712
|
+
FROM user_role_assignments
|
1713
|
+
WHERE role_id = $1 AND tenant_id = $2
|
1714
|
+
"""
|
1715
|
+
|
1716
|
+
count_result = self._db_node.run(
|
1717
|
+
query=count_query, parameters=[role_id, tenant_id], fetch_mode="one"
|
1718
|
+
)
|
1719
|
+
total_count = count_result.get("data", [{}])[0].get("total", 0)
|
1720
|
+
|
1721
|
+
# Format user data
|
1722
|
+
assigned_users = []
|
1723
|
+
for user_data in users_data:
|
1724
|
+
user_info = {
|
1725
|
+
"user_id": user_data["user_id"],
|
1726
|
+
"assigned_at": (
|
1727
|
+
user_data["assigned_at"].isoformat()
|
1728
|
+
if user_data["assigned_at"]
|
1729
|
+
else None
|
1730
|
+
),
|
1731
|
+
"assigned_by": user_data["assigned_by"],
|
1732
|
+
}
|
1733
|
+
|
1734
|
+
if include_user_details:
|
1735
|
+
user_info.update(
|
1736
|
+
{
|
1737
|
+
"email": user_data.get("email"),
|
1738
|
+
"first_name": user_data.get("first_name"),
|
1739
|
+
"last_name": user_data.get("last_name"),
|
1740
|
+
"status": user_data.get("status"),
|
1741
|
+
"user_created_at": (
|
1742
|
+
user_data["user_created_at"].isoformat()
|
1743
|
+
if user_data.get("user_created_at")
|
1744
|
+
else None
|
1745
|
+
),
|
1746
|
+
}
|
1747
|
+
)
|
1748
|
+
|
1749
|
+
assigned_users.append(user_info)
|
1750
|
+
|
1751
|
+
return {
|
1752
|
+
"result": {
|
1753
|
+
"role": {
|
1754
|
+
"role_id": role_id,
|
1755
|
+
"name": role_data["name"],
|
1756
|
+
"description": role_data["description"],
|
1757
|
+
"is_active": role_data["is_active"],
|
1758
|
+
},
|
1759
|
+
"assigned_users": assigned_users,
|
1760
|
+
"pagination": {
|
1761
|
+
"total": total_count,
|
1762
|
+
"limit": limit,
|
1763
|
+
"offset": offset,
|
1764
|
+
"returned": len(assigned_users),
|
1765
|
+
},
|
1766
|
+
"options": {"include_user_details": include_user_details},
|
1767
|
+
"operation": "get_role_users",
|
1768
|
+
"timestamp": datetime.now(UTC).isoformat(),
|
1769
|
+
}
|
1770
|
+
}
|
820
1771
|
|
821
1772
|
def _validate_hierarchy(self, inputs: Dict[str, Any]) -> Dict[str, Any]:
|
822
1773
|
"""Validate entire role hierarchy for consistency."""
|
823
|
-
|
1774
|
+
tenant_id = inputs.get("tenant_id", "default")
|
1775
|
+
fix_issues = inputs.get("fix_issues", False)
|
1776
|
+
|
1777
|
+
# Build role hierarchy
|
1778
|
+
role_hierarchy = self._build_role_hierarchy(tenant_id)
|
1779
|
+
|
1780
|
+
validation_results = {
|
1781
|
+
"circular_dependencies": [],
|
1782
|
+
"missing_parents": [],
|
1783
|
+
"orphaned_child_references": [],
|
1784
|
+
"inactive_parent_references": [],
|
1785
|
+
"inconsistent_child_arrays": [],
|
1786
|
+
"total_roles": len(role_hierarchy),
|
1787
|
+
"issues_found": 0,
|
1788
|
+
}
|
1789
|
+
|
1790
|
+
# Check each role for issues
|
1791
|
+
for role_id, role_data in role_hierarchy.items():
|
1792
|
+
# Check for circular dependencies
|
1793
|
+
try:
|
1794
|
+
visited = set()
|
1795
|
+
self._check_circular_dependency_recursive(
|
1796
|
+
role_id, role_hierarchy, visited
|
1797
|
+
)
|
1798
|
+
except ValueError as e:
|
1799
|
+
validation_results["circular_dependencies"].append(
|
1800
|
+
{"role_id": role_id, "issue": str(e)}
|
1801
|
+
)
|
1802
|
+
|
1803
|
+
# Check for missing parent roles
|
1804
|
+
parent_roles = role_data.get("parent_roles", [])
|
1805
|
+
for parent_id in parent_roles:
|
1806
|
+
if parent_id not in role_hierarchy:
|
1807
|
+
validation_results["missing_parents"].append(
|
1808
|
+
{"role_id": role_id, "missing_parent": parent_id}
|
1809
|
+
)
|
1810
|
+
elif not role_hierarchy[parent_id].get("is_active", True):
|
1811
|
+
validation_results["inactive_parent_references"].append(
|
1812
|
+
{"role_id": role_id, "inactive_parent": parent_id}
|
1813
|
+
)
|
1814
|
+
|
1815
|
+
# Check child role consistency
|
1816
|
+
child_roles = role_data.get("child_roles", [])
|
1817
|
+
for child_id in child_roles:
|
1818
|
+
if child_id not in role_hierarchy:
|
1819
|
+
validation_results["orphaned_child_references"].append(
|
1820
|
+
{"role_id": role_id, "orphaned_child": child_id}
|
1821
|
+
)
|
1822
|
+
else:
|
1823
|
+
# Check if child actually has this role as parent
|
1824
|
+
child_data = role_hierarchy[child_id]
|
1825
|
+
child_parents = child_data.get("parent_roles", [])
|
1826
|
+
if role_id not in child_parents:
|
1827
|
+
validation_results["inconsistent_child_arrays"].append(
|
1828
|
+
{
|
1829
|
+
"parent_role": role_id,
|
1830
|
+
"child_role": child_id,
|
1831
|
+
"issue": "Child role does not reference parent",
|
1832
|
+
}
|
1833
|
+
)
|
1834
|
+
|
1835
|
+
# Count total issues
|
1836
|
+
total_issues = (
|
1837
|
+
len(validation_results["circular_dependencies"])
|
1838
|
+
+ len(validation_results["missing_parents"])
|
1839
|
+
+ len(validation_results["orphaned_child_references"])
|
1840
|
+
+ len(validation_results["inactive_parent_references"])
|
1841
|
+
+ len(validation_results["inconsistent_child_arrays"])
|
1842
|
+
)
|
1843
|
+
|
1844
|
+
validation_results["issues_found"] = total_issues
|
1845
|
+
validation_results["is_valid"] = total_issues == 0
|
1846
|
+
|
1847
|
+
# Fix issues if requested
|
1848
|
+
fixes_applied = []
|
1849
|
+
if fix_issues and total_issues > 0:
|
1850
|
+
# Fix orphaned child references
|
1851
|
+
for issue in validation_results["orphaned_child_references"]:
|
1852
|
+
try:
|
1853
|
+
self._remove_orphaned_child_reference(
|
1854
|
+
issue["role_id"], issue["orphaned_child"], tenant_id
|
1855
|
+
)
|
1856
|
+
fixes_applied.append(
|
1857
|
+
f"Removed orphaned child reference {issue['orphaned_child']} from {issue['role_id']}"
|
1858
|
+
)
|
1859
|
+
except Exception as e:
|
1860
|
+
fixes_applied.append(
|
1861
|
+
f"Failed to fix orphaned child reference: {str(e)}"
|
1862
|
+
)
|
1863
|
+
|
1864
|
+
# Fix inconsistent child arrays
|
1865
|
+
for issue in validation_results["inconsistent_child_arrays"]:
|
1866
|
+
try:
|
1867
|
+
self._sync_parent_child_relationship(
|
1868
|
+
issue["parent_role"], issue["child_role"], tenant_id
|
1869
|
+
)
|
1870
|
+
fixes_applied.append(
|
1871
|
+
f"Synced parent-child relationship: {issue['parent_role']} <-> {issue['child_role']}"
|
1872
|
+
)
|
1873
|
+
except Exception as e:
|
1874
|
+
fixes_applied.append(f"Failed to sync relationship: {str(e)}")
|
1875
|
+
|
1876
|
+
result = {
|
1877
|
+
"result": {
|
1878
|
+
"validation": validation_results,
|
1879
|
+
"operation": "validate_hierarchy",
|
1880
|
+
"timestamp": datetime.now(UTC).isoformat(),
|
1881
|
+
}
|
1882
|
+
}
|
1883
|
+
|
1884
|
+
if fix_issues:
|
1885
|
+
result["result"]["fixes_applied"] = fixes_applied
|
1886
|
+
result["result"]["fix_count"] = len(fixes_applied)
|
1887
|
+
|
1888
|
+
return result
|
1889
|
+
|
1890
|
+
def _check_circular_dependency_recursive(
|
1891
|
+
self, role_id: str, role_hierarchy: Dict[str, Dict], visited: Set[str]
|
1892
|
+
):
|
1893
|
+
"""Recursively check for circular dependencies."""
|
1894
|
+
if role_id in visited:
|
1895
|
+
raise ValueError(f"Circular dependency detected involving role: {role_id}")
|
1896
|
+
|
1897
|
+
visited.add(role_id)
|
1898
|
+
|
1899
|
+
if role_id in role_hierarchy:
|
1900
|
+
parent_roles = role_hierarchy[role_id].get("parent_roles", [])
|
1901
|
+
for parent_id in parent_roles:
|
1902
|
+
self._check_circular_dependency_recursive(
|
1903
|
+
parent_id, role_hierarchy, visited.copy()
|
1904
|
+
)
|
1905
|
+
|
1906
|
+
def _remove_orphaned_child_reference(
|
1907
|
+
self, parent_role_id: str, orphaned_child_id: str, tenant_id: str
|
1908
|
+
):
|
1909
|
+
"""Remove orphaned child reference from parent role."""
|
1910
|
+
update_query = """
|
1911
|
+
UPDATE roles
|
1912
|
+
SET child_roles = (
|
1913
|
+
SELECT COALESCE(
|
1914
|
+
json_agg(value),
|
1915
|
+
'[]'::json
|
1916
|
+
)
|
1917
|
+
FROM jsonb_array_elements_text(child_roles) AS value
|
1918
|
+
WHERE value != $1
|
1919
|
+
)::jsonb,
|
1920
|
+
updated_at = $2
|
1921
|
+
WHERE role_id = $3 AND tenant_id = $4
|
1922
|
+
"""
|
1923
|
+
|
1924
|
+
self._db_node.run(
|
1925
|
+
query=update_query,
|
1926
|
+
parameters=[
|
1927
|
+
orphaned_child_id,
|
1928
|
+
datetime.now(UTC),
|
1929
|
+
parent_role_id,
|
1930
|
+
tenant_id,
|
1931
|
+
],
|
1932
|
+
)
|
1933
|
+
|
1934
|
+
def _sync_parent_child_relationship(
|
1935
|
+
self, parent_role_id: str, child_role_id: str, tenant_id: str
|
1936
|
+
):
|
1937
|
+
"""Ensure parent-child relationship is consistent in both directions."""
|
1938
|
+
# Add child to parent's child_roles if not present
|
1939
|
+
add_child_query = """
|
1940
|
+
UPDATE roles
|
1941
|
+
SET child_roles = (
|
1942
|
+
CASE
|
1943
|
+
WHEN child_roles ? $1 THEN child_roles
|
1944
|
+
ELSE child_roles || jsonb_build_array($1)
|
1945
|
+
END
|
1946
|
+
),
|
1947
|
+
updated_at = $2
|
1948
|
+
WHERE role_id = $3 AND tenant_id = $4
|
1949
|
+
AND NOT ($1 = ANY(
|
1950
|
+
SELECT jsonb_array_elements_text(child_roles)
|
1951
|
+
))
|
1952
|
+
"""
|
1953
|
+
|
1954
|
+
self._db_node.run(
|
1955
|
+
query=add_child_query,
|
1956
|
+
parameters=[child_role_id, datetime.now(UTC), parent_role_id, tenant_id],
|
1957
|
+
)
|