Release v0.1.5

This commit is contained in:
2026-07-07 15:49:06 +02:00
commit 992b99e5ba
37 changed files with 542 additions and 0 deletions

View File

@@ -0,0 +1,18 @@
Metadata-Version: 2.4
Name: govoplan-policy
Version: 0.1.4
Summary: GovOPlaN policy platform module.
Author: GovOPlaN
Requires-Python: >=3.12
Description-Content-Type: text/markdown
Requires-Dist: govoplan-core>=0.1.4
Requires-Dist: govoplan-access>=0.1.4
# GovOPlaN Policy
`govoplan-policy` owns policy and retention API route contributions during the
GovOPlaN module split.
The current package delegates to the legacy access administration
implementation while route ownership is separated before model migration.

View File

@@ -0,0 +1,16 @@
README.md
pyproject.toml
src/govoplan_policy/__init__.py
src/govoplan_policy/py.typed
src/govoplan_policy.egg-info/PKG-INFO
src/govoplan_policy.egg-info/SOURCES.txt
src/govoplan_policy.egg-info/dependency_links.txt
src/govoplan_policy.egg-info/entry_points.txt
src/govoplan_policy.egg-info/requires.txt
src/govoplan_policy.egg-info/top_level.txt
src/govoplan_policy/backend/__init__.py
src/govoplan_policy/backend/manifest.py
src/govoplan_policy/backend/api/__init__.py
src/govoplan_policy/backend/api/v1/__init__.py
src/govoplan_policy/backend/api/v1/routes.py
src/govoplan_policy/backend/api/v1/schemas.py

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1,2 @@
[govoplan.modules]
policy = govoplan_policy.backend.manifest:get_manifest

View File

@@ -0,0 +1,2 @@
govoplan-core>=0.1.4
govoplan-access>=0.1.4

View File

@@ -0,0 +1 @@
govoplan_policy

View File

@@ -0,0 +1,2 @@
"""GovOPlaN policy module."""

View File

@@ -0,0 +1,2 @@
"""Backend integration for the GovOPlaN policy module."""

View File

@@ -0,0 +1,168 @@
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy.orm import Session
from govoplan_access.backend.auth.dependencies import ApiPrincipal, get_api_principal, has_scope, require_scope
from govoplan_core.audit.logging import audit_from_principal
from govoplan_core.db.session import get_session
from govoplan_core.privacy.retention import (
PrivacyPolicyError,
apply_retention_policy,
effective_privacy_policy,
effective_privacy_policy_sources,
get_privacy_policy_for_scope,
parent_privacy_policy,
parent_privacy_policy_sources,
set_privacy_policy_for_scope,
)
from .schemas import (
PrivacyRetentionPolicyItem,
PrivacyRetentionPolicyScopeRequest,
PrivacyRetentionPolicyScopeResponse,
RetentionRunRequest,
RetentionRunResponse,
)
router = APIRouter(prefix="/admin", tags=["admin"])
def _require_permission(principal: ApiPrincipal, scope: str) -> None:
if not has_scope(principal, scope):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=f"Missing scope: {scope}")
def _require_privacy_policy_read(principal: ApiPrincipal, scope_type: str) -> None:
if scope_type == "system":
_require_permission(principal, "system:settings:read")
else:
_require_permission(principal, "admin:policies:read")
def _require_privacy_policy_write(principal: ApiPrincipal, scope_type: str) -> None:
if scope_type == "system":
_require_permission(principal, "system:settings:write")
else:
_require_permission(principal, "admin:policies:write")
@router.get("/privacy-retention/policies/{scope_type}", response_model=PrivacyRetentionPolicyScopeResponse)
def read_privacy_retention_policy(
scope_type: str,
scope_id: str | None = Query(default=None),
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
):
clean_scope = scope_type.strip().casefold()
_require_privacy_policy_read(principal, clean_scope)
try:
policy = get_privacy_policy_for_scope(session, tenant_id=principal.tenant_id, scope_type=clean_scope, scope_id=scope_id)
effective = _effective_privacy_policy_for_response(session, tenant_id=principal.tenant_id, scope_type=clean_scope, scope_id=scope_id)
parent = _parent_privacy_policy_for_response(session, tenant_id=principal.tenant_id, scope_type=clean_scope, scope_id=scope_id)
return PrivacyRetentionPolicyScopeResponse(
scope_type=clean_scope,
scope_id=scope_id,
policy=policy,
effective_policy=PrivacyRetentionPolicyItem.model_validate(effective.model_dump(mode="json")),
parent_policy=PrivacyRetentionPolicyItem.model_validate(parent.model_dump(mode="json")) if parent else None,
effective_policy_sources=_effective_privacy_policy_sources_for_response(session, tenant_id=principal.tenant_id, scope_type=clean_scope, scope_id=scope_id),
parent_policy_sources=_parent_privacy_policy_sources_for_response(session, tenant_id=principal.tenant_id, scope_type=clean_scope, scope_id=scope_id),
)
except PrivacyPolicyError as exc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
@router.put("/privacy-retention/policies/{scope_type}", response_model=PrivacyRetentionPolicyScopeResponse)
def write_privacy_retention_policy(
scope_type: str,
payload: PrivacyRetentionPolicyScopeRequest,
scope_id: str | None = Query(default=None),
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
):
clean_scope = scope_type.strip().casefold()
_require_privacy_policy_write(principal, clean_scope)
try:
policy = set_privacy_policy_for_scope(
session,
tenant_id=principal.tenant_id,
scope_type=clean_scope,
scope_id=scope_id,
policy=payload.policy.model_dump(mode="json", exclude_none=True),
)
session.commit()
effective = _effective_privacy_policy_for_response(session, tenant_id=principal.tenant_id, scope_type=clean_scope, scope_id=scope_id)
parent = _parent_privacy_policy_for_response(session, tenant_id=principal.tenant_id, scope_type=clean_scope, scope_id=scope_id)
return PrivacyRetentionPolicyScopeResponse(
scope_type=clean_scope,
scope_id=scope_id,
policy=policy,
effective_policy=PrivacyRetentionPolicyItem.model_validate(effective.model_dump(mode="json")),
parent_policy=PrivacyRetentionPolicyItem.model_validate(parent.model_dump(mode="json")) if parent else None,
effective_policy_sources=_effective_privacy_policy_sources_for_response(session, tenant_id=principal.tenant_id, scope_type=clean_scope, scope_id=scope_id),
parent_policy_sources=_parent_privacy_policy_sources_for_response(session, tenant_id=principal.tenant_id, scope_type=clean_scope, scope_id=scope_id),
)
except PrivacyPolicyError as exc:
session.rollback()
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc
def _parent_privacy_policy_sources_for_response(session: Session, *, tenant_id: str, scope_type: str, scope_id: str | None):
if scope_type == "system":
return []
return parent_privacy_policy_sources(session, tenant_id=tenant_id, scope_type=scope_type, scope_id=scope_id or (tenant_id if scope_type == "tenant" else None))
def _effective_privacy_policy_sources_for_response(session: Session, *, tenant_id: str, scope_type: str, scope_id: str | None):
if scope_type == "system":
return effective_privacy_policy_sources(session)
if scope_type == "tenant":
return effective_privacy_policy_sources(session, tenant_id=scope_id or tenant_id)
if scope_type == "campaign" and scope_id:
return effective_privacy_policy_sources(session, campaign_id=scope_id)
if scope_type == "user" and scope_id:
return effective_privacy_policy_sources(session, tenant_id=tenant_id, owner_user_id=scope_id)
if scope_type == "group" and scope_id:
return effective_privacy_policy_sources(session, tenant_id=tenant_id, owner_group_id=scope_id)
return effective_privacy_policy_sources(session, tenant_id=tenant_id)
def _parent_privacy_policy_for_response(session: Session, *, tenant_id: str, scope_type: str, scope_id: str | None):
if scope_type == "system":
return None
return parent_privacy_policy(session, tenant_id=tenant_id, scope_type=scope_type, scope_id=scope_id or (tenant_id if scope_type == "tenant" else None))
def _effective_privacy_policy_for_response(session: Session, *, tenant_id: str, scope_type: str, scope_id: str | None):
if scope_type == "system":
return effective_privacy_policy(session)
if scope_type == "tenant":
return effective_privacy_policy(session, tenant_id=scope_id or tenant_id)
if scope_type == "campaign" and scope_id:
return effective_privacy_policy(session, campaign_id=scope_id)
if scope_type == "user" and scope_id:
return effective_privacy_policy(session, tenant_id=tenant_id, owner_user_id=scope_id)
if scope_type == "group" and scope_id:
return effective_privacy_policy(session, tenant_id=tenant_id, owner_group_id=scope_id)
return effective_privacy_policy(session, tenant_id=tenant_id)
@router.post("/system/retention/run", response_model=RetentionRunResponse)
def run_retention_policy(
payload: RetentionRunRequest,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("system:settings:write")),
):
result = apply_retention_policy(session, dry_run=payload.dry_run)
audit_from_principal(
session,
principal,
action="retention_policy.run",
scope="system",
object_type="retention_policy",
object_id="global",
details={"dry_run": payload.dry_run, "counts": result.get("counts")},
)
session.commit()
return RetentionRunResponse(result=result)

View File

@@ -0,0 +1,110 @@
from __future__ import annotations
from datetime import datetime
from typing import Any, Literal
from pydantic import BaseModel, ConfigDict, Field, field_validator
RETENTION_DAY_KEYS = (
"raw_campaign_json_retention_days",
"generated_eml_retention_days",
"stored_report_detail_retention_days",
"mock_mailbox_retention_days",
"audit_detail_retention_days",
)
RETENTION_POLICY_FIELD_KEYS = (
"store_raw_campaign_json",
*RETENTION_DAY_KEYS,
"audit_detail_level",
)
def default_allow_lower_level_limits() -> dict[str, bool]:
return {key: True for key in RETENTION_POLICY_FIELD_KEYS}
def normalize_allow_lower_level_limits(value: Any, *, fill_defaults: bool) -> dict[str, bool] | None:
if value in (None, ""):
return default_allow_lower_level_limits() if fill_defaults else None
if not isinstance(value, dict):
raise ValueError("allow_lower_level_limits must be an object")
normalized = default_allow_lower_level_limits() if fill_defaults else {}
for key, allowed in value.items():
clean_key = str(key)
if clean_key not in RETENTION_POLICY_FIELD_KEYS:
raise ValueError(f"Unknown retention policy field: {clean_key}")
normalized[clean_key] = bool(allowed)
return normalized
class PolicySourceStepItem(BaseModel):
scope_type: str
scope_id: str | None = None
label: str
applied_fields: list[str] = Field(default_factory=list)
policy: dict[str, Any] = Field(default_factory=dict)
class PrivacyRetentionPolicyItem(BaseModel):
model_config = ConfigDict(extra="forbid")
store_raw_campaign_json: bool = True
raw_campaign_json_retention_days: int | None = Field(default=None, ge=0)
generated_eml_retention_days: int | None = Field(default=None, ge=0)
stored_report_detail_retention_days: int | None = Field(default=None, ge=0)
mock_mailbox_retention_days: int | None = Field(default=None, ge=0)
audit_detail_retention_days: int | None = Field(default=None, ge=0)
audit_detail_level: Literal["full", "redacted", "minimal"] = "full"
allow_lower_level_limits: dict[str, bool] = Field(default_factory=default_allow_lower_level_limits)
@field_validator("allow_lower_level_limits", mode="before")
@classmethod
def _normalize_allow_lower_level_limits(cls, value: Any) -> Any:
return normalize_allow_lower_level_limits(value, fill_defaults=True)
class PrivacyRetentionPolicyPatchItem(BaseModel):
model_config = ConfigDict(extra="forbid")
store_raw_campaign_json: bool | None = None
raw_campaign_json_retention_days: int | None = Field(default=None, ge=0)
generated_eml_retention_days: int | None = Field(default=None, ge=0)
stored_report_detail_retention_days: int | None = Field(default=None, ge=0)
mock_mailbox_retention_days: int | None = Field(default=None, ge=0)
audit_detail_retention_days: int | None = Field(default=None, ge=0)
audit_detail_level: Literal["full", "redacted", "minimal"] | None = None
allow_lower_level_limits: dict[str, bool] | None = None
@field_validator("allow_lower_level_limits", mode="before")
@classmethod
def _normalize_allow_lower_level_limits(cls, value: Any) -> Any:
return normalize_allow_lower_level_limits(value, fill_defaults=False)
class PrivacyRetentionPolicyScopeRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
policy: PrivacyRetentionPolicyPatchItem = Field(default_factory=PrivacyRetentionPolicyPatchItem)
class PrivacyRetentionPolicyScopeResponse(BaseModel):
scope_type: Literal["system", "tenant", "user", "group", "campaign"]
scope_id: str | None = None
policy: dict[str, Any]
effective_policy: PrivacyRetentionPolicyItem
parent_policy: PrivacyRetentionPolicyItem | None = None
effective_policy_sources: list[PolicySourceStepItem] = Field(default_factory=list)
parent_policy_sources: list[PolicySourceStepItem] = Field(default_factory=list)
class RetentionRunRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
dry_run: bool = True
class RetentionRunResponse(BaseModel):
result: dict[str, Any]

View File

@@ -0,0 +1,23 @@
from __future__ import annotations
from govoplan_core.core.modules import ModuleContext, ModuleManifest
def _route_factory(context: ModuleContext):
del context
from govoplan_policy.backend.api.v1.routes import router
return router
manifest = ModuleManifest(
id="policy",
name="Policy",
version="0.1.5",
dependencies=("access",),
route_factory=_route_factory,
)
def get_manifest() -> ModuleManifest:
return manifest

View File

@@ -0,0 +1 @@