fastapi-ext-pkg 0.4.4__tar.gz → 0.6.0__tar.gz
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.
- {fastapi_ext_pkg-0.4.4 → fastapi_ext_pkg-0.6.0}/PKG-INFO +3 -1
- {fastapi_ext_pkg-0.4.4 → fastapi_ext_pkg-0.6.0}/fastapi_ext_pkg.egg-info/PKG-INFO +3 -1
- {fastapi_ext_pkg-0.4.4 → fastapi_ext_pkg-0.6.0}/fastapi_ext_pkg.egg-info/SOURCES.txt +10 -1
- fastapi_ext_pkg-0.6.0/fastapi_ext_pkg.egg-info/requires.txt +5 -0
- fastapi_ext_pkg-0.6.0/fastapi_extras/apm/__init__.py +19 -0
- fastapi_ext_pkg-0.6.0/fastapi_extras/apm/config.py +66 -0
- fastapi_ext_pkg-0.6.0/fastapi_extras/apm/constants.py +46 -0
- fastapi_ext_pkg-0.6.0/fastapi_extras/apm/decorators.py +127 -0
- fastapi_ext_pkg-0.6.0/fastapi_extras/apm/labels.py +20 -0
- fastapi_ext_pkg-0.6.0/fastapi_extras/apm/masking.py +87 -0
- fastapi_ext_pkg-0.6.0/fastapi_extras/apm/middleware.py +105 -0
- fastapi_ext_pkg-0.6.0/fastapi_extras/apm/processors.py +80 -0
- {fastapi_ext_pkg-0.4.4 → fastapi_ext_pkg-0.6.0}/fastapi_extras/databases/redis.py +13 -2
- {fastapi_ext_pkg-0.4.4 → fastapi_ext_pkg-0.6.0}/fastapi_extras/security/auth.py +38 -16
- fastapi_ext_pkg-0.6.0/fastapi_extras/security/auth_async.py +180 -0
- {fastapi_ext_pkg-0.4.4 → fastapi_ext_pkg-0.6.0}/pyproject.toml +4 -1
- fastapi_ext_pkg-0.4.4/fastapi_ext_pkg.egg-info/requires.txt +0 -2
- {fastapi_ext_pkg-0.4.4 → fastapi_ext_pkg-0.6.0}/README.md +0 -0
- {fastapi_ext_pkg-0.4.4 → fastapi_ext_pkg-0.6.0}/fastapi_ext_pkg.egg-info/dependency_links.txt +0 -0
- {fastapi_ext_pkg-0.4.4 → fastapi_ext_pkg-0.6.0}/fastapi_ext_pkg.egg-info/top_level.txt +0 -0
- {fastapi_ext_pkg-0.4.4 → fastapi_ext_pkg-0.6.0}/fastapi_extras/__init__.py +0 -0
- {fastapi_ext_pkg-0.4.4 → fastapi_ext_pkg-0.6.0}/fastapi_extras/databases/__init__.py +0 -0
- {fastapi_ext_pkg-0.4.4 → fastapi_ext_pkg-0.6.0}/fastapi_extras/middlewares/__init__.py +0 -0
- {fastapi_ext_pkg-0.4.4 → fastapi_ext_pkg-0.6.0}/fastapi_extras/middlewares/remove_cloud_trace_headers.py +0 -0
- {fastapi_ext_pkg-0.4.4 → fastapi_ext_pkg-0.6.0}/fastapi_extras/middlewares/remove_path.py +0 -0
- {fastapi_ext_pkg-0.4.4 → fastapi_ext_pkg-0.6.0}/fastapi_extras/orm/__init__.py +0 -0
- {fastapi_ext_pkg-0.4.4 → fastapi_ext_pkg-0.6.0}/fastapi_extras/orm/sqlalchemy.py +0 -0
- {fastapi_ext_pkg-0.4.4 → fastapi_ext_pkg-0.6.0}/fastapi_extras/security/__init__.py +0 -0
- {fastapi_ext_pkg-0.4.4 → fastapi_ext_pkg-0.6.0}/setup.cfg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: fastapi-ext-pkg
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.6.0
|
|
4
4
|
Summary: A well-tailored utility set for FastAPI.
|
|
5
5
|
Author-email: Dotz Developers <devs-dotz@dotz.com>
|
|
6
6
|
Project-URL: Repository, https://github.com/DotzInc/fastapi-ext-pkg
|
|
@@ -20,6 +20,8 @@ Requires-Python: >=3.8
|
|
|
20
20
|
Description-Content-Type: text/markdown
|
|
21
21
|
Requires-Dist: fastapi
|
|
22
22
|
Requires-Dist: httpx
|
|
23
|
+
Provides-Extra: apm
|
|
24
|
+
Requires-Dist: elastic-apm<7,>=6.20; extra == "apm"
|
|
23
25
|
|
|
24
26
|
# FastAPI Extension Package
|
|
25
27
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: fastapi-ext-pkg
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.6.0
|
|
4
4
|
Summary: A well-tailored utility set for FastAPI.
|
|
5
5
|
Author-email: Dotz Developers <devs-dotz@dotz.com>
|
|
6
6
|
Project-URL: Repository, https://github.com/DotzInc/fastapi-ext-pkg
|
|
@@ -20,6 +20,8 @@ Requires-Python: >=3.8
|
|
|
20
20
|
Description-Content-Type: text/markdown
|
|
21
21
|
Requires-Dist: fastapi
|
|
22
22
|
Requires-Dist: httpx
|
|
23
|
+
Provides-Extra: apm
|
|
24
|
+
Requires-Dist: elastic-apm<7,>=6.20; extra == "apm"
|
|
23
25
|
|
|
24
26
|
# FastAPI Extension Package
|
|
25
27
|
|
|
@@ -6,6 +6,14 @@ fastapi_ext_pkg.egg-info/dependency_links.txt
|
|
|
6
6
|
fastapi_ext_pkg.egg-info/requires.txt
|
|
7
7
|
fastapi_ext_pkg.egg-info/top_level.txt
|
|
8
8
|
fastapi_extras/__init__.py
|
|
9
|
+
fastapi_extras/apm/__init__.py
|
|
10
|
+
fastapi_extras/apm/config.py
|
|
11
|
+
fastapi_extras/apm/constants.py
|
|
12
|
+
fastapi_extras/apm/decorators.py
|
|
13
|
+
fastapi_extras/apm/labels.py
|
|
14
|
+
fastapi_extras/apm/masking.py
|
|
15
|
+
fastapi_extras/apm/middleware.py
|
|
16
|
+
fastapi_extras/apm/processors.py
|
|
9
17
|
fastapi_extras/databases/__init__.py
|
|
10
18
|
fastapi_extras/databases/redis.py
|
|
11
19
|
fastapi_extras/middlewares/__init__.py
|
|
@@ -14,4 +22,5 @@ fastapi_extras/middlewares/remove_path.py
|
|
|
14
22
|
fastapi_extras/orm/__init__.py
|
|
15
23
|
fastapi_extras/orm/sqlalchemy.py
|
|
16
24
|
fastapi_extras/security/__init__.py
|
|
17
|
-
fastapi_extras/security/auth.py
|
|
25
|
+
fastapi_extras/security/auth.py
|
|
26
|
+
fastapi_extras/security/auth_async.py
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
from fastapi_extras.apm.config import build_apm_config
|
|
2
|
+
from fastapi_extras.apm.constants import DEFAULT_SANITIZE_FIELD_NAMES
|
|
3
|
+
from fastapi_extras.apm.decorators import capture_http_request
|
|
4
|
+
from fastapi_extras.apm.labels import label_transaction
|
|
5
|
+
from fastapi_extras.apm.masking import mask_sensitive_data, normalize_span_value
|
|
6
|
+
from fastapi_extras.apm.middleware import ElasticAPMMiddleware
|
|
7
|
+
from fastapi_extras.apm.processors import ALL_PROCESSORS, PROCESSORS
|
|
8
|
+
|
|
9
|
+
__all__ = [
|
|
10
|
+
"ALL_PROCESSORS",
|
|
11
|
+
"DEFAULT_SANITIZE_FIELD_NAMES",
|
|
12
|
+
"PROCESSORS",
|
|
13
|
+
"ElasticAPMMiddleware",
|
|
14
|
+
"build_apm_config",
|
|
15
|
+
"capture_http_request",
|
|
16
|
+
"label_transaction",
|
|
17
|
+
"mask_sensitive_data",
|
|
18
|
+
"normalize_span_value",
|
|
19
|
+
]
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
from typing import Any, Dict, Iterable, List, Optional, Union
|
|
2
|
+
|
|
3
|
+
import fastapi
|
|
4
|
+
|
|
5
|
+
from fastapi_extras.apm.constants import (
|
|
6
|
+
DEFAULT_CAPTURE_BODY,
|
|
7
|
+
DEFAULT_CLOUD_PROVIDER,
|
|
8
|
+
DEFAULT_LOG_LEVEL,
|
|
9
|
+
DEFAULT_SANITIZE_FIELD_NAMES,
|
|
10
|
+
)
|
|
11
|
+
from fastapi_extras.apm.processors import ALL_PROCESSORS
|
|
12
|
+
|
|
13
|
+
FieldNamesInput = Union[str, Iterable[str]]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def parse_field_names(value: FieldNamesInput) -> List[str]:
|
|
17
|
+
items = value.split(",") if isinstance(value, str) else value
|
|
18
|
+
return [item.strip() for item in items if item.strip()]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def unique(items: Iterable[str]) -> List[str]:
|
|
22
|
+
return list(dict.fromkeys(items))
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def has_credentials(server_url: Optional[str], secret_token: Optional[str]) -> bool:
|
|
26
|
+
return bool((server_url or "").strip() and (secret_token or "").strip())
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def build_apm_config(
|
|
30
|
+
service_name: str,
|
|
31
|
+
*,
|
|
32
|
+
server_url: Optional[str] = "",
|
|
33
|
+
secret_token: Optional[str] = "",
|
|
34
|
+
environment: str = "",
|
|
35
|
+
enabled: Optional[bool] = None,
|
|
36
|
+
log_level: str = DEFAULT_LOG_LEVEL,
|
|
37
|
+
capture_body: str = DEFAULT_CAPTURE_BODY,
|
|
38
|
+
sanitize_field_names: Optional[FieldNamesInput] = None,
|
|
39
|
+
extra_sanitize_field_names: FieldNamesInput = (),
|
|
40
|
+
extra_processors: Iterable[str] = (),
|
|
41
|
+
**overrides: Any,
|
|
42
|
+
) -> Dict[str, Any]:
|
|
43
|
+
base_field_names = (
|
|
44
|
+
DEFAULT_SANITIZE_FIELD_NAMES
|
|
45
|
+
if sanitize_field_names is None
|
|
46
|
+
else parse_field_names(sanitize_field_names)
|
|
47
|
+
)
|
|
48
|
+
config: Dict[str, Any] = {
|
|
49
|
+
"ENABLED": has_credentials(server_url, secret_token) if enabled is None else enabled,
|
|
50
|
+
"SERVICE_NAME": service_name,
|
|
51
|
+
"SECRET_TOKEN": secret_token or "",
|
|
52
|
+
"SERVER_URL": server_url or "",
|
|
53
|
+
"ENVIRONMENT": environment,
|
|
54
|
+
"LOG_LEVEL": log_level,
|
|
55
|
+
"CLOUD_PROVIDER": DEFAULT_CLOUD_PROVIDER,
|
|
56
|
+
"FRAMEWORK_NAME": "fastapi",
|
|
57
|
+
"FRAMEWORK_VERSION": fastapi.__version__,
|
|
58
|
+
"CAPTURE_HEADERS": True,
|
|
59
|
+
"CAPTURE_BODY": capture_body,
|
|
60
|
+
"SANITIZE_FIELD_NAMES": unique(
|
|
61
|
+
[*base_field_names, *parse_field_names(extra_sanitize_field_names)]
|
|
62
|
+
),
|
|
63
|
+
"PROCESSORS": (*ALL_PROCESSORS, *extra_processors),
|
|
64
|
+
}
|
|
65
|
+
config.update({key.upper(): value for key, value in overrides.items()})
|
|
66
|
+
return config
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
EXTERNAL = "external"
|
|
2
|
+
UNKNOWN = "UNKNOWN"
|
|
3
|
+
UNKNOWN_STATUS_CODE = 999
|
|
4
|
+
MAX_FIELD_LENGTH = 500
|
|
5
|
+
MAX_RESPONSE_BODY_BYTES = 64 * 1024
|
|
6
|
+
TRUNCATED_SUFFIX = "… [TRUNCATED]"
|
|
7
|
+
UNREADABLE_RESPONSE = "<could not decode response>"
|
|
8
|
+
RESPONSE_BODY_TOO_LARGE = "<response body too large>"
|
|
9
|
+
RESPONSE_BODY_CONTEXT_KEY = "response_body"
|
|
10
|
+
JSON_MEDIA_TYPE = "application/json"
|
|
11
|
+
JSON_MEDIA_TYPE_SUFFIX = "+json"
|
|
12
|
+
IDENTITY_ENCODING = "identity"
|
|
13
|
+
DEFAULT_SANITIZE_FIELD_NAMES = (
|
|
14
|
+
"password",
|
|
15
|
+
"passwd",
|
|
16
|
+
"pwd",
|
|
17
|
+
"secret",
|
|
18
|
+
"*key",
|
|
19
|
+
"*token*",
|
|
20
|
+
"*session*",
|
|
21
|
+
"*credit*",
|
|
22
|
+
"*card*",
|
|
23
|
+
"*auth*",
|
|
24
|
+
"*principal*",
|
|
25
|
+
"set-cookie",
|
|
26
|
+
"pin",
|
|
27
|
+
"otp",
|
|
28
|
+
"*document*",
|
|
29
|
+
"*cpf*",
|
|
30
|
+
"*cnpj*",
|
|
31
|
+
"*email*",
|
|
32
|
+
"*phone*",
|
|
33
|
+
"*mobile*",
|
|
34
|
+
"*birth*",
|
|
35
|
+
"mother_name",
|
|
36
|
+
"rg",
|
|
37
|
+
"*account_number*",
|
|
38
|
+
"agency",
|
|
39
|
+
"pix_key",
|
|
40
|
+
"*cvv*",
|
|
41
|
+
"*salary*",
|
|
42
|
+
"*income*",
|
|
43
|
+
)
|
|
44
|
+
DEFAULT_LOG_LEVEL = "INFO"
|
|
45
|
+
DEFAULT_CAPTURE_BODY = "off"
|
|
46
|
+
DEFAULT_CLOUD_PROVIDER = "gcp"
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import functools
|
|
2
|
+
import inspect
|
|
3
|
+
from typing import Any, Awaitable, Callable, Dict, Mapping, Optional, Tuple, TypeVar
|
|
4
|
+
from urllib.parse import urlparse
|
|
5
|
+
|
|
6
|
+
import elasticapm
|
|
7
|
+
from elasticapm.conf.constants import MASK
|
|
8
|
+
|
|
9
|
+
from fastapi_extras.apm.constants import EXTERNAL, UNKNOWN, UNKNOWN_STATUS_CODE, UNREADABLE_RESPONSE
|
|
10
|
+
from fastapi_extras.apm.labels import to_label_value
|
|
11
|
+
from fastapi_extras.apm.masking import (
|
|
12
|
+
FieldNames,
|
|
13
|
+
get_sanitize_field_names,
|
|
14
|
+
is_sensitive_key,
|
|
15
|
+
mask_sensitive_data,
|
|
16
|
+
normalize_span_value,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
ReturnType = TypeVar("ReturnType")
|
|
20
|
+
|
|
21
|
+
ERROR_BODY_ATTRIBUTES = ("errors", "content", "detail")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def get_span_name(method: str, url: str) -> str:
|
|
25
|
+
parsed = urlparse(url)
|
|
26
|
+
path = parsed.path
|
|
27
|
+
if parsed.query:
|
|
28
|
+
path = f"{path}?{parsed.query}"
|
|
29
|
+
return f"{method.upper()} {path}"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def is_outgoing_request(value: Any) -> bool:
|
|
33
|
+
return isinstance(getattr(value, "method", None), str) and isinstance(
|
|
34
|
+
getattr(value, "url", None), str
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def find_outgoing_request(args: Tuple[Any, ...], kwargs: Mapping[str, Any]) -> Any:
|
|
39
|
+
candidate = kwargs.get("request")
|
|
40
|
+
if is_outgoing_request(candidate):
|
|
41
|
+
return candidate
|
|
42
|
+
return next((arg for arg in args if is_outgoing_request(arg)), None)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
async def read_response(response: Any) -> Tuple[Optional[int], Any]:
|
|
46
|
+
status_code = getattr(response, "status", None)
|
|
47
|
+
if not isinstance(status_code, int):
|
|
48
|
+
status_code = getattr(response, "status_code", None)
|
|
49
|
+
if not isinstance(status_code, int):
|
|
50
|
+
return None, None
|
|
51
|
+
try:
|
|
52
|
+
text = response.text
|
|
53
|
+
body = text() if callable(text) else text
|
|
54
|
+
if inspect.isawaitable(body):
|
|
55
|
+
body = await body
|
|
56
|
+
except Exception:
|
|
57
|
+
body = UNREADABLE_RESPONSE
|
|
58
|
+
return status_code, body
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def read_error(error: Exception) -> Tuple[int, Any]:
|
|
62
|
+
status_code = getattr(error, "status_code", None)
|
|
63
|
+
if not isinstance(status_code, int):
|
|
64
|
+
return UNKNOWN_STATUS_CODE, repr(error)
|
|
65
|
+
body = {}
|
|
66
|
+
for attribute in ERROR_BODY_ATTRIBUTES:
|
|
67
|
+
value = getattr(error, attribute, None)
|
|
68
|
+
if value:
|
|
69
|
+
body[attribute] = value
|
|
70
|
+
return status_code, body
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def build_span_labels(
|
|
74
|
+
status_code: Optional[int],
|
|
75
|
+
response_body: Any,
|
|
76
|
+
payload: Any,
|
|
77
|
+
extra_labels: Optional[Mapping[str, Any]] = None,
|
|
78
|
+
field_names: Optional[FieldNames] = None,
|
|
79
|
+
) -> Dict[str, Any]:
|
|
80
|
+
names = get_sanitize_field_names() if field_names is None else field_names
|
|
81
|
+
labels: Dict[str, Any] = {
|
|
82
|
+
"status_code": status_code,
|
|
83
|
+
"response_body": normalize_span_value(response_body),
|
|
84
|
+
}
|
|
85
|
+
if payload:
|
|
86
|
+
labels["payload"] = normalize_span_value(payload)
|
|
87
|
+
if extra_labels:
|
|
88
|
+
labels.update(extra_labels)
|
|
89
|
+
return {
|
|
90
|
+
key: MASK
|
|
91
|
+
if is_sensitive_key(key, names)
|
|
92
|
+
else to_label_value(mask_sensitive_data(value, names))
|
|
93
|
+
for key, value in labels.items()
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def capture_http_request(
|
|
98
|
+
func: Callable[..., Awaitable[ReturnType]],
|
|
99
|
+
) -> Callable[..., Awaitable[ReturnType]]:
|
|
100
|
+
@functools.wraps(func)
|
|
101
|
+
async def wrapper(*args: Any, **kwargs: Any) -> ReturnType:
|
|
102
|
+
request = find_outgoing_request(args, kwargs)
|
|
103
|
+
method = getattr(request, "method", UNKNOWN)
|
|
104
|
+
url = getattr(request, "url", UNKNOWN)
|
|
105
|
+
payload = getattr(request, "payload", None)
|
|
106
|
+
status_code: Optional[int] = None
|
|
107
|
+
response_body: Any = None
|
|
108
|
+
|
|
109
|
+
async with elasticapm.async_capture_span(
|
|
110
|
+
get_span_name(method, url), span_type=EXTERNAL
|
|
111
|
+
) as span:
|
|
112
|
+
try:
|
|
113
|
+
response = await func(*args, **kwargs)
|
|
114
|
+
status_code, response_body = await read_response(response)
|
|
115
|
+
return response
|
|
116
|
+
except Exception as error:
|
|
117
|
+
status_code, response_body = read_error(error)
|
|
118
|
+
raise
|
|
119
|
+
finally:
|
|
120
|
+
if span:
|
|
121
|
+
span.label(
|
|
122
|
+
**build_span_labels(
|
|
123
|
+
status_code, response_body, payload, kwargs.get("labels")
|
|
124
|
+
)
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
return wrapper
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from typing import Any, Iterable, Mapping
|
|
3
|
+
|
|
4
|
+
import elasticapm
|
|
5
|
+
|
|
6
|
+
from fastapi_extras.apm.masking import get_sanitize_field_names, sanitize, truncate
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def to_label_value(value: Any) -> Any:
|
|
10
|
+
if isinstance(value, (dict, list)):
|
|
11
|
+
return json.dumps(value, ensure_ascii=False, default=str)
|
|
12
|
+
return value
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def label_transaction(data: Mapping[str, Any], keys: Iterable[str]) -> None:
|
|
16
|
+
labels = {key: data[key] for key in keys if data.get(key) is not None}
|
|
17
|
+
if not labels:
|
|
18
|
+
return
|
|
19
|
+
safe_labels = truncate(sanitize(labels, get_sanitize_field_names()))
|
|
20
|
+
elasticapm.label(**{key: to_label_value(value) for key, value in safe_labels.items()})
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import base64
|
|
2
|
+
import json
|
|
3
|
+
import logging
|
|
4
|
+
from typing import Any, Optional, Pattern, Sequence
|
|
5
|
+
|
|
6
|
+
import elasticapm
|
|
7
|
+
from elasticapm.conf.constants import BASE_SANITIZE_FIELD_NAMES, MASK
|
|
8
|
+
from pydantic import BaseModel
|
|
9
|
+
|
|
10
|
+
from fastapi_extras.apm.constants import MAX_FIELD_LENGTH, TRUNCATED_SUFFIX
|
|
11
|
+
|
|
12
|
+
logger = logging.getLogger(__name__)
|
|
13
|
+
|
|
14
|
+
FieldNames = Sequence[Pattern]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def get_sanitize_field_names() -> FieldNames:
|
|
18
|
+
client = elasticapm.get_client()
|
|
19
|
+
if client is None:
|
|
20
|
+
return BASE_SANITIZE_FIELD_NAMES
|
|
21
|
+
return client.config.sanitize_field_names
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def is_sensitive_key(key: Any, field_names: FieldNames) -> bool:
|
|
25
|
+
if not isinstance(key, str):
|
|
26
|
+
return False
|
|
27
|
+
normalized = key.strip().lower()
|
|
28
|
+
return any(pattern.match(normalized) for pattern in field_names)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def sanitize(data: Any, field_names: FieldNames) -> Any:
|
|
32
|
+
if isinstance(data, dict):
|
|
33
|
+
return {
|
|
34
|
+
key: MASK if is_sensitive_key(key, field_names) else sanitize(value, field_names)
|
|
35
|
+
for key, value in data.items()
|
|
36
|
+
}
|
|
37
|
+
if isinstance(data, (list, tuple)):
|
|
38
|
+
return [sanitize(item, field_names) for item in data]
|
|
39
|
+
return data
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def truncate(data: Any, max_length: int = MAX_FIELD_LENGTH) -> Any:
|
|
43
|
+
if isinstance(data, dict):
|
|
44
|
+
return {key: truncate(value, max_length) for key, value in data.items()}
|
|
45
|
+
if isinstance(data, (list, tuple)):
|
|
46
|
+
return [truncate(item, max_length) for item in data]
|
|
47
|
+
if data is None or isinstance(data, (bool, int, float)):
|
|
48
|
+
return data
|
|
49
|
+
text = data if isinstance(data, str) else str(data)
|
|
50
|
+
if len(text) > max_length:
|
|
51
|
+
return text[:max_length] + TRUNCATED_SUFFIX
|
|
52
|
+
return text
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def normalize_span_value(value: Any) -> Any:
|
|
56
|
+
if isinstance(value, BaseModel):
|
|
57
|
+
dump = getattr(value, "model_dump", None) or value.dict
|
|
58
|
+
return dump()
|
|
59
|
+
if isinstance(value, (bytes, bytearray)):
|
|
60
|
+
try:
|
|
61
|
+
return bytes(value).decode("utf-8")
|
|
62
|
+
except UnicodeDecodeError:
|
|
63
|
+
return base64.b64encode(bytes(value)).decode("ascii")
|
|
64
|
+
return value
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def parse_json(value: str) -> Any:
|
|
68
|
+
try:
|
|
69
|
+
return json.loads(value)
|
|
70
|
+
except ValueError:
|
|
71
|
+
return value
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def mask_sensitive_data(
|
|
75
|
+
data: Any,
|
|
76
|
+
field_names: Optional[FieldNames] = None,
|
|
77
|
+
max_length: int = MAX_FIELD_LENGTH,
|
|
78
|
+
) -> Any:
|
|
79
|
+
names = get_sanitize_field_names() if field_names is None else field_names
|
|
80
|
+
try:
|
|
81
|
+
value = normalize_span_value(data)
|
|
82
|
+
if isinstance(value, str):
|
|
83
|
+
value = parse_json(value)
|
|
84
|
+
return truncate(sanitize(value, names), max_length)
|
|
85
|
+
except Exception:
|
|
86
|
+
logger.exception("Could not mask APM data")
|
|
87
|
+
return MASK
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import functools
|
|
2
|
+
from typing import Any, List, Optional
|
|
3
|
+
|
|
4
|
+
import elasticapm
|
|
5
|
+
from elasticapm.base import Client
|
|
6
|
+
from elasticapm.contrib.starlette import ElasticAPM
|
|
7
|
+
from starlette.datastructures import Headers
|
|
8
|
+
from starlette.types import ASGIApp, Message, Receive, Scope, Send
|
|
9
|
+
|
|
10
|
+
from fastapi_extras.apm.constants import (
|
|
11
|
+
IDENTITY_ENCODING,
|
|
12
|
+
JSON_MEDIA_TYPE,
|
|
13
|
+
JSON_MEDIA_TYPE_SUFFIX,
|
|
14
|
+
MAX_RESPONSE_BODY_BYTES,
|
|
15
|
+
RESPONSE_BODY_CONTEXT_KEY,
|
|
16
|
+
RESPONSE_BODY_TOO_LARGE,
|
|
17
|
+
)
|
|
18
|
+
from fastapi_extras.apm.masking import mask_sensitive_data
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def is_json(headers: Headers) -> bool:
|
|
22
|
+
media_type = headers.get("content-type", "").split(";")[0].strip().lower()
|
|
23
|
+
return media_type == JSON_MEDIA_TYPE or media_type.endswith(JSON_MEDIA_TYPE_SUFFIX)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def is_encoded(headers: Headers) -> bool:
|
|
27
|
+
encoding = headers.get("content-encoding", IDENTITY_ENCODING).strip().lower()
|
|
28
|
+
return encoding not in ("", IDENTITY_ENCODING)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class ResponseBodyRecorder:
|
|
32
|
+
def __init__(self, max_bytes: int) -> None:
|
|
33
|
+
self.max_bytes = max_bytes
|
|
34
|
+
self.chunks: List[bytes] = []
|
|
35
|
+
self.size = 0
|
|
36
|
+
self.readable = False
|
|
37
|
+
self.too_large = False
|
|
38
|
+
|
|
39
|
+
def start(self, message: Message) -> None:
|
|
40
|
+
headers = Headers(raw=message.get("headers", []))
|
|
41
|
+
content_length = headers.get("content-length", "")
|
|
42
|
+
self.readable = is_json(headers) and not is_encoded(headers)
|
|
43
|
+
self.too_large = content_length.isdigit() and int(content_length) > self.max_bytes
|
|
44
|
+
|
|
45
|
+
def feed(self, message: Message) -> bool:
|
|
46
|
+
if self.readable and not self.too_large:
|
|
47
|
+
self._append(message.get("body", b""))
|
|
48
|
+
return not message.get("more_body", False)
|
|
49
|
+
|
|
50
|
+
def _append(self, chunk: bytes) -> None:
|
|
51
|
+
self.size += len(chunk)
|
|
52
|
+
if self.size > self.max_bytes:
|
|
53
|
+
self.too_large = True
|
|
54
|
+
self.chunks.clear()
|
|
55
|
+
elif chunk:
|
|
56
|
+
self.chunks.append(chunk)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class ElasticAPMMiddleware(ElasticAPM):
|
|
60
|
+
def __init__(
|
|
61
|
+
self,
|
|
62
|
+
app: ASGIApp,
|
|
63
|
+
client: Optional[Client] = None,
|
|
64
|
+
capture_response_body: bool = True,
|
|
65
|
+
max_response_body_bytes: int = MAX_RESPONSE_BODY_BYTES,
|
|
66
|
+
**kwargs: Any,
|
|
67
|
+
) -> None:
|
|
68
|
+
super().__init__(app, client, **kwargs)
|
|
69
|
+
self.capture_response_body = capture_response_body
|
|
70
|
+
self.max_response_body_bytes = max_response_body_bytes
|
|
71
|
+
|
|
72
|
+
def _record_response_body(self, recorder: ResponseBodyRecorder) -> None:
|
|
73
|
+
if not recorder.readable:
|
|
74
|
+
return
|
|
75
|
+
if recorder.too_large:
|
|
76
|
+
body = RESPONSE_BODY_TOO_LARGE
|
|
77
|
+
else:
|
|
78
|
+
body = mask_sensitive_data(
|
|
79
|
+
b"".join(recorder.chunks), self.client.config.sanitize_field_names
|
|
80
|
+
)
|
|
81
|
+
elasticapm.set_custom_context({RESPONSE_BODY_CONTEXT_KEY: body})
|
|
82
|
+
|
|
83
|
+
def _should_record(self, scope: Scope) -> bool:
|
|
84
|
+
return (
|
|
85
|
+
scope["type"] == "http"
|
|
86
|
+
and self.capture_response_body
|
|
87
|
+
and not self.client.should_ignore_url(scope["path"])
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
|
91
|
+
if not self._should_record(scope):
|
|
92
|
+
await super().__call__(scope, receive, send)
|
|
93
|
+
return
|
|
94
|
+
|
|
95
|
+
recorder = ResponseBodyRecorder(self.max_response_body_bytes)
|
|
96
|
+
|
|
97
|
+
@functools.wraps(send)
|
|
98
|
+
async def recording_send(message: Message) -> None:
|
|
99
|
+
if message.get("type") == "http.response.start":
|
|
100
|
+
recorder.start(message)
|
|
101
|
+
elif message.get("type") == "http.response.body" and recorder.feed(message):
|
|
102
|
+
self._record_response_body(recorder)
|
|
103
|
+
await send(message)
|
|
104
|
+
|
|
105
|
+
await super().__call__(scope, receive, recording_send)
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from typing import Any, Dict, Optional
|
|
3
|
+
|
|
4
|
+
from elasticapm.conf.constants import ERROR, MASK, SPAN, TRANSACTION
|
|
5
|
+
from elasticapm.processors import for_events
|
|
6
|
+
|
|
7
|
+
from fastapi_extras.apm.masking import FieldNames, is_sensitive_key, sanitize
|
|
8
|
+
|
|
9
|
+
PROCESSORS = (
|
|
10
|
+
"fastapi_extras.apm.processors.sanitize_http_request_json_body",
|
|
11
|
+
"fastapi_extras.apm.processors.sanitize_custom_context",
|
|
12
|
+
"fastapi_extras.apm.processors.sanitize_labels",
|
|
13
|
+
)
|
|
14
|
+
AGENT_PROCESSORS = (
|
|
15
|
+
"elasticapm.processors.sanitize_stacktrace_locals",
|
|
16
|
+
"elasticapm.processors.sanitize_http_request_cookies",
|
|
17
|
+
"elasticapm.processors.sanitize_http_response_cookies",
|
|
18
|
+
"elasticapm.processors.sanitize_http_headers",
|
|
19
|
+
"elasticapm.processors.sanitize_http_wsgi_env",
|
|
20
|
+
"elasticapm.processors.sanitize_http_request_body",
|
|
21
|
+
)
|
|
22
|
+
ALL_PROCESSORS = (*PROCESSORS, *AGENT_PROCESSORS)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def load_json_structure(value: Any) -> Optional[Any]:
|
|
26
|
+
if isinstance(value, (dict, list)):
|
|
27
|
+
return value
|
|
28
|
+
if not isinstance(value, (str, bytes, bytearray)):
|
|
29
|
+
return None
|
|
30
|
+
try:
|
|
31
|
+
parsed = json.loads(value)
|
|
32
|
+
except ValueError:
|
|
33
|
+
return None
|
|
34
|
+
return parsed if isinstance(parsed, (dict, list)) else None
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def sanitize_label(key: str, value: Any, field_names: FieldNames) -> Any:
|
|
38
|
+
if is_sensitive_key(key, field_names):
|
|
39
|
+
return MASK
|
|
40
|
+
if not isinstance(value, str):
|
|
41
|
+
return value
|
|
42
|
+
parsed = load_json_structure(value)
|
|
43
|
+
if parsed is None:
|
|
44
|
+
return value
|
|
45
|
+
return json.dumps(sanitize(parsed, field_names), ensure_ascii=False)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@for_events(ERROR, TRANSACTION)
|
|
49
|
+
def sanitize_http_request_json_body(client: Any, event: Dict[str, Any]) -> Dict[str, Any]:
|
|
50
|
+
try:
|
|
51
|
+
body = event["context"]["request"]["body"]
|
|
52
|
+
except (KeyError, TypeError):
|
|
53
|
+
return event
|
|
54
|
+
parsed = load_json_structure(body)
|
|
55
|
+
if parsed is None:
|
|
56
|
+
return event
|
|
57
|
+
event["context"]["request"]["body"] = json.dumps(
|
|
58
|
+
sanitize(parsed, client.config.sanitize_field_names)
|
|
59
|
+
)
|
|
60
|
+
return event
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@for_events(ERROR, TRANSACTION)
|
|
64
|
+
def sanitize_custom_context(client: Any, event: Dict[str, Any]) -> Dict[str, Any]:
|
|
65
|
+
custom = (event.get("context") or {}).get("custom")
|
|
66
|
+
if isinstance(custom, dict):
|
|
67
|
+
event["context"]["custom"] = sanitize(custom, client.config.sanitize_field_names)
|
|
68
|
+
return event
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@for_events(SPAN, TRANSACTION)
|
|
72
|
+
def sanitize_labels(client: Any, event: Dict[str, Any]) -> Dict[str, Any]:
|
|
73
|
+
tags = (event.get("context") or {}).get("tags")
|
|
74
|
+
if not isinstance(tags, dict):
|
|
75
|
+
return event
|
|
76
|
+
field_names = client.config.sanitize_field_names
|
|
77
|
+
event["context"]["tags"] = {
|
|
78
|
+
key: sanitize_label(key, value, field_names) for key, value in tags.items()
|
|
79
|
+
}
|
|
80
|
+
return event
|
|
@@ -15,8 +15,19 @@ class Redis(redis.Redis):
|
|
|
15
15
|
|
|
16
16
|
|
|
17
17
|
class RedisManager:
|
|
18
|
-
def __init__(
|
|
19
|
-
self
|
|
18
|
+
def __init__(
|
|
19
|
+
self,
|
|
20
|
+
url: Union[RedisDsn, str],
|
|
21
|
+
socket_timeout: float = 2.0,
|
|
22
|
+
socket_connect_timeout: float = 2.0,
|
|
23
|
+
health_check_interval: int = 30,
|
|
24
|
+
):
|
|
25
|
+
self.pool = redis.ConnectionPool.from_url(
|
|
26
|
+
str(url),
|
|
27
|
+
socket_timeout=socket_timeout,
|
|
28
|
+
socket_connect_timeout=socket_connect_timeout,
|
|
29
|
+
health_check_interval=health_check_interval,
|
|
30
|
+
)
|
|
20
31
|
|
|
21
32
|
async def __call__(self) -> AsyncGenerator[Redis, None]:
|
|
22
33
|
cli = Redis(connection_pool=self.pool)
|
|
@@ -2,7 +2,7 @@ import hashlib
|
|
|
2
2
|
import json
|
|
3
3
|
import logging
|
|
4
4
|
import typing
|
|
5
|
-
from typing import Any, AsyncGenerator, Awaitable, Callable, Optional, Protocol, Union
|
|
5
|
+
from typing import Any, AsyncGenerator, Awaitable, Callable, Optional, Protocol, Tuple, Union
|
|
6
6
|
|
|
7
7
|
import httpx
|
|
8
8
|
from fastapi import Depends, HTTPException, Request, status
|
|
@@ -14,6 +14,12 @@ logger = logging.getLogger(__name__)
|
|
|
14
14
|
ssl_context = httpx.create_ssl_context()
|
|
15
15
|
|
|
16
16
|
UNAUTHORIZED = HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials")
|
|
17
|
+
SERVICE_UNAVAILABLE = HTTPException(
|
|
18
|
+
status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="Authorization service unavailable"
|
|
19
|
+
)
|
|
20
|
+
DENIED_STATUS_CODES = frozenset(
|
|
21
|
+
{status.HTTP_400_BAD_REQUEST, status.HTTP_401_UNAUTHORIZED, status.HTTP_403_FORBIDDEN}
|
|
22
|
+
)
|
|
17
23
|
DEFAULT_SCHEME = APIKeyHeader(name="Authorization")
|
|
18
24
|
DEFAULT_CACHE_TTL = 60 # seconds
|
|
19
25
|
|
|
@@ -38,6 +44,21 @@ def keygen(seed: str, prefix: str = "") -> str:
|
|
|
38
44
|
return f"{prefix}{hash}"
|
|
39
45
|
|
|
40
46
|
|
|
47
|
+
def parse_response(response: httpx.Response) -> Tuple[bool, Any]:
|
|
48
|
+
if response.status_code in DENIED_STATUS_CODES:
|
|
49
|
+
return False, None
|
|
50
|
+
|
|
51
|
+
if response.status_code != status.HTTP_200_OK:
|
|
52
|
+
logger.error(f"Authorization service returned unexpected status {response.status_code}")
|
|
53
|
+
raise SERVICE_UNAVAILABLE
|
|
54
|
+
|
|
55
|
+
try:
|
|
56
|
+
return True, response.json()
|
|
57
|
+
except ValueError:
|
|
58
|
+
logger.error(f"Authorization service returned a non-JSON body: {response.text}")
|
|
59
|
+
raise SERVICE_UNAVAILABLE
|
|
60
|
+
|
|
61
|
+
|
|
41
62
|
def remote_authorization(
|
|
42
63
|
url: Union[str, AnyHttpUrl],
|
|
43
64
|
*,
|
|
@@ -60,7 +81,7 @@ def remote_authorization(
|
|
|
60
81
|
try:
|
|
61
82
|
cached = await cache.get(key)
|
|
62
83
|
except Exception as cache_error:
|
|
63
|
-
logger.warning("Cache get failed, proceeding without cache:
|
|
84
|
+
logger.warning(f"Cache get failed, proceeding without cache: {cache_error}")
|
|
64
85
|
|
|
65
86
|
if cached:
|
|
66
87
|
cached = json.loads(cached)
|
|
@@ -84,22 +105,23 @@ def remote_authorization(
|
|
|
84
105
|
try:
|
|
85
106
|
response = await client.post(str(url), json=info, **kwargs)
|
|
86
107
|
except httpx.HTTPError as http_error:
|
|
87
|
-
logger.error(
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
108
|
+
logger.error(
|
|
109
|
+
f"Authorization request failed: {type(http_error).__name__}: {http_error}"
|
|
110
|
+
)
|
|
111
|
+
raise SERVICE_UNAVAILABLE
|
|
112
|
+
|
|
113
|
+
authorized, context = parse_response(response)
|
|
92
114
|
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
115
|
+
try:
|
|
116
|
+
value = json.dumps({"authorized": authorized, "context": context})
|
|
117
|
+
await cache.set(key, value, cache_ttl)
|
|
118
|
+
except Exception as cache_error:
|
|
119
|
+
logger.warning(f"Cache set failed, proceeding without cache: {cache_error}")
|
|
98
120
|
|
|
99
|
-
|
|
100
|
-
|
|
121
|
+
if not authorized:
|
|
122
|
+
raise UNAUTHORIZED
|
|
101
123
|
|
|
102
|
-
|
|
103
|
-
|
|
124
|
+
request.scope["authorizer"] = context
|
|
125
|
+
return request.scope["authorizer"]
|
|
104
126
|
|
|
105
127
|
return authorizer
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import hashlib
|
|
2
|
+
import json
|
|
3
|
+
import logging
|
|
4
|
+
from typing import Any, AsyncGenerator, Callable, Optional, Protocol, Union
|
|
5
|
+
|
|
6
|
+
import httpx
|
|
7
|
+
from fastapi import Depends, HTTPException, Request, status
|
|
8
|
+
from fastapi.security import APIKeyCookie, APIKeyHeader, APIKeyQuery
|
|
9
|
+
from pydantic import AnyHttpUrl, RedisDsn
|
|
10
|
+
from redis.asyncio import ConnectionPool, Redis
|
|
11
|
+
from redis.asyncio.retry import Retry
|
|
12
|
+
from redis.backoff import ExponentialBackoff
|
|
13
|
+
from redis.exceptions import ConnectionError as RedisConnectionError
|
|
14
|
+
from redis.exceptions import RedisError
|
|
15
|
+
from typing_extensions import Annotated
|
|
16
|
+
|
|
17
|
+
from fastapi_extras.security.auth import SERVICE_UNAVAILABLE, parse_response
|
|
18
|
+
|
|
19
|
+
logger = logging.getLogger(__name__)
|
|
20
|
+
ssl_context = httpx.create_ssl_context()
|
|
21
|
+
|
|
22
|
+
UNAUTHORIZED = HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials")
|
|
23
|
+
DEFAULT_SCHEME = APIKeyHeader(name="Authorization")
|
|
24
|
+
DEFAULT_CACHE_TTL = 60 # seconds
|
|
25
|
+
|
|
26
|
+
# Redis client bounds: keep a stuck connection from a Redis outage from being
|
|
27
|
+
# reused forever instead of getting discarded and replaced.
|
|
28
|
+
DEFAULT_SOCKET_TIMEOUT = 2.0
|
|
29
|
+
DEFAULT_SOCKET_CONNECT_TIMEOUT = 2.0
|
|
30
|
+
DEFAULT_HEALTH_CHECK_INTERVAL = 30
|
|
31
|
+
DEFAULT_HTTP_TIMEOUT = 5.0
|
|
32
|
+
DEFAULT_HTTP_CONNECT_TIMEOUT = 2.0
|
|
33
|
+
|
|
34
|
+
APIKeyScheme = Union[APIKeyCookie, APIKeyHeader, APIKeyQuery]
|
|
35
|
+
|
|
36
|
+
CacheDependency = Callable[..., Union[Redis, AsyncGenerator[Redis, None]]]
|
|
37
|
+
HTTPClientDependency = Callable[..., httpx.AsyncClient]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def keygen(seed: str, prefix: str = "") -> str:
|
|
41
|
+
hash = hashlib.sha256(seed.encode()).hexdigest()
|
|
42
|
+
return f"{prefix}{hash}"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class AsyncRedisCache:
|
|
46
|
+
"""FastAPI dependency yielding a redis.asyncio.Redis bound to a shared
|
|
47
|
+
pool with bounded timeouts + health checks, so stuck connections from a
|
|
48
|
+
Redis outage get discarded instead of reused forever."""
|
|
49
|
+
|
|
50
|
+
def __init__(
|
|
51
|
+
self,
|
|
52
|
+
url: Union[RedisDsn, str],
|
|
53
|
+
*,
|
|
54
|
+
socket_timeout: float = DEFAULT_SOCKET_TIMEOUT,
|
|
55
|
+
socket_connect_timeout: float = DEFAULT_SOCKET_CONNECT_TIMEOUT,
|
|
56
|
+
health_check_interval: int = DEFAULT_HEALTH_CHECK_INTERVAL,
|
|
57
|
+
) -> None:
|
|
58
|
+
self.pool = ConnectionPool.from_url(
|
|
59
|
+
str(url),
|
|
60
|
+
decode_responses=True,
|
|
61
|
+
socket_timeout=socket_timeout,
|
|
62
|
+
socket_connect_timeout=socket_connect_timeout,
|
|
63
|
+
health_check_interval=health_check_interval,
|
|
64
|
+
retry_on_error=[RedisConnectionError],
|
|
65
|
+
retry=Retry(ExponentialBackoff(cap=1.0, base=0.05), retries=1),
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
async def __call__(self) -> AsyncGenerator[Redis, None]:
|
|
69
|
+
cli = Redis(connection_pool=self.pool)
|
|
70
|
+
try:
|
|
71
|
+
yield cli
|
|
72
|
+
finally:
|
|
73
|
+
await cli.aclose()
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class Authorizer(Protocol):
|
|
77
|
+
"""Structural type for what remote_authorization_async returns: a FastAPI
|
|
78
|
+
dependency callable that also owns a closeable httpx client."""
|
|
79
|
+
|
|
80
|
+
async def __call__(
|
|
81
|
+
self, request: Request, token: str, cache: Redis, http_client: httpx.AsyncClient
|
|
82
|
+
) -> Any: ...
|
|
83
|
+
async def aclose(self) -> None: ...
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def remote_authorization_async(
|
|
87
|
+
url: Union[str, AnyHttpUrl],
|
|
88
|
+
*,
|
|
89
|
+
scheme: APIKeyScheme = DEFAULT_SCHEME,
|
|
90
|
+
cache_gen: CacheDependency,
|
|
91
|
+
http_client_gen: Optional[HTTPClientDependency] = None,
|
|
92
|
+
cache_ttl: int = DEFAULT_CACHE_TTL,
|
|
93
|
+
http_timeout: float = DEFAULT_HTTP_TIMEOUT,
|
|
94
|
+
http_connect_timeout: float = DEFAULT_HTTP_CONNECT_TIMEOUT,
|
|
95
|
+
**kwargs: Any,
|
|
96
|
+
) -> Authorizer:
|
|
97
|
+
if not isinstance(scheme, (APIKeyCookie, APIKeyHeader, APIKeyQuery)):
|
|
98
|
+
raise TypeError("Invalid APIKeyScheme")
|
|
99
|
+
|
|
100
|
+
request_timeout = httpx.Timeout(http_timeout, connect=http_connect_timeout)
|
|
101
|
+
|
|
102
|
+
# Only built when the caller has no httpx client of its own to inject.
|
|
103
|
+
_owned_client: Optional[httpx.AsyncClient] = None
|
|
104
|
+
|
|
105
|
+
def _get_owned_client() -> httpx.AsyncClient:
|
|
106
|
+
nonlocal _owned_client
|
|
107
|
+
if _owned_client is None:
|
|
108
|
+
_owned_client = httpx.AsyncClient(verify=ssl_context, timeout=request_timeout)
|
|
109
|
+
return _owned_client
|
|
110
|
+
|
|
111
|
+
async def _default_http_client_gen() -> httpx.AsyncClient:
|
|
112
|
+
return _get_owned_client()
|
|
113
|
+
|
|
114
|
+
async def aclose() -> None:
|
|
115
|
+
nonlocal _owned_client
|
|
116
|
+
if _owned_client is not None:
|
|
117
|
+
await _owned_client.aclose()
|
|
118
|
+
_owned_client = None
|
|
119
|
+
|
|
120
|
+
async def authorizer(
|
|
121
|
+
request: Request,
|
|
122
|
+
token: Annotated[str, Depends(scheme)],
|
|
123
|
+
cache: Annotated[Redis, Depends(cache_gen)],
|
|
124
|
+
http_client: Annotated[
|
|
125
|
+
httpx.AsyncClient, Depends(http_client_gen or _default_http_client_gen)
|
|
126
|
+
],
|
|
127
|
+
) -> Any:
|
|
128
|
+
key = keygen(token, prefix="authorizer:")
|
|
129
|
+
|
|
130
|
+
cached = None
|
|
131
|
+
try:
|
|
132
|
+
cached = await cache.get(key)
|
|
133
|
+
except RedisError as cache_error:
|
|
134
|
+
logger.warning("Cache get failed, proceeding without cache: %s", cache_error)
|
|
135
|
+
|
|
136
|
+
if cached:
|
|
137
|
+
cached = json.loads(cached)
|
|
138
|
+
|
|
139
|
+
if cached and isinstance(cached, dict):
|
|
140
|
+
if not cached.get("authorized"):
|
|
141
|
+
raise UNAUTHORIZED
|
|
142
|
+
|
|
143
|
+
request.scope["authorizer"] = cached.get("context")
|
|
144
|
+
return request.scope["authorizer"]
|
|
145
|
+
|
|
146
|
+
info = {
|
|
147
|
+
"method": request.method,
|
|
148
|
+
"path": request.url.path,
|
|
149
|
+
"query_params": dict(request.query_params),
|
|
150
|
+
"headers": dict(request.headers),
|
|
151
|
+
"cookies": request.cookies,
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
# Explicit per-call timeout so it applies even when http_client is an
|
|
155
|
+
# externally-shared client whose own default timeout differs.
|
|
156
|
+
request_kwargs = {**kwargs, "timeout": request_timeout}
|
|
157
|
+
try:
|
|
158
|
+
response = await http_client.post(str(url), json=info, **request_kwargs)
|
|
159
|
+
except httpx.HTTPError as http_error:
|
|
160
|
+
logger.error(
|
|
161
|
+
"Authorization request failed: %s: %s", type(http_error).__name__, http_error
|
|
162
|
+
)
|
|
163
|
+
raise SERVICE_UNAVAILABLE
|
|
164
|
+
|
|
165
|
+
authorized, context = parse_response(response)
|
|
166
|
+
|
|
167
|
+
try:
|
|
168
|
+
value = json.dumps({"authorized": authorized, "context": context})
|
|
169
|
+
await cache.set(key, value, ex=cache_ttl)
|
|
170
|
+
except RedisError as cache_error:
|
|
171
|
+
logger.warning("Cache set failed, proceeding without cache: %s", cache_error)
|
|
172
|
+
|
|
173
|
+
if not authorized:
|
|
174
|
+
raise UNAUTHORIZED
|
|
175
|
+
|
|
176
|
+
request.scope["authorizer"] = context
|
|
177
|
+
return request.scope["authorizer"]
|
|
178
|
+
|
|
179
|
+
authorizer.aclose = aclose
|
|
180
|
+
return authorizer
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "fastapi-ext-pkg"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.6.0"
|
|
8
8
|
description = "A well-tailored utility set for FastAPI."
|
|
9
9
|
readme = {file = "README.md", content-type = "text/markdown"}
|
|
10
10
|
authors = [
|
|
@@ -27,6 +27,9 @@ classifiers = [
|
|
|
27
27
|
requires-python = ">=3.8"
|
|
28
28
|
dependencies = ["fastapi", "httpx"]
|
|
29
29
|
|
|
30
|
+
[project.optional-dependencies]
|
|
31
|
+
apm = ["elastic-apm>=6.20,<7"]
|
|
32
|
+
|
|
30
33
|
[project.urls]
|
|
31
34
|
Repository = "https://github.com/DotzInc/fastapi-ext-pkg"
|
|
32
35
|
|
|
File without changes
|
{fastapi_ext_pkg-0.4.4 → fastapi_ext_pkg-0.6.0}/fastapi_ext_pkg.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|