sovereign 0.19.3__py3-none-any.whl → 1.0.0b148__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 sovereign might be problematic. Click here for more details.
- sovereign/__init__.py +13 -81
- sovereign/app.py +59 -48
- sovereign/cache/__init__.py +172 -0
- sovereign/cache/backends/__init__.py +110 -0
- sovereign/cache/backends/s3.py +143 -0
- sovereign/cache/filesystem.py +73 -0
- sovereign/cache/types.py +15 -0
- sovereign/configuration.py +573 -0
- sovereign/constants.py +1 -0
- sovereign/context.py +271 -104
- sovereign/dynamic_config/__init__.py +113 -0
- sovereign/dynamic_config/deser.py +78 -0
- sovereign/dynamic_config/loaders.py +120 -0
- sovereign/events.py +49 -0
- sovereign/logging/access_logger.py +85 -0
- sovereign/logging/application_logger.py +54 -0
- sovereign/logging/base_logger.py +41 -0
- sovereign/logging/bootstrapper.py +36 -0
- sovereign/logging/types.py +10 -0
- sovereign/middlewares.py +8 -7
- sovereign/modifiers/lib.py +1 -0
- sovereign/rendering.py +192 -0
- sovereign/response_class.py +18 -0
- sovereign/server.py +93 -35
- sovereign/sources/file.py +1 -1
- sovereign/sources/inline.py +1 -0
- sovereign/sources/lib.py +1 -0
- sovereign/sources/poller.py +296 -53
- sovereign/statistics.py +17 -20
- sovereign/templates/base.html +59 -46
- sovereign/templates/resources.html +203 -102
- sovereign/testing/loaders.py +8 -0
- sovereign/{modifiers/test.py → testing/modifiers.py} +0 -2
- sovereign/tracing.py +102 -0
- sovereign/types.py +299 -0
- sovereign/utils/auth.py +26 -13
- sovereign/utils/crypto/__init__.py +0 -0
- sovereign/utils/crypto/crypto.py +135 -0
- sovereign/utils/crypto/suites/__init__.py +21 -0
- sovereign/utils/crypto/suites/aes_gcm_cipher.py +42 -0
- sovereign/utils/crypto/suites/base_cipher.py +21 -0
- sovereign/utils/crypto/suites/disabled_cipher.py +25 -0
- sovereign/utils/crypto/suites/fernet_cipher.py +29 -0
- sovereign/utils/dictupdate.py +2 -1
- sovereign/utils/eds.py +37 -21
- sovereign/utils/mock.py +54 -16
- sovereign/utils/resources.py +17 -0
- sovereign/utils/version_info.py +8 -0
- sovereign/views/__init__.py +4 -0
- sovereign/views/api.py +61 -0
- sovereign/views/crypto.py +46 -15
- sovereign/views/discovery.py +37 -116
- sovereign/views/healthchecks.py +87 -18
- sovereign/views/interface.py +112 -112
- sovereign/worker.py +204 -0
- {sovereign-0.19.3.dist-info → sovereign-1.0.0b148.dist-info}/METADATA +79 -76
- sovereign-1.0.0b148.dist-info/RECORD +77 -0
- {sovereign-0.19.3.dist-info → sovereign-1.0.0b148.dist-info}/WHEEL +1 -1
- sovereign-1.0.0b148.dist-info/entry_points.txt +38 -0
- sovereign_files/__init__.py +0 -0
- sovereign_files/static/darkmode.js +51 -0
- sovereign_files/static/node_expression.js +42 -0
- sovereign_files/static/panel.js +76 -0
- sovereign_files/static/resources.css +246 -0
- sovereign_files/static/resources.js +642 -0
- sovereign_files/static/sass/style.scss +33 -0
- sovereign_files/static/style.css +16143 -0
- sovereign_files/static/style.css.map +1 -0
- sovereign/config_loader.py +0 -225
- sovereign/discovery.py +0 -175
- sovereign/logs.py +0 -131
- sovereign/schemas.py +0 -780
- sovereign/static/sass/style.scss +0 -27
- sovereign/static/style.css +0 -13553
- sovereign/templates/ul_filter.html +0 -22
- sovereign/utils/crypto.py +0 -103
- sovereign/views/admin.py +0 -120
- sovereign-0.19.3.dist-info/LICENSE.txt +0 -13
- sovereign-0.19.3.dist-info/RECORD +0 -47
- sovereign-0.19.3.dist-info/entry_points.txt +0 -10
sovereign/tracing.py
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
from typing_extensions import NotRequired
|
|
2
|
+
from typing import TypedDict, Any
|
|
3
|
+
from sovereign.configuration import TracingConfig
|
|
4
|
+
import time
|
|
5
|
+
import uuid
|
|
6
|
+
import requests
|
|
7
|
+
from contextvars import ContextVar
|
|
8
|
+
from contextlib import nullcontext
|
|
9
|
+
|
|
10
|
+
from sovereign import config
|
|
11
|
+
|
|
12
|
+
_trace_id_ctx_var: ContextVar[str] = ContextVar("trace_id", default="")
|
|
13
|
+
_span_id_ctx_var: ContextVar[str] = ContextVar("span_id", default="")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def get_trace_id() -> str:
|
|
17
|
+
return _trace_id_ctx_var.get()
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def get_span_id() -> str:
|
|
21
|
+
return _span_id_ctx_var.get()
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def generate_128bit():
|
|
25
|
+
return str(uuid.uuid4()).replace("-", "")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def generate_64bit():
|
|
29
|
+
return generate_128bit()[:32]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def timestamp():
|
|
33
|
+
return str(time.time()).replace(".", "")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
TRACING = config.tracing
|
|
37
|
+
TRACING_DISABLED = not getattr(TRACING, "enabled", False)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class Trace(TypedDict):
|
|
41
|
+
traceId: str
|
|
42
|
+
id: str
|
|
43
|
+
name: str
|
|
44
|
+
timestamp: float
|
|
45
|
+
tags: dict[str, Any]
|
|
46
|
+
duration: NotRequired[float]
|
|
47
|
+
parentSpanId: NotRequired[str]
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class Tracer:
|
|
51
|
+
def __init__(self, span_name):
|
|
52
|
+
if TRACING_DISABLED:
|
|
53
|
+
return
|
|
54
|
+
|
|
55
|
+
self.tracing: TracingConfig = TRACING
|
|
56
|
+
span_id = get_span_id()
|
|
57
|
+
self.parent_span_id = None
|
|
58
|
+
if span_id != "":
|
|
59
|
+
# We are already inside a trace context
|
|
60
|
+
self.parent_span_id = span_id
|
|
61
|
+
self.trace_id = get_trace_id()
|
|
62
|
+
self.span_id = self.gen_id()
|
|
63
|
+
self.span_name = span_name
|
|
64
|
+
|
|
65
|
+
def gen_id(self):
|
|
66
|
+
if self.tracing.trace_id_128bit:
|
|
67
|
+
trace_id = generate_128bit()
|
|
68
|
+
else:
|
|
69
|
+
trace_id = generate_64bit()
|
|
70
|
+
_trace_id_ctx_var.set(trace_id)
|
|
71
|
+
return trace_id
|
|
72
|
+
|
|
73
|
+
def __enter__(self):
|
|
74
|
+
if TRACING_DISABLED:
|
|
75
|
+
return nullcontext()
|
|
76
|
+
self.trace = Trace(
|
|
77
|
+
{
|
|
78
|
+
"traceId": self.trace_id,
|
|
79
|
+
"id": self.span_id,
|
|
80
|
+
"name": self.span_name,
|
|
81
|
+
"timestamp": time.time(),
|
|
82
|
+
"tags": self.tracing.tags,
|
|
83
|
+
}
|
|
84
|
+
)
|
|
85
|
+
if self.parent_span_id:
|
|
86
|
+
self.trace["parentSpanId"] = self.parent_span_id
|
|
87
|
+
return self
|
|
88
|
+
|
|
89
|
+
def __exit__(self, exc_type, exc_value, traceback):
|
|
90
|
+
if TRACING_DISABLED:
|
|
91
|
+
return
|
|
92
|
+
self.trace["duration"] = time.time() - self.trace["timestamp"]
|
|
93
|
+
self.submit()
|
|
94
|
+
|
|
95
|
+
def submit(self):
|
|
96
|
+
print(f"{self.span_name}: {self.trace['duration']}")
|
|
97
|
+
try:
|
|
98
|
+
url = f"{self.tracing.collector}{self.tracing.endpoint}"
|
|
99
|
+
requests.post(url, json=self.trace)
|
|
100
|
+
# pylint: disable=broad-except
|
|
101
|
+
except Exception as e:
|
|
102
|
+
print(f"Failed to submit trace: {self.trace}, Error:{e}")
|
sovereign/types.py
ADDED
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
import hashlib
|
|
2
|
+
import importlib
|
|
3
|
+
from types import ModuleType
|
|
4
|
+
from typing_extensions import Any, cast
|
|
5
|
+
from functools import cached_property
|
|
6
|
+
|
|
7
|
+
import jmespath
|
|
8
|
+
from jinja2 import Template
|
|
9
|
+
from pydantic import (
|
|
10
|
+
BaseModel,
|
|
11
|
+
ConfigDict,
|
|
12
|
+
Field,
|
|
13
|
+
computed_field,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
from sovereign.dynamic_config import Loadable
|
|
17
|
+
from sovereign.utils.version_info import compute_hash
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
missing_arguments = {"missing", "positional", "arguments:"}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class Resources(list[str]):
|
|
24
|
+
"""
|
|
25
|
+
Acts like a regular list except it returns True
|
|
26
|
+
for all membership tests when empty.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
def __contains__(self, item: object) -> bool:
|
|
30
|
+
if len(self) == 0:
|
|
31
|
+
return True
|
|
32
|
+
return super().__contains__(item)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class Locality(BaseModel):
|
|
36
|
+
region: str | None = Field(None)
|
|
37
|
+
zone: str | None = Field(None)
|
|
38
|
+
sub_zone: str | None = Field(None)
|
|
39
|
+
|
|
40
|
+
def __str__(self) -> str:
|
|
41
|
+
return f"{self.region}::{self.zone}::{self.sub_zone}"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class SemanticVersion(BaseModel):
|
|
45
|
+
major_number: int = 0
|
|
46
|
+
minor_number: int = 0
|
|
47
|
+
patch: int = 0
|
|
48
|
+
|
|
49
|
+
def __str__(self) -> str:
|
|
50
|
+
return f"{self.major_number}.{self.minor_number}.{self.patch}"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class BuildVersion(BaseModel):
|
|
54
|
+
version: SemanticVersion = SemanticVersion()
|
|
55
|
+
metadata: dict[str, Any] = {}
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class Extension(BaseModel):
|
|
59
|
+
name: str | None = None
|
|
60
|
+
category: str | None = None
|
|
61
|
+
version: BuildVersion | None = None
|
|
62
|
+
disabled: bool | None = None
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class Node(BaseModel):
|
|
66
|
+
id: str = Field("-", title="Hostname")
|
|
67
|
+
cluster: str = Field(
|
|
68
|
+
...,
|
|
69
|
+
title="Envoy service-cluster",
|
|
70
|
+
description="The ``--service-cluster`` configured by the Envoy client",
|
|
71
|
+
)
|
|
72
|
+
metadata: dict[str, Any] = Field(default_factory=dict, title="Key:value metadata")
|
|
73
|
+
locality: Locality = Field(Locality(), title="Locality")
|
|
74
|
+
build_version: str | None = Field(
|
|
75
|
+
None, # Optional in the v3 Envoy API
|
|
76
|
+
title="Envoy build/release version string",
|
|
77
|
+
description="Used to identify what version of Envoy the "
|
|
78
|
+
"client is running, and what config to provide in response",
|
|
79
|
+
)
|
|
80
|
+
user_agent_name: str = "envoy"
|
|
81
|
+
user_agent_version: str = ""
|
|
82
|
+
user_agent_build_version: BuildVersion = BuildVersion()
|
|
83
|
+
extensions: list[Extension] = []
|
|
84
|
+
client_features: list[str] = []
|
|
85
|
+
|
|
86
|
+
@property
|
|
87
|
+
def common(self) -> tuple[str, str | None, str, BuildVersion, Locality]:
|
|
88
|
+
"""
|
|
89
|
+
Returns fields that are the same in adjacent proxies
|
|
90
|
+
ie. proxies that are part of the same logical group
|
|
91
|
+
"""
|
|
92
|
+
return (
|
|
93
|
+
self.cluster,
|
|
94
|
+
self.build_version,
|
|
95
|
+
self.user_agent_version,
|
|
96
|
+
self.user_agent_build_version,
|
|
97
|
+
self.locality,
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class Status(BaseModel):
|
|
102
|
+
code: int
|
|
103
|
+
message: str
|
|
104
|
+
details: list[Any]
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
class XdsTemplate(BaseModel):
|
|
108
|
+
path: str | Loadable
|
|
109
|
+
resource_type: str
|
|
110
|
+
depends_on: list[str] = Field(default_factory=list)
|
|
111
|
+
|
|
112
|
+
@property
|
|
113
|
+
def loadable(self):
|
|
114
|
+
if isinstance(self.path, str):
|
|
115
|
+
return Loadable.from_legacy_fmt(self.path)
|
|
116
|
+
elif isinstance(self.path, Loadable):
|
|
117
|
+
return self.path
|
|
118
|
+
raise TypeError(
|
|
119
|
+
"Template path must be a loadable format. "
|
|
120
|
+
"e.g. file+yaml:///etc/templates/clusters.yaml"
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
@property
|
|
124
|
+
def is_python_source(self):
|
|
125
|
+
return self.loadable.protocol == "python"
|
|
126
|
+
|
|
127
|
+
@property
|
|
128
|
+
def code(self) -> ModuleType | Template:
|
|
129
|
+
return self.loadable.load()
|
|
130
|
+
|
|
131
|
+
def generate(self, *args: Any, **kwargs: Any) -> dict[str, Any] | str | None:
|
|
132
|
+
if isinstance(self.code, ModuleType):
|
|
133
|
+
try:
|
|
134
|
+
template_fn = self.code.call # type: ignore
|
|
135
|
+
return {"resources": list(template_fn(*args, **kwargs))}
|
|
136
|
+
except TypeError as e:
|
|
137
|
+
if not set(str(e).split()).issuperset(missing_arguments):
|
|
138
|
+
raise e
|
|
139
|
+
message_start = str(e).find(":")
|
|
140
|
+
missing_args = str(e)[message_start + 2 :]
|
|
141
|
+
supplied_args = list(kwargs.keys())
|
|
142
|
+
raise TypeError(
|
|
143
|
+
f"Tried to render a template using partial arguments. "
|
|
144
|
+
f"Missing args: {missing_args}. Supplied args: {args} "
|
|
145
|
+
f"Supplied keyword args: {supplied_args}"
|
|
146
|
+
)
|
|
147
|
+
else:
|
|
148
|
+
return self.code.render(*args, **kwargs)
|
|
149
|
+
|
|
150
|
+
@property
|
|
151
|
+
def source(self) -> str:
|
|
152
|
+
old_serialization = self.loadable.serialization
|
|
153
|
+
if self.loadable.serialization in ("jinja", "jinja2"):
|
|
154
|
+
# The Jinja2 template serializer does not properly set a name
|
|
155
|
+
# for the loaded template.
|
|
156
|
+
# The repr for the template prints out as the memory address
|
|
157
|
+
# This makes it really hard to generate a consistent version_info string
|
|
158
|
+
# in rendered configuration.
|
|
159
|
+
# For this reason, we re-load the template as a string instead, and create a checksum.
|
|
160
|
+
self.loadable.serialization = "string"
|
|
161
|
+
ret = self.loadable.load()
|
|
162
|
+
self.loadable.serialization = old_serialization
|
|
163
|
+
return str(ret)
|
|
164
|
+
elif self.is_python_source:
|
|
165
|
+
# If the template specified is a python source file,
|
|
166
|
+
# we can simply read and return the source of it.
|
|
167
|
+
old_protocol = self.loadable.protocol
|
|
168
|
+
self.loadable.protocol = "inline"
|
|
169
|
+
self.loadable.serialization = "string"
|
|
170
|
+
ret = self.loadable.load()
|
|
171
|
+
self.loadable.protocol = old_protocol
|
|
172
|
+
self.loadable.serialization = old_serialization
|
|
173
|
+
return str(ret)
|
|
174
|
+
ret = self.loadable.load()
|
|
175
|
+
return str(ret)
|
|
176
|
+
|
|
177
|
+
def __repr__(self) -> str:
|
|
178
|
+
return f"XdsTemplate({self.loadable}, {hash(self)})"
|
|
179
|
+
|
|
180
|
+
@computed_field # type: ignore[prop-decorator]
|
|
181
|
+
@cached_property
|
|
182
|
+
def version(self) -> str:
|
|
183
|
+
return compute_hash(self.source)
|
|
184
|
+
|
|
185
|
+
def __hash__(self) -> int:
|
|
186
|
+
return int(self.version)
|
|
187
|
+
|
|
188
|
+
__str__ = __repr__
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
class DiscoveryRequest(BaseModel):
|
|
192
|
+
# Actual envoy fields
|
|
193
|
+
node: Node = Field(..., title="Node information about the envoy proxy")
|
|
194
|
+
version_info: str = Field(
|
|
195
|
+
"0", title="The version of the envoy clients current configuration"
|
|
196
|
+
)
|
|
197
|
+
resource_names: list[str] = Field(
|
|
198
|
+
default_factory=list, title="list of requested resource names"
|
|
199
|
+
)
|
|
200
|
+
error_detail: Status | None = Field(
|
|
201
|
+
None, title="Error details from the previous xDS request"
|
|
202
|
+
)
|
|
203
|
+
# Internal fields for sovereign
|
|
204
|
+
is_internal_request: bool = False
|
|
205
|
+
type_url: str | None = Field(
|
|
206
|
+
None, title="The corresponding type_url for the requested resource"
|
|
207
|
+
)
|
|
208
|
+
resource_type: str | None = Field(None, title="Resource type requested")
|
|
209
|
+
api_version: str | None = Field(None, title="Envoy API version (v2/v3/etc)")
|
|
210
|
+
desired_controlplane: str | None = Field(
|
|
211
|
+
None, title="The host header provided in the Discovery Request"
|
|
212
|
+
)
|
|
213
|
+
# Pydantic
|
|
214
|
+
model_config = ConfigDict(extra="ignore")
|
|
215
|
+
|
|
216
|
+
@computed_field # type: ignore[prop-decorator]
|
|
217
|
+
@cached_property
|
|
218
|
+
def envoy_version(self) -> str:
|
|
219
|
+
try:
|
|
220
|
+
version = str(self.node.user_agent_build_version.version)
|
|
221
|
+
assert version != "0.0.0"
|
|
222
|
+
except AssertionError:
|
|
223
|
+
try:
|
|
224
|
+
build_version = self.node.build_version
|
|
225
|
+
if build_version is None:
|
|
226
|
+
return "default"
|
|
227
|
+
_, version, *_ = build_version.split("/")
|
|
228
|
+
except (AttributeError, ValueError):
|
|
229
|
+
# TODO: log/metric this?
|
|
230
|
+
return "default"
|
|
231
|
+
return version
|
|
232
|
+
|
|
233
|
+
@property
|
|
234
|
+
def resources(self) -> Resources:
|
|
235
|
+
return Resources(self.resource_names)
|
|
236
|
+
|
|
237
|
+
def cache_key(self, rules: list[str]) -> str:
|
|
238
|
+
map = self.model_dump()
|
|
239
|
+
hash = hashlib.sha256()
|
|
240
|
+
for expr in sorted(rules):
|
|
241
|
+
value = cast(str, jmespath.search(expr, map))
|
|
242
|
+
val_str = f"{expr}={repr(value)}"
|
|
243
|
+
hash.update(val_str.encode())
|
|
244
|
+
return hash.hexdigest()
|
|
245
|
+
|
|
246
|
+
@computed_field # type: ignore[misc]
|
|
247
|
+
@property
|
|
248
|
+
def template(self) -> XdsTemplate:
|
|
249
|
+
# lazy load configured templates
|
|
250
|
+
mod = importlib.import_module("sovereign.configuration")
|
|
251
|
+
templates = mod.XDS_TEMPLATES
|
|
252
|
+
|
|
253
|
+
version = self.envoy_version
|
|
254
|
+
selection = "default"
|
|
255
|
+
for v in templates.keys():
|
|
256
|
+
if version.startswith(v):
|
|
257
|
+
selection = v
|
|
258
|
+
selected_version = templates[selection]
|
|
259
|
+
try:
|
|
260
|
+
assert self.resource_type
|
|
261
|
+
return selected_version[self.resource_type]
|
|
262
|
+
except AssertionError:
|
|
263
|
+
raise RuntimeError(
|
|
264
|
+
"DiscoveryRequest has no resource type set, cannot find template"
|
|
265
|
+
)
|
|
266
|
+
except KeyError:
|
|
267
|
+
raise KeyError(
|
|
268
|
+
(
|
|
269
|
+
f"Unable to get {self.resource_type} for template "
|
|
270
|
+
f'version "{selection}". Envoy client version: {version}'
|
|
271
|
+
)
|
|
272
|
+
)
|
|
273
|
+
|
|
274
|
+
def debug(self):
|
|
275
|
+
return f"version={self.envoy_version}, cluster={self.node.cluster}, resource={self.resource_type}, names={self.resources}"
|
|
276
|
+
|
|
277
|
+
def __str__(self) -> str:
|
|
278
|
+
return f"DiscoveryRequest({self.debug()})"
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
class DiscoveryResponse(BaseModel):
|
|
282
|
+
version_info: str = Field(
|
|
283
|
+
..., title="The version of the configuration in the response"
|
|
284
|
+
)
|
|
285
|
+
resources: list[Any] = Field(..., title="The requested configuration resources")
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
class RegisterClientRequest(BaseModel):
|
|
289
|
+
request: DiscoveryRequest
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
class ProcessedTemplate(BaseModel):
|
|
293
|
+
resources: list[dict[str, Any]]
|
|
294
|
+
metadata: list[str] = Field(default_factory=list, exclude=True)
|
|
295
|
+
|
|
296
|
+
@computed_field # type: ignore[prop-decorator]
|
|
297
|
+
@cached_property
|
|
298
|
+
def version_info(self) -> str:
|
|
299
|
+
return compute_hash(self.resources)
|
sovereign/utils/auth.py
CHANGED
|
@@ -1,18 +1,20 @@
|
|
|
1
|
-
from fastapi.exceptions import HTTPException
|
|
2
1
|
from cryptography.fernet import InvalidToken
|
|
3
|
-
from
|
|
4
|
-
|
|
2
|
+
from fastapi.exceptions import HTTPException
|
|
3
|
+
|
|
4
|
+
from sovereign import server_cipher_container, stats, application_logger as log
|
|
5
|
+
from sovereign.types import DiscoveryRequest
|
|
6
|
+
from sovereign.configuration import config
|
|
5
7
|
|
|
6
8
|
AUTH_ENABLED = config.authentication.enabled
|
|
7
9
|
|
|
8
10
|
|
|
11
|
+
@stats.timed("discovery.auth.ms")
|
|
9
12
|
def validate_authentication_string(s: str) -> bool:
|
|
10
13
|
try:
|
|
11
|
-
password =
|
|
14
|
+
password = server_cipher_container.decrypt(s)
|
|
12
15
|
except Exception:
|
|
13
16
|
stats.increment("discovery.auth.failed")
|
|
14
17
|
raise
|
|
15
|
-
|
|
16
18
|
if password in config.passwords:
|
|
17
19
|
stats.increment("discovery.auth.success")
|
|
18
20
|
return True
|
|
@@ -23,28 +25,39 @@ def validate_authentication_string(s: str) -> bool:
|
|
|
23
25
|
def authenticate(request: DiscoveryRequest) -> None:
|
|
24
26
|
if not AUTH_ENABLED:
|
|
25
27
|
return
|
|
26
|
-
if not
|
|
28
|
+
if not server_cipher_container.key_available:
|
|
27
29
|
raise RuntimeError(
|
|
28
|
-
"No
|
|
29
|
-
"
|
|
30
|
-
"See https://vsyrakis.bitbucket.io/sovereign/docs/html/guides/encryption.html "
|
|
31
|
-
"for more details"
|
|
30
|
+
"No encryption key loaded, and auth is enabled. "
|
|
31
|
+
"An encryption key must be provided via SOVEREIGN_ENCRYPTION_KEY. "
|
|
32
32
|
)
|
|
33
33
|
try:
|
|
34
34
|
encrypted_auth = request.node.metadata["auth"]
|
|
35
|
-
with stats.timed("discovery.auth.ms"):
|
|
36
|
-
assert validate_authentication_string(encrypted_auth)
|
|
37
35
|
except KeyError:
|
|
38
36
|
raise HTTPException(
|
|
39
37
|
status_code=401,
|
|
40
38
|
detail=f"Discovery request from {request.node.id} is missing auth field",
|
|
41
39
|
)
|
|
40
|
+
except Exception as e:
|
|
41
|
+
description = getattr(e, "detail", "unknown")
|
|
42
|
+
raise HTTPException(
|
|
43
|
+
status_code=400,
|
|
44
|
+
detail=f"The authentication provided was malformed [Reason: {description}]",
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
try:
|
|
48
|
+
assert isinstance(encrypted_auth, str)
|
|
49
|
+
assert validate_authentication_string(encrypted_auth)
|
|
42
50
|
except (InvalidToken, AssertionError):
|
|
43
51
|
raise HTTPException(
|
|
44
52
|
status_code=401, detail="The authentication provided was invalid"
|
|
45
53
|
)
|
|
46
54
|
except Exception as e:
|
|
47
|
-
|
|
55
|
+
alt_desc = repr(e)
|
|
56
|
+
alt_desc = alt_desc.replace(encrypted_auth, "********")
|
|
57
|
+
for password in config.passwords:
|
|
58
|
+
alt_desc = alt_desc.replace(password, "********")
|
|
59
|
+
description = getattr(e, "detail", alt_desc)
|
|
60
|
+
log.exception(f"Failed to auth client: {description}")
|
|
48
61
|
raise HTTPException(
|
|
49
62
|
status_code=400,
|
|
50
63
|
detail=f"The authentication provided was malformed [Reason: {description}]",
|
|
File without changes
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
from typing import Literal, Self, Sequence
|
|
2
|
+
|
|
3
|
+
from cachetools import TTLCache, cached
|
|
4
|
+
from fastapi.exceptions import HTTPException
|
|
5
|
+
from structlog.stdlib import BoundLogger
|
|
6
|
+
from typing_extensions import TypedDict
|
|
7
|
+
|
|
8
|
+
from sovereign.configuration import EncryptionConfig
|
|
9
|
+
from sovereign.utils.crypto.suites import (
|
|
10
|
+
AESGCMCipher,
|
|
11
|
+
CipherSuite,
|
|
12
|
+
DisabledCipher,
|
|
13
|
+
EncryptionType,
|
|
14
|
+
FernetCipher,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class EncryptOutput(TypedDict):
|
|
19
|
+
encrypted_data: str
|
|
20
|
+
encryption_type: str
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class DecryptOutput(TypedDict):
|
|
24
|
+
decrypted_data: str
|
|
25
|
+
encryption_type: str
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class CipherContainer:
|
|
29
|
+
"""
|
|
30
|
+
Object which intercepts encrypt/decryptions
|
|
31
|
+
Tries to decrypt data using the ciphers provided in order
|
|
32
|
+
Encrypts with the first suite available.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
def __init__(self, suites: Sequence[CipherSuite], logger: BoundLogger) -> None:
|
|
36
|
+
self.suites: Sequence[CipherSuite] = suites
|
|
37
|
+
self.logger = logger
|
|
38
|
+
|
|
39
|
+
def encrypt(self, data: str) -> EncryptOutput:
|
|
40
|
+
try:
|
|
41
|
+
# Use the first cipher suite to encrypt the data
|
|
42
|
+
encrypted_data = self.suites[0].encrypt(data)
|
|
43
|
+
return {
|
|
44
|
+
"encrypted_data": encrypted_data,
|
|
45
|
+
"encryption_type": str(self.suites[0]),
|
|
46
|
+
}
|
|
47
|
+
# pylint: disable=broad-except
|
|
48
|
+
except Exception as e:
|
|
49
|
+
self.logger.exception(str(e))
|
|
50
|
+
# TODO: defer this http error to later, return a normal error here
|
|
51
|
+
raise HTTPException(status_code=400, detail="Encryption failed")
|
|
52
|
+
|
|
53
|
+
def decrypt_with_type(self, data: str) -> DecryptOutput:
|
|
54
|
+
return self._decrypt(data)
|
|
55
|
+
|
|
56
|
+
def decrypt(self, data: str) -> str:
|
|
57
|
+
return self._decrypt(data)["decrypted_data"]
|
|
58
|
+
|
|
59
|
+
@cached(cache=TTLCache(maxsize=128, ttl=600))
|
|
60
|
+
def _decrypt(self, data: str) -> DecryptOutput:
|
|
61
|
+
try:
|
|
62
|
+
for suite in self.suites:
|
|
63
|
+
try:
|
|
64
|
+
decrypted_data = suite.decrypt(data)
|
|
65
|
+
return {
|
|
66
|
+
"decrypted_data": decrypted_data,
|
|
67
|
+
"encryption_type": str(suite),
|
|
68
|
+
}
|
|
69
|
+
# pylint: disable=broad-except
|
|
70
|
+
except Exception as e:
|
|
71
|
+
self.logger.exception(str(e))
|
|
72
|
+
self.logger.debug(f"Failed to decrypt with suite {suite}")
|
|
73
|
+
else:
|
|
74
|
+
raise ValueError("Could not decrypt with any suite")
|
|
75
|
+
except Exception as e:
|
|
76
|
+
self.logger.exception(str(e))
|
|
77
|
+
# TODO: defer this http error to later, return a normal error here
|
|
78
|
+
raise HTTPException(status_code=400, detail="Decryption failed")
|
|
79
|
+
|
|
80
|
+
@property
|
|
81
|
+
def key_available(self) -> bool:
|
|
82
|
+
if not self.suites:
|
|
83
|
+
return False
|
|
84
|
+
return self.suites[0].key_available
|
|
85
|
+
|
|
86
|
+
AVAILABLE_CIPHERS: dict[EncryptionType | Literal["default"], type[CipherSuite]] = {
|
|
87
|
+
EncryptionType.DISABLED: DisabledCipher,
|
|
88
|
+
EncryptionType.AESGCM: AESGCMCipher,
|
|
89
|
+
EncryptionType.FERNET: FernetCipher,
|
|
90
|
+
"default": FernetCipher,
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
@classmethod
|
|
94
|
+
def get_cipher_suite(cls, encryption_type: EncryptionType) -> type[CipherSuite]:
|
|
95
|
+
SelectedCipher = cls.AVAILABLE_CIPHERS.get(
|
|
96
|
+
encryption_type, cls.AVAILABLE_CIPHERS["default"]
|
|
97
|
+
)
|
|
98
|
+
return SelectedCipher
|
|
99
|
+
|
|
100
|
+
@classmethod
|
|
101
|
+
def create_cipher_suite(
|
|
102
|
+
cls,
|
|
103
|
+
encryption_type: EncryptionType,
|
|
104
|
+
key: str,
|
|
105
|
+
logger: BoundLogger,
|
|
106
|
+
) -> CipherSuite:
|
|
107
|
+
kwargs = {
|
|
108
|
+
"secret_key": key,
|
|
109
|
+
}
|
|
110
|
+
try:
|
|
111
|
+
SelectedCipher = cls.get_cipher_suite(encryption_type)
|
|
112
|
+
return SelectedCipher(**kwargs)
|
|
113
|
+
except TypeError:
|
|
114
|
+
pass
|
|
115
|
+
except ValueError as e:
|
|
116
|
+
if key not in (b"", ""):
|
|
117
|
+
logger.error(
|
|
118
|
+
f"Encryption key was provided, but appears to be invalid: {repr(e)}"
|
|
119
|
+
)
|
|
120
|
+
return DisabledCipher(**kwargs)
|
|
121
|
+
|
|
122
|
+
@classmethod
|
|
123
|
+
def from_encryption_configs(
|
|
124
|
+
cls, encryption_configs: Sequence[EncryptionConfig], logger: BoundLogger
|
|
125
|
+
) -> Self:
|
|
126
|
+
cipher_suites: list[CipherSuite] = []
|
|
127
|
+
for encryption_config in encryption_configs:
|
|
128
|
+
cipher_suites.append(
|
|
129
|
+
cls.create_cipher_suite(
|
|
130
|
+
key=encryption_config.encryption_key,
|
|
131
|
+
encryption_type=encryption_config.encryption_type,
|
|
132
|
+
logger=logger,
|
|
133
|
+
)
|
|
134
|
+
)
|
|
135
|
+
return cls(suites=cipher_suites, logger=logger)
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
from enum import StrEnum
|
|
2
|
+
|
|
3
|
+
from sovereign.utils.crypto.suites.aes_gcm_cipher import AESGCMCipher
|
|
4
|
+
from sovereign.utils.crypto.suites.base_cipher import CipherSuite
|
|
5
|
+
from sovereign.utils.crypto.suites.disabled_cipher import DisabledCipher
|
|
6
|
+
from sovereign.utils.crypto.suites.fernet_cipher import FernetCipher
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class EncryptionType(StrEnum):
|
|
10
|
+
FERNET = "fernet"
|
|
11
|
+
AESGCM = "aesgcm"
|
|
12
|
+
DISABLED = "disabled"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
__all__ = [
|
|
16
|
+
"AESGCMCipher",
|
|
17
|
+
"CipherSuite",
|
|
18
|
+
"DisabledCipher",
|
|
19
|
+
"FernetCipher",
|
|
20
|
+
"EncryptionType",
|
|
21
|
+
]
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import base64
|
|
2
|
+
import os
|
|
3
|
+
|
|
4
|
+
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
|
5
|
+
|
|
6
|
+
from .base_cipher import CipherSuite
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class AESGCMCipher(CipherSuite):
|
|
10
|
+
AUTHENTICATE_DATA = "sovereign".encode()
|
|
11
|
+
NONCE_LEN = 12
|
|
12
|
+
|
|
13
|
+
def __str__(self) -> str:
|
|
14
|
+
return "aesgcm"
|
|
15
|
+
|
|
16
|
+
def __init__(self, secret_key: str):
|
|
17
|
+
self.secret_key = base64.urlsafe_b64decode(secret_key)
|
|
18
|
+
|
|
19
|
+
def encrypt(self, data: str) -> str:
|
|
20
|
+
aesgcm = AESGCM(self.secret_key)
|
|
21
|
+
nonce = os.urandom(self.NONCE_LEN)
|
|
22
|
+
ct = aesgcm.encrypt(nonce, data.encode(), self.AUTHENTICATE_DATA)
|
|
23
|
+
return base64.b64encode(nonce + ct).decode("utf-8")
|
|
24
|
+
|
|
25
|
+
def decrypt(self, data: str) -> str:
|
|
26
|
+
decoded_data = base64.b64decode(data)
|
|
27
|
+
nonce, ct = (
|
|
28
|
+
decoded_data[: self.NONCE_LEN],
|
|
29
|
+
decoded_data[self.NONCE_LEN :],
|
|
30
|
+
)
|
|
31
|
+
aesgcm = AESGCM(self.secret_key)
|
|
32
|
+
decrypted_output = aesgcm.decrypt(nonce, ct, self.AUTHENTICATE_DATA)
|
|
33
|
+
return decrypted_output.decode("utf-8")
|
|
34
|
+
|
|
35
|
+
@property
|
|
36
|
+
def key_available(self) -> bool:
|
|
37
|
+
return True
|
|
38
|
+
|
|
39
|
+
@classmethod
|
|
40
|
+
def generate_key(cls) -> bytes:
|
|
41
|
+
# Generate 256 bit length key
|
|
42
|
+
return base64.urlsafe_b64encode(os.urandom(32))
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
from abc import ABC, abstractmethod
|
|
2
|
+
from typing import Any
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class CipherSuite(ABC):
|
|
6
|
+
@abstractmethod
|
|
7
|
+
def __init__(self, *args: Any, **kwargs: Any) -> None: ...
|
|
8
|
+
|
|
9
|
+
@abstractmethod
|
|
10
|
+
def encrypt(self, data: str) -> str: ...
|
|
11
|
+
|
|
12
|
+
@abstractmethod
|
|
13
|
+
def decrypt(self, data: str) -> str: ...
|
|
14
|
+
|
|
15
|
+
@property
|
|
16
|
+
@abstractmethod
|
|
17
|
+
def key_available(self) -> bool: ...
|
|
18
|
+
|
|
19
|
+
@classmethod
|
|
20
|
+
@abstractmethod
|
|
21
|
+
def generate_key(cls) -> bytes: ...
|