Jul 20 - Update codes to comply with some framework (SOC 2 TYPE 2, ISO, etc)
This commit is contained in:
+8
-1
@@ -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
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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})
|
||||
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
@@ -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(
|
||||
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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', '')
|
||||
|
||||
|
||||
Reference in New Issue
Block a user