feat(security): isolate bounded work and support required auth actions

This commit is contained in:
2026-09-08 07:47:17 +02:00
parent a6d056a3df
commit dc1f244f17
23 changed files with 1628 additions and 21 deletions
+4
View File
@@ -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
+11
View File
@@ -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,