Release v0.1.5
This commit is contained in:
@@ -0,0 +1,35 @@
|
||||
---
|
||||
name: "Bug"
|
||||
about: "Report a reproducible defect, regression, or incorrect behavior"
|
||||
title: "[Bug] "
|
||||
labels:
|
||||
- type/bug
|
||||
- status/triage
|
||||
- module/policy
|
||||
---
|
||||
|
||||
## Scope
|
||||
|
||||
- Repository:
|
||||
- Area/module:
|
||||
- Affected version or commit:
|
||||
|
||||
## Behavior
|
||||
|
||||
Expected:
|
||||
|
||||
Actual:
|
||||
|
||||
## Reproduction
|
||||
|
||||
1.
|
||||
2.
|
||||
3.
|
||||
|
||||
## Evidence
|
||||
|
||||
Logs, screenshots, traces, or failing test output:
|
||||
|
||||
## Verification Target
|
||||
|
||||
Command or workflow that should pass when fixed:
|
||||
@@ -0,0 +1 @@
|
||||
blank_issues_enabled: false
|
||||
@@ -0,0 +1,27 @@
|
||||
---
|
||||
name: "Docs / workflow"
|
||||
about: "Request documentation, process, or developer workflow changes"
|
||||
title: "[Docs] "
|
||||
labels:
|
||||
- type/docs
|
||||
- status/triage
|
||||
- module/policy
|
||||
- area/docs
|
||||
---
|
||||
|
||||
## Scope
|
||||
|
||||
- Repository:
|
||||
- Document or workflow:
|
||||
|
||||
## Current State
|
||||
|
||||
What is missing, unclear, duplicated, or stale?
|
||||
|
||||
## Desired State
|
||||
|
||||
What should the docs or workflow make clear?
|
||||
|
||||
## Verification Target
|
||||
|
||||
How should this be checked?
|
||||
@@ -0,0 +1,32 @@
|
||||
---
|
||||
name: "Feature"
|
||||
about: "Propose new user-visible behavior or platform capability"
|
||||
title: "[Feature] "
|
||||
labels:
|
||||
- type/feature
|
||||
- status/triage
|
||||
- module/policy
|
||||
---
|
||||
|
||||
## Problem
|
||||
|
||||
What user, operator, or developer problem should this solve?
|
||||
|
||||
## Proposed Capability
|
||||
|
||||
What should exist when this is done?
|
||||
|
||||
## Ownership
|
||||
|
||||
- Owning repository:
|
||||
- Related module repositories:
|
||||
- Extension point or integration boundary:
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ]
|
||||
- [ ]
|
||||
|
||||
## Verification Target
|
||||
|
||||
Command, scenario, or UI flow that should prove completion:
|
||||
@@ -0,0 +1,28 @@
|
||||
---
|
||||
name: "Task"
|
||||
about: "Track implementation, maintenance, or migration work"
|
||||
title: "[Task] "
|
||||
labels:
|
||||
- type/task
|
||||
- status/triage
|
||||
- module/policy
|
||||
---
|
||||
|
||||
## Objective
|
||||
|
||||
What needs to be completed?
|
||||
|
||||
## Scope
|
||||
|
||||
- Owning repository:
|
||||
- In-scope:
|
||||
- Out-of-scope:
|
||||
|
||||
## Checklist
|
||||
|
||||
- [ ]
|
||||
- [ ]
|
||||
|
||||
## Verification Target
|
||||
|
||||
Command or manual check:
|
||||
@@ -0,0 +1,25 @@
|
||||
---
|
||||
name: "Tech debt"
|
||||
about: "Track cleanup, refactoring, risk reduction, or deferred engineering work"
|
||||
title: "[Debt] "
|
||||
labels:
|
||||
- type/debt
|
||||
- status/triage
|
||||
- module/policy
|
||||
---
|
||||
|
||||
## Current Cost
|
||||
|
||||
What does this make harder, riskier, slower, or more fragile?
|
||||
|
||||
## Desired Shape
|
||||
|
||||
What should the code, tests, or architecture look like afterwards?
|
||||
|
||||
## Constraints
|
||||
|
||||
What behavior, compatibility, or module boundary must be preserved?
|
||||
|
||||
## Verification Target
|
||||
|
||||
Focused checks that should pass:
|
||||
@@ -0,0 +1,15 @@
|
||||
## Issue
|
||||
|
||||
Closes #
|
||||
|
||||
## Summary
|
||||
|
||||
-
|
||||
|
||||
## Verification
|
||||
|
||||
-
|
||||
|
||||
## Notes
|
||||
|
||||
Follow-up issues:
|
||||
@@ -0,0 +1,8 @@
|
||||
# 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.
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
[build-system]
|
||||
requires = ["setuptools>=69", "wheel"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "govoplan-policy"
|
||||
version = "0.1.5"
|
||||
description = "GovOPlaN policy platform module."
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.12"
|
||||
authors = [{ name = "GovOPlaN" }]
|
||||
dependencies = [
|
||||
"govoplan-core>=0.1.5",
|
||||
"govoplan-access>=0.1.5",
|
||||
]
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
where = ["src"]
|
||||
|
||||
[tool.setuptools.package-data]
|
||||
govoplan_policy = ["py.typed"]
|
||||
|
||||
[project.entry-points."govoplan.modules"]
|
||||
policy = "govoplan_policy.backend.manifest:get_manifest"
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
[govoplan.modules]
|
||||
policy = govoplan_policy.backend.manifest:get_manifest
|
||||
@@ -0,0 +1,2 @@
|
||||
govoplan-core>=0.1.4
|
||||
govoplan-access>=0.1.4
|
||||
@@ -0,0 +1 @@
|
||||
govoplan_policy
|
||||
@@ -0,0 +1,2 @@
|
||||
"""GovOPlaN policy module."""
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,2 @@
|
||||
"""Backend integration for the GovOPlaN policy module."""
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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)
|
||||
@@ -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]
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
Reference in New Issue
Block a user