auditize 0.2.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.
Files changed (90) hide show
  1. auditize/__init__.py +4 -0
  2. auditize/__main__.py +212 -0
  3. auditize/apikey/__init__.py +0 -0
  4. auditize/apikey/api.py +143 -0
  5. auditize/apikey/api_models.py +84 -0
  6. auditize/apikey/models.py +17 -0
  7. auditize/apikey/service.py +103 -0
  8. auditize/app/__init__.py +48 -0
  9. auditize/app/app_api.py +42 -0
  10. auditize/app/app_static.py +25 -0
  11. auditize/app/cors.py +15 -0
  12. auditize/auth/__init__.py +0 -0
  13. auditize/auth/api.py +83 -0
  14. auditize/auth/authorizer.py +204 -0
  15. auditize/auth/constants.py +2 -0
  16. auditize/auth/jwt.py +102 -0
  17. auditize/config.py +191 -0
  18. auditize/data/html/assets/favicon-CmRhlul4.png +0 -0
  19. auditize/data/html/assets/index-BBo2OJ_i.js +316 -0
  20. auditize/data/html/assets/index-XfLvnFsA.css +1 -0
  21. auditize/data/html/auditize-web-component.mjs +32947 -0
  22. auditize/data/html/index.html +17 -0
  23. auditize/database.py +105 -0
  24. auditize/exceptions.py +56 -0
  25. auditize/helpers/__init__.py +0 -0
  26. auditize/helpers/api/__init__.py +0 -0
  27. auditize/helpers/api/errors.py +155 -0
  28. auditize/helpers/api/validators.py +10 -0
  29. auditize/helpers/datetime.py +42 -0
  30. auditize/helpers/email.py +22 -0
  31. auditize/i18n/__init__.py +7 -0
  32. auditize/i18n/detection.py +24 -0
  33. auditize/i18n/lang.py +9 -0
  34. auditize/i18n/translations/en.json +45 -0
  35. auditize/i18n/translations/fr.json +45 -0
  36. auditize/i18n/translator.py +38 -0
  37. auditize/log/__init__.py +0 -0
  38. auditize/log/api.py +559 -0
  39. auditize/log/api_models.py +422 -0
  40. auditize/log/db.py +118 -0
  41. auditize/log/models.py +92 -0
  42. auditize/log/service/__init__.py +31 -0
  43. auditize/log/service/consolidation.py +515 -0
  44. auditize/log/service/csv.py +161 -0
  45. auditize/log/service/main.py +216 -0
  46. auditize/log_filter/__init__.py +0 -0
  47. auditize/log_filter/api.py +115 -0
  48. auditize/log_filter/api_models.py +187 -0
  49. auditize/log_filter/models.py +28 -0
  50. auditize/log_filter/service.py +92 -0
  51. auditize/log_i18n_profile/__init__.py +0 -0
  52. auditize/log_i18n_profile/api.py +137 -0
  53. auditize/log_i18n_profile/api_models.py +74 -0
  54. auditize/log_i18n_profile/models.py +74 -0
  55. auditize/log_i18n_profile/service.py +103 -0
  56. auditize/openapi.py +157 -0
  57. auditize/permissions/__init__.py +0 -0
  58. auditize/permissions/api_models.py +172 -0
  59. auditize/permissions/assertions.py +128 -0
  60. auditize/permissions/models.py +97 -0
  61. auditize/permissions/operations.py +269 -0
  62. auditize/permissions/service.py +12 -0
  63. auditize/repo/__init__.py +0 -0
  64. auditize/repo/api.py +253 -0
  65. auditize/repo/api_models.py +141 -0
  66. auditize/repo/models.py +45 -0
  67. auditize/repo/service.py +232 -0
  68. auditize/resource/__init__.py +0 -0
  69. auditize/resource/api_models.py +27 -0
  70. auditize/resource/models.py +13 -0
  71. auditize/resource/pagination/__init__.py +0 -0
  72. auditize/resource/pagination/cursor/__init__.py +0 -0
  73. auditize/resource/pagination/cursor/api_models.py +48 -0
  74. auditize/resource/pagination/cursor/service.py +93 -0
  75. auditize/resource/pagination/page/__init__.py +0 -0
  76. auditize/resource/pagination/page/api_models.py +60 -0
  77. auditize/resource/pagination/page/models.py +17 -0
  78. auditize/resource/pagination/page/service.py +29 -0
  79. auditize/resource/service.py +82 -0
  80. auditize/scheduler.py +15 -0
  81. auditize/user/__init__.py +0 -0
  82. auditize/user/api.py +224 -0
  83. auditize/user/api_models.py +156 -0
  84. auditize/user/models.py +32 -0
  85. auditize/user/service.py +220 -0
  86. auditize-0.2.1.dist-info/METADATA +100 -0
  87. auditize-0.2.1.dist-info/RECORD +90 -0
  88. auditize-0.2.1.dist-info/WHEEL +5 -0
  89. auditize-0.2.1.dist-info/entry_points.txt +2 -0
  90. auditize-0.2.1.dist-info/top_level.txt +1 -0
File without changes
auditize/auth/api.py ADDED
@@ -0,0 +1,83 @@
1
+ from fastapi import APIRouter
2
+ from starlette.responses import Response
3
+
4
+ from auditize.apikey.api_models import AccessTokenRequest, AccessTokenResponse
5
+ from auditize.auth.authorizer import (
6
+ AuthorizedApikey,
7
+ AuthorizedUser,
8
+ )
9
+ from auditize.auth.constants import ACCESS_TOKEN_PREFIX
10
+ from auditize.auth.jwt import generate_access_token, generate_session_token
11
+ from auditize.config import get_config
12
+ from auditize.database import get_dbm
13
+ from auditize.helpers.api.errors import error_responses
14
+ from auditize.permissions.models import Permissions
15
+ from auditize.permissions.operations import authorize_grant
16
+ from auditize.user import service
17
+ from auditize.user.api_models import UserAuthenticationRequest, UserMeResponse
18
+
19
+ router = APIRouter()
20
+
21
+
22
+ @router.post(
23
+ "/auth/user/login",
24
+ summary="User login",
25
+ operation_id="user_login",
26
+ tags=["auth", "internal"],
27
+ status_code=200,
28
+ responses=error_responses(400, 401),
29
+ )
30
+ async def login_user(
31
+ request: UserAuthenticationRequest,
32
+ response: Response,
33
+ ) -> UserMeResponse:
34
+ config = get_config()
35
+ user = await service.authenticate_user(request.email, request.password)
36
+ token, expires_at = generate_session_token(user.email)
37
+
38
+ response.set_cookie(
39
+ "session",
40
+ token,
41
+ expires=expires_at,
42
+ httponly=True,
43
+ samesite="strict",
44
+ secure=config.cookie_secure,
45
+ )
46
+
47
+ return UserMeResponse.from_user(user)
48
+
49
+
50
+ @router.post(
51
+ "/auth/user/logout",
52
+ summary="User logout",
53
+ operation_id="user_logout",
54
+ tags=["auth", "internal"],
55
+ status_code=204,
56
+ responses=error_responses(401),
57
+ )
58
+ async def logout_user(
59
+ authorized: AuthorizedUser(),
60
+ response: Response,
61
+ ):
62
+ response.delete_cookie("session", httponly=True, samesite="strict", secure=True)
63
+
64
+
65
+ @router.post(
66
+ "/auth/access-token",
67
+ summary="Generate access token",
68
+ operation_id="generate_access_token",
69
+ tags=["auth"],
70
+ status_code=200,
71
+ responses=error_responses(401, 403),
72
+ )
73
+ async def auth_access_token(
74
+ authorized: AuthorizedApikey(),
75
+ request: AccessTokenRequest,
76
+ ) -> AccessTokenResponse:
77
+ permissions = Permissions.model_validate(request.permissions.model_dump())
78
+ authorize_grant(authorized.permissions, permissions)
79
+ access_token, expires_at = generate_access_token(authorized.apikey.id, permissions)
80
+
81
+ return AccessTokenResponse(
82
+ access_token=ACCESS_TOKEN_PREFIX + access_token, expires_at=expires_at
83
+ )
@@ -0,0 +1,204 @@
1
+ import dataclasses
2
+ from typing import Annotated, Callable, Type
3
+ from uuid import UUID
4
+
5
+ from fastapi import Depends
6
+ from starlette.requests import Request
7
+
8
+ from auditize.apikey.models import Apikey
9
+ from auditize.apikey.service import get_apikey, get_apikey_by_key
10
+ from auditize.auth.constants import ACCESS_TOKEN_PREFIX, APIKEY_SECRET_PREFIX
11
+ from auditize.auth.jwt import get_access_token_data, get_user_email_from_session_token
12
+ from auditize.exceptions import (
13
+ AuthenticationFailure,
14
+ PermissionDenied,
15
+ UnknownModelException,
16
+ )
17
+ from auditize.permissions.assertions import (
18
+ PermissionAssertion,
19
+ can_read_logs,
20
+ can_write_logs,
21
+ )
22
+ from auditize.permissions.models import Permissions
23
+ from auditize.permissions.operations import authorize_grant
24
+ from auditize.user.models import User
25
+ from auditize.user.service import get_user_by_email
26
+
27
+ _BEARER_PREFIX = "Bearer "
28
+
29
+
30
+ @dataclasses.dataclass
31
+ class AccessToken:
32
+ apikey: Apikey
33
+ permissions: Permissions
34
+
35
+
36
+ @dataclasses.dataclass
37
+ class Authenticated:
38
+ name: str
39
+ user: User = None
40
+ apikey: Apikey = None
41
+ access_token: AccessToken = None
42
+
43
+ @classmethod
44
+ def from_user(cls, user: User):
45
+ return cls(name=user.email, user=user)
46
+
47
+ @classmethod
48
+ def from_apikey(cls, apikey: Apikey):
49
+ return cls(name=apikey.name, apikey=apikey)
50
+
51
+ @classmethod
52
+ def from_access_token(cls, access_token: AccessToken):
53
+ return cls(
54
+ name=f"Access token for API key {access_token.apikey.name!r}",
55
+ access_token=access_token,
56
+ )
57
+
58
+ @property
59
+ def permissions(self) -> Permissions:
60
+ if self.user:
61
+ return self.user.permissions
62
+ if self.apikey:
63
+ return self.apikey.permissions
64
+ if self.access_token:
65
+ return self.access_token.permissions
66
+ raise Exception(
67
+ "Authenticated is neither a user, an API key nor an access token"
68
+ ) # pragma: no cover
69
+
70
+ def comply(self, assertion: PermissionAssertion) -> bool:
71
+ return assertion(self.permissions)
72
+
73
+ def ensure_user(self):
74
+ if not self.user:
75
+ raise PermissionDenied("This operation is only available to users")
76
+
77
+ def ensure_apikey(self):
78
+ if not self.apikey:
79
+ raise PermissionDenied("This operation is only available to API keys")
80
+
81
+
82
+ def _get_authorization_bearer(request: Request) -> str | None:
83
+ authorization = request.headers.get("Authorization")
84
+ if not authorization:
85
+ return None
86
+ if not authorization.startswith(_BEARER_PREFIX):
87
+ raise AuthenticationFailure("Authorization header is not a Bearer")
88
+ return authorization[len(_BEARER_PREFIX) :]
89
+
90
+
91
+ async def authenticate_apikey(key: str) -> Authenticated:
92
+ try:
93
+ apikey = await get_apikey_by_key(key)
94
+ except UnknownModelException:
95
+ raise AuthenticationFailure("Invalid API key")
96
+
97
+ return Authenticated.from_apikey(apikey)
98
+
99
+
100
+ async def authenticate_access_token(access_token: str) -> Authenticated:
101
+ jwt_token = access_token[len(ACCESS_TOKEN_PREFIX) :]
102
+ apikey_id, permissions = get_access_token_data(jwt_token)
103
+ try:
104
+ apikey = await get_apikey(apikey_id)
105
+ except UnknownModelException:
106
+ raise AuthenticationFailure(
107
+ "Invalid API key corresponding to access token is no longer valid"
108
+ )
109
+
110
+ # Make sure the permissions once granted do not exceed the ones of the original API key
111
+ try:
112
+ authorize_grant(apikey.permissions, permissions)
113
+ except PermissionDenied:
114
+ raise AuthenticationFailure(
115
+ "The access token has more permissions than the original API key"
116
+ )
117
+
118
+ return Authenticated.from_access_token(
119
+ AccessToken(apikey=apikey, permissions=permissions)
120
+ )
121
+
122
+
123
+ async def authenticate_user(request: Request) -> Authenticated:
124
+ if not request.cookies:
125
+ raise AuthenticationFailure()
126
+
127
+ session_token = request.cookies.get("session")
128
+ if not session_token:
129
+ raise AuthenticationFailure()
130
+
131
+ user_email = get_user_email_from_session_token(session_token)
132
+ try:
133
+ user = await get_user_by_email(user_email)
134
+ except UnknownModelException:
135
+ raise AuthenticationFailure("User does no longer exist")
136
+
137
+ # to be used by the lang detection mechanism
138
+ request.state.auditize_lang = str(user.lang.value)
139
+
140
+ return Authenticated.from_user(user)
141
+
142
+
143
+ async def get_authenticated(request: Request) -> Authenticated:
144
+ bearer = _get_authorization_bearer(request)
145
+ if bearer:
146
+ if bearer.startswith(APIKEY_SECRET_PREFIX):
147
+ return await authenticate_apikey(bearer)
148
+ if bearer.startswith(ACCESS_TOKEN_PREFIX):
149
+ return await authenticate_access_token(bearer)
150
+ raise AuthenticationFailure("Invalid bearer token")
151
+
152
+ return await authenticate_user(request)
153
+
154
+
155
+ class _Authorized:
156
+ def __init__(self, assertion: PermissionAssertion = None):
157
+ self.assertion = assertion
158
+
159
+ def __call__(self, authenticated: Authenticated = Depends(get_authenticated)):
160
+ if self.assertion and not authenticated.comply(self.assertion):
161
+ raise PermissionDenied()
162
+ return authenticated
163
+
164
+
165
+ class _AuthorizedUser(_Authorized):
166
+ def __call__(self, authenticated: Authenticated = Depends(get_authenticated)):
167
+ authenticated.ensure_user()
168
+ return super().__call__(authenticated)
169
+
170
+
171
+ class _AuthorizedApikey(_Authorized):
172
+ def __call__(self, authenticated: Authenticated = Depends(get_authenticated)):
173
+ authenticated.ensure_apikey()
174
+ return super().__call__(authenticated)
175
+
176
+
177
+ def _authorized_on_logs(assertion_func: Callable[[UUID], PermissionAssertion]):
178
+ def func(repo_id: UUID, authenticated: Authenticated = Depends(get_authenticated)):
179
+ assertion = assertion_func(repo_id)
180
+ if not authenticated.comply(assertion):
181
+ raise PermissionDenied()
182
+ return authenticated
183
+
184
+ return func
185
+
186
+
187
+ def Authorized(assertion: PermissionAssertion = None) -> Type[Authenticated]: # noqa
188
+ return Annotated[Authenticated, Depends(_Authorized(assertion))]
189
+
190
+
191
+ def AuthorizedUser(assertion: PermissionAssertion = None) -> Type[Authenticated]: # noqa
192
+ return Annotated[Authenticated, Depends(_AuthorizedUser(assertion))]
193
+
194
+
195
+ def AuthorizedApikey(assertion: PermissionAssertion = None) -> Type[Authenticated]: # noqa
196
+ return Annotated[Authenticated, Depends(_AuthorizedApikey(assertion))]
197
+
198
+
199
+ def AuthorizedForLogRead() -> Type[Authenticated]: # noqa
200
+ return Annotated[Authenticated, Depends(_authorized_on_logs(can_read_logs))]
201
+
202
+
203
+ def AuthorizedForLogWrite() -> Type[Authenticated]: # noqa
204
+ return Annotated[Authenticated, Depends(_authorized_on_logs(can_write_logs))]
@@ -0,0 +1,2 @@
1
+ APIKEY_SECRET_PREFIX = "aak-"
2
+ ACCESS_TOKEN_PREFIX = "aat-"
auditize/auth/jwt.py ADDED
@@ -0,0 +1,102 @@
1
+ import json
2
+ from datetime import datetime, timedelta
3
+ from uuid import UUID
4
+
5
+ from authlib.jose import JsonWebToken
6
+ from authlib.jose.errors import ExpiredTokenError, JoseError
7
+
8
+ from auditize.config import get_config
9
+ from auditize.exceptions import AuthenticationFailure
10
+ from auditize.helpers.datetime import now
11
+ from auditize.permissions.models import Permissions
12
+
13
+ _SUB_PREFIX_SESSION_TOKEN = "user_email:"
14
+ _SUB_PREFIX_ACCESS_TOKEN = "apikey_id:"
15
+
16
+ jwt = JsonWebToken(["HS256"])
17
+
18
+
19
+ def _generate_jwt_payload(data, lifetime) -> tuple[dict, datetime]:
20
+ expires_at = now() + timedelta(seconds=lifetime)
21
+ return {**data, "exp": expires_at}, expires_at
22
+
23
+
24
+ def _sign_jwt_token(payload: dict) -> str:
25
+ value = jwt.encode({"alg": "HS256"}, payload, key=get_config().jwt_signing_key)
26
+ return value.decode()
27
+
28
+
29
+ def _get_jwt_token_payload(token: str) -> dict:
30
+ # Load JWT token
31
+ try:
32
+ claims = jwt.decode(token, get_config().jwt_signing_key)
33
+ claims.validate()
34
+ except ExpiredTokenError:
35
+ raise AuthenticationFailure("JWT token expired")
36
+ except JoseError:
37
+ raise AuthenticationFailure("Cannot decode JWT token")
38
+
39
+ # Ensure the token has a 'sub' field
40
+ if "sub" not in claims:
41
+ raise AuthenticationFailure("Missing 'sub' field in JWT token")
42
+
43
+ return claims
44
+
45
+
46
+ # NB: make this function public so we can test valid JWT tokens but signed with another key
47
+ def generate_session_token_payload(user_email: str) -> tuple[dict, datetime]:
48
+ return _generate_jwt_payload(
49
+ {"sub": f"{_SUB_PREFIX_SESSION_TOKEN}{user_email}"},
50
+ get_config().user_session_token_lifetime,
51
+ )
52
+
53
+
54
+ def generate_session_token(user_email) -> tuple[str, datetime]:
55
+ payload, expires_at = generate_session_token_payload(user_email)
56
+ return _sign_jwt_token(payload), expires_at
57
+
58
+
59
+ def get_user_email_from_session_token(token: str) -> str:
60
+ payload = _get_jwt_token_payload(token)
61
+ sub = payload["sub"]
62
+
63
+ if not sub.startswith(_SUB_PREFIX_SESSION_TOKEN):
64
+ raise AuthenticationFailure("Invalid 'sub' field in JWT token")
65
+ email = sub[len(_SUB_PREFIX_SESSION_TOKEN) :]
66
+
67
+ return email
68
+
69
+
70
+ def generate_access_token_payload(
71
+ apikey_id: UUID, permissions: Permissions
72
+ ) -> tuple[dict, datetime]:
73
+ return _generate_jwt_payload(
74
+ {
75
+ "sub": _SUB_PREFIX_ACCESS_TOKEN + str(apikey_id),
76
+ # NB: this data will be serialized to JSON by authlib.jose using json.dumps internally,
77
+ # unfortunately json.dumps does not support UUID serialization, that's why we
78
+ # have to do this dump->load extra step to get UUID instances turned into strings
79
+ "permissions": json.loads(permissions.model_dump_json()),
80
+ },
81
+ get_config().access_token_lifetime,
82
+ )
83
+
84
+
85
+ def generate_access_token(
86
+ apikey_id: UUID, permissions: Permissions
87
+ ) -> tuple[str, datetime]:
88
+ payload, expires_at = generate_access_token_payload(apikey_id, permissions)
89
+ return _sign_jwt_token(payload), expires_at
90
+
91
+
92
+ def get_access_token_data(token: str) -> tuple[UUID, Permissions]:
93
+ payload = _get_jwt_token_payload(token)
94
+ sub = payload["sub"]
95
+
96
+ if not sub.startswith(_SUB_PREFIX_ACCESS_TOKEN):
97
+ raise AuthenticationFailure("Invalid 'sub' field in JWT token")
98
+ apikey_id = sub[len(_SUB_PREFIX_ACCESS_TOKEN) :]
99
+
100
+ permissions = Permissions.model_validate(payload["permissions"])
101
+
102
+ return UUID(apikey_id), permissions
auditize/config.py ADDED
@@ -0,0 +1,191 @@
1
+ import dataclasses
2
+ import os
3
+
4
+ from apscheduler.triggers.cron import CronTrigger
5
+
6
+ from auditize.exceptions import (
7
+ ConfigAlreadyInitialized,
8
+ ConfigError,
9
+ ConfigNotInitialized,
10
+ )
11
+
12
+ _DEFAULT_ATTACHMENT_MAX_SIZE = 1024 * 1024 * 5 # 5MB
13
+ _DEFAULT_CSV_MAX_ROWS = 10_000
14
+ _DEFAULT_USER_SESSION_TOKEN_LIFETIME = 60 * 60 * 12 # 12 hours
15
+ _DEFAULT_ACCESS_TOKEN_LIFETIME = 10 * 60 # 10 minutes
16
+ _DEFAULT_LOG_EXPIRATION_SCHEDULE = "0 1 * * *"
17
+
18
+
19
+ @dataclasses.dataclass
20
+ class Config:
21
+ base_url: str
22
+ jwt_signing_key: str
23
+ user_session_token_lifetime: int
24
+ access_token_lifetime: int
25
+ attachment_max_size: int
26
+ csv_max_rows: int
27
+ mongodb_uri: str
28
+ smtp_server: str
29
+ smtp_port: int
30
+ smtp_username: str
31
+ smtp_password: str
32
+ _smtp_sender: str
33
+ cors_allow_origins: list[str]
34
+ cookie_secure: bool
35
+ test_mode: bool
36
+ online_doc: bool
37
+ log_expiration_schedule: str
38
+
39
+ @staticmethod
40
+ def _validate_list(value):
41
+ return value.split(",")
42
+
43
+ @staticmethod
44
+ def _validate_bool(value):
45
+ if value == "true":
46
+ return True
47
+ if value == "false":
48
+ return False
49
+ raise ValueError(f"must be either 'true' or 'false'")
50
+
51
+ @staticmethod
52
+ def _validate_cron_expr(value):
53
+ try:
54
+ CronTrigger.from_crontab(value)
55
+ except ValueError as exc:
56
+ raise ValueError(f"not a valid cron expression ({exc})")
57
+ return value
58
+
59
+ def _validate(self):
60
+ smtp_values_required = (
61
+ self.smtp_server,
62
+ self.smtp_port,
63
+ self.smtp_username,
64
+ self.smtp_password,
65
+ )
66
+ smtp_values = smtp_values_required + (self._smtp_sender,)
67
+
68
+ if any(smtp_values) and not all(smtp_values_required):
69
+ raise ConfigError(
70
+ "SMTP configuration is incomplete, please provide all of the following environment variables:\n"
71
+ "- AUDITIZE_SMTP_SERVER\n"
72
+ "- AUDITIZE_SMTP_PORT\n"
73
+ "- AUDITIZE_SMTP_USERNAME\n"
74
+ "- AUDITIZE_SMTP_PASSWORD\n"
75
+ )
76
+
77
+ @classmethod
78
+ def load_from_env(cls, env=None):
79
+ if env is None:
80
+ env = os.environ
81
+
82
+ def required(key, validator=None):
83
+ value = env[key]
84
+ if validator:
85
+ try:
86
+ value = validator(value)
87
+ except ValueError as exc:
88
+ raise ConfigError(
89
+ f"Could not load configuration, invalid value {value!r} for {key!r}: {exc}"
90
+ )
91
+ return value
92
+
93
+ def optional(key, default=None, validator=None):
94
+ try:
95
+ return required(key, validator)
96
+ except KeyError:
97
+ return default
98
+
99
+ try:
100
+ config = cls(
101
+ base_url=required("AUDITIZE_BASE_URL"),
102
+ jwt_signing_key=required("AUDITIZE_JWT_SIGNING_KEY"),
103
+ user_session_token_lifetime=optional(
104
+ "AUDITIZE_USER_SESSION_TOKEN_LIFETIME",
105
+ _DEFAULT_USER_SESSION_TOKEN_LIFETIME,
106
+ validator=int,
107
+ ),
108
+ access_token_lifetime=optional(
109
+ "AUDITIZE_ACCESS_TOKEN_LIFETIME",
110
+ _DEFAULT_ACCESS_TOKEN_LIFETIME,
111
+ validator=int,
112
+ ),
113
+ attachment_max_size=optional(
114
+ "AUDITIZE_ATTACHMENT_MAX_SIZE",
115
+ default=_DEFAULT_ATTACHMENT_MAX_SIZE,
116
+ validator=int,
117
+ ),
118
+ csv_max_rows=optional(
119
+ "AUDITIZE_CSV_MAX_ROWS",
120
+ default=_DEFAULT_CSV_MAX_ROWS,
121
+ validator=int,
122
+ ),
123
+ mongodb_uri=optional("AUDITIZE_MONGODB_URI"),
124
+ smtp_server=optional("AUDITIZE_SMTP_SERVER"),
125
+ smtp_port=optional("AUDITIZE_SMTP_PORT", validator=int),
126
+ smtp_username=optional("AUDITIZE_SMTP_USERNAME"),
127
+ smtp_password=optional("AUDITIZE_SMTP_PASSWORD"),
128
+ _smtp_sender=optional("AUDITIZE_SMTP_SENDER"),
129
+ cors_allow_origins=optional(
130
+ "AUDITIZE_CORS_ALLOW_ORIGINS",
131
+ validator=cls._validate_list,
132
+ default=[],
133
+ ),
134
+ log_expiration_schedule=optional(
135
+ "AUDITIZE_LOG_EXPIRATION_SCHEDULE",
136
+ validator=cls._validate_cron_expr,
137
+ default=_DEFAULT_LOG_EXPIRATION_SCHEDULE,
138
+ ),
139
+ ###
140
+ # "Private" configuration
141
+ ###
142
+ cookie_secure=optional(
143
+ # Needed to disable Secure Cookies for Safari on localhost
144
+ # (see https://flaviocopes.com/cookie-not-being-set-in-safari/)
145
+ "_AUDITIZE_COOKIE_SECURE",
146
+ validator=cls._validate_bool,
147
+ default=True,
148
+ ),
149
+ test_mode=optional(
150
+ "_AUDITIZE_TEST_MODE", validator=cls._validate_bool, default=False
151
+ ),
152
+ online_doc=optional(
153
+ "_AUDITIZE_ONLINE_DOC", validator=cls._validate_bool, default=False
154
+ ),
155
+ )
156
+ except KeyError as e:
157
+ var_name = str(e)
158
+ raise ConfigError(
159
+ f"Could not load configuration, variable {var_name} is missing"
160
+ )
161
+
162
+ config._validate()
163
+
164
+ return config
165
+
166
+ @property
167
+ def smtp_sender(self):
168
+ return self._smtp_sender or self.smtp_username
169
+
170
+ def is_smtp_enabled(self):
171
+ return self.smtp_sender is not None
172
+
173
+ def to_dict(self):
174
+ return dataclasses.asdict(self)
175
+
176
+
177
+ _config: Config | None = None
178
+
179
+
180
+ def init_config(env=None) -> Config:
181
+ global _config
182
+ if _config:
183
+ raise ConfigAlreadyInitialized()
184
+ _config = Config.load_from_env(env)
185
+ return _config
186
+
187
+
188
+ def get_config() -> Config:
189
+ if not _config:
190
+ raise ConfigNotInitialized()
191
+ return _config