From db9b5de68f75b74698d405fa54abdc0232036093 Mon Sep 17 00:00:00 2001 From: Codex Date: Fri, 29 May 2026 13:54:01 +0800 Subject: [PATCH] feat: add v02 first admin setup --- docs/reference/spec-user-access.md | 1 + internal/cloud/access-control.test.ts | 49 ++++++++++++++++++++++ internal/cloud/access-control.ts | 58 ++++++++++++++++++++++++++- internal/cloud/server.ts | 5 +++ 4 files changed, 112 insertions(+), 1 deletion(-) diff --git a/docs/reference/spec-user-access.md b/docs/reference/spec-user-access.md index 839d9acf..01497d1c 100644 --- a/docs/reference/spec-user-access.md +++ b/docs/reference/spec-user-access.md @@ -272,6 +272,7 @@ code agent prompt、runner 或 worker 不得直接绕过 cloud-api 调用 device | `POST /auth/login` | 校验本地账号并写入 `user_sessions` token hash。 | | `GET /auth/session` | 从 cookie 恢复 actor、role 和 session 状态。 | | `GET /v1/auth/session`、`GET /v1/users/me`、`GET /v1/access/status`、`GET /v1/setup/status` | REST 状态和兼容入口;不得读取或返回 password hash、session token 原文或 Secret 值。 | +| `POST /v1/setup/first-admin` | 仅当 `users` 表为空时创建第一个 `admin` 并建立 session;一旦已有用户必须返回 `409 setup_already_completed`。该入口不读取 Kubernetes Secret,不替代正常 admin API。 | | `POST /auth/logout` | 设置 `revoked_at`,撤销当前 browser session。 | | `POST /v1/admin/users` | admin 创建用户,响应不得返回 `password_hash` 或 token。 | | `POST /v1/admin/device-pods`、`PUT /v1/admin/device-pods/{devicePodId}` | admin 管理 device pod profile authority。 | diff --git a/internal/cloud/access-control.test.ts b/internal/cloud/access-control.test.ts index 787d6504..a8923403 100644 --- a/internal/cloud/access-control.test.ts +++ b/internal/cloud/access-control.test.ts @@ -572,6 +572,55 @@ test("cloud api exposes v1 access status routes and returns structured REST erro } }); +test("cloud api first-admin setup opens access when bootstrap secret is absent", async () => { + const server = createCloudApiServer({ + env: { HWLAB_ACCESS_CONTROL_REQUIRED: "1" }, + now: () => "2026-05-28T00:00:00.000Z" + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + + try { + const { port } = server.address(); + const before = await getJson(port, "/v1/setup/status"); + assert.equal(before.status, 200); + assert.equal(before.body.setupRequired, true); + assert.equal(before.body.bootstrapAdminConfigured, false); + assert.equal(before.body.routes.firstAdminSetup, "/v1/setup/first-admin"); + + const setup = await postJson(port, "/v1/setup/first-admin", { + username: "admin", + password: "admin-pass", + displayName: "Initial Admin" + }); + assert.equal(setup.status, 201); + assert.equal(setup.body.created, true); + assert.equal(setup.body.authenticated, true); + assert.equal(setup.body.actor.role, "admin"); + assert.equal(setup.body.actor.username, "admin"); + assert.equal(setup.body.setupRequired, false); + assert.equal(JSON.stringify(setup.body).includes("admin-pass"), false); + assert.equal(typeof setup.cookie, "string"); + + const session = await getJson(port, "/v1/users/me", setup.cookie); + assert.equal(session.status, 200); + assert.equal(session.body.actor.role, "admin"); + assert.equal(JSON.stringify(session.body).includes("passwordHash"), false); + + const second = await postJson(port, "/v1/setup/first-admin", { + username: "other-admin", + password: "other-pass" + }); + assert.equal(second.status, 409); + assert.equal(second.body.error.code, "setup_already_completed"); + + const login = await postJson(port, "/auth/login", { username: "admin", password: "admin-pass" }); + assert.equal(login.status, 200); + assert.equal(login.body.actor.role, "admin"); + } finally { + await new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve()))); + } +}); + async function postJson(port, path, body, cookie = null, extraHeaders = {}) { const response = await fetch(`http://127.0.0.1:${port}${path}`, { method: "POST", diff --git a/internal/cloud/access-control.ts b/internal/cloud/access-control.ts index 70feca55..7f64d5cb 100644 --- a/internal/cloud/access-control.ts +++ b/internal/cloud/access-control.ts @@ -206,6 +206,51 @@ class AccessController { } } + async handleSetupRoute(request, response, url) { + try { + await this.ensureBootstrap(); + if (request.method !== "POST" || url.pathname !== "/v1/setup/first-admin") { + return sendJson(response, 404, errorPayload("not_found", "Setup route is not implemented", 404)); + } + + if (!(await this.setupRequired())) { + return sendJson(response, 409, errorPayload("setup_already_completed", "First admin setup is closed because at least one user already exists", 409)); + } + + const body = await jsonBody(request); + const password = requiredText(body.password, "password"); + const now = this.now(); + const user = await this.store.createFirstAdmin?.({ + id: this.env.HWLAB_BOOTSTRAP_ADMIN_ID || "usr_bootstrap_admin", + username: textOr(body.username, this.env.HWLAB_BOOTSTRAP_ADMIN_USERNAME || "admin"), + displayName: textOr(body.displayName, this.env.HWLAB_BOOTSTRAP_ADMIN_DISPLAY_NAME || "HWLAB Admin"), + passwordHash: hashPassword(password), + now + }) ?? null; + if (!user) { + return sendJson(response, 409, errorPayload("setup_already_completed", "First admin setup is closed because at least one user already exists", 409)); + } + const token = randomBytes(32).toString("base64url"); + const session = await this.store.createSession({ + userId: user.id, + tokenHash: sha256(token), + now, + expiresAt: new Date(Date.parse(now) + SESSION_MAX_AGE_SECONDS * 1000).toISOString() + }); + setSessionCookie(response, token, SESSION_MAX_AGE_SECONDS); + return sendJson(response, 201, { + created: true, + authenticated: true, + actor: publicActor(user), + session: publicSession({ ...session, user }), + setupRequired: false, + contractVersion: "user-access-v1" + }); + } catch (error) { + sendAccessError(response, error); + } + } + async sessionPayload(request) { const auth = await this.authenticate(request, { required: false }); return { @@ -928,6 +973,10 @@ class MemoryAccessStore { this.users.set(user.id, user); return user; } + async createFirstAdmin(input) { + if (this.users.size > 0) return null; + return this.createUser({ ...input, role: "admin", status: "active" }); + } async createSession(input) { const session = { id: `uss_${randomUUID()}`, userId: input.userId, tokenHash: input.tokenHash, createdAt: input.now, lastSeenAt: input.now, expiresAt: input.expiresAt, revokedAt: null }; this.sessions.set(session.id, session); @@ -1015,6 +1064,13 @@ class PostgresAccessStore extends MemoryAccessStore { const result = await this.query("INSERT INTO users (id, username, display_name, role, status, password_hash, created_at, updated_at) VALUES ($1,$2,$3,$4,$5,$6,$7,$8) ON CONFLICT (username) DO UPDATE SET display_name = EXCLUDED.display_name, role = EXCLUDED.role, status = EXCLUDED.status, password_hash = EXCLUDED.password_hash, updated_at = EXCLUDED.updated_at RETURNING id, username, display_name, role, status, password_hash, created_at, updated_at", [user.id, user.username, user.displayName, user.role, user.status, user.passwordHash, user.createdAt, user.updatedAt]); return pgUser(result.rows?.[0]); } + async createFirstAdmin(input) { + await this.ensureSchema(); + const now = input.now ?? this.now(); + const user = { id: input.id, username: input.username, displayName: input.displayName ?? "", role: "admin", status: "active", passwordHash: input.passwordHash ?? null, createdAt: now, updatedAt: now }; + const result = await this.query("INSERT INTO users (id, username, display_name, role, status, password_hash, created_at, updated_at) SELECT $1,$2,$3,$4,$5,$6,$7,$8 WHERE NOT EXISTS (SELECT 1 FROM users) ON CONFLICT DO NOTHING RETURNING id, username, display_name, role, status, password_hash, created_at, updated_at", [user.id, user.username, user.displayName, user.role, user.status, user.passwordHash, user.createdAt, user.updatedAt]); + return pgUser(result.rows?.[0]); + } async createSession(input) { await this.ensureSchema(); const session = await super.createSession(input); await this.query("INSERT INTO user_sessions (id, user_id, session_token_hash, created_at, last_seen_at, expires_at, revoked_at) VALUES ($1,$2,$3,$4,$5,$6,$7)", [session.id, session.userId, session.tokenHash, session.createdAt, session.lastSeenAt, session.expiresAt, session.revokedAt]); return session; } async findSessionByTokenHash(tokenHash, now) { await this.ensureSchema(); const result = await this.query("SELECT s.id, s.user_id, s.session_token_hash, s.created_at, s.last_seen_at, s.expires_at, s.revoked_at, u.username, u.display_name, u.role, u.status, u.password_hash, u.created_at AS user_created_at, u.updated_at AS user_updated_at FROM user_sessions s JOIN users u ON u.id = s.user_id WHERE s.session_token_hash = $1 AND s.revoked_at IS NULL AND s.expires_at > $2 LIMIT 1", [tokenHash, now]); const row = result.rows?.[0]; return row ? pgSession(row) : null; } async touchSession(id, now) { await this.ensureSchema(); await this.query("UPDATE user_sessions SET last_seen_at = $2 WHERE id = $1", [id, now]); } @@ -1091,7 +1147,7 @@ function sortJson(value) { if (Array.isArray(value)) return value.map(sortJson); function profileHash(profile) { return `sha256:${sha256(stableJson(profile))}`; } function grantKey(devicePodId, userId) { return `${devicePodId}\u0000${userId}`; } function authoritySource() { return { kind: "CLOUD_API_PROFILE_AUTHORITY", serviceId: CLOUD_API_SERVICE_ID, fake: false, devLiveEvidence: false, note: "Profile, grant, lease, and job authority are owned by hwlab-cloud-api; this is not fake data." }; } -function accessRoutes() { return { session: "/auth/session", login: "/auth/login", logout: "/auth/logout", v1Session: "/v1/auth/session", currentUser: "/v1/users/me", accessStatus: "/v1/access/status", setupStatus: "/v1/setup/status", devicePods: "/v1/device-pods" }; } +function accessRoutes() { return { session: "/auth/session", login: "/auth/login", logout: "/auth/logout", v1Session: "/v1/auth/session", currentUser: "/v1/users/me", accessStatus: "/v1/access/status", setupStatus: "/v1/setup/status", firstAdminSetup: "/v1/setup/first-admin", devicePods: "/v1/device-pods" }; } function devicePodExecutorBlocker(summary) { return { code: "device_pod_executor_unavailable", layer: "device-pod", retryable: true, summary, userMessage: "Device Pod 已授权,但内部执行服务当前不可用。" }; } function gatewayDispatchBlocker(summary) { return { code: "gateway_dispatch_unavailable", layer: "device-pod", retryable: true, summary, userMessage: "Device Pod 已授权,但当前没有可用 gateway/device-host-cli 执行通道。" }; } function normalizeDeviceJobStatus(status, httpStatus) { const text = textOr(status, ""); return ["queued", "running", "completed", "failed", "blocked", "canceled"].includes(text) ? text : httpStatus >= 400 ? "blocked" : "running"; } diff --git a/internal/cloud/server.ts b/internal/cloud/server.ts index 15f1b4a3..2bc9518f 100644 --- a/internal/cloud/server.ts +++ b/internal/cloud/server.ts @@ -307,6 +307,11 @@ async function handleRestAdapter(request, response, url, options) { return; } + if (url.pathname === "/v1/setup/first-admin") { + await options.accessController.handleSetupRoute(request, response, url); + return; + } + if (url.pathname.startsWith("/v1/admin/")) { await options.accessController.handleAdminRoute(request, response, url); return;