infrahub-server 1.6.0__py3-none-any.whl → 1.6.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 (41) hide show
  1. infrahub/api/oauth2.py +33 -6
  2. infrahub/api/oidc.py +36 -6
  3. infrahub/auth.py +11 -0
  4. infrahub/auth_pkce.py +41 -0
  5. infrahub/config.py +8 -2
  6. infrahub/core/branch/models.py +3 -2
  7. infrahub/core/changelog/models.py +2 -2
  8. infrahub/core/graph/__init__.py +1 -1
  9. infrahub/core/integrity/object_conflict/conflict_recorder.py +1 -1
  10. infrahub/core/migrations/graph/__init__.py +2 -0
  11. infrahub/core/migrations/graph/m047_backfill_or_null_display_label.py +606 -0
  12. infrahub/core/node/__init__.py +5 -8
  13. infrahub/core/node/proposed_change.py +5 -3
  14. infrahub/core/relationship/model.py +9 -3
  15. infrahub/core/schema/manager.py +8 -3
  16. infrahub/core/validators/attribute/choices.py +2 -2
  17. infrahub/git/models.py +13 -0
  18. infrahub/git/tasks.py +23 -19
  19. infrahub/git/utils.py +16 -9
  20. infrahub/graphql/app.py +6 -6
  21. infrahub/graphql/mutations/action.py +15 -7
  22. infrahub/graphql/mutations/hfid.py +1 -1
  23. infrahub/graphql/mutations/repository.py +3 -3
  24. infrahub/graphql/mutations/schema.py +4 -4
  25. infrahub/graphql/mutations/webhook.py +2 -2
  26. infrahub/proposed_change/branch_diff.py +1 -1
  27. infrahub/repositories/create_repository.py +3 -3
  28. infrahub/task_manager/models.py +1 -1
  29. infrahub/task_manager/task.py +3 -3
  30. infrahub/validators/tasks.py +1 -1
  31. infrahub_sdk/ctl/AGENTS.md +67 -0
  32. infrahub_sdk/ctl/repository.py +4 -46
  33. infrahub_sdk/node/constants.py +2 -0
  34. infrahub_sdk/node/node.py +303 -3
  35. infrahub_sdk/pytest_plugin/AGENTS.md +67 -0
  36. infrahub_sdk/timestamp.py +7 -7
  37. {infrahub_server-1.6.0.dist-info → infrahub_server-1.6.1.dist-info}/METADATA +2 -3
  38. {infrahub_server-1.6.0.dist-info → infrahub_server-1.6.1.dist-info}/RECORD +41 -37
  39. {infrahub_server-1.6.0.dist-info → infrahub_server-1.6.1.dist-info}/WHEEL +0 -0
  40. {infrahub_server-1.6.0.dist-info → infrahub_server-1.6.1.dist-info}/entry_points.txt +0 -0
  41. {infrahub_server-1.6.0.dist-info → infrahub_server-1.6.1.dist-info}/licenses/LICENSE.txt +0 -0
infrahub/api/oauth2.py CHANGED
@@ -12,10 +12,12 @@ from opentelemetry import trace
12
12
  from infrahub import config, models
13
13
  from infrahub.api.dependencies import get_db
14
14
  from infrahub.auth import (
15
+ SSOStateCache,
15
16
  get_groups_from_provider,
16
17
  signin_sso_account,
17
18
  validate_auth_response,
18
19
  )
20
+ from infrahub.auth_pkce import compute_code_challenge, generate_code_verifier
19
21
  from infrahub.exceptions import ProcessingError
20
22
  from infrahub.log import get_logger
21
23
  from infrahub.message_bus.types import KVTTL
@@ -42,6 +44,7 @@ async def authorize(request: Request, provider_name: str, final_url: str | None
42
44
  with trace.get_tracer(__name__).start_as_current_span("sso_oauth2_client_configuration") as span:
43
45
  span.set_attribute("provider_name", provider_name)
44
46
  span.set_attribute("scopes", provider.scopes)
47
+ span.set_attribute("pkce_enabled", provider.pkce_enabled)
45
48
 
46
49
  client = AsyncOAuth2Client(
47
50
  client_id=provider.client_id,
@@ -52,14 +55,32 @@ async def authorize(request: Request, provider_name: str, final_url: str | None
52
55
  redirect_uri = _get_redirect_url(request=request, provider_name=provider_name)
53
56
  final_url = final_url or config.SETTINGS.main.public_url or str(request.base_url)
54
57
 
58
+ # Generate PKCE parameters if enabled
59
+ code_verifier = None
60
+ pkce_params: dict[str, str] = {}
61
+ if provider.pkce_enabled:
62
+ code_verifier = generate_code_verifier()
63
+ code_challenge = compute_code_challenge(code_verifier)
64
+ pkce_params = {
65
+ "code_challenge": code_challenge,
66
+ "code_challenge_method": "S256",
67
+ }
68
+
55
69
  authorization_uri, state = client.create_authorization_url(
56
- url=provider.authorization_url, redirect_uri=redirect_uri, scope=provider.scopes, final_url=final_url
70
+ url=provider.authorization_url,
71
+ redirect_uri=redirect_uri,
72
+ scope=provider.scopes,
73
+ final_url=final_url,
74
+ **pkce_params,
57
75
  )
58
76
 
59
77
  service: InfrahubServices = request.app.state.service
60
78
 
79
+ cache_data = SSOStateCache(final_url=final_url, code_verifier=code_verifier)
61
80
  await service.cache.set(
62
- key=f"security:oauth2:provider:{provider_name}:state:{state}", value=final_url, expires=KVTTL.TWO_HOURS
81
+ key=f"security:oauth2:provider:{provider_name}:state:{state}",
82
+ value=cache_data.model_dump_json(),
83
+ expires=KVTTL.TWO_HOURS,
63
84
  )
64
85
 
65
86
  if config.SETTINGS.dev.frontend_redirect_sso:
@@ -82,13 +103,15 @@ async def token(
82
103
  service: InfrahubServices = request.app.state.service
83
104
 
84
105
  cache_key = f"security:oauth2:provider:{provider_name}:state:{state}"
85
- stored_final_url = await service.cache.get(key=cache_key)
106
+ cached_data = await service.cache.get(key=cache_key)
86
107
  await service.cache.delete(key=cache_key)
87
108
 
88
- if not stored_final_url:
109
+ if not cached_data:
89
110
  raise ProcessingError(message="Invalid 'state' parameter")
90
111
 
91
- token_data = {
112
+ sso_state = SSOStateCache.model_validate_json(cached_data)
113
+
114
+ token_data: dict[str, str | None] = {
92
115
  "code": code,
93
116
  "client_id": provider.client_id,
94
117
  "client_secret": provider.client_secret,
@@ -96,6 +119,10 @@ async def token(
96
119
  "grant_type": "authorization_code",
97
120
  }
98
121
 
122
+ # Add code_verifier if PKCE was used
123
+ if sso_state.code_verifier:
124
+ token_data["code_verifier"] = sso_state.code_verifier
125
+
99
126
  token_response = await service.http.post(provider.token_url, data=token_data)
100
127
  validate_auth_response(response=token_response, provider_type="OAuth 2.0")
101
128
 
@@ -139,5 +166,5 @@ async def token(
139
166
  )
140
167
 
141
168
  return models.UserTokenWithUrl(
142
- access_token=user_token.access_token, refresh_token=user_token.refresh_token, final_url=stored_final_url
169
+ access_token=user_token.access_token, refresh_token=user_token.refresh_token, final_url=sso_state.final_url
143
170
  )
infrahub/api/oidc.py CHANGED
@@ -14,10 +14,12 @@ from pydantic import BaseModel, HttpUrl
14
14
  from infrahub import config, models
15
15
  from infrahub.api.dependencies import get_db
16
16
  from infrahub.auth import (
17
+ SSOStateCache,
17
18
  get_groups_from_provider,
18
19
  signin_sso_account,
19
20
  validate_auth_response,
20
21
  )
22
+ from infrahub.auth_pkce import compute_code_challenge, generate_code_verifier
21
23
  from infrahub.exceptions import ProcessingError
22
24
  from infrahub.log import get_logger
23
25
  from infrahub.message_bus.types import KVTTL
@@ -58,6 +60,10 @@ class OIDCDiscoveryConfig(BaseModel):
58
60
  tls_client_certificate_bound_access_tokens: bool | None = None
59
61
  mtls_endpoint_aliases: dict[str, HttpUrl] | None = None
60
62
 
63
+ @property
64
+ def supports_pkce(self) -> bool:
65
+ return "S256" in (self.code_challenge_methods_supported or [])
66
+
61
67
 
62
68
  def _get_redirect_url(request: Request, provider_name: str) -> str:
63
69
  """Return public redirect URL."""
@@ -74,10 +80,14 @@ async def authorize(request: Request, provider_name: str, final_url: str | None
74
80
  validate_auth_response(response=response, provider_type="OIDC")
75
81
  oidc_config = OIDCDiscoveryConfig(**response.json())
76
82
 
83
+ pkce_supported = oidc_config.supports_pkce
84
+
77
85
  with trace.get_tracer(__name__).start_as_current_span("sso_oauth2_client_configuration") as span:
78
86
  span.set_attribute("provider_name", provider_name)
79
87
  span.set_attribute("scopes", provider.scopes)
80
88
  span.set_attribute("discovery_url", provider.discovery_url)
89
+ span.set_attribute("pkce_enabled", provider.pkce_enabled)
90
+ span.set_attribute("pkce_supported", pkce_supported)
81
91
 
82
92
  client = AsyncOAuth2Client(
83
93
  client_id=provider.client_id,
@@ -88,12 +98,26 @@ async def authorize(request: Request, provider_name: str, final_url: str | None
88
98
  redirect_uri = _get_redirect_url(request=request, provider_name=provider_name)
89
99
  final_url = final_url or config.SETTINGS.main.public_url or str(request.base_url)
90
100
 
101
+ # Generate PKCE parameters if enabled and supported by provider
102
+ code_verifier = None
103
+ pkce_params: dict[str, str] = {}
104
+ if provider.pkce_enabled and pkce_supported:
105
+ code_verifier = generate_code_verifier()
106
+ code_challenge = compute_code_challenge(code_verifier)
107
+ pkce_params = {
108
+ "code_challenge": code_challenge,
109
+ "code_challenge_method": "S256",
110
+ }
111
+
91
112
  authorization_uri, state = client.create_authorization_url(
92
- url=str(oidc_config.authorization_endpoint), redirect_uri=redirect_uri, scope=provider.scopes
113
+ url=str(oidc_config.authorization_endpoint), redirect_uri=redirect_uri, scope=provider.scopes, **pkce_params
93
114
  )
94
115
 
116
+ cache_data = SSOStateCache(final_url=final_url, code_verifier=code_verifier)
95
117
  await service.cache.set(
96
- key=f"security:oidc:provider:{provider_name}:state:{state}", value=final_url, expires=KVTTL.TWO_HOURS
118
+ key=f"security:oidc:provider:{provider_name}:state:{state}",
119
+ value=cache_data.model_dump_json(),
120
+ expires=KVTTL.TWO_HOURS,
97
121
  )
98
122
 
99
123
  if config.SETTINGS.dev.frontend_redirect_sso:
@@ -116,13 +140,15 @@ async def token(
116
140
  service: InfrahubServices = request.app.state.service
117
141
 
118
142
  cache_key = f"security:oidc:provider:{provider_name}:state:{state}"
119
- stored_final_url = await service.cache.get(key=cache_key)
143
+ cached_data = await service.cache.get(key=cache_key)
120
144
  await service.cache.delete(key=cache_key)
121
145
 
122
- if not stored_final_url:
146
+ if not cached_data:
123
147
  raise ProcessingError(message="Invalid 'state' parameter")
124
148
 
125
- token_data = {
149
+ sso_state = SSOStateCache.model_validate_json(cached_data)
150
+
151
+ token_data: dict[str, str | None] = {
126
152
  "code": code,
127
153
  "client_id": provider.client_id,
128
154
  "client_secret": provider.client_secret,
@@ -130,6 +156,10 @@ async def token(
130
156
  "grant_type": "authorization_code",
131
157
  }
132
158
 
159
+ # Add code_verifier if PKCE was used
160
+ if sso_state.code_verifier:
161
+ token_data["code_verifier"] = sso_state.code_verifier
162
+
133
163
  discovery_response = await service.http.get(url=provider.discovery_url)
134
164
  validate_auth_response(response=discovery_response, provider_type="OIDC")
135
165
 
@@ -183,7 +213,7 @@ async def token(
183
213
  )
184
214
 
185
215
  return models.UserTokenWithUrl(
186
- access_token=user_token.access_token, refresh_token=user_token.refresh_token, final_url=stored_final_url
216
+ access_token=user_token.access_token, refresh_token=user_token.refresh_token, final_url=sso_state.final_url
187
217
  )
188
218
 
189
219
 
infrahub/auth.py CHANGED
@@ -51,6 +51,17 @@ class AccountSession(BaseModel):
51
51
  return self.auth_type == AuthType.JWT
52
52
 
53
53
 
54
+ class SSOStateCache(BaseModel):
55
+ """Cache data stored during OAuth2/OIDC authorization flow.
56
+
57
+ This model is used to store state information between the authorization
58
+ request and the token exchange, including PKCE code_verifier when enabled.
59
+ """
60
+
61
+ final_url: str
62
+ code_verifier: str | None = None
63
+
64
+
54
65
  async def validate_active_account(db: InfrahubDatabase, account_id: str) -> None:
55
66
  account = await NodeManager.get_one(db=db, kind=CoreGenericAccount, id=account_id, raise_on_error=True)
56
67
  if account.status.value != AccountStatus.ACTIVE.value:
infrahub/auth_pkce.py ADDED
@@ -0,0 +1,41 @@
1
+ """PKCE (RFC 7636) utilities for OAuth2/OIDC authentication.
2
+
3
+ This module provides functions to generate code verifiers and compute
4
+ code challenges for the Proof Key for Code Exchange (PKCE) extension.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import base64
10
+ import hashlib
11
+ import secrets
12
+
13
+
14
+ def generate_code_verifier() -> str:
15
+ """Generate a cryptographically random code verifier.
16
+
17
+ The code verifier is a high-entropy cryptographic random string using
18
+ the unreserved characters [A-Z] / [a-z] / [0-9] / "-" / "." / "_" / "~",
19
+ with a minimum length of 43 characters and a maximum length of 128
20
+ characters.
21
+
22
+ Returns:
23
+ A 43-character URL-safe string (256 bits of entropy).
24
+ """
25
+ return secrets.token_urlsafe(32)
26
+
27
+
28
+ def compute_code_challenge(code_verifier: str) -> str:
29
+ """Compute S256 code challenge from verifier.
30
+
31
+ Implements the S256 code challenge method as defined in RFC 7636:
32
+ code_challenge = BASE64URL(SHA256(code_verifier))
33
+
34
+ Args:
35
+ code_verifier: The code verifier string.
36
+
37
+ Returns:
38
+ Base64URL-encoded SHA256 hash without padding.
39
+ """
40
+ digest = hashlib.sha256(code_verifier.encode("ascii")).digest()
41
+ return base64.urlsafe_b64encode(digest).decode("ascii").rstrip("=")
infrahub/config.py CHANGED
@@ -585,11 +585,14 @@ class SecurityOIDCBaseSettings(BaseSettings):
585
585
  icon: str = Field(default="mdi:account-key")
586
586
  display_label: str = Field(default="Single Sign on")
587
587
  userinfo_method: UserInfoMethod = Field(default=UserInfoMethod.GET)
588
+ pkce_enabled: bool = Field(
589
+ default=True, description="Enable PKCE (RFC 7636) with S256 method for authorization code flow"
590
+ )
588
591
 
589
592
 
590
593
  class SecurityOIDCSettings(SecurityOIDCBaseSettings):
591
594
  client_id: str = Field(..., description="Client ID of the application created in the auth provider")
592
- client_secret: str = Field(..., description="Client secret as defined in auth provider")
595
+ client_secret: str | None = Field(default=None, description="Client secret as defined in auth provider")
593
596
  discovery_url: str = Field(..., description="The OIDC discovery URL xyz/.well-known/openid-configuration")
594
597
  scopes: list[str] = Field(default_factory=_default_scopes)
595
598
 
@@ -637,13 +640,16 @@ class SecurityOAuth2BaseSettings(BaseSettings):
637
640
 
638
641
  icon: str = Field(default="mdi:account-key")
639
642
  userinfo_method: UserInfoMethod = Field(default=UserInfoMethod.GET)
643
+ pkce_enabled: bool = Field(
644
+ default=True, description="Enable PKCE (RFC 7636) with S256 method for authorization code flow"
645
+ )
640
646
 
641
647
 
642
648
  class SecurityOAuth2Settings(SecurityOAuth2BaseSettings):
643
649
  """Common base for Oauth2 providers"""
644
650
 
645
651
  client_id: str = Field(..., description="Client ID of the application created in the auth provider")
646
- client_secret: str = Field(..., description="Client secret as defined in auth provider")
652
+ client_secret: str | None = Field(default=None, description="Client secret as defined in auth provider")
647
653
  authorization_url: str = Field(...)
648
654
  token_url: str = Field(...)
649
655
  userinfo_url: str = Field(...)
@@ -3,7 +3,6 @@ from __future__ import annotations
3
3
  import re
4
4
  from typing import TYPE_CHECKING, Any, Optional, Self, Union, cast
5
5
 
6
- from neo4j.graph import Node as Neo4jNode
7
6
  from pydantic import Field, field_validator
8
7
 
9
8
  from infrahub.core.branch.enums import BranchStatus
@@ -24,6 +23,8 @@ from infrahub.core.timestamp import Timestamp
24
23
  from infrahub.exceptions import BranchNotFoundError, InitializationError, ValidationError
25
24
 
26
25
  if TYPE_CHECKING:
26
+ from neo4j.graph import Node as Neo4jNode
27
+
27
28
  from infrahub.database import InfrahubDatabase
28
29
 
29
30
 
@@ -168,7 +169,7 @@ class Branch(StandardNode):
168
169
  )
169
170
  await query.execute(db=db)
170
171
 
171
- return [cls.from_db(node=cast(Neo4jNode, result.get("n"))) for result in query.get_results()]
172
+ return [cls.from_db(node=cast("Neo4jNode", result.get("n"))) for result in query.get_results()]
172
173
 
173
174
  @classmethod
174
175
  async def get_list_count(
@@ -290,7 +290,7 @@ class NodeChangelog(BaseModel):
290
290
  name=relationship.schema.name
291
291
  )
292
292
  relationship_container = cast(
293
- RelationshipCardinalityManyChangelog, self.relationships[relationship.schema.name]
293
+ "RelationshipCardinalityManyChangelog", self.relationships[relationship.schema.name]
294
294
  )
295
295
 
296
296
  relationship_container.add_new_peer(relationship=relationship)
@@ -311,7 +311,7 @@ class NodeChangelog(BaseModel):
311
311
  name=relationship.schema.name
312
312
  )
313
313
  relationship_container = cast(
314
- RelationshipCardinalityManyChangelog, self.relationships[relationship.schema.name]
314
+ "RelationshipCardinalityManyChangelog", self.relationships[relationship.schema.name]
315
315
  )
316
316
  relationship_container.remove_peer(
317
317
  peer_id=relationship.get_peer_id(), peer_kind=relationship.get_peer_kind()
@@ -1 +1 @@
1
- GRAPH_VERSION = 46
1
+ GRAPH_VERSION = 47
@@ -24,7 +24,7 @@ class ObjectConflictValidatorRecorder:
24
24
  )
25
25
  except NodeNotFoundError:
26
26
  return []
27
- proposed_change = cast(CoreProposedChange, proposed_change)
27
+ proposed_change = cast("CoreProposedChange", proposed_change)
28
28
  validator = await self.get_or_create_validator(proposed_change)
29
29
  await self.initialize_validator(validator)
30
30
 
@@ -48,6 +48,7 @@ from .m043_create_hfid_display_label_in_db import Migration043
48
48
  from .m044_backfill_hfid_display_label_in_db import Migration044
49
49
  from .m045_backfill_hfid_display_label_in_db_profile_template import Migration045
50
50
  from .m046_fill_agnostic_hfid_display_labels import Migration046
51
+ from .m047_backfill_or_null_display_label import Migration047
51
52
 
52
53
  if TYPE_CHECKING:
53
54
  from ..shared import MigrationTypes
@@ -100,6 +101,7 @@ MIGRATIONS: list[type[MigrationTypes]] = [
100
101
  Migration044,
101
102
  Migration045,
102
103
  Migration046,
104
+ Migration047,
103
105
  ]
104
106
 
105
107