Files
Agent-System-Backend/internal/store/account_tx.go
T
weichen 4d1ca60067 Expose role and system permission APIs
Why: System, role, and project entry permissions need explicit actions instead of a single administrator flag or unrestricted project creation.

Evidence: The full Backend test suite passed, including role APIs, action isolation, project.create denial/allow, storage migration coverage, and account write fences.

Impact: Adds versioned role and grant endpoints, maps account actions to explicit permissions, and requires project.create authority.

Refs: Focused role and permission system task 3
2026-10-14 17:02:11 +00:00

415 lines
12 KiB
Go

package store
import (
"crypto/rand"
"database/sql"
"encoding/hex"
"errors"
"fmt"
"time"
)
// AccountWrite 是一次账户变更的调用参数。成功审计与变更同事务。
type AccountWrite struct {
Owner string
Name string
PasswordHash string
IsAdmin bool
MustChange bool
ExceptToken string
Purpose string
CreatedBy string
TTL time.Duration
RevokeAllKeys bool
Actor string
InstanceID string
Action string
}
// AccountOutcome 是成功提交后可以对外展示的标识。不含密码、哈希或明文。
type AccountOutcome struct {
Plain string
CredentialID string
OperationID string
}
// CreateActivatedAccount 严格插入账户。passwordHash 为空且 Purpose 非空时,同一事务签发登录 Key。
func (s *Store) CreateActivatedAccount(w AccountWrite) (AccountOutcome, error) {
if w.Action == "" {
w.Action = "account.create"
}
var out AccountOutcome
err := s.accountTx(w, func(tx *sql.Tx, opID string) error {
if s.dialect.name() == "postgres" {
// A first ordinary account and first-admin bootstrap use the same
// admission lock. If bootstrap commits while this call waits, the
// earlier empty-database observation cannot authorize a second
// competing first account.
var before, after int
if err := tx.QueryRow(s.q(`SELECT COUNT(*) FROM users`)).Scan(&before); err != nil {
return err
}
if before == 0 {
if err := s.dialect.lockBootstrap(tx); err != nil {
return err
}
if err := tx.QueryRow(s.q(`SELECT COUNT(*) FROM users`)).Scan(&after); err != nil {
return err
}
if after != 0 {
return ErrConflict
}
}
}
if err := insertAccount(s, tx, w); err != nil {
return err
}
if w.IsAdmin {
if err := s.seedSystemAdminGrantsTx(tx, w.Owner); err != nil {
return err
}
}
if w.PasswordHash == "" && w.Purpose != "" {
plain, id, err := insertLoginKey(s, tx, w)
if err != nil {
return err
}
out.Plain, out.CredentialID = plain, id
}
out.OperationID = opID
return nil
})
return out, err
}
// ApplyPasswordChange 在同一事务里写哈希、更新强制改密标志、作废未使用登录 Key,并处理会话。
func (s *Store) ApplyPasswordChange(w AccountWrite) (AccountOutcome, error) {
if w.Action == "" {
w.Action = "account.password"
}
var out AccountOutcome
err := s.accountTx(w, func(tx *sql.Tx, opID string) error {
res, err := tx.Exec(s.q(`UPDATE users SET password_hash=?, password_set_at=?, must_change_password=?, auth_version=auth_version+1 WHERE owner=?`),
w.PasswordHash, nowISO(), w.MustChange, w.Owner)
if err != nil {
return err
}
if n, _ := res.RowsAffected(); n == 0 {
return ErrNotFound
}
if _, err := tx.Exec(s.q(`UPDATE login_keys SET status=? WHERE owner=? AND status=?`),
LoginKeyStatusRevoked, w.Owner, LoginKeyStatusUnused); err != nil {
return err
}
if w.ExceptToken != "" {
_, err = tx.Exec(s.q(`DELETE FROM sessions WHERE owner=? AND token_hash<>?`), w.Owner, hashKey(w.ExceptToken))
} else {
_, err = tx.Exec(s.q(`DELETE FROM sessions WHERE owner=?`), w.Owner)
}
if err != nil {
return err
}
if w.ExceptToken != "" {
if _, err := tx.Exec(s.q(`UPDATE sessions SET auth_version=(SELECT auth_version FROM users WHERE owner=?) WHERE owner=? AND token_hash=?`),
w.Owner, w.Owner, hashKey(w.ExceptToken)); err != nil {
return err
}
}
out.OperationID = opID
return nil
})
return out, err
}
// BootstrapFirstAdmin 在立即事务(SQLite)或身份行锁(PostgreSQL)内复核空库后建首位管理员。
func (s *Store) BootstrapFirstAdminWrite(w AccountWrite) (AccountOutcome, error) {
w.IsAdmin = true
w.PasswordHash = ""
w.Purpose = LoginKeyPurposeActivate
if w.CreatedBy == "" {
w.CreatedBy = localOwnerActor()
}
if w.Action == "" {
w.Action = "account.bootstrap"
}
var out AccountOutcome
err := s.accountTx(w, func(tx *sql.Tx, opID string) error {
if err := s.dialect.lockBootstrap(tx); err != nil {
return err
}
if err := lockIdentity(s, tx); err != nil {
return err
}
if err := requireEmptyBusiness(s, tx); err != nil {
return err
}
account, err := s.insertPendingAccountTx(tx, w.Owner, AccountProfile{Name: w.Name})
if err != nil {
return err
}
if _, err := tx.Exec(s.q(`UPDATE users SET is_admin=?,initialization_generation=? WHERE id=?`), true, 1, account.ID); err != nil {
return err
}
if err := s.seedSystemAdminGrantsTx(tx, w.Owner); err != nil {
return err
}
var users int
if err := tx.QueryRow(`SELECT COUNT(*) FROM users`).Scan(&users); err != nil {
return err
}
if users != 1 {
return ErrConflict
}
if err := s.insertAccountEvent(tx, w.CreatedBy, entranceOf(w.CreatedBy), w.Owner, "bootstrap_admin", w.Owner); err != nil {
return err
}
plain, err := newActivationPlain()
if err != nil {
return err
}
id := newID("lk")
now := time.Now().UTC()
if _, err := s.insertLoginKeyTx(tx, w.Owner, LoginKeyPurposeActivate, w.CreatedBy, plain, id, now, now.Add(defaultLoginKeyTTL),
grantBinding{origin: CredentialOriginOwnerInitialization, generation: 1, version: 0}); err != nil {
return err
}
if err := s.stampLoginKeyInstance(tx, id, w.InstanceID); err != nil {
return err
}
out.Plain, out.CredentialID, out.OperationID = plain, id, opID
return nil
})
return out, err
}
// RevokeAllAndIssue 把吊销长期密钥、删除会话、作废未使用登录 Key 和签发新 Key 放进一次提交。
func (s *Store) RevokeAllAndIssue(w AccountWrite) (AccountOutcome, error) {
if w.Action == "" {
w.Action = "account.revoke_credentials"
}
var out AccountOutcome
err := s.accountTx(w, func(tx *sql.Tx, opID string) error {
res, err := tx.Exec(s.q(`UPDATE users SET auth_version=auth_version+1 WHERE owner=?`), w.Owner)
if err != nil {
return err
}
if n, err := res.RowsAffected(); err != nil {
return err
} else if n != 1 {
return ErrNotFound
}
if _, err := tx.Exec(s.q(`UPDATE api_keys SET revoked=1 WHERE owner=? AND revoked=0`), w.Owner); err != nil {
return err
}
if _, err := tx.Exec(s.q(`DELETE FROM sessions WHERE owner=?`), w.Owner); err != nil {
return err
}
if _, err := tx.Exec(s.q(`UPDATE login_keys SET status=? WHERE owner=? AND status=?`),
LoginKeyStatusRevoked, w.Owner, LoginKeyStatusUnused); err != nil {
return err
}
plain, id, err := insertLoginKey(s, tx, w)
if err != nil {
return err
}
out.Plain, out.CredentialID, out.OperationID = plain, id, opID
return nil
})
return out, err
}
func (s *Store) accountTx(w AccountWrite, fn func(*sql.Tx, string) error) error {
opID := newID("op")
tx, err := s.db.Begin()
if err != nil {
return err
}
committed := false
defer func() {
if !committed {
_ = tx.Rollback()
}
}()
if w.Owner != "" {
if err := s.dialect.lockAccount(tx, w.Owner); err != nil {
return err
}
}
if w.InstanceID == "" {
if err := tx.QueryRow(`SELECT instance_id FROM agent_system_identity`).Scan(&w.InstanceID); err != nil && !errors.Is(err, sql.ErrNoRows) {
return err
}
}
if err := fn(tx, opID); err != nil {
_ = tx.Rollback()
committed = true
s.recordRejected(w, opID)
return err
}
if s.accountFault != nil {
err = s.accountFault
_ = tx.Rollback()
committed = true
s.recordRejected(w, opID)
return err
}
if err := insertAudit(s, tx, w, opID, "success"); err != nil {
_ = tx.Rollback()
committed = true
s.recordRejected(w, opID)
return err
}
if err := tx.Commit(); err != nil {
// Commit may have reached the database even if the client lost its
// response. Recording a rejection here would assert an unknown outcome.
return err
}
committed = true
return nil
}
func (s *Store) recordRejected(w AccountWrite, opID string) {
_, _ = s.db.Exec(s.q(`INSERT INTO audit_events(time,owner,action,actor,target,entry,instance_id,operation_id,outcome) VALUES(?,?,?,?,?,?,?,?,?)`),
nowISO(), w.Owner, w.Action, w.Actor, w.Owner, "account", w.InstanceID, opID, "rejected")
}
func insertAudit(s *Store, tx *sql.Tx, w AccountWrite, opID, outcome string) error {
_, err := tx.Exec(s.q(`INSERT INTO audit_events(time,owner,action,actor,target,entry,instance_id,operation_id,outcome) VALUES(?,?,?,?,?,?,?,?,?)`),
nowISO(), w.Owner, w.Action, w.Actor, w.Owner, "account", w.InstanceID, opID, outcome)
return err
}
// auditAccountMutation records U3 account operations that have their own
// multi-account transaction and therefore cannot enter accountTx's single
// target callback. It must be called before the caller commits.
func (s *Store) auditAccountMutation(tx *sql.Tx, owner, actor, action string) error {
var instanceID string
if err := tx.QueryRow(`SELECT instance_id FROM agent_system_identity`).Scan(&instanceID); err != nil && !errors.Is(err, sql.ErrNoRows) {
return err
}
return insertAudit(s, tx, AccountWrite{Owner: owner, Actor: actor, Action: action, InstanceID: instanceID}, newID("op"), "success")
}
func (s *Store) stampLoginKeyInstance(tx *sql.Tx, credentialID, instanceID string) error {
if instanceID == "" {
if err := tx.QueryRow(`SELECT instance_id FROM agent_system_identity`).Scan(&instanceID); err != nil && !errors.Is(err, sql.ErrNoRows) {
return err
}
}
_, err := tx.Exec(s.q(`UPDATE login_keys SET instance_id=? WHERE id=?`), instanceID, credentialID)
return err
}
func insertAccount(s *Store, tx *sql.Tx, w AccountWrite) error {
mustChange := w.PasswordHash != "" || w.MustChange
setAt := ""
if w.PasswordHash != "" {
setAt = nowISO()
}
_, err := tx.Exec(s.q(`INSERT INTO users(id,owner,name,created_at,password_hash,password_set_at,must_change_password,is_admin) VALUES(?,?,?,?,?,?,?,?)`),
newID("usr"), w.Owner, w.Name, nowISO(), w.PasswordHash, setAt, mustChange, w.IsAdmin)
if isUniqueViolation(err) {
return ErrConflict
}
return err
}
func insertLoginKey(s *Store, tx *sql.Tx, w AccountWrite) (string, string, error) {
purpose := w.Purpose
if purpose != LoginKeyPurposeActivate && purpose != LoginKeyPurposeRecover {
return "", "", fmt.Errorf("login key: invalid purpose %q", purpose)
}
ttl := w.TTL
if ttl <= 0 {
ttl = defaultLoginKeyTTL
}
if ttl < minLoginKeyTTL {
ttl = minLoginKeyTTL
}
if ttl > maxLoginKeyTTL {
ttl = maxLoginKeyTTL
}
raw := make([]byte, 32)
if _, err := rand.Read(raw); err != nil {
return "", "", err
}
idRaw := make([]byte, 8)
if _, err := rand.Read(idRaw); err != nil {
return "", "", err
}
plain := "alk_" + hex.EncodeToString(raw)
id := "lk_" + hex.EncodeToString(idRaw)
now := time.Now().UTC()
if _, err := s.insertLoginKeyTx(tx, w.Owner, purpose, w.CreatedBy, plain, id, now, now.Add(ttl)); err != nil {
return "", "", err
}
_, err := tx.Exec(s.q(`UPDATE login_keys SET instance_id=? WHERE id=?`), w.InstanceID, id)
return plain, id, err
}
func lockIdentity(s *Store, tx *sql.Tx) error {
q := `SELECT instance_id FROM agent_system_identity`
if s.dialect.name() == "postgres" {
q += ` FOR UPDATE`
}
rows, err := tx.Query(q)
if err != nil {
return err
}
defer rows.Close()
n := 0
for rows.Next() {
var id string
if err := rows.Scan(&id); err != nil {
return err
}
n++
}
if err := rows.Err(); err != nil {
return err
}
if n == 0 {
return fmt.Errorf("%w: agent_system_identity 无行", ErrSystemIdentityMissing)
}
return nil
}
func requireEmptyBusiness(s *Store, tx *sql.Tx) error {
for _, table := range []string{"users", "projects", "conversations", "messages", "runs", "api_keys", "sessions", "login_keys"} {
var n int
if err := tx.QueryRow(`SELECT COUNT(1) FROM ` + table).Scan(&n); err != nil {
return err
}
if n > 0 {
return ErrConflict
}
}
return nil
}
func (s *Store) requireCurrentSchema() error {
applied, err := s.MigrationStatus()
if err != nil || len(applied) == 0 {
return fmt.Errorf("schema 版本不可读;请运行 agent-system migrate,或先启动一次服务")
}
latest := migrations[len(migrations)-1].version
got := applied[len(applied)-1]
if got != latest {
return fmt.Errorf("schema v%d 与本二进制 v%d 不一致;请运行 agent-system migrate,或先启动一次服务", got, latest)
}
return nil
}
// SetAccountFault 让下一次账户事务在提交前失败,用于半态测试。
func (s *Store) SetAccountFault(err error) {
s.accountFault = err
}
// CountAudit 返回指定 outcome 的账户审计条数。
func (s *Store) CountAudit(owner, outcome string) (int, error) {
var n int
err := s.db.QueryRow(s.q(`SELECT COUNT(1) FROM audit_events WHERE owner=? AND entry=? AND outcome=?`), owner, "account", outcome).Scan(&n)
return n, err
}