2025-10-27 09:29:27 +00:00
|
|
|
"""
|
|
|
|
|
REST API Server for Calejo Control Adapter.
|
|
|
|
|
|
|
|
|
|
Provides REST endpoints for emergency stop, status monitoring, and setpoint access.
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
from typing import Optional, Dict, Any
|
|
|
|
|
from datetime import datetime
|
|
|
|
|
import structlog
|
2025-10-27 20:07:37 +00:00
|
|
|
from fastapi import FastAPI, HTTPException, status, Depends, Request
|
2025-10-27 09:29:27 +00:00
|
|
|
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
2025-10-27 20:07:37 +00:00
|
|
|
from fastapi.middleware.cors import CORSMiddleware
|
2025-10-27 09:29:27 +00:00
|
|
|
from pydantic import BaseModel
|
|
|
|
|
|
|
|
|
|
from src.core.setpoint_manager import SetpointManager
|
|
|
|
|
from src.core.emergency_stop import EmergencyStopManager
|
2025-10-27 20:07:37 +00:00
|
|
|
from src.core.security import (
|
|
|
|
|
SecurityManager, TokenData, UserRole, get_security_manager
|
|
|
|
|
)
|
|
|
|
|
from src.core.tls_manager import get_tls_manager
|
2025-10-27 09:29:27 +00:00
|
|
|
|
|
|
|
|
logger = structlog.get_logger()
|
|
|
|
|
|
|
|
|
|
# Security
|
|
|
|
|
security = HTTPBearer()
|
|
|
|
|
|
|
|
|
|
|
2025-10-27 20:07:37 +00:00
|
|
|
class LoginRequest(BaseModel):
|
|
|
|
|
"""Request model for user login."""
|
|
|
|
|
username: str
|
|
|
|
|
password: str
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class LoginResponse(BaseModel):
|
|
|
|
|
"""Response model for successful login."""
|
|
|
|
|
access_token: str
|
|
|
|
|
token_type: str = "bearer"
|
|
|
|
|
expires_in: int
|
|
|
|
|
user_id: str
|
|
|
|
|
username: str
|
|
|
|
|
role: str
|
|
|
|
|
permissions: list[str]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class UserInfoResponse(BaseModel):
|
|
|
|
|
"""Response model for user information."""
|
|
|
|
|
user_id: str
|
|
|
|
|
username: str
|
|
|
|
|
email: str
|
|
|
|
|
role: str
|
|
|
|
|
permissions: list[str]
|
|
|
|
|
last_login: Optional[str]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def get_current_user(
|
|
|
|
|
credentials: HTTPAuthorizationCredentials = Depends(security),
|
|
|
|
|
security_manager: SecurityManager = Depends(get_security_manager)
|
|
|
|
|
) -> TokenData:
|
|
|
|
|
"""Dependency to get current user from JWT token."""
|
|
|
|
|
token = credentials.credentials
|
|
|
|
|
token_data = security_manager.verify_access_token(token)
|
|
|
|
|
if not token_data:
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
|
|
|
detail="Invalid authentication credentials",
|
|
|
|
|
headers={"WWW-Authenticate": "Bearer"},
|
|
|
|
|
)
|
|
|
|
|
return token_data
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def require_permission(permission: str):
|
|
|
|
|
"""Dependency factory to require specific permission."""
|
|
|
|
|
def permission_dependency(
|
|
|
|
|
token_data: TokenData = Depends(get_current_user),
|
|
|
|
|
security_manager: SecurityManager = Depends(get_security_manager)
|
|
|
|
|
):
|
|
|
|
|
if not security_manager.check_permission(token_data, permission):
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
status_code=status.HTTP_403_FORBIDDEN,
|
|
|
|
|
detail=f"Insufficient permissions. Required: {permission}"
|
|
|
|
|
)
|
|
|
|
|
return token_data
|
|
|
|
|
return permission_dependency
|
|
|
|
|
|
|
|
|
|
|
2025-10-27 09:29:27 +00:00
|
|
|
class EmergencyStopRequest(BaseModel):
|
|
|
|
|
"""Request model for emergency stop."""
|
|
|
|
|
triggered_by: str
|
|
|
|
|
reason: str
|
|
|
|
|
station_id: Optional[str] = None
|
|
|
|
|
pump_id: Optional[str] = None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class EmergencyStopClearRequest(BaseModel):
|
|
|
|
|
"""Request model for emergency stop clearance."""
|
|
|
|
|
cleared_by: str
|
|
|
|
|
notes: str
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class SetpointResponse(BaseModel):
|
|
|
|
|
"""Response model for setpoint data."""
|
|
|
|
|
station_id: str
|
|
|
|
|
pump_id: str
|
|
|
|
|
setpoint_hz: Optional[float]
|
|
|
|
|
control_type: str
|
|
|
|
|
safety_status: str
|
|
|
|
|
timestamp: str
|
|
|
|
|
|
|
|
|
|
|
Phase 5: Complete Protocol Server Security Enhancement
- Enhanced OPC UA Server with certificate-based authentication, RBAC, and security event logging
- Enhanced Modbus TCP Server with IP-based access control, rate limiting, and security monitoring
- Completed REST API security integration with setpoint write operations and security status endpoint
- Created comprehensive protocol security integration tests (8/8 tests passing)
- All 197 tests passing across the entire codebase
Security Features Implemented:
- OPC UA: Certificate authentication, client tracking, RBAC node access control
- Modbus TCP: IP filtering, rate limiting, security monitoring, security registers
- REST API: Setpoint write operations with authorization, security status endpoint
- Cross-protocol: Shared security manager and audit logger integration
2025-10-27 20:59:19 +00:00
|
|
|
class SetpointUpdateRequest(BaseModel):
|
|
|
|
|
"""Request model for updating setpoint."""
|
|
|
|
|
setpoint_hz: float
|
|
|
|
|
reason: Optional[str] = None
|
|
|
|
|
|
|
|
|
|
|
2025-10-27 09:29:27 +00:00
|
|
|
class RESTAPIServer:
|
|
|
|
|
"""REST API Server for Calejo Control Adapter."""
|
|
|
|
|
|
|
|
|
|
def __init__(
|
|
|
|
|
self,
|
|
|
|
|
setpoint_manager: SetpointManager,
|
|
|
|
|
emergency_stop_manager: EmergencyStopManager,
|
|
|
|
|
host: str = "0.0.0.0",
|
|
|
|
|
port: int = 8000
|
|
|
|
|
):
|
|
|
|
|
self.setpoint_manager = setpoint_manager
|
|
|
|
|
self.emergency_stop_manager = emergency_stop_manager
|
|
|
|
|
self.host = host
|
|
|
|
|
self.port = port
|
|
|
|
|
|
|
|
|
|
# Create FastAPI app
|
|
|
|
|
self.app = FastAPI(
|
|
|
|
|
title="Calejo Control API",
|
|
|
|
|
version="2.0",
|
2025-10-27 20:07:37 +00:00
|
|
|
description="REST API for Calejo Control Adapter with safety framework",
|
|
|
|
|
docs_url="/docs",
|
|
|
|
|
redoc_url="/redoc"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# Add CORS middleware
|
|
|
|
|
self.app.add_middleware(
|
|
|
|
|
CORSMiddleware,
|
|
|
|
|
allow_origins=["*"], # In production, restrict to specific origins
|
|
|
|
|
allow_credentials=True,
|
|
|
|
|
allow_methods=["*"],
|
|
|
|
|
allow_headers=["*"],
|
2025-10-27 09:29:27 +00:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
self._setup_routes()
|
|
|
|
|
|
|
|
|
|
def _setup_routes(self):
|
|
|
|
|
"""Setup all API routes."""
|
|
|
|
|
|
|
|
|
|
@self.app.get("/", summary="API Root", tags=["General"])
|
|
|
|
|
async def root():
|
|
|
|
|
"""API root endpoint."""
|
|
|
|
|
return {
|
|
|
|
|
"name": "Calejo Control API",
|
|
|
|
|
"version": "2.0",
|
2025-10-27 20:07:37 +00:00
|
|
|
"status": "operational",
|
|
|
|
|
"authentication_required": True
|
2025-10-27 09:29:27 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@self.app.get("/health", summary="Health Check", tags=["General"])
|
|
|
|
|
async def health_check():
|
|
|
|
|
"""Health check endpoint."""
|
|
|
|
|
return {
|
|
|
|
|
"status": "healthy",
|
|
|
|
|
"timestamp": datetime.now().isoformat()
|
|
|
|
|
}
|
|
|
|
|
|
2025-10-27 20:07:37 +00:00
|
|
|
# Authentication endpoints (no authentication required)
|
|
|
|
|
@self.app.post(
|
|
|
|
|
"/api/v1/auth/login",
|
|
|
|
|
summary="User Login",
|
|
|
|
|
tags=["Authentication"],
|
|
|
|
|
response_model=LoginResponse
|
|
|
|
|
)
|
|
|
|
|
async def login(
|
|
|
|
|
request: LoginRequest,
|
|
|
|
|
security_manager: SecurityManager = Depends(get_security_manager)
|
|
|
|
|
):
|
|
|
|
|
"""Authenticate user and return JWT token."""
|
|
|
|
|
token = security_manager.authenticate(request.username, request.password)
|
|
|
|
|
if not token:
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
|
|
|
detail="Invalid username or password"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# Get user permissions
|
|
|
|
|
token_data = security_manager.verify_access_token(token)
|
|
|
|
|
permissions = security_manager.get_user_permissions(token_data)
|
|
|
|
|
|
|
|
|
|
return LoginResponse(
|
|
|
|
|
access_token=token,
|
|
|
|
|
token_type="bearer",
|
|
|
|
|
expires_in=60, # 60 minutes
|
|
|
|
|
user_id=token_data.user_id,
|
|
|
|
|
username=token_data.username,
|
|
|
|
|
role=token_data.role.value,
|
|
|
|
|
permissions=permissions
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
@self.app.get(
|
|
|
|
|
"/api/v1/auth/me",
|
|
|
|
|
summary="Get Current User Info",
|
|
|
|
|
tags=["Authentication"],
|
|
|
|
|
response_model=UserInfoResponse
|
|
|
|
|
)
|
|
|
|
|
async def get_current_user_info(
|
|
|
|
|
token_data: TokenData = Depends(get_current_user),
|
|
|
|
|
security_manager: SecurityManager = Depends(get_security_manager)
|
|
|
|
|
):
|
|
|
|
|
"""Get information about the currently authenticated user."""
|
|
|
|
|
# Get user from auth manager
|
|
|
|
|
user = security_manager.auth_manager.users.get(token_data.user_id)
|
|
|
|
|
if not user:
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
|
|
|
detail="User not found"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
permissions = security_manager.get_user_permissions(token_data)
|
|
|
|
|
|
|
|
|
|
return UserInfoResponse(
|
|
|
|
|
user_id=user.user_id,
|
|
|
|
|
username=user.username,
|
|
|
|
|
email=user.email,
|
|
|
|
|
role=user.role.value,
|
|
|
|
|
permissions=permissions,
|
|
|
|
|
last_login=user.last_login.isoformat() if user.last_login else None
|
|
|
|
|
)
|
|
|
|
|
|
2025-10-27 09:29:27 +00:00
|
|
|
@self.app.get(
|
|
|
|
|
"/api/v1/setpoints",
|
|
|
|
|
summary="Get All Setpoints",
|
|
|
|
|
tags=["Setpoints"],
|
|
|
|
|
response_model=Dict[str, Dict[str, Optional[float]]]
|
|
|
|
|
)
|
|
|
|
|
async def get_all_setpoints(
|
2025-10-27 20:07:37 +00:00
|
|
|
token_data: TokenData = Depends(require_permission("read_pump_status"))
|
2025-10-27 09:29:27 +00:00
|
|
|
):
|
|
|
|
|
"""
|
|
|
|
|
Get current setpoints for all pumps.
|
|
|
|
|
|
|
|
|
|
Returns dictionary mapping station_id -> pump_id -> setpoint_hz
|
2025-10-27 20:07:37 +00:00
|
|
|
|
|
|
|
|
Requires permission: read_pump_status
|
2025-10-27 09:29:27 +00:00
|
|
|
"""
|
|
|
|
|
try:
|
|
|
|
|
setpoints = self.setpoint_manager.get_all_current_setpoints()
|
|
|
|
|
return setpoints
|
|
|
|
|
except Exception as e:
|
|
|
|
|
logger.error("failed_to_get_setpoints", error=str(e))
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
|
|
|
detail="Failed to retrieve setpoints"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
@self.app.get(
|
|
|
|
|
"/api/v1/setpoints/{station_id}/{pump_id}",
|
|
|
|
|
summary="Get Setpoint for Specific Pump",
|
|
|
|
|
tags=["Setpoints"],
|
|
|
|
|
response_model=SetpointResponse
|
|
|
|
|
)
|
|
|
|
|
async def get_pump_setpoint(
|
|
|
|
|
station_id: str,
|
|
|
|
|
pump_id: str,
|
2025-10-27 20:07:37 +00:00
|
|
|
token_data: TokenData = Depends(require_permission("read_pump_status")),
|
|
|
|
|
security_manager: SecurityManager = Depends(get_security_manager)
|
2025-10-27 09:29:27 +00:00
|
|
|
):
|
2025-10-27 20:07:37 +00:00
|
|
|
"""
|
|
|
|
|
Get current setpoint for a specific pump.
|
|
|
|
|
|
|
|
|
|
Requires permission: read_pump_status
|
|
|
|
|
"""
|
2025-10-27 09:29:27 +00:00
|
|
|
try:
|
2025-10-27 20:07:37 +00:00
|
|
|
# Check if user can access this station
|
|
|
|
|
if not security_manager.can_access_station(token_data, station_id):
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
status_code=status.HTTP_403_FORBIDDEN,
|
|
|
|
|
detail=f"Access denied to station {station_id}"
|
|
|
|
|
)
|
|
|
|
|
|
2025-10-27 09:29:27 +00:00
|
|
|
setpoint = self.setpoint_manager.get_current_setpoint(station_id, pump_id)
|
|
|
|
|
|
|
|
|
|
# Get pump info for control type
|
|
|
|
|
pump_info = self.setpoint_manager.discovery.get_pump(station_id, pump_id)
|
|
|
|
|
control_type = pump_info['control_type'] if pump_info else "UNKNOWN"
|
|
|
|
|
|
|
|
|
|
# Determine safety status
|
|
|
|
|
safety_status = "normal"
|
|
|
|
|
if self.emergency_stop_manager.is_emergency_stop_active(station_id, pump_id):
|
|
|
|
|
safety_status = "emergency_stop"
|
|
|
|
|
elif self.setpoint_manager.watchdog.is_failsafe_active(station_id, pump_id):
|
|
|
|
|
safety_status = "failsafe"
|
|
|
|
|
|
|
|
|
|
return SetpointResponse(
|
|
|
|
|
station_id=station_id,
|
|
|
|
|
pump_id=pump_id,
|
|
|
|
|
setpoint_hz=setpoint,
|
|
|
|
|
control_type=control_type,
|
|
|
|
|
safety_status=safety_status,
|
|
|
|
|
timestamp=datetime.now().isoformat()
|
|
|
|
|
)
|
|
|
|
|
|
2025-10-27 20:07:37 +00:00
|
|
|
except HTTPException:
|
|
|
|
|
raise
|
2025-10-27 09:29:27 +00:00
|
|
|
except Exception as e:
|
|
|
|
|
logger.error(
|
|
|
|
|
"failed_to_get_pump_setpoint",
|
|
|
|
|
station_id=station_id,
|
|
|
|
|
pump_id=pump_id,
|
|
|
|
|
error=str(e)
|
|
|
|
|
)
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
|
|
|
detail=f"Failed to retrieve setpoint for {station_id}/{pump_id}"
|
|
|
|
|
)
|
|
|
|
|
|
Phase 5: Complete Protocol Server Security Enhancement
- Enhanced OPC UA Server with certificate-based authentication, RBAC, and security event logging
- Enhanced Modbus TCP Server with IP-based access control, rate limiting, and security monitoring
- Completed REST API security integration with setpoint write operations and security status endpoint
- Created comprehensive protocol security integration tests (8/8 tests passing)
- All 197 tests passing across the entire codebase
Security Features Implemented:
- OPC UA: Certificate authentication, client tracking, RBAC node access control
- Modbus TCP: IP filtering, rate limiting, security monitoring, security registers
- REST API: Setpoint write operations with authorization, security status endpoint
- Cross-protocol: Shared security manager and audit logger integration
2025-10-27 20:59:19 +00:00
|
|
|
@self.app.put(
|
|
|
|
|
"/api/v1/setpoints/{station_id}/{pump_id}",
|
|
|
|
|
summary="Update Setpoint for Specific Pump",
|
|
|
|
|
tags=["Setpoints"]
|
|
|
|
|
)
|
|
|
|
|
async def update_pump_setpoint(
|
|
|
|
|
station_id: str,
|
|
|
|
|
pump_id: str,
|
|
|
|
|
request: SetpointUpdateRequest,
|
|
|
|
|
token_data: TokenData = Depends(require_permission("write_pump_setpoint")),
|
|
|
|
|
security_manager: SecurityManager = Depends(get_security_manager)
|
|
|
|
|
):
|
|
|
|
|
"""
|
|
|
|
|
Update setpoint for a specific pump.
|
|
|
|
|
|
|
|
|
|
Requires permission: write_pump_setpoint
|
|
|
|
|
"""
|
|
|
|
|
try:
|
|
|
|
|
# Check if user can access this station
|
|
|
|
|
if not security_manager.can_access_station(token_data, station_id):
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
status_code=status.HTTP_403_FORBIDDEN,
|
|
|
|
|
detail=f"Access denied to station {station_id}"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# Validate setpoint range
|
|
|
|
|
if request.setpoint_hz < 0 or request.setpoint_hz > 50:
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
|
|
|
detail="Setpoint must be between 0 and 50 Hz"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# Check if pump is in emergency stop
|
|
|
|
|
if self.emergency_stop_manager.is_emergency_stop_active(station_id, pump_id):
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
status_code=status.HTTP_423_LOCKED,
|
|
|
|
|
detail="Cannot update setpoint - pump is in emergency stop"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# Update setpoint
|
|
|
|
|
success = self.setpoint_manager.update_setpoint(
|
|
|
|
|
station_id=station_id,
|
|
|
|
|
pump_id=pump_id,
|
|
|
|
|
setpoint_hz=request.setpoint_hz,
|
|
|
|
|
user_id=token_data.username,
|
|
|
|
|
reason=request.reason or "Manual update via REST API"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
if success:
|
|
|
|
|
return {
|
|
|
|
|
"status": "setpoint_updated",
|
|
|
|
|
"station_id": station_id,
|
|
|
|
|
"pump_id": pump_id,
|
|
|
|
|
"setpoint_hz": request.setpoint_hz,
|
|
|
|
|
"updated_by": token_data.username,
|
|
|
|
|
"reason": request.reason,
|
|
|
|
|
"timestamp": datetime.now().isoformat()
|
|
|
|
|
}
|
|
|
|
|
else:
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
|
|
|
detail="Failed to update setpoint"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
except HTTPException:
|
|
|
|
|
raise
|
|
|
|
|
except Exception as e:
|
|
|
|
|
logger.error(
|
|
|
|
|
"failed_to_update_pump_setpoint",
|
|
|
|
|
station_id=station_id,
|
|
|
|
|
pump_id=pump_id,
|
|
|
|
|
error=str(e)
|
|
|
|
|
)
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
|
|
|
detail=f"Failed to update setpoint for {station_id}/{pump_id}"
|
|
|
|
|
)
|
|
|
|
|
|
2025-10-27 09:29:27 +00:00
|
|
|
@self.app.post(
|
|
|
|
|
"/api/v1/emergency-stop",
|
|
|
|
|
summary="Trigger Emergency Stop",
|
|
|
|
|
tags=["Emergency Stop"],
|
|
|
|
|
status_code=status.HTTP_201_CREATED
|
|
|
|
|
)
|
|
|
|
|
async def trigger_emergency_stop(
|
|
|
|
|
request: EmergencyStopRequest,
|
2025-10-27 20:07:37 +00:00
|
|
|
token_data: TokenData = Depends(require_permission("emergency_stop")),
|
|
|
|
|
security_manager: SecurityManager = Depends(get_security_manager)
|
2025-10-27 09:29:27 +00:00
|
|
|
):
|
|
|
|
|
"""
|
|
|
|
|
Trigger emergency stop ("big red button").
|
|
|
|
|
|
|
|
|
|
Scope:
|
|
|
|
|
- If station_id and pump_id provided: Stop single pump
|
|
|
|
|
- If station_id only: Stop all pumps at station
|
|
|
|
|
- If neither: Stop ALL pumps system-wide
|
2025-10-27 20:07:37 +00:00
|
|
|
|
|
|
|
|
Requires permission: emergency_stop
|
2025-10-27 09:29:27 +00:00
|
|
|
"""
|
|
|
|
|
try:
|
2025-10-27 20:07:37 +00:00
|
|
|
# Check station access if station_id is provided
|
|
|
|
|
if request.station_id and not security_manager.can_access_station(token_data, request.station_id):
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
status_code=status.HTTP_403_FORBIDDEN,
|
|
|
|
|
detail=f"Access denied to station {request.station_id}"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# Use authenticated user as triggered_by
|
|
|
|
|
triggered_by = token_data.username
|
|
|
|
|
|
2025-10-27 09:29:27 +00:00
|
|
|
if request.station_id and request.pump_id:
|
|
|
|
|
# Single pump stop
|
|
|
|
|
result = self.emergency_stop_manager.emergency_stop_pump(
|
|
|
|
|
station_id=request.station_id,
|
|
|
|
|
pump_id=request.pump_id,
|
|
|
|
|
reason=request.reason,
|
2025-10-27 20:07:37 +00:00
|
|
|
user_id=triggered_by
|
2025-10-27 09:29:27 +00:00
|
|
|
)
|
|
|
|
|
scope = f"pump {request.station_id}/{request.pump_id}"
|
|
|
|
|
elif request.station_id:
|
|
|
|
|
# Station-wide stop
|
|
|
|
|
result = self.emergency_stop_manager.emergency_stop_station(
|
|
|
|
|
station_id=request.station_id,
|
|
|
|
|
reason=request.reason,
|
2025-10-27 20:07:37 +00:00
|
|
|
user_id=triggered_by
|
2025-10-27 09:29:27 +00:00
|
|
|
)
|
|
|
|
|
scope = f"station {request.station_id}"
|
|
|
|
|
else:
|
|
|
|
|
# System-wide stop
|
|
|
|
|
result = self.emergency_stop_manager.emergency_stop_system(
|
|
|
|
|
reason=request.reason,
|
2025-10-27 20:07:37 +00:00
|
|
|
user_id=triggered_by
|
2025-10-27 09:29:27 +00:00
|
|
|
)
|
|
|
|
|
scope = "system-wide"
|
|
|
|
|
|
|
|
|
|
if result:
|
|
|
|
|
return {
|
|
|
|
|
"status": "emergency_stop_triggered",
|
|
|
|
|
"scope": scope,
|
|
|
|
|
"reason": request.reason,
|
2025-10-27 20:07:37 +00:00
|
|
|
"triggered_by": triggered_by,
|
2025-10-27 09:29:27 +00:00
|
|
|
"timestamp": datetime.now().isoformat()
|
|
|
|
|
}
|
|
|
|
|
else:
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
|
|
|
detail="Failed to trigger emergency stop"
|
|
|
|
|
)
|
|
|
|
|
|
2025-10-27 20:07:37 +00:00
|
|
|
except HTTPException:
|
|
|
|
|
raise
|
2025-10-27 09:29:27 +00:00
|
|
|
except Exception as e:
|
|
|
|
|
logger.error("failed_to_trigger_emergency_stop", error=str(e))
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
|
|
|
detail="Failed to trigger emergency stop"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
@self.app.post(
|
|
|
|
|
"/api/v1/emergency-stop/clear",
|
|
|
|
|
summary="Clear Emergency Stop",
|
|
|
|
|
tags=["Emergency Stop"]
|
|
|
|
|
)
|
|
|
|
|
async def clear_emergency_stop(
|
|
|
|
|
request: EmergencyStopClearRequest,
|
2025-10-27 20:07:37 +00:00
|
|
|
token_data: TokenData = Depends(require_permission("clear_emergency_stop"))
|
2025-10-27 09:29:27 +00:00
|
|
|
):
|
2025-10-27 20:07:37 +00:00
|
|
|
"""
|
|
|
|
|
Clear all active emergency stops.
|
|
|
|
|
|
|
|
|
|
Requires permission: clear_emergency_stop
|
|
|
|
|
"""
|
2025-10-27 09:29:27 +00:00
|
|
|
try:
|
2025-10-27 20:07:37 +00:00
|
|
|
# Use authenticated user as cleared_by
|
|
|
|
|
cleared_by = token_data.username
|
|
|
|
|
|
2025-10-27 09:29:27 +00:00
|
|
|
# Clear system-wide emergency stop
|
|
|
|
|
self.emergency_stop_manager.clear_emergency_stop_system(
|
|
|
|
|
reason=request.notes,
|
2025-10-27 20:07:37 +00:00
|
|
|
user_id=cleared_by
|
2025-10-27 09:29:27 +00:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
"status": "emergency_stop_cleared",
|
2025-10-27 20:07:37 +00:00
|
|
|
"cleared_by": cleared_by,
|
2025-10-27 09:29:27 +00:00
|
|
|
"notes": request.notes,
|
|
|
|
|
"timestamp": datetime.now().isoformat()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
logger.error("failed_to_clear_emergency_stop", error=str(e))
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
|
|
|
detail="Failed to clear emergency stop"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
@self.app.get(
|
|
|
|
|
"/api/v1/emergency-stop/status",
|
|
|
|
|
summary="Get Emergency Stop Status",
|
|
|
|
|
tags=["Emergency Stop"]
|
|
|
|
|
)
|
|
|
|
|
async def get_emergency_stop_status(
|
2025-10-27 20:07:37 +00:00
|
|
|
token_data: TokenData = Depends(require_permission("read_safety_status"))
|
2025-10-27 09:29:27 +00:00
|
|
|
):
|
2025-10-27 20:07:37 +00:00
|
|
|
"""
|
|
|
|
|
Check if any emergency stops are active.
|
|
|
|
|
|
|
|
|
|
Requires permission: read_safety_status
|
|
|
|
|
"""
|
2025-10-27 09:29:27 +00:00
|
|
|
try:
|
|
|
|
|
# Check system-wide emergency stop
|
|
|
|
|
system_stop = self.emergency_stop_manager.system_emergency_stop
|
|
|
|
|
|
|
|
|
|
# Count station and pump stops
|
|
|
|
|
station_stops = len(self.emergency_stop_manager.emergency_stop_stations)
|
|
|
|
|
pump_stops = len(self.emergency_stop_manager.emergency_stop_pumps)
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
"system_emergency_stop": system_stop,
|
|
|
|
|
"station_emergency_stops": station_stops,
|
|
|
|
|
"pump_emergency_stops": pump_stops,
|
|
|
|
|
"any_active": system_stop or station_stops > 0 or pump_stops > 0,
|
|
|
|
|
"timestamp": datetime.now().isoformat()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
logger.error("failed_to_get_emergency_stop_status", error=str(e))
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
|
|
|
detail="Failed to retrieve emergency stop status"
|
|
|
|
|
)
|
Phase 5: Complete Protocol Server Security Enhancement
- Enhanced OPC UA Server with certificate-based authentication, RBAC, and security event logging
- Enhanced Modbus TCP Server with IP-based access control, rate limiting, and security monitoring
- Completed REST API security integration with setpoint write operations and security status endpoint
- Created comprehensive protocol security integration tests (8/8 tests passing)
- All 197 tests passing across the entire codebase
Security Features Implemented:
- OPC UA: Certificate authentication, client tracking, RBAC node access control
- Modbus TCP: IP filtering, rate limiting, security monitoring, security registers
- REST API: Setpoint write operations with authorization, security status endpoint
- Cross-protocol: Shared security manager and audit logger integration
2025-10-27 20:59:19 +00:00
|
|
|
|
|
|
|
|
@self.app.get(
|
|
|
|
|
"/api/v1/security/status",
|
|
|
|
|
summary="Get Security Status",
|
|
|
|
|
tags=["Security"]
|
|
|
|
|
)
|
|
|
|
|
async def get_security_status(
|
|
|
|
|
token_data: TokenData = Depends(require_permission("read_security_status"))
|
|
|
|
|
):
|
|
|
|
|
"""
|
|
|
|
|
Get current security status and configuration.
|
|
|
|
|
|
|
|
|
|
Requires permission: read_security_status
|
|
|
|
|
"""
|
|
|
|
|
try:
|
|
|
|
|
security_manager = get_security_manager()
|
|
|
|
|
tls_manager = get_tls_manager()
|
|
|
|
|
|
|
|
|
|
# Get security status
|
|
|
|
|
auth_status = security_manager.get_security_status()
|
|
|
|
|
tls_status = tls_manager.get_tls_status()
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
"authentication": auth_status,
|
|
|
|
|
"tls": tls_status,
|
|
|
|
|
"timestamp": datetime.now().isoformat()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
logger.error("failed_to_get_security_status", error=str(e))
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
|
|
|
detail="Failed to retrieve security status"
|
|
|
|
|
)
|
2025-10-27 09:29:27 +00:00
|
|
|
|
|
|
|
|
async def start(self):
|
|
|
|
|
"""Start the REST API server."""
|
|
|
|
|
import uvicorn
|
|
|
|
|
|
2025-10-27 20:07:37 +00:00
|
|
|
# Get TLS configuration
|
|
|
|
|
tls_manager = get_tls_manager()
|
|
|
|
|
ssl_config = tls_manager.get_rest_api_ssl_config()
|
|
|
|
|
|
2025-10-27 09:29:27 +00:00
|
|
|
logger.info(
|
|
|
|
|
"rest_api_server_starting",
|
|
|
|
|
host=self.host,
|
|
|
|
|
port=self.port,
|
2025-10-27 20:07:37 +00:00
|
|
|
tls_enabled=ssl_config is not None
|
2025-10-27 09:29:27 +00:00
|
|
|
)
|
2025-10-27 20:07:37 +00:00
|
|
|
|
|
|
|
|
config_kwargs = {
|
|
|
|
|
"app": self.app,
|
|
|
|
|
"host": self.host,
|
|
|
|
|
"port": self.port,
|
|
|
|
|
"log_level": "info"
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
# Add SSL configuration if available
|
|
|
|
|
if ssl_config:
|
|
|
|
|
certfile, keyfile = ssl_config
|
|
|
|
|
config_kwargs.update({
|
|
|
|
|
"ssl_certfile": certfile,
|
|
|
|
|
"ssl_keyfile": keyfile
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
config = uvicorn.Config(**config_kwargs)
|
2025-10-27 09:29:27 +00:00
|
|
|
server = uvicorn.Server(config)
|
|
|
|
|
await server.serve()
|
|
|
|
|
|
|
|
|
|
async def stop(self):
|
|
|
|
|
"""Stop the REST API server."""
|
|
|
|
|
logger.info("rest_api_server_stopping")
|