vantage6-algorithm-store 5.0.2__tar.gz → 5.0.3rc5__tar.gz

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.
Files changed (68) hide show
  1. {vantage6_algorithm_store-5.0.2 → vantage6_algorithm_store-5.0.3rc5}/PKG-INFO +4 -4
  2. {vantage6_algorithm_store-5.0.2 → vantage6_algorithm_store-5.0.3rc5}/pyproject.toml +3 -3
  3. {vantage6_algorithm_store-5.0.2 → vantage6_algorithm_store-5.0.3rc5}/tests_store/base/unittest_base.py +25 -3
  4. {vantage6_algorithm_store-5.0.2 → vantage6_algorithm_store-5.0.3rc5}/tests_store/resources/test_algorithm.py +74 -2
  5. {vantage6_algorithm_store-5.0.2 → vantage6_algorithm_store-5.0.3rc5}/vantage6/algorithm/store/__init__.py +17 -4
  6. {vantage6_algorithm_store-5.0.2 → vantage6_algorithm_store-5.0.3rc5}/vantage6/algorithm/store/link_algorithms.py +1 -1
  7. {vantage6_algorithm_store-5.0.2 → vantage6_algorithm_store-5.0.3rc5}/vantage6/algorithm/store/model/algorithm.py +3 -5
  8. {vantage6_algorithm_store-5.0.2 → vantage6_algorithm_store-5.0.3rc5}/vantage6/algorithm/store/model/base.py +1 -1
  9. {vantage6_algorithm_store-5.0.2 → vantage6_algorithm_store-5.0.3rc5}/vantage6/algorithm/store/model/policy.py +1 -3
  10. {vantage6_algorithm_store-5.0.2 → vantage6_algorithm_store-5.0.3rc5}/vantage6/algorithm/store/model/review.py +1 -3
  11. {vantage6_algorithm_store-5.0.2 → vantage6_algorithm_store-5.0.3rc5}/vantage6/algorithm/store/permission.py +1 -4
  12. {vantage6_algorithm_store-5.0.2 → vantage6_algorithm_store-5.0.3rc5}/vantage6/algorithm/store/resource/__init__.py +47 -9
  13. {vantage6_algorithm_store-5.0.2 → vantage6_algorithm_store-5.0.3rc5}/vantage6/algorithm/store/resource/algorithm.py +16 -4
  14. {vantage6_algorithm_store-5.0.2 → vantage6_algorithm_store-5.0.3rc5}/vantage6/algorithm/store/resource/policy.py +1 -1
  15. {vantage6_algorithm_store-5.0.2 → vantage6_algorithm_store-5.0.3rc5}/vantage6/algorithm/store/resource/review.py +7 -7
  16. {vantage6_algorithm_store-5.0.2 → vantage6_algorithm_store-5.0.3rc5}/vantage6/algorithm/store/resource/role.py +1 -2
  17. {vantage6_algorithm_store-5.0.2 → vantage6_algorithm_store-5.0.3rc5}/vantage6/algorithm/store/resource/rule.py +1 -1
  18. {vantage6_algorithm_store-5.0.2 → vantage6_algorithm_store-5.0.3rc5}/vantage6/algorithm/store/resource/schema/input_schema.py +24 -20
  19. {vantage6_algorithm_store-5.0.2 → vantage6_algorithm_store-5.0.3rc5}/vantage6/algorithm/store/resource/schema/output_schema.py +9 -9
  20. {vantage6_algorithm_store-5.0.2 → vantage6_algorithm_store-5.0.3rc5}/vantage6/algorithm/store/resource/user.py +1 -2
  21. {vantage6_algorithm_store-5.0.2 → vantage6_algorithm_store-5.0.3rc5}/vantage6/algorithm/store/resource/version.py +1 -1
  22. {vantage6_algorithm_store-5.0.2 → vantage6_algorithm_store-5.0.3rc5}/vantage6/algorithm/store/wsgi.py +1 -1
  23. {vantage6_algorithm_store-5.0.2 → vantage6_algorithm_store-5.0.3rc5}/.gitignore +0 -0
  24. {vantage6_algorithm_store-5.0.2 → vantage6_algorithm_store-5.0.3rc5}/Makefile +0 -0
  25. {vantage6_algorithm_store-5.0.2 → vantage6_algorithm_store-5.0.3rc5}/README.md +0 -0
  26. {vantage6_algorithm_store-5.0.2 → vantage6_algorithm_store-5.0.3rc5}/dev_store.sh +0 -0
  27. {vantage6_algorithm_store-5.0.2 → vantage6_algorithm_store-5.0.3rc5}/server.sh +0 -0
  28. {vantage6_algorithm_store-5.0.2 → vantage6_algorithm_store-5.0.3rc5}/tests_store/__init__.py +0 -0
  29. {vantage6_algorithm_store-5.0.2 → vantage6_algorithm_store-5.0.3rc5}/tests_store/base/__init__.py +0 -0
  30. {vantage6_algorithm_store-5.0.2 → vantage6_algorithm_store-5.0.3rc5}/tests_store/resources/__init__.py +0 -0
  31. {vantage6_algorithm_store-5.0.2 → vantage6_algorithm_store-5.0.3rc5}/tests_store/resources/test_policy.py +0 -0
  32. {vantage6_algorithm_store-5.0.2 → vantage6_algorithm_store-5.0.3rc5}/tests_store/resources/test_review.py +0 -0
  33. {vantage6_algorithm_store-5.0.2 → vantage6_algorithm_store-5.0.3rc5}/tests_store/resources/test_role.py +0 -0
  34. {vantage6_algorithm_store-5.0.2 → vantage6_algorithm_store-5.0.3rc5}/tests_store/resources/test_rule.py +0 -0
  35. {vantage6_algorithm_store-5.0.2 → vantage6_algorithm_store-5.0.3rc5}/tests_store/resources/test_user.py +0 -0
  36. {vantage6_algorithm_store-5.0.2 → vantage6_algorithm_store-5.0.3rc5}/tests_store/resources/test_version.py +0 -0
  37. {vantage6_algorithm_store-5.0.2 → vantage6_algorithm_store-5.0.3rc5}/tests_store/test___init__.py +0 -0
  38. {vantage6_algorithm_store-5.0.2 → vantage6_algorithm_store-5.0.3rc5}/tests_store/test_argument_schema.py +0 -0
  39. {vantage6_algorithm_store-5.0.2 → vantage6_algorithm_store-5.0.3rc5}/tests_store/test_link_algorithms.py +0 -0
  40. {vantage6_algorithm_store-5.0.2 → vantage6_algorithm_store-5.0.3rc5}/vantage6/algorithm/store/_data/unittest_config.yaml +0 -0
  41. {vantage6_algorithm_store-5.0.2 → vantage6_algorithm_store-5.0.3rc5}/vantage6/algorithm/store/_data/unittest_fixtures.yaml +0 -0
  42. {vantage6_algorithm_store-5.0.2 → vantage6_algorithm_store-5.0.3rc5}/vantage6/algorithm/store/algorithm_create.py +0 -0
  43. {vantage6_algorithm_store-5.0.2 → vantage6_algorithm_store-5.0.3rc5}/vantage6/algorithm/store/db.py +0 -0
  44. {vantage6_algorithm_store-5.0.2 → vantage6_algorithm_store-5.0.3rc5}/vantage6/algorithm/store/default_roles.py +0 -0
  45. {vantage6_algorithm_store-5.0.2 → vantage6_algorithm_store-5.0.3rc5}/vantage6/algorithm/store/globals.py +0 -0
  46. {vantage6_algorithm_store-5.0.2 → vantage6_algorithm_store-5.0.3rc5}/vantage6/algorithm/store/init_db.py +0 -0
  47. {vantage6_algorithm_store-5.0.2 → vantage6_algorithm_store-5.0.3rc5}/vantage6/algorithm/store/model/__init__.py +0 -0
  48. {vantage6_algorithm_store-5.0.2 → vantage6_algorithm_store-5.0.3rc5}/vantage6/algorithm/store/model/allowed_argument_value.py +0 -0
  49. {vantage6_algorithm_store-5.0.2 → vantage6_algorithm_store-5.0.3rc5}/vantage6/algorithm/store/model/argument.py +0 -0
  50. {vantage6_algorithm_store-5.0.2 → vantage6_algorithm_store-5.0.3rc5}/vantage6/algorithm/store/model/common/enums.py +0 -0
  51. {vantage6_algorithm_store-5.0.2 → vantage6_algorithm_store-5.0.3rc5}/vantage6/algorithm/store/model/common/ui_visualization_schemas.py +0 -0
  52. {vantage6_algorithm_store-5.0.2 → vantage6_algorithm_store-5.0.3rc5}/vantage6/algorithm/store/model/database.py +0 -0
  53. {vantage6_algorithm_store-5.0.2 → vantage6_algorithm_store-5.0.3rc5}/vantage6/algorithm/store/model/function.py +0 -0
  54. {vantage6_algorithm_store-5.0.2 → vantage6_algorithm_store-5.0.3rc5}/vantage6/algorithm/store/model/permission.py +0 -0
  55. {vantage6_algorithm_store-5.0.2 → vantage6_algorithm_store-5.0.3rc5}/vantage6/algorithm/store/model/role.py +0 -0
  56. {vantage6_algorithm_store-5.0.2 → vantage6_algorithm_store-5.0.3rc5}/vantage6/algorithm/store/model/role_rule_association.py +0 -0
  57. {vantage6_algorithm_store-5.0.2 → vantage6_algorithm_store-5.0.3rc5}/vantage6/algorithm/store/model/rule.py +0 -0
  58. {vantage6_algorithm_store-5.0.2 → vantage6_algorithm_store-5.0.3rc5}/vantage6/algorithm/store/model/ui_visualization.py +0 -0
  59. {vantage6_algorithm_store-5.0.2 → vantage6_algorithm_store-5.0.3rc5}/vantage6/algorithm/store/model/user.py +0 -0
  60. {vantage6_algorithm_store-5.0.2 → vantage6_algorithm_store-5.0.3rc5}/vantage6/algorithm/store/static/robots.txt +0 -0
  61. {vantage6_algorithm_store-5.0.2 → vantage6_algorithm_store-5.0.3rc5}/vantage6/algorithm/store/templates/mail/algorithm_review_finalized.html +0 -0
  62. {vantage6_algorithm_store-5.0.2 → vantage6_algorithm_store-5.0.3rc5}/vantage6/algorithm/store/templates/mail/algorithm_review_finalized.txt +0 -0
  63. {vantage6_algorithm_store-5.0.2 → vantage6_algorithm_store-5.0.3rc5}/vantage6/algorithm/store/templates/mail/new_algorithm.html +0 -0
  64. {vantage6_algorithm_store-5.0.2 → vantage6_algorithm_store-5.0.3rc5}/vantage6/algorithm/store/templates/mail/new_algorithm.txt +0 -0
  65. {vantage6_algorithm_store-5.0.2 → vantage6_algorithm_store-5.0.3rc5}/vantage6/algorithm/store/templates/mail/new_review.html +0 -0
  66. {vantage6_algorithm_store-5.0.2 → vantage6_algorithm_store-5.0.3rc5}/vantage6/algorithm/store/templates/mail/new_review.txt +0 -0
  67. {vantage6_algorithm_store-5.0.2 → vantage6_algorithm_store-5.0.3rc5}/vantage6/algorithm/store/templates/mail/review_no_longer_required.html +0 -0
  68. {vantage6_algorithm_store-5.0.2 → vantage6_algorithm_store-5.0.3rc5}/vantage6/algorithm/store/templates/mail/review_no_longer_required.txt +0 -0
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: vantage6-algorithm-store
3
- Version: 5.0.2
3
+ Version: 5.0.3rc5
4
4
  Summary: Vantage6 algorithm store
5
5
  Author: Vantage6 Team
6
6
  Maintainer-email: Frank Martin <f.martin@iknl.nl>, Bart van Beusekom <b.vanbeusekom@iknl.nl>
@@ -18,8 +18,8 @@ Requires-Dist: python-keycloak==5.5.0
18
18
  Requires-Dist: requests==2.33.1
19
19
  Requires-Dist: schema==0.7.5
20
20
  Requires-Dist: sqlalchemy==2.0.37
21
- Requires-Dist: vantage6-common==5.0.2
22
- Requires-Dist: vantage6==5.0.2
21
+ Requires-Dist: vantage6-common==5.0.3rc5
22
+ Requires-Dist: vantage6==5.0.3rc5
23
23
  Provides-Extra: dev
24
24
  Requires-Dist: black; extra == 'dev'
25
25
  Requires-Dist: coverage==7.10.2; extra == 'dev'
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "vantage6-algorithm-store"
7
- version = "5.0.2"
7
+ version = "5.0.3rc5"
8
8
  description = "Vantage6 algorithm store"
9
9
  readme = "README.md"
10
10
  license = {text = "MIT"}
@@ -30,8 +30,8 @@ dependencies = [
30
30
  "requests==2.33.1",
31
31
  "schema==0.7.5",
32
32
  "sqlalchemy==2.0.37",
33
- "vantage6-common==5.0.2",
34
- "vantage6==5.0.2",
33
+ "vantage6-common==5.0.3rc5",
34
+ "vantage6==5.0.3rc5",
35
35
  ]
36
36
 
37
37
  [project.optional-dependencies]
@@ -2,7 +2,9 @@ import os
2
2
  import unittest
3
3
  from http import HTTPStatus
4
4
  from unittest.mock import Mock, patch
5
+ from uuid import uuid1
5
6
 
7
+ import jwt
6
8
  from cryptography.hazmat.backends import default_backend
7
9
  from cryptography.hazmat.primitives import serialization
8
10
  from cryptography.hazmat.primitives.asymmetric import rsa
@@ -107,10 +109,10 @@ class TestResources(unittest.TestCase):
107
109
  def register_user(
108
110
  self,
109
111
  username: str = "test_user",
110
- user_roles: list[Role] = None,
111
- user_rules: list[Rule] = None,
112
+ user_roles: list[Role] | None = None,
113
+ user_rules: list[Rule] | None = None,
112
114
  organization_id: int = 1,
113
- authenticate_mock: Mock = None,
115
+ authenticate_mock: Mock | None = None,
114
116
  auth: bool = True,
115
117
  ) -> User:
116
118
  user = User(username=username, organization_id=organization_id)
@@ -147,3 +149,23 @@ class TestResources(unittest.TestCase):
147
149
  rules=role["rules"],
148
150
  )
149
151
  new_role.save()
152
+
153
+ def login_node(self) -> dict:
154
+ """
155
+ Build headers with a real node JWT (mirrors vantage6-hq's `login_node` test
156
+ helper), for testing endpoints that recognize nodes via the
157
+ `vantage6_client_type` claim rather than a registered store `User`.
158
+
159
+ Returns
160
+ -------
161
+ dict
162
+ Authorization header with a valid node bearer token.
163
+ """
164
+ mock_claims = {
165
+ "sub": str(uuid1()),
166
+ "vantage6_client_type": "node",
167
+ "exp": 9999999999,
168
+ "iat": 0,
169
+ }
170
+ mock_token = jwt.encode(mock_claims, MOCK_PRIVATE_KEY_PEM, algorithm="RS256")
171
+ return {"Authorization": f"Bearer {mock_token}"}
@@ -136,6 +136,63 @@ class TestAlgorithmResources(TestResources):
136
136
  policy.delete()
137
137
  user.delete()
138
138
 
139
+ def test_view_algorithm_decorator_node(self):
140
+ """
141
+ Test that a node JWT is always allowed to list approved algorithms
142
+ (`allow_node=True` on the decorator), even under the store's most
143
+ restrictive algorithm_view policy - and that this does not extend to
144
+ non-approved algorithms.
145
+ """
146
+ # even the most restrictive policy should not block a node
147
+ policy = Policy(
148
+ key=StorePolicies.ALGORITHM_VIEW.value,
149
+ value=AlgorithmViewPolicies.ONLY_WITH_EXPLICIT_PERMISSION.value,
150
+ )
151
+ policy.save()
152
+
153
+ node_headers = self.login_node()
154
+
155
+ # a node with a valid token can list approved algorithms
156
+ rv = self.app.get("/api/algorithm", headers=node_headers)
157
+ self.assertEqual(rv.status_code, HTTPStatus.OK)
158
+
159
+ # a node cannot use this to see non-approved algorithms
160
+ for arg in [
161
+ "awaiting_reviewer_assignment",
162
+ "under_review",
163
+ "in_review_process",
164
+ "invalidated",
165
+ ]:
166
+ rv = self.app.get(f"/api/algorithm?{arg}=1", headers=node_headers)
167
+ self.assertEqual(rv.status_code, HTTPStatus.UNAUTHORIZED)
168
+
169
+ # this is not a blanket bypass: a request without a token is still governed
170
+ # by the normal policy
171
+ rv = self.app.get("/api/algorithm")
172
+ self.assertEqual(rv.status_code, HTTPStatus.UNAUTHORIZED)
173
+
174
+ # cleanup
175
+ policy.delete()
176
+
177
+ def test_view_algorithm_single_not_affected_by_node_bypass(self):
178
+ """
179
+ Test that the node bypass on the algorithm list endpoint does not extend to
180
+ /api/algorithm/<id>, which was deliberately left on the un-modified
181
+ decorator.
182
+ """
183
+ algorithm = Algorithm(
184
+ name="test_algorithm", status=AlgorithmStatus.APPROVED.value
185
+ )
186
+ algorithm.save()
187
+
188
+ # no policy is defined, so the default (AUTHENTICATED) applies - and a node
189
+ # token is not looked up as a store User, so it is rejected here
190
+ rv = self.app.get(f"/api/algorithm/{algorithm.id}", headers=self.login_node())
191
+ self.assertEqual(rv.status_code, HTTPStatus.UNAUTHORIZED)
192
+
193
+ # cleanup
194
+ algorithm.delete()
195
+
139
196
  @patch("vantage6.algorithm.store.resource._authenticate")
140
197
  def test_algorithm_view_multi(self, authenticate_mock):
141
198
  """Test GET /api/algorithm"""
@@ -544,9 +601,24 @@ class TestAlgorithmResources(TestResources):
544
601
  self.assertEqual(response.status_code, HTTPStatus.OK)
545
602
  self.assertEqual(response.json["description"], "new_description")
546
603
 
604
+ # check that another developer cannot update this algorithm - they are
605
+ # not its owner
606
+ self.register_user(
607
+ username="other_developer",
608
+ user_rules=[Rule.get_by_("algorithm", Operation.EDIT)],
609
+ authenticate_mock=authenticate_mock,
610
+ )
611
+ response = self.app.patch(
612
+ f"/api/algorithm/{algorithm.id}", json={"description": "other_description"}
613
+ )
614
+ self.assertEqual(response.status_code, HTTPStatus.FORBIDDEN)
615
+
616
+ # switch back to the algorithm's own developer for the remaining checks
617
+ self._mock_auth(user, authenticate_mock, True)
618
+
547
619
  # check that algorithm cannot be updated if it is approved
548
620
  algorithm.status = AlgorithmStatus.APPROVED.value
549
- algorithm.approved_at = datetime.datetime.now(datetime.timezone.utc)
621
+ algorithm.approved_at = datetime.datetime.now(datetime.UTC)
550
622
  algorithm.save()
551
623
  response = self.app.patch(
552
624
  f"/api/algorithm/{algorithm.id}", json={"description": "new_description"}
@@ -554,7 +626,7 @@ class TestAlgorithmResources(TestResources):
554
626
  self.assertEqual(response.status_code, HTTPStatus.FORBIDDEN)
555
627
 
556
628
  # check that algorithm cannot be updated if it is invalidated
557
- algorithm.invalidated_at = datetime.datetime.now(datetime.timezone.utc)
629
+ algorithm.invalidated_at = datetime.datetime.now(datetime.UTC)
558
630
  algorithm.save()
559
631
  response = self.app.patch(
560
632
  f"/api/algorithm/{algorithm.id}", json={"description": "new_description"}
@@ -26,6 +26,7 @@ from flask_principal import Identity, identity_changed
26
26
 
27
27
  from vantage6.common import logger_name
28
28
  from vantage6.common.enum import StorePolicies
29
+ from vantage6.common.exceptions import AuthenticationException
29
30
  from vantage6.common.globals import DEFAULT_API_PATH
30
31
 
31
32
  from vantage6.cli.context.algorithm_store import AlgorithmStoreContext
@@ -100,7 +101,7 @@ class AlgorithmStoreApp(Vantage6App):
100
101
  """Configure JWT authentication."""
101
102
 
102
103
  @self.jwt.user_lookup_loader
103
- def user_lookup_loader(jwt_payload: dict, jwt_headers: dict) -> db.User:
104
+ def user_lookup_loader(jwt_payload: dict, jwt_headers: dict) -> db.User | str:
104
105
  """
105
106
  Load the user, node or container instance from the JWT payload.
106
107
 
@@ -113,15 +114,27 @@ class AlgorithmStoreApp(Vantage6App):
113
114
 
114
115
  Returns
115
116
  -------
116
- db.User:
117
- The user identity.
117
+ db.User | str:
118
+ The user identity, or the keycloak id if it is a node that is
119
+ authenticating.
120
+
121
+ Raises
122
+ ------
123
+ AuthenticationException
124
+ If no store `User` is registered for this (non-node) identity.
118
125
  """
119
126
  identity = jwt_headers["sub"]
127
+
128
+ if jwt_headers.get("vantage6_client_type") == "node":
129
+ return identity
130
+
120
131
  auth_identity = Identity(identity)
121
132
 
122
133
  user = db.User.get_by_keycloak_id(identity)
123
134
  if not user:
124
- raise Exception("No user found for keycloak id %s", identity)
135
+ raise AuthenticationException(
136
+ f"No user found for keycloak id {identity}"
137
+ )
125
138
 
126
139
  # add role permissions
127
140
  for role in user.roles:
@@ -213,7 +213,7 @@ def link_algorithms_from_config(config: dict) -> None:
213
213
  developer = db.User.get_first_user()
214
214
  if not developer:
215
215
  log.warning(
216
- "link_algorithms: No users found; skipping seeding",
216
+ "link_algorithms: No users found (root_username=%s); skipping seeding",
217
217
  root_username,
218
218
  )
219
219
  return
@@ -60,9 +60,7 @@ class Algorithm(Base):
60
60
  partitioning = Column(String)
61
61
  vantage6_version = Column(String)
62
62
  digest = Column(String)
63
- submitted_at = Column(
64
- DateTime, default=datetime.datetime.now(datetime.timezone.utc)
65
- )
63
+ submitted_at = Column(DateTime, default=datetime.datetime.now(datetime.UTC))
66
64
  approved_at = Column(DateTime)
67
65
  invalidated_at = Column(DateTime)
68
66
  developer_id = Column(Integer, ForeignKey("user.id"))
@@ -96,14 +94,14 @@ class Algorithm(Base):
96
94
  bool
97
95
  True if all reviews are approved, False otherwise
98
96
  """
99
- return all([review.status == ReviewStatus.APPROVED for review in self.reviews])
97
+ return all(review.status == ReviewStatus.APPROVED for review in self.reviews)
100
98
 
101
99
  def approve(self) -> None:
102
100
  """
103
101
  Approve the algorithm, and invalidate all other algorithms with the same image.
104
102
  """
105
103
  self.status = AlgorithmStatus.APPROVED.value
106
- self.approved_at = datetime.datetime.now(datetime.timezone.utc)
104
+ self.approved_at = datetime.datetime.now(datetime.UTC)
107
105
  self.save()
108
106
 
109
107
  for other_version in self.get_by_image(self.image):
@@ -109,7 +109,7 @@ class ModelBase(BaseModelBase):
109
109
  """
110
110
 
111
111
  @classmethod
112
- def get(cls, id_: int = None):
112
+ def get(cls, id_: int | None = None):
113
113
  """
114
114
  Get a single object by its id, or a list of objects when no id is
115
115
  specified.
@@ -130,6 +130,4 @@ class Policy(Base):
130
130
  None,
131
131
  )
132
132
 
133
- if result is None:
134
- return False
135
- return True
133
+ return result is not None
@@ -32,9 +32,7 @@ class Review(Base):
32
32
  algorithm_id = Column(Integer, ForeignKey("algorithm.id"))
33
33
  reviewer_id = Column(Integer, ForeignKey("user.id"))
34
34
  requested_by_id = Column(Integer, ForeignKey("user.id"))
35
- requested_at = Column(
36
- DateTime, default=datetime.datetime.now(datetime.timezone.utc)
37
- )
35
+ requested_at = Column(DateTime, default=datetime.datetime.now(datetime.UTC))
38
36
  submitted_at = Column(DateTime)
39
37
  status = Column(Text, default=ReviewStatus.UNDER_REVIEW.value)
40
38
  comment = Column(Text)
@@ -36,10 +36,7 @@ class RuleCollection(RuleCollectionBase):
36
36
  """
37
37
  perm = getattr(self, operation, None)
38
38
 
39
- if perm and perm.can():
40
- return True
41
-
42
- return False
39
+ return bool(perm and perm.can())
43
40
 
44
41
 
45
42
  class PermissionManager(PermissionManagerBase):
@@ -7,7 +7,7 @@ from http import HTTPStatus
7
7
  from typing import TYPE_CHECKING
8
8
 
9
9
  from flask import g, request
10
- from flask_jwt_extended import get_jwt_identity, verify_jwt_in_request
10
+ from flask_jwt_extended import get_jwt, get_jwt_identity, verify_jwt_in_request
11
11
  from flask_mail import Mail
12
12
  from flask_restful import Api
13
13
 
@@ -105,6 +105,26 @@ def _authorize_user(
105
105
  return None, None
106
106
 
107
107
 
108
+ def _is_node_request() -> bool:
109
+ """
110
+ Check whether the current request carries a valid node JWT.
111
+
112
+ Nodes are never registered as a store `User` - unlike `_authenticate`, this does
113
+ not do a database lookup. It only relies on the `vantage6_client_type` claim set
114
+ by the shared vantage6 Keycloak realm.
115
+
116
+ Note that this function does not raise if no token is present at all, so it is safe
117
+ to call unconditionally before any policy-based access checks.
118
+
119
+ Returns
120
+ -------
121
+ bool
122
+ Whether the request is authenticated as a node.
123
+ """
124
+ verify_jwt_in_request(optional=True)
125
+ return get_jwt().get("vantage6_client_type") == "node"
126
+
127
+
108
128
  def with_authentication() -> Callable:
109
129
  """
110
130
  Decorator to verify that the user is authenticated from the linked Keycloak service.
@@ -169,10 +189,21 @@ def with_permission(resource: str, operation: Operation) -> Callable:
169
189
  return protection_decorator
170
190
 
171
191
 
172
- def with_permission_to_view_algorithms() -> Callable:
192
+ def with_permission_to_view_algorithms(allow_node: bool = False) -> Callable:
173
193
  """
174
194
  Decorator to verify that the user has as a permission on a resource.
175
195
 
196
+ Parameters
197
+ ----------
198
+ allow_node : bool, optional
199
+ If True, also allow requests authenticated as a node (see `_is_node_request`)
200
+ to view approved algorithms, regardless of the store's `algorithm_view` policy.
201
+ This lets nodes independently re-verify - for their own
202
+ `allowed_algorithm_stores` policy - that an image is a registered, approved
203
+ algorithm in this store, without requiring the node to be registered as a
204
+ store `User`. Node access is still restricted to approved algorithms only.
205
+ Default is False.
206
+
176
207
  Returns
177
208
  -------
178
209
  Callable
@@ -183,13 +214,6 @@ def with_permission_to_view_algorithms() -> Callable:
183
214
  def protection_decorator(fn):
184
215
  @wraps(fn)
185
216
  def decorator(self, *args, **kwargs):
186
- policies = Policy.get_as_dict()
187
- # check if everyone has permission to view algorithms
188
- algorithm_view_policy = policies.get(
189
- StorePolicies.ALGORITHM_VIEW.value,
190
- DefaultStorePolicies.ALGORITHM_VIEW.value,
191
- )
192
-
193
217
  # check if user is trying to view algorithms that are not approved by review
194
218
  # or have been invalidated - these algorithms always require authentication
195
219
  # even when algorithms are open to all
@@ -201,6 +225,20 @@ def with_permission_to_view_algorithms() -> Callable:
201
225
  or request_args.get("invalidated")
202
226
  )
203
227
 
228
+ # nodes re-verifying an image against this store's catalog are always
229
+ # allowed to see approved algorithms, independent of the store's
230
+ # algorithm_view policy - they are never allowed to see non-approved ones
231
+ if allow_node and request_approved and _is_node_request():
232
+ g.node = True
233
+ return fn(self, *args, **kwargs)
234
+
235
+ policies = Policy.get_as_dict()
236
+ # check if everyone has permission to view algorithms
237
+ algorithm_view_policy = policies.get(
238
+ StorePolicies.ALGORITHM_VIEW.value,
239
+ DefaultStorePolicies.ALGORITHM_VIEW.value,
240
+ )
241
+
204
242
  # if the algorithm is public and approved, allow access
205
243
  if (
206
244
  algorithm_view_policy == AlgorithmViewPolicies.PUBLIC
@@ -67,7 +67,7 @@ def setup(api: Api, api_base: str, services: dict) -> None:
67
67
  services : dict
68
68
  Dictionary with services required for the resource endpoints
69
69
  """
70
- path = "/".join([api_base, module_name])
70
+ path = f"{api_base}/{module_name}"
71
71
  log.info('Setting up "%s" and subdirectories', path)
72
72
 
73
73
  api.add_resource(
@@ -148,7 +148,7 @@ class AlgorithmBaseResource(AlgorithmStoreResources):
148
148
  class Algorithms(AlgorithmBaseResource):
149
149
  """Resource for /algorithm"""
150
150
 
151
- @with_permission_to_view_algorithms()
151
+ @with_permission_to_view_algorithms(allow_node=True)
152
152
  def get(self):
153
153
  """List algorithms
154
154
  ---
@@ -159,6 +159,12 @@ class Algorithms(AlgorithmBaseResource):
159
159
  algorithms, set the 'awaiting_reviewer_assignment', 'under_review' or
160
160
  'invalidated' parameter to True.
161
161
 
162
+ Nodes are always allowed to list approved algorithms (e.g. filtered by
163
+ 'image'), regardless of this store's algorithm_view policy - this lets a node
164
+ independently verify that an image is a registered, approved algorithm for
165
+ its own allowed_algorithm_stores policy. Nodes cannot use the non-approved
166
+ filters.
167
+
162
168
  parameters:
163
169
  - in: query
164
170
  name: name
@@ -863,6 +869,12 @@ class Algorithm(AlgorithmBaseResource):
863
869
  if not algorithm:
864
870
  return {"msg": "Algorithm not found"}, HTTPStatus.NOT_FOUND
865
871
 
872
+ # only the algorithm's own developer may edit it
873
+ if algorithm.developer_id != g.user.id:
874
+ return {
875
+ "msg": "You can only edit algorithms you submitted yourself."
876
+ }, HTTPStatus.FORBIDDEN
877
+
866
878
  data = request.get_json(silent=True)
867
879
 
868
880
  # validate the request body
@@ -887,7 +899,7 @@ class Algorithm(AlgorithmBaseResource):
887
899
  "algorithm and go through the review process if you want to update it."
888
900
  }, HTTPStatus.FORBIDDEN
889
901
  elif algorithm.reviews and any(
890
- [r.is_review_finished() for r in algorithm.reviews]
902
+ r.is_review_finished() for r in algorithm.reviews
891
903
  ):
892
904
  return {
893
905
  "msg": "This algorithm has at least one submitted review, and can "
@@ -1043,7 +1055,7 @@ class AlgorithmInvalidate(AlgorithmStoreResources):
1043
1055
  return {"msg": "Algorithm not found"}, HTTPStatus.NOT_FOUND
1044
1056
 
1045
1057
  # invalidate the algorithm
1046
- algorithm.invalidated_at = datetime.datetime.now(datetime.timezone.utc)
1058
+ algorithm.invalidated_at = datetime.datetime.now(datetime.UTC)
1047
1059
  algorithm.status = AlgorithmStatus.REMOVED.value
1048
1060
  algorithm.save()
1049
1061
 
@@ -38,7 +38,7 @@ def setup(api: Api, api_base: str, services: dict) -> None:
38
38
  services : dict
39
39
  Dictionary with services required for the resource endpoints
40
40
  """
41
- path = "/".join([api_base, module_name])
41
+ path = f"{api_base}/{module_name}"
42
42
  log.info('Setting up "%s" and subdirectories', path)
43
43
 
44
44
  api.add_resource(
@@ -52,7 +52,7 @@ def setup(api: Api, api_base: str, services: dict) -> None:
52
52
  Dictionary with services required for the resource endpoints
53
53
  """
54
54
 
55
- path = "/".join([api_base, module_name])
55
+ path = f"{api_base}/{module_name}"
56
56
  log.info(f'Setting up "{path}" and subdirectories')
57
57
 
58
58
  api.add_resource(
@@ -138,7 +138,7 @@ class ReviewBase(AlgorithmStoreResources):
138
138
  True if the reviewers assignment is finished, False otherwise
139
139
  """
140
140
  # get the number of unique organizations assigned to review the algorithm.
141
- current_orgs = len(set([(rev.reviewer.organization_id) for rev in reviews]))
141
+ current_orgs = len({(rev.reviewer.organization_id) for rev in reviews})
142
142
 
143
143
  return (
144
144
  current_orgs >= Policy.get_minimum_reviewing_orgs()
@@ -565,12 +565,12 @@ class Review(ReviewBase):
565
565
  if not algorithm.is_review_finished():
566
566
  # if number of reviews after deletion is less than the minium,
567
567
  # new reviewers should be assigned
568
- other_reviews = [r for r in algorithm.reviews if not r.id == review.id]
568
+ other_reviews = [r for r in algorithm.reviews if r.id != review.id]
569
569
  if not other_reviews or not self._is_reviewers_assignment_finished(
570
570
  other_reviews
571
571
  ):
572
572
  algorithm.status = AlgorithmStatus.AWAITING_REVIEWER_ASSIGNMENT.value
573
- elif all([r.status == ReviewStatus.APPROVED for r in other_reviews]):
573
+ elif all(r.status == ReviewStatus.APPROVED for r in other_reviews):
574
574
  # if this was the last remaining review that needed to be approved, but
575
575
  # it is now deleted, the algorithm should be approved
576
576
  algorithm.status = AlgorithmStatus.APPROVED.value
@@ -747,7 +747,7 @@ class ReviewApprove(ReviewUpdateResources):
747
747
 
748
748
  # update the review status to 'approved'
749
749
  review.status = ReviewStatus.APPROVED.value
750
- review.submitted_at = datetime.datetime.now(datetime.timezone.utc)
750
+ review.submitted_at = datetime.datetime.now(datetime.UTC)
751
751
  if comment := data.get("comment"):
752
752
  review.comment = comment
753
753
  review.save()
@@ -842,7 +842,7 @@ class ReviewReject(ReviewUpdateResources):
842
842
 
843
843
  # update the review status to 'rejected'
844
844
  review.status = ReviewStatus.REJECTED.value
845
- review.submitted_at = datetime.datetime.now(datetime.timezone.utc)
845
+ review.submitted_at = datetime.datetime.now(datetime.UTC)
846
846
  if comment := data.get("comment"):
847
847
  review.comment = comment
848
848
  review.save()
@@ -850,7 +850,7 @@ class ReviewReject(ReviewUpdateResources):
850
850
  # also update the algorithm status to 'rejected'
851
851
  algorithm: db.Algorithm = review.algorithm
852
852
  algorithm.status = AlgorithmStatus.REJECTED.value
853
- algorithm.invalidated_at = datetime.datetime.now(datetime.timezone.utc)
853
+ algorithm.invalidated_at = datetime.datetime.now(datetime.UTC)
854
854
  algorithm.save()
855
855
 
856
856
  # the other reviews of this algorithm are dropped as they are no longer required
@@ -1,4 +1,3 @@
1
- # -*- coding: utf-8 -*-
2
1
  import logging
3
2
  from http import HTTPStatus
4
3
 
@@ -50,7 +49,7 @@ def setup(api: Api, api_base: str, services: dict) -> None:
50
49
  services : dict
51
50
  Dictionary with services required for the resource endpoints
52
51
  """
53
- path = "/".join([api_base, module_name])
52
+ path = f"{api_base}/{module_name}"
54
53
  log.info(f'Setting up "{path}" and subdirectories')
55
54
 
56
55
  api.add_resource(
@@ -35,7 +35,7 @@ def setup(api: Api, api_base: str, services: dict) -> None:
35
35
  services : dict
36
36
  Dictionary with services required for the resource endpoints
37
37
  """
38
- path = "/".join([api_base, module_name])
38
+ path = f"{api_base}/{module_name}"
39
39
  log.info(f'Setting up "{path}" and subdirectories')
40
40
 
41
41
  api.add_resource(
@@ -4,9 +4,8 @@ Marshmallow schemas for validating input data for the API.
4
4
 
5
5
  import json
6
6
 
7
- import marshmallow.validate as validate
8
7
  from jsonschema import validate as json_validate
9
- from marshmallow import Schema, ValidationError, fields, validates_schema
8
+ from marshmallow import Schema, ValidationError, fields, validate, validates_schema
10
9
 
11
10
  from vantage6.common.enum import (
12
11
  AlgorithmArgumentType,
@@ -177,14 +176,16 @@ class FunctionInputSchema(_NameDescriptionSchema):
177
176
  f"float, while the conditional argument '{conditional_on}' "
178
177
  "requires a float"
179
178
  ) from exc
180
- elif conditional_type == AlgorithmArgumentType.BOOLEAN:
181
- if conditional_value.lower() not in ["true", "false", "1", "0"]:
182
- raise ValidationError(
183
- f"Conditional value '{conditional_value}' is not a valid "
184
- "boolean, while the conditional argument "
185
- f"'{conditional_on}' requires a boolean. Please use 'true',"
186
- " 'false', '1', or '0'"
187
- )
179
+ elif (
180
+ conditional_type == AlgorithmArgumentType.BOOLEAN
181
+ and conditional_value.lower() not in ["true", "false", "1", "0"]
182
+ ):
183
+ raise ValidationError(
184
+ f"Conditional value '{conditional_value}' is not a valid "
185
+ "boolean, while the conditional argument "
186
+ f"'{conditional_on}' requires a boolean. Please use 'true',"
187
+ " 'false', '1', or '0'"
188
+ )
188
189
 
189
190
  @staticmethod
190
191
  def _check_circular_conditions(arguments: list[dict]) -> None:
@@ -244,7 +245,7 @@ class _MixedBaseTypeField(fields.Field):
244
245
  """
245
246
 
246
247
  def _deserialize(self, value, attr, data, **kwargs):
247
- if isinstance(value, str) or isinstance(value, int) or isinstance(value, float):
248
+ if isinstance(value, (str, int, float)):
248
249
  return value
249
250
  else:
250
251
  raise ValidationError("Values should be str, int or float")
@@ -381,8 +382,8 @@ class ArgumentInputSchema(_NameDescriptionSchema):
381
382
  try:
382
383
  json_list = json.loads(default)
383
384
  if not isinstance(json_list, list):
384
- raise ValueError(f"Not a list: {json_list}")
385
- except ValueError as exc:
385
+ raise TypeError(f"Not a list: {json_list}")
386
+ except (ValueError, TypeError) as exc:
386
387
  raise ValidationError(
387
388
  f"Default value '{default}' is not a valid JSON array, while "
388
389
  f"the argument type {type_} requires a JSON array"
@@ -394,10 +395,10 @@ class ArgumentInputSchema(_NameDescriptionSchema):
394
395
  try:
395
396
  json_list = json.loads(default)
396
397
  if not isinstance(json_list, list):
397
- raise ValueError(f"Not a list: {json_list}")
398
+ raise TypeError(f"Not a list: {json_list}")
398
399
  for value in json_list:
399
400
  int(value)
400
- except ValueError as exc:
401
+ except (ValueError, TypeError) as exc:
401
402
  raise ValidationError(
402
403
  f"Default value '{default}' is not a valid JSON array of "
403
404
  f"integers, while the argument type {type_} requires a JSON "
@@ -407,10 +408,10 @@ class ArgumentInputSchema(_NameDescriptionSchema):
407
408
  try:
408
409
  json_list = json.loads(default)
409
410
  if not isinstance(json_list, list):
410
- raise ValueError(f"Not a list: {json_list}")
411
+ raise TypeError(f"Not a list: {json_list}")
411
412
  for value in json_list:
412
413
  float(value)
413
- except ValueError as exc:
414
+ except (ValueError, TypeError) as exc:
414
415
  raise ValidationError(
415
416
  f"Default value '{default}' is not a valid JSON array of "
416
417
  f"floats, while the argument type {type_} requires a JSON array"
@@ -436,9 +437,12 @@ class ArgumentInputSchema(_NameDescriptionSchema):
436
437
 
437
438
  # if there are both allowed values and a default value, validate that the
438
439
  # default value is one of the allowed values
439
- if data.get("default_value") and data.get("allowed_values"):
440
- if data.get("default_value") not in data.get("allowed_values"):
441
- raise ValidationError("Default value is not one of the allowed values")
440
+ if (
441
+ data.get("default_value")
442
+ and data.get("allowed_values")
443
+ and data.get("default_value") not in data.get("allowed_values")
444
+ ):
445
+ raise ValidationError("Default value is not one of the allowed values")
442
446
 
443
447
 
444
448
  class UIVisualizationInputSchema(_NameDescriptionSchema):
@@ -27,15 +27,15 @@ class HATEOASModelSchema(BaseHATEOASModelSchema):
27
27
  def __init__(self, *args, **kwargs) -> None:
28
28
  # set lambda functions to create links for one to one relationship
29
29
  # TODO check if all below are used
30
- setattr(self, "algorithm", lambda obj: self.create_hateoas("algorithm", obj))
31
- setattr(self, "function", lambda obj: self.create_hateoas("function", obj))
32
- setattr(self, "database", lambda obj: self.create_hateoas("database", obj))
33
- setattr(self, "argument", lambda obj: self.create_hateoas("argument", obj))
34
- setattr(self, "rule", lambda obj: self.create_hateoas("rule", obj))
35
- setattr(self, "role", lambda obj: self.create_hateoas("role", obj))
36
- setattr(self, "user", lambda obj: self.create_hateoas("user", obj))
37
- setattr(self, "review", lambda obj: self.create_hateoas("review", obj))
38
- setattr(self, "hq", lambda obj: self.create_hateoas("hq", obj))
30
+ self.algorithm = lambda obj: self.create_hateoas("algorithm", obj)
31
+ self.function = lambda obj: self.create_hateoas("function", obj)
32
+ self.database = lambda obj: self.create_hateoas("database", obj)
33
+ self.argument = lambda obj: self.create_hateoas("argument", obj)
34
+ self.rule = lambda obj: self.create_hateoas("rule", obj)
35
+ self.role = lambda obj: self.create_hateoas("role", obj)
36
+ self.user = lambda obj: self.create_hateoas("user", obj)
37
+ self.review = lambda obj: self.create_hateoas("review", obj)
38
+ self.hq = lambda obj: self.create_hateoas("hq", obj)
39
39
 
40
40
  # call super class. Do this after setting the attributes above, because
41
41
  # the super class initializer will call the attributes.
@@ -1,4 +1,3 @@
1
- # -*- coding: utf-8 -*-
2
1
  import logging
3
2
  from http import HTTPStatus
4
3
 
@@ -49,7 +48,7 @@ def setup(api: Api, api_base: str, services: dict) -> None:
49
48
  Dictionary with services required for the resource endpoints
50
49
  """
51
50
 
52
- path = "/".join([api_base, module_name])
51
+ path = f"{api_base}/{module_name}"
53
52
  log.info(f'Setting up "{path}" and subdirectories')
54
53
 
55
54
  api.add_resource(
@@ -31,7 +31,7 @@ def setup(api: Api, api_base: str, services: dict) -> None:
31
31
  services : dict
32
32
  Dictionary with services required for the resource endpoints
33
33
  """
34
- path = "/".join([api_base, module_name])
34
+ path = f"{api_base}/{module_name}"
35
35
  log.info('Setting up "%s" and subdirectories', path)
36
36
 
37
37
  api.add_resource(
@@ -11,7 +11,7 @@ from vantage6.algorithm.store import run_store
11
11
 
12
12
  if len(sys.argv) < 2:
13
13
  error("No config file provided from WSGI! Exiting...")
14
- exit(1)
14
+ sys.exit(1)
15
15
  config_file = sys.argv[1]
16
16
 
17
17
  store_app = run_store(config_file, system_folders=False)