From b3bc0f1c5dd95297974893f7f0e0c6562b1a2ee7 Mon Sep 17 00:00:00 2001 From: Douwe van der Meij Date: Wed, 30 Jun 2021 10:36:06 +0200 Subject: [PATCH] add django signal projector, improve roles, added token-based roles service, several fixes, improvements and cleanup --- .gitignore | 2 +- Makefile | 2 +- fractal/contrib/django/projectors.py | 22 +++ fractal/contrib/django/repositories.py | 2 +- fractal/contrib/roles/models.py | 3 +- fractal/contrib/roles/services.py | 31 +++- fractal/contrib/tokens/__init__.py | 0 fractal/contrib/tokens/exceptions.py | 11 ++ fractal/contrib/tokens/services.py | 152 ++++++++++++++++++ fractal/contrib/tokens/settings.py | 2 + fractal/core/models/__init__.py | 11 +- fractal/core/process/actions/__init__.py | 44 ++++- fractal/core/repositories/__init__.py | 4 - .../repositories/inmemory_repository_mixin.py | 6 +- fractal/core/utils/application_context.py | 17 ++ 15 files changed, 293 insertions(+), 16 deletions(-) create mode 100644 fractal/contrib/django/projectors.py create mode 100644 fractal/contrib/tokens/__init__.py create mode 100644 fractal/contrib/tokens/exceptions.py create mode 100644 fractal/contrib/tokens/services.py create mode 100644 fractal/contrib/tokens/settings.py diff --git a/.gitignore b/.gitignore index 3ad8b90..501572c 100644 --- a/.gitignore +++ b/.gitignore @@ -43,6 +43,7 @@ cover/ # SonarQube sonar-project.properties +.scannerwork # Translations *.mo @@ -75,7 +76,6 @@ __pycache__ .pytest_cache htmlcov site -coverage.xml .netlify test.db log.txt diff --git a/Makefile b/Makefile index 1c78c8a..1f602b6 100644 --- a/Makefile +++ b/Makefile @@ -25,7 +25,7 @@ publish: ## Publish to PyPi push: ## Push code with tags git push && git push --tags -sonar: ## Run tests +sonar: ## Run sonar-scanner make coverage python -m coverage xml sonar-scanner diff --git a/fractal/contrib/django/projectors.py b/fractal/contrib/django/projectors.py new file mode 100644 index 0000000..08e19cb --- /dev/null +++ b/fractal/contrib/django/projectors.py @@ -0,0 +1,22 @@ +import datetime +from typing import Type + +from django.dispatch import Signal + +from fractal.core.event_sourcing.event import SendingEvent +from fractal.core.event_sourcing.event_projector import EventProjector +from fractal.core.event_sourcing.message import Message + +fractal_event_signal = Signal(providing_args=["message"]) + + +class DjangoSignalEventProjector(EventProjector): + def project(self, id: str, event: Type[SendingEvent]): + message = Message( + id=id, + occurred_on=datetime.datetime.now(tz=datetime.timezone.utc), + event_type=event.__class__.__name__, + event=event, + object_id=str(event.object_id), + ) + fractal_event_signal.send(sender=self.__class__, message=message) diff --git a/fractal/contrib/django/repositories.py b/fractal/contrib/django/repositories.py index af5529d..cbdc8d7 100644 --- a/fractal/contrib/django/repositories.py +++ b/fractal/contrib/django/repositories.py @@ -1,7 +1,7 @@ from dataclasses import asdict from typing import Dict, Generator, Optional, Type -from django.db.models import Model, Q, ForeignKey +from django.db.models import ForeignKey, Model, Q from fractal.contrib.django.specifications import DjangoOrmSpecificationBuilder from fractal.core.repositories import Entity, Repository diff --git a/fractal/contrib/roles/models.py b/fractal/contrib/roles/models.py index 19897dc..9f64d38 100644 --- a/fractal/contrib/roles/models.py +++ b/fractal/contrib/roles/models.py @@ -2,4 +2,5 @@ class Role(EnumModel): - pass + OWNER = "role.owner" + ADMIN = "role.admin" diff --git a/fractal/contrib/roles/services.py b/fractal/contrib/roles/services.py index d59f031..5edebd8 100644 --- a/fractal/contrib/roles/services.py +++ b/fractal/contrib/roles/services.py @@ -1,8 +1,12 @@ +from abc import abstractmethod +from functools import wraps from typing import Set from fractal.contrib.roles.models import Role +from fractal.contrib.tokens.services import TokenService from fractal.core.exceptions import DomainException from fractal.core.services import Service +from fractal.core.utils.application_context import ApplicationContext class NoPermissionException(DomainException): @@ -11,14 +15,39 @@ class NoPermissionException(DomainException): class RolesService(Service): + @abstractmethod + def verify(self, *args, **kwargs): + raise NotImplementedError + @staticmethod def verify_roles(user_roles: Set[Role], required_roles: Set[Role]): if not RolesService.verify_roles_check(user_roles, required_roles): raise NoPermissionException( - f"Got '{user_roles}' while one of '{required_roles}' or higher is required." + f"Got '{user_roles}' while one of '{[role.value for role in required_roles]}' or higher is required." ) return True @staticmethod def verify_roles_check(user_roles: Set[Role], required_roles: Set[Role]) -> bool: return bool(user_roles & required_roles) + + +class TokenRolesService(RolesService): + def __init__(self, token_service: TokenService): + self.token_service = token_service + + def verify(self, required_roles: Set[Role], *args, **kwargs): + def decorator(func): + @wraps(func) + def wrap(token: str, *args, **kwargs): + payload = self.token_service.verify(token) + self.verify_roles(set(payload["roles"]), required_roles) + return func(token, *args, **kwargs) + + return wrap + + return decorator + + @classmethod + def install(cls, context: ApplicationContext): + yield cls(*context.get_parameters(["token_service"])) diff --git a/fractal/contrib/tokens/__init__.py b/fractal/contrib/tokens/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/fractal/contrib/tokens/exceptions.py b/fractal/contrib/tokens/exceptions.py new file mode 100644 index 0000000..f2704cc --- /dev/null +++ b/fractal/contrib/tokens/exceptions.py @@ -0,0 +1,11 @@ +from fractal.core.exceptions import DomainException + + +class TokenInvalidException(DomainException): + code = "TOKEN_INVALID" + status_code = 403 + + +class TokenExpiredException(DomainException): + code = "TOKEN_EXPIRED" + status_code = 403 diff --git a/fractal/contrib/tokens/services.py b/fractal/contrib/tokens/services.py new file mode 100644 index 0000000..47cb6ce --- /dev/null +++ b/fractal/contrib/tokens/services.py @@ -0,0 +1,152 @@ +import json +import uuid +from abc import abstractmethod +from calendar import timegm +from datetime import datetime +from typing import Dict + +import jwt +from jwt import DecodeError, ExpiredSignatureError + +from fractal.contrib.tokens.exceptions import ( + TokenExpiredException, + TokenInvalidException, +) +from fractal.contrib.tokens.settings import ( + ACCESS_TOKEN_EXPIRATION_SECONDS, + REFRESH_TOKEN_EXPIRATION_SECONDS, +) +from fractal.core.services import Service +from fractal.core.utils.application_context import ApplicationContext + + +class TokenService(Service): + @abstractmethod + def generate( + self, + payload: Dict, + token_type: str = "access", + seconds_valid: int = ACCESS_TOKEN_EXPIRATION_SECONDS, + ) -> str: + raise NotImplementedError + + def _prepare( + self, payload: Dict, token_type: str, seconds_valid: int, issuer: str + ) -> Dict: + utcnow = timegm(datetime.utcnow().utctimetuple()) + if not seconds_valid: + seconds_valid = ( + REFRESH_TOKEN_EXPIRATION_SECONDS + if token_type == "refresh" + else ACCESS_TOKEN_EXPIRATION_SECONDS + ) + payload.update( + { + "iat": utcnow, + "nbf": utcnow, + "jti": str(uuid.uuid4()), + "iss": issuer, + "exp": utcnow + seconds_valid, + "typ": token_type, + } + ) + return payload + + @abstractmethod + def verify(self, token: str): + raise NotImplementedError + + +class DummyJsonTokenService(TokenService): + def generate( + self, + payload: Dict, + token_type: str = "access", + seconds_valid: int = ACCESS_TOKEN_EXPIRATION_SECONDS, + ) -> str: + return json.dumps(payload) + + def verify(self, token: str): + return json.loads(token) + + +class SymmetricJwtTokenService(TokenService): + def __init__(self, issuer: str, secret: str): + self.issuer = issuer + self.secret = secret + self.algorithm = "HS256" + + @classmethod + def install(cls, context: ApplicationContext): + app_name, app_env, app_domain, secret_key = context.get_parameters( + ["app_name", "app_env", "app_domain", "secret_key"] + ) + yield cls( + f"{app_name}@{app_env}.{app_domain}", + secret_key, + ) + + def generate( + self, + payload: Dict, + token_type: str = "access", + seconds_valid: int = ACCESS_TOKEN_EXPIRATION_SECONDS, + ) -> str: + return jwt.encode( + self._prepare(payload, token_type, seconds_valid, self.issuer), + self.secret, + algorithm=self.algorithm, + ) + + def verify(self, token: str): + try: + payload = jwt.decode(token, self.secret, algorithms=self.algorithm) + except DecodeError: + raise TokenInvalidException("The supplied token is invalid!") + except ExpiredSignatureError: + raise TokenExpiredException("The supplied token is expired!") + if payload["typ"] != "access": + raise TokenInvalidException("The supplied token is invalid!") + return payload + + +class AsymmetricJwtTokenService(TokenService): + def __init__(self, issuer: str, private_key: str, public_key: str): + self.issuer = issuer + self.private_key = private_key + self.public_key = public_key + self.algorithm = "RS256" + + @classmethod + def install(cls, context: ApplicationContext): + app_name, app_env, app_domain, private_key, public_key = context.get_parameters( + ["app_name", "app_env", "app_domain", "private_key", "public_key"] + ) + yield cls( + f"{app_name}@{app_env}.{app_domain}", + private_key, + public_key, + ) + + def generate( + self, + payload: Dict, + token_type: str = "access", + seconds_valid: int = ACCESS_TOKEN_EXPIRATION_SECONDS, + ) -> str: + return jwt.encode( + self._prepare(payload, token_type, seconds_valid, self.issuer), + self.private_key, + algorithm=self.algorithm, + ) + + def verify(self, token: str): + try: + payload = jwt.decode(token, self.public_key, algorithms=self.algorithm) + except DecodeError: + raise TokenInvalidException("The supplied token is invalid!") + except ExpiredSignatureError: + raise TokenExpiredException("The supplied token is expired!") + if payload["typ"] not in ["access", "refresh"]: + raise TokenInvalidException("The supplied token is invalid!") + return payload diff --git a/fractal/contrib/tokens/settings.py b/fractal/contrib/tokens/settings.py new file mode 100644 index 0000000..ae9ee36 --- /dev/null +++ b/fractal/contrib/tokens/settings.py @@ -0,0 +1,2 @@ +REFRESH_TOKEN_EXPIRATION_SECONDS = 60 * 60 * 24 # 1 day +ACCESS_TOKEN_EXPIRATION_SECONDS = 60 * 10 # 10 minutes diff --git a/fractal/core/models/__init__.py b/fractal/core/models/__init__.py index 35e9088..9eb66e7 100644 --- a/fractal/core/models/__init__.py +++ b/fractal/core/models/__init__.py @@ -1,19 +1,24 @@ -import dataclasses -from dataclasses import dataclass +from dataclasses import asdict, dataclass, fields from enum import Enum +from typing import Dict @dataclass class Model: @classmethod def clean(cls, **kwargs): - field_names = set(f.name for f in dataclasses.fields(cls)) + field_names = set(f.name for f in fields(cls)) return cls(**{k: v for k, v in kwargs.items() if k in field_names}) # NOQA @classmethod def from_dict(cls, data): return cls.clean(**data) + def update(self, model: Dict): + current = asdict(self) + current.update(model) + return self.from_dict(current) + @dataclass class Contract(Model): diff --git a/fractal/core/process/actions/__init__.py b/fractal/core/process/actions/__init__.py index da2ec69..2b260b6 100644 --- a/fractal/core/process/actions/__init__.py +++ b/fractal/core/process/actions/__init__.py @@ -1,3 +1,5 @@ +from typing import Optional + from fractal.core.process.action import Action from fractal.core.process.process_scope import ProcessScope from fractal.core.specifications.generic.specification import Specification @@ -54,11 +56,51 @@ def execute(self, scope: ProcessScope) -> ProcessScope: return scope +class UpdateEntityAction(Action): + def __init__(self, repository_key: str = "repository"): + self.repository_key = repository_key + + def execute(self, scope: ProcessScope) -> ProcessScope: + entity = scope["entity"].update(scope["contract"]) + scope["entity"] = scope[self.repository_key].update(entity) + return scope + + class FetchEntityAction(Action): - def __init__(self, specification: Specification, repository_key: str): + def __init__( + self, specification: Specification, repository_key: str = "repository" + ): self.specification = specification self.repository_key = repository_key def execute(self, scope: ProcessScope) -> ProcessScope: scope["entity"] = scope[self.repository_key].find_one(self.specification) return scope + + +class FindEntitiesAction(Action): + def __init__( + self, + specification: Optional[Specification] = None, + repository_key: str = "repository", + ): + self.specification = specification + self.repository_key = repository_key + + def execute(self, scope: ProcessScope) -> ProcessScope: + scope["entities"] = scope["entity"] = scope[self.repository_key].find( + self.specification + ) + return scope + + +class DeleteEntityAction(Action): + def __init__( + self, specification: Specification, repository_key: str = "repository" + ): + self.specification = specification + self.repository_key = repository_key + + def execute(self, scope: ProcessScope) -> ProcessScope: + scope[self.repository_key].remove_one(self.specification) + return scope diff --git a/fractal/core/repositories/__init__.py b/fractal/core/repositories/__init__.py index 509bcde..e6294e4 100644 --- a/fractal/core/repositories/__init__.py +++ b/fractal/core/repositories/__init__.py @@ -32,7 +32,3 @@ def find( @abstractmethod def is_healthy(self) -> bool: raise NotImplementedError - - @abstractmethod - def process(self) -> bool: - raise NotImplementedError diff --git a/fractal/core/repositories/inmemory_repository_mixin.py b/fractal/core/repositories/inmemory_repository_mixin.py index 486e024..e575f66 100644 --- a/fractal/core/repositories/inmemory_repository_mixin.py +++ b/fractal/core/repositories/inmemory_repository_mixin.py @@ -17,8 +17,8 @@ def update(self, entity: Entity, upsert=False) -> Entity: return self.add(entity) def remove_one(self, specification: Specification): - if self.find_one(specification): - del self.entities[id] + if obj := self.find_one(specification): + del self.entities[obj.id] def find_one(self, specification: Specification) -> Optional[Entity]: for entity in filter( @@ -27,7 +27,7 @@ def find_one(self, specification: Specification) -> Optional[Entity]: return entity def find( - self, specification: Specification = None + self, specification: Optional[Specification] = None ) -> Generator[Entity, None, None]: if specification: entities = filter( diff --git a/fractal/core/utils/application_context.py b/fractal/core/utils/application_context.py index a87cbdf..5744fc3 100644 --- a/fractal/core/utils/application_context.py +++ b/fractal/core/utils/application_context.py @@ -1,9 +1,11 @@ import logging import os from io import StringIO +from typing import List, Tuple from dotenv import load_dotenv +from fractal import FractalException from fractal.core.repositories import Repository from fractal.core.services import Service from fractal.core.utils.loggers import init_logging @@ -25,6 +27,7 @@ def load(self): init_logging(os.getenv("LOG_LEVEL", "INFO")) self.logger = logging.getLogger("app") self.repositories = [] + self.services = [] self.load_internal_services() self.load_repositories() @@ -32,6 +35,11 @@ def load(self): self.load_egress_services() self.load_command_bus() + for repository in self.repositories: + repository.is_healthy() + for service_name in self.services: + getattr(self, service_name).is_healthy() + def reload(self, defaults: dict): self.logger.debug(f"Reloading ApplicationContext with '{defaults}'") filelike = StringIO("\n".join([f"{k}={v}" for k, v, in defaults.items()])) @@ -76,6 +84,15 @@ def install_repository(self, repository): def install_service(self, service, *, name=""): if not name: name = camel_to_snake(service.__name__) + self.services.append(name) setattr( ApplicationContext, name, property(lambda self: next(service.install(self))) ) + + def get_parameters(self, parameters: List[str]) -> Tuple: + for parameter in parameters: + if not hasattr(self, parameter): + raise FractalException( + f"ApplicationContext does not provide '{parameter}'" + ) + return tuple(getattr(self, p) for p in parameters)