```
feat: 添加审批系统和遗留查询功能支持 - 添加审批系统,包括审批请求模型、服务和路由,支持创建、批准和拒绝操作 - 实现审批API密钥验证机制,区分普通API和审批API访问权限 - 添加Alembic数据库迁移支持,更新初始schema版本并添加降级保护 - 配置遗留MySQL查询白名单机制,支持命名查询和参数化查询 - 更新业务服务以集成审批流程,高风险操作需要审批票证 - 调整安全认证使用常量定义的HTTP头,增强安全性比较 - 优化.gitignore配置,添加日志目录排除和文档文件包含规则 - 更新Dockerfile添加alembic依赖包,修复OpenClaw适配器错误处理 ```
This commit is contained in:
@@ -1,4 +1,6 @@
|
||||
import json
|
||||
from functools import lru_cache
|
||||
from typing import Any
|
||||
|
||||
from pydantic import Field, field_validator
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
@@ -19,11 +21,14 @@ class Settings(BaseSettings):
|
||||
api_prefix: str = "/api/v1"
|
||||
api_key: str | None = None
|
||||
api_actor: str = ActorValue.API
|
||||
approval_api_key: str | None = None
|
||||
approval_api_actor: str = ActorValue.APPROVER
|
||||
cors_origins: list[str] = Field(default_factory=lambda: ["*"])
|
||||
|
||||
database_url: str = "mysql+pymysql://root:password@127.0.0.1:3306/company_ai?charset=utf8mb4"
|
||||
legacy_database_url: str | None = None
|
||||
legacy_project_query: str | None = None
|
||||
legacy_allowed_queries: dict[str, str] = Field(default_factory=dict)
|
||||
legacy_project_code_prefix: str = "LEGACY"
|
||||
redis_url: str = "redis://127.0.0.1:6379/0"
|
||||
|
||||
@@ -71,6 +76,20 @@ class Settings(BaseSettings):
|
||||
return value
|
||||
return [item.strip() for item in value.split(",") if item.strip()]
|
||||
|
||||
@field_validator("legacy_allowed_queries", mode="before")
|
||||
@classmethod
|
||||
def parse_legacy_allowed_queries(cls, value: Any) -> dict[str, str]:
|
||||
if value is None or value == "":
|
||||
return {}
|
||||
if isinstance(value, dict):
|
||||
return {str(key): str(item) for key, item in value.items()}
|
||||
if isinstance(value, str):
|
||||
data = json.loads(value)
|
||||
if not isinstance(data, dict):
|
||||
raise ValueError("LEGACY_ALLOWED_QUERIES must be a JSON object")
|
||||
return {str(key): str(item) for key, item in data.items()}
|
||||
raise ValueError("LEGACY_ALLOWED_QUERIES must be a JSON object")
|
||||
|
||||
|
||||
@lru_cache
|
||||
def get_settings() -> Settings:
|
||||
|
||||
@@ -3,6 +3,7 @@ from enum import StrEnum
|
||||
|
||||
class ActorValue(StrEnum):
|
||||
API = "api"
|
||||
APPROVER = "approver"
|
||||
SYSTEM = "system"
|
||||
SCHEDULER = "scheduler"
|
||||
FEISHU = "feishu"
|
||||
@@ -10,6 +11,8 @@ class ActorValue(StrEnum):
|
||||
|
||||
class HttpHeader(StrEnum):
|
||||
AUTHORIZATION = "Authorization"
|
||||
X_API_KEY = "X-API-Key"
|
||||
X_APPROVAL_API_KEY = "X-Approval-API-Key"
|
||||
|
||||
|
||||
BEARER_TOKEN_TEMPLATE = "Bearer {token}"
|
||||
|
||||
@@ -1,15 +1,10 @@
|
||||
from collections.abc import Generator
|
||||
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import DeclarativeBase, Session, sessionmaker
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
|
||||
from app.core.config import get_settings
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
"""Base class for SQLAlchemy ORM models."""
|
||||
|
||||
pass
|
||||
from app.core.db_base import Base
|
||||
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
7
app/core/db_base.py
Normal file
7
app/core/db_base.py
Normal file
@@ -0,0 +1,7 @@
|
||||
from sqlalchemy.orm import DeclarativeBase
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
"""Base class for SQLAlchemy ORM models."""
|
||||
|
||||
pass
|
||||
@@ -1,8 +1,10 @@
|
||||
from dataclasses import dataclass
|
||||
from secrets import compare_digest
|
||||
|
||||
from fastapi import Header, HTTPException, status
|
||||
|
||||
from app.core.config import get_settings
|
||||
from app.core.constants import HttpHeader
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -12,7 +14,9 @@ class ApiPrincipal:
|
||||
actor: str
|
||||
|
||||
|
||||
def require_api_key(x_api_key: str | None = Header(default=None)) -> ApiPrincipal:
|
||||
def require_api_key(
|
||||
x_api_key: str | None = Header(default=None, alias=HttpHeader.X_API_KEY),
|
||||
) -> ApiPrincipal:
|
||||
"""Validate the internal API key header and return its service principal."""
|
||||
|
||||
settings = get_settings()
|
||||
@@ -21,6 +25,31 @@ def require_api_key(x_api_key: str | None = Header(default=None)) -> ApiPrincipa
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="API_KEY is required",
|
||||
)
|
||||
if x_api_key != settings.api_key:
|
||||
if not x_api_key or not compare_digest(x_api_key, settings.api_key):
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid API key")
|
||||
return ApiPrincipal(actor=settings.api_actor)
|
||||
|
||||
|
||||
def require_approval_api_key(
|
||||
x_approval_api_key: str | None = Header(
|
||||
default=None,
|
||||
alias=HttpHeader.X_APPROVAL_API_KEY,
|
||||
),
|
||||
) -> ApiPrincipal:
|
||||
"""Validate the approval API key and return the approval principal."""
|
||||
|
||||
settings = get_settings()
|
||||
if not settings.approval_api_key:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="APPROVAL_API_KEY is required",
|
||||
)
|
||||
if (
|
||||
not x_approval_api_key
|
||||
or not compare_digest(x_approval_api_key, settings.approval_api_key)
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid approval API key",
|
||||
)
|
||||
return ApiPrincipal(actor=settings.approval_api_actor)
|
||||
|
||||
@@ -263,8 +263,13 @@ class OpenClawHermesAdapter(AIAdapter):
|
||||
or AIDefault.SESSION_KEY_MAIN
|
||||
),
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as exc:
|
||||
result[AIResponseKey.TOOL_ERROR] = _error_detail(exc)
|
||||
raise HTTPException(
|
||||
status_code=502,
|
||||
detail={AIErrorKey.OPENCLAW: _error_detail(exc)},
|
||||
) from exc
|
||||
return result
|
||||
|
||||
def _recall_memory(self, prompt: str, context: dict[str, Any]) -> dict[str, Any]:
|
||||
|
||||
@@ -5,8 +5,17 @@ class ApprovalStatus(StrEnum):
|
||||
PENDING = "pending"
|
||||
APPROVED = "approved"
|
||||
REJECTED = "rejected"
|
||||
USED = "used"
|
||||
|
||||
|
||||
class ApprovalActionValue(StrEnum):
|
||||
CREATE = "create"
|
||||
UPDATE = "update"
|
||||
WILDCARD = "*"
|
||||
|
||||
|
||||
class ApprovalErrorDetail(StrEnum):
|
||||
NOT_FOUND = "Approval ticket not found"
|
||||
ALREADY_DECIDED = "Approval ticket already decided"
|
||||
SELF_APPROVAL = "Approval applicant cannot approve their own ticket"
|
||||
NOT_APPROVED = "Approval ticket is not approved for this change"
|
||||
PAYLOAD_MISMATCH = "Approval ticket payload does not match this change"
|
||||
|
||||
@@ -4,7 +4,7 @@ from sqlalchemy import DateTime, Integer, String, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.core.constants import ActorValue
|
||||
from app.core.database import Base
|
||||
from app.core.db_base import Base
|
||||
from app.core.time import utc_now
|
||||
from app.modules.approvals.constants import ApprovalStatus
|
||||
|
||||
@@ -23,6 +23,7 @@ class ApprovalRequest(Base):
|
||||
reason: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
payload: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
decision_comment: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
used_by: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=utc_now, index=True)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime,
|
||||
@@ -30,3 +31,4 @@ class ApprovalRequest(Base):
|
||||
onupdate=utc_now,
|
||||
)
|
||||
decided_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
used_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
|
||||
@@ -2,7 +2,7 @@ from fastapi import APIRouter, Depends
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.core.security import ApiPrincipal, require_api_key
|
||||
from app.core.security import ApiPrincipal, require_api_key, require_approval_api_key
|
||||
from app.modules.approvals.schemas import ApprovalCreate, ApprovalDecision, ApprovalRead
|
||||
from app.modules.approvals.service import ApprovalService
|
||||
|
||||
@@ -37,7 +37,7 @@ def approve(
|
||||
ticket_id: str,
|
||||
payload: ApprovalDecision,
|
||||
db: Session = Depends(get_db),
|
||||
principal: ApiPrincipal = Depends(require_api_key),
|
||||
principal: ApiPrincipal = Depends(require_approval_api_key),
|
||||
):
|
||||
return ApprovalService(db).decide(ticket_id, principal.actor, True, payload.comment)
|
||||
|
||||
@@ -47,6 +47,6 @@ def reject(
|
||||
ticket_id: str,
|
||||
payload: ApprovalDecision,
|
||||
db: Session = Depends(get_db),
|
||||
principal: ApiPrincipal = Depends(require_api_key),
|
||||
principal: ApiPrincipal = Depends(require_approval_api_key),
|
||||
):
|
||||
return ApprovalService(db).decide(ticket_id, principal.actor, False, payload.comment)
|
||||
|
||||
@@ -34,6 +34,8 @@ class ApprovalRead(BaseModel):
|
||||
reason: str | None
|
||||
payload: str | None
|
||||
decision_comment: str | None
|
||||
used_by: str | None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
decided_at: datetime | None
|
||||
used_at: datetime | None
|
||||
|
||||
@@ -1,12 +1,19 @@
|
||||
import json
|
||||
import uuid
|
||||
from datetime import date, datetime
|
||||
from decimal import Decimal
|
||||
from typing import Any
|
||||
|
||||
from fastapi import HTTPException, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.time import utc_now
|
||||
from app.modules.approvals.constants import ApprovalActionValue, ApprovalStatus
|
||||
from app.modules.approvals.constants import (
|
||||
ApprovalActionValue,
|
||||
ApprovalErrorDetail,
|
||||
ApprovalStatus,
|
||||
)
|
||||
from app.modules.approvals.models import ApprovalRequest
|
||||
from app.modules.approvals.schemas import ApprovalCreate
|
||||
from app.modules.audit.constants import AuditAction, AuditRiskLevel, AuditSource
|
||||
@@ -61,7 +68,7 @@ class ApprovalService:
|
||||
if ticket is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Approval ticket not found",
|
||||
detail=ApprovalErrorDetail.NOT_FOUND,
|
||||
)
|
||||
return ticket
|
||||
|
||||
@@ -74,7 +81,15 @@ class ApprovalService:
|
||||
) -> ApprovalRequest:
|
||||
ticket = self.get_by_ticket(ticket_id)
|
||||
if ticket.status != ApprovalStatus.PENDING:
|
||||
raise HTTPException(status_code=409, detail="Approval ticket already decided")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail=ApprovalErrorDetail.ALREADY_DECIDED,
|
||||
)
|
||||
if approved and approver == ticket.applicant:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=ApprovalErrorDetail.SELF_APPROVAL,
|
||||
)
|
||||
ticket.status = ApprovalStatus.APPROVED if approved else ApprovalStatus.REJECTED
|
||||
ticket.approver = approver
|
||||
ticket.decision_comment = comment
|
||||
@@ -95,6 +110,33 @@ class ApprovalService:
|
||||
)
|
||||
return ticket
|
||||
|
||||
def consume_for(
|
||||
self,
|
||||
ticket_id: str,
|
||||
domain: str,
|
||||
record_id: str | int | None,
|
||||
action: str,
|
||||
payload: dict[str, Any],
|
||||
actor: str,
|
||||
) -> ApprovalRequest:
|
||||
ticket = self.get_by_ticket(ticket_id)
|
||||
if not self._is_ticket_scope_valid(ticket, domain, record_id, action):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=ApprovalErrorDetail.NOT_APPROVED,
|
||||
)
|
||||
if not _payload_matches(ticket.payload, payload):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=ApprovalErrorDetail.PAYLOAD_MISMATCH,
|
||||
)
|
||||
ticket.status = ApprovalStatus.USED
|
||||
ticket.used_by = actor
|
||||
ticket.used_at = utc_now()
|
||||
if record_id is not None and not ticket.record_id:
|
||||
ticket.record_id = str(record_id)
|
||||
return ticket
|
||||
|
||||
def is_approved_for(
|
||||
self,
|
||||
ticket_id: str,
|
||||
@@ -103,6 +145,15 @@ class ApprovalService:
|
||||
action: str,
|
||||
) -> bool:
|
||||
ticket = self.get_by_ticket(ticket_id)
|
||||
return self._is_ticket_scope_valid(ticket, domain, record_id, action)
|
||||
|
||||
@staticmethod
|
||||
def _is_ticket_scope_valid(
|
||||
ticket: ApprovalRequest,
|
||||
domain: str,
|
||||
record_id: str | int | None,
|
||||
action: str,
|
||||
) -> bool:
|
||||
if ticket.status != ApprovalStatus.APPROVED:
|
||||
return False
|
||||
if ticket.domain != domain:
|
||||
@@ -115,5 +166,28 @@ class ApprovalService:
|
||||
action,
|
||||
ApprovalActionValue.UPDATE,
|
||||
f"{ApprovalActionValue.UPDATE}:{domain}",
|
||||
ApprovalActionValue.WILDCARD,
|
||||
}
|
||||
|
||||
|
||||
def _payload_matches(approved_payload: str | None, requested_payload: dict[str, Any]) -> bool:
|
||||
try:
|
||||
parsed_payload = json.loads(approved_payload or "{}")
|
||||
except json.JSONDecodeError:
|
||||
parsed_payload = {}
|
||||
return _canonical_payload(parsed_payload) == _canonical_payload(requested_payload)
|
||||
|
||||
|
||||
def _canonical_payload(value: Any) -> str:
|
||||
return json.dumps(_json_safe(value), ensure_ascii=False, sort_keys=True, default=str)
|
||||
|
||||
|
||||
def _json_safe(value: Any) -> Any:
|
||||
if isinstance(value, Decimal):
|
||||
return float(value)
|
||||
if isinstance(value, (datetime, date)):
|
||||
return value.isoformat()
|
||||
if isinstance(value, dict):
|
||||
return {str(key): _json_safe(item) for key, item in value.items()}
|
||||
if isinstance(value, list):
|
||||
return [_json_safe(item) for item in value]
|
||||
return value
|
||||
|
||||
@@ -4,7 +4,7 @@ from sqlalchemy import DateTime, Integer, String, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.core.constants import ActorValue
|
||||
from app.core.database import Base
|
||||
from app.core.db_base import Base
|
||||
from app.core.time import utc_now
|
||||
from app.modules.audit.constants import AuditRiskLevel, AuditSource, AuditStatus
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ from sqlalchemy import JSON, Date, DateTime, Integer, Numeric, String, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.core.constants import ActorValue
|
||||
from app.core.database import Base
|
||||
from app.core.db_base import Base
|
||||
from app.core.time import utc_now
|
||||
from app.modules.business.constants import (
|
||||
AccountType,
|
||||
|
||||
@@ -48,14 +48,17 @@ def _coerce_column_value(column: Column, value: Any) -> Any:
|
||||
|
||||
|
||||
def _model_payload(model: Any, data: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Filter unknown keys and coerce values according to model column types."""
|
||||
"""Validate keys and coerce values according to model column types."""
|
||||
|
||||
columns = {column.name: column for column in model.__table__.columns if column.name != "id"}
|
||||
payload: dict[str, Any] = {}
|
||||
for key, value in data.items():
|
||||
column = columns.get(key)
|
||||
if column is None:
|
||||
continue
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail=f"Unknown field '{key}'",
|
||||
)
|
||||
try:
|
||||
payload[key] = _coerce_column_value(column, value)
|
||||
except (ValueError, TypeError, InvalidOperation) as exc:
|
||||
@@ -104,12 +107,20 @@ class BusinessService:
|
||||
actor: str = ActorValue.API,
|
||||
approval_ticket_id: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
if domain in HIGH_RISK_DOMAINS:
|
||||
self._ensure_approved(approval_ticket_id, domain, None, f"create:{domain}")
|
||||
model = get_domain_model(domain)
|
||||
payload = _model_payload(model, data)
|
||||
record = model(**payload)
|
||||
self.db.add(record)
|
||||
if domain in HIGH_RISK_DOMAINS:
|
||||
self.db.flush()
|
||||
self._consume_approval(
|
||||
approval_ticket_id,
|
||||
domain,
|
||||
record.id,
|
||||
f"create:{domain}",
|
||||
data,
|
||||
actor,
|
||||
)
|
||||
self.db.commit()
|
||||
self.db.refresh(record)
|
||||
result = serialize_model(record)
|
||||
@@ -137,8 +148,6 @@ class BusinessService:
|
||||
actor: str = ActorValue.API,
|
||||
approval_ticket_id: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
if domain in HIGH_RISK_DOMAINS:
|
||||
self._ensure_approved(approval_ticket_id, domain, record_id, f"update:{domain}")
|
||||
model = get_domain_model(domain)
|
||||
record = self.db.get(model, record_id)
|
||||
if record is None:
|
||||
@@ -146,7 +155,17 @@ class BusinessService:
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Record not found",
|
||||
)
|
||||
for key, value in _model_payload(model, data).items():
|
||||
payload = _model_payload(model, data)
|
||||
if domain in HIGH_RISK_DOMAINS:
|
||||
self._consume_approval(
|
||||
approval_ticket_id,
|
||||
domain,
|
||||
record_id,
|
||||
f"update:{domain}",
|
||||
data,
|
||||
actor,
|
||||
)
|
||||
for key, value in payload.items():
|
||||
setattr(record, key, value)
|
||||
self.db.commit()
|
||||
self.db.refresh(record)
|
||||
@@ -167,25 +186,25 @@ class BusinessService:
|
||||
)
|
||||
return result
|
||||
|
||||
def _ensure_approved(
|
||||
def _consume_approval(
|
||||
self,
|
||||
approval_ticket_id: str | None,
|
||||
domain: str,
|
||||
record_id: str | int | None,
|
||||
action: str,
|
||||
payload: dict[str, Any],
|
||||
actor: str,
|
||||
) -> None:
|
||||
if not approval_ticket_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail="High-risk domain change requires approval_ticket_id",
|
||||
)
|
||||
if not ApprovalService(self.db).is_approved_for(
|
||||
ApprovalService(self.db).consume_for(
|
||||
approval_ticket_id,
|
||||
domain,
|
||||
record_id,
|
||||
action,
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Approval ticket is not approved for this change",
|
||||
)
|
||||
payload,
|
||||
actor,
|
||||
)
|
||||
|
||||
10
app/modules/legacy_mysql/constants.py
Normal file
10
app/modules/legacy_mysql/constants.py
Normal file
@@ -0,0 +1,10 @@
|
||||
from enum import StrEnum
|
||||
|
||||
|
||||
class LegacyQueryName(StrEnum):
|
||||
PROJECTS = "projects"
|
||||
|
||||
|
||||
class LegacyQueryError(StrEnum):
|
||||
QUERY_NOT_ALLOWED = "Legacy query is not in the configured allowlist"
|
||||
PROJECT_QUERY_NOT_CONFIGURED = "LEGACY_PROJECT_QUERY is not configured. Configure it first."
|
||||
@@ -31,7 +31,10 @@ def describe_table(table_name: str, db: Session = Depends(get_db)) -> dict:
|
||||
|
||||
@router.post("/query", response_model=QueryResult)
|
||||
def readonly_query(payload: ReadonlyQueryRequest, db: Session = Depends(get_db)) -> dict:
|
||||
return LegacyMySQLService(db).execute_readonly(payload.sql, payload.params, payload.limit)
|
||||
service = LegacyMySQLService(db)
|
||||
if payload.sql:
|
||||
return service.execute_readonly(payload.sql, payload.params, payload.limit)
|
||||
return service.execute_allowed_query(payload.query_name, payload.params, payload.limit)
|
||||
|
||||
|
||||
@router.get("/projects", response_model=QueryResult)
|
||||
@@ -47,6 +50,7 @@ def sync_projects(
|
||||
) -> dict:
|
||||
return LegacyMySQLService(db).sync_projects(
|
||||
source_query=payload.source_query,
|
||||
source_query_name=payload.source_query_name,
|
||||
field_map=payload.field_map,
|
||||
limit=payload.limit,
|
||||
dry_run=payload.dry_run,
|
||||
|
||||
@@ -3,12 +3,20 @@ from typing import Any
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.core.constants import ActorValue
|
||||
from app.modules.legacy_mysql.constants import LegacyQueryName
|
||||
|
||||
|
||||
class ReadonlyQueryRequest(BaseModel):
|
||||
"""Readonly SQL query request for the legacy MySQL connection."""
|
||||
"""Allowlisted readonly query request for the legacy MySQL connection."""
|
||||
|
||||
sql: str = Field(..., description="Readonly SELECT statement.")
|
||||
query_name: str | None = Field(
|
||||
default=LegacyQueryName.PROJECTS,
|
||||
description="Configured readonly query name.",
|
||||
)
|
||||
sql: str | None = Field(
|
||||
default=None,
|
||||
description="Deprecated: must exactly match a configured readonly query.",
|
||||
)
|
||||
params: dict[str, Any] = Field(default_factory=dict)
|
||||
limit: int = Field(default=100, ge=1, le=500)
|
||||
|
||||
@@ -32,7 +40,11 @@ class LegacyProjectSyncRequest(BaseModel):
|
||||
|
||||
source_query: str | None = Field(
|
||||
default=None,
|
||||
description="Optional SELECT query for project sync.",
|
||||
description="Deprecated: must exactly match a configured readonly query.",
|
||||
)
|
||||
source_query_name: str | None = Field(
|
||||
default=LegacyQueryName.PROJECTS,
|
||||
description="Configured readonly query name for project sync.",
|
||||
)
|
||||
field_map: dict[str, str] = Field(
|
||||
default_factory=dict,
|
||||
|
||||
@@ -19,6 +19,7 @@ from app.modules.audit.service import AuditService
|
||||
from app.modules.business.constants import BusinessDomain, SourceSystem, StatusValue
|
||||
from app.modules.business.models import LegacySyncRun, Project
|
||||
from app.modules.business.service import serialize_model
|
||||
from app.modules.legacy_mysql.constants import LegacyQueryError, LegacyQueryName
|
||||
|
||||
FORBIDDEN_SQL_TOKENS = {
|
||||
"insert",
|
||||
@@ -50,6 +51,18 @@ def _row_to_dict(row: RowMapping) -> dict[str, Any]:
|
||||
return {key: _jsonable(value) for key, value in row.items()}
|
||||
|
||||
|
||||
def _normalize_sql(sql: str) -> str:
|
||||
return " ".join(sql.strip().rstrip(";").split()).lower()
|
||||
|
||||
|
||||
def _query_name_text(query_name: str | LegacyQueryName | None) -> str:
|
||||
if query_name is None:
|
||||
return LegacyQueryName.PROJECTS.value
|
||||
if isinstance(query_name, LegacyQueryName):
|
||||
return query_name.value
|
||||
return str(query_name)
|
||||
|
||||
|
||||
class LegacyMySQLService:
|
||||
"""Read legacy MySQL data and sync projects into the internal ledger."""
|
||||
|
||||
@@ -74,6 +87,17 @@ class LegacyMySQLService:
|
||||
if tokens & FORBIDDEN_SQL_TOKENS:
|
||||
raise HTTPException(status_code=400, detail="Forbidden SQL token in readonly query")
|
||||
|
||||
@staticmethod
|
||||
def _allowed_queries() -> dict[str, str]:
|
||||
settings = get_settings()
|
||||
queries = {
|
||||
_query_name_text(name): sql
|
||||
for name, sql in settings.legacy_allowed_queries.items()
|
||||
}
|
||||
if settings.legacy_project_query:
|
||||
queries.setdefault(LegacyQueryName.PROJECTS.value, settings.legacy_project_query)
|
||||
return queries
|
||||
|
||||
def health(self) -> dict[str, str]:
|
||||
engine = self._ensure_engine()
|
||||
try:
|
||||
@@ -113,6 +137,39 @@ class LegacyMySQLService:
|
||||
sql: str,
|
||||
params: dict[str, Any] | None = None,
|
||||
limit: int = 100,
|
||||
) -> dict[str, Any]:
|
||||
"""Execute a readonly SQL statement only when it matches the allowlist."""
|
||||
|
||||
normalized_sql = _normalize_sql(sql)
|
||||
for allowed_sql in self._allowed_queries().values():
|
||||
if _normalize_sql(allowed_sql) == normalized_sql:
|
||||
return self._execute_readonly_sql(allowed_sql, params, limit)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=LegacyQueryError.QUERY_NOT_ALLOWED,
|
||||
)
|
||||
|
||||
def execute_allowed_query(
|
||||
self,
|
||||
query_name: str | None,
|
||||
params: dict[str, Any] | None = None,
|
||||
limit: int = 100,
|
||||
) -> dict[str, Any]:
|
||||
queries = self._allowed_queries()
|
||||
normalized_name = _query_name_text(query_name)
|
||||
sql = queries.get(normalized_name)
|
||||
if not sql:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=LegacyQueryError.QUERY_NOT_ALLOWED,
|
||||
)
|
||||
return self._execute_readonly_sql(sql, params, limit)
|
||||
|
||||
def _execute_readonly_sql(
|
||||
self,
|
||||
sql: str,
|
||||
params: dict[str, Any] | None = None,
|
||||
limit: int = 100,
|
||||
) -> dict[str, Any]:
|
||||
self._ensure_readonly(sql)
|
||||
engine = self._ensure_engine()
|
||||
@@ -132,9 +189,9 @@ class LegacyMySQLService:
|
||||
if not settings.legacy_project_query:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="LEGACY_PROJECT_QUERY is not configured. Configure it in .env first.",
|
||||
detail=LegacyQueryError.PROJECT_QUERY_NOT_CONFIGURED,
|
||||
)
|
||||
return self.execute_readonly(settings.legacy_project_query, {"limit": limit}, limit=limit)
|
||||
return self.execute_allowed_query(LegacyQueryName.PROJECTS, {"limit": limit}, limit=limit)
|
||||
|
||||
@staticmethod
|
||||
def _value(
|
||||
@@ -187,6 +244,7 @@ class LegacyMySQLService:
|
||||
def sync_projects(
|
||||
self,
|
||||
source_query: str | None = None,
|
||||
source_query_name: str | None = None,
|
||||
field_map: dict[str, str] | None = None,
|
||||
limit: int = 100,
|
||||
dry_run: bool = True,
|
||||
@@ -198,12 +256,13 @@ class LegacyMySQLService:
|
||||
detail="Application database session is not available",
|
||||
)
|
||||
|
||||
settings = get_settings()
|
||||
query = source_query or settings.legacy_project_query
|
||||
if not query:
|
||||
raise HTTPException(status_code=400, detail="Project sync query is not configured")
|
||||
|
||||
rows = self.execute_readonly(query, {"limit": limit}, limit=limit)["rows"]
|
||||
query_name = source_query_name or LegacyQueryName.PROJECTS
|
||||
if source_query:
|
||||
rows = self.execute_readonly(source_query, {"limit": limit}, limit=limit)["rows"]
|
||||
query_ref = "allowlisted_inline_sql"
|
||||
else:
|
||||
rows = self.execute_allowed_query(query_name, {"limit": limit}, limit=limit)["rows"]
|
||||
query_ref = _query_name_text(query_name)
|
||||
field_map = field_map or {}
|
||||
created = 0
|
||||
updated = 0
|
||||
@@ -288,7 +347,7 @@ class LegacyMySQLService:
|
||||
target_type=BusinessDomain.PROJECTS,
|
||||
risk_level=AuditRiskLevel.MEDIUM,
|
||||
request_payload={
|
||||
"source_query": source_query or "LEGACY_PROJECT_QUERY",
|
||||
"source_query": query_ref,
|
||||
"field_map": field_map,
|
||||
"limit": limit,
|
||||
"dry_run": dry_run,
|
||||
|
||||
@@ -900,19 +900,13 @@ class ReportService:
|
||||
WorkTask.due_date >= start,
|
||||
WorkTask.due_date <= end,
|
||||
]
|
||||
start_at = datetime.combine(start, datetime.min.time())
|
||||
end_at = datetime.combine(end, datetime.max.time())
|
||||
project_filters = []
|
||||
risk_filters = [RiskEvent.status == StatusValue.OPEN]
|
||||
procurement_filters = [
|
||||
Procurement.approval_status.in_(PENDING_APPROVAL_STATUSES),
|
||||
Procurement.created_at >= start_at,
|
||||
Procurement.created_at <= end_at,
|
||||
]
|
||||
expense_filters = [
|
||||
Expense.approval_status.in_(PENDING_APPROVAL_STATUSES),
|
||||
Expense.created_at >= start_at,
|
||||
Expense.created_at <= end_at,
|
||||
]
|
||||
attendance_filters = [
|
||||
AttendanceRecord.work_date >= start,
|
||||
|
||||
Reference in New Issue
Block a user