feat: add auth broker p0 skeleton
This commit is contained in:
@@ -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 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 高风险审批边界。
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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());
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user