Jul 20 - Update codes to comply with some framework (SOC 2 TYPE 2, ISO, etc)

This commit is contained in:
2026-07-20 21:10:47 -04:00
parent 66f9fc30f4
commit 65666d19e8
8 changed files with 358 additions and 21 deletions
+8 -1
View File
@@ -1,4 +1,4 @@
from flask import Flask from flask import Flask, request
from flask_sqlalchemy import SQLAlchemy from flask_sqlalchemy import SQLAlchemy
from flask_login import LoginManager from flask_login import LoginManager
from flask_migrate import Migrate 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-Content-Type-Options', 'nosniff')
response.headers.setdefault('X-Frame-Options', 'SAMEORIGIN') response.headers.setdefault('X-Frame-Options', 'SAMEORIGIN')
response.headers.setdefault('Referrer-Policy', 'strict-origin-when-cross-origin') 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( response.headers.setdefault(
'Content-Security-Policy', 'Content-Security-Policy',
"default-src 'self'; " "default-src 'self'; "
+17 -8
View File
@@ -96,24 +96,29 @@ def view(log_id):
# ── Purge old logs ──────────────────────────────────────────────────────────── # ── 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 = { PURGE_OPTIONS = {
7: '7 days',
30: '30 days',
60: '60 days',
90: '90 days',
180: '180 days',
365: '1 year', 365: '1 year',
730: '2 years',
} }
PURGE_CONFIRM_PHRASE = 'PURGE'
@bp.route('/purge', methods=['POST']) @bp.route('/purge', methods=['POST'])
@login_required @login_required
@admin_required @admin_required
def purge(): def purge():
"""Delete audit log entries older than the selected threshold. """Delete audit log entries older than the selected threshold.
Accepts a POST form field `older_than` (integer days). Accepts POST form fields `older_than` (integer days, >= 1 year) and
The purge itself is recorded as a new audit log entry so there is `confirm_phrase` (must exactly equal PURGE_CONFIRM_PHRASE) — the typed
always a traceable record of who purged what and when. 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: try:
older_than = int(request.form.get('older_than', 0)) older_than = int(request.form.get('older_than', 0))
@@ -124,6 +129,10 @@ def purge():
flash('Invalid purge threshold selected.', 'danger') flash('Invalid purge threshold selected.', 'danger')
return redirect(url_for('audit.index')) 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) cutoff = now_eastern() - timedelta(days=older_than)
deleted = AuditLog.query.filter(AuditLog.created_at < cutoff).delete() deleted = AuditLog.query.filter(AuditLog.created_at < cutoff).delete()
db.session.flush() db.session.flush()
+130
View File
@@ -5,6 +5,7 @@ from app.models.user import User
from app.utils.forms import LoginForm, UserForm, ProfileForm, ForgotPasswordForm, ResetPasswordForm from app.utils.forms import LoginForm, UserForm, ProfileForm, ForgotPasswordForm, ResetPasswordForm
from app.utils.decorators import admin_required, supervisor_required, safe_redirect_url from app.utils.decorators import admin_required, supervisor_required, safe_redirect_url
import logging import logging
import secrets
from app.utils.audit import log_action, ACTION_CREATE, ACTION_UPDATE, ACTION_DELETE, ACTION_LOGIN, ACTION_LOGOUT from app.utils.audit import log_action, ACTION_CREATE, ACTION_UPDATE, ACTION_DELETE, ACTION_LOGIN, ACTION_LOGOUT
logger = logging.getLogger(__name__) 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') @bp.route('/users')
@login_required @login_required
@admin_required @admin_required
+91 -1
View File
@@ -305,4 +305,94 @@ def check_score_trends():
sent = send_score_alerts(**kwargs) sent = send_score_alerts(**kwargs)
logger.info('SCORE TREND CHECK TRIGGERED | alerts_sent=%s', sent) logger.info('SCORE TREND CHECK TRIGGERED | alerts_sent=%s', sent)
return jsonify({'ok': True, 'alerts_sent': sent}) 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})
+32 -3
View File
@@ -123,6 +123,33 @@ FAQS = [
_KB_MAX_CHARS = 6000 _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(): def _system_prompt_with_kb():
"""Return the base system prompt plus all ACTIVE admin knowledge entries """Return the base system prompt plus all ACTIVE admin knowledge entries
(phase38), so staff can curate the chatbot's knowledge without code changes. (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) client = Groq(api_key=api_key)
messages = [{'role': 'system', 'content': _system_prompt_with_kb()}] 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:]: for m in history[-20:]:
if m.get('role') in ('user', 'assistant') and m.get('content'): if m.get('role') in ('user', 'assistant') and m.get('content'):
messages.append({'role': m['role'], 'content': m['content']}) messages.append({'role': m['role'], 'content': _redact_pii(m['content'])})
messages.append({'role': 'user', 'content': user_message}) messages.append({'role': 'user', 'content': _redact_pii(user_message)})
model = os.environ.get('GROQ_MODEL', 'llama-3.3-70b-versatile') model = os.environ.get('GROQ_MODEL', 'llama-3.3-70b-versatile')
completion = client.chat.completions.create( completion = client.chat.completions.create(
+19 -8
View File
@@ -221,13 +221,20 @@
<label class="form-label fw-semibold">Delete logs older than</label> <label class="form-label fw-semibold">Delete logs older than</label>
<select name="older_than" class="form-select" id="purgeOlderThan" required> <select name="older_than" class="form-select" id="purgeOlderThan" required>
<option value="">— Select a threshold —</option> <option value="">— Select a threshold —</option>
<option value="7">7 days</option>
<option value="30">30 days</option>
<option value="60">60 days</option>
<option value="90">90 days</option>
<option value="180">180 days</option>
<option value="365">1 year</option> <option value="365">1 year</option>
<option value="730">2 years</option>
</select> </select>
<div class="form-text">
Minimum retention is 1 year, so audit history stays available for
a full compliance lookback window.
</div>
</div>
<div class="mb-3">
<label class="form-label fw-semibold">
Type <code>PURGE</code> to confirm
</label>
<input type="text" name="confirm_phrase" class="form-control"
id="purgeConfirmPhrase" autocomplete="off" required>
</div> </div>
<p class="text-muted small mb-0"> <p class="text-muted small mb-0">
All audit log entries created before the selected threshold will be All audit log entries created before the selected threshold will be
@@ -247,8 +254,12 @@
</div> </div>
<script> <script>
document.getElementById('purgeOlderThan').addEventListener('change', function () { function updatePurgeSubmitState() {
document.getElementById('purgeSubmitBtn').disabled = !this.value; var thresholdOk = !!document.getElementById('purgeOlderThan').value;
}); var confirmOk = document.getElementById('purgeConfirmPhrase').value === 'PURGE';
document.getElementById('purgeSubmitBtn').disabled = !(thresholdOk && confirmOk);
}
document.getElementById('purgeOlderThan').addEventListener('change', updatePurgeSubmitState);
document.getElementById('purgeConfirmPhrase').addEventListener('input', updatePurgeSubmitState);
</script> </script>
{% endblock %} {% endblock %}
+54
View File
@@ -81,6 +81,26 @@
</div> </div>
</div> </div>
<!-- My Data & Privacy Card -->
<div class="card shadow-sm mt-4">
<div class="card-header bg-light">
<h6 class="mb-0 fw-semibold"><i class="bi bi-shield-lock me-1"></i>My Data & Privacy</h6>
</div>
<div class="card-body">
<p class="text-muted small mb-3">
Download a copy of the data tied to your account, or request its
erasure.
</p>
<a href="{{ url_for('auth.export_my_data') }}" class="btn btn-outline-secondary btn-sm w-100 mb-2">
<i class="bi bi-download me-1"></i>Export My Data
</a>
<button type="button" class="btn btn-outline-danger btn-sm w-100"
data-bs-toggle="modal" data-bs-target="#deleteMyDataModal">
<i class="bi bi-trash me-1"></i>Delete My Account & Data
</button>
</div>
</div>
</div> </div>
<!-- ── Right column: edit form + recent inspections ──────────────── --> <!-- ── Right column: edit form + recent inspections ──────────────── -->
@@ -221,4 +241,38 @@
</div> </div>
</div> </div>
<!-- ── Delete My Data Modal ─────────────────────────────────────────────── -->
<div class="modal fade" id="deleteMyDataModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header bg-danger text-white">
<h5 class="modal-title"><i class="bi bi-exclamation-triangle me-2"></i>Delete My Account & Data</h5>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
</div>
<form method="POST" action="{{ url_for('auth.request_my_data_deletion') }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="modal-body">
<div class="alert alert-warning mb-3">
<i class="bi bi-exclamation-triangle-fill me-1"></i>
This action is <strong>permanent</strong> and logs you out immediately.
</div>
<p class="mb-0">
If you have no inspection or issue history tied to your account, it will be
<strong>permanently deleted</strong>. If you do have history (common for staff
accounts), your name, username, and email will be replaced with a
non-identifying placeholder and the account deactivated — historical records
stay intact for audit continuity but will no longer identify you.
</p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="submit" class="btn btn-danger">
<i class="bi bi-trash me-1"></i>Confirm Deletion
</button>
</div>
</form>
</div>
</div>
</div>
{% endblock %} {% endblock %}
+7
View File
@@ -68,6 +68,13 @@ class Config:
# ── Digest email secret token (used to authenticate cron trigger) ──────── # ── Digest email secret token (used to authenticate cron trigger) ────────
DIGEST_SECRET = os.environ.get('DIGEST_SECRET') DIGEST_SECRET = os.environ.get('DIGEST_SECRET')
# ── Photo retention (data minimization — GDPR Art. 5(1)(e)) ─────────────
# Unset (None) by default: no automatic photo deletion happens unless the
# operator opts in. When set, /notifications/purge-old-photos deletes
# photo files (not the issue record) for RESOLVED issues older than this
# many days.
PHOTO_RETENTION_DAYS = int(os.environ['PHOTO_RETENTION_DAYS']) if os.environ.get('PHOTO_RETENTION_DAYS') else None
# ── Google Maps (used for GPS map on inspection view) ──────────────────── # ── Google Maps (used for GPS map on inspection view) ────────────────────
GOOGLE_MAPS_API_KEY = os.environ.get('GOOGLE_MAPS_API_KEY', '') GOOGLE_MAPS_API_KEY = os.environ.get('GOOGLE_MAPS_API_KEY', '')