feat: add v02 first admin setup

This commit is contained in:
Codex
2026-05-29 13:54:01 +08:00
parent 138d4a8cfb
commit db9b5de68f
4 changed files with 112 additions and 1 deletions
+1
View File
@@ -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。 |
+49
View File
@@ -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",
+57 -1
View File
@@ -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"; }
+5
View File
@@ -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;