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.
107 lines
3.6 KiB
Go
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
|
|
}
|