mirror of
https://github.com/MeshTender/MeshTender.git
synced 2026-09-01 17:38:15 +00:00
273 lines
10 KiB
Go
273 lines
10 KiB
Go
package store
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
|
|
"github.com/jackc/pgx/v5"
|
|
)
|
|
|
|
// Account deletion. The schema does most of the work — every FK to users either
|
|
// cascades or nulls out — so this file is about the two things the schema can't
|
|
// decide: what must BLOCK a deletion (leaving an org or the instance with no
|
|
// admin), and what must be cleaned up alongside the row.
|
|
//
|
|
// What deliberately survives, anonymized: the command log keeps its write-time
|
|
// sender_username, maintenance entries keep author_name, and orgs/config profiles
|
|
// keep their created_by history as NULL. That's by design (see migration 0020) —
|
|
// the record of what was done to a repeater outlives the person who did it.
|
|
|
|
var (
|
|
// ErrSoleOrgAdmin blocks deletion: the user is the only admin of an org that
|
|
// still has other members, which a cascade would leave adminless.
|
|
ErrSoleOrgAdmin = errors.New("store: sole admin of an org with other members")
|
|
// ErrLastSiteAdmin blocks deletion: no one else holds cap_manage_users.
|
|
ErrLastSiteAdmin = errors.New("store: last site administrator")
|
|
)
|
|
|
|
// DeletionOrg is one of the user's organizations, classified by what deleting
|
|
// their account would do to it.
|
|
type DeletionOrg struct {
|
|
ID int64
|
|
Slug string
|
|
Name string
|
|
Role string
|
|
Members int
|
|
}
|
|
|
|
// DeletionRepeater is one owned repeater that would be deleted, with the number
|
|
// of stewards who could receive it instead (a transfer is the alternative to
|
|
// destroying the site's documentation and history).
|
|
type DeletionRepeater struct {
|
|
PublicID string
|
|
Name string
|
|
Stewards int
|
|
}
|
|
|
|
// DeletionPreview is everything the confirm page needs to tell the truth about
|
|
// what deletion would do, plus the blockers that would refuse it.
|
|
type DeletionPreview struct {
|
|
// Repeaters they own; deleting the account deletes these outright.
|
|
Repeaters []DeletionRepeater
|
|
// OrgsDeleted are orgs where they're the only member — nobody else is left to
|
|
// keep them, so they go with the account.
|
|
OrgsDeleted []DeletionOrg
|
|
// OrgsLeft are orgs that simply lose a member.
|
|
OrgsLeft []DeletionOrg
|
|
// OrgsBlocked are orgs where they're the sole admin but others remain: someone
|
|
// else must be promoted first.
|
|
OrgsBlocked []DeletionOrg
|
|
// LastSiteAdmin is set when no other account holds cap_manage_users.
|
|
LastSiteAdmin bool
|
|
// SharedWithUser counts repeaters other people share with them (access lost,
|
|
// but nothing of anyone else's is destroyed).
|
|
SharedWithUser int
|
|
// Passkeys they have registered.
|
|
Passkeys int
|
|
}
|
|
|
|
// Blocked reports whether deletion would be refused as things stand.
|
|
func (p *DeletionPreview) Blocked() bool { return p.LastSiteAdmin || len(p.OrgsBlocked) > 0 }
|
|
|
|
// orgClassifySQL classifies every org the user belongs to in one pass: the org,
|
|
// their role in it, and the member/admin counts that decide whether deleting the
|
|
// account would leave it adminless. $1 is the user id.
|
|
const orgClassifySQL = `
|
|
SELECT o.id, o.slug, o.name, m.role,
|
|
(SELECT count(*) FROM org_members x WHERE x.org_id = o.id) AS members,
|
|
(SELECT count(*) FROM org_members x WHERE x.org_id = o.id AND x.role = 'admin') AS admins
|
|
FROM org_members m
|
|
JOIN organizations o ON o.id = m.org_id
|
|
WHERE m.user_id = $1
|
|
ORDER BY lower(o.name), o.id`
|
|
|
|
// classifiedOrg is one row of orgClassifySQL.
|
|
type classifiedOrg struct {
|
|
DeletionOrg
|
|
Admins int
|
|
}
|
|
|
|
// scanClassifiedOrgs reads orgClassifySQL rows.
|
|
func scanClassifiedOrgs(rows pgx.Rows) ([]classifiedOrg, error) {
|
|
return collectRows(rows, func(r pgx.Row) (classifiedOrg, error) {
|
|
var c classifiedOrg
|
|
err := r.Scan(&c.ID, &c.Slug, &c.Name, &c.Role, &c.Members, &c.Admins)
|
|
return c, err
|
|
})
|
|
}
|
|
|
|
// blocksDeletion reports whether this membership stops the account going: the
|
|
// user is an admin, the only one, and other people are still in the org.
|
|
func (c classifiedOrg) blocksDeletion() bool {
|
|
return c.Role == "admin" && c.Admins <= 1 && c.Members > 1
|
|
}
|
|
|
|
// goesWithAccount reports whether the org should be deleted alongside the
|
|
// account: the user is its only member, so nothing of anyone else's is in it.
|
|
func (c classifiedOrg) goesWithAccount() bool { return c.Members <= 1 }
|
|
|
|
// PreviewUserDeletion assembles what deleting userID would do. It is a read-only
|
|
// snapshot for the confirm page — DeleteUser re-checks every blocker inside its
|
|
// transaction, so a stale preview can't let a blocked deletion through.
|
|
func (s *Store) PreviewUserDeletion(ctx context.Context, userID int64) (*DeletionPreview, error) {
|
|
p := &DeletionPreview{}
|
|
|
|
rows, err := s.pool.Query(ctx, `
|
|
SELECT r.public_id, r.name,
|
|
(SELECT count(*) FROM repeater_shares rs
|
|
WHERE rs.repeater_id = r.id AND rs.steward) AS stewards
|
|
FROM repeaters r WHERE r.owner_id = $1
|
|
ORDER BY lower(r.name), r.id`, userID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("preview repeaters: %w", err)
|
|
}
|
|
p.Repeaters, err = collectRows(rows, func(r pgx.Row) (DeletionRepeater, error) {
|
|
var d DeletionRepeater
|
|
err := r.Scan(&d.PublicID, &d.Name, &d.Stewards)
|
|
return d, err
|
|
})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("scan preview repeaters: %w", err)
|
|
}
|
|
|
|
orgRows, err := s.pool.Query(ctx, orgClassifySQL, userID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("preview orgs: %w", err)
|
|
}
|
|
orgs, err := scanClassifiedOrgs(orgRows)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("scan preview orgs: %w", err)
|
|
}
|
|
for _, o := range orgs {
|
|
switch {
|
|
case o.blocksDeletion():
|
|
p.OrgsBlocked = append(p.OrgsBlocked, o.DeletionOrg)
|
|
case o.goesWithAccount():
|
|
p.OrgsDeleted = append(p.OrgsDeleted, o.DeletionOrg)
|
|
default:
|
|
p.OrgsLeft = append(p.OrgsLeft, o.DeletionOrg)
|
|
}
|
|
}
|
|
|
|
if err := s.pool.QueryRow(ctx, `
|
|
SELECT
|
|
(SELECT cap_manage_users FROM users WHERE id = $1)
|
|
AND (SELECT count(*) FROM users WHERE cap_manage_users) <= 1,
|
|
(SELECT count(*) FROM repeater_shares WHERE user_id = $1),
|
|
(SELECT count(*) FROM webauthn_credentials WHERE user_id = $1)`,
|
|
userID).Scan(&p.LastSiteAdmin, &p.SharedWithUser, &p.Passkeys); err != nil {
|
|
return nil, fmt.Errorf("preview counts: %w", err)
|
|
}
|
|
return p, nil
|
|
}
|
|
|
|
// DeleteUser permanently deletes an account and everything the schema cascades
|
|
// from it: passkeys, logins (which drops every host session at once), profile
|
|
// links, org memberships, shares, and the repeaters they own along with those
|
|
// repeaters' invites, docs, confirmations, maintenance and command history.
|
|
//
|
|
// It refuses with ErrLastSiteAdmin or ErrSoleOrgAdmin rather than leaving the
|
|
// instance or an organization with nobody able to administer it. Both checks run
|
|
// under row locks inside the transaction, so two people deleting simultaneously
|
|
// can't both see "someone else is still an admin" and race the count to zero.
|
|
//
|
|
// Orgs where the user is the only member are deleted with the account — there is
|
|
// nobody left to hand them to, and everything in them is the departing user's.
|
|
//
|
|
// Returns ErrNotFound if the account is already gone.
|
|
func (s *Store) DeleteUser(ctx context.Context, userID int64) error {
|
|
return s.inTx(ctx, func(tx pgx.Tx) error {
|
|
var username string
|
|
var siteAdmin bool
|
|
if err := tx.QueryRow(ctx,
|
|
`SELECT username, cap_manage_users FROM users WHERE id = $1 FOR UPDATE`,
|
|
userID).Scan(&username, &siteAdmin); err != nil {
|
|
return notFoundOr(err, "lock user")
|
|
}
|
|
|
|
// Locking every site-admin row serializes concurrent admin deletions: the
|
|
// second one blocks, then re-reads a set that no longer contains the first
|
|
// and correctly finds itself to be the last.
|
|
if siteAdmin {
|
|
rows, err := tx.Query(ctx, `SELECT id FROM users WHERE cap_manage_users FOR UPDATE`)
|
|
if err != nil {
|
|
return fmt.Errorf("lock site admins: %w", err)
|
|
}
|
|
admins, err := collectRows(rows, scanID)
|
|
if err != nil {
|
|
return fmt.Errorf("lock site admins: %w", err)
|
|
}
|
|
if len(admins) <= 1 {
|
|
return ErrLastSiteAdmin
|
|
}
|
|
}
|
|
|
|
// Lock the membership rows of every org the user belongs to before
|
|
// classifying them, so a concurrent leave/demote elsewhere can't change the
|
|
// answer between the check and the delete (the same guarantee
|
|
// guardLastAdminTx gives the leave path).
|
|
if _, err := tx.Exec(ctx, `
|
|
SELECT 1 FROM org_members
|
|
WHERE org_id IN (SELECT org_id FROM org_members WHERE user_id = $1)
|
|
FOR UPDATE`, userID); err != nil {
|
|
return fmt.Errorf("lock org memberships: %w", err)
|
|
}
|
|
orgRows, err := tx.Query(ctx, orgClassifySQL, userID)
|
|
if err != nil {
|
|
return fmt.Errorf("classify orgs: %w", err)
|
|
}
|
|
orgs, err := scanClassifiedOrgs(orgRows)
|
|
if err != nil {
|
|
return fmt.Errorf("scan orgs: %w", err)
|
|
}
|
|
var orphaned []int64
|
|
for _, o := range orgs {
|
|
if o.blocksDeletion() {
|
|
return ErrSoleOrgAdmin
|
|
}
|
|
if o.goesWithAccount() {
|
|
orphaned = append(orphaned, o.ID)
|
|
}
|
|
}
|
|
if len(orphaned) > 0 {
|
|
if _, err := tx.Exec(ctx,
|
|
`DELETE FROM organizations WHERE id = ANY($1)`, orphaned); err != nil {
|
|
return fmt.Errorf("delete solo orgs: %w", err)
|
|
}
|
|
}
|
|
|
|
// Scrub the personal data in the rename history that the FK can't reach.
|
|
// username_changes.user_id is ON DELETE SET NULL, so the rows survive (the
|
|
// cooldown below depends on them) — but they also carry the IP and user
|
|
// agent captured at each rename, which would otherwise outlive the account
|
|
// indefinitely. The old/new handles and timestamps are what the cooldown
|
|
// needs; the IP and UA are not, so they go.
|
|
if _, err := tx.Exec(ctx, `
|
|
UPDATE username_changes SET ip = NULL, user_agent = NULL
|
|
WHERE user_id = $1 OR changed_by = $1`, userID); err != nil {
|
|
return fmt.Errorf("scrub rename history: %w", err)
|
|
}
|
|
|
|
// Reserve the freed username for the usual release cooldown. Profiles are
|
|
// public at /u/{username} and @handles are baked into command logs and
|
|
// maintenance notes, so a name freed by deletion must not be claimable the
|
|
// next minute by someone inheriting that history. The row's user_id nulls
|
|
// out with the cascade below, and nameReservedByOther treats NULL as "not
|
|
// you" for every caller — so it's reserved against everyone, which is what
|
|
// a deleted account needs (nobody can prove they were its owner).
|
|
//
|
|
// new_username is empty: this is a release, not a rename to something.
|
|
if _, err := tx.Exec(ctx, `
|
|
INSERT INTO username_changes (user_id, old_username, new_username, changed_by)
|
|
VALUES ($1, $2, '', $1)`, userID, username); err != nil {
|
|
return fmt.Errorf("reserve released username: %w", err)
|
|
}
|
|
|
|
if _, err := tx.Exec(ctx, `DELETE FROM users WHERE id = $1`, userID); err != nil {
|
|
return fmt.Errorf("delete user: %w", err)
|
|
}
|
|
return nil
|
|
})
|
|
}
|