feat: multi-user UI login with admin and monitor roles

Add sac_users table, JWT roles, settings/users API guarded for admin, Users page in UI, and sac_manage_user.py CLI.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
PTah
2026-06-01 09:27:14 +10:00
parent bc77f82307
commit 56c469ddbd
20 changed files with 859 additions and 85 deletions
+42 -9
View File
@@ -1,8 +1,11 @@
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field
from sqlalchemy.orm import Session
from app.auth.jwt_auth import create_access_token
from app.auth.jwt_auth import CurrentUser, create_access_token, get_current_user
from app.config import get_settings
from app.database import get_db
from app.services.user_auth import authenticate_user
router = APIRouter(prefix="/auth", tags=["auth"])
@@ -15,17 +18,47 @@ class LoginRequest(BaseModel):
class TokenResponse(BaseModel):
access_token: str
token_type: str = "bearer"
username: str
role: str
class MeResponse(BaseModel):
username: str
role: str
@router.post("/login", response_model=TokenResponse)
def login(body: LoginRequest) -> TokenResponse:
def login(body: LoginRequest, db: Session = Depends(get_db)) -> TokenResponse:
settings = get_settings()
if not settings.sac_admin_password:
if not settings.sac_admin_password and not _has_any_user(db):
raise HTTPException(
status_code=503,
detail="SAC_ADMIN_PASSWORD is not configured",
detail="SAC UI users are not configured (run alembic upgrade and set SAC_ADMIN_PASSWORD for bootstrap)",
)
if body.username != settings.sac_admin_username or body.password != settings.sac_admin_password:
user = authenticate_user(db, body.username, body.password)
if user is None:
raise HTTPException(status_code=401, detail="Invalid username or password")
token = create_access_token(body.username)
return TokenResponse(access_token=token)
token = create_access_token(user.username, user.role)
return TokenResponse(
access_token=token,
username=user.username,
role=user.role,
)
@router.get("/me", response_model=MeResponse)
def me(current_user: CurrentUser = Depends(get_current_user)) -> MeResponse:
return MeResponse(username=current_user.username, role=current_user.role)
def _has_any_user(db: Session) -> bool:
from sqlalchemy import func, select
from app.models.user import User
try:
count = db.scalar(select(func.count()).select_from(User)) or 0
return count > 0
except Exception:
db.rollback()
return False
+2 -1
View File
@@ -1,6 +1,6 @@
from fastapi import APIRouter
from app.api.v1 import auth, dashboards, events, health, hosts, problems, settings, stream
from app.api.v1 import auth, dashboards, events, health, hosts, problems, settings, stream, users
api_router = APIRouter()
api_router.include_router(health.router)
@@ -10,4 +10,5 @@ api_router.include_router(hosts.router)
api_router.include_router(problems.router)
api_router.include_router(dashboards.router)
api_router.include_router(settings.router)
api_router.include_router(users.router)
api_router.include_router(stream.router)
+9 -9
View File
@@ -2,7 +2,7 @@ from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field
from sqlalchemy.orm import Session
from app.auth.jwt_auth import get_current_user
from app.auth.jwt_auth import require_admin
from app.database import get_db
from app.services.email_notify import EmailNotConfiguredError, EmailSendError, send_email_test_message
from app.services.notification_policy import (
@@ -185,7 +185,7 @@ def _email_to_response(cfg: EmailConfig, policy: NotificationPolicyConfig) -> Em
@router.get("/notifications", response_model=NotificationSettingsResponse)
def get_notification_settings(
db: Session = Depends(get_db),
_user: str = Depends(get_current_user),
_user=Depends(require_admin),
) -> NotificationSettingsResponse:
policy = get_effective_notification_policy(db)
return NotificationSettingsResponse(
@@ -200,7 +200,7 @@ def get_notification_settings(
def update_notification_policy(
body: NotificationPolicyUpdate,
db: Session = Depends(get_db),
_user: str = Depends(get_current_user),
_user=Depends(require_admin),
) -> NotificationPolicyResponse:
if body.min_severity not in VALID_SEVERITIES:
raise HTTPException(status_code=422, detail=f"min_severity must be one of: {sorted(VALID_SEVERITIES)}")
@@ -223,7 +223,7 @@ def update_notification_policy(
def update_telegram_settings(
body: TelegramSettingsUpdate,
db: Session = Depends(get_db),
_user: str = Depends(get_current_user),
_user=Depends(require_admin),
) -> TelegramSettingsResponse:
try:
cfg = upsert_telegram_channel(
@@ -242,7 +242,7 @@ def update_telegram_settings(
def update_webhook_settings(
body: WebhookSettingsUpdate,
db: Session = Depends(get_db),
_user: str = Depends(get_current_user),
_user=Depends(require_admin),
) -> WebhookSettingsResponse:
try:
cfg = upsert_webhook_channel(
@@ -262,7 +262,7 @@ def update_webhook_settings(
def update_email_settings(
body: EmailSettingsUpdate,
db: Session = Depends(get_db),
_user: str = Depends(get_current_user),
_user=Depends(require_admin),
) -> EmailSettingsResponse:
try:
cfg = upsert_email_channel(
@@ -286,7 +286,7 @@ def update_email_settings(
@router.post("/notifications/telegram/test", response_model=ChannelTestResponse)
def test_telegram_settings(
db: Session = Depends(get_db),
_user: str = Depends(get_current_user),
_user=Depends(require_admin),
) -> ChannelTestResponse:
cfg = get_effective_telegram_config(db)
try:
@@ -302,7 +302,7 @@ def test_telegram_settings(
@router.post("/notifications/webhook/test", response_model=ChannelTestResponse)
def test_webhook_settings(
db: Session = Depends(get_db),
_user: str = Depends(get_current_user),
_user=Depends(require_admin),
) -> ChannelTestResponse:
cfg = get_effective_webhook_config(db)
try:
@@ -318,7 +318,7 @@ def test_webhook_settings(
@router.post("/notifications/email/test", response_model=ChannelTestResponse)
def test_email_settings(
db: Session = Depends(get_db),
_user: str = Depends(get_current_user),
_user=Depends(require_admin),
) -> ChannelTestResponse:
cfg = get_effective_email_config(db)
try:
+129
View File
@@ -0,0 +1,129 @@
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field
from sqlalchemy import func, select
from sqlalchemy.orm import Session
from app.auth.jwt_auth import CurrentUser, require_admin
from app.database import get_db
from app.models.user import USER_ROLE_ADMIN, USER_ROLE_MONITOR, USER_ROLES, User
from app.services.user_auth import create_user, hash_password
router = APIRouter(prefix="/users", tags=["users"])
class UserSummary(BaseModel):
id: int
username: str
role: str
is_active: bool
created_at: datetime
class UserListResponse(BaseModel):
items: list[UserSummary]
class CreateUserRequest(BaseModel):
username: str = Field(min_length=2, max_length=64)
password: str = Field(min_length=8, max_length=128)
role: str = Field(default=USER_ROLE_MONITOR)
class UpdateUserRequest(BaseModel):
role: str | None = None
password: str | None = Field(default=None, min_length=8, max_length=128)
is_active: bool | None = None
@router.get("", response_model=UserListResponse)
def list_users(
_admin: CurrentUser = Depends(require_admin),
db: Session = Depends(get_db),
) -> UserListResponse:
rows = db.scalars(select(User).order_by(User.username)).all()
return UserListResponse(
items=[
UserSummary(
id=u.id,
username=u.username,
role=u.role,
is_active=u.is_active,
created_at=u.created_at,
)
for u in rows
]
)
@router.post("", response_model=UserSummary, status_code=201)
def create_user_endpoint(
body: CreateUserRequest,
admin: CurrentUser = Depends(require_admin),
db: Session = Depends(get_db),
) -> UserSummary:
if body.role not in USER_ROLES:
raise HTTPException(status_code=422, detail=f"role must be one of: {', '.join(sorted(USER_ROLES))}")
try:
user = create_user(db, username=body.username, password=body.password, role=body.role)
db.commit()
db.refresh(user)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
return UserSummary(
id=user.id,
username=user.username,
role=user.role,
is_active=user.is_active,
created_at=user.created_at,
)
@router.patch("/{user_id}", response_model=UserSummary)
def update_user(
user_id: int,
body: UpdateUserRequest,
admin: CurrentUser = Depends(require_admin),
db: Session = Depends(get_db),
) -> UserSummary:
user = db.get(User, user_id)
if user is None:
raise HTTPException(status_code=404, detail="User not found")
if body.role is not None:
if body.role not in USER_ROLES:
raise HTTPException(status_code=422, detail=f"role must be one of: {', '.join(sorted(USER_ROLES))}")
if user.role == USER_ROLE_ADMIN and body.role != USER_ROLE_ADMIN:
_ensure_not_last_admin(db, user.id)
user.role = body.role
if body.password is not None:
user.password_hash = hash_password(body.password)
if body.is_active is not None:
if not body.is_active and user.username.lower() == admin.username.lower():
raise HTTPException(status_code=400, detail="Cannot deactivate your own account")
if not body.is_active and user.role == USER_ROLE_ADMIN:
_ensure_not_last_admin(db, user.id)
user.is_active = body.is_active
db.commit()
db.refresh(user)
return UserSummary(
id=user.id,
username=user.username,
role=user.role,
is_active=user.is_active,
created_at=user.created_at,
)
def _ensure_not_last_admin(db: Session, user_id: int) -> None:
active_admins = db.scalar(
select(func.count())
.select_from(User)
.where(User.role == USER_ROLE_ADMIN, User.is_active.is_(True), User.id != user_id)
)
if not active_admins:
raise HTTPException(status_code=400, detail="Cannot remove or deactivate the last admin user")
+70 -48
View File
@@ -1,48 +1,70 @@
from datetime import datetime, timedelta, timezone
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from jose import JWTError, jwt
from app.config import get_settings
bearer_scheme = HTTPBearer(auto_error=False)
def create_access_token(subject: str) -> str:
settings = get_settings()
expire = datetime.now(timezone.utc) + timedelta(minutes=settings.jwt_expire_minutes)
payload = {"sub": subject, "exp": expire}
return jwt.encode(payload, settings.jwt_secret, algorithm=settings.jwt_algorithm)
def _decode_username(token: str) -> str:
settings = get_settings()
try:
payload = jwt.decode(
token,
settings.jwt_secret,
algorithms=[settings.jwt_algorithm],
)
username = payload.get("sub")
if not username:
raise HTTPException(status_code=401, detail="Invalid token")
return str(username)
except JWTError as exc:
raise HTTPException(status_code=401, detail="Invalid token") from exc
def verify_access_token(token: str) -> str:
"""Проверка JWT (в т.ч. query-параметр для SSE)."""
return _decode_username(token)
def get_current_user(
credentials: HTTPAuthorizationCredentials | None = Depends(bearer_scheme),
) -> str:
if credentials is None or credentials.scheme.lower() != "bearer":
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Not authenticated",
)
return _decode_username(credentials.credentials)
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from jose import JWTError, jwt
from app.config import get_settings
from app.models.user import USER_ROLE_ADMIN
bearer_scheme = HTTPBearer(auto_error=False)
@dataclass(frozen=True)
class CurrentUser:
username: str
role: str
@property
def is_admin(self) -> bool:
return self.role == USER_ROLE_ADMIN
def create_access_token(subject: str, role: str) -> str:
settings = get_settings()
expire = datetime.now(timezone.utc) + timedelta(minutes=settings.jwt_expire_minutes)
payload = {"sub": subject, "role": role, "exp": expire}
return jwt.encode(payload, settings.jwt_secret, algorithm=settings.jwt_algorithm)
def _decode_current_user(token: str) -> CurrentUser:
settings = get_settings()
try:
payload = jwt.decode(
token,
+10
View File
@@ -13,6 +13,7 @@ from app.auth.api_key import hash_api_key
from app.config import get_settings
from app.database import SessionLocal
from app.models import ApiKey
from app.services.user_auth import bootstrap_admin_user
from app.version import APP_VERSION, APP_VERSION_LABEL
ROOT = Path(__file__).resolve().parents[2]
@@ -43,10 +44,19 @@ def bootstrap_api_key() -> None:
db.close()
def bootstrap_users() -> None:
db = SessionLocal()
try:
bootstrap_admin_user(db)
finally:
db.close()
@asynccontextmanager
async def lifespan(_app: FastAPI):
logger.info("%s — application startup (version %s)", APP_VERSION_LABEL, APP_VERSION)
bootstrap_api_key()
bootstrap_users()
yield
logger.info("%s — application shutdown", APP_VERSION_LABEL)
+2
View File
@@ -5,6 +5,7 @@ from app.models.notification_channel import NotificationChannel
from app.models.notification_cooldown import NotificationCooldown
from app.models.notification_policy import NotificationPolicy
from app.models.problem import Problem, ProblemEvent
from app.models.user import User
__all__ = [
"ApiKey",
@@ -15,4 +16,5 @@ __all__ = [
"NotificationPolicy",
"Problem",
"ProblemEvent",
"User",
]
+21
View File
@@ -0,0 +1,21 @@
from datetime import datetime
from sqlalchemy import Boolean, DateTime, String, func
from sqlalchemy.orm import Mapped, mapped_column
from app.database import Base
USER_ROLE_ADMIN = "admin"
USER_ROLE_MONITOR = "monitor"
USER_ROLES = frozenset({USER_ROLE_ADMIN, USER_ROLE_MONITOR})
class User(Base):
__tablename__ = "sac_users"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
username: Mapped[str] = mapped_column(String(64), unique=True, index=True)
password_hash: Mapped[str] = mapped_column(String(128))
role: Mapped[str] = mapped_column(String(16), default=USER_ROLE_MONITOR)
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
+96
View File
@@ -0,0 +1,96 @@
"""SAC UI users: password hashing, authentication, bootstrap."""
from __future__ import annotations
import logging
from passlib.context import CryptContext
from sqlalchemy import func, select
from sqlalchemy.orm import Session
from app.config import get_settings
from app.models.user import USER_ROLE_ADMIN, USER_ROLE_MONITOR, USER_ROLES, User
logger = logging.getLogger(__name__)
_pwd_context = CryptContext(schemes=["pbkdf2_sha256"], deprecated="auto")
def hash_password(plain: str) -> str:
return _pwd_context.hash(plain)
def verify_password(plain: str, password_hash: str) -> bool:
try:
return _pwd_context.verify(plain, password_hash)
except ValueError:
return False
def get_user_by_username(db: Session, username: str) -> User | None:
normalized = username.strip()
if not normalized:
return None
return db.scalar(
select(User).where(
func.lower(User.username) == normalized.lower(),
User.is_active.is_(True),
)
)
def authenticate_user(db: Session, username: str, password: str) -> User | None:
user = get_user_by_username(db, username)
if user is None:
return None
if not verify_password(password, user.password_hash):
return None
return user
def create_user(
db: Session,
*,
username: str,
password: str,
role: str = USER_ROLE_MONITOR,
) -> User:
normalized = username.strip()
if not normalized:
raise ValueError("username is required")
if role not in USER_ROLES:
raise ValueError(f"invalid role: {role}")
if len(password) < 8:
raise ValueError("password must be at least 8 characters")
existing = db.scalar(select(User).where(func.lower(User.username) == normalized.lower()))
if existing is not None:
raise ValueError("username already exists")
user = User(
username=normalized,
password_hash=hash_password(password),
role=role,
is_active=True,
)
db.add(user)
db.flush()
return user
def bootstrap_admin_user(db: Session) -> None:
"""Create first admin from SAC_ADMIN_* when sac_users table is empty."""
try:
count = db.scalar(select(func.count()).select_from(User)) or 0
except Exception as exc:
logger.warning("SAC users bootstrap skipped (run alembic upgrade head): %s", exc)
db.rollback()
return
if count > 0:
return
settings = get_settings()
if not settings.sac_admin_password:
logger.warning("SAC users table empty and SAC_ADMIN_PASSWORD not set — UI login disabled until a user is created")
return
username = (settings.sac_admin_username or "admin").strip() or "admin"
create_user(db, username=username, password=settings.sac_admin_password, role=USER_ROLE_ADMIN)
db.commit()
logger.info("SAC bootstrap: created admin user %r from SAC_ADMIN_* env", username)