Initial commit

This commit is contained in:
zsh 2025-12-10 12:02:17 +08:00
commit 47c18e03fe
27445 changed files with 3341806 additions and 0 deletions

38
README.md Normal file
View File

@ -0,0 +1,38 @@
# AWS EC2 Panel (FastAPI + Vue 3)
Minimal multi-tenant EC2 管理面板示例,后端使用 FastAPI/SQLAlchemy前端使用 Vue 3 + Vite + TypeScript + Naive UI + Tailwind。
## 数据库初始化
1. 确认 MySQL 8 已安装并创建空库:
```sql
SOURCE db_schema.md;
```
2. 准备 `backend/.env`(复制 `backend/.env.example` 并修改 DB_URL/JWT_SECRET 等)。
## 启动后端
```bash
cd backend
python -m venv .venv && source .venv/bin/activate
pip install -U pip
pip install fastapi uvicorn[standard] sqlalchemy[asyncio] asyncmy pydantic pydantic-settings python-jose passlib[bcrypt] boto3
uvicorn backend.app:app --reload --host 0.0.0.0 --port 8000
```
健康检查:`GET http://localhost:8000/healthz`
元数据接口示例:
- 区域列表:`GET /api/v1/instances/meta/aws/regions`
- VPC/子网/安全组:`GET /api/v1/instances/meta/aws/network?credential_id=1&region=ap-northeast-1`
- KeyPair 列表:`GET /api/v1/instances/meta/aws/keypairs?credential_id=1&region=ap-northeast-1`
## 启动前端
```bash
cd frontend
npm install
npm run dev
```
默认前端开发服务器使用 `/api` 代理到后端(在 `vite.config.ts` 可调整)。
## 目录速览
- `backend/`FastAPI 应用、路由、SQLAlchemy 模型、AWS 封装。
- `frontend/`Vue 3 + Vite + Naive UI 前端。
- `db_schema.md`MySQL 表结构(务必保持字段/表名一致)。

6
backend/.env Normal file
View File

@ -0,0 +1,6 @@
DB_URL=mysql+asyncmy://aws_ec2_panel:74110ZSH@localhost:3306
JWT_SECRET=change-me
JWT_EXPIRE_MINUTES=720
AWS_PROXY_URL=
AWS_TIMEOUT=30
AUTO_OPEN_SECURITY_GROUP_ENABLED=true

6
backend/.env.example Normal file
View File

@ -0,0 +1,6 @@
DB_URL=mysql+asyncmy://root:74110ZSH@localhost:3306/aws_ec2_panel?charset=utf8mb4
JWT_SECRET=change-me
JWT_EXPIRE_MINUTES=720
AWS_PROXY_URL=
AWS_TIMEOUT=30
AUTO_OPEN_SECURITY_GROUP_ENABLED=true

1
backend/__init__.py Normal file
View File

@ -0,0 +1 @@
# aws_ec2_panel backend package initializer

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

1
backend/api/__init__.py Normal file
View File

@ -0,0 +1 @@
# API dependencies package

Binary file not shown.

Binary file not shown.

63
backend/api/deps.py Normal file
View File

@ -0,0 +1,63 @@
from dataclasses import dataclass
from typing import Iterable, Optional
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from backend.core.security import decode_token
from backend.db.session import get_session
from backend.modules.users.models import RoleName, User
security = HTTPBearer(auto_error=False)
@dataclass
class AuthUser:
user: User
role_name: str
customer_id: Optional[int]
token: str
async def get_current_user(
credentials: HTTPAuthorizationCredentials = Depends(security),
session: AsyncSession = Depends(get_session),
) -> AuthUser:
if credentials is None:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Not authenticated")
try:
payload = decode_token(credentials.credentials)
except ValueError:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token")
user_id = payload.get("sub")
if not user_id:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token payload")
user = await session.scalar(
select(User).where(User.id == int(user_id)).options(selectinload(User.role), selectinload(User.customer))
)
if not user:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found")
if not user.is_active:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="User disabled")
role_name = payload.get("role") or (user.role.name if user.role else "")
return AuthUser(user=user, role_name=role_name, customer_id=user.customer_id, token=credentials.credentials)
def require_roles(roles: Iterable[RoleName]):
allowed = {r.value for r in roles}
async def dependency(auth_user: AuthUser = Depends(get_current_user)) -> AuthUser:
if auth_user.role_name not in allowed:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Insufficient role")
return auth_user
return dependency
def require_admin(auth_user: AuthUser = Depends(require_roles([RoleName.ADMIN]))) -> AuthUser:
return auth_user

45
backend/app.py Normal file
View File

@ -0,0 +1,45 @@
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
# Ensure models are imported so metadata is populated
import backend.db.import_all_models # noqa: F401
from backend.modules.auth.router import router as auth_router
from backend.modules.users.router import router as users_router
from backend.modules.customers.router import router as customers_router
from backend.modules.aws_accounts.router import router as aws_credentials_router
from backend.modules.instances.router import router as instances_router
from backend.modules.jobs.router import router as jobs_router
from backend.modules.audit.router import router as audit_router
from backend.modules.settings.router import router as settings_router
from backend.modules.menus.router import router as menus_router
from backend.modules.instances.service import start_global_sync_scheduler
app = FastAPI(title="AWS EC2 Panel", version="0.2.0")
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_methods=["*"],
allow_headers=["*"],
)
@app.get("/healthz")
async def healthz():
return {"status": "ok"}
app.include_router(auth_router)
app.include_router(users_router)
app.include_router(customers_router)
app.include_router(aws_credentials_router)
app.include_router(instances_router)
app.include_router(jobs_router)
app.include_router(audit_router)
app.include_router(settings_router)
app.include_router(menus_router)
@app.on_event("startup")
async def _start_background_tasks():
start_global_sync_scheduler()

1
backend/auth/__init__.py Normal file
View File

@ -0,0 +1 @@
# Authentication package for aws_ec2_panel backend

32
backend/auth/jwt_utils.py Normal file
View File

@ -0,0 +1,32 @@
from datetime import datetime, timedelta, timezone
from typing import Any, Dict, Optional
from jose import JWTError, jwt
from passlib.context import CryptContext
from ..config import settings
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
def verify_password(plain_password: str, hashed_password: str) -> bool:
return pwd_context.verify(plain_password, hashed_password)
def get_password_hash(password: str) -> str:
return pwd_context.hash(password)
def create_access_token(data: Dict[str, Any], expires_delta: Optional[timedelta] = None) -> str:
to_encode = data.copy()
expire = datetime.now(timezone.utc) + (expires_delta or timedelta(minutes=settings.jwt_expire_minutes))
to_encode.update({"exp": expire})
return jwt.encode(to_encode, settings.jwt_secret, algorithm=settings.jwt_algorithm)
def decode_token(token: str) -> Dict[str, Any]:
try:
payload = jwt.decode(token, settings.jwt_secret, algorithms=[settings.jwt_algorithm])
return payload
except JWTError as exc:
raise ValueError("Invalid token") from exc

63
backend/auth/router.py Normal file
View File

@ -0,0 +1,63 @@
from datetime import datetime, timezone
from fastapi import APIRouter, Depends, HTTPException, Request, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from ..db import get_session
from ..dependencies import AuthUser, get_current_user
from ..models import AuditAction, AuditLog, AuditResourceType, User
from ..schemas import LoginRequest, TokenResponse, UserOut
from .jwt_utils import create_access_token, verify_password
router = APIRouter(prefix="/api/v1/auth", tags=["auth"])
@router.post("/login", response_model=TokenResponse)
async def login(payload: LoginRequest, request: Request, session: AsyncSession = Depends(get_session)) -> TokenResponse:
user = await session.scalar(
select(User).where(User.username == payload.username).options(selectinload(User.role), selectinload(User.customer))
)
if not user or not verify_password(payload.password, user.password_hash):
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials")
if not user.is_active:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="User disabled")
user.last_login_at = datetime.now(timezone.utc)
await session.commit()
await session.refresh(user)
token_payload = {"sub": str(user.id), "role": user.role.name if user.role else "", "customer_id": user.customer_id}
access_token = create_access_token(token_payload)
audit = AuditLog(
user_id=user.id,
customer_id=user.customer_id,
action=AuditAction.LOGIN,
resource_type=AuditResourceType.USER,
resource_id=user.id,
description="User login success",
ip_address=request.client.host if request.client else None,
user_agent=request.headers.get("User-Agent"),
)
session.add(audit)
await session.commit()
return TokenResponse(access_token=access_token, user=UserOut.model_validate(user))
@router.get("/me", response_model=UserOut)
async def read_me(auth_user: AuthUser = Depends(get_current_user)) -> UserOut:
return UserOut.model_validate(auth_user.user)
@router.post("/refresh", response_model=TokenResponse)
async def refresh_token(auth_user: AuthUser = Depends(get_current_user)) -> TokenResponse:
token_payload = {
"sub": str(auth_user.user.id),
"role": auth_user.role_name,
"customer_id": auth_user.customer_id,
}
access_token = create_access_token(token_payload)
return TokenResponse(access_token=access_token, user=UserOut.model_validate(auth_user.user))

135
backend/aws_ops.py Normal file
View File

@ -0,0 +1,135 @@
from __future__ import annotations
from typing import Any, Dict, List, Optional
import boto3
from botocore.config import Config as BotoConfig
from .config import settings
from .models import AWSCredential, CredentialType
def _boto_config() -> BotoConfig:
proxies = None
if settings.aws_proxy_url:
proxies = {"https": settings.aws_proxy_url, "http": settings.aws_proxy_url}
return BotoConfig(
connect_timeout=settings.aws_timeout,
read_timeout=settings.aws_timeout,
proxies=proxies,
)
def build_session(credential: AWSCredential, region: str):
cfg = _boto_config()
if credential.credential_type == CredentialType.ACCESS_KEY:
session = boto3.Session(
aws_access_key_id=credential.access_key_id,
aws_secret_access_key=credential.secret_access_key,
region_name=region or credential.default_region,
)
return session, cfg
# Assume role path
base_session = boto3.Session(
aws_access_key_id=credential.access_key_id,
aws_secret_access_key=credential.secret_access_key,
region_name=region or credential.default_region,
)
sts = base_session.client("sts", config=cfg, region_name=region or credential.default_region)
assume_kwargs: Dict[str, Any] = {
"RoleArn": credential.role_arn,
"RoleSessionName": "ec2-panel",
}
if credential.external_id:
assume_kwargs["ExternalId"] = credential.external_id
resp = sts.assume_role(**assume_kwargs)
creds = resp["Credentials"]
session = boto3.Session(
aws_access_key_id=creds["AccessKeyId"],
aws_secret_access_key=creds["SecretAccessKey"],
aws_session_token=creds["SessionToken"],
region_name=region or credential.default_region,
)
return session, cfg
def describe_instances(
credential: AWSCredential,
region: str,
filters: Optional[List[Dict[str, Any]]] = None,
instance_ids: Optional[List[str]] = None,
) -> Dict[str, Any]:
session, cfg = build_session(credential, region)
client = session.client("ec2", region_name=region or credential.default_region, config=cfg)
params: Dict[str, Any] = {}
if filters:
params["Filters"] = filters
if instance_ids:
params["InstanceIds"] = instance_ids
return client.describe_instances(**params)
def describe_instance_status(
credential: AWSCredential, region: str, instance_ids: List[str]
) -> Dict[str, Any]:
session, cfg = build_session(credential, region)
client = session.client("ec2", region_name=region or credential.default_region, config=cfg)
return client.describe_instance_status(InstanceIds=instance_ids, IncludeAllInstances=True)
def run_instances(
credential: AWSCredential,
region: str,
ami_id: str,
instance_type: str,
key_name: Optional[str],
security_groups: Optional[List[str]],
subnet_id: Optional[str],
min_count: int = 1,
max_count: int = 1,
name_tag: Optional[str] = None,
) -> Dict[str, Any]:
session, cfg = build_session(credential, region)
client = session.client("ec2", region_name=region or credential.default_region, config=cfg)
params: Dict[str, Any] = {
"ImageId": ami_id,
"InstanceType": instance_type,
"MinCount": min_count,
"MaxCount": max_count,
}
if key_name:
params["KeyName"] = key_name
if security_groups:
params["SecurityGroupIds"] = security_groups
if subnet_id:
params["SubnetId"] = subnet_id
if name_tag:
params["TagSpecifications"] = [
{"ResourceType": "instance", "Tags": [{"Key": "Name", "Value": name_tag}]}
]
return client.run_instances(**params)
def start_instances(credential: AWSCredential, region: str, instance_ids: List[str]) -> Dict[str, Any]:
session, cfg = build_session(credential, region)
client = session.client("ec2", region_name=region or credential.default_region, config=cfg)
return client.start_instances(InstanceIds=instance_ids)
def stop_instances(credential: AWSCredential, region: str, instance_ids: List[str]) -> Dict[str, Any]:
session, cfg = build_session(credential, region)
client = session.client("ec2", region_name=region or credential.default_region, config=cfg)
return client.stop_instances(InstanceIds=instance_ids)
def reboot_instances(credential: AWSCredential, region: str, instance_ids: List[str]) -> Dict[str, Any]:
session, cfg = build_session(credential, region)
client = session.client("ec2", region_name=region or credential.default_region, config=cfg)
return client.reboot_instances(InstanceIds=instance_ids)
def terminate_instances(credential: AWSCredential, region: str, instance_ids: List[str]) -> Dict[str, Any]:
session, cfg = build_session(credential, region)
client = session.client("ec2", region_name=region or credential.default_region, config=cfg)
return client.terminate_instances(InstanceIds=instance_ids)

27
backend/config.py Normal file
View File

@ -0,0 +1,27 @@
from functools import lru_cache
from typing import Optional
from pydantic import Field
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
db_url: str = Field(
"mysql+asyncmy://root:password@localhost:3306/aws_ec2_panel?charset=utf8mb4",
description="SQLAlchemy database URL",
)
jwt_secret: str = Field("change-me", description="Secret used to sign JWT tokens")
jwt_expire_minutes: int = Field(60, description="JWT expiration in minutes")
jwt_algorithm: str = Field("HS256", description="JWT signing algorithm")
aws_proxy_url: Optional[str] = Field(default=None, description="Optional proxy URL for AWS SDK")
aws_timeout: int = Field(30, description="Timeout seconds for AWS API calls")
model_config = SettingsConfigDict(env_file=".env", env_prefix="", extra="ignore")
@lru_cache(maxsize=1)
def get_settings() -> Settings:
return Settings()
settings = get_settings()

1
backend/core/__init__.py Normal file
View File

@ -0,0 +1 @@
# Core package initializer

Binary file not shown.

Binary file not shown.

Binary file not shown.

30
backend/core/config.py Normal file
View File

@ -0,0 +1,30 @@
from functools import lru_cache
from typing import Optional
from pydantic import Field
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
db_url: str = Field(
"mysql+asyncmy://root:password@localhost:3306/aws_ec2_panel?charset=utf8mb4", description="SQLAlchemy DB URL"
)
jwt_secret: str = Field("change-me", description="JWT secret key")
jwt_algorithm: str = Field("HS256", description="JWT algorithm")
jwt_expire_minutes: int = Field(60, description="JWT expiry in minutes")
aws_proxy_url: Optional[str] = Field(default=None, description="Optional proxy for AWS SDK")
aws_timeout: int = Field(30, description="AWS SDK timeout seconds")
auto_open_sg_enabled: bool = Field(True, description="Auto-create/use open-all security group when none provided")
scope_sync_min_interval_seconds: int = Field(15, description="Min interval between scope sync enqueue")
global_sync_interval_minutes: int = Field(5, description="Interval minutes for background global sync")
global_sync_max_concurrency: int = Field(3, description="Max concurrent scope sync tasks")
model_config = SettingsConfigDict(env_file=("backend/.env", ".env"), env_prefix="", extra="ignore")
@lru_cache(maxsize=1)
def get_settings() -> Settings:
return Settings()
settings = get_settings()

8
backend/core/logging.py Normal file
View File

@ -0,0 +1,8 @@
import logging
def configure_logging(level: int = logging.INFO) -> None:
logging.basicConfig(
level=level,
format="%(asctime)s [%(levelname)s] %(name)s - %(message)s",
)

32
backend/core/security.py Normal file
View File

@ -0,0 +1,32 @@
from datetime import datetime, timedelta, timezone
from typing import Any, Dict, Optional
from jose import JWTError, jwt
from passlib.context import CryptContext
from backend.core.config import settings
# Use pbkdf2_sha256 to avoid native bcrypt backend issues across platforms.
pwd_context = CryptContext(schemes=["pbkdf2_sha256"], deprecated="auto")
def verify_password(plain_password: str, hashed_password: str) -> bool:
return pwd_context.verify(plain_password, hashed_password)
def get_password_hash(password: str) -> str:
return pwd_context.hash(password)
def create_access_token(data: Dict[str, Any], expires_delta: Optional[timedelta] = None) -> str:
to_encode = data.copy()
expire = datetime.now(timezone.utc) + (expires_delta or timedelta(minutes=settings.jwt_expire_minutes))
to_encode.update({"exp": expire})
return jwt.encode(to_encode, settings.jwt_secret, algorithm=settings.jwt_algorithm)
def decode_token(token: str) -> Dict[str, Any]:
try:
return jwt.decode(token, settings.jwt_secret, algorithms=[settings.jwt_algorithm])
except JWTError as exc:
raise ValueError("Invalid token") from exc

17
backend/db.py Normal file
View File

@ -0,0 +1,17 @@
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from .config import settings
from .models import Base
engine = create_async_engine(settings.db_url, echo=False, pool_pre_ping=True, future=True)
SessionLocal = async_sessionmaker(engine, expire_on_commit=False, class_=AsyncSession)
async def get_session() -> AsyncSession:
async with SessionLocal() as session:
yield session
async def init_db() -> None:
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)

1
backend/db/__init__.py Normal file
View File

@ -0,0 +1 @@
# Database package initializer

Binary file not shown.

Binary file not shown.

Binary file not shown.

6
backend/db/base.py Normal file
View File

@ -0,0 +1,6 @@
from sqlalchemy.orm import DeclarativeBase
class Base(DeclarativeBase):
"""Declarative base for all ORM models."""

View File

@ -0,0 +1,12 @@
"""
Import all ORM models so Base.metadata has every table registered.
This module should be imported before calling metadata.create_all or running Alembic autogenerate.
"""
from backend.modules.users.models import Role, User # noqa: F401
from backend.modules.customers.models import Customer # noqa: F401
from backend.modules.aws_accounts.models import AWSCredential, CustomerCredential # noqa: F401
from backend.modules.instances.models import Instance # noqa: F401
from backend.modules.jobs.models import Job, JobItem # noqa: F401
from backend.modules.audit.models import AuditLog # noqa: F401
from backend.modules.settings.models import Setting # noqa: F401

53
backend/db/init_admin.py Normal file
View File

@ -0,0 +1,53 @@
import asyncio
import sys
from pathlib import Path
from sqlalchemy import select
from sqlalchemy.exc import IntegrityError
ROOT_DIR = Path(__file__).resolve().parents[1]
if str(ROOT_DIR.parent) not in sys.path:
sys.path.insert(0, str(ROOT_DIR.parent))
from backend.core.security import get_password_hash # noqa: E402
from backend.db import import_all_models # noqa: F401,E402
from backend.db.session import AsyncSessionLocal # noqa: E402
from backend.modules.users.models import Role, RoleName, User # noqa: E402
async def ensure_roles(session):
existing = {r.name for r in (await session.scalars(select(Role))).all()}
for name in [RoleName.ADMIN.value, RoleName.CUSTOMER_ADMIN.value, RoleName.CUSTOMER_USER.value]:
if name not in existing:
session.add(Role(name=name, description=f"{name} role"))
await session.commit()
async def ensure_admin(username: str, password: str):
async with AsyncSessionLocal() as session:
await ensure_roles(session)
role = await session.scalar(select(Role).where(Role.name == RoleName.ADMIN.value))
user = await session.scalar(select(User).where(User.username == username))
if user:
print(f"User {username} already exists (id={user.id})")
return
admin = User(
username=username,
email=None,
password_hash=get_password_hash(password),
role_id=role.id,
customer_id=None,
is_active=1,
)
session.add(admin)
try:
await session.commit()
await session.refresh(admin)
print(f"Created admin user {username} with id={admin.id}")
except IntegrityError:
await session.rollback()
print("Failed to create admin (maybe already exists)")
if __name__ == "__main__":
asyncio.run(ensure_admin("admin", "Admin@123"))

18
backend/db/session.py Normal file
View File

@ -0,0 +1,18 @@
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from backend.core.config import settings
from backend.db.base import Base
engine = create_async_engine(settings.db_url, echo=False, pool_pre_ping=True, future=True)
AsyncSessionLocal = async_sessionmaker(bind=engine, expire_on_commit=False, class_=AsyncSession)
async def get_session() -> AsyncSession:
async with AsyncSessionLocal() as session:
yield session
async def init_models() -> None:
"""Create all tables based on imported models."""
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)

72
backend/dependencies.py Normal file
View File

@ -0,0 +1,72 @@
from dataclasses import dataclass
from typing import Iterable, Optional
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from .auth.jwt_utils import decode_token
from .db import get_session
from .models import RoleName, User
security = HTTPBearer(auto_error=False)
@dataclass
class AuthUser:
user: User
role_name: str
customer_id: Optional[int]
token: str
async def get_current_user(
credentials: HTTPAuthorizationCredentials = Depends(security),
session: AsyncSession = Depends(get_session),
) -> AuthUser:
if credentials is None:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Not authenticated")
try:
payload = decode_token(credentials.credentials)
except ValueError:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token")
user_id = payload.get("sub")
if not user_id:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token payload")
user = await session.scalar(
select(User)
.where(User.id == int(user_id))
.options(selectinload(User.role), selectinload(User.customer))
)
if not user:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found")
if not user.is_active:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="User disabled")
role_name = payload.get("role") or (user.role.name if user.role else "")
customer_id = (
payload.get("customer_id")
or user.customer_id
or (user.customer.id if user.customer is not None else None)
)
return AuthUser(user=user, role_name=role_name, customer_id=customer_id, token=credentials.credentials)
def require_roles(roles: Iterable[RoleName]):
allowed = {r.value for r in roles}
async def dependency(auth_user: AuthUser = Depends(get_current_user)) -> AuthUser:
if auth_user.role_name not in allowed:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Insufficient role")
return auth_user
return dependency
def require_admin(auth_user: AuthUser = Depends(require_roles([RoleName.ADMIN]))) -> AuthUser:
return auth_user

29
backend/main.py Normal file
View File

@ -0,0 +1,29 @@
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from .auth import router as auth_router
from .routers import audit_logs, aws_credentials, customers, instances, jobs, menus, users
app = FastAPI(title="AWS EC2 Panel", version="0.1.0")
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_methods=["*"],
allow_headers=["*"],
)
@app.get("/healthz")
async def healthz():
return {"status": "ok"}
app.include_router(auth_router.router)
app.include_router(users.router)
app.include_router(customers.router)
app.include_router(aws_credentials.router)
app.include_router(instances.router)
app.include_router(jobs.router)
app.include_router(audit_logs.router)
app.include_router(menus.router)

406
backend/models.py Normal file
View File

@ -0,0 +1,406 @@
from __future__ import annotations
from datetime import datetime
from enum import Enum
from typing import Optional
from sqlalchemy import (
DateTime,
Enum as SAEnum,
ForeignKey,
Index,
JSON,
String,
Text,
UniqueConstraint,
text,
)
from sqlalchemy.dialects.mysql import BIGINT, INTEGER, TINYINT
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
class Base(DeclarativeBase):
"""Base declarative class for all models."""
# Enumerations centralized to avoid magic strings across the codebase.
class CredentialType(str, Enum):
ACCESS_KEY = "ACCESS_KEY"
ASSUME_ROLE = "ASSUME_ROLE"
class RoleName(str, Enum):
ADMIN = "ADMIN"
CUSTOMER_ADMIN = "CUSTOMER_ADMIN"
CUSTOMER_USER = "CUSTOMER_USER"
class InstanceStatus(str, Enum):
PENDING = "PENDING"
RUNNING = "RUNNING"
STOPPING = "STOPPING"
STOPPED = "STOPPED"
SHUTTING_DOWN = "SHUTTING_DOWN"
TERMINATED = "TERMINATED"
UNKNOWN = "UNKNOWN"
class InstanceDesiredStatus(str, Enum):
RUNNING = "RUNNING"
STOPPED = "STOPPED"
TERMINATED = "TERMINATED"
class JobType(str, Enum):
SYNC_INSTANCES = "SYNC_INSTANCES"
START_INSTANCES = "START_INSTANCES"
STOP_INSTANCES = "STOP_INSTANCES"
REBOOT_INSTANCES = "REBOOT_INSTANCES"
TERMINATE_INSTANCES = "TERMINATE_INSTANCES"
CREATE_INSTANCES = "CREATE_INSTANCES"
class JobStatus(str, Enum):
PENDING = "PENDING"
RUNNING = "RUNNING"
SUCCESS = "SUCCESS"
FAILED = "FAILED"
class JobItemResourceType(str, Enum):
INSTANCE = "INSTANCE"
OTHER = "OTHER"
class JobItemAction(str, Enum):
CREATE = "CREATE"
START = "START"
STOP = "STOP"
REBOOT = "REBOOT"
TERMINATE = "TERMINATE"
SYNC = "SYNC"
class JobItemStatus(str, Enum):
PENDING = "PENDING"
RUNNING = "RUNNING"
SUCCESS = "SUCCESS"
FAILED = "FAILED"
SKIPPED = "SKIPPED"
class AuditAction(str, Enum):
LOGIN = "LOGIN"
LOGOUT = "LOGOUT"
INSTANCE_CREATE = "INSTANCE_CREATE"
INSTANCE_START = "INSTANCE_START"
INSTANCE_STOP = "INSTANCE_STOP"
INSTANCE_REBOOT = "INSTANCE_REBOOT"
INSTANCE_TERMINATE = "INSTANCE_TERMINATE"
INSTANCE_SYNC = "INSTANCE_SYNC"
CREDENTIAL_CREATE = "CREDENTIAL_CREATE"
CREDENTIAL_UPDATE = "CREDENTIAL_UPDATE"
CREDENTIAL_DELETE = "CREDENTIAL_DELETE"
CUSTOMER_CREATE = "CUSTOMER_CREATE"
CUSTOMER_UPDATE = "CUSTOMER_UPDATE"
CUSTOMER_DELETE = "CUSTOMER_DELETE"
USER_CREATE = "USER_CREATE"
USER_UPDATE = "USER_UPDATE"
USER_DELETE = "USER_DELETE"
OTHER = "OTHER"
class AuditResourceType(str, Enum):
USER = "USER"
CUSTOMER = "CUSTOMER"
AWS_CREDENTIAL = "AWS_CREDENTIAL"
INSTANCE = "INSTANCE"
JOB = "JOB"
OTHER = "OTHER"
class Role(Base):
__tablename__ = "roles"
__table_args__ = (UniqueConstraint("name", name="uniq_role_name"),)
id: Mapped[int] = mapped_column(BIGINT(unsigned=True), primary_key=True, autoincrement=True)
name: Mapped[str] = mapped_column(String(64), nullable=False)
description: Mapped[Optional[str]] = mapped_column(String(255))
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=text("CURRENT_TIMESTAMP"), nullable=False)
updated_at: Mapped[datetime] = mapped_column(
DateTime, server_default=text("CURRENT_TIMESTAMP"), onupdate=text("CURRENT_TIMESTAMP"), nullable=False
)
users: Mapped[list["User"]] = relationship("User", back_populates="role")
class Customer(Base):
__tablename__ = "customers"
__table_args__ = (UniqueConstraint("name", name="uniq_customer_name"),)
id: Mapped[int] = mapped_column(BIGINT(unsigned=True), primary_key=True, autoincrement=True)
name: Mapped[str] = mapped_column(String(128), nullable=False)
contact_email: Mapped[Optional[str]] = mapped_column(String(128))
is_active: Mapped[int] = mapped_column(TINYINT(1), server_default=text("1"), nullable=False)
quota_instances: Mapped[Optional[int]] = mapped_column(INTEGER(unsigned=True))
notes: Mapped[Optional[str]] = mapped_column(String(255))
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=text("CURRENT_TIMESTAMP"), nullable=False)
updated_at: Mapped[datetime] = mapped_column(
DateTime, server_default=text("CURRENT_TIMESTAMP"), onupdate=text("CURRENT_TIMESTAMP"), nullable=False
)
users: Mapped[list["User"]] = relationship("User", back_populates="customer")
instances: Mapped[list["Instance"]] = relationship("Instance", back_populates="customer")
customer_credentials: Mapped[list["CustomerCredential"]] = relationship(
"CustomerCredential", back_populates="customer"
)
jobs: Mapped[list["Job"]] = relationship("Job", back_populates="customer")
audit_logs: Mapped[list["AuditLog"]] = relationship("AuditLog", back_populates="customer")
class User(Base):
__tablename__ = "users"
__table_args__ = (
UniqueConstraint("username", name="uniq_username"),
UniqueConstraint("email", name="uniq_email"),
Index("idx_users_role", "role_id"),
Index("idx_users_customer", "customer_id"),
)
id: Mapped[int] = mapped_column(BIGINT(unsigned=True), primary_key=True, autoincrement=True)
username: Mapped[str] = mapped_column(String(64), nullable=False)
email: Mapped[Optional[str]] = mapped_column(String(128))
password_hash: Mapped[str] = mapped_column(String(255), nullable=False)
role_id: Mapped[int] = mapped_column(ForeignKey("roles.id", ondelete="RESTRICT", onupdate="CASCADE"), nullable=False)
customer_id: Mapped[Optional[int]] = mapped_column(
ForeignKey("customers.id", ondelete="SET NULL", onupdate="CASCADE")
)
is_active: Mapped[int] = mapped_column(TINYINT(1), server_default=text("1"), nullable=False)
last_login_at: Mapped[Optional[datetime]] = mapped_column(DateTime)
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=text("CURRENT_TIMESTAMP"), nullable=False)
updated_at: Mapped[datetime] = mapped_column(
DateTime, server_default=text("CURRENT_TIMESTAMP"), onupdate=text("CURRENT_TIMESTAMP"), nullable=False
)
role: Mapped["Role"] = relationship("Role", back_populates="users")
customer: Mapped[Optional["Customer"]] = relationship("Customer", back_populates="users")
jobs: Mapped[list["Job"]] = relationship("Job", back_populates="created_by_user")
audit_logs: Mapped[list["AuditLog"]] = relationship("AuditLog", back_populates="user")
class AWSCredential(Base):
__tablename__ = "aws_credentials"
__table_args__ = (
UniqueConstraint("account_id", "name", name="uniq_credential_account_name"),
Index("idx_credential_account", "account_id"),
Index("idx_credential_active", "is_active"),
)
id: Mapped[int] = mapped_column(BIGINT(unsigned=True), primary_key=True, autoincrement=True)
name: Mapped[str] = mapped_column(String(128), nullable=False)
account_id: Mapped[str] = mapped_column(String(32), nullable=False)
credential_type: Mapped[CredentialType] = mapped_column(
SAEnum(CredentialType), nullable=False, server_default=text("'ACCESS_KEY'")
)
access_key_id: Mapped[Optional[str]] = mapped_column(String(128))
secret_access_key: Mapped[Optional[str]] = mapped_column(String(256))
role_arn: Mapped[Optional[str]] = mapped_column(String(256))
external_id: Mapped[Optional[str]] = mapped_column(String(128))
default_region: Mapped[str] = mapped_column(String(32), nullable=False, server_default=text("'ap-northeast-1'"))
is_active: Mapped[int] = mapped_column(TINYINT(1), server_default=text("1"), nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=text("CURRENT_TIMESTAMP"), nullable=False)
updated_at: Mapped[datetime] = mapped_column(
DateTime, server_default=text("CURRENT_TIMESTAMP"), onupdate=text("CURRENT_TIMESTAMP"), nullable=False
)
instances: Mapped[list["Instance"]] = relationship("Instance", back_populates="credential")
customer_credentials: Mapped[list["CustomerCredential"]] = relationship(
"CustomerCredential", back_populates="credential"
)
class CustomerCredential(Base):
__tablename__ = "customer_credentials"
__table_args__ = (
UniqueConstraint("customer_id", "credential_id", name="uniq_customer_credential"),
Index("idx_cc_customer", "customer_id"),
Index("idx_cc_credential", "credential_id"),
)
id: Mapped[int] = mapped_column(BIGINT(unsigned=True), primary_key=True, autoincrement=True)
customer_id: Mapped[int] = mapped_column(
ForeignKey("customers.id", ondelete="CASCADE", onupdate="CASCADE"), nullable=False
)
credential_id: Mapped[int] = mapped_column(
ForeignKey("aws_credentials.id", ondelete="CASCADE", onupdate="CASCADE"), nullable=False
)
is_allowed: Mapped[int] = mapped_column(TINYINT(1), server_default=text("1"), nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=text("CURRENT_TIMESTAMP"), nullable=False)
updated_at: Mapped[datetime] = mapped_column(
DateTime, server_default=text("CURRENT_TIMESTAMP"), onupdate=text("CURRENT_TIMESTAMP"), nullable=False
)
customer: Mapped["Customer"] = relationship("Customer", back_populates="customer_credentials")
credential: Mapped["AWSCredential"] = relationship("AWSCredential", back_populates="customer_credentials")
class Instance(Base):
__tablename__ = "instances"
__table_args__ = (
UniqueConstraint("account_id", "region", "instance_id", name="uniq_instance_cloud"),
Index("idx_instances_customer", "customer_id"),
Index("idx_instances_status", "status"),
Index("idx_instances_region", "region"),
Index("idx_instances_last_sync", "last_sync"),
)
id: Mapped[int] = mapped_column(BIGINT(unsigned=True), primary_key=True, autoincrement=True)
customer_id: Mapped[int] = mapped_column(
ForeignKey("customers.id", ondelete="CASCADE", onupdate="CASCADE"), nullable=False
)
credential_id: Mapped[Optional[int]] = mapped_column(
ForeignKey("aws_credentials.id", ondelete="SET NULL", onupdate="CASCADE")
)
account_id: Mapped[str] = mapped_column(String(32), nullable=False)
region: Mapped[str] = mapped_column(String(32), nullable=False)
az: Mapped[Optional[str]] = mapped_column(String(32))
instance_id: Mapped[str] = mapped_column(String(32), nullable=False)
name_tag: Mapped[Optional[str]] = mapped_column(String(255))
instance_type: Mapped[str] = mapped_column(String(64), nullable=False)
ami_id: Mapped[Optional[str]] = mapped_column(String(64))
key_name: Mapped[Optional[str]] = mapped_column(String(128))
public_ip: Mapped[Optional[str]] = mapped_column(String(45))
private_ip: Mapped[Optional[str]] = mapped_column(String(45))
status: Mapped[InstanceStatus] = mapped_column(
SAEnum(InstanceStatus), nullable=False, server_default=text("'UNKNOWN'")
)
desired_status: Mapped[Optional[InstanceDesiredStatus]] = mapped_column(SAEnum(InstanceDesiredStatus))
security_groups: Mapped[Optional[dict]] = mapped_column(JSON)
subnet_id: Mapped[Optional[str]] = mapped_column(String(64))
vpc_id: Mapped[Optional[str]] = mapped_column(String(64))
launched_at: Mapped[Optional[datetime]] = mapped_column(DateTime)
terminated_at: Mapped[Optional[datetime]] = mapped_column(DateTime)
last_sync: Mapped[Optional[datetime]] = mapped_column(DateTime)
last_cloud_state: Mapped[Optional[dict]] = mapped_column(JSON)
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=text("CURRENT_TIMESTAMP"), nullable=False)
updated_at: Mapped[datetime] = mapped_column(
DateTime, server_default=text("CURRENT_TIMESTAMP"), onupdate=text("CURRENT_TIMESTAMP"), nullable=False
)
customer: Mapped["Customer"] = relationship("Customer", back_populates="instances")
credential: Mapped[Optional["AWSCredential"]] = relationship("AWSCredential", back_populates="instances")
job_items: Mapped[list["JobItem"]] = relationship("JobItem", back_populates="instance")
class Job(Base):
__tablename__ = "jobs"
__table_args__ = (
UniqueConstraint("job_uuid", name="uniq_job_uuid"),
Index("idx_jobs_type", "job_type"),
Index("idx_jobs_status", "status"),
Index("idx_jobs_created_at", "created_at"),
)
id: Mapped[int] = mapped_column(BIGINT(unsigned=True), primary_key=True, autoincrement=True)
job_uuid: Mapped[str] = mapped_column(String(32), nullable=False)
job_type: Mapped[JobType] = mapped_column(SAEnum(JobType), nullable=False)
status: Mapped[JobStatus] = mapped_column(SAEnum(JobStatus), nullable=False, server_default=text("'PENDING'"))
progress: Mapped[int] = mapped_column(TINYINT(unsigned=True), nullable=False, server_default=text("0"))
total_count: Mapped[Optional[int]] = mapped_column(INTEGER(unsigned=True), server_default=text("0"))
success_count: Mapped[Optional[int]] = mapped_column(INTEGER(unsigned=True), server_default=text("0"))
fail_count: Mapped[Optional[int]] = mapped_column(INTEGER(unsigned=True), server_default=text("0"))
skipped_count: Mapped[Optional[int]] = mapped_column(INTEGER(unsigned=True), server_default=text("0"))
payload: Mapped[Optional[dict]] = mapped_column(JSON)
error_message: Mapped[Optional[str]] = mapped_column(String(512))
created_by_user_id: Mapped[Optional[int]] = mapped_column(
ForeignKey("users.id", ondelete="SET NULL", onupdate="CASCADE")
)
created_for_customer: Mapped[Optional[int]] = mapped_column(
ForeignKey("customers.id", ondelete="SET NULL", onupdate="CASCADE")
)
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=text("CURRENT_TIMESTAMP"), nullable=False)
started_at: Mapped[Optional[datetime]] = mapped_column(DateTime)
finished_at: Mapped[Optional[datetime]] = mapped_column(DateTime)
updated_at: Mapped[datetime] = mapped_column(
DateTime, server_default=text("CURRENT_TIMESTAMP"), onupdate=text("CURRENT_TIMESTAMP"), nullable=False
)
created_by_user: Mapped[Optional["User"]] = relationship("User", back_populates="jobs")
customer: Mapped[Optional["Customer"]] = relationship("Customer", back_populates="jobs")
items: Mapped[list["JobItem"]] = relationship("JobItem", back_populates="job")
class JobItem(Base):
__tablename__ = "job_items"
__table_args__ = (
Index("idx_job_items_job", "job_id"),
Index("idx_job_items_instance", "resource_id"),
Index("idx_job_items_status", "status"),
)
id: Mapped[int] = mapped_column(BIGINT(unsigned=True), primary_key=True, autoincrement=True)
job_id: Mapped[int] = mapped_column(ForeignKey("jobs.id", ondelete="CASCADE", onupdate="CASCADE"), nullable=False)
resource_type: Mapped[JobItemResourceType] = mapped_column(
SAEnum(JobItemResourceType), nullable=False, server_default=text("'INSTANCE'")
)
resource_id: Mapped[Optional[int]] = mapped_column(
ForeignKey("instances.id", ondelete="SET NULL", onupdate="CASCADE")
)
account_id: Mapped[Optional[str]] = mapped_column(String(32))
region: Mapped[Optional[str]] = mapped_column(String(32))
instance_id: Mapped[Optional[str]] = mapped_column(String(32))
action: Mapped[JobItemAction] = mapped_column(SAEnum(JobItemAction), nullable=False)
status: Mapped[JobItemStatus] = mapped_column(
SAEnum(JobItemStatus), nullable=False, server_default=text("'PENDING'")
)
error_message: Mapped[Optional[str]] = mapped_column(String(512))
extra: Mapped[Optional[dict]] = mapped_column(JSON)
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=text("CURRENT_TIMESTAMP"), nullable=False)
updated_at: Mapped[datetime] = mapped_column(
DateTime, server_default=text("CURRENT_TIMESTAMP"), onupdate=text("CURRENT_TIMESTAMP"), nullable=False
)
job: Mapped["Job"] = relationship("Job", back_populates="items")
instance: Mapped[Optional["Instance"]] = relationship("Instance", back_populates="job_items")
class AuditLog(Base):
__tablename__ = "audit_logs"
__table_args__ = (
Index("idx_audit_customer", "customer_id"),
Index("idx_audit_action", "action"),
Index("idx_audit_created_at", "created_at"),
Index("idx_audit_resource", "resource_type", "resource_id"),
)
id: Mapped[int] = mapped_column(BIGINT(unsigned=True), primary_key=True, autoincrement=True)
user_id: Mapped[Optional[int]] = mapped_column(
ForeignKey("users.id", ondelete="SET NULL", onupdate="CASCADE")
)
customer_id: Mapped[Optional[int]] = mapped_column(
ForeignKey("customers.id", ondelete="SET NULL", onupdate="CASCADE")
)
action: Mapped[AuditAction] = mapped_column(SAEnum(AuditAction), nullable=False)
resource_type: Mapped[AuditResourceType] = mapped_column(SAEnum(AuditResourceType), nullable=False)
resource_id: Mapped[Optional[int]] = mapped_column(BIGINT(unsigned=True))
description: Mapped[Optional[str]] = mapped_column(String(512))
payload: Mapped[Optional[dict]] = mapped_column(JSON)
ip_address: Mapped[Optional[str]] = mapped_column(String(45))
user_agent: Mapped[Optional[str]] = mapped_column(String(255))
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=text("CURRENT_TIMESTAMP"), nullable=False)
user: Mapped[Optional["User"]] = relationship("User", back_populates="audit_logs")
customer: Mapped[Optional["Customer"]] = relationship("Customer", back_populates="audit_logs")
class Setting(Base):
__tablename__ = "settings"
__table_args__ = (UniqueConstraint("k", name="uniq_settings_key"),)
id: Mapped[int] = mapped_column(BIGINT(unsigned=True), primary_key=True, autoincrement=True)
k: Mapped[str] = mapped_column(String(128), nullable=False)
v: Mapped[Optional[str]] = mapped_column(Text)
description: Mapped[Optional[str]] = mapped_column(String(255))
updated_at: Mapped[datetime] = mapped_column(
DateTime, server_default=text("CURRENT_TIMESTAMP"), onupdate=text("CURRENT_TIMESTAMP"), nullable=False
)

View File

@ -0,0 +1 @@
# Modules package initializer kept minimal to avoid circular imports

Binary file not shown.

Binary file not shown.

View File

@ -0,0 +1 @@
# audit package initializer

View File

@ -0,0 +1,68 @@
from __future__ import annotations
from datetime import datetime
from enum import Enum
from typing import Optional
from sqlalchemy import DateTime, Enum as SAEnum, ForeignKey, Index, JSON, String, text
from sqlalchemy.dialects.mysql import BIGINT
from sqlalchemy.orm import Mapped, mapped_column, relationship
from backend.db.base import Base
class AuditAction(str, Enum):
LOGIN = "LOGIN"
LOGOUT = "LOGOUT"
INSTANCE_CREATE = "INSTANCE_CREATE"
INSTANCE_START = "INSTANCE_START"
INSTANCE_STOP = "INSTANCE_STOP"
INSTANCE_REBOOT = "INSTANCE_REBOOT"
INSTANCE_TERMINATE = "INSTANCE_TERMINATE"
INSTANCE_SYNC = "INSTANCE_SYNC"
CREDENTIAL_CREATE = "CREDENTIAL_CREATE"
CREDENTIAL_UPDATE = "CREDENTIAL_UPDATE"
CREDENTIAL_DELETE = "CREDENTIAL_DELETE"
CUSTOMER_CREATE = "CUSTOMER_CREATE"
CUSTOMER_UPDATE = "CUSTOMER_UPDATE"
CUSTOMER_DELETE = "CUSTOMER_DELETE"
USER_CREATE = "USER_CREATE"
USER_UPDATE = "USER_UPDATE"
USER_DELETE = "USER_DELETE"
OTHER = "OTHER"
class AuditResourceType(str, Enum):
USER = "USER"
CUSTOMER = "CUSTOMER"
AWS_CREDENTIAL = "AWS_CREDENTIAL"
INSTANCE = "INSTANCE"
JOB = "JOB"
OTHER = "OTHER"
class AuditLog(Base):
__tablename__ = "audit_logs"
__table_args__ = (
Index("idx_audit_customer", "customer_id"),
Index("idx_audit_action", "action"),
Index("idx_audit_created_at", "created_at"),
Index("idx_audit_resource", "resource_type", "resource_id"),
)
id: Mapped[int] = mapped_column(BIGINT(unsigned=True), primary_key=True, autoincrement=True)
user_id: Mapped[Optional[int]] = mapped_column(ForeignKey("users.id", ondelete="SET NULL", onupdate="CASCADE"))
customer_id: Mapped[Optional[int]] = mapped_column(
ForeignKey("customers.id", ondelete="SET NULL", onupdate="CASCADE")
)
action: Mapped[AuditAction] = mapped_column(SAEnum(AuditAction), nullable=False)
resource_type: Mapped[AuditResourceType] = mapped_column(SAEnum(AuditResourceType), nullable=False)
resource_id: Mapped[Optional[int]] = mapped_column(BIGINT(unsigned=True))
description: Mapped[Optional[str]] = mapped_column(String(512))
payload: Mapped[Optional[dict]] = mapped_column(JSON)
ip_address: Mapped[Optional[str]] = mapped_column(String(45))
user_agent: Mapped[Optional[str]] = mapped_column(String(255))
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=text("CURRENT_TIMESTAMP"), nullable=False)
user: Mapped[Optional["User"]] = relationship("User")
customer: Mapped[Optional["Customer"]] = relationship("Customer")

View File

@ -0,0 +1,29 @@
from datetime import datetime
from typing import List, Optional
from fastapi import APIRouter, Depends
from sqlalchemy.ext.asyncio import AsyncSession
from backend.api.deps import AuthUser, require_roles
from backend.db.session import get_session
from backend.modules.audit.models import AuditAction, AuditResourceType
from backend.modules.audit.schemas import AuditLogOut
from backend.modules.audit.service import list_audit_logs
from backend.modules.users.models import RoleName
router = APIRouter(prefix="/api/v1/audit_logs", tags=["audit_logs"])
@router.get("", response_model=List[AuditLogOut])
async def audit_logs(
action: Optional[AuditAction] = None,
user_id: Optional[int] = None,
customer_id: Optional[int] = None,
start: Optional[datetime] = None,
end: Optional[datetime] = None,
session: AsyncSession = Depends(get_session),
auth_user: AuthUser = Depends(require_roles([RoleName.ADMIN, RoleName.CUSTOMER_ADMIN])),
) -> List[AuditLogOut]:
effective_customer_id = customer_id if auth_user.role_name == RoleName.ADMIN.value else auth_user.customer_id
logs = await list_audit_logs(session, effective_customer_id, user_id, action, start, end)
return [AuditLogOut.model_validate(log) for log in logs]

View File

@ -0,0 +1,24 @@
from datetime import datetime
from typing import Any, Optional
from pydantic import BaseModel, ConfigDict
from backend.modules.audit.models import AuditAction, AuditResourceType
class AuditLogOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
user_id: Optional[int] = None
user_name: Optional[str] = None
customer_id: Optional[int] = None
customer_name: Optional[str] = None
action: AuditAction
resource_type: AuditResourceType
resource_id: Optional[int] = None
description: Optional[str] = None
payload: Optional[Any] = None
ip_address: Optional[str] = None
user_agent: Optional[str] = None
created_at: datetime

View File

@ -0,0 +1,41 @@
from datetime import datetime
from typing import List, Optional
from sqlalchemy import and_, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from backend.modules.audit.models import AuditAction, AuditLog, AuditResourceType
async def list_audit_logs(
session: AsyncSession,
customer_id: Optional[int],
user_id: Optional[int],
action: Optional[AuditAction],
start: Optional[datetime],
end: Optional[datetime],
) -> List[AuditLog]:
query = (
select(AuditLog)
.options(selectinload(AuditLog.user), selectinload(AuditLog.customer))
.order_by(AuditLog.created_at.desc())
)
conditions = []
if customer_id:
conditions.append(AuditLog.customer_id == customer_id)
if user_id:
conditions.append(AuditLog.user_id == user_id)
if action:
conditions.append(AuditLog.action == action)
if start:
conditions.append(AuditLog.created_at >= start)
if end:
conditions.append(AuditLog.created_at <= end)
if conditions:
query = query.where(and_(*conditions))
logs = (await session.scalars(query)).all()
for log in logs:
setattr(log, "user_name", log.user.username if log.user else None)
setattr(log, "customer_name", log.customer.name if log.customer else None)
return logs

View File

@ -0,0 +1 @@
# auth package initializer

View File

@ -0,0 +1,29 @@
from fastapi import APIRouter, Depends, Request
from sqlalchemy.ext.asyncio import AsyncSession
from backend.api.deps import AuthUser, get_current_user
from backend.db.session import get_session
from backend.modules.auth.schemas import LoginRequest, TokenResponse
from backend.modules.auth.service import authenticate_user, build_access_token, create_login_audit
from backend.modules.users.schemas import UserOut
router = APIRouter(prefix="/api/v1/auth", tags=["auth"])
@router.post("/login", response_model=TokenResponse)
async def login(payload: LoginRequest, request: Request, session: AsyncSession = Depends(get_session)) -> TokenResponse:
user = await authenticate_user(session, payload.username, payload.password)
token = build_access_token(user)
await create_login_audit(session, user, request)
return TokenResponse(access_token=token, user=UserOut.model_validate(user))
@router.get("/me", response_model=UserOut)
async def me(auth_user: AuthUser = Depends(get_current_user)) -> UserOut:
return UserOut.model_validate(auth_user.user)
@router.post("/refresh", response_model=TokenResponse)
async def refresh(auth_user: AuthUser = Depends(get_current_user)) -> TokenResponse:
token = build_access_token(auth_user.user)
return TokenResponse(access_token=token, user=UserOut.model_validate(auth_user.user))

View File

@ -0,0 +1,18 @@
from datetime import datetime
from typing import Optional
from pydantic import BaseModel, ConfigDict, EmailStr
from backend.modules.users.schemas import UserOut
class LoginRequest(BaseModel):
username: str
password: str
class TokenResponse(BaseModel):
access_token: str
token_type: str = "bearer"
user: UserOut

View File

@ -0,0 +1,44 @@
from datetime import datetime, timezone
from fastapi import HTTPException, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from backend.core.security import create_access_token, verify_password
from backend.modules.audit.models import AuditAction, AuditLog, AuditResourceType
from backend.modules.users.models import User
async def authenticate_user(session: AsyncSession, username: str, password: str) -> User:
user = await session.scalar(
select(User).where(User.username == username).options(selectinload(User.role), selectinload(User.customer))
)
if not user or not verify_password(password, user.password_hash):
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials")
if not user.is_active:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="User disabled")
user.last_login_at = datetime.now(timezone.utc)
await session.commit()
await session.refresh(user)
return user
async def create_login_audit(session: AsyncSession, user: User, request) -> None:
audit = AuditLog(
user_id=user.id,
customer_id=user.customer_id,
action=AuditAction.LOGIN,
resource_type=AuditResourceType.USER,
resource_id=user.id,
description="User login success",
ip_address=request.client.host if request and request.client else None,
user_agent=request.headers.get("User-Agent") if request else None,
)
session.add(audit)
await session.commit()
def build_access_token(user: User) -> str:
payload = {"sub": str(user.id), "role": user.role.name if user.role else "", "customer_id": user.customer_id}
return create_access_token(payload)

View File

@ -0,0 +1 @@
# aws_accounts package initializer

View File

@ -0,0 +1,74 @@
from __future__ import annotations
from datetime import datetime
from enum import Enum
from typing import Optional
from sqlalchemy import DateTime, Enum as SAEnum, ForeignKey, Index, String, UniqueConstraint, text
from sqlalchemy.dialects.mysql import BIGINT, TINYINT
from sqlalchemy.orm import Mapped, mapped_column, relationship
from backend.db.base import Base
class CredentialType(str, Enum):
ACCESS_KEY = "ACCESS_KEY"
ASSUME_ROLE = "ASSUME_ROLE"
class AWSCredential(Base):
__tablename__ = "aws_credentials"
__table_args__ = (
UniqueConstraint("account_id", "name", name="uniq_credential_account_name"),
Index("idx_credential_account", "account_id"),
Index("idx_credential_active", "is_active"),
)
id: Mapped[int] = mapped_column(BIGINT(unsigned=True), primary_key=True, autoincrement=True)
name: Mapped[str] = mapped_column(String(128), nullable=False)
account_id: Mapped[str] = mapped_column(String(32), nullable=False)
credential_type: Mapped[CredentialType] = mapped_column(
SAEnum(CredentialType), nullable=False, server_default=text("'ACCESS_KEY'")
)
access_key_id: Mapped[Optional[str]] = mapped_column(String(128))
secret_access_key: Mapped[Optional[str]] = mapped_column(String(256))
role_arn: Mapped[Optional[str]] = mapped_column(String(256))
external_id: Mapped[Optional[str]] = mapped_column(String(128))
default_region: Mapped[str] = mapped_column(String(32), nullable=False, server_default=text("'ap-northeast-1'"))
is_active: Mapped[int] = mapped_column(TINYINT(1), nullable=False, server_default=text("1"))
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=text("CURRENT_TIMESTAMP"), nullable=False)
updated_at: Mapped[datetime] = mapped_column(
DateTime, server_default=text("CURRENT_TIMESTAMP"), onupdate=text("CURRENT_TIMESTAMP"), nullable=False
)
customer_credentials: Mapped[list["CustomerCredential"]] = relationship(
"CustomerCredential", back_populates="credential", passive_deletes=True
)
instances: Mapped[list["Instance"]] = relationship("Instance", back_populates="credential", passive_deletes=True)
class CustomerCredential(Base):
__tablename__ = "customer_credentials"
__table_args__ = (
UniqueConstraint("customer_id", "credential_id", name="uniq_customer_credential"),
Index("idx_cc_customer", "customer_id"),
Index("idx_cc_credential", "credential_id"),
)
id: Mapped[int] = mapped_column(BIGINT(unsigned=True), primary_key=True, autoincrement=True)
customer_id: Mapped[int] = mapped_column(
ForeignKey("customers.id", ondelete="CASCADE", onupdate="CASCADE"), nullable=False
)
credential_id: Mapped[int] = mapped_column(
ForeignKey("aws_credentials.id", ondelete="CASCADE", onupdate="CASCADE"), nullable=False
)
is_allowed: Mapped[int] = mapped_column(TINYINT(1), nullable=False, server_default=text("1"))
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=text("CURRENT_TIMESTAMP"), nullable=False)
updated_at: Mapped[datetime] = mapped_column(
DateTime, server_default=text("CURRENT_TIMESTAMP"), onupdate=text("CURRENT_TIMESTAMP"), nullable=False
)
customer: Mapped["Customer"] = relationship("Customer")
credential: Mapped["AWSCredential"] = relationship(
"AWSCredential", back_populates="customer_credentials", passive_deletes=True
)

View File

@ -0,0 +1,108 @@
from typing import List
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from backend.api.deps import AuthUser, get_current_user, require_admin
from backend.db.session import get_session
from backend.modules.aws_accounts.schemas import (
AWSCredentialCreate,
AWSCredentialOut,
AWSCredentialUpdate,
CustomerCredentialCreate,
CustomerCredentialOut,
)
from backend.modules.aws_accounts.service import (
authorize_customer,
create_credential,
delete_credential,
get_credential,
list_credentials,
update_credential,
)
from backend.modules.users.models import RoleName
router = APIRouter(prefix="/api/v1/aws_credentials", tags=["aws_credentials"])
@router.get("", response_model=List[AWSCredentialOut])
async def get_credentials(
session: AsyncSession = Depends(get_session),
auth_user: AuthUser = Depends(get_current_user),
) -> List[AWSCredentialOut]:
# 客户普通用户只能看到自己所属客户被授权的凭证
if auth_user.role_name == RoleName.ADMIN.value:
target_customer_id = None
else:
target_customer_id = auth_user.customer_id
if target_customer_id is None:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, detail="customer_id required for non-admin user"
)
credentials = await list_credentials(session, target_customer_id)
return [AWSCredentialOut.model_validate(c) for c in credentials]
@router.post("", response_model=AWSCredentialOut, status_code=status.HTTP_201_CREATED)
async def create_credential_endpoint(
payload: AWSCredentialCreate,
session: AsyncSession = Depends(get_session),
auth_user: AuthUser = Depends(require_admin),
) -> AWSCredentialOut:
cred = await create_credential(session, payload.model_dump(), auth_user.user)
return AWSCredentialOut.model_validate(cred)
@router.put("/{credential_id}", response_model=AWSCredentialOut)
async def update_credential_endpoint(
credential_id: int,
payload: AWSCredentialUpdate,
session: AsyncSession = Depends(get_session),
auth_user: AuthUser = Depends(require_admin),
) -> AWSCredentialOut:
cred = await update_credential(session, credential_id, payload.model_dump(exclude_unset=True), auth_user.user)
return AWSCredentialOut.model_validate(cred)
@router.get("/{credential_id}", response_model=AWSCredentialOut)
async def get_credential_endpoint(
credential_id: int,
session: AsyncSession = Depends(get_session),
auth_user: AuthUser = Depends(get_current_user),
) -> AWSCredentialOut:
cred = await get_credential(session, credential_id)
if auth_user.role_name != RoleName.ADMIN.value:
# ensure mapping
_ = await session.execute(
select(CustomerCredential).where(
CustomerCredential.customer_id == auth_user.customer_id,
CustomerCredential.credential_id == credential_id,
CustomerCredential.is_allowed == 1,
)
)
return AWSCredentialOut.model_validate(cred)
@router.delete("/{credential_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_credential_endpoint(
credential_id: int,
session: AsyncSession = Depends(get_session),
auth_user: AuthUser = Depends(require_admin),
) -> None:
await delete_credential(session, credential_id, auth_user.user)
@router.post("/{credential_id}/authorize", response_model=CustomerCredentialOut)
async def authorize_credential_endpoint(
credential_id: int,
payload: CustomerCredentialCreate,
session: AsyncSession = Depends(get_session),
auth_user: AuthUser = Depends(require_admin),
) -> CustomerCredentialOut:
if credential_id != payload.credential_id:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="credential_id mismatch")
mapping = await authorize_customer(
session, payload.customer_id, payload.credential_id, payload.is_allowed, auth_user.user
)
return CustomerCredentialOut.model_validate(mapping)

View File

@ -0,0 +1,60 @@
from datetime import datetime
from typing import Optional
from pydantic import BaseModel, ConfigDict
from backend.modules.aws_accounts.models import CredentialType
class AWSCredentialBase(BaseModel):
name: str
account_id: str
credential_type: CredentialType = CredentialType.ACCESS_KEY
access_key_id: Optional[str] = None
secret_access_key: Optional[str] = None
role_arn: Optional[str] = None
external_id: Optional[str] = None
default_region: str = "ap-northeast-1"
is_active: int = 1
class AWSCredentialCreate(AWSCredentialBase):
pass
class AWSCredentialUpdate(BaseModel):
name: Optional[str] = None
account_id: Optional[str] = None
credential_type: Optional[CredentialType] = None
access_key_id: Optional[str] = None
secret_access_key: Optional[str] = None
role_arn: Optional[str] = None
external_id: Optional[str] = None
default_region: Optional[str] = None
is_active: Optional[int] = None
class AWSCredentialOut(AWSCredentialBase):
model_config = ConfigDict(from_attributes=True)
id: int
created_at: datetime
updated_at: datetime
class CustomerCredentialBase(BaseModel):
customer_id: int
credential_id: int
is_allowed: int = 1
class CustomerCredentialCreate(CustomerCredentialBase):
pass
class CustomerCredentialOut(CustomerCredentialBase):
model_config = ConfigDict(from_attributes=True)
id: int
created_at: datetime
updated_at: datetime

View File

@ -0,0 +1,117 @@
from typing import List
from fastapi import HTTPException, status
from sqlalchemy import and_, select
from sqlalchemy.ext.asyncio import AsyncSession
from backend.modules.audit.models import AuditAction, AuditLog, AuditResourceType
from backend.modules.aws_accounts.models import AWSCredential, CustomerCredential
from backend.modules.users.models import User
async def list_credentials(session: AsyncSession, customer_id: int | None = None) -> List[AWSCredential]:
query = select(AWSCredential)
if customer_id:
query = (
query.join(CustomerCredential, CustomerCredential.credential_id == AWSCredential.id)
.where(CustomerCredential.customer_id == customer_id)
.where(CustomerCredential.is_allowed == 1)
)
return (await session.scalars(query)).all()
async def create_credential(session: AsyncSession, data: dict, actor: User) -> AWSCredential:
cred = AWSCredential(**data)
session.add(cred)
await session.commit()
await session.refresh(cred)
session.add(
AuditLog(
user_id=actor.id,
customer_id=actor.customer_id,
action=AuditAction.CREDENTIAL_CREATE,
resource_type=AuditResourceType.AWS_CREDENTIAL,
resource_id=cred.id,
description=f"Create credential {cred.name}",
)
)
await session.commit()
return cred
async def get_credential(session: AsyncSession, credential_id: int) -> AWSCredential:
cred = await session.get(AWSCredential, credential_id)
if not cred:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Credential not found")
return cred
async def update_credential(session: AsyncSession, credential_id: int, data: dict, actor: User) -> AWSCredential:
cred = await get_credential(session, credential_id)
for field, value in data.items():
setattr(cred, field, value)
await session.commit()
await session.refresh(cred)
session.add(
AuditLog(
user_id=actor.id,
customer_id=actor.customer_id,
action=AuditAction.CREDENTIAL_UPDATE,
resource_type=AuditResourceType.AWS_CREDENTIAL,
resource_id=cred.id,
description=f"Update credential {cred.name}",
payload=data,
)
)
await session.commit()
return cred
async def delete_credential(session: AsyncSession, credential_id: int, actor: User) -> None:
cred = await session.get(AWSCredential, credential_id)
if not cred:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Credential not found")
await session.delete(cred)
session.add(
AuditLog(
user_id=actor.id,
customer_id=actor.customer_id,
action=AuditAction.CREDENTIAL_DELETE,
resource_type=AuditResourceType.AWS_CREDENTIAL,
resource_id=credential_id,
description=f"Delete credential {credential_id}",
)
)
await session.commit()
async def authorize_customer(
session: AsyncSession, customer_id: int, credential_id: int, is_allowed: int, actor: User
) -> CustomerCredential:
mapping = await session.scalar(
select(CustomerCredential).where(
and_(
CustomerCredential.customer_id == customer_id,
CustomerCredential.credential_id == credential_id,
)
)
)
if mapping:
mapping.is_allowed = is_allowed
else:
mapping = CustomerCredential(customer_id=customer_id, credential_id=credential_id, is_allowed=is_allowed)
session.add(mapping)
await session.commit()
await session.refresh(mapping)
session.add(
AuditLog(
user_id=actor.id,
customer_id=customer_id,
action=AuditAction.CREDENTIAL_UPDATE,
resource_type=AuditResourceType.AWS_CREDENTIAL,
resource_id=credential_id,
description=f"Authorize credential {credential_id} to customer {customer_id}",
)
)
await session.commit()
return mapping

View File

@ -0,0 +1 @@
# customers package initializer

View File

@ -0,0 +1,28 @@
from __future__ import annotations
from datetime import datetime
from typing import Optional
from sqlalchemy import DateTime, String, UniqueConstraint, text
from sqlalchemy.dialects.mysql import BIGINT, INTEGER, TINYINT
from sqlalchemy.orm import Mapped, mapped_column, relationship
from backend.db.base import Base
class Customer(Base):
__tablename__ = "customers"
__table_args__ = (UniqueConstraint("name", name="uniq_customer_name"),)
id: Mapped[int] = mapped_column(BIGINT(unsigned=True), primary_key=True, autoincrement=True)
name: Mapped[str] = mapped_column(String(128), nullable=False)
contact_email: Mapped[Optional[str]] = mapped_column(String(128))
is_active: Mapped[int] = mapped_column(TINYINT(1), nullable=False, server_default=text("1"))
quota_instances: Mapped[Optional[int]] = mapped_column(INTEGER(unsigned=True))
notes: Mapped[Optional[str]] = mapped_column(String(255))
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=text("CURRENT_TIMESTAMP"), nullable=False)
updated_at: Mapped[datetime] = mapped_column(
DateTime, server_default=text("CURRENT_TIMESTAMP"), onupdate=text("CURRENT_TIMESTAMP"), nullable=False
)
users: Mapped[list["User"]] = relationship("User", back_populates="customer")

View File

@ -0,0 +1,50 @@
from typing import List
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from backend.api.deps import AuthUser, require_admin
from backend.db.session import get_session
from backend.modules.customers.schemas import CustomerCreate, CustomerOut, CustomerUpdate
from backend.modules.customers.service import create_customer, list_customers, update_customer, delete_customer
router = APIRouter(prefix="/api/v1/customers", tags=["customers"])
@router.get("", response_model=List[CustomerOut])
async def get_customers(
session: AsyncSession = Depends(get_session),
auth_user: AuthUser = Depends(require_admin),
) -> List[CustomerOut]:
customers = await list_customers(session)
return [CustomerOut.model_validate(c) for c in customers]
@router.post("", response_model=CustomerOut, status_code=status.HTTP_201_CREATED)
async def create_customer_endpoint(
payload: CustomerCreate,
session: AsyncSession = Depends(get_session),
auth_user: AuthUser = Depends(require_admin),
) -> CustomerOut:
customer = await create_customer(session, payload.model_dump(), auth_user.user)
return CustomerOut.model_validate(customer)
@router.put("/{customer_id}", response_model=CustomerOut)
async def update_customer_endpoint(
customer_id: int,
payload: CustomerUpdate,
session: AsyncSession = Depends(get_session),
auth_user: AuthUser = Depends(require_admin),
) -> CustomerOut:
customer = await update_customer(session, customer_id, payload.model_dump(exclude_unset=True), auth_user.user)
return CustomerOut.model_validate(customer)
@router.delete("/{customer_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_customer_endpoint(
customer_id: int,
session: AsyncSession = Depends(get_session),
auth_user: AuthUser = Depends(require_admin),
):
await delete_customer(session, customer_id, auth_user.user)

View File

@ -0,0 +1,34 @@
from datetime import datetime
from typing import Optional
from pydantic import BaseModel, ConfigDict, EmailStr
class CustomerBase(BaseModel):
name: str
contact_email: Optional[EmailStr] = None
is_active: int = 1
quota_instances: Optional[int] = None
notes: Optional[str] = None
class CustomerCreate(CustomerBase):
pass
class CustomerUpdate(BaseModel):
name: Optional[str] = None
contact_email: Optional[EmailStr] = None
is_active: Optional[int] = None
quota_instances: Optional[int] = None
notes: Optional[str] = None
class CustomerOut(CustomerBase):
model_config = ConfigDict(from_attributes=True)
id: int
created_at: datetime
updated_at: datetime
credential_names: list[str] = []
usernames: list[str] = []

View File

@ -0,0 +1,103 @@
from collections import defaultdict
from typing import List
from fastapi import HTTPException, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from backend.modules.audit.models import AuditAction, AuditLog, AuditResourceType
from backend.modules.aws_accounts.models import AWSCredential, CustomerCredential
from backend.modules.customers.models import Customer
from backend.modules.users.models import User
async def list_customers(session: AsyncSession) -> List[Customer]:
customers = (await session.scalars(select(Customer))).all()
if not customers:
return customers
customer_ids = [c.id for c in customers]
cred_map: dict[int, list[str]] = defaultdict(list)
cred_rows = await session.execute(
select(CustomerCredential.customer_id, AWSCredential.name, AWSCredential.account_id)
.join(AWSCredential, AWSCredential.id == CustomerCredential.credential_id)
.where(CustomerCredential.customer_id.in_(customer_ids))
.where(CustomerCredential.is_allowed == 1)
)
for customer_id, cred_name, account_id in cred_rows:
cred_map[customer_id].append(f"{cred_name} ({account_id})")
user_map: dict[int, list[str]] = defaultdict(list)
user_rows = await session.execute(
select(User.customer_id, User.username).where(User.customer_id.in_(customer_ids))
)
for customer_id, username in user_rows:
user_map[customer_id].append(username)
for customer in customers:
# attach additional presentation fields for API response
setattr(customer, "credential_names", cred_map.get(customer.id, []))
setattr(customer, "usernames", user_map.get(customer.id, []))
return customers
async def create_customer(session: AsyncSession, data: dict, actor: User) -> Customer:
customer = Customer(**data)
session.add(customer)
await session.commit()
await session.refresh(customer)
session.add(
AuditLog(
user_id=actor.id,
customer_id=None,
action=AuditAction.CUSTOMER_CREATE,
resource_type=AuditResourceType.CUSTOMER,
resource_id=customer.id,
description=f"Create customer {customer.name}",
)
)
await session.commit()
return customer
async def update_customer(session: AsyncSession, customer_id: int, data: dict, actor: User) -> Customer:
customer = await session.get(Customer, customer_id)
if not customer:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Customer not found")
for field, value in data.items():
setattr(customer, field, value)
await session.commit()
await session.refresh(customer)
session.add(
AuditLog(
user_id=actor.id,
customer_id=customer.id,
action=AuditAction.CUSTOMER_UPDATE,
resource_type=AuditResourceType.CUSTOMER,
resource_id=customer.id,
description=f"Update customer {customer.name}",
payload=data,
)
)
await session.commit()
return customer
async def delete_customer(session: AsyncSession, customer_id: int, actor: User) -> None:
customer = await session.get(Customer, customer_id)
if not customer:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Customer not found")
await session.delete(customer)
session.add(
AuditLog(
user_id=actor.id,
customer_id=customer.id,
action=AuditAction.CUSTOMER_DELETE,
resource_type=AuditResourceType.CUSTOMER,
resource_id=customer.id,
description=f"Delete customer {customer.name}",
)
)
await session.commit()

View File

@ -0,0 +1 @@
# instances package initializer

View File

@ -0,0 +1,284 @@
from __future__ import annotations
from typing import Any, Dict, List, Optional
import boto3
from botocore.config import Config as BotoConfig
from botocore.exceptions import ClientError
from backend.core.config import settings
from backend.modules.aws_accounts.models import AWSCredential, CredentialType
OPEN_ALL_SG_NAME = "panel-open-all"
def _boto_config() -> BotoConfig:
proxies = None
if settings.aws_proxy_url:
proxies = {"https": settings.aws_proxy_url, "http": settings.aws_proxy_url}
return BotoConfig(connect_timeout=settings.aws_timeout, read_timeout=settings.aws_timeout, proxies=proxies)
def build_session(credential: AWSCredential, region: str):
cfg = _boto_config()
if credential.credential_type == CredentialType.ACCESS_KEY:
session = boto3.Session(
aws_access_key_id=credential.access_key_id,
aws_secret_access_key=credential.secret_access_key,
region_name=region or credential.default_region,
)
return session, cfg
base_session = boto3.Session(
aws_access_key_id=credential.access_key_id,
aws_secret_access_key=credential.secret_access_key,
region_name=region or credential.default_region,
)
sts = base_session.client("sts", config=cfg, region_name=region or credential.default_region)
assume_kwargs: Dict[str, Any] = {"RoleArn": credential.role_arn, "RoleSessionName": "ec2-panel"}
if credential.external_id:
assume_kwargs["ExternalId"] = credential.external_id
resp = sts.assume_role(**assume_kwargs)
creds = resp["Credentials"]
session = boto3.Session(
aws_access_key_id=creds["AccessKeyId"],
aws_secret_access_key=creds["SecretAccessKey"],
aws_session_token=creds["SessionToken"],
region_name=region or credential.default_region,
)
return session, cfg
def describe_instances(
credential: AWSCredential,
region: str,
filters: Optional[List[Dict[str, Any]]] = None,
instance_ids: Optional[List[str]] = None,
) -> Dict[str, Any]:
session, cfg = build_session(credential, region)
client = session.client("ec2", region_name=region or credential.default_region, config=cfg)
params: Dict[str, Any] = {}
if filters:
params["Filters"] = filters
if instance_ids:
params["InstanceIds"] = instance_ids
return client.describe_instances(**params)
def describe_instance_status(
credential: AWSCredential, region: str, instance_ids: List[str]
) -> Dict[str, Any]:
session, cfg = build_session(credential, region)
client = session.client("ec2", region_name=region or credential.default_region, config=cfg)
return client.describe_instance_status(InstanceIds=instance_ids, IncludeAllInstances=True)
def run_instances(
credential: AWSCredential,
region: str,
ami_id: str,
instance_type: str,
key_name: Optional[str],
security_groups: Optional[List[str]],
subnet_id: Optional[str],
block_device_mappings: Optional[List[Dict[str, Any]]] = None,
cpu_options: Optional[Dict[str, Any]] = None,
min_count: int = 1,
max_count: int = 1,
name_tag: Optional[str] = None,
user_data: Optional[str] = None,
) -> Dict[str, Any]:
session, cfg = build_session(credential, region)
client = session.client("ec2", region_name=region or credential.default_region, config=cfg)
params: Dict[str, Any] = {
"ImageId": ami_id,
"InstanceType": instance_type,
"MinCount": min_count,
"MaxCount": max_count,
}
if key_name:
params["KeyName"] = key_name
if security_groups:
params["SecurityGroupIds"] = security_groups
if subnet_id:
params["SubnetId"] = subnet_id
if block_device_mappings:
params["BlockDeviceMappings"] = block_device_mappings
if cpu_options:
params["CreditSpecification"] = cpu_options
if name_tag:
params["TagSpecifications"] = [
{"ResourceType": "instance", "Tags": [{"Key": "Name", "Value": name_tag}]},
{"ResourceType": "volume", "Tags": [{"Key": "Name", "Value": name_tag}]},
]
if user_data:
params["UserData"] = user_data
return client.run_instances(**params)
def start_instances(credential: AWSCredential, region: str, instance_ids: List[str]) -> Dict[str, Any]:
session, cfg = build_session(credential, region)
client = session.client("ec2", region_name=region or credential.default_region, config=cfg)
return client.start_instances(InstanceIds=instance_ids)
def stop_instances(credential: AWSCredential, region: str, instance_ids: List[str]) -> Dict[str, Any]:
session, cfg = build_session(credential, region)
client = session.client("ec2", region_name=region or credential.default_region, config=cfg)
return client.stop_instances(InstanceIds=instance_ids)
def reboot_instances(credential: AWSCredential, region: str, instance_ids: List[str]) -> Dict[str, Any]:
session, cfg = build_session(credential, region)
client = session.client("ec2", region_name=region or credential.default_region, config=cfg)
return client.reboot_instances(InstanceIds=instance_ids)
def terminate_instances(credential: AWSCredential, region: str, instance_ids: List[str]) -> Dict[str, Any]:
session, cfg = build_session(credential, region)
client = session.client("ec2", region_name=region or credential.default_region, config=cfg)
return client.terminate_instances(InstanceIds=instance_ids)
def get_service_quota(credential: AWSCredential, region: str, service_code: str, quota_code: str) -> Dict[str, Any]:
"""
Best-effort service quota lookup, used to hint at maximum runnable instances in a region.
"""
session, cfg = build_session(credential, region)
client = session.client("service-quotas", region_name=region or credential.default_region, config=cfg)
return client.get_service_quota(ServiceCode=service_code, QuotaCode=quota_code)
def describe_vpcs(credential: AWSCredential, region: str) -> Dict[str, Any]:
session, cfg = build_session(credential, region)
client = session.client("ec2", region_name=region or credential.default_region, config=cfg)
return client.describe_vpcs()
def describe_subnets(credential: AWSCredential, region: str, filters: Optional[List[Dict[str, Any]]] = None) -> Dict[str, Any]:
session, cfg = build_session(credential, region)
client = session.client("ec2", region_name=region or credential.default_region, config=cfg)
params: Dict[str, Any] = {}
if filters:
params["Filters"] = filters
return client.describe_subnets(**params)
def describe_security_groups(
credential: AWSCredential, region: str, filters: Optional[List[Dict[str, Any]]] = None
) -> Dict[str, Any]:
session, cfg = build_session(credential, region)
client = session.client("ec2", region_name=region or credential.default_region, config=cfg)
params: Dict[str, Any] = {}
if filters:
params["Filters"] = filters
return client.describe_security_groups(**params)
def describe_key_pairs(credential: AWSCredential, region: str) -> Dict[str, Any]:
session, cfg = build_session(credential, region)
client = session.client("ec2", region_name=region or credential.default_region, config=cfg)
return client.describe_key_pairs()
def create_key_pair(credential: AWSCredential, region: str, key_name: str) -> Dict[str, Any]:
session, cfg = build_session(credential, region)
client = session.client("ec2", region_name=region or credential.default_region, config=cfg)
return client.create_key_pair(KeyName=key_name, KeyType="rsa", KeyFormat="pem")
def describe_regions(credential: AWSCredential) -> Dict[str, Any]:
session, cfg = build_session(credential, credential.default_region)
client = session.client("ec2", region_name=credential.default_region, config=cfg)
return client.describe_regions(AllRegions=True)
def describe_instance_types(credential: AWSCredential, region: str, filters: Optional[List[Dict[str, Any]]] = None) -> List[Dict[str, Any]]:
session, cfg = build_session(credential, region)
client = session.client("ec2", region_name=region or credential.default_region, config=cfg)
paginator = client.get_paginator("describe_instance_types")
params: Dict[str, Any] = {}
if filters:
params["Filters"] = filters
results: List[Dict[str, Any]] = []
for page in paginator.paginate(**params):
results.extend(page.get("InstanceTypes", []))
return results
def describe_images(credential: AWSCredential, region: str, image_ids: List[str]) -> Dict[str, Any]:
session, cfg = build_session(credential, region)
client = session.client("ec2", region_name=region or credential.default_region, config=cfg)
return client.describe_images(ImageIds=image_ids)
def _is_open_all_sg(sg: Dict[str, Any]) -> bool:
ingress = sg.get("IpPermissions", [])
egress = sg.get("IpPermissionsEgress", [])
def _has_all(perms: List[Dict[str, Any]]) -> bool:
for p in perms:
if p.get("IpProtocol") == "-1":
if any(r.get("CidrIp") == "0.0.0.0/0" for r in p.get("IpRanges", [])):
return True
return False
return _has_all(ingress) and _has_all(egress)
def ensure_open_all_sg_for_vpc(ec2_client, vpc_id: str) -> str:
"""Ensure an open-all security group exists in a VPC. Returns GroupId."""
resp = ec2_client.describe_security_groups(Filters=[{"Name": "vpc-id", "Values": [vpc_id]}])
default_sg_id = None
for sg in resp.get("SecurityGroups", []):
name = sg.get("GroupName")
if name == "default":
default_sg_id = sg.get("GroupId")
if (name == OPEN_ALL_SG_NAME or name == "default") and _is_open_all_sg(sg):
return sg["GroupId"]
# If default exists but不是全开直接把默认安全组放开
if default_sg_id:
ingress_rule = {
"IpProtocol": "-1",
"IpRanges": [{"CidrIp": "0.0.0.0/0"}],
}
egress_rule = {
"IpProtocol": "-1",
"IpRanges": [{"CidrIp": "0.0.0.0/0"}],
}
try:
ec2_client.authorize_security_group_ingress(GroupId=default_sg_id, IpPermissions=[ingress_rule])
except ClientError as exc: # noqa: PERF203
if exc.response.get("Error", {}).get("Code") != "InvalidPermission.Duplicate":
raise
try:
ec2_client.authorize_security_group_egress(GroupId=default_sg_id, IpPermissions=[egress_rule])
except ClientError as exc: # noqa: PERF203
if exc.response.get("Error", {}).get("Code") != "InvalidPermission.Duplicate":
raise
return default_sg_id
# create new
create_resp = ec2_client.create_security_group(
GroupName=OPEN_ALL_SG_NAME,
Description="Open all inbound/outbound for panel-created instances",
VpcId=vpc_id,
)
sg_id = create_resp["GroupId"]
ingress_rule = {
"IpProtocol": "-1",
"IpRanges": [{"CidrIp": "0.0.0.0/0"}],
}
egress_rule = {
"IpProtocol": "-1",
"IpRanges": [{"CidrIp": "0.0.0.0/0"}],
}
try:
ec2_client.authorize_security_group_ingress(GroupId=sg_id, IpPermissions=[ingress_rule])
except ClientError as exc: # noqa: PERF203
if exc.response.get("Error", {}).get("Code") != "InvalidPermission.Duplicate":
raise
try:
ec2_client.authorize_security_group_egress(GroupId=sg_id, IpPermissions=[egress_rule])
except ClientError as exc: # noqa: PERF203
if exc.response.get("Error", {}).get("Code") != "InvalidPermission.Duplicate":
raise
return sg_id

View File

@ -0,0 +1,51 @@
COMMON_TEMPLATE = r"""#!/bin/bash
set -eux
USER_NAME="{username}"
USER_PWD="{password}"
# 1. 设置密码
echo "$USER_NAME:$USER_PWD" | chpasswd
SSH_MAIN="/etc/ssh/sshd_config"
# 2. 修改主配置中的 PermitRootLogin / PasswordAuthentication
if [ -f "$SSH_MAIN" ]; then
if grep -qE '^[#[:space:]]*PermitRootLogin' "$SSH_MAIN"; then
sed -i 's/^[#[:space:]]*PermitRootLogin.*/PermitRootLogin yes/' "$SSH_MAIN"
else
echo 'PermitRootLogin yes' >> "$SSH_MAIN"
fi
if grep -qE '^[#[:space:]]*PasswordAuthentication' "$SSH_MAIN"; then
sed -i 's/^[#[:space:]]*PasswordAuthentication.*/PasswordAuthentication yes/' "$SSH_MAIN"
else
echo 'PasswordAuthentication yes' >> "$SSH_MAIN"
fi
fi
# 3. 针对 cloud-init/ubuntu 的附加配置(若存在)
if [ -d /etc/ssh/sshd_config.d ]; then
for f in /etc/ssh/sshd_config.d/*.conf; do
[ -f "$f" ] || continue
if grep -q 'PasswordAuthentication' "$f"; then
sed -i 's/^[#[:space:]]*PasswordAuthentication.*/PasswordAuthentication yes/' "$f"
fi
done
fi
# 4. 重启 SSH 服务(尝试多种名称)
if command -v systemctl >/dev/null 2>&1; then
systemctl restart sshd 2>/dev/null || \
systemctl restart ssh 2>/dev/null || \
service sshd restart 2>/dev/null || \
service ssh restart 2>/dev/null || true
else
service sshd restart 2>/dev/null || \
service ssh restart 2>/dev/null || true
fi
"""
def build_user_data(os_family: str, username: str, password: str) -> str:
return COMMON_TEMPLATE.format(username=username, password=password)

View File

@ -0,0 +1,32 @@
AWS_REGIONS = {
"af-south-1": {"en": "Africa (Cape Town)", "zh": "非洲(开普敦)"},
"ap-east-1": {"en": "Asia Pacific (Hong Kong)", "zh": "亚太地区(香港)"},
"ap-south-1": {"en": "Asia Pacific (Mumbai)", "zh": "亚太地区(孟买)"},
"ap-south-2": {"en": "Asia Pacific South (Hyderabad)", "zh": "亚太南部(海得拉巴)"},
"ap-southeast-1": {"en": "Asia Pacific (Singapore)", "zh": "亚太地区(新加坡)"},
"ap-southeast-2": {"en": "Asia Pacific (Sydney)", "zh": "亚太地区(悉尼)"},
"ap-southeast-3": {"en": "Asia Pacific (Jakarta)", "zh": "亚太地区(雅加达)"},
"ap-southeast-4": {"en": "Asia Pacific (Melbourne)", "zh": "亚太地区(墨尔本)"},
"ap-northeast-1": {"en": "Asia Pacific (Tokyo)", "zh": "亚太地区(东京)"},
"ap-northeast-2": {"en": "Asia Pacific (Seoul)", "zh": "亚太地区(首尔)"},
"ap-northeast-3": {"en": "Asia Pacific (Osaka)", "zh": "亚太地区(大阪)"},
"ca-central-1": {"en": "Canada (Central)", "zh": "加拿大(中部)"},
"eu-central-1": {"en": "Europe (Frankfurt)", "zh": "欧洲(法兰克福)"},
"eu-central-2": {"en": "Europe (Zurich)", "zh": "欧洲(苏黎世)"},
"eu-west-1": {"en": "Europe (Ireland)", "zh": "欧洲(爱尔兰)"},
"eu-west-2": {"en": "Europe (London)", "zh": "欧洲(伦敦)"},
"eu-west-3": {"en": "Europe (Paris)", "zh": "欧洲(巴黎)"},
"eu-south-1": {"en": "Europe (Milan)", "zh": "欧洲(米兰)"},
"eu-south-2": {"en": "Europe (Spain)", "zh": "欧洲(西班牙)"},
"eu-north-1": {"en": "Europe (Stockholm)", "zh": "欧洲(斯德哥尔摩)"},
"me-south-1": {"en": "Middle East (Bahrain)", "zh": "中东(巴林)"},
"me-central-1": {"en": "Middle East (UAE)", "zh": "中东(阿联酋)"},
"sa-east-1": {"en": "South America (São Paulo)", "zh": "南美洲(圣保罗)"},
"us-east-1": {"en": "US East (N. Virginia)", "zh": "美国东部(弗吉尼亚北部)"},
"us-east-2": {"en": "US East (Ohio)", "zh": "美国东部(俄亥俄)"},
"us-west-1": {"en": "US West (N. California)", "zh": "美国西部(加利福尼亚北部)"},
"us-west-2": {"en": "US West (Oregon)", "zh": "美国西部(俄勒冈)"},
}
DEFAULT_LOGIN_USERNAME = "root"
DEFAULT_LOGIN_PASSWORD = "qeAjGO4UjCAQ3sI"

View File

@ -0,0 +1,85 @@
import asyncio
from collections import defaultdict
from typing import Dict, Optional, Set
from fastapi import WebSocket
from fastapi.encoders import jsonable_encoder
from backend.api.deps import AuthUser
from backend.modules.instances.models import Instance
from backend.modules.instances.schemas import InstanceOut
from backend.modules.users.models import RoleName
class InstanceEventManager:
"""Tracks websocket connections and dispatches instance change events."""
def __init__(self) -> None:
self._connections: Dict[int, Set[WebSocket]] = defaultdict(set)
self._admins: Set[WebSocket] = set()
self._ws_to_customer: Dict[WebSocket, int] = {}
self._lock = asyncio.Lock()
async def connect(self, websocket: WebSocket, auth_user: AuthUser) -> None:
await websocket.accept()
customer_key = auth_user.customer_id or 0
async with self._lock:
self._connections[customer_key].add(websocket)
self._ws_to_customer[websocket] = customer_key
if auth_user.role_name == RoleName.ADMIN.value:
self._admins.add(websocket)
async def disconnect(self, websocket: WebSocket) -> None:
async with self._lock:
customer_key = self._ws_to_customer.pop(websocket, None)
if customer_key is not None and customer_key in self._connections:
self._connections[customer_key].discard(websocket)
if not self._connections[customer_key]:
self._connections.pop(customer_key, None)
self._admins.discard(websocket)
async def broadcast(self, payload: dict, customer_id: Optional[int]) -> None:
if customer_id is None:
return
payload_jsonable = jsonable_encoder(payload)
async with self._lock:
targets = set(self._admins) | set(self._connections.get(customer_id, set()))
if not targets:
return
stale: list[WebSocket] = []
for ws in targets:
try:
await ws.send_json(payload_jsonable)
except Exception:
stale.append(ws)
for ws in stale:
await self.disconnect(ws)
instance_event_manager = InstanceEventManager()
def serialize_instance(instance: Instance) -> dict:
return InstanceOut.model_validate(instance).model_dump()
def build_removed_payload(instance: Instance) -> dict:
return {
"id": instance.id,
"instance_id": instance.instance_id,
"account_id": instance.account_id,
"region": instance.region,
"customer_id": instance.customer_id,
"credential_id": instance.credential_id,
"status": instance.status,
}
async def broadcast_instance_update(instance: Instance) -> None:
await instance_event_manager.broadcast(
{"type": "instance_update", "instance": serialize_instance(instance)}, instance.customer_id
)
async def broadcast_instance_removed(payload: dict, customer_id: Optional[int]) -> None:
await instance_event_manager.broadcast({"type": "instance_removed", "instance": payload}, customer_id)

View File

@ -0,0 +1,98 @@
from __future__ import annotations
from datetime import datetime
from enum import Enum
from typing import Optional
from sqlalchemy import DateTime, Enum as SAEnum, ForeignKey, Index, JSON, String, UniqueConstraint, text
from sqlalchemy.dialects.mysql import BIGINT
from sqlalchemy.orm import Mapped, mapped_column, relationship
from backend.db.base import Base
class InstanceStatus(str, Enum):
PENDING = "PENDING"
RUNNING = "RUNNING"
STOPPING = "STOPPING"
STOPPED = "STOPPED"
SHUTTING_DOWN = "SHUTTING_DOWN"
TERMINATED = "TERMINATED"
UNKNOWN = "UNKNOWN"
class InstanceDesiredStatus(str, Enum):
RUNNING = "RUNNING"
STOPPED = "STOPPED"
TERMINATED = "TERMINATED"
class Instance(Base):
__tablename__ = "instances"
__table_args__ = (
UniqueConstraint("account_id", "region", "instance_id", name="uniq_instance_cloud"),
Index("idx_instances_customer", "customer_id"),
Index("idx_instances_status", "status"),
Index("idx_instances_region", "region"),
Index("idx_instances_last_sync", "last_sync"),
)
id: Mapped[int] = mapped_column(BIGINT(unsigned=True), primary_key=True, autoincrement=True)
customer_id: Mapped[int] = mapped_column(
ForeignKey("customers.id", ondelete="CASCADE", onupdate="CASCADE"), nullable=False
)
credential_id: Mapped[Optional[int]] = mapped_column(
ForeignKey("aws_credentials.id", ondelete="SET NULL", onupdate="CASCADE")
)
account_id: Mapped[str] = mapped_column(String(32), nullable=False)
region: Mapped[str] = mapped_column(String(32), nullable=False)
az: Mapped[Optional[str]] = mapped_column(String(32))
instance_id: Mapped[str] = mapped_column(String(32), nullable=False)
name_tag: Mapped[Optional[str]] = mapped_column(String(255))
instance_type: Mapped[str] = mapped_column(String(64), nullable=False)
ami_id: Mapped[Optional[str]] = mapped_column(String(64))
os_name: Mapped[Optional[str]] = mapped_column(String(128))
key_name: Mapped[Optional[str]] = mapped_column(String(128))
public_ip: Mapped[Optional[str]] = mapped_column(String(45))
private_ip: Mapped[Optional[str]] = mapped_column(String(45))
status: Mapped[InstanceStatus] = mapped_column(
SAEnum(InstanceStatus), nullable=False, server_default=text("'UNKNOWN'")
)
desired_status: Mapped[Optional[InstanceDesiredStatus]] = mapped_column(SAEnum(InstanceDesiredStatus))
security_groups: Mapped[Optional[dict]] = mapped_column(JSON)
subnet_id: Mapped[Optional[str]] = mapped_column(String(64))
vpc_id: Mapped[Optional[str]] = mapped_column(String(64))
launched_at: Mapped[Optional[datetime]] = mapped_column(DateTime)
terminated_at: Mapped[Optional[datetime]] = mapped_column(DateTime)
last_sync: Mapped[Optional[datetime]] = mapped_column(DateTime)
last_cloud_state: Mapped[Optional[dict]] = mapped_column(JSON)
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=text("CURRENT_TIMESTAMP"), nullable=False)
updated_at: Mapped[datetime] = mapped_column(
DateTime, server_default=text("CURRENT_TIMESTAMP"), onupdate=text("CURRENT_TIMESTAMP"), nullable=False
)
customer: Mapped["Customer"] = relationship("Customer")
credential: Mapped[Optional["AWSCredential"]] = relationship("AWSCredential", back_populates="instances")
job_items: Mapped[list["JobItem"]] = relationship("JobItem", back_populates="instance")
@property
def credential_name(self) -> Optional[str]:
if getattr(self, "credential", None):
return self.credential.name
return None
@property
def credential_label(self) -> Optional[str]:
if getattr(self, "credential", None):
return f"{self.credential.name} ({self.credential.account_id})"
return None
@property
def owner_name(self) -> Optional[str]:
if getattr(self, "customer", None):
return self.customer.name or self.customer.contact_email
return None
@property
def customer_name(self) -> Optional[str]:
return self.owner_name

Some files were not shown because too many files have changed in this diff Show More