feat: add auth broker p0 skeleton

This commit is contained in:
Codex
2026-05-21 13:03:47 +00:00
parent b10fd46414
commit 3d9f8b2f24
11 changed files with 1575 additions and 4 deletions
+2
View File
@@ -43,6 +43,7 @@ UniDesk 是一个以主 server 为统一入口的分布式工作平台;本文
- `bun scripts/cli.ts deploy check/plan/apply [--file deploy.json|--env dev|prod] [--service <id>]`:按根目录 `deploy.json``origin/master:deploy.json#environments.<env>` 的服务 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 contractrunner 无 `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 e2ecatalog/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 高风险审批边界。
+42 -1
View File
@@ -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 <head-branch> --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:
+110 -3
View File
@@ -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<string, string | undefined> = {}): Promise<{ status: number | null; stdout: string; stderr: string; json: Record<string, unknown> | 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<string, unknown> | null = null;
try {
json = JSON.parse(stdout) as Record<string, unknown>;
} catch {
json = null;
}
resolve({ status, stdout, stderr: Buffer.concat(stderrChunks).toString("utf8"), json });
});
});
}
function dataOf(response: Record<string, unknown>): Record<string, unknown> {
assertCondition(typeof response.data === "object" && response.data !== null && !Array.isArray(response.data), "CLI response data should be object", response);
return response.data as Record<string, unknown>;
}
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<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/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<string, unknown>;
const brokerCoverage = readyData.brokerCoverage as Record<string, unknown>;
const prCapability = readyData.prCapabilityContract as Record<string, unknown>;
const brokerProxy = prCapability.brokerProxy as Record<string, unknown>;
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;
});
+9
View File
@@ -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<void> {
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;
+295
View File
@@ -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") ?? "<head-branch>",
issueNumber: numberOption(args, "--issue"),
};
}
function runnerEnvTokenCoverage(): Record<string, unknown> {
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<string, unknown> {
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<string, unknown> {
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<string, unknown> {
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<string, unknown> {
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<string, unknown> {
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<string, unknown> {
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 <head-branch> --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: "<UNIDESK_AUTH_BROKER_URL>" }),
};
}
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 <head-branch> [--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<string, unknown> {
if (args.some((arg) => arg === "help" || arg === "--help" || arg === "-h")) return authBrokerHelp() as Record<string, unknown>;
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);
}
+10
View File
@@ -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());
+3
View File
@@ -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;
}
+414
View File
@@ -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"
@@ -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"
@@ -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"]
@@ -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<String>,
log_file: Option<String>,
}
#[derive(Deserialize)]
struct Caller {
plane: Option<String>,
#[serde(rename = "taskId")]
task_id: Option<String>,
#[serde(rename = "queueId")]
queue_id: Option<String>,
}
#[derive(Deserialize)]
struct BrokerRequest {
#[serde(rename = "requestId")]
request_id: Option<String>,
caller: Option<Caller>,
repo: Option<String>,
operation: Option<String>,
#[serde(rename = "dryRun")]
dry_run: Option<bool>,
params: Option<Value>,
}
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<String>,
#[serde(rename = "degradedReason")]
degraded_reason: Option<String>,
#[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::<BTreeSet<_>>();
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::<u16>()
.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<std::io::Cursor<Vec<u8>>> {
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<std::io::Cursor<Vec<u8>>> {
Response::from_string(text.to_string()).with_status_code(StatusCode(status))
}
fn read_body(request: &mut Request) -> Result<String, String> {
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::<Vec<_>>(),
"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::<BrokerRequest>(&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::<Value>(&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("<head-branch>");
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);
}
}