Skip to content

Commit a11022c

Browse files
committed
Merge branch 'development' into deck-pitch
2 parents a4ad252 + d99f648 commit a11022c

18 files changed

Lines changed: 635 additions & 96 deletions

README.md

Lines changed: 63 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -102,14 +102,74 @@ FireForm is designed from the ground up to ensure that all generated and collect
102102

103103
## 🔒 Privacy & Applicable Laws
104104

105-
FireForm is built on a local-first architecture, ensuring that all processing (including AI extraction) occurs locally on the user's hardware. We do not collect, process, or share any Personally Identifiable Information (PII) with third parties. By keeping data completely offline, FireForm natively assists deploying organizations in complying with strict data protection laws such as **HIPAA**, **CCPA**, and **GDPR**.
105+
FireForm is built on a **local-first architecture**: all processing (including AI extraction) occurs locally on the operator's hardware and nothing is transmitted to external servers by default. However, operators should be aware of the following:
106106

107-
For complete details on our data handling, consent management procedures, and how the solution was designed to comply with HIPAA, CCPA, and GDPR, please review our public [Privacy Policy](https://fireform-core.github.io/FireForm/privacy.html).
107+
- **PII is processed locally.** Incident descriptions, names, dates, and other personally identifiable information are extracted by the local LLM, written into filled PDF files, and persisted in the local database (`FormSubmission` records). These files and records remain on disk until explicitly deleted.
108+
- **No external transmission.** Data does not leave the machine by default. Audio transcription (Whisper) and LLM inference (Ollama) are both local services.
109+
- **Operator responsibility.** Because PII is stored locally, the security of that data depends entirely on the operator's host system, filesystem permissions, backup practices, and access controls.
110+
111+
For complete details on data handling and compliance guidance, please review our public [Privacy Policy](https://fireform-core.github.io/FireForm/privacy.html).
112+
113+
## 🗑️ Data Deletion & Retention
114+
115+
FireForm provides API endpoints and an automated purge mechanism to manage stored PII.
116+
117+
### Deleting individual records
118+
119+
```bash
120+
# Delete a single form submission (removes DB record + output PDF)
121+
curl -X DELETE http://localhost:8000/api/v1/forms/{submission_id} \
122+
-H "X-API-Key: your-key"
123+
124+
# Delete a template and all associated submissions
125+
curl -X DELETE http://localhost:8000/api/v1/templates/{template_id} \
126+
-H "X-API-Key: your-key"
127+
```
128+
129+
### Bulk purge
130+
131+
```bash
132+
# Purge all submissions older than 30 days
133+
curl -X POST "http://localhost:8000/api/v1/forms/purge?days=30" \
134+
-H "X-API-Key: your-key"
135+
```
136+
137+
### Automated retention
138+
139+
Set `RETENTION_PERIOD_DAYS` in `docker/.env.dev` (default: `30`). Celery Beat will automatically run a purge job every night at 03:00 UTC:
140+
141+
```env
142+
RETENTION_PERIOD_DAYS=30
143+
```
144+
145+
To enable the scheduler: `celery -A app.core.celery beat -l info`
146+
147+
## 🔑 Access Control
148+
149+
Deletion and purge endpoints can be protected by setting an API key:
150+
151+
```env
152+
FIREFORM_API_KEY=your-strong-secret-key
153+
```
154+
155+
When set, all `DELETE` and `POST /purge` requests must include one of:
156+
- **Header:** `X-API-Key: your-strong-secret-key`
157+
- **Bearer token:** `Authorization: Bearer your-strong-secret-key`
158+
159+
Read-only endpoints (list, preview, fill) do **not** require authentication.
160+
161+
## 🛡️ Operator Hardening Recommendations
162+
163+
- **Filesystem permissions:** Restrict access to `data/inputs/` and `data/outputs/` to the application user only (`chmod 700`).
164+
- **Backups:** Encrypt any backups containing output PDFs.
165+
- **HTTPS:** Deploy behind a reverse proxy (e.g., nginx) with TLS enabled.
166+
- **API key rotation:** Rotate `FIREFORM_API_KEY` regularly and never commit it to version control.
167+
- **Retention policy:** Configure `RETENTION_PERIOD_DAYS` in line with applicable regulations (HIPAA, GDPR, CCPA).
108168

109169
## 🛡️ Do No Harm by Design
110170

111171
FireForm is built to anticipate and prevent harm:
112-
- **Data Privacy & Security (9A):** We handle sensitive incident data entirely offline. By never transmitting data to the cloud, we natively prevent online data breaches of PII.
172+
- **Data Privacy & Security (9A):** We handle sensitive incident data entirely offline. By never transmitting data to the cloud, we natively prevent online data breaches of PII. Operators are responsible for securing local storage; see the Operator Hardening Recommendations above.
113173
- **Inappropriate Content (9B):** The application is an internal productivity tool without public social interactions or user-generated content hosting, completely mitigating the risks of public harassment or illegal content distribution.
114174
- **Protection from Harassment (9C):** For our open-source contributor community, we strictly enforce our [Code of Conduct](CODE_OF_CONDUCT.md) to ensure a safe, harassment-free environment for all contributors.
115175

app/api/deps.py

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,20 @@
11
from app.db.database import get_session
2+
from fastapi import Header, Request
3+
from app.core.config import FIREFORM_API_KEY
4+
from app.core.errors.base import AppError
25

36
def get_db():
4-
yield from get_session()
7+
yield from get_session()
8+
9+
def verify_api_key(request: Request, x_api_key: str | None = Header(None)):
10+
if not FIREFORM_API_KEY:
11+
return
12+
13+
provided_key = x_api_key
14+
if not provided_key:
15+
auth_header = request.headers.get("Authorization")
16+
if auth_header and auth_header.startswith("Bearer "):
17+
provided_key = auth_header[len("Bearer "):]
18+
19+
if not provided_key or provided_key != FIREFORM_API_KEY:
20+
raise AppError("Unauthorized", status_code=401, error_code="UNAUTHORIZED")

app/api/router.py

Lines changed: 8 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,12 @@
1-
"""Aggregates every route module into a single API router.
2-
3-
Add new feature routers here; main.py only mounts this one router.
4-
5-
IMPORTANT: Never add prefix="/api/v1" to api_router itself — that would
6-
silently move the existing /templates and /forms routes and break the frontend.
7-
New v1 endpoints live in app/api/v1/ and are included via v1_router below.
8-
"""
9-
101
from fastapi import APIRouter
112

12-
from app.api.routes import forms, jobs, templates
13-
from app.api.v1.router import v1_router
14-
from app.api.routes import forms, templates, weather, zipcode
3+
from app.api.routes import forms, templates, weather, zipcode, jobs, system
4+
from app.core.config import API_PREFIX
155

166
api_router = APIRouter()
17-
api_router.include_router(templates.router)
18-
api_router.include_router(forms.router)
19-
api_router.include_router(v1_router)
20-
api_router.include_router(jobs.router)
21-
22-
api_router.include_router(weather.router)
23-
api_router.include_router(zipcode.router)
7+
api_router.include_router(templates.router, prefix=API_PREFIX)
8+
api_router.include_router(forms.router, prefix=API_PREFIX)
9+
api_router.include_router(system.router, prefix=API_PREFIX)
10+
api_router.include_router(jobs.router, prefix=API_PREFIX)
11+
api_router.include_router(weather.router, prefix=API_PREFIX)
12+
api_router.include_router(zipcode.router, prefix=API_PREFIX)

app/api/routes/forms.py

Lines changed: 68 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,40 @@
1+
from datetime import datetime, timedelta, timezone
2+
from pathlib import Path
13
import requests
2-
from fastapi import APIRouter, Depends, File, UploadFile
3-
from sqlmodel import Session
4+
from fastapi import APIRouter, Depends, File, UploadFile, Query
5+
from sqlmodel import Session, select
46

5-
from app.api.deps import get_db
7+
from app.api.deps import get_db, verify_api_key
68
from app.api.schemas.forms import (
79
FormFill,
810
FormFillResponse,
911
ModelsResponse,
1012
TranscriptionResponse,
1113
)
12-
from app.core.config import OLLAMA_HOST, OLLAMA_MODEL, WHISPER_HOST
14+
from app.core.config import OLLAMA_HOST, OLLAMA_MODEL, WHISPER_HOST, BASE_DIR, RETENTION_PERIOD_DAYS
1315
from app.core.errors.base import AppError
14-
from app.db.repositories import create_form, get_template
15-
from app.models import FormSubmission, Template
16+
from app.db.repositories import create_form, get_template, get_form_submission, delete_form_submission
17+
from app.models import FormSubmission
1618
from app.services.controller import Controller
1719

20+
PROJECT_ROOT = BASE_DIR
21+
22+
def _resolve_project_file(file_path: str) -> Path:
23+
raw_path = (file_path or "").strip()
24+
if not raw_path:
25+
raise AppError("Path is required", status_code=400)
26+
27+
candidate = Path(raw_path)
28+
if not candidate.is_absolute():
29+
candidate = (PROJECT_ROOT / candidate).resolve()
30+
else:
31+
candidate = candidate.resolve()
32+
33+
if candidate != PROJECT_ROOT and PROJECT_ROOT not in candidate.parents:
34+
raise AppError("Path must be inside the project", status_code=400)
35+
36+
return candidate
37+
1838
router = APIRouter(prefix="/forms", tags=["forms"])
1939

2040

@@ -106,6 +126,48 @@ def transcribe(audio: UploadFile = File(...)):
106126
return TranscriptionResponse(text=text)
107127

108128

129+
@router.delete("/{submission_id}", dependencies=[Depends(verify_api_key)])
130+
def delete_submission_endpoint(submission_id: int, db: Session = Depends(get_db)):
131+
sub = get_form_submission(db, submission_id)
132+
if not sub:
133+
raise AppError("Submission not found", status_code=404, error_code="SUBMISSION_NOT_FOUND")
134+
135+
if sub.output_pdf_path:
136+
try:
137+
resolved_out = _resolve_project_file(sub.output_pdf_path)
138+
if resolved_out.exists() and resolved_out.is_file():
139+
resolved_out.unlink()
140+
except Exception:
141+
pass
142+
143+
delete_form_submission(db, sub)
144+
return {"status": "success", "message": "Submission and associated output file deleted"}
145+
146+
147+
@router.post("/purge", dependencies=[Depends(verify_api_key)])
148+
def purge_submissions_endpoint(days: int = Query(default=None), db: Session = Depends(get_db)):
149+
retention_days = days if days is not None else RETENTION_PERIOD_DAYS
150+
cutoff_date = datetime.now(timezone.utc) - timedelta(days=retention_days)
151+
152+
statement = select(FormSubmission).where(FormSubmission.created_at < cutoff_date)
153+
submissions = list(db.exec(statement))
154+
155+
purged_count = 0
156+
for sub in submissions:
157+
if sub.output_pdf_path:
158+
try:
159+
resolved_out = _resolve_project_file(sub.output_pdf_path)
160+
if resolved_out.exists() and resolved_out.is_file():
161+
resolved_out.unlink()
162+
except Exception:
163+
pass
164+
delete_form_submission(db, sub)
165+
purged_count += 1
166+
167+
return {"status": "success", "purged_count": purged_count, "retention_days_used": retention_days}
168+
169+
170+
109171
@router.get("/submissions")
110172
def get_submissions(db: Session = Depends(get_db)):
111173
from sqlmodel import select

app/api/routes/templates.py

Lines changed: 44 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
from fastapi.responses import FileResponse
77
from sqlmodel import Session
88

9-
from app.api.deps import get_db
9+
from app.api.deps import get_db, verify_api_key
1010
from app.api.schemas.templates import (
1111
TemplateCreate,
1212
TemplateResponse,
@@ -15,9 +15,10 @@
1515
MakeFillableResponse,
1616
)
1717
from app.core.config import BASE_DIR, DEFAULT_TEMPLATE_DIR
18-
from app.db.repositories import create_template, list_templates
19-
from app.models import Template
18+
from app.db.repositories import create_template, list_templates, get_template, delete_template
19+
from app.models import Template, FormSubmission, Job
2020
from app.services.controller import Controller
21+
from sqlmodel import select
2122

2223
router = APIRouter(prefix="/templates", tags=["templates"])
2324
PROJECT_ROOT = BASE_DIR
@@ -197,3 +198,43 @@ def make_fillable(req: MakeFillableRequest):
197198
pdf_path=relative_path,
198199
field_count=_count_pdf_widgets(relative_path),
199200
)
201+
202+
203+
@router.delete("/{template_id}", dependencies=[Depends(verify_api_key)])
204+
def delete_template_endpoint(template_id: int, db: Session = Depends(get_db)):
205+
template = get_template(db, template_id)
206+
if not template:
207+
raise HTTPException(status_code=404, detail="Template not found")
208+
209+
# 1. Clean up associated submissions and their generated PDFs
210+
sub_stmt = select(FormSubmission).where(FormSubmission.template_id == template_id)
211+
submissions = list(db.exec(sub_stmt))
212+
for sub in submissions:
213+
if sub.output_pdf_path:
214+
try:
215+
resolved_out = _resolve_project_file(sub.output_pdf_path)
216+
if resolved_out.exists() and resolved_out.is_file():
217+
resolved_out.unlink()
218+
except Exception:
219+
pass
220+
db.delete(sub)
221+
222+
# 2. Clean up associated jobs
223+
job_stmt = select(Job).where(Job.template_id == template_id)
224+
jobs = list(db.exec(job_stmt))
225+
for job in jobs:
226+
db.delete(job)
227+
228+
# 3. Delete template PDF file
229+
if template.pdf_path:
230+
try:
231+
resolved_pdf = _resolve_project_file(template.pdf_path)
232+
if resolved_pdf.exists() and resolved_pdf.is_file():
233+
resolved_pdf.unlink()
234+
except Exception:
235+
pass
236+
237+
# 4. Delete the template itself
238+
delete_template(db, template)
239+
return {"status": "success", "message": "Template and all associated data deleted"}
240+

app/api/v1/__init__.py

Whitespace-only changes.

app/api/v1/router.py

Lines changed: 0 additions & 13 deletions
This file was deleted.

app/api/v1/routes/__init__.py

Whitespace-only changes.

app/core/celery.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,4 +16,14 @@
1616
result_expires=86400,
1717
)
1818

19-
celery_app.conf.include = ["app.tasks.fill"]
19+
celery_app.conf.include = ["app.tasks.fill", "app.tasks.purge"]
20+
21+
# Optional Celery Beat schedule — runs purge_old_submissions once a day.
22+
# Enable by running: celery -A app.core.celery beat
23+
from celery.schedules import crontab # noqa: E402
24+
celery_app.conf.beat_schedule = {
25+
"daily-submission-purge": {
26+
"task": "purge_old_submissions",
27+
"schedule": crontab(hour=3, minute=0), # 03:00 UTC daily
28+
},
29+
}

0 commit comments

Comments
 (0)