Selaa lähdekoodia

feat: a client-certificate credential, so a workflow can present an identity (mTLS)

The other half of certificate handling: some internal servers do not just need
their own certificate trusted, they ask the caller to prove who it is.

This is a credential type rather than a second kind of certificate, because
what decides where it lives is the private key. The certificate half is public;
the key is not, and the credential store already encrypts secrets and already
delivers them to a runner over a mediated gRPC service that checks which
workflow is asking. Building a parallel path for one more secret would have
been a second thing to get right.

  credential type   client_certificate: certificate + key + optional passphrase
  workflow settings clientCertificateId
  runner            CURLOPT_SSLCERT_BLOB / SSLKEY_BLOB - a blob, so the key
                    never reaches the filesystem

The pair is encrypted together rather than splitting the public half out. A
certificate stored without its key proves nothing and a key without its
certificate cannot be presented, so keeping them in one blob means they cannot
drift apart. The public half of the record says only that a key is present -
nothing about the key itself, not even its length.

Asking for one as an HTTP auth header now says what it is for instead of
"unsupported credential type": it is presented during the handshake and
assigned in the workflow's settings.

A named identity that cannot be loaded fails the run before any node executes,
like a missing anchor. A server that asks for a client certificate and is given
none refuses the connection, and "could not load the client certificate" reads
far better than the handshake failure that would otherwise follow.

Verified against an openssl s_server started with -Verify, which genuinely
demands a client certificate: the workflow gets 200 with the identity assigned,
fails without it, and refuses the run when the credential does not exist.
fszontagh 1 kuukausi sitten
vanhempi
sitoutus
66c3d6b1a5

+ 32 - 0
lib/credentials/credential_client.cpp

@@ -81,6 +81,38 @@ Result<ImapData> CredentialClient::getImapCredentials(const std::string& credent
     return data;
     return data;
 }
 }
 
 
+Result<ClientCertificateData> CredentialClient::getClientCertificate(
+    const std::string& credential_id, const std::string& workflow_id) {
+    proto::GetClientCertificateRequest request;
+    request.set_credential_id(credential_id);
+    request.set_workflow_id(workflow_id);
+
+    proto::GetClientCertificateResponse response;
+    grpc::ClientContext context;
+
+    auto deadline = std::chrono::system_clock::now() +
+                   std::chrono::milliseconds(config_.timeout_ms);
+    context.set_deadline(deadline);
+
+    grpc::Status status = stub_->GetClientCertificate(&context, request, &response);
+
+    if (!status.ok()) {
+        return Error(ErrorCode::Unavailable,
+                    "Credential service error: " + status.error_message());
+    }
+
+    if (!response.success()) {
+        return Error(ErrorCode::NotFound, response.error());
+    }
+
+    ClientCertificateData data;
+    data.certificate_pem = response.certificate_pem();
+    data.private_key_pem = response.private_key_pem();
+    data.passphrase = response.passphrase();
+
+    return data;
+}
+
 Result<SmtpData> CredentialClient::getSmtpCredentials(const std::string& credential_id,
 Result<SmtpData> CredentialClient::getSmtpCredentials(const std::string& credential_id,
                                                        const std::string& workflow_id) {
                                                        const std::string& workflow_id) {
     proto::GetSmtpCredentialsRequest request;
     proto::GetSmtpCredentialsRequest request;

+ 6 - 0
lib/credentials/credential_client.hpp

@@ -30,6 +30,12 @@ public:
     common::Result<ImapData> getImapCredentials(const std::string& credential_id,
     common::Result<ImapData> getImapCredentials(const std::string& credential_id,
                                                  const std::string& workflow_id = "");
                                                  const std::string& workflow_id = "");
 
 
+    // The mTLS identity a workflow presents. The private key comes back over
+    // this call and is held in memory for the length of the run, never written
+    // anywhere.
+    common::Result<ClientCertificateData> getClientCertificate(const std::string& credential_id,
+                                                               const std::string& workflow_id = "");
+
     // Also satisfied by an imap credential for the same account.
     // Also satisfied by an imap credential for the same account.
     common::Result<SmtpData> getSmtpCredentials(const std::string& credential_id,
     common::Result<SmtpData> getSmtpCredentials(const std::string& credential_id,
                                                 const std::string& workflow_id = "");
                                                 const std::string& workflow_id = "");

+ 50 - 0
lib/credentials/credential_store.cpp

@@ -184,6 +184,23 @@ Result<CredentialInfo> CredentialStore::create(const CreateCredentialRequest& re
             };
             };
             break;
             break;
         }
         }
+        case CredentialType::ClientCertificate: {
+            auto data = ClientCertificateData::fromJson(request.data);
+            if (data.certificate_pem.empty() || data.private_key_pem.empty()) {
+                return Error(ErrorCode::InvalidArgument,
+                             "A client certificate needs both the certificate and its private key");
+            }
+            // Both halves are encrypted together. The certificate alone is
+            // public, but keeping the pair in one blob means they cannot drift
+            // apart - a certificate stored without its key proves nothing, and
+            // a key without its certificate cannot be presented.
+            data_to_encrypt = data.toJson();
+            // Nothing about the key, not even its length, in the public half.
+            public_data = {
+                {"hasPrivateKey", true}
+            };
+            break;
+        }
         default:
         default:
             return Error(ErrorCode::InvalidArgument, "Unsupported credential type");
             return Error(ErrorCode::InvalidArgument, "Unsupported credential type");
     }
     }
@@ -521,6 +538,12 @@ Result<HttpAuth> CredentialStore::getHttpAuth(const std::string& id, const std::
             return Error(ErrorCode::InvalidArgument, "MySQL credentials cannot be used for HTTP authentication");
             return Error(ErrorCode::InvalidArgument, "MySQL credentials cannot be used for HTTP authentication");
         case CredentialType::Postgresql:
         case CredentialType::Postgresql:
             return Error(ErrorCode::InvalidArgument, "PostgreSQL credentials cannot be used for HTTP authentication");
             return Error(ErrorCode::InvalidArgument, "PostgreSQL credentials cannot be used for HTTP authentication");
+        case CredentialType::ClientCertificate:
+            // Not a header. This one is presented during the TLS handshake, and
+            // is assigned in a workflow's settings rather than on a node.
+            return Error(ErrorCode::InvalidArgument,
+                         "A client certificate is presented during the TLS handshake, not as an "
+                         "authentication header. Assign it in the workflow's settings instead.");
         default:
         default:
             return Error(ErrorCode::Internal, "Unsupported credential type");
             return Error(ErrorCode::Internal, "Unsupported credential type");
     }
     }
@@ -555,6 +578,33 @@ Result<ImapData> CredentialStore::getImapCredentials(const std::string& id, cons
     return ImapData::fromJson(decrypt_result.value());
     return ImapData::fromJson(decrypt_result.value());
 }
 }
 
 
+Result<ClientCertificateData> CredentialStore::getClientCertificate(const std::string& id,
+                                                                    const std::string& workflow_id) {
+    auto get_result = storage_.get(COLLECTION, id);
+    if (get_result.failed()) {
+        return Error(ErrorCode::NotFound, "Credential not found: " + id);
+    }
+
+    auto doc = CredentialDocument::fromJson(get_result.value());
+
+    if (doc.metadata.type != CredentialType::ClientCertificate) {
+        return Error(ErrorCode::InvalidArgument,
+                     "Credential " + id + " is not a client certificate");
+    }
+
+    if (!hasWorkflowAccess(doc.metadata, workflow_id)) {
+        return Error(ErrorCode::PermissionDenied,
+            "Workflow " + workflow_id + " does not have access to credential " + id);
+    }
+
+    auto decrypt_result = decryptData(doc.encrypted_data);
+    if (decrypt_result.failed()) {
+        return decrypt_result.error();
+    }
+
+    return ClientCertificateData::fromJson(decrypt_result.value());
+}
+
 Result<SmtpData> CredentialStore::getSmtpCredentials(const std::string& id, const std::string& workflow_id) {
 Result<SmtpData> CredentialStore::getSmtpCredentials(const std::string& id, const std::string& workflow_id) {
     auto get_result = storage_.get(COLLECTION, id);
     auto get_result = storage_.get(COLLECTION, id);
     if (get_result.failed()) {
     if (get_result.failed()) {

+ 5 - 0
lib/credentials/credential_store.hpp

@@ -54,6 +54,11 @@ public:
     // Get IMAP credentials (checks workflow access)
     // Get IMAP credentials (checks workflow access)
     common::Result<ImapData> getImapCredentials(const std::string& id, const std::string& workflow_id = "");
     common::Result<ImapData> getImapCredentials(const std::string& id, const std::string& workflow_id = "");
 
 
+    // The mTLS identity a workflow presents. Decrypted here and nowhere else,
+    // like every other secret in this store.
+    common::Result<ClientCertificateData> getClientCertificate(const std::string& id,
+                                                               const std::string& workflow_id = "");
+
     // Accepts an smtp credential, or an imap one, so the same mailbox does not
     // Accepts an smtp credential, or an imap one, so the same mailbox does not
     // have to be stored twice to both read and send mail. An imap credential is
     // have to be stored twice to both read and send mail. An imap credential is
     // adapted using the submission port and STARTTLS.
     // adapted using the submission port and STARTTLS.

+ 19 - 0
lib/credentials/credential_types.cpp

@@ -17,6 +17,7 @@ std::string credentialTypeToString(CredentialType type) {
         case CredentialType::Smtp: return "smtp";
         case CredentialType::Smtp: return "smtp";
         case CredentialType::Mysql: return "mysql";
         case CredentialType::Mysql: return "mysql";
         case CredentialType::Postgresql: return "postgresql";
         case CredentialType::Postgresql: return "postgresql";
+        case CredentialType::ClientCertificate: return "client_certificate";
         default: return "unknown";
         default: return "unknown";
     }
     }
 }
 }
@@ -30,6 +31,7 @@ CredentialType credentialTypeFromString(const std::string& str) {
     if (str == "smtp") return CredentialType::Smtp;
     if (str == "smtp") return CredentialType::Smtp;
     if (str == "mysql") return CredentialType::Mysql;
     if (str == "mysql") return CredentialType::Mysql;
     if (str == "postgresql") return CredentialType::Postgresql;
     if (str == "postgresql") return CredentialType::Postgresql;
+    if (str == "client_certificate") return CredentialType::ClientCertificate;
     throw std::invalid_argument("Unknown credential type: " + str);
     throw std::invalid_argument("Unknown credential type: " + str);
 }
 }
 
 
@@ -206,6 +208,23 @@ SmtpData SmtpData::fromJson(const nlohmann::json& j) {
     return data;
     return data;
 }
 }
 
 
+// ClientCertificateData
+nlohmann::json ClientCertificateData::toJson() const {
+    return {
+        {"certificatePem", certificate_pem},
+        {"privateKeyPem", private_key_pem},
+        {"passphrase", passphrase}
+    };
+}
+
+ClientCertificateData ClientCertificateData::fromJson(const nlohmann::json& j) {
+    ClientCertificateData data;
+    data.certificate_pem = j.value("certificatePem", j.value("certificate_pem", ""));
+    data.private_key_pem = j.value("privateKeyPem", j.value("private_key_pem", ""));
+    data.passphrase = j.value("passphrase", "");
+    return data;
+}
+
 nlohmann::json ImapData::toJson() const {
 nlohmann::json ImapData::toJson() const {
     return {
     return {
         {"host", host},
         {"host", host},

+ 20 - 1
lib/credentials/credential_types.hpp

@@ -16,7 +16,12 @@ enum class CredentialType {
     Imap,       // IMAP: host + port + username + password + SSL
     Imap,       // IMAP: host + port + username + password + SSL
     Smtp,       // SMTP: host + port + username + password + security + sender
     Smtp,       // SMTP: host + port + username + password + security + sender
     Mysql,      // MySQL: host + port + username + password + database
     Mysql,      // MySQL: host + port + username + password + database
-    Postgresql  // PostgreSQL: host + port + username + password + database
+    Postgresql, // PostgreSQL: host + port + username + password + database
+    // Client certificate: the identity a workflow presents when a server asks
+    // for one (mTLS). A credential rather than a certificate because it
+    // carries a private key - the certificate half is public, the key is not,
+    // and it is the key that decides where this has to live.
+    ClientCertificate
 };
 };
 
 
 std::string credentialTypeToString(CredentialType type);
 std::string credentialTypeToString(CredentialType type);
@@ -89,6 +94,20 @@ struct OAuth2Data {
 };
 };
 
 
 // IMAP data (stored encrypted)
 // IMAP data (stored encrypted)
+// Client certificate data (stored encrypted). The certificate is public, but
+// it is kept with its key rather than in the certificates collection so the
+// pair cannot drift apart - a certificate without its key proves nothing.
+struct ClientCertificateData {
+    std::string certificate_pem;
+    std::string private_key_pem;
+    // Empty when the key is not encrypted, which is the common case for a key
+    // a server was given to a service rather than to a person.
+    std::string passphrase;
+
+    nlohmann::json toJson() const;
+    static ClientCertificateData fromJson(const nlohmann::json& j);
+};
+
 struct ImapData {
 struct ImapData {
     std::string host;
     std::string host;
     int port = 993;               // Default IMAPS port
     int port = 993;               // Default IMAPS port

+ 22 - 1
proto/credentials.proto

@@ -12,6 +12,12 @@ service CredentialService {
     // Get IMAP credentials for a credential
     // Get IMAP credentials for a credential
     rpc GetImapCredentials(GetImapCredentialsRequest) returns (GetImapCredentialsResponse);
     rpc GetImapCredentials(GetImapCredentialsRequest) returns (GetImapCredentialsResponse);
 
 
+    // Get the client certificate a workflow presents when a server asks for
+    // one. Mediated like every other secret here: the private key never
+    // reaches the runner except in answer to a request the webserver has
+    // checked.
+    rpc GetClientCertificate(GetClientCertificateRequest) returns (GetClientCertificateResponse);
+
     // Get SMTP credentials for a credential
     // Get SMTP credentials for a credential
     rpc GetSmtpCredentials(GetSmtpCredentialsRequest) returns (GetSmtpCredentialsResponse);
     rpc GetSmtpCredentials(GetSmtpCredentialsRequest) returns (GetSmtpCredentialsResponse);
 
 
@@ -43,10 +49,25 @@ message GetCredentialAuthResponse {
 message CredentialInfo {
 message CredentialInfo {
     string id = 1;
     string id = 1;
     string name = 2;
     string name = 2;
-    string type = 3;  // "basic", "bearer", "api_key", "oauth2", "imap", "smtp", "mysql", "postgresql"
+    string type = 3;  // "basic", "bearer", "api_key", "oauth2", "imap", "smtp", "mysql", "postgresql", "client_certificate"
     string description = 4;
     string description = 4;
 }
 }
 
 
+// Request to get a client certificate (mTLS identity)
+message GetClientCertificateRequest {
+    string credential_id = 1;
+    string workflow_id = 2;  // For access control verification
+}
+
+// Response with the certificate and its key. Both are PEM.
+message GetClientCertificateResponse {
+    bool success = 1;
+    string certificate_pem = 2;
+    string private_key_pem = 3;
+    string passphrase = 4;   // Empty when the key is not encrypted
+    string error = 5;        // Error message if success is false
+}
+
 // Request to get IMAP credentials
 // Request to get IMAP credentials
 message GetImapCredentialsRequest {
 message GetImapCredentialsRequest {
     string credential_id = 1;
     string credential_id = 1;

+ 16 - 0
src/runner/runner_service.cpp

@@ -485,6 +485,22 @@ RunnerService::RunnerService(const RunnerServiceConfig& config)
             return auth;
             return auth;
         });
         });
 
 
+    // The mTLS identity a workflow presents, fetched over the same mediated
+    // service the other secrets use.
+    engine_->setClientCertificateCallback(
+        [this](const std::string& credential_id, const std::string& workflow_id)
+            -> common::Result<engine::TlsClientIdentity> {
+            auto result = credential_client_->getClientCertificate(credential_id, workflow_id);
+            if (result.failed()) {
+                return result.error();
+            }
+            engine::TlsClientIdentity identity;
+            identity.certificate_pem = result.value().certificate_pem;
+            identity.private_key_pem = result.value().private_key_pem;
+            identity.passphrase = result.value().passphrase;
+            return identity;
+        });
+
     // Set up IMAP credential callback for workflow engine
     // Set up IMAP credential callback for workflow engine
     engine_->setImapCredentialCallback(
     engine_->setImapCredentialCallback(
         [this](const std::string& credential_id, const std::string& workflow_id)
         [this](const std::string& credential_id, const std::string& workflow_id)

+ 54 - 0
src/runner/workflow_engine.cpp

@@ -585,6 +585,55 @@ Result<ExecutionResult> WorkflowEngine::execute(const Workflow& workflow,
         }
         }
     }
     }
 
 
+    // The identity this workflow presents, if it names one. Refused the same
+    // way a missing anchor is: a server that asks for a client certificate and
+    // is given none refuses the connection, and "could not load the identity"
+    // is a far more useful thing to read than the handshake failure that would
+    // follow.
+    {
+        const std::string identity_id =
+            workflow.settings.is_object()
+                ? workflow.settings.value("clientCertificateId", std::string())
+                : std::string();
+        if (!identity_id.empty()) {
+            if (!client_certificate_callback_) {
+                result.status = ExecutionStatus::Failed;
+                result.error = "This workflow presents a client certificate, but the runner has "
+                               "no credential service configured to fetch it";
+            } else {
+                auto identity = client_certificate_callback_(identity_id, workflow.id);
+                if (identity.failed()) {
+                    result.status = ExecutionStatus::Failed;
+                    result.error = "Could not load the client certificate " + identity_id + ": " +
+                                   identity.error().message();
+                } else {
+                    std::lock_guard<std::mutex> lock(mutex_);
+                    execution_tls_identity_[result.execution_id] = identity.value();
+                }
+            }
+
+            if (result.status == ExecutionStatus::Failed) {
+                result.finished_at = TimeUtils::nowMs();
+                storeExecution(result);
+                --active_count_;
+                {
+                    std::lock_guard<std::mutex> lock(mutex_);
+                    active_executions_.erase(result.execution_id);
+                    execution_tls_anchors_.erase(result.execution_id);
+                }
+                LOG_ERROR("Execution {} refused: {}", result.execution_id, result.error);
+                if (callback) {
+                    callback("execution.failed", {
+                        {"executionId", result.execution_id},
+                        {"workflowId", workflow.id},
+                        {"error", result.error}
+                    });
+                }
+                return result;
+            }
+        }
+    }
+
     if (callback) {
     if (callback) {
         callback("execution.started", {
         callback("execution.started", {
             {"executionId", result.execution_id},
             {"executionId", result.execution_id},
@@ -1120,6 +1169,7 @@ Result<ExecutionResult> WorkflowEngine::execute(const Workflow& workflow,
         std::lock_guard<std::mutex> lock(mutex_);
         std::lock_guard<std::mutex> lock(mutex_);
         active_executions_.erase(result.execution_id);
         active_executions_.erase(result.execution_id);
         execution_tls_anchors_.erase(result.execution_id);
         execution_tls_anchors_.erase(result.execution_id);
+        execution_tls_identity_.erase(result.execution_id);
         cancelled_executions_.erase(result.execution_id);
         cancelled_executions_.erase(result.execution_id);
     }
     }
 
 
@@ -1681,6 +1731,10 @@ NodeExecutionResult WorkflowEngine::executeNode(const WorkflowNode& node,
         if (anchors != execution_tls_anchors_.end()) {
         if (anchors != execution_tls_anchors_.end()) {
             ctx.tls_anchors = anchors->second;
             ctx.tls_anchors = anchors->second;
         }
         }
+        auto identity = execution_tls_identity_.find(execution_id);
+        if (identity != execution_tls_identity_.end()) {
+            ctx.tls_client_identity = identity->second;
+        }
     }
     }
 
 
     ctx.log_handler = [&node](const std::string& level, const std::string& msg) {
     ctx.log_handler = [&node](const std::string& level, const std::string& msg) {

+ 12 - 0
src/runner/workflow_engine.hpp

@@ -215,6 +215,11 @@ using SmtpCredentialCallback = std::function<common::Result<engine::SmtpCredenti
 using ImapCredentialCallback = std::function<common::Result<engine::ImapCredential>(
 using ImapCredentialCallback = std::function<common::Result<engine::ImapCredential>(
     const std::string& credential_id, const std::string& workflow_id)>;
     const std::string& credential_id, const std::string& workflow_id)>;
 
 
+// The mTLS identity a workflow presents, fetched through the credential
+// service like every other secret.
+using ClientCertificateCallback = std::function<common::Result<engine::TlsClientIdentity>(
+    const std::string& credential_id, const std::string& workflow_id)>;
+
 // MySQL credential callback type
 // MySQL credential callback type
 using MysqlCredentialCallback = std::function<common::Result<engine::MysqlCredential>(
 using MysqlCredentialCallback = std::function<common::Result<engine::MysqlCredential>(
     const std::string& credential_id, const std::string& workflow_id)>;
     const std::string& credential_id, const std::string& workflow_id)>;
@@ -283,6 +288,7 @@ public:
 
 
     // Set IMAP credential callback (called by runner service to provide IMAP credential access)
     // Set IMAP credential callback (called by runner service to provide IMAP credential access)
     void setImapCredentialCallback(ImapCredentialCallback callback) { imap_credential_callback_ = callback; }
     void setImapCredentialCallback(ImapCredentialCallback callback) { imap_credential_callback_ = callback; }
+    void setClientCertificateCallback(ClientCertificateCallback callback) { client_certificate_callback_ = callback; }
     void setSmtpCredentialCallback(SmtpCredentialCallback callback) { smtp_credential_callback_ = callback; }
     void setSmtpCredentialCallback(SmtpCredentialCallback callback) { smtp_credential_callback_ = callback; }
 
 
     // Set MySQL credential callback (called by runner service to provide MySQL credential access)
     // Set MySQL credential callback (called by runner service to provide MySQL credential access)
@@ -526,6 +532,7 @@ private:
     std::unique_ptr<CollectionPermissions> collection_permissions_;
     std::unique_ptr<CollectionPermissions> collection_permissions_;
     CredentialAuthCallback credential_auth_callback_;
     CredentialAuthCallback credential_auth_callback_;
     ImapCredentialCallback imap_credential_callback_;
     ImapCredentialCallback imap_credential_callback_;
+    ClientCertificateCallback client_certificate_callback_;
     SmtpCredentialCallback smtp_credential_callback_;
     SmtpCredentialCallback smtp_credential_callback_;
     MysqlCredentialCallback mysql_credential_callback_;
     MysqlCredentialCallback mysql_credential_callback_;
     MysqlQueryCallback mysql_query_callback_;
     MysqlQueryCallback mysql_query_callback_;
@@ -540,6 +547,11 @@ private:
     // run's state, because the engine is shared by concurrent executions and
     // run's state, because the engine is shared by concurrent executions and
     // one run's trust must never leak into another's.
     // one run's trust must never leak into another's.
     std::unordered_map<std::string, std::vector<engine::TlsAnchor>> execution_tls_anchors_;
     std::unordered_map<std::string, std::vector<engine::TlsAnchor>> execution_tls_anchors_;
+
+    // The identity each running execution presents, resolved with its anchors
+    // and cleared with them. Held in memory only - a private key is never
+    // written anywhere by this process.
+    std::unordered_map<std::string, engine::TlsClientIdentity> execution_tls_identity_;
     std::unordered_set<std::string> cancelled_executions_;
     std::unordered_set<std::string> cancelled_executions_;
 
 
     // Which executions were started by which. A workflow called as a step runs
     // Which executions were started by which. A workflow called as a step runs

+ 26 - 0
src/webserver/grpc/credential_service.cpp

@@ -59,6 +59,32 @@ CredentialServiceImpl::CredentialServiceImpl(CredentialStore& credential_store)
     return ::grpc::Status::OK;
     return ::grpc::Status::OK;
 }
 }
 
 
+::grpc::Status CredentialServiceImpl::GetClientCertificate(
+    ::grpc::ServerContext* context,
+    const proto::GetClientCertificateRequest* request,
+    proto::GetClientCertificateResponse* response) {
+
+    LOG_DEBUG("GetClientCertificate request: credential={} workflow={}",
+              request->credential_id(), request->workflow_id());
+
+    auto result = credential_store_.getClientCertificate(request->credential_id(),
+                                                         request->workflow_id());
+
+    if (result.failed()) {
+        response->set_success(false);
+        response->set_error(result.error().message());
+        LOG_WARN("GetClientCertificate failed: {}", result.error().message());
+        return ::grpc::Status::OK;
+    }
+
+    response->set_success(true);
+    response->set_certificate_pem(result.value().certificate_pem);
+    response->set_private_key_pem(result.value().private_key_pem);
+    response->set_passphrase(result.value().passphrase);
+
+    return ::grpc::Status::OK;
+}
+
 ::grpc::Status CredentialServiceImpl::GetSmtpCredentials(
 ::grpc::Status CredentialServiceImpl::GetSmtpCredentials(
     ::grpc::ServerContext* context,
     ::grpc::ServerContext* context,
     const proto::GetSmtpCredentialsRequest* request,
     const proto::GetSmtpCredentialsRequest* request,

+ 5 - 0
src/webserver/grpc/credential_service.hpp

@@ -24,6 +24,11 @@ public:
         const proto::GetImapCredentialsRequest* request,
         const proto::GetImapCredentialsRequest* request,
         proto::GetImapCredentialsResponse* response) override;
         proto::GetImapCredentialsResponse* response) override;
 
 
+    ::grpc::Status GetClientCertificate(
+        ::grpc::ServerContext* context,
+        const proto::GetClientCertificateRequest* request,
+        proto::GetClientCertificateResponse* response) override;
+
     ::grpc::Status GetSmtpCredentials(
     ::grpc::Status GetSmtpCredentials(
         ::grpc::ServerContext* context,
         ::grpc::ServerContext* context,
         const proto::GetSmtpCredentialsRequest* request,
         const proto::GetSmtpCredentialsRequest* request,