Prhub

#2303 feat(ci): add authorized Neon SQL workflow

原始 PR 作者 guapisolo 合并时间 2026-08-12 05:05 文件变更 7 提交数 6 评论 0 代码增减 +796 / -1

执行摘要

新增授权 Neon SQL 工作流,写权限用户可安全执行任意 SQL

PR body 明确说明动机:"Run arbitrary SQL against Miles Neon through a write-gated workflow without exposing credentials",即让有写权限的调用者通过已存在的 Neon secret 执行 SQL,同时避免任何用户接触数据库连接串。该能力用于查询或修复托管的历史指标数据(如 docs/ci/03-metric-history-gate.md 中提到的 hosted metric history),并强调 "Users never handle NEON_DATABASE_URL"。

值得精读,尤其适合关注“受权临时数据库访问”“避免 secret 暴露”设计的工程师。重点看三点:双重 actor 权限校验(工作流与客户端各自独立执行)、CMS 加密结果交付与 artifact 自清理、执行器对 SQL 的 byte-for-byte 透传与结果截断语义。鉴于该 PR 没有公开 review 讨论,正式用于生产前建议再补充一轮针对权限与故障路径的走查。

讨论亮点

该 PR 未产生任何公开 review 评论或讨论线程,仅由 yushengsu-thu 直接 approve。PR body 作者自列了三个 review focus:.github/workflows/neon-access.yml 的权限执行、.github/scripts/neon_access_job.py 的 SQL 执行语义、run_neon_workflow.py 的结果校验与 artifact 清理,但仓库内没有留下公开交锋。从 6 个提交演进可见作者自行完成了关键设计收敛:去掉重复文档与 public summary 输出;把执行器移入既有 workflow 所有权路径以避免改动 CODEOWNERS;取消二次确认(显式数据库请求即视为授权);以及为 tests/ci 补注册 stage-a-cpu 修复 CPU 收集失败。

实现拆解

  1. 本地发起端 .claude/skills/neon-access/scripts/run_neon_workflow.py:新增 mainpack_sql_create_recipientdecrypt_resultvalidate_result_delete_artifact。脚本读取 SQL 文件,先做 UTF-8 校验再 gzip(mtime=0)+base64 打包并计算 SHA-256;本地用 openssl 生成一次性 RSA 3072 密钥与一日期证书;通过 gh api 核对当前用户为 write/admin 后 dispatch neon-access.yml;随后 watch 运行、下载加密 artifact、本地解密并按 request_id/run_id/actor/sql_sha256 校验身份,最后删除远端 artifact。
  2. 工作流授权层 .github/workflows/neon-access.yml:新增 workflow_dispatch 入口,authorize job 先要求运行在 default branch,再对 github.actorgithub.triggering_actor 双 actor 逐一调用 collaborators permission API,强制 write/admin;execute job 使用固定 SHA 的 checkout 做 sparse checkout,只检出执行器脚本,安装 psycopg[binary]==3.3.4,在 NEON_DATABASE_URL 环境下执行 SQL,结果用 openssl cms -aes256 加密后上传 artifact,continue-on-error 保证失败路径也返回加密结果,最后失败时显式 exit 1。
  3. 远端执行器 .github/workflows/scripts/neon_access_job.py:新增 decode_sqlencode_value_base_resultexecute_requestmain。执行器从环境变量取参,REQUEST_ID 必须是规范 UUID;execute_request 使用 autocommit=Trueprepare_threshold=None 一次性提交整段 SQL(不拆分、不改写),对每个结果集按列名+行值编码,bytes/float/嵌套结构转为可 JSON 序列化的结构,并累计行字节数实现 truncated 上限;数据库异常不抛出,打包为 status=errorsqlstate 返回。
  4. 技能与文档 .claude/skills/neon-access/SKILL.mdagents/openai.yaml:定义 $neon-access 技能用法,明确边界:不暴露 secret、不分类 SQL、autocommit 语义、结果 4 MiB 上限、不支持 psql 反斜杠命令;docs/ci/03-metric-history-gate.md 增加“Inspect hosted history”小节,指引仓库作者用 $neon-access 查询或修复托管指标历史,保持运行时 store 的 DML-only 边界。
  5. 测试与 CI 注册 tests/ci/test/test_neon_access.py:新增 6 个离线测试,用 load_module 直接加载工作流脚本与客户端脚本,构造 FakeDriver/FakeCursor/FakeResult 验证 SQL 字节级透传、结果编码与截断、数据库错误打包、SQL 传输往返、CMS 加解密与身份校验、以及 workflow 边界(secret 仅出现一次、无 GITHUB_STEP_SUMMARY、retention-days=1 等);最后一个提交把测试注册到 stage-a-cpu,修复 CI 收集失败问题。
文件 模块 状态 重要度
.claude/skills/neon-access/scripts/run_neon_workflow.py 客户端 added 9.06
.github/workflows/scripts/neon_access_job.py 执行器 added 7.57
.github/workflows/neon-access.yml 工作流 added 5.86
tests/ci/test/test_neon_access.py 测试 added 7.76
.claude/skills/neon-access/SKILL.md 技能文档 added 3.77
.claude/skills/neon-access/agents/openai.yaml 技能配置 added 3.49
docs/ci/03-metric-history-gate.md 文档 modified 2.74

关键符号

run_neon_workflow.main run_neon_workflow.pack_sql run_neon_workflow.validate_result run_neon_workflow._delete_artifact neon_access_job.main neon_access_job.execute_request neon_access_job.decode_sql neon_access_job.encode_value

关键源码片段

.claude/skills/neon-access/scripts/run_neon_workflow.py core-logic

本地发起端核心:SQL 打包、一次性密钥生成、workflow dispatch、结果解密与身份校验、artifact 清理,是整套流程的客户端主路径。

#!/usr/bin/env python3
# 核心常量:仓库、工作流文件名、GitHub API 版本
REPOSITORY = "radixark/miles"
WORKFLOW = "neon-access.yml"
API_VERSION = "2026-03-10"
MAX_DISPATCH_CHARACTERS = 65_535
DEFAULT_MAX_RESULT_BYTES = 4 * 1024 * 1024
​
​
def pack_sql(sql_bytes):
    # 先验证 UTF-8 编码,保证 SQL 字节能无丢失地传送到执行端
    sql_bytes.decode("utf-8")
    # gzip 固定 mtime=0,保证相同输入产生相同压缩结果,便于可复现调试
    compressed = gzip.compress(sql_bytes, mtime=0)
    # 同时返回 base64 载荷与 SHA-256 摘要,供执行端回传校验
    return base64.b64encode(compressed).decode("ascii"), hashlib.sha256(sql_bytes).hexdigest()
​
​
def validate_result(result, *, request_id, run_id, actor, sql_sha256):
    # 结果身份绑定:五项都必须与本地发起时的值一致,防止结果被调包
    expected = {
        "schema_version": 1,
        "request_id": request_id,
        "run_id": str(run_id),
        "actor": actor,
        "sql_sha256": sql_sha256,
    }
    for key, value in expected.items():
        if result.get(key) != value:
            raise RuntimeError(f"result identity mismatch for {key}")
    # 状态白名单:只接受 ok 或 error,拒绝任何未知状态
    if result.get("status") not in {"ok", "error"}:
        raise RuntimeError("result has an invalid status")
​
​
def _delete_artifact(run_id, artifact_name):
    # 列出本次运行的所有 artifact,要求重名项必须唯一,避免误删
    response = _gh_api(f"repos/{REPOSITORY}/actions/runs/{run_id}/artifacts")
    matches = [item for item in response["artifacts"] if item["name"] == artifact_name]
    if len(matches) != 1:
        raise RuntimeError(f"expected one artifact named {artifact_name}, found {len(matches)}")
    # 通过 artifact id 删除,确保加密结果不长期留在仓库可见区
    _gh_api(
        f"repos/{REPOSITORY}/actions/artifacts/{matches[0]['id']}",
        method="DELETE",
    )
.github/workflows/scripts/neon_access_job.py core-logic

远端执行器核心:解码 SQL、调用 psycopg 执行任意语句、编码结果行、限制返回字节数、打包数据库错误,是 SQL 语义与防泄露的关键实现。

def execute_request(driver, *, dsn, sql_text, request_id, run_id, actor, reason, max_result_bytes):
    # 构造基础结果结构,其中 sql_sha256 用于和客户端侧比对,确认执行的就是原始 SQL
    result = _base_result(
        request_id=request_id,
        run_id=run_id,
        actor=actor,
        reason=reason,
        sql_text=sql_text,
    )
    returned_bytes = 0
    try:
        # autocommit=True 表示不隐式包事务;prepare_threshold=None 关闭服务端预编译
        with driver.connect(dsn, autocommit=True, prepare_threshold=None) as connection:
            with connection.cursor() as cursor:
                # 整段 SQL 一次性传给 PostgreSQL,不拆分、不改写,支持多语句
                cursor.execute(sql_text, prepare=False)
                for current in cursor.results():
                    # 列名取驱动描述信息;无描述(如 DDL 语句)则列为空
                    columns = (
                        [column.name for column in current.description]
                        if current.description is not None
                        else []
                    )
                    result_set = {
                        "command_status": current.statusmessage,
                        "columns": columns,
                        "rows": [],
                    }
                    if current.description is not None:
                        for row in current:
                            if result["truncated"]:
                                continue
                            encoded_row = [encode_value(value) for value in row]
                            # 按 JSON 序列化后的字节数计算返回上限,达到上限即标记 truncated 并丢弃后续行
                            row_bytes = len(
                                json.dumps(
                                    encoded_row,
                                    ensure_ascii=False,
                                    separators=(",", ":"),
                                    allow_nan=False,
                                ).encode("utf-8")
                            )
                            if returned_bytes + row_bytes > max_result_bytes:
                                result["truncated"] = True
                                continue
                            result_set["rows"].append(encoded_row)
                            returned_bytes += row_bytes
                    result["results"].append(result_set)
    except driver.Error as error:
        # 数据库异常不抛出,而是打包进结果,便于调用方展示错误与部分执行效果
        result["status"] = "error"
        result["error"] = {
            "type": type(error).__name__,
            "sqlstate": getattr(error, "sqlstate", None),
            "message": str(error),
        }
    return result

评论区精华

没有提炼出高价值讨论线程

当前评论区没有形成足够清晰的争议点或结论,后续有更多讨论时会体现在这里。

风险与影响

  • 任意 SQL 执行能力(高危):neon_access_job.py 不分类、不限制语句类型,DDL/DML/事务控制均可执行,且 autocommit=True 意味着报错前语句已经提交,无法回滚;一旦权限校验被绕过,可造成数据破坏。
  • 权限检查单点依赖:客户端与工作流都依赖 repos/{repo}/collaborators/{actor}/permission API 的实时结果,GH_TOKEN 的权限范围如果小于仓库级 write 判定,可能产生误判;工作流对 github.actorgithub.triggering_actor 都做了检查,但未验证 token 实际 scopes。
  • 结果保密依赖本机密钥:run_neon_workflow.py 的一次性私钥保存在本地临时目录(chmod 0o700),artifact 保留 1 天;若发起方机器被攻破,加密结果可被解密;workflow 侧 RECIPIENT_CERT_BASE64 会出现在 workflow inputs 中,存在被审计日志记录的可能。
  • 测试覆盖缺口:6 个离线测试均基于 FakeDriver,未覆盖真实 psycopg 行为、openssl cms 在不同发行版的差异、以及 workflow 中 shell 权限检查脚本本身的正确性(测试只是字符串断言)。
  • 对 GitHub API 版本的依赖:客户端固定 X-GitHub-Api-Version: 2026-03-10,较新版本若在旧 GitHub Enterprise 或代理环境不可用,dispatch 会失败;MAX_DISPATCH_CHARACTERS=65535 对大 SQL 有限制。
  • 用户影响:拥有 write/admin 权限的仓库贡献者和 agent 可通过 $neon-access 技能直接查询或运维 Neon 数据库,无需接触 NEON_DATABASE_URL;无权限用户会被明确拒绝。普通读者只能看到文档说明。
  • 系统影响:仓库新增一个 workflow_dispatch CI 入口、一个执行器脚本和一个技能目录,CI 会增加少量 runner 消耗与 artifact 存储(保留 1 天)。NEON_DATABASE_URL 的暴露面没有扩大,仍只存在于 workflow secret。
  • 团队影响:metric-history-gate 的托管数据查询/修复从“带外人工操作”变成“受控自助通道”;需要维护一次性密钥生成、CMS 加密、artifact 清理链路,后续若出现真实数据库事故,该通道会是最直接的追溯入口。
任意 SQL 执行能力 权限检查单点依赖 autocommit 无回滚 离线测试未覆盖真实驱动 依赖新 GitHub API 版本

关联 Issue

未识别关联 Issue

当前没有检测到明确关联的 Issue 链接,后续同步到相关引用后会出现在这里。

完整报告

参与讨论