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.
325 lines
9.9 KiB
Go
325 lines
9.9 KiB
Go
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
|
||
}
|