implement patches
This commit is contained in:
@@ -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):
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user