From cc8030364a1963e842cd2bbc0c19128c6ddb07eb Mon Sep 17 00:00:00 2001 From: Scott Converse Date: Sun, 28 Jun 2026 15:46:15 -0600 Subject: [PATCH 1/2] =?UTF-8?q?feat(citycore):=20Phase=20A=20hardening=20?= =?UTF-8?q?=E2=80=94=20Postgres=20default,=20write=20authz,=20audit,=20bac?= =?UTF-8?q?kup/restore=20(v0.4.0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bring CivicAccess to city-core readiness in its own repo by mirroring the shipped CivicNotice no-AI pattern. Closes probe gaps #2 (authz), #3 (audit), #4 (backup/restore) and defaults persistence to the shared CivicCore PostgreSQL. Cuts v0.4.0. Persistence (#Postgres default): - Read the supervisor's DATABASE_URL and derive a sync psycopg2 URL (_sync_database_url); CIVICACCESS_REVIEW_DB_URL overrides; SQLite is now an explicit dev fallback, not the default. - Move psycopg2-binary to a runtime dependency. - Rename the migration id to civicaccess-windows-local-state-v1. - Mandatory Postgres release gate: verify-release.sh + CI require CIVICACCESS_POSTGRES_TEST_URL (postgres:16 service); tests/test_postgres_persistence.py. Authz (#2): - Trusted-write guard on POST /review and POST /reviews/{id}/records-export (CIVICACCESS_TRUSTED_WRITE_TOKEN + X-CivicAccess-Write-Token): 403 missing/invalid, 503 fail-closed. - New stateless public POST /analyze (no persistence, no token); public page uses it so the public surface can no longer write city records. - Staff page carries the server-rendered token and sends it on save/export. Audit (#3): - audit_events table + record_audit_event; review.create written in the same transaction as the review, review.records_export on export. Backup/restore (#4): - Round-trip test proving review + audit data survive a Data-directory backup and restore. Version bumped 0.3.0 -> 0.4.0 across code, docs, and the release gate. PROBE-PROGRESS.md added. Co-Authored-By: Claude Opus 4.8 --- .github/workflows/verify.yml | 18 +++ CHANGELOG.md | 19 +++ PROBE-PROGRESS.md | 38 +++++ README.md | 2 +- README.txt | 2 +- SECURITY.md | 4 +- USER-MANUAL.md | 2 +- USER-MANUAL.txt | 2 +- civicaccess/__init__.py | 2 +- civicaccess/access_review.py | 59 ++++++- civicaccess/main.py | 88 ++++++++++- civicaccess/public_ui.py | 35 +++- docs/index.html | 4 +- pyproject.toml | 4 +- scripts/verify-release.sh | 21 ++- tests/conftest.py | 7 + tests/test_accessibility_foundation.py | 24 ++- tests/test_citycore_hardening.py | 149 ++++++++++++++++++ tests/test_postgres_persistence.py | 53 +++++++ ...est_production_depth_review_persistence.py | 7 +- tests/test_runtime_foundation.py | 8 +- 21 files changed, 507 insertions(+), 41 deletions(-) create mode 100644 PROBE-PROGRESS.md create mode 100644 tests/test_citycore_hardening.py create mode 100644 tests/test_postgres_persistence.py diff --git a/.github/workflows/verify.yml b/.github/workflows/verify.yml index 588d027..874722b 100644 --- a/.github/workflows/verify.yml +++ b/.github/workflows/verify.yml @@ -7,6 +7,20 @@ on: jobs: verify: runs-on: ubuntu-latest + services: + postgres: + image: postgres:16 + env: + POSTGRES_PASSWORD: civicaccess + POSTGRES_USER: civicaccess + POSTGRES_DB: civicaccess_test + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U civicaccess -d civicaccess_test" + --health-interval 10s + --health-timeout 5s + --health-retries 5 steps: - uses: actions/checkout@v5 - uses: actions/setup-python@v6 @@ -16,5 +30,9 @@ jobs: run: python -m pip install https://github.com/CivicSuite/civiccore/releases/download/v1.2.0/civiccore-1.2.0-py3-none-any.whl - name: Install package run: python -m pip install -e ".[dev]" + - name: Assert CivicCore release version + run: python -c "import civiccore; assert civiccore.__version__ == '1.2.0', civiccore.__version__" - name: Run release gate + env: + CIVICACCESS_POSTGRES_TEST_URL: postgresql+psycopg2://civicaccess:civicaccess@localhost:5432/civicaccess_test run: bash scripts/verify-release.sh diff --git a/CHANGELOG.md b/CHANGELOG.md index 3fbabbf..eab56bb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,25 @@ The format follows Keep a Changelog, and this project follows Semantic Versionin ## [Unreleased] +## [0.4.0] - 2026-06-28 + +City-core hardening: closes probe gaps #2 (authz), #3 (audit), and #4 (backup/restore), and makes the shared CivicCore PostgreSQL the default review store. + +### Added + +- Added a trusted-write guard: persistent writes (`POST /api/v1/civicaccess/review`, `POST /api/v1/civicaccess/reviews/{id}/records-export`) now require the `CIVICACCESS_TRUSTED_WRITE_TOKEN` server secret, sent as the `X-CivicAccess-Write-Token` header. Missing/invalid token returns 403; unconfigured guard fails closed with 503. (Probe gap #2.) +- Added `POST /api/v1/civicaccess/analyze`: a stateless, public, no-persistence accessibility check. The public `/civicaccess` surface now uses it, so public users can no longer write city records. +- Added a persisted `audit_events` table and module audit events on writes/exports (`review.create`, `review.records_export`), written in the same transaction as the review. (Probe gap #3.) +- Added a backup/restore round-trip test proving review + audit data survive a Data-directory backup and restore. (Probe gap #4.) +- Added a mandatory PostgreSQL release gate: `verify-release.sh` and CI require `CIVICACCESS_POSTGRES_TEST_URL` so PostgreSQL persistence coverage cannot be skipped, plus `tests/test_postgres_persistence.py`. + +### Changed + +- Defaulted the review store to the shared CivicCore PostgreSQL: the module reads the supervisor's `DATABASE_URL` (asyncpg) and derives a sync psycopg2 URL. `CIVICACCESS_REVIEW_DB_URL` still overrides; SQLite is now an explicit dev fallback rather than the default. +- Moved `psycopg2-binary` from dev/optional dependencies to a runtime dependency so the PostgreSQL default works out of the box. +- Renamed the schema migration id to `civicaccess-windows-local-state-v1` to match the CivicCore Windows Local module convention. +- Staff `/civicaccess/staff` surface now sends the trusted-write token on save and export. + ## [0.3.0] - 2026-06-25 ### Added diff --git a/PROBE-PROGRESS.md b/PROBE-PROGRESS.md new file mode 100644 index 0000000..d214cc4 --- /dev/null +++ b/PROBE-PROGRESS.md @@ -0,0 +1,38 @@ +# CivicAccess City-Core Probe Progress + +Tracks the city-core readiness probe gaps for CivicAccess. The probe demoted CivicAccess from +city-core (`excluded_from_city_core_needs_work_probe`) until these gaps are closed with evidence. + +Phase A (this release, **v0.4.0**) closes the module-repo gaps #1–#4. Gaps #5–#6 are integration/QA +gaps owned by later phases of the CivicAccess → city-core plan +(`CivicSuite/civicsuite` → `docs/roadmap/civicaccess-citycore-integration/`). + +| Gap | Description | Status | Evidence | +|-----|-------------|--------|----------| +| #1 | Clean install with the published CivicCore v1.2.0 wheel pin | **Closed** (v0.3.0) | `pyproject.toml` pins `civiccore` to the v1.2.0 release wheel + SHA256; `tests/test_runtime_foundation.py::test_pyproject_uses_published_civiccore_release_wheel` (and asserts the bad `civiccore==1.1.0`/`1.0.0` pins are absent). CI installs the wheel and asserts `civiccore.__version__ == "1.2.0"`. | +| #2 | Staff/public authz boundary on persistent writes | **Closed** (v0.4.0) | Trusted-write guard `_authorize_persistent_write` (`civicaccess/main.py`) on `POST /api/v1/civicaccess/review` and `POST /api/v1/civicaccess/reviews/{id}/records-export` — requires `CIVICACCESS_TRUSTED_WRITE_TOKEN` via `X-CivicAccess-Write-Token`; 403 on missing/invalid, 503 fail-closed when unconfigured. Public surface uses the new stateless `POST /api/v1/civicaccess/analyze` (no persistence, no token). Tests: `tests/test_citycore_hardening.py::test_review_write_rejects_missing_and_wrong_token`, `::test_records_export_write_requires_token`, `::test_write_guard_not_configured_returns_503`, `::test_analyze_is_open_and_never_persists`. | +| #3 | Module audit logging on writes/exports | **Closed** (v0.4.0) | `audit_events` table (`civicaccess/access_review.py`) + `record_audit_event`; `review.create` is written in the same transaction as the review, `review.records_export` on export. Tests: `tests/test_citycore_hardening.py::test_audit_event_persisted_on_review_create`, `::test_audit_event_persisted_on_records_export`; Postgres-side in `tests/test_postgres_persistence.py`. | +| #4 | Backup/restore proof (not declaration) | **Closed** (v0.4.0) | Review + audit data are stored in the database the desktop supervisor backs up (shared CivicCore PostgreSQL cluster captured wholesale under `Data/postgres`; SQLite dev DB lives under `CIVICACCESS_DATA_DIR`). Round-trip proof: `tests/test_citycore_hardening.py::test_backup_restore_roundtrip_preserves_records_and_audit` writes, backs up (file copy), loses the live data, restores, and asserts records + audit survive. | +| #5 | Installer / desktop registry record (6-module city-core) | **Deferred → Phases B–C** | The `civicaccess` record in `CivicSuite/civicsuite` `installer/modules.json` still carries the stale `civiccore_requirement: "1.1.0"` and lacks the full contract fields. Authoring the runtime-valid record (Phase B) and flipping the city-core profile to 6 modules (Phase C) are out of scope for the module repo. | +| #6 | Clean-VM browser QA + full accessibility acceptance | **Deferred → Phase D** | Exercised on a clean VM (Windows Sandbox) against the installer-built stack with a full accessibility + export-correctness acceptance pass. | + +## Phase A persistence model (v0.4.0) + +- **Default store:** the shared CivicCore PostgreSQL. The module reads the supervisor-injected + `DATABASE_URL` (`postgresql+asyncpg://…:15432/…`) and derives a sync psycopg2 URL via + `_sync_database_url` (`civicaccess/main.py`). +- **Override:** `CIVICACCESS_REVIEW_DB_URL` (a dev SQLite path or a pre-built Postgres URL). +- **Fallback:** SQLite under `CIVICACCESS_DATA_DIR` only when neither is set (explicit dev use). +- **Release gate:** `CIVICACCESS_POSTGRES_TEST_URL` is required by `scripts/verify-release.sh` and CI + (a `postgres:16` service), so PostgreSQL persistence coverage cannot be silently skipped. + +## Notes / deliberate decisions + +- `POST /api/v1/civicaccess/export` (the generic export-checklist builder) is **not** token-guarded: + it is stateless advisory compute with no persisted data, in the same class as the form/plain-language/ + workflow planning routes. The records-grade export that reads persisted data + (`/reviews/{id}/records-export`) **is** guarded. This is the correct reading of "every + persistence-write route". +- The staff surface receives the write token server-rendered from `CIVICACCESS_TRUSTED_WRITE_TOKEN` + and sends it on save/export. The local desktop supervisor controls access to the staff surface; the + token gates the API for external/scripted callers and fails closed when unconfigured. diff --git a/README.md b/README.md index f898d1f..6ed9875 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ CivicAccess is the CivicSuite module for accessibility, plain-language, multilingual, and ADA Title II review-support workflows. -Current state: **v0.3.0 standalone readiness candidate**. This repo contains a FastAPI package aligned to the published CivicCore v1.2.0 release wheel, health/root endpoints, readiness gates, WCAG-aligned review support, local database-backed review records, accessible form planning, accessible publishing workflow checks, plain-language rewrites, multilingual draft variants, ADA Title II review-support packages, tagged-PDF expectations, records-ready export checklists, an API-backed public review UI at `/civicaccess`, and a staff review/export workspace at `/civicaccess/staff`. The previous `v1.0.0` release was published in error and remains historical evidence only. +Current state: **v0.4.0 standalone readiness candidate**. This repo contains a FastAPI package aligned to the published CivicCore v1.2.0 release wheel, health/root endpoints, readiness gates, WCAG-aligned review support, database-backed review records that default to the shared CivicCore PostgreSQL, accessible form planning, accessible publishing workflow checks, plain-language rewrites, multilingual draft variants, ADA Title II review-support packages, tagged-PDF expectations, records-ready export checklists, a stateless public accessibility checker at `/civicaccess`, and a staff review/export workspace at `/civicaccess/staff`. As of v0.4.0, persistent writes (saving reviews and records exports) require a trusted-write token, every write/export emits a persisted audit event, and the public surface analyzes without persisting. The previous `v1.0.0` release was published in error and remains historical evidence only. CivicAccess does **not** provide legal advice, certified ADA compliance, official translation certification, live LLM calls, or final publication approval. City staff, ADA coordinators, translators, and qualified reviewers remain responsible for publication decisions. diff --git a/README.txt b/README.txt index 38fec7c..fcb2749 100644 --- a/README.txt +++ b/README.txt @@ -3,7 +3,7 @@ CivicAccess CivicAccess is the CivicSuite module for accessibility, plain-language, multilingual, and ADA Title II review-support workflows. -Current state: v0.3.0 corrective demotion state. This repo contains a deterministic scaffold with a FastAPI package aligned to the published CivicCore v1.2.0 release wheel, health/root endpoints, readiness gates, WCAG-aligned review support, optional database-backed review records via CIVICACCESS_REVIEW_DB_URL, accessible form planning, accessible publishing workflow checks, plain-language rewrites, multilingual draft variants, ADA Title II review-support packages, tagged-PDF expectations, records-ready export checklists, and an API-backed public review UI at /civicaccess. The previous v1.0.0 release was published in error and is superseded by this honest sub-1.0.0 label. +Current state: v0.4.0 standalone readiness candidate. This repo contains a deterministic FastAPI package aligned to the published CivicCore v1.2.0 release wheel, health/root endpoints, readiness gates, WCAG-aligned review support, database-backed review records that default to the shared CivicCore PostgreSQL (with a SQLite dev fallback), accessible form planning, accessible publishing workflow checks, plain-language rewrites, multilingual draft variants, ADA Title II review-support packages, tagged-PDF expectations, records-ready export checklists, a stateless public accessibility checker at /civicaccess, and a trusted-write-token-guarded staff persistence/export surface with persisted audit events. The previous v1.0.0 release was published in error and is superseded by this honest sub-1.0.0 label. CivicAccess does not provide legal advice, certified ADA compliance, official translation certification, live LLM calls, or final publication approval. City staff, ADA coordinators, translators, and qualified reviewers remain responsible for publication decisions. diff --git a/SECURITY.md b/SECURITY.md index 9ee7fd5..83c2dff 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -1,7 +1,9 @@ # Security -CivicAccess version: `0.3.0`. +CivicAccess version: `0.4.0`. CivicAccess is self-hosted municipal software. It provides advisory accessibility, plain-language, multilingual draft, and ADA Title II review-support workflows; it does not make legal, certification, translation, or publication decisions. +Persistent writes (saving reviews and records exports) require the `CIVICACCESS_TRUSTED_WRITE_TOKEN` server secret, sent by the staff surface as the `X-CivicAccess-Write-Token` header. The public surface (`/civicaccess`) analyzes content statelessly and never persists. When the write token is not configured, persistence-backed writes fail closed (HTTP 503) rather than accepting unauthenticated writes. + Report vulnerabilities privately through the CivicSuite project maintainers. Do not include secrets, resident data, or protected municipal records in public issues. diff --git a/USER-MANUAL.md b/USER-MANUAL.md index a2d6826..41996b2 100644 --- a/USER-MANUAL.md +++ b/USER-MANUAL.md @@ -4,7 +4,7 @@ CivicAccess helps cities make public information easier to read, reach, translate, review, and preserve. It supports accessibility review, accessible forms, public publishing workflows, plain-language rewrites, multilingual draft variants, ADA Title II review support, tagged-PDF expectations, and records-ready export checklists. -Current state: `0.3.0` standalone readiness candidate. CivicAccess includes deterministic checks, local database-backed review records, readiness gates, an API-backed public review UI at `/civicaccess`, a staff review/export workspace at `/civicaccess/staff`, and CivicCore v1.2.0 release-wheel alignment. The previous `v1.0.0` release was published in error and remains historical evidence only. CivicAccess does not provide legal advice, certified ADA compliance, official translation certification, live LLM calls, or final publication approval. +Current state: `0.4.0` standalone readiness candidate. CivicAccess includes deterministic checks, database-backed review records that default to the shared CivicCore PostgreSQL (with a SQLite dev fallback), readiness gates, a stateless public accessibility checker at `/civicaccess`, a staff review/export workspace at `/civicaccess/staff`, trusted-write-token-guarded persistence with persisted audit events, and CivicCore v1.2.0 release-wheel alignment. The previous `v1.0.0` release was published in error and remains historical evidence only. CivicAccess does not provide legal advice, certified ADA compliance, official translation certification, live LLM calls, or final publication approval. ## For IT And Technical Staff diff --git a/USER-MANUAL.txt b/USER-MANUAL.txt index 365fb62..1905b2d 100644 --- a/USER-MANUAL.txt +++ b/USER-MANUAL.txt @@ -3,7 +3,7 @@ CivicAccess User Manual CivicAccess helps cities make public information easier to read, reach, translate, review, and preserve. It supports accessibility review, accessible forms, public publishing workflows, plain-language rewrites, multilingual draft variants, ADA Title II review support, tagged-PDF expectations, and records-ready export checklists. -Current state: 0.3.0 corrective demotion state. CivicAccess includes deterministic checks, optional database-backed review records, readiness gates, an API-backed public review UI at /civicaccess, and CivicCore v1.2.0 release-wheel alignment. The previous v1.0.0 release was published in error and is superseded by this honest sub-1.0.0 label. It does not provide legal advice, certified ADA compliance, official translation certification, live LLM calls, or final publication approval. +Current state: 0.4.0 standalone readiness candidate. CivicAccess includes deterministic checks, database-backed review records that default to the shared CivicCore PostgreSQL (with a SQLite dev fallback), readiness gates, a stateless public accessibility checker at /civicaccess, a staff review/export workspace at /civicaccess/staff, trusted-write-token-guarded persistence with persisted audit events, and CivicCore v1.2.0 release-wheel alignment. The previous v1.0.0 release was published in error and is superseded by this honest sub-1.0.0 label. It does not provide legal advice, certified ADA compliance, official translation certification, live LLM calls, or final publication approval. Runtime routes: diff --git a/civicaccess/__init__.py b/civicaccess/__init__.py index 3f3c522..6dc0269 100644 --- a/civicaccess/__init__.py +++ b/civicaccess/__init__.py @@ -1,3 +1,3 @@ """civicaccess package.""" -__version__ = "0.3.0" +__version__ = "0.4.0" diff --git a/civicaccess/access_review.py b/civicaccess/access_review.py index c30ce3a..15e48dc 100644 --- a/civicaccess/access_review.py +++ b/civicaccess/access_review.py @@ -49,7 +49,7 @@ class StoredAccessibilityReview: metadata = sa.MetaData() -SCHEMA_VERSION = "2026-06-05-001" +SCHEMA_VERSION = "civicaccess-windows-local-state-v1" accessibility_review_records = sa.Table( "accessibility_review_records", @@ -66,6 +66,17 @@ class StoredAccessibilityReview: schema="civicaccess", ) +audit_events = sa.Table( + "audit_events", + metadata, + sa.Column("event_id", sa.String(36), primary_key=True), + sa.Column("action", sa.String(80), nullable=False), + sa.Column("subject_id", sa.String(80), nullable=True), + sa.Column("actor", sa.String(120), nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + schema="civicaccess", +) + schema_migrations = sa.Table( "schema_migrations", metadata, @@ -120,7 +131,7 @@ def schema_status(self) -> SchemaStatus: inspector = sa.inspect(self.engine) translated_schema = None if self.engine.dialect.name == "sqlite" else "civicaccess" available_tables = set(inspector.get_table_names(schema=translated_schema)) - expected_tables = {"accessibility_review_records", "schema_migrations"} + expected_tables = {"accessibility_review_records", "audit_events", "schema_migrations"} missing_tables = tuple(sorted(expected_tables - available_tables)) schema_version = None if "schema_migrations" not in missing_tables: @@ -143,7 +154,7 @@ def review_count(self) -> int: return connection.execute(sa.select(sa.func.count()).select_from(accessibility_review_records)).scalar_one() def create_review( - self, *, title: str, body: str, has_alt_text: bool, language: str + self, *, title: str, body: str, has_alt_text: bool, language: str, actor: str = "staff" ) -> StoredAccessibilityReview: review = review_accessibility( title=title, @@ -176,8 +187,50 @@ def create_review( created_at=stored.created_at, ) ) + # Audit the write in the same transaction so the trail cannot drift from the record. + self.record_audit_event( + action="review.create", + subject_id=stored.review_id, + actor=actor, + connection=connection, + ) return stored + def record_audit_event( + self, + *, + action: str, + subject_id: str | None = None, + actor: str = "staff", + connection: object | None = None, + ) -> str: + """Persist a who/what/when audit row for a write or export action.""" + + event_id = str(uuid4()) + statement = audit_events.insert().values( + event_id=event_id, + action=action, + subject_id=subject_id, + actor=actor, + created_at=datetime.now(UTC), + ) + if connection is not None: + connection.execute(statement) + else: + with self.engine.begin() as own_connection: + own_connection.execute(statement) + return event_id + + def list_audit_events(self, *, limit: int = 50) -> tuple[dict[str, object], ...]: + bounded_limit = max(1, min(limit, 200)) + with self.engine.begin() as connection: + rows = connection.execute( + sa.select(audit_events) + .order_by(audit_events.c.created_at.desc()) + .limit(bounded_limit) + ).mappings().all() + return tuple(dict(row) for row in rows) + def get_review(self, review_id: str) -> StoredAccessibilityReview | None: with self.engine.begin() as connection: row = connection.execute( diff --git a/civicaccess/main.py b/civicaccess/main.py index 0dd8393..f70fcc8 100644 --- a/civicaccess/main.py +++ b/civicaccess/main.py @@ -4,13 +4,17 @@ from pathlib import Path from civiccore import __version__ as CIVICCORE_VERSION -from fastapi import FastAPI, HTTPException, Request +from fastapi import FastAPI, Header, HTTPException, Request from fastapi.exceptions import RequestValidationError from fastapi.responses import HTMLResponse, JSONResponse from pydantic import BaseModel, Field from civicaccess import __version__ -from civicaccess.access_review import AccessibilityReviewRepository, StoredAccessibilityReview +from civicaccess.access_review import ( + AccessibilityReviewRepository, + StoredAccessibilityReview, + review_accessibility, +) from civicaccess.exports import build_accessible_export from civicaccess.multilingual import create_language_variant from civicaccess.plain_language import rewrite_plain_language @@ -155,11 +159,34 @@ def public_civicaccess_page() -> str: def staff_civicaccess_page() -> str: """Return the staff publication review workspace.""" - return render_staff_page() + return render_staff_page(write_token=_trusted_write_token()) + + +@app.post("/api/v1/civicaccess/analyze") +def analyze_accessibility(request: AccessibilityReviewRequest) -> dict[str, object]: + """Stateless public accessibility analysis. No persistence, no token required.""" + + review = review_accessibility( + title=request.title, + body=request.body, + has_alt_text=request.has_alt_text, + language=request.language, + ) + return { + "status": review.status, + "findings": [finding.__dict__ for finding in review.findings], + "disclaimer": review.disclaimer, + "next_steps": list(review.next_steps), + "persisted": False, + } @app.post("/api/v1/civicaccess/review") -def accessibility_review(request: AccessibilityReviewRequest) -> dict[str, object]: +def accessibility_review( + request: AccessibilityReviewRequest, + x_civicaccess_write_token: str | None = Header(default=None), +) -> dict[str, object]: + _authorize_persistent_write(x_civicaccess_write_token) stored = _get_review_repository().create_review( title=request.title, body=request.body, @@ -194,8 +221,13 @@ def get_accessibility_review(review_id: str) -> dict[str, object]: @app.post("/api/v1/civicaccess/reviews/{review_id}/records-export") -def export_accessibility_review_record(review_id: str) -> dict[str, object]: - stored = _get_review_repository().get_review(review_id) +def export_accessibility_review_record( + review_id: str, + x_civicaccess_write_token: str | None = Header(default=None), +) -> dict[str, object]: + _authorize_persistent_write(x_civicaccess_write_token) + repository = _get_review_repository() + stored = repository.get_review(review_id) if stored is None: raise HTTPException( status_code=404, @@ -205,6 +237,7 @@ def export_accessibility_review_record(review_id: str) -> dict[str, object]: }, ) export = build_accessible_export(title=stored.title or "Untitled accessible publication") + repository.record_audit_event(action="review.records_export", subject_id=review_id) return { "status": "records-export-ready", "module": "civicaccess", @@ -336,10 +369,51 @@ def accessible_export(request: AccessibleExportRequest) -> dict[str, object]: } +def _trusted_write_token() -> str | None: + return os.environ.get("CIVICACCESS_TRUSTED_WRITE_TOKEN") + + +def _authorize_persistent_write(provided_token: str | None) -> None: + expected_token = _trusted_write_token() + if not expected_token: + raise HTTPException( + status_code=503, + detail={ + "message": "CivicAccess durable write guard is not configured.", + "fix": "Set CIVICACCESS_TRUSTED_WRITE_TOKEN before enabling persistence-backed writes.", + }, + ) + if provided_token != expected_token: + raise HTTPException( + status_code=403, + detail={ + "message": "CivicAccess durable write token is missing or invalid.", + "fix": "Send the configured X-CivicAccess-Write-Token header for persistence-backed writes.", + }, + ) + + +def _sync_database_url(url: str) -> str: + """Convert the supervisor's async DATABASE_URL to a sync psycopg2 URL (SQLite passes through).""" + + return ( + url.replace("postgresql+asyncpg", "postgresql+psycopg2") + .replace("postgres+asyncpg", "postgresql+psycopg2") + .replace("postgresql://", "postgresql+psycopg2://", 1) + .replace("postgres://", "postgresql+psycopg2://", 1) + ) + + def _review_database_url() -> str | None: + # Explicit per-module override wins (dev SQLite or a pre-built Postgres URL). configured = os.environ.get("CIVICACCESS_REVIEW_DB_URL") if configured: - return configured + return _sync_database_url(configured) + # Default to the shared CivicCore Postgres the desktop supervisor injects. + supervisor_url = os.environ.get("DATABASE_URL") + if supervisor_url: + return _sync_database_url(supervisor_url) + # ponytail: SQLite is the explicit dev fallback only when no shared Postgres is configured. data_dir = Path(os.environ.get("CIVICACCESS_DATA_DIR", Path.cwd() / "data")).resolve() data_dir.mkdir(parents=True, exist_ok=True) return f"sqlite:///{data_dir / 'civicaccess-reviews.db'}" diff --git a/civicaccess/public_ui.py b/civicaccess/public_ui.py index f301410..9872fae 100644 --- a/civicaccess/public_ui.py +++ b/civicaccess/public_ui.py @@ -1,7 +1,9 @@ -"""Public UI for CivicAccess v0.3.0.""" +"""Public UI for CivicAccess.""" from __future__ import annotations +import json + def render_public_lookup_page() -> str: """Render the accessible public-facing CivicAccess review page.""" @@ -53,7 +55,7 @@ def render_public_lookup_page() -> str:

CivicSuite / CivicAccess

Make public information easier to read, reach, and preserve.

CivicAccess gives staff a deterministic review path for accessible forms, public notices, plain-language rewrites, multilingual samples, ADA Title II review support, tagged-PDF expectations, and municipal-record exports.

-

v0.3.0 standalone readiness candidate

+

v0.4.0 standalone readiness candidate

@@ -146,7 +148,7 @@ def render_public_lookup_page() -> str: setResult("pending", "Loading review", "Checking the notice text and publication fields.", []); runReview.disabled = true; try { - const response = await fetch("/api/v1/civicaccess/review", { + const response = await fetch("/api/v1/civicaccess/analyze", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ @@ -181,15 +183,21 @@ def render_public_lookup_page() -> str: """ -def render_staff_page() -> str: +def render_staff_page(write_token: str | None = None) -> str: """Render the staff review workspace for saved CivicAccess work.""" - return """ + token_script = ( + "" + ) + return (""" CivicAccess Staff Workspace +