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
415 lines
12 KiB
Go
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
|
|
}
|