From ae5990eaef61155f3e3d0edbffb836387c4dd62a Mon Sep 17 00:00:00 2001 From: JiuContinent Date: Mon, 6 Jul 2026 00:11:44 +0800 Subject: [PATCH] =?UTF-8?q?```=20feat(core):=20=E6=B7=BB=E5=8A=A0API?= =?UTF-8?q?=E8=AE=A4=E8=AF=81=E4=B8=BB=E4=BD=93=E9=85=8D=E7=BD=AE=E5=92=8C?= =?UTF-8?q?=E5=AE=89=E5=85=A8=E9=AA=8C=E8=AF=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 在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): 增强飞书验证令牌安全检查 - 确保飞书验证令牌配置存在时才接受请求 - 修正令牌验证逻辑以提高安全性 ``` --- alembic.ini | 39 +++++++++ alembic/env.py | 54 ++++++++++++ .../versions/202607060001_initial_schema.py | 29 +++++++ app/core/config.py | 12 +++ app/core/security.py | 19 +++- app/core/time.py | 7 ++ app/modules/ai_agent/adapters.py | 7 ++ app/modules/ai_agent/constants.py | 21 +++++ app/modules/ai_agent/routes.py | 43 ++++++--- app/modules/ai_agent/service.py | 43 +++++++-- app/modules/approvals/models.py | 7 +- app/modules/approvals/routes.py | 28 ++++-- app/modules/approvals/service.py | 15 ++-- app/modules/audit/constants.py | 2 + app/modules/audit/models.py | 3 +- app/modules/business/constants.py | 9 ++ app/modules/business/models.py | 9 +- app/modules/business/routes.py | 13 ++- app/modules/business/schemas.py | 4 + app/modules/business/service.py | 47 ++++++---- app/modules/feishu/routes.py | 24 +++-- app/modules/feishu/service.py | 7 +- app/modules/legacy_mysql/routes.py | 10 ++- app/modules/legacy_mysql/service.py | 11 ++- app/modules/reports/constants.py | 2 + app/modules/reports/routes.py | 26 ++++-- app/modules/reports/service.py | 75 ++++++++++++---- app/modules/risk/routes.py | 10 ++- app/modules/risk/service.py | 21 +++-- environment.yml | 1 + tests/test_ai_adapters.py | 21 +++++ tests/test_smoke.py | 87 +++++++++++++++++-- 32 files changed, 588 insertions(+), 118 deletions(-) create mode 100644 alembic.ini create mode 100644 alembic/env.py create mode 100644 alembic/versions/202607060001_initial_schema.py create mode 100644 app/core/time.py diff --git a/alembic.ini b/alembic.ini new file mode 100644 index 0000000..57d672e --- /dev/null +++ b/alembic.ini @@ -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 diff --git a/alembic/env.py b/alembic/env.py new file mode 100644 index 0000000..f9be3b1 --- /dev/null +++ b/alembic/env.py @@ -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() diff --git a/alembic/versions/202607060001_initial_schema.py b/alembic/versions/202607060001_initial_schema.py new file mode 100644 index 0000000..efbf7ba --- /dev/null +++ b/alembic/versions/202607060001_initial_schema.py @@ -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()) diff --git a/app/core/config.py b/app/core/config.py index beba442..c47c22e 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -3,6 +3,8 @@ from functools import lru_cache from pydantic import Field, field_validator from pydantic_settings import BaseSettings, SettingsConfigDict +from app.core.constants import ActorValue + DEFAULT_MODEL_PROVIDER = "noop" @@ -16,6 +18,7 @@ class Settings(BaseSettings): debug: bool = False api_prefix: str = "/api/v1" api_key: str | None = None + api_actor: str = ActorValue.API cors_origins: list[str] = Field(default_factory=lambda: ["*"]) 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_api_key: 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_api_key: str | None = None hermes_model: str = "hermes-agent" @@ -59,6 +64,13 @@ class Settings(BaseSettings): return value 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 def get_settings() -> Settings: diff --git a/app/core/security.py b/app/core/security.py index 377aa0d..6c9e432 100644 --- a/app/core/security.py +++ b/app/core/security.py @@ -1,13 +1,26 @@ +from dataclasses import dataclass + from fastapi import Header, HTTPException, status from app.core.config import get_settings -def require_api_key(x_api_key: str | None = Header(default=None)) -> None: - """Validate the optional internal API key header.""" +@dataclass(frozen=True) +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() 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: raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid API key") + return ApiPrincipal(actor=settings.api_actor) diff --git a/app/core/time.py b/app/core/time.py new file mode 100644 index 0000000..102e864 --- /dev/null +++ b/app/core/time.py @@ -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) diff --git a/app/modules/ai_agent/adapters.py b/app/modules/ai_agent/adapters.py index 445211c..b5bebbe 100644 --- a/app/modules/ai_agent/adapters.py +++ b/app/modules/ai_agent/adapters.py @@ -111,6 +111,7 @@ class OpenClawAdapter(AIAdapter): ) -> dict[str, Any]: """Invoke one OpenClaw Gateway tool through `/tools/invoke`.""" + self._ensure_tool_allowed(tool, action) payload = { AIHttpPayloadKey.TOOL: tool, AIHttpPayloadKey.ACTION: action, @@ -135,6 +136,12 @@ class OpenClawAdapter(AIAdapter): 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): """Adapter for the Hermes OpenAI-compatible agent endpoint.""" diff --git a/app/modules/ai_agent/constants.py b/app/modules/ai_agent/constants.py index 7c2c1e8..060813c 100644 --- a/app/modules/ai_agent/constants.py +++ b/app/modules/ai_agent/constants.py @@ -126,3 +126,24 @@ NOOP_PROVIDER_ANSWER = ( OPENCLAW_TOOL_COMPLETED_ANSWER = "OpenClaw tool invocation completed." DIRECT_LLM_API_KEY_MISSING = "DIRECT_LLM_API_KEY is not configured" 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", + } +) diff --git a/app/modules/ai_agent/routes.py b/app/modules/ai_agent/routes.py index 3ef82e1..ce541cf 100644 --- a/app/modules/ai_agent/routes.py +++ b/app/modules/ai_agent/routes.py @@ -1,9 +1,9 @@ from fastapi import APIRouter, Depends from sqlalchemy.orm import Session -from app.core.constants import ActorValue 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 ( AIAskRequest, AIAskResponse, @@ -17,43 +17,64 @@ router = APIRouter(dependencies=[Depends(require_api_key)]) @router.post("/ask", response_model=AIAskResponse) -def ask(payload: AIAskRequest, db: Session = Depends(get_db)) -> dict: - return AIService(db).ask(payload.prompt, payload.context, payload.actor, payload.source) +def ask( + 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") -def provider_health(actor: str = ActorValue.API, db: Session = Depends(get_db)) -> dict: - return AIService(db).provider_health(actor=actor) +def provider_health( + 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") def invoke_openclaw_tool( payload: OpenClawToolInvokeRequest, db: Session = Depends(get_db), + principal: ApiPrincipal = Depends(require_api_key), ) -> dict: return AIService(db).invoke_openclaw_tool( tool=payload.tool, action=payload.action, args=payload.args, session_key=payload.session_key, - actor=payload.actor, + actor=principal.actor, ) @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( title=payload.title, policy_type=payload.policy_type, requirements=payload.requirements, - actor=payload.actor, + actor=principal.actor, ) @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( symbol_or_topic=payload.symbol_or_topic, risk_preference=payload.risk_preference, - actor=payload.actor, + actor=principal.actor, ) diff --git a/app/modules/ai_agent/service.py b/app/modules/ai_agent/service.py index 60e9860..4c89194 100644 --- a/app/modules/ai_agent/service.py +++ b/app/modules/ai_agent/service.py @@ -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.constants import ( 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, AIRequestKey, AIResponseKey, @@ -50,11 +56,11 @@ class AIService: action=AuditAction.AI_ASK, target_type=AuditTargetType.AI, risk_level=AuditRiskLevel.MEDIUM, - request_payload={ + request_payload=_audit_safe_payload({ AIRequestKey.PROMPT: prompt, AIRequestKey.CONTEXT: context or {}, - }, - response_payload=response, + }), + response_payload=_audit_safe_payload(response), ) ) return response @@ -90,7 +96,7 @@ class AIService: action=AuditAction.AI_PROVIDER_HEALTH, target_type=AuditTargetType.AI, risk_level=AuditRiskLevel.LOW, - response_payload=response, + response_payload=_audit_safe_payload(response), ) ) return response @@ -113,13 +119,13 @@ class AIService: target_type=AuditTargetType.OPENCLAW_TOOL, target_id=tool, risk_level=AuditRiskLevel.HIGH, - request_payload={ + request_payload=_audit_safe_payload({ "tool": tool, "action": action, "args": args or {}, "session_key": session_key, - }, - response_payload=result, + }), + response_payload=_audit_safe_payload(result), ) ) return response @@ -167,3 +173,26 @@ class AIService: }, 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 diff --git a/app/modules/approvals/models.py b/app/modules/approvals/models.py index a4403c2..42d8769 100644 --- a/app/modules/approvals/models.py +++ b/app/modules/approvals/models.py @@ -5,6 +5,7 @@ from sqlalchemy.orm import Mapped, mapped_column from app.core.constants import ActorValue from app.core.database import Base +from app.core.time import utc_now from app.modules.approvals.constants import ApprovalStatus @@ -22,10 +23,10 @@ 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) - 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( DateTime, - default=datetime.utcnow, - onupdate=datetime.utcnow, + default=utc_now, + onupdate=utc_now, ) decided_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) diff --git a/app/modules/approvals/routes.py b/app/modules/approvals/routes.py index e4dfcc8..a010616 100644 --- a/app/modules/approvals/routes.py +++ b/app/modules/approvals/routes.py @@ -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 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.service import ApprovalService @@ -10,8 +10,12 @@ router = APIRouter(dependencies=[Depends(require_api_key)]) @router.post("", response_model=ApprovalRead) -def create_approval(payload: ApprovalCreate, db: Session = Depends(get_db)): - return ApprovalService(db).create(payload) +def create_approval( + 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]) @@ -29,10 +33,20 @@ def get_approval(ticket_id: str, db: Session = Depends(get_db)): @router.post("/{ticket_id}/approve", response_model=ApprovalRead) -def approve(ticket_id: str, payload: ApprovalDecision, db: Session = Depends(get_db)): - return ApprovalService(db).decide(ticket_id, payload.approver, True, payload.comment) +def approve( + 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) -def reject(ticket_id: str, payload: ApprovalDecision, db: Session = Depends(get_db)): - return ApprovalService(db).decide(ticket_id, payload.approver, False, payload.comment) +def reject( + 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) diff --git a/app/modules/approvals/service.py b/app/modules/approvals/service.py index 833aefe..e1051f0 100644 --- a/app/modules/approvals/service.py +++ b/app/modules/approvals/service.py @@ -5,6 +5,7 @@ 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.models import ApprovalRequest from app.modules.approvals.schemas import ApprovalCreate @@ -20,13 +21,13 @@ class ApprovalService: self.db = db self.audit = AuditService(db) - def create(self, payload: ApprovalCreate) -> ApprovalRequest: + def create(self, payload: ApprovalCreate, applicant: str) -> ApprovalRequest: ticket = ApprovalRequest( ticket_id=f"APR-{uuid.uuid4().hex[:12].upper()}", domain=payload.domain, record_id=payload.record_id, action=payload.action, - applicant=payload.applicant, + applicant=applicant, reason=payload.reason, payload=json.dumps(payload.payload, ensure_ascii=False, default=str), ) @@ -35,7 +36,7 @@ class ApprovalService: self.db.refresh(ticket) self.audit.log( AuditLogCreate( - actor=payload.applicant, + actor=applicant, source=AuditSource.APPROVAL, action=AuditAction.APPROVAL_CREATE, target_type=payload.domain, @@ -77,9 +78,7 @@ class ApprovalService: ticket.status = ApprovalStatus.APPROVED if approved else ApprovalStatus.REJECTED ticket.approver = approver ticket.decision_comment = comment - from datetime import datetime - - ticket.decided_at = datetime.utcnow() + ticket.decided_at = utc_now() self.db.commit() self.db.refresh(ticket) self.audit.log( @@ -108,7 +107,9 @@ class ApprovalService: return False if ticket.domain != domain: 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 ticket.action in { action, diff --git a/app/modules/audit/constants.py b/app/modules/audit/constants.py index 4a1799a..c9a8e96 100644 --- a/app/modules/audit/constants.py +++ b/app/modules/audit/constants.py @@ -27,12 +27,14 @@ class AuditSource(StrEnum): FEISHU = "feishu" APPROVAL = "approval" LEGACY_MYSQL = "legacy_mysql" + REPORTS = "reports" class AuditTargetType(StrEnum): AI = "ai" OPENCLAW_TOOL = "openclaw_tool" RISK_EVENTS = "risk-events" + WORK_REPORTS = "work-reports" class AuditStatus(StrEnum): diff --git a/app/modules/audit/models.py b/app/modules/audit/models.py index 15151ef..8e171ef 100644 --- a/app/modules/audit/models.py +++ b/app/modules/audit/models.py @@ -5,6 +5,7 @@ from sqlalchemy.orm import Mapped, mapped_column from app.core.constants import ActorValue from app.core.database import Base +from app.core.time import utc_now 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) response_payload: Mapped[str | None] = mapped_column(Text, nullable=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) diff --git a/app/modules/business/constants.py b/app/modules/business/constants.py index 78b10ca..5fda7b5 100644 --- a/app/modules/business/constants.py +++ b/app/modules/business/constants.py @@ -115,3 +115,12 @@ ATTENDANCE_ABNORMAL_STATUSES = frozenset( ) SUPPLIER_RISK_LEVELS = frozenset({RiskLevel.MEDIUM, RiskLevel.HIGH}) 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, + } +) diff --git a/app/modules/business/models.py b/app/modules/business/models.py index a2aefd7..d5788d7 100644 --- a/app/modules/business/models.py +++ b/app/modules/business/models.py @@ -6,6 +6,7 @@ from sqlalchemy.orm import Mapped, mapped_column from app.core.constants import ActorValue from app.core.database import Base +from app.core.time import utc_now from app.modules.business.constants import ( AccountType, PriorityValue, @@ -17,9 +18,9 @@ from app.modules.business.constants import ( 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( - 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) 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) - 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) description: 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) 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) - 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) created_count: Mapped[int] = mapped_column(Integer, default=0) updated_count: Mapped[int] = mapped_column(Integer, default=0) diff --git a/app/modules/business/routes.py b/app/modules/business/routes.py index f1f06d3..76683b6 100644 --- a/app/modules/business/routes.py +++ b/app/modules/business/routes.py @@ -2,7 +2,7 @@ from fastapi import APIRouter, Depends, HTTPException from sqlalchemy.orm import Session 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.schemas import DomainListRead, DomainRecordCreate, DomainRecordUpdate from app.modules.business.service import BusinessService @@ -43,9 +43,15 @@ def create_record( domain: str, payload: DomainRecordCreate, db: Session = Depends(get_db), + principal: ApiPrincipal = Depends(require_api_key), ) -> dict: 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: raise HTTPException(status_code=404, detail=str(exc)) from exc return {"domain": domain, "data": data} @@ -57,13 +63,14 @@ def update_record( record_id: int, payload: DomainRecordUpdate, db: Session = Depends(get_db), + principal: ApiPrincipal = Depends(require_api_key), ) -> dict: try: data = BusinessService(db).update_record( domain, record_id, payload.data, - actor=payload.actor, + actor=principal.actor, approval_ticket_id=payload.approval_ticket_id, ) except KeyError as exc: diff --git a/app/modules/business/schemas.py b/app/modules/business/schemas.py index ae64161..60e81f2 100644 --- a/app/modules/business/schemas.py +++ b/app/modules/business/schemas.py @@ -8,6 +8,10 @@ from app.core.constants import ActorValue class DomainRecordCreate(BaseModel): data: dict[str, Any] = Field(..., description="Domain fields to create.") 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): diff --git a/app/modules/business/service.py b/app/modules/business/service.py index d9de071..395f343 100644 --- a/app/modules/business/service.py +++ b/app/modules/business/service.py @@ -102,7 +102,10 @@ class BusinessService: domain: str, data: dict[str, Any], 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) @@ -117,7 +120,10 @@ class BusinessService: action=f"create:{domain}", target_type=domain, 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, ) ) @@ -132,21 +138,7 @@ class BusinessService: approval_ticket_id: str | None = None, ) -> dict[str, Any]: if domain in HIGH_RISK_DOMAINS: - if not approval_ticket_id: - 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", - ) + 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: @@ -174,3 +166,26 @@ class BusinessService: ) ) 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", + ) diff --git a/app/modules/feishu/routes.py b/app/modules/feishu/routes.py index 71b11d8..820308c 100644 --- a/app/modules/feishu/routes.py +++ b/app/modules/feishu/routes.py @@ -2,7 +2,7 @@ from fastapi import APIRouter, Depends, Request from sqlalchemy.orm import Session 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.events import FeishuEventService 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)]) -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( payload.text, receive_id=payload.receive_id, receive_id_type=payload.receive_id_type, + actor=principal.actor, ) return {"ok": result.get("code") == 0, "provider_response": result} @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( payload.card, receive_id=payload.receive_id, receive_id_type=payload.receive_id_type, + actor=principal.actor, ) 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, 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.""" return FeishuCommandService(db).handle_text( payload.text, chat_id=payload.chat_id, - actor=payload.actor, + actor=principal.actor, auto_reply=payload.auto_reply, ) diff --git a/app/modules/feishu/service.py b/app/modules/feishu/service.py index 76630e2..eeedfee 100644 --- a/app/modules/feishu/service.py +++ b/app/modules/feishu/service.py @@ -25,7 +25,12 @@ class FeishuService: expected = settings.feishu_verification_token header = payload.get("header") or {} 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( status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid Feishu token", diff --git a/app/modules/legacy_mysql/routes.py b/app/modules/legacy_mysql/routes.py index 56da8fc..59d5454 100644 --- a/app/modules/legacy_mysql/routes.py +++ b/app/modules/legacy_mysql/routes.py @@ -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 require_api_key +from app.core.security import ApiPrincipal, require_api_key from app.modules.legacy_mysql.schemas import ( LegacyProjectSyncRequest, 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) -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( source_query=payload.source_query, field_map=payload.field_map, limit=payload.limit, dry_run=payload.dry_run, - actor=payload.actor, + actor=principal.actor, ) diff --git a/app/modules/legacy_mysql/service.py b/app/modules/legacy_mysql/service.py index 000ef65..38e58c7 100644 --- a/app/modules/legacy_mysql/service.py +++ b/app/modules/legacy_mysql/service.py @@ -12,6 +12,7 @@ from sqlalchemy.orm import Session from app.core.constants import ActorValue from app.core.config import get_settings 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.schemas import AuditLogCreate from app.modules.audit.service import AuditService @@ -151,7 +152,11 @@ class LegacyMySQLService: settings = get_settings() external_id = self._value(row, field_map, "external_id", row.get("id")) 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 { "code": code, "external_id": str(external_id) if external_id is not None else code, @@ -260,11 +265,11 @@ class LegacyMySQLService: "items": items, } 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, source_table="LEGACY_PROJECT_QUERY", status=StatusValue.DRY_RUN if dry_run else AuditStatus.SUCCESS, - finished_at=datetime.utcnow(), + finished_at=utc_now(), created_count=created, updated_count=updated, skipped_count=skipped, diff --git a/app/modules/reports/constants.py b/app/modules/reports/constants.py index ee0a1ad..f3346a5 100644 --- a/app/modules/reports/constants.py +++ b/app/modules/reports/constants.py @@ -101,6 +101,8 @@ class MetricKey(StrEnum): OVER_BUDGET_PROJECTS = "over_budget_projects" OPEN_EVENTS = "open_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_LEVEL = "events_by_level" SCORE = "score" diff --git a/app/modules/reports/routes.py b/app/modules/reports/routes.py index cf25e44..c84ae1b 100644 --- a/app/modules/reports/routes.py +++ b/app/modules/reports/routes.py @@ -3,9 +3,8 @@ from datetime import date from fastapi import APIRouter, Depends from sqlalchemy.orm import Session -from app.core.constants import ActorValue 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 ( PushReportRequest, ReportResponse, @@ -33,8 +32,8 @@ def project_lifecycle_report( period_start: date | None = None, period_end: date | None = None, include_ai: bool = False, - actor: str = ActorValue.API, db: Session = Depends(get_db), + principal: ApiPrincipal = Depends(require_api_key), ) -> dict: return ReportService(db).project_lifecycle_report( project_code=project_code, @@ -42,7 +41,7 @@ def project_lifecycle_report( period_start=period_start, period_end=period_end, include_ai=include_ai, - actor=actor, + actor=principal.actor, ) @@ -58,6 +57,7 @@ def attendance_summary( def generate_work_report( payload: WorkReportGenerateRequest, db: Session = Depends(get_db), + principal: ApiPrincipal = Depends(require_api_key), ) -> dict: return ReportService(db).generate_work_report( report_type=payload.report_type, @@ -67,27 +67,35 @@ def generate_work_report( period_start=payload.period_start, period_end=payload.period_end, persist=payload.persist, - actor=payload.actor, + actor=principal.actor, ) @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() return ReportService(db).push_report( report, payload.receive_id, payload.receive_id_type, - payload.actor, + principal.actor, ) @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() return ReportService(db).push_report( report, payload.receive_id, payload.receive_id_type, - payload.actor, + principal.actor, ) diff --git a/app/modules/reports/service.py b/app/modules/reports/service.py index cbf3144..c5686f7 100644 --- a/app/modules/reports/service.py +++ b/app/modules/reports/service.py @@ -6,11 +6,14 @@ from sqlalchemy import func, or_, select from sqlalchemy.orm import Session 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.service import AuditService from app.modules.business.constants import ( ATTENDANCE_ABNORMAL_STATUSES, DONE_STATUSES, + GENERATED_RISK_EVENT_TYPES, PROJECT_CLOSED_STATUSES, PENDING_APPROVAL_STATUSES, SUPPLIER_RISK_LEVELS, @@ -69,7 +72,7 @@ def _json_safe(value: Any) -> Any: def _next_code(prefix: str) -> str: """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: @@ -228,7 +231,14 @@ class ReportService: task_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) recommendations = self._lifecycle_recommendations( project_stats, @@ -238,6 +248,7 @@ class ReportService: fund_stats, supplier_stats, risk_stats, + include_global_risk, ) metrics = { @@ -533,12 +544,25 @@ class ReportService: RiskEvent.risk_level == RiskLevel.HIGH, *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 = ( overdue_tasks * 1 + delayed_projects * 3 + over_budget_projects * 4 - + open_events * 2 - + high_events * 3 + + external_open_events * 2 + + external_high_events * 3 ) if risk_score >= 15: level = RiskLevel.HIGH @@ -554,6 +578,8 @@ class ReportService: MetricKey.OVER_BUDGET_PROJECTS: over_budget_projects, MetricKey.OPEN_EVENTS: open_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( RiskEvent, RiskEvent.risk_type, @@ -572,16 +598,18 @@ class ReportService: tasks: dict[str, Any], risks: dict[str, Any], suppliers: dict[str, Any], + include_global_risk: bool, ) -> dict[str, Any]: penalty = ( risks[MetricKey.OVERDUE_TASKS] * 3 + risks[MetricKey.DELAYED_PROJECTS] * 8 + risks[MetricKey.OVER_BUDGET_PROJECTS] * 10 - + risks[MetricKey.HIGH_EVENTS] * 8 - + suppliers[MetricKey.BLACKLISTED] * 10 + + risks[MetricKey.EXTERNAL_HIGH_EVENTS] * 8 + max(0, projects[MetricKey.BUDGET_USAGE_RATE] - 100) * 0.4 + (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))) if score >= 80: level = HealthLevel.HEALTHY @@ -641,6 +669,7 @@ class ReportService: funds: dict[str, Any], suppliers: dict[str, Any], risks: dict[str, Any], + include_global_risk: bool, ) -> list[str]: recommendations: list[str] = [] if risks[MetricKey.DELAYED_PROJECTS]: @@ -654,9 +683,9 @@ class ReportService: or expenses[MetricKey.PENDING_APPROVAL] ): recommendations.append(ReportText.RECOMMEND_APPROVALS) - if funds[MetricKey.RISK_ACCOUNTS]: + if include_global_risk and funds[MetricKey.RISK_ACCOUNTS]: recommendations.append(ReportText.RECOMMEND_FUNDS) - if suppliers[MetricKey.RISKY]: + if include_global_risk and suppliers[MetricKey.RISKY]: recommendations.append(ReportText.RECOMMEND_SUPPLIERS) if not recommendations: recommendations.append(ReportText.RECOMMEND_STABLE) @@ -835,9 +864,9 @@ class ReportService: AuditService(self.db).log( AuditLogCreate( actor=actor, - source="reports", + source=AuditSource.REPORTS, action=f"generate_{report_type}_report", - target_type="work-reports", + target_type=AuditTargetType.WORK_REPORTS, target_id=str(record.id), response_payload=record_data, ) @@ -871,17 +900,31 @@ class ReportService: WorkTask.due_date >= start, WorkTask.due_date <= end, ] - procurement_filters = [Procurement.approval_status.in_(PENDING_APPROVAL_STATUSES)] - expense_filters = [Expense.approval_status.in_(PENDING_APPROVAL_STATUSES)] + 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, AttendanceRecord.work_date <= end, ] if project_code: + project_filters.append(Project.code == project_code) task_filters.append(WorkTask.project_code == project_code) procurement_filters.append(Procurement.project_code == project_code) expense_filters.append(Expense.project_code == project_code) attendance_filters.append(AttendanceRecord.project_code == project_code) + risk_filters.append(RiskEvent.project_code == project_code) if department: expense_filters.append(Expense.department == department) attendance_filters.append(AttendanceRecord.department == department) @@ -894,10 +937,11 @@ class ReportService: *task_filters, ) return { - "projects_total": self._count(Project), + "projects_total": self._count(Project, *project_filters), "active_projects": self._count( Project, Project.status.notin_(PROJECT_CLOSED_STATUSES), + *project_filters, ), "tasks_total": self._count(WorkTask, *task_filters), "tasks_completed": completed_tasks, @@ -905,10 +949,7 @@ class ReportService: "procurements_pending": self._count(Procurement, *procurement_filters), "expenses_pending": self._count(Expense, *expense_filters), "attendance_total": self._count(AttendanceRecord, *attendance_filters), - "open_risk_events": self._count( - RiskEvent, - RiskEvent.status == StatusValue.OPEN, - ), + "open_risk_events": self._count(RiskEvent, *risk_filters), } def _work_report_lines( diff --git a/app/modules/risk/routes.py b/app/modules/risk/routes.py index 803e587..ed45c4f 100644 --- a/app/modules/risk/routes.py +++ b/app/modules/risk/routes.py @@ -1,9 +1,8 @@ from fastapi import APIRouter, Depends from sqlalchemy.orm import Session -from app.core.constants import ActorValue 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 router = APIRouter(dependencies=[Depends(require_api_key)]) @@ -49,5 +48,8 @@ def risk_events( @router.post("/events/generate") -def generate_risk_events(actor: str = ActorValue.API, db: Session = Depends(get_db)) -> dict: - return RiskService(db).generate_events(actor=actor) +def generate_risk_events( + db: Session = Depends(get_db), + principal: ApiPrincipal = Depends(require_api_key), +) -> dict: + return RiskService(db).generate_events(actor=principal.actor) diff --git a/app/modules/risk/service.py b/app/modules/risk/service.py index 7944a11..db96ab9 100644 --- a/app/modules/risk/service.py +++ b/app/modules/risk/service.py @@ -1,4 +1,4 @@ -from datetime import date, datetime +from datetime import date from decimal import Decimal from typing import Any @@ -6,6 +6,7 @@ from sqlalchemy import select from sqlalchemy.orm import Session from app.core.constants import ActorValue +from app.core.time import utc_now from app.modules.audit.constants import ( AuditAction, AuditRiskLevel, @@ -17,6 +18,7 @@ from app.modules.audit.service import AuditService from app.modules.business.constants import ( CLOSED_RISK_STATUSES, DONE_STATUSES, + GENERATED_RISK_EVENT_TYPES, PROJECT_CLOSED_STATUSES, SUPPLIER_RISK_LEVELS, BusinessDomain, @@ -90,13 +92,18 @@ class RiskService: fund_risks = self.fund_risks() supplier_risks = self.supplier_risks() 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 = ( len(overdue_tasks) * 1 + len(delayed_projects) * 3 + len(over_budget_projects) * 4 + len(fund_risks) * 5 + len(supplier_risks) * 3 - + len(open_events) * 2 + + len(external_open_events) * 2 ) if risk_score >= 15: level = RiskLevel.HIGH @@ -192,7 +199,7 @@ class RiskService: "project_code": task.project_code, "owner": task.owner, "due_date": task.due_date, - "detected_at": datetime.utcnow(), + "detected_at": utc_now(), "description": "任务已超过截止日期且未完成。", "mitigation": ( "请负责人更新进度、明确阻塞项并给出新的完成时间。" @@ -223,7 +230,7 @@ class RiskService: "project_code": project.code, "owner": project.owner, "due_date": project.due_date, - "detected_at": datetime.utcnow(), + "detected_at": utc_now(), "description": "项目已超过计划截止日期且未进入完成状态。", "mitigation": ( "请项目负责人提交延期原因、资源需求和纠偏计划。" @@ -252,7 +259,7 @@ class RiskService: "project_code": project.code, "owner": project.owner, "due_date": project.due_date, - "detected_at": datetime.utcnow(), + "detected_at": utc_now(), "description": "项目实际成本已超过预算。", "mitigation": ( "请复核预算科目、冻结非必要采购并补充审批依据。" @@ -276,7 +283,7 @@ class RiskService: "source_domain": BusinessDomain.FUND_ACCOUNTS, "source_record_id": str(account.id), "owner": None, - "detected_at": datetime.utcnow(), + "detected_at": utc_now(), "description": "账户当前余额低于设置的安全线。", "mitigation": ( "请财务确认收付款计划," @@ -309,7 +316,7 @@ class RiskService: "source_domain": BusinessDomain.SUPPLIERS, "source_record_id": str(supplier.id), "owner": supplier.contact, - "detected_at": datetime.utcnow(), + "detected_at": utc_now(), "description": "供应商风险等级或黑名单状态需要关注。", "mitigation": ( "请采购负责人复核供应商准入、履约和替代方案。" diff --git a/environment.yml b/environment.yml index 3cd6477..303f7a4 100644 --- a/environment.yml +++ b/environment.yml @@ -12,6 +12,7 @@ dependencies: - psycopg[binary]==3.2.3 - pydantic-settings==2.7.1 - python-dotenv==1.0.1 + - alembic==1.14.0 - httpx==0.28.1 - lark-oapi==1.6.8 - apscheduler==3.10.4 diff --git a/tests/test_ai_adapters.py b/tests/test_ai_adapters.py index cee345c..f2c7495 100644 --- a/tests/test_ai_adapters.py +++ b/tests/test_ai_adapters.py @@ -1,5 +1,8 @@ from typing import Any +import pytest +from fastapi import HTTPException + from app.core.config import Settings from app.modules.ai_agent import adapters 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( openclaw_http_url="http://openclaw.local", openclaw_gateway_token="gateway-token", + openclaw_allowed_tools=["sessions_list"], ) health = adapters.OpenClawAdapter(settings).health() @@ -158,6 +162,7 @@ def test_openclaw_hermes_adapter_runs_recall_answer_and_remember(monkeypatch) -> model_provider="openclaw_hermes", openclaw_http_url="http://openclaw.local", openclaw_gateway_token="openclaw-key", + openclaw_allowed_tools=["sessions_list"], hermes_base_url="http://hermes.local/v1", 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][ 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 == [] diff --git a/tests/test_smoke.py b/tests/test_smoke.py index 1d3f9af..3f98fa3 100644 --- a/tests/test_smoke.py +++ b/tests/test_smoke.py @@ -4,6 +4,9 @@ import tempfile from datetime import date, timedelta from pathlib import Path +import pytest +from fastapi import HTTPException + from app.modules.ai_agent.constants import AIProviderName, AIResponseKey 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["FEISHU_APP_ID"] = "" os.environ["FEISHU_APP_SECRET"] = "" +os.environ["FEISHU_VERIFICATION_TOKEN"] = "test-feishu-token" os.environ["MODEL_PROVIDER"] = AIProviderName.NOOP os.environ["SCHEDULER_ENABLED"] = "false" from fastapi.testclient import TestClient +from app.core.config import get_settings from app.core.database import Base, engine +from app.core.security import require_api_key from app.main import app +from app.modules.legacy_mysql.service import LegacyMySQLService from app.modules.reports.constants import ( LifecycleAttentionKey, LifecycleResponseKey, @@ -78,7 +85,7 @@ def test_project_report_and_feishu_command_preview() -> None: def test_feishu_webhook_routes_message_event() -> None: payload = { "schema": "2.0", - "header": {"event_type": "im.message.receive_v1"}, + "header": {"event_type": "im.message.receive_v1", "token": "test-feishu-token"}, "event": { "sender": {"sender_id": {"open_id": "ou_test"}}, "message": { @@ -95,12 +102,70 @@ def test_feishu_webhook_routes_message_event() -> None: 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: + 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( "/api/v1/business/fund-accounts", headers=headers, json={ - "actor": "pytest", + "actor": "spoofed-user", + "approval_ticket_id": create_ticket_id, "data": { "code": "FUND-SMOKE-001", "name": "Main Account", @@ -115,7 +180,7 @@ def test_approval_gate_for_high_risk_update() -> None: blocked_response = client.patch( f"/api/v1/business/fund-accounts/{record_id}", headers=headers, - json={"actor": "pytest", "data": {"current_balance": 100}}, + json={"actor": "spoofed-user", "data": {"current_balance": 100}}, ) assert blocked_response.status_code == 409 @@ -126,7 +191,7 @@ def test_approval_gate_for_high_risk_update() -> None: "domain": "fund-accounts", "record_id": str(record_id), "action": "update:fund-accounts", - "applicant": "pytest", + "applicant": "spoofed-user", "reason": "Smoke test balance adjustment", "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}", headers=headers, json={ - "actor": "pytest", + "actor": "spoofed-user", "approval_ticket_id": ticket_id, "data": {"current_balance": 100}, }, @@ -148,16 +213,17 @@ def test_approval_gate_for_high_risk_update() -> None: approve_response = client.post( f"/api/v1/approvals/{ticket_id}/approve", headers=headers, - json={"approver": "manager", "comment": "ok"}, + json={"approver": "spoofed-manager", "comment": "ok"}, ) assert approve_response.status_code == 200 assert approve_response.json()["status"] == "approved" + assert approve_response.json()["approver"] == "api" update_response = client.patch( f"/api/v1/business/fund-accounts/{record_id}", headers=headers, json={ - "actor": "pytest", + "actor": "spoofed-user", "approval_ticket_id": ticket_id, "data": {"current_balance": 100}, }, @@ -383,3 +449,10 @@ def test_ai_noop_provider() -> None: ) assert response.status_code == 200 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