feat(security): isolate bounded work and support required auth actions
This commit is contained in:
@@ -176,6 +176,8 @@ class UserInfo(BaseModel):
|
||||
tenant_display_name: str | None = None
|
||||
is_tenant_admin: bool = False
|
||||
password_reset_required: bool = False
|
||||
required_auth_action: Literal["change_password"] | None = None
|
||||
local_password: bool = False
|
||||
preferred_language: str | None = None
|
||||
enabled_language_codes: list[str] = Field(default_factory=list)
|
||||
ui_preferences: UserUiPreferences = Field(default_factory=UserUiPreferences)
|
||||
@@ -190,6 +192,8 @@ class AuthSessionUserInfo(BaseModel):
|
||||
tenant_display_name: str | None = None
|
||||
is_tenant_admin: bool = False
|
||||
password_reset_required: bool = False
|
||||
required_auth_action: Literal["change_password"] | None = None
|
||||
local_password: bool = False
|
||||
|
||||
|
||||
class AuthSessionResponse(BaseModel):
|
||||
|
||||
@@ -0,0 +1,285 @@
|
||||
"""Resource-bounded, disposable workers for trusted module-owned byte operations.
|
||||
|
||||
This is not an arbitrary-code sandbox. Callers supply a server-owned top-level
|
||||
function, never a client-selected module/path/callable. Sessions, credentials and
|
||||
authority remain in the parent; only explicit bounded bytes cross the pipe.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from contextlib import contextmanager
|
||||
from dataclasses import dataclass
|
||||
import inspect
|
||||
import math
|
||||
import os
|
||||
from pathlib import Path
|
||||
import selectors
|
||||
import signal
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ProcessLimits:
|
||||
wall_seconds: float = 10.0
|
||||
cpu_seconds: int = 10
|
||||
memory_bytes: int = 256 * 1024 * 1024
|
||||
input_bytes: int = 8 * 1024 * 1024
|
||||
output_bytes: int = 8 * 1024 * 1024
|
||||
file_bytes: int = 0
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if not math.isfinite(self.wall_seconds) or not 0 < self.wall_seconds <= 600:
|
||||
raise ValueError("Worker wall time must be finite and within (0, 600] seconds.")
|
||||
for name, minimum, maximum in (
|
||||
("cpu_seconds", 1, 600),
|
||||
("memory_bytes", 64 * 1024 * 1024, 8 * 1024 * 1024 * 1024),
|
||||
("input_bytes", 1, 256 * 1024 * 1024),
|
||||
("output_bytes", 1, 256 * 1024 * 1024),
|
||||
("file_bytes", 0, 2 * 1024 * 1024 * 1024),
|
||||
):
|
||||
value = getattr(self, name)
|
||||
if type(value) is not int or not minimum <= value <= maximum:
|
||||
raise ValueError(f"Invalid worker {name} limit.")
|
||||
|
||||
|
||||
class ProcessBudgetError(RuntimeError):
|
||||
def __init__(self, code: str) -> None:
|
||||
messages = {
|
||||
"busy": "The isolated-work capacity is busy; retry later.",
|
||||
"cancelled": "Isolated work was cancelled.",
|
||||
"timeout": "Isolated work exceeded its wall-clock limit.",
|
||||
"cpu_limit": "Isolated work exceeded its CPU limit.",
|
||||
"memory_limit": "Isolated work exceeded its memory limit.",
|
||||
"input_limit": "Isolated work input exceeded its byte limit.",
|
||||
"output_limit": "Isolated work output exceeded its byte limit.",
|
||||
"unavailable": "Required isolated-worker resource controls are unavailable.",
|
||||
"worker_failed": "Isolated work could not complete safely.",
|
||||
}
|
||||
self.code = code
|
||||
super().__init__(messages[code])
|
||||
|
||||
|
||||
_gate = threading.Lock()
|
||||
_active = 0
|
||||
_STDERR_LIMIT = 64 * 1024
|
||||
|
||||
|
||||
def _reset_after_fork() -> None:
|
||||
global _gate, _active
|
||||
_gate, _active = threading.Lock(), 0
|
||||
|
||||
|
||||
if hasattr(os, "register_at_fork"):
|
||||
os.register_at_fork(after_in_child=_reset_after_fork)
|
||||
|
||||
|
||||
def _reserve() -> None:
|
||||
from govoplan_core.settings import settings
|
||||
|
||||
global _active
|
||||
with _gate:
|
||||
if _active >= settings.isolated_process_concurrency:
|
||||
raise ProcessBudgetError("busy")
|
||||
_active += 1
|
||||
|
||||
|
||||
def _release() -> None:
|
||||
global _active
|
||||
with _gate:
|
||||
_active -= 1
|
||||
|
||||
|
||||
@dataclass(slots=True, eq=False)
|
||||
class _OperationAdmission:
|
||||
process_id: int
|
||||
thread_id: int
|
||||
active: bool = True
|
||||
running: bool = False
|
||||
|
||||
|
||||
@contextmanager
|
||||
def bounded_operation_admission():
|
||||
"""Reserve shared capacity before bounded parent-side input preparation.
|
||||
|
||||
The yielded token may be reused sequentially in this thread only. Never
|
||||
expose it to clients or hold it while waiting for user input/network work.
|
||||
Parent preparation still needs its own byte/time/disk bounds.
|
||||
"""
|
||||
_reserve()
|
||||
admission = _OperationAdmission(os.getpid(), threading.get_ident())
|
||||
try:
|
||||
yield admission
|
||||
finally:
|
||||
admission.active = False
|
||||
# Fork resets the child's admission counter. An inherited context must
|
||||
# still expire its token, but cannot release the parent's reservation.
|
||||
if admission.process_id == os.getpid():
|
||||
_release()
|
||||
|
||||
|
||||
def _enter_admission(admission: _OperationAdmission) -> None:
|
||||
with _gate:
|
||||
if (
|
||||
not isinstance(admission, _OperationAdmission)
|
||||
or not admission.active or admission.running
|
||||
or admission.process_id != os.getpid()
|
||||
or admission.thread_id != threading.get_ident()
|
||||
):
|
||||
raise ValueError("Worker admission must be active, unused and owned by this thread/process.")
|
||||
admission.running = True
|
||||
|
||||
|
||||
def run_bounded_operation(
|
||||
operation: Callable[[bytes], bytes],
|
||||
payload: bytes,
|
||||
*,
|
||||
limits: ProcessLimits = ProcessLimits(),
|
||||
cancelled: Callable[[], bool] | None = None,
|
||||
admission: _OperationAdmission | None = None,
|
||||
) -> bytes:
|
||||
"""Run a trusted, importable module function without inheriting parent state.
|
||||
|
||||
Admission is non-queuing and per API/worker process. POSIX process groups,
|
||||
resource limits and waitid(WNOWAIT) are required; no in-process fallback.
|
||||
Output and stderr are drained incrementally, including while input is sent.
|
||||
Every exit path kills the owned process group before reaping its leader.
|
||||
"""
|
||||
if type(payload) is not bytes or len(payload) > limits.input_bytes:
|
||||
raise ProcessBudgetError("input_limit")
|
||||
if os.name != "posix" or not hasattr(os, "WNOWAIT") or not hasattr(os, "waitid"):
|
||||
raise ProcessBudgetError("unavailable")
|
||||
module = inspect.getmodule(operation)
|
||||
name = getattr(operation, "__name__", "")
|
||||
if (
|
||||
module is None or not name.isidentifier()
|
||||
or getattr(module, name, None) is not operation
|
||||
or not inspect.isfunction(operation) or not getattr(module, "__file__", None)
|
||||
):
|
||||
raise ValueError("Isolated operations must be server-owned module-level functions.")
|
||||
module_name = module.__name__
|
||||
if not all(part.isidentifier() for part in module_name.split(".")):
|
||||
raise ValueError("Invalid isolated operation module.")
|
||||
# Explicit source identity supports installed modules and editable development
|
||||
# checkouts without inheriting arbitrary PYTHONPATH or the parent's cwd.
|
||||
source = Path(module.__file__).resolve()
|
||||
source_root = source.parents[len(module_name.split(".")) - 1]
|
||||
if source.name == "__init__.py":
|
||||
source_root = source_root.parent
|
||||
_check_cancelled(cancelled)
|
||||
if admission is None:
|
||||
with bounded_operation_admission() as reserved:
|
||||
return run_bounded_operation(
|
||||
operation, payload, limits=limits, cancelled=cancelled, admission=reserved,
|
||||
)
|
||||
_enter_admission(admission)
|
||||
try:
|
||||
return _run(module_name, name, source_root, payload, limits, cancelled)
|
||||
finally:
|
||||
admission.running = False
|
||||
|
||||
|
||||
def _check_cancelled(cancelled: Callable[[], bool] | None) -> None:
|
||||
if cancelled is not None and cancelled():
|
||||
raise ProcessBudgetError("cancelled")
|
||||
|
||||
|
||||
def _run(
|
||||
module: str, name: str, source_root: Path, payload: bytes,
|
||||
limits: ProcessLimits, cancelled: Callable[[], bool] | None,
|
||||
) -> bytes:
|
||||
command = [
|
||||
sys.executable, "-I", "-B", "-m", "govoplan_core.security.process_worker",
|
||||
module, name, str(source_root), str(limits.cpu_seconds),
|
||||
str(limits.memory_bytes), str(limits.input_bytes), str(limits.output_bytes),
|
||||
str(limits.file_bytes),
|
||||
]
|
||||
deadline = time.monotonic() + limits.wall_seconds
|
||||
try:
|
||||
process = subprocess.Popen( # noqa: S603 - fixed interpreter/bootstrap, trusted operation.
|
||||
command, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
|
||||
bufsize=0, close_fds=True, start_new_session=True, cwd="/",
|
||||
env={
|
||||
"LANG": "C.UTF-8", "LC_ALL": "C.UTF-8", "TZ": "UTC",
|
||||
"OPENBLAS_NUM_THREADS": "1", "OMP_NUM_THREADS": "1",
|
||||
"MKL_NUM_THREADS": "1", "NUMEXPR_NUM_THREADS": "1",
|
||||
},
|
||||
)
|
||||
except OSError as exc:
|
||||
raise ProcessBudgetError("unavailable") from exc
|
||||
output = bytearray()
|
||||
sent = 0
|
||||
stderr_bytes = 0
|
||||
try:
|
||||
with selectors.DefaultSelector() as selector:
|
||||
for stream in (process.stdin, process.stdout, process.stderr):
|
||||
os.set_blocking(stream.fileno(), False)
|
||||
selector.register(process.stdout, selectors.EVENT_READ, "stdout")
|
||||
selector.register(process.stderr, selectors.EVENT_READ, "stderr")
|
||||
if payload:
|
||||
selector.register(process.stdin, selectors.EVENT_WRITE, "stdin")
|
||||
else:
|
||||
process.stdin.close()
|
||||
while True:
|
||||
_check_cancelled(cancelled)
|
||||
remaining = deadline - time.monotonic()
|
||||
if remaining <= 0:
|
||||
raise ProcessBudgetError("timeout")
|
||||
# WNOWAIT keeps the owned group leader's PID reserved until the
|
||||
# group is killed, including descendants that close their pipes.
|
||||
exited = os.waitid(os.P_PID, process.pid, os.WEXITED | os.WNOHANG | os.WNOWAIT)
|
||||
if exited is not None and not selector.get_map():
|
||||
break
|
||||
events = selector.select(min(remaining, 0.05))
|
||||
for key, _event in events:
|
||||
if key.data == "stdin":
|
||||
try:
|
||||
sent += os.write(key.fd, memoryview(payload)[sent:sent + 65536])
|
||||
except BrokenPipeError:
|
||||
sent = len(payload)
|
||||
except BlockingIOError:
|
||||
continue
|
||||
if sent == len(payload):
|
||||
selector.unregister(key.fileobj)
|
||||
key.fileobj.close()
|
||||
continue
|
||||
available = (
|
||||
limits.output_bytes - len(output)
|
||||
if key.data == "stdout" else _STDERR_LIMIT - stderr_bytes
|
||||
)
|
||||
try:
|
||||
chunk = os.read(key.fd, min(65536, available + 1))
|
||||
except BlockingIOError:
|
||||
continue
|
||||
if not chunk:
|
||||
selector.unregister(key.fileobj)
|
||||
elif len(chunk) > available:
|
||||
raise ProcessBudgetError("output_limit")
|
||||
elif key.data == "stdout":
|
||||
output.extend(chunk)
|
||||
else:
|
||||
# Never expose raw exception/log output to a client.
|
||||
stderr_bytes += len(chunk)
|
||||
exit_code = os.waitstatus_to_exitcode(
|
||||
(exited.si_status << 8) if exited.si_code == os.CLD_EXITED else exited.si_status
|
||||
)
|
||||
finally:
|
||||
# Do not communicate(): it would collect unbounded output during cleanup.
|
||||
try:
|
||||
os.killpg(process.pid, signal.SIGKILL)
|
||||
except ProcessLookupError:
|
||||
pass
|
||||
finally:
|
||||
for stream in (process.stdin, process.stdout, process.stderr):
|
||||
stream.close()
|
||||
process.wait()
|
||||
if exit_code != 0:
|
||||
code = {
|
||||
71: "memory_limit", 72: "input_limit", 73: "output_limit",
|
||||
74: "unavailable", -signal.SIGXCPU: "cpu_limit",
|
||||
-signal.SIGXFSZ: "output_limit",
|
||||
}.get(exit_code, "worker_failed")
|
||||
raise ProcessBudgetError(code)
|
||||
return bytes(output)
|
||||
@@ -0,0 +1,69 @@
|
||||
"""Private child entry point; resource controls precede module-owned imports."""
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
from io import BytesIO
|
||||
import os
|
||||
import sys
|
||||
|
||||
|
||||
def _limit(resource, kind: int, soft: int, hard: int | None = None) -> None:
|
||||
_old_soft, old_hard = resource.getrlimit(kind)
|
||||
selected_hard = soft if hard is None else hard
|
||||
if old_hard != resource.RLIM_INFINITY:
|
||||
selected_hard = min(selected_hard, old_hard)
|
||||
resource.setrlimit(kind, (min(soft, selected_hard), selected_hard))
|
||||
|
||||
|
||||
def _read_input(source, maximum: int) -> bytes | None:
|
||||
# read(maximum + 1) can reserve the entire configured cap for a tiny DTO.
|
||||
# Keep temporary reads small; BytesIO.getvalue() avoids a second full buffer
|
||||
# in CPython, and the input cap remains checked during collection.
|
||||
with BytesIO() as collected:
|
||||
total = 0
|
||||
while True:
|
||||
chunk = source.read(min(65536, maximum - total + 1))
|
||||
if not chunk:
|
||||
return collected.getvalue()
|
||||
total += len(chunk)
|
||||
if total > maximum:
|
||||
return None
|
||||
collected.write(chunk)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
module, name, source_root, cpu, memory, input_limit, output_limit, file_limit = sys.argv[1:]
|
||||
try:
|
||||
import resource
|
||||
|
||||
_limit(resource, resource.RLIMIT_CORE, 0)
|
||||
_limit(resource, resource.RLIMIT_AS, int(memory))
|
||||
_limit(resource, resource.RLIMIT_CPU, int(cpu), int(cpu) + 1)
|
||||
_limit(resource, resource.RLIMIT_FSIZE, int(file_limit))
|
||||
_limit(resource, resource.RLIMIT_NOFILE, 64)
|
||||
os.umask(0o077)
|
||||
except (ImportError, AttributeError, OSError, ValueError):
|
||||
return 74
|
||||
try:
|
||||
payload = _read_input(sys.stdin.buffer, int(input_limit))
|
||||
if payload is None:
|
||||
return 72
|
||||
sys.path.insert(0, source_root)
|
||||
operation = getattr(importlib.import_module(module), name)
|
||||
result = operation(payload)
|
||||
if type(result) is not bytes:
|
||||
return 70
|
||||
if len(result) > int(output_limit):
|
||||
return 73
|
||||
sys.stdout.buffer.write(result)
|
||||
sys.stdout.buffer.flush()
|
||||
return 0
|
||||
except MemoryError:
|
||||
return 71
|
||||
except BaseException:
|
||||
return 70
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Avoid arbitrary module atexit hooks extending completion beyond the budget.
|
||||
os._exit(main())
|
||||
@@ -0,0 +1,215 @@
|
||||
"""Bounded data-only binary transport for disposable workers.
|
||||
|
||||
Static tags and unsigned 32-bit scalar lengths/container counts are validated
|
||||
before allocation. No object hooks, imports, pickle, or JSON object graph is used.
|
||||
Operation owners still validate their own DTO after decoding these basic types.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date, datetime, time
|
||||
from decimal import Decimal, DecimalException
|
||||
import struct
|
||||
from uuid import UUID
|
||||
|
||||
_MAGIC = b"GWP\x01"
|
||||
_MAX_DEPTH = 64
|
||||
_MAX_NODES = 1_000_000
|
||||
_TEXT_CHUNK = 16 * 1024
|
||||
_U32 = struct.Struct(">I")
|
||||
_NULL, _FALSE, _TRUE = 0, 1, 2
|
||||
_STR, _BYTES, _INT, _FLOAT, _DECIMAL, _UUID = 3, 4, 5, 6, 7, 8
|
||||
_DATE, _DATETIME, _TIME, _TUPLE, _LIST, _DICT = 9, 10, 11, 12, 13, 14
|
||||
|
||||
|
||||
class WorkerPayloadError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
def _validate_limit(max_bytes: int) -> None:
|
||||
if type(max_bytes) is not int or not 0 <= max_bytes <= 0xFFFFFFFF:
|
||||
raise WorkerPayloadError("Invalid worker payload byte limit.")
|
||||
|
||||
|
||||
def encode_worker_payload(value: object, *, max_bytes: int = 32 * 1024 * 1024) -> bytes:
|
||||
_validate_limit(max_bytes)
|
||||
wire = bytearray()
|
||||
nodes = 0
|
||||
|
||||
def append(data: bytes) -> None:
|
||||
if len(data) > max_bytes - len(wire):
|
||||
raise WorkerPayloadError("Worker payload exceeds its byte limit.")
|
||||
wire.extend(data)
|
||||
|
||||
def scalar(tag: int, item: str | bytes) -> None:
|
||||
append(bytes((tag,)))
|
||||
length_position = len(wire)
|
||||
append(b"\x00\x00\x00\x00")
|
||||
value_position = len(wire)
|
||||
if type(item) is bytes:
|
||||
append(item)
|
||||
else:
|
||||
# UTF-8 needs at least one byte per code point. Never construct
|
||||
# a whole escaped or encoded copy before checking the byte cap.
|
||||
if len(item) > max_bytes - len(wire):
|
||||
raise WorkerPayloadError("Worker payload exceeds its byte limit.")
|
||||
for offset in range(0, len(item), _TEXT_CHUNK):
|
||||
append(item[offset : offset + _TEXT_CHUNK].encode("utf-8"))
|
||||
_U32.pack_into(wire, length_position, len(wire) - value_position)
|
||||
|
||||
def encode(item: object, depth: int = 0) -> None:
|
||||
nonlocal nodes
|
||||
nodes += 1
|
||||
if depth > _MAX_DEPTH or nodes > _MAX_NODES:
|
||||
raise WorkerPayloadError("Worker payload exceeds its structural limit.")
|
||||
kind = type(item)
|
||||
if item is None:
|
||||
append(bytes((_NULL,)))
|
||||
elif kind is bool:
|
||||
append(bytes((_TRUE if item else _FALSE,)))
|
||||
elif kind is str:
|
||||
scalar(_STR, item)
|
||||
elif kind is bytes:
|
||||
scalar(_BYTES, item)
|
||||
elif kind is int:
|
||||
length = (item.bit_length() + 8) // 8
|
||||
if length > max_bytes - len(wire) - 5:
|
||||
raise WorkerPayloadError("Worker payload exceeds its byte limit.")
|
||||
scalar(_INT, item.to_bytes(length, "big", signed=True))
|
||||
elif kind is float:
|
||||
scalar(_FLOAT, struct.pack(">d", item))
|
||||
elif kind is Decimal:
|
||||
# C Decimal uses at least 8 bytes per 19 coefficient digits.
|
||||
# This conservative bound includes inline digits and exponent text,
|
||||
# avoiding an unbounded str() or as_tuple() allocation in the parent.
|
||||
if item.__sizeof__() * 3 + 64 > max_bytes - len(wire):
|
||||
raise WorkerPayloadError("Worker payload exceeds its byte limit.")
|
||||
scalar(_DECIMAL, str(item))
|
||||
elif kind is UUID:
|
||||
scalar(_UUID, item.bytes)
|
||||
elif kind is date:
|
||||
scalar(_DATE, item.isoformat())
|
||||
elif kind is datetime:
|
||||
scalar(_DATETIME, item.isoformat())
|
||||
elif kind is time:
|
||||
scalar(_TIME, item.isoformat())
|
||||
elif kind in (tuple, list, dict):
|
||||
count = len(item)
|
||||
children = count * (2 if kind is dict else 1)
|
||||
if count > 0xFFFFFFFF or children > _MAX_NODES - nodes:
|
||||
raise WorkerPayloadError("Worker payload exceeds its structural limit.")
|
||||
if children > max_bytes - len(wire) - 5:
|
||||
raise WorkerPayloadError("Worker payload exceeds its byte limit.")
|
||||
append(bytes(({tuple: _TUPLE, list: _LIST, dict: _DICT}[kind],)))
|
||||
append(_U32.pack(count))
|
||||
if kind is dict:
|
||||
for key, child in item.items():
|
||||
if type(key) is not str:
|
||||
raise WorkerPayloadError("Worker mapping keys must be strings.")
|
||||
encode(key, depth + 1)
|
||||
encode(child, depth + 1)
|
||||
else:
|
||||
for child in item:
|
||||
encode(child, depth + 1)
|
||||
else:
|
||||
raise WorkerPayloadError("Unsupported worker payload value type.")
|
||||
|
||||
try:
|
||||
append(_MAGIC)
|
||||
encode(value)
|
||||
return bytes(wire)
|
||||
except (ValueError, OverflowError, RecursionError, DecimalException) as exc:
|
||||
if isinstance(exc, WorkerPayloadError):
|
||||
raise
|
||||
raise WorkerPayloadError("Invalid worker payload value.") from exc
|
||||
|
||||
|
||||
def decode_worker_payload(
|
||||
payload: bytes, *, max_bytes: int = 32 * 1024 * 1024
|
||||
) -> object:
|
||||
_validate_limit(max_bytes)
|
||||
if type(payload) is not bytes or len(payload) > max_bytes:
|
||||
raise WorkerPayloadError("Worker payload exceeds its byte limit.")
|
||||
if not payload.startswith(_MAGIC):
|
||||
raise WorkerPayloadError("Unknown worker payload format.")
|
||||
wire = memoryview(payload)
|
||||
cursor = len(_MAGIC)
|
||||
nodes = 0
|
||||
|
||||
def take(size: int) -> memoryview:
|
||||
nonlocal cursor
|
||||
if size > len(wire) - cursor:
|
||||
raise WorkerPayloadError("Truncated worker payload.")
|
||||
start = cursor
|
||||
cursor += size
|
||||
return wire[start:cursor]
|
||||
|
||||
def decode(depth: int = 0) -> object:
|
||||
nonlocal nodes
|
||||
nodes += 1
|
||||
if depth > _MAX_DEPTH or nodes > _MAX_NODES:
|
||||
raise WorkerPayloadError("Worker payload exceeds its structural limit.")
|
||||
tag = take(1)[0]
|
||||
if tag == _NULL:
|
||||
return None
|
||||
if tag in (_FALSE, _TRUE):
|
||||
return tag == _TRUE
|
||||
if tag not in range(_STR, _DICT + 1):
|
||||
raise WorkerPayloadError("Unknown worker payload tag.")
|
||||
length = _U32.unpack(take(4))[0]
|
||||
if tag in (_TUPLE, _LIST, _DICT):
|
||||
children = length * (2 if tag == _DICT else 1)
|
||||
if children > _MAX_NODES - nodes or children > len(wire) - cursor:
|
||||
raise WorkerPayloadError("Invalid worker container count.")
|
||||
if tag == _DICT:
|
||||
result = {}
|
||||
for _index in range(length):
|
||||
key = decode(depth + 1)
|
||||
if type(key) is not str or key in result:
|
||||
raise WorkerPayloadError(
|
||||
"Invalid or duplicate worker mapping key."
|
||||
)
|
||||
result[key] = decode(depth + 1)
|
||||
return result
|
||||
result = [decode(depth + 1) for _index in range(length)]
|
||||
return tuple(result) if tag == _TUPLE else result
|
||||
value = take(length)
|
||||
if tag == _BYTES:
|
||||
return bytes(value)
|
||||
if tag == _INT:
|
||||
if not length:
|
||||
raise WorkerPayloadError("Invalid worker integer.")
|
||||
return int.from_bytes(value, "big", signed=True)
|
||||
if tag == _FLOAT:
|
||||
if length != 8:
|
||||
raise WorkerPayloadError("Invalid worker float.")
|
||||
return struct.unpack(">d", value)[0]
|
||||
if tag == _UUID:
|
||||
if length != 16:
|
||||
raise WorkerPayloadError("Invalid worker UUID.")
|
||||
return UUID(bytes=bytes(value))
|
||||
text = str(value, "utf-8")
|
||||
decoders = {
|
||||
_STR: lambda item: item,
|
||||
_DECIMAL: Decimal,
|
||||
_DATE: date.fromisoformat,
|
||||
_DATETIME: datetime.fromisoformat,
|
||||
_TIME: time.fromisoformat,
|
||||
}
|
||||
return decoders[tag](text)
|
||||
|
||||
try:
|
||||
result = decode()
|
||||
if cursor != len(wire):
|
||||
raise WorkerPayloadError("Trailing worker payload data.")
|
||||
return result
|
||||
except (
|
||||
ValueError,
|
||||
TypeError,
|
||||
RecursionError,
|
||||
OverflowError,
|
||||
DecimalException,
|
||||
) as exc:
|
||||
if isinstance(exc, WorkerPayloadError):
|
||||
raise
|
||||
raise WorkerPayloadError("Invalid worker payload.") from exc
|
||||
@@ -40,6 +40,12 @@ class Settings(BaseSettings):
|
||||
le=1000,
|
||||
alias="GOVOPLAN_EXPECTED_WORKER_REPLICAS",
|
||||
)
|
||||
isolated_process_concurrency: int = Field(
|
||||
default=1,
|
||||
ge=1,
|
||||
le=16,
|
||||
alias="GOVOPLAN_ISOLATED_PROCESS_CONCURRENCY",
|
||||
)
|
||||
module_live_apply_enabled: bool | None = Field(
|
||||
default=None,
|
||||
alias="GOVOPLAN_MODULE_LIVE_APPLY_ENABLED",
|
||||
@@ -214,6 +220,11 @@ class Settings(BaseSettings):
|
||||
alias="TENANT_MODULE_ENTITLEMENT_CACHE_MAX_ENTRIES",
|
||||
)
|
||||
auth_login_throttle_enabled: bool = Field(default=True, alias="AUTH_LOGIN_THROTTLE_ENABLED")
|
||||
# Enable only after the administrator-assisted identity-verification and
|
||||
# recovery-code handoff policy has been adopted for this installation.
|
||||
auth_local_password_recovery_enabled: bool = Field(
|
||||
default=False, alias="AUTH_LOCAL_PASSWORD_RECOVERY_ENABLED"
|
||||
)
|
||||
auth_login_throttle_identity_limit: int = Field(
|
||||
default=10,
|
||||
ge=1,
|
||||
|
||||
Reference in New Issue
Block a user