Repository: s-kostyuk/everpl

Issue:
Implement Authentication aspect (in a form of context manager)
Implement an Aspect that will control Authorization in the system. I.e. will check if the current user is able to (is authorized to) execute a requested action.

Use Python's Context Managers and a concept of Ambient Context for passing of authentication tokens, use Interceptor pattern as an implementation detail for controlling of calls to Services.

Blocked by #63 

Discussion:

Patch:
diff --git a/dpl/auth/auth_aspect.py b/dpl/auth/auth_aspect.py
new file mode 100644
index 0000000..a861d9a
--- /dev/null
+++ b/dpl/auth/auth_aspect.py
@@ -0,0 +1,94 @@
+"""
+This module contains a definition of authorization aspect -
+a factory of interceptor methods which will intercept original
+calls, extract an access token and other contextual information,
+check user authentication using AuthService.check_permission
+method and only then will pass execution to the wrapped method
+"""
+import inspect
+import functools
+from typing import Callable
+
+from .exceptions import AuthMissingTokenError
+from .abs_auth_service import AbsAuthService
+from .auth_context import AuthContext
+
+
+class AuthAspect(object):
+    """
+    A callable factory class that contains a definition of
+    an authorization advice. Such advice contains a logic
+    of extracting of contextual information (like an access
+    token, name of the domain / requested service and
+    requested action / service method) and checking it with
+    AuthService.check_permission functionality.
+    """
+    def __init__(self, auth_service: AbsAuthService, auth_context: AuthContext):
+        """
+        Constructor. Accepts an instance of AuthService used
+        for permission checking and an instance of AuthContext
+        for extraction of an access token
+
+        :param auth_service: an instance of AuthService,
+               an object that will be used for permission
+               (authorization) checking
+        :param auth_context: an instance of AuthContext, an
+               object that will be used for extraction of
+               contextual information and access token in
+               particular
+        """
+        self._auth_service = auth_service
+        self._auth_context = auth_context
+
+    def __call__(self, wrapped_f: Callable) -> Callable:
+        """
+        Returns a new callable which wraps the specified
+        wrapped_f callable with authorization logic
+
+        :param wrapped_f: a callable to be wrapped
+        :return: a new callable which wraps the specified one
+        """
+        original_wrapped = inspect.unwrap(wrapped_f)
+        qualname = original_wrapped.__qualname__  # type: str
+
+        domain, method_name = qualname.rsplit(sep='.', maxsplit=1)
+
+        @functools.wraps(wrapped_f)
+        def _auth_advice(*args, **kwargs):
+            """
+            An authorization advice. Checks if the access token
+            associated with a current authorization context permits
+            execution of the requested action in the specified domain
+            with the specified arguments and, if everything is OK,
+            passes control to the wrapped (requested) method.
+
+            :param args: positional arguments to be passed to the
+                   wrapped callable
+            :param kwargs: keyword arguments to be passed to the
+                   wrapped callable
+            :return: the same value as was returned by the wrapped
+                     callable
+            :raises: the same exceptions as was raised by the wrapped
+                     callable
+            :raises AuthMissingTokenError: if there is no access token
+                    saved in the current context
+            """
+            token = self._auth_context.current_token
+
+            if token is None:
+                raise AuthMissingTokenError(
+                    "Denied access to a protected service: Method was "
+                    "called outside of an Authorization Context?"
+                )
+
+            self._auth_service.check_permission(
+                access_token=token,
+                in_domain=domain,
+                to_execute=method_name,
+                args=args,
+                kwargs=kwargs
+            )
+
+            return wrapped_f(*args, **kwargs)
+
+        return _auth_advice
diff --git a/dpl/auth/auth_context.py b/dpl/auth/auth_context.py
new file mode 100644
index 0000000..edb7ae8
--- /dev/null
+++ b/dpl/auth/auth_context.py
@@ -0,0 +1,81 @@
+"""
+This module contains a definition of AuthContext - an
+implementation of Ambient Context pattern based on a Python's
+context manager
+"""
+import contextlib
+from typing import Optional
+
+from dpl.utils.get_concurrent_identity import get_concurrent_identity
+
+
+class AuthContext(object):
+    """
+    AuthContext is an implementation of Ambient Context
+    pattern which:
+
+    - will save current authentication token in a temporary
+      storage;
+    - will allow to read currently stored token;
+    - will remove a token from a temporary storage on an exit
+      from a context;
+    - will be used by AuthAspect for authorization and
+      authentication checking.
+
+    All received access tokens are bound to the current thread (for
+    multi-threaded environments) or task (for asynchronous
+    environments).
+    """
+    def __init__(self):
+        """
+        Constructor. Initializes an internal storage of tokens
+        """
+        # key: a thread- or task-unique identifier
+        # value: a token itself
+        self._tokens = dict()
+
+    def __call__(self, token: str):
+        """
+        Constructs and returns a Python context manager. Adds auth
+        token to the temporary storage
+
+        :param token: an auth token to be used for the current
+               Thread or Task
+        :return: an instance of a Python context manager
+        """
+        return self._context_manager(token)
+
+    @contextlib.contextmanager
+    def _context_manager(self, token: str):
+        """
+        An implementation of a Python context manager. Saves the
+        specified token to the temporary storage, allows for a
+        code in the context to execute and finally removes an
+        access token from a temporary storage
+
+        For more information about the structure of context
+        managers see Python documentation at https://goo.gl/6g7VES
+
+        :param token: an auth token to be used for the current
+               Thread or Task
+        :return: None
+        """
+        concurrent_id = get_concurrent_identity()
+        self._tokens[concurrent_id] = token
+
+        yield
+
+        self._tokens.pop(concurrent_id)
+
+    @property
+    def current_token(self) -> Optional[str]:
+        """
+        Returns an access token bound to the current Thread
+        or Task. Returns None if no access token was bound
+
+        :return: an access token which is currently bound to
+                 the current Thread or Task; returns None if
+                 no access token was bound
+        """
+        concurrent_id = get_concurrent_identity()
+        return self._tokens.get(concurrent_id, None)
diff --git a/dpl/auth/exceptions.py b/dpl/auth/exceptions.py
index 102c872..6b8ae35 100644
--- a/dpl/auth/exceptions.py
+++ b/dpl/auth/exceptions.py
@@ -30,3 +30,11 @@ class AuthInvalidTokenError(ServiceValidationError):
     was revoked or was not existing at all
     """
     pass
+
+
+class AuthMissingTokenError(ServiceValidationError):
+    """
+    Exception to be raised if an attempt to extract an
+    access token out of a current context was failed
+    """
+    pass


Test patch:
diff --git a/tests/auth/test_auth_aspect.py b/tests/auth/test_auth_aspect.py
new file mode 100644
index 0000000..068ae82
--- /dev/null
+++ b/tests/auth/test_auth_aspect.py
@@ -0,0 +1,87 @@
+import unittest
+from unittest.mock import Mock
+
+from dpl.auth.auth_context import AuthContext
+from dpl.auth.abs_auth_service import AbsAuthService
+from dpl.repo_impls.in_memory.placement_repository import PlacementRepository
+from dpl.service_impls.placement_service import PlacementService
+from dpl.auth.auth_aspect import AuthAspect, AuthMissingTokenError
+from dpl.utils.simple_interceptor import SimpleInterceptor
+from dpl.utils.generate_token import generate_token
+
+
+class TestAuthAspect(unittest.TestCase):
+    def setUp(self):
+        self.auth_service = Mock(spec=AbsAuthService)  # type: AbsAuthService
+        self.auth_service.check_permission = Mock()
+
+        self.auth_context = AuthContext()
+        self.auth_aspect = AuthAspect(
+            auth_service=self.auth_service,
+            auth_context=self.auth_context
+        )
+        self.placement_repo = PlacementRepository()
+        self.placement_service = PlacementService(self.placement_repo)
+        self.protected_service = SimpleInterceptor(
+            wrapped=self.placement_service,
+            aspect=self.auth_aspect
+        )  # type: PlacementService
+
+        self.sample_token = generate_token()
+
+    def test_permission_checked(self):
+        """
+        Tests that a random method of a protected service (or
+        some another class) was correctly intercepted, access
+        token with other contextual information was extracted
+        and passed to the AuthService's check_permission method
+
+        :return: None
+        """
+        with self.auth_context(token=self.sample_token):
+            self.protected_service.view_all()
+
+        mocked_method = self.auth_service.check_permission  # type: Mock
+        mocked_method.assert_called_with(
+            access_token=self.sample_token,
+            in_domain='PlacementService',
+            to_execute='view_all',
+            args=(),
+            kwargs=dict()
+        )
+
+    def test_call_arguments_passed(self):
+        """
+        Tests that on permission checking all call arguments
+        was intercepted and passed to the authorization
+        checker
+
+        :return: None
+        """
+        placement_name = "Some Placement"
+        placement_image = None
+
+        with self.auth_context(token=self.sample_token):
+            self.protected_service.create_placement(
+                placement_name,
+                image_url=placement_image
+            )
+
+        mocked_method = self.auth_service.check_permission  # type: Mock
+        mocked_method.assert_called_with(
+            access_token=self.sample_token,
+            in_domain='PlacementService',
+            to_execute='create_placement',
+            args=(placement_name,),
+            kwargs={'image_url': placement_image}
+        )
+
+    def test_out_of_context_error(self):
+        """
+        Tests that if an access token is missing in an
+        AuthContext, then an FIXME error will be raised
+
+        :return: None
+        """
+        with self.assertRaises(AuthMissingTokenError):
+            self.protected_service.view_all()
diff --git a/tests/auth/test_auth_context.py b/tests/auth/test_auth_context.py
new file mode 100644
index 0000000..aea5001
--- /dev/null
+++ b/tests/auth/test_auth_context.py
@@ -0,0 +1,105 @@
+import unittest
+import uuid
+import threading
+import asyncio
+
+from dpl.auth.auth_context import AuthContext
+
+
+class TestAuthContext(unittest.TestCase):
+    def test_same_thread(self):
+        ac = AuthContext()
+        token = uuid.uuid4().hex
+
+        self.assertIsNone(ac.current_token)
+
+        with ac(token=token):
+            self.assertEqual(ac.current_token, token)
+
+        self.assertIsNone(ac.current_token)
+
+    def test_thread_separation(self):
+        def _check_token_unavailable():
+            self.assertIsNone(ac.current_token)
+
+        ac = AuthContext()
+        token = uuid.uuid4().hex
+
+        thread = threading.Thread(target=_check_token_unavailable)
+        thread2 = threading.Thread(target=_check_token_unavailable)
+
+        with ac(token=token):
+            thread.start()
+            thread2.start()
+
+            thread.join()
+            thread2.join()
+
+    def test_thread_unique_tokens(self):
+        def _enter_context_and_save_token():
+            token = uuid.uuid4().hex
+
+            with ac(token=token):
+                tokens.append(token)
+
+        tokens = list()
+        ac = AuthContext()
+
+        thread = threading.Thread(target=_enter_context_and_save_token)
+        thread2 = threading.Thread(target=_enter_context_and_save_token)
+
+        thread.start()
+        thread2.start()
+
+        thread.join()
+        thread2.join()
+
+        self.assertTrue(
+            len(tokens) == 2
+        )
+
+        self.assertNotEqual(
+            tokens[0], tokens[1]
+        )
+
+    def test_task_separation(self):
+        async def _check_token_unavailable():
+            self.assertIsNone(ac.current_token)
+
+        ac = AuthContext()
+        token = uuid.uuid4().hex
+        loop = asyncio.get_event_loop()
+
+        with ac(token=token):
+            loop.run_until_complete(_check_token_unavailable())
+            loop.run_until_complete(_check_token_unavailable())
+
+    def test_task_unique_tokens(self):
+        async def _enter_context_and_save_token():
+            token = uuid.uuid4().hex
+
+            with ac(token=token):
+                tokens.append(token)
+                await asyncio.sleep(0.02)
+
+                self.assertTrue(
+                    len(tokens) == 2
+                )
+
+        tokens = list()
+        ac = AuthContext()
+        loop = asyncio.get_event_loop()
+
+        t1 = asyncio.ensure_future(_enter_context_and_save_token())
+        t2 = asyncio.ensure_future(_enter_context_and_save_token())
+
+        loop.run_until_complete(t1)
+        loop.run_until_complete(t2)
+
+        self.assertTrue(
+            len(tokens) == 2
+        )
+
+        self.assertNotEqual(
+            tokens[0], tokens[1]
+        )