implement patches

This commit is contained in:
2026-04-17 16:35:44 -04:00
parent 15f8a38ba3
commit b0a475642d
8 changed files with 334 additions and 26 deletions
+1
View File
@@ -35,6 +35,7 @@ def create_app(config_name: str = 'development') -> Flask:
from .models.token_blacklist import TokenBlacklist from .models.token_blacklist import TokenBlacklist
from .models.shared_item import SharedItem from .models.shared_item import SharedItem
from .models.emergency_access import EmergencyAccess from .models.emergency_access import EmergencyAccess
from .models.audit_log import AuditLog
@login_manager.user_loader @login_manager.user_loader
def load_user(user_id): def load_user(user_id):
+58
View File
@@ -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'<AuditLog {self.action} by user={self.user_id} on {self.resource_type}#{self.resource_id}>'
+2 -2
View File
@@ -25,8 +25,8 @@ class User(db.Model, UserMixin):
totp_secret = db.Column(db.String(64), nullable=True) totp_secret = db.Column(db.String(64), nullable=True)
totp_enabled = db.Column(db.Boolean, default=False, nullable=False) totp_enabled = db.Column(db.Boolean, default=False, nullable=False)
# ECDH P-256 sharing keypair # ECDH P-256 sharing keypair
# Public key: raw uncompressed point (65 bytes), base64-encoded, stored plaintext # Public key: raw uncompressed point (65 bytes), base64-encoded (~88 chars), stored plaintext
sharing_public_key = db.Column(db.Text, nullable=True) sharing_public_key = db.Column(db.String(128), nullable=True)
# Private key: JWK, AES-256-GCM encrypted with the user's vault key # 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_enc = db.Column(db.Text, nullable=True)
sharing_private_key_iv = db.Column(db.String(64), nullable=True) sharing_private_key_iv = db.Column(db.String(64), nullable=True)
+58 -5
View File
@@ -4,6 +4,7 @@ import time
from flask import Blueprint, request, jsonify, g from flask import Blueprint, request, jsonify, g
from app import db, limiter from app import db, limiter
from app.models.user import User from app.models.user import User
from app.models.audit_log import AuditLog
from app.services.auth_service import ( from app.services.auth_service import (
hash_auth_token, hash_auth_token,
verify_auth_token, verify_auth_token,
@@ -19,6 +20,11 @@ auth_bp = Blueprint('auth', __name__)
EMAIL_RE = re.compile(r'^[^@\s]+@[^@\s]+\.[^@\s]+$') 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']) @auth_bp.route('/register', methods=['POST'])
@limiter.limit('10 per minute') @limiter.limit('10 per minute')
def register(): def register():
@@ -40,6 +46,16 @@ def register():
master_hash = hash_auth_token(auth_hash) master_hash = hash_auth_token(auth_hash)
user = User(email=email, master_hash=master_hash, enc_key_salt=enc_key_salt) user = User(email=email, master_hash=master_hash, enc_key_salt=enc_key_salt)
db.session.add(user) 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() db.session.commit()
return jsonify({'message': 'Account created successfully'}), 201 return jsonify({'message': 'Account created successfully'}), 201
@@ -63,6 +79,15 @@ def login():
from datetime import datetime from datetime import datetime
user.last_login = datetime.utcnow() 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() db.session.commit()
# MFA gate: if enabled, issue a short-lived mfa_token instead of full tokens # MFA gate: if enabled, issue a short-lived mfa_token instead of full tokens
@@ -125,7 +150,7 @@ def refresh():
@require_jwt @require_jwt
def mfa_setup(): def mfa_setup():
"""Generate a new TOTP secret and return QR code (as base64 PNG data URI).""" """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: if user.totp_enabled:
return jsonify({'error': 'MFA is already enabled'}), 400 return jsonify({'error': 'MFA is already enabled'}), 400
@@ -156,7 +181,7 @@ def mfa_setup():
@require_jwt @require_jwt
def mfa_enable(): def mfa_enable():
"""Enable MFA after verifying the first TOTP code.""" """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: if user.totp_enabled:
return jsonify({'error': 'MFA is already enabled'}), 400 return jsonify({'error': 'MFA is already enabled'}), 400
@@ -173,6 +198,15 @@ def mfa_enable():
user.totp_secret = secret user.totp_secret = secret
user.totp_enabled = True 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() db.session.commit()
return jsonify({'message': 'MFA enabled successfully'}), 200 return jsonify({'message': 'MFA enabled successfully'}), 200
@@ -182,7 +216,7 @@ def mfa_enable():
@require_jwt @require_jwt
def mfa_disable(): def mfa_disable():
"""Disable MFA after verifying the current TOTP code.""" """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: if not user.totp_enabled:
return jsonify({'error': 'MFA is not enabled'}), 400 return jsonify({'error': 'MFA is not enabled'}), 400
@@ -195,6 +229,15 @@ def mfa_disable():
user.totp_secret = None user.totp_secret = None
user.totp_enabled = False 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() db.session.commit()
return jsonify({'message': 'MFA disabled'}), 200 return jsonify({'message': 'MFA disabled'}), 200
@@ -216,7 +259,7 @@ def mfa_verify():
except Exception: except Exception:
return jsonify({'error': 'Invalid or expired MFA token'}), 401 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: if not user or not user.totp_enabled:
return jsonify({'error': 'MFA not configured for this account'}), 400 return jsonify({'error': 'MFA not configured for this account'}), 400
@@ -227,6 +270,16 @@ def mfa_verify():
# One-time use: blacklist the mfa_token # One-time use: blacklist the mfa_token
blacklist_token(mfa_token, 'mfa') 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) tokens = generate_tokens(user.id)
return jsonify({ return jsonify({
'access_token': tokens['access_token'], 'access_token': tokens['access_token'],
@@ -237,5 +290,5 @@ def mfa_verify():
@auth_bp.route('/mfa/status', methods=['GET']) @auth_bp.route('/mfa/status', methods=['GET'])
@require_jwt @require_jwt
def mfa_status(): 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 return jsonify({'totp_enabled': user.totp_enabled}), 200
+89 -11
View File
@@ -4,16 +4,21 @@ from flask import Blueprint, request, jsonify, g
from app import db from app import db
from app.models.user import User from app.models.user import User
from app.models.emergency_access import EmergencyAccess from app.models.emergency_access import EmergencyAccess
from app.models.audit_log import AuditLog
from app.services.auth_service import require_jwt from app.services.auth_service import require_jwt
emergency_bp = Blueprint('emergency', __name__) 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']) @emergency_bp.route('', methods=['GET'])
@require_jwt @require_jwt
def list_emergency(): def list_emergency():
"""Return emergency access records both as grantor and as grantee.""" """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( grants = EmergencyAccess.query.filter_by(grantor_id=user.id).order_by(
EmergencyAccess.created_at.desc() EmergencyAccess.created_at.desc()
@@ -33,7 +38,7 @@ def list_emergency():
def _ea_as_grantee(ea: EmergencyAccess) -> dict: 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 = ea.to_dict(grantor_email=grantor.email if grantor else None)
d['grantor_public_key'] = grantor.sharing_public_key if grantor else None d['grantor_public_key'] = grantor.sharing_public_key if grantor else None
return d return d
@@ -52,15 +57,19 @@ def create_emergency():
if not (1 <= wait_days <= 90): if not (1 <= wait_days <= 90):
return jsonify({'error': 'wait_days must be between 1 and 90'}), 400 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: if owner.email == grantee_email:
return jsonify({'error': 'Cannot designate yourself as emergency contact'}), 400 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( existing = EmergencyAccess.query.filter(
EmergencyAccess.grantor_id == g.current_user_id, EmergencyAccess.grantor_id == g.current_user_id,
EmergencyAccess.grantee_email == grantee_email, EmergencyAccess.grantee_email == grantee_email,
EmergencyAccess.status != 'denied', EmergencyAccess.status.in_(['invited', 'accepted', 'ready', 'pending']),
).first() ).first()
if existing: if existing:
return jsonify({'error': 'Emergency access already set up for this contact'}), 409 return jsonify({'error': 'Emergency access already set up for this contact'}), 409
@@ -73,6 +82,16 @@ def create_emergency():
wait_days=wait_days, wait_days=wait_days,
) )
db.session.add(ea) 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() db.session.commit()
return jsonify(ea.to_dict(grantor_email=owner.email)), 201 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() ea = EmergencyAccess.query.filter_by(id=ea_id, grantor_id=g.current_user_id).first()
if not ea: if not ea:
return jsonify({'error': 'Not found'}), 404 return jsonify({'error': 'Not found'}), 404
grantee_email = ea.grantee_email
db.session.delete(ea) 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() db.session.commit()
return jsonify({'message': 'Emergency access removed'}), 200 return jsonify({'message': 'Emergency access removed'}), 200
@@ -94,7 +125,7 @@ def delete_emergency(ea_id):
@require_jwt @require_jwt
def accept_emergency(ea_id): def accept_emergency(ea_id):
"""Grantee accepts an emergency access invitation.""" """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( ea = EmergencyAccess.query.filter(
EmergencyAccess.id == ea_id, EmergencyAccess.id == ea_id,
EmergencyAccess.status == 'invited', EmergencyAccess.status == 'invited',
@@ -108,6 +139,15 @@ def accept_emergency(ea_id):
ea.status = 'accepted' ea.status = 'accepted'
ea.grantee_id = user.id 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() db.session.commit()
return jsonify(_ea_as_grantee(ea)), 200 return jsonify(_ea_as_grantee(ea)), 200
@@ -135,9 +175,18 @@ def provide_vault(ea_id):
ea.enc_vault = enc_vault ea.enc_vault = enc_vault
ea.status = 'ready' 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() 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 return jsonify(ea.to_dict(grantor_email=grantor.email)), 200
@@ -145,7 +194,7 @@ def provide_vault(ea_id):
@require_jwt @require_jwt
def request_access(ea_id): def request_access(ea_id):
"""Grantee initiates an access request, starting the wait-period clock.""" """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( ea = EmergencyAccess.query.filter(
EmergencyAccess.id == ea_id, EmergencyAccess.id == ea_id,
EmergencyAccess.status == 'ready', EmergencyAccess.status == 'ready',
@@ -159,6 +208,15 @@ def request_access(ea_id):
ea.status = 'pending' ea.status = 'pending'
ea.request_initiated_at = datetime.utcnow() 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() db.session.commit()
return jsonify(_ea_as_grantee(ea)), 200 return jsonify(_ea_as_grantee(ea)), 200
@@ -176,9 +234,18 @@ def deny_access(ea_id):
ea.status = 'ready' ea.status = 'ready'
ea.request_initiated_at = None 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() 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 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. 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. 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( ea = EmergencyAccess.query.filter(
EmergencyAccess.id == ea_id, EmergencyAccess.id == ea_id,
db.or_( 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)' 'error': f'Wait period not yet elapsed ({days_left:.1f} day(s) remaining)'
}), 403 }), 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({ return jsonify({
'enc_vault': ea.enc_vault, 'enc_vault': ea.enc_vault,
'grantor_public_key': grantor.sharing_public_key if grantor else None, 'grantor_public_key': grantor.sharing_public_key if grantor else None,
}), 200 }), 200
+35
View File
@@ -1,11 +1,17 @@
from flask import Blueprint, request, jsonify, g from flask import Blueprint, request, jsonify, g
from app import db from app import db
from app.models.folder import Folder from app.models.folder import Folder
from app.models.audit_log import AuditLog
from app.services.auth_service import require_jwt from app.services.auth_service import require_jwt
folders_bp = Blueprint('folders', __name__) 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']) @folders_bp.route('', methods=['GET'])
@require_jwt @require_jwt
def list_folders(): def list_folders():
@@ -23,6 +29,15 @@ def create_folder():
folder = Folder(user_id=g.current_user_id, name=name) folder = Folder(user_id=g.current_user_id, name=name)
db.session.add(folder) 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() db.session.commit()
return jsonify(folder.to_dict()), 201 return jsonify(folder.to_dict()), 201
@@ -38,7 +53,16 @@ def update_folder(folder_id):
name = (data.get('name') or '').strip() name = (data.get('name') or '').strip()
if not name: if not name:
return jsonify({'error': 'name is required'}), 400 return jsonify({'error': 'name is required'}), 400
old_name = folder.name
folder.name = 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() db.session.commit()
return jsonify(folder.to_dict()), 200 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() folder = Folder.query.filter_by(id=folder_id, user_id=g.current_user_id).first()
if not folder: if not folder:
return jsonify({'error': 'Folder not found'}), 404 return jsonify({'error': 'Folder not found'}), 404
folder_name = folder.name
folder_id = folder.id
db.session.delete(folder) 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() db.session.commit()
return jsonify({'message': 'Folder deleted'}), 200 return jsonify({'message': 'Folder deleted'}), 200
+56 -8
View File
@@ -2,18 +2,23 @@ from flask import Blueprint, request, jsonify, g
from app import db from app import db
from app.models.user import User from app.models.user import User
from app.models.shared_item import SharedItem from app.models.shared_item import SharedItem
from app.models.audit_log import AuditLog
from app.services.auth_service import require_jwt from app.services.auth_service import require_jwt
sharing_bp = Blueprint('sharing', __name__) 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 keypair management ────────────────────────────────────────────────
@sharing_bp.route('/keys', methods=['GET']) @sharing_bp.route('/keys', methods=['GET'])
@require_jwt @require_jwt
def get_my_keys(): def get_my_keys():
"""Return current user's encrypted sharing private key (to decrypt client-side).""" """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: if not user.sharing_public_key:
return jsonify({'keys_setup': False}), 200 return jsonify({'keys_setup': False}), 200
return jsonify({ return jsonify({
@@ -36,10 +41,20 @@ def store_my_keys():
if not public_key or not private_key_enc or not private_key_iv: 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 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_public_key = public_key
user.sharing_private_key_enc = private_key_enc user.sharing_private_key_enc = private_key_enc
user.sharing_private_key_iv = private_key_iv 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() db.session.commit()
return jsonify({'message': 'Sharing keys stored'}), 200 return jsonify({'message': 'Sharing keys stored'}), 200
@@ -81,7 +96,7 @@ def list_outgoing():
result = [] result = []
for s in shares: for s in shares:
d = s.to_dict() 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 d['recipient_name'] = recipient.email if recipient else s.recipient_email
result.append(d) result.append(d)
return jsonify(result), 200 return jsonify(result), 200
@@ -110,7 +125,7 @@ def create_share():
if not all([item_id, recipient_email, enc_data, iv, item_name]): 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 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: if owner.email == recipient_email:
return jsonify({'error': 'Cannot share an item with yourself'}), 400 return jsonify({'error': 'Cannot share an item with yourself'}), 400
@@ -133,6 +148,16 @@ def create_share():
iv=iv, iv=iv,
) )
db.session.add(share) 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() db.session.commit()
return jsonify(share.to_dict()), 201 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() share = SharedItem.query.filter_by(id=share_id, owner_id=g.current_user_id).first()
if not share: if not share:
return jsonify({'error': 'Share not found'}), 404 return jsonify({'error': 'Share not found'}), 404
item_name = share.item_name
recipient_email = share.recipient_email
db.session.delete(share) 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() db.session.commit()
return jsonify({'message': 'Share removed'}), 200 return jsonify({'message': 'Share removed'}), 200
@@ -155,7 +193,7 @@ def delete_share(share_id):
@require_jwt @require_jwt
def inbox(): def inbox():
"""List all items shared with the current user.""" """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 = ( shares = (
SharedItem.query SharedItem.query
.filter( .filter(
@@ -170,7 +208,7 @@ def inbox():
result = [] result = []
for s in shares: for s in shares:
d = s.to_dict() 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_email'] = owner.email if owner else 'Unknown'
d['owner_public_key'] = owner.sharing_public_key if owner else None d['owner_public_key'] = owner.sharing_public_key if owner else None
result.append(d) result.append(d)
@@ -181,7 +219,7 @@ def inbox():
@require_jwt @require_jwt
def accept_share(share_id): def accept_share(share_id):
"""Mark a received share as accepted (links recipient_id if not already set).""" """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( share = SharedItem.query.filter(
SharedItem.id == share_id, SharedItem.id == share_id,
db.or_( db.or_(
@@ -194,10 +232,20 @@ def accept_share(share_id):
share.accepted = True share.accepted = True
share.recipient_id = user.id 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() db.session.commit()
d = share.to_dict() 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_email'] = owner.email if owner else 'Unknown'
d['owner_public_key'] = owner.sharing_public_key if owner else None d['owner_public_key'] = owner.sharing_public_key if owner else None
return jsonify(d), 200 return jsonify(d), 200
+35
View File
@@ -1,6 +1,7 @@
from flask import Blueprint, request, jsonify, g from flask import Blueprint, request, jsonify, g
from app import db from app import db
from app.models.vault_item import VaultItem, ItemType from app.models.vault_item import VaultItem, ItemType
from app.models.audit_log import AuditLog
from app.services.auth_service import require_jwt from app.services.auth_service import require_jwt
vault_bp = Blueprint('vault', __name__) vault_bp = Blueprint('vault', __name__)
@@ -8,6 +9,11 @@ vault_bp = Blueprint('vault', __name__)
VALID_TYPES = {t.value for t in ItemType} 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']) @vault_bp.route('', methods=['GET'])
@require_jwt @require_jwt
def list_items(): def list_items():
@@ -44,6 +50,15 @@ def create_item():
) )
try: try:
db.session.add(item) 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() db.session.commit()
except Exception as e: except Exception as e:
db.session.rollback() db.session.rollback()
@@ -80,6 +95,15 @@ def update_item(item_id):
if 'iv' in data: if 'iv' in data:
item.iv = data['iv'] 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() db.session.commit()
return jsonify(item.to_dict()), 200 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() item = VaultItem.query.filter_by(id=item_id, user_id=g.current_user_id).first()
if not item: if not item:
return jsonify({'error': 'Item not found'}), 404 return jsonify({'error': 'Item not found'}), 404
item_name = item.name
item_id = item.id
db.session.delete(item) 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() db.session.commit()
return jsonify({'message': 'Item deleted'}), 200 return jsonify({'message': 'Item deleted'}), 200