Răsfoiți Sursa

feat: add ownership transfer for workflows and folders

Add POST /api/v1/workflows/{id}/transfer-owner and POST
/api/v1/workflow-groups/{id}/transfer-owner (with includeWorkflows) so an
ownerId can be changed once set. This is attribution, not authority -
access.cpp never consults ownerId to decide who may read, edit, run or
delete something, only the caller's role in the containing project. Both
endpoints require Action::Manage (project admin) on that project, validate
the new owner is a real user, and refuse the transfer if that user cannot
reach the project rather than writing an ownerId nobody with that id can
see. Folder transfers only move workflows directly in the folder, skip and
separately report any already owned by someone other than the folder's
previous owner, and count how many actually moved.
fszontagh 1 lună în urmă
părinte
comite
dc1ed5b850

+ 76 - 0
src/webserver/api/workflow_controller.cpp

@@ -150,6 +150,12 @@ void WorkflowController::registerRoutes(httplib::Server& server) {
         });
     });
 
+    server.Post(R"(/api/v1/workflows/([^/]+)/transfer-owner)", [this](const httplib::Request& req, httplib::Response& res) {
+        middleware_.requireAuth(req, res, [this](auto& req, auto& res, auto& ctx) {
+            transferWorkflowOwner(req, res, ctx);
+        });
+    });
+
     // Scheduler status endpoint
     server.Get("/api/v1/scheduler/status", [this](const httplib::Request& req, httplib::Response& res) {
         middleware_.requireAuth(req, res, [this](auto& req, auto& res, auto& ctx) {
@@ -984,6 +990,76 @@ void WorkflowController::deactivateWorkflow(const httplib::Request& req, httplib
     sendJson(res, {{"success", true}, {"active", false}});
 }
 
+bool WorkflowController::checkTransferTarget(httplib::Response& res, const std::string& new_owner_id,
+                                             const std::string& project_id) {
+    if (new_owner_id.empty()) {
+        sendError(res, "newOwnerId is required", 400);
+        return false;
+    }
+
+    auto user = storage_.get("users", new_owner_id);
+    if (user.failed()) {
+        sendError(res, "Unknown user id", 400);
+        return false;
+    }
+
+    // The new owner has to be somebody who can actually reach the project
+    // this thing lives in. A personal project cannot have members, so this
+    // also means: inside a personal project, only that project's own owner
+    // is a valid target. Refusing here, rather than transferring anyway, is
+    // deliberate - a transfer that leaves a record naming somebody who
+    // cannot see it is worse than an error that says why.
+    auth::AuthContext target_ctx;
+    target_ctx.user_id = new_owner_id;
+    target_ctx.role = user.value().value("role", "user");
+    if (access_.roleIn(target_ctx, project_id) == auth::ProjectRole::None) {
+        sendError(res, "That user has no access to the project this belongs to. "
+                       "Add them to the project before transferring ownership to them.", 400);
+        return false;
+    }
+    return true;
+}
+
+void WorkflowController::transferWorkflowOwner(const httplib::Request& req, httplib::Response& res,
+                                               const auth::AuthContext& ctx) {
+    std::string id = req.matches[1];
+
+    // Manage, not Write: transferring somebody's work is an administrative
+    // act on the project, not an edit to the workflow itself.
+    nlohmann::json existing;
+    if (!loadAllowed(req, res, ctx, id, auth::Action::Manage, existing)) return;
+
+    nlohmann::json body;
+    try {
+        body = nlohmann::json::parse(req.body);
+    } catch (const std::exception&) {
+        sendError(res, "Invalid request body", 400);
+        return;
+    }
+
+    const std::string new_owner_id = body.value("newOwnerId", "");
+    const std::string project = auth::AccessControl::projectOf(existing);
+    if (!checkTransferTarget(res, new_owner_id, project)) return;
+
+    nlohmann::json patch;
+    patch["ownerId"] = new_owner_id;
+    auto result = storage_.update("workflows", id, patch, 0, true);
+    if (result.failed()) {
+        sendError(res, result.error().message(), 500);
+        return;
+    }
+
+    auto workflow = storage_.get("workflows", id);
+    if (workflow.ok()) {
+        ws_server_.broadcast("workflows.updated", workflow.value());
+        LOG_INFO("Workflow {} ownership transferred from {} to {} by {}", id,
+                 existing.value("ownerId", ""), new_owner_id, ctx.user_id);
+        sendJson(res, workflow.value());
+    } else {
+        sendJson(res, {{"id", id}, {"ownerId", new_owner_id}});
+    }
+}
+
 void WorkflowController::updateScheduledTriggers(const std::string& workflow_id, bool activate) {
     if (!activate) {
         scheduler_.unregisterWorkflow(workflow_id);

+ 12 - 0
src/webserver/api/workflow_controller.hpp

@@ -63,6 +63,13 @@ private:
     void deactivateWorkflow(const httplib::Request& req, httplib::Response& res,
                             const auth::AuthContext& ctx);
 
+    // Attribution, not authority: ownerId is never consulted to decide who
+    // may read, edit, run or delete a workflow - that comes from the
+    // caller's role in the project it belongs to. This changes who the
+    // workflow is attributed to, nothing about who can act on it.
+    void transferWorkflowOwner(const httplib::Request& req, httplib::Response& res,
+                               const auth::AuthContext& ctx);
+
     void sendJson(httplib::Response& res, const nlohmann::json& data, int status = 200);
     void sendError(httplib::Response& res, const std::string& message, int status);
 
@@ -91,6 +98,11 @@ private:
     bool loadAllowed(const httplib::Request& req, httplib::Response& res,
                      const auth::AuthContext& ctx, const std::string& id,
                      auth::Action action, nlohmann::json& out);
+
+    // Whether newOwnerId names an account that exists and can reach
+    // project_id. On failure, writes the error response itself.
+    bool checkTransferTarget(httplib::Response& res, const std::string& new_owner_id,
+                             const std::string& project_id);
 };
 
 } // namespace smartbotic::webserver::api

+ 121 - 0
src/webserver/api/workflow_group_controller.cpp

@@ -64,6 +64,14 @@ void WorkflowGroupController::registerRoutes(httplib::Server& server) {
             moveGroup(req, res, ctx);
         });
     });
+
+    // Transfer folder ownership, optionally taking its workflows with it -
+    // POST /api/v1/workflow-groups/:id/transfer-owner
+    server.Post(R"(/api/v1/workflow-groups/([^/]+)/transfer-owner)", [this](const httplib::Request& req, httplib::Response& res) {
+        middleware_.requireAuth(req, res, [this](auto& req, auto& res, auto& ctx) {
+            transferGroupOwner(req, res, ctx);
+        });
+    });
 }
 
 
@@ -471,6 +479,119 @@ void WorkflowGroupController::moveGroup(const httplib::Request& req, httplib::Re
     }
 }
 
+bool WorkflowGroupController::checkTransferTarget(httplib::Response& res, const std::string& new_owner_id,
+                                                  const std::string& project_id) {
+    if (new_owner_id.empty()) {
+        sendError(res, "newOwnerId is required", 400);
+        return false;
+    }
+
+    auto user = storage_.get("users", new_owner_id);
+    if (user.failed()) {
+        sendError(res, "Unknown user id", 400);
+        return false;
+    }
+
+    // The new owner has to be somebody who can actually reach the project
+    // this folder lives in. A personal project cannot have members, so this
+    // also means: inside a personal project, only that project's own owner
+    // is a valid target. Refusing here, rather than transferring anyway, is
+    // deliberate - a transfer that leaves a record naming somebody who
+    // cannot see it is worse than an error that says why.
+    auth::AuthContext target_ctx;
+    target_ctx.user_id = new_owner_id;
+    target_ctx.role = user.value().value("role", "user");
+    if (access_.roleIn(target_ctx, project_id) == auth::ProjectRole::None) {
+        sendError(res, "That user has no access to the project this belongs to. "
+                       "Add them to the project before transferring ownership to them.", 400);
+        return false;
+    }
+    return true;
+}
+
+void WorkflowGroupController::transferGroupOwner(const httplib::Request& req, httplib::Response& res,
+                                                  const auth::AuthContext& ctx) {
+    try {
+        std::string id = req.matches[1];
+
+        // Manage, not Write: transferring somebody's work is an
+        // administrative act on the project, not an edit to the folder.
+        nlohmann::json existing;
+        if (!loadAllowed(res, ctx, id, auth::Action::Manage, existing)) return;
+
+        auto body = nlohmann::json::parse(req.body);
+
+        const std::string new_owner_id = body.value("newOwnerId", "");
+        const std::string project = auth::AccessControl::projectOf(existing);
+        if (!checkTransferTarget(res, new_owner_id, project)) return;
+
+        const bool include_workflows = body.value("includeWorkflows", false);
+        const std::string previous_owner = existing.value("ownerId", "");
+
+        auto folder_result = storage_.update("workflow_groups", id, {{"ownerId", new_owner_id}}, 0, true);
+        if (folder_result.failed()) {
+            sendError(res, folder_result.error().message(), 500);
+            return;
+        }
+
+        // Only what is directly in this folder - a folder's ownership does
+        // not reach down into its subfolders' own workflows.
+        int transferred = 0;
+        nlohmann::json skipped = nlohmann::json::array();
+        if (include_workflows) {
+            storage::QueryOptions in_folder;
+            in_folder.page_size = 1000;
+            in_folder.filters.push_back({"groupId", id});
+            auto contents = storage_.query("workflows", in_folder);
+            if (contents.ok()) {
+                for (const auto& wf : contents.value().documents) {
+                    const std::string wf_id = wf.value("_id", "");
+                    if (wf_id.empty()) continue;
+
+                    const std::string wf_owner = wf.value("ownerId", "");
+                    // A workflow already owned by someone other than the
+                    // folder's previous owner is somebody else's work that
+                    // happens to sit in this folder. It is skipped rather
+                    // than swept up, and reported rather than left
+                    // unmentioned, so nobody's work is reassigned as a side
+                    // effect of moving the folder around it.
+                    if (!wf_owner.empty() && wf_owner != previous_owner) {
+                        skipped.push_back({
+                            {"id", wf_id},
+                            {"name", wf.value("name", "")},
+                            {"ownerId", wf_owner},
+                        });
+                        continue;
+                    }
+
+                    if (storage_.update("workflows", wf_id, {{"ownerId", new_owner_id}}, 0, true).ok()) {
+                        transferred++;
+                    }
+                }
+            }
+        }
+
+        auto group = storage_.get("workflow_groups", id);
+        nlohmann::json folder_json = group.ok() ? group.value() : nlohmann::json{{"id", id}};
+        if (group.ok()) {
+            ws_server_.broadcast("workflow_groups.updated", group.value());
+        }
+
+        LOG_INFO("Folder {} ownership transferred from {} to {} by {} ({} workflows transferred, "
+                 "{} skipped as foreign-owned)",
+                 id, previous_owner, new_owner_id, ctx.user_id, transferred, skipped.size());
+
+        sendJson(res, {
+            {"folder", folder_json},
+            {"includeWorkflows", include_workflows},
+            {"transferredWorkflowCount", transferred},
+            {"skippedWorkflows", skipped},
+        });
+    } catch (const std::exception& e) {
+        sendError(res, "Invalid request body", 400);
+    }
+}
+
 std::vector<nlohmann::json> WorkflowGroupController::buildGroupPath(const std::string& group_id) {
     std::vector<nlohmann::json> path;
     std::string current_id = group_id;

+ 12 - 0
src/webserver/api/workflow_group_controller.hpp

@@ -37,6 +37,13 @@ private:
     void moveGroup(const httplib::Request& req, httplib::Response& res,
                    const auth::AuthContext& ctx);
 
+    // Attribution, not authority: ownerId is never consulted to decide who
+    // may read, edit, run or delete a folder or its workflows - that comes
+    // from the caller's role in the project it belongs to. This changes who
+    // things are attributed to, nothing about who can act on them.
+    void transferGroupOwner(const httplib::Request& req, httplib::Response& res,
+                            const auth::AuthContext& ctx);
+
     // Helpers
     // Loads a folder and checks the caller may do this to it. A folder is not
     // secret in itself, but it names somebody's work and says how it is
@@ -44,6 +51,11 @@ private:
     bool loadAllowed(httplib::Response& res, const auth::AuthContext& ctx,
                      const std::string& id, auth::Action action, nlohmann::json& out);
 
+    // Whether newOwnerId names an account that exists and can reach
+    // project_id. On failure, writes the error response itself.
+    bool checkTransferTarget(httplib::Response& res, const std::string& new_owner_id,
+                             const std::string& project_id);
+
     void sendJson(httplib::Response& res, const nlohmann::json& data, int status = 200);
     void sendError(httplib::Response& res, const std::string& message, int status);