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
+35
View File
@@ -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")
+42 -9
View File
@@ -1,8 +1,11 @@
from fastapi import APIRouter, HTTPException from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel 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.config import get_settings
from app.database import get_db
from app.services.user_auth import authenticate_user
router = APIRouter(prefix="/auth", tags=["auth"]) router = APIRouter(prefix="/auth", tags=["auth"])
@@ -15,17 +18,47 @@ class LoginRequest(BaseModel):
class TokenResponse(BaseModel): class TokenResponse(BaseModel):
access_token: str access_token: str
token_type: str = "bearer" token_type: str = "bearer"
username: str
role: str
class MeResponse(BaseModel):
username: str
role: str
@router.post("/login", response_model=TokenResponse) @router.post("/login", response_model=TokenResponse)
def login(body: LoginRequest) -> TokenResponse: def login(body: LoginRequest, db: Session = Depends(get_db)) -> TokenResponse:
settings = get_settings() settings = get_settings()
if not settings.sac_admin_password: if not settings.sac_admin_password and not _has_any_user(db):
raise HTTPException( raise HTTPException(
status_code=503, 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") raise HTTPException(status_code=401, detail="Invalid username or password")
token = create_access_token(body.username) token = create_access_token(user.username, user.role)
return TokenResponse(access_token=token) 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 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 = APIRouter()
api_router.include_router(health.router) 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(problems.router)
api_router.include_router(dashboards.router) api_router.include_router(dashboards.router)
api_router.include_router(settings.router) api_router.include_router(settings.router)
api_router.include_router(users.router)
api_router.include_router(stream.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 pydantic import BaseModel, Field
from sqlalchemy.orm import Session 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.database import get_db
from app.services.email_notify import EmailNotConfiguredError, EmailSendError, send_email_test_message from app.services.email_notify import EmailNotConfiguredError, EmailSendError, send_email_test_message
from app.services.notification_policy import ( 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) @router.get("/notifications", response_model=NotificationSettingsResponse)
def get_notification_settings( def get_notification_settings(
db: Session = Depends(get_db), db: Session = Depends(get_db),
_user: str = Depends(get_current_user), _user=Depends(require_admin),
) -> NotificationSettingsResponse: ) -> NotificationSettingsResponse:
policy = get_effective_notification_policy(db) policy = get_effective_notification_policy(db)
return NotificationSettingsResponse( return NotificationSettingsResponse(
@@ -200,7 +200,7 @@ def get_notification_settings(
def update_notification_policy( def update_notification_policy(
body: NotificationPolicyUpdate, body: NotificationPolicyUpdate,
db: Session = Depends(get_db), db: Session = Depends(get_db),
_user: str = Depends(get_current_user), _user=Depends(require_admin),
) -> NotificationPolicyResponse: ) -> NotificationPolicyResponse:
if body.min_severity not in VALID_SEVERITIES: if body.min_severity not in VALID_SEVERITIES:
raise HTTPException(status_code=422, detail=f"min_severity must be one of: {sorted(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( def update_telegram_settings(
body: TelegramSettingsUpdate, body: TelegramSettingsUpdate,
db: Session = Depends(get_db), db: Session = Depends(get_db),
_user: str = Depends(get_current_user), _user=Depends(require_admin),
) -> TelegramSettingsResponse: ) -> TelegramSettingsResponse:
try: try:
cfg = upsert_telegram_channel( cfg = upsert_telegram_channel(
@@ -242,7 +242,7 @@ def update_telegram_settings(
def update_webhook_settings( def update_webhook_settings(
body: WebhookSettingsUpdate, body: WebhookSettingsUpdate,
db: Session = Depends(get_db), db: Session = Depends(get_db),
_user: str = Depends(get_current_user), _user=Depends(require_admin),
) -> WebhookSettingsResponse: ) -> WebhookSettingsResponse:
try: try:
cfg = upsert_webhook_channel( cfg = upsert_webhook_channel(
@@ -262,7 +262,7 @@ def update_webhook_settings(
def update_email_settings( def update_email_settings(
body: EmailSettingsUpdate, body: EmailSettingsUpdate,
db: Session = Depends(get_db), db: Session = Depends(get_db),
_user: str = Depends(get_current_user), _user=Depends(require_admin),
) -> EmailSettingsResponse: ) -> EmailSettingsResponse:
try: try:
cfg = upsert_email_channel( cfg = upsert_email_channel(
@@ -286,7 +286,7 @@ def update_email_settings(
@router.post("/notifications/telegram/test", response_model=ChannelTestResponse) @router.post("/notifications/telegram/test", response_model=ChannelTestResponse)
def test_telegram_settings( def test_telegram_settings(
db: Session = Depends(get_db), db: Session = Depends(get_db),
_user: str = Depends(get_current_user), _user=Depends(require_admin),
) -> ChannelTestResponse: ) -> ChannelTestResponse:
cfg = get_effective_telegram_config(db) cfg = get_effective_telegram_config(db)
try: try:
@@ -302,7 +302,7 @@ def test_telegram_settings(
@router.post("/notifications/webhook/test", response_model=ChannelTestResponse) @router.post("/notifications/webhook/test", response_model=ChannelTestResponse)
def test_webhook_settings( def test_webhook_settings(
db: Session = Depends(get_db), db: Session = Depends(get_db),
_user: str = Depends(get_current_user), _user=Depends(require_admin),
) -> ChannelTestResponse: ) -> ChannelTestResponse:
cfg = get_effective_webhook_config(db) cfg = get_effective_webhook_config(db)
try: try:
@@ -318,7 +318,7 @@ def test_webhook_settings(
@router.post("/notifications/email/test", response_model=ChannelTestResponse) @router.post("/notifications/email/test", response_model=ChannelTestResponse)
def test_email_settings( def test_email_settings(
db: Session = Depends(get_db), db: Session = Depends(get_db),
_user: str = Depends(get_current_user), _user=Depends(require_admin),
) -> ChannelTestResponse: ) -> ChannelTestResponse:
cfg = get_effective_email_config(db) cfg = get_effective_email_config(db)
try: 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")
+30 -8
View File
@@ -1,3 +1,4 @@
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
@@ -5,18 +6,29 @@ from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from fastapi import Depends, HTTPException, status from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from jose import JWTError, jwt from jose import JWTError, jwt
def create_access_token(subject: str) -> str:
from app.config import get_settings
from app.models.user import USER_ROLE_ADMIN
bearer_scheme = HTTPBearer(auto_error=False)
payload = {"sub": subject, "exp": expire} @dataclass(frozen=True)
class CurrentUser: class CurrentUser:
def _decode_username(token: str) -> str: username: str
role: str role: str
@@ -27,22 +39,32 @@ def _decode_username(token: str) -> str:
def is_admin(self) -> bool: def is_admin(self) -> bool:
return self.role == USER_ROLE_ADMIN return self.role == USER_ROLE_ADMIN
return str(username)
def create_access_token(subject: str, role: str) -> str: def create_access_token(subject: str, role: str) -> str:
"""Проверка JWT (в т.ч. query-параметр для SSE).""" settings = get_settings()
return _decode_username(token)
expire = datetime.now(timezone.utc) + timedelta(minutes=settings.jwt_expire_minutes) expire = datetime.now(timezone.utc) + timedelta(minutes=settings.jwt_expire_minutes)
payload = {"sub": subject, "role": role, "exp": expire} payload = {"sub": subject, "role": role, "exp": expire}
) -> str: return jwt.encode(payload, settings.jwt_secret, algorithm=settings.jwt_algorithm)
return _decode_username(credentials.credentials) 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.config import get_settings
from app.database import SessionLocal from app.database import SessionLocal
from app.models import ApiKey from app.models import ApiKey
from app.services.user_auth import bootstrap_admin_user
from app.version import APP_VERSION, APP_VERSION_LABEL from app.version import APP_VERSION, APP_VERSION_LABEL
ROOT = Path(__file__).resolve().parents[2] ROOT = Path(__file__).resolve().parents[2]
@@ -43,10 +44,19 @@ def bootstrap_api_key() -> None:
db.close() db.close()
def bootstrap_users() -> None:
db = SessionLocal()
try:
bootstrap_admin_user(db)
finally:
db.close()
@asynccontextmanager @asynccontextmanager
async def lifespan(_app: FastAPI): async def lifespan(_app: FastAPI):
logger.info("%s — application startup (version %s)", APP_VERSION_LABEL, APP_VERSION) logger.info("%s — application startup (version %s)", APP_VERSION_LABEL, APP_VERSION)
bootstrap_api_key() bootstrap_api_key()
bootstrap_users()
yield yield
logger.info("%s — application shutdown", APP_VERSION_LABEL) 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_cooldown import NotificationCooldown
from app.models.notification_policy import NotificationPolicy from app.models.notification_policy import NotificationPolicy
from app.models.problem import Problem, ProblemEvent from app.models.problem import Problem, ProblemEvent
from app.models.user import User
__all__ = [ __all__ = [
"ApiKey", "ApiKey",
@@ -15,4 +16,5 @@ __all__ = [
"NotificationPolicy", "NotificationPolicy",
"Problem", "Problem",
"ProblemEvent", "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)
+80
View File
@@ -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())
+27 -2
View File
@@ -18,7 +18,9 @@ from app.auth.jwt_auth import create_access_token
from app.database import Base, get_db from app.database import Base, get_db
from app.main import app as fastapi_app from app.main import app as fastapi_app
import app.models # noqa: F401 — register all tables on Base.metadata 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"] TEST_API_KEY = os.environ["SAC_BOOTSTRAP_API_KEY"]
@@ -57,6 +59,22 @@ def db_session(db_engine):
is_active=True, 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() session.commit()
yield session yield session
session.close() session.close()
@@ -65,6 +83,7 @@ def db_session(db_engine):
@pytest.fixture @pytest.fixture
def client(db_session, monkeypatch): def client(db_session, monkeypatch):
monkeypatch.setattr("app.main.bootstrap_api_key", lambda: None) monkeypatch.setattr("app.main.bootstrap_api_key", lambda: None)
monkeypatch.setattr("app.main.bootstrap_users", lambda: None)
def override_get_db(): def override_get_db():
try: try:
@@ -85,5 +104,11 @@ def auth_headers():
@pytest.fixture @pytest.fixture
def jwt_headers(): 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}"} return {"Authorization": f"Bearer {token}"}
+64
View File
@@ -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
+11
View File
@@ -308,6 +308,17 @@ sudo systemctl restart sac-api
В `config/sac-api.env` задайте `SAC_ADMIN_PASSWORD`, затем откройте `https://sac.kalinamall.ru/` (логин `SAC_ADMIN_USERNAME`, по умолчанию `admin`). В `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. Проверка ## 11. Проверка
+8 -1
View File
@@ -8,11 +8,18 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed } from "vue"; import { computed, onMounted } from "vue";
import { useRoute } from "vue-router"; import { useRoute } from "vue-router";
import { getToken } from "./api"; import { getToken } from "./api";
import AppSidebar from "./components/AppSidebar.vue"; import AppSidebar from "./components/AppSidebar.vue";
import { refreshSessionRole } from "./router";
const route = useRoute(); const route = useRoute();
const showShell = computed(() => route.path !== "/login" && !!getToken()); const showShell = computed(() => route.path !== "/login" && !!getToken());
onMounted(() => {
if (getToken()) {
void refreshSessionRole();
}
});
</script> </script>
+33
View File
@@ -1,15 +1,34 @@
const TOKEN_KEY = "sac_token"; const TOKEN_KEY = "sac_token";
const ROLE_KEY = "sac_role";
export function getToken(): string | null { export function getToken(): string | null {
return localStorage.getItem(TOKEN_KEY); 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 { export function setToken(token: string): void {
localStorage.setItem(TOKEN_KEY, token); 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 { export function clearToken(): void {
localStorage.removeItem(TOKEN_KEY); localStorage.removeItem(TOKEN_KEY);
localStorage.removeItem(ROLE_KEY);
} }
export async function apiFetch<T>(path: string, init: RequestInit = {}): Promise<T> { export async function apiFetch<T>(path: string, init: RequestInit = {}): Promise<T> {
@@ -27,6 +46,9 @@ export async function apiFetch<T>(path: string, init: RequestInit = {}): Promise
window.location.href = "/login"; window.location.href = "/login";
throw new Error("Unauthorized"); throw new Error("Unauthorized");
} }
if (res.status === 403) {
throw new Error("Forbidden");
}
if (!res.ok) { if (!res.ok) {
const text = await res.text(); const text = await res.text();
throw new Error(text || res.statusText); throw new Error(text || res.statusText);
@@ -37,6 +59,17 @@ export async function apiFetch<T>(path: string, init: RequestInit = {}): Promise
export interface TokenResponse { export interface TokenResponse {
access_token: string; access_token: string;
token_type: string; token_type: string;
username: string;
role: string;
}
export interface MeResponse {
username: string;
role: string;
}
export function fetchMe(): Promise<MeResponse> {
return apiFetch<MeResponse>("/api/v1/auth/me");
} }
export interface EventSummary { export interface EventSummary {
+19 -10
View File
@@ -29,8 +29,9 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed } from "vue";
import { useRoute, useRouter } from "vue-router"; import { useRoute, useRouter } from "vue-router";
import { clearToken } from "../api"; import { clearToken, isAdmin } from "../api";
import { APP_NAME, APP_VERSION } from "../version"; import { APP_NAME, APP_VERSION } from "../version";
const appName = APP_NAME; const appName = APP_NAME;
@@ -38,16 +39,24 @@ const appVersion = APP_VERSION;
const route = useRoute(); const route = useRoute();
const router = useRouter(); const router = useRouter();
const navItems = [ const navItems = computed(() => {
{ to: "/dashboard", label: "Обзор", icon: "▦", match: "exact" as const }, const items = [
{ to: "/events", label: "События", icon: "", match: "prefix" as const }, { to: "/dashboard", label: "Обзор", icon: "", match: "exact" as const },
{ to: "/reports", label: "Отчёты", icon: "", match: "prefix" as const }, { to: "/events", label: "События", icon: "", match: "prefix" as const },
{ to: "/problems", label: "Проблемы", icon: "!", match: "prefix" as const }, { to: "/reports", label: "Отчёты", icon: "", match: "prefix" as const },
{ to: "/hosts", label: "Хосты", icon: "", match: "exact" as const }, { to: "/problems", label: "Проблемы", icon: "!", match: "prefix" as const },
{ to: "/settings", label: "Настройки", icon: "", match: "exact" as const }, { to: "/hosts", label: "Хосты", icon: "", match: "exact" as const },
]; ];
if (isAdmin()) {
items.push(
{ to: "/settings", label: "Настройки", icon: "⚙", match: "exact" as const },
{ to: "/users", label: "Пользователи", icon: "👤", match: "exact" as const },
);
}
return items;
});
function isActive(item: (typeof navItems)[number]) { function isActive(item: (typeof navItems.value)[number]) {
if (item.match === "exact") { if (item.match === "exact") {
return route.path === item.to; return route.path === item.to;
} }
+18 -3
View File
@@ -1,5 +1,5 @@
import { createRouter, createWebHistory } from "vue-router"; import { createRouter, createWebHistory } from "vue-router";
import { getToken } from "./api"; import { getToken, isAdmin, setRole, fetchMe } from "./api";
import EventsView from "./views/EventsView.vue"; import EventsView from "./views/EventsView.vue";
import EventDetailView from "./views/EventDetailView.vue"; import EventDetailView from "./views/EventDetailView.vue";
import HostsView from "./views/HostsView.vue"; import HostsView from "./views/HostsView.vue";
@@ -9,6 +9,7 @@ import ProblemDetailView from "./views/ProblemDetailView.vue";
import DashboardView from "./views/DashboardView.vue"; import DashboardView from "./views/DashboardView.vue";
import ReportsView from "./views/ReportsView.vue"; import ReportsView from "./views/ReportsView.vue";
import SettingsView from "./views/SettingsView.vue"; import SettingsView from "./views/SettingsView.vue";
import UsersView from "./views/UsersView.vue";
const router = createRouter({ const router = createRouter({
history: createWebHistory(), history: createWebHistory(),
@@ -22,14 +23,28 @@ const router = createRouter({
{ path: "/hosts", component: HostsView }, { path: "/hosts", component: HostsView },
{ path: "/problems", component: ProblemsView }, { path: "/problems", component: ProblemsView },
{ path: "/problems/:id", component: ProblemDetailView, props: true }, { path: "/problems/:id", component: ProblemDetailView, props: true },
{ path: "/settings", component: SettingsView }, { path: "/settings", component: SettingsView, meta: { adminOnly: true } },
{ path: "/users", component: UsersView, meta: { adminOnly: true } },
], ],
}); });
router.beforeEach((to) => { router.beforeEach(async (to) => {
if (to.path === "/login") return true; if (to.path === "/login") return true;
if (!getToken()) return { path: "/login" }; if (!getToken()) return { path: "/login" };
if (to.meta.adminOnly && !isAdmin()) {
return { path: "/dashboard" };
}
return true; return true;
}); });
export default router; export default router;
export async function refreshSessionRole(): Promise<void> {
if (!getToken()) return;
try {
const me = await fetchMe();
setRole(me.role);
} catch {
/* 401 handled in apiFetch */
}
}
+2 -2
View File
@@ -25,7 +25,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref } from "vue"; import { ref } from "vue";
import { useRouter } from "vue-router"; import { useRouter } from "vue-router";
import { apiFetch, setToken, type TokenResponse } from "../api"; import { apiFetch, setAuth, type TokenResponse } from "../api";
const router = useRouter(); const router = useRouter();
const username = ref("admin"); const username = ref("admin");
@@ -41,7 +41,7 @@ async function submit() {
method: "POST", method: "POST",
body: JSON.stringify({ username: username.value, password: password.value }), body: JSON.stringify({ username: username.value, password: password.value }),
}); });
setToken(data.access_token); setAuth(data.access_token, data.role);
await router.push("/events"); await router.push("/events");
} catch (e) { } catch (e) {
error.value = e instanceof Error ? e.message : "Ошибка входа"; error.value = e instanceof Error ? e.message : "Ошибка входа";
+181
View File
@@ -0,0 +1,181 @@
<template>
<div class="users-page">
<h1>Пользователи</h1>
<p class="users-intro">
Учётные записи входа в веб-панель SAC. Роль <strong>monitor</strong> просмотр без раздела «Настройки».
</p>
<p v-if="error" class="error">{{ error }}</p>
<p v-if="success" class="success">{{ success }}</p>
<section class="card users-card">
<h2>Новый пользователь</h2>
<form class="users-form" @submit.prevent="createUser">
<label>
Логин
<input v-model="form.username" required minlength="2" autocomplete="off" />
</label>
<label>
Пароль
<input v-model="form.password" type="password" required minlength="8" autocomplete="new-password" />
</label>
<label>
Роль
<select v-model="form.role">
<option value="monitor">monitor просмотр</option>
<option value="admin">admin полный доступ</option>
</select>
</label>
<button type="submit" :disabled="creating">{{ creating ? "Создание…" : "Создать" }}</button>
</form>
</section>
<section class="card users-card">
<h2>Список</h2>
<p v-if="loading">Загрузка</p>
<table v-else class="users-table">
<thead>
<tr>
<th>Логин</th>
<th>Роль</th>
<th>Статус</th>
<th>Создан</th>
</tr>
</thead>
<tbody>
<tr v-for="u in users" :key="u.id">
<td>{{ u.username }}</td>
<td><code>{{ u.role }}</code></td>
<td>{{ u.is_active ? "активен" : "отключён" }}</td>
<td>{{ formatDate(u.created_at) }}</td>
</tr>
</tbody>
</table>
</section>
</div>
</template>
<script setup lang="ts">
import { onMounted, reactive, ref } from "vue";
import { apiFetch } from "../api";
interface UserSummary {
id: number;
username: string;
role: string;
is_active: boolean;
created_at: string;
}
interface UserListResponse {
items: UserSummary[];
}
const users = ref<UserSummary[]>([]);
const loading = ref(true);
const creating = ref(false);
const error = ref("");
const success = ref("");
const form = reactive({
username: "",
password: "",
role: "monitor",
});
function formatDate(iso: string): string {
try {
return new Date(iso).toLocaleString();
} catch {
return iso;
}
}
async function loadUsers() {
loading.value = true;
error.value = "";
try {
const data = await apiFetch<UserListResponse>("/api/v1/users");
users.value = data.items;
} catch (e) {
error.value = e instanceof Error ? e.message : "Ошибка загрузки";
} finally {
loading.value = false;
}
}
async function createUser() {
creating.value = true;
error.value = "";
success.value = "";
try {
await apiFetch<UserSummary>("/api/v1/users", {
method: "POST",
body: JSON.stringify({
username: form.username.trim(),
password: form.password,
role: form.role,
}),
});
success.value = `Пользователь ${form.username} создан`;
form.username = "";
form.password = "";
form.role = "monitor";
await loadUsers();
} catch (e) {
error.value = e instanceof Error ? e.message : "Ошибка создания";
} finally {
creating.value = false;
}
}
onMounted(loadUsers);
</script>
<style scoped>
.users-page {
max-width: 52rem;
}
.users-intro {
color: #9aa4b2;
margin-bottom: 1rem;
}
.users-card {
margin-bottom: 1.25rem;
}
.users-form {
display: flex;
flex-direction: column;
gap: 0.75rem;
max-width: 24rem;
}
.users-form label {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.users-table {
width: 100%;
border-collapse: collapse;
}
.users-table th,
.users-table td {
text-align: left;
padding: 0.45rem 0.5rem;
border-bottom: 1px solid #2a3441;
}
.error {
color: #ff8a8a;
}
.success {
color: #7dcea0;
}
</style>