From 00ac048fcafe5658fa0a4847746cfed704e2f23a Mon Sep 17 00:00:00 2001 From: Albrecht Degering Date: Fri, 10 Jul 2026 21:57:21 +0200 Subject: [PATCH] Sync GovOPlaN module state --- .gitignore | 253 ++++++++++++++++++++++++ src/govoplan_audit/backend/manifest.py | 25 ++- src/govoplan_audit/backend/recording.py | 45 +++++ src/govoplan_audit/backend/retention.py | 74 +++++++ 4 files changed, 396 insertions(+), 1 deletion(-) create mode 100644 src/govoplan_audit/backend/recording.py create mode 100644 src/govoplan_audit/backend/retention.py diff --git a/.gitignore b/.gitignore index 5fd4061..2a33867 100644 --- a/.gitignore +++ b/.gitignore @@ -21,3 +21,256 @@ webui/.module-test-build/ webui/.policy-test-build/ webui/.template-preview-test-build/ webui/.import-test-build/ + +# GovOPlaN shared ignore rules from govoplan-core +# ---> Node +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +lerna-debug.log* +.pnpm-debug.log* +# Diagnostic reports (https://nodejs.org/api/report.html) +report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json +# Runtime data +pids +*.pid +*.seed +*.pid.lock +# Directory for instrumented libs generated by jscoverage/JSCover +lib-cov +# Coverage directory used by tools like istanbul +coverage +*.lcov +# nyc test coverage +.nyc_output +# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) +.grunt +# Bower dependency directory (https://bower.io/) +bower_components +# node-waf configuration +.lock-wscript +# Compiled binary addons (https://nodejs.org/api/addons.html) +build/Release +# Dependency directories +jspm_packages/ +# Snowpack dependency directory (https://snowpack.dev/) +web_modules/ +# TypeScript cache +# Optional npm cache directory +.npm +# Optional eslint cache +.eslintcache +# Optional stylelint cache +.stylelintcache +# Microbundle cache +.rpt2_cache/ +.rts2_cache_cjs/ +.rts2_cache_es/ +.rts2_cache_umd/ +# Optional REPL history +.node_repl_history +# Output of 'npm pack' +*.tgz +# Yarn Integrity file +.yarn-integrity +# dotenv environment variable files +.env +.env.development.local +.env.test.local +.env.production.local +.env.local +# parcel-bundler cache (https://parceljs.org/) +.cache +.parcel-cache +# Next.js build output +.next +out +# Nuxt.js build / generate output +.nuxt +dist +# Gatsby files +.cache/ +# Comment in the public line in if your project uses Gatsby and not Next.js +# https://nextjs.org/blog/next-9-1#public-directory-support +# public +# vuepress build output +.vuepress/dist +# vuepress v2.x temp and cache directory +.temp +.cache +# vitepress build output +**/.vitepress/dist +# vitepress cache directory +**/.vitepress/cache +# Docusaurus cache and generated files +.docusaurus +# Serverless directories +.serverless/ +# FuseBox cache +.fusebox/ +# DynamoDB Local files +.dynamodb/ +# TernJS port file +.tern-port +# Stores VSCode versions used for testing VSCode extensions +.vscode-test +# yarn v2 +.yarn/cache +.yarn/unplugged +.yarn/build-state.yml +.yarn/install-state.gz +.pnp.* +# Local WebUI test/build scratch directories +# ---> Python +# Byte-compiled / optimized / DLL files +*$py.class +# C extensions +*.so +# Distribution / packaging +.Python +develop-eggs/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +.installed.cfg +*.egg +MANIFEST +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec +# Installer logs +pip-log.txt +pip-delete-this-directory.txt +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +cover/ +# Translations +*.mo +*.pot +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal +# Flask stuff: +instance/ +.webassets-cache +# Scrapy stuff: +.scrapy +# Sphinx documentation +docs/_build/ +# PyBuilder +.pybuilder/ +target/ +# Jupyter Notebook +.ipynb_checkpoints +# IPython +profile_default/ +ipython_config.py +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock +# UV +# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +#uv.lock +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +#poetry.lock +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +#pdm.lock +# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it +# in version control. +# https://pdm.fming.dev/latest/usage/project/#working-with-version-control +.pdm.toml +.pdm-python +.pdm-build/ +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ +# Celery stuff +celerybeat-schedule +celerybeat.pid +# SageMath parsed files +*.sage.py +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ +# Spyder project settings +.spyderproject +.spyproject +# Rope project settings +.ropeproject +# mkdocs documentation +/site +# mypy +.dmypy.json +dmypy.json +# Pyre type checker +.pyre/ +# pytype static type analyzer +.pytype/ +# Cython debug symbols +cython_debug/ +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +#.idea/ +# Ruff stuff: +# PyPI configuration file +.pypirc +# ---> VisualStudioCode +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json +!.vscode/*.code-snippets +# Local History for Visual Studio Code +.history/ +# Built Visual Studio Code Extensions +*.vsix +*.db +# GovOPlaN local runtime state +runtime/ +# GovOPlaN WebUI test output diff --git a/src/govoplan_audit/backend/manifest.py b/src/govoplan_audit/backend/manifest.py index 13b4434..d17fb49 100644 --- a/src/govoplan_audit/backend/manifest.py +++ b/src/govoplan_audit/backend/manifest.py @@ -1,7 +1,12 @@ from __future__ import annotations from govoplan_audit.backend.db import models as audit_models # noqa: F401 - populate Audit ORM metadata -from govoplan_core.core.access import CAPABILITY_AUTH_PERMISSION_EVALUATOR, CAPABILITY_AUTH_PRINCIPAL_RESOLVER +from govoplan_core.core.access import ( + CAPABILITY_AUDIT_RECORDER, + CAPABILITY_AUDIT_RETENTION, + CAPABILITY_AUTH_PERMISSION_EVALUATOR, + CAPABILITY_AUTH_PRINCIPAL_RESOLVER, +) from govoplan_core.core.module_guards import drop_table_retirement_provider, persistent_table_uninstall_guard from govoplan_core.core.modules import MigrationSpec, ModuleContext, ModuleManifest from govoplan_core.db.base import Base @@ -14,6 +19,20 @@ def _route_factory(context: ModuleContext): return router +def _audit_recorder(context: ModuleContext): + del context + from govoplan_audit.backend.recording import SqlAuditRecorder + + return SqlAuditRecorder() + + +def _audit_retention(context: ModuleContext): + del context + from govoplan_audit.backend.retention import SqlAuditRetentionProvider + + return SqlAuditRetentionProvider() + + manifest = ModuleManifest( id="audit", name="Audit", @@ -30,6 +49,10 @@ manifest = ModuleManifest( uninstall_guard_providers=( persistent_table_uninstall_guard(audit_models.AuditLog, label="Audit"), ), + capability_factories={ + CAPABILITY_AUDIT_RECORDER: _audit_recorder, + CAPABILITY_AUDIT_RETENTION: _audit_retention, + }, ) diff --git a/src/govoplan_audit/backend/recording.py b/src/govoplan_audit/backend/recording.py new file mode 100644 index 0000000..0a1942b --- /dev/null +++ b/src/govoplan_audit/backend/recording.py @@ -0,0 +1,45 @@ +from __future__ import annotations + +from sqlalchemy.orm import Session + +from govoplan_audit.backend.db.models import AuditLog +from govoplan_core.core.access import AuditEvent, AuditRecordRef, AuditRecorder + + +class SqlAuditRecorder(AuditRecorder): + def record_event(self, session: object, event: AuditEvent) -> AuditRecordRef: + db = _session(session) + item = AuditLog( + scope=event.scope, + tenant_id=event.tenant_id, + user_id=event.user_id, + api_key_id=event.api_key_id, + action=event.action, + object_type=event.resource_type, + object_id=event.resource_id, + details=dict(event.details or {}), + ) + db.add(item) + db.flush() + return _record_ref(item) + + +def _record_ref(item: AuditLog) -> AuditRecordRef: + return AuditRecordRef( + id=item.id, + scope=item.scope, + tenant_id=item.tenant_id, + user_id=item.user_id, + api_key_id=item.api_key_id, + action=item.action, + object_type=item.object_type, + object_id=item.object_id, + details=dict(item.details or {}), + created_at=item.created_at, + ) + + +def _session(session: object) -> Session: + if not isinstance(session, Session): + raise TypeError("Audit recording requires a SQLAlchemy Session") + return session diff --git a/src/govoplan_audit/backend/retention.py b/src/govoplan_audit/backend/retention.py new file mode 100644 index 0000000..106e412 --- /dev/null +++ b/src/govoplan_audit/backend/retention.py @@ -0,0 +1,74 @@ +from __future__ import annotations + +from collections.abc import Callable, Mapping +from datetime import datetime, timedelta + +from sqlalchemy.orm import Session + +from govoplan_audit.backend.db.models import AuditLog +from govoplan_audit.backend.recording import _record_ref +from govoplan_core.core.access import AuditRecordRef, AuditRetentionProvider + + +class SqlAuditRetentionProvider(AuditRetentionProvider): + def apply_detail_retention( + self, + session: object, + *, + dry_run: bool, + now: datetime, + policy_for_record: Callable[[AuditRecordRef], object], + ) -> Mapping[str, int]: + db = _session(session) + result = {"eligible": 0, "redacted": 0, "already_redacted": 0} + items = db.query(AuditLog).order_by(AuditLog.created_at.asc()).all() + for item in items: + policy = policy_for_record(_record_ref(item)) + cutoff = _cutoff(getattr(policy, "audit_detail_retention_days", None), now=now) + if not _is_before_cutoff(item.created_at, cutoff): + continue + details = item.details if isinstance(item.details, dict) else {} + if details.get("_retention", {}).get("audit_detail_redacted"): + result["already_redacted"] += 1 + continue + result["eligible"] += 1 + if dry_run: + continue + item.details = { + "_retention": { + "audit_detail_redacted": True, + "redacted_at": now.isoformat(), + "original_detail_sha256": _json_sha256(details), + } + } + db.add(item) + result["redacted"] += 1 + return result + + +def _cutoff(days: int | None, *, now: datetime) -> datetime | None: + if days is None: + return None + return now - timedelta(days=days) + + +def _is_before_cutoff(value: datetime | None, cutoff: datetime | None) -> bool: + if value is None or cutoff is None: + return False + candidate = value + if candidate.tzinfo is None: + candidate = candidate.replace(tzinfo=cutoff.tzinfo) + return candidate < cutoff + + +def _json_sha256(value: object) -> str: + import hashlib + import json + + return hashlib.sha256(json.dumps(value, sort_keys=True, default=str).encode("utf-8")).hexdigest() + + +def _session(session: object) -> Session: + if not isinstance(session, Session): + raise TypeError("Audit retention requires a SQLAlchemy Session") + return session