Просмотр исходного кода

feat: projects - who work belongs to, and who may touch it

First half of user and permission management. A workflow recorded who
created it and nothing ever read that back, so any account that could log
in could read, change, run and delete every workflow and every credential
in the installation. Invisible with one account; a surprise with two.

Two levels, as in the tools people arrive from. The instance role says
what somebody is here - owner, admin or member. The project role says
what they may do inside one project - admin, editor or viewer. Instance
owners and admins reach every project on purpose: an installation where
the administrator cannot fix a workflow because nobody added them to a
project is an installation with a locked room in it.

Everybody gets a personal project, made with the account rather than at
the next restart - without one they log in and see nothing at all, which
looks like a broken installation rather than an empty one. It cannot be
shared or deleted. Team projects are the ones with members.

Existing work is filed at startup into the personal project of whoever
created it, falling back to the owner's. A record with no project is
reachable only by an instance admin, so nothing became unreachable and
nothing leaked.

The guards are the interesting part, and each is tested:
- Whether a project exists is only told to people who can see it, so a
  project somebody cannot reach answers 404 rather than 403.
- A project cannot be left without an admin, whether by demoting the last
  one or removing them - that would be a project nobody could rename,
  share or delete.
- Leaving a project yourself needs no special power; removing somebody
  else does.
- A project holding work refuses to be deleted and says what it holds,
  rather than taking eight workflows with it or orphaning them.

This commit adds the model, the API and the migration. The endpoints that
serve workflows, credentials and executions do not consult it yet - that
is the next commit, and until it lands the old behaviour stands.

Verified with three accounts: the migration filed 8 workflows, 7
credentials and 3 folders; each member sees only their own project while
the admin sees all; a non-member gets 404 and cannot add themselves; a
viewer added by the project admin can see it; and every guard above
refuses with its own message.
fszontagh 1 месяц назад
Родитель
Сommit
68d27a0091

+ 2 - 0
CMakeLists.txt

@@ -156,6 +156,8 @@ add_executable(smartbotic-webserver
     src/webserver/auth/bcrypt_utils.cpp
     src/webserver/auth/jwt_utils.cpp
     src/webserver/auth/auth_store.cpp
+    src/webserver/auth/access.cpp
+    src/webserver/api/project_controller.cpp
     src/webserver/auth/auth_middleware.cpp
     src/webserver/runners/runner_registry.cpp
     src/webserver/runners/load_balancer.cpp

+ 544 - 0
src/webserver/api/project_controller.cpp

@@ -0,0 +1,544 @@
+#include "project_controller.hpp"
+
+#include "common/time_utils.hpp"
+#include "common/uuid.hpp"
+#include "logging/logger.hpp"
+
+namespace smartbotic::webserver::api {
+
+using namespace common;
+using auth::Action;
+using auth::InstanceRole;
+using auth::ProjectRole;
+
+namespace {
+constexpr const char* PROJECTS = "projects";
+
+// Everything that belongs to a project, so the migration and the delete guard
+// do not each keep their own half-remembered list.
+const std::vector<std::string>& ownedCollections() {
+    static const std::vector<std::string> collections = {
+        "workflows", "credentials", "workflow_groups"
+    };
+    return collections;
+}
+}  // namespace
+
+ProjectController::ProjectController(storage::StorageClient& storage,
+                                     auth::AuthMiddleware& middleware,
+                                     auth::AccessControl& access,
+                                     auth::AuthStore& auth_store)
+    : storage_(storage), middleware_(middleware), access_(access), auth_store_(auth_store) {}
+
+void ProjectController::registerRoutes(httplib::Server& server) {
+    server.Get("/api/v1/projects", [this](const httplib::Request& req, httplib::Response& res) {
+        middleware_.requireAuth(req, res, [this](auto& req, auto& res, auto& ctx) {
+            listProjects(req, res, ctx);
+        });
+    });
+
+    server.Post("/api/v1/projects", [this](const httplib::Request& req, httplib::Response& res) {
+        middleware_.requireAuth(req, res, [this](auto& req, auto& res, auto& ctx) {
+            createProject(req, res, ctx);
+        });
+    });
+
+    server.Get(R"(/api/v1/projects/([^/]+))", [this](const httplib::Request& req, httplib::Response& res) {
+        middleware_.requireAuth(req, res, [this](auto& req, auto& res, auto& ctx) {
+            getProject(req, res, ctx);
+        });
+    });
+
+    server.Put(R"(/api/v1/projects/([^/]+))", [this](const httplib::Request& req, httplib::Response& res) {
+        middleware_.requireAuth(req, res, [this](auto& req, auto& res, auto& ctx) {
+            updateProject(req, res, ctx);
+        });
+    });
+
+    server.Delete(R"(/api/v1/projects/([^/]+))", [this](const httplib::Request& req, httplib::Response& res) {
+        middleware_.requireAuth(req, res, [this](auto& req, auto& res, auto& ctx) {
+            deleteProject(req, res, ctx);
+        });
+    });
+
+    server.Post(R"(/api/v1/projects/([^/]+)/members)", [this](const httplib::Request& req, httplib::Response& res) {
+        middleware_.requireAuth(req, res, [this](auto& req, auto& res, auto& ctx) {
+            addMember(req, res, ctx);
+        });
+    });
+
+    server.Put(R"(/api/v1/projects/([^/]+)/members/([^/]+))", [this](const httplib::Request& req, httplib::Response& res) {
+        middleware_.requireAuth(req, res, [this](auto& req, auto& res, auto& ctx) {
+            updateMember(req, res, ctx);
+        });
+    });
+
+    server.Delete(R"(/api/v1/projects/([^/]+)/members/([^/]+))", [this](const httplib::Request& req, httplib::Response& res) {
+        middleware_.requireAuth(req, res, [this](auto& req, auto& res, auto& ctx) {
+            removeMember(req, res, ctx);
+        });
+    });
+}
+
+nlohmann::json ProjectController::describe(const nlohmann::json& project,
+                                           const auth::AuthContext& ctx) {
+    nlohmann::json out = project;
+
+    // The UI draws "Anna (editor)", not a row of identifiers.
+    nlohmann::json members = nlohmann::json::array();
+    for (const auto& member : project.value("members", nlohmann::json::array())) {
+        nlohmann::json entry = member;
+        auto user = auth_store_.getUser(member.value("userId", ""));
+        if (user.ok()) {
+            entry["username"] = user.value().username;
+            entry["email"] = user.value().email;
+        } else {
+            // A member whose account has been deleted. Said plainly rather than
+            // shown as a blank row somebody has to guess about.
+            entry["username"] = "(deleted user)";
+        }
+        members.push_back(entry);
+    }
+    out["members"] = members;
+
+    const std::string id = project.value("_id", "");
+    out["myRole"] = auth::projectRoleToString(access_.roleIn(ctx, id));
+
+    // What a project holds, so the UI can warn before deleting one and show a
+    // count beside its name.
+    int64_t workflows = 0;
+    storage::QueryOptions options;
+    options.page_size = 1000;
+    options.filters.push_back({"projectId", id});
+    auto found = storage_.query("workflows", options);
+    if (found.ok()) workflows = static_cast<int64_t>(found.value().documents.size());
+    out["workflowCount"] = workflows;
+
+    return out;
+}
+
+void ProjectController::listProjects(const httplib::Request& req, httplib::Response& res,
+                                     const auth::AuthContext& ctx) {
+    (void)req;
+
+    auto reachable = access_.projectsFor(ctx);
+
+    storage::QueryOptions options;
+    options.page_size = 1000;
+    auto result = storage_.query(PROJECTS, options);
+    if (result.failed()) {
+        sendError(res, result.error().message(), 500);
+        return;
+    }
+
+    nlohmann::json projects = nlohmann::json::array();
+    for (const auto& project : result.value().documents) {
+        const std::string id = project.value("_id", "");
+        if (!reachable.contains(id)) continue;
+        projects.push_back(describe(project, ctx));
+    }
+
+    sendJson(res, {{"projects", projects}});
+}
+
+void ProjectController::getProject(const httplib::Request& req, httplib::Response& res,
+                                   const auth::AuthContext& ctx) {
+    const std::string id = req.matches[1];
+
+    auto project = storage_.get(PROJECTS, id);
+    if (project.failed()) {
+        sendError(res, "Project not found", 404);
+        return;
+    }
+    if (!access_.allowed(ctx, id, Action::Read)) {
+        // Not "forbidden": whether a project exists is itself something only
+        // its members should learn.
+        sendError(res, "Project not found", 404);
+        return;
+    }
+    sendJson(res, describe(project.value(), ctx));
+}
+
+void ProjectController::createProject(const httplib::Request& req, httplib::Response& res,
+                                      const auth::AuthContext& ctx) {
+    nlohmann::json body;
+    try {
+        body = nlohmann::json::parse(req.body);
+    } catch (...) {
+        sendError(res, "Invalid JSON body", 400);
+        return;
+    }
+
+    const std::string name = body.value("name", "");
+    if (name.empty()) {
+        sendError(res, "A project needs a name", 400);
+        return;
+    }
+
+    const std::string id = "prj_" + UUID::generate();
+    nlohmann::json project = {
+        {"name", name},
+        {"description", body.value("description", "")},
+        {"type", "team"},
+        {"ownerId", ctx.user_id},
+        // The creator is an admin of it, or they could make a project and then
+        // not be able to put anybody in it.
+        {"members", nlohmann::json::array({
+            nlohmann::json{{"userId", ctx.user_id}, {"role", "admin"}, {"addedAt", TimeUtils::nowMs()}}
+        })},
+        {"createdAt", TimeUtils::nowMs()},
+    };
+
+    auto inserted = storage_.insert(PROJECTS, project, id);
+    if (inserted.failed()) {
+        sendError(res, inserted.error().message(), 500);
+        return;
+    }
+    project["_id"] = id;
+
+    LOG_INFO("Project {} ({}) created by {}", id, name, ctx.username);
+    sendJson(res, describe(project, ctx), 201);
+}
+
+void ProjectController::updateProject(const httplib::Request& req, httplib::Response& res,
+                                      const auth::AuthContext& ctx) {
+    const std::string id = req.matches[1];
+
+    auto existing = storage_.get(PROJECTS, id);
+    if (existing.failed()) {
+        sendError(res, "Project not found", 404);
+        return;
+    }
+    if (!access_.allowed(ctx, id, Action::Manage)) {
+        sendError(res, "Only a project admin can change the project", 403);
+        return;
+    }
+
+    nlohmann::json body;
+    try {
+        body = nlohmann::json::parse(req.body);
+    } catch (...) {
+        sendError(res, "Invalid JSON body", 400);
+        return;
+    }
+
+    nlohmann::json patch;
+    if (body.contains("name") && body["name"].is_string() && !body["name"].get<std::string>().empty()) {
+        patch["name"] = body["name"];
+    }
+    if (body.contains("description")) patch["description"] = body["description"];
+    if (patch.empty()) {
+        sendError(res, "Nothing to change - send a name or a description", 400);
+        return;
+    }
+
+    auto updated = storage_.update(PROJECTS, id, patch, 0, true);
+    if (updated.failed()) {
+        sendError(res, updated.error().message(), 500);
+        return;
+    }
+
+    auto after = storage_.get(PROJECTS, id);
+    sendJson(res, describe(after.ok() ? after.value() : existing.value(), ctx));
+}
+
+void ProjectController::deleteProject(const httplib::Request& req, httplib::Response& res,
+                                      const auth::AuthContext& ctx) {
+    const std::string id = req.matches[1];
+
+    auto project = storage_.get(PROJECTS, id);
+    if (project.failed()) {
+        sendError(res, "Project not found", 404);
+        return;
+    }
+    if (project.value().value("type", "") == "personal") {
+        sendError(res, "A personal project cannot be deleted - it is where somebody's own work lives", 400);
+        return;
+    }
+    if (!access_.allowed(ctx, id, Action::Manage)) {
+        sendError(res, "Only a project admin can delete the project", 403);
+        return;
+    }
+
+    // Deleting a project must not take a workflow with it by accident, and must
+    // not leave one behind that nobody can reach either. So it is refused while
+    // anything is still in it, and the caller is told what and how much.
+    nlohmann::json holding = nlohmann::json::object();
+    int64_t total = 0;
+    for (const auto& collection : ownedCollections()) {
+        storage::QueryOptions options;
+        options.page_size = 1000;
+        options.filters.push_back({"projectId", id});
+        auto found = storage_.query(collection, options);
+        if (found.ok() && !found.value().documents.empty()) {
+            holding[collection] = found.value().documents.size();
+            total += static_cast<int64_t>(found.value().documents.size());
+        }
+    }
+    if (total > 0) {
+        nlohmann::json error = {
+            {"error", "This project still holds work. Move it somewhere else first, or delete it."},
+            {"holding", holding},
+        };
+        res.status = 409;
+        res.set_content(error.dump(), "application/json");
+        return;
+    }
+
+    auto removed = storage_.remove(PROJECTS, id);
+    if (removed.failed()) {
+        sendError(res, removed.error().message(), 500);
+        return;
+    }
+
+    LOG_INFO("Project {} deleted by {}", id, ctx.username);
+    sendJson(res, {{"success", true}, {"id", id}});
+}
+
+void ProjectController::addMember(const httplib::Request& req, httplib::Response& res,
+                                  const auth::AuthContext& ctx) {
+    const std::string id = req.matches[1];
+
+    auto project = storage_.get(PROJECTS, id);
+    if (project.failed()) {
+        sendError(res, "Project not found", 404);
+        return;
+    }
+    if (project.value().value("type", "") == "personal") {
+        sendError(res, "A personal project is one person's own - share a team project instead", 400);
+        return;
+    }
+    if (!access_.allowed(ctx, id, Action::Manage)) {
+        sendError(res, "Only a project admin can add members", 403);
+        return;
+    }
+
+    nlohmann::json body;
+    try {
+        body = nlohmann::json::parse(req.body);
+    } catch (...) {
+        sendError(res, "Invalid JSON body", 400);
+        return;
+    }
+
+    const std::string user_id = body.value("userId", "");
+    const std::string role = body.value("role", "editor");
+    if (user_id.empty()) {
+        sendError(res, "Which user? Send a userId", 400);
+        return;
+    }
+    if (auth::projectRoleFromString(role) == ProjectRole::None) {
+        sendError(res, "Role has to be admin, editor or viewer", 400);
+        return;
+    }
+    if (auth_store_.getUser(user_id).failed()) {
+        sendError(res, "No such user", 404);
+        return;
+    }
+
+    auto members = project.value().value("members", nlohmann::json::array());
+    for (const auto& member : members) {
+        if (member.value("userId", "") == user_id) {
+            sendError(res, "That user is already a member - change their role instead", 409);
+            return;
+        }
+    }
+    members.push_back({{"userId", user_id}, {"role", role}, {"addedAt", TimeUtils::nowMs()},
+                       {"addedBy", ctx.user_id}});
+
+    auto updated = storage_.update(PROJECTS, id, {{"members", members}}, 0, true);
+    if (updated.failed()) {
+        sendError(res, updated.error().message(), 500);
+        return;
+    }
+
+    auto after = storage_.get(PROJECTS, id);
+    LOG_INFO("User {} added to project {} as {} by {}", user_id, id, role, ctx.username);
+    sendJson(res, describe(after.ok() ? after.value() : project.value(), ctx), 201);
+}
+
+void ProjectController::updateMember(const httplib::Request& req, httplib::Response& res,
+                                     const auth::AuthContext& ctx) {
+    const std::string id = req.matches[1];
+    const std::string user_id = req.matches[2];
+
+    auto project = storage_.get(PROJECTS, id);
+    if (project.failed()) {
+        sendError(res, "Project not found", 404);
+        return;
+    }
+    if (!access_.allowed(ctx, id, Action::Manage)) {
+        sendError(res, "Only a project admin can change roles", 403);
+        return;
+    }
+
+    nlohmann::json body;
+    try {
+        body = nlohmann::json::parse(req.body);
+    } catch (...) {
+        sendError(res, "Invalid JSON body", 400);
+        return;
+    }
+
+    const std::string role = body.value("role", "");
+    if (auth::projectRoleFromString(role) == ProjectRole::None) {
+        sendError(res, "Role has to be admin, editor or viewer", 400);
+        return;
+    }
+
+    auto members = project.value().value("members", nlohmann::json::array());
+    bool found = false;
+    int admins = 0;
+    for (auto& member : members) {
+        if (member.value("role", "") == "admin") admins++;
+    }
+    for (auto& member : members) {
+        if (member.value("userId", "") != user_id) continue;
+        // A project with nobody who can manage it is a project nobody can add
+        // anyone to, rename, or delete.
+        if (member.value("role", "") == "admin" && role != "admin" && admins <= 1) {
+            sendError(res, "This is the project's only admin - make somebody else an admin first", 409);
+            return;
+        }
+        member["role"] = role;
+        found = true;
+        break;
+    }
+    if (!found) {
+        sendError(res, "That user is not a member of this project", 404);
+        return;
+    }
+
+    auto updated = storage_.update(PROJECTS, id, {{"members", members}}, 0, true);
+    if (updated.failed()) {
+        sendError(res, updated.error().message(), 500);
+        return;
+    }
+
+    auto after = storage_.get(PROJECTS, id);
+    sendJson(res, describe(after.ok() ? after.value() : project.value(), ctx));
+}
+
+void ProjectController::removeMember(const httplib::Request& req, httplib::Response& res,
+                                     const auth::AuthContext& ctx) {
+    const std::string id = req.matches[1];
+    const std::string user_id = req.matches[2];
+
+    auto project = storage_.get(PROJECTS, id);
+    if (project.failed()) {
+        sendError(res, "Project not found", 404);
+        return;
+    }
+    // Leaving a project yourself needs no special power; removing somebody else
+    // does.
+    const bool leaving = (user_id == ctx.user_id);
+    if (!leaving && !access_.allowed(ctx, id, Action::Manage)) {
+        sendError(res, "Only a project admin can remove members", 403);
+        return;
+    }
+
+    auto members = project.value().value("members", nlohmann::json::array());
+    nlohmann::json kept = nlohmann::json::array();
+    int admins = 0;
+    for (const auto& member : members) {
+        if (member.value("role", "") == "admin") admins++;
+    }
+    bool found = false;
+    for (const auto& member : members) {
+        if (member.value("userId", "") == user_id) {
+            if (member.value("role", "") == "admin" && admins <= 1) {
+                sendError(res, "This is the project's only admin - make somebody else an admin first", 409);
+                return;
+            }
+            found = true;
+            continue;
+        }
+        kept.push_back(member);
+    }
+    if (!found) {
+        sendError(res, "That user is not a member of this project", 404);
+        return;
+    }
+
+    auto updated = storage_.update(PROJECTS, id, {{"members", kept}}, 0, true);
+    if (updated.failed()) {
+        sendError(res, updated.error().message(), 500);
+        return;
+    }
+
+    auto after = storage_.get(PROJECTS, id);
+    LOG_INFO("User {} removed from project {} by {}", user_id, id, ctx.username);
+    sendJson(res, describe(after.ok() ? after.value() : project.value(), ctx));
+}
+
+void ProjectController::migrateExistingRecords() {
+    // Everybody gets a personal project, including accounts made before there
+    // were projects at all.
+    auto users = auth_store_.listUsers(1, 1000);
+    if (users.failed()) {
+        LOG_WARN("Could not read users while setting up projects: {}", users.error().message());
+        return;
+    }
+
+    std::string fallback_project;
+    for (const auto& user : users.value()) {
+        auto project = access_.ensurePersonalProject(user.id, user.username);
+        if (project.failed()) {
+            LOG_WARN("Could not create a personal project for {}: {}", user.username,
+                     project.error().message());
+            continue;
+        }
+        // Work that predates projects goes to whoever runs the installation.
+        const auto role = auth::instanceRoleFromString(user.role);
+        if (fallback_project.empty() || role == InstanceRole::Owner) {
+            if (fallback_project.empty() || role == InstanceRole::Owner) {
+                fallback_project = project.value().value("_id", "");
+            }
+        }
+    }
+
+    if (fallback_project.empty()) {
+        LOG_WARN("No personal project to file existing work into - skipping");
+        return;
+    }
+
+    for (const auto& collection : ownedCollections()) {
+        storage::QueryOptions options;
+        options.page_size = 1000;
+        auto found = storage_.query(collection, options);
+        if (found.failed()) continue;
+
+        int moved = 0;
+        for (const auto& record : found.value().documents) {
+            if (!record.value("projectId", std::string()).empty()) continue;
+            const std::string id = record.value("_id", "");
+            if (id.empty()) continue;
+
+            // Its creator's own project if that is known, the owner's otherwise.
+            std::string target = fallback_project;
+            const std::string owner = record.value("ownerId", "");
+            if (!owner.empty()) {
+                auto personal = access_.personalProjectFor(owner);
+                if (personal.ok()) target = personal.value();
+            }
+
+            auto updated = storage_.update(collection, id, {{"projectId", target}}, 0, true);
+            if (updated.ok()) moved++;
+        }
+        if (moved > 0) {
+            LOG_INFO("Filed {} record(s) in {} into a project", moved, collection);
+        }
+    }
+}
+
+void ProjectController::sendJson(httplib::Response& res, const nlohmann::json& data, int status) {
+    res.status = status;
+    res.set_content(data.dump(), "application/json");
+}
+
+void ProjectController::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

+ 66 - 0
src/webserver/api/project_controller.hpp

@@ -0,0 +1,66 @@
+#pragma once
+
+#include <httplib.h>
+#include <nlohmann/json.hpp>
+
+#include "../auth/access.hpp"
+#include "../auth/auth_middleware.hpp"
+#include "../auth/auth_store.hpp"
+#include "storage/storage_client.hpp"
+
+namespace smartbotic::webserver::api {
+
+/**
+ * Projects: who work belongs to, and who may touch it.
+ *
+ * A workflow, a credential and a folder each belong to exactly one project.
+ * Everybody gets a personal project when their account is made - it is where
+ * work lands when nobody chose otherwise, and it cannot be shared or deleted.
+ * Team projects are the ones with members in them.
+ */
+class ProjectController {
+public:
+    ProjectController(storage::StorageClient& storage,
+                      auth::AuthMiddleware& middleware,
+                      auth::AccessControl& access,
+                      auth::AuthStore& auth_store);
+
+    void registerRoutes(httplib::Server& server);
+
+    // Files anything left over from before projects existed into the instance
+    // owner's personal project, and makes sure every account has one. Run at
+    // startup: a workflow with no project is one only an admin can see.
+    void migrateExistingRecords();
+
+private:
+    void listProjects(const httplib::Request& req, httplib::Response& res,
+                      const auth::AuthContext& ctx);
+    void getProject(const httplib::Request& req, httplib::Response& res,
+                    const auth::AuthContext& ctx);
+    void createProject(const httplib::Request& req, httplib::Response& res,
+                       const auth::AuthContext& ctx);
+    void updateProject(const httplib::Request& req, httplib::Response& res,
+                       const auth::AuthContext& ctx);
+    void deleteProject(const httplib::Request& req, httplib::Response& res,
+                       const auth::AuthContext& ctx);
+    void addMember(const httplib::Request& req, httplib::Response& res,
+                   const auth::AuthContext& ctx);
+    void updateMember(const httplib::Request& req, httplib::Response& res,
+                      const auth::AuthContext& ctx);
+    void removeMember(const httplib::Request& req, httplib::Response& res,
+                      const auth::AuthContext& ctx);
+
+    // The project with its members' names filled in, so the UI does not have to
+    // fetch every user to draw a list of three.
+    nlohmann::json describe(const nlohmann::json& project, 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);
+
+    storage::StorageClient& storage_;
+    auth::AuthMiddleware& middleware_;
+    auth::AccessControl& access_;
+    auth::AuthStore& auth_store_;
+};
+
+} // namespace smartbotic::webserver::api

+ 15 - 3
src/webserver/api/user_controller.cpp

@@ -3,8 +3,9 @@
 
 namespace smartbotic::webserver::api {
 
-UserController::UserController(auth::AuthStore& auth_store, auth::AuthMiddleware& middleware)
-    : auth_store_(auth_store), middleware_(middleware) {}
+UserController::UserController(auth::AuthStore& auth_store, auth::AuthMiddleware& middleware,
+                               auth::AccessControl& access)
+    : auth_store_(auth_store), access_(access), middleware_(middleware) {}
 
 void UserController::registerRoutes(httplib::Server& server) {
     server.Get("/api/v1/users", [this](const httplib::Request& req, httplib::Response& res) {
@@ -119,8 +120,19 @@ void UserController::createUser(const httplib::Request& req, httplib::Response&
             return;
         }
 
+        // Their own project, made with the account rather than at the next
+        // restart - without one they log in and can see nothing at all, which
+        // looks like a broken installation rather than an empty one.
+        auto project = access_.ensurePersonalProject(result.value().id, result.value().username);
+        if (project.failed()) {
+            LOG_WARN("User {} has no personal project: {}", username, project.error().message());
+        }
+
         LOG_INFO("User created: {} by admin {}", username, ctx.user_id);
-        sendJson(res, result.value().toJson(), 201);
+        auto payload = result.value().toJson();
+        payload["id"] = result.value().id;
+        if (project.ok()) payload["personalProjectId"] = project.value().value("_id", "");
+        sendJson(res, payload, 201);
     } catch (const std::exception& e) {
         sendError(res, "Invalid request body", 400);
     }

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

@@ -4,12 +4,14 @@
 #include <nlohmann/json.hpp>
 #include "../auth/auth_store.hpp"
 #include "../auth/auth_middleware.hpp"
+#include "../auth/access.hpp"
 
 namespace smartbotic::webserver::api {
 
 class UserController {
 public:
-    UserController(auth::AuthStore& auth_store, auth::AuthMiddleware& middleware);
+    UserController(auth::AuthStore& auth_store, auth::AuthMiddleware& middleware,
+                   auth::AccessControl& access);
 
     void registerRoutes(httplib::Server& server);
 
@@ -31,6 +33,7 @@ private:
     void sendError(httplib::Response& res, const std::string& message, int status);
 
     auth::AuthStore& auth_store_;
+    auth::AccessControl& access_;
     auth::AuthMiddleware& middleware_;
 };
 

+ 171 - 0
src/webserver/auth/access.cpp

@@ -0,0 +1,171 @@
+#include "access.hpp"
+
+#include "common/time_utils.hpp"
+#include "common/uuid.hpp"
+#include "logging/logger.hpp"
+
+namespace smartbotic::webserver::auth {
+
+using namespace common;
+
+namespace {
+constexpr const char* PROJECTS = "projects";
+}
+
+InstanceRole instanceRoleFromString(const std::string& role) {
+    if (role == "owner") return InstanceRole::Owner;
+    if (role == "admin") return InstanceRole::Admin;
+    // "user" is what the role was called before there were three of them, and
+    // stored accounts still say it.
+    return InstanceRole::Member;
+}
+
+std::string instanceRoleToString(InstanceRole role) {
+    switch (role) {
+        case InstanceRole::Owner: return "owner";
+        case InstanceRole::Admin: return "admin";
+        case InstanceRole::Member: return "member";
+    }
+    return "member";
+}
+
+ProjectRole projectRoleFromString(const std::string& role) {
+    if (role == "admin") return ProjectRole::Admin;
+    if (role == "editor") return ProjectRole::Editor;
+    if (role == "viewer") return ProjectRole::Viewer;
+    return ProjectRole::None;
+}
+
+std::string projectRoleToString(ProjectRole role) {
+    switch (role) {
+        case ProjectRole::Admin: return "admin";
+        case ProjectRole::Editor: return "editor";
+        case ProjectRole::Viewer: return "viewer";
+        case ProjectRole::None: return "none";
+    }
+    return "none";
+}
+
+AccessControl::AccessControl(storage::StorageClient& storage) : storage_(storage) {}
+
+std::string AccessControl::projectOf(const nlohmann::json& record) {
+    return record.value("projectId", std::string());
+}
+
+std::unordered_set<std::string> AccessControl::projectsFor(const AuthContext& ctx) {
+    std::unordered_set<std::string> out;
+
+    storage::QueryOptions options;
+    options.page_size = 1000;
+    auto result = storage_.query(PROJECTS, options);
+    if (result.failed()) {
+        LOG_WARN("Could not read projects for access check: {}", result.error().message());
+        return out;
+    }
+
+    const bool sees_everything = instanceRoleFromString(ctx.role) != InstanceRole::Member;
+
+    for (const auto& project : result.value().documents) {
+        const std::string id = project.value("_id", "");
+        if (id.empty()) continue;
+
+        if (sees_everything) {
+            out.insert(id);
+            continue;
+        }
+        if (project.value("ownerId", "") == ctx.user_id) {
+            out.insert(id);
+            continue;
+        }
+        for (const auto& member : project.value("members", nlohmann::json::array())) {
+            if (member.value("userId", "") == ctx.user_id) {
+                out.insert(id);
+                break;
+            }
+        }
+    }
+    return out;
+}
+
+ProjectRole AccessControl::roleIn(const AuthContext& ctx, const std::string& project_id) {
+    if (instanceRoleFromString(ctx.role) != InstanceRole::Member) {
+        return ProjectRole::Admin;
+    }
+    if (project_id.empty()) return ProjectRole::None;
+
+    auto project = storage_.get(PROJECTS, project_id);
+    if (project.failed()) return ProjectRole::None;
+
+    // A personal project belongs entirely to its owner - there is no
+    // membership row to look up and none can be added.
+    if (project.value().value("ownerId", "") == ctx.user_id) return ProjectRole::Admin;
+
+    for (const auto& member : project.value().value("members", nlohmann::json::array())) {
+        if (member.value("userId", "") == ctx.user_id) {
+            return projectRoleFromString(member.value("role", "viewer"));
+        }
+    }
+    return ProjectRole::None;
+}
+
+bool AccessControl::allowed(const AuthContext& ctx, const std::string& project_id, Action action) {
+    const ProjectRole role = roleIn(ctx, project_id);
+    switch (action) {
+        case Action::Read:
+            return role != ProjectRole::None;
+        case Action::Run:
+            // A viewer may watch but not start. Running a workflow sends email,
+            // writes to collections and spends money at an API - it is not a
+            // read however little it changes the workflow itself.
+            return role == ProjectRole::Editor || role == ProjectRole::Admin;
+        case Action::Write:
+            return role == ProjectRole::Editor || role == ProjectRole::Admin;
+        case Action::Manage:
+            return role == ProjectRole::Admin;
+    }
+    return false;
+}
+
+common::Result<std::string> AccessControl::personalProjectFor(const std::string& user_id) {
+    storage::QueryOptions options;
+    options.page_size = 1000;
+    options.filters.push_back({"type", "personal"});
+
+    auto result = storage_.query(PROJECTS, options);
+    if (result.failed()) return Error(ErrorCode::DatabaseError, result.error().message());
+
+    for (const auto& project : result.value().documents) {
+        if (project.value("ownerId", "") == user_id) {
+            return project.value("_id", std::string());
+        }
+    }
+    return Error(ErrorCode::NotFound, "No personal project for user " + user_id);
+}
+
+common::Result<nlohmann::json> AccessControl::ensurePersonalProject(const std::string& user_id,
+                                                                    const std::string& username) {
+    auto existing = personalProjectFor(user_id);
+    if (existing.ok()) {
+        auto project = storage_.get(PROJECTS, existing.value());
+        if (project.ok()) return project.value();
+    }
+
+    const std::string id = "prj_" + UUID::generate();
+    nlohmann::json project = {
+        {"name", username.empty() ? std::string("Personal") : username + "'s project"},
+        {"type", "personal"},
+        {"ownerId", user_id},
+        {"members", nlohmann::json::array()},
+        {"createdAt", TimeUtils::nowMs()},
+    };
+
+    auto inserted = storage_.insert(PROJECTS, project, id);
+    if (inserted.failed()) {
+        return Error(ErrorCode::DatabaseError, inserted.error().message());
+    }
+    project["_id"] = id;
+    LOG_INFO("Created personal project {} for user {}", id, user_id);
+    return project;
+}
+
+} // namespace smartbotic::webserver::auth

+ 87 - 0
src/webserver/auth/access.hpp

@@ -0,0 +1,87 @@
+#pragma once
+
+#include <string>
+#include <unordered_set>
+#include <vector>
+
+#include <nlohmann/json.hpp>
+
+#include "auth_middleware.hpp"
+#include "storage/storage_client.hpp"
+
+namespace smartbotic::webserver::auth {
+
+/**
+ * Who may do what.
+ *
+ * Until now a workflow recorded who created it and nothing ever read that
+ * back: any account that could log in could read, change, run and delete every
+ * workflow and every credential in the installation. That was invisible with
+ * one account and would have been a surprise with two.
+ *
+ * There are two levels, as in the tools people arrive from:
+ *
+ *   The instance role says what somebody is on this installation.
+ *     owner  - one per installation, cannot be demoted or deleted
+ *     admin  - everything except taking the installation away from the owner
+ *     member - only what their projects give them
+ *
+ *   The project role says what they may do inside one project.
+ *     admin  - the project itself, its members, and everything in it
+ *     editor - create, change, run and delete the work in it
+ *     viewer - look, and watch it run
+ *
+ * Instance owners and admins can reach every project. That is deliberate: an
+ * installation where the administrator cannot fix a workflow because nobody
+ * added them to a project is an installation with a locked room in it.
+ */
+
+enum class InstanceRole { Member, Admin, Owner };
+enum class ProjectRole { None, Viewer, Editor, Admin };
+
+InstanceRole instanceRoleFromString(const std::string& role);
+std::string instanceRoleToString(InstanceRole role);
+
+ProjectRole projectRoleFromString(const std::string& role);
+std::string projectRoleToString(ProjectRole role);
+
+/** What a caller is being asked to do with something. */
+enum class Action {
+    Read,    // look at it, and at what it did
+    Write,   // change it, create one, delete it
+    Run,     // start it by hand
+    Manage,  // the project itself: rename, members, delete
+};
+
+class AccessControl {
+public:
+    explicit AccessControl(storage::StorageClient& storage);
+
+    // Every project this user can reach, by id. For an instance admin that is
+    // all of them, which is why this is asked rather than derived from
+    // membership alone.
+    std::unordered_set<std::string> projectsFor(const AuthContext& ctx);
+
+    // The role this user holds in one project - Admin for an instance
+    // admin or owner, whatever their membership says.
+    ProjectRole roleIn(const AuthContext& ctx, const std::string& project_id);
+
+    bool allowed(const AuthContext& ctx, const std::string& project_id, Action action);
+
+    // The project a stored record belongs to. Records written before projects
+    // existed have no projectId; they are treated as belonging to nobody's
+    // project, which only an instance admin can reach - so nothing becomes
+    // unreachable by accident and nothing leaks by default.
+    static std::string projectOf(const nlohmann::json& record);
+
+    // Everybody has one, made when their account is. It is where work lands
+    // when they have not chosen a project, and it cannot be deleted or shared.
+    common::Result<std::string> personalProjectFor(const std::string& user_id);
+    common::Result<nlohmann::json> ensurePersonalProject(const std::string& user_id,
+                                                         const std::string& username);
+
+private:
+    storage::StorageClient& storage_;
+};
+
+} // namespace smartbotic::webserver::auth

+ 15 - 1
src/webserver/webserver_service.cpp

@@ -183,6 +183,13 @@ void WebServerService::start() {
         }
     }
 
+    // Everybody gets a personal project, and anything stored before projects
+    // existed is filed into one - a record with no project is one only an
+    // instance admin can see.
+    if (project_ctrl_) {
+        project_ctrl_->migrateExistingRecords();
+    }
+
     // Ensure the executions summary view exists before serving requests
     ensureExecutionsSummaryView();
 
@@ -234,9 +241,16 @@ 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_);
+    user_ctrl_ = std::make_unique<api::UserController>(*auth_store_, *auth_middleware_, *access_);
     user_ctrl_->registerRoutes(server);
 
+    // Who may do what. Built before the controllers that ask it.
+    access_ = std::make_unique<auth::AccessControl>(*storage_);
+
+    project_ctrl_ = std::make_unique<api::ProjectController>(
+        *storage_, *auth_middleware_, *access_, *auth_store_);
+    project_ctrl_->registerRoutes(server);
+
     workflow_ctrl_ = std::make_unique<api::WorkflowController>(
         *storage_, *auth_middleware_, *runner_registry_, *load_balancer_, *ws_server_,
         *scheduler_, *db_watcher_, *node_store_);

+ 4 - 0
src/webserver/webserver_service.hpp

@@ -14,6 +14,8 @@
 #include "config/config_loader.hpp"
 #include "scheduler/workflow_scheduler.hpp"
 #include "scheduler/database_watcher.hpp"
+#include "api/project_controller.hpp"
+#include "auth/access.hpp"
 
 namespace smartbotic::webserver::nodes {
     class NodeStore;
@@ -136,6 +138,8 @@ private:
     // Scheduler
     std::unique_ptr<WorkflowScheduler> scheduler_;
     std::unique_ptr<DatabaseWatcher> db_watcher_;
+    std::unique_ptr<auth::AccessControl> access_;
+    std::unique_ptr<api::ProjectController> project_ctrl_;
 
     // Controllers (must outlive httplib::Server callbacks)
     std::unique_ptr<api::AuthController> auth_ctrl_;

+ 149 - 7
webui/src/pages/NodesPage.tsx

@@ -1,10 +1,11 @@
 import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
 import { nodesApi, NodeDefinition } from '../api/workflows'
-import { useState, useMemo } from 'react'
-import { Plus, RefreshCw, Trash2, Code, Save, X, FileCode } from 'lucide-react'
+import { useState, useMemo, useEffect, useRef } from 'react'
+import { Plus, RefreshCw, Trash2, Code, Save, X, Search, Zap, FileCode } from 'lucide-react'
 import { useTheme } from '../contexts/ThemeContext'
 import Editor from '@monaco-editor/react'
 import { getCategoryConfig, getSortedCategories } from '../config/nodeCategories'
+import { NodeIcon } from '../components/workflow/NodeIcon'
 
 const NODE_TEMPLATE = `/**
  * @node my-node
@@ -50,6 +51,11 @@ module.exports = { configSchema, inputSchema, outputSchema, execute };
 export default function NodesPage() {
   const queryClient = useQueryClient()
   const { resolvedTheme } = useTheme()
+  const [search, setSearch] = useState('')
+  const [categoryFilter, setCategoryFilter] = useState<string | null>(null)
+  const [triggersOnly, setTriggersOnly] = useState(false)
+  const searchRef = useRef<HTMLInputElement>(null)
+
   const [selectedNode, setSelectedNode] = useState<NodeDefinition | null>(null)
   const [editingCode, setEditingCode] = useState('')
   const [showEditor, setShowEditor] = useState(false)
@@ -120,15 +126,58 @@ export default function NodesPage() {
 
   const nodes: NodeDefinition[] = nodesData?.nodes || []
 
+  // Every category, from the full list - so the filter chips do not vanish as
+  // you narrow the results and leave no way back.
+  const allCategories = useMemo(
+    () => getSortedCategories([...new Set(nodes.map((n) => n.category || 'uncategorized'))]),
+    [nodes]
+  )
+
+  const filtered = useMemo(() => {
+    const term = search.trim().toLowerCase()
+    return nodes.filter((node) => {
+      if (triggersOnly && !node.isTrigger) return false
+      if (categoryFilter && (node.category || 'uncategorized') !== categoryFilter) return false
+      if (!term) return true
+      // The id is searched as well as the name: it is what a workflow file and
+      // an expression refer to, so it is what somebody arrives knowing.
+      return (
+        node.name?.toLowerCase().includes(term) ||
+        node.id?.toLowerCase().includes(term) ||
+        node.description?.toLowerCase().includes(term) ||
+        (node.category || '').toLowerCase().includes(term)
+      )
+    })
+  }, [nodes, search, categoryFilter, triggersOnly])
+
   // Group nodes by category
   const nodesByCategory = useMemo(() => {
-    return nodes.reduce((acc, node) => {
+    return filtered.reduce((acc, node) => {
       const cat = node.category || 'uncategorized'
       if (!acc[cat]) acc[cat] = []
       acc[cat].push(node)
       return acc
     }, {} as Record<string, NodeDefinition[]>)
-  }, [nodes])
+  }, [filtered])
+
+  // "/" puts the cursor in the search box, which is the fastest way through
+  // eighty-odd nodes and costs nothing to anyone who does not know about it.
+  useEffect(() => {
+    const onKey = (e: KeyboardEvent) => {
+      const el = e.target as HTMLElement | null
+      const typing = !!el && (el.tagName === 'INPUT' || el.tagName === 'TEXTAREA' || el.isContentEditable)
+      if (e.key === '/' && !typing) {
+        e.preventDefault()
+        searchRef.current?.focus()
+      }
+      if (e.key === 'Escape' && document.activeElement === searchRef.current) {
+        setSearch('')
+        searchRef.current?.blur()
+      }
+    }
+    window.addEventListener('keydown', onKey)
+    return () => window.removeEventListener('keydown', onKey)
+  }, [])
 
   // Get sorted category keys
   const sortedCategories = useMemo(() => {
@@ -157,8 +206,94 @@ export default function NodesPage() {
         </button>
       </div>
 
+      {/* Finding one node among eighty-odd was a matter of scrolling and reading
+          every card, so the page opens with the ways of narrowing it down. */}
+      <div className="px-6 py-3 bg-white dark:bg-slate-800 border-b border-gray-200 dark:border-slate-700 space-y-3">
+        <div className="flex items-center gap-3">
+          <div className="relative flex-1 max-w-md">
+            <Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
+            <input
+              ref={searchRef}
+              type="text"
+              value={search}
+              onChange={(e) => setSearch(e.target.value)}
+              placeholder="Search nodes by name, id or description...    /"
+              className="w-full pl-9 pr-8 py-1.5 text-sm border border-gray-300 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-700 text-gray-900 dark:text-gray-100 placeholder-gray-400 focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
+            />
+            {search && (
+              <button
+                onClick={() => setSearch('')}
+                className="absolute right-2 top-1/2 -translate-y-1/2 p-0.5 text-gray-400 hover:text-gray-600 dark:hover:text-gray-200"
+              >
+                <X className="w-4 h-4" />
+              </button>
+            )}
+          </div>
+
+          <button
+            onClick={() => setTriggersOnly((v) => !v)}
+            className={`flex items-center gap-1.5 px-3 py-1.5 text-sm rounded-lg border ${
+              triggersOnly
+                ? 'bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-400 border-amber-300 dark:border-amber-700'
+                : 'text-gray-600 dark:text-gray-400 border-gray-300 dark:border-slate-600 hover:bg-gray-50 dark:hover:bg-slate-700'
+            }`}
+            title="Show only nodes that can start a workflow"
+          >
+            <Zap className="w-4 h-4" />
+            Triggers
+          </button>
+
+          <span className="text-sm text-gray-500 dark:text-gray-400 ml-auto whitespace-nowrap">
+            {filtered.length === nodes.length
+              ? `${nodes.length} nodes`
+              : `${filtered.length} of ${nodes.length}`}
+          </span>
+        </div>
+
+        <div className="flex items-center gap-1.5 flex-wrap">
+          <button
+            onClick={() => setCategoryFilter(null)}
+            className={`px-2.5 py-1 text-xs rounded-full border ${
+              categoryFilter === null
+                ? 'bg-primary-600 text-white border-primary-600'
+                : 'text-gray-600 dark:text-gray-400 border-gray-300 dark:border-slate-600 hover:bg-gray-50 dark:hover:bg-slate-700'
+            }`}
+          >
+            All
+          </button>
+          {allCategories.map((cat) => {
+            const config = getCategoryConfig(cat)
+            const count = nodes.filter((n) => (n.category || 'uncategorized') === cat).length
+            return (
+              <button
+                key={cat}
+                onClick={() => setCategoryFilter(categoryFilter === cat ? null : cat)}
+                className={`px-2.5 py-1 text-xs rounded-full border ${
+                  categoryFilter === cat
+                    ? 'bg-primary-600 text-white border-primary-600'
+                    : 'text-gray-600 dark:text-gray-400 border-gray-300 dark:border-slate-600 hover:bg-gray-50 dark:hover:bg-slate-700'
+                }`}
+              >
+                {config.displayName} <span className="opacity-60">{count}</span>
+              </button>
+            )
+          })}
+        </div>
+      </div>
+
       {/* Content */}
       <div className="flex-1 overflow-auto p-6">
+        {filtered.length === 0 && (
+          <div className="text-center py-16 text-gray-500 dark:text-gray-400">
+            <p>No node matches {search ? `"${search}"` : 'those filters'}.</p>
+            <button
+              onClick={() => { setSearch(''); setCategoryFilter(null); setTriggersOnly(false) }}
+              className="mt-2 text-primary-600 dark:text-primary-400 hover:underline"
+            >
+              Clear the filters
+            </button>
+          </div>
+        )}
         {sortedCategories.map((category) => {
           const categoryNodes = nodesByCategory[category]
           // Use the first node's icon as the category icon
@@ -180,9 +315,16 @@ export default function NodesPage() {
                   className="bg-white dark:bg-slate-800 border border-gray-200 dark:border-slate-700 rounded-lg p-4 hover:shadow-md transition-shadow"
                 >
                   <div className="flex items-start justify-between mb-2">
-                    <div className="flex items-center gap-2">
-                      <FileCode className="w-5 h-5 text-primary-600 dark:text-primary-400" />
-                      <h3 className="font-medium text-gray-900 dark:text-gray-100">{node.name}</h3>
+                    <div className="flex items-center gap-2 min-w-0">
+                      {/* The node's own icon, not the same file glyph on all of
+                          them - it is what it looks like on the canvas. */}
+                      <NodeIcon icon={node.icon} className="w-5 h-5 shrink-0 text-primary-600 dark:text-primary-400" />
+                      <div className="min-w-0">
+                        <h3 className="font-medium text-gray-900 dark:text-gray-100 truncate">{node.name}</h3>
+                        {/* The id is what a workflow and an expression refer to,
+                            and it is not always the name with the spaces taken out. */}
+                        <code className="text-xs text-gray-400 dark:text-gray-500">{node.id}</code>
+                      </div>
                     </div>
                     {node.isTrigger && (
                       <span className="text-xs bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-400 px-2 py-0.5 rounded">