feat: consolidate shared UI and harden browser authority for release

This commit is contained in:
2026-09-08 01:35:05 +02:00
parent ac40774785
commit b75ca34295
143 changed files with 8664 additions and 752 deletions
+8
View File
@@ -95,6 +95,13 @@ class TenantMembershipInfo(TenantInfo):
is_active: bool = True
class NavigationSeparatorPayload(BaseModel):
model_config = ConfigDict(extra="forbid")
id: str = Field(pattern=r"^separator:[a-zA-Z0-9_.:-]+$", max_length=255)
label: str = Field(default="", max_length=120, pattern=r"^[^\x00-\x1f]*$")
class NavigationPreferencesPayload(BaseModel):
model_config = ConfigDict(extra="forbid")
@@ -102,6 +109,7 @@ class NavigationPreferencesPayload(BaseModel):
order: list[str] = Field(default_factory=list, max_length=256)
hidden: list[str] = Field(default_factory=list, max_length=256)
locked: list[str] = Field(default_factory=list, max_length=256)
separators: list[NavigationSeparatorPayload] | None = Field(default=None, max_length=256)
class AppearanceModeOverrides(BaseModel):
+3 -2
View File
@@ -780,8 +780,9 @@ def send_email(self, job_id: str):
"""Send one explicitly queued campaign job.
SMTP failures are persisted but are not retried implicitly. A worker-loss
redelivery is safe because the delivery service converts an unfinished
SMTP attempt into ``outcome_unknown`` instead of transmitting again.
redelivery leaves an active delivery claim unchanged instead of transmitting
again. Explicit fenced recovery requires stopped-owner evidence before an
abandoned attempt can become ``outcome_unknown`` for reconciliation.
"""
from govoplan_core.db.session import get_database
@@ -371,6 +371,24 @@ def _approval_count(request: dict[str, Any]) -> int:
def _sanitize_value(key: str, value: object) -> object:
if key == "campaign_archive_encryption_policy":
if not isinstance(value, dict):
return "<redacted>"
# These are format/channel names, never passwords. Preserve only the
# exact public enum lists so rollback history remains useful without
# exempting arbitrary password-named fields from secret redaction.
allowed_values = {
"allowed_password_encryption_methods": frozenset({"aes", "zip_standard"}),
"allowed_password_delivery_channels": frozenset({"separate_mail", "sms", "letter", "phone", "in_person"}),
}
return {
name: list(items)
if name in allowed_values
and isinstance(items, list)
and all(isinstance(item, str) and item in allowed_values[name] for item in items)
else "<redacted>"
for name, items in value.items()
}
field = classify_configuration_field(key)
if field is not None and field.secret_handling in {"reference_only", "env_only"}:
return _redact_secrets(value)
@@ -107,6 +107,20 @@ class _ConfigurationChangeSafetyState:
_CONFIGURATION_FIELD_SAFETY: tuple[ConfigurationFieldSafety, ...] = (
ConfigurationFieldSafety(
key="campaign_delivery_policy.system", label="System Campaign synchronous delivery limit",
owner_module="campaigns", scope="system", storage="system_settings", ui_managed=True,
risk="medium", required_scopes=("system:settings:write",),
audit_event="campaign.delivery_policy_updated", rollback_history_required=True,
notes="A bounded 0500 recipient-job maximum for one interactive Send now request. Explicit deployment ceilings remain authoritative; saving never delivers mail or changes review evidence.",
),
ConfigurationFieldSafety(
key="campaign_delivery_policy.tenant", label="Tenant Campaign synchronous delivery limit",
owner_module="campaigns", scope="tenant", storage="tenant_settings", ui_managed=True,
risk="medium", required_scopes=("admin:policies:write",),
audit_event="campaign.delivery_policy_updated", rollback_history_required=True,
notes="Tenant policy may only narrow the inherited system/deployment recipient-job maximum; clearing an override restores inheritance. Changes retain before/after history.",
),
ConfigurationFieldSafety(
key="module_management.desired_enabled",
label="Enabled modules",
@@ -171,6 +185,21 @@ _CONFIGURATION_FIELD_SAFETY: tuple[ConfigurationFieldSafety, ...] = (
rollback_history_required=True,
notes="Maintenance mode controls platform availability and gates dangerous operations.",
),
ConfigurationFieldSafety(
key="campaign_archive_encryption_policy",
label="Campaign archive encryption policy",
owner_module="policy",
scope="system",
storage="policy_overrides",
ui_managed=True,
risk="high",
required_scopes=("system:settings:write", "admin:policies:write"),
validation_required=True,
policy_explanation_required=True,
audit_event="campaign_archive_encryption_policy.updated",
rollback_history_required=True,
notes="Explicit system ceiling for Campaign archive methods and separate password-delivery channels. Policy validates allowed values and retains before/after history; lower scopes may only narrow. Legacy use additionally requires the dedicated Campaign permission and reasoned weak-encryption acknowledgement, so saving policy alone never enables or sends an archive.",
),
ConfigurationFieldSafety(
key="privacy_retention_policy",
label="Privacy retention policy",
+59 -5
View File
@@ -8,11 +8,22 @@ NAVIGATION_PREFERENCES_CONTRACT_VERSION = "1"
_MAX_ITEMS = 256
@dataclass(frozen=True, slots=True)
class NavigationSeparator:
id: str
label: str = ""
def as_dict(self) -> dict[str, str]:
return {"id": self.id, "label": self.label}
@dataclass(frozen=True, slots=True)
class NavigationPreferences:
order: tuple[str, ...] = ()
hidden: tuple[str, ...] = ()
locked: tuple[str, ...] = ()
# None preserves inherited grouping; an empty tuple explicitly removes it.
separators: tuple[NavigationSeparator, ...] | None = None
def as_dict(self) -> dict[str, object]:
return {
@@ -20,6 +31,7 @@ class NavigationPreferences:
"order": list(self.order),
"hidden": list(self.hidden),
"locked": list(self.locked),
**({"separators": [item.as_dict() for item in self.separators]} if self.separators is not None else {}),
}
@@ -32,6 +44,9 @@ class EffectiveNavigationItem:
order_source: str
visibility_source: str
lock_source: str | None = None
section: NavigationSeparator | None = None
custom_layout: bool = False
layout_source: str = "module"
def as_dict(self) -> dict[str, object]:
return {
@@ -42,6 +57,9 @@ class EffectiveNavigationItem:
"navigation_order_source": self.order_source,
"navigation_visibility_source": self.visibility_source,
"navigation_lock_source": self.lock_source,
"navigation_section": self.section.as_dict() if self.section else None,
"navigation_custom_layout": self.custom_layout,
"navigation_layout_source": self.layout_source,
}
@@ -63,6 +81,7 @@ def navigation_preferences_from_mapping(
order=_ids(raw.get("order")),
hidden=_ids(raw.get("hidden")),
locked=_ids(raw.get("locked")),
separators=_separators(raw.get("separators")),
)
@@ -95,6 +114,9 @@ def resolve_navigation_preferences(
visibility = {item_id: True for item_id in ordered}
visibility_source = {item_id: "module" for item_id in ordered}
locks: dict[str, str] = {}
separators: dict[str, NavigationSeparator] = {}
custom_layout = False
layout_source = "module"
for source, preferences, may_lock in (
("system", system, True),
@@ -103,7 +125,13 @@ def resolve_navigation_preferences(
):
if preferences is None:
continue
requested_order = [item_id for item_id in preferences.order if item_id in available]
if preferences.separators is not None:
separators = {item.id: item for item in preferences.separators if item.id not in available}
ordered = [item_id for item_id in ordered if item_id in available or item_id in separators]
ordered.extend(item_id for item_id in separators if item_id not in ordered)
custom_layout = True
layout_source = source
requested_order = list(dict.fromkeys(item_id for item_id in preferences.order if item_id in available or item_id in separators))
if requested_order:
requested = set(requested_order)
ordered = [*requested_order, *(item_id for item_id in ordered if item_id not in requested)]
@@ -127,8 +155,13 @@ def resolve_navigation_preferences(
visibility[item_id] = True
visibility_source[item_id] = source
return {
item_id: EffectiveNavigationItem(
result: dict[str, EffectiveNavigationItem] = {}
section: NavigationSeparator | None = None
for index, item_id in enumerate(ordered):
if item_id in separators:
section = separators[item_id]
continue
result[item_id] = EffectiveNavigationItem(
id=item_id,
order=index,
visible=visibility[item_id],
@@ -136,9 +169,29 @@ def resolve_navigation_preferences(
order_source=order_source[item_id],
visibility_source=visibility_source[item_id],
lock_source=locks.get(item_id),
section=section,
custom_layout=custom_layout,
layout_source=layout_source,
)
for index, item_id in enumerate(ordered)
}
return result
def _separators(value: object) -> tuple[NavigationSeparator, ...] | None:
if not isinstance(value, (list, tuple)):
return None
items: dict[str, NavigationSeparator] = {}
for raw in value[:_MAX_ITEMS]:
if not isinstance(raw, Mapping):
continue
item_id = _clean_id(raw.get("id"))
label = raw.get("label", "")
if not item_id.startswith("separator:") or not isinstance(label, str):
continue
label = label.strip()[:120]
if any(ord(character) < 32 for character in label):
continue
items[item_id] = NavigationSeparator(item_id, label)
return tuple(items.values())
def _ids(value: object) -> tuple[str, ...]:
@@ -168,6 +221,7 @@ __all__ = [
"NAVIGATION_PREFERENCES_CONTRACT_VERSION",
"NAVIGATION_PREFERENCES_KEY",
"NavigationPreferences",
"NavigationSeparator",
"navigation_preferences_from_mapping",
"navigation_preferences_from_settings",
"resolve_navigation_preferences",
+3
View File
@@ -200,6 +200,9 @@ def _nav_item_payload(
"visible": scoped.visible,
"locked": scoped.locked,
"lock_source": scoped.lock_source,
"section": scoped.section.as_dict() if scoped.section else None,
"custom_layout": scoped.custom_layout,
"layout_source": scoped.layout_source,
}
for scope in ("module", "system", "tenant")
if (scoped := navigation.get(scope, {}).get(navigation_id)) is not None
+3
View File
@@ -164,6 +164,9 @@ class Settings(BaseSettings):
ge=60,
alias="FILE_ARCHIVE_PREVIEW_TTL_SECONDS",
)
file_archive_work_root: str | None = Field(default=None, alias="FILE_ARCHIVE_WORK_ROOT")
file_archive_staged_max_bytes: int = Field(default=2 * 1024 ** 3, ge=1, alias="FILE_ARCHIVE_STAGED_MAX_BYTES")
file_archive_staged_per_actor: int = Field(default=4, ge=1, le=64, alias="FILE_ARCHIVE_STAGED_PER_ACTOR")
auth_session_cookie_name: str = Field(default="govoplan_session", alias="AUTH_SESSION_COOKIE_NAME")
auth_csrf_cookie_name: str = Field(default="govoplan_csrf", alias="AUTH_CSRF_COOKIE_NAME")