From ce1f7dc8c457e9d759dd8884aa616cd3810c5312 Mon Sep 17 00:00:00 2001 From: Codex Date: Thu, 21 May 2026 14:37:01 +0000 Subject: [PATCH] feat(auth-broker): register dry-run service surface --- config.json | 45 +++++ deploy.json | 10 ++ docker-compose.yml | 33 ++++ docs/reference/auth-broker.md | 15 ++ scripts/auth-broker-contract-test.ts | 92 +++++++++- scripts/src/artifact-registry.ts | 60 +++++++ scripts/src/auth-broker.ts | 246 ++++++++++++++++++++++++++- scripts/src/deploy.ts | 8 +- scripts/src/docker.ts | 8 + 9 files changed, 511 insertions(+), 6 deletions(-) diff --git a/config.json b/config.json index 57a7fed7..42b314a2 100644 --- a/config.json +++ b/config.json @@ -765,6 +765,51 @@ "mode": "internal-sidecar" } }, + { + "id": "auth-broker", + "name": "Auth Broker", + "providerId": "main-server", + "description": "Auth Broker 是主 server 私有鉴权代理 skeleton,为 Code Queue PR preflight 和 GitHub issue/PR dry-run 提供 broker-held credential ref 语义;当前只登记 source/contract/profile,不启用 live secret 或公网入口。", + "repository": { + "url": "https://github.com/pikasTech/unidesk", + "commitId": "local", + "dockerfile": "src/components/microservices/auth-broker/Dockerfile", + "composeFile": "docker-compose.yml", + "composeService": "auth-broker", + "containerName": "auth-broker-backend" + }, + "backend": { + "nodeBaseUrl": "http://auth-broker:4291", + "nodeBindHost": "auth-broker", + "nodePort": 4291, + "proxyMode": "unidesk-direct", + "frontendOnly": true, + "public": false, + "allowedMethods": [ + "GET", + "HEAD", + "POST" + ], + "allowedPathPrefixes": [ + "/health", + "/v1/github/" + ], + "healthPath": "/health", + "timeoutMs": 10000 + }, + "development": { + "providerId": "main-server", + "sshPassthrough": false, + "worktreePath": "/root/unidesk/src/components/microservices/auth-broker" + }, + "frontend": { + "route": "/apps/auth-broker", + "integrated": false + }, + "deployment": { + "mode": "internal-sidecar" + } + }, { "id": "mdtodo", "name": "MDTODO", diff --git a/deploy.json b/deploy.json index 89d3c030..383bf83f 100644 --- a/deploy.json +++ b/deploy.json @@ -68,6 +68,11 @@ "repo": "https://github.com/pikasTech/unidesk", "commitId": "fee1b1b710151d827749cc4b0662b1560cbe1fd6" }, + { + "id": "auth-broker", + "repo": "https://github.com/pikasTech/unidesk", + "commitId": "a6144ae71069d1467ccf452f53674b386978fc1d" + }, { "id": "mdtodo", "repo": "https://github.com/pikasTech/unidesk", @@ -224,6 +229,11 @@ "repo": "https://github.com/pikasTech/unidesk", "commitId": "22b02e7ce98a32647f8c3962dbf90aafabd53ff0" }, + { + "id": "auth-broker", + "repo": "https://github.com/pikasTech/unidesk", + "commitId": "a6144ae71069d1467ccf452f53674b386978fc1d" + }, { "id": "code-queue", "repo": "https://github.com/pikasTech/unidesk", diff --git a/docker-compose.yml b/docker-compose.yml index a2b140a7..d31e512b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -165,6 +165,39 @@ services: timeout: 3s retries: 20 + auth-broker: + image: auth-broker + profiles: + - auth-broker + build: + context: . + dockerfile: src/components/microservices/auth-broker/Dockerfile + container_name: auth-broker-backend + restart: "no" + expose: + - "4291" + environment: + HOST: "0.0.0.0" + PORT: "4291" + AUTH_BROKER_GITHUB_CONFIGURED: "${UNIDESK_AUTH_BROKER_GITHUB_CONFIGURED:-false}" + AUTH_BROKER_GITHUB_CREDENTIAL_REF: "${UNIDESK_AUTH_BROKER_GITHUB_CREDENTIAL_REF:-github:unidesk-dev}" + AUTH_BROKER_ALLOWED_REPOS: "${UNIDESK_AUTH_BROKER_ALLOWED_REPOS:-pikasTech/unidesk}" + AUTH_BROKER_AUDIT_LOG: "/var/log/unidesk/${UNIDESK_LOG_DAY}/${UNIDESK_LOG_PREFIX}_auth-broker.jsonl" + UNIDESK_DEPLOY_REF: "${UNIDESK_AUTH_BROKER_DEPLOY_REF:-deploy.json#environments.prod.services.auth-broker}" + UNIDESK_DEPLOY_SERVICE_ID: "${UNIDESK_AUTH_BROKER_DEPLOY_SERVICE_ID:-auth-broker}" + UNIDESK_DEPLOY_REPO: "${UNIDESK_AUTH_BROKER_DEPLOY_REPO:-}" + UNIDESK_DEPLOY_COMMIT: "${UNIDESK_AUTH_BROKER_DEPLOY_COMMIT:-}" + UNIDESK_DEPLOY_REQUESTED_COMMIT: "${UNIDESK_AUTH_BROKER_DEPLOY_REQUESTED_COMMIT:-}" + LOG_FILE: "/var/log/unidesk/${UNIDESK_LOG_DAY}/${UNIDESK_LOG_PREFIX}_auth-broker.jsonl" + UNIDESK_LOG_RETENTION_BYTES: "${UNIDESK_LOG_RETENTION_BYTES:-1GiB}" + volumes: + - ${UNIDESK_LOG_DIR}:/var/log/unidesk + healthcheck: + test: ["CMD", "auth-broker", "--healthcheck"] + interval: 30s + timeout: 3s + retries: 1 + todo-note: image: todo-note build: diff --git a/docs/reference/auth-broker.md b/docs/reference/auth-broker.md index a0f5184c..726f5b9a 100644 --- a/docs/reference/auth-broker.md +++ b/docs/reference/auth-broker.md @@ -36,10 +36,25 @@ 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` +- `config.json` microservice id `auth-broker` +- `deploy.json` prod/dev desired-state entries for `auth-broker` +- `docker-compose.yml` service `auth-broker` behind Compose profile `auth-broker` - `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. +## P1 Source Registration + +P1 keeps Auth Broker in source/contract/dry-run only: + +- `config.json` registers stable microservice id `auth-broker` on `main-server`, private backend `http://auth-broker:4291`, health path `/health`, and allowed proxy prefixes `/health` plus `/v1/github/`. +- `docker-compose.yml` defines service `auth-broker` with `profiles: ["auth-broker"]`, `restart: "no"`, no public `ports`, and redacted env names only. Default `server start` does not select this profile, so this source registration must not change current production runtime. +- `deploy.json` includes prod and dev desired-state entries so `deploy plan --env prod|dev --service auth-broker` has a stable identity. Live apply is supervisor-gated until credential mounting and private exposure are separately reviewed. +- `bun scripts/cli.ts auth-broker contract|health --dry-run|credential-request --dry-run|pr-preflight --dry-run` reports `serviceRegistration.config`, `serviceRegistration.compose`, `serviceRegistration.deploy`, and `serviceRegistration.runtimeCredentialRef` using presence/ref fields only. +- Runtime credential readiness is expressed by `UNIDESK_AUTH_BROKER_GITHUB_CONFIGURED` / `AUTH_BROKER_GITHUB_CONFIGURED` and `UNIDESK_AUTH_BROKER_GITHUB_CREDENTIAL_REF` / `AUTH_BROKER_GITHUB_CREDENTIAL_REF` presence. The CLI prints only the source key and a sanitized `github:` style preview, never a token or raw credential value. + +P1 still does not start Auth Broker, mount real secrets, deploy to prod/dev, restart backend-core/provider-gateway/Code Queue, or proxy registry/deploy credentials. + ### `GET /health` 只返回服务状态和 redacted capability,不返回 secret 值。 diff --git a/scripts/auth-broker-contract-test.ts b/scripts/auth-broker-contract-test.ts index 5aca5799..0d9cc6de 100644 --- a/scripts/auth-broker-contract-test.ts +++ b/scripts/auth-broker-contract-test.ts @@ -15,6 +15,9 @@ 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"; +const configPath = "config.json"; +const deployPath = "deploy.json"; +const composePath = "docker-compose.yml"; function assertCondition(condition: unknown, message: string, detail: unknown = {}): void { if (!condition) throw new Error(`${message}: ${JSON.stringify(detail)}`); @@ -30,10 +33,18 @@ function runCli(args: string[], env: Record = {}): P delete childEnv.GITHUB_TOKEN; delete childEnv.UNIDESK_AUTH_BROKER_URL; delete childEnv.AUTH_BROKER_URL; + delete childEnv.UNIDESK_AUTH_BROKER_GITHUB_CONFIGURED; + delete childEnv.AUTH_BROKER_GITHUB_CONFIGURED; + delete childEnv.UNIDESK_AUTH_BROKER_GITHUB_CREDENTIAL_REF; + delete childEnv.AUTH_BROKER_GITHUB_CREDENTIAL_REF; 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; + if (env.UNIDESK_AUTH_BROKER_GITHUB_CONFIGURED !== undefined) childEnv.UNIDESK_AUTH_BROKER_GITHUB_CONFIGURED = env.UNIDESK_AUTH_BROKER_GITHUB_CONFIGURED; + if (env.AUTH_BROKER_GITHUB_CONFIGURED !== undefined) childEnv.AUTH_BROKER_GITHUB_CONFIGURED = env.AUTH_BROKER_GITHUB_CONFIGURED; + if (env.UNIDESK_AUTH_BROKER_GITHUB_CREDENTIAL_REF !== undefined) childEnv.UNIDESK_AUTH_BROKER_GITHUB_CREDENTIAL_REF = env.UNIDESK_AUTH_BROKER_GITHUB_CREDENTIAL_REF; + if (env.AUTH_BROKER_GITHUB_CREDENTIAL_REF !== undefined) childEnv.AUTH_BROKER_GITHUB_CREDENTIAL_REF = env.AUTH_BROKER_GITHUB_CREDENTIAL_REF; return new Promise((resolve, reject) => { const child = spawn("bun", ["scripts/cli.ts", ...args], { cwd: process.cwd(), @@ -62,6 +73,47 @@ function dataOf(response: Record): Record { return response.data as Record; } +function asRecord(value: unknown, message: string): Record { + assertCondition(typeof value === "object" && value !== null && !Array.isArray(value), message, value); + return value as Record; +} + +function assertServiceRegistration(value: unknown): void { + const registration = asRecord(value, "serviceRegistration must be an object"); + const config = asRecord(registration.config, "serviceRegistration.config must be an object"); + assertCondition(config.ok === true, "auth-broker should be registered in config.json", config); + assertCondition(config.providerId === "main-server", "auth-broker config provider should be main-server", config); + assertCondition(config.composeService === "auth-broker", "auth-broker compose service should be stable", config); + assertCondition(config.containerName === "auth-broker-backend", "auth-broker container name should be stable", config); + assertCondition(config.public === false, "auth-broker must not be public", config); + const compose = asRecord(registration.compose, "serviceRegistration.compose must be an object"); + assertCondition(compose.servicePresent === true, "auth-broker compose service should exist", compose); + assertCondition(compose.profileGated === true, "auth-broker compose service should require auth-broker profile", compose); + assertCondition(compose.publicPortPublished === false, "auth-broker compose service must not publish a public port", compose); + assertCondition(compose.mutatesDefaultRuntime === false, "auth-broker compose registration must not mutate default runtime", compose); + const deploy = asRecord(registration.deploy, "serviceRegistration.deploy must be an object"); + assertCondition(deploy.ok === true, "auth-broker should be registered in deploy.json prod and dev", deploy); + const prod = asRecord(deploy.prod, "serviceRegistration.deploy.prod must be an object"); + const dev = asRecord(deploy.dev, "serviceRegistration.deploy.dev must be an object"); + assertCondition(prod.present === true && dev.present === true, "deploy.json should include auth-broker in prod and dev", deploy); + const runtimeCredentialRef = asRecord(registration.runtimeCredentialRef, "runtimeCredentialRef must be an object"); + assertCondition(runtimeCredentialRef.valuesRead === false && runtimeCredentialRef.valuesPrinted === false, "runtime credential ref must be presence-only", runtimeCredentialRef); +} + +function extractComposeServiceBlock(composeText: string, serviceName: string): string { + const lines = composeText.split("\n"); + const startLine = lines.findIndex((line) => line === ` ${serviceName}:`); + if (startLine < 0) return ""; + let endLine = lines.length; + for (let index = startLine + 1; index < lines.length; index += 1) { + if (/^ [A-Za-z0-9][A-Za-z0-9_-]*:$/u.test(lines[index] ?? "")) { + endLine = index; + break; + } + } + return lines.slice(startLine, endLine).join("\n"); +} + const requiredOperations = [ "github.auth.status", "github.issue.list", @@ -155,11 +207,15 @@ async function main(): Promise { for (const heading of ["## Existing Paths", "## API", "## Permission Boundary", "## Audit Fields", "## Failure Semantics", "## D601 Dev Acceptance"]) { assertDocContains(heading); } + assertDocContains("## P1 Source Registration"); 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", + "config.json", + "deploy.json", + "docker-compose.yml", "scripts/code-queue-pr-preflight-example.ts", "src/components/microservices/code-queue/src/runtime-preflight.ts", "scripts/src/code-queue.ts", @@ -183,18 +239,30 @@ async function main(): Promise { assertCondition(samplePreflightResponse.prCapabilityContract.preflightMergesPr === false, "P0 preflight must not merge PRs", samplePreflightResponse.prCapabilityContract); walk(samplePreflightResponse); - for (const path of [rustCargoPath, rustMainPath, cliAdapterPath]) { + for (const path of [rustCargoPath, rustMainPath, cliAdapterPath, configPath, deployPath, composePath]) { assertCondition(existsSync(path), `required auth broker implementation file is missing: ${path}`); } const rustMain = readFileSync(rustMainPath, "utf8"); const cliAdapter = readFileSync(cliAdapterPath, "utf8"); + const config = JSON.parse(readFileSync(configPath, "utf8")) as { microservices?: Array> }; + const deploy = JSON.parse(readFileSync(deployPath, "utf8")) as { environments?: Record> }> }; + const composeText = readFileSync(composePath, "utf8"); + const authBrokerComposeBlock = extractComposeServiceBlock(composeText, "auth-broker"); 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("--healthcheck"), "Rust skeleton should expose a process healthcheck mode", 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 configService = config.microservices?.find((item) => item.id === "auth-broker"); + assertCondition(configService !== undefined, "config.json should register auth-broker microservice", configPath); + assertCondition(asRecord(configService?.backend, "auth-broker backend should be object").public === false, "auth-broker backend must be private", configService); + assertCondition(deploy.environments?.prod?.services?.some((item) => item.id === "auth-broker") === true, "deploy.json prod should include auth-broker", deployPath); + assertCondition(deploy.environments?.dev?.services?.some((item) => item.id === "auth-broker") === true, "deploy.json dev should include auth-broker", deployPath); + assertCondition(authBrokerComposeBlock.includes(" auth-broker:") && authBrokerComposeBlock.includes("profiles:") && authBrokerComposeBlock.includes("- auth-broker"), "docker-compose should include auth-broker behind an explicit profile", authBrokerComposeBlock); + assertCondition(!/^\s{4}ports:/mu.test(authBrokerComposeBlock), "auth-broker compose service must not publish ports", authBrokerComposeBlock); 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 }); @@ -207,6 +275,7 @@ async function main(): Promise { assertCondition(noTokenAuthBroker.source === "broker/auth-broker-needed", "missing broker endpoint should expose broker-needed auth source", noTokenAuthBroker); assertCondition(noTokenAuthBroker.capability === "missing-token", "missing broker endpoint should expose missing-token capability", noTokenAuthBroker); assertCondition(noTokenAuthBroker.nextAction === "configure-auth-broker", "missing broker endpoint should expose next action", noTokenAuthBroker); + assertServiceRegistration(noTokenData.serviceRegistration); assertCondition(!noToken.stdout.includes("contract-secret-marker"), "missing-token response must not leak secret marker strings", noToken.stdout); const brokerReady = await runCli([ @@ -232,6 +301,7 @@ async function main(): Promise { const readyAuthBroker = readyData.authBroker as Record; const prCapability = readyData.prCapabilityContract as Record; const brokerProxy = prCapability.brokerProxy as Record; + assertServiceRegistration(readyData.serviceRegistration); assertCondition(readyAuthBroker.source === "auth-broker", "ready auth broker source should be auth-broker", readyAuthBroker); assertCondition(readyAuthBroker.capability === "broker-issued-token", "ready auth broker capability should be broker-issued-token", readyAuthBroker); assertCondition(readyAuthBroker.nextAction === "use-auth-broker", "ready auth broker next action should use broker", readyAuthBroker); @@ -247,6 +317,23 @@ async function main(): Promise { assertCondition(Array.isArray(brokerProxy.operations) && brokerProxy.operations.includes("github.pr.create"), "P0 broker proxy should include PR create dry-run operation", brokerProxy); walk(readyData); + const credentialRefPresence = await runCli( + ["auth-broker", "health", "--dry-run", "--endpoint", "http://127.0.0.1:4291"], + { + UNIDESK_AUTH_BROKER_GITHUB_CONFIGURED: "true", + UNIDESK_AUTH_BROKER_GITHUB_CREDENTIAL_REF: "github:contract-secret-marker", + }, + ); + assertCondition(credentialRefPresence.status === 0, "configured credential-ref presence dry-run should exit 0", credentialRefPresence); + const credentialRefData = dataOf(credentialRefPresence.json ?? {}); + const registration = asRecord(credentialRefData.serviceRegistration, "credential-ref health should include service registration"); + const runtimeCredentialRef = asRecord(registration.runtimeCredentialRef, "health registration should include runtimeCredentialRef"); + const credentialRef = asRecord(runtimeCredentialRef.credentialRef, "runtimeCredentialRef.credentialRef should be object"); + assertCondition(runtimeCredentialRef.ok === true, "credential ref presence should be ready when configured flag and ref key are present", runtimeCredentialRef); + assertCondition(credentialRef.valuePreview === "github:", "credential ref preview should be sanitized", credentialRef); + assertCondition(!credentialRefPresence.stdout.includes("contract-secret-marker"), "credential ref dry-run must not print the raw credential ref value", credentialRefPresence.stdout); + walk(credentialRefData); + process.stdout.write(`${JSON.stringify({ ok: true, docPath, @@ -254,6 +341,9 @@ async function main(): Promise { rustMainPath, rustCargoPath, cliAdapterPath, + configPath, + deployPath, + composePath, }, operations: requiredOperations, failureKinds: failureContracts.map((item) => item.failureKind), diff --git a/scripts/src/artifact-registry.ts b/scripts/src/artifact-registry.ts index 9b03881a..e473ffb0 100644 --- a/scripts/src/artifact-registry.ts +++ b/scripts/src/artifact-registry.ts @@ -111,6 +111,7 @@ const defaultOptions: ArtifactRegistryOptions = { deployJsonService: null, }; const supportedArtifactConsumerServices = [ + "auth-broker", "backend-core", "baidu-netdisk", "claudeqq", @@ -259,6 +260,43 @@ const baiduNetdiskAuthHealthGate: AuthHealthGate = { }; const artifactConsumerSpecs: Record = { + "auth-broker": { + serviceId: "auth-broker", + environment: "prod", + kind: "compose", + registryRepository: "unidesk/auth-broker", + dockerfile: "src/components/microservices/auth-broker/Dockerfile", + prodLiveApply: "supervisor-only", + prodLiveBlockReason: "auth-broker is registered for source/contract/profile dry-run only; live production apply requires a separate credential mounting and exposure review.", + devLiveApply: "supervisor-only", + devLiveBlockReason: "auth-broker DEV live apply requires explicit operator authorization after reviewing credential mounting, private exposure, and dry-run evidence.", + targets: { + dev: { + targetImage: "auth-broker", + targetCommitImage: (commit: string) => `auth-broker:${commit}`, + deployRef: "deploy.json#environments.dev.services.auth-broker", + compose: { + serviceName: "auth-broker", + containerName: "auth-broker-backend", + deployEnvPrefix: "UNIDESK_AUTH_BROKER_DEPLOY", + healthProbeCommand: "auth-broker --healthcheck", + requireHealthCommit: false, + }, + }, + prod: { + targetImage: "auth-broker", + targetCommitImage: (commit: string) => `auth-broker:${commit}`, + deployRef: "deploy.json#environments.prod.services.auth-broker", + compose: { + serviceName: "auth-broker", + containerName: "auth-broker-backend", + deployEnvPrefix: "UNIDESK_AUTH_BROKER_DEPLOY", + healthProbeCommand: "auth-broker --healthcheck", + requireHealthCommit: false, + }, + }, + }, + }, "backend-core": { serviceId: "backend-core", environment: "prod", @@ -1435,6 +1473,27 @@ function codeQueueMgrSelfBootstrapGuard(environment: ArtifactDeployEnvironment, }; } +function authBrokerCredentialMountGuard(environment: ArtifactDeployEnvironment, requiresSupervisorApproval: boolean): Record { + return { + check: "auth-broker-credential-mount-guard", + serviceId: "auth-broker", + requiresSupervisorApproval, + actorBoundary: "dry-run and contract evidence are allowed, but live broker startup needs explicit review of credential reference mounting and private exposure", + targetScope: "main-server Compose profile auth-broker / container auth-broker-backend only", + composeProfileRequired: "auth-broker", + publicPortAllowed: false, + registryCredentialsProxied: false, + deployCredentialsProxied: false, + environment, + forbiddenActions: [ + "write real GitHub token into config.json, deploy.json, or docker-compose.yml", + "publish auth-broker on a public port", + "restart backend-core, provider-gateway, or Code Queue as part of broker dry-run registration", + "grant registry, deploy, database, k3s, provider token, or host SSH permissions", + ], + }; +} + function artifactConsumerSelfBootstrapGuard( spec: ArtifactConsumerSpec, environment: ArtifactDeployEnvironment, @@ -1442,6 +1501,7 @@ function artifactConsumerSelfBootstrapGuard( ): Record | undefined { if (spec.serviceId === "code-queue") return codeQueueSelfBootstrapGuard(environment); if (spec.serviceId === "code-queue-mgr") return codeQueueMgrSelfBootstrapGuard(environment, requiresSupervisorApproval); + if (spec.serviceId === "auth-broker") return authBrokerCredentialMountGuard(environment, requiresSupervisorApproval); return undefined; } diff --git a/scripts/src/auth-broker.ts b/scripts/src/auth-broker.ts index c46dc9d9..6ebe9285 100644 --- a/scripts/src/auth-broker.ts +++ b/scripts/src/auth-broker.ts @@ -1,7 +1,13 @@ +import { readFileSync } from "node:fs"; +import { type UniDeskConfig, readConfig, rootPath } from "./config"; + 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 BROKER_CREDENTIAL_REF_ENV_KEYS = ["UNIDESK_AUTH_BROKER_GITHUB_CREDENTIAL_REF", "AUTH_BROKER_GITHUB_CREDENTIAL_REF"] as const; +const BROKER_CONFIGURED_ENV_KEYS = ["UNIDESK_AUTH_BROKER_GITHUB_CONFIGURED", "AUTH_BROKER_GITHUB_CONFIGURED"] as const; +const AUTH_BROKER_SERVICE_ID = "auth-broker"; const DEFAULT_CAPABILITIES = [ "github.auth.status", "github.issue.list", @@ -15,6 +21,7 @@ const DEFAULT_CAPABILITIES = [ type BrokerCommand = "contract" | "credential-request" | "pr-preflight" | "health"; type RunnerDisposition = "ready" | "infra-blocked" | "business-failed"; +type ConfigSource = "config.json" | "unavailable"; interface BrokerAdapterOptions { command: BrokerCommand; @@ -27,10 +34,76 @@ interface BrokerAdapterOptions { issueNumber: number | null; } +interface AuthBrokerServiceRegistration { + ok: boolean; + serviceId: "auth-broker"; + source: ConfigSource; + configured: boolean; + providerId: string | null; + deploymentMode: string | null; + proxyMode: string | null; + backendBaseUrl: string | null; + healthPath: string | null; + allowedPathPrefixes: string[]; + composeService: string | null; + containerName: string | null; + dockerfile: string | null; + public: boolean | null; + error: string | null; +} + +interface ComposeProfileRegistration { + ok: boolean; + source: "docker-compose.yml"; + serviceId: "auth-broker"; + servicePresent: boolean; + profileGated: boolean; + profiles: string[]; + restart: string | null; + publicPortPublished: boolean; + exposesPort: boolean; + mutatesDefaultRuntime: false; +} + +interface DeployManifestRegistration { + ok: boolean; + source: "deploy.json"; + serviceId: "auth-broker"; + prod: { present: boolean; commitId: string | null }; + dev: { present: boolean; commitId: string | null }; + dryRunOnly: true; +} + +interface RuntimeCredentialRefPresence { + ok: boolean; + source: string | null; + configuredFlag: { + present: boolean; + key: string | null; + truthy: boolean; + }; + credentialRef: { + present: boolean; + key: string | null; + valuePrinted: false; + valuePreview: string | null; + }; + presenceOnly: true; + valuesRead: false; + valuesPrinted: false; +} + function hasEnvKey(name: string): boolean { return Object.prototype.hasOwnProperty.call(process.env, name); } +function firstPresentEnvKey(keys: readonly string[]): string | null { + for (const key of keys) { + if (hasEnvKey(key)) return key; + } + return null; +} + function stringOption(args: string[], name: string): string | undefined { const index = args.indexOf(name); if (index === -1) return undefined; @@ -52,6 +125,14 @@ function sanitizeEndpoint(value: string): string { } } +function sanitizeCredentialRef(value: string): string { + const trimmed = value.trim(); + if (trimmed.length === 0) return ""; + const separator = trimmed.indexOf(":"); + if (separator <= 0) return ""; + return `${trimmed.slice(0, separator)}:`; +} + function numberOption(args: string[], name: string): number | null { const raw = stringOption(args, name); if (raw === undefined) return null; @@ -67,6 +148,160 @@ function firstConfiguredBrokerUrl(): string | null { return null; } +function envTruthy(key: string | null): boolean { + if (key === null) return false; + const value = process.env[key]?.trim().toLowerCase(); + return value === "1" || value === "true" || value === "yes" || value === "on"; +} + +function runtimeCredentialRefPresence(): RuntimeCredentialRefPresence { + const configuredKey = firstPresentEnvKey(BROKER_CONFIGURED_ENV_KEYS); + const refKey = firstPresentEnvKey(BROKER_CREDENTIAL_REF_ENV_KEYS); + return { + ok: configuredKey !== null && envTruthy(configuredKey) && refKey !== null, + source: refKey === null ? null : "broker-held-github-credential-ref", + configuredFlag: { + present: configuredKey !== null, + key: configuredKey, + truthy: envTruthy(configuredKey), + }, + credentialRef: { + present: refKey !== null, + key: refKey, + valuePrinted: false, + valuePreview: refKey === null ? null : sanitizeCredentialRef(process.env[refKey] ?? ""), + }, + presenceOnly: true, + valuesRead: false, + valuesPrinted: false, + }; +} + +function readConfigRegistration(): AuthBrokerServiceRegistration { + let config: UniDeskConfig; + try { + config = readConfig(); + } catch (error) { + return { + ok: false, + serviceId: AUTH_BROKER_SERVICE_ID, + source: "unavailable", + configured: false, + providerId: null, + deploymentMode: null, + proxyMode: null, + backendBaseUrl: null, + healthPath: null, + allowedPathPrefixes: [], + composeService: null, + containerName: null, + dockerfile: null, + public: null, + error: error instanceof Error ? error.message : String(error), + }; + } + const service = config.microservices.find((item) => item.id === AUTH_BROKER_SERVICE_ID); + return { + ok: service !== undefined, + serviceId: AUTH_BROKER_SERVICE_ID, + source: "config.json", + configured: service !== undefined, + providerId: service?.providerId ?? null, + deploymentMode: service?.deployment.mode ?? null, + proxyMode: service?.backend.proxyMode ?? null, + backendBaseUrl: service?.backend.nodeBaseUrl ?? null, + healthPath: service?.backend.healthPath ?? null, + allowedPathPrefixes: service?.backend.allowedPathPrefixes ?? [], + composeService: service?.repository.composeService ?? null, + containerName: service?.repository.containerName ?? null, + dockerfile: service?.repository.dockerfile ?? null, + public: service?.backend.public ?? null, + error: service === undefined ? "auth-broker is not registered in config.json microservices" : null, + }; +} + +function extractComposeServiceBlock(composeText: string, serviceName: string): string { + const lines = composeText.split("\n"); + const startLine = lines.findIndex((line) => line === ` ${serviceName}:`); + if (startLine < 0) return ""; + let endLine = lines.length; + for (let index = startLine + 1; index < lines.length; index += 1) { + if (/^ [A-Za-z0-9][A-Za-z0-9_-]*:$/u.test(lines[index] ?? "")) { + endLine = index; + break; + } + } + return lines.slice(startLine, endLine).join("\n"); +} + +function composeProfileRegistration(): ComposeProfileRegistration { + const composeText = readFileSync(rootPath("docker-compose.yml"), "utf8"); + const block = extractComposeServiceBlock(composeText, AUTH_BROKER_SERVICE_ID); + const blockLines = block.split("\n"); + const profiles: string[] = []; + const profileStart = blockLines.findIndex((line) => /^\s{4}profiles:\s*$/u.test(line)); + if (profileStart >= 0) { + for (let index = profileStart + 1; index < blockLines.length; index += 1) { + const line = blockLines[index] ?? ""; + if (/^\s{4}[A-Za-z0-9_-]+:/u.test(line)) break; + const profile = line.match(/^\s{6}-\s+"?([A-Za-z0-9_.-]+)"?\s*$/u)?.[1]; + if (profile !== undefined) profiles.push(profile); + } + } + const restart = block.match(/^\s{4}restart:\s+"?([^"\n]+)"?\s*$/mu)?.[1] ?? null; + const portsSection = /^\s{4}ports:\s*$/mu.test(block); + const portMapping = /^\s{6}-\s+["']?[^"'\n]*4291:/mu.test(block); + return { + ok: block.length > 0 && profiles.includes(AUTH_BROKER_SERVICE_ID) && !portsSection && !portMapping, + source: "docker-compose.yml", + serviceId: AUTH_BROKER_SERVICE_ID, + servicePresent: block.length > 0, + profileGated: profiles.includes(AUTH_BROKER_SERVICE_ID), + profiles, + restart, + publicPortPublished: portsSection || portMapping, + exposesPort: block.includes('"4291"'), + mutatesDefaultRuntime: false, + }; +} + +function serviceCommitFromDeployJson(environment: "prod" | "dev"): string | null { + const parsed = JSON.parse(readFileSync(rootPath("deploy.json"), "utf8")) as { + environments?: Record }>; + }; + const service = parsed.environments?.[environment]?.services?.find((item) => item.id === AUTH_BROKER_SERVICE_ID); + return typeof service?.commitId === "string" ? service.commitId : null; +} + +function deployManifestRegistration(): DeployManifestRegistration { + const prodCommit = serviceCommitFromDeployJson("prod"); + const devCommit = serviceCommitFromDeployJson("dev"); + return { + ok: prodCommit !== null && devCommit !== null, + source: "deploy.json", + serviceId: AUTH_BROKER_SERVICE_ID, + prod: { present: prodCommit !== null, commitId: prodCommit }, + dev: { present: devCommit !== null, commitId: devCommit }, + dryRunOnly: true, + }; +} + +function serviceRegistrationContract(): Record { + return { + config: readConfigRegistration(), + compose: composeProfileRegistration(), + deploy: deployManifestRegistration(), + runtimeCredentialRef: runtimeCredentialRefPresence(), + defaultRuntimeMutation: { + ok: true, + mutatesCurrentProd: false, + composeProfileRequired: AUTH_BROKER_SERVICE_ID, + publicPortPublished: false, + liveDeployPerformed: false, + }, + }; +} + function parseOptions(args: string[]): BrokerAdapterOptions { const rawCommand = args[0] ?? "contract"; if (!["contract", "credential-request", "pr-preflight", "health"].includes(rawCommand)) { @@ -101,11 +336,14 @@ function runnerEnvTokenCoverage(): Record { } function brokerCoverage(endpoint: string | null): Record { + const credential = runtimeCredentialRefPresence(); return { ok: endpoint !== null, source: endpoint === null ? null : "auth-broker", endpoint: endpoint ?? null, - credentialRef: endpoint === null ? null : "github:unidesk-dev", + credentialRef: endpoint === null ? null : credential.credentialRef.valuePreview ?? "github:", + credentialRefPresent: credential.credentialRef.present, + credentialRefSourceKey: credential.credentialRef.key, scope: endpoint === null ? null : "broker-held-github-credential", runnerEnvTokenRequired: false, valuesRead: false, @@ -124,6 +362,7 @@ function brokerNeededResult(options: BrokerAdapterOptions): Record ? { base: options.base, head: options.head, issueNumber: options.issueNumber } : null, dryRun: true, - credentialRef: "github:unidesk-dev", + credentialRef: "github:", credentialKind: "github-rest-token-ref", credentialValuePrinted: false, upstream: { method: "planned", path: "planned GitHub REST path without query secrets" }, @@ -218,6 +457,7 @@ function readyContract(options: BrokerAdapterOptions): Record { brokerCoverage: brokerCoverage(options.endpoint), credentialRequest: plannedCredentialRequest(options), auditEventShape: auditEventShape(options), + serviceRegistration: serviceRegistrationContract(), prCapabilityContract: { targetBranch: options.base, headBranch: options.head, @@ -263,6 +503,7 @@ function contractResult(options: BrokerAdapterOptions): Record registryCredentials: false, deployPermissions: false, }, + serviceRegistration: serviceRegistrationContract(), runnerNoTokenResult: brokerNeededResult({ ...options, endpoint: null }), readyShape: readyContract({ ...options, endpoint: "" }), }; @@ -305,6 +546,7 @@ export function runAuthBrokerCommand(args: string[]): Record { phase: "p0", brokerCoverage: broker, tokenCoverage: envCoverage, + serviceRegistration: serviceRegistrationContract(), healthRequest: { method: "GET", path: "/health", diff --git a/scripts/src/deploy.ts b/scripts/src/deploy.ts index bba25667..14d79203 100644 --- a/scripts/src/deploy.ts +++ b/scripts/src/deploy.ts @@ -149,16 +149,18 @@ const nativeK3sCtrAddress = "/run/k3s/containerd/containerd.sock"; const unideskRepoUrl = "https://github.com/pikasTech/unidesk"; const d601MaintenanceDeployAllowedServiceIds = new Set(["k3sctl-adapter"]); const devApplySupportedServiceIds = new Set(); -const devArtifactConsumerServiceIds = new Set(["backend-core", "baidu-netdisk", "claudeqq", "code-queue", "code-queue-mgr", "decision-center", "findjob", "frontend", "mdtodo", "met-nonlinear", "oa-event-flow", "pipeline", "project-manager", "todo-note"]); +const devArtifactConsumerServiceIds = new Set(["auth-broker", "backend-core", "baidu-netdisk", "claudeqq", "code-queue", "code-queue-mgr", "decision-center", "findjob", "frontend", "mdtodo", "met-nonlinear", "oa-event-flow", "pipeline", "project-manager", "todo-note"]); const devArtifactConsumerProdDesiredFallbackServiceIds = new Set(["code-queue-mgr", "oa-event-flow", "project-manager", "todo-note"]); -const prodArtifactConsumerServiceIds = new Set(["backend-core", "baidu-netdisk", "claudeqq", "code-queue-mgr", "decision-center", "findjob", "frontend", "k3sctl-adapter", "mdtodo", "met-nonlinear", "oa-event-flow", "pipeline", "project-manager", "todo-note"]); +const prodArtifactConsumerServiceIds = new Set(["auth-broker", "backend-core", "baidu-netdisk", "claudeqq", "code-queue-mgr", "decision-center", "findjob", "frontend", "k3sctl-adapter", "mdtodo", "met-nonlinear", "oa-event-flow", "pipeline", "project-manager", "todo-note"]); const prodForbiddenTargetSideBuildServiceIds = new Set(["backend-core", "baidu-netdisk", "claudeqq", "decision-center", "findjob", "frontend", "k3sctl-adapter", "mdtodo", "met-nonlinear", "pipeline"]); const prodArtifactLiveApplyBlockedServiceIds = new Map([ + ["auth-broker", "auth-broker is registered for source/contract/profile dry-run only; live production apply requires a separate credential mounting and exposure review."], ["code-queue-mgr", "code-queue-mgr is the main-server Code Queue control-plane sidecar; live production apply requires explicit supervisor confirmation."], ["met-nonlinear", "met-nonlinear is blocked for live artifact deploy because config.json points at docker/unidesk/Dockerfile.ml while the compose service is met-nonlinear-ts."], ["k3sctl-adapter", "k3sctl-adapter is an infrastructure control bridge; this executor exposes artifact consumer plan/dry-run only. Real production deployment requires supervisor confirmation outside this task."], ]); const devArtifactLiveApplyBlockedServiceIds = new Map([ + ["auth-broker", "auth-broker is registered for source/contract/profile dry-run only; live DEV apply requires a separate credential mounting and exposure review."], ["code-queue", "Code Queue DEV live apply is self-bootstrap sensitive: a running Code Queue task may produce dry-run evidence only. A human operator or supervisor must explicitly authorize DEV apply outside Code Queue after reviewing the CI artifact digest and dry-run target list."], ]); const artifactConsumerDryRunBlockedServiceIds = new Map([ @@ -3538,7 +3540,7 @@ export async function runDeployCommand(config: UniDeskConfig | null, args: strin } const unsupported = unsupportedDevApplyServices(manifest, options.serviceId); if (unsupported.length > 0) { - throw new Error(`deploy apply --env dev currently supports backend-core/frontend/baidu-netdisk/decision-center/mdtodo/claudeqq/code-queue/project-manager/oa-event-flow/code-queue-mgr/todo-note/findjob/pipeline/met-nonlinear artifact consumers; unsupported selected services: ${unsupported.join(", ")}. Use ci run-dev-e2e for smoke verification.`); + throw new Error(`deploy apply --env dev currently supports auth-broker/backend-core/frontend/baidu-netdisk/decision-center/mdtodo/claudeqq/code-queue/project-manager/oa-event-flow/code-queue-mgr/todo-note/findjob/pipeline/met-nonlinear artifact consumers; unsupported selected services: ${unsupported.join(", ")}. Use ci run-dev-e2e for smoke verification.`); } const devArtifactServices = selectedDevArtifactServicesWithProdFallback(manifest, options.serviceId); const devTargetServices = selectedDevTargetServices(manifest, options.serviceId); diff --git a/scripts/src/docker.ts b/scripts/src/docker.ts index 86260631..5cf5bdfb 100644 --- a/scripts/src/docker.ts +++ b/scripts/src/docker.ts @@ -181,6 +181,14 @@ export function writeComposeEnv(config: UniDeskConfig, freshLogPrefix: boolean): UNIDESK_CODE_QUEUE_MGR_DEPLOY_REPO: runtimeSecret("UNIDESK_CODE_QUEUE_MGR_DEPLOY_REPO"), UNIDESK_CODE_QUEUE_MGR_DEPLOY_COMMIT: runtimeSecret("UNIDESK_CODE_QUEUE_MGR_DEPLOY_COMMIT"), UNIDESK_CODE_QUEUE_MGR_DEPLOY_REQUESTED_COMMIT: runtimeSecret("UNIDESK_CODE_QUEUE_MGR_DEPLOY_REQUESTED_COMMIT"), + UNIDESK_AUTH_BROKER_DEPLOY_REF: runtimeSecret("UNIDESK_AUTH_BROKER_DEPLOY_REF"), + UNIDESK_AUTH_BROKER_DEPLOY_SERVICE_ID: runtimeSecret("UNIDESK_AUTH_BROKER_DEPLOY_SERVICE_ID") || "auth-broker", + UNIDESK_AUTH_BROKER_DEPLOY_REPO: runtimeSecret("UNIDESK_AUTH_BROKER_DEPLOY_REPO"), + UNIDESK_AUTH_BROKER_DEPLOY_COMMIT: runtimeSecret("UNIDESK_AUTH_BROKER_DEPLOY_COMMIT"), + UNIDESK_AUTH_BROKER_DEPLOY_REQUESTED_COMMIT: runtimeSecret("UNIDESK_AUTH_BROKER_DEPLOY_REQUESTED_COMMIT"), + UNIDESK_AUTH_BROKER_GITHUB_CONFIGURED: runtimeSecret("UNIDESK_AUTH_BROKER_GITHUB_CONFIGURED") || "false", + UNIDESK_AUTH_BROKER_GITHUB_CREDENTIAL_REF: runtimeSecret("UNIDESK_AUTH_BROKER_GITHUB_CREDENTIAL_REF") || "github:unidesk-dev", + UNIDESK_AUTH_BROKER_ALLOWED_REPOS: runtimeSecret("UNIDESK_AUTH_BROKER_ALLOWED_REPOS") || "pikasTech/unidesk", UNIDESK_TODO_NOTE_DEPLOY_REF: runtimeSecret("UNIDESK_TODO_NOTE_DEPLOY_REF"), UNIDESK_TODO_NOTE_DEPLOY_SERVICE_ID: runtimeSecret("UNIDESK_TODO_NOTE_DEPLOY_SERVICE_ID") || "todo-note", UNIDESK_TODO_NOTE_DEPLOY_REPO: runtimeSecret("UNIDESK_TODO_NOTE_DEPLOY_REPO"),