Files
keeporg/internal/client/admin.go
T
ReactorcoremeltdownandClaude Opus 5 e0977a9241
ci/woodpecker/push/build Pipeline was successful
Let an admin delete a vault, behind their own password
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>
2026-10-30 12:57:02 +01:00

199 lines
7.0 KiB
Go

package client
import (
"context"
"net/http"
"net/url"
"time"
)
// Group is a node of the server's group tree as the client sees it.
type Group struct {
ID string `json:"id"`
Name string `json:"name"`
ParentID string `json:"parent_id"`
Admin bool `json:"admin"`
Members int `json:"members"`
}
// Invite is a registration link.
type Invite struct {
Token string `json:"token"`
URL string `json:"url"`
GroupID string `json:"group_id"`
Role string `json:"role"`
Uses int `json:"uses"`
MaxUses int `json:"max_uses"`
ExpiresAt time.Time `json:"expires_at"`
}
// User is an account as an administrator sees it.
type User struct {
ID string `json:"id"`
Username string `json:"username"`
Email string `json:"email"`
ServerAdmin bool `json:"server_admin"`
TOTPEnabled bool `json:"totp_enabled"`
}
// Identity is the answer to "who am I and where do I belong".
type Identity struct {
User
Groups []struct {
ID string `json:"id"`
Name string `json:"name"`
Role string `json:"role"`
} `json:"groups"`
}
func (a *API) Whoami(ctx context.Context) (*Identity, error) {
var out Identity
return &out, a.do(ctx, http.MethodGet, "/api/v1/auth/whoami", nil, &out)
}
func (a *API) Groups(ctx context.Context) ([]Group, error) {
var out struct {
Groups []Group `json:"groups"`
}
err := a.do(ctx, http.MethodGet, "/api/v1/groups", nil, &out)
return out.Groups, err
}
func (a *API) CreateGroup(ctx context.Context, name, parentID string) (*Group, error) {
var out Group
return &out, a.do(ctx, http.MethodPost, "/api/v1/groups",
map[string]string{"name": name, "parent_id": parentID}, &out)
}
func (a *API) DeleteGroup(ctx context.Context, id string) error {
return a.do(ctx, http.MethodDelete, "/api/v1/groups/"+url.PathEscape(id), nil, nil)
}
func (a *API) SetMember(ctx context.Context, groupID, username, role string) error {
return a.do(ctx, http.MethodPost,
"/api/v1/groups/"+url.PathEscape(groupID)+"/members",
map[string]string{"username": username, "role": role}, nil)
}
func (a *API) RemoveMember(ctx context.Context, groupID, user string) error {
return a.do(ctx, http.MethodDelete,
"/api/v1/groups/"+url.PathEscape(groupID)+"/members/"+url.PathEscape(user), nil, nil)
}
func (a *API) CreateDatabase(ctx context.Context, name, groupID, strategy string) (*Database, error) {
body := map[string]string{"name": name, "owner_group_id": groupID}
if strategy != "" {
body["merge_strategy"] = strategy
}
var out Database
return &out, a.do(ctx, http.MethodPost, "/api/v1/databases", body, &out)
}
// SetStrategy changes the merge strategy of a database. Only an
// administrator of the owning group may do this.
func (a *API) SetStrategy(ctx context.Context, dbID, strategy string) (*Database, error) {
var out Database
return &out, a.do(ctx, http.MethodPatch, "/api/v1/databases/"+url.PathEscape(dbID),
map[string]string{"merge_strategy": strategy}, &out)
}
func (a *API) RenameDatabase(ctx context.Context, dbID, name string) (*Database, error) {
var out Database
return &out, a.do(ctx, http.MethodPatch, "/api/v1/databases/"+url.PathEscape(dbID),
map[string]string{"name": name}, &out)
}
// DeleteDatabase removes a database and every version of it stored on the
// server. The caller's own account password is required, and the one-time
// code too when that account has one enrolled; the server answers
// totp_required when it is missing.
func (a *API) DeleteDatabase(ctx context.Context, dbID, password, totp string) error {
return a.do(ctx, http.MethodDelete, "/api/v1/databases/"+url.PathEscape(dbID),
map[string]string{"password": password, "totp": totp}, nil)
}
func (a *API) SetGrant(ctx context.Context, groupID, dbID, perm string) error {
return a.do(ctx, http.MethodPost, "/api/v1/grants",
map[string]string{"group_id": groupID, "database_id": dbID, "perm": perm}, nil)
}
func (a *API) DeleteGrant(ctx context.Context, groupID, dbID string) error {
q := url.Values{"group_id": {groupID}, "database_id": {dbID}}
return a.do(ctx, http.MethodDelete, "/api/v1/grants?"+q.Encode(), nil, nil)
}
func (a *API) CreateInvite(ctx context.Context, groupID, role string, ttlHours, maxUses int) (*Invite, error) {
var out Invite
return &out, a.do(ctx, http.MethodPost, "/api/v1/invites", map[string]any{
"group_id": groupID, "role": role, "ttl_hours": ttlHours, "max_uses": maxUses}, &out)
}
func (a *API) Invites(ctx context.Context) ([]Invite, error) {
var out struct {
Invites []Invite `json:"invites"`
}
err := a.do(ctx, http.MethodGet, "/api/v1/invites", nil, &out)
return out.Invites, err
}
func (a *API) Users(ctx context.Context) ([]User, error) {
var out struct {
Users []User `json:"users"`
}
err := a.do(ctx, http.MethodGet, "/api/v1/users", nil, &out)
return out.Users, err
}
// ChangePassword replaces the account password. The server requires the
// current one, and the second factor when one is enrolled, then signs out
// this account's other sessions.
func (a *API) ChangePassword(ctx context.Context, current, next, totp string) error {
return a.do(ctx, http.MethodPost, "/api/v1/auth/password", map[string]string{
"current_password": current, "new_password": next, "totp": totp}, nil)
}
// EnrollTOTP starts second-factor enrolment and returns the shared secret
// and its otpauth URI.
func (a *API) EnrollTOTP(ctx context.Context) (secret, uri string, err error) {
var out struct {
Secret string `json:"secret"`
URI string `json:"uri"`
}
err = a.do(ctx, http.MethodPost, "/api/v1/auth/totp/enroll", nil, &out)
return out.Secret, out.URI, err
}
// ConfirmTOTP completes enrolment by proving the secret was stored.
func (a *API) ConfirmTOTP(ctx context.Context, code string) error {
return a.do(ctx, http.MethodPost, "/api/v1/auth/totp/confirm",
map[string]string{"code": code}, nil)
}
// CreateVault asks the server to create a new, empty KDBX vault encrypted
// with this password. For the merge strategy the server keeps the password,
// wrapped under its master key, because merging means opening the file.
func (a *API) CreateVault(ctx context.Context, name, groupID, strategy, password string) (*Database, error) {
var out Database
return &out, a.do(ctx, http.MethodPost, "/api/v1/vaults", map[string]string{
"name": name, "owner_group_id": groupID,
"merge_strategy": strategy, "password": password,
}, &out)
}
// StoreVaultKey gives the server the password of an existing database, so it
// can merge it. The server verifies the password against the stored file
// before accepting it.
func (a *API) StoreVaultKey(ctx context.Context, dbID, password string) error {
return a.do(ctx, http.MethodPost,
"/api/v1/databases/"+url.PathEscape(dbID)+"/key",
map[string]any{"password": password}, nil)
}
// ForgetVaultKey removes the stored password, which also ends any ability to
// merge that database here.
func (a *API) ForgetVaultKey(ctx context.Context, dbID string) error {
return a.do(ctx, http.MethodPost,
"/api/v1/databases/"+url.PathEscape(dbID)+"/key",
map[string]any{"forget": true}, nil)
}