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.
- auditize/__init__.py +4 -0
- auditize/__main__.py +212 -0
- auditize/apikey/__init__.py +0 -0
- auditize/apikey/api.py +143 -0
- auditize/apikey/api_models.py +84 -0
- auditize/apikey/models.py +17 -0
- auditize/apikey/service.py +103 -0
- auditize/app/__init__.py +48 -0
- auditize/app/app_api.py +42 -0
- auditize/app/app_static.py +25 -0
- auditize/app/cors.py +15 -0
- auditize/auth/__init__.py +0 -0
- auditize/auth/api.py +83 -0
- auditize/auth/authorizer.py +204 -0
- auditize/auth/constants.py +2 -0
- auditize/auth/jwt.py +102 -0
- auditize/config.py +191 -0
- auditize/data/html/assets/favicon-CmRhlul4.png +0 -0
- auditize/data/html/assets/index-BBo2OJ_i.js +316 -0
- auditize/data/html/assets/index-XfLvnFsA.css +1 -0
- auditize/data/html/auditize-web-component.mjs +32947 -0
- auditize/data/html/index.html +17 -0
- auditize/database.py +105 -0
- auditize/exceptions.py +56 -0
- auditize/helpers/__init__.py +0 -0
- auditize/helpers/api/__init__.py +0 -0
- auditize/helpers/api/errors.py +155 -0
- auditize/helpers/api/validators.py +10 -0
- auditize/helpers/datetime.py +42 -0
- auditize/helpers/email.py +22 -0
- auditize/i18n/__init__.py +7 -0
- auditize/i18n/detection.py +24 -0
- auditize/i18n/lang.py +9 -0
- auditize/i18n/translations/en.json +45 -0
- auditize/i18n/translations/fr.json +45 -0
- auditize/i18n/translator.py +38 -0
- auditize/log/__init__.py +0 -0
- auditize/log/api.py +559 -0
- auditize/log/api_models.py +422 -0
- auditize/log/db.py +118 -0
- auditize/log/models.py +92 -0
- auditize/log/service/__init__.py +31 -0
- auditize/log/service/consolidation.py +515 -0
- auditize/log/service/csv.py +161 -0
- auditize/log/service/main.py +216 -0
- auditize/log_filter/__init__.py +0 -0
- auditize/log_filter/api.py +115 -0
- auditize/log_filter/api_models.py +187 -0
- auditize/log_filter/models.py +28 -0
- auditize/log_filter/service.py +92 -0
- auditize/log_i18n_profile/__init__.py +0 -0
- auditize/log_i18n_profile/api.py +137 -0
- auditize/log_i18n_profile/api_models.py +74 -0
- auditize/log_i18n_profile/models.py +74 -0
- auditize/log_i18n_profile/service.py +103 -0
- auditize/openapi.py +157 -0
- auditize/permissions/__init__.py +0 -0
- auditize/permissions/api_models.py +172 -0
- auditize/permissions/assertions.py +128 -0
- auditize/permissions/models.py +97 -0
- auditize/permissions/operations.py +269 -0
- auditize/permissions/service.py +12 -0
- auditize/repo/__init__.py +0 -0
- auditize/repo/api.py +253 -0
- auditize/repo/api_models.py +141 -0
- auditize/repo/models.py +45 -0
- auditize/repo/service.py +232 -0
- auditize/resource/__init__.py +0 -0
- auditize/resource/api_models.py +27 -0
- auditize/resource/models.py +13 -0
- auditize/resource/pagination/__init__.py +0 -0
- auditize/resource/pagination/cursor/__init__.py +0 -0
- auditize/resource/pagination/cursor/api_models.py +48 -0
- auditize/resource/pagination/cursor/service.py +93 -0
- auditize/resource/pagination/page/__init__.py +0 -0
- auditize/resource/pagination/page/api_models.py +60 -0
- auditize/resource/pagination/page/models.py +17 -0
- auditize/resource/pagination/page/service.py +29 -0
- auditize/resource/service.py +82 -0
- auditize/scheduler.py +15 -0
- auditize/user/__init__.py +0 -0
- auditize/user/api.py +224 -0
- auditize/user/api_models.py +156 -0
- auditize/user/models.py +32 -0
- auditize/user/service.py +220 -0
- auditize-0.2.1.dist-info/METADATA +100 -0
- auditize-0.2.1.dist-info/RECORD +90 -0
- auditize-0.2.1.dist-info/WHEEL +5 -0
- auditize-0.2.1.dist-info/entry_points.txt +2 -0
- 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))]
|
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
|
|
Binary file
|