Files
pvemanager/backend/app/api/users.py
Dilshod Rasulov de967c35e0 refactor(rbac): migrate API permission checks to resource:action format
Replace all legacy dotted permission codes (vms.view, proxmox.manage, ...)
in PermissionChecker/has_permission across app/api with the new
resource:action format (vm:view, server:manage, ...), per LEGACY_PERMISSION_MAP.
All 33 codes used by the API are now registered in the permission registry.

Also switch the /api/auth/me admin branch to get_all_permissions_v2 so the
endpoint emits permissions in the new format only.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 02:07:48 +05:00

1131 lines
37 KiB
Python

"""
Users and Roles Management API
"""
from datetime import datetime, timedelta
from typing import List, Optional
from fastapi import APIRouter, Depends, HTTPException, status, Request
from sqlalchemy.orm import Session
from pydantic import BaseModel, Field, EmailStr
from loguru import logger
from ..db import get_db
from ..models import User, Role, ProxmoxServer, Workspace, WorkspaceServer, WorkspaceUser
from ..auth import (
get_password_hash,
get_current_user,
get_current_active_admin,
PermissionChecker,
get_client_ip
)
from ..config import utcnow
from ..services.security_service import SecurityService
from ..logging_service import LoggingService
router = APIRouter()
# ==================== Schemas ====================
class RoleBase(BaseModel):
name: str = Field(..., min_length=2, max_length=50)
display_name: str = Field(..., min_length=2, max_length=100)
description: Optional[str] = None
permissions: dict = Field(default_factory=dict)
class RoleCreate(RoleBase):
pass
class RoleUpdate(BaseModel):
display_name: Optional[str] = Field(None, min_length=2, max_length=100)
description: Optional[str] = None
permissions: Optional[dict] = None
is_active: Optional[bool] = None
class RoleResponse(BaseModel):
id: int
name: str
display_name: str
description: Optional[str]
permissions: dict
is_system: bool
is_active: bool
created_at: datetime
user_count: int = 0
class Config:
from_attributes = True
class UserCreate(BaseModel):
username: str = Field(..., min_length=3, max_length=50)
email: EmailStr
password: str = Field(..., min_length=6)
full_name: Optional[str] = Field(None, max_length=100)
role_id: Optional[int] = None
is_active: bool = True
is_admin: bool = False
class UserUpdate(BaseModel):
email: Optional[EmailStr] = None
full_name: Optional[str] = Field(None, max_length=100)
password: Optional[str] = Field(None, min_length=1, max_length=100)
role_id: Optional[int] = None
is_active: Optional[bool] = None
is_admin: Optional[bool] = None
require_password_change: Optional[bool] = None
class UserResponse(BaseModel):
id: int
username: str
email: str
full_name: Optional[str]
is_active: bool
is_admin: bool
role_id: Optional[int]
role_name: Optional[str] = None
failed_login_attempts: int = 0
locked_until: Optional[datetime] = None
last_login: Optional[datetime] = None
created_at: datetime
class Config:
from_attributes = True
class PasswordChange(BaseModel):
new_password: str = Field(..., min_length=6)
class SecuritySettingsUpdate(BaseModel):
max_login_attempts: Optional[int] = Field(None, ge=1, le=20)
lockout_duration_minutes: Optional[int] = Field(None, ge=1, le=1440)
session_timeout_minutes: Optional[int] = Field(None, ge=5, le=1440)
ip_block_threshold: Optional[int] = Field(None, ge=1, le=100)
ip_block_duration_minutes: Optional[int] = Field(None, ge=1, le=10080)
password_min_length: Optional[int] = Field(None, ge=6, le=50)
password_require_uppercase: Optional[bool] = None
password_require_lowercase: Optional[bool] = None
password_require_numbers: Optional[bool] = None
password_require_special: Optional[bool] = None
# ==================== Role API ====================
@router.get("/api/roles", response_model=List[RoleResponse])
async def list_roles(
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("role:view"))
):
"""Get all roles"""
from sqlalchemy import func
roles = SecurityService.get_all_roles(db, include_inactive=True)
# Count users per role
counts = dict(
db.query(User.role_id, func.count(User.id))
.filter(User.role_id.isnot(None))
.group_by(User.role_id)
.all()
)
result = []
for role in roles:
result.append({
"id": role.id,
"name": role.name,
"display_name": role.display_name,
"description": role.description,
"permissions": role.permissions or {},
"is_system": role.is_system,
"is_active": role.is_active,
"created_at": role.created_at,
"user_count": counts.get(role.id, 0),
})
return result
@router.get("/api/roles/{role_id}", response_model=RoleResponse)
async def get_role(
role_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("role:view"))
):
"""Get role by ID"""
role = db.query(Role).filter(Role.id == role_id).first()
if not role:
raise HTTPException(status_code=404, detail="Role not found")
return role
@router.post("/api/roles", response_model=RoleResponse, status_code=201)
async def create_role(
role_data: RoleCreate,
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("role:manage"))
):
"""Create a new role"""
# Check if role name exists
existing = db.query(Role).filter(Role.name == role_data.name.lower()).first()
if existing:
raise HTTPException(status_code=400, detail="Role name already exists")
role = SecurityService.create_role(
db=db,
name=role_data.name,
display_name=role_data.display_name,
description=role_data.description,
permissions=role_data.permissions
)
LoggingService.log(
db=db,
level=LoggingService.INFO,
category=LoggingService.AUTH,
action="role_created",
message=f"Role '{role.name}' created by {current_user.username}",
username=current_user.username,
user_id=current_user.id,
ip_address=get_client_ip(request),
resource_type="role",
resource_id=str(role.id),
resource_name=role.name
)
return role
@router.put("/api/roles/{role_id}", response_model=RoleResponse)
async def update_role(
role_id: int,
role_data: RoleUpdate,
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("role:manage"))
):
"""Update a role"""
role = db.query(Role).filter(Role.id == role_id).first()
if not role:
raise HTTPException(status_code=404, detail="Role not found")
# Update fields
if role_data.display_name is not None:
role.display_name = role_data.display_name
if role_data.description is not None:
role.description = role_data.description
if role_data.permissions is not None:
role.permissions = role_data.permissions
if role_data.is_active is not None:
role.is_active = role_data.is_active
role.updated_at = utcnow()
db.commit()
db.refresh(role)
LoggingService.log(
db=db,
level=LoggingService.INFO,
category=LoggingService.AUTH,
action="role_updated",
message=f"Role '{role.name}' updated by {current_user.username}",
username=current_user.username,
user_id=current_user.id,
ip_address=get_client_ip(request),
resource_type="role",
resource_id=str(role.id),
resource_name=role.name
)
return role
@router.delete("/api/roles/{role_id}")
async def delete_role(
role_id: int,
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("role:manage"))
):
"""Delete a role"""
success, message = SecurityService.delete_role(db, role_id)
if not success:
raise HTTPException(status_code=400, detail=message)
LoggingService.log(
db=db,
level=LoggingService.WARNING,
category=LoggingService.AUTH,
action="role_deleted",
message=f"Role ID {role_id} deleted by {current_user.username}",
username=current_user.username,
user_id=current_user.id,
ip_address=get_client_ip(request),
resource_type="role",
resource_id=str(role_id)
)
return {"message": "Role deleted"}
@router.get("/api/permissions/v2")
async def get_all_permissions_v2(
current_user: User = Depends(PermissionChecker("role:view"))
):
"""
Get all available permissions in new format.
Returns permissions grouped by category with resource:action codes.
"""
return SecurityService.get_all_permissions_v2()
# ==================== User API ====================
@router.get("/api/users", response_model=List[UserResponse])
async def list_users(
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("user:view"))
):
"""Get all users"""
users = db.query(User).order_by(User.username).all()
# Add role_name
result = []
for user in users:
user_dict = {
"id": user.id,
"username": user.username,
"email": user.email,
"full_name": user.full_name,
"is_active": user.is_active,
"is_admin": user.is_admin,
"role_id": user.role_id,
"role_name": user.role.display_name if user.role else ("Administrator" if user.is_admin else "User"),
"failed_login_attempts": user.failed_login_attempts or 0,
"locked_until": user.locked_until,
"last_login": user.last_login,
"created_at": user.created_at
}
result.append(user_dict)
return result
@router.get("/api/users/{user_id}", response_model=UserResponse)
async def get_user(
user_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("user:view"))
):
"""Get user by ID"""
user = db.query(User).filter(User.id == user_id).first()
if not user:
raise HTTPException(status_code=404, detail="User not found")
return {
"id": user.id,
"username": user.username,
"email": user.email,
"full_name": user.full_name,
"is_active": user.is_active,
"is_admin": user.is_admin,
"role_id": user.role_id,
"role_name": user.role.display_name if user.role else None,
"failed_login_attempts": user.failed_login_attempts or 0,
"locked_until": user.locked_until,
"last_login": user.last_login,
"created_at": user.created_at
}
@router.post("/api/users", response_model=UserResponse, status_code=201)
async def create_user(
user_data: UserCreate,
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("user:create"))
):
"""Create a new user"""
# Check username
if db.query(User).filter(User.username == user_data.username).first():
raise HTTPException(status_code=400, detail="Username already exists")
# Check email
if db.query(User).filter(User.email == user_data.email).first():
raise HTTPException(status_code=400, detail="Email already exists")
# Validate password
lang = request.cookies.get("language", "en")
is_valid, errors = SecurityService.validate_password(db, user_data.password, lang)
if not is_valid:
raise HTTPException(status_code=400, detail="; ".join(errors))
# Validate role
if user_data.role_id:
role = db.query(Role).filter(Role.id == user_data.role_id).first()
if not role:
raise HTTPException(status_code=400, detail="Invalid role")
# Create user
user = User(
username=user_data.username,
email=user_data.email,
hashed_password=get_password_hash(user_data.password),
full_name=user_data.full_name,
role_id=user_data.role_id,
is_active=user_data.is_active,
is_admin=user_data.is_admin,
last_password_change=utcnow()
)
db.add(user)
db.commit()
db.refresh(user)
LoggingService.log(
db=db,
level=LoggingService.INFO,
category=LoggingService.AUTH,
action="user_created",
message=f"User '{user.username}' created by {current_user.username}",
username=current_user.username,
user_id=current_user.id,
ip_address=get_client_ip(request),
resource_type="user",
resource_id=str(user.id),
resource_name=user.username
)
return {
"id": user.id,
"username": user.username,
"email": user.email,
"full_name": user.full_name,
"is_active": user.is_active,
"is_admin": user.is_admin,
"role_id": user.role_id,
"role_name": user.role.display_name if user.role else None,
"failed_login_attempts": 0,
"locked_until": None,
"last_login": None,
"created_at": user.created_at
}
@router.put("/api/users/{user_id}", response_model=UserResponse)
async def update_user(
user_id: int,
user_data: UserUpdate,
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("user:update"))
):
"""Update a user"""
user = db.query(User).filter(User.id == user_id).first()
if not user:
raise HTTPException(status_code=404, detail="User not found")
# Prevent editing admin by non-admin
if user.is_admin and not current_user.is_admin:
raise HTTPException(status_code=403, detail="Cannot edit admin user")
# Update fields
if user_data.email is not None:
# Check email uniqueness
existing = db.query(User).filter(User.email == user_data.email, User.id != user_id).first()
if existing:
raise HTTPException(status_code=400, detail="Email already exists")
user.email = user_data.email
if user_data.full_name is not None:
user.full_name = user_data.full_name
if user_data.password is not None:
# Validate password
lang = request.cookies.get("language", "en")
is_valid, errors = SecurityService.validate_password(db, user_data.password, lang)
if not is_valid:
raise HTTPException(status_code=400, detail="; ".join(errors))
user.hashed_password = get_password_hash(user_data.password)
user.last_password_change = utcnow()
if user_data.role_id is not None:
role = db.query(Role).filter(Role.id == user_data.role_id).first()
if not role:
raise HTTPException(status_code=400, detail="Invalid role")
user.role_id = user_data.role_id
if user_data.is_active is not None:
# Cannot deactivate yourself
if user_id == current_user.id and not user_data.is_active:
raise HTTPException(status_code=400, detail="Cannot deactivate yourself")
user.is_active = user_data.is_active
# Terminate sessions if deactivating
if not user_data.is_active:
SecurityService.terminate_all_user_sessions(db, user_id)
if user_data.require_password_change is not None:
user.require_password_change = user_data.require_password_change
if user_data.is_admin is not None:
# Only admin can change admin status
if not current_user.is_admin:
raise HTTPException(status_code=403, detail="Only admin can change admin status")
# Cannot remove own admin status
if user_id == current_user.id and not user_data.is_admin:
raise HTTPException(status_code=400, detail="Cannot remove your own admin status")
user.is_admin = user_data.is_admin
user.updated_at = utcnow()
db.commit()
db.refresh(user)
LoggingService.log(
db=db,
level=LoggingService.INFO,
category=LoggingService.AUTH,
action="user_updated",
message=f"User '{user.username}' updated by {current_user.username}",
username=current_user.username,
user_id=current_user.id,
ip_address=get_client_ip(request),
resource_type="user",
resource_id=str(user.id),
resource_name=user.username
)
return {
"id": user.id,
"username": user.username,
"email": user.email,
"full_name": user.full_name,
"is_active": user.is_active,
"is_admin": user.is_admin,
"role_id": user.role_id,
"role_name": user.role.display_name if user.role else None,
"failed_login_attempts": user.failed_login_attempts or 0,
"locked_until": user.locked_until,
"last_login": user.last_login,
"created_at": user.created_at
}
@router.delete("/api/users/{user_id}")
async def delete_user(
user_id: int,
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("user:delete"))
):
"""Delete a user"""
user = db.query(User).filter(User.id == user_id).first()
if not user:
raise HTTPException(status_code=404, detail="User not found")
# Cannot delete yourself
if user_id == current_user.id:
raise HTTPException(status_code=400, detail="Cannot delete yourself")
# Cannot delete admin
if user.is_admin:
raise HTTPException(status_code=400, detail="Cannot delete admin user")
username = user.username
# Terminate all sessions
SecurityService.terminate_all_user_sessions(db, user_id)
db.delete(user)
db.commit()
LoggingService.log(
db=db,
level=LoggingService.WARNING,
category=LoggingService.AUTH,
action="user_deleted",
message=f"User '{username}' deleted by {current_user.username}",
username=current_user.username,
user_id=current_user.id,
ip_address=get_client_ip(request),
resource_type="user",
resource_id=str(user_id),
resource_name=username
)
return {"message": "User deleted"}
# ==================== Server Assignment API ====================
def _get_user_workspace_ids(db: Session, user_id: int) -> set:
"""Return set of workspace IDs the user belongs to."""
rows = db.query(WorkspaceUser.workspace_id).filter(WorkspaceUser.user_id == user_id).all()
return {r.workspace_id for r in rows}
def _get_server_workspace_info(db: Session, server_id: int):
"""Return list of {id, name} workspaces that own this server."""
rows = (
db.query(WorkspaceServer.workspace_id, Workspace.name)
.join(Workspace, Workspace.id == WorkspaceServer.workspace_id)
.filter(WorkspaceServer.server_id == server_id)
.all()
)
return [{"id": r.workspace_id, "name": r.name} for r in rows]
@router.get("/api/users/{user_id}/servers")
async def get_user_servers(
user_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("user:view"))
):
"""Get list of Proxmox servers assigned to a specific user"""
user = db.query(User).filter(User.id == user_id).first()
if not user:
raise HTTPException(status_code=404, detail="User not found")
return [
{"id": s.id, "name": s.name, "hostname": s.hostname, "ip_address": s.ip_address, "is_online": s.is_online}
for s in user.assigned_servers
]
@router.get("/api/users/{user_id}/server-assignments")
async def get_user_server_assignments(
user_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("user:view"))
):
"""
Return all Proxmox servers enriched with:
- workspaces each server belongs to
- whether the server is compatible with the user (user is in at least one of its workspaces)
- whether the server is currently assigned to the user
"""
user = db.query(User).filter(User.id == user_id).first()
if not user:
raise HTTPException(status_code=404, detail="User not found")
user_ws_ids = _get_user_workspace_ids(db, user_id)
assigned_ids = {s.id for s in user.assigned_servers}
# User's own workspaces info
user_workspaces = [
{"id": ws.id, "name": ws.name}
for ws in db.query(Workspace).filter(Workspace.id.in_(user_ws_ids)).all()
] if user_ws_ids else []
all_servers = db.query(ProxmoxServer).all()
result = []
for server in all_servers:
server_workspaces = _get_server_workspace_info(db, server.id)
server_ws_ids = {w["id"] for w in server_workspaces}
# Compatible = user's workspaces and server's workspaces intersect
compatible = bool(user_ws_ids & server_ws_ids) if (user_ws_ids and server_ws_ids) else False
result.append({
"id": server.id,
"name": server.name,
"ip_address": server.ip_address,
"is_online": server.is_online,
"workspaces": server_workspaces,
"compatible": compatible,
"assigned": server.id in assigned_ids,
})
return {
"servers": result,
"user_workspaces": user_workspaces,
}
@router.put("/api/users/{user_id}/servers")
async def set_user_servers(
user_id: int,
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("user:update"))
):
"""Assign a list of Proxmox servers to a user (replaces current assignment).
Rejects the operation if any server's workspaces don't intersect with the user's workspaces.
"""
user = db.query(User).filter(User.id == user_id).first()
if not user:
raise HTTPException(status_code=404, detail="User not found")
data = await request.json()
server_ids = data.get("server_ids", [])
if not server_ids:
user.assigned_servers = []
db.commit()
return {"message": "Servers cleared", "server_ids": []}
servers = db.query(ProxmoxServer).filter(ProxmoxServer.id.in_(server_ids)).all()
# Workspace compatibility validation
user_ws_ids = _get_user_workspace_ids(db, user_id)
conflicts = []
for server in servers:
server_workspaces = _get_server_workspace_info(db, server.id)
server_ws_ids = {w["id"] for w in server_workspaces}
# No overlap between user workspaces and server workspaces → conflict
if user_ws_ids and server_ws_ids and not (user_ws_ids & server_ws_ids):
user_workspaces_info = [
{"id": ws.id, "name": ws.name}
for ws in db.query(Workspace).filter(Workspace.id.in_(user_ws_ids)).all()
]
conflicts.append({
"server_id": server.id,
"server_name": server.name,
"server_workspaces": server_workspaces,
"user_workspaces": user_workspaces_info,
})
if conflicts:
raise HTTPException(
status_code=409,
detail={
"code": "workspace_conflict",
"message": "Cannot assign: server(s) are not in any workspace the user belongs to",
"conflicts": conflicts,
}
)
user.assigned_servers = servers
db.commit()
LoggingService.log(
db=db,
level=LoggingService.INFO,
category=LoggingService.AUTH,
action="user_servers_updated",
message=f"Servers assigned to '{user.username}' by {current_user.username}: {[s.id for s in servers]}",
username=current_user.username,
user_id=current_user.id,
ip_address=get_client_ip(request),
resource_type="user",
resource_id=str(user.id),
resource_name=user.username
)
return {"message": "Servers assigned successfully", "server_ids": [s.id for s in servers]}
@router.post("/api/users/{user_id}/reset-password")
async def reset_user_password(
user_id: int,
password_data: PasswordChange,
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("user:update"))
):
"""Reset user password (admin action)"""
user = db.query(User).filter(User.id == user_id).first()
if not user:
raise HTTPException(status_code=404, detail="User not found")
# Validate password
lang = request.cookies.get("language", "en")
is_valid, errors = SecurityService.validate_password(db, password_data.new_password, lang)
if not is_valid:
raise HTTPException(status_code=400, detail="; ".join(errors))
user.hashed_password = get_password_hash(password_data.new_password)
user.last_password_change = utcnow()
user.require_password_change = True # Force password change on next login
user.failed_login_attempts = 0
user.locked_until = None
db.commit()
# Terminate all sessions
SecurityService.terminate_all_user_sessions(db, user_id)
LoggingService.log(
db=db,
level=LoggingService.WARNING,
category=LoggingService.AUTH,
action="password_reset",
message=f"Password reset for user '{user.username}' by {current_user.username}",
username=current_user.username,
user_id=current_user.id,
ip_address=get_client_ip(request),
resource_type="user",
resource_id=str(user.id),
resource_name=user.username
)
return {"message": "Password reset successfully"}
@router.post("/api/users/{user_id}/unlock")
async def unlock_user(
user_id: int,
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("user:update"))
):
"""Unlock a locked user account"""
user = db.query(User).filter(User.id == user_id).first()
if not user:
raise HTTPException(status_code=404, detail="User not found")
user.failed_login_attempts = 0
user.locked_until = None
db.commit()
LoggingService.log(
db=db,
level=LoggingService.INFO,
category=LoggingService.AUTH,
action="user_unlocked",
message=f"User '{user.username}' unlocked by {current_user.username}",
username=current_user.username,
user_id=current_user.id,
ip_address=get_client_ip(request),
resource_type="user",
resource_id=str(user.id),
resource_name=user.username
)
return {"message": "User unlocked"}
@router.post("/api/users/{user_id}/terminate-sessions")
async def terminate_user_sessions(
user_id: int,
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("user:update"))
):
"""Terminate all sessions for a user"""
user = db.query(User).filter(User.id == user_id).first()
if not user:
raise HTTPException(status_code=404, detail="User not found")
count = SecurityService.terminate_all_user_sessions(db, user_id)
LoggingService.log(
db=db,
level=LoggingService.INFO,
category=LoggingService.AUTH,
action="sessions_terminated",
message=f"Terminated {count} sessions for user '{user.username}' by {current_user.username}",
username=current_user.username,
user_id=current_user.id,
ip_address=get_client_ip(request),
resource_type="user",
resource_id=str(user.id),
resource_name=user.username
)
return {"message": f"Terminated {count} sessions"}
# ==================== Security Settings API ====================
@router.get("/api/security/settings")
async def get_security_settings(
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("setting:manage"))
):
"""Get security settings"""
return {
"max_login_attempts": SecurityService.get_setting_int(db, "max_login_attempts", 5),
"lockout_duration_minutes": SecurityService.get_setting_int(db, "lockout_duration_minutes", 30),
"session_timeout_minutes": SecurityService.get_setting_int(db, "session_timeout_minutes", 60),
"ip_block_threshold": SecurityService.get_setting_int(db, "ip_block_threshold", 10),
"ip_block_duration_minutes": SecurityService.get_setting_int(db, "ip_block_duration_minutes", 60),
"password_min_length": SecurityService.get_setting_int(db, "password_min_length", 8),
"password_require_uppercase": SecurityService.get_setting_bool(db, "password_require_uppercase", True),
"password_require_lowercase": SecurityService.get_setting_bool(db, "password_require_lowercase", True),
"password_require_numbers": SecurityService.get_setting_bool(db, "password_require_numbers", True),
"password_require_special": SecurityService.get_setting_bool(db, "password_require_special", False),
}
@router.put("/api/security/settings")
async def update_security_settings(
settings_data: SecuritySettingsUpdate,
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("setting:manage"))
):
"""Update security settings"""
updated = []
for key, value in settings_data.model_dump(exclude_unset=True).items():
SecurityService.update_setting(db, key, str(value).lower() if isinstance(value, bool) else str(value))
updated.append(key)
if updated:
LoggingService.log(
db=db,
level=LoggingService.INFO,
category=LoggingService.AUTH,
action="security_settings_updated",
message=f"Security settings updated by {current_user.username}: {', '.join(updated)}",
username=current_user.username,
user_id=current_user.id,
ip_address=get_client_ip(request)
)
return {"message": "Settings updated", "updated": updated}
# ==================== Blocked IPs API ====================
@router.get("/api/security/blocked-ips")
async def list_blocked_ips(
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("setting:manage"))
):
"""Get list of blocked IPs"""
from ..models import BlockedIP
blocked = db.query(BlockedIP).order_by(BlockedIP.blocked_at.desc()).all()
return [{
"id": ip.id,
"ip_address": ip.ip_address,
"reason": ip.reason,
"blocked_by": ip.blocked_by,
"blocked_at": ip.blocked_at.isoformat() if ip.blocked_at else None,
"expires_at": ip.expires_at.isoformat() if ip.expires_at else None,
"is_permanent": ip.is_permanent,
"attempts_count": ip.attempts_count,
"is_active": ip.is_blocked()
} for ip in blocked]
@router.delete("/api/security/blocked-ips/{ip_address:path}")
async def unblock_ip(
ip_address: str,
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("setting:manage"))
):
"""Unblock an IP address"""
if SecurityService.unblock_ip(db, ip_address):
LoggingService.log(
db=db,
level=LoggingService.INFO,
category=LoggingService.AUTH,
action="ip_unblocked",
message=f"IP {ip_address} unblocked by {current_user.username}",
username=current_user.username,
user_id=current_user.id,
ip_address=get_client_ip(request)
)
return {"message": f"IP {ip_address} unblocked"}
raise HTTPException(status_code=404, detail="IP not found in blocked list")
# ==================== Sessions API ====================
@router.get("/api/sessions")
async def list_sessions(
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("user:view"))
):
"""Get list of active sessions"""
from ..models import ActiveSession
sessions = db.query(ActiveSession).filter(
ActiveSession.expires_at > utcnow()
).order_by(ActiveSession.last_activity.desc()).all()
result = []
for session in sessions:
user = db.query(User).filter(User.id == session.user_id).first()
result.append({
"id": session.id,
"user_id": session.user_id,
"username": user.username if user else "Unknown",
"ip_address": session.ip_address,
"device_info": session.device_info,
"created_at": session.created_at.isoformat() if session.created_at else None,
"last_activity": session.last_activity.isoformat() if session.last_activity else None,
"expires_at": session.expires_at.isoformat() if session.expires_at else None,
})
return result
@router.delete("/api/sessions/{session_id}")
async def terminate_session(
session_id: int,
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("user:update"))
):
"""Terminate a specific session"""
from ..models import ActiveSession
session = db.query(ActiveSession).filter(ActiveSession.id == session_id).first()
if not session:
raise HTTPException(status_code=404, detail="Session not found")
db.delete(session)
db.commit()
LoggingService.log(
db=db,
level=LoggingService.INFO,
category=LoggingService.AUTH,
action="session_terminated",
message=f"Session {session_id} terminated by {current_user.username}",
username=current_user.username,
user_id=current_user.id,
ip_address=get_client_ip(request)
)
return {"message": "Session terminated"}
@router.post("/api/sessions/terminate-all")
async def terminate_all_sessions(
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("user:update"))
):
"""Terminate all sessions except current user's"""
from ..models import ActiveSession
# Delete all sessions except current user's
deleted = db.query(ActiveSession).filter(
ActiveSession.user_id != current_user.id
).delete()
db.commit()
LoggingService.log(
db=db,
level=LoggingService.WARNING,
category=LoggingService.AUTH,
action="all_sessions_terminated",
message=f"All sessions ({deleted}) terminated by {current_user.username}",
username=current_user.username,
user_id=current_user.id,
ip_address=get_client_ip(request)
)
return {"message": f"Terminated {deleted} sessions"}
# ==================== Security Events API ====================
@router.get("/api/security/events")
async def list_security_events(
limit: int = 50,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("setting:manage"))
):
"""Get security events from logs"""
from ..models import AuditLog
# Get auth-related logs
events = db.query(AuditLog).filter(
AuditLog.category == "auth"
).order_by(AuditLog.created_at.desc()).limit(limit).all()
return [{
"id": event.id,
"event_type": event.action,
"ip_address": event.ip_address,
"username": event.username,
"message": event.message,
"created_at": event.created_at.isoformat() if event.created_at else None,
} for event in events]
# ==================== Block IP API ====================
@router.post("/api/security/blocked-ips")
async def block_ip(
ip_data: dict,
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("setting:manage"))
):
"""Block an IP address manually"""
from ..models import BlockedIP
ip_address = ip_data.get("ip_address")
reason = ip_data.get("reason", "Manual block")
duration_minutes = ip_data.get("duration_minutes")
if not ip_address:
raise HTTPException(status_code=400, detail="IP address is required")
# Check if already blocked
existing = db.query(BlockedIP).filter(BlockedIP.ip_address == ip_address).first()
if existing:
raise HTTPException(status_code=400, detail="IP is already blocked")
# Calculate expiry
expires_at = None
is_permanent = True
if duration_minutes:
expires_at = utcnow() + timedelta(minutes=int(duration_minutes))
is_permanent = False
blocked_ip = BlockedIP(
ip_address=ip_address,
reason=reason,
blocked_by=current_user.username,
blocked_at=utcnow(),
expires_at=expires_at,
is_permanent=is_permanent
)
db.add(blocked_ip)
db.commit()
LoggingService.log(
db=db,
level=LoggingService.WARNING,
category=LoggingService.AUTH,
action="ip_blocked_manual",
message=f"IP {ip_address} blocked manually by {current_user.username}: {reason}",
username=current_user.username,
user_id=current_user.id,
ip_address=get_client_ip(request)
)
return {"message": f"IP {ip_address} blocked", "id": blocked_ip.id}
@router.delete("/api/security/blocked-ips/{blocked_id}")
async def unblock_ip_by_id(
blocked_id: int,
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("setting:manage"))
):
"""Unblock an IP address by ID"""
from ..models import BlockedIP
blocked_ip = db.query(BlockedIP).filter(BlockedIP.id == blocked_id).first()
if not blocked_ip:
raise HTTPException(status_code=404, detail="Blocked IP not found")
ip_address = blocked_ip.ip_address
db.delete(blocked_ip)
db.commit()
LoggingService.log(
db=db,
level=LoggingService.INFO,
category=LoggingService.AUTH,
action="ip_unblocked",
message=f"IP {ip_address} unblocked by {current_user.username}",
username=current_user.username,
user_id=current_user.id,
ip_address=get_client_ip(request)
)
return {"message": f"IP {ip_address} unblocked"}