From 6bd855b3c52952fe5b69c14df34b12f5bee5f397 Mon Sep 17 00:00:00 2001
From: grossmj <grossmj@gns3.net>
Date: Mon, 21 Aug 2023 21:32:23 +1000
Subject: [PATCH 1/8] New database schema for better RBAC

---
 gns3server/db/models/__init__.py    |  2 ++
 gns3server/db/models/acl.py         | 41 ++++++++++++++++++++++++++
 gns3server/db/models/images.py      |  6 ++--
 gns3server/db/models/permissions.py |  9 +++---
 gns3server/db/models/resources.py   | 45 +++++++++++++++++++++++++++++
 gns3server/db/models/roles.py       | 13 +++++----
 gns3server/db/models/templates.py   |  4 +--
 gns3server/db/models/users.py       | 15 ++++++----
 8 files changed, 114 insertions(+), 21 deletions(-)
 create mode 100644 gns3server/db/models/acl.py
 create mode 100644 gns3server/db/models/resources.py

diff --git a/gns3server/db/models/__init__.py b/gns3server/db/models/__init__.py
index d10d0668..a643d38c 100644
--- a/gns3server/db/models/__init__.py
+++ b/gns3server/db/models/__init__.py
@@ -16,6 +16,8 @@
 # along with this program.  If not, see <http://www.gnu.org/licenses/>.
 
 from .base import Base
+from .acl import ACL
+from .resources import Resource
 from .users import User, UserGroup
 from .roles import Role
 from .permissions import Permission
diff --git a/gns3server/db/models/acl.py b/gns3server/db/models/acl.py
new file mode 100644
index 00000000..23ed6f8f
--- /dev/null
+++ b/gns3server/db/models/acl.py
@@ -0,0 +1,41 @@
+#!/usr/bin/env python
+#
+# Copyright (C) 2023 GNS3 Technologies Inc.
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+from sqlalchemy import Column, Boolean, ForeignKey
+from sqlalchemy.orm import relationship
+
+from .base import BaseTable, generate_uuid, GUID
+
+import logging
+
+log = logging.getLogger(__name__)
+
+
+class ACL(BaseTable):
+
+    __tablename__ = "acl"
+
+    acl_id = Column(GUID, primary_key=True, default=generate_uuid)
+    allowed = Column(Boolean, default=True)
+    user_id = Column(GUID, ForeignKey('users.user_id', ondelete="CASCADE"))
+    user = relationship("User", back_populates="acl_entries")
+    group_id = Column(GUID, ForeignKey('user_groups.user_group_id', ondelete="CASCADE"))
+    group = relationship("UserGroup", back_populates="acl_entries")
+    resource_id = Column(GUID, ForeignKey('resources.resource_id', ondelete="CASCADE"))
+    resource = relationship("Resource", back_populates="acl_entries")
+    role_id = Column(GUID, ForeignKey('roles.role_id', ondelete="CASCADE"))
+    role = relationship("Role", back_populates="acl_entries")
diff --git a/gns3server/db/models/images.py b/gns3server/db/models/images.py
index 175ab278..529afe4b 100644
--- a/gns3server/db/models/images.py
+++ b/gns3server/db/models/images.py
@@ -21,8 +21,8 @@ from sqlalchemy.orm import relationship
 from .base import Base, BaseTable, GUID
 
 
-image_template_link = Table(
-    "images_templates_link",
+image_template_map = Table(
+    "image_template_map",
     Base.metadata,
     Column("image_id", Integer, ForeignKey("images.image_id", ondelete="CASCADE")),
     Column("template_id", GUID, ForeignKey("templates.template_id", ondelete="CASCADE"))
@@ -40,4 +40,4 @@ class Image(BaseTable):
     image_size = Column(BigInteger)
     checksum = Column(String, index=True)
     checksum_algorithm = Column(String)
-    templates = relationship("Template", secondary=image_template_link, back_populates="images")
+    templates = relationship("Template", secondary=image_template_map, back_populates="images")
diff --git a/gns3server/db/models/permissions.py b/gns3server/db/models/permissions.py
index f7344e31..04029c7e 100644
--- a/gns3server/db/models/permissions.py
+++ b/gns3server/db/models/permissions.py
@@ -25,8 +25,8 @@ import logging
 log = logging.getLogger(__name__)
 
 
-permission_role_link = Table(
-    "permissions_roles_link",
+permission_role_map = Table(
+    "permission_role_map",
     Base.metadata,
     Column("permission_id", GUID, ForeignKey("permissions.permission_id", ondelete="CASCADE")),
     Column("role_id", GUID, ForeignKey("roles.role_id", ondelete="CASCADE"))
@@ -44,7 +44,8 @@ class Permission(BaseTable):
     path = Column(String)
     action = Column(String)
     user_id = Column(GUID, ForeignKey('users.user_id', ondelete="CASCADE"))
-    roles = relationship("Role", secondary=permission_role_link, back_populates="permissions")
+    user = relationship("User", back_populates="permissions")
+    roles = relationship("Role", secondary=permission_role_map, back_populates="permissions")
 
 
 @event.listens_for(Permission.__table__, 'after_create')
@@ -95,7 +96,7 @@ def create_default_roles(target, connection, **kw):
     log.debug("The default permissions have been created in the database")
 
 
-@event.listens_for(permission_role_link, 'after_create')
+@event.listens_for(permission_role_map, 'after_create')
 def add_permissions_to_role(target, connection, **kw):
 
     from .roles import Role
diff --git a/gns3server/db/models/resources.py b/gns3server/db/models/resources.py
new file mode 100644
index 00000000..6cc11ff3
--- /dev/null
+++ b/gns3server/db/models/resources.py
@@ -0,0 +1,45 @@
+#!/usr/bin/env python
+#
+# Copyright (C) 2023 GNS3 Technologies Inc.
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+from sqlalchemy import Column, String, Boolean, ForeignKey
+from sqlalchemy.orm import relationship
+
+from .base import BaseTable, generate_uuid, GUID
+
+import logging
+
+log = logging.getLogger(__name__)
+
+
+class Resource(BaseTable):
+
+    __tablename__ = "resources"
+
+    resource_id = Column(GUID, primary_key=True, default=generate_uuid)
+    name = Column(String, unique=True, index=True)
+    description = Column(String)
+    propagate = Column(Boolean, default=True)
+    user_id = Column(GUID, ForeignKey('users.user_id', ondelete="CASCADE"))
+    user = relationship("User", back_populates="resources")
+    acl_entries = relationship("ACL")
+    parent_id = Column(GUID, ForeignKey("resources.resource_id", ondelete="CASCADE"))
+    children = relationship(
+        "Resource",
+        remote_side=[resource_id],
+        cascade="all, delete-orphan",
+        single_parent=True
+    )
diff --git a/gns3server/db/models/roles.py b/gns3server/db/models/roles.py
index b531a50f..fb4ef4f8 100644
--- a/gns3server/db/models/roles.py
+++ b/gns3server/db/models/roles.py
@@ -19,14 +19,14 @@ from sqlalchemy import Table, Column, String, Boolean, ForeignKey, event
 from sqlalchemy.orm import relationship
 
 from .base import Base, BaseTable, generate_uuid, GUID
-from .permissions import permission_role_link
+from .permissions import permission_role_map
 
 import logging
 
 log = logging.getLogger(__name__)
 
-role_group_link = Table(
-    "roles_groups_link",
+role_group_map = Table(
+    "role_group_map",
     Base.metadata,
     Column("role_id", GUID, ForeignKey("roles.role_id", ondelete="CASCADE")),
     Column("user_group_id", GUID, ForeignKey("user_groups.user_group_id", ondelete="CASCADE"))
@@ -41,8 +41,9 @@ class Role(BaseTable):
     name = Column(String, unique=True, index=True)
     description = Column(String)
     is_builtin = Column(Boolean, default=False)
-    permissions = relationship("Permission", secondary=permission_role_link, back_populates="roles")
-    groups = relationship("UserGroup", secondary=role_group_link, back_populates="roles")
+    permissions = relationship("Permission", secondary=permission_role_map, back_populates="roles")
+    groups = relationship("UserGroup", secondary=role_group_map, back_populates="roles")
+    acl_entries = relationship("ACL")
 
 
 @event.listens_for(Role.__table__, 'after_create')
@@ -59,7 +60,7 @@ def create_default_roles(target, connection, **kw):
     log.debug("The default roles have been created in the database")
 
 
-@event.listens_for(role_group_link, 'after_create')
+@event.listens_for(role_group_map, 'after_create')
 def add_admin_to_group(target, connection, **kw):
 
     from .users import UserGroup
diff --git a/gns3server/db/models/templates.py b/gns3server/db/models/templates.py
index 210ce1c5..a35ed076 100644
--- a/gns3server/db/models/templates.py
+++ b/gns3server/db/models/templates.py
@@ -20,7 +20,7 @@ from sqlalchemy import Boolean, Column, String, Integer, ForeignKey, PickleType
 from sqlalchemy.orm import relationship
 
 from .base import BaseTable, generate_uuid, GUID
-from .images import image_template_link
+from .images import image_template_map
 
 
 class Template(BaseTable):
@@ -37,7 +37,7 @@ class Template(BaseTable):
     usage = Column(String)
     template_type = Column(String)
     compute_id = Column(String)
-    images = relationship("Image", secondary=image_template_link, back_populates="templates")
+    images = relationship("Image", secondary=image_template_map, back_populates="templates")
 
     __mapper_args__ = {
         "polymorphic_identity": "templates",
diff --git a/gns3server/db/models/users.py b/gns3server/db/models/users.py
index 0c85ccbe..cc923aec 100644
--- a/gns3server/db/models/users.py
+++ b/gns3server/db/models/users.py
@@ -19,7 +19,7 @@ from sqlalchemy import Table, Boolean, Column, String, DateTime, ForeignKey, eve
 from sqlalchemy.orm import relationship
 
 from .base import Base, BaseTable, generate_uuid, GUID
-from .roles import role_group_link
+from .roles import role_group_map
 
 from gns3server.config import Config
 from gns3server.services import auth_service
@@ -28,8 +28,8 @@ import logging
 
 log = logging.getLogger(__name__)
 
-user_group_link = Table(
-    "users_groups_link",
+user_group_map = Table(
+    "user_group_map",
     Base.metadata,
     Column("user_id", GUID, ForeignKey("users.user_id", ondelete="CASCADE")),
     Column("user_group_id", GUID, ForeignKey("user_groups.user_group_id", ondelete="CASCADE"))
@@ -48,8 +48,10 @@ class User(BaseTable):
     last_login = Column(DateTime)
     is_active = Column(Boolean, default=True)
     is_superadmin = Column(Boolean, default=False)
-    groups = relationship("UserGroup", secondary=user_group_link, back_populates="users")
+    groups = relationship("UserGroup", secondary=user_group_map, back_populates="users")
+    resources = relationship("Resource")
     permissions = relationship("Permission")
+    acl_entries = relationship("ACL")
 
 
 @event.listens_for(User.__table__, 'after_create')
@@ -77,8 +79,9 @@ class UserGroup(BaseTable):
     user_group_id = Column(GUID, primary_key=True, default=generate_uuid)
     name = Column(String, unique=True, index=True)
     is_builtin = Column(Boolean, default=False)
-    users = relationship("User", secondary=user_group_link, back_populates="groups")
-    roles = relationship("Role", secondary=role_group_link, back_populates="groups")
+    users = relationship("User", secondary=user_group_map, back_populates="groups")
+    roles = relationship("Role", secondary=role_group_map, back_populates="groups")
+    acl_entries = relationship("ACL")
 
 
 @event.listens_for(UserGroup.__table__, 'after_create')

From 60ce1172e0be60ae919ea908e447446d3e08d225 Mon Sep 17 00:00:00 2001
From: grossmj <grossmj@gns3.net>
Date: Sun, 27 Aug 2023 18:20:42 +1000
Subject: [PATCH 2/8] Use an ACL table to check for privileges

---
 gns3server/api/routes/controller/__init__.py  |   8 +-
 gns3server/api/routes/controller/acl.py       | 145 ++++++++
 .../controller/dependencies/authentication.py |  28 --
 .../routes/controller/dependencies/rbac.py    |  78 ++++
 gns3server/api/routes/controller/groups.py    |  68 +---
 .../api/routes/controller/permissions.py      | 161 --------
 gns3server/api/routes/controller/projects.py  |  64 ++--
 gns3server/api/routes/controller/roles.py     |  42 +--
 gns3server/api/routes/controller/templates.py |  29 +-
 gns3server/api/routes/controller/users.py     |  68 +---
 gns3server/controller/appliance_manager.py    |   4 +-
 gns3server/db/models/__init__.py              |   5 +-
 gns3server/db/models/acl.py                   |  15 +-
 gns3server/db/models/permissions.py           | 129 -------
 gns3server/db/models/privileges.py            | 258 +++++++++++++
 .../{resources.py => resource_pools.py}       |  29 +-
 gns3server/db/models/roles.py                 |  42 +--
 gns3server/db/models/users.py                 |  26 +-
 gns3server/db/repositories/rbac.py            | 349 ++++++++----------
 gns3server/db/repositories/users.py           |  57 ---
 gns3server/schemas/__init__.py                |   2 +-
 gns3server/schemas/controller/rbac.py         |  77 ++--
 gns3server/schemas/controller/users.py        |   2 +-
 tests/api/routes/controller/test_acl.py       | 214 +++++++++++
 tests/api/routes/controller/test_groups.py    |  85 -----
 .../api/routes/controller/test_permissions.py | 136 -------
 tests/api/routes/controller/test_roles.py     |  52 +--
 tests/api/routes/controller/test_users.py     |  95 +----
 tests/conftest.py                             |   2 +-
 tests/controller/test_rbac.py                 | 348 ++++++++---------
 30 files changed, 1195 insertions(+), 1423 deletions(-)
 create mode 100644 gns3server/api/routes/controller/acl.py
 create mode 100644 gns3server/api/routes/controller/dependencies/rbac.py
 delete mode 100644 gns3server/api/routes/controller/permissions.py
 delete mode 100644 gns3server/db/models/permissions.py
 create mode 100644 gns3server/db/models/privileges.py
 rename gns3server/db/models/{resources.py => resource_pools.py} (57%)
 create mode 100644 tests/api/routes/controller/test_acl.py
 delete mode 100644 tests/api/routes/controller/test_permissions.py

diff --git a/gns3server/api/routes/controller/__init__.py b/gns3server/api/routes/controller/__init__.py
index 390ce7b3..1b3f2d4f 100644
--- a/gns3server/api/routes/controller/__init__.py
+++ b/gns3server/api/routes/controller/__init__.py
@@ -32,7 +32,7 @@ from . import images
 from . import users
 from . import groups
 from . import roles
-from . import permissions
+from . import acl
 
 from .dependencies.authentication import get_current_active_user
 
@@ -56,10 +56,10 @@ router.include_router(
 )
 
 router.include_router(
-    permissions.router,
+    acl.router,
     dependencies=[Depends(get_current_active_user)],
-    prefix="/permissions",
-    tags=["Permissions"]
+    prefix="/acl",
+    tags=["ACL"]
 )
 
 router.include_router(
diff --git a/gns3server/api/routes/controller/acl.py b/gns3server/api/routes/controller/acl.py
new file mode 100644
index 00000000..2bc4e1db
--- /dev/null
+++ b/gns3server/api/routes/controller/acl.py
@@ -0,0 +1,145 @@
+#!/usr/bin/env python
+#
+# Copyright (C) 2023 GNS3 Technologies Inc.
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+"""
+API routes for ACL.
+"""
+
+import re
+
+from fastapi import APIRouter, Depends, Request, status
+from fastapi.routing import APIRoute
+from uuid import UUID
+from typing import List
+
+
+from gns3server import schemas
+from gns3server.controller.controller_error import (
+    ControllerBadRequestError,
+    ControllerNotFoundError,
+    ControllerForbiddenError,
+)
+
+from gns3server.db.repositories.rbac import RbacRepository
+from .dependencies.database import get_repository
+from .dependencies.authentication import get_current_active_user
+
+import logging
+
+log = logging.getLogger(__name__)
+
+router = APIRouter()
+
+
+@router.get("", response_model=List[schemas.ACE])
+async def get_aces(
+        rbac_repo: RbacRepository = Depends(get_repository(RbacRepository))
+) -> List[schemas.ACE]:
+    """
+    Get all ACL entries.
+    """
+
+    return await rbac_repo.get_aces()
+
+
+@router.post("", response_model=schemas.ACE, status_code=status.HTTP_201_CREATED)
+async def create_ace(
+        request: Request,
+        ace_create: schemas.ACECreate,
+        current_user: schemas.User = Depends(get_current_active_user),
+        rbac_repo: RbacRepository = Depends(get_repository(RbacRepository))
+) -> schemas.ACE:
+    """
+    Create a new ACL entry.
+    """
+
+    for route in request.app.routes:
+        if isinstance(route, APIRoute):
+
+            # remove the prefix (e.g. "/v3") from the route path
+            route_path = re.sub(r"^/v[0-9]", "", route.path)
+            # replace route path ID parameters by a UUID regex
+            route_path = re.sub(r"{\w+_id}", "[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}", route_path)
+            # replace remaining route path parameters by a word matching regex
+            route_path = re.sub(r"/{[\w:]+}", r"/\\w+", route_path)
+
+            if re.fullmatch(route_path, ace_create.path):
+                log.info("Creating ACE for route path", ace_create.path, route_path)
+                return await rbac_repo.create_ace(ace_create)
+
+    raise ControllerBadRequestError(f"Path '{ace_create.path}' doesn't match any existing endpoint")
+
+
+@router.get("/{ace_id}", response_model=schemas.ACE)
+async def get_ace(
+        ace_id: UUID,
+        rbac_repo: RbacRepository = Depends(get_repository(RbacRepository)),
+) -> schemas.ACE:
+    """
+    Get an ACL entry.
+    """
+
+    ace = await rbac_repo.get_ace(ace_id)
+    if not ace:
+        raise ControllerNotFoundError(f"ACL entry '{ace_id}' not found")
+    return ace
+
+
+@router.put("/{ace_id}", response_model=schemas.ACE)
+async def update_ace(
+        ace_id: UUID,
+        ace_update: schemas.ACEUpdate,
+        rbac_repo: RbacRepository = Depends(get_repository(RbacRepository))
+) -> schemas.ACE:
+    """
+    Update an ACL entry.
+    """
+
+    ace = await rbac_repo.get_ace(ace_id)
+    if not ace:
+        raise ControllerNotFoundError(f"ACL entry '{ace_id}' not found")
+
+    return await rbac_repo.update_ace(ace_id, ace_update)
+
+
+@router.delete("/{ace_id}", status_code=status.HTTP_204_NO_CONTENT)
+async def delete_ace(
+    ace_id: UUID,
+    rbac_repo: RbacRepository = Depends(get_repository(RbacRepository)),
+) -> None:
+    """
+    Delete an ACL entry.
+    """
+
+    ace = await rbac_repo.get_ace(ace_id)
+    if not ace:
+        raise ControllerNotFoundError(f"ACL entry '{ace_id}' not found")
+
+    success = await rbac_repo.delete_ace(ace_id)
+    if not success:
+        raise ControllerNotFoundError(f"ACL entry '{ace_id}' could not be deleted")
+
+
+# @router.post("/prune", status_code=status.HTTP_204_NO_CONTENT)
+# async def prune_permissions(
+#         rbac_repo: RbacRepository = Depends(get_repository(RbacRepository))
+# ) -> None:
+#     """
+#     Prune orphaned permissions.
+#     """
+#
+#     await rbac_repo.prune_permissions()
diff --git a/gns3server/api/routes/controller/dependencies/authentication.py b/gns3server/api/routes/controller/dependencies/authentication.py
index ce07e9b3..fa846cb4 100644
--- a/gns3server/api/routes/controller/dependencies/authentication.py
+++ b/gns3server/api/routes/controller/dependencies/authentication.py
@@ -74,21 +74,6 @@ async def get_current_active_user(
             headers={"WWW-Authenticate": "Bearer"},
         )
 
-    # remove the prefix (e.g. "/v3") from URL path
-    path = re.sub(r"^/v[0-9]", "", request.url.path)
-
-    # special case: always authorize access to the "/users/me" endpoint
-    if path == "/users/me":
-        return current_user
-
-    authorized = await rbac_repo.check_user_is_authorized(current_user.user_id, request.method, path)
-    if not authorized:
-        raise HTTPException(
-            status_code=status.HTTP_401_UNAUTHORIZED,
-            detail=f"User is not authorized '{current_user.user_id}' on {request.method} '{path}'",
-            headers={"WWW-Authenticate": "Bearer"},
-        )
-
     return current_user
 
 
@@ -96,7 +81,6 @@ async def get_current_active_user_from_websocket(
         websocket: WebSocket,
         token: str = Query(...),
         user_repo: UsersRepository = Depends(get_repository(UsersRepository)),
-        rbac_repo: RbacRepository = Depends(get_repository(RbacRepository))
 ) -> Optional[schemas.User]:
 
     await websocket.accept()
@@ -121,18 +105,6 @@ async def get_current_active_user_from_websocket(
                 detail=f"'{username}' is not an active user"
             )
 
-        # remove the prefix (e.g. "/v3") from URL path
-        path = re.sub(r"^/v[0-9]", "", websocket.url.path)
-
-        # there are no HTTP methods for web sockets, assuming "GET"...
-        authorized = await rbac_repo.check_user_is_authorized(user.user_id, "GET", path)
-        if not authorized:
-            raise HTTPException(
-                status_code=status.HTTP_401_UNAUTHORIZED,
-                detail=f"User is not authorized '{user.user_id}' on '{path}'",
-                headers={"WWW-Authenticate": "Bearer"},
-            )
-
         return user
 
     except HTTPException as e:
diff --git a/gns3server/api/routes/controller/dependencies/rbac.py b/gns3server/api/routes/controller/dependencies/rbac.py
new file mode 100644
index 00000000..fd6e2dda
--- /dev/null
+++ b/gns3server/api/routes/controller/dependencies/rbac.py
@@ -0,0 +1,78 @@
+#
+# Copyright (C) 2023 GNS3 Technologies Inc.
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+import re
+
+from fastapi import Request, WebSocket, Depends, HTTPException
+from gns3server import schemas
+from gns3server.db.repositories.rbac import RbacRepository
+from .authentication import get_current_active_user, get_current_active_user_from_websocket
+from .database import get_repository
+
+import logging
+
+log = logging.getLogger()
+
+
+def has_privilege(
+        privilege_name: str
+):
+    async def get_user_and_check_privilege(
+            request: Request,
+            current_user: schemas.User = Depends(get_current_active_user),
+            rbac_repo: RbacRepository = Depends(get_repository(RbacRepository))
+    ):
+        if not current_user.is_superadmin:
+            path = re.sub(r"^/v[0-9]", "", request.url.path)  # remove the prefix (e.g. "/v3") from URL path
+            print(f"Checking user {current_user.username} has privilege {privilege_name} on '{path}'")
+            if not await rbac_repo.check_user_has_privilege(current_user.user_id, path, privilege_name):
+                raise HTTPException(status_code=403, detail=f"Permission denied (privilege {privilege_name} is required)")
+            return current_user
+    return get_user_and_check_privilege
+
+
+def has_privilege_on_websocket(
+        privilege_name: str
+):
+    async def get_user_and_check_privilege(
+            websocket: WebSocket,
+            current_user: schemas.User = Depends(get_current_active_user_from_websocket),
+            rbac_repo: RbacRepository = Depends(get_repository(RbacRepository))
+    ):
+        if not current_user.is_superadmin:
+            path = re.sub(r"^/v[0-9]", "", websocket.url.path)  # remove the prefix (e.g. "/v3") from URL path
+            log.debug(f"Checking user {current_user.username} has privilege {privilege_name} on '{path}'")
+            if not await rbac_repo.check_user_has_privilege(current_user.user_id, path, privilege_name):
+                raise HTTPException(status_code=403, detail=f"Permission denied (privilege {privilege_name} is required)")
+            return current_user
+    return get_user_and_check_privilege
+
+# class PrivilegeChecker:
+#
+#     def __init__(self, required_privilege: str) -> None:
+#         self._required_privilege = required_privilege
+#
+#     async def __call__(
+#             self,
+#             current_user: schemas.User = Depends(get_current_active_user),
+#             rbac_repo: RbacRepository = Depends(get_repository(RbacRepository))
+#     ) -> bool:
+#
+#         if not await rbac_repo.check_user_has_privilege(current_user.user_id, "/projects", self._required_privilege):
+#             raise HTTPException(status_code=403, detail=f"Permission denied (privilege {self._required_privilege} is required)")
+#         return True
+
+# Depends(PrivilegeChecker("Project.Audit"))
diff --git a/gns3server/api/routes/controller/groups.py b/gns3server/api/routes/controller/groups.py
index 85ebfdc0..9665ab3a 100644
--- a/gns3server/api/routes/controller/groups.py
+++ b/gns3server/api/routes/controller/groups.py
@@ -78,7 +78,7 @@ async def get_user_group(
         users_repo: UsersRepository = Depends(get_repository(UsersRepository)),
 ) -> schemas.UserGroup:
     """
-    Get an user group.
+    Get a user group.
     """
 
     user_group = await users_repo.get_user_group(user_group_id)
@@ -94,7 +94,7 @@ async def update_user_group(
         users_repo: UsersRepository = Depends(get_repository(UsersRepository))
 ) -> schemas.UserGroup:
     """
-    Update an user group.
+    Update a user group.
     """
     user_group = await users_repo.get_user_group(user_group_id)
     if not user_group:
@@ -115,7 +115,7 @@ async def delete_user_group(
     users_repo: UsersRepository = Depends(get_repository(UsersRepository)),
 ) -> None:
     """
-    Delete an user group
+    Delete a user group
     """
 
     user_group = await users_repo.get_user_group(user_group_id)
@@ -152,7 +152,7 @@ async def add_member_to_group(
         users_repo: UsersRepository = Depends(get_repository(UsersRepository))
 ) -> None:
     """
-    Add member to an user group.
+    Add member to a user group.
     """
 
     user = await users_repo.get_user(user_id)
@@ -174,7 +174,7 @@ async def remove_member_from_group(
     users_repo: UsersRepository = Depends(get_repository(UsersRepository)),
 ) -> None:
     """
-    Remove member from an user group.
+    Remove member from a user group.
     """
 
     user = await users_repo.get_user(user_id)
@@ -184,61 +184,3 @@ async def remove_member_from_group(
     user_group = await users_repo.remove_member_from_user_group(user_group_id, user)
     if not user_group:
         raise ControllerNotFoundError(f"User group '{user_group_id}' not found")
-
-
-@router.get("/{user_group_id}/roles", response_model=List[schemas.Role])
-async def get_user_group_roles(
-        user_group_id: UUID,
-        users_repo: UsersRepository = Depends(get_repository(UsersRepository))
-) -> List[schemas.Role]:
-    """
-    Get all user group roles.
-    """
-
-    return await users_repo.get_user_group_roles(user_group_id)
-
-
-@router.put(
-    "/{user_group_id}/roles/{role_id}",
-    status_code=status.HTTP_204_NO_CONTENT
-)
-async def add_role_to_group(
-        user_group_id: UUID,
-        role_id: UUID,
-        users_repo: UsersRepository = Depends(get_repository(UsersRepository)),
-        rbac_repo: RbacRepository = Depends(get_repository(RbacRepository))
-) -> Response:
-    """
-    Add role to an user group.
-    """
-
-    role = await rbac_repo.get_role(role_id)
-    if not role:
-        raise ControllerNotFoundError(f"Role '{role_id}' not found")
-
-    user_group = await users_repo.add_role_to_user_group(user_group_id, role)
-    if not user_group:
-        raise ControllerNotFoundError(f"User group '{user_group_id}' not found")
-
-
-@router.delete(
-    "/{user_group_id}/roles/{role_id}",
-    status_code=status.HTTP_204_NO_CONTENT
-)
-async def remove_role_from_group(
-    user_group_id: UUID,
-    role_id: UUID,
-    users_repo: UsersRepository = Depends(get_repository(UsersRepository)),
-    rbac_repo: RbacRepository = Depends(get_repository(RbacRepository))
-) -> None:
-    """
-    Remove role from an user group.
-    """
-
-    role = await rbac_repo.get_role(role_id)
-    if not role:
-        raise ControllerNotFoundError(f"Role '{role_id}' not found")
-
-    user_group = await users_repo.remove_role_from_user_group(user_group_id, role)
-    if not user_group:
-        raise ControllerNotFoundError(f"User group '{user_group_id}' not found")
diff --git a/gns3server/api/routes/controller/permissions.py b/gns3server/api/routes/controller/permissions.py
deleted file mode 100644
index d5b31e1b..00000000
--- a/gns3server/api/routes/controller/permissions.py
+++ /dev/null
@@ -1,161 +0,0 @@
-#!/usr/bin/env python
-#
-# Copyright (C) 2021 GNS3 Technologies Inc.
-#
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-#
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU General Public License for more details.
-#
-# You should have received a copy of the GNU General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-
-"""
-API routes for permissions.
-"""
-
-import re
-
-from fastapi import APIRouter, Depends, Response, Request, status
-from fastapi.routing import APIRoute
-from uuid import UUID
-from typing import List
-
-
-from gns3server import schemas
-from gns3server.controller.controller_error import (
-    ControllerBadRequestError,
-    ControllerNotFoundError,
-    ControllerForbiddenError,
-)
-
-from gns3server.db.repositories.rbac import RbacRepository
-from .dependencies.database import get_repository
-from .dependencies.authentication import get_current_active_user
-
-import logging
-
-log = logging.getLogger(__name__)
-
-router = APIRouter()
-
-
-@router.get("", response_model=List[schemas.Permission])
-async def get_permissions(
-        rbac_repo: RbacRepository = Depends(get_repository(RbacRepository))
-) -> List[schemas.Permission]:
-    """
-    Get all permissions.
-    """
-
-    return await rbac_repo.get_permissions()
-
-
-@router.post("", response_model=schemas.Permission, status_code=status.HTTP_201_CREATED)
-async def create_permission(
-        request: Request,
-        permission_create: schemas.PermissionCreate,
-        current_user: schemas.User = Depends(get_current_active_user),
-        rbac_repo: RbacRepository = Depends(get_repository(RbacRepository))
-) -> schemas.Permission:
-    """
-    Create a new permission.
-    """
-
-    # TODO: should we prevent having multiple permissions with same methods/path?
-    #if await rbac_repo.check_permission_exists(permission_create):
-    #    raise ControllerBadRequestError(f"Permission '{permission_create.methods} {permission_create.path} "
-    #                                    f"{permission_create.action}' already exists")
-
-    for route in request.app.routes:
-        if isinstance(route, APIRoute):
-
-            # remove the prefix (e.g. "/v3") from the route path
-            route_path = re.sub(r"^/v[0-9]", "", route.path)
-            # replace route path ID parameters by an UUID regex
-            route_path = re.sub(r"{\w+_id}", "[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}", route_path)
-            # replace remaining route path parameters by an word matching regex
-            route_path = re.sub(r"/{[\w:]+}", r"/\\w+", route_path)
-
-            # the permission can match multiple routes
-            if permission_create.path.endswith("/*"):
-                route_path += r"/.*"
-
-            if re.fullmatch(route_path, permission_create.path):
-                for method in permission_create.methods:
-                    if method in list(route.methods):
-                        # check user has the right to add the permission (i.e has already to right on the path)
-                        if not await rbac_repo.check_user_is_authorized(current_user.user_id, method, permission_create.path):
-                            raise ControllerForbiddenError(f"User '{current_user.username}' doesn't have the rights to "
-                                                           f"add a permission on {method} {permission_create.path} or "
-                                                           f"the endpoint doesn't exist")
-                        return await rbac_repo.create_permission(permission_create)
-
-    raise ControllerBadRequestError(f"Permission '{permission_create.methods} {permission_create.path}' "
-                                    f"doesn't match any existing endpoint")
-
-
-@router.get("/{permission_id}", response_model=schemas.Permission)
-async def get_permission(
-        permission_id: UUID,
-        rbac_repo: RbacRepository = Depends(get_repository(RbacRepository)),
-) -> schemas.Permission:
-    """
-    Get a permission.
-    """
-
-    permission = await rbac_repo.get_permission(permission_id)
-    if not permission:
-        raise ControllerNotFoundError(f"Permission '{permission_id}' not found")
-    return permission
-
-
-@router.put("/{permission_id}", response_model=schemas.Permission)
-async def update_permission(
-        permission_id: UUID,
-        permission_update: schemas.PermissionUpdate,
-        rbac_repo: RbacRepository = Depends(get_repository(RbacRepository))
-) -> schemas.Permission:
-    """
-    Update a permission.
-    """
-
-    permission = await rbac_repo.get_permission(permission_id)
-    if not permission:
-        raise ControllerNotFoundError(f"Permission '{permission_id}' not found")
-
-    return await rbac_repo.update_permission(permission_id, permission_update)
-
-
-@router.delete("/{permission_id}", status_code=status.HTTP_204_NO_CONTENT)
-async def delete_permission(
-    permission_id: UUID,
-    rbac_repo: RbacRepository = Depends(get_repository(RbacRepository)),
-) -> None:
-    """
-    Delete a permission.
-    """
-
-    permission = await rbac_repo.get_permission(permission_id)
-    if not permission:
-        raise ControllerNotFoundError(f"Permission '{permission_id}' not found")
-
-    success = await rbac_repo.delete_permission(permission_id)
-    if not success:
-        raise ControllerNotFoundError(f"Permission '{permission_id}' could not be deleted")
-
-
-@router.post("/prune", status_code=status.HTTP_204_NO_CONTENT)
-async def prune_permissions(
-        rbac_repo: RbacRepository = Depends(get_repository(RbacRepository))
-) -> None:
-    """
-    Prune orphaned permissions.
-    """
-
-    await rbac_repo.prune_permissions()
diff --git a/gns3server/api/routes/controller/projects.py b/gns3server/api/routes/controller/projects.py
index 1727fe0c..ca441d85 100644
--- a/gns3server/api/routes/controller/projects.py
+++ b/gns3server/api/routes/controller/projects.py
@@ -49,7 +49,8 @@ from gns3server.db.repositories.rbac import RbacRepository
 from gns3server.db.repositories.templates import TemplatesRepository
 from gns3server.services.templates import TemplatesService
 
-from .dependencies.authentication import get_current_active_user, get_current_active_user_from_websocket
+from .dependencies.authentication import get_current_active_user
+from .dependencies.rbac import has_privilege, has_privilege_on_websocket
 from .dependencies.database import get_repository
 
 responses = {404: {"model": schemas.ErrorMessage, "description": "Could not find project"}}
@@ -84,9 +85,11 @@ async def get_projects(
     else:
         user_projects = []
         for project in controller.projects.values():
-            authorized = await rbac_repo.check_user_is_authorized(
-                current_user.user_id, "GET", f"/projects/{project.id}")
-            if authorized:
+            if await rbac_repo.check_user_has_privilege(
+                    current_user.user_id,
+                    f"/projects/{project.id}",
+                    "Project.Audit"
+            ):
                 user_projects.append(project.asdict())
     return user_projects
 
@@ -97,11 +100,10 @@ async def get_projects(
     response_model=schemas.Project,
     response_model_exclude_unset=True,
     responses={409: {"model": schemas.ErrorMessage, "description": "Could not create project"}},
+    dependencies=[Depends(has_privilege("Project.Allocate"))]
 )
 async def create_project(
         project_data: schemas.ProjectCreate,
-        current_user: schemas.User = Depends(get_current_active_user),
-        rbac_repo: RbacRepository = Depends(get_repository(RbacRepository))
 ) -> schemas.Project:
     """
     Create a new project.
@@ -109,12 +111,11 @@ async def create_project(
 
     controller = Controller.instance()
     project = await controller.add_project(**jsonable_encoder(project_data, exclude_unset=True))
-    await rbac_repo.add_permission_to_user_with_path(current_user.user_id, f"/projects/{project.id}/*")
     return project.asdict()
 
 
-@router.get("/{project_id}", response_model=schemas.Project, dependencies=[Depends(get_current_active_user)])
-def get_project(project: Project = Depends(dep_project)) -> schemas.Project:
+@router.get("/{project_id}", response_model=schemas.Project, dependencies=[Depends(has_privilege("Project.Audit"))])
+async def get_project(project: Project = Depends(dep_project)) -> schemas.Project:
     """
     Return a project.
     """
@@ -126,7 +127,7 @@ def get_project(project: Project = Depends(dep_project)) -> schemas.Project:
     "/{project_id}",
     response_model=schemas.Project,
     response_model_exclude_unset=True,
-    dependencies=[Depends(get_current_active_user)]
+    dependencies=[Depends(has_privilege("Project.Modify"))]
 )
 async def update_project(
         project_data: schemas.ProjectUpdate,
@@ -143,11 +144,10 @@ async def update_project(
 @router.delete(
     "/{project_id}",
     status_code=status.HTTP_204_NO_CONTENT,
-    dependencies=[Depends(get_current_active_user)]
+    dependencies=[Depends(has_privilege("Project.Allocate"))]
 )
 async def delete_project(
-        project: Project = Depends(dep_project),
-        rbac_repo: RbacRepository = Depends(get_repository(RbacRepository))
+        project: Project = Depends(dep_project)
 ) -> None:
     """
     Delete a project.
@@ -156,10 +156,9 @@ async def delete_project(
     controller = Controller.instance()
     await project.delete()
     controller.remove_project(project)
-    await rbac_repo.delete_all_permissions_with_path(f"/projects/{project.id}")
 
 
-@router.get("/{project_id}/stats", dependencies=[Depends(get_current_active_user)])
+@router.get("/{project_id}/stats", dependencies=[Depends(has_privilege("Project.Audit"))])
 def get_project_stats(project: Project = Depends(dep_project)) -> dict:
     """
     Return a project statistics.
@@ -172,7 +171,7 @@ def get_project_stats(project: Project = Depends(dep_project)) -> dict:
     "/{project_id}/close",
     status_code=status.HTTP_204_NO_CONTENT,
     responses={**responses, 409: {"model": schemas.ErrorMessage, "description": "Could not close project"}},
-    dependencies=[Depends(get_current_active_user)]
+    dependencies=[Depends(has_privilege("Project.Allocate"))]
 )
 async def close_project(project: Project = Depends(dep_project)) -> None:
     """
@@ -187,7 +186,7 @@ async def close_project(project: Project = Depends(dep_project)) -> None:
     status_code=status.HTTP_201_CREATED,
     response_model=schemas.Project,
     responses={**responses, 409: {"model": schemas.ErrorMessage, "description": "Could not open project"}},
-    dependencies=[Depends(get_current_active_user)]
+    dependencies=[Depends(has_privilege("Project.Allocate"))]
 )
 async def open_project(project: Project = Depends(dep_project)) -> schemas.Project:
     """
@@ -203,7 +202,7 @@ async def open_project(project: Project = Depends(dep_project)) -> schemas.Proje
     status_code=status.HTTP_201_CREATED,
     response_model=schemas.Project,
     responses={**responses, 409: {"model": schemas.ErrorMessage, "description": "Could not load project"}},
-    dependencies=[Depends(get_current_active_user)]
+    dependencies=[Depends(has_privilege("Project.Allocate"))]
 )
 async def load_project(path: str = Body(..., embed=True)) -> schemas.Project:
     """
@@ -216,7 +215,7 @@ async def load_project(path: str = Body(..., embed=True)) -> schemas.Project:
     return project.asdict()
 
 
-@router.get("/{project_id}/notifications", dependencies=[Depends(get_current_active_user)])
+@router.get("/{project_id}/notifications", dependencies=[Depends(has_privilege("Project.Audit"))])
 async def project_http_notifications(project_id: UUID) -> StreamingResponse:
     """
     Receive project notifications about the controller from HTTP stream.
@@ -252,7 +251,7 @@ async def project_http_notifications(project_id: UUID) -> StreamingResponse:
 async def project_ws_notifications(
         project_id: UUID,
         websocket: WebSocket,
-        current_user: schemas.User = Depends(get_current_active_user_from_websocket)
+        current_user: schemas.User = Depends(has_privilege_on_websocket("Project.Audit"))
 ) -> None:
     """
     Receive project notifications about the controller from WebSocket.
@@ -288,7 +287,7 @@ async def project_ws_notifications(
                 await project.close()
 
 
-@router.get("/{project_id}/export", dependencies=[Depends(get_current_active_user)])
+@router.get("/{project_id}/export", dependencies=[Depends(has_privilege("Project.Audit"))])
 async def export_project(
     project: Project = Depends(dep_project),
     include_snapshots: bool = False,
@@ -345,7 +344,7 @@ async def export_project(
 
             log.info(f"Project '{project.name}' exported in {time.time() - begin:.4f} seconds")
 
-    # Will be raise if you have no space left or permission issue on your temporary directory
+    # Will be raised if you have no space left or permission issue on your temporary directory
     # RuntimeError: something was wrong during the zip process
     except (ValueError, OSError, RuntimeError) as e:
         raise ConnectionError(f"Cannot export project: {e}")
@@ -358,7 +357,7 @@ async def export_project(
     "/{project_id}/import",
     status_code=status.HTTP_201_CREATED,
     response_model=schemas.Project,
-    dependencies=[Depends(get_current_active_user)]
+    dependencies=[Depends(has_privilege("Project.Allocate"))]
 )
 async def import_project(
         project_id: UUID,
@@ -394,13 +393,11 @@ async def import_project(
     status_code=status.HTTP_201_CREATED,
     response_model=schemas.Project,
     responses={**responses, 409: {"model": schemas.ErrorMessage, "description": "Could not duplicate project"}},
-    dependencies=[Depends(get_current_active_user)]
+    dependencies=[Depends(has_privilege("Project.Allocate"))]
 )
 async def duplicate_project(
         project_data: schemas.ProjectDuplicate,
-        project: Project = Depends(dep_project),
-        current_user: schemas.User = Depends(get_current_active_user),
-        rbac_repo: RbacRepository = Depends(get_repository(RbacRepository))
+        project: Project = Depends(dep_project)
 ) -> schemas.Project:
     """
     Duplicate a project.
@@ -410,11 +407,10 @@ async def duplicate_project(
     new_project = await project.duplicate(
         name=project_data.name, reset_mac_addresses=reset_mac_addresses
     )
-    await rbac_repo.add_permission_to_user_with_path(current_user.user_id, f"/projects/{new_project.id}/*")
     return new_project.asdict()
 
 
-@router.get("/{project_id}/locked", dependencies=[Depends(get_current_active_user)])
+@router.get("/{project_id}/locked", dependencies=[Depends(has_privilege("Project.Audit"))])
 async def locked_project(project: Project = Depends(dep_project)) -> bool:
     """
     Returns whether a project is locked or not
@@ -426,7 +422,7 @@ async def locked_project(project: Project = Depends(dep_project)) -> bool:
 @router.post(
     "/{project_id}/lock",
     status_code=status.HTTP_204_NO_CONTENT,
-    dependencies=[Depends(get_current_active_user)]
+    dependencies=[Depends(has_privilege("Project.Modify"))]
 )
 async def lock_project(project: Project = Depends(dep_project)) -> None:
     """
@@ -439,7 +435,7 @@ async def lock_project(project: Project = Depends(dep_project)) -> None:
 @router.post(
     "/{project_id}/unlock",
     status_code=status.HTTP_204_NO_CONTENT,
-    dependencies=[Depends(get_current_active_user)]
+    dependencies=[Depends(has_privilege("Project.Modify"))]
 )
 async def unlock_project(project: Project = Depends(dep_project)) -> None:
     """
@@ -449,7 +445,7 @@ async def unlock_project(project: Project = Depends(dep_project)) -> None:
     project.unlock()
 
 
-@router.get("/{project_id}/files/{file_path:path}", dependencies=[Depends(get_current_active_user)])
+@router.get("/{project_id}/files/{file_path:path}", dependencies=[Depends(has_privilege("Project.Audit"))])
 async def get_file(file_path: str, project: Project = Depends(dep_project)) -> FileResponse:
     """
     Return a file from a project.
@@ -472,7 +468,7 @@ async def get_file(file_path: str, project: Project = Depends(dep_project)) -> F
 @router.post(
     "/{project_id}/files/{file_path:path}",
     status_code=status.HTTP_204_NO_CONTENT,
-    dependencies=[Depends(get_current_active_user)]
+    dependencies=[Depends(has_privilege("Project.Modify"))]
 )
 async def write_file(file_path: str, request: Request, project: Project = Depends(dep_project)) -> None:
     """
@@ -505,7 +501,7 @@ async def write_file(file_path: str, request: Request, project: Project = Depend
     response_model=schemas.Node,
     status_code=status.HTTP_201_CREATED,
     responses={404: {"model": schemas.ErrorMessage, "description": "Could not find project or template"}},
-    dependencies=[Depends(get_current_active_user)]
+    dependencies=[Depends(has_privilege("Node.Allocate"))]
 )
 async def create_node_from_template(
     project_id: UUID,
diff --git a/gns3server/api/routes/controller/roles.py b/gns3server/api/routes/controller/roles.py
index 2c9ee0d6..f8c3b9e6 100644
--- a/gns3server/api/routes/controller/roles.py
+++ b/gns3server/api/routes/controller/roles.py
@@ -123,57 +123,57 @@ async def delete_role(
         raise ControllerError(f"Role '{role_id}' could not be deleted")
 
 
-@router.get("/{role_id}/permissions", response_model=List[schemas.Permission])
-async def get_role_permissions(
+@router.get("/{role_id}/privileges", response_model=List[schemas.Privilege])
+async def get_role_privileges(
         role_id: UUID,
         rbac_repo: RbacRepository = Depends(get_repository(RbacRepository))
-) -> List[schemas.Permission]:
+) -> List[schemas.Privilege]:
     """
-    Get all role permissions.
+    Get all role privileges.
     """
 
-    return await rbac_repo.get_role_permissions(role_id)
+    return await rbac_repo.get_role_privileges(role_id)
 
 
 @router.put(
-    "/{role_id}/permissions/{permission_id}",
+    "/{role_id}/privileges/{privilege_id}",
     status_code=status.HTTP_204_NO_CONTENT
 )
-async def add_permission_to_role(
+async def add_privilege_to_role(
         role_id: UUID,
-        permission_id: UUID,
+        privilege_id: UUID,
         rbac_repo: RbacRepository = Depends(get_repository(RbacRepository))
 ) -> None:
     """
-    Add a permission to a role.
+    Add a privilege to a role.
     """
 
-    permission = await rbac_repo.get_permission(permission_id)
-    if not permission:
-        raise ControllerNotFoundError(f"Permission '{permission_id}' not found")
+    privilege = await rbac_repo.get_privilege(privilege_id)
+    if not privilege:
+        raise ControllerNotFoundError(f"Privilege '{privilege_id}' not found")
 
-    role = await rbac_repo.add_permission_to_role(role_id, permission)
+    role = await rbac_repo.add_privilege_to_role(role_id, privilege)
     if not role:
         raise ControllerNotFoundError(f"Role '{role_id}' not found")
 
 
 @router.delete(
-    "/{role_id}/permissions/{permission_id}",
+    "/{role_id}/privileges/{privilege_id}",
     status_code=status.HTTP_204_NO_CONTENT
 )
-async def remove_permission_from_role(
+async def remove_privilege_from_role(
     role_id: UUID,
-    permission_id: UUID,
+    privilege_id: UUID,
     rbac_repo: RbacRepository = Depends(get_repository(RbacRepository)),
 ) -> None:
     """
-    Remove member from an user group.
+    Remove privilege from a role.
     """
 
-    permission = await rbac_repo.get_permission(permission_id)
-    if not permission:
-        raise ControllerNotFoundError(f"Permission '{permission_id}' not found")
+    privilege = await rbac_repo.get_privilege(privilege_id)
+    if not privilege:
+        raise ControllerNotFoundError(f"Privilege '{privilege_id}' not found")
 
-    role = await rbac_repo.remove_permission_from_role(role_id, permission)
+    role = await rbac_repo.remove_privilege_from_role(role_id, privilege)
     if not role:
         raise ControllerNotFoundError(f"Role '{role_id}' not found")
diff --git a/gns3server/api/routes/controller/templates.py b/gns3server/api/routes/controller/templates.py
index 3dfe2ceb..fee359be 100644
--- a/gns3server/api/routes/controller/templates.py
+++ b/gns3server/api/routes/controller/templates.py
@@ -46,17 +46,13 @@ router = APIRouter(responses=responses)
 @router.post("", response_model=schemas.Template, status_code=status.HTTP_201_CREATED)
 async def create_template(
     template_create: schemas.TemplateCreate,
-    templates_repo: TemplatesRepository = Depends(get_repository(TemplatesRepository)),
-    current_user: schemas.User = Depends(get_current_active_user),
-    rbac_repo: RbacRepository = Depends(get_repository(RbacRepository))
+    templates_repo: TemplatesRepository = Depends(get_repository(TemplatesRepository))
 ) -> schemas.Template:
     """
     Create a new template.
     """
 
     template = await TemplatesService(templates_repo).create_template(template_create)
-    template_id = template.get("template_id")
-    await rbac_repo.add_permission_to_user_with_path(current_user.user_id, f"/templates/{template_id}/*")
     return template
 
 
@@ -108,7 +104,7 @@ async def delete_template(
     """
 
     await TemplatesService(templates_repo).delete_template(template_id)
-    await rbac_repo.delete_all_permissions_with_path(f"/templates/{template_id}")
+    #await rbac_repo.delete_all_permissions_with_path(f"/templates/{template_id}")
     if prune_images:
         await images_repo.prune_images()
 
@@ -129,27 +125,24 @@ async def get_templates(
     else:
         user_templates = []
         for template in templates:
-            if template.get("builtin") is True:
-                user_templates.append(template)
-                continue
-            template_id = template.get("template_id")
-            authorized = await rbac_repo.check_user_is_authorized(
-                current_user.user_id, "GET", f"/templates/{template_id}")
-            if authorized:
-                user_templates.append(template)
+            # if template.get("builtin") is True:
+            #     user_templates.append(template)
+            #     continue
+            # template_id = template.get("template_id")
+            # authorized = await rbac_repo.check_user_is_authorized(
+            #     current_user.user_id, "GET", f"/templates/{template_id}")
+            # if authorized:
+            user_templates.append(template)
     return user_templates
 
 
 @router.post("/{template_id}/duplicate", response_model=schemas.Template, status_code=status.HTTP_201_CREATED)
 async def duplicate_template(
-        template_id: UUID, templates_repo: TemplatesRepository = Depends(get_repository(TemplatesRepository)),
-        current_user: schemas.User = Depends(get_current_active_user),
-        rbac_repo: RbacRepository = Depends(get_repository(RbacRepository))
+        template_id: UUID, templates_repo: TemplatesRepository = Depends(get_repository(TemplatesRepository))
 ) -> schemas.Template:
     """
     Duplicate a template.
     """
 
     template = await TemplatesService(templates_repo).duplicate_template(template_id)
-    await rbac_repo.add_permission_to_user_with_path(current_user.user_id, f"/templates/{template_id}/*")
     return template
diff --git a/gns3server/api/routes/controller/users.py b/gns3server/api/routes/controller/users.py
index 577e1167..7dd9c2c8 100644
--- a/gns3server/api/routes/controller/users.py
+++ b/gns3server/api/routes/controller/users.py
@@ -155,7 +155,7 @@ async def get_user(
         users_repo: UsersRepository = Depends(get_repository(UsersRepository)),
 ) -> schemas.User:
     """
-    Get an user.
+    Get a user.
     """
 
     user = await users_repo.get_user(user_id)
@@ -171,7 +171,7 @@ async def update_user(
         users_repo: UsersRepository = Depends(get_repository(UsersRepository))
 ) -> schemas.User:
     """
-    Update an user.
+    Update a user.
     """
 
     if user_update.username and await users_repo.get_user_by_username(user_update.username):
@@ -196,7 +196,7 @@ async def delete_user(
     users_repo: UsersRepository = Depends(get_repository(UsersRepository)),
 ) -> None:
     """
-    Delete an user.
+    Delete a user.
     """
 
     user = await users_repo.get_user(user_id)
@@ -225,65 +225,3 @@ async def get_user_memberships(
     """
 
     return await users_repo.get_user_memberships(user_id)
-
-
-@router.get(
-    "/{user_id}/permissions",
-    dependencies=[Depends(get_current_active_user)],
-    response_model=List[schemas.Permission]
-)
-async def get_user_permissions(
-        user_id: UUID,
-        rbac_repo: RbacRepository = Depends(get_repository(RbacRepository))
-) -> List[schemas.Permission]:
-    """
-    Get user permissions.
-    """
-
-    return await rbac_repo.get_user_permissions(user_id)
-
-
-@router.put(
-    "/{user_id}/permissions/{permission_id}",
-    dependencies=[Depends(get_current_active_user)],
-    status_code=status.HTTP_204_NO_CONTENT
-)
-async def add_permission_to_user(
-        user_id: UUID,
-        permission_id: UUID,
-        rbac_repo: RbacRepository = Depends(get_repository(RbacRepository))
-) -> None:
-    """
-    Add a permission to an user.
-    """
-
-    permission = await rbac_repo.get_permission(permission_id)
-    if not permission:
-        raise ControllerNotFoundError(f"Permission '{permission_id}' not found")
-
-    user = await rbac_repo.add_permission_to_user(user_id, permission)
-    if not user:
-        raise ControllerNotFoundError(f"User '{user_id}' not found")
-
-
-@router.delete(
-    "/{user_id}/permissions/{permission_id}",
-    dependencies=[Depends(get_current_active_user)],
-    status_code=status.HTTP_204_NO_CONTENT
-)
-async def remove_permission_from_user(
-    user_id: UUID,
-    permission_id: UUID,
-    rbac_repo: RbacRepository = Depends(get_repository(RbacRepository)),
-) -> None:
-    """
-    Remove permission from an user.
-    """
-
-    permission = await rbac_repo.get_permission(permission_id)
-    if not permission:
-        raise ControllerNotFoundError(f"Permission '{permission_id}' not found")
-
-    user = await rbac_repo.remove_permission_from_user(user_id, permission)
-    if not user:
-        raise ControllerNotFoundError(f"User '{user_id}' not found")
diff --git a/gns3server/controller/appliance_manager.py b/gns3server/controller/appliance_manager.py
index 831aa70f..ac8bd7fd 100644
--- a/gns3server/controller/appliance_manager.py
+++ b/gns3server/controller/appliance_manager.py
@@ -213,8 +213,8 @@ class ApplianceManager:
         except ValidationError as e:
             raise ControllerError(message=f"Could not validate template data: {e}")
         template = await TemplatesService(templates_repo).create_template(template_create)
-        template_id = template.get("template_id")
-        await rbac_repo.add_permission_to_user_with_path(current_user.user_id, f"/templates/{template_id}/*")
+        #template_id = template.get("template_id")
+        #await rbac_repo.add_permission_to_user_with_path(current_user.user_id, f"/templates/{template_id}/*")
         log.info(f"Template '{template.get('name')}' has been created")
 
     async def _appliance_to_template(self, appliance: Appliance, version: str = None) -> dict:
diff --git a/gns3server/db/models/__init__.py b/gns3server/db/models/__init__.py
index a643d38c..91d6a314 100644
--- a/gns3server/db/models/__init__.py
+++ b/gns3server/db/models/__init__.py
@@ -16,11 +16,10 @@
 # along with this program.  If not, see <http://www.gnu.org/licenses/>.
 
 from .base import Base
-from .acl import ACL
-from .resources import Resource
+from .acl import ACE
 from .users import User, UserGroup
 from .roles import Role
-from .permissions import Permission
+from .privileges import Privilege
 from .computes import Compute
 from .images import Image
 from .templates import (
diff --git a/gns3server/db/models/acl.py b/gns3server/db/models/acl.py
index 23ed6f8f..fe033262 100644
--- a/gns3server/db/models/acl.py
+++ b/gns3server/db/models/acl.py
@@ -15,7 +15,7 @@
 # You should have received a copy of the GNU General Public License
 # along with this program.  If not, see <http://www.gnu.org/licenses/>.
 
-from sqlalchemy import Column, Boolean, ForeignKey
+from sqlalchemy import Column, String, Boolean, ForeignKey, CheckConstraint
 from sqlalchemy.orm import relationship
 
 from .base import BaseTable, generate_uuid, GUID
@@ -25,17 +25,22 @@ import logging
 log = logging.getLogger(__name__)
 
 
-class ACL(BaseTable):
+class ACE(BaseTable):
 
     __tablename__ = "acl"
 
-    acl_id = Column(GUID, primary_key=True, default=generate_uuid)
+    ace_id = Column(GUID, primary_key=True, default=generate_uuid)
+    path = Column(String)
+    propagate = Column(Boolean, default=True)
     allowed = Column(Boolean, default=True)
+    type: str = Column(String)
     user_id = Column(GUID, ForeignKey('users.user_id', ondelete="CASCADE"))
     user = relationship("User", back_populates="acl_entries")
     group_id = Column(GUID, ForeignKey('user_groups.user_group_id', ondelete="CASCADE"))
     group = relationship("UserGroup", back_populates="acl_entries")
-    resource_id = Column(GUID, ForeignKey('resources.resource_id', ondelete="CASCADE"))
-    resource = relationship("Resource", back_populates="acl_entries")
     role_id = Column(GUID, ForeignKey('roles.role_id', ondelete="CASCADE"))
     role = relationship("Role", back_populates="acl_entries")
+
+    __table_args__ = (
+        CheckConstraint("(user_id IS NOT NULL AND type = 'user') OR (group_id IS NOT NULL AND type = 'group')"),
+    )
diff --git a/gns3server/db/models/permissions.py b/gns3server/db/models/permissions.py
deleted file mode 100644
index 04029c7e..00000000
--- a/gns3server/db/models/permissions.py
+++ /dev/null
@@ -1,129 +0,0 @@
-#!/usr/bin/env python
-#
-# Copyright (C) 2021 GNS3 Technologies Inc.
-#
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-#
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU General Public License for more details.
-#
-# You should have received a copy of the GNU General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-
-from sqlalchemy import Table, Column, String, ForeignKey, event
-from sqlalchemy.orm import relationship
-
-from .base import Base, BaseTable, generate_uuid, GUID, ListType
-
-import logging
-
-log = logging.getLogger(__name__)
-
-
-permission_role_map = Table(
-    "permission_role_map",
-    Base.metadata,
-    Column("permission_id", GUID, ForeignKey("permissions.permission_id", ondelete="CASCADE")),
-    Column("role_id", GUID, ForeignKey("roles.role_id", ondelete="CASCADE"))
-
-)
-
-
-class Permission(BaseTable):
-
-    __tablename__ = "permissions"
-
-    permission_id = Column(GUID, primary_key=True, default=generate_uuid)
-    description = Column(String)
-    methods = Column(ListType)
-    path = Column(String)
-    action = Column(String)
-    user_id = Column(GUID, ForeignKey('users.user_id', ondelete="CASCADE"))
-    user = relationship("User", back_populates="permissions")
-    roles = relationship("Role", secondary=permission_role_map, back_populates="permissions")
-
-
-@event.listens_for(Permission.__table__, 'after_create')
-def create_default_roles(target, connection, **kw):
-
-    default_permissions = [
-        {
-            "description": "Allow access to all endpoints",
-            "methods": ["GET", "POST", "PUT", "DELETE"],
-            "path": "/",
-            "action": "ALLOW"
-        },
-        {
-            "description": "Allow to receive controller notifications",
-            "methods": ["GET"],
-            "path": "/notifications",
-            "action": "ALLOW"
-        },
-        {
-            "description": "Allow to create and list projects",
-            "methods": ["GET", "POST"],
-            "path": "/projects",
-            "action": "ALLOW"
-        },
-        {
-            "description": "Allow to create and list templates",
-            "methods": ["GET", "POST"],
-            "path": "/templates",
-            "action": "ALLOW"
-        },
-        {
-            "description": "Allow to list computes",
-            "methods": ["GET"],
-            "path": "/computes/*",
-            "action": "ALLOW"
-        },
-        {
-            "description": "Allow access to all symbol endpoints",
-            "methods": ["GET", "POST"],
-            "path": "/symbols/*",
-            "action": "ALLOW"
-        },
-    ]
-
-    stmt = target.insert().values(default_permissions)
-    connection.execute(stmt)
-    connection.commit()
-    log.debug("The default permissions have been created in the database")
-
-
-@event.listens_for(permission_role_map, 'after_create')
-def add_permissions_to_role(target, connection, **kw):
-
-    from .roles import Role
-    roles_table = Role.__table__
-    stmt = roles_table.select().where(roles_table.c.name == "Administrator")
-    result = connection.execute(stmt)
-    role_id = result.first().role_id
-
-    permissions_table = Permission.__table__
-    stmt = permissions_table.select().where(permissions_table.c.path == "/")
-    result = connection.execute(stmt)
-    permission_id = result.first().permission_id
-
-    # add root path to the "Administrator" role
-    stmt = target.insert().values(permission_id=permission_id, role_id=role_id)
-    connection.execute(stmt)
-
-    stmt = roles_table.select().where(roles_table.c.name == "User")
-    result = connection.execute(stmt)
-    role_id = result.first().role_id
-
-    # add minimum required paths to the "User" role
-    for path in ("/notifications", "/projects", "/templates", "/computes/*", "/symbols/*"):
-        stmt = permissions_table.select().where(permissions_table.c.path == path)
-        result = connection.execute(stmt)
-        permission_id = result.first().permission_id
-        stmt = target.insert().values(permission_id=permission_id, role_id=role_id)
-        connection.execute(stmt)
-
-    connection.commit()
diff --git a/gns3server/db/models/privileges.py b/gns3server/db/models/privileges.py
new file mode 100644
index 00000000..b3da8ebf
--- /dev/null
+++ b/gns3server/db/models/privileges.py
@@ -0,0 +1,258 @@
+#!/usr/bin/env python
+#
+# Copyright (C) 2023 GNS3 Technologies Inc.
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+from sqlalchemy import Table, Column, String, ForeignKey, event
+from sqlalchemy.orm import relationship
+
+from .base import Base, BaseTable, generate_uuid, GUID
+
+import logging
+
+log = logging.getLogger(__name__)
+
+
+privilege_role_map = Table(
+    "privilege_role_map",
+    Base.metadata,
+    Column("privilege_id", GUID, ForeignKey("privileges.privilege_id", ondelete="CASCADE")),
+    Column("role_id", GUID, ForeignKey("roles.role_id", ondelete="CASCADE"))
+)
+
+
+class Privilege(BaseTable):
+
+    __tablename__ = "privileges"
+
+    privilege_id = Column(GUID, primary_key=True, default=generate_uuid)
+    name = Column(String)
+    description = Column(String)
+    roles = relationship("Role", secondary=privilege_role_map, back_populates="privileges")
+
+
+@event.listens_for(Privilege.__table__, 'after_create')
+def create_default_roles(target, connection, **kw):
+
+    default_privileges = [
+        {
+            "description": "Create or delete a user",
+            "name": "User.Allocate"
+        },
+        {
+            "description": "View a user",
+            "name": "User.Audit"
+        },
+        {
+            "description": "Update a user",
+            "name": "User.Modify"
+        },
+        {
+            "description": "Create or delete a group",
+            "name": "Group.Allocate"
+        },
+        {
+            "description": "View a group",
+            "name": "Group.Audit"
+        },
+        {
+            "description": "Update a group",
+            "name": "Group.Modify"
+        },
+        {
+            "description": "Create or delete a template",
+            "name": "Template.Allocate"
+        },
+        {
+            "description": "View a template",
+            "name": "Template.Audit"
+        },
+        {
+            "description": "Update a template",
+            "name": "Template.Modify"
+        },
+        {
+            "description": "Create or delete a project",
+            "name": "Project.Allocate"
+        },
+        {
+            "description": "View a project",
+            "name": "Project.Audit"
+        },
+        {
+            "description": "Update a project",
+            "name": "Project.Modify"
+        },
+        {
+            "description": "Create or delete project snapshots",
+            "name": "Project.Snapshot"
+        },
+        {
+            "description": "Create or delete a node",
+            "name": "Node.Allocate"
+        },
+        {
+            "description": "View a node",
+            "name": "Node.Audit"
+        },
+        {
+            "description": "Update a node",
+            "name": "Node.Modify"
+        },
+        {
+            "description": "Console access to a node",
+            "name": "Node.Console"
+        },
+        {
+            "description": "Power management for a node",
+            "name": "Node.PowerMgmt"
+        },
+        {
+            "description": "Create or delete a link",
+            "name": "Link.Allocate"
+        },
+        {
+            "description": "View a link",
+            "name": "Link.Audit"
+        },
+        {
+            "description": "Update a link",
+            "name": "Link.Modify"
+        },
+        {
+            "description": "Capture packets on a link",
+            "name": "Link.Capture"
+        },
+        {
+            "description": "Create or delete a drawing",
+            "name": "Drawing.Allocate"
+        },
+        {
+            "description": "View a drawing",
+            "name": "Drawing.Audit"
+        },
+        {
+            "description": "Update a drawing",
+            "name": "Drawing.Modify"
+        },
+        {
+            "description": "Create or delete a symbol",
+            "name": "Symbol.Allocate"
+        },
+        {
+            "description": "View a symbol",
+            "name": "Symbol.Audit"
+        },
+        {
+            "description": "Create or delete an image",
+            "name": "Image.Allocate"
+        },
+        {
+            "description": "View an image",
+            "name": "Image.Audit"
+        },
+        {
+            "description": "Create or delete a compute",
+            "name": "Compute.Allocate"
+        },
+        {
+            "description": "View a compute",
+            "name": "Compute.Audit"
+        },
+    ]
+
+    stmt = target.insert().values(default_privileges)
+    connection.execute(stmt)
+    connection.commit()
+    log.debug("The default privileges have been created in the database")
+
+
+def add_privileges_to_role(target, connection, role, privileges):
+
+    from .roles import Role
+    roles_table = Role.__table__
+    privileges_table = Privilege.__table__
+
+    stmt = roles_table.select().where(roles_table.c.name == role)
+    result = connection.execute(stmt)
+    role_id = result.first().role_id
+    for privilege_name in privileges:
+        stmt = privileges_table.select().where(privileges_table.c.name == privilege_name)
+        result = connection.execute(stmt)
+        privilege_id = result.first().privilege_id
+        stmt = target.insert().values(privilege_id=privilege_id, role_id=role_id)
+        connection.execute(stmt)
+
+
+@event.listens_for(privilege_role_map, 'after_create')
+def add_privileges_to_default_roles(target, connection, **kw):
+
+    from .roles import Role
+    roles_table = Role.__table__
+    stmt = roles_table.select().where(roles_table.c.name == "Administrator")
+    result = connection.execute(stmt)
+    role_id = result.first().role_id
+
+    # add all privileges to the "Administrator" role
+    privileges_table = Privilege.__table__
+    stmt = privileges_table.select()
+    result = connection.execute(stmt)
+    for row in result:
+        privilege_id = row.privilege_id
+        stmt = target.insert().values(privilege_id=privilege_id, role_id=role_id)
+        connection.execute(stmt)
+
+    # add required privileges to the "User" role
+    user_privileges = (
+        "Project.Allocate",
+        "Project.Audit",
+        "Project.Modify",
+        "Project.Snapshot",
+        "Node.Allocate",
+        "Node.Audit",
+        "Node.Modify",
+        "Node.Console",
+        "Node.PowerMgmt",
+        "Link.Allocate",
+        "Link.Audit",
+        "Link.Modify",
+        "Link.Capture",
+        "Drawing.Allocate",
+        "Drawing.Audit",
+        "Drawing.Modify",
+        "Template.Audit",
+        "Symbol.Audit",
+        "Image.Audit",
+        "Compute.Audit"
+    )
+
+    add_privileges_to_role(target, connection, "User", user_privileges)
+
+    # add required privileges to the "Auditor" role
+    auditor_privileges = (
+        "Project.Audit",
+        "Node.Audit",
+        "Link.Audit",
+        "Drawing.Audit",
+        "Template.Audit",
+        "Symbol.Audit",
+        "Image.Audit",
+        "Compute.Audit"
+    )
+
+    add_privileges_to_role(target, connection, "Auditor", auditor_privileges)
+
+    connection.commit()
+    log.debug("Privileges have been added to the default roles in the database")
diff --git a/gns3server/db/models/resources.py b/gns3server/db/models/resource_pools.py
similarity index 57%
rename from gns3server/db/models/resources.py
rename to gns3server/db/models/resource_pools.py
index 6cc11ff3..220bc7ec 100644
--- a/gns3server/db/models/resources.py
+++ b/gns3server/db/models/resource_pools.py
@@ -25,21 +25,18 @@ import logging
 log = logging.getLogger(__name__)
 
 
-class Resource(BaseTable):
+class ResourcePool(BaseTable):
 
-    __tablename__ = "resources"
+    __tablename__ = "resource_pools"
 
-    resource_id = Column(GUID, primary_key=True, default=generate_uuid)
-    name = Column(String, unique=True, index=True)
-    description = Column(String)
-    propagate = Column(Boolean, default=True)
-    user_id = Column(GUID, ForeignKey('users.user_id', ondelete="CASCADE"))
-    user = relationship("User", back_populates="resources")
-    acl_entries = relationship("ACL")
-    parent_id = Column(GUID, ForeignKey("resources.resource_id", ondelete="CASCADE"))
-    children = relationship(
-        "Resource",
-        remote_side=[resource_id],
-        cascade="all, delete-orphan",
-        single_parent=True
-    )
+    resource_id = Column(GUID, primary_key=True)
+    resource_type = Column(String)
+
+    # # Create a self-referential relationship to represent a hierarchy of resources
+    # parent_id = Column(GUID, ForeignKey("resources.resource_id", ondelete="CASCADE"))
+    # children = relationship(
+    #     "Resource",
+    #     remote_side=[resource_id],
+    #     cascade="all, delete-orphan",
+    #     single_parent=True
+    # )
diff --git a/gns3server/db/models/roles.py b/gns3server/db/models/roles.py
index fb4ef4f8..f2a9ea81 100644
--- a/gns3server/db/models/roles.py
+++ b/gns3server/db/models/roles.py
@@ -15,23 +15,16 @@
 # You should have received a copy of the GNU General Public License
 # along with this program.  If not, see <http://www.gnu.org/licenses/>.
 
-from sqlalchemy import Table, Column, String, Boolean, ForeignKey, event
+from sqlalchemy import Column, String, Boolean, event
 from sqlalchemy.orm import relationship
 
-from .base import Base, BaseTable, generate_uuid, GUID
-from .permissions import permission_role_map
+from .base import BaseTable, generate_uuid, GUID
+from .privileges import privilege_role_map
 
 import logging
 
 log = logging.getLogger(__name__)
 
-role_group_map = Table(
-    "role_group_map",
-    Base.metadata,
-    Column("role_id", GUID, ForeignKey("roles.role_id", ondelete="CASCADE")),
-    Column("user_group_id", GUID, ForeignKey("user_groups.user_group_id", ondelete="CASCADE"))
-)
-
 
 class Role(BaseTable):
 
@@ -41,9 +34,8 @@ class Role(BaseTable):
     name = Column(String, unique=True, index=True)
     description = Column(String)
     is_builtin = Column(Boolean, default=False)
-    permissions = relationship("Permission", secondary=permission_role_map, back_populates="roles")
-    groups = relationship("UserGroup", secondary=role_group_map, back_populates="roles")
-    acl_entries = relationship("ACL")
+    privileges = relationship("Privilege", secondary=privilege_role_map, back_populates="roles")
+    acl_entries = relationship("ACE")
 
 
 @event.listens_for(Role.__table__, 'after_create')
@@ -52,31 +44,11 @@ def create_default_roles(target, connection, **kw):
     default_roles = [
         {"name": "Administrator", "description": "Administrator role", "is_builtin": True},
         {"name": "User", "description": "User role", "is_builtin": True},
+        {"name": "Auditor", "description": "Role with read only access", "is_builtin": True},
+        {"name": "No Access", "description": "Role with no privileges (used to forbid access)", "is_builtin": True}
     ]
 
     stmt = target.insert().values(default_roles)
     connection.execute(stmt)
     connection.commit()
     log.debug("The default roles have been created in the database")
-
-
-@event.listens_for(role_group_map, 'after_create')
-def add_admin_to_group(target, connection, **kw):
-
-    from .users import UserGroup
-    user_groups_table = UserGroup.__table__
-    roles_table = Role.__table__
-
-    # Add roles to built-in user groups
-    groups_to_roles = {"Administrators": "Administrator", "Users": "User"}
-    for user_group, role in groups_to_roles.items():
-        stmt = user_groups_table.select().where(user_groups_table.c.name == user_group)
-        result = connection.execute(stmt)
-        user_group_id = result.first().user_group_id
-        stmt = roles_table.select().where(roles_table.c.name == role)
-        result = connection.execute(stmt)
-        role_id = result.first().role_id
-        stmt = target.insert().values(role_id=role_id, user_group_id=user_group_id)
-        connection.execute(stmt)
-
-    connection.commit()
diff --git a/gns3server/db/models/users.py b/gns3server/db/models/users.py
index cc923aec..f68bc49f 100644
--- a/gns3server/db/models/users.py
+++ b/gns3server/db/models/users.py
@@ -19,7 +19,6 @@ from sqlalchemy import Table, Boolean, Column, String, DateTime, ForeignKey, eve
 from sqlalchemy.orm import relationship
 
 from .base import Base, BaseTable, generate_uuid, GUID
-from .roles import role_group_map
 
 from gns3server.config import Config
 from gns3server.services import auth_service
@@ -49,9 +48,7 @@ class User(BaseTable):
     is_active = Column(Boolean, default=True)
     is_superadmin = Column(Boolean, default=False)
     groups = relationship("UserGroup", secondary=user_group_map, back_populates="users")
-    resources = relationship("Resource")
-    permissions = relationship("Permission")
-    acl_entries = relationship("ACL")
+    acl_entries = relationship("ACE")
 
 
 @event.listens_for(User.__table__, 'after_create')
@@ -80,8 +77,7 @@ class UserGroup(BaseTable):
     name = Column(String, unique=True, index=True)
     is_builtin = Column(Boolean, default=False)
     users = relationship("User", secondary=user_group_map, back_populates="groups")
-    roles = relationship("Role", secondary=role_group_map, back_populates="groups")
-    acl_entries = relationship("ACL")
+    acl_entries = relationship("ACE")
 
 
 @event.listens_for(UserGroup.__table__, 'after_create')
@@ -96,21 +92,3 @@ def create_default_user_groups(target, connection, **kw):
     connection.execute(stmt)
     connection.commit()
     log.debug("The default user groups have been created in the database")
-
-
-# @event.listens_for(user_group_link, 'after_create')
-# def add_admin_to_group(target, connection, **kw):
-#
-#     user_groups_table = UserGroup.__table__
-#     stmt = user_groups_table.select().where(user_groups_table.c.name == "Administrators")
-#     result = connection.execute(stmt)
-#     user_group_id = result.first().user_group_id
-#
-#     users_table = User.__table__
-#     stmt = users_table.select().where(users_table.c.is_superadmin.is_(True))
-#     result = connection.execute(stmt)
-#     user_id = result.first().user_id
-#
-#     stmt = target.insert().values(user_id=user_id, user_group_id=user_group_id)
-#     connection.execute(stmt)
-#     connection.commit()
diff --git a/gns3server/db/repositories/rbac.py b/gns3server/db/repositories/rbac.py
index a2ae380d..54be04c2 100644
--- a/gns3server/db/repositories/rbac.py
+++ b/gns3server/db/repositories/rbac.py
@@ -1,6 +1,6 @@
 #!/usr/bin/env python
 #
-# Copyright (C) 2020 GNS3 Technologies Inc.
+# Copyright (C) 2023 GNS3 Technologies Inc.
 #
 # This program is free software: you can redistribute it and/or modify
 # it under the terms of the GNU General Public License as published by
@@ -24,7 +24,6 @@ from sqlalchemy.orm import selectinload
 from .base import BaseRepository
 
 import gns3server.db.models as models
-from gns3server.schemas.controller.rbac import HTTPMethods, PermissionAction
 from gns3server import schemas
 
 import logging
@@ -44,7 +43,7 @@ class RbacRepository(BaseRepository):
         """
 
         query = select(models.Role).\
-            options(selectinload(models.Role.permissions)).\
+            options(selectinload(models.Role.privileges)).\
             where(models.Role.role_id == role_id)
         result = await self._db_session.execute(query)
         return result.scalars().first()
@@ -55,9 +54,8 @@ class RbacRepository(BaseRepository):
         """
 
         query = select(models.Role).\
-            options(selectinload(models.Role.permissions)).\
+            options(selectinload(models.Role.privileges)).\
             where(models.Role.name == name)
-        #query = select(models.Role).where(models.Role.name == name)
         result = await self._db_session.execute(query)
         return result.scalars().first()
 
@@ -66,7 +64,7 @@ class RbacRepository(BaseRepository):
         Get all roles.
         """
 
-        query = select(models.Role).options(selectinload(models.Role.permissions))
+        query = select(models.Role).options(selectinload(models.Role.privileges))
         result = await self._db_session.execute(query)
         return result.scalars().all()
 
@@ -81,7 +79,6 @@ class RbacRepository(BaseRepository):
         )
         self._db_session.add(db_role)
         await self._db_session.commit()
-        #await self._db_session.refresh(db_role)
         return await self.get_role(db_role.role_id)
 
     async def update_role(
@@ -115,286 +112,256 @@ class RbacRepository(BaseRepository):
         await self._db_session.commit()
         return result.rowcount > 0
 
-    async def add_permission_to_role(
+    async def add_privilege_to_role(
             self,
             role_id: UUID,
-            permission: models.Permission
+            privilege: models.Privilege
     ) -> Union[None, models.Role]:
         """
-        Add a permission to a role.
+        Add a privilege to a role.
         """
 
         query = select(models.Role).\
-            options(selectinload(models.Role.permissions)).\
+            options(selectinload(models.Role.privileges)).\
             where(models.Role.role_id == role_id)
         result = await self._db_session.execute(query)
         role_db = result.scalars().first()
         if not role_db:
             return None
 
-        role_db.permissions.append(permission)
+        role_db.privileges.append(privilege)
         await self._db_session.commit()
         await self._db_session.refresh(role_db)
         return role_db
 
-    async def remove_permission_from_role(
+    async def remove_privilege_from_role(
             self,
             role_id: UUID,
-            permission: models.Permission
+            privilege: models.Privilege
     ) -> Union[None, models.Role]:
         """
-        Remove a permission from a role.
+        Remove a privilege from a role.
         """
 
         query = select(models.Role).\
-            options(selectinload(models.Role.permissions)).\
+            options(selectinload(models.Role.privileges)).\
             where(models.Role.role_id == role_id)
         result = await self._db_session.execute(query)
         role_db = result.scalars().first()
         if not role_db:
             return None
 
-        role_db.permissions.remove(permission)
+        role_db.privileges.remove(privilege)
         await self._db_session.commit()
         await self._db_session.refresh(role_db)
         return role_db
 
-    async def get_role_permissions(self, role_id: UUID) -> List[models.Permission]:
+    async def get_role_privileges(self, role_id: UUID) -> List[models.Privilege]:
         """
-        Get all the role permissions.
+        Get all the role privileges.
         """
 
-        query = select(models.Permission).\
-            join(models.Permission.roles).\
+        query = select(models.Privilege).\
+            join(models.Privilege.roles).\
             filter(models.Role.role_id == role_id)
 
         result = await self._db_session.execute(query)
         return result.scalars().all()
 
-    async def get_permission(self, permission_id: UUID) -> Optional[models.Permission]:
+    async def get_privilege(self, privilege_id: UUID) -> Optional[models.Privilege]:
         """
-        Get a permission by its ID.
+        Get a privilege by its ID.
         """
 
-        query = select(models.Permission).where(models.Permission.permission_id == permission_id)
+        query = select(models.Privilege).where(models.Privilege.privilege_id == privilege_id)
         result = await self._db_session.execute(query)
         return result.scalars().first()
 
-    async def get_permission_by_path(self, path: str) -> Optional[models.Permission]:
+    async def get_privilege_by_name(self, name: str) -> Optional[models.Privilege]:
         """
-        Get a permission by its path.
+        Get a privilege by its name.
         """
 
-        query = select(models.Permission).where(models.Permission.path == path)
+        query = select(models.Privilege).where(models.Privilege.name == name)
         result = await self._db_session.execute(query)
         return result.scalars().first()
 
-    async def get_permissions(self) -> List[models.Permission]:
+    async def get_privileges(self) -> List[models.Privilege]:
         """
-        Get all permissions.
+        Get all privileges.
         """
 
-        query = select(models.Permission).\
-            order_by(models.Permission.path.desc())
+        query = select(models.Privilege)
         result = await self._db_session.execute(query)
         return result.scalars().all()
 
-    async def check_permission_exists(self, permission_create: schemas.PermissionCreate) -> bool:
+    async def get_ace(self, ace_id: UUID) -> Optional[models.ACE]:
         """
-        Check if a permission exists.
+        Get an ACE by its ID.
         """
 
-        query = select(models.Permission).\
-            where(models.Permission.methods == permission_create.methods,
-                  models.Permission.path == permission_create.path,
-                  models.Permission.action == permission_create.action)
+        query = select(models.ACE).where(models.ACE.ace_id == ace_id)
+        result = await self._db_session.execute(query)
+        return result.scalars().first()
+
+    async def get_ace_by_path(self, path: str) -> Optional[models.ACE]:
+        """
+        Get an ACE by its path.
+        """
+
+        query = select(models.ACE).where(models.ACE.path == path)
+        result = await self._db_session.execute(query)
+        return result.scalars().first()
+
+    async def get_aces(self) -> List[models.ACE]:
+        """
+        Get all ACEs.
+        """
+
+        query = select(models.ACE)
+        result = await self._db_session.execute(query)
+        return result.scalars().all()
+
+    async def check_ace_exists(self, path: str) -> bool:
+        """
+        Check if an ACE exists.
+        """
+
+        query = select(models.ACE).\
+            where(models.ACE.path == path)
         result = await self._db_session.execute(query)
         return result.scalars().first() is not None
 
-    async def create_permission(self, permission_create: schemas.PermissionCreate) -> models.Permission:
+    async def create_ace(self, ace_create: schemas.ACECreate) -> models.ACE:
         """
-        Create a new permission.
+        Create a new ACE
         """
 
-        db_permission = models.Permission(
-            description=permission_create.description,
-            methods=permission_create.methods,
-            path=permission_create.path,
-            action=permission_create.action,
-        )
-        self._db_session.add(db_permission)
+        create_values = ace_create.model_dump(exclude_unset=True)
+        db_ace = models.ACE(**create_values)
+        self._db_session.add(db_ace)
         await self._db_session.commit()
-        await self._db_session.refresh(db_permission)
-        return db_permission
+        await self._db_session.refresh(db_ace)
+        return db_ace
 
-    async def update_permission(
+    async def update_ace(
             self,
-            permission_id: UUID,
-            permission_update: schemas.PermissionUpdate
-    ) -> Optional[models.Permission]:
+            ace_id: UUID,
+            ace_update: schemas.ACEUpdate
+    ) -> Optional[models.ACE]:
         """
-        Update a permission.
+        Update an ACE
         """
 
-        update_values = permission_update.model_dump(exclude_unset=True)
-        query = update(models.Permission).\
-            where(models.Permission.permission_id == permission_id).\
+        update_values = ace_update.model_dump(exclude_unset=True)
+        query = update(models.ACE).\
+            where(models.ACE.ace_id == ace_id).\
             values(update_values)
 
         await self._db_session.execute(query)
         await self._db_session.commit()
-        permission_db = await self.get_permission(permission_id)
-        if permission_db:
-            await self._db_session.refresh(permission_db)  # force refresh of updated_at value
-        return permission_db
+        ace_db = await self.get_ace(ace_id)
+        if ace_db:
+            await self._db_session.refresh(ace_db)  # force refresh of updated_at value
+        return ace_db
 
-    async def delete_permission(self, permission_id: UUID) -> bool:
+    async def delete_ace(self, ace_id: UUID) -> bool:
         """
-        Delete a permission.
+        Delete an ACE
         """
 
-        query = delete(models.Permission).where(models.Permission.permission_id == permission_id)
+        query = delete(models.ACE).where(models.ACE.ace_id == ace_id)
         result = await self._db_session.execute(query)
         await self._db_session.commit()
         return result.rowcount > 0
 
-    async def prune_permissions(self) -> int:
+    # async def prune_permissions(self) -> int:
+    #     """
+    #     Prune orphaned permissions.
+    #     """
+    #
+    #     query = select(models.Permission).\
+    #         filter((~models.Permission.roles.any()) & (models.Permission.user_id == null()))
+    #     result = await self._db_session.execute(query)
+    #     permissions = result.scalars().all()
+    #     permissions_deleted = 0
+    #     for permission in permissions:
+    #         if await self.delete_permission(permission.permission_id):
+    #             permissions_deleted += 1
+    #     log.info(f"{permissions_deleted} orphaned permissions have been deleted")
+    #     return permissions_deleted
+    #
+    # def _match_permission(
+    #         self,
+    #         permissions: List[models.Permission],
+    #         method: str,
+    #         path: str
+    # ) -> Union[None, models.Permission]:
+    #     """
+    #     Match the methods and path with a permission.
+    #     """
+    #
+    #     for permission in permissions:
+    #         log.debug(f"RBAC: checking permission {permission.methods} {permission.path} {permission.action}")
+    #         if method not in permission.methods:
+    #             continue
+    #         if permission.path.endswith("/*") and path.startswith(permission.path[:-2]):
+    #             return permission
+    #         elif permission.path == path:
+    #             return permission
+
+    async def delete_all_ace_starting_with_path(self, path: str) -> None:
         """
-        Prune orphaned permissions.
+        Delete all ACEs starting with path.
         """
 
-        query = select(models.Permission).\
-            filter((~models.Permission.roles.any()) & (models.Permission.user_id == null()))
-        result = await self._db_session.execute(query)
-        permissions = result.scalars().all()
-        permissions_deleted = 0
-        for permission in permissions:
-            if await self.delete_permission(permission.permission_id):
-                permissions_deleted += 1
-        log.info(f"{permissions_deleted} orphaned permissions have been deleted")
-        return permissions_deleted
-
-    def _match_permission(
-            self,
-            permissions: List[models.Permission],
-            method: str,
-            path: str
-    ) -> Union[None, models.Permission]:
-        """
-        Match the methods and path with a permission.
-        """
-
-        for permission in permissions:
-            log.debug(f"RBAC: checking permission {permission.methods} {permission.path} {permission.action}")
-            if method not in permission.methods:
-                continue
-            if permission.path.endswith("/*") and path.startswith(permission.path[:-2]):
-                return permission
-            elif permission.path == path:
-                return permission
-
-    async def get_user_permissions(self, user_id: UUID):
-        """
-        Get all permissions from an user.
-        """
-
-        query = select(models.Permission).\
-            join(models.User.permissions).\
-            filter(models.User.user_id == user_id).\
-            order_by(models.Permission.path.desc())
-
-        result = await self._db_session.execute(query)
-        return result.scalars().all()
-
-    async def add_permission_to_user(
-            self,
-            user_id: UUID,
-            permission: models.Permission
-    ) -> Union[None, models.User]:
-        """
-        Add a permission to an user.
-        """
-
-        query = select(models.User).\
-            options(selectinload(models.User.permissions)).\
-            where(models.User.user_id == user_id)
-        result = await self._db_session.execute(query)
-        user_db = result.scalars().first()
-        if not user_db:
-            return None
-
-        user_db.permissions.append(permission)
-        await self._db_session.commit()
-        await self._db_session.refresh(user_db)
-        return user_db
-
-    async def remove_permission_from_user(
-            self,
-            user_id: UUID,
-            permission: models.Permission
-    ) -> Union[None, models.User]:
-        """
-        Remove a permission from a role.
-        """
-
-        query = select(models.User).\
-            options(selectinload(models.User.permissions)).\
-            where(models.User.user_id == user_id)
-        result = await self._db_session.execute(query)
-        user_db = result.scalars().first()
-        if not user_db:
-            return None
-
-        user_db.permissions.remove(permission)
-        await self._db_session.commit()
-        await self._db_session.refresh(user_db)
-        return user_db
-
-    async def add_permission_to_user_with_path(self, user_id: UUID, path: str) -> Union[None, models.User]:
-        """
-        Add a permission to an user.
-        """
-
-        # Create a new permission with full rights on path
-        new_permission = schemas.PermissionCreate(
-            description=f"Allow access to {path}",
-            methods=[HTTPMethods.get, HTTPMethods.head, HTTPMethods.post, HTTPMethods.put, HTTPMethods.delete],
-            path=path,
-            action=PermissionAction.allow
-        )
-        permission_db = await self.create_permission(new_permission)
-
-        # Add the permission to the user
-        query = select(models.User).\
-            options(selectinload(models.User.permissions)).\
-            where(models.User.user_id == user_id)
-
-        result = await self._db_session.execute(query)
-        user_db = result.scalars().first()
-        if not user_db:
-            return None
-
-        user_db.permissions.append(permission_db)
-        await self._db_session.commit()
-        await self._db_session.refresh(user_db)
-        return user_db
-
-    async def delete_all_permissions_with_path(self, path: str) -> None:
-        """
-        Delete all permissions with path.
-        """
-
-        query = delete(models.Permission).\
-            where(models.Permission.path.startswith(path)).\
+        query = delete(models.ACE).\
+            where(models.ACE.path.startswith(path)).\
             execution_options(synchronize_session=False)
         result = await self._db_session.execute(query)
-        log.debug(f"{result.rowcount} permission(s) have been deleted")
+        log.debug(f"{result.rowcount} ACE(s) have been deleted")
 
-    async def check_user_is_authorized(self, user_id: UUID, method: str, path: str) -> bool:
+    async def check_user_has_privilege(self, user_id: UUID, path: str, privilege_name: str) -> bool:
+
+        # query = select(models.Privilege.name).\
+        #     join(models.Privilege.roles).\
+        #     join(models.Role.acl_entries).\
+        #     join(models.ACE.user).\
+        #     filter(models.Privilege.name == privilege). \
+        #     filter(models.User.user_id == user_id).\
+        #     filter(models.ACE.path == path).\
+        #     distinct()
+
+        #query = select(models.ACE.path)
+        #result = await self._db_session.execute(query)
+        #res = result.scalars().all()
+        #print("ACL TABLE ==>", res)
+        #for ace in res:
+        #    print(ace)
+
+        query = select(models.Privilege.name, models.ACE.path, models.ACE.propagate).\
+            join(models.Privilege.roles).\
+            join(models.Role.acl_entries).\
+            join(models.ACE.user).\
+            filter(models.User.user_id == user_id).\
+            filter(models.Privilege.name == privilege_name).\
+            filter(models.ACE.path == path).\
+            order_by(models.ACE.path.desc())
+        result = await self._db_session.execute(query)
+        privileges = result.all()
+        #print(privileges)
+        for privilege, privilege_path, propagate in privileges:
+            if privilege_path == path:
+                return True
+        return False
+
+    async def check_user_is_authorized(self, user_id: UUID, path: str) -> bool:
         """
-        Check if an user is authorized to access a resource.
+        Check if a user is authorized to access a resource.
         """
 
+        return True
+
         query = select(models.Permission).\
             join(models.Permission.roles).\
             join(models.Role.groups).\
diff --git a/gns3server/db/repositories/users.py b/gns3server/db/repositories/users.py
index 97c9fcee..ea53c2e3 100644
--- a/gns3server/db/repositories/users.py
+++ b/gns3server/db/repositories/users.py
@@ -287,60 +287,3 @@ class UsersRepository(BaseRepository):
 
         result = await self._db_session.execute(query)
         return result.scalars().all()
-
-    async def add_role_to_user_group(
-            self,
-            user_group_id: UUID,
-            role: models.Role
-    ) -> Union[None, models.UserGroup]:
-        """
-        Add a role to a user group.
-        """
-
-        query = select(models.UserGroup).\
-            options(selectinload(models.UserGroup.roles)).\
-            where(models.UserGroup.user_group_id == user_group_id)
-        result = await self._db_session.execute(query)
-        user_group_db = result.scalars().first()
-        if not user_group_db:
-            return None
-
-        user_group_db.roles.append(role)
-        await self._db_session.commit()
-        await self._db_session.refresh(user_group_db)
-        return user_group_db
-
-    async def remove_role_from_user_group(
-            self,
-            user_group_id: UUID,
-            role: models.Role
-    ) -> Union[None, models.UserGroup]:
-        """
-        Remove a role from a user group.
-        """
-
-        query = select(models.UserGroup).\
-            options(selectinload(models.UserGroup.roles)).\
-            where(models.UserGroup.user_group_id == user_group_id)
-        result = await self._db_session.execute(query)
-        user_group_db = result.scalars().first()
-        if not user_group_db:
-            return None
-
-        user_group_db.roles.remove(role)
-        await self._db_session.commit()
-        await self._db_session.refresh(user_group_db)
-        return user_group_db
-
-    async def get_user_group_roles(self, user_group_id: UUID) -> List[models.Role]:
-        """
-        Get all roles from a user group.
-        """
-
-        query = select(models.Role). \
-            options(selectinload(models.Role.permissions)). \
-            join(models.UserGroup.roles). \
-            filter(models.UserGroup.user_group_id == user_group_id)
-
-        result = await self._db_session.execute(query)
-        return result.scalars().all()
diff --git a/gns3server/schemas/__init__.py b/gns3server/schemas/__init__.py
index d9ff9015..5b764c7e 100644
--- a/gns3server/schemas/__init__.py
+++ b/gns3server/schemas/__init__.py
@@ -30,7 +30,7 @@ from .controller.gns3vm import GNS3VM
 from .controller.nodes import NodeCreate, NodeUpdate, NodeDuplicate, NodeCapture, Node
 from .controller.projects import ProjectCreate, ProjectUpdate, ProjectDuplicate, Project, ProjectFile, ProjectCompression
 from .controller.users import UserCreate, UserUpdate, LoggedInUserUpdate, User, Credentials, UserGroupCreate, UserGroupUpdate, UserGroup
-from .controller.rbac import RoleCreate, RoleUpdate, Role, PermissionCreate, PermissionUpdate, Permission
+from .controller.rbac import RoleCreate, RoleUpdate, Role, Privilege, ACECreate, ACEUpdate, ACE
 from .controller.tokens import Token
 from .controller.snapshots import SnapshotCreate, Snapshot
 from .controller.iou_license import IOULicense
diff --git a/gns3server/schemas/controller/rbac.py b/gns3server/schemas/controller/rbac.py
index 2ff08b8a..36cb2897 100644
--- a/gns3server/schemas/controller/rbac.py
+++ b/gns3server/schemas/controller/rbac.py
@@ -15,71 +15,68 @@
 # along with this program.  If not, see <http://www.gnu.org/licenses/>.
 
 from typing import Optional, List
-from pydantic import field_validator, ConfigDict, BaseModel
+from pydantic import ConfigDict, BaseModel, Field
 from uuid import UUID
 from enum import Enum
 
 from .base import DateTimeModelMixin
 
 
-class HTTPMethods(str, Enum):
+class PrivilegeBase(BaseModel):
     """
-    HTTP method type.
+    Common privilege properties.
     """
 
-    get = "GET"
-    head = "HEAD"
-    post = "POST"
-    patch = "PATCH"
-    put = "PUT"
-    delete = "DELETE"
-
-
-class PermissionAction(str, Enum):
-    """
-    Action to perform when permission is matched.
-    """
-
-    allow = "ALLOW"
-    deny = "DENY"
-
-
-class PermissionBase(BaseModel):
-    """
-    Common permission properties.
-    """
-
-    methods: List[HTTPMethods]
-    path: str
-    action: PermissionAction
+    name: str
     description: Optional[str] = None
+
+
+class Privilege(DateTimeModelMixin, PrivilegeBase):
+
+    privilege_id: UUID
+    model_config = ConfigDict(from_attributes=True)
+
+
+class ACEType(str, Enum):
+
+    user = "user"
+    group = "group"
+
+
+class ACEBase(BaseModel):
+    """
+    Common ACE properties.
+    """
+
+    path: str
+    propagate: Optional[bool] = True
+    allowed: Optional[bool] = True
+    type: ACEType = Field(..., description="Type of the ACE")
+    user_id: Optional[UUID] = None
+    group_id: Optional[UUID] = None
+    role_id: UUID
     model_config = ConfigDict(use_enum_values=True)
 
-    @field_validator("action", mode="before")
-    @classmethod
-    def action_uppercase(cls, v):
-        return v.upper()
 
-
-class PermissionCreate(PermissionBase):
+class ACECreate(ACEBase):
     """
-    Properties to create a permission.
+    Properties to create an ACE.
     """
 
     pass
 
 
-class PermissionUpdate(PermissionBase):
+class ACEUpdate(ACEBase):
     """
-    Properties to update a role.
+    Properties to update an ACE.
     """
 
     pass
 
 
-class Permission(DateTimeModelMixin, PermissionBase):
+class ACE(DateTimeModelMixin, ACEBase):
 
-    permission_id: UUID
+    ace_id: UUID
     model_config = ConfigDict(from_attributes=True)
 
 
@@ -112,5 +109,5 @@ class Role(DateTimeModelMixin, RoleBase):
 
     role_id: UUID
     is_builtin: bool
-    permissions: List[Permission]
+    privileges: List[Privilege]
     model_config = ConfigDict(from_attributes=True)
diff --git a/gns3server/schemas/controller/users.py b/gns3server/schemas/controller/users.py
index 5372ff6a..673b28dd 100644
--- a/gns3server/schemas/controller/users.py
+++ b/gns3server/schemas/controller/users.py
@@ -52,7 +52,7 @@ class UserUpdate(UserBase):
 
 class LoggedInUserUpdate(BaseModel):
     """
-    Properties to update a logged in user.
+    Properties to update a logged-in user.
     """
 
     password: Optional[SecretStr] = Field(None, min_length=6, max_length=100)
diff --git a/tests/api/routes/controller/test_acl.py b/tests/api/routes/controller/test_acl.py
new file mode 100644
index 00000000..bf67d1a9
--- /dev/null
+++ b/tests/api/routes/controller/test_acl.py
@@ -0,0 +1,214 @@
+#!/usr/bin/env python
+#
+# Copyright (C) 2023 GNS3 Technologies Inc.
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+import pytest
+import pytest_asyncio
+import uuid
+
+from fastapi import FastAPI, status
+from httpx import AsyncClient
+
+from sqlalchemy.ext.asyncio import AsyncSession
+from gns3server.db.repositories.users import UsersRepository
+from gns3server.db.repositories.rbac import RbacRepository
+from gns3server.controller import Controller
+from gns3server.controller.project import Project
+from gns3server.schemas.controller.users import User
+from gns3server.schemas.controller.rbac import ACECreate
+
+pytestmark = pytest.mark.asyncio
+
+
+class TestACLRoutes:
+
+    # @pytest_asyncio.fixture
+    # async def project(
+    #         self,
+    #         app: FastAPI,
+    #         authorized_client: AsyncClient,
+    #         test_user: User,
+    #         db_session: AsyncSession,
+    #         controller: Controller
+    # ) -> Project:
+    #
+    #     # add an ACE to allow user to create a project
+    #     user_id = test_user.user_id
+    #     rbac_repo = RbacRepository(db_session)
+    #     role_in_db = await rbac_repo.get_role_by_name("User")
+    #     role_id = role_in_db.role_id
+    #     ace = ACECreate(
+    #         path="/projects",
+    #         type="user",
+    #         user_id=user_id,
+    #         role_id=role_id
+    #     )
+    #     await rbac_repo.create_ace(ace)
+    #     project_uuid = str(uuid.uuid4())
+    #     params = {"name": "test", "project_id": project_uuid}
+    #     response = await authorized_client.post(app.url_path_for("create_project"), json=params)
+    #     assert response.status_code == status.HTTP_201_CREATED
+    #     return controller.get_project(project_uuid)
+
+    #@pytest_asyncio.fixture
+    # async def project(
+    #         self,
+    #         app: FastAPI,
+    #         client: AsyncClient,
+    #         controller: Controller
+    # ) -> Project:
+    #
+    #     project_uuid = str(uuid.uuid4())
+    #     params = {"name": "test", "project_id": project_uuid}
+    #     response = await client.post(app.url_path_for("create_project"), json=params)
+    #     assert response.status_code == status.HTTP_201_CREATED
+    #     return controller.get_project(project_uuid)
+
+    @pytest_asyncio.fixture
+    async def group_id(self, db_session: AsyncSession) -> str:
+
+        users_repo = UsersRepository(db_session)
+        group_in_db = await users_repo.get_user_group_by_name("Users")
+        group_id = str(group_in_db.user_group_id)
+        return group_id
+
+    @pytest_asyncio.fixture
+    async def role_id(self, db_session: AsyncSession) -> str:
+
+        rbac_repo = RbacRepository(db_session)
+        role_in_db = await rbac_repo.get_role_by_name("User")
+        role_id = str(role_in_db.role_id)
+        return role_id
+
+    async def test_create_ace(
+            self,
+            app: FastAPI,
+            authorized_client: AsyncClient,
+            db_session: AsyncSession,
+            test_user: User,
+            role_id: str
+    ) -> None:
+
+        # add an ACE on /projects to allow user to create a project
+        path = f"/projects"
+        new_ace = {
+            "path": path,
+            "type": "user",
+            "user_id": str(test_user.user_id),
+            "role_id": role_id
+        }
+
+        response = await authorized_client.post(app.url_path_for("create_ace"), json=new_ace)
+        assert response.status_code == status.HTTP_201_CREATED
+
+        rbac_repo = RbacRepository(db_session)
+        assert await rbac_repo.check_user_has_privilege(test_user.user_id, path, "Project.Allocate") is True
+
+        response = await authorized_client.post(app.url_path_for("create_project"), json={"name": "test"})
+        assert response.status_code == status.HTTP_201_CREATED
+
+    async def test_create_ace_not_existing_endpoint(
+            self,
+            app: FastAPI,
+            client: AsyncClient,
+            group_id: str,
+            role_id: str
+    ) -> None:
+
+        new_ace = {
+            "path": "/projects/invalid",
+            "type": "group",
+            "group_id": group_id,
+            "role_id": role_id
+        }
+        response = await client.post(app.url_path_for("create_ace"), json=new_ace)
+        assert response.status_code == status.HTTP_400_BAD_REQUEST
+
+    # async def test_create_ace_not_existing_resource(
+    #         self,
+    #         app: FastAPI,
+    #         client: AsyncClient,
+    #         group_id: str,
+    #         role_id: str
+    # ) -> None:
+    #
+    #     new_ace = {
+    #         "path": f"/projects/{str(uuid.uuid4())}",
+    #         "group_id": group_id,
+    #         "role_id": role_id
+    #     }
+    #     response = await client.post(app.url_path_for("create_ace"), json=new_ace)
+    #     assert response.status_code == status.HTTP_403_FORBIDDEN
+
+    async def test_get_ace(self, app: FastAPI, client: AsyncClient, db_session: AsyncSession) -> None:
+
+        rbac_repo = RbacRepository(db_session)
+        ace_in_db = await rbac_repo.get_ace_by_path(f"/projects")
+        response = await client.get(app.url_path_for("get_ace", ace_id=ace_in_db.ace_id))
+        assert response.status_code == status.HTTP_200_OK
+        assert response.json()["ace_id"] == str(ace_in_db.ace_id)
+
+    async def test_list_aces(self, app: FastAPI, client: AsyncClient) -> None:
+
+        response = await client.get(app.url_path_for("get_aces"))
+        assert response.status_code == status.HTTP_200_OK
+        assert len(response.json()) == 1
+
+    async def test_update_ace(
+            self, app: FastAPI,
+            client: AsyncClient,
+            db_session: AsyncSession,
+            test_user: User,
+            role_id: str
+    ) -> None:
+
+        rbac_repo = RbacRepository(db_session)
+        ace_in_db = await rbac_repo.get_ace_by_path(f"/projects")
+
+        update_ace = {
+            "path": f"/appliances",
+            "type": "user",
+            "user_id": str(test_user.user_id),
+            "role_id": role_id
+        }
+        response = await client.put(
+            app.url_path_for("update_ace", ace_id=ace_in_db.ace_id),
+            json=update_ace
+        )
+        assert response.status_code == status.HTTP_200_OK
+        updated_ace_in_db = await rbac_repo.get_ace(ace_in_db.ace_id)
+        assert updated_ace_in_db.path == f"/appliances"
+
+    async def test_delete_ace(
+            self,
+            app: FastAPI,
+            client: AsyncClient,
+            db_session: AsyncSession,
+    ) -> None:
+
+        rbac_repo = RbacRepository(db_session)
+        ace_in_db = await rbac_repo.get_ace_by_path(f"/appliances")
+        response = await client.delete(app.url_path_for("delete_ace", ace_id=ace_in_db.ace_id))
+        assert response.status_code == status.HTTP_204_NO_CONTENT
+
+    # async def test_prune_permissions(self, app: FastAPI, client: AsyncClient, db_session: AsyncSession) -> None:
+    #
+    #     response = await client.post(app.url_path_for("prune_permissions"))
+    #     assert response.status_code == status.HTTP_204_NO_CONTENT
+    #
+    #     rbac_repo = RbacRepository(db_session)
+    #     permissions_in_db = await rbac_repo.get_permissions()
+    #     assert len(permissions_in_db) == 10  # 6 default permissions + 4 custom permissions
diff --git a/tests/api/routes/controller/test_groups.py b/tests/api/routes/controller/test_groups.py
index e8d6e791..5dee0809 100644
--- a/tests/api/routes/controller/test_groups.py
+++ b/tests/api/routes/controller/test_groups.py
@@ -16,17 +16,13 @@
 # along with this program.  If not, see <http://www.gnu.org/licenses/>.
 
 import pytest
-import pytest_asyncio
 
 from fastapi import FastAPI, status
 from httpx import AsyncClient
 
 from sqlalchemy.ext.asyncio import AsyncSession
 from gns3server.db.repositories.users import UsersRepository
-from gns3server.db.repositories.rbac import RbacRepository
 from gns3server.schemas.controller.users import User
-from gns3server.schemas.controller.rbac import Role
-from gns3server import schemas
 
 pytestmark = pytest.mark.asyncio
 
@@ -170,84 +166,3 @@ class TestGroupMembersRoutes:
         assert response.status_code == status.HTTP_204_NO_CONTENT
         members = await user_repo.get_user_group_members(group_in_db.user_group_id)
         assert len(members) == 0
-
-
-@pytest_asyncio.fixture
-async def test_role(db_session: AsyncSession) -> Role:
-
-    new_role = schemas.RoleCreate(
-        name="TestRole",
-        description="This is my test role"
-    )
-    rbac_repo = RbacRepository(db_session)
-    existing_role = await rbac_repo.get_role_by_name(new_role.name)
-    if existing_role:
-        return existing_role
-    return await rbac_repo.create_role(new_role)
-
-
-class TestGroupRolesRoutes:
-
-    async def test_add_role_to_group(
-            self,
-            app: FastAPI,
-            client: AsyncClient,
-            test_role: Role,
-            db_session: AsyncSession
-    ) -> None:
-
-        user_repo = UsersRepository(db_session)
-        group_in_db = await user_repo.get_user_group_by_name("Users")
-        response = await client.put(
-            app.url_path_for(
-                "add_role_to_group",
-                user_group_id=group_in_db.user_group_id,
-                role_id=str(test_role.role_id)
-            )
-        )
-        assert response.status_code == status.HTTP_204_NO_CONTENT
-        roles = await user_repo.get_user_group_roles(group_in_db.user_group_id)
-        assert len(roles) == 2  # 1 default role + 1 custom role
-        for role in roles:
-            if not role.is_builtin:
-                assert role.name == test_role.name
-
-    async def test_get_user_group_roles(
-            self,
-            app: FastAPI,
-            client: AsyncClient,
-            db_session: AsyncSession
-    ) -> None:
-
-        user_repo = UsersRepository(db_session)
-        group_in_db = await user_repo.get_user_group_by_name("Users")
-        response = await client.get(
-            app.url_path_for(
-                "get_user_group_roles",
-                user_group_id=group_in_db.user_group_id)
-        )
-        assert response.status_code == status.HTTP_200_OK
-        assert len(response.json()) == 2  # 1 default role + 1 custom role
-
-    async def test_remove_role_from_group(
-            self,
-            app: FastAPI,
-            client: AsyncClient,
-            test_role: Role,
-            db_session: AsyncSession
-    ) -> None:
-
-        user_repo = UsersRepository(db_session)
-        group_in_db = await user_repo.get_user_group_by_name("Users")
-
-        response = await client.delete(
-            app.url_path_for(
-                "remove_role_from_group",
-                user_group_id=group_in_db.user_group_id,
-                role_id=test_role.role_id
-            ),
-        )
-        assert response.status_code == status.HTTP_204_NO_CONTENT
-        roles = await user_repo.get_user_group_roles(group_in_db.user_group_id)
-        assert len(roles) == 1  # 1 default role
-        assert roles[0].name != test_role.name
diff --git a/tests/api/routes/controller/test_permissions.py b/tests/api/routes/controller/test_permissions.py
deleted file mode 100644
index d35583f1..00000000
--- a/tests/api/routes/controller/test_permissions.py
+++ /dev/null
@@ -1,136 +0,0 @@
-#!/usr/bin/env python
-#
-# Copyright (C) 2021 GNS3 Technologies Inc.
-#
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-#
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU General Public License for more details.
-#
-# You should have received a copy of the GNU General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-
-import pytest
-import pytest_asyncio
-import uuid
-
-from fastapi import FastAPI, status
-from httpx import AsyncClient
-
-from sqlalchemy.ext.asyncio import AsyncSession
-from gns3server.db.repositories.rbac import RbacRepository
-from gns3server.controller import Controller
-from gns3server.controller.project import Project
-
-pytestmark = pytest.mark.asyncio
-
-
-class TestPermissionRoutes:
-
-    @pytest_asyncio.fixture
-    async def project(self, app: FastAPI, client: AsyncClient, controller: Controller) -> Project:
-
-        project_uuid = str(uuid.uuid4())
-        params = {"name": "test", "project_id": project_uuid}
-        await client.post(app.url_path_for("create_project"), json=params)
-        return controller.get_project(project_uuid)
-
-    async def test_create_permission(self, app: FastAPI, client: AsyncClient, project: Project) -> None:
-
-        new_permission = {
-            "methods": ["GET"],
-            "path": f"/projects/{project.id}",
-            "action": "ALLOW"
-        }
-        response = await client.post(app.url_path_for("create_permission"), json=new_permission)
-        assert response.status_code == status.HTTP_201_CREATED
-
-    async def test_create_wildcard_permission(self, app: FastAPI, client: AsyncClient, project: Project) -> None:
-
-        new_permission = {
-            "methods": ["POST"],
-            "path": f"/projects/{project.id}/*",
-            "action": "ALLOW"
-        }
-
-        response = await client.post(app.url_path_for("create_permission"), json=new_permission)
-        assert response.status_code == status.HTTP_201_CREATED
-
-    async def test_create_permission_not_existing_endpoint(self, app: FastAPI, client: AsyncClient) -> None:
-
-        new_permission = {
-            "methods": ["GET"],
-            "path": "/projects/invalid",
-            "action": "ALLOW"
-        }
-        response = await client.post(app.url_path_for("create_permission"), json=new_permission)
-        assert response.status_code == status.HTTP_400_BAD_REQUEST
-
-    async def test_create_permission_not_existing_object(self, app: FastAPI, client: AsyncClient) -> None:
-
-        new_permission = {
-            "methods": ["GET"],
-            "path": f"/projects/{str(uuid.uuid4())}/*",
-            "action": "ALLOW"
-        }
-        response = await client.post(app.url_path_for("create_permission"), json=new_permission)
-        assert response.status_code == status.HTTP_403_FORBIDDEN
-
-    async def test_get_permission(self, app: FastAPI, client: AsyncClient, db_session: AsyncSession, project: Project) -> None:
-
-        rbac_repo = RbacRepository(db_session)
-        permission_in_db = await rbac_repo.get_permission_by_path(f"/projects/{project.id}/*")
-        response = await client.get(app.url_path_for("get_permission", permission_id=permission_in_db.permission_id))
-        assert response.status_code == status.HTTP_200_OK
-        assert response.json()["permission_id"] == str(permission_in_db.permission_id)
-
-    async def test_list_permissions(self, app: FastAPI, client: AsyncClient) -> None:
-
-        response = await client.get(app.url_path_for("get_permissions"))
-        assert response.status_code == status.HTTP_200_OK
-        assert len(response.json()) == 11  # 6 default permissions + 5 custom permissions
-
-    async def test_update_permission(self, app: FastAPI, client: AsyncClient, db_session: AsyncSession, project: Project) -> None:
-
-        rbac_repo = RbacRepository(db_session)
-        permission_in_db = await rbac_repo.get_permission_by_path(f"/projects/{project.id}/*")
-
-        update_permission = {
-            "methods": ["GET"],
-            "path": f"/projects/{project.id}/*",
-            "action": "ALLOW"
-        }
-        response = await client.put(
-            app.url_path_for("update_permission", permission_id=permission_in_db.permission_id),
-            json=update_permission
-        )
-        assert response.status_code == status.HTTP_200_OK
-        updated_permission_in_db = await rbac_repo.get_permission(permission_in_db.permission_id)
-        assert updated_permission_in_db.path == f"/projects/{project.id}/*"
-
-    async def test_delete_permission(
-            self,
-            app: FastAPI,
-            client: AsyncClient,
-            db_session: AsyncSession,
-            project: Project,
-    ) -> None:
-
-        rbac_repo = RbacRepository(db_session)
-        permission_in_db = await rbac_repo.get_permission_by_path(f"/projects/{project.id}/*")
-        response = await client.delete(app.url_path_for("delete_permission", permission_id=permission_in_db.permission_id))
-        assert response.status_code == status.HTTP_204_NO_CONTENT
-
-    async def test_prune_permissions(self, app: FastAPI, client: AsyncClient, db_session: AsyncSession) -> None:
-
-        response = await client.post(app.url_path_for("prune_permissions"))
-        assert response.status_code == status.HTTP_204_NO_CONTENT
-
-        rbac_repo = RbacRepository(db_session)
-        permissions_in_db = await rbac_repo.get_permissions()
-        assert len(permissions_in_db) == 10  # 6 default permissions + 4 custom permissions
diff --git a/tests/api/routes/controller/test_roles.py b/tests/api/routes/controller/test_roles.py
index 600ca89d..66778eae 100644
--- a/tests/api/routes/controller/test_roles.py
+++ b/tests/api/routes/controller/test_roles.py
@@ -16,15 +16,12 @@
 # along with this program.  If not, see <http://www.gnu.org/licenses/>.
 
 import pytest
-import pytest_asyncio
 
 from fastapi import FastAPI, status
 from httpx import AsyncClient
 
 from sqlalchemy.ext.asyncio import AsyncSession
 from gns3server.db.repositories.rbac import RbacRepository
-from gns3server.schemas.controller.rbac import Permission, HTTPMethods, PermissionAction
-from gns3server import schemas
 
 pytestmark = pytest.mark.asyncio
 
@@ -49,7 +46,7 @@ class TestRolesRoutes:
 
         response = await client.get(app.url_path_for("get_roles"))
         assert response.status_code == status.HTTP_200_OK
-        assert len(response.json()) == 3  # 2 default roles + role1
+        assert len(response.json()) == 5  # 4 default roles + role1
 
     async def test_update_role(self, app: FastAPI, client: AsyncClient, db_session: AsyncSession) -> None:
 
@@ -106,46 +103,31 @@ class TestRolesRoutes:
         assert response.status_code == status.HTTP_403_FORBIDDEN
 
 
-@pytest_asyncio.fixture
-async def test_permission(db_session: AsyncSession) -> Permission:
+class TestRolesPrivilegesRoutes:
 
-    new_permission = schemas.PermissionCreate(
-        methods=[HTTPMethods.get],
-        path="/statistics",
-        action=PermissionAction.allow
-    )
-    rbac_repo = RbacRepository(db_session)
-    existing_permission = await rbac_repo.get_permission_by_path("/statistics")
-    if existing_permission:
-        return existing_permission
-    return await rbac_repo.create_permission(new_permission)
-
-
-class TestRolesPermissionsRoutes:
-
-    async def test_add_permission_to_role(
+    async def test_add_privilege_to_role(
             self,
             app: FastAPI,
             client: AsyncClient,
-            test_permission: Permission,
             db_session: AsyncSession
     ) -> None:
 
         rbac_repo = RbacRepository(db_session)
         role_in_db = await rbac_repo.get_role_by_name("User")
+        privilege = await rbac_repo.get_privilege_by_name("Template.Allocate")
 
         response = await client.put(
             app.url_path_for(
-                "add_permission_to_role",
+                "add_privilege_to_role",
                 role_id=role_in_db.role_id,
-                permission_id=str(test_permission.permission_id)
+                privilege_id=str(privilege.privilege_id)
             )
         )
         assert response.status_code == status.HTTP_204_NO_CONTENT
-        permissions = await rbac_repo.get_role_permissions(role_in_db.role_id)
-        assert len(permissions) == 6  # 5 default permissions + 1 custom permission
+        privileges = await rbac_repo.get_role_privileges(role_in_db.role_id)
+        assert len(privileges) == 21  # 20 default privileges + 1 custom privilege
 
-    async def test_get_role_permissions(
+    async def test_get_role_privileges(
             self,
             app: FastAPI,
             client: AsyncClient,
@@ -157,30 +139,30 @@ class TestRolesPermissionsRoutes:
 
         response = await client.get(
             app.url_path_for(
-                "get_role_permissions",
+                "get_role_privileges",
                 role_id=role_in_db.role_id)
         )
         assert response.status_code == status.HTTP_200_OK
-        assert len(response.json()) == 6  # 5 default permissions + 1 custom permission
+        assert len(response.json()) == 21  # 20 default privileges + 1 custom privilege
 
-    async def test_remove_role_from_group(
+    async def test_remove_privilege_from_role(
             self,
             app: FastAPI,
             client: AsyncClient,
-            test_permission: Permission,
             db_session: AsyncSession
     ) -> None:
 
         rbac_repo = RbacRepository(db_session)
         role_in_db = await rbac_repo.get_role_by_name("User")
+        privilege = await rbac_repo.get_privilege_by_name("Template.Allocate")
 
         response = await client.delete(
             app.url_path_for(
-                "remove_permission_from_role",
+                "remove_privilege_from_role",
                 role_id=role_in_db.role_id,
-                permission_id=str(test_permission.permission_id)
+                privilege_id=str(privilege.privilege_id)
             ),
         )
         assert response.status_code == status.HTTP_204_NO_CONTENT
-        permissions = await rbac_repo.get_role_permissions(role_in_db.role_id)
-        assert len(permissions) == 5  # 5 default permissions
+        privileges = await rbac_repo.get_role_privileges(role_in_db.role_id)
+        assert len(privileges) == 20  # 20 default privileges
diff --git a/tests/api/routes/controller/test_users.py b/tests/api/routes/controller/test_users.py
index a7b60472..2b33b52c 100644
--- a/tests/api/routes/controller/test_users.py
+++ b/tests/api/routes/controller/test_users.py
@@ -16,7 +16,6 @@
 # along with this program.  If not, see <http://www.gnu.org/licenses/>.
 
 import pytest
-import pytest_asyncio
 
 from typing import Optional
 from fastapi import FastAPI, HTTPException, status
@@ -26,12 +25,9 @@ from jose import jwt
 
 from sqlalchemy.ext.asyncio import AsyncSession
 from gns3server.db.repositories.users import UsersRepository
-from gns3server.db.repositories.rbac import RbacRepository
-from gns3server.schemas.controller.rbac import Permission, HTTPMethods, PermissionAction
 from gns3server.services import auth_service
 from gns3server.config import Config
 from gns3server.schemas.controller.users import User
-from gns3server import schemas
 import gns3server.db.models as models
 
 pytestmark = pytest.mark.asyncio
@@ -352,7 +348,7 @@ class TestUserMe:
         assert user.email == test_user.email
         assert user.user_id == test_user.user_id
 
-    # logged in users can only change their email, full name and password
+    # logged-in users can only change their email, full name and password
     @pytest.mark.parametrize(
         "attr, value, status_code",
         (
@@ -426,92 +422,3 @@ class TestSuperAdmin:
         response = await unauthorized_client.post(app.url_path_for("login"), data=login_data)
         assert response.status_code == status.HTTP_200_OK
 
-    # async def test_super_admin_belongs_to_admin_group(
-    #         self,
-    #         app: FastAPI,
-    #         client: AsyncClient,
-    #         db_session: AsyncSession
-    # ) -> None:
-    #
-    #     user_repo = UsersRepository(db_session)
-    #     admin_in_db = await user_repo.get_user_by_username("admin")
-    #     response = await client.get(app.url_path_for("get_user_memberships", user_id=admin_in_db.user_id))
-    #     assert response.status_code == status.HTTP_200_OK
-    #     assert len(response.json()) == 1
-
-
-@pytest_asyncio.fixture
-async def test_permission(db_session: AsyncSession) -> Permission:
-
-    new_permission = schemas.PermissionCreate(
-        methods=[HTTPMethods.get],
-        path="/statistics",
-        action=PermissionAction.allow
-    )
-    rbac_repo = RbacRepository(db_session)
-    existing_permission = await rbac_repo.get_permission_by_path("/statistics")
-    if existing_permission:
-        return existing_permission
-    return await rbac_repo.create_permission(new_permission)
-
-
-class TestUserPermissionsRoutes:
-
-    async def test_add_permission_to_user(
-            self,
-            app: FastAPI,
-            client: AsyncClient,
-            test_user: User,
-            test_permission: Permission,
-            db_session: AsyncSession
-    ) -> None:
-
-        response = await client.put(
-            app.url_path_for(
-                "add_permission_to_user",
-                user_id=str(test_user.user_id),
-                permission_id=str(test_permission.permission_id)
-            )
-        )
-        assert response.status_code == status.HTTP_204_NO_CONTENT
-        rbac_repo = RbacRepository(db_session)
-        permissions = await rbac_repo.get_user_permissions(test_user.user_id)
-        assert len(permissions) == 1
-        assert permissions[0].permission_id == test_permission.permission_id
-
-    async def test_get_user_permissions(
-            self,
-            app: FastAPI,
-            client: AsyncClient,
-            test_user: User,
-            db_session: AsyncSession
-    ) -> None:
-
-        response = await client.get(
-            app.url_path_for(
-                "get_user_permissions",
-                user_id=str(test_user.user_id))
-        )
-        assert response.status_code == status.HTTP_200_OK
-        assert len(response.json()) == 1
-
-    async def test_remove_permission_from_user(
-            self,
-            app: FastAPI,
-            client: AsyncClient,
-            test_user: User,
-            test_permission: Permission,
-            db_session: AsyncSession
-    ) -> None:
-
-        response = await client.delete(
-            app.url_path_for(
-                "remove_permission_from_user",
-                user_id=str(test_user.user_id),
-                permission_id=str(test_permission.permission_id)
-            ),
-        )
-        assert response.status_code == status.HTTP_204_NO_CONTENT
-        rbac_repo = RbacRepository(db_session)
-        permissions = await rbac_repo.get_user_permissions(test_user.user_id)
-        assert len(permissions) == 0
diff --git a/tests/conftest.py b/tests/conftest.py
index 40dd9604..d575b526 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -115,7 +115,7 @@ async def test_user(db_session: AsyncSession) -> User:
         return existing_user
     user = await user_repo.create_user(new_user)
 
-    # add new user to "Users group
+    # add new user to the "Users" group
     group = await user_repo.get_user_group_by_name("Users")
     await user_repo.add_member_to_user_group(group.user_group_id, user)
     return user
diff --git a/tests/controller/test_rbac.py b/tests/controller/test_rbac.py
index faa4e6df..178e96fb 100644
--- a/tests/controller/test_rbac.py
+++ b/tests/controller/test_rbac.py
@@ -27,177 +27,177 @@ from gns3server.db.models import User
 pytestmark = pytest.mark.asyncio
 
 
-class TestPermissions:
-
-    @pytest.mark.parametrize(
-        "method, path, result",
-        (
-            ("GET", "/users", False),
-            ("GET", "/projects", True),
-            ("GET", "/projects/e451ad73-2519-4f83-87fe-a8e821792d44", False),
-            ("POST", "/projects", True),
-            ("GET", "/templates", True),
-            ("GET", "/templates/62e92cf1-244a-4486-8dae-b95439b54da9", False),
-            ("POST", "/templates", True),
-            ("GET", "/computes", True),
-            ("GET", "/computes/local", True),
-            ("GET", "/symbols", True),
-            ("GET", "/symbols/default_symbols", True),
-        ),
-    )
-    async def test_default_permissions_user_group(
-            self,
-            app: FastAPI,
-            authorized_client: AsyncClient,
-            test_user: User,
-            db_session: AsyncSession,
-            method: str,
-            path: str,
-            result: bool
-    ) -> None:
-
-        rbac_repo = RbacRepository(db_session)
-        authorized = await rbac_repo.check_user_is_authorized(test_user.user_id, method, path)
-        assert authorized == result
-
-
-class TestProjectsWithRbac:
-
-    async def test_admin_create_project(self, app: FastAPI, client: AsyncClient):
-
-        params = {"name": "Admin project"}
-        response = await client.post(app.url_path_for("create_project"), json=params)
-        assert response.status_code == status.HTTP_201_CREATED
-
-    async def test_user_only_access_own_projects(
-            self,
-            app: FastAPI,
-            authorized_client: AsyncClient,
-            test_user: User,
-            db_session: AsyncSession
-    ) -> None:
-
-        params = {"name": "User project"}
-        response = await authorized_client.post(app.url_path_for("create_project"), json=params)
-        assert response.status_code == status.HTTP_201_CREATED
-        project_id = response.json()["project_id"]
-
-        rbac_repo = RbacRepository(db_session)
-        permissions_in_db = await rbac_repo.get_user_permissions(test_user.user_id)
-        assert len(permissions_in_db) == 1
-        assert permissions_in_db[0].path == f"/projects/{project_id}/*"
-
-        response = await authorized_client.get(app.url_path_for("get_projects"))
-        assert response.status_code == status.HTTP_200_OK
-        projects = response.json()
-        assert len(projects) == 1
-
-    async def test_admin_access_all_projects(self, app: FastAPI, client: AsyncClient):
-
-        response = await client.get(app.url_path_for("get_projects"))
-        assert response.status_code == status.HTTP_200_OK
-        projects = response.json()
-        assert len(projects) == 2
-
-    async def test_admin_user_give_permission_on_project(
-            self,
-            app: FastAPI,
-            client: AsyncClient,
-            test_user: User
-    ):
-
-        response = await client.get(app.url_path_for("get_projects"))
-        assert response.status_code == status.HTTP_200_OK
-        projects = response.json()
-        project_id = None
-        for project in projects:
-            if project["name"] == "Admin project":
-                project_id = project["project_id"]
-                break
-
-        new_permission = {
-            "methods": ["GET"],
-            "path": f"/projects/{project_id}",
-            "action": "ALLOW"
-        }
-        response = await client.post(app.url_path_for("create_permission"), json=new_permission)
-        assert response.status_code == status.HTTP_201_CREATED
-        permission_id = response.json()["permission_id"]
-
-        response = await client.put(
-            app.url_path_for(
-                "add_permission_to_user",
-                user_id=test_user.user_id,
-                permission_id=permission_id
-            )
-        )
-        assert response.status_code == status.HTTP_204_NO_CONTENT
-
-    async def test_user_access_admin_project(
-            self,
-            app: FastAPI,
-            authorized_client: AsyncClient,
-            test_user: User,
-            db_session: AsyncSession
-    ) -> None:
-
-        response = await authorized_client.get(app.url_path_for("get_projects"))
-        assert response.status_code == status.HTTP_200_OK
-        projects = response.json()
-        assert len(projects) == 2
-
-
-class TestTemplatesWithRbac:
-
-    async def test_admin_create_template(self, app: FastAPI, client: AsyncClient):
-
-        new_template = {"base_script_file": "vpcs_base_config.txt",
-                        "category": "guest",
-                        "console_auto_start": False,
-                        "console_type": "telnet",
-                        "default_name_format": "PC{0}",
-                        "name": "ADMIN_VPCS_TEMPLATE",
-                        "compute_id": "local",
-                        "symbol": ":/symbols/vpcs_guest.svg",
-                        "template_type": "vpcs"}
-
-        response = await client.post(app.url_path_for("create_template"), json=new_template)
-        assert response.status_code == status.HTTP_201_CREATED
-
-    async def test_user_only_access_own_templates(
-            self, app: FastAPI,
-            authorized_client: AsyncClient,
-            test_user: User,
-            db_session: AsyncSession
-    ) -> None:
-
-        new_template = {"base_script_file": "vpcs_base_config.txt",
-                        "category": "guest",
-                        "console_auto_start": False,
-                        "console_type": "telnet",
-                        "default_name_format": "PC{0}",
-                        "name": "USER_VPCS_TEMPLATE",
-                        "compute_id": "local",
-                        "symbol": ":/symbols/vpcs_guest.svg",
-                        "template_type": "vpcs"}
-
-        response = await authorized_client.post(app.url_path_for("create_template"), json=new_template)
-        assert response.status_code == status.HTTP_201_CREATED
-        template_id = response.json()["template_id"]
-
-        rbac_repo = RbacRepository(db_session)
-        permissions_in_db = await rbac_repo.get_user_permissions(test_user.user_id)
-        assert len(permissions_in_db) == 1
-        assert permissions_in_db[0].path == f"/templates/{template_id}/*"
-
-        response = await authorized_client.get(app.url_path_for("get_templates"))
-        assert response.status_code == status.HTTP_200_OK
-        templates = [template for template in response.json() if template["builtin"] is False]
-        assert len(templates) == 1
-
-    async def test_admin_access_all_templates(self, app: FastAPI, client: AsyncClient):
-
-        response = await client.get(app.url_path_for("get_templates"))
-        assert response.status_code == status.HTTP_200_OK
-        templates = [template for template in response.json() if template["builtin"] is False]
-        assert len(templates) == 2
+# class TestPermissions:
+#
+#     @pytest.mark.parametrize(
+#         "method, path, result",
+#         (
+#             ("GET", "/users", False),
+#             ("GET", "/projects", True),
+#             ("GET", "/projects/e451ad73-2519-4f83-87fe-a8e821792d44", False),
+#             ("POST", "/projects", True),
+#             ("GET", "/templates", True),
+#             ("GET", "/templates/62e92cf1-244a-4486-8dae-b95439b54da9", False),
+#             ("POST", "/templates", True),
+#             ("GET", "/computes", True),
+#             ("GET", "/computes/local", True),
+#             ("GET", "/symbols", True),
+#             ("GET", "/symbols/default_symbols", True),
+#         ),
+#     )
+#     async def test_default_permissions_user_group(
+#             self,
+#             app: FastAPI,
+#             authorized_client: AsyncClient,
+#             test_user: User,
+#             db_session: AsyncSession,
+#             method: str,
+#             path: str,
+#             result: bool
+#     ) -> None:
+#
+#         rbac_repo = RbacRepository(db_session)
+#         authorized = await rbac_repo.check_user_is_authorized(test_user.user_id, method, path)
+#         assert authorized == result
+#
+#
+# class TestProjectsWithRbac:
+#
+#     async def test_admin_create_project(self, app: FastAPI, client: AsyncClient):
+#
+#         params = {"name": "Admin project"}
+#         response = await client.post(app.url_path_for("create_project"), json=params)
+#         assert response.status_code == status.HTTP_201_CREATED
+#
+#     async def test_user_only_access_own_projects(
+#             self,
+#             app: FastAPI,
+#             authorized_client: AsyncClient,
+#             test_user: User,
+#             db_session: AsyncSession
+#     ) -> None:
+#
+#         params = {"name": "User project"}
+#         response = await authorized_client.post(app.url_path_for("create_project"), json=params)
+#         assert response.status_code == status.HTTP_201_CREATED
+#         project_id = response.json()["project_id"]
+#
+#         rbac_repo = RbacRepository(db_session)
+#         permissions_in_db = await rbac_repo.get_user_permissions(test_user.user_id)
+#         assert len(permissions_in_db) == 1
+#         assert permissions_in_db[0].path == f"/projects/{project_id}/*"
+#
+#         response = await authorized_client.get(app.url_path_for("get_projects"))
+#         assert response.status_code == status.HTTP_200_OK
+#         projects = response.json()
+#         assert len(projects) == 1
+#
+#     async def test_admin_access_all_projects(self, app: FastAPI, client: AsyncClient):
+#
+#         response = await client.get(app.url_path_for("get_projects"))
+#         assert response.status_code == status.HTTP_200_OK
+#         projects = response.json()
+#         assert len(projects) == 2
+#
+#     async def test_admin_user_give_permission_on_project(
+#             self,
+#             app: FastAPI,
+#             client: AsyncClient,
+#             test_user: User
+#     ):
+#
+#         response = await client.get(app.url_path_for("get_projects"))
+#         assert response.status_code == status.HTTP_200_OK
+#         projects = response.json()
+#         project_id = None
+#         for project in projects:
+#             if project["name"] == "Admin project":
+#                 project_id = project["project_id"]
+#                 break
+#
+#         new_permission = {
+#             "methods": ["GET"],
+#             "path": f"/projects/{project_id}",
+#             "action": "ALLOW"
+#         }
+#         response = await client.post(app.url_path_for("create_permission"), json=new_permission)
+#         assert response.status_code == status.HTTP_201_CREATED
+#         permission_id = response.json()["permission_id"]
+#
+#         response = await client.put(
+#             app.url_path_for(
+#                 "add_permission_to_user",
+#                 user_id=test_user.user_id,
+#                 permission_id=permission_id
+#             )
+#         )
+#         assert response.status_code == status.HTTP_204_NO_CONTENT
+#
+#     async def test_user_access_admin_project(
+#             self,
+#             app: FastAPI,
+#             authorized_client: AsyncClient,
+#             test_user: User,
+#             db_session: AsyncSession
+#     ) -> None:
+#
+#         response = await authorized_client.get(app.url_path_for("get_projects"))
+#         assert response.status_code == status.HTTP_200_OK
+#         projects = response.json()
+#         assert len(projects) == 2
+#
+#
+# class TestTemplatesWithRbac:
+#
+#     async def test_admin_create_template(self, app: FastAPI, client: AsyncClient):
+#
+#         new_template = {"base_script_file": "vpcs_base_config.txt",
+#                         "category": "guest",
+#                         "console_auto_start": False,
+#                         "console_type": "telnet",
+#                         "default_name_format": "PC{0}",
+#                         "name": "ADMIN_VPCS_TEMPLATE",
+#                         "compute_id": "local",
+#                         "symbol": ":/symbols/vpcs_guest.svg",
+#                         "template_type": "vpcs"}
+#
+#         response = await client.post(app.url_path_for("create_template"), json=new_template)
+#         assert response.status_code == status.HTTP_201_CREATED
+#
+#     async def test_user_only_access_own_templates(
+#             self, app: FastAPI,
+#             authorized_client: AsyncClient,
+#             test_user: User,
+#             db_session: AsyncSession
+#     ) -> None:
+#
+#         new_template = {"base_script_file": "vpcs_base_config.txt",
+#                         "category": "guest",
+#                         "console_auto_start": False,
+#                         "console_type": "telnet",
+#                         "default_name_format": "PC{0}",
+#                         "name": "USER_VPCS_TEMPLATE",
+#                         "compute_id": "local",
+#                         "symbol": ":/symbols/vpcs_guest.svg",
+#                         "template_type": "vpcs"}
+#
+#         response = await authorized_client.post(app.url_path_for("create_template"), json=new_template)
+#         assert response.status_code == status.HTTP_201_CREATED
+#         template_id = response.json()["template_id"]
+#
+#         rbac_repo = RbacRepository(db_session)
+#         permissions_in_db = await rbac_repo.get_user_permissions(test_user.user_id)
+#         assert len(permissions_in_db) == 1
+#         assert permissions_in_db[0].path == f"/templates/{template_id}/*"
+#
+#         response = await authorized_client.get(app.url_path_for("get_templates"))
+#         assert response.status_code == status.HTTP_200_OK
+#         templates = [template for template in response.json() if template["builtin"] is False]
+#         assert len(templates) == 1
+#
+#     async def test_admin_access_all_templates(self, app: FastAPI, client: AsyncClient):
+#
+#         response = await client.get(app.url_path_for("get_templates"))
+#         assert response.status_code == status.HTTP_200_OK
+#         templates = [template for template in response.json() if template["builtin"] is False]
+#         assert len(templates) == 2

From 57197c3d1ca3769a55ab5cf94a429b5c952d27e8 Mon Sep 17 00:00:00 2001
From: grossmj <grossmj@gns3.net>
Date: Sun, 27 Aug 2023 18:23:10 +1000
Subject: [PATCH 3/8] Comment unused code

---
 gns3server/db/repositories/rbac.py | 58 +++++++++++++++---------------
 1 file changed, 29 insertions(+), 29 deletions(-)

diff --git a/gns3server/db/repositories/rbac.py b/gns3server/db/repositories/rbac.py
index 54be04c2..f3d3971b 100644
--- a/gns3server/db/repositories/rbac.py
+++ b/gns3server/db/repositories/rbac.py
@@ -362,32 +362,32 @@ class RbacRepository(BaseRepository):
 
         return True
 
-        query = select(models.Permission).\
-            join(models.Permission.roles).\
-            join(models.Role.groups).\
-            join(models.UserGroup.users).\
-            filter(models.User.user_id == user_id).\
-            order_by(models.Permission.path.desc())
-
-        result = await self._db_session.execute(query)
-        permissions = result.scalars().all()
-        log.debug(f"RBAC: checking authorization for user '{user_id}' on {method} '{path}'")
-        matched_permission = self._match_permission(permissions, method, path)
-        if matched_permission:
-            log.debug(f"RBAC: matched role permission {matched_permission.methods} "
-                      f"{matched_permission.path} {matched_permission.action}")
-            if matched_permission.action == "DENY":
-                return False
-            return True
-
-        log.debug(f"RBAC: could not find a role permission, checking user permissions...")
-        permissions = await self.get_user_permissions(user_id)
-        matched_permission = self._match_permission(permissions, method, path)
-        if matched_permission:
-            log.debug(f"RBAC: matched user permission {matched_permission.methods} "
-                      f"{matched_permission.path} {matched_permission.action}")
-            if matched_permission.action == "DENY":
-                return False
-            return True
-
-        return False
+        # query = select(models.Permission).\
+        #     join(models.Permission.roles).\
+        #     join(models.Role.groups).\
+        #     join(models.UserGroup.users).\
+        #     filter(models.User.user_id == user_id).\
+        #     order_by(models.Permission.path.desc())
+        #
+        # result = await self._db_session.execute(query)
+        # permissions = result.scalars().all()
+        # log.debug(f"RBAC: checking authorization for user '{user_id}' on {method} '{path}'")
+        # matched_permission = self._match_permission(permissions, method, path)
+        # if matched_permission:
+        #     log.debug(f"RBAC: matched role permission {matched_permission.methods} "
+        #               f"{matched_permission.path} {matched_permission.action}")
+        #     if matched_permission.action == "DENY":
+        #         return False
+        #     return True
+        #
+        # log.debug(f"RBAC: could not find a role permission, checking user permissions...")
+        # permissions = await self.get_user_permissions(user_id)
+        # matched_permission = self._match_permission(permissions, method, path)
+        # if matched_permission:
+        #     log.debug(f"RBAC: matched user permission {matched_permission.methods} "
+        #               f"{matched_permission.path} {matched_permission.action}")
+        #     if matched_permission.action == "DENY":
+        #         return False
+        #     return True
+        #
+        # return False

From 3e0592520b222dae9da05632766b54216e6f4ea1 Mon Sep 17 00:00:00 2001
From: grossmj <grossmj@gns3.net>
Date: Mon, 28 Aug 2023 12:06:01 +1000
Subject: [PATCH 4/8] Handle ACE propagate and allowed

---
 gns3server/db/models/resource_pools.py | 16 ++---
 gns3server/db/repositories/rbac.py     | 98 +++++---------------------
 2 files changed, 27 insertions(+), 87 deletions(-)

diff --git a/gns3server/db/models/resource_pools.py b/gns3server/db/models/resource_pools.py
index 220bc7ec..80f65070 100644
--- a/gns3server/db/models/resource_pools.py
+++ b/gns3server/db/models/resource_pools.py
@@ -32,11 +32,11 @@ class ResourcePool(BaseTable):
     resource_id = Column(GUID, primary_key=True)
     resource_type = Column(String)
 
-    # # Create a self-referential relationship to represent a hierarchy of resources
-    # parent_id = Column(GUID, ForeignKey("resources.resource_id", ondelete="CASCADE"))
-    # children = relationship(
-    #     "Resource",
-    #     remote_side=[resource_id],
-    #     cascade="all, delete-orphan",
-    #     single_parent=True
-    # )
+    # Create a self-referential relationship to represent a hierarchy of resources
+    parent_id = Column(GUID, ForeignKey("resources.resource_id", ondelete="CASCADE"))
+    children = relationship(
+        "Resource",
+        remote_side=[resource_id],
+        cascade="all, delete-orphan",
+        single_parent=True
+    )
diff --git a/gns3server/db/repositories/rbac.py b/gns3server/db/repositories/rbac.py
index f3d3971b..6277e856 100644
--- a/gns3server/db/repositories/rbac.py
+++ b/gns3server/db/repositories/rbac.py
@@ -16,6 +16,7 @@
 # along with this program.  If not, see <http://www.gnu.org/licenses/>.
 
 from uuid import UUID
+from urllib.parse import urlparse
 from typing import Optional, List, Union
 from sqlalchemy import select, update, delete, null
 from sqlalchemy.ext.asyncio import AsyncSession
@@ -290,25 +291,6 @@ class RbacRepository(BaseRepository):
     #             permissions_deleted += 1
     #     log.info(f"{permissions_deleted} orphaned permissions have been deleted")
     #     return permissions_deleted
-    #
-    # def _match_permission(
-    #         self,
-    #         permissions: List[models.Permission],
-    #         method: str,
-    #         path: str
-    # ) -> Union[None, models.Permission]:
-    #     """
-    #     Match the methods and path with a permission.
-    #     """
-    #
-    #     for permission in permissions:
-    #         log.debug(f"RBAC: checking permission {permission.methods} {permission.path} {permission.action}")
-    #         if method not in permission.methods:
-    #             continue
-    #         if permission.path.endswith("/*") and path.startswith(permission.path[:-2]):
-    #             return permission
-    #         elif permission.path == path:
-    #             return permission
 
     async def delete_all_ace_starting_with_path(self, path: str) -> None:
         """
@@ -323,71 +305,29 @@ class RbacRepository(BaseRepository):
 
     async def check_user_has_privilege(self, user_id: UUID, path: str, privilege_name: str) -> bool:
 
-        # query = select(models.Privilege.name).\
-        #     join(models.Privilege.roles).\
-        #     join(models.Role.acl_entries).\
-        #     join(models.ACE.user).\
-        #     filter(models.Privilege.name == privilege). \
-        #     filter(models.User.user_id == user_id).\
-        #     filter(models.ACE.path == path).\
-        #     distinct()
-
-        #query = select(models.ACE.path)
-        #result = await self._db_session.execute(query)
-        #res = result.scalars().all()
-        #print("ACL TABLE ==>", res)
-        #for ace in res:
-        #    print(ace)
-
-        query = select(models.Privilege.name, models.ACE.path, models.ACE.propagate).\
+        #TODO: handle when user belong to one or more groups (left join?)
+        query = select(models.ACE.path, models.ACE.propagate, models.ACE.allowed, models.Privilege.name).\
             join(models.Privilege.roles).\
             join(models.Role.acl_entries).\
             join(models.ACE.user).\
             filter(models.User.user_id == user_id).\
             filter(models.Privilege.name == privilege_name).\
-            filter(models.ACE.path == path).\
             order_by(models.ACE.path.desc())
         result = await self._db_session.execute(query)
-        privileges = result.all()
-        #print(privileges)
-        for privilege, privilege_path, propagate in privileges:
-            if privilege_path == path:
-                return True
+        aces = result.all()
+
+        parsed_url = urlparse(path)
+        original_path = path
+        path_components = parsed_url.path.split("/")
+        # traverse the path in reverse order
+        for i in range(len(path_components), 0, -1):
+            path = "/".join(path_components[:i])
+            if not path:
+                path = "/"
+            for ace_path, ace_propagate, ace_allowed, ace_privilege in aces:
+                if ace_path == path:
+                    if not ace_allowed:
+                        return False
+                    if path == original_path or ace_propagate:
+                        return True  # only allow if the path is the original path or the ACE is set to propagate
         return False
-
-    async def check_user_is_authorized(self, user_id: UUID, path: str) -> bool:
-        """
-        Check if a user is authorized to access a resource.
-        """
-
-        return True
-
-        # query = select(models.Permission).\
-        #     join(models.Permission.roles).\
-        #     join(models.Role.groups).\
-        #     join(models.UserGroup.users).\
-        #     filter(models.User.user_id == user_id).\
-        #     order_by(models.Permission.path.desc())
-        #
-        # result = await self._db_session.execute(query)
-        # permissions = result.scalars().all()
-        # log.debug(f"RBAC: checking authorization for user '{user_id}' on {method} '{path}'")
-        # matched_permission = self._match_permission(permissions, method, path)
-        # if matched_permission:
-        #     log.debug(f"RBAC: matched role permission {matched_permission.methods} "
-        #               f"{matched_permission.path} {matched_permission.action}")
-        #     if matched_permission.action == "DENY":
-        #         return False
-        #     return True
-        #
-        # log.debug(f"RBAC: could not find a role permission, checking user permissions...")
-        # permissions = await self.get_user_permissions(user_id)
-        # matched_permission = self._match_permission(permissions, method, path)
-        # if matched_permission:
-        #     log.debug(f"RBAC: matched user permission {matched_permission.methods} "
-        #               f"{matched_permission.path} {matched_permission.action}")
-        #     if matched_permission.action == "DENY":
-        #         return False
-        #     return True
-        #
-        # return False

From f3a4ad49f44f60e7507ae1ae162184dd87877fd1 Mon Sep 17 00:00:00 2001
From: grossmj <grossmj@gns3.net>
Date: Mon, 28 Aug 2023 18:14:34 +1000
Subject: [PATCH 5/8] Check for group ACEs to find user privileges

---
 gns3server/db/models/privileges.py | 15 ++++++-
 gns3server/db/repositories/rbac.py | 64 ++++++++++++++++++++++++------
 2 files changed, 64 insertions(+), 15 deletions(-)

diff --git a/gns3server/db/models/privileges.py b/gns3server/db/models/privileges.py
index b3da8ebf..2bd746df 100644
--- a/gns3server/db/models/privileges.py
+++ b/gns3server/db/models/privileges.py
@@ -171,6 +171,14 @@ def create_default_roles(target, connection, **kw):
             "description": "View a compute",
             "name": "Compute.Audit"
         },
+        {
+            "description": "Install an appliance",
+            "name": "Appliance.Allocate"
+        },
+        {
+            "description": "View an appliance",
+            "name": "Appliance.Audit"
+        }
     ]
 
     stmt = target.insert().values(default_privileges)
@@ -235,7 +243,9 @@ def add_privileges_to_default_roles(target, connection, **kw):
         "Template.Audit",
         "Symbol.Audit",
         "Image.Audit",
-        "Compute.Audit"
+        "Compute.Audit",
+        "Appliance.Allocate",
+        "Appliance.Audit"
     )
 
     add_privileges_to_role(target, connection, "User", user_privileges)
@@ -249,7 +259,8 @@ def add_privileges_to_default_roles(target, connection, **kw):
         "Template.Audit",
         "Symbol.Audit",
         "Image.Audit",
-        "Compute.Audit"
+        "Compute.Audit",
+        "Appliance.Audit"
     )
 
     add_privileges_to_role(target, connection, "Auditor", auditor_privileges)
diff --git a/gns3server/db/repositories/rbac.py b/gns3server/db/repositories/rbac.py
index 6277e856..418e8775 100644
--- a/gns3server/db/repositories/rbac.py
+++ b/gns3server/db/repositories/rbac.py
@@ -303,18 +303,8 @@ class RbacRepository(BaseRepository):
         result = await self._db_session.execute(query)
         log.debug(f"{result.rowcount} ACE(s) have been deleted")
 
-    async def check_user_has_privilege(self, user_id: UUID, path: str, privilege_name: str) -> bool:
-
-        #TODO: handle when user belong to one or more groups (left join?)
-        query = select(models.ACE.path, models.ACE.propagate, models.ACE.allowed, models.Privilege.name).\
-            join(models.Privilege.roles).\
-            join(models.Role.acl_entries).\
-            join(models.ACE.user).\
-            filter(models.User.user_id == user_id).\
-            filter(models.Privilege.name == privilege_name).\
-            order_by(models.ACE.path.desc())
-        result = await self._db_session.execute(query)
-        aces = result.all()
+    @staticmethod
+    def _match_path_to_aces(path: str, aces) -> bool:
 
         parsed_url = urlparse(path)
         original_path = path
@@ -327,7 +317,55 @@ class RbacRepository(BaseRepository):
             for ace_path, ace_propagate, ace_allowed, ace_privilege in aces:
                 if ace_path == path:
                     if not ace_allowed:
-                        return False
+                        raise PermissionError(f"Permission denied for {path}")
                     if path == original_path or ace_propagate:
                         return True  # only allow if the path is the original path or the ACE is set to propagate
         return False
+
+    async def check_user_has_privilege(self, user_id: UUID, path: str, privilege_name: str) -> bool:
+        """
+        Resource paths form a file system like tree and privileges can be inherited by paths down that tree
+        (the propagate field is True by default)
+
+        The following inheritance rules are used:
+
+        * Privileges for individual users always replace group privileges.
+        * Privileges for groups apply when the user is member of that group.
+        * Privileges on deeper levels replace those inherited from an upper level.
+        """
+
+        # retrieve all user ACEs matching the user_id and privilege name
+        query = select(models.ACE.path, models.ACE.propagate, models.ACE.allowed, models.Privilege.name).\
+            join(models.Privilege.roles).\
+            join(models.Role.acl_entries).\
+            join(models.ACE.user). \
+            filter(models.User.user_id == user_id).\
+            filter(models.Privilege.name == privilege_name).\
+            order_by(models.ACE.path.desc())
+
+        result = await self._db_session.execute(query)
+        aces = result.all()
+
+        try:
+            if self._match_path_to_aces(path, aces):
+                # the user has an ACE matching the path and privilege,there is no need to check group ACEs
+                return True
+        except PermissionError:
+            return False
+
+        # retrieve all group ACEs matching the user_id and privilege name
+        query = select(models.ACE.path, models.ACE.propagate, models.ACE.allowed, models.Privilege.name). \
+            join(models.Privilege.roles). \
+            join(models.Role.acl_entries). \
+            join(models.ACE.group). \
+            join(models.UserGroup.users).\
+            filter(models.User.user_id == user_id). \
+            filter(models.Privilege.name == privilege_name)
+
+        result = await self._db_session.execute(query)
+        aces = result.all()
+
+        try:
+            return self._match_path_to_aces(path, aces)
+        except PermissionError:
+            return False

From 0077fd98aaa8a0c2e4b5ec0dee0c8ba8b8334770 Mon Sep 17 00:00:00 2001
From: grossmj <grossmj@gns3.net>
Date: Sat, 2 Sep 2023 17:54:24 +0700
Subject: [PATCH 6/8] Add required privileges to all endpoints

---
 gns3server/api/routes/controller/__init__.py  |  12 +-
 gns3server/api/routes/controller/acl.py       | 145 ++++++++--
 .../api/routes/controller/appliances.py       |  40 ++-
 gns3server/api/routes/controller/computes.py  |  53 +++-
 .../routes/controller/dependencies/rbac.py    |   6 +-
 gns3server/api/routes/controller/drawings.py  |  57 +++-
 gns3server/api/routes/controller/groups.py    |  64 ++++-
 gns3server/api/routes/controller/images.py    |  44 ++-
 gns3server/api/routes/controller/links.py     |  92 ++++++-
 gns3server/api/routes/controller/nodes.py     | 191 +++++++++++--
 gns3server/api/routes/controller/projects.py  |  79 ++++--
 gns3server/api/routes/controller/roles.py     |  63 ++++-
 gns3server/api/routes/controller/snapshots.py |  48 +++-
 gns3server/api/routes/controller/symbols.py   |  29 +-
 gns3server/api/routes/controller/templates.py |  56 +++-
 gns3server/api/routes/controller/users.py     |  51 +++-
 gns3server/db/models/acl.py                   |   4 +-
 gns3server/db/models/privileges.py            |  43 ++-
 gns3server/db/repositories/rbac.py            |  25 +-
 gns3server/schemas/controller/rbac.py         |   2 +-
 tests/api/routes/controller/test_acl.py       | 114 ++++----
 tests/api/routes/controller/test_roles.py     |   6 +-
 tests/api/routes/controller/test_users.py     |   2 +-
 tests/controller/test_rbac.py                 | 256 +++++++++++-------
 24 files changed, 1125 insertions(+), 357 deletions(-)

diff --git a/gns3server/api/routes/controller/__init__.py b/gns3server/api/routes/controller/__init__.py
index 1b3f2d4f..8a012b95 100644
--- a/gns3server/api/routes/controller/__init__.py
+++ b/gns3server/api/routes/controller/__init__.py
@@ -43,35 +43,30 @@ router.include_router(users.router, prefix="/users", tags=["Users"])
 
 router.include_router(
     groups.router,
-    dependencies=[Depends(get_current_active_user)],
     prefix="/groups",
     tags=["Users groups"]
 )
 
 router.include_router(
     roles.router,
-    dependencies=[Depends(get_current_active_user)],
     prefix="/roles",
     tags=["Roles"]
 )
 
 router.include_router(
     acl.router,
-    dependencies=[Depends(get_current_active_user)],
     prefix="/acl",
     tags=["ACL"]
 )
 
 router.include_router(
     images.router,
-    dependencies=[Depends(get_current_active_user)],
     prefix="/images",
     tags=["Images"]
 )
 
 router.include_router(
     templates.router,
-    dependencies=[Depends(get_current_active_user)],
     prefix="/templates",
     tags=["Templates"]
 )
@@ -83,21 +78,18 @@ router.include_router(
 
 router.include_router(
     nodes.router,
-    dependencies=[Depends(get_current_active_user)],
     prefix="/projects/{project_id}/nodes",
     tags=["Nodes"]
 )
 
 router.include_router(
     links.router,
-    dependencies=[Depends(get_current_active_user)],
     prefix="/projects/{project_id}/links",
     tags=["Links"]
 )
 
 router.include_router(
     drawings.router,
-    dependencies=[Depends(get_current_active_user)],
     prefix="/projects/{project_id}/drawings",
     tags=["Drawings"])
 
@@ -108,7 +100,6 @@ router.include_router(
 
 router.include_router(
     snapshots.router,
-    dependencies=[Depends(get_current_active_user)],
     prefix="/projects/{project_id}/snapshots",
     tags=["Snapshots"])
 
@@ -126,15 +117,14 @@ router.include_router(
 
 router.include_router(
     appliances.router,
-    dependencies=[Depends(get_current_active_user)],
     prefix="/appliances",
     tags=["Appliances"]
 )
 
 router.include_router(
     gns3vm.router,
-    deprecated=True,
     dependencies=[Depends(get_current_active_user)],
+    deprecated=True,
     prefix="/gns3vm",
     tags=["GNS3 VM"]
 )
diff --git a/gns3server/api/routes/controller/acl.py b/gns3server/api/routes/controller/acl.py
index 2bc4e1db..763bb194 100644
--- a/gns3server/api/routes/controller/acl.py
+++ b/gns3server/api/routes/controller/acl.py
@@ -30,13 +30,16 @@ from typing import List
 from gns3server import schemas
 from gns3server.controller.controller_error import (
     ControllerBadRequestError,
-    ControllerNotFoundError,
-    ControllerForbiddenError,
+    ControllerNotFoundError
 )
 
+from gns3server.controller import Controller
+from gns3server.db.repositories.users import UsersRepository
 from gns3server.db.repositories.rbac import RbacRepository
+from gns3server.db.repositories.images import ImagesRepository
+from gns3server.db.repositories.templates import TemplatesRepository
 from .dependencies.database import get_repository
-from .dependencies.authentication import get_current_active_user
+from .dependencies.rbac import has_privilege
 
 import logging
 
@@ -45,26 +48,121 @@ log = logging.getLogger(__name__)
 router = APIRouter()
 
 
-@router.get("", response_model=List[schemas.ACE])
+@router.get(
+    "/endpoints",
+    status_code=status.HTTP_201_CREATED,
+    dependencies=[Depends(has_privilege("ACE.Audit"))]
+)
+async def endpoints(
+        users_repo: UsersRepository = Depends(get_repository(UsersRepository)),
+        rbac_repo: RbacRepository = Depends(get_repository(RbacRepository)),
+        images_repo: ImagesRepository = Depends(get_repository(ImagesRepository)),
+        templates_repo: TemplatesRepository = Depends(get_repository(TemplatesRepository))
+) -> List[dict]:
+    """
+    List all endpoints to be used in ACL entries.
+    """
+
+    controller = Controller.instance()
+    endpoints = [{"endpoint": "/", "name": "All endpoints", "endpoint_type": "root"}]
+
+    def add_to_endpoints(endpoint: str, name: str, endpoint_type: str) -> None:
+        if endpoint not in endpoints:
+            endpoints.append({"endpoint": endpoint, "name": name, "endpoint_type": endpoint_type})
+
+    # projects
+    add_to_endpoints("/projects", "All projects", "project")
+    projects = [p for p in controller.projects.values()]
+    for project in projects:
+        add_to_endpoints(f"/projects/{project.id}", f'Project "{project.name}"', "project")
+
+        # nodes
+        add_to_endpoints(f"/projects/{project.id}/nodes", f'All nodes in project "{project.name}"', "node")
+        for node in project.nodes.values():
+            add_to_endpoints(
+                f"/projects/{project.id}/nodes/{node['node_id']}",
+                f'Node "{node["name"]}" in project "{project.name}"',
+                endpoint_type="node"
+            )
+
+        # links
+        add_to_endpoints(f"/projects/{project.id}/links", f'All links in project "{project.name}"', "link")
+        for link in project.links.values():
+            node_id_1 = link["nodes"][0]["node_id"]
+            node_id_2 = link["nodes"][1]["node_id"]
+            node_name_1 = project.nodes[node_id_1]["name"]
+            node_name_2 = project.nodes[node_id_2]["name"]
+            add_to_endpoints(
+                f"/projects/{project.id}/links/{link['link_id']}",
+                f'Link from "{node_name_1}" to "{node_name_2}" in project "{project.name}"',
+                endpoint_type="link"
+            )
+
+    # users
+    add_to_endpoints("/users", "All users", "user")
+    users = await users_repo.get_users()
+    for user in users:
+        add_to_endpoints(f"/users/{user.user_id}", f'User "{user.username}"', "user")
+
+    # groups
+    add_to_endpoints("/groups", "All groups", "group")
+    groups = await users_repo.get_user_groups()
+    for group in groups:
+        add_to_endpoints(f"/groups/{group.user_group_id}", f'Group "{group.name}"', "group")
+
+    # roles
+    add_to_endpoints("/roles", "All roles", "role")
+    roles = await rbac_repo.get_roles()
+    for role in roles:
+        add_to_endpoints(f"/roles/{role.role_id}", f'Role "{role.name}"', "role")
+
+    # images
+    add_to_endpoints("/images", "All images", "image")
+    images = await images_repo.get_images()
+    for image in images:
+        add_to_endpoints(f"/images/{image.filename}", f'Image "{image.filename}"', "image")
+
+    # templates
+    add_to_endpoints("/templates", "All templates", "template")
+    templates = await templates_repo.get_templates()
+    for template in templates:
+        add_to_endpoints(f"/templates/{template.template_id}", f'Template "{template.name}"', "template")
+
+    return endpoints
+
+
+@router.get(
+    "",
+    response_model=List[schemas.ACE],
+    dependencies=[Depends(has_privilege("ACE.Audit"))]
+)
 async def get_aces(
         rbac_repo: RbacRepository = Depends(get_repository(RbacRepository))
 ) -> List[schemas.ACE]:
     """
     Get all ACL entries.
+
+    Required privilege: ACE.Audit
     """
 
     return await rbac_repo.get_aces()
 
 
-@router.post("", response_model=schemas.ACE, status_code=status.HTTP_201_CREATED)
+@router.post(
+    "",
+    response_model=schemas.ACE,
+    status_code=status.HTTP_201_CREATED,
+    dependencies=[Depends(has_privilege("ACE.Allocate"))]
+)
 async def create_ace(
         request: Request,
         ace_create: schemas.ACECreate,
-        current_user: schemas.User = Depends(get_current_active_user),
         rbac_repo: RbacRepository = Depends(get_repository(RbacRepository))
 ) -> schemas.ACE:
     """
     Create a new ACL entry.
+
+    Required privilege: ACE.Allocate
     """
 
     for route in request.app.routes:
@@ -84,13 +182,19 @@ async def create_ace(
     raise ControllerBadRequestError(f"Path '{ace_create.path}' doesn't match any existing endpoint")
 
 
-@router.get("/{ace_id}", response_model=schemas.ACE)
+@router.get(
+    "/{ace_id}",
+    response_model=schemas.ACE,
+    dependencies=[Depends(has_privilege("ACE.Audit"))]
+)
 async def get_ace(
         ace_id: UUID,
         rbac_repo: RbacRepository = Depends(get_repository(RbacRepository)),
 ) -> schemas.ACE:
     """
     Get an ACL entry.
+
+    Required privilege: ACE.Audit
     """
 
     ace = await rbac_repo.get_ace(ace_id)
@@ -99,7 +203,11 @@ async def get_ace(
     return ace
 
 
-@router.put("/{ace_id}", response_model=schemas.ACE)
+@router.put(
+    "/{ace_id}",
+    response_model=schemas.ACE,
+    dependencies=[Depends(has_privilege("ACE.Modify"))]
+)
 async def update_ace(
         ace_id: UUID,
         ace_update: schemas.ACEUpdate,
@@ -107,6 +215,8 @@ async def update_ace(
 ) -> schemas.ACE:
     """
     Update an ACL entry.
+
+    Required privilege: ACE.Modify
     """
 
     ace = await rbac_repo.get_ace(ace_id)
@@ -116,13 +226,19 @@ async def update_ace(
     return await rbac_repo.update_ace(ace_id, ace_update)
 
 
-@router.delete("/{ace_id}", status_code=status.HTTP_204_NO_CONTENT)
+@router.delete(
+    "/{ace_id}",
+    status_code=status.HTTP_204_NO_CONTENT,
+    dependencies=[Depends(has_privilege("ACE.Allocate"))]
+)
 async def delete_ace(
     ace_id: UUID,
     rbac_repo: RbacRepository = Depends(get_repository(RbacRepository)),
 ) -> None:
     """
     Delete an ACL entry.
+
+    Required privilege: ACE.Allocate
     """
 
     ace = await rbac_repo.get_ace(ace_id)
@@ -132,14 +248,3 @@ async def delete_ace(
     success = await rbac_repo.delete_ace(ace_id)
     if not success:
         raise ControllerNotFoundError(f"ACL entry '{ace_id}' could not be deleted")
-
-
-# @router.post("/prune", status_code=status.HTTP_204_NO_CONTENT)
-# async def prune_permissions(
-#         rbac_repo: RbacRepository = Depends(get_repository(RbacRepository))
-# ) -> None:
-#     """
-#     Prune orphaned permissions.
-#     """
-#
-#     await rbac_repo.prune_permissions()
diff --git a/gns3server/api/routes/controller/appliances.py b/gns3server/api/routes/controller/appliances.py
index 918cca5e..93db5b20 100644
--- a/gns3server/api/routes/controller/appliances.py
+++ b/gns3server/api/routes/controller/appliances.py
@@ -20,7 +20,7 @@ API routes for appliances.
 
 import logging
 
-from fastapi import APIRouter, Depends, Response, status
+from fastapi import APIRouter, Depends, status
 from typing import Optional, List
 from uuid import UUID
 
@@ -38,19 +38,28 @@ from gns3server.db.repositories.rbac import RbacRepository
 
 from .dependencies.authentication import get_current_active_user
 from .dependencies.database import get_repository
+from .dependencies.rbac import has_privilege
+
 
 log = logging.getLogger(__name__)
 
 router = APIRouter()
 
 
-@router.get("", response_model=List[schemas.Appliance], response_model_exclude_unset=True)
+@router.get(
+    "",
+    response_model=List[schemas.Appliance],
+    response_model_exclude_unset=True,
+    dependencies=[Depends(has_privilege("Appliance.Audit"))]
+)
 async def get_appliances(
         update: Optional[bool] = False,
         symbol_theme: Optional[str] = None
 ) -> List[schemas.Appliance]:
     """
     Return all appliances known by the controller.
+
+    Required privilege: Appliance.Audit
     """
 
     controller = Controller.instance()
@@ -60,10 +69,17 @@ async def get_appliances(
     return [c.asdict() for c in controller.appliance_manager.appliances.values()]
 
 
-@router.get("/{appliance_id}", response_model=schemas.Appliance, response_model_exclude_unset=True)
+@router.get(
+    "/{appliance_id}",
+    response_model=schemas.Appliance,
+    response_model_exclude_unset=True,
+    dependencies=[Depends(has_privilege("Appliance.Audit"))]
+)
 def get_appliance(appliance_id: UUID) -> schemas.Appliance:
     """
     Get an appliance file.
+
+    Required privilege: Appliance.Audit
     """
 
     controller = Controller.instance()
@@ -73,10 +89,16 @@ def get_appliance(appliance_id: UUID) -> schemas.Appliance:
     return appliance.asdict()
 
 
-@router.post("/{appliance_id}/version", status_code=status.HTTP_201_CREATED)
+@router.post(
+    "/{appliance_id}/version",
+    status_code=status.HTTP_201_CREATED,
+    dependencies=[Depends(has_privilege("Appliance.Allocate"))]
+)
 def add_appliance_version(appliance_id: UUID, appliance_version: schemas.ApplianceVersion) -> dict:
     """
-    Add a version to an appliance
+    Add a version to an appliance.
+
+    Required privilege: Appliance.Allocate
     """
 
     controller = Controller.instance()
@@ -98,7 +120,11 @@ def add_appliance_version(appliance_id: UUID, appliance_version: schemas.Applian
     return appliance.asdict()
 
 
-@router.post("/{appliance_id}/install", status_code=status.HTTP_204_NO_CONTENT)
+@router.post(
+    "/{appliance_id}/install",
+    status_code=status.HTTP_204_NO_CONTENT,
+    dependencies=[Depends(has_privilege("Appliance.Allocate"))]
+)
 async def install_appliance(
         appliance_id: UUID,
         version: Optional[str] = None,
@@ -109,6 +135,8 @@ async def install_appliance(
 ) -> None:
     """
     Install an appliance.
+
+    Required privilege: Appliance.Allocate
     """
 
     controller = Controller.instance()
diff --git a/gns3server/api/routes/controller/computes.py b/gns3server/api/routes/controller/computes.py
index 2fc9e55e..e9619d0b 100644
--- a/gns3server/api/routes/controller/computes.py
+++ b/gns3server/api/routes/controller/computes.py
@@ -24,10 +24,12 @@ from uuid import UUID
 
 from gns3server.controller import Controller
 from gns3server.db.repositories.computes import ComputesRepository
+from gns3server.db.repositories.rbac import RbacRepository
 from gns3server.services.computes import ComputesService
 from gns3server import schemas
 
 from .dependencies.database import get_repository
+from .dependencies.rbac import has_privilege
 
 responses = {404: {"model": schemas.ErrorMessage, "description": "Compute not found"}}
 
@@ -43,6 +45,7 @@ router = APIRouter(responses=responses)
         409: {"model": schemas.ErrorMessage, "description": "Could not create compute"},
         401: {"model": schemas.ErrorMessage, "description": "Invalid authentication for compute"},
     },
+    dependencies=[Depends(has_privilege("Compute.Allocate"))]
 )
 async def create_compute(
     compute_create: schemas.ComputeCreate,
@@ -51,15 +54,23 @@ async def create_compute(
 ) -> schemas.Compute:
     """
     Create a new compute on the controller.
+
+    Required privilege: Compute.Allocate
     """
 
     return await ComputesService(computes_repo).create_compute(compute_create, connect)
 
 
-@router.post("/{compute_id}/connect", status_code=status.HTTP_204_NO_CONTENT)
+@router.post(
+    "/{compute_id}/connect",
+    status_code=status.HTTP_204_NO_CONTENT,
+    dependencies=[Depends(has_privilege("Compute.Audit"))]
+)
 async def connect_compute(compute_id: Union[str, UUID]) -> None:
     """
     Connect to compute on the controller.
+
+    Required privilege: Compute.Audit
     """
 
     compute = Controller.instance().get_compute(str(compute_id))
@@ -67,29 +78,48 @@ async def connect_compute(compute_id: Union[str, UUID]) -> None:
         await compute.connect(report_failed_connection=True)
 
 
-@router.get("/{compute_id}", response_model=schemas.Compute, response_model_exclude_unset=True)
+@router.get(
+    "/{compute_id}",
+    response_model=schemas.Compute,
+    response_model_exclude_unset=True,
+    dependencies=[Depends(has_privilege("Compute.Audit"))]
+)
 async def get_compute(
     compute_id: Union[str, UUID], computes_repo: ComputesRepository = Depends(get_repository(ComputesRepository))
 ) -> schemas.Compute:
     """
     Return a compute from the controller.
+
+    Required privilege: Compute.Audit
     """
 
     return await ComputesService(computes_repo).get_compute(compute_id)
 
 
-@router.get("", response_model=List[schemas.Compute], response_model_exclude_unset=True)
+@router.get(
+    "",
+    response_model=List[schemas.Compute],
+    response_model_exclude_unset=True,
+    dependencies=[Depends(has_privilege("Compute.Audit"))]
+)
 async def get_computes(
     computes_repo: ComputesRepository = Depends(get_repository(ComputesRepository)),
 ) -> List[schemas.Compute]:
     """
     Return all computes known by the controller.
+
+    Required privilege: Compute.Audit
     """
 
     return await ComputesService(computes_repo).get_computes()
 
 
-@router.put("/{compute_id}", response_model=schemas.Compute, response_model_exclude_unset=True)
+@router.put(
+    "/{compute_id}",
+    response_model=schemas.Compute,
+    response_model_exclude_unset=True,
+    dependencies=[Depends(has_privilege("Compute.Modify"))]
+)
 async def update_compute(
     compute_id: Union[str, UUID],
     compute_update: schemas.ComputeUpdate,
@@ -97,20 +127,31 @@ async def update_compute(
 ) -> schemas.Compute:
     """
     Update a compute on the controller.
+
+    Required privilege: Compute.Modify
     """
 
     return await ComputesService(computes_repo).update_compute(compute_id, compute_update)
 
 
-@router.delete("/{compute_id}", status_code=status.HTTP_204_NO_CONTENT)
+@router.delete(
+    "/{compute_id}",
+    status_code=status.HTTP_204_NO_CONTENT,
+    dependencies=[Depends(has_privilege("Compute.Allocate"))]
+)
 async def delete_compute(
-    compute_id: Union[str, UUID], computes_repo: ComputesRepository = Depends(get_repository(ComputesRepository))
+        compute_id: Union[str, UUID],
+        computes_repo: ComputesRepository = Depends(get_repository(ComputesRepository)),
+        rbac_repo: RbacRepository = Depends(get_repository(RbacRepository)),
 ) -> None:
     """
     Delete a compute from the controller.
+
+    Required privilege: Compute.Allocate
     """
 
     await ComputesService(computes_repo).delete_compute(compute_id)
+    await rbac_repo.delete_all_ace_starting_with_path(f"/computes/{compute_id}")
 
 
 @router.get("/{compute_id}/docker/images", response_model=List[schemas.ComputeDockerImage])
diff --git a/gns3server/api/routes/controller/dependencies/rbac.py b/gns3server/api/routes/controller/dependencies/rbac.py
index fd6e2dda..e6795348 100644
--- a/gns3server/api/routes/controller/dependencies/rbac.py
+++ b/gns3server/api/routes/controller/dependencies/rbac.py
@@ -37,10 +37,10 @@ def has_privilege(
     ):
         if not current_user.is_superadmin:
             path = re.sub(r"^/v[0-9]", "", request.url.path)  # remove the prefix (e.g. "/v3") from URL path
-            print(f"Checking user {current_user.username} has privilege {privilege_name} on '{path}'")
+            log.debug(f"Checking user {current_user.username} has privilege {privilege_name} on '{path}'")
             if not await rbac_repo.check_user_has_privilege(current_user.user_id, path, privilege_name):
                 raise HTTPException(status_code=403, detail=f"Permission denied (privilege {privilege_name} is required)")
-            return current_user
+        return current_user
     return get_user_and_check_privilege
 
 
@@ -57,7 +57,7 @@ def has_privilege_on_websocket(
             log.debug(f"Checking user {current_user.username} has privilege {privilege_name} on '{path}'")
             if not await rbac_repo.check_user_has_privilege(current_user.user_id, path, privilege_name):
                 raise HTTPException(status_code=403, detail=f"Permission denied (privilege {privilege_name} is required)")
-            return current_user
+        return current_user
     return get_user_and_check_privilege
 
 # class PrivilegeChecker:
diff --git a/gns3server/api/routes/controller/drawings.py b/gns3server/api/routes/controller/drawings.py
index 18b0f80a..22ca1b95 100644
--- a/gns3server/api/routes/controller/drawings.py
+++ b/gns3server/api/routes/controller/drawings.py
@@ -18,33 +18,51 @@
 API routes for drawings.
 """
 
-from fastapi import APIRouter, Response, status
+from fastapi import APIRouter, Depends, status
 from fastapi.encoders import jsonable_encoder
 from typing import List
 from uuid import UUID
 
 from gns3server.controller import Controller
+from gns3server.db.repositories.rbac import RbacRepository
 from gns3server import schemas
 
+from .dependencies.database import get_repository
+from .dependencies.rbac import has_privilege
+
 responses = {404: {"model": schemas.ErrorMessage, "description": "Project or drawing not found"}}
 
 router = APIRouter(responses=responses)
 
 
-@router.get("", response_model=List[schemas.Drawing], response_model_exclude_unset=True)
+@router.get(
+    "",
+    response_model=List[schemas.Drawing],
+    response_model_exclude_unset=True,
+    dependencies=[Depends(has_privilege("Drawing.Audit"))]
+)
 async def get_drawings(project_id: UUID) -> List[schemas.Drawing]:
     """
     Return the list of all drawings for a given project.
+
+    Required privilege: Drawing.Audit
     """
 
     project = await Controller.instance().get_loaded_project(str(project_id))
     return [v.asdict() for v in project.drawings.values()]
 
 
-@router.post("", status_code=status.HTTP_201_CREATED, response_model=schemas.Drawing)
+@router.post(
+    "",
+    status_code=status.HTTP_201_CREATED,
+    response_model=schemas.Drawing,
+    dependencies=[Depends(has_privilege("Drawing.Allocate"))]
+)
 async def create_drawing(project_id: UUID, drawing_data: schemas.Drawing) -> schemas.Drawing:
     """
     Create a new drawing.
+
+    Required privilege: Drawing.Allocate
     """
 
     project = await Controller.instance().get_loaded_project(str(project_id))
@@ -52,10 +70,17 @@ async def create_drawing(project_id: UUID, drawing_data: schemas.Drawing) -> sch
     return drawing.asdict()
 
 
-@router.get("/{drawing_id}", response_model=schemas.Drawing, response_model_exclude_unset=True)
+@router.get(
+    "/{drawing_id}",
+    response_model=schemas.Drawing,
+    response_model_exclude_unset=True,
+    dependencies=[Depends(has_privilege("Drawing.Audit"))]
+)
 async def get_drawing(project_id: UUID, drawing_id: UUID) -> schemas.Drawing:
     """
     Return a drawing.
+
+    Required privilege: Drawing.Audit
     """
 
     project = await Controller.instance().get_loaded_project(str(project_id))
@@ -63,10 +88,17 @@ async def get_drawing(project_id: UUID, drawing_id: UUID) -> schemas.Drawing:
     return drawing.asdict()
 
 
-@router.put("/{drawing_id}", response_model=schemas.Drawing, response_model_exclude_unset=True)
+@router.put(
+    "/{drawing_id}",
+    response_model=schemas.Drawing,
+    response_model_exclude_unset=True,
+    dependencies=[Depends(has_privilege("Drawing.Modify"))]
+)
 async def update_drawing(project_id: UUID, drawing_id: UUID, drawing_data: schemas.Drawing) -> schemas.Drawing:
     """
     Update a drawing.
+
+    Required privilege: Drawing.Modify
     """
 
     project = await Controller.instance().get_loaded_project(str(project_id))
@@ -75,11 +107,22 @@ async def update_drawing(project_id: UUID, drawing_id: UUID, drawing_data: schem
     return drawing.asdict()
 
 
-@router.delete("/{drawing_id}", status_code=status.HTTP_204_NO_CONTENT)
-async def delete_drawing(project_id: UUID, drawing_id: UUID) -> None:
+@router.delete(
+    "/{drawing_id}",
+    status_code=status.HTTP_204_NO_CONTENT,
+    dependencies=[Depends(has_privilege("Drawing.Allocate"))]
+)
+async def delete_drawing(
+        project_id: UUID,
+        drawing_id: UUID,
+        rbac_repo: RbacRepository = Depends(get_repository(RbacRepository))
+) -> None:
     """
     Delete a drawing.
+
+    Required privilege: Drawing.Allocate
     """
 
     project = await Controller.instance().get_loaded_project(str(project_id))
     await project.delete_drawing(str(drawing_id))
+    await rbac_repo.delete_all_ace_starting_with_path(f"/drawings/{drawing_id}")
diff --git a/gns3server/api/routes/controller/groups.py b/gns3server/api/routes/controller/groups.py
index 9665ab3a..d4652353 100644
--- a/gns3server/api/routes/controller/groups.py
+++ b/gns3server/api/routes/controller/groups.py
@@ -19,7 +19,7 @@
 API routes for user groups.
 """
 
-from fastapi import APIRouter, Depends, Response, status
+from fastapi import APIRouter, Depends, status
 from uuid import UUID
 from typing import List
 
@@ -33,6 +33,8 @@ from gns3server.controller.controller_error import (
 
 from gns3server.db.repositories.users import UsersRepository
 from gns3server.db.repositories.rbac import RbacRepository
+
+from .dependencies.rbac import has_privilege
 from .dependencies.database import get_repository
 
 import logging
@@ -42,12 +44,18 @@ log = logging.getLogger(__name__)
 router = APIRouter()
 
 
-@router.get("", response_model=List[schemas.UserGroup])
+@router.get(
+    "",
+    response_model=List[schemas.UserGroup],
+    dependencies=[Depends(has_privilege("Group.Audit"))]
+)
 async def get_user_groups(
         users_repo: UsersRepository = Depends(get_repository(UsersRepository))
 ) -> List[schemas.UserGroup]:
     """
     Get all user groups.
+
+    Required privilege: Group.Audit
     """
 
     return await users_repo.get_user_groups()
@@ -56,7 +64,8 @@ async def get_user_groups(
 @router.post(
     "",
     response_model=schemas.UserGroup,
-    status_code=status.HTTP_201_CREATED
+    status_code=status.HTTP_201_CREATED,
+    dependencies=[Depends(has_privilege("Group.Allocate"))]
 )
 async def create_user_group(
         user_group_create: schemas.UserGroupCreate,
@@ -64,6 +73,8 @@ async def create_user_group(
 ) -> schemas.UserGroup:
     """
     Create a new user group.
+
+    Required privilege: Group.Allocate
     """
 
     if await users_repo.get_user_group_by_name(user_group_create.name):
@@ -72,13 +83,19 @@ async def create_user_group(
     return await users_repo.create_user_group(user_group_create)
 
 
-@router.get("/{user_group_id}", response_model=schemas.UserGroup)
+@router.get(
+    "/{user_group_id}",
+    response_model=schemas.UserGroup,
+    dependencies=[Depends(has_privilege("Group.Audit"))]
+)
 async def get_user_group(
         user_group_id: UUID,
         users_repo: UsersRepository = Depends(get_repository(UsersRepository)),
 ) -> schemas.UserGroup:
     """
     Get a user group.
+
+    Required privilege: Group.Audit
     """
 
     user_group = await users_repo.get_user_group(user_group_id)
@@ -87,7 +104,11 @@ async def get_user_group(
     return user_group
 
 
-@router.put("/{user_group_id}", response_model=schemas.UserGroup)
+@router.put(
+    "/{user_group_id}",
+    response_model=schemas.UserGroup,
+    dependencies=[Depends(has_privilege("Group.Modify"))]
+)
 async def update_user_group(
         user_group_id: UUID,
         user_group_update: schemas.UserGroupUpdate,
@@ -95,6 +116,8 @@ async def update_user_group(
 ) -> schemas.UserGroup:
     """
     Update a user group.
+
+    Required privilege: Group.Modify
     """
     user_group = await users_repo.get_user_group(user_group_id)
     if not user_group:
@@ -108,14 +131,18 @@ async def update_user_group(
 
 @router.delete(
     "/{user_group_id}",
-    status_code=status.HTTP_204_NO_CONTENT
+    status_code=status.HTTP_204_NO_CONTENT,
+    dependencies=[Depends(has_privilege("Group.Allocate"))]
 )
 async def delete_user_group(
-    user_group_id: UUID,
-    users_repo: UsersRepository = Depends(get_repository(UsersRepository)),
+        user_group_id: UUID,
+        users_repo: UsersRepository = Depends(get_repository(UsersRepository)),
+        rbac_repo: RbacRepository = Depends(get_repository(RbacRepository))
 ) -> None:
     """
-    Delete a user group
+    Delete a user group.
+
+    Required privilege: Group.Allocate
     """
 
     user_group = await users_repo.get_user_group(user_group_id)
@@ -128,15 +155,22 @@ async def delete_user_group(
     success = await users_repo.delete_user_group(user_group_id)
     if not success:
         raise ControllerError(f"User group '{user_group_id}' could not be deleted")
+    await rbac_repo.delete_all_ace_starting_with_path(f"/groups/{user_group_id}")
 
 
-@router.get("/{user_group_id}/members", response_model=List[schemas.User])
+@router.get(
+    "/{user_group_id}/members",
+    response_model=List[schemas.User],
+    dependencies=[Depends(has_privilege("Group.Audit"))]
+)
 async def get_user_group_members(
         user_group_id: UUID,
         users_repo: UsersRepository = Depends(get_repository(UsersRepository))
 ) -> List[schemas.User]:
     """
     Get all user group members.
+
+    Required privilege: Group.Audit
     """
 
     return await users_repo.get_user_group_members(user_group_id)
@@ -144,7 +178,8 @@ async def get_user_group_members(
 
 @router.put(
     "/{user_group_id}/members/{user_id}",
-    status_code=status.HTTP_204_NO_CONTENT
+    status_code=status.HTTP_204_NO_CONTENT,
+    dependencies=[Depends(has_privilege("Group.Modify"))]
 )
 async def add_member_to_group(
         user_group_id: UUID,
@@ -153,6 +188,8 @@ async def add_member_to_group(
 ) -> None:
     """
     Add member to a user group.
+
+    Required privilege: Group.Modify
     """
 
     user = await users_repo.get_user(user_id)
@@ -166,7 +203,8 @@ async def add_member_to_group(
 
 @router.delete(
     "/{user_group_id}/members/{user_id}",
-    status_code=status.HTTP_204_NO_CONTENT
+    status_code=status.HTTP_204_NO_CONTENT,
+    dependencies=[Depends(has_privilege("Group.Modify"))]
 )
 async def remove_member_from_group(
     user_group_id: UUID,
@@ -175,6 +213,8 @@ async def remove_member_from_group(
 ) -> None:
     """
     Remove member from a user group.
+
+    Required privilege: Group.Modify
     """
 
     user = await users_repo.get_user(user_id)
diff --git a/gns3server/api/routes/controller/images.py b/gns3server/api/routes/controller/images.py
index a37566fa..f4780db0 100644
--- a/gns3server/api/routes/controller/images.py
+++ b/gns3server/api/routes/controller/images.py
@@ -22,7 +22,7 @@ import os
 import logging
 import urllib.parse
 
-from fastapi import APIRouter, Request, Response, Depends, status
+from fastapi import APIRouter, Request, Depends, status
 from starlette.requests import ClientDisconnect
 from sqlalchemy.orm.exc import MultipleResultsFound
 from typing import List, Optional
@@ -43,25 +43,37 @@ from gns3server.controller.controller_error import (
 
 from .dependencies.authentication import get_current_active_user
 from .dependencies.database import get_repository
+from .dependencies.rbac import has_privilege
 
 log = logging.getLogger(__name__)
 
 router = APIRouter()
 
 
-@router.get("", response_model=List[schemas.Image])
+@router.get(
+    "",
+    response_model=List[schemas.Image],
+    dependencies=[Depends(has_privilege("Image.Audit"))]
+)
 async def get_images(
         images_repo: ImagesRepository = Depends(get_repository(ImagesRepository)),
         image_type: Optional[schemas.ImageType] = None
 ) -> List[schemas.Image]:
     """
     Return all images.
+
+    Required privilege: Image.Audit
     """
 
     return await images_repo.get_images(image_type)
 
 
-@router.post("/upload/{image_path:path}", response_model=schemas.Image, status_code=status.HTTP_201_CREATED)
+@router.post(
+    "/upload/{image_path:path}",
+    response_model=schemas.Image,
+    status_code=status.HTTP_201_CREATED,
+    dependencies=[Depends(has_privilege("Image.Allocate"))]
+)
 async def upload_image(
         image_path: str,
         request: Request,
@@ -76,6 +88,8 @@ async def upload_image(
 
     Example: curl -X POST http://host:port/v3/images/upload/my_image_name.qcow2 \
     -H 'Authorization: Bearer <token>' --data-binary @"/path/to/image.qcow2"
+
+    Required privilege: Image.Allocate
     """
 
     image_path = urllib.parse.unquote(image_path)
@@ -110,13 +124,19 @@ async def upload_image(
     return image
 
 
-@router.get("/{image_path:path}", response_model=schemas.Image)
+@router.get(
+    "/{image_path:path}",
+    response_model=schemas.Image,
+    dependencies=[Depends(has_privilege("Image.Audit"))]
+)
 async def get_image(
         image_path: str,
         images_repo: ImagesRepository = Depends(get_repository(ImagesRepository)),
 ) -> schemas.Image:
     """
     Return an image.
+
+    Required privilege: Image.Audit
     """
 
     image_path = urllib.parse.unquote(image_path)
@@ -126,13 +146,19 @@ async def get_image(
     return image
 
 
-@router.delete("/{image_path:path}", status_code=status.HTTP_204_NO_CONTENT)
+@router.delete(
+    "/{image_path:path}",
+    status_code=status.HTTP_204_NO_CONTENT,
+    dependencies=[Depends(has_privilege("Image.Allocate"))]
+)
 async def delete_image(
         image_path: str,
         images_repo: ImagesRepository = Depends(get_repository(ImagesRepository)),
 ) -> None:
     """
     Delete an image.
+
+    Required privilege: Image.Allocate
     """
 
     image_path = urllib.parse.unquote(image_path)
@@ -161,12 +187,18 @@ async def delete_image(
         raise ControllerError(f"Image '{image_path}' could not be deleted")
 
 
-@router.post("/prune", status_code=status.HTTP_204_NO_CONTENT)
+@router.post(
+    "/prune",
+    status_code=status.HTTP_204_NO_CONTENT,
+    dependencies=[Depends(has_privilege("Image.Allocate"))]
+)
 async def prune_images(
         images_repo: ImagesRepository = Depends(get_repository(ImagesRepository)),
 ) -> None:
     """
     Prune images not attached to any template.
+
+    Required privilege: Image.Allocate
     """
 
     await images_repo.prune_images()
diff --git a/gns3server/api/routes/controller/links.py b/gns3server/api/routes/controller/links.py
index c228751f..28743b75 100644
--- a/gns3server/api/routes/controller/links.py
+++ b/gns3server/api/routes/controller/links.py
@@ -1,5 +1,5 @@
 #
-# Copyright (C) 2016 GNS3 Technologies Inc.
+# Copyright (C) 2023 GNS3 Technologies Inc.
 #
 # This program is free software: you can redistribute it and/or modify
 # it under the terms of the GNU General Public License as published by
@@ -21,7 +21,7 @@ API routes for links.
 import multidict
 import aiohttp
 
-from fastapi import APIRouter, Depends, Request, Response, status
+from fastapi import APIRouter, Depends, Request, status
 from fastapi.responses import StreamingResponse
 from fastapi.encoders import jsonable_encoder
 from typing import List
@@ -29,10 +29,14 @@ from uuid import UUID
 
 from gns3server.controller import Controller
 from gns3server.controller.controller_error import ControllerError
+from gns3server.db.repositories.rbac import RbacRepository
 from gns3server.controller.link import Link
 from gns3server.utils.http_client import HTTPClient
 from gns3server import schemas
 
+from .dependencies.database import get_repository
+from .dependencies.rbac import has_privilege
+
 import logging
 
 log = logging.getLogger(__name__)
@@ -52,10 +56,17 @@ async def dep_link(project_id: UUID, link_id: UUID) -> Link:
     return link
 
 
-@router.get("", response_model=List[schemas.Link], response_model_exclude_unset=True)
+@router.get(
+    "",
+    response_model=List[schemas.Link],
+    response_model_exclude_unset=True,
+    dependencies=[Depends(has_privilege("Link.Audit"))]
+)
 async def get_links(project_id: UUID) -> List[schemas.Link]:
     """
     Return all links for a given project.
+
+    Required privilege: Link.Audit
     """
 
     project = await Controller.instance().get_loaded_project(str(project_id))
@@ -70,10 +81,13 @@ async def get_links(project_id: UUID) -> List[schemas.Link]:
         404: {"model": schemas.ErrorMessage, "description": "Could not find project"},
         409: {"model": schemas.ErrorMessage, "description": "Could not create link"},
     },
+    dependencies=[Depends(has_privilege("Link.Allocate"))]
 )
 async def create_link(project_id: UUID, link_data: schemas.LinkCreate) -> schemas.Link:
     """
     Create a new link.
+
+    Required privilege: Link.Allocate
     """
 
     project = await Controller.instance().get_loaded_project(str(project_id))
@@ -99,28 +113,47 @@ async def create_link(project_id: UUID, link_data: schemas.LinkCreate) -> schema
     return link.asdict()
 
 
-@router.get("/{link_id}/available_filters")
+@router.get(
+    "/{link_id}/available_filters",
+    dependencies=[Depends(has_privilege("Link.Audit"))]
+)
 async def get_filters(link: Link = Depends(dep_link)) -> List[dict]:
     """
     Return all filters available for a given link.
+
+    Required privilege: Link.Audit
     """
 
     return link.available_filters()
 
 
-@router.get("/{link_id}", response_model=schemas.Link, response_model_exclude_unset=True)
+@router.get(
+    "/{link_id}",
+    response_model=schemas.Link,
+    response_model_exclude_unset=True,
+    dependencies=[Depends(has_privilege("Link.Audit"))]
+)
 async def get_link(link: Link = Depends(dep_link)) -> schemas.Link:
     """
     Return a link.
+
+    Required privilege: Link.Audit
     """
 
     return link.asdict()
 
 
-@router.put("/{link_id}", response_model=schemas.Link, response_model_exclude_unset=True)
+@router.put(
+    "/{link_id}",
+    response_model=schemas.Link,
+    response_model_exclude_unset=True,
+    dependencies=[Depends(has_privilege("Link.Modify"))]
+)
 async def update_link(link_data: schemas.LinkUpdate, link: Link = Depends(dep_link)) -> schemas.Link:
     """
     Update a link.
+
+    Required privilege: Link.Modify
     """
 
     link_data = jsonable_encoder(link_data, exclude_unset=True)
@@ -135,30 +168,54 @@ async def update_link(link_data: schemas.LinkUpdate, link: Link = Depends(dep_li
     return link.asdict()
 
 
-@router.delete("/{link_id}", status_code=status.HTTP_204_NO_CONTENT)
-async def delete_link(project_id: UUID, link: Link = Depends(dep_link)) -> None:
+@router.delete(
+    "/{link_id}",
+    status_code=status.HTTP_204_NO_CONTENT,
+    dependencies=[Depends(has_privilege("Link.Allocate"))]
+)
+async def delete_link(
+        project_id: UUID,
+        link: Link = Depends(dep_link),
+        rbac_repo: RbacRepository = Depends(get_repository(RbacRepository))
+) -> None:
     """
     Delete a link.
+
+    Required privilege: Link.Allocate
     """
 
     project = await Controller.instance().get_loaded_project(str(project_id))
     await project.delete_link(link.id)
+    await rbac_repo.delete_all_ace_starting_with_path(f"/links/{link.id}")
 
 
-@router.post("/{link_id}/reset", response_model=schemas.Link)
+@router.post(
+    "/{link_id}/reset",
+    response_model=schemas.Link,
+    dependencies=[Depends(has_privilege("Link.Modify"))]
+)
 async def reset_link(link: Link = Depends(dep_link)) -> schemas.Link:
     """
     Reset a link.
+
+    Required privilege: Link.Modify
     """
 
     await link.reset()
     return link.asdict()
 
 
-@router.post("/{link_id}/capture/start", status_code=status.HTTP_201_CREATED, response_model=schemas.Link)
+@router.post(
+    "/{link_id}/capture/start",
+    status_code=status.HTTP_201_CREATED,
+    response_model=schemas.Link,
+    dependencies=[Depends(has_privilege("Link.Capture"))]
+)
 async def start_capture(capture_data: dict, link: Link = Depends(dep_link)) -> schemas.Link:
     """
     Start packet capture on the link.
+
+    Required privilege: Link.Capture
     """
 
     await link.start_capture(
@@ -168,19 +225,30 @@ async def start_capture(capture_data: dict, link: Link = Depends(dep_link)) -> s
     return link.asdict()
 
 
-@router.post("/{link_id}/capture/stop", status_code=status.HTTP_204_NO_CONTENT)
+@router.post(
+    "/{link_id}/capture/stop",
+    status_code=status.HTTP_204_NO_CONTENT,
+    dependencies=[Depends(has_privilege("Link.Capture"))]
+)
 async def stop_capture(link: Link = Depends(dep_link)) -> None:
     """
     Stop packet capture on the link.
+
+    Required privilege: Link.Capture
     """
 
     await link.stop_capture()
 
 
-@router.get("/{link_id}/capture/stream")
+@router.get(
+    "/{link_id}/capture/stream",
+    dependencies=[Depends(has_privilege("Link.Capture"))]
+)
 async def stream_pcap(request: Request, link: Link = Depends(dep_link)) -> StreamingResponse:
     """
     Stream the PCAP capture file from compute.
+
+    Required privilege: Link.Capture
     """
 
     if not link.capturing:
diff --git a/gns3server/api/routes/controller/nodes.py b/gns3server/api/routes/controller/nodes.py
index 20ce2479..cc93c0c1 100644
--- a/gns3server/api/routes/controller/nodes.py
+++ b/gns3server/api/routes/controller/nodes.py
@@ -34,8 +34,12 @@ from gns3server.controller.project import Project
 from gns3server.utils import force_unix_path
 from gns3server.utils.http_client import HTTPClient
 from gns3server.controller.controller_error import ControllerForbiddenError, ControllerBadRequestError
+from gns3server.db.repositories.rbac import RbacRepository
 from gns3server import schemas
 
+from .dependencies.database import get_repository
+from .dependencies.rbac import has_privilege, has_privilege_on_websocket
+
 import logging
 
 log = logging.getLogger(__name__)
@@ -108,10 +112,13 @@ async def dep_node(node_id: UUID, project: Project = Depends(dep_project)) -> No
         404: {"model": schemas.ErrorMessage, "description": "Could not find project"},
         409: {"model": schemas.ErrorMessage, "description": "Could not create node"},
     },
+    dependencies=[Depends(has_privilege("Node.Allocate"))]
 )
 async def create_node(node_data: schemas.NodeCreate, project: Project = Depends(dep_project)) -> schemas.Node:
     """
     Create a new node.
+
+    Required privilege: Node.Allocate
     """
 
     controller = Controller.instance()
@@ -121,65 +128,89 @@ async def create_node(node_data: schemas.NodeCreate, project: Project = Depends(
     return node.asdict()
 
 
-@router.get("", response_model=List[schemas.Node], response_model_exclude_unset=True)
-async def get_nodes(project: Project = Depends(dep_project)) -> List[schemas.Node]:
+@router.get(
+    "",
+    response_model=List[schemas.Node],
+    response_model_exclude_unset=True,
+    dependencies=[Depends(has_privilege("Node.Audit"))]
+)
+def get_nodes(project: Project = Depends(dep_project)) -> List[schemas.Node]:
     """
     Return all nodes belonging to a given project.
+
+    Required privilege: Node.Audit
     """
 
     return [v.asdict() for v in project.nodes.values()]
 
 
-@router.post("/start", status_code=status.HTTP_204_NO_CONTENT)
+@router.post("/start", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(has_privilege("Node."))])
 async def start_all_nodes(project: Project = Depends(dep_project)) -> None:
     """
     Start all nodes belonging to a given project.
+
+    Required privilege: Node.PowerMgmt
     """
 
     await project.start_all()
 
 
-@router.post("/stop", status_code=status.HTTP_204_NO_CONTENT)
+@router.post("/stop", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(has_privilege("Node.PowerMgmt"))])
 async def stop_all_nodes(project: Project = Depends(dep_project)) -> None:
     """
     Stop all nodes belonging to a given project.
+
+    Required privilege: Node.PowerMgmt
     """
 
     await project.stop_all()
 
 
-@router.post("/suspend", status_code=status.HTTP_204_NO_CONTENT)
+@router.post("/suspend", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(has_privilege("Node.PowerMgmt"))])
 async def suspend_all_nodes(project: Project = Depends(dep_project)) -> None:
     """
     Suspend all nodes belonging to a given project.
+
+    Required privilege: Node.PowerMgmt
     """
 
     await project.suspend_all()
 
 
-@router.post("/reload", status_code=status.HTTP_204_NO_CONTENT)
+@router.post("/reload", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(has_privilege("Node.PowerMgmt"))])
 async def reload_all_nodes(project: Project = Depends(dep_project)) -> None:
     """
     Reload all nodes belonging to a given project.
+
+    Required privilege: Node.PowerMgmt
     """
 
     await project.stop_all()
     await project.start_all()
 
 
-@router.get("/{node_id}", response_model=schemas.Node)
+@router.get("/{node_id}", response_model=schemas.Node, dependencies=[Depends(has_privilege("Node.Audit"))])
 def get_node(node: Node = Depends(dep_node)) -> schemas.Node:
     """
     Return a node from a given project.
+
+    Required privilege: Node.Audit
     """
 
     return node.asdict()
 
 
-@router.put("/{node_id}", response_model=schemas.Node, response_model_exclude_unset=True)
+@router.put(
+    "/{node_id}",
+    response_model=schemas.Node,
+    response_model_exclude_unset=True,
+    dependencies=[Depends(has_privilege("Node.Modify"))]
+)
 async def update_node(node_data: schemas.NodeUpdate, node: Node = Depends(dep_node)) -> schemas.Node:
     """
     Update a node.
+
+    Required privilege: Node.Modify
     """
 
     node_data = jsonable_encoder(node_data, exclude_unset=True)
@@ -197,85 +228,142 @@ async def update_node(node_data: schemas.NodeUpdate, node: Node = Depends(dep_no
     "/{node_id}",
     status_code=status.HTTP_204_NO_CONTENT,
     responses={**responses, 409: {"model": schemas.ErrorMessage, "description": "Cannot delete node"}},
+    dependencies=[Depends(has_privilege("Node.Allocate"))]
 )
-async def delete_node(node_id: UUID, project: Project = Depends(dep_project)) -> None:
+async def delete_node(
+        node_id: UUID, project: Project = Depends(dep_project),
+        rbac_repo: RbacRepository = Depends(get_repository(RbacRepository)),
+) -> None:
     """
     Delete a node from a project.
+
+    Required privilege: Node.Allocate
     """
 
     await project.delete_node(str(node_id))
+    await rbac_repo.delete_all_ace_starting_with_path(f"/projects/{project.id}/nodes/{node_id}")
 
 
-@router.post("/{node_id}/duplicate", response_model=schemas.Node, status_code=status.HTTP_201_CREATED)
+@router.post(
+    "/{node_id}/duplicate",
+    response_model=schemas.Node,
+    status_code=status.HTTP_201_CREATED,
+    dependencies=[Depends(has_privilege("Node.Allocate"))]
+)
 async def duplicate_node(duplicate_data: schemas.NodeDuplicate, node: Node = Depends(dep_node)) -> schemas.Node:
     """
     Duplicate a node.
+
+    Required privilege: Node.Allocate
     """
 
     new_node = await node.project.duplicate_node(node, duplicate_data.x, duplicate_data.y, duplicate_data.z)
     return new_node.asdict()
 
 
-@router.post("/{node_id}/start", status_code=status.HTTP_204_NO_CONTENT)
+@router.post(
+    "/{node_id}/start",
+    status_code=status.HTTP_204_NO_CONTENT,
+    dependencies=[Depends(has_privilege("Node.PowerMgmt"))]
+)
 async def start_node(start_data: dict, node: Node = Depends(dep_node)) -> None:
     """
     Start a node.
+
+    Required privilege: Node.PowerMgmt
     """
 
     await node.start(data=start_data)
 
 
-@router.post("/{node_id}/stop", status_code=status.HTTP_204_NO_CONTENT)
+@router.post(
+    "/{node_id}/stop",
+    status_code=status.HTTP_204_NO_CONTENT,
+    dependencies=[Depends(has_privilege("Node.PowerMgmt"))]
+)
 async def stop_node(node: Node = Depends(dep_node)) -> None:
     """
     Stop a node.
+
+    Required privilege: Node.PowerMgmt
     """
 
     await node.stop()
 
 
-@router.post("/{node_id}/suspend", status_code=status.HTTP_204_NO_CONTENT)
+@router.post(
+    "/{node_id}/suspend",
+    status_code=status.HTTP_204_NO_CONTENT,
+    dependencies=[Depends(has_privilege("Node.PowerMgmt"))]
+)
 async def suspend_node(node: Node = Depends(dep_node)) -> None:
     """
     Suspend a node.
+
+    Required privilege: Node.PowerMgmt
     """
 
     await node.suspend()
 
 
-@router.post("/{node_id}/reload", status_code=status.HTTP_204_NO_CONTENT)
+@router.post(
+    "/{node_id}/reload",
+    status_code=status.HTTP_204_NO_CONTENT,
+    dependencies=[Depends(has_privilege("Node.PowerMgmt"))]
+)
 async def reload_node(node: Node = Depends(dep_node)) -> None:
     """
     Reload a node.
+
+    Required privilege: Node.PowerMgmt
     """
 
     await node.reload()
 
 
-@router.post("/{node_id}/isolate", status_code=status.HTTP_204_NO_CONTENT)
+@router.post(
+    "/{node_id}/isolate",
+    status_code=status.HTTP_204_NO_CONTENT,
+    dependencies=[Depends(has_privilege("Link.Modify"))]
+)
 async def isolate_node(node: Node = Depends(dep_node)) -> None:
     """
     Isolate a node (suspend all attached links).
+
+    Required privilege: Link.Modify
     """
 
     for link in node.links:
         await link.update_suspend(True)
 
 
-@router.post("/{node_id}/unisolate", status_code=status.HTTP_204_NO_CONTENT)
+@router.post(
+    "/{node_id}/unisolate",
+    status_code=status.HTTP_204_NO_CONTENT,
+    dependencies=[Depends(has_privilege("Link.Modify"))]
+)
 async def unisolate_node(node: Node = Depends(dep_node)) -> None:
     """
     Un-isolate a node (resume all attached suspended links).
+
+    Required privilege: Link.Modify
     """
 
     for link in node.links:
         await link.update_suspend(False)
 
 
-@router.get("/{node_id}/links", response_model=List[schemas.Link], response_model_exclude_unset=True)
+@router.get(
+    "/{node_id}/links",
+    response_model=List[schemas.Link],
+    response_model_exclude_unset=True,
+    dependencies = [Depends(has_privilege("Link.Audit"))]
+)
 async def get_node_links(node: Node = Depends(dep_node)) -> List[schemas.Link]:
     """
     Return all the links connected to a node.
+
+    Required privilege: Link.Audit
     """
 
     links = []
@@ -284,10 +372,12 @@ async def get_node_links(node: Node = Depends(dep_node)) -> List[schemas.Link]:
     return links
 
 
-@router.get("/{node_id}/dynamips/auto_idlepc")
+@router.get("/{node_id}/dynamips/auto_idlepc", dependencies=[Depends(has_privilege("Node.Audit"))])
 async def auto_idlepc(node: Node = Depends(dep_node)) -> dict:
     """
     Compute an Idle-PC value for a Dynamips node
+
+    Required privilege: Node.Audit
     """
 
     if node.node_type != "dynamips":
@@ -295,10 +385,12 @@ async def auto_idlepc(node: Node = Depends(dep_node)) -> dict:
     return await node.dynamips_auto_idlepc()
 
 
-@router.get("/{node_id}/dynamips/idlepc_proposals")
+@router.get("/{node_id}/dynamips/idlepc_proposals", dependencies=[Depends(has_privilege("Node.Audit"))])
 async def idlepc_proposals(node: Node = Depends(dep_node)) -> List[str]:
     """
     Compute a list of potential idle-pc values for a Dynamips node
+
+    Required privilege: Node.Audit
     """
 
     if node.node_type != "dynamips":
@@ -306,7 +398,11 @@ async def idlepc_proposals(node: Node = Depends(dep_node)) -> List[str]:
     return await node.dynamips_idlepc_proposals()
 
 
-@router.post("/{node_id}/qemu/disk_image/{disk_name}", status_code=status.HTTP_204_NO_CONTENT)
+@router.post(
+    "/{node_id}/qemu/disk_image/{disk_name}",
+    status_code=status.HTTP_204_NO_CONTENT,
+    dependencies=[Depends(has_privilege("Node.Allocate"))]
+)
 async def create_disk_image(
         disk_name: str,
         disk_data: schemas.QemuDiskImageCreate,
@@ -314,6 +410,8 @@ async def create_disk_image(
 ) -> None:
     """
     Create a Qemu disk image.
+
+    Required privilege: Node.Allocate
     """
 
     if node.node_type != "qemu":
@@ -321,7 +419,11 @@ async def create_disk_image(
     await node.post(f"/disk_image/{disk_name}", data=disk_data.model_dump(exclude_unset=True))
 
 
-@router.put("/{node_id}/qemu/disk_image/{disk_name}", status_code=status.HTTP_204_NO_CONTENT)
+@router.put(
+    "/{node_id}/qemu/disk_image/{disk_name}",
+    status_code=status.HTTP_204_NO_CONTENT,
+    dependencies=[Depends(has_privilege("Node.Allocate"))]
+)
 async def update_disk_image(
         disk_name: str,
         disk_data: schemas.QemuDiskImageUpdate,
@@ -329,6 +431,8 @@ async def update_disk_image(
 ) -> None:
     """
     Update a Qemu disk image.
+
+    Required privilege: Node.Allocate
     """
 
     if node.node_type != "qemu":
@@ -336,13 +440,19 @@ async def update_disk_image(
     await node.put(f"/disk_image/{disk_name}", data=disk_data.model_dump(exclude_unset=True))
 
 
-@router.delete("/{node_id}/qemu/disk_image/{disk_name}", status_code=status.HTTP_204_NO_CONTENT)
+@router.delete(
+    "/{node_id}/qemu/disk_image/{disk_name}",
+    status_code=status.HTTP_204_NO_CONTENT,
+    dependencies=[Depends(has_privilege("Node.Allocate"))]
+)
 async def delete_disk_image(
         disk_name: str,
         node: Node = Depends(dep_node)
 ) -> None:
     """
     Delete a Qemu disk image.
+
+    Required privilege: Node.Allocate
     """
 
     if node.node_type != "qemu":
@@ -350,10 +460,12 @@ async def delete_disk_image(
     await node.delete(f"/disk_image/{disk_name}")
 
 
-@router.get("/{node_id}/files/{file_path:path}")
+@router.get("/{node_id}/files/{file_path:path}", dependencies=[Depends(has_privilege("Node.Audit"))])
 async def get_file(file_path: str, node: Node = Depends(dep_node)) -> Response:
     """
-    Return a file in the node directory
+    Return a file from the node directory.
+
+    Required privilege: Node.Audit
     """
 
     path = force_unix_path(file_path)
@@ -369,10 +481,16 @@ async def get_file(file_path: str, node: Node = Depends(dep_node)) -> Response:
     return Response(res.body, media_type="application/octet-stream", status_code=res.status)
 
 
-@router.post("/{node_id}/files/{file_path:path}", status_code=status.HTTP_201_CREATED)
+@router.post(
+    "/{node_id}/files/{file_path:path}",
+    status_code=status.HTTP_201_CREATED,
+    dependencies=[Depends(has_privilege("Node.Modify"))]
+)
 async def post_file(file_path: str, request: Request, node: Node = Depends(dep_node)):
     """
     Write a file in the node directory.
+
+    Required privilege: Node.Modify
     """
 
     path = force_unix_path(file_path)
@@ -389,10 +507,12 @@ async def post_file(file_path: str, request: Request, node: Node = Depends(dep_n
     # FIXME: response with correct status code (from compute)
 
 
-@router.websocket("/{node_id}/console/ws")
+@router.websocket("/{node_id}/console/ws", dependencies=[Depends(has_privilege_on_websocket("Node.Console"))])
 async def ws_console(websocket: WebSocket, node: Node = Depends(dep_node)) -> None:
     """
     WebSocket console.
+
+    Required privilege: Node.Console
     """
 
     compute = node.compute
@@ -447,16 +567,31 @@ async def ws_console(websocket: WebSocket, node: Node = Depends(dep_node)) -> No
         log.error(f"Client error received when forwarding to compute console WebSocket: {e}")
 
 
-@router.post("/console/reset", status_code=status.HTTP_204_NO_CONTENT)
+@router.post(
+    "/console/reset",
+    status_code=status.HTTP_204_NO_CONTENT,
+    dependencies=[Depends(has_privilege("Node.Console"))]
+)
 async def reset_console_all_nodes(project: Project = Depends(dep_project)) -> None:
     """
     Reset console for all nodes belonging to the project.
+
+    Required privilege: Node.Console
     """
 
     await project.reset_console_all()
 
 
-@router.post("/{node_id}/console/reset", status_code=status.HTTP_204_NO_CONTENT)
+@router.post(
+    "/{node_id}/console/reset",
+    status_code=status.HTTP_204_NO_CONTENT,
+    dependencies=[Depends(has_privilege("Node.Console"))]
+)
 async def console_reset(node: Node = Depends(dep_node)) -> None:
+    """
+    Reset a console for a given node.
+
+    Required privilege: Node.Console
+    """
 
     await node.post("/console/reset")
diff --git a/gns3server/api/routes/controller/projects.py b/gns3server/api/routes/controller/projects.py
index ca441d85..d228fcf7 100644
--- a/gns3server/api/routes/controller/projects.py
+++ b/gns3server/api/routes/controller/projects.py
@@ -45,11 +45,10 @@ from gns3server.controller.import_project import import_project as import_contro
 from gns3server.controller.export_project import export_project as export_controller_project
 from gns3server.utils.asyncio import aiozipstream
 from gns3server.utils.path import is_safe_path
-from gns3server.db.repositories.rbac import RbacRepository
 from gns3server.db.repositories.templates import TemplatesRepository
+from gns3server.db.repositories.rbac import RbacRepository
 from gns3server.services.templates import TemplatesService
 
-from .dependencies.authentication import get_current_active_user
 from .dependencies.rbac import has_privilege, has_privilege_on_websocket
 from .dependencies.database import get_repository
 
@@ -67,31 +66,21 @@ def dep_project(project_id: UUID) -> Project:
     return project
 
 
-CHUNK_SIZE = 1024 * 8  # 8KB
-
-
-@router.get("", response_model=List[schemas.Project], response_model_exclude_unset=True)
-async def get_projects(
-        current_user: schemas.User = Depends(get_current_active_user),
-        rbac_repo: RbacRepository = Depends(get_repository(RbacRepository))
-) -> List[schemas.Project]:
+@router.get(
+    "",
+    response_model=List[schemas.Project],
+    response_model_exclude_unset=True,
+    dependencies=[Depends(has_privilege("Project.Audit"))]
+)
+async def get_projects() -> List[schemas.Project]:
     """
     Return all projects.
+
+    Required privilege: Project.Audit
     """
 
     controller = Controller.instance()
-    if current_user.is_superadmin:
-        return [p.asdict() for p in controller.projects.values()]
-    else:
-        user_projects = []
-        for project in controller.projects.values():
-            if await rbac_repo.check_user_has_privilege(
-                    current_user.user_id,
-                    f"/projects/{project.id}",
-                    "Project.Audit"
-            ):
-                user_projects.append(project.asdict())
-    return user_projects
+    return [p.asdict() for p in controller.projects.values()]
 
 
 @router.post(
@@ -107,6 +96,8 @@ async def create_project(
 ) -> schemas.Project:
     """
     Create a new project.
+
+    Required privilege: Project.Allocate
     """
 
     controller = Controller.instance()
@@ -115,9 +106,11 @@ async def create_project(
 
 
 @router.get("/{project_id}", response_model=schemas.Project, dependencies=[Depends(has_privilege("Project.Audit"))])
-async def get_project(project: Project = Depends(dep_project)) -> schemas.Project:
+def get_project(project: Project = Depends(dep_project)) -> schemas.Project:
     """
     Return a project.
+
+    Required privilege: Project.Audit
     """
 
     return project.asdict()
@@ -135,6 +128,8 @@ async def update_project(
 ) -> schemas.Project:
     """
     Update a project.
+
+    Required privilege: Project.Modify
     """
 
     await project.update(**jsonable_encoder(project_data, exclude_unset=True))
@@ -147,21 +142,27 @@ async def update_project(
     dependencies=[Depends(has_privilege("Project.Allocate"))]
 )
 async def delete_project(
-        project: Project = Depends(dep_project)
+        project: Project = Depends(dep_project),
+        rbac_repo: RbacRepository = Depends(get_repository(RbacRepository)),
 ) -> None:
     """
     Delete a project.
+
+    Required privilege: Project.Allocate
     """
 
     controller = Controller.instance()
     await project.delete()
     controller.remove_project(project)
+    await rbac_repo.delete_all_ace_starting_with_path(f"/projects/{project.id}")
 
 
 @router.get("/{project_id}/stats", dependencies=[Depends(has_privilege("Project.Audit"))])
 def get_project_stats(project: Project = Depends(dep_project)) -> dict:
     """
     Return a project statistics.
+
+    Required privilege: Project.Audit
     """
 
     return project.stats()
@@ -176,6 +177,8 @@ def get_project_stats(project: Project = Depends(dep_project)) -> dict:
 async def close_project(project: Project = Depends(dep_project)) -> None:
     """
     Close a project.
+
+    Required privilege: Project.Allocate
     """
 
     await project.close()
@@ -191,6 +194,8 @@ async def close_project(project: Project = Depends(dep_project)) -> None:
 async def open_project(project: Project = Depends(dep_project)) -> schemas.Project:
     """
     Open a project.
+
+    Required privilege: Project.Allocate
     """
 
     await project.open()
@@ -207,6 +212,8 @@ async def open_project(project: Project = Depends(dep_project)) -> schemas.Proje
 async def load_project(path: str = Body(..., embed=True)) -> schemas.Project:
     """
     Load a project (local server only).
+
+    Required privilege: Project.Allocate
     """
 
     controller = Controller.instance()
@@ -219,6 +226,8 @@ async def load_project(path: str = Body(..., embed=True)) -> schemas.Project:
 async def project_http_notifications(project_id: UUID) -> StreamingResponse:
     """
     Receive project notifications about the controller from HTTP stream.
+
+    Required privilege: Project.Audit
     """
 
     from gns3server.api.server import app
@@ -255,6 +264,8 @@ async def project_ws_notifications(
 ) -> None:
     """
     Receive project notifications about the controller from WebSocket.
+
+    Required privilege: Project.Audit
     """
 
     if current_user is None:
@@ -298,6 +309,8 @@ async def export_project(
 ) -> StreamingResponse:
     """
     Export a project as a portable archive.
+
+    Required privilege: Project.Audit
     """
 
     compression_query = compression.lower()
@@ -366,6 +379,8 @@ async def import_project(
 ) -> schemas.Project:
     """
     Import a project from a portable archive.
+
+    Required privilege: Project.Allocate
     """
 
     controller = Controller.instance()
@@ -401,6 +416,8 @@ async def duplicate_project(
 ) -> schemas.Project:
     """
     Duplicate a project.
+
+    Required privilege: Project.Allocate
     """
 
     reset_mac_addresses = project_data.reset_mac_addresses
@@ -413,7 +430,9 @@ async def duplicate_project(
 @router.get("/{project_id}/locked", dependencies=[Depends(has_privilege("Project.Audit"))])
 async def locked_project(project: Project = Depends(dep_project)) -> bool:
     """
-    Returns whether a project is locked or not
+    Returns whether a project is locked or not.
+
+    Required privilege: Project.Audit
     """
 
     return project.locked
@@ -427,6 +446,8 @@ async def locked_project(project: Project = Depends(dep_project)) -> bool:
 async def lock_project(project: Project = Depends(dep_project)) -> None:
     """
     Lock all drawings and nodes in a given project.
+
+    Required privilege: Project.Audit
     """
 
     project.lock()
@@ -440,6 +461,8 @@ async def lock_project(project: Project = Depends(dep_project)) -> None:
 async def unlock_project(project: Project = Depends(dep_project)) -> None:
     """
     Unlock all drawings and nodes in a given project.
+
+    Required privilege: Project.Modify
     """
 
     project.unlock()
@@ -449,6 +472,8 @@ async def unlock_project(project: Project = Depends(dep_project)) -> None:
 async def get_file(file_path: str, project: Project = Depends(dep_project)) -> FileResponse:
     """
     Return a file from a project.
+
+    Required privilege: Project.Audit
     """
 
     file_path = urllib.parse.unquote(file_path)
@@ -473,6 +498,8 @@ async def get_file(file_path: str, project: Project = Depends(dep_project)) -> F
 async def write_file(file_path: str, request: Request, project: Project = Depends(dep_project)) -> None:
     """
     Write a file to a project.
+
+    Required privilege: Project.Modify
     """
 
     file_path = urllib.parse.unquote(file_path)
@@ -511,6 +538,8 @@ async def create_node_from_template(
 ) -> schemas.Node:
     """
     Create a new node from a template.
+
+    Required privilege: Node.Allocate
     """
 
     template = await TemplatesService(templates_repo).get_template(template_id)
diff --git a/gns3server/api/routes/controller/roles.py b/gns3server/api/routes/controller/roles.py
index f8c3b9e6..f1a5434f 100644
--- a/gns3server/api/routes/controller/roles.py
+++ b/gns3server/api/routes/controller/roles.py
@@ -19,7 +19,7 @@
 API routes for roles.
 """
 
-from fastapi import APIRouter, Depends, Response, status
+from fastapi import APIRouter, Depends, status
 from uuid import UUID
 from typing import List
 
@@ -33,6 +33,7 @@ from gns3server.controller.controller_error import (
 
 from gns3server.db.repositories.rbac import RbacRepository
 from .dependencies.database import get_repository
+from .dependencies.rbac import has_privilege
 
 import logging
 
@@ -41,24 +42,37 @@ log = logging.getLogger(__name__)
 router = APIRouter()
 
 
-@router.get("", response_model=List[schemas.Role])
+@router.get(
+    "",
+    response_model=List[schemas.Role],
+    dependencies=[Depends(has_privilege("Role.Audit"))]
+)
 async def get_roles(
         rbac_repo: RbacRepository = Depends(get_repository(RbacRepository))
 ) -> List[schemas.Role]:
     """
     Get all roles.
+
+    Required privilege: Role.Audit
     """
 
     return await rbac_repo.get_roles()
 
 
-@router.post("", response_model=schemas.Role, status_code=status.HTTP_201_CREATED)
+@router.post(
+    "",
+    response_model=schemas.Role,
+    status_code=status.HTTP_201_CREATED,
+    dependencies=[Depends(has_privilege("Role.Allocate"))]
+)
 async def create_role(
         role_create: schemas.RoleCreate,
         rbac_repo: RbacRepository = Depends(get_repository(RbacRepository))
 ) -> schemas.Role:
     """
     Create a new role.
+
+    Required privilege: Role.Allocate
     """
 
     if await rbac_repo.get_role_by_name(role_create.name):
@@ -67,13 +81,19 @@ async def create_role(
     return await rbac_repo.create_role(role_create)
 
 
-@router.get("/{role_id}", response_model=schemas.Role)
+@router.get(
+    "/{role_id}",
+    response_model=schemas.Role,
+    dependencies=[Depends(has_privilege("Role.Audit"))]
+)
 async def get_role(
         role_id: UUID,
         rbac_repo: RbacRepository = Depends(get_repository(RbacRepository)),
 ) -> schemas.Role:
     """
     Get a role.
+
+    Required privilege: Role.Audit
     """
 
     role = await rbac_repo.get_role(role_id)
@@ -82,7 +102,11 @@ async def get_role(
     return role
 
 
-@router.put("/{role_id}", response_model=schemas.Role)
+@router.put(
+    "/{role_id}",
+    response_model=schemas.Role,
+    dependencies=[Depends(has_privilege("Role.Modify"))]
+)
 async def update_role(
         role_id: UUID,
         role_update: schemas.RoleUpdate,
@@ -90,6 +114,8 @@ async def update_role(
 ) -> schemas.Role:
     """
     Update a role.
+
+    Required privilege: Role.Modify
     """
 
     role = await rbac_repo.get_role(role_id)
@@ -102,13 +128,19 @@ async def update_role(
     return await rbac_repo.update_role(role_id, role_update)
 
 
-@router.delete("/{role_id}", status_code=status.HTTP_204_NO_CONTENT)
+@router.delete(
+    "/{role_id}",
+    status_code=status.HTTP_204_NO_CONTENT,
+    dependencies=[Depends(has_privilege("Role.Allocate"))]
+)
 async def delete_role(
     role_id: UUID,
     rbac_repo: RbacRepository = Depends(get_repository(RbacRepository)),
 ) -> None:
     """
     Delete a role.
+
+    Required privilege: Role.Allocate
     """
 
     role = await rbac_repo.get_role(role_id)
@@ -121,15 +153,22 @@ async def delete_role(
     success = await rbac_repo.delete_role(role_id)
     if not success:
         raise ControllerError(f"Role '{role_id}' could not be deleted")
+    await rbac_repo.delete_all_ace_starting_with_path(f"/roles/{role_id}")
 
 
-@router.get("/{role_id}/privileges", response_model=List[schemas.Privilege])
+@router.get(
+    "/{role_id}/privileges",
+    response_model=List[schemas.Privilege],
+    dependencies=[Depends(has_privilege("Role.Audit"))]
+)
 async def get_role_privileges(
         role_id: UUID,
         rbac_repo: RbacRepository = Depends(get_repository(RbacRepository))
 ) -> List[schemas.Privilege]:
     """
     Get all role privileges.
+
+    Required privilege: Role.Audit
     """
 
     return await rbac_repo.get_role_privileges(role_id)
@@ -137,7 +176,8 @@ async def get_role_privileges(
 
 @router.put(
     "/{role_id}/privileges/{privilege_id}",
-    status_code=status.HTTP_204_NO_CONTENT
+    status_code=status.HTTP_204_NO_CONTENT,
+    dependencies=[Depends(has_privilege("Role.Modify"))]
 )
 async def add_privilege_to_role(
         role_id: UUID,
@@ -146,6 +186,8 @@ async def add_privilege_to_role(
 ) -> None:
     """
     Add a privilege to a role.
+
+    Required privilege: Role.Modify
     """
 
     privilege = await rbac_repo.get_privilege(privilege_id)
@@ -159,7 +201,8 @@ async def add_privilege_to_role(
 
 @router.delete(
     "/{role_id}/privileges/{privilege_id}",
-    status_code=status.HTTP_204_NO_CONTENT
+    status_code=status.HTTP_204_NO_CONTENT,
+    dependencies=[Depends(has_privilege("Role.Modify"))]
 )
 async def remove_privilege_from_role(
     role_id: UUID,
@@ -168,6 +211,8 @@ async def remove_privilege_from_role(
 ) -> None:
     """
     Remove privilege from a role.
+
+    Required privilege: Role.Modify
     """
 
     privilege = await rbac_repo.get_privilege(privilege_id)
diff --git a/gns3server/api/routes/controller/snapshots.py b/gns3server/api/routes/controller/snapshots.py
index c372a8e8..37d9f777 100644
--- a/gns3server/api/routes/controller/snapshots.py
+++ b/gns3server/api/routes/controller/snapshots.py
@@ -23,14 +23,18 @@ import logging
 
 log = logging.getLogger()
 
-from fastapi import APIRouter, Depends, Response, status
+from fastapi import APIRouter, Depends, status
 from typing import List
 from uuid import UUID
 
 from gns3server.controller.project import Project
+from gns3server.db.repositories.rbac import RbacRepository
 from gns3server import schemas
 from gns3server.controller import Controller
 
+from .dependencies.database import get_repository
+from .dependencies.rbac import has_privilege
+
 responses = {404: {"model": schemas.ErrorMessage, "description": "Could not find project or snapshot"}}
 
 router = APIRouter(responses=responses)
@@ -45,42 +49,74 @@ def dep_project(project_id: UUID) -> Project:
     return project
 
 
-@router.post("", status_code=status.HTTP_201_CREATED, response_model=schemas.Snapshot)
+@router.post(
+    "",
+    status_code=status.HTTP_201_CREATED,
+    response_model=schemas.Snapshot,
+    dependencies=[Depends(has_privilege("Snapshot.Allocate"))]
+)
 async def create_snapshot(
         snapshot_data: schemas.SnapshotCreate,
         project: Project = Depends(dep_project)
 ) -> schemas.Snapshot:
     """
     Create a new snapshot of a project.
+
+    Required privilege: Snapshot.Allocate
     """
 
     snapshot = await project.snapshot(snapshot_data.name)
     return snapshot.asdict()
 
 
-@router.get("", response_model=List[schemas.Snapshot], response_model_exclude_unset=True)
+@router.get(
+    "",
+    response_model=List[schemas.Snapshot],
+    response_model_exclude_unset=True,
+    dependencies=[Depends(has_privilege("Snapshot.Audit"))]
+)
 def get_snapshots(project: Project = Depends(dep_project)) -> List[schemas.Snapshot]:
     """
     Return all snapshots belonging to a given project.
+
+    Required privilege: Snapshot.Audit
     """
 
     snapshots = [s for s in project.snapshots.values()]
     return [s.asdict() for s in sorted(snapshots, key=lambda s: (s.created_at, s.name))]
 
 
-@router.delete("/{snapshot_id}", status_code=status.HTTP_204_NO_CONTENT)
-async def delete_snapshot(snapshot_id: UUID, project: Project = Depends(dep_project)) -> None:
+@router.delete(
+    "/{snapshot_id}",
+    status_code=status.HTTP_204_NO_CONTENT,
+    dependencies=[Depends(has_privilege("Snapshot.Allocate"))]
+)
+async def delete_snapshot(
+        snapshot_id: UUID,
+        project: Project = Depends(dep_project),
+        rbac_repo=Depends(get_repository(RbacRepository))
+) -> None:
     """
     Delete a snapshot.
+
+    Required privilege: Snapshot.Allocate
     """
 
     await project.delete_snapshot(str(snapshot_id))
+    await rbac_repo.delete_all_ace_starting_with_path(f"/projects/{project.id}/snapshots/{snapshot_id}")
 
 
-@router.post("/{snapshot_id}/restore", status_code=status.HTTP_201_CREATED, response_model=schemas.Project)
+@router.post(
+    "/{snapshot_id}/restore",
+    status_code=status.HTTP_201_CREATED,
+    response_model=schemas.Project,
+    dependencies=[Depends(has_privilege("Snapshot.Restore"))]
+)
 async def restore_snapshot(snapshot_id: UUID, project: Project = Depends(dep_project)) -> schemas.Project:
     """
     Restore a snapshot.
+
+    Required privilege: Snapshot.Restore
     """
 
     snapshot = project.get_snapshot(str(snapshot_id))
diff --git a/gns3server/api/routes/controller/symbols.py b/gns3server/api/routes/controller/symbols.py
index 10b39387..6d73c623 100644
--- a/gns3server/api/routes/controller/symbols.py
+++ b/gns3server/api/routes/controller/symbols.py
@@ -29,7 +29,7 @@ from gns3server.controller import Controller
 from gns3server import schemas
 from gns3server.controller.controller_error import ControllerError, ControllerNotFoundError
 
-from .dependencies.authentication import get_current_active_user
+from .dependencies.rbac import has_privilege
 
 import logging
 
@@ -39,19 +39,28 @@ log = logging.getLogger(__name__)
 router = APIRouter()
 
 
-@router.get("")
+@router.get("", dependencies=[Depends(has_privilege("Symbol.Audit"))])
 def get_symbols() -> List[dict]:
+    """
+    Return all symbols.
+
+    Required privilege: Symbol.Audit
+    """
 
     controller = Controller.instance()
     return controller.symbols.list()
 
 
 @router.get(
-    "/{symbol_id:path}/raw", responses={404: {"model": schemas.ErrorMessage, "description": "Could not find symbol"}}
+    "/{symbol_id:path}/raw",
+    responses={404: {"model": schemas.ErrorMessage, "description": "Could not find symbol"}},
+    dependencies=[Depends(has_privilege("Symbol.Audit"))]
 )
 async def get_symbol(symbol_id: str) -> FileResponse:
     """
     Download a symbol file.
+
+    Required privilege: Symbol.Audit
     """
 
     controller = Controller.instance()
@@ -65,10 +74,13 @@ async def get_symbol(symbol_id: str) -> FileResponse:
 @router.get(
     "/{symbol_id:path}/dimensions",
     responses={404: {"model": schemas.ErrorMessage, "description": "Could not find symbol"}},
+    dependencies=[Depends(has_privilege("Symbol.Audit"))]
 )
 async def get_symbol_dimensions(symbol_id: str) -> dict:
     """
     Get a symbol dimensions.
+
+    Required privilege: Symbol.Audit
     """
 
     controller = Controller.instance()
@@ -80,10 +92,12 @@ async def get_symbol_dimensions(symbol_id: str) -> dict:
         raise ControllerNotFoundError(f"Could not get symbol file: {e}")
 
 
-@router.get("/default_symbols")
+@router.get("/default_symbols", dependencies=[Depends(has_privilege("Symbol.Audit"))])
 def get_default_symbols() -> dict:
     """
     Return all default symbols.
+
+    Required privilege: Symbol.Audit
     """
 
     controller = Controller.instance()
@@ -92,12 +106,14 @@ def get_default_symbols() -> dict:
 
 @router.post(
     "/{symbol_id:path}/raw",
-    dependencies=[Depends(get_current_active_user)],
-    status_code=status.HTTP_204_NO_CONTENT
+    status_code=status.HTTP_204_NO_CONTENT,
+    dependencies=[Depends(has_privilege("Symbol.Allocate"))]
 )
 async def upload_symbol(symbol_id: str, request: Request) -> None:
     """
     Upload a symbol file.
+
+    Required privilege: Symbol.Allocate
     """
 
     controller = Controller.instance()
@@ -111,4 +127,3 @@ async def upload_symbol(symbol_id: str, request: Request) -> None:
 
     # Reset the symbol list
     controller.symbols.list()
-
diff --git a/gns3server/api/routes/controller/templates.py b/gns3server/api/routes/controller/templates.py
index fee359be..9d67a74d 100644
--- a/gns3server/api/routes/controller/templates.py
+++ b/gns3server/api/routes/controller/templates.py
@@ -36,6 +36,7 @@ from gns3server.db.repositories.rbac import RbacRepository
 from gns3server.db.repositories.images import ImagesRepository
 
 from .dependencies.authentication import get_current_active_user
+from .dependencies.rbac import has_privilege
 from .dependencies.database import get_repository
 
 responses = {404: {"model": schemas.ErrorMessage, "description": "Could not find template"}}
@@ -43,20 +44,32 @@ responses = {404: {"model": schemas.ErrorMessage, "description": "Could not find
 router = APIRouter(responses=responses)
 
 
-@router.post("", response_model=schemas.Template, status_code=status.HTTP_201_CREATED)
+@router.post(
+    "",
+    response_model=schemas.Template,
+    status_code=status.HTTP_201_CREATED,
+    dependencies=[Depends(has_privilege("Template.Allocate"))]
+)
 async def create_template(
     template_create: schemas.TemplateCreate,
     templates_repo: TemplatesRepository = Depends(get_repository(TemplatesRepository))
 ) -> schemas.Template:
     """
     Create a new template.
+
+    Required privilege: Template.Allocate
     """
 
     template = await TemplatesService(templates_repo).create_template(template_create)
     return template
 
 
-@router.get("/{template_id}", response_model=schemas.Template, response_model_exclude_unset=True)
+@router.get(
+    "/{template_id}",
+    response_model=schemas.Template,
+    response_model_exclude_unset=True,
+    dependencies=[Depends(has_privilege("Template.Audit"))]
+)
 async def get_template(
     template_id: UUID,
     request: Request,
@@ -65,6 +78,8 @@ async def get_template(
 ) -> schemas.Template:
     """
     Return a template.
+
+    Required privilege: Template.Audit
     """
 
     request_etag = request.headers.get("If-None-Match", "")
@@ -78,7 +93,12 @@ async def get_template(
         return template
 
 
-@router.put("/{template_id}", response_model=schemas.Template, response_model_exclude_unset=True)
+@router.put(
+    "/{template_id}",
+    response_model=schemas.Template,
+    response_model_exclude_unset=True,
+    dependencies=[Depends(has_privilege("Template.Modify"))]
+)
 async def update_template(
     template_id: UUID,
     template_update: schemas.TemplateUpdate,
@@ -86,12 +106,18 @@ async def update_template(
 ) -> schemas.Template:
     """
     Update a template.
+
+    Required privilege: Template.Modify
     """
 
     return await TemplatesService(templates_repo).update_template(template_id, template_update)
 
 
-@router.delete("/{template_id}", status_code=status.HTTP_204_NO_CONTENT)
+@router.delete(
+    "/{template_id}",
+    status_code=status.HTTP_204_NO_CONTENT,
+    dependencies=[Depends(has_privilege("Template.Allocate"))]
+)
 async def delete_template(
         template_id: UUID,
         prune_images: Optional[bool] = False,
@@ -101,15 +127,22 @@ async def delete_template(
 ) -> None:
     """
     Delete a template.
+
+    Required privilege: Template.Allocate
     """
 
     await TemplatesService(templates_repo).delete_template(template_id)
-    #await rbac_repo.delete_all_permissions_with_path(f"/templates/{template_id}")
+    await rbac_repo.delete_all_ace_starting_with_path(f"/templates/{template_id}")
     if prune_images:
         await images_repo.prune_images()
 
 
-@router.get("", response_model=List[schemas.Template], response_model_exclude_unset=True)
+@router.get(
+    "",
+    response_model=List[schemas.Template],
+    response_model_exclude_unset=True,
+    dependencies=[Depends(has_privilege("Template.Audit"))]
+)
 async def get_templates(
         templates_repo: TemplatesRepository = Depends(get_repository(TemplatesRepository)),
         current_user: schemas.User = Depends(get_current_active_user),
@@ -117,6 +150,8 @@ async def get_templates(
 ) -> List[schemas.Template]:
     """
     Return all templates.
+
+    Required privilege: Template.Audit
     """
 
     templates = await TemplatesService(templates_repo).get_templates()
@@ -136,12 +171,19 @@ async def get_templates(
     return user_templates
 
 
-@router.post("/{template_id}/duplicate", response_model=schemas.Template, status_code=status.HTTP_201_CREATED)
+@router.post(
+    "/{template_id}/duplicate",
+    response_model=schemas.Template,
+    status_code=status.HTTP_201_CREATED,
+    dependencies=[Depends(has_privilege("Template.Allocate"))]
+)
 async def duplicate_template(
         template_id: UUID, templates_repo: TemplatesRepository = Depends(get_repository(TemplatesRepository))
 ) -> schemas.Template:
     """
     Duplicate a template.
+
+    Required privilege: Template.Allocate
     """
 
     template = await TemplatesService(templates_repo).duplicate_template(template_id)
diff --git a/gns3server/api/routes/controller/users.py b/gns3server/api/routes/controller/users.py
index 7dd9c2c8..9901dd5c 100644
--- a/gns3server/api/routes/controller/users.py
+++ b/gns3server/api/routes/controller/users.py
@@ -1,6 +1,6 @@
 #!/usr/bin/env python
 #
-# Copyright (C) 2020 GNS3 Technologies Inc.
+# Copyright (C) 2023 GNS3 Technologies Inc.
 #
 # This program is free software: you can redistribute it and/or modify
 # it under the terms of the GNU General Public License as published by
@@ -38,6 +38,7 @@ from gns3server.services import auth_service
 
 from .dependencies.authentication import get_current_active_user
 from .dependencies.database import get_repository
+from .dependencies.rbac import has_privilege
 
 import logging
 
@@ -115,12 +116,18 @@ async def update_logged_in_user(
     return await users_repo.update_user(current_user.user_id, user_update)
 
 
-@router.get("", response_model=List[schemas.User], dependencies=[Depends(get_current_active_user)])
+@router.get(
+    "",
+    response_model=List[schemas.User],
+    dependencies=[Depends(has_privilege("User.Audit"))]
+)
 async def get_users(
         users_repo: UsersRepository = Depends(get_repository(UsersRepository))
 ) -> List[schemas.User]:
     """
     Get all users.
+
+    Required privilege: User.Audit
     """
 
     return await users_repo.get_users()
@@ -129,8 +136,8 @@ async def get_users(
 @router.post(
     "",
     response_model=schemas.User,
-    dependencies=[Depends(get_current_active_user)],
-    status_code=status.HTTP_201_CREATED
+    status_code=status.HTTP_201_CREATED,
+    dependencies=[Depends(has_privilege("User.Allocate"))]
 )
 async def create_user(
         user_create: schemas.UserCreate,
@@ -138,6 +145,8 @@ async def create_user(
 ) -> schemas.User:
     """
     Create a new user.
+
+    Required privilege: User.Allocate
     """
 
     if await users_repo.get_user_by_username(user_create.username):
@@ -149,13 +158,19 @@ async def create_user(
     return await users_repo.create_user(user_create)
 
 
-@router.get("/{user_id}", dependencies=[Depends(get_current_active_user)], response_model=schemas.User)
+@router.get(
+    "/{user_id}",
+    response_model=schemas.User,
+    dependencies=[Depends(has_privilege("User.Audit"))]
+)
 async def get_user(
         user_id: UUID,
         users_repo: UsersRepository = Depends(get_repository(UsersRepository)),
 ) -> schemas.User:
     """
     Get a user.
+
+    Required privilege: User.Audit
     """
 
     user = await users_repo.get_user(user_id)
@@ -164,7 +179,11 @@ async def get_user(
     return user
 
 
-@router.put("/{user_id}", dependencies=[Depends(get_current_active_user)], response_model=schemas.User)
+@router.put(
+    "/{user_id}",
+    response_model=schemas.User,
+    dependencies=[Depends(has_privilege("User.Modify"))]
+)
 async def update_user(
         user_id: UUID,
         user_update: schemas.UserUpdate,
@@ -172,6 +191,8 @@ async def update_user(
 ) -> schemas.User:
     """
     Update a user.
+
+    Required privilege: User.Modify
     """
 
     if user_update.username and await users_repo.get_user_by_username(user_update.username):
@@ -188,15 +209,18 @@ async def update_user(
 
 @router.delete(
     "/{user_id}",
-    dependencies=[Depends(get_current_active_user)],
-    status_code=status.HTTP_204_NO_CONTENT
+    status_code=status.HTTP_204_NO_CONTENT,
+    dependencies=[Depends(has_privilege("User.Allocate"))]
 )
 async def delete_user(
-    user_id: UUID,
-    users_repo: UsersRepository = Depends(get_repository(UsersRepository)),
+        user_id: UUID,
+        users_repo: UsersRepository = Depends(get_repository(UsersRepository)),
+        rbac_repo: RbacRepository = Depends(get_repository(RbacRepository))
 ) -> None:
     """
     Delete a user.
+
+    Required privilege: User.Allocate
     """
 
     user = await users_repo.get_user(user_id)
@@ -209,12 +233,13 @@ async def delete_user(
     success = await users_repo.delete_user(user_id)
     if not success:
         raise ControllerError(f"User '{user_id}' could not be deleted")
+    await rbac_repo.delete_all_ace_starting_with_path(f"/users/{user_id}")
 
 
 @router.get(
     "/{user_id}/groups",
-    dependencies=[Depends(get_current_active_user)],
-    response_model=List[schemas.UserGroup]
+    response_model=List[schemas.UserGroup],
+    dependencies=[Depends(has_privilege("Group.Audit"))]
 )
 async def get_user_memberships(
         user_id: UUID,
@@ -222,6 +247,8 @@ async def get_user_memberships(
 ) -> List[schemas.UserGroup]:
     """
     Get user memberships.
+
+    Required privilege: Group.Audit
     """
 
     return await users_repo.get_user_memberships(user_id)
diff --git a/gns3server/db/models/acl.py b/gns3server/db/models/acl.py
index fe033262..20df831d 100644
--- a/gns3server/db/models/acl.py
+++ b/gns3server/db/models/acl.py
@@ -30,10 +30,10 @@ class ACE(BaseTable):
     __tablename__ = "acl"
 
     ace_id = Column(GUID, primary_key=True, default=generate_uuid)
+    ace_type: str = Column(String)
     path = Column(String)
     propagate = Column(Boolean, default=True)
     allowed = Column(Boolean, default=True)
-    type: str = Column(String)
     user_id = Column(GUID, ForeignKey('users.user_id', ondelete="CASCADE"))
     user = relationship("User", back_populates="acl_entries")
     group_id = Column(GUID, ForeignKey('user_groups.user_group_id', ondelete="CASCADE"))
@@ -42,5 +42,5 @@ class ACE(BaseTable):
     role = relationship("Role", back_populates="acl_entries")
 
     __table_args__ = (
-        CheckConstraint("(user_id IS NOT NULL AND type = 'user') OR (group_id IS NOT NULL AND type = 'group')"),
+        CheckConstraint("(user_id IS NOT NULL AND ace_type = 'user') OR (group_id IS NOT NULL AND ace_type = 'group')"),
     )
diff --git a/gns3server/db/models/privileges.py b/gns3server/db/models/privileges.py
index 2bd746df..dcc141fc 100644
--- a/gns3server/db/models/privileges.py
+++ b/gns3server/db/models/privileges.py
@@ -71,6 +71,30 @@ def create_default_roles(target, connection, **kw):
             "description": "Update a group",
             "name": "Group.Modify"
         },
+        {
+            "description": "Create or delete a role",
+            "name": "Role.Allocate"
+        },
+        {
+            "description": "View a role",
+            "name": "Role.Audit"
+        },
+        {
+            "description": "Update a role",
+            "name": "Role.Modify"
+        },
+        {
+            "description": "Create or delete an ACE",
+            "name": "ACE.Allocate"
+        },
+        {
+            "description": "View an ACE",
+            "name": "ACE.Audit"
+        },
+        {
+            "description": "Update an ACE",
+            "name": "ACE.Modify"
+        },
         {
             "description": "Create or delete a template",
             "name": "Template.Allocate"
@@ -97,7 +121,15 @@ def create_default_roles(target, connection, **kw):
         },
         {
             "description": "Create or delete project snapshots",
-            "name": "Project.Snapshot"
+            "name": "Snapshot.Allocate"
+        },
+        {
+            "description": "Restore a snapshot",
+            "name": "Snapshot.Restore"
+        },
+        {
+            "description": "View a snapshot",
+            "name": "Snapshot.Audit"
         },
         {
             "description": "Create or delete a node",
@@ -167,6 +199,10 @@ def create_default_roles(target, connection, **kw):
             "description": "Create or delete a compute",
             "name": "Compute.Allocate"
         },
+        {
+            "description": "Update a compute",
+            "name": "Compute.Modify"
+        },
         {
             "description": "View a compute",
             "name": "Compute.Audit"
@@ -227,7 +263,9 @@ def add_privileges_to_default_roles(target, connection, **kw):
         "Project.Allocate",
         "Project.Audit",
         "Project.Modify",
-        "Project.Snapshot",
+        "Snapshot.Allocate",
+        "Snapshot.Audit",
+        "Snapshot.Restore",
         "Node.Allocate",
         "Node.Audit",
         "Node.Modify",
@@ -253,6 +291,7 @@ def add_privileges_to_default_roles(target, connection, **kw):
     # add required privileges to the "Auditor" role
     auditor_privileges = (
         "Project.Audit",
+        "Snapshot.Audit",
         "Node.Audit",
         "Link.Audit",
         "Drawing.Audit",
diff --git a/gns3server/db/repositories/rbac.py b/gns3server/db/repositories/rbac.py
index 418e8775..cf1aa9a8 100644
--- a/gns3server/db/repositories/rbac.py
+++ b/gns3server/db/repositories/rbac.py
@@ -276,22 +276,6 @@ class RbacRepository(BaseRepository):
         await self._db_session.commit()
         return result.rowcount > 0
 
-    # async def prune_permissions(self) -> int:
-    #     """
-    #     Prune orphaned permissions.
-    #     """
-    #
-    #     query = select(models.Permission).\
-    #         filter((~models.Permission.roles.any()) & (models.Permission.user_id == null()))
-    #     result = await self._db_session.execute(query)
-    #     permissions = result.scalars().all()
-    #     permissions_deleted = 0
-    #     for permission in permissions:
-    #         if await self.delete_permission(permission.permission_id):
-    #             permissions_deleted += 1
-    #     log.info(f"{permissions_deleted} orphaned permissions have been deleted")
-    #     return permissions_deleted
-
     async def delete_all_ace_starting_with_path(self, path: str) -> None:
         """
         Delete all ACEs starting with path.
@@ -304,7 +288,10 @@ class RbacRepository(BaseRepository):
         log.debug(f"{result.rowcount} ACE(s) have been deleted")
 
     @staticmethod
-    def _match_path_to_aces(path: str, aces) -> bool:
+    def _check_path_with_aces(path: str, aces) -> bool:
+        """
+        Compare path with existing ACEs to check if the user has the required privilege on that path.
+        """
 
         parsed_url = urlparse(path)
         original_path = path
@@ -347,7 +334,7 @@ class RbacRepository(BaseRepository):
         aces = result.all()
 
         try:
-            if self._match_path_to_aces(path, aces):
+            if self._check_path_with_aces(path, aces):
                 # the user has an ACE matching the path and privilege,there is no need to check group ACEs
                 return True
         except PermissionError:
@@ -366,6 +353,6 @@ class RbacRepository(BaseRepository):
         aces = result.all()
 
         try:
-            return self._match_path_to_aces(path, aces)
+            return self._check_path_with_aces(path, aces)
         except PermissionError:
             return False
diff --git a/gns3server/schemas/controller/rbac.py b/gns3server/schemas/controller/rbac.py
index 36cb2897..9d44e5c4 100644
--- a/gns3server/schemas/controller/rbac.py
+++ b/gns3server/schemas/controller/rbac.py
@@ -48,10 +48,10 @@ class ACEBase(BaseModel):
     Common ACE properties.
     """
 
+    ace_type: ACEType = Field(..., description="Type of the ACE")
     path: str
     propagate: Optional[bool] = True
     allowed: Optional[bool] = True
-    type: ACEType = Field(..., description="Type of the ACE")
     user_id: Optional[UUID] = None
     group_id: Optional[UUID] = None
     role_id: UUID
diff --git a/tests/api/routes/controller/test_acl.py b/tests/api/routes/controller/test_acl.py
index bf67d1a9..a5461d89 100644
--- a/tests/api/routes/controller/test_acl.py
+++ b/tests/api/routes/controller/test_acl.py
@@ -17,7 +17,6 @@
 
 import pytest
 import pytest_asyncio
-import uuid
 
 from fastapi import FastAPI, status
 from httpx import AsyncClient
@@ -25,58 +24,15 @@ from httpx import AsyncClient
 from sqlalchemy.ext.asyncio import AsyncSession
 from gns3server.db.repositories.users import UsersRepository
 from gns3server.db.repositories.rbac import RbacRepository
-from gns3server.controller import Controller
-from gns3server.controller.project import Project
 from gns3server.schemas.controller.users import User
 from gns3server.schemas.controller.rbac import ACECreate
+from gns3server.controller import Controller
 
 pytestmark = pytest.mark.asyncio
 
 
 class TestACLRoutes:
 
-    # @pytest_asyncio.fixture
-    # async def project(
-    #         self,
-    #         app: FastAPI,
-    #         authorized_client: AsyncClient,
-    #         test_user: User,
-    #         db_session: AsyncSession,
-    #         controller: Controller
-    # ) -> Project:
-    #
-    #     # add an ACE to allow user to create a project
-    #     user_id = test_user.user_id
-    #     rbac_repo = RbacRepository(db_session)
-    #     role_in_db = await rbac_repo.get_role_by_name("User")
-    #     role_id = role_in_db.role_id
-    #     ace = ACECreate(
-    #         path="/projects",
-    #         type="user",
-    #         user_id=user_id,
-    #         role_id=role_id
-    #     )
-    #     await rbac_repo.create_ace(ace)
-    #     project_uuid = str(uuid.uuid4())
-    #     params = {"name": "test", "project_id": project_uuid}
-    #     response = await authorized_client.post(app.url_path_for("create_project"), json=params)
-    #     assert response.status_code == status.HTTP_201_CREATED
-    #     return controller.get_project(project_uuid)
-
-    #@pytest_asyncio.fixture
-    # async def project(
-    #         self,
-    #         app: FastAPI,
-    #         client: AsyncClient,
-    #         controller: Controller
-    # ) -> Project:
-    #
-    #     project_uuid = str(uuid.uuid4())
-    #     params = {"name": "test", "project_id": project_uuid}
-    #     response = await client.post(app.url_path_for("create_project"), json=params)
-    #     assert response.status_code == status.HTTP_201_CREATED
-    #     return controller.get_project(project_uuid)
-
     @pytest_asyncio.fixture
     async def group_id(self, db_session: AsyncSession) -> str:
 
@@ -102,11 +58,22 @@ class TestACLRoutes:
             role_id: str
     ) -> None:
 
+        # allow the user to create an ACE
+        rbac_repo = RbacRepository(db_session)
+        admin_role_id = (await rbac_repo.get_role_by_name("Administrator")).role_id
+        ace = ACECreate(
+            path="/acl",
+            ace_type="user",
+            user_id=test_user.user_id,
+            role_id=admin_role_id
+        )
+        await rbac_repo.create_ace(ace)
+
         # add an ACE on /projects to allow user to create a project
         path = f"/projects"
         new_ace = {
             "path": path,
-            "type": "user",
+            "ace_type": "user",
             "user_id": str(test_user.user_id),
             "role_id": role_id
         }
@@ -130,14 +97,14 @@ class TestACLRoutes:
 
         new_ace = {
             "path": "/projects/invalid",
-            "type": "group",
+            "ace_type": "group",
             "group_id": group_id,
             "role_id": role_id
         }
         response = await client.post(app.url_path_for("create_ace"), json=new_ace)
         assert response.status_code == status.HTTP_400_BAD_REQUEST
 
-    # async def test_create_ace_not_existing_resource(
+    # async def test_create_ace_non_existing_resource(
     #         self,
     #         app: FastAPI,
     #         client: AsyncClient,
@@ -147,6 +114,7 @@ class TestACLRoutes:
     #
     #     new_ace = {
     #         "path": f"/projects/{str(uuid.uuid4())}",
+    #         "ace_type": "group",
     #         "group_id": group_id,
     #         "role_id": role_id
     #     }
@@ -165,7 +133,7 @@ class TestACLRoutes:
 
         response = await client.get(app.url_path_for("get_aces"))
         assert response.status_code == status.HTTP_200_OK
-        assert len(response.json()) == 1
+        assert len(response.json()) == 2
 
     async def test_update_ace(
             self, app: FastAPI,
@@ -180,7 +148,7 @@ class TestACLRoutes:
 
         update_ace = {
             "path": f"/appliances",
-            "type": "user",
+            "ace_type": "user",
             "user_id": str(test_user.user_id),
             "role_id": role_id
         }
@@ -204,11 +172,41 @@ class TestACLRoutes:
         response = await client.delete(app.url_path_for("delete_ace", ace_id=ace_in_db.ace_id))
         assert response.status_code == status.HTTP_204_NO_CONTENT
 
-    # async def test_prune_permissions(self, app: FastAPI, client: AsyncClient, db_session: AsyncSession) -> None:
-    #
-    #     response = await client.post(app.url_path_for("prune_permissions"))
-    #     assert response.status_code == status.HTTP_204_NO_CONTENT
-    #
-    #     rbac_repo = RbacRepository(db_session)
-    #     permissions_in_db = await rbac_repo.get_permissions()
-    #     assert len(permissions_in_db) == 10  # 6 default permissions + 4 custom permissions
+    async def test_ace_cleanup(
+            self,
+            app: FastAPI,
+            authorized_client: AsyncClient,
+            db_session: AsyncSession,
+            test_user: User,
+            role_id: str,
+    ) -> None:
+
+        # allow the user to create projects
+        rbac_repo = RbacRepository(db_session)
+        ace = ACECreate(
+            path="/projects",
+            ace_type="user",
+            user_id=test_user.user_id,
+            role_id=role_id
+        )
+        await rbac_repo.create_ace(ace)
+
+        response = await authorized_client.post(app.url_path_for("create_project"), json={"name": "test2"})
+        assert response.status_code == status.HTTP_201_CREATED
+        project_id = response.json()["project_id"]
+
+        path = f"/projects/{project_id}"
+        ace = ACECreate(
+            path=path,
+            ace_type="user",
+            user_id=test_user.user_id,
+            role_id=role_id
+        )
+        await rbac_repo.create_ace(ace)
+        assert await rbac_repo.get_ace_by_path(path)
+
+        response = await authorized_client.delete(app.url_path_for("delete_project", project_id=project_id))
+        assert response.status_code == status.HTTP_204_NO_CONTENT
+
+        # the ACE should have been deleted after deleting the project
+        assert not await rbac_repo.get_ace_by_path(path)
diff --git a/tests/api/routes/controller/test_roles.py b/tests/api/routes/controller/test_roles.py
index 66778eae..f0c85856 100644
--- a/tests/api/routes/controller/test_roles.py
+++ b/tests/api/routes/controller/test_roles.py
@@ -125,7 +125,7 @@ class TestRolesPrivilegesRoutes:
         )
         assert response.status_code == status.HTTP_204_NO_CONTENT
         privileges = await rbac_repo.get_role_privileges(role_in_db.role_id)
-        assert len(privileges) == 21  # 20 default privileges + 1 custom privilege
+        assert len(privileges) == 25  # 24 default privileges + 1 custom privilege
 
     async def test_get_role_privileges(
             self,
@@ -143,7 +143,7 @@ class TestRolesPrivilegesRoutes:
                 role_id=role_in_db.role_id)
         )
         assert response.status_code == status.HTTP_200_OK
-        assert len(response.json()) == 21  # 20 default privileges + 1 custom privilege
+        assert len(response.json()) == 25  # 24 default privileges + 1 custom privilege
 
     async def test_remove_privilege_from_role(
             self,
@@ -165,4 +165,4 @@ class TestRolesPrivilegesRoutes:
         )
         assert response.status_code == status.HTTP_204_NO_CONTENT
         privileges = await rbac_repo.get_role_privileges(role_in_db.role_id)
-        assert len(privileges) == 20  # 20 default privileges
+        assert len(privileges) == 24  # 24 default privileges
diff --git a/tests/api/routes/controller/test_users.py b/tests/api/routes/controller/test_users.py
index 2b33b52c..94262fdb 100644
--- a/tests/api/routes/controller/test_users.py
+++ b/tests/api/routes/controller/test_users.py
@@ -305,7 +305,7 @@ class TestUserLogin:
         assert response.status_code == status.HTTP_200_OK
         token = response.json().get("access_token")
 
-        response = await unauthorized_client.get(app.url_path_for("get_projects"), params={"token": token})
+        response = await unauthorized_client.get(app.url_path_for("statistics"), params={"token": token})
         assert response.status_code == status.HTTP_200_OK
 
 
diff --git a/tests/controller/test_rbac.py b/tests/controller/test_rbac.py
index 178e96fb..51339d83 100644
--- a/tests/controller/test_rbac.py
+++ b/tests/controller/test_rbac.py
@@ -16,51 +16,118 @@
 # along with this program.  If not, see <http://www.gnu.org/licenses/>.
 
 import pytest
+import pytest_asyncio
 
 from fastapi import FastAPI, status
 from httpx import AsyncClient
 
 from sqlalchemy.ext.asyncio import AsyncSession
 from gns3server.db.repositories.rbac import RbacRepository
+from gns3server.db.repositories.users import UsersRepository
+from gns3server.schemas.controller.rbac import ACECreate
 from gns3server.db.models import User
 
 pytestmark = pytest.mark.asyncio
 
-
-# class TestPermissions:
+# @pytest_asyncio.fixture
+# async def project_ace(db_session: AsyncSession):
 #
-#     @pytest.mark.parametrize(
-#         "method, path, result",
-#         (
-#             ("GET", "/users", False),
-#             ("GET", "/projects", True),
-#             ("GET", "/projects/e451ad73-2519-4f83-87fe-a8e821792d44", False),
-#             ("POST", "/projects", True),
-#             ("GET", "/templates", True),
-#             ("GET", "/templates/62e92cf1-244a-4486-8dae-b95439b54da9", False),
-#             ("POST", "/templates", True),
-#             ("GET", "/computes", True),
-#             ("GET", "/computes/local", True),
-#             ("GET", "/symbols", True),
-#             ("GET", "/symbols/default_symbols", True),
-#         ),
+#     group_id = (await UsersRepository(db_session).get_user_group_by_name("Users")).user_group_id
+#     role_id = (await RbacRepository(db_session).get_role_by_name("User")).role_id
+#     ace = ACECreate(
+#         path="/projects",
+#         ace_type="group",
+#         propagate=False,
+#         group_id=str(group_id),
+#         role_id=str(role_id)
 #     )
-#     async def test_default_permissions_user_group(
-#             self,
-#             app: FastAPI,
-#             authorized_client: AsyncClient,
-#             test_user: User,
-#             db_session: AsyncSession,
-#             method: str,
-#             path: str,
-#             result: bool
-#     ) -> None:
-#
-#         rbac_repo = RbacRepository(db_session)
-#         authorized = await rbac_repo.check_user_is_authorized(test_user.user_id, method, path)
-#         assert authorized == result
-#
-#
+#     await RbacRepository(db_session).create_ace(ace)
+
+
+class TestPrivileges:
+
+    @pytest.mark.parametrize(
+        "privilege, path, result",
+        (
+            ("User.Allocate", "/users", False),
+            ("Project.Allocate", "/projects", False),
+            ("Project.Allocate", "/projects", True),
+            ("Project.Audit", "/projects/e451ad73-2519-4f83-87fe-a8e821792d44", True),
+            ("Project.Audit", "/templates", False),
+            ("Template.Audit", "/templates", True),
+            ("Template.Allocate", "/templates", False),
+            ("Compute.Audit", "/computes", True),
+            ("Compute.Audit", "/computes/local", True),
+            ("Symbol.Audit", "/symbols", True),
+            ("Symbol.Audit", "/symbols/default_symbols", True),
+        ),
+    )
+    async def test_default_privileges_user_group(
+            self,
+            test_user: User,
+            db_session: AsyncSession,
+            privilege: str,
+            path: str,
+            result: bool
+    ) -> None:
+
+        # add an ACE for path
+        if result:
+            group_id = (await UsersRepository(db_session).get_user_group_by_name("Users")).user_group_id
+            role_id = (await RbacRepository(db_session).get_role_by_name("User")).role_id
+            ace = ACECreate(
+                path=path,
+                ace_type="group",
+                propagate=False,
+                group_id=str(group_id),
+                role_id=str(role_id)
+            )
+            await RbacRepository(db_session).create_ace(ace)
+
+        authorized = await RbacRepository(db_session).check_user_has_privilege(test_user.user_id, path, privilege)
+        assert authorized == result
+
+    async def test_propagate(self, test_user: User, db_session: AsyncSession):
+
+        privilege = "Project.Audit"
+        path = "/projects/44929147-47bb-460a-90ae-c782c4dbb6ef"
+        authorized = await RbacRepository(db_session).check_user_has_privilege(test_user.user_id, path, privilege)
+        assert authorized is False
+
+        ace = await RbacRepository(db_session).get_ace_by_path("/projects")
+        ace.propagate = True
+        await db_session.commit()
+
+        authorized = await RbacRepository(db_session).check_user_has_privilege(test_user.user_id, path, privilege)
+        assert authorized is True
+
+    async def test_allowed(self, test_user: User, db_session: AsyncSession):
+
+        ace = await RbacRepository(db_session).get_ace_by_path("/projects")
+        ace.allowed = False
+        ace.propagate = True
+        await db_session.commit()
+
+        privilege = "Project.Audit"
+        path = "/projects/44929147-47bb-460a-90ae-c782c4dbb6ef"
+        authorized = await RbacRepository(db_session).check_user_has_privilege(test_user.user_id, path, privilege)
+        assert authorized is False
+
+        # privileges on deeper levels replace those inherited from an upper level.
+        group_id = (await UsersRepository(db_session).get_user_group_by_name("Users")).user_group_id
+        role_id = (await RbacRepository(db_session).get_role_by_name("User")).role_id
+        ace = ACECreate(
+            path=path,
+            ace_type="group",
+            propagate=False,
+            group_id=str(group_id),
+            role_id=str(role_id)
+        )
+        await RbacRepository(db_session).create_ace(ace)
+
+        authorized = await RbacRepository(db_session).check_user_has_privilege(test_user.user_id, path, privilege)
+        assert authorized is True
+
 # class TestProjectsWithRbac:
 #
 #     async def test_admin_create_project(self, app: FastAPI, client: AsyncClient):
@@ -73,6 +140,7 @@ pytestmark = pytest.mark.asyncio
 #             self,
 #             app: FastAPI,
 #             authorized_client: AsyncClient,
+#             project_ace,
 #             test_user: User,
 #             db_session: AsyncSession
 #     ) -> None:
@@ -86,67 +154,67 @@ pytestmark = pytest.mark.asyncio
 #         permissions_in_db = await rbac_repo.get_user_permissions(test_user.user_id)
 #         assert len(permissions_in_db) == 1
 #         assert permissions_in_db[0].path == f"/projects/{project_id}/*"
-#
-#         response = await authorized_client.get(app.url_path_for("get_projects"))
-#         assert response.status_code == status.HTTP_200_OK
-#         projects = response.json()
-#         assert len(projects) == 1
-#
-#     async def test_admin_access_all_projects(self, app: FastAPI, client: AsyncClient):
-#
-#         response = await client.get(app.url_path_for("get_projects"))
-#         assert response.status_code == status.HTTP_200_OK
-#         projects = response.json()
-#         assert len(projects) == 2
-#
-#     async def test_admin_user_give_permission_on_project(
-#             self,
-#             app: FastAPI,
-#             client: AsyncClient,
-#             test_user: User
-#     ):
-#
-#         response = await client.get(app.url_path_for("get_projects"))
-#         assert response.status_code == status.HTTP_200_OK
-#         projects = response.json()
-#         project_id = None
-#         for project in projects:
-#             if project["name"] == "Admin project":
-#                 project_id = project["project_id"]
-#                 break
-#
-#         new_permission = {
-#             "methods": ["GET"],
-#             "path": f"/projects/{project_id}",
-#             "action": "ALLOW"
-#         }
-#         response = await client.post(app.url_path_for("create_permission"), json=new_permission)
-#         assert response.status_code == status.HTTP_201_CREATED
-#         permission_id = response.json()["permission_id"]
-#
-#         response = await client.put(
-#             app.url_path_for(
-#                 "add_permission_to_user",
-#                 user_id=test_user.user_id,
-#                 permission_id=permission_id
-#             )
-#         )
-#         assert response.status_code == status.HTTP_204_NO_CONTENT
-#
-#     async def test_user_access_admin_project(
-#             self,
-#             app: FastAPI,
-#             authorized_client: AsyncClient,
-#             test_user: User,
-#             db_session: AsyncSession
-#     ) -> None:
-#
-#         response = await authorized_client.get(app.url_path_for("get_projects"))
-#         assert response.status_code == status.HTTP_200_OK
-#         projects = response.json()
-#         assert len(projects) == 2
-#
-#
+        #
+        # response = await authorized_client.get(app.url_path_for("get_projects"))
+        # assert response.status_code == status.HTTP_200_OK
+        # projects = response.json()
+        # assert len(projects) == 1
+
+    # async def test_admin_access_all_projects(self, app: FastAPI, client: AsyncClient):
+    #
+    #     response = await client.get(app.url_path_for("get_projects"))
+    #     assert response.status_code == status.HTTP_200_OK
+    #     projects = response.json()
+    #     assert len(projects) == 2
+    #
+    # async def test_admin_user_give_permission_on_project(
+    #         self,
+    #         app: FastAPI,
+    #         client: AsyncClient,
+    #         test_user: User
+    # ):
+    #
+    #     response = await client.get(app.url_path_for("get_projects"))
+    #     assert response.status_code == status.HTTP_200_OK
+    #     projects = response.json()
+    #     project_id = None
+    #     for project in projects:
+    #         if project["name"] == "Admin project":
+    #             project_id = project["project_id"]
+    #             break
+    #
+    #     new_permission = {
+    #         "methods": ["GET"],
+    #         "path": f"/projects/{project_id}",
+    #         "action": "ALLOW"
+    #     }
+    #     response = await client.post(app.url_path_for("create_permission"), json=new_permission)
+    #     assert response.status_code == status.HTTP_201_CREATED
+    #     permission_id = response.json()["permission_id"]
+    #
+    #     response = await client.put(
+    #         app.url_path_for(
+    #             "add_permission_to_user",
+    #             user_id=test_user.user_id,
+    #             permission_id=permission_id
+    #         )
+    #     )
+    #     assert response.status_code == status.HTTP_204_NO_CONTENT
+    #
+    # async def test_user_access_admin_project(
+    #         self,
+    #         app: FastAPI,
+    #         authorized_client: AsyncClient,
+    #         test_user: User,
+    #         db_session: AsyncSession
+    # ) -> None:
+    #
+    #     response = await authorized_client.get(app.url_path_for("get_projects"))
+    #     assert response.status_code == status.HTTP_200_OK
+    #     projects = response.json()
+    #     assert len(projects) == 2
+    #
+
 # class TestTemplatesWithRbac:
 #
 #     async def test_admin_create_template(self, app: FastAPI, client: AsyncClient):

From 170f47673319bb1cb1fec792d15dee3c65cd4fe0 Mon Sep 17 00:00:00 2001
From: grossmj <grossmj@gns3.net>
Date: Sat, 2 Sep 2023 18:15:00 +0700
Subject: [PATCH 7/8] Add more built-in roles

---
 gns3server/db/models/privileges.py        | 39 +++++++++++++++++++++++
 gns3server/db/models/roles.py             |  3 ++
 tests/api/routes/controller/test_roles.py |  2 +-
 3 files changed, 43 insertions(+), 1 deletion(-)

diff --git a/gns3server/db/models/privileges.py b/gns3server/db/models/privileges.py
index dcc141fc..65f0df38 100644
--- a/gns3server/db/models/privileges.py
+++ b/gns3server/db/models/privileges.py
@@ -304,5 +304,44 @@ def add_privileges_to_default_roles(target, connection, **kw):
 
     add_privileges_to_role(target, connection, "Auditor", auditor_privileges)
 
+    # add required privileges to the "Template manager" role
+    template_manager_privileges = (
+        "Template.Allocate",
+        "Template.Audit",
+        "Template.Modify",
+        "Symbol.Allocate",
+        "Symbol.Audit",
+        "Image.Allocate",
+        "Image.Audit",
+        "Appliance.Allocate",
+        "Appliance.Audit"
+    )
+
+    add_privileges_to_role(target, connection, "Template manager", template_manager_privileges)
+
+    # add required privileges to the "User manager" role
+    user_manager_privileges = (
+        "User.Allocate",
+        "User.Audit",
+        "User.Modify",
+        "Group.Allocate",
+        "Group.Audit",
+        "Group.Modify"
+    )
+
+    add_privileges_to_role(target, connection, "User manager", user_manager_privileges)
+
+    # add required privileges to the "ACL manager" role
+    acl_manager_privileges = (
+        "Role.Allocate",
+        "Role.Audit",
+        "Role.Modify",
+        "ACE.Allocate",
+        "ACE.Audit",
+        "ACE.Modify"
+    )
+
+    add_privileges_to_role(target, connection, "ACL manager", acl_manager_privileges)
+
     connection.commit()
     log.debug("Privileges have been added to the default roles in the database")
diff --git a/gns3server/db/models/roles.py b/gns3server/db/models/roles.py
index f2a9ea81..ea02365f 100644
--- a/gns3server/db/models/roles.py
+++ b/gns3server/db/models/roles.py
@@ -45,6 +45,9 @@ def create_default_roles(target, connection, **kw):
         {"name": "Administrator", "description": "Administrator role", "is_builtin": True},
         {"name": "User", "description": "User role", "is_builtin": True},
         {"name": "Auditor", "description": "Role with read only access", "is_builtin": True},
+        {"name": "Template manager", "description": "Role to manage templates", "is_builtin": True},
+        {"name": "User manager", "description": "Role to manage users and groups", "is_builtin": True},
+        {"name": "ACL manager", "description": "Role to manage other roles and the ACL", "is_builtin": True},
         {"name": "No Access", "description": "Role with no privileges (used to forbid access)", "is_builtin": True}
     ]
 
diff --git a/tests/api/routes/controller/test_roles.py b/tests/api/routes/controller/test_roles.py
index f0c85856..b6fca22b 100644
--- a/tests/api/routes/controller/test_roles.py
+++ b/tests/api/routes/controller/test_roles.py
@@ -46,7 +46,7 @@ class TestRolesRoutes:
 
         response = await client.get(app.url_path_for("get_roles"))
         assert response.status_code == status.HTTP_200_OK
-        assert len(response.json()) == 5  # 4 default roles + role1
+        assert len(response.json()) == 8  # 7 default roles + role1
 
     async def test_update_role(self, app: FastAPI, client: AsyncClient, db_session: AsyncSession) -> None:
 

From e72b07bf5c34fb00905fbbb44145cb4ab86f4492 Mon Sep 17 00:00:00 2001
From: grossmj <grossmj@gns3.net>
Date: Sat, 2 Sep 2023 18:42:40 +0700
Subject: [PATCH 8/8] Prepare resource pools table for future development

---
 gns3server/api/routes/controller/nodes.py |  2 +-
 gns3server/db/models/__init__.py          |  1 +
 gns3server/db/models/resource_pools.py    | 36 +++++++++++++++--------
 3 files changed, 25 insertions(+), 14 deletions(-)

diff --git a/gns3server/api/routes/controller/nodes.py b/gns3server/api/routes/controller/nodes.py
index cc93c0c1..b096b634 100644
--- a/gns3server/api/routes/controller/nodes.py
+++ b/gns3server/api/routes/controller/nodes.py
@@ -144,7 +144,7 @@ def get_nodes(project: Project = Depends(dep_project)) -> List[schemas.Node]:
     return [v.asdict() for v in project.nodes.values()]
 
 
-@router.post("/start", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(has_privilege("Node."))])
+@router.post("/start", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(has_privilege("Node.PowerMgmt"))])
 async def start_all_nodes(project: Project = Depends(dep_project)) -> None:
     """
     Start all nodes belonging to a given project.
diff --git a/gns3server/db/models/__init__.py b/gns3server/db/models/__init__.py
index 91d6a314..c31c21b1 100644
--- a/gns3server/db/models/__init__.py
+++ b/gns3server/db/models/__init__.py
@@ -22,6 +22,7 @@ from .roles import Role
 from .privileges import Privilege
 from .computes import Compute
 from .images import Image
+from .resource_pools import Resource, ResourcePool
 from .templates import (
     Template,
     CloudTemplate,
diff --git a/gns3server/db/models/resource_pools.py b/gns3server/db/models/resource_pools.py
index 80f65070..dce9bf5b 100644
--- a/gns3server/db/models/resource_pools.py
+++ b/gns3server/db/models/resource_pools.py
@@ -15,28 +15,38 @@
 # You should have received a copy of the GNU General Public License
 # along with this program.  If not, see <http://www.gnu.org/licenses/>.
 
-from sqlalchemy import Column, String, Boolean, ForeignKey
+from sqlalchemy import Table, Column, String, ForeignKey
 from sqlalchemy.orm import relationship
 
-from .base import BaseTable, generate_uuid, GUID
+from .base import Base, BaseTable, generate_uuid, GUID
 
 import logging
 
 log = logging.getLogger(__name__)
 
 
+resource_pool_map = Table(
+    "resource_pool_map",
+    Base.metadata,
+    Column("resource_id", GUID, ForeignKey("resources.resource_id", ondelete="CASCADE")),
+    Column("resource_pool_id", GUID, ForeignKey("resource_pools.resource_pool_id", ondelete="CASCADE"))
+)
+
+
+class Resource(BaseTable):
+
+    __tablename__ = "resources"
+
+    resource_id = Column(GUID, primary_key=True)
+    name = Column(String, unique=True, index=True)
+    resource_type = Column(String)
+    resource_pools = relationship("ResourcePool", secondary=resource_pool_map, back_populates="resources")
+
+
 class ResourcePool(BaseTable):
 
     __tablename__ = "resource_pools"
 
-    resource_id = Column(GUID, primary_key=True)
-    resource_type = Column(String)
-
-    # Create a self-referential relationship to represent a hierarchy of resources
-    parent_id = Column(GUID, ForeignKey("resources.resource_id", ondelete="CASCADE"))
-    children = relationship(
-        "Resource",
-        remote_side=[resource_id],
-        cascade="all, delete-orphan",
-        single_parent=True
-    )
+    resource_pool_id = Column(GUID, primary_key=True, default=generate_uuid)
+    name = Column(String, unique=True, index=True)
+    resources = relationship("Resource", secondary=resource_pool_map, back_populates="resource_pools")