fix(panel): 本人与所有者账户的禁用和删除按钮提前锁定并说明原因,角色改为只读,后端拒绝改用专用错误码 self_protected 与 owner_protected
This commit is contained in:
13 files changed
+269
-27
No files matched your search
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user