diff --git a/app/__init__.py b/app/__init__.py index d16329a..92343ab 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -1,4 +1,4 @@ -from flask import Flask +from flask import Flask, request from flask_sqlalchemy import SQLAlchemy from flask_login import LoginManager from flask_migrate import Migrate @@ -251,6 +251,13 @@ def create_app(config_name='default'): response.headers.setdefault('X-Content-Type-Options', 'nosniff') response.headers.setdefault('X-Frame-Options', 'SAMEORIGIN') response.headers.setdefault('Referrer-Policy', 'strict-origin-when-cross-origin') + # Only asserted over an actual HTTPS request — ProxyFix (x_proto=1) makes + # request.is_secure reflect the real client-facing scheme behind Nginx, + # so this never fires for plain-HTTP local/dev requests. + if request.is_secure: + response.headers.setdefault( + 'Strict-Transport-Security', 'max-age=31536000; includeSubDomains' + ) response.headers.setdefault( 'Content-Security-Policy', "default-src 'self'; " diff --git a/app/routes/audit.py b/app/routes/audit.py index ab07e62..530ebf9 100644 --- a/app/routes/audit.py +++ b/app/routes/audit.py @@ -96,24 +96,29 @@ def view(log_id): # ── Purge old logs ──────────────────────────────────────────────────────────── +# Minimum floor of 1 year is deliberate: audit trails are the primary control +# evidence for SOC 2 / ISO 27001 access-monitoring, so shorter windows (the old +# 7/30/60/90/180-day options) are no longer offered — a purge can only ever +# remove entries old enough that they're outside any plausible audit lookback. PURGE_OPTIONS = { - 7: '7 days', - 30: '30 days', - 60: '60 days', - 90: '90 days', - 180: '180 days', 365: '1 year', + 730: '2 years', } +PURGE_CONFIRM_PHRASE = 'PURGE' + @bp.route('/purge', methods=['POST']) @login_required @admin_required def purge(): """Delete audit log entries older than the selected threshold. - Accepts a POST form field `older_than` (integer days). - The purge itself is recorded as a new audit log entry so there is - always a traceable record of who purged what and when. + Accepts POST form fields `older_than` (integer days, >= 1 year) and + `confirm_phrase` (must exactly equal PURGE_CONFIRM_PHRASE) — the typed + confirmation is extra friction against an accidental click on an + otherwise-irreversible action. The purge itself is recorded as a new + audit log entry so there is always a traceable record of who purged + what and when. """ try: older_than = int(request.form.get('older_than', 0)) @@ -124,6 +129,10 @@ def purge(): flash('Invalid purge threshold selected.', 'danger') return redirect(url_for('audit.index')) + if request.form.get('confirm_phrase', '').strip() != PURGE_CONFIRM_PHRASE: + flash(f'You must type "{PURGE_CONFIRM_PHRASE}" to confirm this action.', 'danger') + return redirect(url_for('audit.index')) + cutoff = now_eastern() - timedelta(days=older_than) deleted = AuditLog.query.filter(AuditLog.created_at < cutoff).delete() db.session.flush() diff --git a/app/routes/auth.py b/app/routes/auth.py index d40711e..50c4491 100644 --- a/app/routes/auth.py +++ b/app/routes/auth.py @@ -5,6 +5,7 @@ from app.models.user import User from app.utils.forms import LoginForm, UserForm, ProfileForm, ForgotPasswordForm, ResetPasswordForm from app.utils.decorators import admin_required, supervisor_required, safe_redirect_url import logging +import secrets from app.utils.audit import log_action, ACTION_CREATE, ACTION_UPDATE, ACTION_DELETE, ACTION_LOGIN, ACTION_LOGOUT logger = logging.getLogger(__name__) @@ -109,6 +110,135 @@ def profile(): ) +# ── Self-service data export (GDPR Art. 15/20, CCPA right-to-know) ──────────── + +@bp.route('/my-data/export') +@login_required +def export_my_data(): + """Download a JSON snapshot of everything this account's own records hold: + profile fields, inspections performed, issues reported/assigned/commented + on, and the audit log entries recorded against this user id. + + Read-only — does not touch other users' data even where it references + this user (e.g. an issue this user commented on is included, but the + facility/other participants' details are not expanded).""" + from flask import Response + import json + from app.models.inspection import Inspection + from app.models.issue import Issue, IssueComment + from app.models.audit import AuditLog + from app.utils.time_utils import now_eastern + + user = current_user + + payload = { + 'exported_at': now_eastern().isoformat(), + 'profile': { + 'id': user.id, + 'username': user.username, + 'full_name': user.full_name, + 'email': user.email, + 'role': user.role, + 'created_at': user.created_at.isoformat() if user.created_at else None, + 'active': user.active, + }, + 'inspections_performed': [ + { + 'id': i.id, 'facility_id': i.facility_id, 'inspection_date': i.inspection_date.isoformat() if i.inspection_date else None, + 'overall_score': i.overall_score, 'status': i.status, + } + for i in Inspection.query.filter_by(inspector_id=user.id).all() + ], + 'issues_reported': [ + {'id': iss.id, 'facility_id': iss.facility_id, 'description': iss.description, + 'status': iss.status, 'severity': iss.severity, 'reported_at': iss.reported_at.isoformat() if iss.reported_at else None} + for iss in Issue.query.filter_by(reported_by=user.id).all() + ], + 'issues_assigned': [ + {'id': iss.id, 'facility_id': iss.facility_id, 'description': iss.description, + 'status': iss.status, 'severity': iss.severity} + for iss in Issue.query.filter_by(assigned_to=user.id).all() + ], + 'issue_comments_authored': [ + {'id': c.id, 'issue_id': c.issue_id, 'body': c.body, + 'created_at': c.created_at.isoformat() if c.created_at else None} + for c in IssueComment.query.filter_by(user_id=user.id).all() + ], + 'audit_log_entries': [ + {'id': a.id, 'action': a.action, 'entity_type': a.entity_type, + 'entity_id': a.entity_id, 'entity_label': a.entity_label, + 'created_at': a.created_at.isoformat() if a.created_at else None} + for a in AuditLog.query.filter_by(user_id=user.id).all() + ], + } + + log_action(ACTION_UPDATE, 'User', user.id, user.username, 'self-service data export') + logger.info('AUTH | export_my_data | user_id=%s username=%s', user.id, user.username) + + body = json.dumps(payload, indent=2, default=str) + return Response( + body, + mimetype='application/json', + headers={'Content-Disposition': f'attachment; filename=jqc_my_data_{user.id}.json'}, + ) + + +# ── Self-service erasure request (GDPR Art. 17, CCPA right-to-delete) ───────── + +@bp.route('/my-data/delete-request', methods=['POST']) +@login_required +def request_my_data_deletion(): + """Erase this account's PII on request. + + If the account has no records that would be orphaned by a hard delete + (same guard rails as the admin delete_user route), it is deleted outright. + Otherwise — the common case, since inspectors/staff usually have + inspection or issue history that must be kept for business/audit + continuity — the account is anonymized in place: name/email/username are + replaced with a non-identifying placeholder, the password hash is + invalidated, and the account is deactivated. Historical records (which + reference the user id, not the PII) are preserved unchanged.""" + user = current_user + + from app.models.issue import Issue as _Issue, IssueComment as _IssueComment + from app.models.inspection import InspectionTemplate as _InspectionTemplate + + blocking = ( + user.inspections.count() > 0 + or _Issue.query.filter_by(assigned_to=user.id).count() > 0 + or _IssueComment.query.filter_by(user_id=user.id).count() > 0 + or _InspectionTemplate.query.filter_by(created_by=user.id).count() > 0 + ) + + username = user.username + user_id = user.id + + if not blocking: + db.session.delete(user) + db.session.commit() + logout_user() + logger.info('AUTH | self_delete | user_id=%s username=%s', user_id, username) + log_action(ACTION_DELETE, 'User', user_id, username, 'self-service account deletion') + flash('Your account and data have been permanently deleted.', 'success') + return redirect(url_for('auth.login')) + + placeholder = f'deleted_user_{user_id}' + user.full_name = None + user.email = f'{placeholder}@deleted.local' + user.username = placeholder + user.set_password(secrets.token_hex(32)) # invalidate — no one can log in as this account again + user.active = False + db.session.commit() + logger.info('AUTH | self_anonymize | user_id=%s (had blocking records, hard delete not possible)', user_id) + log_action(ACTION_UPDATE, 'User', user_id, placeholder, + 'self-service erasure request — anonymized (blocking records retained for audit/business continuity)') + logout_user() + flash('Your personal information has been removed and your account deactivated. ' + 'Historical records tied to your account id are retained for audit continuity but no longer identify you.', + 'success') + return redirect(url_for('auth.login')) + + @bp.route('/users') @login_required @admin_required diff --git a/app/routes/notifications.py b/app/routes/notifications.py index c8a9c0a..7203746 100644 --- a/app/routes/notifications.py +++ b/app/routes/notifications.py @@ -305,4 +305,94 @@ def check_score_trends(): sent = send_score_alerts(**kwargs) logger.info('SCORE TREND CHECK TRIGGERED | alerts_sent=%s', sent) - return jsonify({'ok': True, 'alerts_sent': sent}) \ No newline at end of file + return jsonify({'ok': True, 'alerts_sent': sent}) + + +# ── Photo retention purge (called by cron) ──────────────────────────────────── + +@bp.route('/purge-old-photos', methods=['POST']) +@csrf.exempt +def purge_old_photos(): + """Delete photo FILES (not the issue records) for issues resolved longer + ago than PHOTO_RETENTION_DAYS, addressing GDPR Art. 5(1)(e) storage + limitation — evidence photos otherwise persist forever. + + Disabled by default (no-op) unless PHOTO_RETENTION_DAYS is set in config/ + env — this is a data-minimization policy the operator opts into, not a + forced deletion, since some deployments may have a longer required + retention for their own contractual/audit reasons. + + Only touches RESOLVED issues whose resolved_at predates the cutoff. + Clears photo_path / mobile_photo_paths / result_photos to null/empty and + deletes the underlying files via the storage abstraction (safe on both + the local and R2 backends). The issue record itself, its description, + and its audit trail are untouched — only the photo bytes are removed. + + Recommended cron schedule — nightly is sufficient: + + 0 4 * * * curl -s -X POST https://yourdomain.com/notifications/purge-old-photos \\ + -d "token=YOUR_DIGEST_SECRET" + """ + token = request.form.get('token') or request.args.get('token') + expected = current_app.config.get('DIGEST_SECRET') + + if not expected or token != expected: + logger.warning('PHOTO PURGE REJECTED | bad or missing token') + abort(403) + + retention_days = current_app.config.get('PHOTO_RETENTION_DAYS') + if not retention_days: + return jsonify({'ok': True, 'skipped': 'PHOTO_RETENTION_DAYS not configured', 'issues_purged': 0}) + + from datetime import timedelta + from app.models.issue import Issue + from app.utils.time_utils import now_eastern + from app.utils.audit import log_action, ACTION_UPDATE + from app.utils import storage + + cutoff = now_eastern() - timedelta(days=int(retention_days)) + candidates = ( + Issue.query + .filter(Issue.status == 'resolved') + .filter(Issue.resolved_at.isnot(None)) + .filter(Issue.resolved_at < cutoff) + .filter( + db.or_( + Issue.photo_path.isnot(None), + Issue.mobile_photo_paths.isnot(None), + Issue.result_photos.isnot(None), + ) + ) + .all() + ) + + purged_count = 0 + for issue in candidates: + keys = [] + if issue.photo_path: + keys.append(issue.photo_path) + keys.extend(issue.mobile_photo_paths or []) + keys.extend(issue.result_photos or []) + for key in keys: + try: + storage.delete(key) + except Exception as exc: + logger.warning('PHOTO PURGE | failed to delete key=%s issue_id=%s: %s', + key, issue.id, exc) + issue.photo_path = None + issue.mobile_photo_paths = None + issue.result_photos = None + purged_count += 1 + + db.session.commit() + + if purged_count: + log_action( + ACTION_UPDATE, 'Issue', None, + f'Photo retention purge — {purged_count} resolved issue(s)', + f'cutoff={cutoff.strftime("%Y-%m-%d %H:%M:%S")}; retention_days={retention_days}', + ) + + logger.info('PHOTO PURGE TRIGGERED | issues_purged=%s | retention_days=%s', + purged_count, retention_days) + return jsonify({'ok': True, 'issues_purged': purged_count, 'retention_days': retention_days}) \ No newline at end of file diff --git a/app/routes/support.py b/app/routes/support.py index 6919e4e..5301edc 100644 --- a/app/routes/support.py +++ b/app/routes/support.py @@ -123,6 +123,33 @@ FAQS = [ _KB_MAX_CHARS = 6000 +# ── PII redaction for the outbound Groq payload ─────────────────────────────── +# Groq is a third-party processor. Customers may type identifying details +# (their own email/phone, or a coworker's) into a support question; there is +# no need for that to leave the app to get a helpful, generic answer. This +# scrubs a best-effort set of PII patterns from the copy of the text sent to +# Groq only — the original text is still saved as-is in support_chat_messages +# so the customer's own conversation history reads normally in the app. +import re as _re + +_PII_PATTERNS = [ + (_re.compile(r'[\w.+-]+@[\w-]+\.[\w.-]+'), '[redacted-email]'), + (_re.compile(r'\b\d{3}-\d{2}-\d{4}\b'), '[redacted-ssn]'), + (_re.compile(r'\b(?:\d[ -]?){13,19}\b'), '[redacted-number]'), + (_re.compile(r'\b(?:\+?1[ .-]?)?\(?\d{3}\)?[ .-]?\d{3}[ .-]?\d{4}\b'), '[redacted-phone]'), +] + + +def _redact_pii(text): + """Best-effort scrub of email/phone/SSN/card-like sequences from outbound text.""" + if not text: + return text + redacted = text + for pattern, placeholder in _PII_PATTERNS: + redacted = pattern.sub(placeholder, redacted) + return redacted + + def _system_prompt_with_kb(): """Return the base system prompt plus all ACTIVE admin knowledge entries (phase38), so staff can curate the chatbot's knowledge without code changes. @@ -214,11 +241,13 @@ def chat_message(): client = Groq(api_key=api_key) messages = [{'role': 'system', 'content': _system_prompt_with_kb()}] - # Append prior conversation (cap at last 20 turns to control token usage) + # Append prior conversation (cap at last 20 turns to control token usage). + # Redact PII-shaped text before it leaves the app for the Groq API — + # the unredacted originals stay in support_chat_messages below. for m in history[-20:]: if m.get('role') in ('user', 'assistant') and m.get('content'): - messages.append({'role': m['role'], 'content': m['content']}) - messages.append({'role': 'user', 'content': user_message}) + messages.append({'role': m['role'], 'content': _redact_pii(m['content'])}) + messages.append({'role': 'user', 'content': _redact_pii(user_message)}) model = os.environ.get('GROQ_MODEL', 'llama-3.3-70b-versatile') completion = client.chat.completions.create( diff --git a/app/templates/audit/index.html b/app/templates/audit/index.html index c11a5e0..226f647 100644 --- a/app/templates/audit/index.html +++ b/app/templates/audit/index.html @@ -221,13 +221,20 @@ +
All audit log entries created before the selected threshold will be @@ -247,8 +254,12 @@ {% endblock %} \ No newline at end of file diff --git a/app/templates/auth/profile.html b/app/templates/auth/profile.html index ae96513..d09eadb 100644 --- a/app/templates/auth/profile.html +++ b/app/templates/auth/profile.html @@ -81,6 +81,26 @@ + +
+ Download a copy of the data tied to your account, or request its + erasure. +
+ + Export My Data + + +