Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3b34ba78b8 |
No files matched your search
File diff suppressed because it is too large.
Load diff
@@ -1,6 +1,7 @@
|
|||||||
# Auto-extracted from chat.py during Phase 1 refactor (Strangler Fig).
|
# Auto-extracted from chat.py during Phase 1 refactor (Strangler Fig).
|
||||||
# Source of truth: scripts/phase1_extract.py. Do not hand-merge back.
|
# Source of truth: scripts/phase1_extract.py. Do not hand-merge back.
|
||||||
|
|
||||||
|
from app.core.credential import decrypt_secret, mask_secret
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query, Response
|
from fastapi import APIRouter, Depends, HTTPException, Query, Response
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
from sqlalchemy import and_, desc, asc, func
|
from sqlalchemy import and_, desc, asc, func
|
||||||
@@ -530,7 +531,7 @@ def _resolve_market_agents(db: Session, agent_ids: list, user_id: str) -> list:
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
"agent_api_key": ag.agent_api_key or None,
|
"agent_api_key": mask_secret(decrypt_secret(ag.agent_api_key)) or None,
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
from app.core.credential import encrypt_secret
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
from sqlalchemy import and_, desc, asc, func
|
from sqlalchemy import and_, desc, asc, func
|
||||||
@@ -557,7 +558,7 @@ def upload_app(
|
|||||||
is_member_free=req.is_member_free if req.is_member_free is not None else False,
|
is_member_free=req.is_member_free if req.is_member_free is not None else False,
|
||||||
join_promotion=req.join_promotion if req.join_promotion is not None else False,
|
join_promotion=req.join_promotion if req.join_promotion is not None else False,
|
||||||
agent_endpoint=req.agent_endpoint,
|
agent_endpoint=req.agent_endpoint,
|
||||||
agent_api_key=req.api_key,
|
agent_api_key=encrypt_secret(req.api_key),
|
||||||
status="published", # 用户创建的应用直接发布
|
status="published", # 用户创建的应用直接发布
|
||||||
created_at=datetime.now(),
|
created_at=datetime.now(),
|
||||||
updated_at=datetime.now(),
|
updated_at=datetime.now(),
|
||||||
|
|||||||
@@ -0,0 +1,105 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""Phase 3.2 - Agent 凭据加解密 (Fernet 对称加密)。
|
||||||
|
|
||||||
|
主密钥获取策略 (防竞态):
|
||||||
|
- 优先环境变量 AGENT_MASTER_KEY。
|
||||||
|
- 否则读写 /app/secrets/agent_master_key:
|
||||||
|
* 部署阶段由 patch 显式生成并落盘 (原子写), 容器重启/提交后稳定存在。
|
||||||
|
* 运行时只读, 文件存在即读取, 绝不重新生成 —— 避免多进程/多请求并发
|
||||||
|
重建密钥导致新旧密文无法互解。
|
||||||
|
* 仅当文件确实不存在时, 才在文件锁保护下生成 (双重检查, 原子替换)。
|
||||||
|
- 存储格式: 密文以 "enc:" 前缀标识; 非 "enc:" 视为旧明文 (向后兼容, 解密原样返回)。
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import logging
|
||||||
|
import fcntl
|
||||||
|
from cryptography.fernet import Fernet
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_MASTER_KEY_FILE = "/app/secrets/agent_master_key"
|
||||||
|
_fernet_instance = None
|
||||||
|
|
||||||
|
|
||||||
|
def _generate_and_persist_key() -> bytes:
|
||||||
|
key = Fernet.generate_key()
|
||||||
|
os.makedirs(os.path.dirname(_MASTER_KEY_FILE), exist_ok=True)
|
||||||
|
tmp = _MASTER_KEY_FILE + ".tmp"
|
||||||
|
with open(tmp, "w") as f:
|
||||||
|
f.write(key.decode())
|
||||||
|
f.flush()
|
||||||
|
os.fsync(f.fileno())
|
||||||
|
try:
|
||||||
|
os.chmod(tmp, 0o600)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
os.replace(tmp, _MASTER_KEY_FILE) # 原子替换, 避免读到半截文件
|
||||||
|
return key
|
||||||
|
|
||||||
|
|
||||||
|
def get_master_key() -> bytes:
|
||||||
|
env_key = os.environ.get("AGENT_MASTER_KEY")
|
||||||
|
if env_key:
|
||||||
|
return env_key.encode() if isinstance(env_key, str) else env_key
|
||||||
|
# 运行时只读: 文件已存在则直接读取 (部署阶段已生成)
|
||||||
|
if os.path.exists(_MASTER_KEY_FILE):
|
||||||
|
try:
|
||||||
|
with open(_MASTER_KEY_FILE, "r") as f:
|
||||||
|
k = f.read().strip()
|
||||||
|
if k:
|
||||||
|
return k.encode()
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("[credential] 读取主密钥失败: %s" % e)
|
||||||
|
# 文件缺失: 加锁串行生成, 防多进程并发重建
|
||||||
|
lockf = open(_MASTER_KEY_FILE + ".lock", "w")
|
||||||
|
try:
|
||||||
|
fcntl.flock(lockf, fcntl.LOCK_EX)
|
||||||
|
if os.path.exists(_MASTER_KEY_FILE): # 双重检查
|
||||||
|
with open(_MASTER_KEY_FILE, "r") as f:
|
||||||
|
k = f.read().strip()
|
||||||
|
if k:
|
||||||
|
return k.encode()
|
||||||
|
return _generate_and_persist_key()
|
||||||
|
finally:
|
||||||
|
fcntl.flock(lockf, fcntl.LOCK_UN)
|
||||||
|
try:
|
||||||
|
lockf.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _fernet() -> Fernet:
|
||||||
|
global _fernet_instance
|
||||||
|
if _fernet_instance is None:
|
||||||
|
_fernet_instance = Fernet(get_master_key())
|
||||||
|
return _fernet_instance
|
||||||
|
|
||||||
|
|
||||||
|
def encrypt_secret(plain):
|
||||||
|
if plain is None or plain == "":
|
||||||
|
return None
|
||||||
|
if isinstance(plain, str) and plain.startswith("enc:"):
|
||||||
|
return plain # 已是密文, 幂等
|
||||||
|
try:
|
||||||
|
return "enc:" + _fernet().encrypt(plain.encode()).decode()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("[credential] 加密失败: %s" % e)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def decrypt_secret(cipher):
|
||||||
|
if cipher is None or cipher == "":
|
||||||
|
return None
|
||||||
|
if isinstance(cipher, str) and cipher.startswith("enc:"):
|
||||||
|
try:
|
||||||
|
return _fernet().decrypt(cipher[4:].encode()).decode()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("[credential] 解密失败(密钥不匹配或数据损坏): %s" % e)
|
||||||
|
return None
|
||||||
|
return cipher # 旧明文, 向后兼容
|
||||||
|
|
||||||
|
|
||||||
|
def mask_secret(plain):
|
||||||
|
if not plain:
|
||||||
|
return ""
|
||||||
|
return ("*" * 8) + plain[-4:]
|
||||||
@@ -5,6 +5,7 @@ AIFlow 数据库模型定义 - SQLAlchemy ORM
|
|||||||
import uuid
|
import uuid
|
||||||
from datetime import datetime, date
|
from datetime import datetime, date
|
||||||
from sqlalchemy import (
|
from sqlalchemy import (
|
||||||
|
Text,
|
||||||
Column, String, Integer, BigInteger, Float, Boolean,
|
Column, String, Integer, BigInteger, Float, Boolean,
|
||||||
DateTime, Date, Text, Enum, ForeignKey, Index, JSON,
|
DateTime, Date, Text, Enum, ForeignKey, Index, JSON,
|
||||||
DECIMAL, UniqueConstraint
|
DECIMAL, UniqueConstraint
|
||||||
@@ -299,7 +300,7 @@ class MarketApp(Base):
|
|||||||
platform = Column(String(20), default=None, comment="Agent 接入平台:dify/n8n/coze")
|
platform = Column(String(20), default=None, comment="Agent 接入平台:dify/n8n/coze")
|
||||||
capability = Column(String(20), default=None, comment="能力分类:text/thinking/vision/tool/image/audio/asr/ocr/embedding/rerank/video")
|
capability = Column(String(20), default=None, comment="能力分类:text/thinking/vision/tool/image/audio/asr/ocr/embedding/rerank/video")
|
||||||
agent_endpoint = Column(String(500), default=None)
|
agent_endpoint = Column(String(500), default=None)
|
||||||
agent_api_key = Column(String(500), default=None, comment="单个Agent接入的API Key")
|
agent_api_key = Column(Text, default=None, comment="Agent 接入 API Key(3.2 起 Fernet 加密存储)")
|
||||||
tool_policy = Column(JSON, default=None, comment="Agent 工具/MCP 权限沙箱策略(3.1)")
|
tool_policy = Column(JSON, default=None, comment="Agent 工具/MCP 权限沙箱策略(3.1)")
|
||||||
created_at = Column(DateTime, default=datetime.now)
|
created_at = Column(DateTime, default=datetime.now)
|
||||||
updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now)
|
updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now)
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
-- Phase 3.2: 放宽 market_apps.agent_api_key 容量以容纳 Fernet 密文
|
||||||
|
SET @db = DATABASE();
|
||||||
|
SET @tbl = 'market_apps';
|
||||||
|
SET @col = 'agent_api_key';
|
||||||
|
SELECT COLUMN_TYPE INTO @ctype FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=@db AND TABLE_NAME=@tbl AND COLUMN_NAME=@col;
|
||||||
|
SET @sql = IF(@ctype IS NULL OR @ctype NOT LIKE '%text%',
|
||||||
|
CONCAT('ALTER TABLE ', @tbl, ' MODIFY COLUMN ', @col, ' TEXT NULL COMMENT \'Agent 接入 API Key(3.2 起 Fernet 加密存储)\''),
|
||||||
|
'SELECT 1');
|
||||||
|
PREPARE stmt FROM @sql;
|
||||||
|
EXECUTE stmt;
|
||||||
|
DEALLOCATE PREPARE stmt;
|
||||||
Reference in new issue
Block a user