Skip to content

Commit

Permalink
Merge pull request #27 from opengeokube/feature_26
Browse files Browse the repository at this point in the history
Feature #26: `web` component
  • Loading branch information
Marco Mancini authored Jan 25, 2023
2 parents 5a24011 + a0bc7b7 commit 2bb8c70
Show file tree
Hide file tree
Showing 91 changed files with 7,294 additions and 1,406 deletions.
15 changes: 2 additions & 13 deletions api/Dockerfile
Original file line number Diff line number Diff line change
@@ -1,23 +1,12 @@
FROM continuumio/miniconda3
FROM rg.nl-ams.scw.cloud/dds-production/geokube:v0.2a5
WORKDIR /code
COPY ./api/requirements.txt /code/requirements.txt
RUN conda install -c conda-forge xesmf cartopy psycopg2 -y
RUN pip install --no-cache-dir --upgrade -r /code/requirements.txt
COPY geokube_packages/geokube-0.2a0-py3-none-any.whl /code
COPY geokube_packages/intake_geokube-0.1a0-py3-none-any.whl /code
RUN pip install /code/geokube-0.2a0-py3-none-any.whl
RUN pip install /code/intake_geokube-0.1a0-py3-none-any.whl
# RUN conda install -c anaconda psycopg2
COPY ./utils/wait-for-it.sh /code/wait-for-it.sh
COPY ./datastore /code/app/datastore
COPY ./db/dbmanager /code/db/dbmanager
COPY ./geoquery/ /code/geoquery
COPY ./resources /code/app/resources
COPY ./api/app /code/app
COPY ./api/tests /code/tests
RUN pytest /code/tests
EXPOSE 80
# VOLUME /code
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "80"]
# if behind a proxy use --proxy-headers
# CMD ["uvicorn", "app.main:app", "--proxy-headers", "--host", "0.0.0.0", "--port", "80"]
CMD ["uvicorn", "app.main:app", "--proxy-headers", "--host", "0.0.0.0", "--port", "80"]
13 changes: 0 additions & 13 deletions api/app/__init__.py
Original file line number Diff line number Diff line change
@@ -1,13 +0,0 @@
from .components.access import AccessManager
from .components.dataset import DatasetManager
from .components.file import FileManager
from .components.request import RequestManager
from .components.logging_conf import configure_logger

from .datastore.datastore import Datastore

configure_logger(AccessManager)
configure_logger(DatasetManager)
configure_logger(RequestManager)
configure_logger(FileManager)
configure_logger(Datastore)
52 changes: 52 additions & 0 deletions api/app/api_logging.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import os
from typing import Literal
import logging as default_logging


class UnknownRIDFilter(default_logging.Filter):
"""Logging filter which passes default value `rid`.
It can be replaced by `defaults` paramter of `logging.Formatter`
in Python 3.10."""

def filter(self, record):
if not hasattr(record, "rid"):
record.rid = "N/A"
return True


def get_dds_logger(
name: str,
level: Literal["debug", "info", "warning", "error", "critical"] = "info",
):
"""Get DDS logger with the expected format, handlers and formatter.
Parameters
----------
name : str
Name of the logger
level : str, default="info"
Value of the logging level. One out of ["debug", "info", "warn",
"error", "critical"].
Logging level is taken from the
enviornmental variable `LOGGING_FORMAT`. If this variable is not defined,
the value of the `level` argument is used.
Returns
-------
log : logging.Logger
Logger with the handlers set
"""
log = default_logging.getLogger(name)
format_ = os.environ.get(
"LOGGING_FORMAT",
"%(asctime)s %(name)s %(levelname)s %(rid)s %(message)s",
)
formatter = default_logging.Formatter(format_)
logging_level = os.environ.get("LOGGING_LEVEL", level.upper())
log.setLevel(logging_level)
stream_handler = default_logging.StreamHandler()
stream_handler.setFormatter(formatter)
stream_handler.setLevel(logging_level)
log.addHandler(stream_handler)
log.addFilter(UnknownRIDFilter())
return log
2 changes: 2 additions & 0 deletions api/app/auth/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
from .context import Context
from .manager import assert_not_anonymous
179 changes: 179 additions & 0 deletions api/app/auth/context.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
"""Module with auth utils"""
from uuid import UUID
from typing import Optional

from fastapi import Request
from db.dbmanager.dbmanager import DBManager

from ..api_logging import get_dds_logger
from .. import exceptions as exc

log = get_dds_logger(__name__)


class UserCredentials:
"""Class containing current user credentials"""

__slots__ = ("_user_id", "_user_key")

def __init__(
self, user_id: Optional[str] = None, user_key: Optional[str] = None
):
self._user_id = user_id
if self._user_id is None:
self._user_key = None
else:
self._user_key = user_key

@property
def is_public(self) -> bool:
"""Determine if the current user is public (anonymous)"""
return self._user_id is None

@property
def id(self) -> int:
"""Get the ID of the current user"""
return self._user_id

@property
def key(self) -> str:
"Get key of the current user"
return self._user_key

def __eq__(self, other) -> bool:
if not isinstance(other, UserCredentials):
return False
if self.id == other.id and self.key == other.key:
return True
return False

def __ne__(self, other):
return self != other

def __repr__(self):
return (
f"<UserCredentials(id={self.id}, key=***,"
f" is_public={self.is_public}>"
)


class Context:
"""The class managing execution context of the single request passing
through the Web component. Its attributes are immutable when set to
non-None values.
Context contains following attributes:
1. user: UserCredentials
Credentials of the user within the context
2. rid: UUID- like string
ID of the request passing throught the Web component
"""

__slots__ = ("rid", "user")

rid: str
user: UserCredentials

def __init__(self, rid: str, user: UserCredentials):
log.debug("creating new context", extra={"rid": rid})
self.rid = rid
self.user = user

@property
def is_public(self) -> bool:
"""Determine if the context contains an anonymous user"""
return self.user.is_public

def __delattr__(self, name):
if getattr(self, name, None) is not None:
raise AttributeError("The attribute '{name}' cannot be deleted!")
super().__delattr__(name)

def __setattr__(self, name, value):
if getattr(self, name, None) is not None:
raise AttributeError(
"The attribute '{name}' cannot modified when not None!"
)
super().__setattr__(name, value)


class ContextCreator:
"""Class managing the Context creation"""

@staticmethod
def new_context(
request: Request, *, rid: str, user_token: Optional[str] = None
) -> Context:
"""Create a brand new `Context` object based on the provided
`request`, `rid`, and `user_token` arguments.
Parameters
----------
request : fastapi.Request
A request for which context is about to be created
rid : str
ID of the DDS Request
user_token : str
Token of a user
Returns
-------
context : Context
A new context
Raises
------
ImproperUserTokenError
If user token is not in the right format
AuthenticationFailed
If provided api key does not agree with the one stored in the DB
"""
assert rid is not None, "DDS Request ID cannot be `None`!"
try:
user_credentials = UserCredentials(
*ContextCreator._get_user_id_and_key_from_token(user_token)
)
except exc.EmptyUserTokenError:
# NOTE: we then consider a user as anonymous
user_credentials = UserCredentials()
if not user_credentials.is_public:
log.debug("context authentication", extra={"rid": rid})
ContextCreator.authenticate(user_credentials)
context = Context(rid=rid, user=user_credentials)
return context

@staticmethod
def authenticate(user: UserCredentials):
"""Authenticate user. Verify that the provided api agrees with
the one stored in the database.
Parameters
----------
user : UserCredentials
Raises
------
AuthenticationFailed
If user with the given ID is found in the database but stored api key
is different than the provided one.
"""
user_db = DBManager().get_user_details(user.id)
if user_db.api_key != user.key:
raise exc.AuthenticationFailed(user.id)

@staticmethod
def _get_user_id_and_key_from_token(user_token: str):
if user_token is None or user_token.strip() == "":
raise exc.EmptyUserTokenError
if ":" not in user_token:
raise exc.ImproperUserTokenError
user_id, api_key, *rest = user_token.split(":")
if len(rest) > 0:
raise exc.ImproperUserTokenError
try:
_ = UUID(user_id, version=4)
except ValueError as err:
raise exc.ImproperUserTokenError from err
else:
return (user_id, api_key)
98 changes: 98 additions & 0 deletions api/app/auth/manager.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
"""Module with access/authentication functions"""
from inspect import signature
from functools import wraps
from typing import Optional

from ..api_logging import get_dds_logger
from ..auth import Context
from ..decorators_factory import assert_parameters_are_defined, bind_arguments
from .. import exceptions as exc

log = get_dds_logger(__name__)


def is_role_eligible_for_product(
context: Context,
product_role_name: Optional[str] = None,
user_roles_names: Optional[list[str]] = None,
):
"""Check if given role is eligible for the product with the provided
`product_role_name`.
Parameters
----------
product_role_name : str, optional, default=None
The role which is eligible for the given product.
If `None`, product_role_name is claimed to be public
user_roles_names: list of str, optional, default=None
A list of user roles names. If `None`, user_roles_names is claimed
to be public
Returns
-------
is_eligible : bool
Flag which indicate if any role within the given `user_roles_names`
is eligible for the product with `product_role_name`
"""
log.debug(
"verifying eligibility of the product role '%s' against roles '%s'",
product_role_name,
user_roles_names,
extra={"rid": context.rid},
)
if product_role_name == "public" or product_role_name is None:
return True
if user_roles_names is None:
# NOTE: it means, we consider the public profile
return False
if "admin" in user_roles_names:
return True
if product_role_name in user_roles_names:
return True
return False


def assert_is_role_eligible(
context: Context,
product_role_name: Optional[str] = None,
user_roles_names: Optional[list[str]] = None,
):
"""Assert that user role is eligible for the product
Parameters
----------
product_role_name : str, optional, default=None
The role which is eligible for the given product.
If `None`, product_role_name is claimed to be public
user_roles_names: list of str, optional, default=None
A list of user roles names. If `None`, user_roles_names is claimed
to be public
Raises
-------
AuthorizationFailed
"""
if not is_role_eligible_for_product(
context=context,
product_role_name=product_role_name,
user_roles_names=user_roles_names,
):
raise exc.AuthorizationFailed(user_id=context.user.id)


def assert_not_anonymous(func):
"""Decorator for convenient authentication management"""
sig = signature(func)
assert_parameters_are_defined(
sig, required_parameters=[("context", Context)]
)

@wraps(func)
def wrapper_sync(*args, **kwargs):
args_dict = bind_arguments(sig, *args, **kwargs)
context = args_dict["context"]
if context.is_public:
raise exc.AuthorizationFailed(user_id=None)
return func(*args, **kwargs)

return wrapper_sync
1 change: 1 addition & 0 deletions api/app/callbacks/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
from .on_startup import all_onstartup_callbacks
15 changes: 15 additions & 0 deletions api/app/callbacks/on_startup.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
"""Module with functions call during API server startup"""
from ..api_logging import get_dds_logger

from ..datastore.datastore import Datastore

log = get_dds_logger(__name__)


def _load_cache() -> None:
log.info("loading cache started...")
Datastore()._load_cache()
log.info("cache loaded succesfully!")


all_onstartup_callbacks = [_load_cache]
Loading

0 comments on commit 2bb8c70

Please sign in to comment.