feat(core): 添加API认证主体配置和安全验证

- 在Settings中添加api_actor字段,用于标识API调用方身份
- 创建ApiPrincipal数据类来表示服务主体
- 修改require_api_key函数返回认证的服务主体信息
- 更新配置文件引入ActorValue常量

feat(ai_agent): 增强OpenClaw工具调用的安全性检查

- 实现_openclaw_allowed_tools和openclaw_allowed_actions配置项
- 添加CSV列表解析验证器
- 实现工具和操作权限检查方法_ensure_tool_allowed
- 在工具调用前验证允许的工具和操作类型

feat(security): 强化API密钥认证和审计安全性

- 更新require_api_key函数在缺少API_KEY时抛出异常
- 在AI代理、审批、飞书等模块的路由中统一使用ApiPrincipal获取调用方信息
- 替换硬编码的ActorValue.API为动态的principal.actor

feat(audit): 实现安全审计负载脱敏处理

- 添加敏感键名集合AI_AUDIT_SENSITIVE_KEYS
- 实现审计安全负载处理函数_audit_safe_payload
- 支持深度遍历、文本截断、序列限制和敏感信息脱敏
- 在AI服务的审计日志中应用安全负载处理

feat(approval): 完善审批流程的申请人身份验证

- 更新审批创建接口使用认证主体作为申请人
- 使用utc_now替换datetime.utcnow确保时间一致性
- 修复审批逻辑中的条件判断问题

feat(business): 加强业务领域高风险操作的审批控制

- 为高风险域创建统一的审批验证方法_ensure_approved
- 在创建和更新操作中强制要求审批票证
- 为项目同步功能添加认证主体参数

feat(config): 统一时间处理使用UTC时间函数

- 创建并使用utc_now函数替代datetime.utcnow
- 在审批、审计、业务、遗留数据等模块中更新时间戳处理

feat(constants): 扩展风险事件类型和报告指标

- 添加新风险事件类型到GENERATED_RISK_EVENT_TYPES
- 为报告模块添加外部开放和高风险事件指标

refactor(feishu): 增强飞书验证令牌安全检查

- 确保飞书验证令牌配置存在时才接受请求
- 修正令牌验证逻辑以提高安全性
```
This commit is contained in:
2026-07-06 00:11:44 +08:00
parent aa81fc5321
commit ae5990eaef
32 changed files with 588 additions and 118 deletions

39
alembic.ini Normal file
View File

@@ -0,0 +1,39 @@
[alembic]
script_location = alembic
prepend_sys_path = .
path_separator = os
sqlalchemy.url = sqlite:///./company_ai.db
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
qualname =
[logger_sqlalchemy]
level = WARN
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

54
alembic/env.py Normal file
View File

@@ -0,0 +1,54 @@
from logging.config import fileConfig
from alembic import context
from sqlalchemy import engine_from_config, pool
from app.core.config import get_settings
from app.core.database import Base
from app.modules.approvals import models as approval_models
from app.modules.audit import models as audit_models
from app.modules.business import models as business_models
config = context.config
if config.config_file_name is not None:
fileConfig(config.config_file_name)
target_metadata = Base.metadata
settings = get_settings()
# Keep imports referenced so SQLAlchemy model classes register with Base.metadata.
_REGISTERED_MODEL_MODULES = (approval_models, audit_models, business_models)
def run_migrations_offline() -> None:
context.configure(
url=settings.database_url,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online() -> None:
configuration = config.get_section(config.config_ini_section, {})
configuration["sqlalchemy.url"] = settings.database_url
connectable = engine_from_config(
configuration,
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()

View File

@@ -0,0 +1,29 @@
"""Initial schema.
Revision ID: 202607060001
Revises:
Create Date: 2026-07-06
"""
from alembic import op
from app.core.database import Base
from app.modules.approvals import models as approval_models
from app.modules.audit import models as audit_models
from app.modules.business import models as business_models
revision = "202607060001"
down_revision = None
branch_labels = None
depends_on = None
# Keep imports referenced so SQLAlchemy model classes register with Base.metadata.
_REGISTERED_MODEL_MODULES = (approval_models, audit_models, business_models)
def upgrade() -> None:
Base.metadata.create_all(bind=op.get_bind())
def downgrade() -> None:
Base.metadata.drop_all(bind=op.get_bind())

View File

@@ -3,6 +3,8 @@ from functools import lru_cache
from pydantic import Field, field_validator from pydantic import Field, field_validator
from pydantic_settings import BaseSettings, SettingsConfigDict from pydantic_settings import BaseSettings, SettingsConfigDict
from app.core.constants import ActorValue
DEFAULT_MODEL_PROVIDER = "noop" DEFAULT_MODEL_PROVIDER = "noop"
@@ -16,6 +18,7 @@ class Settings(BaseSettings):
debug: bool = False debug: bool = False
api_prefix: str = "/api/v1" api_prefix: str = "/api/v1"
api_key: str | None = None api_key: str | None = None
api_actor: str = ActorValue.API
cors_origins: list[str] = Field(default_factory=lambda: ["*"]) cors_origins: list[str] = Field(default_factory=lambda: ["*"])
database_url: str = "mysql+pymysql://root:password@127.0.0.1:3306/company_ai?charset=utf8mb4" database_url: str = "mysql+pymysql://root:password@127.0.0.1:3306/company_ai?charset=utf8mb4"
@@ -37,6 +40,8 @@ class Settings(BaseSettings):
openclaw_ws_url: str | None = None openclaw_ws_url: str | None = None
openclaw_api_key: str | None = None openclaw_api_key: str | None = None
openclaw_gateway_token: str | None = None openclaw_gateway_token: str | None = None
openclaw_allowed_tools: list[str] = Field(default_factory=list)
openclaw_allowed_actions: list[str] = Field(default_factory=lambda: ["json"])
hermes_base_url: str = "http://127.0.0.1:2073/v1" hermes_base_url: str = "http://127.0.0.1:2073/v1"
hermes_api_key: str | None = None hermes_api_key: str | None = None
hermes_model: str = "hermes-agent" hermes_model: str = "hermes-agent"
@@ -59,6 +64,13 @@ class Settings(BaseSettings):
return value return value
return [item.strip() for item in value.split(",") if item.strip()] return [item.strip() for item in value.split(",") if item.strip()]
@field_validator("openclaw_allowed_tools", "openclaw_allowed_actions", mode="before")
@classmethod
def parse_csv_list(cls, value: str | list[str]) -> list[str]:
if isinstance(value, list):
return value
return [item.strip() for item in value.split(",") if item.strip()]
@lru_cache @lru_cache
def get_settings() -> Settings: def get_settings() -> Settings:

View File

@@ -1,13 +1,26 @@
from dataclasses import dataclass
from fastapi import Header, HTTPException, status from fastapi import Header, HTTPException, status
from app.core.config import get_settings from app.core.config import get_settings
def require_api_key(x_api_key: str | None = Header(default=None)) -> None: @dataclass(frozen=True)
"""Validate the optional internal API key header.""" class ApiPrincipal:
"""Authenticated service principal derived from server-side configuration."""
actor: str
def require_api_key(x_api_key: str | None = Header(default=None)) -> ApiPrincipal:
"""Validate the internal API key header and return its service principal."""
settings = get_settings() settings = get_settings()
if not settings.api_key: if not settings.api_key:
return raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="API_KEY is required",
)
if x_api_key != settings.api_key: if x_api_key != settings.api_key:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid API key") raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid API key")
return ApiPrincipal(actor=settings.api_actor)

7
app/core/time.py Normal file
View File

@@ -0,0 +1,7 @@
from datetime import UTC, datetime
def utc_now() -> datetime:
"""Return a naive UTC timestamp for existing DateTime columns."""
return datetime.now(UTC).replace(tzinfo=None)

View File

@@ -111,6 +111,7 @@ class OpenClawAdapter(AIAdapter):
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Invoke one OpenClaw Gateway tool through `/tools/invoke`.""" """Invoke one OpenClaw Gateway tool through `/tools/invoke`."""
self._ensure_tool_allowed(tool, action)
payload = { payload = {
AIHttpPayloadKey.TOOL: tool, AIHttpPayloadKey.TOOL: tool,
AIHttpPayloadKey.ACTION: action, AIHttpPayloadKey.ACTION: action,
@@ -135,6 +136,12 @@ class OpenClawAdapter(AIAdapter):
AIHttpHeader.AUTHORIZATION: AUTHORIZATION_BEARER_TEMPLATE.format(token=token) AIHttpHeader.AUTHORIZATION: AUTHORIZATION_BEARER_TEMPLATE.format(token=token)
} }
def _ensure_tool_allowed(self, tool: str, action: str) -> None:
if tool not in set(self.settings.openclaw_allowed_tools):
raise HTTPException(status_code=403, detail="OpenClaw tool is not allowed")
if action not in set(self.settings.openclaw_allowed_actions):
raise HTTPException(status_code=403, detail="OpenClaw action is not allowed")
class HermesAdapter(AIAdapter): class HermesAdapter(AIAdapter):
"""Adapter for the Hermes OpenAI-compatible agent endpoint.""" """Adapter for the Hermes OpenAI-compatible agent endpoint."""

View File

@@ -126,3 +126,24 @@ NOOP_PROVIDER_ANSWER = (
OPENCLAW_TOOL_COMPLETED_ANSWER = "OpenClaw tool invocation completed." OPENCLAW_TOOL_COMPLETED_ANSWER = "OpenClaw tool invocation completed."
DIRECT_LLM_API_KEY_MISSING = "DIRECT_LLM_API_KEY is not configured" DIRECT_LLM_API_KEY_MISSING = "DIRECT_LLM_API_KEY is not configured"
UNEXPECTED_HERMES_RESPONSE = "Unexpected chat completion response" UNEXPECTED_HERMES_RESPONSE = "Unexpected chat completion response"
AI_AUDIT_REDACTED_VALUE = "[REDACTED]"
AI_AUDIT_TRUNCATED_VALUE = "[TRUNCATED]"
AI_AUDIT_MAX_TEXT_LENGTH = 1000
AI_AUDIT_MAX_SEQUENCE_ITEMS = 20
AI_AUDIT_MAX_DEPTH = 4
AI_AUDIT_SENSITIVE_KEYS = frozenset(
{
"authorization",
"api_key",
"apikey",
"access_token",
"tenant_access_token",
"token",
"secret",
"password",
"openclaw_gateway_token",
"hermes_api_key",
"direct_llm_api_key",
}
)

View File

@@ -1,9 +1,9 @@
from fastapi import APIRouter, Depends from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.core.constants import ActorValue
from app.core.database import get_db from app.core.database import get_db
from app.core.security import require_api_key from app.core.security import ApiPrincipal, require_api_key
from app.modules.audit.constants import AuditSource
from app.modules.ai_agent.schemas import ( from app.modules.ai_agent.schemas import (
AIAskRequest, AIAskRequest,
AIAskResponse, AIAskResponse,
@@ -17,43 +17,64 @@ router = APIRouter(dependencies=[Depends(require_api_key)])
@router.post("/ask", response_model=AIAskResponse) @router.post("/ask", response_model=AIAskResponse)
def ask(payload: AIAskRequest, db: Session = Depends(get_db)) -> dict: def ask(
return AIService(db).ask(payload.prompt, payload.context, payload.actor, payload.source) payload: AIAskRequest,
db: Session = Depends(get_db),
principal: ApiPrincipal = Depends(require_api_key),
) -> dict:
return AIService(db).ask(
payload.prompt,
payload.context,
actor=principal.actor,
source=AuditSource.API,
)
@router.get("/provider-health") @router.get("/provider-health")
def provider_health(actor: str = ActorValue.API, db: Session = Depends(get_db)) -> dict: def provider_health(
return AIService(db).provider_health(actor=actor) db: Session = Depends(get_db),
principal: ApiPrincipal = Depends(require_api_key),
) -> dict:
return AIService(db).provider_health(actor=principal.actor)
@router.post("/openclaw/tools/invoke") @router.post("/openclaw/tools/invoke")
def invoke_openclaw_tool( def invoke_openclaw_tool(
payload: OpenClawToolInvokeRequest, payload: OpenClawToolInvokeRequest,
db: Session = Depends(get_db), db: Session = Depends(get_db),
principal: ApiPrincipal = Depends(require_api_key),
) -> dict: ) -> dict:
return AIService(db).invoke_openclaw_tool( return AIService(db).invoke_openclaw_tool(
tool=payload.tool, tool=payload.tool,
action=payload.action, action=payload.action,
args=payload.args, args=payload.args,
session_key=payload.session_key, session_key=payload.session_key,
actor=payload.actor, actor=principal.actor,
) )
@router.post("/draft-policy", response_model=AIAskResponse) @router.post("/draft-policy", response_model=AIAskResponse)
def draft_policy(payload: DraftPolicyRequest, db: Session = Depends(get_db)) -> dict: def draft_policy(
payload: DraftPolicyRequest,
db: Session = Depends(get_db),
principal: ApiPrincipal = Depends(require_api_key),
) -> dict:
return AIService(db).draft_policy( return AIService(db).draft_policy(
title=payload.title, title=payload.title,
policy_type=payload.policy_type, policy_type=payload.policy_type,
requirements=payload.requirements, requirements=payload.requirements,
actor=payload.actor, actor=principal.actor,
) )
@router.post("/investment-research", response_model=AIAskResponse) @router.post("/investment-research", response_model=AIAskResponse)
def investment_research(payload: InvestmentResearchRequest, db: Session = Depends(get_db)) -> dict: def investment_research(
payload: InvestmentResearchRequest,
db: Session = Depends(get_db),
principal: ApiPrincipal = Depends(require_api_key),
) -> dict:
return AIService(db).draft_investment_research( return AIService(db).draft_investment_research(
symbol_or_topic=payload.symbol_or_topic, symbol_or_topic=payload.symbol_or_topic,
risk_preference=payload.risk_preference, risk_preference=payload.risk_preference,
actor=payload.actor, actor=principal.actor,
) )

View File

@@ -7,6 +7,12 @@ from app.core.config import get_settings
from app.modules.ai_agent.adapters import HermesAdapter, OpenClawAdapter, get_adapter from app.modules.ai_agent.adapters import HermesAdapter, OpenClawAdapter, get_adapter
from app.modules.ai_agent.constants import ( from app.modules.ai_agent.constants import (
AIDefault, AIDefault,
AI_AUDIT_MAX_DEPTH,
AI_AUDIT_MAX_SEQUENCE_ITEMS,
AI_AUDIT_MAX_TEXT_LENGTH,
AI_AUDIT_REDACTED_VALUE,
AI_AUDIT_SENSITIVE_KEYS,
AI_AUDIT_TRUNCATED_VALUE,
AIProviderName, AIProviderName,
AIRequestKey, AIRequestKey,
AIResponseKey, AIResponseKey,
@@ -50,11 +56,11 @@ class AIService:
action=AuditAction.AI_ASK, action=AuditAction.AI_ASK,
target_type=AuditTargetType.AI, target_type=AuditTargetType.AI,
risk_level=AuditRiskLevel.MEDIUM, risk_level=AuditRiskLevel.MEDIUM,
request_payload={ request_payload=_audit_safe_payload({
AIRequestKey.PROMPT: prompt, AIRequestKey.PROMPT: prompt,
AIRequestKey.CONTEXT: context or {}, AIRequestKey.CONTEXT: context or {},
}, }),
response_payload=response, response_payload=_audit_safe_payload(response),
) )
) )
return response return response
@@ -90,7 +96,7 @@ class AIService:
action=AuditAction.AI_PROVIDER_HEALTH, action=AuditAction.AI_PROVIDER_HEALTH,
target_type=AuditTargetType.AI, target_type=AuditTargetType.AI,
risk_level=AuditRiskLevel.LOW, risk_level=AuditRiskLevel.LOW,
response_payload=response, response_payload=_audit_safe_payload(response),
) )
) )
return response return response
@@ -113,13 +119,13 @@ class AIService:
target_type=AuditTargetType.OPENCLAW_TOOL, target_type=AuditTargetType.OPENCLAW_TOOL,
target_id=tool, target_id=tool,
risk_level=AuditRiskLevel.HIGH, risk_level=AuditRiskLevel.HIGH,
request_payload={ request_payload=_audit_safe_payload({
"tool": tool, "tool": tool,
"action": action, "action": action,
"args": args or {}, "args": args or {},
"session_key": session_key, "session_key": session_key,
}, }),
response_payload=result, response_payload=_audit_safe_payload(result),
) )
) )
return response return response
@@ -167,3 +173,26 @@ class AIService:
}, },
actor=actor, actor=actor,
) )
def _audit_safe_payload(value: Any, depth: int = 0) -> Any:
if depth >= AI_AUDIT_MAX_DEPTH:
return AI_AUDIT_TRUNCATED_VALUE
if isinstance(value, dict):
safe: dict[str, Any] = {}
for key, item in value.items():
key_text = str(key)
if key_text.lower() in AI_AUDIT_SENSITIVE_KEYS:
safe[key_text] = AI_AUDIT_REDACTED_VALUE
else:
safe[key_text] = _audit_safe_payload(item, depth + 1)
return safe
if isinstance(value, (list, tuple)):
items = list(value[:AI_AUDIT_MAX_SEQUENCE_ITEMS])
safe_items = [_audit_safe_payload(item, depth + 1) for item in items]
if len(value) > AI_AUDIT_MAX_SEQUENCE_ITEMS:
safe_items.append(AI_AUDIT_TRUNCATED_VALUE)
return safe_items
if isinstance(value, str) and len(value) > AI_AUDIT_MAX_TEXT_LENGTH:
return value[:AI_AUDIT_MAX_TEXT_LENGTH] + AI_AUDIT_TRUNCATED_VALUE
return value

View File

@@ -5,6 +5,7 @@ from sqlalchemy.orm import Mapped, mapped_column
from app.core.constants import ActorValue from app.core.constants import ActorValue
from app.core.database import Base from app.core.database import Base
from app.core.time import utc_now
from app.modules.approvals.constants import ApprovalStatus from app.modules.approvals.constants import ApprovalStatus
@@ -22,10 +23,10 @@ class ApprovalRequest(Base):
reason: Mapped[str | None] = mapped_column(Text, nullable=True) reason: Mapped[str | None] = mapped_column(Text, nullable=True)
payload: 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) decision_comment: Mapped[str | None] = mapped_column(Text, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, index=True) created_at: Mapped[datetime] = mapped_column(DateTime, default=utc_now, index=True)
updated_at: Mapped[datetime] = mapped_column( updated_at: Mapped[datetime] = mapped_column(
DateTime, DateTime,
default=datetime.utcnow, default=utc_now,
onupdate=datetime.utcnow, onupdate=utc_now,
) )
decided_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) decided_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)

View File

@@ -2,7 +2,7 @@ from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.core.database import get_db from app.core.database import get_db
from app.core.security import require_api_key from app.core.security import ApiPrincipal, require_api_key
from app.modules.approvals.schemas import ApprovalCreate, ApprovalDecision, ApprovalRead from app.modules.approvals.schemas import ApprovalCreate, ApprovalDecision, ApprovalRead
from app.modules.approvals.service import ApprovalService from app.modules.approvals.service import ApprovalService
@@ -10,8 +10,12 @@ router = APIRouter(dependencies=[Depends(require_api_key)])
@router.post("", response_model=ApprovalRead) @router.post("", response_model=ApprovalRead)
def create_approval(payload: ApprovalCreate, db: Session = Depends(get_db)): def create_approval(
return ApprovalService(db).create(payload) payload: ApprovalCreate,
db: Session = Depends(get_db),
principal: ApiPrincipal = Depends(require_api_key),
):
return ApprovalService(db).create(payload, applicant=principal.actor)
@router.get("", response_model=list[ApprovalRead]) @router.get("", response_model=list[ApprovalRead])
@@ -29,10 +33,20 @@ def get_approval(ticket_id: str, db: Session = Depends(get_db)):
@router.post("/{ticket_id}/approve", response_model=ApprovalRead) @router.post("/{ticket_id}/approve", response_model=ApprovalRead)
def approve(ticket_id: str, payload: ApprovalDecision, db: Session = Depends(get_db)): def approve(
return ApprovalService(db).decide(ticket_id, payload.approver, True, payload.comment) ticket_id: str,
payload: ApprovalDecision,
db: Session = Depends(get_db),
principal: ApiPrincipal = Depends(require_api_key),
):
return ApprovalService(db).decide(ticket_id, principal.actor, True, payload.comment)
@router.post("/{ticket_id}/reject", response_model=ApprovalRead) @router.post("/{ticket_id}/reject", response_model=ApprovalRead)
def reject(ticket_id: str, payload: ApprovalDecision, db: Session = Depends(get_db)): def reject(
return ApprovalService(db).decide(ticket_id, payload.approver, False, payload.comment) ticket_id: str,
payload: ApprovalDecision,
db: Session = Depends(get_db),
principal: ApiPrincipal = Depends(require_api_key),
):
return ApprovalService(db).decide(ticket_id, principal.actor, False, payload.comment)

View File

@@ -5,6 +5,7 @@ from fastapi import HTTPException, status
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.orm import Session 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, ApprovalStatus
from app.modules.approvals.models import ApprovalRequest from app.modules.approvals.models import ApprovalRequest
from app.modules.approvals.schemas import ApprovalCreate from app.modules.approvals.schemas import ApprovalCreate
@@ -20,13 +21,13 @@ class ApprovalService:
self.db = db self.db = db
self.audit = AuditService(db) self.audit = AuditService(db)
def create(self, payload: ApprovalCreate) -> ApprovalRequest: def create(self, payload: ApprovalCreate, applicant: str) -> ApprovalRequest:
ticket = ApprovalRequest( ticket = ApprovalRequest(
ticket_id=f"APR-{uuid.uuid4().hex[:12].upper()}", ticket_id=f"APR-{uuid.uuid4().hex[:12].upper()}",
domain=payload.domain, domain=payload.domain,
record_id=payload.record_id, record_id=payload.record_id,
action=payload.action, action=payload.action,
applicant=payload.applicant, applicant=applicant,
reason=payload.reason, reason=payload.reason,
payload=json.dumps(payload.payload, ensure_ascii=False, default=str), payload=json.dumps(payload.payload, ensure_ascii=False, default=str),
) )
@@ -35,7 +36,7 @@ class ApprovalService:
self.db.refresh(ticket) self.db.refresh(ticket)
self.audit.log( self.audit.log(
AuditLogCreate( AuditLogCreate(
actor=payload.applicant, actor=applicant,
source=AuditSource.APPROVAL, source=AuditSource.APPROVAL,
action=AuditAction.APPROVAL_CREATE, action=AuditAction.APPROVAL_CREATE,
target_type=payload.domain, target_type=payload.domain,
@@ -77,9 +78,7 @@ class ApprovalService:
ticket.status = ApprovalStatus.APPROVED if approved else ApprovalStatus.REJECTED ticket.status = ApprovalStatus.APPROVED if approved else ApprovalStatus.REJECTED
ticket.approver = approver ticket.approver = approver
ticket.decision_comment = comment ticket.decision_comment = comment
from datetime import datetime ticket.decided_at = utc_now()
ticket.decided_at = datetime.utcnow()
self.db.commit() self.db.commit()
self.db.refresh(ticket) self.db.refresh(ticket)
self.audit.log( self.audit.log(
@@ -108,7 +107,9 @@ class ApprovalService:
return False return False
if ticket.domain != domain: if ticket.domain != domain:
return False return False
if ticket.record_id and record_id is not None and str(ticket.record_id) != str(record_id): if ticket.record_id and (
record_id is None or str(ticket.record_id) != str(record_id)
):
return False return False
return ticket.action in { return ticket.action in {
action, action,

View File

@@ -27,12 +27,14 @@ class AuditSource(StrEnum):
FEISHU = "feishu" FEISHU = "feishu"
APPROVAL = "approval" APPROVAL = "approval"
LEGACY_MYSQL = "legacy_mysql" LEGACY_MYSQL = "legacy_mysql"
REPORTS = "reports"
class AuditTargetType(StrEnum): class AuditTargetType(StrEnum):
AI = "ai" AI = "ai"
OPENCLAW_TOOL = "openclaw_tool" OPENCLAW_TOOL = "openclaw_tool"
RISK_EVENTS = "risk-events" RISK_EVENTS = "risk-events"
WORK_REPORTS = "work-reports"
class AuditStatus(StrEnum): class AuditStatus(StrEnum):

View File

@@ -5,6 +5,7 @@ from sqlalchemy.orm import Mapped, mapped_column
from app.core.constants import ActorValue from app.core.constants import ActorValue
from app.core.database import Base from app.core.database import Base
from app.core.time import utc_now
from app.modules.audit.constants import AuditRiskLevel, AuditSource, AuditStatus from app.modules.audit.constants import AuditRiskLevel, AuditSource, AuditStatus
@@ -21,4 +22,4 @@ class AuditLog(Base):
request_payload: Mapped[str | None] = mapped_column(Text, nullable=True) request_payload: Mapped[str | None] = mapped_column(Text, nullable=True)
response_payload: Mapped[str | None] = mapped_column(Text, nullable=True) response_payload: Mapped[str | None] = mapped_column(Text, nullable=True)
status: Mapped[str] = mapped_column(String(32), default=AuditStatus.SUCCESS, index=True) status: Mapped[str] = mapped_column(String(32), default=AuditStatus.SUCCESS, index=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, index=True) created_at: Mapped[datetime] = mapped_column(DateTime, default=utc_now, index=True)

View File

@@ -115,3 +115,12 @@ ATTENDANCE_ABNORMAL_STATUSES = frozenset(
) )
SUPPLIER_RISK_LEVELS = frozenset({RiskLevel.MEDIUM, RiskLevel.HIGH}) SUPPLIER_RISK_LEVELS = frozenset({RiskLevel.MEDIUM, RiskLevel.HIGH})
CLOSED_RISK_STATUSES = frozenset({StatusValue.CLOSED, StatusValue.RESOLVED}) CLOSED_RISK_STATUSES = frozenset({StatusValue.CLOSED, StatusValue.RESOLVED})
GENERATED_RISK_EVENT_TYPES = frozenset(
{
RiskEventType.OVERDUE_TASK,
RiskEventType.DELAYED_PROJECT,
RiskEventType.OVER_BUDGET_PROJECT,
RiskEventType.FUND_SAFETY_LINE,
RiskEventType.SUPPLIER_RISK,
}
)

View File

@@ -6,6 +6,7 @@ from sqlalchemy.orm import Mapped, mapped_column
from app.core.constants import ActorValue from app.core.constants import ActorValue
from app.core.database import Base from app.core.database import Base
from app.core.time import utc_now
from app.modules.business.constants import ( from app.modules.business.constants import (
AccountType, AccountType,
PriorityValue, PriorityValue,
@@ -17,9 +18,9 @@ from app.modules.business.constants import (
class TimestampMixin: class TimestampMixin:
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) created_at: Mapped[datetime] = mapped_column(DateTime, default=utc_now)
updated_at: Mapped[datetime] = mapped_column( updated_at: Mapped[datetime] = mapped_column(
DateTime, default=datetime.utcnow, onupdate=datetime.utcnow DateTime, default=utc_now, onupdate=utc_now
) )
@@ -230,7 +231,7 @@ class RiskEvent(Base, TimestampMixin):
source_record_id: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True) source_record_id: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True)
project_code: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True) project_code: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
owner: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True) owner: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True)
detected_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, index=True) detected_at: Mapped[datetime] = mapped_column(DateTime, default=utc_now, index=True)
due_date: Mapped[date | None] = mapped_column(Date, nullable=True, index=True) due_date: Mapped[date | None] = mapped_column(Date, nullable=True, index=True)
description: Mapped[str | None] = mapped_column(Text, nullable=True) description: Mapped[str | None] = mapped_column(Text, nullable=True)
mitigation: Mapped[str | None] = mapped_column(Text, nullable=True) mitigation: Mapped[str | None] = mapped_column(Text, nullable=True)
@@ -245,7 +246,7 @@ class LegacySyncRun(Base, TimestampMixin):
domain: Mapped[str] = mapped_column(String(128), index=True) domain: Mapped[str] = mapped_column(String(128), index=True)
source_table: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True) source_table: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True)
status: Mapped[str] = mapped_column(String(32), default=StatusValue.RUNNING, index=True) status: Mapped[str] = mapped_column(String(32), default=StatusValue.RUNNING, index=True)
started_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, index=True) started_at: Mapped[datetime] = mapped_column(DateTime, default=utc_now, index=True)
finished_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) finished_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
created_count: Mapped[int] = mapped_column(Integer, default=0) created_count: Mapped[int] = mapped_column(Integer, default=0)
updated_count: Mapped[int] = mapped_column(Integer, default=0) updated_count: Mapped[int] = mapped_column(Integer, default=0)

View File

@@ -2,7 +2,7 @@ from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.core.database import get_db from app.core.database import get_db
from app.core.security import require_api_key from app.core.security import ApiPrincipal, require_api_key
from app.modules.business.registry import DOMAIN_MODELS from app.modules.business.registry import DOMAIN_MODELS
from app.modules.business.schemas import DomainListRead, DomainRecordCreate, DomainRecordUpdate from app.modules.business.schemas import DomainListRead, DomainRecordCreate, DomainRecordUpdate
from app.modules.business.service import BusinessService from app.modules.business.service import BusinessService
@@ -43,9 +43,15 @@ def create_record(
domain: str, domain: str,
payload: DomainRecordCreate, payload: DomainRecordCreate,
db: Session = Depends(get_db), db: Session = Depends(get_db),
principal: ApiPrincipal = Depends(require_api_key),
) -> dict: ) -> dict:
try: try:
data = BusinessService(db).create_record(domain, payload.data, payload.actor) data = BusinessService(db).create_record(
domain,
payload.data,
principal.actor,
payload.approval_ticket_id,
)
except KeyError as exc: except KeyError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc raise HTTPException(status_code=404, detail=str(exc)) from exc
return {"domain": domain, "data": data} return {"domain": domain, "data": data}
@@ -57,13 +63,14 @@ def update_record(
record_id: int, record_id: int,
payload: DomainRecordUpdate, payload: DomainRecordUpdate,
db: Session = Depends(get_db), db: Session = Depends(get_db),
principal: ApiPrincipal = Depends(require_api_key),
) -> dict: ) -> dict:
try: try:
data = BusinessService(db).update_record( data = BusinessService(db).update_record(
domain, domain,
record_id, record_id,
payload.data, payload.data,
actor=payload.actor, actor=principal.actor,
approval_ticket_id=payload.approval_ticket_id, approval_ticket_id=payload.approval_ticket_id,
) )
except KeyError as exc: except KeyError as exc:

View File

@@ -8,6 +8,10 @@ from app.core.constants import ActorValue
class DomainRecordCreate(BaseModel): class DomainRecordCreate(BaseModel):
data: dict[str, Any] = Field(..., description="Domain fields to create.") data: dict[str, Any] = Field(..., description="Domain fields to create.")
actor: str = ActorValue.API actor: str = ActorValue.API
approval_ticket_id: str | None = Field(
default=None,
description="Required by policy for high-risk creates such as funds or performance.",
)
class DomainRecordUpdate(BaseModel): class DomainRecordUpdate(BaseModel):

View File

@@ -102,7 +102,10 @@ class BusinessService:
domain: str, domain: str,
data: dict[str, Any], data: dict[str, Any],
actor: str = ActorValue.API, actor: str = ActorValue.API,
approval_ticket_id: str | None = None,
) -> dict[str, Any]: ) -> 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) model = get_domain_model(domain)
payload = _model_payload(model, data) payload = _model_payload(model, data)
record = model(**payload) record = model(**payload)
@@ -117,7 +120,10 @@ class BusinessService:
action=f"create:{domain}", action=f"create:{domain}",
target_type=domain, target_type=domain,
target_id=str(record.id), target_id=str(record.id),
request_payload=data, risk_level=(
AuditRiskLevel.HIGH if domain in HIGH_RISK_DOMAINS else AuditRiskLevel.LOW
),
request_payload={"data": data, "approval_ticket_id": approval_ticket_id},
response_payload=result, response_payload=result,
) )
) )
@@ -132,21 +138,7 @@ class BusinessService:
approval_ticket_id: str | None = None, approval_ticket_id: str | None = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
if domain in HIGH_RISK_DOMAINS: if domain in HIGH_RISK_DOMAINS:
if not approval_ticket_id: self._ensure_approved(approval_ticket_id, domain, record_id, f"update:{domain}")
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="High-risk domain update requires approval_ticket_id",
)
if not ApprovalService(self.db).is_approved_for(
approval_ticket_id,
domain,
record_id,
f"update:{domain}",
):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Approval ticket is not approved for this update",
)
model = get_domain_model(domain) model = get_domain_model(domain)
record = self.db.get(model, record_id) record = self.db.get(model, record_id)
if record is None: if record is None:
@@ -174,3 +166,26 @@ class BusinessService:
) )
) )
return result return result
def _ensure_approved(
self,
approval_ticket_id: str | None,
domain: str,
record_id: str | int | None,
action: 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(
approval_ticket_id,
domain,
record_id,
action,
):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Approval ticket is not approved for this change",
)

View File

@@ -2,7 +2,7 @@ from fastapi import APIRouter, Depends, Request
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.core.database import get_db from app.core.database import get_db
from app.core.security import require_api_key from app.core.security import ApiPrincipal, require_api_key
from app.modules.feishu.commands import FeishuCommandService from app.modules.feishu.commands import FeishuCommandService
from app.modules.feishu.events import FeishuEventService from app.modules.feishu.events import FeishuEventService
from app.modules.feishu.schemas import ( from app.modules.feishu.schemas import (
@@ -30,21 +30,31 @@ async def feishu_webhook(request: Request, db: Session = Depends(get_db)) -> dic
@router.post("/send-text", response_model=FeishuSendResult, dependencies=[Depends(require_api_key)]) @router.post("/send-text", response_model=FeishuSendResult, dependencies=[Depends(require_api_key)])
def send_text(payload: FeishuTextMessage, db: Session = Depends(get_db)) -> dict: def send_text(
payload: FeishuTextMessage,
db: Session = Depends(get_db),
principal: ApiPrincipal = Depends(require_api_key),
) -> dict:
result = FeishuService(db).send_text( result = FeishuService(db).send_text(
payload.text, payload.text,
receive_id=payload.receive_id, receive_id=payload.receive_id,
receive_id_type=payload.receive_id_type, receive_id_type=payload.receive_id_type,
actor=principal.actor,
) )
return {"ok": result.get("code") == 0, "provider_response": result} return {"ok": result.get("code") == 0, "provider_response": result}
@router.post("/send-card", response_model=FeishuSendResult, dependencies=[Depends(require_api_key)]) @router.post("/send-card", response_model=FeishuSendResult, dependencies=[Depends(require_api_key)])
def send_card(payload: FeishuCardMessage, db: Session = Depends(get_db)) -> dict: def send_card(
payload: FeishuCardMessage,
db: Session = Depends(get_db),
principal: ApiPrincipal = Depends(require_api_key),
) -> dict:
result = FeishuService(db).send_card( result = FeishuService(db).send_card(
payload.card, payload.card,
receive_id=payload.receive_id, receive_id=payload.receive_id,
receive_id_type=payload.receive_id_type, receive_id_type=payload.receive_id_type,
actor=principal.actor,
) )
return {"ok": result.get("code") == 0, "provider_response": result} return {"ok": result.get("code") == 0, "provider_response": result}
@@ -54,12 +64,16 @@ def send_card(payload: FeishuCardMessage, db: Session = Depends(get_db)) -> dict
response_model=FeishuCommandResult, response_model=FeishuCommandResult,
dependencies=[Depends(require_api_key)], dependencies=[Depends(require_api_key)],
) )
def preview_command(payload: FeishuCommandRequest, db: Session = Depends(get_db)) -> dict: def preview_command(
payload: FeishuCommandRequest,
db: Session = Depends(get_db),
principal: ApiPrincipal = Depends(require_api_key),
) -> dict:
"""Preview local Feishu command routing without requiring webhook delivery.""" """Preview local Feishu command routing without requiring webhook delivery."""
return FeishuCommandService(db).handle_text( return FeishuCommandService(db).handle_text(
payload.text, payload.text,
chat_id=payload.chat_id, chat_id=payload.chat_id,
actor=payload.actor, actor=principal.actor,
auto_reply=payload.auto_reply, auto_reply=payload.auto_reply,
) )

View File

@@ -25,7 +25,12 @@ class FeishuService:
expected = settings.feishu_verification_token expected = settings.feishu_verification_token
header = payload.get("header") or {} header = payload.get("header") or {}
token = payload.get("token") or header.get("token") token = payload.get("token") or header.get("token")
if expected and token and token != expected: if not expected:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="FEISHU_VERIFICATION_TOKEN is required",
)
if not token or token != expected:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid Feishu token", detail="Invalid Feishu token",

View File

@@ -2,7 +2,7 @@ from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.core.database import get_db from app.core.database import get_db
from app.core.security import require_api_key from app.core.security import ApiPrincipal, require_api_key
from app.modules.legacy_mysql.schemas import ( from app.modules.legacy_mysql.schemas import (
LegacyProjectSyncRequest, LegacyProjectSyncRequest,
LegacyProjectSyncResult, LegacyProjectSyncResult,
@@ -40,11 +40,15 @@ def default_project_query(limit: int = 100, db: Session = Depends(get_db)) -> di
@router.post("/projects/sync", response_model=LegacyProjectSyncResult) @router.post("/projects/sync", response_model=LegacyProjectSyncResult)
def sync_projects(payload: LegacyProjectSyncRequest, db: Session = Depends(get_db)) -> dict: def sync_projects(
payload: LegacyProjectSyncRequest,
db: Session = Depends(get_db),
principal: ApiPrincipal = Depends(require_api_key),
) -> dict:
return LegacyMySQLService(db).sync_projects( return LegacyMySQLService(db).sync_projects(
source_query=payload.source_query, source_query=payload.source_query,
field_map=payload.field_map, field_map=payload.field_map,
limit=payload.limit, limit=payload.limit,
dry_run=payload.dry_run, dry_run=payload.dry_run,
actor=payload.actor, actor=principal.actor,
) )

View File

@@ -12,6 +12,7 @@ from sqlalchemy.orm import Session
from app.core.constants import ActorValue from app.core.constants import ActorValue
from app.core.config import get_settings from app.core.config import get_settings
from app.core.database import legacy_engine from app.core.database import legacy_engine
from app.core.time import utc_now
from app.modules.audit.constants import AuditAction, AuditRiskLevel, AuditSource, AuditStatus from app.modules.audit.constants import AuditAction, AuditRiskLevel, AuditSource, AuditStatus
from app.modules.audit.schemas import AuditLogCreate from app.modules.audit.schemas import AuditLogCreate
from app.modules.audit.service import AuditService from app.modules.audit.service import AuditService
@@ -151,7 +152,11 @@ class LegacyMySQLService:
settings = get_settings() settings = get_settings()
external_id = self._value(row, field_map, "external_id", row.get("id")) external_id = self._value(row, field_map, "external_id", row.get("id"))
raw_code = self._value(row, field_map, "code", None) raw_code = self._value(row, field_map, "code", None)
code = str(raw_code) if raw_code else f"{settings.legacy_project_code_prefix}-{external_id}" code = None
if raw_code:
code = str(raw_code)
elif external_id is not None:
code = f"{settings.legacy_project_code_prefix}-{external_id}"
return { return {
"code": code, "code": code,
"external_id": str(external_id) if external_id is not None else code, "external_id": str(external_id) if external_id is not None else code,
@@ -260,11 +265,11 @@ class LegacyMySQLService:
"items": items, "items": items,
} }
sync_run = LegacySyncRun( sync_run = LegacySyncRun(
code=f"SYNC-PROJECTS-{datetime.utcnow():%Y%m%d%H%M%S%f}", code=f"SYNC-PROJECTS-{utc_now():%Y%m%d%H%M%S%f}",
domain=BusinessDomain.PROJECTS, domain=BusinessDomain.PROJECTS,
source_table="LEGACY_PROJECT_QUERY", source_table="LEGACY_PROJECT_QUERY",
status=StatusValue.DRY_RUN if dry_run else AuditStatus.SUCCESS, status=StatusValue.DRY_RUN if dry_run else AuditStatus.SUCCESS,
finished_at=datetime.utcnow(), finished_at=utc_now(),
created_count=created, created_count=created,
updated_count=updated, updated_count=updated,
skipped_count=skipped, skipped_count=skipped,

View File

@@ -101,6 +101,8 @@ class MetricKey(StrEnum):
OVER_BUDGET_PROJECTS = "over_budget_projects" OVER_BUDGET_PROJECTS = "over_budget_projects"
OPEN_EVENTS = "open_events" OPEN_EVENTS = "open_events"
HIGH_EVENTS = "high_events" HIGH_EVENTS = "high_events"
EXTERNAL_OPEN_EVENTS = "external_open_events"
EXTERNAL_HIGH_EVENTS = "external_high_events"
EVENTS_BY_TYPE = "events_by_type" EVENTS_BY_TYPE = "events_by_type"
EVENTS_BY_LEVEL = "events_by_level" EVENTS_BY_LEVEL = "events_by_level"
SCORE = "score" SCORE = "score"

View File

@@ -3,9 +3,8 @@ from datetime import date
from fastapi import APIRouter, Depends from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.core.constants import ActorValue
from app.core.database import get_db from app.core.database import get_db
from app.core.security import require_api_key from app.core.security import ApiPrincipal, require_api_key
from app.modules.reports.schemas import ( from app.modules.reports.schemas import (
PushReportRequest, PushReportRequest,
ReportResponse, ReportResponse,
@@ -33,8 +32,8 @@ def project_lifecycle_report(
period_start: date | None = None, period_start: date | None = None,
period_end: date | None = None, period_end: date | None = None,
include_ai: bool = False, include_ai: bool = False,
actor: str = ActorValue.API,
db: Session = Depends(get_db), db: Session = Depends(get_db),
principal: ApiPrincipal = Depends(require_api_key),
) -> dict: ) -> dict:
return ReportService(db).project_lifecycle_report( return ReportService(db).project_lifecycle_report(
project_code=project_code, project_code=project_code,
@@ -42,7 +41,7 @@ def project_lifecycle_report(
period_start=period_start, period_start=period_start,
period_end=period_end, period_end=period_end,
include_ai=include_ai, include_ai=include_ai,
actor=actor, actor=principal.actor,
) )
@@ -58,6 +57,7 @@ def attendance_summary(
def generate_work_report( def generate_work_report(
payload: WorkReportGenerateRequest, payload: WorkReportGenerateRequest,
db: Session = Depends(get_db), db: Session = Depends(get_db),
principal: ApiPrincipal = Depends(require_api_key),
) -> dict: ) -> dict:
return ReportService(db).generate_work_report( return ReportService(db).generate_work_report(
report_type=payload.report_type, report_type=payload.report_type,
@@ -67,27 +67,35 @@ def generate_work_report(
period_start=payload.period_start, period_start=payload.period_start,
period_end=payload.period_end, period_end=payload.period_end,
persist=payload.persist, persist=payload.persist,
actor=payload.actor, actor=principal.actor,
) )
@router.post("/daily-brief/push") @router.post("/daily-brief/push")
def push_daily_brief(payload: PushReportRequest, db: Session = Depends(get_db)) -> dict: def push_daily_brief(
payload: PushReportRequest,
db: Session = Depends(get_db),
principal: ApiPrincipal = Depends(require_api_key),
) -> dict:
report = ReportService(db).daily_brief() report = ReportService(db).daily_brief()
return ReportService(db).push_report( return ReportService(db).push_report(
report, report,
payload.receive_id, payload.receive_id,
payload.receive_id_type, payload.receive_id_type,
payload.actor, principal.actor,
) )
@router.post("/project-weekly/push") @router.post("/project-weekly/push")
def push_project_weekly(payload: PushReportRequest, db: Session = Depends(get_db)) -> dict: def push_project_weekly(
payload: PushReportRequest,
db: Session = Depends(get_db),
principal: ApiPrincipal = Depends(require_api_key),
) -> dict:
report = ReportService(db).project_weekly() report = ReportService(db).project_weekly()
return ReportService(db).push_report( return ReportService(db).push_report(
report, report,
payload.receive_id, payload.receive_id,
payload.receive_id_type, payload.receive_id_type,
payload.actor, principal.actor,
) )

View File

@@ -6,11 +6,14 @@ from sqlalchemy import func, or_, select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.core.constants import ActorValue from app.core.constants import ActorValue
from app.core.time import utc_now
from app.modules.audit.constants import AuditSource, AuditTargetType
from app.modules.audit.schemas import AuditLogCreate from app.modules.audit.schemas import AuditLogCreate
from app.modules.audit.service import AuditService from app.modules.audit.service import AuditService
from app.modules.business.constants import ( from app.modules.business.constants import (
ATTENDANCE_ABNORMAL_STATUSES, ATTENDANCE_ABNORMAL_STATUSES,
DONE_STATUSES, DONE_STATUSES,
GENERATED_RISK_EVENT_TYPES,
PROJECT_CLOSED_STATUSES, PROJECT_CLOSED_STATUSES,
PENDING_APPROVAL_STATUSES, PENDING_APPROVAL_STATUSES,
SUPPLIER_RISK_LEVELS, SUPPLIER_RISK_LEVELS,
@@ -69,7 +72,7 @@ def _json_safe(value: Any) -> Any:
def _next_code(prefix: str) -> str: def _next_code(prefix: str) -> str:
"""Build a compact unique code for generated report records.""" """Build a compact unique code for generated report records."""
return f"{prefix}-{datetime.utcnow():%Y%m%d%H%M%S%f}" return f"{prefix}-{utc_now():%Y%m%d%H%M%S%f}"
def _rate(numerator: int | Decimal, denominator: int | Decimal) -> float: def _rate(numerator: int | Decimal, denominator: int | Decimal) -> float:
@@ -228,7 +231,14 @@ class ReportService:
task_conditions, task_conditions,
risk_conditions, risk_conditions,
) )
health = self._lifecycle_health(project_stats, task_stats, risk_stats, supplier_stats) include_global_risk = not (project_code or owner)
health = self._lifecycle_health(
project_stats,
task_stats,
risk_stats,
supplier_stats,
include_global_risk,
)
attention = self._lifecycle_attention(project_conditions, task_conditions) attention = self._lifecycle_attention(project_conditions, task_conditions)
recommendations = self._lifecycle_recommendations( recommendations = self._lifecycle_recommendations(
project_stats, project_stats,
@@ -238,6 +248,7 @@ class ReportService:
fund_stats, fund_stats,
supplier_stats, supplier_stats,
risk_stats, risk_stats,
include_global_risk,
) )
metrics = { metrics = {
@@ -533,12 +544,25 @@ class ReportService:
RiskEvent.risk_level == RiskLevel.HIGH, RiskEvent.risk_level == RiskLevel.HIGH,
*risk_conditions, *risk_conditions,
) )
external_open_events = self._count(
RiskEvent,
RiskEvent.status == StatusValue.OPEN,
RiskEvent.risk_type.notin_(GENERATED_RISK_EVENT_TYPES),
*risk_conditions,
)
external_high_events = self._count(
RiskEvent,
RiskEvent.status == StatusValue.OPEN,
RiskEvent.risk_level == RiskLevel.HIGH,
RiskEvent.risk_type.notin_(GENERATED_RISK_EVENT_TYPES),
*risk_conditions,
)
risk_score = ( risk_score = (
overdue_tasks * 1 overdue_tasks * 1
+ delayed_projects * 3 + delayed_projects * 3
+ over_budget_projects * 4 + over_budget_projects * 4
+ open_events * 2 + external_open_events * 2
+ high_events * 3 + external_high_events * 3
) )
if risk_score >= 15: if risk_score >= 15:
level = RiskLevel.HIGH level = RiskLevel.HIGH
@@ -554,6 +578,8 @@ class ReportService:
MetricKey.OVER_BUDGET_PROJECTS: over_budget_projects, MetricKey.OVER_BUDGET_PROJECTS: over_budget_projects,
MetricKey.OPEN_EVENTS: open_events, MetricKey.OPEN_EVENTS: open_events,
MetricKey.HIGH_EVENTS: high_events, MetricKey.HIGH_EVENTS: high_events,
MetricKey.EXTERNAL_OPEN_EVENTS: external_open_events,
MetricKey.EXTERNAL_HIGH_EVENTS: external_high_events,
MetricKey.EVENTS_BY_TYPE: self._group_counts( MetricKey.EVENTS_BY_TYPE: self._group_counts(
RiskEvent, RiskEvent,
RiskEvent.risk_type, RiskEvent.risk_type,
@@ -572,16 +598,18 @@ class ReportService:
tasks: dict[str, Any], tasks: dict[str, Any],
risks: dict[str, Any], risks: dict[str, Any],
suppliers: dict[str, Any], suppliers: dict[str, Any],
include_global_risk: bool,
) -> dict[str, Any]: ) -> dict[str, Any]:
penalty = ( penalty = (
risks[MetricKey.OVERDUE_TASKS] * 3 risks[MetricKey.OVERDUE_TASKS] * 3
+ risks[MetricKey.DELAYED_PROJECTS] * 8 + risks[MetricKey.DELAYED_PROJECTS] * 8
+ risks[MetricKey.OVER_BUDGET_PROJECTS] * 10 + risks[MetricKey.OVER_BUDGET_PROJECTS] * 10
+ risks[MetricKey.HIGH_EVENTS] * 8 + risks[MetricKey.EXTERNAL_HIGH_EVENTS] * 8
+ suppliers[MetricKey.BLACKLISTED] * 10
+ max(0, projects[MetricKey.BUDGET_USAGE_RATE] - 100) * 0.4 + max(0, projects[MetricKey.BUDGET_USAGE_RATE] - 100) * 0.4
+ (100 - tasks[MetricKey.COMPLETION_RATE]) * 0.1 + (100 - tasks[MetricKey.COMPLETION_RATE]) * 0.1
) )
if include_global_risk:
penalty += suppliers[MetricKey.BLACKLISTED] * 10
score = max(0, min(100, round(100 - penalty, 2))) score = max(0, min(100, round(100 - penalty, 2)))
if score >= 80: if score >= 80:
level = HealthLevel.HEALTHY level = HealthLevel.HEALTHY
@@ -641,6 +669,7 @@ class ReportService:
funds: dict[str, Any], funds: dict[str, Any],
suppliers: dict[str, Any], suppliers: dict[str, Any],
risks: dict[str, Any], risks: dict[str, Any],
include_global_risk: bool,
) -> list[str]: ) -> list[str]:
recommendations: list[str] = [] recommendations: list[str] = []
if risks[MetricKey.DELAYED_PROJECTS]: if risks[MetricKey.DELAYED_PROJECTS]:
@@ -654,9 +683,9 @@ class ReportService:
or expenses[MetricKey.PENDING_APPROVAL] or expenses[MetricKey.PENDING_APPROVAL]
): ):
recommendations.append(ReportText.RECOMMEND_APPROVALS) recommendations.append(ReportText.RECOMMEND_APPROVALS)
if funds[MetricKey.RISK_ACCOUNTS]: if include_global_risk and funds[MetricKey.RISK_ACCOUNTS]:
recommendations.append(ReportText.RECOMMEND_FUNDS) recommendations.append(ReportText.RECOMMEND_FUNDS)
if suppliers[MetricKey.RISKY]: if include_global_risk and suppliers[MetricKey.RISKY]:
recommendations.append(ReportText.RECOMMEND_SUPPLIERS) recommendations.append(ReportText.RECOMMEND_SUPPLIERS)
if not recommendations: if not recommendations:
recommendations.append(ReportText.RECOMMEND_STABLE) recommendations.append(ReportText.RECOMMEND_STABLE)
@@ -835,9 +864,9 @@ class ReportService:
AuditService(self.db).log( AuditService(self.db).log(
AuditLogCreate( AuditLogCreate(
actor=actor, actor=actor,
source="reports", source=AuditSource.REPORTS,
action=f"generate_{report_type}_report", action=f"generate_{report_type}_report",
target_type="work-reports", target_type=AuditTargetType.WORK_REPORTS,
target_id=str(record.id), target_id=str(record.id),
response_payload=record_data, response_payload=record_data,
) )
@@ -871,17 +900,31 @@ class ReportService:
WorkTask.due_date >= start, WorkTask.due_date >= start,
WorkTask.due_date <= end, WorkTask.due_date <= end,
] ]
procurement_filters = [Procurement.approval_status.in_(PENDING_APPROVAL_STATUSES)] start_at = datetime.combine(start, datetime.min.time())
expense_filters = [Expense.approval_status.in_(PENDING_APPROVAL_STATUSES)] 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 = [ attendance_filters = [
AttendanceRecord.work_date >= start, AttendanceRecord.work_date >= start,
AttendanceRecord.work_date <= end, AttendanceRecord.work_date <= end,
] ]
if project_code: if project_code:
project_filters.append(Project.code == project_code)
task_filters.append(WorkTask.project_code == project_code) task_filters.append(WorkTask.project_code == project_code)
procurement_filters.append(Procurement.project_code == project_code) procurement_filters.append(Procurement.project_code == project_code)
expense_filters.append(Expense.project_code == project_code) expense_filters.append(Expense.project_code == project_code)
attendance_filters.append(AttendanceRecord.project_code == project_code) attendance_filters.append(AttendanceRecord.project_code == project_code)
risk_filters.append(RiskEvent.project_code == project_code)
if department: if department:
expense_filters.append(Expense.department == department) expense_filters.append(Expense.department == department)
attendance_filters.append(AttendanceRecord.department == department) attendance_filters.append(AttendanceRecord.department == department)
@@ -894,10 +937,11 @@ class ReportService:
*task_filters, *task_filters,
) )
return { return {
"projects_total": self._count(Project), "projects_total": self._count(Project, *project_filters),
"active_projects": self._count( "active_projects": self._count(
Project, Project,
Project.status.notin_(PROJECT_CLOSED_STATUSES), Project.status.notin_(PROJECT_CLOSED_STATUSES),
*project_filters,
), ),
"tasks_total": self._count(WorkTask, *task_filters), "tasks_total": self._count(WorkTask, *task_filters),
"tasks_completed": completed_tasks, "tasks_completed": completed_tasks,
@@ -905,10 +949,7 @@ class ReportService:
"procurements_pending": self._count(Procurement, *procurement_filters), "procurements_pending": self._count(Procurement, *procurement_filters),
"expenses_pending": self._count(Expense, *expense_filters), "expenses_pending": self._count(Expense, *expense_filters),
"attendance_total": self._count(AttendanceRecord, *attendance_filters), "attendance_total": self._count(AttendanceRecord, *attendance_filters),
"open_risk_events": self._count( "open_risk_events": self._count(RiskEvent, *risk_filters),
RiskEvent,
RiskEvent.status == StatusValue.OPEN,
),
} }
def _work_report_lines( def _work_report_lines(

View File

@@ -1,9 +1,8 @@
from fastapi import APIRouter, Depends from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.core.constants import ActorValue
from app.core.database import get_db from app.core.database import get_db
from app.core.security import require_api_key from app.core.security import ApiPrincipal, require_api_key
from app.modules.risk.service import RiskService from app.modules.risk.service import RiskService
router = APIRouter(dependencies=[Depends(require_api_key)]) router = APIRouter(dependencies=[Depends(require_api_key)])
@@ -49,5 +48,8 @@ def risk_events(
@router.post("/events/generate") @router.post("/events/generate")
def generate_risk_events(actor: str = ActorValue.API, db: Session = Depends(get_db)) -> dict: def generate_risk_events(
return RiskService(db).generate_events(actor=actor) db: Session = Depends(get_db),
principal: ApiPrincipal = Depends(require_api_key),
) -> dict:
return RiskService(db).generate_events(actor=principal.actor)

View File

@@ -1,4 +1,4 @@
from datetime import date, datetime from datetime import date
from decimal import Decimal from decimal import Decimal
from typing import Any from typing import Any
@@ -6,6 +6,7 @@ from sqlalchemy import select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.core.constants import ActorValue from app.core.constants import ActorValue
from app.core.time import utc_now
from app.modules.audit.constants import ( from app.modules.audit.constants import (
AuditAction, AuditAction,
AuditRiskLevel, AuditRiskLevel,
@@ -17,6 +18,7 @@ from app.modules.audit.service import AuditService
from app.modules.business.constants import ( from app.modules.business.constants import (
CLOSED_RISK_STATUSES, CLOSED_RISK_STATUSES,
DONE_STATUSES, DONE_STATUSES,
GENERATED_RISK_EVENT_TYPES,
PROJECT_CLOSED_STATUSES, PROJECT_CLOSED_STATUSES,
SUPPLIER_RISK_LEVELS, SUPPLIER_RISK_LEVELS,
BusinessDomain, BusinessDomain,
@@ -90,13 +92,18 @@ class RiskService:
fund_risks = self.fund_risks() fund_risks = self.fund_risks()
supplier_risks = self.supplier_risks() supplier_risks = self.supplier_risks()
open_events = self.list_events(status_filter=StatusValue.OPEN) open_events = self.list_events(status_filter=StatusValue.OPEN)
external_open_events = [
item
for item in open_events
if item.get("risk_type") not in GENERATED_RISK_EVENT_TYPES
]
risk_score = ( risk_score = (
len(overdue_tasks) * 1 len(overdue_tasks) * 1
+ len(delayed_projects) * 3 + len(delayed_projects) * 3
+ len(over_budget_projects) * 4 + len(over_budget_projects) * 4
+ len(fund_risks) * 5 + len(fund_risks) * 5
+ len(supplier_risks) * 3 + len(supplier_risks) * 3
+ len(open_events) * 2 + len(external_open_events) * 2
) )
if risk_score >= 15: if risk_score >= 15:
level = RiskLevel.HIGH level = RiskLevel.HIGH
@@ -192,7 +199,7 @@ class RiskService:
"project_code": task.project_code, "project_code": task.project_code,
"owner": task.owner, "owner": task.owner,
"due_date": task.due_date, "due_date": task.due_date,
"detected_at": datetime.utcnow(), "detected_at": utc_now(),
"description": "任务已超过截止日期且未完成。", "description": "任务已超过截止日期且未完成。",
"mitigation": ( "mitigation": (
"请负责人更新进度、明确阻塞项并给出新的完成时间。" "请负责人更新进度、明确阻塞项并给出新的完成时间。"
@@ -223,7 +230,7 @@ class RiskService:
"project_code": project.code, "project_code": project.code,
"owner": project.owner, "owner": project.owner,
"due_date": project.due_date, "due_date": project.due_date,
"detected_at": datetime.utcnow(), "detected_at": utc_now(),
"description": "项目已超过计划截止日期且未进入完成状态。", "description": "项目已超过计划截止日期且未进入完成状态。",
"mitigation": ( "mitigation": (
"请项目负责人提交延期原因、资源需求和纠偏计划。" "请项目负责人提交延期原因、资源需求和纠偏计划。"
@@ -252,7 +259,7 @@ class RiskService:
"project_code": project.code, "project_code": project.code,
"owner": project.owner, "owner": project.owner,
"due_date": project.due_date, "due_date": project.due_date,
"detected_at": datetime.utcnow(), "detected_at": utc_now(),
"description": "项目实际成本已超过预算。", "description": "项目实际成本已超过预算。",
"mitigation": ( "mitigation": (
"请复核预算科目、冻结非必要采购并补充审批依据。" "请复核预算科目、冻结非必要采购并补充审批依据。"
@@ -276,7 +283,7 @@ class RiskService:
"source_domain": BusinessDomain.FUND_ACCOUNTS, "source_domain": BusinessDomain.FUND_ACCOUNTS,
"source_record_id": str(account.id), "source_record_id": str(account.id),
"owner": None, "owner": None,
"detected_at": datetime.utcnow(), "detected_at": utc_now(),
"description": "账户当前余额低于设置的安全线。", "description": "账户当前余额低于设置的安全线。",
"mitigation": ( "mitigation": (
"请财务确认收付款计划," "请财务确认收付款计划,"
@@ -309,7 +316,7 @@ class RiskService:
"source_domain": BusinessDomain.SUPPLIERS, "source_domain": BusinessDomain.SUPPLIERS,
"source_record_id": str(supplier.id), "source_record_id": str(supplier.id),
"owner": supplier.contact, "owner": supplier.contact,
"detected_at": datetime.utcnow(), "detected_at": utc_now(),
"description": "供应商风险等级或黑名单状态需要关注。", "description": "供应商风险等级或黑名单状态需要关注。",
"mitigation": ( "mitigation": (
"请采购负责人复核供应商准入、履约和替代方案。" "请采购负责人复核供应商准入、履约和替代方案。"

View File

@@ -12,6 +12,7 @@ dependencies:
- psycopg[binary]==3.2.3 - psycopg[binary]==3.2.3
- pydantic-settings==2.7.1 - pydantic-settings==2.7.1
- python-dotenv==1.0.1 - python-dotenv==1.0.1
- alembic==1.14.0
- httpx==0.28.1 - httpx==0.28.1
- lark-oapi==1.6.8 - lark-oapi==1.6.8
- apscheduler==3.10.4 - apscheduler==3.10.4

View File

@@ -1,5 +1,8 @@
from typing import Any from typing import Any
import pytest
from fastapi import HTTPException
from app.core.config import Settings from app.core.config import Settings
from app.modules.ai_agent import adapters from app.modules.ai_agent import adapters
from app.modules.ai_agent.constants import ( from app.modules.ai_agent.constants import (
@@ -128,6 +131,7 @@ def test_openclaw_adapter_uses_gateway_health_and_tool_invoke(monkeypatch) -> No
settings = Settings( settings = Settings(
openclaw_http_url="http://openclaw.local", openclaw_http_url="http://openclaw.local",
openclaw_gateway_token="gateway-token", openclaw_gateway_token="gateway-token",
openclaw_allowed_tools=["sessions_list"],
) )
health = adapters.OpenClawAdapter(settings).health() health = adapters.OpenClawAdapter(settings).health()
@@ -158,6 +162,7 @@ def test_openclaw_hermes_adapter_runs_recall_answer_and_remember(monkeypatch) ->
model_provider="openclaw_hermes", model_provider="openclaw_hermes",
openclaw_http_url="http://openclaw.local", openclaw_http_url="http://openclaw.local",
openclaw_gateway_token="openclaw-key", openclaw_gateway_token="openclaw-key",
openclaw_allowed_tools=["sessions_list"],
hermes_base_url="http://hermes.local/v1", hermes_base_url="http://hermes.local/v1",
hermes_api_key="hermes-key", hermes_api_key="hermes-key",
) )
@@ -187,3 +192,19 @@ def test_openclaw_hermes_adapter_runs_recall_answer_and_remember(monkeypatch) ->
assert AIContextKey.OPENCLAW in DummyClient.calls[4]["json"][AIHttpPayloadKey.MESSAGES][1][ assert AIContextKey.OPENCLAW in DummyClient.calls[4]["json"][AIHttpPayloadKey.MESSAGES][1][
AIHttpPayloadKey.CONTENT AIHttpPayloadKey.CONTENT
] ]
def test_openclaw_adapter_blocks_tools_not_in_allowlist(monkeypatch) -> None:
DummyClient.calls = []
monkeypatch.setattr(adapters.httpx, "Client", DummyClient)
settings = Settings(
openclaw_http_url="http://openclaw.local",
openclaw_gateway_token="gateway-token",
openclaw_allowed_tools=["sessions_list"],
)
with pytest.raises(HTTPException) as exc_info:
adapters.OpenClawAdapter(settings).invoke_tool("filesystem_write")
assert exc_info.value.status_code == 403
assert DummyClient.calls == []

View File

@@ -4,6 +4,9 @@ import tempfile
from datetime import date, timedelta from datetime import date, timedelta
from pathlib import Path from pathlib import Path
import pytest
from fastapi import HTTPException
from app.modules.ai_agent.constants import AIProviderName, AIResponseKey from app.modules.ai_agent.constants import AIProviderName, AIResponseKey
from app.modules.business.constants import StatusValue from app.modules.business.constants import StatusValue
@@ -14,13 +17,17 @@ os.environ["DATABASE_URL"] = "sqlite:///" + _db.name.replace("\\", "/")
os.environ["API_KEY"] = "test-key" os.environ["API_KEY"] = "test-key"
os.environ["FEISHU_APP_ID"] = "" os.environ["FEISHU_APP_ID"] = ""
os.environ["FEISHU_APP_SECRET"] = "" os.environ["FEISHU_APP_SECRET"] = ""
os.environ["FEISHU_VERIFICATION_TOKEN"] = "test-feishu-token"
os.environ["MODEL_PROVIDER"] = AIProviderName.NOOP os.environ["MODEL_PROVIDER"] = AIProviderName.NOOP
os.environ["SCHEDULER_ENABLED"] = "false" os.environ["SCHEDULER_ENABLED"] = "false"
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
from app.core.config import get_settings
from app.core.database import Base, engine from app.core.database import Base, engine
from app.core.security import require_api_key
from app.main import app from app.main import app
from app.modules.legacy_mysql.service import LegacyMySQLService
from app.modules.reports.constants import ( from app.modules.reports.constants import (
LifecycleAttentionKey, LifecycleAttentionKey,
LifecycleResponseKey, LifecycleResponseKey,
@@ -78,7 +85,7 @@ def test_project_report_and_feishu_command_preview() -> None:
def test_feishu_webhook_routes_message_event() -> None: def test_feishu_webhook_routes_message_event() -> None:
payload = { payload = {
"schema": "2.0", "schema": "2.0",
"header": {"event_type": "im.message.receive_v1"}, "header": {"event_type": "im.message.receive_v1", "token": "test-feishu-token"},
"event": { "event": {
"sender": {"sender_id": {"open_id": "ou_test"}}, "sender": {"sender_id": {"open_id": "ou_test"}},
"message": { "message": {
@@ -95,12 +102,70 @@ def test_feishu_webhook_routes_message_event() -> None:
assert data["result"]["command"] == "risk_summary" assert data["result"]["command"] == "risk_summary"
def test_api_key_and_feishu_webhook_fail_closed(monkeypatch) -> None:
monkeypatch.setenv("API_KEY", "")
get_settings.cache_clear()
try:
with pytest.raises(HTTPException) as exc_info:
require_api_key("test-key")
assert exc_info.value.status_code == 503
monkeypatch.setenv("API_KEY", "test-key")
get_settings.cache_clear()
response = client.post(
"/api/v1/integrations/feishu/webhook",
json={"schema": "2.0", "header": {"event_type": "im.message.receive_v1"}},
)
assert response.status_code == 401
finally:
monkeypatch.setenv("API_KEY", "test-key")
get_settings.cache_clear()
def test_approval_gate_for_high_risk_update() -> None: def test_approval_gate_for_high_risk_update() -> None:
blocked_create_response = client.post(
"/api/v1/business/fund-accounts",
headers=headers,
json={
"actor": "spoofed-user",
"data": {
"code": "FUND-SMOKE-BLOCKED",
"name": "Blocked Account",
},
},
)
assert blocked_create_response.status_code == 409
create_approval_response = client.post(
"/api/v1/approvals",
headers=headers,
json={
"domain": "fund-accounts",
"action": "create:fund-accounts",
"applicant": "spoofed-user",
"reason": "Smoke test account creation",
"payload": {"code": "FUND-SMOKE-001"},
},
)
assert create_approval_response.status_code == 200
assert create_approval_response.json()["applicant"] == "api"
create_ticket_id = create_approval_response.json()["ticket_id"]
approve_create_response = client.post(
f"/api/v1/approvals/{create_ticket_id}/approve",
headers=headers,
json={"approver": "spoofed-manager", "comment": "ok"},
)
assert approve_create_response.status_code == 200
assert approve_create_response.json()["approver"] == "api"
create_response = client.post( create_response = client.post(
"/api/v1/business/fund-accounts", "/api/v1/business/fund-accounts",
headers=headers, headers=headers,
json={ json={
"actor": "pytest", "actor": "spoofed-user",
"approval_ticket_id": create_ticket_id,
"data": { "data": {
"code": "FUND-SMOKE-001", "code": "FUND-SMOKE-001",
"name": "Main Account", "name": "Main Account",
@@ -115,7 +180,7 @@ def test_approval_gate_for_high_risk_update() -> None:
blocked_response = client.patch( blocked_response = client.patch(
f"/api/v1/business/fund-accounts/{record_id}", f"/api/v1/business/fund-accounts/{record_id}",
headers=headers, headers=headers,
json={"actor": "pytest", "data": {"current_balance": 100}}, json={"actor": "spoofed-user", "data": {"current_balance": 100}},
) )
assert blocked_response.status_code == 409 assert blocked_response.status_code == 409
@@ -126,7 +191,7 @@ def test_approval_gate_for_high_risk_update() -> None:
"domain": "fund-accounts", "domain": "fund-accounts",
"record_id": str(record_id), "record_id": str(record_id),
"action": "update:fund-accounts", "action": "update:fund-accounts",
"applicant": "pytest", "applicant": "spoofed-user",
"reason": "Smoke test balance adjustment", "reason": "Smoke test balance adjustment",
"payload": {"current_balance": 100}, "payload": {"current_balance": 100},
}, },
@@ -138,7 +203,7 @@ def test_approval_gate_for_high_risk_update() -> None:
f"/api/v1/business/fund-accounts/{record_id}", f"/api/v1/business/fund-accounts/{record_id}",
headers=headers, headers=headers,
json={ json={
"actor": "pytest", "actor": "spoofed-user",
"approval_ticket_id": ticket_id, "approval_ticket_id": ticket_id,
"data": {"current_balance": 100}, "data": {"current_balance": 100},
}, },
@@ -148,16 +213,17 @@ def test_approval_gate_for_high_risk_update() -> None:
approve_response = client.post( approve_response = client.post(
f"/api/v1/approvals/{ticket_id}/approve", f"/api/v1/approvals/{ticket_id}/approve",
headers=headers, headers=headers,
json={"approver": "manager", "comment": "ok"}, json={"approver": "spoofed-manager", "comment": "ok"},
) )
assert approve_response.status_code == 200 assert approve_response.status_code == 200
assert approve_response.json()["status"] == "approved" assert approve_response.json()["status"] == "approved"
assert approve_response.json()["approver"] == "api"
update_response = client.patch( update_response = client.patch(
f"/api/v1/business/fund-accounts/{record_id}", f"/api/v1/business/fund-accounts/{record_id}",
headers=headers, headers=headers,
json={ json={
"actor": "pytest", "actor": "spoofed-user",
"approval_ticket_id": ticket_id, "approval_ticket_id": ticket_id,
"data": {"current_balance": 100}, "data": {"current_balance": 100},
}, },
@@ -383,3 +449,10 @@ def test_ai_noop_provider() -> None:
) )
assert response.status_code == 200 assert response.status_code == 200
assert response.json()[AIResponseKey.PROVIDER] == AIProviderName.NOOP assert response.json()[AIResponseKey.PROVIDER] == AIProviderName.NOOP
def test_legacy_project_payload_does_not_create_legacy_none_code() -> None:
payload = LegacyMySQLService(None)._project_payload({"name": "Missing Id"}, {})
assert payload["code"] is None
assert payload["external_id"] is None