Let an admin delete a vault, behind their own password
ci/woodpecker/push/build Pipeline was successful

The web interface could create a vault and never remove one, so the only
way to undo a mistake was the CLI. There is now a Delete link on every
vault you administer, leading to a page that says what is about to go —
owner, versions stored, grants pointing at it — and asks for your
account password before it goes.

The gate is re-authentication, not a confirmation checkbox. A session
token is enough to read and write a vault; it should not be enough to
erase every version of one, because a session is what an unlocked
laptop leaves lying around. The one-time code is required too when the
account has one enrolled.

It lives in confirmDeletion, so the rule holds wherever deletion is
reachable: DELETE /api/v1/databases/{id} now takes {password, totp?}
and answers 401 without it, and keeporg db -delete prompts. That is a
breaking change to the endpoint, which is why the server goes to 0.7.0
and the desktop clients to 0.4.0.

An account that signs in only through an identity provider has no
password to type. Rather than lock those deployments out of deleting
anything, they confirm by typing the vault's exact name — the weaker
gate, and deliberately the fallback.

Deleting takes the record, its grants, every stored version and the key
a merge vault left with the server, and tells the watchers over SSE.
Copies already in people's mirror directories stay where they are: the
server stops tracking a vault, it does not reach into anybody's home
directory.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Reactorcoremeltdown
2026-10-30 12:57:02 +01:00
co-authored by Claude Opus 5
parent 967d9c0e2e
commit e0977a9241
12 changed files with 521 additions and 21 deletions
+237
View File
@@ -0,0 +1,237 @@
package server
import (
"net/http"
"net/url"
"os"
"path/filepath"
"strings"
"testing"
"time"
"cc.tiredsysadmin.keeporg/internal/secret"
"cc.tiredsysadmin.keeporg/internal/store"
"cc.tiredsysadmin.keeporg/internal/totp"
)
// blobsExist reports whether the server still keeps any version of a
// database on disk.
func (h *harness) blobsExist(dbID string) bool {
h.t.Helper()
_, err := os.Stat(filepath.Join(h.dataDir, "blobs", dbID))
return err == nil
}
func TestWebDeletesAVaultOnlyWithTheAdminsPassword(t *testing.T) {
h := newHarness(t)
b := newBrowser(t, h)
csrf := b.signIn()
admin, _, _ := h.login("admin", adminPassword, "")
id := h.makeVault(admin, "Doomed")
if !h.blobsExist(id) {
t.Fatal("the new vault has no stored version")
}
// A grant so the delete has something to clean up besides the record.
if status, body := h.call("POST", "/api/v1/grants", admin, map[string]any{
"group_id": "everyone", "database_id": id, "perm": "read",
}); status != http.StatusOK {
t.Fatalf("grant: %d %v", status, body)
}
status, page := b.get("/web/vaults/" + id + "/delete")
if status != http.StatusOK || !strings.Contains(page, "Delete Doomed.kdbx?") {
t.Fatalf("the confirmation page = %d\n%s", status, page)
}
if !strings.Contains(page, "Your account password") {
t.Fatalf("the page does not ask for a password:\n%s", page)
}
_, page = b.post("/web/vaults/"+id+"/delete", url.Values{
"csrf": {csrf}, "password": {"not-the-password"}})
if !strings.Contains(page, "not your account password") {
t.Fatalf("a wrong password was not reported:\n%s", page)
}
h.srv.Store().Read(func(st *store.State) {
if st.Databases[id] == nil {
t.Fatal("the vault was deleted on a wrong password")
}
})
if !h.blobsExist(id) {
t.Fatal("the stored versions went on a wrong password")
}
_, page = b.post("/web/vaults/"+id+"/delete", url.Values{
"csrf": {csrf}, "password": {adminPassword}})
if !strings.Contains(page, "Deleted Doomed") {
t.Fatalf("delete:\n%s", page)
}
if strings.Contains(page, "Doomed.kdbx") {
t.Fatal("the deleted vault is still listed")
}
h.srv.Store().Read(func(st *store.State) {
if st.Databases[id] != nil {
t.Fatal("the record survived")
}
for _, g := range st.Grants {
if g.DatabaseID == id {
t.Fatal("a grant outlived the vault")
}
}
})
if h.blobsExist(id) {
t.Fatal("the stored versions outlived the vault")
}
}
// Write access is not administration: it must not reach the page, and must
// not delete even with the right password.
func TestWebDeleteIsRefusedWithoutAdminRights(t *testing.T) {
h := newHarness(t)
b := newBrowser(t, h)
csrf := b.signIn()
admin, _, _ := h.login("admin", adminPassword, "")
id := h.makeVault(admin, "Theirs")
h.join(b.invite(csrf, "everyone", "member"), "wally", "wally-password-1")
if status, body := h.call("POST", "/api/v1/grants", admin, map[string]any{
"group_id": "everyone", "database_id": id, "perm": "write",
}); status != http.StatusOK {
t.Fatalf("grant: %d %v", status, body)
}
w := newBrowser(t, h)
wCSRF := w.signInAs("wally", "wally-password-1")
_, page := w.get("/web/vaults/" + id + "/delete")
if !strings.Contains(page, "you do not administer it") {
t.Fatalf("a writer reached the delete page:\n%s", page)
}
_, page = w.post("/web/vaults/"+id+"/delete", url.Values{
"csrf": {wCSRF}, "password": {"wally-password-1"}})
if !strings.Contains(page, "you do not administer it") {
t.Fatalf("a writer's delete was not refused:\n%s", page)
}
h.srv.Store().Read(func(st *store.State) {
if st.Databases[id] == nil {
t.Fatal("a writer deleted the vault")
}
})
// The vault list offers them no way in either.
_, page = w.get("/web/vaults")
if strings.Contains(page, "/delete") {
t.Fatalf("a writer was offered a delete link:\n%s", page)
}
}
func TestAPIDeleteNeedsThePassword(t *testing.T) {
h := newHarness(t)
admin, _, _ := h.login("admin", adminPassword, "")
id := h.makeVault(admin, "By API")
status, body := h.call("DELETE", "/api/v1/databases/"+id, admin, nil)
if status != http.StatusUnauthorized || body["code"] != "bad_credentials" {
t.Fatalf("delete with no password: %d %v", status, body)
}
status, body = h.call("DELETE", "/api/v1/databases/"+id, admin,
map[string]string{"password": "wrong"})
if status != http.StatusUnauthorized {
t.Fatalf("delete with a wrong password: %d %v", status, body)
}
h.srv.Store().Read(func(st *store.State) {
if st.Databases[id] == nil {
t.Fatal("the vault went without a password")
}
})
if status, body = h.call("DELETE", "/api/v1/databases/"+id, admin,
map[string]string{"password": adminPassword}); status != http.StatusOK {
t.Fatalf("delete with the password: %d %v", status, body)
}
if h.blobsExist(id) {
t.Fatal("the stored versions outlived the vault")
}
}
func TestDeleteAsksForTheSecondFactorToo(t *testing.T) {
h := newHarness(t)
admin, _, _ := h.login("admin", adminPassword, "")
id := h.makeVault(admin, "Guarded")
status, body := h.call("POST", "/api/v1/auth/totp/enroll", admin, nil)
if status != http.StatusOK {
t.Fatalf("enroll: %d %v", status, body)
}
sec := body["secret"].(string)
code, err := totp.Code(sec, time.Now())
if err != nil {
t.Fatal(err)
}
if status, body = h.call("POST", "/api/v1/auth/totp/confirm", admin,
map[string]string{"code": code}); status != http.StatusOK {
t.Fatalf("confirm: %d %v", status, body)
}
if status, body = h.call("DELETE", "/api/v1/databases/"+id, admin,
map[string]string{"password": adminPassword}); status != http.StatusUnauthorized ||
body["code"] != "totp_required" {
t.Fatalf("delete without a code: %d %v", status, body)
}
code, _ = totp.Code(sec, time.Now())
if status, body = h.call("DELETE", "/api/v1/databases/"+id, admin,
map[string]string{"password": adminPassword, "totp": code}); status != http.StatusOK {
t.Fatalf("delete with the code: %d %v", status, body)
}
}
// An account that only ever signs in through an identity provider has no
// password to re-type, so it confirms with the vault's name instead.
func TestSingleSignOnAccountConfirmsByName(t *testing.T) {
h := newHarness(t)
admin, _, _ := h.login("admin", adminPassword, "")
id := h.makeVault(admin, "Federated")
sso := &store.User{ID: secret.ID(), Username: "sso", OIDCIssuer: "https://id.example.com",
OIDCSubject: "abc", ServerAdmin: true, CreatedAt: time.Now()}
if err := h.srv.Store().Update(func(st *store.State) error {
st.Users[sso.ID] = sso
return nil
}); err != nil {
t.Fatal(err)
}
if _, err := h.srv.deleteDatabase(sso, id, "", "", ""); err != errConfirmName {
t.Fatalf("delete with nothing typed = %v", err)
}
if _, err := h.srv.deleteDatabase(sso, id, "", "", "federated"); err != errConfirmName {
t.Fatalf("delete with the wrong case = %v", err)
}
if _, err := h.srv.deleteDatabase(sso, id, "", "", "Federated"); err != nil {
t.Fatalf("delete with the name typed = %v", err)
}
h.srv.Store().Read(func(st *store.State) {
if st.Databases[id] != nil {
t.Fatal("the vault survived a confirmed delete")
}
})
}
// Deleting a vault tells everyone who could reach it, so their client stops
// expecting the file rather than finding out at the next save.
func TestDeleteNotifiesTheWatchers(t *testing.T) {
h := newHarness(t)
admin, _, _ := h.login("admin", adminPassword, "")
id := h.makeVault(admin, "Watched")
sub, events := h.srv.hub.Subscribe("admin")
defer h.srv.hub.Unsubscribe(sub)
if _, err := h.srv.deleteDatabase(&store.User{ID: "admin", Username: "admin"},
id, adminPassword, "", ""); err != nil {
t.Fatal(err)
}
select {
case ev := <-events:
if ev.DatabaseID != id || ev.Type != EventChanged {
t.Fatalf("event = %+v", ev)
}
case <-time.After(2 * time.Second):
t.Fatal("no event reached a watcher")
}
}