Why: RDM delegated rename rebuilt the creator Host and could commit a Backend project rename after the delegated operator was disabled.
Evidence: SQLite and isolated PostgreSQL targeted checks cover operator/creator disable order, two processes, sorted locks, auth-off local compatibility, and HTTP/command Host wiring; exact committed-head replay remains pending.
Impact: Adds a request-bound single-project extension capability and checks operator plus creator in the final Backend transaction. Global ScopedHost cannot derive delegated authority; RDM cross-database atomicity remains outside the promise.
Refs: Approved Documents delegated-projection amendment 5783940, U3 A1-A4 candidate adcd8fe, Dev feature/iam-u3-account-directory-recovery.
264 lines
7.9 KiB
Go
264 lines
7.9 KiB
Go
package store
|
|
|
|
import (
|
|
core "agent-system-core"
|
|
"database/sql"
|
|
"errors"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// lockProjectNames serializes projection identity and name admission across all
|
|
// owners. SQLite acquires its write reservation before any reads; PostgreSQL
|
|
// uses a transaction-scoped advisory lock shared by every projection mutation.
|
|
func (s *Store) lockProjectNames(tx *sql.Tx) error {
|
|
if s.dialect.name() == "sqlite" {
|
|
_, err := tx.Exec(`UPDATE projects SET id=id WHERE 1=0`)
|
|
return err
|
|
}
|
|
return s.dialect.lockScope(tx, "", "host-project-names")
|
|
}
|
|
|
|
// EnsureProject creates a stable projection or verifies an identical retry.
|
|
func (s *Store) EnsureProject(owner, id, name string) (core.Project, error) {
|
|
name = strings.TrimSpace(name)
|
|
if owner == "" || id == "" || name == "" {
|
|
return core.Project{}, errors.New("store: project owner, id and name required")
|
|
}
|
|
tx, err := s.db.Begin()
|
|
if err != nil {
|
|
return core.Project{}, err
|
|
}
|
|
defer tx.Rollback()
|
|
if err = s.lockProjectNames(tx); err != nil {
|
|
return core.Project{}, err
|
|
}
|
|
p, err := s.projectMetadata(tx, id)
|
|
if err == nil {
|
|
if p.Owner != owner {
|
|
return core.Project{}, ErrNotFound
|
|
}
|
|
if p.Name != name {
|
|
return core.Project{}, ErrConflict
|
|
}
|
|
return p, tx.Commit()
|
|
}
|
|
if !errors.Is(err, ErrNotFound) {
|
|
return core.Project{}, err
|
|
}
|
|
available, err := s.projectNameAvailable(tx, name, id)
|
|
if err != nil {
|
|
return core.Project{}, err
|
|
}
|
|
if !available {
|
|
return core.Project{}, ErrConflict
|
|
}
|
|
now := time.Now().UTC()
|
|
p = core.Project{ID: id, Owner: owner, Name: name, CreatedAt: now, UpdatedAt: now, Settings: map[string]any{}}
|
|
_, err = tx.Exec(s.q(`INSERT INTO projects(id,owner,name,settings,created_at,updated_at) VALUES(?,?,?,?,?,?)`), id, owner, name, "{}", now.Format(time.RFC3339Nano), now.Format(time.RFC3339Nano))
|
|
if err != nil {
|
|
return core.Project{}, err
|
|
}
|
|
return p, tx.Commit()
|
|
}
|
|
|
|
func (s *Store) RenameOwnedProject(owner, id, name string) (core.Project, error) {
|
|
name = strings.TrimSpace(name)
|
|
if name == "" {
|
|
return core.Project{}, errors.New("store: project name required")
|
|
}
|
|
tx, err := s.db.Begin()
|
|
if err != nil {
|
|
return core.Project{}, err
|
|
}
|
|
defer tx.Rollback()
|
|
if err = s.lockProjectNames(tx); err != nil {
|
|
return core.Project{}, err
|
|
}
|
|
p, err := s.projectMetadata(tx, id)
|
|
if err != nil {
|
|
return core.Project{}, err
|
|
}
|
|
if p.Owner != owner {
|
|
return core.Project{}, ErrNotFound
|
|
}
|
|
available, err := s.projectNameAvailable(tx, name, id)
|
|
if err != nil {
|
|
return core.Project{}, err
|
|
}
|
|
if !available {
|
|
return core.Project{}, ErrConflict
|
|
}
|
|
p.Name, p.UpdatedAt = name, time.Now().UTC()
|
|
_, err = tx.Exec(s.q(`UPDATE projects SET name=?,updated_at=? WHERE id=? AND owner=?`), p.Name, p.UpdatedAt.Format(time.RFC3339Nano), id, owner)
|
|
if err != nil {
|
|
return core.Project{}, err
|
|
}
|
|
return p, tx.Commit()
|
|
}
|
|
|
|
// projectProjectionForWrite locks an existing projection row after the actor
|
|
// and global project-name locks. A missing row remains ErrNotFound.
|
|
func (s *Store) projectProjectionForWrite(tx *sql.Tx, id string) (core.Project, error) {
|
|
if s.Dialect() == "postgres" {
|
|
var one int
|
|
err := tx.QueryRow(s.q(`SELECT 1 FROM projects WHERE id=? FOR UPDATE`), id).Scan(&one)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return core.Project{}, ErrNotFound
|
|
}
|
|
if err != nil {
|
|
return core.Project{}, err
|
|
}
|
|
}
|
|
return s.projectMetadata(tx, id)
|
|
}
|
|
|
|
// EnsureProjectAccountChecked creates a stable projection or verifies an
|
|
// identical retry after checking the actual actor in the final transaction.
|
|
// The trusted owner scope remains distinct from the actor.
|
|
func (s *Store) EnsureProjectAccountChecked(actor, owner, id, name string, allowImplicitDefault bool) (core.Project, error) {
|
|
if actor == "" {
|
|
return core.Project{}, ErrAccountForbidden
|
|
}
|
|
name = strings.TrimSpace(name)
|
|
if owner == "" || id == "" || name == "" {
|
|
return core.Project{}, errors.New("store: project owner, id and name required")
|
|
}
|
|
var project core.Project
|
|
err := s.withEffectiveProjectActorsWrite(actor, owner, allowImplicitDefault, func(tx *sql.Tx) error {
|
|
if err := s.lockProjectNames(tx); err != nil {
|
|
return err
|
|
}
|
|
var err error
|
|
project, err = s.projectProjectionForWrite(tx, id)
|
|
if err == nil {
|
|
if project.Owner != owner {
|
|
return ErrNotFound
|
|
}
|
|
if project.Name != name {
|
|
return ErrConflict
|
|
}
|
|
return nil
|
|
}
|
|
if !errors.Is(err, ErrNotFound) {
|
|
return err
|
|
}
|
|
available, err := s.projectNameAvailable(tx, name, id)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !available {
|
|
return ErrConflict
|
|
}
|
|
now := time.Now().UTC()
|
|
project = core.Project{ID: id, Owner: owner, Name: name, CreatedAt: now, UpdatedAt: now, Settings: map[string]any{}}
|
|
_, err = tx.Exec(s.q(`INSERT INTO projects(id,owner,name,settings,created_at,updated_at) VALUES(?,?,?,?,?,?)`),
|
|
id, owner, name, "{}", now.Format(time.RFC3339Nano), now.Format(time.RFC3339Nano))
|
|
return err
|
|
})
|
|
if err != nil {
|
|
return core.Project{}, err
|
|
}
|
|
return project, nil
|
|
}
|
|
|
|
// RenameOwnedProjectAccountChecked keeps the stable ID and persisted owner
|
|
// while checking actor state, owner scope and global name uniqueness in one tx.
|
|
func (s *Store) RenameOwnedProjectAccountChecked(actor, owner, id, name string, allowImplicitDefault bool) (core.Project, error) {
|
|
if actor == "" {
|
|
return core.Project{}, ErrAccountForbidden
|
|
}
|
|
name = strings.TrimSpace(name)
|
|
if name == "" {
|
|
return core.Project{}, errors.New("store: project name required")
|
|
}
|
|
var project core.Project
|
|
err := s.withEffectiveProjectActorsWrite(actor, owner, allowImplicitDefault, func(tx *sql.Tx) error {
|
|
if err := s.lockProjectNames(tx); err != nil {
|
|
return err
|
|
}
|
|
var err error
|
|
project, err = s.projectProjectionForWrite(tx, id)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if project.Owner != owner {
|
|
return ErrNotFound
|
|
}
|
|
available, err := s.projectNameAvailable(tx, name, id)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !available {
|
|
return ErrConflict
|
|
}
|
|
project.Name, project.UpdatedAt = name, time.Now().UTC()
|
|
res, err := tx.Exec(s.q(`UPDATE projects SET name=?,updated_at=? WHERE id=? AND owner=?`),
|
|
project.Name, project.UpdatedAt.Format(time.RFC3339Nano), id, owner)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if n, err := res.RowsAffected(); err != nil {
|
|
return err
|
|
} else if n != 1 {
|
|
return ErrNotFound
|
|
}
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return core.Project{}, err
|
|
}
|
|
return project, nil
|
|
}
|
|
|
|
type projectQuerier interface {
|
|
QueryRow(string, ...any) *sql.Row
|
|
Query(string, ...any) (*sql.Rows, error)
|
|
}
|
|
|
|
func (s *Store) projectNameAvailable(q projectQuerier, name, exceptID string) (bool, error) {
|
|
// Use the same Go whitespace rule as admission, including legacy raw names.
|
|
rows, err := q.Query(s.q(`SELECT name FROM projects WHERE id<>?`), exceptID)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
defer rows.Close()
|
|
name = strings.TrimSpace(name)
|
|
for rows.Next() {
|
|
var candidate string
|
|
if err := rows.Scan(&candidate); err != nil {
|
|
return false, err
|
|
}
|
|
if strings.TrimSpace(candidate) == name {
|
|
return false, nil
|
|
}
|
|
}
|
|
return true, rows.Err()
|
|
}
|
|
|
|
func (s *Store) ProjectNameAvailable(name, exceptID string) (bool, error) {
|
|
return s.projectNameAvailable(s.db, name, exceptID)
|
|
}
|
|
func (s *Store) projectMetadata(q projectQuerier, id string) (core.Project, error) {
|
|
var p core.Project
|
|
var settings, ca, ua string
|
|
err := q.QueryRow(s.q(`SELECT id,owner,name,settings,created_at,updated_at FROM projects WHERE id=?`), id).Scan(&p.ID, &p.Owner, &p.Name, &settings, &ca, &ua)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return p, ErrNotFound
|
|
}
|
|
if err != nil {
|
|
return p, err
|
|
}
|
|
p.CreatedAt, _ = time.Parse(time.RFC3339Nano, ca)
|
|
p.UpdatedAt, _ = time.Parse(time.RFC3339Nano, ua)
|
|
p.Settings = map[string]any{}
|
|
if settings != "" && settings != "{}" {
|
|
p.Settings, _ = unmarshalJSON[map[string]any](settings)
|
|
}
|
|
return p, nil
|
|
}
|
|
|
|
// ProjectMetadata is an internal trusted lookup. Capabilities must authorize it
|
|
// before exposing metadata to another owner.
|
|
func (s *Store) ProjectMetadata(id string) (core.Project, error) { return s.projectMetadata(s.db, id) }
|