From 56c469ddbd01a5b61b530ff173a37c415cc7699a Mon Sep 17 00:00:00 2001 From: PTah Date: Mon, 1 Jun 2026 09:27:14 +1000 Subject: [PATCH] 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 --- backend/alembic/versions/009_sac_users.py | 35 +++++ backend/app/api/v1/auth.py | 51 ++++-- backend/app/api/v1/router.py | 3 +- backend/app/api/v1/settings.py | 18 +-- backend/app/api/v1/users.py | 129 +++++++++++++++ backend/app/auth/jwt_auth.py | 118 ++++++++------ backend/app/main.py | 10 ++ backend/app/models/__init__.py | 2 + backend/app/models/user.py | 21 +++ backend/app/services/user_auth.py | 96 ++++++++++++ backend/scripts/sac_manage_user.py | 80 ++++++++++ backend/tests/conftest.py | 29 +++- backend/tests/test_users_auth.py | 64 ++++++++ docs/install-ubuntu-24.04-native.md | 11 ++ frontend/src/App.vue | 9 +- frontend/src/api.ts | 33 ++++ frontend/src/components/AppSidebar.vue | 29 ++-- frontend/src/router.ts | 21 ++- frontend/src/views/LoginView.vue | 4 +- frontend/src/views/UsersView.vue | 181 ++++++++++++++++++++++ 20 files changed, 859 insertions(+), 85 deletions(-) create mode 100644 backend/alembic/versions/009_sac_users.py create mode 100644 backend/app/api/v1/users.py create mode 100644 backend/app/models/user.py create mode 100644 backend/app/services/user_auth.py create mode 100644 backend/scripts/sac_manage_user.py create mode 100644 backend/tests/test_users_auth.py create mode 100644 frontend/src/views/UsersView.vue 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 @@ + +