Răsfoiți Sursa

feat: enforce project access on workflows, credentials and executions

The rules existed and nothing consulted them. Any account that could log
in could still read, change, run and delete every workflow, and every
credential belonged to everybody. This is the commit where that stops.

List endpoints return only what the caller can reach, and item endpoints
refuse what they cannot. A workflow somebody has no access to answers 404
rather than 403 - whether it exists is itself something only people who
can reach it should learn. Once they can see it, being unable to run or
change it is said plainly, because a refusal with no reason reads as a
broken button.

Roles are ranked rather than compared by name. Without that the owner -
the account that runs the installation - would have failed every check
asking for "admin", which is most of them. The first admin is promoted to
owner at startup, so there is always exactly one account that cannot be
demoted or deleted.

Credentials were admin-only wholesale, which would have left every project
member unable to use any credential at all. The project decides now, so a
project editor can manage the credentials their workflows need.

Executions follow the workflow they ran: an execution carries the trigger
data and every node's output, which is often the very thing a credential
was used to fetch. A run whose workflow has been deleted, or that recorded
no workflow at all - the webhook path does that - is shown only to
somebody who can already reach everything, rather than to everybody or to
nobody.

Two problems found while doing this, both of which would have quietly lost
something:

- A credential update writes the whole record, and the credential model
  did not know about projectId. The first edit of any credential would
  have dropped it and made it reachable only by an admin. It is part of
  the model now, so it survives a round trip.
- Deleting a user left their personal project behind for ever: the API
  refuses to delete a personal project, and there was no owner left to
  ask. An empty one now goes with the account. One still holding work is
  kept - the work matters more than the tidiness, and an admin can reach
  it.

Verified with three accounts. A member sees no workflows, credentials or
executions that are not theirs, and gets 404 for one by id. Added to a
project as a viewer they can read it but not run or change it, each
refusal saying which. Promoted to editor they can change it and it appears
in their list. They cannot move a workflow into a project they cannot
write to. Afterwards every workflow was returned to its original project:
8 workflows, 7 credentials, 6 still active, nothing lost.
fszontagh 1 lună în urmă
părinte
comite
d70f73fdfc

+ 1 - 0
lib/credentials/credential_store.cpp

@@ -206,6 +206,7 @@ Result<CredentialInfo> CredentialStore::create(const CreateCredentialRequest& re
     doc.metadata.created_by = user_id;
     doc.metadata.allowed_workflows = request.allowed_workflows;
     doc.metadata.public_data = public_data;
+    doc.metadata.project_id = request.project_id;
     doc.encrypted_data = encrypt_result.value();
 
     // Store in database

+ 9 - 0
lib/credentials/credential_types.cpp

@@ -275,6 +275,9 @@ nlohmann::json CredentialMetadata::toJson() const {
     if (!declared_type.empty()) {
         j["declaredType"] = declared_type;
     }
+    if (!project_id.empty()) {
+        j["projectId"] = project_id;
+    }
     return j;
 }
 
@@ -287,6 +290,7 @@ CredentialMetadata CredentialMetadata::fromJson(const nlohmann::json& j) {
     meta.type = credentialTypeFromString(j.value("type", "basic"));
     meta.created_by = j.value("createdBy", j.value("created_by", ""));
     meta.declared_type = j.value("declaredType", "");
+    meta.project_id = j.value("projectId", "");
     meta.created_at = j.value("_createdAt", j.value("createdAt", j.value("created_at", int64_t{0})));
     meta.updated_at = j.value("_updatedAt", j.value("updatedAt", j.value("updated_at", int64_t{0})));
     if (j.contains("allowedWorkflows") && j["allowedWorkflows"].is_array()) {
@@ -336,6 +340,9 @@ nlohmann::json CredentialInfo::toJson() const {
     if (!declared_type.empty()) {
         j["declaredType"] = declared_type;
     }
+    if (!project_id.empty()) {
+        j["projectId"] = project_id;
+    }
     return j;
 }
 
@@ -351,6 +358,7 @@ CredentialInfo CredentialInfo::fromMetadata(const CredentialMetadata& metadata)
     info.allowed_workflows = metadata.allowed_workflows;
     info.public_data = metadata.public_data;
     info.declared_type = metadata.declared_type;
+    info.project_id = metadata.project_id;
     return info;
 }
 
@@ -370,6 +378,7 @@ Result<CreateCredentialRequest> CreateCredentialRequest::fromJson(const nlohmann
     // storage type rather than replacing it: the store still needs to know it
     // is a basic credential to encrypt it and build an auth header.
     req.declared_type = j.value("declaredType", "");
+    req.project_id = j.value("projectId", "");
 
     try {
         req.type = credentialTypeFromString(j["type"].get<std::string>());

+ 9 - 0
lib/credentials/credential_types.hpp

@@ -153,6 +153,11 @@ struct CredentialMetadata {
     // header. It exists so a person can tell which of four basic credentials is
     // the one an SD.cpp node wants.
     std::string declared_type;
+    // Which project this belongs to, and so who may see and use it. Carried in
+    // the model rather than left on the stored document: an update writes the
+    // whole record, so a field the model does not know about is a field that
+    // disappears the first time somebody edits the credential.
+    std::string project_id;
 
     nlohmann::json toJson() const;
     static CredentialMetadata fromJson(const nlohmann::json& j);
@@ -179,6 +184,7 @@ struct CredentialInfo {
     std::vector<std::string> allowed_workflows;
     nlohmann::json public_data;  // Non-secret fields for editing
     std::string declared_type;   // See CredentialMetadata::declared_type
+    std::string project_id;
 
     nlohmann::json toJson() const;
     static CredentialInfo fromMetadata(const CredentialMetadata& metadata);
@@ -188,6 +194,9 @@ struct CredentialInfo {
 struct CreateCredentialRequest {
     std::string name;
     std::string description;
+    // Which project it belongs to. The controller decides this - it knows who
+    // is asking and what they may write to.
+    std::string project_id;
     CredentialType type;
     std::string declared_type;  // Optional named type a node registered
     nlohmann::json data;  // Type-specific data (Basic, Bearer, ApiKey, OAuth2)

+ 72 - 7
src/webserver/api/credential_controller.cpp

@@ -6,14 +6,16 @@ namespace smartbotic::webserver::api {
 using namespace credentials;
 
 CredentialController::CredentialController(CredentialStore& credential_store,
+                                           auth::AccessControl& access,
                                            auth::AuthMiddleware& middleware)
     : credential_store_(credential_store)
+    , access_(access)
     , middleware_(middleware) {}
 
 void CredentialController::registerRoutes(httplib::Server& server) {
     // List credentials - requires admin role
     server.Get("/api/v1/credentials", [this](const httplib::Request& req, httplib::Response& res) {
-        middleware_.requireRole(req, res, "admin", [this](const httplib::Request& req,
+        middleware_.requireAuth(req, res, [this](const httplib::Request& req,
                                                           httplib::Response& res,
                                                           const auth::AuthContext& ctx) {
             listCredentials(req, res, ctx);
@@ -22,7 +24,7 @@ void CredentialController::registerRoutes(httplib::Server& server) {
 
     // Get credential - requires admin role
     server.Get(R"(/api/v1/credentials/([^/]+))", [this](const httplib::Request& req, httplib::Response& res) {
-        middleware_.requireRole(req, res, "admin", [this](const httplib::Request& req,
+        middleware_.requireAuth(req, res, [this](const httplib::Request& req,
                                                           httplib::Response& res,
                                                           const auth::AuthContext& ctx) {
             getCredential(req, res, ctx);
@@ -31,7 +33,7 @@ void CredentialController::registerRoutes(httplib::Server& server) {
 
     // Create credential - requires admin role
     server.Post("/api/v1/credentials", [this](const httplib::Request& req, httplib::Response& res) {
-        middleware_.requireRole(req, res, "admin", [this](const httplib::Request& req,
+        middleware_.requireAuth(req, res, [this](const httplib::Request& req,
                                                           httplib::Response& res,
                                                           const auth::AuthContext& ctx) {
             createCredential(req, res, ctx);
@@ -40,7 +42,7 @@ void CredentialController::registerRoutes(httplib::Server& server) {
 
     // Update credential - requires admin role
     server.Put(R"(/api/v1/credentials/([^/]+))", [this](const httplib::Request& req, httplib::Response& res) {
-        middleware_.requireRole(req, res, "admin", [this](const httplib::Request& req,
+        middleware_.requireAuth(req, res, [this](const httplib::Request& req,
                                                           httplib::Response& res,
                                                           const auth::AuthContext& ctx) {
             updateCredential(req, res, ctx);
@@ -49,7 +51,7 @@ void CredentialController::registerRoutes(httplib::Server& server) {
 
     // Delete credential - requires admin role
     server.Delete(R"(/api/v1/credentials/([^/]+))", [this](const httplib::Request& req, httplib::Response& res) {
-        middleware_.requireRole(req, res, "admin", [this](const httplib::Request& req,
+        middleware_.requireAuth(req, res, [this](const httplib::Request& req,
                                                           httplib::Response& res,
                                                           const auth::AuthContext& ctx) {
             deleteCredential(req, res, ctx);
@@ -58,7 +60,7 @@ void CredentialController::registerRoutes(httplib::Server& server) {
 
     // Refresh OAuth2 token - requires admin role
     server.Post(R"(/api/v1/credentials/([^/]+)/refresh)", [this](const httplib::Request& req, httplib::Response& res) {
-        middleware_.requireRole(req, res, "admin", [this](const httplib::Request& req,
+        middleware_.requireAuth(req, res, [this](const httplib::Request& req,
                                                           httplib::Response& res,
                                                           const auth::AuthContext& ctx) {
             refreshToken(req, res, ctx);
@@ -76,8 +78,12 @@ void CredentialController::listCredentials(const httplib::Request& req, httplib:
         return;
     }
 
+    // Only the projects this caller can reach. A credential is the most
+    // sensitive thing here - it is somebody's password to another system.
+    const auto reachable = access_.projectsFor(ctx);
     nlohmann::json credentials_json = nlohmann::json::array();
     for (const auto& cred : result.value()) {
+        if (!reachable.contains(cred.project_id)) continue;
         credentials_json.push_back(cred.toJson());
     }
 
@@ -94,6 +100,10 @@ void CredentialController::getCredential(const httplib::Request& req, httplib::R
         sendError(res, result.error().message(), status);
         return;
     }
+    if (!access_.allowed(ctx, result.value().project_id, auth::Action::Read)) {
+        sendError(res, "Credential not found", 404);
+        return;
+    }
 
     sendJson(res, result.value().toJson());
 }
@@ -109,7 +119,30 @@ void CredentialController::createCredential(const httplib::Request& req, httplib
             return;
         }
 
-        auto result = credential_store_.create(request_result.value(), ctx.user_id);
+        // Where the caller asked for, if they may write there; their own
+        // project otherwise. Never nowhere - a credential belonging to no
+        // project is one only an admin could ever use again.
+        auto request = request_result.value();
+        if (!request.project_id.empty() &&
+            !access_.allowed(ctx, request.project_id, auth::Action::Write)) {
+            sendError(res, "You cannot create a credential in that project", 403);
+            return;
+        }
+        if (request.project_id.empty()) {
+            auto personal = access_.personalProjectFor(ctx.user_id);
+            if (personal.failed()) {
+                auto created = access_.ensurePersonalProject(ctx.user_id, ctx.username);
+                if (created.failed()) {
+                    sendError(res, "You have no project to put this in", 500);
+                    return;
+                }
+                request.project_id = created.value().value("_id", "");
+            } else {
+                request.project_id = personal.value();
+            }
+        }
+
+        auto result = credential_store_.create(request, ctx.user_id);
         if (result.failed()) {
             sendError(res, result.error().message(), 400);
             return;
@@ -125,6 +158,22 @@ void CredentialController::updateCredential(const httplib::Request& req, httplib
                                             const auth::AuthContext& ctx) {
     std::string id = req.matches[1].str();
 
+    {
+        auto existing = credential_store_.get(id);
+        if (existing.failed()) {
+            sendError(res, "Credential not found", 404);
+            return;
+        }
+        if (!access_.allowed(ctx, existing.value().project_id, auth::Action::Read)) {
+            sendError(res, "Credential not found", 404);
+            return;
+        }
+        if (!access_.allowed(ctx, existing.value().project_id, auth::Action::Write)) {
+            sendError(res, "You can see this credential but not change it", 403);
+            return;
+        }
+    }
+
     try {
         auto body = nlohmann::json::parse(req.body);
 
@@ -157,6 +206,22 @@ void CredentialController::deleteCredential(const httplib::Request& req, httplib
                                             const auth::AuthContext& ctx) {
     std::string id = req.matches[1].str();
 
+    {
+        auto existing = credential_store_.get(id);
+        if (existing.failed()) {
+            sendError(res, "Credential not found", 404);
+            return;
+        }
+        if (!access_.allowed(ctx, existing.value().project_id, auth::Action::Read)) {
+            sendError(res, "Credential not found", 404);
+            return;
+        }
+        if (!access_.allowed(ctx, existing.value().project_id, auth::Action::Write)) {
+            sendError(res, "You can see this credential but not delete it", 403);
+            return;
+        }
+    }
+
     auto result = credential_store_.remove(id);
     if (result.failed()) {
         int status = result.error().code() == common::ErrorCode::NotFound ? 404 : 500;

+ 3 - 0
src/webserver/api/credential_controller.hpp

@@ -3,6 +3,7 @@
 #include <httplib.h>
 #include <nlohmann/json.hpp>
 #include "../auth/auth_middleware.hpp"
+#include "../auth/access.hpp"
 #include "credentials/credential_store.hpp"
 
 namespace smartbotic::webserver::api {
@@ -10,6 +11,7 @@ namespace smartbotic::webserver::api {
 class CredentialController {
 public:
     CredentialController(credentials::CredentialStore& credential_store,
+                         auth::AccessControl& access,
                         auth::AuthMiddleware& middleware);
 
     // Register routes
@@ -45,6 +47,7 @@ private:
     void sendError(httplib::Response& res, const std::string& message, int status);
 
     credentials::CredentialStore& credential_store_;
+    auth::AccessControl& access_;
     auth::AuthMiddleware& middleware_;
 };
 

+ 37 - 3
src/webserver/api/execution_controller.cpp

@@ -101,13 +101,35 @@ std::string literalCaseInsensitiveRegex(const std::string& term) {
 
 ExecutionController::ExecutionController(storage::StorageClient& storage,
                                          auth::AuthMiddleware& middleware,
+                                         auth::AccessControl& access,
                                          WebSocketServer& ws_server,
                                          WorkflowScheduler& scheduler,
                                          runners::LoadBalancer& load_balancer,
                                          FinishedHandler on_finished)
-    : storage_(storage), middleware_(middleware), ws_server_(ws_server),
+    : storage_(storage), access_(access), middleware_(middleware), ws_server_(ws_server),
       scheduler_(scheduler), load_balancer_(load_balancer), on_finished_(std::move(on_finished)) {}
 
+
+bool ExecutionController::canSeeExecution(const auth::AuthContext& ctx,
+                                          const nlohmann::json& execution) {
+    const std::string workflow_id = execution.value("workflowId", "");
+    if (workflow_id.empty()) {
+        // Some runs record no workflow id - the webhook path is one. Rather
+        // than leak them to everybody or hide them from everybody, they are
+        // shown only to somebody who can reach every project anyway.
+        return auth::instanceRoleFromString(ctx.role) != auth::InstanceRole::Member;
+    }
+
+    auto workflow = storage_.get("workflows", workflow_id);
+    if (workflow.failed()) {
+        // The workflow has been deleted. Its history outlives it, and only an
+        // admin can still account for it.
+        return auth::instanceRoleFromString(ctx.role) != auth::InstanceRole::Member;
+    }
+    return access_.allowed(ctx, auth::AccessControl::projectOf(workflow.value()),
+                           auth::Action::Read);
+}
+
 void ExecutionController::registerRoutes(httplib::Server& server) {
     server.Get("/api/v1/executions", [this](const httplib::Request& req, httplib::Response& res) {
         middleware_.requireAuth(req, res, [this](auto& req, auto& res, auto& ctx) {
@@ -223,8 +245,15 @@ void ExecutionController::listExecutions(const httplib::Request& req, httplib::R
     }
 
     nlohmann::json response;
-    response["executions"] = result.value().documents;
-    response["total"] = result.value().total_count;
+    // Only runs of workflows this caller can reach. An execution carries the
+    // trigger data and every node's output, which is often the very thing a
+    // credential was used to fetch.
+    nlohmann::json visible = nlohmann::json::array();
+    for (const auto& execution : result.value().documents) {
+        if (canSeeExecution(ctx, execution)) visible.push_back(execution);
+    }
+    response["executions"] = visible;
+    response["total"] = visible.size();
     response["page"] = page;
     response["pageSize"] = page_size;
     response["hasMore"] = result.value().has_more;
@@ -242,6 +271,11 @@ void ExecutionController::getExecution(const httplib::Request& req, httplib::Res
         return;
     }
 
+    if (!canSeeExecution(ctx, result.value())) {
+        sendError(res, "Execution not found", 404);
+        return;
+    }
+
     sendJson(res, result.value());
 }
 

+ 8 - 0
src/webserver/api/execution_controller.hpp

@@ -5,6 +5,7 @@
 #include <optional>
 #include <string>
 #include "../auth/auth_middleware.hpp"
+#include "../auth/access.hpp"
 #include "../websocket_server.hpp"
 #include "storage/storage_client.hpp"
 #include "../scheduler/workflow_scheduler.hpp"
@@ -26,6 +27,7 @@ public:
                                                const std::string& error)>;
 
     ExecutionController(storage::StorageClient& storage, auth::AuthMiddleware& middleware,
+                        auth::AccessControl& access,
                         WebSocketServer& ws_server, WorkflowScheduler& scheduler,
                         runners::LoadBalancer& load_balancer,
                         FinishedHandler on_finished = nullptr);
@@ -47,10 +49,16 @@ private:
                      const auth::AuthContext& ctx);
     void receiveExecutionEvent(const httplib::Request& req, httplib::Response& res);
 
+    // An execution belongs to whatever project its workflow does. Looked up
+    // rather than stored on the execution, so moving a workflow between
+    // projects takes its history with it.
+    bool canSeeExecution(const auth::AuthContext& ctx, const nlohmann::json& execution);
+
     void sendJson(httplib::Response& res, const nlohmann::json& data, int status = 200);
     void sendError(httplib::Response& res, const std::string& message, int status);
 
     storage::StorageClient& storage_;
+    auth::AccessControl& access_;
     auth::AuthMiddleware& middleware_;
     WebSocketServer& ws_server_;
     WorkflowScheduler& scheduler_;

+ 43 - 8
src/webserver/api/user_controller.cpp

@@ -3,9 +3,17 @@
 
 namespace smartbotic::webserver::api {
 
+namespace {
+// The owner outranks an admin, so a bare comparison against "admin" would lock
+// the account that runs the installation out of the things admins may do.
+bool not_admin(const auth::AuthContext& ctx) {
+    return ctx.role != "admin" && ctx.role != "owner";
+}
+}  // namespace
+
 UserController::UserController(auth::AuthStore& auth_store, auth::AuthMiddleware& middleware,
-                               auth::AccessControl& access)
-    : auth_store_(auth_store), access_(access), middleware_(middleware) {}
+                               auth::AccessControl& access, storage::StorageClient& storage)
+    : auth_store_(auth_store), access_(access), storage_(storage), middleware_(middleware) {}
 
 void UserController::registerRoutes(httplib::Server& server) {
     server.Get("/api/v1/users", [this](const httplib::Request& req, httplib::Response& res) {
@@ -85,7 +93,7 @@ void UserController::getUser(const httplib::Request& req, httplib::Response& res
     std::string id = req.matches[1];
 
     // Users can only view their own profile unless admin
-    if (id != ctx.user_id && ctx.role != "admin") {
+    if (id != ctx.user_id && not_admin(ctx)) {
         sendError(res, "Forbidden", 403);
         return;
     }
@@ -144,7 +152,7 @@ void UserController::updateUser(const httplib::Request& req, httplib::Response&
         std::string id = req.matches[1];
 
         // Users can only update their own profile unless admin
-        if (id != ctx.user_id && ctx.role != "admin") {
+        if (id != ctx.user_id && not_admin(ctx)) {
             sendError(res, "Forbidden", 403);
             return;
         }
@@ -152,7 +160,7 @@ void UserController::updateUser(const httplib::Request& req, httplib::Response&
         auto body = nlohmann::json::parse(req.body);
 
         // Non-admins can't change role
-        if (ctx.role != "admin") {
+        if (not_admin(ctx)) {
             body.erase("role");
         }
 
@@ -182,14 +190,41 @@ void UserController::deleteUser(const httplib::Request& req, httplib::Response&
         return;
     }
 
+    // Their personal project, before the account goes - afterwards there is no
+    // way to tell whose it was.
+    auto personal = access_.personalProjectFor(id);
+
     auto result = auth_store_.deleteUser(id);
     if (result.failed()) {
         sendError(res, "User not found", 404);
         return;
     }
 
+    // An empty personal project outlives its owner as clutter nobody can
+    // remove - the API refuses to delete a personal project, and there is no
+    // owner left to ask. One holding work is left alone: the work matters more
+    // than the tidiness, and an instance admin can still reach it.
+    bool project_removed = false;
+    if (personal.ok()) {
+        bool empty = true;
+        for (const char* collection : {"workflows", "credentials", "workflow_groups"}) {
+            storage::QueryOptions options;
+            options.page_size = 1;
+            options.filters.push_back({"projectId", personal.value()});
+            auto found = storage_.query(collection, options);
+            if (found.ok() && !found.value().documents.empty()) { empty = false; break; }
+        }
+        if (empty) {
+            auto dropped = storage_.remove("projects", personal.value());
+            project_removed = dropped.ok();
+        } else {
+            LOG_INFO("Kept project {} - it still holds work from the deleted account {}",
+                     personal.value(), id);
+        }
+    }
+
     LOG_INFO("User deleted: {} by admin {}", id, ctx.user_id);
-    sendJson(res, {{"success", true}});
+    sendJson(res, {{"success", true}, {"personalProjectRemoved", project_removed}});
 }
 
 void UserController::changePassword(const httplib::Request& req, httplib::Response& res,
@@ -198,7 +233,7 @@ void UserController::changePassword(const httplib::Request& req, httplib::Respon
         std::string id = req.matches[1];
 
         // Users can only change their own password
-        if (id != ctx.user_id && ctx.role != "admin") {
+        if (id != ctx.user_id && not_admin(ctx)) {
             sendError(res, "Forbidden", 403);
             return;
         }
@@ -209,7 +244,7 @@ void UserController::changePassword(const httplib::Request& req, httplib::Respon
         std::string new_password = body.value("newPassword", "");
 
         // Admin can change without old password
-        if (ctx.role != "admin" && old_password.empty()) {
+        if (not_admin(ctx) && old_password.empty()) {
             sendError(res, "Current password required", 400);
             return;
         }

+ 3 - 1
src/webserver/api/user_controller.hpp

@@ -5,13 +5,14 @@
 #include "../auth/auth_store.hpp"
 #include "../auth/auth_middleware.hpp"
 #include "../auth/access.hpp"
+#include "storage/storage_client.hpp"
 
 namespace smartbotic::webserver::api {
 
 class UserController {
 public:
     UserController(auth::AuthStore& auth_store, auth::AuthMiddleware& middleware,
-                   auth::AccessControl& access);
+                   auth::AccessControl& access, storage::StorageClient& storage);
 
     void registerRoutes(httplib::Server& server);
 
@@ -34,6 +35,7 @@ private:
 
     auth::AuthStore& auth_store_;
     auth::AccessControl& access_;
+    storage::StorageClient& storage_;
     auth::AuthMiddleware& middleware_;
 };
 

+ 111 - 25
src/webserver/api/workflow_controller.cpp

@@ -17,6 +17,7 @@ WorkflowController::WorkflowController(storage::StorageClient& storage,
                                        WebSocketServer& ws_server,
                                        WorkflowScheduler& scheduler,
                                        DatabaseWatcher& db_watcher,
+                                       auth::AccessControl& access,
                                        nodes::NodeStore& node_store)
     : storage_(storage)
     , middleware_(middleware)
@@ -25,6 +26,7 @@ WorkflowController::WorkflowController(storage::StorageClient& storage,
     , ws_server_(ws_server)
     , scheduler_(scheduler)
     , db_watcher_(db_watcher)
+    , access_(access)
     , node_store_(node_store) {}
 
 
@@ -147,6 +149,36 @@ void WorkflowController::registerRoutes(httplib::Server& server) {
     });
 }
 
+
+bool WorkflowController::loadAllowed(const httplib::Request& req, httplib::Response& res,
+                                     const auth::AuthContext& ctx, const std::string& id,
+                                     auth::Action action, nlohmann::json& out) {
+    (void)req;
+    auto stored = storage_.get("workflows", id);
+    if (stored.failed()) {
+        sendError(res, "Workflow not found", 404);
+        return false;
+    }
+
+    const std::string project = auth::AccessControl::projectOf(stored.value());
+
+    if (!access_.allowed(ctx, project, auth::Action::Read)) {
+        // Not "forbidden". Whether a workflow exists is itself something only
+        // people who can reach it should learn.
+        sendError(res, "Workflow not found", 404);
+        return false;
+    }
+    if (action != auth::Action::Read && !access_.allowed(ctx, project, action)) {
+        sendError(res, action == auth::Action::Run
+                           ? "You can see this workflow but not run it"
+                           : "You can see this workflow but not change it", 403);
+        return false;
+    }
+
+    out = stored.value();
+    return true;
+}
+
 void WorkflowController::listWorkflows(const httplib::Request& req, httplib::Response& res,
                                         const auth::AuthContext& ctx) {
     storage::QueryOptions options;
@@ -202,6 +234,24 @@ void WorkflowController::listWorkflows(const httplib::Request& req, httplib::Res
         return;
     }
 
+    // Only the projects this caller can reach. Filtering here rather than in
+    // the query because the database cannot be asked "any of these ids", and
+    // a workflows collection is the handful of things somebody built.
+    const auto reachable = access_.projectsFor(ctx);
+    {
+        nlohmann::json kept = nlohmann::json::array();
+        for (const auto& workflow : result.value().documents) {
+            if (reachable.contains(auth::AccessControl::projectOf(workflow))) {
+                kept.push_back(workflow);
+            }
+        }
+        result.value().documents = kept;
+        // The totals have to describe what is being returned, or the pager
+        // promises pages that are not there.
+        result.value().total_count = static_cast<int64_t>(kept.size());
+        result.value().has_more = false;
+    }
+
     nlohmann::json workflows = result.value().documents;
     int64_t total = result.value().total_count;
     bool has_more = result.value().has_more;
@@ -241,13 +291,8 @@ void WorkflowController::getWorkflow(const httplib::Request& req, httplib::Respo
                                       const auth::AuthContext& ctx) {
     std::string id = req.matches[1];
 
-    auto result = storage_.get("workflows", id);
-    if (result.failed()) {
-        sendError(res, "Workflow not found", 404);
-        return;
-    }
-
-    auto workflow = result.value();
+    nlohmann::json workflow;
+    if (!loadAllowed(req, res, ctx, id, auth::Action::Read, workflow)) return;
 
     // Whether the workflow that runs differs from the workflow on screen.
     //
@@ -285,6 +330,29 @@ void WorkflowController::createWorkflow(const httplib::Request& req, httplib::Re
         body["ownerId"] = ctx.user_id;
         body["active"] = body.value("active", false);
 
+        // Everything belongs to a project. Where the caller asked for, if they
+        // may write there; their own project otherwise - never nowhere, which
+        // would make it reachable only by an admin.
+        std::string project = body.value("projectId", "");
+        if (!project.empty() && !access_.allowed(ctx, project, auth::Action::Write)) {
+            sendError(res, "You cannot create a workflow in that project", 403);
+            return;
+        }
+        if (project.empty()) {
+            auto personal = access_.personalProjectFor(ctx.user_id);
+            if (personal.failed()) {
+                auto created = access_.ensurePersonalProject(ctx.user_id, ctx.username);
+                if (created.failed()) {
+                    sendError(res, "You have no project to put this in", 500);
+                    return;
+                }
+                project = created.value().value("_id", "");
+            } else {
+                project = personal.value();
+            }
+        }
+        body["projectId"] = project;
+
         // Validate required fields
         if (!body.contains("name") || body["name"].get<std::string>().empty()) {
             sendError(res, "Name is required", 400);
@@ -320,8 +388,24 @@ void WorkflowController::updateWorkflow(const httplib::Request& req, httplib::Re
                                          const auth::AuthContext& ctx) {
     try {
         std::string id = req.matches[1];
+
+        nlohmann::json existing;
+        if (!loadAllowed(req, res, ctx, id, auth::Action::Write, existing)) return;
+
         auto body = nlohmann::json::parse(req.body);
 
+        // Moving it to another project is a change of who can reach it, so it
+        // needs the right to write in the project it is going to as well as
+        // the one it is leaving.
+        if (body.contains("projectId") && body["projectId"].is_string()) {
+            const std::string target = body["projectId"];
+            if (target != auth::AccessControl::projectOf(existing) &&
+                !access_.allowed(ctx, target, auth::Action::Write)) {
+                sendError(res, "You cannot move a workflow into a project you cannot write to", 403);
+                return;
+            }
+        }
+
         // What the editor had loaded when the user started changing it. Two
         // people with the same workflow open used to mean whoever pressed save
         // second silently threw the other's work away, with nothing anywhere to
@@ -466,6 +550,9 @@ void WorkflowController::deleteWorkflow(const httplib::Request& req, httplib::Re
                                          const auth::AuthContext& ctx) {
     std::string id = req.matches[1];
 
+    nlohmann::json existing;
+    if (!loadAllowed(req, res, ctx, id, auth::Action::Write, existing)) return;
+
     // Every trigger it had, not just the scheduled ones - a deleted workflow
     // that kept its database watch would go on being started by changes, with
     // nothing left to run.
@@ -487,14 +574,8 @@ void WorkflowController::executeWorkflow(const httplib::Request& req, httplib::R
                                           const auth::AuthContext& ctx) {
     std::string workflow_id = req.matches[1];
 
-    // Get workflow
-    auto workflow_result = storage_.get("workflows", workflow_id);
-    if (workflow_result.failed()) {
-        sendError(res, "Workflow not found", 404);
-        return;
-    }
-
-    auto& workflow = workflow_result.value();
+    nlohmann::json workflow;
+    if (!loadAllowed(req, res, ctx, workflow_id, auth::Action::Run, workflow)) return;
 
     // Select runner
     auto runner = load_balancer_.selectRunner();
@@ -585,11 +666,9 @@ void WorkflowController::listWorkflowVersions(const httplib::Request& req, httpl
         }
     }
 
-    auto current = storage_.get("workflows", id);
-    if (current.failed()) {
-        sendError(res, "Workflow not found", 404);
-        return;
-    }
+    nlohmann::json current_doc;
+    if (!loadAllowed(req, res, ctx, id, auth::Action::Read, current_doc)) return;
+    auto current = common::Result<nlohmann::json>(current_doc);
 
     auto listed = storage_.listVersions("workflows", id, limit, 0);
     if (listed.failed()) {
@@ -632,6 +711,9 @@ void WorkflowController::getWorkflowVersion(const httplib::Request& req, httplib
         return;
     }
 
+    nlohmann::json current_doc;
+    if (!loadAllowed(req, res, ctx, id, auth::Action::Read, current_doc)) return;
+
     auto doc = storage_.getVersion("workflows", id, version);
     if (doc.failed()) {
         sendError(res, "That version of the workflow is no longer stored", 404);
@@ -644,11 +726,9 @@ void WorkflowController::publishWorkflow(const httplib::Request& req, httplib::R
                                          const auth::AuthContext& ctx) {
     std::string id = req.matches[1];
 
-    auto current = storage_.get("workflows", id);
-    if (current.failed()) {
-        sendError(res, "Workflow not found", 404);
-        return;
-    }
+    nlohmann::json current_doc;
+    if (!loadAllowed(req, res, ctx, id, auth::Action::Write, current_doc)) return;
+    auto current = common::Result<nlohmann::json>(current_doc);
 
     // Publishing what is already published would store a new version whose only
     // difference is the act of publishing it, and leave the button offering to
@@ -715,6 +795,9 @@ void WorkflowController::activateWorkflow(const httplib::Request& req, httplib::
     // An active workflow runs what was published, so switching one on without
     // anything published would leave its triggers with nothing to run. Turning
     // it on is a clear enough statement that what is there now should go live.
+    nlohmann::json guard_doc;
+    if (!loadAllowed(req, res, ctx, id, auth::Action::Write, guard_doc)) return;
+
     auto before = storage_.get("workflows", id);
     nlohmann::json patch = {{"active", true},
                             {"consecutiveFailures", 0},
@@ -751,6 +834,9 @@ void WorkflowController::deactivateWorkflow(const httplib::Request& req, httplib
                                              const auth::AuthContext& ctx) {
     std::string id = req.matches[1];
 
+    nlohmann::json guard_doc;
+    if (!loadAllowed(req, res, ctx, id, auth::Action::Write, guard_doc)) return;
+
     auto result = storage_.update("workflows", id, {{"active", false}, {"updatedAt", TimeUtils::nowMs()}}, 0, true);
     if (result.failed()) {
         sendError(res, "Workflow not found", 404);

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

@@ -10,6 +10,7 @@
 #include "../scheduler/workflow_scheduler.hpp"
 #include "../nodes/node_store.hpp"
 #include "../scheduler/database_watcher.hpp"
+#include "../auth/access.hpp"
 #include "proto/runner.grpc.pb.h"
 
 namespace smartbotic::webserver::api {
@@ -23,6 +24,7 @@ public:
                       WebSocketServer& ws_server,
                       WorkflowScheduler& scheduler,
                       DatabaseWatcher& db_watcher,
+                      auth::AccessControl& access,
                       nodes::NodeStore& node_store);
 
     void registerRoutes(httplib::Server& server);
@@ -65,6 +67,7 @@ private:
     WebSocketServer& ws_server_;
     WorkflowScheduler& scheduler_;
     DatabaseWatcher& db_watcher_;
+    auth::AccessControl& access_;
     nodes::NodeStore& node_store_;
 
     // Helper to check for scheduled triggers and register/unregister with scheduler
@@ -74,6 +77,13 @@ private:
     // Fills in each node's stored config with any missing configSchema
     // defaults, in place, so a saved workflow document is self-describing.
     void materializeNodeConfigDefaults(nlohmann::json& body);
+
+    // Loads a workflow and checks the caller may do this to it. Answers the
+    // request and returns false when they may not, so every handler is one
+    // early return rather than its own idea of the rules.
+    bool loadAllowed(const httplib::Request& req, httplib::Response& res,
+                     const auth::AuthContext& ctx, const std::string& id,
+                     auth::Action action, nlohmann::json& out);
 };
 
 } // namespace smartbotic::webserver::api

+ 11 - 6
src/webserver/auth/auth_middleware.cpp

@@ -47,12 +47,17 @@ bool AuthMiddleware::hasRole(const AuthContext& ctx, const std::string& required
         return false;
     }
 
-    // Admin has all roles
-    if (ctx.role == "admin") {
-        return true;
-    }
-
-    return ctx.role == required_role;
+    // The roles are ranked, not a set of separate names. Without this the owner
+    // - the one account that runs the installation - would fail every check
+    // that asks for "admin", which is most of them.
+    auto rank = [](const std::string& role) {
+        if (role == "owner") return 3;
+        if (role == "admin") return 2;
+        // "user" is what a member was called before there were three.
+        return 1;
+    };
+
+    return rank(ctx.role) >= rank(required_role);
 }
 
 void AuthMiddleware::requireAuth(const httplib::Request& req, httplib::Response& res,

+ 31 - 4
src/webserver/webserver_service.cpp

@@ -190,6 +190,32 @@ void WebServerService::start() {
         project_ctrl_->migrateExistingRecords();
     }
 
+    // Somebody has to be the owner - the account that cannot be demoted or
+    // deleted, so an installation can never end up with nobody able to
+    // administer it. If nobody is, the first admin becomes it.
+    {
+        auto users = auth_store_->listUsers(1, 1000);
+        if (users.ok()) {
+            bool have_owner = false;
+            for (const auto& user : users.value()) {
+                if (user.role == "owner") { have_owner = true; break; }
+            }
+            if (!have_owner) {
+                for (const auto& user : users.value()) {
+                    if (user.role != "admin") continue;
+                    auto promoted = storage_->update("users", user.id, {{"role", "owner"}}, 0, true);
+                    if (promoted.ok()) {
+                        LOG_INFO("{} is now the owner of this installation", user.username);
+                    } else {
+                        LOG_WARN("Could not make {} the owner: {}", user.username,
+                                 promoted.error().message());
+                    }
+                    break;
+                }
+            }
+        }
+    }
+
     // Ensure the executions summary view exists before serving requests
     ensureExecutionsSummaryView();
 
@@ -241,7 +267,7 @@ void WebServerService::setupRoutes() {
     auth_ctrl_ = std::make_unique<api::AuthController>(*auth_store_, *auth_middleware_);
     auth_ctrl_->registerRoutes(server);
 
-    user_ctrl_ = std::make_unique<api::UserController>(*auth_store_, *auth_middleware_, *access_);
+    user_ctrl_ = std::make_unique<api::UserController>(*auth_store_, *auth_middleware_, *access_, *storage_);
     user_ctrl_->registerRoutes(server);
 
     // Who may do what. Built before the controllers that ask it.
@@ -253,7 +279,7 @@ void WebServerService::setupRoutes() {
 
     workflow_ctrl_ = std::make_unique<api::WorkflowController>(
         *storage_, *auth_middleware_, *runner_registry_, *load_balancer_, *ws_server_,
-        *scheduler_, *db_watcher_, *node_store_);
+        *scheduler_, *db_watcher_, *access_, *node_store_);
     workflow_ctrl_->registerRoutes(server);
 
     workflow_group_ctrl_ = std::make_unique<api::WorkflowGroupController>(
@@ -261,7 +287,7 @@ void WebServerService::setupRoutes() {
     workflow_group_ctrl_->registerRoutes(server);
 
     execution_ctrl_ = std::make_unique<api::ExecutionController>(
-        *storage_, *auth_middleware_, *ws_server_, *scheduler_, *load_balancer_,
+        *storage_, *auth_middleware_, *access_, *ws_server_, *scheduler_, *load_balancer_,
         [this](const std::string& workflow_id, const std::string& execution_id,
                bool failed, const std::string& error) {
             noteExecutionOutcome(workflow_id, execution_id, failed, error);
@@ -291,7 +317,8 @@ void WebServerService::setupRoutes() {
     database_ctrl_ = std::make_unique<api::DatabaseController>(*storage_, *auth_middleware_);
     database_ctrl_->registerRoutes(server);
 
-    credential_ctrl_ = std::make_unique<api::CredentialController>(*credential_store_, *auth_middleware_);
+    credential_ctrl_ = std::make_unique<api::CredentialController>(
+        *credential_store_, *access_, *auth_middleware_);
     credential_ctrl_->registerRoutes(server);
 
     LOG_INFO("API routes registered");