dbl-gateway 0.3.2__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.
- dbl_gateway-0.3.2/LICENSE +21 -0
- dbl_gateway-0.3.2/PKG-INFO +78 -0
- dbl_gateway-0.3.2/README.md +53 -0
- dbl_gateway-0.3.2/pyproject.toml +53 -0
- dbl_gateway-0.3.2/setup.cfg +4 -0
- dbl_gateway-0.3.2/src/dbl_gateway/__init__.py +1 -0
- dbl_gateway-0.3.2/src/dbl_gateway/adapters/__init__.py +9 -0
- dbl_gateway-0.3.2/src/dbl_gateway/adapters/execution_adapter_kl.py +133 -0
- dbl_gateway-0.3.2/src/dbl_gateway/adapters/policy_adapter_dbl_policy.py +96 -0
- dbl_gateway-0.3.2/src/dbl_gateway/adapters/store_adapter_sqlite.py +55 -0
- dbl_gateway-0.3.2/src/dbl_gateway/admission.py +67 -0
- dbl_gateway-0.3.2/src/dbl_gateway/app.py +501 -0
- dbl_gateway-0.3.2/src/dbl_gateway/auth.py +295 -0
- dbl_gateway-0.3.2/src/dbl_gateway/capabilities.py +79 -0
- dbl_gateway-0.3.2/src/dbl_gateway/digest.py +31 -0
- dbl_gateway-0.3.2/src/dbl_gateway/execution.py +15 -0
- dbl_gateway-0.3.2/src/dbl_gateway/governance.py +20 -0
- dbl_gateway-0.3.2/src/dbl_gateway/models.py +24 -0
- dbl_gateway-0.3.2/src/dbl_gateway/ports/__init__.py +11 -0
- dbl_gateway-0.3.2/src/dbl_gateway/ports/execution_port.py +19 -0
- dbl_gateway-0.3.2/src/dbl_gateway/ports/policy_port.py +18 -0
- dbl_gateway-0.3.2/src/dbl_gateway/ports/store_port.py +33 -0
- dbl_gateway-0.3.2/src/dbl_gateway/projection.py +34 -0
- dbl_gateway-0.3.2/src/dbl_gateway/providers/__init__.py +1 -0
- dbl_gateway-0.3.2/src/dbl_gateway/providers/anthropic.py +63 -0
- dbl_gateway-0.3.2/src/dbl_gateway/providers/errors.py +5 -0
- dbl_gateway-0.3.2/src/dbl_gateway/providers/openai.py +105 -0
- dbl_gateway-0.3.2/src/dbl_gateway/store/__init__.py +1 -0
- dbl_gateway-0.3.2/src/dbl_gateway/store/base.py +35 -0
- dbl_gateway-0.3.2/src/dbl_gateway/store/factory.py +12 -0
- dbl_gateway-0.3.2/src/dbl_gateway/store/sqlite.py +200 -0
- dbl_gateway-0.3.2/src/dbl_gateway/wire_contract.py +65 -0
- dbl_gateway-0.3.2/src/dbl_gateway.egg-info/PKG-INFO +78 -0
- dbl_gateway-0.3.2/src/dbl_gateway.egg-info/SOURCES.txt +40 -0
- dbl_gateway-0.3.2/src/dbl_gateway.egg-info/dependency_links.txt +1 -0
- dbl_gateway-0.3.2/src/dbl_gateway.egg-info/entry_points.txt +2 -0
- dbl_gateway-0.3.2/src/dbl_gateway.egg-info/requires.txt +17 -0
- dbl_gateway-0.3.2/src/dbl_gateway.egg-info/top_level.txt +1 -0
- dbl_gateway-0.3.2/tests/test_dependency_imports.py +20 -0
- dbl_gateway-0.3.2/tests/test_gateway.py +405 -0
- dbl_gateway-0.3.2/tests/test_reference_oracle.py +123 -0
- dbl_gateway-0.3.2/tests/test_schema_snapshot.py +15 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Lukas Pfister
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: dbl-gateway
|
|
3
|
+
Version: 0.3.2
|
|
4
|
+
Summary: DBL Gateway
|
|
5
|
+
License: MIT
|
|
6
|
+
Requires-Python: >=3.11
|
|
7
|
+
Description-Content-Type: text/markdown
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Requires-Dist: dbl-core<0.4.0,>=0.3.2
|
|
10
|
+
Requires-Dist: dbl-policy<0.2.0,>=0.1.0
|
|
11
|
+
Requires-Dist: dbl-main<0.4.0,>=0.3.0
|
|
12
|
+
Requires-Dist: dbl-ingress<0.2.0,>=0.1.1
|
|
13
|
+
Requires-Dist: kl-kernel-logic<0.6.0,>=0.5.0
|
|
14
|
+
Requires-Dist: fastapi>=0.110.0
|
|
15
|
+
Requires-Dist: uvicorn>=0.27.0
|
|
16
|
+
Requires-Dist: httpx>=0.27.0
|
|
17
|
+
Provides-Extra: dev
|
|
18
|
+
Requires-Dist: pytest<9,>=8; extra == "dev"
|
|
19
|
+
Requires-Dist: ruff<1,>=0.5; extra == "dev"
|
|
20
|
+
Requires-Dist: mypy<2,>=1.10; extra == "dev"
|
|
21
|
+
Requires-Dist: dbl-reference>=0.2.0; extra == "dev"
|
|
22
|
+
Provides-Extra: oidc
|
|
23
|
+
Requires-Dist: python-jose[cryptography]<4,>=3.3; extra == "oidc"
|
|
24
|
+
Dynamic: license-file
|
|
25
|
+
|
|
26
|
+
# DBL Gateway 0.3.2
|
|
27
|
+
|
|
28
|
+
Authoritative DBL and KL gateway. This service is the single writer for append-only trails,
|
|
29
|
+
applies policy via dbl-policy, and executes via kl-kernel-logic. UI and boundary services
|
|
30
|
+
consume its snapshots and emit INTENT only.
|
|
31
|
+
|
|
32
|
+
This release stabilizes the 0.3.x stackline and does not introduce new wire contracts.
|
|
33
|
+
|
|
34
|
+
Compatible stack versions:
|
|
35
|
+
- dbl-core==0.3.2
|
|
36
|
+
- dbl-policy==0.1.0
|
|
37
|
+
- dbl-main==0.3.0
|
|
38
|
+
- kl-kernel-logic==0.5.0
|
|
39
|
+
|
|
40
|
+
## Quickstart (PowerShell)
|
|
41
|
+
|
|
42
|
+
```powershell
|
|
43
|
+
py -3.11 -m venv .venv
|
|
44
|
+
.venv\Scripts\Activate.ps1
|
|
45
|
+
python -m pip install -e ".[dev]"
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Run the gateway:
|
|
49
|
+
```powershell
|
|
50
|
+
dbl-gateway serve --db .\data\trail.sqlite --host 127.0.0.1 --port 8010
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
Run with uvicorn:
|
|
54
|
+
```powershell
|
|
55
|
+
$env:DBL_GATEWAY_DB=".\data\trail.sqlite"
|
|
56
|
+
py -3.11 -m uvicorn dbl_gateway.app:app --host 127.0.0.1 --port 8010
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
## Endpoints
|
|
60
|
+
|
|
61
|
+
Write:
|
|
62
|
+
- POST `/ingress/intent`
|
|
63
|
+
|
|
64
|
+
Read:
|
|
65
|
+
- GET `/snapshot`
|
|
66
|
+
- GET `/capabilities`
|
|
67
|
+
- GET `/healthz`
|
|
68
|
+
|
|
69
|
+
## Environment contract
|
|
70
|
+
|
|
71
|
+
See `docs/env_contract.md`.
|
|
72
|
+
|
|
73
|
+
## Notes
|
|
74
|
+
|
|
75
|
+
- The gateway is the only component that performs governance and execution.
|
|
76
|
+
- All stabilization is expressed explicitly via DECISION events.
|
|
77
|
+
- Boundary and UI clients do not import dbl-core or dbl-policy.
|
|
78
|
+
- The gateway uses dbl-core for canonicalization and digest computation.
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# DBL Gateway 0.3.2
|
|
2
|
+
|
|
3
|
+
Authoritative DBL and KL gateway. This service is the single writer for append-only trails,
|
|
4
|
+
applies policy via dbl-policy, and executes via kl-kernel-logic. UI and boundary services
|
|
5
|
+
consume its snapshots and emit INTENT only.
|
|
6
|
+
|
|
7
|
+
This release stabilizes the 0.3.x stackline and does not introduce new wire contracts.
|
|
8
|
+
|
|
9
|
+
Compatible stack versions:
|
|
10
|
+
- dbl-core==0.3.2
|
|
11
|
+
- dbl-policy==0.1.0
|
|
12
|
+
- dbl-main==0.3.0
|
|
13
|
+
- kl-kernel-logic==0.5.0
|
|
14
|
+
|
|
15
|
+
## Quickstart (PowerShell)
|
|
16
|
+
|
|
17
|
+
```powershell
|
|
18
|
+
py -3.11 -m venv .venv
|
|
19
|
+
.venv\Scripts\Activate.ps1
|
|
20
|
+
python -m pip install -e ".[dev]"
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
Run the gateway:
|
|
24
|
+
```powershell
|
|
25
|
+
dbl-gateway serve --db .\data\trail.sqlite --host 127.0.0.1 --port 8010
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Run with uvicorn:
|
|
29
|
+
```powershell
|
|
30
|
+
$env:DBL_GATEWAY_DB=".\data\trail.sqlite"
|
|
31
|
+
py -3.11 -m uvicorn dbl_gateway.app:app --host 127.0.0.1 --port 8010
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## Endpoints
|
|
35
|
+
|
|
36
|
+
Write:
|
|
37
|
+
- POST `/ingress/intent`
|
|
38
|
+
|
|
39
|
+
Read:
|
|
40
|
+
- GET `/snapshot`
|
|
41
|
+
- GET `/capabilities`
|
|
42
|
+
- GET `/healthz`
|
|
43
|
+
|
|
44
|
+
## Environment contract
|
|
45
|
+
|
|
46
|
+
See `docs/env_contract.md`.
|
|
47
|
+
|
|
48
|
+
## Notes
|
|
49
|
+
|
|
50
|
+
- The gateway is the only component that performs governance and execution.
|
|
51
|
+
- All stabilization is expressed explicitly via DECISION events.
|
|
52
|
+
- Boundary and UI clients do not import dbl-core or dbl-policy.
|
|
53
|
+
- The gateway uses dbl-core for canonicalization and digest computation.
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=61.0"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "dbl-gateway"
|
|
7
|
+
version = "0.3.2"
|
|
8
|
+
description = "DBL Gateway"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.11"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
|
|
13
|
+
dependencies = [
|
|
14
|
+
"dbl-core>=0.3.2,<0.4.0",
|
|
15
|
+
"dbl-policy>=0.1.0,<0.2.0",
|
|
16
|
+
"dbl-main>=0.3.0,<0.4.0",
|
|
17
|
+
"dbl-ingress>=0.1.1,<0.2.0",
|
|
18
|
+
"kl-kernel-logic>=0.5.0,<0.6.0",
|
|
19
|
+
"fastapi>=0.110.0",
|
|
20
|
+
"uvicorn>=0.27.0",
|
|
21
|
+
"httpx>=0.27.0",
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
[project.scripts]
|
|
25
|
+
dbl-gateway = "dbl_gateway.app:main"
|
|
26
|
+
|
|
27
|
+
[project.optional-dependencies]
|
|
28
|
+
dev = [
|
|
29
|
+
"pytest>=8,<9",
|
|
30
|
+
"ruff>=0.5,<1",
|
|
31
|
+
"mypy>=1.10,<2",
|
|
32
|
+
"dbl-reference>=0.2.0",
|
|
33
|
+
]
|
|
34
|
+
oidc = [
|
|
35
|
+
"python-jose[cryptography]>=3.3,<4",
|
|
36
|
+
]
|
|
37
|
+
|
|
38
|
+
[tool.setuptools]
|
|
39
|
+
package-dir = { "" = "src" }
|
|
40
|
+
license-files = ["LICENSE"]
|
|
41
|
+
|
|
42
|
+
[tool.setuptools.packages.find]
|
|
43
|
+
where = ["src"]
|
|
44
|
+
include = ["dbl_gateway*"]
|
|
45
|
+
exclude = ["tests*", "docs*", "scripts*"]
|
|
46
|
+
|
|
47
|
+
[tool.pytest.ini_options]
|
|
48
|
+
testpaths = ["tests"]
|
|
49
|
+
pythonpath = ["src"]
|
|
50
|
+
filterwarnings = [
|
|
51
|
+
"ignore:.*on_event is deprecated.*:DeprecationWarning",
|
|
52
|
+
]
|
|
53
|
+
addopts = ["-q", "--strict-markers", "--strict-config"]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__all__ = ["app"]
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from typing import Any, Mapping
|
|
6
|
+
|
|
7
|
+
from dbl_core import normalize_trace
|
|
8
|
+
|
|
9
|
+
from ..ports.execution_port import ExecutionPort, ExecutionResult
|
|
10
|
+
from ..providers import anthropic, openai
|
|
11
|
+
from ..providers.errors import ProviderError
|
|
12
|
+
from ..capabilities import resolve_provider
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(frozen=True)
|
|
16
|
+
class KlExecutionAdapter(ExecutionPort):
|
|
17
|
+
async def run(self, intent_event: Mapping[str, Any]) -> ExecutionResult:
|
|
18
|
+
payload = intent_event.get("payload")
|
|
19
|
+
if not isinstance(payload, Mapping):
|
|
20
|
+
return ExecutionResult(error={"message": "invalid payload"})
|
|
21
|
+
requested_model_id = payload.get("requested_model_id")
|
|
22
|
+
model_id = str(requested_model_id) if requested_model_id else ""
|
|
23
|
+
provider, reason = resolve_provider(model_id)
|
|
24
|
+
if provider is None or reason is not None:
|
|
25
|
+
return ExecutionResult(
|
|
26
|
+
provider=provider,
|
|
27
|
+
model_id=model_id,
|
|
28
|
+
error={"provider": provider, "message": reason or "model.unavailable"},
|
|
29
|
+
)
|
|
30
|
+
message = _extract_message(payload)
|
|
31
|
+
if message is None:
|
|
32
|
+
return ExecutionResult(provider=provider, model_id=model_id, error={"message": "input.invalid"})
|
|
33
|
+
call = _select_provider(provider)
|
|
34
|
+
try:
|
|
35
|
+
output_text, trace, trace_digest, error = await _call_kernel(message, model_id, provider, call)
|
|
36
|
+
return ExecutionResult(
|
|
37
|
+
output_text=output_text,
|
|
38
|
+
provider=provider,
|
|
39
|
+
model_id=model_id,
|
|
40
|
+
trace=trace,
|
|
41
|
+
trace_digest=trace_digest,
|
|
42
|
+
error=error,
|
|
43
|
+
)
|
|
44
|
+
except Exception:
|
|
45
|
+
return ExecutionResult(
|
|
46
|
+
provider=provider,
|
|
47
|
+
model_id=model_id,
|
|
48
|
+
error={
|
|
49
|
+
"provider": provider,
|
|
50
|
+
"message": "execution failed",
|
|
51
|
+
},
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def schedule_execution(coro: asyncio.Task | asyncio.Future | Any) -> None:
|
|
56
|
+
asyncio.create_task(coro)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _select_provider(name: str):
|
|
60
|
+
if name == "openai":
|
|
61
|
+
return openai.execute
|
|
62
|
+
if name == "anthropic":
|
|
63
|
+
return anthropic.execute
|
|
64
|
+
raise RuntimeError("unsupported provider")
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
async def _call_kernel(message: str, model_id: str, provider: str, provider_call):
|
|
68
|
+
import kl_kernel_logic
|
|
69
|
+
|
|
70
|
+
psi = kl_kernel_logic.PsiDefinition(
|
|
71
|
+
psi_type="llm",
|
|
72
|
+
name=provider,
|
|
73
|
+
metadata={"model_id": model_id},
|
|
74
|
+
)
|
|
75
|
+
kernel = kl_kernel_logic.Kernel()
|
|
76
|
+
|
|
77
|
+
def _task(message: str, model_id: str) -> dict[str, object]:
|
|
78
|
+
try:
|
|
79
|
+
return {"ok": True, "output": provider_call(message, model_id)}
|
|
80
|
+
except ProviderError as exc:
|
|
81
|
+
return {
|
|
82
|
+
"ok": False,
|
|
83
|
+
"error": {
|
|
84
|
+
"status_code": exc.status_code,
|
|
85
|
+
"code": exc.code,
|
|
86
|
+
"message": str(exc),
|
|
87
|
+
},
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
trace = await asyncio.to_thread(
|
|
91
|
+
kernel.execute,
|
|
92
|
+
psi=psi,
|
|
93
|
+
task=_task,
|
|
94
|
+
metadata={"provider": provider, "model_id": model_id},
|
|
95
|
+
message=message,
|
|
96
|
+
model_id=model_id,
|
|
97
|
+
)
|
|
98
|
+
trace_dict, trace_digest_value = normalize_trace(trace)
|
|
99
|
+
if not trace.success:
|
|
100
|
+
return (
|
|
101
|
+
"",
|
|
102
|
+
trace_dict,
|
|
103
|
+
trace_digest_value,
|
|
104
|
+
{
|
|
105
|
+
"provider": provider,
|
|
106
|
+
"message": trace.error or "execution failed",
|
|
107
|
+
"failure_code": getattr(trace.failure_code, "value", None),
|
|
108
|
+
},
|
|
109
|
+
)
|
|
110
|
+
output = trace.output
|
|
111
|
+
if isinstance(output, dict) and output.get("ok") is False:
|
|
112
|
+
err = output.get("error") if isinstance(output.get("error"), dict) else {}
|
|
113
|
+
return (
|
|
114
|
+
"",
|
|
115
|
+
trace_dict,
|
|
116
|
+
trace_digest_value,
|
|
117
|
+
{
|
|
118
|
+
"provider": provider,
|
|
119
|
+
"status_code": err.get("status_code"),
|
|
120
|
+
"code": err.get("code"),
|
|
121
|
+
"message": str(err.get("message") or "execution failed"),
|
|
122
|
+
},
|
|
123
|
+
)
|
|
124
|
+
if isinstance(output, dict) and "output" in output:
|
|
125
|
+
return str(output.get("output") or ""), trace_dict, trace_digest_value, None
|
|
126
|
+
return str(output or ""), trace_dict, trace_digest_value, None
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _extract_message(payload: Mapping[str, Any]) -> str | None:
|
|
130
|
+
message = payload.get("message")
|
|
131
|
+
if isinstance(message, str) and message.strip():
|
|
132
|
+
return message.strip()
|
|
133
|
+
return None
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from importlib import import_module
|
|
5
|
+
from typing import Any, Mapping, get_type_hints
|
|
6
|
+
|
|
7
|
+
from dbl_policy import Policy, PolicyContext, PolicyDecision, decision_to_dbl_event
|
|
8
|
+
|
|
9
|
+
from ..ports.policy_port import DecisionResult, PolicyPort
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
ALLOWED_CONTEXT_KEYS = {
|
|
13
|
+
"stream_id",
|
|
14
|
+
"lane",
|
|
15
|
+
"actor",
|
|
16
|
+
"intent_type",
|
|
17
|
+
"correlation_id",
|
|
18
|
+
"payload",
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass(frozen=True)
|
|
23
|
+
class DblPolicyAdapter(PolicyPort):
|
|
24
|
+
def decide(self, authoritative_input: Mapping[str, Any]) -> DecisionResult:
|
|
25
|
+
context = _build_policy_context(authoritative_input)
|
|
26
|
+
decision = _evaluate_policy(context)
|
|
27
|
+
gate_event = decision_to_dbl_event(decision, authoritative_input["correlation_id"])
|
|
28
|
+
policy_version = _policy_version_as_int(decision.policy_version.value)
|
|
29
|
+
return DecisionResult(
|
|
30
|
+
decision=decision.outcome.value,
|
|
31
|
+
reason_codes=[decision.reason_code],
|
|
32
|
+
policy_id=decision.policy_id.value,
|
|
33
|
+
policy_version=policy_version,
|
|
34
|
+
gate_event=gate_event,
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _build_policy_context(authoritative_input: Mapping[str, Any]) -> PolicyContext:
|
|
39
|
+
filtered = {key: authoritative_input.get(key) for key in ALLOWED_CONTEXT_KEYS}
|
|
40
|
+
tenant = authoritative_input.get("tenant_id", "unknown")
|
|
41
|
+
tenant_type = _tenant_id_type()
|
|
42
|
+
try:
|
|
43
|
+
tenant_value = tenant_type(str(tenant))
|
|
44
|
+
except Exception as exc:
|
|
45
|
+
raise RuntimeError("invalid tenant_id") from exc
|
|
46
|
+
return PolicyContext(tenant_id=tenant_value, inputs=filtered)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _evaluate_policy(context: PolicyContext) -> PolicyDecision:
|
|
50
|
+
policy = _load_policy()
|
|
51
|
+
return policy.evaluate(context)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _load_policy() -> Policy:
|
|
55
|
+
module_path = _get_env("DBL_GATEWAY_POLICY_MODULE")
|
|
56
|
+
obj_name = _get_env("DBL_GATEWAY_POLICY_OBJECT", "policy")
|
|
57
|
+
module = import_module(module_path)
|
|
58
|
+
obj = getattr(module, obj_name, None)
|
|
59
|
+
if obj is None:
|
|
60
|
+
raise RuntimeError("policy object not found")
|
|
61
|
+
if callable(obj) and not hasattr(obj, "evaluate"):
|
|
62
|
+
return obj() # type: ignore[return-value]
|
|
63
|
+
return obj # type: ignore[return-value]
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _get_env(name: str, default: str | None = None) -> str:
|
|
67
|
+
import os
|
|
68
|
+
|
|
69
|
+
value = os.getenv(name, "")
|
|
70
|
+
if value:
|
|
71
|
+
return value
|
|
72
|
+
if default is None:
|
|
73
|
+
raise RuntimeError(f"{name} is required")
|
|
74
|
+
return default
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _tenant_id_type() -> type:
|
|
78
|
+
hints = get_type_hints(PolicyContext)
|
|
79
|
+
tenant_type = hints.get("tenant_id")
|
|
80
|
+
if not isinstance(tenant_type, type):
|
|
81
|
+
raise RuntimeError("PolicyContext.tenant_id type missing")
|
|
82
|
+
return tenant_type
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _policy_version_as_int(value: object) -> int:
|
|
86
|
+
try:
|
|
87
|
+
if isinstance(value, str):
|
|
88
|
+
text = value.strip()
|
|
89
|
+
if text == "":
|
|
90
|
+
raise ValueError("empty")
|
|
91
|
+
if "." in text:
|
|
92
|
+
text = text.split(".", 1)[0]
|
|
93
|
+
return int(text)
|
|
94
|
+
return int(value)
|
|
95
|
+
except (TypeError, ValueError) as exc:
|
|
96
|
+
raise RuntimeError("policy_version must be int") from exc
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from ..ports.store_port import StorePort
|
|
7
|
+
from ..store.sqlite import SQLiteStore
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass
|
|
11
|
+
class SQLiteStoreAdapter(StorePort):
|
|
12
|
+
_store: SQLiteStore
|
|
13
|
+
|
|
14
|
+
@classmethod
|
|
15
|
+
def from_path(cls, db_path: Path) -> "SQLiteStoreAdapter":
|
|
16
|
+
return cls(SQLiteStore(db_path))
|
|
17
|
+
|
|
18
|
+
def append(
|
|
19
|
+
self,
|
|
20
|
+
*,
|
|
21
|
+
kind: str,
|
|
22
|
+
lane: str,
|
|
23
|
+
actor: str,
|
|
24
|
+
intent_type: str,
|
|
25
|
+
stream_id: str,
|
|
26
|
+
correlation_id: str,
|
|
27
|
+
payload: dict[str, object],
|
|
28
|
+
):
|
|
29
|
+
return self._store.append(
|
|
30
|
+
kind=kind,
|
|
31
|
+
lane=lane,
|
|
32
|
+
actor=actor,
|
|
33
|
+
intent_type=intent_type,
|
|
34
|
+
stream_id=stream_id,
|
|
35
|
+
correlation_id=correlation_id,
|
|
36
|
+
payload=payload,
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
def snapshot(
|
|
40
|
+
self,
|
|
41
|
+
*,
|
|
42
|
+
limit: int,
|
|
43
|
+
offset: int,
|
|
44
|
+
stream_id: str | None = None,
|
|
45
|
+
lane: str | None = None,
|
|
46
|
+
):
|
|
47
|
+
return self._store.snapshot(
|
|
48
|
+
limit=limit,
|
|
49
|
+
offset=offset,
|
|
50
|
+
stream_id=stream_id,
|
|
51
|
+
lane=lane,
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
def close(self) -> None:
|
|
55
|
+
self._store.close()
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import Any, Mapping
|
|
5
|
+
|
|
6
|
+
from dbl_ingress import (
|
|
7
|
+
AdmissionError,
|
|
8
|
+
InvalidInputError,
|
|
9
|
+
AdmissionRecord,
|
|
10
|
+
shape_input,
|
|
11
|
+
ADMISSION_INVALID_INPUT,
|
|
12
|
+
ADMISSION_SECRETS_PRESENT,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
SECRET_KEYS = {"api_key", "authorization", "token", "secret", "bearer"}
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(frozen=True)
|
|
20
|
+
class AdmissionFailure(Exception):
|
|
21
|
+
reason_code: str
|
|
22
|
+
detail: str
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def admit_and_shape_intent(payload: Mapping[str, Any], *, raw_payload: Mapping[str, Any] | None = None) -> AdmissionRecord:
|
|
26
|
+
if raw_payload is not None and _contains_secrets(raw_payload):
|
|
27
|
+
raise AdmissionFailure(reason_code=ADMISSION_SECRETS_PRESENT, detail="secrets detected in payload")
|
|
28
|
+
if _contains_secrets(payload):
|
|
29
|
+
raise AdmissionFailure(reason_code=ADMISSION_SECRETS_PRESENT, detail="secrets detected in payload")
|
|
30
|
+
|
|
31
|
+
correlation_id = payload.get("correlation_id")
|
|
32
|
+
deterministic = payload.get("deterministic")
|
|
33
|
+
observational = payload.get("observational")
|
|
34
|
+
|
|
35
|
+
if not isinstance(correlation_id, str) or not correlation_id.strip():
|
|
36
|
+
raise AdmissionFailure(reason_code=ADMISSION_INVALID_INPUT, detail="correlation_id must be a non-empty string")
|
|
37
|
+
if not isinstance(deterministic, Mapping):
|
|
38
|
+
raise AdmissionFailure(reason_code=ADMISSION_INVALID_INPUT, detail="deterministic must be an object")
|
|
39
|
+
if observational is not None and not isinstance(observational, Mapping):
|
|
40
|
+
raise AdmissionFailure(reason_code=ADMISSION_INVALID_INPUT, detail="observational must be an object if provided")
|
|
41
|
+
|
|
42
|
+
try:
|
|
43
|
+
record = shape_input(
|
|
44
|
+
correlation_id=correlation_id,
|
|
45
|
+
deterministic=deterministic,
|
|
46
|
+
observational=observational,
|
|
47
|
+
)
|
|
48
|
+
except InvalidInputError as exc:
|
|
49
|
+
raise AdmissionFailure(reason_code=exc.reason_code, detail=str(exc)) from exc
|
|
50
|
+
except AdmissionError as exc:
|
|
51
|
+
reason = getattr(exc, "reason_code", ADMISSION_INVALID_INPUT)
|
|
52
|
+
raise AdmissionFailure(reason_code=reason, detail=str(exc)) from exc
|
|
53
|
+
return record
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _contains_secrets(value: object) -> bool:
|
|
57
|
+
if isinstance(value, Mapping):
|
|
58
|
+
for key, item in value.items():
|
|
59
|
+
if isinstance(key, str) and key.lower() in SECRET_KEYS:
|
|
60
|
+
if isinstance(item, str) and item.strip():
|
|
61
|
+
return True
|
|
62
|
+
if _contains_secrets(item):
|
|
63
|
+
return True
|
|
64
|
+
return False
|
|
65
|
+
if isinstance(value, list):
|
|
66
|
+
return any(_contains_secrets(item) for item in value)
|
|
67
|
+
return False
|