|
@@ -0,0 +1,369 @@
|
|
|
|
|
+#include "certificate_controller.hpp"
|
|
|
|
|
+
|
|
|
|
|
+#include <algorithm>
|
|
|
|
|
+
|
|
|
|
|
+#include "certs/pem_info.hpp"
|
|
|
|
|
+#include "common/uuid.hpp"
|
|
|
|
|
+#include "logging/logger.hpp"
|
|
|
|
|
+
|
|
|
|
|
+namespace smartbotic::webserver::api {
|
|
|
|
|
+
|
|
|
|
|
+CertificateController::CertificateController(storage::StorageClient& storage,
|
|
|
|
|
+ auth::AccessControl& access,
|
|
|
|
|
+ auth::AuthMiddleware& middleware)
|
|
|
|
|
+ : storage_(storage), access_(access), middleware_(middleware) {}
|
|
|
|
|
+
|
|
|
|
|
+void CertificateController::registerRoutes(httplib::Server& server) {
|
|
|
|
|
+ server.Get("/api/v1/certificates", [this](const httplib::Request& req, httplib::Response& res) {
|
|
|
|
|
+ middleware_.requireAuth(req, res, [this](auto& req, auto& res, auto& ctx) {
|
|
|
|
|
+ listCertificates(req, res, ctx);
|
|
|
|
|
+ });
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ server.Post("/api/v1/certificates", [this](const httplib::Request& req, httplib::Response& res) {
|
|
|
|
|
+ middleware_.requireAuth(req, res, [this](auto& req, auto& res, auto& ctx) {
|
|
|
|
|
+ createCertificate(req, res, ctx);
|
|
|
|
|
+ });
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ server.Get(R"(/api/v1/certificates/([^/]+))",
|
|
|
|
|
+ [this](const httplib::Request& req, httplib::Response& res) {
|
|
|
|
|
+ middleware_.requireAuth(req, res, [this](auto& req, auto& res, auto& ctx) {
|
|
|
|
|
+ getCertificate(req, res, ctx);
|
|
|
|
|
+ });
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ server.Put(R"(/api/v1/certificates/([^/]+))",
|
|
|
|
|
+ [this](const httplib::Request& req, httplib::Response& res) {
|
|
|
|
|
+ middleware_.requireAuth(req, res, [this](auto& req, auto& res, auto& ctx) {
|
|
|
|
|
+ updateCertificate(req, res, ctx);
|
|
|
|
|
+ });
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ server.Delete(R"(/api/v1/certificates/([^/]+))",
|
|
|
|
|
+ [this](const httplib::Request& req, httplib::Response& res) {
|
|
|
|
|
+ middleware_.requireAuth(req, res, [this](auto& req, auto& res, auto& ctx) {
|
|
|
|
|
+ deleteCertificate(req, res, ctx);
|
|
|
|
|
+ });
|
|
|
|
|
+ });
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+bool CertificateController::buildDocument(httplib::Response& res, const nlohmann::json& body,
|
|
|
|
|
+ nlohmann::json& doc) {
|
|
|
|
|
+ const std::string name = body.value("name", "");
|
|
|
|
|
+ if (name.empty()) {
|
|
|
|
|
+ sendError(res, "A name is required, so this can be told apart in a workflow's settings", 400);
|
|
|
|
|
+ return false;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ const std::string pem = body.value("pem", "");
|
|
|
|
|
+ auto parsed = certs::parsePem(pem);
|
|
|
|
|
+ if (parsed.failed()) {
|
|
|
|
|
+ // Refused rather than stored. A certificate that cannot be parsed
|
|
|
|
|
+ // cannot be applied either, and storing it would leave a workflow
|
|
|
|
|
+ // trusting something that silently does nothing at request time.
|
|
|
|
|
+ sendError(res, parsed.error().message(), 400);
|
|
|
|
|
+ return false;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ if (!body.contains("hosts") || !body["hosts"].is_array() || body["hosts"].empty()) {
|
|
|
|
|
+ sendError(res,
|
|
|
|
|
+ "Name at least one host this certificate is for, such as "
|
|
|
|
|
+ "internal.example.com or *.example.com. A certificate with no hosts would "
|
|
|
|
|
+ "never be applied to anything.",
|
|
|
|
|
+ 400);
|
|
|
|
|
+ return false;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ std::vector<std::string> hosts;
|
|
|
|
|
+ for (const auto& entry : body["hosts"]) {
|
|
|
|
|
+ if (!entry.is_string()) {
|
|
|
|
|
+ sendError(res, "Each host has to be a string", 400);
|
|
|
|
|
+ return false;
|
|
|
|
|
+ }
|
|
|
|
|
+ std::string host = entry.get<std::string>();
|
|
|
|
|
+ // Trimmed here rather than at match time: a stored " example.com" would
|
|
|
|
|
+ // never match anything and the reason would not be visible on screen.
|
|
|
|
|
+ const auto first = host.find_first_not_of(" \t\r\n");
|
|
|
|
|
+ const auto last = host.find_last_not_of(" \t\r\n");
|
|
|
|
|
+ if (first == std::string::npos) {
|
|
|
|
|
+ continue;
|
|
|
|
|
+ }
|
|
|
|
|
+ host = host.substr(first, last - first + 1);
|
|
|
|
|
+
|
|
|
|
|
+ // A pattern with a wildcard anywhere but a single leading label does
|
|
|
|
|
+ // not mean what whoever typed it thinks it means, so it is refused
|
|
|
|
|
+ // rather than quietly never matching.
|
|
|
|
|
+ const auto star = host.find('*');
|
|
|
|
|
+ if (star != std::string::npos && host.rfind("*.", 0) != 0) {
|
|
|
|
|
+ sendError(res,
|
|
|
|
|
+ "\"" + host + "\" is not a host pattern this understands. A wildcard is "
|
|
|
|
|
+ "allowed only as a single leading label, like *.example.com.",
|
|
|
|
|
+ 400);
|
|
|
|
|
+ return false;
|
|
|
|
|
+ }
|
|
|
|
|
+ if (host == "*" || host == "*.") {
|
|
|
|
|
+ sendError(res, "A bare * would trust this certificate for every host, which "
|
|
|
|
|
+ "defeats naming hosts at all", 400);
|
|
|
|
|
+ return false;
|
|
|
|
|
+ }
|
|
|
|
|
+ hosts.push_back(host);
|
|
|
|
|
+ }
|
|
|
|
|
+ if (hosts.empty()) {
|
|
|
|
|
+ sendError(res, "Name at least one host this certificate is for", 400);
|
|
|
|
|
+ return false;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ doc["name"] = name;
|
|
|
|
|
+ doc["pem"] = pem;
|
|
|
|
|
+ doc["hosts"] = hosts;
|
|
|
|
|
+ doc["description"] = body.value("description", "");
|
|
|
|
|
+
|
|
|
|
|
+ // Parsed once, on write. A listing that had to parse every PEM to show an
|
|
|
|
|
+ // expiry would parse them all on every page.
|
|
|
|
|
+ nlohmann::json certificates = nlohmann::json::array();
|
|
|
|
|
+ for (const auto& info : parsed.value()) {
|
|
|
|
|
+ certificates.push_back(info.toJson());
|
|
|
|
|
+ }
|
|
|
|
|
+ doc["certificates"] = certificates;
|
|
|
|
|
+ // The leaf-most entry is what a reader means by "this certificate", and it
|
|
|
|
|
+ // is the one whose expiry matters first.
|
|
|
|
|
+ doc["subject"] = parsed.value().front().subject;
|
|
|
|
|
+ doc["issuer"] = parsed.value().front().issuer;
|
|
|
|
|
+ doc["notBefore"] = parsed.value().front().not_before_ms;
|
|
|
|
|
+ doc["notAfter"] = parsed.value().front().not_after_ms;
|
|
|
|
|
+ doc["fingerprintSha256"] = parsed.value().front().fingerprint_sha256;
|
|
|
|
|
+ doc["isCa"] = parsed.value().front().is_ca;
|
|
|
|
|
+ return true;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+void CertificateController::listCertificates(const httplib::Request& req, httplib::Response& res,
|
|
|
|
|
+ const auth::AuthContext& ctx) {
|
|
|
|
|
+ storage::QueryOptions options;
|
|
|
|
|
+ options.page_size = 500;
|
|
|
|
|
+ if (req.has_param("projectId")) {
|
|
|
|
|
+ options.filters.push_back({"projectId", req.get_param_value("projectId")});
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ auto result = storage_.query(kCollection, options);
|
|
|
|
|
+ if (result.failed()) {
|
|
|
|
|
+ // A collection that does not exist yet is an empty list, not an error -
|
|
|
|
|
+ // this is the first request on a fresh install.
|
|
|
|
|
+ sendJson(res, {{"certificates", nlohmann::json::array()}, {"total", 0}});
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ const auto usage = certificateUsage();
|
|
|
|
|
+
|
|
|
|
|
+ nlohmann::json visible = nlohmann::json::array();
|
|
|
|
|
+ for (auto doc : result.value().documents) {
|
|
|
|
|
+ if (!access_.allowed(ctx, auth::AccessControl::projectOf(doc), auth::Action::Read)) {
|
|
|
|
|
+ continue;
|
|
|
|
|
+ }
|
|
|
|
|
+ doc["usedByWorkflows"] = usage.count(doc.value("_id", "")) ? usage.at(doc.value("_id", "")) : 0;
|
|
|
|
|
+ visible.push_back(doc);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ sendJson(res, {{"certificates", visible}, {"total", visible.size()}});
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+void CertificateController::getCertificate(const httplib::Request& req, httplib::Response& res,
|
|
|
|
|
+ const auth::AuthContext& ctx) {
|
|
|
|
|
+ const std::string id = req.matches[1].str();
|
|
|
|
|
+ auto found = storage_.get(kCollection, id);
|
|
|
|
|
+ if (found.failed()) {
|
|
|
|
|
+ sendError(res, "Certificate not found", 404);
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ // 404 rather than 403 for somebody who cannot reach the project at all,
|
|
|
|
|
+ // matching every other listing here: whether a thing exists is only told to
|
|
|
|
|
+ // people who can see it.
|
|
|
|
|
+ if (!access_.allowed(ctx, auth::AccessControl::projectOf(found.value()), auth::Action::Read)) {
|
|
|
|
|
+ sendError(res, "Certificate not found", 404);
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ sendJson(res, found.value());
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+void CertificateController::createCertificate(const httplib::Request& req, httplib::Response& res,
|
|
|
|
|
+ const auth::AuthContext& ctx) {
|
|
|
|
|
+ nlohmann::json body;
|
|
|
|
|
+ try {
|
|
|
|
|
+ body = nlohmann::json::parse(req.body);
|
|
|
|
|
+ } catch (const std::exception&) {
|
|
|
|
|
+ sendError(res, "Invalid request body: not valid JSON", 400);
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ const std::string project_id = body.value("projectId", "");
|
|
|
|
|
+ if (project_id.empty()) {
|
|
|
|
|
+ sendError(res, "A projectId is required - a certificate belongs to a project", 400);
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ if (!access_.allowed(ctx, project_id, auth::Action::Write)) {
|
|
|
|
|
+ sendError(res, "You cannot add a certificate to that project", 403);
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ nlohmann::json doc;
|
|
|
|
|
+ if (!buildDocument(res, body, doc)) {
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ doc["projectId"] = project_id;
|
|
|
|
|
+ doc["createdBy"] = ctx.user_id;
|
|
|
|
|
+ doc["ownerId"] = ctx.user_id;
|
|
|
|
|
+
|
|
|
|
|
+ const std::string id = common::UUID::generatePrefixed("cert");
|
|
|
|
|
+ auto result = storage_.insert(kCollection, doc, id);
|
|
|
|
|
+ if (result.failed()) {
|
|
|
|
|
+ sendError(res, result.error().message(), 500);
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ LOG_INFO("Certificate {} ({}) created in project {} by {}", id, doc.value("name", ""),
|
|
|
|
|
+ project_id, ctx.username);
|
|
|
|
|
+
|
|
|
|
|
+ auto stored = storage_.get(kCollection, id);
|
|
|
|
|
+ sendJson(res, stored.ok() ? stored.value() : doc, 201);
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+void CertificateController::updateCertificate(const httplib::Request& req, httplib::Response& res,
|
|
|
|
|
+ const auth::AuthContext& ctx) {
|
|
|
|
|
+ const std::string id = req.matches[1].str();
|
|
|
|
|
+ auto existing = storage_.get(kCollection, id);
|
|
|
|
|
+ if (existing.failed()) {
|
|
|
|
|
+ sendError(res, "Certificate not found", 404);
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ const std::string project_id = auth::AccessControl::projectOf(existing.value());
|
|
|
|
|
+ if (!access_.allowed(ctx, project_id, auth::Action::Read)) {
|
|
|
|
|
+ sendError(res, "Certificate not found", 404);
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ if (!access_.allowed(ctx, project_id, auth::Action::Write)) {
|
|
|
|
|
+ sendError(res, "You cannot change this certificate", 403);
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ nlohmann::json body;
|
|
|
|
|
+ try {
|
|
|
|
|
+ body = nlohmann::json::parse(req.body);
|
|
|
|
|
+ } catch (const std::exception&) {
|
|
|
|
|
+ sendError(res, "Invalid request body: not valid JSON", 400);
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // Whatever the body leaves out keeps its stored value, so a caller that
|
|
|
|
|
+ // only wants to rename it does not have to send the PEM back.
|
|
|
|
|
+ if (!body.contains("pem")) body["pem"] = existing.value().value("pem", "");
|
|
|
|
|
+ if (!body.contains("hosts")) body["hosts"] = existing.value().value("hosts", nlohmann::json::array());
|
|
|
|
|
+ if (!body.contains("name")) body["name"] = existing.value().value("name", "");
|
|
|
|
|
+ if (!body.contains("description")) body["description"] = existing.value().value("description", "");
|
|
|
|
|
+
|
|
|
|
|
+ nlohmann::json doc;
|
|
|
|
|
+ if (!buildDocument(res, body, doc)) {
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ // The project a certificate belongs to is not moved by an edit. Moving it
|
|
|
|
|
+ // would change who can use it, which is a different act and would need the
|
|
|
|
|
+ // target project checked as well.
|
|
|
|
|
+ doc["projectId"] = project_id;
|
|
|
|
|
+ doc["createdBy"] = existing.value().value("createdBy", "");
|
|
|
|
|
+ doc["ownerId"] = existing.value().value("ownerId", existing.value().value("createdBy", ""));
|
|
|
|
|
+
|
|
|
|
|
+ auto result = storage_.update(kCollection, id, doc);
|
|
|
|
|
+ if (result.failed()) {
|
|
|
|
|
+ sendError(res, result.error().message(), 500);
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ LOG_INFO("Certificate {} updated by {}", id, ctx.username);
|
|
|
|
|
+ auto stored = storage_.get(kCollection, id);
|
|
|
|
|
+ sendJson(res, stored.ok() ? stored.value() : doc);
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+void CertificateController::deleteCertificate(const httplib::Request& req, httplib::Response& res,
|
|
|
|
|
+ const auth::AuthContext& ctx) {
|
|
|
|
|
+ const std::string id = req.matches[1].str();
|
|
|
|
|
+ auto existing = storage_.get(kCollection, id);
|
|
|
|
|
+ if (existing.failed()) {
|
|
|
|
|
+ sendError(res, "Certificate not found", 404);
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ const std::string project_id = auth::AccessControl::projectOf(existing.value());
|
|
|
|
|
+ if (!access_.allowed(ctx, project_id, auth::Action::Read)) {
|
|
|
|
|
+ sendError(res, "Certificate not found", 404);
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ if (!access_.allowed(ctx, project_id, auth::Action::Manage)) {
|
|
|
|
|
+ sendError(res, "You cannot delete this certificate", 403);
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // A workflow that names a deleted certificate refuses to run rather than
|
|
|
|
|
+ // running with less trust than its author asked for, so deleting one in use
|
|
|
|
|
+ // breaks those runs. Said plainly here, before it happens, rather than
|
|
|
|
|
+ // discovered at three in the morning.
|
|
|
|
|
+ const auto usage = certificateUsage();
|
|
|
|
|
+ const auto in_use = usage.find(id);
|
|
|
|
|
+ if (in_use != usage.end() && in_use->second > 0 &&
|
|
|
|
|
+ req.get_param_value("force") != "true") {
|
|
|
|
|
+ sendError(res,
|
|
|
|
|
+ "This certificate is assigned to " + std::to_string(in_use->second) +
|
|
|
|
|
+ " workflow(s), which will refuse to run without it. Remove it from them "
|
|
|
|
|
+ "first, or repeat this with ?force=true.",
|
|
|
|
|
+ 409);
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ auto result = storage_.remove(kCollection, id);
|
|
|
|
|
+ if (result.failed()) {
|
|
|
|
|
+ sendError(res, result.error().message(), 500);
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ LOG_INFO("Certificate {} ({}) deleted by {}", id, existing.value().value("name", ""),
|
|
|
|
|
+ ctx.username);
|
|
|
|
|
+ sendJson(res, {{"success", true}});
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+std::unordered_map<std::string, int> CertificateController::certificateUsage() {
|
|
|
|
|
+ std::unordered_map<std::string, int> usage;
|
|
|
|
|
+
|
|
|
|
|
+ storage::QueryOptions options;
|
|
|
|
|
+ options.page_size = 1000;
|
|
|
|
|
+ options.fields = {"settings"};
|
|
|
|
|
+ auto workflows = storage_.query("workflows", options);
|
|
|
|
|
+ if (workflows.failed()) {
|
|
|
|
|
+ return usage;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ for (const auto& wf : workflows.value().documents) {
|
|
|
|
|
+ if (!wf.contains("settings") || !wf["settings"].is_object()) {
|
|
|
|
|
+ continue;
|
|
|
|
|
+ }
|
|
|
|
|
+ const auto& settings = wf["settings"];
|
|
|
|
|
+ if (settings.contains("certificateIds") && settings["certificateIds"].is_array()) {
|
|
|
|
|
+ for (const auto& entry : settings["certificateIds"]) {
|
|
|
|
|
+ if (entry.is_string()) {
|
|
|
|
|
+ ++usage[entry.get<std::string>()];
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ return usage;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+void CertificateController::sendJson(httplib::Response& res, const nlohmann::json& data,
|
|
|
|
|
+ int status) {
|
|
|
|
|
+ res.status = status;
|
|
|
|
|
+ res.set_content(data.dump(), "application/json");
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+void CertificateController::sendError(httplib::Response& res, const std::string& message,
|
|
|
|
|
+ int status) {
|
|
|
|
|
+ res.status = status;
|
|
|
|
|
+ res.set_content(nlohmann::json{{"error", message}}.dump(), "application/json");
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+} // namespace smartbotic::webserver::api
|