Files
YPMS/src/ypms/framework/retention.py
T
Chris TonjesandClaude Opus 5.5 5f2d885fdc Phase 0 milestone 0.8: CI pipeline on Gitea Actions
Workflow moved to .gitea/workflows/ci.yml (Gitea is the CI host). Every
gate now passes locally, step-for-step; green on main is pending an
act_runner on gitea.doesworks.net (runs #1-#5 are queued "Waiting").

Gate fixes found by actually running them:
- pip-audit: `--disable-pip` without `-r` was a usage error, so the old
  step could never pass. Now `pip-audit --strict .` audits the runtime
  dependency set from pyproject.
- pip-audit then flagged ecdsa (PYSEC-2026-1325, no fix), a hard dep of
  python-jose. Swapped to PyJWT (HS256 only; 3-line change in
  auth/security.py). Flag to OWC.
- PyJWT warned the test JWT secret was 11 bytes. JWT_SECRET now requires
  >= 32 bytes (RFC 7518 s3.2) at settings load; dev/compose/test defaults
  updated. Test-only value carries an inline gitleaks:allow.
- bandit B101: assert in retention._count_past replaced with a raise.
- gitleaks: gitleaks-action needs a GitHub token/license; replaced with
  the pinned 8.30.1 binary, sha256-verified, scanning full history.

Added:
- SBOM: CycloneDX 1.6 from a clean runtime-only venv (39 components),
  uploaded as a CI artifact. YPMS-POAM-042.
- cyclonedx-bom in dev extras; CI outputs gitignored.

158 tests passing, 95.83% coverage; ruff, mypy, bandit, pip-audit,
gitleaks clean.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 10:57:09 -04:00

198 lines
6.4 KiB
Python

"""Retention scaffold — policy type + nightly job (milestone 0.7; YPMS addition).
ARCHITECTURE.md §6.4: every entity declares a retention policy; a nightly job
scans for records past their envelope and applies the policy. NIST AU-11,
SI-12; source RFP `C.5.7.42`, `C.5.21.34`.
**Phase 0 is report-only.** The job never modifies data. For each mapped
table it resolves the policy and logs one of:
- `retain` — kept by design (configuration tables);
- `unset` — no period yet; named so the gap stays visible;
- `interim` — PIA §9 interim period, not yet signed off. Rows past the
interim envelope are *counted* (`interim_eligible`) but
never purged;
- `approved` — signed-off period. Rows past it are counted as
`to_purge`. Disposal execution lands in Phase 5 with the
ingest pipelines; until then the job still only reports.
Every run writes one `retention_run` audit row with the summary.
Policy catalog: `ypms/retention_classes.py`. Entity models declare
`retention_policy: ClassVar[RetentionPolicy]`; vendored framework tables are
mapped by table name in `retention_classes.FRAMEWORK_TABLE_POLICIES` so the
vendored files stay unchanged.
Run: ``python -m ypms.framework.retention`` (nightly schedule is wired at
deployment — Azure Container Apps job / cron; see ARCH §9).
"""
from __future__ import annotations
import enum
import json
from dataclasses import asdict, dataclass, field
from datetime import UTC, datetime, timedelta
from typing import Any
from sqlalchemy import func, select
from sqlalchemy.orm import Session
from ypms.audit import service as audit
from ypms.logging import get_logger
RETENTION_RUN_ACTION = "retention_run"
RETENTION_ENTITY = "Retention"
class Disposal(enum.StrEnum):
TOMBSTONE = "tombstone" # soft-delete + scrub, keep the row shell
HARD_DELETE = "hard_delete" # delete with audit
ARCHIVE = "archive" # move to cold storage, then delete online copy
ARCHIVE_SCRUB = "archive_scrub" # archive, scrub PII from the online copy
RETAIN = "retain" # never purged (configuration)
class Cadence(enum.StrEnum):
NIGHTLY = "nightly"
ANNUAL = "annual"
@dataclass(frozen=True)
class RetentionPolicy:
data_class: str
disposal: Disposal
period_days: int | None
anchor: str = "created_at"
cadence: Cadence = Cadence.NIGHTLY
approved: bool = False
authority: str = ""
@property
def status(self) -> str:
if self.disposal is Disposal.RETAIN:
return "retain"
if self.period_days is None:
return "unset"
return "approved" if self.approved else "interim"
@dataclass
class TableResult:
table: str
data_class: str
status: str
period_days: int | None
authority: str
interim_eligible: int = 0
to_purge: int = 0
@dataclass
class RetentionReport:
ran_at: str
dry_run: bool = True
tables: list[TableResult] = field(default_factory=list)
@property
def to_purge(self) -> int:
return sum(t.to_purge for t in self.tables)
@property
def interim_eligible(self) -> int:
return sum(t.interim_eligible for t in self.tables)
def tables_with(self, status: str) -> list[str]:
return sorted(t.table for t in self.tables if t.status == status)
def summary(self) -> dict[str, Any]:
return {
"ran_at": self.ran_at,
"dry_run": self.dry_run,
"to_purge": self.to_purge,
"interim_eligible": self.interim_eligible,
"unset": self.tables_with("unset"),
"tables": len(self.tables),
}
def resolve_policy(model: type[Any]) -> RetentionPolicy | None:
from ypms.retention_classes import FRAMEWORK_TABLE_POLICIES
policy: RetentionPolicy | None = getattr(model, "retention_policy", None)
if policy is not None:
return policy
return FRAMEWORK_TABLE_POLICIES.get(model.__tablename__)
def mapped_models() -> list[type[Any]]:
import ypms.models # noqa: F401 — registers every model on Base
from ypms.db import Base
return sorted((m.class_ for m in Base.registry.mappers), key=lambda c: c.__tablename__)
def _count_past(db: Session, model: type[Any], policy: RetentionPolicy, now: datetime) -> int:
if policy.period_days is None:
raise ValueError(f"policy {policy.data_class!r} has no period")
anchor = getattr(model, policy.anchor)
cutoff = now - timedelta(days=policy.period_days)
stmt = select(func.count()).select_from(model).where(anchor < cutoff)
return int(db.execute(stmt).scalar_one())
def run_retention(db: Session, *, now: datetime | None = None) -> RetentionReport:
"""Report-only retention pass over every mapped table. Never mutates data
other than the single `retention_run` audit row; caller commits."""
log = get_logger(__name__)
now = now or datetime.now(UTC)
report = RetentionReport(ran_at=now.isoformat())
for model in mapped_models():
policy = resolve_policy(model)
if policy is None:
raise LookupError(f"no retention policy for table {model.__tablename__}")
result = TableResult(
table=model.__tablename__, data_class=policy.data_class,
status=policy.status, period_days=policy.period_days,
authority=policy.authority,
)
if result.status in {"interim", "approved"}:
n = _count_past(db, model, policy, now)
if result.status == "approved":
result.to_purge = n
else:
result.interim_eligible = n
report.tables.append(result)
log.info("retention.table", **asdict(result))
for result in report.tables:
if result.status == "unset":
log.warning("retention.policy_unset", table=result.table, authority=result.authority)
log.info(
"retention.run",
message=f"{report.to_purge} records to purge",
**report.summary(),
)
audit.record(
db, actor=None, action=RETENTION_RUN_ACTION, entity=RETENTION_ENTITY,
after=report.summary(),
)
return report
def main() -> int:
from ypms.db import session_factory
from ypms.logging import configure_logging
configure_logging()
with session_factory()() as db:
report = run_retention(db)
db.commit()
print(json.dumps(report.summary()))
return 0
if __name__ == "__main__":
raise SystemExit(main())