Skip to content

Commit

Permalink
add django signal projector, improve roles, added token-based roles s…
Browse files Browse the repository at this point in the history
…ervice, several fixes, improvements and cleanup
  • Loading branch information
douwevandermeij committed Jun 30, 2021
1 parent d867cbd commit b3bc0f1
Show file tree
Hide file tree
Showing 15 changed files with 293 additions and 16 deletions.
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ cover/

# SonarQube
sonar-project.properties
.scannerwork

# Translations
*.mo
Expand Down Expand Up @@ -75,7 +76,6 @@ __pycache__
.pytest_cache
htmlcov
site
coverage.xml
.netlify
test.db
log.txt
Expand Down
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
22 changes: 22 additions & 0 deletions fractal/contrib/django/projectors.py
Original file line number Diff line number Diff line change
@@ -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)
2 changes: 1 addition & 1 deletion fractal/contrib/django/repositories.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down
3 changes: 2 additions & 1 deletion fractal/contrib/roles/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,5 @@


class Role(EnumModel):
pass
OWNER = "role.owner"
ADMIN = "role.admin"
31 changes: 30 additions & 1 deletion fractal/contrib/roles/services.py
Original file line number Diff line number Diff line change
@@ -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):
Expand All @@ -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"]))
Empty file.
11 changes: 11 additions & 0 deletions fractal/contrib/tokens/exceptions.py
Original file line number Diff line number Diff line change
@@ -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
152 changes: 152 additions & 0 deletions fractal/contrib/tokens/services.py
Original file line number Diff line number Diff line change
@@ -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
2 changes: 2 additions & 0 deletions fractal/contrib/tokens/settings.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
REFRESH_TOKEN_EXPIRATION_SECONDS = 60 * 60 * 24 # 1 day
ACCESS_TOKEN_EXPIRATION_SECONDS = 60 * 10 # 10 minutes
11 changes: 8 additions & 3 deletions fractal/core/models/__init__.py
Original file line number Diff line number Diff line change
@@ -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):
Expand Down
44 changes: 43 additions & 1 deletion fractal/core/process/actions/__init__.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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
4 changes: 0 additions & 4 deletions fractal/core/repositories/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,3 @@ def find(
@abstractmethod
def is_healthy(self) -> bool:
raise NotImplementedError

@abstractmethod
def process(self) -> bool:
raise NotImplementedError
Loading

0 comments on commit b3bc0f1

Please sign in to comment.