constellation-node-sdk 1.0.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- constellation_node_sdk/__init__.py +123 -0
- constellation_node_sdk/gate/__init__.py +47 -0
- constellation_node_sdk/gate/client.py +108 -0
- constellation_node_sdk/gate/config.py +153 -0
- constellation_node_sdk/gate/errors.py +17 -0
- constellation_node_sdk/gate/policy.py +70 -0
- constellation_node_sdk/gate/registration.py +144 -0
- constellation_node_sdk/orchestrator/__init__.py +3 -0
- constellation_node_sdk/orchestrator/base.py +57 -0
- constellation_node_sdk/orchestrator/merge.py +47 -0
- constellation_node_sdk/orchestrator/packet_builder.py +49 -0
- constellation_node_sdk/orchestrator/retry.py +61 -0
- constellation_node_sdk/orchestrator/state.py +78 -0
- constellation_node_sdk/orchestrator/step_executor.py +87 -0
- constellation_node_sdk/py.typed +0 -0
- constellation_node_sdk/runtime/__init__.py +52 -0
- constellation_node_sdk/runtime/app.py +274 -0
- constellation_node_sdk/runtime/config.py +307 -0
- constellation_node_sdk/runtime/errors.py +112 -0
- constellation_node_sdk/runtime/execution.py +249 -0
- constellation_node_sdk/runtime/handlers.py +45 -0
- constellation_node_sdk/runtime/inbound_policy.py +86 -0
- constellation_node_sdk/runtime/lifecycle.py +29 -0
- constellation_node_sdk/runtime/observability.py +72 -0
- constellation_node_sdk/runtime/preflight.py +122 -0
- constellation_node_sdk/security/__init__.py +36 -0
- constellation_node_sdk/security/delegation.py +57 -0
- constellation_node_sdk/security/errors.py +25 -0
- constellation_node_sdk/security/signing.py +121 -0
- constellation_node_sdk/security/validation.py +230 -0
- constellation_node_sdk/security/verification.py +107 -0
- constellation_node_sdk/transport/__init__.py +88 -0
- constellation_node_sdk/transport/codec.py +21 -0
- constellation_node_sdk/transport/errors.py +41 -0
- constellation_node_sdk/transport/hashing.py +73 -0
- constellation_node_sdk/transport/hop_trace.py +378 -0
- constellation_node_sdk/transport/lineage.py +28 -0
- constellation_node_sdk/transport/models.py +447 -0
- constellation_node_sdk/transport/packet.py +331 -0
- constellation_node_sdk/transport/provenance.py +53 -0
- constellation_node_sdk/transport/tenant.py +76 -0
- constellation_node_sdk-1.0.1.dist-info/METADATA +141 -0
- constellation_node_sdk-1.0.1.dist-info/RECORD +45 -0
- constellation_node_sdk-1.0.1.dist-info/WHEEL +5 -0
- constellation_node_sdk-1.0.1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import AsyncIterator, Callable
|
|
4
|
+
from contextlib import asynccontextmanager
|
|
5
|
+
from typing import Any, Literal
|
|
6
|
+
|
|
7
|
+
from fastapi import FastAPI, HTTPException, Request
|
|
8
|
+
from fastapi.responses import JSONResponse
|
|
9
|
+
from starlette.responses import Response
|
|
10
|
+
|
|
11
|
+
from constellation_node_sdk.gate.registration import register_from_env
|
|
12
|
+
from constellation_node_sdk.transport.packet import TransportPacket
|
|
13
|
+
|
|
14
|
+
from .config import NodeRuntimeConfig, get_runtime_config
|
|
15
|
+
from .errors import raise_http_exception
|
|
16
|
+
from .execution import create_error_transport_packet, execute_transport_packet
|
|
17
|
+
from .inbound_policy import validate_execute_ingress_packet, validate_relay_ingress_packet
|
|
18
|
+
from .lifecycle import LifecycleHook, NoOpLifecycle
|
|
19
|
+
from .observability import configure_logging, metrics_response, record_request, set_readiness
|
|
20
|
+
from .preflight import run_preflight
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _key_material_from_config(config: NodeRuntimeConfig) -> tuple[bytes | str | None, str | None]:
|
|
24
|
+
if config.signing_algorithm == "hmac-sha256":
|
|
25
|
+
return config.signing_key, config.signing_algorithm
|
|
26
|
+
if config.signing_algorithm == "ed25519":
|
|
27
|
+
return config.signing_private_key, config.signing_algorithm
|
|
28
|
+
return None, None
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _resolve_route_validation(
|
|
32
|
+
*,
|
|
33
|
+
route_mode: Literal["execute", "relay"],
|
|
34
|
+
resolved_config: NodeRuntimeConfig,
|
|
35
|
+
) -> dict[str, Any]:
|
|
36
|
+
if route_mode == "relay":
|
|
37
|
+
return {
|
|
38
|
+
"require_signature": resolved_config.relay_require_signature
|
|
39
|
+
if resolved_config.relay_require_signature is not None
|
|
40
|
+
else resolved_config.require_signature,
|
|
41
|
+
"verify_hop_signatures": resolved_config.relay_verify_hop_signatures
|
|
42
|
+
if resolved_config.relay_verify_hop_signatures is not None
|
|
43
|
+
else resolved_config.verify_hop_signatures,
|
|
44
|
+
"allowed_actions": (
|
|
45
|
+
resolved_config.relay_allowed_actions or resolved_config.allowed_actions or None
|
|
46
|
+
),
|
|
47
|
+
"allowed_packet_types": (
|
|
48
|
+
resolved_config.relay_allowed_packet_types
|
|
49
|
+
or resolved_config.allowed_packet_types
|
|
50
|
+
or None
|
|
51
|
+
),
|
|
52
|
+
}
|
|
53
|
+
return {
|
|
54
|
+
"require_signature": resolved_config.execute_require_signature
|
|
55
|
+
if resolved_config.execute_require_signature is not None
|
|
56
|
+
else resolved_config.require_signature,
|
|
57
|
+
"verify_hop_signatures": resolved_config.execute_verify_hop_signatures
|
|
58
|
+
if resolved_config.execute_verify_hop_signatures is not None
|
|
59
|
+
else resolved_config.verify_hop_signatures,
|
|
60
|
+
"allowed_actions": (
|
|
61
|
+
resolved_config.execute_allowed_actions or resolved_config.allowed_actions or None
|
|
62
|
+
),
|
|
63
|
+
"allowed_packet_types": (
|
|
64
|
+
resolved_config.execute_allowed_packet_types
|
|
65
|
+
or resolved_config.allowed_packet_types
|
|
66
|
+
or None
|
|
67
|
+
),
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
async def _handle_transport_request(
|
|
72
|
+
request: Request,
|
|
73
|
+
*,
|
|
74
|
+
execution_mode: Literal["execute", "relay"],
|
|
75
|
+
resolved_config: NodeRuntimeConfig,
|
|
76
|
+
validate_ingress: Callable[[TransportPacket], None],
|
|
77
|
+
) -> JSONResponse:
|
|
78
|
+
packet: TransportPacket | None = None
|
|
79
|
+
try:
|
|
80
|
+
body = await request.json()
|
|
81
|
+
except Exception as exc:
|
|
82
|
+
record_request(config=resolved_config, action="unknown", status="invalid_json")
|
|
83
|
+
raise HTTPException(status_code=400, detail=f"invalid JSON body: {exc}") from exc
|
|
84
|
+
|
|
85
|
+
try:
|
|
86
|
+
if not isinstance(body, dict):
|
|
87
|
+
raise ValueError("request body must be a JSON object")
|
|
88
|
+
packet = TransportPacket.model_validate(body)
|
|
89
|
+
if resolved_config.enforce_gate_only_ingress:
|
|
90
|
+
validate_ingress(packet)
|
|
91
|
+
|
|
92
|
+
route_validation = _resolve_route_validation(
|
|
93
|
+
route_mode=execution_mode,
|
|
94
|
+
resolved_config=resolved_config,
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
response_signing_key, response_signing_algorithm = _key_material_from_config(
|
|
98
|
+
resolved_config
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
response_packet = await execute_transport_packet(
|
|
102
|
+
packet,
|
|
103
|
+
node_name=resolved_config.node_name,
|
|
104
|
+
signing_key=(
|
|
105
|
+
response_signing_key if response_signing_algorithm == "hmac-sha256" else None
|
|
106
|
+
),
|
|
107
|
+
signing_private_key=(
|
|
108
|
+
str(response_signing_key)
|
|
109
|
+
if response_signing_algorithm == "ed25519" and response_signing_key
|
|
110
|
+
else None
|
|
111
|
+
),
|
|
112
|
+
signing_key_id=resolved_config.signing_key_id,
|
|
113
|
+
signing_algorithm=response_signing_algorithm,
|
|
114
|
+
verifying_keys=resolved_config.verifying_keys,
|
|
115
|
+
require_signature=route_validation["require_signature"],
|
|
116
|
+
allowed_actions=route_validation["allowed_actions"],
|
|
117
|
+
allowed_packet_types=route_validation["allowed_packet_types"],
|
|
118
|
+
required_idempotency_actions=(resolved_config.require_idempotency_for_actions or None),
|
|
119
|
+
replay_enabled=resolved_config.replay_enabled,
|
|
120
|
+
dev_mode=resolved_config.dev_mode,
|
|
121
|
+
verify_hop_signatures=route_validation["verify_hop_signatures"],
|
|
122
|
+
allowed_clock_skew_seconds=resolved_config.allowed_clock_skew_seconds,
|
|
123
|
+
max_packet_bytes=resolved_config.max_packet_bytes,
|
|
124
|
+
max_hop_depth=resolved_config.max_hop_depth,
|
|
125
|
+
max_delegation_depth=resolved_config.max_delegation_depth,
|
|
126
|
+
max_attachments=resolved_config.max_attachments,
|
|
127
|
+
max_attachment_size_bytes=resolved_config.max_attachment_size_bytes,
|
|
128
|
+
allowed_attachment_schemes=resolved_config.attachment_allowed_schemes,
|
|
129
|
+
allow_private_attachment_hosts=resolved_config.allow_private_attachment_hosts,
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
status = str(response_packet.payload.get("status", "completed")).strip().lower()
|
|
133
|
+
record_request(config=resolved_config, action=packet.header.action, status=status)
|
|
134
|
+
return JSONResponse(content=response_packet.model_dump_json_dict())
|
|
135
|
+
|
|
136
|
+
except Exception as exc:
|
|
137
|
+
if packet is not None and resolved_config.return_transport_errors:
|
|
138
|
+
response_signing_key, response_signing_algorithm = _key_material_from_config(
|
|
139
|
+
resolved_config
|
|
140
|
+
)
|
|
141
|
+
failure_packet = create_error_transport_packet(
|
|
142
|
+
packet,
|
|
143
|
+
exc,
|
|
144
|
+
node_name=resolved_config.node_name,
|
|
145
|
+
signing_key=(
|
|
146
|
+
response_signing_key if response_signing_algorithm == "hmac-sha256" else None
|
|
147
|
+
),
|
|
148
|
+
signing_private_key=(
|
|
149
|
+
str(response_signing_key)
|
|
150
|
+
if response_signing_algorithm == "ed25519" and response_signing_key
|
|
151
|
+
else None
|
|
152
|
+
),
|
|
153
|
+
signing_key_id=resolved_config.signing_key_id,
|
|
154
|
+
signing_algorithm=response_signing_algorithm,
|
|
155
|
+
expose_internal_errors=resolved_config.expose_internal_errors,
|
|
156
|
+
)
|
|
157
|
+
record_request(
|
|
158
|
+
config=resolved_config,
|
|
159
|
+
action=packet.header.action,
|
|
160
|
+
status="failed",
|
|
161
|
+
)
|
|
162
|
+
return JSONResponse(content=failure_packet.model_dump_json_dict())
|
|
163
|
+
|
|
164
|
+
record_request(
|
|
165
|
+
config=resolved_config,
|
|
166
|
+
action="unknown" if packet is None else packet.header.action,
|
|
167
|
+
status="error",
|
|
168
|
+
)
|
|
169
|
+
raise_http_exception(exc)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def create_node_app(
|
|
173
|
+
*,
|
|
174
|
+
service_name: str | None = None,
|
|
175
|
+
version: str | None = None,
|
|
176
|
+
lifecycle_hook: LifecycleHook | None = None,
|
|
177
|
+
config: NodeRuntimeConfig | None = None,
|
|
178
|
+
auto_register_with_gate: bool = True,
|
|
179
|
+
) -> FastAPI:
|
|
180
|
+
resolved_config = config or get_runtime_config()
|
|
181
|
+
resolved_service_name = service_name or resolved_config.service_name
|
|
182
|
+
resolved_version = version or resolved_config.service_version
|
|
183
|
+
resolved_lifecycle = lifecycle_hook or NoOpLifecycle()
|
|
184
|
+
|
|
185
|
+
@asynccontextmanager
|
|
186
|
+
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
|
|
187
|
+
configure_logging(resolved_config)
|
|
188
|
+
run_preflight(resolved_config)
|
|
189
|
+
|
|
190
|
+
app.state.runtime_ready = False
|
|
191
|
+
set_readiness(config=resolved_config, ready=False)
|
|
192
|
+
|
|
193
|
+
await resolved_lifecycle.startup()
|
|
194
|
+
|
|
195
|
+
if auto_register_with_gate and resolved_config.gate_url:
|
|
196
|
+
await register_from_env()
|
|
197
|
+
|
|
198
|
+
app.state.runtime_ready = True
|
|
199
|
+
set_readiness(config=resolved_config, ready=True)
|
|
200
|
+
|
|
201
|
+
yield
|
|
202
|
+
|
|
203
|
+
app.state.runtime_ready = False
|
|
204
|
+
set_readiness(config=resolved_config, ready=False)
|
|
205
|
+
await resolved_lifecycle.shutdown()
|
|
206
|
+
|
|
207
|
+
app = FastAPI(
|
|
208
|
+
title=resolved_service_name,
|
|
209
|
+
version=resolved_version,
|
|
210
|
+
lifespan=lifespan,
|
|
211
|
+
)
|
|
212
|
+
app.state.runtime_ready = False
|
|
213
|
+
|
|
214
|
+
@app.get("/v1/health")
|
|
215
|
+
async def health() -> dict[str, Any]:
|
|
216
|
+
return {
|
|
217
|
+
"status": "healthy" if bool(getattr(app.state, "runtime_ready", False)) else "starting",
|
|
218
|
+
"service_name": resolved_config.service_name,
|
|
219
|
+
"service_version": resolved_config.service_version,
|
|
220
|
+
"node_name": resolved_config.node_name,
|
|
221
|
+
"ready": bool(getattr(app.state, "runtime_ready", False)),
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
@app.get("/metrics")
|
|
225
|
+
async def metrics() -> Response:
|
|
226
|
+
return metrics_response()
|
|
227
|
+
|
|
228
|
+
@app.post("/v1/execute")
|
|
229
|
+
async def execute(request: Request) -> JSONResponse:
|
|
230
|
+
def _validate_execute_ingress(packet: TransportPacket) -> None:
|
|
231
|
+
validate_execute_ingress_packet(
|
|
232
|
+
packet,
|
|
233
|
+
local_node=resolved_config.node_name,
|
|
234
|
+
gate_node_name=resolved_config.gate_node_name,
|
|
235
|
+
require_route_kind=resolved_config.require_gate_mediation_provenance,
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
return await _handle_transport_request(
|
|
239
|
+
request,
|
|
240
|
+
execution_mode="execute",
|
|
241
|
+
resolved_config=resolved_config,
|
|
242
|
+
validate_ingress=_validate_execute_ingress,
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
if resolved_config.enable_relay_route:
|
|
246
|
+
|
|
247
|
+
@app.post("/v1/relay")
|
|
248
|
+
async def relay(request: Request) -> JSONResponse:
|
|
249
|
+
def _validate_relay_ingress(packet: TransportPacket) -> None:
|
|
250
|
+
validate_relay_ingress_packet(
|
|
251
|
+
packet,
|
|
252
|
+
local_node=resolved_config.node_name,
|
|
253
|
+
gate_node_name=resolved_config.gate_node_name,
|
|
254
|
+
require_route_kind=resolved_config.require_gate_mediation_provenance,
|
|
255
|
+
allowed_actions=(
|
|
256
|
+
resolved_config.relay_allowed_actions
|
|
257
|
+
or resolved_config.allowed_actions
|
|
258
|
+
or None
|
|
259
|
+
),
|
|
260
|
+
allowed_packet_types=(
|
|
261
|
+
resolved_config.relay_allowed_packet_types
|
|
262
|
+
or resolved_config.allowed_packet_types
|
|
263
|
+
or None
|
|
264
|
+
),
|
|
265
|
+
)
|
|
266
|
+
|
|
267
|
+
return await _handle_transport_request(
|
|
268
|
+
request,
|
|
269
|
+
execution_mode="relay",
|
|
270
|
+
resolved_config=resolved_config,
|
|
271
|
+
validate_ingress=_validate_relay_ingress,
|
|
272
|
+
)
|
|
273
|
+
|
|
274
|
+
return app
|
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
from functools import lru_cache
|
|
6
|
+
|
|
7
|
+
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
|
|
8
|
+
|
|
9
|
+
_ALLOWED_ENVIRONMENTS = {"local", "dev", "test", "staging", "prod"}
|
|
10
|
+
_ALLOWED_SIGNING_ALGORITHMS = {"hmac-sha256", "ed25519"}
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _env_bool(name: str, default: bool = False) -> bool:
|
|
14
|
+
raw = os.getenv(name)
|
|
15
|
+
if raw is None:
|
|
16
|
+
return default
|
|
17
|
+
return raw.strip().lower() in {"1", "true", "yes", "on"}
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _env_tuple(name: str) -> tuple[str, ...]:
|
|
21
|
+
raw = os.getenv(name, "")
|
|
22
|
+
return tuple(item.strip() for item in raw.split(",") if item.strip())
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _env_json_map(name: str) -> dict[str, str]:
|
|
26
|
+
raw = os.getenv(name, "").strip()
|
|
27
|
+
if not raw:
|
|
28
|
+
return {}
|
|
29
|
+
parsed = json.loads(raw)
|
|
30
|
+
if not isinstance(parsed, dict):
|
|
31
|
+
raise ValueError(f"{name} must be a JSON object")
|
|
32
|
+
result: dict[str, str] = {}
|
|
33
|
+
for key, value in parsed.items():
|
|
34
|
+
if not isinstance(key, str) or not isinstance(value, str):
|
|
35
|
+
raise ValueError(f"{name} must map strings to strings")
|
|
36
|
+
result[key] = value
|
|
37
|
+
return result
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class NodeRuntimeConfig(BaseModel):
|
|
41
|
+
model_config = ConfigDict(frozen=True, extra="forbid")
|
|
42
|
+
|
|
43
|
+
environment: str
|
|
44
|
+
node_name: str
|
|
45
|
+
service_name: str
|
|
46
|
+
service_version: str
|
|
47
|
+
|
|
48
|
+
dev_mode: bool = False
|
|
49
|
+
require_signature: bool = False
|
|
50
|
+
expose_internal_errors: bool = False
|
|
51
|
+
return_transport_errors: bool = True
|
|
52
|
+
|
|
53
|
+
signing_algorithm: str = "hmac-sha256"
|
|
54
|
+
signing_key: str | None = None
|
|
55
|
+
signing_private_key: str | None = None
|
|
56
|
+
signing_key_id: str | None = None
|
|
57
|
+
verifying_keys: dict[str, str] = Field(default_factory=dict)
|
|
58
|
+
|
|
59
|
+
allowed_actions: tuple[str, ...] = ()
|
|
60
|
+
allowed_packet_types: tuple[str, ...] = ("request", "command", "delegation", "replay_request")
|
|
61
|
+
require_idempotency_for_actions: tuple[str, ...] = ()
|
|
62
|
+
|
|
63
|
+
allowed_clock_skew_seconds: int = Field(default=30, ge=0)
|
|
64
|
+
max_packet_bytes: int = Field(default=262_144, ge=1024)
|
|
65
|
+
max_hop_depth: int = Field(default=64, ge=1)
|
|
66
|
+
max_delegation_depth: int = Field(default=8, ge=1)
|
|
67
|
+
max_attachments: int = Field(default=32, ge=0)
|
|
68
|
+
max_attachment_size_bytes: int = Field(default=10_485_760, ge=0)
|
|
69
|
+
attachment_allowed_schemes: tuple[str, ...] = ()
|
|
70
|
+
allow_private_attachment_hosts: bool = False
|
|
71
|
+
|
|
72
|
+
replay_enabled: bool = True
|
|
73
|
+
verify_hop_signatures: bool = False
|
|
74
|
+
|
|
75
|
+
gate_node_name: str = "gate"
|
|
76
|
+
enforce_gate_only_ingress: bool = True
|
|
77
|
+
enable_relay_route: bool = True
|
|
78
|
+
require_gate_mediation_provenance: bool = True
|
|
79
|
+
|
|
80
|
+
execute_require_signature: bool | None = None
|
|
81
|
+
execute_verify_hop_signatures: bool | None = None
|
|
82
|
+
execute_allowed_actions: tuple[str, ...] = ()
|
|
83
|
+
execute_allowed_packet_types: tuple[str, ...] = ()
|
|
84
|
+
|
|
85
|
+
relay_require_signature: bool | None = None
|
|
86
|
+
relay_verify_hop_signatures: bool | None = None
|
|
87
|
+
relay_allowed_actions: tuple[str, ...] = ()
|
|
88
|
+
relay_allowed_packet_types: tuple[str, ...] = (
|
|
89
|
+
"request",
|
|
90
|
+
"command",
|
|
91
|
+
"delegation",
|
|
92
|
+
"replay_request",
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
gate_url: str | None = None
|
|
96
|
+
# Default to loopback — containers must set HOST=0.0.0.0 explicitly via env var.
|
|
97
|
+
# This prevents accidental public binding on bare-metal or dev environments.
|
|
98
|
+
host: str = "127.0.0.1"
|
|
99
|
+
port: int = Field(default=8000, ge=1, le=65535)
|
|
100
|
+
|
|
101
|
+
@field_validator(
|
|
102
|
+
"environment",
|
|
103
|
+
"node_name",
|
|
104
|
+
"gate_node_name",
|
|
105
|
+
"service_name",
|
|
106
|
+
"service_version",
|
|
107
|
+
"signing_algorithm",
|
|
108
|
+
"host",
|
|
109
|
+
)
|
|
110
|
+
@classmethod
|
|
111
|
+
def validate_required_strings(cls, value: str) -> str:
|
|
112
|
+
normalized = value.strip()
|
|
113
|
+
if not normalized:
|
|
114
|
+
raise ValueError("field must not be empty")
|
|
115
|
+
return normalized
|
|
116
|
+
|
|
117
|
+
@field_validator("environment")
|
|
118
|
+
@classmethod
|
|
119
|
+
def validate_environment(cls, value: str) -> str:
|
|
120
|
+
normalized = value.strip().lower()
|
|
121
|
+
if normalized not in _ALLOWED_ENVIRONMENTS:
|
|
122
|
+
raise ValueError(f"environment must be one of {sorted(_ALLOWED_ENVIRONMENTS)}")
|
|
123
|
+
return normalized
|
|
124
|
+
|
|
125
|
+
@field_validator("signing_algorithm")
|
|
126
|
+
@classmethod
|
|
127
|
+
def validate_signing_algorithm(cls, value: str) -> str:
|
|
128
|
+
normalized = value.strip().lower()
|
|
129
|
+
if normalized not in _ALLOWED_SIGNING_ALGORITHMS:
|
|
130
|
+
raise ValueError(
|
|
131
|
+
f"signing_algorithm must be one of {sorted(_ALLOWED_SIGNING_ALGORITHMS)}"
|
|
132
|
+
)
|
|
133
|
+
return normalized
|
|
134
|
+
|
|
135
|
+
@field_validator(
|
|
136
|
+
"allowed_actions",
|
|
137
|
+
"allowed_packet_types",
|
|
138
|
+
"require_idempotency_for_actions",
|
|
139
|
+
"attachment_allowed_schemes",
|
|
140
|
+
"execute_allowed_actions",
|
|
141
|
+
"execute_allowed_packet_types",
|
|
142
|
+
"relay_allowed_actions",
|
|
143
|
+
"relay_allowed_packet_types",
|
|
144
|
+
)
|
|
145
|
+
@classmethod
|
|
146
|
+
def validate_string_tuples(cls, value: tuple[str, ...]) -> tuple[str, ...]:
|
|
147
|
+
normalized = tuple(item.strip().lower() for item in value if item.strip())
|
|
148
|
+
if len(set(normalized)) != len(normalized):
|
|
149
|
+
raise ValueError("tuple entries must not contain duplicates")
|
|
150
|
+
return normalized
|
|
151
|
+
|
|
152
|
+
@field_validator("signing_key_id")
|
|
153
|
+
@classmethod
|
|
154
|
+
def validate_optional_key_id(cls, value: str | None) -> str | None:
|
|
155
|
+
if value is None:
|
|
156
|
+
return None
|
|
157
|
+
normalized = value.strip()
|
|
158
|
+
if not normalized:
|
|
159
|
+
raise ValueError("signing_key_id must not be blank")
|
|
160
|
+
return normalized
|
|
161
|
+
|
|
162
|
+
@field_validator("verifying_keys")
|
|
163
|
+
@classmethod
|
|
164
|
+
def validate_verifying_keys(cls, value: dict[str, str]) -> dict[str, str]:
|
|
165
|
+
normalized: dict[str, str] = {}
|
|
166
|
+
for key_id, key_value in value.items():
|
|
167
|
+
normalized_key = key_id.strip()
|
|
168
|
+
normalized_value = key_value.strip()
|
|
169
|
+
if not normalized_key or not normalized_value:
|
|
170
|
+
raise ValueError("verifying_keys must not contain blank keys or values")
|
|
171
|
+
normalized[normalized_key] = normalized_value
|
|
172
|
+
return normalized
|
|
173
|
+
|
|
174
|
+
@field_validator("gate_url")
|
|
175
|
+
@classmethod
|
|
176
|
+
def validate_gate_url(cls, value: str | None) -> str | None:
|
|
177
|
+
if value is None:
|
|
178
|
+
return None
|
|
179
|
+
normalized = value.strip().rstrip("/")
|
|
180
|
+
if not normalized:
|
|
181
|
+
raise ValueError("gate_url must not be blank")
|
|
182
|
+
if not (normalized.startswith("http://") or normalized.startswith("https://")):
|
|
183
|
+
raise ValueError("gate_url must start with http:// or https://")
|
|
184
|
+
return normalized
|
|
185
|
+
|
|
186
|
+
@model_validator(mode="after")
|
|
187
|
+
def validate_security_profile(self) -> NodeRuntimeConfig:
|
|
188
|
+
if self.environment in {"staging", "prod"} and self.dev_mode:
|
|
189
|
+
raise ValueError("dev_mode cannot be enabled in staging or prod")
|
|
190
|
+
|
|
191
|
+
if self.require_signature:
|
|
192
|
+
if self.signing_algorithm == "hmac-sha256" and not self.signing_key:
|
|
193
|
+
raise ValueError("hmac-sha256 requires signing_key when require_signature=true")
|
|
194
|
+
if self.signing_algorithm == "ed25519" and not self.signing_private_key:
|
|
195
|
+
raise ValueError("ed25519 requires signing_private_key when require_signature=true")
|
|
196
|
+
|
|
197
|
+
if self.signing_algorithm == "ed25519" and self.signing_key is not None:
|
|
198
|
+
raise ValueError("signing_key must not be used with ed25519")
|
|
199
|
+
|
|
200
|
+
known_actions = set(self.allowed_actions) | set(self.execute_allowed_actions)
|
|
201
|
+
invalid_idempotency = set(self.require_idempotency_for_actions) - known_actions
|
|
202
|
+
if invalid_idempotency:
|
|
203
|
+
invalid = ", ".join(sorted(invalid_idempotency))
|
|
204
|
+
raise ValueError(f"require_idempotency_for_actions contains unknown actions: {invalid}")
|
|
205
|
+
|
|
206
|
+
if self.max_attachment_size_bytes > self.max_packet_bytes:
|
|
207
|
+
raise ValueError("max_attachment_size_bytes must not exceed max_packet_bytes")
|
|
208
|
+
|
|
209
|
+
if self.max_attachments > 0 and not self.attachment_allowed_schemes:
|
|
210
|
+
raise ValueError(
|
|
211
|
+
"attachment_allowed_schemes must be configured when attachments are enabled"
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
execute_actions = (
|
|
215
|
+
set(self.execute_allowed_actions)
|
|
216
|
+
if self.execute_allowed_actions
|
|
217
|
+
else set(self.allowed_actions)
|
|
218
|
+
)
|
|
219
|
+
invalid_execute_idempotency = set(self.require_idempotency_for_actions) - execute_actions
|
|
220
|
+
if invalid_execute_idempotency:
|
|
221
|
+
invalid = ", ".join(sorted(invalid_execute_idempotency))
|
|
222
|
+
raise ValueError(
|
|
223
|
+
f"require_idempotency_for_actions contains unknown execute actions: {invalid}"
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
return self
|
|
227
|
+
|
|
228
|
+
def resolve_verifying_key(self, key_id: str | None) -> str | bytes | None:
|
|
229
|
+
if key_id is None:
|
|
230
|
+
return None
|
|
231
|
+
if key_id in self.verifying_keys:
|
|
232
|
+
return self.verifying_keys[key_id]
|
|
233
|
+
if self.signing_key_id is not None and key_id == self.signing_key_id:
|
|
234
|
+
if self.signing_algorithm == "hmac-sha256":
|
|
235
|
+
return self.signing_key
|
|
236
|
+
return None
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
@lru_cache
|
|
240
|
+
def get_runtime_config() -> NodeRuntimeConfig:
|
|
241
|
+
environment = os.getenv("L9_ENVIRONMENT", "local").strip().lower() or "local"
|
|
242
|
+
dev_mode = _env_bool("L9_DEV_MODE", False)
|
|
243
|
+
return NodeRuntimeConfig(
|
|
244
|
+
environment=environment,
|
|
245
|
+
node_name=os.getenv("L9_NODE_NAME", "unknown-node"),
|
|
246
|
+
service_name=os.getenv("L9_SERVICE_NAME", os.getenv("L9_NODE_NAME", "unknown-node")),
|
|
247
|
+
service_version=os.getenv("L9_SERVICE_VERSION", "1.0.0"),
|
|
248
|
+
dev_mode=dev_mode,
|
|
249
|
+
require_signature=_env_bool("L9_REQUIRE_SIGNATURE", False),
|
|
250
|
+
expose_internal_errors=_env_bool("L9_EXPOSE_INTERNAL_ERRORS", False),
|
|
251
|
+
return_transport_errors=_env_bool("L9_RETURN_TRANSPORT_ERRORS", True),
|
|
252
|
+
signing_algorithm=os.getenv("L9_SIGNING_ALGORITHM", "hmac-sha256"),
|
|
253
|
+
signing_key=os.getenv("L9_SIGNING_KEY") or os.getenv("L9_SIGNING_SECRET"),
|
|
254
|
+
signing_private_key=os.getenv("L9_SIGNING_PRIVATE_KEY"),
|
|
255
|
+
signing_key_id=os.getenv("L9_SIGNING_KEY_ID"),
|
|
256
|
+
verifying_keys=_env_json_map("L9_VERIFYING_KEYS_JSON"),
|
|
257
|
+
allowed_actions=_env_tuple("L9_ALLOWED_ACTIONS"),
|
|
258
|
+
allowed_packet_types=_env_tuple("L9_ALLOWED_PACKET_TYPES")
|
|
259
|
+
or ("request", "command", "delegation", "replay_request"),
|
|
260
|
+
require_idempotency_for_actions=_env_tuple("L9_REQUIRE_IDEMPOTENCY_FOR_ACTIONS"),
|
|
261
|
+
allowed_clock_skew_seconds=int(os.getenv("L9_ALLOWED_CLOCK_SKEW_SECONDS", "30")),
|
|
262
|
+
max_packet_bytes=int(os.getenv("L9_MAX_PACKET_BYTES", "262144")),
|
|
263
|
+
max_hop_depth=int(os.getenv("L9_MAX_HOP_DEPTH", "64")),
|
|
264
|
+
max_delegation_depth=int(os.getenv("L9_MAX_DELEGATION_DEPTH", "8")),
|
|
265
|
+
max_attachments=int(os.getenv("L9_MAX_ATTACHMENTS", "32")),
|
|
266
|
+
max_attachment_size_bytes=int(os.getenv("L9_MAX_ATTACHMENT_SIZE_BYTES", "10485760")),
|
|
267
|
+
attachment_allowed_schemes=_env_tuple("L9_ATTACHMENT_ALLOWED_SCHEMES"),
|
|
268
|
+
allow_private_attachment_hosts=_env_bool("L9_ALLOW_PRIVATE_ATTACHMENT_HOSTS", False),
|
|
269
|
+
replay_enabled=_env_bool("L9_REPLAY_ENABLED", True),
|
|
270
|
+
verify_hop_signatures=_env_bool("L9_VERIFY_HOP_SIGNATURES", False),
|
|
271
|
+
gate_node_name=os.getenv("L9_GATE_NODE_NAME", "gate"),
|
|
272
|
+
enforce_gate_only_ingress=_env_bool("L9_ENFORCE_GATE_ONLY_INGRESS", True),
|
|
273
|
+
enable_relay_route=_env_bool("L9_ENABLE_RELAY_ROUTE", True),
|
|
274
|
+
require_gate_mediation_provenance=_env_bool("L9_REQUIRE_GATE_MEDIATION_PROVENANCE", True),
|
|
275
|
+
execute_require_signature=(
|
|
276
|
+
_env_bool("L9_EXECUTE_REQUIRE_SIGNATURE", False)
|
|
277
|
+
if os.getenv("L9_EXECUTE_REQUIRE_SIGNATURE") is not None
|
|
278
|
+
else None
|
|
279
|
+
),
|
|
280
|
+
execute_verify_hop_signatures=(
|
|
281
|
+
_env_bool("L9_EXECUTE_VERIFY_HOP_SIGNATURES", False)
|
|
282
|
+
if os.getenv("L9_EXECUTE_VERIFY_HOP_SIGNATURES") is not None
|
|
283
|
+
else None
|
|
284
|
+
),
|
|
285
|
+
execute_allowed_actions=_env_tuple("L9_EXECUTE_ALLOWED_ACTIONS"),
|
|
286
|
+
execute_allowed_packet_types=_env_tuple("L9_EXECUTE_ALLOWED_PACKET_TYPES"),
|
|
287
|
+
relay_require_signature=(
|
|
288
|
+
_env_bool("L9_RELAY_REQUIRE_SIGNATURE", False)
|
|
289
|
+
if os.getenv("L9_RELAY_REQUIRE_SIGNATURE") is not None
|
|
290
|
+
else None
|
|
291
|
+
),
|
|
292
|
+
relay_verify_hop_signatures=(
|
|
293
|
+
_env_bool("L9_RELAY_VERIFY_HOP_SIGNATURES", False)
|
|
294
|
+
if os.getenv("L9_RELAY_VERIFY_HOP_SIGNATURES") is not None
|
|
295
|
+
else None
|
|
296
|
+
),
|
|
297
|
+
relay_allowed_actions=_env_tuple("L9_RELAY_ALLOWED_ACTIONS"),
|
|
298
|
+
relay_allowed_packet_types=(
|
|
299
|
+
_env_tuple("L9_RELAY_ALLOWED_PACKET_TYPES")
|
|
300
|
+
or ("request", "command", "delegation", "replay_request")
|
|
301
|
+
),
|
|
302
|
+
gate_url=os.getenv("GATE_URL"),
|
|
303
|
+
# HOST env var overrides the safe 127.0.0.1 default.
|
|
304
|
+
# In containers set HOST=0.0.0.0. On bare metal leave unset.
|
|
305
|
+
host=os.getenv("HOST", "127.0.0.1"),
|
|
306
|
+
port=int(os.getenv("PORT", "8000")),
|
|
307
|
+
)
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import NoReturn
|
|
5
|
+
|
|
6
|
+
from fastapi import HTTPException
|
|
7
|
+
|
|
8
|
+
from constellation_node_sdk.transport.errors import (
|
|
9
|
+
PacketSizeError,
|
|
10
|
+
SchemaVersionError,
|
|
11
|
+
TenantMutationError,
|
|
12
|
+
TransportAuthenticationError,
|
|
13
|
+
TransportAuthorizationError,
|
|
14
|
+
TransportExpiredError,
|
|
15
|
+
TransportIntegrityError,
|
|
16
|
+
TransportNotYetValidError,
|
|
17
|
+
TransportValidationError,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class RuntimeErrorBase(Exception):
|
|
22
|
+
"""Base exception for runtime-layer failures."""
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class RuntimeConfigurationError(RuntimeErrorBase):
|
|
26
|
+
"""Raised when runtime configuration is invalid or incomplete."""
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class RuntimeValidationError(RuntimeErrorBase):
|
|
30
|
+
"""Raised when an inbound request fails runtime validation."""
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class RuntimeExecutionError(RuntimeErrorBase):
|
|
34
|
+
"""Raised when handler execution fails within the runtime shell."""
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass(frozen=True)
|
|
38
|
+
class RuntimeErrorDetail:
|
|
39
|
+
code: str
|
|
40
|
+
message: str
|
|
41
|
+
status_code: int
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def classify_exception(exc: Exception) -> RuntimeErrorDetail:
|
|
45
|
+
"""
|
|
46
|
+
Map runtime and transport exceptions to safe HTTP-facing error details.
|
|
47
|
+
"""
|
|
48
|
+
if isinstance(
|
|
49
|
+
exc,
|
|
50
|
+
(
|
|
51
|
+
ValueError,
|
|
52
|
+
TransportValidationError,
|
|
53
|
+
TransportIntegrityError,
|
|
54
|
+
PacketSizeError,
|
|
55
|
+
SchemaVersionError,
|
|
56
|
+
TenantMutationError,
|
|
57
|
+
),
|
|
58
|
+
):
|
|
59
|
+
return RuntimeErrorDetail(
|
|
60
|
+
code="invalid_request",
|
|
61
|
+
message=str(exc),
|
|
62
|
+
status_code=400,
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
if isinstance(exc, (TransportAuthenticationError,)):
|
|
66
|
+
return RuntimeErrorDetail(
|
|
67
|
+
code="authentication_failed",
|
|
68
|
+
message="transport authentication failed",
|
|
69
|
+
status_code=401,
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
if isinstance(exc, (TransportAuthorizationError,)):
|
|
73
|
+
return RuntimeErrorDetail(
|
|
74
|
+
code="authorization_failed",
|
|
75
|
+
message="transport authorization failed",
|
|
76
|
+
status_code=403,
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
if isinstance(exc, (TransportExpiredError, TransportNotYetValidError)):
|
|
80
|
+
return RuntimeErrorDetail(
|
|
81
|
+
code="temporal_validity_failed",
|
|
82
|
+
message=str(exc),
|
|
83
|
+
status_code=409,
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
if isinstance(exc, TimeoutError):
|
|
87
|
+
return RuntimeErrorDetail(
|
|
88
|
+
code="execution_timeout",
|
|
89
|
+
message="handler execution timed out",
|
|
90
|
+
status_code=504,
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
return RuntimeErrorDetail(
|
|
94
|
+
code="internal_error",
|
|
95
|
+
message="internal server error",
|
|
96
|
+
status_code=500,
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def raise_http_exception(exc: Exception) -> NoReturn:
|
|
101
|
+
"""
|
|
102
|
+
Raise a FastAPI HTTPException from a classified runtime exception.
|
|
103
|
+
Always raises — annotated NoReturn so mypy understands no return sentinel needed.
|
|
104
|
+
"""
|
|
105
|
+
detail = classify_exception(exc)
|
|
106
|
+
raise HTTPException(
|
|
107
|
+
status_code=detail.status_code,
|
|
108
|
+
detail={
|
|
109
|
+
"code": detail.code,
|
|
110
|
+
"message": detail.message,
|
|
111
|
+
},
|
|
112
|
+
) from exc
|