diff --git a/backend/alembic/versions/009_sac_users.py b/backend/alembic/versions/009_sac_users.py
new file mode 100644
index 0000000..dffcc4a
--- /dev/null
+++ b/backend/alembic/versions/009_sac_users.py
@@ -0,0 +1,35 @@
+"""sac_users table for multi-user UI login
+
+Revision ID: 009
+Revises: 008
+"""
+
+from typing import Sequence, Union
+
+import sqlalchemy as sa
+from alembic import op
+
+revision: str = "009"
+down_revision: Union[str, None] = "008"
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+ op.create_table(
+ "sac_users",
+ sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
+ sa.Column("username", sa.String(length=64), nullable=False),
+ sa.Column("password_hash", sa.String(length=128), nullable=False),
+ sa.Column("role", sa.String(length=16), nullable=False, server_default="monitor"),
+ sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.text("true")),
+ sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
+ sa.PrimaryKeyConstraint("id"),
+ sa.UniqueConstraint("username"),
+ )
+ op.create_index("ix_sac_users_username", "sac_users", ["username"], unique=True)
+
+
+def downgrade() -> None:
+ op.drop_index("ix_sac_users_username", table_name="sac_users")
+ op.drop_table("sac_users")
diff --git a/backend/app/api/v1/auth.py b/backend/app/api/v1/auth.py
index f45dfeb..1364f5f 100644
--- a/backend/app/api/v1/auth.py
+++ b/backend/app/api/v1/auth.py
@@ -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
diff --git a/backend/app/api/v1/router.py b/backend/app/api/v1/router.py
index a796880..ffb90ac 100644
--- a/backend/app/api/v1/router.py
+++ b/backend/app/api/v1/router.py
@@ -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)
diff --git a/backend/app/api/v1/settings.py b/backend/app/api/v1/settings.py
index 16a4b1b..bca5da2 100644
--- a/backend/app/api/v1/settings.py
+++ b/backend/app/api/v1/settings.py
@@ -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:
diff --git a/backend/app/api/v1/users.py b/backend/app/api/v1/users.py
new file mode 100644
index 0000000..beabf1a
--- /dev/null
+++ b/backend/app/api/v1/users.py
@@ -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")
diff --git a/backend/app/auth/jwt_auth.py b/backend/app/auth/jwt_auth.py
index c3675c7..ab9c281 100644
--- a/backend/app/auth/jwt_auth.py
+++ b/backend/app/auth/jwt_auth.py
@@ -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,
+ settings.jwt_secret,
+ algorithms=[settings.jwt_algorithm],
+ )
+ username = payload.get("sub")
+ if not username:
+ raise HTTPException(status_code=401, detail="Invalid token")
+ role = str(payload.get("role") or USER_ROLE_ADMIN)
+ return CurrentUser(username=str(username), role=role)
+ except JWTError as exc:
+ raise HTTPException(status_code=401, detail="Invalid token") from exc
+
+
+def verify_access_token(token: str) -> str:
+ """Проверка JWT (в т.ч. query-параметр для SSE). Возвращает username."""
+ return _decode_current_user(token).username
+
+
+def get_current_user(
+ credentials: HTTPAuthorizationCredentials | None = Depends(bearer_scheme),
+) -> CurrentUser:
+ if credentials is None or credentials.scheme.lower() != "bearer":
+ raise HTTPException(
+ status_code=status.HTTP_401_UNAUTHORIZED,
+ detail="Not authenticated",
+ )
+ return _decode_current_user(credentials.credentials)
+
+
+def require_admin(user: CurrentUser = Depends(get_current_user)) -> CurrentUser:
+ if not user.is_admin:
+ raise HTTPException(
+ status_code=status.HTTP_403_FORBIDDEN,
+ detail="Admin access required",
+ )
+ return user
diff --git a/backend/app/main.py b/backend/app/main.py
index 2b3bd4d..2e5359a 100644
--- a/backend/app/main.py
+++ b/backend/app/main.py
@@ -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)
diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py
index fbbb366..ec12872 100644
--- a/backend/app/models/__init__.py
+++ b/backend/app/models/__init__.py
@@ -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",
]
diff --git a/backend/app/models/user.py b/backend/app/models/user.py
new file mode 100644
index 0000000..9cc49f3
--- /dev/null
+++ b/backend/app/models/user.py
@@ -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())
diff --git a/backend/app/services/user_auth.py b/backend/app/services/user_auth.py
new file mode 100644
index 0000000..23ebca2
--- /dev/null
+++ b/backend/app/services/user_auth.py
@@ -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)
diff --git a/backend/scripts/sac_manage_user.py b/backend/scripts/sac_manage_user.py
new file mode 100644
index 0000000..1f1ad99
--- /dev/null
+++ b/backend/scripts/sac_manage_user.py
@@ -0,0 +1,80 @@
+#!/usr/bin/env python3
+"""Manage SAC UI users (create admin/monitor accounts).
+
+Examples (from backend/ on prod server):
+
+ python scripts/sac_manage_user.py create ivanov 'SecretPass123' --role monitor
+ python scripts/sac_manage_user.py list
+"""
+
+from __future__ import annotations
+
+import argparse
+import sys
+from pathlib import Path
+
+BACKEND_ROOT = Path(__file__).resolve().parents[1]
+if str(BACKEND_ROOT) not in sys.path:
+ sys.path.insert(0, str(BACKEND_ROOT))
+
+from sqlalchemy import select
+
+from app.database import SessionLocal
+from app.models.user import USER_ROLE_ADMIN, USER_ROLE_MONITOR, User
+from app.services.user_auth import create_user
+
+
+def cmd_list() -> int:
+ db = SessionLocal()
+ try:
+ rows = db.scalars(select(User).order_by(User.username)).all()
+ if not rows:
+ print("No users.")
+ return 0
+ for u in rows:
+ status = "active" if u.is_active else "inactive"
+ print(f"{u.id}\t{u.username}\t{u.role}\t{status}\t{u.created_at.isoformat()}")
+ return 0
+ finally:
+ db.close()
+
+
+def cmd_create(username: str, password: str, role: str) -> int:
+ db = SessionLocal()
+ try:
+ user = create_user(db, username=username, password=password, role=role)
+ db.commit()
+ print(f"Created user id={user.id} username={user.username!r} role={user.role}")
+ return 0
+ except ValueError as exc:
+ print(f"Error: {exc}", file=sys.stderr)
+ return 1
+ finally:
+ db.close()
+
+
+def main() -> int:
+ parser = argparse.ArgumentParser(description="SAC UI user management")
+ sub = parser.add_subparsers(dest="command", required=True)
+
+ sub.add_parser("list", help="List UI users")
+
+ create_p = sub.add_parser("create", help="Create a user")
+ create_p.add_argument("username")
+ create_p.add_argument("password")
+ create_p.add_argument(
+ "--role",
+ choices=[USER_ROLE_ADMIN, USER_ROLE_MONITOR],
+ default=USER_ROLE_MONITOR,
+ )
+
+ args = parser.parse_args()
+ if args.command == "list":
+ return cmd_list()
+ if args.command == "create":
+ return cmd_create(args.username, args.password, args.role)
+ return 1
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py
index ee4ec14..50fb876 100644
--- a/backend/tests/conftest.py
+++ b/backend/tests/conftest.py
@@ -18,7 +18,9 @@ from app.auth.jwt_auth import create_access_token
from app.database import Base, get_db
from app.main import app as fastapi_app
import app.models # noqa: F401 — register all tables on Base.metadata
-from app.models import ApiKey
+from app.models import ApiKey, User
+from app.models.user import USER_ROLE_ADMIN, USER_ROLE_MONITOR
+from app.services.user_auth import hash_password
TEST_API_KEY = os.environ["SAC_BOOTSTRAP_API_KEY"]
@@ -57,6 +59,22 @@ def db_session(db_engine):
is_active=True,
)
)
+ session.add(
+ User(
+ username="test-admin",
+ password_hash=hash_password("test-admin-password"),
+ role=USER_ROLE_ADMIN,
+ is_active=True,
+ )
+ )
+ session.add(
+ User(
+ username="test-monitor",
+ password_hash=hash_password("test-monitor-password"),
+ role=USER_ROLE_MONITOR,
+ is_active=True,
+ )
+ )
session.commit()
yield session
session.close()
@@ -65,6 +83,7 @@ def db_session(db_engine):
@pytest.fixture
def client(db_session, monkeypatch):
monkeypatch.setattr("app.main.bootstrap_api_key", lambda: None)
+ monkeypatch.setattr("app.main.bootstrap_users", lambda: None)
def override_get_db():
try:
@@ -85,5 +104,11 @@ def auth_headers():
@pytest.fixture
def jwt_headers():
- token = create_access_token("test-admin")
+ token = create_access_token("test-admin", USER_ROLE_ADMIN)
+ return {"Authorization": f"Bearer {token}"}
+
+
+@pytest.fixture
+def jwt_monitor_headers():
+ token = create_access_token("test-monitor", USER_ROLE_MONITOR)
return {"Authorization": f"Bearer {token}"}
diff --git a/backend/tests/test_users_auth.py b/backend/tests/test_users_auth.py
new file mode 100644
index 0000000..ff31f7d
--- /dev/null
+++ b/backend/tests/test_users_auth.py
@@ -0,0 +1,64 @@
+"""Multi-user UI auth and RBAC."""
+
+from app.models.user import USER_ROLE_MONITOR
+
+
+def test_login_success(client):
+ response = client.post(
+ "/api/v1/auth/login",
+ json={"username": "test-admin", "password": "test-admin-password"},
+ )
+ assert response.status_code == 200
+ body = response.json()
+ assert body["username"] == "test-admin"
+ assert body["role"] == "admin"
+ assert body["access_token"]
+
+
+def test_login_monitor(client):
+ response = client.post(
+ "/api/v1/auth/login",
+ json={"username": "test-monitor", "password": "test-monitor-password"},
+ )
+ assert response.status_code == 200
+ assert response.json()["role"] == USER_ROLE_MONITOR
+
+
+def test_login_invalid_password(client):
+ response = client.post(
+ "/api/v1/auth/login",
+ json={"username": "test-admin", "password": "wrong"},
+ )
+ assert response.status_code == 401
+
+
+def test_auth_me(client, jwt_headers):
+ response = client.get("/api/v1/auth/me", headers=jwt_headers)
+ assert response.status_code == 200
+ assert response.json() == {"username": "test-admin", "role": "admin"}
+
+
+def test_settings_forbidden_for_monitor(client, jwt_monitor_headers):
+ response = client.get("/api/v1/settings/notifications", headers=jwt_monitor_headers)
+ assert response.status_code == 403
+
+
+def test_events_allowed_for_monitor(client, jwt_monitor_headers):
+ response = client.get("/api/v1/events", headers=jwt_monitor_headers)
+ assert response.status_code == 200
+
+
+def test_admin_create_monitor_user(client, jwt_headers):
+ response = client.post(
+ "/api/v1/users",
+ headers=jwt_headers,
+ json={"username": "new-monitor", "password": "longpassword1", "role": "monitor"},
+ )
+ assert response.status_code == 201
+ assert response.json()["username"] == "new-monitor"
+ assert response.json()["role"] == "monitor"
+
+
+def test_monitor_cannot_list_users(client, jwt_monitor_headers):
+ response = client.get("/api/v1/users", headers=jwt_monitor_headers)
+ assert response.status_code == 403
diff --git a/docs/install-ubuntu-24.04-native.md b/docs/install-ubuntu-24.04-native.md
index d6d2167..e779b00 100644
--- a/docs/install-ubuntu-24.04-native.md
+++ b/docs/install-ubuntu-24.04-native.md
@@ -308,6 +308,17 @@ sudo systemctl restart sac-api
В `config/sac-api.env` задайте `SAC_ADMIN_PASSWORD`, затем откройте `https://sac.kalinamall.ru/` (логин `SAC_ADMIN_USERNAME`, по умолчанию `admin`).
+После обновления с multi-user (миграция `009_sac_users`): выполните `alembic upgrade head` в каталоге `backend/`. При первом старте API создаст пользователя **admin** из `SAC_ADMIN_*`, если таблица `sac_users` пуста.
+
+Добавить сотрудника с ролью **monitor** (просмотр без «Настройки»):
+
+```bash
+cd /opt/security-alert-center/backend
+sudo -u sac python scripts/sac_manage_user.py create ivanov 'StrongPass123' --role monitor
+```
+
+Роли: **admin** — полный доступ; **monitor** — события, хосты, проблемы, отчёты (без `/settings` и `/users`).
+
---
## 11. Проверка
diff --git a/frontend/src/App.vue b/frontend/src/App.vue
index b578354..c99a3bb 100644
--- a/frontend/src/App.vue
+++ b/frontend/src/App.vue
@@ -8,11 +8,18 @@
diff --git a/frontend/src/api.ts b/frontend/src/api.ts
index 54c0c91..05df2bc 100644
--- a/frontend/src/api.ts
+++ b/frontend/src/api.ts
@@ -1,15 +1,34 @@
const TOKEN_KEY = "sac_token";
+const ROLE_KEY = "sac_role";
export function getToken(): string | null {
return localStorage.getItem(TOKEN_KEY);
}
+export function getRole(): string | null {
+ return localStorage.getItem(ROLE_KEY);
+}
+
+export function isAdmin(): boolean {
+ return getRole() === "admin";
+}
+
export function setToken(token: string): void {
localStorage.setItem(TOKEN_KEY, token);
}
+export function setRole(role: string): void {
+ localStorage.setItem(ROLE_KEY, role);
+}
+
+export function setAuth(token: string, role: string): void {
+ setToken(token);
+ setRole(role);
+}
+
export function clearToken(): void {
localStorage.removeItem(TOKEN_KEY);
+ localStorage.removeItem(ROLE_KEY);
}
export async function apiFetch(path: string, init: RequestInit = {}): Promise {
@@ -27,6 +46,9 @@ export async function apiFetch(path: string, init: RequestInit = {}): Promise
window.location.href = "/login";
throw new Error("Unauthorized");
}
+ if (res.status === 403) {
+ throw new Error("Forbidden");
+ }
if (!res.ok) {
const text = await res.text();
throw new Error(text || res.statusText);
@@ -37,6 +59,17 @@ export async function apiFetch(path: string, init: RequestInit = {}): Promise
export interface TokenResponse {
access_token: string;
token_type: string;
+ username: string;
+ role: string;
+}
+
+export interface MeResponse {
+ username: string;
+ role: string;
+}
+
+export function fetchMe(): Promise {
+ return apiFetch("/api/v1/auth/me");
}
export interface EventSummary {
diff --git a/frontend/src/components/AppSidebar.vue b/frontend/src/components/AppSidebar.vue
index 94fe1f2..5ba4f5f 100644
--- a/frontend/src/components/AppSidebar.vue
+++ b/frontend/src/components/AppSidebar.vue
@@ -29,8 +29,9 @@
+
+