Let an admin delete a vault, behind their own password
ci/woodpecker/push/build Pipeline was successful
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:
co-authored by
Claude Opus 5
parent
967d9c0e2e
commit
e0977a9241
@@ -71,8 +71,10 @@ a{color:var(--accent)}
|
||||
td form{display:inline;margin:0}
|
||||
td button{width:auto;margin:0 .25rem .25rem 0;padding:.3rem .5rem;font-size:.8rem;font-weight:500;
|
||||
background:transparent;color:var(--accent);border:1px solid var(--line)}
|
||||
td button.danger{color:#b02a37}
|
||||
@media(prefers-color-scheme:dark){td button.danger{color:#f2b8c0}}
|
||||
td button.danger,a.danger{color:#b02a37}
|
||||
button.destroy{background:#b02a37}
|
||||
@media(prefers-color-scheme:dark){td button.danger,a.danger{color:#f2b8c0}
|
||||
button.destroy{background:#a3313c}}
|
||||
.inline{display:flex;gap:.6rem;align-items:flex-end;flex-wrap:wrap;margin:0}
|
||||
.inline>div{flex:1 1 9rem}
|
||||
.inline label{margin-top:0}
|
||||
@@ -133,7 +135,7 @@ var vaultsTmpl = template.Must(template.Must(pages.Clone()).Parse(`
|
||||
{{if .Notice}}<p class="ok">{{.Notice}}</p>{{end}}
|
||||
{{if .Vaults}}
|
||||
<div class="scroll"><table>
|
||||
<thead><tr><th>Vault</th><th>Access</th><th>On conflict</th><th>Version</th><th>Last saved</th></tr></thead>
|
||||
<thead><tr><th>Vault</th><th>Access</th><th>On conflict</th><th>Version</th><th>Last saved</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{{range .Vaults}}<tr>
|
||||
<td class="name">{{.Name}}.kdbx</td>
|
||||
@@ -141,6 +143,7 @@ var vaultsTmpl = template.Must(template.Must(pages.Clone()).Parse(`
|
||||
<td><span class="tag">{{.Strategy}}</span></td>
|
||||
<td>{{.Version}}</td>
|
||||
<td>{{.Updated}}{{if .By}}<br><small>by {{.By}}</small>{{end}}</td>
|
||||
<td>{{if eq .Perm "admin"}}<a class="danger" href="/web/vaults/{{.ID}}/delete">Delete</a>{{end}}</td>
|
||||
</tr>{{end}}
|
||||
</tbody></table></div>
|
||||
{{else}}
|
||||
@@ -201,6 +204,51 @@ Changing it signs out your other sessions.</p>
|
||||
</details>
|
||||
{{end}}`))
|
||||
|
||||
var deleteVaultTmpl = template.Must(template.Must(pages.Clone()).Parse(`
|
||||
{{define "body"}}
|
||||
{{with .V}}
|
||||
<h1>Delete {{.Name}}.kdbx?</h1>
|
||||
<p class="sub">This cannot be undone. <a href="/web/vaults">Back to your vaults</a></p>
|
||||
{{end}}
|
||||
{{if .Error}}<p class="err">{{.Error}}</p>{{end}}
|
||||
{{with .V}}
|
||||
<div class="scroll"><table>
|
||||
<tbody>
|
||||
<tr><td class="name">Owned by</td><td>{{.Group}}</td></tr>
|
||||
<tr><td class="name">Versions stored</td><td>{{.Version}}</td></tr>
|
||||
<tr><td class="name">Last saved</td><td>{{.Updated}}</td></tr>
|
||||
<tr><td class="name">Groups granted access</td><td>{{.Grants}}</td></tr>
|
||||
</tbody></table></div>
|
||||
|
||||
<p class="note">Every stored version of this file is erased from the server,
|
||||
along with the grants that point at it. Copies already in people's mirror
|
||||
directories stay on their computers: the server stops tracking the vault, it
|
||||
does not reach into anybody's home directory. If the vault is merged here,
|
||||
the key the server holds goes with it.</p>
|
||||
|
||||
<fieldset><legend>Confirm it is you</legend>
|
||||
<form method="post" action="/web/vaults/{{.ID}}/delete">
|
||||
<input type="hidden" name="csrf" value="{{$.CSRF}}">
|
||||
{{if .NeedsName}}
|
||||
<label for="confirm_name">Type the vault's name: <strong>{{.Name}}</strong></label>
|
||||
<input id="confirm_name" name="confirm_name" required autocomplete="off" autofocus>
|
||||
<p class="sub">You sign in through single sign-on, so there is no password here
|
||||
to ask for.</p>
|
||||
{{else}}
|
||||
<label for="password">Your account password</label>
|
||||
<input id="password" name="password" type="password" autocomplete="current-password" required autofocus>
|
||||
{{if .NeedsTOTP}}
|
||||
<label for="totp">One-time code</label>
|
||||
<input id="totp" name="totp" inputmode="numeric" autocomplete="one-time-code" required>
|
||||
{{end}}
|
||||
<p class="sub">Not the vault's password — the one you signed in with.</p>
|
||||
{{end}}
|
||||
<button class="destroy" type="submit">Delete {{.Name}} permanently</button>
|
||||
</form>
|
||||
</fieldset>
|
||||
{{end}}
|
||||
{{end}}`))
|
||||
|
||||
var doneTmpl = template.Must(template.Must(pages.Clone()).Parse(`
|
||||
{{define "body"}}<h1>{{.Title}}</h1><p class="sub">{{.Message}}</p>{{end}}`))
|
||||
|
||||
@@ -227,6 +275,17 @@ func (s *Server) renderVaults(w http.ResponseWriter, sess *webSession, errMsg, n
|
||||
render(w, vaultsTmpl, d)
|
||||
}
|
||||
|
||||
func (s *Server) renderDeleteVault(w http.ResponseWriter, sess *webSession, id, errMsg string) {
|
||||
v := s.deleteVaultView(sess.user, id)
|
||||
if v == nil {
|
||||
s.renderVaults(w, sess, "There is no such vault, or you do not administer it.", "")
|
||||
return
|
||||
}
|
||||
d := s.shell(sess, "Delete vault", "Vaults")
|
||||
d["V"], d["Error"] = v, errMsg
|
||||
render(w, deleteVaultTmpl, d)
|
||||
}
|
||||
|
||||
func (s *Server) renderDone(w http.ResponseWriter, title, msg string) {
|
||||
render(w, doneTmpl, map[string]any{"Title": title, "Message": msg})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user