feat: Phase 4 - SQL AST analyzer, audit rules CRUD, Alembic, Docker (128 tests)
This commit is contained in:
1 parent
e45fbf3586
commit
ff0a330e8a
23 files changed
+1404
-159
No files matched your search
@@ -0,0 +1,6 @@
|
||||
venv/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.pytest_cache/
|
||||
*.db
|
||||
alembic/versions/__pycache__/
|
||||
+17
-2
@@ -2,15 +2,30 @@ FROM python:3.11-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install system deps
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
gcc libmariadb-dev libpq-dev && \
|
||||
gcc libffi-dev && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Install Python deps
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
# Copy app
|
||||
COPY . .
|
||||
|
||||
# Default env
|
||||
ENV DATABASE_URL="sqlite+aiosqlite:///./data/sqlite3.db"
|
||||
ENV SECRET_KEY="change-me-in-production"
|
||||
ENV ADMIN_USERNAME="admin"
|
||||
ENV ADMIN_PASSWORD="changeme"
|
||||
ENV ENV="production"
|
||||
ENV DEBUG="false"
|
||||
|
||||
# Create data dir
|
||||
RUN mkdir -p /app/data
|
||||
|
||||
EXPOSE 9000
|
||||
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "9000", "--reload"]
|
||||
# Run alembic + uvicorn
|
||||
CMD ["sh", "-c", "alembic upgrade head && python -m uvicorn app.main:app --host 0.0.0.0 --port 9000"]
|
||||
@@ -0,0 +1,147 @@
|
||||
# A generic, single database configuration.
|
||||
|
||||
[alembic]
|
||||
# path to migration scripts.
|
||||
# this is typically a path given in POSIX (e.g. forward slashes)
|
||||
# format, relative to the token %(here)s which refers to the location of this
|
||||
# ini file
|
||||
script_location = %(here)s/alembic
|
||||
|
||||
# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
|
||||
# Uncomment the line below if you want the files to be prepended with date and time
|
||||
# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file
|
||||
# for all available tokens
|
||||
# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s
|
||||
|
||||
# sys.path path, will be prepended to sys.path if present.
|
||||
# defaults to the current working directory. for multiple paths, the path separator
|
||||
# is defined by "path_separator" below.
|
||||
prepend_sys_path = .
|
||||
|
||||
|
||||
# timezone to use when rendering the date within the migration file
|
||||
# as well as the filename.
|
||||
# If specified, requires the python>=3.9 or backports.zoneinfo library and tzdata library.
|
||||
# Any required deps can installed by adding `alembic[tz]` to the pip requirements
|
||||
# string value is passed to ZoneInfo()
|
||||
# leave blank for localtime
|
||||
# timezone =
|
||||
|
||||
# max length of characters to apply to the "slug" field
|
||||
# truncate_slug_length = 40
|
||||
|
||||
# set to 'true' to run the environment during
|
||||
# the 'revision' command, regardless of autogenerate
|
||||
# revision_environment = false
|
||||
|
||||
# set to 'true' to allow .pyc and .pyo files without
|
||||
# a source .py file to be detected as revisions in the
|
||||
# versions/ directory
|
||||
# sourceless = false
|
||||
|
||||
# version location specification; This defaults
|
||||
# to <script_location>/versions. When using multiple version
|
||||
# directories, initial revisions must be specified with --version-path.
|
||||
# The path separator used here should be the separator specified by "path_separator"
|
||||
# below.
|
||||
# version_locations = %(here)s/bar:%(here)s/bat:%(here)s/alembic/versions
|
||||
|
||||
# path_separator; This indicates what character is used to split lists of file
|
||||
# paths, including version_locations and prepend_sys_path within configparser
|
||||
# files such as alembic.ini.
|
||||
# The default rendered in new alembic.ini files is "os", which uses os.pathsep
|
||||
# to provide os-dependent path splitting.
|
||||
#
|
||||
# Note that in order to support legacy alembic.ini files, this default does NOT
|
||||
# take place if path_separator is not present in alembic.ini. If this
|
||||
# option is omitted entirely, fallback logic is as follows:
|
||||
#
|
||||
# 1. Parsing of the version_locations option falls back to using the legacy
|
||||
# "version_path_separator" key, which if absent then falls back to the legacy
|
||||
# behavior of splitting on spaces and/or commas.
|
||||
# 2. Parsing of the prepend_sys_path option falls back to the legacy
|
||||
# behavior of splitting on spaces, commas, or colons.
|
||||
#
|
||||
# Valid values for path_separator are:
|
||||
#
|
||||
# path_separator = :
|
||||
# path_separator = ;
|
||||
# path_separator = space
|
||||
# path_separator = newline
|
||||
#
|
||||
# Use os.pathsep. Default configuration used for new projects.
|
||||
path_separator = os
|
||||
|
||||
# set to 'true' to search source files recursively
|
||||
# in each "version_locations" directory
|
||||
# new in Alembic version 1.10
|
||||
# recursive_version_locations = false
|
||||
|
||||
# the output encoding used when revision files
|
||||
# are written from script.py.mako
|
||||
# output_encoding = utf-8
|
||||
|
||||
# database URL. This is consumed by the user-maintained env.py script only.
|
||||
# other means of configuring database URLs may be customized within the env.py
|
||||
# file.
|
||||
sqlalchemy.url = driver://user:pass@localhost/dbname
|
||||
|
||||
|
||||
[post_write_hooks]
|
||||
# post_write_hooks defines scripts or Python functions that are run
|
||||
# on newly generated revision scripts. See the documentation for further
|
||||
# detail and examples
|
||||
|
||||
# format using "black" - use the console_scripts runner, against the "black" entrypoint
|
||||
# hooks = black
|
||||
# black.type = console_scripts
|
||||
# black.entrypoint = black
|
||||
# black.options = -l 79 REVISION_SCRIPT_FILENAME
|
||||
|
||||
# lint with attempts to fix using "ruff" - use the module runner, against the "ruff" module
|
||||
# hooks = ruff
|
||||
# ruff.type = module
|
||||
# ruff.module = ruff
|
||||
# ruff.options = check --fix REVISION_SCRIPT_FILENAME
|
||||
|
||||
# Alternatively, use the exec runner to execute a binary found on your PATH
|
||||
# hooks = ruff
|
||||
# ruff.type = exec
|
||||
# ruff.executable = ruff
|
||||
# ruff.options = check --fix REVISION_SCRIPT_FILENAME
|
||||
|
||||
# Logging configuration. This is also consumed by the user-maintained
|
||||
# env.py script only.
|
||||
[loggers]
|
||||
keys = root,sqlalchemy,alembic
|
||||
|
||||
[handlers]
|
||||
keys = console
|
||||
|
||||
[formatters]
|
||||
keys = generic
|
||||
|
||||
[logger_root]
|
||||
level = WARNING
|
||||
handlers = console
|
||||
qualname =
|
||||
|
||||
[logger_sqlalchemy]
|
||||
level = WARNING
|
||||
handlers =
|
||||
qualname = sqlalchemy.engine
|
||||
|
||||
[logger_alembic]
|
||||
level = INFO
|
||||
handlers =
|
||||
qualname = alembic
|
||||
|
||||
[handler_console]
|
||||
class = StreamHandler
|
||||
args = (sys.stderr,)
|
||||
level = NOTSET
|
||||
formatter = generic
|
||||
|
||||
[formatter_generic]
|
||||
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||
datefmt = %H:%M:%S
|
||||
@@ -0,0 +1 @@
|
||||
Generic single-database configuration.
|
||||
@@ -0,0 +1,61 @@
|
||||
from logging.config import fileConfig
|
||||
import os
|
||||
import sys
|
||||
|
||||
from sqlalchemy import engine_from_config
|
||||
from sqlalchemy import pool
|
||||
|
||||
from alembic import context
|
||||
|
||||
# Add project root to path
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
|
||||
|
||||
from app.core.database import Base
|
||||
from app.core.config import get_settings
|
||||
|
||||
# Import all models so Alembic can detect them
|
||||
from app.models.user import User # noqa
|
||||
from app.models.instance import Instance # noqa
|
||||
from app.models.ticket import Ticket # noqa
|
||||
from app.models.audit_log import AuditLog # noqa
|
||||
from app.models.audit_rule import AuditRule # noqa
|
||||
|
||||
config = context.config
|
||||
|
||||
# Override sqlalchemy.url from settings
|
||||
config.set_main_option("sqlalchemy.url", get_settings().DATABASE_URL.replace("+aiosqlite", ""))
|
||||
|
||||
if config.config_file_name is not None:
|
||||
fileConfig(config.config_file_name)
|
||||
|
||||
target_metadata = Base.metadata
|
||||
|
||||
|
||||
def run_migrations_offline() -> None:
|
||||
url = config.get_main_option("sqlalchemy.url")
|
||||
context.configure(
|
||||
url=url,
|
||||
target_metadata=target_metadata,
|
||||
literal_binds=True,
|
||||
dialect_opts={"paramstyle": "named"},
|
||||
)
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
def run_migrations_online() -> None:
|
||||
connectable = engine_from_config(
|
||||
config.get_section(config.config_ini_section, {}),
|
||||
prefix="sqlalchemy.",
|
||||
poolclass=pool.NullPool,
|
||||
)
|
||||
with connectable.connect() as connection:
|
||||
context.configure(connection=connection, target_metadata=target_metadata)
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
if context.is_offline_mode():
|
||||
run_migrations_offline()
|
||||
else:
|
||||
run_migrations_online()
|
||||
@@ -0,0 +1,28 @@
|
||||
"""${message}
|
||||
|
||||
Revision ID: ${up_revision}
|
||||
Revises: ${down_revision | comma,n}
|
||||
Create Date: ${create_date}
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
${imports if imports else ""}
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = ${repr(up_revision)}
|
||||
down_revision: Union[str, Sequence[str], None] = ${repr(down_revision)}
|
||||
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
|
||||
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Upgrade schema."""
|
||||
${upgrades if upgrades else "pass"}
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Downgrade schema."""
|
||||
${downgrades if downgrades else "pass"}
|
||||
@@ -0,0 +1,32 @@
|
||||
"""initial_schema
|
||||
|
||||
Revision ID: 5c8b75f38c7e
|
||||
Revises:
|
||||
Create Date: 2026-05-22 13:32:02.584732
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '5c8b75f38c7e'
|
||||
down_revision: Union[str, Sequence[str], None] = None
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Upgrade schema."""
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
pass
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Downgrade schema."""
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
pass
|
||||
# ### end Alembic commands ###
|
||||
@@ -0,0 +1,138 @@
|
||||
"""Audit rule management endpoints."""
|
||||
from typing import Optional, List
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy import select, func
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.core.security import require_role
|
||||
from app.models.user import User
|
||||
from app.models.audit_rule import AuditRule
|
||||
from app.schemas.audit_rule import AuditRuleCreate, AuditRuleUpdate, AuditRuleResponse
|
||||
|
||||
router = APIRouter(prefix="/audit-rules", tags=["审核规则"])
|
||||
|
||||
|
||||
@router.get("", response_model=List[AuditRuleResponse])
|
||||
async def list_audit_rules(
|
||||
rule_type: Optional[str] = None,
|
||||
severity: Optional[str] = None,
|
||||
db_type: Optional[str] = None,
|
||||
is_enabled: Optional[bool] = None,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(require_role("admin", "dba")),
|
||||
):
|
||||
stmt = select(AuditRule).order_by(AuditRule.id)
|
||||
if rule_type:
|
||||
stmt = stmt.where(AuditRule.rule_type == rule_type)
|
||||
if severity:
|
||||
stmt = stmt.where(AuditRule.severity == severity)
|
||||
if db_type:
|
||||
stmt = stmt.where((AuditRule.db_type == db_type) | (AuditRule.db_type == "all"))
|
||||
if is_enabled is not None:
|
||||
stmt = stmt.where(AuditRule.is_enabled == is_enabled)
|
||||
|
||||
result = await db.execute(stmt)
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
@router.post("", response_model=AuditRuleResponse, status_code=201)
|
||||
async def create_audit_rule(
|
||||
data: AuditRuleCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(require_role("admin")),
|
||||
):
|
||||
exists = await db.execute(select(AuditRule).where(AuditRule.name == data.name))
|
||||
if exists.scalar_one_or_none():
|
||||
raise HTTPException(status_code=400, detail="规则名称已存在")
|
||||
|
||||
rule = AuditRule(**data.model_dump())
|
||||
db.add(rule)
|
||||
await db.commit()
|
||||
await db.refresh(rule)
|
||||
return rule
|
||||
|
||||
|
||||
@router.get("/{rule_id}", response_model=AuditRuleResponse)
|
||||
async def get_audit_rule(
|
||||
rule_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(require_role("admin", "dba")),
|
||||
):
|
||||
result = await db.execute(select(AuditRule).where(AuditRule.id == rule_id))
|
||||
rule = result.scalar_one_or_none()
|
||||
if not rule:
|
||||
raise HTTPException(status_code=404, detail="规则不存在")
|
||||
return rule
|
||||
|
||||
|
||||
@router.put("/{rule_id}", response_model=AuditRuleResponse)
|
||||
async def update_audit_rule(
|
||||
rule_id: int,
|
||||
data: AuditRuleUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(require_role("admin")),
|
||||
):
|
||||
result = await db.execute(select(AuditRule).where(AuditRule.id == rule_id))
|
||||
rule = result.scalar_one_or_none()
|
||||
if not rule:
|
||||
raise HTTPException(status_code=404, detail="规则不存在")
|
||||
|
||||
update_data = data.model_dump(exclude_unset=True)
|
||||
for key, value in update_data.items():
|
||||
setattr(rule, key, value)
|
||||
await db.commit()
|
||||
await db.refresh(rule)
|
||||
return rule
|
||||
|
||||
|
||||
@router.delete("/{rule_id}")
|
||||
async def delete_audit_rule(
|
||||
rule_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(require_role("admin")),
|
||||
):
|
||||
result = await db.execute(select(AuditRule).where(AuditRule.id == rule_id))
|
||||
rule = result.scalar_one_or_none()
|
||||
if not rule:
|
||||
raise HTTPException(status_code=404, detail="规则不存在")
|
||||
|
||||
await db.delete(rule)
|
||||
await db.commit()
|
||||
return {"message": "规则已删除"}
|
||||
|
||||
|
||||
@router.post("/{rule_id}/toggle", response_model=AuditRuleResponse)
|
||||
async def toggle_audit_rule(
|
||||
rule_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(require_role("admin", "dba")),
|
||||
):
|
||||
result = await db.execute(select(AuditRule).where(AuditRule.id == rule_id))
|
||||
rule = result.scalar_one_or_none()
|
||||
if not rule:
|
||||
raise HTTPException(status_code=404, detail="规则不存在")
|
||||
|
||||
rule.is_enabled = not rule.is_enabled
|
||||
await db.commit()
|
||||
await db.refresh(rule)
|
||||
return rule
|
||||
|
||||
|
||||
@router.get("/stats/summary")
|
||||
async def rule_stats(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(require_role("admin", "dba")),
|
||||
):
|
||||
total_stmt = select(func.count(AuditRule.id))
|
||||
total = (await db.execute(total_stmt)).scalar()
|
||||
|
||||
enabled_stmt = select(func.count(AuditRule.id)).where(AuditRule.is_enabled == True)
|
||||
enabled = (await db.execute(enabled_stmt)).scalar()
|
||||
|
||||
type_stmt = select(AuditRule.rule_type, func.count(AuditRule.id)).group_by(AuditRule.rule_type)
|
||||
type_result = (await db.execute(type_stmt)).all()
|
||||
by_type = {row[0]: row[1] for row in type_result}
|
||||
|
||||
return {"total": total, "enabled": enabled, "disabled": total - enabled, "by_type": by_type}
|
||||
@@ -1,6 +1,6 @@
|
||||
from fastapi import APIRouter
|
||||
|
||||
from app.api.v1.endpoints import auth, user, instance, ticket, query, dashboard, audit
|
||||
from app.api.v1.endpoints import auth, user, instance, ticket, query, dashboard, audit, audit_rule
|
||||
|
||||
api_router = APIRouter(prefix="/api/v1")
|
||||
|
||||
@@ -11,3 +11,4 @@ api_router.include_router(ticket.router)
|
||||
api_router.include_router(query.router)
|
||||
api_router.include_router(dashboard.router)
|
||||
api_router.include_router(audit.router)
|
||||
api_router.include_router(audit_rule.router)
|
||||
@@ -48,6 +48,7 @@ async def lifespan(app: FastAPI):
|
||||
import app.models.instance # noqa
|
||||
import app.models.ticket # noqa
|
||||
import app.models.audit_log # noqa
|
||||
import app.models.audit_rule # noqa
|
||||
|
||||
await init_db()
|
||||
await seed_data()
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
from sqlalchemy import Column, Integer, String, Text, Boolean, DateTime, func
|
||||
|
||||
from app.core.database import Base
|
||||
|
||||
|
||||
class AuditRule(Base):
|
||||
__tablename__ = "audit_rules"
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
name = Column(String(100), nullable=False, unique=True)
|
||||
description = Column(Text, nullable=True)
|
||||
rule_type = Column(String(50), nullable=False) # syntax / performance / safety / naming
|
||||
severity = Column(String(20), default="warning") # error / warning / info
|
||||
db_type = Column(String(20), default="mysql") # mysql / postgres / all
|
||||
pattern = Column(Text, nullable=True) # regex or keyword pattern
|
||||
is_enabled = Column(Boolean, default=True)
|
||||
message = Column(String(500), nullable=False) # human-readable message
|
||||
created_at = Column(DateTime, server_default=func.now())
|
||||
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
|
||||
@@ -0,0 +1,42 @@
|
||||
from datetime import datetime
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import Optional, Literal
|
||||
|
||||
|
||||
class AuditRuleCreate(BaseModel):
|
||||
name: str = Field(..., min_length=1, max_length=100)
|
||||
description: Optional[str] = None
|
||||
rule_type: Literal["syntax", "performance", "safety", "naming"]
|
||||
severity: Literal["error", "warning", "info"] = "warning"
|
||||
db_type: str = "mysql"
|
||||
pattern: Optional[str] = None
|
||||
is_enabled: bool = True
|
||||
message: str = Field(..., min_length=1, max_length=500)
|
||||
|
||||
|
||||
class AuditRuleUpdate(BaseModel):
|
||||
name: Optional[str] = Field(default=None, min_length=1, max_length=100)
|
||||
description: Optional[str] = None
|
||||
rule_type: Optional[Literal["syntax", "performance", "safety", "naming"]] = None
|
||||
severity: Optional[Literal["error", "warning", "info"]] = None
|
||||
db_type: Optional[str] = None
|
||||
pattern: Optional[str] = None
|
||||
is_enabled: Optional[bool] = None
|
||||
message: Optional[str] = Field(default=None, min_length=1, max_length=500)
|
||||
|
||||
|
||||
class AuditRuleResponse(BaseModel):
|
||||
id: int
|
||||
name: str
|
||||
description: Optional[str] = None
|
||||
rule_type: str
|
||||
severity: str
|
||||
db_type: str
|
||||
pattern: Optional[str] = None
|
||||
is_enabled: bool
|
||||
message: str
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
@@ -5,13 +5,14 @@ from typing import Optional
|
||||
|
||||
from app.models.instance import Instance
|
||||
from app.schemas.query import QueryResult
|
||||
from app.services.sql_analyzer import SQLAnalyzer
|
||||
|
||||
|
||||
FORBIDDEN_KEYWORDS = ["DROP", "TRUNCATE", "DELETE", "UPDATE", "INSERT", "CREATE", "ALTER", "GRANT", "REVOKE"]
|
||||
MAX_ROWS = 10000
|
||||
|
||||
|
||||
class QueryService:
|
||||
_analyzer = SQLAnalyzer()
|
||||
|
||||
@staticmethod
|
||||
async def execute_query(
|
||||
instance: Instance, database: Optional[str], sql: str, user_id: int
|
||||
@@ -19,11 +20,13 @@ class QueryService:
|
||||
if instance.db_type != "mysql":
|
||||
raise ValueError(f"暂不支持 {instance.db_type} 查询")
|
||||
|
||||
upper_sql = sql.strip().upper()
|
||||
for kw in FORBIDDEN_KEYWORDS:
|
||||
if upper_sql.startswith(kw) or f" {kw} " in upper_sql:
|
||||
raise ValueError(f"查询模式禁止执行 {kw} 操作")
|
||||
# AST-based SQL validation (replaces keyword matching)
|
||||
is_safe, error_msg = QueryService._analyzer.validate_readonly(sql)
|
||||
if not is_safe:
|
||||
raise ValueError(error_msg)
|
||||
|
||||
# Auto-add LIMIT if missing
|
||||
upper_sql = sql.strip().upper()
|
||||
if not re.search(r"LIMIT\s+\d+", upper_sql, re.IGNORECASE):
|
||||
sql = sql.rstrip(";") + f" LIMIT {MAX_ROWS};"
|
||||
|
||||
|
||||
@@ -0,0 +1,255 @@
|
||||
"""SQL AST Analyzer — parse SQL using sqlparse for safe query validation.
|
||||
|
||||
Used by query endpoint to determine if a SQL statement is safe for read-only execution.
|
||||
Separate from audit_engine.py (which is for ticket review scoring).
|
||||
"""
|
||||
import sqlparse
|
||||
from sqlparse.sql import IdentifierList, Identifier, Where, Comparison
|
||||
from sqlparse.tokens import Keyword, DML, DDL, Punctuation
|
||||
from dataclasses import dataclass
|
||||
from typing import List, Optional
|
||||
from enum import Enum
|
||||
|
||||
|
||||
class SQLType(str, Enum):
|
||||
SELECT = "SELECT"
|
||||
INSERT = "INSERT"
|
||||
UPDATE = "UPDATE"
|
||||
DELETE = "DELETE"
|
||||
CREATE = "CREATE"
|
||||
ALTER = "ALTER"
|
||||
DROP = "DROP"
|
||||
TRUNCATE = "TRUNCATE"
|
||||
UNKNOWN = "UNKNOWN"
|
||||
|
||||
|
||||
class RiskLevel(str, Enum):
|
||||
SAFE = "safe" # SELECT only
|
||||
LOW = "low" # SELECT with functions
|
||||
MEDIUM = "medium" # DML with WHERE
|
||||
HIGH = "high" # DML without WHERE
|
||||
DANGEROUS = "dangerous" # DDL operations
|
||||
|
||||
|
||||
@dataclass
|
||||
class SQLAnalysis:
|
||||
sql_type: SQLType
|
||||
risk_level: RiskLevel
|
||||
is_readonly: bool
|
||||
has_where: bool
|
||||
tables: List[str]
|
||||
warnings: List[str]
|
||||
errors: List[str]
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"sql_type": self.sql_type.value,
|
||||
"risk_level": self.risk_level.value,
|
||||
"is_readonly": self.is_readonly,
|
||||
"has_where": self.has_where,
|
||||
"tables": self.tables,
|
||||
"warnings": self.warnings,
|
||||
"errors": self.errors,
|
||||
}
|
||||
|
||||
|
||||
class SQLAnalyzer:
|
||||
"""Parse and analyze SQL statements using sqlparse AST."""
|
||||
|
||||
# Statements that are always read-only
|
||||
READONLY_TYPES = {SQLType.SELECT}
|
||||
|
||||
# Statements that modify data
|
||||
DML_TYPES = {SQLType.INSERT, SQLType.UPDATE, SQLType.DELETE}
|
||||
|
||||
# Statements that change schema
|
||||
DDL_TYPES = {SQLType.CREATE, SQLType.ALTER, SQLType.DROP, SQLType.TRUNCATE}
|
||||
|
||||
def analyze(self, sql: str) -> SQLAnalysis:
|
||||
"""Analyze a SQL statement and return detailed analysis."""
|
||||
sql = sql.strip()
|
||||
|
||||
# Detect SQL type from the first meaningful token
|
||||
sql_type = self._detect_sql_type(sql)
|
||||
|
||||
# Parse with sqlparse
|
||||
parsed = sqlparse.parse(sql)
|
||||
if not parsed:
|
||||
return SQLAnalysis(
|
||||
sql_type=SQLType.UNKNOWN, risk_level=RiskLevel.SAFE,
|
||||
is_readonly=True, has_where=False, tables=[],
|
||||
warnings=[], errors=["无法解析 SQL 语句"],
|
||||
)
|
||||
|
||||
statement = parsed[0]
|
||||
tokens = list(statement.flatten())
|
||||
|
||||
# Extract tables
|
||||
tables = self._extract_tables(statement)
|
||||
|
||||
# Check for WHERE clause
|
||||
has_where = self._has_where(statement)
|
||||
|
||||
# Check for multiple statements (SQL injection vector)
|
||||
multi_errors = self._check_multi_statement(sql)
|
||||
|
||||
# Determine risk level and generate warnings/errors
|
||||
warnings = []
|
||||
errors = list(multi_errors)
|
||||
|
||||
if sql_type in self.DDL_TYPES:
|
||||
risk_level = RiskLevel.DANGEROUS
|
||||
is_readonly = False
|
||||
errors.append(f"DDL 操作 ({sql_type.value}) 在查询模式下被禁止")
|
||||
|
||||
elif sql_type in self.DML_TYPES:
|
||||
is_readonly = False
|
||||
if has_where:
|
||||
risk_level = RiskLevel.MEDIUM
|
||||
warnings.append(f"{sql_type.value} 操作包含 WHERE 条件,但查询模式为只读")
|
||||
else:
|
||||
risk_level = RiskLevel.HIGH
|
||||
errors.append(f"{sql_type.value} 缺少 WHERE 条件,影响全表")
|
||||
errors.append(f"查询模式禁止执行 {sql_type.value} 操作")
|
||||
|
||||
elif sql_type == SQLType.SELECT:
|
||||
is_readonly = True
|
||||
risk_level = RiskLevel.SAFE
|
||||
# Check for INTO OUTFILE (writes to filesystem)
|
||||
if self._has_into_outfile(statement):
|
||||
risk_level = RiskLevel.DANGEROUS
|
||||
is_readonly = False
|
||||
errors.append("SELECT INTO OUTFILE 被禁止(文件写入风险)")
|
||||
|
||||
# Check for subqueries that modify data
|
||||
sub_type = self._check_subquery_dml(sql)
|
||||
if sub_type:
|
||||
is_readonly = False
|
||||
risk_level = RiskLevel.DANGEROUS
|
||||
errors.append(f"子查询包含 {sub_type} 操作,被禁止")
|
||||
else:
|
||||
is_readonly = True
|
||||
risk_level = RiskLevel.LOW
|
||||
|
||||
return SQLAnalysis(
|
||||
sql_type=sql_type,
|
||||
risk_level=risk_level,
|
||||
is_readonly=is_readonly,
|
||||
has_where=has_where,
|
||||
tables=tables,
|
||||
warnings=warnings,
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
def validate_readonly(self, sql: str) -> tuple[bool, str]:
|
||||
"""Validate SQL is read-only safe. Returns (is_safe, error_message)."""
|
||||
analysis = self.analyze(sql)
|
||||
if analysis.errors:
|
||||
return False, "; ".join(analysis.errors)
|
||||
if not analysis.is_readonly:
|
||||
return False, f"查询模式仅允许 SELECT 语句,当前为 {analysis.sql_type.value}"
|
||||
return True, ""
|
||||
|
||||
def _detect_sql_type(self, sql: str) -> SQLType:
|
||||
"""Detect the SQL statement type from the first keyword."""
|
||||
upper = sql.strip().upper()
|
||||
type_map = {
|
||||
"SELECT": SQLType.SELECT,
|
||||
"INSERT": SQLType.INSERT,
|
||||
"UPDATE": SQLType.UPDATE,
|
||||
"DELETE": SQLType.DELETE,
|
||||
"CREATE": SQLType.CREATE,
|
||||
"ALTER": SQLType.ALTER,
|
||||
"DROP": SQLType.DROP,
|
||||
"TRUNCATE": SQLType.TRUNCATE,
|
||||
"WITH": SQLType.SELECT, # CTE is still SELECT
|
||||
"EXPLAIN": SQLType.SELECT,
|
||||
}
|
||||
for keyword, sql_type in type_map.items():
|
||||
if upper.startswith(keyword):
|
||||
return sql_type
|
||||
return SQLType.UNKNOWN
|
||||
|
||||
def _extract_tables(self, statement) -> List[str]:
|
||||
"""Extract table names from the statement."""
|
||||
tables = []
|
||||
seen = set()
|
||||
|
||||
# Get tokens after FROM or JOIN or INTO or UPDATE
|
||||
tokens = list(statement.tokens)
|
||||
capture = False
|
||||
for token in tokens:
|
||||
if token.ttype in (Keyword,) and token.normalized in ("FROM", "INTO", "UPDATE", "JOIN", "TABLE"):
|
||||
capture = True
|
||||
continue
|
||||
if capture:
|
||||
if isinstance(token, IdentifierList):
|
||||
for ident in token.get_identifiers():
|
||||
name = self._get_identifier_name(ident)
|
||||
if name and name not in seen:
|
||||
seen.add(name)
|
||||
tables.append(name)
|
||||
elif isinstance(token, Identifier):
|
||||
name = self._get_identifier_name(token)
|
||||
if name and name not in seen:
|
||||
seen.add(name)
|
||||
tables.append(name)
|
||||
capture = False
|
||||
|
||||
return tables
|
||||
|
||||
def _get_identifier_name(self, ident) -> Optional[str]:
|
||||
"""Get the table name from an identifier."""
|
||||
if isinstance(ident, Identifier):
|
||||
# Use the real name (not alias)
|
||||
return ident.get_real_name()
|
||||
return None
|
||||
|
||||
def _has_where(self, statement) -> bool:
|
||||
"""Check if the statement has a WHERE clause."""
|
||||
for token in statement.tokens:
|
||||
if isinstance(token, Where):
|
||||
return True
|
||||
# Also check flattened tokens
|
||||
for token in statement.flatten():
|
||||
if token.ttype is Keyword and token.normalized == "WHERE":
|
||||
return True
|
||||
return False
|
||||
|
||||
def _has_into_outfile(self, statement) -> bool:
|
||||
"""Check for INTO OUTFILE (filesystem write)."""
|
||||
upper = str(statement).upper()
|
||||
return "INTO OUTFILE" in upper or "INTO DUMPFILE" in upper
|
||||
|
||||
def _check_multi_statement(self, sql: str) -> List[str]:
|
||||
"""Check for multiple statements (semicolons inside the SQL)."""
|
||||
errors = []
|
||||
stripped = sql.strip().rstrip(";")
|
||||
# Count semicolons not inside strings
|
||||
in_string = False
|
||||
string_char = None
|
||||
semi_count = 0
|
||||
for ch in stripped:
|
||||
if ch in ("'", '"') and not in_string:
|
||||
in_string = True
|
||||
string_char = ch
|
||||
elif ch == string_char and in_string:
|
||||
in_string = False
|
||||
string_char = None
|
||||
elif ch == ";" and not in_string:
|
||||
semi_count += 1
|
||||
|
||||
if semi_count > 0:
|
||||
errors.append("查询不允许包含多条 SQL 语句(分号分隔)")
|
||||
return errors
|
||||
|
||||
def _check_subquery_dml(self, sql: str) -> Optional[str]:
|
||||
"""Check if a SELECT contains DML subqueries."""
|
||||
upper = sql.upper()
|
||||
# Simple heuristic: look for INSERT/UPDATE/DELETE after a parenthesis
|
||||
for kw in ("INSERT", "UPDATE", "DELETE", "CREATE", "ALTER", "DROP"):
|
||||
# Check if keyword appears inside parentheses
|
||||
pattern = f"({kw} "
|
||||
if pattern in upper:
|
||||
return kw
|
||||
return None
|
||||
+12
-15
@@ -1,15 +1,12 @@
|
||||
fastapi==0.115.6
|
||||
uvicorn[standard]==0.34.0
|
||||
sqlalchemy==2.0.36
|
||||
aiosqlite==0.20.0
|
||||
python-jose[cryptography]==3.3.0
|
||||
passlib[bcrypt]==1.7.4
|
||||
bcrypt==4.1.2
|
||||
python-multipart==0.0.20
|
||||
pydantic==2.10.4
|
||||
pydantic-settings==2.7.1
|
||||
httpx==0.28.1
|
||||
pymysql==1.1.1
|
||||
aiomysql==0.2.0
|
||||
cryptography==44.0.0
|
||||
greenlet==3.1.1
|
||||
fastapi>=0.104.0
|
||||
uvicorn[standard]>=0.24.0
|
||||
sqlalchemy[asyncio]>=2.0.23
|
||||
aiosqlite>=0.19.0
|
||||
pydantic>=2.5.0
|
||||
pydantic-settings>=2.1.0
|
||||
python-jose[cryptography]>=3.3.0
|
||||
passlib[bcrypt]>=1.7.4
|
||||
python-multipart>=0.0.6
|
||||
cryptography>=41.0.0
|
||||
sqlparse>=0.4.4
|
||||
alembic>=1.13.0
|
||||
@@ -11,6 +11,7 @@ from app.models.user import User
|
||||
from app.models.instance import Instance
|
||||
from app.models.ticket import Ticket
|
||||
from app.models.audit_log import AuditLog
|
||||
from app.models.audit_rule import AuditRule
|
||||
|
||||
SQLALCHEMY_DATABASE_URL = "sqlite+aiosqlite://"
|
||||
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
"""Tests for Audit Rule CRUD API (Phase 4.2)."""
|
||||
import pytest
|
||||
from tests.conftest import auth_header
|
||||
|
||||
|
||||
class TestListAuditRules:
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_rules_admin(self, client, admin_token):
|
||||
resp = await client.get("/api/v1/audit-rules", headers=auth_header(admin_token))
|
||||
assert resp.status_code == 200
|
||||
assert isinstance(resp.json(), list)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_rules_dba(self, client, dba_token):
|
||||
resp = await client.get("/api/v1/audit-rules", headers=auth_header(dba_token))
|
||||
assert resp.status_code == 200
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_rules_developer_forbidden(self, client, developer_token):
|
||||
resp = await client.get("/api/v1/audit-rules", headers=auth_header(developer_token))
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
class TestCreateAuditRule:
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_rule_success(self, client, admin_token):
|
||||
resp = await client.post(
|
||||
"/api/v1/audit-rules",
|
||||
json={
|
||||
"name": "no_select_star",
|
||||
"description": "禁止 SELECT *",
|
||||
"rule_type": "performance",
|
||||
"severity": "warning",
|
||||
"db_type": "mysql",
|
||||
"pattern": r"SELECT\s+\*",
|
||||
"message": "禁止使用 SELECT *,请明确指定字段",
|
||||
},
|
||||
headers=auth_header(admin_token),
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
data = resp.json()
|
||||
assert data["name"] == "no_select_star"
|
||||
assert data["rule_type"] == "performance"
|
||||
assert data["is_enabled"] is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_duplicate_name(self, client, admin_token):
|
||||
payload = {
|
||||
"name": "dup_rule",
|
||||
"rule_type": "safety",
|
||||
"severity": "error",
|
||||
"message": "test",
|
||||
}
|
||||
await client.post("/api/v1/audit-rules", json=payload, headers=auth_header(admin_token))
|
||||
resp = await client.post("/api/v1/audit-rules", json=payload, headers=auth_header(admin_token))
|
||||
assert resp.status_code == 400
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_rule_non_admin(self, client, dba_token):
|
||||
resp = await client.post(
|
||||
"/api/v1/audit-rules",
|
||||
json={"name": "dba_rule", "rule_type": "syntax", "severity": "info", "message": "test"},
|
||||
headers=auth_header(dba_token),
|
||||
)
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
class TestGetAuditRule:
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_rule(self, client, admin_token):
|
||||
create = await client.post(
|
||||
"/api/v1/audit-rules",
|
||||
json={"name": "get_test", "rule_type": "naming", "severity": "info", "message": "test"},
|
||||
headers=auth_header(admin_token),
|
||||
)
|
||||
rule_id = create.json()["id"]
|
||||
resp = await client.get(f"/api/v1/audit-rules/{rule_id}", headers=auth_header(admin_token))
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["name"] == "get_test"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_nonexistent_rule(self, client, admin_token):
|
||||
resp = await client.get("/api/v1/audit-rules/99999", headers=auth_header(admin_token))
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
class TestUpdateAuditRule:
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_rule(self, client, admin_token):
|
||||
create = await client.post(
|
||||
"/api/v1/audit-rules",
|
||||
json={"name": "update_test", "rule_type": "safety", "severity": "warning", "message": "old msg"},
|
||||
headers=auth_header(admin_token),
|
||||
)
|
||||
rule_id = create.json()["id"]
|
||||
resp = await client.put(
|
||||
f"/api/v1/audit-rules/{rule_id}",
|
||||
json={"severity": "error", "message": "new msg"},
|
||||
headers=auth_header(admin_token),
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["severity"] == "error"
|
||||
assert resp.json()["message"] == "new msg"
|
||||
|
||||
|
||||
class TestDeleteAuditRule:
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_rule(self, client, admin_token):
|
||||
create = await client.post(
|
||||
"/api/v1/audit-rules",
|
||||
json={"name": "delete_test", "rule_type": "syntax", "severity": "info", "message": "test"},
|
||||
headers=auth_header(admin_token),
|
||||
)
|
||||
rule_id = create.json()["id"]
|
||||
resp = await client.delete(f"/api/v1/audit-rules/{rule_id}", headers=auth_header(admin_token))
|
||||
assert resp.status_code == 200
|
||||
|
||||
# Verify deleted
|
||||
get = await client.get(f"/api/v1/audit-rules/{rule_id}", headers=auth_header(admin_token))
|
||||
assert get.status_code == 404
|
||||
|
||||
|
||||
class TestToggleAuditRule:
|
||||
@pytest.mark.asyncio
|
||||
async def test_toggle_rule(self, client, admin_token):
|
||||
create = await client.post(
|
||||
"/api/v1/audit-rules",
|
||||
json={"name": "toggle_test", "rule_type": "safety", "severity": "warning", "message": "test"},
|
||||
headers=auth_header(admin_token),
|
||||
)
|
||||
rule_id = create.json()["id"]
|
||||
assert create.json()["is_enabled"] is True
|
||||
|
||||
# Toggle off
|
||||
resp = await client.post(f"/api/v1/audit-rules/{rule_id}/toggle", headers=auth_header(admin_token))
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["is_enabled"] is False
|
||||
|
||||
# Toggle back on
|
||||
resp = await client.post(f"/api/v1/audit-rules/{rule_id}/toggle", headers=auth_header(admin_token))
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["is_enabled"] is True
|
||||
|
||||
|
||||
class TestRuleStats:
|
||||
@pytest.mark.asyncio
|
||||
async def test_rule_stats(self, client, admin_token):
|
||||
# Create some rules
|
||||
await client.post(
|
||||
"/api/v1/audit-rules",
|
||||
json={"name": "stats_rule_1", "rule_type": "safety", "severity": "error", "message": "test"},
|
||||
headers=auth_header(admin_token),
|
||||
)
|
||||
await client.post(
|
||||
"/api/v1/audit-rules",
|
||||
json={"name": "stats_rule_2", "rule_type": "performance", "severity": "warning", "message": "test"},
|
||||
headers=auth_header(admin_token),
|
||||
)
|
||||
|
||||
resp = await client.get("/api/v1/audit-rules/stats/summary", headers=auth_header(admin_token))
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["total"] >= 2
|
||||
assert data["enabled"] >= 2
|
||||
assert "by_type" in data
|
||||
@@ -0,0 +1,157 @@
|
||||
"""Tests for SQL AST Analyzer (Phase 4.1)."""
|
||||
import pytest
|
||||
from app.services.sql_analyzer import SQLAnalyzer, SQLType, RiskLevel
|
||||
|
||||
|
||||
analyzer = SQLAnalyzer()
|
||||
|
||||
|
||||
class TestSQLTypeDetection:
|
||||
def test_select(self):
|
||||
result = analyzer.analyze("SELECT id, name FROM users WHERE id = 1;")
|
||||
assert result.sql_type == SQLType.SELECT
|
||||
|
||||
def test_insert(self):
|
||||
result = analyzer.analyze("INSERT INTO users (name) VALUES ('test');")
|
||||
assert result.sql_type == SQLType.INSERT
|
||||
|
||||
def test_update(self):
|
||||
result = analyzer.analyze("UPDATE users SET name = 'test' WHERE id = 1;")
|
||||
assert result.sql_type == SQLType.UPDATE
|
||||
|
||||
def test_delete(self):
|
||||
result = analyzer.analyze("DELETE FROM users WHERE id = 1;")
|
||||
assert result.sql_type == SQLType.DELETE
|
||||
|
||||
def test_drop_table(self):
|
||||
result = analyzer.analyze("DROP TABLE users;")
|
||||
assert result.sql_type == SQLType.DROP
|
||||
|
||||
def test_truncate(self):
|
||||
result = analyzer.analyze("TRUNCATE TABLE logs;")
|
||||
assert result.sql_type == SQLType.TRUNCATE
|
||||
|
||||
def test_alter_table(self):
|
||||
result = analyzer.analyze("ALTER TABLE users ADD COLUMN age INT;")
|
||||
assert result.sql_type == SQLType.ALTER
|
||||
|
||||
def test_create_table(self):
|
||||
result = analyzer.analyze("CREATE TABLE test (id INT PRIMARY KEY);")
|
||||
assert result.sql_type == SQLType.CREATE
|
||||
|
||||
def test_cte_select(self):
|
||||
result = analyzer.analyze("WITH cte AS (SELECT 1) SELECT * FROM cte;")
|
||||
assert result.sql_type == SQLType.SELECT
|
||||
|
||||
def test_explain(self):
|
||||
result = analyzer.analyze("EXPLAIN SELECT * FROM users;")
|
||||
assert result.sql_type == SQLType.SELECT
|
||||
|
||||
|
||||
class TestReadonlyValidation:
|
||||
def test_select_is_safe(self):
|
||||
safe, msg = analyzer.validate_readonly("SELECT id FROM users LIMIT 10;")
|
||||
assert safe is True
|
||||
|
||||
def test_insert_not_safe(self):
|
||||
safe, msg = analyzer.validate_readonly("INSERT INTO users (name) VALUES ('test');")
|
||||
assert safe is False
|
||||
assert "INSERT" in msg
|
||||
|
||||
def test_update_not_safe(self):
|
||||
safe, msg = analyzer.validate_readonly("UPDATE users SET name = 'test' WHERE id = 1;")
|
||||
assert safe is False
|
||||
|
||||
def test_delete_not_safe(self):
|
||||
safe, msg = analyzer.validate_readonly("DELETE FROM users WHERE id = 1;")
|
||||
assert safe is False
|
||||
|
||||
def test_drop_not_safe(self):
|
||||
safe, msg = analyzer.validate_readonly("DROP TABLE users;")
|
||||
assert safe is False
|
||||
|
||||
def test_truncate_not_safe(self):
|
||||
safe, msg = analyzer.validate_readonly("TRUNCATE TABLE logs;")
|
||||
assert safe is False
|
||||
|
||||
def test_alter_not_safe(self):
|
||||
safe, msg = analyzer.validate_readonly("ALTER TABLE users ADD COLUMN age INT;")
|
||||
assert safe is False
|
||||
|
||||
def test_create_not_safe(self):
|
||||
safe, msg = analyzer.validate_readonly("CREATE TABLE test (id INT);")
|
||||
assert safe is False
|
||||
|
||||
|
||||
class TestRiskLevels:
|
||||
def test_select_is_safe_risk(self):
|
||||
result = analyzer.analyze("SELECT * FROM users;")
|
||||
assert result.risk_level == RiskLevel.SAFE
|
||||
|
||||
def test_dml_with_where_is_medium(self):
|
||||
result = analyzer.analyze("UPDATE users SET name = 'a' WHERE id = 1;")
|
||||
assert result.risk_level == RiskLevel.MEDIUM
|
||||
|
||||
def test_dml_without_where_is_high(self):
|
||||
result = analyzer.analyze("UPDATE users SET name = 'a';")
|
||||
assert result.risk_level == RiskLevel.HIGH
|
||||
|
||||
def test_ddl_is_dangerous(self):
|
||||
result = analyzer.analyze("DROP TABLE users;")
|
||||
assert result.risk_level == RiskLevel.DANGEROUS
|
||||
|
||||
|
||||
class TestSecurityChecks:
|
||||
def test_multi_statement_rejected(self):
|
||||
safe, msg = analyzer.validate_readonly("SELECT 1; DROP TABLE users;")
|
||||
assert safe is False
|
||||
assert "多条" in msg or "分号" in msg
|
||||
|
||||
def test_into_outfile_rejected(self):
|
||||
safe, msg = analyzer.validate_readonly("SELECT * INTO OUTFILE '/tmp/data.txt' FROM users;")
|
||||
assert safe is False
|
||||
assert "OUTFILE" in msg
|
||||
|
||||
def test_subquery_dml_rejected(self):
|
||||
safe, msg = analyzer.validate_readonly("SELECT * FROM (DELETE FROM users) AS t;")
|
||||
assert safe is False
|
||||
|
||||
def test_normal_subquery_ok(self):
|
||||
safe, msg = analyzer.validate_readonly("SELECT * FROM (SELECT id FROM users) AS t;")
|
||||
assert safe is True
|
||||
|
||||
def test_semicolon_in_string_ok(self):
|
||||
safe, msg = analyzer.validate_readonly("SELECT 'hello;world' FROM users;")
|
||||
assert safe is True
|
||||
|
||||
|
||||
class TestAnalysis:
|
||||
def test_has_where(self):
|
||||
result = analyzer.analyze("SELECT * FROM users WHERE id = 1;")
|
||||
assert result.has_where is True
|
||||
|
||||
def test_no_where(self):
|
||||
result = analyzer.analyze("SELECT * FROM users;")
|
||||
assert result.has_where is False
|
||||
|
||||
def test_to_dict(self):
|
||||
result = analyzer.analyze("SELECT id FROM users;")
|
||||
d = result.to_dict()
|
||||
assert "sql_type" in d
|
||||
assert "risk_level" in d
|
||||
assert "is_readonly" in d
|
||||
assert d["sql_type"] == "SELECT"
|
||||
assert d["is_readonly"] is True
|
||||
|
||||
def test_complex_select(self):
|
||||
result = analyzer.analyze("""
|
||||
SELECT u.id, u.name, o.total
|
||||
FROM users u
|
||||
JOIN orders o ON u.id = o.user_id
|
||||
WHERE u.status = 'active'
|
||||
ORDER BY o.total DESC
|
||||
LIMIT 50;
|
||||
""")
|
||||
assert result.sql_type == SQLType.SELECT
|
||||
assert result.is_readonly is True
|
||||
assert result.has_where is True
|
||||
+16
-18
@@ -2,31 +2,29 @@ version: '3.8'
|
||||
|
||||
services:
|
||||
backend:
|
||||
build:
|
||||
context: ./backend
|
||||
dockerfile: Dockerfile
|
||||
build: ./backend
|
||||
container_name: sql-audit-backend
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "9000:9000"
|
||||
environment:
|
||||
- ENV=development
|
||||
- DEBUG=true
|
||||
- SECRET_KEY=dev-secret-key-change-in-production
|
||||
- DATABASE_URL=sqlite+aiosqlite:///./sqlite3.db
|
||||
volumes:
|
||||
- ./backend:/app
|
||||
- backend_data:/app/data
|
||||
- backend-data:/app/data
|
||||
environment:
|
||||
- DATABASE_URL=sqlite+aiosqlite:///./data/sqlite3.db
|
||||
- SECRET_KEY=${SECRET_KEY:-change-me-in-production}
|
||||
- ADMIN_USERNAME=${ADMIN_USERNAME:-admin}
|
||||
- ADMIN_PASSWORD=${ADMIN_PASSWORD:-changeme}
|
||||
- ENV=production
|
||||
- DEBUG=false
|
||||
|
||||
frontend:
|
||||
build:
|
||||
context: ./frontend
|
||||
dockerfile: Dockerfile
|
||||
build: ./frontend
|
||||
container_name: sql-audit-frontend
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "5173:5173"
|
||||
volumes:
|
||||
- ./frontend:/app
|
||||
- /app/node_modules
|
||||
- "80:80"
|
||||
depends_on:
|
||||
- backend
|
||||
|
||||
volumes:
|
||||
backend_data:
|
||||
backend-data:
|
||||
@@ -0,0 +1,3 @@
|
||||
node_modules/
|
||||
dist/
|
||||
.git/
|
||||
+9
-8
@@ -1,12 +1,13 @@
|
||||
FROM node:20-alpine
|
||||
FROM node:20-alpine AS build
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json .
|
||||
RUN npm install
|
||||
|
||||
COPY package*.json ./
|
||||
RUN npm ci
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
EXPOSE 5173
|
||||
|
||||
CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0"]
|
||||
FROM nginx:alpine
|
||||
COPY --from=build /app/dist /usr/share/nginx/html
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
EXPOSE 80
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
@@ -0,0 +1,20 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
location /api/ {
|
||||
proxy_pass http://backend:9000;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_read_timeout 300s;
|
||||
proxy_connect_timeout 75s;
|
||||
}
|
||||
}
|
||||
@@ -1,134 +1,288 @@
|
||||
import { useState } from 'react';
|
||||
import { Plus, Search, Edit2, Trash2, Shield, AlertTriangle, AlertCircle, Info, CheckCircle, ToggleLeft, ToggleRight, FileCode } from 'lucide-react';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { auditRules, dbTypeColorMap } from '@/lib/mock';
|
||||
import { useState, useEffect } from 'react';
|
||||
import request from '../api/request';
|
||||
|
||||
const levelConfig = {
|
||||
error: { icon: AlertTriangle, color: 'text-red-400 bg-red-400/10', label: '错误' },
|
||||
warning: { icon: AlertCircle, color: 'text-yellow-400 bg-yellow-400/10', label: '警告' },
|
||||
info: { icon: Info, color: 'text-blue-400 bg-blue-400/10', label: '提示' },
|
||||
interface AuditRule {
|
||||
id: number;
|
||||
name: string;
|
||||
description: string | null;
|
||||
rule_type: string;
|
||||
severity: string;
|
||||
db_type: string;
|
||||
pattern: string | null;
|
||||
is_enabled: boolean;
|
||||
message: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
const RULE_TYPES = [
|
||||
{ value: 'syntax', label: '语法检查', color: 'bg-blue-100 text-blue-700' },
|
||||
{ value: 'performance', label: '性能优化', color: 'bg-yellow-100 text-yellow-700' },
|
||||
{ value: 'safety', label: '安全检查', color: 'bg-red-100 text-red-700' },
|
||||
{ value: 'naming', label: '命名规范', color: 'bg-green-100 text-green-700' },
|
||||
];
|
||||
|
||||
const SEVERITY_MAP: Record<string, { label: string; color: string }> = {
|
||||
error: { label: '错误', color: 'bg-red-500 text-white' },
|
||||
warning: { label: '警告', color: 'bg-yellow-500 text-white' },
|
||||
info: { label: '提示', color: 'bg-blue-500 text-white' },
|
||||
};
|
||||
|
||||
export default function AuditRulesPage() {
|
||||
const [search, setSearch] = useState('');
|
||||
const [levelFilter, setLevelFilter] = useState('');
|
||||
const [rules, setRules] = useState(auditRules);
|
||||
|
||||
const filtered = rules.filter(r => {
|
||||
if (levelFilter && r.level !== levelFilter) return false;
|
||||
if (search && !r.name.includes(search) && !r.description.includes(search)) return false;
|
||||
return true;
|
||||
const [rules, setRules] = useState<AuditRule[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [filter, setFilter] = useState({ rule_type: '', severity: '', is_enabled: '' });
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
const [stats, setStats] = useState<any>(null);
|
||||
const [form, setForm] = useState({
|
||||
name: '', description: '', rule_type: 'safety', severity: 'warning',
|
||||
db_type: 'mysql', pattern: '', is_enabled: true, message: '',
|
||||
});
|
||||
|
||||
const toggleRule = (id: string) => {
|
||||
setRules(prev => prev.map(r => r.id === id ? { ...r, enabled: !r.enabled } : r));
|
||||
};
|
||||
useEffect(() => { fetchRules(); fetchStats(); }, []);
|
||||
|
||||
async function fetchRules() {
|
||||
try {
|
||||
setLoading(true);
|
||||
const params = new URLSearchParams();
|
||||
if (filter.rule_type) params.set('rule_type', filter.rule_type);
|
||||
if (filter.severity) params.set('severity', filter.severity);
|
||||
if (filter.is_enabled !== '') params.set('is_enabled', filter.is_enabled);
|
||||
const res = await request.get('/audit-rules', { params });
|
||||
setRules(res.data);
|
||||
} catch { /* */ } finally { setLoading(false); }
|
||||
}
|
||||
|
||||
async function fetchStats() {
|
||||
try {
|
||||
const res = await request.get('/audit-rules/stats/summary');
|
||||
setStats(res.data);
|
||||
} catch { /* */ }
|
||||
}
|
||||
|
||||
async function handleCreate() {
|
||||
try {
|
||||
await request.post('/audit-rules', form);
|
||||
setShowCreate(false);
|
||||
setForm({ name: '', description: '', rule_type: 'safety', severity: 'warning', db_type: 'mysql', pattern: '', is_enabled: true, message: '' });
|
||||
fetchRules();
|
||||
fetchStats();
|
||||
} catch (err: any) {
|
||||
alert(err.response?.data?.detail || '创建失败');
|
||||
}
|
||||
}
|
||||
|
||||
async function handleToggle(rule: AuditRule) {
|
||||
try {
|
||||
await request.post('/audit-rules/' + rule.id + '/toggle');
|
||||
fetchRules();
|
||||
fetchStats();
|
||||
} catch { /* */ }
|
||||
}
|
||||
|
||||
async function handleDelete(id: number) {
|
||||
if (!confirm('确认删除此规则?')) return;
|
||||
try {
|
||||
await request.delete('/audit-rules/' + id);
|
||||
fetchRules();
|
||||
fetchStats();
|
||||
} catch { /* */ }
|
||||
}
|
||||
|
||||
const getTypeInfo = (type: string) => RULE_TYPES.find(t => t.value === type) || RULE_TYPES[0];
|
||||
const getSeverityInfo = (sev: string) => SEVERITY_MAP[sev] || SEVERITY_MAP.info;
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold">SQL 审核规则</h1>
|
||||
<p className="text-sm text-[var(--dg-text-secondary)] mt-1">配置 SQL 审核规则,确保数据变更安全合规</p>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<button className="flex items-center gap-2 px-4 py-2.5 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm font-medium hover:bg-[var(--dg-bg-hover)] transition-all">
|
||||
<FileCode className="w-4 h-4" /> 导入规则
|
||||
</button>
|
||||
<button className="flex items-center gap-2 px-4 py-2.5 bg-gradient-to-r from-blue-500 to-purple-600 rounded-lg text-sm font-medium text-white hover:opacity-90 transition-opacity">
|
||||
<Plus className="w-4 h-4" /> 添加规则
|
||||
</button>
|
||||
<h1 className="text-xl font-semibold text-gray-900">审核规则</h1>
|
||||
<p className="text-sm text-gray-500 mt-1">管理 SQL 审核引擎的检查规则</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowCreate(true)}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded-lg text-sm hover:bg-blue-700"
|
||||
>
|
||||
新建规则
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-4 gap-4">
|
||||
{[
|
||||
{ label: '规则总数', value: rules.length, icon: Shield, color: 'text-blue-400', bg: 'bg-blue-400/10' },
|
||||
{ label: '已启用', value: rules.filter(r => r.enabled).length, icon: CheckCircle, color: 'text-green-400', bg: 'bg-green-400/10' },
|
||||
{ label: '错误级', value: rules.filter(r => r.level === 'error').length, icon: AlertTriangle, color: 'text-red-400', bg: 'bg-red-400/10' },
|
||||
{ label: '警告级', value: rules.filter(r => r.level === 'warning').length, icon: AlertCircle, color: 'text-yellow-400', bg: 'bg-yellow-400/10' },
|
||||
].map((s, i) => (
|
||||
<Card key={i} className="dg-card">
|
||||
<CardContent className="p-4 flex items-center gap-3">
|
||||
<div className={`w-10 h-10 rounded-lg ${s.bg} flex items-center justify-center`}><s.icon className={`w-5 h-5 ${s.color}`} /></div>
|
||||
<div><div className="text-2xl font-bold">{s.value}</div><div className="text-xs text-[var(--dg-text-muted)]">{s.label}</div></div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 p-4 dg-card flex-wrap">
|
||||
<div className="relative flex-1 max-w-xs">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-[var(--dg-text-muted)]" />
|
||||
<input className="w-full pl-10 pr-4 py-2.5 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm placeholder:text-[var(--dg-text-muted)] focus:border-blue-400 focus:outline-none" placeholder="搜索规则名称" value={search} onChange={e => setSearch(e.target.value)} />
|
||||
{/* Stats */}
|
||||
{stats && (
|
||||
<div className="grid grid-cols-4 gap-4">
|
||||
{[
|
||||
{ label: '总规则数', value: stats.total, color: 'text-gray-900' },
|
||||
{ label: '已启用', value: stats.enabled, color: 'text-green-600' },
|
||||
{ label: '已禁用', value: stats.disabled, color: 'text-gray-400' },
|
||||
{ label: '类型', value: Object.keys(stats.by_type || {}).length, color: 'text-blue-600' },
|
||||
].map(s => (
|
||||
<div key={s.label} className="bg-white rounded-lg p-4 shadow-sm">
|
||||
<p className="text-sm text-gray-500">{s.label}</p>
|
||||
<p className={'text-2xl font-bold ' + s.color}>{s.value}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<select className="px-3 py-2.5 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm focus:border-blue-400 focus:outline-none" value={levelFilter} onChange={e => setLevelFilter(e.target.value)}>
|
||||
)}
|
||||
|
||||
{/* Filters */}
|
||||
<div className="flex gap-3">
|
||||
<select
|
||||
value={filter.rule_type}
|
||||
onChange={e => setFilter({ ...filter, rule_type: e.target.value })}
|
||||
className="px-3 py-2 border border-gray-200 rounded-lg text-sm"
|
||||
>
|
||||
<option value="">全部类型</option>
|
||||
{RULE_TYPES.map(t => <option key={t.value} value={t.value}>{t.label}</option>)}
|
||||
</select>
|
||||
<select
|
||||
value={filter.severity}
|
||||
onChange={e => setFilter({ ...filter, severity: e.target.value })}
|
||||
className="px-3 py-2 border border-gray-200 rounded-lg text-sm"
|
||||
>
|
||||
<option value="">全部级别</option>
|
||||
<option value="error">错误</option>
|
||||
<option value="warning">警告</option>
|
||||
<option value="info">提示</option>
|
||||
</select>
|
||||
<select
|
||||
value={filter.is_enabled}
|
||||
onChange={e => setFilter({ ...filter, is_enabled: e.target.value })}
|
||||
className="px-3 py-2 border border-gray-200 rounded-lg text-sm"
|
||||
>
|
||||
<option value="">全部状态</option>
|
||||
<option value="true">已启用</option>
|
||||
<option value="false">已禁用</option>
|
||||
</select>
|
||||
<button onClick={fetchRules} className="px-3 py-2 bg-gray-100 rounded-lg text-sm hover:bg-gray-200">
|
||||
刷新
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<Card className="dg-card">
|
||||
<CardContent className="p-0">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-[var(--dg-border)] bg-[var(--dg-bg-tertiary)]">
|
||||
<th className="text-left px-5 py-3 font-semibold">规则ID</th>
|
||||
<th className="text-left px-5 py-3 font-semibold">规则名称</th>
|
||||
<th className="text-left px-5 py-3 font-semibold">级别</th>
|
||||
<th className="text-left px-5 py-3 font-semibold">适用数据库</th>
|
||||
<th className="text-left px-5 py-3 font-semibold">描述</th>
|
||||
<th className="text-left px-5 py-3 font-semibold">状态</th>
|
||||
<th className="text-left px-5 py-3 font-semibold">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filtered.map(rule => {
|
||||
const lc = levelConfig[rule.level];
|
||||
const LIcon = lc.icon;
|
||||
return (
|
||||
<tr key={rule.id} className={`border-b border-[var(--dg-border-light)] hover:bg-[var(--dg-bg-hover)] transition-colors ${!rule.enabled ? 'opacity-50' : ''}`}>
|
||||
<td className="px-5 py-3.5 font-mono text-xs text-[var(--dg-text-secondary)]">{rule.id}</td>
|
||||
<td className="px-5 py-3.5 font-medium">{rule.name}</td>
|
||||
<td className="px-5 py-3.5">
|
||||
<span className={`inline-flex items-center gap-1.5 px-2 py-1 rounded-md text-xs font-medium ${lc.color}`}>
|
||||
<LIcon className="w-3.5 h-3.5" /> {lc.label}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-5 py-3.5">
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{rule.dbTypes.map(dt => (
|
||||
<span key={dt} className={`inline-flex px-2 py-0.5 rounded text-[11px] font-medium ${dbTypeColorMap[dt] || ''}`}>{dt.toUpperCase()}</span>
|
||||
))}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-5 py-3.5 text-[var(--dg-text-secondary)] max-w-xs truncate">{rule.description}</td>
|
||||
<td className="px-5 py-3.5">
|
||||
<button onClick={() => toggleRule(rule.id)} className="transition-colors">
|
||||
{rule.enabled ? (
|
||||
<ToggleRight className="w-8 h-8 text-green-400" />
|
||||
) : (
|
||||
<ToggleLeft className="w-8 h-8 text-[var(--dg-text-muted)]" />
|
||||
)}
|
||||
</button>
|
||||
</td>
|
||||
<td className="px-5 py-3.5">
|
||||
<div className="flex gap-2">
|
||||
<button className="text-blue-400 hover:text-blue-300"><Edit2 className="w-4 h-4" /></button>
|
||||
<button className="text-red-400 hover:text-red-300"><Trash2 className="w-4 h-4" /></button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
{/* Create Modal */}
|
||||
{showCreate && (
|
||||
<div className="fixed inset-0 bg-black/30 flex items-center justify-center z-50">
|
||||
<div className="bg-white rounded-xl shadow-xl w-full max-w-lg p-6">
|
||||
<h2 className="text-lg font-semibold mb-4">新建审核规则</h2>
|
||||
<div className="space-y-3">
|
||||
<input
|
||||
placeholder="规则名称"
|
||||
value={form.name}
|
||||
onChange={e => setForm({ ...form, name: e.target.value })}
|
||||
className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm"
|
||||
/>
|
||||
<textarea
|
||||
placeholder="描述(可选)"
|
||||
value={form.description}
|
||||
onChange={e => setForm({ ...form, description: e.target.value })}
|
||||
className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm"
|
||||
rows={2}
|
||||
/>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<select
|
||||
value={form.rule_type}
|
||||
onChange={e => setForm({ ...form, rule_type: e.target.value })}
|
||||
className="px-3 py-2 border border-gray-200 rounded-lg text-sm"
|
||||
>
|
||||
{RULE_TYPES.map(t => <option key={t.value} value={t.value}>{t.label}</option>)}
|
||||
</select>
|
||||
<select
|
||||
value={form.severity}
|
||||
onChange={e => setForm({ ...form, severity: e.target.value })}
|
||||
className="px-3 py-2 border border-gray-200 rounded-lg text-sm"
|
||||
>
|
||||
<option value="error">错误</option>
|
||||
<option value="warning">警告</option>
|
||||
<option value="info">提示</option>
|
||||
</select>
|
||||
</div>
|
||||
<input
|
||||
placeholder="匹配模式(正则表达式,可选)"
|
||||
value={form.pattern}
|
||||
onChange={e => setForm({ ...form, pattern: e.target.value })}
|
||||
className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm font-mono"
|
||||
/>
|
||||
<input
|
||||
placeholder="提示消息"
|
||||
value={form.message}
|
||||
onChange={e => setForm({ ...form, message: e.target.value })}
|
||||
className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-end gap-3 mt-5">
|
||||
<button onClick={() => setShowCreate(false)} className="px-4 py-2 text-sm text-gray-600 hover:bg-gray-100 rounded-lg">
|
||||
取消
|
||||
</button>
|
||||
<button onClick={handleCreate} className="px-4 py-2 bg-blue-600 text-white rounded-lg text-sm hover:bg-blue-700">
|
||||
创建
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Rules Table */}
|
||||
<div className="bg-white rounded-lg shadow-sm overflow-hidden">
|
||||
{loading ? (
|
||||
<div className="p-8 text-center text-gray-400">加载中...</div>
|
||||
) : rules.length === 0 ? (
|
||||
<div className="p-8 text-center text-gray-400">暂无规则,点击"新建规则"添加</div>
|
||||
) : (
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="bg-gray-50 text-left text-xs text-gray-500">
|
||||
<th className="px-4 py-3">规则名称</th>
|
||||
<th className="px-4 py-3">类型</th>
|
||||
<th className="px-4 py-3">级别</th>
|
||||
<th className="px-4 py-3">提示消息</th>
|
||||
<th className="px-4 py-3">状态</th>
|
||||
<th className="px-4 py-3 text-right">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100">
|
||||
{rules.map(rule => {
|
||||
const typeInfo = getTypeInfo(rule.rule_type);
|
||||
const sevInfo = getSeverityInfo(rule.severity);
|
||||
return (
|
||||
<tr key={rule.id} className="hover:bg-gray-50">
|
||||
<td className="px-4 py-3">
|
||||
<div>
|
||||
<p className="font-medium text-sm text-gray-900">{rule.name}</p>
|
||||
{rule.description && <p className="text-xs text-gray-400 mt-0.5">{rule.description}</p>}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className={'px-2 py-0.5 rounded text-xs ' + typeInfo.color}>{typeInfo.label}</span>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className={'px-2 py-0.5 rounded text-xs ' + sevInfo.color}>{sevInfo.label}</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-600 max-w-xs truncate">{rule.message}</td>
|
||||
<td className="px-4 py-3">
|
||||
<button
|
||||
onClick={() => handleToggle(rule)}
|
||||
className={'px-2 py-1 rounded text-xs ' + (
|
||||
rule.is_enabled ? 'bg-green-100 text-green-700' : 'bg-gray-100 text-gray-400'
|
||||
)}
|
||||
>
|
||||
{rule.is_enabled ? '启用' : '禁用'}
|
||||
</button>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
<button
|
||||
onClick={() => handleDelete(rule.id)}
|
||||
className="text-xs text-red-500 hover:text-red-700"
|
||||
>
|
||||
删除
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user