from datetime import datetime, timezone from flask import Blueprint, request, jsonify, g from app import db, limiter, client_ip from app.models.user import User from app.models.emergency_access import EmergencyAccess from app.models.audit_log import AuditLog from app.services.auth_service import require_jwt emergency_bp = Blueprint('emergency', __name__) @emergency_bp.route('', methods=['GET']) @limiter.limit('30 per minute') @require_jwt def list_emergency(): """Return emergency access records both as grantor and as grantee.""" user = db.session.get(User, g.current_user_id) grants = EmergencyAccess.query.filter_by(grantor_id=user.id).order_by( EmergencyAccess.created_at.desc() ).all() access = EmergencyAccess.query.filter( db.or_( EmergencyAccess.grantee_email == user.email, EmergencyAccess.grantee_id == user.id, ) ).order_by(EmergencyAccess.created_at.desc()).all() grantor_ids = {ea.grantor_id for ea in access} grantors = ( {u.id: u for u in User.query.filter(User.id.in_(grantor_ids)).all()} if grantor_ids else {} ) return jsonify({ 'grants': [ea.to_dict(grantor_email=user.email) for ea in grants], 'access': [_ea_as_grantee(ea, grantors.get(ea.grantor_id)) for ea in access], }), 200 def _log_for_both(ea: EmergencyAccess, action: str, grantor_detail: str, grantee_detail: str) -> None: """ Write the audit entry twice — once under each party's user_id. /api/auth/audit-log filters by user_id, so an entry written only under the acting user is invisible to the other party. That meant a grantee could request access and retrieve the vault snapshot without a single line of it appearing in the grantor's own audit log or security dashboard — the person whose vault it was had no way to see it had happened. Until email notifications exist (roadmap item 4), the grantor's audit log is the only channel that reaches them, so it must carry these events. """ AuditLog.log( user_id=ea.grantor_id, action=action, resource_type='emergency_access', resource_id=ea.id, detail=grantor_detail, ip_address=client_ip(), ) if ea.grantee_id and ea.grantee_id != ea.grantor_id: AuditLog.log( user_id=ea.grantee_id, action=action, resource_type='emergency_access', resource_id=ea.id, detail=grantee_detail, ip_address=client_ip(), ) def _ea_as_grantee(ea: EmergencyAccess, grantor: 'User | None' = None) -> dict: if grantor is None: grantor = db.session.get(User, ea.grantor_id) d = ea.to_dict(grantor_email=grantor.email if grantor else None) d['grantor_public_key'] = grantor.sharing_public_key if grantor else None return d @emergency_bp.route('', methods=['POST']) @limiter.limit('10 per minute') @require_jwt def create_emergency(): """Grantor creates an emergency access invitation for a trusted contact.""" data = request.get_json(silent=True) or {} grantee_email = (data.get('grantee_email') or '').strip().lower() try: wait_days = int(data.get('wait_days', 7)) except (TypeError, ValueError): return jsonify({'error': 'wait_days must be an integer'}), 400 if not grantee_email: return jsonify({'error': 'grantee_email is required'}), 400 if not (1 <= wait_days <= 90): return jsonify({'error': 'wait_days must be between 1 and 90'}), 400 owner = db.session.get(User, g.current_user_id) if owner.email == grantee_email: return jsonify({'error': 'Cannot designate yourself as emergency contact'}), 400 # No duplicate active grants — exclude only 'denied' records so a grantor # cannot re-invite a contact who already has an active/pending/ready grant, # but CAN re-invite after explicitly removing a previous grant (deleted rows). # Note: 'denied' status is a terminal reset-to-ready internal state, not a # standalone tombstone, so we block on all non-denied statuses. existing = EmergencyAccess.query.filter( EmergencyAccess.grantor_id == g.current_user_id, EmergencyAccess.grantee_email == grantee_email, EmergencyAccess.status.in_(['invited', 'accepted', 'ready', 'pending']), ).first() if existing: return jsonify({'error': 'Emergency access already set up for this contact'}), 409 grantee = User.query.filter_by(email=grantee_email).first() ea = EmergencyAccess( grantor_id=g.current_user_id, grantee_email=grantee_email, grantee_id=grantee.id if grantee else None, wait_days=wait_days, ) db.session.add(ea) db.session.flush() # populate ea.id before logging AuditLog.log( user_id=g.current_user_id, action='emergency_access.create', resource_type='emergency_access', resource_id=ea.id, detail=f'Created emergency access invitation for {grantee_email} (wait: {wait_days}d)', ip_address=client_ip(), ) db.session.commit() return jsonify(ea.to_dict(grantor_email=owner.email)), 201 @emergency_bp.route('/', methods=['DELETE']) @limiter.limit('10 per minute') @require_jwt def delete_emergency(ea_id): """Grantor removes an emergency access grant.""" ea = EmergencyAccess.query.filter_by(id=ea_id, grantor_id=g.current_user_id).first() if not ea: return jsonify({'error': 'Not found'}), 404 grantee_email = ea.grantee_email db.session.delete(ea) db.session.flush() AuditLog.log( user_id=g.current_user_id, action='emergency_access.delete', resource_type='emergency_access', resource_id=ea_id, detail=f'Removed emergency access grant for {grantee_email}', ip_address=client_ip(), ) db.session.commit() return jsonify({'message': 'Emergency access removed'}), 200 @emergency_bp.route('//accept', methods=['POST']) @limiter.limit('10 per minute') @require_jwt def accept_emergency(ea_id): """Grantee accepts an emergency access invitation.""" user = db.session.get(User, g.current_user_id) ea = EmergencyAccess.query.filter( EmergencyAccess.id == ea_id, EmergencyAccess.status == 'invited', db.or_( EmergencyAccess.grantee_email == user.email, EmergencyAccess.grantee_id == user.id, ), ).first() if not ea: return jsonify({'error': 'Not found or not in invited state'}), 404 ea.status = 'accepted' ea.grantee_id = user.id db.session.flush() _log_for_both( ea, 'emergency_access.accept', grantor_detail=f'{ea.grantee_email} accepted your emergency access invitation', grantee_detail=f'Accepted emergency access invitation from grantor_id={ea.grantor_id}', ) db.session.commit() return jsonify(_ea_as_grantee(ea)), 200 @emergency_bp.route('//provide', methods=['POST']) @limiter.limit('10 per minute') @require_jwt def provide_vault(ea_id): """ Grantor provides the ECDH-encrypted vault snapshot for emergency recovery. The client re-encrypts each vault item's plaintext with the ECDH shared secret (grantor private key + grantee public key) and sends the JSON array as enc_vault. """ ea = EmergencyAccess.query.filter_by(id=ea_id, grantor_id=g.current_user_id).first() if not ea: return jsonify({'error': 'Not found'}), 404 if ea.status not in ('accepted', 'ready'): return jsonify({'error': 'Emergency access must be in accepted or ready state'}), 400 data = request.get_json(silent=True) or {} enc_vault = data.get('enc_vault', '') if not enc_vault: return jsonify({'error': 'enc_vault (JSON array) is required'}), 400 ea.enc_vault = enc_vault ea.status = 'ready' AuditLog.log( user_id=g.current_user_id, action='emergency_access.provide_vault', resource_type='emergency_access', resource_id=ea.id, detail=f'Provided encrypted vault snapshot for grantee {ea.grantee_email}', ip_address=client_ip(), ) db.session.commit() grantor = db.session.get(User, g.current_user_id) return jsonify(ea.to_dict(grantor_email=grantor.email)), 200 @emergency_bp.route('//request', methods=['POST']) @limiter.limit('10 per minute') @require_jwt def request_access(ea_id): """Grantee initiates an access request, starting the wait-period clock.""" user = db.session.get(User, g.current_user_id) ea = EmergencyAccess.query.filter( EmergencyAccess.id == ea_id, EmergencyAccess.status == 'ready', db.or_( EmergencyAccess.grantee_email == user.email, EmergencyAccess.grantee_id == user.id, ), ).first() if not ea: return jsonify({'error': 'Not found or not in ready state'}), 404 ea.status = 'pending' ea.request_initiated_at = datetime.now(timezone.utc).replace(tzinfo=None) db.session.flush() # The grantor has `wait_days` to notice and deny this. If it only appeared in # the grantee's audit log they would never see it in time. _log_for_both( ea, 'emergency_access.request', grantor_detail=( f'ACTION REQUIRED: {ea.grantee_email} requested emergency access to ' f'your vault. It unlocks in {ea.wait_days} day(s) unless you deny it.' ), grantee_detail=( f'Requested emergency vault access from grantor_id={ea.grantor_id} ' f'(wait: {ea.wait_days}d)' ), ) db.session.commit() return jsonify(_ea_as_grantee(ea)), 200 @emergency_bp.route('//deny', methods=['POST']) @limiter.limit('10 per minute') @require_jwt def deny_access(ea_id): """Grantor denies a pending access request (resets to ready).""" ea = EmergencyAccess.query.filter_by(id=ea_id, grantor_id=g.current_user_id).first() if not ea: return jsonify({'error': 'Not found'}), 404 if ea.status != 'pending': return jsonify({'error': 'No pending request to deny'}), 400 ea.status = 'ready' ea.request_initiated_at = None AuditLog.log( user_id=g.current_user_id, action='emergency_access.deny', resource_type='emergency_access', resource_id=ea.id, detail=f'Denied emergency access request from grantee {ea.grantee_email}', ip_address=client_ip(), ) db.session.commit() grantor = db.session.get(User, g.current_user_id) return jsonify(ea.to_dict(grantor_email=grantor.email)), 200 @emergency_bp.route('//vault', methods=['GET']) @limiter.limit('10 per minute') @require_jwt def get_emergency_vault(ea_id): """ Grantee retrieves the encrypted vault snapshot after the wait period has elapsed. Also returns the grantor's public key so the client can derive the ECDH secret. """ user = db.session.get(User, g.current_user_id) ea = EmergencyAccess.query.filter( EmergencyAccess.id == ea_id, db.or_( EmergencyAccess.grantee_email == user.email, EmergencyAccess.grantee_id == user.id, ), ).first() if not ea: return jsonify({'error': 'Not found'}), 404 if not ea.wait_elapsed: if ea.request_initiated_at: elapsed_secs = (datetime.now(timezone.utc).replace(tzinfo=None) - ea.request_initiated_at).total_seconds() days_left = max(0, ea.wait_days - elapsed_secs / 86400) else: days_left = ea.wait_days return jsonify({ 'error': f'Wait period not yet elapsed ({days_left:.1f} day(s) remaining)' }), 403 # Record the retrieval. Access is deliberately not revoked afterwards — the # grantor may be unable to re-provision, and a failed import must not strand # the grantee — but every retrieval is counted and shown to the grantor, who # can revoke the grant outright. now = datetime.now(timezone.utc).replace(tzinfo=None) is_first = ea.vault_retrieved_at is None if is_first: ea.vault_retrieved_at = now ea.vault_retrieval_count = (ea.vault_retrieval_count or 0) + 1 _log_for_both( ea, 'emergency_access.vault_retrieved', grantor_detail=( f'{ea.grantee_email} retrieved your emergency vault snapshot ' f'({"first" if is_first else f"retrieval #{ea.vault_retrieval_count}"}). ' 'Remove the grant if this was not expected.' ), grantee_detail=f'Retrieved emergency vault from grantor_id={ea.grantor_id}', ) db.session.commit() grantor = db.session.get(User, ea.grantor_id) return jsonify({ 'enc_vault': ea.enc_vault, 'grantor_public_key': grantor.sharing_public_key if grantor else None, }), 200