Enforce deployment security boundaries

This commit is contained in:
2026-07-21 12:10:05 +02:00
parent 825791e9b0
commit 230ecf42b0
10 changed files with 629 additions and 28 deletions

View File

@@ -192,15 +192,60 @@ prefer `FILE_STORAGE_*`.
| Setting | Default | Notes | | Setting | Default | Notes |
| --- | --- | --- | | --- | --- | --- |
| `CORS_ORIGINS` | local dev origins | Set to the exact WebUI origins in staging/production. | | `CORS_ORIGINS` | local dev origins | Set to the exact WebUI origins in staging/production. |
| `GOVOPLAN_TRUSTED_HOSTS` | empty | Exact API host names accepted by the application. Production-like validation requires an explicit list; narrowly scoped `*.example.org` entries are supported. |
| `FORWARDED_ALLOW_IPS` | Uvicorn default | Address or network of the trusted reverse proxy. Never use `*` in production-like deployments. |
| `AUTH_SESSION_COOKIE_NAME` | configured default | Change only through a controlled rollout because it logs users out. | | `AUTH_SESSION_COOKIE_NAME` | configured default | Change only through a controlled rollout because it logs users out. |
| `AUTH_CSRF_COOKIE_NAME` | configured default | Must match WebUI/API deployment. | | `AUTH_CSRF_COOKIE_NAME` | configured default | Must match WebUI/API deployment. |
| `AUTH_COOKIE_SECURE` | `false` | Set `true` behind HTTPS. | | `AUTH_COOKIE_SECURE` | `false` | Set `true` behind HTTPS. |
| `AUTH_COOKIE_SAMESITE` | `lax` | Use a stricter value only after testing login and CSRF flows. | | `AUTH_COOKIE_SAMESITE` | `lax` | Use a stricter value only after testing login and CSRF flows. |
| `AUTH_COOKIE_DOMAIN` | empty | Set only when the API and WebUI intentionally share a parent domain. | | `AUTH_COOKIE_DOMAIN` | empty | Set only when the API and WebUI intentionally share a parent domain. |
| `GOVOPLAN_HTTP_HSTS_SECONDS` | `31536000` in production, otherwise `0` | Emitted only for HTTPS requests. Set `0` while rehearsing a deployment that is not yet HTTPS-only. |
| `GOVOPLAN_HTTP_MAX_REQUEST_BODY_BYTES` | `536870912` (512 MiB) | Deployment hard ceiling; file and module APIs apply their own lower limits where appropriate. |
Interactive password login is enabled with fixed-window limits of 10 failures
per normalized identity and 100 failures per direct client over 900 seconds.
`AUTH_LOGIN_THROTTLE_*` settings change those limits. Counters use `REDIS_URL`
when Redis is reachable so replicas share state; a bounded process-local
fallback keeps development and Redis outages functional, with per-process
enforcement until Redis recovers.
### Outbound Connector Egress
| Setting | Default | Notes |
| --- | --- | --- |
| `GOVOPLAN_CONNECTOR_ALLOW_PRIVATE_NETWORKS` | `true` in dev/test, otherwise `false` | Deployment-wide decision. Set `true` only when connectors, mail, SMB, or object storage must reach internal addresses. |
| `GOVOPLAN_CONNECTOR_MAX_STRUCTURED_RESPONSE_BYTES` | `16777216` (16 MiB) | Maximum buffered JSON, XML, iCalendar, vCard, catalog, and connector error response. |
| `GOVOPLAN_CONNECTOR_MAX_FILE_TRANSFER_BYTES` | `536870912` (512 MiB) | Hard upper bound for a single remote file; module upload limits may be lower. |
| `GOVOPLAN_CONNECTOR_SECRET_ENV_ALLOWLIST` | empty | Comma-separated exact environment names usable by deployment-owned connector profiles. Tenant/API-managed profiles cannot select process variables, even when a name is listed. |
| `GOVOPLAN_CONNECTOR_CA_BUNDLE_ALLOWLIST` | empty | Comma-separated exact absolute CA bundle paths. Mount the same files at the same paths on every API and connector worker. |
Production-like configuration validation requires the private-network choice to
be explicit. HTTP connector downloads are streamed up to the configured bound,
and credential-bearing DAV redirects remain confined to their configured
origin.
The urllib, HTTPX/httpcore, SMTP, and IMAP transports resolve, validate, and
connect to the same approved address record while retaining the original host
for HTTP Host, TLS SNI, and certificate verification. SMB uses an approved
numeric connection target in public-only mode. The S3 SDK cannot provide the
same peer and redirect guarantee, so S3 connector endpoints fail closed while
application-enforced public-only egress is required. Production deployments
should still enforce the same decision at their worker/container egress firewall
or outbound proxy as a second boundary.
File connector TLS verification may be disabled only in dev/test. A custom CA
bundle must be an existing regular file whose resolved absolute path is listed
in `GOVOPLAN_CONNECTOR_CA_BUNDLE_ALLOWLIST`. Environment-backed file connector
credentials are supported only in deployment-owned connector JSON and require
their exact names in `GOVOPLAN_CONNECTOR_SECRET_ENV_ALLOWLIST`; UI/API profiles
must use encrypted stored credentials or a scoped secret-provider reference.
Public URLs are currently supplied by deployment/reverse-proxy configuration and Public URLs are currently supplied by deployment/reverse-proxy configuration and
module settings. Do not hardcode them in core; configuration packages should ask module settings. Do not hardcode them in core; configuration packages should ask
for portal, WebUI, postbox, and notification URLs when they become relevant. for portal, WebUI, postbox, and notification URLs when they become relevant.
Uvicorn applies `X-Forwarded-*` only from `FORWARDED_ALLOW_IPS`; keep that value
aligned with the reverse proxy and do not expose the application server directly
through the same trusted address range.
### Module Catalogs, Licenses, And Trust Roots ### Module Catalogs, Licenses, And Trust Roots

View File

@@ -40,10 +40,16 @@ set +a
The command reports all known blockers at once. Production-like/self-hosted The command reports all known blockers at once. Production-like/self-hosted
profiles require explicit `APP_ENV`, `DATABASE_URL`, `MASTER_KEY_B64`, profiles require explicit `APP_ENV`, `DATABASE_URL`, `MASTER_KEY_B64`,
`ENABLED_MODULES`, and `CORS_ORIGINS`. Production rejects SQLite, development `ENABLED_MODULES`, `CORS_ORIGINS`, `GOVOPLAN_TRUSTED_HOSTS`, and a deployment-wide decision for
`GOVOPLAN_CONNECTOR_ALLOW_PRIVATE_NETWORKS`. Production rejects SQLite, development
bootstrap, insecure auth cookies, and unsigned catalog trust roots when a bootstrap, insecure auth cookies, and unsigned catalog trust roots when a
catalog source is configured. catalog source is configured.
Connector process-secret names and custom CA files are deployment-owned through
the exact, default-empty `GOVOPLAN_CONNECTOR_SECRET_ENV_ALLOWLIST` and
`GOVOPLAN_CONNECTOR_CA_BUNDLE_ALLOWLIST`; tenant/API configuration cannot widen
either boundary.
## Production-Like Dev Stack ## Production-Like Dev Stack
Use the local production-like wrapper for repeatable rehearsal: Use the local production-like wrapper for repeatable rehearsal:

View File

@@ -262,7 +262,7 @@ _CONFIGURATION_FIELD_SAFETY: tuple[ConfigurationFieldSafety, ...] = (
audit_event="files.connector_profile.updated", audit_event="files.connector_profile.updated",
two_person_approval_required=True, two_person_approval_required=True,
rollback_history_required=True, rollback_history_required=True,
notes="Connector endpoints are UI-manageable, but passwords/tokens remain env or secret refs.", notes="Connector endpoints are UI-manageable. API-managed credentials are encrypted or use scoped secret refs; process-environment references remain deployment-owned.",
), ),
ConfigurationFieldSafety( ConfigurationFieldSafety(
key="DATABASE_URL", key="DATABASE_URL",
@@ -289,6 +289,174 @@ _CONFIGURATION_FIELD_SAFETY: tuple[ConfigurationFieldSafety, ...] = (
two_person_approval_required=True, two_person_approval_required=True,
notes="Encryption roots remain out of band; UI may only report missing/rotated state.", notes="Encryption roots remain out of band; UI may only report missing/rotated state.",
), ),
ConfigurationFieldSafety(
key="GOVOPLAN_CONNECTOR_ALLOW_PRIVATE_NETWORKS",
label="Private-network connector access",
owner_module="core",
scope="system",
storage="environment",
ui_managed=False,
risk="high",
secret_handling="env_only", # noqa: S106 # nosec B106 - policy vocabulary.
maintenance_required=True,
notes="This deployment-wide egress boundary remains out of band and applies to every connector worker.",
),
ConfigurationFieldSafety(
key="GOVOPLAN_CONNECTOR_MAX_STRUCTURED_RESPONSE_BYTES",
label="Structured connector response limit",
owner_module="core",
scope="system",
storage="environment",
ui_managed=False,
risk="high",
secret_handling="env_only", # noqa: S106 # nosec B106 - policy vocabulary.
maintenance_required=True,
notes="A deployment-wide memory-safety limit applied consistently by API and worker processes.",
),
ConfigurationFieldSafety(
key="GOVOPLAN_CONNECTOR_MAX_FILE_TRANSFER_BYTES",
label="Connector file-transfer limit",
owner_module="core",
scope="system",
storage="environment",
ui_managed=False,
risk="high",
secret_handling="env_only", # noqa: S106 # nosec B106 - policy vocabulary.
maintenance_required=True,
notes="A deployment-wide hard ceiling; individual module upload policies may impose lower limits.",
),
ConfigurationFieldSafety(
key="GOVOPLAN_CONNECTOR_SECRET_ENV_ALLOWLIST",
label="Connector secret environment allowlist",
owner_module="core",
scope="system",
storage="environment",
ui_managed=False,
risk="destructive",
secret_handling="env_only", # noqa: S106 # nosec B106 - policy vocabulary.
maintenance_required=True,
notes="Exact environment names available only to deployment-owned connector profiles; tenant/API profiles cannot select process variables.",
),
ConfigurationFieldSafety(
key="GOVOPLAN_CONNECTOR_CA_BUNDLE_ALLOWLIST",
label="Connector CA bundle allowlist",
owner_module="core",
scope="system",
storage="environment",
ui_managed=False,
risk="high",
secret_handling="env_only", # noqa: S106 # nosec B106 - policy vocabulary.
maintenance_required=True,
notes="Exact absolute CA bundle paths approved by the deployment and mounted consistently on every connector worker.",
),
ConfigurationFieldSafety(
key="GOVOPLAN_HTTP_MAX_REQUEST_BODY_BYTES",
label="HTTP request-body limit",
owner_module="core",
scope="system",
storage="environment",
ui_managed=False,
risk="high",
secret_handling="env_only", # noqa: S106 # nosec B106 - policy vocabulary.
maintenance_required=True,
notes="A deployment-wide hard ceiling; endpoint-specific upload policies may impose lower limits.",
),
ConfigurationFieldSafety(
key="GOVOPLAN_HTTP_HSTS_SECONDS",
label="HTTP Strict Transport Security duration",
owner_module="core",
scope="system",
storage="environment",
ui_managed=False,
risk="high",
secret_handling="env_only", # noqa: S106 # nosec B106 - policy vocabulary.
maintenance_required=True,
notes="Deployment-managed browser transport policy; use only after the public service is HTTPS-only.",
),
ConfigurationFieldSafety(
key="AUTH_LOGIN_THROTTLE_ENABLED",
label="Interactive login throttling",
owner_module="access",
scope="system",
storage="environment",
ui_managed=False,
risk="high",
secret_handling="env_only", # noqa: S106 # nosec B106 - policy vocabulary.
maintenance_required=True,
notes="Disabling the deployment login throttle weakens protection against password guessing.",
),
ConfigurationFieldSafety(
key="AUTH_LOGIN_THROTTLE_IDENTITY_LIMIT",
label="Login failures per identity",
owner_module="access",
scope="system",
storage="environment",
ui_managed=False,
risk="high",
secret_handling="env_only", # noqa: S106 # nosec B106 - policy vocabulary.
maintenance_required=True,
notes="Shared through Redis when available, with a bounded process-local fallback.",
),
ConfigurationFieldSafety(
key="AUTH_LOGIN_THROTTLE_CLIENT_LIMIT",
label="Login failures per client",
owner_module="access",
scope="system",
storage="environment",
ui_managed=False,
risk="high",
secret_handling="env_only", # noqa: S106 # nosec B106 - policy vocabulary.
maintenance_required=True,
notes="Counts the direct peer after the deployment's trusted-proxy boundary is applied.",
),
ConfigurationFieldSafety(
key="AUTH_LOGIN_THROTTLE_WINDOW_SECONDS",
label="Login throttle window",
owner_module="access",
scope="system",
storage="environment",
ui_managed=False,
risk="high",
secret_handling="env_only", # noqa: S106 # nosec B106 - policy vocabulary.
maintenance_required=True,
notes="Fixed counter window shared by identity and direct-client limits.",
),
ConfigurationFieldSafety(
key="AUTH_LOGIN_THROTTLE_REDIS_RETRY_SECONDS",
label="Login throttle Redis retry interval",
owner_module="access",
scope="system",
storage="environment",
ui_managed=False,
risk="medium",
secret_handling="env_only", # noqa: S106 # nosec B106 - policy vocabulary.
maintenance_required=True,
notes="Controls how quickly a worker retries the distributed counter after falling back locally.",
),
ConfigurationFieldSafety(
key="GOVOPLAN_TRUSTED_HOSTS",
label="Trusted HTTP hosts",
owner_module="core",
scope="system",
storage="environment",
ui_managed=False,
risk="high",
secret_handling="env_only", # noqa: S106 # nosec B106 - policy vocabulary.
maintenance_required=True,
notes="Host-header validation is deployment-managed and must cover every public API host.",
),
ConfigurationFieldSafety(
key="FORWARDED_ALLOW_IPS",
label="Trusted reverse proxy addresses",
owner_module="core",
scope="system",
storage="environment",
ui_managed=False,
risk="high",
secret_handling="env_only", # noqa: S106 # nosec B106 - policy vocabulary.
maintenance_required=True,
notes="Uvicorn trusts forwarded client and scheme data only from this deployment-managed boundary.",
),
ConfigurationFieldSafety( ConfigurationFieldSafety(
key="GOVOPLAN_MODULE_PACKAGE_CATALOG_TRUSTED_KEYS", key="GOVOPLAN_MODULE_PACKAGE_CATALOG_TRUSTED_KEYS",
label="Module package catalog trusted keys", label="Module package catalog trusted keys",

View File

@@ -3,8 +3,10 @@ from __future__ import annotations
import base64 import base64
import json import json
import os import os
import re
from collections.abc import Mapping from collections.abc import Mapping
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path
from typing import Literal from typing import Literal
from cryptography.fernet import Fernet from cryptography.fernet import Fernet
@@ -142,6 +144,7 @@ def validate_runtime_configuration(
_validate_async_and_auth_settings(env, runtime, collector) _validate_async_and_auth_settings(env, runtime, collector)
_validate_cors_settings(env, runtime, collector) _validate_cors_settings(env, runtime, collector)
_validate_file_storage_settings(env, runtime, collector) _validate_file_storage_settings(env, runtime, collector)
_validate_outbound_connector_policy(env, runtime, collector)
_validate_module_catalog_trust(env, runtime, collector) _validate_module_catalog_trust(env, runtime, collector)
return ConfigValidationResult(profile=runtime.name, issues=tuple(collector.issues)) return ConfigValidationResult(profile=runtime.name, issues=tuple(collector.issues))
@@ -224,6 +227,14 @@ def _validate_cors_settings(env: Mapping[str, str], runtime: _RuntimeProfile, co
collector.add("error", "CORS_ORIGINS", "Wildcard CORS is not allowed for production-like installs.", "Replace `*` with exact HTTPS/WebUI origins.") collector.add("error", "CORS_ORIGINS", "Wildcard CORS is not allowed for production-like installs.", "Replace `*` with exact HTTPS/WebUI origins.")
elif runtime.production and set(cors_origins) <= _DEFAULT_LOCAL_CORS: elif runtime.production and set(cors_origins) <= _DEFAULT_LOCAL_CORS:
collector.add("warning", "CORS_ORIGINS", "CORS_ORIGINS still contains only local development origins.", "Set CORS_ORIGINS to the deployed WebUI origin.") collector.add("warning", "CORS_ORIGINS", "CORS_ORIGINS still contains only local development origins.", "Set CORS_ORIGINS to the deployed WebUI origin.")
trusted_hosts = _csv(env.get("GOVOPLAN_TRUSTED_HOSTS"))
if runtime.production_like and not trusted_hosts:
collector.add("error", "GOVOPLAN_TRUSTED_HOSTS", "Trusted HTTP hosts are not configured.", "Set GOVOPLAN_TRUSTED_HOSTS to the exact API host names accepted by this deployment.")
elif "*" in trusted_hosts and runtime.production_like:
collector.add("error", "GOVOPLAN_TRUSTED_HOSTS", "Wildcard trusted hosts are not allowed for production-like installs.", "Replace `*` with exact host names or narrowly scoped `*.example.org` entries.")
forwarded_allow_ips = _csv(env.get("FORWARDED_ALLOW_IPS"))
if runtime.production_like and "*" in forwarded_allow_ips:
collector.add("error", "FORWARDED_ALLOW_IPS", "Proxy headers must not be trusted from every address.", "Set FORWARDED_ALLOW_IPS to the reverse proxy address or network passed to Uvicorn.")
def _validate_file_storage_settings(env: Mapping[str, str], runtime: _RuntimeProfile, collector: _ConfigIssueCollector) -> None: def _validate_file_storage_settings(env: Mapping[str, str], runtime: _RuntimeProfile, collector: _ConfigIssueCollector) -> None:
@@ -249,6 +260,59 @@ def _validate_s3_file_storage(env: Mapping[str, str], collector: _ConfigIssueCol
collector.add("error", key, f"{key} is required when FILE_STORAGE_BACKEND=s3.", "Configure all FILE_STORAGE_S3_* settings through deployment secrets.") collector.add("error", key, f"{key} is required when FILE_STORAGE_BACKEND=s3.", "Configure all FILE_STORAGE_S3_* settings through deployment secrets.")
def _validate_outbound_connector_policy(
env: Mapping[str, str],
runtime: _RuntimeProfile,
collector: _ConfigIssueCollector,
) -> None:
private_networks = _clean(env.get("GOVOPLAN_CONNECTOR_ALLOW_PRIVATE_NETWORKS")).lower()
if runtime.production_like and private_networks not in {"true", "false", "1", "0", "yes", "no", "on", "off"}:
collector.add(
"error",
"GOVOPLAN_CONNECTOR_ALLOW_PRIVATE_NETWORKS",
"Private-network connector access must be an explicit deployment decision.",
"Set GOVOPLAN_CONNECTOR_ALLOW_PRIVATE_NETWORKS=false for public-only egress, or true when this deployment must reach internal services.",
)
for key in (
"GOVOPLAN_CONNECTOR_MAX_STRUCTURED_RESPONSE_BYTES",
"GOVOPLAN_CONNECTOR_MAX_FILE_TRANSFER_BYTES",
"GOVOPLAN_HTTP_MAX_REQUEST_BODY_BYTES",
):
value = _clean(env.get(key))
if not value:
continue
try:
parsed = int(value)
except ValueError:
parsed = 0
if parsed <= 0:
collector.add("error", key, f"{key} must be a positive byte count.", "Use a positive integer byte limit.")
secret_env_names = [
item.strip()
for item in env.get("GOVOPLAN_CONNECTOR_SECRET_ENV_ALLOWLIST", "").split(",")
if item.strip()
]
if any(re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", item) is None for item in secret_env_names):
collector.add(
"error",
"GOVOPLAN_CONNECTOR_SECRET_ENV_ALLOWLIST",
"Connector secret environment allowlist contains an invalid variable name.",
"Use a comma-separated list of exact environment variable names, or leave the setting empty.",
)
ca_bundle_paths = [
item.strip()
for item in env.get("GOVOPLAN_CONNECTOR_CA_BUNDLE_ALLOWLIST", "").split(",")
if item.strip()
]
if any(not Path(item).is_absolute() for item in ca_bundle_paths):
collector.add(
"error",
"GOVOPLAN_CONNECTOR_CA_BUNDLE_ALLOWLIST",
"Connector CA bundle allowlist contains a non-absolute path.",
"Use comma-separated absolute paths mounted on every API and connector worker, or leave the setting empty.",
)
def _validate_module_catalog_trust(env: Mapping[str, str], runtime: _RuntimeProfile, collector: _ConfigIssueCollector) -> None: def _validate_module_catalog_trust(env: Mapping[str, str], runtime: _RuntimeProfile, collector: _ConfigIssueCollector) -> None:
catalog_source = _clean(env.get("GOVOPLAN_MODULE_PACKAGE_CATALOG_URL")) or _clean(env.get("GOVOPLAN_MODULE_PACKAGE_CATALOG")) catalog_source = _clean(env.get("GOVOPLAN_MODULE_PACKAGE_CATALOG_URL")) or _clean(env.get("GOVOPLAN_MODULE_PACKAGE_CATALOG"))
if not runtime.production or not catalog_source: if not runtime.production or not catalog_source:
@@ -277,7 +341,28 @@ REDIS_URL=redis://127.0.0.1:6379/0
CELERY_QUEUES=send_email,append_sent,notifications,calendar,default CELERY_QUEUES=send_email,append_sent,notifications,calendar,default
CALENDAR_OUTBOX_TERMINAL_RETENTION_DAYS=90 CALENDAR_OUTBOX_TERMINAL_RETENTION_DAYS=90
# Deployment-wide connector egress policy. Enable private networks only when
# this installation intentionally integrates with internal services.
GOVOPLAN_CONNECTOR_ALLOW_PRIVATE_NETWORKS=false
GOVOPLAN_CONNECTOR_MAX_STRUCTURED_RESPONSE_BYTES=16777216
GOVOPLAN_CONNECTOR_MAX_FILE_TRANSFER_BYTES=536870912
# Exact names/paths only. Keep empty until a deployment-owned connector needs them.
GOVOPLAN_CONNECTOR_SECRET_ENV_ALLOWLIST=
GOVOPLAN_CONNECTOR_CA_BUNDLE_ALLOWLIST=
GOVOPLAN_HTTP_MAX_REQUEST_BODY_BYTES=536870912
GOVOPLAN_HTTP_HSTS_SECONDS=31536000
AUTH_LOGIN_THROTTLE_ENABLED=true
AUTH_LOGIN_THROTTLE_IDENTITY_LIMIT=10
AUTH_LOGIN_THROTTLE_CLIENT_LIMIT=100
AUTH_LOGIN_THROTTLE_WINDOW_SECONDS=900
AUTH_LOGIN_THROTTLE_REDIS_RETRY_SECONDS=30
CORS_ORIGINS=https://govoplan.example.org CORS_ORIGINS=https://govoplan.example.org
GOVOPLAN_TRUSTED_HOSTS=govoplan.example.org
# Uvicorn reads FORWARDED_ALLOW_IPS when proxy headers are enabled. Keep this
# restricted to the actual reverse proxy address or network.
FORWARDED_ALLOW_IPS=127.0.0.1
AUTH_COOKIE_SECURE=true AUTH_COOKIE_SECURE=true
AUTH_COOKIE_SAMESITE=lax AUTH_COOKIE_SAMESITE=lax
AUTH_COOKIE_DOMAIN= AUTH_COOKIE_DOMAIN=
@@ -321,8 +406,24 @@ CELERY_ENABLED=true
CELERY_QUEUES=send_email,append_sent,notifications,calendar,default CELERY_QUEUES=send_email,append_sent,notifications,calendar,default
CALENDAR_OUTBOX_TERMINAL_RETENTION_DAYS=90 CALENDAR_OUTBOX_TERMINAL_RETENTION_DAYS=90
GOVOPLAN_CONNECTOR_ALLOW_PRIVATE_NETWORKS=true
GOVOPLAN_CONNECTOR_MAX_STRUCTURED_RESPONSE_BYTES=16777216
GOVOPLAN_CONNECTOR_MAX_FILE_TRANSFER_BYTES=536870912
GOVOPLAN_CONNECTOR_SECRET_ENV_ALLOWLIST=
GOVOPLAN_CONNECTOR_CA_BUNDLE_ALLOWLIST=
GOVOPLAN_HTTP_MAX_REQUEST_BODY_BYTES=536870912
GOVOPLAN_HTTP_HSTS_SECONDS=0
AUTH_LOGIN_THROTTLE_ENABLED=true
AUTH_LOGIN_THROTTLE_IDENTITY_LIMIT=10
AUTH_LOGIN_THROTTLE_CLIENT_LIMIT=100
AUTH_LOGIN_THROTTLE_WINDOW_SECONDS=900
AUTH_LOGIN_THROTTLE_REDIS_RETRY_SECONDS=30
ENABLED_MODULES=tenancy,organizations,identity,access,admin,dashboard,policy,audit,campaigns,files,mail,calendar,docs,ops ENABLED_MODULES=tenancy,organizations,identity,access,admin,dashboard,policy,audit,campaigns,files,mail,calendar,docs,ops
CORS_ORIGINS=http://127.0.0.1:5173,http://localhost:5173 CORS_ORIGINS=http://127.0.0.1:5173,http://localhost:5173
GOVOPLAN_TRUSTED_HOSTS=127.0.0.1,localhost,testserver
FORWARDED_ALLOW_IPS=127.0.0.1
AUTH_COOKIE_SECURE=false AUTH_COOKIE_SECURE=false
FILE_STORAGE_BACKEND=local FILE_STORAGE_BACKEND=local
FILE_STORAGE_LOCAL_ROOT=runtime/production-like/files FILE_STORAGE_LOCAL_ROOT=runtime/production-like/files

View File

@@ -9,14 +9,21 @@ from typing import Any
from fastapi import APIRouter, FastAPI, Request from fastapi import APIRouter, FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from starlette.middleware.trustedhost import TrustedHostMiddleware
from govoplan_core.core.events import event_context, new_event_id, normalize_trace_id from govoplan_core.core.events import event_context, new_event_id, normalize_trace_id
from govoplan_core.core.install_config import validate_runtime_configuration
from govoplan_core.core.registry import PlatformRegistry from govoplan_core.core.registry import PlatformRegistry
from govoplan_core.db.query_metrics import collect_query_metrics from govoplan_core.db.query_metrics import collect_query_metrics
from govoplan_core.server.conditional_requests import conditional_json_get_middleware from govoplan_core.server.conditional_requests import conditional_json_get_middleware
from govoplan_core.server.request_limits import RequestBodyLimitMiddleware
LifespanFactory = Callable[[FastAPI], AbstractAsyncContextManager[None] | AsyncIterator[None]] LifespanFactory = Callable[[FastAPI], AbstractAsyncContextManager[None] | AsyncIterator[None]]
logger = logging.getLogger("govoplan.request") logger = logging.getLogger("govoplan.request")
_CONTENT_SECURITY_POLICY = "base-uri 'self'; object-src 'none'; frame-ancestors 'none'"
_PRODUCTION_LIKE_ENVIRONMENTS = frozenset(
{"prod", "production", "self-hosted", "staging", "production-like", "production-like-dev"}
)
def _slow_request_threshold_ms() -> float: def _slow_request_threshold_ms() -> float:
@@ -27,6 +34,49 @@ def _slow_request_threshold_ms() -> float:
return 500.0 return 500.0
def _hsts_seconds() -> int:
default = "31536000" if os.getenv("APP_ENV", "dev").strip().lower() in {"prod", "production"} else "0"
raw = os.getenv("GOVOPLAN_HTTP_HSTS_SECONDS", default).strip()
try:
return max(0, int(raw))
except ValueError:
return int(default)
def _max_request_body_bytes() -> int:
default = 512 * 1024 * 1024
raw = os.getenv("GOVOPLAN_HTTP_MAX_REQUEST_BODY_BYTES", str(default)).strip()
try:
value = int(raw)
except ValueError:
return default
return value if value > 0 else default
def _trusted_hosts() -> tuple[str, ...]:
return tuple(item.strip() for item in os.getenv("GOVOPLAN_TRUSTED_HOSTS", "").split(",") if item.strip())
def _validate_production_startup() -> None:
app_env = os.getenv("APP_ENV", "").strip().lower().replace("_", "-")
install_profile = os.getenv("GOVOPLAN_INSTALL_PROFILE", "").strip().lower().replace("_", "-")
if app_env not in _PRODUCTION_LIKE_ENVIRONMENTS and install_profile not in _PRODUCTION_LIKE_ENVIRONMENTS:
return
validation = validate_runtime_configuration()
if validation.errors:
raise RuntimeError(validation.to_text())
throttle_enabled = os.getenv("AUTH_LOGIN_THROTTLE_ENABLED", "true").strip().lower() not in {
"0",
"false",
"no",
"off",
}
if throttle_enabled and not os.getenv("REDIS_URL", "").strip():
logger.warning(
"Redis is not configured; login throttling is process-local and will not coordinate horizontally"
)
def create_govoplan_app( def create_govoplan_app(
*, *,
title: str, title: str,
@@ -37,9 +87,27 @@ def create_govoplan_app(
cors_origins: Iterable[str] = (), cors_origins: Iterable[str] = (),
health_payload: dict[str, Any] | None = None, health_payload: dict[str, Any] | None = None,
) -> FastAPI: ) -> FastAPI:
_validate_production_startup()
app = FastAPI(title=title, version=version, lifespan=lifespan) app = FastAPI(title=title, version=version, lifespan=lifespan)
app.add_middleware(RequestBodyLimitMiddleware, max_bytes=_max_request_body_bytes())
trusted_hosts = _trusted_hosts()
if trusted_hosts:
app.add_middleware(TrustedHostMiddleware, allowed_hosts=list(trusted_hosts))
app.state.govoplan_registry = registry app.state.govoplan_registry = registry
slow_request_threshold_ms = _slow_request_threshold_ms() slow_request_threshold_ms = _slow_request_threshold_ms()
hsts_seconds = _hsts_seconds()
@app.middleware("http")
async def security_response_headers(request: Request, call_next):
response = await call_next(request)
response.headers.setdefault("X-Content-Type-Options", "nosniff")
response.headers.setdefault("X-Frame-Options", "DENY")
response.headers.setdefault("Referrer-Policy", "strict-origin-when-cross-origin")
response.headers.setdefault("Permissions-Policy", "camera=(), microphone=(), geolocation=()")
response.headers.setdefault("Content-Security-Policy", _CONTENT_SECURITY_POLICY)
if hsts_seconds and request.url.scheme == "https":
response.headers.setdefault("Strict-Transport-Security", f"max-age={hsts_seconds}")
return response
@app.middleware("http") @app.middleware("http")
async def request_correlation_context(request: Request, call_next): async def request_correlation_context(request: Request, call_next):

View File

@@ -0,0 +1,71 @@
from __future__ import annotations
from collections.abc import Iterable
from typing import Any
from starlette.responses import JSONResponse
class _RequestBodyTooLarge(RuntimeError):
pass
class RequestBodyLimitMiddleware:
def __init__(self, app: Any, *, max_bytes: int) -> None:
if max_bytes <= 0:
raise ValueError("max_bytes must be positive")
self.app = app
self.max_bytes = max_bytes
async def __call__(self, scope: dict[str, Any], receive: Any, send: Any) -> None:
if scope.get("type") != "http":
await self.app(scope, receive, send)
return
declared = _content_length(scope.get("headers", ()))
if declared is not None and declared > self.max_bytes:
await self._reject(scope, receive, send)
return
received = 0
response_started = False
async def limited_receive() -> dict[str, Any]:
nonlocal received
message = await receive()
if message.get("type") == "http.request":
received += len(message.get("body", b""))
if received > self.max_bytes:
raise _RequestBodyTooLarge
return message
async def tracked_send(message: dict[str, Any]) -> None:
nonlocal response_started
if message.get("type") == "http.response.start":
response_started = True
await send(message)
try:
await self.app(scope, limited_receive, tracked_send)
except _RequestBodyTooLarge:
if response_started:
raise
await self._reject(scope, receive, send)
async def _reject(self, scope: dict[str, Any], receive: Any, send: Any) -> None:
response = JSONResponse(
{"detail": f"Request body exceeds the deployment limit of {self.max_bytes} bytes"},
status_code=413,
)
await response(scope, receive, send)
def _content_length(headers: Iterable[tuple[bytes, bytes]]) -> int | None:
for key, value in headers:
if bytes(key).lower() != b"content-length":
continue
try:
parsed = int(bytes(value))
except (TypeError, ValueError):
return None
return parsed if parsed >= 0 else None
return None

View File

@@ -47,6 +47,27 @@ class Settings(BaseSettings):
auth_cookie_samesite: str = Field(default="lax", alias="AUTH_COOKIE_SAMESITE") auth_cookie_samesite: str = Field(default="lax", alias="AUTH_COOKIE_SAMESITE")
auth_cookie_domain: str | None = Field(default=None, alias="AUTH_COOKIE_DOMAIN") auth_cookie_domain: str | None = Field(default=None, alias="AUTH_COOKIE_DOMAIN")
auth_session_hours: int = Field(default=12, alias="AUTH_SESSION_HOURS") auth_session_hours: int = Field(default=12, alias="AUTH_SESSION_HOURS")
auth_login_throttle_enabled: bool = Field(default=True, alias="AUTH_LOGIN_THROTTLE_ENABLED")
auth_login_throttle_identity_limit: int = Field(
default=10,
ge=1,
alias="AUTH_LOGIN_THROTTLE_IDENTITY_LIMIT",
)
auth_login_throttle_client_limit: int = Field(
default=100,
ge=1,
alias="AUTH_LOGIN_THROTTLE_CLIENT_LIMIT",
)
auth_login_throttle_window_seconds: int = Field(
default=15 * 60,
ge=1,
alias="AUTH_LOGIN_THROTTLE_WINDOW_SECONDS",
)
auth_login_throttle_redis_retry_seconds: int = Field(
default=30,
ge=1,
alias="AUTH_LOGIN_THROTTLE_REDIS_RETRY_SECONDS",
)
master_key_b64: str | None = Field(default=None, alias="MASTER_KEY_B64") master_key_b64: str | None = Field(default=None, alias="MASTER_KEY_B64")
celery_queues: str = Field(default="send_email,append_sent,notifications,calendar,default", alias="CELERY_QUEUES") celery_queues: str = Field(default="send_email,append_sent,notifications,calendar,default", alias="CELERY_QUEUES")

View File

@@ -1811,12 +1811,21 @@ class ApiSmokeTests(unittest.TestCase):
tenant_id = str(login["tenant"]["id"]) tenant_id = str(login["tenant"]["id"])
env_keys = [ env_keys = [
"GOVOPLAN_FILES_CONNECTOR_PROFILES_JSON", "GOVOPLAN_FILES_CONNECTOR_PROFILES_JSON",
"GOVOPLAN_CONNECTOR_SECRET_ENV_ALLOWLIST",
"GOVOPLAN_TEST_SEAFILE_PASSWORD", "GOVOPLAN_TEST_SEAFILE_PASSWORD",
"GOVOPLAN_TEST_NEXTCLOUD_TOKEN", "GOVOPLAN_TEST_NEXTCLOUD_TOKEN",
"GOVOPLAN_TEST_SMB_PASSWORD", "GOVOPLAN_TEST_SMB_PASSWORD",
] ]
previous_env = {key: os.environ.get(key) for key in env_keys} previous_env = {key: os.environ.get(key) for key in env_keys}
try: try:
os.environ["GOVOPLAN_CONNECTOR_SECRET_ENV_ALLOWLIST"] = ",".join(
(
"GOVOPLAN_TEST_SEAFILE_PASSWORD",
"GOVOPLAN_TEST_WEBDAV_PASSWORD",
"GOVOPLAN_TEST_NEXTCLOUD_TOKEN",
"GOVOPLAN_TEST_SMB_PASSWORD",
)
)
os.environ["GOVOPLAN_TEST_SEAFILE_PASSWORD"] = "super-secret-seafile" os.environ["GOVOPLAN_TEST_SEAFILE_PASSWORD"] = "super-secret-seafile"
os.environ["GOVOPLAN_TEST_NEXTCLOUD_TOKEN"] = "super-secret-nextcloud" os.environ["GOVOPLAN_TEST_NEXTCLOUD_TOKEN"] = "super-secret-nextcloud"
os.environ["GOVOPLAN_TEST_SMB_PASSWORD"] = "super-secret-smb" os.environ["GOVOPLAN_TEST_SMB_PASSWORD"] = "super-secret-smb"
@@ -2203,36 +2212,37 @@ class ApiSmokeTests(unittest.TestCase):
self.assertFalse(deleted_space.json()["is_active"]) self.assertFalse(deleted_space.json()["is_active"])
self.assertIsNotNone(deleted_space.json()["deleted_at"]) self.assertIsNotNone(deleted_space.json()["deleted_at"])
import httpx from govoplan_files.backend.storage.http_client import ConnectorHttpResponse
def seafile_request(method: str, url: str, **kwargs): def seafile_request(method: str, url: str, **kwargs):
request = httpx.Request(method, url)
params = kwargs.get("params") or {} params = kwargs.get("params") or {}
if url == "http://127.0.0.1:9082/api2/auth-token/": if url == "http://127.0.0.1:9082/api2/auth-token/":
return httpx.Response(200, json={"token": "token-1"}, request=request) return ConnectorHttpResponse(200, {}, b'{"token":"token-1"}')
if url == "http://127.0.0.1:9082/api2/repos/repo-1/file/detail/": if url == "http://127.0.0.1:9082/api2/repos/repo-1/file/detail/":
self.assertEqual(params.get("p"), "/reports/summary.txt") self.assertEqual(params.get("p"), "/reports/summary.txt")
self.assertEqual(kwargs.get("headers", {}).get("Authorization"), "Token token-1") self.assertEqual(kwargs.get("headers", {}).get("Authorization"), "Token token-1")
return httpx.Response( return ConnectorHttpResponse(
200, 200,
json={ {},
"id": "file-1", json.dumps(
"name": "summary.txt", {
"size": 14, "id": "file-1",
"type": "file", "name": "summary.txt",
"mtime": 1783425600, "size": 14,
"permission": "r", "type": "file",
}, "mtime": 1783425600,
request=request, "permission": "r",
}
).encode(),
) )
if url == "http://127.0.0.1:9082/api2/repos/repo-1/file/": if url == "http://127.0.0.1:9082/api2/repos/repo-1/file/":
self.assertEqual(params, {"p": "/reports/summary.txt", "reuse": "1"}) self.assertEqual(params, {"p": "/reports/summary.txt", "reuse": "1"})
return httpx.Response(200, json="https://download.example.invalid/summary.txt", request=request) return ConnectorHttpResponse(200, {}, b'"https://download.example.invalid/summary.txt"')
if url == "https://download.example.invalid/summary.txt": if url == "https://download.example.invalid/summary.txt":
return httpx.Response(200, content=b"summary report", headers={"content-type": "text/plain"}, request=request) return ConnectorHttpResponse(200, {"content-type": "text/plain"}, b"summary report")
return httpx.Response(404, request=request) return ConnectorHttpResponse(404, {}, b"")
with patch("govoplan_files.backend.storage.connector_browse.httpx.request", side_effect=seafile_request), patch("govoplan_files.backend.storage.connector_imports.httpx.request", side_effect=seafile_request): with patch("govoplan_files.backend.storage.connector_browse.request_connector_bytes", side_effect=seafile_request), patch("govoplan_files.backend.storage.connector_imports.request_connector_bytes", side_effect=seafile_request):
imported = self.client.post( imported = self.client.post(
"/api/v1/files/connectors/profiles/seafile-dev/import", "/api/v1/files/connectors/profiles/seafile-dev/import",
headers=headers, headers=headers,
@@ -2259,18 +2269,16 @@ class ApiSmokeTests(unittest.TestCase):
webdav_payload = {"content": b"nextcloud notice", "etag": "\"nextcloud-etag-2\""} webdav_payload = {"content": b"nextcloud notice", "etag": "\"nextcloud-etag-2\""}
def webdav_request(method: str, url: str, **kwargs): def webdav_request(method: str, url: str, **kwargs):
request = httpx.Request(method, url)
self.assertEqual(method, "GET") self.assertEqual(method, "GET")
self.assertEqual(url, "http://127.0.0.1:9081/Shared/notice.txt") self.assertEqual(url, "http://127.0.0.1:9081/Shared/notice.txt")
self.assertEqual(kwargs.get("headers", {}).get("Authorization"), "Bearer super-secret-nextcloud") self.assertEqual(kwargs.get("headers", {}).get("Authorization"), "Bearer super-secret-nextcloud")
return httpx.Response( return ConnectorHttpResponse(
200, 200,
content=webdav_payload["content"], {"content-type": "text/plain", "etag": webdav_payload["etag"], "content-length": str(len(webdav_payload["content"]))},
headers={"content-type": "text/plain", "etag": webdav_payload["etag"], "content-length": str(len(webdav_payload["content"]))}, webdav_payload["content"],
request=request,
) )
with patch("govoplan_files.backend.storage.connector_imports.httpx.request", side_effect=webdav_request): with patch("govoplan_files.backend.storage.connector_imports.request_connector_bytes", side_effect=webdav_request):
nextcloud_import = self.client.post( nextcloud_import = self.client.post(
"/api/v1/files/connectors/profiles/user-nextcloud/import", "/api/v1/files/connectors/profiles/user-nextcloud/import",
headers=headers, headers=headers,
@@ -2292,7 +2300,7 @@ class ApiSmokeTests(unittest.TestCase):
webdav_payload["content"] = b"nextcloud notice updated" webdav_payload["content"] = b"nextcloud notice updated"
webdav_payload["etag"] = "\"nextcloud-etag-3\"" webdav_payload["etag"] = "\"nextcloud-etag-3\""
with patch("govoplan_files.backend.storage.connector_imports.httpx.request", side_effect=webdav_request): with patch("govoplan_files.backend.storage.connector_imports.request_connector_bytes", side_effect=webdav_request):
nextcloud_sync = self.client.post( nextcloud_sync = self.client.post(
"/api/v1/files/connectors/profiles/user-nextcloud/sync", "/api/v1/files/connectors/profiles/user-nextcloud/sync",
headers=headers, headers=headers,
@@ -2314,7 +2322,7 @@ class ApiSmokeTests(unittest.TestCase):
self.assertEqual(synced["file"]["source_revision"], "\"nextcloud-etag-3\"") self.assertEqual(synced["file"]["source_revision"], "\"nextcloud-etag-3\"")
self.assertEqual(synced["file"]["size_bytes"], len(webdav_payload["content"])) self.assertEqual(synced["file"]["size_bytes"], len(webdav_payload["content"]))
with patch("govoplan_files.backend.storage.connector_imports.httpx.request", side_effect=webdav_request): with patch("govoplan_files.backend.storage.connector_imports.request_connector_bytes", side_effect=webdav_request):
nextcloud_sync_unchanged = self.client.post( nextcloud_sync_unchanged = self.client.post(
"/api/v1/files/connectors/profiles/user-nextcloud/sync", "/api/v1/files/connectors/profiles/user-nextcloud/sync",
headers=headers, headers=headers,

View File

@@ -4,8 +4,9 @@ import shutil
import tempfile import tempfile
import unittest import unittest
from pathlib import Path from pathlib import Path
from unittest.mock import patch
from fastapi import APIRouter from fastapi import APIRouter, Request
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
from govoplan_core.audit.logging import audit_event, audit_operation_context from govoplan_core.audit.logging import audit_event, audit_operation_context
@@ -142,6 +143,75 @@ class CoreEventTests(unittest.TestCase):
cors_origins=("*",), cors_origins=("*",),
) )
def test_app_sets_safe_default_browser_headers_and_https_hsts(self) -> None:
with patch.dict(
"os.environ",
{"APP_ENV": "test", "GOVOPLAN_HTTP_HSTS_SECONDS": "86400"},
):
app = create_govoplan_app(
title="security header test",
version="test",
registry=PlatformRegistry(),
)
with TestClient(app, base_url="https://govoplan.example.test") as client:
response = client.get("/health")
self.assertEqual("nosniff", response.headers["X-Content-Type-Options"])
self.assertEqual("DENY", response.headers["X-Frame-Options"])
self.assertEqual("strict-origin-when-cross-origin", response.headers["Referrer-Policy"])
self.assertIn("frame-ancestors 'none'", response.headers["Content-Security-Policy"])
self.assertEqual("max-age=86400", response.headers["Strict-Transport-Security"])
def test_production_app_startup_rejects_an_unvalidated_environment(self) -> None:
with patch.dict("os.environ", {"APP_ENV": "production"}, clear=True), self.assertRaisesRegex(
RuntimeError,
"GovOPlaN configuration validation: FAILED",
):
create_govoplan_app(
title="unsafe production test",
version="test",
registry=PlatformRegistry(),
)
def test_app_rejects_request_bodies_above_deployment_limit(self) -> None:
router = APIRouter()
@router.post("/body")
async def body(request: Request):
return {"size": len(await request.body())}
with patch.dict("os.environ", {"GOVOPLAN_HTTP_MAX_REQUEST_BODY_BYTES": "10"}):
app = create_govoplan_app(
title="request body limit test",
version="test",
registry=PlatformRegistry(),
api_router=router,
)
with TestClient(app) as client:
response = client.post("/body", content=b"12345678901")
streamed = client.post("/body", content=(chunk for chunk in (b"123456", b"78901")))
self.assertEqual(413, response.status_code, response.text)
self.assertIn("10 bytes", response.json()["detail"])
self.assertEqual(413, streamed.status_code, streamed.text)
def test_app_enforces_configured_trusted_hosts(self) -> None:
with patch.dict("os.environ", {"GOVOPLAN_TRUSTED_HOSTS": "govoplan.example.test,*.internal.test"}):
app = create_govoplan_app(
title="trusted host test",
version="test",
registry=PlatformRegistry(),
)
with TestClient(app, base_url="https://govoplan.example.test") as client:
allowed = client.get("/health")
rejected = client.get("https://attacker.example.test/health")
self.assertEqual(200, allowed.status_code, allowed.text)
self.assertEqual(400, rejected.status_code, rejected.text)
def test_audit_event_persists_trace_details_from_event_context(self) -> None: def test_audit_event_persists_trace_details_from_event_context(self) -> None:
root = Path(tempfile.mkdtemp(prefix="govoplan-audit-trace-")) root = Path(tempfile.mkdtemp(prefix="govoplan-audit-trace-"))
try: try:

View File

@@ -45,7 +45,9 @@ class InstallConfigTests(unittest.TestCase):
"CELERY_ENABLED": "true", "CELERY_ENABLED": "true",
"REDIS_URL": "redis://redis.example.internal:6379/0", "REDIS_URL": "redis://redis.example.internal:6379/0",
"CORS_ORIGINS": "https://govoplan.example.org", "CORS_ORIGINS": "https://govoplan.example.org",
"GOVOPLAN_TRUSTED_HOSTS": "govoplan.example.org",
"AUTH_COOKIE_SECURE": "true", "AUTH_COOKIE_SECURE": "true",
"GOVOPLAN_CONNECTOR_ALLOW_PRIVATE_NETWORKS": "false",
"FILE_STORAGE_BACKEND": "local", "FILE_STORAGE_BACKEND": "local",
"FILE_STORAGE_LOCAL_ROOT": "/var/lib/govoplan/files", "FILE_STORAGE_LOCAL_ROOT": "/var/lib/govoplan/files",
}, },
@@ -75,6 +77,35 @@ class InstallConfigTests(unittest.TestCase):
self.assertIn("DATABASE_URL", error_keys) self.assertIn("DATABASE_URL", error_keys)
self.assertIn("DEV_BOOTSTRAP_ENABLED", error_keys) self.assertIn("DEV_BOOTSTRAP_ENABLED", error_keys)
def test_production_validation_rejects_wildcard_host_and_proxy_trust(self) -> None:
result = validate_runtime_configuration(
{
"APP_ENV": "production",
"GOVOPLAN_TRUSTED_HOSTS": "*",
"FORWARDED_ALLOW_IPS": "*",
"GOVOPLAN_CONNECTOR_ALLOW_PRIVATE_NETWORKS": "false",
},
profile="self-hosted",
)
error_keys = {issue.key for issue in result.errors}
self.assertIn("GOVOPLAN_TRUSTED_HOSTS", error_keys)
self.assertIn("FORWARDED_ALLOW_IPS", error_keys)
def test_connector_deployment_allowlists_require_exact_names_and_absolute_paths(self) -> None:
result = validate_runtime_configuration(
{
"APP_ENV": "development",
"GOVOPLAN_CONNECTOR_SECRET_ENV_ALLOWLIST": "VALID_SECRET,invalid-name",
"GOVOPLAN_CONNECTOR_CA_BUNDLE_ALLOWLIST": "relative/connector-ca.pem",
},
profile="development",
)
error_keys = {issue.key for issue in result.errors}
self.assertIn("GOVOPLAN_CONNECTOR_SECRET_ENV_ALLOWLIST", error_keys)
self.assertIn("GOVOPLAN_CONNECTOR_CA_BUNDLE_ALLOWLIST", error_keys)
def test_env_templates_surface_install_profiles(self) -> None: def test_env_templates_surface_install_profiles(self) -> None:
self_hosted = env_template(profile="self-hosted") self_hosted = env_template(profile="self-hosted")
production_like = env_template(profile="production-like", generate_secrets=True) production_like = env_template(profile="production-like", generate_secrets=True)
@@ -82,9 +113,21 @@ class InstallConfigTests(unittest.TestCase):
self.assertIn("GOVOPLAN_INSTALL_PROFILE=self-hosted", self_hosted) self.assertIn("GOVOPLAN_INSTALL_PROFILE=self-hosted", self_hosted)
self.assertIn("MASTER_KEY_B64=<generate", self_hosted) self.assertIn("MASTER_KEY_B64=<generate", self_hosted)
self.assertIn("CALENDAR_OUTBOX_TERMINAL_RETENTION_DAYS=90", self_hosted) self.assertIn("CALENDAR_OUTBOX_TERMINAL_RETENTION_DAYS=90", self_hosted)
self.assertIn("GOVOPLAN_CONNECTOR_ALLOW_PRIVATE_NETWORKS=false", self_hosted)
self.assertIn("GOVOPLAN_CONNECTOR_SECRET_ENV_ALLOWLIST=", self_hosted)
self.assertIn("GOVOPLAN_CONNECTOR_CA_BUNDLE_ALLOWLIST=", self_hosted)
self.assertIn("GOVOPLAN_TRUSTED_HOSTS=govoplan.example.org", self_hosted)
self.assertIn("GOVOPLAN_HTTP_HSTS_SECONDS=31536000", self_hosted)
self.assertIn("AUTH_LOGIN_THROTTLE_IDENTITY_LIMIT=10", self_hosted)
self.assertIn("GOVOPLAN_INSTALL_PROFILE=production-like", production_like) self.assertIn("GOVOPLAN_INSTALL_PROFILE=production-like", production_like)
self.assertNotIn("MASTER_KEY_B64=<generate", production_like) self.assertNotIn("MASTER_KEY_B64=<generate", production_like)
self.assertIn("CALENDAR_OUTBOX_TERMINAL_RETENTION_DAYS=90", production_like) self.assertIn("CALENDAR_OUTBOX_TERMINAL_RETENTION_DAYS=90", production_like)
self.assertIn("GOVOPLAN_CONNECTOR_ALLOW_PRIVATE_NETWORKS=true", production_like)
self.assertIn("GOVOPLAN_CONNECTOR_SECRET_ENV_ALLOWLIST=", production_like)
self.assertIn("GOVOPLAN_CONNECTOR_CA_BUNDLE_ALLOWLIST=", production_like)
self.assertIn("GOVOPLAN_TRUSTED_HOSTS=127.0.0.1,localhost,testserver", production_like)
self.assertIn("GOVOPLAN_HTTP_HSTS_SECONDS=0", production_like)
self.assertIn("AUTH_LOGIN_THROTTLE_IDENTITY_LIMIT=10", production_like)
if __name__ == "__main__": if __name__ == "__main__":