Unverified Commit fde677c0 authored by Lemon-miaow's avatar Lemon-miaow
Browse files

fix(panel): 本人与所有者账户的禁用和删除按钮提前锁定并说明原因,角色改为只读,后端拒绝改用专用错误码 self_protected 与 owner_protected

parent dee4d87f
Loading
Loading
Loading
Loading
+15 −3
Changes for docs/openapi.yaml: 15 added lines, 3 removed lines.
Original line number Diff line number Diff line
@@ -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'

+15 −6
Changes for internal/api/handlers_users.go: 15 added lines, 6 removed lines.
Original line number Diff line number Diff line
@@ -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
		}
+30 −0
Changes for internal/api/handlers_users_test.go: 30 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -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":"[email protected]"}`, jsonHeader)
		if w.Code != http.StatusOK {
+20 −3
Changes for panel/dev/mockApi.ts: 20 added lines, 3 removed lines.
Original line number Diff line number Diff line
@@ -1170,7 +1170,16 @@ async function handleUserRoute(ctx: SessionContext): Promise<boolean> {
          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<boolean> {
    // 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<boolean> {
    // 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 });
+4 −0
Changes for panel/src/i18n/resources/en-US/admin.json: 4 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -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",
Loading