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
+1384
-139
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
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Install system deps
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
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/*
|
rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Install Python deps
|
||||||
COPY requirements.txt .
|
COPY requirements.txt .
|
||||||
RUN pip install --no-cache-dir -r requirements.txt
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
# Copy app
|
||||||
COPY . .
|
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
|
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 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")
|
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(query.router)
|
||||||
api_router.include_router(dashboard.router)
|
api_router.include_router(dashboard.router)
|
||||||
api_router.include_router(audit.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.instance # noqa
|
||||||
import app.models.ticket # noqa
|
import app.models.ticket # noqa
|
||||||
import app.models.audit_log # noqa
|
import app.models.audit_log # noqa
|
||||||
|
import app.models.audit_rule # noqa
|
||||||
|
|
||||||
await init_db()
|
await init_db()
|
||||||
await seed_data()
|
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.models.instance import Instance
|
||||||
from app.schemas.query import QueryResult
|
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
|
MAX_ROWS = 10000
|
||||||
|
|
||||||
|
|
||||||
class QueryService:
|
class QueryService:
|
||||||
|
_analyzer = SQLAnalyzer()
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
async def execute_query(
|
async def execute_query(
|
||||||
instance: Instance, database: Optional[str], sql: str, user_id: int
|
instance: Instance, database: Optional[str], sql: str, user_id: int
|
||||||
@@ -19,11 +20,13 @@ class QueryService:
|
|||||||
if instance.db_type != "mysql":
|
if instance.db_type != "mysql":
|
||||||
raise ValueError(f"暂不支持 {instance.db_type} 查询")
|
raise ValueError(f"暂不支持 {instance.db_type} 查询")
|
||||||
|
|
||||||
upper_sql = sql.strip().upper()
|
# AST-based SQL validation (replaces keyword matching)
|
||||||
for kw in FORBIDDEN_KEYWORDS:
|
is_safe, error_msg = QueryService._analyzer.validate_readonly(sql)
|
||||||
if upper_sql.startswith(kw) or f" {kw} " in upper_sql:
|
if not is_safe:
|
||||||
raise ValueError(f"查询模式禁止执行 {kw} 操作")
|
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):
|
if not re.search(r"LIMIT\s+\d+", upper_sql, re.IGNORECASE):
|
||||||
sql = sql.rstrip(";") + f" LIMIT {MAX_ROWS};"
|
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
|
fastapi>=0.104.0
|
||||||
uvicorn[standard]==0.34.0
|
uvicorn[standard]>=0.24.0
|
||||||
sqlalchemy==2.0.36
|
sqlalchemy[asyncio]>=2.0.23
|
||||||
aiosqlite==0.20.0
|
aiosqlite>=0.19.0
|
||||||
python-jose[cryptography]==3.3.0
|
pydantic>=2.5.0
|
||||||
passlib[bcrypt]==1.7.4
|
pydantic-settings>=2.1.0
|
||||||
bcrypt==4.1.2
|
python-jose[cryptography]>=3.3.0
|
||||||
python-multipart==0.0.20
|
passlib[bcrypt]>=1.7.4
|
||||||
pydantic==2.10.4
|
python-multipart>=0.0.6
|
||||||
pydantic-settings==2.7.1
|
cryptography>=41.0.0
|
||||||
httpx==0.28.1
|
sqlparse>=0.4.4
|
||||||
pymysql==1.1.1
|
alembic>=1.13.0
|
||||||
aiomysql==0.2.0
|
|
||||||
cryptography==44.0.0
|
|
||||||
greenlet==3.1.1
|
|
||||||
@@ -11,6 +11,7 @@ from app.models.user import User
|
|||||||
from app.models.instance import Instance
|
from app.models.instance import Instance
|
||||||
from app.models.ticket import Ticket
|
from app.models.ticket import Ticket
|
||||||
from app.models.audit_log import AuditLog
|
from app.models.audit_log import AuditLog
|
||||||
|
from app.models.audit_rule import AuditRule
|
||||||
|
|
||||||
SQLALCHEMY_DATABASE_URL = "sqlite+aiosqlite://"
|
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:
|
services:
|
||||||
backend:
|
backend:
|
||||||
build:
|
build: ./backend
|
||||||
context: ./backend
|
container_name: sql-audit-backend
|
||||||
dockerfile: Dockerfile
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
- "9000:9000"
|
- "9000:9000"
|
||||||
environment:
|
|
||||||
- ENV=development
|
|
||||||
- DEBUG=true
|
|
||||||
- SECRET_KEY=dev-secret-key-change-in-production
|
|
||||||
- DATABASE_URL=sqlite+aiosqlite:///./sqlite3.db
|
|
||||||
volumes:
|
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:
|
frontend:
|
||||||
build:
|
build: ./frontend
|
||||||
context: ./frontend
|
container_name: sql-audit-frontend
|
||||||
dockerfile: Dockerfile
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
- "5173:5173"
|
- "80:80"
|
||||||
volumes:
|
|
||||||
- ./frontend:/app
|
|
||||||
- /app/node_modules
|
|
||||||
depends_on:
|
depends_on:
|
||||||
- backend
|
- backend
|
||||||
|
|
||||||
volumes:
|
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
|
WORKDIR /app
|
||||||
|
COPY package*.json ./
|
||||||
COPY package.json .
|
RUN npm ci
|
||||||
RUN npm install
|
|
||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
EXPOSE 5173
|
FROM nginx:alpine
|
||||||
|
COPY --from=build /app/dist /usr/share/nginx/html
|
||||||
CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0"]
|
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 { useState, useEffect } from 'react';
|
||||||
import { Plus, Search, Edit2, Trash2, Shield, AlertTriangle, AlertCircle, Info, CheckCircle, ToggleLeft, ToggleRight, FileCode } from 'lucide-react';
|
import request from '../api/request';
|
||||||
import { Card, CardContent } from '@/components/ui/card';
|
|
||||||
import { auditRules, dbTypeColorMap } from '@/lib/mock';
|
|
||||||
|
|
||||||
const levelConfig = {
|
interface AuditRule {
|
||||||
error: { icon: AlertTriangle, color: 'text-red-400 bg-red-400/10', label: '错误' },
|
id: number;
|
||||||
warning: { icon: AlertCircle, color: 'text-yellow-400 bg-yellow-400/10', label: '警告' },
|
name: string;
|
||||||
info: { icon: Info, color: 'text-blue-400 bg-blue-400/10', label: '提示' },
|
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() {
|
export default function AuditRulesPage() {
|
||||||
const [search, setSearch] = useState('');
|
const [rules, setRules] = useState<AuditRule[]>([]);
|
||||||
const [levelFilter, setLevelFilter] = useState('');
|
const [loading, setLoading] = useState(true);
|
||||||
const [rules, setRules] = useState(auditRules);
|
const [filter, setFilter] = useState({ rule_type: '', severity: '', is_enabled: '' });
|
||||||
|
const [showCreate, setShowCreate] = useState(false);
|
||||||
const filtered = rules.filter(r => {
|
const [stats, setStats] = useState<any>(null);
|
||||||
if (levelFilter && r.level !== levelFilter) return false;
|
const [form, setForm] = useState({
|
||||||
if (search && !r.name.includes(search) && !r.description.includes(search)) return false;
|
name: '', description: '', rule_type: 'safety', severity: 'warning',
|
||||||
return true;
|
db_type: 'mysql', pattern: '', is_enabled: true, message: '',
|
||||||
});
|
});
|
||||||
|
|
||||||
const toggleRule = (id: string) => {
|
useEffect(() => { fetchRules(); fetchStats(); }, []);
|
||||||
setRules(prev => prev.map(r => r.id === id ? { ...r, enabled: !r.enabled } : r));
|
|
||||||
};
|
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 (
|
return (
|
||||||
<div className="space-y-5">
|
<div className="space-y-6">
|
||||||
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-semibold">SQL 审核规则</h1>
|
<h1 className="text-xl font-semibold text-gray-900">审核规则</h1>
|
||||||
<p className="text-sm text-[var(--dg-text-secondary)] mt-1">配置 SQL 审核规则,确保数据变更安全合规</p>
|
<p className="text-sm text-gray-500 mt-1">管理 SQL 审核引擎的检查规则</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-3">
|
<button
|
||||||
<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">
|
onClick={() => setShowCreate(true)}
|
||||||
<FileCode className="w-4 h-4" /> 导入规则
|
className="px-4 py-2 bg-blue-600 text-white rounded-lg text-sm hover:bg-blue-700"
|
||||||
|
>
|
||||||
|
新建规则
|
||||||
</button>
|
</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>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Stats */}
|
||||||
|
{stats && (
|
||||||
<div className="grid grid-cols-4 gap-4">
|
<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: stats.total, color: 'text-gray-900' },
|
||||||
{ label: '已启用', value: rules.filter(r => r.enabled).length, icon: CheckCircle, color: 'text-green-400', bg: 'bg-green-400/10' },
|
{ label: '已启用', value: stats.enabled, color: 'text-green-600' },
|
||||||
{ label: '错误级', value: rules.filter(r => r.level === 'error').length, icon: AlertTriangle, color: 'text-red-400', bg: 'bg-red-400/10' },
|
{ label: '已禁用', value: stats.disabled, color: 'text-gray-400' },
|
||||||
{ label: '警告级', value: rules.filter(r => r.level === 'warning').length, icon: AlertCircle, color: 'text-yellow-400', bg: 'bg-yellow-400/10' },
|
{ label: '类型', value: Object.keys(stats.by_type || {}).length, color: 'text-blue-600' },
|
||||||
].map((s, i) => (
|
].map(s => (
|
||||||
<Card key={i} className="dg-card">
|
<div key={s.label} className="bg-white rounded-lg p-4 shadow-sm">
|
||||||
<CardContent className="p-4 flex items-center gap-3">
|
<p className="text-sm text-gray-500">{s.label}</p>
|
||||||
<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>
|
<p className={'text-2xl font-bold ' + s.color}>{s.value}</p>
|
||||||
<div><div className="text-2xl font-bold">{s.value}</div><div className="text-xs text-[var(--dg-text-muted)]">{s.label}</div></div>
|
</div>
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="flex items-center gap-3 p-4 dg-card flex-wrap">
|
{/* Filters */}
|
||||||
<div className="relative flex-1 max-w-xs">
|
<div className="flex gap-3">
|
||||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-[var(--dg-text-muted)]" />
|
<select
|
||||||
<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)} />
|
value={filter.rule_type}
|
||||||
</div>
|
onChange={e => setFilter({ ...filter, rule_type: e.target.value })}
|
||||||
<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)}>
|
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="">全部级别</option>
|
||||||
<option value="error">错误</option>
|
<option value="error">错误</option>
|
||||||
<option value="warning">警告</option>
|
<option value="warning">警告</option>
|
||||||
<option value="info">提示</option>
|
<option value="info">提示</option>
|
||||||
</select>
|
</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>
|
</div>
|
||||||
|
|
||||||
<Card className="dg-card">
|
{/* Create Modal */}
|
||||||
<CardContent className="p-0">
|
{showCreate && (
|
||||||
<div className="overflow-x-auto">
|
<div className="fixed inset-0 bg-black/30 flex items-center justify-center z-50">
|
||||||
<table className="w-full text-sm">
|
<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>
|
||||||
|
</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>
|
<thead>
|
||||||
<tr className="border-b border-[var(--dg-border)] bg-[var(--dg-bg-tertiary)]">
|
<tr className="bg-gray-50 text-left text-xs text-gray-500">
|
||||||
<th className="text-left px-5 py-3 font-semibold">规则ID</th>
|
<th className="px-4 py-3">规则名称</th>
|
||||||
<th className="text-left px-5 py-3 font-semibold">规则名称</th>
|
<th className="px-4 py-3">类型</th>
|
||||||
<th className="text-left px-5 py-3 font-semibold">级别</th>
|
<th className="px-4 py-3">级别</th>
|
||||||
<th className="text-left px-5 py-3 font-semibold">适用数据库</th>
|
<th className="px-4 py-3">提示消息</th>
|
||||||
<th className="text-left px-5 py-3 font-semibold">描述</th>
|
<th className="px-4 py-3">状态</th>
|
||||||
<th className="text-left px-5 py-3 font-semibold">状态</th>
|
<th className="px-4 py-3 text-right">操作</th>
|
||||||
<th className="text-left px-5 py-3 font-semibold">操作</th>
|
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody className="divide-y divide-gray-100">
|
||||||
{filtered.map(rule => {
|
{rules.map(rule => {
|
||||||
const lc = levelConfig[rule.level];
|
const typeInfo = getTypeInfo(rule.rule_type);
|
||||||
const LIcon = lc.icon;
|
const sevInfo = getSeverityInfo(rule.severity);
|
||||||
return (
|
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' : ''}`}>
|
<tr key={rule.id} className="hover:bg-gray-50">
|
||||||
<td className="px-5 py-3.5 font-mono text-xs text-[var(--dg-text-secondary)]">{rule.id}</td>
|
<td className="px-4 py-3">
|
||||||
<td className="px-5 py-3.5 font-medium">{rule.name}</td>
|
<div>
|
||||||
<td className="px-5 py-3.5">
|
<p className="font-medium text-sm text-gray-900">{rule.name}</p>
|
||||||
<span className={`inline-flex items-center gap-1.5 px-2 py-1 rounded-md text-xs font-medium ${lc.color}`}>
|
{rule.description && <p className="text-xs text-gray-400 mt-0.5">{rule.description}</p>}
|
||||||
<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>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-5 py-3.5 text-[var(--dg-text-secondary)] max-w-xs truncate">{rule.description}</td>
|
<td className="px-4 py-3">
|
||||||
<td className="px-5 py-3.5">
|
<span className={'px-2 py-0.5 rounded text-xs ' + typeInfo.color}>{typeInfo.label}</span>
|
||||||
<button onClick={() => toggleRule(rule.id)} className="transition-colors">
|
</td>
|
||||||
{rule.enabled ? (
|
<td className="px-4 py-3">
|
||||||
<ToggleRight className="w-8 h-8 text-green-400" />
|
<span className={'px-2 py-0.5 rounded text-xs ' + sevInfo.color}>{sevInfo.label}</span>
|
||||||
) : (
|
</td>
|
||||||
<ToggleLeft className="w-8 h-8 text-[var(--dg-text-muted)]" />
|
<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>
|
</button>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-5 py-3.5">
|
<td className="px-4 py-3 text-right">
|
||||||
<div className="flex gap-2">
|
<button
|
||||||
<button className="text-blue-400 hover:text-blue-300"><Edit2 className="w-4 h-4" /></button>
|
onClick={() => handleDelete(rule.id)}
|
||||||
<button className="text-red-400 hover:text-red-300"><Trash2 className="w-4 h-4" /></button>
|
className="text-xs text-red-500 hover:text-red-700"
|
||||||
</div>
|
>
|
||||||
|
删除
|
||||||
|
</button>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user