infrahub-server 1.4.0b1__py3-none-any.whl → 1.4.1__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.
Files changed (60) hide show
  1. infrahub/api/schema.py +3 -7
  2. infrahub/cli/db.py +25 -0
  3. infrahub/cli/db_commands/__init__.py +0 -0
  4. infrahub/cli/db_commands/check_inheritance.py +284 -0
  5. infrahub/cli/upgrade.py +3 -0
  6. infrahub/config.py +4 -4
  7. infrahub/core/attribute.py +6 -0
  8. infrahub/core/constants/__init__.py +1 -0
  9. infrahub/core/diff/model/path.py +0 -39
  10. infrahub/core/graph/__init__.py +1 -1
  11. infrahub/core/initialization.py +26 -21
  12. infrahub/core/manager.py +2 -2
  13. infrahub/core/migrations/__init__.py +2 -0
  14. infrahub/core/migrations/graph/__init__.py +4 -2
  15. infrahub/core/migrations/graph/m033_deduplicate_relationship_vertices.py +1 -1
  16. infrahub/core/migrations/graph/m035_orphan_relationships.py +43 -0
  17. infrahub/core/migrations/graph/{m035_drop_attr_value_index.py → m036_drop_attr_value_index.py} +3 -3
  18. infrahub/core/migrations/graph/{m036_index_attr_vals.py → m037_index_attr_vals.py} +3 -3
  19. infrahub/core/migrations/query/node_duplicate.py +26 -3
  20. infrahub/core/migrations/schema/attribute_kind_update.py +156 -0
  21. infrahub/core/models.py +5 -1
  22. infrahub/core/node/resource_manager/ip_address_pool.py +50 -48
  23. infrahub/core/node/resource_manager/ip_prefix_pool.py +55 -53
  24. infrahub/core/node/resource_manager/number_pool.py +20 -18
  25. infrahub/core/query/branch.py +37 -20
  26. infrahub/core/query/node.py +15 -0
  27. infrahub/core/relationship/model.py +13 -13
  28. infrahub/core/schema/definitions/internal.py +1 -1
  29. infrahub/core/schema/generated/attribute_schema.py +1 -1
  30. infrahub/core/schema/node_schema.py +0 -5
  31. infrahub/core/schema/schema_branch.py +2 -2
  32. infrahub/core/validators/attribute/choices.py +28 -3
  33. infrahub/core/validators/attribute/kind.py +5 -1
  34. infrahub/core/validators/determiner.py +22 -2
  35. infrahub/events/__init__.py +2 -0
  36. infrahub/events/proposed_change_action.py +22 -0
  37. infrahub/graphql/app.py +2 -1
  38. infrahub/graphql/context.py +1 -1
  39. infrahub/graphql/mutations/proposed_change.py +5 -0
  40. infrahub/graphql/mutations/relationship.py +1 -1
  41. infrahub/graphql/mutations/schema.py +14 -1
  42. infrahub/graphql/queries/diff/tree.py +53 -2
  43. infrahub/graphql/schema.py +3 -14
  44. infrahub/graphql/types/event.py +8 -0
  45. infrahub/permissions/__init__.py +3 -0
  46. infrahub/permissions/constants.py +13 -0
  47. infrahub/permissions/globals.py +32 -0
  48. infrahub/task_manager/event.py +5 -1
  49. infrahub_sdk/client.py +8 -8
  50. infrahub_sdk/node/node.py +2 -2
  51. infrahub_sdk/protocols.py +6 -40
  52. infrahub_sdk/pytest_plugin/items/graphql_query.py +1 -1
  53. infrahub_sdk/schema/repository.py +1 -1
  54. infrahub_sdk/testing/docker.py +1 -1
  55. infrahub_sdk/utils.py +11 -7
  56. {infrahub_server-1.4.0b1.dist-info → infrahub_server-1.4.1.dist-info}/METADATA +4 -4
  57. {infrahub_server-1.4.0b1.dist-info → infrahub_server-1.4.1.dist-info}/RECORD +60 -56
  58. {infrahub_server-1.4.0b1.dist-info → infrahub_server-1.4.1.dist-info}/LICENSE.txt +0 -0
  59. {infrahub_server-1.4.0b1.dist-info → infrahub_server-1.4.1.dist-info}/WHEEL +0 -0
  60. {infrahub_server-1.4.0b1.dist-info → infrahub_server-1.4.1.dist-info}/entry_points.txt +0 -0
@@ -161,7 +161,22 @@ class NodeCreateAllQuery(NodeQuery):
161
161
  relationships: list[RelationshipCreateData] = []
162
162
  for rel_name in self.node._relationships:
163
163
  rel_manager: RelationshipManager = getattr(self.node, rel_name)
164
+ if rel_manager.schema.cardinality == "many":
165
+ # Fetch all relationship peers through a single database call for performances.
166
+ peers = await rel_manager.get_peers(db=db, branch_agnostic=self.branch_agnostic)
167
+
164
168
  for rel in rel_manager._relationships:
169
+ if rel_manager.schema.cardinality == "many":
170
+ try:
171
+ rel.set_peer(value=peers[rel.get_peer_id()])
172
+ except KeyError:
173
+ pass
174
+ except ValueError:
175
+ # Relationship has not been initialized yet, it means the peer does not exist in db yet
176
+ # typically because it will be allocated from a ressource pool. In that case, the peer
177
+ # will be fetched using `rel.resolve` later.
178
+ pass
179
+
165
180
  rel_create_data = await rel.get_create_data(db=db, at=at)
166
181
  if rel_create_data.peer_branch_level > deepest_branch_level or (
167
182
  deepest_branch_name == GLOBAL_BRANCH_NAME and rel_create_data.peer_branch == registry.default_branch
@@ -166,11 +166,11 @@ class Relationship(FlagPropertyMixin, NodePropertyMixin):
166
166
  return registry.get_global_branch()
167
167
  return self.branch
168
168
 
169
- async def _process_data(self, data: dict | RelationshipPeerData | str) -> None:
169
+ def _process_data(self, data: dict | RelationshipPeerData | str) -> None:
170
170
  self.data = data
171
171
 
172
172
  if isinstance(data, RelationshipPeerData):
173
- await self.set_peer(value=str(data.peer_id))
173
+ self.set_peer(value=str(data.peer_id))
174
174
 
175
175
  if not self.id and data.rel_node_id:
176
176
  self.id = data.rel_node_id
@@ -187,7 +187,7 @@ class Relationship(FlagPropertyMixin, NodePropertyMixin):
187
187
  elif isinstance(data, dict):
188
188
  for key, value in data.items():
189
189
  if key in ["peer", "id"]:
190
- await self.set_peer(value=data.get(key, None))
190
+ self.set_peer(value=data.get(key, None))
191
191
  elif key == "hfid" and self.peer_id is None:
192
192
  self.peer_hfid = value
193
193
  elif key.startswith(PREFIX_PROPERTY) and key.replace(PREFIX_PROPERTY, "") in self._flag_properties:
@@ -198,7 +198,7 @@ class Relationship(FlagPropertyMixin, NodePropertyMixin):
198
198
  self.from_pool = value
199
199
 
200
200
  else:
201
- await self.set_peer(value=data)
201
+ self.set_peer(value=data)
202
202
 
203
203
  async def new(
204
204
  self,
@@ -206,11 +206,11 @@ class Relationship(FlagPropertyMixin, NodePropertyMixin):
206
206
  data: dict | RelationshipPeerData | Any = None,
207
207
  **kwargs: Any, # noqa: ARG002
208
208
  ) -> Relationship:
209
- await self._process_data(data=data)
209
+ self._process_data(data=data)
210
210
 
211
211
  return self
212
212
 
213
- async def load(
213
+ def load(
214
214
  self,
215
215
  db: InfrahubDatabase, # noqa: ARG002
216
216
  id: UUID | None = None,
@@ -223,7 +223,7 @@ class Relationship(FlagPropertyMixin, NodePropertyMixin):
223
223
  self.id = id or self.id
224
224
  self.db_id = db_id or self.db_id
225
225
 
226
- await self._process_data(data=data)
226
+ self._process_data(data=data)
227
227
 
228
228
  if updated_at and hash(self) != hash_before:
229
229
  self.updated_at = Timestamp(updated_at)
@@ -252,7 +252,7 @@ class Relationship(FlagPropertyMixin, NodePropertyMixin):
252
252
  self._node_id = self._node.id
253
253
  return node
254
254
 
255
- async def set_peer(self, value: str | Node) -> None:
255
+ def set_peer(self, value: str | Node) -> None:
256
256
  if isinstance(value, str):
257
257
  self.peer_id = value
258
258
  else:
@@ -433,7 +433,7 @@ class Relationship(FlagPropertyMixin, NodePropertyMixin):
433
433
  db=db, id=self.peer_id, branch=self.branch, kind=self.schema.peer, fields={"display_label": None}
434
434
  )
435
435
  if peer:
436
- await self.set_peer(value=peer)
436
+ self.set_peer(value=peer)
437
437
 
438
438
  if not self.peer_id and self.peer_hfid:
439
439
  peer_schema = db.schema.get(name=self.schema.peer, branch=self.branch)
@@ -450,7 +450,7 @@ class Relationship(FlagPropertyMixin, NodePropertyMixin):
450
450
  fields={"display_label": None},
451
451
  raise_on_error=True,
452
452
  )
453
- await self.set_peer(value=peer)
453
+ self.set_peer(value=peer)
454
454
 
455
455
  if not self.peer_id and self.from_pool and "id" in self.from_pool:
456
456
  pool_id = str(self.from_pool.get("id"))
@@ -473,7 +473,7 @@ class Relationship(FlagPropertyMixin, NodePropertyMixin):
473
473
  data_from_pool["identifier"] = f"hfid={hfid_str} rel={self.name}"
474
474
 
475
475
  assigned_peer: Node = await pool.get_resource(db=db, branch=self.branch, at=at, **data_from_pool) # type: ignore[attr-defined]
476
- await self.set_peer(value=assigned_peer)
476
+ self.set_peer(value=assigned_peer)
477
477
  self.set_source(value=pool.id)
478
478
 
479
479
  async def save(self, db: InfrahubDatabase, at: Timestamp | None = None) -> Self:
@@ -962,7 +962,7 @@ class RelationshipManager:
962
962
 
963
963
  for peer_id in details.peer_ids_present_database_only:
964
964
  self._relationships.append(
965
- await Relationship(
965
+ Relationship(
966
966
  schema=self.schema,
967
967
  branch=self.branch,
968
968
  at=at or self.at,
@@ -1050,7 +1050,7 @@ class RelationshipManager:
1050
1050
  if isinstance(item, dict) and item.get("id", None) in previous_relationships:
1051
1051
  rel = previous_relationships[item["id"]]
1052
1052
  hash_before = hash(rel)
1053
- await rel.load(data=item, db=db)
1053
+ rel.load(data=item, db=db)
1054
1054
  if hash(rel) != hash_before:
1055
1055
  changed = True
1056
1056
  self._relationships.append(rel)
@@ -487,7 +487,7 @@ attribute_schema = SchemaNode(
487
487
  kind="Text",
488
488
  description="Defines the type of the attribute.",
489
489
  enum=ATTRIBUTE_KIND_LABELS,
490
- extra={"update": UpdateSupport.VALIDATE_CONSTRAINT},
490
+ extra={"update": UpdateSupport.MIGRATION_REQUIRED},
491
491
  ),
492
492
  SchemaAttribute(
493
493
  name="enum",
@@ -31,7 +31,7 @@ class GeneratedAttributeSchema(HashableModel):
31
31
  json_schema_extra={"update": "migration_required"},
32
32
  )
33
33
  kind: str = Field(
34
- ..., description="Defines the type of the attribute.", json_schema_extra={"update": "validate_constraint"}
34
+ ..., description="Defines the type of the attribute.", json_schema_extra={"update": "migration_required"}
35
35
  )
36
36
  enum: list | None = Field(
37
37
  default=None,
@@ -63,11 +63,6 @@ class NodeSchema(GeneratedNodeSchema):
63
63
  f"{self.kind}.{attribute.name} inherited from {interface.namespace}{interface.name} must be the same kind "
64
64
  f'["{interface_attr.kind}", "{attribute.kind}"]'
65
65
  )
66
- if attribute.optional != interface_attr.optional:
67
- raise ValueError(
68
- f"{self.kind}.{attribute.name} inherited from {interface.namespace}{interface.name} must have the same value for property "
69
- f'"optional" ["{interface_attr.optional}", "{attribute.optional}"]'
70
- )
71
66
 
72
67
  for relationship in self.relationships:
73
68
  if relationship.name in interface.relationship_names and not relationship.inherited:
@@ -226,8 +226,8 @@ class SchemaBranch:
226
226
  def update(self, schema: SchemaBranch) -> None:
227
227
  """Update another SchemaBranch into this one."""
228
228
 
229
- local_kinds = list(self.nodes.keys()) + list(self.generics.keys())
230
- other_kinds = list(schema.nodes.keys()) + list(schema.generics.keys())
229
+ local_kinds = self.all_names
230
+ other_kinds = schema.all_names
231
231
 
232
232
  in_both, _, other_only = compare_lists(list1=local_kinds, list2=other_kinds)
233
233
 
@@ -1,9 +1,10 @@
1
1
  from __future__ import annotations
2
2
 
3
- from typing import TYPE_CHECKING, Any
3
+ from typing import TYPE_CHECKING, Any, cast
4
4
 
5
5
  from infrahub.core.constants import NULL_VALUE, PathType
6
6
  from infrahub.core.path import DataPath, GroupedDataPaths
7
+ from infrahub.core.schema.generic_schema import GenericSchema
7
8
 
8
9
  from ..interface import ConstraintCheckerInterface
9
10
  from ..shared import AttributeSchemaValidatorQuery
@@ -18,6 +19,14 @@ if TYPE_CHECKING:
18
19
  class AttributeChoicesUpdateValidatorQuery(AttributeSchemaValidatorQuery):
19
20
  name: str = "attribute_constraints_choices_validator"
20
21
 
22
+ def __init__(
23
+ self,
24
+ excluded_kinds: list[str] | None = None,
25
+ **kwargs: Any,
26
+ ) -> None:
27
+ self.excluded_kinds: list[str] = excluded_kinds or []
28
+ super().__init__(**kwargs)
29
+
21
30
  async def query_init(self, db: InfrahubDatabase, **kwargs: dict[str, Any]) -> None: # noqa: ARG002
22
31
  if self.attribute_schema.choices is None:
23
32
  return
@@ -28,9 +37,11 @@ class AttributeChoicesUpdateValidatorQuery(AttributeSchemaValidatorQuery):
28
37
  self.params["attr_name"] = self.attribute_schema.name
29
38
  self.params["allowed_values"] = [choice.name for choice in self.attribute_schema.choices]
30
39
  self.params["null_value"] = NULL_VALUE
40
+ self.params["excluded_kinds"] = self.excluded_kinds
31
41
 
32
42
  query = """
33
- MATCH p = (n:%(node_kind)s)
43
+ MATCH (n:%(node_kind)s)
44
+ WHERE size($excluded_kinds) = 0 OR NOT n.kind IN $excluded_kinds
34
45
  CALL (n) {
35
46
  MATCH path = (root:Root)<-[rr:IS_PART_OF]-(n)-[ra:HAS_ATTRIBUTE]-(:Attribute { name: $attr_name } )-[rv:HAS_VALUE]-(av:AttributeValue)
36
47
  WHERE all(
@@ -92,10 +103,24 @@ class AttributeChoicesChecker(ConstraintCheckerInterface):
92
103
  if attribute_schema.choices is None:
93
104
  return grouped_data_paths_list
94
105
 
106
+ # skip inheriting schemas that override the attribute being checked
107
+ excluded_kinds: list[str] = []
108
+ if request.node_schema.is_generic_schema:
109
+ request.node_schema = cast(GenericSchema, request.node_schema)
110
+ for inheriting_kind in request.node_schema.used_by:
111
+ inheriting_schema = request.schema_branch.get_node(name=inheriting_kind, duplicate=False)
112
+ inheriting_schema_attribute = inheriting_schema.get_attribute(name=request.schema_path.field_name)
113
+ if not inheriting_schema_attribute.inherited:
114
+ excluded_kinds.append(inheriting_kind)
115
+
95
116
  for query_class in self.query_classes:
96
117
  # TODO add exception handling
97
118
  query = await query_class.init(
98
- db=self.db, branch=self.branch, node_schema=request.node_schema, schema_path=request.schema_path
119
+ db=self.db,
120
+ branch=self.branch,
121
+ node_schema=request.node_schema,
122
+ schema_path=request.schema_path,
123
+ excluded_kinds=excluded_kinds,
99
124
  )
100
125
  await query.execute(db=self.db)
101
126
  grouped_data_paths_list.append(await query.get_paths())
@@ -65,8 +65,12 @@ class AttributeKindUpdateValidatorQuery(AttributeSchemaValidatorQuery):
65
65
  if value in (None, NULL_VALUE):
66
66
  continue
67
67
  try:
68
+ attr_value = result.get("attribute_value")
68
69
  infrahub_attribute_class.validate_format(
69
- value=result.get("attribute_value"), name=self.attribute_schema.name, schema=self.attribute_schema
70
+ value=attr_value, name=self.attribute_schema.name, schema=self.attribute_schema
71
+ )
72
+ infrahub_attribute_class.validate_content(
73
+ value=attr_value, name=self.attribute_schema.name, schema=self.attribute_schema
70
74
  )
71
75
  except ValidationError:
72
76
  grouped_data_paths.add_data_path(
@@ -98,7 +98,10 @@ class ConstraintValidatorDeterminer:
98
98
  continue
99
99
 
100
100
  prop_field_update = prop_field_info.json_schema_extra.get("update")
101
- if prop_field_update != UpdateSupport.VALIDATE_CONSTRAINT.value:
101
+ if prop_field_update not in (
102
+ UpdateSupport.VALIDATE_CONSTRAINT.value,
103
+ UpdateSupport.MIGRATION_REQUIRED.value,
104
+ ):
102
105
  continue
103
106
 
104
107
  if getattr(schema, prop_name) is None:
@@ -112,6 +115,13 @@ class ConstraintValidatorDeterminer:
112
115
  )
113
116
  constraint_name = f"node.{prop_name}.update"
114
117
 
118
+ do_constraint_validation = prop_field_update == UpdateSupport.VALIDATE_CONSTRAINT.value or (
119
+ prop_field_update == UpdateSupport.MIGRATION_REQUIRED.value
120
+ and CONSTRAINT_VALIDATOR_MAP.get(constraint_name)
121
+ )
122
+ if not do_constraint_validation:
123
+ continue
124
+
115
125
  constraints.append(SchemaUpdateConstraintInfo(constraint_name=constraint_name, path=schema_path))
116
126
  return constraints
117
127
 
@@ -154,7 +164,10 @@ class ConstraintValidatorDeterminer:
154
164
  continue
155
165
 
156
166
  prop_field_update = prop_field_info.json_schema_extra.get("update")
157
- if prop_field_update != UpdateSupport.VALIDATE_CONSTRAINT.value:
167
+ if prop_field_update not in (
168
+ UpdateSupport.VALIDATE_CONSTRAINT.value,
169
+ UpdateSupport.MIGRATION_REQUIRED.value,
170
+ ):
158
171
  continue
159
172
 
160
173
  if prop_value is None:
@@ -168,6 +181,13 @@ class ConstraintValidatorDeterminer:
168
181
  path_type = SchemaPathType.RELATIONSHIP
169
182
  constraint_name = f"relationship.{prop_name}.update"
170
183
 
184
+ do_constraint_validation = prop_field_update == UpdateSupport.VALIDATE_CONSTRAINT.value or (
185
+ prop_field_update == UpdateSupport.MIGRATION_REQUIRED.value
186
+ and CONSTRAINT_VALIDATOR_MAP.get(constraint_name)
187
+ )
188
+ if not do_constraint_validation:
189
+ continue
190
+
171
191
  schema_path = SchemaPath(
172
192
  schema_kind=schema.kind,
173
193
  path_type=path_type,
@@ -5,6 +5,7 @@ from .models import EventMeta, InfrahubEvent
5
5
  from .node_action import NodeCreatedEvent, NodeDeletedEvent, NodeUpdatedEvent
6
6
  from .proposed_change_action import (
7
7
  ProposedChangeApprovalRevokedEvent,
8
+ ProposedChangeApprovalsRevokedEvent,
8
9
  ProposedChangeApprovedEvent,
9
10
  ProposedChangeMergedEvent,
10
11
  ProposedChangeRejectedEvent,
@@ -32,6 +33,7 @@ __all__ = [
32
33
  "NodeDeletedEvent",
33
34
  "NodeUpdatedEvent",
34
35
  "ProposedChangeApprovalRevokedEvent",
36
+ "ProposedChangeApprovalsRevokedEvent",
35
37
  "ProposedChangeApprovedEvent",
36
38
  "ProposedChangeMergedEvent",
37
39
  "ProposedChangeRejectedEvent",
@@ -150,6 +150,28 @@ class ProposedChangeRejectionRevokedEvent(ProposedChangeReviewRevokedEvent):
150
150
  event_name: ClassVar[str] = f"{EVENT_NAMESPACE}.proposed_change.rejection_revoked"
151
151
 
152
152
 
153
+ class ProposedChangeApprovalsRevokedEvent(ProposedChangeEvent):
154
+ reviewer_accounts: dict[str, str] = Field(
155
+ default_factory=dict, description="ID to name map of accounts whose approval was revoked"
156
+ )
157
+
158
+ event_name: ClassVar[str] = f"{EVENT_NAMESPACE}.proposed_change.approvals_revoked"
159
+
160
+ def get_related(self) -> list[dict[str, str]]:
161
+ related = super().get_related()
162
+ for account_id, account_name in self.reviewer_accounts.items():
163
+ related.append(
164
+ {
165
+ "prefect.resource.id": account_id,
166
+ "prefect.resource.role": "infrahub.related.node",
167
+ "infrahub.node.kind": InfrahubKind.GENERICACCOUNT,
168
+ "infrahub.node.id": account_id,
169
+ "infrahub.reviewer.account.name": account_name,
170
+ }
171
+ )
172
+ return related
173
+
174
+
153
175
  class ProposedChangeThreadEvent(ProposedChangeEvent):
154
176
  thread_id: str = Field(..., description="The ID of the thread that was created or updated")
155
177
  thread_kind: str = Field(..., description="The name of the thread that was created or updated")
infrahub/graphql/app.py CHANGED
@@ -231,6 +231,7 @@ class InfrahubGraphQLApp:
231
231
  operation_name=operation_name,
232
232
  branch=branch,
233
233
  )
234
+ impacted_models = analyzed_query.query_report.impacted_models
234
235
 
235
236
  await self._evaluate_permissions(
236
237
  db=db,
@@ -282,7 +283,7 @@ class InfrahubGraphQLApp:
282
283
  GRAPHQL_QUERY_HEIGHT_METRICS.labels(**labels).observe(await analyzed_query.calculate_height())
283
284
  # GRAPHQL_QUERY_VARS_METRICS.labels(**labels).observe(len(analyzed_query.variables))
284
285
  GRAPHQL_TOP_LEVEL_QUERIES_METRICS.labels(**labels).observe(analyzed_query.nbr_queries)
285
- GRAPHQL_QUERY_OBJECTS_METRICS.labels(**labels).observe(len(analyzed_query.query_report.impacted_models))
286
+ GRAPHQL_QUERY_OBJECTS_METRICS.labels(**labels).observe(len(impacted_models))
286
287
 
287
288
  _, errors = analyzed_query.is_valid
288
289
  if errors:
@@ -5,7 +5,7 @@ from typing import TYPE_CHECKING
5
5
  from infrahub.core.constants import GlobalPermissions, InfrahubKind
6
6
  from infrahub.core.manager import NodeManager
7
7
  from infrahub.exceptions import NodeNotFoundError, ValidationError
8
- from infrahub.permissions.globals import define_global_permission_from_branch
8
+ from infrahub.permissions import define_global_permission_from_branch
9
9
 
10
10
  if TYPE_CHECKING:
11
11
  from .initialization import GraphqlContext
@@ -137,6 +137,9 @@ class InfrahubProposedChangeMutation(InfrahubMutationMixin, Mutation):
137
137
  updated_state = ProposedChangeState(state_update)
138
138
  state.validate_state_transition(updated_state)
139
139
 
140
+ # Check if the draft state will change (defaults to current draft state)
141
+ will_be_draft = data.get("is_draft", {}).get("value", obj.is_draft.value)
142
+
140
143
  # Check before starting a transaction, stopping in the middle of the transaction seems to break with memgraph
141
144
  if updated_state == ProposedChangeState.MERGED and graphql_context.account_session:
142
145
  try:
@@ -150,6 +153,8 @@ class InfrahubProposedChangeMutation(InfrahubMutationMixin, Mutation):
150
153
  raise ValidationError(str(exc)) from exc
151
154
 
152
155
  if updated_state == ProposedChangeState.MERGED:
156
+ if will_be_draft:
157
+ raise ValidationError("A draft proposed change is not allowed to be merged")
153
158
  data["state"]["value"] = ProposedChangeState.MERGING.value
154
159
 
155
160
  proposed_change, result = await super().mutate_update(
@@ -233,7 +233,7 @@ class RelationshipRemove(Mutation):
233
233
  # we should use RelationshipDataDeleteQuery to delete the relationship
234
234
  # it would be more query efficient
235
235
  rel = Relationship(schema=rel_schema, branch=graphql_context.branch, node=source)
236
- await rel.load(db=db, data=existing_peers[node_data.get("id")])
236
+ rel.load(db=db, data=existing_peers[node_data.get("id")])
237
237
  if group_event_type != GroupUpdateType.NONE:
238
238
  peers.append(EventNode(id=rel.get_peer_id(), kind=nodes[rel.get_peer_id()].get_kind()))
239
239
  node_changelog.delete_relationship(relationship=rel)
@@ -6,7 +6,7 @@ from graphene import Boolean, Field, InputObjectType, Mutation, String
6
6
 
7
7
  from infrahub import lock
8
8
  from infrahub.core import registry
9
- from infrahub.core.constants import RESTRICTED_NAMESPACES
9
+ from infrahub.core.constants import RESTRICTED_NAMESPACES, GlobalPermissions
10
10
  from infrahub.core.manager import NodeManager
11
11
  from infrahub.core.schema import DropdownChoice, GenericSchema, NodeSchema
12
12
  from infrahub.database import InfrahubDatabase, retry_db_transaction
@@ -16,6 +16,7 @@ from infrahub.exceptions import ValidationError
16
16
  from infrahub.graphql.context import apply_external_context
17
17
  from infrahub.graphql.types.context import ContextInput
18
18
  from infrahub.log import get_log_data, get_logger
19
+ from infrahub.permissions import define_global_permission_from_branch
19
20
  from infrahub.worker import WORKER_IDENTITY
20
21
 
21
22
  from ..types import DropdownFields
@@ -32,6 +33,14 @@ if TYPE_CHECKING:
32
33
  log = get_logger()
33
34
 
34
35
 
36
+ def _validate_schema_permission(graphql_context: GraphqlContext) -> None:
37
+ graphql_context.active_permissions.raise_for_permission(
38
+ permission=define_global_permission_from_branch(
39
+ permission=GlobalPermissions.MANAGE_SCHEMA, branch_name=graphql_context.branch.name
40
+ )
41
+ )
42
+
43
+
35
44
  class SchemaEnumInput(InputObjectType):
36
45
  kind = String(required=True)
37
46
  attribute = String(required=True)
@@ -69,6 +78,7 @@ class SchemaDropdownAdd(Mutation):
69
78
  ) -> Self:
70
79
  graphql_context: GraphqlContext = info.context
71
80
 
81
+ _validate_schema_permission(graphql_context=graphql_context)
72
82
  await apply_external_context(graphql_context=graphql_context, context_input=context)
73
83
 
74
84
  kind = graphql_context.db.schema.get(name=str(data.kind), branch=graphql_context.branch.name)
@@ -130,6 +140,7 @@ class SchemaDropdownRemove(Mutation):
130
140
  ) -> dict[str, bool]:
131
141
  graphql_context: GraphqlContext = info.context
132
142
 
143
+ _validate_schema_permission(graphql_context=graphql_context)
133
144
  kind = graphql_context.db.schema.get(name=str(data.kind), branch=graphql_context.branch.name)
134
145
  await apply_external_context(graphql_context=graphql_context, context_input=context)
135
146
 
@@ -185,6 +196,7 @@ class SchemaEnumAdd(Mutation):
185
196
  ) -> dict[str, bool]:
186
197
  graphql_context: GraphqlContext = info.context
187
198
 
199
+ _validate_schema_permission(graphql_context=graphql_context)
188
200
  kind = graphql_context.db.schema.get(name=str(data.kind), branch=graphql_context.branch.name)
189
201
  await apply_external_context(graphql_context=graphql_context, context_input=context)
190
202
 
@@ -230,6 +242,7 @@ class SchemaEnumRemove(Mutation):
230
242
  ) -> dict[str, bool]:
231
243
  graphql_context: GraphqlContext = info.context
232
244
 
245
+ _validate_schema_permission(graphql_context=graphql_context)
233
246
  kind = graphql_context.db.schema.get(name=str(data.kind), branch=graphql_context.branch.name)
234
247
  await apply_external_context(graphql_context=graphql_context, context_input=context)
235
248
 
@@ -1,12 +1,13 @@
1
1
  from __future__ import annotations
2
2
 
3
+ from dataclasses import dataclass
3
4
  from typing import TYPE_CHECKING, Any
4
5
 
5
6
  from graphene import Argument, Boolean, DateTime, Field, InputObjectType, Int, List, NonNull, ObjectType, String
6
7
  from graphene import Enum as GrapheneEnum
7
8
 
8
9
  from infrahub.core import registry
9
- from infrahub.core.constants import DiffAction, RelationshipCardinality
10
+ from infrahub.core.constants import DiffAction, RelationshipCardinality, RelationshipDirection
10
11
  from infrahub.core.constants.database import DatabaseEdgeType
11
12
  from infrahub.core.diff.model.path import NameTrackingId
12
13
  from infrahub.core.diff.query.filters import EnrichedDiffQueryFilters
@@ -38,6 +39,12 @@ GrapheneDiffActionEnum = GrapheneEnum.from_enum(DiffAction)
38
39
  GrapheneCardinalityEnum = GrapheneEnum.from_enum(RelationshipCardinality)
39
40
 
40
41
 
42
+ @dataclass
43
+ class ParentNodeInfo:
44
+ node: EnrichedDiffNode
45
+ relationship_name: str
46
+
47
+
41
48
  class ConflictDetails(ObjectType):
42
49
  uuid = String(required=True)
43
50
  base_branch_action = Field(GrapheneDiffActionEnum, required=True)
@@ -145,9 +152,16 @@ class DiffTreeSummary(DiffSummaryCounts):
145
152
 
146
153
 
147
154
  class DiffTreeResolver:
155
+ def __init__(self) -> None:
156
+ self.source_branch_name: str | None = None
157
+
158
+ def initialize(self, enriched_diff_root: EnrichedDiffRoot) -> None:
159
+ self.source_branch_name = enriched_diff_root.diff_branch_name
160
+
148
161
  async def to_diff_tree(
149
162
  self, enriched_diff_root: EnrichedDiffRoot, graphql_context: GraphqlContext | None = None
150
163
  ) -> DiffTree:
164
+ self.initialize(enriched_diff_root=enriched_diff_root)
151
165
  all_nodes = list(enriched_diff_root.nodes)
152
166
  tree_nodes = [self.to_diff_node(enriched_node=e_node, graphql_context=graphql_context) for e_node in all_nodes]
153
167
  name = None
@@ -166,6 +180,43 @@ class DiffTreeResolver:
166
180
  num_conflicts=enriched_diff_root.num_conflicts,
167
181
  )
168
182
 
183
+ def _get_parent_info(
184
+ self, diff_node: EnrichedDiffNode, graphql_context: GraphqlContext | None = None
185
+ ) -> ParentNodeInfo | None:
186
+ for r in diff_node.relationships:
187
+ for n in r.nodes:
188
+ relationship_name: str = "undefined"
189
+
190
+ if not graphql_context or not self.source_branch_name:
191
+ return ParentNodeInfo(node=n, relationship_name=relationship_name)
192
+
193
+ node_schema = graphql_context.db.schema.get(
194
+ name=diff_node.kind, branch=self.source_branch_name, duplicate=False
195
+ )
196
+ rel_schema = node_schema.get_relationship(name=r.name)
197
+
198
+ parent_schema = graphql_context.db.schema.get(
199
+ name=n.kind, branch=self.source_branch_name, duplicate=False
200
+ )
201
+ rels_parent = parent_schema.get_relationships_by_identifier(id=rel_schema.get_identifier())
202
+
203
+ if rels_parent and len(rels_parent) == 1:
204
+ relationship_name = rels_parent[0].name
205
+ elif rels_parent and len(rels_parent) > 1:
206
+ for rel_parent in rels_parent:
207
+ if (
208
+ rel_schema.direction == RelationshipDirection.INBOUND
209
+ and rel_parent.direction == RelationshipDirection.OUTBOUND
210
+ ) or (
211
+ rel_schema.direction == RelationshipDirection.OUTBOUND
212
+ and rel_parent.direction == RelationshipDirection.INBOUND
213
+ ):
214
+ relationship_name = rel_parent.name
215
+ break
216
+
217
+ return ParentNodeInfo(node=n, relationship_name=relationship_name)
218
+ return None
219
+
169
220
  def to_diff_node(self, enriched_node: EnrichedDiffNode, graphql_context: GraphqlContext | None = None) -> DiffNode:
170
221
  diff_attributes = [
171
222
  self.to_diff_attribute(enriched_attribute=e_attr, graphql_context=graphql_context)
@@ -181,7 +232,7 @@ class DiffTreeResolver:
181
232
  conflict = self.to_diff_conflict(enriched_conflict=enriched_node.conflict, graphql_context=graphql_context)
182
233
 
183
234
  parent = None
184
- if parent_info := enriched_node.get_parent_info(graphql_context=graphql_context):
235
+ if parent_info := self._get_parent_info(diff_node=enriched_node, graphql_context=graphql_context):
185
236
  parent = DiffNodeParent(
186
237
  uuid=parent_info.node.uuid,
187
238
  kind=parent_info.node.kind,
@@ -26,21 +26,10 @@ from .mutations.proposed_change import (
26
26
  ProposedChangeRequestRunCheck,
27
27
  ProposedChangeReview,
28
28
  )
29
- from .mutations.relationship import (
30
- RelationshipAdd,
31
- RelationshipRemove,
32
- )
33
- from .mutations.repository import (
34
- ProcessRepository,
35
- ValidateRepositoryConnectivity,
36
- )
29
+ from .mutations.relationship import RelationshipAdd, RelationshipRemove
30
+ from .mutations.repository import ProcessRepository, ValidateRepositoryConnectivity
37
31
  from .mutations.resource_manager import IPAddressPoolGetResource, IPPrefixPoolGetResource
38
- from .mutations.schema import (
39
- SchemaDropdownAdd,
40
- SchemaDropdownRemove,
41
- SchemaEnumAdd,
42
- SchemaEnumRemove,
43
- )
32
+ from .mutations.schema import SchemaDropdownAdd, SchemaDropdownRemove, SchemaEnumAdd, SchemaEnumRemove
44
33
  from .queries import (
45
34
  AccountPermissions,
46
35
  AccountToken,
@@ -136,6 +136,13 @@ class ProposedChangeReviewRevokedEvent(ObjectType):
136
136
  payload = Field(GenericScalar, required=True)
137
137
 
138
138
 
139
+ class ProposedChangeApprovalsRevokedEvent(ObjectType):
140
+ class Meta:
141
+ interfaces = (EventNodeInterface,)
142
+
143
+ payload = Field(GenericScalar, required=True)
144
+
145
+
139
146
  class ProposedChangeReviewRequestedEvent(ObjectType):
140
147
  class Meta:
141
148
  interfaces = (EventNodeInterface,)
@@ -220,6 +227,7 @@ EVENT_TYPES: dict[str, type[ObjectType]] = {
220
227
  events.ProposedChangeRejectedEvent.event_name: ProposedChangeReviewEvent,
221
228
  events.ProposedChangeRejectionRevokedEvent.event_name: ProposedChangeReviewRevokedEvent,
222
229
  events.ProposedChangeReviewRequestedEvent.event_name: ProposedChangeReviewRequestedEvent,
230
+ events.ProposedChangeApprovalsRevokedEvent.event_name: ProposedChangeApprovalsRevokedEvent,
223
231
  events.ProposedChangeMergedEvent.event_name: ProposedChangeMergedEvent,
224
232
  events.ProposedChangeThreadCreatedEvent.event_name: ProposedChangeThreadEvent,
225
233
  events.ProposedChangeThreadUpdatedEvent.event_name: ProposedChangeThreadEvent,
@@ -1,4 +1,5 @@
1
1
  from infrahub.permissions.backend import PermissionBackend
2
+ from infrahub.permissions.globals import define_global_permission_from_branch, get_or_create_global_permission
2
3
  from infrahub.permissions.local_backend import LocalPermissionBackend
3
4
  from infrahub.permissions.manager import PermissionManager
4
5
  from infrahub.permissions.report import report_schema_permissions
@@ -9,6 +10,8 @@ __all__ = [
9
10
  "LocalPermissionBackend",
10
11
  "PermissionBackend",
11
12
  "PermissionManager",
13
+ "define_global_permission_from_branch",
12
14
  "get_global_permission_for_kind",
15
+ "get_or_create_global_permission",
13
16
  "report_schema_permissions",
14
17
  ]
@@ -25,8 +25,21 @@ GLOBAL_PERMISSION_DENIAL_MESSAGE = {
25
25
  GlobalPermissions.EDIT_DEFAULT_BRANCH.value: "You are not allowed to change data in the default branch",
26
26
  GlobalPermissions.MERGE_BRANCH.value: "You are not allowed to merge a branch",
27
27
  GlobalPermissions.MERGE_PROPOSED_CHANGE.value: "You are not allowed to merge proposed changes",
28
+ GlobalPermissions.REVIEW_PROPOSED_CHANGE.value: "You are not allowed to review proposed changes",
28
29
  GlobalPermissions.MANAGE_SCHEMA.value: "You are not allowed to manage the schema",
29
30
  GlobalPermissions.MANAGE_ACCOUNTS.value: "You are not allowed to manage user accounts, groups or roles",
30
31
  GlobalPermissions.MANAGE_PERMISSIONS.value: "You are not allowed to manage permissions",
31
32
  GlobalPermissions.MANAGE_REPOSITORIES.value: "You are not allowed to manage repositories",
32
33
  }
34
+
35
+ GLOBAL_PERMISSION_DESCRIPTION = {
36
+ GlobalPermissions.EDIT_DEFAULT_BRANCH: "Allow a user to change data in the default branch",
37
+ GlobalPermissions.MERGE_BRANCH: "Allow a user to merge branches",
38
+ GlobalPermissions.MERGE_PROPOSED_CHANGE: "Allow a user to merge proposed changes",
39
+ GlobalPermissions.REVIEW_PROPOSED_CHANGE: "Allow a user to approve or reject proposed changes",
40
+ GlobalPermissions.MANAGE_SCHEMA: "Allow a user to manage the schema",
41
+ GlobalPermissions.MANAGE_ACCOUNTS: "Allow a user to manage accounts, account roles and account groups",
42
+ GlobalPermissions.MANAGE_PERMISSIONS: "Allow a user to manage permissions",
43
+ GlobalPermissions.MANAGE_REPOSITORIES: "Allow a user to manage repositories",
44
+ GlobalPermissions.SUPER_ADMIN: "Allow a user to do anything",
45
+ }