aps-agent/server/agent_core/approval_migrate.py

448 lines
20 KiB
Python
Raw Normal View History

# ============================================================
# 审批文件后端 → database 后端存量迁移(moduleId: approval-migrate, 可重生 ✅)
# 矩阵 117 行剩余项:文件→DB 存量迁移可靠性(round-67 R67-B1)。
# 安全不变量:
# 1. grant 只持久化 token_digest(raw_token),绝不落 raw token;
# 2. 文件缺失维持空源语义;文件存在但 JSON/schema/记录损坏一律 fail closed;
# 3. 全部写入在单个 SQLAlchemy Session 事务内 flush/commit,后段异常整体回滚;
# 4. 幂等按「不可变身份字段冲突即拒绝 + 单调状态只前进不回退」判定;
# 5. 迁移要求 APS_APPROVAL_MIGRATION_QUIESCED=1,并持有与文件后端相同的
# `{source}.lock` 进程锁直到事务提交;flush 后复核源文件 SHA-256,
# 漂移即回滚。
# dry_run 只预检与报告,不写库。
# ============================================================
from __future__ import annotations
import copy
import hashlib
import json
import math
import os
import time
from collections.abc import Iterator
from contextlib import contextmanager
from typing import Any
from sqlalchemy import select
from sqlalchemy.exc import SQLAlchemyError
from server.agent_core.approval_db_store import DatabaseApprovalStore
from server.agent_core.approval_store import (
_SCHEMA_VERSION,
default_approval_path,
token_digest,
)
from server.db.database import get_session
from server.db.models import (
ApprovalEventRecord,
ApprovalGrantRecord,
ApprovalRequestRecord,
)
_QUIESCED_ENV = "APS_APPROVAL_MIGRATION_QUIESCED"
_HEX_DIGEST_LEN = 64
_REQUEST_IDENTITY_FIELDS = ("action", "power", "paramsHash", "tenantUuid", "projectId", "ownerUserId")
_GRANT_IDENTITY_FIELDS = ("confirmId", "action", "paramsHash", "tenantUuid", "projectId", "ownerUserId")
_GRANT_STATUS_RANK = {"ACTIVE": 0, "CONSUMED": 1, "EXPIRED": 1}
class MigrationError(RuntimeError):
"""迁移 fail-closed:源损坏、身份冲突、状态回退、未停写或源漂移。"""
def _optional_int(value: Any) -> int | None:
try:
parsed = int(value)
except (TypeError, ValueError):
return None
return parsed or None
def _optional_float(value: Any) -> float | None:
try:
return float(value)
except (TypeError, ValueError):
return None
def _optional_str(value: Any) -> str | None:
return str(value) if value is not None else None
def _sha256_path(path: str) -> str:
digest = hashlib.sha256()
with open(path, "rb") as handle:
for chunk in iter(lambda: handle.read(65536), b""):
digest.update(chunk)
return digest.hexdigest()
@contextmanager
def _source_lock(path: str) -> Iterator[None]:
"""与文件后端 ApprovalStore 相同的 `{path}.lock` 跨进程锁(持有到事务提交)。"""
lock_path = f"{path}.lock"
parent = os.path.dirname(lock_path) or "."
os.makedirs(parent, exist_ok=True)
with open(lock_path, "a+b") as handle:
handle.seek(0, os.SEEK_END)
if handle.tell() == 0:
handle.write(b"\0")
handle.flush()
handle.seek(0)
if os.name == "nt":
import msvcrt
msvcrt.locking(handle.fileno(), msvcrt.LK_LOCK, 1)
try:
yield
finally:
handle.seek(0)
msvcrt.locking(handle.fileno(), msvcrt.LK_UNLCK, 1)
else:
import fcntl
fcntl.flock(handle.fileno(), fcntl.LOCK_EX)
try:
yield
finally:
fcntl.flock(handle.fileno(), fcntl.LOCK_UN)
def _canonical_event_digest(event: dict[str, Any]) -> str:
"""完整事件 canonical digest:同秒同状态但 approver/note/payload 不同的事件必须区分。"""
blob = json.dumps(event, sort_keys=True, ensure_ascii=False, separators=(",", ":"), default=str)
return hashlib.sha256(blob.encode("utf-8")).hexdigest()
def _validate_store_record(kind: str, key: Any, record: Any) -> None:
if not isinstance(key, str) or not isinstance(record, dict):
raise MigrationError(f"审批源损坏:{kind} 记录键/值类型非法(key={key!r})")
try:
expires_at = float(record.get("expiresAtEpoch"))
except (TypeError, ValueError):
raise MigrationError(f"审批源损坏:{kind} 记录 {key} 缺少合法 expiresAtEpoch") from None
if not math.isfinite(expires_at) or expires_at <= 0:
raise MigrationError(f"审批源损坏:{kind} 记录 {key} expiresAtEpoch 非法")
if not isinstance(record.get("action"), str) or not record.get("action"):
raise MigrationError(f"审批源损坏:{kind} 记录 {key} 缺少 action")
if not isinstance(record.get("tenantUuid"), str):
raise MigrationError(f"审批源损坏:{kind} 记录 {key} 缺少 tenantUuid")
def _validate_history(events: Any) -> None:
if not isinstance(events, list):
raise MigrationError("审批源损坏:history 不是数组")
for index, event in enumerate(events):
if not isinstance(event, dict):
raise MigrationError(f"审批源损坏:history[{index}] 不是对象")
if not isinstance(event.get("action"), str) or not isinstance(event.get("status"), str):
raise MigrationError(f"审批源损坏:history[{index}] 缺少 action/status")
if _optional_float(event.get("decidedAtEpoch")) is None:
raise MigrationError(f"审批源损坏:history[{index}] 缺少合法 decidedAtEpoch")
def load_file_store(file_path: str | None = None) -> dict[str, Any]:
"""读取并校验文件后端审批仓。
文件不存在 → 空源语义(不抛错);文件存在但 JSON/schema/记录损坏 → fail closed。
"""
path = file_path or default_approval_path()
try:
with open(path, "r", encoding="utf-8") as handle:
raw = json.load(handle)
except FileNotFoundError:
return {"schemaVersion": _SCHEMA_VERSION, "pending": {}, "grants": {}, "history": []}
except (json.JSONDecodeError, OSError, UnicodeDecodeError) as exc:
raise MigrationError(f"审批源损坏:{path} 无法解析({type(exc).__name__}),已拒绝迁移") from exc
if not isinstance(raw, dict) or raw.get("schemaVersion") != _SCHEMA_VERSION:
raise MigrationError(f"审批源损坏:{path} 顶层 schema 非法,已拒绝迁移")
pending, grants, history = raw.get("pending"), raw.get("grants"), raw.get("history")
if not isinstance(pending, dict) or not isinstance(grants, dict):
raise MigrationError(f"审批源损坏:{path} pending/grants 结构非法,已拒绝迁移")
for confirm_id, record in pending.items():
_validate_store_record("pending", confirm_id, record)
for key, record in grants.items():
_validate_store_record("grants", key, record)
_validate_history(history)
return {"schemaVersion": _SCHEMA_VERSION, "pending": pending, "grants": grants, "history": history}
def _identity_of(record: dict[str, Any], fields: tuple[str, ...]) -> tuple[Any, ...]:
normalized: list[Any] = []
for field in fields:
value = record.get(field)
normalized.append(_optional_int(value) if field == "ownerUserId" else str(value or ""))
return tuple(normalized)
def _request_identity_matches(row: ApprovalRequestRecord, record: dict[str, Any]) -> bool:
db_identity = (
row.action,
row.power,
row.params_hash,
row.tenant_uuid,
row.project_id,
row.owner_user_id,
)
return db_identity == _identity_of(record, _REQUEST_IDENTITY_FIELDS)
def _grant_identity_matches(row: ApprovalGrantRecord, record: dict[str, Any], confirm_id: str) -> bool:
db_identity = (
row.confirm_id,
row.action,
row.params_hash,
row.tenant_uuid,
row.project_id,
row.owner_user_id,
)
expected = list(_identity_of(record, _GRANT_IDENTITY_FIELDS))
expected[0] = confirm_id
return db_identity == tuple(expected)
def _grant_row(digest: str, confirm_id: str, record: dict[str, Any], now_epoch: float) -> ApprovalGrantRecord:
return ApprovalGrantRecord(
token_hash=digest,
confirm_id=confirm_id,
tenant_uuid=str(record.get("tenantUuid") or "platform"),
project_id=str(record.get("projectId") or "default"),
owner_user_id=_optional_int(record.get("ownerUserId")),
action=str(record.get("action") or ""),
params_hash=str(record.get("paramsHash") or ""),
status=str(record.get("status") or "ACTIVE"),
created_at_epoch=float(record.get("createdAtEpoch") or now_epoch),
expires_at_epoch=float(record.get("expiresAtEpoch") or now_epoch),
consumed_at_epoch=_optional_float(record.get("consumedAtEpoch")),
revision=int(record.get("revision") or 0),
payload=copy.deepcopy(record),
)
def _terminal_request_row(confirm_id: str, grant: dict[str, Any], now_epoch: float) -> ApprovalRequestRecord:
"""grant 缺 pending request 时,依据 grant 重建终态 APPROVED request 以满足外键。"""
approvals = list(grant.get("approvals") or [])
power = str(grant.get("power") or "")
payload = copy.deepcopy(grant)
payload["confirmId"] = confirm_id
payload["status"] = "APPROVED"
payload["migratedTerminalRequest"] = True # 诚实标注:迁移重建,非现场决策
return ApprovalRequestRecord(
confirm_id=confirm_id,
tenant_uuid=str(grant.get("tenantUuid") or "platform"),
project_id=str(grant.get("projectId") or "default"),
owner_user_id=_optional_int(grant.get("ownerUserId")),
action=str(grant.get("action") or ""),
power=power,
params_hash=str(grant.get("paramsHash") or ""),
status="APPROVED",
approval_step=len(approvals),
required_approvals=max(len(approvals), 2 if power == "P3" else 1),
created_at_epoch=float(grant.get("createdAtEpoch") or now_epoch),
expires_at_epoch=float(grant.get("expiresAtEpoch") or now_epoch),
revision=0,
payload=payload,
)
def migrate_file_to_db(file_path: str | None = None, *, dry_run: bool = False) -> dict[str, Any]:
"""把文件后端审批存量迁移到 database 后端(幂等、单事务、fail-closed)。
前置:APS_APPROVAL_BACKEND=database、DB schema 就绪、
APS_APPROVAL_MIGRATION_QUIESCED=1(确认文件后端已停写)。
Returns: {"pending": {migrated, skipped, rebuilt}, "grants": {migrated, skipped, repaired},
"events": {migrated, skipped}, "dryRun": bool, "source": str,
"sourceDigest": str}
"""
backend = (os.environ.get("APS_APPROVAL_BACKEND") or "file").strip().lower()
if backend != "database":
raise MigrationError(
"approval migration requires APS_APPROVAL_BACKEND=database(请先配置 database 后端)")
if (os.environ.get(_QUIESCED_ENV) or "").strip() != "1":
raise MigrationError(
f"approval migration requires {_QUIESCED_ENV}=1"
"(请先停止文件后端写入并显式确认停写)")
source = file_path or default_approval_path()
result: dict[str, Any] = {
"pending": {"migrated": 0, "skipped": 0, "rebuilt": 0},
"grants": {"migrated": 0, "skipped": 0, "repaired": 0},
"events": {"migrated": 0, "skipped": 0},
"dryRun": bool(dry_run),
"source": str(source),
}
if not os.path.exists(source):
result["sourceDigest"] = None # 文件缺失维持空源语义
return result
DatabaseApprovalStore() # DB 可用性/schema 门禁
with _source_lock(str(source)):
source_digest = _sha256_path(str(source))
result["sourceDigest"] = source_digest
data = load_file_store(str(source))
now_epoch = time.time()
try:
with get_session() as session:
_migrate_pending(session, data, result, dry_run=dry_run)
_migrate_grants(session, data, result, now_epoch=now_epoch, dry_run=dry_run)
_migrate_history(session, data, result, dry_run=dry_run)
if dry_run:
session.rollback()
return result
session.flush()
if _sha256_path(str(source)) != source_digest:
session.rollback()
raise MigrationError("迁移期间审批源文件发生变化,已整体回滚(请在停写后重试)")
session.commit()
except MigrationError:
raise
except SQLAlchemyError as exc:
raise MigrationError(f"approval database write failed(已整体回滚):{type(exc).__name__}") from exc
return result
def _migrate_pending(
session: Any, data: dict[str, Any], result: dict[str, Any], *, dry_run: bool
) -> None:
"""pending 请求:不可变身份字段冲突即拒绝;DB 端已合法前进到终态则不覆盖。"""
pending = data.get("pending") or {}
for confirm_id, record in pending.items():
existing = session.get(ApprovalRequestRecord, confirm_id)
if existing is not None:
if not _request_identity_matches(existing, record):
raise MigrationError(
f"审批迁移冲突:请求 {confirm_id} 与数据库已有记录的身份字段不一致,已拒绝迁移")
# 已迁移过,或 DB 端已合法前进到终态(APPROVED/REJECTED/EXPIRED):不覆盖、不回退
result["pending"]["skipped"] += 1
continue
result["pending"]["migrated"] += 1
if dry_run:
continue
payload = copy.deepcopy(record)
payload.setdefault("confirmId", confirm_id)
session.add(ApprovalRequestRecord(
confirm_id=confirm_id,
tenant_uuid=str(record.get("tenantUuid") or "platform"),
project_id=str(record.get("projectId") or "default"),
owner_user_id=_optional_int(record.get("ownerUserId")),
action=str(record.get("action") or ""),
power=str(record.get("power") or ""),
params_hash=str(record.get("paramsHash") or ""),
status="PENDING",
approval_step=int(record.get("approvalStep") or 0),
required_approvals=int(record.get("requiredApprovals") or 1),
created_at_epoch=float(record.get("createdAtEpoch") or 0),
expires_at_epoch=float(record.get("expiresAtEpoch") or 0),
revision=0,
payload=payload,
))
def _migrate_grants(
session: Any, data: dict[str, Any], result: dict[str, Any], *, now_epoch: float, dry_run: bool
) -> None:
"""grants:只落 digest;legacy raw-token 行一致时原子修复,冲突 fail-closed。"""
grants = data.get("grants") or {}
pending = data.get("pending") or {}
planned_confirm_ids: set[str] = set()
for raw_key, record in grants.items():
confirm_id = str(record.get("confirmId") or "")
if not confirm_id:
raise MigrationError(f"审批源损坏:grant {raw_key} 缺少 confirmId,已拒绝迁移")
# 守卫:文件键本身已是 DB 已知 digest 时按已迁移处理,禁止再哈希
key_is_digest = len(raw_key) == _HEX_DIGEST_LEN and all(
ch in "0123456789abcdef" for ch in raw_key.lower())
existing_by_key = session.get(ApprovalGrantRecord, raw_key)
if key_is_digest and existing_by_key is not None:
if not _grant_identity_matches(existing_by_key, record, confirm_id):
raise MigrationError(
f"审批迁移冲突:grant {raw_key[:12]}… 与数据库已有记录的身份字段不一致,已拒绝迁移")
result["grants"]["skipped"] += 1
continue
digest = token_digest(raw_key)
existing_digest = session.get(ApprovalGrantRecord, digest)
if existing_digest is not None and existing_by_key is not None:
raise MigrationError(
f"审批迁移冲突:grant {raw_key[:12]}… 在数据库中同时存在 raw/digest 两行,已拒绝迁移")
if existing_digest is not None:
if not _grant_identity_matches(existing_digest, record, confirm_id):
raise MigrationError(
f"审批迁移冲突:grant {raw_key[:12]}… 与数据库已有记录的身份字段不一致,已拒绝迁移")
db_rank = _GRANT_STATUS_RANK.get(existing_digest.status, 0)
src_rank = _GRANT_STATUS_RANK.get(str(record.get("status") or "ACTIVE"), 0)
if db_rank >= src_rank:
result["grants"]["skipped"] += 1 # 合法前进或一致:不覆盖、不回退
continue
result["grants"]["migrated"] += 1 # 源更新:单调前进 DB 状态
if not dry_run:
existing_digest.status = str(record.get("status") or "ACTIVE")
existing_digest.consumed_at_epoch = _optional_float(record.get("consumedAtEpoch"))
existing_digest.revision = int(existing_digest.revision) + 1
merged = copy.deepcopy(existing_digest.payload)
merged.update(copy.deepcopy(record))
existing_digest.payload = merged
continue
if existing_by_key is not None:
# legacy raw-token 主键行:一致 → 事务内原子修复为 digest;不一致 → fail-closed
if not _grant_identity_matches(existing_by_key, record, confirm_id):
raise MigrationError(
f"审批迁移冲突:legacy grant {raw_key[:12]}… 与源记录身份字段不一致,已拒绝迁移")
result["grants"]["repaired"] += 1
if not dry_run:
repaired = _grant_row(digest, confirm_id, record, now_epoch)
repaired.status = existing_by_key.status # 保留 DB 端合法前进后的状态
repaired.consumed_at_epoch = existing_by_key.consumed_at_epoch
repaired.revision = int(existing_by_key.revision)
session.delete(existing_by_key)
session.flush()
session.add(repaired)
continue
result["grants"]["migrated"] += 1
if confirm_id in planned_confirm_ids:
raise MigrationError(
f"审批迁移冲突:confirmId {confirm_id} 对应多个待插入 grant(唯一约束),已拒绝迁移")
planned_confirm_ids.add(confirm_id)
# grant 缺 pending request 且 DB 无该 request → 重建终态 APPROVED request 满足外键
needs_rebuild = (
confirm_id not in pending
and session.get(ApprovalRequestRecord, confirm_id) is None
)
if needs_rebuild:
result["pending"]["rebuilt"] += 1
if dry_run:
continue
if needs_rebuild:
session.add(_terminal_request_row(confirm_id, record, now_epoch))
session.add(_grant_row(digest, confirm_id, record, now_epoch))
def _migrate_history(
session: Any, data: dict[str, Any], result: dict[str, Any], *, dry_run: bool
) -> None:
"""history:完整 canonical event digest 去重;近似但不相同的事件全部保留。"""
events = data.get("history") or []
existing_digests = {
_canonical_event_digest(payload)
for payload in session.execute(select(ApprovalEventRecord.payload)).scalars()
}
batch_digests: set[str] = set()
for event in events:
digest = _canonical_event_digest(event)
if digest in existing_digests or digest in batch_digests:
result["events"]["skipped"] += 1
continue
batch_digests.add(digest)
result["events"]["migrated"] += 1
if dry_run:
continue
session.add(ApprovalEventRecord(
confirm_id=_optional_str(event.get("confirmId")),
tenant_uuid=str(event.get("tenantUuid") or "platform"),
project_id=str(event.get("projectId") or "default"),
owner_user_id=_optional_int(event.get("ownerUserId")),
action=str(event.get("action") or ""),
power=str(event.get("power") or ""),
status=str(event.get("status") or ""),
decided_at_epoch=float(event.get("decidedAtEpoch") or 0),
payload=copy.deepcopy(event),
))