From a2c469ee8c144d2fd8fe12b5708de76f215cae27 Mon Sep 17 00:00:00 2001 From: Codex Date: Thu, 21 May 2026 12:14:25 +0000 Subject: [PATCH] docs: define auth broker p0 contract --- docs/reference/auth-broker.md | 215 +++++++++++++++++++++++++++ scripts/auth-broker-contract-test.ts | 155 +++++++++++++++++++ 2 files changed, 370 insertions(+) create mode 100644 docs/reference/auth-broker.md create mode 100644 scripts/auth-broker-contract-test.ts diff --git a/docs/reference/auth-broker.md b/docs/reference/auth-broker.md new file mode 100644 index 00000000..3029797f --- /dev/null +++ b/docs/reference/auth-broker.md @@ -0,0 +1,215 @@ +# Auth Broker P0 Contract + +本文收口 GitHub issue/PR 权限注入的 P0 方案。P0 目标不是建设完整 IAM,也不是把 secret 分发给 runner;目标是先在 D601 dev 验证一个 Rust 单二进制 broker proxy,让 Code Queue runner 即使没有 `GH_TOKEN` / `GITHUB_TOKEN`,也能通过受控代理完成 GitHub issue/PR 访问和 PR preflight dry-run。 + +## Existing Paths + +当前阻塞来自多个路径都把 GitHub 能力等同于 runner 环境变量: + +- `scripts/src/gh.ts`:UniDesk `gh` CLI 已经是 repo-native GitHub REST wrapper,优先读取 `GH_TOKEN`,其次读取 `GITHUB_TOKEN`,再 fallback 到系统 `gh auth token`。它不会打印 token 值,并已把 `missing-token`、`permission-denied`、`scope-insufficient`、`network-proxy-failed`、`unsupported-command` 等失败结构化。 +- `scripts/code-queue-pr-preflight-example.ts`:本地示例先检查 env token,再跑 `gh auth status`、`gh pr create --dry-run` 和 `gh pr comment --dry-run`。没有 env token 时该 workflow 直接失败。 +- `src/components/microservices/code-queue/src/runtime-preflight.ts`:D601 scheduler preflight 检查 `GH_TOKEN` / `GITHUB_TOKEN` 是否存在,用 curl/gh/git 探测 GitHub API、issue、PR、SSH 和可选 push dry-run。 +- `scripts/src/code-queue.ts`:`codex pr-preflight` 把 runtime preflight 压缩为 runner 合同;`tokenCoverageStatus` 目前要求 env token,缺失时输出 `failureKind=auth-missing`、`degradedReason=GH_TOKEN/GITHUB_TOKEN missing` 和 `runnerDisposition=infra-blocked`。 +- `src/components/microservices/code-queue/src/index.ts`:`CODE_QUEUE_REMOTE_CODEX_ENV_KEYS` 默认包含 `GH_TOKEN`、`GITHUB_TOKEN`、`GH_HOST`、`GITHUB_API_URL` 和 `GH_REPO`,用于把 scheduler env 继续传给 provider dev container。这是凭证分发路径,不适合作为 P0 的唯一解。 +- `src/components/microservices/code-queue/docker-compose.d601.yml`:D601 Code Queue 从 `.state/code-queue-d601.env` 读取运行时环境,并配置 provider-gateway egress proxy。该文件不能承载本任务中的真实 secret 值。 +- `src/components/microservices/code-queue/Dockerfile`:runner 镜像已经包含 `git`、`gh`、`curl`、`jq`、`rg`、`cargo`、`rustc` 和 `rustfmt`,足够执行轻量 broker client、preflight 和 contract test;本 P0 不需要为了调研临时安装新系统包。 + +Artifact registry / deploy 路径的问题不同:D601 registry 是 host loopback 的 artifact cache,标准 CD 先验证 commit-pinned manifest,再 pull/import/retag/rollout。P0 auth broker 不代理 Docker registry credential、不替代 provider-gateway Host SSH、不执行 deploy apply,也不解决 production registry 发布授权。 + +## P0 Scope + +P0 只解决 GitHub REST 权限不应出现在普通 runner env 中的问题: + +- 新增一个计划中的 Rust 单二进制服务,工作名 `auth-broker`。 +- 先只在 D601 dev 验证,入口只能是 k3s ClusterIP、backend-core/microservice 私有代理或 D601 loopback,不开放公网端口。 +- broker 持有服务端 GitHub 凭证引用并调用 GitHub REST;runner 不接收、不读取、不打印 `GH_TOKEN` / `GITHUB_TOKEN`。 +- API 只接受结构化 operation,不接受 shell、argv、任意 URL 或原始 `gh api`。 +- P0 不实现真实 secret 存储、轮换、用户身份绑定、细粒度授权、production rollout 或 registry/deploy 凭证代理。 + +P0 可以让 Code Queue 并行推进,但必须把实现拆成互不冲突的 lane:Rust API skeleton、CLI client adapter、runtime-preflight contract、D601 dev manifest/dry-run、文档和 contract test。真实凭证配置、D601 dev Secret 创建、production 启用和 live write allowlist 需要人工确认。 + +## API + +### `GET /health` + +只返回服务状态和 redacted capability,不返回 secret 值。 + +```json +{ + "ok": true, + "service": "auth-broker", + "phase": "p0", + "github": { + "configured": true, + "credentialRef": "github:unidesk-dev", + "valuesPrinted": false + }, + "capabilities": ["github.issue.read", "github.pr.read", "github.pr.preflight.dry-run"] +} +``` + +### `POST /v1/github/gh` + +通用 GitHub issue/PR proxy。请求必须是结构化 JSON: + +```json +{ + "requestId": "uuid", + "caller": { + "plane": "code-queue", + "taskId": "codex_...", + "queueId": "default" + }, + "repo": "pikasTech/unidesk", + "operation": "github.issue.read", + "dryRun": false, + "params": { + "number": 59, + "jsonFields": ["body", "title", "state"] + } +} +``` + +P0 required operation allowlist: + +| Operation | Upstream | Remote write | P0 status | +| --- | --- | --- | --- | +| `github.auth.status` | GitHub REST rate limit + repo probe | no | enabled | +| `github.issue.list` | `GET /repos/{owner}/{repo}/issues` | no | enabled | +| `github.issue.read` | `GET /repos/{owner}/{repo}/issues/{number}` | no | enabled | +| `github.pr.list` | `GET /repos/{owner}/{repo}/pulls` | no | enabled | +| `github.pr.read` | `GET /repos/{owner}/{repo}/pulls/{number}` | no | enabled | +| `github.pr.create` | planned request only when `dryRun=true` | no | enabled as dry-run | +| `github.pr.comment.create` | planned request only when `dryRun=true` | no | enabled as dry-run | + +P0 explicitly blocks `gh pr merge`, issue/PR delete, arbitrary `gh api`, arbitrary HTTP proxying, raw git push, Docker registry login and deploy commands. Live GitHub writes can be added later only after a user-confirmed allowlist and identity/audit review; without that confirmation P0 mutation operations must return `dry-run-required`. + +### `POST /v1/github/pr-preflight` + +Runner-facing PR preflight proxy. It must preserve the existing `codex pr-preflight` semantics while replacing env-token coverage with broker coverage: + +```json +{ + "requestId": "uuid", + "repo": "pikasTech/unidesk", + "base": "master", + "head": "feature/example", + "issueNumber": 59, + "includePrCreateDryRun": true, + "includePushDryRun": false +} +``` + +Successful response shape: + +```json +{ + "ok": true, + "runnerDisposition": "ready", + "failureKind": null, + "degradedReason": null, + "tokenCoverage": { + "ok": true, + "source": "auth-broker", + "scope": "broker-held-github-credential", + "runnerEnvTokenRequired": false, + "valuesPrinted": false + }, + "prCapabilityContract": { + "targetBranch": "master", + "systemGhBinaryRequiredForWrites": false, + "preflightCreatesPr": false, + "preflightMergesPr": false, + "brokerProxy": { + "ok": true, + "operations": ["github.auth.status", "github.pr.create"], + "writesRemote": false + } + } +} +``` + +Broker PR preflight can prove GitHub REST auth, repo visibility, issue/PR read, and PR create body/parameter shape. It cannot prove runner-local git push capability unless the runner still performs `git push --dry-run` with its own git credentials. Therefore `includePushDryRun=true` remains a runner-local check and may still fail as `git-remote-gap`. + +## Permission Boundary + +P0 permission is intentionally coarse because identity verification is not in scope: + +- Allowed repo list defaults to `pikasTech/unidesk`; unknown repos return `repo-not-allowed`. +- Allowed operations are finite strings. The broker never executes caller-provided shell, argv or URLs. +- Request body size is bounded. Markdown bodies are accepted only for planned dry-run output in P0. +- Response redaction is mandatory. No response, log or audit field may contain token values, Authorization headers, cookie values or upstream credential material. +- Broker-held credential is identified only by `credentialRef`, `credentialKind` and boolean readiness. +- Network exposure is dev-only and private. Public frontend access must go through existing authenticated UniDesk proxy if exposed at all. +- P0 does not grant production deploy, registry push/pull, provider token, database, k3s or host SSH permissions. + +## Audit Fields + +Every broker request must emit one JSONL audit record with these fields: + +| Field | Meaning | +| --- | --- | +| `requestId` | Caller-provided or broker-generated id | +| `observedAt` | ISO timestamp | +| `caller.plane` | `code-queue`, `ci`, `commander`, `manual-cli` or `unknown` | +| `caller.taskId` / `caller.queueId` | Code Queue correlation, nullable | +| `operation` | One allowlisted operation string | +| `repo` | Owner/repo after allowlist validation | +| `resource` | Issue/PR number or branch names, no body text | +| `dryRun` | Whether upstream mutation is impossible | +| `credentialRef` | Stable redacted credential reference | +| `credentialValuePrinted` | Always false | +| `upstream.method` / `upstream.path` | GitHub REST method/path without query secrets | +| `status` | HTTP status returned to caller | +| `ok` | Boolean success | +| `failureKind` / `degradedReason` | Structured failure semantics | +| `runnerDisposition` | `ready`, `infra-blocked` or `business-failed` | +| `retryable` | Boolean retry hint | +| `durationMs` | End-to-end broker latency | +| `redaction.valuesPrinted` | Always false | + +Audit records may include body length and SHA-256 for planned PR/issue bodies, but must not store full Markdown bodies by default. + +## Failure Semantics + +P0 must use stable failure kinds so Code Queue can decide whether to retry, split blocker work or ask for manual action. + +| Failure kind | HTTP | Runner disposition | Retryable | Meaning | +| --- | --- | --- | --- | --- | +| `auth-not-configured` | 503 | `infra-blocked` | false | Broker has no configured GitHub credential reference | +| `broker-unavailable` | 503 | `infra-blocked` | true | Broker service/proxy is unreachable | +| `unauthorized-caller` | 403 | `infra-blocked` | false | Caller is outside the private dev/proxy boundary | +| `repo-not-allowed` | 403 | `business-failed` | false | Repo is not in the broker allowlist | +| `operation-not-allowed` | 403 | `business-failed` | false | Operation is not in the finite allowlist | +| `dry-run-required` | 409 | `business-failed` | false | Mutation was requested but P0 only allows dry-run | +| `validation-failed` | 400 | `business-failed` | false | Missing or invalid structured parameters | +| `github-egress-failed` | 502 | `infra-blocked` | true | GitHub or proxy network path failed | +| `github-rate-limited` | 429 | `infra-blocked` | true | GitHub returned rate limiting | +| `github-permission-denied` | 403 | `infra-blocked` | false | Credential lacks repo or action access | +| `scope-insufficient` | 403 | `infra-blocked` | false | Accepted scopes do not satisfy the operation | +| `repo-not-found` | 404 | `business-failed` | false | Allowed repo/resource does not exist or is hidden | +| `upstream-invalid-response` | 502 | `infra-blocked` | true | GitHub response could not be parsed safely | + +All failures must include `message`, `requestId`, `failureKind`, `degradedReason`, `runnerDisposition`, `retryable`, and a `next` array with bounded diagnostic commands or manual actions. None may include secret values. + +## D601 Dev Acceptance + +The minimum D601 dev verification is: + +1. Code Queue scheduler has no `GH_TOKEN` / `GITHUB_TOKEN` requirement for PR preflight success when broker is configured. +2. `codex pr-preflight --remote` reports `tokenCoverage.source=auth-broker`, `runnerEnvTokenRequired=false` and `valuesPrinted=false`. +3. Broker-backed issue/PR read returns structured GitHub data for `pikasTech/unidesk` through the stable proxy. +4. Broker-backed PR create dry-run returns a planned operation with `writesRemote=false`. +5. `includePushDryRun=true` is clearly reported as runner-local and can fail independently as `git-remote-gap`. +6. Logs and audit records contain request ids, operation names and failure semantics, but no token values. +7. No deploy, restart, production mutation, registry credential proxy or long-lived extra control plane is introduced by the verification itself. + +## Manual Confirmation Points + +These items are outside P0 automation and require user or operator confirmation: + +- Where the broker-held GitHub credential reference is mounted in D601 dev. +- Whether P0 permits any live GitHub write. Default is read-only plus dry-run. +- Whether Code Queue CLI should default to broker mode when env token is absent or require an explicit `--auth-broker` flag first. +- Production exposure, production credentials, rotation, identity binding and per-user authorization. +- Any deploy, restart, registry credential, provider token, database or k3s permission integration. diff --git a/scripts/auth-broker-contract-test.ts b/scripts/auth-broker-contract-test.ts new file mode 100644 index 00000000..2b1db6b2 --- /dev/null +++ b/scripts/auth-broker-contract-test.ts @@ -0,0 +1,155 @@ +import { readFileSync } from "node:fs"; + +type RunnerDisposition = "ready" | "infra-blocked" | "business-failed"; + +interface FailureContract { + failureKind: string; + httpStatus: number; + runnerDisposition: RunnerDisposition; + retryable: boolean; +} + +const docPath = "docs/reference/auth-broker.md"; +const doc = readFileSync(docPath, "utf8"); + +function assertCondition(condition: unknown, message: string, detail: unknown = {}): void { + if (!condition) throw new Error(`${message}: ${JSON.stringify(detail)}`); +} + +function assertDocContains(text: string): void { + assertCondition(doc.includes(text), `missing auth broker doc text: ${text}`); +} + +const requiredOperations = [ + "github.auth.status", + "github.issue.list", + "github.issue.read", + "github.pr.list", + "github.pr.read", + "github.pr.create", + "github.pr.comment.create", +]; + +const forbiddenBoundaries = [ + "gh pr merge", + "arbitrary `gh api`", + "Docker registry login", + "deploy commands", +]; + +const requiredAuditFields = [ + "requestId", + "observedAt", + "caller.plane", + "operation", + "repo", + "credentialRef", + "credentialValuePrinted", + "runnerDisposition", + "retryable", +]; + +const failureContracts: FailureContract[] = [ + { failureKind: "auth-not-configured", httpStatus: 503, runnerDisposition: "infra-blocked", retryable: false }, + { failureKind: "broker-unavailable", httpStatus: 503, runnerDisposition: "infra-blocked", retryable: true }, + { failureKind: "unauthorized-caller", httpStatus: 403, runnerDisposition: "infra-blocked", retryable: false }, + { failureKind: "repo-not-allowed", httpStatus: 403, runnerDisposition: "business-failed", retryable: false }, + { failureKind: "operation-not-allowed", httpStatus: 403, runnerDisposition: "business-failed", retryable: false }, + { failureKind: "dry-run-required", httpStatus: 409, runnerDisposition: "business-failed", retryable: false }, + { failureKind: "validation-failed", httpStatus: 400, runnerDisposition: "business-failed", retryable: false }, + { failureKind: "github-egress-failed", httpStatus: 502, runnerDisposition: "infra-blocked", retryable: true }, + { failureKind: "github-rate-limited", httpStatus: 429, runnerDisposition: "infra-blocked", retryable: true }, + { failureKind: "github-permission-denied", httpStatus: 403, runnerDisposition: "infra-blocked", retryable: false }, + { failureKind: "scope-insufficient", httpStatus: 403, runnerDisposition: "infra-blocked", retryable: false }, + { failureKind: "repo-not-found", httpStatus: 404, runnerDisposition: "business-failed", retryable: false }, + { failureKind: "upstream-invalid-response", httpStatus: 502, runnerDisposition: "infra-blocked", retryable: true }, +]; + +const samplePreflightResponse = { + ok: true, + runnerDisposition: "ready" as const, + failureKind: null, + degradedReason: null, + tokenCoverage: { + ok: true, + source: "auth-broker", + scope: "broker-held-github-credential", + runnerEnvTokenRequired: false, + valuesPrinted: false, + }, + prCapabilityContract: { + targetBranch: "master", + systemGhBinaryRequiredForWrites: false, + preflightCreatesPr: false, + preflightMergesPr: false, + brokerProxy: { + ok: true, + operations: ["github.auth.status", "github.pr.create"], + writesRemote: false, + }, + }, +}; + +function walk(value: unknown, path: string[] = []): void { + if (typeof value === "string") { + assertCondition(!/gh[pousr]_[A-Za-z0-9_]{20,}/u.test(value), "sample must not contain GitHub token-like values", { path, value }); + assertCondition(!/github_pat_[A-Za-z0-9_]+/u.test(value), "sample must not contain GitHub PAT-like values", { path, value }); + assertCondition(!/^Bearer\s+/iu.test(value), "sample must not contain Authorization header values", { path, value }); + return; + } + if (Array.isArray(value)) { + value.forEach((item, index) => walk(item, [...path, String(index)])); + return; + } + if (typeof value === "object" && value !== null) { + for (const [key, entry] of Object.entries(value)) { + assertCondition(!["token", "secret", "authorization", "cookie"].includes(key.toLowerCase()), "sample must not expose secret-bearing keys", { path, key }); + walk(entry, [...path, key]); + } + } +} + +function main(): void { + for (const heading of ["## Existing Paths", "## API", "## Permission Boundary", "## Audit Fields", "## Failure Semantics", "## D601 Dev Acceptance"]) { + assertDocContains(heading); + } + for (const path of [ + "scripts/src/gh.ts", + "scripts/code-queue-pr-preflight-example.ts", + "src/components/microservices/code-queue/src/runtime-preflight.ts", + "scripts/src/code-queue.ts", + "src/components/microservices/code-queue/src/index.ts", + "src/components/microservices/code-queue/docker-compose.d601.yml", + "src/components/microservices/code-queue/Dockerfile", + ]) { + assertDocContains(path); + } + for (const operation of requiredOperations) assertDocContains(operation); + for (const boundary of forbiddenBoundaries) assertDocContains(boundary); + for (const field of requiredAuditFields) assertDocContains(field); + for (const failure of failureContracts) { + assertDocContains(failure.failureKind); + assertCondition(Number.isInteger(failure.httpStatus) && failure.httpStatus >= 400, "failure HTTP status must be an error status", failure); + } + assertCondition(new Set(failureContracts.map((item) => item.failureKind)).size === failureContracts.length, "failure kinds must be unique", failureContracts); + assertCondition(samplePreflightResponse.tokenCoverage.source === "auth-broker", "preflight should use broker token coverage", samplePreflightResponse.tokenCoverage); + assertCondition(samplePreflightResponse.tokenCoverage.runnerEnvTokenRequired === false, "runner env token must not be required", samplePreflightResponse.tokenCoverage); + assertCondition(samplePreflightResponse.prCapabilityContract.brokerProxy.writesRemote === false, "P0 preflight must not write remotely", samplePreflightResponse.prCapabilityContract); + assertCondition(samplePreflightResponse.prCapabilityContract.preflightMergesPr === false, "P0 preflight must not merge PRs", samplePreflightResponse.prCapabilityContract); + walk(samplePreflightResponse); + + process.stdout.write(`${JSON.stringify({ + ok: true, + docPath, + operations: requiredOperations, + failureKinds: failureContracts.map((item) => item.failureKind), + p0Safety: { + runnerEnvTokenRequired: false, + preflightWritesRemote: false, + secretValuesPrinted: false, + liveWritesDefault: "dry-run-required", + }, + }, null, 2)}\n`); +} + +main();