Files
PacketStation/server/packetstation/settings.py
T
spencermandClaude Fable 5.1 37d9a0cb50 Phase 2: core domain, policy engine, packet API, Clerical screen
Migration 0003: projects, aliases, renumbers, units, packets and every child table,
change orders, assets, documents, ITPs, station limits, department weld flag.
Policy package: Principal, PacketState, Action rules, custody, preconditions,
CO hold, order of checks, can/blocked/holder on reads. docs/POLICY.md with the
tightenings table for Seth. Write pipeline: FOR UPDATE, CAS on status and dept,
If-Match row versions, server-assigned numbers, field CAS, version bump, packet
log, audit, NOTIFY, signatures, IT reasons, filed lock and HMAC seal.
API: /api/packets (header, transitions, work orders, pages, docs, move, remove,
BOM, shorts, welds, heat tags, heat table, stage, checklist, clock, warehouse
stage, Q.C., change orders, log, seal), /api/projects, /api/units, /api/assets,
/_blob. Frontend: Clerical home, projects and units, packet page with pdf.js
and QR codes, hand draft, heat map, send to warehouse, department or Q.C.
Tests: policy matrix, API, concurrency, schema, projects; Playwright Clerical
draft scenario. Verified on docker-1 with the CI stack and the Samba DC.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-10-16 09:31:45 -06:00

178 lines
6.6 KiB
Python

"""Settings from environment variables (no .env autoload; PS_ENV_FILE points at one for dev)."""
from __future__ import annotations
import os
from functools import lru_cache
from pathlib import Path
from typing import Any, Literal
from pydantic import Field, SecretStr, field_validator, model_validator
from pydantic_settings import BaseSettings, SettingsConfigDict
ENV_FILE_VAR = "PS_ENV_FILE"
Stack = Literal["prod", "test", "ci", "dev"]
class SecretFileError(Exception):
"""A secret file is missing, unreadable or empty. The message names the file, never its content."""
def read_secret(path: Path | str, what: str = "secret") -> str:
"""Read a password or key file, stripping trailing CR/LF. Empty is an error."""
p = Path(path)
try:
text = p.read_text(encoding="utf-8")
except OSError as e:
raise SecretFileError(f"can't read {what} file {p}: {e.strerror or type(e).__name__}") from None
value = text.rstrip("\r\n")
if not value:
raise SecretFileError(f"{what} file {p} is empty")
return value
_OPTIONAL_PATHS = ("ldap_ca_bundle", "smtp_ca_bundle")
class Settings(BaseSettings):
model_config = SettingsConfigDict(extra="ignore", case_sensitive=False)
def __init__(self, **values: Any):
# Containers set no PS_ENV_FILE, so only the real environment is read there
values.setdefault("_env_file", os.environ.get(ENV_FILE_VAR) or None)
super().__init__(**values)
stack: Stack
site_id: str = "PESCO"
site_tz: str = "America/Denver"
public_url: str = ""
trusted_proxy_host: str = "npm_app_1"
pg_host: str = "postgres"
pg_port: int = 5432
pg_database: str = "packetstation"
pg_app_user: str = "ps_app"
pg_app_password_file: Path = Path("/run/secrets/postgres_app_password")
pg_migrate_user: str = "ps_migrate"
pg_migrate_password_file: Path = Path("/run/secrets/postgres_migrate_password")
pg_superuser_url: SecretStr | None = Field(default=None, description="dev/test only, for --bootstrap-roles")
assets_dir: Path = Path("/data/assets")
backup_dir: Path = Path("/backups")
session_key_file: Path = Path("/run/secrets/session_key")
seal_key_file: Path = Path("/run/secrets/seal_key")
static_dir: Path = Path("/app/static")
worker_beat_file: Path = Path("/tmp/ps-worker.beat") # noqa: S108 (private /tmp in the container)
ps_build: str = "dev"
log_level: str = "INFO"
ldap_url: str = ""
ldap_base_dn: str = ""
ldap_bind_dn: str = ""
ldap_password_file: Path = Path("/run/secrets/ldap_bind_password")
ldap_user_filter: str = "(|(sAMAccountName={0})(userPrincipalName={0}))"
ldap_group_base_dn: str = ""
ldap_ca_bundle: Path | None = None
ldap_starttls: bool = False
ldap_allow_insecure: bool = False
smtp_host: str = "app-03.pesco.com"
smtp_port: int = 25
smtp_from: str = "[email protected]"
smtp_starttls: bool = True
smtp_ca_bundle: Path | None = None
notify_dry_run: bool = False
session_idle_minutes: int = Field(default=15, ge=1)
session_lock_minutes: int = Field(default=3, ge=1)
session_hidden_lock_seconds: int = Field(default=60, ge=0)
session_password_hours: int = Field(default=12, ge=1)
session_pin_hours: int = Field(default=10, ge=1)
roles_refresh_minutes: int = Field(default=15, ge=1)
signin_account_limit: int = Field(default=5, ge=1)
signin_account_window_minutes: int = Field(default=5, ge=1)
signin_account_cooldown_minutes: int = Field(default=10, ge=0)
signin_ip_limit: int = Field(default=30, ge=1)
signin_ip_window_minutes: int = Field(default=5, ge=1)
pin_max_failures: int = Field(default=5, ge=1)
stepup_max_failures: int = Field(default=3, ge=1)
device_cookie_days: int = Field(default=365, ge=1)
pin_min_digits: int = Field(default=6, ge=4)
pin_max_digits: int = Field(default=12, ge=4)
upload_max_bytes: int = Field(default=25 * 1024 * 1024, ge=1024)
@model_validator(mode="before")
@classmethod
def _blank_is_unset(cls, data: Any) -> Any:
if isinstance(data, dict):
data = dict(data)
for key in list(data):
if key.lower() in _OPTIONAL_PATHS and data[key] in ("", None):
data[key] = None
for key in list(data):
if key.lower() == "stack" and isinstance(data[key], str):
data[key] = data[key].strip().lower()
return data
@field_validator("log_level")
@classmethod
def _level(cls, v: str) -> str:
v = v.strip().upper()
if v not in ("DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"):
raise ValueError("LOG_LEVEL must be DEBUG, INFO, WARNING, ERROR or CRITICAL")
return v
@model_validator(mode="after")
def _insecure_ldap(self) -> Settings:
if self.stack == "prod" and self.ldap_allow_insecure:
raise ValueError("LDAP_ALLOW_INSECURE=true is refused on STACK=prod")
return self
@model_validator(mode="after")
def _pin_digits(self) -> Settings:
if self.pin_min_digits > self.pin_max_digits:
raise ValueError("PIN_MIN_DIGITS must not exceed PIN_MAX_DIGITS")
return self
@property
def ldap_urls(self) -> list[str]:
return self.ldap_url.split()
@property
def is_prod(self) -> bool:
return self.stack == "prod"
def pg_password(self, role: str) -> str:
if role == "migrate":
return read_secret(self.pg_migrate_password_file, "PG_MIGRATE_PASSWORD_FILE")
return read_secret(self.pg_app_password_file, "PG_APP_PASSWORD_FILE")
def pg_user(self, role: str) -> str:
return self.pg_migrate_user if role == "migrate" else self.pg_app_user
def ldap_password(self) -> str:
return read_secret(self.ldap_password_file, "LDAP_PASSWORD_FILE")
def session_key(self) -> bytes:
"""The HMAC key for session and device tokens, from SESSION_KEY_FILE."""
key = read_secret(self.session_key_file, "SESSION_KEY_FILE").strip()
if len(key) < 16:
raise SecretFileError(f"SESSION_KEY_FILE file {self.session_key_file} holds a key that is too short")
return key.encode("utf-8")
def seal_key(self) -> bytes:
"""The HMAC key for filing seals, from SEAL_KEY_FILE."""
key = read_secret(self.seal_key_file, "SEAL_KEY_FILE").strip()
if len(key) < 16:
raise SecretFileError(f"SEAL_KEY_FILE file {self.seal_key_file} holds a key that is too short")
return key.encode("utf-8")
@lru_cache(maxsize=1)
def get_settings() -> Settings:
return Settings()
def clear_settings_cache() -> None:
get_settings.cache_clear()