Merge pull request #2275 from GNS3/rbac-new-implementation

New RBAC implementation
This commit is contained in:
Jeremy Grossmann 2023-09-02 18:47:35 +07:00 committed by GitHub
commit b0c4fc17ad
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
40 changed files with 2219 additions and 1544 deletions

View File

@ -32,7 +32,7 @@ from . import images
from . import users from . import users
from . import groups from . import groups
from . import roles from . import roles
from . import permissions from . import acl
from .dependencies.authentication import get_current_active_user from .dependencies.authentication import get_current_active_user
@ -43,35 +43,30 @@ router.include_router(users.router, prefix="/users", tags=["Users"])
router.include_router( router.include_router(
groups.router, groups.router,
dependencies=[Depends(get_current_active_user)],
prefix="/groups", prefix="/groups",
tags=["Users groups"] tags=["Users groups"]
) )
router.include_router( router.include_router(
roles.router, roles.router,
dependencies=[Depends(get_current_active_user)],
prefix="/roles", prefix="/roles",
tags=["Roles"] tags=["Roles"]
) )
router.include_router( router.include_router(
permissions.router, acl.router,
dependencies=[Depends(get_current_active_user)], prefix="/acl",
prefix="/permissions", tags=["ACL"]
tags=["Permissions"]
) )
router.include_router( router.include_router(
images.router, images.router,
dependencies=[Depends(get_current_active_user)],
prefix="/images", prefix="/images",
tags=["Images"] tags=["Images"]
) )
router.include_router( router.include_router(
templates.router, templates.router,
dependencies=[Depends(get_current_active_user)],
prefix="/templates", prefix="/templates",
tags=["Templates"] tags=["Templates"]
) )
@ -83,21 +78,18 @@ router.include_router(
router.include_router( router.include_router(
nodes.router, nodes.router,
dependencies=[Depends(get_current_active_user)],
prefix="/projects/{project_id}/nodes", prefix="/projects/{project_id}/nodes",
tags=["Nodes"] tags=["Nodes"]
) )
router.include_router( router.include_router(
links.router, links.router,
dependencies=[Depends(get_current_active_user)],
prefix="/projects/{project_id}/links", prefix="/projects/{project_id}/links",
tags=["Links"] tags=["Links"]
) )
router.include_router( router.include_router(
drawings.router, drawings.router,
dependencies=[Depends(get_current_active_user)],
prefix="/projects/{project_id}/drawings", prefix="/projects/{project_id}/drawings",
tags=["Drawings"]) tags=["Drawings"])
@ -108,7 +100,6 @@ router.include_router(
router.include_router( router.include_router(
snapshots.router, snapshots.router,
dependencies=[Depends(get_current_active_user)],
prefix="/projects/{project_id}/snapshots", prefix="/projects/{project_id}/snapshots",
tags=["Snapshots"]) tags=["Snapshots"])
@ -126,15 +117,14 @@ router.include_router(
router.include_router( router.include_router(
appliances.router, appliances.router,
dependencies=[Depends(get_current_active_user)],
prefix="/appliances", prefix="/appliances",
tags=["Appliances"] tags=["Appliances"]
) )
router.include_router( router.include_router(
gns3vm.router, gns3vm.router,
deprecated=True,
dependencies=[Depends(get_current_active_user)], dependencies=[Depends(get_current_active_user)],
deprecated=True,
prefix="/gns3vm", prefix="/gns3vm",
tags=["GNS3 VM"] tags=["GNS3 VM"]
) )

View File

@ -0,0 +1,250 @@
#!/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
)
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.rbac import has_privilege
import logging
log = logging.getLogger(__name__)
router = APIRouter()
@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,
dependencies=[Depends(has_privilege("ACE.Allocate"))]
)
async def create_ace(
request: Request,
ace_create: schemas.ACECreate,
rbac_repo: RbacRepository = Depends(get_repository(RbacRepository))
) -> schemas.ACE:
"""
Create a new ACL entry.
Required privilege: ACE.Allocate
"""
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,
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)
if not ace:
raise ControllerNotFoundError(f"ACL entry '{ace_id}' not found")
return 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,
rbac_repo: RbacRepository = Depends(get_repository(RbacRepository))
) -> schemas.ACE:
"""
Update an ACL entry.
Required privilege: ACE.Modify
"""
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,
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)
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")

View File

@ -20,7 +20,7 @@ API routes for appliances.
import logging import logging
from fastapi import APIRouter, Depends, Response, status from fastapi import APIRouter, Depends, status
from typing import Optional, List from typing import Optional, List
from uuid import UUID 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.authentication import get_current_active_user
from .dependencies.database import get_repository from .dependencies.database import get_repository
from .dependencies.rbac import has_privilege
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
router = APIRouter() 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( async def get_appliances(
update: Optional[bool] = False, update: Optional[bool] = False,
symbol_theme: Optional[str] = None symbol_theme: Optional[str] = None
) -> List[schemas.Appliance]: ) -> List[schemas.Appliance]:
""" """
Return all appliances known by the controller. Return all appliances known by the controller.
Required privilege: Appliance.Audit
""" """
controller = Controller.instance() controller = Controller.instance()
@ -60,10 +69,17 @@ async def get_appliances(
return [c.asdict() for c in controller.appliance_manager.appliances.values()] 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: def get_appliance(appliance_id: UUID) -> schemas.Appliance:
""" """
Get an appliance file. Get an appliance file.
Required privilege: Appliance.Audit
""" """
controller = Controller.instance() controller = Controller.instance()
@ -73,10 +89,16 @@ def get_appliance(appliance_id: UUID) -> schemas.Appliance:
return appliance.asdict() 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: 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() controller = Controller.instance()
@ -98,7 +120,11 @@ def add_appliance_version(appliance_id: UUID, appliance_version: schemas.Applian
return appliance.asdict() 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( async def install_appliance(
appliance_id: UUID, appliance_id: UUID,
version: Optional[str] = None, version: Optional[str] = None,
@ -109,6 +135,8 @@ async def install_appliance(
) -> None: ) -> None:
""" """
Install an appliance. Install an appliance.
Required privilege: Appliance.Allocate
""" """
controller = Controller.instance() controller = Controller.instance()

View File

@ -24,10 +24,12 @@ from uuid import UUID
from gns3server.controller import Controller from gns3server.controller import Controller
from gns3server.db.repositories.computes import ComputesRepository from gns3server.db.repositories.computes import ComputesRepository
from gns3server.db.repositories.rbac import RbacRepository
from gns3server.services.computes import ComputesService from gns3server.services.computes import ComputesService
from gns3server import schemas from gns3server import schemas
from .dependencies.database import get_repository from .dependencies.database import get_repository
from .dependencies.rbac import has_privilege
responses = {404: {"model": schemas.ErrorMessage, "description": "Compute not found"}} 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"}, 409: {"model": schemas.ErrorMessage, "description": "Could not create compute"},
401: {"model": schemas.ErrorMessage, "description": "Invalid authentication for compute"}, 401: {"model": schemas.ErrorMessage, "description": "Invalid authentication for compute"},
}, },
dependencies=[Depends(has_privilege("Compute.Allocate"))]
) )
async def create_compute( async def create_compute(
compute_create: schemas.ComputeCreate, compute_create: schemas.ComputeCreate,
@ -51,15 +54,23 @@ async def create_compute(
) -> schemas.Compute: ) -> schemas.Compute:
""" """
Create a new compute on the controller. Create a new compute on the controller.
Required privilege: Compute.Allocate
""" """
return await ComputesService(computes_repo).create_compute(compute_create, connect) 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: async def connect_compute(compute_id: Union[str, UUID]) -> None:
""" """
Connect to compute on the controller. Connect to compute on the controller.
Required privilege: Compute.Audit
""" """
compute = Controller.instance().get_compute(str(compute_id)) 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) 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( async def get_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))
) -> schemas.Compute: ) -> schemas.Compute:
""" """
Return a compute from the controller. Return a compute from the controller.
Required privilege: Compute.Audit
""" """
return await ComputesService(computes_repo).get_compute(compute_id) 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( async def get_computes(
computes_repo: ComputesRepository = Depends(get_repository(ComputesRepository)), computes_repo: ComputesRepository = Depends(get_repository(ComputesRepository)),
) -> List[schemas.Compute]: ) -> List[schemas.Compute]:
""" """
Return all computes known by the controller. Return all computes known by the controller.
Required privilege: Compute.Audit
""" """
return await ComputesService(computes_repo).get_computes() 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( async def update_compute(
compute_id: Union[str, UUID], compute_id: Union[str, UUID],
compute_update: schemas.ComputeUpdate, compute_update: schemas.ComputeUpdate,
@ -97,20 +127,31 @@ async def update_compute(
) -> schemas.Compute: ) -> schemas.Compute:
""" """
Update a compute on the controller. Update a compute on the controller.
Required privilege: Compute.Modify
""" """
return await ComputesService(computes_repo).update_compute(compute_id, compute_update) 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( 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: ) -> None:
""" """
Delete a compute from the controller. Delete a compute from the controller.
Required privilege: Compute.Allocate
""" """
await ComputesService(computes_repo).delete_compute(compute_id) 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]) @router.get("/{compute_id}/docker/images", response_model=List[schemas.ComputeDockerImage])

View File

@ -74,21 +74,6 @@ async def get_current_active_user(
headers={"WWW-Authenticate": "Bearer"}, 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 return current_user
@ -96,7 +81,6 @@ async def get_current_active_user_from_websocket(
websocket: WebSocket, websocket: WebSocket,
token: str = Query(...), token: str = Query(...),
user_repo: UsersRepository = Depends(get_repository(UsersRepository)), user_repo: UsersRepository = Depends(get_repository(UsersRepository)),
rbac_repo: RbacRepository = Depends(get_repository(RbacRepository))
) -> Optional[schemas.User]: ) -> Optional[schemas.User]:
await websocket.accept() await websocket.accept()
@ -121,18 +105,6 @@ async def get_current_active_user_from_websocket(
detail=f"'{username}' is not an active user" 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 return user
except HTTPException as e: except HTTPException as e:

View File

@ -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
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
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"))

View File

@ -18,33 +18,51 @@
API routes for drawings. API routes for drawings.
""" """
from fastapi import APIRouter, Response, status from fastapi import APIRouter, Depends, status
from fastapi.encoders import jsonable_encoder from fastapi.encoders import jsonable_encoder
from typing import List from typing import List
from uuid import UUID from uuid import UUID
from gns3server.controller import Controller from gns3server.controller import Controller
from gns3server.db.repositories.rbac import RbacRepository
from gns3server import schemas 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"}} responses = {404: {"model": schemas.ErrorMessage, "description": "Project or drawing not found"}}
router = APIRouter(responses=responses) 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]: async def get_drawings(project_id: UUID) -> List[schemas.Drawing]:
""" """
Return the list of all drawings for a given project. Return the list of all drawings for a given project.
Required privilege: Drawing.Audit
""" """
project = await Controller.instance().get_loaded_project(str(project_id)) project = await Controller.instance().get_loaded_project(str(project_id))
return [v.asdict() for v in project.drawings.values()] 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: async def create_drawing(project_id: UUID, drawing_data: schemas.Drawing) -> schemas.Drawing:
""" """
Create a new drawing. Create a new drawing.
Required privilege: Drawing.Allocate
""" """
project = await Controller.instance().get_loaded_project(str(project_id)) 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() 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: async def get_drawing(project_id: UUID, drawing_id: UUID) -> schemas.Drawing:
""" """
Return a drawing. Return a drawing.
Required privilege: Drawing.Audit
""" """
project = await Controller.instance().get_loaded_project(str(project_id)) 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() 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: async def update_drawing(project_id: UUID, drawing_id: UUID, drawing_data: schemas.Drawing) -> schemas.Drawing:
""" """
Update a drawing. Update a drawing.
Required privilege: Drawing.Modify
""" """
project = await Controller.instance().get_loaded_project(str(project_id)) 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() return drawing.asdict()
@router.delete("/{drawing_id}", status_code=status.HTTP_204_NO_CONTENT) @router.delete(
async def delete_drawing(project_id: UUID, drawing_id: UUID) -> None: "/{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. Delete a drawing.
Required privilege: Drawing.Allocate
""" """
project = await Controller.instance().get_loaded_project(str(project_id)) project = await Controller.instance().get_loaded_project(str(project_id))
await project.delete_drawing(str(drawing_id)) await project.delete_drawing(str(drawing_id))
await rbac_repo.delete_all_ace_starting_with_path(f"/drawings/{drawing_id}")

View File

@ -19,7 +19,7 @@
API routes for user groups. API routes for user groups.
""" """
from fastapi import APIRouter, Depends, Response, status from fastapi import APIRouter, Depends, status
from uuid import UUID from uuid import UUID
from typing import List 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.users import UsersRepository
from gns3server.db.repositories.rbac import RbacRepository from gns3server.db.repositories.rbac import RbacRepository
from .dependencies.rbac import has_privilege
from .dependencies.database import get_repository from .dependencies.database import get_repository
import logging import logging
@ -42,12 +44,18 @@ log = logging.getLogger(__name__)
router = APIRouter() 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( async def get_user_groups(
users_repo: UsersRepository = Depends(get_repository(UsersRepository)) users_repo: UsersRepository = Depends(get_repository(UsersRepository))
) -> List[schemas.UserGroup]: ) -> List[schemas.UserGroup]:
""" """
Get all user groups. Get all user groups.
Required privilege: Group.Audit
""" """
return await users_repo.get_user_groups() return await users_repo.get_user_groups()
@ -56,7 +64,8 @@ async def get_user_groups(
@router.post( @router.post(
"", "",
response_model=schemas.UserGroup, 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( async def create_user_group(
user_group_create: schemas.UserGroupCreate, user_group_create: schemas.UserGroupCreate,
@ -64,6 +73,8 @@ async def create_user_group(
) -> schemas.UserGroup: ) -> schemas.UserGroup:
""" """
Create a new user group. Create a new user group.
Required privilege: Group.Allocate
""" """
if await users_repo.get_user_group_by_name(user_group_create.name): 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) 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( async def get_user_group(
user_group_id: UUID, user_group_id: UUID,
users_repo: UsersRepository = Depends(get_repository(UsersRepository)), users_repo: UsersRepository = Depends(get_repository(UsersRepository)),
) -> schemas.UserGroup: ) -> schemas.UserGroup:
""" """
Get an user group. Get a user group.
Required privilege: Group.Audit
""" """
user_group = await users_repo.get_user_group(user_group_id) user_group = await users_repo.get_user_group(user_group_id)
@ -87,14 +104,20 @@ async def get_user_group(
return 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( async def update_user_group(
user_group_id: UUID, user_group_id: UUID,
user_group_update: schemas.UserGroupUpdate, user_group_update: schemas.UserGroupUpdate,
users_repo: UsersRepository = Depends(get_repository(UsersRepository)) users_repo: UsersRepository = Depends(get_repository(UsersRepository))
) -> schemas.UserGroup: ) -> schemas.UserGroup:
""" """
Update an user group. Update a user group.
Required privilege: Group.Modify
""" """
user_group = await users_repo.get_user_group(user_group_id) user_group = await users_repo.get_user_group(user_group_id)
if not user_group: if not user_group:
@ -108,14 +131,18 @@ async def update_user_group(
@router.delete( @router.delete(
"/{user_group_id}", "/{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( async def delete_user_group(
user_group_id: UUID, user_group_id: UUID,
users_repo: UsersRepository = Depends(get_repository(UsersRepository)), users_repo: UsersRepository = Depends(get_repository(UsersRepository)),
rbac_repo: RbacRepository = Depends(get_repository(RbacRepository))
) -> None: ) -> None:
""" """
Delete an user group Delete a user group.
Required privilege: Group.Allocate
""" """
user_group = await users_repo.get_user_group(user_group_id) 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) success = await users_repo.delete_user_group(user_group_id)
if not success: if not success:
raise ControllerError(f"User group '{user_group_id}' could not be deleted") 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( async def get_user_group_members(
user_group_id: UUID, user_group_id: UUID,
users_repo: UsersRepository = Depends(get_repository(UsersRepository)) users_repo: UsersRepository = Depends(get_repository(UsersRepository))
) -> List[schemas.User]: ) -> List[schemas.User]:
""" """
Get all user group members. Get all user group members.
Required privilege: Group.Audit
""" """
return await users_repo.get_user_group_members(user_group_id) return await users_repo.get_user_group_members(user_group_id)
@ -144,7 +178,8 @@ async def get_user_group_members(
@router.put( @router.put(
"/{user_group_id}/members/{user_id}", "/{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( async def add_member_to_group(
user_group_id: UUID, user_group_id: UUID,
@ -152,7 +187,9 @@ async def add_member_to_group(
users_repo: UsersRepository = Depends(get_repository(UsersRepository)) users_repo: UsersRepository = Depends(get_repository(UsersRepository))
) -> None: ) -> None:
""" """
Add member to an user group. Add member to a user group.
Required privilege: Group.Modify
""" """
user = await users_repo.get_user(user_id) user = await users_repo.get_user(user_id)
@ -166,7 +203,8 @@ async def add_member_to_group(
@router.delete( @router.delete(
"/{user_group_id}/members/{user_id}", "/{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( async def remove_member_from_group(
user_group_id: UUID, user_group_id: UUID,
@ -174,7 +212,9 @@ async def remove_member_from_group(
users_repo: UsersRepository = Depends(get_repository(UsersRepository)), users_repo: UsersRepository = Depends(get_repository(UsersRepository)),
) -> None: ) -> None:
""" """
Remove member from an user group. Remove member from a user group.
Required privilege: Group.Modify
""" """
user = await users_repo.get_user(user_id) user = await users_repo.get_user(user_id)
@ -184,61 +224,3 @@ async def remove_member_from_group(
user_group = await users_repo.remove_member_from_user_group(user_group_id, user) user_group = await users_repo.remove_member_from_user_group(user_group_id, user)
if not user_group: if not user_group:
raise ControllerNotFoundError(f"User group '{user_group_id}' not found") 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")

View File

@ -22,7 +22,7 @@ import os
import logging import logging
import urllib.parse import urllib.parse
from fastapi import APIRouter, Request, Response, Depends, status from fastapi import APIRouter, Request, Depends, status
from starlette.requests import ClientDisconnect from starlette.requests import ClientDisconnect
from sqlalchemy.orm.exc import MultipleResultsFound from sqlalchemy.orm.exc import MultipleResultsFound
from typing import List, Optional 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.authentication import get_current_active_user
from .dependencies.database import get_repository from .dependencies.database import get_repository
from .dependencies.rbac import has_privilege
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
router = APIRouter() 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( async def get_images(
images_repo: ImagesRepository = Depends(get_repository(ImagesRepository)), images_repo: ImagesRepository = Depends(get_repository(ImagesRepository)),
image_type: Optional[schemas.ImageType] = None image_type: Optional[schemas.ImageType] = None
) -> List[schemas.Image]: ) -> List[schemas.Image]:
""" """
Return all images. Return all images.
Required privilege: Image.Audit
""" """
return await images_repo.get_images(image_type) 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( async def upload_image(
image_path: str, image_path: str,
request: Request, request: Request,
@ -76,6 +88,8 @@ async def upload_image(
Example: curl -X POST http://host:port/v3/images/upload/my_image_name.qcow2 \ Example: curl -X POST http://host:port/v3/images/upload/my_image_name.qcow2 \
-H 'Authorization: Bearer <token>' --data-binary @"/path/to/image.qcow2" -H 'Authorization: Bearer <token>' --data-binary @"/path/to/image.qcow2"
Required privilege: Image.Allocate
""" """
image_path = urllib.parse.unquote(image_path) image_path = urllib.parse.unquote(image_path)
@ -110,13 +124,19 @@ async def upload_image(
return 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( async def get_image(
image_path: str, image_path: str,
images_repo: ImagesRepository = Depends(get_repository(ImagesRepository)), images_repo: ImagesRepository = Depends(get_repository(ImagesRepository)),
) -> schemas.Image: ) -> schemas.Image:
""" """
Return an image. Return an image.
Required privilege: Image.Audit
""" """
image_path = urllib.parse.unquote(image_path) image_path = urllib.parse.unquote(image_path)
@ -126,13 +146,19 @@ async def get_image(
return 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( async def delete_image(
image_path: str, image_path: str,
images_repo: ImagesRepository = Depends(get_repository(ImagesRepository)), images_repo: ImagesRepository = Depends(get_repository(ImagesRepository)),
) -> None: ) -> None:
""" """
Delete an image. Delete an image.
Required privilege: Image.Allocate
""" """
image_path = urllib.parse.unquote(image_path) 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") 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( async def prune_images(
images_repo: ImagesRepository = Depends(get_repository(ImagesRepository)), images_repo: ImagesRepository = Depends(get_repository(ImagesRepository)),
) -> None: ) -> None:
""" """
Prune images not attached to any template. Prune images not attached to any template.
Required privilege: Image.Allocate
""" """
await images_repo.prune_images() await images_repo.prune_images()

View File

@ -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 # 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 # it under the terms of the GNU General Public License as published by
@ -21,7 +21,7 @@ API routes for links.
import multidict import multidict
import aiohttp import aiohttp
from fastapi import APIRouter, Depends, Request, Response, status from fastapi import APIRouter, Depends, Request, status
from fastapi.responses import StreamingResponse from fastapi.responses import StreamingResponse
from fastapi.encoders import jsonable_encoder from fastapi.encoders import jsonable_encoder
from typing import List from typing import List
@ -29,10 +29,14 @@ from uuid import UUID
from gns3server.controller import Controller from gns3server.controller import Controller
from gns3server.controller.controller_error import ControllerError from gns3server.controller.controller_error import ControllerError
from gns3server.db.repositories.rbac import RbacRepository
from gns3server.controller.link import Link from gns3server.controller.link import Link
from gns3server.utils.http_client import HTTPClient from gns3server.utils.http_client import HTTPClient
from gns3server import schemas from gns3server import schemas
from .dependencies.database import get_repository
from .dependencies.rbac import has_privilege
import logging import logging
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
@ -52,10 +56,17 @@ async def dep_link(project_id: UUID, link_id: UUID) -> Link:
return 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]: async def get_links(project_id: UUID) -> List[schemas.Link]:
""" """
Return all links for a given project. Return all links for a given project.
Required privilege: Link.Audit
""" """
project = await Controller.instance().get_loaded_project(str(project_id)) 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"}, 404: {"model": schemas.ErrorMessage, "description": "Could not find project"},
409: {"model": schemas.ErrorMessage, "description": "Could not create link"}, 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: async def create_link(project_id: UUID, link_data: schemas.LinkCreate) -> schemas.Link:
""" """
Create a new link. Create a new link.
Required privilege: Link.Allocate
""" """
project = await Controller.instance().get_loaded_project(str(project_id)) 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() 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]: async def get_filters(link: Link = Depends(dep_link)) -> List[dict]:
""" """
Return all filters available for a given link. Return all filters available for a given link.
Required privilege: Link.Audit
""" """
return link.available_filters() 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: async def get_link(link: Link = Depends(dep_link)) -> schemas.Link:
""" """
Return a link. Return a link.
Required privilege: Link.Audit
""" """
return link.asdict() 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: async def update_link(link_data: schemas.LinkUpdate, link: Link = Depends(dep_link)) -> schemas.Link:
""" """
Update a link. Update a link.
Required privilege: Link.Modify
""" """
link_data = jsonable_encoder(link_data, exclude_unset=True) 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() return link.asdict()
@router.delete("/{link_id}", status_code=status.HTTP_204_NO_CONTENT) @router.delete(
async def delete_link(project_id: UUID, link: Link = Depends(dep_link)) -> None: "/{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. Delete a link.
Required privilege: Link.Allocate
""" """
project = await Controller.instance().get_loaded_project(str(project_id)) project = await Controller.instance().get_loaded_project(str(project_id))
await project.delete_link(link.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: async def reset_link(link: Link = Depends(dep_link)) -> schemas.Link:
""" """
Reset a link. Reset a link.
Required privilege: Link.Modify
""" """
await link.reset() await link.reset()
return link.asdict() 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: async def start_capture(capture_data: dict, link: Link = Depends(dep_link)) -> schemas.Link:
""" """
Start packet capture on the link. Start packet capture on the link.
Required privilege: Link.Capture
""" """
await link.start_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() 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: async def stop_capture(link: Link = Depends(dep_link)) -> None:
""" """
Stop packet capture on the link. Stop packet capture on the link.
Required privilege: Link.Capture
""" """
await link.stop_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: async def stream_pcap(request: Request, link: Link = Depends(dep_link)) -> StreamingResponse:
""" """
Stream the PCAP capture file from compute. Stream the PCAP capture file from compute.
Required privilege: Link.Capture
""" """
if not link.capturing: if not link.capturing:

View File

@ -34,8 +34,12 @@ from gns3server.controller.project import Project
from gns3server.utils import force_unix_path from gns3server.utils import force_unix_path
from gns3server.utils.http_client import HTTPClient from gns3server.utils.http_client import HTTPClient
from gns3server.controller.controller_error import ControllerForbiddenError, ControllerBadRequestError from gns3server.controller.controller_error import ControllerForbiddenError, ControllerBadRequestError
from gns3server.db.repositories.rbac import RbacRepository
from gns3server import schemas from gns3server import schemas
from .dependencies.database import get_repository
from .dependencies.rbac import has_privilege, has_privilege_on_websocket
import logging import logging
log = logging.getLogger(__name__) 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"}, 404: {"model": schemas.ErrorMessage, "description": "Could not find project"},
409: {"model": schemas.ErrorMessage, "description": "Could not create node"}, 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: async def create_node(node_data: schemas.NodeCreate, project: Project = Depends(dep_project)) -> schemas.Node:
""" """
Create a new node. Create a new node.
Required privilege: Node.Allocate
""" """
controller = Controller.instance() controller = Controller.instance()
@ -121,65 +128,89 @@ async def create_node(node_data: schemas.NodeCreate, project: Project = Depends(
return node.asdict() return node.asdict()
@router.get("", response_model=List[schemas.Node], response_model_exclude_unset=True) @router.get(
async def get_nodes(project: Project = Depends(dep_project)) -> List[schemas.Node]: "",
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. Return all nodes belonging to a given project.
Required privilege: Node.Audit
""" """
return [v.asdict() for v in project.nodes.values()] 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.PowerMgmt"))])
async def start_all_nodes(project: Project = Depends(dep_project)) -> None: async def start_all_nodes(project: Project = Depends(dep_project)) -> None:
""" """
Start all nodes belonging to a given project. Start all nodes belonging to a given project.
Required privilege: Node.PowerMgmt
""" """
await project.start_all() 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: async def stop_all_nodes(project: Project = Depends(dep_project)) -> None:
""" """
Stop all nodes belonging to a given project. Stop all nodes belonging to a given project.
Required privilege: Node.PowerMgmt
""" """
await project.stop_all() 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: async def suspend_all_nodes(project: Project = Depends(dep_project)) -> None:
""" """
Suspend all nodes belonging to a given project. Suspend all nodes belonging to a given project.
Required privilege: Node.PowerMgmt
""" """
await project.suspend_all() 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: async def reload_all_nodes(project: Project = Depends(dep_project)) -> None:
""" """
Reload all nodes belonging to a given project. Reload all nodes belonging to a given project.
Required privilege: Node.PowerMgmt
""" """
await project.stop_all() await project.stop_all()
await project.start_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: def get_node(node: Node = Depends(dep_node)) -> schemas.Node:
""" """
Return a node from a given project. Return a node from a given project.
Required privilege: Node.Audit
""" """
return node.asdict() 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: async def update_node(node_data: schemas.NodeUpdate, node: Node = Depends(dep_node)) -> schemas.Node:
""" """
Update a node. Update a node.
Required privilege: Node.Modify
""" """
node_data = jsonable_encoder(node_data, exclude_unset=True) 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}", "/{node_id}",
status_code=status.HTTP_204_NO_CONTENT, status_code=status.HTTP_204_NO_CONTENT,
responses={**responses, 409: {"model": schemas.ErrorMessage, "description": "Cannot delete node"}}, 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. Delete a node from a project.
Required privilege: Node.Allocate
""" """
await project.delete_node(str(node_id)) 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: async def duplicate_node(duplicate_data: schemas.NodeDuplicate, node: Node = Depends(dep_node)) -> schemas.Node:
""" """
Duplicate a 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) new_node = await node.project.duplicate_node(node, duplicate_data.x, duplicate_data.y, duplicate_data.z)
return new_node.asdict() 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: async def start_node(start_data: dict, node: Node = Depends(dep_node)) -> None:
""" """
Start a node. Start a node.
Required privilege: Node.PowerMgmt
""" """
await node.start(data=start_data) 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: async def stop_node(node: Node = Depends(dep_node)) -> None:
""" """
Stop a node. Stop a node.
Required privilege: Node.PowerMgmt
""" """
await node.stop() 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: async def suspend_node(node: Node = Depends(dep_node)) -> None:
""" """
Suspend a node. Suspend a node.
Required privilege: Node.PowerMgmt
""" """
await node.suspend() 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: async def reload_node(node: Node = Depends(dep_node)) -> None:
""" """
Reload a node. Reload a node.
Required privilege: Node.PowerMgmt
""" """
await node.reload() 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: async def isolate_node(node: Node = Depends(dep_node)) -> None:
""" """
Isolate a node (suspend all attached links). Isolate a node (suspend all attached links).
Required privilege: Link.Modify
""" """
for link in node.links: for link in node.links:
await link.update_suspend(True) 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: async def unisolate_node(node: Node = Depends(dep_node)) -> None:
""" """
Un-isolate a node (resume all attached suspended links). Un-isolate a node (resume all attached suspended links).
Required privilege: Link.Modify
""" """
for link in node.links: for link in node.links:
await link.update_suspend(False) 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]: async def get_node_links(node: Node = Depends(dep_node)) -> List[schemas.Link]:
""" """
Return all the links connected to a node. Return all the links connected to a node.
Required privilege: Link.Audit
""" """
links = [] links = []
@ -284,10 +372,12 @@ async def get_node_links(node: Node = Depends(dep_node)) -> List[schemas.Link]:
return links 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: async def auto_idlepc(node: Node = Depends(dep_node)) -> dict:
""" """
Compute an Idle-PC value for a Dynamips node Compute an Idle-PC value for a Dynamips node
Required privilege: Node.Audit
""" """
if node.node_type != "dynamips": 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() 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]: async def idlepc_proposals(node: Node = Depends(dep_node)) -> List[str]:
""" """
Compute a list of potential idle-pc values for a Dynamips node Compute a list of potential idle-pc values for a Dynamips node
Required privilege: Node.Audit
""" """
if node.node_type != "dynamips": 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() 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( async def create_disk_image(
disk_name: str, disk_name: str,
disk_data: schemas.QemuDiskImageCreate, disk_data: schemas.QemuDiskImageCreate,
@ -314,6 +410,8 @@ async def create_disk_image(
) -> None: ) -> None:
""" """
Create a Qemu disk image. Create a Qemu disk image.
Required privilege: Node.Allocate
""" """
if node.node_type != "qemu": 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)) 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( async def update_disk_image(
disk_name: str, disk_name: str,
disk_data: schemas.QemuDiskImageUpdate, disk_data: schemas.QemuDiskImageUpdate,
@ -329,6 +431,8 @@ async def update_disk_image(
) -> None: ) -> None:
""" """
Update a Qemu disk image. Update a Qemu disk image.
Required privilege: Node.Allocate
""" """
if node.node_type != "qemu": 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)) 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( async def delete_disk_image(
disk_name: str, disk_name: str,
node: Node = Depends(dep_node) node: Node = Depends(dep_node)
) -> None: ) -> None:
""" """
Delete a Qemu disk image. Delete a Qemu disk image.
Required privilege: Node.Allocate
""" """
if node.node_type != "qemu": if node.node_type != "qemu":
@ -350,10 +460,12 @@ async def delete_disk_image(
await node.delete(f"/disk_image/{disk_name}") 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: 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) 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) 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)): async def post_file(file_path: str, request: Request, node: Node = Depends(dep_node)):
""" """
Write a file in the node directory. Write a file in the node directory.
Required privilege: Node.Modify
""" """
path = force_unix_path(file_path) 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) # 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: async def ws_console(websocket: WebSocket, node: Node = Depends(dep_node)) -> None:
""" """
WebSocket console. WebSocket console.
Required privilege: Node.Console
""" """
compute = node.compute 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}") 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: async def reset_console_all_nodes(project: Project = Depends(dep_project)) -> None:
""" """
Reset console for all nodes belonging to the project. Reset console for all nodes belonging to the project.
Required privilege: Node.Console
""" """
await project.reset_console_all() 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: 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") await node.post("/console/reset")

View File

@ -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()

View File

@ -45,11 +45,11 @@ 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.controller.export_project import export_project as export_controller_project
from gns3server.utils.asyncio import aiozipstream from gns3server.utils.asyncio import aiozipstream
from gns3server.utils.path import is_safe_path 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.templates import TemplatesRepository
from gns3server.db.repositories.rbac import RbacRepository
from gns3server.services.templates import TemplatesService from gns3server.services.templates import TemplatesService
from .dependencies.authentication import get_current_active_user, get_current_active_user_from_websocket from .dependencies.rbac import has_privilege, has_privilege_on_websocket
from .dependencies.database import get_repository from .dependencies.database import get_repository
responses = {404: {"model": schemas.ErrorMessage, "description": "Could not find project"}} responses = {404: {"model": schemas.ErrorMessage, "description": "Could not find project"}}
@ -66,29 +66,21 @@ def dep_project(project_id: UUID) -> Project:
return project return project
CHUNK_SIZE = 1024 * 8 # 8KB @router.get(
"",
response_model=List[schemas.Project],
@router.get("", response_model=List[schemas.Project], response_model_exclude_unset=True) response_model_exclude_unset=True,
async def get_projects( dependencies=[Depends(has_privilege("Project.Audit"))]
current_user: schemas.User = Depends(get_current_active_user), )
rbac_repo: RbacRepository = Depends(get_repository(RbacRepository)) async def get_projects() -> List[schemas.Project]:
) -> List[schemas.Project]:
""" """
Return all projects. Return all projects.
Required privilege: Project.Audit
""" """
controller = Controller.instance() controller = Controller.instance()
if current_user.is_superadmin: return [p.asdict() for p in controller.projects.values()]
return [p.asdict() for p in controller.projects.values()]
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:
user_projects.append(project.asdict())
return user_projects
@router.post( @router.post(
@ -97,26 +89,28 @@ async def get_projects(
response_model=schemas.Project, response_model=schemas.Project,
response_model_exclude_unset=True, response_model_exclude_unset=True,
responses={409: {"model": schemas.ErrorMessage, "description": "Could not create project"}}, responses={409: {"model": schemas.ErrorMessage, "description": "Could not create project"}},
dependencies=[Depends(has_privilege("Project.Allocate"))]
) )
async def create_project( async def create_project(
project_data: schemas.ProjectCreate, project_data: schemas.ProjectCreate,
current_user: schemas.User = Depends(get_current_active_user),
rbac_repo: RbacRepository = Depends(get_repository(RbacRepository))
) -> schemas.Project: ) -> schemas.Project:
""" """
Create a new project. Create a new project.
Required privilege: Project.Allocate
""" """
controller = Controller.instance() controller = Controller.instance()
project = await controller.add_project(**jsonable_encoder(project_data, exclude_unset=True)) 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() return project.asdict()
@router.get("/{project_id}", response_model=schemas.Project, dependencies=[Depends(get_current_active_user)]) @router.get("/{project_id}", response_model=schemas.Project, dependencies=[Depends(has_privilege("Project.Audit"))])
def get_project(project: Project = Depends(dep_project)) -> schemas.Project: def get_project(project: Project = Depends(dep_project)) -> schemas.Project:
""" """
Return a project. Return a project.
Required privilege: Project.Audit
""" """
return project.asdict() return project.asdict()
@ -126,7 +120,7 @@ def get_project(project: Project = Depends(dep_project)) -> schemas.Project:
"/{project_id}", "/{project_id}",
response_model=schemas.Project, response_model=schemas.Project,
response_model_exclude_unset=True, response_model_exclude_unset=True,
dependencies=[Depends(get_current_active_user)] dependencies=[Depends(has_privilege("Project.Modify"))]
) )
async def update_project( async def update_project(
project_data: schemas.ProjectUpdate, project_data: schemas.ProjectUpdate,
@ -134,6 +128,8 @@ async def update_project(
) -> schemas.Project: ) -> schemas.Project:
""" """
Update a project. Update a project.
Required privilege: Project.Modify
""" """
await project.update(**jsonable_encoder(project_data, exclude_unset=True)) await project.update(**jsonable_encoder(project_data, exclude_unset=True))
@ -143,26 +139,30 @@ async def update_project(
@router.delete( @router.delete(
"/{project_id}", "/{project_id}",
status_code=status.HTTP_204_NO_CONTENT, status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(get_current_active_user)] dependencies=[Depends(has_privilege("Project.Allocate"))]
) )
async def delete_project( async def delete_project(
project: Project = Depends(dep_project), project: Project = Depends(dep_project),
rbac_repo: RbacRepository = Depends(get_repository(RbacRepository)) rbac_repo: RbacRepository = Depends(get_repository(RbacRepository)),
) -> None: ) -> None:
""" """
Delete a project. Delete a project.
Required privilege: Project.Allocate
""" """
controller = Controller.instance() controller = Controller.instance()
await project.delete() await project.delete()
controller.remove_project(project) controller.remove_project(project)
await rbac_repo.delete_all_permissions_with_path(f"/projects/{project.id}") await rbac_repo.delete_all_ace_starting_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: def get_project_stats(project: Project = Depends(dep_project)) -> dict:
""" """
Return a project statistics. Return a project statistics.
Required privilege: Project.Audit
""" """
return project.stats() return project.stats()
@ -172,11 +172,13 @@ def get_project_stats(project: Project = Depends(dep_project)) -> dict:
"/{project_id}/close", "/{project_id}/close",
status_code=status.HTTP_204_NO_CONTENT, status_code=status.HTTP_204_NO_CONTENT,
responses={**responses, 409: {"model": schemas.ErrorMessage, "description": "Could not close project"}}, 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: async def close_project(project: Project = Depends(dep_project)) -> None:
""" """
Close a project. Close a project.
Required privilege: Project.Allocate
""" """
await project.close() await project.close()
@ -187,11 +189,13 @@ async def close_project(project: Project = Depends(dep_project)) -> None:
status_code=status.HTTP_201_CREATED, status_code=status.HTTP_201_CREATED,
response_model=schemas.Project, response_model=schemas.Project,
responses={**responses, 409: {"model": schemas.ErrorMessage, "description": "Could not open 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: async def open_project(project: Project = Depends(dep_project)) -> schemas.Project:
""" """
Open a project. Open a project.
Required privilege: Project.Allocate
""" """
await project.open() await project.open()
@ -203,11 +207,13 @@ async def open_project(project: Project = Depends(dep_project)) -> schemas.Proje
status_code=status.HTTP_201_CREATED, status_code=status.HTTP_201_CREATED,
response_model=schemas.Project, response_model=schemas.Project,
responses={**responses, 409: {"model": schemas.ErrorMessage, "description": "Could not load 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: async def load_project(path: str = Body(..., embed=True)) -> schemas.Project:
""" """
Load a project (local server only). Load a project (local server only).
Required privilege: Project.Allocate
""" """
controller = Controller.instance() controller = Controller.instance()
@ -216,10 +222,12 @@ async def load_project(path: str = Body(..., embed=True)) -> schemas.Project:
return project.asdict() 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: async def project_http_notifications(project_id: UUID) -> StreamingResponse:
""" """
Receive project notifications about the controller from HTTP stream. Receive project notifications about the controller from HTTP stream.
Required privilege: Project.Audit
""" """
from gns3server.api.server import app from gns3server.api.server import app
@ -252,10 +260,12 @@ async def project_http_notifications(project_id: UUID) -> StreamingResponse:
async def project_ws_notifications( async def project_ws_notifications(
project_id: UUID, project_id: UUID,
websocket: WebSocket, 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: ) -> None:
""" """
Receive project notifications about the controller from WebSocket. Receive project notifications about the controller from WebSocket.
Required privilege: Project.Audit
""" """
if current_user is None: if current_user is None:
@ -288,7 +298,7 @@ async def project_ws_notifications(
await project.close() 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( async def export_project(
project: Project = Depends(dep_project), project: Project = Depends(dep_project),
include_snapshots: bool = False, include_snapshots: bool = False,
@ -299,6 +309,8 @@ async def export_project(
) -> StreamingResponse: ) -> StreamingResponse:
""" """
Export a project as a portable archive. Export a project as a portable archive.
Required privilege: Project.Audit
""" """
compression_query = compression.lower() compression_query = compression.lower()
@ -345,7 +357,7 @@ async def export_project(
log.info(f"Project '{project.name}' exported in {time.time() - begin:.4f} seconds") 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 # RuntimeError: something was wrong during the zip process
except (ValueError, OSError, RuntimeError) as e: except (ValueError, OSError, RuntimeError) as e:
raise ConnectionError(f"Cannot export project: {e}") raise ConnectionError(f"Cannot export project: {e}")
@ -358,7 +370,7 @@ async def export_project(
"/{project_id}/import", "/{project_id}/import",
status_code=status.HTTP_201_CREATED, status_code=status.HTTP_201_CREATED,
response_model=schemas.Project, response_model=schemas.Project,
dependencies=[Depends(get_current_active_user)] dependencies=[Depends(has_privilege("Project.Allocate"))]
) )
async def import_project( async def import_project(
project_id: UUID, project_id: UUID,
@ -367,6 +379,8 @@ async def import_project(
) -> schemas.Project: ) -> schemas.Project:
""" """
Import a project from a portable archive. Import a project from a portable archive.
Required privilege: Project.Allocate
""" """
controller = Controller.instance() controller = Controller.instance()
@ -394,30 +408,31 @@ async def import_project(
status_code=status.HTTP_201_CREATED, status_code=status.HTTP_201_CREATED,
response_model=schemas.Project, response_model=schemas.Project,
responses={**responses, 409: {"model": schemas.ErrorMessage, "description": "Could not duplicate 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( async def duplicate_project(
project_data: schemas.ProjectDuplicate, project_data: schemas.ProjectDuplicate,
project: Project = Depends(dep_project), project: Project = Depends(dep_project)
current_user: schemas.User = Depends(get_current_active_user),
rbac_repo: RbacRepository = Depends(get_repository(RbacRepository))
) -> schemas.Project: ) -> schemas.Project:
""" """
Duplicate a project. Duplicate a project.
Required privilege: Project.Allocate
""" """
reset_mac_addresses = project_data.reset_mac_addresses reset_mac_addresses = project_data.reset_mac_addresses
new_project = await project.duplicate( new_project = await project.duplicate(
name=project_data.name, reset_mac_addresses=reset_mac_addresses 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() 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: 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 return project.locked
@ -426,11 +441,13 @@ async def locked_project(project: Project = Depends(dep_project)) -> bool:
@router.post( @router.post(
"/{project_id}/lock", "/{project_id}/lock",
status_code=status.HTTP_204_NO_CONTENT, 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: async def lock_project(project: Project = Depends(dep_project)) -> None:
""" """
Lock all drawings and nodes in a given project. Lock all drawings and nodes in a given project.
Required privilege: Project.Audit
""" """
project.lock() project.lock()
@ -439,20 +456,24 @@ async def lock_project(project: Project = Depends(dep_project)) -> None:
@router.post( @router.post(
"/{project_id}/unlock", "/{project_id}/unlock",
status_code=status.HTTP_204_NO_CONTENT, 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: async def unlock_project(project: Project = Depends(dep_project)) -> None:
""" """
Unlock all drawings and nodes in a given project. Unlock all drawings and nodes in a given project.
Required privilege: Project.Modify
""" """
project.unlock() 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: async def get_file(file_path: str, project: Project = Depends(dep_project)) -> FileResponse:
""" """
Return a file from a project. Return a file from a project.
Required privilege: Project.Audit
""" """
file_path = urllib.parse.unquote(file_path) file_path = urllib.parse.unquote(file_path)
@ -472,11 +493,13 @@ async def get_file(file_path: str, project: Project = Depends(dep_project)) -> F
@router.post( @router.post(
"/{project_id}/files/{file_path:path}", "/{project_id}/files/{file_path:path}",
status_code=status.HTTP_204_NO_CONTENT, 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: async def write_file(file_path: str, request: Request, project: Project = Depends(dep_project)) -> None:
""" """
Write a file to a project. Write a file to a project.
Required privilege: Project.Modify
""" """
file_path = urllib.parse.unquote(file_path) file_path = urllib.parse.unquote(file_path)
@ -505,7 +528,7 @@ async def write_file(file_path: str, request: Request, project: Project = Depend
response_model=schemas.Node, response_model=schemas.Node,
status_code=status.HTTP_201_CREATED, status_code=status.HTTP_201_CREATED,
responses={404: {"model": schemas.ErrorMessage, "description": "Could not find project or template"}}, 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( async def create_node_from_template(
project_id: UUID, project_id: UUID,
@ -515,6 +538,8 @@ async def create_node_from_template(
) -> schemas.Node: ) -> schemas.Node:
""" """
Create a new node from a template. Create a new node from a template.
Required privilege: Node.Allocate
""" """
template = await TemplatesService(templates_repo).get_template(template_id) template = await TemplatesService(templates_repo).get_template(template_id)

View File

@ -19,7 +19,7 @@
API routes for roles. API routes for roles.
""" """
from fastapi import APIRouter, Depends, Response, status from fastapi import APIRouter, Depends, status
from uuid import UUID from uuid import UUID
from typing import List from typing import List
@ -33,6 +33,7 @@ from gns3server.controller.controller_error import (
from gns3server.db.repositories.rbac import RbacRepository from gns3server.db.repositories.rbac import RbacRepository
from .dependencies.database import get_repository from .dependencies.database import get_repository
from .dependencies.rbac import has_privilege
import logging import logging
@ -41,24 +42,37 @@ log = logging.getLogger(__name__)
router = APIRouter() 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( async def get_roles(
rbac_repo: RbacRepository = Depends(get_repository(RbacRepository)) rbac_repo: RbacRepository = Depends(get_repository(RbacRepository))
) -> List[schemas.Role]: ) -> List[schemas.Role]:
""" """
Get all roles. Get all roles.
Required privilege: Role.Audit
""" """
return await rbac_repo.get_roles() 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( async def create_role(
role_create: schemas.RoleCreate, role_create: schemas.RoleCreate,
rbac_repo: RbacRepository = Depends(get_repository(RbacRepository)) rbac_repo: RbacRepository = Depends(get_repository(RbacRepository))
) -> schemas.Role: ) -> schemas.Role:
""" """
Create a new role. Create a new role.
Required privilege: Role.Allocate
""" """
if await rbac_repo.get_role_by_name(role_create.name): 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) 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( async def get_role(
role_id: UUID, role_id: UUID,
rbac_repo: RbacRepository = Depends(get_repository(RbacRepository)), rbac_repo: RbacRepository = Depends(get_repository(RbacRepository)),
) -> schemas.Role: ) -> schemas.Role:
""" """
Get a role. Get a role.
Required privilege: Role.Audit
""" """
role = await rbac_repo.get_role(role_id) role = await rbac_repo.get_role(role_id)
@ -82,7 +102,11 @@ async def get_role(
return 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( async def update_role(
role_id: UUID, role_id: UUID,
role_update: schemas.RoleUpdate, role_update: schemas.RoleUpdate,
@ -90,6 +114,8 @@ async def update_role(
) -> schemas.Role: ) -> schemas.Role:
""" """
Update a role. Update a role.
Required privilege: Role.Modify
""" """
role = await rbac_repo.get_role(role_id) 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) 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( async def delete_role(
role_id: UUID, role_id: UUID,
rbac_repo: RbacRepository = Depends(get_repository(RbacRepository)), rbac_repo: RbacRepository = Depends(get_repository(RbacRepository)),
) -> None: ) -> None:
""" """
Delete a role. Delete a role.
Required privilege: Role.Allocate
""" """
role = await rbac_repo.get_role(role_id) role = await rbac_repo.get_role(role_id)
@ -121,59 +153,72 @@ async def delete_role(
success = await rbac_repo.delete_role(role_id) success = await rbac_repo.delete_role(role_id)
if not success: if not success:
raise ControllerError(f"Role '{role_id}' could not be deleted") 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}/permissions", response_model=List[schemas.Permission]) @router.get(
async def get_role_permissions( "/{role_id}/privileges",
response_model=List[schemas.Privilege],
dependencies=[Depends(has_privilege("Role.Audit"))]
)
async def get_role_privileges(
role_id: UUID, role_id: UUID,
rbac_repo: RbacRepository = Depends(get_repository(RbacRepository)) rbac_repo: RbacRepository = Depends(get_repository(RbacRepository))
) -> List[schemas.Permission]: ) -> List[schemas.Privilege]:
""" """
Get all role permissions. Get all role privileges.
Required privilege: Role.Audit
""" """
return await rbac_repo.get_role_permissions(role_id) return await rbac_repo.get_role_privileges(role_id)
@router.put( @router.put(
"/{role_id}/permissions/{permission_id}", "/{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_permission_to_role( async def add_privilege_to_role(
role_id: UUID, role_id: UUID,
permission_id: UUID, privilege_id: UUID,
rbac_repo: RbacRepository = Depends(get_repository(RbacRepository)) rbac_repo: RbacRepository = Depends(get_repository(RbacRepository))
) -> None: ) -> None:
""" """
Add a permission to a role. Add a privilege to a role.
Required privilege: Role.Modify
""" """
permission = await rbac_repo.get_permission(permission_id) privilege = await rbac_repo.get_privilege(privilege_id)
if not permission: if not privilege:
raise ControllerNotFoundError(f"Permission '{permission_id}' not found") 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: if not role:
raise ControllerNotFoundError(f"Role '{role_id}' not found") raise ControllerNotFoundError(f"Role '{role_id}' not found")
@router.delete( @router.delete(
"/{role_id}/permissions/{permission_id}", "/{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_permission_from_role( async def remove_privilege_from_role(
role_id: UUID, role_id: UUID,
permission_id: UUID, privilege_id: UUID,
rbac_repo: RbacRepository = Depends(get_repository(RbacRepository)), rbac_repo: RbacRepository = Depends(get_repository(RbacRepository)),
) -> None: ) -> None:
""" """
Remove member from an user group. Remove privilege from a role.
Required privilege: Role.Modify
""" """
permission = await rbac_repo.get_permission(permission_id) privilege = await rbac_repo.get_privilege(privilege_id)
if not permission: if not privilege:
raise ControllerNotFoundError(f"Permission '{permission_id}' not found") 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: if not role:
raise ControllerNotFoundError(f"Role '{role_id}' not found") raise ControllerNotFoundError(f"Role '{role_id}' not found")

View File

@ -23,14 +23,18 @@ import logging
log = logging.getLogger() log = logging.getLogger()
from fastapi import APIRouter, Depends, Response, status from fastapi import APIRouter, Depends, status
from typing import List from typing import List
from uuid import UUID from uuid import UUID
from gns3server.controller.project import Project from gns3server.controller.project import Project
from gns3server.db.repositories.rbac import RbacRepository
from gns3server import schemas from gns3server import schemas
from gns3server.controller import Controller 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"}} responses = {404: {"model": schemas.ErrorMessage, "description": "Could not find project or snapshot"}}
router = APIRouter(responses=responses) router = APIRouter(responses=responses)
@ -45,42 +49,74 @@ def dep_project(project_id: UUID) -> Project:
return 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( async def create_snapshot(
snapshot_data: schemas.SnapshotCreate, snapshot_data: schemas.SnapshotCreate,
project: Project = Depends(dep_project) project: Project = Depends(dep_project)
) -> schemas.Snapshot: ) -> schemas.Snapshot:
""" """
Create a new snapshot of a project. Create a new snapshot of a project.
Required privilege: Snapshot.Allocate
""" """
snapshot = await project.snapshot(snapshot_data.name) snapshot = await project.snapshot(snapshot_data.name)
return snapshot.asdict() 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]: def get_snapshots(project: Project = Depends(dep_project)) -> List[schemas.Snapshot]:
""" """
Return all snapshots belonging to a given project. Return all snapshots belonging to a given project.
Required privilege: Snapshot.Audit
""" """
snapshots = [s for s in project.snapshots.values()] snapshots = [s for s in project.snapshots.values()]
return [s.asdict() for s in sorted(snapshots, key=lambda s: (s.created_at, s.name))] 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) @router.delete(
async def delete_snapshot(snapshot_id: UUID, project: Project = Depends(dep_project)) -> None: "/{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. Delete a snapshot.
Required privilege: Snapshot.Allocate
""" """
await project.delete_snapshot(str(snapshot_id)) 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: async def restore_snapshot(snapshot_id: UUID, project: Project = Depends(dep_project)) -> schemas.Project:
""" """
Restore a snapshot. Restore a snapshot.
Required privilege: Snapshot.Restore
""" """
snapshot = project.get_snapshot(str(snapshot_id)) snapshot = project.get_snapshot(str(snapshot_id))

View File

@ -29,7 +29,7 @@ from gns3server.controller import Controller
from gns3server import schemas from gns3server import schemas
from gns3server.controller.controller_error import ControllerError, ControllerNotFoundError from gns3server.controller.controller_error import ControllerError, ControllerNotFoundError
from .dependencies.authentication import get_current_active_user from .dependencies.rbac import has_privilege
import logging import logging
@ -39,19 +39,28 @@ log = logging.getLogger(__name__)
router = APIRouter() router = APIRouter()
@router.get("") @router.get("", dependencies=[Depends(has_privilege("Symbol.Audit"))])
def get_symbols() -> List[dict]: def get_symbols() -> List[dict]:
"""
Return all symbols.
Required privilege: Symbol.Audit
"""
controller = Controller.instance() controller = Controller.instance()
return controller.symbols.list() return controller.symbols.list()
@router.get( @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: async def get_symbol(symbol_id: str) -> FileResponse:
""" """
Download a symbol file. Download a symbol file.
Required privilege: Symbol.Audit
""" """
controller = Controller.instance() controller = Controller.instance()
@ -65,10 +74,13 @@ async def get_symbol(symbol_id: str) -> FileResponse:
@router.get( @router.get(
"/{symbol_id:path}/dimensions", "/{symbol_id:path}/dimensions",
responses={404: {"model": schemas.ErrorMessage, "description": "Could not find symbol"}}, 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: async def get_symbol_dimensions(symbol_id: str) -> dict:
""" """
Get a symbol dimensions. Get a symbol dimensions.
Required privilege: Symbol.Audit
""" """
controller = Controller.instance() 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}") 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: def get_default_symbols() -> dict:
""" """
Return all default symbols. Return all default symbols.
Required privilege: Symbol.Audit
""" """
controller = Controller.instance() controller = Controller.instance()
@ -92,12 +106,14 @@ def get_default_symbols() -> dict:
@router.post( @router.post(
"/{symbol_id:path}/raw", "/{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: async def upload_symbol(symbol_id: str, request: Request) -> None:
""" """
Upload a symbol file. Upload a symbol file.
Required privilege: Symbol.Allocate
""" """
controller = Controller.instance() controller = Controller.instance()
@ -111,4 +127,3 @@ async def upload_symbol(symbol_id: str, request: Request) -> None:
# Reset the symbol list # Reset the symbol list
controller.symbols.list() controller.symbols.list()

View File

@ -36,6 +36,7 @@ from gns3server.db.repositories.rbac import RbacRepository
from gns3server.db.repositories.images import ImagesRepository from gns3server.db.repositories.images import ImagesRepository
from .dependencies.authentication import get_current_active_user from .dependencies.authentication import get_current_active_user
from .dependencies.rbac import has_privilege
from .dependencies.database import get_repository from .dependencies.database import get_repository
responses = {404: {"model": schemas.ErrorMessage, "description": "Could not find template"}} responses = {404: {"model": schemas.ErrorMessage, "description": "Could not find template"}}
@ -43,24 +44,32 @@ responses = {404: {"model": schemas.ErrorMessage, "description": "Could not find
router = APIRouter(responses=responses) 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( async def create_template(
template_create: schemas.TemplateCreate, template_create: schemas.TemplateCreate,
templates_repo: TemplatesRepository = Depends(get_repository(TemplatesRepository)), templates_repo: TemplatesRepository = Depends(get_repository(TemplatesRepository))
current_user: schemas.User = Depends(get_current_active_user),
rbac_repo: RbacRepository = Depends(get_repository(RbacRepository))
) -> schemas.Template: ) -> schemas.Template:
""" """
Create a new template. Create a new template.
Required privilege: Template.Allocate
""" """
template = await TemplatesService(templates_repo).create_template(template_create) 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 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( async def get_template(
template_id: UUID, template_id: UUID,
request: Request, request: Request,
@ -69,6 +78,8 @@ async def get_template(
) -> schemas.Template: ) -> schemas.Template:
""" """
Return a template. Return a template.
Required privilege: Template.Audit
""" """
request_etag = request.headers.get("If-None-Match", "") request_etag = request.headers.get("If-None-Match", "")
@ -82,7 +93,12 @@ async def get_template(
return 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( async def update_template(
template_id: UUID, template_id: UUID,
template_update: schemas.TemplateUpdate, template_update: schemas.TemplateUpdate,
@ -90,12 +106,18 @@ async def update_template(
) -> schemas.Template: ) -> schemas.Template:
""" """
Update a template. Update a template.
Required privilege: Template.Modify
""" """
return await TemplatesService(templates_repo).update_template(template_id, template_update) 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( async def delete_template(
template_id: UUID, template_id: UUID,
prune_images: Optional[bool] = False, prune_images: Optional[bool] = False,
@ -105,15 +127,22 @@ async def delete_template(
) -> None: ) -> None:
""" """
Delete a template. Delete a template.
Required privilege: Template.Allocate
""" """
await TemplatesService(templates_repo).delete_template(template_id) 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: if prune_images:
await images_repo.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( async def get_templates(
templates_repo: TemplatesRepository = Depends(get_repository(TemplatesRepository)), templates_repo: TemplatesRepository = Depends(get_repository(TemplatesRepository)),
current_user: schemas.User = Depends(get_current_active_user), current_user: schemas.User = Depends(get_current_active_user),
@ -121,6 +150,8 @@ async def get_templates(
) -> List[schemas.Template]: ) -> List[schemas.Template]:
""" """
Return all templates. Return all templates.
Required privilege: Template.Audit
""" """
templates = await TemplatesService(templates_repo).get_templates() templates = await TemplatesService(templates_repo).get_templates()
@ -129,27 +160,31 @@ async def get_templates(
else: else:
user_templates = [] user_templates = []
for template in templates: for template in templates:
if template.get("builtin") is True: # if template.get("builtin") is True:
user_templates.append(template) # user_templates.append(template)
continue # continue
template_id = template.get("template_id") # template_id = template.get("template_id")
authorized = await rbac_repo.check_user_is_authorized( # authorized = await rbac_repo.check_user_is_authorized(
current_user.user_id, "GET", f"/templates/{template_id}") # current_user.user_id, "GET", f"/templates/{template_id}")
if authorized: # if authorized:
user_templates.append(template) user_templates.append(template)
return user_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( async def duplicate_template(
template_id: UUID, templates_repo: TemplatesRepository = Depends(get_repository(TemplatesRepository)), 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))
) -> schemas.Template: ) -> schemas.Template:
""" """
Duplicate a template. Duplicate a template.
Required privilege: Template.Allocate
""" """
template = await TemplatesService(templates_repo).duplicate_template(template_id) 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 return template

View File

@ -1,6 +1,6 @@
#!/usr/bin/env python #!/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 # 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 # 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.authentication import get_current_active_user
from .dependencies.database import get_repository from .dependencies.database import get_repository
from .dependencies.rbac import has_privilege
import logging import logging
@ -115,12 +116,18 @@ async def update_logged_in_user(
return await users_repo.update_user(current_user.user_id, user_update) 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( async def get_users(
users_repo: UsersRepository = Depends(get_repository(UsersRepository)) users_repo: UsersRepository = Depends(get_repository(UsersRepository))
) -> List[schemas.User]: ) -> List[schemas.User]:
""" """
Get all users. Get all users.
Required privilege: User.Audit
""" """
return await users_repo.get_users() return await users_repo.get_users()
@ -129,8 +136,8 @@ async def get_users(
@router.post( @router.post(
"", "",
response_model=schemas.User, 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( async def create_user(
user_create: schemas.UserCreate, user_create: schemas.UserCreate,
@ -138,6 +145,8 @@ async def create_user(
) -> schemas.User: ) -> schemas.User:
""" """
Create a new user. Create a new user.
Required privilege: User.Allocate
""" """
if await users_repo.get_user_by_username(user_create.username): 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) 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( async def get_user(
user_id: UUID, user_id: UUID,
users_repo: UsersRepository = Depends(get_repository(UsersRepository)), users_repo: UsersRepository = Depends(get_repository(UsersRepository)),
) -> schemas.User: ) -> schemas.User:
""" """
Get an user. Get a user.
Required privilege: User.Audit
""" """
user = await users_repo.get_user(user_id) user = await users_repo.get_user(user_id)
@ -164,14 +179,20 @@ async def get_user(
return 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( async def update_user(
user_id: UUID, user_id: UUID,
user_update: schemas.UserUpdate, user_update: schemas.UserUpdate,
users_repo: UsersRepository = Depends(get_repository(UsersRepository)) users_repo: UsersRepository = Depends(get_repository(UsersRepository))
) -> schemas.User: ) -> schemas.User:
""" """
Update an user. Update a user.
Required privilege: User.Modify
""" """
if user_update.username and await users_repo.get_user_by_username(user_update.username): 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( @router.delete(
"/{user_id}", "/{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( async def delete_user(
user_id: UUID, user_id: UUID,
users_repo: UsersRepository = Depends(get_repository(UsersRepository)), users_repo: UsersRepository = Depends(get_repository(UsersRepository)),
rbac_repo: RbacRepository = Depends(get_repository(RbacRepository))
) -> None: ) -> None:
""" """
Delete an user. Delete a user.
Required privilege: User.Allocate
""" """
user = await users_repo.get_user(user_id) user = await users_repo.get_user(user_id)
@ -209,12 +233,13 @@ async def delete_user(
success = await users_repo.delete_user(user_id) success = await users_repo.delete_user(user_id)
if not success: if not success:
raise ControllerError(f"User '{user_id}' could not be deleted") 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( @router.get(
"/{user_id}/groups", "/{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( async def get_user_memberships(
user_id: UUID, user_id: UUID,
@ -222,68 +247,8 @@ async def get_user_memberships(
) -> List[schemas.UserGroup]: ) -> List[schemas.UserGroup]:
""" """
Get user memberships. Get user memberships.
Required privilege: Group.Audit
""" """
return await users_repo.get_user_memberships(user_id) 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")

View File

@ -213,8 +213,8 @@ class ApplianceManager:
except ValidationError as e: except ValidationError as e:
raise ControllerError(message=f"Could not validate template data: {e}") raise ControllerError(message=f"Could not validate template data: {e}")
template = await TemplatesService(templates_repo).create_template(template_create) template = await TemplatesService(templates_repo).create_template(template_create)
template_id = template.get("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}/*") #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") log.info(f"Template '{template.get('name')}' has been created")
async def _appliance_to_template(self, appliance: Appliance, version: str = None) -> dict: async def _appliance_to_template(self, appliance: Appliance, version: str = None) -> dict:

View File

@ -16,11 +16,13 @@
# along with this program. If not, see <http://www.gnu.org/licenses/>. # along with this program. If not, see <http://www.gnu.org/licenses/>.
from .base import Base from .base import Base
from .acl import ACE
from .users import User, UserGroup from .users import User, UserGroup
from .roles import Role from .roles import Role
from .permissions import Permission from .privileges import Privilege
from .computes import Compute from .computes import Compute
from .images import Image from .images import Image
from .resource_pools import Resource, ResourcePool
from .templates import ( from .templates import (
Template, Template,
CloudTemplate, CloudTemplate,

View File

@ -0,0 +1,46 @@
#!/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, CheckConstraint
from sqlalchemy.orm import relationship
from .base import BaseTable, generate_uuid, GUID
import logging
log = logging.getLogger(__name__)
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)
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")
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 ace_type = 'user') OR (group_id IS NOT NULL AND ace_type = 'group')"),
)

View File

@ -21,8 +21,8 @@ from sqlalchemy.orm import relationship
from .base import Base, BaseTable, GUID from .base import Base, BaseTable, GUID
image_template_link = Table( image_template_map = Table(
"images_templates_link", "image_template_map",
Base.metadata, Base.metadata,
Column("image_id", Integer, ForeignKey("images.image_id", ondelete="CASCADE")), Column("image_id", Integer, ForeignKey("images.image_id", ondelete="CASCADE")),
Column("template_id", GUID, ForeignKey("templates.template_id", ondelete="CASCADE")) Column("template_id", GUID, ForeignKey("templates.template_id", ondelete="CASCADE"))
@ -40,4 +40,4 @@ class Image(BaseTable):
image_size = Column(BigInteger) image_size = Column(BigInteger)
checksum = Column(String, index=True) checksum = Column(String, index=True)
checksum_algorithm = Column(String) checksum_algorithm = Column(String)
templates = relationship("Template", secondary=image_template_link, back_populates="images") templates = relationship("Template", secondary=image_template_map, back_populates="images")

View File

@ -1,128 +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_link = Table(
"permissions_roles_link",
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"))
roles = relationship("Role", secondary=permission_role_link, 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_link, '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()

View File

@ -0,0 +1,347 @@
#!/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 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"
},
{
"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": "Snapshot.Allocate"
},
{
"description": "Restore a snapshot",
"name": "Snapshot.Restore"
},
{
"description": "View a snapshot",
"name": "Snapshot.Audit"
},
{
"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": "Update a compute",
"name": "Compute.Modify"
},
{
"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)
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",
"Snapshot.Allocate",
"Snapshot.Audit",
"Snapshot.Restore",
"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",
"Appliance.Allocate",
"Appliance.Audit"
)
add_privileges_to_role(target, connection, "User", user_privileges)
# add required privileges to the "Auditor" role
auditor_privileges = (
"Project.Audit",
"Snapshot.Audit",
"Node.Audit",
"Link.Audit",
"Drawing.Audit",
"Template.Audit",
"Symbol.Audit",
"Image.Audit",
"Compute.Audit",
"Appliance.Audit"
)
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")

View File

@ -0,0 +1,52 @@
#!/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
from sqlalchemy.orm import relationship
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_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")

View File

@ -15,23 +15,16 @@
# You should have received a copy of the GNU General Public License # You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>. # 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 sqlalchemy.orm import relationship
from .base import Base, BaseTable, generate_uuid, GUID from .base import BaseTable, generate_uuid, GUID
from .permissions import permission_role_link from .privileges import privilege_role_map
import logging import logging
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
role_group_link = Table(
"roles_groups_link",
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): class Role(BaseTable):
@ -41,8 +34,8 @@ class Role(BaseTable):
name = Column(String, unique=True, index=True) name = Column(String, unique=True, index=True)
description = Column(String) description = Column(String)
is_builtin = Column(Boolean, default=False) is_builtin = Column(Boolean, default=False)
permissions = relationship("Permission", secondary=permission_role_link, back_populates="roles") privileges = relationship("Privilege", secondary=privilege_role_map, back_populates="roles")
groups = relationship("UserGroup", secondary=role_group_link, back_populates="roles") acl_entries = relationship("ACE")
@event.listens_for(Role.__table__, 'after_create') @event.listens_for(Role.__table__, 'after_create')
@ -51,31 +44,14 @@ def create_default_roles(target, connection, **kw):
default_roles = [ default_roles = [
{"name": "Administrator", "description": "Administrator role", "is_builtin": True}, {"name": "Administrator", "description": "Administrator role", "is_builtin": True},
{"name": "User", "description": "User 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}
] ]
stmt = target.insert().values(default_roles) stmt = target.insert().values(default_roles)
connection.execute(stmt) connection.execute(stmt)
connection.commit() connection.commit()
log.debug("The default roles have been created in the database") log.debug("The default roles have been created in the database")
@event.listens_for(role_group_link, '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()

View File

@ -20,7 +20,7 @@ from sqlalchemy import Boolean, Column, String, Integer, ForeignKey, PickleType
from sqlalchemy.orm import relationship from sqlalchemy.orm import relationship
from .base import BaseTable, generate_uuid, GUID from .base import BaseTable, generate_uuid, GUID
from .images import image_template_link from .images import image_template_map
class Template(BaseTable): class Template(BaseTable):
@ -37,7 +37,7 @@ class Template(BaseTable):
usage = Column(String) usage = Column(String)
template_type = Column(String) template_type = Column(String)
compute_id = 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__ = { __mapper_args__ = {
"polymorphic_identity": "templates", "polymorphic_identity": "templates",

View File

@ -19,7 +19,6 @@ from sqlalchemy import Table, Boolean, Column, String, DateTime, ForeignKey, eve
from sqlalchemy.orm import relationship from sqlalchemy.orm import relationship
from .base import Base, BaseTable, generate_uuid, GUID from .base import Base, BaseTable, generate_uuid, GUID
from .roles import role_group_link
from gns3server.config import Config from gns3server.config import Config
from gns3server.services import auth_service from gns3server.services import auth_service
@ -28,8 +27,8 @@ import logging
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
user_group_link = Table( user_group_map = Table(
"users_groups_link", "user_group_map",
Base.metadata, Base.metadata,
Column("user_id", GUID, ForeignKey("users.user_id", ondelete="CASCADE")), Column("user_id", GUID, ForeignKey("users.user_id", ondelete="CASCADE")),
Column("user_group_id", GUID, ForeignKey("user_groups.user_group_id", ondelete="CASCADE")) Column("user_group_id", GUID, ForeignKey("user_groups.user_group_id", ondelete="CASCADE"))
@ -48,8 +47,8 @@ class User(BaseTable):
last_login = Column(DateTime) last_login = Column(DateTime)
is_active = Column(Boolean, default=True) is_active = Column(Boolean, default=True)
is_superadmin = Column(Boolean, default=False) 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")
permissions = relationship("Permission") acl_entries = relationship("ACE")
@event.listens_for(User.__table__, 'after_create') @event.listens_for(User.__table__, 'after_create')
@ -77,8 +76,8 @@ class UserGroup(BaseTable):
user_group_id = Column(GUID, primary_key=True, default=generate_uuid) user_group_id = Column(GUID, primary_key=True, default=generate_uuid)
name = Column(String, unique=True, index=True) name = Column(String, unique=True, index=True)
is_builtin = Column(Boolean, default=False) is_builtin = Column(Boolean, default=False)
users = relationship("User", secondary=user_group_link, back_populates="groups") users = relationship("User", secondary=user_group_map, back_populates="groups")
roles = relationship("Role", secondary=role_group_link, back_populates="groups") acl_entries = relationship("ACE")
@event.listens_for(UserGroup.__table__, 'after_create') @event.listens_for(UserGroup.__table__, 'after_create')
@ -93,21 +92,3 @@ def create_default_user_groups(target, connection, **kw):
connection.execute(stmt) connection.execute(stmt)
connection.commit() connection.commit()
log.debug("The default user groups have been created in the database") 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()

View File

@ -1,6 +1,6 @@
#!/usr/bin/env python #!/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 # 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 # it under the terms of the GNU General Public License as published by
@ -16,6 +16,7 @@
# along with this program. If not, see <http://www.gnu.org/licenses/>. # along with this program. If not, see <http://www.gnu.org/licenses/>.
from uuid import UUID from uuid import UUID
from urllib.parse import urlparse
from typing import Optional, List, Union from typing import Optional, List, Union
from sqlalchemy import select, update, delete, null from sqlalchemy import select, update, delete, null
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
@ -24,7 +25,6 @@ from sqlalchemy.orm import selectinload
from .base import BaseRepository from .base import BaseRepository
import gns3server.db.models as models import gns3server.db.models as models
from gns3server.schemas.controller.rbac import HTTPMethods, PermissionAction
from gns3server import schemas from gns3server import schemas
import logging import logging
@ -44,7 +44,7 @@ class RbacRepository(BaseRepository):
""" """
query = select(models.Role).\ query = select(models.Role).\
options(selectinload(models.Role.permissions)).\ options(selectinload(models.Role.privileges)).\
where(models.Role.role_id == role_id) where(models.Role.role_id == role_id)
result = await self._db_session.execute(query) result = await self._db_session.execute(query)
return result.scalars().first() return result.scalars().first()
@ -55,9 +55,8 @@ class RbacRepository(BaseRepository):
""" """
query = select(models.Role).\ query = select(models.Role).\
options(selectinload(models.Role.permissions)).\ options(selectinload(models.Role.privileges)).\
where(models.Role.name == name) where(models.Role.name == name)
#query = select(models.Role).where(models.Role.name == name)
result = await self._db_session.execute(query) result = await self._db_session.execute(query)
return result.scalars().first() return result.scalars().first()
@ -66,7 +65,7 @@ class RbacRepository(BaseRepository):
Get all roles. 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) result = await self._db_session.execute(query)
return result.scalars().all() return result.scalars().all()
@ -81,7 +80,6 @@ class RbacRepository(BaseRepository):
) )
self._db_session.add(db_role) self._db_session.add(db_role)
await self._db_session.commit() await self._db_session.commit()
#await self._db_session.refresh(db_role)
return await self.get_role(db_role.role_id) return await self.get_role(db_role.role_id)
async def update_role( async def update_role(
@ -115,312 +113,246 @@ class RbacRepository(BaseRepository):
await self._db_session.commit() await self._db_session.commit()
return result.rowcount > 0 return result.rowcount > 0
async def add_permission_to_role( async def add_privilege_to_role(
self, self,
role_id: UUID, role_id: UUID,
permission: models.Permission privilege: models.Privilege
) -> Union[None, models.Role]: ) -> Union[None, models.Role]:
""" """
Add a permission to a role. Add a privilege to a role.
""" """
query = select(models.Role).\ query = select(models.Role).\
options(selectinload(models.Role.permissions)).\ options(selectinload(models.Role.privileges)).\
where(models.Role.role_id == role_id) where(models.Role.role_id == role_id)
result = await self._db_session.execute(query) result = await self._db_session.execute(query)
role_db = result.scalars().first() role_db = result.scalars().first()
if not role_db: if not role_db:
return None return None
role_db.permissions.append(permission) role_db.privileges.append(privilege)
await self._db_session.commit() await self._db_session.commit()
await self._db_session.refresh(role_db) await self._db_session.refresh(role_db)
return role_db return role_db
async def remove_permission_from_role( async def remove_privilege_from_role(
self, self,
role_id: UUID, role_id: UUID,
permission: models.Permission privilege: models.Privilege
) -> Union[None, models.Role]: ) -> Union[None, models.Role]:
""" """
Remove a permission from a role. Remove a privilege from a role.
""" """
query = select(models.Role).\ query = select(models.Role).\
options(selectinload(models.Role.permissions)).\ options(selectinload(models.Role.privileges)).\
where(models.Role.role_id == role_id) where(models.Role.role_id == role_id)
result = await self._db_session.execute(query) result = await self._db_session.execute(query)
role_db = result.scalars().first() role_db = result.scalars().first()
if not role_db: if not role_db:
return None return None
role_db.permissions.remove(permission) role_db.privileges.remove(privilege)
await self._db_session.commit() await self._db_session.commit()
await self._db_session.refresh(role_db) await self._db_session.refresh(role_db)
return 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).\ query = select(models.Privilege).\
join(models.Permission.roles).\ join(models.Privilege.roles).\
filter(models.Role.role_id == role_id) filter(models.Role.role_id == role_id)
result = await self._db_session.execute(query) result = await self._db_session.execute(query)
return result.scalars().all() 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) result = await self._db_session.execute(query)
return result.scalars().first() 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) result = await self._db_session.execute(query)
return result.scalars().first() 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).\ query = select(models.Privilege)
order_by(models.Permission.path.desc())
result = await self._db_session.execute(query) result = await self._db_session.execute(query)
return result.scalars().all() 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).\ query = select(models.ACE).where(models.ACE.ace_id == ace_id)
where(models.Permission.methods == permission_create.methods, result = await self._db_session.execute(query)
models.Permission.path == permission_create.path, return result.scalars().first()
models.Permission.action == permission_create.action)
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) result = await self._db_session.execute(query)
return result.scalars().first() is not None 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( create_values = ace_create.model_dump(exclude_unset=True)
description=permission_create.description, db_ace = models.ACE(**create_values)
methods=permission_create.methods, self._db_session.add(db_ace)
path=permission_create.path,
action=permission_create.action,
)
self._db_session.add(db_permission)
await self._db_session.commit() await self._db_session.commit()
await self._db_session.refresh(db_permission) await self._db_session.refresh(db_ace)
return db_permission return db_ace
async def update_permission( async def update_ace(
self, self,
permission_id: UUID, ace_id: UUID,
permission_update: schemas.PermissionUpdate ace_update: schemas.ACEUpdate
) -> Optional[models.Permission]: ) -> Optional[models.ACE]:
""" """
Update a permission. Update an ACE
""" """
update_values = permission_update.model_dump(exclude_unset=True) update_values = ace_update.model_dump(exclude_unset=True)
query = update(models.Permission).\ query = update(models.ACE).\
where(models.Permission.permission_id == permission_id).\ where(models.ACE.ace_id == ace_id).\
values(update_values) values(update_values)
await self._db_session.execute(query) await self._db_session.execute(query)
await self._db_session.commit() await self._db_session.commit()
permission_db = await self.get_permission(permission_id) ace_db = await self.get_ace(ace_id)
if permission_db: if ace_db:
await self._db_session.refresh(permission_db) # force refresh of updated_at value await self._db_session.refresh(ace_db) # force refresh of updated_at value
return permission_db 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) result = await self._db_session.execute(query)
await self._db_session.commit() await self._db_session.commit()
return result.rowcount > 0 return result.rowcount > 0
async def prune_permissions(self) -> int: 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).\ query = delete(models.ACE).\
filter((~models.Permission.roles.any()) & (models.Permission.user_id == null())) where(models.ACE.path.startswith(path)).\
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)).\
execution_options(synchronize_session=False) execution_options(synchronize_session=False)
result = await self._db_session.execute(query) 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: @staticmethod
def _check_path_with_aces(path: str, aces) -> bool:
""" """
Check if an user is authorized to access a resource. Compare path with existing ACEs to check if the user has the required privilege on that path.
""" """
query = select(models.Permission).\ parsed_url = urlparse(path)
join(models.Permission.roles).\ original_path = path
join(models.Role.groups).\ path_components = parsed_url.path.split("/")
join(models.UserGroup.users).\ # 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:
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.User.user_id == user_id).\
order_by(models.Permission.path.desc()) filter(models.Privilege.name == privilege_name).\
order_by(models.ACE.path.desc())
result = await self._db_session.execute(query) result = await self._db_session.execute(query)
permissions = result.scalars().all() aces = result.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...") try:
permissions = await self.get_user_permissions(user_id) if self._check_path_with_aces(path, aces):
matched_permission = self._match_permission(permissions, method, path) # the user has an ACE matching the path and privilege,there is no need to check group ACEs
if matched_permission: return True
log.debug(f"RBAC: matched user permission {matched_permission.methods} " except PermissionError:
f"{matched_permission.path} {matched_permission.action}") return False
if matched_permission.action == "DENY":
return False
return True
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._check_path_with_aces(path, aces)
except PermissionError:
return False

View File

@ -287,60 +287,3 @@ class UsersRepository(BaseRepository):
result = await self._db_session.execute(query) result = await self._db_session.execute(query)
return result.scalars().all() 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()

View File

@ -30,7 +30,7 @@ from .controller.gns3vm import GNS3VM
from .controller.nodes import NodeCreate, NodeUpdate, NodeDuplicate, NodeCapture, Node from .controller.nodes import NodeCreate, NodeUpdate, NodeDuplicate, NodeCapture, Node
from .controller.projects import ProjectCreate, ProjectUpdate, ProjectDuplicate, Project, ProjectFile, ProjectCompression from .controller.projects import ProjectCreate, ProjectUpdate, ProjectDuplicate, Project, ProjectFile, ProjectCompression
from .controller.users import UserCreate, UserUpdate, LoggedInUserUpdate, User, Credentials, UserGroupCreate, UserGroupUpdate, UserGroup 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.tokens import Token
from .controller.snapshots import SnapshotCreate, Snapshot from .controller.snapshots import SnapshotCreate, Snapshot
from .controller.iou_license import IOULicense from .controller.iou_license import IOULicense

View File

@ -15,71 +15,68 @@
# along with this program. If not, see <http://www.gnu.org/licenses/>. # along with this program. If not, see <http://www.gnu.org/licenses/>.
from typing import Optional, List from typing import Optional, List
from pydantic import field_validator, ConfigDict, BaseModel from pydantic import ConfigDict, BaseModel, Field
from uuid import UUID from uuid import UUID
from enum import Enum from enum import Enum
from .base import DateTimeModelMixin from .base import DateTimeModelMixin
class HTTPMethods(str, Enum): class PrivilegeBase(BaseModel):
""" """
HTTP method type. Common privilege properties.
""" """
get = "GET" name: str
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
description: Optional[str] = None 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.
"""
ace_type: ACEType = Field(..., description="Type of the ACE")
path: str
propagate: Optional[bool] = True
allowed: Optional[bool] = True
user_id: Optional[UUID] = None
group_id: Optional[UUID] = None
role_id: UUID
model_config = ConfigDict(use_enum_values=True) model_config = ConfigDict(use_enum_values=True)
@field_validator("action", mode="before")
@classmethod
def action_uppercase(cls, v):
return v.upper()
class ACECreate(ACEBase):
class PermissionCreate(PermissionBase):
""" """
Properties to create a permission. Properties to create an ACE.
""" """
pass pass
class PermissionUpdate(PermissionBase): class ACEUpdate(ACEBase):
""" """
Properties to update a role. Properties to update an ACE.
""" """
pass pass
class Permission(DateTimeModelMixin, PermissionBase): class ACE(DateTimeModelMixin, ACEBase):
permission_id: UUID ace_id: UUID
model_config = ConfigDict(from_attributes=True) model_config = ConfigDict(from_attributes=True)
@ -112,5 +109,5 @@ class Role(DateTimeModelMixin, RoleBase):
role_id: UUID role_id: UUID
is_builtin: bool is_builtin: bool
permissions: List[Permission] privileges: List[Privilege]
model_config = ConfigDict(from_attributes=True) model_config = ConfigDict(from_attributes=True)

View File

@ -52,7 +52,7 @@ class UserUpdate(UserBase):
class LoggedInUserUpdate(BaseModel): 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) password: Optional[SecretStr] = Field(None, min_length=6, max_length=100)

View File

@ -0,0 +1,212 @@
#!/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
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 ACECreate
from gns3server.controller import Controller
pytestmark = pytest.mark.asyncio
class TestACLRoutes:
@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:
# 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,
"ace_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",
"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_non_existing_resource(
# self,
# app: FastAPI,
# client: AsyncClient,
# group_id: str,
# role_id: str
# ) -> None:
#
# new_ace = {
# "path": f"/projects/{str(uuid.uuid4())}",
# "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_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()) == 2
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",
"ace_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_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)

View File

@ -16,17 +16,13 @@
# along with this program. If not, see <http://www.gnu.org/licenses/>. # along with this program. If not, see <http://www.gnu.org/licenses/>.
import pytest import pytest
import pytest_asyncio
from fastapi import FastAPI, status from fastapi import FastAPI, status
from httpx import AsyncClient from httpx import AsyncClient
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from gns3server.db.repositories.users import UsersRepository 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.users import User
from gns3server.schemas.controller.rbac import Role
from gns3server import schemas
pytestmark = pytest.mark.asyncio pytestmark = pytest.mark.asyncio
@ -170,84 +166,3 @@ class TestGroupMembersRoutes:
assert response.status_code == status.HTTP_204_NO_CONTENT assert response.status_code == status.HTTP_204_NO_CONTENT
members = await user_repo.get_user_group_members(group_in_db.user_group_id) members = await user_repo.get_user_group_members(group_in_db.user_group_id)
assert len(members) == 0 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

View File

@ -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

View File

@ -16,15 +16,12 @@
# along with this program. If not, see <http://www.gnu.org/licenses/>. # along with this program. If not, see <http://www.gnu.org/licenses/>.
import pytest import pytest
import pytest_asyncio
from fastapi import FastAPI, status from fastapi import FastAPI, status
from httpx import AsyncClient from httpx import AsyncClient
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from gns3server.db.repositories.rbac import RbacRepository from gns3server.db.repositories.rbac import RbacRepository
from gns3server.schemas.controller.rbac import Permission, HTTPMethods, PermissionAction
from gns3server import schemas
pytestmark = pytest.mark.asyncio pytestmark = pytest.mark.asyncio
@ -49,7 +46,7 @@ class TestRolesRoutes:
response = await client.get(app.url_path_for("get_roles")) response = await client.get(app.url_path_for("get_roles"))
assert response.status_code == status.HTTP_200_OK assert response.status_code == status.HTTP_200_OK
assert len(response.json()) == 3 # 2 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: 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 assert response.status_code == status.HTTP_403_FORBIDDEN
@pytest_asyncio.fixture class TestRolesPrivilegesRoutes:
async def test_permission(db_session: AsyncSession) -> Permission:
new_permission = schemas.PermissionCreate( async def test_add_privilege_to_role(
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(
self, self,
app: FastAPI, app: FastAPI,
client: AsyncClient, client: AsyncClient,
test_permission: Permission,
db_session: AsyncSession db_session: AsyncSession
) -> None: ) -> None:
rbac_repo = RbacRepository(db_session) rbac_repo = RbacRepository(db_session)
role_in_db = await rbac_repo.get_role_by_name("User") 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( response = await client.put(
app.url_path_for( app.url_path_for(
"add_permission_to_role", "add_privilege_to_role",
role_id=role_in_db.role_id, 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 assert response.status_code == status.HTTP_204_NO_CONTENT
permissions = await rbac_repo.get_role_permissions(role_in_db.role_id) privileges = await rbac_repo.get_role_privileges(role_in_db.role_id)
assert len(permissions) == 6 # 5 default permissions + 1 custom permission assert len(privileges) == 25 # 24 default privileges + 1 custom privilege
async def test_get_role_permissions( async def test_get_role_privileges(
self, self,
app: FastAPI, app: FastAPI,
client: AsyncClient, client: AsyncClient,
@ -157,30 +139,30 @@ class TestRolesPermissionsRoutes:
response = await client.get( response = await client.get(
app.url_path_for( app.url_path_for(
"get_role_permissions", "get_role_privileges",
role_id=role_in_db.role_id) role_id=role_in_db.role_id)
) )
assert response.status_code == status.HTTP_200_OK assert response.status_code == status.HTTP_200_OK
assert len(response.json()) == 6 # 5 default permissions + 1 custom permission assert len(response.json()) == 25 # 24 default privileges + 1 custom privilege
async def test_remove_role_from_group( async def test_remove_privilege_from_role(
self, self,
app: FastAPI, app: FastAPI,
client: AsyncClient, client: AsyncClient,
test_permission: Permission,
db_session: AsyncSession db_session: AsyncSession
) -> None: ) -> None:
rbac_repo = RbacRepository(db_session) rbac_repo = RbacRepository(db_session)
role_in_db = await rbac_repo.get_role_by_name("User") 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( response = await client.delete(
app.url_path_for( app.url_path_for(
"remove_permission_from_role", "remove_privilege_from_role",
role_id=role_in_db.role_id, 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 assert response.status_code == status.HTTP_204_NO_CONTENT
permissions = await rbac_repo.get_role_permissions(role_in_db.role_id) privileges = await rbac_repo.get_role_privileges(role_in_db.role_id)
assert len(permissions) == 5 # 5 default permissions assert len(privileges) == 24 # 24 default privileges

View File

@ -16,7 +16,6 @@
# along with this program. If not, see <http://www.gnu.org/licenses/>. # along with this program. If not, see <http://www.gnu.org/licenses/>.
import pytest import pytest
import pytest_asyncio
from typing import Optional from typing import Optional
from fastapi import FastAPI, HTTPException, status from fastapi import FastAPI, HTTPException, status
@ -26,12 +25,9 @@ from jose import jwt
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from gns3server.db.repositories.users import UsersRepository 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.services import auth_service
from gns3server.config import Config from gns3server.config import Config
from gns3server.schemas.controller.users import User from gns3server.schemas.controller.users import User
from gns3server import schemas
import gns3server.db.models as models import gns3server.db.models as models
pytestmark = pytest.mark.asyncio pytestmark = pytest.mark.asyncio
@ -309,7 +305,7 @@ class TestUserLogin:
assert response.status_code == status.HTTP_200_OK assert response.status_code == status.HTTP_200_OK
token = response.json().get("access_token") 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 assert response.status_code == status.HTTP_200_OK
@ -352,7 +348,7 @@ class TestUserMe:
assert user.email == test_user.email assert user.email == test_user.email
assert user.user_id == test_user.user_id 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( @pytest.mark.parametrize(
"attr, value, status_code", "attr, value, status_code",
( (
@ -426,92 +422,3 @@ class TestSuperAdmin:
response = await unauthorized_client.post(app.url_path_for("login"), data=login_data) response = await unauthorized_client.post(app.url_path_for("login"), data=login_data)
assert response.status_code == status.HTTP_200_OK 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

View File

@ -115,7 +115,7 @@ async def test_user(db_session: AsyncSession) -> User:
return existing_user return existing_user
user = await user_repo.create_user(new_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") group = await user_repo.get_user_group_by_name("Users")
await user_repo.add_member_to_user_group(group.user_group_id, user) await user_repo.add_member_to_user_group(group.user_group_id, user)
return user return user

View File

@ -16,188 +16,256 @@
# along with this program. If not, see <http://www.gnu.org/licenses/>. # along with this program. If not, see <http://www.gnu.org/licenses/>.
import pytest import pytest
import pytest_asyncio
from fastapi import FastAPI, status from fastapi import FastAPI, status
from httpx import AsyncClient from httpx import AsyncClient
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from gns3server.db.repositories.rbac import RbacRepository 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 from gns3server.db.models import User
pytestmark = pytest.mark.asyncio pytestmark = pytest.mark.asyncio
# @pytest_asyncio.fixture
# async def project_ace(db_session: AsyncSession):
#
# 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)
# )
# await RbacRepository(db_session).create_ace(ace)
class TestPermissions:
class TestPrivileges:
@pytest.mark.parametrize( @pytest.mark.parametrize(
"method, path, result", "privilege, path, result",
( (
("GET", "/users", False), ("User.Allocate", "/users", False),
("GET", "/projects", True), ("Project.Allocate", "/projects", False),
("GET", "/projects/e451ad73-2519-4f83-87fe-a8e821792d44", False), ("Project.Allocate", "/projects", True),
("POST", "/projects", True), ("Project.Audit", "/projects/e451ad73-2519-4f83-87fe-a8e821792d44", True),
("GET", "/templates", True), ("Project.Audit", "/templates", False),
("GET", "/templates/62e92cf1-244a-4486-8dae-b95439b54da9", False), ("Template.Audit", "/templates", True),
("POST", "/templates", True), ("Template.Allocate", "/templates", False),
("GET", "/computes", True), ("Compute.Audit", "/computes", True),
("GET", "/computes/local", True), ("Compute.Audit", "/computes/local", True),
("GET", "/symbols", True), ("Symbol.Audit", "/symbols", True),
("GET", "/symbols/default_symbols", True), ("Symbol.Audit", "/symbols/default_symbols", True),
), ),
) )
async def test_default_permissions_user_group( async def test_default_privileges_user_group(
self, self,
app: FastAPI,
authorized_client: AsyncClient,
test_user: User, test_user: User,
db_session: AsyncSession, db_session: AsyncSession,
method: str, privilege: str,
path: str, path: str,
result: bool result: bool
) -> None: ) -> None:
rbac_repo = RbacRepository(db_session) # add an ACE for path
authorized = await rbac_repo.check_user_is_authorized(test_user.user_id, method, 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 assert authorized == result
async def test_propagate(self, test_user: User, db_session: AsyncSession):
class TestProjectsWithRbac: 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
async def test_admin_create_project(self, app: FastAPI, client: AsyncClient): ace = await RbacRepository(db_session).get_ace_by_path("/projects")
ace.propagate = True
await db_session.commit()
params = {"name": "Admin project"} authorized = await RbacRepository(db_session).check_user_has_privilege(test_user.user_id, path, privilege)
response = await client.post(app.url_path_for("create_project"), json=params) assert authorized is True
assert response.status_code == status.HTTP_201_CREATED
async def test_user_only_access_own_projects( async def test_allowed(self, test_user: User, db_session: AsyncSession):
self,
app: FastAPI,
authorized_client: AsyncClient,
test_user: User,
db_session: AsyncSession
) -> None:
params = {"name": "User project"} ace = await RbacRepository(db_session).get_ace_by_path("/projects")
response = await authorized_client.post(app.url_path_for("create_project"), json=params) ace.allowed = False
assert response.status_code == status.HTTP_201_CREATED ace.propagate = True
project_id = response.json()["project_id"] await db_session.commit()
rbac_repo = RbacRepository(db_session) privilege = "Project.Audit"
permissions_in_db = await rbac_repo.get_user_permissions(test_user.user_id) path = "/projects/44929147-47bb-460a-90ae-c782c4dbb6ef"
assert len(permissions_in_db) == 1 authorized = await RbacRepository(db_session).check_user_has_privilege(test_user.user_id, path, privilege)
assert permissions_in_db[0].path == f"/projects/{project_id}/*" assert authorized is False
response = await authorized_client.get(app.url_path_for("get_projects")) # privileges on deeper levels replace those inherited from an upper level.
assert response.status_code == status.HTTP_200_OK group_id = (await UsersRepository(db_session).get_user_group_by_name("Users")).user_group_id
projects = response.json() role_id = (await RbacRepository(db_session).get_role_by_name("User")).role_id
assert len(projects) == 1 ace = ACECreate(
path=path,
async def test_admin_access_all_projects(self, app: FastAPI, client: AsyncClient): ace_type="group",
propagate=False,
response = await client.get(app.url_path_for("get_projects")) group_id=str(group_id),
assert response.status_code == status.HTTP_200_OK role_id=str(role_id)
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 await RbacRepository(db_session).create_ace(ace)
async def test_user_access_admin_project( authorized = await RbacRepository(db_session).check_user_has_privilege(test_user.user_id, path, privilege)
self, assert authorized is True
app: FastAPI,
authorized_client: AsyncClient,
test_user: User,
db_session: AsyncSession
) -> None:
response = await authorized_client.get(app.url_path_for("get_projects")) # class TestProjectsWithRbac:
assert response.status_code == status.HTTP_200_OK #
projects = response.json() # async def test_admin_create_project(self, app: FastAPI, client: AsyncClient):
assert len(projects) == 2 #
# 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,
# project_ace,
# 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: # class TestTemplatesWithRbac:
#
async def test_admin_create_template(self, app: FastAPI, client: AsyncClient): # async def test_admin_create_template(self, app: FastAPI, client: AsyncClient):
#
new_template = {"base_script_file": "vpcs_base_config.txt", # new_template = {"base_script_file": "vpcs_base_config.txt",
"category": "guest", # "category": "guest",
"console_auto_start": False, # "console_auto_start": False,
"console_type": "telnet", # "console_type": "telnet",
"default_name_format": "PC{0}", # "default_name_format": "PC{0}",
"name": "ADMIN_VPCS_TEMPLATE", # "name": "ADMIN_VPCS_TEMPLATE",
"compute_id": "local", # "compute_id": "local",
"symbol": ":/symbols/vpcs_guest.svg", # "symbol": ":/symbols/vpcs_guest.svg",
"template_type": "vpcs"} # "template_type": "vpcs"}
#
response = await client.post(app.url_path_for("create_template"), json=new_template) # response = await client.post(app.url_path_for("create_template"), json=new_template)
assert response.status_code == status.HTTP_201_CREATED # assert response.status_code == status.HTTP_201_CREATED
#
async def test_user_only_access_own_templates( # async def test_user_only_access_own_templates(
self, app: FastAPI, # self, app: FastAPI,
authorized_client: AsyncClient, # authorized_client: AsyncClient,
test_user: User, # test_user: User,
db_session: AsyncSession # db_session: AsyncSession
) -> None: # ) -> None:
#
new_template = {"base_script_file": "vpcs_base_config.txt", # new_template = {"base_script_file": "vpcs_base_config.txt",
"category": "guest", # "category": "guest",
"console_auto_start": False, # "console_auto_start": False,
"console_type": "telnet", # "console_type": "telnet",
"default_name_format": "PC{0}", # "default_name_format": "PC{0}",
"name": "USER_VPCS_TEMPLATE", # "name": "USER_VPCS_TEMPLATE",
"compute_id": "local", # "compute_id": "local",
"symbol": ":/symbols/vpcs_guest.svg", # "symbol": ":/symbols/vpcs_guest.svg",
"template_type": "vpcs"} # "template_type": "vpcs"}
#
response = await authorized_client.post(app.url_path_for("create_template"), json=new_template) # response = await authorized_client.post(app.url_path_for("create_template"), json=new_template)
assert response.status_code == status.HTTP_201_CREATED # assert response.status_code == status.HTTP_201_CREATED
template_id = response.json()["template_id"] # template_id = response.json()["template_id"]
#
rbac_repo = RbacRepository(db_session) # rbac_repo = RbacRepository(db_session)
permissions_in_db = await rbac_repo.get_user_permissions(test_user.user_id) # permissions_in_db = await rbac_repo.get_user_permissions(test_user.user_id)
assert len(permissions_in_db) == 1 # assert len(permissions_in_db) == 1
assert permissions_in_db[0].path == f"/templates/{template_id}/*" # assert permissions_in_db[0].path == f"/templates/{template_id}/*"
#
response = await authorized_client.get(app.url_path_for("get_templates")) # response = await authorized_client.get(app.url_path_for("get_templates"))
assert response.status_code == status.HTTP_200_OK # assert response.status_code == status.HTTP_200_OK
templates = [template for template in response.json() if template["builtin"] is False] # templates = [template for template in response.json() if template["builtin"] is False]
assert len(templates) == 1 # assert len(templates) == 1
#
async def test_admin_access_all_templates(self, app: FastAPI, client: AsyncClient): # async def test_admin_access_all_templates(self, app: FastAPI, client: AsyncClient):
#
response = await client.get(app.url_path_for("get_templates")) # response = await client.get(app.url_path_for("get_templates"))
assert response.status_code == status.HTTP_200_OK # assert response.status_code == status.HTTP_200_OK
templates = [template for template in response.json() if template["builtin"] is False] # templates = [template for template in response.json() if template["builtin"] is False]
assert len(templates) == 2 # assert len(templates) == 2