ecodev-core 0.0.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of ecodev-core might be problematic. Click here for more details.

@@ -0,0 +1,79 @@
1
+ """
2
+ Module listing all public method from the ecodev_core modules
3
+ """
4
+ from ecodev_core.app_activity import AppActivity
5
+ from ecodev_core.app_activity import dash_monitor
6
+ from ecodev_core.app_activity import fastapi_monitor
7
+ from ecodev_core.app_activity import get_method
8
+ from ecodev_core.app_activity import get_recent_activities
9
+ from ecodev_core.app_rights import AppRight
10
+ from ecodev_core.app_user import AppUser
11
+ from ecodev_core.app_user import select_user
12
+ from ecodev_core.app_user import upsert_app_users
13
+ from ecodev_core.auth_configuration import AUTH
14
+ from ecodev_core.authentication import attempt_to_log
15
+ from ecodev_core.authentication import get_access_token
16
+ from ecodev_core.authentication import get_app_services
17
+ from ecodev_core.authentication import get_current_user
18
+ from ecodev_core.authentication import get_user
19
+ from ecodev_core.authentication import is_admin_user
20
+ from ecodev_core.authentication import is_authorized_user
21
+ from ecodev_core.authentication import is_monitoring_user
22
+ from ecodev_core.authentication import JwtAuth
23
+ from ecodev_core.authentication import SCHEME
24
+ from ecodev_core.authentication import Token
25
+ from ecodev_core.check_dependencies import check_dependencies
26
+ from ecodev_core.check_dependencies import compute_dependencies
27
+ from ecodev_core.custom_equal import custom_equal
28
+ from ecodev_core.db_connection import create_db_and_tables
29
+ from ecodev_core.db_connection import DB_URL
30
+ from ecodev_core.db_connection import delete_table
31
+ from ecodev_core.db_connection import engine
32
+ from ecodev_core.db_connection import get_session
33
+ from ecodev_core.db_connection import info_message
34
+ from ecodev_core.db_filters import ServerSideFilter
35
+ from ecodev_core.db_insertion import generic_insertion
36
+ from ecodev_core.db_insertion import get_raw_df
37
+ from ecodev_core.db_retrieval import count_rows
38
+ from ecodev_core.db_retrieval import get_rows
39
+ from ecodev_core.db_retrieval import ServerSideField
40
+ from ecodev_core.enum_utils import enum_converter
41
+ from ecodev_core.list_utils import first_or_default
42
+ from ecodev_core.list_utils import first_transformed_or_default
43
+ from ecodev_core.list_utils import group_by_value
44
+ from ecodev_core.list_utils import lselect
45
+ from ecodev_core.list_utils import lselectfirst
46
+ from ecodev_core.logger import log_critical
47
+ from ecodev_core.logger import logger_get
48
+ from ecodev_core.pandas_utils import jsonify_series
49
+ from ecodev_core.pandas_utils import pd_equals
50
+ from ecodev_core.permissions import Permission
51
+ from ecodev_core.pydantic_utils import Basic
52
+ from ecodev_core.pydantic_utils import CustomFrozen
53
+ from ecodev_core.pydantic_utils import Frozen
54
+ from ecodev_core.pydantic_utils import OrmFrozen
55
+ from ecodev_core.read_write import load_json_file
56
+ from ecodev_core.read_write import make_dir
57
+ from ecodev_core.read_write import write_json_file
58
+ from ecodev_core.safe_utils import boolify
59
+ from ecodev_core.safe_utils import floatify
60
+ from ecodev_core.safe_utils import intify
61
+ from ecodev_core.safe_utils import safe_clt
62
+ from ecodev_core.safe_utils import SafeTestCase
63
+ from ecodev_core.safe_utils import SimpleReturn
64
+ from ecodev_core.safe_utils import stringify
65
+
66
+
67
+ __all__ = [
68
+ 'AUTH', 'Token', 'get_app_services', 'attempt_to_log', 'get_current_user', 'is_admin_user',
69
+ 'write_json_file', 'load_json_file', 'make_dir', 'check_dependencies', 'compute_dependencies',
70
+ 'engine', 'create_db_and_tables', 'get_session', 'info_message', 'group_by_value', 'OrmFrozen',
71
+ 'first_or_default', 'lselect', 'lselectfirst', 'first_transformed_or_default', 'log_critical',
72
+ 'logger_get', 'Permission', 'AppUser', 'AppRight', 'Basic', 'Frozen', 'CustomFrozen', 'JwtAuth',
73
+ 'SafeTestCase', 'SimpleReturn', 'safe_clt', 'stringify', 'boolify', 'get_user', 'floatify',
74
+ 'delete_table', 'SCHEME', 'DB_URL', 'pd_equals', 'jsonify_series', 'upsert_app_users', 'intify',
75
+ 'enum_converter', 'ServerSideFilter', 'get_rows', 'count_rows', 'ServerSideField', 'get_raw_df',
76
+ 'generic_insertion', 'custom_equal', 'is_authorized_user', 'get_method', 'AppActivity',
77
+ 'fastapi_monitor', 'dash_monitor', 'is_monitoring_user', 'get_recent_activities', 'select_user',
78
+ 'get_access_token'
79
+ ]
@@ -0,0 +1,108 @@
1
+ """
2
+ Module implementing a simple monitoring table
3
+ """
4
+ import inspect
5
+ from datetime import datetime
6
+ from typing import Dict
7
+ from typing import Optional
8
+
9
+ from sqlmodel import col
10
+ from sqlmodel import Field
11
+ from sqlmodel import select
12
+ from sqlmodel import Session
13
+ from sqlmodel import SQLModel
14
+
15
+ from ecodev_core.app_user import AppUser
16
+ from ecodev_core.authentication import get_user
17
+ from ecodev_core.db_connection import engine
18
+
19
+
20
+ """
21
+ Simple helper to retrieve the method name in which this helper is called
22
+
23
+ NB: this is meant to stay a lambda, otherwise the name retrieved is get_method, not the caller
24
+ """
25
+
26
+
27
+ def get_method(): return inspect.stack()[1][3]
28
+
29
+
30
+ class AppActivityBase(SQLModel):
31
+ """
32
+ Simple monitoring class
33
+
34
+ Attributes are:
35
+ - user: the name of the user that triggered the monitoring log
36
+ - application: the application in which the user triggered the monitoring log
37
+ - method: the method called by the user that triggered the monitoring log
38
+ - relevant_option: if filled, complementary information on method (num of treated lines...)
39
+ """
40
+ user: str = Field(index=True)
41
+ application: str = Field(index=True)
42
+ method: str = Field(index=True)
43
+ relevant_option: Optional[str] = Field(index=True, default=None)
44
+
45
+
46
+ class AppActivity(AppActivityBase, table=True): # type: ignore
47
+ """
48
+ The table version of the AppActivityBase monitoring class
49
+ """
50
+ __tablename__ = 'app_activity'
51
+ id: Optional[int] = Field(default=None, primary_key=True)
52
+ created_at: datetime = Field(default_factory=datetime.utcnow)
53
+
54
+
55
+ def dash_monitor(method: str,
56
+ token: Dict,
57
+ application: str,
58
+ relevant_option: Optional[str] = None):
59
+ """
60
+ Generic dash monitor.
61
+
62
+ Attributes are:
63
+ - method: the method called by the user that triggered the monitoring log
64
+ - token: contains the information on the user that triggered the monitoring log
65
+ - application: the application in which the user triggered the monitoring log
66
+ - relevant_option: if filled, complementary information on method (num of treated lines...)
67
+ """
68
+ with Session(engine) as session:
69
+ user = get_user(token.get('token', {}).get('access_token'))
70
+ add_activity_to_db(method, user, application, session, relevant_option)
71
+
72
+
73
+ def fastapi_monitor(method: str,
74
+ user: AppUser,
75
+ application: str,
76
+ session: Session,
77
+ relevant_option: Optional[str] = None):
78
+ """
79
+ Generic fastapi monitor.
80
+
81
+ Attributes are:
82
+ - method: the method called by the user that triggered the monitoring log
83
+ - user: the name of the user that triggered the monitoring log
84
+ - application: the application in which the user triggered the monitoring log
85
+ - session: db connection
86
+ - relevant_option: if filled, complementary information on method (num of treated lines...)
87
+ """
88
+ add_activity_to_db(method, user, application, session, relevant_option)
89
+
90
+
91
+ def add_activity_to_db(method: str,
92
+ user: AppUser,
93
+ application: str,
94
+ session: Session,
95
+ relevant_option: Optional[str] = None):
96
+ """
97
+ Add a new entry in AppActivity given the passed arguments
98
+ """
99
+ session.add(AppActivity(user=user.user, application=application, method=method,
100
+ relevant_option=relevant_option))
101
+ session.commit()
102
+
103
+
104
+ def get_recent_activities(last_date: str, session: Session):
105
+ """
106
+ Returns all activities that happened after last_date
107
+ """
108
+ return session.exec(select(AppActivity).where(col(AppActivity.created_at) > last_date)).all()
@@ -0,0 +1,24 @@
1
+ """
2
+ Module implementing the sqlmodel orm part of the right table
3
+ """
4
+ from typing import Optional
5
+ from typing import TYPE_CHECKING
6
+
7
+ from sqlmodel import Field
8
+ from sqlmodel import Relationship
9
+ from sqlmodel import SQLModel
10
+
11
+
12
+ if TYPE_CHECKING: # pragma: no cover
13
+ from ecodev_core.app_user import AppUser
14
+
15
+
16
+ class AppRight(SQLModel, table=True): # type: ignore
17
+ """
18
+ Simple right class: listing all app_services that a particular user can access to
19
+ """
20
+ __tablename__ = 'app_right'
21
+ id: Optional[int] = Field(default=None, primary_key=True)
22
+ app_service: str
23
+ user_id: Optional[int] = Field(default=None, foreign_key='app_user.id')
24
+ user: Optional['AppUser'] = Relationship(back_populates='rights')
@@ -0,0 +1,92 @@
1
+ """
2
+ Module implementing the sqlmodel orm part of the user table
3
+ """
4
+ from pathlib import Path
5
+ from typing import Dict
6
+ from typing import List
7
+ from typing import Optional
8
+ from typing import TYPE_CHECKING
9
+
10
+ import pandas as pd
11
+ from sqlmodel import col
12
+ from sqlmodel import Field
13
+ from sqlmodel import Relationship
14
+ from sqlmodel import select
15
+ from sqlmodel import Session
16
+ from sqlmodel import SQLModel
17
+ from sqlmodel.sql.expression import SelectOfScalar
18
+
19
+ from ecodev_core.db_insertion import create_or_update
20
+ from ecodev_core.db_insertion import Insertor
21
+ from ecodev_core.permissions import Permission
22
+ from ecodev_core.read_write import load_json_file
23
+
24
+
25
+ if TYPE_CHECKING: # pragma: no cover
26
+ from ecodev_core.app_rights import AppRight
27
+
28
+
29
+ class AppUser(SQLModel, table=True): # type: ignore
30
+ """
31
+ Simple user class: an id associate to a user with a password
32
+ """
33
+ __tablename__ = 'app_user'
34
+ id: Optional[int] = Field(default=None, primary_key=True)
35
+ user: str = Field(index=True)
36
+ password: str
37
+ permission: Permission = Field(default=Permission.ADMIN)
38
+ client: Optional[str] = Field(default=None)
39
+ rights: List['AppRight'] = Relationship(back_populates='user')
40
+
41
+
42
+ def user_convertor(df: pd.DataFrame) -> List[Dict]:
43
+ """
44
+ Dummy user convertor
45
+ """
46
+ return [x for _, x in df.iterrows()]
47
+
48
+
49
+ def user_reductor(in_db_row: AppUser, db_row: AppUser) -> AppUser:
50
+ """
51
+ Update an existing in_db_row with new information coming from db_row
52
+
53
+ NB: in the future this will maybe handle the client as well
54
+ """
55
+ in_db_row.permission = db_row.permission
56
+ in_db_row.password = db_row.password
57
+ return in_db_row
58
+
59
+
60
+ def user_selector(db_row: AppUser) -> SelectOfScalar:
61
+ """
62
+ Criteria on which to decide whether creating a new row or updating an existing one in db
63
+ """
64
+ return select(AppUser).where(col(AppUser.user) == db_row.user)
65
+
66
+
67
+ USER_INSERTOR = Insertor(convertor=user_convertor, selector=user_selector,
68
+ reductor=user_reductor, db_schema=AppUser, read_excel_file=False)
69
+
70
+
71
+ def upsert_app_users(file_path: Path, session: Session) -> None:
72
+ """
73
+ Upsert db users with a list of users provided in the file_path (json format)
74
+ """
75
+ for user in load_json_file(file_path):
76
+ session.add(create_or_update(session, user, USER_INSERTOR))
77
+ session.commit()
78
+
79
+
80
+ def select_user(username: str, session: Session) -> AppUser:
81
+ """
82
+ Helper function to (attempt to) retrieve AppUser from username.
83
+
84
+ NB: Used to check whether user exists, before resetting its password.
85
+ I.e. User does not yet have a token - we are simply checking if
86
+ an active account exists under that username.
87
+
88
+ Raises:
89
+ sqlalchemy.exc.NoResultFound: Typical error is no users are found.
90
+ sqlalchemy.exc.MultipleResultsFound: Should normally never be an issue.
91
+ """
92
+ return session.exec(select(AppUser).where(col(AppUser.user) == username)).one()
@@ -0,0 +1,22 @@
1
+ """
2
+ Module implementing authentication configuration.
3
+ """
4
+ from pydantic import BaseSettings
5
+
6
+
7
+ class AuthenticationConfiguration(BaseSettings):
8
+ """
9
+ Simple authentication configuration class
10
+ """
11
+ secret_key: str
12
+ algorithm: str
13
+ access_token_expire_minutes: int
14
+
15
+ class Config:
16
+ """
17
+ Config class specifying the name of the environment file to read
18
+ """
19
+ env_file = '.env'
20
+
21
+
22
+ AUTH = AuthenticationConfiguration()
@@ -0,0 +1,226 @@
1
+ """
2
+ Module implementing all jwt security logic
3
+ """
4
+ from datetime import datetime
5
+ from datetime import timedelta
6
+ from datetime import timezone
7
+ from typing import Any
8
+ from typing import Dict
9
+ from typing import List
10
+ from typing import Optional
11
+ from typing import Union
12
+
13
+ from fastapi import APIRouter
14
+ from fastapi import Depends
15
+ from fastapi import HTTPException
16
+ from fastapi import status
17
+ from fastapi.security import OAuth2PasswordBearer
18
+ from jose import jwt
19
+ from jose import JWTError
20
+ from passlib.context import CryptContext
21
+ from sqladmin.authentication import AuthenticationBackend
22
+ from sqlmodel import col
23
+ from sqlmodel import select
24
+ from sqlmodel import Session
25
+ from starlette.requests import Request
26
+ from starlette.responses import RedirectResponse
27
+
28
+ from ecodev_core.app_user import AppUser
29
+ from ecodev_core.auth_configuration import AUTH
30
+ from ecodev_core.db_connection import engine
31
+ from ecodev_core.logger import logger_get
32
+ from ecodev_core.permissions import Permission
33
+ from ecodev_core.pydantic_utils import Frozen
34
+
35
+
36
+ SCHEME = OAuth2PasswordBearer(tokenUrl='login')
37
+ auth_router = APIRouter(tags=['authentication'])
38
+ CONTEXT = CryptContext(schemes=['bcrypt'], deprecated='auto')
39
+ MONITORING = 'monitoring'
40
+ MONITORING_ERROR = 'Could not validate credentials. You need to be the monitoring user to call this'
41
+ INVALID_USER = 'Invalid User'
42
+ ADMIN_ERROR = 'Could not validate credentials. You need admin rights to call this'
43
+ INVALID_CREDENTIALS = 'Invalid Credentials'
44
+ log = logger_get(__name__)
45
+
46
+
47
+ class Token(Frozen):
48
+ """
49
+ Simple class for storing token value and type
50
+ """
51
+ access_token: str
52
+ token_type: str
53
+
54
+
55
+ class TokenData(Frozen):
56
+ """
57
+ Simple class storing token id information
58
+ """
59
+ id: int
60
+
61
+
62
+ def get_access_token(token: Dict[str, Any]):
63
+ """
64
+ Robust method to return access token or None
65
+ """
66
+ try:
67
+ return token.get('token', {}).get('access_token')
68
+ except AttributeError:
69
+ return None
70
+
71
+
72
+ def get_app_services(user: AppUser, session: Session) -> List[str]:
73
+ """
74
+ Retrieve all app services the passed user has access to
75
+ """
76
+ if db_user := session.exec(select(AppUser).where(col(AppUser.id) == user.id)).first():
77
+ return [right.app_service for right in db_user.rights]
78
+ return []
79
+
80
+
81
+ class JwtAuth(AuthenticationBackend):
82
+ """
83
+ Sqladmin security class. Implement login/logout procedure as well as the authentication check.
84
+ """
85
+
86
+ async def login(self, request: Request) -> bool:
87
+ """
88
+ Login procedure: factorized with the fastapi jwt logic
89
+ """
90
+ form = await request.form()
91
+ if token := self.authorized(form):
92
+ request.session.update(token)
93
+ return True if token else False
94
+
95
+ @staticmethod
96
+ def authorized(form: Dict):
97
+ """
98
+ Check that the user information contained in the form corresponds to an admin user
99
+ """
100
+ with Session(engine) as session:
101
+ try:
102
+ token = attempt_to_log(form.get('username', ''), form.get('password', ''), session)
103
+ if is_admin_user(token['access_token']):
104
+ return token
105
+ return None
106
+ except HTTPException:
107
+ return None
108
+
109
+ async def logout(self, request: Request) -> bool:
110
+ """
111
+ Logout procedure: clears the cache
112
+ """
113
+ request.session.clear()
114
+ return True
115
+
116
+ async def authenticate(self, request: Request) -> Optional[RedirectResponse]:
117
+ """
118
+ Authentication procedure
119
+ """
120
+ return (token := request.session.get('access_token')) and is_admin_user(token)
121
+
122
+
123
+ def attempt_to_log(user: str,
124
+ password: str,
125
+ session: Session) -> Union[Dict, HTTPException]:
126
+ """
127
+ Factorized security logic. Ensure that the user is a legit one with a valid password
128
+ """
129
+ selector = select(AppUser).where(col(AppUser.user) == user)
130
+ if not (db_user := session.exec(selector).first()):
131
+ log.warning('unauthorized user')
132
+ raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=INVALID_USER)
133
+ if not _check_password(password, db_user.password):
134
+ log.warning('invalid user')
135
+ raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=INVALID_CREDENTIALS)
136
+
137
+ return {'access_token': _create_access_token(data={'user_id': db_user.id}),
138
+ 'token_type': 'bearer'}
139
+
140
+
141
+ def is_authorized_user(token: str = Depends(SCHEME)) -> bool:
142
+ """
143
+ Check if the passed token corresponds to an authorized user
144
+ """
145
+ try:
146
+ return get_current_user(token) is not None
147
+ except Exception:
148
+ return False
149
+
150
+
151
+ def get_user(token: str = Depends(SCHEME)) -> AppUser:
152
+ """
153
+ Retrieves (if it exists) the db user corresponding to the passed token
154
+ """
155
+ if user := get_current_user(token):
156
+ return user
157
+ raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=INVALID_CREDENTIALS,
158
+ headers={'WWW-Authenticate': 'Bearer'})
159
+
160
+
161
+ def get_current_user(token: str) -> Union[AppUser, None]:
162
+ """
163
+ Retrieves (if it exists) a valid (meaning who has valid credentials) user from the db
164
+ """
165
+ token = _verify_access_token(token)
166
+ with Session(engine) as session:
167
+ return session.exec(select(AppUser).where(col(AppUser.id) == token.id)).first()
168
+
169
+
170
+ def is_admin_user(token: str = Depends(SCHEME)) -> AppUser:
171
+ """
172
+ Retrieves (if it exists) the admin (meaning who has valid credentials) user from the db
173
+ """
174
+ if (user := get_current_user(token)) and user.permission == Permission.ADMIN:
175
+ return user
176
+ raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=ADMIN_ERROR,
177
+ headers={'WWW-Authenticate': 'Bearer'})
178
+
179
+
180
+ def is_monitoring_user(token: str = Depends(SCHEME)) -> AppUser:
181
+ """
182
+ Retrieves (if it exists) the monitoring user from the db
183
+ """
184
+ if (user := get_current_user(token)) and user.user == MONITORING:
185
+ return user
186
+ raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED,
187
+ detail=MONITORING_ERROR, headers={'WWW-Authenticate': 'Bearer'})
188
+
189
+
190
+ def _create_access_token(data: Dict) -> str:
191
+ """
192
+ Create an access token out of the passed data. Only called if credentials are valid
193
+ """
194
+ to_encode = data.copy()
195
+ expire = datetime.now(timezone.utc) + timedelta(minutes=AUTH.access_token_expire_minutes)
196
+ to_encode.update({'exp': expire})
197
+ return jwt.encode(to_encode, AUTH.secret_key, algorithm=AUTH.algorithm)
198
+
199
+
200
+ def _verify_access_token(token: str) -> TokenData:
201
+ """
202
+ Retrieves the token data associated to the passed token if it contains valid credential info.
203
+ """
204
+ try:
205
+ payload = jwt.decode(token, AUTH.secret_key, algorithms=[AUTH.algorithm])
206
+ if (user_id := payload.get('user_id')) is None:
207
+ raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=INVALID_USER,
208
+ headers={'WWW-Authenticate': 'Bearer'})
209
+ return TokenData(id=user_id)
210
+ except JWTError as e:
211
+ raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=INVALID_CREDENTIALS,
212
+ headers={'WWW-Authenticate': 'Bearer'}) from e
213
+
214
+
215
+ def _hash_password(password: str) -> str:
216
+ """
217
+ Hashes the passed password (encoding).
218
+ """
219
+ return CONTEXT.hash(password)
220
+
221
+
222
+ def _check_password(plain_password: str, hashed_password: str) -> bool:
223
+ """
224
+ Check the passed password (compare it to the passed encoded one).
225
+ """
226
+ return CONTEXT.verify(plain_password, hashed_password)