|
@@ -23,6 +23,28 @@ void DatabaseController::registerRoutes(httplib::Server& server) {
|
|
|
});
|
|
});
|
|
|
});
|
|
});
|
|
|
|
|
|
|
|
|
|
+ // One collection: size, document count, TTL, versioning
|
|
|
|
|
+ server.Get(R"(/api/v1/database/collections/([^/]+))",
|
|
|
|
|
+ [this](const httplib::Request& req, httplib::Response& res) {
|
|
|
|
|
+ middleware_.requireRole(req, res, "admin", [this](auto& req, auto& res, auto& ctx) {
|
|
|
|
|
+ getCollection(req, res, ctx);
|
|
|
|
|
+ });
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ server.Delete(R"(/api/v1/database/collections/([^/]+))",
|
|
|
|
|
+ [this](const httplib::Request& req, httplib::Response& res) {
|
|
|
|
|
+ middleware_.requireRole(req, res, "admin", [this](auto& req, auto& res, auto& ctx) {
|
|
|
|
|
+ deleteCollection(req, res, ctx);
|
|
|
|
|
+ });
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ server.Put(R"(/api/v1/database/collections/([^/]+)/config)",
|
|
|
|
|
+ [this](const httplib::Request& req, httplib::Response& res) {
|
|
|
|
|
+ middleware_.requireRole(req, res, "admin", [this](auto& req, auto& res, auto& ctx) {
|
|
|
|
|
+ updateCollectionConfig(req, res, ctx);
|
|
|
|
|
+ });
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
// Get documents in a collection
|
|
// Get documents in a collection
|
|
|
server.Get(R"(/api/v1/database/collections/([^/]+)/documents)",
|
|
server.Get(R"(/api/v1/database/collections/([^/]+)/documents)",
|
|
|
[this](const httplib::Request& req, httplib::Response& res) {
|
|
[this](const httplib::Request& req, httplib::Response& res) {
|
|
@@ -64,6 +86,162 @@ void DatabaseController::registerRoutes(httplib::Server& server) {
|
|
|
});
|
|
});
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+
|
|
|
|
|
+// ---------------------------------------------------------------------------
|
|
|
|
|
+// What this API may touch
|
|
|
|
|
+//
|
|
|
|
|
+// Being an admin is permission to look after the data, not permission to read
|
|
|
|
|
+// everybody's credentials or to drop the table the scheduler reads from. These
|
|
|
|
|
+// collections belong to the services; the database page is for the collections
|
|
|
|
|
+// workflows create.
|
|
|
|
|
+// ---------------------------------------------------------------------------
|
|
|
|
|
+
|
|
|
|
|
+DatabaseController::Protection DatabaseController::protectionFor(const std::string& collection) {
|
|
|
|
|
+ // A caller may address a collection with its project prefix. The protection
|
|
|
|
|
+ // is about which collection it is, not how it was spelled.
|
|
|
|
|
+ std::string name = collection;
|
|
|
|
|
+ const auto colon = name.rfind(':');
|
|
|
|
|
+ if (colon != std::string::npos) name = name.substr(colon + 1);
|
|
|
|
|
+
|
|
|
|
|
+ // Password hashes, session tokens and encrypted credentials. Readable
|
|
|
|
|
+ // through this API means copyable out of it.
|
|
|
|
|
+ static const std::unordered_set<std::string> SECRET = {
|
|
|
|
|
+ "users", "sessions", "api_keys", "credentials", "collection_permissions"
|
|
|
|
|
+ };
|
|
|
|
|
+ // The services read and write these themselves. Editing a workflow document
|
|
|
|
|
+ // by hand here would sidestep every check the workflow endpoints make, and
|
|
|
|
|
+ // dropping one would take the installation apart.
|
|
|
|
|
+ static const std::unordered_set<std::string> STRUCTURAL = {
|
|
|
|
|
+ "workflows", "workflow_groups", "executions", "runners", "nodes"
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ if (SECRET.contains(name)) return Protection::Secret;
|
|
|
|
|
+ if (STRUCTURAL.contains(name)) return Protection::Structural;
|
|
|
|
|
+ return Protection::None;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+bool DatabaseController::refuseIfProtected(httplib::Response& res,
|
|
|
|
|
+ const std::string& collection,
|
|
|
|
|
+ bool writing) {
|
|
|
|
|
+ switch (protectionFor(collection)) {
|
|
|
|
|
+ case Protection::Secret:
|
|
|
|
|
+ sendError(res, "\"" + collection + "\" holds credentials or session material and "
|
|
|
|
|
+ "is not available through the database API", 403);
|
|
|
|
|
+ return true;
|
|
|
|
|
+ case Protection::Structural:
|
|
|
|
|
+ if (!writing) return false;
|
|
|
|
|
+ sendError(res, "\"" + collection + "\" belongs to SmartBotic itself. It can be read "
|
|
|
|
|
+ "here, but changing it has to go through the endpoints that own it", 403);
|
|
|
|
|
+ return true;
|
|
|
|
|
+ case Protection::None:
|
|
|
|
|
+ return false;
|
|
|
|
|
+ }
|
|
|
|
|
+ return false;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+void DatabaseController::getCollection(const httplib::Request& req, httplib::Response& res,
|
|
|
|
|
+ const auth::AuthContext& ctx) {
|
|
|
|
|
+ (void)ctx;
|
|
|
|
|
+ const std::string name = req.matches[1];
|
|
|
|
|
+
|
|
|
|
|
+ auto info = storage_.getCollectionInfo(name);
|
|
|
|
|
+ if (info.failed()) {
|
|
|
|
|
+ sendError(res, info.error().message(), 404);
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ auto cfg = storage_.getCollectionConfig(name);
|
|
|
|
|
+
|
|
|
|
|
+ nlohmann::json out = {
|
|
|
|
|
+ {"name", info.value().name},
|
|
|
|
|
+ {"documentCount", info.value().document_count},
|
|
|
|
|
+ {"sizeBytes", info.value().size_bytes},
|
|
|
|
|
+ {"defaultTtlMs", info.value().default_ttl_ms},
|
|
|
|
|
+ {"encrypted", info.value().encrypted},
|
|
|
|
|
+ {"maxVersions", info.value().max_versions},
|
|
|
|
|
+ {"createdAt", info.value().created_at},
|
|
|
|
|
+ {"updatedAt", info.value().updated_at},
|
|
|
|
|
+ {"protection", protectionFor(name) == Protection::Secret ? "secret"
|
|
|
|
|
+ : protectionFor(name) == Protection::Structural ? "structural" : "none"},
|
|
|
|
|
+ };
|
|
|
|
|
+ if (cfg.ok()) {
|
|
|
|
|
+ out["timestampPrecision"] = cfg.value().timestamp_precision;
|
|
|
|
|
+ if (cfg.value().versioning_enabled.has_value()) {
|
|
|
|
|
+ out["versioningEnabled"] = *cfg.value().versioning_enabled;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ sendJson(res, out);
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+void DatabaseController::deleteCollection(const httplib::Request& req, httplib::Response& res,
|
|
|
|
|
+ const auth::AuthContext& ctx) {
|
|
|
|
|
+ const std::string name = req.matches[1];
|
|
|
|
|
+
|
|
|
|
|
+ if (refuseIfProtected(res, name, /*writing=*/true)) return;
|
|
|
|
|
+
|
|
|
|
|
+ // Dropping takes the documents with it and there is no undo, so the caller
|
|
|
|
|
+ // has to name what it is dropping. A stray DELETE on the collections URL
|
|
|
|
|
+ // should not be able to empty the database.
|
|
|
|
|
+ if (req.get_param_value("confirm") != name) {
|
|
|
|
|
+ sendError(res, "Add ?confirm=<collection name> to drop a collection - this deletes "
|
|
|
|
|
+ "every document in it and cannot be undone", 400);
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ auto info = storage_.getCollectionInfo(name);
|
|
|
|
|
+ const uint64_t had = info.ok() ? info.value().document_count : 0;
|
|
|
|
|
+
|
|
|
|
|
+ auto dropped = storage_.dropCollection(name);
|
|
|
|
|
+ if (dropped.failed()) {
|
|
|
|
|
+ sendError(res, dropped.error().message(), 500);
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ LOG_WARN("Collection {} dropped by {} ({} documents)", name, ctx.username, had);
|
|
|
|
|
+ sendJson(res, {{"success", true}, {"name", name}, {"documentsRemoved", had}});
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+void DatabaseController::updateCollectionConfig(const httplib::Request& req, httplib::Response& res,
|
|
|
|
|
+ const auth::AuthContext& ctx) {
|
|
|
|
|
+ const std::string name = req.matches[1];
|
|
|
|
|
+
|
|
|
|
|
+ if (refuseIfProtected(res, name, /*writing=*/true)) return;
|
|
|
|
|
+
|
|
|
|
|
+ nlohmann::json body;
|
|
|
|
|
+ try {
|
|
|
|
|
+ body = nlohmann::json::parse(req.body);
|
|
|
|
|
+ } catch (...) {
|
|
|
|
|
+ sendError(res, "Invalid JSON body", 400);
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ storage::CollectionConfig cfg;
|
|
|
|
|
+ if (body.contains("versioningEnabled") && body["versioningEnabled"].is_boolean()) {
|
|
|
|
|
+ cfg.versioning_enabled = body["versioningEnabled"].get<bool>();
|
|
|
|
|
+ }
|
|
|
|
|
+ if (body.contains("timestampPrecision") && body["timestampPrecision"].is_string()) {
|
|
|
|
|
+ const std::string precision = body["timestampPrecision"];
|
|
|
|
|
+ if (precision != "ms" && precision != "ns") {
|
|
|
|
|
+ sendError(res, "timestampPrecision has to be \"ms\" or \"ns\"", 400);
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ cfg.timestamp_precision = precision;
|
|
|
|
|
+ }
|
|
|
|
|
+ if (!cfg.versioning_enabled.has_value() && cfg.timestamp_precision.empty()) {
|
|
|
|
|
+ sendError(res, "Nothing to change - send versioningEnabled or timestampPrecision", 400);
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ auto configured = storage_.configureCollection(name, cfg);
|
|
|
|
|
+ if (configured.failed()) {
|
|
|
|
|
+ sendError(res, configured.error().message(), 500);
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ LOG_INFO("Collection {} reconfigured by {}", name, ctx.username);
|
|
|
|
|
+ getCollection(req, res, ctx);
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
void DatabaseController::listCollections(const httplib::Request& req, httplib::Response& res,
|
|
void DatabaseController::listCollections(const httplib::Request& req, httplib::Response& res,
|
|
|
const auth::AuthContext& ctx) {
|
|
const auth::AuthContext& ctx) {
|
|
|
auto collections = storage_.listCollections();
|
|
auto collections = storage_.listCollections();
|
|
@@ -126,6 +304,7 @@ void DatabaseController::createCollection(const httplib::Request& req, httplib::
|
|
|
void DatabaseController::getDocuments(const httplib::Request& req, httplib::Response& res,
|
|
void DatabaseController::getDocuments(const httplib::Request& req, httplib::Response& res,
|
|
|
const auth::AuthContext& ctx) {
|
|
const auth::AuthContext& ctx) {
|
|
|
std::string collection = req.matches[1];
|
|
std::string collection = req.matches[1];
|
|
|
|
|
+ if (refuseIfProtected(res, collection, /*writing=*/false)) return;
|
|
|
|
|
|
|
|
int page = 1;
|
|
int page = 1;
|
|
|
int page_size = 50;
|
|
int page_size = 50;
|
|
@@ -165,6 +344,7 @@ void DatabaseController::getDocument(const httplib::Request& req, httplib::Respo
|
|
|
const auth::AuthContext& ctx) {
|
|
const auth::AuthContext& ctx) {
|
|
|
std::string collection = req.matches[1];
|
|
std::string collection = req.matches[1];
|
|
|
std::string id = req.matches[2];
|
|
std::string id = req.matches[2];
|
|
|
|
|
+ if (refuseIfProtected(res, collection, /*writing=*/false)) return;
|
|
|
|
|
|
|
|
auto result = storage_.get(collection, id);
|
|
auto result = storage_.get(collection, id);
|
|
|
|
|
|
|
@@ -179,6 +359,7 @@ void DatabaseController::getDocument(const httplib::Request& req, httplib::Respo
|
|
|
void DatabaseController::createDocument(const httplib::Request& req, httplib::Response& res,
|
|
void DatabaseController::createDocument(const httplib::Request& req, httplib::Response& res,
|
|
|
const auth::AuthContext& ctx) {
|
|
const auth::AuthContext& ctx) {
|
|
|
std::string collection = req.matches[1];
|
|
std::string collection = req.matches[1];
|
|
|
|
|
+ if (refuseIfProtected(res, collection, /*writing=*/true)) return;
|
|
|
|
|
|
|
|
nlohmann::json body;
|
|
nlohmann::json body;
|
|
|
try {
|
|
try {
|
|
@@ -205,6 +386,7 @@ void DatabaseController::updateDocument(const httplib::Request& req, httplib::Re
|
|
|
const auth::AuthContext& ctx) {
|
|
const auth::AuthContext& ctx) {
|
|
|
std::string collection = req.matches[1];
|
|
std::string collection = req.matches[1];
|
|
|
std::string id = req.matches[2];
|
|
std::string id = req.matches[2];
|
|
|
|
|
+ if (refuseIfProtected(res, collection, /*writing=*/true)) return;
|
|
|
|
|
|
|
|
nlohmann::json body;
|
|
nlohmann::json body;
|
|
|
try {
|
|
try {
|
|
@@ -231,6 +413,7 @@ void DatabaseController::deleteDocument(const httplib::Request& req, httplib::Re
|
|
|
const auth::AuthContext& ctx) {
|
|
const auth::AuthContext& ctx) {
|
|
|
std::string collection = req.matches[1];
|
|
std::string collection = req.matches[1];
|
|
|
std::string id = req.matches[2];
|
|
std::string id = req.matches[2];
|
|
|
|
|
+ if (refuseIfProtected(res, collection, /*writing=*/true)) return;
|
|
|
|
|
|
|
|
auto result = storage_.remove(collection, id);
|
|
auto result = storage_.remove(collection, id);
|
|
|
|
|
|