Skip to content

Commit 7d9ffcb

Browse files
committed
feat: Upload certiticates to Gateway, mTLS support
https://issues.redhat.com/browse/AAP-29924 EDA will manage certificates so that it can be used by the Secret Discovery Service in Envoy.
1 parent ed52478 commit 7d9ffcb

File tree

10 files changed

+301
-11
lines changed

10 files changed

+301
-11
lines changed

src/aap_eda/api/views/event_stream.py

Lines changed: 25 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,6 @@
1515
import logging
1616
from urllib.parse import urljoin
1717

18-
import yaml
1918
from ansible_base.rbac.api.related import check_related_permissions
2019
from ansible_base.rbac.models import RoleDefinition
2120
from django.conf import settings
@@ -34,7 +33,9 @@
3433
from aap_eda.api import exceptions as api_exc, filters, serializers
3534
from aap_eda.core import models
3635
from aap_eda.core.enums import EventStreamAuthType, ResourceType
36+
from aap_eda.core.exceptions import GatewayAPIError, MissingCredentials
3737
from aap_eda.core.utils import logging_utils
38+
from aap_eda.services.sync_certs import SyncCertificates
3839

3940
logger = logging.getLogger(__name__)
4041

@@ -115,6 +116,8 @@ def destroy(self, request, *args, **kwargs):
115116
f"Event stream '{event_stream.name}' is being referenced by "
116117
f"{ref_count} activation(s) and cannot be deleted"
117118
)
119+
120+
self._sync_certificates(event_stream, "destroy")
118121
self.perform_destroy(event_stream)
119122

120123
logger.info(
@@ -187,11 +190,11 @@ def create(self, request, *args, **kwargs):
187190
RoleDefinition.objects.give_creator_permissions(
188191
request.user, serializer.instance
189192
)
190-
inputs = yaml.safe_load(
191-
response.eda_credential.inputs.get_secret_value()
192-
)
193193
sub_path = f"{EVENT_STREAM_EXTERNAL_PATH}/{response.uuid}/post/"
194-
if inputs["auth_type"] == EventStreamAuthType.MTLS:
194+
if (
195+
response.eda_credential.credential_type.kind
196+
== EventStreamAuthType.MTLS_V2.value
197+
):
195198
response.url = urljoin(
196199
settings.EVENT_STREAM_MTLS_BASE_URL, sub_path
197200
)
@@ -200,6 +203,7 @@ def create(self, request, *args, **kwargs):
200203
settings.EVENT_STREAM_BASE_URL, sub_path
201204
)
202205
response.save(update_fields=["url"])
206+
self._sync_certificates(response, "create")
203207

204208
logger.info(
205209
logging_utils.generate_simple_audit_log(
@@ -325,3 +329,19 @@ def activations(self, request, id):
325329
)
326330
)
327331
return self.get_paginated_response(serializer.data)
332+
333+
def _sync_certificates(
334+
self, event_stream: models.EventStream, action: str
335+
):
336+
if (
337+
event_stream.eda_credential.credential_type.kind
338+
== EventStreamAuthType.MTLS_V2.value
339+
):
340+
try:
341+
obj = SyncCertificates(event_stream.eda_credential.id)
342+
if action == "destroy":
343+
obj.delete(event_stream.id)
344+
else:
345+
obj.update()
346+
except (GatewayAPIError, MissingCredentials) as ex:
347+
logger.error("Could not %s certificates %s", action, str(ex))

src/aap_eda/api/views/external_event_stream.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -148,7 +148,7 @@ def _handle_auth(self, request, inputs):
148148
secret=inputs["secret"].encode("utf-8"),
149149
)
150150
obj.authenticate(request.body)
151-
elif inputs["auth_type"] == EventStreamAuthType.MTLS:
151+
elif inputs["auth_type"] == EventStreamAuthType.MTLS_V2:
152152
obj = MTLSAuthentication(
153153
subject=inputs.get("subject", ""),
154154
value=request.headers[inputs["http_header_key"]],
@@ -199,7 +199,7 @@ def _handle_auth(self, request, inputs):
199199
)
200200
obj.authenticate(request.body)
201201
else:
202-
message = "Unknown auth type"
202+
message = f"Unknown auth type {inputs['auth_type']}"
203203
logger.error(message)
204204
raise ParseError(message)
205205
except AuthenticationFailed as err:

src/aap_eda/core/enums.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -148,7 +148,7 @@ class EventStreamAuthType(DjangoStrEnum):
148148
OAUTH2 = "oauth2"
149149
OAUTH2JWT = "oauth2-jwt"
150150
ECDSA = "ecdsa"
151-
MTLS = "mtls"
151+
MTLS_V2 = "mtls_v2"
152152

153153

154154
class SignatureEncodingType(DjangoStrEnum):
@@ -165,7 +165,7 @@ class EventStreamCredentialType(DjangoStrEnum):
165165
OAUTH2 = "OAuth2 Event Stream"
166166
OAUTH2_JWT = "OAuth2 JWT Event Stream"
167167
ECDSA = "ECDSA Event Stream"
168-
MTLS = "mTLS Event Stream"
168+
MTLS_V2 = "mTLS Event Stream"
169169

170170

171171
class CustomEventStreamCredentialType(DjangoStrEnum):

src/aap_eda/core/exceptions.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,3 +31,11 @@ class PGNotifyError(Exception):
3131

3232
class ParseError(Exception):
3333
pass
34+
35+
36+
class MissingCredentials(Exception):
37+
pass
38+
39+
40+
class GatewayAPIError(Exception):
41+
pass

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

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -834,6 +834,48 @@
834834
"required": ["auth_type", "username", "password", "http_header_key"],
835835
}
836836

837+
EVENT_STREAM_MTLS_V2_INPUTS = {
838+
"fields": [
839+
{
840+
"id": "auth_type",
841+
"label": AUTH_TYPE_LABEL,
842+
"type": "string",
843+
"default": "mtls_v2",
844+
"hidden": True,
845+
},
846+
{
847+
"id": "certificate",
848+
"label": "Certificate",
849+
"type": "string",
850+
"multiline": True,
851+
"help_text": (
852+
"The Certificate collection in PEM format. You can have "
853+
"multiple certificates in this field separated by "
854+
"-----BEGIN CERTIFICATE----- "
855+
"and ending in -----END CERTIFICATE-----"
856+
),
857+
},
858+
{
859+
"id": "subject",
860+
"label": "Certificate Subject",
861+
"type": "string",
862+
"help_text": (
863+
"The Subject from Certificate compliant with RFC 2253."
864+
"This is optional and can be used to check the subject "
865+
"defined in the certificate."
866+
),
867+
},
868+
{
869+
"id": "http_header_key",
870+
"label": HTTP_HEADER_LABEL,
871+
"type": "string",
872+
"default": "Subject",
873+
"hidden": True,
874+
},
875+
],
876+
"required": ["auth_type", "certificate", "http_header_key"],
877+
}
878+
837879
CREDENTIAL_TYPES = [
838880
{
839881
"name": enums.DefaultCredentialType.SOURCE_CONTROL,
@@ -957,6 +999,21 @@
957999
"the signature."
9581000
),
9591001
},
1002+
{
1003+
"name": enums.EventStreamCredentialType.MTLS_V2,
1004+
"namespace": "event_stream",
1005+
"kind": "mtls_v2",
1006+
"inputs": EVENT_STREAM_MTLS_V2_INPUTS,
1007+
"injectors": {},
1008+
"managed": True,
1009+
"description": (
1010+
"Credential for Event Streams that use mutual TLS. "
1011+
"The Certificates can be defined in the UI and it "
1012+
"be transferred to the Gateway proxy for validation "
1013+
"of incoming requests. We can optionally validate the "
1014+
"Subject defined in the inbound Certificate."
1015+
),
1016+
},
9601017
{
9611018
"name": enums.CustomEventStreamCredentialType.GITLAB,
9621019
"namespace": "event_stream",

src/aap_eda/core/validators.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -278,7 +278,7 @@ def valid_hash_format(fmt: str):
278278
def _validate_event_stream_settings(auth_type: str):
279279
"""Check event stream settings."""
280280
if (
281-
auth_type == enums.EventStreamCredentialType.MTLS
281+
auth_type == enums.EventStreamCredentialType.MTLS_V2
282282
and not settings.EVENT_STREAM_MTLS_BASE_URL
283283
):
284284
raise serializers.ValidationError(
@@ -290,7 +290,7 @@ def _validate_event_stream_settings(auth_type: str):
290290
)
291291

292292
if (
293-
auth_type != enums.EventStreamCredentialType.MTLS
293+
auth_type != enums.EventStreamCredentialType.MTLS_V2
294294
and not settings.EVENT_STREAM_BASE_URL
295295
):
296296
raise serializers.ValidationError(

src/aap_eda/services/sync_certs.py

Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,189 @@
1+
# Copyright 2024 Red Hat, Inc.
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
""" Synchronize Certificates with Gateway """
15+
import base64
16+
import hashlib
17+
import logging
18+
from urllib.parse import urljoin
19+
20+
import requests
21+
import yaml
22+
from django.conf import settings
23+
from django.db.models.signals import post_save
24+
from django.dispatch import receiver
25+
from rest_framework import status
26+
27+
from aap_eda.core import enums, models
28+
from aap_eda.core.exceptions import GatewayAPIError, MissingCredentials
29+
30+
LOGGER = logging.getLogger(__name__)
31+
SLUG = "api/gateway/v1/ca_certificates/"
32+
DEFAULT_TIMEOUT = 30
33+
34+
35+
class SyncCertificates:
36+
"""This class synchronizes the certificates with Gateway"""
37+
38+
def __init__(self, eda_credential_id: int):
39+
self.eda_credential_id = eda_credential_id
40+
self.gateway_url = settings.GATEWAY_URL
41+
self.gateway_user = settings.GATEWAY_USER
42+
self.gateway_password = settings.GATEWAY_PASSWORD
43+
self.gateway_ssl_verify = settings.GATEWAY_SSL_VERIFY
44+
self.gateway_token = settings.GATEWAY_TOKEN
45+
self.eda_credential = models.EdaCredential.objects.get(
46+
id=self.eda_credential_id
47+
)
48+
49+
def update(self):
50+
"""Handle creating and updating the certificate in Gateway."""
51+
inputs = yaml.safe_load(self.eda_credential.inputs.get_secret_value())
52+
sha256 = hashlib.sha256(
53+
inputs["certificate"].encode("utf-8")
54+
).hexdigest()
55+
existing_object = self._fetch_from_gateway()
56+
LOGGER.info(f"Existing object is {existing_object}")
57+
58+
if existing_object.get("sha256", "") != sha256:
59+
data = {
60+
"name": self.eda_credential.name,
61+
"pem_data": inputs["certificate"],
62+
"sha256": sha256,
63+
"remote_id": self.eda_credential_id,
64+
}
65+
headers = self._prep_headers()
66+
if existing_object:
67+
slug = f"{SLUG}/{existing_object['id']}/"
68+
url = urljoin(self.gateway_url, slug)
69+
response = requests.patch(
70+
url,
71+
json=data,
72+
headers=headers,
73+
verify=self.gateway_ssl_verify,
74+
timeout=DEFAULT_TIMEOUT,
75+
)
76+
else:
77+
url = urljoin(self.gateway_url, SLUG)
78+
response = requests.post(
79+
url,
80+
json=data,
81+
headers=headers,
82+
verify=self.gateway_ssl_verify,
83+
timeout=DEFAULT_TIMEOUT,
84+
)
85+
86+
if response.status_code in [
87+
status.HTTP_200_OK,
88+
status.HTTP_201_CREATED,
89+
]:
90+
LOGGER.debug("Certificate updated")
91+
elif response.status_code == status.HTTP_400_BAD_REQUEST:
92+
LOGGER.error("Update failed")
93+
else:
94+
LOGGER.error("Couldn't update certificate")
95+
96+
else:
97+
LOGGER.debug("No changes detected")
98+
99+
def delete(self, event_stream_id: int):
100+
"""Delete the Certificate from Gateway if no other EventStream
101+
is using it.
102+
Parameters
103+
----------
104+
event_stream_id: int
105+
The event_stream associated with this credential, if this is
106+
the only event stream using this certificate we can safely
107+
delete it
108+
"""
109+
existing_object = self._fetch_from_gateway()
110+
if not existing_object:
111+
return
112+
113+
objects = models.EventStream.objects.filter(
114+
eda_credential_id=self.eda_credential
115+
)
116+
if len(objects) == 1 and event_stream_id == objects[0].id:
117+
slug = f"{SLUG}/{existing_object['id']}/"
118+
url = urljoin(self.gateway_url, slug)
119+
headers = self._prep_headers()
120+
response = requests.delete(
121+
url,
122+
headers=headers,
123+
verify=self.gateway_ssl_verify,
124+
timeout=DEFAULT_TIMEOUT,
125+
)
126+
if response.status_code == status.HTTP_200_OK:
127+
LOGGER.debug("Certificate object deleted")
128+
if response.status_code == status.HTTP_404_NOT_FOUND:
129+
LOGGER.warning("Certificate object missing during delete")
130+
else:
131+
LOGGER.error("Couldn't delete certificate object in gateway")
132+
raise GatewayAPIError
133+
134+
def _fetch_from_gateway(self):
135+
slug = f"{SLUG}/?remote_id={self.eda_credential_id}"
136+
url = urljoin(self.gateway_url, slug)
137+
headers = self._prep_headers()
138+
response = requests.get(
139+
url,
140+
headers=headers,
141+
verify=self.gateway_ssl_verify,
142+
timeout=DEFAULT_TIMEOUT,
143+
)
144+
if response.status_code == status.HTTP_200_OK:
145+
LOGGER.debug("Certificate object exists in gateway")
146+
data = response.json()
147+
if data["count"] > 0:
148+
return data["results"][0]
149+
else:
150+
return {}
151+
if response.status_code == status.HTTP_404_NOT_FOUND:
152+
LOGGER.debug("Certificate object does not exist in gateway")
153+
return {}
154+
155+
LOGGER.error("Error fetching certificate object")
156+
raise GatewayAPIError
157+
158+
def _prep_headers(self) -> dict:
159+
if self.gateway_token:
160+
return {"Authorization": f"Bearer {self.gateway_token}"}
161+
162+
if self.gateway_user and self.gateway_password:
163+
user_pass = f"{self.gateway_user}:{self.gateway_password}"
164+
auth_value = (
165+
f"Basic {base64.b64encode(user_pass.encode()).decode()}"
166+
)
167+
return {"Authorization": auth_value}
168+
169+
LOGGER.error("Cannot connect to gateway missing Credentials")
170+
raise MissingCredentials
171+
172+
173+
@receiver(post_save, sender=models.EdaCredential)
174+
def gw_handler(sender, instance, **kwargs):
175+
"""Handle updates to EdaCredential object and force a certificate sync."""
176+
if (
177+
instance.credential_type.name
178+
== enums.EventStreamCredentialType.MTLS_V2
179+
):
180+
try:
181+
objects = models.EventStream.objects.filter(
182+
eda_credential_id=instance.id
183+
)
184+
if len(objects) > 0:
185+
SyncCertificates(instance.id).update()
186+
except (GatewayAPIError, MissingCredentials) as ex:
187+
LOGGER.error(
188+
"Couldn't trigger gateway certificate updates %s", str(ex)
189+
)

0 commit comments

Comments
 (0)