-
Notifications
You must be signed in to change notification settings - Fork 2
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request #27 from opengeokube/feature_26
Feature #26: `web` component
- Loading branch information
Showing
91 changed files
with
7,294 additions
and
1,406 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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"] |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) | ||
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,2 @@ | ||
from .context import Context | ||
from .manager import assert_not_anonymous |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1 @@ | ||
from .on_startup import all_onstartup_callbacks |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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] |
Oops, something went wrong.