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_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'; "
+17 -8
View File
@@ -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()
+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.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
+90
View File
@@ -306,3 +306,93 @@ def check_score_trends():
logger.info('SCORE TREND CHECK TRIGGERED | alerts_sent=%s', 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
# ── 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(
+19 -8
View File
@@ -221,13 +221,20 @@
<label class="form-label fw-semibold">Delete logs older than</label>
<select name="older_than" class="form-select" id="purgeOlderThan" required>
<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="730">2 years</option>
</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>
<p class="text-muted small mb-0">
All audit log entries created before the selected threshold will be
@@ -247,8 +254,12 @@
</div>
<script>
document.getElementById('purgeOlderThan').addEventListener('change', function () {
document.getElementById('purgeSubmitBtn').disabled = !this.value;
});
function updatePurgeSubmitState() {
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>
{% endblock %}
+54
View File
@@ -81,6 +81,26 @@
</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>
<!-- ── Right column: edit form + recent inspections ──────────────── -->
@@ -221,4 +241,38 @@
</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 %}
+7
View File
@@ -68,6 +68,13 @@ class Config:
# ── Digest email secret token (used to authenticate cron trigger) ────────
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_API_KEY = os.environ.get('GOOGLE_MAPS_API_KEY', '')