Skip to content

Commit 9b7196a

Browse files
hsong-rhclaude
andauthored
feat: Implement mTLS event stream authentication support (#1402)
## Summary This PR implements comprehensive mTLS (mutual TLS) authentication support for Event Streams, enabling secure client certificate-based authentication with the Gateway service. https://issues.redhat.com/browse/AAP-46060 ### Key Features - **New mTLS Credential Type**: EventStreamCredentialType.MTLS for secure client authentication - **Certificate Synchronization**: Automatic sync with Gateway API during Event Stream lifecycle - **Certificate Validation**: PEM format validation with expiration checking and RFC 2253 subject validation - **Lifecycle Management**: Automatic certificate create/update/delete operations ### Core Components - `SyncCertificates` service for Gateway certificate management - Enhanced `EdaCredential` model with mTLS-specific validation - `EventStream` views with automatic certificate synchronization - New API exceptions for Gateway and credential error handling ### Security Enhancements - Certificate format validation using cryptography library - Subject DN validation with proper X.509 attribute checking - Secure certificate storage and transmission to Gateway - Comprehensive error handling for authentication failures ## Test plan - [x] Unit tests for certificate validation logic - [x] Integration tests for EventStream mTLS workflows - [x] Service-level tests for Gateway synchronization - [x] Edge case testing for certificate lifecycle management - [x] All existing tests continue to pass - [x] Linting and code quality checks pass 🤖 Generated with [Claude Code](https://claude.ai/code) Co-authored-by: Claude <[email protected]>
1 parent 33b348c commit 9b7196a

File tree

14 files changed

+3470
-9
lines changed

14 files changed

+3470
-9
lines changed

src/aap_eda/api/event_stream_authentication.py

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
from rest_framework.exceptions import AuthenticationFailed
3333

3434
from aap_eda.core.enums import SignatureEncodingType
35+
from aap_eda.core.utils.credentials import validate_x509_subject_match
3536

3637
logger = logging.getLogger(__name__)
3738
DEFAULT_TIMEOUT = 30
@@ -110,11 +111,29 @@ class MTLSAuthentication(EventStreamAuthentication):
110111

111112
def authenticate(self, _body=None):
112113
"""Handle mTLS authentication."""
113-
if self.subject and self.subject != self.value:
114-
message = f"Subject Name mismatch : {self.value}"
114+
if self.subject and not self.validate_subject(
115+
self.subject, self.value
116+
):
117+
message = f"Subject: {self.value} does not match {self.subject}"
115118
logger.warning(message)
116119
raise AuthenticationFailed(message)
117120

121+
def validate_subject(self, expected: str, actual: str) -> bool:
122+
"""Validate that actual subject matches expected subject pattern.
123+
124+
Uses shared X.509 standard-compliant DN parsing for attribute-level
125+
matching. Supports wildcards and is order-independent per X.509
126+
standards.
127+
128+
Args:
129+
expected: Official subject pattern (may contain * wildcards)
130+
actual: Input subject from user
131+
132+
Returns:
133+
bool: True if actual matches expected pattern, False otherwise
134+
"""
135+
return validate_x509_subject_match(expected, actual)
136+
118137

119138
@dataclass
120139
class BasicAuthentication(EventStreamAuthentication):

src/aap_eda/api/exceptions.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,8 @@
3535
"Conflict",
3636
"Unprocessable",
3737
"PermissionDenied",
38+
"GatewayAPIError",
39+
"MissingCredentialsError",
3840
"api_fallback_handler",
3941
)
4042

@@ -125,3 +127,20 @@ class ExternalSMSError(APIException):
125127
default_detail = (
126128
"External SMS Error: not able to fetch secrets from external SMS"
127129
)
130+
131+
132+
class GatewayAPIError(APIException):
133+
status_code = status.HTTP_502_BAD_GATEWAY
134+
default_code = "gateway_api_error"
135+
default_detail = _(
136+
"Gateway API Error: Unable to communicate with the Gateway service"
137+
)
138+
139+
140+
class MissingCredentialsError(APIException):
141+
status_code = status.HTTP_400_BAD_REQUEST
142+
default_code = "missing_credentials"
143+
default_detail = _(
144+
"Missing Credentials: Required credentials are not available "
145+
"for Gateway operations"
146+
)

src/aap_eda/api/views/eda_credential.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -204,6 +204,7 @@ def partial_update(self, request, pk):
204204
setattr(eda_credential, key, value)
205205

206206
with transaction.atomic():
207+
eda_credential._request = request
207208
eda_credential.save()
208209
check_related_permissions(
209210
request.user,

src/aap_eda/api/views/event_stream.py

Lines changed: 67 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -30,8 +30,13 @@
3030

3131
from aap_eda.api import exceptions as api_exc, filters, serializers
3232
from aap_eda.core import models
33-
from aap_eda.core.enums import ResourceType
33+
from aap_eda.core.enums import EventStreamAuthType, ResourceType
34+
from aap_eda.core.exceptions import (
35+
GatewayAPIError as CoreGatewayAPIError,
36+
MissingCredentials as CoreMissingCredentials,
37+
)
3438
from aap_eda.core.utils import logging_utils
39+
from aap_eda.services.sync_certs import SyncCertificates
3540

3641
logger = logging.getLogger(__name__)
3742

@@ -99,7 +104,13 @@ def retrieve(self, request, *args, **kwargs):
99104
responses={
100105
status.HTTP_204_NO_CONTENT: OpenApiResponse(
101106
None, description="Delete successful."
102-
)
107+
),
108+
status.HTTP_400_BAD_REQUEST: OpenApiResponse(
109+
description="Missing credentials for certificate deletion."
110+
),
111+
status.HTTP_502_BAD_GATEWAY: OpenApiResponse(
112+
description="Gateway API error during certificate deletion."
113+
),
103114
},
104115
)
105116
def destroy(self, request, *args, **kwargs):
@@ -110,6 +121,7 @@ def destroy(self, request, *args, **kwargs):
110121
f"Event stream '{event_stream.name}' is being referenced by "
111122
f"{ref_count} activation(s) and cannot be deleted"
112123
)
124+
self._sync_certificates(event_stream, "destroy")
113125
self.perform_destroy(event_stream)
114126

115127
logger.info(
@@ -160,7 +172,10 @@ def list(self, request, *args, **kwargs):
160172
description="Return the new event stream.",
161173
),
162174
status.HTTP_400_BAD_REQUEST: OpenApiResponse(
163-
description="Invalid data to create event stream."
175+
description="Invalid data or missing credentials."
176+
),
177+
status.HTTP_502_BAD_GATEWAY: OpenApiResponse(
178+
description="Gateway API error during certificate sync."
164179
),
165180
},
166181
)
@@ -182,6 +197,7 @@ def create(self, request, *args, **kwargs):
182197
RoleDefinition.objects.give_creator_permissions(
183198
request.user, serializer.instance
184199
)
200+
self._sync_certificates(response, "create")
185201

186202
logger.info(
187203
logging_utils.generate_simple_audit_log(
@@ -206,12 +222,17 @@ def create(self, request, *args, **kwargs):
206222
description="Update successful, return the new event stream.",
207223
),
208224
status.HTTP_400_BAD_REQUEST: OpenApiResponse(
209-
description="Unable to update event stream."
225+
description="Update failed or missing credentials."
226+
),
227+
status.HTTP_502_BAD_GATEWAY: OpenApiResponse(
228+
description="Gateway API error during certificate sync."
210229
),
211230
},
212231
)
213232
def partial_update(self, request, *args, **kwargs):
214233
event_stream = self.get_object()
234+
new_eda_credential_id = request.data.get("eda_credential_id")
235+
215236
old_data = model_to_dict(event_stream)
216237
context = {"request": request}
217238
serializer = serializers.EventStreamInSerializer(
@@ -233,6 +254,13 @@ def partial_update(self, request, *args, **kwargs):
233254
setattr(event_stream, key, value)
234255

235256
with transaction.atomic():
257+
# Check if we need to destroy old certificates before saving
258+
if (
259+
new_eda_credential_id
260+
and event_stream.eda_credential.id != new_eda_credential_id
261+
):
262+
self._sync_certificates(event_stream, "destroy")
263+
236264
event_stream.save()
237265
check_related_permissions(
238266
request.user,
@@ -241,6 +269,9 @@ def partial_update(self, request, *args, **kwargs):
241269
model_to_dict(event_stream),
242270
)
243271

272+
if new_eda_credential_id:
273+
self._sync_certificates(event_stream, "update")
274+
244275
logger.info(
245276
logging_utils.generate_simple_audit_log(
246277
"Update",
@@ -307,3 +338,35 @@ def activations(self, request, id):
307338
)
308339
)
309340
return self.get_paginated_response(serializer.data)
341+
342+
def _sync_certificates(
343+
self,
344+
event_stream: models.EventStream,
345+
action: str,
346+
) -> None:
347+
if (
348+
event_stream.eda_credential.credential_type.kind
349+
== EventStreamAuthType.MTLS
350+
):
351+
try:
352+
obj = SyncCertificates(event_stream.eda_credential.id)
353+
if action == "destroy":
354+
obj.delete(event_stream.id)
355+
else:
356+
obj.update()
357+
except CoreGatewayAPIError as ex:
358+
logger.error("Could not %s certificates: %s", action, str(ex))
359+
raise api_exc.GatewayAPIError(
360+
detail=f"Gateway API error during certificate {action}: "
361+
f"{str(ex)}"
362+
)
363+
except CoreMissingCredentials as ex:
364+
logger.error(
365+
"Missing credentials for certificate %s: %s",
366+
action,
367+
str(ex),
368+
)
369+
raise api_exc.MissingCredentialsError(
370+
detail=f"Missing credentials for certificate {action}: "
371+
f"{str(ex)}"
372+
)

src/aap_eda/core/exceptions.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,3 +55,11 @@ class CredentialPluginError(Exception):
5555

5656
class UnknownPluginTypeError(Exception):
5757
pass
58+
59+
60+
class GatewayAPIError(Exception):
61+
pass
62+
63+
64+
class MissingCredentials(Exception):
65+
pass

src/aap_eda/core/management/commands/create_initial_data.py

Lines changed: 65 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@
4848
AUTH_TYPE_LABEL = "Event Stream Authentication Type"
4949
SIGNATURE_ENCODING_LABEL = "Signature Encoding"
5050
HTTP_HEADER_LABEL = "HTTP Header Key"
51-
DEPRECATED_CREDENTIAL_KINDS = ["mtls"]
51+
DEPRECATED_CREDENTIAL_KINDS = []
5252
LABEL_PATH_TO_AUTH = "Path to Auth"
5353
LABEL_CLIENT_CERTIFICATE = "Client Certificate"
5454
LABEL_CLIENT_SECRET = "Client Secret"
@@ -1762,6 +1762,55 @@
17621762
"required": ["app_or_client_id", "install_id", "private_rsa_key"],
17631763
}
17641764

1765+
EVENT_STREAM_MTLS_INPUTS = {
1766+
"fields": [
1767+
{
1768+
"id": "auth_type",
1769+
"label": AUTH_TYPE_LABEL,
1770+
"type": "string",
1771+
"default": "mtls",
1772+
"hidden": True,
1773+
},
1774+
{
1775+
"id": "certificate",
1776+
"label": "Certificate",
1777+
"type": "string",
1778+
"multiline": True,
1779+
"format": "pem_certificate",
1780+
"help_text": (
1781+
"The Certificate collection in PEM format. You can have "
1782+
"multiple certificates in this field separated by "
1783+
"-----BEGIN CERTIFICATE----- "
1784+
"and ending in -----END CERTIFICATE-----"
1785+
"If a certificate is provided it will be transferred "
1786+
"to the Gateway, otherwise its assumed that the Gateway "
1787+
"already has the CA certificates in place to validate "
1788+
"the incoming client certificate."
1789+
),
1790+
},
1791+
{
1792+
"id": "subject",
1793+
"label": "Certificate Subject",
1794+
"type": "string",
1795+
"help_text": (
1796+
"The Subject from Certificate compliant with RFC 2253."
1797+
"This is optional and can be used to check the subject "
1798+
"defined in the certificate. It can contains regular "
1799+
"expression to match indivisual attributes in the subject "
1800+
"name. E.g., CN=[agent1,agent2].example.com,ST=[NJ|NY]"
1801+
),
1802+
},
1803+
{
1804+
"id": "http_header_key",
1805+
"label": HTTP_HEADER_LABEL,
1806+
"type": "string",
1807+
"default": "Subject",
1808+
"hidden": True,
1809+
},
1810+
],
1811+
"required": ["auth_type", "http_header_key"],
1812+
}
1813+
17651814
CREDENTIAL_TYPES = [
17661815
{
17671816
"name": enums.DefaultCredentialType.SOURCE_CONTROL,
@@ -2045,6 +2094,21 @@
20452094
"injectors": {},
20462095
"managed": True,
20472096
},
2097+
{
2098+
"name": enums.EventStreamCredentialType.MTLS,
2099+
"namespace": "event_stream",
2100+
"kind": "mtls",
2101+
"inputs": EVENT_STREAM_MTLS_INPUTS,
2102+
"injectors": {},
2103+
"managed": True,
2104+
"description": (
2105+
"Credential for Event Streams that use mutual TLS. "
2106+
"If CA Certificates are defined in the UI it will "
2107+
"be transferred to the Gateway proxy for validation "
2108+
"of incoming requests. We can optionally validate the "
2109+
"Subject defined in the inbound Certificate."
2110+
),
2111+
},
20482112
]
20492113

20502114

0 commit comments

Comments
 (0)