cornflow 1.2.3a3__py3-none-any.whl → 1.2.3a5__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.
- cornflow/app.py +1 -1
- cornflow/cli/roles.py +1 -1
- cornflow/cli/service.py +32 -4
- cornflow/commands/access.py +14 -3
- cornflow/commands/auxiliar.py +106 -0
- cornflow/commands/permissions.py +183 -102
- cornflow/commands/roles.py +15 -14
- cornflow/commands/views.py +171 -41
- cornflow/shared/authentication/auth.py +3 -3
- cornflow/shared/const.py +1 -1
- cornflow/tests/unit/test_commands.py +0 -193
- cornflow/tests/unit/test_external_role_creation.py +785 -0
- {cornflow-1.2.3a3.dist-info → cornflow-1.2.3a5.dist-info}/METADATA +2 -2
- {cornflow-1.2.3a3.dist-info → cornflow-1.2.3a5.dist-info}/RECORD +17 -15
- {cornflow-1.2.3a3.dist-info → cornflow-1.2.3a5.dist-info}/WHEEL +0 -0
- {cornflow-1.2.3a3.dist-info → cornflow-1.2.3a5.dist-info}/entry_points.txt +0 -0
- {cornflow-1.2.3a3.dist-info → cornflow-1.2.3a5.dist-info}/top_level.txt +0 -0
cornflow/commands/views.py
CHANGED
@@ -1,9 +1,11 @@
|
|
1
|
-
|
2
1
|
# Imports from external libraries
|
3
|
-
|
2
|
+
import sys
|
4
3
|
from importlib import import_module
|
4
|
+
|
5
|
+
from flask import current_app
|
5
6
|
from sqlalchemy.exc import DBAPIError, IntegrityError
|
6
|
-
|
7
|
+
|
8
|
+
from cornflow.endpoints import resources, alarms_resources
|
7
9
|
|
8
10
|
# Imports from internal libraries
|
9
11
|
from cornflow.models import ViewModel
|
@@ -11,45 +13,21 @@ from cornflow.shared import db
|
|
11
13
|
|
12
14
|
|
13
15
|
def register_views_command(external_app: str = None, verbose: bool = False):
|
16
|
+
"""
|
17
|
+
Register views for the application.
|
18
|
+
external_app: If provided, it will register the views for the external app.
|
19
|
+
verbose: If True, it will print the views that are being registered.
|
20
|
+
"""
|
21
|
+
resources_to_register = get_resources_to_register(external_app)
|
22
|
+
views_registered_urls_all_attributes = get_database_view()
|
23
|
+
views_to_register, views_registered_urls_all_attributes = get_views_to_register(
|
24
|
+
resources_to_register, views_registered_urls_all_attributes
|
25
|
+
)
|
26
|
+
views_to_delete, views_to_update = get_views_to_update_and_delete(
|
27
|
+
resources_to_register, views_registered_urls_all_attributes
|
28
|
+
)
|
14
29
|
|
15
|
-
|
16
|
-
from cornflow.endpoints import resources, alarms_resources
|
17
|
-
resources_to_register = resources
|
18
|
-
if current_app.config["ALARMS_ENDPOINTS"]:
|
19
|
-
resources_to_register = resources + alarms_resources
|
20
|
-
elif external_app is not None:
|
21
|
-
sys.path.append("./")
|
22
|
-
external_module = import_module(external_app)
|
23
|
-
resources_to_register = external_module.endpoints.resources
|
24
|
-
else:
|
25
|
-
resources_to_register = []
|
26
|
-
exit()
|
27
|
-
|
28
|
-
views_registered = [view.name for view in ViewModel.get_all_objects()]
|
29
|
-
|
30
|
-
views_to_register = [
|
31
|
-
ViewModel(
|
32
|
-
{
|
33
|
-
"name": view["endpoint"],
|
34
|
-
"url_rule": view["urls"],
|
35
|
-
"description": view["resource"].DESCRIPTION,
|
36
|
-
}
|
37
|
-
)
|
38
|
-
for view in resources_to_register
|
39
|
-
if view["endpoint"] not in views_registered
|
40
|
-
]
|
41
|
-
|
42
|
-
if len(views_to_register) > 0:
|
43
|
-
db.session.bulk_save_objects(views_to_register)
|
44
|
-
|
45
|
-
try:
|
46
|
-
db.session.commit()
|
47
|
-
except IntegrityError as e:
|
48
|
-
db.session.rollback()
|
49
|
-
current_app.logger.error(f"Integrity error on views register: {e}")
|
50
|
-
except DBAPIError as e:
|
51
|
-
db.session.rollback()
|
52
|
-
current_app.logger.error(f"Unknow error on views register: {e}")
|
30
|
+
load_changes_to_db(views_to_delete, views_to_register, views_to_update)
|
53
31
|
|
54
32
|
if "postgres" in str(db.session.get_bind()):
|
55
33
|
db.engine.execute(
|
@@ -68,3 +46,155 @@ def register_views_command(external_app: str = None, verbose: bool = False):
|
|
68
46
|
current_app.logger.info("No new endpoints to be registered")
|
69
47
|
|
70
48
|
return True
|
49
|
+
|
50
|
+
|
51
|
+
def load_changes_to_db(views_to_delete, views_to_register, views_to_update):
|
52
|
+
"""
|
53
|
+
Load changes to the database.
|
54
|
+
views_to_delete: List of views to delete.
|
55
|
+
views_to_register: List of views to register.
|
56
|
+
views_to_update: List of views to update.
|
57
|
+
"""
|
58
|
+
if len(views_to_register) > 0:
|
59
|
+
db.session.bulk_save_objects(views_to_register)
|
60
|
+
if len(views_to_update) > 0:
|
61
|
+
db.session.bulk_update_mappings(ViewModel, views_to_update)
|
62
|
+
# If the list views_to_delete is not empty, we will iterate over it and delete the views
|
63
|
+
# If it is empty, we will not delete any view since we are iterating over an empty list
|
64
|
+
for view_id in views_to_delete:
|
65
|
+
view_to_delete = ViewModel.get_one_object(idx=view_id)
|
66
|
+
if view_to_delete:
|
67
|
+
view_to_delete.delete()
|
68
|
+
try:
|
69
|
+
db.session.commit()
|
70
|
+
except IntegrityError as e:
|
71
|
+
db.session.rollback()
|
72
|
+
current_app.logger.error(f"Integrity error on views register: {e}")
|
73
|
+
except DBAPIError as e:
|
74
|
+
db.session.rollback()
|
75
|
+
current_app.logger.error(f"Unknow error on views register: {e}")
|
76
|
+
|
77
|
+
|
78
|
+
def get_views_to_delete(
|
79
|
+
all_resources_to_register_views_endpoints, views_registered_urls_all_attributes
|
80
|
+
):
|
81
|
+
"""
|
82
|
+
Get the views to delete.
|
83
|
+
Views to delete: exist in registered views but not in resources to register.
|
84
|
+
"""
|
85
|
+
return [
|
86
|
+
view_attrs["id"]
|
87
|
+
for view_name, view_attrs in views_registered_urls_all_attributes.items()
|
88
|
+
if view_name not in all_resources_to_register_views_endpoints
|
89
|
+
]
|
90
|
+
|
91
|
+
|
92
|
+
def get_views_to_update(
|
93
|
+
all_resources_to_register_views_endpoints, views_registered_urls_all_attributes
|
94
|
+
):
|
95
|
+
"""
|
96
|
+
Get the views to update.
|
97
|
+
Views to update: exist in both but with different url_rule or description.
|
98
|
+
"""
|
99
|
+
return [
|
100
|
+
{
|
101
|
+
"id": view_attrs["id"],
|
102
|
+
"name": view_name,
|
103
|
+
"url_rule": all_resources_to_register_views_endpoints[view_name][
|
104
|
+
"url_rule"
|
105
|
+
],
|
106
|
+
"description": all_resources_to_register_views_endpoints[view_name][
|
107
|
+
"description"
|
108
|
+
],
|
109
|
+
}
|
110
|
+
for view_name, view_attrs in views_registered_urls_all_attributes.items()
|
111
|
+
if view_name in all_resources_to_register_views_endpoints
|
112
|
+
and (
|
113
|
+
view_attrs["url_rule"]
|
114
|
+
!= all_resources_to_register_views_endpoints[view_name]["url_rule"]
|
115
|
+
or view_attrs["description"]
|
116
|
+
!= all_resources_to_register_views_endpoints[view_name]["description"]
|
117
|
+
)
|
118
|
+
]
|
119
|
+
|
120
|
+
|
121
|
+
def get_views_to_update_and_delete(
|
122
|
+
resources_to_register, views_registered_urls_all_attributes
|
123
|
+
):
|
124
|
+
"""
|
125
|
+
Get the views to update and delete.
|
126
|
+
all_resources_to_register_views_endpoints: Dictionary of all resources to register views endpoints.
|
127
|
+
views_registered_urls_all_attributes: Dictionary of views registered urls all attributes.
|
128
|
+
"""
|
129
|
+
all_resources_to_register_views_endpoints = {
|
130
|
+
view["endpoint"]: {
|
131
|
+
"url_rule": view["urls"],
|
132
|
+
"description": view["resource"].DESCRIPTION,
|
133
|
+
}
|
134
|
+
for view in resources_to_register
|
135
|
+
}
|
136
|
+
|
137
|
+
views_to_delete = get_views_to_delete(
|
138
|
+
all_resources_to_register_views_endpoints, views_registered_urls_all_attributes
|
139
|
+
)
|
140
|
+
|
141
|
+
views_to_update = get_views_to_update(
|
142
|
+
all_resources_to_register_views_endpoints, views_registered_urls_all_attributes
|
143
|
+
)
|
144
|
+
|
145
|
+
return views_to_delete, views_to_update
|
146
|
+
|
147
|
+
|
148
|
+
def get_views_to_register(resources_to_register, views_registered_urls_all_attributes):
|
149
|
+
"""
|
150
|
+
Get the views to register.
|
151
|
+
resources_to_register: List of resources to register.
|
152
|
+
"""
|
153
|
+
|
154
|
+
views_to_register = [
|
155
|
+
ViewModel(
|
156
|
+
{
|
157
|
+
"name": view["endpoint"],
|
158
|
+
"url_rule": view["urls"],
|
159
|
+
"description": view["resource"].DESCRIPTION,
|
160
|
+
}
|
161
|
+
)
|
162
|
+
for view in resources_to_register
|
163
|
+
if view["endpoint"] not in views_registered_urls_all_attributes.keys()
|
164
|
+
]
|
165
|
+
|
166
|
+
return views_to_register, views_registered_urls_all_attributes
|
167
|
+
|
168
|
+
|
169
|
+
def get_database_view():
|
170
|
+
"""
|
171
|
+
Get the database views.
|
172
|
+
"""
|
173
|
+
views_registered_urls_all_attributes = {
|
174
|
+
view.name: {
|
175
|
+
"url_rule": view.url_rule,
|
176
|
+
"description": view.description,
|
177
|
+
"id": view.id,
|
178
|
+
}
|
179
|
+
for view in ViewModel.get_all_objects()
|
180
|
+
}
|
181
|
+
return views_registered_urls_all_attributes
|
182
|
+
|
183
|
+
|
184
|
+
def get_resources_to_register(external_app):
|
185
|
+
if external_app is None:
|
186
|
+
resources_to_register = resources
|
187
|
+
if current_app.config["ALARMS_ENDPOINTS"]:
|
188
|
+
resources_to_register = resources + alarms_resources
|
189
|
+
current_app.logger.info(" ALARMS ENDPOINTS ENABLED ")
|
190
|
+
else:
|
191
|
+
current_app.logger.info(f" USING EXTERNAL APP: {external_app} ")
|
192
|
+
sys.path.append("./")
|
193
|
+
external_module = import_module(external_app)
|
194
|
+
if current_app.config["ALARMS_ENDPOINTS"]:
|
195
|
+
resources_to_register = (
|
196
|
+
external_module.endpoints.resources + resources + alarms_resources
|
197
|
+
)
|
198
|
+
else:
|
199
|
+
resources_to_register = external_module.endpoints.resources + resources
|
200
|
+
return resources_to_register
|
@@ -396,10 +396,10 @@ class Auth:
|
|
396
396
|
"The permission for this endpoint is not in the database."
|
397
397
|
)
|
398
398
|
raise NoPermission(
|
399
|
-
error="
|
399
|
+
error="The permission for this endpoint is not in the database.",
|
400
400
|
status_code=403,
|
401
401
|
log_txt=f"Error while user {user_id} tries to access endpoint. "
|
402
|
-
f"The
|
402
|
+
f"The permission for this endpoint is not in the database.",
|
403
403
|
)
|
404
404
|
|
405
405
|
for role in user_roles:
|
@@ -414,7 +414,7 @@ class Auth:
|
|
414
414
|
error="You do not have permission to access this endpoint",
|
415
415
|
status_code=403,
|
416
416
|
log_txt=f"Error while user {user_id} tries to access endpoint {view_id} with action {action_id}. "
|
417
|
-
f"The user does not permission to access. ",
|
417
|
+
f"The user does not have permission to access. ",
|
418
418
|
)
|
419
419
|
|
420
420
|
@staticmethod
|
cornflow/shared/const.py
CHANGED
@@ -29,7 +29,6 @@ from cornflow.app import (
|
|
29
29
|
register_dag_permissions,
|
30
30
|
register_roles,
|
31
31
|
register_views,
|
32
|
-
register_base_assignations,
|
33
32
|
)
|
34
33
|
from cornflow.commands.dag import register_deployed_dags_command_test
|
35
34
|
from cornflow.endpoints import resources, alarms_resources
|
@@ -495,195 +494,3 @@ class TestCommands(TestCase):
|
|
495
494
|
},
|
496
495
|
)
|
497
496
|
self.assertEqual(403, response.status_code)
|
498
|
-
|
499
|
-
def test_permissions_not_deleted_when_roles_removed_from_code(self):
|
500
|
-
"""
|
501
|
-
Test that permissions are NOT deleted when roles are removed from ROLES_WITH_ACCESS.
|
502
|
-
|
503
|
-
This test demonstrates the current bug: when a role is removed from
|
504
|
-
ROLES_WITH_ACCESS in an endpoint's code, the corresponding permissions
|
505
|
-
in the database are not automatically deleted. This happens because
|
506
|
-
the deletion logic in register_base_permissions_command is commented out.
|
507
|
-
|
508
|
-
The test should currently FAIL to demonstrate the bug exists.
|
509
|
-
"""
|
510
|
-
# First, initialize the access system normally
|
511
|
-
self.runner.invoke(access_init)
|
512
|
-
|
513
|
-
# Get the original ROLES_WITH_ACCESS for ExampleDataListEndpoint
|
514
|
-
from cornflow.endpoints.example_data import ExampleDataListEndpoint
|
515
|
-
|
516
|
-
original_roles = ExampleDataListEndpoint.ROLES_WITH_ACCESS.copy()
|
517
|
-
|
518
|
-
# Verify initial permissions are created for all three roles
|
519
|
-
# Get the view ID for the endpoint
|
520
|
-
from cornflow.models import ViewModel
|
521
|
-
|
522
|
-
view = ViewModel.query.filter_by(name="example-data").first()
|
523
|
-
self.assertIsNotNone(view, "example-data view should exist")
|
524
|
-
|
525
|
-
# Check permissions exist for all original roles
|
526
|
-
from cornflow.shared.const import ACTIONS_MAP
|
527
|
-
from cornflow.models import PermissionViewRoleModel
|
528
|
-
|
529
|
-
# Check GET action permissions (action_id=1 is typically GET)
|
530
|
-
get_action_id = 1
|
531
|
-
initial_permissions = PermissionViewRoleModel.query.filter_by(
|
532
|
-
api_view_id=view.id, action_id=get_action_id
|
533
|
-
).all()
|
534
|
-
|
535
|
-
initial_role_ids = [perm.role_id for perm in initial_permissions]
|
536
|
-
self.assertEqual(
|
537
|
-
len(original_roles),
|
538
|
-
len(initial_permissions),
|
539
|
-
f"Should have permissions for all {len(original_roles)} original roles",
|
540
|
-
)
|
541
|
-
|
542
|
-
# Now simulate removing PLANNER_ROLE from ROLES_WITH_ACCESS
|
543
|
-
from cornflow.shared.const import PLANNER_ROLE, VIEWER_ROLE, ADMIN_ROLE
|
544
|
-
|
545
|
-
modified_roles = [VIEWER_ROLE, ADMIN_ROLE] # Remove PLANNER_ROLE
|
546
|
-
|
547
|
-
# Temporarily modify the ROLES_WITH_ACCESS
|
548
|
-
ExampleDataListEndpoint.ROLES_WITH_ACCESS = modified_roles
|
549
|
-
|
550
|
-
try:
|
551
|
-
|
552
|
-
# Run the permission registration again
|
553
|
-
# (this simulates redeploying the app with modified roles)
|
554
|
-
self.runner.invoke(register_base_assignations, ["-v"])
|
555
|
-
|
556
|
-
# Check permissions after the "code change"
|
557
|
-
updated_permissions = PermissionViewRoleModel.query.filter_by(
|
558
|
-
api_view_id=view.id, action_id=get_action_id
|
559
|
-
).all()
|
560
|
-
|
561
|
-
updated_role_ids = [perm.role_id for perm in updated_permissions]
|
562
|
-
|
563
|
-
# THIS IS THE BUG: The permission for PLANNER_ROLE should be deleted
|
564
|
-
# but it's not because the deletion logic is commented out
|
565
|
-
# So we expect this assertion to FAIL, demonstrating the bug
|
566
|
-
self.assertEqual(
|
567
|
-
len(modified_roles),
|
568
|
-
len(updated_permissions),
|
569
|
-
f"After removing PLANNER_ROLE from code, should only have {len(modified_roles)} permissions, "
|
570
|
-
f"but still has {len(updated_permissions)} permissions. "
|
571
|
-
f"This demonstrates the bug: permissions are not deleted when roles are removed from ROLES_WITH_ACCESS.",
|
572
|
-
)
|
573
|
-
|
574
|
-
# Also check that PLANNER_ROLE permission was actually removed
|
575
|
-
planner_permissions = [
|
576
|
-
perm for perm in updated_permissions if perm.role_id == PLANNER_ROLE
|
577
|
-
]
|
578
|
-
self.assertEqual(
|
579
|
-
0,
|
580
|
-
len(planner_permissions),
|
581
|
-
"PLANNER_ROLE permission should have been deleted but still exists",
|
582
|
-
)
|
583
|
-
|
584
|
-
finally:
|
585
|
-
# Restore original ROLES_WITH_ACCESS to avoid affecting other tests
|
586
|
-
ExampleDataListEndpoint.ROLES_WITH_ACCESS = original_roles
|
587
|
-
|
588
|
-
def test_custom_role_permissions_are_preserved(self):
|
589
|
-
"""
|
590
|
-
Test that custom roles (not in ROLES_MAP) are automatically detected
|
591
|
-
and assigned complete permissions during synchronization.
|
592
|
-
|
593
|
-
This test verifies that when permissions are synchronized:
|
594
|
-
1. Custom roles in the database are automatically detected
|
595
|
-
2. They are assigned all actions (GET, PATCH, POST, PUT, DELETE)
|
596
|
-
3. These permissions are created for all endpoints where the role has access
|
597
|
-
"""
|
598
|
-
# First, initialize the access system normally
|
599
|
-
self.runner.invoke(access_init)
|
600
|
-
|
601
|
-
# Get a view to work with
|
602
|
-
from cornflow.models import ViewModel, PermissionViewRoleModel, RoleModel
|
603
|
-
|
604
|
-
view = ViewModel.query.filter_by(name="example-data").first()
|
605
|
-
self.assertIsNotNone(view, "example-data view should exist")
|
606
|
-
|
607
|
-
# Create a custom role that is NOT in ROLES_MAP
|
608
|
-
custom_role_id = 999 # Use an ID that's not in ROLES_MAP
|
609
|
-
custom_role = RoleModel({"id": custom_role_id, "name": "custom_role"})
|
610
|
-
custom_role.save()
|
611
|
-
|
612
|
-
# Temporarily add the custom role to ExampleDataListEndpoint ROLES_WITH_ACCESS
|
613
|
-
from cornflow.endpoints.example_data import ExampleDataListEndpoint
|
614
|
-
|
615
|
-
original_roles = ExampleDataListEndpoint.ROLES_WITH_ACCESS.copy()
|
616
|
-
ExampleDataListEndpoint.ROLES_WITH_ACCESS = original_roles + [custom_role_id]
|
617
|
-
|
618
|
-
try:
|
619
|
-
# Count permissions before synchronization
|
620
|
-
perms_before = PermissionViewRoleModel.query.filter_by(
|
621
|
-
role_id=custom_role_id, api_view_id=view.id
|
622
|
-
).count()
|
623
|
-
self.assertEqual(
|
624
|
-
0, perms_before, "No custom permissions should exist initially"
|
625
|
-
)
|
626
|
-
|
627
|
-
# Run permission synchronization - this should auto-detect the custom role
|
628
|
-
self.runner.invoke(register_base_assignations, ["-v"])
|
629
|
-
|
630
|
-
# Verify that ALL actions have been assigned to the custom role
|
631
|
-
from cornflow.shared.const import (
|
632
|
-
GET_ACTION,
|
633
|
-
PATCH_ACTION,
|
634
|
-
POST_ACTION,
|
635
|
-
PUT_ACTION,
|
636
|
-
DELETE_ACTION,
|
637
|
-
)
|
638
|
-
|
639
|
-
expected_actions = [
|
640
|
-
GET_ACTION,
|
641
|
-
PATCH_ACTION,
|
642
|
-
POST_ACTION,
|
643
|
-
PUT_ACTION,
|
644
|
-
DELETE_ACTION,
|
645
|
-
]
|
646
|
-
|
647
|
-
for action in expected_actions:
|
648
|
-
permission = PermissionViewRoleModel.query.filter_by(
|
649
|
-
role_id=custom_role_id, action_id=action, api_view_id=view.id
|
650
|
-
).first()
|
651
|
-
self.assertIsNotNone(
|
652
|
-
permission,
|
653
|
-
f"Custom role should have permission for action {action}. "
|
654
|
-
f"The system should auto-detect custom roles and assign complete permissions.",
|
655
|
-
)
|
656
|
-
|
657
|
-
# Verify total count of permissions for custom role
|
658
|
-
total_perms = PermissionViewRoleModel.query.filter_by(
|
659
|
-
role_id=custom_role_id, api_view_id=view.id
|
660
|
-
).count()
|
661
|
-
self.assertEqual(
|
662
|
-
len(expected_actions),
|
663
|
-
total_perms,
|
664
|
-
f"Custom role should have {len(expected_actions)} permissions (all actions)",
|
665
|
-
)
|
666
|
-
|
667
|
-
# Test that running sync again doesn't duplicate permissions
|
668
|
-
self.runner.invoke(register_base_assignations, ["-v"])
|
669
|
-
|
670
|
-
total_perms_after_second_sync = PermissionViewRoleModel.query.filter_by(
|
671
|
-
role_id=custom_role_id, api_view_id=view.id
|
672
|
-
).count()
|
673
|
-
self.assertEqual(
|
674
|
-
len(expected_actions),
|
675
|
-
total_perms_after_second_sync,
|
676
|
-
"Permissions should not be duplicated on subsequent syncs",
|
677
|
-
)
|
678
|
-
|
679
|
-
finally:
|
680
|
-
# Clean up
|
681
|
-
# Delete permissions first (due to foreign key constraints)
|
682
|
-
permissions_to_delete = PermissionViewRoleModel.query.filter_by(
|
683
|
-
role_id=custom_role_id
|
684
|
-
).all()
|
685
|
-
for perm in permissions_to_delete:
|
686
|
-
perm.delete()
|
687
|
-
custom_role.delete()
|
688
|
-
# Restore original ROLES_WITH_ACCESS
|
689
|
-
ExampleDataListEndpoint.ROLES_WITH_ACCESS = original_roles
|