From 3d9f8b2f2446f6e9ed360b8576daab1836e52f21 Mon Sep 17 00:00:00 2001 From: Codex Date: Thu, 21 May 2026 13:03:47 +0000 Subject: [PATCH] feat: add auth broker p0 skeleton --- AGENTS.md | 2 + docs/reference/auth-broker.md | 43 +- scripts/auth-broker-contract-test.ts | 113 ++- scripts/cli.ts | 9 + scripts/src/auth-broker.ts | 295 ++++++++ scripts/src/check.ts | 10 + scripts/src/help.ts | 3 + .../microservices/auth-broker/Cargo.lock | 414 +++++++++++ .../microservices/auth-broker/Cargo.toml | 20 + .../microservices/auth-broker/Dockerfile | 24 + .../microservices/auth-broker/src/main.rs | 646 ++++++++++++++++++ 11 files changed, 1575 insertions(+), 4 deletions(-) create mode 100644 scripts/src/auth-broker.ts create mode 100644 src/components/microservices/auth-broker/Cargo.lock create mode 100644 src/components/microservices/auth-broker/Cargo.toml create mode 100644 src/components/microservices/auth-broker/Dockerfile create mode 100644 src/components/microservices/auth-broker/src/main.rs diff --git a/AGENTS.md b/AGENTS.md index a8d24a0a..8ab5fec4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -43,6 +43,7 @@ UniDesk 是一个以主 server 为统一入口的分布式工作平台;本文 - `bun scripts/cli.ts deploy check/plan/apply [--file deploy.json|--env dev|prod] [--service ]`:按根目录 `deploy.json` 或 `origin/master:deploy.json#environments.` 的服务 repo 和 commit 期望状态校验或更新用户服务;`--env dev` 开放 D601 `backend-core` rollout、reviewed registry artifact consumers 和 D601 direct consumer validation,`findjob`/`pipeline` 是 D601 direct pull-only 样板,`met-nonlinear` dry-run blocked,`k3sctl-adapter` supervisor-only,`code-queue` prod unsupported,规则见 `docs/reference/deploy.md` 与 `docs/reference/dev-environment.md`。 - `bun scripts/cli.ts dev-env validate [--manifest path] [--kubectl-dry-run]` / `dev-env prewarm-images`:离线校验 D601 `unidesk-dev` 生产隔离护栏和 dev workload manifests,或把开发底座基础镜像预热到 D601 原生 k3s containerd,规则见 `docs/reference/deploy.md` 与 `docs/reference/microservices.md`。 - `bun scripts/cli.ts artifact-registry plan|render|status|health|install|deploy-backend-core|deploy-service`:管理 D601 host-managed CNCF Distribution registry,并通过短生命周期 relay 或 D601 pull/import 做 commit-pinned pull-only artifact CD;`deploy-backend-core` 是 deprecated 兼容名,`findjob`/`pipeline` 支持 D601 direct dev/prod,`met-nonlinear` 和 `k3sctl-adapter` 只给受限计划路径,`code-queue` 只支持 dev,规则见 `docs/reference/artifact-registry.md`。 +- `bun scripts/cli.ts auth-broker contract|health --dry-run|credential-request --dry-run|pr-preflight --dry-run`:查看 Auth Broker P0 Rust skeleton 与 CLI adapter contract,runner 无 `GH_TOKEN`/`GITHUB_TOKEN` 时返回结构化 `auth-missing`/`broker-needed`,不读取或打印 token 值,规则见 `docs/reference/auth-broker.md`。 - `bun scripts/cli.ts gh auth status|issue ...|pr list|view|create|comment` / `bun scripts/code-queue-pr-preflight-example.ts`:通过 REST 执行安全 GitHub issue 读写、脱敏 auth/status 诊断、body-file Markdown 写入、当日滚动简报时间线 ClaudeQQ 通知、escape 扫描、只读 cleanup-plan 和 #20 board-audit、PR 创建/评论 dry-run 与 runner PR preflight;`gh pr merge` 当前仍结构化拒绝,规则见 `docs/reference/cli.md` 和 `docs/reference/code-queue-supervision.md`。 - `bun scripts/cli.ts commander contract|plan --dry-run|smoke --dry-run|approval request --dry-run`:查看 host Codex 指挥官直管微服务 skeleton 的 source/contract、无 daemon smoke 验证计划、.state/commander/ 状态模型、trace summary 聚合和 ClaudeQQ 高风险请示草案;当前只返回 dry-run 计划,不接 live bridge、不接管人工指挥官,不发送消息,规则见 `docs/reference/host-codex-commander.md`。 - `bun scripts/cli.ts ci install/status/run/publish-backend-core/publish-user-service/run-dev-e2e/logs`:在 D601 原生 k3s 上安装和运行 Tekton CI,支持每 commit 检查、Code Queue 只读性能门禁、`CI.json` catalog 驱动的 backend-core 与 user-service commit-pinned 镜像发布和手动触发的 `origin/master:deploy.json#environments.dev` 临时 namespace e2e;catalog/producer/consumer 分工见 `docs/reference/cicd-standardization.md`,`run-dev-e2e` 的 Git 控制 runner、短 launcher 和 no-CD 边界见 `docs/reference/dev-ci-runner.md`,Tekton 规则见 `docs/reference/ci.md`。 @@ -85,6 +86,7 @@ UniDesk 是一个以主 server 为统一入口的分布式工作平台;本文 - `docs/reference/deploy.md`:`deploy.json` desired-state、target-side build、一次性构建 proxy、直管/代管服务部署 executor 和 live commit 验证规则。 - `docs/reference/devops-hygiene.md`:Git-backed deployment truth、dirty worktree/manual repair 边界、受限手动操作和 CI 私有仓库 source-auth 规则。 - `docs/reference/cicd-standardization.md`:`CI.json` catalog、CI producer summary、blocked/upstream-image 服务、File Browser 上游镜像例外、legacy CI/CD 路径分类和 CD consumer 分工。 +- `docs/reference/auth-broker.md`:Auth Broker P0 的 Rust skeleton、GitHub REST operation allowlist、CLI dry-run adapter、审计字段、失败语义和人工确认点。 - `docs/reference/release-governance.md`:`release/v1` 稳定维护线、`master` 集成线、CI/CD server 版本固定、master CLI 兼容和 feature flag 治理规则;决策记录见 GitHub issue #6。 - `docs/reference/artifact-registry.md`:D601 host-managed CNCF Distribution registry、loopback-only 边界和 backend-core artifact CD 目标流程。 - `docs/reference/host-codex-commander.md`:host Codex 指挥官 skeleton 的 source/contract、CLI dry-run、状态模型、SSH/PTY/stdio bridge 预留边界、#20/#46 入口和 ClaudeQQ 高风险审批边界。 diff --git a/docs/reference/auth-broker.md b/docs/reference/auth-broker.md index 3029797f..a0f5184c 100644 --- a/docs/reference/auth-broker.md +++ b/docs/reference/auth-broker.md @@ -20,7 +20,8 @@ Artifact registry / deploy 路径的问题不同:D601 registry 是 host loopba P0 只解决 GitHub REST 权限不应出现在普通 runner env 中的问题: -- 新增一个计划中的 Rust 单二进制服务,工作名 `auth-broker`。 +- 新增 Rust 单二进制服务 skeleton,路径为 `src/components/microservices/auth-broker`,工作名 `auth-broker`。 +- 新增 CLI adapter contract,入口为 `bun scripts/cli.ts auth-broker contract|health --dry-run|credential-request --dry-run|pr-preflight --dry-run`。 - 先只在 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`。 @@ -30,6 +31,15 @@ P0 可以让 Code Queue 并行推进,但必须把实现拆成互不冲突的 l ## API +The first skeleton lives at: + +- `src/components/microservices/auth-broker/Cargo.toml` +- `src/components/microservices/auth-broker/src/main.rs` +- `src/components/microservices/auth-broker/Dockerfile` +- `scripts/src/auth-broker.ts` + +The skeleton intentionally does not read `GH_TOKEN` or `GITHUB_TOKEN`. It uses only redacted readiness configuration such as `AUTH_BROKER_GITHUB_CONFIGURED`, `AUTH_BROKER_GITHUB_CREDENTIAL_REF`, `AUTH_BROKER_ALLOWED_REPOS` and optional `AUTH_BROKER_AUDIT_LOG`. Real secret mounting is outside this contract. + ### `GET /health` 只返回服务状态和 redacted capability,不返回 secret 值。 @@ -192,6 +202,37 @@ P0 must use stable failure kinds so Code Queue can decide whether to retry, spli 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. +## CLI Adapter + +The local runner adapter is a dry-run contract surface only: + +```bash +bun scripts/cli.ts auth-broker contract +bun scripts/cli.ts auth-broker health --dry-run +bun scripts/cli.ts auth-broker credential-request --operation github.pr.create --repo pikasTech/unidesk --dry-run +bun scripts/cli.ts auth-broker pr-preflight --repo pikasTech/unidesk --base master --head --issue 59 --dry-run +``` + +If no `UNIDESK_AUTH_BROKER_URL` / `AUTH_BROKER_URL` is configured, the adapter returns a structured failure instead of falling through to live GitHub or a shell fallback. `GH_TOKEN` / `GITHUB_TOKEN` presence is reported only as migration diagnostics and does not make the Auth Broker adapter ready: + +```json +{ + "ok": false, + "failureKind": "auth-missing", + "degradedReason": "broker-needed", + "runnerDisposition": "infra-blocked", + "brokerNeeded": true, + "tokenCoverage": { + "ok": false, + "presenceOnly": true, + "valuesRead": false, + "valuesPrinted": false + } +} +``` + +When a broker endpoint is configured, the same command returns the P0 ready shape with `tokenCoverage.source=auth-broker`, `runnerEnvTokenRequired=false`, `valuesPrinted=false`, `preflightCreatesPr=false`, `preflightMergesPr=false` and `brokerProxy.writesRemote=false`. The adapter sanitizes endpoint URLs before printing and never reads token values. + ## D601 Dev Acceptance The minimum D601 dev verification is: diff --git a/scripts/auth-broker-contract-test.ts b/scripts/auth-broker-contract-test.ts index 2b1db6b2..bebbd8da 100644 --- a/scripts/auth-broker-contract-test.ts +++ b/scripts/auth-broker-contract-test.ts @@ -1,4 +1,5 @@ -import { readFileSync } from "node:fs"; +import { spawn } from "node:child_process"; +import { existsSync, readFileSync } from "node:fs"; type RunnerDisposition = "ready" | "infra-blocked" | "business-failed"; @@ -11,6 +12,9 @@ interface FailureContract { const docPath = "docs/reference/auth-broker.md"; const doc = readFileSync(docPath, "utf8"); +const rustMainPath = "src/components/microservices/auth-broker/src/main.rs"; +const rustCargoPath = "src/components/microservices/auth-broker/Cargo.toml"; +const cliAdapterPath = "scripts/src/auth-broker.ts"; function assertCondition(condition: unknown, message: string, detail: unknown = {}): void { if (!condition) throw new Error(`${message}: ${JSON.stringify(detail)}`); @@ -20,6 +24,44 @@ function assertDocContains(text: string): void { assertCondition(doc.includes(text), `missing auth broker doc text: ${text}`); } +function runCli(args: string[], env: Record = {}): Promise<{ status: number | null; stdout: string; stderr: string; json: Record | null }> { + const childEnv = { ...process.env, ...env }; + delete childEnv.GH_TOKEN; + delete childEnv.GITHUB_TOKEN; + delete childEnv.UNIDESK_AUTH_BROKER_URL; + delete childEnv.AUTH_BROKER_URL; + if (env.GH_TOKEN !== undefined) childEnv.GH_TOKEN = env.GH_TOKEN; + if (env.GITHUB_TOKEN !== undefined) childEnv.GITHUB_TOKEN = env.GITHUB_TOKEN; + if (env.UNIDESK_AUTH_BROKER_URL !== undefined) childEnv.UNIDESK_AUTH_BROKER_URL = env.UNIDESK_AUTH_BROKER_URL; + if (env.AUTH_BROKER_URL !== undefined) childEnv.AUTH_BROKER_URL = env.AUTH_BROKER_URL; + return new Promise((resolve, reject) => { + const child = spawn("bun", ["scripts/cli.ts", ...args], { + cwd: process.cwd(), + env: childEnv, + }); + const stdoutChunks: Buffer[] = []; + const stderrChunks: Buffer[] = []; + child.stdout.on("data", (chunk) => stdoutChunks.push(Buffer.from(chunk))); + child.stderr.on("data", (chunk) => stderrChunks.push(Buffer.from(chunk))); + child.on("error", reject); + child.on("close", (status) => { + const stdout = Buffer.concat(stdoutChunks).toString("utf8"); + let json: Record | null = null; + try { + json = JSON.parse(stdout) as Record; + } catch { + json = null; + } + resolve({ status, stdout, stderr: Buffer.concat(stderrChunks).toString("utf8"), json }); + }); + }); +} + +function dataOf(response: Record): Record { + assertCondition(typeof response.data === "object" && response.data !== null && !Array.isArray(response.data), "CLI response data should be object", response); + return response.data as Record; +} + const requiredOperations = [ "github.auth.status", "github.issue.list", @@ -109,12 +151,15 @@ function walk(value: unknown, path: string[] = []): void { } } -function main(): void { +async function main(): Promise { 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/src/auth-broker.ts", + "src/components/microservices/auth-broker/Cargo.toml", + "src/components/microservices/auth-broker/src/main.rs", "scripts/code-queue-pr-preflight-example.ts", "src/components/microservices/code-queue/src/runtime-preflight.ts", "scripts/src/code-queue.ts", @@ -138,9 +183,68 @@ function main(): void { assertCondition(samplePreflightResponse.prCapabilityContract.preflightMergesPr === false, "P0 preflight must not merge PRs", samplePreflightResponse.prCapabilityContract); walk(samplePreflightResponse); + for (const path of [rustCargoPath, rustMainPath, cliAdapterPath]) { + assertCondition(existsSync(path), `required auth broker implementation file is missing: ${path}`); + } + const rustMain = readFileSync(rustMainPath, "utf8"); + const cliAdapter = readFileSync(cliAdapterPath, "utf8"); + assertCondition(rustMain.includes("GET") && rustMain.includes("/health"), "Rust skeleton should expose GET /health", rustMainPath); + assertCondition(rustMain.includes("/v1/github/gh"), "Rust skeleton should expose credential-request endpoint", rustMainPath); + assertCondition(rustMain.includes("/v1/github/pr-preflight"), "Rust skeleton should expose pr-preflight endpoint", rustMainPath); + assertCondition(rustMain.includes("credential_value_printed: false"), "audit event must force credentialValuePrinted=false", rustMainPath); + assertCondition(!rustMain.includes("GH_TOKEN") && !rustMain.includes("GITHUB_TOKEN"), "Rust skeleton must not read runner token env keys", rustMainPath); + assertCondition(cliAdapter.includes("valuesRead: false") && cliAdapter.includes("valuesPrinted: false"), "CLI adapter must declare secret values unread/unprinted", cliAdapterPath); + assertCondition(cliAdapter.includes("broker-needed") && cliAdapter.includes("auth-missing"), "CLI adapter must expose broker-needed/auth-missing shape", cliAdapterPath); + + const noToken = await runCli(["auth-broker", "pr-preflight", "--repo", "pikasTech/unidesk", "--base", "master", "--head", "feature/auth-broker", "--issue", "59", "--dry-run"]); + assertCondition(noToken.status === 1, "missing broker endpoint should exit 1", { status: noToken.status, stdout: noToken.stdout, stderr: noToken.stderr }); + assertCondition(noToken.json?.ok === false, "missing token response envelope should fail", noToken.json); + const noTokenData = dataOf(noToken.json ?? {}); + assertCondition(noTokenData.failureKind === "auth-missing", "missing token should classify as auth-missing", noTokenData); + assertCondition(noTokenData.degradedReason === "broker-needed", "missing token should classify as broker-needed", noTokenData); + assertCondition(noTokenData.brokerNeeded === true, "missing token should set brokerNeeded", noTokenData); + assertCondition(!noToken.stdout.includes("contract-secret-marker"), "missing-token response must not leak secret marker strings", noToken.stdout); + + const brokerReady = await runCli([ + "auth-broker", + "pr-preflight", + "--repo", + "pikasTech/unidesk", + "--base", + "master", + "--head", + "feature/auth-broker", + "--issue", + "59", + "--dry-run", + "--endpoint", + "http://user:pass@127.0.0.1:4291?credential=abc", + ]); + assertCondition(brokerReady.status === 0, "configured broker dry-run should exit 0", { status: brokerReady.status, stdout: brokerReady.stdout, stderr: brokerReady.stderr }); + assertCondition(brokerReady.json?.ok === true, "configured broker dry-run envelope should succeed", brokerReady.json); + const readyData = dataOf(brokerReady.json ?? {}); + const tokenCoverage = readyData.tokenCoverage as Record; + const brokerCoverage = readyData.brokerCoverage as Record; + const prCapability = readyData.prCapabilityContract as Record; + const brokerProxy = prCapability.brokerProxy as Record; + assertCondition(tokenCoverage.source === "auth-broker", "ready token coverage should come from broker", tokenCoverage); + assertCondition(tokenCoverage.runnerEnvTokenRequired === false, "ready token coverage should not require runner env token", tokenCoverage); + assertCondition(tokenCoverage.valuesPrinted === false, "ready token coverage must not print values", tokenCoverage); + assertCondition(String(brokerCoverage.endpoint).includes("http://***:***@127.0.0.1:4291/?..."), "endpoint should be sanitized", brokerCoverage); + assertCondition(prCapability.targetBranch === "master", "P0 capability should preserve target branch", prCapability); + assertCondition(prCapability.preflightCreatesPr === false && prCapability.preflightMergesPr === false, "P0 PR preflight must not write or merge", prCapability); + assertCondition(brokerProxy.writesRemote === false, "P0 broker proxy should not write remote", brokerProxy); + assertCondition(Array.isArray(brokerProxy.operations) && brokerProxy.operations.includes("github.pr.create"), "P0 broker proxy should include PR create dry-run operation", brokerProxy); + walk(readyData); + process.stdout.write(`${JSON.stringify({ ok: true, docPath, + implementation: { + rustMainPath, + rustCargoPath, + cliAdapterPath, + }, operations: requiredOperations, failureKinds: failureContracts.map((item) => item.failureKind), p0Safety: { @@ -152,4 +256,7 @@ function main(): void { }, null, 2)}\n`); } -main(); +main().catch((error) => { + process.stderr.write(`${error instanceof Error ? error.stack ?? error.message : String(error)}\n`); + process.exitCode = 1; +}); diff --git a/scripts/cli.ts b/scripts/cli.ts index a25bda1a..f99455ef 100644 --- a/scripts/cli.ts +++ b/scripts/cli.ts @@ -18,6 +18,7 @@ import { ciHelp, runCiCommand } from "./src/ci"; import { runSwapCommand } from "./src/swap"; import { runDevEnvCommand } from "./src/dev-env"; import { runArtifactRegistryCommand } from "./src/artifact-registry"; +import { runAuthBrokerCommand } from "./src/auth-broker"; import { runGhCommand } from "./src/gh"; import { runCommanderCommand } from "./src/commander"; import { isHelpToken, rootHelp, serverHelp, sshHelp, staticNamespaceHelp } from "./src/help"; @@ -182,6 +183,14 @@ async function main(): Promise { return; } + if (top === "auth-broker") { + const result = runAuthBrokerCommand(args.slice(1)); + const ok = (result as { ok?: unknown }).ok !== false; + emitJson(commandName, result, ok); + if (!ok) process.exitCode = 1; + return; + } + if (top === "gh") { const result = await runGhCommand(args.slice(1)); const ok = (result as { ok?: unknown }).ok !== false; diff --git a/scripts/src/auth-broker.ts b/scripts/src/auth-broker.ts new file mode 100644 index 00000000..1156b510 --- /dev/null +++ b/scripts/src/auth-broker.ts @@ -0,0 +1,295 @@ +const DEFAULT_REPO = "pikasTech/unidesk"; +const DEFAULT_BASE = "master"; +const SECRET_ENV_KEYS = ["GH_TOKEN", "GITHUB_TOKEN"] as const; +const BROKER_URL_ENV_KEYS = ["UNIDESK_AUTH_BROKER_URL", "AUTH_BROKER_URL"] as const; +const DEFAULT_CAPABILITIES = [ + "github.auth.status", + "github.issue.list", + "github.issue.read", + "github.pr.list", + "github.pr.read", + "github.pr.create", + "github.pr.comment.create", + "github.pr.preflight.dry-run", +] as const; + +type BrokerCommand = "contract" | "credential-request" | "pr-preflight" | "health"; +type RunnerDisposition = "ready" | "infra-blocked" | "business-failed"; + +interface BrokerAdapterOptions { + command: BrokerCommand; + repo: string; + operation: string; + dryRun: boolean; + endpoint: string | null; + base: string; + head: string; + issueNumber: number | null; +} + +function hasEnvKey(name: string): boolean { + return Object.prototype.hasOwnProperty.call(process.env, name); +} + +function stringOption(args: string[], name: string): string | undefined { + const index = args.indexOf(name); + if (index === -1) return undefined; + const value = args[index + 1]; + if (value === undefined || value.length === 0) throw new Error(`${name} requires a non-empty value`); + return value; +} + +function sanitizeEndpoint(value: string): string { + try { + const parsed = new URL(value); + if (parsed.username.length > 0) parsed.username = "***"; + if (parsed.password.length > 0) parsed.password = "***"; + if (parsed.search.length > 0) parsed.search = "?..."; + parsed.hash = ""; + return parsed.toString(); + } catch { + return value.includes("?") ? `${value.split("?")[0]}?...` : value; + } +} + +function numberOption(args: string[], name: string): number | null { + const raw = stringOption(args, name); + if (raw === undefined) return null; + const value = Number(raw); + if (!Number.isInteger(value) || value <= 0) throw new Error(`${name} must be a positive integer`); + return value; +} + +function firstConfiguredBrokerUrl(): string | null { + for (const key of BROKER_URL_ENV_KEYS) { + if (hasEnvKey(key)) return `<${key}>`; + } + return null; +} + +function parseOptions(args: string[]): BrokerAdapterOptions { + const rawCommand = args[0] ?? "contract"; + if (!["contract", "credential-request", "pr-preflight", "health"].includes(rawCommand)) { + throw new Error(`unknown auth-broker command: ${rawCommand}`); + } + const command = rawCommand as BrokerCommand; + const endpoint = stringOption(args, "--endpoint"); + return { + command, + repo: stringOption(args, "--repo") ?? DEFAULT_REPO, + operation: stringOption(args, "--operation") ?? (command === "pr-preflight" ? "github.pr.preflight.dry-run" : "github.auth.status"), + dryRun: args.includes("--dry-run") || command === "contract" || command === "credential-request" || command === "pr-preflight" || command === "health", + endpoint: endpoint === undefined ? firstConfiguredBrokerUrl() : sanitizeEndpoint(endpoint), + base: stringOption(args, "--base") ?? DEFAULT_BASE, + head: stringOption(args, "--head") ?? "", + issueNumber: numberOption(args, "--issue"), + }; +} + +function runnerEnvTokenCoverage(): Record { + const present = SECRET_ENV_KEYS.filter((key) => hasEnvKey(key)); + return { + ok: present.length > 0, + source: present.length > 0 ? "runner-env" : null, + checkedKeys: SECRET_ENV_KEYS, + presentKeys: present, + missingKeys: SECRET_ENV_KEYS.filter((key) => !present.includes(key)), + presenceOnly: true, + valuesRead: false, + valuesPrinted: false, + }; +} + +function brokerCoverage(endpoint: string | null): Record { + return { + ok: endpoint !== null, + source: endpoint === null ? null : "auth-broker", + endpoint: endpoint ?? null, + credentialRef: endpoint === null ? null : "github:unidesk-dev", + scope: endpoint === null ? null : "broker-held-github-credential", + runnerEnvTokenRequired: false, + valuesRead: false, + valuesPrinted: false, + }; +} + +function brokerNeededResult(options: BrokerAdapterOptions): Record { + return { + ok: false, + failureKind: "auth-missing", + degradedReason: "broker-needed", + runnerDisposition: "infra-blocked" as RunnerDisposition, + retryable: false, + brokerNeeded: true, + message: "No auth broker endpoint is configured for this dry-run contract; runner env token coverage is reported only for migration diagnostics.", + tokenCoverage: runnerEnvTokenCoverage(), + brokerCoverage: brokerCoverage(options.endpoint), + next: [ + "configure UNIDESK_AUTH_BROKER_URL or AUTH_BROKER_URL for broker-backed runner auth", + "keep GH_TOKEN/GITHUB_TOKEN out of ordinary runner env once broker mode is enabled", + ], + redaction: { + valuesRead: false, + valuesPrinted: false, + forbiddenOutputKeys: ["token", "secret", "authorization", "cookie"], + }, + }; +} + +function auditEventShape(options: BrokerAdapterOptions): Record { + return { + requestId: "authbroker-contract-request", + observedAt: "ISO-8601 timestamp", + caller: { plane: "code-queue", taskId: null, queueId: null }, + operation: options.operation, + repo: options.repo, + resource: options.command === "pr-preflight" + ? { base: options.base, head: options.head, issueNumber: options.issueNumber } + : null, + dryRun: true, + credentialRef: "github:unidesk-dev", + credentialKind: "github-rest-token-ref", + credentialValuePrinted: false, + upstream: { method: "planned", path: "planned GitHub REST path without query secrets" }, + status: "HTTP status", + ok: "boolean", + failureKind: null, + degradedReason: null, + runnerDisposition: "ready|infra-blocked|business-failed", + retryable: "boolean", + durationMs: "integer", + redaction: { valuesPrinted: false }, + }; +} + +function plannedCredentialRequest(options: BrokerAdapterOptions): Record { + return { + requestId: "authbroker-cli-dry-run", + caller: { plane: "manual-cli" }, + repo: options.repo, + operation: options.operation, + dryRun: true, + params: options.command === "pr-preflight" + ? { base: options.base, head: options.head, issueNumber: options.issueNumber } + : {}, + }; +} + +function readyContract(options: BrokerAdapterOptions): Record { + return { + ok: true, + runnerDisposition: "ready" as RunnerDisposition, + failureKind: null, + degradedReason: null, + brokerNeeded: false, + dryRun: true, + mutation: false, + capabilities: [...DEFAULT_CAPABILITIES], + tokenCoverage: { + ok: true, + source: "auth-broker", + scope: "broker-held-github-credential", + runnerEnvTokenRequired: false, + valuesRead: false, + valuesPrinted: false, + }, + brokerCoverage: brokerCoverage(options.endpoint), + credentialRequest: plannedCredentialRequest(options), + auditEventShape: auditEventShape(options), + prCapabilityContract: { + targetBranch: options.base, + headBranch: options.head, + systemGhBinaryRequiredForWrites: false, + preflightCreatesPr: false, + preflightMergesPr: false, + brokerProxy: { + ok: true, + operations: ["github.auth.status", "github.issue.read", "github.pr.read", "github.pr.create"], + writesRemote: false, + }, + pushDryRun: { + runnerLocal: true, + coveredByBroker: false, + }, + }, + redaction: { + valuesRead: false, + valuesPrinted: false, + secretKeysBlocked: ["token", "secret", "authorization", "cookie"], + }, + }; +} + +function contractResult(options: BrokerAdapterOptions): Record { + return { + ok: true, + service: "auth-broker", + phase: "p0", + commands: [ + "bun scripts/cli.ts auth-broker contract", + "bun scripts/cli.ts auth-broker health --dry-run", + "bun scripts/cli.ts auth-broker credential-request --operation github.pr.create --repo pikasTech/unidesk --dry-run", + "bun scripts/cli.ts auth-broker pr-preflight --repo pikasTech/unidesk --base master --head --issue 59 --dry-run", + ], + capabilities: [...DEFAULT_CAPABILITIES], + permissionBoundary: { + allowedRepos: [DEFAULT_REPO], + liveGithubWrites: false, + arbitraryGhApi: false, + registryCredentials: false, + deployPermissions: false, + }, + runnerNoTokenResult: brokerNeededResult({ ...options, endpoint: null }), + readyShape: readyContract({ ...options, endpoint: "" }), + }; +} + +export function authBrokerHelp(): unknown { + return { + command: "auth-broker", + output: "json", + usage: [ + "bun scripts/cli.ts auth-broker contract", + "bun scripts/cli.ts auth-broker health --dry-run [--endpoint URL]", + "bun scripts/cli.ts auth-broker credential-request --operation github.pr.create --repo pikasTech/unidesk --dry-run [--endpoint URL]", + "bun scripts/cli.ts auth-broker pr-preflight --repo pikasTech/unidesk --base master --head [--issue N] --dry-run [--endpoint URL]", + ], + boundary: [ + "P0 adapter is contract/dry-run only and never starts a service.", + "GH_TOKEN and GITHUB_TOKEN values are not read or printed; only key presence is reported.", + "No dry-run command writes GitHub, registry, deploy, filesystem credential, or service state.", + ], + reference: "docs/reference/auth-broker.md", + }; +} + +export function runAuthBrokerCommand(args: string[]): Record { + if (args.some((arg) => arg === "help" || arg === "--help" || arg === "-h")) return authBrokerHelp() as Record; + const options = parseOptions(args); + if (options.command === "contract") return contractResult(options); + + const envCoverage = runnerEnvTokenCoverage(); + const broker = brokerCoverage(options.endpoint); + if (broker.ok !== true) return brokerNeededResult(options); + + if (options.command === "health") { + return { + ok: true, + dryRun: true, + mutation: false, + service: "auth-broker", + phase: "p0", + brokerCoverage: broker, + tokenCoverage: envCoverage, + healthRequest: { + method: "GET", + path: "/health", + wouldCallBroker: false, + }, + capabilities: [...DEFAULT_CAPABILITIES], + redaction: { valuesRead: false, valuesPrinted: false }, + }; + } + + return readyContract(options); +} diff --git a/scripts/src/check.ts b/scripts/src/check.ts index a984f13f..996126df 100644 --- a/scripts/src/check.ts +++ b/scripts/src/check.ts @@ -14,6 +14,7 @@ const syntaxFiles = [ "scripts/cli.ts", "scripts/src/check.ts", "scripts/src/artifact-registry.ts", + "scripts/src/auth-broker.ts", "scripts/src/code-queue.ts", "scripts/src/command.ts", "scripts/src/decision-center.ts", @@ -27,6 +28,7 @@ const syntaxFiles = [ "scripts/host-codex-commander-contract-test.ts", "scripts/host-codex-commander-no-daemon-smoke-contract-test.ts", "scripts/host-codex-commander-skeleton-contract-test.ts", + "scripts/auth-broker-contract-test.ts", "src/components/frontend/src/index.ts", "src/components/frontend/src/app.tsx", "src/components/frontend/src/decision-center.tsx", @@ -271,6 +273,7 @@ export function runChecks(config: UniDeskConfig, options: CheckOptions = default fileItem("AGENTS.md"), fileItem("TEST.md"), fileItem("docs/reference/artifact-registry.md"), + fileItem("docs/reference/auth-broker.md"), fileItem("docker-compose.yml"), fileItem("src/components/backend-core/Cargo.toml"), fileItem("src/components/backend-core/Cargo.lock"), @@ -314,6 +317,11 @@ export function runChecks(config: UniDeskConfig, options: CheckOptions = default fileItem("scripts/code-queue-pr-preflight-example.ts"), fileItem("scripts/schedule-cli-contract-test.ts"), fileItem("scripts/src/artifact-registry.ts"), + fileItem("scripts/src/auth-broker.ts"), + fileItem("scripts/auth-broker-contract-test.ts"), + fileItem("src/components/microservices/auth-broker/Cargo.toml"), + fileItem("src/components/microservices/auth-broker/Dockerfile"), + fileItem("src/components/microservices/auth-broker/src/main.rs"), fileItem("scripts/artifact-consumer-dry-run-matrix-test.ts"), fileItem("src/components/microservices/k3sctl-adapter/k3s/ci/unidesk-ci.pipeline.yaml"), ); @@ -342,6 +350,7 @@ export function runChecks(config: UniDeskConfig, options: CheckOptions = default items.push(commandItem("schedule:cli-contract", ["bun", "scripts/schedule-cli-contract-test.ts"], 30_000)); items.push(commandItem("gh:issue-guard-contract", ["bun", "scripts/gh-cli-issue-guard-contract-test.ts"], 30_000)); items.push(commandItem("gh:pr-contract", ["bun", "scripts/gh-cli-pr-contract-test.ts"], 30_000)); + items.push(commandItem("auth-broker:p0-contract", ["bun", "scripts/auth-broker-contract-test.ts"], 30_000)); } else { items.push(skippedItem("typescript:scripts", "scripts TypeScript typecheck is opt-in", "--scripts-typecheck or --full")); items.push(skippedItem("code-queue:prompt-observation-contract", "prompt observation contract is opt-in with script checks", "--scripts-typecheck or --full")); @@ -360,6 +369,7 @@ export function runChecks(config: UniDeskConfig, options: CheckOptions = default items.push(skippedItem("schedule:cli-contract", "Schedule CLI contract is opt-in with script checks", "--scripts-typecheck or --full")); items.push(skippedItem("gh:issue-guard-contract", "GitHub issue CLI contract is opt-in with script checks", "--scripts-typecheck or --full")); items.push(skippedItem("gh:pr-contract", "GitHub PR CLI contract is opt-in with script checks", "--scripts-typecheck or --full")); + items.push(skippedItem("auth-broker:p0-contract", "Auth Broker P0 skeleton and CLI adapter contract is opt-in with script checks", "--scripts-typecheck or --full")); } if (options.logs) { items.push(unifiedLogRotationItem()); diff --git a/scripts/src/help.ts b/scripts/src/help.ts index 224474ca..ddf26137 100644 --- a/scripts/src/help.ts +++ b/scripts/src/help.ts @@ -1,4 +1,5 @@ import { ghHelp } from "./gh"; +import { authBrokerHelp } from "./auth-broker"; export function rootHelp(): unknown { return { @@ -43,6 +44,7 @@ export function rootHelp(): unknown { { command: "deploy check|plan|apply [--file deploy.json|--env dev|prod] [--service id] [--commit full-sha] [--dry-run] [--force]", description: "Reconcile services from origin/master:deploy.json environments; --commit overrides one reviewed artifact consumer such as frontend for release/v1 validation or rollback. code-queue artifact consumption is dev-only." }, { command: "dev-env validate|prewarm-images", description: "Validate D601 unidesk-dev guardrails or prewarm dev foundation images into native k3s containerd through a bounded async job." }, { command: "artifact-registry plan|render|status|health|install|deploy-backend-core|deploy-service", description: "Manage the D601 host-managed CNCF Distribution registry and run pull-only artifact CD for supported services, including D601 direct, k3s-managed, and code-queue dev-only consumers." }, + { command: "auth-broker contract|health --dry-run|credential-request --dry-run|pr-preflight --dry-run", description: "Inspect the P0 Rust auth broker and CLI adapter contract without reading token values, writing GitHub, or starting services." }, { command: "gh auth|issue|pr", description: "Run safe GitHub issue and PR CRUD/lifecycle operations through REST with body-file update replace/append, comment delete, token diagnostics, hard delete unsupported, and merge blocked." }, { command: "commander contract|plan --dry-run|smoke --dry-run|approval request --dry-run", description: "Host Codex commander skeleton contract, no-daemon smoke plan, and dry-run preview; exposes local health, state, trace summary, and approval draft helpers without live bridges or message sends." }, { command: "code-agent-sandbox", description: "Independent Code Agent Sandbox service skeleton for adapter, mode, and credential-boundary diagnostics." }, @@ -371,6 +373,7 @@ export function staticNamespaceHelp(args: string[]): unknown | null { if (top === "e2e") return e2eHelp(); if (top === "dev-env") return devEnvHelp(); if (top === "artifact-registry") return artifactRegistryHelp(); + if (top === "auth-broker") return authBrokerHelp(); if (top === "gh") return ghHelp(); return null; } diff --git a/src/components/microservices/auth-broker/Cargo.lock b/src/components/microservices/auth-broker/Cargo.lock new file mode 100644 index 00000000..4fe331c9 --- /dev/null +++ b/src/components/microservices/auth-broker/Cargo.lock @@ -0,0 +1,414 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "ascii" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d92bec98840b8f03a5ff5413de5293bfcd8bf96467cf5452609f939ec6f5de16" + +[[package]] +name = "auth-broker" +version = "0.1.0" +dependencies = [ + "chrono", + "serde", + "serde_json", + "tiny_http", +] + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "bumpalo" +version = "3.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" + +[[package]] +name = "cc" +version = "1.2.62" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1dce859f0832a7d088c4f1119888ab94ef4b5d6795d1ce05afb7fe159d79f98" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "chrono" +version = "0.4.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" +dependencies = [ + "iana-time-zone", + "num-traits", + "windows-link", +] + +[[package]] +name = "chunked_transfer" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e4de3bc4ea267985becf712dc6d9eed8b04c953b3fcfb339ebc87acd9804901" + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.98" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67df7112613f8bfd9150013a0314e196f4800d3201ae742489d999db2f979f08" +dependencies = [ + "cfg-if", + "futures-util", + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "tiny_http" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389915df6413a2e74fb181895f933386023c71110878cd0825588928e64cdc82" +dependencies = [ + "ascii", + "chunked_transfer", + "httpdate", + "log", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "wasm-bindgen" +version = "0.2.121" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49ace1d07c165b0864824eee619580c4689389afa9dc9ed3a4c75040d82e6790" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.121" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e68e6f4afd367a562002c05637acb8578ff2dea1943df76afb9e83d177c8578" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.121" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d95a9ec35c64b2a7cb35d3fead40c4238d0940c86d107136999567a4703259f2" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.121" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4e0100b01e9f0d03189a92b96772a1fb998639d981193d7dbab487302513441" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/src/components/microservices/auth-broker/Cargo.toml b/src/components/microservices/auth-broker/Cargo.toml new file mode 100644 index 00000000..daf47725 --- /dev/null +++ b/src/components/microservices/auth-broker/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "auth-broker" +version = "0.1.0" +edition = "2021" + +[[bin]] +name = "auth-broker" +path = "src/main.rs" + +[profile.release] +codegen-units = 1 +lto = "thin" +opt-level = "z" +strip = true + +[dependencies] +chrono = { version = "0.4", default-features = false, features = ["clock", "std"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +tiny_http = "0.12" diff --git a/src/components/microservices/auth-broker/Dockerfile b/src/components/microservices/auth-broker/Dockerfile new file mode 100644 index 00000000..197af8ac --- /dev/null +++ b/src/components/microservices/auth-broker/Dockerfile @@ -0,0 +1,24 @@ +ARG AUTH_BROKER_RUST_IMAGE=rust:1.85-bookworm +FROM ${AUTH_BROKER_RUST_IMAGE} AS build + +WORKDIR /build +ENV CARGO_BUILD_JOBS=1 +COPY src/components/microservices/auth-broker/Cargo.toml ./Cargo.toml +COPY src/components/microservices/auth-broker/Cargo.lock ./Cargo.lock +RUN mkdir -p src \ + && printf 'fn main() { println!("dependency cache"); }\n' > src/main.rs \ + && cargo build --release \ + && rm -rf src +COPY src/components/microservices/auth-broker/src ./src +RUN touch src/main.rs && cargo build --release + +FROM debian:bookworm-slim + +RUN apt-get update \ + && apt-get install -y --no-install-recommends ca-certificates \ + && rm -rf /var/lib/apt/lists/* + +COPY --from=build /build/target/release/auth-broker /usr/local/bin/auth-broker + +EXPOSE 4291 +CMD ["auth-broker"] diff --git a/src/components/microservices/auth-broker/src/main.rs b/src/components/microservices/auth-broker/src/main.rs new file mode 100644 index 00000000..757bf5ab --- /dev/null +++ b/src/components/microservices/auth-broker/src/main.rs @@ -0,0 +1,646 @@ +use chrono::{SecondsFormat, Utc}; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; +use std::collections::BTreeSet; +use std::env; +use std::fs::{create_dir_all, OpenOptions}; +use std::io::{Read, Write}; +use std::path::Path; +use std::time::Instant; +use tiny_http::{Header, Method, Request, Response, Server, StatusCode}; + +const DEFAULT_ALLOWED_REPO: &str = "pikasTech/unidesk"; +const DEFAULT_CREDENTIAL_REF: &str = "github:unidesk-dev"; +const MAX_BODY_BYTES: usize = 64 * 1024; + +const CAPABILITIES: &[&str] = &[ + "github.auth.status", + "github.issue.list", + "github.issue.read", + "github.pr.list", + "github.pr.read", + "github.pr.create", + "github.pr.comment.create", + "github.pr.preflight.dry-run", +]; + +#[derive(Clone)] +struct Config { + host: String, + port: u16, + credential_ref: String, + credential_kind: String, + credential_configured: bool, + allowed_repos: BTreeSet, + log_file: Option, +} + +#[derive(Deserialize)] +struct Caller { + plane: Option, + #[serde(rename = "taskId")] + task_id: Option, + #[serde(rename = "queueId")] + queue_id: Option, +} + +#[derive(Deserialize)] +struct BrokerRequest { + #[serde(rename = "requestId")] + request_id: Option, + caller: Option, + repo: Option, + operation: Option, + #[serde(rename = "dryRun")] + dry_run: Option, + params: Option, +} + +impl BrokerRequest { + fn caller_json(&self) -> Value { + caller_json(self.caller.as_ref()) + } +} + +#[derive(Serialize)] +struct AuditEvent { + #[serde(rename = "requestId")] + request_id: String, + #[serde(rename = "observedAt")] + observed_at: String, + caller: Value, + operation: String, + repo: String, + resource: Value, + #[serde(rename = "dryRun")] + dry_run: bool, + #[serde(rename = "credentialRef")] + credential_ref: String, + #[serde(rename = "credentialKind")] + credential_kind: String, + #[serde(rename = "credentialValuePrinted")] + credential_value_printed: bool, + upstream: Value, + status: u16, + ok: bool, + #[serde(rename = "failureKind")] + failure_kind: Option, + #[serde(rename = "degradedReason")] + degraded_reason: Option, + #[serde(rename = "runnerDisposition")] + runner_disposition: String, + retryable: bool, + #[serde(rename = "durationMs")] + duration_ms: u128, + redaction: Value, +} + +fn now_iso() -> String { + Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true) +} + +fn env_string(name: &str, fallback: &str) -> String { + env::var(name) + .ok() + .filter(|value| !value.trim().is_empty()) + .unwrap_or_else(|| fallback.to_string()) +} + +fn env_flag(name: &str, fallback: bool) -> bool { + let raw = env::var(name).ok().filter(|value| !value.trim().is_empty()); + match raw.as_deref().map(str::to_ascii_lowercase).as_deref() { + Some("1") | Some("true") | Some("yes") | Some("on") => true, + Some("0") | Some("false") | Some("no") | Some("off") => false, + _ => fallback, + } +} + +fn config_from_env() -> Config { + let allowed_repos = env_string("AUTH_BROKER_ALLOWED_REPOS", DEFAULT_ALLOWED_REPO) + .split(',') + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string) + .collect::>(); + let credential_ref = env_string("AUTH_BROKER_GITHUB_CREDENTIAL_REF", DEFAULT_CREDENTIAL_REF); + Config { + host: env_string("HOST", "0.0.0.0"), + port: env_string("PORT", "4291") + .parse::() + .ok() + .filter(|port| *port > 0) + .unwrap_or(4291), + credential_ref, + credential_kind: "github-rest-token-ref".to_string(), + credential_configured: env_flag("AUTH_BROKER_GITHUB_CONFIGURED", false), + allowed_repos, + log_file: env::var("AUTH_BROKER_AUDIT_LOG") + .ok() + .filter(|value| !value.trim().is_empty()), + } +} + +fn json_response(value: Value, status: u16) -> Response>> { + let body = serde_json::to_vec_pretty(&value).unwrap_or_else(|_| b"{\"ok\":false}".to_vec()); + let header = Header::from_bytes( + &b"Content-Type"[..], + &b"application/json; charset=utf-8"[..], + ) + .unwrap(); + Response::from_data(body) + .with_status_code(StatusCode(status)) + .with_header(header) +} + +fn text_response(text: &str, status: u16) -> Response>> { + Response::from_string(text.to_string()).with_status_code(StatusCode(status)) +} + +fn read_body(request: &mut Request) -> Result { + let mut body = String::new(); + request + .as_reader() + .take(MAX_BODY_BYTES as u64 + 1) + .read_to_string(&mut body) + .map_err(|error| error.to_string())?; + if body.len() > MAX_BODY_BYTES { + return Err("request body exceeds 65536 bytes".to_string()); + } + Ok(body) +} + +fn health_payload(config: &Config) -> Value { + json!({ + "ok": true, + "service": "auth-broker", + "phase": "p0", + "github": { + "configured": config.credential_configured, + "credentialRef": config.credential_ref, + "credentialKind": config.credential_kind, + "valuesPrinted": false + }, + "capabilities": CAPABILITIES, + "allowedRepos": config.allowed_repos.iter().collect::>(), + "redaction": { + "valuesPrinted": false, + "secretKeysBlocked": ["token", "secret", "authorization", "cookie"] + } + }) +} + +fn request_id(input: Option<&String>) -> String { + input + .filter(|value| !value.trim().is_empty()) + .cloned() + .unwrap_or_else(|| format!("authbroker-{}", Utc::now().timestamp_millis())) +} + +fn caller_json(caller: Option<&Caller>) -> Value { + json!({ + "plane": caller.and_then(|value| value.plane.clone()).unwrap_or_else(|| "unknown".to_string()), + "taskId": caller.and_then(|value| value.task_id.clone()), + "queueId": caller.and_then(|value| value.queue_id.clone()) + }) +} + +fn operation_allowed(operation: &str) -> bool { + CAPABILITIES.iter().any(|item| *item == operation) && operation != "github.pr.preflight.dry-run" +} + +fn resource_from_params(operation: &str, params: Option<&Value>) -> Value { + let Some(Value::Object(map)) = params else { + return json!(null); + }; + match operation { + "github.issue.read" | "github.pr.read" | "github.pr.comment.create" => { + json!({ "number": map.get("number").and_then(Value::as_i64) }) + } + "github.pr.create" => json!({ + "base": map.get("base").and_then(Value::as_str), + "head": map.get("head").and_then(Value::as_str), + "titleChars": map.get("title").and_then(Value::as_str).map(str::len), + "bodyChars": map.get("body").and_then(Value::as_str).map(str::len) + }), + _ => json!(null), + } +} + +fn upstream_plan(operation: &str, repo: &str, resource: &Value) -> Value { + let path = match operation { + "github.auth.status" => "/rate_limit".to_string(), + "github.issue.list" => format!("/repos/{repo}/issues"), + "github.issue.read" => format!( + "/repos/{repo}/issues/{}", + resource + .get("number") + .and_then(Value::as_i64) + .unwrap_or_default() + ), + "github.pr.list" => format!("/repos/{repo}/pulls"), + "github.pr.read" => format!( + "/repos/{repo}/pulls/{}", + resource + .get("number") + .and_then(Value::as_i64) + .unwrap_or_default() + ), + "github.pr.create" => format!("/repos/{repo}/pulls"), + "github.pr.comment.create" => format!( + "/repos/{repo}/issues/{}/comments", + resource + .get("number") + .and_then(Value::as_i64) + .unwrap_or_default() + ), + _ => "/unsupported".to_string(), + }; + let method = match operation { + "github.pr.create" | "github.pr.comment.create" => "POST", + _ => "GET", + }; + json!({ "method": method, "path": path }) +} + +fn failure_response( + config: &Config, + request_id: &str, + failure_kind: &str, + status: u16, + runner_disposition: &str, + retryable: bool, + message: &str, +) -> Value { + json!({ + "ok": false, + "requestId": request_id, + "failureKind": failure_kind, + "degradedReason": message, + "runnerDisposition": runner_disposition, + "retryable": retryable, + "message": message, + "credential": { + "credentialRef": config.credential_ref, + "credentialKind": config.credential_kind, + "configured": config.credential_configured, + "valuesPrinted": false + }, + "status": status, + "next": [ + "configure broker-held credential reference outside runner env", + "retry only after broker health reports github.configured=true" + ], + "redaction": { + "valuesPrinted": false + } + }) +} + +fn dry_run_required(operation: &str, dry_run: bool) -> bool { + matches!(operation, "github.pr.create" | "github.pr.comment.create") && !dry_run +} + +fn handle_github(config: &Config, mut request: Request) { + let start = Instant::now(); + let mut status = 200; + let body = match read_body(&mut request) { + Ok(body) => body, + Err(message) => { + let id = request_id(None); + status = 400; + let result = failure_response( + config, + &id, + "validation-failed", + status, + "business-failed", + false, + &message, + ); + let audit = audit_from_response(config, &result, start.elapsed().as_millis(), status); + write_audit(config, &audit); + let _ = request.respond(json_response(result, status)); + return; + } + }; + let parsed = serde_json::from_str::(&body).map_err(|error| error.to_string()); + let result = match parsed { + Ok(input) => { + let id = request_id(input.request_id.as_ref()); + let repo = input + .repo + .clone() + .unwrap_or_else(|| DEFAULT_ALLOWED_REPO.to_string()); + let operation = input.operation.clone().unwrap_or_default(); + let dry_run = input.dry_run.unwrap_or(false); + let caller = input.caller_json(); + if operation.is_empty() { + status = 400; + failure_response( + config, + &id, + "validation-failed", + status, + "business-failed", + false, + "operation is required", + ) + } else if !config.allowed_repos.contains(&repo) { + status = 403; + failure_response( + config, + &id, + "repo-not-allowed", + status, + "business-failed", + false, + "repo is not allowed", + ) + } else if !operation_allowed(&operation) { + status = 403; + failure_response( + config, + &id, + "operation-not-allowed", + status, + "business-failed", + false, + "operation is not allowed", + ) + } else if !config.credential_configured { + status = 503; + failure_response( + config, + &id, + "auth-not-configured", + status, + "infra-blocked", + false, + "broker GitHub credential reference is not configured", + ) + } else if dry_run_required(&operation, dry_run) { + status = 409; + failure_response( + config, + &id, + "dry-run-required", + status, + "business-failed", + false, + "P0 mutation operations require dryRun=true", + ) + } else { + let resource = resource_from_params(&operation, input.params.as_ref()); + let upstream = upstream_plan(&operation, &repo, &resource); + let writes_remote = false; + json!({ + "ok": true, + "requestId": id, + "runnerDisposition": "ready", + "failureKind": null, + "degradedReason": null, + "repo": repo, + "caller": caller, + "operation": operation, + "dryRun": dry_run, + "planned": true, + "writesRemote": writes_remote, + "credential": { + "credentialRef": config.credential_ref, + "credentialKind": config.credential_kind, + "configured": true, + "valuesPrinted": false + }, + "upstream": upstream, + "resource": resource, + "audit": { + "credentialValuePrinted": false, + "redaction": { "valuesPrinted": false } + } + }) + } + } + Err(message) => { + let id = request_id(None); + status = 400; + failure_response( + config, + &id, + "validation-failed", + status, + "business-failed", + false, + &message, + ) + } + }; + + let audit = audit_from_response(config, &result, start.elapsed().as_millis(), status); + write_audit(config, &audit); + let _ = request.respond(json_response(result, status)); +} + +fn handle_pr_preflight(config: &Config, mut request: Request) { + let start = Instant::now(); + let mut status = 200; + let body = read_body(&mut request) + .ok() + .and_then(|body| serde_json::from_str::(&body).ok()) + .unwrap_or_else(|| json!({})); + let id_source = body + .get("requestId") + .and_then(Value::as_str) + .map(String::from); + let id = request_id(id_source.as_ref()); + let repo = body + .get("repo") + .and_then(Value::as_str) + .unwrap_or(DEFAULT_ALLOWED_REPO) + .to_string(); + let result = if !config.allowed_repos.contains(&repo) { + status = 403; + failure_response( + config, + &id, + "repo-not-allowed", + status, + "business-failed", + false, + "repo is not allowed", + ) + } else if !config.credential_configured { + status = 503; + failure_response( + config, + &id, + "auth-not-configured", + status, + "infra-blocked", + false, + "broker GitHub credential reference is not configured", + ) + } else { + let base = body.get("base").and_then(Value::as_str).unwrap_or("master"); + let head = body + .get("head") + .and_then(Value::as_str) + .unwrap_or(""); + json!({ + "ok": true, + "requestId": id, + "runnerDisposition": "ready", + "failureKind": null, + "degradedReason": null, + "tokenCoverage": { + "ok": true, + "source": "auth-broker", + "scope": "broker-held-github-credential", + "runnerEnvTokenRequired": false, + "valuesPrinted": false + }, + "prCapabilityContract": { + "targetBranch": base, + "headBranch": head, + "systemGhBinaryRequiredForWrites": false, + "preflightCreatesPr": false, + "preflightMergesPr": false, + "brokerProxy": { + "ok": true, + "operations": ["github.auth.status", "github.issue.read", "github.pr.read", "github.pr.create"], + "writesRemote": false + }, + "pushDryRun": { + "runnerLocal": true, + "coveredByBroker": false + } + }, + "redaction": { + "valuesPrinted": false + } + }) + }; + let audit = audit_from_response(config, &result, start.elapsed().as_millis(), status); + write_audit(config, &audit); + let _ = request.respond(json_response(result, status)); +} + +fn audit_from_response( + config: &Config, + response: &Value, + duration_ms: u128, + status: u16, +) -> AuditEvent { + let request_id = response + .get("requestId") + .and_then(Value::as_str) + .unwrap_or("unknown") + .to_string(); + let operation = response + .get("operation") + .and_then(Value::as_str) + .unwrap_or("github.pr.preflight.dry-run") + .to_string(); + let repo = response + .get("repo") + .and_then(Value::as_str) + .unwrap_or(DEFAULT_ALLOWED_REPO) + .to_string(); + AuditEvent { + request_id, + observed_at: now_iso(), + caller: response + .get("caller") + .cloned() + .unwrap_or_else(|| json!({ "plane": "unknown", "taskId": null, "queueId": null })), + operation, + repo, + resource: response + .get("resource") + .cloned() + .unwrap_or_else(|| json!(null)), + dry_run: response + .get("dryRun") + .and_then(Value::as_bool) + .unwrap_or(true), + credential_ref: config.credential_ref.clone(), + credential_kind: config.credential_kind.clone(), + credential_value_printed: false, + upstream: response + .get("upstream") + .cloned() + .unwrap_or_else(|| json!(null)), + status, + ok: response.get("ok").and_then(Value::as_bool).unwrap_or(false), + failure_kind: response + .get("failureKind") + .and_then(Value::as_str) + .map(str::to_string), + degraded_reason: response + .get("degradedReason") + .and_then(Value::as_str) + .map(str::to_string), + runner_disposition: response + .get("runnerDisposition") + .and_then(Value::as_str) + .unwrap_or("infra-blocked") + .to_string(), + retryable: response + .get("retryable") + .and_then(Value::as_bool) + .unwrap_or(false), + duration_ms, + redaction: json!({ "valuesPrinted": false }), + } +} + +fn write_audit(config: &Config, event: &AuditEvent) { + let Ok(line) = serde_json::to_string(event) else { + return; + }; + if let Some(path) = &config.log_file { + if let Some(parent) = Path::new(path).parent() { + let _ = create_dir_all(parent); + } + if let Ok(mut file) = OpenOptions::new().create(true).append(true).open(path) { + let _ = writeln!(file, "{line}"); + return; + } + } + println!("{line}"); +} + +fn route(config: &Config, request: Request) { + let method = request.method().clone(); + let path = request.url().split('?').next().unwrap_or("/").to_string(); + match (method, path.as_str()) { + (Method::Get, "/health") => { + let _ = request.respond(json_response(health_payload(config), 200)); + } + (Method::Post, "/v1/github/gh") => handle_github(config, request), + (Method::Post, "/v1/github/pr-preflight") => handle_pr_preflight(config, request), + _ => { + let _ = request.respond(text_response("not found", 404)); + } + } +} + +fn main() { + let config = config_from_env(); + let address = format!("{}:{}", config.host, config.port); + let server = Server::http(&address) + .unwrap_or_else(|error| panic!("auth-broker listen failed on {address}: {error}")); + println!( + "{}", + json!({ + "ok": true, + "service": "auth-broker", + "event": "started", + "address": address, + "credentialRef": config.credential_ref, + "credentialValuePrinted": false + }) + ); + for request in server.incoming_requests() { + route(&config, request); + } +}