Why: U7 account audit and identity gates advanced on main while U3 account initialization and recovery were in progress. Evidence: SELF merge-tree checks passed with go test ./... and isolated PostgreSQL store/server tests; exact-head replay follows. Impact: Preserve pending-account grants, proof-linked recovery, protected CLI delivery, account serialization, and atomic audit. Refs: IAM U3 r1; U7 PR #185
142 lines
5.2 KiB
Go
142 lines
5.2 KiB
Go
package store
|
|
|
|
import (
|
|
"crypto/rand"
|
|
"database/sql"
|
|
"encoding/hex"
|
|
"fmt"
|
|
"os"
|
|
"strconv"
|
|
"time"
|
|
)
|
|
|
|
func localOwnerActor() string { return fmt.Sprintf("local-owner:uid:%d", os.Geteuid()) }
|
|
|
|
func newActivationPlain() (string, error) {
|
|
var raw [32]byte
|
|
if _, err := rand.Read(raw[:]); err != nil {
|
|
return "", err
|
|
}
|
|
return "alk_" + hex.EncodeToString(raw[:]), nil
|
|
}
|
|
|
|
// InitializePendingAccountWithAudit reissues only a trusted pending account's
|
|
// activation grant. The account mutation, grant, and success audit commit together.
|
|
func (s *Store) InitializePendingAccountWithAudit(userID string, expectedStateVersion, expectedGeneration int64, instanceID string) (AccountOutcome, error) {
|
|
var out AccountOutcome
|
|
if userID == "" || expectedStateVersion < 1 || expectedGeneration < 0 {
|
|
return out, ErrConflict
|
|
}
|
|
pre, err := s.GetAccount(userID)
|
|
if err != nil {
|
|
return out, err
|
|
}
|
|
plain, err := newActivationPlain()
|
|
if err != nil {
|
|
return out, err
|
|
}
|
|
operator := localOwnerActor()
|
|
w := AccountWrite{Owner: pre.Owner, Actor: strconv.Itoa(os.Geteuid()), InstanceID: instanceID, Action: "account.initialize"}
|
|
err = s.accountTx(w, func(tx *sql.Tx, opID string) error {
|
|
locked, err := s.lockAccountUsers(tx, pre.Owner)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
account := locked[pre.Owner]
|
|
if account.ID != userID || account.StateVersion != expectedStateVersion || account.InitializationGeneration != expectedGeneration {
|
|
return ErrConflict
|
|
}
|
|
if !account.Enabled || account.InitializationState != InitializationPending || account.HasPassword || account.CredentialOrigin != CredentialOriginLegacyUnknown {
|
|
return ErrAccountForbidden
|
|
}
|
|
for _, query := range []string{
|
|
`SELECT COUNT(1) FROM api_keys WHERE owner=? AND revoked=0`,
|
|
`SELECT COUNT(1) FROM sessions WHERE owner=? AND scope='full'`,
|
|
`SELECT COUNT(1) FROM login_keys WHERE owner=? AND grant_origin<>'owner_initialization'`,
|
|
} {
|
|
var unsafeCreds int
|
|
if err := tx.QueryRow(s.q(query), account.Owner).Scan(&unsafeCreds); err != nil {
|
|
return err
|
|
}
|
|
if unsafeCreds != 0 {
|
|
return ErrAccountForbidden
|
|
}
|
|
}
|
|
nextGeneration := account.InitializationGeneration + 1
|
|
nextAuthVersion := account.AuthVersion + 1
|
|
result, err := tx.Exec(s.q(`UPDATE users SET initialization_generation=?,state_version=state_version+1,auth_version=auth_version+1
|
|
WHERE id=? AND state_version=? AND initialization_generation=? AND initialization_state=? AND password_hash='' AND enabled=?`),
|
|
nextGeneration, userID, expectedStateVersion, expectedGeneration, InitializationPending, true)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if n, err := result.RowsAffected(); err != nil || n != 1 {
|
|
return ErrConflict
|
|
}
|
|
now := time.Now().UTC()
|
|
id := newID("lk")
|
|
if _, err := s.insertLoginKeyTx(tx, account.Owner, LoginKeyPurposeActivate, operator, plain, id, now, now.Add(defaultLoginKeyTTL),
|
|
grantBinding{origin: CredentialOriginOwnerInitialization, generation: nextGeneration, version: nextAuthVersion}); err != nil {
|
|
return err
|
|
}
|
|
if err := s.stampLoginKeyInstance(tx, id, instanceID); err != nil {
|
|
return err
|
|
}
|
|
if err := s.insertAccountEvent(tx, operator, "local-owner-cli", account.Owner, "initialization_issued", fmt.Sprintf("user_id=%s generation=%d", userID, nextGeneration)); err != nil {
|
|
return err
|
|
}
|
|
out = AccountOutcome{Plain: plain, CredentialID: id, OperationID: opID}
|
|
return nil
|
|
})
|
|
return out, err
|
|
}
|
|
|
|
// CreateLocalPendingAccountWithAudit admits a new pending account through the
|
|
// same serialized, audited account transaction as other maintenance actions.
|
|
func (s *Store) CreateLocalPendingAccountWithAudit(owner, name string, isAdmin bool, instanceID string) (Account, AccountOutcome, error) {
|
|
var account Account
|
|
var out AccountOutcome
|
|
plain, err := newActivationPlain()
|
|
if err != nil {
|
|
return account, out, err
|
|
}
|
|
operator := localOwnerActor()
|
|
w := AccountWrite{Owner: owner, Actor: strconv.Itoa(os.Geteuid()), InstanceID: instanceID, Action: "account.create.local_pending"}
|
|
err = s.accountTx(w, func(tx *sql.Tx, opID string) error {
|
|
if err := s.dialect.lockBootstrap(tx); err != nil {
|
|
return err
|
|
}
|
|
var users int
|
|
if err := tx.QueryRow(`SELECT COUNT(1) FROM users`).Scan(&users); err != nil {
|
|
return err
|
|
}
|
|
if users == 0 {
|
|
return ErrConflict
|
|
}
|
|
var err error
|
|
account, err = s.insertPendingAccountTx(tx, owner, AccountProfile{Name: name})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if _, err := tx.Exec(s.q(`UPDATE users SET is_admin=?,initialization_generation=? WHERE id=?`), isAdmin, 1, account.ID); err != nil {
|
|
return err
|
|
}
|
|
now := time.Now().UTC()
|
|
id := newID("lk")
|
|
if _, err := s.insertLoginKeyTx(tx, owner, LoginKeyPurposeActivate, operator, plain, id, now, now.Add(defaultLoginKeyTTL),
|
|
grantBinding{origin: CredentialOriginOwnerInitialization, generation: 1, version: 0}); err != nil {
|
|
return err
|
|
}
|
|
if err := s.stampLoginKeyInstance(tx, id, instanceID); err != nil {
|
|
return err
|
|
}
|
|
if err := s.insertAccountEvent(tx, operator, "local-owner-cli", owner, "local_account_created", account.ID); err != nil {
|
|
return err
|
|
}
|
|
account.IsAdmin, account.InitializationGeneration = isAdmin, 1
|
|
out = AccountOutcome{Plain: plain, CredentialID: id, OperationID: opID}
|
|
return nil
|
|
})
|
|
return account, out, err
|
|
}
|