package store import ( "errors" "sort" ) // SetAccountEnabled changes only the effective account state. The actor and // target are rechecked inside one transaction; disabling also consumes every // existing credential handle. A global manager lock serializes the last-admin // decision across PostgreSQL connections. func (s *Store) SetAccountEnabled(actorOwner, userID string, expectedStateVersion int64, enabled bool) (Account, error) { if expectedStateVersion < 1 { return Account{}, ErrConflict } pre, err := s.GetAccount(userID) if err != nil { return Account{}, err } tx, err := s.db.Begin() if err != nil { return Account{}, err } defer tx.Rollback() owners := []string{actorOwner, pre.Owner} sort.Strings(owners) for i, owner := range owners { if i > 0 && owner == owners[i-1] { continue } if err := s.dialect.lockAccount(tx, owner); err != nil { return Account{}, err } } if err := s.dialect.lockBootstrap(tx); err != nil { return Account{}, err } target, err := scanAccount(tx.QueryRow(s.q(`SELECT `+accountColumns+` FROM users WHERE id=?`), userID)) if err != nil { return Account{}, err } locked, err := s.lockAccountUsers(tx, actorOwner, target.Owner) if errors.Is(err, ErrNotFound) { return Account{}, ErrAccountForbidden } if err != nil { return Account{}, err } actor, target := locked[actorOwner], locked[target.Owner] if !actor.Enabled || !actor.IsAdmin { return Account{}, ErrAccountForbidden } if target.ID != userID || target.StateVersion != expectedStateVersion || target.Enabled == enabled { return Account{}, ErrConflict } if !enabled && target.IsAdmin && target.HasPassword && !target.MustChangePassword { var managers int if err := tx.QueryRow(s.q(`SELECT COUNT(1) FROM users WHERE enabled=? AND is_admin=? AND password_hash<>'' AND must_change_password=?`), true, true, false).Scan(&managers); err != nil { return Account{}, err } if managers <= 1 { return Account{}, ErrConflict } } result, err := tx.Exec(s.q(`UPDATE users SET enabled=?,state_version=state_version+1,auth_version=auth_version+1 WHERE id=? AND state_version=?`), enabled, userID, expectedStateVersion) if err != nil { return Account{}, err } if n, err := result.RowsAffected(); err != nil { return Account{}, err } else if n != 1 { return Account{}, ErrConflict } if !enabled { if _, err := tx.Exec(s.q(`UPDATE api_keys SET revoked=? WHERE owner=? AND revoked=?`), 1, target.Owner, 0); err != nil { return Account{}, err } if _, err := tx.Exec(s.q(`DELETE FROM sessions WHERE owner=?`), target.Owner); err != nil { return Account{}, err } if _, err := tx.Exec(s.q(`UPDATE login_keys SET status=? WHERE owner=? AND status=?`), LoginKeyStatusRevoked, target.Owner, LoginKeyStatusUnused); err != nil { return Account{}, err } if _, err := tx.Exec(s.q(`UPDATE recovery_proposals SET status='revoked',version=version+1 WHERE owner=? AND status IN ('pending','confirmed')`), target.Owner); err != nil { return Account{}, err } } if err := s.bumpAccountDirectoryRevision(tx, true, false); err != nil { return Account{}, err } action := "account_enabled" if !enabled { action = "account_disabled" } if err := s.insertAccountEvent(tx, actorOwner, "account-state", target.Owner, action, ""); err != nil { return Account{}, err } if err := s.auditAccountMutation(tx, target.Owner, actorOwner, "account.state"); err != nil { return Account{}, err } if err := tx.Commit(); err != nil { return Account{}, err } target.Enabled, target.StateVersion, target.AuthVersion = enabled, expectedStateVersion+1, target.AuthVersion+1 return target, nil }