163 lines
6.5 KiB
Python
163 lines
6.5 KiB
Python
"""知识引用解析服务(05-接口文档 K001)。
|
||
|
||
token 形态:``kr1.{user_id}.{knowledge_id}.{expires_at_epoch}.{signature}``。
|
||
|
||
- 签名:HMAC-SHA256(hex)覆盖 token 前四段,防止伪造与越权拼接;
|
||
- 密钥:只从环境变量 ``KNOWLEDGE_REFERENCE_SIGNING_SECRET`` 读取,缺失时失败关闭,
|
||
不提供默认值、不硬编码;
|
||
- 解析:按 knowledge_id 只读回查 ``fin_knowledge_meta``,仍要求
|
||
``review_status='published' AND status='active'`` 且在有效期内;
|
||
- 返回:脱敏元数据(标题/版本/类型/集合/标签/有效期),**绝不返回 ``content_text`` 全文**,
|
||
避免绕过工具审计直读知识正文。
|
||
"""
|
||
|
||
import hashlib
|
||
import hmac
|
||
import json
|
||
import os
|
||
from collections.abc import Callable, Mapping
|
||
from datetime import UTC, date, datetime
|
||
from typing import Any
|
||
|
||
from sqlalchemy import text
|
||
|
||
from app.core.contracts import RequestContext
|
||
from app.core.errors import RecoverableAgentError, ReferenceNotFoundError
|
||
from app.infrastructure.db import SessionFactory
|
||
from app.service.authorization_service import AuthorizationService
|
||
|
||
REFERENCE_TOKEN_PREFIX = "kr1"
|
||
REFERENCE_TOKEN_PARTS = 5
|
||
SIGNING_SECRET_ENV = "KNOWLEDGE_REFERENCE_SIGNING_SECRET"
|
||
PUBLISHED_REVIEW_STATUS = "published"
|
||
ACTIVE_STATUS = "active"
|
||
|
||
_REFERENCE_SQL = text(
|
||
"""
|
||
SELECT id, knowledge_type, title, version, milvus_collection, tags,
|
||
effective_date, expire_date, review_status, status
|
||
FROM fin_knowledge_meta
|
||
WHERE id = :knowledge_id
|
||
"""
|
||
)
|
||
|
||
|
||
def signing_secret() -> str:
|
||
"""签名密钥只来自环境变量;缺失即失败关闭(可识别的配置错误)。"""
|
||
secret = os.environ.get(SIGNING_SECRET_ENV, "").strip()
|
||
if not secret:
|
||
raise RecoverableAgentError(f"知识引用签名密钥未配置:{SIGNING_SECRET_ENV}")
|
||
return secret
|
||
|
||
|
||
def _signature(secret: str, head: str) -> str:
|
||
return hmac.new(secret.encode("utf-8"), head.encode("utf-8"), hashlib.sha256).hexdigest()
|
||
|
||
|
||
def build_reference_token(
|
||
*, user_id: str, knowledge_id: int, expires_at: datetime, secret: str | None = None
|
||
) -> str:
|
||
"""签发侧入口:与解析侧共用同一算法,供引用落库时同步签发 token。
|
||
|
||
``expires_at`` 必须带时区;无时区视为 UTC 以保持与解析侧一致。
|
||
"""
|
||
resolved = secret if secret is not None else signing_secret()
|
||
moment = expires_at if expires_at.tzinfo is not None else expires_at.replace(tzinfo=UTC)
|
||
head = f"{REFERENCE_TOKEN_PREFIX}.{user_id}.{knowledge_id}.{int(moment.timestamp())}"
|
||
return f"{head}.{_signature(resolved, head)}"
|
||
|
||
|
||
def _not_found() -> ReferenceNotFoundError:
|
||
"""所有解析失败对外表现一致,不泄露失败原因。"""
|
||
return ReferenceNotFoundError("引用不存在")
|
||
|
||
|
||
def _as_date(value: object) -> date | None:
|
||
return value if isinstance(value, date) else None
|
||
|
||
|
||
def _as_iso(value: date | None) -> str | None:
|
||
return value.isoformat() if value is not None else None
|
||
|
||
|
||
def _as_tags(value: object) -> list[str]:
|
||
if isinstance(value, str):
|
||
try:
|
||
value = json.loads(value)
|
||
except ValueError:
|
||
return []
|
||
if isinstance(value, list | tuple):
|
||
return [str(item) for item in value]
|
||
return []
|
||
|
||
|
||
class KnowledgeReferenceService:
|
||
"""只读解析知识引用 token,返回脱敏元数据。"""
|
||
|
||
def __init__(self, *, session_factory: Callable[[], Any] | None = None) -> None:
|
||
self._session_factory: Callable[[], Any] = session_factory or SessionFactory
|
||
|
||
async def resolve(self, context: RequestContext, token: str) -> dict[str, Any]:
|
||
await AuthorizationService.require(context, "knowledge:reference:read")
|
||
knowledge_id, expires_at = self._verify_token(context, token)
|
||
metadata = await self._load_metadata(knowledge_id)
|
||
return self._redact(knowledge_id, expires_at, self._assert_resolvable(metadata))
|
||
|
||
def _verify_token(self, context: RequestContext, token: str) -> tuple[int, datetime]:
|
||
secret = signing_secret()
|
||
pieces = token.split(".")
|
||
if len(pieces) != REFERENCE_TOKEN_PARTS or pieces[0] != REFERENCE_TOKEN_PREFIX:
|
||
raise _not_found()
|
||
_, token_user, knowledge_raw, expires_raw, signature = pieces
|
||
if not hmac.compare_digest(_signature(secret, ".".join(pieces[:4])), signature):
|
||
raise _not_found()
|
||
if token_user != context.user_id:
|
||
raise _not_found()
|
||
if not knowledge_raw.isdecimal() or not expires_raw.isdecimal():
|
||
raise _not_found()
|
||
expires_at = datetime.fromtimestamp(int(expires_raw), tz=UTC)
|
||
if expires_at <= datetime.now(UTC):
|
||
raise _not_found()
|
||
return int(knowledge_raw), expires_at
|
||
|
||
async def _load_metadata(self, knowledge_id: int) -> Mapping[str, Any] | None:
|
||
async with self._session_factory() as session:
|
||
result = await session.execute(_REFERENCE_SQL, {"knowledge_id": knowledge_id})
|
||
return result.mappings().first() # type: ignore[no-any-return]
|
||
|
||
@staticmethod
|
||
def _assert_resolvable(metadata: Mapping[str, Any] | None) -> Mapping[str, Any]:
|
||
"""校验发布状态与有效期;不满足一律 404,且不泄露失败原因。"""
|
||
if metadata is None:
|
||
raise _not_found()
|
||
if str(metadata["review_status"]) != PUBLISHED_REVIEW_STATUS:
|
||
raise _not_found()
|
||
if str(metadata["status"]) != ACTIVE_STATUS:
|
||
raise _not_found()
|
||
today = datetime.now(UTC).date()
|
||
effective_date = _as_date(metadata["effective_date"])
|
||
if effective_date is not None and effective_date > today:
|
||
raise _not_found()
|
||
expire_date = _as_date(metadata["expire_date"])
|
||
if expire_date is not None and expire_date <= today:
|
||
raise _not_found()
|
||
return metadata
|
||
|
||
@staticmethod
|
||
def _redact(
|
||
knowledge_id: int, expires_at: datetime, metadata: Mapping[str, Any]
|
||
) -> dict[str, Any]:
|
||
version = metadata["version"]
|
||
return {
|
||
"knowledge_id": knowledge_id,
|
||
"title": str(metadata["title"]),
|
||
"knowledge_type": str(metadata["knowledge_type"]),
|
||
"version": version if isinstance(version, str) else None,
|
||
"collection": str(metadata["milvus_collection"]),
|
||
"tags": _as_tags(metadata["tags"]),
|
||
"effective_date": _as_iso(_as_date(metadata["effective_date"])),
|
||
"expire_date": _as_iso(_as_date(metadata["expire_date"])),
|
||
"reference_expires_at": expires_at.isoformat(),
|
||
"content_included": False,
|
||
}
|