diff --git a/app/__init__.py b/app/__init__.py index a182240..1fd92d9 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -35,6 +35,7 @@ def create_app(config_name: str = 'development') -> Flask: from .models.token_blacklist import TokenBlacklist from .models.shared_item import SharedItem from .models.emergency_access import EmergencyAccess + from .models.audit_log import AuditLog @login_manager.user_loader def load_user(user_id): diff --git a/app/models/audit_log.py b/app/models/audit_log.py new file mode 100644 index 0000000..bd4957e --- /dev/null +++ b/app/models/audit_log.py @@ -0,0 +1,58 @@ +from datetime import datetime + +from sqlalchemy.dialects.mysql import INTEGER + +from app import db + + +class AuditLog(db.Model): + """ + Server-side audit trail for create, edit, and delete actions. + + Captures who did what, when, and on which resource. + Sensitive field values are never logged — only resource IDs and action types. + """ + __tablename__ = 'audit_logs' + + id = db.Column(INTEGER(unsigned=True), autoincrement=True, primary_key=True) + user_id = db.Column(INTEGER(unsigned=True), nullable=False, index=True) + action = db.Column(db.String(64), nullable=False) # e.g. "vault_item.create" + resource_type = db.Column(db.String(64), nullable=False) # e.g. "vault_item" + resource_id = db.Column(INTEGER(unsigned=True), nullable=True) # FK to the affected row + detail = db.Column(db.String(512), nullable=True) # human-readable summary (no secrets) + ip_address = db.Column(db.String(45), nullable=True) # IPv4 or IPv6 + created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False, index=True) + + @classmethod + def log(cls, user_id: int, action: str, resource_type: str, + resource_id: int = None, detail: str = None, ip_address: str = None): + """ + Convenience factory that creates and flushes an audit entry. + Caller is responsible for calling db.session.commit() (or the surrounding + request handler will do so together with the business-logic commit). + """ + entry = cls( + user_id=user_id, + action=action, + resource_type=resource_type, + resource_id=resource_id, + detail=detail, + ip_address=ip_address, + ) + db.session.add(entry) + return entry + + def to_dict(self): + return { + 'id': self.id, + 'user_id': self.user_id, + 'action': self.action, + 'resource_type': self.resource_type, + 'resource_id': self.resource_id, + 'detail': self.detail, + 'ip_address': self.ip_address, + 'created_at': self.created_at.isoformat() if self.created_at else None, + } + + def __repr__(self): + return f'' diff --git a/app/models/user.py b/app/models/user.py index 58808fb..59717bf 100644 --- a/app/models/user.py +++ b/app/models/user.py @@ -25,8 +25,8 @@ class User(db.Model, UserMixin): totp_secret = db.Column(db.String(64), nullable=True) totp_enabled = db.Column(db.Boolean, default=False, nullable=False) # ECDH P-256 sharing keypair - # Public key: raw uncompressed point (65 bytes), base64-encoded, stored plaintext - sharing_public_key = db.Column(db.Text, nullable=True) + # Public key: raw uncompressed point (65 bytes), base64-encoded (~88 chars), stored plaintext + sharing_public_key = db.Column(db.String(128), nullable=True) # Private key: JWK, AES-256-GCM encrypted with the user's vault key sharing_private_key_enc = db.Column(db.Text, nullable=True) sharing_private_key_iv = db.Column(db.String(64), nullable=True) diff --git a/app/routes/auth.py b/app/routes/auth.py index 6dbd301..b094c33 100644 --- a/app/routes/auth.py +++ b/app/routes/auth.py @@ -4,6 +4,7 @@ import time from flask import Blueprint, request, jsonify, g from app import db, limiter from app.models.user import User +from app.models.audit_log import AuditLog from app.services.auth_service import ( hash_auth_token, verify_auth_token, @@ -19,6 +20,11 @@ auth_bp = Blueprint('auth', __name__) EMAIL_RE = re.compile(r'^[^@\s]+@[^@\s]+\.[^@\s]+$') +def _client_ip(): + """Return the best-effort client IP from the request context.""" + return request.headers.get('X-Forwarded-For', request.remote_addr or '').split(',')[0].strip() + + @auth_bp.route('/register', methods=['POST']) @limiter.limit('10 per minute') def register(): @@ -40,6 +46,16 @@ def register(): master_hash = hash_auth_token(auth_hash) user = User(email=email, master_hash=master_hash, enc_key_salt=enc_key_salt) db.session.add(user) + db.session.flush() # populate user.id before logging + + AuditLog.log( + user_id=user.id, + action='auth.register', + resource_type='user', + resource_id=user.id, + detail=f'New account registered: {email}', + ip_address=_client_ip(), + ) db.session.commit() return jsonify({'message': 'Account created successfully'}), 201 @@ -63,6 +79,15 @@ def login(): from datetime import datetime user.last_login = datetime.utcnow() + + AuditLog.log( + user_id=user.id, + action='auth.login', + resource_type='user', + resource_id=user.id, + detail=f'Successful login{" (MFA pending)" if user.totp_enabled else ""}', + ip_address=_client_ip(), + ) db.session.commit() # MFA gate: if enabled, issue a short-lived mfa_token instead of full tokens @@ -125,7 +150,7 @@ def refresh(): @require_jwt def mfa_setup(): """Generate a new TOTP secret and return QR code (as base64 PNG data URI).""" - user = User.query.get(g.current_user_id) + user = db.session.get(User, g.current_user_id) if user.totp_enabled: return jsonify({'error': 'MFA is already enabled'}), 400 @@ -156,7 +181,7 @@ def mfa_setup(): @require_jwt def mfa_enable(): """Enable MFA after verifying the first TOTP code.""" - user = User.query.get(g.current_user_id) + user = db.session.get(User, g.current_user_id) if user.totp_enabled: return jsonify({'error': 'MFA is already enabled'}), 400 @@ -173,6 +198,15 @@ def mfa_enable(): user.totp_secret = secret user.totp_enabled = True + + AuditLog.log( + user_id=user.id, + action='auth.mfa_enable', + resource_type='user', + resource_id=user.id, + detail='TOTP two-factor authentication enabled', + ip_address=_client_ip(), + ) db.session.commit() return jsonify({'message': 'MFA enabled successfully'}), 200 @@ -182,7 +216,7 @@ def mfa_enable(): @require_jwt def mfa_disable(): """Disable MFA after verifying the current TOTP code.""" - user = User.query.get(g.current_user_id) + user = db.session.get(User, g.current_user_id) if not user.totp_enabled: return jsonify({'error': 'MFA is not enabled'}), 400 @@ -195,6 +229,15 @@ def mfa_disable(): user.totp_secret = None user.totp_enabled = False + + AuditLog.log( + user_id=user.id, + action='auth.mfa_disable', + resource_type='user', + resource_id=user.id, + detail='TOTP two-factor authentication disabled', + ip_address=_client_ip(), + ) db.session.commit() return jsonify({'message': 'MFA disabled'}), 200 @@ -216,7 +259,7 @@ def mfa_verify(): except Exception: return jsonify({'error': 'Invalid or expired MFA token'}), 401 - user = User.query.get(int(payload['sub'])) + user = db.session.get(User, int(payload['sub'])) if not user or not user.totp_enabled: return jsonify({'error': 'MFA not configured for this account'}), 400 @@ -227,6 +270,16 @@ def mfa_verify(): # One-time use: blacklist the mfa_token blacklist_token(mfa_token, 'mfa') + AuditLog.log( + user_id=user.id, + action='auth.mfa_verify', + resource_type='user', + resource_id=user.id, + detail='MFA verification successful — session tokens issued', + ip_address=_client_ip(), + ) + db.session.commit() + tokens = generate_tokens(user.id) return jsonify({ 'access_token': tokens['access_token'], @@ -237,5 +290,5 @@ def mfa_verify(): @auth_bp.route('/mfa/status', methods=['GET']) @require_jwt def mfa_status(): - user = User.query.get(g.current_user_id) + user = db.session.get(User, g.current_user_id) return jsonify({'totp_enabled': user.totp_enabled}), 200 diff --git a/app/routes/emergency.py b/app/routes/emergency.py index e86d41d..38598ff 100644 --- a/app/routes/emergency.py +++ b/app/routes/emergency.py @@ -4,16 +4,21 @@ from flask import Blueprint, request, jsonify, g from app import db 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__) +def _client_ip(): + return request.headers.get('X-Forwarded-For', request.remote_addr or '').split(',')[0].strip() + + @emergency_bp.route('', methods=['GET']) @require_jwt def list_emergency(): """Return emergency access records both as grantor and as grantee.""" - user = User.query.get(g.current_user_id) + user = db.session.get(User, g.current_user_id) grants = EmergencyAccess.query.filter_by(grantor_id=user.id).order_by( EmergencyAccess.created_at.desc() @@ -33,7 +38,7 @@ def list_emergency(): def _ea_as_grantee(ea: EmergencyAccess) -> dict: - grantor = User.query.get(ea.grantor_id) + 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 @@ -52,15 +57,19 @@ def create_emergency(): if not (1 <= wait_days <= 90): return jsonify({'error': 'wait_days must be between 1 and 90'}), 400 - owner = User.query.get(g.current_user_id) + 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 + # 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 != 'denied', + EmergencyAccess.status.in_(['invited', 'accepted', 'ready', 'pending']), ).first() if existing: return jsonify({'error': 'Emergency access already set up for this contact'}), 409 @@ -73,6 +82,16 @@ def create_emergency(): 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 @@ -85,7 +104,19 @@ def delete_emergency(ea_id): 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 @@ -94,7 +125,7 @@ def delete_emergency(ea_id): @require_jwt def accept_emergency(ea_id): """Grantee accepts an emergency access invitation.""" - user = User.query.get(g.current_user_id) + user = db.session.get(User, g.current_user_id) ea = EmergencyAccess.query.filter( EmergencyAccess.id == ea_id, EmergencyAccess.status == 'invited', @@ -108,6 +139,15 @@ def accept_emergency(ea_id): ea.status = 'accepted' ea.grantee_id = user.id + + AuditLog.log( + user_id=g.current_user_id, + action='emergency_access.accept', + resource_type='emergency_access', + resource_id=ea.id, + detail=f'Accepted emergency access invitation from grantor_id={ea.grantor_id}', + ip_address=_client_ip(), + ) db.session.commit() return jsonify(_ea_as_grantee(ea)), 200 @@ -135,9 +175,18 @@ def provide_vault(ea_id): 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 = User.query.get(g.current_user_id) + grantor = db.session.get(User, g.current_user_id) return jsonify(ea.to_dict(grantor_email=grantor.email)), 200 @@ -145,7 +194,7 @@ def provide_vault(ea_id): @require_jwt def request_access(ea_id): """Grantee initiates an access request, starting the wait-period clock.""" - user = User.query.get(g.current_user_id) + user = db.session.get(User, g.current_user_id) ea = EmergencyAccess.query.filter( EmergencyAccess.id == ea_id, EmergencyAccess.status == 'ready', @@ -159,6 +208,15 @@ def request_access(ea_id): ea.status = 'pending' ea.request_initiated_at = datetime.utcnow() + + AuditLog.log( + user_id=g.current_user_id, + action='emergency_access.request', + resource_type='emergency_access', + resource_id=ea.id, + detail=f'Requested emergency vault access from grantor_id={ea.grantor_id} (wait: {ea.wait_days}d)', + ip_address=_client_ip(), + ) db.session.commit() return jsonify(_ea_as_grantee(ea)), 200 @@ -176,9 +234,18 @@ def deny_access(ea_id): 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 = User.query.get(g.current_user_id) + grantor = db.session.get(User, g.current_user_id) return jsonify(ea.to_dict(grantor_email=grantor.email)), 200 @@ -189,7 +256,7 @@ 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 = User.query.get(g.current_user_id) + user = db.session.get(User, g.current_user_id) ea = EmergencyAccess.query.filter( EmergencyAccess.id == ea_id, db.or_( @@ -209,8 +276,19 @@ def get_emergency_vault(ea_id): 'error': f'Wait period not yet elapsed ({days_left:.1f} day(s) remaining)' }), 403 - grantor = User.query.get(ea.grantor_id) + AuditLog.log( + user_id=g.current_user_id, + action='emergency_access.vault_retrieved', + resource_type='emergency_access', + resource_id=ea.id, + detail=f'Retrieved emergency vault from grantor_id={ea.grantor_id}', + ip_address=_client_ip(), + ) + 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 + diff --git a/app/routes/folders.py b/app/routes/folders.py index 735728b..2701b7f 100644 --- a/app/routes/folders.py +++ b/app/routes/folders.py @@ -1,11 +1,17 @@ from flask import Blueprint, request, jsonify, g from app import db from app.models.folder import Folder +from app.models.audit_log import AuditLog from app.services.auth_service import require_jwt folders_bp = Blueprint('folders', __name__) +def _client_ip(): + from flask import request as _req + return _req.headers.get('X-Forwarded-For', _req.remote_addr or '').split(',')[0].strip() + + @folders_bp.route('', methods=['GET']) @require_jwt def list_folders(): @@ -23,6 +29,15 @@ def create_folder(): folder = Folder(user_id=g.current_user_id, name=name) db.session.add(folder) + db.session.flush() # populate folder.id before logging + AuditLog.log( + user_id=g.current_user_id, + action='folder.create', + resource_type='folder', + resource_id=folder.id, + detail=f'Created folder: "{name}"', + ip_address=_client_ip(), + ) db.session.commit() return jsonify(folder.to_dict()), 201 @@ -38,7 +53,16 @@ def update_folder(folder_id): name = (data.get('name') or '').strip() if not name: return jsonify({'error': 'name is required'}), 400 + old_name = folder.name folder.name = name + AuditLog.log( + user_id=g.current_user_id, + action='folder.update', + resource_type='folder', + resource_id=folder.id, + detail=f'Renamed folder: "{old_name}" → "{name}"', + ip_address=_client_ip(), + ) db.session.commit() return jsonify(folder.to_dict()), 200 @@ -49,6 +73,17 @@ def delete_folder(folder_id): folder = Folder.query.filter_by(id=folder_id, user_id=g.current_user_id).first() if not folder: return jsonify({'error': 'Folder not found'}), 404 + folder_name = folder.name + folder_id = folder.id db.session.delete(folder) + db.session.flush() + AuditLog.log( + user_id=g.current_user_id, + action='folder.delete', + resource_type='folder', + resource_id=folder_id, + detail=f'Deleted folder: "{folder_name}"', + ip_address=_client_ip(), + ) db.session.commit() return jsonify({'message': 'Folder deleted'}), 200 diff --git a/app/routes/sharing.py b/app/routes/sharing.py index 44e3284..c2cd0b2 100644 --- a/app/routes/sharing.py +++ b/app/routes/sharing.py @@ -2,18 +2,23 @@ from flask import Blueprint, request, jsonify, g from app import db from app.models.user import User from app.models.shared_item import SharedItem +from app.models.audit_log import AuditLog from app.services.auth_service import require_jwt sharing_bp = Blueprint('sharing', __name__) +def _client_ip(): + return request.headers.get('X-Forwarded-For', request.remote_addr or '').split(',')[0].strip() + + # ── Sharing keypair management ──────────────────────────────────────────────── @sharing_bp.route('/keys', methods=['GET']) @require_jwt def get_my_keys(): """Return current user's encrypted sharing private key (to decrypt client-side).""" - user = User.query.get(g.current_user_id) + user = db.session.get(User, g.current_user_id) if not user.sharing_public_key: return jsonify({'keys_setup': False}), 200 return jsonify({ @@ -36,10 +41,20 @@ def store_my_keys(): if not public_key or not private_key_enc or not private_key_iv: return jsonify({'error': 'public_key, private_key_enc, and private_key_iv are required'}), 400 - user = User.query.get(g.current_user_id) + user = db.session.get(User, g.current_user_id) + action = 'sharing_keys.update' if user.sharing_public_key else 'sharing_keys.create' user.sharing_public_key = public_key user.sharing_private_key_enc = private_key_enc user.sharing_private_key_iv = private_key_iv + + AuditLog.log( + user_id=g.current_user_id, + action=action, + resource_type='sharing_keys', + resource_id=g.current_user_id, + detail='ECDH sharing keypair stored/updated', + ip_address=_client_ip(), + ) db.session.commit() return jsonify({'message': 'Sharing keys stored'}), 200 @@ -81,7 +96,7 @@ def list_outgoing(): result = [] for s in shares: d = s.to_dict() - recipient = User.query.get(s.recipient_id) if s.recipient_id else None + recipient = db.session.get(User, s.recipient_id) if s.recipient_id else None d['recipient_name'] = recipient.email if recipient else s.recipient_email result.append(d) return jsonify(result), 200 @@ -110,7 +125,7 @@ def create_share(): if not all([item_id, recipient_email, enc_data, iv, item_name]): return jsonify({'error': 'item_id, recipient_email, enc_data, iv, item_name are required'}), 400 - owner = User.query.get(g.current_user_id) + owner = db.session.get(User, g.current_user_id) if owner.email == recipient_email: return jsonify({'error': 'Cannot share an item with yourself'}), 400 @@ -133,6 +148,16 @@ def create_share(): iv=iv, ) db.session.add(share) + db.session.flush() # populate share.id before logging + + AuditLog.log( + user_id=g.current_user_id, + action='shared_item.create', + resource_type='shared_item', + resource_id=share.id, + detail=f'Shared item "{item_name}" ({item_type}) with {recipient_email}', + ip_address=_client_ip(), + ) db.session.commit() return jsonify(share.to_dict()), 201 @@ -144,7 +169,20 @@ def delete_share(share_id): share = SharedItem.query.filter_by(id=share_id, owner_id=g.current_user_id).first() if not share: return jsonify({'error': 'Share not found'}), 404 + + item_name = share.item_name + recipient_email = share.recipient_email db.session.delete(share) + db.session.flush() + + AuditLog.log( + user_id=g.current_user_id, + action='shared_item.delete', + resource_type='shared_item', + resource_id=share_id, + detail=f'Revoked share of "{item_name}" with {recipient_email}', + ip_address=_client_ip(), + ) db.session.commit() return jsonify({'message': 'Share removed'}), 200 @@ -155,7 +193,7 @@ def delete_share(share_id): @require_jwt def inbox(): """List all items shared with the current user.""" - user = User.query.get(g.current_user_id) + user = db.session.get(User, g.current_user_id) shares = ( SharedItem.query .filter( @@ -170,7 +208,7 @@ def inbox(): result = [] for s in shares: d = s.to_dict() - owner = User.query.get(s.owner_id) + owner = db.session.get(User, s.owner_id) d['owner_email'] = owner.email if owner else 'Unknown' d['owner_public_key'] = owner.sharing_public_key if owner else None result.append(d) @@ -181,7 +219,7 @@ def inbox(): @require_jwt def accept_share(share_id): """Mark a received share as accepted (links recipient_id if not already set).""" - user = User.query.get(g.current_user_id) + user = db.session.get(User, g.current_user_id) share = SharedItem.query.filter( SharedItem.id == share_id, db.or_( @@ -194,10 +232,20 @@ def accept_share(share_id): share.accepted = True share.recipient_id = user.id + + AuditLog.log( + user_id=g.current_user_id, + action='shared_item.accept', + resource_type='shared_item', + resource_id=share.id, + detail=f'Accepted shared item "{share.item_name}" from {share.recipient_email}', + ip_address=_client_ip(), + ) db.session.commit() d = share.to_dict() - owner = User.query.get(share.owner_id) + owner = db.session.get(User, share.owner_id) d['owner_email'] = owner.email if owner else 'Unknown' d['owner_public_key'] = owner.sharing_public_key if owner else None return jsonify(d), 200 + diff --git a/app/routes/vault.py b/app/routes/vault.py index 312d23a..6fc27ae 100644 --- a/app/routes/vault.py +++ b/app/routes/vault.py @@ -1,6 +1,7 @@ from flask import Blueprint, request, jsonify, g from app import db from app.models.vault_item import VaultItem, ItemType +from app.models.audit_log import AuditLog from app.services.auth_service import require_jwt vault_bp = Blueprint('vault', __name__) @@ -8,6 +9,11 @@ vault_bp = Blueprint('vault', __name__) VALID_TYPES = {t.value for t in ItemType} +def _client_ip(): + from flask import request as _req + return _req.headers.get('X-Forwarded-For', _req.remote_addr or '').split(',')[0].strip() + + @vault_bp.route('', methods=['GET']) @require_jwt def list_items(): @@ -44,6 +50,15 @@ def create_item(): ) try: db.session.add(item) + db.session.flush() # populate item.id before logging + AuditLog.log( + user_id=g.current_user_id, + action='vault_item.create', + resource_type='vault_item', + resource_id=item.id, + detail=f'Created {item_type} item: "{name}"', + ip_address=_client_ip(), + ) db.session.commit() except Exception as e: db.session.rollback() @@ -80,6 +95,15 @@ def update_item(item_id): if 'iv' in data: item.iv = data['iv'] + db.session.flush() + AuditLog.log( + user_id=g.current_user_id, + action='vault_item.update', + resource_type='vault_item', + resource_id=item.id, + detail=f'Updated item: "{item.name}"', + ip_address=_client_ip(), + ) db.session.commit() return jsonify(item.to_dict()), 200 @@ -90,6 +114,17 @@ def delete_item(item_id): item = VaultItem.query.filter_by(id=item_id, user_id=g.current_user_id).first() if not item: return jsonify({'error': 'Item not found'}), 404 + item_name = item.name + item_id = item.id db.session.delete(item) + db.session.flush() + AuditLog.log( + user_id=g.current_user_id, + action='vault_item.delete', + resource_type='vault_item', + resource_id=item_id, + detail=f'Deleted item: "{item_name}"', + ip_address=_client_ip(), + ) db.session.commit() return jsonify({'message': 'Item deleted'}), 200