Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions services/create_endpoint.go
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,11 @@ func (a *CreateEndpointService) Run(ctx context.Context) (*datastore.Endpoint, e

// Set mTLS client certificate if provided
if a.E.MtlsClientCert != nil {
// Check license before allowing mTLS configuration
if !a.Licenser.MutualTLS() {
return nil, &ServiceError{ErrMsg: ErrMutualTLSFeatureUnavailable}
}

// Validate both fields provided together
cc := a.E.MtlsClientCert
if util.IsStringEmpty(cc.ClientCert) || util.IsStringEmpty(cc.ClientKey) {
Expand Down
33 changes: 33 additions & 0 deletions services/create_endpoint_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,7 @@ func TestCreateEndpointService_Run(t *testing.T) {
licenser.EXPECT().IpRules().Times(2).Return(true)
licenser.EXPECT().AdvancedEndpointMgmt().Times(1).Return(true)
licenser.EXPECT().CustomCertificateAuthority().Times(1).Return(true)
licenser.EXPECT().MutualTLS().Times(1).Return(true)
},
wantErr: true,
wantErrMsg: "mtls_client_cert requires both client_cert and client_key",
Expand Down Expand Up @@ -326,6 +327,7 @@ func TestCreateEndpointService_Run(t *testing.T) {
licenser.EXPECT().IpRules().Times(2).Return(true)
licenser.EXPECT().AdvancedEndpointMgmt().Times(1).Return(true)
licenser.EXPECT().CustomCertificateAuthority().Times(1).Return(true)
licenser.EXPECT().MutualTLS().Times(1).Return(true)
},
wantEndpoint: &datastore.Endpoint{
Name: "mtls_endpoint",
Expand All @@ -344,6 +346,37 @@ func TestCreateEndpointService_Run(t *testing.T) {
},
wantErr: false,
},
{
name: "should_fail_to_create_endpoint_with_mtls_when_license_denies",
args: args{
ctx: ctx,
e: models.CreateEndpoint{
Name: "mtls_endpoint_denied",
Secret: "1234",
URL: "https://secure.example.com",
Description: "endpoint with mTLS but license denies",
MtlsClientCert: &models.MtlsClientCert{
ClientCert: testCertPEM,
ClientKey: testKeyPEM,
},
},
g: project,
},
dbFn: func(app *CreateEndpointService) {
p, _ := app.ProjectRepo.(*mocks.MockProjectRepository)
p.EXPECT().FetchProjectByID(gomock.Any(), gomock.Any()).
Times(1).
Return(project, nil)

licenser, _ := app.Licenser.(*mocks.MockLicenser)
licenser.EXPECT().IpRules().Times(2).Return(true)
licenser.EXPECT().AdvancedEndpointMgmt().Times(1).Return(true)
licenser.EXPECT().CustomCertificateAuthority().Times(1).Return(true)
licenser.EXPECT().MutualTLS().Times(1).Return(false)
},
wantErr: true,
wantErrMsg: ErrMutualTLSFeatureUnavailable,
},
{
name: "should_fail_to_create_endpoint",
args: args{
Expand Down
4 changes: 4 additions & 0 deletions services/errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@ const (
ErrCodeLicenseExpired = "license.expired"
)

const (
ErrMutualTLSFeatureUnavailable = "mutual TLS feature unavailable, please upgrade your license"
)

type ServiceError struct {
ErrMsg string
Err error
Expand Down
5 changes: 5 additions & 0 deletions services/update_endpoint.go
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,11 @@ func (a *UpdateEndpointService) updateEndpoint(endpoint *datastore.Endpoint, e m
// Clear cached certificate since it's being removed
config.GetCertCache().Delete(endpoint.UID)
} else {
// Check license before allowing mTLS configuration
if !a.Licenser.MutualTLS() {
return nil, &ServiceError{ErrMsg: ErrMutualTLSFeatureUnavailable}
}

// Updating or setting new mTLS cert - both fields required
if util.IsStringEmpty(cc.ClientCert) || util.IsStringEmpty(cc.ClientKey) {
return nil, &ServiceError{ErrMsg: "mtls_client_cert requires both client_cert and client_key"}
Expand Down
37 changes: 37 additions & 0 deletions services/update_endpoint_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,9 @@ func TestUpdateEndpointService_Run(t *testing.T) {
_ = config.LoadCaCert("", "")
ctx := context.Background()
project := &datastore.Project{UID: "1234567890", Config: &datastore.DefaultProjectConfig}

// Generate valid test certificate for mTLS tests
testCertPEM, testKeyPEM := generateTestCertificate(t)
type args struct {
ctx context.Context
e models.UpdateEndpoint
Expand Down Expand Up @@ -336,6 +339,7 @@ func TestUpdateEndpointService_Run(t *testing.T) {
licenser.EXPECT().IpRules().Times(2).Return(true)
licenser.EXPECT().AdvancedEndpointMgmt().AnyTimes().Return(true)
licenser.EXPECT().CustomCertificateAuthority().Times(1).Return(true)
licenser.EXPECT().MutualTLS().Times(1).Return(true)
},
wantErr: true,
wantErrMsg: "mtls_client_cert requires both client_cert and client_key",
Expand Down Expand Up @@ -420,10 +424,43 @@ func TestUpdateEndpointService_Run(t *testing.T) {
licenser.EXPECT().IpRules().Times(2).Return(true)
licenser.EXPECT().AdvancedEndpointMgmt().AnyTimes().Return(true)
licenser.EXPECT().CustomCertificateAuthority().Times(1).Return(true)
licenser.EXPECT().MutualTLS().Times(1).Return(true)
},
wantErr: true,
wantErrMsg: "invalid mTLS client certificate: failed to parse client certificate and key: tls: failed to find any PEM data in certificate input",
},
{
name: "should_fail_to_update_endpoint_with_mtls_when_license_denies",
args: args{
ctx: ctx,
e: models.UpdateEndpoint{
Name: stringPtr("Endpoint with mTLS denied"),
Description: "updating endpoint with mTLS but license denies",
URL: "https://www.google.com/webhp",
MtlsClientCert: &models.MtlsClientCert{
ClientCert: testCertPEM,
ClientKey: testKeyPEM,
},
},
endpoint: &datastore.Endpoint{UID: "endpoint-mtls-denied"},
project: project,
},
dbFn: func(as *UpdateEndpointService) {
a, _ := as.EndpointRepo.(*mocks.MockEndpointRepository)
existingEndpoint := &datastore.Endpoint{
UID: "endpoint-mtls-denied",
}
a.EXPECT().FindEndpointByID(gomock.Any(), gomock.Any(), "1234567890").Times(1).Return(existingEndpoint, nil)

licenser, _ := as.Licenser.(*mocks.MockLicenser)
licenser.EXPECT().IpRules().Times(2).Return(true)
licenser.EXPECT().AdvancedEndpointMgmt().AnyTimes().Return(true)
licenser.EXPECT().CustomCertificateAuthority().Times(1).Return(true)
licenser.EXPECT().MutualTLS().Times(1).Return(false)
},
wantErr: true,
wantErrMsg: ErrMutualTLSFeatureUnavailable,
},
{
name: "should_remove_mtls_cert_with_empty_strings",
args: args{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -234,37 +234,47 @@
<ng-container *ngIf="showConfig('mtls')">
<div class="border-l-2 border-new.primary-25 pl-16px mb-40px">
<div class="flex justify-between items-center mb-16px">
<p class="flex items-center text-12 font-medium text-neutral-11">
mTLS Client Certificate
<convoy-tooltip size="sm" position="top-right" class="ml-4px">Configure client certificate and private key for mutual TLS authentication. Provide PEM-encoded certificate and key strings.</convoy-tooltip>
</p>
<div class="flex items-center gap-12px">
<p class="flex items-center text-12 font-medium text-neutral-11">
mTLS Client Certificate
<convoy-tooltip size="sm" position="top-right" class="ml-4px">Configure client certificate and private key for mutual TLS authentication. Provide PEM-encoded certificate and key strings.</convoy-tooltip>
</p>
<div convoy-tag size="sm" color="primary" *ngIf="!licenseService.hasLicense('MUTUAL_TLS')">
<svg width="10" height="10" class="fill-new.primary-400 scale-150">
<use xlink:href="#lock-icon"></use>
</svg>
Business
</div>
</div>
<button convoy-permission="Endpoints|MANAGE" convoy-button type="button" size="xs" fill="soft-outline" color="neutral" (click)="toggleConfigForm('mtls', true)">
<svg width="14" height="14" class="fill-transparent stroke-neutral-10">
<use xlink:href="#delete-icon2"></use>
</svg>
</button>
</div>

<ng-container formGroupName="mtls_client_cert">
<div class="grid grid-cols-1 gap-24px">
<convoy-input-field className="mb-0">
<label for="client_cert" convoy-label>Client Certificate (PEM)</label>
<textarea id="client_cert" convoy-input autocomplete="client_cert" formControlName="client_cert" placeholder="-----BEGIN CERTIFICATE-----&#10;...&#10;-----END CERTIFICATE-----" rows="6"></textarea>
<convoy-input-error *ngIf="addNewEndpointForm.get('mtls_client_cert.client_cert')?.touched && addNewEndpointForm.get('mtls_client_cert.client_cert')?.invalid">
<span *ngIf="addNewEndpointForm.get('mtls_client_cert.client_cert')?.hasError('pattern')">Certificate must be in valid PEM format (-----BEGIN CERTIFICATE-----)</span>
<span *ngIf="addNewEndpointForm.get('mtls_client_cert.client_cert')?.hasError('required')">Certificate is required when using mTLS</span>
</convoy-input-error>
</convoy-input-field>
<ng-container *ngIf="licenseService.hasLicense('MUTUAL_TLS')">
<ng-container formGroupName="mtls_client_cert">
<div class="grid grid-cols-1 gap-24px">
<convoy-input-field className="mb-0">
<label for="client_cert" convoy-label>Client Certificate (PEM)</label>
<textarea id="client_cert" convoy-input autocomplete="client_cert" formControlName="client_cert" placeholder="-----BEGIN CERTIFICATE-----&#10;...&#10;-----END CERTIFICATE-----" rows="6"></textarea>
<convoy-input-error *ngIf="addNewEndpointForm.get('mtls_client_cert.client_cert')?.touched && addNewEndpointForm.get('mtls_client_cert.client_cert')?.invalid">
<span *ngIf="addNewEndpointForm.get('mtls_client_cert.client_cert')?.hasError('pattern')">Certificate must be in valid PEM format (-----BEGIN CERTIFICATE-----)</span>
<span *ngIf="addNewEndpointForm.get('mtls_client_cert.client_cert')?.hasError('required')">Certificate is required when using mTLS</span>
</convoy-input-error>
</convoy-input-field>

<convoy-input-field className="mb-0">
<label for="client_key" convoy-label>Client Private Key (PEM)</label>
<textarea id="client_key" convoy-input autocomplete="client_key" formControlName="client_key" placeholder="-----BEGIN PRIVATE KEY-----&#10;...&#10;-----END PRIVATE KEY-----" rows="6"></textarea>
<convoy-input-error *ngIf="addNewEndpointForm.get('mtls_client_cert.client_key')?.touched && addNewEndpointForm.get('mtls_client_cert.client_key')?.invalid">
<span *ngIf="addNewEndpointForm.get('mtls_client_cert.client_key')?.hasError('pattern')">Private key must be in valid PEM format (-----BEGIN PRIVATE KEY-----)</span>
<span *ngIf="addNewEndpointForm.get('mtls_client_cert.client_key')?.hasError('required')">Private key is required when using mTLS</span>
</convoy-input-error>
</convoy-input-field>
</div>
<convoy-input-field className="mb-0">
<label for="client_key" convoy-label>Client Private Key (PEM)</label>
<textarea id="client_key" convoy-input autocomplete="client_key" formControlName="client_key" placeholder="-----BEGIN PRIVATE KEY-----&#10;...&#10;-----END PRIVATE KEY-----" rows="6"></textarea>
<convoy-input-error *ngIf="addNewEndpointForm.get('mtls_client_cert.client_key')?.touched && addNewEndpointForm.get('mtls_client_cert.client_key')?.invalid">
<span *ngIf="addNewEndpointForm.get('mtls_client_cert.client_key')?.hasError('pattern')">Private key must be in valid PEM format (-----BEGIN PRIVATE KEY-----)</span>
<span *ngIf="addNewEndpointForm.get('mtls_client_cert.client_key')?.hasError('required')">Private key is required when using mTLS</span>
</convoy-input-error>
</convoy-input-field>
</div>
</ng-container>
</ng-container>
</div>
</ng-container>
Expand Down
17 changes: 17 additions & 0 deletions worker/task/process_event_delivery.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,10 @@ import (
"github.com/hibiken/asynq"
)

const (
errMutualTLSFeatureUnavailable = "mutual TLS feature unavailable, please upgrade your license"
)

func ProcessEventDelivery(endpointRepo datastore.EndpointRepository, eventDeliveryRepo datastore.EventDeliveryRepository, licenser license.Licenser, projectRepo datastore.ProjectRepository, q queue.Queuer, rateLimiter limiter.RateLimiter, dispatch *net.Dispatcher, attemptsRepo datastore.DeliveryAttemptsRepository, circuitBreakerManager *circuit_breaker.CircuitBreakerManager, featureFlag *fflag.FFlag, tracerBackend tracer.Backend) func(context.Context, *asynq.Task) error {
return func(ctx context.Context, t *asynq.Task) (err error) {
// Start a new trace span for event delivery
Expand Down Expand Up @@ -221,6 +225,19 @@ func ProcessEventDelivery(endpointRepo datastore.EndpointRepository, eventDelive
// Load mTLS client certificate if configured
var mtlsCert *tls.Certificate
if endpoint.MtlsClientCert != nil {
// Check license before using mTLS during delivery
if !licenser.MutualTLS() {
log.FromContext(ctx).Error(errMutualTLSFeatureUnavailable)
eventDelivery.Status = datastore.FailureEventStatus
eventDelivery.Description = errMutualTLSFeatureUnavailable
err = eventDeliveryRepo.UpdateStatusOfEventDelivery(ctx, project.UID, *eventDelivery, datastore.FailureEventStatus)
if err != nil {
log.FromContext(ctx).WithError(err).Error("failed to update event delivery status to failed")
}
tracerBackend.Capture(ctx, "event.delivery.error", attributes, traceStartTime, time.Now())
return nil // Return nil to avoid retrying
}

// Use cached certificate loading to avoid parsing on every request
cert, certErr := config.LoadClientCertificateWithCache(
endpoint.UID, // Use endpoint ID as cache key
Expand Down
13 changes: 13 additions & 0 deletions worker/task/process_retry_event_delivery.go
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,19 @@ func ProcessRetryEventDelivery(endpointRepo datastore.EndpointRepository, eventD
// Load mTLS client certificate if configured
var mtlsCert *tls.Certificate
if endpoint.MtlsClientCert != nil {
// Check license before using mTLS during delivery
if !licenser.MutualTLS() {
log.FromContext(ctx).Error(errMutualTLSFeatureUnavailable)
eventDelivery.Status = datastore.FailureEventStatus
eventDelivery.Description = errMutualTLSFeatureUnavailable
innerErr := eventDeliveryRepo.UpdateStatusOfEventDelivery(ctx, project.UID, *eventDelivery, datastore.FailureEventStatus)
if innerErr != nil {
log.FromContext(ctx).WithError(innerErr).Error("failed to update event delivery status to failed")
}
tracerBackend.Capture(ctx, "event.retry.delivery.error", attributes, traceStartTime, time.Now())
return nil // Return nil to avoid retrying
}

// Use cached certificate loading to avoid parsing on every request
cert, certErr := config.LoadClientCertificateWithCache(
endpoint.UID, // Use endpoint ID as cache key
Expand Down
Loading