From d3b76d0c9ea3f7f857dd7ce8b6fb6e422f46131b Mon Sep 17 00:00:00 2001 From: Pim Witlox Date: Thu, 7 Nov 2024 15:10:44 +0100 Subject: [PATCH] fixed --- horao/__init__.py | 3 +- horao/api/synchronization.py | 5 +- horao/auth/__init__.py | 6 +- horao/auth/error.py | 20 ++++ horao/auth/multi.py | 41 ++----- horao/auth/permissions.py | 86 ++++++++++++++ horao/auth/validate.py | 44 +++++++ horao/conceptual/claim.py | 14 +-- horao/conceptual/crdt.py | 46 ++++---- horao/logical/infrastructure.py | 2 +- horao/persistance/serialize.py | 22 ++-- horao/physical/computer.py | 10 +- horao/physical/network.py | 3 + horao/rbac/__init__.py | 22 ---- horao/rbac/permission.py | 128 --------------------- horao/auth/basic.py => tests/basic_auth.py | 0 tests/test_api.py | 3 +- 17 files changed, 214 insertions(+), 241 deletions(-) create mode 100644 horao/auth/error.py create mode 100644 horao/auth/permissions.py create mode 100644 horao/auth/validate.py delete mode 100644 horao/rbac/__init__.py delete mode 100644 horao/rbac/permission.py rename horao/auth/basic.py => tests/basic_auth.py (100%) diff --git a/horao/__init__.py b/horao/__init__.py index 71383c3..08b2255 100644 --- a/horao/__init__.py +++ b/horao/__init__.py @@ -9,6 +9,7 @@ """ import logging import os +from typing import Optional from opentelemetry import metrics, trace # type: ignore from opentelemetry.instrumentation.logging import LoggingInstrumentor # type: ignore @@ -121,7 +122,7 @@ async def docs(request): return HTMLResponse(html) -def init(authorization: AuthenticationBackend = None) -> Starlette: +def init(authorization: Optional[AuthenticationBackend] = None) -> Starlette: """ Initialize the API authorization: optional authorization backend to overwrite default behavior (useful for testing) diff --git a/horao/api/synchronization.py b/horao/api/synchronization.py index e55a1c9..ea2c57c 100644 --- a/horao/api/synchronization.py +++ b/horao/api/synchronization.py @@ -8,10 +8,13 @@ from starlette.requests import Request from starlette.responses import JSONResponse +from horao.auth.permissions import Namespace, Permission +from horao.auth.validate import permission_required from horao.persistance import HoraoDecoder, init_session -@requires("authenticated_peer") +@requires("authenticated") +@permission_required(Namespace.Peer, Permission.Write) async def synchronize(request: Request) -> JSONResponse: """ responses: diff --git a/horao/auth/__init__.py b/horao/auth/__init__.py index 8837012..d4b76fe 100644 --- a/horao/auth/__init__.py +++ b/horao/auth/__init__.py @@ -5,5 +5,7 @@ only contains authorization. The RBAC module is used to define the roles and permissions of the users. There are various implementations that can be used, but some are only meant for development purpose. """ -from horao.auth.basic import BasicAuthBackend -from horao.auth.multi import MultiAuthBackend, Peer +from __future__ import annotations + +from horao.auth.multi import MultiAuthBackend +from horao.auth.roles import Peer diff --git a/horao/auth/error.py b/horao/auth/error.py new file mode 100644 index 0000000..0380ece --- /dev/null +++ b/horao/auth/error.py @@ -0,0 +1,20 @@ +from __future__ import annotations + +from typing import Callable, TypeVar + +RT = TypeVar("RT") + + +class UnauthorizedError(RuntimeError): + """We raise this exception when a user tries to access a resource without the proper permissions.""" + + def __init__(self, function: Callable[..., RT], *args: str, **kwargs: int): + super().__init__("unauthorized access to resource") + self.function = function + self.args = args + self.kwargs = kwargs + + def __str__(self): + return ( + f"UnauthorizedError from [{self.function}] - ({self.args} ; {self.kwargs})" + ) diff --git a/horao/auth/multi.py b/horao/auth/multi.py index 253ef55..959b317 100644 --- a/horao/auth/multi.py +++ b/horao/auth/multi.py @@ -17,35 +17,7 @@ ) from starlette.requests import HTTPConnection - -class Peer(BaseUser): - def __init__(self, identity: str, token: str, payload, origin: str) -> None: - self.id = identity - self.token = token - self.payload = payload - self.origin = origin - - @property - def is_authenticated(self) -> bool: - return True - - @property - def display_name(self) -> str: - return self.origin - - @property - def identity(self) -> str: - return self.id - - def is_true(self) -> bool: - """ - Check if the identity matches the origin. - :return: bool - """ - return self.identity == self.origin - - def __str__(self) -> str: - return f"{self.origin} -> {self.identity}" +from horao.auth.roles import Peer class MultiAuthBackend(AuthenticationBackend): @@ -54,20 +26,21 @@ class MultiAuthBackend(AuthenticationBackend): def digest_authentication( self, conn: HTTPConnection, token: str ) -> Union[None, Tuple[AuthCredentials, BaseUser]]: + host = conn.client.host # type: ignore peer_match_source = False for peer in os.getenv("PEERS").split(","): # type: ignore - if peer in conn.client.host: + if peer in host: self.logger.debug(f"Peer {peer} is trying to authenticate") peer_match_source = True if not peer_match_source and os.getenv("PEER_STRICT", "True") == "True": - raise AuthenticationError(f"access not allowed for {conn.client.host}") + raise AuthenticationError(f"access not allowed for {host}") payload = jwt.decode(token, os.getenv("PEER_SECRET"), algorithms=["HS256"]) # type: ignore self.logger.debug(f"valid token for {payload['peer']}") - return AuthCredentials(["authenticated_peer"]), Peer( + return AuthCredentials(["authenticated"]), Peer( identity=payload["peer"], token=token, payload=payload, - origin=conn.client.host, + origin=host, ) async def authenticate( @@ -93,4 +66,4 @@ async def authenticate( binascii.Error, ) as exc: self.logger.error(f"Invalid token for peer ({exc})") - raise AuthenticationError(f"access not allowed for {conn.client.host}") + raise AuthenticationError(f"access not allowed for {conn.client.host}") # type: ignore diff --git a/horao/auth/permissions.py b/horao/auth/permissions.py new file mode 100644 index 0000000..de217d8 --- /dev/null +++ b/horao/auth/permissions.py @@ -0,0 +1,86 @@ +# -*- coding: utf-8 -*-# +"""Permission classes and functions for the role-based access control module.""" +from __future__ import annotations + +from abc import ABC +from enum import Enum, auto +from typing import Dict, TypeVar + +RT = TypeVar("RT") + + +class Namespace(Enum): + """Namespaces define where a permission applies""" + + System = auto() + User = auto() + Peer = auto() + + +class Permission(Enum): + """We have two permissions: Read and Write, Write implies read.""" + + Read = auto() + Write = auto() + + +class Permissions(ABC): + def __init__(self, name: str, permissions: Dict[Namespace, Permission]): + self.name = name + self.permissions: Dict[Namespace, Permission] = permissions + + def __len__(self): + return len(self.permissions.keys()) + + def __repr__(self): + return f"" + + def __iter__(self): + for permission in self.permissions: + yield permission + + def can_read(self, namespace: Namespace): + if namespace not in self.permissions.keys(): + return False + return ( + self.permissions[namespace] == Permission.Read + or self.permissions[namespace] == Permission.Write + ) + + def can_write(self, namespace: Namespace): + if namespace not in self.permissions.keys(): + return False + return self.permissions[namespace] == Permission.Write + + +class AdministratorPermissions(Permissions): + def __init__(self): + super().__init__( + "System Administrator", + {Namespace.System: Permission.Write, Namespace.User: Permission.Read}, + ) + + def __str__(self): + return self.name + + +class PeerPermissions(Permissions): + def __init__(self): + super().__init__( + "Peer Node", + {Namespace.Peer: Permission.Write}, + ) + + def __str__(self): + return self.name + + +class TenantPermissions(Permissions): + def __init__(self): + super().__init__( + "TenantOwner", + {Namespace.System: Permission.Read, Namespace.User: Permission.Write}, + ) + + def __str__(self): + return self.name diff --git a/horao/auth/validate.py b/horao/auth/validate.py new file mode 100644 index 0000000..65a2ab3 --- /dev/null +++ b/horao/auth/validate.py @@ -0,0 +1,44 @@ +from __future__ import annotations + +from functools import wraps +from typing import Callable + +from starlette.requests import Request + +from horao.auth.error import UnauthorizedError +from horao.auth.permissions import RT, Namespace, Permission +from horao.auth.roles import User + + +def permission_required( + namespace: Namespace, permission: Permission +) -> Callable[[Callable[..., RT]], Callable[..., RT]]: + """ + Decorator to check if a user has the permission to access a resource. + :param namespace: namespace to check + :param permission: permission to check + :return: function call if the user has the permission + :raises: UnauthorizedError if the user does not have the permission + """ + + def decorator(func: Callable[..., RT]) -> Callable[..., RT]: + @wraps(func) + async def wrapper(*args: str, **kwargs: int) -> RT: + for arg in args: + if isinstance(arg, Request): + if not arg.user: + raise UnauthorizedError(func, *args, **kwargs) + if isinstance(arg.user, User): + if permission.Write and any( + [p for p in arg.user.permissions if p.can_write(namespace)] + ): + return await func(*args, **kwargs) + if permission.Read and any( + [p for p in arg.user.permissions if p.can_read(namespace)] + ): + return await func(*args, **kwargs) + raise UnauthorizedError(func, *args, **kwargs) + + return wrapper # type: ignore + + return decorator diff --git a/horao/conceptual/claim.py b/horao/conceptual/claim.py index ab47ab3..7fbbf4a 100644 --- a/horao/conceptual/claim.py +++ b/horao/conceptual/claim.py @@ -11,13 +11,6 @@ from horao.physical.computer import Computer from horao.physical.hardware import Hardware from horao.physical.storage import StorageType -from horao.rbac.roles import ( - Delegate, - NetworkEngineer, - SecurityEngineer, - SystemEngineer, - TenantOwner, -) class Claim(ABC): @@ -84,7 +77,6 @@ def __init__( start: datetime, end: datetime, reason: str, - operator: SecurityEngineer | SystemEngineer | NetworkEngineer, target: List[DataCenter | DataCenterNetwork | Computer | Hardware], ): """ @@ -98,7 +90,6 @@ def __init__( """ super().__init__(name, start, end) self.reason = reason - self.operator = operator self.target = target def __eq__(self, other) -> bool: @@ -107,14 +98,11 @@ def __eq__(self, other) -> bool: return ( super().__eq__(other) and self.reason == other.reason - and self.operator == other.operator and self.target == other.target ) def __hash__(self): - return hash( - (self.name, self.start, self.end, self.reason, self.operator, self.target) - ) + return hash((self.name, self.start, self.end, self.reason, self.target)) class Reservation(Claim): diff --git a/horao/conceptual/crdt.py b/horao/conceptual/crdt.py index 39a538b..df4169a 100644 --- a/horao/conceptual/crdt.py +++ b/horao/conceptual/crdt.py @@ -6,15 +6,14 @@ from typing import ( Callable, Dict, - Generic, Hashable, Iterable, + Iterator, List, Optional, Set, Tuple, TypeVar, - Any, ) from horao.conceptual.decorators import instrument_class_function @@ -777,29 +776,34 @@ def copy(self) -> CRDTList[T]: results.append(item.copy()) return results - def extend(self, other: Iterable[T]) -> None: + def extend(self, other: Iterable[T]) -> CRDTList[T]: # type: ignore for item in other: if item not in self.items.read(): self.items.set(len(self), item, hash(item)) + return self - def index(self, item: T, **kwargs: Any) -> int: + def index( + self, item: T, start: Optional[int] = None, stop: Optional[int] = None # type: ignore + ) -> int: """ Return the index of the hardware instance :param item: instance to search for - :param kwargs: additional arguments + :param start: start index + :param stop: stop index :return: int :raises ValueError: item not found """ - result = next( - iter([i for i, h in self.items.read() if h == item]), - None, - ) - if result is None: - self.log.error(f"{item} not found.") - raise ValueError(f"{item} not found.") - return result + for i in range(len(self)): + if start is not None and i < start: + continue + if stop is not None and i > stop: + continue + if self.items.read()[i] == item: + return i + self.log.error(f"{item} not found.") + raise ValueError(f"{item} not found.") - def insert(self, index: int, item: T) -> None: + def insert(self, index: int, item: T) -> None: # type: ignore self.items.set(index, item, hash(item)) @instrument_class_function(name="pop", level=logging.DEBUG) @@ -836,21 +840,21 @@ def __eq__(self, other) -> bool: return False return self.items.read() == other.items.read() - def __contains__(self, item: T) -> bool: + def __contains__(self, item: T) -> bool: # type: ignore return item in self.items.read() - def __delitem__(self, item: T) -> None: + def __delitem__(self, item: T) -> None: # type: ignore if item not in self.items.read(): raise KeyError(f"{item} not found.") self.remove(item) - def __getitem__(self, index: int) -> T: + def __getitem__(self, index: int) -> T: # type: ignore return self.items.read()[index] - def __setitem__(self, index: int, value: T) -> None: + def __setitem__(self, index: int, value: T) -> None: # type: ignore self.items.set(index, value, hash(value)) - def __iter__(self) -> Iterable[T]: + def __iter__(self) -> Iterator[T]: for _, item in self.items.read().items(): yield item @@ -862,7 +866,7 @@ def __next__(self) -> T: self.iterator += 1 return item - def __add__(self, other: CRDTList[T]) -> CRDTList[T]: + def __add__(self, other: CRDTList[T]) -> CRDTList[T]: # type: ignore return self.extend(iter(other)) def __sub__(self, other: CRDTList[T]) -> CRDTList[T]: @@ -877,7 +881,7 @@ def __reversed__(self) -> CRDTList[T]: return self.items.read()[::-1] def __sizeof__(self) -> int: - return self.count() + return self.__len__() def __hash__(self): return hash(self.items) diff --git a/horao/logical/infrastructure.py b/horao/logical/infrastructure.py index acca60e..c90decb 100644 --- a/horao/logical/infrastructure.py +++ b/horao/logical/infrastructure.py @@ -130,7 +130,7 @@ def total_compute(self, hsn_only: bool = False) -> List[Compute]: Compute( sum([c.cores for c in n.cpus]), # type: ignore sum([r.size_gb for r in n.rams]), # type: ignore - len(n.accelerators) > 0, + len([m.accelerators for m in n.modules]) > 0, len(n.modules), ) ) diff --git a/horao/persistance/serialize.py b/horao/persistance/serialize.py index 5344c74..a8edd13 100644 --- a/horao/persistance/serialize.py +++ b/horao/persistance/serialize.py @@ -6,12 +6,13 @@ from networkx.convert import from_dict_of_dicts, to_dict_of_dicts # type: ignore +from horao.auth.roles import TenantController from horao.conceptual.claim import Reservation from horao.conceptual.crdt import ( + CRDTList, LastWriterWinsMap, LastWriterWinsRegister, ObservedRemovedSet, - CRDTList, ) from horao.conceptual.osi_layers import LinkLayer from horao.conceptual.support import LogicalClock, Update, UpdateType @@ -21,7 +22,7 @@ from horao.logical.resource import Compute, Storage from horao.physical.component import CPU, RAM, Accelerator, Disk from horao.physical.composite import Blade, Cabinet, Chassis, Node -from horao.physical.computer import Module, Server, ComputerList +from horao.physical.computer import ComputerList, Module, Server from horao.physical.hardware import HardwareList from horao.physical.network import ( NIC, @@ -32,11 +33,9 @@ RouterType, Switch, SwitchType, - NetworkList, ) from horao.physical.status import DeviceStatus from horao.physical.storage import StorageType -from horao.rbac import TenantOwner class HoraoEncoder(json.JSONEncoder): @@ -312,9 +311,7 @@ def default(self, obj): "name": obj.name, "number": obj.number, } - rows = [] - for row in obj.rows.read(): - rows = json.dumps(row, cls=HoraoEncoder) + rows = json.dumps(obj.rows, cls=HoraoEncoder) result["rows"] = rows if rows else None return result if isinstance(obj, DataCenterNetwork): @@ -363,9 +360,9 @@ def default(self, obj): "compute_limits": json.dumps(obj.compute_limits, cls=HoraoEncoder), "storage_limits": json.dumps(obj.storage_limits, cls=HoraoEncoder), } - if isinstance(obj, TenantOwner): + if isinstance(obj, TenantController): return { - "type": "TenantOwner", + "type": "TenantController", "name": obj.name, } if isinstance(obj, Tenant): @@ -743,8 +740,11 @@ def object_hook(obj): compute_limits=json.loads(obj["compute_limits"], cls=HoraoDecoder), storage_limits=json.loads(obj["storage_limits"], cls=HoraoDecoder), ) - if "type" in obj and obj["type"] == "TenantOwner": - return TenantOwner() + if "type" in obj and obj["type"] == "TenantController": + return TenantController( + name=obj["name"], + tenants=json.load(obj["tenants"]) if obj["tenants"] else None, + ) if "type" in obj and obj["type"] == "Tenant": return Tenant( name=obj["name"], diff --git a/horao/physical/computer.py b/horao/physical/computer.py index b1b4ed1..94d4fb3 100644 --- a/horao/physical/computer.py +++ b/horao/physical/computer.py @@ -31,23 +31,23 @@ def __init__( self.number = number self.cpus = HardwareList[CPU]( hardware=cpus if isinstance(cpus, list) else None, - items=cpus if isinstance(cpus, HardwareList) else None, + items=cpus if isinstance(cpus, HardwareList) else None, # type: ignore ) self.rams = HardwareList[RAM]( hardware=rams if isinstance(rams, list) else None, - items=rams if isinstance(rams, HardwareList) else None, + items=rams if isinstance(rams, HardwareList) else None, # type: ignore ) self.nics = HardwareList[NIC]( hardware=nics if isinstance(nics, list) else None, - items=nics if isinstance(nics, HardwareList) else None, + items=nics if isinstance(nics, HardwareList) else None, # type: ignore ) self.disks = HardwareList[Disk]( hardware=disks if isinstance(disks, list) else None, - items=disks if isinstance(disks, HardwareList) else None, + items=disks if isinstance(disks, HardwareList) else None, # type: ignore ) self.accelerators = HardwareList[Accelerator]( hardware=accelerators if isinstance(accelerators, list) else None, - items=accelerators if isinstance(accelerators, HardwareList) else None, + items=accelerators if isinstance(accelerators, HardwareList) else None, # type: ignore ) def __copy__(self): diff --git a/horao/physical/network.py b/horao/physical/network.py index 2f43e3d..7d0c293 100644 --- a/horao/physical/network.py +++ b/horao/physical/network.py @@ -150,6 +150,9 @@ def __init__( ): super().__init__(serial_number, model, number, ports) + def __iter__(self): + return iter(self.ports) + class Firewall(NetworkDevice): def __init__( diff --git a/horao/rbac/__init__.py b/horao/rbac/__init__.py deleted file mode 100644 index 9b51b83..0000000 --- a/horao/rbac/__init__.py +++ /dev/null @@ -1,22 +0,0 @@ -# -*- coding: utf-8 -*-# -"""Role-based access control module for the application. - -This module contains the classes and functions that are used to authorize the users of the application. The module -only contains authorization. The auth module is used to define the authentication method. -""" -from .permission import ( - Permissions, - Namespace, - Permission, - PermissionSession, - User, - permission_required, - UnauthorizedError, -) -from .roles import ( - NetworkEngineer, - SystemEngineer, - SecurityEngineer, - TenantOwner, - Delegate, -) diff --git a/horao/rbac/permission.py b/horao/rbac/permission.py deleted file mode 100644 index c225300..0000000 --- a/horao/rbac/permission.py +++ /dev/null @@ -1,128 +0,0 @@ -# -*- coding: utf-8 -*-# -"""Permission classes and functions for the role-based access control module.""" -from __future__ import annotations - -from abc import ABC -from enum import Enum, auto -from typing import Dict, Callable, TypeVar, List - - -class Permission(Enum): - """We have two permissions: Read and Write, Write implies read.""" - - Read = auto() - Write = auto() - - -class Permissions(ABC): - def __init__(self, name: str, permissions: Dict[Namespace, Permission]): - self.name = name - self.permissions: Dict[Namespace, Permission] = permissions - - def __len__(self): - return len(self.permissions.keys()) - - def __repr__(self): - return f"" - - def __iter__(self): - for permission in self.permissions: - yield permission - - def can_read(self, namespace: Namespace): - if namespace not in self.permissions.keys(): - return False - return ( - self.permissions[namespace] == Permission.Read - or self.permissions[namespace] == Permission.Write - ) - - def can_write(self, namespace: Namespace): - if namespace not in self.permissions.keys(): - return False - return self.permissions[namespace] == Permission.Write - - -class Namespace(Enum): - """Namespaces define where a permission applies""" - - System = auto() - Network = auto() - - -RT = TypeVar("RT") - - -def permission_required( - namespace: Namespace, permission: Permission -) -> Callable[[Callable[..., RT]], Callable[..., RT]]: - """ - Decorator to check if a user has the permission to access a resource. - :param namespace: namespace to check - :param permission: permission to validate - :return: function call if the user has the permission - :raises: UnauthorizedError if the user does not have the permission - """ - - def decorator(func: Callable[..., RT]) -> Callable[..., RT]: - def wrapper(session: PermissionSession, *args: str, **kwargs: int) -> RT: - if not session.check_permission(namespace, permission): - raise UnauthorizedError({session.user}, func, *args, **kwargs) - return func(session, *args, **kwargs) - - return wrapper - - return decorator - - -class UnauthorizedError(RuntimeError): - """We raise this exception when a user tries to access a resource without the proper permissions.""" - - def __init__(self, user, function: Callable[..., RT], *args: str, **kwargs: int): - super().__init__("unauthorized access to resource") - self.user = user - self.function = function - self.args = args - self.kwargs = kwargs - - def __str__(self): - return f"UnauthorizedError from {self.user}: [{self.function}] - ({self.args} ; {self.kwargs})" - - -class PermissionSession: - - def __init__(self, user: User, permissions: List[Permissions]): - self.user = user - self.permissions = permissions - - def check_permission(self, namespace: Namespace, permission: Permission): - """ - Check if the user has the permission to access the namespace given the permission. - :param namespace: Namespace to validate - :param permission: Permission to check - :return: True if the user has the permission, False otherwise - """ - if permission.Read: - return any([p for p in self.permissions if p.can_read(namespace)]) - elif permission.Write: - return any([p for p in self.permissions if p.can_write(namespace)]) - else: - return False - - def __str__(self): - return f"{self.user} : {', '.join([p.name for p in self.permissions])}" - - -class SessionBuilder: - pass - - -class User: - """Simplistic class to map groups to roles.""" - - def __init__(self, name: str, groups: List[str]): - self.name = name - self.groups = groups - - def roles(self): - pass diff --git a/horao/auth/basic.py b/tests/basic_auth.py similarity index 100% rename from horao/auth/basic.py rename to tests/basic_auth.py diff --git a/tests/test_api.py b/tests/test_api.py index 75ff2f9..2d599cd 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -6,10 +6,9 @@ from starlette.testclient import TestClient from horao import init -from horao.auth import BasicAuthBackend -from horao.auth.basic import basic_auth from horao.logical.infrastructure import LogicalInfrastructure from horao.persistance import HoraoEncoder +from tests.basic_auth import BasicAuthBackend, basic_auth from tests.helpers import initialize_logical_infrastructure