Files
Agent-System-Backend/internal/store/config.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

325 lines
9.9 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package store
import (
"database/sql"
"errors"
"strings"
core "agent-system-core"
)
// ---- 画像(builtin=owner'' 共享;用户画像按 owner 隔离)----
func (s *Store) SaveProfile(p core.AgentProfile) error {
_, err := s.db.Exec(s.q(`INSERT INTO profiles(id,owner,builtin,payload) VALUES(?,?,?,?)
ON CONFLICT(id) DO UPDATE SET owner=excluded.owner, builtin=excluded.builtin, payload=excluded.payload`),
p.ID, p.Owner, boolInt(p.Builtin), marshalJSON(p))
return err
}
// ListProfiles 返回该租户的画像 + 共享内置画像(owner=”)。
func (s *Store) ListProfiles(owner string) ([]core.AgentProfile, error) {
rows, err := s.db.Query(s.q(`SELECT payload FROM profiles WHERE owner=? OR builtin=1 ORDER BY builtin DESC, id`), owner)
if err != nil {
return nil, err
}
defer rows.Close()
out := []core.AgentProfile{}
for rows.Next() {
var payload string
if err := rows.Scan(&payload); err != nil {
return nil, err
}
p, _ := unmarshalJSON[core.AgentProfile](payload)
out = append(out, p)
}
return out, rows.Err()
}
func (s *Store) GetProfile(owner, id string) (core.AgentProfile, error) {
var payload string
err := s.db.QueryRow(s.q(`SELECT payload FROM profiles WHERE id=? AND (owner=? OR builtin=1)`), id, owner).Scan(&payload)
if errors.Is(err, sql.ErrNoRows) {
return core.AgentProfile{}, ErrNotFound
}
if err != nil {
return core.AgentProfile{}, err
}
return unmarshalJSON[core.AgentProfile](payload)
}
func (s *Store) DeleteProfile(owner, id string) error {
var builtin int
err := s.db.QueryRow(s.q(`SELECT builtin FROM profiles WHERE id=? AND owner=?`), id, owner).Scan(&builtin)
if errors.Is(err, sql.ErrNoRows) {
return ErrNotFound
}
if err != nil {
return err
}
if builtin == 1 {
return errors.New("store: builtin profile cannot be deleted")
}
_, err = s.db.Exec(s.q(`DELETE FROM profiles WHERE id=? AND owner=?`), id, owner)
return err
}
// SaveProfileAccountChecked writes a user profile only while its actor remains
// effective. Profile IDs are global, so an existing foreign or builtin row
// cannot be taken over through the upsert path.
func (s *Store) SaveProfileAccountChecked(actor string, p core.AgentProfile, allowImplicitDefault bool) error {
if p.Builtin || (p.Owner != "" && p.Owner != actor) {
return ErrAccountForbidden
}
p.Owner = actor
return s.withEffectiveAccountWrite(actor, allowImplicitDefault, func(tx *sql.Tx, _ Account) error {
res, err := tx.Exec(s.q(`INSERT INTO profiles(id,owner,builtin,payload) VALUES(?,?,0,?)
ON CONFLICT(id) DO UPDATE SET payload=excluded.payload
WHERE profiles.owner=excluded.owner AND profiles.builtin=0`),
p.ID, actor, marshalJSON(p))
if err != nil {
return err
}
if n, err := res.RowsAffected(); err != nil {
return err
} else if n != 1 {
return ErrNotFound
}
return nil
})
}
// DeleteProfileAccountChecked resolves owner and builtin state from the row
// inside the final actor-guarded transaction before deleting it.
func (s *Store) DeleteProfileAccountChecked(actor, id string, allowImplicitDefault bool) error {
return s.withEffectiveAccountWrite(actor, allowImplicitDefault, func(tx *sql.Tx, _ Account) error {
var owner string
var builtin int
query := s.q(`SELECT owner,builtin FROM profiles WHERE id=?`)
if s.Dialect() == "postgres" {
query += ` FOR UPDATE`
}
err := tx.QueryRow(query, id).Scan(&owner, &builtin)
if errors.Is(err, sql.ErrNoRows) {
return ErrNotFound
}
if err != nil {
return err
}
if owner != actor {
return ErrNotFound
}
if builtin == 1 {
return errors.New("store: builtin profile cannot be deleted")
}
res, err := tx.Exec(s.q(`DELETE FROM profiles WHERE id=? AND owner=? AND builtin=0`), id, actor)
if err != nil {
return err
}
if n, err := res.RowsAffected(); err != nil {
return err
} else if n != 1 {
return ErrNotFound
}
return nil
})
}
// ---- 设置(scope = user | project:<id>;PK=(owner,scope),租户隔离)----
func (s *Store) GetSettingsScope(owner, scope string) (map[string]any, error) {
var payload string
err := s.db.QueryRow(s.q(`SELECT "values" FROM settings WHERE owner=? AND scope=?`), owner, scope).Scan(&payload)
if errors.Is(err, sql.ErrNoRows) {
return map[string]any{}, nil
}
if err != nil {
return nil, err
}
out, err := unmarshalJSON[map[string]any](payload)
if err != nil {
return nil, err
}
if out == nil {
out = map[string]any{}
}
return out, nil
}
// SettingsScopeRef 标识一行设置(owner + scope),不含值。
type SettingsScopeRef struct {
Owner string
Scope string
}
// ListSettingsScopes 返回全部已落库的 scope,供启动迁移遍历。
func (s *Store) ListSettingsScopes() ([]SettingsScopeRef, error) {
rows, err := s.db.Query(s.q(`SELECT owner, scope FROM settings ORDER BY owner, scope`))
if err != nil {
return nil, err
}
defer rows.Close()
out := []SettingsScopeRef{}
for rows.Next() {
var ref SettingsScopeRef
if err := rows.Scan(&ref.Owner, &ref.Scope); err != nil {
return nil, err
}
out = append(out, ref)
}
return out, rows.Err()
}
// UpdateSettingsScope 在事务和 scope 锁内读改写。fn 返回的 map 与当前值
// JSON 相同时不写库。并发合并不再丢失更新。
func (s *Store) UpdateSettingsScope(owner, scope string, fn func(cur map[string]any) (map[string]any, error)) error {
tx, err := s.db.Begin()
if err != nil {
return err
}
defer tx.Rollback()
if err := s.dialect.lockScope(tx, owner, scope); err != nil {
return err
}
var payload string
err = tx.QueryRow(s.q(`SELECT "values" FROM settings WHERE owner=? AND scope=?`), owner, scope).Scan(&payload)
if err != nil && !errors.Is(err, sql.ErrNoRows) {
return err
}
cur := map[string]any{}
if payload != "" {
cur, _ = unmarshalJSON[map[string]any](payload)
if cur == nil {
cur = map[string]any{}
}
}
next, err := fn(cur)
if err != nil {
return err
}
if next == nil {
next = map[string]any{}
}
if marshalJSON(cur) == marshalJSON(next) {
return tx.Commit()
}
_, err = tx.Exec(s.q(`INSERT INTO settings(owner,scope,"values") VALUES(?,?,?)
ON CONFLICT(owner,scope) DO UPDATE SET "values"=excluded."values"`), owner, scope, marshalJSON(next))
if err != nil {
return err
}
return tx.Commit()
}
// UpdateSettingsScopeAccountChecked resolves the request actor independently
// from the persisted settings owner. The actor lock is acquired before the
// settings scope and project row locks in the final write transaction.
func (s *Store) UpdateSettingsScopeAccountChecked(actor, owner, scope string, allowImplicitDefault bool, fn func(cur map[string]any) (map[string]any, error)) error {
if fn == nil {
return ErrAccountForbidden
}
return s.withEffectiveAccountWrite(actor, allowImplicitDefault, func(tx *sql.Tx, account Account) error {
if err := s.dialect.lockScope(tx, owner, scope); err != nil {
return err
}
if err := s.checkSettingsScopeWriteActor(tx, actor, owner, scope, account, allowImplicitDefault); err != nil {
return err
}
var payload string
err := tx.QueryRow(s.q(`SELECT "values" FROM settings WHERE owner=? AND scope=?`), owner, scope).Scan(&payload)
if err != nil && !errors.Is(err, sql.ErrNoRows) {
return err
}
cur := map[string]any{}
if payload != "" {
cur, err = unmarshalJSON[map[string]any](payload)
if err != nil {
return err
}
if cur == nil {
cur = map[string]any{}
}
}
next, err := fn(cur)
if err != nil {
return err
}
if next == nil {
next = map[string]any{}
}
if marshalJSON(cur) == marshalJSON(next) {
return nil
}
_, err = tx.Exec(s.q(`INSERT INTO settings(owner,scope,"values") VALUES(?,?,?)
ON CONFLICT(owner,scope) DO UPDATE SET "values"=excluded."values"`), owner, scope, marshalJSON(next))
return err
})
}
// checkSettingsScopeWriteActor keeps settings ownership and administrator
// policy separate from the actor's enabled-state admission.
func (s *Store) checkSettingsScopeWriteActor(tx *sql.Tx, actor, owner, scope string, account Account, allowImplicitDefault bool) error {
switch {
case scope == "system" && owner == "":
if account.IsAdmin || (allowImplicitDefault && actor == OwnerDefault) {
return nil
}
return ErrAccountForbidden
case scope == "user" && owner == actor:
return nil
case strings.HasPrefix(scope, "project:") && owner == actor:
projectID := strings.TrimPrefix(scope, "project:")
if projectID == "" {
return ErrNotFound
}
_, err := s.projectForAccountWrite(tx, actor, projectID)
return err
default:
return ErrAccountForbidden
}
}
// MergeSettingsScope 读改写走事务 + scope 锁:并发合并不再丢失更新。
func (s *Store) MergeSettingsScope(owner, scope string, values map[string]any) error {
return s.UpdateSettingsScope(owner, scope, func(cur map[string]any) (map[string]any, error) {
return core.MergeSettings(cur, values), nil
})
}
func (s *Store) DeleteSettingsScope(owner, scope string) error {
_, err := s.db.Exec(s.q(`DELETE FROM settings WHERE owner=? AND scope=?`), owner, scope)
return err
}
// ---- 插件注册表(节点级:无租户维度;加载状态在 pluginhost 内存态)----
func (s *Store) UpsertPlugin(id, dir string, m core.PluginManifest) error {
_, err := s.db.Exec(s.q(`INSERT INTO plugins(id,dir,manifest,enabled,loaded_at) VALUES(?,?,?,?,?)
ON CONFLICT(id) DO UPDATE SET dir=excluded.dir, manifest=excluded.manifest, enabled=excluded.enabled, loaded_at=excluded.loaded_at`),
id, dir, marshalJSON(m), 1, nowISO())
return err
}
func (s *Store) ListPlugins() ([]core.PluginManifest, error) {
rows, err := s.db.Query(s.q(`SELECT manifest FROM plugins ORDER BY id`))
if err != nil {
return nil, err
}
defer rows.Close()
out := []core.PluginManifest{}
for rows.Next() {
var payload string
if err := rows.Scan(&payload); err != nil {
return nil, err
}
m, _ := unmarshalJSON[core.PluginManifest](payload)
out = append(out, m)
}
return out, rows.Err()
}
func (s *Store) RemovePlugin(id string) error {
_, err := s.db.Exec(s.q(`DELETE FROM plugins WHERE id=?`), id)
return err
}