package store import ( "context" "crypto/rand" "encoding/hex" "time" ) // Forgotten is what forgetting a person did, for saying so. type Forgotten struct { // Pseudonym is what their usage is counted under now: random, so it // links to nothing. Pseudonym string // UsageRows and RollupRows are the request records and sums moved to it. UsageRows int64 RollupRows int64 // Requests, Files and Batches are kept requests, and personal files and // batches, deleted; AuditEvents the audit lines their name was taken out of. Requests int64 Files int64 Batches int64 AuditEvents int64 } // ForgetUser erases a person: what was kept of their requests, their // personal files and batches and their limits are deleted; their usage — the // request records and every sum of them — is kept, for the totals stay true, // under a random pseudonym; their name is taken out of the audit log; and the // account goes as DeleteUser removes it. What remains cannot be tied back to // them. // // Conversations and agent notes belong to projects, not people, and are not // touched: a project forgets them by its retention, or by // DELETE /v1/threads/{id}. func (s *DB) ForgetUser(ctx context.Context, userID string, at time.Time) (Forgotten, error) { u, err := s.UserByID(ctx, userID) if err != nil { return Forgotten{}, err } b := make([]byte, 8) if _, err := rand.Read(b); err != nil { return Forgotten{}, err } f := Forgotten{Pseudonym: "forgotten-" + hex.EncodeToString(b)} err = s.inTx(ctx, func(c conn) error { n := func(q string, args ...any) (int64, error) { res, err := c.ExecContext(ctx, q, args...) if err != nil { return 0, err } return res.RowsAffected() } var err error if f.Requests, err = n(`DELETE FROM request_bodies WHERE request_id IN (SELECT request_id FROM usage WHERE user_id = ? AND request_id <> '')`, userID); err != nil { return err } session, pseudoSession := "session:"+userID, "session:"+f.Pseudonym if f.UsageRows, err = n(`UPDATE usage SET user_id = ? WHERE user_id = ?`, f.Pseudonym, userID); err != nil { return err } if _, err = n(`UPDATE usage SET key_id = ? WHERE key_id = ?`, pseudoSession, session); err != nil { return err } for _, t := range []string{"usage_5m", "usage_1h", "usage_1d"} { m, err := n(`UPDATE `+t+` SET user_id = ? WHERE user_id = ?`, f.Pseudonym, userID) if err != nil { return err } f.RollupRows += m if _, err := n(`UPDATE `+t+` SET key_id = ? WHERE key_id = ?`, pseudoSession, session); err != nil { return err } } owner := "user:" + userID if _, err = n(`DELETE FROM batch_results WHERE batch_id IN (SELECT id FROM batches WHERE owner = ?)`, owner); err != nil { return err } if f.Batches, err = n(`DELETE FROM batches WHERE owner = ?`, owner); err != nil { return err } if f.Files, err = n(`DELETE FROM files WHERE owner = ?`, owner); err != nil { return err } if _, err = n(`DELETE FROM limits WHERE scope_type = ? AND scope_id = ?`, string(ScopeUser), userID); err != nil { return err } // Who did what stays in the audit log; that it was them does not. for _, name := range []string{u.Email, userID} { if name == "" { continue } m, err := n(`UPDATE audit_events SET actor = REPLACE(actor, ?, ?), target = REPLACE(target, ?, ?), detail = REPLACE(detail, ?, ?) WHERE actor LIKE ? OR target LIKE ? OR detail LIKE ?`, name, f.Pseudonym, name, f.Pseudonym, name, f.Pseudonym, "%"+name+"%", "%"+name+"%", "%"+name+"%") if err != nil { return err } f.AuditEvents += m } return nil }) if err != nil { return Forgotten{}, err } return f, s.DeleteUser(ctx, userID, at) }