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>
1131 lines
37 KiB
Python
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"}
|