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:
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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}>"
|
||||
@@ -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())
|
||||
@@ -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
@@ -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}>"
|
||||
|
||||
@@ -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
@@ -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}>"
|
||||
|
||||
@@ -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}>"
|
||||
|
||||
@@ -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}>"
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user