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
+81
View File
@@ -0,0 +1,81 @@
# Disposable resource-bounded operations
`security.bounded_process.run_bounded_operation` runs a trusted, importable,
module-level `bytes -> bytes` function in a fresh interpreter. Core owns the
process lifecycle, not the business parser. Owners retain authorization,
sessions, provider reads, idempotency and persistence in the parent and pass
only explicit bounded data. Never accept the operation, module or source path
from a client. Use `security.worker_payload` for typed values; it does not use
pickle, arbitrary constructors or JSON object hooks.
The runner requires POSIX process groups, `waitid(WNOWAIT)` and resource limits.
Unsupported controls fail closed; there is no in-process fallback. The child
uses `-I -B`, a fixed minimal environment, `/` as working directory, closed
inherited descriptors and a new process session. Limits are installed before
the owning module is imported. Installed dependencies must support isolated
Python imports; development `PYTHONPATH` alone is insufficient.
`ProcessLimits` specifies wall-clock seconds (including child startup), CPU
seconds, virtual address space, input/output pipe bytes and maximum regular-file
size. Defaults are 10 seconds wall/CPU, 256 MiB address space, 8 MiB input and
output, and no regular-file output. Wall/CPU limits are at most 600 seconds,
memory 64 MiB8 GiB, pipe limits 1 byte256 MiB, and file size 02 GiB. Owners
must document their tighter functional limits; a transport cap does not replace
row, archive expansion, item-count or artifact limits.
Admission is non-queuing. `GOVOPLAN_ISOLATED_PROCESS_CONCURRENCY` defaults to 1
(range 116) and applies across these operations **within each API/worker
process**. Multiply capacity and memory budgets by the number of API/worker
processes when sizing an installation. This is not a fleet-wide semaphore,
cgroup quota, filesystem/network sandbox or permission to run arbitrary code.
`RLIMIT_FSIZE` is per file, not a total disk quota. Owners creating staged files
must enforce cumulative quotas and clean up their own private directories.
Owners preparing bounded local snapshots can enter
`bounded_operation_admission()` before preparation and pass its token as
`admission=` to the runner. This reuses shared capacity rather than reserving a
second slot. Tokens belong to their active context, thread and process; expired,
cross-thread and overlapping reuse fail. Preparation exceptions release the
slot without launching a child. Never hold admission while waiting for a user;
parent-side preparation still requires explicit I/O and byte bounds.
The parent concurrently drains stdout/stderr while writing input. Output is
bounded during reading, stderr is discarded and capped at 64 KiB, and raw child
tracebacks are never returned. Every success, exception, timeout, cancellation
and callback failure kills the owned process group before reaping its leader,
including descendants which close their inherited pipes. A module-level child
handler must return bytes; it must not print logs/progress to stdout.
The optional `cancelled` callback runs in the parent at most roughly every
50 ms while waiting. It must be fast and must not return an awaitable. It may
also service a module-owned bounded progress protocol; exceptions terminate
the child and propagate. There is no fabricated progress for killed work.
`ProcessBudgetError.code` distinguishes busy, cancelled, timeout, CPU, memory,
input/output limits, unavailable controls and worker failure. Owners map these
to their existing structured diagnostics and recovery semantics.
The private typed-data codec supports null, booleans, strings, bytes, integers,
floats, Decimal, UUID, date/time/datetime, lists, tuples and string-keyed maps.
It rejects unsupported objects, malformed/trailing bytes, duplicate keys,
excess depth (64) and node counts (1,000,000). Operation DTOs remain owner
contracts and require owner validation. Do not persist this private wire format
or use it as a public API.
Tests use real child processes for catastrophic regex, memory exhaustion,
noisy output, exact limits, cancellation, closed-pipe hangs and descendant
cleanup. These are local process regression tests, not production concurrent
load certification. Operators still need target Linux/cgroup, cancellation,
worker-count, memory and disk-quota evidence before raising concurrency.
## Deutsche Betriebszusammenfassung
Rechenintensive, vertrauenswürdige Moduloperationen laufen in einem frischen
Prozess mit harten Laufzeit-, CPU-, Speicher- und Ausgabegrenzen. Berechtigungen,
Sitzungen, Zugangsdaten und Datenbankänderungen bleiben im Hauptprozess. Fehlende
Betriebssystemkontrollen führen zu einer Diagnose, nicht zu ungeschützter
Ausführung. `GOVOPLAN_ISOLATED_PROCESS_CONCURRENCY` begrenzt die gemeinsame
Zulassung je API-/Worker-Prozess, standardmäßig auf 1. Mehrere Prozesse haben
jeweils eigene Grenzen; systemweite Speicher- und Festplattenquoten müssen
Betreiber zusätzlich konfigurieren und auf der Zielinstallation prüfen. Die
Schnittstelle ist keine Sandbox für beliebigen Code. Modul-Dokumentation nennt
die jeweiligen fachlichen Grenzen, Fortschritts- und Wiederholungsregeln.
+2
View File
@@ -13,3 +13,5 @@ tools/checks/security-audit/run.sh --mode full --scope govoplan
Canonical documentation:
- `/mnt/DATA/git/govoplan/docs/operations/SECURITY_AUDIT.md`
Implementation contract: [disposable resource-bounded operations](BOUNDED_PROCESS_CONTRACT.md).
+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,
+69
View File
@@ -0,0 +1,69 @@
"""Synthetic operations, imported only by isolated tests; no application effects."""
from __future__ import annotations
import json
import os
import re
import subprocess
import sys
import time
def echo(payload: bytes) -> bytes:
return payload
def wait(payload: bytes) -> bytes:
time.sleep(float(payload))
return b"done"
def regex_stall(_payload: bytes) -> bytes:
re.fullmatch(r"(a+)+$", "a" * 100 + "!")
return b"unreachable"
def allocate(_payload: bytes) -> bytes:
return b"x" * (256 * 1024 * 1024)
def too_much_stdout(_payload: bytes) -> bytes:
while True:
os.write(1, b"x" * 65536)
def too_much_stderr(_payload: bytes) -> bytes:
while True:
os.write(2, b"sensitive synthetic log" * 4096)
def fail(_payload: bytes) -> bytes:
raise ValueError("private synthetic data must not become an error response")
def close_pipes_then_wait(_payload: bytes) -> bytes:
os.close(1)
os.close(2)
time.sleep(30)
return b""
def observe(_payload: bytes) -> bytes:
import resource
return json.dumps({
"pid": os.getpid(), "pgid": os.getpgrp(), "sid": os.getsid(0),
"cpu": resource.getrlimit(resource.RLIMIT_CPU),
"memory": resource.getrlimit(resource.RLIMIT_AS),
"file": resource.getrlimit(resource.RLIMIT_FSIZE),
"core": resource.getrlimit(resource.RLIMIT_CORE),
"env": sorted(os.environ), "cwd": os.getcwd(),
}).encode()
def child_with_closed_pipes(_payload: bytes) -> bytes:
child = subprocess.Popen(
[sys.executable, "-I", "-c", "import time; time.sleep(30)"],
stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
)
return str(child.pid).encode()
+50 -6
View File
@@ -8,10 +8,11 @@ import tempfile
import time
import unittest
import zipfile
from datetime import datetime, timezone
from datetime import datetime, timedelta, timezone
from email import policy
from email.parser import BytesParser
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import patch
import pyzipper
@@ -3545,13 +3546,17 @@ class ApiSmokeTests(unittest.TestCase):
self.assertEqual(len(jobs.json()["jobs"]), 1)
job_summary = jobs.json()["jobs"][0]
self.assertEqual(job_summary["campaign_version_id"], version_id)
self.assertNotIn("resolved_recipients", job_summary)
self.assertEqual(job_summary["resolved_recipients"]["to"][0]["email"], "recipient@example.org")
self.assertNotIn("attachments", job_summary)
detail = self.client.get(
f"/api/v1/campaigns/{campaign_id}/jobs/{job_summary['id']}",
headers=headers,
)
self.assertEqual(detail.status_code, 200, detail.text)
job = detail.json()["job"]
self.assertEqual(job_summary["resolved_recipients"], {
kind: job["resolved_recipients"][kind] for kind in ("to", "cc", "bcc")
})
self.assertEqual(job["resolved_recipients"]["to"][0]["email"], "recipient@example.org")
self.assertEqual(
{
@@ -4613,7 +4618,9 @@ class ApiSmokeTests(unittest.TestCase):
self.assertEqual(queued_summary.json()["status_counts"]["send"]["queued"], 2)
from govoplan_campaign.backend.db.models import Campaign, CampaignJob, CampaignVersion, SendAttempt
from govoplan_campaign.backend.sending.jobs import send_campaign_job
from govoplan_campaign.backend.sending.jobs import _begin_job_delivery_recovery, send_campaign_job
from govoplan_campaign.backend.services.delivery_recovery import job_recovery_metadata
from govoplan_core.core.runtime_coordination import DistributedLease, RuntimeNode, process_runtime_identity
with SessionLocal() as session:
jobs = (
@@ -4645,7 +4652,42 @@ class ApiSmokeTests(unittest.TestCase):
with SessionLocal() as session:
result = send_campaign_job(session, job_id=uncertain_job_id, use_rate_limit=False)
self.assertEqual(result.status, "outcome_unknown")
# Observing SENDING is not proof that its owner has stopped.
self.assertEqual(result.status, "already_sending")
job = session.get(CampaignJob, uncertain_job_id)
version = session.get(CampaignVersion, version_id)
recovery = _begin_job_delivery_recovery(
job=job, context=SimpleNamespace(version=version), claim_token=job.claim_token,
)
self.assertTrue(recovery.operation_id)
session.expire_all()
lease = session.query(DistributedLease).filter(
DistributedLease.resource_key == f"campaign:delivery:{job.tenant_id}:{job.id}",
).one()
lease.holder_node_id = "smoke-stopped-worker"
lease.holder_incarnation = "smoke-old-incarnation"
lease.expires_at = datetime.now(timezone.utc) - timedelta(minutes=1)
session.add(RuntimeNode(
installation_id=process_runtime_identity().installation_id,
node_id="smoke-stopped-worker", incarnation="smoke-old-incarnation",
role="worker", software_version="test", composition_hash="c" * 64,
state="stopped",
))
session.commit()
# Read the committed representation, just as an independent HTTP
# reader does (SQLite drops timezone objects during persistence).
session.expire_all()
metadata = job_recovery_metadata(session, [job])[job.id]["smtp"]
self.assertTrue(metadata["eligible"])
recovered = self.client.post(
f"/api/v1/campaigns/{campaign_id}/jobs/{uncertain_job_id}/recover-claim",
headers=headers,
json={"channel": "smtp", "expected_revision": metadata["revision"],
"note": "Fixture worker is confirmed stopped; inspect provider evidence next."},
)
self.assertEqual(recovered.status_code, 200, recovered.text)
self.assertTrue(recovered.json()["result"]["reconciliation_required"])
retry_unknown = self.client.post(
f"/api/v1/campaigns/{campaign_id}/jobs/retry",
@@ -4672,7 +4714,8 @@ class ApiSmokeTests(unittest.TestCase):
self.assertEqual(page.json()["total"], 2)
self.assertEqual(page.json()["pages"], 2)
self.assertEqual(page.json()["counts"]["send"]["outcome_unknown"], 1)
self.assertNotIn("resolved_recipients", page.json()["jobs"][0])
self.assertIn("resolved_recipients", page.json()["jobs"][0])
self.assertNotIn("attachments", page.json()["jobs"][0])
filtered_page = self.client.get(
f"/api/v1/campaigns/{campaign_id}/jobs",
@@ -4851,7 +4894,8 @@ class ApiSmokeTests(unittest.TestCase):
self.assertEqual(first_jobs.json()["total_unfiltered"], 1)
self.assertEqual(first_jobs.json()["review"]["required_count"], 0)
self.assertIn("reviewed", first_jobs.json()["jobs"][0])
self.assertNotIn("resolved_recipients", first_jobs.json()["jobs"][0])
self.assertEqual(first_jobs.json()["jobs"][0]["resolved_recipients"]["to"][0]["email"], "recipient-1@example.org")
self.assertNotIn("attachments", first_jobs.json()["jobs"][0])
first_csv = self.client.get(
f"/api/v1/campaigns/{campaign_id}/report/jobs.csv",
+326
View File
@@ -0,0 +1,326 @@
from __future__ import annotations
from concurrent.futures import ThreadPoolExecutor
from dataclasses import replace
from datetime import date, datetime, time as daytime, timezone
from decimal import Decimal
from io import BytesIO
import json
import os
from pathlib import Path
import subprocess
import threading
import time
import unittest
from unittest.mock import patch
from uuid import UUID
from govoplan_core.security import bounded_process
from govoplan_core.security.bounded_process import (
ProcessBudgetError, ProcessLimits, bounded_operation_admission, run_bounded_operation,
)
from govoplan_core.security.worker_payload import (
WorkerPayloadError, decode_worker_payload, encode_worker_payload,
)
from govoplan_core.settings import settings
from govoplan_core.security.process_worker import _read_input
from tests import bounded_process_fixtures as operations
class BoundedProcessTests(unittest.TestCase):
def setUp(self) -> None:
self.processes = []
original = subprocess.Popen
def start(*args, **kwargs):
process = original(*args, **kwargs)
self.processes.append(process)
return process
self.patcher = patch.object(bounded_process.subprocess, "Popen", start)
self.patcher.start()
self.addCleanup(self.patcher.stop)
self.addCleanup(self.assert_reaped)
def assert_reaped(self) -> None:
for process in self.processes:
self.assertIsNotNone(process.returncode)
self.assertTrue(all(stream.closed for stream in (process.stdin, process.stdout, process.stderr)))
with self.assertRaises(ChildProcessError):
os.waitpid(process.pid, os.WNOHANG)
self.assertEqual(bounded_process._active, 0)
def test_roundtrip_empty_and_pipe_sized_input_exact_output_limit(self) -> None:
for payload in (b"", b"x" * 200_000):
with self.subTest(length=len(payload)):
result = run_bounded_operation(operations.echo, payload, limits=ProcessLimits(output_bytes=max(1, len(payload))))
self.assertEqual(result, payload)
def test_controls_and_environment_are_applied_before_operation(self) -> None:
with patch.dict(os.environ, {"DATABASE_URL": "synthetic-secret", "PYTHONPATH": "/untrusted", "SYNTHETIC_SECRET": "no"}):
result = json.loads(run_bounded_operation(operations.observe, b"", limits=ProcessLimits(cpu_seconds=3)))
self.assertEqual(result["pid"], result["pgid"])
self.assertEqual(result["pid"], result["sid"])
self.assertNotEqual(result["pid"], os.getpid())
self.assertEqual(result["cpu"], [3, 4])
self.assertEqual(result["memory"], [256 * 1024 * 1024] * 2)
self.assertEqual(result["file"], [0, 0])
self.assertEqual(result["core"], [0, 0])
self.assertEqual(result["cwd"], "/")
self.assertFalse({"DATABASE_URL", "PYTHONPATH", "SYNTHETIC_SECRET"} & set(result["env"]))
def test_real_regex_cpu_is_stopped_before_long_wall_limit(self) -> None:
started = time.monotonic()
with self.assertRaises(ProcessBudgetError) as raised:
run_bounded_operation(operations.regex_stall, b"", limits=ProcessLimits(cpu_seconds=1, wall_seconds=5))
self.assertEqual(raised.exception.code, "cpu_limit")
self.assertLess(time.monotonic() - started, 4)
def test_memory_failure_never_allocates_the_large_result_in_parent(self) -> None:
with self.assertRaises(ProcessBudgetError) as raised:
run_bounded_operation(operations.allocate, b"", limits=ProcessLimits(memory_bytes=64 * 1024 * 1024))
self.assertEqual(raised.exception.code, "memory_limit")
def test_noisy_stdout_and_stderr_are_bounded_during_execution(self) -> None:
for operation in (operations.too_much_stdout, operations.too_much_stderr):
with self.subTest(operation=operation.__name__):
with self.assertRaises(ProcessBudgetError) as raised:
run_bounded_operation(operation, b"", limits=ProcessLimits(output_bytes=1024, wall_seconds=3))
self.assertEqual(raised.exception.code, "output_limit")
def test_sleep_and_closed_pipe_hangs_are_timed_out(self) -> None:
for operation in (operations.wait, operations.close_pipes_then_wait):
with self.subTest(operation=operation.__name__):
with self.assertRaises(ProcessBudgetError) as raised:
run_bounded_operation(operation, b"30", limits=ProcessLimits(wall_seconds=0.3))
self.assertEqual(raised.exception.code, "timeout")
def test_cancellation_kills_and_reaps(self) -> None:
started = time.monotonic()
with self.assertRaises(ProcessBudgetError) as raised:
run_bounded_operation(operations.wait, b"30", cancelled=lambda: time.monotonic() - started > 0.2)
self.assertEqual(raised.exception.code, "cancelled")
def test_cancellation_callback_exception_also_cleans_up(self) -> None:
calls = 0
def cancel():
nonlocal calls
calls += 1
if calls > 2:
raise KeyboardInterrupt
return False
with self.assertRaises(KeyboardInterrupt):
run_bounded_operation(operations.wait, b"30", cancelled=cancel)
def test_failure_does_not_return_child_exception_or_partial_output(self) -> None:
with self.assertRaises(ProcessBudgetError) as raised:
run_bounded_operation(operations.fail, b"")
self.assertEqual(raised.exception.code, "worker_failed")
self.assertNotIn("private", str(raised.exception))
def test_descendant_is_stopped_even_after_successful_leader_exit(self) -> None:
pid = int(run_bounded_operation(operations.child_with_closed_pipes, b""))
deadline = time.monotonic() + 2
while time.monotonic() < deadline:
try:
state = Path(f"/proc/{pid}/stat").read_text().split(") ", 1)[1].split()[0]
except (FileNotFoundError, ProcessLookupError):
return
if state == "Z":
return # stopped, awaiting the operating system's orphan reaper
time.sleep(0.01)
self.fail("Descendant is still running after its owned group was cleaned up.")
def test_admission_is_bounded_and_releases_capacity(self) -> None:
entered = threading.Event()
def pending():
return run_bounded_operation(operations.wait, b"0.4", cancelled=lambda: entered.set() and False)
with patch.object(settings, "isolated_process_concurrency", 1), ThreadPoolExecutor(max_workers=2) as executor:
future = executor.submit(pending)
entered.wait(1)
deadline = time.monotonic() + 1
while bounded_process._active == 0 and time.monotonic() < deadline:
time.sleep(0.001)
with self.assertRaises(ProcessBudgetError) as raised:
run_bounded_operation(operations.echo, b"denied")
self.assertEqual(raised.exception.code, "busy")
self.assertEqual(future.result(), b"done")
self.assertEqual(run_bounded_operation(operations.echo, b"after"), b"after")
def test_invalid_inputs_never_spawn_a_child(self) -> None:
with self.assertRaises(ProcessBudgetError):
run_bounded_operation(operations.echo, b"too large", limits=ProcessLimits(input_bytes=1))
with self.assertRaises(ValueError):
run_bounded_operation(lambda value: value, b"")
self.assertEqual(self.processes, [])
for changes in ({"wall_seconds": float("nan")}, {"wall_seconds": float("inf")}, {"cpu_seconds": True}, {"memory_bytes": 1}):
with self.subTest(changes=changes), self.assertRaises(ValueError):
replace(ProcessLimits(), **changes)
def test_explicit_admission_covers_preparation_and_can_be_reused_sequentially(self) -> None:
with patch.object(settings, "isolated_process_concurrency", 1):
with bounded_operation_admission() as admission:
self.assertEqual(bounded_process._active, 1)
with self.assertRaises(ProcessBudgetError) as busy:
with bounded_operation_admission():
self.fail("Preparation should not start without shared capacity.")
self.assertEqual(busy.exception.code, "busy")
self.assertEqual(run_bounded_operation(operations.echo, b"first", admission=admission), b"first")
self.assertEqual(run_bounded_operation(operations.echo, b"second", admission=admission), b"second")
self.assertEqual(bounded_process._active, 0)
with self.assertRaises(ValueError):
run_bounded_operation(operations.echo, b"expired", admission=admission)
def test_explicit_admission_rejects_other_threads_and_overlapping_reuse(self) -> None:
with bounded_operation_admission() as admission:
with ThreadPoolExecutor(max_workers=1) as executor:
future = executor.submit(run_bounded_operation, operations.echo, b"wrong thread", admission=admission)
with self.assertRaises(ValueError):
future.result()
nested = False
def poll():
nonlocal nested
if admission.running and not nested:
nested = True
with self.assertRaises(ValueError):
run_bounded_operation(operations.echo, b"overlap", admission=admission)
return False
self.assertEqual(run_bounded_operation(operations.wait, b"0.1", admission=admission, cancelled=poll), b"done")
self.assertTrue(nested)
def test_preparation_failure_releases_capacity_without_spawning(self) -> None:
with self.assertRaisesRegex(RuntimeError, "prepare"):
with bounded_operation_admission():
raise RuntimeError("prepare")
self.assertEqual(bounded_process._active, 0)
self.assertEqual(self.processes, [])
@unittest.skipUnless(hasattr(os, "fork"), "Fork ownership requires POSIX fork")
def test_inherited_admission_expires_without_releasing_child_capacity(self) -> None:
read_fd, write_fd = os.pipe()
try:
with bounded_operation_admission() as admission:
child_pid = os.fork()
if child_pid == 0:
os.close(read_fd)
if child_pid == 0:
try:
os.write(write_fd, json.dumps({
"active": bounded_process._active,
"token_active": admission.active,
}).encode())
finally:
os.close(write_fd)
os._exit(0)
os.close(write_fd)
write_fd = None
observation = json.loads(os.read(read_fd, 256))
_, status = os.waitpid(child_pid, 0)
self.assertEqual(0, os.waitstatus_to_exitcode(status))
self.assertEqual({"active": 0, "token_active": False}, observation)
self.assertEqual(0, bounded_process._active)
finally:
os.close(read_fd)
if write_fd is not None:
os.close(write_fd)
def test_worker_stdin_reads_are_incremental_instead_of_allocating_the_cap(self) -> None:
class ObservedInput(BytesIO):
def read(self, size=-1):
self_test.assertLessEqual(size, 65536)
return super().read(size)
self_test = self
self.assertEqual(_read_input(ObservedInput(b"tiny"), 64 * 1024 * 1024), b"tiny")
self.assertEqual(_read_input(ObservedInput(b"x" * 100000), 100000), b"x" * 100000)
self.assertIsNone(_read_input(ObservedInput(b"x" * 100001), 100000))
class WorkerPayloadTests(unittest.TestCase):
def test_roundtrip_explicit_types_and_user_keys_cannot_impersonate_tags(self) -> None:
value = {"str": ["bytes", "not transport"], "values": (
None, True, 3, 1.25, Decimal("1.2500"), b"\x00\xff", date(2026, 9, 8),
datetime(2026, 9, 8, tzinfo=timezone.utc), daytime(12, 30), UUID(int=4),
)}
self.assertEqual(decode_worker_payload(encode_worker_payload(value)), value)
def test_rejects_arbitrary_objects_duplicate_keys_and_invalid_tags(self) -> None:
with self.assertRaises(WorkerPayloadError):
encode_worker_payload(object())
key = encode_worker_payload("a")[4:]
duplicate_keys = b"GWP\x01\x0e\x00\x00\x00\x02" + (key + b"\x00") * 2
for wire in (b'["pickle","payload"]', duplicate_keys, b"GWP\x01\xff", b"GWP\x01\x03\x00\x00\x00\x01\xff"):
with self.subTest(wire=wire), self.assertRaises(WorkerPayloadError):
decode_worker_payload(wire)
def test_transport_depth_and_byte_limits(self) -> None:
with self.assertRaises(WorkerPayloadError):
encode_worker_payload("x" * 1000, max_bytes=100)
with self.assertRaises(WorkerPayloadError):
decode_worker_payload(b" " * 1000, max_bytes=100)
value = []
for _index in range(66):
value = [value]
with self.assertRaises(WorkerPayloadError):
encode_worker_payload(value)
def test_astral_unicode_uses_utf8_bytes_and_exact_byte_caps(self) -> None:
value = "\U0001f30d" * 32_769
limit = 4 + 5 + len(value) * 4
wire = encode_worker_payload(value, max_bytes=limit)
self.assertEqual(len(wire), limit)
self.assertEqual(value, decode_worker_payload(wire, max_bytes=limit))
with self.assertRaises(WorkerPayloadError):
encode_worker_payload(value, max_bytes=limit - 1)
with self.assertRaises(WorkerPayloadError):
decode_worker_payload(wire, max_bytes=limit - 1)
def test_unicode_limit_is_checked_without_whole_encoded_temporary(self) -> None:
import tracemalloc
value = "\U0001f30d" * 900_000
tracemalloc.start()
try:
with self.assertRaises(WorkerPayloadError):
encode_worker_payload(value, max_bytes=1_000_000)
_current, peak = tracemalloc.get_traced_memory()
finally:
tracemalloc.stop()
self.assertLess(peak, 2_000_000)
def test_malformed_counts_depth_and_trailing_data_fail_before_children(self) -> None:
import struct
deep = b"GWP\x01" + (b"\x0d" + struct.pack(">I", 1)) * 66 + b"\x00"
for wire in (
b"GWP\x01\x0d" + struct.pack(">I", 0xFFFFFFFF),
b"GWP\x01\x0e" + struct.pack(">I", 0xFFFFFFFF),
b"GWP\x01\x03" + struct.pack(">I", 0xFFFFFFFF),
deep,
encode_worker_payload(None) + b"\x00",
b"GWP\x01\x05\x00\x00\x00\x00",
b"GWP\x01\x06\x00\x00\x00\x01x",
b"GWP\x01\x08\x00\x00\x00\x01x",
):
with self.subTest(wire=wire[:20]), self.assertRaises(WorkerPayloadError):
decode_worker_payload(wire)
def test_decoder_checks_node_budget_before_allocating_container(self) -> None:
from govoplan_core.security import worker_payload
wire = encode_worker_payload([None] * 20)
with patch.object(worker_payload, "_MAX_NODES", 10):
with self.assertRaises(WorkerPayloadError):
decode_worker_payload(wire)
def test_large_signed_integers_roundtrip_and_decimal_errors_are_normalized(self) -> None:
for value in (0, -1, 127, 128, -128, -129, 1 << 20_000, -(1 << 20_000)):
with self.subTest(bits=value.bit_length()):
self.assertEqual(value, decode_worker_payload(encode_worker_payload(value)))
with self.assertRaises(WorkerPayloadError):
decode_worker_payload(b"GWP\x01\x07\x00\x00\x00\x07invalid")
with self.assertRaises(WorkerPayloadError):
encode_worker_payload(1 << 20_000, max_bytes=100)
+3
View File
@@ -28,6 +28,7 @@ import CampaignBulkReviewScenario from "./CampaignBulkReviewScenario";
import CampaignReviewDetailsScenario from "./CampaignReviewDetailsScenario";
import CampaignDeliveryPolicyScenario from "./CampaignDeliveryPolicyScenario";
import MailCredentialPolicyScenario from "./MailCredentialPolicyScenario";
import PasswordLifecycleScenario from "./PasswordLifecycleScenario";
import type { NavigationPreferenceScope } from "../src/components/navigationPreferenceLayout";
import FormInstancePage from "../../../govoplan-forms-runtime/webui/src/features/forms/FormInstancePage";
import FormsRuntimePage from "../../../govoplan-forms-runtime/webui/src/features/forms/FormsRuntimePage";
@@ -83,6 +84,8 @@ export default function ConformanceApp() {
const [editorDirty, setEditorDirty] = useState(true);
const [metricDrilldown, setMetricDrilldown] = useState("");
if (new URLSearchParams(location.search).has("password-lifecycle")) return <PasswordLifecycleScenario />;
if (new URLSearchParams(location.search).has("credential-references")) return <CredentialReferencesScenario />;
if (new URLSearchParams(location.search).has("files-toolbar")) return <FilesToolbarScenario />;
if (new URLSearchParams(location.search).has("form-control-layout")) return <FormControlLayoutScenario />;
@@ -0,0 +1,67 @@
import { useState } from "react";
import "../src/styles/auth-gate.css";
import { useLocation } from "react-router";
import PasswordChangePanel from "../../../govoplan-access/webui/src/features/passwords/PasswordChangePanel";
import PasswordRecoveryPage from "../../../govoplan-access/webui/src/features/passwords/PasswordRecoveryPage";
import PasswordRecoveryIssueDialog from "../../../govoplan-access/webui/src/features/passwords/PasswordRecoveryIssueDialog";
import PasswordLoginHelp from "../../../govoplan-access/webui/src/features/passwords/PasswordLoginHelp";
import SystemUsersPanel from "../../../govoplan-access/webui/src/features/admin/SystemUsersPanel";
import { passwordTranslations } from "../../../govoplan-access/webui/src/i18n/passwordTranslations";
import { generatedTranslations } from "../../../govoplan-access/webui/src/i18n/generatedTranslations";
import AuthActionGate from "../src/features/auth/AuthActionGate";
import LoginModal from "../src/features/auth/LoginModal";
import Button from "../src/components/Button";
import { PlatformModulesProvider } from "../src/platform/ModuleContext";
import { PlatformLanguageProvider } from "../src/i18n/LanguageContext";
import type { ApiSettings, AuthActionUiCapability, AuthInfo, AuthUpdate, PlatformWebModule } from "../src/types";
const settings: ApiSettings = { apiBaseUrl: "", apiKey: "", accessToken: "" };
const capability: AuthActionUiCapability = {
actions: ["change_password"], RequiredAction: PasswordChangePanel, LoginHelp: PasswordLoginHelp
};
const modules: PlatformWebModule[] = [{
id: "access", label: "Access", version: "fixture", uiCapabilities: { "auth.actions": capability },
helpContexts: [
{ id: "access.password.change", topic_id: "access.help.password-change", title: "Change your local password", documentation_types: ["user", "admin"] },
{ id: "access.password.recover", topic_id: "access.help.password-recovery", title: "Recover a local password", documentation_types: ["user", "admin"] },
{ id: "access.password.issue-recovery", topic_id: "access.help.password-issue-recovery", title: "Issue and hand over a recovery code", documentation_types: ["user", "admin"] }
]
}];
export default function PasswordLifecycleScenario() {
const parameters = new URLSearchParams(useLocation().search);
const mode = parameters.get("mode") ?? "required";
const language = parameters.get("language") ?? "en";
const tenant = { id: "tenant-1", slug: "fixture", name: "Fixture" };
const owner = parameters.get("owner") !== "false";
const [auth, setAuth] = useState<AuthInfo>({
user: { id: "membership-1", account_id: "account-1", email: "person@example.test", local_password: parameters.get("external") !== "true", required_auth_action: mode === "required" || mode === "missing" ? "change_password" : null },
tenant, active_tenant: tenant, scopes: mode === "required" || mode === "missing" ? [] : owner ? ["system:*"] : ["system:accounts:update"], roles: [], groups: [],
principal: { account_id: "account-1", membership_id: "membership-1", auth_method: parameters.get("api-key") ? "api_key" : "session", scopes: [], group_ids: [], session_id: "old-session" },
profile_loaded: true, roles_loaded: true, groups_loaded: true
});
const [updated, setUpdated] = useState("");
const [open, setOpen] = useState(true);
function update(next: AuthUpdate | null, token?: string) {
setUpdated(JSON.stringify({ action: next?.user?.required_auth_action ?? null, token, session: next?.principal?.session_id }));
if (next?.user) setAuth((current) => ({ ...current, ...next, user: { ...current.user, ...next.user }, tenant: current.tenant, active_tenant: current.active_tenant, tenants: current.tenants }));
}
return <PlatformLanguageProvider preferredLanguageCode={language} moduleTranslations={[generatedTranslations, passwordTranslations]}>
<PlatformModulesProvider modules={mode === "missing" ? [] : modules}>
<div data-testid="password-scenario">
{mode === "required" || mode === "missing"
? auth.user.required_auth_action
? <AuthActionGate settings={settings} auth={auth} capability={mode === "missing" ? null : capability} onAuthChange={update} onSignOut={() => setOpen(false)} />
: <h1>Workspace available</h1>
: mode === "recover" ? <PasswordRecoveryPage settings={settings} />
: mode === "issue" ? open
? <PasswordRecoveryIssueDialog settings={settings} account={{ account_id: "target-1", email: "target@example.test" }} onClose={() => setOpen(false)} />
: <Button onClick={() => setOpen(true)}>Reopen recovery</Button>
: mode === "admin" ? <SystemUsersPanel settings={settings} auth={auth} canCreate={false} canUpdate canSuspend={false} canAssignRoles={false} canManageMemberships={false} onAuthRefresh={async () => {}} />
: mode === "login" ? open && <LoginModal settings={settings} onClose={() => setOpen(false)} onLogin={() => {}} />
: <PasswordChangePanel settings={settings} auth={auth} onAuthChange={update} />}
<output data-testid="auth-update">{updated}</output>
</div>
</PlatformModulesProvider>
</PlatformLanguageProvider>;
}
+5 -1
View File
@@ -3,6 +3,10 @@
// generated module catalogue into this isolated test bundle.
export { ApiError, apiDownload, apiFetch, apiGetList, apiPath, apiPost, apiPostJson, apiQuery, apiUrl, authHeaders, csrfToken } from "../src/api/client";
export { fetchAuthGroups } from "../src/api/auth";
export { fetchAdminOverview, fetchPermissionCatalog, fetchTenants } from "../src/api/adminCommon";
export type { AdminOverview, PermissionItem, TenantAdminItem } from "../src/api/adminCommon";
export type * from "../src/api/privacyRetention";
export type { ResourceAccessExplanationOptions } from "../src/api/resourceAccess";
export { default as FormSection } from "../src/components/FormSection";
export { mailProfilePatternKeys, mailProfilePolicyLimitKeys } from "../src/api/mailContracts";
export type * from "../src/api/mailContracts";
@@ -62,7 +66,7 @@ export { MailServerFolderLookupResultView } from "../src/components/mail/MailSer
export type { MailServerFolderLookupResult } from "../src/components/mail/MailServerSettingsPanel";
export { default as AdminSelectionList } from "../src/components/admin/AdminSelectionList";
export { default as AdminPageLayout } from "../src/components/admin/AdminPageLayout";
export { adminErrorMessage } from "../src/components/admin/adminUtils";
export { adminErrorMessage, formatAdminDateTime, joinLabels } from "../src/components/admin/adminUtils";
export { default as ConnectionTree } from "../src/components/ConnectionTree";
export type { ConnectionTreeColumn } from "../src/components/ConnectionTree";
export { default as StageRail } from "../src/components/StageRail";
@@ -0,0 +1,273 @@
import { expect, test, type Locator, type Page } from "@playwright/test";
import { createRequire } from "node:module";
const axePath = createRequire(import.meta.url).resolve("axe-core/axe.min.js");
const currentPassword = "current-password-fixture";
const nextPassword = "new-password-fixture";
const recoveryCode = "pr_fixture-code-never-a-real-secret";
const policy = { recovery_enabled: true, min_length: 10, max_length: 1024, recovery_minutes: 15 };
async function mockPasswordApi(page: Page, options: { enabled?: boolean; failChange?: boolean; failRecovery?: boolean } = {}) {
const posts: Array<{ path: string; body: Record<string, unknown> }> = [];
await page.route("**/api/v1/**", async (route) => {
const request = route.request();
const path = new URL(request.url()).pathname;
if (request.method() === "POST") posts.push({ path, body: request.postDataJSON() });
const json = (data: unknown, status = 200) => route.fulfill({ status, contentType: "application/json", body: JSON.stringify(data) });
if (path === "/api/v1/auth/password/policy") return json({ ...policy, recovery_enabled: options.enabled ?? true });
if (path === "/api/v1/auth/password/change") {
if (options.failChange) return json({ detail: { code: "current_password_invalid", input: currentPassword } }, 403);
return json({ user: { required_auth_action: null, local_password: true }, principal: { auth_method: "session", session_id: "rotated-session" } });
}
if (path.startsWith("/api/v1/auth/password/recovery/")) return json({ recovery_code: recoveryCode, expires_at: "2026-10-01T10:15:00Z" });
if (path === "/api/v1/auth/password/recover") return options.failRecovery
? json({ detail: { code: "recovery_invalid", input: recoveryCode } }, 400) : json({ ok: true });
if (path === "/api/v1/admin/system/accounts/delta") return json({ accounts: [
{ account_id: "target-local", email: "local@example.test", local_password: true, is_active: true, roles: [], memberships: [] },
{ account_id: "target-external", email: "external@example.test", local_password: false, is_active: true, roles: [], memberships: [] }
], roles: [], deleted: [], watermark: "fixture", full: true, has_more: false });
if (path.endsWith("/tenants")) return json({ tenants: [] });
// All browser verification uses synthetic responses; nothing reaches a live provider.
return json({ detail: "Unmocked fixture request" }, 404);
});
return posts;
}
async function expectNoSecretsInStorage(page: Page) {
const values = await page.evaluate(() => JSON.stringify({ local: { ...localStorage }, session: { ...sessionStorage } }));
for (const secret of [currentPassword, nextPassword, recoveryCode]) {
expect(values).not.toContain(secret);
expect(page.url()).not.toContain(secret);
}
}
async function expectHelpContext(control: Locator, context: string) {
await expect(control).toBeVisible();
expect(await control.evaluate((element) => {
const scoped = element.closest<HTMLElement>("[data-help-context-id]");
return { context: scoped?.dataset.helpContextId, module: scoped?.dataset.helpModuleId };
})).toEqual({ context, module: "access" });
}
test("required-action F1 resolves public static help without privileged API calls or secret queries", async ({ page }) => {
await mockPasswordApi(page);
const apiRequests: string[] = [];
page.on("request", (request) => {
if (new URL(request.url()).pathname.startsWith("/api/v1/")) apiRequests.push(new URL(request.url()).pathname);
});
await page.goto("/?password-lifecycle&mode=required&language=en");
const current = page.getByLabel("Current password", { exact: true });
await current.fill(currentPassword);
await expectHelpContext(current, "access.password.change");
await expectHelpContext(page.getByLabel("New password", { exact: true }), "access.password.change");
await expectHelpContext(page.getByLabel("Confirm new password", { exact: true }), "access.password.change");
await expectHelpContext(page.getByRole("button", { name: "Change password", exact: true }), "access.password.change");
await current.press("F1");
const dialog = page.getByRole("dialog");
await expect(dialog.locator('[data-help-context="access.password.change"]')).toBeVisible();
await expect(dialog).toContainText("access.help.password-change");
await expect(dialog).not.toContainText(currentPassword);
await page.evaluate(() => {
window.open = (url) => {
document.body.dataset.openedHelpUrl = String(url);
return null;
};
});
await dialog.getByRole("button", { name: "Open user documentation", exact: true }).click();
const opened = new URL(await page.locator("body").getAttribute("data-opened-help-url") ?? "");
expect(opened.origin).toBe("https://govoplan.add-ideas.de");
expect(opened.searchParams.get("topic")).toBe("access.help.password-change");
expect(opened.searchParams.get("module")).toBe("access");
expect(opened.href).not.toContain(currentPassword);
expect(apiRequests.every((path) => path === "/api/v1/auth/password/policy")).toBe(true);
await expect(page.getByText("Workspace available")).toHaveCount(0);
await expectNoSecretsInStorage(page);
});
test("recovery credentials, verification, one-time display and navigation have exact owning help", async ({ page }) => {
await mockPasswordApi(page);
await page.goto("/?password-lifecycle&mode=recover&language=en");
for (const label of ["Email", "Recovery code", "New password", "Confirm new password"]) {
await expectHelpContext(page.getByLabel(label, { exact: true }), "access.password.recover");
}
await expectHelpContext(page.getByRole("button", { name: "Recover local password", exact: true }), "access.password.recover");
await expectHelpContext(page.getByRole("link", { name: "Return to sign in", exact: true }), "access.password.recover");
await page.goto("/?password-lifecycle&mode=login&language=en");
await expectHelpContext(page.getByRole("link", { name: "Forgot your password?", exact: true }), "access.password.recover");
await page.goto("/?password-lifecycle&mode=admin&language=en");
await expectHelpContext(page.getByRole("button", { name: "Issue recovery code", exact: true }), "access.password.issue-recovery");
await page.goto("/?password-lifecycle&mode=issue&language=en");
const current = page.getByLabel("Current password", { exact: true });
await expectHelpContext(current, "access.password.issue-recovery");
const verified = page.getByRole("checkbox");
await expectHelpContext(verified, "access.password.issue-recovery");
const issue = page.getByRole("button", { name: "Issue recovery code", exact: true });
await expectHelpContext(issue, "access.password.issue-recovery");
await current.fill(currentPassword);
await verified.check();
await issue.click();
await expectHelpContext(page.getByLabel("Recovery code", { exact: true }), "access.password.issue-recovery");
await expectHelpContext(page.getByRole("dialog").getByRole("button", { name: "Close", exact: true }).last(), "access.password.issue-recovery");
});
test("required password change gates workspace and accepts the rotated cookie session", async ({ page }) => {
const posts = await mockPasswordApi(page);
await page.goto("/?password-lifecycle&mode=required&language=en");
await expect(page.getByRole("heading", { name: "Change your initial password" })).toBeVisible();
await expect(page.getByText("Workspace available")).toHaveCount(0);
await page.getByLabel("Current password", { exact: true }).fill(currentPassword);
await page.getByLabel("New password", { exact: true }).fill(nextPassword);
await page.getByLabel("Confirm new password", { exact: true }).fill(nextPassword);
await page.getByRole("button", { name: "Change password", exact: true }).click();
await expect(page.getByRole("heading", { name: "Workspace available" })).toBeVisible();
expect(posts).toEqual([{ path: "/api/v1/auth/password/change", body: { current_password: currentPassword, new_password: nextPassword } }]);
await expect(page.getByTestId("auth-update")).toHaveText(JSON.stringify({ action: null, token: "", session: "rotated-session" }));
await expectNoSecretsInStorage(page);
});
test("missing optional auth UI keeps the required account out of the workspace", async ({ page }) => {
await mockPasswordApi(page);
await page.goto("/?password-lifecycle&mode=missing&language=en");
await expect(page.getByText(/A required account action must be completed/)).toBeVisible();
await expect(page.getByText("Workspace available")).toHaveCount(0);
});
test("password limits count Unicode code points, including astral characters", async ({ page }) => {
const posts = await mockPasswordApi(page);
const unicodeCurrent = "🔑".repeat(600);
const unicodeNext = "🔐".repeat(1024);
await page.goto("/?password-lifecycle&mode=settings&language=en");
await page.getByLabel("Current password", { exact: true }).fill(unicodeCurrent);
const password = page.getByLabel("New password", { exact: true });
const confirmation = page.getByLabel("Confirm new password", { exact: true });
await password.fill("a".repeat(1025));
await confirmation.fill("a".repeat(1025));
await expect(page.getByRole("button", { name: "Change password", exact: true })).toBeDisabled();
await password.fill(unicodeNext);
await confirmation.fill(unicodeNext);
await expect(password).toHaveValue(unicodeNext);
await page.getByRole("button", { name: "Change password", exact: true }).click();
await expect(page.getByTestId("auth-update")).toContainText("rotated-session");
expect(posts[0].body).toEqual({ current_password: unicodeCurrent, new_password: unicodeNext });
});
test("self-service is available with recovery disabled and clears rejected credentials", async ({ page }) => {
const posts = await mockPasswordApi(page, { enabled: false, failChange: true });
await page.goto("/?password-lifecycle&mode=settings&language=en");
await page.getByLabel("Current password", { exact: true }).fill(currentPassword);
await page.getByLabel("New password", { exact: true }).fill(nextPassword);
await page.getByLabel("Confirm new password", { exact: true }).fill(nextPassword);
await page.getByRole("button", { name: "Change password", exact: true }).click();
await expect(page.getByText(/Your current password was not accepted/)).toBeVisible();
expect(posts).toHaveLength(1);
for (const label of ["Current password", "New password", "Confirm new password"]) await expect(page.getByLabel(label, { exact: true })).toHaveValue("");
await expect(page.locator("body")).not.toContainText(currentPassword);
await expectNoSecretsInStorage(page);
});
test("external accounts and API-key sessions cannot use the password change form", async ({ page }) => {
const posts = await mockPasswordApi(page);
for (const query of ["external=true", "api-key=true"]) {
await page.goto(`/?password-lifecycle&mode=settings&language=en&${query}`);
await expect(page.getByText(/Password changes require an interactive session/)).toBeVisible();
await expect(page.getByLabel("Current password", { exact: true })).toHaveCount(0);
}
expect(posts).toHaveLength(0);
});
test("recovery replaces the password without signing in and clears all secrets", async ({ page }) => {
const posts = await mockPasswordApi(page);
await page.goto("/?password-lifecycle&mode=recover&language=en");
await page.getByLabel("Email", { exact: true }).fill("person@example.test");
await page.getByLabel("Recovery code", { exact: true }).fill(recoveryCode);
await page.getByLabel("New password", { exact: true }).fill(nextPassword);
await page.getByLabel("Confirm new password", { exact: true }).fill(nextPassword);
await page.getByRole("button", { name: "Recover local password", exact: true }).click();
await expect(page.getByText(/Your password was replaced and existing sessions/)).toBeVisible();
expect(posts).toEqual([{ path: "/api/v1/auth/password/recover", body: { email: "person@example.test", recovery_code: recoveryCode, new_password: nextPassword } }]);
await expect(page.getByTestId("auth-update")).toHaveText("");
await expect(page.getByRole("link", { name: "Return to sign in" })).toHaveAttribute("href", "/");
await expect(page.getByLabel("Recovery code", { exact: true })).toHaveCount(0);
await expectNoSecretsInStorage(page);
});
test("expired recovery codes show translated errors without reflecting response secrets", async ({ page }) => {
await mockPasswordApi(page, { failRecovery: true });
await page.goto("/?password-lifecycle&mode=recover&language=de");
await page.getByLabel("E-Mail", { exact: true }).fill("person@example.test");
await page.getByLabel("Wiederherstellungscode", { exact: true }).fill(recoveryCode);
await page.getByLabel("Neues Passwort", { exact: true }).fill(nextPassword);
await page.getByLabel("Neues Passwort bestätigen", { exact: true }).fill(nextPassword);
await page.getByRole("button", { name: "Lokales Passwort wiederherstellen", exact: true }).click();
await expect(page.getByText(/Dieser Wiederherstellungscode ist ungültig/)).toBeVisible();
await expect(page.getByLabel("Wiederherstellungscode", { exact: true })).toHaveValue("");
await expect(page.locator("body")).not.toContainText(recoveryCode);
});
test("issuing a code requires independent identity verification and discards the one-time display on close", async ({ page }) => {
const posts = await mockPasswordApi(page);
await page.goto("/?password-lifecycle&mode=issue&language=en");
const issue = page.getByRole("button", { name: "Issue recovery code", exact: true });
await page.getByLabel("Current password", { exact: true }).fill(currentPassword);
await expect(issue).toBeDisabled();
await page.getByRole("checkbox", { name: /I independently verified/ }).check();
await issue.click();
await expect(page.getByLabel("Recovery code", { exact: true })).toHaveValue(recoveryCode);
expect(posts).toEqual([{ path: "/api/v1/auth/password/recovery/target-1", body: { current_password: currentPassword, identity_verified: true } }]);
await expect(page.getByText(/Expires:/)).toBeVisible();
await expectNoSecretsInStorage(page);
await page.getByRole("dialog").getByRole("button", { name: "Close", exact: true }).last().click();
await page.getByRole("button", { name: "Reopen recovery" }).click();
await expect(page.getByLabel("Recovery code", { exact: true })).toHaveCount(0);
await expect(page.getByLabel("Current password", { exact: true })).toHaveValue("");
await expect(page.getByRole("checkbox", { name: /I independently verified/ })).not.toBeChecked();
});
test("System account recovery actions require a local interactive System owner and a local target", async ({ page }) => {
await mockPasswordApi(page);
await page.goto("/?password-lifecycle&mode=admin&language=en");
await expect(page.getByRole("button", { name: "Issue recovery code", exact: true })).toHaveCount(1);
for (const query of ["owner=false", "external=true", "api-key=true"]) {
await page.goto(`/?password-lifecycle&mode=admin&language=en&${query}`);
await expect(page.getByText("local@example.test", { exact: true }).first()).toBeVisible();
await expect(page.getByRole("button", { name: "Issue recovery code", exact: true })).toHaveCount(0);
}
});
test("forgot-password link and recovery controls follow the disabled policy", async ({ page }) => {
await mockPasswordApi(page, { enabled: false });
await page.goto("/?password-lifecycle&mode=login&language=en");
await expect(page.getByRole("dialog")).toBeVisible();
await expect(page.getByRole("link", { name: "Forgot your password?" })).toHaveCount(0);
await page.goto("/?password-lifecycle&mode=recover&language=en");
await expect(page.getByText(/Administrator-assisted password recovery is not enabled/)).toBeVisible();
await expect(page.getByLabel("Recovery code", { exact: true })).toHaveCount(0);
});
test("enabled forgot-password link navigates without credentials in its URL", async ({ page }) => {
await mockPasswordApi(page);
await page.goto("/?password-lifecycle&mode=login&language=en");
const link = page.getByRole("link", { name: "Forgot your password?" });
await expect(link).toHaveAttribute("href", "/password-recovery");
await link.click();
await expect(page).toHaveURL(/\/password-recovery$/);
});
for (const [language, theme] of [["en", "light"], ["de", "light"], ["en", "dark"], ["de", "dark"]]) {
test(`required password form is accessible on mobile in ${language} ${theme}`, async ({ page }, testInfo) => {
await mockPasswordApi(page);
await page.setViewportSize({ width: 390, height: 844 });
await page.goto(`/?password-lifecycle&mode=required&language=${language}&theme=${theme}`);
await expect(page.getByRole("heading", { level: 1 })).toBeVisible();
await page.addScriptTag({ path: axePath });
const violations = await page.evaluate(async () => {
const axe = (window as typeof window & { axe: { run: (options: unknown) => Promise<{ violations: Array<{ id: string; nodes: Array<{ target: unknown; failureSummary?: string }> }> }> } }).axe;
const result = await axe.run({ runOnly: { type: "tag", values: ["wcag2a", "wcag2aa", "wcag21aa"] } });
return result.violations.map(({ id, nodes }) => ({ id, nodes: nodes.map(({ target, failureSummary }) => ({ target, failureSummary })) }));
});
expect(violations).toEqual([]);
expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true);
await page.screenshot({ path: testInfo.outputPath(`password-required-${language}-${theme}.png`), fullPage: true });
});
}
+1
View File
@@ -43,6 +43,7 @@
"test:core-interface-patterns": "node scripts/test-core-interface-patterns.mjs",
"test:vite-cache-isolation": "node scripts/test-vite-cache-isolation.mjs",
"test:api-client-cache": "node --test tests/api-client-cache.test.mjs",
"test:auth-action-state": "node --test tests/auth-action-state.test.mjs",
"test:dependency-security": "node --test tests/dependency-security.test.mjs",
"test:file-drop-zone": "rm -rf .file-drop-test-build && mkdir -p .file-drop-test-build && printf '{\"type\":\"commonjs\"}\\n' > .file-drop-test-build/package.json && tsc -p tsconfig.file-drop-tests.json && node .file-drop-test-build/tests/file-drop-resolver.test.js && node scripts/test-file-drop-zone-structure.mjs",
"test:data-grid-actions": "rm -rf .component-test-build && mkdir -p .component-test-build && printf '{\"type\":\"commonjs\"}\\n' > .component-test-build/package.json && tsc -p tsconfig.component-tests.json && node .component-test-build/tests/data-grid-actions.test.js && node .component-test-build/tests/data-grid-sizing.test.js",
+50 -11
View File
@@ -1,6 +1,7 @@
import { Navigate, Route, Routes, useLocation } from "react-router";
import { lazy, useEffect, useMemo, useState } from "react";
import { fetchSession, fetchShellAuth, updateProfile } from "./api/auth";
import { fetchSession, fetchShellAuth, logout, updateProfile } from "./api/auth";
import type { AuthActionUiCapability } from "./types";
import { fetchPlatformModules, fetchPlatformPublicModules, fetchPlatformStatus } from "./api/platform";
import { AUTH_REQUIRED_EVENT, apiSettingsForAuthUpdate, clearApiReadCache, isApiError, loadApiSettings, saveApiSettings, type AuthRequiredEventDetail } from "./api/client";
import type { ApiSettings, AuthInfo, AuthSessionInfo, AuthUpdate, AuthUser, EffectiveViewProjection, LoginResponse, PlatformModuleInfo, PlatformPublicModuleInfo, PlatformWebModule, UserUiPalette, UserUiPreferences, ViewsRuntimeUiCapability } from "./types";
@@ -34,6 +35,7 @@ import { applyAppearanceOverrides } from "./components/appearanceOverrides";
const DashboardPage = lazy(() => import("./features/dashboard/DashboardPage"));
const SettingsPage = lazy(() => import("./features/settings/SettingsPage"));
const ProductSurfaceRoute = lazy(() => import("./components/ProductSurfaceRoute"));
const AuthActionGate = lazy(() => import("./features/auth/AuthActionGate"));
const DEFAULT_UI_PREFERENCES: UserUiPreferences = {
compact_tables: false,
@@ -69,6 +71,11 @@ export default function App() {
const webModules = useMemo(() => mergeWebModules(localWebModules, remoteWebModules), [localWebModules, remoteWebModules]);
const publicWebModules = useMemo(() => mergeWebModules(localPublicWebModules, remotePublicWebModules), [localPublicWebModules, remotePublicWebModules]);
const requiredAuthAction = auth?.user.required_auth_action ?? null;
const authActions = useMemo(
() => uiCapability<AuthActionUiCapability>("auth.actions", publicWebModules),
[publicWebModules]
);
const viewsRuntime = useMemo(
() => uiCapability<ViewsRuntimeUiCapability>("views.runtime", webModules),
[webModules]
@@ -83,7 +90,7 @@ export default function App() {
);
const moduleRoutes = useMemo(() => routeContributionsForModules(webModules), [webModules]);
const publicRoutes = useMemo(() => publicRouteContributionsForModules(publicWebModules), [publicWebModules]);
const contextModules = auth ? webModules : publicWebModules;
const contextModules = auth && !requiredAuthAction ? webModules : publicWebModules;
const moduleTranslations = useMemo(() => contextModules.map((module) => module.translations).filter(Boolean), [contextModules]);
const dashboardModuleInstalled = useMemo(() => moduleInstalled("dashboard", webModules), [webModules]);
@@ -102,7 +109,7 @@ export default function App() {
}, []);
useEffect(() => {
if (!auth || !viewsRuntime) {
if (!auth || requiredAuthAction || !viewsRuntime) {
setBaseViewProjection(null);
setWorkflowViewProjection(null);
return;
@@ -137,11 +144,12 @@ export default function App() {
settings.accessToken,
settings.apiBaseUrl,
settings.apiKey,
requiredAuthAction,
viewsRuntime
]);
useEffect(() => {
if (!auth || !viewsRuntime) {
if (!auth || requiredAuthAction || !viewsRuntime) {
setWorkflowViewProjection(null);
return;
}
@@ -167,6 +175,7 @@ export default function App() {
auth?.user?.id,
auth?.active_tenant?.id,
auth?.tenant.id,
requiredAuthAction,
viewsRuntime
]);
@@ -289,7 +298,10 @@ export default function App() {
}, [settings.apiBaseUrl, settings.apiKey]);
useEffect(() => {
if (!auth) return;
if (!auth || requiredAuthAction) {
setPlatformModules(null);
return;
}
let cancelled = false;
let inFlight = false;
@@ -329,12 +341,12 @@ export default function App() {
window.removeEventListener("focus", refreshVisibleModules);
document.removeEventListener("visibilitychange", refreshVisibleModules);
};
}, [auth?.user?.id, auth?.active_tenant?.id, auth?.tenant.id, settings.apiBaseUrl, settings.apiKey]);
}, [auth?.user?.id, auth?.active_tenant?.id, auth?.tenant.id, requiredAuthAction, settings.apiBaseUrl, settings.apiKey]);
useEffect(() => {
let cancelled = false;
setWebModuleLoadFailures([]);
if (!auth) {
if (!auth || requiredAuthAction) {
setLocalWebModules([]);
setRemoteWebModules([]);
setWebModulesLoading(false);
@@ -374,7 +386,7 @@ export default function App() {
}
});
return () => {cancelled = true;};
}, [auth?.user?.id, auth?.active_tenant?.id, auth?.tenant.id, platformModules]);
}, [auth?.user?.id, auth?.active_tenant?.id, auth?.tenant.id, requiredAuthAction, platformModules]);
useEffect(() => {
let cancelled = false;
@@ -489,7 +501,7 @@ export default function App() {
window.removeEventListener("focus", refreshVisibleSession);
document.removeEventListener("visibilitychange", refreshVisibleSession);
};
}, [auth?.user?.id, auth?.active_tenant?.id, auth?.tenant.id, settings.apiBaseUrl, settings.apiKey]);
}, [auth?.user?.id, auth?.active_tenant?.id, auth?.tenant.id, auth?.principal?.session_id, requiredAuthAction, settings.apiBaseUrl, settings.apiKey]);
if (checkingSession) {
return (
@@ -540,6 +552,27 @@ export default function App() {
}
if (requiredAuthAction) {
return <PlatformLanguageProvider
systemAvailableLanguages={systemLanguages?.available}
systemEnabledLanguageCodes={systemLanguages?.enabled}
defaultLanguage={systemLanguages?.defaultLanguage}
preferredLanguageCode={auth.user.preferred_language ?? undefined}
moduleTranslations={moduleTranslations}>
<PlatformModulesProvider modules={publicWebModules}>
<PlatformViewProvider modules={publicWebModules} projection={null}>
<ModuleLoadBoundary resetKey={requiredAuthAction}>
<AuthActionGate settings={settings} auth={auth} capability={authActions}
onAuthChange={updateAuth}
onSignOut={() => { void logout(settings).catch(() => undefined).finally(() => updateAuth(null, "")); }} />
</ModuleLoadBoundary>
{reloginMessage && <LoginModal settings={settings} message={reloginMessage}
onClose={() => setReloginMessage("")} onLogin={handleRelogin} />}
</PlatformViewProvider>
</PlatformModulesProvider>
</PlatformLanguageProvider>;
}
const defaultRoute = firstAccessibleRoute(auth, webModules, viewProjection);
const localDocsAvailable = hasAnyScope(auth, ["docs:documentation:read", "docs:documentation:admin", "system:settings:read", "admin:settings:read"]) &&
webModules.some((module) => module.id === "docs" && module.routes?.some((route) => route.path === "/docs"));
@@ -657,7 +690,7 @@ function mergeAuthPayload(current: AuthInfo | null, next: AuthPayload): AuthPayl
};
}
function normalizeAuthInfo(response: AuthPayload): AuthInfo {
export function normalizeAuthInfo(response: AuthPayload): AuthInfo {
const principal = response.principal ?? null;
const activeTenant = response.active_tenant ?? response.tenant ?? response.tenants?.[0] ?? null;
const user = normalizeAuthUser(response.user, principal);
@@ -710,6 +743,8 @@ function normalizeAuthUser(user: Partial<AuthUser> | null | undefined, principal
tenant_display_name: user.tenant_display_name ?? null,
is_tenant_admin: user.is_tenant_admin ?? false,
password_reset_required: user.password_reset_required ?? false,
required_auth_action: user.required_auth_action ?? null,
local_password: user.local_password ?? false,
preferred_language: user.preferred_language ?? null,
enabled_language_codes: user.enabled_language_codes ?? [],
ui_preferences: normalizeUiPreferences(user.ui_preferences)
@@ -728,16 +763,20 @@ function normalizeAuthUser(user: Partial<AuthUser> | null | undefined, principal
tenant_display_name: principal.display_name ?? null,
is_tenant_admin: false,
password_reset_required: false,
required_auth_action: null,
local_password: false,
preferred_language: null,
enabled_language_codes: [],
ui_preferences: DEFAULT_UI_PREFERENCES
};
}
function sessionMatchesAuth(sessionInfo: AuthSessionInfo, auth: AuthInfo): boolean {
export function sessionMatchesAuth(sessionInfo: AuthSessionInfo, auth: AuthInfo): boolean {
const activeTenant = auth.active_tenant ?? auth.tenant;
if (sessionInfo.user.id !== auth.user.id) return false;
if (sessionInfo.user.account_id !== auth.user.account_id) return false;
if ((sessionInfo.user.required_auth_action ?? null) !== (auth.user.required_auth_action ?? null)) return false;
if (Boolean(sessionInfo.user.local_password) !== Boolean(auth.user.local_password)) return false;
if ((sessionInfo.active_tenant ?? sessionInfo.tenant).id !== activeTenant.id) return false;
if (auth.principal?.auth_method && sessionInfo.auth_method !== auth.principal.auth_method) return false;
if (auth.principal?.session_id && sessionInfo.session_id && auth.principal.session_id !== sessionInfo.session_id) return false;
@@ -0,0 +1,31 @@
import type { ApiSettings, AuthActionUiCapability, AuthInfo, AuthUpdate } from "../../types";
import Button from "../../components/Button";
import DismissibleAlert from "../../components/DismissibleAlert";
import ModuleLoadBoundary from "../../components/ModuleLoadBoundary";
import HelpMenu from "../../layout/HelpMenu";
export default function AuthActionGate({
settings, auth, capability, onAuthChange, onSignOut
}: {
settings: ApiSettings;
auth: AuthInfo;
capability: AuthActionUiCapability | null;
onAuthChange: (auth: AuthUpdate | null, accessToken?: string) => void;
onSignOut: () => void;
}) {
const action = auth.user.required_auth_action;
const RequiredAction = action && capability?.actions.includes(action)
? capability.RequiredAction : null;
return <main className="public-landing auth-action-page">
<section className="public-card">
<ModuleLoadBoundary resetKey={action ?? "auth-action"}>
{RequiredAction
? <RequiredAction settings={settings} auth={auth} onAuthChange={onAuthChange} />
: <DismissibleAlert tone="warning" dismissible={false}>
i18n:govoplan-core.required_account_action_unavailable
</DismissibleAlert>}
</ModuleLoadBoundary>
<div className="public-actions"><Button onClick={onSignOut}>i18n:govoplan-core.sign_out.dc1649a1</Button><HelpMenu auth={auth} /></div>
</section>
</main>;
}
+7 -1
View File
@@ -1,12 +1,14 @@
import { FormLayout } from "../../components/ContentGrid";
import { useId, useState } from "react";
import type { ApiSettings, LoginResponse } from "../../types";
import type { ApiSettings, AuthActionUiCapability, LoginResponse } from "../../types";
import { login } from "../../api/auth";
import Button from "../../components/Button";
import Dialog from "../../components/Dialog";
import FormField from "../../components/FormField";
import PasswordField from "../../components/PasswordField";
import DismissibleAlert from "../../components/DismissibleAlert";
import { usePlatformUiCapability } from "../../platform/ModuleContext";
import { Suspense } from "react";
export default function LoginModal({
settings,
@@ -26,6 +28,8 @@ export default function LoginModal({
const [error, setError] = useState("");
const [busy, setBusy] = useState(false);
const formId = useId();
const authActions = usePlatformUiCapability<AuthActionUiCapability>("auth.actions");
const LoginHelp = authActions?.LoginHelp;
async function submit(event: React.FormEvent) {
event.preventDefault();
@@ -38,6 +42,7 @@ export default function LoginModal({
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setPassword("");
setBusy(false);
}
}
@@ -64,6 +69,7 @@ export default function LoginModal({
<PasswordField helpContextId="access.authentication.password" helpModuleId="access" value={password} autoComplete="current-password" onValueChange={setPassword} />
</FormField>
</FormLayout>
{LoginHelp && <Suspense fallback={null}><LoginHelp settings={settings} onNavigate={onClose} /></Suspense>}
</Dialog>);
}
+3 -1
View File
@@ -2,6 +2,7 @@ import type { PlatformTranslations } from "../types";
export const generatedTranslations: PlatformTranslations = {
"en": {
"i18n:govoplan-core.required_account_action_unavailable": "A required account action must be completed before you can continue. The account module is loading or unavailable. If this persists, contact your administrator or sign out.",
"i18n:govoplan-core.optional_module_load_failed": "An enabled module could not load after retrying: {value0}. Its screens and integrations may be unavailable; the module has not been uninstalled. Save any other drafts before reloading this page.",
"i18n:govoplan-core.data_grid_resize_help": "Drag to resize. Left/Right: 10 px; Shift: 40 px. Enter or double-click: reset this column. Escape: cancel dragging.",
"i18n:govoplan-core.inherit_governed_palette": "Inherit governed default",
@@ -741,6 +742,7 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-core.temporal_selection_invalid": "The selected data state is invalid."
},
"de": {
"i18n:govoplan-core.required_account_action_unavailable": "Bevor Sie fortfahren können, müssen Sie eine erforderliche Kontoaktion abschließen. Das Kontomodul wird geladen oder ist nicht verfügbar. Wenden Sie sich bei anhaltenden Problemen an die Administration oder melden Sie sich ab.",
"i18n:govoplan-core.optional_module_load_failed": "Ein aktiviertes Modul konnte auch nach einem Wiederholungsversuch nicht geladen werden: {value0}. Seine Ansichten und Integrationen sind möglicherweise nicht verfügbar; das Modul wurde nicht deinstalliert. Andere Entwürfe vor dem Neuladen dieser Seite speichern.",
"i18n:govoplan-core.data_grid_resize_help": "Zum Ändern der Breite ziehen. Links/Rechts: 10 px; Umschalt: 40 px. Eingabe oder Doppelklick: Spalte zurücksetzen. Escape: Ziehen abbrechen.",
"i18n:govoplan-core.inherit_governed_palette": "Verwalteten Standard übernehmen",
@@ -1291,7 +1293,7 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-core.show_password.044b852f": "Show password",
"i18n:govoplan-core.sign_in_to_open_the_modules_available_to_your_te.8bb7dab4": "Sign in to open the modules available to your tenant and role.",
"i18n:govoplan-core.sign_in.ada2e9e9": "Anmelden",
"i18n:govoplan-core.sign_out.dc1649a1": "Sign out",
"i18n:govoplan-core.sign_out.dc1649a1": "Abmelden",
"i18n:govoplan-core.signing_in.c66b2adc": "Signing in…",
"i18n:govoplan-core.smtp_host.2d4a434b": "SMTP host",
"i18n:govoplan-core.smtp_port.65b5a108": "SMTP port",
+6
View File
@@ -38,6 +38,12 @@
padding: 42px 48px;
}
.auth-action-page form { margin-top: 18px; }
@media (max-width: 600px) {
.auth-action-page { padding: 16px; }
.auth-action-page .public-card { padding: 24px; }
}
.public-kicker {
color: var(--accent);
text-transform: uppercase;
+10 -1
View File
@@ -35,6 +35,8 @@ export type AuthUser = {
tenant_display_name?: string | null;
is_tenant_admin?: boolean;
password_reset_required?: boolean;
required_auth_action?: "change_password" | null;
local_password?: boolean;
preferred_language?: string | null;
enabled_language_codes?: string[];
ui_preferences?: UserUiPreferences;
@@ -134,6 +136,13 @@ export type ActingContextRuntimeUiCapability = {
Selector: ComponentType<ActingContextSelectorProps>;
};
/** Optional authentication UI, including actions before normal module access. */
export type AuthActionUiCapability = {
actions: readonly string[];
RequiredAction: ComponentType<ActingContextSelectorProps>;
LoginHelp?: ComponentType<{ settings: ApiSettings; onNavigate: () => void }>;
};
export type AuthInfo = {
user: AuthUser;
// Backwards-compatible active tenant alias returned by older/newer APIs.
@@ -162,7 +171,7 @@ export type AuthUpdate = Partial<Omit<AuthInfo, "user" | "tenant" | "active_tena
export type AuthSessionInfo = {
authenticated: boolean;
auth_method: "session" | "api_key";
user: Pick<AuthUser, "id" | "account_id" | "email" | "display_name" | "tenant_display_name" | "is_tenant_admin" | "password_reset_required">;
user: Pick<AuthUser, "id" | "account_id" | "email" | "display_name" | "tenant_display_name" | "is_tenant_admin" | "password_reset_required" | "required_auth_action" | "local_password">;
tenant: AuthTenant;
active_tenant: AuthTenant;
session_id?: string | null;
+46
View File
@@ -0,0 +1,46 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { createRequire } from "node:module";
import test from "node:test";
import vm from "node:vm";
const require = createRequire(import.meta.url);
const { transformSync } = require("esbuild");
const source = readFileSync(new URL("../src/App.tsx", import.meta.url), "utf8");
const code = transformSync(source, { loader: "tsx", format: "cjs", target: "es2022" }).code;
const context = vm.createContext({
module: { exports: {} },
require: (name) => name === "react" ? { lazy: () => null } : {}
});
context.exports = context.module.exports;
vm.runInContext(code, context);
const { normalizeAuthInfo, sessionMatchesAuth } = context.module.exports;
const tenant = { id: "tenant-1", name: "Tenant", slug: "tenant" };
const base = {
user: { id: "membership-1", account_id: "account-1", email: "person@example.test", password_reset_required: true },
tenant,
principal: { auth_method: "session", account_id: "account-1", membership_id: "membership-1", session_id: "session-1", scopes: [] }
};
test("normalization preserves an explicit required action and local password capability", () => {
const normalized = normalizeAuthInfo({ ...base, user: { ...base.user, required_auth_action: "change_password", local_password: true } });
assert.equal(normalized.user.required_auth_action, "change_password");
assert.equal(normalized.user.local_password, true);
assert.equal(normalized.scopes.length, 0);
});
test("legacy password-reset metadata remains advisory without the server action", () => {
const normalized = normalizeAuthInfo(base);
assert.equal(normalized.user.password_reset_required, true);
assert.equal(normalized.user.required_auth_action, null);
assert.equal(normalized.user.local_password, false);
});
test("lightweight session changes trigger full auth refresh for required actions and provider changes", () => {
const auth = normalizeAuthInfo({ ...base, user: { ...base.user, required_auth_action: null, local_password: true } });
const session = { user: { ...auth.user }, tenant, active_tenant: tenant, auth_method: "session", session_id: "session-1" };
assert.equal(sessionMatchesAuth(session, auth), true);
assert.equal(sessionMatchesAuth({ ...session, user: { ...session.user, required_auth_action: "change_password" } }, auth), false);
assert.equal(sessionMatchesAuth({ ...session, user: { ...session.user, local_password: false } }, auth), false);
assert.equal(sessionMatchesAuth({ ...session, session_id: "rotated-session" }, auth), false);
});
+14
View File
@@ -1,5 +1,6 @@
import type {
AuthInfo,
AuthActionUiCapability,
DashboardWidgetsUiCapability,
OrganizationFunctionActionContext,
OrganizationFunctionActionContribution,
@@ -80,6 +81,19 @@ for (const testCase of cases) {
assert(uiCapability("files.fileExplorer", [access, files]) === filesCapability, "files capability should return the module-provided object");
assert(uiCapability("mail.profiles", [access, mail]) === mailCapability, "mail capability should return the module-provided object");
const authActionCapability: AuthActionUiCapability = {
actions: ["change_password"], RequiredAction: () => null, LoginHelp: () => null
};
const publicAuthModule: PlatformWebModule = {
id: "access", label: "Access", version: "test",
publicRoutes: [{ path: "/password-recovery", render: () => null }],
uiCapabilities: { "auth.actions": authActionCapability }
};
assert(uiCapability<AuthActionUiCapability>("auth.actions", [publicAuthModule]) === authActionCapability,
"required authentication actions can resolve from the public catalogue without normal scopes");
assert(uiCapability<AuthActionUiCapability>("auth.actions", []) === null,
"core-only compositions have no authentication action implementation");
const configurableDashboardWidgets: DashboardWidgetsUiCapability = {
widgets: [
{