""" Session database model for server-side session tracking. OWASP/NIST Compliant Session Management (Issue #264): - Server-side session state is authoritative - Tracks idle timeout (30 min) and absolute timeout (8 hours) - Session token hash stored (never plaintext) """ from datetime import datetime from typing import Optional, Dict, Any from sqlalchemy import Column, Integer, String, DateTime, Boolean, Text, ForeignKey from sqlalchemy.dialects.postgresql import UUID from sqlalchemy.orm import relationship from sqlalchemy.sql import func import uuid from app.core.database import Base class Session(Base): """Server-side session model for OWASP/NIST compliant session management""" __tablename__ = "sessions" id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True) session_token_hash = Column(String(64), unique=True, nullable=False, index=True) # SHA-256 hash # Session timing (NIST SP 800-63B compliant) created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False) last_activity_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False) absolute_expires_at = Column(DateTime(timezone=True), nullable=False) # Session metadata for security auditing ip_address = Column(String(45), nullable=True) # IPv6 compatible user_agent = Column(Text, nullable=True) tenant_id = Column(Integer, ForeignKey("tenants.id"), nullable=True, index=True) # Session state is_active = Column(Boolean, default=True, nullable=False) revoked_at = Column(DateTime(timezone=True), nullable=True) revoke_reason = Column(String(50), nullable=True) # 'logout', 'idle_timeout', 'absolute_timeout', 'admin_revoke', 'password_change', 'cleanup_stale' ended_at = Column(DateTime(timezone=True), nullable=True) # When session ended (any reason: logout, timeout, etc.) app_type = Column(String(20), default='control_panel', nullable=False) # 'control_panel' or 'tenant_app' # Relationships user = relationship("User", back_populates="sessions") tenant = relationship("Tenant", backref="sessions") def __repr__(self): return f"" def to_dict(self) -> Dict[str, Any]: """Convert session to dictionary (excluding sensitive data)""" return { "id": str(self.id), "user_id": self.user_id, "tenant_id": self.tenant_id, "created_at": self.created_at.isoformat() if self.created_at else None, "last_activity_at": self.last_activity_at.isoformat() if self.last_activity_at else None, "absolute_expires_at": self.absolute_expires_at.isoformat() if self.absolute_expires_at else None, "ip_address": self.ip_address, "is_active": self.is_active, "revoked_at": self.revoked_at.isoformat() if self.revoked_at else None, "revoke_reason": self.revoke_reason, "ended_at": self.ended_at.isoformat() if self.ended_at else None, "app_type": self.app_type, } @property def is_expired(self) -> bool: """Check if session is expired (either idle or absolute)""" if not self.is_active: return True now = datetime.now(self.absolute_expires_at.tzinfo) if self.absolute_expires_at.tzinfo else datetime.utcnow() # Check absolute timeout if now >= self.absolute_expires_at: return True # Check idle timeout (30 minutes) from datetime import timedelta idle_timeout = timedelta(minutes=30) idle_expires_at = self.last_activity_at + idle_timeout if now >= idle_expires_at: return True return False