Refactor backend: Split models and proxmox_client into modules, add alembic
Some checks failed
Backend Tests / pytest (Python 3.12) (push) Failing after 2m27s
Some checks failed
Backend Tests / pytest (Python 3.12) (push) Failing after 2m27s
This commit is contained in:
204
backend/app/models/auth.py
Normal file
204
backend/app/models/auth.py
Normal file
@@ -0,0 +1,204 @@
|
||||
from .base import *
|
||||
|
||||
# Association table: User <-> ProxmoxServer (assigned servers)
|
||||
user_servers = Table(
|
||||
'user_servers',
|
||||
Base.metadata,
|
||||
Column('user_id', Integer, ForeignKey('users.id', ondelete='CASCADE'), primary_key=True),
|
||||
Column('server_id', Integer, ForeignKey('proxmox_servers.id', ondelete='CASCADE'), primary_key=True)
|
||||
)
|
||||
|
||||
class Role(Base):
|
||||
"""User roles with permissions"""
|
||||
__tablename__ = "roles"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
name = Column(String(50), unique=True, nullable=False, index=True)
|
||||
display_name = Column(String(100), nullable=False)
|
||||
description = Column(Text, nullable=True)
|
||||
permissions = Column(JSON, nullable=False, default={})
|
||||
is_system = Column(Boolean, default=False, nullable=False) # System roles can't be deleted
|
||||
is_active = Column(Boolean, default=True, nullable=False)
|
||||
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
|
||||
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False)
|
||||
|
||||
# Relationships
|
||||
users = relationship("User", back_populates="role")
|
||||
|
||||
def has_permission(self, permission: str) -> bool:
|
||||
"""Check if role has specific permission"""
|
||||
if not self.permissions:
|
||||
return False
|
||||
return self.permissions.get(permission, False)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<Role(id={self.id}, name='{self.name}')>"
|
||||
|
||||
class User(Base):
|
||||
__tablename__ = "users"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
username = Column(String(50), unique=True, nullable=False, index=True)
|
||||
email = Column(String(255), unique=True, nullable=False, index=True)
|
||||
hashed_password = Column(String(255), nullable=False)
|
||||
full_name = Column(String(100), nullable=True)
|
||||
|
||||
is_active = Column(Boolean, default=True, nullable=False)
|
||||
is_admin = Column(Boolean, default=False, nullable=False) # Legacy, use role instead
|
||||
|
||||
# Role-based access
|
||||
role_id = Column(Integer, ForeignKey("roles.id"), nullable=True, index=True)
|
||||
role = relationship("Role", back_populates="users", lazy="joined")
|
||||
|
||||
# SSH Public Key for VM/LXC deployment
|
||||
ssh_public_key = Column(Text, nullable=True)
|
||||
|
||||
# Security fields
|
||||
failed_login_attempts = Column(Integer, default=0, nullable=False)
|
||||
locked_until = Column(DateTime(timezone=True), nullable=True)
|
||||
last_password_change = Column(DateTime(timezone=True), nullable=True)
|
||||
require_password_change = Column(Boolean, default=False, nullable=False)
|
||||
two_factor_enabled = Column(Boolean, default=False, nullable=False)
|
||||
two_factor_secret = Column(String(100), nullable=True)
|
||||
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
|
||||
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False)
|
||||
last_login = Column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
# Relationships
|
||||
sessions = relationship("ActiveSession", back_populates="user", cascade="all, delete-orphan")
|
||||
assigned_servers = relationship("ProxmoxServer", secondary=user_servers, back_populates="assigned_users", lazy="select")
|
||||
|
||||
def has_permission(self, permission: str) -> bool:
|
||||
"""
|
||||
Check if user has specific permission.
|
||||
Uses new RBAC engine with legacy format support.
|
||||
"""
|
||||
try:
|
||||
from ..rbac import PermissionEngine
|
||||
return PermissionEngine.has_permission(self, permission)
|
||||
except ImportError:
|
||||
if self.is_admin:
|
||||
return True
|
||||
if self.role:
|
||||
return self.role.has_permission(permission)
|
||||
return False
|
||||
|
||||
def get_permissions(self) -> set:
|
||||
"""Get all effective permissions for this user"""
|
||||
try:
|
||||
from ..rbac import PermissionEngine
|
||||
return PermissionEngine.get_user_permissions(self)
|
||||
except ImportError:
|
||||
if self.is_admin:
|
||||
return set() # Admin has all
|
||||
if self.role and self.role.permissions:
|
||||
return {k for k, v in self.role.permissions.items() if v}
|
||||
return set()
|
||||
|
||||
def is_locked(self) -> bool:
|
||||
"""Check if account is locked"""
|
||||
if self.locked_until and make_tz_aware(self.locked_until) > utcnow():
|
||||
return True
|
||||
return False
|
||||
|
||||
@property
|
||||
def role_name(self) -> str:
|
||||
"""Get role name"""
|
||||
if self.role:
|
||||
return self.role.name
|
||||
return "admin" if self.is_admin else "user"
|
||||
|
||||
def __repr__(self):
|
||||
return f"<User(id={self.id}, username='{self.username}', is_admin={self.is_admin})>"
|
||||
|
||||
class ActiveSession(Base):
|
||||
"""Active user sessions for single-session enforcement"""
|
||||
__tablename__ = "active_sessions"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
|
||||
session_token = Column(String(64), unique=True, nullable=False, index=True)
|
||||
ip_address = Column(String(45), nullable=True)
|
||||
user_agent = Column(String(500), nullable=True)
|
||||
device_info = Column(String(200), nullable=True)
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
|
||||
expires_at = Column(DateTime(timezone=True), nullable=False)
|
||||
last_activity = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
|
||||
is_active = Column(Boolean, default=True, nullable=False)
|
||||
|
||||
# Relationships
|
||||
user = relationship("User", back_populates="sessions")
|
||||
|
||||
def is_expired(self) -> bool:
|
||||
"""Check if session is expired"""
|
||||
from datetime import timezone
|
||||
now = datetime.now(timezone.utc)
|
||||
if self.expires_at is None:
|
||||
return True
|
||||
return now > make_tz_aware(self.expires_at)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<ActiveSession(id={self.id}, user_id={self.user_id}, active={self.is_active})>"
|
||||
|
||||
class LoginAttempt(Base):
|
||||
"""Login attempts for brute-force protection"""
|
||||
__tablename__ = "login_attempts"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
username = Column(String(100), nullable=True, index=True)
|
||||
ip_address = Column(String(45), nullable=False, index=True)
|
||||
user_agent = Column(String(500), nullable=True)
|
||||
success = Column(Boolean, default=False, nullable=False)
|
||||
failure_reason = Column(String(200), nullable=True)
|
||||
attempted_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False, index=True)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<LoginAttempt(id={self.id}, ip={self.ip_address}, success={self.success})>"
|
||||
|
||||
class BlockedIP(Base):
|
||||
"""Blocked IP addresses"""
|
||||
__tablename__ = "blocked_ips"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
ip_address = Column(String(45), unique=True, nullable=False, index=True)
|
||||
reason = Column(String(500), nullable=True)
|
||||
blocked_by = Column(String(100), nullable=True)
|
||||
blocked_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
|
||||
expires_at = Column(DateTime(timezone=True), nullable=True)
|
||||
is_permanent = Column(Boolean, default=False, nullable=False)
|
||||
attempts_count = Column(Integer, default=0, nullable=False)
|
||||
|
||||
def is_blocked(self) -> bool:
|
||||
"""Check if IP is currently blocked"""
|
||||
if self.is_permanent:
|
||||
return True
|
||||
if self.expires_at and make_tz_aware(self.expires_at) > utcnow():
|
||||
return True
|
||||
return False
|
||||
|
||||
def __repr__(self):
|
||||
return f"<BlockedIP(id={self.id}, ip={self.ip_address})>"
|
||||
|
||||
class UserSSHKey(Base):
|
||||
"""SSH key attached to a user account."""
|
||||
__tablename__ = "user_ssh_keys"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
name = Column(String(100), nullable=False)
|
||||
public_key = Column(Text, nullable=False)
|
||||
private_key = Column(EncryptedString(8000), nullable=True)
|
||||
fingerprint = Column(String(100), nullable=True, index=True)
|
||||
comment = Column(String(255), nullable=True)
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
|
||||
|
||||
user = relationship("User", backref="ssh_keys_pool")
|
||||
|
||||
__table_args__ = (
|
||||
Index('idx_user_ssh_keys_user', 'user_id'),
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<UserSSHKey(id={self.id}, user_id={self.user_id}, name='{self.name}')>"
|
||||
Reference in New Issue
Block a user