diff --git a/docs/openapi.yaml b/docs/openapi.yaml index 01e9f13..e383bc5 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -3542,7 +3542,11 @@ paths: '401': $ref: '#/components/responses/Unauthorized' '403': - $ref: '#/components/responses/Forbidden' + description: >- + Not an owner (forbidden); a change to the caller's own role (self_protected); or a role change on the owner account (owner_protected), which only the host's break-glass console (sudo felis breakGlass) may make. + content: + application/json: + schema: { $ref: '#/components/schemas/Error' } '404': $ref: '#/components/responses/NotFound' '409': @@ -3572,7 +3576,11 @@ paths: '401': $ref: '#/components/responses/Unauthorized' '403': - $ref: '#/components/responses/Forbidden' + description: >- + Not an owner (forbidden); the caller's own account (self_protected); or the owner account (owner_protected), which only the host's break-glass console (sudo felis breakGlass) may remove. + content: + application/json: + schema: { $ref: '#/components/schemas/Error' } '404': $ref: '#/components/responses/NotFound' @@ -3612,7 +3620,11 @@ paths: '401': $ref: '#/components/responses/Unauthorized' '403': - $ref: '#/components/responses/Forbidden' + description: >- + Not an owner (forbidden); the caller's own account (self_protected); or disabling the owner account (owner_protected). Re-enabling the owner is allowed. + content: + application/json: + schema: { $ref: '#/components/schemas/Error' } '404': $ref: '#/components/responses/NotFound' diff --git a/internal/api/handlers_users.go b/internal/api/handlers_users.go index d3c28f6..3b264ce 100644 --- a/internal/api/handlers_users.go +++ b/internal/api/handlers_users.go @@ -114,6 +114,15 @@ type patchUserRequest struct { } // handlePatchUser is the admin-tier patch-user endpoint (PATCH /users/{id}). +// The two refusals an admin meets on the user page get codes of their own, so +// the panel can say why instead of a bare "not allowed": acting on your own +// account (a slip that would lock you out), and changing the owner account, +// which only the local break-glass console (sudo felis breakGlass) may do. +const ( + codeSelfProtected = "self_protected" + codeOwnerProtected = "owner_protected" +) + func (a *API) handlePatchUser(w http.ResponseWriter, r *http.Request) { p := principalFromContext(r.Context()) id := r.PathValue("id") @@ -137,7 +146,7 @@ func (a *API) handlePatchUser(w http.ResponseWriter, r *http.Request) { // Self-demotion guard: an admin/owner may edit their own email or username, // but must never downgrade themselves to a lower role. if body.Role != nil && id == p.UserID && *body.Role != p.Role { - writeError(w, r, newError(http.StatusForbidden, "forbidden", + writeError(w, r, newError(http.StatusForbidden, codeSelfProtected, "cannot change your own role")) return } @@ -148,7 +157,7 @@ func (a *API) handlePatchUser(w http.ResponseWriter, r *http.Request) { // UpdateUser then answers the real 404. if body.Role != nil && *body.Role != "owner" { if d, err := a.Repo.UserDetail(r.Context(), id); err == nil && d.Role == "owner" { - writeError(w, r, newError(http.StatusForbidden, "forbidden", + writeError(w, r, newError(http.StatusForbidden, codeOwnerProtected, "the owner account's role cannot be changed from the panel")) return } @@ -195,7 +204,7 @@ func (a *API) handleDeleteUser(w http.ResponseWriter, r *http.Request) { } if id == p.UserID { - writeError(w, r, newError(http.StatusForbidden, "forbidden", + writeError(w, r, newError(http.StatusForbidden, codeSelfProtected, "cannot delete your own account")) return } @@ -203,7 +212,7 @@ func (a *API) handleDeleteUser(w http.ResponseWriter, r *http.Request) { // Same owner protection as the role guard above: only break-glass retires the // owner identity. A failed detail read falls through to the real 404. if d, err := a.Repo.UserDetail(r.Context(), id); err == nil && d.Role == "owner" { - writeError(w, r, newError(http.StatusForbidden, "forbidden", + writeError(w, r, newError(http.StatusForbidden, codeOwnerProtected, "the owner account cannot be deleted from the panel")) return } @@ -231,7 +240,7 @@ func (a *API) handleDisableUser(w http.ResponseWriter, r *http.Request) { } if id == p.UserID { - writeError(w, r, newError(http.StatusForbidden, "forbidden", + writeError(w, r, newError(http.StatusForbidden, codeSelfProtected, "cannot disable your own account")) return } @@ -249,7 +258,7 @@ func (a *API) handleDisableUser(w http.ResponseWriter, r *http.Request) { // break-glass touches the owner identity. Re-enabling stays allowed. if body.Disabled { if d, err := a.Repo.UserDetail(r.Context(), id); err == nil && d.Role == "owner" { - writeError(w, r, newError(http.StatusForbidden, "forbidden", + writeError(w, r, newError(http.StatusForbidden, codeOwnerProtected, "the owner account cannot be disabled from the panel")) return } diff --git a/internal/api/handlers_users_test.go b/internal/api/handlers_users_test.go index a18ec26..92e9650 100644 --- a/internal/api/handlers_users_test.go +++ b/internal/api/handlers_users_test.go @@ -25,19 +25,49 @@ func TestOwnerAccountProtectedFromPanelMutations(t *testing.T) { if w.Code != http.StatusForbidden { t.Fatalf("demote owner: code = %d body %s, want 403", w.Code, w.Body.String()) } + if got := decodeErr(t, w); got != "owner_protected" { + t.Fatalf("demote owner: error code = %q, want owner_protected", got) + } }) t.Run("delete refused", func(t *testing.T) { w := do(eh, "DELETE", "/api/v1/users/usr-owner2", "", nil) if w.Code != http.StatusForbidden { t.Fatalf("delete owner: code = %d body %s, want 403", w.Code, w.Body.String()) } + if got := decodeErr(t, w); got != "owner_protected" { + t.Fatalf("delete owner: error code = %q, want owner_protected", got) + } }) t.Run("disable refused", func(t *testing.T) { w := do(eh, "POST", "/api/v1/users/usr-owner2/disable", `{"disabled":true}`, jsonHeader) if w.Code != http.StatusForbidden { t.Fatalf("disable owner: code = %d body %s, want 403", w.Code, w.Body.String()) } + if got := decodeErr(t, w); got != "owner_protected" { + t.Fatalf("disable owner: error code = %q, want owner_protected", got) + } }) + // The caller's own row is refused as self_protected even though it is also + // an owner: that is the reason the admin can act on. + for _, tc := range []struct{ name, method, path, body string }{ + {"own role", "PATCH", "/api/v1/users/usr-root", `{"role":"admin"}`}, + {"own delete", "DELETE", "/api/v1/users/usr-root", ""}, + {"own disable", "POST", "/api/v1/users/usr-root/disable", `{"disabled":true}`}, + } { + t.Run(tc.name+" refused as self", func(t *testing.T) { + var h map[string]string + if tc.body != "" { + h = jsonHeader + } + w := do(eh, tc.method, tc.path, tc.body, h) + if w.Code != http.StatusForbidden { + t.Fatalf("code = %d body %s, want 403", w.Code, w.Body.String()) + } + if got := decodeErr(t, w); got != "self_protected" { + t.Fatalf("error code = %q, want self_protected", got) + } + }) + } t.Run("email edits on an owner stay allowed", func(t *testing.T) { w := do(eh, "PATCH", "/api/v1/users/usr-owner2", `{"email":"root2@example.net"}`, jsonHeader) if w.Code != http.StatusOK { diff --git a/panel/dev/mockApi.ts b/panel/dev/mockApi.ts index 8392b5e..e37ce55 100644 --- a/panel/dev/mockApi.ts +++ b/panel/dev/mockApi.ts @@ -1170,7 +1170,16 @@ async function handleUserRoute(ctx: SessionContext): Promise { return true; } if (ctx.account.id === acc.id && body.role !== ctx.account.role) { - sendError(ctx.res, 403, "forbidden", "cannot change your own role"); + sendError(ctx.res, 403, "self_protected", "cannot change your own role"); + return true; + } + if (acc.role === "owner") { + sendError( + ctx.res, + 403, + "owner_protected", + "the owner account's role cannot be changed from the panel" + ); return true; } acc.role = body.role; @@ -1196,7 +1205,11 @@ async function handleUserRoute(ctx: SessionContext): Promise { // DELETE /api/v1/users/{id} if (is("DELETE", ctx) && !subAction) { if (ctx.account.id === acc.id) { - sendError(ctx.res, 403, "forbidden", "cannot delete your own account"); + sendError(ctx.res, 403, "self_protected", "cannot delete your own account"); + return true; + } + if (acc.role === "owner") { + sendError(ctx.res, 403, "owner_protected", "the owner account cannot be deleted from the panel"); return true; } const activeServers = ctx.state.servers.filter( @@ -1226,10 +1239,14 @@ async function handleUserRoute(ctx: SessionContext): Promise { // POST /api/v1/users/{id}/disable if (is("POST", ctx) && subAction === "disable") { if (ctx.account.id === acc.id) { - sendError(ctx.res, 403, "forbidden", "cannot disable your own account"); + sendError(ctx.res, 403, "self_protected", "cannot disable your own account"); return true; } const body = await readJSON<{ disabled: boolean }>(ctx.req); + if (acc.role === "owner" && body.disabled) { + sendError(ctx.res, 403, "owner_protected", "the owner account cannot be disabled from the panel"); + return true; + } acc.disabled = !!body.disabled; acc.updated_at = new Date().toISOString(); sendJSON(ctx.res, 200, { id: `mock-${acc.id}`, disabled: acc.disabled }); diff --git a/panel/src/i18n/resources/en-US/admin.json b/panel/src/i18n/resources/en-US/admin.json index 82c3009..7228a25 100644 --- a/panel/src/i18n/resources/en-US/admin.json +++ b/panel/src/i18n/resources/en-US/admin.json @@ -172,6 +172,8 @@ "users_field_username": "Username", "users_field_email": "Email", "users_field_role": "Role", + "users_role_locked_self": "You can't change your own role.", + "users_role_locked_owner": "The owner's role is fixed. Only the host's break-glass console (sudo felis breakGlass) manages the owner.", "users_save_btn": "Save Changes", "users_save_ok": "Changes saved successfully.", "users_linked_accounts": "Linked Minecraft Accounts", @@ -203,6 +205,8 @@ "users_session_revoke_all_dlg_desc": "Are you sure you want to revoke all active sessions? The user will be logged out from every device.", "users_session_revoke_confirm": "Revoke", "users_danger_zone": "Danger Zone", + "users_protected_self": "You can't disable or delete the account you're signed in with.", + "users_protected_owner": "The owner account can't be disabled or deleted from the panel. Only the host's break-glass console (sudo felis breakGlass) manages it.", "users_danger_disable": "Disable User", "users_danger_disable_desc": "Prevent this user from logging in. All active sessions will be revoked immediately.", "users_danger_disable_btn": "Disable User", diff --git a/panel/src/i18n/resources/en-US/errors.json b/panel/src/i18n/resources/en-US/errors.json index d70f11a..830cec9 100644 --- a/panel/src/i18n/resources/en-US/errors.json +++ b/panel/src/i18n/resources/en-US/errors.json @@ -27,6 +27,8 @@ "restore_unavailable": "Restore isn't available right now — try again later.", "session_expired": "Your session expired — please sign in again.", "forbidden": "You are not allowed to do that.", + "self_protected": "You can't do that to the account you're signed in with.", + "owner_protected": "The owner account can't be demoted, disabled or deleted from the panel. Only the host's break-glass console (sudo felis breakGlass) manages it.", "generic": "Something went wrong.", "otp_resend_cooldown": "Verification code requested too frequently, please try again later.", "otp_locked": "Too many incorrect attempts, please request a new verification code.", diff --git a/panel/src/i18n/resources/zh-CN/admin.json b/panel/src/i18n/resources/zh-CN/admin.json index e3fe7fe..55d2a25 100644 --- a/panel/src/i18n/resources/zh-CN/admin.json +++ b/panel/src/i18n/resources/zh-CN/admin.json @@ -172,6 +172,8 @@ "users_field_username": "用户名", "users_field_email": "邮箱", "users_field_role": "角色", + "users_role_locked_self": "不能修改自己的角色。", + "users_role_locked_owner": "所有者的角色是固定的,只能在主机的应急控制台(sudo felis breakGlass)上管理。", "users_save_btn": "保存更改", "users_save_ok": "更改保存成功。", "users_linked_accounts": "已关联的 Minecraft 账号", @@ -203,6 +205,8 @@ "users_session_revoke_all_dlg_desc": "确定撤销所有活跃会话吗?用户将从所有设备登出。", "users_session_revoke_confirm": "撤销", "users_danger_zone": "危险操作区", + "users_protected_self": "不能禁用或删除当前登录的账号。", + "users_protected_owner": "所有者账号不能在面板里禁用或删除,只能在主机的应急控制台(sudo felis breakGlass)上管理。", "users_danger_disable": "禁用用户", "users_danger_disable_desc": "阻止此用户登录。所有活跃会话将被立即撤销。", "users_danger_disable_btn": "禁用用户", diff --git a/panel/src/i18n/resources/zh-CN/errors.json b/panel/src/i18n/resources/zh-CN/errors.json index 376327b..ad37f25 100644 --- a/panel/src/i18n/resources/zh-CN/errors.json +++ b/panel/src/i18n/resources/zh-CN/errors.json @@ -27,6 +27,8 @@ "restore_unavailable": "回档功能当前不可用,请稍后再试。", "session_expired": "会话已过期——请重新登录。", "forbidden": "你无权执行此操作。", + "self_protected": "不能对当前登录的账号执行此操作。", + "owner_protected": "所有者账号不能在面板里降级、禁用或删除,只能在主机的应急控制台(sudo felis breakGlass)上管理。", "generic": "出了点问题,请稍后重试。", "otp_resend_cooldown": "验证码发送频繁,请稍后再试。", "otp_locked": "验证码错误次数过多,请重新获取验证码。", diff --git a/panel/src/lib/api.test.ts b/panel/src/lib/api.test.ts index 788fcf0..09349d0 100644 --- a/panel/src/lib/api.test.ts +++ b/panel/src/lib/api.test.ts @@ -392,6 +392,16 @@ describe("api access-control wire shapes", () => { expect(humanizeError({ code: "console_unavailable" })).toMatch(/console/i); }); + it("says why a user change was refused for the caller's own or the owner account", async () => { + const { humanizeError } = await import("./api"); + expect(humanizeError({ status: 403, code: "self_protected" })).toBe( + "You can't do that to the account you're signed in with.", + ); + expect(humanizeError({ status: 403, code: "owner_protected" })).toBe( + "The owner account can't be demoted, disabled or deleted from the panel. Only the host's break-glass console (sudo felis breakGlass) manages it.", + ); + }); + it("maps the backup rationing codes to their own copy", async () => { const { humanizeError } = await import("./api"); expect(humanizeError({ code: "backup_cooldown" })).toMatch(/cooldown/i); diff --git a/panel/src/lib/api.ts b/panel/src/lib/api.ts index d1109c3..3ef42fb 100644 --- a/panel/src/lib/api.ts +++ b/panel/src/lib/api.ts @@ -822,6 +822,12 @@ export function humanizeError(e: unknown): string { return t("passkey_unavailable"); case "last_passkey": return t("last_passkey"); + // User admin (internal/api/handlers_users.go): the caller's own account and + // the owner account are refused, each for its own reason. + case "self_protected": + return t("self_protected"); + case "owner_protected": + return t("owner_protected"); case "quota_exceeded": return t("quota_exceeded"); case "already_claimed": diff --git a/panel/src/lib/openapi.gen.ts b/panel/src/lib/openapi.gen.ts index c49cc94..263f11e 100644 --- a/panel/src/lib/openapi.gen.ts +++ b/panel/src/lib/openapi.gen.ts @@ -5205,7 +5205,15 @@ export interface operations { }; }; 401: components["responses"]["Unauthorized"]; - 403: components["responses"]["Forbidden"]; + /** @description Not an owner (forbidden); the caller's own account (self_protected); or the owner account (owner_protected), which only the host's break-glass console (sudo felis breakGlass) may remove. */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; 404: components["responses"]["NotFound"]; }; }; @@ -5241,7 +5249,15 @@ export interface operations { }; 400: components["responses"]["BadRequest"]; 401: components["responses"]["Unauthorized"]; - 403: components["responses"]["Forbidden"]; + /** @description Not an owner (forbidden); a change to the caller's own role (self_protected); or a role change on the owner account (owner_protected), which only the host's break-glass console (sudo felis breakGlass) may make. */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; 404: components["responses"]["NotFound"]; /** @description Username conflict. */ 409: { @@ -5284,7 +5300,15 @@ export interface operations { }; }; 401: components["responses"]["Unauthorized"]; - 403: components["responses"]["Forbidden"]; + /** @description Not an owner (forbidden); the caller's own account (self_protected); or disabling the owner account (owner_protected). Re-enabling the owner is allowed. */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; 404: components["responses"]["NotFound"]; }; }; diff --git a/panel/src/pages/admin/UserDetailPage.test.tsx b/panel/src/pages/admin/UserDetailPage.test.tsx index 64000c7..19227a7 100644 --- a/panel/src/pages/admin/UserDetailPage.test.tsx +++ b/panel/src/pages/admin/UserDetailPage.test.tsx @@ -1,6 +1,6 @@ // @vitest-environment jsdom import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; -import { render, screen } from "@testing-library/react"; +import { render, screen, within } from "@testing-library/react"; import { MemoryRouter, Route, Routes } from "react-router-dom"; import i18next from "i18next"; import { UserDetailPage } from "./UserDetailPage"; @@ -35,9 +35,9 @@ const USER: UserDetail = { linked_accounts: [{ mc_uuid: "069a79f4-44e9-4726-a5be-fca90e38aaf5", auth_source: "thirdparty", verified_at: VERIFIED }], }; -function renderPage() { +function renderPage(id = "u-1") { return render( - + } /> @@ -88,4 +88,68 @@ describe("UserDetailPage", () => { expect(await screen.findByText(`第三方 Yggdrasil · ${zhVerified}`)).toBeTruthy(); expect(await screen.findByText(zhExpires, { exact: false })).toBeTruthy(); }); + + describe("danger zone for accounts the server protects", () => { + const SELF_REASON = "You can't disable or delete the account you're signed in with."; + const OWNER_REASON = + "The owner account can't be disabled or deleted from the panel. Only the host's break-glass console (sudo felis breakGlass) manages it."; + + const button = (name: string) => screen.getByRole("button", { name }) as HTMLButtonElement; + const describedBy = (b: HTMLElement) => + document.getElementById(b.getAttribute("aria-describedby") ?? "")?.textContent; + + it("locks disable and delete on the signed-in owner's own row and says why", async () => { + calls.getUser.mockResolvedValue({ ...USER, id: "owner-1", username: "root", role: "owner" }); + renderPage("owner-1"); + + expect(await screen.findByText(SELF_REASON)).toBeTruthy(); + for (const name of ["Disable User", "Delete User"]) { + expect(button(name).disabled).toBe(true); + expect(describedBy(button(name))).toBe(SELF_REASON); + expect(button(name).parentElement?.getAttribute("title")).toBe(SELF_REASON); + } + expect(button("Unbind passkeys").disabled).toBe(false); + expect(screen.getByText("You can't change your own role.")).toBeTruthy(); + expect(screen.queryByRole("combobox")).toBeNull(); + }); + + it("locks disable and delete on another owner with the break-glass reason", async () => { + calls.getUser.mockResolvedValue({ ...USER, id: "owner-2", username: "co-owner", role: "owner" }); + renderPage("owner-2"); + + expect(await screen.findByText(OWNER_REASON)).toBeTruthy(); + expect(screen.queryByText(SELF_REASON)).toBeNull(); + expect(button("Disable User").disabled).toBe(true); + expect(button("Delete User").disabled).toBe(true); + expect(describedBy(button("Delete User"))).toBe(OWNER_REASON); + expect( + screen.getByText( + "The owner's role is fixed. Only the host's break-glass console (sudo felis breakGlass) manages the owner.", + ), + ).toBeTruthy(); + expect(screen.queryByRole("combobox")).toBeNull(); + }); + + it("still lets a disabled owner be re-enabled", async () => { + calls.getUser.mockResolvedValue({ ...USER, id: "owner-2", role: "owner", disabled: true }); + renderPage("owner-2"); + + expect((await screen.findByRole("button", { name: "Enable" }) as HTMLButtonElement).disabled).toBe(false); + expect(button("Enable").getAttribute("aria-describedby")).toBeNull(); + expect(button("Delete User").disabled).toBe(true); + }); + + it("leaves every action open on an ordinary user", async () => { + calls.getUser.mockResolvedValue(USER); + renderPage(); + + expect((await screen.findByRole("button", { name: "Disable User" }) as HTMLButtonElement).disabled).toBe(false); + expect(button("Delete User").disabled).toBe(false); + expect(button("Delete User").parentElement?.getAttribute("title")).toBeNull(); + expect(screen.queryByText(SELF_REASON)).toBeNull(); + expect(screen.queryByText(OWNER_REASON)).toBeNull(); + const role = screen.getByRole("combobox"); + expect(within(role).getByText("User")).toBeTruthy(); + }); + }); }); diff --git a/panel/src/pages/admin/UserDetailPage.tsx b/panel/src/pages/admin/UserDetailPage.tsx index 7e7ca1a..5f2d43b 100644 --- a/panel/src/pages/admin/UserDetailPage.tsx +++ b/panel/src/pages/admin/UserDetailPage.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect } from "react"; +import { useState, useEffect, useId } from "react"; import { useParams, useNavigate } from "react-router-dom"; import { MessageLine, InlineError } from "@/components/MessageLine"; import { RoleBadge } from "@/components/RoleBadge"; @@ -20,6 +20,7 @@ import { X, AlertTriangle, Fingerprint, + Lock, } from "lucide-react"; import { useTranslation } from "react-i18next"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; @@ -86,7 +87,11 @@ export function UserDetailPage() { icon={(
@@ -130,7 +135,7 @@ export function UserDetailPage() { {/* Danger zone */} - + ); } @@ -195,7 +200,17 @@ function EditProfileCard({ user, onSaved, isSelf }: { user: UserDetail; onSaved: className="h-9 text-sm" /> - {!isSelf && ( + {/* Your own role and the owner's are what the server refuses to change + (self_protected / owner_protected), so they show read-only with why. */} + {isSelf || user.role === "owner" ? ( +
+

{t("users_field_role")}

+ +

+ {isSelf ? t("users_role_locked_self") : t("users_role_locked_owner")} +

+
+ ) : (