Add JWT authentication with user/password login, role-based access control, and Bearer token support while maintaining legacy API key compatibility for transition; update README with comprehensive JWT security guidelines, installation instructions for Python/SSH, development startup scripts (.env, run_dev.sh/ps1), and migrate all API examples to JWT authentication; update Ansible inventory SSH key paths from Docker container paths to local user paths

This commit is contained in:
2025-12-14 17:33:34 -05:00
parent 5a512d39b5
commit 0030fcc101
177 changed files with 22810 additions and 289 deletions
+9
View File
@@ -5,6 +5,10 @@ from .task import Task
from .schedule import Schedule
from .schedule_run import ScheduleRun
from .log import Log
from .user import User, UserRole
from .host_metrics import HostMetrics
from .alert import Alert
from .app_setting import AppSetting
__all__ = [
"Base",
@@ -14,4 +18,9 @@ __all__ = [
"Schedule",
"ScheduleRun",
"Log",
"Alert",
"User",
"UserRole",
"HostMetrics",
"AppSetting",
]
+36
View File
@@ -0,0 +1,36 @@
from __future__ import annotations
from datetime import datetime
from typing import Optional
from sqlalchemy import DateTime, ForeignKey, Integer, JSON, String, Text, Index
from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy.sql import func
from .database import Base
class Alert(Base):
__tablename__ = "alerts"
__table_args__ = (
Index("idx_alerts_created_at", "created_at"),
Index("idx_alerts_user_id", "user_id"),
Index("idx_alerts_category", "category"),
Index("idx_alerts_read_at", "read_at"),
)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
user_id: Mapped[Optional[int]] = mapped_column(Integer, ForeignKey("users.id", ondelete="SET NULL"))
category: Mapped[str] = mapped_column(String(50), nullable=False)
level: Mapped[Optional[str]] = mapped_column(String(20))
title: Mapped[Optional[str]] = mapped_column(String(255))
message: Mapped[str] = mapped_column(Text, nullable=False)
source: Mapped[Optional[str]] = mapped_column(String(50))
details: Mapped[Optional[dict]] = mapped_column(JSON)
read_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
def __repr__(self) -> str:
return f"<Alert id={self.id} category={self.category} user_id={self.user_id} read={self.read_at is not None}>"
+23
View File
@@ -0,0 +1,23 @@
from __future__ import annotations
from datetime import datetime
from typing import Optional
from sqlalchemy import DateTime, String, Text, Index
from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy.sql import func
from .database import Base
class AppSetting(Base):
__tablename__ = "app_settings"
__table_args__ = (
Index("idx_app_settings_updated_at", "updated_at"),
)
key: Mapped[str] = mapped_column(String(100), primary_key=True)
value: Mapped[Optional[str]] = mapped_column(Text)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now())
+1 -1
View File
@@ -112,7 +112,7 @@ async def get_db() -> AsyncGenerator[AsyncSession, None]:
async def init_db() -> None:
"""Create all tables (mostly for dev/tests; migrations should be handled by Alembic)."""
from . import host, task, schedule, schedule_run, log # noqa: F401
from . import host, task, schedule, schedule_run, log, alert, app_setting # noqa: F401
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
+3 -1
View File
@@ -27,7 +27,9 @@ class Host(Base):
bootstrap_statuses: Mapped[List["BootstrapStatus"]] = relationship(
"BootstrapStatus", back_populates="host", cascade="all, delete-orphan"
)
logs: Mapped[List["Log"]] = relationship("Log", back_populates="host")
metrics: Mapped[List["HostMetrics"]] = relationship(
"HostMetrics", back_populates="host", cascade="all, delete-orphan"
)
def __repr__(self) -> str: # pragma: no cover - debug helper
return f"<Host id={self.id} name={self.name} ip={self.ip_address}>"
+85
View File
@@ -0,0 +1,85 @@
from __future__ import annotations
from datetime import datetime
from typing import Optional
from sqlalchemy import DateTime, Float, ForeignKey, Integer, JSON, String, Text, Index
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.sql import func
from .database import Base
class HostMetrics(Base):
"""Stocke les métriques collectées par les builtin playbooks pour chaque hôte."""
__tablename__ = "host_metrics"
__table_args__ = (
Index("idx_host_metrics_host_id", "host_id"),
Index("idx_host_metrics_collected_at", "collected_at"),
Index("idx_host_metrics_metric_type", "metric_type"),
)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
host_id: Mapped[str] = mapped_column(String, ForeignKey("hosts.id", ondelete="CASCADE"), nullable=False)
metric_type: Mapped[str] = mapped_column(String(50), nullable=False) # 'system_info', 'disk_usage', 'memory', etc.
# Métriques CPU
cpu_count: Mapped[Optional[int]] = mapped_column(Integer)
cpu_model: Mapped[Optional[str]] = mapped_column(String(200))
cpu_cores: Mapped[Optional[int]] = mapped_column(Integer)
cpu_threads: Mapped[Optional[int]] = mapped_column(Integer)
cpu_threads_per_core: Mapped[Optional[int]] = mapped_column(Integer)
cpu_sockets: Mapped[Optional[int]] = mapped_column(Integer)
cpu_mhz: Mapped[Optional[float]] = mapped_column(Float)
cpu_max_mhz: Mapped[Optional[float]] = mapped_column(Float)
cpu_min_mhz: Mapped[Optional[float]] = mapped_column(Float)
cpu_load_1m: Mapped[Optional[float]] = mapped_column(Float)
cpu_load_5m: Mapped[Optional[float]] = mapped_column(Float)
cpu_load_15m: Mapped[Optional[float]] = mapped_column(Float)
cpu_usage_percent: Mapped[Optional[float]] = mapped_column(Float)
cpu_temperature: Mapped[Optional[float]] = mapped_column(Float)
# Métriques mémoire
memory_total_mb: Mapped[Optional[int]] = mapped_column(Integer)
memory_used_mb: Mapped[Optional[int]] = mapped_column(Integer)
memory_free_mb: Mapped[Optional[int]] = mapped_column(Integer)
memory_usage_percent: Mapped[Optional[float]] = mapped_column(Float)
swap_total_mb: Mapped[Optional[int]] = mapped_column(Integer)
swap_used_mb: Mapped[Optional[int]] = mapped_column(Integer)
swap_usage_percent: Mapped[Optional[float]] = mapped_column(Float)
# Métriques disque (stockées en JSON pour flexibilité - plusieurs disques)
disk_info: Mapped[Optional[object]] = mapped_column(JSON) # Liste des points de montage avec usage
disk_devices: Mapped[Optional[object]] = mapped_column(JSON) # Liste des disques + partitions (layout)
disk_root_total_gb: Mapped[Optional[float]] = mapped_column(Float)
disk_root_used_gb: Mapped[Optional[float]] = mapped_column(Float)
disk_root_usage_percent: Mapped[Optional[float]] = mapped_column(Float)
# Storage stacks (JSON)
lvm_info: Mapped[Optional[object]] = mapped_column(JSON)
zfs_info: Mapped[Optional[object]] = mapped_column(JSON)
# Informations système
os_name: Mapped[Optional[str]] = mapped_column(String(100))
os_version: Mapped[Optional[str]] = mapped_column(String(100))
kernel_version: Mapped[Optional[str]] = mapped_column(String(100))
hostname: Mapped[Optional[str]] = mapped_column(String(200))
uptime_seconds: Mapped[Optional[int]] = mapped_column(Integer)
uptime_human: Mapped[Optional[str]] = mapped_column(String(100))
# Réseau (stocké en JSON pour flexibilité)
network_info: Mapped[Optional[dict]] = mapped_column(JSON)
# Données brutes et métadonnées
raw_data: Mapped[Optional[dict]] = mapped_column(JSON) # Données brutes du playbook
collection_source: Mapped[Optional[str]] = mapped_column(String(100)) # Nom du builtin playbook
collection_duration_ms: Mapped[Optional[int]] = mapped_column(Integer)
error_message: Mapped[Optional[str]] = mapped_column(Text)
collected_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
host: Mapped["Host"] = relationship("Host", back_populates="metrics")
def __repr__(self) -> str:
return f"<HostMetrics id={self.id} host_id={self.host_id} type={self.metric_type}>"
+3 -6
View File
@@ -23,14 +23,11 @@ class Log(Base):
source: Mapped[Optional[str]] = mapped_column(String)
message: Mapped[str] = mapped_column(Text, nullable=False)
details: Mapped[Optional[dict]] = mapped_column(JSON)
host_id: Mapped[Optional[str]] = mapped_column(String, ForeignKey("hosts.id", ondelete="SET NULL"))
task_id: Mapped[Optional[str]] = mapped_column(String, ForeignKey("tasks.id", ondelete="SET NULL"))
schedule_id: Mapped[Optional[str]] = mapped_column(String, ForeignKey("schedules.id", ondelete="SET NULL"))
host_id: Mapped[Optional[str]] = mapped_column(String, nullable=True)
task_id: Mapped[Optional[str]] = mapped_column(String, nullable=True)
schedule_id: Mapped[Optional[str]] = mapped_column(String, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
host: Mapped[Optional["Host"]] = relationship("Host", back_populates="logs")
task: Mapped[Optional["Task"]] = relationship("Task", back_populates="logs")
schedule: Mapped[Optional["Schedule"]] = relationship("Schedule", back_populates="logs")
def __repr__(self) -> str: # pragma: no cover - debug helper
return f"<Log id={self.id} level={self.level} source={self.source}>"
-1
View File
@@ -48,7 +48,6 @@ class Schedule(Base):
runs: Mapped[List["ScheduleRun"]] = relationship(
"ScheduleRun", back_populates="schedule", cascade="all, delete-orphan"
)
logs: Mapped[List["Log"]] = relationship("Log", back_populates="schedule")
def __repr__(self) -> str: # pragma: no cover - debug helper
return f"<Schedule id={self.id} name={self.name} target={self.target}>"
-1
View File
@@ -25,7 +25,6 @@ class Task(Base):
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
schedule_runs: Mapped[list["ScheduleRun"]] = relationship("ScheduleRun", back_populates="task")
logs: Mapped[list["Log"]] = relationship("Log", back_populates="task")
def __repr__(self) -> str: # pragma: no cover - debug helper
return f"<Task id={self.id} action={self.action} target={self.target} status={self.status}>"
+83
View File
@@ -0,0 +1,83 @@
"""User model for authentication and authorization.
Designed for single-user now but prepared for multi-user with roles in the future.
"""
from __future__ import annotations
from datetime import datetime
from enum import Enum
from typing import Optional
from sqlalchemy import Boolean, DateTime, String, Text, text
from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy.sql import func
from .database import Base
class UserRole(str, Enum):
"""User roles for authorization.
Current implementation: single admin user.
Future: can be extended with more granular roles.
"""
ADMIN = "admin"
OPERATOR = "operator" # Future: can execute tasks but not manage users
VIEWER = "viewer" # Future: read-only access
class User(Base):
"""User model for authentication.
Fields prepared for future multi-user support:
- role: determines access level
- is_active: allows disabling users without deletion
- last_login: track user activity
- password_changed_at: for password rotation policies
"""
__tablename__ = "users"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
username: Mapped[str] = mapped_column(String(50), unique=True, nullable=False, index=True)
email: Mapped[Optional[str]] = mapped_column(String(255), unique=True, nullable=True)
hashed_password: Mapped[str] = mapped_column(String(255), nullable=False)
# Role-based access control (prepared for future)
role: Mapped[str] = mapped_column(
String(20),
nullable=False,
server_default=text("'admin'") # Default to admin for single-user setup
)
# Account status
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default=text("1"))
is_superuser: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default=text("0"))
# Display name (optional)
display_name: Mapped[Optional[str]] = mapped_column(String(100), nullable=True)
# Timestamps
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
nullable=False,
server_default=func.now()
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
nullable=False,
server_default=func.now(),
onupdate=func.now()
)
last_login: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
password_changed_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
# Soft delete support
deleted_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
def __repr__(self) -> str:
return f"<User id={self.id} username={self.username} role={self.role}>"
@property
def is_admin(self) -> bool:
"""Check if user has admin privileges."""
return self.role == UserRole.ADMIN.value or self.is_superuser