feat: add permanent deletion for revoked keys

DELETE /admin/api/keys/{id}/purge removes the key record; it requires the key to be revoked first and keeps historical logs. The keys page swaps the revoke button for a delete button once a key is revoked.
This commit is contained in:
2026-10-25 18:40:32 +08:00
parent 293f7f524a
commit 578afe509c
4 changed files with 86 additions and 4 deletions
+21
View File
@@ -203,6 +203,27 @@ func (s *Server) keys(w http.ResponseWriter, r *http.Request) {
}
func (s *Server) keyID(w http.ResponseWriter, r *http.Request) {
id := strings.TrimPrefix(r.URL.Path, "/admin/api/keys/")
if purgeID, ok := strings.CutSuffix(id, "/purge"); ok {
if r.Method != "DELETE" {
errJSON(w, 405, "method not allowed")
return
}
var revoked bool
if e := s.db.QueryRow("SELECT revoked FROM keys WHERE id=?", purgeID).Scan(&revoked); e == sql.ErrNoRows {
errJSON(w, 404, "not found")
return
} else if e != nil {
errJSON(w, 500, "database error")
return
}
if !revoked {
errJSON(w, 409, "revoke the key before deleting")
return
}
res, e := s.db.Exec("DELETE FROM keys WHERE id=?", purgeID)
mutation(w, res, e)
return
}
if r.Method == "DELETE" {
res, e := s.db.Exec("UPDATE keys SET enabled=0,revoked=1 WHERE id=?", id)
mutation(w, res, e)
+50
View File
@@ -252,6 +252,56 @@ func TestKeyRevocationAndProxy(t *testing.T) {
if n != 1 {
t.Fatal(n)
}
w = call(s, "DELETE", "/admin/api/keys/"+id+"/purge", "", c)
if w.Code != 200 {
t.Fatal(w.Body.String())
}
s.db.QueryRow("SELECT count(*) FROM keys WHERE id=?", id).Scan(&n)
if n != 0 {
t.Fatal("purged key still stored", n)
}
w = call(s, "DELETE", "/admin/api/keys/"+id+"/purge", "", c)
if w.Code != 404 {
t.Fatal("purged key not reported missing", w.Code)
}
s.db.QueryRow("SELECT count(*) FROM logs").Scan(&n)
if n != 1 {
t.Fatal("purge removed history", n)
}
}
func TestKeyPurgeRequiresRevocation(t *testing.T) {
s := testServer(t)
c := loginCookie(t, s)
w := call(s, "POST", "/admin/api/keys", `{"name":"gone","all_channels":true}`, c)
var key map[string]string
if err := json.Unmarshal(w.Body.Bytes(), &key); err != nil || w.Code != 200 {
t.Fatal(w.Code, w.Body.String(), err)
}
var id string
if err := s.db.QueryRow("SELECT id FROM keys WHERE name='gone'").Scan(&id); err != nil {
t.Fatal(err)
}
if w = call(s, "DELETE", "/admin/api/keys/"+id+"/purge", "", c); w.Code != 409 {
t.Fatal("purged active key", w.Code, w.Body.String())
}
if w = call(s, "DELETE", "/admin/api/keys/"+id, "", c); w.Code != 200 {
t.Fatal(w.Body.String())
}
if w = call(s, "DELETE", "/admin/api/keys/"+id+"/purge", "", c); w.Code != 200 {
t.Fatal(w.Body.String())
}
w = call(s, "GET", "/admin/api/keys", "", c)
if strings.Contains(w.Body.String(), `"gone"`) {
t.Fatal("purged key still listed", w.Body.String())
}
r := httptest.NewRequest("GET", "/v1/models", nil)
r.Header.Set("Authorization", "Bearer "+key["key"])
w = httptest.NewRecorder()
s.ServeHTTP(w, r)
if w.Code != http.StatusUnauthorized {
t.Fatal("purged key still authenticates", w.Code)
}
}
func TestStatsUnknownAndFilters(t *testing.T) {
s := testServer(t)