Files
Agent-System-Backend/internal/store/account_state.go
T
weichen adcd8fefd2 Fence U3 account writes and stabilize directory cursors
Why: Account disable could be followed by late Backend business writes, API key creation could implicitly create users, account bootstrap locks could invert, and directory cursors were process-local or leaked out-of-scope changes.

Evidence: Local go test ./... and isolated PostgreSQL package suites passed on the staged candidate; exact committed-head SELF replay and stage 6 human review remain pending.

Impact: Adds final account transactions and external-effect checks across HTTP, Host, Bus and run manager; tightens API key issuance and persists versioned cursor signing. Delegated RDM rename still needs the separately proposed real-operator contract, so A1 and stage 6 are not complete.

Refs: Documents U3 r1, approved A1-A4 amendment fb3a821, pending delegated-projection amendment 5783940, Dev feature/iam-u3-account-directory-recovery.
2026-10-14 07:33:54 +00:00

107 lines
3.6 KiB
Go

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
}