From 6c0289faf72d461a12a6509d17032b7fc020ba40 Mon Sep 17 00:00:00 2001 From: weichen Date: Wed, 14 Oct 2026 02:57:31 +0000 Subject: [PATCH 01/17] Add versioned account profiles and credential provenance Why: preserve identity while supporting optional profiles and controlled account lifecycle. Evidence: SELF complete SQLite store tests and real PostgreSQL account-model regressions; red receipts retained. Impact: additive account schema with a security compatibility floor; no production migration executed. Refs: IAM U3-1; approved Documents c236d3099dd3e20f7bdd35caf162e0be9a7d52eb --- internal/store/account_model.go | 368 +++++++++++++++++++++++ internal/store/account_model_test.go | 423 +++++++++++++++++++++++++++ internal/store/auth.go | 37 ++- internal/store/migrate.go | 50 ++++ internal/store/migrate_compat.go | 1 + 5 files changed, 863 insertions(+), 16 deletions(-) create mode 100644 internal/store/account_model.go create mode 100644 internal/store/account_model_test.go diff --git a/internal/store/account_model.go b/internal/store/account_model.go new file mode 100644 index 0000000..dc7c2cd --- /dev/null +++ b/internal/store/account_model.go @@ -0,0 +1,368 @@ +package store + +import ( + "crypto/rand" + "database/sql" + "encoding/hex" + "errors" + "fmt" + "net/mail" + "sort" + "strings" + "unicode" + "unicode/utf8" +) + +var ErrAccountForbidden = errors.New("store: account action forbidden") +var ErrInvalidAccountProfile = errors.New("store: invalid account profile") + +// CredentialOrigin describes persisted credential history, not the authority +// of the caller. Unknown values never supply owner proof. +type CredentialOrigin string + +const ( + CredentialOriginLegacyUnknown CredentialOrigin = "legacy_unknown" + CredentialOriginAdminPassword CredentialOrigin = "admin_password" + CredentialOriginOwnerInitialization CredentialOrigin = "owner_initialization" + CredentialOriginOwnerRecovery CredentialOrigin = "owner_recovery" + InitializationLegacy = "legacy" + InitializationPending = "pending" + InitializationInitialized = "initialized" + AuthMethodLegacyUnknown = "legacy_unknown" + AuthMethodPassword = "password" + AuthMethodGrant = "grant" +) + +// TrustedForOwnerProof checks only lineage. The recovery boundary must also +// check the exact live password session, account/version and proposal binding. +func (origin CredentialOrigin) TrustedForOwnerProof() bool { + return origin == CredentialOriginOwnerInitialization || origin == CredentialOriginOwnerRecovery +} + +type AccountProfile struct { + Name string `json:"name"` + Phone string `json:"phone"` + Email string `json:"email"` + StaffNo string `json:"staffNo"` + Department string `json:"department"` + Notes string `json:"notes"` +} + +// Account is an internal account view without credential material. Handlers and +// directory providers must project only the fields authorized for their caller. +type Account struct { + ID string + Owner string + Profile AccountProfile + ProfileVersion int64 + Enabled bool + StateVersion int64 + IsAdmin bool + HasPassword bool + MustChangePassword bool + AuthVersion int + CredentialOrigin CredentialOrigin + InitializationState string + InitializationGeneration int64 +} + +type AccountDirectoryRevision struct { + Public string + StaffNo string + Private string +} + +const accountColumns = `id,owner,COALESCE(name,''),phone,email,staff_no,department,notes, + profile_version,enabled,state_version,is_admin,password_hash<>'',must_change_password, + auth_version,credential_origin,initialization_state,initialization_generation` + +type accountQuerier interface { + QueryRow(string, ...any) *sql.Row +} + +func scanAccount(row *sql.Row) (Account, error) { + var a Account + err := row.Scan(&a.ID, &a.Owner, &a.Profile.Name, &a.Profile.Phone, &a.Profile.Email, + &a.Profile.StaffNo, &a.Profile.Department, &a.Profile.Notes, &a.ProfileVersion, + &a.Enabled, &a.StateVersion, &a.IsAdmin, &a.HasPassword, &a.MustChangePassword, + &a.AuthVersion, &a.CredentialOrigin, &a.InitializationState, &a.InitializationGeneration) + if errors.Is(err, sql.ErrNoRows) { + return Account{}, ErrNotFound + } + return a, err +} + +// GetAccount uses the stable identity; it performs no caller authorization. +func (s *Store) GetAccount(userID string) (Account, error) { + return scanAccount(s.db.QueryRow(s.q(`SELECT `+accountColumns+` FROM users WHERE id=?`), userID)) +} + +func (s *Store) GetAccountByOwner(owner string) (Account, error) { + return s.accountByOwner(s.db, owner) +} + +func (s *Store) accountByOwner(db accountQuerier, owner string) (Account, error) { + return scanAccount(db.QueryRow(s.q(`SELECT `+accountColumns+` FROM users WHERE owner=?`), owner)) +} + +// lockAccountUsers is the common first lock for account transactions. Always +// acquire all participating users in owner order before proposal, grant and +// session locks. PG locks persist to commit; SQLite already holds its immediate +// transaction write lock. Never lock a second user later in an arbitrary order. +func (s *Store) lockAccountUsers(tx *sql.Tx, owners ...string) (map[string]Account, error) { + ordered := append([]string(nil), owners...) + sort.Strings(ordered) + accounts := make(map[string]Account, len(ordered)) + query := s.q(`SELECT ` + accountColumns + ` FROM users WHERE owner=?`) + if s.Dialect() == "postgres" { + query += ` FOR UPDATE` + } + for _, owner := range ordered { + if _, exists := accounts[owner]; exists { + continue + } + a, err := scanAccount(tx.QueryRow(query, owner)) + if err != nil { + return nil, err + } + accounts[owner] = a + } + return accounts, nil +} + +func validateAccountProfilePatch(patch map[string]string) (map[string]string, error) { + limits := map[string]int{"name": 128, "phone": 64, "email": 254, "staffNo": 64, "department": 128, "notes": 2000} + normalized := make(map[string]string, len(patch)) + for field, value := range patch { + limit, ok := limits[field] + if !ok { + return nil, fmt.Errorf("%w: unknown field", ErrInvalidAccountProfile) + } + if !utf8.ValidString(value) { + return nil, fmt.Errorf("%w: %s is not UTF-8", ErrInvalidAccountProfile, field) + } + for _, r := range value { + if unicode.IsControl(r) { + return nil, fmt.Errorf("%w: %s contains control characters", ErrInvalidAccountProfile, field) + } + } + value = strings.TrimSpace(value) + if utf8.RuneCountInString(value) > limit { + return nil, fmt.Errorf("%w: %s exceeds %d characters", ErrInvalidAccountProfile, field, limit) + } + if field == "email" && value != "" { + address, err := mail.ParseAddress(value) + if err != nil || address.Address != value || address.Name != "" { + return nil, fmt.Errorf("%w: invalid email", ErrInvalidAccountProfile) + } + } + normalized[field] = value + } + return normalized, nil +} + +func applyAccountProfilePatch(profile AccountProfile, patch map[string]string) AccountProfile { + for field, value := range patch { + switch field { + case "name": + profile.Name = value + case "phone": + profile.Phone = value + case "email": + profile.Email = value + case "staffNo": + profile.StaffNo = value + case "department": + profile.Department = value + case "notes": + profile.Notes = value + } + } + return profile +} + +// PatchAccountProfile takes an actor from a trusted authentication boundary; +// callers must enforce its full password-session requirement. Current account +// status/admin authorization is rechecked under the same locks as the write. +func (s *Store) PatchAccountProfile(actorOwner, userID string, expectedProfileVersion int64, patch map[string]string) (Account, error) { + if expectedProfileVersion < 1 { + return Account{}, fmt.Errorf("%w: expectedProfileVersion is required", ErrInvalidAccountProfile) + } + normalized, err := validateAccountProfilePatch(patch) + if err != nil { + return Account{}, err + } + tx, err := s.db.Begin() + if err != nil { + return Account{}, err + } + defer tx.Rollback() + actor, err := s.accountByOwner(tx, actorOwner) + if errors.Is(err, ErrNotFound) || (err == nil && (!actor.Enabled || (!actor.IsAdmin && actor.ID != userID))) { + return Account{}, ErrAccountForbidden + } + if err != nil { + return Account{}, err + } + target, err := scanAccount(tx.QueryRow(s.q(`SELECT `+accountColumns+` FROM users WHERE id=?`), userID)) + if err != nil { + return Account{}, err + } + locked, err := s.lockAccountUsers(tx, actorOwner, target.Owner) + if err != nil { + return Account{}, err + } + actor, target = locked[actorOwner], locked[target.Owner] + if !actor.Enabled || (actor.ID != target.ID && !actor.IsAdmin) { + return Account{}, ErrAccountForbidden + } + if target.ID != userID || target.ProfileVersion != expectedProfileVersion { + return Account{}, ErrConflict + } + profile := applyAccountProfilePatch(target.Profile, normalized) + result, err := tx.Exec(s.q(`UPDATE users SET name=?,phone=?,email=?,staff_no=?,department=?,notes=?,profile_version=profile_version+1 WHERE id=? AND profile_version=?`), + profile.Name, profile.Phone, profile.Email, profile.StaffNo, profile.Department, profile.Notes, userID, expectedProfileVersion) + if err != nil { + return Account{}, err + } + if n, err := result.RowsAffected(); err != nil { + return Account{}, err + } else if n != 1 { + return Account{}, ErrConflict + } + if profile != target.Profile { + if err := s.bumpAccountDirectoryRevision(tx, profile.Name != target.Profile.Name, profile.StaffNo != target.Profile.StaffNo); err != nil { + return Account{}, err + } + } + // Audit field names and versions only, never profile values. + fields := make([]string, 0, len(normalized)) + for field := range normalized { + fields = append(fields, field) + } + sort.Strings(fields) + if err := s.insertAccountEvent(tx, actorOwner, "account-profile", target.Owner, "profile_updated", fmt.Sprintf("version=%d fields=%s", expectedProfileVersion+1, strings.Join(fields, ","))); err != nil { + return Account{}, err + } + if err := tx.Commit(); err != nil { + return Account{}, err + } + target.Profile, target.ProfileVersion = profile, expectedProfileVersion+1 + return target, nil +} + +// CreatePendingAccount is the ordinary account-management primitive. It cannot +// set passwords, administrator privileges or trusted credential provenance. +func (s *Store) CreatePendingAccount(actorOwner, owner string, profile AccountProfile) (Account, error) { + tx, err := s.db.Begin() + if err != nil { + return Account{}, err + } + defer tx.Rollback() + locked, err := s.lockAccountUsers(tx, actorOwner) + if errors.Is(err, ErrNotFound) { + return Account{}, ErrAccountForbidden + } + if err != nil { + return Account{}, err + } + actor := locked[actorOwner] + if !actor.Enabled || !actor.IsAdmin { + return Account{}, ErrAccountForbidden + } + a, err := s.insertPendingAccountTx(tx, owner, profile) + if err != nil { + return Account{}, err + } + if err := s.insertAccountEvent(tx, actorOwner, "account-management", owner, "account_created_pending", a.ID); err != nil { + return Account{}, err + } + if err := tx.Commit(); err != nil { + return Account{}, err + } + return a, nil +} + +// insertPendingAccountTx lets the trusted local initializer create and grant in +// one transaction without impersonating an administrator. The caller owns its +// authorization, audit and commit; this helper always inserts an ordinary user. +func (s *Store) insertPendingAccountTx(tx *sql.Tx, owner string, profile AccountProfile) (Account, error) { + if owner == "" || owner != strings.TrimSpace(owner) || !utf8.ValidString(owner) { + return Account{}, fmt.Errorf("%w: invalid owner", ErrInvalidAccountProfile) + } + for _, r := range owner { + if unicode.IsControl(r) { + return Account{}, fmt.Errorf("%w: invalid owner", ErrInvalidAccountProfile) + } + } + patch, err := validateAccountProfilePatch(map[string]string{"name": profile.Name, "phone": profile.Phone, "email": profile.Email, "staffNo": profile.StaffNo, "department": profile.Department, "notes": profile.Notes}) + if err != nil { + return Account{}, err + } + profile = applyAccountProfilePatch(AccountProfile{}, patch) + _, err = tx.Exec(s.q(`INSERT INTO users(id,owner,name,phone,email,staff_no,department,notes,created_at,initialization_state) VALUES(?,?,?,?,?,?,?,?,?,?)`), + newID("usr"), owner, profile.Name, profile.Phone, profile.Email, profile.StaffNo, profile.Department, profile.Notes, nowISO(), InitializationPending) + if isUniqueViolation(err) { + return Account{}, ErrConflict + } + if err != nil { + return Account{}, err + } + if err := s.bumpAccountDirectoryRevision(tx, true, true); err != nil { + return Account{}, err + } + return s.accountByOwner(tx, owner) +} + +func (s *Store) GetAccountDirectoryRevision() (AccountDirectoryRevision, error) { + var revision AccountDirectoryRevision + err := s.db.QueryRow(`SELECT public_revision,staff_no_revision,private_revision FROM account_directory_revision WHERE id=1`).Scan(&revision.Public, &revision.StaffNo, &revision.Private) + return revision, err +} + +func newAccountDirectoryRevision() (string, error) { + var bytes [16]byte + if _, err := rand.Read(bytes[:]); err != nil { + return "", err + } + return hex.EncodeToString(bytes[:]), nil +} + +// Call only after all user locks. Public covers name/identity/valid; StaffNo is +// composed into a directory version only when that field is authorized. Private +// changes on every profile edit, without exposing hidden-field edits to callers. +func (s *Store) bumpAccountDirectoryRevision(tx *sql.Tx, publicChanged, staffNoChanged bool) error { + private, err := newAccountDirectoryRevision() + if err != nil { + return err + } + assignments := []string{`private_revision=?`} + args := []any{private} + if publicChanged { + public, err := newAccountDirectoryRevision() + if err != nil { + return err + } + assignments = append(assignments, `public_revision=?`) + args = append(args, public) + } + if staffNoChanged { + staffNo, err := newAccountDirectoryRevision() + if err != nil { + return err + } + assignments = append(assignments, `staff_no_revision=?`) + args = append(args, staffNo) + } + query := `UPDATE account_directory_revision SET ` + strings.Join(assignments, ",") + ` WHERE id=1` + result, err := tx.Exec(s.q(query), args...) + if err != nil { + return err + } + if n, err := result.RowsAffected(); err != nil { + return err + } else if n != 1 { + return errors.New("store: account directory revision missing") + } + return nil +} diff --git a/internal/store/account_model_test.go b/internal/store/account_model_test.go new file mode 100644 index 0000000..abc7db3 --- /dev/null +++ b/internal/store/account_model_test.go @@ -0,0 +1,423 @@ +package store + +import ( + "errors" + "os" + "strings" + "sync" + "testing" +) + +// A legacy account must retain its identity and credentials and never acquire +// pending enrollment or trusted provenance merely by applying the migration. +func TestAccountModelLegacyMigrationDefaults(t *testing.T) { + st := openTestStore(t) + t.Logf("account model dialect=%s", st.Dialect()) + if err := st.MigrateDown(30); err != nil { + t.Fatal(err) + } + if err := st.CreateAccount("legacy", "原姓名", "existing-hash", true); err != nil { + t.Fatal(err) + } + if err := st.CreateAccount("legacy-empty", "", "", false); err != nil { + t.Fatal(err) + } + token, err := st.CreateSession("legacy") + if err != nil { + t.Fatal(err) + } + before, err := st.GetUser("legacy") + if err != nil { + t.Fatal(err) + } + if err := st.migrate(); err != nil { + t.Fatal(err) + } + var id, owner, name, hash, origin, initialization string + var enabled, admin, mustChange bool + var profileVersion, stateVersion, generation int64 + err = st.db.QueryRow(`SELECT id,owner,name,password_hash,is_admin,must_change_password, + enabled,profile_version,state_version,credential_origin,initialization_state,initialization_generation + FROM users WHERE owner='legacy'`).Scan(&id, &owner, &name, &hash, &admin, &mustChange, + &enabled, &profileVersion, &stateVersion, &origin, &initialization, &generation) + if err != nil { + t.Fatalf("account foundation migration is missing: %v", err) + } + if id != before.ID || owner != "legacy" || name != "原姓名" || hash != "existing-hash" || !admin || !mustChange { + t.Fatal("migration changed existing identity or credentials") + } + if !enabled || profileVersion != 1 || stateVersion != 1 || origin != "legacy_unknown" || initialization != "legacy" || generation != 0 { + t.Fatalf("unsafe migrated defaults: enabled=%v profile=%d state=%d origin=%q initialization=%q generation=%d", enabled, profileVersion, stateVersion, origin, initialization, generation) + } + if SchemaCompatFloor() < 31 { + t.Fatal("old authentication writers must be excluded by the compatibility floor") + } + empty, err := st.GetAccountByOwner("legacy-empty") + if err != nil || empty.HasPassword || empty.InitializationState != InitializationLegacy || empty.CredentialOrigin.TrustedForOwnerProof() { + t.Fatalf("a credential-free legacy account was inferred as pending/trusted: %+v %v", empty, err) + } + var sessionMethod, sessionOrigin, grantID string + if err := st.db.QueryRow(st.q(`SELECT auth_method,credential_origin,grant_id FROM sessions WHERE token_hash=?`), hashKey(token)).Scan(&sessionMethod, &sessionOrigin, &grantID); err != nil { + t.Fatal(err) + } + if sessionMethod != "legacy_unknown" || sessionOrigin != "legacy_unknown" || grantID != "" { + t.Fatal("migration invented proof metadata on a pre-existing session") + } +} + +func TestAccountModelPendingProfileIdentity(t *testing.T) { + st := openTestStore(t) + t.Logf("account model dialect=%s", st.Dialect()) + if err := st.CreateAccount("admin", "", "admin-hash", true); err != nil { + t.Fatal(err) + } + a, err := st.CreatePendingAccount("admin", "alice", AccountProfile{}) + if err != nil { + t.Fatal(err) + } + if a.ID == "" || a.Owner != "alice" || a.Profile != (AccountProfile{}) || !a.Enabled || a.IsAdmin || a.HasPassword || a.MustChangePassword || a.ProfileVersion != 1 || a.StateVersion != 1 || a.AuthVersion != 0 || a.InitializationState != InitializationPending || a.InitializationGeneration != 0 || a.CredentialOrigin != CredentialOriginLegacyUnknown { + t.Fatalf("pending account must be ordinary and have no credentials: %+v", a) + } + if _, err := st.CreatePendingAccount("admin", "alice", AccountProfile{Name: "other"}); !errors.Is(err, ErrConflict) { + t.Fatalf("duplicate owner: %v", err) + } + if err := st.MergeSettingsScope("alice", "user", map[string]any{"identity-sentinel": "preserved"}); err != nil { + t.Fatal(err) + } + patch := map[string]string{"name": " 同名 ", "phone": " +86 123 ", "email": "alice@example.org", "staffNo": " A-1 ", "department": "研发", "notes": "可选备注"} + after, err := st.PatchAccountProfile("alice", a.ID, 1, patch) + if err != nil { + t.Fatal(err) + } + if after.Profile.Name != "同名" || after.Profile.Phone != "+86 123" || after.Profile.StaffNo != "A-1" || after.ProfileVersion != 2 { + t.Fatalf("profile normalization: %+v", after) + } + for k := range patch { + patch[k] = "" + } + after, err = st.PatchAccountProfile("admin", a.ID, 2, patch) + if err != nil { + t.Fatal(err) + } + if after.ID != a.ID || after.Owner != a.Owner || after.Profile != (AccountProfile{}) || after.ProfileVersion != 3 || after.StateVersion != a.StateVersion || after.AuthVersion != a.AuthVersion || after.CredentialOrigin != a.CredentialOrigin || after.InitializationState != a.InitializationState || after.IsAdmin || after.HasPassword { + t.Fatalf("profile changed identity/auth: %+v", after) + } + settings, err := st.GetSettingsScope("alice", "user") + if err != nil || settings["identity-sentinel"] != "preserved" { + t.Fatalf("business data changed: %v %v", settings, err) + } + other, err := st.CreatePendingAccount("admin", "bob", AccountProfile{Name: "同名"}) + if err != nil || other.ID == a.ID { + t.Fatalf("same names must have distinct identities: %+v %v", other, err) + } +} + +func TestAccountModelProfileAuthorization(t *testing.T) { + st := openTestStore(t) + for _, owner := range []string{"alice", "bob", "admin"} { + if err := st.CreateAccount(owner, "", "existing-hash", owner == "admin"); err != nil { + t.Fatal(err) + } + } + a, err := st.GetAccountByOwner("alice") + if err != nil { + t.Fatal(err) + } + for _, actor := range []string{"", "missing", "bob"} { + if _, err := st.PatchAccountProfile(actor, a.ID, 1, map[string]string{"name": "forbidden"}); !errors.Is(err, ErrAccountForbidden) { + t.Fatalf("actor %q: %v", actor, err) + } + } + if _, err := st.PatchAccountProfile("bob", "missing-user-id", 1, map[string]string{"name": "forbidden"}); !errors.Is(err, ErrAccountForbidden) { + t.Fatalf("non-admin must not learn target existence: %v", err) + } + if _, err := st.PatchAccountProfile("alice", a.ID, 1, map[string]string{"name": "self"}); err != nil { + t.Fatal(err) + } + if _, err := st.db.Exec(st.q(`UPDATE users SET enabled=? WHERE owner=?`), false, "alice"); err != nil { + t.Fatal(err) + } + if _, err := st.PatchAccountProfile("alice", a.ID, 2, map[string]string{"name": "disabled"}); !errors.Is(err, ErrAccountForbidden) { + t.Fatalf("disabled self: %v", err) + } + if _, err := st.PatchAccountProfile("admin", a.ID, 2, map[string]string{"name": "admin"}); err != nil { + t.Fatal(err) + } + if _, err := st.db.Exec(st.q(`UPDATE users SET is_admin=? WHERE owner=?`), false, "admin"); err != nil { + t.Fatal(err) + } + if _, err := st.PatchAccountProfile("admin", a.ID, 3, map[string]string{"name": "stale admin"}); !errors.Is(err, ErrAccountForbidden) { + t.Fatalf("demoted admin: %v", err) + } + if _, err := st.CreatePendingAccount("admin", "forbidden", AccountProfile{}); !errors.Is(err, ErrAccountForbidden) { + t.Fatalf("demoted creator: %v", err) + } + if _, err := st.db.Exec(st.q(`UPDATE users SET is_admin=?,enabled=? WHERE owner=?`), true, false, "admin"); err != nil { + t.Fatal(err) + } + if _, err := st.PatchAccountProfile("admin", a.ID, 3, map[string]string{"name": "disabled admin"}); !errors.Is(err, ErrAccountForbidden) { + t.Fatalf("disabled admin: %v", err) + } + if _, err := st.CreatePendingAccount("admin", "forbidden", AccountProfile{}); !errors.Is(err, ErrAccountForbidden) { + t.Fatalf("disabled creator: %v", err) + } + got, err := st.GetAccount(a.ID) + if err != nil { + t.Fatal(err) + } + if got.Profile.Name != "admin" || got.ProfileVersion != 3 || !got.HasPassword || !got.MustChangePassword || got.AuthVersion != 0 || got.CredentialOrigin != CredentialOriginLegacyUnknown { + t.Fatalf("unexpected rejection side effects: %+v", got) + } + var hash string + if err := st.db.QueryRow(`SELECT password_hash FROM users WHERE owner='alice'`).Scan(&hash); err != nil || hash != "existing-hash" { + t.Fatalf("profile modified hash: %v", err) + } +} + +func TestAccountModelProfileValidation(t *testing.T) { + st := openTestStore(t) + if err := st.CreateAccount("alice", "", "", true); err != nil { + t.Fatal(err) + } + a, err := st.GetAccountByOwner("alice") + if err != nil { + t.Fatal(err) + } + limits := map[string]int{"name": 128, "phone": 64, "email": 254, "staffNo": 64, "department": 128, "notes": 2000} + for field, limit := range limits { + if _, err := st.PatchAccountProfile("alice", a.ID, 1, map[string]string{field: strings.Repeat("界", limit+1)}); !errors.Is(err, ErrInvalidAccountProfile) { + t.Errorf("overlong %s: %v", field, err) + } + } + for _, value := range []string{"x\x00y", "x\ny", "x\u0085y", string([]byte{0xff})} { + if _, err := st.PatchAccountProfile("alice", a.ID, 1, map[string]string{"notes": value}); !errors.Is(err, ErrInvalidAccountProfile) { + t.Errorf("invalid text %q: %v", value, err) + } + } + for _, value := range []string{"missing-at", "a@", "@example.org", "Name ", "a b@example.org", "a@example.org,b@example.org"} { + if _, err := st.PatchAccountProfile("alice", a.ID, 1, map[string]string{"email": value}); !errors.Is(err, ErrInvalidAccountProfile) { + t.Errorf("invalid email %q: %v", value, err) + } + } + for _, field := range []string{"owner", "id", "isAdmin", "enabled", "roles", "password", "proof", "profileVersion", "staff_no", "unknown"} { + if _, err := st.PatchAccountProfile("alice", a.ID, 1, map[string]string{"name": "must not write", field: "x"}); !errors.Is(err, ErrInvalidAccountProfile) { + t.Errorf("unknown field %q: %v", field, err) + } + } + if _, err := st.PatchAccountProfile("alice", a.ID, 0, map[string]string{"name": "x"}); !errors.Is(err, ErrInvalidAccountProfile) { + t.Fatalf("missing version: %v", err) + } + if _, err := st.CreatePendingAccount("alice", "bad", AccountProfile{Email: "bad"}); !errors.Is(err, ErrInvalidAccountProfile) { + t.Fatalf("invalid create: %v", err) + } + if _, err := st.GetAccountByOwner("bad"); !errors.Is(err, ErrNotFound) { + t.Fatalf("invalid create left account: %v", err) + } + valid := map[string]string{"name": strings.Repeat("界", 128), "phone": strings.Repeat("1", 64), "staffNo": strings.Repeat("号", 64), "department": strings.Repeat("部", 128), "notes": strings.Repeat("注", 2000), "email": strings.Repeat("a", 64) + "@" + strings.Repeat("b", 63) + "." + strings.Repeat("c", 63) + "." + strings.Repeat("d", 61)} + got, err := st.PatchAccountProfile("alice", a.ID, 1, valid) + if err != nil || got.ProfileVersion != 2 { + t.Fatalf("Unicode and exact length boundaries: %+v %v", got, err) + } +} + +func TestAccountModelDirectoryRevisionsAndRollback(t *testing.T) { + st := openTestStore(t) + if err := st.CreateAccount("admin", "", "", true); err != nil { + t.Fatal(err) + } + initial, err := st.GetAccountDirectoryRevision() + if err != nil { + t.Fatal(err) + } + a, err := st.CreatePendingAccount("admin", "alice", AccountProfile{}) + if err != nil { + t.Fatal(err) + } + created, err := st.GetAccountDirectoryRevision() + if err != nil { + t.Fatal(err) + } + if created.Public == initial.Public || created.StaffNo == initial.StaffNo || created.Private == initial.Private || created.Public == "" || created.StaffNo == "" || created.Private == "" { + t.Fatal("creation did not invalidate directory") + } + if _, err := st.PatchAccountProfile("alice", a.ID, 1, map[string]string{"phone": "123", "email": "a@example.org", "department": "private", "notes": "secret"}); err != nil { + t.Fatal(err) + } + private, err := st.GetAccountDirectoryRevision() + if err != nil { + t.Fatal(err) + } + if private.Public != created.Public || private.StaffNo != created.StaffNo || private.Private == created.Private { + t.Fatal("private edits must change only the private revision") + } + if _, err := st.PatchAccountProfile("alice", a.ID, 2, map[string]string{"name": "visible"}); err != nil { + t.Fatal(err) + } + public, err := st.GetAccountDirectoryRevision() + if err != nil { + t.Fatal(err) + } + if public.Public == private.Public || public.StaffNo != private.StaffNo || public.Private == private.Private { + t.Fatal("name edit must change public/private revisions and preserve the staff number revision") + } + if _, err := st.PatchAccountProfile("alice", a.ID, 3, map[string]string{"staffNo": "visible staff"}); err != nil { + t.Fatal(err) + } + staff, err := st.GetAccountDirectoryRevision() + if err != nil { + t.Fatal(err) + } + if staff.Public != public.Public || staff.StaffNo == public.StaffNo || staff.Private == public.Private { + t.Fatal("staff number edit must preserve the default candidate revision and change staff number/private revisions") + } + if _, err := st.PatchAccountProfile("alice", a.ID, 4, map[string]string{"name": "visible"}); err != nil { + t.Fatal(err) + } + noChange, err := st.GetAccountDirectoryRevision() + if err != nil || noChange != staff { + t.Fatalf("no-op changed visible directory: %+v %v", noChange, err) + } + before, err := st.GetAccount(a.ID) + if err != nil { + t.Fatal(err) + } + if _, err := st.db.Exec(`DROP TABLE account_events`); err != nil { + t.Fatal(err) + } + if _, err := st.PatchAccountProfile("alice", a.ID, before.ProfileVersion, map[string]string{"name": "must roll back", "staffNo": "must roll back"}); err == nil { + t.Fatal("audit failure allowed mutation") + } + after, err := st.GetAccount(a.ID) + if err != nil || after != before { + t.Fatalf("audit failure partially changed account: %+v %v", after, err) + } + revision, err := st.GetAccountDirectoryRevision() + if err != nil || revision != staff { + t.Fatalf("audit failure changed revision: %+v %v", revision, err) + } + if _, err := st.CreatePendingAccount("admin", "rollback", AccountProfile{}); err == nil { + t.Fatal("create succeeded without audit") + } + if _, err := st.GetAccountByOwner("rollback"); !errors.Is(err, ErrNotFound) { + t.Fatalf("failed creation leaked account: %v", err) + } +} + +// Open a genuinely separate connection pool to the exact isolated test database. +func reopenAccountModelStore(t *testing.T, st *Store) *Store { + t.Helper() + var next *Store + var err error + if st.Dialect() == "postgres" { + var schema string + if err := st.db.QueryRow(`SELECT current_schema()`).Scan(&schema); err != nil { + t.Fatal(err) + } + next, err = OpenPostgres(withTestSearchPath(os.Getenv("AGENT_PG_TEST_DSN"), schema), st.dataDir) + } else { + var seq int + var name, path string + if err := st.db.QueryRow(`PRAGMA database_list`).Scan(&seq, &name, &path); err != nil { + t.Fatal(err) + } + next, err = OpenSQLite(path) + } + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { next.Close() }) + return next +} + +func TestAccountModelConcurrentCASAndReopen(t *testing.T) { + st := openTestStore(t) + other := reopenAccountModelStore(t, st) + t.Logf("account model dialect=%s two independent pools", st.Dialect()) + if err := st.CreateAccount("alice", "", "", false); err != nil { + t.Fatal(err) + } + a, err := st.GetAccountByOwner("alice") + if err != nil { + t.Fatal(err) + } + start := make(chan struct{}) + errs := make(chan error, 2) + var wg sync.WaitGroup + for i, conn := range []*Store{st, other} { + wg.Add(1) + go func(i int, conn *Store) { + defer wg.Done() + <-start + _, err := conn.PatchAccountProfile("alice", a.ID, 1, map[string]string{"name": []string{"first", "second"}[i]}) + errs <- err + }(i, conn) + } + close(start) + wg.Wait() + close(errs) + wins, conflicts := 0, 0 + for err := range errs { + if err == nil { + wins++ + } else if errors.Is(err, ErrConflict) { + conflicts++ + } else { + t.Fatal(err) + } + } + if wins != 1 || conflicts != 1 { + t.Fatalf("CAS wins=%d conflicts=%d", wins, conflicts) + } + if _, err := st.db.Exec(st.q(`UPDATE users SET enabled=? WHERE owner=?`), false, "alice"); err != nil { + t.Fatal(err) + } + revision, err := st.GetAccountDirectoryRevision() + if err != nil { + t.Fatal(err) + } + reopened := reopenAccountModelStore(t, st) + got, err := reopened.GetAccount(a.ID) + if err != nil || got.ProfileVersion != 2 || got.ID != a.ID || got.Enabled || got.CredentialOrigin != CredentialOriginLegacyUnknown || got.InitializationState != InitializationLegacy { + t.Fatalf("persistent model changed after reopen: %+v %v", got, err) + } + next, err := reopened.GetAccountDirectoryRevision() + if err != nil || next != revision { + t.Fatalf("revision changed on reopen: %+v %v", next, err) + } + events, err := reopened.ListAccountEvents("alice", 10) + if err != nil || len(events) != 1 { + t.Fatalf("CAS must audit only winner: %v %v", events, err) + } +} + +func TestAccountModelUnknownProvenance(t *testing.T) { + for _, origin := range []CredentialOrigin{"", "unknown", "admin_password", "legacy_unknown"} { + if origin.TrustedForOwnerProof() { + t.Errorf("untrusted origin %q accepted", origin) + } + } + for _, origin := range []CredentialOrigin{CredentialOriginOwnerInitialization, CredentialOriginOwnerRecovery} { + if !origin.TrustedForOwnerProof() { + t.Errorf("trusted origin %q rejected", origin) + } + } + st := openTestStore(t) + if err := st.CreateAccount("legacy", "", "hash", false); err != nil { + t.Fatal(err) + } + token, err := st.CreateSession("legacy") + if err != nil { + t.Fatal(err) + } + var method, origin, grant string + if err := st.db.QueryRow(st.q(`SELECT auth_method,credential_origin,grant_id FROM sessions WHERE token_hash=?`), hashKey(token)).Scan(&method, &origin, &grant); err != nil { + t.Fatal(err) + } + if method != "legacy_unknown" || origin != "legacy_unknown" || grant != "" { + t.Fatal("legacy session received proof provenance") + } + state, err := st.GetUserAuthState("legacy") + if err != nil || state.UserID == "" || !state.Enabled || state.StateVersion != 1 || state.CredentialOrigin.TrustedForOwnerProof() || state.InitializationState != InitializationLegacy { + t.Fatalf("unsafe auth metadata: %+v %v", state, err) + } +} diff --git a/internal/store/auth.go b/internal/store/auth.go index 4d31272..5335f6f 100644 --- a/internal/store/auth.go +++ b/internal/store/auth.go @@ -260,28 +260,33 @@ func (s *Store) ClearMustChangePassword(owner string) error { // UserAuthState 登录与身份视图。 type UserAuthState struct { - Owner string - Name string - IsAdmin bool - MustChangePassword bool - HasPassword bool + UserID string + Owner string + Name string + IsAdmin bool + MustChangePassword bool + HasPassword bool + Enabled bool + StateVersion int64 + AuthVersion int + CredentialOrigin CredentialOrigin + InitializationState string + InitializationGeneration int64 } // GetUserAuthState 按 owner 取认证态(含会话/APIOwner 路径共用)。 func (s *Store) GetUserAuthState(owner string) (UserAuthState, error) { - var st UserAuthState - var hash, ca string - // 直接扫进 bool:PG 返回原生 boolean;SQLite 整数 0/1 经 driver.Bool 转换同样成立。 - err := s.db.QueryRow(s.q(`SELECT owner,COALESCE(name,''),password_hash,must_change_password,is_admin,created_at FROM users WHERE owner=?`), owner). - Scan(&st.Owner, &st.Name, &hash, &st.MustChangePassword, &st.IsAdmin, &ca) - if errors.Is(err, sql.ErrNoRows) { - return st, ErrNotFound - } + a, err := s.GetAccountByOwner(owner) if err != nil { - return st, err + return UserAuthState{}, err } - st.HasPassword = hash != "" - return st, nil + return UserAuthState{ + UserID: a.ID, Owner: a.Owner, Name: a.Profile.Name, IsAdmin: a.IsAdmin, + MustChangePassword: a.MustChangePassword, HasPassword: a.HasPassword, + Enabled: a.Enabled, StateVersion: a.StateVersion, AuthVersion: a.AuthVersion, + CredentialOrigin: a.CredentialOrigin, InitializationState: a.InitializationState, + InitializationGeneration: a.InitializationGeneration, + }, nil } // VerifyLogin 校验用户名密码。 diff --git a/internal/store/migrate.go b/internal/store/migrate.go index c31e0e8..46be44b 100644 --- a/internal/store/migrate.go +++ b/internal/store/migrate.go @@ -848,9 +848,59 @@ func init() { }, down: map[string][]string{"sqlite": {`DROP TABLE IF EXISTS project_event_shares`}, "postgres": {`DROP TABLE IF EXISTS project_event_shares`}}, }, + migration{version: 31, name: "account_profile_state_and_provenance", + up: map[string][]string{ + "sqlite": accountFoundationUp("INTEGER NOT NULL DEFAULT 1", "lower(hex(randomblob(16)))"), + "postgres": accountFoundationUp("BOOLEAN NOT NULL DEFAULT TRUE", "md5(random()::text || clock_timestamp()::text)"), + }, + down: map[string][]string{"sqlite": accountFoundationDown, "postgres": accountFoundationDown}, + }, ) } +// v31 preserves legacy identities and credentials. Only the explicit pending +// account primitive sets pending initialization; migration cannot infer proof. +func accountFoundationUp(enabledType, revisionExpression string) []string { + return []string{ + `ALTER TABLE users ADD COLUMN phone TEXT NOT NULL DEFAULT ''`, + `ALTER TABLE users ADD COLUMN email TEXT NOT NULL DEFAULT ''`, + `ALTER TABLE users ADD COLUMN staff_no TEXT NOT NULL DEFAULT ''`, + `ALTER TABLE users ADD COLUMN department TEXT NOT NULL DEFAULT ''`, + `ALTER TABLE users ADD COLUMN notes TEXT NOT NULL DEFAULT ''`, + `ALTER TABLE users ADD COLUMN profile_version BIGINT NOT NULL DEFAULT 1 CHECK(profile_version>=1)`, + `ALTER TABLE users ADD COLUMN enabled ` + enabledType, + `ALTER TABLE users ADD COLUMN state_version BIGINT NOT NULL DEFAULT 1 CHECK(state_version>=1)`, + `ALTER TABLE users ADD COLUMN credential_origin TEXT NOT NULL DEFAULT 'legacy_unknown'`, + `ALTER TABLE users ADD COLUMN initialization_state TEXT NOT NULL DEFAULT 'legacy'`, + `ALTER TABLE users ADD COLUMN initialization_generation BIGINT NOT NULL DEFAULT 0 CHECK(initialization_generation>=0)`, + `ALTER TABLE sessions ADD COLUMN auth_method TEXT NOT NULL DEFAULT 'legacy_unknown'`, + `ALTER TABLE sessions ADD COLUMN credential_origin TEXT NOT NULL DEFAULT 'legacy_unknown'`, + `ALTER TABLE sessions ADD COLUMN grant_id TEXT NOT NULL DEFAULT ''`, + `CREATE TABLE account_directory_revision(id INTEGER PRIMARY KEY CHECK(id=1),public_revision TEXT NOT NULL,staff_no_revision TEXT NOT NULL,private_revision TEXT NOT NULL)`, + `INSERT INTO account_directory_revision(id,public_revision,staff_no_revision,private_revision) VALUES(1,` + revisionExpression + `,` + revisionExpression + `,` + revisionExpression + `)`, + } +} + +// Down is for isolated migration/recovery rehearsal. Operational rollback must +// respect the security compatibility floor and cannot re-enable old writers. +var accountFoundationDown = []string{ + `DROP TABLE account_directory_revision`, + `ALTER TABLE sessions DROP COLUMN grant_id`, + `ALTER TABLE sessions DROP COLUMN credential_origin`, + `ALTER TABLE sessions DROP COLUMN auth_method`, + `ALTER TABLE users DROP COLUMN initialization_generation`, + `ALTER TABLE users DROP COLUMN initialization_state`, + `ALTER TABLE users DROP COLUMN credential_origin`, + `ALTER TABLE users DROP COLUMN state_version`, + `ALTER TABLE users DROP COLUMN enabled`, + `ALTER TABLE users DROP COLUMN profile_version`, + `ALTER TABLE users DROP COLUMN notes`, + `ALTER TABLE users DROP COLUMN department`, + `ALTER TABLE users DROP COLUMN staff_no`, + `ALTER TABLE users DROP COLUMN email`, + `ALTER TABLE users DROP COLUMN phone`, +} + // v17 后台子代理完成通知的持久化队列(N2 WP5:重启后重投未投递结果)。 var v17Up = []string{ `CREATE TABLE IF NOT EXISTS pending_notices ( diff --git a/internal/store/migrate_compat.go b/internal/store/migrate_compat.go index 86c692c..545ff6b 100644 --- a/internal/store/migrate_compat.go +++ b/internal/store/migrate_compat.go @@ -35,6 +35,7 @@ var migrationBreaksOldReaders = map[int]bool{ 28: false, // auth_version_account_events:仅加列与新表,旧读者不受影响 29: false, // redeem_attempts:新表,旧读者不受影响 30: false, // project_event_publication_proofs:新表,旧读者不受影响 + 31: true, // 账号有效态/凭据来源是安全协议边界;即使旧 SQL 可读,也不得恢复旧认证写者 } // SchemaTarget 是本二进制会迁移到的 schema 版本。 From d2efea284521bc68e775a7483dfbe321b6adf97e Mon Sep 17 00:00:00 2001 From: weichen Date: Wed, 14 Oct 2026 03:20:41 +0000 Subject: [PATCH 02/17] Guard active runs with current account state Why: Disabling an account must stop in-flight work as well as new HTTP requests. Evidence: SELF receipts /tmp/iam-u3-u2-runtime-regression.json and /tmp/iam-u3-u2-app-compile.json. Impact: Run start, tools, HITL, streaming output, child runs and final settlement recheck the account independently of project authorization. Refs: IAM U3 r1 stages 4-5; Dev feature/iam-u3-account-directory-recovery --- internal/app/app.go | 13 +- internal/runmgr/account_guard_test.go | 145 ++++++++++++++++++++ internal/runmgr/runmgr.go | 186 ++++++++++++++++++++++---- 3 files changed, 317 insertions(+), 27 deletions(-) create mode 100644 internal/runmgr/account_guard_test.go diff --git a/internal/app/app.go b/internal/app/app.go index 33749b7..cbc202b 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -7,6 +7,7 @@ import ( "crypto/rand" "encoding/hex" "encoding/json" + "errors" "fmt" "log" "net" @@ -450,6 +451,17 @@ func Run(opt Options) error { subRunner := &runtime.SubLLMRunner{Prompts: promptSvc, Trace: traceFn} mockRunner := &runtime.MockRunner{} mgr := runmgr.New(st, b, reg, profSvc, setSvc, convRunner, subRunner, mockRunner, &runtime.MockSubagent{}) + authOn := resolved.AuthEnabled() + mgr.SetAccountEffectiveAuthorization(func(_ context.Context, owner string) (bool, error) { + account, err := st.GetAccountByOwner(owner) + if errors.Is(err, store.ErrNotFound) && !authOn && owner == store.OwnerDefault { + return true, nil // legacy implicit local singleton has no users row + } + if err != nil { + return false, err + } + return account.Enabled, nil + }) instanceID := opt.InstanceID if instanceID == "" { host, err := os.Hostname() @@ -693,7 +705,6 @@ func Run(opt Options) error { return int(setSvc.Effective(store.OwnerDefault, "").GetNumber("runtime.rateLimitPerMin", 0)) }) // 认证策略(auto):PG 档默认开启(多用户面不能裸奔);嵌入式档默认关闭(本地单用户)。 - authOn := resolved.AuthEnabled() srv.Auth = server.NewAuthenticator(st, authOn) if authOn { log.Printf("auth: API key required (create with: agent-system apikey create --owner )") diff --git a/internal/runmgr/account_guard_test.go b/internal/runmgr/account_guard_test.go new file mode 100644 index 0000000..ef363c7 --- /dev/null +++ b/internal/runmgr/account_guard_test.go @@ -0,0 +1,145 @@ +package runmgr + +import ( + "context" + "encoding/json" + "errors" + "testing" + + "agent-system-backend/internal/store" + core "agent-system-core" +) + +// A missing domain provider must not make a registered account guard optional. +func TestAccountGuardBlocksRunToolAndQueuedHITL(t *testing.T) { + c, st := newExecEnv(t) + if err := st.CreateAccount("alice", "Alice", "", false); err != nil { + t.Fatal(err) + } + c.mgr.SetAccountEffectiveAuthorization(func(_ context.Context, owner string) (bool, error) { + a, err := st.GetAccountByOwner(owner) + return err == nil && a.Enabled, err + }) + c.run.ID = store.NewID("run") + c.run.Status = core.RunWaitingHITL + if err := st.CreateRun(&c.run); err != nil { + t.Fatal(err) + } + h := &runHandle{hitlCh: make(chan hitlAnswer, 1)} + c.mgr.active = map[string]*runHandle{c.run.ID: h} + card := core.HITLCard{ID: "disabled-card", RunID: c.run.ID, ConversationID: c.run.ConversationID} + if err := st.SaveHITLCard("alice", &card); err != nil { + t.Fatal(err) + } + if _, err := st.DB().Exec(`UPDATE users SET enabled=FALSE WHERE owner='alice'`); err != nil { + t.Fatal(err) + } + conv, err := st.GetConversation("alice", c.run.ConversationID) + if err != nil { + t.Fatal(err) + } + if _, err := c.mgr.StartConversationRun(conv, core.Message{}, nil, nil); !errors.Is(err, store.ErrNotFound) { + t.Fatalf("disabled account started run without domain provider: %v", err) + } + res := c.ExecuteTool(core.ToolCallRequest{CallID: "disabled-tool", Name: "write_artifact", Arguments: json.RawMessage(`{"name":"denied.txt","content":"x"}`)}) + if res.Status != "error" { + t.Fatalf("disabled account executed tool: %+v", res) + } + arts, err := st.ListArtifacts("alice", c.run.ProjectID) + if err != nil || len(arts) != 0 { + t.Fatalf("disabled account wrote artifact: %+v %v", arts, err) + } + + if _, _, err := c.mgr.ResolveHITL("alice", card.ID, core.HITLResponse{ChoiceID: "yes"}); !errors.Is(err, store.ErrNotFound) { + t.Fatalf("disabled account resolved HITL: %v", err) + } + persisted, err := st.GetHITLCard("alice", card.ID) + if err != nil || persisted.Resolved || len(h.hitlCh) != 0 { + t.Fatalf("disabled HITL changed state: %+v channel=%d err=%v", persisted, len(h.hitlCh), err) + } + payload, _ := json.Marshal(HITLCommandPayload{Owner: "alice", CardID: card.ID, Kind: "yes"}) + c.mgr.applyHITLCommand(ownedCommand{CommandID: "queued-disabled", Payload: string(payload)}) + if len(h.hitlCh) != 0 { + t.Fatal("queued approval delivered to disabled run") + } +} + +func TestAccountGuardStoreReadFailureBlocksOutput(t *testing.T) { + c, st := newExecEnv(t) + runCtx, cancel := context.WithCancel(context.Background()) + defer cancel() + c.ctx = runCtx + c.mgr.active = map[string]*runHandle{"active": {owner: "alice", cancel: cancel}} + readErr := errors.New("account store unavailable") + c.mgr.SetAccountEffectiveAuthorization(func(context.Context, string) (bool, error) { return false, readErr }) + if err := c.Emit(core.EvToolOutput, core.ToolOutputPayload{Text: "secret"}); !errors.Is(err, readErr) { + t.Fatalf("store failure emitted output: %v", err) + } + if runCtx.Err() == nil { + t.Fatal("account-state read failure left active local run executing") + } + if err := c.SetAssistantParts([]core.Part{{Type: core.PartText, Text: "secret"}}); !errors.Is(err, readErr) { + t.Fatalf("store failure retained assistant output: %v", err) + } + evs, err := st.EventsAfter(c.run.ConversationID, 0, 100) + if err != nil || len(evs) != 0 { + t.Fatalf("store failure persisted output events: %+v %v", evs, err) + } +} + +func TestAccountGuardBlocksRestoredNoticeOutput(t *testing.T) { + c, st := newExecEnv(t) + if err := st.CreateAccount("alice", "Alice", "", false); err != nil { + t.Fatal(err) + } + c.mgr.SetAccountEffectiveAuthorization(func(_ context.Context, owner string) (bool, error) { + a, err := st.GetAccountByOwner(owner) + return err == nil && a.Enabled, err + }) + if _, err := st.DB().Exec(`UPDATE users SET enabled=FALSE WHERE owner='alice'`); err != nil { + t.Fatal(err) + } + if err := c.mgr.startNoticeRun(c.run.ConversationID, []bgNotice{{owner: "alice", name: "restored", payload: core.AgentResultPayload{Summary: "must not deliver"}}}); !errors.Is(err, store.ErrNotFound) { + t.Fatalf("restored notice delivered to disabled account: %v", err) + } + msgs, err := st.ListMessages(c.run.ConversationID) + if err != nil || len(msgs) != 0 { + t.Fatalf("restored notice persisted output: %+v %v", msgs, err) + } +} + +func TestAccountGuardDiscardsComposedOutputAfterDisable(t *testing.T) { + c, st := newExecEnv(t) + if err := st.CreateAccount("alice", "Alice", "", false); err != nil { + t.Fatal(err) + } + c.mgr.SetAccountEffectiveAuthorization(func(_ context.Context, owner string) (bool, error) { + a, err := st.GetAccountByOwner(owner) + return err == nil && a.Enabled, err + }) + c.run.ID = store.NewID("run") + c.run.Status = core.RunRunning + if err := st.CreateRun(&c.run); err != nil { + t.Fatal(err) + } + c.h = &runHandle{assistant: []core.Part{{Type: core.PartText, Text: "must not persist"}}} + if _, err := st.DB().Exec(`UPDATE users SET enabled=FALSE WHERE owner='alice'`); err != nil { + t.Fatal(err) + } + c.mgr.finishRun(c, core.RunCompleted, core.Usage{}, nil) + got, err := st.GetRunByID(c.run.ID) + if err != nil || got.Status == core.RunCompleted { + t.Fatalf("disabled run completed: %+v %v", got, err) + } + msgs, err := st.ListMessages(c.run.ConversationID) + if err != nil { + t.Fatal(err) + } + for _, msg := range msgs { + for _, part := range msg.Parts { + if part.Text == "must not persist" { + t.Fatal("previously composed assistant output persisted after disable") + } + } + } +} diff --git a/internal/runmgr/runmgr.go b/internal/runmgr/runmgr.go index b3f3d99..0833ca5 100644 --- a/internal/runmgr/runmgr.go +++ b/internal/runmgr/runmgr.go @@ -57,10 +57,11 @@ type Manager struct { retriever core.Retriever // 可选混合检索(nil = store 全文) // sharedKnowledgeAllow EXT-13:回合内检索的共享授权谓词(请求 owner × 知识所属 // projectID);nil = 未注册(现状:仅本人两路,不触碰共享通道)。 - sharedKnowledgeAllow func(owner string, projectID core.ID) bool - knowledgeResourceAllow func(owner string, doc core.KnowledgeDoc) bool - knowledgeProjectScope func(context.Context, string, core.ID) (core.ID, error) - projectWorkAuthorization func(context.Context, string, core.ID) (bool, error) + sharedKnowledgeAllow func(owner string, projectID core.ID) bool + knowledgeResourceAllow func(owner string, doc core.KnowledgeDoc) bool + knowledgeProjectScope func(context.Context, string, core.ID) (core.ID, error) + projectWorkAuthorization func(context.Context, string, core.ID) (bool, error) + accountEffectiveAuthorization func(context.Context, string) (bool, error) // commands EXT-14:命令即工具的投影与分发源(nil = 未接线,命令不进工具清单)。 commands CommandSource indexer core.DocumentIndexer // 可选索引入口(nil = store 整文档单块) @@ -132,6 +133,7 @@ type bgNotice struct { var ErrConversationBusy = fmt.Errorf("runmgr: conversation already has an in-flight run") type runHandle struct { + owner string cancel context.CancelFunc hitlCh chan hitlAnswer assistant []core.Part @@ -217,6 +219,42 @@ func (m *Manager) SetKnowledgeResourceAllow(fn func(string, core.KnowledgeDoc) b func (m *Manager) SetProjectWorkAuthorization(fn func(context.Context, string, core.ID) (bool, error)) { m.projectWorkAuthorization = fn } + +// SetAccountEffectiveAuthorization binds the current account-state guard. It is +// independent of the optional domain project provider. +func (m *Manager) SetAccountEffectiveAuthorization(fn func(context.Context, string) (bool, error)) { + m.accountEffectiveAuthorization = fn +} + +func (m *Manager) authorizeAccountEffective(ctx context.Context, owner string) error { + if m.accountEffectiveAuthorization == nil { + return nil + } + ok, err := m.accountEffectiveAuthorization(ctx, owner) + if err != nil { + m.cancelActiveOwnerRuns(owner) + return err + } + if !ok { + m.cancelActiveOwnerRuns(owner) + return store.ErrNotFound + } + return nil +} + +func (m *Manager) cancelActiveOwnerRuns(owner string) { + m.mu.Lock() + var cancels []context.CancelFunc + for _, h := range m.active { + if h.owner == owner && h.cancel != nil { + cancels = append(cancels, h.cancel) + } + } + m.mu.Unlock() + for _, cancel := range cancels { + cancel() + } +} func (m *Manager) authorizeProjectWork(ctx context.Context, owner string, id core.ID) error { if m.projectWorkAuthorization == nil { return nil @@ -231,6 +269,13 @@ func (m *Manager) authorizeProjectWork(ctx context.Context, owner string, id cor return nil } +func (m *Manager) authorizeRunWork(ctx context.Context, owner string, id core.ID) error { + if err := m.authorizeAccountEffective(ctx, owner); err != nil { + return err + } + return m.authorizeProjectWork(ctx, owner, id) +} + // SetKnowledgeProjectScope binds host project filters to the provider's trusted // canonical domain metadata without widening the current conversation scope. func (m *Manager) SetKnowledgeProjectScope(fn func(context.Context, string, core.ID) (core.ID, error)) { @@ -625,11 +670,17 @@ func (c *runCtx) Memories() []core.MemoryEntry { } func (c *runCtx) Emit(typ core.StreamEventType, payload any) error { + if err := c.mgr.authorizeRunWork(c.ctx, c.run.Owner, c.run.ProjectID); err != nil { + return err + } _, err := c.mgr.emit(c.run.ConversationID, c.run.ID, typ, payload) return err } func (c *runCtx) SetAssistantParts(parts []core.Part) error { + if err := c.mgr.authorizeRunWork(c.ctx, c.run.Owner, c.run.ProjectID); err != nil { + return err + } c.mgr.mu.Lock() c.h.assistant = parts c.mgr.mu.Unlock() @@ -637,6 +688,9 @@ func (c *runCtx) SetAssistantParts(parts []core.Part) error { } func (c *runCtx) AskHITL(card core.HITLCard) (answer core.HITLResponse, err error) { + if err := c.mgr.authorizeRunWork(c.ctx, c.run.Owner, c.run.ProjectID); err != nil { + return core.HITLResponse{}, err + } card.ID = store.NewID("hitl") card.RunID = c.run.ID card.ConversationID = c.run.ConversationID @@ -680,11 +734,14 @@ func (c *runCtx) AskHITL(card core.HITLCard) (answer core.HITLResponse, err erro defer func() { if acquireErr := c.mgr.adm.acquire(c.ctx, c.mgr.st, string(c.run.ID), c.run.Owner); acquireErr != nil { log.Printf("[runmgr] admission reacquire after HITL %s: %v", c.run.ID, acquireErr) + if err == nil { + err = acquireErr + } } // Permission can change during both confirmation and admission waiting. // Every HITL caller receives the live decision before it resumes work. if err == nil { - err = c.mgr.authorizeProjectWork(c.ctx, c.run.Owner, c.run.ProjectID) + err = c.mgr.authorizeRunWork(c.ctx, c.run.Owner, c.run.ProjectID) } }() return waitHITL(c.ctx, c.h.hitlCh, timeout) @@ -731,6 +788,9 @@ func (c *runCtx) Subhint() string { return c.subHint } func (c *runCtx) Submodel() string { return c.subModel } func (c *runCtx) UpdatePlan(p core.PlanSnapshot) error { + if err := c.mgr.authorizeRunWork(c.ctx, c.run.Owner, c.run.ProjectID); err != nil { + return err + } prev, _ := c.mgr.st.GetPlan(c.run.ConversationID) p.PlanID = c.run.ConversationID if err := c.mgr.st.SavePlan(c.run.ConversationID, p); err != nil { @@ -762,6 +822,9 @@ func (c *runCtx) Plan() (core.PlanSnapshot, bool) { } func (c *runCtx) UpdateTodos(l core.TodoList) error { + if err := c.mgr.authorizeRunWork(c.ctx, c.run.Owner, c.run.ProjectID); err != nil { + return err + } prev, _ := c.mgr.st.GetTodos(c.run.ConversationID) l.ConversationID = c.run.ConversationID if err := c.mgr.st.SaveTodos(c.run.ConversationID, l); err != nil { @@ -780,6 +843,9 @@ func (c *runCtx) UpdateTodos(l core.TodoList) error { // (scope=project 工具参数),补全 ProjectID、ConversationID 保持空;其余照旧 // 归属当前会话。 func (c *runCtx) CreateCard(card core.ActivityCard) (core.ActivityCard, error) { + if err := c.mgr.authorizeRunWork(c.ctx, c.run.Owner, c.run.ProjectID); err != nil { + return core.ActivityCard{}, err + } if c.mgr.cards == nil { return card, nil } @@ -792,6 +858,9 @@ func (c *runCtx) CreateCard(card core.ActivityCard) (core.ActivityCard, error) { } func (c *runCtx) SetCardStatus(id string, status core.CardStatus, tone, note string) error { + if err := c.mgr.authorizeRunWork(c.ctx, c.run.Owner, c.run.ProjectID); err != nil { + return err + } if c.mgr.cards == nil { return nil } @@ -831,6 +900,9 @@ func (c *runCtx) BeginStreamCard(card core.ActivityCard) (core.ActivityCard, err // AppendStreamCard:card_append 工具入口——向活跃流式卡追加正文(事件节流)。 func (c *runCtx) AppendStreamCard(text string) error { + if err := c.mgr.authorizeRunWork(c.ctx, c.run.Owner, c.run.ProjectID); err != nil { + return err + } c.mgr.mu.Lock() id := c.h.streamCardID c.mgr.mu.Unlock() @@ -843,6 +915,9 @@ func (c *runCtx) AppendStreamCard(text string) error { // CommitStreamCard:card_commit 工具入口——活跃流式卡置 done、登记锚点回填 // 并清空活跃指针。 func (c *runCtx) CommitStreamCard() (core.ActivityCard, error) { + if err := c.mgr.authorizeRunWork(c.ctx, c.run.Owner, c.run.ProjectID); err != nil { + return core.ActivityCard{}, err + } c.mgr.mu.Lock() id := c.h.streamCardID c.h.streamCardID = "" @@ -887,7 +962,7 @@ func (c *runCtx) Artifact(id string) (core.Artifact, []byte, bool) { } func (c *runCtx) WriteArtifact(name, kind, mime string, content []byte) (core.Artifact, error) { - if err := c.mgr.authorizeProjectWork(c.ctx, c.run.Owner, c.run.ProjectID); err != nil { + if err := c.mgr.authorizeRunWork(c.ctx, c.run.Owner, c.run.ProjectID); err != nil { return core.Artifact{}, err } art := core.Artifact{ProjectID: c.run.ProjectID, Owner: c.run.Owner, Name: name, Kind: kind, MIME: mime, SourceRun: c.run.ID} @@ -999,7 +1074,7 @@ func (c *runCtx) Retrieve(q core.RetrievalQuery) ([]core.RetrievalHit, error) { // ExecuteTool 执行画像策略:deny→错误;confirm→HITL confirm 卡片;执行→截断转工件。 func (c *runCtx) ExecuteTool(call core.ToolCallRequest) core.ToolCallResult { - if err := c.mgr.authorizeProjectWork(c.ctx, c.run.Owner, c.run.ProjectID); err != nil { + if err := c.mgr.authorizeRunWork(c.ctx, c.run.Owner, c.run.ProjectID); err != nil { return core.ToolCallResult{CallID: call.CallID, Status: "error", Output: err.Error()} } policy := c.prof.Tools @@ -1022,7 +1097,7 @@ func (c *runCtx) ExecuteTool(call core.ToolCallRequest) core.ToolCallResult { if err != nil { return core.ToolCallResult{CallID: call.CallID, Status: "error", Output: "hitl aborted: " + err.Error()} } - if err := c.mgr.authorizeProjectWork(c.ctx, c.run.Owner, c.run.ProjectID); err != nil { + if err := c.mgr.authorizeRunWork(c.ctx, c.run.Owner, c.run.ProjectID); err != nil { return core.ToolCallResult{CallID: call.CallID, Status: "error", Output: err.Error()} } if resp.ChoiceID == "session" { @@ -1031,7 +1106,7 @@ func (c *runCtx) ExecuteTool(call core.ToolCallRequest) core.ToolCallResult { return core.ToolCallResult{CallID: call.CallID, Status: "error", Output: "用户取消了本次工具调用"} } } - if err := c.mgr.authorizeProjectWork(c.ctx, c.run.Owner, c.run.ProjectID); err != nil { + if err := c.mgr.authorizeRunWork(c.ctx, c.run.Owner, c.run.ProjectID); err != nil { return core.ToolCallResult{CallID: call.CallID, Status: "error", Output: err.Error()} } // EXT-14:命令即工具——命中命令投影时经命令总分发(Entrance=agent、 @@ -1049,6 +1124,9 @@ func (c *runCtx) ExecuteTool(call core.ToolCallRequest) core.ToolCallResult { ID: call.Name, Params: params, Entrance: "agent", Actor: c.run.Owner, Owner: c.run.Owner, ConversationID: c.run.ConversationID, }) + if err := c.mgr.authorizeRunWork(c.ctx, c.run.Owner, c.run.ProjectID); err != nil { + return core.ToolCallResult{CallID: call.CallID, Status: "error", Output: err.Error()} + } out, _ := json.Marshal(resp.Payload) if resp.Card && resp.State == "accepted" { return core.ToolCallResult{CallID: call.CallID, Status: "ok", @@ -1063,6 +1141,9 @@ func (c *runCtx) ExecuteTool(call core.ToolCallRequest) core.ToolCallResult { } tc := &toolCtx{rc: c, callID: call.CallID} res := c.mgr.registry.Execute(tc, call) + if err := c.mgr.authorizeRunWork(c.ctx, c.run.Owner, c.run.ProjectID); err != nil { + return core.ToolCallResult{CallID: call.CallID, Status: "error", Output: err.Error()} + } // 工具产物回填(交付物卡片根因修复):注册表工具执行期经 rc.WriteArtifact // 写入的工件(write_artifact / fs_write_file / register_artifact / 未来执行 // 原语产物)挂到结果上——llm.go 依 res.Artifact 生成对话内 artifact_ref 卡片。 @@ -1232,7 +1313,7 @@ func selectConversationRunner(m *Manager, eff core.EffectiveSettings, launch *wo // attachments 为已解析的附件文档(server 层经 ingest 处理)。 // inputs 是同一批附件的原件,落到会话工作区 inputs/,供模板和仿制使用。 func (m *Manager) StartConversationRun(conv core.Conversation, userMessage core.Message, attachments []core.ParsedDocument, inputs []WorkspaceFile) (core.Run, error) { - if err := m.authorizeProjectWork(context.Background(), conv.Owner, conv.ProjectID); err != nil { + if err := m.authorizeRunWork(context.Background(), conv.Owner, conv.ProjectID); err != nil { return core.Run{}, err } if err := m.claimConversation(conv.ID); err != nil { @@ -1244,7 +1325,7 @@ func (m *Manager) StartConversationRun(conv core.Conversation, userMessage core. // StartWorkflowRun 启动一条绑定工作流的会话 run。定义在这里拷贝, // 之后插件卸载不会改到已经在跑的这份。 func (m *Manager) StartWorkflowRun(conv core.Conversation, userMessage core.Message, def core.WorkflowDef, slots map[string]string, missing []workflow.SlotQuestion) (core.Run, error) { - if err := m.authorizeProjectWork(context.Background(), conv.Owner, conv.ProjectID); err != nil { + if err := m.authorizeRunWork(context.Background(), conv.Owner, conv.ProjectID); err != nil { return core.Run{}, err } if err := m.claimConversation(conv.ID); err != nil { @@ -1356,7 +1437,7 @@ func (m *Manager) claimConversation(conversationID string) error { } func (m *Manager) startClaimedRun(conv core.Conversation, userMessage core.Message, attachments []core.ParsedDocument, inputs []WorkspaceFile, launch *workflow.Launch) (core.Run, error) { - if err := m.authorizeProjectWork(context.Background(), conv.Owner, conv.ProjectID); err != nil { + if err := m.authorizeRunWork(context.Background(), conv.Owner, conv.ProjectID); err != nil { m.releaseConversation(conv.ID) return core.Run{}, err } @@ -1371,7 +1452,7 @@ func (m *Manager) startClaimedRun(conv core.Conversation, userMessage core.Messa return run, err } ctx, cancel := context.WithCancel(context.Background()) - h := &runHandle{cancel: cancel, hitlCh: make(chan hitlAnswer, 1), started: time.Now()} + h := &runHandle{owner: run.Owner, cancel: cancel, hitlCh: make(chan hitlAnswer, 1), started: time.Now()} m.mu.Lock() m.active[run.ID] = h m.mu.Unlock() @@ -1432,6 +1513,10 @@ func (m *Manager) execute(rc *runCtx, runner runtime.Runner) { m.mu.Unlock() m.deliverNotices(rc.run.ConversationID) }() + if err := m.authorizeRunWork(rc.ctx, rc.run.Owner, rc.run.ProjectID); err != nil { + m.finishRun(rc, core.RunFailed, core.Usage{}, &core.ErrorPart{Code: "run.account_inactive", Message: err.Error()}) + return + } m.setRunStatus(&rc.run, core.RunRunning, core.PhaseThinking) m.emit(rc.run.ConversationID, rc.run.ID, core.EvRunPhase, core.RunPhasePayload{Phase: core.PhaseThinking}) @@ -1457,6 +1542,14 @@ func (m *Manager) execute(rc *runCtx, runner runtime.Runner) { } func (m *Manager) finishRun(rc *runCtx, status core.RunStatus, usage core.Usage, errPart *core.ErrorPart) { + accountDenied := m.authorizeAccountEffective(rc.ctx, rc.run.Owner) + if accountDenied != nil { + status = core.RunFailed + errPart = &core.ErrorPart{Code: "run.account_inactive", Message: accountDenied.Error()} + if rc.h.cancel != nil { + rc.h.cancel() + } + } m.mu.Lock() usage.PromptTokens = rc.h.promptTokens usage.CompletionTokens = rc.h.completionTokens @@ -1481,6 +1574,9 @@ func (m *Manager) finishRun(rc *runCtx, status core.RunStatus, usage core.Usage, m.mu.Lock() parts := append([]core.Part{}, rc.h.assistant...) m.mu.Unlock() + if accountDenied != nil { + parts = nil + } if errPart != nil { parts = append(parts, core.Part{Type: core.PartError, Err: errPart}) } @@ -1685,7 +1781,7 @@ func (m *Manager) ResolveHITL(owner, cardID string, resp core.HITLResponse) (piv if m.projectWorkAuthorization != nil && (run.Owner != owner || run.ConversationID != card.ConversationID) { return "", false, store.ErrNotFound } - if err := m.authorizeProjectWork(context.Background(), run.Owner, run.ProjectID); err != nil { + if err := m.authorizeRunWork(context.Background(), run.Owner, run.ProjectID); err != nil { return "", false, err } // U7 双进程验收实测缺陷修复:run 不在本进程执行时不得先落 resolve—— @@ -1732,7 +1828,7 @@ func (m *Manager) ResolveHITL(owner, cardID string, resp core.HITLResponse) (piv } } if pivot { - if err := m.authorizeProjectWork(context.Background(), run.Owner, run.ProjectID); err != nil { + if err := m.authorizeRunWork(context.Background(), run.Owner, run.ProjectID); err != nil { return "", false, err } _ = m.CancelRun(run.ID) @@ -1745,7 +1841,7 @@ func (m *Manager) ResolveHITL(owner, cardID string, resp core.HITLResponse) (piv return trimmed, true, nil } } - if err := m.authorizeProjectWork(context.Background(), run.Owner, run.ProjectID); err != nil { + if err := m.authorizeRunWork(context.Background(), run.Owner, run.ProjectID); err != nil { return "", false, err } resolution := core.HITLResolvedPayload{CardID: core.ID(cardID), Answer: answer, Kind: kind} @@ -1780,6 +1876,9 @@ func (m *Manager) ResolveHITL(owner, cardID string, resp core.HITLResponse) (piv // ---- 子代理(任务树)---- func (m *Manager) spawnSubagent(parentCtx context.Context, parent core.Run, agentID, name, task, hint string) (core.AgentResultPayload, error) { + if err := m.authorizeRunWork(parentCtx, parent.Owner, parent.ProjectID); err != nil { + return core.AgentResultPayload{}, err + } rc, runner, err := m.startChild(parentCtx, parent, agentID, name, task, hint, false) if err != nil { return core.AgentResultPayload{}, err @@ -1811,6 +1910,9 @@ func (m *Manager) startChild(parentCtx context.Context, parent core.Run, agentID // startChildOpts 在 startChild 之上支持系统派发选项(模型档等)。 func (m *Manager) startChildOpts(parentCtx context.Context, parent core.Run, o bgSpawnOpts, detach bool) (*runCtx, runtime.Runner, error) { + if err := m.authorizeRunWork(parentCtx, parent.Owner, parent.ProjectID); err != nil { + return nil, nil, err + } agentID, name, task, hint := o.agentID, o.name, o.task, o.hint if parent.Kind == core.RunSubagent { return nil, nil, fmt.Errorf("子代理不能再派发子代理") @@ -1835,7 +1937,7 @@ func (m *Manager) startChildOpts(parentCtx context.Context, parent core.Run, o b base = context.Background() } ctx, cancel := context.WithCancel(base) - h := &runHandle{cancel: cancel, hitlCh: make(chan hitlAnswer, 1), started: time.Now()} + h := &runHandle{owner: child.Owner, cancel: cancel, hitlCh: make(chan hitlAnswer, 1), started: time.Now()} m.mu.Lock() m.active[child.ID] = h m.mu.Unlock() @@ -1855,14 +1957,24 @@ func (m *Manager) startChildOpts(parentCtx context.Context, parent core.Run, o b // finishChild 执行子 run、落库并汇总。emitResult 为假时不发 agent.result, // 留给后台投递成功之后再发。 func (m *Manager) finishChild(rc *runCtx, runner runtime.Runner, emitResult bool) (core.AgentResultPayload, error) { - execErr := func() (err error) { - defer func() { - if r := recover(); r != nil { - err = fmt.Errorf("subagent panic: %v", r) - } + execErr := m.authorizeRunWork(rc.ctx, rc.run.Owner, rc.run.ProjectID) + if execErr == nil { + execErr = func() (err error) { + defer func() { + if r := recover(); r != nil { + err = fmt.Errorf("subagent panic: %v", r) + } + }() + return runner.Execute(rc) }() - return runner.Execute(rc) - }() + } + accountDenied := m.authorizeAccountEffective(rc.ctx, rc.run.Owner) + if accountDenied != nil { + execErr = accountDenied + if rc.h.cancel != nil { + rc.h.cancel() + } + } // 子代理记账(N3 WP6):token 用量随子 run 入账(此前只有 DurationMs)。 usage := core.Usage{DurationMs: float64(time.Since(rc.h.started).Milliseconds())} @@ -1879,7 +1991,11 @@ func (m *Manager) finishChild(rc *runCtx, runner runtime.Runner, emitResult bool summary := "完成" payload := core.AgentResultPayload{AgentRun: rc.run.ID, Status: status, Summary: summary} if execErr != nil { - if rc.ctx.Err() != nil { + if accountDenied != nil { + status = core.RunFailed + payload.Status = status + payload.Summary = accountDenied.Error() + } else if rc.ctx.Err() != nil { status = core.RunCancelled payload.Status = status payload.Summary = "已取消" @@ -1896,13 +2012,16 @@ func (m *Manager) finishChild(rc *runCtx, runner runtime.Runner, emitResult bool child.Phase = core.PhaseDone child.Usage = &usage m.st.UpdateRun(&child) - if emitResult { + if emitResult && accountDenied == nil { m.emit(child.ConversationID, child.ID, core.EvAgentResult, payload) } m.mu.Lock() subParts := append([]core.Part{}, rc.h.assistant...) m.mu.Unlock() + if accountDenied != nil { + subParts = nil + } // 独立转录落定(含终态 done 标记,供续跑幂等判定)。 m.writeSubagentTranscript(child, subParts, string(status)) fullText := partsText(subParts) @@ -1944,6 +2063,9 @@ func (m *Manager) spawnBackground(parent core.Run, agentID, name, task, hint str } func (m *Manager) spawnBackgroundOpts(parent core.Run, o bgSpawnOpts) (core.ID, error) { + if err := m.authorizeRunWork(context.Background(), parent.Owner, parent.ProjectID); err != nil { + return "", err + } if parent.Kind == core.RunSubagent { return "", fmt.Errorf("子代理不能再派发子代理") } @@ -1990,6 +2112,10 @@ func (m *Manager) spawnBackgroundOpts(parent core.Run, o bgSpawnOpts) (core.ID, // startBackgroundSlot 持槽启动后台任务(调用方已占 bgInFlight)。 // preCard 非空时复用排队卡(回填 SourceRunID 并转执行态),否则新建执行卡。 func (m *Manager) startBackgroundSlot(parent core.Run, o bgSpawnOpts, preCard core.ActivityCard) (core.ID, error) { + if err := m.authorizeRunWork(context.Background(), parent.Owner, parent.ProjectID); err != nil { + m.decBackground(parent.ConversationID) + return "", err + } rc, runner, err := m.startChildOpts(context.Background(), parent, o, true) if err != nil { m.decBackground(parent.ConversationID) @@ -2271,6 +2397,10 @@ func (m *Manager) startNoticeRun(convID string, items []bgNotice) error { m.releaseConversation(convID) return err } + if err := m.authorizeRunWork(context.Background(), owner, conv.ProjectID); err != nil { + m.releaseConversation(convID) + return err + } var b strings.Builder b.WriteString("\n") for _, n := range items { @@ -2300,6 +2430,10 @@ func (m *Manager) startNoticeRun(convID string, items []bgNotice) error { ConversationID: convID, Role: core.RoleSystem, Parts: []core.Part{{Type: core.PartText, Text: b.String()}}, } + if err := m.authorizeRunWork(context.Background(), owner, conv.ProjectID); err != nil { + m.releaseConversation(convID) + return err + } if err := m.st.AppendMessage(&msg); err != nil { m.releaseConversation(convID) return err From 8dfc223fb6063a3eef98572cce9189d4fe73f595 Mon Sep 17 00:00:00 2001 From: weichen Date: Wed, 14 Oct 2026 03:21:30 +0000 Subject: [PATCH 03/17] Enforce current account state at credential and HTTP boundaries Why: Disabled accounts and mixed credentials could continue to authenticate, while web creation accepted passwords and administrator privileges. Evidence: SELF receipts /tmp/iam-u3-u2-store-sqlite-green.json, /tmp/iam-u3-u2-store-pg-regression.json, and /tmp/iam-u3-u2-server-sqlite-green.json. Impact: Adds versioned enable/disable, revokes existing handles, protects the last operable manager, and exposes pending-account/profile APIs. Recovery proposal and legacy-entry closure remain in U3-4. Refs: IAM U3 r1 stages 4-5; Dev feature/iam-u3-account-directory-recovery --- internal/server/account_contract_test.go | 54 ++++++++ internal/server/accounts.go | 154 +++++++++++++++++++---- internal/server/auth.go | 11 +- internal/server/auth_e2e_test.go | 23 ++-- internal/server/server.go | 22 ++++ internal/store/account_model_test.go | 7 +- internal/store/account_state.go | 85 +++++++++++++ internal/store/account_state_test.go | 99 +++++++++++++++ internal/store/auth.go | 53 +++++--- internal/store/login_keys.go | 40 ++++-- 10 files changed, 493 insertions(+), 55 deletions(-) create mode 100644 internal/server/account_contract_test.go create mode 100644 internal/store/account_state.go create mode 100644 internal/store/account_state_test.go diff --git a/internal/server/account_contract_test.go b/internal/server/account_contract_test.go new file mode 100644 index 0000000..df0e76f --- /dev/null +++ b/internal/server/account_contract_test.go @@ -0,0 +1,54 @@ +package server + +import ( + "encoding/json" + "net/http" + "testing" + + "agent-system-backend/internal/store" +) + +func TestAccountHTTPPendingCreateProfileAndDisable(t *testing.T) { + srv, st := newAuthTestServer(t) + h := srv.Handler() + adminCookie := bootAdmin(t, h, st) + for _, body := range []string{ + `{"owner":"blocked-password","password":"old-pass-9"}`, + `{"owner":"blocked-admin","isAdmin":true}`, + } { + if rec := do(h, "POST", "/api/admin/users", body, adminCookie); rec.Code != http.StatusBadRequest { + t.Fatalf("unsafe create accepted: %d %s", rec.Code, rec.Body.String()) + } + } + if _, err := st.GetUser("blocked-password"); err != store.ErrNotFound { + t.Fatalf("unsafe create had side effect: %v", err) + } + rec := do(h, "POST", "/api/admin/users", `{"owner":"alice","profile":{"name":"同名"}}`, adminCookie) + if rec.Code != http.StatusOK { + t.Fatalf("pending create: %d %s", rec.Code, rec.Body.String()) + } + var created struct { + UserID string `json:"userId"` + Owner string `json:"owner"` + ProfileVersion int64 `json:"profileVersion"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &created); err != nil || created.UserID == "" || created.Owner != "alice" || created.ProfileVersion != 1 { + t.Fatalf("create projection: %+v %v", created, err) + } + rec = do(h, "PATCH", "/api/admin/users/"+created.UserID+"/profile", `{"expectedProfileVersion":1,"patch":{"phone":"123"}}`, adminCookie) + if rec.Code != http.StatusOK { + t.Fatalf("profile patch: %d %s", rec.Code, rec.Body.String()) + } + rec = do(h, "PATCH", "/api/admin/users/"+created.UserID+"/profile", `{"expectedProfileVersion":2,"patch":{"isAdmin":"true"}}`, adminCookie) + if rec.Code != http.StatusBadRequest { + t.Fatalf("privilege field accepted: %d %s", rec.Code, rec.Body.String()) + } + rec = do(h, "PUT", "/api/admin/users/"+created.UserID+"/state", `{"expectedStateVersion":1,"enabled":false}`, adminCookie) + if rec.Code != http.StatusOK { + t.Fatalf("disable: %d %s", rec.Code, rec.Body.String()) + } + account, err := st.GetAccount(created.UserID) + if err != nil || account.Enabled || account.Profile.Phone != "123" { + t.Fatalf("state/profile after disable: %+v %v", account, err) + } +} diff --git a/internal/server/accounts.go b/internal/server/accounts.go index 68e7697..1beae30 100644 --- a/internal/server/accounts.go +++ b/internal/server/accounts.go @@ -24,6 +24,19 @@ func decodeJSON(r *http.Request, v any) error { return json.NewDecoder(r.Body).Decode(v) } +func decodeStrictJSON(r *http.Request, v any) error { + decoder := json.NewDecoder(io.LimitReader(r.Body, 1<<20)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(v); err != nil { + return err + } + var extra any + if err := decoder.Decode(&extra); !errors.Is(err, io.EOF) { + return errors.New("exactly one JSON object required") + } + return nil +} + // 登录失败退避(进程内;管理员发放制下用户量小,进程内即可,实例重启清零)。 var loginGuard = struct { sync.Mutex @@ -270,7 +283,7 @@ func validPassword(pw string) error { func (s *Server) requireAdmin(w http.ResponseWriter, r *http.Request) (string, bool) { owner := s.owner(r) st, err := s.st.GetUserAuthState(owner) - if err != nil || !st.IsAdmin { + if err != nil || !st.Enabled || !st.IsAdmin || s.Auth == nil || !s.Auth.Enabled { http.Error(w, "admin required", http.StatusForbidden) return "", false } @@ -286,16 +299,15 @@ func (s *Server) listAdminUsers(w http.ResponseWriter, r *http.Request) { } func (s *Server) createAdminUser(w http.ResponseWriter, r *http.Request) { - if _, ok := s.requireAdmin(w, r); !ok { + actor, ok := s.requireAdmin(w, r) + if !ok { return } var req struct { - Owner string `json:"owner"` - Name string `json:"name"` - Password string `json:"password"` - IsAdmin bool `json:"isAdmin"` + Owner string `json:"owner"` + Profile store.AccountProfile `json:"profile"` } - if err := decodeJSON(r, &req); err != nil { + if err := decodeStrictJSON(r, &req); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } @@ -304,24 +316,124 @@ func (s *Server) createAdminUser(w http.ResponseWriter, r *http.Request) { http.Error(w, "owner required (no spaces or slashes)", http.StatusBadRequest) return } - if err := validPassword(req.Password); err != nil { - http.Error(w, "initial password must be 8+ characters", http.StatusBadRequest) - return - } - hash, err := store.HashPassword(req.Password) + account, err := s.st.CreatePendingAccount(actor, req.Owner, req.Profile) if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - if err := s.st.CreateAccount(req.Owner, strings.TrimSpace(req.Name), hash, req.IsAdmin); err != nil { writeStoreErr(w, err) return } - st, _ := s.st.GetUserAuthState(req.Owner) - writeJSON(w, http.StatusOK, map[string]any{ - "owner": st.Owner, "name": st.Name, "isAdmin": st.IsAdmin, - "mustChangePassword": st.MustChangePassword, - }) + writeJSON(w, http.StatusOK, accountProfileJSON(account)) +} + +func accountProfileJSON(account store.Account) map[string]any { + return map[string]any{ + "userId": account.ID, "owner": account.Owner, "profile": account.Profile, + "profileVersion": account.ProfileVersion, "enabled": account.Enabled, + "stateVersion": account.StateVersion, "isAdmin": account.IsAdmin, + "passwordSet": account.HasPassword, "initializationState": account.InitializationState, + } +} + +func (s *Server) requireFullPasswordSession(w http.ResponseWriter, r *http.Request) (string, bool) { + if s.Auth == nil || !s.Auth.Enabled || AuthVia(r) != AuthViaSession || SessionScope(r) != store.SessionScopeFull { + http.Error(w, "full password session required", http.StatusForbidden) + return "", false + } + token := sessionToken(r) + owner, scope, valid := s.st.SessionInfo(token) + if !valid || scope != store.SessionScopeFull || owner != s.owner(r) { + http.Error(w, "full password session required", http.StatusForbidden) + return "", false + } + st, err := s.st.GetUserAuthState(owner) + if err != nil || !st.Enabled || st.MustChangePassword || !st.HasPassword { + http.Error(w, "full password session required", http.StatusForbidden) + return "", false + } + return owner, true +} + +func (s *Server) meProfile(w http.ResponseWriter, r *http.Request) { + owner, ok := s.requireFullPasswordSession(w, r) + if !ok { + return + } + account, err := s.st.GetAccountByOwner(owner) + if err != nil { + writeStoreErr(w, err) + return + } + writeJSON(w, http.StatusOK, accountProfileJSON(account)) +} + +func (s *Server) patchMeProfile(w http.ResponseWriter, r *http.Request) { + owner, ok := s.requireFullPasswordSession(w, r) + if !ok { + return + } + account, err := s.st.GetAccountByOwner(owner) + if err != nil { + writeStoreErr(w, err) + return + } + s.patchAccountProfile(w, r, owner, account.ID) +} + +func (s *Server) adminAccount(w http.ResponseWriter, r *http.Request) { + if _, ok := s.requireAdmin(w, r); !ok { + return + } + account, err := s.st.GetAccount(r.PathValue("userId")) + if err != nil { + writeStoreErr(w, err) + return + } + writeJSON(w, http.StatusOK, accountProfileJSON(account)) +} + +func (s *Server) patchAdminAccountProfile(w http.ResponseWriter, r *http.Request) { + actor, ok := s.requireAdmin(w, r) + if !ok { + return + } + s.patchAccountProfile(w, r, actor, r.PathValue("userId")) +} + +func (s *Server) patchAccountProfile(w http.ResponseWriter, r *http.Request, actor, userID string) { + var req struct { + ExpectedProfileVersion int64 `json:"expectedProfileVersion"` + Patch map[string]string `json:"patch"` + } + if err := decodeStrictJSON(r, &req); err != nil || req.Patch == nil { + http.Error(w, "invalid profile patch", http.StatusBadRequest) + return + } + account, err := s.st.PatchAccountProfile(actor, userID, req.ExpectedProfileVersion, req.Patch) + if err != nil { + writeStoreErr(w, err) + return + } + writeJSON(w, http.StatusOK, accountProfileJSON(account)) +} + +func (s *Server) setAdminAccountState(w http.ResponseWriter, r *http.Request) { + actor, ok := s.requireAdmin(w, r) + if !ok { + return + } + var req struct { + ExpectedStateVersion int64 `json:"expectedStateVersion"` + Enabled *bool `json:"enabled"` + } + if err := decodeStrictJSON(r, &req); err != nil || req.Enabled == nil { + http.Error(w, "invalid state change", http.StatusBadRequest) + return + } + account, err := s.st.SetAccountEnabled(actor, r.PathValue("userId"), req.ExpectedStateVersion, *req.Enabled) + if err != nil { + writeStoreErr(w, err) + return + } + writeJSON(w, http.StatusOK, accountProfileJSON(account)) } // resetAdminUserPassword:管理员直接重置密码(兼容保留的既有入口;常规恢复 diff --git a/internal/server/auth.go b/internal/server/auth.go index c32127e..f8ecd43 100644 --- a/internal/server/auth.go +++ b/internal/server/auth.go @@ -54,6 +54,10 @@ func (a *Authenticator) Middleware(next http.Handler) http.Handler { if key == "" { key = r.URL.Query().Get("key") } + if key != "" && sessionToken(r) != "" { + http.Error(w, "conflicting credentials", http.StatusUnauthorized) + return + } // 一次性登录 Key 不得经 Bearer / ?key= 使用。 if strings.HasPrefix(key, "alk_") { http.Error(w, "unauthorized: one-time login keys must use /api/auth/login-key/redeem", http.StatusUnauthorized) @@ -66,8 +70,6 @@ func (a *Authenticator) Middleware(next http.Handler) http.Handler { if key != "" { if o, keyOK := a.st.AuthOwner(key); keyOK { owner, via, scope, ok = o, AuthViaKey, store.SessionScopeFull, true - } else if o, sc, sessOK := cookieSession(r, a.st); sessOK { - owner, via, scope, ok = o, AuthViaSession, sc, true } else { http.Error(w, "unauthorized: invalid or revoked API key", http.StatusUnauthorized) return @@ -99,8 +101,9 @@ func (a *Authenticator) enforcePasswordGate(w http.ResponseWriter, r *http.Reque return nil } st, err := a.st.GetUserAuthState(owner) - if err != nil { - return nil + if err != nil || !st.Enabled { + http.Error(w, "unauthorized: account unavailable", http.StatusUnauthorized) + return errGate } // 长期 API Key:仅当 must_change(已有密码待改)时拒绝业务,防止绕过改密门; // 无密码的纯集成账户仍可走 Key(与历史 apikey 语义兼容)。 diff --git a/internal/server/auth_e2e_test.go b/internal/server/auth_e2e_test.go index cb31fef..d5b5073 100644 --- a/internal/server/auth_e2e_test.go +++ b/internal/server/auth_e2e_test.go @@ -345,14 +345,21 @@ func TestAdminAccountFlow(t *testing.T) { } tok = strings.Split(strings.Split(rec.Header().Get("Set-Cookie"), SessionCookie+"=")[1], ";")[0] - // 管理员建号(普通用户,初始密码)。 - if rec := session("POST", "/api/admin/users", `{"owner":"alice","name":"爱丽丝","password":"alice-init-01"}`); rec.Code != http.StatusOK { + // 管理员只建无凭据的普通账号;历史强制改密夹具由 Store 直接设置。 + if rec := session("POST", "/api/admin/users", `{"owner":"alice","profile":{"name":"爱丽丝"}}`); rec.Code != http.StatusOK { t.Fatalf("admin create user: %d %s", rec.Code, rec.Body.String()) } // 重名冲突 409。 - if rec := session("POST", "/api/admin/users", `{"owner":"alice","password":"alice-init-01"}`); rec.Code != http.StatusConflict { + if rec := session("POST", "/api/admin/users", `{"owner":"alice"}`); rec.Code != http.StatusConflict { t.Fatalf("duplicate owner must 409: %d", rec.Code) } + legacyHash, err := store.HashPassword("alice-init-01") + if err != nil { + t.Fatal(err) + } + if err := st.SetUserPassword("alice", legacyHash); err != nil { + t.Fatal(err) + } // alice 首登:mustChangePassword=true;业务 API 被设密门拒绝。 req = httptest.NewRequest("POST", "/api/auth/login", strings.NewReader(`{"owner":"alice","password":"alice-init-01"}`)) @@ -464,8 +471,8 @@ func TestAdminAccountFlow(t *testing.T) { } } -// TestStaleKeySessionFallback:浏览器残留失效 Bearer key 时,有效会话不被一票否决; -// /api/me 的 authVia 区分凭证通道(key / session),供前端区分改密门放行范围。 +// TestStaleKeySessionFallback:混合 Bearer 与 cookie 的认证上下文不确定,必须拒绝。 +// /api/me 的 authVia 仍区分单独有效的 key / session。 func TestStaleKeySessionFallback(t *testing.T) { srv, st := newAuthTestServer(t) h := srv.Handler() @@ -486,14 +493,14 @@ func TestStaleKeySessionFallback(t *testing.T) { } tok := strings.Split(strings.Split(rec.Header().Get("Set-Cookie"), SessionCookie+"=")[1], ";")[0] - // 会话 + 失效 key:回落会话(200,authVia=session),不再被 key 一票否决。 + // 会话 + 失效 key:拒绝混合凭证,不把有效 cookie 当证明兜底。 req = httptest.NewRequest("GET", "/api/me", nil) req.AddCookie(&http.Cookie{Name: SessionCookie, Value: tok}) req.Header.Set("Authorization", "Bearer ask_stale_key") rec = httptest.NewRecorder() h.ServeHTTP(rec, req) - if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), `"authVia":"session"`) { - t.Fatalf("stale key + valid session must fall back to session: %d %s", rec.Code, rec.Body.String()) + if rec.Code != http.StatusUnauthorized { + t.Fatalf("stale key + valid session must be rejected: %d %s", rec.Code, rec.Body.String()) } // 失效 key 且无会话:仍 401(key 专属文案)。 diff --git a/internal/server/server.go b/internal/server/server.go index d90b3c3..3e1ad8a 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -286,6 +286,11 @@ func (s *Server) Handler() http.Handler { mux.HandleFunc("POST /api/auth/password", s.changePassword) mux.HandleFunc("GET /api/admin/users", s.listAdminUsers) mux.HandleFunc("POST /api/admin/users", s.createAdminUser) + mux.HandleFunc("GET /api/me/profile", s.meProfile) + mux.HandleFunc("PATCH /api/me/profile", s.patchMeProfile) + mux.HandleFunc("GET /api/admin/users/{userId}", s.adminAccount) + mux.HandleFunc("PATCH /api/admin/users/{userId}/profile", s.patchAdminAccountProfile) + mux.HandleFunc("PUT /api/admin/users/{userId}/state", s.setAdminAccountState) mux.HandleFunc("POST /api/admin/users/{owner}/password", s.resetAdminUserPassword) mux.HandleFunc("POST /api/admin/users/{owner}/login-keys", s.issueAdminLoginKey) mux.HandleFunc("GET /api/admin/users/{owner}/login-keys", s.listAdminLoginKeys) @@ -513,6 +518,11 @@ func (s *Server) events(w http.ResponseWriter, r *http.Request) { lastVisible := since send := func(e core.StreamEvent) bool { + // Recheck before every frame, including stored replay. A revocation during + // an active stream must not disclose the next queued business event. + if !stillAuthed() { + return false + } if e.Seq <= lastVisible { return true } @@ -572,6 +582,10 @@ func (s *Server) events(w http.ResponseWriter, r *http.Request) { return } case <-heartbeat.C: + if !stillAuthed() { + stop() + return + } fmt.Fprint(w, ": ping\n\n") flusher.Flush() case e, ok := <-live: @@ -2152,6 +2166,14 @@ func writeOKOr(w http.ResponseWriter, err error) { } func writeStoreErr(w http.ResponseWriter, err error) { + if errors.Is(err, store.ErrAccountForbidden) { + http.Error(w, err.Error(), http.StatusForbidden) + return + } + if errors.Is(err, store.ErrInvalidAccountProfile) { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } if errors.Is(err, store.ErrNotFound) { http.Error(w, err.Error(), http.StatusNotFound) return diff --git a/internal/store/account_model_test.go b/internal/store/account_model_test.go index abc7db3..464c2fc 100644 --- a/internal/store/account_model_test.go +++ b/internal/store/account_model_test.go @@ -6,6 +6,7 @@ import ( "strings" "sync" "testing" + "time" ) // A legacy account must retain its identity and credentials and never acquire @@ -22,7 +23,11 @@ func TestAccountModelLegacyMigrationDefaults(t *testing.T) { if err := st.CreateAccount("legacy-empty", "", "", false); err != nil { t.Fatal(err) } - token, err := st.CreateSession("legacy") + // This fixture intentionally writes a v30 row before v31 migration. The + // current session API enforces the v31 account state and cannot be used here. + token := "sess_legacy_migration_fixture" + _, err := st.db.Exec(st.q(`INSERT INTO sessions(token_hash,owner,created_at,expires_at,last_seen_at,scope,auth_version) + VALUES(?,?,?,?,?,?,?)`), hashKey(token), "legacy", nowISO(), time.Now().Add(time.Hour).UTC().Format(time.RFC3339Nano), nowISO(), SessionScopeFull, 0) if err != nil { t.Fatal(err) } diff --git a/internal/store/account_state.go b/internal/store/account_state.go new file mode 100644 index 0000000..d1c18d8 --- /dev/null +++ b/internal/store/account_state.go @@ -0,0 +1,85 @@ +package store + +import ( + "errors" +) + +// SetAccountEnabled changes only the effective account state. The actor and +// target are rechecked inside one transaction; disabling also consumes every +// existing credential handle. A global manager lock serializes the last-admin +// decision across PostgreSQL connections. +func (s *Store) SetAccountEnabled(actorOwner, userID string, expectedStateVersion int64, enabled bool) (Account, error) { + if expectedStateVersion < 1 { + return Account{}, ErrConflict + } + tx, err := s.db.Begin() + if err != nil { + return Account{}, err + } + defer tx.Rollback() + if err := s.dialect.lockBootstrap(tx); err != nil { + return Account{}, err + } + target, err := scanAccount(tx.QueryRow(s.q(`SELECT `+accountColumns+` FROM users WHERE id=?`), userID)) + if err != nil { + return Account{}, err + } + locked, err := s.lockAccountUsers(tx, actorOwner, target.Owner) + if errors.Is(err, ErrNotFound) { + return Account{}, ErrAccountForbidden + } + if err != nil { + return Account{}, err + } + actor, target := locked[actorOwner], locked[target.Owner] + if !actor.Enabled || !actor.IsAdmin { + return Account{}, ErrAccountForbidden + } + if target.ID != userID || target.StateVersion != expectedStateVersion || target.Enabled == enabled { + return Account{}, ErrConflict + } + if !enabled && target.IsAdmin && target.HasPassword && !target.MustChangePassword { + var managers int + if err := tx.QueryRow(s.q(`SELECT COUNT(1) FROM users WHERE enabled=? AND is_admin=? AND password_hash<>'' AND must_change_password=?`), true, true, false).Scan(&managers); err != nil { + return Account{}, err + } + if managers <= 1 { + return Account{}, ErrConflict + } + } + result, err := tx.Exec(s.q(`UPDATE users SET enabled=?,state_version=state_version+1,auth_version=auth_version+1 WHERE id=? AND state_version=?`), enabled, userID, expectedStateVersion) + if err != nil { + return Account{}, err + } + if n, err := result.RowsAffected(); err != nil { + return Account{}, err + } else if n != 1 { + return Account{}, ErrConflict + } + if !enabled { + if _, err := tx.Exec(s.q(`UPDATE api_keys SET revoked=? WHERE owner=? AND revoked=?`), 1, target.Owner, 0); err != nil { + return Account{}, err + } + if _, err := tx.Exec(s.q(`DELETE FROM sessions WHERE owner=?`), target.Owner); err != nil { + return Account{}, err + } + if _, err := tx.Exec(s.q(`UPDATE login_keys SET status=? WHERE owner=? AND status=?`), LoginKeyStatusRevoked, target.Owner, LoginKeyStatusUnused); err != nil { + return Account{}, err + } + } + if err := s.bumpAccountDirectoryRevision(tx, true, false); err != nil { + return Account{}, err + } + action := "account_enabled" + if !enabled { + action = "account_disabled" + } + if err := s.insertAccountEvent(tx, actorOwner, "account-state", target.Owner, action, ""); err != nil { + return Account{}, err + } + if err := tx.Commit(); err != nil { + return Account{}, err + } + target.Enabled, target.StateVersion, target.AuthVersion = enabled, expectedStateVersion+1, target.AuthVersion+1 + return target, nil +} diff --git a/internal/store/account_state_test.go b/internal/store/account_state_test.go new file mode 100644 index 0000000..9847577 --- /dev/null +++ b/internal/store/account_state_test.go @@ -0,0 +1,99 @@ +package store + +import ( + "errors" + "testing" +) + +func TestAccountStateDisablesCredentialsWithoutRestoringThem(t *testing.T) { + st := openTestStore(t) + adminHash, err := HashPassword("admin-password-1") + if err != nil { + t.Fatal(err) + } + if err := st.CreateAccount("admin", "", adminHash, true); err != nil { + t.Fatal(err) + } + if err := st.ClearMustChangePassword("admin"); err != nil { + t.Fatal(err) + } + userHash, err := HashPassword("user-password-1") + if err != nil { + t.Fatal(err) + } + if err := st.CreateAccount("user", "", userHash, false); err != nil { + t.Fatal(err) + } + if err := st.ClearMustChangePassword("user"); err != nil { + t.Fatal(err) + } + key, err := st.CreateAPIKey("user", "existing") + if err != nil { + t.Fatal(err) + } + session, err := st.CreateSession("user") + if err != nil { + t.Fatal(err) + } + user, err := st.GetAccountByOwner("user") + if err != nil { + t.Fatal(err) + } + if _, err := st.SetAccountEnabled("admin", user.ID, user.StateVersion, false); err != nil { + t.Fatal(err) + } + if _, ok := st.AuthOwner(key); ok { + t.Fatal("disabled user's API key authenticated") + } + if _, _, ok := st.SessionInfo(session); ok { + t.Fatal("disabled user's session authenticated") + } + if _, _, ok := st.VerifyLoginWithVersion("user", "user-password-1"); ok { + t.Fatal("disabled user logged in") + } + if _, err := st.CreateSession("user"); !errors.Is(err, ErrAccountForbidden) { + t.Fatalf("disabled user gained a new session: %v", err) + } + if _, err := st.CreateAPIKey("user", "new"); !errors.Is(err, ErrAccountForbidden) { + t.Fatalf("disabled user gained a new key: %v", err) + } + if _, err := st.SetAccountEnabled("admin", user.ID, user.StateVersion+1, true); err != nil { + t.Fatal(err) + } + if _, ok := st.AuthOwner(key); ok { + t.Fatal("revoked key was restored by enable") + } + if _, _, ok := st.SessionInfo(session); ok { + t.Fatal("revoked session was restored by enable") + } + if _, _, ok := st.VerifyLoginWithVersion("user", "user-password-1"); !ok { + t.Fatal("retained password could not log in after enable") + } +} + +func TestAccountStateProtectsLastOperableSystemManager(t *testing.T) { + st := openTestStore(t) + hash, err := HashPassword("admin-password-1") + if err != nil { + t.Fatal(err) + } + for _, owner := range []string{"admin-a", "admin-b"} { + if err := st.CreateAccount(owner, "", hash, true); err != nil { + t.Fatal(err) + } + if err := st.ClearMustChangePassword(owner); err != nil { + t.Fatal(err) + } + } + a, _ := st.GetAccountByOwner("admin-a") + b, _ := st.GetAccountByOwner("admin-b") + if _, err := st.SetAccountEnabled("admin-a", a.ID, a.StateVersion, false); err != nil { + t.Fatal(err) + } + if _, err := st.SetAccountEnabled("admin-b", b.ID, b.StateVersion, false); !errors.Is(err, ErrConflict) { + t.Fatalf("last operable manager disabled: %v", err) + } + if _, err := st.SetAccountEnabled("admin-a", b.ID, b.StateVersion, false); !errors.Is(err, ErrAccountForbidden) { + t.Fatalf("disabled manager changed another account: %v", err) + } +} diff --git a/internal/store/auth.go b/internal/store/auth.go index 5335f6f..ea62d12 100644 --- a/internal/store/auth.go +++ b/internal/store/auth.go @@ -92,11 +92,17 @@ func (s *Store) CreateAPIKey(owner, name string) (string, error) { if name == "" { name = "key-" + nowISO()[:10] } - _, err := s.db.Exec(s.q(`INSERT INTO api_keys(key_hash,owner,name,created_at,revoked) VALUES(?,?,?,?,0)`), - hashKey(plain), owner, name, nowISO()) + result, err := s.db.Exec(s.q(`INSERT INTO api_keys(key_hash,owner,name,created_at,revoked) + SELECT ?,owner,?,?,? FROM users WHERE owner=? AND enabled=?`), + hashKey(plain), name, nowISO(), 0, owner, true) if err != nil { return "", err } + if n, err := result.RowsAffected(); err != nil { + return "", err + } else if n != 1 { + return "", ErrAccountForbidden + } return plain, nil } @@ -142,7 +148,8 @@ func (s *Store) AuthOwner(plainKey string) (string, bool) { } var owner string var revoked int - err := s.db.QueryRow(s.q(`SELECT owner,revoked FROM api_keys WHERE key_hash=?`), hashKey(plainKey)).Scan(&owner, &revoked) + err := s.db.QueryRow(s.q(`SELECT k.owner,k.revoked FROM api_keys k JOIN users u ON u.owner=k.owner + WHERE k.key_hash=? AND u.enabled=?`), hashKey(plainKey), true).Scan(&owner, &revoked) if err != nil || revoked == 1 { return "", false } @@ -301,7 +308,7 @@ func (s *Store) VerifyLogin(owner, password string) (UserAuthState, bool) { func (s *Store) VerifyLoginWithVersion(owner, password string) (UserAuthState, int, bool) { var hash string var ver int - err := s.db.QueryRow(s.q(`SELECT password_hash,auth_version FROM users WHERE owner=?`), owner).Scan(&hash, &ver) + err := s.db.QueryRow(s.q(`SELECT password_hash,auth_version FROM users WHERE owner=? AND enabled=?`), owner, true).Scan(&hash, &ver) if err != nil || hash == "" || !VerifyPassword(hash, password) { return UserAuthState{}, 0, false } @@ -327,17 +334,31 @@ func (s *Store) CreateSessionScoped(owner, scope string, ttl time.Duration) (str if ttl <= 0 { ttl = sessionTTL } - ver := 0 - _ = s.db.QueryRow(s.q(`SELECT auth_version FROM users WHERE owner=?`), owner).Scan(&ver) + state, err := s.GetUserAuthState(owner) + if err != nil { + return "", err + } + if !state.Enabled { + return "", ErrAccountForbidden + } raw := make([]byte, 32) if _, err := rand.Read(raw); err != nil { return "", err } token := "sess_" + hex.EncodeToString(raw) exp := time.Now().Add(ttl) - _, err := s.db.Exec(s.q(`INSERT INTO sessions(token_hash,owner,created_at,expires_at,last_seen_at,scope,auth_version) VALUES(?,?,?,?,?,?,?)`), - hashKey(token), owner, nowISO(), exp.UTC().Format(time.RFC3339Nano), nowISO(), scope, ver) - return token, err + result, err := s.db.Exec(s.q(`INSERT INTO sessions(token_hash,owner,created_at,expires_at,last_seen_at,scope,auth_version) + SELECT ?,owner,?,?,?,?,auth_version FROM users WHERE owner=? AND enabled=? AND auth_version=?`), + hashKey(token), nowISO(), exp.UTC().Format(time.RFC3339Nano), nowISO(), scope, owner, true, state.AuthVersion) + if err != nil { + return "", err + } + if n, err := result.RowsAffected(); err != nil { + return "", err + } else if n != 1 { + return "", ErrConflict + } + return token, nil } // CreateSessionVerified 建会话并绑定期望认证版本:事务内重读账户版本,与密码验证 @@ -361,18 +382,21 @@ func (s *Store) CreateSessionVerified(owner, scope string, ttl time.Duration, ex } defer tx.Rollback() var ver int - err = tx.QueryRow(s.q(`SELECT auth_version FROM users WHERE owner=?`), owner).Scan(&ver) + var enabled bool + var origin CredentialOrigin + err = tx.QueryRow(s.q(`SELECT auth_version,enabled,credential_origin FROM users WHERE owner=?`), owner).Scan(&ver, &enabled, &origin) if errors.Is(err, sql.ErrNoRows) { return "", ErrNotFound } if err != nil { return "", err } - if ver != expectVersion { + if !enabled || ver != expectVersion { return "", ErrConflict } - if _, err := tx.Exec(s.q(`INSERT INTO sessions(token_hash,owner,created_at,expires_at,last_seen_at,scope,auth_version) VALUES(?,?,?,?,?,?,?)`), - hashKey(token), owner, nowISO(), exp.UTC().Format(time.RFC3339Nano), nowISO(), scope, ver); err != nil { + if _, err := tx.Exec(s.q(`INSERT INTO sessions(token_hash,owner,created_at,expires_at,last_seen_at,scope,auth_version,auth_method,credential_origin) + VALUES(?,?,?,?,?,?,?,?,?)`), + hashKey(token), owner, nowISO(), exp.UTC().Format(time.RFC3339Nano), nowISO(), scope, ver, AuthMethodPassword, origin); err != nil { return "", err } return token, tx.Commit() @@ -390,7 +414,8 @@ func (s *Store) SessionInfo(token string) (owner, scope string, ok bool) { return "", "", false } var exp string - err := s.db.QueryRow(s.q(`SELECT owner,expires_at,COALESCE(scope,'full') FROM sessions WHERE token_hash=?`), hashKey(token)). + err := s.db.QueryRow(s.q(`SELECT s.owner,s.expires_at,COALESCE(s.scope,'full') FROM sessions s + JOIN users u ON u.owner=s.owner WHERE s.token_hash=? AND u.enabled=? AND s.auth_version=u.auth_version`), hashKey(token), true). Scan(&owner, &exp, &scope) if err != nil { return "", "", false diff --git a/internal/store/login_keys.go b/internal/store/login_keys.go index cea3683..06ceced 100644 --- a/internal/store/login_keys.go +++ b/internal/store/login_keys.go @@ -112,6 +112,13 @@ func (s *Store) IssueLoginKey(owner, purpose, createdBy string, ttl time.Duratio return "", meta, err } defer tx.Rollback() + locked, err := s.lockAccountUsers(tx, owner) + if err != nil { + return "", meta, err + } + if !locked[owner].Enabled { + return "", meta, ErrAccountForbidden + } meta, err = s.insertLoginKeyTx(tx, owner, purpose, createdBy, plain, id, now, exp) if err != nil { return "", meta, err @@ -361,10 +368,14 @@ func (s *Store) RedeemLoginKey(plain string) (token string, owner string, err er return "", "", ErrConflict } var authVersion int - err = tx.QueryRow(s.q(`SELECT auth_version FROM users WHERE owner=?`), o).Scan(&authVersion) + var enabled bool + err = tx.QueryRow(s.q(`SELECT auth_version,enabled FROM users WHERE owner=?`), o).Scan(&authVersion, &enabled) if err != nil { return "", "", err // 账户已不存在:兑换失败整体回滚 } + if !enabled { + return "", "", ErrAccountForbidden + } raw := make([]byte, 32) if _, err := rand.Read(raw); err != nil { return "", "", err @@ -396,17 +407,20 @@ func (s *Store) CompleteSetPasswordChecked(token, passwordHash string) (owner st defer tx.Rollback() var scope, sessExp string var sessVer, userVer int - var mustChange bool + var mustChange, enabled bool var hash sql.NullString - err = tx.QueryRow(s.q(`SELECT s.owner,s.scope,s.expires_at,s.auth_version,u.auth_version,u.must_change_password,u.password_hash + err = tx.QueryRow(s.q(`SELECT s.owner,s.scope,s.expires_at,s.auth_version,u.auth_version,u.must_change_password,u.password_hash,u.enabled FROM sessions s JOIN users u ON u.owner=s.owner WHERE s.token_hash=?`), hashKey(token)). - Scan(&owner, &scope, &sessExp, &sessVer, &userVer, &mustChange, &hash) + Scan(&owner, &scope, &sessExp, &sessVer, &userVer, &mustChange, &hash, &enabled) if errors.Is(err, sql.ErrNoRows) { return "", ErrNotFound } if err != nil { return "", err } + if !enabled { + return "", ErrAccountForbidden + } if expT, perr := time.Parse(time.RFC3339Nano, sessExp); perr != nil || time.Now().After(expT) { return "", ErrNotFound } @@ -453,15 +467,18 @@ func (s *Store) ChangeUserPasswordActive(owner, current, passwordHash, keepToken defer tx.Rollback() var hash string var ver int - var mustChange bool - err = tx.QueryRow(s.q(`SELECT password_hash,auth_version,must_change_password FROM users WHERE owner=?`), owner). - Scan(&hash, &ver, &mustChange) + var mustChange, enabled bool + err = tx.QueryRow(s.q(`SELECT password_hash,auth_version,must_change_password,enabled FROM users WHERE owner=?`), owner). + Scan(&hash, &ver, &mustChange, &enabled) if errors.Is(err, sql.ErrNoRows) { return ErrNotFound } if err != nil { return err } + if !enabled { + return ErrAccountForbidden + } if mustChange || hash == "" { // 强制态必须走受限设密路径,不在此处用旧密码换新密码。 return ErrConflict @@ -478,6 +495,15 @@ func (s *Store) ChangeUserPasswordActive(owner, current, passwordHash, keepToken return ErrConflict // 并发改密:版本已前进 } if keepToken != "" { + kept, err := tx.Exec(s.q(`UPDATE sessions SET auth_version=? WHERE token_hash=? AND owner=? AND auth_version=? AND scope=?`), ver+1, hashKey(keepToken), owner, ver, SessionScopeFull) + if err != nil { + return err + } + if n, err := kept.RowsAffected(); err != nil { + return err + } else if n != 1 { + return ErrConflict + } if _, err := tx.Exec(s.q(`DELETE FROM sessions WHERE owner=? AND token_hash<>?`), owner, hashKey(keepToken)); err != nil { return err } From 2fb505c0ebb6ef2c9aa5f1a8586b16e0ee581bd5 Mon Sep 17 00:00:00 2001 From: weichen Date: Wed, 14 Oct 2026 03:29:26 +0000 Subject: [PATCH 04/17] Add scoped account directory provider Why: The old account lister exposed a global, always-valid directory without a domain locator or field authorization. Evidence: SELF receipts /tmp/iam-u3-u3-backend-sqlite.json and /tmp/iam-u3-u3-backend-pg.json. Impact: Adds versioned optional query and exact lookup, trusted RDM scope registration, visible-field cursor binding, and restricts legacy EXT-19 to effective system administrators. RDM consumption remains a separate U3-5 unit. Refs: IAM U3 r1 stages 4-5; Dev feature/iam-u3-account-directory-recovery --- internal/app/account_directory.go | 353 ++++++++++++++++++++++++ internal/app/account_directory_test.go | 129 +++++++++ internal/app/account_lister_test.go | 21 +- internal/app/app.go | 6 +- internal/app/compiled.go | 19 ++ internal/app/hostcaps.go | 39 ++- internal/store/account_directory.go | 30 ++ internal/store/auth.go | 5 +- pkg/extension/account_directory.go | 88 ++++++ pkg/extension/account_directory_test.go | 13 + pkg/extension/extension.go | 14 +- pkg/extension/surface.txt | 18 ++ 12 files changed, 709 insertions(+), 26 deletions(-) create mode 100644 internal/app/account_directory.go create mode 100644 internal/app/account_directory_test.go create mode 100644 internal/store/account_directory.go create mode 100644 pkg/extension/account_directory.go create mode 100644 pkg/extension/account_directory_test.go diff --git a/internal/app/account_directory.go b/internal/app/account_directory.go new file mode 100644 index 0000000..faf0b48 --- /dev/null +++ b/internal/app/account_directory.go @@ -0,0 +1,353 @@ +package app + +import ( + "context" + "crypto/hmac" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "encoding/json" + "errors" + "sort" + "strings" + "sync" + "unicode" + "unicode/utf8" + + "agent-system-backend/internal/store" + "agent-system-backend/pkg/extension" +) + +type accountDirectorySlot struct { + mu sync.RWMutex + pluginID string + resolver extension.AccountDirectoryScopeResolver + secret [32]byte +} + +func newAccountDirectorySlot() (*accountDirectorySlot, error) { + slot := &accountDirectorySlot{} + if _, err := rand.Read(slot.secret[:]); err != nil { + return nil, err + } + return slot, nil +} + +func (s *accountDirectorySlot) register(pluginID string, fn extension.AccountDirectoryScopeResolver) error { + if fn == nil || pluginID != "rdm" { + return store.ErrAccountForbidden + } + s.mu.Lock() + defer s.mu.Unlock() + if s.resolver != nil { + return errors.New("account directory resolver already registered") + } + s.pluginID, s.resolver = pluginID, fn + return nil +} + +type directoryPolicy struct { + allowed map[string]bool + match []string + candidates map[string]bool + all bool + status string + version string +} + +type directoryCursor struct { + Version string `json:"v"` + Owner string `json:"o"` + UserID string `json:"u"` +} + +func knownDirectoryField(field string) bool { + return field == "owner" || field == "name" || field == "staffNo" +} + +func (h inprocHost) directoryPolicy(ctx context.Context, q extension.AccountDirectoryQuery) (directoryPolicy, error) { + var p directoryPolicy + if h.actor == "" || h.caps.st == nil || h.caps.directory == nil || q.SchemaVersion != extension.AccountDirectorySchemaVersion { + return p, store.ErrAccountForbidden + } + actor, err := h.caps.st.GetUserAuthState(h.actor) + if err != nil || !actor.Enabled { + return p, store.ErrAccountForbidden + } + if q.Mode != extension.AccountDirectoryModeExact && q.Mode != extension.AccountDirectoryModeFuzzy { + return p, store.ErrInvalidAccountProfile + } + if !utf8.ValidString(q.Query) || utf8.RuneCountInString(q.Query) > 128 { + return p, store.ErrInvalidAccountProfile + } + for _, r := range q.Query { + if unicode.IsControl(r) { + return p, store.ErrInvalidAccountProfile + } + } + p.status = q.Status + if p.status == "" { + p.status = "enabled" + } + if p.status != "enabled" && p.status != "disabled" && p.status != "all" { + return p, store.ErrInvalidAccountProfile + } + var scope extension.AccountDirectoryScope + switch q.Purpose { + case extension.AccountDirectoryPurposeSystemUsers: + if !actor.IsAdmin || q.ProjectID != "" || q.DocumentID != "" { + return p, store.ErrAccountForbidden + } + scope = extension.AccountDirectoryScope{AllowedFields: []string{"owner", "name", "staffNo"}, + MatchFields: []string{"owner", "name", "staffNo"}, AllCandidates: true, ScopeVersion: "system.users.v1"} + case extension.AccountDirectoryPurposeProjectMembers, extension.AccountDirectoryPurposeDocumentDuties: + if h.pluginID != "rdm" || q.ProjectID == "" || (q.Purpose == extension.AccountDirectoryPurposeDocumentDuties && q.DocumentID == "") || + (q.Purpose == extension.AccountDirectoryPurposeProjectMembers && q.DocumentID != "") || p.status != "enabled" { + return p, store.ErrAccountForbidden + } + h.caps.directory.mu.RLock() + resolver, pluginID := h.caps.directory.resolver, h.caps.directory.pluginID + h.caps.directory.mu.RUnlock() + if resolver == nil || pluginID != h.pluginID { + return p, store.ErrAccountForbidden + } + scope, err = resolver(ctx, extension.AccountDirectoryScopeRequest{ + Actor: h.actor, PluginID: h.pluginID, Purpose: q.Purpose, ProjectID: q.ProjectID, DocumentID: q.DocumentID, + }) + if err != nil { + return p, store.ErrAccountForbidden + } + if q.Purpose == extension.AccountDirectoryPurposeDocumentDuties && scope.AllCandidates { + return p, store.ErrAccountForbidden + } + default: + return p, store.ErrAccountForbidden + } + if scope.ScopeVersion == "" { + return p, store.ErrAccountForbidden + } + p.allowed = map[string]bool{"owner": true} + for _, field := range scope.AllowedFields { + if !knownDirectoryField(field) { + return p, store.ErrAccountForbidden + } + p.allowed[field] = true + } + matchAllowed := map[string]bool{} + for _, field := range scope.MatchFields { + if !knownDirectoryField(field) || !p.allowed[field] { + return p, store.ErrAccountForbidden + } + matchAllowed[field] = true + } + requested := q.SearchFields + if len(requested) == 0 { + requested = []string{"owner", "name"} + } + for _, field := range requested { + if !matchAllowed[field] { + return p, store.ErrAccountForbidden + } + p.match = append(p.match, field) + } + sort.Strings(p.match) + p.candidates = make(map[string]bool, len(scope.CandidateIDs)) + for _, id := range scope.CandidateIDs { + if id == "" { + return p, store.ErrAccountForbidden + } + p.candidates[id] = true + } + p.all = scope.AllCandidates + if q.Purpose == extension.AccountDirectoryPurposeSystemUsers { + p.all = true + } + revision, err := h.caps.st.GetAccountDirectoryRevision() + if err != nil { + return p, err + } + ids := make([]string, 0, len(p.candidates)) + for id := range p.candidates { + ids = append(ids, id) + } + sort.Strings(ids) + fingerprint := struct { + Actor, Plugin, Purpose, Project, Document, Mode, Query, Status, Public, Staff, Scope string + Match, IDs []string + All bool + }{Actor: h.actor, Plugin: h.pluginID, Purpose: q.Purpose, Project: q.ProjectID, Document: q.DocumentID, + Mode: q.Mode, Query: strings.TrimSpace(q.Query), Status: p.status, Public: revision.Public, + Scope: scope.ScopeVersion, Match: p.match, IDs: ids, All: p.all} + if p.allowed["staffNo"] { + fingerprint.Staff = revision.StaffNo + } + encoded, err := json.Marshal(fingerprint) + if err != nil { + return p, err + } + digest := sha256.Sum256(encoded) + p.version = hex.EncodeToString(digest[:]) + return p, nil +} + +func (s *accountDirectorySlot) encodeCursor(c directoryCursor) (string, error) { + data, err := json.Marshal(c) + if err != nil { + return "", err + } + mac := hmac.New(sha256.New, s.secret[:]) + _, _ = mac.Write(data) + return base64.RawURLEncoding.EncodeToString(data) + "." + base64.RawURLEncoding.EncodeToString(mac.Sum(nil)), nil +} + +func (s *accountDirectorySlot) decodeCursor(token, version string) (directoryCursor, error) { + var c directoryCursor + payload, signature, found := strings.Cut(token, ".") + if !found { + return c, store.ErrConflict + } + data, err := base64.RawURLEncoding.DecodeString(payload) + if err != nil || len(data) > 1024 { + return c, store.ErrConflict + } + provided, err := base64.RawURLEncoding.DecodeString(signature) + if err != nil { + return c, store.ErrConflict + } + mac := hmac.New(sha256.New, s.secret[:]) + _, _ = mac.Write(data) + if !hmac.Equal(provided, mac.Sum(nil)) || json.Unmarshal(data, &c) != nil || c.Version != version || c.Owner == "" || c.UserID == "" { + return directoryCursor{}, store.ErrConflict + } + return c, nil +} + +func directoryEntry(row store.DirectoryAccountRow, p directoryPolicy) extension.AccountDirectoryEntry { + entry := extension.AccountDirectoryEntry{UserID: row.UserID, Owner: row.Owner, Valid: row.Enabled} + if p.allowed["name"] { + entry.Name = row.Name + } + if p.allowed["staffNo"] { + entry.StaffNo = row.StaffNo + } + return entry +} + +func directoryMatches(row store.DirectoryAccountRow, fields []string, mode, query string) bool { + for _, field := range fields { + value := row.Owner + switch field { + case "name": + value = row.Name + case "staffNo": + value = row.StaffNo + } + if mode == extension.AccountDirectoryModeExact && strings.EqualFold(value, query) { + return true + } + if mode == extension.AccountDirectoryModeFuzzy && strings.Contains(strings.ToLower(value), strings.ToLower(query)) { + return true + } + } + return false +} + +func directoryRowAllowed(row store.DirectoryAccountRow, p directoryPolicy) bool { + if !p.all && !p.candidates[row.UserID] { + return false + } + return p.status == "all" || (p.status == "enabled" && row.Enabled) || (p.status == "disabled" && !row.Enabled) +} + +func (h inprocHost) QueryAccountDirectory(ctx context.Context, q extension.AccountDirectoryQuery) (extension.AccountDirectoryPage, error) { + p, err := h.directoryPolicy(ctx, q) + if err != nil { + return extension.AccountDirectoryPage{}, err + } + maxLimit := 50 + if q.Purpose == extension.AccountDirectoryPurposeSystemUsers { + maxLimit = 100 + } + if q.Limit == 0 { + q.Limit = 20 + } + if q.Limit < 1 || q.Limit > maxLimit { + return extension.AccountDirectoryPage{}, store.ErrInvalidAccountProfile + } + page := extension.AccountDirectoryPage{Entries: []extension.AccountDirectoryEntry{}, DirectoryVersion: p.version} + query := strings.TrimSpace(q.Query) + if query == "" { + if q.Cursor != "" { + return extension.AccountDirectoryPage{}, store.ErrConflict + } + return page, nil + } + if q.Mode == extension.AccountDirectoryModeFuzzy && utf8.RuneCountInString(query) < 2 { + return extension.AccountDirectoryPage{}, store.ErrInvalidAccountProfile + } + var after directoryCursor + if q.Cursor != "" { + after, err = h.caps.directory.decodeCursor(q.Cursor, p.version) + if err != nil { + return extension.AccountDirectoryPage{}, err + } + } + rows, err := h.caps.st.ListDirectoryAccountRows() + if err != nil { + return extension.AccountDirectoryPage{}, err + } + matched := make([]store.DirectoryAccountRow, 0, q.Limit+1) + for _, row := range rows { + if !directoryRowAllowed(row, p) || !directoryMatches(row, p.match, q.Mode, query) { + continue + } + if after.Owner != "" && (row.Owner < after.Owner || (row.Owner == after.Owner && row.UserID <= after.UserID)) { + continue + } + matched = append(matched, row) + if len(matched) > q.Limit { + break + } + } + more := len(matched) > q.Limit + if more { + matched = matched[:q.Limit] + } + for _, row := range matched { + page.Entries = append(page.Entries, directoryEntry(row, p)) + } + if more { + last := matched[len(matched)-1] + page.NextCursor, err = h.caps.directory.encodeCursor(directoryCursor{Version: p.version, Owner: last.Owner, UserID: last.UserID}) + if err != nil { + return extension.AccountDirectoryPage{}, err + } + } + return page, nil +} + +func (h inprocHost) LookupAccountDirectory(ctx context.Context, q extension.AccountDirectoryQuery, userID string) (extension.AccountDirectoryLookup, error) { + if userID == "" || q.Cursor != "" { + return extension.AccountDirectoryLookup{}, store.ErrInvalidAccountProfile + } + p, err := h.directoryPolicy(ctx, q) + if err != nil { + return extension.AccountDirectoryLookup{}, err + } + if !p.all && !p.candidates[userID] { + return extension.AccountDirectoryLookup{}, store.ErrNotFound + } + account, err := h.caps.st.GetAccount(userID) + if err != nil { + return extension.AccountDirectoryLookup{}, err + } + row := store.DirectoryAccountRow{UserID: account.ID, Owner: account.Owner, Name: account.Profile.Name, + StaffNo: account.Profile.StaffNo, Enabled: account.Enabled} + if !directoryRowAllowed(row, p) { + return extension.AccountDirectoryLookup{}, store.ErrNotFound + } + return extension.AccountDirectoryLookup{Entry: directoryEntry(row, p), DirectoryVersion: p.version}, nil +} + +var _ extension.AccountDirectoryProvider = inprocHost{} diff --git a/internal/app/account_directory_test.go b/internal/app/account_directory_test.go new file mode 100644 index 0000000..65588d0 --- /dev/null +++ b/internal/app/account_directory_test.go @@ -0,0 +1,129 @@ +package app + +import ( + "context" + "database/sql" + "errors" + "os" + "path/filepath" + "strings" + "testing" + + "agent-system-backend/internal/store" + "agent-system-backend/pkg/extension" +) + +func openDirectoryTestStore(t *testing.T) *store.Store { + t.Helper() + dsn := os.Getenv("AGENT_PG_TEST_DSN") + if dsn == "" { + st, err := store.OpenSQLite(filepath.Join(t.TempDir(), "directory.db")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { st.Close() }) + return st + } + admin, err := sql.Open("pgx", dsn) + if err != nil { + t.Fatal(err) + } + schema := "t_" + strings.ToLower(store.NewID("dir")) + if _, err := admin.Exec(`CREATE EXTENSION IF NOT EXISTS pg_trgm`); err != nil { + t.Fatal(err) + } + if _, err := admin.Exec(`CREATE SCHEMA ` + schema); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + _, _ = admin.Exec(`DROP SCHEMA IF EXISTS ` + schema + ` CASCADE`) + _ = admin.Close() + }) + separator := "?" + if strings.Contains(dsn, "?") { + separator = "&" + } + st, err := store.OpenPostgres(dsn+separator+"search_path="+schema+",public", filepath.Join(t.TempDir(), "artifacts")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { st.Close() }) + return st +} + +func TestAccountDirectoryAppliesDomainScopeBeforeSearchAndCursor(t *testing.T) { + st := openDirectoryTestStore(t) + t.Logf("directory provider dialect=%s", st.Dialect()) + if err := st.CreateAccount("admin", "", "hash", true); err != nil { + t.Fatal(err) + } + alice, err := st.CreatePendingAccount("admin", "alice", store.AccountProfile{Name: "同名", StaffNo: "hidden-a"}) + if err != nil { + t.Fatal(err) + } + bob, err := st.CreatePendingAccount("admin", "bob", store.AccountProfile{Name: "同名", StaffNo: "hidden-b"}) + if err != nil { + t.Fatal(err) + } + _, err = st.CreatePendingAccount("admin", "outsider", store.AccountProfile{Name: "同名", StaffNo: "hidden-x"}) + if err != nil { + t.Fatal(err) + } + slot, err := newAccountDirectorySlot() + if err != nil { + t.Fatal(err) + } + h := inprocHost{caps: hostcaps{st: st, directory: slot}, pluginID: "rdm", actor: "admin", owner: "admin"} + query := extension.AccountDirectoryQuery{SchemaVersion: extension.AccountDirectorySchemaVersion, + Purpose: extension.AccountDirectoryPurposeProjectMembers, ProjectID: "project-1", + Mode: extension.AccountDirectoryModeFuzzy, Query: "同名", Limit: 1} + if _, err := h.QueryAccountDirectory(context.Background(), query); err == nil { + t.Fatal("domain directory ran without trusted resolver") + } + if err := (pluginRegistry{pluginID: "rdm", caps: h.caps}).RegisterAccountDirectoryScopeResolver(func(context.Context, extension.AccountDirectoryScopeRequest) (extension.AccountDirectoryScope, error) { + return extension.AccountDirectoryScope{AllowedFields: []string{"owner", "name"}, MatchFields: []string{"owner", "name"}, + CandidateIDs: []string{alice.ID, bob.ID}, ScopeVersion: "scope-1"}, nil + }); err != nil { + t.Fatal(err) + } + page, err := h.QueryAccountDirectory(context.Background(), query) + if err != nil || len(page.Entries) != 1 || page.Entries[0].UserID != alice.ID || page.Entries[0].StaffNo != "" || page.NextCursor == "" { + t.Fatalf("first scoped page: %+v %v", page, err) + } + query.Cursor = page.NextCursor + second, err := h.QueryAccountDirectory(context.Background(), query) + if err != nil || len(second.Entries) != 1 || second.Entries[0].UserID != bob.ID || second.NextCursor != "" { + t.Fatalf("second scoped page: %+v %v", second, err) + } + query.Cursor = "" + query.Query = "hidden-x" + if found, err := h.QueryAccountDirectory(context.Background(), query); err != nil || len(found.Entries) != 0 { + t.Fatalf("hidden staff number matched: %+v %v", found, err) + } + query.SearchFields = []string{"staffNo"} + if _, err := h.QueryAccountDirectory(context.Background(), query); err == nil { + t.Fatal("hidden staff number accepted as search field") + } + query.SearchFields = nil + query.Query = "同名" + if _, err := h.LookupAccountDirectory(context.Background(), query, "not-in-scope"); !errors.Is(err, store.ErrNotFound) { + t.Fatalf("exact lookup leaked outsider: %v", err) + } + page, err = h.QueryAccountDirectory(context.Background(), query) + if err != nil { + t.Fatal(err) + } + if _, err := st.PatchAccountProfile("admin", alice.ID, alice.ProfileVersion, map[string]string{"phone": "private-edit"}); err != nil { + t.Fatal(err) + } + query.Cursor = page.NextCursor + if _, err := h.QueryAccountDirectory(context.Background(), query); err != nil { + t.Fatalf("private edit invalidated candidate cursor: %v", err) + } + if _, err := st.PatchAccountProfile("admin", alice.ID, alice.ProfileVersion+1, map[string]string{"name": "changed"}); err != nil { + t.Fatal(err) + } + if _, err := h.QueryAccountDirectory(context.Background(), query); !errors.Is(err, store.ErrConflict) { + t.Fatalf("visible edit did not invalidate cursor: %v", err) + } +} diff --git a/internal/app/account_lister_test.go b/internal/app/account_lister_test.go index 6ee2898..2facd50 100644 --- a/internal/app/account_lister_test.go +++ b/internal/app/account_lister_test.go @@ -20,7 +20,13 @@ func TestInprocHostListAccounts(t *testing.T) { t.Fatal(err) } } - h := inprocHost{caps: hostcaps{st: st}} + if _, err := st.DB().Exec(`UPDATE users SET is_admin=TRUE WHERE owner='alice'`); err != nil { + t.Fatal(err) + } + h := inprocHost{caps: hostcaps{st: st}, owner: "alice", actor: "alice"} + if _, _, err := (inprocHost{caps: hostcaps{st: st}}).ListAccounts("", 2); err == nil { + t.Fatal("legacy directory allowed a host without a trusted dispatch actor") + } // 首页(limit=2):alice/bob,next=bob。 page, next, err := h.ListAccounts("", 2) @@ -50,6 +56,12 @@ func TestInprocHostListAccounts(t *testing.T) { if _, _, err := h.ListAccounts("", 999); err != nil { t.Fatalf("limit 超限应钳制不报错: %v", err) } + if _, err := st.DB().Exec(`UPDATE users SET enabled=FALSE WHERE owner='alice'`); err != nil { + t.Fatal(err) + } + if _, _, err := h.ListAccounts("", 2); err == nil { + t.Fatal("disabled administrator retained legacy directory access") + } } // TestInprocHostSearchAccounts:EXT-19 扩展 SearchAccounts——账号/姓名子串 @@ -65,7 +77,7 @@ func TestInprocHostSearchAccounts(t *testing.T) { if err != nil { t.Fatal(err) } - if err := st.CreateAccount("alice", "王小明", hash, false); err != nil { + if err := st.CreateAccount("alice", "王小明", hash, true); err != nil { t.Fatal(err) } if err := st.CreateAccount("bob", "李大明", hash, false); err != nil { @@ -74,7 +86,10 @@ func TestInprocHostSearchAccounts(t *testing.T) { if err := st.CreateAccount("carol", "", hash, false); err != nil { t.Fatal(err) } - h := inprocHost{caps: hostcaps{st: st}} + h := inprocHost{caps: hostcaps{st: st}, owner: "alice", actor: "alice"} + if _, err := (inprocHost{caps: hostcaps{st: st}, owner: "bob", actor: "bob"}).SearchAccounts("alice", 50); err == nil { + t.Fatal("ordinary actor gained legacy system directory search") + } // 姓名子串(同名区分场景):两个"大明/小明"都以"明"命中。 got, err := h.SearchAccounts("明", 50) diff --git a/internal/app/app.go b/internal/app/app.go index cbc202b..777f837 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -609,7 +609,11 @@ func Run(opt Options) error { // hostcaps:hostapi 闭包与进程内插件句柄共用的 owner 作用域能力(EXT-1 批注 1)。 authzSlot := &projectAuthz{} projectSlot := &projectLifecycleSlot{} - caps := hostcaps{projects: projectSlot, st: st, pipe: pipe, set: setSvc, authz: authzSlot, surfaces: surfaceSvc} + directorySlot, err := newAccountDirectorySlot() + if err != nil { + return err + } + caps := hostcaps{projects: projectSlot, directory: directorySlot, st: st, pipe: pipe, set: setSvc, authz: authzSlot, surfaces: surfaceSvc} // EXT-13:回合内检索(knowledge_search / @knowledge 注入)与 HTTP/Host 检索面 // 同一适配器授权;槽为共享指针,扩展在 applyCompiled 期间注册后即生效。 mgr.SetSharedKnowledgeAllow(func(owner string, projectID core.ID) bool { diff --git a/internal/app/compiled.go b/internal/app/compiled.go index da47164..1e3c90a 100644 --- a/internal/app/compiled.go +++ b/internal/app/compiled.go @@ -116,6 +116,17 @@ func (r compiledRegistry) RegisterResourceAuthorizer(extension.ResourceAuthorize return errors.New("extension: declarative plugins cannot register resource authorizers") } +func (r compiledRegistry) RegisterAccountDirectoryScopeResolver(extension.AccountDirectoryScopeResolver) error { + return errors.New("extension: declarative plugins cannot register account directory authorization") +} + +func (r pluginRegistry) RegisterAccountDirectoryScopeResolver(fn extension.AccountDirectoryScopeResolver) error { + if r.pluginID != "rdm" || r.caps.directory == nil { + return errors.New("extension: account directory resolver requires trusted RDM registration") + } + return r.caps.directory.register(r.pluginID, fn) +} + // RegisterResourceAuthorizer 进程内扩展(EXT-1):填充 EXT-8 适配器槽 // (hostcaps.authz 共享指针;首个注册者生效,组合根已装配进 Server/runmgr)。 // 此前 pluginRegistry 未覆盖该方法——进程内插件注册适配器会命中声明式分支的 @@ -150,6 +161,14 @@ type recordRegistry struct { sum *server.CompiledPlugin } +func (r recordRegistry) RegisterAccountDirectoryScopeResolver(fn extension.AccountDirectoryScopeResolver) error { + registrar, ok := r.Registry.(extension.AccountDirectoryRegistrar) + if !ok { + return errors.New("extension: account directory registration unavailable") + } + return registrar.RegisterAccountDirectoryScopeResolver(fn) +} + func (r recordRegistry) AddCommand(spec json.RawMessage) error { if err := r.Registry.AddCommand(spec); err != nil { return err diff --git a/internal/app/hostcaps.go b/internal/app/hostcaps.go index 866a4f4..0fd237e 100644 --- a/internal/app/hostcaps.go +++ b/internal/app/hostcaps.go @@ -21,12 +21,13 @@ import ( // 同一实现同时服务 hostapi.Server 的 Search/Complete 闭包与进程内 // inprocHost(EXT-1 批注 1:复用现有 owner 作用域函数,不新造句柄)。 type hostcaps struct { - projects *projectLifecycleSlot - st *store.Store - pipe *rag.Pipeline - set *settings.Service - authz *projectAuthz // EXT-8 适配器槽(共享指针;首个注册者生效) - surfaces *surfacesvc.Service // EXT-17 Surface 能力槽(nil = 未装配,明确拒绝) + projects *projectLifecycleSlot + directory *accountDirectorySlot + st *store.Store + pipe *rag.Pipeline + set *settings.Service + authz *projectAuthz // EXT-8 适配器槽(共享指针;首个注册者生效) + surfaces *surfacesvc.Service // EXT-17 Surface 能力槽(nil = 未装配,明确拒绝) } // projectAuthz EXT-8 资源授权适配器槽:进程内扩展注册,组合根装配进 Server。 @@ -190,14 +191,23 @@ func (h inprocHost) IsAdmin() bool { if err != nil { return false } - return state.IsAdmin + return state.Enabled && state.IsAdmin } -// ListAccounts 实现 EXT-19 AccountLister(最小账号目录):仅授权管理读面用。 -// 有效态=账号存在且非停用(当前 users 表无停用位——must_change_password 不影响 -// 有效性;后续加停用位在此映射)。P3(2026-10-13):携带 Name 供同名区分。 -// 游标=owner 升序偏移。 +func (h inprocHost) legacySystemDirectoryAuthorized() bool { + if h.actor == "" { + return false + } + state, err := h.caps.st.GetUserAuthState(h.actor) + return err == nil && state.Enabled && state.IsAdmin +} + +// ListAccounts 保留 EXT-19 旧签名,仅供有受信分发主体的系统管理上下文; +// 项目和文档候选必须走带 locator 的 AccountDirectoryProvider。 func (h inprocHost) ListAccounts(cursor string, limit int) ([]extension.AccountEntry, string, error) { + if !h.legacySystemDirectoryAuthorized() { + return nil, "", store.ErrAccountForbidden + } if limit <= 0 || limit > 200 { limit = 50 } @@ -207,7 +217,7 @@ func (h inprocHost) ListAccounts(cursor string, limit int) ([]extension.AccountE } entries := make([]extension.AccountEntry, 0, len(users)) for _, u := range users { - entries = append(entries, extension.AccountEntry{Owner: u.Owner, Name: u.Name, Valid: true}) + entries = append(entries, extension.AccountEntry{Owner: u.Owner, Name: u.Name, Valid: u.Enabled}) } // 排序+游标(owner 升序;cursor 为上一页末 owner,空=首页)。 for i := 0; i < len(entries); i++ { @@ -238,6 +248,9 @@ func (h inprocHost) ListAccounts(cursor string, limit int) ([]extension.AccountE // 姓名子串检索(大小写不敏感;空 query = 首页候选)。结果按 owner 升序, // 有效态口径与 ListAccounts 一致。 func (h inprocHost) SearchAccounts(query string, limit int) ([]extension.AccountEntry, error) { + if !h.legacySystemDirectoryAuthorized() { + return nil, store.ErrAccountForbidden + } if limit <= 0 || limit > 200 { limit = 50 } @@ -253,7 +266,7 @@ func (h inprocHost) SearchAccounts(query string, limit int) ([]extension.Account !strings.Contains(strings.ToLower(u.Name), q) { continue } - entries = append(entries, extension.AccountEntry{Owner: u.Owner, Name: u.Name, Valid: true}) + entries = append(entries, extension.AccountEntry{Owner: u.Owner, Name: u.Name, Valid: u.Enabled}) } sort.Slice(entries, func(i, j int) bool { return entries[i].Owner < entries[j].Owner }) if len(entries) > limit { diff --git a/internal/store/account_directory.go b/internal/store/account_directory.go new file mode 100644 index 0000000..e426043 --- /dev/null +++ b/internal/store/account_directory.go @@ -0,0 +1,30 @@ +package store + +// DirectoryAccountRow is an internal candidate projection. It intentionally +// excludes contact details, notes, credentials and administrator privileges. +type DirectoryAccountRow struct { + UserID string + Owner string + Name string + StaffNo string + Enabled bool +} + +// ListDirectoryAccountRows only supplies data to the authorized directory +// service; this method itself is not an authorization boundary. +func (s *Store) ListDirectoryAccountRows() ([]DirectoryAccountRow, error) { + rows, err := s.db.Query(`SELECT id,owner,COALESCE(name,''),staff_no,enabled FROM users ORDER BY owner,id`) + if err != nil { + return nil, err + } + defer rows.Close() + results := []DirectoryAccountRow{} + for rows.Next() { + var row DirectoryAccountRow + if err := rows.Scan(&row.UserID, &row.Owner, &row.Name, &row.StaffNo, &row.Enabled); err != nil { + return nil, err + } + results = append(results, row) + } + return results, rows.Err() +} diff --git a/internal/store/auth.go b/internal/store/auth.go index ea62d12..c451f7e 100644 --- a/internal/store/auth.go +++ b/internal/store/auth.go @@ -167,6 +167,7 @@ const sessionTTL = 7 * 24 * time.Hour type AdminUser struct { Owner string `json:"owner"` Name string `json:"name"` + Enabled bool `json:"enabled"` IsAdmin bool `json:"isAdmin"` MustChangePassword bool `json:"mustChangePassword"` HasPassword bool `json:"hasPassword"` @@ -447,7 +448,7 @@ func (s *Store) DeleteUserSessions(owner, exceptToken string) { // ListUsersAdmin 管理员用户列表(按创建序)。 func (s *Store) ListUsersAdmin() ([]AdminUser, error) { - rows, err := s.db.Query(s.q(`SELECT owner,COALESCE(name,''),password_hash,must_change_password,is_admin,password_set_at,created_at FROM users ORDER BY created_at`)) + rows, err := s.db.Query(s.q(`SELECT owner,COALESCE(name,''),enabled,password_hash,must_change_password,is_admin,password_set_at,created_at FROM users ORDER BY created_at`)) if err != nil { return nil, err } @@ -456,7 +457,7 @@ func (s *Store) ListUsersAdmin() ([]AdminUser, error) { for rows.Next() { var u AdminUser var hash, psa, ca string - if err := rows.Scan(&u.Owner, &u.Name, &hash, &u.MustChangePassword, &u.IsAdmin, &psa, &ca); err != nil { + if err := rows.Scan(&u.Owner, &u.Name, &u.Enabled, &hash, &u.MustChangePassword, &u.IsAdmin, &psa, &ca); err != nil { return nil, err } u.HasPassword = hash != "" diff --git a/pkg/extension/account_directory.go b/pkg/extension/account_directory.go new file mode 100644 index 0000000..6be3a9d --- /dev/null +++ b/pkg/extension/account_directory.go @@ -0,0 +1,88 @@ +package extension + +import "context" + +const AccountDirectorySchemaVersion = 1 + +const ( + AccountDirectoryPurposeSystemUsers = "system.users" + AccountDirectoryPurposeProjectMembers = "project.member-candidate" + AccountDirectoryPurposeDocumentDuties = "document.duty-candidate" + AccountDirectoryModeExact = "exact" + AccountDirectoryModeFuzzy = "fuzzy" +) + +// AccountDirectoryQuery is a request for account candidates. Actor and field +// permissions are deliberately absent: the host supplies the authenticated +// dispatch actor and obtains current domain authorization from its registrar. +type AccountDirectoryQuery struct { + SchemaVersion int `json:"schemaVersion"` + Purpose string `json:"purpose"` + ProjectID string `json:"projectId,omitempty"` + DocumentID string `json:"documentId,omitempty"` + Mode string `json:"mode"` + Query string `json:"query"` + SearchFields []string `json:"searchFields,omitempty"` + Status string `json:"status,omitempty"` // enabled|disabled|all; domain candidates require enabled + Cursor string `json:"cursor,omitempty"` + Limit int `json:"limit,omitempty"` +} + +// AccountDirectoryEntry contains only identity, effective state and fields +// specifically authorized for the current purpose. Contact details never use +// this candidate DTO. +type AccountDirectoryEntry struct { + UserID string `json:"userId"` + Owner string `json:"owner"` + Name string `json:"name,omitempty"` + StaffNo string `json:"staffNo,omitempty"` + Valid bool `json:"valid"` +} + +type AccountDirectoryPage struct { + Entries []AccountDirectoryEntry `json:"entries"` + DirectoryVersion string `json:"directoryVersion"` + NextCursor string `json:"nextCursor,omitempty"` +} + +type AccountDirectoryLookup struct { + Entry AccountDirectoryEntry `json:"entry"` + DirectoryVersion string `json:"directoryVersion"` +} + +// AccountDirectoryProvider is optional on trusted dispatch Host handles. +// Missing capability is a denial, not permission to use EXT-19 as a fallback. +type AccountDirectoryProvider interface { + QueryAccountDirectory(context.Context, AccountDirectoryQuery) (AccountDirectoryPage, error) + LookupAccountDirectory(context.Context, AccountDirectoryQuery, string) (AccountDirectoryLookup, error) +} + +// AccountDirectoryScopeRequest is constructed by the Backend from the trusted +// dispatch actor, registered plugin and untrusted locator. A resolver must +// validate the locator and current member.manage / duty.assign authority. +type AccountDirectoryScopeRequest struct { + Actor string + PluginID string + Purpose string + ProjectID string + DocumentID string +} + +// AccountDirectoryScope is returned by the trusted in-process domain resolver. +// AllCandidates is explicit: a nil candidate list never means global access. +// Candidates are stable user IDs and are applied before search and pagination. +type AccountDirectoryScope struct { + AllowedFields []string + MatchFields []string + AllCandidates bool + CandidateIDs []string + ScopeVersion string +} + +type AccountDirectoryScopeResolver func(context.Context, AccountDirectoryScopeRequest) (AccountDirectoryScope, error) + +// AccountDirectoryRegistrar is optional on a compiled in-process Registry. +// Only the trusted RDM plugin may register the domain authorization resolver. +type AccountDirectoryRegistrar interface { + RegisterAccountDirectoryScopeResolver(AccountDirectoryScopeResolver) error +} diff --git a/pkg/extension/account_directory_test.go b/pkg/extension/account_directory_test.go new file mode 100644 index 0000000..9404747 --- /dev/null +++ b/pkg/extension/account_directory_test.go @@ -0,0 +1,13 @@ +package extension + +import "testing" + +func TestAccountDirectoryContractIsOptional(t *testing.T) { + var host Host + _, _ = host.(AccountDirectoryProvider) + var registry Registry + _, _ = registry.(AccountDirectoryRegistrar) + if AccountDirectorySchemaVersion < 1 { + t.Fatal("directory schema version must be explicit") + } +} diff --git a/pkg/extension/extension.go b/pkg/extension/extension.go index 2454e4e..bbfe1d1 100644 --- a/pkg/extension/extension.go +++ b/pkg/extension/extension.go @@ -34,7 +34,9 @@ import ( // 分区组件自身的命令面 fail-closed)。 // 2.12.0-experimental: optional ProjectHost/ProjectLifecycleRegistry and trusted // ResourceAuthzQuery.Source for per-document publication authorization. -const SurfaceVersion = "2.12.0-experimental" +// 2.13.0-experimental: optional AccountDirectoryProvider and trusted in-process +// AccountDirectoryRegistrar for scoped, current account candidate decisions. +const SurfaceVersion = "2.13.0-experimental" // ErrNotLinked 表示进程没有空白导入 link 包,Main 无法启动服务。 var ErrNotLinked = errors.New("extension: runner not linked; blank-import agent-system-backend/pkg/extension/link") @@ -97,17 +99,15 @@ type AccountEntry struct { Valid bool `json:"valid"` } -// AccountLister EXT-19 可选能力接口(实现由宿主注入):游标分页列举系统账号。 -// 未实现时下游回落现行行为(成员添加不做账号有效性校验);实现后仅用于 -// 授权管理读面——不授予任何写权。 +// AccountLister EXT-19 旧接口:仅有受信系统管理员分发上下文时可读取。 +// 新领域消费者必须使用 AccountDirectoryProvider,不得以本接口作为降级路径。 type AccountLister interface { ListAccounts(cursor string, limit int) (entries []AccountEntry, next string, err error) } // AccountSearcher EXT-19 扩展可选能力(P3,2026-10-13):按账号或姓名检索账号 -// 目录,供人员选择器的输入搜索。query 匹配账号子串或姓名子串(大小写不敏感; -// 空串=不限,返回首页候选)。仅授权管理读面使用;未实现时下游回落 -// AccountLister 分页浏览。 +// 目录。与 AccountLister 相同,它不含领域定位,只允许受信系统管理员上下文; +// 新领域消费者不得回落到此接口。 type AccountSearcher interface { SearchAccounts(query string, limit int) (entries []AccountEntry, err error) } diff --git a/pkg/extension/surface.txt b/pkg/extension/surface.txt index 7d88ba4..25f7d2b 100644 --- a/pkg/extension/surface.txt +++ b/pkg/extension/surface.txt @@ -1,3 +1,21 @@ +AccountDirectoryEntry +AccountDirectoryLookup +AccountDirectoryModeExact +AccountDirectoryModeFuzzy +AccountDirectoryPage +AccountDirectoryProvider +AccountDirectoryProvider.LookupAccountDirectory +AccountDirectoryProvider.QueryAccountDirectory +AccountDirectoryPurposeDocumentDuties +AccountDirectoryPurposeProjectMembers +AccountDirectoryPurposeSystemUsers +AccountDirectoryQuery +AccountDirectoryRegistrar +AccountDirectoryRegistrar.RegisterAccountDirectoryScopeResolver +AccountDirectorySchemaVersion +AccountDirectoryScope +AccountDirectoryScopeRequest +AccountDirectoryScopeResolver AccountEntry AccountLister AccountLister.ListAccounts From b9c0a344b6d6e71ac8bd2eaa6842eb6a053b92b4 Mon Sep 17 00:00:00 2001 From: weichen Date: Wed, 14 Oct 2026 04:20:48 +0000 Subject: [PATCH 05/17] Implement owner-confirmed account recovery and account controls Why: Administrator-issued password and login-key paths could take over target accounts, while old credentials and plugin handles ignored current account state. Evidence: SELF receipts /tmp/iam-u3-backend-all-replay.json, /tmp/iam-u3-backend-final-pg-core.json, /tmp/iam-u3-backend-build.json, and focused HTTP/concurrency receipts in /tmp/iam-u3-*.json. Impact: Adds trusted pending-account initialization and reissue, preexisting owner-session proof, proposal/rotation/grant transactions, server-side admin search, current Host guards, and legacy HTTP/CLI closure. Production cutover and downstream pins remain pending. Refs: IAM U3 r1 stages 4-5; Dev feature/iam-u3-account-directory-recovery --- cmd/agent-system/main.go | 110 +++-- cmd/agent-system/user_contract_test.go | 57 +++ cmd/storage-migrate/main.go | 14 +- internal/app/account_directory.go | 47 ++- internal/app/account_directory_test.go | 6 + internal/app/account_guard.go | 24 ++ internal/app/account_guard_test.go | 84 ++++ internal/app/app.go | 121 +++--- internal/app/hostcaps.go | 73 +++- internal/app/local_account_test.go | 48 +++ internal/app/project_lifecycle.go | 18 + internal/server/account_contract_test.go | 62 +++ internal/server/account_search.go | 205 +++++++++ internal/server/accounts.go | 98 ++--- internal/server/auth.go | 2 +- internal/server/auth_e2e_test.go | 26 +- internal/server/login_key_test.go | 8 +- internal/server/login_recovery_test.go | 156 ++----- internal/server/recovery.go | 120 ++++++ internal/server/recovery_contract_test.go | 161 +++++++ internal/server/server.go | 63 ++- internal/store/account_directory.go | 61 +++ internal/store/account_directory_test.go | 37 ++ internal/store/account_model.go | 4 +- internal/store/account_state.go | 3 + internal/store/account_state_test.go | 54 +++ internal/store/auth.go | 27 +- internal/store/login_keys.go | 409 ++++++++++++++---- internal/store/login_keys_test.go | 88 ++-- internal/store/migrate.go | 33 ++ internal/store/migrate_compat.go | 1 + internal/store/owner_initialization.go | 137 ++++++ internal/store/owner_initialization_test.go | 56 +++ internal/store/recovery_proposals.go | 438 ++++++++++++++++++++ internal/store/recovery_proposals_test.go | 220 ++++++++++ internal/store/store_test.go | 7 + pkg/extension/account_directory.go | 7 +- pkg/extension/surface.txt | 1 + 38 files changed, 2605 insertions(+), 481 deletions(-) create mode 100644 cmd/agent-system/user_contract_test.go create mode 100644 internal/app/account_guard.go create mode 100644 internal/app/account_guard_test.go create mode 100644 internal/app/local_account_test.go create mode 100644 internal/server/account_search.go create mode 100644 internal/server/recovery.go create mode 100644 internal/server/recovery_contract_test.go create mode 100644 internal/store/account_directory_test.go create mode 100644 internal/store/owner_initialization.go create mode 100644 internal/store/owner_initialization_test.go create mode 100644 internal/store/recovery_proposals.go create mode 100644 internal/store/recovery_proposals_test.go diff --git a/cmd/agent-system/main.go b/cmd/agent-system/main.go index f06c6f8..f6a5299 100644 --- a/cmd/agent-system/main.go +++ b/cmd/agent-system/main.go @@ -238,12 +238,11 @@ func runAPIKey(args []string) int { } } -// deliverCredential 凭据单次交付(KTD14):--out 时写 0600 JSON 文件,stdout 只报 -// 属主/用途/文件路径不含明文;缺省沿用 stdout 一次输出。文件含 expiresAt 便于交接。 +// deliverCredential writes a new 0600 file without following/overwriting an +// existing path. A secret never goes to stdout or a world-readable old file. func deliverCredential(outPath, kind, owner, plaintext string, expiresAt any) error { if outPath == "" { - fmt.Println(plaintext) - return nil + return fmt.Errorf("protected --out delivery path required") } payload := map[string]any{"kind": kind, "owner": owner, "secret": plaintext} if expiresAt != nil { @@ -253,18 +252,25 @@ func deliverCredential(outPath, kind, owner, plaintext string, expiresAt any) er if err != nil { return err } - if err := os.WriteFile(outPath, buf, 0o600); err != nil { + file, err := os.OpenFile(outPath, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600) + if err != nil { + return err + } + if _, err := file.Write(buf); err != nil { + _ = file.Close() + return err + } + if err := file.Close(); err != nil { return err } fmt.Fprintf(os.Stderr, "凭据已写入 %s(0600,仅此一次生成;勿入库勿入日志)\n", outPath) return nil } -// runUser:create / reset-password / list / issue-login-key / revoke-login-key / bootstrap-admin。 -// 默认 create 无初始密码并签发激活 Key;--generate-password 兼容旧随机密码路径。 +// runUser manages local owner initialization and metadata-only account actions. func runUser(args []string) int { if len(args) == 0 { - fmt.Fprintln(os.Stderr, "用法:agent-system user [flags]") + fmt.Fprintln(os.Stderr, "用法:agent-system user [flags]") return 2 } sub := args[0] @@ -272,20 +278,27 @@ func runUser(args []string) int { data := fs.String("data", "./data", "数据目录") dsn := fs.String("dsn", "", "PostgreSQL 档 DSN(空 = 嵌入式档)") owner := fs.String("owner", "", "用户名(owner)") + userID := fs.String("user-id", "", "待初始化账号稳定 ID") + expectedStateVersion := fs.Int64("expected-state-version", 0, "待初始化账号当前 stateVersion") + expectedGeneration := fs.Int64("expected-generation", -1, "待初始化账号当前 generation") name := fs.String("name", "", "显示名") - password := fs.String("password", "", "密码(create 空=激活 Key;加 --generate-password 则随机)") + password := fs.String("password", "", "旧建号密码参数(现已拒绝)") admin := fs.Bool("admin", false, "管理员") role := fs.String("role", "user", "角色 user|admin") openExisting := fs.Bool("open-existing", false, "只打开既有存储") - generatePassword := fs.Bool("generate-password", false, "create 时生成随机初始密码") - purpose := fs.String("purpose", "recover", "issue-login-key:activate|recover") - ttlMin := fs.Int("ttl-minutes", 30, "登录 Key 有效分钟(5–120)") - revokeAll := fs.Bool("revoke-all-credentials", false, "签发时撤销全部长期 API Key 与会话") + generatePassword := fs.Bool("generate-password", false, "旧随机初始密码参数(现已拒绝)") + fs.String("purpose", "recover", "旧 issue-login-key 参数(现已拒绝)") + fs.Int("ttl-minutes", 30, "旧 issue-login-key 参数(现已拒绝)") + fs.Bool("revoke-all-credentials", false, "旧 issue-login-key 参数(现已拒绝)") keyID := fs.String("id", "", "revoke-login-key 凭据编号") - out := fs.String("out", "", "凭据交付文件(0600 JSON,代替 stdout 明文;非交互推荐)") + out := fs.String("out", "", "凭据保护交付文件(0600 JSON,必填)") fs.Parse(args[1:]) switch sub { case "create": + if *out == "" { + fmt.Fprintln(os.Stderr, "user create: --out 必填(保护交付文件)") + return 2 + } if *owner == "" { fmt.Fprintln(os.Stderr, "user create: --owner 必填") return 2 @@ -308,32 +321,25 @@ func runUser(args []string) int { fmt.Fprintln(os.Stderr, "user create:", err) return 1 } - if *out == "" { - if *password == "" && !*generatePassword { - fmt.Fprintln(os.Stderr, "一次性激活 Key 仅此一次输出;兑换后设置密码。") - } else if *password == "" { - fmt.Fprintln(os.Stderr, "初始密码仅此一次输出;该用户首次登录须修改密码。") - } + return 0 + case "initialize-account": + if *userID == "" || *expectedStateVersion < 1 || *expectedGeneration < 0 || *out == "" { + fmt.Fprintln(os.Stderr, "user initialize-account: --user-id、--expected-state-version、--expected-generation、--out 必填") + return 2 + } + plain, targetOwner, err := app.InitializeAccount(*dsn, *data, *userID, *expectedStateVersion, *expectedGeneration) + if err != nil { + fmt.Fprintln(os.Stderr, "user initialize-account:", err) + return 1 + } + if err := deliverCredential(*out, "activation", targetOwner, plain, time.Now().Add(30*time.Minute).UTC()); err != nil { + fmt.Fprintln(os.Stderr, "user initialize-account:", err) + return 1 } return 0 case "reset-password": - if *owner == "" { - fmt.Fprintln(os.Stderr, "user reset-password: --owner 必填") - return 2 - } - pw, err := app.ResetUserPassword(*dsn, *data, *owner, *password, *openExisting) - if err != nil { - fmt.Fprintln(os.Stderr, "user reset-password:", err) - return 1 - } - if err := deliverCredential(*out, "password", *owner, pw, nil); err != nil { - fmt.Fprintln(os.Stderr, "user reset-password:", err) - return 1 - } - if *out == "" && *password == "" { - fmt.Fprintln(os.Stderr, "新密码仅此一次输出;该用户下次登录须修改密码(其余会话已吊销)。") - } - return 0 + fmt.Fprintln(os.Stderr, "RECOVERY_CONTRACT_REPLACED: administrators cannot set another user's password") + return 2 case "list": users, err := app.ListUserAccounts(*dsn, *data, *openExisting) if err != nil { @@ -344,27 +350,8 @@ func runUser(args []string) int { fmt.Println(string(out)) return 0 case "issue-login-key": - if *owner == "" { - fmt.Fprintln(os.Stderr, "user issue-login-key: --owner 必填") - return 2 - } - if *purpose != store.LoginKeyPurposeActivate && *purpose != store.LoginKeyPurposeRecover { - fmt.Fprintf(os.Stderr, "user issue-login-key: 无效 purpose %q\n", *purpose) - return 2 - } - plain, err := app.IssueUserLoginKey(*dsn, *data, *owner, *purpose, *openExisting, *revokeAll, time.Duration(*ttlMin)*time.Minute) - if err != nil { - fmt.Fprintln(os.Stderr, "user issue-login-key:", err) - return 1 - } - if err := deliverCredential(*out, "login-key", *owner, plain, time.Now().Add(time.Duration(*ttlMin)*time.Minute).UTC()); err != nil { - fmt.Fprintln(os.Stderr, "user issue-login-key:", err) - return 1 - } - if *out == "" { - fmt.Fprintln(os.Stderr, "一次性登录 Key 仅此一次输出。") - } - return 0 + fmt.Fprintln(os.Stderr, "RECOVERY_CONTRACT_REPLACED: use owner initialization or owner-confirmed recovery") + return 2 case "revoke-login-key": if *keyID == "" { fmt.Fprintln(os.Stderr, "user revoke-login-key: --id 必填") @@ -377,6 +364,10 @@ func runUser(args []string) int { fmt.Println("revoked") return 0 case "bootstrap-admin": + if *out == "" { + fmt.Fprintln(os.Stderr, "user bootstrap-admin: --out 必填(保护交付文件)") + return 2 + } if *owner == "" { fmt.Fprintln(os.Stderr, "user bootstrap-admin: --owner 必填") return 2 @@ -390,9 +381,6 @@ func runUser(args []string) int { fmt.Fprintln(os.Stderr, "user bootstrap-admin:", err) return 1 } - if *out == "" { - fmt.Fprintln(os.Stderr, "首位管理员激活 Key 仅此一次输出。") - } return 0 default: fmt.Fprintln(os.Stderr, "user: 未知子命令", sub) diff --git a/cmd/agent-system/user_contract_test.go b/cmd/agent-system/user_contract_test.go new file mode 100644 index 0000000..1abf705 --- /dev/null +++ b/cmd/agent-system/user_contract_test.go @@ -0,0 +1,57 @@ +package main + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestLegacyUserCredentialCommandsStopBeforeOpeningStorage(t *testing.T) { + data := t.TempDir() + for _, args := range [][]string{ + {"reset-password", "--data", data, "--owner", "victim", "--password", "attacker-password"}, + {"issue-login-key", "--data", data, "--owner", "victim", "--purpose", "recover", "--revoke-all-credentials"}, + } { + if code := runUser(args); code != 2 { + t.Fatalf("legacy command did not reject before storage: %v code=%d", args[0], code) + } + } + out := filepath.Join(data, "delivery.json") + if code := runUser([]string{"create", "--data", data, "--owner", "victim", "--password", "attacker-password", "--out", out}); code != 1 { + t.Fatalf("password-bearing local create accepted: %d", code) + } + if _, err := os.Stat(filepath.Join(data, "agent-system.db")); !os.IsNotExist(err) { + t.Fatalf("rejected commands opened/created a database: %v", err) + } + if _, err := os.Stat(out); !os.IsNotExist(err) { + t.Fatalf("rejected commands wrote a delivery file: %v", err) + } +} + +func TestCredentialDeliveryCreatesExclusive0600File(t *testing.T) { + dir := t.TempDir() + out := filepath.Join(dir, "credential.json") + if err := os.WriteFile(out, []byte("old"), 0o644); err != nil { + t.Fatal(err) + } + if err := deliverCredential(out, "activation", "alice", "secret", nil); err == nil { + t.Fatal("existing permissive file was overwritten") + } + bytes, err := os.ReadFile(out) + if err != nil || string(bytes) != "old" { + t.Fatalf("existing file changed: %q %v", bytes, err) + } + out = filepath.Join(dir, "new.json") + if err := deliverCredential(out, "activation", "alice", "secret", nil); err != nil { + t.Fatal(err) + } + info, err := os.Stat(out) + if err != nil || info.Mode().Perm() != 0o600 { + t.Fatalf("delivery mode: %v %v", info, err) + } + bytes, err = os.ReadFile(out) + if err != nil || !strings.Contains(string(bytes), `"secret": "secret"`) { + t.Fatalf("delivery payload: %v", err) + } +} diff --git a/cmd/storage-migrate/main.go b/cmd/storage-migrate/main.go index 7ee9cb2..496e5f7 100644 --- a/cmd/storage-migrate/main.go +++ b/cmd/storage-migrate/main.go @@ -281,7 +281,7 @@ func cmdBackup(args []string) error { // copyOrder:按外键依赖排序的业务表全集(projects 最先,引用方在后)。 // admission_leases 是运行态执行许可(迁移无意义),明确排除。 var copyOrder = []string{ - "projects", "users", "profiles", "settings", "plugins", + "projects", "users", "account_directory_revision", "profiles", "settings", "plugins", "conversations", "runs", "messages", "events", "artifacts", "hitl_cards", "plans", "todos", "knowledge", "knowledge_vectors", "activity_cards", "project_event_shares", "command_audit", "prompt_modules", @@ -297,6 +297,7 @@ var copyOrder = []string{ "agent_system_identity", "account_events", "redeem_attempts", + "recovery_proposals", "recovery_proposal_proofs", } const controlDDL = ` @@ -707,6 +708,14 @@ func copyRows(db *sql.DB, src *sql.DB, table string, cols []string) error { return err } defer tx.Rollback() + // The destination migrator creates a fresh singleton directory revision at + // schema startup. Replace it with the source's opaque revision so existing + // cursors/visible-change history remain tied to the migrated account data. + if table == "account_directory_revision" { + if _, err := tx.Exec(`DELETE FROM account_directory_revision`); err != nil { + return err + } + } stmt, err := tx.Prepare(fmt.Sprintf(`INSERT INTO "%s" (%s) VALUES (%s) ON CONFLICT DO NOTHING`, table, colList, placeholders)) if err != nil { return err @@ -784,7 +793,8 @@ func advanceSequences(db *sql.DB, table string) error { // checkOwners:业务行引用的 owner 必须存在于 users(单租户默认 ”/'local' // 除外)。未知 owner 阻塞迁移(U4/R7:不静默带过未知归属)。 func checkOwners(db *sql.DB, src *sql.DB) error { - ownerTables := []string{"projects", "conversations", "runs", "artifacts", "knowledge", "hitl_cards", "profiles", "activity_cards", "project_event_shares", "command_audit"} + ownerTables := []string{"projects", "conversations", "runs", "artifacts", "knowledge", "hitl_cards", "profiles", "activity_cards", + "project_event_shares", "command_audit", "sessions", "api_keys", "login_keys", "account_events", "recovery_proposals"} for _, t := range ownerTables { if !sqliteHasTable(src, t) { continue diff --git a/internal/app/account_directory.go b/internal/app/account_directory.go index faf0b48..e8beb24 100644 --- a/internal/app/account_directory.go +++ b/internal/app/account_directory.go @@ -51,6 +51,7 @@ type directoryPolicy struct { allowed map[string]bool match []string candidates map[string]bool + owners map[string]bool all bool status string version string @@ -68,6 +69,9 @@ func knownDirectoryField(field string) bool { func (h inprocHost) directoryPolicy(ctx context.Context, q extension.AccountDirectoryQuery) (directoryPolicy, error) { var p directoryPolicy + if err := h.requireEffective(); err != nil { + return p, err + } if h.actor == "" || h.caps.st == nil || h.caps.directory == nil || q.SchemaVersion != extension.AccountDirectorySchemaVersion { return p, store.ErrAccountForbidden } @@ -153,12 +157,19 @@ func (h inprocHost) directoryPolicy(ctx context.Context, q extension.AccountDire } sort.Strings(p.match) p.candidates = make(map[string]bool, len(scope.CandidateIDs)) + p.owners = make(map[string]bool, len(scope.CandidateOwners)) for _, id := range scope.CandidateIDs { if id == "" { return p, store.ErrAccountForbidden } p.candidates[id] = true } + for _, owner := range scope.CandidateOwners { + if owner == "" { + return p, store.ErrAccountForbidden + } + p.owners[owner] = true + } p.all = scope.AllCandidates if q.Purpose == extension.AccountDirectoryPurposeSystemUsers { p.all = true @@ -172,13 +183,18 @@ func (h inprocHost) directoryPolicy(ctx context.Context, q extension.AccountDire ids = append(ids, id) } sort.Strings(ids) + owners := make([]string, 0, len(p.owners)) + for owner := range p.owners { + owners = append(owners, owner) + } + sort.Strings(owners) fingerprint := struct { Actor, Plugin, Purpose, Project, Document, Mode, Query, Status, Public, Staff, Scope string - Match, IDs []string + Match, IDs, Owners []string All bool }{Actor: h.actor, Plugin: h.pluginID, Purpose: q.Purpose, Project: q.ProjectID, Document: q.DocumentID, Mode: q.Mode, Query: strings.TrimSpace(q.Query), Status: p.status, Public: revision.Public, - Scope: scope.ScopeVersion, Match: p.match, IDs: ids, All: p.all} + Scope: scope.ScopeVersion, Match: p.match, IDs: ids, Owners: owners, All: p.all} if p.allowed["staffNo"] { fingerprint.Staff = revision.StaffNo } @@ -254,7 +270,7 @@ func directoryMatches(row store.DirectoryAccountRow, fields []string, mode, quer } func directoryRowAllowed(row store.DirectoryAccountRow, p directoryPolicy) bool { - if !p.all && !p.candidates[row.UserID] { + if !p.all && !p.candidates[row.UserID] && !p.owners[row.Owner] { return false } return p.status == "all" || (p.status == "enabled" && row.Enabled) || (p.status == "disabled" && !row.Enabled) @@ -335,7 +351,7 @@ func (h inprocHost) LookupAccountDirectory(ctx context.Context, q extension.Acco if err != nil { return extension.AccountDirectoryLookup{}, err } - if !p.all && !p.candidates[userID] { + if !p.all && len(p.owners) == 0 && !p.candidates[userID] { return extension.AccountDirectoryLookup{}, store.ErrNotFound } account, err := h.caps.st.GetAccount(userID) @@ -350,4 +366,27 @@ func (h inprocHost) LookupAccountDirectory(ctx context.Context, q extension.Acco return extension.AccountDirectoryLookup{Entry: directoryEntry(row, p), DirectoryVersion: p.version}, nil } +func (h inprocHost) LookupAccountDirectoryOwner(ctx context.Context, q extension.AccountDirectoryQuery, owner string) (extension.AccountDirectoryLookup, error) { + if owner == "" || q.Cursor != "" { + return extension.AccountDirectoryLookup{}, store.ErrInvalidAccountProfile + } + p, err := h.directoryPolicy(ctx, q) + if err != nil { + return extension.AccountDirectoryLookup{}, err + } + if !p.all && len(p.candidates) == 0 && !p.owners[owner] { + return extension.AccountDirectoryLookup{}, store.ErrNotFound + } + account, err := h.caps.st.GetAccountByOwner(owner) + if err != nil { + return extension.AccountDirectoryLookup{}, err + } + row := store.DirectoryAccountRow{UserID: account.ID, Owner: account.Owner, Name: account.Profile.Name, + StaffNo: account.Profile.StaffNo, Enabled: account.Enabled} + if !directoryRowAllowed(row, p) { + return extension.AccountDirectoryLookup{}, store.ErrNotFound + } + return extension.AccountDirectoryLookup{Entry: directoryEntry(row, p), DirectoryVersion: p.version}, nil +} + var _ extension.AccountDirectoryProvider = inprocHost{} diff --git a/internal/app/account_directory_test.go b/internal/app/account_directory_test.go index 65588d0..c1b35ce 100644 --- a/internal/app/account_directory_test.go +++ b/internal/app/account_directory_test.go @@ -106,6 +106,12 @@ func TestAccountDirectoryAppliesDomainScopeBeforeSearchAndCursor(t *testing.T) { } query.SearchFields = nil query.Query = "同名" + if hit, err := h.LookupAccountDirectoryOwner(context.Background(), query, "alice"); err != nil || hit.Entry.UserID != alice.ID { + t.Fatalf("explicit owner lookup failed to resolve stable ID: %+v %v", hit, err) + } + if _, err := h.LookupAccountDirectoryOwner(context.Background(), query, "outsider"); !errors.Is(err, store.ErrNotFound) { + t.Fatalf("owner lookup leaked outsider: %v", err) + } if _, err := h.LookupAccountDirectory(context.Background(), query, "not-in-scope"); !errors.Is(err, store.ErrNotFound) { t.Fatalf("exact lookup leaked outsider: %v", err) } diff --git a/internal/app/account_guard.go b/internal/app/account_guard.go new file mode 100644 index 0000000..46e2632 --- /dev/null +++ b/internal/app/account_guard.go @@ -0,0 +1,24 @@ +package app + +import ( + "context" + "errors" + + "agent-system-backend/internal/store" +) + +// accountEffectiveAuthorization is shared by runmgr and in-process Host +// capabilities. The implicit local singleton is the only auth-off missing-row +// exception; an existing disabled local account remains denied. +func accountEffectiveAuthorization(st *store.Store, authOn bool) func(context.Context, string) (bool, error) { + return func(_ context.Context, owner string) (bool, error) { + account, err := st.GetAccountByOwner(owner) + if errors.Is(err, store.ErrNotFound) && !authOn && owner == store.OwnerDefault { + return true, nil + } + if err != nil { + return false, err + } + return account.Enabled, nil + } +} diff --git a/internal/app/account_guard_test.go b/internal/app/account_guard_test.go new file mode 100644 index 0000000..2f78e35 --- /dev/null +++ b/internal/app/account_guard_test.go @@ -0,0 +1,84 @@ +package app + +import ( + "context" + "errors" + "testing" + + "agent-system-backend/internal/store" +) + +func TestInprocHostRechecksEffectiveAccountOnEveryCapability(t *testing.T) { + st, err := store.OpenSQLite(t.TempDir() + "/host.db") + if err != nil { + t.Fatal(err) + } + defer st.Close() + if err := st.CreateAccount("alice", "", "", false); err != nil { + t.Fatal(err) + } + check := accountEffectiveAuthorization(st, true) + h := inprocHost{caps: hostcaps{st: st, accountGuard: func(owner string) error { + valid, err := check(context.Background(), owner) + if err != nil { + return err + } + if !valid { + return store.ErrAccountForbidden + } + return nil + }}, owner: "alice", actor: "alice", pluginID: "rdm"} + if err := h.KVPut("before", "allowed"); err != nil { + t.Fatal(err) + } + if _, err := st.DB().Exec(`UPDATE users SET enabled=FALSE WHERE owner=?`, "alice"); err != nil { + t.Fatal(err) + } + if err := h.KVPut("after", "denied"); !errors.Is(err, store.ErrAccountForbidden) { + t.Fatalf("disabled host wrote KV: %v", err) + } + if _, _, err := h.KVGet("before"); !errors.Is(err, store.ErrAccountForbidden) { + t.Fatalf("disabled host read KV: %v", err) + } + if _, err := h.TaskCreate("test", "denied", ""); !errors.Is(err, store.ErrAccountForbidden) { + t.Fatalf("disabled host created task: %v", err) + } + if _, err := h.ProjectNameAvailable("denied", ""); !errors.Is(err, store.ErrAccountForbidden) { + t.Fatalf("disabled host used project capability: %v", err) + } + if err := h.Audit("action", "target", ""); !errors.Is(err, store.ErrAccountForbidden) { + t.Fatalf("disabled host wrote audit: %v", err) + } + if _, err := h.Complete(context.Background(), "denied"); !errors.Is(err, store.ErrAccountForbidden) { + t.Fatalf("disabled host reached model completion: %v", err) + } + if _, err := h.KnowledgeSearch(context.Background(), "", "denied", 1); !errors.Is(err, store.ErrAccountForbidden) { + t.Fatalf("disabled host read knowledge: %v", err) + } +} + +func TestImplicitLocalAccountExceptionIsAuthOffOnly(t *testing.T) { + st, err := store.OpenSQLite(t.TempDir() + "/local.db") + if err != nil { + t.Fatal(err) + } + defer st.Close() + allowed, err := accountEffectiveAuthorization(st, false)(context.Background(), store.OwnerDefault) + if err != nil || !allowed { + t.Fatalf("implicit local denied: %v %v", allowed, err) + } + allowed, err = accountEffectiveAuthorization(st, true)(context.Background(), store.OwnerDefault) + if !errors.Is(err, store.ErrNotFound) || allowed { + t.Fatalf("auth-on missing account allowed: %v %v", allowed, err) + } + if err := st.EnsureUser(store.OwnerDefault, ""); err != nil { + t.Fatal(err) + } + if _, err := st.DB().Exec(`UPDATE users SET enabled=FALSE WHERE owner=?`, store.OwnerDefault); err != nil { + t.Fatal(err) + } + allowed, err = accountEffectiveAuthorization(st, false)(context.Background(), store.OwnerDefault) + if err != nil || allowed { + t.Fatalf("disabled existing local account allowed: %v %v", allowed, err) + } +} diff --git a/internal/app/app.go b/internal/app/app.go index 777f837..8f27dc8 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -7,7 +7,6 @@ import ( "crypto/rand" "encoding/hex" "encoding/json" - "errors" "fmt" "log" "net" @@ -452,16 +451,8 @@ func Run(opt Options) error { mockRunner := &runtime.MockRunner{} mgr := runmgr.New(st, b, reg, profSvc, setSvc, convRunner, subRunner, mockRunner, &runtime.MockSubagent{}) authOn := resolved.AuthEnabled() - mgr.SetAccountEffectiveAuthorization(func(_ context.Context, owner string) (bool, error) { - account, err := st.GetAccountByOwner(owner) - if errors.Is(err, store.ErrNotFound) && !authOn && owner == store.OwnerDefault { - return true, nil // legacy implicit local singleton has no users row - } - if err != nil { - return false, err - } - return account.Enabled, nil - }) + effectiveAccount := accountEffectiveAuthorization(st, authOn) + mgr.SetAccountEffectiveAuthorization(effectiveAccount) instanceID := opt.InstanceID if instanceID == "" { host, err := os.Hostname() @@ -613,7 +604,17 @@ func Run(opt Options) error { if err != nil { return err } - caps := hostcaps{projects: projectSlot, directory: directorySlot, st: st, pipe: pipe, set: setSvc, authz: authzSlot, surfaces: surfaceSvc} + caps := hostcaps{projects: projectSlot, directory: directorySlot, st: st, pipe: pipe, set: setSvc, authz: authzSlot, surfaces: surfaceSvc, + accountGuard: func(owner string) error { + valid, err := effectiveAccount(context.Background(), owner) + if err != nil { + return err + } + if !valid { + return store.ErrAccountForbidden + } + return nil + }} // EXT-13:回合内检索(knowledge_search / @knowledge 注入)与 HTTP/Host 检索面 // 同一适配器授权;槽为共享指针,扩展在 applyCompiled 期间注册后即生效。 mgr.SetSharedKnowledgeAllow(func(owner string, projectID core.ID) bool { @@ -649,6 +650,7 @@ func Run(opt Options) error { } } hostSrv.Dispatch = func(ctx context.Context, pluginID, owner, commandID string, params map[string]any) error { + if err := caps.accountGuard(owner); err != nil { return err } resp := bus.Dispatch(ctx, cmdbus.Request{ID: commandID, Params: params, Entrance: "api", Actor: pluginID, Owner: owner}) if resp.Receipt.State == "rejected" || resp.Receipt.State == "unknown" { return fmt.Errorf("%s", resp.Receipt.Reason) @@ -753,31 +755,38 @@ func Run(opt Options) error { return nil } -// CreateUserAccount CLI 建号。 -// password 空且 generatePassword=false:无初始密码,同事务签发 activate Key,返回明文 Key。 -// generatePassword=true 或显式 password:兼容旧路径(随机/给定密码 + must_change)。 -func CreateUserAccount(storageDSN, dataDir, owner, name, password string, isAdmin, openExisting, generatePassword bool) (string, error) { - var out string - err := withStoreMode(storageDSN, dataDir, openExisting, func(st *store.Store) error { - if password != "" || generatePassword { - pw := password - if pw == "" { - pw = server.RandomPassword() - } - hash, err := store.HashPassword(pw) - if err != nil { - return err - } - if err := st.CreateAccount(owner, name, hash, isAdmin); err != nil { - return err - } - out = pw - return nil +func assertLocalAccountOwner(st *store.Store, storageDSN, dataDir string) error { + if storageDSN != "" { + var isOwner bool + err := st.DB().QueryRow(`SELECT current_user = pg_get_userbyid(c.relowner) FROM pg_class c WHERE c.oid='users'::regclass`).Scan(&isOwner) + if err != nil || !isOwner { + return store.ErrAccountForbidden } - if err := st.CreateAccount(owner, name, "", isAdmin); err != nil { + return nil + } + info, err := os.Stat(filepath.Join(dataDir, "agent-system.db")) + if err != nil { + return err + } + stat, ok := info.Sys().(*syscall.Stat_t) + if !ok || (os.Geteuid() != 0 && stat.Uid != uint32(os.Geteuid())) { + return store.ErrAccountForbidden + } + return nil +} + +// CreateUserAccount is the local owner-only atomic pending-account path. +// Password-bearing creation is rejected before opening or changing storage. +func CreateUserAccount(storageDSN, dataDir, owner, name, password string, isAdmin, openExisting, generatePassword bool) (string, error) { + if password != "" || generatePassword { + return "", fmt.Errorf("RECOVERY_CONTRACT_REPLACED: local creation uses trusted activation only") + } + var out string + err := withStoreMode(storageDSN, dataDir, true, func(st *store.Store) error { + if err := assertLocalAccountOwner(st, storageDSN, dataDir); err != nil { return err } - plain, _, err := st.IssueLoginKey(owner, store.LoginKeyPurposeActivate, "cli", 0) + _, plain, err := st.CreateLocalPendingAccount(owner, name, isAdmin) if err != nil { return err } @@ -789,19 +798,26 @@ func CreateUserAccount(storageDSN, dataDir, owner, name, password string, isAdmi // IssueUserLoginKey CLI 签发激活/恢复 Key。 func IssueUserLoginKey(storageDSN, dataDir, owner, purpose string, openExisting, revokeAllCreds bool, ttl time.Duration) (string, error) { - var plain string - err := withStoreMode(storageDSN, dataDir, openExisting, func(st *store.Store) error { - if revokeAllCreds { - if err := st.RevokeAllAPIKeys(owner); err != nil { - return err - } - st.DeleteUserSessions(owner, "") + return "", fmt.Errorf("RECOVERY_CONTRACT_REPLACED: use owner initialization or owner-confirmed recovery") +} + +// InitializeAccount resumes a specific pending account. The database/file owner +// and the local OS UID are obtained from the live environment, not CLI flags. +func InitializeAccount(storageDSN, dataDir, userID string, expectedStateVersion, expectedGeneration int64) (string, string, error) { + var plain, owner string + err := withStoreMode(storageDSN, dataDir, true, func(st *store.Store) error { + if err := assertLocalAccountOwner(st, storageDSN, dataDir); err != nil { + return err } - p, _, err := st.IssueLoginKey(owner, purpose, "cli", ttl) - plain = p + account, err := st.GetAccount(userID) + if err != nil { + return err + } + plain, err = st.InitializePendingAccount(userID, expectedStateVersion, expectedGeneration) + owner = account.Owner return err }) - return plain, err + return plain, owner, err } // RevokeUserLoginKey CLI 撤销未使用登录 Key。 @@ -816,6 +832,9 @@ func RevokeUserLoginKey(storageDSN, dataDir, id string, openExisting bool) error func BootstrapAdmin(storageDSN, dataDir, owner string, openExisting bool) (string, error) { var plain string err := withStoreMode(storageDSN, dataDir, openExisting, func(st *store.Store) error { + if err := assertLocalAccountOwner(st, storageDSN, dataDir); err != nil { + return err + } p, err := st.BootstrapFirstAdmin(owner, owner, "cli-bootstrap") plain = p return err @@ -826,19 +845,7 @@ func BootstrapAdmin(storageDSN, dataDir, owner string, openExisting bool) (strin // ResetUserPassword CLI 重置密码:单事务写 must_change=1、bump 认证版本、 // 作废未用登录 Key、吊销全部会话并审计。 func ResetUserPassword(storageDSN, dataDir, owner, password string, openExisting bool) (string, error) { - if password == "" { - password = server.RandomPassword() - } - hash, err := store.HashPassword(password) - if err != nil { - return "", err - } - if err := withStoreMode(storageDSN, dataDir, openExisting, func(st *store.Store) error { - return st.AdminResetUserPassword(owner, hash, "cli", "cli") - }); err != nil { - return "", err - } - return password, nil + return "", fmt.Errorf("RECOVERY_CONTRACT_REPLACED: administrators cannot set another user's password") } // ListUserAccounts CLI 用户列表。 diff --git a/internal/app/hostcaps.go b/internal/app/hostcaps.go index 0fd237e..8d73d88 100644 --- a/internal/app/hostcaps.go +++ b/internal/app/hostcaps.go @@ -21,13 +21,14 @@ import ( // 同一实现同时服务 hostapi.Server 的 Search/Complete 闭包与进程内 // inprocHost(EXT-1 批注 1:复用现有 owner 作用域函数,不新造句柄)。 type hostcaps struct { - projects *projectLifecycleSlot - directory *accountDirectorySlot - st *store.Store - pipe *rag.Pipeline - set *settings.Service - authz *projectAuthz // EXT-8 适配器槽(共享指针;首个注册者生效) - surfaces *surfacesvc.Service // EXT-17 Surface 能力槽(nil = 未装配,明确拒绝) + projects *projectLifecycleSlot + directory *accountDirectorySlot + accountGuard func(string) error + st *store.Store + pipe *rag.Pipeline + set *settings.Service + authz *projectAuthz // EXT-8 适配器槽(共享指针;首个注册者生效) + surfaces *surfacesvc.Service // EXT-17 Surface 能力槽(nil = 未装配,明确拒绝) } // projectAuthz EXT-8 资源授权适配器槽:进程内扩展注册,组合根装配进 Server。 @@ -110,6 +111,9 @@ type sharedRetriever interface { } func (c hostcaps) search(ctx context.Context, owner string, projectID core.ID, text string, topK int) ([]core.RetrievalHit, error) { + if c.accountGuard != nil { + if err := c.accountGuard(owner); err != nil { return nil, err } + } q := core.RetrievalQuery{ProjectID: projectID, Text: text, TopK: topK} if c.projects != nil && projectID != "" { id, err := c.projects.resolve(ctx, c, owner, projectID) @@ -144,6 +148,9 @@ func (c hostcaps) searchDefault(ctx context.Context, owner, text string) ([]core // complete 经基座 LLM 路由补全(owner 的模型配置生效;未配置报错)。 func (c hostcaps) complete(ctx context.Context, owner, prompt string) (string, error) { + if c.accountGuard != nil { + if err := c.accountGuard(owner); err != nil { return "", err } + } route := runtime.ResolveChatModel(core.AgentProfile{}, c.set.Effective(owner, "")) if route.Model == "" || route.Base == "" { return "", fmt.Errorf("llm route not configured") @@ -177,6 +184,13 @@ type inprocHost struct { actor string // EXT-18:分发主体(空=非 dispatch 上下文) } +func (h inprocHost) requireEffective() error { + if h.caps.accountGuard != nil { + return h.caps.accountGuard(h.owner) + } + return nil +} + func (h inprocHost) PluginID() string { return h.pluginID } func (h inprocHost) Owner() string { return h.owner } @@ -195,6 +209,9 @@ func (h inprocHost) IsAdmin() bool { } func (h inprocHost) legacySystemDirectoryAuthorized() bool { + if h.requireEffective() != nil { + return false + } if h.actor == "" { return false } @@ -276,14 +293,23 @@ func (h inprocHost) SearchAccounts(query string, limit int) ([]extension.Account } func (h inprocHost) KVPut(key, value string) error { + if err := h.requireEffective(); err != nil { + return err + } return h.caps.st.PutPluginKV(h.pluginID, h.owner, key, value) } func (h inprocHost) KVGet(key string) (string, bool, error) { + if err := h.requireEffective(); err != nil { + return "", false, err + } return h.caps.st.GetPluginKV(h.pluginID, h.owner, key) } func (h inprocHost) Audit(action, target, evidence string) error { + if err := h.requireEffective(); err != nil { + return err + } return h.caps.st.AppendAuditEvent(store.AuditEvent{ Owner: h.owner, Actor: "plugin:" + h.pluginID, Action: action, Target: target, Evidence: evidence, @@ -291,10 +317,16 @@ func (h inprocHost) Audit(action, target, evidence string) error { } func (h inprocHost) Complete(ctx context.Context, prompt string) (string, error) { + if err := h.requireEffective(); err != nil { + return "", err + } return h.caps.complete(ctx, h.owner, prompt) } func (h inprocHost) KnowledgeSearch(ctx context.Context, projectID core.ID, text string, topK int) ([]core.RetrievalHit, error) { + if err := h.requireEffective(); err != nil { + return nil, err + } return h.caps.search(ctx, h.owner, projectID, text, topK) } @@ -316,6 +348,9 @@ func (h inprocHost) knowledgeSource(source string) error { // "plugin:";embed=false 只写全文行,embed=true 走 IndexChunks 建向量 // (embedding 失败降级纯全文,不阻断写入)。 func (h inprocHost) KnowledgeWrite(ctx context.Context, docs []core.KnowledgeDoc, embed bool) error { + if err := h.requireEffective(); err != nil { + return err + } if len(docs) == 0 { return nil } @@ -339,6 +374,9 @@ func (h inprocHost) KnowledgeWrite(ctx context.Context, docs []core.KnowledgeDoc // A registered lifecycle scopes replacement to the exact owner/project/plugin; // ordinary unregistered Hosts retain the legacy cross-source ref semantics. func (h inprocHost) KnowledgePublish(ctx context.Context, docs []core.KnowledgeDoc, embed bool) error { + if err := h.requireEffective(); err != nil { + return err + } if len(docs) == 0 { return nil } @@ -375,6 +413,9 @@ func (h inprocHost) KnowledgePublish(ctx context.Context, docs []core.KnowledgeD // KnowledgeDelete 撤销本插件来源(EXT-9):前缀强制 plugin:<自身ID>——manual // 或他人来源拒绝(fail-closed);删行+向量,幂等。 func (h inprocHost) KnowledgeDelete(ctx context.Context, sourcePrefix string) error { + if err := h.requireEffective(); err != nil { + return err + } if sourcePrefix != "plugin:"+h.pluginID { return fmt.Errorf("ext-9: plugins may only delete their own source prefix, want %q", "plugin:"+h.pluginID) } @@ -384,6 +425,9 @@ func (h inprocHost) KnowledgeDelete(ctx context.Context, sourcePrefix string) er // TaskCreate/TaskUpdate 后台任务台账:基座只记进度/状态,执行体由下游自跑(H5)。 func (h inprocHost) TaskCreate(kind, title, conversationID string) (string, error) { + if err := h.requireEffective(); err != nil { + return "", err + } t, err := h.caps.st.CreateBackgroundTask(h.owner, kind, title, conversationID) if err != nil { return "", err @@ -392,27 +436,42 @@ func (h inprocHost) TaskCreate(kind, title, conversationID string) (string, erro } func (h inprocHost) TaskUpdate(taskID, status string, progress int, errText string) error { + if err := h.requireEffective(); err != nil { + return err + } return h.caps.st.UpdateBackgroundTask(h.owner, taskID, status, progress, errText) } // ArtifactGet/ArtifactContent 工件只读(R1-3 ingest 读原件;写入属完整集)。 func (h inprocHost) ArtifactGet(artifactID string) (core.Artifact, error) { + if err := h.requireEffective(); err != nil { + return core.Artifact{}, err + } return h.caps.st.GetArtifact(h.owner, artifactID) } func (h inprocHost) ArtifactContent(artifactID string) ([]byte, error) { + if err := h.requireEffective(); err != nil { + return nil, err + } return h.caps.st.ArtifactContent(h.owner, artifactID) } // ParseDocument 内置文档解析(EXT-5):委托 hostcaps.parse;纯计算不经存储, // owner 作用域由调用方经 ArtifactContent 取字节时保证。 func (h inprocHost) ParseDocument(ctx context.Context, filename, mime string, content []byte) (core.ParsedDocument, error) { + if err := h.requireEffective(); err != nil { + return core.ParsedDocument{}, err + } return h.caps.parse(ctx, filename, mime, content) } // ---- EXT-17 Surface 能力(可选):nil 槽=能力缺失,明确拒绝不降级 ---- func (h inprocHost) surface() (*surfacesvc.Service, error) { + if err := h.requireEffective(); err != nil { + return nil, err + } if h.caps.surfaces == nil { return nil, surfacesvc.ErrUnavailable } diff --git a/internal/app/local_account_test.go b/internal/app/local_account_test.go new file mode 100644 index 0000000..716c7c8 --- /dev/null +++ b/internal/app/local_account_test.go @@ -0,0 +1,48 @@ +package app + +import ( + "path/filepath" + "testing" + + "agent-system-backend/internal/store" +) + +func TestLocalOwnerAccountCreationAndReissuePreserveIdentity(t *testing.T) { + dir := t.TempDir() + bootstrap, err := BootstrapAdmin("", dir, "root", false) + if err != nil || bootstrap == "" { + t.Fatalf("bootstrap: %v", err) + } + first, err := CreateUserAccount("", dir, "alice", "", "", false, true, false) + if err != nil || first == "" { + t.Fatalf("local pending create: %v", err) + } + st, err := store.OpenExistingSQLite(filepath.Join(dir, "agent-system.db")) + if err != nil { + t.Fatal(err) + } + account, err := st.GetAccountByOwner("alice") + if err != nil { + t.Fatal(err) + } + st.Close() + second, owner, err := InitializeAccount("", dir, account.ID, account.StateVersion, account.InitializationGeneration) + if err != nil || owner != "alice" || second == first { + t.Fatalf("owner reissue: owner=%s err=%v", owner, err) + } + st, err = store.OpenExistingSQLite(filepath.Join(dir, "agent-system.db")) + if err != nil { + t.Fatal(err) + } + defer st.Close() + current, err := st.GetAccount(account.ID) + if err != nil || current.Owner != account.Owner || current.ID != account.ID || current.InitializationGeneration != account.InitializationGeneration+1 { + t.Fatalf("reissue changed account identity: %+v %v", current, err) + } + if _, _, err := st.RedeemLoginKey(first); err == nil { + t.Fatal("old activation grant survived reissue") + } + if _, _, err := st.RedeemLoginKey(second); err != nil { + t.Fatalf("new activation grant failed: %v", err) + } +} diff --git a/internal/app/project_lifecycle.go b/internal/app/project_lifecycle.go index 78ccf95..9055970 100644 --- a/internal/app/project_lifecycle.go +++ b/internal/app/project_lifecycle.go @@ -84,6 +84,9 @@ func (slot *projectLifecycleSlot) resolve(ctx context.Context, caps hostcaps, ow var _ extension.ProjectHost = inprocHost{} func (h inprocHost) OwnedProjects() ([]core.Project, error) { + if err := h.requireEffective(); err != nil { + return nil, err + } ps, err := h.caps.st.ListProjects(h.owner) for i := range ps { ps[i].Owner = h.owner @@ -91,9 +94,15 @@ func (h inprocHost) OwnedProjects() ([]core.Project, error) { return ps, err } func (h inprocHost) OwnedProject(id string) (core.Project, error) { + if err := h.requireEffective(); err != nil { + return core.Project{}, err + } return h.caps.st.GetProject(h.owner, id) } func (h inprocHost) ProjectMetadata(id string) (core.Project, error) { + if err := h.requireEffective(); err != nil { + return core.Project{}, err + } p, err := h.caps.st.ProjectMetadata(id) if err != nil { return core.Project{}, err @@ -114,11 +123,20 @@ func (h inprocHost) ProjectMetadata(id string) (core.Project, error) { return p, nil } func (h inprocHost) EnsureProject(id, name string) (core.Project, error) { + if err := h.requireEffective(); err != nil { + return core.Project{}, err + } return h.caps.st.EnsureProject(h.owner, id, name) } func (h inprocHost) RenameOwnedProject(id, name string) (core.Project, error) { + if err := h.requireEffective(); err != nil { + return core.Project{}, err + } return h.caps.st.RenameOwnedProject(h.owner, id, name) } func (h inprocHost) ProjectNameAvailable(name, exceptID string) (bool, error) { + if err := h.requireEffective(); err != nil { + return false, err + } return h.caps.st.ProjectNameAvailable(name, exceptID) } diff --git a/internal/server/account_contract_test.go b/internal/server/account_contract_test.go index df0e76f..beaf59f 100644 --- a/internal/server/account_contract_test.go +++ b/internal/server/account_contract_test.go @@ -3,11 +3,73 @@ package server import ( "encoding/json" "net/http" + "net/url" "testing" "agent-system-backend/internal/store" ) +func TestAdminAccountSearchFiltersAndVersionedCursor(t *testing.T) { + srv, st := newAuthTestServer(t) + h := srv.Handler() + admin := bootAdmin(t, h, st) + alice, err := st.CreatePendingAccount("admin", "alice-search", store.AccountProfile{Name: "同名", Phone: "private-needle"}) + if err != nil { + t.Fatal(err) + } + bob, err := st.CreatePendingAccount("admin", "bob-search", store.AccountProfile{Name: "同名", Phone: "other"}) + if err != nil { + t.Fatal(err) + } + search := func(query url.Values) (int, struct { + Accounts []struct { + UserID string `json:"userId"` + Owner string `json:"owner"` + } `json:"accounts"` + NextCursor string `json:"nextCursor"` + }) { + response := do(h, "GET", "/api/admin/users/search?"+query.Encode(), "", admin) + var page struct { + Accounts []struct { + UserID string `json:"userId"` + Owner string `json:"owner"` + } `json:"accounts"` + NextCursor string `json:"nextCursor"` + } + _ = json.Unmarshal(response.Body.Bytes(), &page) + return response.Code, page + } + params := url.Values{"query": {"同名"}, "mode": {"fuzzy"}, "fields": {"name"}, "limit": {"1"}} + code, first := search(params) + if code != http.StatusOK || len(first.Accounts) != 1 || first.Accounts[0].UserID != alice.ID || first.NextCursor == "" { + t.Fatalf("first search page: %d %+v", code, first) + } + params.Set("cursor", first.NextCursor) + code, second := search(params) + if code != http.StatusOK || len(second.Accounts) != 1 || second.Accounts[0].UserID != bob.ID || second.NextCursor != "" { + t.Fatalf("second search page: %d %+v", code, second) + } + params.Del("cursor") + params.Set("query", "private-needle") + params.Set("fields", "phone") + params.Set("mode", "exact") + code, contact := search(params) + if code != http.StatusOK || len(contact.Accounts) != 1 || contact.Accounts[0].UserID != alice.ID { + t.Fatalf("authorized contact search: %d %+v", code, contact) + } + params.Set("query", "同名") + params.Set("fields", "name") + params.Set("mode", "fuzzy") + params.Set("cursor", first.NextCursor) + if _, err := st.PatchAccountProfile("admin", alice.ID, alice.ProfileVersion, map[string]string{"phone": "changed-private"}); err != nil { + t.Fatal(err) + } + code, _ = search(params) + if code != http.StatusConflict { + t.Fatalf("private profile edit did not expire system cursor: %d", code) + } +} + func TestAccountHTTPPendingCreateProfileAndDisable(t *testing.T) { srv, st := newAuthTestServer(t) h := srv.Handler() diff --git a/internal/server/account_search.go b/internal/server/account_search.go new file mode 100644 index 0000000..c6ce04c --- /dev/null +++ b/internal/server/account_search.go @@ -0,0 +1,205 @@ +package server + +import ( + "crypto/hmac" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "encoding/json" + "net/http" + "sort" + "strconv" + "strings" + "unicode" + "unicode/utf8" + + "agent-system-backend/internal/store" +) + +func newAccountSearchSecret() [32]byte { + var key [32]byte + if _, err := rand.Read(key[:]); err != nil { + panic(err) + } + return key +} + +type adminSearchCursor struct { + Version string `json:"v"` + Owner string `json:"o"` + UserID string `json:"u"` +} + +func (s *Server) signAdminCursor(cursor adminSearchCursor) string { + data, _ := json.Marshal(cursor) + mac := hmac.New(sha256.New, s.accountSearchSecret[:]) + _, _ = mac.Write(data) + return base64.RawURLEncoding.EncodeToString(data) + "." + base64.RawURLEncoding.EncodeToString(mac.Sum(nil)) +} + +func (s *Server) parseAdminCursor(encoded, version string) (adminSearchCursor, bool) { + var cursor adminSearchCursor + payload, signature, found := strings.Cut(encoded, ".") + if !found { + return cursor, false + } + data, err := base64.RawURLEncoding.DecodeString(payload) + if err != nil || len(data) > 1024 { + return cursor, false + } + provided, err := base64.RawURLEncoding.DecodeString(signature) + if err != nil { + return cursor, false + } + mac := hmac.New(sha256.New, s.accountSearchSecret[:]) + _, _ = mac.Write(data) + if !hmac.Equal(provided, mac.Sum(nil)) || json.Unmarshal(data, &cursor) != nil || cursor.Version != version || cursor.Owner == "" || cursor.UserID == "" { + return adminSearchCursor{}, false + } + return cursor, true +} + +func searchAccountField(account store.Account, field string) string { + switch field { + case "userId": + return account.ID + case "owner": + return account.Owner + case "name": + return account.Profile.Name + case "phone": + return account.Profile.Phone + case "email": + return account.Profile.Email + case "staffNo": + return account.Profile.StaffNo + case "department": + return account.Profile.Department + case "notes": + return account.Profile.Notes + } + return "" +} + +func (s *Server) searchAdminUsers(w http.ResponseWriter, r *http.Request) { + actor, ok := s.requireAdmin(w, r) + if !ok { + return + } + params := r.URL.Query() + query := strings.TrimSpace(params.Get("query")) + if !utf8.ValidString(query) || utf8.RuneCountInString(query) > 128 { + http.Error(w, "invalid account search query", http.StatusBadRequest) + return + } + for _, ch := range query { + if unicode.IsControl(ch) { + http.Error(w, "invalid account search query", http.StatusBadRequest) + return + } + } + mode := params.Get("mode") + if mode == "" { + mode = "fuzzy" + } + if mode != "exact" && mode != "fuzzy" { + http.Error(w, "unknown account search mode", http.StatusBadRequest) + return + } + status := params.Get("status") + if status == "" { + status = "all" + } + if status != "all" && status != "enabled" && status != "disabled" { + http.Error(w, "unknown account status filter", http.StatusBadRequest) + return + } + limit := 20 + if raw := params.Get("limit"); raw != "" { + parsed, err := strconv.Atoi(raw) + if err != nil || parsed < 1 || parsed > 100 { + http.Error(w, "invalid account search limit", http.StatusBadRequest) + return + } + limit = parsed + } + fields := []string{"owner", "name", "userId"} + if raw := params.Get("fields"); raw != "" { + fields = strings.Split(raw, ",") + } + allowed := map[string]bool{"owner": true, "name": true, "userId": true, "phone": true, "email": true, + "staffNo": true, "department": true, "notes": true} + seen := map[string]bool{} + for _, field := range fields { + if !allowed[field] || seen[field] { + http.Error(w, "unknown or duplicate account search field", http.StatusBadRequest) + return + } + seen[field] = true + } + sort.Strings(fields) + accounts, revision, err := s.st.ListAdminAccountsForActor(actor) + if err != nil { + writeStoreErr(w, err) + return + } + visibleRevision := sha256.Sum256([]byte(revision.Private)) + directoryVersion := hex.EncodeToString(visibleRevision[:]) + fingerprint, _ := json.Marshal(struct { + Actor, Query, Mode, Status, Revision string + Fields []string + }{actor, query, mode, status, directoryVersion, fields}) + digest := sha256.Sum256(fingerprint) + version := hex.EncodeToString(digest[:]) + var after adminSearchCursor + if token := params.Get("cursor"); token != "" { + var valid bool + after, valid = s.parseAdminCursor(token, version) + if !valid { + http.Error(w, "account search cursor expired or invalid", http.StatusConflict) + return + } + } + page := make([]store.Account, 0, limit+1) + for _, account := range accounts { + if status == "enabled" && !account.Enabled || status == "disabled" && account.Enabled { + continue + } + if after.Owner != "" && (account.Owner < after.Owner || account.Owner == after.Owner && account.ID <= after.UserID) { + continue + } + if query != "" { + matched := false + for _, field := range fields { + value := searchAccountField(account, field) + if mode == "exact" && strings.EqualFold(value, query) || mode == "fuzzy" && strings.Contains(strings.ToLower(value), strings.ToLower(query)) { + matched = true + break + } + } + if !matched { + continue + } + } + page = append(page, account) + if len(page) > limit { + break + } + } + more := len(page) > limit + if more { + page = page[:limit] + } + items := make([]map[string]any, 0, len(page)) + for _, account := range page { + items = append(items, accountProfileJSON(account)) + } + next := "" + if more { + last := page[len(page)-1] + next = s.signAdminCursor(adminSearchCursor{Version: version, Owner: last.Owner, UserID: last.ID}) + } + w.Header().Set("Cache-Control", "no-store") + writeJSON(w, http.StatusOK, map[string]any{"accounts": items, "nextCursor": next, "directoryVersion": directoryVersion}) +} diff --git a/internal/server/accounts.go b/internal/server/accounts.go index 1beae30..c1c17df 100644 --- a/internal/server/accounts.go +++ b/internal/server/accounts.go @@ -104,6 +104,7 @@ func authStateJSON(st store.UserAuthState) map[string]any { // redeemLoginKey:一次性登录 Key → set_password_only 会话(专用 body,不经 Bearer)。 // 请求只有 Key:身份由服务端绑定记录确定;按来源 IP 退避防爆破。 func (s *Server) redeemLoginKey(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Cache-Control", "no-store") if s.Auth == nil || !s.Auth.Enabled { http.Error(w, "auth disabled (embedded single-tenant mode)", http.StatusPreconditionFailed) return @@ -137,6 +138,7 @@ func (s *Server) redeemLoginKey(w http.ResponseWriter, r *http.Request) { st, _ := s.st.GetUserAuthState(owner) out := authStateJSON(st) out["sessionScope"] = store.SessionScopeSetPasswordOnly + out["passwordChangeMode"] = store.PasswordChangeModeGrantSetup out["reloginRequired"] = false writeJSON(w, http.StatusOK, out) } @@ -192,6 +194,7 @@ func (s *Server) login(w http.ResponseWriter, r *http.Request) { setSessionCookie(w, r, token, maxAge) out := authStateJSON(st) out["sessionScope"] = scope + out["passwordChangeMode"] = store.PasswordChangeModeCurrentRequired writeJSON(w, http.StatusOK, out) } @@ -219,6 +222,11 @@ func (s *Server) changePassword(w http.ResponseWriter, r *http.Request) { http.Error(w, "session expired", http.StatusUnauthorized) return } + mode, err := s.st.SessionPasswordChangeMode(tok) + if err != nil { + writeStoreErr(w, err) + return + } var req struct { Current string `json:"current"` Next string `json:"next"` @@ -236,15 +244,27 @@ func (s *Server) changePassword(w http.ResponseWriter, r *http.Request) { http.Error(w, err.Error(), http.StatusInternalServerError) return } - st0, _ := s.st.GetUserAuthState(owner) - forceSet := scope == store.SessionScopeSetPasswordOnly || st0.MustChangePassword || !st0.HasPassword - if forceSet { - if _, err := s.st.CompleteSetPasswordChecked(tok, hash); err != nil { - if errors.Is(err, store.ErrNotFound) { + if scope == store.SessionScopeSetPasswordOnly { + var completeErr error + switch mode { + case store.PasswordChangeModeGrantSetup: + _, completeErr = s.st.CompleteSetPasswordChecked(tok, hash) + case store.PasswordChangeModeCurrentRequired: + _, completeErr = s.st.CompleteForcedPasswordChecked(tok, req.Current, hash) + default: + http.Error(w, "unsupported password change mode", http.StatusForbidden) + return + } + if completeErr != nil { + if errors.Is(completeErr, store.ErrBadCredentials) { + http.Error(w, "current password incorrect", http.StatusUnauthorized) + return + } + if errors.Is(completeErr, store.ErrNotFound) { http.Error(w, "session expired", http.StatusUnauthorized) return } - writeStoreErr(w, err) + writeStoreErr(w, completeErr) return } setSessionCookie(w, r, "", -1) @@ -330,6 +350,7 @@ func accountProfileJSON(account store.Account) map[string]any { "profileVersion": account.ProfileVersion, "enabled": account.Enabled, "stateVersion": account.StateVersion, "isAdmin": account.IsAdmin, "passwordSet": account.HasPassword, "initializationState": account.InitializationState, + "initializationGeneration": account.InitializationGeneration, } } @@ -440,35 +461,9 @@ func (s *Server) setAdminAccountState(w http.ResponseWriter, r *http.Request) { // 走签发一次性登录 Key)。单事务:must_change=1、bump auth_version、作废未用 // 登录 Key、吊销全部会话、审计。 func (s *Server) resetAdminUserPassword(w http.ResponseWriter, r *http.Request) { - actor, ok := s.requireAdmin(w, r) - if !ok { - return + if _, ok := s.requireAdmin(w, r); ok { + http.Error(w, "RECOVERY_CONTRACT_REPLACED: propose recovery instead", http.StatusGone) } - owner := r.PathValue("owner") - var req struct { - Password string `json:"password"` - } - if err := decodeJSON(r, &req); err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) - return - } - if err := validPassword(req.Password); err != nil { - http.Error(w, "new password must be 8+ characters", http.StatusBadRequest) - return - } - hash, err := store.HashPassword(req.Password) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - if err := s.st.AdminResetUserPassword(owner, hash, actor, "admin-http"); err != nil { - writeStoreErr(w, err) - return - } - st, _ := s.st.GetUserAuthState(owner) - writeJSON(w, http.StatusOK, map[string]any{ - "owner": st.Owner, "mustChangePassword": st.MustChangePassword, - }) } // ---- 管理员:一次性登录 Key(恢复流程主入口)---- @@ -476,38 +471,9 @@ func (s *Server) resetAdminUserPassword(w http.ResponseWriter, r *http.Request) // issueAdminLoginKey:为指定既有用户签发恢复 Key(明文仅本次响应返回)。 // 同事务撤销旧恢复 Key 及其受限会话;不改密码、不吊销正常会话、不动业务数据。 func (s *Server) issueAdminLoginKey(w http.ResponseWriter, r *http.Request) { - actor, ok := s.requireAdmin(w, r) - if !ok { - return + if _, ok := s.requireAdmin(w, r); ok { + http.Error(w, "RECOVERY_CONTRACT_REPLACED: propose recovery instead", http.StatusGone) } - owner := strings.TrimSpace(r.PathValue("owner")) - var req struct { - Purpose string `json:"purpose"` - TTLMinutes int `json:"ttlMinutes"` - } - if err := decodeJSON(r, &req); err != nil && !errors.Is(err, io.EOF) { - http.Error(w, err.Error(), http.StatusBadRequest) - return - } - purpose := req.Purpose - if purpose == "" { - purpose = store.LoginKeyPurposeRecover - } - if purpose != store.LoginKeyPurposeRecover && purpose != store.LoginKeyPurposeActivate { - http.Error(w, "purpose must be recover or activate", http.StatusBadRequest) - return - } - ttl := time.Duration(req.TTLMinutes) * time.Minute - plain, meta, err := s.st.IssueLoginKey(owner, purpose, actor, ttl) - if err != nil { - writeStoreErr(w, err) - return - } - writeJSON(w, http.StatusOK, map[string]any{ - "id": meta.ID, "owner": meta.Owner, "purpose": meta.Purpose, - "status": meta.Status, "expiresAt": meta.ExpiresAt, "createdBy": meta.CreatedBy, - "key": plain, // 明文仅此一次;关闭弹层后不可再取 - }) } // listAdminLoginKeys:指定用户的 Key 状态与有效期(不返回明文)。 @@ -525,7 +491,7 @@ func (s *Server) revokeAdminLoginKey(w http.ResponseWriter, r *http.Request) { if !ok { return } - if _, err := s.st.RevokeLoginKeyCascade(r.PathValue("id"), actor); err != nil { + if _, err := s.st.RevokeLoginKeyCascade(r.PathValue("id"), actor, r.PathValue("owner")); err != nil { writeStoreErr(w, err) return } diff --git a/internal/server/auth.go b/internal/server/auth.go index f8ecd43..12bfa51 100644 --- a/internal/server/auth.go +++ b/internal/server/auth.go @@ -46,7 +46,7 @@ func (a *Authenticator) Middleware(next http.Handler) http.Handler { } p := r.URL.Path // 豁免:静态前端、登录、一次性 Key 兑换(body 专用,不走 Bearer)。 - if !strings.HasPrefix(p, "/api/") || p == "/api/auth/login" || p == "/api/auth/login-key/redeem" { + if !strings.HasPrefix(p, "/api/") || p == "/api/auth/login" || p == "/api/auth/login-key/redeem" || p == "/api/auth/recovery-grants/redeem" { next.ServeHTTP(w, r) return } diff --git a/internal/server/auth_e2e_test.go b/internal/server/auth_e2e_test.go index d5b5073..b9ea234 100644 --- a/internal/server/auth_e2e_test.go +++ b/internal/server/auth_e2e_test.go @@ -328,8 +328,8 @@ func TestAdminAccountFlow(t *testing.T) { if rec := session("POST", "/api/admin/users", `{"owner":"alice","name":"爱丽丝","password":"alice-init-01"}`); rec.Code != http.StatusForbidden { t.Fatalf("must_change admin must be gated before create: %d %s", rec.Code, rec.Body.String()) } - // 首登改密:不要求 current;成功后销毁会话,须重新密码登录。 - if rec := session("POST", "/api/auth/password", `{"next":"admin-final-9"}`); rec.Code != http.StatusOK || + // 历史初始密码必须提供 current;成功后销毁会话,须重新密码登录。 + if rec := session("POST", "/api/auth/password", `{"current":"boot-pass-00","next":"admin-final-9"}`); rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), `"reloginRequired":true`) { t.Fatalf("admin force set-password: %d %s", rec.Code, rec.Body.String()) } @@ -387,11 +387,11 @@ func TestAdminAccountFlow(t *testing.T) { if rec := alice("POST", "/api/admin/users", `{"owner":"bob","password":"bob-init-001"}`); rec.Code != http.StatusForbidden { t.Fatalf("must_change non-admin create must 403: %d", rec.Code) } - // must_change 改密忽略 current;短密码拒绝;成功后 reloginRequired。 + // must_change 改密要求 current;短密码拒绝;成功后 reloginRequired。 if rec := alice("POST", "/api/auth/password", `{"next":"short"}`); rec.Code != http.StatusBadRequest { t.Fatalf("short password must 400: %d", rec.Code) } - if rec := alice("POST", "/api/auth/password", `{"current":"ignored","next":"alice-final-9"}`); rec.Code != http.StatusOK || + if rec := alice("POST", "/api/auth/password", `{"current":"alice-init-01","next":"alice-final-9"}`); rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), `"reloginRequired":true`) { t.Fatalf("alice force set-password: %d %s", rec.Code, rec.Body.String()) } @@ -419,19 +419,19 @@ func TestAdminAccountFlow(t *testing.T) { t.Fatalf("non-admin create must 403: %d", rec.Code) } - // 管理员重置 alice 密码 → 其旧会话吊销 + 首登再改密。 - if rec := session("POST", "/api/admin/users/alice/password", `{"password":"reset-init-02"}`); rec.Code != http.StatusOK { - t.Fatalf("admin reset: %d %s", rec.Code, rec.Body.String()) + // 旧管理员重置入口关闭,不能动 alice 的密码与当前会话。 + if rec := session("POST", "/api/admin/users/alice/password", `{"password":"reset-init-02"}`); rec.Code != http.StatusGone { + t.Fatalf("admin reset must be closed: %d %s", rec.Code, rec.Body.String()) } - if rec := alice("GET", "/api/me", ""); rec.Code != http.StatusUnauthorized { - t.Fatalf("alice old session must be revoked after reset: %d", rec.Code) + if rec := alice("GET", "/api/me", ""); rec.Code != http.StatusOK { + t.Fatalf("closed reset must preserve alice session: %d", rec.Code) } - req = httptest.NewRequest("POST", "/api/auth/login", strings.NewReader(`{"owner":"alice","password":"reset-init-02"}`)) + req = httptest.NewRequest("POST", "/api/auth/login", strings.NewReader(`{"owner":"alice","password":"alice-final-9"}`)) req.Header.Set("Content-Type", "application/json") rec = httptest.NewRecorder() h.ServeHTTP(rec, req) - if !strings.Contains(rec.Body.String(), `"mustChangePassword":true`) { - t.Fatalf("reset must force change on next login: %s", rec.Body.String()) + if rec.Code != http.StatusOK || strings.Contains(rec.Body.String(), `"mustChangePassword":true`) { + t.Fatalf("closed reset must preserve normal login: %d %s", rec.Code, rec.Body.String()) } // API key 通道兼容:密钥登录照旧(并具备其 owner 身份)。 @@ -454,7 +454,7 @@ func TestAdminAccountFlow(t *testing.T) { rec = httptest.NewRecorder() h.ServeHTTP(rec, req) } - req = httptest.NewRequest("POST", "/api/auth/login", strings.NewReader(`{"owner":"alice","password":"reset-init-02"}`)) + req = httptest.NewRequest("POST", "/api/auth/login", strings.NewReader(`{"owner":"alice","password":"alice-final-9"}`)) req.Header.Set("Content-Type", "application/json") rec = httptest.NewRecorder() h.ServeHTTP(rec, req) diff --git a/internal/server/login_key_test.go b/internal/server/login_key_test.go index 84f2503..c999e5c 100644 --- a/internal/server/login_key_test.go +++ b/internal/server/login_key_test.go @@ -13,10 +13,14 @@ import ( func TestRedeemLoginKeyRestrictedSession(t *testing.T) { srv, st := newAuthTestServer(t) h := srv.Handler() - if err := st.CreateAccount("carol", "", "", false); err != nil { + if err := st.CreateAccount("admin", "", "admin-hash", true); err != nil { t.Fatal(err) } - plain, _, err := st.IssueLoginKey("carol", store.LoginKeyPurposeActivate, "test", 0) + account, err := st.CreatePendingAccount("admin", "carol", store.AccountProfile{}) + if err != nil { + t.Fatal(err) + } + plain, err := st.InitializePendingAccount(account.ID, account.StateVersion, account.InitializationGeneration) if err != nil { t.Fatal(err) } diff --git a/internal/server/login_recovery_test.go b/internal/server/login_recovery_test.go index 0936fd5..c70f6ad 100644 --- a/internal/server/login_recovery_test.go +++ b/internal/server/login_recovery_test.go @@ -5,7 +5,6 @@ package server import ( "bytes" - "encoding/json" "net/http" "net/http/httptest" "strings" @@ -35,7 +34,7 @@ func bootAdmin(t *testing.T, h http.Handler, st *store.Store) string { if tok == "" { t.Fatal("missing session cookie") } - req = httptest.NewRequest("POST", "/api/auth/password", strings.NewReader(`{"next":"admin-final-9"}`)) + req = httptest.NewRequest("POST", "/api/auth/password", strings.NewReader(`{"current":"boot-pass-00","next":"admin-final-9"}`)) req.AddCookie(&http.Cookie{Name: SessionCookie, Value: tok}) req.Header.Set("Content-Type", "application/json") rec = httptest.NewRecorder() @@ -89,145 +88,48 @@ func do(h http.Handler, method, path, body, cookie string) *httptest.ResponseRec func TestAdminLoginKeyRecoveryFlow(t *testing.T) { srv, st := newAuthTestServer(t) h := srv.Handler() - adminTok := bootAdmin(t, h, st) - if err := st.CreateAccount("mallory", "", "", false); err != nil { + admin := bootAdmin(t, h, st) + hash, err := store.HashPassword("victim-old-password") + if err != nil { t.Fatal(err) } - if err := st.CreateAccount("pleb", "", "", false); err != nil { + if err := st.CreateAccount("victim", "", hash, false); err != nil { t.Fatal(err) } - - // 匿名/无会话不能签发。 - if rec := do(h, "POST", "/api/admin/users/mallory/login-keys", `{}`, "not-a-session"); rec.Code != http.StatusUnauthorized { - t.Fatalf("anonymous issue: %d", rec.Code) - } - - // 管理员签发:返回明文一次 + 元数据。 - rec := do(h, "POST", "/api/admin/users/mallory/login-keys", `{}`, adminTok) - if rec.Code != http.StatusOK { - t.Fatalf("issue: %d %s", rec.Code, rec.Body.String()) - } - var issued struct { - ID string `json:"id"` - Key string `json:"key"` - } - if err := json.Unmarshal(rec.Body.Bytes(), &issued); err != nil { + legacy, _, err := st.IssueLoginKey("victim", store.LoginKeyPurposeRecover, "legacy-admin", 0) + if err != nil { t.Fatal(err) } - if !strings.HasPrefix(issued.Key, "alk_") || issued.ID == "" { - t.Fatalf("issue payload: %+v", issued) + if rec := do(h, "POST", "/api/admin/users/victim/login-keys", `{}`, admin); rec.Code != http.StatusGone { + t.Fatalf("administrator secret issuance must be closed: %d %s", rec.Code, rec.Body.String()) } - - // 列表:状态可见、无明文。 - rec = do(h, "GET", "/api/admin/users/mallory/login-keys", "", adminTok) - if rec.Code != http.StatusOK || strings.Contains(rec.Body.String(), "alk_") { - t.Fatalf("list must not leak plaintext: %d %s", rec.Code, rec.Body.String()) + if rec := do(h, "POST", "/api/auth/login-key/redeem", `{"key":"`+legacy+`"}`, ""); rec.Code == http.StatusOK { + t.Fatal("legacy administrator key redeemed after cutover") } - if !strings.Contains(rec.Body.String(), `"status":"unused"`) { - t.Fatalf("list should show unused status: %s", rec.Body.String()) - } - - // 用户兑换:只提交 Key,身份由服务端绑定决定。 - req := httptest.NewRequest("POST", "/api/auth/login-key/redeem", strings.NewReader(`{"key":"`+issued.Key+`"}`)) - req.Header.Set("Content-Type", "application/json") - rec = httptest.NewRecorder() - h.ServeHTTP(rec, req) - if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), `"owner":"mallory"`) || - !strings.Contains(rec.Body.String(), `"sessionScope":"set_password_only"`) { - t.Fatalf("redeem: %d %s", rec.Code, rec.Body.String()) - } - userTok := cookieOf(rec) - - // 重复兑换失败。 - req = httptest.NewRequest("POST", "/api/auth/login-key/redeem", strings.NewReader(`{"key":"`+issued.Key+`"}`)) - req.Header.Set("Content-Type", "application/json") - rec = httptest.NewRecorder() - h.ServeHTTP(rec, req) - if rec.Code != http.StatusConflict { - t.Fatalf("replay redeem: %d", rec.Code) - } - - // 受限会话:业务与管理员入口均拒绝。 - if rec := do(h, "GET", "/api/projects", "", userTok); rec.Code != http.StatusForbidden { - t.Fatalf("business api: %d", rec.Code) - } - if rec := do(h, "POST", "/api/admin/users/pleb/login-keys", `{}`, userTok); rec.Code != http.StatusForbidden { - t.Fatalf("restricted session must not issue keys: %d", rec.Code) - } - - // 设密(无 current)→ reloginRequired → 新密码登录。 - rec = do(h, "POST", "/api/auth/password", `{"next":"mallory-final-9"}`, userTok) - if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), `"reloginRequired":true`) { - t.Fatalf("set password: %d %s", rec.Code, rec.Body.String()) - } - req = httptest.NewRequest("POST", "/api/auth/login", strings.NewReader(`{"owner":"mallory","password":"mallory-final-9"}`)) - req.Header.Set("Content-Type", "application/json") - rec = httptest.NewRecorder() - h.ServeHTTP(rec, req) - if rec.Code != http.StatusOK || strings.Contains(rec.Body.String(), `"mustChangePassword":true`) { - t.Fatalf("login with new password: %d %s", rec.Code, rec.Body.String()) + if rec := do(h, "GET", "/api/admin/users/victim/login-keys", "", admin); rec.Code != http.StatusOK || strings.Contains(rec.Body.String(), legacy) { + t.Fatalf("metadata lookup leaked secret: %d %s", rec.Code, rec.Body.String()) } } -// 补发与撤销:旧 Key 作废、旧受限会话失效。 func TestAdminLoginKeyReissueAndRevoke(t *testing.T) { srv, st := newAuthTestServer(t) h := srv.Handler() - adminTok := bootAdmin(t, h, st) - if err := st.CreateAccount("nia", "", "", false); err != nil { + admin := bootAdmin(t, h, st) + if err := st.CreateAccount("victim", "", "", false); err != nil { t.Fatal(err) } - issue := func() (string, string) { - rec := do(h, "POST", "/api/admin/users/nia/login-keys", `{}`, adminTok) - if rec.Code != http.StatusOK { - t.Fatalf("issue: %d %s", rec.Code, rec.Body.String()) - } - var out struct { - ID string `json:"id"` - Key string `json:"key"` - } - json.Unmarshal(rec.Body.Bytes(), &out) - return out.ID, out.Key + legacy, meta, err := st.IssueLoginKey("victim", store.LoginKeyPurposeRecover, "legacy-admin", 0) + if err != nil { + t.Fatal(err) } - oldID, oldKey := issue() - _ = oldID - - // 兑换旧 Key 得受限会话;补发后该会话失效、旧 Key 不可再用。 - req := httptest.NewRequest("POST", "/api/auth/login-key/redeem", strings.NewReader(`{"key":"`+oldKey+`"}`)) - req.Header.Set("Content-Type", "application/json") - rec := httptest.NewRecorder() - h.ServeHTTP(rec, req) - oldSess := cookieOf(rec) - if oldSess == "" { - t.Fatal("redeem old key failed") + if rec := do(h, "DELETE", "/api/admin/users/other/login-keys/"+meta.ID, "", admin); rec.Code != http.StatusNotFound { + t.Fatalf("path owner mismatch revoked key: %d %s", rec.Code, rec.Body.String()) } - issue() - if rec := do(h, "GET", "/api/me", "", oldSess); rec.Code != http.StatusUnauthorized { - t.Fatalf("restricted session must die after reissue: %d", rec.Code) + if rec := do(h, "DELETE", "/api/admin/users/victim/login-keys/"+meta.ID, "", admin); rec.Code != http.StatusOK { + t.Fatalf("legacy metadata revoke: %d %s", rec.Code, rec.Body.String()) } - req = httptest.NewRequest("POST", "/api/auth/login-key/redeem", strings.NewReader(`{"key":"`+oldKey+`"}`)) - req.Header.Set("Content-Type", "application/json") - rec = httptest.NewRecorder() - h.ServeHTTP(rec, req) - if rec.Code == http.StatusOK { - t.Fatal("consumed old key must not redeem again") - } - - // 撤销端点:未用 Key 撤销后不可兑换。 - newID, newKey := issue() - if rec := do(h, "DELETE", "/api/admin/users/nia/login-keys/"+newID, "", adminTok); rec.Code != http.StatusOK { - t.Fatalf("revoke: %d %s", rec.Code, rec.Body.String()) - } - req = httptest.NewRequest("POST", "/api/auth/login-key/redeem", strings.NewReader(`{"key":"`+newKey+`"}`)) - req.Header.Set("Content-Type", "application/json") - rec = httptest.NewRecorder() - h.ServeHTTP(rec, req) - if rec.Code == http.StatusOK { - t.Fatal("revoked key must not redeem") - } - // 不存在的编号 404。 - if rec := do(h, "DELETE", "/api/admin/users/nia/login-keys/lk_nope", "", adminTok); rec.Code != http.StatusNotFound { - t.Fatalf("unknown key revoke: %d", rec.Code) + if rec := do(h, "POST", "/api/auth/login-key/redeem", `{"key":"`+legacy+`"}`, ""); rec.Code == http.StatusOK { + t.Fatal("revoked legacy key redeemed") } } @@ -253,7 +155,7 @@ func TestLoginMustChangeCreatesRestrictedSession(t *testing.T) { if rec := do(h, "GET", "/api/projects", "", tok); rec.Code != http.StatusForbidden { t.Fatalf("business api before set: %d", rec.Code) } - rec = do(h, "POST", "/api/auth/password", `{"next":"oscar-final-9"}`, tok) + rec = do(h, "POST", "/api/auth/password", `{"current":"temp-pass-00","next":"oscar-final-9"}`, tok) if rec.Code != http.StatusOK { t.Fatalf("set password: %d %s", rec.Code, rec.Body.String()) } @@ -271,10 +173,14 @@ func TestActiveChangePasswordRequiresCurrent(t *testing.T) { srv, st := newAuthTestServer(t) h := srv.Handler() _ = srv - if err := st.CreateAccount("petra", "", "", false); err != nil { + if err := st.CreateAccount("admin", "", "admin-hash", true); err != nil { t.Fatal(err) } - plain, _, err := st.IssueLoginKey("petra", store.LoginKeyPurposeActivate, "test", 0) + account, err := st.CreatePendingAccount("admin", "petra", store.AccountProfile{}) + if err != nil { + t.Fatal(err) + } + plain, err := st.InitializePendingAccount(account.ID, account.StateVersion, account.InitializationGeneration) if err != nil { t.Fatal(err) } diff --git a/internal/server/recovery.go b/internal/server/recovery.go new file mode 100644 index 0000000..f3ba03e --- /dev/null +++ b/internal/server/recovery.go @@ -0,0 +1,120 @@ +package server + +import ( + "net/http" +) + +func (s *Server) createAdminRecoveryProposal(w http.ResponseWriter, r *http.Request) { + actor, ok := s.requireAdmin(w, r) + if !ok { + return + } + var req struct { + ExpectedStateVersion int64 `json:"expectedStateVersion"` + IdempotencyKey string `json:"idempotencyKey"` + } + if err := decodeStrictJSON(r, &req); err != nil { + http.Error(w, "invalid recovery proposal", http.StatusBadRequest) + return + } + proposal, err := s.st.CreateRecoveryProposal(actor, r.PathValue("userId"), req.ExpectedStateVersion, req.IdempotencyKey) + if err != nil { + writeStoreErr(w, err) + return + } + w.Header().Set("Cache-Control", "no-store") + writeJSON(w, http.StatusOK, proposal) +} + +func (s *Server) getAdminRecoveryProposal(w http.ResponseWriter, r *http.Request) { + actor, ok := s.requireAdmin(w, r) + if !ok { + return + } + proposal, err := s.st.GetRecoveryProposalForAdmin(actor, r.PathValue("userId"), r.PathValue("id")) + writeJSONOr(w, proposal, err) +} + +func (s *Server) listAdminRecoveryProposals(w http.ResponseWriter, r *http.Request) { + actor, ok := s.requireAdmin(w, r) + if !ok { + return + } + proposals, err := s.st.ListRecoveryProposalsForAdmin(actor, r.PathValue("userId")) + writeJSONOr(w, map[string]any{"proposals": proposals}, err) +} + +func (s *Server) revokeAdminRecoveryProposal(w http.ResponseWriter, r *http.Request) { + actor, ok := s.requireAdmin(w, r) + if !ok { + return + } + var req struct { + ExpectedVersion int64 `json:"expectedVersion"` + } + if err := decodeStrictJSON(r, &req); err != nil { + http.Error(w, "invalid recovery revocation", http.StatusBadRequest) + return + } + proposal, err := s.st.RevokeRecoveryProposal(actor, r.PathValue("userId"), r.PathValue("id"), req.ExpectedVersion) + writeJSONOr(w, proposal, err) +} + +func (s *Server) listMyRecoveryProposals(w http.ResponseWriter, r *http.Request) { + owner, ok := s.requireFullPasswordSession(w, r) + if !ok { + return + } + proposals, err := s.st.ListOwnerRecoveryProposals(owner) + writeJSONOr(w, map[string]any{"proposals": proposals}, err) +} + +func (s *Server) getMyRecoveryProposal(w http.ResponseWriter, r *http.Request) { + owner, ok := s.requireFullPasswordSession(w, r) + if !ok { + return + } + proposal, err := s.st.GetOwnerRecoveryProposal(owner, r.PathValue("id")) + writeJSONOr(w, proposal, err) +} + +func (s *Server) confirmRecoveryProposal(w http.ResponseWriter, r *http.Request) { + if _, ok := s.requireFullPasswordSession(w, r); !ok { + return + } + var req struct { + ExpectedVersion int64 `json:"expectedVersion"` + } + if err := decodeStrictJSON(r, &req); err != nil { + http.Error(w, "invalid recovery confirmation", http.StatusBadRequest) + return + } + plain, proposal, err := s.st.ConfirmRecoveryProposal(sessionToken(r), r.PathValue("id"), req.ExpectedVersion) + if err != nil { + writeStoreErr(w, err) + return + } + w.Header().Set("Cache-Control", "no-store") + writeJSON(w, http.StatusOK, map[string]any{"proposal": proposal, "grant": plain}) +} + +func (s *Server) rotateRecoveryGrant(w http.ResponseWriter, r *http.Request) { + if _, ok := s.requireFullPasswordSession(w, r); !ok { + return + } + var req struct { + ExpectedVersion int64 `json:"expectedVersion"` + ExpectedGeneration int64 `json:"expectedGeneration"` + } + if err := decodeStrictJSON(r, &req); err != nil { + http.Error(w, "invalid recovery grant rotation", http.StatusBadRequest) + return + } + plain, proposal, err := s.st.RotateRecoveryGrant(sessionToken(r), r.PathValue("id"), req.ExpectedVersion, req.ExpectedGeneration) + if err != nil { + writeStoreErr(w, err) + return + } + w.Header().Set("Cache-Control", "no-store") + writeJSON(w, http.StatusOK, map[string]any{"proposal": proposal, "grant": plain}) +} diff --git a/internal/server/recovery_contract_test.go b/internal/server/recovery_contract_test.go new file mode 100644 index 0000000..aa5aadb --- /dev/null +++ b/internal/server/recovery_contract_test.go @@ -0,0 +1,161 @@ +package server + +import ( + "encoding/json" + "net/http" + "strconv" + "strings" + "testing" + + "agent-system-backend/internal/store" +) + +func TestOwnerConfirmedRecoveryHTTPKeepsGrantFromAdministrator(t *testing.T) { + srv, st := newAuthTestServer(t) + h := srv.Handler() + admin := bootAdmin(t, h, st) + pending, err := st.CreatePendingAccount("admin", "recovery-user", store.AccountProfile{}) + if err != nil { + t.Fatal(err) + } + activation, err := st.InitializePendingAccount(pending.ID, pending.StateVersion, pending.InitializationGeneration) + if err != nil { + t.Fatal(err) + } + setup := do(h, "POST", "/api/auth/login-key/redeem", `{"key":"`+activation+`"}`, "") + if setup.Code != http.StatusOK || !strings.Contains(setup.Body.String(), `"passwordChangeMode":"grant_setup"`) { + t.Fatalf("activation redeem: %d %s", setup.Code, setup.Body.String()) + } + if rec := do(h, "POST", "/api/auth/password", `{"next":"alice-initial-password"}`, cookieOf(setup)); rec.Code != http.StatusOK { + t.Fatalf("initial set: %d %s", rec.Code, rec.Body.String()) + } + ownerLogin := do(h, "POST", "/api/auth/login", `{"owner":"recovery-user","password":"alice-initial-password"}`, "") + if ownerLogin.Code != http.StatusOK { + t.Fatalf("trusted owner login: %d %s", ownerLogin.Code, ownerLogin.Body.String()) + } + ownerCookie := cookieOf(ownerLogin) + account, err := st.GetAccount(pending.ID) + if err != nil { + t.Fatal(err) + } + proposalResponse := do(h, "POST", "/api/admin/users/"+pending.ID+"/recovery-proposals", + `{"expectedStateVersion":`+jsonNumber(account.StateVersion)+`,"idempotencyKey":"http-request-1"}`, admin) + if proposalResponse.Code != http.StatusOK || strings.Contains(proposalResponse.Body.String(), "grant") { + t.Fatalf("administrator proposal leaked grant: %d %s", proposalResponse.Code, proposalResponse.Body.String()) + } + var proposal store.RecoveryProposal + if err := json.Unmarshal(proposalResponse.Body.Bytes(), &proposal); err != nil || proposal.ID == "" { + t.Fatalf("proposal metadata: %+v %v", proposal, err) + } + if got := do(h, "GET", "/api/admin/users/"+pending.ID+"/recovery-proposals/"+proposal.ID, "", admin); got.Code != http.StatusOK || strings.Contains(got.Body.String(), "grant") { + t.Fatalf("administrator status leaked grant: %d %s", got.Code, got.Body.String()) + } + confirmed := do(h, "POST", "/api/auth/recovery-proposals/"+proposal.ID+"/confirm", `{"expectedVersion":1}`, ownerCookie) + if confirmed.Code != http.StatusOK || confirmed.Header().Get("Cache-Control") != "no-store" { + t.Fatalf("owner confirm: %d %s", confirmed.Code, confirmed.Body.String()) + } + var confirmation struct { + Proposal store.RecoveryProposal `json:"proposal"` + Grant string `json:"grant"` + } + if err := json.Unmarshal(confirmed.Body.Bytes(), &confirmation); err != nil || confirmation.Grant == "" { + t.Fatalf("confirmation secret response: %v", err) + } + if got := do(h, "GET", "/api/me/recovery-proposals/"+proposal.ID, "", ownerCookie); got.Code != http.StatusOK || strings.Contains(got.Body.String(), confirmation.Grant) { + t.Fatalf("owner status replayed grant: %d %s", got.Code, got.Body.String()) + } + rotated := do(h, "POST", "/api/auth/recovery-proposals/"+proposal.ID+"/rotate-grant", + `{"expectedVersion":`+jsonNumber(confirmation.Proposal.Version)+`,"expectedGeneration":`+jsonNumber(confirmation.Proposal.Generation)+`}`, ownerCookie) + if rotated.Code != http.StatusOK { + t.Fatalf("owner rotate: %d %s", rotated.Code, rotated.Body.String()) + } + var rotation struct { + Grant string `json:"grant"` + } + if err := json.Unmarshal(rotated.Body.Bytes(), &rotation); err != nil || rotation.Grant == "" || rotation.Grant == confirmation.Grant { + t.Fatalf("rotation grant: %v", err) + } + if old := do(h, "POST", "/api/auth/recovery-grants/redeem", `{"key":"`+confirmation.Grant+`"}`, ""); old.Code == http.StatusOK { + t.Fatal("old recovery grant remained usable") + } + redeemed := do(h, "POST", "/api/auth/recovery-grants/redeem", `{"key":"`+rotation.Grant+`"}`, "") + if redeemed.Code != http.StatusOK { + t.Fatalf("recovery redeem: %d %s", redeemed.Code, redeemed.Body.String()) + } + if done := do(h, "POST", "/api/auth/password", `{"next":"alice-recovered-password"}`, cookieOf(redeemed)); done.Code != http.StatusOK { + t.Fatalf("recovery finish: %d %s", done.Code, done.Body.String()) + } + if login := do(h, "POST", "/api/auth/login", `{"owner":"recovery-user","password":"alice-recovered-password"}`, ""); login.Code != http.StatusOK { + t.Fatalf("relogin: %d %s", login.Code, login.Body.String()) + } +} + +func jsonNumber(v int64) string { return strconv.FormatInt(v, 10) } + +func TestLegacyAdministratorRecoveryEntrancesAreClosed(t *testing.T) { + srv, st := newAuthTestServer(t) + h := srv.Handler() + admin := bootAdmin(t, h, st) + hash, err := store.HashPassword("old-victim-pass") + if err != nil { + t.Fatal(err) + } + if err := st.CreateAccount("victim", "", hash, false); err != nil { + t.Fatal(err) + } + if err := st.ClearMustChangePassword("victim"); err != nil { + t.Fatal(err) + } + for _, route := range []struct{ path, body string }{ + {"/api/admin/users/victim/password", `{"password":"attacker-pass"}`}, + {"/api/admin/users/victim/login-keys", `{"purpose":"recover"}`}, + {"/api/admin/users/victim/login-keys", `{"purpose":"activate"}`}, + } { + rec := do(h, "POST", route.path, route.body, admin) + if rec.Code != http.StatusGone || !strings.Contains(rec.Body.String(), "RECOVERY_CONTRACT_REPLACED") { + t.Fatalf("legacy route remained active: %s %d %s", route.path, rec.Code, rec.Body.String()) + } + } + if _, ok := st.VerifyLogin("victim", "old-victim-pass"); !ok { + t.Fatal("closed routes changed existing password") + } + if keys, err := st.ListLoginKeys("victim"); err != nil || len(keys) != 0 { + t.Fatalf("closed routes issued a grant: %+v %v", keys, err) + } +} + +func TestLegacyGrantAndRestrictedSessionCannotSkipCurrentPassword(t *testing.T) { + srv, st := newAuthTestServer(t) + h := srv.Handler() + hash, err := store.HashPassword("old-victim-pass") + if err != nil { + t.Fatal(err) + } + if err := st.CreateAccount("victim", "", hash, false); err != nil { + t.Fatal(err) + } + grant, _, err := st.IssueLoginKey("victim", store.LoginKeyPurposeRecover, "legacy-admin", 0) + if err != nil { + t.Fatal(err) + } + if rec := do(h, "POST", "/api/auth/login-key/redeem", `{"key":"`+grant+`"}`, ""); rec.Code == http.StatusOK { + t.Fatal("legacy administrator grant created a restricted session") + } + if err := st.SetUserPassword("victim", hash); err != nil { + t.Fatal(err) + } + login := do(h, "POST", "/api/auth/login", `{"owner":"victim","password":"old-victim-pass"}`, "") + if login.Code != http.StatusOK || !strings.Contains(login.Body.String(), `"passwordChangeMode":"current_required"`) { + t.Fatalf("legacy forced login mode: %d %s", login.Code, login.Body.String()) + } + cookie := cookieOf(login) + if rec := do(h, "POST", "/api/auth/password", `{"next":"victim-new-pass"}`, cookie); rec.Code == http.StatusOK { + t.Fatal("forced password session skipped current password") + } + if rec := do(h, "POST", "/api/auth/password", `{"current":"old-victim-pass","next":"victim-new-pass"}`, cookie); rec.Code != http.StatusOK { + t.Fatalf("legacy forced change with current failed: %d %s", rec.Code, rec.Body.String()) + } + if _, ok := st.VerifyLogin("victim", "victim-new-pass"); !ok { + t.Fatal("current-required password change did not complete") + } +} diff --git a/internal/server/server.go b/internal/server/server.go index 3e1ad8a..13abb06 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -42,26 +42,27 @@ import ( ) type Server struct { - projectLifecycle *extension.ProjectLifecycle - projectHost func(string) extension.Host - st *store.Store - Auth *Authenticator // API key 认证(nil/未启用 = 默认租户;组合根注入) - Retriever core.Retriever // 混合检索(nil = store 全文;组合根注入) - Indexer core.DocumentIndexer // 检索索引入口(nil = 整文档单块;组合根注入) - Limiter ratelimit.Limiter // 租户限流(nil = 不限;组合根注入) - LimitLookup func() int // 当前限流值(次/分;<=0 不限;组合根缓存注入) - mgr *runmgr.Manager - Cards *cardsvc.Service // 收件箱任务卡(nil = 不启用;组合根注入) - Surfaces *surfacesvc.Service // Generative UI Surface(nil = 不启用;EXT-17) - Bus *cmdbus.Bus // 命令总线(nil = 不启用;组合根注入;四入口同源 D27) - Gate NLIntentGate // 意图闸门(chat 入口同源判定;nil = 不接) - Workflows *workflow.Registry // 已装载工作流(nil = 不接) - Prompts *prompts.Service // 提示词库(nil = 不启用 API 与技能命令;执行器另有回退) - reg *tools.Registry - prof *profiles.Service - set *settings.Service - plugins *plugins.Host - static fs.FS + accountSearchSecret [32]byte + projectLifecycle *extension.ProjectLifecycle + projectHost func(string) extension.Host + st *store.Store + Auth *Authenticator // API key 认证(nil/未启用 = 默认租户;组合根注入) + Retriever core.Retriever // 混合检索(nil = store 全文;组合根注入) + Indexer core.DocumentIndexer // 检索索引入口(nil = 整文档单块;组合根注入) + Limiter ratelimit.Limiter // 租户限流(nil = 不限;组合根注入) + LimitLookup func() int // 当前限流值(次/分;<=0 不限;组合根缓存注入) + mgr *runmgr.Manager + Cards *cardsvc.Service // 收件箱任务卡(nil = 不启用;组合根注入) + Surfaces *surfacesvc.Service // Generative UI Surface(nil = 不启用;EXT-17) + Bus *cmdbus.Bus // 命令总线(nil = 不启用;组合根注入;四入口同源 D27) + Gate NLIntentGate // 意图闸门(chat 入口同源判定;nil = 不接) + Workflows *workflow.Registry // 已装载工作流(nil = 不接) + Prompts *prompts.Service // 提示词库(nil = 不启用 API 与技能命令;执行器另有回退) + reg *tools.Registry + prof *profiles.Service + set *settings.Service + plugins *plugins.Host + static fs.FS // Subscribe 注入 bus 订阅(事件在线转发;重放走 store 游标)。 Subscribe func(conversationID string) (<-chan core.StreamEvent, func()) // DisableUploadIndexing EXT-9:true 时上传端点不再自动写检索索引(原件与 @@ -160,7 +161,8 @@ func New(st *store.Store, mgr *runmgr.Manager, reg *tools.Registry, p *profiles. if err != nil { panic(err) // embed 布局错误属构建期问题 } - return &Server{st: st, mgr: mgr, reg: reg, prof: p, set: s, plugins: ph, static: sub} + return &Server{st: st, mgr: mgr, reg: reg, prof: p, set: s, plugins: ph, static: sub, + accountSearchSecret: newAccountSearchSecret()} } // NLIntentGate 意图闸门端口(intentgate.Gate 实现;避免包依赖方向反转)。 @@ -282,15 +284,25 @@ func (s *Server) Handler() http.Handler { // ---- 认证与会话(管理员发放制,R2)---- mux.HandleFunc("POST /api/auth/login", s.login) mux.HandleFunc("POST /api/auth/login-key/redeem", s.redeemLoginKey) + mux.HandleFunc("POST /api/auth/recovery-grants/redeem", s.redeemLoginKey) mux.HandleFunc("POST /api/auth/logout", s.logout) mux.HandleFunc("POST /api/auth/password", s.changePassword) mux.HandleFunc("GET /api/admin/users", s.listAdminUsers) + mux.HandleFunc("GET /api/admin/users/search", s.searchAdminUsers) mux.HandleFunc("POST /api/admin/users", s.createAdminUser) mux.HandleFunc("GET /api/me/profile", s.meProfile) mux.HandleFunc("PATCH /api/me/profile", s.patchMeProfile) mux.HandleFunc("GET /api/admin/users/{userId}", s.adminAccount) mux.HandleFunc("PATCH /api/admin/users/{userId}/profile", s.patchAdminAccountProfile) mux.HandleFunc("PUT /api/admin/users/{userId}/state", s.setAdminAccountState) + mux.HandleFunc("POST /api/admin/users/{userId}/recovery-proposals", s.createAdminRecoveryProposal) + mux.HandleFunc("GET /api/admin/users/{userId}/recovery-proposals", s.listAdminRecoveryProposals) + mux.HandleFunc("GET /api/admin/users/{userId}/recovery-proposals/{id}", s.getAdminRecoveryProposal) + mux.HandleFunc("DELETE /api/admin/users/{userId}/recovery-proposals/{id}", s.revokeAdminRecoveryProposal) + mux.HandleFunc("GET /api/me/recovery-proposals", s.listMyRecoveryProposals) + mux.HandleFunc("GET /api/me/recovery-proposals/{id}", s.getMyRecoveryProposal) + mux.HandleFunc("POST /api/auth/recovery-proposals/{id}/confirm", s.confirmRecoveryProposal) + mux.HandleFunc("POST /api/auth/recovery-proposals/{id}/rotate-grant", s.rotateRecoveryGrant) mux.HandleFunc("POST /api/admin/users/{owner}/password", s.resetAdminUserPassword) mux.HandleFunc("POST /api/admin/users/{owner}/login-keys", s.issueAdminLoginKey) mux.HandleFunc("GET /api/admin/users/{owner}/login-keys", s.listAdminLoginKeys) @@ -845,6 +857,11 @@ func (s *Server) me(w http.ResponseWriter, r *http.Request) { if sc := SessionScope(r); sc != "" { out["sessionScope"] = sc } + if AuthVia(r) == AuthViaSession { + if mode, err := s.st.SessionPasswordChangeMode(sessionToken(r)); err == nil { + out["passwordChangeMode"] = mode + } + } if st, err := s.st.GetUserAuthState(owner); err == nil { out["isAdmin"] = st.IsAdmin out["mustChangePassword"] = st.MustChangePassword @@ -2166,6 +2183,10 @@ func writeOKOr(w http.ResponseWriter, err error) { } func writeStoreErr(w http.ResponseWriter, err error) { + if errors.Is(err, store.ErrOwnerProofUnavailable) { + http.Error(w, "OWNER_PROOF_UNAVAILABLE", http.StatusConflict) + return + } if errors.Is(err, store.ErrAccountForbidden) { http.Error(w, err.Error(), http.StatusForbidden) return diff --git a/internal/store/account_directory.go b/internal/store/account_directory.go index e426043..bef0de9 100644 --- a/internal/store/account_directory.go +++ b/internal/store/account_directory.go @@ -1,5 +1,11 @@ package store +import ( + "context" + "database/sql" + "errors" +) + // DirectoryAccountRow is an internal candidate projection. It intentionally // excludes contact details, notes, credentials and administrator privileges. type DirectoryAccountRow struct { @@ -28,3 +34,58 @@ func (s *Store) ListDirectoryAccountRows() ([]DirectoryAccountRow, error) { } return results, rows.Err() } + +// ListAdminAccountsForActor takes a consistent snapshot for the protected +// system.users.view adapter. The actor's current admin/effective state is +// checked under lock before any profile fields are read. +func (s *Store) ListAdminAccountsForActor(actorOwner string) ([]Account, AccountDirectoryRevision, error) { + var tx *sql.Tx + var err error + if s.Dialect() == "postgres" { + tx, err = s.db.BeginTx(context.Background(), &sql.TxOptions{Isolation: sql.LevelRepeatableRead}) + } else { + tx, err = s.db.Begin() + } + if err != nil { + return nil, AccountDirectoryRevision{}, err + } + defer tx.Rollback() + locked, err := s.lockAccountUsers(tx, actorOwner) + if errors.Is(err, ErrNotFound) { + return nil, AccountDirectoryRevision{}, ErrAccountForbidden + } + if err != nil { + return nil, AccountDirectoryRevision{}, err + } + actor := locked[actorOwner] + if !actor.Enabled || !actor.IsAdmin { + return nil, AccountDirectoryRevision{}, ErrAccountForbidden + } + rows, err := tx.Query(s.q(`SELECT ` + accountColumns + ` FROM users ORDER BY owner,id`)) + if err != nil { + return nil, AccountDirectoryRevision{}, err + } + accounts := []Account{} + for rows.Next() { + account, err := scanAccount(rows) + if err != nil { + rows.Close() + return nil, AccountDirectoryRevision{}, err + } + accounts = append(accounts, account) + } + if err := rows.Err(); err != nil { + rows.Close() + return nil, AccountDirectoryRevision{}, err + } + rows.Close() + var revision AccountDirectoryRevision + if err := tx.QueryRow(`SELECT public_revision,staff_no_revision,private_revision FROM account_directory_revision WHERE id=1`). + Scan(&revision.Public, &revision.StaffNo, &revision.Private); err != nil { + return nil, AccountDirectoryRevision{}, err + } + if err := tx.Commit(); err != nil { + return nil, AccountDirectoryRevision{}, err + } + return accounts, revision, nil +} diff --git a/internal/store/account_directory_test.go b/internal/store/account_directory_test.go new file mode 100644 index 0000000..593cdfb --- /dev/null +++ b/internal/store/account_directory_test.go @@ -0,0 +1,37 @@ +package store + +import ( + "errors" + "testing" +) + +func TestAdminAccountSearchSnapshotRequiresCurrentSystemAuthority(t *testing.T) { + st := openTestStore(t) + t.Logf("admin directory dialect=%s", st.Dialect()) + if err := st.CreateAccount("admin", "", "hash", true); err != nil { + t.Fatal(err) + } + if err := st.CreateAccount("ordinary", "", "hash", false); err != nil { + t.Fatal(err) + } + pending, err := st.CreatePendingAccount("admin", "alice", AccountProfile{Email: "alice@example.test", Notes: "private"}) + if err != nil { + t.Fatal(err) + } + accounts, revision, err := st.ListAdminAccountsForActor("admin") + if err != nil || len(accounts) != 3 || revision.Private == "" { + t.Fatalf("authorized snapshot: accounts=%d rev=%+v err=%v", len(accounts), revision, err) + } + if accounts[1].ID != pending.ID || accounts[1].Profile.Email != "alice@example.test" { + t.Fatalf("profile snapshot or stable sort: %+v", accounts) + } + if _, _, err := st.ListAdminAccountsForActor("ordinary"); !errors.Is(err, ErrAccountForbidden) { + t.Fatalf("ordinary account read protected profiles: %v", err) + } + if _, err := st.DB().Exec(st.q(`UPDATE users SET enabled=? WHERE owner=?`), false, "admin"); err != nil { + t.Fatal(err) + } + if _, _, err := st.ListAdminAccountsForActor("admin"); !errors.Is(err, ErrAccountForbidden) { + t.Fatalf("disabled administrator read protected profiles: %v", err) + } +} diff --git a/internal/store/account_model.go b/internal/store/account_model.go index dc7c2cd..b2c47a7 100644 --- a/internal/store/account_model.go +++ b/internal/store/account_model.go @@ -80,7 +80,9 @@ type accountQuerier interface { QueryRow(string, ...any) *sql.Row } -func scanAccount(row *sql.Row) (Account, error) { +type accountScanner interface{ Scan(...any) error } + +func scanAccount(row accountScanner) (Account, error) { var a Account err := row.Scan(&a.ID, &a.Owner, &a.Profile.Name, &a.Profile.Phone, &a.Profile.Email, &a.Profile.StaffNo, &a.Profile.Department, &a.Profile.Notes, &a.ProfileVersion, diff --git a/internal/store/account_state.go b/internal/store/account_state.go index d1c18d8..3cb3cc2 100644 --- a/internal/store/account_state.go +++ b/internal/store/account_state.go @@ -66,6 +66,9 @@ func (s *Store) SetAccountEnabled(actorOwner, userID string, expectedStateVersio if _, err := tx.Exec(s.q(`UPDATE login_keys SET status=? WHERE owner=? AND status=?`), LoginKeyStatusRevoked, target.Owner, LoginKeyStatusUnused); err != nil { return Account{}, err } + if _, err := tx.Exec(s.q(`UPDATE recovery_proposals SET status='revoked',version=version+1 WHERE owner=? AND status IN ('pending','confirmed')`), target.Owner); err != nil { + return Account{}, err + } } if err := s.bumpAccountDirectoryRevision(tx, true, false); err != nil { return Account{}, err diff --git a/internal/store/account_state_test.go b/internal/store/account_state_test.go index 9847577..6ea8bcf 100644 --- a/internal/store/account_state_test.go +++ b/internal/store/account_state_test.go @@ -2,6 +2,7 @@ package store import ( "errors" + "sync" "testing" ) @@ -71,6 +72,59 @@ func TestAccountStateDisablesCredentialsWithoutRestoringThem(t *testing.T) { } } +func TestAccountStateConcurrentLastTwoManagersAcrossPools(t *testing.T) { + st := openTestStore(t) + other := reopenAccountModelStore(t, st) + t.Logf("last managers dialect=%s two independent pools", st.Dialect()) + for _, owner := range []string{"admin-a", "admin-b"} { + if err := st.CreateAccount(owner, "", "existing-hash", true); err != nil { + t.Fatal(err) + } + if err := st.ClearMustChangePassword(owner); err != nil { + t.Fatal(err) + } + } + a, _ := st.GetAccountByOwner("admin-a") + b, _ := st.GetAccountByOwner("admin-b") + start := make(chan struct{}) + results := make(chan error, 2) + var wg sync.WaitGroup + for i, item := range []struct { + owner string + account Account + }{{"admin-a", a}, {"admin-b", b}} { + conn := []*Store{st, other}[i] + wg.Add(1) + go func() { + defer wg.Done() + <-start + _, err := conn.SetAccountEnabled(item.owner, item.account.ID, item.account.StateVersion, false) + results <- err + }() + } + close(start) + wg.Wait() + close(results) + wins, conflicts := 0, 0 + for err := range results { + if err == nil { + wins++ + } else if errors.Is(err, ErrConflict) { + conflicts++ + } else { + t.Fatal(err) + } + } + if wins != 1 || conflicts != 1 { + t.Fatalf("last-admin race wins=%d conflicts=%d", wins, conflicts) + } + a, _ = st.GetAccount(a.ID) + b, _ = st.GetAccount(b.ID) + if a.Enabled == b.Enabled { + t.Fatalf("must retain exactly one operable manager: a=%v b=%v", a.Enabled, b.Enabled) + } +} + func TestAccountStateProtectsLastOperableSystemManager(t *testing.T) { st := openTestStore(t) hash, err := HashPassword("admin-password-1") diff --git a/internal/store/auth.go b/internal/store/auth.go index c451f7e..484e021 100644 --- a/internal/store/auth.go +++ b/internal/store/auth.go @@ -165,9 +165,14 @@ const sessionTTL = 7 * 24 * time.Hour // AdminUser 管理员用户列表的展示视图(不含哈希)。 type AdminUser struct { + ID string `json:"userId"` Owner string `json:"owner"` Name string `json:"name"` Enabled bool `json:"enabled"` + StateVersion int64 `json:"stateVersion"` + ProfileVersion int64 `json:"profileVersion"` + InitializationState string `json:"initializationState"` + InitializationGeneration int64 `json:"initializationGeneration"` IsAdmin bool `json:"isAdmin"` MustChangePassword bool `json:"mustChangePassword"` HasPassword bool `json:"hasPassword"` @@ -249,15 +254,7 @@ func (s *Store) CreateAccount(owner, name, passwordHash string, isAdmin bool) er // SetUserPassword 设置/重置密码:must_change=1(首登强制改密)。 func (s *Store) SetUserPassword(owner, passwordHash string) error { - res, err := s.db.Exec(s.q(`UPDATE users SET password_hash=?, password_set_at=?, must_change_password=? WHERE owner=?`), - passwordHash, nowISO(), true, owner) - if err != nil { - return err - } - if n, _ := res.RowsAffected(); n == 0 { - return ErrNotFound - } - return nil + return s.AdminResetUserPassword(owner, passwordHash, "legacy-store", "legacy-store") } // ClearMustChangePassword 改密完成后清标志(改密者会话保留,其余吊销由调用方处理)。 @@ -385,7 +382,11 @@ func (s *Store) CreateSessionVerified(owner, scope string, ttl time.Duration, ex var ver int var enabled bool var origin CredentialOrigin - err = tx.QueryRow(s.q(`SELECT auth_version,enabled,credential_origin FROM users WHERE owner=?`), owner).Scan(&ver, &enabled, &origin) + versionQuery := s.q(`SELECT auth_version,enabled,credential_origin FROM users WHERE owner=?`) + if s.Dialect() == "postgres" { + versionQuery += ` FOR UPDATE` + } + err = tx.QueryRow(versionQuery, owner).Scan(&ver, &enabled, &origin) if errors.Is(err, sql.ErrNoRows) { return "", ErrNotFound } @@ -448,7 +449,8 @@ func (s *Store) DeleteUserSessions(owner, exceptToken string) { // ListUsersAdmin 管理员用户列表(按创建序)。 func (s *Store) ListUsersAdmin() ([]AdminUser, error) { - rows, err := s.db.Query(s.q(`SELECT owner,COALESCE(name,''),enabled,password_hash,must_change_password,is_admin,password_set_at,created_at FROM users ORDER BY created_at`)) + rows, err := s.db.Query(s.q(`SELECT id,owner,COALESCE(name,''),enabled,state_version,profile_version,initialization_state,initialization_generation, + password_hash,must_change_password,is_admin,password_set_at,created_at FROM users ORDER BY created_at`)) if err != nil { return nil, err } @@ -457,7 +459,8 @@ func (s *Store) ListUsersAdmin() ([]AdminUser, error) { for rows.Next() { var u AdminUser var hash, psa, ca string - if err := rows.Scan(&u.Owner, &u.Name, &u.Enabled, &hash, &u.MustChangePassword, &u.IsAdmin, &psa, &ca); err != nil { + if err := rows.Scan(&u.ID, &u.Owner, &u.Name, &u.Enabled, &u.StateVersion, &u.ProfileVersion, + &u.InitializationState, &u.InitializationGeneration, &hash, &u.MustChangePassword, &u.IsAdmin, &psa, &ca); err != nil { return nil, err } u.HasPassword = hash != "" diff --git a/internal/store/login_keys.go b/internal/store/login_keys.go index 06ceced..5b76039 100644 --- a/internal/store/login_keys.go +++ b/internal/store/login_keys.go @@ -6,6 +6,8 @@ import ( "encoding/hex" "errors" "fmt" + "os" + "strings" "time" ) @@ -33,12 +35,66 @@ const ( SessionScopeFull = "full" SessionScopeSetPasswordOnly = "set_password_only" - defaultLoginKeyTTL = 30 * time.Minute - minLoginKeyTTL = 5 * time.Minute - maxLoginKeyTTL = 120 * time.Minute - restrictedSessionTTL = 10 * time.Minute + defaultLoginKeyTTL = 30 * time.Minute + minLoginKeyTTL = 5 * time.Minute + maxLoginKeyTTL = 120 * time.Minute + restrictedSessionTTL = 10 * time.Minute + PasswordChangeModeGrantSetup = "grant_setup" + PasswordChangeModeCurrentRequired = "current_required" ) +// SessionPasswordChangeMode is server-derived. A legacy restricted session has +// no mode and therefore cannot enter either password-writing branch. +func (s *Store) SessionPasswordChangeMode(token string) (string, error) { + if token == "" { + return "", ErrNotFound + } + var method, origin, grantID, scope, exp string + var sessVersion, userVersion int + var enabled bool + err := s.db.QueryRow(s.q(`SELECT s.auth_method,s.credential_origin,s.grant_id,s.scope,s.expires_at,s.auth_version,u.auth_version,u.enabled + FROM sessions s JOIN users u ON u.owner=s.owner WHERE s.token_hash=?`), hashKey(token)). + Scan(&method, &origin, &grantID, &scope, &exp, &sessVersion, &userVersion, &enabled) + if errors.Is(err, sql.ErrNoRows) { + return "", ErrNotFound + } + if err != nil { + return "", err + } + expiry, err := time.Parse(time.RFC3339Nano, exp) + if err != nil || time.Now().After(expiry) || !enabled || sessVersion != userVersion { + return "", ErrNotFound + } + if method == AuthMethodPassword || (method == AuthMethodLegacyUnknown && scope == SessionScopeFull) { + return PasswordChangeModeCurrentRequired, nil + } + if method == AuthMethodGrant && scope == SessionScopeSetPasswordOnly && grantID != "" && + (CredentialOrigin(origin) == CredentialOriginOwnerInitialization || CredentialOrigin(origin) == CredentialOriginOwnerRecovery) { + var grantStatus, grantExpiry, grantOrigin, proposalID string + var issuedVersion int + if err := s.db.QueryRow(s.q(`SELECT status,expires_at,grant_origin,proposal_id,issued_auth_version FROM login_keys WHERE id=?`), grantID). + Scan(&grantStatus, &grantExpiry, &grantOrigin, &proposalID, &issuedVersion); err != nil { + return "", ErrAccountForbidden + } + expiry, err := time.Parse(time.RFC3339Nano, grantExpiry) + if err != nil || time.Now().After(expiry) || grantStatus != LoginKeyStatusConsumed || grantOrigin != origin || issuedVersion != userVersion { + return "", ErrAccountForbidden + } + if proposalID != "" { + var proposalStatus, proposalExpiry string + if err := s.db.QueryRow(s.q(`SELECT status,expires_at FROM recovery_proposals WHERE id=?`), proposalID).Scan(&proposalStatus, &proposalExpiry); err != nil { + return "", ErrAccountForbidden + } + expiry, err := time.Parse(time.RFC3339Nano, proposalExpiry) + if err != nil || time.Now().After(expiry) || proposalStatus != RecoveryProposalConfirmed { + return "", ErrAccountForbidden + } + } + return PasswordChangeModeGrantSetup, nil + } + return "", ErrAccountForbidden +} + // LoginKeyMeta 对外可见元数据(无明文)。 type LoginKeyMeta struct { ID string `json:"id"` @@ -50,6 +106,13 @@ type LoginKeyMeta struct { CreatedBy string `json:"createdBy,omitempty"` } +type grantBinding struct { + origin CredentialOrigin + generation int64 + version int + proposalID string +} + // AccountEvent 账户生命周期审计行(不含任何凭据材料)。 type AccountEvent struct { ID string `json:"id"` @@ -131,8 +194,12 @@ func (s *Store) IssueLoginKey(owner, purpose, createdBy string, ttl time.Duratio // insertLoginKeyTx 在既有事务内签发:撤销旧 Key、作废受限会话、插入新 Key、审计。 // (BootstrapFirstAdmin 与建号同事务复用——KTD12:管理员行与激活 Key 共同提交。) -func (s *Store) insertLoginKeyTx(tx *sql.Tx, owner, purpose, createdBy, plain, id string, now, exp time.Time) (LoginKeyMeta, error) { +func (s *Store) insertLoginKeyTx(tx *sql.Tx, owner, purpose, createdBy, plain, id string, now, exp time.Time, bindings ...grantBinding) (LoginKeyMeta, error) { meta := LoginKeyMeta{} + binding := grantBinding{origin: CredentialOriginLegacyUnknown} + if len(bindings) == 1 { + binding = bindings[0] + } if _, err := tx.Exec(s.q(`UPDATE login_keys SET status=? WHERE owner=? AND purpose=? AND status=?`), LoginKeyStatusRevoked, owner, purpose, LoginKeyStatusUnused); err != nil { return meta, err @@ -141,8 +208,10 @@ func (s *Store) insertLoginKeyTx(tx *sql.Tx, owner, purpose, createdBy, plain, i if _, err := tx.Exec(s.q(`DELETE FROM sessions WHERE owner=? AND scope=?`), owner, SessionScopeSetPasswordOnly); err != nil { return meta, err } - if _, err := tx.Exec(s.q(`INSERT INTO login_keys(id,owner,purpose,key_hash,status,expires_at,created_at,created_by) VALUES(?,?,?,?,?,?,?,?)`), - id, owner, purpose, hashKey(plain), LoginKeyStatusUnused, exp.Format(time.RFC3339Nano), now.Format(time.RFC3339Nano), createdBy); err != nil { + if _, err := tx.Exec(s.q(`INSERT INTO login_keys(id,owner,purpose,key_hash,status,expires_at,created_at,created_by,grant_origin,generation,issued_auth_version,proposal_id) + VALUES(?,?,?,?,?,?,?,?,?,?,?,?)`), + id, owner, purpose, hashKey(plain), LoginKeyStatusUnused, exp.Format(time.RFC3339Nano), now.Format(time.RFC3339Nano), createdBy, + binding.origin, binding.generation, binding.version, binding.proposalID); err != nil { return meta, err } if err := s.insertAccountEvent(tx, createdBy, entranceOf(createdBy), owner, "login_key_issued", purpose+" "+id); err != nil { @@ -153,8 +222,10 @@ func (s *Store) insertLoginKeyTx(tx *sql.Tx, owner, purpose, createdBy, plain, i // BootstrapFirstAdmin 首位管理员引导单事务:咨询锁串行(PG)→ 核验库内无用户 → // 建管理员行 + 激活 Key + 审计共同提交;任何用户已存在返回 ErrConflict(拒绝接管)。 +// createdBy 保留旧调用签名,但审计主体总从实际本地 OS UID 取得。 // SQLite 无咨询锁:插入后复查用户数恰为 1,双引导并发时后提交者回滚。 func (s *Store) BootstrapFirstAdmin(owner, name, createdBy string) (plain string, err error) { + createdBy = fmt.Sprintf("local-owner:uid:%d", os.Geteuid()) raw := make([]byte, 32) if _, err := rand.Read(raw); err != nil { return "", err @@ -182,9 +253,9 @@ func (s *Store) BootstrapFirstAdmin(owner, name, createdBy string) (plain string if n > 0 { return "", ErrConflict // 有数据环境拒绝接管(R11) } - if _, 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"), owner, name, nowISO(), "", "", false, true); err != nil { + if _, err := tx.Exec(s.q(`INSERT INTO users(id,owner,name,created_at,password_hash,password_set_at,must_change_password,is_admin,initialization_state,initialization_generation) + VALUES(?,?,?,?,?,?,?,?,?,?)`), + newID("usr"), owner, name, nowISO(), "", "", false, true, InitializationPending, 1); err != nil { if isUniqueViolation(err) { return "", ErrConflict } @@ -197,7 +268,8 @@ func (s *Store) BootstrapFirstAdmin(owner, name, createdBy string) (plain string if n != 1 { return "", ErrConflict } - if _, err := s.insertLoginKeyTx(tx, owner, LoginKeyPurposeActivate, createdBy, plain, id, now, exp); err != nil { + if _, err := s.insertLoginKeyTx(tx, owner, LoginKeyPurposeActivate, createdBy, plain, id, now, exp, + grantBinding{origin: CredentialOriginOwnerInitialization, generation: 1, version: 0}); err != nil { return "", err } if err := s.insertAccountEvent(tx, createdBy, entranceOf(createdBy), owner, "bootstrap_admin", owner); err != nil { @@ -239,6 +311,12 @@ func entranceOf(createdBy string) string { if createdBy == "" { return "api" } + if strings.HasPrefix(createdBy, "local-owner:") { + return "local-owner-cli" + } + if strings.HasPrefix(createdBy, "owner-proof:") { + return "owner-http" + } if createdBy == "cli" || createdBy == "cli-bootstrap" || (len(createdBy) > 4 && createdBy[:4] == "cli-") { return "cli" } @@ -260,7 +338,7 @@ func (s *Store) RevokeLoginKey(id string) error { // RevokeLoginKeyCascade 撤销 Key 并作废该用户全部 set_password_only 会话(管理员 // HTTP 入口)。Key 已消费/过期时仍返回属主并作废会话;编号不存在才报 NotFound。 -func (s *Store) RevokeLoginKeyCascade(id, actor string) (owner string, err error) { +func (s *Store) RevokeLoginKeyCascade(id, actor string, expectedOwner ...string) (owner string, err error) { tx, err := s.db.Begin() if err != nil { return "", err @@ -273,6 +351,9 @@ func (s *Store) RevokeLoginKeyCascade(id, actor string) (owner string, err error if err != nil { return "", err } + if len(expectedOwner) > 0 && expectedOwner[0] != owner { + return "", ErrNotFound + } if _, err := tx.Exec(s.q(`UPDATE login_keys SET status=? WHERE id=? AND status=?`), LoginKeyStatusRevoked, id, LoginKeyStatusUnused); err != nil { return "", err @@ -336,24 +417,75 @@ func (s *Store) RedeemLoginKey(plain string) (token string, owner string, err er if plain == "" || len(plain) < 5 || plain[:4] != "alk_" { return "", "", ErrNotFound } - tx, err := s.db.Begin() - if err != nil { - return "", "", err - } - defer tx.Rollback() - var id, o, status, exp string - err = tx.QueryRow(s.q(`SELECT id,owner,status,expires_at FROM login_keys WHERE key_hash=?`), hashKey(plain)). - Scan(&id, &o, &status, &exp) + // Resolve immutable routing fields before acquiring locks. The transaction + // locks user → proposal → grant, then rechecks the exact grant row. + var routeOwner, routeProposal string + err = s.db.QueryRow(s.q(`SELECT owner,proposal_id FROM login_keys WHERE key_hash=?`), hashKey(plain)).Scan(&routeOwner, &routeProposal) if errors.Is(err, sql.ErrNoRows) { return "", "", ErrNotFound } if err != nil { return "", "", err } + tx, err := s.db.Begin() + if err != nil { + return "", "", err + } + defer tx.Rollback() + locked, err := s.lockAccountUsers(tx, routeOwner) + if err != nil { + return "", "", err + } + account := locked[routeOwner] + if !account.Enabled { + return "", "", ErrAccountForbidden + } + var proposalStatus, proposalOwner, proposalExp string + var proposalGeneration int64 + var proposalAuthVersion int + if routeProposal != "" { + proposalQuery := s.q(`SELECT owner,status,generation,expires_at,issued_auth_version FROM recovery_proposals WHERE id=?`) + if s.Dialect() == "postgres" { + proposalQuery += ` FOR UPDATE` + } + if err := tx.QueryRow(proposalQuery, routeProposal).Scan(&proposalOwner, &proposalStatus, &proposalGeneration, &proposalExp, &proposalAuthVersion); err != nil { + return "", "", err + } + } + var id, o, status, exp, purpose, grantOrigin, proposalID string + var generation int64 + var issuedVersion int + grantQuery := s.q(`SELECT id,owner,status,expires_at,purpose,grant_origin,generation,issued_auth_version,proposal_id FROM login_keys WHERE key_hash=?`) + if s.Dialect() == "postgres" { + grantQuery += ` FOR UPDATE` + } + err = tx.QueryRow(grantQuery, hashKey(plain)).Scan(&id, &o, &status, &exp, &purpose, &grantOrigin, &generation, &issuedVersion, &proposalID) + if errors.Is(err, sql.ErrNoRows) { + return "", "", ErrNotFound + } + if err != nil { + return "", "", err + } + if o != routeOwner || proposalID != routeProposal || issuedVersion != account.AuthVersion { + return "", "", ErrConflict + } + switch { + case purpose == LoginKeyPurposeActivate && grantOrigin == string(CredentialOriginOwnerInitialization): + if proposalID != "" || account.InitializationState != InitializationPending || account.InitializationGeneration != generation || account.HasPassword { + return "", "", ErrAccountForbidden + } + case purpose == LoginKeyPurposeRecover && grantOrigin == string(CredentialOriginOwnerRecovery): + proposalExpiry, perr := time.Parse(time.RFC3339Nano, proposalExp) + if proposalID == "" || proposalOwner != o || proposalStatus != "confirmed" || proposalGeneration != generation || proposalAuthVersion != account.AuthVersion || + perr != nil || time.Now().After(proposalExpiry) { + return "", "", ErrAccountForbidden + } + default: + // Migrated administrator-issued keys have legacy_unknown provenance. + return "", "", ErrAccountForbidden + } expT, err := time.Parse(time.RFC3339Nano, exp) if err != nil || time.Now().After(expT) { - _, _ = tx.Exec(s.q(`UPDATE login_keys SET status=? WHERE id=? AND status=?`), LoginKeyStatusExpired, id, LoginKeyStatusUnused) - _ = tx.Commit() return "", "", ErrNotFound } if status != LoginKeyStatusUnused { @@ -367,23 +499,14 @@ func (s *Store) RedeemLoginKey(plain string) (token string, owner string, err er if n, _ := res.RowsAffected(); n != 1 { return "", "", ErrConflict } - var authVersion int - var enabled bool - err = tx.QueryRow(s.q(`SELECT auth_version,enabled FROM users WHERE owner=?`), o).Scan(&authVersion, &enabled) - if err != nil { - return "", "", err // 账户已不存在:兑换失败整体回滚 - } - if !enabled { - return "", "", ErrAccountForbidden - } raw := make([]byte, 32) if _, err := rand.Read(raw); err != nil { return "", "", err } token = "sess_" + hex.EncodeToString(raw) sessExp := time.Now().Add(restrictedSessionTTL) - if _, err := tx.Exec(s.q(`INSERT INTO sessions(token_hash,owner,created_at,expires_at,last_seen_at,scope,auth_version) VALUES(?,?,?,?,?,?,?)`), - hashKey(token), o, nowISO(), sessExp.UTC().Format(time.RFC3339Nano), nowISO(), SessionScopeSetPasswordOnly, authVersion); err != nil { + if _, err := tx.Exec(s.q(`INSERT INTO sessions(token_hash,owner,created_at,expires_at,last_seen_at,scope,auth_version,auth_method,credential_origin,grant_id) VALUES(?,?,?,?,?,?,?,?,?,?)`), + hashKey(token), o, nowISO(), sessExp.UTC().Format(time.RFC3339Nano), nowISO(), SessionScopeSetPasswordOnly, account.AuthVersion, AuthMethodGrant, grantOrigin, id); err != nil { return "", "", err } if err := s.insertAccountEvent(tx, o, "login-key", o, "login_key_redeemed", id); err != nil { @@ -395,51 +518,102 @@ func (s *Store) RedeemLoginKey(plain string) (token string, owner string, err er return token, o, nil } -// CompleteSetPasswordChecked 设密事务(恢复/强制路径):在最终事务内复验会话 -// (存在、未过期、scope 受限或账户仍处强制态、auth_version 未变),然后统一 -// 写 hash、清 must_change、bump auth_version、作废未用登录 Key、删除全部会话、 -// 写成功审计。会话或版本失效时返回 ErrConflict,不做任何写入。 +// CompleteSetPasswordChecked accepts only a restricted session derived from a +// trusted owner initialization or owner-confirmed recovery grant. Historical +// key sessions and password-authenticated forced sessions cannot use it. func (s *Store) CompleteSetPasswordChecked(token, passwordHash string) (owner string, err error) { - tx, err := s.db.Begin() - if err != nil { - return "", err - } - defer tx.Rollback() - var scope, sessExp string - var sessVer, userVer int - var mustChange, enabled bool - var hash sql.NullString - err = tx.QueryRow(s.q(`SELECT s.owner,s.scope,s.expires_at,s.auth_version,u.auth_version,u.must_change_password,u.password_hash,u.enabled - FROM sessions s JOIN users u ON u.owner=s.owner WHERE s.token_hash=?`), hashKey(token)). - Scan(&owner, &scope, &sessExp, &sessVer, &userVer, &mustChange, &hash, &enabled) + var routeGrant string + err = s.db.QueryRow(s.q(`SELECT owner,grant_id FROM sessions WHERE token_hash=?`), hashKey(token)).Scan(&owner, &routeGrant) if errors.Is(err, sql.ErrNoRows) { return "", ErrNotFound } if err != nil { return "", err } - if !enabled { + if routeGrant == "" { return "", ErrAccountForbidden } - if expT, perr := time.Parse(time.RFC3339Nano, sessExp); perr != nil || time.Now().After(expT) { - return "", ErrNotFound + var routeProposal string + if err := s.db.QueryRow(s.q(`SELECT proposal_id FROM login_keys WHERE id=? AND owner=?`), routeGrant, owner).Scan(&routeProposal); err != nil { + return "", ErrAccountForbidden } - // 受限会话、待改密、未设密三者之一才允许免旧密码设密;其余按主动改密路径走。 - if scope != SessionScopeSetPasswordOnly && !mustChange && (hash.Valid && hash.String != "") { - return "", ErrConflict - } - // 会话创建后账户凭据已变(并发改密/重置):本次授权过期,拒绝继续。 - if sessVer != userVer { - return "", ErrConflict - } - res, err := tx.Exec(s.q(`UPDATE users SET password_hash=?, password_set_at=?, must_change_password=?, auth_version=auth_version+1 WHERE owner=?`), - passwordHash, nowISO(), false, owner) + tx, err := s.db.Begin() if err != nil { return "", err } - if n, _ := res.RowsAffected(); n == 0 { + defer tx.Rollback() + accounts, err := s.lockAccountUsers(tx, owner) + if err != nil { + return "", err + } + account := accounts[owner] + if !account.Enabled { + return "", ErrAccountForbidden + } + var proposalStatus, proposalOwner, proposalExpiry string + var proposalGeneration int64 + var proposalVersion int + if routeProposal != "" { + query := s.q(`SELECT owner,status,generation,expires_at,issued_auth_version FROM recovery_proposals WHERE id=?`) + if s.Dialect() == "postgres" { + query += ` FOR UPDATE` + } + if err := tx.QueryRow(query, routeProposal).Scan(&proposalOwner, &proposalStatus, &proposalGeneration, &proposalExpiry, &proposalVersion); err != nil { + return "", ErrAccountForbidden + } + } + grantQuery := s.q(`SELECT owner,purpose,status,expires_at,grant_origin,generation,issued_auth_version,proposal_id FROM login_keys WHERE id=?`) + if s.Dialect() == "postgres" { + grantQuery += ` FOR UPDATE` + } + var grantOwner, purpose, status, grantExpiry, origin, proposalID string + var generation int64 + var issuedVersion int + if err := tx.QueryRow(grantQuery, routeGrant).Scan(&grantOwner, &purpose, &status, &grantExpiry, &origin, &generation, &issuedVersion, &proposalID); err != nil { + return "", ErrAccountForbidden + } + sessionQuery := s.q(`SELECT owner,scope,expires_at,auth_version,auth_method,credential_origin,grant_id FROM sessions WHERE token_hash=?`) + if s.Dialect() == "postgres" { + sessionQuery += ` FOR UPDATE` + } + var sessionOwner, scope, sessionExpiry, method, sessionOrigin, grantID string + var sessionVersion int + if err := tx.QueryRow(sessionQuery, hashKey(token)).Scan(&sessionOwner, &scope, &sessionExpiry, &sessionVersion, &method, &sessionOrigin, &grantID); err != nil { return "", ErrNotFound } + grantExpires, err := time.Parse(time.RFC3339Nano, grantExpiry) + if err != nil || time.Now().After(grantExpires) { + return "", ErrAccountForbidden + } + sessionExpires, err := time.Parse(time.RFC3339Nano, sessionExpiry) + if err != nil || time.Now().After(sessionExpires) || sessionOwner != owner || scope != SessionScopeSetPasswordOnly || method != AuthMethodGrant || + grantID != routeGrant || sessionVersion != account.AuthVersion || issuedVersion != account.AuthVersion || + grantOwner != owner || status != LoginKeyStatusConsumed || sessionOrigin != origin || proposalID != routeProposal { + return "", ErrAccountForbidden + } + switch { + case purpose == LoginKeyPurposeActivate && origin == string(CredentialOriginOwnerInitialization): + if routeProposal != "" || account.InitializationState != InitializationPending || account.InitializationGeneration != generation || account.HasPassword { + return "", ErrAccountForbidden + } + case purpose == LoginKeyPurposeRecover && origin == string(CredentialOriginOwnerRecovery): + expires, perr := time.Parse(time.RFC3339Nano, proposalExpiry) + if routeProposal == "" || proposalOwner != owner || proposalStatus != "confirmed" || proposalGeneration != generation || + proposalVersion != account.AuthVersion || perr != nil || time.Now().After(expires) || !account.HasPassword { + return "", ErrAccountForbidden + } + default: + return "", ErrAccountForbidden + } + res, err := tx.Exec(s.q(`UPDATE users SET password_hash=?,password_set_at=?,must_change_password=?,auth_version=auth_version+1, + credential_origin=?,initialization_state=?,state_version=state_version+1 WHERE owner=? AND auth_version=? AND enabled=?`), + passwordHash, nowISO(), false, origin, InitializationInitialized, owner, account.AuthVersion, true) + if err != nil { + return "", err + } + if n, err := res.RowsAffected(); err != nil || n != 1 { + return "", ErrConflict + } if _, err := tx.Exec(s.q(`UPDATE login_keys SET status=? WHERE owner=? AND status=?`), LoginKeyStatusRevoked, owner, LoginKeyStatusUnused); err != nil { return "", err @@ -447,11 +621,83 @@ func (s *Store) CompleteSetPasswordChecked(token, passwordHash string) (owner st if _, err := tx.Exec(s.q(`DELETE FROM sessions WHERE owner=?`), owner); err != nil { return "", err } - entrance := "must-change" - if scope == SessionScopeSetPasswordOnly { - entrance = "recovery" + if routeProposal != "" { + if _, err := tx.Exec(s.q(`UPDATE recovery_proposals SET status='completed',version=version+1 WHERE id=? AND status='confirmed'`), routeProposal); err != nil { + return "", err + } } - if err := s.insertAccountEvent(tx, owner, entrance, owner, "password_set", entrance); err != nil { + if _, err := tx.Exec(s.q(`UPDATE recovery_proposals SET status='revoked',version=version+1 WHERE owner=? AND id<>? AND status IN ('pending','confirmed')`), owner, routeProposal); err != nil { + return "", err + } + if err := s.insertAccountEvent(tx, owner, "trusted-grant", owner, "password_set", origin); err != nil { + return "", err + } + return owner, tx.Commit() +} + +// CompleteForcedPasswordChecked preserves the provenance of a historical +// password-authenticated account. It requires the current password even when +// the session is restricted by must_change_password. +func (s *Store) CompleteForcedPasswordChecked(token, current, passwordHash string) (string, error) { + var owner string + if err := s.db.QueryRow(s.q(`SELECT owner FROM sessions WHERE token_hash=?`), hashKey(token)).Scan(&owner); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return "", ErrNotFound + } + return "", err + } + tx, err := s.db.Begin() + if err != nil { + return "", err + } + defer tx.Rollback() + accounts, err := s.lockAccountUsers(tx, owner) + if err != nil { + return "", err + } + account := accounts[owner] + if !account.Enabled || !account.MustChangePassword || !account.HasPassword { + return "", ErrAccountForbidden + } + var hash string + if err := tx.QueryRow(s.q(`SELECT password_hash FROM users WHERE owner=?`), owner).Scan(&hash); err != nil { + return "", err + } + query := s.q(`SELECT scope,auth_method,expires_at,auth_version FROM sessions WHERE token_hash=? AND owner=?`) + if s.Dialect() == "postgres" { + query += ` FOR UPDATE` + } + var scope, method, expiresAt string + var version int + if err := tx.QueryRow(query, hashKey(token), owner).Scan(&scope, &method, &expiresAt, &version); err != nil { + return "", ErrNotFound + } + expiry, err := time.Parse(time.RFC3339Nano, expiresAt) + if err != nil || time.Now().After(expiry) || version != account.AuthVersion || scope != SessionScopeSetPasswordOnly || method != AuthMethodPassword { + return "", ErrAccountForbidden + } + if !VerifyPassword(hash, current) { + return "", ErrBadCredentials + } + res, err := tx.Exec(s.q(`UPDATE users SET password_hash=?,password_set_at=?,must_change_password=?,auth_version=auth_version+1 + WHERE owner=? AND auth_version=? AND enabled=? AND must_change_password=?`), + passwordHash, nowISO(), false, owner, account.AuthVersion, true, true) + if err != nil { + return "", err + } + if n, err := res.RowsAffected(); err != nil || n != 1 { + return "", ErrConflict + } + if _, err := tx.Exec(s.q(`UPDATE login_keys SET status=? WHERE owner=? AND status=?`), LoginKeyStatusRevoked, owner, LoginKeyStatusUnused); err != nil { + return "", err + } + if _, err := tx.Exec(s.q(`UPDATE recovery_proposals SET status='revoked',version=version+1 WHERE owner=? AND status IN ('pending','confirmed')`), owner); err != nil { + return "", err + } + if _, err := tx.Exec(s.q(`DELETE FROM sessions WHERE owner=?`), owner); err != nil { + return "", err + } + if err := s.insertAccountEvent(tx, owner, "password-session", owner, "password_change_forced", ""); err != nil { return "", err } return owner, tx.Commit() @@ -468,7 +714,11 @@ func (s *Store) ChangeUserPasswordActive(owner, current, passwordHash, keepToken var hash string var ver int var mustChange, enabled bool - err = tx.QueryRow(s.q(`SELECT password_hash,auth_version,must_change_password,enabled FROM users WHERE owner=?`), owner). + accountQuery := s.q(`SELECT password_hash,auth_version,must_change_password,enabled FROM users WHERE owner=?`) + if s.Dialect() == "postgres" { + accountQuery += ` FOR UPDATE` + } + err = tx.QueryRow(accountQuery, owner). Scan(&hash, &ver, &mustChange, &enabled) if errors.Is(err, sql.ErrNoRows) { return ErrNotFound @@ -510,6 +760,12 @@ func (s *Store) ChangeUserPasswordActive(owner, current, passwordHash, keepToken } else if _, err := tx.Exec(s.q(`DELETE FROM sessions WHERE owner=?`), owner); err != nil { return err } + if _, err := tx.Exec(s.q(`UPDATE login_keys SET status=? WHERE owner=? AND status=?`), LoginKeyStatusRevoked, owner, LoginKeyStatusUnused); err != nil { + return err + } + if _, err := tx.Exec(s.q(`UPDATE recovery_proposals SET status='revoked',version=version+1 WHERE owner=? AND status IN ('pending','confirmed')`), owner); err != nil { + return err + } if err := s.insertAccountEvent(tx, owner, "settings", owner, "password_change", ""); err != nil { return err } @@ -524,18 +780,29 @@ func (s *Store) AdminResetUserPassword(owner, passwordHash, actor, entrance stri return err } defer tx.Rollback() - res, err := tx.Exec(s.q(`UPDATE users SET password_hash=?, password_set_at=?, must_change_password=?, auth_version=auth_version+1 WHERE owner=?`), - passwordHash, nowISO(), true, owner) + locked, err := s.lockAccountUsers(tx, owner) + if err != nil { + return err + } + if !locked[owner].Enabled { + return ErrAccountForbidden + } + res, err := tx.Exec(s.q(`UPDATE users SET password_hash=?, password_set_at=?, must_change_password=?, auth_version=auth_version+1, + credential_origin=?,initialization_state=? WHERE owner=? AND auth_version=? AND enabled=?`), + passwordHash, nowISO(), true, CredentialOriginAdminPassword, InitializationInitialized, owner, locked[owner].AuthVersion, true) if err != nil { return err } if n, _ := res.RowsAffected(); n == 0 { - return ErrNotFound + return ErrConflict } if _, err := tx.Exec(s.q(`UPDATE login_keys SET status=? WHERE owner=? AND status=?`), LoginKeyStatusRevoked, owner, LoginKeyStatusUnused); err != nil { return err } + if _, err := tx.Exec(s.q(`UPDATE recovery_proposals SET status='revoked',version=version+1 WHERE owner=? AND status IN ('pending','confirmed')`), owner); err != nil { + return err + } if _, err := tx.Exec(s.q(`DELETE FROM sessions WHERE owner=?`), owner); err != nil { return err } diff --git a/internal/store/login_keys_test.go b/internal/store/login_keys_test.go index 94f0974..406db21 100644 --- a/internal/store/login_keys_test.go +++ b/internal/store/login_keys_test.go @@ -15,18 +15,32 @@ func TestOpenExistingSQLiteMissing(t *testing.T) { } } +func pendingActivationFixture(t *testing.T, st *Store, owner string) (Account, string) { + t.Helper() + if err := st.CreateAccount("admin", "", "admin-hash", true); err != nil { + t.Fatal(err) + } + account, err := st.CreatePendingAccount("admin", owner, AccountProfile{}) + if err != nil { + t.Fatal(err) + } + plain, err := st.InitializePendingAccount(account.ID, account.StateVersion, account.InitializationGeneration) + if err != nil { + t.Fatal(err) + } + return account, plain +} + func TestLoginKeyRedeemOnceAndSetPassword(t *testing.T) { st, err := OpenSQLite(filepath.Join(t.TempDir(), "t.db")) if err != nil { t.Fatal(err) } defer st.Close() - if err := st.CreateAccount("alice", "Alice", "", false); err != nil { - t.Fatal(err) - } - plain, meta, err := st.IssueLoginKey("alice", LoginKeyPurposeActivate, "test", 0) - if err != nil || plain == "" || meta.ID == "" { - t.Fatalf("issue: %v meta=%+v", err, meta) + _, plain := pendingActivationFixture(t, st, "alice") + keys, err := st.ListLoginKeys("alice") + if err != nil || plain == "" || len(keys) != 1 || keys[0].ID == "" { + t.Fatalf("issue: %v keys=%+v", err, keys) } tok, owner, err := st.RedeemLoginKey(plain) if err != nil || owner != "alice" || tok == "" { @@ -72,13 +86,7 @@ func TestLoginKeyConcurrentRedeem(t *testing.T) { t.Fatal(err) } defer st.Close() - if err := st.CreateAccount("bob", "", "", false); err != nil { - t.Fatal(err) - } - plain, _, err := st.IssueLoginKey("bob", LoginKeyPurposeRecover, "test", 0) - if err != nil { - t.Fatal(err) - } + _, plain := pendingActivationFixture(t, st, "bob") var okCount atomic.Int32 var wg sync.WaitGroup for i := 0; i < 100; i++ { @@ -103,18 +111,16 @@ func TestIssueLoginKeyRevokesDerivedSessions(t *testing.T) { t.Fatal(err) } defer st.Close() - if err := st.CreateAccount("carol", "", "", false); err != nil { - t.Fatal(err) - } - old1, _, err := st.IssueLoginKey("carol", LoginKeyPurposeRecover, "test", 0) - if err != nil { - t.Fatal(err) - } + account, old1 := pendingActivationFixture(t, st, "carol") oldTok, _, err := st.RedeemLoginKey(old1) if err != nil { t.Fatal(err) } - old2, _, err := st.IssueLoginKey("carol", LoginKeyPurposeRecover, "test", 0) + account, err = st.GetAccount(account.ID) + if err != nil { + t.Fatal(err) + } + old2, err := st.InitializePendingAccount(account.ID, account.StateVersion, account.InitializationGeneration) if err != nil { t.Fatal(err) } @@ -123,7 +129,11 @@ func TestIssueLoginKeyRevokesDerivedSessions(t *testing.T) { t.Fatal("restricted session from superseded key should be revoked") } // 旧 Key 未用场景同样撤销(old1 已消费,此处验证 old2 补发路径)。 - new1, _, err := st.IssueLoginKey("carol", LoginKeyPurposeRecover, "test", 0) + account, err = st.GetAccount(account.ID) + if err != nil { + t.Fatal(err) + } + new1, err := st.InitializePendingAccount(account.ID, account.StateVersion, account.InitializationGeneration) if err != nil { t.Fatal(err) } @@ -150,19 +160,17 @@ func TestCompleteSetPasswordRevalidatesSession(t *testing.T) { t.Fatal(err) } defer st.Close() - if err := st.CreateAccount("dave", "", "", false); err != nil { - t.Fatal(err) - } - plain, _, err := st.IssueLoginKey("dave", LoginKeyPurposeRecover, "test", 0) - if err != nil { - t.Fatal(err) - } + account, plain := pendingActivationFixture(t, st, "dave") tok, _, err := st.RedeemLoginKey(plain) if err != nil { t.Fatal(err) } // 会话被签发补发作废后,设密必须失败。 - if _, _, err := st.IssueLoginKey("dave", LoginKeyPurposeRecover, "test", 0); err != nil { + account, err = st.GetAccount(account.ID) + if err != nil { + t.Fatal(err) + } + if _, err := st.InitializePendingAccount(account.ID, account.StateVersion, account.InitializationGeneration); err != nil { t.Fatal(err) } hash, err := HashPassword("dave-final-99") @@ -177,7 +185,11 @@ func TestCompleteSetPasswordRevalidatesSession(t *testing.T) { t.Fatal("password must be unchanged after rejected set") } // 新 Key 兑换出的会话可以设密,且版本前进、旧密码不适用(未设密前无旧密码)。 - plain2, _, err := st.IssueLoginKey("dave", LoginKeyPurposeRecover, "test", 0) + account, err = st.GetAccount(account.ID) + if err != nil { + t.Fatal(err) + } + plain2, err := st.InitializePendingAccount(account.ID, account.StateVersion, account.InitializationGeneration) if err != nil { t.Fatal(err) } @@ -338,18 +350,20 @@ func TestRevokeLoginKeyCascade(t *testing.T) { t.Fatal(err) } defer st.Close() - if err := st.CreateAccount("hana", "", "", false); err != nil { - t.Fatal(err) - } - plain, meta, err := st.IssueLoginKey("hana", LoginKeyPurposeRecover, "test", 0) - if err != nil { + _, plain := pendingActivationFixture(t, st, "hana") + issued, err := st.ListLoginKeys("hana") + if err != nil || len(issued) != 1 { t.Fatal(err) } + meta := issued[0] tok, _, err := st.RedeemLoginKey(plain) if err != nil { t.Fatal(err) } - if _, err := st.RevokeLoginKeyCascade(meta.ID, "admin"); err != nil { + if _, err := st.RevokeLoginKeyCascade(meta.ID, "admin", "other-owner"); err == nil { + t.Fatal("path owner mismatch revoked another user's grant") + } + if _, err := st.RevokeLoginKeyCascade(meta.ID, "admin", "hana"); err != nil { t.Fatal(err) } if _, _, ok := st.SessionInfo(tok); ok { diff --git a/internal/store/migrate.go b/internal/store/migrate.go index 46be44b..e0951c9 100644 --- a/internal/store/migrate.go +++ b/internal/store/migrate.go @@ -855,9 +855,42 @@ func init() { }, down: map[string][]string{"sqlite": accountFoundationDown, "postgres": accountFoundationDown}, }, + migration{version: 32, name: "owner_proof_recovery", + up: map[string][]string{"sqlite": recoveryFoundationUp, "postgres": recoveryFoundationUp}, + down: map[string][]string{"sqlite": recoveryFoundationDown, "postgres": recoveryFoundationDown}, + }, ) } +var recoveryFoundationUp = []string{ + `ALTER TABLE login_keys ADD COLUMN grant_origin TEXT NOT NULL DEFAULT 'legacy_unknown'`, + `ALTER TABLE login_keys ADD COLUMN generation BIGINT NOT NULL DEFAULT 0`, + `ALTER TABLE login_keys ADD COLUMN issued_auth_version INTEGER NOT NULL DEFAULT 0`, + `ALTER TABLE login_keys ADD COLUMN proposal_id TEXT NOT NULL DEFAULT ''`, + `CREATE TABLE recovery_proposals ( + id TEXT PRIMARY KEY,user_id TEXT NOT NULL,owner TEXT NOT NULL,status TEXT NOT NULL, + version BIGINT NOT NULL DEFAULT 1,generation BIGINT NOT NULL DEFAULT 0, + created_by TEXT NOT NULL,created_at TEXT NOT NULL,expires_at TEXT NOT NULL, + issued_auth_version INTEGER NOT NULL,idempotency_key TEXT NOT NULL DEFAULT '' + )`, + `CREATE INDEX idx_recovery_proposals_owner_status ON recovery_proposals(owner,status)`, + `CREATE UNIQUE INDEX uq_recovery_proposals_actor_idempotency ON recovery_proposals(created_by,idempotency_key) WHERE idempotency_key<>''`, + `CREATE TABLE recovery_proposal_proofs ( + proposal_id TEXT NOT NULL,session_hash TEXT NOT NULL,PRIMARY KEY(proposal_id,session_hash) + )`, +} + +var recoveryFoundationDown = []string{ + `DROP TABLE recovery_proposal_proofs`, + `DROP INDEX uq_recovery_proposals_actor_idempotency`, + `DROP INDEX idx_recovery_proposals_owner_status`, + `DROP TABLE recovery_proposals`, + `ALTER TABLE login_keys DROP COLUMN proposal_id`, + `ALTER TABLE login_keys DROP COLUMN issued_auth_version`, + `ALTER TABLE login_keys DROP COLUMN generation`, + `ALTER TABLE login_keys DROP COLUMN grant_origin`, +} + // v31 preserves legacy identities and credentials. Only the explicit pending // account primitive sets pending initialization; migration cannot infer proof. func accountFoundationUp(enabledType, revisionExpression string) []string { diff --git a/internal/store/migrate_compat.go b/internal/store/migrate_compat.go index 545ff6b..c1cf23f 100644 --- a/internal/store/migrate_compat.go +++ b/internal/store/migrate_compat.go @@ -36,6 +36,7 @@ var migrationBreaksOldReaders = map[int]bool{ 29: false, // redeem_attempts:新表,旧读者不受影响 30: false, // project_event_publication_proofs:新表,旧读者不受影响 31: true, // 账号有效态/凭据来源是安全协议边界;即使旧 SQL 可读,也不得恢复旧认证写者 + 32: true, // 本人证明与 grant 绑定协议;旧恢复写者和兑换者必须排除 } // SchemaTarget 是本二进制会迁移到的 schema 版本。 diff --git a/internal/store/owner_initialization.go b/internal/store/owner_initialization.go new file mode 100644 index 0000000..8f09eca --- /dev/null +++ b/internal/store/owner_initialization.go @@ -0,0 +1,137 @@ +package store + +import ( + "crypto/rand" + "encoding/hex" + "fmt" + "os" + "time" +) + +// InitializePendingAccount is reserved for the local machine/DB owner CLI. It +// accepts a stable user ID and expected versions, never an actor string from a +// request. The audit actor is the actual local OS UID. The caller must use the +// protected delivery channel; a failed delivery is retried with the same ID. +func (s *Store) InitializePendingAccount(userID string, expectedStateVersion, expectedGeneration int64) (string, error) { + if userID == "" || expectedStateVersion < 1 || expectedGeneration < 0 { + return "", ErrConflict + } + pre, err := s.GetAccount(userID) + if err != nil { + return "", err + } + var raw [32]byte + if _, err := rand.Read(raw[:]); err != nil { + return "", err + } + plain := "alk_" + hex.EncodeToString(raw[:]) + operator := fmt.Sprintf("local-owner:uid:%d", os.Geteuid()) + now := time.Now().UTC() + tx, err := s.db.Begin() + if err != nil { + return "", err + } + defer tx.Rollback() + locked, err := s.lockAccountUsers(tx, pre.Owner) + if err != nil { + return "", err + } + account := locked[pre.Owner] + if account.ID != userID || account.StateVersion != expectedStateVersion || account.InitializationGeneration != expectedGeneration { + return "", ErrConflict + } + if !account.Enabled || account.InitializationState != InitializationPending || account.HasPassword || account.CredentialOrigin != CredentialOriginLegacyUnknown { + return "", ErrAccountForbidden + } + var unsafeCreds int + err = tx.QueryRow(s.q(`SELECT COUNT(1) FROM api_keys WHERE owner=? AND revoked=0`), account.Owner).Scan(&unsafeCreds) + if err != nil { + return "", err + } + if unsafeCreds != 0 { + return "", ErrAccountForbidden + } + err = tx.QueryRow(s.q(`SELECT COUNT(1) FROM sessions WHERE owner=? AND scope=?`), account.Owner, SessionScopeFull).Scan(&unsafeCreds) + if err != nil { + return "", err + } + if unsafeCreds != 0 { + return "", ErrAccountForbidden + } + err = tx.QueryRow(s.q(`SELECT COUNT(1) FROM login_keys WHERE owner=? AND grant_origin<>?`), account.Owner, CredentialOriginOwnerInitialization).Scan(&unsafeCreds) + if err != nil { + return "", err + } + if unsafeCreds != 0 { + return "", ErrAccountForbidden + } + nextGeneration := account.InitializationGeneration + 1 + nextAuthVersion := account.AuthVersion + 1 + result, err := tx.Exec(s.q(`UPDATE users SET initialization_generation=?,state_version=state_version+1,auth_version=auth_version+1 + WHERE id=? AND state_version=? AND initialization_generation=? AND initialization_state=? AND password_hash='' AND enabled=?`), + nextGeneration, userID, expectedStateVersion, expectedGeneration, InitializationPending, true) + if err != nil { + return "", err + } + if n, err := result.RowsAffected(); err != nil || n != 1 { + return "", ErrConflict + } + if _, err := s.insertLoginKeyTx(tx, account.Owner, LoginKeyPurposeActivate, operator, plain, newID("lk"), now, now.Add(defaultLoginKeyTTL), + grantBinding{origin: CredentialOriginOwnerInitialization, generation: nextGeneration, version: nextAuthVersion}); err != nil { + return "", err + } + if err := s.insertAccountEvent(tx, operator, "local-owner-cli", account.Owner, "initialization_issued", fmt.Sprintf("user_id=%s generation=%d", userID, nextGeneration)); err != nil { + return "", err + } + if err := tx.Commit(); err != nil { + return "", err + } + return plain, nil +} + +// CreateLocalPendingAccount is the local owner-only new-account path. Account +// and first activation grant commit together, including a local administrator +// flag when explicitly requested by the machine/DB owner CLI. +func (s *Store) CreateLocalPendingAccount(owner, name string, isAdmin bool) (Account, string, error) { + operator := fmt.Sprintf("local-owner:uid:%d", os.Geteuid()) + var raw [32]byte + if _, err := rand.Read(raw[:]); err != nil { + return Account{}, "", err + } + plain := "alk_" + hex.EncodeToString(raw[:]) + now := time.Now().UTC() + tx, err := s.db.Begin() + if err != nil { + return Account{}, "", err + } + defer tx.Rollback() + if err := s.dialect.lockBootstrap(tx); err != nil { + return Account{}, "", err + } + var users int + if err := tx.QueryRow(`SELECT COUNT(1) FROM users`).Scan(&users); err != nil { + return Account{}, "", err + } + if users == 0 { + return Account{}, "", ErrConflict // first administrator requires BootstrapFirstAdmin + } + account, err := s.insertPendingAccountTx(tx, owner, AccountProfile{Name: name}) + if err != nil { + return Account{}, "", err + } + if _, err := tx.Exec(s.q(`UPDATE users SET is_admin=?,initialization_generation=? WHERE id=?`), isAdmin, 1, account.ID); err != nil { + return Account{}, "", err + } + if _, err := s.insertLoginKeyTx(tx, owner, LoginKeyPurposeActivate, operator, plain, newID("lk"), now, now.Add(defaultLoginKeyTTL), + grantBinding{origin: CredentialOriginOwnerInitialization, generation: 1, version: 0}); err != nil { + return Account{}, "", err + } + if err := s.insertAccountEvent(tx, operator, "local-owner-cli", owner, "local_account_created", account.ID); err != nil { + return Account{}, "", err + } + if err := tx.Commit(); err != nil { + return Account{}, "", err + } + account.IsAdmin, account.InitializationGeneration = isAdmin, 1 + return account, plain, nil +} diff --git a/internal/store/owner_initialization_test.go b/internal/store/owner_initialization_test.go new file mode 100644 index 0000000..8d7189d --- /dev/null +++ b/internal/store/owner_initialization_test.go @@ -0,0 +1,56 @@ +package store + +import ( + "errors" + "testing" +) + +func TestOwnerInitializationReissuesSamePendingIdentityAndClosesAfterCompletion(t *testing.T) { + st := openTestStore(t) + t.Logf("owner initialization dialect=%s", st.Dialect()) + if err := st.CreateAccount("admin", "", "admin-hash", true); err != nil { + t.Fatal(err) + } + pending, err := st.CreatePendingAccount("admin", "alice", AccountProfile{Name: "Alice"}) + if err != nil { + t.Fatal(err) + } + first, err := st.InitializePendingAccount(pending.ID, pending.StateVersion, pending.InitializationGeneration) + if err != nil || first == "" { + t.Fatalf("initialize pending: %v", err) + } + firstSession, owner, err := st.RedeemLoginKey(first) + if err != nil || owner != "alice" { + t.Fatalf("redeem first activation: %v %q", err, owner) + } + stillPending, err := st.GetAccount(pending.ID) + if err != nil { + t.Fatal(err) + } + second, err := st.InitializePendingAccount(pending.ID, stillPending.StateVersion, stillPending.InitializationGeneration) + if err != nil || second == first { + t.Fatalf("reissue: %v", err) + } + if _, _, ok := st.SessionInfo(firstSession); ok { + t.Fatal("reissue left old restricted session active") + } + secondSession, owner, err := st.RedeemLoginKey(second) + if err != nil || owner != "alice" { + t.Fatalf("redeem second activation: %v %q", err, owner) + } + hash, err := HashPassword("alice-initialized-password") + if err != nil { + t.Fatal(err) + } + if _, err := st.CompleteSetPasswordChecked(secondSession, hash); err != nil { + t.Fatalf("complete initial password: %v", err) + } + completed, err := st.GetAccount(pending.ID) + if err != nil || completed.ID != pending.ID || completed.Owner != pending.Owner || completed.InitializationState != InitializationInitialized || + completed.CredentialOrigin != CredentialOriginOwnerInitialization || !completed.HasPassword { + t.Fatalf("completion changed identity or lost trusted lineage: %+v %v", completed, err) + } + if _, err := st.InitializePendingAccount(pending.ID, completed.StateVersion, completed.InitializationGeneration); !errors.Is(err, ErrAccountForbidden) { + t.Fatalf("completed account could be initialized again: %v", err) + } +} diff --git a/internal/store/recovery_proposals.go b/internal/store/recovery_proposals.go new file mode 100644 index 0000000..58efd41 --- /dev/null +++ b/internal/store/recovery_proposals.go @@ -0,0 +1,438 @@ +package store + +import ( + "crypto/rand" + "database/sql" + "encoding/hex" + "errors" + "fmt" + "strings" + "time" +) + +var ErrOwnerProofUnavailable = errors.New("store: no eligible preexisting owner proof") + +const ( + RecoveryProposalPending = "pending" + RecoveryProposalConfirmed = "confirmed" + RecoveryProposalCompleted = "completed" + RecoveryProposalRevoked = "revoked" + RecoveryProposalExpired = "expired" + recoveryProposalTTL = 24 * time.Hour + recoveryGrantTTL = 5 * time.Minute +) + +// RecoveryProposal is metadata only. No proof-session digest or grant material +// is included in the admin or owner read projection. +type RecoveryProposal struct { + ID string `json:"id"` + UserID string `json:"userId"` + Owner string `json:"owner"` + Status string `json:"status"` + Version int64 `json:"version"` + Generation int64 `json:"generation"` + CreatedBy string `json:"createdBy"` + CreatedAt time.Time `json:"createdAt"` + ExpiresAt time.Time `json:"expiresAt"` + AuthVersion int `json:"-"` +} + +func (s *Store) recoveryProposal(q accountQuerier, id string, lock bool) (RecoveryProposal, error) { + query := s.q(`SELECT id,user_id,owner,status,version,generation,created_by,created_at,expires_at,issued_auth_version + FROM recovery_proposals WHERE id=?`) + if lock && s.Dialect() == "postgres" { + query += ` FOR UPDATE` + } + var p RecoveryProposal + var created, expires string + err := q.QueryRow(query, id).Scan(&p.ID, &p.UserID, &p.Owner, &p.Status, &p.Version, &p.Generation, + &p.CreatedBy, &created, &expires, &p.AuthVersion) + if errors.Is(err, sql.ErrNoRows) { + return p, ErrNotFound + } + if err != nil { + return p, err + } + p.CreatedAt, err = time.Parse(time.RFC3339Nano, created) + if err != nil { + return p, err + } + p.ExpiresAt, err = time.Parse(time.RFC3339Nano, expires) + if err != nil { + return p, err + } + return p, nil +} + +func newRecoveryGrant() (plain, id string, err error) { + var raw [32]byte + if _, err = rand.Read(raw[:]); err != nil { + return "", "", err + } + return "alk_" + hex.EncodeToString(raw[:]), newID("lk"), nil +} + +// CreateRecoveryProposal records precisely the currently eligible full +// password sessions. Later logins are not eligible proof for this proposal. +func (s *Store) CreateRecoveryProposal(actorOwner, userID string, expectedStateVersion int64, idempotencyKey string) (RecoveryProposal, error) { + if actorOwner == "" || userID == "" || expectedStateVersion < 1 || idempotencyKey == "" || len(idempotencyKey) > 128 || strings.TrimSpace(idempotencyKey) != idempotencyKey { + return RecoveryProposal{}, ErrConflict + } + pre, err := s.GetAccount(userID) + if err != nil { + return RecoveryProposal{}, err + } + tx, err := s.db.Begin() + if err != nil { + return RecoveryProposal{}, err + } + defer tx.Rollback() + locked, err := s.lockAccountUsers(tx, actorOwner, pre.Owner) + if errors.Is(err, ErrNotFound) { + return RecoveryProposal{}, ErrAccountForbidden + } + if err != nil { + return RecoveryProposal{}, err + } + actor, target := locked[actorOwner], locked[pre.Owner] + if !actor.Enabled || !actor.IsAdmin || !target.Enabled { + return RecoveryProposal{}, ErrAccountForbidden + } + if target.ID != userID || target.StateVersion != expectedStateVersion { + return RecoveryProposal{}, ErrConflict + } + var existingID string + err = tx.QueryRow(s.q(`SELECT id FROM recovery_proposals WHERE created_by=? AND idempotency_key=?`), actorOwner, idempotencyKey).Scan(&existingID) + if err == nil { + existing, err := s.recoveryProposal(tx, existingID, false) + if err != nil || existing.UserID != userID { + return RecoveryProposal{}, ErrConflict + } + return existing, nil + } + if !errors.Is(err, sql.ErrNoRows) { + return RecoveryProposal{}, err + } + now := time.Now().UTC() + active, err := tx.Query(s.q(`SELECT id,expires_at FROM recovery_proposals WHERE owner=? AND status IN (?,?)`), target.Owner, RecoveryProposalPending, RecoveryProposalConfirmed) + if err != nil { + return RecoveryProposal{}, err + } + var expiredIDs []string + for active.Next() { + var id, expires string + if err := active.Scan(&id, &expires); err != nil { + active.Close() + return RecoveryProposal{}, err + } + expiry, err := time.Parse(time.RFC3339Nano, expires) + if err != nil || now.Before(expiry) { + active.Close() + return RecoveryProposal{}, ErrConflict + } + expiredIDs = append(expiredIDs, id) + } + if err := active.Err(); err != nil { + active.Close() + return RecoveryProposal{}, err + } + active.Close() + for _, id := range expiredIDs { + if _, err := tx.Exec(s.q(`UPDATE recovery_proposals SET status=?,version=version+1 WHERE id=?`), RecoveryProposalExpired, id); err != nil { + return RecoveryProposal{}, err + } + } + if !target.CredentialOrigin.TrustedForOwnerProof() || !target.HasPassword || target.MustChangePassword { + return RecoveryProposal{}, ErrOwnerProofUnavailable + } + rows, err := tx.Query(s.q(`SELECT token_hash,expires_at,credential_origin FROM sessions WHERE owner=? AND scope=? AND auth_method=? AND auth_version=?`), + target.Owner, SessionScopeFull, AuthMethodPassword, target.AuthVersion) + if err != nil { + return RecoveryProposal{}, err + } + var proofs []string + for rows.Next() { + var digest, expires, origin string + if err := rows.Scan(&digest, &expires, &origin); err != nil { + rows.Close() + return RecoveryProposal{}, err + } + expiry, err := time.Parse(time.RFC3339Nano, expires) + if err == nil && now.Before(expiry) && CredentialOrigin(origin) == target.CredentialOrigin { + proofs = append(proofs, digest) + } + } + if err := rows.Err(); err != nil { + rows.Close() + return RecoveryProposal{}, err + } + rows.Close() + if len(proofs) == 0 { + return RecoveryProposal{}, ErrOwnerProofUnavailable + } + p := RecoveryProposal{ID: newID("rp"), UserID: target.ID, Owner: target.Owner, Status: RecoveryProposalPending, + Version: 1, Generation: 0, CreatedBy: actorOwner, CreatedAt: now, ExpiresAt: now.Add(recoveryProposalTTL), AuthVersion: target.AuthVersion} + if _, err := tx.Exec(s.q(`INSERT INTO recovery_proposals(id,user_id,owner,status,version,generation,created_by,created_at,expires_at,issued_auth_version,idempotency_key) + VALUES(?,?,?,?,?,?,?,?,?,?,?)`), p.ID, p.UserID, p.Owner, p.Status, p.Version, p.Generation, p.CreatedBy, + p.CreatedAt.Format(time.RFC3339Nano), p.ExpiresAt.Format(time.RFC3339Nano), p.AuthVersion, idempotencyKey); err != nil { + if isUniqueViolation(err) { + return RecoveryProposal{}, ErrConflict + } + return RecoveryProposal{}, err + } + for _, digest := range proofs { + if _, err := tx.Exec(s.q(`INSERT INTO recovery_proposal_proofs(proposal_id,session_hash) VALUES(?,?)`), p.ID, digest); err != nil { + return RecoveryProposal{}, err + } + } + if err := s.insertAccountEvent(tx, actorOwner, "admin-http", target.Owner, "recovery_proposed", p.ID); err != nil { + return RecoveryProposal{}, err + } + return p, tx.Commit() +} + +func (s *Store) eligibleRecoveryProof(tx *sql.Tx, account Account, proposal RecoveryProposal, token string) error { + query := s.q(`SELECT owner,scope,auth_method,credential_origin,auth_version,expires_at FROM sessions WHERE token_hash=?`) + if s.Dialect() == "postgres" { + query += ` FOR UPDATE` + } + var owner, scope, method, origin, expires string + var version int + err := tx.QueryRow(query, hashKey(token)).Scan(&owner, &scope, &method, &origin, &version, &expires) + if err != nil { + return ErrOwnerProofUnavailable + } + expiry, err := time.Parse(time.RFC3339Nano, expires) + if err != nil || time.Now().After(expiry) || owner != account.Owner || scope != SessionScopeFull || method != AuthMethodPassword || + CredentialOrigin(origin) != account.CredentialOrigin || !CredentialOrigin(origin).TrustedForOwnerProof() || version != account.AuthVersion { + return ErrOwnerProofUnavailable + } + var bound int + if err := tx.QueryRow(s.q(`SELECT COUNT(1) FROM recovery_proposal_proofs WHERE proposal_id=? AND session_hash=?`), proposal.ID, hashKey(token)).Scan(&bound); err != nil || bound != 1 { + return ErrOwnerProofUnavailable + } + return nil +} + +func (s *Store) issueConfirmedRecoveryGrant(tx *sql.Tx, p RecoveryProposal, account Account, plain, id string, now time.Time) error { + _, err := s.insertLoginKeyTx(tx, p.Owner, LoginKeyPurposeRecover, "owner-proof:"+p.Owner, plain, id, now, now.Add(recoveryGrantTTL), + grantBinding{origin: CredentialOriginOwnerRecovery, generation: p.Generation, version: account.AuthVersion, proposalID: p.ID}) + return err +} + +func (s *Store) confirmOrRotateRecovery(token, proposalID string, expectedVersion, expectedGeneration int64, rotate bool) (string, RecoveryProposal, error) { + pre, err := s.recoveryProposal(s.db, proposalID, false) + if err != nil { + return "", RecoveryProposal{}, err + } + plain, id, err := newRecoveryGrant() + if err != nil { + return "", RecoveryProposal{}, err + } + tx, err := s.db.Begin() + if err != nil { + return "", RecoveryProposal{}, err + } + defer tx.Rollback() + locked, err := s.lockAccountUsers(tx, pre.Owner) + if err != nil { + return "", RecoveryProposal{}, err + } + account := locked[pre.Owner] + p, err := s.recoveryProposal(tx, proposalID, true) + if err != nil { + return "", RecoveryProposal{}, err + } + if !account.Enabled || account.ID != p.UserID || account.AuthVersion != p.AuthVersion || account.MustChangePassword || !account.CredentialOrigin.TrustedForOwnerProof() || + p.Version != expectedVersion || p.Generation != expectedGeneration || time.Now().After(p.ExpiresAt) { + return "", RecoveryProposal{}, ErrConflict + } + if (!rotate && p.Status != RecoveryProposalPending) || (rotate && p.Status != RecoveryProposalConfirmed) { + return "", RecoveryProposal{}, ErrConflict + } + if err := s.eligibleRecoveryProof(tx, account, p, token); err != nil { + return "", RecoveryProposal{}, err + } + now := time.Now().UTC() + newGeneration := p.Generation + 1 + newStatus := RecoveryProposalConfirmed + res, err := tx.Exec(s.q(`UPDATE recovery_proposals SET status=?,version=version+1,generation=? WHERE id=? AND version=? AND generation=? AND status=?`), + newStatus, newGeneration, p.ID, p.Version, p.Generation, p.Status) + if err != nil { + return "", RecoveryProposal{}, err + } + if n, err := res.RowsAffected(); err != nil || n != 1 { + return "", RecoveryProposal{}, ErrConflict + } + p.Version++ + p.Generation = newGeneration + p.Status = newStatus + if err := s.issueConfirmedRecoveryGrant(tx, p, account, plain, id, now); err != nil { + return "", RecoveryProposal{}, err + } + action := "recovery_confirmed" + if rotate { + action = "recovery_grant_rotated" + } + if err := s.insertAccountEvent(tx, p.Owner, "owner-http", p.Owner, action, fmt.Sprintf("proposal=%s generation=%d", p.ID, p.Generation)); err != nil { + return "", RecoveryProposal{}, err + } + if err := tx.Commit(); err != nil { + return "", RecoveryProposal{}, err + } + return plain, p, nil +} + +func (s *Store) ConfirmRecoveryProposal(token, proposalID string, expectedVersion int64) (string, RecoveryProposal, error) { + return s.confirmOrRotateRecovery(token, proposalID, expectedVersion, 0, false) +} + +func (s *Store) RotateRecoveryGrant(token, proposalID string, expectedVersion, expectedGeneration int64) (string, RecoveryProposal, error) { + return s.confirmOrRotateRecovery(token, proposalID, expectedVersion, expectedGeneration, true) +} + +func effectiveRecoveryProposal(p RecoveryProposal) RecoveryProposal { + if (p.Status == RecoveryProposalPending || p.Status == RecoveryProposalConfirmed) && time.Now().After(p.ExpiresAt) { + p.Status = RecoveryProposalExpired + } + return p +} + +// ListOwnerRecoveryProposals returns only non-secret metadata. The HTTP layer +// obtains owner from a live full password session, never a request parameter. +func (s *Store) ListOwnerRecoveryProposals(owner string) ([]RecoveryProposal, error) { + rows, err := s.db.Query(s.q(`SELECT id FROM recovery_proposals WHERE owner=? ORDER BY created_at DESC LIMIT 50`), owner) + if err != nil { + return nil, err + } + ids := []string{} + for rows.Next() { + var id string + if err := rows.Scan(&id); err != nil { + rows.Close() + return nil, err + } + ids = append(ids, id) + } + if err := rows.Err(); err != nil { + rows.Close() + return nil, err + } + rows.Close() + proposals := make([]RecoveryProposal, 0, len(ids)) + for _, id := range ids { + p, err := s.recoveryProposal(s.db, id, false) + if err != nil { + return nil, err + } + proposals = append(proposals, effectiveRecoveryProposal(p)) + } + return proposals, nil +} + +func (s *Store) GetRecoveryProposalForAdmin(actorOwner, userID, proposalID string) (RecoveryProposal, error) { + actor, err := s.GetAccountByOwner(actorOwner) + if err != nil || !actor.Enabled || !actor.IsAdmin { + return RecoveryProposal{}, ErrAccountForbidden + } + p, err := s.recoveryProposal(s.db, proposalID, false) + if err != nil { + return RecoveryProposal{}, err + } + if p.UserID != userID { + return RecoveryProposal{}, ErrNotFound + } + return effectiveRecoveryProposal(p), nil +} + +func (s *Store) ListRecoveryProposalsForAdmin(actorOwner, userID string) ([]RecoveryProposal, error) { + actor, err := s.GetAccountByOwner(actorOwner) + if err != nil || !actor.Enabled || !actor.IsAdmin { + return nil, ErrAccountForbidden + } + target, err := s.GetAccount(userID) + if err != nil { + return nil, err + } + proposals, err := s.ListOwnerRecoveryProposals(target.Owner) + if err != nil { + return nil, err + } + filtered := proposals[:0] + for _, proposal := range proposals { + if proposal.UserID == userID { + filtered = append(filtered, proposal) + } + } + return filtered, nil +} + +func (s *Store) GetOwnerRecoveryProposal(owner, proposalID string) (RecoveryProposal, error) { + p, err := s.recoveryProposal(s.db, proposalID, false) + if err != nil { + return RecoveryProposal{}, err + } + if p.Owner != owner { + return RecoveryProposal{}, ErrNotFound + } + return effectiveRecoveryProposal(p), nil +} + +// RevokeRecoveryProposal checks the path target, actor and expected version in +// the same transaction as revoking grant handles and derived restricted sessions. +func (s *Store) RevokeRecoveryProposal(actorOwner, userID, proposalID string, expectedVersion int64) (RecoveryProposal, error) { + pre, err := s.recoveryProposal(s.db, proposalID, false) + if err != nil { + return RecoveryProposal{}, err + } + if pre.UserID != userID || expectedVersion < 1 { + return RecoveryProposal{}, ErrNotFound + } + tx, err := s.db.Begin() + if err != nil { + return RecoveryProposal{}, err + } + defer tx.Rollback() + locked, err := s.lockAccountUsers(tx, actorOwner, pre.Owner) + if errors.Is(err, ErrNotFound) { + return RecoveryProposal{}, ErrAccountForbidden + } + if err != nil { + return RecoveryProposal{}, err + } + actor := locked[actorOwner] + if !actor.Enabled || !actor.IsAdmin { + return RecoveryProposal{}, ErrAccountForbidden + } + p, err := s.recoveryProposal(tx, proposalID, true) + if err != nil { + return RecoveryProposal{}, err + } + if p.UserID != userID { + return RecoveryProposal{}, ErrNotFound + } + if p.Version != expectedVersion || (p.Status != RecoveryProposalPending && p.Status != RecoveryProposalConfirmed) { + return RecoveryProposal{}, ErrConflict + } + if _, err := tx.Exec(s.q(`DELETE FROM sessions WHERE grant_id IN (SELECT id FROM login_keys WHERE proposal_id=?)`), p.ID); err != nil { + return RecoveryProposal{}, err + } + if _, err := tx.Exec(s.q(`UPDATE login_keys SET status=? WHERE proposal_id=? AND status=?`), LoginKeyStatusRevoked, p.ID, LoginKeyStatusUnused); err != nil { + return RecoveryProposal{}, err + } + res, err := tx.Exec(s.q(`UPDATE recovery_proposals SET status=?,version=version+1 WHERE id=? AND version=?`), RecoveryProposalRevoked, p.ID, expectedVersion) + if err != nil { + return RecoveryProposal{}, err + } + if n, err := res.RowsAffected(); err != nil || n != 1 { + return RecoveryProposal{}, ErrConflict + } + if err := s.insertAccountEvent(tx, actorOwner, "admin-http", p.Owner, "recovery_revoked", p.ID); err != nil { + return RecoveryProposal{}, err + } + if err := tx.Commit(); err != nil { + return RecoveryProposal{}, err + } + p.Status, p.Version = RecoveryProposalRevoked, p.Version+1 + return p, nil +} diff --git a/internal/store/recovery_proposals_test.go b/internal/store/recovery_proposals_test.go new file mode 100644 index 0000000..767ed3a --- /dev/null +++ b/internal/store/recovery_proposals_test.go @@ -0,0 +1,220 @@ +package store + +import ( + "errors" + "sync" + "testing" + "time" +) + +func trustedRecoveryFixture(t *testing.T, st *Store) (Account, string) { + t.Helper() + if err := st.CreateAccount("admin", "", "admin-hash", true); err != nil { + t.Fatal(err) + } + account, err := st.CreatePendingAccount("admin", "alice", AccountProfile{}) + if err != nil { + t.Fatal(err) + } + activation, err := st.InitializePendingAccount(account.ID, account.StateVersion, account.InitializationGeneration) + if err != nil { + t.Fatal(err) + } + setup, _, err := st.RedeemLoginKey(activation) + if err != nil { + t.Fatal(err) + } + hash, err := HashPassword("initial-owner-password") + if err != nil { + t.Fatal(err) + } + if _, err := st.CompleteSetPasswordChecked(setup, hash); err != nil { + t.Fatal(err) + } + account, err = st.GetAccount(account.ID) + if err != nil { + t.Fatal(err) + } + _, version, valid := st.VerifyLoginWithVersion("alice", "initial-owner-password") + if !valid { + t.Fatal("trusted owner could not log in") + } + session, err := st.CreateSessionVerified("alice", SessionScopeFull, time.Hour, version) + if err != nil { + t.Fatal(err) + } + return account, session +} + +func TestOwnerRecoveryConcurrentConfirmAndDisableAcrossPools(t *testing.T) { + st := openTestStore(t) + other := reopenAccountModelStore(t, st) + t.Logf("owner recovery concurrency dialect=%s two independent pools", st.Dialect()) + account, proof := trustedRecoveryFixture(t, st) + proposal, err := st.CreateRecoveryProposal("admin", account.ID, account.StateVersion, "race-1") + if err != nil { + t.Fatal(err) + } + type result struct { + grant string + err error + } + start := make(chan struct{}) + confirmed := make(chan result, 1) + disabled := make(chan error, 1) + var wg sync.WaitGroup + wg.Add(2) + go func() { + defer wg.Done() + <-start + grant, _, err := st.ConfirmRecoveryProposal(proof, proposal.ID, proposal.Version) + confirmed <- result{grant: grant, err: err} + }() + go func() { + defer wg.Done() + <-start + _, err := other.SetAccountEnabled("admin", account.ID, account.StateVersion, false) + disabled <- err + }() + close(start) + wg.Wait() + if err := <-disabled; err != nil { + t.Fatalf("disable lost race: %v", err) + } + out := <-confirmed + if out.err == nil && out.grant == "" { + t.Fatal("confirm succeeded without a grant") + } + current, err := st.GetAccount(account.ID) + if err != nil || current.Enabled { + t.Fatalf("target remained effective after disable: %+v %v", current, err) + } + if out.grant != "" { + if _, _, err := other.RedeemLoginKey(out.grant); err == nil { + t.Fatal("grant survived concurrent disable") + } + } +} + +func TestOwnerRecoveryConcurrentConfirmHasOneWinnerAcrossPools(t *testing.T) { + st := openTestStore(t) + other := reopenAccountModelStore(t, st) + t.Logf("owner confirmation dialect=%s two independent pools", st.Dialect()) + account, proof := trustedRecoveryFixture(t, st) + proposal, err := st.CreateRecoveryProposal("admin", account.ID, account.StateVersion, "race-2") + if err != nil { + t.Fatal(err) + } + start := make(chan struct{}) + results := make(chan error, 2) + var wg sync.WaitGroup + for _, conn := range []*Store{st, other} { + wg.Add(1) + go func(conn *Store) { + defer wg.Done() + <-start + _, _, err := conn.ConfirmRecoveryProposal(proof, proposal.ID, proposal.Version) + results <- err + }(conn) + } + close(start) + wg.Wait() + close(results) + wins, conflicts := 0, 0 + for err := range results { + if err == nil { + wins++ + } else if errors.Is(err, ErrConflict) { + conflicts++ + } else { + t.Fatal(err) + } + } + if wins != 1 || conflicts != 1 { + t.Fatalf("concurrent confirmation wins=%d conflicts=%d", wins, conflicts) + } +} + +func TestOwnerProofRecoveryBindsPreexistingSessionAndRotatesGrant(t *testing.T) { + st := openTestStore(t) + t.Logf("owner recovery dialect=%s", st.Dialect()) + account, proof := trustedRecoveryFixture(t, st) + proposal, err := st.CreateRecoveryProposal("admin", account.ID, account.StateVersion, "request-1") + if err != nil || proposal.Status != RecoveryProposalPending || proposal.ID == "" { + t.Fatalf("proposal: %+v %v", proposal, err) + } + _, version, _ := st.VerifyLoginWithVersion("alice", "initial-owner-password") + lateSession, err := st.CreateSessionVerified("alice", SessionScopeFull, time.Hour, version) + if err != nil { + t.Fatal(err) + } + if _, _, err := st.ConfirmRecoveryProposal(lateSession, proposal.ID, proposal.Version); !errors.Is(err, ErrOwnerProofUnavailable) { + t.Fatalf("post-proposal session became proof: %v", err) + } + first, confirmed, err := st.ConfirmRecoveryProposal(proof, proposal.ID, proposal.Version) + if err != nil || first == "" || confirmed.Status != RecoveryProposalConfirmed { + t.Fatalf("confirm: %+v %v", confirmed, err) + } + second, rotated, err := st.RotateRecoveryGrant(proof, proposal.ID, confirmed.Version, confirmed.Generation) + if err != nil || second == first || rotated.Generation != confirmed.Generation+1 { + t.Fatalf("rotate: %+v %v", rotated, err) + } + if _, _, err := st.RedeemLoginKey(first); err == nil { + t.Fatal("rotated grant remained redeemable") + } + restricted, owner, err := st.RedeemLoginKey(second) + if err != nil || owner != "alice" { + t.Fatalf("redeem owner recovery: %v %q", err, owner) + } + newHash, err := HashPassword("recovered-owner-password") + if err != nil { + t.Fatal(err) + } + if _, err := st.CompleteSetPasswordChecked(restricted, newHash); err != nil { + t.Fatalf("finish recovery: %v", err) + } + current, err := st.GetAccount(account.ID) + if err != nil || current.ID != account.ID || current.CredentialOrigin != CredentialOriginOwnerRecovery { + t.Fatalf("recovery identity or lineage changed: %+v %v", current, err) + } + if _, ok := st.VerifyLogin("alice", "recovered-owner-password"); !ok { + t.Fatal("recovered password login failed") + } + if _, _, ok := st.SessionInfo(proof); ok { + t.Fatal("old proof session survived recovery") + } + if _, _, err := st.ConfirmRecoveryProposal(proof, proposal.ID, rotated.Version); err == nil { + t.Fatal("completed proposal confirmed twice") + } +} + +func TestLegacyLineageHasNoOnlineOwnerProof(t *testing.T) { + st := openTestStore(t) + if err := st.CreateAccount("admin", "", "admin-hash", true); err != nil { + t.Fatal(err) + } + legacy, err := HashPassword("legacy-password-1") + if err != nil { + t.Fatal(err) + } + if err := st.CreateAccount("legacy", "", legacy, false); err != nil { + t.Fatal(err) + } + if err := st.ClearMustChangePassword("legacy"); err != nil { + t.Fatal(err) + } + account, err := st.GetAccountByOwner("legacy") + if err != nil { + t.Fatal(err) + } + _, version, ok := st.VerifyLoginWithVersion("legacy", "legacy-password-1") + if !ok { + t.Fatal("legacy password login should remain usable") + } + if _, err := st.CreateSessionVerified("legacy", SessionScopeFull, time.Hour, version); err != nil { + t.Fatal(err) + } + if _, err := st.CreateRecoveryProposal("admin", account.ID, account.StateVersion, "legacy-attempt"); !errors.Is(err, ErrOwnerProofUnavailable) { + t.Fatalf("legacy password session became proof: %v", err) + } +} diff --git a/internal/store/store_test.go b/internal/store/store_test.go index 55edadf..01a265d 100644 --- a/internal/store/store_test.go +++ b/internal/store/store_test.go @@ -549,6 +549,9 @@ func TestAdminAccountsAndSessions(t *testing.T) { if err := st.SetUserPassword("admin", hash2); err != nil { t.Fatal(err) } + if _, ok := st.SessionOwner(tok); ok { + t.Fatal("password reset must revoke prior session") + } if err := st.ClearMustChangePassword("admin"); err != nil { t.Fatal(err) } @@ -556,6 +559,10 @@ func TestAdminAccountsAndSessions(t *testing.T) { if st1.MustChangePassword { t.Fatal("must_change should clear after self-change") } + tok, err = st.CreateSession("admin") + if err != nil { + t.Fatal(err) + } st.DeleteUserSessions("admin", tok) if _, ok := st.SessionOwner(tok); !ok { t.Fatal("kept session (except-path) must survive") diff --git a/pkg/extension/account_directory.go b/pkg/extension/account_directory.go index 6be3a9d..5a5dfac 100644 --- a/pkg/extension/account_directory.go +++ b/pkg/extension/account_directory.go @@ -55,6 +55,9 @@ type AccountDirectoryLookup struct { type AccountDirectoryProvider interface { QueryAccountDirectory(context.Context, AccountDirectoryQuery) (AccountDirectoryPage, error) LookupAccountDirectory(context.Context, AccountDirectoryQuery, string) (AccountDirectoryLookup, error) + // LookupAccountDirectoryOwner is an explicit legacy-owner compatibility + // lookup; it resolves to the same stable user ID and applies the same scope. + LookupAccountDirectoryOwner(context.Context, AccountDirectoryQuery, string) (AccountDirectoryLookup, error) } // AccountDirectoryScopeRequest is constructed by the Backend from the trusted @@ -70,12 +73,14 @@ type AccountDirectoryScopeRequest struct { // AccountDirectoryScope is returned by the trusted in-process domain resolver. // AllCandidates is explicit: a nil candidate list never means global access. -// Candidates are stable user IDs and are applied before search and pagination. +// CandidateIDs or CandidateOwners are applied before search and pagination; +// domain membership stores may use owners while the provider returns stable IDs. type AccountDirectoryScope struct { AllowedFields []string MatchFields []string AllCandidates bool CandidateIDs []string + CandidateOwners []string ScopeVersion string } diff --git a/pkg/extension/surface.txt b/pkg/extension/surface.txt index 25f7d2b..8a682ad 100644 --- a/pkg/extension/surface.txt +++ b/pkg/extension/surface.txt @@ -5,6 +5,7 @@ AccountDirectoryModeFuzzy AccountDirectoryPage AccountDirectoryProvider AccountDirectoryProvider.LookupAccountDirectory +AccountDirectoryProvider.LookupAccountDirectoryOwner AccountDirectoryProvider.QueryAccountDirectory AccountDirectoryPurposeDocumentDuties AccountDirectoryPurposeProjectMembers From 7229abe50d878e73ee7d7712e1e8eadc9fef37f9 Mon Sep 17 00:00:00 2001 From: weichen Date: Wed, 14 Oct 2026 04:48:53 +0000 Subject: [PATCH 06/17] Fence final run settlement against account disable Why: A disable could commit after the run manager checked account state but before its final business message was stored. Evidence: SELF receipts /tmp/iam-u3-final-settle-sqlite.json, /tmp/iam-u3-final-settle-pg.json, /tmp/iam-u3-backend-settle-all.json, /tmp/iam-u3-backend-settle-pg.json, and /tmp/iam-u3-http-two-instance-pg.json. Impact: The final run transaction locks and rechecks the account, rejects post-disable business output, and permits only an account-denial failure record. Auth-off implicit local ownership remains explicit. Refs: IAM U3 r1 stages 4-5; Dev feature/iam-u3-account-directory-recovery --- internal/app/app.go | 6 +- internal/runmgr/runmgr.go | 15 ++- internal/server/auth_e2e_test.go | 6 +- .../server/recovery_multi_instance_test.go | 124 ++++++++++++++++++ internal/store/account_settle_test.go | 62 +++++++++ internal/store/recovery.go | 102 +++++++++----- 6 files changed, 274 insertions(+), 41 deletions(-) create mode 100644 internal/server/recovery_multi_instance_test.go create mode 100644 internal/store/account_settle_test.go diff --git a/internal/app/app.go b/internal/app/app.go index 8f27dc8..b7d96f3 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -452,7 +452,7 @@ func Run(opt Options) error { mgr := runmgr.New(st, b, reg, profSvc, setSvc, convRunner, subRunner, mockRunner, &runtime.MockSubagent{}) authOn := resolved.AuthEnabled() effectiveAccount := accountEffectiveAuthorization(st, authOn) - mgr.SetAccountEffectiveAuthorization(effectiveAccount) + mgr.SetAccountEffectiveAuthorization(effectiveAccount, !authOn) instanceID := opt.InstanceID if instanceID == "" { host, err := os.Hostname() @@ -650,7 +650,9 @@ func Run(opt Options) error { } } hostSrv.Dispatch = func(ctx context.Context, pluginID, owner, commandID string, params map[string]any) error { - if err := caps.accountGuard(owner); err != nil { return err } + if err := caps.accountGuard(owner); err != nil { + return err + } resp := bus.Dispatch(ctx, cmdbus.Request{ID: commandID, Params: params, Entrance: "api", Actor: pluginID, Owner: owner}) if resp.Receipt.State == "rejected" || resp.Receipt.State == "unknown" { return fmt.Errorf("%s", resp.Receipt.Reason) diff --git a/internal/runmgr/runmgr.go b/internal/runmgr/runmgr.go index 0833ca5..0f72efb 100644 --- a/internal/runmgr/runmgr.go +++ b/internal/runmgr/runmgr.go @@ -62,6 +62,7 @@ type Manager struct { knowledgeProjectScope func(context.Context, string, core.ID) (core.ID, error) projectWorkAuthorization func(context.Context, string, core.ID) (bool, error) accountEffectiveAuthorization func(context.Context, string) (bool, error) + accountAllowImplicitLocal bool // commands EXT-14:命令即工具的投影与分发源(nil = 未接线,命令不进工具清单)。 commands CommandSource indexer core.DocumentIndexer // 可选索引入口(nil = store 整文档单块) @@ -222,8 +223,11 @@ func (m *Manager) SetProjectWorkAuthorization(fn func(context.Context, string, c // SetAccountEffectiveAuthorization binds the current account-state guard. It is // independent of the optional domain project provider. -func (m *Manager) SetAccountEffectiveAuthorization(fn func(context.Context, string) (bool, error)) { +func (m *Manager) SetAccountEffectiveAuthorization(fn func(context.Context, string) (bool, error), allowImplicitLocal ...bool) { m.accountEffectiveAuthorization = fn + if len(allowImplicitLocal) > 0 { + m.accountAllowImplicitLocal = allowImplicitLocal[0] + } } func (m *Manager) authorizeAccountEffective(ctx context.Context, owner string) error { @@ -1696,7 +1700,11 @@ func (m *Manager) emit(conversationID, runID string, typ core.StreamEventType, p // settleRunWithFence(U5):PG 档以本实例登记的 epoch 做围栏结算。 func (m *Manager) settleRunWithFence(rc *runCtx, msg *core.Message) (updated, fenced bool, err error) { if m.st.Dialect() != "postgres" { - updated, err = m.st.SettleRun(&rc.run, msg) + if m.accountEffectiveAuthorization != nil { + updated, err = m.st.SettleRunAccountChecked(&rc.run, msg, m.accountAllowImplicitLocal) + } else { + updated, err = m.st.SettleRun(&rc.run, msg) + } return updated, false, err } m.execMu.Lock() @@ -1708,6 +1716,9 @@ func (m *Manager) settleRunWithFence(rc *runCtx, msg *core.Message) (updated, fe epoch = es.Epoch } } + if m.accountEffectiveAuthorization != nil { + return m.st.SettleRunFencedAccountChecked(&rc.run, msg, m.InstanceID(), epoch, m.accountAllowImplicitLocal) + } return m.st.SettleRunFenced(&rc.run, msg, m.InstanceID(), epoch) } diff --git a/internal/server/auth_e2e_test.go b/internal/server/auth_e2e_test.go index b9ea234..fa09148 100644 --- a/internal/server/auth_e2e_test.go +++ b/internal/server/auth_e2e_test.go @@ -36,7 +36,11 @@ func newAuthTestServer(t *testing.T) (*Server, *store.Store) { t.Fatal(err) } t.Cleanup(func() { st.Close() }) + return newAuthTestServerOnStore(t, st, dir), st +} +func newAuthTestServerOnStore(t *testing.T, st *store.Store, dir string) *Server { + t.Helper() b := bus.New() reg := tools.NewRegistry() reg.RegisterBuiltin() @@ -48,7 +52,7 @@ func newAuthTestServer(t *testing.T) (*Server, *store.Store) { srv := New(st, mgr, reg, profSvc, setSvc, ph) srv.Auth = NewAuthenticator(st, true) srv.Subscribe = func(cid string) (<-chan core.StreamEvent, func()) { return b.Subscribe(cid) } - return srv, st + return srv } func TestAuthMiddleware(t *testing.T) { diff --git a/internal/server/recovery_multi_instance_test.go b/internal/server/recovery_multi_instance_test.go new file mode 100644 index 0000000..f8dc950 --- /dev/null +++ b/internal/server/recovery_multi_instance_test.go @@ -0,0 +1,124 @@ +package server + +import ( + "database/sql" + "encoding/json" + "net/http" + "os" + "path/filepath" + "strings" + "testing" + + "agent-system-backend/internal/store" +) + +func twinRecoveryStores(t *testing.T) (*store.Store, *store.Store) { + t.Helper() + dsn := os.Getenv("AGENT_PG_TEST_DSN") + if dsn == "" { + path := filepath.Join(t.TempDir(), "shared.db") + first, err := store.OpenSQLite(path) + if err != nil { + t.Fatal(err) + } + second, err := store.OpenSQLite(path) + if err != nil { + first.Close() + t.Fatal(err) + } + t.Cleanup(func() { second.Close(); first.Close() }) + return first, second + } + admin, err := sql.Open("pgx", dsn) + if err != nil { + t.Fatal(err) + } + if _, err := admin.Exec(`CREATE EXTENSION IF NOT EXISTS pg_trgm`); err != nil { + t.Fatal(err) + } + schema := "t_" + strings.ToLower(store.NewID("recovery")) + if _, err := admin.Exec(`CREATE SCHEMA ` + schema); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _, _ = admin.Exec(`DROP SCHEMA IF EXISTS ` + schema + ` CASCADE`); admin.Close() }) + isolate := store.AppendPGRuntimeParam(dsn, "search_path", schema+",public") + first, err := store.OpenPostgres(isolate, filepath.Join(t.TempDir(), "a-artifacts")) + if err != nil { + t.Fatal(err) + } + second, err := store.OpenPostgres(isolate, filepath.Join(t.TempDir(), "b-artifacts")) + if err != nil { + first.Close() + t.Fatal(err) + } + t.Cleanup(func() { second.Close(); first.Close() }) + return first, second +} + +func TestRecoveryAcrossTwoHTTPInstancesAndIndependentPools(t *testing.T) { + first, second := twinRecoveryStores(t) + t.Logf("recovery HTTP dialect=%s two independent stores", first.Dialect()) + hA := newAuthTestServerOnStore(t, first, t.TempDir()).Handler() + hB := newAuthTestServerOnStore(t, second, t.TempDir()).Handler() + adminCookie := bootAdmin(t, hA, first) + account, err := first.CreatePendingAccount("admin", "two-instance-owner", store.AccountProfile{}) + if err != nil { + t.Fatal(err) + } + activation, err := first.InitializePendingAccount(account.ID, account.StateVersion, account.InitializationGeneration) + if err != nil { + t.Fatal(err) + } + setup := do(hB, "POST", "/api/auth/login-key/redeem", `{"key":"`+activation+`"}`, "") + if setup.Code != http.StatusOK { + t.Fatalf("second instance activation redeem: %d %s", setup.Code, setup.Body.String()) + } + if done := do(hA, "POST", "/api/auth/password", `{"next":"two-instance-initial"}`, cookieOf(setup)); done.Code != http.StatusOK { + t.Fatalf("first instance initial set: %d %s", done.Code, done.Body.String()) + } + login := do(hA, "POST", "/api/auth/login", `{"owner":"two-instance-owner","password":"two-instance-initial"}`, "") + if login.Code != http.StatusOK { + t.Fatalf("first instance login: %d %s", login.Code, login.Body.String()) + } + ownerCookie := cookieOf(login) + current, err := second.GetAccount(account.ID) + if err != nil { + t.Fatal(err) + } + proposalResponse := do(hA, "POST", "/api/admin/users/"+account.ID+"/recovery-proposals", + `{"expectedStateVersion":`+jsonNumber(current.StateVersion)+`,"idempotencyKey":"two-instance-proposal"}`, adminCookie) + if proposalResponse.Code != http.StatusOK { + t.Fatalf("proposal: %d %s", proposalResponse.Code, proposalResponse.Body.String()) + } + var proposal store.RecoveryProposal + if err := json.Unmarshal(proposalResponse.Body.Bytes(), &proposal); err != nil { + t.Fatal(err) + } + confirmation := do(hB, "POST", "/api/auth/recovery-proposals/"+proposal.ID+"/confirm", `{"expectedVersion":1}`, ownerCookie) + if confirmation.Code != http.StatusOK { + t.Fatalf("second instance confirm: %d %s", confirmation.Code, confirmation.Body.String()) + } + var confirmed struct { + Grant string `json:"grant"` + } + if err := json.Unmarshal(confirmation.Body.Bytes(), &confirmed); err != nil || confirmed.Grant == "" { + t.Fatalf("grant response: %v", err) + } + redeemed := do(hA, "POST", "/api/auth/recovery-grants/redeem", `{"key":"`+confirmed.Grant+`"}`, "") + if redeemed.Code != http.StatusOK { + t.Fatalf("first instance grant redeem: %d %s", redeemed.Code, redeemed.Body.String()) + } + if completed := do(hB, "POST", "/api/auth/password", `{"next":"two-instance-recovered"}`, cookieOf(redeemed)); completed.Code != http.StatusOK { + t.Fatalf("second instance recovery completion: %d %s", completed.Code, completed.Body.String()) + } + if old := do(hB, "GET", "/api/me", "", ownerCookie); old.Code != http.StatusUnauthorized { + t.Fatalf("old proof session survived: %d", old.Code) + } + if fresh := do(hA, "POST", "/api/auth/login", `{"owner":"two-instance-owner","password":"two-instance-recovered"}`, ""); fresh.Code != http.StatusOK { + t.Fatalf("recovered password login: %d %s", fresh.Code, fresh.Body.String()) + } + after, err := first.GetAccount(account.ID) + if err != nil || after.CredentialOrigin != store.CredentialOriginOwnerRecovery || after.ID != account.ID { + t.Fatalf("persistent identity or provenance changed: %+v %v", after, err) + } +} diff --git a/internal/store/account_settle_test.go b/internal/store/account_settle_test.go new file mode 100644 index 0000000..dc89175 --- /dev/null +++ b/internal/store/account_settle_test.go @@ -0,0 +1,62 @@ +package store + +import ( + "errors" + "testing" + "time" + + core "agent-system-core" +) + +func TestAccountCheckedFinalSettlementCannotWriteAfterDisable(t *testing.T) { + st := openTestStore(t) + t.Logf("account final settlement dialect=%s", st.Dialect()) + if err := st.CreateAccount("admin", "", "hash", true); err != nil { + t.Fatal(err) + } + if err := st.EnsureUser(OwnerDefault, ""); err != nil { + t.Fatal(err) + } + conv := seedConversation(t, st) + run := seedRun(t, st, conv, core.RunRunning) + local, err := st.GetAccountByOwner(OwnerDefault) + if err != nil { + t.Fatal(err) + } + if _, err := st.SetAccountEnabled("admin", local.ID, local.StateVersion, false); err != nil { + t.Fatal(err) + } + now := time.Now().UTC() + run.Status, run.Phase, run.EndedAt = core.RunCompleted, core.PhaseDone, &now + message := core.Message{ConversationID: conv.ID, RunID: run.ID, Role: core.RoleAssistant, + Parts: []core.Part{{Type: core.PartText, Text: "must not persist after disable"}}} + settle := func() error { + if st.Dialect() == "postgres" { + _, _, err := st.SettleRunFencedAccountChecked(&run, &message, "test-instance", 0, true) + return err + } + _, err := st.SettleRunAccountChecked(&run, &message, true) + return err + } + if err := settle(); !errors.Is(err, ErrAccountForbidden) { + t.Fatalf("disabled account completed business output: %v", err) + } + got, err := st.GetRunByID(run.ID) + if err != nil || got.Status != core.RunRunning { + t.Fatalf("failed transaction changed run state: %+v %v", got, err) + } + messages, err := st.ListMessages(conv.ID) + if err != nil || len(messages) != 0 { + t.Fatalf("failed transaction wrote assistant message: %+v %v", messages, err) + } + run.Status = core.RunFailed + run.Error = &core.ErrorPart{Code: "run.account_inactive", Message: "account disabled"} + message.Parts = []core.Part{{Type: core.PartError, Err: run.Error}} + if err := settle(); err != nil { + t.Fatalf("minimal account-denial failure could not settle: %v", err) + } + got, err = st.GetRunByID(run.ID) + if err != nil || got.Status != core.RunFailed { + t.Fatalf("denial state not persisted: %+v %v", got, err) + } +} diff --git a/internal/store/recovery.go b/internal/store/recovery.go index 0ce0b49..08b0354 100644 --- a/internal/store/recovery.go +++ b/internal/store/recovery.go @@ -126,6 +126,36 @@ func (s *Store) ActiveHITLCardsByRun(runID string) ([]core.HITLCard, error) { // (instance,epoch) 仍是该会话的当前执行权,被接管则拒写(fenced=true,run // 不变)。无租约行(未启用执行所有权)恒放行,语义与 SettleRun 一致。 func (s *Store) SettleRunFenced(r *core.Run, msg *core.Message, instanceID string, epoch int64) (updated, fenced bool, err error) { + return s.settleRunRetried(r, msg, &fenceRef{instance: instanceID, epoch: epoch, conversation: string(r.ConversationID)}, nil) +} + +// SettleRunFencedAccountChecked adds a transaction-time account check to the +// execution fence. A disable committing first prevents a later business +// message from reaching the authoritative message tree. +func (s *Store) SettleRunFencedAccountChecked(r *core.Run, msg *core.Message, instanceID string, epoch int64, allowImplicitLocal bool) (bool, bool, error) { + return s.settleRunRetried(r, msg, &fenceRef{instance: instanceID, epoch: epoch, conversation: string(r.ConversationID)}, + &accountSettleCheck{allowImplicitLocal: allowImplicitLocal}) +} + +// fenceRef 执行围栏引用(nil = 不校验)。 +type fenceRef struct { + instance, conversation string + epoch int64 +} + +type accountSettleCheck struct{ allowImplicitLocal bool } + +func (s *Store) SettleRun(r *core.Run, msg *core.Message) (updated bool, err error) { + updated, _, err = s.settleRunRetried(r, msg, nil, nil) + return updated, err +} + +func (s *Store) SettleRunAccountChecked(r *core.Run, msg *core.Message, allowImplicitLocal bool) (bool, error) { + updated, _, err := s.settleRunRetried(r, msg, nil, &accountSettleCheck{allowImplicitLocal: allowImplicitLocal}) + return updated, err +} + +func (s *Store) settleRunRetried(r *core.Run, msg *core.Message, fence *fenceRef, account *accountSettleCheck) (updated, fenced bool, err error) { s.settleMu.Lock() fault := s.settleFault s.settleMu.Unlock() @@ -137,7 +167,7 @@ func (s *Store) SettleRunFenced(r *core.Run, msg *core.Message, instanceID strin } var last error for attempt := 0; attempt < 8; attempt++ { - updated, fenced, last = s.settleRunOnce(r, msg, &fenceRef{instance: instanceID, epoch: epoch, conversation: string(r.ConversationID)}) + updated, fenced, last = s.settleRunOnce(r, msg, fence, account) if last == nil { return updated, fenced, nil } @@ -152,46 +182,46 @@ func (s *Store) SettleRunFenced(r *core.Run, msg *core.Message, instanceID strin return false, false, last } -// fenceRef 执行围栏引用(nil = 不校验)。 -type fenceRef struct { - instance, conversation string - epoch int64 -} - -func (s *Store) SettleRun(r *core.Run, msg *core.Message) (updated bool, err error) { - s.settleMu.Lock() - fault := s.settleFault - s.settleMu.Unlock() - if fault != nil { - return false, fault - } - if msg != nil && msg.ID == "" { - msg.ID = newID("msg") - } - var last error - for attempt := 0; attempt < 8; attempt++ { - var fenced bool - updated, fenced, last = s.settleRunOnce(r, msg, nil) - if last == nil { - return updated && !fenced, nil - } - if !isUniqueViolation(last) { - return false, last - } - time.Sleep(time.Duration(attempt) * time.Millisecond) - } - if last == nil { - last = fmt.Errorf("store: settle retries exhausted (run %s)", r.ID) - } - return false, last -} - -func (s *Store) settleRunOnce(r *core.Run, msg *core.Message, fence *fenceRef) (bool, bool, error) { +func (s *Store) settleRunOnce(r *core.Run, msg *core.Message, fence *fenceRef, account *accountSettleCheck) (bool, bool, error) { tx, err := s.db.Begin() if err != nil { return false, false, err } defer tx.Rollback() + if account != nil { + query := s.q(`SELECT enabled FROM users WHERE owner=?`) + if s.Dialect() == "postgres" { + query += ` FOR UPDATE` + } + var enabled bool + err := tx.QueryRow(query, r.Owner).Scan(&enabled) + if errors.Is(err, sql.ErrNoRows) { + if account.allowImplicitLocal && r.Owner == OwnerDefault { + enabled, err = true, nil + } else { + return false, false, ErrAccountForbidden + } + } + if err != nil { + return false, false, err + } + if !enabled { + // An already identified account-denial failure may persist a minimal + // error state; it contains no assistant content or business output. + allowedFailure := r.Status == core.RunFailed && r.Error != nil && r.Error.Code == "run.account_inactive" + if msg != nil { + for _, part := range msg.Parts { + if part.Type != core.PartError || part.Err == nil || part.Err.Code != "run.account_inactive" { + allowedFailure = false + break + } + } + } + if !allowedFailure { + return false, false, ErrAccountForbidden + } + } + } if err := s.dialect.lockConversation(tx, string(r.ConversationID)); err != nil { return false, false, err } From 3e01614939f727430ed1fffe626d6bc9250c53e5 Mon Sep 17 00:00:00 2001 From: weichen Date: Wed, 14 Oct 2026 04:58:31 +0000 Subject: [PATCH 07/17] Require password lineage and make recovery retries metadata-only Why: Historical full sessions could reach self-profile actions, and a lost recovery response had no safe idempotent retry contract. Evidence: SELF receipts /tmp/iam-u3-full-password-profile.json, /tmp/iam-u3-recovery-idempotent-sqlite.json, /tmp/iam-u3-recovery-idempotent-pg.json, /tmp/iam-u3-account-sse-pass.json, /tmp/iam-u3-backend-idempotent-all.json, and /tmp/iam-u3-backend-idempotent-pg.json. Impact: Profile actions require a password-authenticated full cookie; duplicate owner confirmation or rotation returns status without plaintext; an active SSE stream stops before its next business frame after disable. Refs: IAM U3 r1 stages 4-5; Dev feature/iam-u3-account-directory-recovery --- internal/server/account_contract_test.go | 35 ++++++++++ internal/server/account_sse_test.go | 78 +++++++++++++++++++++++ internal/server/accounts.go | 5 ++ internal/server/recovery.go | 12 +++- internal/server/recovery_contract_test.go | 7 ++ internal/store/auth.go | 41 ++++++++---- internal/store/recovery_proposals.go | 22 ++++--- internal/store/recovery_proposals_test.go | 32 +++++++--- 8 files changed, 199 insertions(+), 33 deletions(-) create mode 100644 internal/server/account_sse_test.go diff --git a/internal/server/account_contract_test.go b/internal/server/account_contract_test.go index beaf59f..d994af4 100644 --- a/internal/server/account_contract_test.go +++ b/internal/server/account_contract_test.go @@ -70,6 +70,41 @@ func TestAdminAccountSearchFiltersAndVersionedCursor(t *testing.T) { } } +func TestSelfProfileRequiresPasswordAuthenticatedFullSession(t *testing.T) { + srv, st := newAuthTestServer(t) + h := srv.Handler() + hash, err := store.HashPassword("self-profile-password") + if err != nil { + t.Fatal(err) + } + if err := st.CreateAccount("legacy-self", "", hash, false); err != nil { + t.Fatal(err) + } + if err := st.ClearMustChangePassword("legacy-self"); err != nil { + t.Fatal(err) + } + legacyCookie, err := st.CreateSession("legacy-self") + if err != nil { + t.Fatal(err) + } + if rec := do(h, "GET", "/api/me", "", legacyCookie); rec.Code != http.StatusOK { + t.Fatalf("historical full session lost ordinary identity: %d", rec.Code) + } + if rec := do(h, "GET", "/api/me/profile", "", legacyCookie); rec.Code != http.StatusForbidden { + t.Fatalf("unknown session lineage read profile: %d %s", rec.Code, rec.Body.String()) + } + if rec := do(h, "PATCH", "/api/me/profile", `{"expectedProfileVersion":1,"patch":{"name":"injected"}}`, legacyCookie); rec.Code != http.StatusForbidden { + t.Fatalf("unknown session lineage edited profile: %d %s", rec.Code, rec.Body.String()) + } + login := do(h, "POST", "/api/auth/login", `{"owner":"legacy-self","password":"self-profile-password"}`, "") + if login.Code != http.StatusOK { + t.Fatalf("password login: %d %s", login.Code, login.Body.String()) + } + if rec := do(h, "GET", "/api/me/profile", "", cookieOf(login)); rec.Code != http.StatusOK { + t.Fatalf("verified password session could not read profile: %d %s", rec.Code, rec.Body.String()) + } +} + func TestAccountHTTPPendingCreateProfileAndDisable(t *testing.T) { srv, st := newAuthTestServer(t) h := srv.Handler() diff --git a/internal/server/account_sse_test.go b/internal/server/account_sse_test.go new file mode 100644 index 0000000..a706bc6 --- /dev/null +++ b/internal/server/account_sse_test.go @@ -0,0 +1,78 @@ +package server + +import ( + "bufio" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + core "agent-system-core" +) + +func TestAccountDisableStopsNextSSEBusinessFrame(t *testing.T) { + srv, st := newAuthTestServer(t) + if err := st.CreateAccount("admin", "", "admin-hash", true); err != nil { + t.Fatal(err) + } + if err := st.CreateAccount("sse-user", "", "user-hash", false); err != nil { + t.Fatal(err) + } + if err := st.ClearMustChangePassword("sse-user"); err != nil { + t.Fatal(err) + } + user, err := st.GetAccountByOwner("sse-user") + if err != nil { + t.Fatal(err) + } + project, err := st.CreateProject("sse-user", "stream") + if err != nil { + t.Fatal(err) + } + conv, err := st.CreateConversation("sse-user", project.ID, "stream", "") + if err != nil { + t.Fatal(err) + } + selected := make(chan core.StreamEvent, 1) + srv.Subscribe = func(string) (<-chan core.StreamEvent, func()) { return selected, func() {} } + token, err := st.CreateSession("sse-user") + if err != nil { + t.Fatal(err) + } + httpServer := httptest.NewServer(srv.Handler()) + defer httpServer.Close() + request, err := http.NewRequest(http.MethodGet, httpServer.URL+"/api/events?conversationId="+conv.ID, nil) + if err != nil { + t.Fatal(err) + } + request.AddCookie(&http.Cookie{Name: SessionCookie, Value: token}) + client := &http.Client{Timeout: 3 * time.Second} + response, err := client.Do(request) + if err != nil { + t.Fatal(err) + } + defer response.Body.Close() + reader := bufio.NewReader(response.Body) + first, err := reader.ReadString('\n') + if err != nil || !strings.HasPrefix(first, "retry:") { + t.Fatalf("SSE did not open: %q %v", first, err) + } + if _, err := st.SetAccountEnabled("admin", user.ID, user.StateVersion, false); err != nil { + t.Fatal(err) + } + selected <- core.StreamEvent{ConversationID: conv.ID, Seq: 1, Type: core.EvToolOutput, + Payload: json.RawMessage(`{"text":"must-not-stream"}`)} + tail, err := io.ReadAll(reader) + if err != nil { + t.Fatalf("revoked stream did not close: %v", err) + } + if strings.Contains(string(tail), "must-not-stream") { + t.Fatal("disabled account received the next SSE business frame") + } + if _, _, ok := st.SessionInfo(token); ok { + t.Fatal("disabled account session still authenticates") + } +} diff --git a/internal/server/accounts.go b/internal/server/accounts.go index c1c17df..ae45be5 100644 --- a/internal/server/accounts.go +++ b/internal/server/accounts.go @@ -370,6 +370,11 @@ func (s *Server) requireFullPasswordSession(w http.ResponseWriter, r *http.Reque http.Error(w, "full password session required", http.StatusForbidden) return "", false } + method, err := s.st.SessionAuthMethod(token) + if err != nil || method != store.AuthMethodPassword { + http.Error(w, "password-authenticated session required", http.StatusForbidden) + return "", false + } return owner, true } diff --git a/internal/server/recovery.go b/internal/server/recovery.go index f3ba03e..92eee7b 100644 --- a/internal/server/recovery.go +++ b/internal/server/recovery.go @@ -95,7 +95,11 @@ func (s *Server) confirmRecoveryProposal(w http.ResponseWriter, r *http.Request) return } w.Header().Set("Cache-Control", "no-store") - writeJSON(w, http.StatusOK, map[string]any{"proposal": proposal, "grant": plain}) + out := map[string]any{"proposal": proposal} + if plain != "" { + out["grant"] = plain + } + writeJSON(w, http.StatusOK, out) } func (s *Server) rotateRecoveryGrant(w http.ResponseWriter, r *http.Request) { @@ -116,5 +120,9 @@ func (s *Server) rotateRecoveryGrant(w http.ResponseWriter, r *http.Request) { return } w.Header().Set("Cache-Control", "no-store") - writeJSON(w, http.StatusOK, map[string]any{"proposal": proposal, "grant": plain}) + out := map[string]any{"proposal": proposal} + if plain != "" { + out["grant"] = plain + } + writeJSON(w, http.StatusOK, out) } diff --git a/internal/server/recovery_contract_test.go b/internal/server/recovery_contract_test.go index aa5aadb..ad983bf 100644 --- a/internal/server/recovery_contract_test.go +++ b/internal/server/recovery_contract_test.go @@ -61,6 +61,9 @@ func TestOwnerConfirmedRecoveryHTTPKeepsGrantFromAdministrator(t *testing.T) { if err := json.Unmarshal(confirmed.Body.Bytes(), &confirmation); err != nil || confirmation.Grant == "" { t.Fatalf("confirmation secret response: %v", err) } + if duplicate := do(h, "POST", "/api/auth/recovery-proposals/"+proposal.ID+"/confirm", `{"expectedVersion":1}`, ownerCookie); duplicate.Code != http.StatusOK || strings.Contains(duplicate.Body.String(), `"grant"`) { + t.Fatalf("duplicate confirmation replayed grant: %d %s", duplicate.Code, duplicate.Body.String()) + } if got := do(h, "GET", "/api/me/recovery-proposals/"+proposal.ID, "", ownerCookie); got.Code != http.StatusOK || strings.Contains(got.Body.String(), confirmation.Grant) { t.Fatalf("owner status replayed grant: %d %s", got.Code, got.Body.String()) } @@ -75,6 +78,10 @@ func TestOwnerConfirmedRecoveryHTTPKeepsGrantFromAdministrator(t *testing.T) { if err := json.Unmarshal(rotated.Body.Bytes(), &rotation); err != nil || rotation.Grant == "" || rotation.Grant == confirmation.Grant { t.Fatalf("rotation grant: %v", err) } + if duplicate := do(h, "POST", "/api/auth/recovery-proposals/"+proposal.ID+"/rotate-grant", + `{"expectedVersion":`+jsonNumber(confirmation.Proposal.Version)+`,"expectedGeneration":`+jsonNumber(confirmation.Proposal.Generation)+`}`, ownerCookie); duplicate.Code != http.StatusOK || strings.Contains(duplicate.Body.String(), `"grant"`) { + t.Fatalf("duplicate rotation replayed grant: %d %s", duplicate.Code, duplicate.Body.String()) + } if old := do(h, "POST", "/api/auth/recovery-grants/redeem", `{"key":"`+confirmation.Grant+`"}`, ""); old.Code == http.StatusOK { t.Fatal("old recovery grant remained usable") } diff --git a/internal/store/auth.go b/internal/store/auth.go index 484e021..1b68b5d 100644 --- a/internal/store/auth.go +++ b/internal/store/auth.go @@ -165,19 +165,19 @@ const sessionTTL = 7 * 24 * time.Hour // AdminUser 管理员用户列表的展示视图(不含哈希)。 type AdminUser struct { - ID string `json:"userId"` - Owner string `json:"owner"` - Name string `json:"name"` - Enabled bool `json:"enabled"` - StateVersion int64 `json:"stateVersion"` - ProfileVersion int64 `json:"profileVersion"` - InitializationState string `json:"initializationState"` - InitializationGeneration int64 `json:"initializationGeneration"` - IsAdmin bool `json:"isAdmin"` - MustChangePassword bool `json:"mustChangePassword"` - HasPassword bool `json:"hasPassword"` - PasswordSetAt *time.Time `json:"passwordSetAt,omitempty"` - CreatedAt time.Time `json:"createdAt"` + ID string `json:"userId"` + Owner string `json:"owner"` + Name string `json:"name"` + Enabled bool `json:"enabled"` + StateVersion int64 `json:"stateVersion"` + ProfileVersion int64 `json:"profileVersion"` + InitializationState string `json:"initializationState"` + InitializationGeneration int64 `json:"initializationGeneration"` + IsAdmin bool `json:"isAdmin"` + MustChangePassword bool `json:"mustChangePassword"` + HasPassword bool `json:"hasPassword"` + PasswordSetAt *time.Time `json:"passwordSetAt,omitempty"` + CreatedAt time.Time `json:"createdAt"` } // HashPassword 生成 argon2id PHC 串(m=64MiB,t=2,p=2;随机盐)。 @@ -410,6 +410,21 @@ func (s *Store) SessionOwner(token string) (string, bool) { return owner, ok } +// SessionAuthMethod reports persisted authentication lineage for an already +// checked cookie. Callers must still validate current scope/version/effective +// state with SessionInfo before granting any capability. +func (s *Store) SessionAuthMethod(token string) (string, error) { + if token == "" { + return "", ErrNotFound + } + var method string + err := s.db.QueryRow(s.q(`SELECT auth_method FROM sessions WHERE token_hash=?`), hashKey(token)).Scan(&method) + if errors.Is(err, sql.ErrNoRows) { + return "", ErrNotFound + } + return method, err +} + // SessionInfo 返回 owner 与 scope。 func (s *Store) SessionInfo(token string) (owner, scope string, ok bool) { if token == "" { diff --git a/internal/store/recovery_proposals.go b/internal/store/recovery_proposals.go index 58efd41..3679d14 100644 --- a/internal/store/recovery_proposals.go +++ b/internal/store/recovery_proposals.go @@ -225,10 +225,6 @@ func (s *Store) confirmOrRotateRecovery(token, proposalID string, expectedVersio if err != nil { return "", RecoveryProposal{}, err } - plain, id, err := newRecoveryGrant() - if err != nil { - return "", RecoveryProposal{}, err - } tx, err := s.db.Begin() if err != nil { return "", RecoveryProposal{}, err @@ -244,15 +240,25 @@ func (s *Store) confirmOrRotateRecovery(token, proposalID string, expectedVersio return "", RecoveryProposal{}, err } if !account.Enabled || account.ID != p.UserID || account.AuthVersion != p.AuthVersion || account.MustChangePassword || !account.CredentialOrigin.TrustedForOwnerProof() || - p.Version != expectedVersion || p.Generation != expectedGeneration || time.Now().After(p.ExpiresAt) { - return "", RecoveryProposal{}, ErrConflict - } - if (!rotate && p.Status != RecoveryProposalPending) || (rotate && p.Status != RecoveryProposalConfirmed) { + time.Now().After(p.ExpiresAt) { return "", RecoveryProposal{}, ErrConflict } if err := s.eligibleRecoveryProof(tx, account, p, token); err != nil { return "", RecoveryProposal{}, err } + if p.Status == RecoveryProposalConfirmed && p.Version == expectedVersion+1 && p.Generation == expectedGeneration+1 { + // A successful response may have been lost. Never replay the one-time + // plaintext; the same qualified owner receives metadata only. + return "", p, nil + } + if p.Version != expectedVersion || p.Generation != expectedGeneration || + (!rotate && p.Status != RecoveryProposalPending) || (rotate && p.Status != RecoveryProposalConfirmed) { + return "", RecoveryProposal{}, ErrConflict + } + plain, id, err := newRecoveryGrant() + if err != nil { + return "", RecoveryProposal{}, err + } now := time.Now().UTC() newGeneration := p.Generation + 1 newStatus := RecoveryProposalConfirmed diff --git a/internal/store/recovery_proposals_test.go b/internal/store/recovery_proposals_test.go index 767ed3a..db21961 100644 --- a/internal/store/recovery_proposals_test.go +++ b/internal/store/recovery_proposals_test.go @@ -106,32 +106,38 @@ func TestOwnerRecoveryConcurrentConfirmHasOneWinnerAcrossPools(t *testing.T) { t.Fatal(err) } start := make(chan struct{}) - results := make(chan error, 2) + type result struct { + grant string + err error + } + results := make(chan result, 2) var wg sync.WaitGroup for _, conn := range []*Store{st, other} { wg.Add(1) go func(conn *Store) { defer wg.Done() <-start - _, _, err := conn.ConfirmRecoveryProposal(proof, proposal.ID, proposal.Version) - results <- err + grant, _, err := conn.ConfirmRecoveryProposal(proof, proposal.ID, proposal.Version) + results <- result{grant, err} }(conn) } close(start) wg.Wait() close(results) - wins, conflicts := 0, 0 - for err := range results { - if err == nil { + wins, metadataOnly, conflicts := 0, 0, 0 + for outcome := range results { + if outcome.err == nil && outcome.grant != "" { wins++ - } else if errors.Is(err, ErrConflict) { + } else if outcome.err == nil && outcome.grant == "" { + metadataOnly++ + } else if errors.Is(outcome.err, ErrConflict) { conflicts++ } else { - t.Fatal(err) + t.Fatal(outcome.err) } } - if wins != 1 || conflicts != 1 { - t.Fatalf("concurrent confirmation wins=%d conflicts=%d", wins, conflicts) + if wins != 1 || metadataOnly+conflicts != 1 { + t.Fatalf("concurrent confirmation plaintext winners=%d metadata-only=%d conflicts=%d", wins, metadataOnly, conflicts) } } @@ -155,10 +161,16 @@ func TestOwnerProofRecoveryBindsPreexistingSessionAndRotatesGrant(t *testing.T) if err != nil || first == "" || confirmed.Status != RecoveryProposalConfirmed { t.Fatalf("confirm: %+v %v", confirmed, err) } + if replay, state, err := st.ConfirmRecoveryProposal(proof, proposal.ID, proposal.Version); err != nil || replay != "" || state.Version != confirmed.Version { + t.Fatalf("duplicate confirmation replayed secret or changed state: %+v %v", state, err) + } second, rotated, err := st.RotateRecoveryGrant(proof, proposal.ID, confirmed.Version, confirmed.Generation) if err != nil || second == first || rotated.Generation != confirmed.Generation+1 { t.Fatalf("rotate: %+v %v", rotated, err) } + if replay, state, err := st.RotateRecoveryGrant(proof, proposal.ID, confirmed.Version, confirmed.Generation); err != nil || replay != "" || state.Version != rotated.Version { + t.Fatalf("duplicate rotation replayed secret or changed state: %+v %v", state, err) + } if _, _, err := st.RedeemLoginKey(first); err == nil { t.Fatal("rotated grant remained redeemable") } From 4053c4d038b1d13a356e0196db56ab11c0e4f8e1 Mon Sep 17 00:00:00 2001 From: weichen Date: Wed, 14 Oct 2026 05:35:44 +0000 Subject: [PATCH 08/17] Preserve API key CLI output compatibility Why: U3 owner activation grants require protected files, while the merged U7 API-key command retains its existing optional file output. Evidence: SELF go test ./cmd/agent-system ./internal/app -count=1. Impact: Keep owner-grant delivery strict without changing API-key CLI defaults. Refs: IAM U3 r1; U7 PR #185 --- cmd/agent-system/account_io_test.go | 14 -------------- cmd/agent-system/main.go | 8 ++++---- 2 files changed, 4 insertions(+), 18 deletions(-) diff --git a/cmd/agent-system/account_io_test.go b/cmd/agent-system/account_io_test.go index 0d8bb85..c703ad5 100644 --- a/cmd/agent-system/account_io_test.go +++ b/cmd/agent-system/account_io_test.go @@ -99,20 +99,6 @@ func TestExistingOutputAndConflictingFormatsRefuseBeforeMutation(t *testing.T) { } } -func TestAPIKeyCreateWithoutProtectedDestinationRefusesBeforeMutation(t *testing.T) { - dir, st := cliStore(t) - if err := st.CreateAccount("ada", "Ada", "", false); err != nil { - t.Fatal(err) - } - if rc := runAPIKey([]string{"create", "--data", dir, "--owner", "ada", "--name", "fixture"}); rc != 2 { - t.Fatalf("API key creation without private destination returned %d", rc) - } - var count int - if err := st.DB().QueryRow(`SELECT COUNT(*) FROM api_keys WHERE owner='ada'`).Scan(&count); err != nil || count != 0 { - t.Fatalf("rejected request created %d API keys: %v", count, err) - } -} - func TestAPIKeyCreateUsesExclusivePrivateOutput(t *testing.T) { if dir := os.Getenv("U7_APIKEY_CHILD_DIR"); dir != "" { rc := runAPIKey([]string{"create", "--data", dir, "--owner", "ada", "--name", "fixture", "--out-file", os.Getenv("U7_APIKEY_CHILD_OUT")}) diff --git a/cmd/agent-system/main.go b/cmd/agent-system/main.go index f10abc7..feb602a 100644 --- a/cmd/agent-system/main.go +++ b/cmd/agent-system/main.go @@ -217,10 +217,6 @@ func runAPIKey(args []string) int { *dsn = resolvedDSN switch sub { case "create": - if *outFile == "" { - fmt.Fprintln(os.Stderr, "apikey create: --out-file is required for protected credential delivery") - return 2 - } delivery, err := reserveCredentialOutput(*outFile, "") if err != nil { fmt.Fprintln(os.Stderr, "apikey create:", err) @@ -233,6 +229,10 @@ func runAPIKey(args []string) int { return 1 } // 明文仅此一次输出;库中只存 SHA-256 摘要。 + if delivery.file == nil { + fmt.Println(key) // Existing API-key CLI compatibility; owner grants require a file. + return 0 + } if err := delivery.deliver("api-key", *owner, key, nil); err != nil { fmt.Fprintln(os.Stderr, "apikey create:", err) return 1 From adcd8fefd2958e4811baf4d3ab94002d4689acac Mon Sep 17 00:00:00 2001 From: weichen Date: Wed, 14 Oct 2026 07:33:54 +0000 Subject: [PATCH 09/17] 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. --- docs/iam-u3-account-write-fence-inventory.md | 120 +++++++ internal/app/account_directory.go | 135 ++++---- internal/app/account_directory_test.go | 96 +++++- internal/app/account_guard_test.go | 33 ++ internal/app/account_lister_test.go | 3 + internal/app/app.go | 42 ++- internal/app/ext9_publish_test.go | 3 + internal/app/hostcaps.go | 73 ++-- internal/app/inproc_host_test.go | 6 + internal/app/knowledge_authorization_test.go | 14 + internal/app/local_account_test.go | 42 +++ internal/app/project_lifecycle.go | 13 +- internal/app/project_lifecycle_test.go | 8 + internal/app/scoped_host_test.go | 3 + internal/cardsvc/account_checked.go | 219 ++++++++++++ internal/cardsvc/account_checked_test.go | 109 ++++++ internal/cardsvc/cardsvc.go | 23 +- internal/cmdbus/account_guard_test.go | 169 +++++++++ internal/cmdbus/account_write_fence_test.go | 18 + internal/cmdbus/cmdbus.go | 222 ++++++++++-- internal/plugins/account_reload_test.go | 137 ++++++++ internal/plugins/host.go | 95 +++++- internal/plugins/host_v2.go | 22 +- internal/profiles/profiles.go | 17 + internal/profiles/profiles_fence_test.go | 54 +++ internal/prompts/service.go | 21 ++ internal/prompts/service_fence_test.go | 52 +++ internal/rag/account_index_test.go | 83 +++++ internal/rag/hybrid.go | 88 +++++ internal/runmgr/admission.go | 13 +- internal/runmgr/commands.go | 2 +- internal/runmgr/events.go | 60 +++- .../runmgr/file_effect_account_check_test.go | 173 ++++++++++ internal/runmgr/genui_auto.go | 2 +- internal/runmgr/inputs.go | 53 ++- internal/runmgr/memory.go | 25 +- internal/runmgr/rewind.go | 13 +- internal/runmgr/runmgr.go | 322 +++++++++++++++--- internal/runmgr/subagent_transcript.go | 99 +++++- internal/server/account_contract_test.go | 5 +- internal/server/account_effect_guard.go | 8 + internal/server/account_search.go | 29 +- internal/server/account_write_fence_test.go | 208 +++++++++++ internal/server/accounts.go | 7 +- internal/server/adminzone_test.go | 2 +- internal/server/api_key_fixture_test.go | 17 + internal/server/audit_scope_test.go | 4 +- internal/server/auth_e2e_test.go | 14 +- internal/server/borrow.go | 97 +++--- internal/server/borrow_account_write_test.go | 158 +++++++++ internal/server/compiled_list_test.go | 2 +- internal/server/e2e_test.go | 12 + internal/server/ext9_upload_switch_test.go | 2 +- internal/server/platform_api.go | 32 +- internal/server/project_events_test.go | 5 +- internal/server/project_lifecycle.go | 6 +- internal/server/project_lifecycle_test.go | 40 +-- .../server/project_resource_filter_test.go | 8 +- .../server/resource_authz_consumption_test.go | 4 +- internal/server/resource_authz_test.go | 6 +- internal/server/rewind.go | 2 +- internal/server/server.go | 183 +++++++--- internal/server/shared_projects_test.go | 4 +- internal/server/shared_retrieval_test.go | 12 +- internal/server/surface_api_test.go | 4 +- internal/settings/secrets.go | 18 + internal/settings/settings.go | 33 ++ .../settings/settings_account_write_test.go | 106 ++++++ internal/store/account_budget_read_test.go | 15 + internal/store/account_cursor_key_test.go | 93 +++++ internal/store/account_event_fence_test.go | 47 +++ internal/store/account_lock_order_pg_test.go | 60 ++++ internal/store/account_model.go | 37 ++ internal/store/account_state.go | 15 + internal/store/account_tx.go | 5 - internal/store/account_write_guard.go | 40 +++ internal/store/admission.go | 33 +- internal/store/artifact_account_write_test.go | 212 ++++++++++++ internal/store/audit.go | 16 + internal/store/auth.go | 42 ++- internal/store/auth_account_key_test.go | 132 +++++++ internal/store/borrow.go | 266 +++++++++++++++ internal/store/borrow_account_write_test.go | 202 +++++++++++ internal/store/cards_account_write.go | 241 +++++++++++++ internal/store/cards_account_write_test.go | 232 +++++++++++++ internal/store/config.go | 130 +++++++ internal/store/content.go | 309 ++++++++++++++++- internal/store/conversation_account_write.go | 35 ++ .../store/conversation_metadata_fence_test.go | 167 +++++++++ internal/store/execution.go | 112 +++++- .../store/execution_account_write_test.go | 152 +++++++++ internal/store/hitl_account_write.go | 71 ++++ .../store/knowledge_account_write_test.go | 252 ++++++++++++++ .../store/knowledge_owned_account_write.go | 35 ++ internal/store/login_key_admin_write.go | 66 ++++ .../store/message_choice_account_write.go | 64 ++++ internal/store/messages.go | 124 ++++++- internal/store/migrate.go | 15 + internal/store/migrate_compat.go | 1 + internal/store/notices.go | 19 ++ internal/store/plan_todo_account_write.go | 39 +++ internal/store/platform.go | 44 +++ internal/store/platform_account_write.go | 95 ++++++ internal/store/plugin_account_write.go | 41 +++ internal/store/plugin_account_write_test.go | 102 ++++++ internal/store/profile_prompt_fence_test.go | 157 +++++++++ internal/store/project_events.go | 25 ++ internal/store/project_fence_test.go | 183 ++++++++++ internal/store/project_projection.go | 114 +++++++ .../store/project_projection_fence_test.go | 149 ++++++++ internal/store/projects.go | 221 ++++++++++++ internal/store/prompts.go | 44 +++ internal/store/recovery.go | 11 +- internal/store/recovery_holds.go | 13 + internal/store/recovery_holds_test.go | 22 ++ internal/store/regeneration_account_write.go | 120 +++++++ .../store/regeneration_account_write_test.go | 176 ++++++++++ internal/store/run_account_write.go | 101 ++++++ internal/store/runtime_account_sink_test.go | 145 ++++++++ internal/store/settings_account_write_test.go | 169 +++++++++ internal/store/store_test.go | 5 + internal/store/surface_account_write_test.go | 104 ++++++ internal/store/surface_actions.go | 55 ++- internal/store/surfaces.go | 68 +++- internal/store/vectors.go | 66 ++++ internal/surfacesvc/account_write_test.go | 133 ++++++++ internal/surfacesvc/surfacesvc.go | 170 +++++++-- internal/tools/codeorch_tool.go | 8 +- internal/tools/effect_guard.go | 36 ++ internal/tools/exec.go | 6 + internal/tools/exec_backend.go | 15 +- internal/tools/exec_interpreter.go | 17 +- internal/tools/exec_workspace.go | 62 +++- internal/tools/external_effect_test.go | 189 ++++++++++ internal/tools/fs.go | 15 + internal/tools/http.go | 6 +- internal/tools/registry.go | 2 + internal/tools/search.go | 10 +- 138 files changed, 9352 insertions(+), 536 deletions(-) create mode 100644 docs/iam-u3-account-write-fence-inventory.md create mode 100644 internal/cardsvc/account_checked.go create mode 100644 internal/cardsvc/account_checked_test.go create mode 100644 internal/cmdbus/account_guard_test.go create mode 100644 internal/cmdbus/account_write_fence_test.go create mode 100644 internal/plugins/account_reload_test.go create mode 100644 internal/profiles/profiles_fence_test.go create mode 100644 internal/prompts/service_fence_test.go create mode 100644 internal/rag/account_index_test.go create mode 100644 internal/runmgr/file_effect_account_check_test.go create mode 100644 internal/server/account_effect_guard.go create mode 100644 internal/server/account_write_fence_test.go create mode 100644 internal/server/api_key_fixture_test.go create mode 100644 internal/server/borrow_account_write_test.go create mode 100644 internal/settings/settings_account_write_test.go create mode 100644 internal/store/account_budget_read_test.go create mode 100644 internal/store/account_cursor_key_test.go create mode 100644 internal/store/account_event_fence_test.go create mode 100644 internal/store/account_lock_order_pg_test.go create mode 100644 internal/store/account_write_guard.go create mode 100644 internal/store/artifact_account_write_test.go create mode 100644 internal/store/auth_account_key_test.go create mode 100644 internal/store/borrow_account_write_test.go create mode 100644 internal/store/cards_account_write.go create mode 100644 internal/store/cards_account_write_test.go create mode 100644 internal/store/conversation_account_write.go create mode 100644 internal/store/conversation_metadata_fence_test.go create mode 100644 internal/store/execution_account_write_test.go create mode 100644 internal/store/hitl_account_write.go create mode 100644 internal/store/knowledge_account_write_test.go create mode 100644 internal/store/knowledge_owned_account_write.go create mode 100644 internal/store/login_key_admin_write.go create mode 100644 internal/store/message_choice_account_write.go create mode 100644 internal/store/plan_todo_account_write.go create mode 100644 internal/store/platform_account_write.go create mode 100644 internal/store/plugin_account_write.go create mode 100644 internal/store/plugin_account_write_test.go create mode 100644 internal/store/profile_prompt_fence_test.go create mode 100644 internal/store/project_fence_test.go create mode 100644 internal/store/project_projection_fence_test.go create mode 100644 internal/store/regeneration_account_write.go create mode 100644 internal/store/regeneration_account_write_test.go create mode 100644 internal/store/run_account_write.go create mode 100644 internal/store/runtime_account_sink_test.go create mode 100644 internal/store/settings_account_write_test.go create mode 100644 internal/store/surface_account_write_test.go create mode 100644 internal/surfacesvc/account_write_test.go create mode 100644 internal/tools/effect_guard.go create mode 100644 internal/tools/external_effect_test.go diff --git a/docs/iam-u3-account-write-fence-inventory.md b/docs/iam-u3-account-write-fence-inventory.md new file mode 100644 index 0000000..48edd6e --- /dev/null +++ b/docs/iam-u3-account-write-fence-inventory.md @@ -0,0 +1,120 @@ +# IAM U3 A1: account write fence inventory + +Status: stage 4–5 implementation inventory with **one open delegated-projection blocker**; stage 6 is not ready. The route table records the **4053c4d baseline finding** and the intended final sink; its `Now` column is historical, not a claim about the amended candidate. Scope follows the approved Documents amendment `docs/proposals/2026-10-14-iam-u3-stage6-review-amendment.md` at `fb3a8214df963697ebd745ad11eb852ed9444fe3`. The pending additional proposal is `docs/proposals/2026-10-14-iam-u3-delegated-projection-actor-amendment.md` in the Documents task branch. + +The 66 non-GET `/api` registrations were enumerated from `internal/server/server.go`. Paths below omit `/api`. `A` means `s.owner(r)` (auth-off: implicit `store.OwnerDefault`); `B` means body owner, `K` redeemed-key owner, `S` current password-session owner. Target resource owner never substitutes for actor. In the historical `Now` column, `T` means the baseline already had a final Store check; `M` means a Backend DB write lacked one; `E` means an external or process-local effect; `—` means no business mutation or permitted credential cleanup. `D` means a short Store transaction with actor account lock and enabled read **before** resource/scope/conversation locks; `F` means stage immutable bytes, then `D` for the visible DB reference; `X` means last effective-state check before external action plus cancellation; `C` means `D` at each command handler's actual sink; `P` means `D` for Backend projection and `X` at the separate RDM callback. `Vnn` identifies the route for traceability; verification uses representative concurrency cases and complete source-to-sink review, rather than one duplicate test per route. + +| # | Route → handler (source line) | Actor | Final sink | Now | Required fence | Verify | +|---:|---|---|---|---|---|---| +| 01 | `POST /auth/login` → `login` (`accounts.go:148`) | B | `CreateSessionVerified` | T | retain account/version transaction check | V01 | +| 02 | `POST /auth/login-key/redeem` → `redeemLoginKey` (`accounts.go:106`) | K | `RedeemLoginKey`; failed-attempt security counter | T | retain account/grant transaction check | V02 | +| 03 | `POST /auth/recovery-grants/redeem` → `redeemLoginKey` (`accounts.go:106`) | K | same as 02 | T | same as 02 | V03 | +| 04 | `POST /auth/logout` → `logout` (`accounts.go:202`) | A | `DeleteSession` credential cleanup | — | permit cleanup after disable | V04 | +| 05 | `POST /auth/password` → `changePassword` (`accounts.go:214`) | S | checked setup/forced/active password Store transaction | T | retain session/account recheck | V05 | +| 06 | `POST /admin/users` → `createAdminUser` (`accounts.go:321`) | A | `CreatePendingAccount` | T | retain actor enabled/admin transaction check | V06 | +| 07 | `PATCH /me/profile` → `patchMeProfile` (`accounts.go:394`) | S | `PatchAccountProfile` | T | retain actor/target transaction check | V07 | +| 08 | `PATCH /admin/users/{userId}/profile` → `patchAdminAccountProfile` (`accounts.go:419`) | A | `PatchAccountProfile` | T | retain actor/target transaction check | V08 | +| 09 | `PUT /admin/users/{userId}/state` → `setAdminAccountState` (`accounts.go:444`) | A | `SetAccountEnabled` | T | retain shared account lock and in-tx actor check | V09 | +| 10 | `POST /admin/users/{userId}/recovery-proposals` → `createAdminRecoveryProposal` (`recovery.go:7`) | A | `CreateRecoveryProposal` | T | retain actor/target transaction check | V10 | +| 11 | `DELETE /admin/users/{userId}/recovery-proposals/{id}` → `revokeAdminRecoveryProposal` (`recovery.go:47`) | A | `RevokeRecoveryProposal` | T | retain actor/target transaction check | V11 | +| 12 | `POST /auth/recovery-proposals/{id}/confirm` → `confirmRecoveryProposal` (`recovery.go:81`) | S | `ConfirmRecoveryProposal` | T | retain owner/session proof in transaction | V12 | +| 13 | `POST /auth/recovery-proposals/{id}/rotate-grant` → `rotateRecoveryGrant` (`recovery.go:105`) | S | `RotateRecoveryGrant` | T | retain owner/session proof in transaction | V13 | +| 14 | `POST /admin/users/{owner}/password` → `resetAdminUserPassword` (`accounts.go:468`) | A | 410 response only | — | no business sink | V14 | +| 15 | `POST /admin/users/{owner}/login-keys` → `issueAdminLoginKey` (`accounts.go:478`) | A | 410 response only | — | no business sink | V15 | +| 16 | `DELETE /admin/users/{owner}/login-keys/{id}` → `revokeAdminLoginKey` (`accounts.go:494`) | A | `RevokeLoginKeyCascade` | M | D: lock/recheck admin actor too, then target/grant | V16 | +| 17 | `POST /projects` → `createProject` (`server.go:713`) | A | `CreateProject` or RDM `Create` + Host projection | M/E | P | V17 | +| 18 | `PATCH /projects/{id}` → `patchProject` (`server.go:734`) | A | RDM `Rename` or `RenameProject`; `UpdateProjectSettings` | M/E | P; D for every settings write | V18 | +| 19 | `DELETE /projects/{id}` → `deleteProject` (`server.go:812`) | A | RDM `Archive` or `DeleteProject` | M/E | P | V19 | +| 20 | `POST /projects/{id}/conversations` → `createConversation` (`server.go:875`) | A | `CreateConversation[InProject]`; RDM `Work` precheck | M | D; keep domain work check | V20 | +| 21 | `PATCH /conversations/{id}` → `patchConversation` (`server.go:914`) | A | update, permission, note, flags: separate Store writes | M | D at each final write | V21 | +| 22 | `DELETE /conversations/{id}` → `deleteConversation` (`server.go:950`) | A | `DeleteConversation` | M | D | V22 | +| 23 | `POST /conversations/{id}/messages` → `postMessage` (`server.go:956`) | A | message, run, message-run link; optional Bus dispatch | M/E | D per write; C for command; actor lock before conversation lock | V23 | +| 24 | `POST /conversations/{id}/rewind` → `rewindFiles` (`rewind.go:27`) | A | checkpoint files, then `AppendMessage` | M/E | X for rewind, D for message | V24 | +| 25 | `POST /conversations/{id}/regenerate` → `regenerateMessage` (`borrow.go:159`) | A | message flags/exclusion, regen marker, rewind files, new run | M/E | D per write, X for rewind | V25 | +| 26 | `POST /messages/{id}/exclude` → `excludeMessage` (`borrow.go:122`) | A | `ExcludeRunGroup(messageID)` | M | D; derive conversation owner in transaction | V26 | +| 27 | `POST /messages/{id}/variant` → `selectVariant` (`borrow.go:146`) | A | `SelectVariant(messageID)` | M | D; derive conversation owner in transaction | V27 | +| 28 | `POST /secrets` → `createSecret` (`borrow.go:21`) | A | `AddSecret` | M | D | V28 | +| 29 | `POST /secrets/{id}/activate` → `activateSecret` (`borrow.go:39`) | A | `ActivateSecret` | M | D | V29 | +| 30 | `PATCH /secrets/{id}` → `renameSecret` (`borrow.go:43`) | A | `RenameSecret` | M | D | V30 | +| 31 | `DELETE /secrets/{id}` → `deleteSecret` (`borrow.go:54`) | A | `DeleteSecret` | M | D | V31 | +| 32 | `POST /context-entries` → `saveContextEntry` (`borrow.go:64`) | A | `SaveContextEntry` | M | D | V32 | +| 33 | `DELETE /context-entries/{id}` → `deleteContextEntry` (`borrow.go:78`) | A | `DeleteContextEntry` | M | D | V33 | +| 34 | `POST /prompt-commands` → `saveUserPromptCommand` (`borrow.go:87`) | A | `SaveUserCommand`, then process Bus register | M/E | D; X before Bus mutation | V34 | +| 35 | `DELETE /prompt-commands/{id}` → `deleteUserPromptCommand` (`borrow.go:102`) | A | `DeleteUserCommand`, then Bus unregister | M/E | D; X before Bus mutation | V35 | +| 36 | `POST /runs/{id}/cancel` → `cancelRun` (`server.go:1278`) | A | PG `EnqueueCommand` or local cancel handle | M/E | D for enqueue with in-tx run owner; X locally | V36 | +| 37 | `POST /hitl/{id}/resolve` → `resolveHITL` (`server.go:1314`) | A | local HITL/card/event writes or PG `EnqueueCommand` | M | D at each Store sink, including queue | V37 | +| 38 | `POST /cards/{id}/actions/{actionId}` → `cardAction` (`server.go:1401`) | A | Cards action or Bus confirm, then receipt message | M/E | D for card/message; C for confirmed handler | V38 | +| 39 | `POST /cards/{id}/pin` → `cardPin` (`server.go:1453`) | A | card version write | M | D | V39 | +| 40 | `POST /cards/{id}/dismiss` → `cardDismiss` (`server.go:1462`) | A | card version/event write | M | D | V40 | +| 41 | `POST /cards/{id}/to_project` → `cardToProject` (`server.go:1386`) | A | project card copy and original dismissal | M | D at both writes | V41 | +| 42 | `POST /projects/{id}/uploads` → `upload` (`server.go:1469`) | A | Blob + artifact row, card, knowledge/vector index | M/E | F; D at later card/index writes | V42 | +| 43 | `PUT /artifacts/{id}/content` → `putArtifactContent` (`server.go:1651`) | A | **in-place Blob overwrite before SQL UPDATE** | M/E | F using new immutable blob ref, guarded DB pointer swap | V43 | +| 44 | `DELETE /artifacts/{id}` → `deleteArtifact` (`server.go:1656`) | A | metadata, index/vector rows, Blob deletion | M/E | D for visible rows; X for post-commit GC | V44 | +| 45 | `POST /profiles` → `saveProfile` (`server.go:1667`) | A | profile Store row | M | D | V45 | +| 46 | `DELETE /profiles/{id}` → `deleteProfile` (`server.go:1683`) | A | profile Store row | M | D | V46 | +| 47 | `POST /prompts` → `savePromptModule` (`server.go:2023`) | A | prompt Store row | M | D | V47 | +| 48 | `DELETE /prompts/{id}` → `deletePromptModule` (`server.go:2044`) | A | prompt Store row | M | D | V48 | +| 49 | `PUT /settings/user` → `putSettingsUser` (`server.go:1717`) | A | settings transaction, then separate snapshot row | M | D for settings and snapshot | V49 | +| 50 | `PUT /settings/project/{id}` → `putSettingsProject` (`server.go:1726`) | A | settings transaction, then separate snapshot row | M | D for settings and snapshot | V50 | +| 51 | `PUT /settings/system` → `putSettingsSystem` (`server.go:1782`) | A | system settings and snapshot under synthetic owner | M | D with **request actor**, not `OwnerSystemRow` | V51 | +| 52 | `POST /tools` → `createUserTool` (`server.go:1815`) | A | process Registry register; future HTTP tool execution | E | X for registration and each tool execution | V52 | +| 53 | `DELETE /tools/{name}` → `deleteUserTool` (`server.go:1834`) | A | process Registry unregister | E | X | V53 | +| 54 | `POST /plugins/{id}/enabled` → `setPluginEnabled` (`platform_api.go:132`) | A | `SetPluginEnabled` upsert | M | D | V54 | +| 55 | `POST /plugins/{id}/activation` → `setSurfaceActivation` (`platform_api.go:251`) | A | activation transaction, separate audit row | M | D for both writes | V55 | +| 56 | `POST /surfaces` → `submitSurface` (`platform_api.go:194`) | A | snapshot transaction, then event/pending row | M | D for snapshot/event writes | V56 | +| 57 | `POST /surfaces/{id}/actions` → `submitSurfaceAction` (`platform_api.go:271`) | A | action insert, Bus dispatch, action-state update | M/E | D for both action writes; C for dispatch | V57 | +| 58 | `POST /plugins/reload` → `reloadPlugins` (`server.go:1908`) | A | process registry/sidecar changes and `UpsertPlugin` | M/E | X for process changes; D for DB writes | V58 | +| 59 | `POST /commands/{id}` → `execCommand` (`server.go:1211`) | A | Bus audit/card/pending or arbitrary command handler/RDM | M/E | C, X for external handler | V59 | +| 60 | `POST /projects/{id}/knowledge` → `addKnowledge` (`server.go:2055`) | A | `AddKnowledge` batch transaction | M | D; in-tx project/owner check | V60 | +| 61 | `POST /tasks` → `createBackgroundTask` (`platform_api.go:16`) | A | task insert | M | D | V61 | +| 62 | `POST /tasks/{id}` → `updateBackgroundTask` (`platform_api.go:52`) | A | task update | M | D | V62 | +| 63 | `POST /memory` → `putMemory` (`platform_api.go:69`) | A | memory upsert | M | D | V63 | +| 64 | `POST /audit` → `postAudit` (`platform_api.go:112`) | A | client-origin business audit row | M | D; security-denial audit is a separate exception | V64 | +| 65 | `POST /admin/backup` → `backup` (`server.go:2224`) | A | directory + SQLite backup file | E | X and explicit admin authorization | V65 | +| 66 | `POST /admin/recovery-holds/{runId}/release` → `releaseRecoveryHold` (`accounts.go:526`) | A | recovery-hold UPDATE | M | D with actor admin check and run/hold owner lookup | V66 | + +The route count is not a full write count. `GET /api/llm/budget` → `llmBudget` (`platform_api.go`) calls `AccountTokens(owner, 0, budget)`; the amended Store returns a read-only total when tokens are zero. **V67** therefore has no business write. Ordinary authenticated GETs can refresh session `last_seen_at` as credential maintenance; session/API-key in-flight revocation remains a separate impact analysis. + +## Host and run manager exits + +| ID | Entry and actor | Final sink | Now → required fence | Verify | +|---|---|---|---|---| +| H01 | `inprocHost.KVPut`, host owner (`hostcaps.go:295`) | `PutPluginKV` | M → D | VH01 | +| H02 | `inprocHost.Audit`, host owner; actor label is `plugin:` (`hostcaps.go:309`) | `AppendAuditEvent` | M → D using host owner | VH02 | +| H03 | `KnowledgeWrite/Publish/Delete`, host owner (`hostcaps.go:350-424`) | knowledge rows, vector rows; publish deletes then writes | M/E → D at every DB sink; X before embedding/remote model | VH03 | +| H04 | `TaskCreate/Update`, host owner (`hostcaps.go:427-443`) | background-task rows | M → D | VH04 | +| H05 | `EnsureProject/RenameOwnedProject`, host owner (`project_lifecycle.go:125-135`) | Backend projection rows, RDM callback upstream | M/E → actor D before project-name lock; X at callback | VH05 | +| H06 | `SurfaceSave`, host owner (`hostcaps.go:481`) | `surfacesvc.Submit` snapshot/event | M → D at Store sinks | VH06 | +| H07 | `Complete`, host owner (`hostcaps.go:319`) | external LLM request | E → X with context cancellation | VH07 | +| H08 | scoped Host reconstructed from owner/plugin ID (`hostcaps.go:103`) | same H01-H07 methods | M/E → same final sink fences; early `requireEffective` alone is insufficient | VH08 | +| G01 | `startClaimedRun`/subagent spawn, run owner (`runmgr.go:1443,1924`) | `CreateRun`, `UpdateRun` and admission rows | M → D for business state; keep failure-only cleanup explicit | VG01 | +| G02 | `Emit`, `AskHITL`, cards, plan/todos (`runmgr.go:676-935`) | events, HITL/card, plan/todo rows | M → D at each final Store write | VG02 | +| G03 | `WriteArtifact`, run owner (`runmgr.go:968`) | Blob, artifact, event, knowledge/vector rows | M/E → F then D for each later write | VG03 | +| G04 | `ExecuteTool`, run owner (`runmgr.go:1080-1167`) | Registry HTTP/command/filesystem side effects, output artifacts | E/M → X before external call; C/D for command and output sinks | VG04 | +| G05 | `stageInputs`, checkpoint recording/rewind and subagent transcript (`inputs.go:17-90`, `rewind.go:37-99`, `subagent_transcript.go:46-84`) | workspace files | E → X before each effect and cancellation; no cross-system atomic claim | VG05 | +| G06 | `ResolveHITL`, run/card owner (`runmgr.go:1780-1860`) | HITL/card/event and possible run writes | M → D | VG06 | +| G07 | background notice delivery and subagent completion (`runmgr.go:1982-2041,2444-2455`) | run/message/event plus transcript files | M/E → D for business rows, X for files | VG07 | +| G08 | `settleRunWithFence`, run owner (`runmgr.go:1700-1722`) | final run + assistant message | T for enabled check in `store/recovery.go:184-220`; retain narrow failure-state exception | VG08 | +| G09 | `OnUsage`, run owner (`app.go:668-680`) | usage and budget audit rows | M → D or documented accounting-only exception | VG09 | + +At the 4053c4d baseline, Host/runmgr effective-account queries were early checks, Bus confirmation and Surface writes had separate sinks, and artifact content could be overwritten in place before a checked SQL update. These are the reasons for the sink-level mapping below; file paths and old line numbers in the historical tables are navigational rather than final-SHA evidence. + +## Amended candidate mapping and limits + +This section supersedes the historical `Now` column when checking the amended candidate. V01–V15 retain their existing account/credential transaction or response-only behavior; V16 uses actor-checked login-key revocation. V17–V23 route project, conversation, message, run creation and link writes through account-checked Store entries. V24–V35 use guarded file/message and borrow/secret/context/command entries; V25 prepares the message exclusion and regeneration marker in one checked transaction, then consumes the marker only with successful run settlement. V36–V44 guard command queue, HITL, card, surface/event, artifact metadata and index writes. Artifact content updates switch immutable blob references in the checked transaction. V45–V51 guard profile, prompt, settings and snapshot writes, using the **request actor** for system settings. V52–V59 combine last checks for process/HTTP/tool effects with guarded plugin, surface, card, command pending and audit DB writes. V60–V66 use guarded knowledge, task, memory, audit and hold updates. V65 refuses whole-database backup under authenticated mode because administrator status does not grant access to other owners' business content; auth-off backup has a last check. V67 is read-only at zero tokens. + +VH01–VH04/VH06/VH08 use the Host owner and final Store account checks for Backend-owned rows; H05's RDM callback has a separate last actor check but the delegated rename gap below remains. VH07 checks immediately before its external model call and passes the caller context. VG01–VG09 use checked run, admission/lease, event, HITL, card, artifact, index, notice, command and settle sinks. File staging, transcript, tool HTTP/search/code execution and overlay writeback check just before effects and propagate cancellation. Failed/cancelled/interrupted run sealing, lease release, notice deletion and failed-start marker deletion are narrowly scoped safety cleanup; positive token usage is accounting for model work already incurred. + +**Open H05/V17–V19 blocker:** delegated RDM rename reconstructs a creator-scoped Host after authorizing the real operator. The current Backend `RenameOwnedProject` final transaction checks the creator as actor and can therefore commit after the delegated operator is disabled. This is a Backend same-database late write, outside the accepted cross-database limitation. The additional Documents proposal specifies a request-bound operator projection capability and a two-account final fence. Do not mark A1 or stage 6 complete until that change is approved, implemented and verified. + +The account lock closes the **account-enabled versus Backend-owned business write** race on the same database. It does not prove that a session or API key revoked after request authentication cannot complete a write while that account remains enabled; that is a distinct credential-version fence and outside this amendment's promise. RDM commits in its own database and external requests, processes and files cannot share the Backend transaction. The last check can race with those effects, and a multi-file operation can leave earlier files committed when a later check fails. No cross-database or external zero-window claim is made. + +**Sink discovery still required:** Backend source alone cannot enumerate every dynamically registered Bus handler, plugin sidecar command, or the RDM callback's independent database writes. V17–V19, V23, V38, V57–V59 and VH05 must record the actual composed handlers and their final sinks in the tested composition; an unknown or unguarded handler blocks the A1 completion claim. The external HTTP tool destination and filesystem actions depend on runtime configuration, so VG04/VG05 must verify the actual enabled tool set and cancellation points. + +## Common implementation and verification gate + +1. Build one short Store write-transaction primitive: `Begin` → PG actor advisory lock (`account:`) → `SELECT enabled ... FOR UPDATE` → existing resource/scope/conversation/admission locks → resource authorization and write → commit. The actor lock must precede all other PG locks, including project-name locks; participating account owners use sorted order, then bootstrap lock where required. `SetAccountEnabled` already takes the account lock (`store/account_state.go:20-45`); `lockAccountUsers` supplies sorted PG account locking (`store/account_model.go:115-145`). SQLite uses `_txlock=immediate` (`store/store.go:188-199`). In auth-off mode, only a missing `OwnerDefault` row is implicitly enabled; an existing disabled row rejects (`app/account_guard.go:10-23`). No DB connection or transaction spans request body decoding, embedding, tool execution, RDM callback, or full HTTP lifetime. +2. Make methods with only resource IDs derive owner inside the guarded transaction. Pass actor separately for system settings (`OwnerSystemRow`), delegated Host calls, commands, queue commands, audit and background jobs. Preserve project/duty authorization as a separate condition; account enabled does not grant resource access. For files, stage invisible immutable bytes and switch the visible DB reference in `D`; failed transaction deletes staged bytes, post-commit old-blob GC may continue. RDM's independent database keeps the explicit cross-database window from the amendment. +3. Review **every** V/VH/VG route against its final sink on the fixed candidate SHA. Use deterministic pause-and-disable tests for representative project, message/run, artifact, settings, command/surface and Host/runmgr paths on SQLite and isolated PostgreSQL. Test account lock ordering with two PG connections. Verify the existing credential routes' in-transaction ordering. For external effects, verify the last check and cancellation and state the remaining check-to-effect window. Record exact command, tested SHA and output in the Dev task card; local SELF is not independent REPLAY or CI GATE. + +Stage 6 stops if any Backend-owned business write lacks a final actor-enabled transaction fence, if artifact content remains visibly mutable after disable via in-place overwrite, or if an external/RDM effect is described as same-DB atomic. Session/API-key in-flight revocation is a separate amendment impact analysis; enabled checks do not prove credential-revocation fencing. diff --git a/internal/app/account_directory.go b/internal/app/account_directory.go index e8beb24..cafd8bc 100644 --- a/internal/app/account_directory.go +++ b/internal/app/account_directory.go @@ -3,7 +3,6 @@ package app import ( "context" "crypto/hmac" - "crypto/rand" "crypto/sha256" "encoding/base64" "encoding/hex" @@ -20,18 +19,19 @@ import ( ) type accountDirectorySlot struct { - mu sync.RWMutex - pluginID string - resolver extension.AccountDirectoryScopeResolver - secret [32]byte + mu sync.RWMutex + pluginID string + resolver extension.AccountDirectoryScopeResolver + secret [32]byte + keyVersion int } -func newAccountDirectorySlot() (*accountDirectorySlot, error) { - slot := &accountDirectorySlot{} - if _, err := rand.Read(slot.secret[:]); err != nil { +func newAccountDirectorySlot(st *store.Store) (*accountDirectorySlot, error) { + key, err := st.GetAccountCursorKey() + if err != nil { return nil, err } - return slot, nil + return &accountDirectorySlot{secret: key.Secret, keyVersion: key.Version}, nil } func (s *accountDirectorySlot) register(pluginID string, fn extension.AccountDirectoryScopeResolver) error { @@ -52,22 +52,32 @@ type directoryPolicy struct { match []string candidates map[string]bool owners map[string]bool + rows []store.DirectoryAccountRow all bool status string version string } type directoryCursor struct { - Version string `json:"v"` - Owner string `json:"o"` - UserID string `json:"u"` + KeyVersion int `json:"k"` + Version string `json:"v"` + Owner string `json:"o"` + UserID string `json:"u"` +} + +type directoryVisibleRow struct { + UserID string + Owner string + Enabled bool + Name string + StaffNo string } func knownDirectoryField(field string) bool { return field == "owner" || field == "name" || field == "staffNo" } -func (h inprocHost) directoryPolicy(ctx context.Context, q extension.AccountDirectoryQuery) (directoryPolicy, error) { +func (h inprocHost) directoryPolicy(ctx context.Context, q extension.AccountDirectoryQuery, loadRows bool) (directoryPolicy, error) { var p directoryPolicy if err := h.requireEffective(); err != nil { return p, err @@ -174,9 +184,26 @@ func (h inprocHost) directoryPolicy(ctx context.Context, q extension.AccountDire if q.Purpose == extension.AccountDirectoryPurposeSystemUsers { p.all = true } - revision, err := h.caps.st.GetAccountDirectoryRevision() - if err != nil { - return p, err + visible := make([]directoryVisibleRow, 0) + if loadRows { + rows, err := h.caps.st.ListDirectoryAccountRows() + if err != nil { + return p, err + } + for _, row := range rows { + if !directoryRowAllowed(row, p) { + continue + } + p.rows = append(p.rows, row) + item := directoryVisibleRow{UserID: row.UserID, Owner: row.Owner, Enabled: row.Enabled} + if p.allowed["name"] { + item.Name = row.Name + } + if p.allowed["staffNo"] { + item.StaffNo = row.StaffNo + } + visible = append(visible, item) + } } ids := make([]string, 0, len(p.candidates)) for id := range p.candidates { @@ -188,16 +215,19 @@ func (h inprocHost) directoryPolicy(ctx context.Context, q extension.AccountDire owners = append(owners, owner) } sort.Strings(owners) - fingerprint := struct { - Actor, Plugin, Purpose, Project, Document, Mode, Query, Status, Public, Staff, Scope string - Match, IDs, Owners []string - All bool - }{Actor: h.actor, Plugin: h.pluginID, Purpose: q.Purpose, Project: q.ProjectID, Document: q.DocumentID, - Mode: q.Mode, Query: strings.TrimSpace(q.Query), Status: p.status, Public: revision.Public, - Scope: scope.ScopeVersion, Match: p.match, IDs: ids, Owners: owners, All: p.all} - if p.allowed["staffNo"] { - fingerprint.Staff = revision.StaffNo + allowed := make([]string, 0, len(p.allowed)) + for field := range p.allowed { + allowed = append(allowed, field) } + sort.Strings(allowed) + fingerprint := struct { + Actor, Plugin, Purpose, Project, Document, Mode, Query, Status, Scope string + Match, Allowed, IDs, Owners []string + Visible []directoryVisibleRow + All bool + }{Actor: h.actor, Plugin: h.pluginID, Purpose: q.Purpose, Project: q.ProjectID, Document: q.DocumentID, + Mode: q.Mode, Query: strings.TrimSpace(q.Query), Status: p.status, + Scope: scope.ScopeVersion, Match: p.match, Allowed: allowed, IDs: ids, Owners: owners, Visible: visible, All: p.all} encoded, err := json.Marshal(fingerprint) if err != nil { return p, err @@ -208,11 +238,13 @@ func (h inprocHost) directoryPolicy(ctx context.Context, q extension.AccountDire } func (s *accountDirectorySlot) encodeCursor(c directoryCursor) (string, error) { + c.KeyVersion = s.keyVersion data, err := json.Marshal(c) if err != nil { return "", err } mac := hmac.New(sha256.New, s.secret[:]) + _, _ = mac.Write([]byte("iam-directory-cursor-v1\x00")) _, _ = mac.Write(data) return base64.RawURLEncoding.EncodeToString(data) + "." + base64.RawURLEncoding.EncodeToString(mac.Sum(nil)), nil } @@ -232,8 +264,9 @@ func (s *accountDirectorySlot) decodeCursor(token, version string) (directoryCur return c, store.ErrConflict } mac := hmac.New(sha256.New, s.secret[:]) + _, _ = mac.Write([]byte("iam-directory-cursor-v1\x00")) _, _ = mac.Write(data) - if !hmac.Equal(provided, mac.Sum(nil)) || json.Unmarshal(data, &c) != nil || c.Version != version || c.Owner == "" || c.UserID == "" { + if !hmac.Equal(provided, mac.Sum(nil)) || json.Unmarshal(data, &c) != nil || c.KeyVersion != s.keyVersion || c.Version != version || c.Owner == "" || c.UserID == "" { return directoryCursor{}, store.ErrConflict } return c, nil @@ -277,7 +310,8 @@ func directoryRowAllowed(row store.DirectoryAccountRow, p directoryPolicy) bool } func (h inprocHost) QueryAccountDirectory(ctx context.Context, q extension.AccountDirectoryQuery) (extension.AccountDirectoryPage, error) { - p, err := h.directoryPolicy(ctx, q) + query := strings.TrimSpace(q.Query) + p, err := h.directoryPolicy(ctx, q, query != "") if err != nil { return extension.AccountDirectoryPage{}, err } @@ -292,7 +326,6 @@ func (h inprocHost) QueryAccountDirectory(ctx context.Context, q extension.Accou return extension.AccountDirectoryPage{}, store.ErrInvalidAccountProfile } page := extension.AccountDirectoryPage{Entries: []extension.AccountDirectoryEntry{}, DirectoryVersion: p.version} - query := strings.TrimSpace(q.Query) if query == "" { if q.Cursor != "" { return extension.AccountDirectoryPage{}, store.ErrConflict @@ -309,13 +342,9 @@ func (h inprocHost) QueryAccountDirectory(ctx context.Context, q extension.Accou return extension.AccountDirectoryPage{}, err } } - rows, err := h.caps.st.ListDirectoryAccountRows() - if err != nil { - return extension.AccountDirectoryPage{}, err - } matched := make([]store.DirectoryAccountRow, 0, q.Limit+1) - for _, row := range rows { - if !directoryRowAllowed(row, p) || !directoryMatches(row, p.match, q.Mode, query) { + for _, row := range p.rows { + if !directoryMatches(row, p.match, q.Mode, query) { continue } if after.Owner != "" && (row.Owner < after.Owner || (row.Owner == after.Owner && row.UserID <= after.UserID)) { @@ -347,46 +376,32 @@ func (h inprocHost) LookupAccountDirectory(ctx context.Context, q extension.Acco if userID == "" || q.Cursor != "" { return extension.AccountDirectoryLookup{}, store.ErrInvalidAccountProfile } - p, err := h.directoryPolicy(ctx, q) + p, err := h.directoryPolicy(ctx, q, true) if err != nil { return extension.AccountDirectoryLookup{}, err } - if !p.all && len(p.owners) == 0 && !p.candidates[userID] { - return extension.AccountDirectoryLookup{}, store.ErrNotFound + for _, row := range p.rows { + if row.UserID == userID { + return extension.AccountDirectoryLookup{Entry: directoryEntry(row, p), DirectoryVersion: p.version}, nil + } } - account, err := h.caps.st.GetAccount(userID) - if err != nil { - return extension.AccountDirectoryLookup{}, err - } - row := store.DirectoryAccountRow{UserID: account.ID, Owner: account.Owner, Name: account.Profile.Name, - StaffNo: account.Profile.StaffNo, Enabled: account.Enabled} - if !directoryRowAllowed(row, p) { - return extension.AccountDirectoryLookup{}, store.ErrNotFound - } - return extension.AccountDirectoryLookup{Entry: directoryEntry(row, p), DirectoryVersion: p.version}, nil + return extension.AccountDirectoryLookup{}, store.ErrNotFound } func (h inprocHost) LookupAccountDirectoryOwner(ctx context.Context, q extension.AccountDirectoryQuery, owner string) (extension.AccountDirectoryLookup, error) { if owner == "" || q.Cursor != "" { return extension.AccountDirectoryLookup{}, store.ErrInvalidAccountProfile } - p, err := h.directoryPolicy(ctx, q) + p, err := h.directoryPolicy(ctx, q, true) if err != nil { return extension.AccountDirectoryLookup{}, err } - if !p.all && len(p.candidates) == 0 && !p.owners[owner] { - return extension.AccountDirectoryLookup{}, store.ErrNotFound + for _, row := range p.rows { + if row.Owner == owner { + return extension.AccountDirectoryLookup{Entry: directoryEntry(row, p), DirectoryVersion: p.version}, nil + } } - account, err := h.caps.st.GetAccountByOwner(owner) - if err != nil { - return extension.AccountDirectoryLookup{}, err - } - row := store.DirectoryAccountRow{UserID: account.ID, Owner: account.Owner, Name: account.Profile.Name, - StaffNo: account.Profile.StaffNo, Enabled: account.Enabled} - if !directoryRowAllowed(row, p) { - return extension.AccountDirectoryLookup{}, store.ErrNotFound - } - return extension.AccountDirectoryLookup{Entry: directoryEntry(row, p), DirectoryVersion: p.version}, nil + return extension.AccountDirectoryLookup{}, store.ErrNotFound } var _ extension.AccountDirectoryProvider = inprocHost{} diff --git a/internal/app/account_directory_test.go b/internal/app/account_directory_test.go index c1b35ce..1fc6b59 100644 --- a/internal/app/account_directory_test.go +++ b/internal/app/account_directory_test.go @@ -5,6 +5,7 @@ import ( "database/sql" "errors" "os" + "os/exec" "path/filepath" "strings" "testing" @@ -65,11 +66,11 @@ func TestAccountDirectoryAppliesDomainScopeBeforeSearchAndCursor(t *testing.T) { if err != nil { t.Fatal(err) } - _, err = st.CreatePendingAccount("admin", "outsider", store.AccountProfile{Name: "同名", StaffNo: "hidden-x"}) + outsider, err := st.CreatePendingAccount("admin", "outsider", store.AccountProfile{Name: "同名", StaffNo: "hidden-x"}) if err != nil { t.Fatal(err) } - slot, err := newAccountDirectorySlot() + slot, err := newAccountDirectorySlot(st) if err != nil { t.Fatal(err) } @@ -91,9 +92,16 @@ func TestAccountDirectoryAppliesDomainScopeBeforeSearchAndCursor(t *testing.T) { t.Fatalf("first scoped page: %+v %v", page, err) } query.Cursor = page.NextCursor - second, err := h.QueryAccountDirectory(context.Background(), query) + otherSlot, err := newAccountDirectorySlot(st) + if err != nil { + t.Fatal(err) + } + otherSlot.pluginID, otherSlot.resolver = slot.pluginID, slot.resolver + otherHost := h + otherHost.caps.directory = otherSlot + second, err := otherHost.QueryAccountDirectory(context.Background(), query) if err != nil || len(second.Entries) != 1 || second.Entries[0].UserID != bob.ID || second.NextCursor != "" { - t.Fatalf("second scoped page: %+v %v", second, err) + t.Fatalf("second scoped page on another host: %+v %v", second, err) } query.Cursor = "" query.Query = "hidden-x" @@ -126,10 +134,88 @@ func TestAccountDirectoryAppliesDomainScopeBeforeSearchAndCursor(t *testing.T) { if _, err := h.QueryAccountDirectory(context.Background(), query); err != nil { t.Fatalf("private edit invalidated candidate cursor: %v", err) } - if _, err := st.PatchAccountProfile("admin", alice.ID, alice.ProfileVersion+1, map[string]string{"name": "changed"}); err != nil { + if _, err := st.PatchAccountProfile("admin", alice.ID, alice.ProfileVersion+1, map[string]string{"staffNo": "still-hidden"}); err != nil { + t.Fatal(err) + } + if _, err := h.QueryAccountDirectory(context.Background(), query); err != nil { + t.Fatalf("unavailable field invalidated candidate cursor: %v", err) + } + if _, err := st.PatchAccountProfile("admin", outsider.ID, outsider.ProfileVersion, map[string]string{"name": "out-of-scope-change"}); err != nil { + t.Fatal(err) + } + if _, err := h.QueryAccountDirectory(context.Background(), query); err != nil { + t.Fatalf("out-of-scope edit invalidated candidate cursor: %v", err) + } + if _, err := st.PatchAccountProfile("admin", alice.ID, alice.ProfileVersion+2, map[string]string{"name": "changed"}); err != nil { t.Fatal(err) } if _, err := h.QueryAccountDirectory(context.Background(), query); !errors.Is(err, store.ErrConflict) { t.Fatalf("visible edit did not invalidate cursor: %v", err) } } + +func TestPGDirectoryCursorContinuesInAnotherProcess(t *testing.T) { + baseDSN := os.Getenv("AGENT_PG_TEST_DSN") + if baseDSN == "" { + t.Skip("isolated PostgreSQL test DSN required") + } + if os.Getenv("IAM_U3_DIRECTORY_CHILD") == "1" { + schema := os.Getenv("IAM_U3_DIRECTORY_SCHEMA") + separator := "?" + if strings.Contains(baseDSN, "?") { + separator = "&" + } + st, err := store.OpenExistingPostgres(baseDSN+separator+"search_path="+schema+",public", filepath.Join(t.TempDir(), "artifacts")) + if err != nil { + t.Fatal(err) + } + defer st.Close() + slot, err := newAccountDirectorySlot(st) + if err != nil { + t.Fatal(err) + } + h := inprocHost{caps: hostcaps{st: st, directory: slot}, actor: "admin", owner: "admin"} + q := extension.AccountDirectoryQuery{SchemaVersion: extension.AccountDirectorySchemaVersion, + Purpose: extension.AccountDirectoryPurposeSystemUsers, Mode: extension.AccountDirectoryModeFuzzy, + Query: "same", Limit: 1, Cursor: os.Getenv("IAM_U3_DIRECTORY_CURSOR")} + page, err := h.QueryAccountDirectory(context.Background(), q) + if err != nil || len(page.Entries) != 1 || page.Entries[0].Owner != "beta" { + t.Fatalf("cross-process page: %+v %v", page, err) + } + return + } + st := openDirectoryTestStore(t) + if err := st.EnsureSystemIdentity("u3-directory-cross-process", "pg"); err != nil { + t.Fatal(err) + } + if err := st.CreateAccount("admin", "Admin", "hash", true); err != nil { + t.Fatal(err) + } + for _, owner := range []string{"alpha", "beta"} { + if _, err := st.CreatePendingAccount("admin", owner, store.AccountProfile{Name: "same"}); err != nil { + t.Fatal(err) + } + } + slot, err := newAccountDirectorySlot(st) + if err != nil { + t.Fatal(err) + } + h := inprocHost{caps: hostcaps{st: st, directory: slot}, actor: "admin", owner: "admin"} + q := extension.AccountDirectoryQuery{SchemaVersion: extension.AccountDirectorySchemaVersion, + Purpose: extension.AccountDirectoryPurposeSystemUsers, Mode: extension.AccountDirectoryModeFuzzy, + Query: "same", Limit: 1} + page, err := h.QueryAccountDirectory(context.Background(), q) + if err != nil || len(page.Entries) != 1 || page.Entries[0].Owner != "alpha" || page.NextCursor == "" { + t.Fatalf("initial page: %+v %v", page, err) + } + var schema string + if err := st.DB().QueryRow(`SELECT current_schema()`).Scan(&schema); err != nil { + t.Fatal(err) + } + cmd := exec.Command(os.Args[0], "-test.run=^TestPGDirectoryCursorContinuesInAnotherProcess$") + cmd.Env = append(os.Environ(), "IAM_U3_DIRECTORY_CHILD=1", "IAM_U3_DIRECTORY_SCHEMA="+schema, + "IAM_U3_DIRECTORY_CURSOR="+page.NextCursor) + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("cross-process cursor failed: %v: %s", err, strings.ReplaceAll(string(out), baseDSN, "[REDACTED]")) + } +} diff --git a/internal/app/account_guard_test.go b/internal/app/account_guard_test.go index 2f78e35..0e24dc0 100644 --- a/internal/app/account_guard_test.go +++ b/internal/app/account_guard_test.go @@ -3,8 +3,11 @@ package app import ( "context" "errors" + "net/http" + "net/http/httptest" "testing" + "agent-system-backend/internal/settings" "agent-system-backend/internal/store" ) @@ -57,6 +60,36 @@ func TestInprocHostRechecksEffectiveAccountOnEveryCapability(t *testing.T) { } } +func TestHostCompletionChecksAgainAfterModelSettingsRead(t *testing.T) { + st, err := store.OpenSQLite(t.TempDir() + "/host-complete.db") + if err != nil { + t.Fatal(err) + } + defer st.Close() + requests := 0 + model := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + requests++ + _, _ = w.Write([]byte(`{"choices":[{"message":{"content":"late"}}]}`)) + })) + defer model.Close() + set := settings.NewService(st) + set.SetSystemOverride(map[string]any{"models": map[string]any{"chat": map[string]any{"model": "test", "baseUrl": model.URL}}}) + checks := 0 + h := inprocHost{caps: hostcaps{st: st, set: set, accountGuard: func(string) error { + checks++ + if checks >= 3 { + return store.ErrAccountForbidden + } + return nil + }}, owner: "alice", actor: "alice", pluginID: "rdm"} + if _, err := h.Complete(context.Background(), "late"); !errors.Is(err, store.ErrAccountForbidden) { + t.Fatalf("completion after disable: %v", err) + } + if checks != 3 || requests != 0 { + t.Fatalf("late completion reached model: checks=%d requests=%d", checks, requests) + } +} + func TestImplicitLocalAccountExceptionIsAuthOffOnly(t *testing.T) { st, err := store.OpenSQLite(t.TempDir() + "/local.db") if err != nil { diff --git a/internal/app/account_lister_test.go b/internal/app/account_lister_test.go index 2facd50..014ff76 100644 --- a/internal/app/account_lister_test.go +++ b/internal/app/account_lister_test.go @@ -16,6 +16,9 @@ func TestInprocHostListAccounts(t *testing.T) { } t.Cleanup(func() { st.Close() }) for _, o := range []string{"carol", "alice", "bob", "dave"} { + if err := st.EnsureUser(o, ""); err != nil { + t.Fatal(err) + } if _, err := st.CreateAPIKey(o, "e2e"); err != nil { t.Fatal(err) } diff --git a/internal/app/app.go b/internal/app/app.go index fa4978d..835884c 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -527,14 +527,25 @@ func Run(opt Options) error { // fs 预留能力沙箱根:/workspaces///。 mgr.SetWorkspaceRoot(filepath.Join(opt.DataDir, "workspaces")) // 中断子代理续跑(N2 WP5):依赖转录目录(workspaceRoot)与派生请求。 - mgr.ResumeInterruptedChildren() + mgr.ResumeInterruptedChildrenChecked(context.Background(), func(ctx context.Context, owner string) error { + valid, err := effectiveAccount(ctx, owner) + if err != nil { + return err + } + if !valid { + return store.ErrAccountForbidden + } + return nil + }) // 收件箱任务卡:事件走会话事件管道(持久化/seq/重放);关闭即 T0 基线。 cardSvc := cardsvc.New(st, mgr.EmitCardEvent) + cardSvc.SetAccountCheckedEmitter(mgr.EmitCardEventAccountChecked) cardSvc.SetProjectSharedEmitter(mgr.EmitProjectSharedCardEvent) surfaceSvc := surfacesvc.New(st, mgr.EmitCardEvent) + surfaceSvc.SetAccountCheckedEmitter(mgr.EmitCardEventAccountChecked) go func() { // 启动恢复:重发「快照已落盘、事件未发出」的待恢复投影(KTD)。 - if n, err := surfaceSvc.RecoverPendingEvents(context.Background()); err == nil && n > 0 { + if n, err := surfaceSvc.RecoverPendingEventsAccountChecked(context.Background(), !authOn); err == nil && n > 0 { log.Printf("surface: recovered %d pending events", n) } }() @@ -557,6 +568,17 @@ func Run(opt Options) error { // 命令总线(提案 T3/D27/D30):四入口同源分发 + 审计 + 危险内联确认卡。 bus := cmdbus.New(st, cardSvc, func() time.Time { return time.Now().UTC() }) + bus.SetEffectiveAccountGuard(func(ctx context.Context, actor string) error { + valid, err := effectiveAccount(ctx, actor) + if err != nil { + return err + } + if !valid { + return store.ErrAccountForbidden + } + return nil + }) + bus.RequireAccountCheckedWrites(!authOn) // U5:页面动作分发口——surfacesvc.SubmitAction 经命令总线四入口同源分发 //(审计信封、参数校验、DangerHigh 内联确认卡全复用;Confirmed 不透传)。 surfaceSvc.SetDispatcher(func(ctx context.Context, d surfacesvc.ActionDispatch) (surfacesvc.ActionOutcome, error) { @@ -600,11 +622,11 @@ func Run(opt Options) error { // hostcaps:hostapi 闭包与进程内插件句柄共用的 owner 作用域能力(EXT-1 批注 1)。 authzSlot := &projectAuthz{} projectSlot := &projectLifecycleSlot{} - directorySlot, err := newAccountDirectorySlot() + directorySlot, err := newAccountDirectorySlot(st) if err != nil { return err } - caps := hostcaps{projects: projectSlot, directory: directorySlot, st: st, pipe: pipe, set: setSvc, authz: authzSlot, surfaces: surfaceSvc, + caps := hostcaps{projects: projectSlot, directory: directorySlot, st: st, pipe: pipe, set: setSvc, authz: authzSlot, surfaces: surfaceSvc, allowImplicitLocal: !authOn, accountGuard: func(owner string) error { valid, err := effectiveAccount(context.Background(), owner) if err != nil { @@ -916,6 +938,16 @@ func withStoreMode(storageDSN, dataDir string, _ bool, fn func(*store.Store) err func CreateAPIKey(storageDSN, dataDir, owner, name string) (string, error) { var plain string err := withStoreMode(storageDSN, dataDir, true, func(st *store.Store) error { + if err := assertLocalAccountOwner(st, storageDSN, dataDir); err != nil { + return err + } + account, err := st.GetAccountByOwner(owner) + if err != nil { + return err + } + if !account.Enabled || account.InitializationState == store.InitializationPending { + return store.ErrAccountForbidden + } p, e := st.CreateAPIKey(owner, name) plain = p return e @@ -928,7 +960,7 @@ func ListAPIKeys(storageDSN, dataDir, owner string) ([]store.APIKeyInfo, error) var out []store.APIKeyInfo err := withStoreMode(storageDSN, dataDir, true, func(st *store.Store) error { if _, err := st.GetUser(owner); err != nil { - return fmt.Errorf("owner %q 不存在(先 apikey create 创建)", owner) + return fmt.Errorf("owner %q 不存在(先建立账号)", owner) } var e error out, e = st.ListAPIKeys(owner) diff --git a/internal/app/ext9_publish_test.go b/internal/app/ext9_publish_test.go index 62b2ff6..8530069 100644 --- a/internal/app/ext9_publish_test.go +++ b/internal/app/ext9_publish_test.go @@ -23,6 +23,9 @@ func newExt9Host(t *testing.T) (interface { t.Fatal(err) } t.Cleanup(func() { st.Close() }) + if err := st.EnsureUser("alice", ""); err != nil { + t.Fatal(err) + } set := settings.NewService(st) pipe := rag.NewPipeline(st, set, nil, nil) h := inprocHost{caps: hostcaps{st: st, pipe: pipe, set: set}, pluginID: "rdm", owner: "alice"} diff --git a/internal/app/hostcaps.go b/internal/app/hostcaps.go index 8d73d88..7ffd00c 100644 --- a/internal/app/hostcaps.go +++ b/internal/app/hostcaps.go @@ -21,14 +21,15 @@ import ( // 同一实现同时服务 hostapi.Server 的 Search/Complete 闭包与进程内 // inprocHost(EXT-1 批注 1:复用现有 owner 作用域函数,不新造句柄)。 type hostcaps struct { - projects *projectLifecycleSlot - directory *accountDirectorySlot - accountGuard func(string) error - st *store.Store - pipe *rag.Pipeline - set *settings.Service - authz *projectAuthz // EXT-8 适配器槽(共享指针;首个注册者生效) - surfaces *surfacesvc.Service // EXT-17 Surface 能力槽(nil = 未装配,明确拒绝) + projects *projectLifecycleSlot + directory *accountDirectorySlot + accountGuard func(string) error + allowImplicitLocal bool + st *store.Store + pipe *rag.Pipeline + set *settings.Service + authz *projectAuthz // EXT-8 适配器槽(共享指针;首个注册者生效) + surfaces *surfacesvc.Service // EXT-17 Surface 能力槽(nil = 未装配,明确拒绝) } // projectAuthz EXT-8 资源授权适配器槽:进程内扩展注册,组合根装配进 Server。 @@ -112,7 +113,9 @@ type sharedRetriever interface { func (c hostcaps) search(ctx context.Context, owner string, projectID core.ID, text string, topK int) ([]core.RetrievalHit, error) { if c.accountGuard != nil { - if err := c.accountGuard(owner); err != nil { return nil, err } + if err := c.accountGuard(owner); err != nil { + return nil, err + } } q := core.RetrievalQuery{ProjectID: projectID, Text: text, TopK: topK} if c.projects != nil && projectID != "" { @@ -149,12 +152,22 @@ func (c hostcaps) searchDefault(ctx context.Context, owner, text string) ([]core // complete 经基座 LLM 路由补全(owner 的模型配置生效;未配置报错)。 func (c hostcaps) complete(ctx context.Context, owner, prompt string) (string, error) { if c.accountGuard != nil { - if err := c.accountGuard(owner); err != nil { return "", err } + if err := c.accountGuard(owner); err != nil { + return "", err + } } route := runtime.ResolveChatModel(core.AgentProfile{}, c.set.Effective(owner, "")) if route.Model == "" || route.Base == "" { return "", fmt.Errorf("llm route not configured") } + if err := ctx.Err(); err != nil { + return "", err + } + if c.accountGuard != nil { + if err := c.accountGuard(owner); err != nil { + return "", err + } + } return completeChat(ctx, route.Base, route.Key, route.Model, prompt) } @@ -296,7 +309,7 @@ func (h inprocHost) KVPut(key, value string) error { if err := h.requireEffective(); err != nil { return err } - return h.caps.st.PutPluginKV(h.pluginID, h.owner, key, value) + return h.caps.st.PutPluginKVAccountChecked(h.owner, h.pluginID, key, value, h.caps.allowImplicitLocal) } func (h inprocHost) KVGet(key string) (string, bool, error) { @@ -310,10 +323,10 @@ func (h inprocHost) Audit(action, target, evidence string) error { if err := h.requireEffective(); err != nil { return err } - return h.caps.st.AppendAuditEvent(store.AuditEvent{ + return h.caps.st.AppendAuditEventAccountChecked(h.owner, store.AuditEvent{ Owner: h.owner, Actor: "plugin:" + h.pluginID, Action: action, Target: target, Evidence: evidence, - }) + }, h.caps.allowImplicitLocal) } func (h inprocHost) Complete(ctx context.Context, prompt string) (string, error) { @@ -365,9 +378,9 @@ func (h inprocHost) KnowledgeWrite(ctx context.Context, docs []core.KnowledgeDoc } } if !embed { - return h.caps.st.AddKnowledge(docs...) + return h.caps.st.AddKnowledgeAccountChecked(h.owner, h.caps.allowImplicitLocal, docs...) } - return h.caps.pipe.IndexChunks(ctx, core.ID(h.owner), docs) + return h.caps.pipe.IndexChunksAccountChecked(ctx, core.ID(h.owner), docs, h.caps.allowImplicitLocal) } // KnowledgePublish replaces refs and their vectors within the publication scope. @@ -380,8 +393,6 @@ func (h inprocHost) KnowledgePublish(ctx context.Context, docs []core.KnowledgeD if len(docs) == 0 { return nil } - type publicationRef struct{ projectID, ref string } - refs := map[publicationRef]bool{} for i := range docs { docs[i].Owner = core.ID(h.owner) if docs[i].Source == "" { @@ -390,24 +401,12 @@ func (h inprocHost) KnowledgePublish(ctx context.Context, docs []core.KnowledgeD if err := h.knowledgeSource(docs[i].Source); err != nil { return err } - if docs[i].Ref != "" { - refs[publicationRef{docs[i].ProjectID, docs[i].Ref}] = true - } } scoped := h.caps.projects != nil && h.caps.projects.active() - for ref := range refs { - var err error - if scoped { - err = h.caps.st.DeletePluginProjectKnowledgeByRef(ctx, h.owner, ref.projectID, ref.ref, h.pluginID) - } else { - err = h.caps.st.DeleteProjectKnowledgeByRef(ctx, h.owner, ref.projectID, ref.ref) - } - if err != nil { - return err - } + if embed { + return h.caps.pipe.PublishChunksAccountChecked(ctx, core.ID(h.owner), h.pluginID, scoped, docs, h.caps.allowImplicitLocal) } - - return h.KnowledgeWrite(ctx, docs, embed) + return h.caps.st.ReplaceKnowledgeAccountChecked(ctx, h.owner, h.pluginID, scoped, h.caps.allowImplicitLocal, docs...) } // KnowledgeDelete 撤销本插件来源(EXT-9):前缀强制 plugin:<自身ID>——manual @@ -419,7 +418,7 @@ func (h inprocHost) KnowledgeDelete(ctx context.Context, sourcePrefix string) er if sourcePrefix != "plugin:"+h.pluginID { return fmt.Errorf("ext-9: plugins may only delete their own source prefix, want %q", "plugin:"+h.pluginID) } - _, err := h.caps.st.DeleteKnowledgeBySource(ctx, h.owner, sourcePrefix) + _, err := h.caps.st.DeleteKnowledgeBySourceAccountChecked(ctx, h.owner, sourcePrefix, h.caps.allowImplicitLocal) return err } @@ -428,7 +427,7 @@ func (h inprocHost) TaskCreate(kind, title, conversationID string) (string, erro if err := h.requireEffective(); err != nil { return "", err } - t, err := h.caps.st.CreateBackgroundTask(h.owner, kind, title, conversationID) + t, err := h.caps.st.CreateBackgroundTaskAccountChecked(h.owner, kind, title, conversationID, h.caps.allowImplicitLocal) if err != nil { return "", err } @@ -439,7 +438,7 @@ func (h inprocHost) TaskUpdate(taskID, status string, progress int, errText stri if err := h.requireEffective(); err != nil { return err } - return h.caps.st.UpdateBackgroundTask(h.owner, taskID, status, progress, errText) + return h.caps.st.UpdateBackgroundTaskAccountChecked(h.owner, taskID, status, progress, errText, h.caps.allowImplicitLocal) } // ArtifactGet/ArtifactContent 工件只读(R1-3 ingest 读原件;写入属完整集)。 @@ -483,11 +482,11 @@ func (h inprocHost) SurfaceSave(ctx context.Context, req extension.SurfaceSaveRe if err != nil { return core.SurfaceSnapshot{}, err } - return svc.Submit(ctx, h.owner, h.pluginID, surfacesvc.SubmitRequest{ + return svc.SubmitAccountChecked(ctx, h.owner, h.pluginID, surfacesvc.SubmitRequest{ SurfaceID: req.SurfaceID, ConversationID: req.ConversationID, SourceRunID: req.SourceRunID, BaseRevision: req.BaseRevision, ObservedEpoch: req.ObservedEpoch, ContextVersion: req.ContextVersion, SemanticText: req.SemanticText, Spec: req.Spec, - }) + }, h.caps.allowImplicitLocal) } func (h inprocHost) SurfaceGet(ctx context.Context, surfaceID core.ID) (core.SurfaceSnapshot, error) { diff --git a/internal/app/inproc_host_test.go b/internal/app/inproc_host_test.go index acb6a39..853b42a 100644 --- a/internal/app/inproc_host_test.go +++ b/internal/app/inproc_host_test.go @@ -78,6 +78,9 @@ func TestInprocHostOwnerScope(t *testing.T) { bus, st := newInprocTestApp(t, nil) // embedder nil:embed=true 仍只落全文(IndexChunks 降级路径) for _, owner := range []string{"alice", "bob"} { + if err := st.EnsureUser(owner, ""); err != nil { + t.Fatal(err) + } resp := bus.Dispatch(context.Background(), cmdbus.Request{ ID: "tst.record", Params: map[string]any{"ref": owner + "-ref"}, Owner: owner, Actor: "ui", Entrance: "chat", }) @@ -157,6 +160,9 @@ func TestInprocHostKnowledgeWriteEmbedHash(t *testing.T) { }, }) bus, st := newInprocTestApp(t, func() string { return "hash" }) + if err := st.EnsureUser("alice", ""); err != nil { + t.Fatal(err) + } resp := bus.Dispatch(context.Background(), cmdbus.Request{ ID: "tst.embed", Params: map[string]any{"text": "Hash 向量内容"}, Owner: "alice", Entrance: "api", diff --git a/internal/app/knowledge_authorization_test.go b/internal/app/knowledge_authorization_test.go index e28dff3..967e667 100644 --- a/internal/app/knowledge_authorization_test.go +++ b/internal/app/knowledge_authorization_test.go @@ -16,6 +16,11 @@ func TestHostKnowledgeV2AndProjectReplacement(t *testing.T) { t.Fatal(err) } defer st.Close() + for _, owner := range []string{"alice", "bob"} { + if err := st.EnsureUser(owner, ""); err != nil { + t.Fatal(err) + } + } pipe := rag.NewPipeline(st, settings.NewService(st), nil, nil) auth := &projectAuthz{} alice := inprocHost{owner: "alice", pluginID: "rdm", caps: hostcaps{st: st, pipe: pipe, authz: auth}} @@ -76,6 +81,9 @@ func TestRegisteredProjectHostRejectsForeignKnowledgeSource(t *testing.T) { t.Fatal(err) } defer st.Close() + if err := st.EnsureUser("alice", ""); err != nil { + t.Fatal(err) + } slot := &projectLifecycleSlot{} caps := hostcaps{st: st, projects: slot} if err := (pluginRegistry{pluginID: "rdm", caps: caps}).RegisterProjectLifecycle(completeLifecycle()); err != nil { @@ -110,6 +118,9 @@ func TestProjectPublishPreservesForeignPluginAndScope(t *testing.T) { t.Fatal(err) } defer st.Close() + if err := st.EnsureUser("alice", ""); err != nil { + t.Fatal(err) + } slot := &projectLifecycleSlot{} caps := hostcaps{st: st, projects: slot} if err := (pluginRegistry{pluginID: "rdm", caps: caps}).RegisterProjectLifecycle(completeLifecycle()); err != nil { @@ -150,6 +161,9 @@ func TestUnregisteredPublishRetainsLegacyRefScope(t *testing.T) { t.Fatal(err) } defer st.Close() + if err := st.EnsureUser("alice", ""); err != nil { + t.Fatal(err) + } if err := st.AddKnowledge(core.KnowledgeDoc{ID: "legacy", Owner: "alice", ProjectID: "old-project", Source: "manual", Ref: "same", Chunk: "legacyneedle"}); err != nil { t.Fatal(err) } diff --git a/internal/app/local_account_test.go b/internal/app/local_account_test.go index 5246863..5130c69 100644 --- a/internal/app/local_account_test.go +++ b/internal/app/local_account_test.go @@ -1,6 +1,7 @@ package app import ( + "errors" "path/filepath" "testing" @@ -54,3 +55,44 @@ func TestLocalOwnerAccountCreationAndReissuePreserveIdentity(t *testing.T) { t.Fatalf("new activation grant failed: %v", err) } } + +func TestAPIKeyCLIRequiresExistingInitializedAccount(t *testing.T) { + dir := t.TempDir() + st, err := store.OpenSQLite(filepath.Join(dir, "agent-system.db")) + if err != nil { + t.Fatal(err) + } + if err := st.EnsureSystemIdentity("test-api-key-admission", "sqlite"); err != nil { + t.Fatal(err) + } + st.Close() + if _, err := CreateAPIKey("", dir, "alice", "integration"); !errors.Is(err, store.ErrNotFound) { + t.Fatalf("missing account was implicitly created: %v", err) + } + grant, err := BootstrapAdmin("", dir, "alice", true) + if err != nil { + t.Fatal(err) + } + if _, err := CreateAPIKey("", dir, "alice", "integration"); !errors.Is(err, store.ErrAccountForbidden) { + t.Fatalf("pending account received a full API key: %v", err) + } + st, err = store.OpenExistingSQLite(filepath.Join(dir, "agent-system.db")) + if err != nil { + t.Fatal(err) + } + defer st.Close() + token, _, err := st.RedeemLoginKey(grant) + if err != nil { + t.Fatal(err) + } + hash, err := store.HashPassword("trusted-owner-password") + if err != nil { + t.Fatal(err) + } + if _, err := st.CompleteSetPasswordChecked(token, hash); err != nil { + t.Fatal(err) + } + if key, err := CreateAPIKey("", dir, "alice", "integration"); err != nil || key == "" { + t.Fatalf("initialized account could not receive its API key: %v", err) + } +} diff --git a/internal/app/project_lifecycle.go b/internal/app/project_lifecycle.go index 9055970..c35f5c3 100644 --- a/internal/app/project_lifecycle.go +++ b/internal/app/project_lifecycle.go @@ -126,13 +126,22 @@ func (h inprocHost) EnsureProject(id, name string) (core.Project, error) { if err := h.requireEffective(); err != nil { return core.Project{}, err } - return h.caps.st.EnsureProject(h.owner, id, name) + if h.actor != "" && h.actor != h.owner { + return core.Project{}, store.ErrAccountForbidden + } + // A creator-scoped Host is the approved I1 projection capability. RDM + // authorizes a delegated operator before invoking it; cross-database + // commit fencing remains outside U3's guarantee. + return h.caps.st.EnsureProjectAccountChecked(h.owner, h.owner, id, name, h.caps.allowImplicitLocal) } func (h inprocHost) RenameOwnedProject(id, name string) (core.Project, error) { if err := h.requireEffective(); err != nil { return core.Project{}, err } - return h.caps.st.RenameOwnedProject(h.owner, id, name) + if h.actor != "" && h.actor != h.owner { + return core.Project{}, store.ErrAccountForbidden + } + return h.caps.st.RenameOwnedProjectAccountChecked(h.owner, h.owner, id, name, h.caps.allowImplicitLocal) } func (h inprocHost) ProjectNameAvailable(name, exceptID string) (bool, error) { if err := h.requireEffective(); err != nil { diff --git a/internal/app/project_lifecycle_test.go b/internal/app/project_lifecycle_test.go index 1650ff9..6422f4e 100644 --- a/internal/app/project_lifecycle_test.go +++ b/internal/app/project_lifecycle_test.go @@ -55,6 +55,11 @@ func TestProjectHostMetadataDoesNotGrantMutation(t *testing.T) { t.Fatal(err) } defer st.Close() + for _, owner := range []string{"alice", "bob"} { + if err := st.EnsureUser(owner, ""); err != nil { + t.Fatal(err) + } + } alice := inprocHost{owner: "alice", caps: hostcaps{st: st}} p, err := alice.EnsureProject("stable", "Shared") if err != nil { @@ -131,6 +136,9 @@ func TestProjectHostMetadataMasksSharedSecretsWithoutMutation(t *testing.T) { srv := server.New(st, nil, nil, nil, svc, nil) srv.Auth = server.NewAuthenticator(st, true) slot.wire(srv, caps) + if err := st.EnsureUser("bob", ""); err != nil { + t.Fatal(err) + } key, err := st.CreateAPIKey("bob", "test") if err != nil { t.Fatal(err) diff --git a/internal/app/scoped_host_test.go b/internal/app/scoped_host_test.go index d284723..2a279e1 100644 --- a/internal/app/scoped_host_test.go +++ b/internal/app/scoped_host_test.go @@ -24,6 +24,9 @@ func TestScopedHostFactory(t *testing.T) { t.Fatal(err) } t.Cleanup(func() { st.Close() }) + if err := st.EnsureUser("alice", ""); err != nil { + t.Fatal(err) + } set := settings.NewService(st) pipe := rag.NewPipeline(st, set, nil, nil) caps := hostcaps{st: st, pipe: pipe, set: set} diff --git a/internal/cardsvc/account_checked.go b/internal/cardsvc/account_checked.go new file mode 100644 index 0000000..01b4521 --- /dev/null +++ b/internal/cardsvc/account_checked.go @@ -0,0 +1,219 @@ +package cardsvc + +import ( + "agent-system-backend/internal/store" + core "agent-system-core" + "errors" +) + +func (s *Service) requireAccountCheckedEmitter() error { + if s.emit != nil && s.accountCheckedEmit == nil { + return ErrGuardedEmitterRequired + } + return nil +} + +func (s *Service) ResolveByRefAccountChecked(actor, ref, actionID, answer string, allowImplicitDefault bool) error { + if err := s.requireAccountCheckedEmitter(); err != nil { + return err + } + id, err := s.st.CardIDByRef(actor, ref) + if errors.Is(err, store.ErrNotFound) { + return nil + } + if err != nil { + return err + } + card, version, err := s.st.GetCardVersion(actor, id) + if err != nil { + return err + } + if card.Status.Terminal() { + return nil + } + card.Status = core.CardResolved + if err := s.UpdateVersionedAccountChecked(actor, card, version, allowImplicitDefault); errors.Is(err, store.ErrConflict) { + return nil + } else if err != nil { + return err + } + return s.emitCardAccountChecked(actor, card, core.EvCardResolved, + core.CardResolvedPayload{CardID: card.ID, ActionID: actionID, Answer: answer}) +} + +// AppendAccountChecked preserves stream-card throttling while fencing its +// durable body update against account disable. +func (s *Service) AppendAccountChecked(actor, id, text string, allowImplicitDefault bool) error { + if err := s.requireAccountCheckedEmitter(); err != nil { + return err + } + card, version, err := s.st.GetCardVersion(actor, id) + if err != nil { + return err + } + if text != "" { + if n := len(card.Body); n > 0 && card.Body[n-1].Type == core.UIMarkdown { + card.Body[n-1].Markdown += text + } else { + card.Body = append(card.Body, core.UINode{Type: core.UIMarkdown, Markdown: text}) + } + } + card.UpdatedAt = s.now().UTC() + if err := s.st.SaveCardVersionedAccountChecked(actor, &card, "", version, allowImplicitDefault); err != nil { + return err + } + if card.ConversationID == "" && card.ProjectID == "" { + return nil + } + s.emitMu.Lock() + last, seen := s.lastEmit[id] + emit := !seen || s.now().Sub(last) >= cardAppendWindow + if emit { + s.lastEmit[id] = s.now() + } + s.emitMu.Unlock() + if !emit { + return nil + } + return s.emitCardAccountChecked(actor, card, core.EvCardUpdated, core.CardPayload{Card: card}) +} + +func (s *Service) emitCardAccountChecked(actor string, card core.ActivityCard, typ core.StreamEventType, payload any) error { + if s.accountCheckedEmit == nil || (card.ConversationID == "" && card.ProjectID == "") { + return nil + } + if card.ConversationID != "" { + if err := s.accountCheckedEmit(actor, card.ConversationID, card.SourceRunID, typ, payload); err != nil { + return err + } + if !s.MirrorConvCards { + return nil + } + } + if card.ProjectID != "" { + return s.accountCheckedEmit(actor, string(core.ProjectChannelKey(card.ProjectID)), card.SourceRunID, typ, payload) + } + return nil +} + +// CreateAccountChecked creates a card under the actor's final Store fence. +func (s *Service) CreateAccountChecked(actor string, card core.ActivityCard, ref string, allowImplicitDefault bool) (core.ActivityCard, error) { + if err := s.requireAccountCheckedEmitter(); err != nil { + return card, err + } + if card.ID == "" { + card.ID = store.NewID("card") + } + now := s.now().UTC() + card.CreatedAt, card.UpdatedAt = now, now + if card.Status == "" { + card.Status = core.CardActive + } + if err := s.st.SaveCardAccountChecked(actor, &card, ref, allowImplicitDefault); err != nil { + return card, err + } + return card, s.emitCardAccountChecked(actor, card, core.EvCardCreated, core.CardPayload{Card: card}) +} + +func (s *Service) updateVersionedAccountChecked(actor string, card core.ActivityCard, expectedVersion int, allowImplicitDefault bool) (core.ActivityCard, error) { + if err := s.requireAccountCheckedEmitter(); err != nil { + return card, err + } + card.UpdatedAt = s.now().UTC() + if err := s.st.SaveCardVersionedAccountChecked(actor, &card, "", expectedVersion, allowImplicitDefault); err != nil { + return card, err + } + return card, s.emitCardAccountChecked(actor, card, core.EvCardUpdated, core.CardPayload{Card: card}) +} + +func (s *Service) UpdateVersionedAccountChecked(actor string, card core.ActivityCard, expectedVersion int, allowImplicitDefault bool) error { + _, err := s.updateVersionedAccountChecked(actor, card, expectedVersion, allowImplicitDefault) + return err +} + +func (s *Service) SetPinnedAccountChecked(actor, id string, pinned, allowImplicitDefault bool) (core.ActivityCard, error) { + card, version, err := s.st.GetCardVersion(actor, id) + if err != nil { + return card, err + } + card.Pinned = pinned + card, err = s.updateVersionedAccountChecked(actor, card, version, allowImplicitDefault) + if err != nil { + return card, err + } + return card, nil +} + +func (s *Service) SetStatusAccountChecked(actor, id string, status core.CardStatus, tone, note string, allowImplicitDefault bool) (core.ActivityCard, error) { + card, version, err := s.st.GetCardVersion(actor, id) + if err != nil { + return card, err + } + card.Status = status + if tone != "" { + card.Tone = tone + } + card, err = s.updateVersionedAccountChecked(actor, card, version, allowImplicitDefault) + if err != nil { + return card, err + } + return card, s.emitCardAccountChecked(actor, card, core.EvCardStatus, + core.CardStatusPayload{CardID: card.ID, Status: status, Tone: card.Tone, Note: note}) +} + +func (s *Service) DismissAccountChecked(actor, id string, allowImplicitDefault bool) (core.ActivityCard, error) { + return s.SetStatusAccountChecked(actor, id, core.CardDismissed, "", "dismissed by user", allowImplicitDefault) +} + +func (s *Service) ActionAccountChecked(actor, id, actionID, freeText string, allowImplicitDefault bool) (core.ActivityCard, string, error) { + card, version, err := s.st.GetCardVersion(actor, id) + if err != nil { + return card, "", err + } + if card.Status != core.CardActionRequired { + return card, "", ErrNotAwaiting + } + label, found := actionID, false + for _, action := range card.Actions { + if action.ID == actionID { + label, found = action.Label, true + break + } + } + if !found { + if actionID == "" && freeText != "" { + label = freeText + } else { + return card, "", ErrNoAction + } + } + card.Status = core.CardResolved + card, err = s.updateVersionedAccountChecked(actor, card, version, allowImplicitDefault) + if err != nil { + return card, "", err + } + err = s.emitCardAccountChecked(actor, card, core.EvCardResolved, + core.CardResolvedPayload{CardID: card.ID, ActionID: actionID, Answer: freeText}) + return card, label, err +} + +// ToProjectAccountChecked performs the copy and dismissal in one transaction. +// Events are published after commit through the actor-aware event sink. +func (s *Service) ToProjectAccountChecked(actor, id string, allowImplicitDefault bool) (core.ActivityCard, error) { + if err := s.requireAccountCheckedEmitter(); err != nil { + return core.ActivityCard{}, err + } + archived, origin, created, err := s.st.ArchiveCardToProjectAccountChecked(actor, id, s.now().UTC(), allowImplicitDefault) + if err != nil || !created { + return archived, err + } + if err := s.emitCardAccountChecked(actor, archived, core.EvCardCreated, core.CardPayload{Card: archived}); err != nil { + return archived, err + } + if err := s.emitCardAccountChecked(actor, origin, core.EvCardUpdated, core.CardPayload{Card: origin}); err != nil { + return archived, err + } + err = s.emitCardAccountChecked(actor, origin, core.EvCardStatus, + core.CardStatusPayload{CardID: origin.ID, Status: core.CardDismissed, Tone: origin.Tone, Note: "已留档到项目"}) + return archived, err +} diff --git a/internal/cardsvc/account_checked_test.go b/internal/cardsvc/account_checked_test.go new file mode 100644 index 0000000..10d2a5b --- /dev/null +++ b/internal/cardsvc/account_checked_test.go @@ -0,0 +1,109 @@ +package cardsvc + +import ( + "errors" + "testing" + + "agent-system-backend/internal/store" + core "agent-system-core" +) + +func TestAccountCheckedCardActionsUseActorEventSinkAndRespectDisable(t *testing.T) { + st, err := store.OpenSQLite(t.TempDir() + "/cards.db") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { st.Close() }) + if err := st.CreateAccount("admin", "", "", true); err != nil { + t.Fatal(err) + } + if err := st.CreateAccount("alice", "", "", false); err != nil { + t.Fatal(err) + } + project, err := st.CreateProject("alice", "cards") + if err != nil { + t.Fatal(err) + } + conv, err := st.CreateConversation("alice", project.ID, "cards", "") + if err != nil { + t.Fatal(err) + } + legacyCalls := 0 + svc := New(st, func(string, string, core.StreamEventType, any) error { + legacyCalls++ + return nil + }) + type emitted struct { + actor, channel string + typ core.StreamEventType + } + var guarded []emitted + svc.SetAccountCheckedEmitter(func(actor, channel, runID string, typ core.StreamEventType, payload any) error { + guarded = append(guarded, emitted{actor, channel, typ}) + return nil + }) + base := core.ActivityCard{ProjectID: project.ID, ConversationID: conv.ID, Title: "Action", + Status: core.CardActionRequired, Actions: []core.UIAction{{ID: "yes", Label: "Approve"}}} + action, err := svc.CreateAccountChecked("alice", base, "", false) + if err != nil { + t.Fatal(err) + } + if _, label, err := svc.ActionAccountChecked("alice", action.ID, "yes", "", false); err != nil || label != "Approve" { + t.Fatalf("guarded action: label=%q err=%v", label, err) + } + pending, err := svc.CreateAccountChecked("alice", base, "", false) + if err != nil { + t.Fatal(err) + } + pin, err := svc.CreateAccountChecked("alice", core.ActivityCard{ProjectID: project.ID, ConversationID: conv.ID, Title: "Pin"}, "", false) + if err != nil { + t.Fatal(err) + } + if _, err := svc.SetPinnedAccountChecked("alice", pin.ID, true, false); err != nil { + t.Fatal(err) + } + if _, err := svc.DismissAccountChecked("alice", pin.ID, false); err != nil { + t.Fatal(err) + } + archive, err := svc.CreateAccountChecked("alice", core.ActivityCard{ProjectID: project.ID, ConversationID: conv.ID, Title: "Archive"}, "", false) + if err != nil { + t.Fatal(err) + } + archived, err := svc.ToProjectAccountChecked("alice", archive.ID, false) + if err != nil || archived.ConversationID != "" { + t.Fatalf("guarded archive: %+v %v", archived, err) + } + if legacyCalls != 0 { + t.Fatalf("guarded card operations used unguarded emitter %d times", legacyCalls) + } + if len(guarded) == 0 { + t.Fatal("guarded operations published no events") + } + for _, event := range guarded { + if event.actor != "alice" { + t.Fatalf("event actor lost: %+v", event) + } + } + before := len(guarded) + account, err := st.GetAccountByOwner("alice") + if err != nil { + t.Fatal(err) + } + if _, err := st.SetAccountEnabled("admin", account.ID, account.StateVersion, false); err != nil { + t.Fatal(err) + } + for _, mutate := range []func() error{ + func() error { _, err := svc.CreateAccountChecked("alice", base, "", false); return err }, + func() error { _, _, err := svc.ActionAccountChecked("alice", pending.ID, "yes", "", false); return err }, + func() error { _, err := svc.SetPinnedAccountChecked("alice", pin.ID, false, false); return err }, + func() error { _, err := svc.DismissAccountChecked("alice", pin.ID, false); return err }, + func() error { _, err := svc.ToProjectAccountChecked("alice", archive.ID, false); return err }, + } { + if err := mutate(); !errors.Is(err, store.ErrAccountForbidden) { + t.Fatalf("disabled actor mutated card: %v", err) + } + } + if len(guarded) != before { + t.Fatalf("disabled actor published %d events", len(guarded)-before) + } +} diff --git a/internal/cardsvc/cardsvc.go b/internal/cardsvc/cardsvc.go index 6b4d0ab..fc7bac5 100644 --- a/internal/cardsvc/cardsvc.go +++ b/internal/cardsvc/cardsvc.go @@ -17,19 +17,26 @@ import ( // 获得持久化、seq 排序、断线重放与终态对账(Stream 契约)。 type Emitter func(conversationID, runID string, typ core.StreamEventType, payload any) error +// AccountCheckedEmitter persists an actor-attributed event through its own +// final account-checked Store transaction. The card write and event emission +// are separate commits; the application wires this to its guarded event sink. +type AccountCheckedEmitter func(actor, conversationID, runID string, typ core.StreamEventType, payload any) error + var ( - ErrNotFound = store.ErrNotFound // 语义对齐存储层:跨租户/不存在一律 NotFound - ErrNoAction = errors.New("card: action not found") - ErrNotAwaiting = errors.New("card: not awaiting action") + ErrNotFound = store.ErrNotFound // 语义对齐存储层:跨租户/不存在一律 NotFound + ErrNoAction = errors.New("card: action not found") + ErrNotAwaiting = errors.New("card: not awaiting action") + ErrGuardedEmitterRequired = errors.New("card: account-checked emitter required") ) // cardAppendWindow 是流式追加(Append)的 card.updated 发布节流窗口(D45)。 const cardAppendWindow = 250 * time.Millisecond type Service struct { - st *store.Store - emit Emitter - projectSharedEmit ProjectSharedEmitter + st *store.Store + emit Emitter + accountCheckedEmit AccountCheckedEmitter + projectSharedEmit ProjectSharedEmitter // MirrorConvCards EXT-12:会话卡是否镜像进项目公共流(缺省 true=现状全量 // 镜像;false 时会话卡只进本人会话流,项目级卡不受影响)。 MirrorConvCards bool @@ -42,6 +49,10 @@ func New(st *store.Store, emit Emitter) *Service { return &Service{st: st, emit: emit, now: time.Now, lastEmit: map[string]time.Time{}, MirrorConvCards: true} } +func (s *Service) SetAccountCheckedEmitter(emit AccountCheckedEmitter) { + s.accountCheckedEmit = emit +} + // ProjectSharedEmitter is an internal trusted publication adapter. It is never // bound to model arguments or exposed as a mandatory extension capability. type ProjectSharedEmitter func(owner, producer, channel, runID string, typ core.StreamEventType, payload any) error diff --git a/internal/cmdbus/account_guard_test.go b/internal/cmdbus/account_guard_test.go new file mode 100644 index 0000000..b968960 --- /dev/null +++ b/internal/cmdbus/account_guard_test.go @@ -0,0 +1,169 @@ +package cmdbus + +import ( + "context" + "errors" + "strings" + "testing" + + agentcommand "agent-command" +) + +func TestEffectiveAccountGuardRejectsBeforeDispatchEffects(t *testing.T) { + for _, tc := range []struct { + name string + req Request + err error + want string + }{ + {"disabled handler", Request{ID: "demo.ping", Owner: "alice", Actor: "alice"}, errors.New("disabled"), "alice"}, + {"failed lookup", Request{ID: "demo.ping", Owner: "alice", Actor: "alice"}, errors.New("database unavailable"), "alice"}, + {"plugin actor uses account owner", Request{ID: "demo.ping", Owner: "alice", Actor: "plugin:rdm"}, errors.New("disabled"), "alice"}, + {"actor fallback", Request{ID: "demo.ping", Actor: "alice"}, errors.New("disabled"), "alice"}, + } { + t.Run(tc.name, func(t *testing.T) { + audit, cards := &memAudit{}, &memCards{} + b := newTestBus(audit, cards) + called := 0 + b.SetEffectiveAccountGuard(func(_ context.Context, actor string) error { + called++ + if actor != tc.want { + t.Fatalf("guard actor = %q, want %q", actor, tc.want) + } + return tc.err + }) + resp := b.Dispatch(context.Background(), tc.req) + if resp.Receipt.State != agentcommand.StateRejected || !strings.Contains(resp.Receipt.Reason, "effective account") { + t.Fatalf("guard rejection: %+v", resp.Receipt) + } + if called != 1 || len(audit.logs) != 0 || len(cards.created) != 0 { + t.Fatalf("guard calls=%d audits=%d cards=%d", called, len(audit.logs), len(cards.created)) + } + }) + } +} + +func TestEffectiveAccountGuardRejectsBeforePendingConfirmAndRechecks(t *testing.T) { + audit, cards := &memAudit{}, &memCards{} + b := newTestBus(audit, cards) + allowed := false + b.SetEffectiveAccountGuard(func(_ context.Context, actor string) error { + if actor != "alice" { + t.Fatalf("guard actor = %q", actor) + } + if !allowed { + return errors.New("disabled") + } + return nil + }) + req := Request{ID: "demo.risky", Owner: "alice", Actor: "alice", ConversationID: "cnv_1", Params: map[string]any{"target": "prod"}} + if r := b.Dispatch(context.Background(), req); r.Receipt.State != agentcommand.StateRejected { + t.Fatalf("disabled dispatch: %+v", r.Receipt) + } + if len(cards.created) != 0 || len(b.pending) != 0 || len(audit.logs) != 0 { + t.Fatal("disabled dispatch created confirmation effects") + } + allowed = true + r := b.Dispatch(context.Background(), req) + if r.Receipt.State != agentcommand.StateAccepted || len(cards.created) != 1 || len(b.pending) != 1 { + t.Fatalf("enabled dispatch: %+v", r.Receipt) + } + allowed = false + before := len(audit.logs) + if r := b.Confirm("alice", "card_x1", "confirm"); r.Receipt.State != agentcommand.StateRejected { + t.Fatalf("disabled confirm: %+v", r.Receipt) + } + if len(b.pending) != 1 || len(audit.logs) != before { + t.Fatal("disabled confirm consumed pending request or wrote audit") + } + allowed = true + if r := b.Confirm("alice", "card_x1", "confirm"); r.Receipt.State != agentcommand.StateCommitted { + t.Fatalf("re-enabled confirm: %+v", r.Receipt) + } +} + +func TestEffectiveAccountGuardStopsConfirmedHandlerAfterStateChanges(t *testing.T) { + b := New(&memAudit{}, &memCards{}, nil) + executed := 0 + if err := b.RegisterPluginCommand(agentcommand.CommandSpec{ID: "demo.effect", Title: "Effect"}, func(context.Context, Request) (any, error) { + executed++ + return "done", nil + }); err != nil { + t.Fatal(err) + } + checks := 0 + b.SetEffectiveAccountGuard(func(_ context.Context, actor string) error { + checks++ + if actor != "alice" { + t.Fatalf("guard actor = %q", actor) + } + if checks == 4 { + return errors.New("disabled after confirmation started") + } + return nil + }) + // Confirm passes its own check, then Dispatch must check once more before + // invoking the handler. A direct confirmed request takes the same path. + if err := b.RegisterBuiltin(agentcommand.CommandSpec{ID: "demo.high", Title: "High", Danger: agentcommand.DangerHigh}, "", func(context.Context, Request) (any, error) { + executed++ + return "done", nil + }); err != nil { + t.Fatal(err) + } + r := b.Dispatch(context.Background(), Request{ID: "demo.high", Owner: "alice", ConversationID: "cnv_1"}) + if r.Receipt.State != agentcommand.StateAccepted { + t.Fatalf("initial dispatch: %+v", r.Receipt) + } + // Dispatch checks again before creating the confirmation card. Confirm's + // check passes; its nested Dispatch catches the fourth-state change. + if r := b.Confirm("alice", "card_x1", "confirm"); r.Receipt.State != agentcommand.StateRejected { + t.Fatalf("confirm after disable: %+v", r.Receipt) + } + if executed != 0 || len(b.pending) != 0 { + t.Fatalf("executed=%d pending=%d", executed, len(b.pending)) + } + // Direct confirmed execution also passes through the same guard. + checks = 3 + if r := b.Dispatch(context.Background(), Request{ID: "demo.effect", Owner: "alice", Confirmed: true}); r.Receipt.State != agentcommand.StateRejected || executed != 0 { + t.Fatalf("direct confirmed dispatch: %+v executed=%d", r.Receipt, executed) + } +} + +type trackingPendingStore struct { + owner, payload string + takes int +} + +func (p *trackingPendingStore) SavePendingConfirm(_ string, owner, payload string) error { + p.owner, p.payload = owner, payload + return nil +} +func (p *trackingPendingStore) TakePendingConfirm(string) (string, string, bool, error) { + p.takes++ + return p.owner, p.payload, p.payload != "", nil +} + +func TestEffectiveAccountGuardDoesNotConsumeStoredPendingOnDeniedConfirm(t *testing.T) { + b := newTestBus(&memAudit{}, &memCards{}) + pendingStorage := &trackingPendingStore{} + b.SetPendingStore(pendingStorage) + allowed := true + b.SetEffectiveAccountGuard(func(_ context.Context, actor string) error { + if actor != "alice" { + t.Fatalf("guard actor = %q", actor) + } + if !allowed { + return errors.New("database unavailable") + } + return nil + }) + r := b.Dispatch(context.Background(), Request{ID: "demo.risky", Owner: "alice", ConversationID: "cnv_1", Params: map[string]any{"target": "prod"}}) + if r.Receipt.State != agentcommand.StateAccepted || pendingStorage.payload == "" { + t.Fatalf("pending setup: %+v", r.Receipt) + } + b.pending = map[string]pending{} // force the restored path + allowed = false + if r := b.Confirm("alice", "card_x1", "confirm"); r.Receipt.State != agentcommand.StateRejected || pendingStorage.takes != 0 { + t.Fatalf("denied confirm: %+v takes=%d", r.Receipt, pendingStorage.takes) + } +} diff --git a/internal/cmdbus/account_write_fence_test.go b/internal/cmdbus/account_write_fence_test.go new file mode 100644 index 0000000..6f38692 --- /dev/null +++ b/internal/cmdbus/account_write_fence_test.go @@ -0,0 +1,18 @@ +package cmdbus + +import ( + "context" + "testing" + + agentcommand "agent-command" +) + +func TestStrictAccountWritesRejectLegacyBusPorts(t *testing.T) { + audit, cards := &memAudit{}, &memCards{} + b := newTestBus(audit, cards) + b.RequireAccountCheckedWrites(false) + resp := b.Dispatch(context.Background(), Request{ID: "demo.risky", Owner: "alice", Actor: "alice", ConversationID: "cnv_1", Params: map[string]any{"target": "prod"}}) + if resp.Receipt.State != agentcommand.StateUnknown || len(cards.created) != 0 || len(audit.logs) != 0 || len(b.pending) != 0 { + t.Fatalf("legacy port wrote under strict mode: %+v, cards=%d audit=%d pending=%d", resp.Receipt, len(cards.created), len(audit.logs), len(b.pending)) + } +} diff --git a/internal/cmdbus/cmdbus.go b/internal/cmdbus/cmdbus.go index 9b63e7d..00b8338 100644 --- a/internal/cmdbus/cmdbus.go +++ b/internal/cmdbus/cmdbus.go @@ -49,6 +49,13 @@ type CommandTool struct { // 不投影给该 owner 的模型。组合根按需装配(如 generative-ui 按 activation 台账)。 type ToolGate func(owner, commandID string) bool +// EffectiveAccountGuard checks the current account state before a command can +// create a confirmation or invoke a handler. The composition root supplies the +// check; nil keeps standalone and auth-off fixtures unchanged. The actor is +// derived only from trusted Request identity fields, never Params. This is an +// early check, not an atomic fence across handler databases or external calls. +type EffectiveAccountGuard func(ctx context.Context, actor string) error + // SetToolGate 装配 owner 级注入闸(组合根;启动期一次)。 func (b *Bus) SetToolGate(g ToolGate) { b.toolGate = g } @@ -108,17 +115,20 @@ type pending struct { } type Bus struct { - reg *agentcommand.Registry - audit AuditStore - cards CardPort - now func() time.Time - handlers map[string]Handler - slash map[string]string // "/x" → command id - pendMu sync.Mutex - pending map[string]pending - pendingStore PendingStore - intents *IntentStore // G2-2:意图声明表(自然语言路由) - toolGate ToolGate // U3:owner 级工具注入闸(nil = 不过滤) + reg *agentcommand.Registry + audit AuditStore + cards CardPort + now func() time.Time + handlers map[string]Handler + slash map[string]string // "/x" → command id + pendMu sync.Mutex + pending map[string]pending + pendingStore PendingStore + intents *IntentStore // G2-2:意图声明表(自然语言路由) + toolGate ToolGate // U3:owner 级工具注入闸(nil = 不过滤) + accountGuard EffectiveAccountGuard // A1: optional command execution precheck + strictAccountWrites bool + allowImplicitLocal bool } // PendingStore 把危险命令的确认挂起态落盘。进程内 map 仍是热路径;重启后从存储取回。 @@ -144,6 +154,70 @@ func (b *Bus) Registry() *agentcommand.Registry { return b.reg } // SetPendingStore 注入确认挂起态的持久化实现(nil = 仅进程内)。 func (b *Bus) SetPendingStore(st PendingStore) { b.pendingStore = st } +// SetEffectiveAccountGuard installs the composition-root check at startup. +// Handler Store writes still need their own final transaction fence. +func (b *Bus) SetEffectiveAccountGuard(g EffectiveAccountGuard) { b.accountGuard = g } + +// RequireAccountCheckedWrites makes every Bus-owned durable sink use a final +// account transaction. The composition root enables this after wiring Store +// and Cards; standalone in-memory fixtures retain their legacy ports. +func (b *Bus) RequireAccountCheckedWrites(allowImplicitLocal bool) { + b.strictAccountWrites = true + b.allowImplicitLocal = allowImplicitLocal +} + +func (b *Bus) createConfirmCard(actor string, card core.ActivityCard, ref string) (core.ActivityCard, error) { + if !b.strictAccountWrites { + return b.cards.Create(actor, card, ref) + } + checked, ok := b.cards.(interface { + CreateAccountChecked(string, core.ActivityCard, string, bool) (core.ActivityCard, error) + }) + if !ok { + return core.ActivityCard{}, fmt.Errorf("cmdbus: account-checked card port required") + } + return checked.CreateAccountChecked(actor, card, ref, b.allowImplicitLocal) +} + +func (b *Bus) setConfirmCardStatus(actor, id string, status core.CardStatus, tone, note string) (core.ActivityCard, error) { + if !b.strictAccountWrites { + return b.cards.SetStatus(actor, id, status, tone, note) + } + checked, ok := b.cards.(interface { + SetStatusAccountChecked(string, string, core.CardStatus, string, string, bool) (core.ActivityCard, error) + }) + if !ok { + return core.ActivityCard{}, fmt.Errorf("cmdbus: account-checked card port required") + } + return checked.SetStatusAccountChecked(actor, id, status, tone, note, b.allowImplicitLocal) +} + +func (b *Bus) savePending(cardID, owner, payload string) error { + if !b.strictAccountWrites { + return b.pendingStore.SavePendingConfirm(cardID, owner, payload) + } + checked, ok := b.pendingStore.(interface { + SavePendingConfirmAccountChecked(string, string, string, bool) error + }) + if !ok { + return fmt.Errorf("cmdbus: account-checked pending store required") + } + return checked.SavePendingConfirmAccountChecked(cardID, owner, payload, b.allowImplicitLocal) +} + +func (b *Bus) takePending(cardID, owner string) (string, string, bool, error) { + if !b.strictAccountWrites { + return b.pendingStore.TakePendingConfirm(cardID) + } + checked, ok := b.pendingStore.(interface { + TakePendingConfirmAccountChecked(string, string, bool) (string, string, bool, error) + }) + if !ok { + return "", "", false, fmt.Errorf("cmdbus: account-checked pending store required") + } + return checked.TakePendingConfirmAccountChecked(cardID, owner, b.allowImplicitLocal) +} + // RegisterBuiltin 登记内置命令并绑定宿主处理器与 `/` 别名。 func (b *Bus) RegisterBuiltin(spec agentcommand.CommandSpec, slash string, h Handler) error { spec.Binding = agentcommand.Binding{Kind: agentcommand.BindBuiltin} @@ -242,10 +316,16 @@ func (b *Bus) HasCommand(id string) bool { // List 四入口共享的命令清单(`/` 菜单、按钮、卡片 action 提示用)。 func (b *Bus) List() []agentcommand.Registered { return b.reg.List() } -// Dispatch 统一分发:校验→危险收口→执行→审计。任何路径都产出回执与审计(入口对称)。 +// Dispatch 统一分发:账号预检→校验→危险收口→执行→审计。账号预检拒绝只返回回执, +// 避免停用账号或检查故障时继续写入审计;其余路径维持四入口审计对称。 func (b *Bus) Dispatch(ctx context.Context, req Request) Response { start := b.now() req.Entrance = agentcommand.CanonicalEntrance(req.Entrance) + if err := b.checkEffectiveAccount(ctx, accountActor(req)); err != nil { + // A rejected account cannot create a card, pending row, audit row, or + // invoke a handler. A failed read is also a denial. + return b.accountRejected(req.ID, start) + } receipt := func(state agentcommand.ResultState, op, reason string) Response { return Response{Receipt: b.auditReceipt(req, state, op, reason, start)} } @@ -261,17 +341,24 @@ func (b *Bus) Dispatch(ctx context.Context, req Request) Response { if b.cards == nil || req.ConversationID == "" { return receipt(agentcommand.StateRejected, "", "high-danger command requires conversation context for inline confirm") } - card, err := b.cards.Create(req.Owner, confirmCard(spec, req), "cmd:"+spec.ID) + if err := b.checkEffectiveAccount(ctx, accountActor(req)); err != nil { + return b.accountRejected(req.ID, start) + } + card, err := b.createConfirmCard(accountActor(req), confirmCard(spec, req), "cmd:"+spec.ID) if err != nil { return receipt(agentcommand.StateUnknown, "", "confirm card: "+err.Error()) } + if b.pendingStore != nil { + raw, _ := json.Marshal(req) + if err := b.savePending(card.ID, accountActor(req), string(raw)); err != nil { + return receipt(agentcommand.StateUnknown, "", "confirm pending: "+err.Error()) + } + } else if b.strictAccountWrites { + return receipt(agentcommand.StateUnknown, "", "confirm pending: account-checked store required") + } b.pendMu.Lock() b.pending[card.ID] = pending{req: req, cardID: card.ID} b.pendMu.Unlock() - if b.pendingStore != nil { - raw, _ := json.Marshal(req) - _ = b.pendingStore.SavePendingConfirm(card.ID, req.Owner, string(raw)) - } r := b.auditReceipt(req, agentcommand.StateAccepted, card.ID, "awaiting explicit confirm", start) return Response{Receipt: r, Card: &card} } @@ -279,6 +366,9 @@ func (b *Bus) Dispatch(ctx context.Context, req Request) Response { if !ok { return receipt(agentcommand.StateRejected, "", "no host handler bound for "+req.ID) } + if err := b.checkEffectiveAccount(ctx, accountActor(req)); err != nil { + return b.accountRejected(req.ID, start) + } payload, err := h(ctx, req) if err != nil { return receipt(agentcommand.StateRejected, "", "rejected: "+err.Error()) @@ -289,49 +379,98 @@ func (b *Bus) Dispatch(ctx context.Context, req Request) Response { // Confirm 确认卡回道:actionID=confirm 执行挂起请求(entrance 归一 card),其余视为取消。 func (b *Bus) Confirm(owner, cardID, actionID string) Response { + return b.ConfirmContext(context.Background(), owner, cardID, actionID) +} + +// ConfirmContext carries the request cancellation through the confirmed +// handler and repeats the account check before consuming durable state. +func (b *Bus) ConfirmContext(ctx context.Context, owner, cardID, actionID string) Response { + // The caller supplies the authenticated card owner. Check before even + // consuming a persisted pending request; Dispatch checks the stored + // request again immediately before executing its handler. + if err := b.checkEffectiveAccount(ctx, owner); err != nil { + return b.accountRejected("unknown", b.now()) + } b.pendMu.Lock() p, ok := b.pending[cardID] - if ok && actionID == "confirm" { - delete(b.pending, cardID) - } b.pendMu.Unlock() - if ok && actionID == "confirm" && b.pendingStore != nil { - _, _, _, _ = b.pendingStore.TakePendingConfirm(cardID) + if ok && p.req.Owner != "" && p.req.Owner != owner { + return Response{Receipt: agentcommand.ExecReceipt{CommandID: p.req.ID, State: agentcommand.StateRejected, Reason: "confirm owner mismatch", Time: b.now().UTC()}} } - if !ok && b.pendingStore != nil { - _, payload, found, err := b.pendingStore.TakePendingConfirm(cardID) - if err == nil && found { - var req Request - if json.Unmarshal([]byte(payload), &req) == nil { - p = pending{req: req, cardID: cardID} - ok = true - } + if b.pendingStore != nil { + storedOwner, payload, found, err := b.takePending(cardID, owner) + if err != nil { + return b.accountRejected("unknown", b.now()) } + if found { + var req Request + if storedOwner != owner || json.Unmarshal([]byte(payload), &req) != nil { + return Response{Receipt: agentcommand.ExecReceipt{CommandID: "unknown", State: agentcommand.StateRejected, Reason: "invalid pending confirm", Time: b.now().UTC()}} + } + p = pending{req: req, cardID: cardID} + ok = true + } else if b.strictAccountWrites { + ok = false + } + } else if b.strictAccountWrites { + ok = false } if !ok { return Response{Receipt: agentcommand.ExecReceipt{CommandID: "unknown", State: agentcommand.StateRejected, Reason: "no pending confirm (expired or already resolved)", Time: b.now().UTC()}} } req := p.req + if req.Owner != "" && req.Owner != owner { + return Response{Receipt: agentcommand.ExecReceipt{CommandID: req.ID, State: agentcommand.StateRejected, Reason: "confirm owner mismatch", Time: b.now().UTC()}} + } + b.pendMu.Lock() + delete(b.pending, cardID) + b.pendMu.Unlock() req.Confirmed = true req.Entrance = "card" if actionID != "confirm" { if b.cards != nil { - _, _ = b.cards.SetStatus(owner, cardID, core.CardResolved, "muted", "cancelled") + _, _ = b.setConfirmCardStatus(owner, cardID, core.CardResolved, "muted", "cancelled") } start := b.now() return Response{Receipt: b.auditReceipt(req, agentcommand.StateRejected, "", "cancelled at confirm card", start)} } - resp := b.Dispatch(context.Background(), req) + resp := b.Dispatch(ctx, req) if b.cards != nil { if resp.Receipt.State == agentcommand.StateCommitted { - _, _ = b.cards.SetStatus(owner, cardID, core.CardResolved, "ok", "confirmed") + _, _ = b.setConfirmCardStatus(owner, cardID, core.CardResolved, "ok", "confirmed") } else { - _, _ = b.cards.SetStatus(owner, cardID, core.CardFailed, "danger", resp.Receipt.Reason) + _, _ = b.setConfirmCardStatus(owner, cardID, core.CardFailed, "danger", resp.Receipt.Reason) } } return resp } +func accountActor(req Request) string { + // Plugin and delegated commands use Actor for an audit label such as a + // plugin ID; Owner is the effective account whose state controls effects. + if req.Owner != "" { + return req.Owner + } + return req.Actor +} + +func (b *Bus) checkEffectiveAccount(ctx context.Context, actor string) error { + if err := ctx.Err(); err != nil { + return err + } + if b.accountGuard == nil { + return nil + } + return b.accountGuard(ctx, actor) +} + +func (b *Bus) accountRejected(commandID string, start time.Time) Response { + return Response{Receipt: agentcommand.ExecReceipt{ + CommandID: commandID, State: agentcommand.StateRejected, + Reason: "effective account unavailable", Time: start.UTC(), + }} +} + func (b *Bus) auditReceipt(req Request, state agentcommand.ResultState, op, reason string, start time.Time) agentcommand.ExecReceipt { env := agentcommand.AuditEnvelope{ Time: start.UTC(), Actor: req.Actor, CommandID: req.ID, Entrance: agentcommand.CanonicalEntrance(req.Entrance), @@ -341,7 +480,20 @@ func (b *Bus) auditReceipt(req Request, state agentcommand.ResultState, op, reas if _, src, err := b.reg.Resolve(req.ID); err == nil { env.Source = src } - if err := b.audit.AppendAudit(req.Owner, env); err != nil { + var err error + if b.strictAccountWrites { + checked, ok := b.audit.(interface { + AppendAuditAccountChecked(string, agentcommand.AuditEnvelope, bool) error + }) + if !ok { + err = fmt.Errorf("cmdbus: account-checked audit store required") + } else { + err = checked.AppendAuditAccountChecked(accountActor(req), env, b.allowImplicitLocal) + } + } else { + err = b.audit.AppendAudit(req.Owner, env) + } + if err != nil { // 审计不可用:结果标记未知,不臆断成功(四态语义)。 return agentcommand.ExecReceipt{CommandID: req.ID, State: agentcommand.StateUnknown, Reason: "audit unavailable: " + err.Error(), Time: b.now().UTC()} } diff --git a/internal/plugins/account_reload_test.go b/internal/plugins/account_reload_test.go new file mode 100644 index 0000000..6db28bd --- /dev/null +++ b/internal/plugins/account_reload_test.go @@ -0,0 +1,137 @@ +package plugins + +import ( + "encoding/json" + "errors" + "os" + "path/filepath" + "testing" + + "agent-system-backend/internal/store" +) + +func TestLoadDirAccountCheckedRejectsDisabledActorWithoutEffects(t *testing.T) { + host, reg, dir := newHost(t) + if err := host.st.CreateAccount("admin", "", "hash", true); err != nil { + t.Fatal(err) + } + if err := host.st.CreateAccount("alice", "", "hash", false); err != nil { + t.Fatal(err) + } + writeExamplePlugin(t, dir, "example") + if err := host.LoadDir()["example"]; err != nil { + t.Fatal(err) + } + if _, _, ok := reg.Get("http_echo"); !ok { + t.Fatal("fixture plugin was not registered") + } + if err := os.RemoveAll(filepath.Join(dir, "example")); err != nil { + t.Fatal(err) + } + root := filepath.Join(dir, "side") + if err := os.MkdirAll(root, 0o755); err != nil { + t.Fatal(err) + } + mark := filepath.Join(t.TempDir(), "sidecar-ran") + manifest := `{"id":"side","name":"Side","version":"1.0.0","apiVersion":"1.0","backend":{"mode":"sidecar","command":"/bin/touch","args":["` + mark + `"],"schemaVersion":1},"provides":{}}` + if err := os.WriteFile(filepath.Join(root, "plugin.json"), []byte(manifest), 0o644); err != nil { + t.Fatal(err) + } + account, err := host.st.GetAccountByOwner("alice") + if err != nil { + t.Fatal(err) + } + if _, err := host.st.SetAccountEnabled("admin", account.ID, account.StateVersion, false); err != nil { + t.Fatal(err) + } + + if _, err := host.LoadDirAccountChecked("alice", false); !errors.Is(err, store.ErrAccountForbidden) { + t.Fatalf("disabled reload: %v", err) + } + if _, _, ok := reg.Get("http_echo"); !ok || len(host.List()) != 1 { + t.Fatal("disabled reload changed the existing process registry") + } + if _, err := os.Stat(mark); !os.IsNotExist(err) { + t.Fatalf("disabled reload started sidecar: %v", err) + } + plugins, err := host.st.ListPlugins() + if err != nil { + t.Fatal(err) + } + if len(plugins) != 1 || plugins[0].ID != "example" { + t.Fatalf("disabled reload changed plugin rows: %+v", plugins) + } + if version, err := host.st.PluginSchemaVersion("side"); err != nil || version != 0 { + t.Fatalf("disabled reload changed schema version: %d, %v", version, err) + } +} + +func TestLoadDirAccountCheckedWritesForEnabledActor(t *testing.T) { + host, _, dir := newHost(t) + if err := host.st.CreateAccount("alice", "", "hash", false); err != nil { + t.Fatal(err) + } + root := filepath.Join(dir, "plain") + if err := os.MkdirAll(root, 0o755); err != nil { + t.Fatal(err) + } + manifest := `{"id":"plain","name":"Plain","version":"1.0.0","apiVersion":"1.0","backend":{"schemaVersion":2},"provides":{}}` + if err := os.WriteFile(filepath.Join(root, "plugin.json"), []byte(manifest), 0o644); err != nil { + t.Fatal(err) + } + results, err := host.LoadDirAccountChecked("alice", false) + if err != nil || results["plain"] != nil { + t.Fatalf("enabled reload: %v, %+v", err, results) + } + plugins, err := host.st.ListPlugins() + if err != nil || len(plugins) != 1 || plugins[0].ID != "plain" { + t.Fatalf("plugin row: %+v, %v", plugins, err) + } + if version, err := host.st.PluginSchemaVersion("plain"); err != nil || version != 2 { + t.Fatalf("schema version: %d, %v", version, err) + } +} + +func TestLoadDirAccountCheckedRechecksBetweenPlugins(t *testing.T) { + host, _, dir := newHost(t) + if err := host.st.CreateAccount("admin", "", "hash", true); err != nil { + t.Fatal(err) + } + if err := host.st.CreateAccount("alice", "", "hash", false); err != nil { + t.Fatal(err) + } + account, err := host.st.GetAccountByOwner("alice") + if err != nil { + t.Fatal(err) + } + mark := filepath.Join(t.TempDir(), "b-ran") + for id, manifest := range map[string]string{ + "a": `{"id":"a","name":"A","version":"1.0.0","apiVersion":"1.0","provides":{}}`, + "b": `{"id":"b","name":"B","version":"1.0.0","apiVersion":"1.0","backend":{"mode":"sidecar","command":"/bin/touch","args":["` + mark + `"]},"provides":{}}`, + } { + root := filepath.Join(dir, id) + if err := os.MkdirAll(root, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(root, "plugin.json"), []byte(manifest), 0o644); err != nil { + t.Fatal(err) + } + } + callbackCount := 0 + host.OnCommands = func([]json.RawMessage) error { + callbackCount++ + _, err := host.st.SetAccountEnabled("admin", account.ID, account.StateVersion, false) + return err + } + results, err := host.LoadDirAccountChecked("alice", false) + if !errors.Is(err, store.ErrAccountForbidden) || results["a"] != nil || callbackCount != 1 { + t.Fatalf("reload after callback disable: %v, %+v, callbacks=%d", err, results, callbackCount) + } + plugins, err := host.st.ListPlugins() + if err != nil || len(plugins) != 1 || plugins[0].ID != "a" { + t.Fatalf("later plugin wrote after disable: %+v, %v", plugins, err) + } + if _, err := os.Stat(mark); !os.IsNotExist(err) { + t.Fatalf("later sidecar started after disable: %v", err) + } +} diff --git a/internal/plugins/host.go b/internal/plugins/host.go index 99f55a3..2ab0126 100644 --- a/internal/plugins/host.go +++ b/internal/plugins/host.go @@ -67,14 +67,55 @@ func New(st *store.Store, reg *tools.Registry, s *settings.Service, dir string) return &Host{st: st, registry: reg, settings: s, dir: dir, loaded: map[string]*Loaded{}, sidecars: map[string]*exec.Cmd{}} } +type loadAccountGuard struct { + st *store.Store + actor string + allowImplicitDefault bool + failed error +} + +func (g *loadAccountGuard) check() error { + if g == nil { + return nil // startup and existing fixture loader retain legacy behavior + } + if g.failed != nil { + return g.failed + } + g.failed = g.st.CheckEffectiveAccountForExternalEffect(g.actor, g.allowImplicitDefault) + return g.failed +} + +func (g *loadAccountGuard) record(err error) error { + if g != nil && err != nil { + g.failed = err + } + return err +} + // LoadDir 全量装载目录(启动与热加载共用);返回每个插件的结果(失败不中断其他)。 func (h *Host) LoadDir() map[string]error { + results, _ := h.loadDir(nil) + return results +} + +// LoadDirAccountChecked reloads on behalf of an already authorized actor. +// Each Backend-owned DB write is fenced in its final transaction; process, +// sidecar and filesystem effects get a fresh state check immediately before +// execution. The caller must separately enforce plugin-management authority. +func (h *Host) LoadDirAccountChecked(actor string, allowImplicitDefault bool) (map[string]error, error) { + return h.loadDir(&loadAccountGuard{st: h.st, actor: actor, allowImplicitDefault: allowImplicitDefault}) +} + +func (h *Host) loadDir(guard *loadAccountGuard) (map[string]error, error) { + if err := guard.check(); err != nil { + return nil, err + } entries, err := os.ReadDir(h.dir) if err != nil { if os.IsNotExist(err) { - return map[string]error{} + return map[string]error{}, nil } - return map[string]error{"": err} + return map[string]error{"": err}, nil } results := map[string]error{} seen := map[string]bool{} @@ -84,12 +125,19 @@ func (h *Host) LoadDir() map[string]error { } id := e.Name() seen[id] = true - results[id] = h.loadOne(filepath.Join(h.dir, id)) + results[id] = h.loadOne(filepath.Join(h.dir, id), guard) + if guard != nil && guard.failed != nil { + return results, guard.failed + } } // 移除已消失的插件(卸载语义)。 h.mu.Lock() for id := range h.loaded { if !seen[id] { + if err := guard.check(); err != nil { + h.mu.Unlock() + return results, err + } delete(h.loaded, id) if cmd := h.sidecars[id]; cmd != nil && cmd.Process != nil { _ = cmd.Process.Kill() @@ -104,25 +152,34 @@ func (h *Host) LoadDir() map[string]error { onIntents, onCommands, onWorkflows := h.OnIntents, h.OnCommands, h.OnWorkflows h.mu.Unlock() if onCommands != nil { + if err := guard.check(); err != nil { + return results, err + } if err := onCommands(cmdOverlay); err != nil { results[""] = err } } if onIntents != nil { + if err := guard.check(); err != nil { + return results, err + } if err := onIntents(overlay); err != nil { results[""] = err } } if onWorkflows != nil { + if err := guard.check(); err != nil { + return results, err + } if err := onWorkflows(defs, scripts); err != nil { results[""] = err } } - return results + return results, nil } // loadOne 装载单个插件;任何失败返回错误且不改变既有注册(fail-closed)。 -func (h *Host) loadOne(dir string) error { +func (h *Host) loadOne(dir string, guard *loadAccountGuard) error { manifestPath := filepath.Join(dir, "plugin.json") raw, err := os.ReadFile(manifestPath) if err != nil { @@ -145,6 +202,9 @@ func (h *Host) loadOne(dir string) error { ld := &Loaded{Manifest: m, Dir: dir, Cards: map[string][]core.UINode{}, Prompts: map[string]string{}, Defaults: map[string]any{}, Scripts: map[string]string{}} // 同 ID 重载:先注销旧注册(含旧版本工具),再装载新资产。 + if err := guard.check(); err != nil { + return err + } h.registry.UnregisterBySource(core.SourcePlugin, m.ID) // tools/*.json:声明式 HTTP 工具(或含 parameters 的描述型工具)。 @@ -159,6 +219,9 @@ func (h *Host) loadOne(dir string) error { if err != nil { return fmt.Errorf("plugins: %s: tool %s: %w", m.ID, name, err) } + if err := guard.check(); err != nil { + return err + } if err := h.registry.Register(desc, exec); err != nil { return fmt.Errorf("plugins: %s: register tool %s: %w", m.ID, name, err) } @@ -261,10 +324,13 @@ func (h *Host) loadOne(dir string) error { } } - if err := h.loadDeclared(dir, m, ld); err != nil { + if err := h.loadDeclared(dir, m, ld, guard); err != nil { return err } - if err := h.startSidecar(m, dir); err != nil { + if err := h.startSidecar(m, dir, guard); err != nil { + return err + } + if err := guard.check(); err != nil { return err } h.mu.Lock() @@ -274,18 +340,31 @@ func (h *Host) loadOne(dir string) error { defs, scripts := h.collectWorkflowsLocked() onIntents, onCommands, onWorkflows := h.OnIntents, h.OnCommands, h.OnWorkflows h.mu.Unlock() - _ = h.st.UpsertPlugin(m.ID, dir, m) + if guard == nil { + _ = h.st.UpsertPlugin(m.ID, dir, m) + } else if err := h.st.UpsertPluginAccountChecked(guard.actor, m.ID, dir, m, guard.allowImplicitDefault); err != nil { + return guard.record(err) + } if onCommands != nil { + if err := guard.check(); err != nil { + return err + } if err := onCommands(cmdOverlay); err != nil { return fmt.Errorf("plugins: %s: commands rejected by command bus (kept previous): %w", m.ID, err) } } if onIntents != nil { + if err := guard.check(); err != nil { + return err + } if err := onIntents(overlay); err != nil { return fmt.Errorf("plugins: %s: intents rejected by host gate (kept previous): %w", m.ID, err) } } if onWorkflows != nil { + if err := guard.check(); err != nil { + return err + } if err := onWorkflows(defs, scripts); err != nil { return fmt.Errorf("plugins: %s: workflows rejected: %w", m.ID, err) } diff --git a/internal/plugins/host_v2.go b/internal/plugins/host_v2.go index b02b827..147ac67 100644 --- a/internal/plugins/host_v2.go +++ b/internal/plugins/host_v2.go @@ -21,7 +21,7 @@ func (h *Host) SetHostAPI(baseURL, token string) { h.hostAPI, h.hostToken = baseURL, token } -func (h *Host) startSidecar(m core.PluginManifest, dir string) error { +func (h *Host) startSidecar(m core.PluginManifest, dir string, guard *loadAccountGuard) error { if m.Backend.Mode != "sidecar" || m.Backend.Command == "" { return nil } @@ -38,10 +38,19 @@ func (h *Host) startSidecar(m core.PluginManifest, dir string) error { "AGENT_HOST_TOKEN="+h.hostToken, "AGENT_PLUGIN_ID="+m.ID, ) + if err := guard.check(); err != nil { + return err + } if err := cmd.Start(); err != nil { return fmt.Errorf("plugins: %s: sidecar: %w", m.ID, err) } h.mu.Lock() + if err := guard.check(); err != nil { + h.mu.Unlock() + _ = cmd.Process.Kill() + _ = cmd.Wait() + return err + } if h.sidecars == nil { h.sidecars = map[string]*exec.Cmd{} } @@ -54,7 +63,7 @@ func (h *Host) startSidecar(m core.PluginManifest, dir string) error { return nil } -func (h *Host) loadDeclared(dir string, m core.PluginManifest, ld *Loaded) error { +func (h *Host) loadDeclared(dir string, m core.PluginManifest, ld *Loaded, guard *loadAccountGuard) error { for _, name := range m.Provides.Skills { body, err := os.ReadFile(filepath.Join(dir, "skills", name+".md")) if err != nil { @@ -70,9 +79,15 @@ func (h *Host) loadDeclared(dir string, m core.PluginManifest, ld *Loaded) error ld.Skills[name] = string(body) if h.skillsRoot != "" { dest := filepath.Join(h.skillsRoot, "plugin-"+m.ID, name) + if err := guard.check(); err != nil { + return err + } if err := os.MkdirAll(dest, 0o755); err != nil { return err } + if err := guard.check(); err != nil { + return err + } if err := os.WriteFile(filepath.Join(dest, "SKILL.md"), body, 0o644); err != nil { return err } @@ -131,6 +146,9 @@ func (h *Host) loadDeclared(dir string, m core.PluginManifest, ld *Loaded) error ld.Pages = append(ld.Pages, item) } if m.Backend.SchemaVersion > 0 && h.st != nil { + if guard != nil { + return guard.record(h.st.SetPluginSchemaVersionAccountChecked(guard.actor, m.ID, m.Backend.SchemaVersion, guard.allowImplicitDefault)) + } cur, err := h.st.PluginSchemaVersion(m.ID) if err != nil { return err diff --git a/internal/profiles/profiles.go b/internal/profiles/profiles.go index f05e17b..bfab9ac 100644 --- a/internal/profiles/profiles.go +++ b/internal/profiles/profiles.go @@ -74,6 +74,23 @@ func (s *Service) Save(owner string, p core.AgentProfile) error { func (s *Service) Delete(owner, id string) error { return s.st.DeleteProfile(owner, id) } +// SaveAccountChecked is the user-facing write path. Builtin profiles remain +// read-only and the Store binds the write to the actor's effective account. +func (s *Service) SaveAccountChecked(actor string, p core.AgentProfile, allowImplicitDefault bool) error { + if p.ID == DefaultProfileID || p.Builtin { + return errReadOnly("profiles: builtin profile is read-only; duplicate and modify") + } + if p.Tools.Allow == nil { + p.Tools.Allow = []string{"*"} + } + p.Owner = actor + return s.st.SaveProfileAccountChecked(actor, p, allowImplicitDefault) +} + +func (s *Service) DeleteAccountChecked(actor, id string, allowImplicitDefault bool) error { + return s.st.DeleteProfileAccountChecked(actor, id, allowImplicitDefault) +} + type errReadOnly string func (e errReadOnly) Error() string { return string(e) } diff --git a/internal/profiles/profiles_fence_test.go b/internal/profiles/profiles_fence_test.go new file mode 100644 index 0000000..0097370 --- /dev/null +++ b/internal/profiles/profiles_fence_test.go @@ -0,0 +1,54 @@ +package profiles + +import ( + "errors" + "path/filepath" + "testing" + + "agent-system-backend/internal/store" + core "agent-system-core" +) + +func TestProfileServiceAccountCheckedWrites(t *testing.T) { + st, err := store.OpenSQLite(filepath.Join(t.TempDir(), "test.db")) + if err != nil { + t.Fatal(err) + } + defer st.Close() + if err := st.CreateAccount("admin", "", "", true); err != nil { + t.Fatal(err) + } + if err := st.CreateAccount("alice", "", "", false); err != nil { + t.Fatal(err) + } + svc := New(st) + if err := svc.EnsureDefault(); err != nil { + t.Fatal(err) + } + if err := svc.SaveAccountChecked("alice", core.AgentProfile{ID: "profile-a", Name: "A"}, false); err != nil { + t.Fatal(err) + } + got, err := st.GetProfile("alice", "profile-a") + if err != nil || got.Owner != "alice" || got.Builtin || len(got.Tools.Allow) != 1 || got.Tools.Allow[0] != "*" { + t.Fatalf("service did not normalize actor profile: %+v %v", got, err) + } + if err := svc.SaveAccountChecked("alice", core.AgentProfile{ID: DefaultProfileID}, false); err == nil { + t.Fatal("service edited builtin default") + } + if err := svc.SaveAccountChecked("alice", core.AgentProfile{ID: "forged", Builtin: true}, false); err == nil { + t.Fatal("service accepted forged builtin profile") + } + account, err := st.GetAccountByOwner("alice") + if err != nil { + t.Fatal(err) + } + if _, err := st.SetAccountEnabled("admin", account.ID, account.StateVersion, false); err != nil { + t.Fatal(err) + } + if err := svc.DeleteAccountChecked("alice", "profile-a", false); !errors.Is(err, store.ErrAccountForbidden) { + t.Fatalf("disabled actor deleted profile: %v", err) + } + if err := svc.SaveAccountChecked("alice", core.AgentProfile{ID: "profile-a", Name: "late"}, false); !errors.Is(err, store.ErrAccountForbidden) { + t.Fatalf("disabled actor saved profile: %v", err) + } +} diff --git a/internal/prompts/service.go b/internal/prompts/service.go index 948e9aa..aad16e1 100644 --- a/internal/prompts/service.go +++ b/internal/prompts/service.go @@ -228,6 +228,27 @@ func (s *Service) Save(owner string, m core.PromptModule) error { // Delete 撤销用户覆盖(回到内置)。 func (s *Service) Delete(owner, id string) error { return s.st.DeletePromptModule(owner, id) } +// SaveAccountChecked applies the same user override rules as Save and uses +// the Store's final actor-state transaction for the database write. +func (s *Service) SaveAccountChecked(actor string, m core.PromptModule, allowImplicitDefault bool) error { + if err := validateSubagentOverride(m); err != nil { + return err + } + m.Source = string(core.SourceUser) + m.Owner = actor + for _, b := range s.builtin { + if b.ID == m.ID { + m.BasedOnVersion = b.Version + break + } + } + return s.st.SavePromptModuleAccountChecked(actor, m, allowImplicitDefault) +} + +func (s *Service) DeleteAccountChecked(actor, id string, allowImplicitDefault bool) error { + return s.st.DeletePromptModuleAccountChecked(actor, id, allowImplicitDefault) +} + // MatchCommand 命令触发匹配:文本以某技能命令开头时返回该模块与剩余文本。 func (s *Service) MatchCommand(owner, text string) (core.PromptModule, string, bool, error) { mods, err := s.Effective(owner) diff --git a/internal/prompts/service_fence_test.go b/internal/prompts/service_fence_test.go new file mode 100644 index 0000000..8d32690 --- /dev/null +++ b/internal/prompts/service_fence_test.go @@ -0,0 +1,52 @@ +package prompts + +import ( + "errors" + "path/filepath" + "testing" + + "agent-system-backend/internal/store" + core "agent-system-core" +) + +func TestPromptServiceAccountCheckedWrites(t *testing.T) { + st, err := store.OpenSQLite(filepath.Join(t.TempDir(), "test.db")) + if err != nil { + t.Fatal(err) + } + defer st.Close() + if err := st.CreateAccount("admin", "", "", true); err != nil { + t.Fatal(err) + } + if err := st.CreateAccount("alice", "", "", false); err != nil { + t.Fatal(err) + } + svc, err := NewService(st, []core.PromptModule{{ID: "task.example", Layer: core.LayerTask, Version: 2}}) + if err != nil { + t.Fatal(err) + } + module := core.PromptModule{ID: "task.example", Layer: core.LayerTask, Body: "Alice"} + if err := svc.SaveAccountChecked("alice", module, false); err != nil { + t.Fatal(err) + } + got, err := st.GetPromptModule("alice", module.ID) + if err != nil || got.Owner != "alice" || got.Source != string(core.SourceUser) || got.BasedOnVersion != 2 { + t.Fatalf("service did not normalize actor module: %+v %v", got, err) + } + if err := svc.SaveAccountChecked("missing", module, false); !errors.Is(err, store.ErrAccountForbidden) { + t.Fatalf("missing actor saved prompt: %v", err) + } + account, err := st.GetAccountByOwner("alice") + if err != nil { + t.Fatal(err) + } + if _, err := st.SetAccountEnabled("admin", account.ID, account.StateVersion, false); err != nil { + t.Fatal(err) + } + if err := svc.DeleteAccountChecked("alice", module.ID, false); !errors.Is(err, store.ErrAccountForbidden) { + t.Fatalf("disabled actor deleted prompt: %v", err) + } + if err := svc.SaveAccountChecked("alice", module, false); !errors.Is(err, store.ErrAccountForbidden) { + t.Fatalf("disabled actor saved prompt: %v", err) + } +} diff --git a/internal/rag/account_index_test.go b/internal/rag/account_index_test.go new file mode 100644 index 0000000..49cda4e --- /dev/null +++ b/internal/rag/account_index_test.go @@ -0,0 +1,83 @@ +package rag + +import ( + "context" + "errors" + "testing" + + "agent-system-backend/internal/store" + core "agent-system-core" +) + +type checkedKnowledgeIndexer interface { + IndexDocumentAccountChecked(ctx context.Context, owner, projectID core.ID, source, title, ref, plainText string, allowImplicitDefault bool) error + IndexChunksAccountChecked(ctx context.Context, owner core.ID, docs []core.KnowledgeDoc, allowImplicitDefault bool) error + PublishChunksAccountChecked(ctx context.Context, owner core.ID, pluginID string, scoped bool, docs []core.KnowledgeDoc, allowImplicitDefault bool) error +} + +func checkedIndexer(t *testing.T, p *Pipeline) checkedKnowledgeIndexer { + t.Helper() + i, ok := any(p).(checkedKnowledgeIndexer) + if !ok { + t.Fatal("Pipeline is missing account-checked indexing APIs") + } + return i +} + +type disableDuringEmbed struct{ st *store.Store } + +func (e disableDuringEmbed) Embed(_ context.Context, texts []string) ([][]float32, error) { + if _, err := e.st.DB().Exec(`UPDATE users SET enabled=FALSE WHERE owner='alice'`); err != nil { + return nil, err + } + vecs := make([][]float32, len(texts)) + for i := range vecs { + vecs[i] = []float32{1, 0} + } + return vecs, nil +} + +func TestCheckedIndexStopsVectorWriteAfterEmbeddingDisablesAccount(t *testing.T) { + p, st := newTestPipeline(t, nil, nil) + i := checkedIndexer(t, p) + if err := st.EnsureUser("alice", ""); err != nil { + t.Fatal(err) + } + p.SetEmbedder(disableDuringEmbed{st: st}) + err := i.IndexChunksAccountChecked(context.Background(), "alice", []core.KnowledgeDoc{ + {ID: "knw-during-embed", Owner: "alice", Ref: "embedded", Source: "manual", Chunk: "A searchable document"}, + }, false) + if !errors.Is(err, store.ErrAccountForbidden) { + t.Fatalf("vector write after disable: got %v, want ErrAccountForbidden", err) + } + var lexical, vectors int + if err := st.DB().QueryRow(`SELECT COUNT(*) FROM knowledge WHERE owner='alice' AND ref='embedded'`).Scan(&lexical); err != nil { + t.Fatal(err) + } + if err := st.DB().QueryRow(`SELECT COUNT(*) FROM knowledge_vectors WHERE owner='alice' AND ref='embedded'`).Scan(&vectors); err != nil { + t.Fatal(err) + } + if lexical != 1 || vectors != 0 { + t.Fatalf("expected pre-disable lexical commit and no late vector: lexical=%d vectors=%d", lexical, vectors) + } +} + +func TestCheckedIndexDocumentAndPublishUseGuardedKnowledge(t *testing.T) { + p, st := newTestPipeline(t, nil, nil) + i := checkedIndexer(t, p) + if err := st.EnsureUser("alice", ""); err != nil { + t.Fatal(err) + } + if err := i.IndexDocumentAccountChecked(context.Background(), "alice", "p1", "upload", "minutes", "upload-ref", "A document with enough searchable text", false); err != nil { + t.Fatal(err) + } + if err := i.PublishChunksAccountChecked(context.Background(), "alice", "alpha", true, []core.KnowledgeDoc{ + {ID: "knw-published", Owner: "alice", ProjectID: "p1", Ref: "published", Source: "plugin:alpha", Chunk: "A published document"}, + }, false); err != nil { + t.Fatal(err) + } + var n int + if err := st.DB().QueryRow(`SELECT COUNT(*) FROM knowledge WHERE owner='alice'`).Scan(&n); err != nil || n != 2 { + t.Fatalf("guarded index and publish rows=%d, %v", n, err) + } +} diff --git a/internal/rag/hybrid.go b/internal/rag/hybrid.go index e9438d2..f22c3fa 100644 --- a/internal/rag/hybrid.go +++ b/internal/rag/hybrid.go @@ -4,6 +4,7 @@ import ( "context" "crypto/sha256" "encoding/hex" + "errors" "log" "agent-system-backend/internal/settings" @@ -85,6 +86,25 @@ func (p *Pipeline) IndexDocument(owner, projectID core.ID, source, title, ref, p return p.IndexChunks(context.Background(), owner, docs) } +// IndexDocumentAccountChecked is the upload/artifact indexing entry when the +// caller has an authenticated actor. Chunking takes no DB connection; every +// later lexical/vector write has its own final account transaction check. +func (p *Pipeline) IndexDocumentAccountChecked(ctx context.Context, owner, projectID core.ID, source, title, ref, plainText string, allowImplicitDefault bool) error { + if plainText == "" { + return nil + } + doc := core.ParsedDocument{Blocks: []core.DocBlock{{Type: core.BlockPara, Text: plainText}}} + chunks := p.chunker.Chunk(doc) + if len(chunks) == 0 { + return nil + } + docs := make([]core.KnowledgeDoc, len(chunks)) + for i, c := range chunks { + docs[i] = core.KnowledgeDoc{Owner: owner, ProjectID: projectID, Source: source, Title: title, Ref: ref, Chunk: c.Text} + } + return p.IndexChunksAccountChecked(ctx, owner, docs, allowImplicitDefault) +} + // IndexChunks 写入已构造好的知识分块并建向量(EXT-1 KnowledgeWrite embed=true 的路径)。 // AddKnowledge 原地回填 docs[i].ID;embedding 失败降级为纯全文(记日志,不阻断写入)。 func (p *Pipeline) IndexChunks(ctx context.Context, owner core.ID, docs []core.KnowledgeDoc) error { @@ -118,6 +138,74 @@ func (p *Pipeline) IndexChunks(ctx context.Context, owner core.ID, docs []core.K return nil } +// IndexChunksAccountChecked preserves batch atomicity for lexical rows and +// fences each vector write after the external embedding call. Embedding cannot +// share the DB transaction; a disable during that call leaves already +// committed lexical rows but blocks subsequent vector publication. +func (p *Pipeline) IndexChunksAccountChecked(ctx context.Context, owner core.ID, docs []core.KnowledgeDoc, allowImplicitDefault bool) error { + if len(docs) == 0 { + return nil + } + if err := p.st.AddKnowledgeAccountChecked(string(owner), allowImplicitDefault, docs...); err != nil { + return err + } + return p.indexVectorsAccountChecked(ctx, owner, docs, allowImplicitDefault) +} + +// PublishChunksAccountChecked replaces all affected refs in one guarded Store +// transaction, then builds vectors through individually guarded writes. +func (p *Pipeline) PublishChunksAccountChecked(ctx context.Context, owner core.ID, pluginID string, scoped bool, docs []core.KnowledgeDoc, allowImplicitDefault bool) error { + if len(docs) == 0 { + return nil + } + if err := p.st.ReplaceKnowledgeAccountChecked(ctx, string(owner), pluginID, scoped, allowImplicitDefault, docs...); err != nil { + return err + } + return p.indexVectorsAccountChecked(ctx, owner, docs, allowImplicitDefault) +} + +func (p *Pipeline) indexVectorsAccountChecked(ctx context.Context, owner core.ID, docs []core.KnowledgeDoc, allowImplicitDefault bool) error { + if p.embedder == nil { + return nil + } + if err := ctx.Err(); err != nil { + return err + } + account, err := p.st.GetAccountByOwner(string(owner)) + if errors.Is(err, store.ErrNotFound) && allowImplicitDefault && owner == store.OwnerDefault { + err = nil + account.Enabled = true + } + if err != nil { + return err + } + if !account.Enabled { + return store.ErrAccountForbidden + } + texts := make([]string, len(docs)) + for i, d := range docs { + texts[i] = d.Chunk + } + vecs, err := p.embedder.Embed(ctx, texts) + if err != nil { + log.Printf("[rag] embedding 降级为纯全文(ref=%s): %v", firstRef(docs), err) + return nil + } + model := embedderModel(p.embedder) + for i, v := range vecs { + if i >= len(docs) { + break + } + if len(v) == 0 { + continue + } + if err := p.st.UpsertKnowledgeVectorExtAccountChecked(ctx, string(owner), string(docs[i].ID), docs[i].Ref, model, contentHash(docs[i].Chunk), v, allowImplicitDefault); err != nil { + return err + } + } + return nil +} + // embedderModel 模型身份(可选接口:EmbeddingProvider 契约未含,实现侧 // 声明;未声明=空=legacy 无身份行,向后兼容)。 func embedderModel(e core.EmbeddingProvider) string { diff --git a/internal/runmgr/admission.go b/internal/runmgr/admission.go index a261845..08c989a 100644 --- a/internal/runmgr/admission.go +++ b/internal/runmgr/admission.go @@ -70,16 +70,23 @@ func (g *admissionGate) QueueFull() bool { } // acquire 阻塞获取执行许可(ctx 取消则放弃排队)。预算关闭时零开销直通。 -func (g *admissionGate) acquire(ctx context.Context, st *store.Store, runID, owner string) error { +func (g *admissionGate) acquire(ctx context.Context, st *store.Store, runID, owner string, accountGuard ...bool) error { if g == nil { return nil } budget := g.budget() if budget.Off() { - return nil + return ctx.Err() } for { - if err := st.TryAcquireExecutionSlot(runID, owner, budget); err == nil { + var err error + if len(accountGuard) > 0 && accountGuard[0] { + allowImplicit := len(accountGuard) > 1 && accountGuard[1] + err = st.TryAcquireExecutionSlotAccountChecked(owner, runID, budget, allowImplicit) + } else { + err = st.TryAcquireExecutionSlot(runID, owner, budget) + } + if err == nil { return nil } else if !errors.Is(err, store.ErrBudgetExceeded) { return err // 存储故障:不放行(fail-closed,宁可拒绝执行) diff --git a/internal/runmgr/commands.go b/internal/runmgr/commands.go index c520651..ecd8cbc 100644 --- a/internal/runmgr/commands.go +++ b/internal/runmgr/commands.go @@ -170,7 +170,7 @@ func (m *Manager) applyHITLCommand(c ownedCommand) { _, _, err := m.ResolveHITL(p.Owner, p.CardID, resp) if err != nil { // 过期/未知 gate:明确销项(resolved + 注记),发出可见结论。 - _ = m.st.ResolveHITLCard(p.Owner, p.CardID, core.HITLResolvedPayload{ + _ = m.resolveHITLCardForEffectiveAccount(p.Owner, p.CardID, core.HITLResolvedPayload{ CardID: core.ID(p.CardID), Kind: "expired", Answer: "跨实例审批迟到:gate 已过期或执行已移交", }) log.Printf("[runmgr] hitl %s (card %s) rejected: %v", c.CommandID, p.CardID, err) diff --git a/internal/runmgr/events.go b/internal/runmgr/events.go index 971e0c2..05d37a8 100644 --- a/internal/runmgr/events.go +++ b/internal/runmgr/events.go @@ -20,10 +20,12 @@ import ( // 会话内事件天然单线程(一个会话至多一个在途 run,子代理在父执行栈内同步运行), // 少数跨 goroutine 路径(HITL resolve)与冲盘互斥由 flushing 锁保证不交叉。 type eventBuffer struct { - store *store.Store - bus *bus.Bus - interval time.Duration // 定时冲盘窗口 - maxBuf int // 单会话缓冲条数上限(触发立即冲盘) + store *store.Store + bus *bus.Bus + accountGuardEnabled bool + allowImplicitLocal bool + interval time.Duration // 定时冲盘窗口 + maxBuf int // 单会话缓冲条数上限(触发立即冲盘) // onPersisted 落盘+本地发布后的回调(PG 档 NOTIFY 跨实例扇出;nil = 无)。 onPersisted func(conversationID string, evs []core.StreamEvent) @@ -116,6 +118,41 @@ func (eb *eventBuffer) emit(conversationID, runID string, typ core.StreamEventTy return 0, nil } +// emitAccountChecked flushes any earlier deltas and the new structural event +// in one final account-checked Store transaction. A disabled owner loses its +// buffered deltas instead of persisting them after disable. +func (eb *eventBuffer) emitAccountChecked(actor, conversationID, runID string, typ core.StreamEventType, payload any, allowImplicitDefault bool) error { + e := core.StreamEvent{ConversationID: conversationID, RunID: runID, + TS: time.Now().UTC().Format(time.RFC3339Nano), V: core.ProtocolVersion, Type: typ} + if projectID, ok := core.ProjectFromChannel(core.ID(conversationID)); ok { + e.ProjectID = projectID + } + if payload != nil { + raw, err := json.Marshal(payload) + if err != nil { + return err + } + e.Payload = raw + } + eb.flushing.Lock() + defer eb.flushing.Unlock() + eb.mu.Lock() + batch := append([]core.StreamEvent(nil), eb.buf[conversationID]...) + delete(eb.buf, conversationID) + eb.mu.Unlock() + batch = append(batch, e) + if _, err := eb.store.AppendEventsAccountChecked(actor, conversationID, batch, allowImplicitDefault); err != nil { + return err + } + for _, event := range batch { + eb.bus.Publish(event) + } + if eb.onPersisted != nil { + eb.onPersisted(conversationID, batch) + } + return nil +} + // emitProjectShared persists an explicit internal publication and its proof // before fan-out. A client-visible payload cannot opt itself into this path. func (eb *eventBuffer) emitProjectShared(owner, producer, channel, runID string, typ core.StreamEventType, payload any) error { @@ -132,7 +169,12 @@ func (eb *eventBuffer) emitProjectShared(owner, producer, channel, runID string, eb.flushing.Lock() defer eb.flushing.Unlock() events := []core.StreamEvent{event} - if _, err := eb.store.AppendProjectSharedEvents(owner, producer, channel, events); err != nil { + if eb.accountGuardEnabled { + _, err = eb.store.AppendProjectSharedEventsAccountChecked(owner, producer, channel, events, eb.allowImplicitLocal) + } else { + _, err = eb.store.AppendProjectSharedEvents(owner, producer, channel, events) + } + if err != nil { return err } for _, event := range events { @@ -148,7 +190,13 @@ func (eb *eventBuffer) emitProjectShared(owner, producer, channel, runID string, func (eb *eventBuffer) append(conversationID string, evs []core.StreamEvent) error { eb.flushing.Lock() defer eb.flushing.Unlock() - if _, err := eb.store.AppendEvents(conversationID, evs); err != nil { + var err error + if eb.accountGuardEnabled { + _, err = eb.store.AppendEventsForEffectiveChannel(conversationID, evs, eb.allowImplicitLocal) + } else { + _, err = eb.store.AppendEvents(conversationID, evs) + } + if err != nil { return err } for i := range evs { diff --git a/internal/runmgr/file_effect_account_check_test.go b/internal/runmgr/file_effect_account_check_test.go new file mode 100644 index 0000000..064e815 --- /dev/null +++ b/internal/runmgr/file_effect_account_check_test.go @@ -0,0 +1,173 @@ +package runmgr + +import ( + "context" + "errors" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "agent-system-backend/internal/store" + core "agent-system-core" +) + +type checkedFileEffects interface { + stageInputsChecked(ctx context.Context, conv core.Conversation, eff core.EffectiveSettings, files []WorkspaceFile, check func(context.Context) error) ([]string, error) + persistInputTextsChecked(ctx context.Context, owner, conversation string, attachments []core.ParsedDocument, check func(context.Context) error) error + writeSubagentRequestChecked(ctx context.Context, run core.Run, agentID, name, task, hint string, detach bool, check func(context.Context) error) error + writeSubagentTranscriptChecked(ctx context.Context, run core.Run, parts []core.Part, status string, check func(context.Context) error) error +} + +func checkedFiles(t *testing.T, m *Manager) checkedFileEffects { + t.Helper() + c, ok := any(m).(checkedFileEffects) + if !ok { + t.Fatal("Manager is missing checked workspace/transcript file effects") + } + return c +} + +func rejectAt(n int, sentinel error) (func(context.Context) error, *int) { + calls := 0 + return func(ctx context.Context) error { + calls++ + if calls == n { + return sentinel + } + return nil + }, &calls +} + +func TestCheckedStageInputsStopsBeforeEachFileEffect(t *testing.T) { + root := t.TempDir() + m := &Manager{workspaceRoot: root} + c := checkedFiles(t, m) + conv := core.Conversation{ID: "conv", Owner: "alice"} + denied := errors.New("account disabled") + check, calls := rejectAt(3, denied) + paths, err := c.stageInputsChecked(context.Background(), conv, core.EffectiveSettings{}, []WorkspaceFile{ + {Name: "first.txt", Content: []byte("first")}, + {Name: "second.txt", Content: []byte("second")}, + }, check) + if !errors.Is(err, denied) || *calls != 3 || len(paths) != 1 || paths[0] != "inputs/first.txt" { + t.Fatalf("late file guard: paths=%v calls=%d err=%v", paths, *calls, err) + } + dir := filepath.Join(root, "alice", "conv", "inputs") + if body, err := os.ReadFile(filepath.Join(dir, "first.txt")); err != nil || string(body) != "first" { + t.Fatalf("first file should predate rejection: %q, %v", body, err) + } + if _, err := os.Stat(filepath.Join(dir, "second.txt")); !os.IsNotExist(err) { + t.Fatalf("late second file was written: %v", err) + } + cancelled, cancel := context.WithCancel(context.Background()) + cancel() + if _, err := c.stageInputsChecked(cancelled, core.Conversation{ID: "other", Owner: "alice"}, core.EffectiveSettings{}, nil, nil); !errors.Is(err, context.Canceled) { + t.Fatalf("cancelled stage: %v", err) + } + if _, err := os.Stat(filepath.Join(root, "alice", "other")); !os.IsNotExist(err) { + t.Fatalf("cancelled stage created directory: %v", err) + } +} + +func TestCheckedPersistTextRejectsBeforeWrite(t *testing.T) { + root := t.TempDir() + m := &Manager{workspaceRoot: root} + c := checkedFiles(t, m) + dir := filepath.Join(root, "alice", "conv", "inputs") + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + docs := []core.ParsedDocument{{ + Meta: core.DocMeta{Filename: "minutes.docx"}, + Blocks: []core.DocBlock{{Type: core.BlockPara, Text: strings.Repeat("substantial meeting content ", 20)}}, + }} + denied := errors.New("account disabled") + check, calls := rejectAt(1, denied) + if err := c.persistInputTextsChecked(context.Background(), "alice", "conv", docs, check); !errors.Is(err, denied) || *calls != 1 { + t.Fatalf("persist guard: calls=%d err=%v", *calls, err) + } + if _, err := os.Stat(filepath.Join(dir, "minutes.md")); !os.IsNotExist(err) { + t.Fatalf("persist wrote after rejection: %v", err) + } + if err := c.persistInputTextsChecked(context.Background(), "alice", "conv", docs, nil); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(filepath.Join(dir, "minutes.md")); err != nil { + t.Fatalf("permitted persist missing: %v", err) + } +} + +func TestCheckedSubagentTranscriptStopsBeforeAppendAndDoneMarker(t *testing.T) { + root := t.TempDir() + m := &Manager{workspaceRoot: root} + c := checkedFiles(t, m) + run := core.Run{ID: "child", Owner: "alice", ConversationID: "conv"} + denied := errors.New("account disabled") + check, calls := rejectAt(2, denied) + if err := c.writeSubagentRequestChecked(context.Background(), run, "agent", "name", "task", "", true, check); !errors.Is(err, denied) || *calls != 2 { + t.Fatalf("request guard: calls=%d err=%v", *calls, err) + } + dir := filepath.Join(root, "alice", "conv", "subagents", "child") + if _, err := os.Stat(filepath.Join(dir, "request.json")); !os.IsNotExist(err) { + t.Fatalf("request written after rejection: %v", err) + } + if err := c.writeSubagentRequestChecked(context.Background(), run, "agent", "name", "task", "", true, nil); err != nil { + t.Fatal(err) + } + check, calls = rejectAt(4, denied) // mkdir, open, first append, second append + parts := []core.Part{{Type: core.PartText, Text: "first"}, {Type: core.PartText, Text: "second"}} + if err := c.writeSubagentTranscriptChecked(context.Background(), run, parts, "completed", check); !errors.Is(err, denied) || *calls != 4 { + t.Fatalf("append guard: calls=%d err=%v", *calls, err) + } + body, err := os.ReadFile(filepath.Join(dir, "transcript.jsonl")) + if err != nil || !strings.Contains(string(body), "first") || strings.Contains(string(body), "second") { + t.Fatalf("transcript after rejection: %q, %v", body, err) + } + if _, err := os.Stat(filepath.Join(dir, "done.json")); !os.IsNotExist(err) { + t.Fatalf("done marker written after rejection: %v", err) + } +} + +func TestCheckedResumePassesOwnerAndStopsBeforeRedispatch(t *testing.T) { + c, st := newSpawnEnv(t, &gateRunner{}) + c.mgr.runner = noopRunner{} + c.mgr.SetWorkspaceRoot(t.TempDir()) + parent := c.run + if err := st.CreateRun(&parent); err != nil { + t.Fatal(err) + } + child := core.Run{ + ID: store.NewID("run"), ParentRunID: parent.ID, ConversationID: parent.ConversationID, + ProjectID: parent.ProjectID, Owner: parent.Owner, Kind: core.RunSubagent, + ProfileID: parent.ProfileID, Status: core.RunRunning, CreatedAt: time.Now().UTC(), + } + if err := st.CreateRun(&child); err != nil { + t.Fatal(err) + } + c.mgr.writeSubagentRequest(child, "agent.sub.explore", "name", "task", "", true) + if _, err := st.SealInterrupted(child.ID); err != nil { + t.Fatal(err) + } + before, err := st.SubagentRunIDs(parent.ConversationID) + if err != nil { + t.Fatal(err) + } + denied := errors.New("account disabled") + var checkedOwner string + c.mgr.ResumeInterruptedChildrenChecked(context.Background(), func(_ context.Context, owner string) error { + checkedOwner = owner + return denied + }) + if checkedOwner != child.Owner { + t.Fatalf("resume checked owner %q, want %q", checkedOwner, child.Owner) + } + after, err := st.SubagentRunIDs(parent.ConversationID) + if err != nil || len(after) != len(before) { + t.Fatalf("denied resume spawned child: before=%d after=%d err=%v", len(before), len(after), err) + } + if _, err := os.Stat(filepath.Join(c.mgr.subagentDir(child.Owner, child.ConversationID, child.ID), "resumed.json")); !os.IsNotExist(err) { + t.Fatalf("denied resume wrote marker: %v", err) + } +} diff --git a/internal/runmgr/genui_auto.go b/internal/runmgr/genui_auto.go index 12e1755..86cd7b8 100644 --- a/internal/runmgr/genui_auto.go +++ b/internal/runmgr/genui_auto.go @@ -28,7 +28,7 @@ func (m *Manager) maybeGenUIAuto(owner, convID, reason string) { ConversationID: convID, Role: core.RoleSystem, Parts: []core.Part{{Type: core.PartText, Text: text}}, } - _ = m.st.AppendMessage(&msg) + _ = m.appendMessageForEffectiveAccount(owner, &msg) } func planStageChanged(prev, next core.PlanSnapshot) bool { diff --git a/internal/runmgr/inputs.go b/internal/runmgr/inputs.go index 6c74c09..0178632 100644 --- a/internal/runmgr/inputs.go +++ b/internal/runmgr/inputs.go @@ -1,6 +1,7 @@ package runmgr import ( + "context" "fmt" "os" "path/filepath" @@ -19,10 +20,20 @@ type WorkspaceFile struct { // StageWorkspaceInputs 把原件和可选默认模板写到 ///inputs/。 // root 为空时不写(工作区未配置)。返回工作区相对路径。同一批里重名的文件加序号。 func StageWorkspaceInputs(root, owner, conversation string, files []WorkspaceFile, templateName string, template []byte) ([]string, error) { + return StageWorkspaceInputsChecked(context.Background(), root, owner, conversation, files, templateName, template, nil) +} + +// StageWorkspaceInputsChecked rechecks the actor immediately before each +// directory/file mutation. The caller supplies its effective-account check; +// check and filesystem syscall cannot be atomic with an account DB commit. +func StageWorkspaceInputsChecked(ctx context.Context, root, owner, conversation string, files []WorkspaceFile, templateName string, template []byte, check func(context.Context) error) ([]string, error) { if strings.TrimSpace(root) == "" { return nil, nil } dir := filepath.Join(root, owner, conversation, "inputs") + if err := checkFileEffect(ctx, check); err != nil { + return nil, err + } if err := os.MkdirAll(dir, 0o755); err != nil { return nil, err } @@ -34,6 +45,9 @@ func StageWorkspaceInputs(root, owner, conversation string, files []WorkspaceFil continue } name = uniqueInputName(name, used) + if err := checkFileEffect(ctx, check); err != nil { + return rels, err + } if err := os.WriteFile(filepath.Join(dir, name), file.Content, 0o644); err != nil { return rels, err } @@ -47,6 +61,9 @@ func StageWorkspaceInputs(root, owner, conversation string, files []WorkspaceFil ext = filepath.Ext(base) } name := "_default-template" + ext + if err := checkFileEffect(ctx, check); err != nil { + return rels, err + } if err := os.WriteFile(filepath.Join(dir, name), template, 0o644); err != nil { return rels, err } @@ -55,13 +72,29 @@ func StageWorkspaceInputs(root, owner, conversation string, files []WorkspaceFil return rels, nil } +func checkFileEffect(ctx context.Context, check func(context.Context) error) error { + if err := ctx.Err(); err != nil { + return err + } + if check != nil { + return check(ctx) + } + return nil +} + // PersistInputTexts 把附件解析出的纯文本写成 inputs/<原名>.md,与原件并列。 // 后续轮次的上下文里原件只剩占位时,模型仍能读到可检索的原文文本—— // 「根据纪要制定方案」不再只能依赖上一轮产物(实测后续轮 0 次读原件)。 // 原本就是文本类(md/txt/csv)的跳过;解析文本过短(<200 字)视为解析失败跳过。 func PersistInputTexts(root, owner, conversation string, attachments []core.ParsedDocument) { + _ = PersistInputTextsChecked(context.Background(), root, owner, conversation, attachments, nil) +} + +// PersistInputTextsChecked propagates a failed final check or file write so a +// guarded caller stops before later extracted files are published. +func PersistInputTextsChecked(ctx context.Context, root, owner, conversation string, attachments []core.ParsedDocument, check func(context.Context) error) error { if strings.TrimSpace(root) == "" { - return + return nil } dir := filepath.Join(root, owner, conversation, "inputs") for _, doc := range attachments { @@ -82,10 +115,14 @@ func PersistInputTexts(root, owner, conversation string, attachments []core.Pars if _, err := os.Stat(out); err == nil { continue // 已有同名解析文本(同一附件重发)不覆盖 } - if err := os.WriteFile(out, []byte(text), 0o644); err == nil { - _ = out + if err := checkFileEffect(ctx, check); err != nil { + return err + } + if err := os.WriteFile(out, []byte(text), 0o644); err != nil { + return err } } + return nil } // ListWorkspaceInputs 列出会话工作区 inputs/ 目录的相对路径(原样返回给环境段)。 @@ -110,6 +147,10 @@ func ListWorkspaceInputs(root, owner, conversation string, staged []string) []st } func (m *Manager) stageInputs(conv core.Conversation, eff core.EffectiveSettings, files []WorkspaceFile) ([]string, error) { + return m.stageInputsChecked(context.Background(), conv, eff, files, nil) +} + +func (m *Manager) stageInputsChecked(ctx context.Context, conv core.Conversation, eff core.EffectiveSettings, files []WorkspaceFile, check func(context.Context) error) ([]string, error) { var templateName string var template []byte if id := strings.TrimSpace(eff.GetString("deliverables.templateArtifactId", "")); id != "" && m.st != nil { @@ -119,7 +160,11 @@ func (m *Manager) stageInputs(conv core.Conversation, eff core.EffectiveSettings } } } - return StageWorkspaceInputs(m.workspaceRoot, conv.Owner, conv.ID, files, templateName, template) + return StageWorkspaceInputsChecked(ctx, m.workspaceRoot, conv.Owner, conv.ID, files, templateName, template, check) +} + +func (m *Manager) persistInputTextsChecked(ctx context.Context, owner, conversation string, attachments []core.ParsedDocument, check func(context.Context) error) error { + return PersistInputTextsChecked(ctx, m.workspaceRoot, owner, conversation, attachments, check) } func safeInputName(name string) (string, bool) { diff --git a/internal/runmgr/memory.go b/internal/runmgr/memory.go index c9bf432..9debfe6 100644 --- a/internal/runmgr/memory.go +++ b/internal/runmgr/memory.go @@ -12,9 +12,11 @@ import ( // storeMemory 把 agent-native 的记忆端口接到 memory_entries 表(N3 WP5)。 // owner/项目态由适配器持有(端口本身不传租户态)。 type storeMemory struct { - st *store.Store - owner string - projectID string + st *store.Store + owner string + projectID string + checked bool + allowImplicitDefault bool } // NewStoreMemory 构建端口实现(owner 为空时用默认租户)。 @@ -25,6 +27,10 @@ func NewStoreMemory(st *store.Store, owner, projectID string) memory.Store { return &storeMemory{st: st, owner: owner, projectID: projectID} } +func newAccountCheckedStoreMemory(st *store.Store, owner, projectID string, allowImplicitDefault bool) memory.Store { + return &storeMemory{st: st, owner: owner, projectID: projectID, checked: true, allowImplicitDefault: allowImplicitDefault} +} + // Recall 按 scope 过滤(空 = 不限,保持迁移前 Memories() 的全量语义), // 过期与空内容条目的剔除与文案渲染在 memory.Block(同一形态)。 func (m *storeMemory) Recall(ctx context.Context, q memory.RecallQuery) ([]memory.Entry, error) { @@ -59,6 +65,9 @@ func (c *runCtx) StoreMemory() memory.Store { if c.mgr == nil || c.mgr.st == nil { return nil } + if c.mgr.accountEffectiveAuthorization != nil { + return newAccountCheckedStoreMemory(c.mgr.st, c.run.Owner, c.run.ProjectID, c.mgr.accountAllowImplicitLocal) + } return NewStoreMemory(c.mgr.st, c.run.Owner, c.run.ProjectID) } @@ -73,10 +82,16 @@ func (m *storeMemory) Append(ctx context.Context, e memory.Entry) error { if created.IsZero() { created = now } - _, err := m.st.PutMemory(m.owner, core.MemoryEntry{ + entry := core.MemoryEntry{ ID: e.ID, Scope: e.Scope, ProjectID: core.ID(projectID), Type: e.Type, Content: e.Content, Source: e.Source, Confidence: e.Confidence, CreatedAt: created, ExpiresAt: e.ExpiresAt, - }) + } + var err error + if m.checked { + _, err = m.st.PutMemoryAccountChecked(m.owner, entry, m.allowImplicitDefault) + } else { + _, err = m.st.PutMemory(m.owner, entry) + } return err } diff --git a/internal/runmgr/rewind.go b/internal/runmgr/rewind.go index 1e1763e..9c85aa6 100644 --- a/internal/runmgr/rewind.go +++ b/internal/runmgr/rewind.go @@ -1,6 +1,7 @@ package runmgr import ( + "context" "fmt" "log" "path/filepath" @@ -80,6 +81,11 @@ func (m *Manager) RewindFiles(owner, convID, messageID string) (checkpoint.Resul if err != nil { return checkpoint.Result{}, err } + if m.accountEffectiveAuthorization != nil { + if err := m.authorizeAccountEffective(context.Background(), owner); err != nil { + return checkpoint.Result{}, err + } + } res, err := cpLog.Rewind(messageID, ordered) if err != nil { return checkpoint.Result{}, err @@ -93,7 +99,12 @@ func (m *Manager) RewindFiles(owner, convID, messageID string) (checkpoint.Resul ConversationID: convID, Role: core.RoleSystem, Parts: []core.Part{{Type: core.PartText, Text: note}}, } - if err := m.st.AppendMessage(&msg); err != nil { + if m.accountEffectiveAuthorization != nil { + err = m.st.AppendMessageWithRequestAccountChecked(owner, m.accountAllowImplicitLocal, &msg, "") + } else { + err = m.st.AppendMessage(&msg) + } + if err != nil { return res, err } return res, nil diff --git a/internal/runmgr/runmgr.go b/internal/runmgr/runmgr.go index 0f72efb..34b4a0e 100644 --- a/internal/runmgr/runmgr.go +++ b/internal/runmgr/runmgr.go @@ -200,6 +200,73 @@ func (m *Manager) EmitCardEvent(conversationID, runID string, typ core.StreamEve return err } +func (m *Manager) EmitCardEventAccountChecked(actor, conversationID, runID string, typ core.StreamEventType, payload any) error { + return m.events.emitAccountChecked(actor, conversationID, runID, typ, payload, m.accountAllowImplicitLocal) +} + +func (m *Manager) appendMessageForEffectiveAccount(actor string, msg *core.Message) error { + if m.accountEffectiveAuthorization != nil { + return m.st.AppendMessageWithRequestAccountChecked(actor, m.accountAllowImplicitLocal, msg, "") + } + return m.st.AppendMessage(msg) +} + +func (m *Manager) saveHITLCardForEffectiveAccount(actor string, card *core.HITLCard) error { + if m.accountEffectiveAuthorization != nil { + return m.st.SaveHITLCardAccountChecked(actor, card, m.accountAllowImplicitLocal) + } + return m.st.SaveHITLCard(actor, card) +} + +func (m *Manager) resolveHITLCardForEffectiveAccount(actor, id string, resolution core.HITLResolvedPayload) error { + if m.accountEffectiveAuthorization != nil { + return m.st.ResolveHITLCardAccountChecked(actor, id, resolution, m.accountAllowImplicitLocal) + } + return m.st.ResolveHITLCard(actor, id, resolution) +} + +func (m *Manager) resolveProjectedCardForEffectiveAccount(actor, ref, actionID, answer string) error { + if m.cards == nil { + return nil + } + if m.accountEffectiveAuthorization != nil { + return m.cards.ResolveByRefAccountChecked(actor, ref, actionID, answer, m.accountAllowImplicitLocal) + } + return m.cards.ResolveByRef(actor, ref, actionID, answer) +} + +func (m *Manager) createCardForEffectiveAccount(actor string, card core.ActivityCard, ref string) (core.ActivityCard, error) { + if m.accountEffectiveAuthorization != nil { + return m.cards.CreateAccountChecked(actor, card, ref, m.accountAllowImplicitLocal) + } + return m.cards.Create(actor, card, ref) +} + +func (m *Manager) setCardStatusForEffectiveAccount(actor, id string, status core.CardStatus, tone, note string) (core.ActivityCard, error) { + if m.accountEffectiveAuthorization != nil { + return m.cards.SetStatusAccountChecked(actor, id, status, tone, note, m.accountAllowImplicitLocal) + } + return m.cards.SetStatus(actor, id, status, tone, note) +} + +func (m *Manager) updateCardForEffectiveAccount(actor string, card core.ActivityCard) error { + if m.accountEffectiveAuthorization != nil { + _, version, err := m.st.GetCardVersion(actor, card.ID) + if err != nil { + return err + } + return m.cards.UpdateVersionedAccountChecked(actor, card, version, m.accountAllowImplicitLocal) + } + return m.cards.Update(actor, card) +} + +func (m *Manager) appendCardForEffectiveAccount(actor, id, text string) error { + if m.accountEffectiveAuthorization != nil { + return m.cards.AppendAccountChecked(actor, id, text, m.accountAllowImplicitLocal) + } + return m.cards.Append(actor, id, text) +} + // SetRetriever 注入混合检索(rag 管线;nil = store 全文检索)。 func (m *Manager) SetRetriever(r core.Retriever) { m.retriever = r } @@ -225,8 +292,10 @@ func (m *Manager) SetProjectWorkAuthorization(fn func(context.Context, string, c // independent of the optional domain project provider. func (m *Manager) SetAccountEffectiveAuthorization(fn func(context.Context, string) (bool, error), allowImplicitLocal ...bool) { m.accountEffectiveAuthorization = fn + m.events.accountGuardEnabled = fn != nil if len(allowImplicitLocal) > 0 { m.accountAllowImplicitLocal = allowImplicitLocal[0] + m.events.allowImplicitLocal = allowImplicitLocal[0] } } @@ -494,11 +563,20 @@ func conversationMode(m *Manager, owner, id string) string { func (c *runCtx) PermissionMode() string { return c.permissionMode } func (c *runCtx) ExitPlanMode() error { - c.permissionMode = "" if c.mgr == nil || c.mgr.st == nil { + c.permissionMode = "" return nil } - return c.mgr.st.SetPermissionMode(c.run.Owner, c.run.ConversationID, "default") + var err error + if c.mgr.accountEffectiveAuthorization != nil { + err = c.mgr.st.SetPermissionModeAccountChecked(c.run.Owner, c.run.ConversationID, "default", c.mgr.accountAllowImplicitLocal) + } else { + err = c.mgr.st.SetPermissionMode(c.run.Owner, c.run.ConversationID, "default") + } + if err == nil { + c.permissionMode = "" + } + return err } func (m *Manager) rememberSessionAllow(conv, tool string) { @@ -701,10 +779,12 @@ func (c *runCtx) AskHITL(card core.HITLCard) (answer core.HITLResponse, err erro if card.CreatedAt == "" { card.CreatedAt = time.Now().UTC().Format(time.RFC3339) } - if err := c.mgr.st.SaveHITLCard(c.run.Owner, &card); err != nil { + if err := c.mgr.saveHITLCardForEffectiveAccount(c.run.Owner, &card); err != nil { + return core.HITLResponse{}, err + } + if err := c.mgr.setRunStatus(&c.run, core.RunWaitingHITL, c.run.Phase); err != nil { return core.HITLResponse{}, err } - c.mgr.setRunStatus(&c.run, core.RunWaitingHITL, c.run.Phase) if _, err := c.mgr.emit(c.run.ConversationID, c.run.ID, core.EvHITLRequest, card); err != nil { return core.HITLResponse{}, err } @@ -724,7 +804,7 @@ func (c *runCtx) AskHITL(card core.HITLCard) (answer core.HITLResponse, err erro if card.AllowFreeInput { proj.Body = append(proj.Body, core.UINode{Type: core.UIMarkdown, Markdown: "_可在输入框自由输入回应。_"}) } - if _, err := c.mgr.cards.Create(c.run.Owner, proj, "hitl:"+card.ID); err != nil { + if _, err := c.mgr.createCardForEffectiveAccount(c.run.Owner, proj, "hitl:"+card.ID); err != nil { return core.HITLResponse{}, err } } @@ -736,7 +816,7 @@ func (c *runCtx) AskHITL(card core.HITLCard) (answer core.HITLResponse, err erro // 排队,取消语境下重取失败由取消路径收口)。 c.mgr.adm.release(c.mgr.st, string(c.run.ID)) defer func() { - if acquireErr := c.mgr.adm.acquire(c.ctx, c.mgr.st, string(c.run.ID), c.run.Owner); acquireErr != nil { + if acquireErr := c.mgr.adm.acquire(c.ctx, c.mgr.st, string(c.run.ID), c.run.Owner, c.mgr.accountEffectiveAuthorization != nil, c.mgr.accountAllowImplicitLocal); acquireErr != nil { log.Printf("[runmgr] admission reacquire after HITL %s: %v", c.run.ID, acquireErr) if err == nil { err = acquireErr @@ -797,7 +877,13 @@ func (c *runCtx) UpdatePlan(p core.PlanSnapshot) error { } prev, _ := c.mgr.st.GetPlan(c.run.ConversationID) p.PlanID = c.run.ConversationID - if err := c.mgr.st.SavePlan(c.run.ConversationID, p); err != nil { + var saveErr error + if c.mgr.accountEffectiveAuthorization != nil { + saveErr = c.mgr.st.SavePlanAccountChecked(c.run.Owner, c.run.ConversationID, p, c.mgr.accountAllowImplicitLocal) + } else { + saveErr = c.mgr.st.SavePlan(c.run.ConversationID, p) + } + if err := saveErr; err != nil { return err } _, err := c.mgr.emit(c.run.ConversationID, c.run.ID, core.EvPlanUpdate, core.PlanUpdatePayload{Plan: p}) @@ -831,7 +917,13 @@ func (c *runCtx) UpdateTodos(l core.TodoList) error { } prev, _ := c.mgr.st.GetTodos(c.run.ConversationID) l.ConversationID = c.run.ConversationID - if err := c.mgr.st.SaveTodos(c.run.ConversationID, l); err != nil { + var saveErr error + if c.mgr.accountEffectiveAuthorization != nil { + saveErr = c.mgr.st.SaveTodosAccountChecked(c.run.Owner, c.run.ConversationID, l, c.mgr.accountAllowImplicitLocal) + } else { + saveErr = c.mgr.st.SaveTodos(c.run.ConversationID, l) + } + if err := saveErr; err != nil { return err } _, err := c.mgr.emit(c.run.ConversationID, c.run.ID, core.EvTodoUpdate, core.TodoUpdatePayload{List: l}) @@ -858,7 +950,7 @@ func (c *runCtx) CreateCard(card core.ActivityCard) (core.ActivityCard, error) { } card.ProjectID = c.run.ProjectID card.SourceRunID = c.run.ID - return c.mgr.cards.Create(c.run.Owner, card, "") + return c.mgr.createCardForEffectiveAccount(c.run.Owner, card, "") } func (c *runCtx) SetCardStatus(id string, status core.CardStatus, tone, note string) error { @@ -868,7 +960,7 @@ func (c *runCtx) SetCardStatus(id string, status core.CardStatus, tone, note str if c.mgr.cards == nil { return nil } - _, err := c.mgr.cards.SetStatus(c.run.Owner, id, status, tone, note) + _, err := c.mgr.setCardStatusForEffectiveAccount(c.run.Owner, id, status, tone, note) return err } @@ -913,7 +1005,7 @@ func (c *runCtx) AppendStreamCard(text string) error { if id == "" { return errors.New("card: no active stream card (card_begin first)") } - return c.mgr.cards.Append(c.run.Owner, string(id), text) + return c.mgr.appendCardForEffectiveAccount(c.run.Owner, string(id), text) } // CommitStreamCard:card_commit 工具入口——活跃流式卡置 done、登记锚点回填 @@ -929,7 +1021,7 @@ func (c *runCtx) CommitStreamCard() (core.ActivityCard, error) { if id == "" { return core.ActivityCard{}, errors.New("card: no active stream card (card_begin first)") } - card, err := c.mgr.cards.SetStatus(c.run.Owner, string(id), core.CardDone, "ok", "") + card, err := c.mgr.setCardStatusForEffectiveAccount(c.run.Owner, string(id), core.CardDone, "ok", "") if err != nil { return card, err } @@ -970,7 +1062,13 @@ func (c *runCtx) WriteArtifact(name, kind, mime string, content []byte) (core.Ar return core.Artifact{}, err } art := core.Artifact{ProjectID: c.run.ProjectID, Owner: c.run.Owner, Name: name, Kind: kind, MIME: mime, SourceRun: c.run.ID} - if err := c.mgr.st.CreateArtifact(&art, content); err != nil { + var writeErr error + if c.mgr.accountEffectiveAuthorization != nil { + writeErr = c.mgr.st.CreateArtifactAccountChecked(c.run.Owner, &art, content, c.mgr.accountAllowImplicitLocal) + } else { + writeErr = c.mgr.st.CreateArtifact(&art, content) + } + if err := writeErr; err != nil { return art, err } if _, err := c.mgr.emit(c.run.ConversationID, c.run.ID, core.EvArtifactCreated, core.ArtifactPayload{Artifact: art}); err != nil { @@ -983,7 +1081,21 @@ func (c *runCtx) WriteArtifact(name, kind, mime string, content []byte) (core.Ar // 抽不到则跳过,避免把 ZIP 字节当文本索引。 if kind == "document" && len(content) < 200000 { if text := indexTextOf(name, mime, content); text != "" { - if c.mgr.indexer != nil { + if c.mgr.accountEffectiveAuthorization != nil { + if c.mgr.indexer != nil { + checked, ok := c.mgr.indexer.(interface { + IndexDocumentAccountChecked(context.Context, core.ID, core.ID, string, string, string, string, bool) error + }) + if !ok { + return art, fmt.Errorf("document indexer does not support account fencing") + } + if err := checked.IndexDocumentAccountChecked(c.ctx, c.run.Owner, c.run.ProjectID, "artifact", name, art.ID, text, c.mgr.accountAllowImplicitLocal); err != nil { + return art, err + } + } else if err := c.mgr.st.AddKnowledgeAccountChecked(string(c.run.Owner), c.mgr.accountAllowImplicitLocal, core.KnowledgeDoc{Owner: c.run.Owner, ProjectID: c.run.ProjectID, Source: "artifact", Title: name, Ref: art.ID, Chunk: text}); err != nil { + return art, err + } + } else if c.mgr.indexer != nil { _ = c.mgr.indexer.IndexDocument(c.run.Owner, c.run.ProjectID, "artifact", name, art.ID, text) } else { _ = c.mgr.st.AddKnowledge(core.KnowledgeDoc{Owner: c.run.Owner, ProjectID: c.run.ProjectID, Source: "artifact", Title: name, Ref: art.ID, Chunk: text}) @@ -1193,7 +1305,10 @@ func (t *toolCtx) WriteArtifact(name, kind, mime string, content []byte) (core.A } return art, err } -func (t *toolCtx) Context() context.Context { return t.rc.Context() } +func (t *toolCtx) Context() context.Context { return t.rc.Context() } +func (t *toolCtx) CheckExternalEffect() error { + return t.rc.mgr.authorizeRunWork(t.rc.ctx, t.rc.run.Owner, t.rc.run.ProjectID) +} func (t *toolCtx) OwnerID() string { return t.rc.run.Owner } func (t *toolCtx) WorkspaceRoot() string { return t.rc.mgr.workspaceRoot } func (t *toolCtx) SettingBool(key string, def bool) bool { return t.rc.eff.GetBool(key, def) } @@ -1365,14 +1480,24 @@ func workflowScriptBodies(reg *workflow.Registry, def core.WorkflowDef) map[stri } // ClaimConversation 抢占会话槽位。忙则返回 ErrConversationBusy,此时调用方不应写消息。 -func (m *Manager) ClaimConversation(conversationID string) error { +func (m *Manager) ClaimConversation(conversationID string, actor ...string) error { if err := m.claimConversation(conversationID); err != nil { return err } // 执行所有权(U5/KTD4):PG 档在内存槽位之上叠加 DB 租约——跨实例的 // 同会话争抢由库裁决;接管递增 epoch 供结算围栏。失败时回退内存槽位。 if m.st.Dialect() == "postgres" { - epoch, err := m.st.TryClaimExecution(conversationID, m.InstanceID(), "", 15*60) + var epoch int64 + var err error + if m.accountEffectiveAuthorization != nil { + if len(actor) == 0 || actor[0] == "" { + m.releaseConversation(conversationID) + return store.ErrAccountForbidden + } + epoch, err = m.st.TryClaimExecutionAccountChecked(actor[0], conversationID, m.InstanceID(), "", 15*60, m.accountAllowImplicitLocal) + } else { + epoch, err = m.st.TryClaimExecution(conversationID, m.InstanceID(), "", 15*60) + } if err != nil { m.releaseConversation(conversationID) if errors.Is(err, store.ErrExecutionBusy) { @@ -1451,7 +1576,13 @@ func (m *Manager) startClaimedRun(conv core.Conversation, userMessage core.Messa ID: store.NewID("run"), ConversationID: conv.ID, ProjectID: conv.ProjectID, Owner: conv.Owner, Kind: core.RunConversation, ProfileID: prof.ID, Status: core.RunPending, Phase: core.PhaseThinking, } - if err := m.st.CreateRun(&run); err != nil { + var createErr error + if m.accountEffectiveAuthorization != nil { + createErr = m.st.CreateRunAccountChecked(&run, m.accountAllowImplicitLocal) + } else { + createErr = m.st.CreateRun(&run) + } + if err := createErr; err != nil { m.releaseConversation(conv.ID) return run, err } @@ -1462,8 +1593,25 @@ func (m *Manager) startClaimedRun(conv core.Conversation, userMessage core.Messa m.mu.Unlock() runner := selectConversationRunner(m, eff, launch) - inputPaths, _ := m.stageInputs(conv, eff, inputs) - PersistInputTexts(m.workspaceRoot, conv.Owner, conv.ID, attachments) + var checkInputEffect func(context.Context) error + if m.accountEffectiveAuthorization != nil { + checkInputEffect = func(checkCtx context.Context) error { + return m.authorizeAccountEffective(checkCtx, conv.Owner) + } + } + inputPaths, inputErr := m.stageInputsChecked(ctx, conv, eff, inputs, checkInputEffect) + if inputErr == nil { + inputErr = m.persistInputTextsChecked(ctx, conv.Owner, conv.ID, attachments, checkInputEffect) + } + if inputErr != nil { + cancel() + m.mu.Lock() + delete(m.active, run.ID) + m.mu.Unlock() + m.releaseConversation(conv.ID) + m.setRunStatus(&run, core.RunFailed, core.PhaseDone) + return run, inputErr + } inputPaths = ListWorkspaceInputs(m.workspaceRoot, conv.Owner, conv.ID, inputPaths) rc := &runCtx{mgr: m, run: run, prof: prof, eff: eff, ctx: ctx, h: h, userMessage: userMessage, attachments: attachments, inputPaths: inputPaths, launch: launch, files: filestate.New(), permissionMode: conversationMode(m, run.Owner, run.ConversationID)} // 材料整理(附件即保证):无文字/大材料发送瞬间预启动,说明注入第 0 回合; @@ -1474,7 +1622,7 @@ func (m *Manager) startClaimedRun(conv core.Conversation, userMessage core.Messa go func() { // 执行许可(U3,R8):排队有界;获取失败(队列满/取消/存储故障)以 // failed 收口——不执行任何模型调用,副作用无从产生。 - if err := m.adm.acquire(rc.ctx, m.st, run.ID, run.Owner); err != nil { + if err := m.adm.acquire(rc.ctx, m.st, run.ID, run.Owner, m.accountEffectiveAuthorization != nil, m.accountAllowImplicitLocal); err != nil { log.Printf("[runmgr] admission denied run %s: %v", run.ID, err) m.setRunStatus(&run, core.RunFailed, core.PhaseThinking) if uErr, ok := err.(error); ok { @@ -1521,7 +1669,10 @@ func (m *Manager) execute(rc *runCtx, runner runtime.Runner) { m.finishRun(rc, core.RunFailed, core.Usage{}, &core.ErrorPart{Code: "run.account_inactive", Message: err.Error()}) return } - m.setRunStatus(&rc.run, core.RunRunning, core.PhaseThinking) + if err := m.setRunStatus(&rc.run, core.RunRunning, core.PhaseThinking); err != nil { + m.finishRun(rc, core.RunFailed, core.Usage{}, &core.ErrorPart{Code: "run.account_inactive", Message: err.Error()}) + return + } m.emit(rc.run.ConversationID, rc.run.ID, core.EvRunPhase, core.RunPhasePayload{Phase: core.PhaseThinking}) execErr := func() (err error) { @@ -1587,7 +1738,7 @@ func (m *Manager) finishRun(rc *runCtx, status core.RunStatus, usage core.Usage, var msg *core.Message if len(parts) > 0 { msg = &core.Message{ConversationID: rc.run.ConversationID, RunID: rc.run.ID, Role: core.RoleAssistant, Parts: parts} - if group, index, ok := m.st.TakeRegenPending(rc.run.ConversationID); ok { + if group, index, ok := m.st.PeekRegenPending(rc.run.ConversationID); ok { msg.VariantGroup = group msg.VariantIndex = index } @@ -1618,7 +1769,7 @@ func (m *Manager) finishRun(rc *runCtx, status core.RunStatus, usage core.Usage, for _, id := range cardIDs { if card, err := m.cards.Get(rc.run.Owner, string(id)); err == nil { card.AnchorMessageID = msg.ID - _ = m.cards.Update(rc.run.Owner, card) + _ = m.updateCardForEffectiveAccount(rc.run.Owner, card) } } } @@ -1635,7 +1786,7 @@ func (m *Manager) finishRun(rc *runCtx, status core.RunStatus, usage core.Usage, if status != core.RunCompleted { st = core.CardFailed } - if card, err := m.cards.SetStatus(rc.run.Owner, string(streamID), st, "warn", "未正常收尾"); err == nil { + if card, err := m.setCardStatusForEffectiveAccount(rc.run.Owner, string(streamID), st, "warn", "未正常收尾"); err == nil { m.mu.Lock() rc.h.submittedCards = append(rc.h.submittedCards, card.ID) m.mu.Unlock() @@ -1686,10 +1837,13 @@ func (m *Manager) failSettle(rc *runCtx, cause error) { } // setRunStatus 更新并广播 phase(状态不单独出事件,phase 事件携带)。 -func (m *Manager) setRunStatus(r *core.Run, status core.RunStatus, phase core.RunPhase) { +func (m *Manager) setRunStatus(r *core.Run, status core.RunStatus, phase core.RunPhase) error { r.Status = status r.Phase = phase - m.st.UpdateRun(r) + if m.accountEffectiveAuthorization != nil && status != core.RunFailed && status != core.RunCancelled && status != core.RunInterrupted { + return m.st.UpdateRunAccountChecked(string(r.Owner), r, m.accountAllowImplicitLocal) + } + return m.st.UpdateRun(r) } // emit 经批处理器持久化事件并广播;返回 seq(缓冲中的增量返回 0)。 @@ -1843,11 +1997,9 @@ func (m *Manager) ResolveHITL(owner, cardID string, resp core.HITLResponse) (piv return "", false, err } _ = m.CancelRun(run.ID) - m.st.ResolveHITLCard(owner, cardID, core.HITLResolvedPayload{CardID: core.ID(cardID), Kind: "cancelled", Answer: answer}) + m.resolveHITLCardForEffectiveAccount(owner, cardID, core.HITLResolvedPayload{CardID: core.ID(cardID), Kind: "cancelled", Answer: answer}) m.emit(run.ConversationID, run.ID, core.EvHITLResolved, core.HITLResolvedPayload{CardID: core.ID(cardID), Kind: "cancelled", Answer: answer}) - if m.cards != nil { - _ = m.cards.ResolveByRef(owner, "hitl:"+cardID, "", answer) - } + _ = m.resolveProjectedCardForEffectiveAccount(owner, "hitl:"+cardID, "", answer) _ = pivotReason return trimmed, true, nil } @@ -1856,14 +2008,12 @@ func (m *Manager) ResolveHITL(owner, cardID string, resp core.HITLResponse) (piv return "", false, err } resolution := core.HITLResolvedPayload{CardID: core.ID(cardID), Answer: answer, Kind: kind} - if err := m.st.ResolveHITLCard(owner, cardID, resolution); err != nil { + if err := m.resolveHITLCardForEffectiveAccount(owner, cardID, resolution); err != nil { return "", false, err } m.emit(run.ConversationID, run.ID, core.EvHITLResolved, resolution) // 投影卡同步销项(双现;幂等)。 - if m.cards != nil { - _ = m.cards.ResolveByRef(owner, "hitl:"+cardID, resp.ChoiceID, answer) - } + _ = m.resolveProjectedCardForEffectiveAccount(owner, "hitl:"+cardID, resp.ChoiceID, answer) m.mu.Lock() h, ok := m.active[run.ID] @@ -1879,7 +2029,13 @@ func (m *Manager) ResolveHITL(owner, cardID string, resp core.HITLResponse) (piv // 恢复运行状态。 if run.Status == core.RunWaitingHITL { run.Status = core.RunRunning - m.st.UpdateRun(&run) + if m.accountEffectiveAuthorization != nil { + if err := m.st.UpdateRunAccountChecked(string(run.Owner), &run, m.accountAllowImplicitLocal); err != nil { + return "", false, err + } + } else if err := m.st.UpdateRun(&run); err != nil { + return "", false, err + } } return "", false, nil } @@ -1899,11 +2055,11 @@ func (m *Manager) spawnSubagent(parentCtx context.Context, parent core.Run, agen // 取消语境下重取失败仅记录,run 由取消路径收口)。 m.adm.release(m.st, string(parent.ID)) defer func() { - if err := m.adm.acquire(parentCtx, m.st, string(parent.ID), parent.Owner); err != nil { + if err := m.adm.acquire(parentCtx, m.st, string(parent.ID), parent.Owner, m.accountEffectiveAuthorization != nil, m.accountAllowImplicitLocal); err != nil { log.Printf("[runmgr] admission reacquire after child %s: %v", parent.ID, err) } }() - if err := m.adm.acquire(parentCtx, m.st, rc.run.ID, rc.run.Owner); err != nil { + if err := m.adm.acquire(parentCtx, m.st, rc.run.ID, rc.run.Owner, m.accountEffectiveAuthorization != nil, m.accountAllowImplicitLocal); err != nil { m.setRunStatus(&rc.run, core.RunFailed, rc.run.Phase) m.st.UpdateRun(&core.Run{ID: rc.run.ID, Status: core.RunFailed, Error: &core.ErrorPart{Code: "run.admission_denied", Message: err.Error()}}) m.emit(rc.run.ConversationID, rc.run.ID, core.EvRunFailed, core.RunFailedPayload{Code: "run.admission_denied", Message: "子代理执行许可排队已满或已取消"}) @@ -1936,11 +2092,26 @@ func (m *Manager) startChildOpts(parentCtx context.Context, parent core.Run, o b ProjectID: parent.ProjectID, Owner: parent.Owner, Kind: core.RunSubagent, ProfileID: parent.ProfileID, Status: core.RunPending, Phase: core.PhaseThinking, AgentName: name, } - if err := m.st.CreateRun(&child); err != nil { + var createErr error + if m.accountEffectiveAuthorization != nil { + createErr = m.st.CreateRunAccountChecked(&child, m.accountAllowImplicitLocal) + } else { + createErr = m.st.CreateRun(&child) + } + if err := createErr; err != nil { return nil, nil, err } // 独立转录(N2 WP5):派生请求落盘,续跑的重建凭据。 - m.writeSubagentRequest(child, agentID, name, task, hint, detach) + var checkChildEffect func(context.Context) error + if m.accountEffectiveAuthorization != nil { + checkChildEffect = func(checkCtx context.Context) error { + return m.authorizeAccountEffective(checkCtx, child.Owner) + } + } + if err := m.writeSubagentRequestChecked(parentCtx, child, agentID, name, task, hint, detach, checkChildEffect); err != nil { + m.setRunStatus(&child, core.RunFailed, core.PhaseDone) + return nil, nil, err + } m.emit(parent.ConversationID, parent.ID, core.EvAgentSpawn, core.AgentSpawnPayload{AgentRun: child.ID, Name: name, Task: task}) base := parentCtx @@ -1956,7 +2127,13 @@ func (m *Manager) startChildOpts(parentCtx context.Context, parent core.Run, o b prof, _ := m.profiles.Get(parent.Owner, parent.ProfileID) eff := m.settings.Effective(parent.Owner, parent.ProjectID) rc := &runCtx{mgr: m, run: child, prof: prof, eff: eff, ctx: ctx, h: h, subTask: task, subHint: hint, subAgent: agentID, subModel: o.model, files: filestate.New(), permissionMode: conversationMode(m, child.Owner, child.ConversationID)} - m.setRunStatus(&rc.run, core.RunRunning, core.PhaseActing) + if err := m.setRunStatus(&rc.run, core.RunRunning, core.PhaseActing); err != nil { + cancel() + m.mu.Lock() + delete(m.active, child.ID) + m.mu.Unlock() + return nil, nil, err + } runner := m.subRunner if eff.GetBool("runtime.mock", false) || runner == nil { @@ -2022,7 +2199,18 @@ func (m *Manager) finishChild(rc *runCtx, runner runtime.Runner, emitResult bool child.Status = status child.Phase = core.PhaseDone child.Usage = &usage - m.st.UpdateRun(&child) + if m.accountEffectiveAuthorization != nil && status == core.RunCompleted { + if err := m.st.UpdateRunAccountChecked(string(child.Owner), &child, m.accountAllowImplicitLocal); err != nil { + accountDenied = err + status = core.RunFailed + child.Status = status + payload.Status = status + payload.Summary = err.Error() + _ = m.st.UpdateRun(&child) + } + } else { + _ = m.st.UpdateRun(&child) + } if emitResult && accountDenied == nil { m.emit(child.ConversationID, child.ID, core.EvAgentResult, payload) } @@ -2034,10 +2222,18 @@ func (m *Manager) finishChild(rc *runCtx, runner runtime.Runner, emitResult bool subParts = nil } // 独立转录落定(含终态 done 标记,供续跑幂等判定)。 - m.writeSubagentTranscript(child, subParts, string(status)) + transcriptCheck := func(checkCtx context.Context) error { + return m.authorizeAccountEffective(checkCtx, child.Owner) + } + if m.accountEffectiveAuthorization == nil { + transcriptCheck = nil + } + if err := m.writeSubagentTranscriptChecked(rc.ctx, child, subParts, string(status), transcriptCheck); err != nil { + log.Printf("[runmgr] subagent transcript %s: %v", child.ID, err) + } fullText := partsText(subParts) if len(subParts) > 0 { - _ = m.st.AppendMessage(&core.Message{ConversationID: child.ConversationID, RunID: child.ID, Role: core.RoleAssistant, Parts: subParts}) + _ = m.appendMessageForEffectiveAccount(child.Owner, &core.Message{ConversationID: child.ConversationID, RunID: child.ID, Role: core.RoleAssistant, Parts: subParts}) } if fullText != "" { payload.Text = capSubagentResult(rc, fullText) @@ -2150,7 +2346,7 @@ func (m *Manager) startBackgroundSlot(parent core.Run, o bgSpawnOpts, preCard co defer m.materialKeyRelease(string(parent.ConversationID), o.taskKey) } // 后台子代理同样计入执行预算(U3:子代理预算计入全局)。 - if err := m.adm.acquire(context.Background(), m.st, rc.run.ID, parent.Owner); err != nil { + if err := m.adm.acquire(rc.ctx, m.st, rc.run.ID, parent.Owner, m.accountEffectiveAuthorization != nil, m.accountAllowImplicitLocal); err != nil { log.Printf("[runmgr] admission denied background %s: %v", rc.run.ID, err) m.setRunStatus(&rc.run, core.RunFailed, rc.run.Phase) m.st.UpdateRun(&core.Run{ID: rc.run.ID, Status: core.RunFailed, Error: &core.ErrorPart{Code: "run.admission_denied", Message: err.Error()}}) @@ -2205,7 +2401,7 @@ func (m *Manager) createBackgroundCard(parent core.Run, childID core.ID, name, t focus := "正在执行:" + title extra := append(append([][2]string(nil), kv...), [2]string{"通道", "慢通道 · 后台执行"}) body := (core.TaskSummaryView{Focus: focus}).RunningBody(statusLabel, extra) - card, err := m.cards.Create(parent.Owner, core.ActivityCard{ + card, err := m.createCardForEffectiveAccount(parent.Owner, core.ActivityCard{ Kind: core.CardKindProgress, Title: title, Status: core.CardActive, Tone: "info", ConversationID: parent.ConversationID, ProjectID: parent.ProjectID, SourceRunID: childID, Body: body, @@ -2233,7 +2429,7 @@ func (m *Manager) promoteQueuedCard(owner string, card core.ActivityCard, childI } } } - if err := m.cards.Update(owner, card); err != nil { + if err := m.updateCardForEffectiveAccount(owner, card); err != nil { log.Printf("[runmgr] queued card promote: %v", err) } } @@ -2262,10 +2458,10 @@ func (m *Manager) finishBackgroundCard(owner string, card core.ActivityCard, pay } extra := append(append([][2]string(nil), kv...), [2]string{"通道", "慢通道 · 后台执行"}) card.Body = (core.TaskSummaryView{OutcomeLine: summary}).FinishedBody(statusLabel, extra) - if uerr := m.cards.Update(owner, card); uerr != nil { + if uerr := m.updateCardForEffectiveAccount(owner, card); uerr != nil { log.Printf("[runmgr] background card update: %v", uerr) } - if _, serr := m.cards.SetStatus(owner, card.ID, status, tone, ""); serr != nil { + if _, serr := m.setCardStatusForEffectiveAccount(owner, card.ID, status, tone, ""); serr != nil { log.Printf("[runmgr] background card status: %v", serr) } } @@ -2285,14 +2481,22 @@ func (m *Manager) decBackground(convID string) { } func (m *Manager) enqueueNotice(convID string, n bgNotice) { - // 先落库(重启重投凭据),再进内存热队列;落库失败不丢内存路径。 + // 先落库(重启重投凭据),再进内存热队列;账号拒绝时不投递迟到结果。 if n.id == "" { n.id = store.NewID("ntc") } - if err := m.st.AppendNotice(store.Notice{ + notice := store.Notice{ ID: n.id, ConversationID: convID, Owner: n.owner, Name: n.name, Payload: n.payload, - }); err != nil { - log.Printf("[runmgr] notice persist %s: %v", convID, err) + } + var persistErr error + if m.accountEffectiveAuthorization != nil { + persistErr = m.st.AppendNoticeAccountChecked(notice, m.accountAllowImplicitLocal) + } else { + persistErr = m.st.AppendNotice(notice) + } + if persistErr != nil { + log.Printf("[runmgr] notice persist %s: %v", convID, persistErr) + return } m.noticeMu.Lock() defer m.noticeMu.Unlock() @@ -2445,7 +2649,7 @@ func (m *Manager) startNoticeRun(convID string, items []bgNotice) error { m.releaseConversation(convID) return err } - if err := m.st.AppendMessage(&msg); err != nil { + if err := m.appendMessageForEffectiveAccount(owner, &msg); err != nil { m.releaseConversation(convID) return err } @@ -2487,7 +2691,13 @@ func (m *Manager) InterruptRunning() { parts = append(parts, h.assistant...) } m.mu.Unlock() - m.writeSubagentTranscript(run, parts, "interrupted") + var check func(context.Context) error + if m.accountEffectiveAuthorization != nil { + check = func(ctx context.Context) error { return m.authorizeAccountEffective(ctx, run.Owner) } + } + if err := m.writeSubagentTranscriptChecked(context.Background(), run, parts, "interrupted", check); err != nil { + log.Printf("[runmgr] interrupted transcript %s: %v", run.ID, err) + } } m.sealRunInterrupted(id) } @@ -2532,7 +2742,7 @@ func (m *Manager) closePendingHITL(r core.Run) { } for _, c := range cards { res := core.HITLResolvedPayload{CardID: c.ID, Kind: "cancelled"} - if err := m.st.ResolveHITLCard(r.Owner, string(c.ID), res); err != nil { + if err := m.resolveHITLCardForEffectiveAccount(r.Owner, string(c.ID), res); err != nil { continue } m.emit(r.ConversationID, r.ID, core.EvHITLResolved, res) diff --git a/internal/runmgr/subagent_transcript.go b/internal/runmgr/subagent_transcript.go index d83a262..21dcff8 100644 --- a/internal/runmgr/subagent_transcript.go +++ b/internal/runmgr/subagent_transcript.go @@ -1,6 +1,7 @@ package runmgr import ( + "context" "encoding/json" "fmt" "log" @@ -39,51 +40,94 @@ type subagentRequest struct { // writeSubagentRequest 在派生时落 request.json 与空转录(失败只记日志, // 不阻断派生——转录是辅助面)。 func (m *Manager) writeSubagentRequest(run core.Run, agentID, name, task, hint string, detach bool) { + if err := m.writeSubagentRequestChecked(context.Background(), run, agentID, name, task, hint, detach, nil); err != nil { + log.Printf("[runmgr] subagent request %s: %v", run.ID, err) + } +} + +// writeSubagentRequestChecked keeps transcript file effects behind an optional +// live account check. Failures stop later file writes; this auxiliary record +// cannot be committed atomically with the account database. +func (m *Manager) writeSubagentRequestChecked(ctx context.Context, run core.Run, agentID, name, task, hint string, detach bool, check func(context.Context) error) error { dir := m.subagentDir(run.Owner, run.ConversationID, run.ID) if dir == "" { - return + return nil + } + if err := checkFileEffect(ctx, check); err != nil { + return err } if err := os.MkdirAll(dir, 0o755); err != nil { - log.Printf("[runmgr] subagent dir %s: %v", run.ID, err) - return + return err } body, _ := json.MarshalIndent(subagentRequest{ AgentID: agentID, Name: name, Task: task, Hint: hint, Detach: detach, CreatedAt: time.Now().UTC().Format(time.RFC3339), }, "", " ") + if err := checkFileEffect(ctx, check); err != nil { + return err + } if err := os.WriteFile(filepath.Join(dir, "request.json"), body, 0o644); err != nil { - log.Printf("[runmgr] subagent request %s: %v", run.ID, err) + return err } // 转录文件先建(append 语义清晰可见)。 - if _, err := os.OpenFile(filepath.Join(dir, "transcript.jsonl"), os.O_CREATE|os.O_WRONLY, 0o644); err != nil { - log.Printf("[runmgr] subagent transcript %s: %v", run.ID, err) + if err := checkFileEffect(ctx, check); err != nil { + return err } + f, err := os.OpenFile(filepath.Join(dir, "transcript.jsonl"), os.O_CREATE|os.O_WRONLY, 0o644) + if err != nil { + return err + } + return f.Close() } // writeSubagentTranscript 把本次收集到的 parts 追加进转录并落 done 标记。 // status 是终态(completed/failed/cancelled/interrupted)。 func (m *Manager) writeSubagentTranscript(run core.Run, parts []core.Part, status string) { + if err := m.writeSubagentTranscriptChecked(context.Background(), run, parts, status, nil); err != nil { + log.Printf("[runmgr] subagent transcript %s: %v", run.ID, err) + } +} + +func (m *Manager) writeSubagentTranscriptChecked(ctx context.Context, run core.Run, parts []core.Part, status string, check func(context.Context) error) error { dir := m.subagentDir(run.Owner, run.ConversationID, run.ID) if dir == "" { - return + return nil + } + if err := checkFileEffect(ctx, check); err != nil { + return err } if err := os.MkdirAll(dir, 0o755); err != nil { - return + return err + } + if err := checkFileEffect(ctx, check); err != nil { + return err } f, err := os.OpenFile(filepath.Join(dir, "transcript.jsonl"), os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644) if err != nil { - log.Printf("[runmgr] transcript open %s: %v", run.ID, err) - return + return err } enc := json.NewEncoder(f) for i, p := range parts { - _ = enc.Encode(map[string]any{"index": i, "type": string(p.Type), "text": p.Text, "at": time.Now().UTC().Format(time.RFC3339)}) + if err := checkFileEffect(ctx, check); err != nil { + _ = f.Close() + return err + } + if err := enc.Encode(map[string]any{"index": i, "type": string(p.Type), "text": p.Text, "at": time.Now().UTC().Format(time.RFC3339)}); err != nil { + _ = f.Close() + return err + } + } + if err := f.Close(); err != nil { + return err } - _ = f.Close() done, _ := json.Marshal(map[string]string{"status": status, "endedAt": time.Now().UTC().Format(time.RFC3339)}) - if err := os.WriteFile(filepath.Join(dir, "done.json"), done, 0o644); err != nil { - log.Printf("[runmgr] done marker %s: %v", run.ID, err) + if err := checkFileEffect(ctx, check); err != nil { + return err } + if err := os.WriteFile(filepath.Join(dir, "done.json"), done, 0o644); err != nil { + return err + } + return nil } // doneStatus 读 done.json 的 status(无文件或不可读按 "interrupted" 处理—— @@ -122,12 +166,24 @@ func readSubagentRequest(dir string) (*subagentRequest, error) { // 未完成未续跑的,用原请求重新派生(后台),并把前次转录路径写进 hint // 供模型读取后继续(N2 WP5 的「可恢复」)。幂等:done/resumed 标记跳过。 func (m *Manager) ResumeInterruptedChildren() { + m.ResumeInterruptedChildrenChecked(context.Background(), nil) +} + +// ResumeInterruptedChildrenChecked rechecks each run owner before redispatch +// and before writing the resumed marker. A disable between redispatch and the +// marker can still leave a spawned run without this file; DB/file atomicity is +// unavailable, so callers must keep the run admission guard as well. +func (m *Manager) ResumeInterruptedChildrenChecked(ctx context.Context, check func(context.Context, string) error) { runs, err := m.st.ListInterruptedSubagentRuns() if err != nil { log.Printf("[runmgr] resume scan: %v", err) return } for _, run := range runs { + if err := ctx.Err(); err != nil { + log.Printf("[runmgr] resume cancelled: %v", err) + return + } // 恢复隔离(协作存储 U3):未知外部副作用的 run 不自动续跑——hold // 未解除即跳过;判定出错同样跳过(fail-closed:宁可人工核对,不盲重派)。 if held, herr := m.st.HasOpenRecoveryHold(string(run.ID), string(run.ConversationID)); herr != nil || held { @@ -162,6 +218,10 @@ func (m *Manager) ResumeInterruptedChildren() { } hint := req.Hint + "\n(本任务是前次中断后的自动续跑;前次转录在 " + filepath.Join(dir, "transcript.jsonl") + ",可读取后继续。)" + if err := checkOwnerFileEffect(ctx, run.Owner, check); err != nil { + log.Printf("[runmgr] resume skip %s: account check: %v", run.ID, err) + continue + } newID, err := m.spawnBackground(parent, req.AgentID, req.Name, req.Task, hint) if err != nil { log.Printf("[runmgr] resume spawn %s: %v", run.ID, err) @@ -171,6 +231,10 @@ func (m *Manager) ResumeInterruptedChildren() { "newRun": newID, "resumedAt": time.Now().UTC().Format(time.RFC3339), }) + if err := checkOwnerFileEffect(ctx, run.Owner, check); err != nil { + log.Printf("[runmgr] resumed marker skip %s: account check: %v", run.ID, err) + continue + } if err := os.WriteFile(filepath.Join(dir, "resumed.json"), marker, 0o644); err != nil { log.Printf("[runmgr] resumed marker %s: %v", run.ID, err) } @@ -178,6 +242,13 @@ func (m *Manager) ResumeInterruptedChildren() { } } +func checkOwnerFileEffect(ctx context.Context, owner string, check func(context.Context, string) error) error { + if check == nil { + return checkFileEffect(ctx, nil) + } + return checkFileEffect(ctx, func(ctx context.Context) error { return check(ctx, owner) }) +} + func fileExists(path string) bool { _, err := os.Stat(path) return err == nil diff --git a/internal/server/account_contract_test.go b/internal/server/account_contract_test.go index d994af4..e0d11f6 100644 --- a/internal/server/account_contract_test.go +++ b/internal/server/account_contract_test.go @@ -45,9 +45,12 @@ func TestAdminAccountSearchFiltersAndVersionedCursor(t *testing.T) { t.Fatalf("first search page: %d %+v", code, first) } params.Set("cursor", first.NextCursor) + other := New(st, srv.mgr, srv.reg, srv.prof, srv.set, srv.plugins) + other.Auth = srv.Auth + h = other.Handler() code, second := search(params) if code != http.StatusOK || len(second.Accounts) != 1 || second.Accounts[0].UserID != bob.ID || second.NextCursor != "" { - t.Fatalf("second search page: %d %+v", code, second) + t.Fatalf("second search page on another server: %d %+v", code, second) } params.Del("cursor") params.Set("query", "private-needle") diff --git a/internal/server/account_effect_guard.go b/internal/server/account_effect_guard.go new file mode 100644 index 0000000..9d01c66 --- /dev/null +++ b/internal/server/account_effect_guard.go @@ -0,0 +1,8 @@ +package server + +// checkActorBeforeExternalEffect is the last effective-state check before a +// process-local or filesystem effect. Such effects cannot share the account +// database transaction, so callers must also use cancellation where available. +func (s *Server) checkActorBeforeExternalEffect(actor string) error { + return s.st.CheckEffectiveAccountForExternalEffect(actor, s.Auth == nil || !s.Auth.Enabled) +} diff --git a/internal/server/account_search.go b/internal/server/account_search.go index c6ce04c..0139c43 100644 --- a/internal/server/account_search.go +++ b/internal/server/account_search.go @@ -2,7 +2,6 @@ package server import ( "crypto/hmac" - "crypto/rand" "crypto/sha256" "encoding/base64" "encoding/hex" @@ -17,23 +16,18 @@ import ( "agent-system-backend/internal/store" ) -func newAccountSearchSecret() [32]byte { - var key [32]byte - if _, err := rand.Read(key[:]); err != nil { - panic(err) - } - return key -} - type adminSearchCursor struct { - Version string `json:"v"` - Owner string `json:"o"` - UserID string `json:"u"` + KeyVersion int `json:"k"` + Version string `json:"v"` + Owner string `json:"o"` + UserID string `json:"u"` } func (s *Server) signAdminCursor(cursor adminSearchCursor) string { + cursor.KeyVersion = s.accountSearchKey.Version data, _ := json.Marshal(cursor) - mac := hmac.New(sha256.New, s.accountSearchSecret[:]) + mac := hmac.New(sha256.New, s.accountSearchKey.Secret[:]) + _, _ = mac.Write([]byte("iam-admin-search-cursor-v1\x00")) _, _ = mac.Write(data) return base64.RawURLEncoding.EncodeToString(data) + "." + base64.RawURLEncoding.EncodeToString(mac.Sum(nil)) } @@ -52,9 +46,10 @@ func (s *Server) parseAdminCursor(encoded, version string) (adminSearchCursor, b if err != nil { return cursor, false } - mac := hmac.New(sha256.New, s.accountSearchSecret[:]) + mac := hmac.New(sha256.New, s.accountSearchKey.Secret[:]) + _, _ = mac.Write([]byte("iam-admin-search-cursor-v1\x00")) _, _ = mac.Write(data) - if !hmac.Equal(provided, mac.Sum(nil)) || json.Unmarshal(data, &cursor) != nil || cursor.Version != version || cursor.Owner == "" || cursor.UserID == "" { + if !hmac.Equal(provided, mac.Sum(nil)) || json.Unmarshal(data, &cursor) != nil || cursor.KeyVersion != s.accountSearchKey.Version || cursor.Version != version || cursor.Owner == "" || cursor.UserID == "" { return adminSearchCursor{}, false } return cursor, true @@ -83,6 +78,10 @@ func searchAccountField(account store.Account, field string) string { } func (s *Server) searchAdminUsers(w http.ResponseWriter, r *http.Request) { + if s.accountSearchKeyErr != nil || s.accountSearchKey.Version < 1 { + http.Error(w, "account directory signing unavailable", http.StatusServiceUnavailable) + return + } actor, ok := s.requireAdmin(w, r) if !ok { return diff --git a/internal/server/account_write_fence_test.go b/internal/server/account_write_fence_test.go new file mode 100644 index 0000000..bc0ea87 --- /dev/null +++ b/internal/server/account_write_fence_test.go @@ -0,0 +1,208 @@ +package server + +import ( + "io" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + "time" + + "agent-system-backend/internal/store" + core "agent-system-core" +) + +type pausedRequestBody struct { + reader io.Reader + entered chan struct{} + release chan struct{} + once sync.Once +} + +func (b *pausedRequestBody) Read(p []byte) (int, error) { + b.once.Do(func() { close(b.entered) }) + <-b.release + return b.reader.Read(p) +} + +func (*pausedRequestBody) Close() error { return nil } + +func TestProjectCreateCannotCommitAfterAccountDisable(t *testing.T) { + srv, st := newAuthTestServer(t) + h := srv.Handler() + _ = bootAdmin(t, h, st) + hash, err := store.HashPassword("actor-password") + if err != nil { + t.Fatal(err) + } + if err := st.CreateAccount("actor", "Actor", hash, false); err != nil { + t.Fatal(err) + } + if err := st.ClearMustChangePassword("actor"); err != nil { + t.Fatal(err) + } + key, err := st.CreateAPIKey("actor", "request") + if err != nil { + t.Fatal(err) + } + account, err := st.GetAccountByOwner("actor") + if err != nil { + t.Fatal(err) + } + body := &pausedRequestBody{reader: strings.NewReader(`{"name":"late-project"}`), entered: make(chan struct{}), release: make(chan struct{})} + req := httptest.NewRequest(http.MethodPost, "/api/projects", body) + req.Header.Set("Authorization", "Bearer "+key) + rec := httptest.NewRecorder() + done := make(chan struct{}) + go func() { + h.ServeHTTP(rec, req) + close(done) + }() + select { + case <-body.entered: + case <-time.After(3 * time.Second): + t.Fatal("project request never reached its body") + } + if _, err := st.SetAccountEnabled("admin", account.ID, account.StateVersion, false); err != nil { + t.Fatal(err) + } + close(body.release) + select { + case <-done: + case <-time.After(3 * time.Second): + t.Fatal("request did not finish after disable") + } + if rec.Code != http.StatusForbidden { + t.Fatalf("disabled request committed: status=%d body=%s", rec.Code, rec.Body.String()) + } + var n int + if err := st.DB().QueryRow(`SELECT COUNT(*) FROM projects WHERE owner='actor'`).Scan(&n); err != nil || n != 0 { + t.Fatalf("late project row count=%d err=%v", n, err) + } +} + +func TestUserMessageCannotCommitAfterAccountDisable(t *testing.T) { + srv, st := newAuthTestServer(t) + h := srv.Handler() + _ = bootAdmin(t, h, st) + key, err := createLegacyAPIKeyFixture(t, st, "actor", "request") + if err != nil { + t.Fatal(err) + } + account, err := st.GetAccountByOwner("actor") + if err != nil { + t.Fatal(err) + } + project, err := st.CreateProject("actor", "fixture") + if err != nil { + t.Fatal(err) + } + conversation, err := st.CreateConversation("actor", string(project.ID), "fixture", "") + if err != nil { + t.Fatal(err) + } + body := &pausedRequestBody{reader: strings.NewReader(`{"text":"late message"}`), entered: make(chan struct{}), release: make(chan struct{})} + req := httptest.NewRequest(http.MethodPost, "/api/conversations/"+string(conversation.ID)+"/messages", body) + req.Header.Set("Authorization", "Bearer "+key) + rec := httptest.NewRecorder() + done := make(chan struct{}) + go func() { + h.ServeHTTP(rec, req) + close(done) + }() + select { + case <-body.entered: + case <-time.After(3 * time.Second): + t.Fatal("message request never reached its body") + } + if _, err := st.SetAccountEnabled("admin", account.ID, account.StateVersion, false); err != nil { + t.Fatal(err) + } + close(body.release) + select { + case <-done: + case <-time.After(3 * time.Second): + t.Fatal("message request did not finish after disable") + } + if rec.Code != http.StatusForbidden { + t.Fatalf("disabled message request committed: status=%d body=%s", rec.Code, rec.Body.String()) + } + var n int + if err := st.DB().QueryRow(`SELECT COUNT(*) FROM messages WHERE conversation_id=?`, conversation.ID).Scan(&n); err != nil || n != 0 { + t.Fatalf("late message count=%d err=%v", n, err) + } +} + +func TestAdminCannotDownloadOtherOwnersThroughDatabaseBackup(t *testing.T) { + srv, st := newAuthTestServer(t) + h := srv.Handler() + admin := bootAdmin(t, h, st) + rec := do(h, http.MethodPost, "/api/admin/backup", "{}", admin) + if rec.Code != http.StatusForbidden { + t.Fatalf("system admin received all-owner database backup: %d %s", rec.Code, rec.Body.String()) + } +} + +func TestRegenerationCannotChangeMessageAfterAccountDisable(t *testing.T) { + srv, st := newAuthTestServer(t) + h := srv.Handler() + _ = bootAdmin(t, h, st) + key, err := createLegacyAPIKeyFixture(t, st, "actor", "regenerate") + if err != nil { + t.Fatal(err) + } + account, err := st.GetAccountByOwner("actor") + if err != nil { + t.Fatal(err) + } + project, err := st.CreateProject("actor", "fixture") + if err != nil { + t.Fatal(err) + } + conv, err := st.CreateConversation("actor", string(project.ID), "fixture", "") + if err != nil { + t.Fatal(err) + } + user := core.Message{ConversationID: conv.ID, Role: core.RoleUser, Parts: []core.Part{{Type: core.PartText, Text: "question"}}} + target := core.Message{ConversationID: conv.ID, Role: core.RoleAssistant, Parts: []core.Part{{Type: core.PartText, Text: "answer"}}} + if err := st.AppendMessage(&user); err != nil { + t.Fatal(err) + } + if err := st.AppendMessage(&target); err != nil { + t.Fatal(err) + } + body := &pausedRequestBody{reader: strings.NewReader(`{"messageId":"` + target.ID + `"}`), entered: make(chan struct{}), release: make(chan struct{})} + req := httptest.NewRequest(http.MethodPost, "/api/conversations/"+string(conv.ID)+"/regenerate", body) + req.Header.Set("Authorization", "Bearer "+key) + rec := httptest.NewRecorder() + done := make(chan struct{}) + go func() { + h.ServeHTTP(rec, req) + close(done) + }() + select { + case <-body.entered: + case <-time.After(3 * time.Second): + t.Fatal("regeneration request never reached its body") + } + if _, err := st.SetAccountEnabled("admin", account.ID, account.StateVersion, false); err != nil { + t.Fatal(err) + } + close(body.release) + select { + case <-done: + case <-time.After(3 * time.Second): + t.Fatal("regeneration did not finish after disable") + } + if rec.Code != http.StatusForbidden { + t.Fatalf("disabled regeneration status=%d body=%s", rec.Code, rec.Body.String()) + } + got, err := st.MessageByID(target.ID) + if err != nil || got.Excluded || got.VariantGroup != "" { + t.Fatalf("rejected regeneration changed target: %+v %v", got, err) + } + if _, _, ok := st.PeekRegenPending(string(conv.ID)); ok { + t.Fatal("rejected regeneration left marker") + } +} diff --git a/internal/server/accounts.go b/internal/server/accounts.go index ae45be5..1b0b734 100644 --- a/internal/server/accounts.go +++ b/internal/server/accounts.go @@ -496,7 +496,7 @@ func (s *Server) revokeAdminLoginKey(w http.ResponseWriter, r *http.Request) { if !ok { return } - if _, err := s.st.RevokeLoginKeyCascade(r.PathValue("id"), actor, r.PathValue("owner")); err != nil { + if err := s.st.RevokeLoginKeyCascadeAdmin(actor, r.PathValue("owner"), r.PathValue("id")); err != nil { writeStoreErr(w, err) return } @@ -524,7 +524,8 @@ func (s *Server) listRecoveryHolds(w http.ResponseWriter, r *http.Request) { // releaseRecoveryHold 人工解除单条 hold(幂等);解除后该 run 才可能被再次 // 续跑/接管。副作用未核对就解除的风险由操作者承担——与 task-close 同级谨慎。 func (s *Server) releaseRecoveryHold(w http.ResponseWriter, r *http.Request) { - if _, ok := s.requireAdmin(w, r); !ok { + actor, ok := s.requireAdmin(w, r) + if !ok { return } runID := r.PathValue("runId") @@ -532,5 +533,5 @@ func (s *Server) releaseRecoveryHold(w http.ResponseWriter, r *http.Request) { writeStoreErr(w, err) return } - writeOKOr(w, s.st.ReleaseRecoveryHold(runID)) + writeOKOr(w, s.st.ReleaseRecoveryHoldAccountChecked(actor, runID, s.Auth == nil || !s.Auth.Enabled)) } diff --git a/internal/server/adminzone_test.go b/internal/server/adminzone_test.go index 1779ccc..84b611b 100644 --- a/internal/server/adminzone_test.go +++ b/internal/server/adminzone_test.go @@ -46,7 +46,7 @@ func TestListAdminZoneSections(t *testing.T) { }}, } h := srv.Handler() - key, err := st.CreateAPIKey("alice", "e2e") + key, err := createLegacyAPIKeyFixture(t, st, "alice", "e2e") if err != nil { t.Fatal(err) } diff --git a/internal/server/api_key_fixture_test.go b/internal/server/api_key_fixture_test.go new file mode 100644 index 0000000..62e808c --- /dev/null +++ b/internal/server/api_key_fixture_test.go @@ -0,0 +1,17 @@ +package server + +import ( + "testing" + + "agent-system-backend/internal/store" +) + +// createLegacyAPIKeyFixture creates the historical integration identity +// explicitly before exercising the formal, non-creating key issuance path. +func createLegacyAPIKeyFixture(t *testing.T, st *store.Store, owner, name string) (string, error) { + t.Helper() + if err := st.EnsureUser(owner, ""); err != nil { + t.Fatal(err) + } + return st.CreateAPIKey(owner, name) +} diff --git a/internal/server/audit_scope_test.go b/internal/server/audit_scope_test.go index 049532c..16bc85b 100644 --- a/internal/server/audit_scope_test.go +++ b/internal/server/audit_scope_test.go @@ -17,11 +17,11 @@ func TestCommandAuditOwnerScoping(t *testing.T) { srv, st := newAuthTestServer(t) h := srv.Handler() - aliceKey, err := st.CreateAPIKey("alice", "e2e") + aliceKey, err := createLegacyAPIKeyFixture(t, st, "alice", "e2e") if err != nil { t.Fatal(err) } - bobKey, err := st.CreateAPIKey("bob", "e2e") + bobKey, err := createLegacyAPIKeyFixture(t, st, "bob", "e2e") if err != nil { t.Fatal(err) } diff --git a/internal/server/auth_e2e_test.go b/internal/server/auth_e2e_test.go index fa09148..91d2a22 100644 --- a/internal/server/auth_e2e_test.go +++ b/internal/server/auth_e2e_test.go @@ -68,7 +68,7 @@ func TestAuthMiddleware(t *testing.T) { } // 创建密钥。 - key, err := st.CreateAPIKey("alice", "e2e") + key, err := createLegacyAPIKeyFixture(t, st, "alice", "e2e") if err != nil { t.Fatal(err) } @@ -107,8 +107,8 @@ func TestAuthMiddleware(t *testing.T) { func TestTenantIsolationHTTP(t *testing.T) { srv, st := newAuthTestServer(t) h := srv.Handler() - keyA, _ := st.CreateAPIKey("alice", "a") - keyB, _ := st.CreateAPIKey("bob", "b") + keyA, _ := createLegacyAPIKeyFixture(t, st, "alice", "a") + keyB, _ := createLegacyAPIKeyFixture(t, st, "bob", "b") do := func(key, method, path string, body any) int { var buf bytes.Buffer @@ -439,7 +439,7 @@ func TestAdminAccountFlow(t *testing.T) { } // API key 通道兼容:密钥登录照旧(并具备其 owner 身份)。 - key, err := st.CreateAPIKey("admin", "cli") + key, err := createLegacyAPIKeyFixture(t, st, "admin", "cli") if err != nil { t.Fatal(err) } @@ -517,7 +517,7 @@ func TestStaleKeySessionFallback(t *testing.T) { } // 有效 key:authVia=key。 - key, err := st.CreateAPIKey("carol", "cli") + key, err := createLegacyAPIKeyFixture(t, st, "carol", "cli") if err != nil { t.Fatal(err) } @@ -555,11 +555,11 @@ func TestSystemSettingsAdminGate(t *testing.T) { if err := st.CreateAccount("dave", "普通乙", hash, false); err != nil { t.Fatal(err) } - keyBoss, err := st.CreateAPIKey("boss", "cli") + keyBoss, err := createLegacyAPIKeyFixture(t, st, "boss", "cli") if err != nil { t.Fatal(err) } - keyDave, err := st.CreateAPIKey("dave", "cli") + keyDave, err := createLegacyAPIKeyFixture(t, st, "dave", "cli") if err != nil { t.Fatal(err) } diff --git a/internal/server/borrow.go b/internal/server/borrow.go index 1ad8fd1..3ce2754 100644 --- a/internal/server/borrow.go +++ b/internal/server/borrow.go @@ -28,16 +28,20 @@ func (s *Server) createSecret(w http.ResponseWriter, r *http.Request) { http.Error(w, err.Error(), http.StatusBadRequest) return } - sec, err := s.st.AddSecret(s.owner(r), req.Service, req.Label, req.Value) + if strings.TrimSpace(req.Service) == "" || strings.TrimSpace(req.Value) == "" { + http.Error(w, "store: secret service and value are required", http.StatusBadRequest) + return + } + sec, err := s.st.AddSecretAccountChecked(s.owner(r), req.Service, req.Label, req.Value, s.Auth == nil || !s.Auth.Enabled) if err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) + writeStoreErr(w, err) return } writeJSON(w, http.StatusOK, sec) } func (s *Server) activateSecret(w http.ResponseWriter, r *http.Request) { - writeOKOr(w, s.st.ActivateSecret(s.owner(r), r.PathValue("id"))) + writeOKOr(w, s.st.ActivateSecretAccountChecked(s.owner(r), r.PathValue("id"), s.Auth == nil || !s.Auth.Enabled)) } func (s *Server) renameSecret(w http.ResponseWriter, r *http.Request) { @@ -48,11 +52,11 @@ func (s *Server) renameSecret(w http.ResponseWriter, r *http.Request) { http.Error(w, err.Error(), http.StatusBadRequest) return } - writeOKOr(w, s.st.RenameSecret(s.owner(r), r.PathValue("id"), req.Label)) + writeOKOr(w, s.st.RenameSecretAccountChecked(s.owner(r), r.PathValue("id"), req.Label, s.Auth == nil || !s.Auth.Enabled)) } func (s *Server) deleteSecret(w http.ResponseWriter, r *http.Request) { - writeOKOr(w, s.st.DeleteSecret(s.owner(r), r.PathValue("id"))) + writeOKOr(w, s.st.DeleteSecretAccountChecked(s.owner(r), r.PathValue("id"), s.Auth == nil || !s.Auth.Enabled)) } func (s *Server) listContextEntries(w http.ResponseWriter, r *http.Request) { @@ -67,16 +71,20 @@ func (s *Server) saveContextEntry(w http.ResponseWriter, r *http.Request) { http.Error(w, err.Error(), http.StatusBadRequest) return } - saved, err := s.st.SaveContextEntry(s.owner(r), e) + if strings.TrimSpace(e.Content) == "" { + http.Error(w, "store: entry content required", http.StatusBadRequest) + return + } + saved, err := s.st.SaveContextEntryAccountChecked(s.owner(r), e, s.Auth == nil || !s.Auth.Enabled) if err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) + writeStoreErr(w, err) return } writeJSON(w, http.StatusOK, saved) } func (s *Server) deleteContextEntry(w http.ResponseWriter, r *http.Request) { - writeOKOr(w, s.st.DeleteContextEntry(s.owner(r), r.PathValue("id"))) + writeOKOr(w, s.st.DeleteContextEntryAccountChecked(s.owner(r), r.PathValue("id"), s.Auth == nil || !s.Auth.Enabled)) } func (s *Server) listUserPromptCommands(w http.ResponseWriter, r *http.Request) { @@ -90,31 +98,46 @@ func (s *Server) saveUserPromptCommand(w http.ResponseWriter, r *http.Request) { http.Error(w, err.Error(), http.StatusBadRequest) return } - saved, err := s.st.SaveUserCommand(s.owner(r), c) - if err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) + if strings.TrimSpace(c.Template) == "" { + http.Error(w, "store: command template required", http.StatusBadRequest) return } - s.registerUserPrompt(saved) + owner := s.owner(r) + allowImplicitDefault := s.Auth == nil || !s.Auth.Enabled + saved, err := s.st.SaveUserCommandAccountChecked(owner, c, allowImplicitDefault) + if err != nil { + writeStoreErr(w, err) + return + } + if s.Bus != nil { + // The Bus is outside the database transaction. Recheck immediately before + // mutation; a disable between this check and RegisterUser remains possible. + if err := s.st.CheckEffectiveAccountForExternalEffect(owner, allowImplicitDefault); err != nil { + writeStoreErr(w, err) + return + } + s.registerUserPrompt(saved) + } writeJSON(w, http.StatusOK, saved) } func (s *Server) deleteUserPromptCommand(w http.ResponseWriter, r *http.Request) { owner := s.owner(r) id := r.PathValue("id") - list, _ := s.st.ListUserCommands(owner) - var name string - for _, c := range list { - if c.ID == id { - name = c.Name - } - } - if err := s.st.DeleteUserCommand(owner, id); err != nil { + allowImplicitDefault := s.Auth == nil || !s.Auth.Enabled + deleted, err := s.st.DeleteUserCommandAccountChecked(owner, id, allowImplicitDefault) + if err != nil { writeStoreErr(w, err) return } - if s.Bus != nil && name != "" { - s.Bus.UnregisterUser(userCommandID(name), name) + if s.Bus != nil && deleted.Name != "" { + // The account check is the last database action before the Bus mutation; + // the two systems cannot share an atomic commit. + if err := s.st.CheckEffectiveAccountForExternalEffect(owner, allowImplicitDefault); err != nil { + writeStoreErr(w, err) + return + } + s.Bus.UnregisterUser(userCommandID(deleted.Name), deleted.Name) } writeJSON(w, http.StatusOK, map[string]any{"deleted": id}) } @@ -140,7 +163,7 @@ func (s *Server) excludeMessage(w http.ResponseWriter, r *http.Request) { writeStoreErr(w, err) return } - writeOKOr(w, s.st.ExcludeRunGroup(msg.ID, excluded)) + writeOKOr(w, s.st.ExcludeRunGroupAccountChecked(s.owner(r), msg.ID, excluded, s.Auth == nil || !s.Auth.Enabled)) } func (s *Server) selectVariant(w http.ResponseWriter, r *http.Request) { @@ -153,7 +176,7 @@ func (s *Server) selectVariant(w http.ResponseWriter, r *http.Request) { writeStoreErr(w, err) return } - writeOKOr(w, s.st.SelectVariant(msg.ID)) + writeOKOr(w, s.st.SelectVariantAccountChecked(s.owner(r), msg.ID, s.Auth == nil || !s.Auth.Enabled)) } func (s *Server) regenerateMessage(w http.ResponseWriter, r *http.Request) { @@ -187,11 +210,7 @@ func (s *Server) regenerateMessage(w http.ResponseWriter, r *http.Request) { } var user core.Message found := false - maxIndex := target.VariantIndex for _, m := range hist { - if m.VariantGroup != "" && m.VariantGroup == target.VariantGroup && m.VariantIndex > maxIndex { - maxIndex = m.VariantIndex - } if m.ID == target.ID { found = true } @@ -203,19 +222,8 @@ func (s *Server) regenerateMessage(w http.ResponseWriter, r *http.Request) { http.Error(w, "no user turn to regenerate", http.StatusBadRequest) return } - group := target.VariantGroup - if group == "" { - group = "var_" + target.ID - } - if err := s.st.SetMessageFlags(target.ID, true, group, target.VariantIndex); err != nil { - writeStoreErr(w, err) - return - } - if err := s.st.ExcludeRunGroup(target.ID, true); err != nil { - writeStoreErr(w, err) - return - } - if err := s.st.SetRegenPending(convID, group, maxIndex+1); err != nil { + group, index, err := s.st.PrepareRegenerationAccountChecked(owner, convID, target.ID, s.Auth == nil || !s.Auth.Enabled) + if err != nil { writeStoreErr(w, err) return } @@ -224,7 +232,7 @@ func (s *Server) regenerateMessage(w http.ResponseWriter, r *http.Request) { } run, err := s.mgr.StartConversationRun(conv, user, nil, nil) if err != nil { - _, _, _ = s.st.TakeRegenPending(convID) + _ = s.st.ClearRegenPendingAfterFailedStart(owner, convID, group, index, s.Auth == nil || !s.Auth.Enabled) writeStoreErr(w, err) return } @@ -281,6 +289,11 @@ func (s *Server) appendUserPromptCommands(owner string, out []map[string]any) [] return out } for _, c := range list { + if s.Bus != nil { + if err := s.st.CheckEffectiveAccountForExternalEffect(owner, s.Auth == nil || !s.Auth.Enabled); err != nil { + return out + } + } s.registerUserPrompt(c) out = append(out, map[string]any{ "id": userCommandID(c.Name), "name": c.Name, "title": c.Title, diff --git a/internal/server/borrow_account_write_test.go b/internal/server/borrow_account_write_test.go new file mode 100644 index 0000000..0e874f3 --- /dev/null +++ b/internal/server/borrow_account_write_test.go @@ -0,0 +1,158 @@ +package server + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "agent-system-backend/internal/store" +) + +func TestBorrowWritesCannotCommitAfterAccountDisable(t *testing.T) { + for _, tc := range []struct { + name, path, body, table string + }{ + {"secret", "/api/secrets", `{"service":"late","value":"hidden"}`, "secret_keys"}, + {"context", "/api/context-entries", `{"content":"late"}`, "context_entries"}, + {"command", "/api/prompt-commands", `{"name":"late","template":"late"}`, "user_commands"}, + } { + t.Run(tc.name, func(t *testing.T) { + srv, st := newAuthTestServer(t) + h := srv.Handler() + _ = bootAdmin(t, h, st) + key, err := createLegacyAPIKeyFixture(t, st, "actor", "borrow") + if err != nil { + t.Fatal(err) + } + account, err := st.GetAccountByOwner("actor") + if err != nil { + t.Fatal(err) + } + body := &pausedRequestBody{reader: strings.NewReader(tc.body), entered: make(chan struct{}), release: make(chan struct{})} + req := httptest.NewRequest(http.MethodPost, tc.path, body) + req.Header.Set("Authorization", "Bearer "+key) + rec := httptest.NewRecorder() + done := make(chan struct{}) + go func() { + h.ServeHTTP(rec, req) + close(done) + }() + select { + case <-body.entered: + case <-time.After(3 * time.Second): + t.Fatal("request did not reach its body") + } + if _, err := st.SetAccountEnabled("admin", account.ID, account.StateVersion, false); err != nil { + t.Fatal(err) + } + close(body.release) + select { + case <-done: + case <-time.After(3 * time.Second): + t.Fatal("request did not finish") + } + if rec.Code != http.StatusForbidden { + t.Fatalf("late write status=%d body=%s", rec.Code, rec.Body.String()) + } + var count int + if err := st.DB().QueryRow("SELECT COUNT(*) FROM " + tc.table + " WHERE owner='actor'").Scan(&count); err != nil || count != 0 { + t.Fatalf("late rows=%d err=%v", count, err) + } + }) + } +} + +func TestBorrowImplicitLocalCannotWriteWhenExplicitlyDisabled(t *testing.T) { + srv, st := newTestServer(t) + if err := st.CreateAccount("admin", "", "", true); err != nil { + t.Fatal(err) + } + secret, err := st.AddSecret(store.OwnerDefault, "service", "original", "hidden") + if err != nil { + t.Fatal(err) + } + entry, err := st.SaveContextEntry(store.OwnerDefault, store.ContextEntry{Content: "original"}) + if err != nil { + t.Fatal(err) + } + command, err := st.SaveUserCommand(store.OwnerDefault, store.UserCommand{Name: "original", Template: "original"}) + if err != nil { + t.Fatal(err) + } + if err := st.EnsureUser(store.OwnerDefault, ""); err != nil { + t.Fatal(err) + } + account, err := st.GetAccountByOwner(store.OwnerDefault) + if err != nil { + t.Fatal(err) + } + if _, err := st.SetAccountEnabled("admin", account.ID, account.StateVersion, false); err != nil { + t.Fatal(err) + } + for _, tc := range []struct{ method, path, body string }{ + {http.MethodPost, "/api/secrets", `{"service":"late","value":"hidden"}`}, + {http.MethodPost, "/api/secrets/" + secret.ID + "/activate", ""}, + {http.MethodPatch, "/api/secrets/" + secret.ID, `{"label":"late"}`}, + {http.MethodDelete, "/api/secrets/" + secret.ID, ""}, + {http.MethodPost, "/api/context-entries", `{"content":"late"}`}, + {http.MethodDelete, "/api/context-entries/" + entry.ID, ""}, + {http.MethodPost, "/api/prompt-commands", `{"name":"late","template":"late"}`}, + {http.MethodDelete, "/api/prompt-commands/" + command.ID, ""}, + } { + rec := httptest.NewRecorder() + srv.Handler().ServeHTTP(rec, httptest.NewRequest(tc.method, tc.path, strings.NewReader(tc.body))) + if rec.Code != http.StatusForbidden { + t.Fatalf("disabled local account wrote %s: %d %s", tc.path, rec.Code, rec.Body.String()) + } + } + if value, ok := st.ActiveSecret(store.OwnerDefault, "service"); !ok || value != "hidden" { + t.Fatalf("rejected secret mutations changed active value: %q %v", value, ok) + } + entries, err := st.ListContextEntries(store.OwnerDefault, "", "") + if err != nil || len(entries) != 1 || entries[0].Content != "original" { + t.Fatalf("rejected context mutations changed rows: %+v %v", entries, err) + } + commands, err := st.ListUserCommands(store.OwnerDefault) + if err != nil || len(commands) != 1 || commands[0].Template != "original" { + t.Fatalf("rejected command mutations changed rows: %+v %v", commands, err) + } + srv.appendUserPromptCommands(store.OwnerDefault, nil) + for _, registered := range srv.Bus.List() { + if registered.Spec.ID == "user.original" { + t.Fatal("disabled account registered a command during listing") + } + } +} + +func TestBorrowPromptCommandBusFollowsCommittedWrite(t *testing.T) { + srv, st := newTestServer(t) + h := srv.Handler() + rec := httptest.NewRecorder() + h.ServeHTTP(rec, httptest.NewRequest(http.MethodPost, "/api/prompt-commands", strings.NewReader(`{"name":"local","template":"hello"}`))) + if rec.Code != http.StatusOK { + t.Fatalf("save command: %d %s", rec.Code, rec.Body.String()) + } + commands, err := st.ListUserCommands(store.OwnerDefault) + if err != nil || len(commands) != 1 { + t.Fatalf("saved command missing: %+v %v", commands, err) + } + registered := false + for _, command := range srv.Bus.List() { + registered = registered || command.Spec.ID == "user.local" + } + if !registered { + t.Fatal("committed command was not registered in the Bus") + } + rec = httptest.NewRecorder() + h.ServeHTTP(rec, httptest.NewRequest(http.MethodDelete, "/api/prompt-commands/"+commands[0].ID, nil)) + if rec.Code != http.StatusOK { + t.Fatalf("delete command: %d %s", rec.Code, rec.Body.String()) + } + for _, command := range srv.Bus.List() { + if command.Spec.ID == "user.local" { + t.Fatal("deleted command remained registered in the Bus") + } + } +} diff --git a/internal/server/compiled_list_test.go b/internal/server/compiled_list_test.go index 93d756d..36ea7d6 100644 --- a/internal/server/compiled_list_test.go +++ b/internal/server/compiled_list_test.go @@ -75,7 +75,7 @@ func TestListPluginsMergesCompiled(t *testing.T) { {ID: "generative-ui", Commands: []string{"generative-ui.present"}}, } h := srv.Handler() - key, err := st.CreateAPIKey("alice", "e2e") + key, err := createLegacyAPIKeyFixture(t, st, "alice", "e2e") if err != nil { t.Fatal(err) } diff --git a/internal/server/e2e_test.go b/internal/server/e2e_test.go index 44a21c8..5e6a86f 100644 --- a/internal/server/e2e_test.go +++ b/internal/server/e2e_test.go @@ -6,6 +6,7 @@ import ( "bytes" "context" "encoding/json" + "errors" "fmt" agentcommand "agent-command" @@ -59,6 +60,16 @@ func newTestServer(t *testing.T) (*Server, *store.Store) { profSvc := profiles.New(st) profSvc.EnsureDefault() mgr := runmgr.New(st, b, reg, profSvc, setSvc, &runtime.LLMRunner{}, &runtime.SubLLMRunner{}, &runtime.MockRunner{}, &runtime.MockSubagent{}) + mgr.SetAccountEffectiveAuthorization(func(_ context.Context, owner string) (bool, error) { + account, err := st.GetAccountByOwner(owner) + if errors.Is(err, store.ErrNotFound) && owner == store.OwnerDefault { + return true, nil + } + if err != nil { + return false, err + } + return account.Enabled, nil + }, true) pluginDir := filepath.Join(dir, "plugins") os.MkdirAll(pluginDir, 0o755) ph := plugins.New(st, reg, setSvc, pluginDir) @@ -66,6 +77,7 @@ func newTestServer(t *testing.T) (*Server, *store.Store) { // 生产同款装配(app.go):卡片事件经 runmgr 事件缓冲持久化/发布——项目流 //(project: 伪键,D50)依赖它;此前为 no-op emitter,事件不入库。 cardSvc := cardsvc.New(st, mgr.EmitCardEvent) + cardSvc.SetAccountCheckedEmitter(mgr.EmitCardEventAccountChecked) srv.Cards = cardSvc mgr.SetCards(cardSvc) // run 内产卡(摄取/编排/submit_card)经 runmgr,与 app.go 装配一致 srv.Bus = cmdbus.New(st, srv.Cards, nil) diff --git a/internal/server/ext9_upload_switch_test.go b/internal/server/ext9_upload_switch_test.go index e4247ca..21e2ba5 100644 --- a/internal/server/ext9_upload_switch_test.go +++ b/internal/server/ext9_upload_switch_test.go @@ -15,7 +15,7 @@ import ( func TestUploadPreIndexSwitch(t *testing.T) { srv, st := newAuthTestServer(t) h := srv.Handler() - alice, err := st.CreateAPIKey("alice", "e2e") + alice, err := createLegacyAPIKeyFixture(t, st, "alice", "e2e") if err != nil { t.Fatal(err) } diff --git a/internal/server/platform_api.go b/internal/server/platform_api.go index bd7d9e9..1c2d21e 100644 --- a/internal/server/platform_api.go +++ b/internal/server/platform_api.go @@ -23,9 +23,9 @@ func (s *Server) createBackgroundTask(w http.ResponseWriter, r *http.Request) { http.Error(w, "kind required", http.StatusBadRequest) return } - task, err := s.st.CreateBackgroundTask(s.owner(r), body.Kind, body.Title, body.ConversationID) + task, err := s.st.CreateBackgroundTaskAccountChecked(s.owner(r), body.Kind, body.Title, body.ConversationID, s.Auth == nil || !s.Auth.Enabled) if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) + writeStoreErr(w, err) return } writeJSON(w, http.StatusCreated, task) @@ -59,8 +59,8 @@ func (s *Server) updateBackgroundTask(w http.ResponseWriter, r *http.Request) { http.Error(w, "status required", http.StatusBadRequest) return } - if err := s.st.UpdateBackgroundTask(s.owner(r), r.PathValue("id"), body.Status, body.Progress, body.Error); err != nil { - http.Error(w, err.Error(), http.StatusNotFound) + if err := s.st.UpdateBackgroundTaskAccountChecked(s.owner(r), r.PathValue("id"), body.Status, body.Progress, body.Error, s.Auth == nil || !s.Auth.Enabled); err != nil { + writeStoreErr(w, err) return } s.getBackgroundTask(w, r) @@ -81,9 +81,9 @@ func (s *Server) putMemory(w http.ResponseWriter, r *http.Request) { if e.Source == "" { e.Source = "manual" } - saved, err := s.st.PutMemory(s.owner(r), e) + saved, err := s.st.PutMemoryAccountChecked(s.owner(r), e, s.Auth == nil || !s.Auth.Enabled) if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) + writeStoreErr(w, err) return } writeJSON(w, http.StatusCreated, saved) @@ -119,11 +119,11 @@ func (s *Server) postAudit(w http.ResponseWriter, r *http.Request) { http.Error(w, "action required", http.StatusBadRequest) return } - if err := s.st.AppendAuditEvent(store.AuditEvent{ + if err := s.st.AppendAuditEventAccountChecked(s.owner(r), store.AuditEvent{ Owner: s.owner(r), Actor: s.owner(r), Action: body.Action, Target: body.Target, Evidence: body.Evidence, TraceID: r.Header.Get("X-Trace-Id"), - }); err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) + }, s.Auth == nil || !s.Auth.Enabled); err != nil { + writeStoreErr(w, err) return } w.WriteHeader(http.StatusNoContent) @@ -137,8 +137,8 @@ func (s *Server) setPluginEnabled(w http.ResponseWriter, r *http.Request) { http.Error(w, "bad json", http.StatusBadRequest) return } - if err := s.st.SetPluginEnabled(s.owner(r), r.PathValue("id"), body.Enabled); err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) + if err := s.st.SetPluginEnabledAccountChecked(s.owner(r), r.PathValue("id"), body.Enabled, s.Auth == nil || !s.Auth.Enabled); err != nil { + writeStoreErr(w, err) return } w.WriteHeader(http.StatusNoContent) @@ -210,11 +210,11 @@ func (s *Server) submitSurface(w http.ResponseWriter, r *http.Request) { http.Error(w, "pluginId and spec required", http.StatusBadRequest) return } - snap, err := s.Surfaces.Submit(r.Context(), s.owner(r), body.PluginID, surfacesvc.SubmitRequest{ + snap, err := s.Surfaces.SubmitAccountChecked(r.Context(), s.owner(r), body.PluginID, surfacesvc.SubmitRequest{ SurfaceID: core.ID(body.SurfaceID), ConversationID: core.ID(body.ConversationID), BaseRevision: body.BaseRevision, ObservedEpoch: body.ObservedEpoch, ContextVersion: body.ContextVersion, SemanticText: body.SemanticText, Spec: body.Spec, - }) + }, s.Auth == nil || !s.Auth.Enabled) if err != nil { s.writeSurfaceError(w, err) return @@ -260,7 +260,7 @@ func (s *Server) setSurfaceActivation(w http.ResponseWriter, r *http.Request) { http.Error(w, "enabled required", http.StatusBadRequest) return } - row, err := s.Surfaces.SetActivation(r.Context(), s.owner(r), r.PathValue("id"), body.Enabled) + row, err := s.Surfaces.SetActivationAccountChecked(r.Context(), s.owner(r), r.PathValue("id"), body.Enabled, s.Auth == nil || !s.Auth.Enabled) if err != nil { s.writeSurfaceError(w, err) return @@ -299,10 +299,10 @@ func (s *Server) submitSurfaceAction(w http.ResponseWriter, r *http.Request) { http.Error(w, "actionId and requestId required", http.StatusBadRequest) return } - res, err := s.Surfaces.SubmitAction(r.Context(), s.owner(r), surfacesvc.SubmitActionRequest{ + res, err := s.Surfaces.SubmitActionAccountChecked(r.Context(), s.owner(r), surfacesvc.SubmitActionRequest{ SurfaceID: core.ID(r.PathValue("id")), ActionID: body.ActionID, Revision: body.Revision, RequestID: body.RequestID, Data: body.Data, - }) + }, s.Auth == nil || !s.Auth.Enabled) if err != nil { s.writeSurfaceError(w, err) return diff --git a/internal/server/project_events_test.go b/internal/server/project_events_test.go index 0360ded..06eb7f4 100644 --- a/internal/server/project_events_test.go +++ b/internal/server/project_events_test.go @@ -29,10 +29,11 @@ func projectStreamReplay(t *testing.T, s *Server, key, project string) *httptest func projectEventFixture(t *testing.T) (*Server, string, string, string, *atomic.Bool) { t.Helper() s, st := newAuthTestServer(t) - alice, _ := st.CreateAPIKey("alice", "test") - bob, _ := st.CreateAPIKey("bob", "test") + alice, _ := createLegacyAPIKeyFixture(t, st, "alice", "test") + bob, _ := createLegacyAPIKeyFixture(t, st, "bob", "test") p, _ := st.CreateProject("alice", "Project") s.Cards = cardsvc.New(st, s.mgr.EmitCardEvent) + s.Cards.SetAccountCheckedEmitter(s.mgr.EmitCardEventAccountChecked) s.Cards.SetProjectSharedEmitter(s.mgr.EmitProjectSharedCardEvent) member := &atomic.Bool{} member.Store(true) diff --git a/internal/server/project_lifecycle.go b/internal/server/project_lifecycle.go index ed2b29c..2bbfb67 100644 --- a/internal/server/project_lifecycle.go +++ b/internal/server/project_lifecycle.go @@ -45,11 +45,11 @@ func (s *Server) createPrivateConversation(ctx context.Context, owner, projectID if err := s.projectWork(ctx, owner, projectID); err != nil { return core.Conversation{}, err } - return s.st.CreateConversationInProject(owner, projectID, title, profile) + return s.st.CreateConversationAccountChecked(owner, projectID, title, profile, s.Auth == nil || !s.Auth.Enabled, false) } - c, err := s.st.CreateConversation(owner, projectID, title, profile) + c, err := s.st.CreateConversationAccountChecked(owner, projectID, title, profile, s.Auth == nil || !s.Auth.Enabled, true) if errors.Is(err, store.ErrNotFound) && s.authorizeSharedResource(owner, "read", "project", core.ID(projectID), "", core.ID(projectID)) { - return s.st.CreateConversationInProject(owner, projectID, title, profile) + return s.st.CreateConversationAccountChecked(owner, projectID, title, profile, s.Auth == nil || !s.Auth.Enabled, false) } return c, err } diff --git a/internal/server/project_lifecycle_test.go b/internal/server/project_lifecycle_test.go index a58553e..6d38c67 100644 --- a/internal/server/project_lifecycle_test.go +++ b/internal/server/project_lifecycle_test.go @@ -32,7 +32,7 @@ func lifecycleRequest(t *testing.T, s *Server, key, method, path, body string) * } func TestProjectLifecycleDelegatesAndPreservesArchiveHistory(t *testing.T) { s, st := newAuthTestServer(t) - key, _ := st.CreateAPIKey("alice", "test") + key, _ := createLegacyAPIKeyFixture(t, st, "alice", "test") p, _ := st.CreateProject("alice", "Original") c, _ := st.CreateConversation("alice", p.ID, "Private", "") calls := []string{} @@ -74,9 +74,9 @@ func TestProjectLifecycleDelegatesAndPreservesArchiveHistory(t *testing.T) { func fmtWrap(err error) error { return errors.Join(errors.New("wrapped"), err) } func TestProjectLifecycleMemberPrivateWork(t *testing.T) { s, st := newAuthTestServer(t) - alice, _ := st.CreateAPIKey("alice", "test") - bob, _ := st.CreateAPIKey("bob", "test") - mallory, _ := st.CreateAPIKey("mallory", "test") + alice, _ := createLegacyAPIKeyFixture(t, st, "alice", "test") + bob, _ := createLegacyAPIKeyFixture(t, st, "bob", "test") + mallory, _ := createLegacyAPIKeyFixture(t, st, "mallory", "test") p, _ := st.CreateProject("alice", "Shared") ac, _ := st.CreateConversation("alice", p.ID, "Alice private", "") ready := true @@ -126,7 +126,7 @@ func TestProjectLifecycleMemberPrivateWork(t *testing.T) { } func TestProjectLifecycleReadOnlyAuthorizerDoesNotGrantWrite(t *testing.T) { s, st := newAuthTestServer(t) - key, _ := st.CreateAPIKey("bob", "test") + key, _ := createLegacyAPIKeyFixture(t, st, "bob", "test") p, _ := st.CreateProject("alice", "Shared") s.SetProjectAuthorizer(func(string, core.ID) (bool, error) { return true, nil }) s.SetProjectLifecycle(extension.ProjectLifecycle{}, nil) @@ -140,7 +140,7 @@ func TestProjectLifecycleReadOnlyAuthorizerDoesNotGrantWrite(t *testing.T) { func TestProjectLifecycleRevocationGuardsExecutionResumption(t *testing.T) { s, st := newAuthTestServer(t) - key, _ := st.CreateAPIKey("alice", "test") + key, _ := createLegacyAPIKeyFixture(t, st, "alice", "test") p, _ := st.CreateProject("alice", "Project") conv, _ := st.CreateConversation("alice", p.ID, "Private", "") s.SetProjectLifecycle(extension.ProjectLifecycle{Work: func(context.Context, string, core.ID) (bool, error) { return false, nil }}, nil) @@ -158,7 +158,7 @@ func TestProjectLifecycleRevocationGuardsExecutionResumption(t *testing.T) { func TestProjectLifecycleRevocationGuardsSurfaceAction(t *testing.T) { s, st := newAuthTestServer(t) - key, _ := st.CreateAPIKey("alice", "test") + key, _ := createLegacyAPIKeyFixture(t, st, "alice", "test") p, _ := st.CreateProject("alice", "Project") conv, _ := st.CreateConversation("alice", p.ID, "Private", "") svc := surfacesvc.New(st, nil) @@ -179,7 +179,7 @@ func TestProjectLifecycleRevocationGuardsSurfaceAction(t *testing.T) { func TestProjectLifecycleSettingsRetainServiceMetadata(t *testing.T) { s, st := newAuthTestServer(t) - key, _ := st.CreateAPIKey("alice", "test") + key, _ := createLegacyAPIKeyFixture(t, st, "alice", "test") p, _ := st.CreateProject("alice", "Project") s.SetProjectLifecycle(extension.ProjectLifecycle{List: func(context.Context, extension.Host) ([]core.Project, error) { current, err := st.GetProject("alice", p.ID) @@ -203,8 +203,8 @@ func TestProjectLifecycleSettingsRetainServiceMetadata(t *testing.T) { func TestProjectLifecyclePublicMetadataMasksSecrets(t *testing.T) { s, st := newAuthTestServer(t) - alice, _ := st.CreateAPIKey("alice", "test") - bob, _ := st.CreateAPIKey("bob", "test") + alice, _ := createLegacyAPIKeyFixture(t, st, "alice", "test") + bob, _ := createLegacyAPIKeyFixture(t, st, "bob", "test") p, _ := st.CreateProject("alice", "Shared") raw := map[string]any{"models.chat.apiKey": "fixture-key", "custom": map[string]any{"token": "fixture-token"}, "custom.api_key": "fixture-flat-token", "plugin.private.credential": "fixture-schema-token", "display": map[string]any{"caption": "Shared"}, "rdm.projectId": "canonical", "rdm.state": "ready"} if err := st.UpdateProjectSettings("alice", p.ID, raw); err != nil { @@ -243,7 +243,7 @@ func TestGenericProjectPatchRejectsManagedSettingsAtomically(t *testing.T) { for _, registered := range []bool{false, true} { t.Run(fmt.Sprint(registered), func(t *testing.T) { s, st := newAuthTestServer(t) - key, _ := st.CreateAPIKey("alice", "test") + key, _ := createLegacyAPIKeyFixture(t, st, "alice", "test") p, _ := st.CreateProject("alice", "Original") s.set.SetExtraSchema([]core.SettingDesc{{Key: "plugins.private.managed", Scope: core.SettingScopeSystem}}) if registered { @@ -286,7 +286,7 @@ func TestGenericProjectPatchAcceptsNestedMaskedWriteback(t *testing.T) { for _, registered := range []bool{false, true} { t.Run(fmt.Sprint(registered), func(t *testing.T) { s, st := newAuthTestServer(t) - key, _ := st.CreateAPIKey("alice", "test") + key, _ := createLegacyAPIKeyFixture(t, st, "alice", "test") p, _ := st.CreateProject("alice", "Project") if err := st.UpdateProjectSettings("alice", p.ID, map[string]any{"models": map[string]any{"chat": map[string]any{"apiKey": "fixture-persisted-key"}}, "display": map[string]any{"caption": "Original"}}); err != nil { t.Fatal(err) @@ -347,9 +347,9 @@ func lifecycleUpload(t *testing.T, s *Server, key, projectID, name, content stri func TestProjectLifecycleMemberUploadStaysPrivate(t *testing.T) { s, st := newAuthTestServer(t) - alice, _ := st.CreateAPIKey("alice", "test") - bob, _ := st.CreateAPIKey("bob", "test") - mallory, _ := st.CreateAPIKey("mallory", "test") + alice, _ := createLegacyAPIKeyFixture(t, st, "alice", "test") + bob, _ := createLegacyAPIKeyFixture(t, st, "bob", "test") + mallory, _ := createLegacyAPIKeyFixture(t, st, "mallory", "test") p, _ := st.CreateProject("alice", "Shared") ready := true s.SetProjectLifecycle(extension.ProjectLifecycle{Work: func(_ context.Context, actor string, id core.ID) (bool, error) { @@ -399,8 +399,8 @@ func TestProjectLifecycleMemberUploadStaysPrivate(t *testing.T) { func TestProjectLifecycleUploadSuppressesPrivateProjectCards(t *testing.T) { s, st := newAuthTestServer(t) - alice, _ := st.CreateAPIKey("alice", "test") - bob, _ := st.CreateAPIKey("bob", "test") + alice, _ := createLegacyAPIKeyFixture(t, st, "alice", "test") + bob, _ := createLegacyAPIKeyFixture(t, st, "bob", "test") p, _ := st.CreateProject("alice", "Shared") s.SetProjectLifecycle(extension.ProjectLifecycle{Work: func(_ context.Context, actor string, id core.ID) (bool, error) { return id == p.ID && (actor == "alice" || actor == "bob"), nil @@ -409,6 +409,7 @@ func TestProjectLifecycleUploadSuppressesPrivateProjectCards(t *testing.T) { return q.ResourceType == "project" && q.ProjectID == p.ID && (q.Actor == "alice" || q.Actor == "bob"), nil }) s.Cards = cardsvc.New(st, s.mgr.EmitCardEvent) + s.Cards.SetAccountCheckedEmitter(s.mgr.EmitCardEventAccountChecked) s.Cards.SetProjectSharedEmitter(s.mgr.EmitProjectSharedCardEvent) s.DisableUploadIndexing = true if _, err := s.Cards.PublishProjectCard("alice", "plugin:rdm:structure", core.ActivityCard{ProjectID: p.ID, Title: "Shared structure", Kind: core.CardKindProgress}, ""); err != nil { @@ -468,9 +469,10 @@ func TestProjectLifecycleUploadSuppressesPrivateProjectCards(t *testing.T) { func TestUnregisteredUploadKeepsLegacyCardsAndValidation(t *testing.T) { s, st := newAuthTestServer(t) - key, _ := st.CreateAPIKey("alice", "test") + key, _ := createLegacyAPIKeyFixture(t, st, "alice", "test") p, _ := st.CreateProject("alice", "Private") s.Cards = cardsvc.New(st, s.mgr.EmitCardEvent) + s.Cards.SetAccountCheckedEmitter(s.mgr.EmitCardEventAccountChecked) s.DisableUploadIndexing = true if w := lifecycleUpload(t, s, key, p.ID, "legacy-upload.txt", "Original"); w.Code != 200 { t.Fatalf("unregistered owner upload=%d", w.Code) @@ -486,7 +488,7 @@ func TestUnregisteredUploadKeepsLegacyCardsAndValidation(t *testing.T) { func TestProjectLifecycleUploadRechecksBeforePersistence(t *testing.T) { s, st := newAuthTestServer(t) - key, _ := st.CreateAPIKey("bob", "test") + key, _ := createLegacyAPIKeyFixture(t, st, "bob", "test") p, _ := st.CreateProject("alice", "Shared") ready := true s.SetProjectLifecycle(extension.ProjectLifecycle{Work: func(context.Context, string, core.ID) (bool, error) { diff --git a/internal/server/project_resource_filter_test.go b/internal/server/project_resource_filter_test.go index 581f147..1282e29 100644 --- a/internal/server/project_resource_filter_test.go +++ b/internal/server/project_resource_filter_test.go @@ -10,7 +10,7 @@ import ( func TestKnowledgeV2UsesDocumentIdentity(t *testing.T) { s, st := newAuthTestServer(t) - key, _ := st.CreateAPIKey("bob", "test") + key, _ := createLegacyAPIKeyFixture(t, st, "bob", "test") docs := []core.KnowledgeDoc{{ID: "published-slice", Owner: "alice", ProjectID: "domain-p", Chunk: "sharedneedle published"}, {ID: "private-draft", Owner: "alice", ProjectID: "domain-p", Chunk: "sharedneedle draft"}, {ID: "self-denied", Owner: "bob", ProjectID: "domain-p", Chunk: "sharedneedle own restricted"}, {ID: "self-allowed", Owner: "bob", ProjectID: "domain-p", Chunk: "sharedneedle own"}} if err := st.AddKnowledge(docs...); err != nil { t.Fatal(err) @@ -39,7 +39,7 @@ func TestKnowledgeV2UsesDocumentIdentity(t *testing.T) { } func TestArtifactListV2FiltersEachResource(t *testing.T) { s, st := newAuthTestServer(t) - key, _ := st.CreateAPIKey("bob", "test") + key, _ := createLegacyAPIKeyFixture(t, st, "bob", "test") p, _ := st.CreateProject("alice", "Project") private := core.Artifact{Owner: "alice", ProjectID: p.ID, Name: "Private", MIME: "text/plain"} public := core.Artifact{Owner: "alice", ProjectID: p.ID, Name: "Shared", MIME: "text/plain"} @@ -62,7 +62,7 @@ func TestArtifactListV2FiltersEachResource(t *testing.T) { func TestKnowledgeV2ResolvesTrustedProjectScope(t *testing.T) { s, st := newAuthTestServer(t) - key, _ := st.CreateAPIKey("bob", "test") + key, _ := createLegacyAPIKeyFixture(t, st, "bob", "test") if err := st.AddKnowledge(core.KnowledgeDoc{ID: "slice-current", Owner: "alice", ProjectID: "domain-current", Chunk: "scopeneedle current"}, core.KnowledgeDoc{ID: "slice-other", Owner: "alice", ProjectID: "domain-other", Chunk: "scopeneedle other"}); err != nil { t.Fatal(err) } @@ -83,7 +83,7 @@ func TestKnowledgeV2ResolvesTrustedProjectScope(t *testing.T) { func TestKnowledgeV2UsesStoredPublicationSource(t *testing.T) { s, st := newAuthTestServer(t) - key, _ := st.CreateAPIKey("bob", "test") + key, _ := createLegacyAPIKeyFixture(t, st, "bob", "test") // A known published identity replaced by a private row must lose sharing. if err := st.AddKnowledge(core.KnowledgeDoc{ID: "known-published-id", Owner: "alice", ProjectID: "domain", Source: "manual", Chunk: "sourceneedle private"}, core.KnowledgeDoc{ID: "real-published-id", Owner: "alice", ProjectID: "domain", Source: "plugin:rdm:published", Chunk: "sourceneedle public"}); err != nil { t.Fatal(err) diff --git a/internal/server/resource_authz_consumption_test.go b/internal/server/resource_authz_consumption_test.go index eebdead..0d0698b 100644 --- a/internal/server/resource_authz_consumption_test.go +++ b/internal/server/resource_authz_consumption_test.go @@ -19,11 +19,11 @@ import ( func newV2CaptureServer(t *testing.T) (*Server, *store.Store, string, string, string) { t.Helper() srv, st := newAuthTestServer(t) - alice, err := st.CreateAPIKey("alice", "e2e") + alice, err := createLegacyAPIKeyFixture(t, st, "alice", "e2e") if err != nil { t.Fatal(err) } - bob, err := st.CreateAPIKey("bob", "e2e") + bob, err := createLegacyAPIKeyFixture(t, st, "bob", "e2e") if err != nil { t.Fatal(err) } diff --git a/internal/server/resource_authz_test.go b/internal/server/resource_authz_test.go index f2599b5..a01e8ff 100644 --- a/internal/server/resource_authz_test.go +++ b/internal/server/resource_authz_test.go @@ -19,15 +19,15 @@ func TestProjectResourceAuthorizerSharesReads(t *testing.T) { srv, st := newAuthTestServer(t) h := srv.Handler() - alice, err := st.CreateAPIKey("alice", "e2e") + alice, err := createLegacyAPIKeyFixture(t, st, "alice", "e2e") if err != nil { t.Fatal(err) } - bob, err := st.CreateAPIKey("bob", "e2e") + bob, err := createLegacyAPIKeyFixture(t, st, "bob", "e2e") if err != nil { t.Fatal(err) } - carol, err := st.CreateAPIKey("carol", "e2e") + carol, err := createLegacyAPIKeyFixture(t, st, "carol", "e2e") if err != nil { t.Fatal(err) } diff --git a/internal/server/rewind.go b/internal/server/rewind.go index 80d7748..b22e6e1 100644 --- a/internal/server/rewind.go +++ b/internal/server/rewind.go @@ -38,7 +38,7 @@ func (s *Server) rewindFiles(w http.ResponseWriter, r *http.Request) { http.Error(w, "messageId required", http.StatusBadRequest) return } - if err := s.mgr.ClaimConversation(id); err != nil { + if err := s.mgr.ClaimConversation(id, owner); err != nil { if errors.Is(err, runmgr.ErrConversationBusy) { http.Error(w, "conversation busy", http.StatusConflict) return diff --git a/internal/server/server.go b/internal/server/server.go index 13abb06..ac2f895 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -42,7 +42,8 @@ import ( ) type Server struct { - accountSearchSecret [32]byte + accountSearchKey store.AccountCursorKey + accountSearchKeyErr error projectLifecycle *extension.ProjectLifecycle projectHost func(string) extension.Host st *store.Store @@ -161,8 +162,9 @@ func New(st *store.Store, mgr *runmgr.Manager, reg *tools.Registry, p *profiles. if err != nil { panic(err) // embed 布局错误属构建期问题 } + key, keyErr := st.GetAccountCursorKey() return &Server{st: st, mgr: mgr, reg: reg, prof: p, set: s, plugins: ph, static: sub, - accountSearchSecret: newAccountSearchSecret()} + accountSearchKey: key, accountSearchKeyErr: keyErr} } // NLIntentGate 意图闸门端口(intentgate.Gate 实现;避免包依赖方向反转)。 @@ -221,7 +223,7 @@ func (s *Server) WireBuiltinCommands() { ParamsSchema: json.RawMessage(`{"type":"object","required":["on"],"properties":{"on":{"type":"boolean"}}}`)}, "/mock", func(ctx context.Context, req cmdbus.Request) (any, error) { on, _ := req.Params["on"].(bool) - if err := s.set.MergeUser(req.Owner, map[string]any{"runtime": map[string]any{"mock": on}}); err != nil { + if err := s.set.MergeUserAccountChecked(req.Owner, map[string]any{"runtime": map[string]any{"mock": on}}, s.Auth == nil || !s.Auth.Enabled); err != nil { return nil, err } return map[string]any{"runtime.mock": on}, nil @@ -230,7 +232,7 @@ func (s *Server) WireBuiltinCommands() { ParamsSchema: json.RawMessage(`{"type":"object","required":["model"],"properties":{"model":{"type":"string"}}}`)}, "/model", func(ctx context.Context, req cmdbus.Request) (any, error) { m, _ := req.Params["model"].(string) - if err := s.set.MergeUser(req.Owner, map[string]any{"models": map[string]any{"chat": map[string]any{"model": m}}}); err != nil { + if err := s.set.MergeUserAccountChecked(req.Owner, map[string]any{"models": map[string]any{"chat": map[string]any{"model": m}}}, s.Auth == nil || !s.Auth.Enabled); err != nil { return nil, err } return map[string]any{"model": m}, nil @@ -239,7 +241,7 @@ func (s *Server) WireBuiltinCommands() { ParamsSchema: json.RawMessage(`{"type":"object","required":["profileId"],"properties":{"profileId":{"type":"string"}}}`)}, "/profile", func(ctx context.Context, req cmdbus.Request) (any, error) { pid, _ := req.Params["profileId"].(string) - if err := s.st.UpdateConversation(req.Owner, req.ConversationID, "", pid); err != nil { + if err := s.st.UpdateConversationAccountChecked(req.Owner, req.ConversationID, "", pid, s.Auth == nil || !s.Auth.Enabled); err != nil { return nil, err } return map[string]any{"profile": pid}, nil @@ -725,7 +727,7 @@ func (s *Server) createProject(w http.ResponseWriter, r *http.Request) { writeProjectJSON(w, s.publicProjectMetadata(p), err) return } - p, err := s.st.CreateProject(s.owner(r), strings.TrimSpace(req.Name)) + p, err := s.st.CreateProjectAccountChecked(s.owner(r), strings.TrimSpace(req.Name), s.Auth == nil || !s.Auth.Enabled) writeJSONOr(w, p, err) } @@ -766,7 +768,7 @@ func (s *Server) patchProject(w http.ResponseWriter, r *http.Request) { } } if req.Settings != nil { - if err := s.st.UpdateProjectSettings(s.owner(r), id, req.Settings); err != nil { + if err := s.st.UpdateProjectSettingsAccountChecked(s.owner(r), id, req.Settings, s.Auth == nil || !s.Auth.Enabled); err != nil { writeStoreErr(w, err) return } @@ -792,13 +794,13 @@ func (s *Server) patchProject(w http.ResponseWriter, r *http.Request) { return } if req.Name != nil { - if err := s.st.RenameProject(s.owner(r), id, *req.Name); err != nil { + if err := s.st.RenameProjectAccountChecked(s.owner(r), id, *req.Name, s.Auth == nil || !s.Auth.Enabled); err != nil { writeStoreErr(w, err) return } } if req.Settings != nil { - if err := s.st.UpdateProjectSettings(s.owner(r), id, req.Settings); err != nil { + if err := s.st.UpdateProjectSettingsAccountChecked(s.owner(r), id, req.Settings, s.Auth == nil || !s.Auth.Enabled); err != nil { writeStoreErr(w, err) return } @@ -820,7 +822,7 @@ func (s *Server) deleteProject(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, map[string]any{"ok": true}) return } - writeOKOr(w, s.st.DeleteProject(s.owner(r), r.PathValue("id"))) + writeOKOr(w, s.st.DeleteProjectAccountChecked(s.owner(r), r.PathValue("id"), s.Auth == nil || !s.Auth.Enabled)) } func (s *Server) listConversations(w http.ResponseWriter, r *http.Request) { @@ -925,28 +927,28 @@ func (s *Server) patchConversation(w http.ResponseWriter, r *http.Request) { onlyMode := req.PermissionMode != nil && req.Title == "" && req.ProfileID == "" && req.Pinned == nil && req.Archived == nil && req.AllowCrossRead == nil && req.CrossPrivate == nil && req.SessionNote == nil onlyNote := req.SessionNote != nil && req.Title == "" && req.ProfileID == "" && req.Pinned == nil && req.Archived == nil && req.AllowCrossRead == nil && req.CrossPrivate == nil && req.PermissionMode == nil if !onlyMode && !onlyNote { - if err := s.st.UpdateConversation(s.owner(r), id, req.Title, req.ProfileID); err != nil { + if err := s.st.UpdateConversationAccountChecked(s.owner(r), id, req.Title, req.ProfileID, s.Auth == nil || !s.Auth.Enabled); err != nil { writeStoreErr(w, err) return } } if req.PermissionMode != nil { - if err := s.st.SetPermissionMode(s.owner(r), id, *req.PermissionMode); err != nil { + if err := s.st.SetPermissionModeAccountChecked(s.owner(r), id, *req.PermissionMode, s.Auth == nil || !s.Auth.Enabled); err != nil { writeStoreErr(w, err) return } } if req.SessionNote != nil { - if err := s.st.SetSessionNote(s.owner(r), id, *req.SessionNote); err != nil { + if err := s.st.SetSessionNoteAccountChecked(s.owner(r), id, *req.SessionNote, s.Auth == nil || !s.Auth.Enabled); err != nil { writeStoreErr(w, err) return } } - writeOKOr(w, s.st.SetConversationFlags(s.owner(r), id, req.Pinned, req.Archived, req.AllowCrossRead, req.CrossPrivate)) + writeOKOr(w, s.st.SetConversationFlagsAccountChecked(s.owner(r), id, req.Pinned, req.Archived, req.AllowCrossRead, req.CrossPrivate, s.Auth == nil || !s.Auth.Enabled)) } func (s *Server) deleteConversation(w http.ResponseWriter, r *http.Request) { - writeOKOr(w, s.st.DeleteConversation(s.owner(r), r.PathValue("id"))) + writeOKOr(w, s.st.DeleteConversationAccountChecked(s.owner(r), r.PathValue("id"), s.Auth == nil || !s.Auth.Enabled)) } // ---- 消息发送(含命令路由与附件) ---- @@ -1066,14 +1068,14 @@ func (s *Server) postMessage(w http.ResponseWriter, r *http.Request) { http.Error(w, "execution queue full(runtime.execQueueMax)", http.StatusTooManyRequests) return } - if err := s.mgr.ClaimConversation(convID); err != nil { + if err := s.mgr.ClaimConversation(convID, owner); err != nil { writeStoreErr(w, err) return } claimed = true } msg := core.Message{ConversationID: convID, Role: core.RoleUser, Parts: parts} - if err := s.st.AppendMessageWithRequest(&msg, req.RequestID); err != nil { + if err := s.st.AppendMessageWithRequestAccountChecked(owner, s.Auth == nil || !s.Auth.Enabled, &msg, req.RequestID); err != nil { var dup *store.DuplicateRequestError if errors.As(err, &dup) { if claimed { @@ -1121,7 +1123,11 @@ func (s *Server) postMessage(w http.ResponseWriter, r *http.Request) { writeProjectErr(w, startErr) return } - _ = s.st.SetMessageRunID(msg.ID, run.ID) + if err := s.st.SetMessageRunIDAccountChecked(owner, msg.ID, string(run.ID), s.Auth == nil || !s.Auth.Enabled); err != nil { + _ = s.mgr.CancelRun(string(run.ID)) + writeStoreErr(w, err) + return + } msg.RunID = run.ID writeJSON(w, http.StatusOK, map[string]any{"message": msg, "run": run}) } @@ -1282,12 +1288,8 @@ func (s *Server) cancelRun(w http.ResponseWriter, r *http.Request) { // conversation_id 必须回填真实值:消费侧按执行权 JOIN 匹配,空串永远 // 无法送达(同批实测)。 if s.st.Dialect() == "postgres" && !s.mgr.RunActiveLocally(runID) { - convID := "" - if run, gerr := s.st.GetRun(s.owner(r), runID); gerr == nil { - convID = string(run.ConversationID) - } - if cerr := s.st.EnqueueCommand(store.NewID("cmd"), "cancel", runID, convID, 0, - runmgr.CancelCommandPayload{Owner: s.owner(r), RunID: runID}); cerr != nil { + if cerr := s.st.EnqueueCommandAccountChecked(s.owner(r), store.NewID("cmd"), "cancel", runID, 0, + runmgr.CancelCommandPayload{Owner: s.owner(r), RunID: runID}, s.Auth == nil || !s.Auth.Enabled); cerr != nil { writeStoreErr(w, cerr) return } @@ -1341,8 +1343,8 @@ func (s *Server) resolveHITL(w http.ResponseWriter, r *http.Request) { if kind == "" { kind = "free" } - if cerr := s.st.EnqueueCommand(store.NewID("cmd"), "hitl", string(card.RunID), string(card.ConversationID), 0, - runmgr.HITLCommandPayload{Owner: s.owner(r), CardID: string(resp.CardID), Kind: kind, Answer: json.RawMessage(resp.FreeText)}); cerr == nil { + if cerr := s.st.EnqueueCommandAccountChecked(s.owner(r), store.NewID("cmd"), "hitl", string(card.RunID), 0, + runmgr.HITLCommandPayload{Owner: s.owner(r), CardID: string(resp.CardID), Kind: kind, Answer: json.RawMessage(resp.FreeText)}, s.Auth == nil || !s.Auth.Enabled); cerr == nil { writeJSON(w, http.StatusAccepted, map[string]any{"accepted": true, "via": "command"}) return } @@ -1382,7 +1384,7 @@ func (s *Server) listProjectCards(w http.ResponseWriter, r *http.Request) { // cardToProject 会话卡留档到项目收件箱(D48):复制为项目级卡(幂等),原卡 // dismissed 留痕;回执给前端 upsert 两张卡的终态。 func (s *Server) cardToProject(w http.ResponseWriter, r *http.Request) { - created, err := s.Cards.ToProject(s.owner(r), r.PathValue("id")) + created, err := s.Cards.ToProjectAccountChecked(s.owner(r), r.PathValue("id"), s.Auth == nil || !s.Auth.Enabled) if err != nil { writeStoreErr(w, err) return @@ -1427,12 +1429,12 @@ func (s *Server) cardAction(w http.ResponseWriter, r *http.Request) { // 命令确认卡(ref=cmd:)走总线回道:confirm=执行挂起命令,其余=取消。 if ref, err := s.st.CardRef(owner, r.PathValue("id")); err == nil && strings.HasPrefix(ref, "cmd:") { if card, err := s.Cards.Get(owner, r.PathValue("id")); err == nil && card.Status == core.CardActionRequired { - resp := s.Bus.Confirm(owner, card.ID, r.PathValue("actionId")) + resp := s.Bus.ConfirmContext(r.Context(), owner, card.ID, r.PathValue("actionId")) writeJSON(w, http.StatusOK, map[string]any{"card": card, "receipt": resp.Receipt, "payload": resp.Payload}) return } } - card, label, err := s.Cards.Action(owner, r.PathValue("id"), r.PathValue("actionId"), body.FreeText) + card, label, err := s.Cards.ActionAccountChecked(owner, r.PathValue("id"), r.PathValue("actionId"), body.FreeText, s.Auth == nil || !s.Auth.Enabled) if err != nil { writeStoreErr(w, err) return @@ -1443,7 +1445,10 @@ func (s *Server) cardAction(w http.ResponseWriter, r *http.Request) { if card.ConversationID != "" { receipt = &core.Message{ID: store.NewID("msg"), ConversationID: card.ConversationID, Role: core.RoleAssistant, CreatedAt: time.Now().UTC()} receipt.Parts = []core.Part{{Type: core.PartText, Text: fmt.Sprintf("✓ 已按「%s」处理「%s」——结论已记录并留痕(card:%s)", label, card.Title, card.ID)}} - _ = s.st.AppendMessage(receipt) + if err := s.st.AppendMessageWithRequestAccountChecked(owner, s.Auth == nil || !s.Auth.Enabled, receipt, ""); err != nil { + writeStoreErr(w, err) + return + } } writeJSON(w, http.StatusOK, map[string]any{"card": card, "receipt": receipt}) } @@ -1453,12 +1458,12 @@ func (s *Server) cardPin(w http.ResponseWriter, r *http.Request) { Pinned bool `json:"pinned"` } _ = json.NewDecoder(r.Body).Decode(&body) - card, err := s.Cards.SetPinned(s.owner(r), r.PathValue("id"), body.Pinned) + card, err := s.Cards.SetPinnedAccountChecked(s.owner(r), r.PathValue("id"), body.Pinned, s.Auth == nil || !s.Auth.Enabled) writeJSONOr(w, map[string]any{"card": card}, err) } func (s *Server) cardDismiss(w http.ResponseWriter, r *http.Request) { - card, err := s.Cards.Dismiss(s.owner(r), r.PathValue("id")) + card, err := s.Cards.DismissAccountChecked(s.owner(r), r.PathValue("id"), s.Auth == nil || !s.Auth.Enabled) writeJSONOr(w, map[string]any{"card": card}, err) } @@ -1504,7 +1509,7 @@ func (s *Server) upload(w http.ResponseWriter, r *http.Request) { } mime := header.Header.Get("Content-Type") art := core.Artifact{ProjectID: projectID, Owner: owner, Name: header.Filename, Kind: kindOf(header.Filename), MIME: mime} - if err := s.st.CreateArtifact(&art, content); err != nil { + if err := s.st.CreateArtifactAccountChecked(owner, &art, content, s.Auth == nil || !s.Auth.Enabled); err != nil { writeStoreErr(w, err) return } @@ -1519,7 +1524,13 @@ func (s *Server) upload(w http.ResponseWriter, r *http.Request) { // original filename/parser metadata out of shared project cards and events. showIngestCard := s.Cards != nil && s.projectLifecycle == nil if showIngestCard { - ingestCard, _ = s.Cards.Create(owner, ingestCard, "") + var cardErr error + ingestCard, cardErr = s.Cards.CreateAccountChecked(owner, ingestCard, "", s.Auth == nil || !s.Auth.Enabled) + if errors.Is(cardErr, store.ErrAccountForbidden) { + writeStoreErr(w, cardErr) + return + } + showIngestCard = cardErr == nil } doc := ingest.Parse(art.Name, mime, content) if showIngestCard { @@ -1538,19 +1549,42 @@ func (s *Server) upload(w http.ResponseWriter, r *http.Request) { body = append(body, core.UINode{Type: core.UIMarkdown, Markdown: "_解析不完整不静默:原始文件已永久保留,可取回或重试。_"}) } ingestCard.Body = body - _ = s.Cards.Update(owner, ingestCard) + if _, version, err := s.st.GetCardVersion(owner, ingestCard.ID); err == nil { + if err := s.Cards.UpdateVersionedAccountChecked(owner, ingestCard, version, s.Auth == nil || !s.Auth.Enabled); errors.Is(err, store.ErrAccountForbidden) { + writeStoreErr(w, err) + return + } + } } // 文本可解析的进检索索引(管线分块+全文+可选向量)。 // EXT-9:宿主关闭自动索引时跳过(发布决定移交下游命令层;原件与摄取卡不变)。 if !s.DisableUploadIndexing { if plain := doc.PlainText(); plain != "" && len(plain) < 200000 { + var indexErr error if s.Indexer != nil { - _ = s.Indexer.IndexDocument(owner, projectID, "upload", art.Name, art.ID, plain) + if checked, ok := s.Indexer.(interface { + IndexDocumentAccountChecked(context.Context, core.ID, core.ID, string, string, string, string, bool) error + }); ok { + indexErr = checked.IndexDocumentAccountChecked(r.Context(), core.ID(owner), core.ID(projectID), "upload", art.Name, art.ID, plain, s.Auth == nil || !s.Auth.Enabled) + } else if s.Auth != nil && s.Auth.Enabled { + indexErr = store.ErrAccountForbidden // Unknown indexer has no final account fence. + } else { + indexErr = s.Indexer.IndexDocument(owner, projectID, "upload", art.Name, art.ID, plain) + } } else { - _ = s.st.AddKnowledge(core.KnowledgeDoc{Owner: owner, ProjectID: projectID, Source: "upload", Title: art.Name, Ref: art.ID, Chunk: plain}) + indexErr = s.st.AddKnowledgeAccountChecked(owner, s.Auth == nil || !s.Auth.Enabled, + core.KnowledgeDoc{Owner: core.ID(owner), ProjectID: core.ID(projectID), Source: "upload", Title: art.Name, Ref: art.ID, Chunk: plain}) + } + if errors.Is(indexErr, store.ErrAccountForbidden) { + writeStoreErr(w, indexErr) + return } } } + if err := s.checkActorBeforeExternalEffect(owner); err != nil { + writeStoreErr(w, err) + return + } writeJSON(w, http.StatusOK, core.UploadInfo{ArtifactID: art.ID, Filename: art.Name, MIME: mime, Size: art.Size, Parsed: &doc}) } @@ -1648,11 +1682,11 @@ func (s *Server) artifactContent(w http.ResponseWriter, r *http.Request) { func (s *Server) putArtifactContent(w http.ResponseWriter, r *http.Request) { body, _ := io.ReadAll(io.LimitReader(r.Body, 20<<20)) - writeOKOr(w, s.st.UpdateArtifactContent(s.owner(r), r.PathValue("id"), body)) + writeOKOr(w, s.st.UpdateArtifactContentAccountChecked(s.owner(r), r.PathValue("id"), body, s.Auth == nil || !s.Auth.Enabled)) } func (s *Server) deleteArtifact(w http.ResponseWriter, r *http.Request) { - writeOKOr(w, s.st.DeleteArtifact(s.owner(r), r.PathValue("id"))) + writeOKOr(w, s.st.DeleteArtifactAccountChecked(s.owner(r), r.PathValue("id"), s.Auth == nil || !s.Auth.Enabled)) } // ---- 画像 ---- @@ -1671,15 +1705,19 @@ func (s *Server) saveProfile(w http.ResponseWriter, r *http.Request) { if p.ID == "" { p.ID = "prof_" + strconv.FormatInt(time.Now().UnixNano()%100000, 10) } - if err := s.prof.Save(s.owner(r), p); err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) + if err := s.prof.SaveAccountChecked(s.owner(r), p, s.Auth == nil || !s.Auth.Enabled); err != nil { + if errors.Is(err, store.ErrAccountForbidden) { + writeStoreErr(w, err) + } else { + http.Error(w, err.Error(), http.StatusBadRequest) + } return } writeJSON(w, http.StatusOK, p) } func (s *Server) deleteProfile(w http.ResponseWriter, r *http.Request) { - writeOKOr(w, s.prof.Delete(s.owner(r), r.PathValue("id"))) + writeOKOr(w, s.prof.DeleteAccountChecked(s.owner(r), r.PathValue("id"), s.Auth == nil || !s.Auth.Enabled)) } // ---- 设置 ---- @@ -1718,7 +1756,9 @@ func (s *Server) putSettingsUser(w http.ResponseWriter, r *http.Request) { http.Error(w, err.Error(), http.StatusBadRequest) return } - s.writeSettingsMerge(w, func() error { return s.set.MergeUser(s.owner(r), values) }) + s.writeSettingsMerge(w, func() error { + return s.set.MergeUserAccountChecked(s.owner(r), values, s.Auth == nil || !s.Auth.Enabled) + }) } func (s *Server) putSettingsProject(w http.ResponseWriter, r *http.Request) { @@ -1728,7 +1768,7 @@ func (s *Server) putSettingsProject(w http.ResponseWriter, r *http.Request) { return } s.writeSettingsMerge(w, func() error { - return s.set.MergeProject(s.owner(r), r.PathValue("id"), values) + return s.set.MergeProjectAccountChecked(s.owner(r), r.PathValue("id"), values, s.Auth == nil || !s.Auth.Enabled) }) } @@ -1754,7 +1794,7 @@ func (s *Server) writeSettingsMerge(w http.ResponseWriter, merge func() error) { // (Auth 未启用)本机即管理员——该档全部 API 本就无认证,锁系统层只会破坏 // 嵌入式部署的配置播种,不增加安全边界。 func (s *Server) requireSystemAdmin(w http.ResponseWriter, r *http.Request) (string, bool) { - if s.Auth == nil { + if s.Auth == nil || !s.Auth.Enabled { return s.owner(r), true } return s.requireAdmin(w, r) @@ -1778,7 +1818,8 @@ func (s *Server) getSettingsSystem(w http.ResponseWriter, r *http.Request) { } func (s *Server) putSettingsSystem(w http.ResponseWriter, r *http.Request) { - if _, ok := s.requireSystemAdmin(w, r); !ok { + actor, ok := s.requireSystemAdmin(w, r) + if !ok { return } var values map[string]any @@ -1786,7 +1827,7 @@ func (s *Server) putSettingsSystem(w http.ResponseWriter, r *http.Request) { http.Error(w, err.Error(), http.StatusBadRequest) return } - writeOKOr(w, s.set.MergeSystem(values)) + writeOKOr(w, s.set.MergeSystemAccountChecked(actor, values, s.Auth == nil || !s.Auth.Enabled)) } // ---- 工具(用户声明式 HTTP 工具注册)---- @@ -1822,6 +1863,10 @@ func (s *Server) createUserTool(w http.ResponseWriter, r *http.Request) { return } lookup := func(key string) string { return s.set.Effective(s.owner(r), "").GetString(key, "") } + if err := s.checkActorBeforeExternalEffect(s.owner(r)); err != nil { + writeStoreErr(w, err) + return + } if err := s.reg.Register(desc, tools.HTTPExecutor(*desc.HTTP, lookup)); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return @@ -1835,6 +1880,10 @@ func (s *Server) deleteUserTool(w http.ResponseWriter, r *http.Request) { http.Error(w, "仅可删除 user 来源工具", http.StatusForbidden) return } + if err := s.checkActorBeforeExternalEffect(s.owner(r)); err != nil { + writeStoreErr(w, err) + return + } s.reg.UnregisterBySource(core.SourceUser, name) writeJSON(w, http.StatusOK, map[string]any{"deleted": name}) } @@ -1904,7 +1953,19 @@ func (s *Server) listPlugins(w http.ResponseWriter, r *http.Request) { } func (s *Server) reloadPlugins(w http.ResponseWriter, r *http.Request) { - results := s.plugins.LoadDir() + actor, ok := s.requireSystemAdmin(w, r) + if !ok { + return + } + if err := s.checkActorBeforeExternalEffect(actor); err != nil { + writeStoreErr(w, err) + return + } + results, err := s.plugins.LoadDirAccountChecked(actor, s.Auth == nil || !s.Auth.Enabled) + if err != nil { + writeStoreErr(w, err) + return + } writeJSON(w, http.StatusOK, map[string]any{"reloaded": len(results), "errors": results}) } @@ -2032,8 +2093,12 @@ func (s *Server) savePromptModule(w http.ResponseWriter, r *http.Request) { http.Error(w, "id/layer/body required", http.StatusBadRequest) return } - if err := s.Prompts.Save(s.owner(r), m); err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) + if err := s.Prompts.SaveAccountChecked(s.owner(r), m, s.Auth == nil || !s.Auth.Enabled); err != nil { + if errors.Is(err, store.ErrAccountForbidden) { + writeStoreErr(w, err) + } else { + http.Error(w, err.Error(), http.StatusBadRequest) + } return } writeJSON(w, http.StatusOK, m) @@ -2044,7 +2109,7 @@ func (s *Server) deletePromptModule(w http.ResponseWriter, r *http.Request) { http.Error(w, "prompt library not enabled", http.StatusServiceUnavailable) return } - writeJSONOr(w, map[string]any{"deleted": r.PathValue("id")}, s.Prompts.Delete(s.owner(r), r.PathValue("id"))) + writeJSONOr(w, map[string]any{"deleted": r.PathValue("id")}, s.Prompts.DeleteAccountChecked(s.owner(r), r.PathValue("id"), s.Auth == nil || !s.Auth.Enabled)) } func (s *Server) listParsers(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, ingest.ParsersDescription()) @@ -2068,7 +2133,7 @@ func (s *Server) addKnowledge(w http.ResponseWriter, r *http.Request) { docs[i].Source = "manual" } } - writeOKOr(w, s.st.AddKnowledge(docs...)) + writeOKOr(w, s.st.AddKnowledgeForOwnedProjectAccountChecked(owner, r.PathValue("id"), s.Auth == nil || !s.Auth.Enabled, docs...)) } func (s *Server) searchKnowledge(w http.ResponseWriter, r *http.Request) { @@ -2220,12 +2285,26 @@ func writeStoreErr(w http.ResponseWriter, err error) { // backup 触发一致性在线备份(嵌入式档:VACUUM INTO;PG 档见运维指南 pg_dump)。 func (s *Server) backup(w http.ResponseWriter, r *http.Request) { + if s.Auth != nil && s.Auth.Enabled { + // A database backup includes other owners' business content. IAM U3 + // does not grant a system administrator that content authority. + http.Error(w, "backup requires local owner maintenance", http.StatusForbidden) + return + } + if err := s.checkActorBeforeExternalEffect(s.owner(r)); err != nil { + writeStoreErr(w, err) + return + } dir := filepath.Join(s.st.DataDir(), "backups") if err := os.MkdirAll(dir, 0o755); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } dest := filepath.Join(dir, "agent-system-"+time.Now().UTC().Format("20060102T150405Z")+".db") + if err := s.checkActorBeforeExternalEffect(s.owner(r)); err != nil { + writeStoreErr(w, err) + return + } if err := s.st.Backup(dest); err != nil { http.Error(w, err.Error(), http.StatusNotImplemented) return diff --git a/internal/server/shared_projects_test.go b/internal/server/shared_projects_test.go index f6ab33f..5955fff 100644 --- a/internal/server/shared_projects_test.go +++ b/internal/server/shared_projects_test.go @@ -16,11 +16,11 @@ import ( func TestSharedProjectListAndConversation(t *testing.T) { srv, st := newAuthTestServer(t) h := srv.Handler() - alice, err := st.CreateAPIKey("alice", "e2e") + alice, err := createLegacyAPIKeyFixture(t, st, "alice", "e2e") if err != nil { t.Fatal(err) } - bob, err := st.CreateAPIKey("bob", "e2e") + bob, err := createLegacyAPIKeyFixture(t, st, "bob", "e2e") if err != nil { t.Fatal(err) } diff --git a/internal/server/shared_retrieval_test.go b/internal/server/shared_retrieval_test.go index f4a1327..69f8b66 100644 --- a/internal/server/shared_retrieval_test.go +++ b/internal/server/shared_retrieval_test.go @@ -19,11 +19,11 @@ import ( func TestSharedKnowledgeRetrieval(t *testing.T) { srv, st := newAuthTestServer(t) h := srv.Handler() - alice, err := st.CreateAPIKey("alice", "e2e") + alice, err := createLegacyAPIKeyFixture(t, st, "alice", "e2e") if err != nil { t.Fatal(err) } - bob, err := st.CreateAPIKey("bob", "e2e") + bob, err := createLegacyAPIKeyFixture(t, st, "bob", "e2e") if err != nil { t.Fatal(err) } @@ -94,11 +94,11 @@ func TestProjectSSEScopeGate(t *testing.T) { srv, st := newAuthTestServer(t) _ = st h := srv.Handler() - alice, err := st.CreateAPIKey("alice", "e2e") + alice, err := createLegacyAPIKeyFixture(t, st, "alice", "e2e") if err != nil { t.Fatal(err) } - bob, err := st.CreateAPIKey("bob", "e2e") + bob, err := createLegacyAPIKeyFixture(t, st, "bob", "e2e") if err != nil { t.Fatal(err) } @@ -147,11 +147,11 @@ func TestProjectSSEScopeGate(t *testing.T) { func TestSharedCitationRefDownloadable(t *testing.T) { srv, st := newAuthTestServer(t) h := srv.Handler() - alice, err := st.CreateAPIKey("alice", "e2e") + alice, err := createLegacyAPIKeyFixture(t, st, "alice", "e2e") if err != nil { t.Fatal(err) } - bob, err := st.CreateAPIKey("bob", "e2e") + bob, err := createLegacyAPIKeyFixture(t, st, "bob", "e2e") if err != nil { t.Fatal(err) } diff --git a/internal/server/surface_api_test.go b/internal/server/surface_api_test.go index 40f8e39..a5635bf 100644 --- a/internal/server/surface_api_test.go +++ b/internal/server/surface_api_test.go @@ -18,7 +18,7 @@ import ( func TestSurfaceDirectAPIGuards(t *testing.T) { srv, st := newAuthTestServer(t) h := srv.Handler() - key, err := st.CreateAPIKey("alice", "e2e") + key, err := createLegacyAPIKeyFixture(t, st, "alice", "e2e") if err != nil { t.Fatal(err) } @@ -94,7 +94,7 @@ func TestSurfaceDirectAPIGuards(t *testing.T) { func TestSurfaceActionEndpoint(t *testing.T) { srv, st := newAuthTestServer(t) h := srv.Handler() - key, _ := st.CreateAPIKey("alice", "e2e") + key, _ := createLegacyAPIKeyFixture(t, st, "alice", "e2e") svc := surfacesvc.New(st, nil) svc.SetDispatcher(func(ctx context.Context, d surfacesvc.ActionDispatch) (surfacesvc.ActionOutcome, error) { return surfacesvc.ActionOutcome{State: "committed", Receipt: map[string]any{"ok": true}}, nil diff --git a/internal/settings/secrets.go b/internal/settings/secrets.go index 712d59f..f6973ef 100644 --- a/internal/settings/secrets.go +++ b/internal/settings/secrets.go @@ -79,6 +79,24 @@ func (s *Service) snapshotThrottled(owner, scope string) { _, _ = s.st.SaveSnapshotIfChanged(owner, scope, string(raw), "save") } +// snapshotThrottledAccountChecked keeps the best-effort snapshot behavior of +// settings writes while fencing its separate final insert transaction. A +// disable between the settings commit and this snapshot can omit the snapshot. +func (s *Service) snapshotThrottledAccountChecked(actor, owner, scope string, allowImplicitDefault bool) { + if at, ok := s.st.LatestSnapshotAt(owner, scope); ok && time.Since(at) < 10*time.Minute { + return + } + cur, err := s.st.GetSettingsScope(owner, scope) + if err != nil { + return + } + raw, err := json.Marshal(cur) + if err != nil { + return + } + _, _ = s.st.SaveSnapshotIfChangedAccountChecked(actor, owner, scope, string(raw), "save", allowImplicitDefault) +} + func lookupString(m map[string]any, path string) (string, bool) { var cur any = m for _, p := range strings.Split(path, ".") { diff --git a/internal/settings/settings.go b/internal/settings/settings.go index c07074c..b6ea8a4 100644 --- a/internal/settings/settings.go +++ b/internal/settings/settings.go @@ -233,6 +233,39 @@ func (s *Service) MergeSystem(values map[string]any) error { return s.mergeScope(OwnerSystemRow, ScopeSystem, values) } +// MergeUserAccountChecked and MergeProjectAccountChecked are request-facing +// writes. Their final Store transaction locks and checks the actor before the +// scope lock and write; startup migration keeps using the legacy methods. +func (s *Service) MergeUserAccountChecked(actor string, values map[string]any, allowImplicitDefault bool) error { + return s.mergeScopeAccountChecked(actor, actor, ScopeUser, values, allowImplicitDefault) +} + +func (s *Service) MergeProjectAccountChecked(actor, projectID string, values map[string]any, allowImplicitDefault bool) error { + return s.mergeScopeAccountChecked(actor, actor, ScopeProjectPrefix+projectID, values, allowImplicitDefault) +} + +// MergeSystemAccountChecked persists under the synthetic system owner while +// checking the real request actor's enabled state and administrator role. +func (s *Service) MergeSystemAccountChecked(actor string, values map[string]any, allowImplicitDefault bool) error { + return s.mergeScopeAccountChecked(actor, OwnerSystemRow, ScopeSystem, values, allowImplicitDefault) +} + +func (s *Service) mergeScopeAccountChecked(actor, owner, scope string, values map[string]any, allowImplicitDefault bool) error { + incoming := prepareWrite(values) + if scope != ScopeSystem { + if err := s.checkManagedWrite(incoming); err != nil { + return err + } + } + if err := s.st.UpdateSettingsScopeAccountChecked(actor, owner, scope, allowImplicitDefault, func(cur map[string]any) (map[string]any, error) { + return core.MergeSettings(normalizeKeys(cur), incoming), nil + }); err != nil { + return err + } + s.snapshotThrottledAccountChecked(actor, owner, scope, allowImplicitDefault) + return nil +} + // mergeScope 先把库里和本次写入的点路径键展开成嵌套,再深合并。 // 租户层(user/project)触碰受管路径时整笔拒绝。掩码占位符视为未修改,不覆盖已存密钥。 func (s *Service) mergeScope(owner, scope string, values map[string]any) error { diff --git a/internal/settings/settings_account_write_test.go b/internal/settings/settings_account_write_test.go new file mode 100644 index 0000000..e3decae --- /dev/null +++ b/internal/settings/settings_account_write_test.go @@ -0,0 +1,106 @@ +package settings + +import ( + "errors" + "path/filepath" + "testing" + + "agent-system-backend/internal/store" +) + +func TestGuardedSettingsMergePreservesManagedPathsAndEffectiveState(t *testing.T) { + st, err := store.OpenSQLite(filepath.Join(t.TempDir(), "settings.db")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { st.Close() }) + svc := NewService(st) + for _, a := range []struct { + owner string + admin bool + }{{"admin", true}, {"alice", false}} { + if err := st.CreateAccount(a.owner, "", "", a.admin); err != nil { + t.Fatal(err) + } + } + project, err := st.CreateProject("alice", "owned") + if err != nil { + t.Fatal(err) + } + if err := svc.MergeUserAccountChecked("alice", map[string]any{"models.chat.apiKey": "forbidden"}, false); err == nil { + t.Fatal("guarded user merge accepted managed path") + } else { + var managed *ErrManagedPath + if !errors.As(err, &managed) { + t.Fatalf("managed path error lost: %v", err) + } + } + if err := svc.MergeUserAccountChecked("alice", map[string]any{"ui.theme": "light"}, false); err != nil { + t.Fatal(err) + } + if err := svc.MergeProjectAccountChecked("alice", project.ID, map[string]any{"context.maxHistoryMessages": 12}, false); err != nil { + t.Fatal(err) + } + if err := svc.MergeSystemAccountChecked("alice", map[string]any{"models.chat.apiKey": "forbidden"}, false); !errors.Is(err, store.ErrAccountForbidden) { + t.Fatalf("non-admin wrote system settings: %v", err) + } + if err := svc.MergeSystemAccountChecked("admin", map[string]any{"models.chat.apiKey": "system-secret"}, false); err != nil { + t.Fatal(err) + } + if got := svc.Effective("alice", project.ID); got.GetString("models.chat.apiKey", "") != "system-secret" || got.GetString("ui.theme", "") != "light" { + t.Fatalf("guarded merges did not produce effective settings: %#v", got) + } + for _, scope := range []struct{ owner, name string }{{"alice", ScopeUser}, {"alice", ScopeProjectPrefix + project.ID}, {OwnerSystemRow, ScopeSystem}} { + var count int + if err := st.DB().QueryRow(`SELECT COUNT(*) FROM settings_snapshots WHERE owner=? AND scope=?`, scope.owner, scope.name).Scan(&count); err != nil || count != 1 { + t.Fatalf("guarded merge snapshot %s/%s count=%d err=%v", scope.owner, scope.name, count, err) + } + } + account, err := st.GetAccountByOwner("alice") + if err != nil { + t.Fatal(err) + } + if _, err := st.SetAccountEnabled("admin", account.ID, account.StateVersion, false); err != nil { + t.Fatal(err) + } + if err := svc.MergeUserAccountChecked("alice", map[string]any{"ui.theme": "dark"}, false); !errors.Is(err, store.ErrAccountForbidden) { + t.Fatalf("disabled user merge admitted: %v", err) + } + if err := svc.MergeProjectAccountChecked("alice", project.ID, map[string]any{"context.maxHistoryMessages": 99}, false); !errors.Is(err, store.ErrAccountForbidden) { + t.Fatalf("disabled project merge admitted: %v", err) + } + if got := svc.Effective("alice", project.ID); got.GetString("ui.theme", "") != "light" { + t.Fatalf("disabled merge changed effective settings: %#v", got) + } +} + +func TestGuardedSystemMergeAllowsImplicitLocalAndRejectsExplicitDisable(t *testing.T) { + st, err := store.OpenSQLite(filepath.Join(t.TempDir(), "settings.db")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { st.Close() }) + svc := NewService(st) + if err := svc.MergeSystemAccountChecked(store.OwnerDefault, map[string]any{"runtime.mock": true}, false); !errors.Is(err, store.ErrAccountForbidden) { + t.Fatalf("implicit local system write admitted without allowance: %v", err) + } + if err := svc.MergeSystemAccountChecked(store.OwnerDefault, map[string]any{"runtime.mock": true}, true); err != nil { + t.Fatal(err) + } + if err := st.CreateAccount("admin", "", "", true); err != nil { + t.Fatal(err) + } + if err := st.EnsureUser(store.OwnerDefault, ""); err != nil { + t.Fatal(err) + } + account, err := st.GetAccountByOwner(store.OwnerDefault) + if err != nil { + t.Fatal(err) + } + if _, err := st.SetAccountEnabled("admin", account.ID, account.StateVersion, false); err != nil { + t.Fatal(err) + } + if err := svc.MergeSystemAccountChecked(store.OwnerDefault, map[string]any{"runtime.mock": false}, true); !errors.Is(err, store.ErrAccountForbidden) { + t.Fatalf("disabled local system write admitted: %v", err) + } +} diff --git a/internal/store/account_budget_read_test.go b/internal/store/account_budget_read_test.go new file mode 100644 index 0000000..3b3bb25 --- /dev/null +++ b/internal/store/account_budget_read_test.go @@ -0,0 +1,15 @@ +package store + +import "testing" + +func TestZeroTokenBudgetLookupDoesNotWrite(t *testing.T) { + st := openTestStore(t) + status, err := st.AccountTokens("viewer", 0, 100) + if err != nil || status.Total != 0 || status.Over { + t.Fatalf("empty budget lookup: %+v %v", status, err) + } + var rows int + if err := st.DB().QueryRow(`SELECT COUNT(*) FROM llm_usage_daily WHERE owner='viewer'`).Scan(&rows); err != nil || rows != 0 { + t.Fatalf("read-only budget lookup wrote %d rows: %v", rows, err) + } +} diff --git a/internal/store/account_cursor_key_test.go b/internal/store/account_cursor_key_test.go new file mode 100644 index 0000000..c953e3d --- /dev/null +++ b/internal/store/account_cursor_key_test.go @@ -0,0 +1,93 @@ +package store + +import ( + "os" + "path/filepath" + "sync" + "testing" +) + +func TestAccountCursorKeySharedAcrossStoreOpens(t *testing.T) { + path := filepath.Join(t.TempDir(), "accounts.db") + first, err := OpenSQLite(path) + if err != nil { + t.Fatal(err) + } + defer first.Close() + second, err := OpenExistingSQLite(path) + if err != nil { + t.Fatal(err) + } + defer second.Close() + key1, err := first.GetAccountCursorKey() + if err != nil { + t.Fatal(err) + } + key2, err := second.GetAccountCursorKey() + if err != nil { + t.Fatal(err) + } + if key1.Version != 1 || key1 != key2 || key1.Secret == ([32]byte{}) { + t.Fatal("distinct instances did not share a nonzero versioned key") + } +} + +func TestAccountCursorKeyMigrationAddsToExistingU3Schema(t *testing.T) { + path := filepath.Join(t.TempDir(), "accounts.db") + st, err := OpenSQLite(path) + if err != nil { + t.Fatal(err) + } + if err := st.CreateAccount("existing", "Existing", "", false); err != nil { + t.Fatal(err) + } + if err := st.MigrateDown(33); err != nil { + t.Fatal(err) + } + st.Close() + st, err = OpenSQLite(path) + if err != nil { + t.Fatal(err) + } + defer st.Close() + if _, err := st.GetAccountByOwner("existing"); err != nil { + t.Fatalf("v34 migration lost existing account: %v", err) + } + if key, err := st.GetAccountCursorKey(); err != nil || key.Version != 1 { + t.Fatalf("v34 migration failed to initialize key: %+v %v", key.Version, err) + } +} + +func TestPGAccountCursorKeyConcurrentInstances(t *testing.T) { + baseDSN := os.Getenv("AGENT_PG_TEST_DSN") + if baseDSN == "" { + t.Skip("isolated PostgreSQL test DSN required") + } + first := openTestStore(t) + if err := first.EnsureSystemIdentity("cursor-key-test", "pg"); err != nil { + t.Fatal(err) + } + var schema string + if err := first.DB().QueryRow(`SELECT current_schema()`).Scan(&schema); err != nil { + t.Fatal(err) + } + second, err := OpenExistingPostgres(withTestSearchPath(baseDSN, schema), filepath.Join(t.TempDir(), "artifacts")) + if err != nil { + t.Fatal(err) + } + defer second.Close() + var keys [2]AccountCursorKey + var failures [2]error + var wg sync.WaitGroup + for i, st := range []*Store{first, second} { + wg.Add(1) + go func() { + defer wg.Done() + keys[i], failures[i] = st.GetAccountCursorKey() + }() + } + wg.Wait() + if failures[0] != nil || failures[1] != nil || keys[0] != keys[1] || keys[0].Version != 1 || keys[0].Secret == ([32]byte{}) { + t.Fatalf("concurrent instances did not converge: %v %v", failures[0], failures[1]) + } +} diff --git a/internal/store/account_event_fence_test.go b/internal/store/account_event_fence_test.go new file mode 100644 index 0000000..731b983 --- /dev/null +++ b/internal/store/account_event_fence_test.go @@ -0,0 +1,47 @@ +package store + +import ( + "errors" + "testing" + + core "agent-system-core" +) + +func TestAccountCheckedEventAppendStopsAfterDisable(t *testing.T) { + st := openTestStore(t) + if err := st.CreateAccount("admin", "Admin", "hash", true); err != nil { + t.Fatal(err) + } + if err := st.CreateAccount("alice", "Alice", "hash", false); err != nil { + t.Fatal(err) + } + project, err := st.CreateProject("alice", "fixture") + if err != nil { + t.Fatal(err) + } + conversation, err := st.CreateConversation("alice", string(project.ID), "fixture", "") + if err != nil { + t.Fatal(err) + } + one := []core.StreamEvent{{ConversationID: string(conversation.ID), Type: core.EvCardCreated, V: core.ProtocolVersion}} + if _, err := st.AppendEventsAccountChecked("admin", string(conversation.ID), one, false); !errors.Is(err, ErrNotFound) { + t.Fatalf("administrator bypassed conversation owner: %v", err) + } + if _, err := st.AppendEventsAccountChecked("alice", string(conversation.ID), one, false); err != nil { + t.Fatal(err) + } + account, err := st.GetAccountByOwner("alice") + if err != nil { + t.Fatal(err) + } + if _, err := st.SetAccountEnabled("admin", account.ID, account.StateVersion, false); err != nil { + t.Fatal(err) + } + if _, err := st.AppendEventsAccountChecked("alice", string(conversation.ID), one, false); !errors.Is(err, ErrAccountForbidden) { + t.Fatalf("disabled account appended event: %v", err) + } + var count int + if err := st.DB().QueryRow(st.q(`SELECT COUNT(*) FROM events WHERE conversation_id=?`), conversation.ID).Scan(&count); err != nil || count != 1 { + t.Fatalf("event count=%d err=%v", count, err) + } +} diff --git a/internal/store/account_lock_order_pg_test.go b/internal/store/account_lock_order_pg_test.go new file mode 100644 index 0000000..439e2ae --- /dev/null +++ b/internal/store/account_lock_order_pg_test.go @@ -0,0 +1,60 @@ +package store + +import ( + "errors" + "os" + "path/filepath" + "testing" + "time" +) + +func TestPGSameOwnerBootstrapAndCreateSerialize(t *testing.T) { + baseDSN := os.Getenv("AGENT_PG_TEST_DSN") + if baseDSN == "" { + t.Skip("isolated PostgreSQL test DSN required") + } + first := openTestStore(t) + if err := first.EnsureSystemIdentity("same-owner-admission", "pg"); err != nil { + t.Fatal(err) + } + var schema string + if err := first.DB().QueryRow(`SELECT current_schema()`).Scan(&schema); err != nil { + t.Fatal(err) + } + second, err := OpenExistingPostgres(withTestSearchPath(baseDSN, schema), filepath.Join(t.TempDir(), "artifacts")) + if err != nil { + t.Fatal(err) + } + defer second.Close() + start := make(chan struct{}) + results := make(chan error, 2) + go func() { + <-start + _, err := first.CreateActivatedAccount(AccountWrite{Owner: "same", Name: "Same", Purpose: LoginKeyPurposeActivate, Actor: "fixture", CreatedBy: "fixture"}) + results <- err + }() + go func() { + <-start + _, err := second.BootstrapFirstAdminWrite(AccountWrite{Owner: "same", Name: "Same", Actor: "fixture", CreatedBy: "fixture"}) + results <- err + }() + close(start) + wins, conflicts := 0, 0 + for range 2 { + select { + case err := <-results: + if err == nil { + wins++ + } else if errors.Is(err, ErrConflict) { + conflicts++ + } else { + t.Fatalf("same-owner admission returned %v", err) + } + case <-time.After(5 * time.Second): + t.Fatal("account/bootstrap lock ordering did not make progress") + } + } + if wins != 1 || conflicts != 1 { + t.Fatalf("wins=%d conflicts=%d, want 1/1", wins, conflicts) + } +} diff --git a/internal/store/account_model.go b/internal/store/account_model.go index 92bc682..7c81fb6 100644 --- a/internal/store/account_model.go +++ b/internal/store/account_model.go @@ -72,6 +72,11 @@ type AccountDirectoryRevision struct { Private string } +type AccountCursorKey struct { + Version int + Secret [32]byte +} + const accountColumns = `id,owner,COALESCE(name,''),phone,email,staff_no,department,notes, profile_version,enabled,state_version,is_admin,password_hash<>'',must_change_password, auth_version,credential_origin,initialization_state,initialization_generation` @@ -331,6 +336,38 @@ func (s *Store) GetAccountDirectoryRevision() (AccountDirectoryRevision, error) return revision, err } +// GetAccountCursorKey initializes one random signing key in the shared account +// database. The compare-and-set update lets concurrent Backend instances +// converge on the same key without exposing it through directory responses. +func (s *Store) GetAccountCursorKey() (AccountCursorKey, error) { + var key AccountCursorKey + var generated [32]byte + if _, err := rand.Read(generated[:]); err != nil { + return key, err + } + tx, err := s.db.Begin() + if err != nil { + return key, err + } + defer tx.Rollback() + if _, err := tx.Exec(s.q(`UPDATE account_directory_revision SET cursor_secret=? WHERE id=1 AND cursor_secret=''`), hex.EncodeToString(generated[:])); err != nil { + return key, err + } + var encoded string + if err := tx.QueryRow(`SELECT cursor_key_version,cursor_secret FROM account_directory_revision WHERE id=1`).Scan(&key.Version, &encoded); err != nil { + return key, err + } + decoded, err := hex.DecodeString(encoded) + if err != nil || len(decoded) != len(key.Secret) || key.Version < 1 { + return AccountCursorKey{}, ErrConflict + } + copy(key.Secret[:], decoded) + if err := tx.Commit(); err != nil { + return AccountCursorKey{}, err + } + return key, nil +} + func newAccountDirectoryRevision() (string, error) { var bytes [16]byte if _, err := rand.Read(bytes[:]); err != nil { diff --git a/internal/store/account_state.go b/internal/store/account_state.go index 4494718..87deb36 100644 --- a/internal/store/account_state.go +++ b/internal/store/account_state.go @@ -2,6 +2,7 @@ package store import ( "errors" + "sort" ) // SetAccountEnabled changes only the effective account state. The actor and @@ -12,11 +13,25 @@ func (s *Store) SetAccountEnabled(actorOwner, userID string, expectedStateVersio if expectedStateVersion < 1 { return Account{}, ErrConflict } + pre, err := s.GetAccount(userID) + if err != nil { + return Account{}, err + } tx, err := s.db.Begin() if err != nil { return Account{}, err } defer tx.Rollback() + owners := []string{actorOwner, pre.Owner} + sort.Strings(owners) + for i, owner := range owners { + if i > 0 && owner == owners[i-1] { + continue + } + if err := s.dialect.lockAccount(tx, owner); err != nil { + return Account{}, err + } + } if err := s.dialect.lockBootstrap(tx); err != nil { return Account{}, err } diff --git a/internal/store/account_tx.go b/internal/store/account_tx.go index cc79947..7709a7f 100644 --- a/internal/store/account_tx.go +++ b/internal/store/account_tx.go @@ -223,11 +223,6 @@ func (s *Store) accountTx(w AccountWrite, fn func(*sql.Tx, string) error) error _ = tx.Rollback() } }() - if w.Action == "account.bootstrap" || w.Action == "account.create.local_pending" { - if err := s.dialect.lockBootstrap(tx); err != nil { - return err - } - } if w.Owner != "" { if err := s.dialect.lockAccount(tx, w.Owner); err != nil { return err diff --git a/internal/store/account_write_guard.go b/internal/store/account_write_guard.go new file mode 100644 index 0000000..a5acf66 --- /dev/null +++ b/internal/store/account_write_guard.go @@ -0,0 +1,40 @@ +package store + +import ( + "database/sql" + "errors" +) + +// withEffectiveAccountWrite runs a Backend-owned business mutation after the +// actor's effective state has been locked and checked in the final transaction. +// Disable uses the same account advisory/user-row lock, so it cannot commit +// before a write that has passed this check, nor can a later write pass after +// disable commits. Callers acquire resource and scope locks only inside fn. +func (s *Store) withEffectiveAccountWrite(actor string, allowImplicitDefault bool, fn func(*sql.Tx, Account) error) error { + if actor == "" || fn == nil { + return ErrAccountForbidden + } + tx, err := s.db.Begin() + if err != nil { + return err + } + defer tx.Rollback() + accounts, err := s.lockAccountUsers(tx, actor) + var account Account + if errors.Is(err, ErrNotFound) && allowImplicitDefault && actor == OwnerDefault { + account = Account{Owner: OwnerDefault, Enabled: true} + } else if errors.Is(err, ErrNotFound) { + return ErrAccountForbidden + } else if err != nil { + return err + } else { + account = accounts[actor] + } + if !account.Enabled { + return ErrAccountForbidden + } + if err := fn(tx, account); err != nil { + return err + } + return tx.Commit() +} diff --git a/internal/store/admission.go b/internal/store/admission.go index c9393fb..d6f88db 100644 --- a/internal/store/admission.go +++ b/internal/store/admission.go @@ -38,6 +38,37 @@ func (s *Store) TryAcquireExecutionSlot(runID, owner string, budget ExecutionBud if err := s.lockAdmission(tx); err != nil { return err } + if err := s.acquireExecutionSlotTx(tx, runID, owner, budget); err != nil { + return err + } + return tx.Commit() +} + +// TryAcquireExecutionSlotAccountChecked takes the actor account lock before +// the global admission lock. The run row, not a caller-supplied owner string, +// supplies the authoritative owner for a new or reactivated slot. +func (s *Store) TryAcquireExecutionSlotAccountChecked(actor, runID string, budget ExecutionBudget, allowImplicitDefault bool) error { + if budget.Off() { + return nil // no lease row or other DB mutation + } + return s.withEffectiveAccountWrite(actor, allowImplicitDefault, func(tx *sql.Tx, _ Account) error { + if err := s.lockAdmission(tx); err != nil { + return err + } + var owner string + if err := tx.QueryRow(s.q(`SELECT owner FROM runs WHERE id=?`), runID).Scan(&owner); errors.Is(err, sql.ErrNoRows) { + return ErrNotFound + } else if err != nil { + return err + } + if owner != actor { + return ErrNotFound + } + return s.acquireExecutionSlotTx(tx, runID, actor, budget) + }) +} + +func (s *Store) acquireExecutionSlotTx(tx *sql.Tx, runID, owner string, budget ExecutionBudget) error { var global, perUser int if budget.Global > 0 { if err := tx.QueryRow(s.q(`SELECT COUNT(*) FROM admission_leases WHERE released_at IS NULL AND run_id<>?`), runID).Scan(&global); err != nil { @@ -60,7 +91,7 @@ func (s *Store) TryAcquireExecutionSlot(runID, owner string, budget ExecutionBud runID, owner, nowISO()); err != nil { return err } - return tx.Commit() + return nil } // ReleaseExecutionSlot 释放执行许可(幂等):父 run 等待子代理/HITL 时让出, diff --git a/internal/store/artifact_account_write_test.go b/internal/store/artifact_account_write_test.go new file mode 100644 index 0000000..bde8b69 --- /dev/null +++ b/internal/store/artifact_account_write_test.go @@ -0,0 +1,212 @@ +package store + +import ( + "errors" + "os" + "path/filepath" + "testing" + "time" + + "agent-system-backend/internal/blobstore" + core "agent-system-core" +) + +type artifactAccountWriter interface { + CreateArtifactAccountChecked(actor string, art *core.Artifact, content []byte, allowImplicitDefault bool) error + UpdateArtifactContentAccountChecked(actor, id string, content []byte, allowImplicitDefault bool) error + DeleteArtifactAccountChecked(actor, id string, allowImplicitDefault bool) error +} + +func artifactWriter(t *testing.T, st *Store) artifactAccountWriter { + t.Helper() + w, ok := any(st).(artifactAccountWriter) + if !ok { + t.Fatal("Store is missing the account-checked artifact write API") + } + return w +} + +func openArtifactFenceStore(t *testing.T, path string) *Store { + t.Helper() + st, err := OpenSQLite(path) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = st.Close() }) + return st +} + +func artifactRef(t *testing.T, st *Store, id string) string { + t.Helper() + var ref string + if err := st.DB().QueryRow(`SELECT content_path FROM artifacts WHERE id=?`, id).Scan(&ref); err != nil { + t.Fatal(err) + } + return ref +} + +func TestArtifactAccountFenceCreateRejectsDisabledAndForeignOwner(t *testing.T) { + st := openArtifactFenceStore(t, filepath.Join(t.TempDir(), "account-artifacts.db")) + w := artifactWriter(t, st) + for _, owner := range []string{"alice", "bob"} { + if err := st.EnsureUser(owner, ""); err != nil { + t.Fatal(err) + } + } + foreign := core.Artifact{Owner: "alice", ProjectID: "project", Name: "foreign.txt", Kind: "data"} + if err := w.CreateArtifactAccountChecked("bob", &foreign, []byte("wrong"), false); !errors.Is(err, ErrNotFound) { + t.Fatalf("foreign-owner create: got %v, want ErrNotFound", err) + } + if _, err := st.DB().Exec(`UPDATE users SET enabled=FALSE WHERE owner='alice'`); err != nil { + t.Fatal(err) + } + blocked := core.Artifact{Owner: "alice", ProjectID: "project", Name: "blocked.txt", Kind: "data"} + if err := w.CreateArtifactAccountChecked("alice", &blocked, []byte("late"), false); !errors.Is(err, ErrAccountForbidden) { + t.Fatalf("disabled create: got %v, want ErrAccountForbidden", err) + } + var n int + if err := st.DB().QueryRow(`SELECT COUNT(*) FROM artifacts`).Scan(&n); err != nil || n != 0 { + t.Fatalf("rejected creates left %d artifact rows: %v", n, err) + } + files, err := os.ReadDir(st.DataDir()) + if err != nil || len(files) != 0 { + t.Fatalf("rejected creates left staged blobs: %d entries, %v", len(files), err) + } +} + +type pauseAfterArtifactPut struct { + blobstore.Store + putDone chan struct{} + resume chan struct{} +} + +func (p *pauseAfterArtifactPut) Put(id string, content []byte) (string, error) { + ref, err := p.Store.Put(id, content) + if err == nil { + p.putDone <- struct{}{} + <-p.resume + } + return ref, err +} + +func TestArtifactAccountFenceUpdatePreservesVisibleBytesWhenDisableWins(t *testing.T) { + path := filepath.Join(t.TempDir(), "account-artifacts.db") + st := openArtifactFenceStore(t, path) + other := openArtifactFenceStore(t, path) + w := artifactWriter(t, st) + if err := st.EnsureUser("alice", ""); err != nil { + t.Fatal(err) + } + art := core.Artifact{Owner: "alice", ProjectID: "project", Name: "minutes.txt", Kind: "data"} + if err := st.CreateArtifact(&art, []byte("original")); err != nil { + t.Fatal(err) + } + oldRef := artifactRef(t, st, art.ID) + pause := &pauseAfterArtifactPut{Store: st.blobs, putDone: make(chan struct{}), resume: make(chan struct{})} + st.blobs = pause + result := make(chan error, 1) + go func() { result <- w.UpdateArtifactContentAccountChecked("alice", art.ID, []byte("late edit"), false) }() + select { + case <-pause.putDone: + case <-time.After(5 * time.Second): + close(pause.resume) + t.Fatal("new blob was not staged") + } + if _, err := other.DB().Exec(`UPDATE users SET enabled=FALSE WHERE owner='alice'`); err != nil { + close(pause.resume) + t.Fatal(err) + } + close(pause.resume) + if err := <-result; !errors.Is(err, ErrAccountForbidden) { + t.Fatalf("late update: got %v, want ErrAccountForbidden", err) + } + if err := w.DeleteArtifactAccountChecked("alice", art.ID, false); !errors.Is(err, ErrAccountForbidden) { + t.Fatalf("disabled delete: got %v, want ErrAccountForbidden", err) + } + if got, err := st.ArtifactContent("alice", art.ID); err != nil || string(got) != "original" { + t.Fatalf("visible content after rejected update: %q, %v", got, err) + } + if got := artifactRef(t, st, art.ID); got != oldRef { + t.Fatalf("rejected update switched content ref: %q -> %q", oldRef, got) + } + files, err := os.ReadDir(st.DataDir()) + if err != nil || len(files) != 1 { + t.Fatalf("rejected update left staged blob: %d entries, %v", len(files), err) + } +} + +func TestArtifactAccountFenceImplicitLocalRejectsRecordedDisable(t *testing.T) { + st := openArtifactFenceStore(t, filepath.Join(t.TempDir(), "account-artifacts.db")) + w := artifactWriter(t, st) + art := core.Artifact{Owner: OwnerDefault, ProjectID: "project", Name: "local.txt", Kind: "data"} + if err := w.CreateArtifactAccountChecked(OwnerDefault, &art, []byte("embedded"), true); err != nil { + t.Fatalf("missing implicit local account should be effective: %v", err) + } + if err := st.EnsureUser(OwnerDefault, ""); err != nil { + t.Fatal(err) + } + if _, err := st.DB().Exec(`UPDATE users SET enabled=FALSE WHERE owner=?`, OwnerDefault); err != nil { + t.Fatal(err) + } + if err := w.UpdateArtifactContentAccountChecked(OwnerDefault, art.ID, []byte("late"), true); !errors.Is(err, ErrAccountForbidden) { + t.Fatalf("recorded disabled local account update: got %v, want ErrAccountForbidden", err) + } + if got, err := st.ArtifactContent(OwnerDefault, art.ID); err != nil || string(got) != "embedded" { + t.Fatalf("disabled local account changed visible content: %q, %v", got, err) + } +} + +func TestArtifactAccountFenceUpdateAndDeleteKeepOwnerAndIndexIsolation(t *testing.T) { + st := openArtifactFenceStore(t, filepath.Join(t.TempDir(), "account-artifacts.db")) + w := artifactWriter(t, st) + for _, owner := range []string{"alice", "bob"} { + if err := st.EnsureUser(owner, ""); err != nil { + t.Fatal(err) + } + } + art := core.Artifact{Owner: "alice", ProjectID: "project", Name: "minutes.txt", Kind: "data"} + if err := w.CreateArtifactAccountChecked("alice", &art, []byte("original"), false); err != nil { + t.Fatal(err) + } + firstRef := artifactRef(t, st, art.ID) + if err := w.UpdateArtifactContentAccountChecked("bob", art.ID, []byte("foreign"), false); !errors.Is(err, ErrNotFound) { + t.Fatalf("foreign update: got %v, want ErrNotFound", err) + } + if err := w.DeleteArtifactAccountChecked("bob", art.ID, false); !errors.Is(err, ErrNotFound) { + t.Fatalf("foreign delete: got %v, want ErrNotFound", err) + } + if got, err := st.ArtifactContent("alice", art.ID); err != nil || string(got) != "original" { + t.Fatalf("foreign actor changed content: %q, %v", got, err) + } + if err := w.UpdateArtifactContentAccountChecked("alice", art.ID, []byte("replacement"), false); err != nil { + t.Fatal(err) + } + if got, err := st.ArtifactContent("alice", art.ID); err != nil || string(got) != "replacement" { + t.Fatalf("guarded update content: %q, %v", got, err) + } + if ref := artifactRef(t, st, art.ID); ref == firstRef { + t.Fatal("guarded update overwrote the visible blob ref") + } + if _, err := os.Stat(firstRef); !os.IsNotExist(err) { + t.Fatalf("old blob remains after successful switch: %v", err) + } + doc := core.KnowledgeDoc{ID: "knw-artifact-fence", Owner: "alice", Ref: art.ID, Source: "artifact", Title: "minutes", Chunk: "indexed content"} + if err := st.AddKnowledge(doc); err != nil { + t.Fatal(err) + } + if err := st.UpsertKnowledgeVectorExt(t.Context(), "alice", string(doc.ID), art.ID, "test", "hash", []float32{1, 0}); err != nil { + t.Fatal(err) + } + if err := w.DeleteArtifactAccountChecked("alice", art.ID, false); err != nil { + t.Fatal(err) + } + if _, err := st.GetArtifact("alice", art.ID); !errors.Is(err, ErrNotFound) { + t.Fatalf("deleted artifact still visible: %v", err) + } + for _, table := range []string{"knowledge", "knowledge_vectors"} { + var n int + if err := st.DB().QueryRow(`SELECT COUNT(*) FROM `+table+` WHERE owner=? AND ref=?`, "alice", art.ID).Scan(&n); err != nil || n != 0 { + t.Fatalf("%s rows after delete: %d, %v", table, n, err) + } + } +} diff --git a/internal/store/audit.go b/internal/store/audit.go index b5479a8..a0a45fb 100644 --- a/internal/store/audit.go +++ b/internal/store/audit.go @@ -23,6 +23,22 @@ func (s *Store) AppendAudit(owner string, env agentcommand.AuditEnvelope) error return err } +// AppendAuditAccountChecked fences command result envelopes against account +// disable. Security-denial audit uses its separate account-state path. +func (s *Store) AppendAuditAccountChecked(owner string, env agentcommand.AuditEnvelope, allowImplicitDefault bool) error { + raw, err := json.Marshal(env) + if err != nil { + return err + } + return s.withEffectiveAccountWrite(owner, allowImplicitDefault, func(tx *sql.Tx, _ Account) error { + _, err := tx.Exec(s.q(`INSERT INTO command_audit (time, command_id, source, entrance, state, operation_id, conversation_id, owner, payload) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`), + env.Time.UTC().Format(time.RFC3339Nano), env.CommandID, string(env.Source), env.Entrance, string(env.State), + env.OperationID, env.ConversationID, owner, string(raw)) + return err + }) +} + // ListAudits 审计查询(owner 作用域,时间倒序;conversationID 空 = 该租户全局)。 // owner 为空视为无作用域:不返回任何行(防调用方漏传租户时把未归属存量行 // 全部泄出——服务端认证层恒注入非空 owner,这里是纵深防御)。 diff --git a/internal/store/auth.go b/internal/store/auth.go index bfdfc0f..87085fa 100644 --- a/internal/store/auth.go +++ b/internal/store/auth.go @@ -81,9 +81,6 @@ func (s *Store) ListUsers() ([]User, error) { // CreateAPIKey 创建密钥:明文 `ask_` 仅此一次返回。 func (s *Store) CreateAPIKey(owner, name string) (string, error) { - if err := s.EnsureUser(owner, ""); err != nil { - return "", err - } raw := make([]byte, 16) if _, err := rand.Read(raw); err != nil { return "", err @@ -92,9 +89,39 @@ func (s *Store) CreateAPIKey(owner, name string) (string, error) { if name == "" { name = "key-" + nowISO()[:10] } - result, err := s.db.Exec(s.q(`INSERT INTO api_keys(key_hash,owner,name,created_at,revoked) - SELECT ?,owner,?,?,? FROM users WHERE owner=? AND enabled=?`), - hashKey(plain), name, nowISO(), 0, owner, true) + tx, err := s.db.Begin() + if err != nil { + return "", err + } + defer tx.Rollback() + if err := s.dialect.lockAccount(tx, owner); err != nil { + return "", err + } + // The account row lock and key insert share a transaction with disable. + // The version predicate also protects the admission decision if the row + // changes between the read and the insert on a different dialect. + var stateVersion int64 + var enabled bool + var initializationState string + query := s.q(`SELECT state_version,enabled,initialization_state FROM users WHERE owner=?`) + if s.Dialect() == "postgres" { + query += ` FOR UPDATE` + } + err = tx.QueryRow(query, owner).Scan(&stateVersion, &enabled, &initializationState) + if errors.Is(err, sql.ErrNoRows) { + return "", ErrAccountForbidden + } + if err != nil { + return "", err + } + if !enabled || (initializationState != InitializationInitialized && initializationState != InitializationLegacy) { + return "", ErrAccountForbidden + } + result, err := tx.Exec(s.q(`INSERT INTO api_keys(key_hash,owner,name,created_at,revoked) + SELECT ?,owner,?,?,? FROM users WHERE owner=? AND state_version=? AND enabled=? + AND initialization_state IN (?,?)`), + hashKey(plain), name, nowISO(), 0, owner, stateVersion, true, + InitializationInitialized, InitializationLegacy) if err != nil { return "", err } @@ -103,6 +130,9 @@ func (s *Store) CreateAPIKey(owner, name string) (string, error) { } else if n != 1 { return "", ErrAccountForbidden } + if err := tx.Commit(); err != nil { + return "", err + } return plain, nil } diff --git a/internal/store/auth_account_key_test.go b/internal/store/auth_account_key_test.go new file mode 100644 index 0000000..90ffcc2 --- /dev/null +++ b/internal/store/auth_account_key_test.go @@ -0,0 +1,132 @@ +package store + +import ( + "errors" + "testing" + "time" +) + +func TestCreateAPIKeyRequiresExistingEnabledReadyAccount(t *testing.T) { + st := openTestStore(t) + t.Logf("API key account admission dialect=%s", st.Dialect()) + if err := st.CreateAccount("admin", "", "admin-hash", true); err != nil { + t.Fatal(err) + } + if _, err := st.CreatePendingAccount("admin", "pending", AccountProfile{}); err != nil { + t.Fatal(err) + } + if err := st.EnsureUser("legacy", ""); err != nil { + t.Fatal(err) + } + if err := st.EnsureUser("disabled", ""); err != nil { + t.Fatal(err) + } + disabled, err := st.GetAccountByOwner("disabled") + if err != nil { + t.Fatal(err) + } + if _, err := st.SetAccountEnabled("admin", disabled.ID, disabled.StateVersion, false); err != nil { + t.Fatal(err) + } + ready, err := st.CreatePendingAccount("admin", "ready", AccountProfile{}) + if err != nil { + t.Fatal(err) + } + grant, err := st.InitializePendingAccount(ready.ID, ready.StateVersion, ready.InitializationGeneration) + if err != nil { + t.Fatal(err) + } + session, _, err := st.RedeemLoginKey(grant) + if err != nil { + t.Fatal(err) + } + hash, err := HashPassword("ready-password") + if err != nil { + t.Fatal(err) + } + if _, err := st.CompleteSetPasswordChecked(session, hash); err != nil { + t.Fatal(err) + } + + var usersBefore, keysBefore int + if err := st.db.QueryRow(`SELECT COUNT(*) FROM users`).Scan(&usersBefore); err != nil { + t.Fatal(err) + } + if err := st.db.QueryRow(`SELECT COUNT(*) FROM api_keys`).Scan(&keysBefore); err != nil { + t.Fatal(err) + } + for _, owner := range []string{"missing", "pending", "disabled"} { + if key, err := st.CreateAPIKey(owner, "rejected"); key != "" || !errors.Is(err, ErrAccountForbidden) { + t.Fatalf("%s received an API key: key=%q err=%v", owner, key, err) + } + var usersAfter, keysAfter int + if err := st.db.QueryRow(`SELECT COUNT(*) FROM users`).Scan(&usersAfter); err != nil { + t.Fatal(err) + } + if err := st.db.QueryRow(`SELECT COUNT(*) FROM api_keys`).Scan(&keysAfter); err != nil { + t.Fatal(err) + } + if usersAfter != usersBefore || keysAfter != keysBefore { + t.Fatalf("%s rejection changed rows: users %d->%d keys %d->%d", owner, usersBefore, usersAfter, keysBefore, keysAfter) + } + } + for _, owner := range []string{"legacy", "ready"} { + key, err := st.CreateAPIKey(owner, owner+"-integration") + if err != nil || key == "" { + t.Fatalf("%s could not receive an API key: %v", owner, err) + } + if got, ok := st.AuthOwner(key); !ok || got != owner { + t.Fatalf("%s key authenticated as %q, ok=%v", owner, got, ok) + } + } +} + +func TestPGCreateAPIKeyAndDisableSerialize(t *testing.T) { + st := openTestStore(t) + if st.Dialect() != "postgres" { + t.Skip("real PostgreSQL required") + } + other := reopenAccountModelStore(t, st) + if err := st.CreateAccount("admin", "", "admin-hash", true); err != nil { + t.Fatal(err) + } + if err := st.CreateAccount("owner", "", "", false); err != nil { + t.Fatal(err) + } + owner, err := st.GetAccountByOwner("owner") + if err != nil { + t.Fatal(err) + } + release := holdPGAccountWrite(t, st, "api_keys", "INSERT", "api_key") + type keyResult struct { + key string + err error + } + keyDone := make(chan keyResult, 1) + go func() { + key, err := st.CreateAPIKey("owner", "racing") + keyDone <- keyResult{key, err} + }() + waitForPGAccountWrite(t, st, "api_key") + disableDone := make(chan error, 1) + go func() { + _, err := other.SetAccountEnabled("admin", owner.ID, owner.StateVersion, false) + disableDone <- err + }() + select { + case err := <-disableDone: + t.Fatalf("disable committed ahead of the in-flight key issuance: %v", err) + case <-time.After(100 * time.Millisecond): + } + release() + issued := awaitPGAccountResult(t, keyDone) + if issued.err != nil || issued.key == "" { + t.Fatalf("in-flight issuance failed: %v", issued.err) + } + if err := awaitPGAccountResult(t, disableDone); err != nil { + t.Fatal(err) + } + if _, ok := st.AuthOwner(issued.key); ok { + t.Fatal("key issued before disable survived the disabling transaction") + } +} diff --git a/internal/store/borrow.go b/internal/store/borrow.go index 2eea5cc..26ac83e 100644 --- a/internal/store/borrow.go +++ b/internal/store/borrow.go @@ -163,6 +163,90 @@ func (s *Store) DeleteSecret(owner, id string) error { return nil } +// AddSecretAccountChecked fences the final secret write against account disable. +func (s *Store) AddSecretAccountChecked(actor, service, label, value string, allowImplicitDefault bool) (Secret, error) { + service, label, value = strings.TrimSpace(service), strings.TrimSpace(label), strings.TrimSpace(value) + if service == "" || value == "" { + return Secret{}, fmt.Errorf("store: secret service and value are required") + } + if label == "" { + label = "未命名" + } + sec := Secret{ID: newID("key"), Service: service, Label: label, Active: true, CreatedAt: nowISO(), Mask: maskSecret(value)} + err := s.withEffectiveAccountWrite(actor, allowImplicitDefault, func(tx *sql.Tx, _ Account) error { + if _, err := tx.Exec(s.q(`UPDATE secret_keys SET active=0 WHERE owner=? AND service=?`), actor, service); err != nil { + return err + } + _, err := tx.Exec(s.q(`INSERT INTO secret_keys(id,owner,service,label,value,active,created_at) VALUES(?,?,?,?,?,?,?)`), + sec.ID, actor, service, label, value, 1, sec.CreatedAt) + return err + }) + if err != nil { + return Secret{}, err + } + return sec, nil +} + +func (s *Store) ActivateSecretAccountChecked(actor, id string, allowImplicitDefault bool) error { + return s.withEffectiveAccountWrite(actor, allowImplicitDefault, func(tx *sql.Tx, _ Account) error { + var service string + err := tx.QueryRow(s.q(`SELECT service FROM secret_keys WHERE id=? AND owner=?`), id, actor).Scan(&service) + if err == sql.ErrNoRows { + return ErrNotFound + } + if err != nil { + return err + } + if _, err := tx.Exec(s.q(`UPDATE secret_keys SET active=0 WHERE owner=? AND service=?`), actor, service); err != nil { + return err + } + res, err := tx.Exec(s.q(`UPDATE secret_keys SET active=1 WHERE id=? AND owner=?`), id, actor) + if err != nil { + return err + } + if n, err := res.RowsAffected(); err != nil { + return err + } else if n != 1 { + return ErrNotFound + } + return nil + }) +} + +func (s *Store) RenameSecretAccountChecked(actor, id, label string, allowImplicitDefault bool) error { + label = strings.TrimSpace(label) + if label == "" { + return fmt.Errorf("store: label required") + } + return s.withEffectiveAccountWrite(actor, allowImplicitDefault, func(tx *sql.Tx, _ Account) error { + res, err := tx.Exec(s.q(`UPDATE secret_keys SET label=? WHERE id=? AND owner=?`), label, id, actor) + if err != nil { + return err + } + if n, err := res.RowsAffected(); err != nil { + return err + } else if n != 1 { + return ErrNotFound + } + return nil + }) +} + +func (s *Store) DeleteSecretAccountChecked(actor, id string, allowImplicitDefault bool) error { + return s.withEffectiveAccountWrite(actor, allowImplicitDefault, func(tx *sql.Tx, _ Account) error { + res, err := tx.Exec(s.q(`DELETE FROM secret_keys WHERE id=? AND owner=?`), id, actor) + if err != nil { + return err + } + if n, err := res.RowsAffected(); err != nil { + return err + } else if n != 1 { + return ErrNotFound + } + return nil + }) +} + func (s *Store) HasSecret(owner, service string) (bool, error) { var n int err := s.db.QueryRow(s.q(`SELECT COUNT(1) FROM secret_keys WHERE owner=? AND service=?`), owner, service).Scan(&n) @@ -184,6 +268,36 @@ func (s *Store) SaveSnapshotIfChanged(owner, scope, body, reason string) (bool, return err == nil, err } +// SaveSnapshotIfChangedAccountChecked fences the optional post-merge snapshot +// in its own final transaction. A disable between the settings commit and this +// transaction may suppress the snapshot, but cannot admit a late write. +func (s *Store) SaveSnapshotIfChangedAccountChecked(actor, owner, scope, body, reason string, allowImplicitDefault bool) (bool, error) { + var saved bool + err := 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 prev string + err := tx.QueryRow(s.q(`SELECT body FROM settings_snapshots WHERE owner=? AND scope=? ORDER BY created_at DESC LIMIT 1`), owner, scope).Scan(&prev) + if err == nil && prev == body { + return nil + } + if err != nil && err != sql.ErrNoRows { + return err + } + _, err = tx.Exec(s.q(`INSERT INTO settings_snapshots(id,owner,scope,body,reason,created_at) VALUES(?,?,?,?,?,?)`), + newID("snap"), owner, scope, body, reason, nowISO()) + if err == nil { + saved = true + } + return err + }) + return saved && err == nil, err +} + // LatestSnapshotAge 返回最近一份快照的时间。没有快照时 ok 为 false。 func (s *Store) LatestSnapshotAt(owner, scope string) (time.Time, bool) { var raw string @@ -280,6 +394,13 @@ func (s *Store) TakeRegenPending(conversationID string) (group string, index int return group, index, true } +// PeekRegenPending leaves the marker in place until settlement commits its +// assistant message, avoiding a disable/fence between consumption and settle. +func (s *Store) PeekRegenPending(conversationID string) (group string, index int, ok bool) { + err := s.db.QueryRow(s.q(`SELECT variant_group, variant_index FROM regen_pending WHERE conversation_id=?`), conversationID).Scan(&group, &index) + return group, index, err == nil +} + func (s *Store) ListContextEntries(owner, projectID, conversationID string) ([]ContextEntry, error) { rows, err := s.db.Query(s.q(`SELECT id,project_id,conversation_id,keys_json,secondary,logic,content,constant,scan_depth,sort_order,budget_chars,enabled FROM context_entries WHERE owner=? ORDER BY sort_order, created_at`), owner) @@ -378,6 +499,72 @@ func (s *Store) DeleteContextEntry(owner, id string) error { return nil } +func (s *Store) SaveContextEntryAccountChecked(actor string, e ContextEntry, allowImplicitDefault bool) (ContextEntry, error) { + if strings.TrimSpace(e.Content) == "" { + return ContextEntry{}, fmt.Errorf("store: entry content required") + } + if e.Logic != "and" && e.Logic != "or" && e.Logic != "not" { + e.Logic = "and" + } + if e.ScanDepth <= 0 { + e.ScanDepth = 4 + } + if e.BudgetChars <= 0 { + e.BudgetChars = 2000 + } + raw, _ := json.Marshal(e.Keys) + constant, enabled := 0, 0 + if e.Constant { + constant = 1 + } + if e.Enabled || e.ID == "" { + enabled = 1 + e.Enabled = true + } + creating := e.ID == "" + if creating { + e.ID = newID("ctx") + } + err := s.withEffectiveAccountWrite(actor, allowImplicitDefault, func(tx *sql.Tx, _ Account) error { + if creating { + _, err := tx.Exec(s.q(`INSERT INTO context_entries(id,owner,project_id,conversation_id,keys_json,secondary,logic,content,constant,scan_depth,sort_order,budget_chars,enabled,created_at) + VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?)`), + e.ID, actor, e.ProjectID, e.ConversationID, string(raw), e.Secondary, e.Logic, e.Content, constant, e.ScanDepth, e.SortOrder, e.BudgetChars, enabled, nowISO()) + return err + } + res, err := tx.Exec(s.q(`UPDATE context_entries SET project_id=?, conversation_id=?, keys_json=?, secondary=?, logic=?, content=?, constant=?, scan_depth=?, sort_order=?, budget_chars=?, enabled=? WHERE id=? AND owner=?`), + e.ProjectID, e.ConversationID, string(raw), e.Secondary, e.Logic, e.Content, constant, e.ScanDepth, e.SortOrder, e.BudgetChars, enabled, e.ID, actor) + 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 ContextEntry{}, err + } + return e, nil +} + +func (s *Store) DeleteContextEntryAccountChecked(actor, id string, allowImplicitDefault bool) error { + return s.withEffectiveAccountWrite(actor, allowImplicitDefault, func(tx *sql.Tx, _ Account) error { + res, err := tx.Exec(s.q(`DELETE FROM context_entries WHERE id=? AND owner=?`), id, actor) + if err != nil { + return err + } + if n, err := res.RowsAffected(); err != nil { + return err + } else if n != 1 { + return ErrNotFound + } + return nil + }) +} + func (s *Store) ListUserCommands(owner string) ([]UserCommand, error) { rows, err := s.db.Query(s.q(`SELECT id,name,title,template,created_at FROM user_commands WHERE owner=? ORDER BY name`), owner) if err != nil { @@ -436,6 +623,85 @@ func (s *Store) DeleteUserCommand(owner, id string) error { return nil } +func (s *Store) SaveUserCommandAccountChecked(actor string, c UserCommand, allowImplicitDefault bool) (UserCommand, error) { + c.Name = strings.TrimSpace(c.Name) + if !strings.HasPrefix(c.Name, "/") { + c.Name = "/" + c.Name + } + if c.Title == "" { + c.Title = c.Name + } + if strings.TrimSpace(c.Template) == "" { + return UserCommand{}, fmt.Errorf("store: command template required") + } + creating := c.ID == "" + if creating { + c.ID = newID("cmd") + c.CreatedAt = nowISO() + } + err := s.withEffectiveAccountWrite(actor, allowImplicitDefault, func(tx *sql.Tx, _ Account) error { + if creating { + _, err := tx.Exec(s.q(`INSERT INTO user_commands(id,owner,name,title,template,created_at) VALUES(?,?,?,?,?,?)`), + c.ID, actor, c.Name, c.Title, c.Template, c.CreatedAt) + return err + } + res, err := tx.Exec(s.q(`UPDATE user_commands SET name=?, title=?, template=? WHERE id=? AND owner=?`), c.Name, c.Title, c.Template, c.ID, actor) + 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 UserCommand{}, err + } + return c, nil +} + +// DeleteUserCommandAccountChecked returns the deleted name from the guarded +// transaction so the caller can unregister that exact command from its Bus. +func (s *Store) DeleteUserCommandAccountChecked(actor, id string, allowImplicitDefault bool) (UserCommand, error) { + var deleted UserCommand + err := s.withEffectiveAccountWrite(actor, allowImplicitDefault, func(tx *sql.Tx, _ Account) error { + query := s.q(`SELECT id,name,title,template,created_at FROM user_commands WHERE id=? AND owner=?`) + if s.Dialect() == "postgres" { + query += ` FOR UPDATE` + } + err := tx.QueryRow(query, id, actor).Scan(&deleted.ID, &deleted.Name, &deleted.Title, &deleted.Template, &deleted.CreatedAt) + if err == sql.ErrNoRows { + return ErrNotFound + } + if err != nil { + return err + } + res, err := tx.Exec(s.q(`DELETE FROM user_commands WHERE id=? AND owner=?`), id, actor) + 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 UserCommand{}, err + } + return deleted, nil +} + +// CheckEffectiveAccountForExternalEffect is a final state check immediately +// before a Bus mutation. The Bus is outside the account database transaction: +// disable can still commit between this check and the in-memory effect. +func (s *Store) CheckEffectiveAccountForExternalEffect(actor string, allowImplicitDefault bool) error { + return s.withEffectiveAccountWrite(actor, allowImplicitDefault, func(*sql.Tx, Account) error { return nil }) +} + // MessageByID 读取一条消息。不存在时返回 ErrNotFound。 func (s *Store) MessageByID(id string) (core.Message, error) { var m core.Message diff --git a/internal/store/borrow_account_write_test.go b/internal/store/borrow_account_write_test.go new file mode 100644 index 0000000..e42cae4 --- /dev/null +++ b/internal/store/borrow_account_write_test.go @@ -0,0 +1,202 @@ +package store + +import ( + "errors" + "testing" + "time" +) + +func TestBorrowAccountCheckedMutationsRespectStateAndOwner(t *testing.T) { + st := openTestStore(t) + for _, a := range []struct { + owner string + admin bool + }{{"admin", true}, {"alice", false}, {"bob", false}} { + if err := st.CreateAccount(a.owner, "", "", a.admin); err != nil { + t.Fatal(err) + } + } + secret, err := st.AddSecretAccountChecked("alice", "model", "first", "top-secret", false) + if err != nil || secret.ID == "" { + t.Fatalf("active secret create: %+v %v", secret, err) + } + second, err := st.AddSecretAccountChecked("alice", "model", "second", "new-secret", false) + if err != nil { + t.Fatal(err) + } + if err := st.ActivateSecretAccountChecked("alice", secret.ID, false); err != nil { + t.Fatal(err) + } + if value, ok := st.ActiveSecret("alice", "model"); !ok || value != "top-secret" { + t.Fatalf("activation changed wrong service: %q %v", value, ok) + } + if err := st.RenameSecretAccountChecked("alice", secret.ID, "renamed", false); err != nil { + t.Fatal(err) + } + entry, err := st.SaveContextEntryAccountChecked("alice", ContextEntry{Content: "before"}, false) + if err != nil { + t.Fatal(err) + } + entry.Content = "after" + if _, err := st.SaveContextEntryAccountChecked("alice", entry, false); err != nil { + t.Fatal(err) + } + command, err := st.SaveUserCommandAccountChecked("alice", UserCommand{Name: "draft", Template: "before"}, false) + if err != nil || command.Name != "/draft" { + t.Fatalf("active command create: %+v %v", command, err) + } + command.Template = "after" + if _, err := st.SaveUserCommandAccountChecked("alice", command, false); err != nil { + t.Fatal(err) + } + throwaway, err := st.AddSecretAccountChecked("alice", "throwaway", "", "temporary", false) + if err != nil { + t.Fatal(err) + } + if err := st.DeleteSecretAccountChecked("alice", throwaway.ID, false); err != nil { + t.Fatal(err) + } + throwawayEntry, err := st.SaveContextEntryAccountChecked("alice", ContextEntry{Content: "temporary"}, false) + if err != nil { + t.Fatal(err) + } + if err := st.DeleteContextEntryAccountChecked("alice", throwawayEntry.ID, false); err != nil { + t.Fatal(err) + } + throwawayCommand, err := st.SaveUserCommandAccountChecked("alice", UserCommand{Name: "temporary", Template: "temporary"}, false) + if err != nil { + t.Fatal(err) + } + deleted, err := st.DeleteUserCommandAccountChecked("alice", throwawayCommand.ID, false) + if err != nil || deleted.Name != "/temporary" { + t.Fatalf("guarded command delete lost Bus unregister name: %+v %v", deleted, err) + } + for _, mutate := range []func() error{ + func() error { return st.ActivateSecretAccountChecked("bob", secret.ID, false) }, + func() error { return st.RenameSecretAccountChecked("bob", secret.ID, "foreign", false) }, + func() error { return st.DeleteSecretAccountChecked("bob", secret.ID, false) }, + func() error { + _, err := st.SaveContextEntryAccountChecked("bob", ContextEntry{ID: entry.ID, Content: "foreign"}, false) + return err + }, + func() error { return st.DeleteContextEntryAccountChecked("bob", entry.ID, false) }, + func() error { + _, err := st.SaveUserCommandAccountChecked("bob", UserCommand{ID: command.ID, Name: "draft", Template: "foreign"}, false) + return err + }, + func() error { _, err := st.DeleteUserCommandAccountChecked("bob", command.ID, false); return err }, + } { + if err := mutate(); !errors.Is(err, ErrNotFound) { + t.Fatalf("foreign owner changed borrowed resource: %v", err) + } + } + account, err := st.GetAccountByOwner("alice") + if err != nil { + t.Fatal(err) + } + if _, err := st.SetAccountEnabled("admin", account.ID, account.StateVersion, false); err != nil { + t.Fatal(err) + } + for _, mutate := range []func() error{ + func() error { _, err := st.AddSecretAccountChecked("alice", "late", "late", "late", false); return err }, + func() error { return st.ActivateSecretAccountChecked("alice", second.ID, false) }, + func() error { return st.RenameSecretAccountChecked("alice", secret.ID, "late", false) }, + func() error { return st.DeleteSecretAccountChecked("alice", secret.ID, false) }, + func() error { + _, err := st.SaveContextEntryAccountChecked("alice", ContextEntry{Content: "late"}, false) + return err + }, + func() error { return st.DeleteContextEntryAccountChecked("alice", entry.ID, false) }, + func() error { + _, err := st.SaveUserCommandAccountChecked("alice", UserCommand{Name: "late", Template: "late"}, false) + return err + }, + func() error { _, err := st.DeleteUserCommandAccountChecked("alice", command.ID, false); return err }, + func() error { return st.CheckEffectiveAccountForExternalEffect("alice", false) }, + } { + if err := mutate(); !errors.Is(err, ErrAccountForbidden) { + t.Fatalf("disabled owner changed borrowed resource: %v", err) + } + } + if value, ok := st.ActiveSecret("alice", "model"); !ok || value != "top-secret" { + t.Fatalf("rejected secret mutation changed active value: %q %v", value, ok) + } + entries, err := st.ListContextEntries("alice", "", "") + if err != nil || len(entries) != 1 || entries[0].Content != "after" { + t.Fatalf("rejected context mutation changed rows: %+v %v", entries, err) + } + commands, err := st.ListUserCommands("alice") + if err != nil || len(commands) != 1 || commands[0].Template != "after" { + t.Fatalf("rejected command mutation changed rows: %+v %v", commands, err) + } + if err := st.DeleteSecretAccountChecked("bob", second.ID, false); !errors.Is(err, ErrNotFound) { + t.Fatalf("foreign delete of inactive secret: %v", err) + } +} + +func TestBorrowAccountCheckedImplicitDefault(t *testing.T) { + st := openTestStore(t) + if _, err := st.AddSecretAccountChecked(OwnerDefault, "service", "", "secret", false); !errors.Is(err, ErrAccountForbidden) { + t.Fatalf("implicit owner admitted without allowance: %v", err) + } + secret, err := st.AddSecretAccountChecked(OwnerDefault, "service", "", "secret", true) + if err != nil { + t.Fatal(err) + } + if err := st.RenameSecretAccountChecked(OwnerDefault, secret.ID, "local", true); err != nil { + t.Fatal(err) + } + if _, err := st.SaveContextEntryAccountChecked(OwnerDefault, ContextEntry{Content: "local"}, true); err != nil { + t.Fatal(err) + } + if _, err := st.SaveUserCommandAccountChecked(OwnerDefault, UserCommand{Name: "local", Template: "local"}, true); err != nil { + t.Fatal(err) + } +} + +func TestPGBorrowSecretWriteSerializesWithDisable(t *testing.T) { + st := openTestStore(t) + if st.Dialect() != "postgres" { + t.Skip("real PostgreSQL required") + } + other := reopenAccountModelStore(t, st) + for _, a := range []struct { + owner string + admin bool + }{{"admin", true}, {"alice", false}} { + if err := st.CreateAccount(a.owner, "", "", a.admin); err != nil { + t.Fatal(err) + } + } + account, err := st.GetAccountByOwner("alice") + if err != nil { + t.Fatal(err) + } + release := holdPGAccountWrite(t, st, "secret_keys", "INSERT", "borrow_secret") + writeDone := make(chan error, 1) + go func() { + _, err := st.AddSecretAccountChecked("alice", "service", "first", "secret", false) + writeDone <- err + }() + waitForPGAccountWrite(t, st, "borrow_secret") + disableDone := make(chan error, 1) + go func() { + _, err := other.SetAccountEnabled("admin", account.ID, account.StateVersion, false) + disableDone <- err + }() + select { + case err := <-disableDone: + t.Fatalf("disable committed ahead of paused secret insert: %v", err) + case <-time.After(100 * time.Millisecond): + } + release() + if err := awaitPGAccountResult(t, writeDone); err != nil { + t.Fatal(err) + } + if err := awaitPGAccountResult(t, disableDone); err != nil { + t.Fatal(err) + } + if _, err := st.AddSecretAccountChecked("alice", "service", "late", "late", false); !errors.Is(err, ErrAccountForbidden) { + t.Fatalf("late secret insert passed after disable: %v", err) + } +} diff --git a/internal/store/cards_account_write.go b/internal/store/cards_account_write.go new file mode 100644 index 0000000..621d2a8 --- /dev/null +++ b/internal/store/cards_account_write.go @@ -0,0 +1,241 @@ +package store + +import ( + "database/sql" + "encoding/json" + "errors" + "fmt" + "strings" + "time" + + core "agent-system-core" +) + +type storedCardLink struct { + owner, projectID, conversationID, ref, payload string + version int +} + +func (s *Store) cardLinkForWrite(tx *sql.Tx, id string, lock bool) (storedCardLink, bool, error) { + query := s.q(`SELECT owner,project_id,conversation_id,ref,payload,version FROM activity_cards WHERE id=?`) + if lock && s.Dialect() == "postgres" { + query += ` FOR UPDATE` + } + var link storedCardLink + err := tx.QueryRow(query, id).Scan(&link.owner, &link.projectID, &link.conversationID, &link.ref, &link.payload, &link.version) + if errors.Is(err, sql.ErrNoRows) { + return storedCardLink{}, false, nil + } + return link, err == nil, err +} + +// cardResourcesForWrite verifies the stored links after the actor lock and +// before taking the card row lock. A member can own a private conversation +// inside another creator's shared project; project ownership is a separate +// authorization decision made by the calling domain service. +func (s *Store) cardResourcesForWrite(tx *sql.Tx, actor, projectID, conversationID string) error { + if conversationID != "" && projectID == "" { + return ErrNotFound + } + if projectID != "" { + if conversationID == "" { + if _, err := s.projectForAccountWrite(tx, actor, projectID); err != nil { + return err + } + } else { + query := s.q(`SELECT id FROM projects WHERE id=?`) + if s.Dialect() == "postgres" { + query += ` FOR UPDATE` + } + var found string + if err := tx.QueryRow(query, projectID).Scan(&found); errors.Is(err, sql.ErrNoRows) { + return ErrNotFound + } else if err != nil { + return err + } + } + } + if conversationID != "" { + query := s.q(`SELECT owner,project_id FROM conversations WHERE id=?`) + if s.Dialect() == "postgres" { + query += ` FOR UPDATE` + } + var owner, project string + err := tx.QueryRow(query, conversationID).Scan(&owner, &project) + if errors.Is(err, sql.ErrNoRows) { + return ErrNotFound + } + if err != nil { + return err + } + if owner != actor || project != projectID { + return ErrNotFound + } + } + return nil +} + +func (s *Store) saveCardAccountChecked(actor string, card *core.ActivityCard, ref string, expected *int, allowImplicitDefault bool) error { + if card == nil || card.ID == "" { + return fmt.Errorf("save card: id and card required") + } + payload, err := json.Marshal(card) + if err != nil { + return fmt.Errorf("save card %s: %w", card.ID, err) + } + return s.withEffectiveAccountWrite(actor, allowImplicitDefault, func(tx *sql.Tx, _ Account) error { + // Read immutable links first, then acquire project, conversation and card + // row locks in that order. Recheck links after the row lock. + first, exists, err := s.cardLinkForWrite(tx, card.ID, false) + if err != nil { + return err + } + if exists && (first.owner != actor || first.projectID != card.ProjectID || first.conversationID != card.ConversationID) { + return ErrNotFound + } + if err := s.cardResourcesForWrite(tx, actor, card.ProjectID, card.ConversationID); err != nil { + return err + } + current, stillExists, err := s.cardLinkForWrite(tx, card.ID, true) + if err != nil { + return err + } + if stillExists != exists || (exists && (current.owner != actor || current.projectID != card.ProjectID || current.conversationID != card.ConversationID)) { + return ErrConflict + } + if expected != nil && current.version != *expected { + return ErrConflict + } + if !exists { + res, err := tx.Exec(s.q(`INSERT INTO activity_cards(id,project_id,conversation_id,ref,owner,payload,status,pinned,created_at,updated_at,version) + VALUES(?,?,?,?,?,?,?,?,?,?,1) ON CONFLICT(id) DO NOTHING`), card.ID, card.ProjectID, card.ConversationID, ref, actor, + string(payload), string(card.Status), boolInt(card.Pinned), card.CreatedAt.Format(time.RFC3339Nano), card.UpdatedAt.Format(time.RFC3339Nano)) + if err != nil { + return err + } + if n, err := res.RowsAffected(); err != nil { + return err + } else if n != 1 { + return ErrConflict + } + return nil + } + if ref == "" { + ref = current.ref + } + res, err := tx.Exec(s.q(`UPDATE activity_cards SET payload=?,status=?,pinned=?,ref=?,updated_at=?,version=version+1 WHERE id=? AND owner=? AND version=?`), + string(payload), string(card.Status), boolInt(card.Pinned), ref, card.UpdatedAt.Format(time.RFC3339Nano), card.ID, actor, current.version) + if err != nil { + return err + } + if n, err := res.RowsAffected(); err != nil { + return err + } else if n != 1 { + return ErrConflict + } + return nil + }) +} + +// SaveCardAccountChecked is the guarded counterpart of the legacy fixture API. +// The actor lock and effective-state check cover the final card mutation. +func (s *Store) SaveCardAccountChecked(actor string, card *core.ActivityCard, ref string, allowImplicitDefault bool) error { + return s.saveCardAccountChecked(actor, card, ref, nil, allowImplicitDefault) +} + +// SaveCardVersionedAccountChecked also enforces the stored card version. +func (s *Store) SaveCardVersionedAccountChecked(actor string, card *core.ActivityCard, ref string, expected int, allowImplicitDefault bool) error { + return s.saveCardAccountChecked(actor, card, ref, &expected, allowImplicitDefault) +} + +// ArchiveCardToProjectAccountChecked commits the project copy and origin +// dismissal together. The return flag is false on an idempotent retry. +func (s *Store) ArchiveCardToProjectAccountChecked(actor, id string, now time.Time, allowImplicitDefault bool) (core.ActivityCard, core.ActivityCard, bool, error) { + var archived, origin core.ActivityCard + created := false + err := s.withEffectiveAccountWrite(actor, allowImplicitDefault, func(tx *sql.Tx, _ Account) error { + first, exists, err := s.cardLinkForWrite(tx, id, false) + if err != nil { + return err + } + if !exists || first.owner != actor { + return ErrNotFound + } + if first.conversationID == "" { + return errors.New("card: already project-level") + } + if err := s.cardResourcesForWrite(tx, actor, first.projectID, first.conversationID); err != nil { + return err + } + current, exists, err := s.cardLinkForWrite(tx, id, true) + if err != nil { + return err + } + if !exists || current.owner != actor || current.projectID != first.projectID || current.conversationID != first.conversationID { + return ErrNotFound + } + if strings.HasPrefix(current.ref, "hitl:") || strings.HasPrefix(current.ref, "cmd:") { + return errors.New("card: flow-bound card cannot be archived to project") + } + if err := json.Unmarshal([]byte(current.payload), &origin); err != nil { + return fmt.Errorf("card %s payload: %w", id, err) + } + if origin.ID != id || origin.ProjectID != current.projectID || origin.ConversationID != current.conversationID { + return ErrNotFound + } + ref := "to_project:" + id + var prior string + err = tx.QueryRow(s.q(`SELECT id FROM activity_cards WHERE owner=? AND ref=? ORDER BY updated_at DESC LIMIT 1`), actor, ref).Scan(&prior) + if err == nil { + link, ok, err := s.cardLinkForWrite(tx, prior, true) + if err != nil { + return err + } + if !ok || link.owner != actor || link.projectID != current.projectID || link.conversationID != "" { + return ErrNotFound + } + return json.Unmarshal([]byte(link.payload), &archived) + } + if !errors.Is(err, sql.ErrNoRows) { + return err + } + archived = origin + archived.ID = NewID("card") + archived.ConversationID = "" + archived.AnchorMessageID = core.ID(id) + archived.Pinned = false + archived.CreatedAt, archived.UpdatedAt = now, now + archived.Body = append(append([]core.UINode{}, origin.Body...), core.UINode{Type: core.UIKV, KV: [][2]string{{"留档", "自会话卡 " + id}}}) + payload, err := json.Marshal(&archived) + if err != nil { + return err + } + if _, err := tx.Exec(s.q(`INSERT INTO activity_cards(id,project_id,conversation_id,ref,owner,payload,status,pinned,created_at,updated_at,version) + VALUES(?,?,?,?,?,?,?,?,?,?,1)`), archived.ID, archived.ProjectID, "", ref, actor, string(payload), string(archived.Status), boolInt(archived.Pinned), now.Format(time.RFC3339Nano), now.Format(time.RFC3339Nano)); err != nil { + return err + } + origin.Status = core.CardDismissed + origin.Tone = "info" + origin.UpdatedAt = now + payload, err = json.Marshal(&origin) + if err != nil { + return err + } + res, err := tx.Exec(s.q(`UPDATE activity_cards SET payload=?,status=?,updated_at=?,version=version+1 WHERE id=? AND owner=? AND version=?`), + string(payload), string(origin.Status), now.Format(time.RFC3339Nano), id, actor, current.version) + if err != nil { + return err + } + if n, err := res.RowsAffected(); err != nil { + return err + } else if n != 1 { + return ErrConflict + } + created = true + return nil + }) + if err != nil { + return core.ActivityCard{}, core.ActivityCard{}, false, err + } + return archived, origin, created, nil +} diff --git a/internal/store/cards_account_write_test.go b/internal/store/cards_account_write_test.go new file mode 100644 index 0000000..813878e --- /dev/null +++ b/internal/store/cards_account_write_test.go @@ -0,0 +1,232 @@ +package store + +import ( + "errors" + "testing" + "time" + + core "agent-system-core" +) + +func cardAccountFixture(t *testing.T, st *Store) (core.Project, core.Conversation, core.ActivityCard) { + t.Helper() + for _, a := range []struct { + owner string + admin bool + }{{"admin", true}, {"alice", false}, {"bob", false}} { + if err := st.CreateAccount(a.owner, "", "", a.admin); err != nil { + t.Fatal(err) + } + } + project, err := st.CreateProject("alice", "cards") + if err != nil { + t.Fatal(err) + } + conv, err := st.CreateConversation("alice", project.ID, "cards", "") + if err != nil { + t.Fatal(err) + } + now := time.Now().UTC() + card := core.ActivityCard{ID: NewID("card"), ProjectID: project.ID, ConversationID: conv.ID, + Title: "Review", Status: core.CardActionRequired, CreatedAt: now, UpdatedAt: now} + return project, conv, card +} + +func TestCardAccountCheckedWriteUsesPersistedOwnerAndResourceLinks(t *testing.T) { + st := openTestStore(t) + project, conv, card := cardAccountFixture(t, st) + if err := st.SaveCardAccountChecked("alice", &card, "", false); err != nil { + t.Fatal(err) + } + card.Title = "foreign replacement" + if err := st.SaveCardVersionedAccountChecked("bob", &card, "", 1, false); !errors.Is(err, ErrNotFound) { + t.Fatalf("foreign actor changed card: %v", err) + } + card.Title = "linked elsewhere" + card.ProjectID = "other-project" + if err := st.SaveCardVersionedAccountChecked("alice", &card, "", 1, false); !errors.Is(err, ErrNotFound) { + t.Fatalf("payload changed immutable project link: %v", err) + } + card.ProjectID = project.ID + card.ConversationID = "other-conversation" + if err := st.SaveCardVersionedAccountChecked("alice", &card, "", 1, false); !errors.Is(err, ErrNotFound) { + t.Fatalf("payload changed immutable conversation link: %v", err) + } + card.ConversationID = conv.ID + if err := st.SaveCardVersionedAccountChecked("alice", &card, "", 1, false); err != nil { + t.Fatalf("owner update: %v", err) + } + if err := st.SaveCardVersionedAccountChecked("alice", &card, "", 1, false); !errors.Is(err, ErrConflict) { + t.Fatalf("stale version replaced card: %v", err) + } + foreignProject, err := st.CreateProject("bob", "foreign") + if err != nil { + t.Fatal(err) + } + foreign := card + foreign.ID = NewID("card") + foreign.ProjectID = foreignProject.ID + foreign.ConversationID = "" + if err := st.SaveCardAccountChecked("alice", &foreign, "", false); !errors.Is(err, ErrNotFound) { + t.Fatalf("foreign project accepted: %v", err) + } + foreign.ProjectID = project.ID + foreign.ConversationID = conv.ID + if err := st.SaveCardAccountChecked("bob", &foreign, "", false); !errors.Is(err, ErrNotFound) { + t.Fatalf("foreign conversation accepted: %v", err) + } + account, err := st.GetAccountByOwner("alice") + if err != nil { + t.Fatal(err) + } + if _, err := st.SetAccountEnabled("admin", account.ID, account.StateVersion, false); err != nil { + t.Fatal(err) + } + card.Title = "late" + if err := st.SaveCardVersionedAccountChecked("alice", &card, "", 2, false); !errors.Is(err, ErrAccountForbidden) { + t.Fatalf("disabled actor changed card: %v", err) + } + if err := st.SaveCardAccountChecked("alice", &foreign, "", false); !errors.Is(err, ErrAccountForbidden) { + t.Fatalf("disabled actor created card: %v", err) + } + got, version, err := st.GetCardVersion("alice", card.ID) + if err != nil || got.Title != "linked elsewhere" || version != 2 { + t.Fatalf("denied write altered card: %+v version=%d err=%v", got, version, err) + } +} + +func TestSharedProjectMemberCanSaveOwnConversationCard(t *testing.T) { + st := openTestStore(t) + for _, owner := range []string{"creator", "member"} { + if err := st.CreateAccount(owner, "", "", false); err != nil { + t.Fatal(err) + } + } + project, err := st.CreateProject("creator", "shared") + if err != nil { + t.Fatal(err) + } + conv, err := st.CreateConversationAccountChecked("member", string(project.ID), "private work", "", false, false) + if err != nil { + t.Fatal(err) + } + now := time.Now().UTC() + card := core.ActivityCard{ID: NewID("card"), ProjectID: project.ID, ConversationID: conv.ID, + Title: "Member decision", Status: core.CardActionRequired, CreatedAt: now, UpdatedAt: now} + if err := st.SaveCardAccountChecked("member", &card, "hitl:member", false); err != nil { + t.Fatalf("member conversation card rejected: %v", err) + } + card.Status = core.CardResolved + if err := st.SaveCardVersionedAccountChecked("member", &card, "", 1, false); err != nil { + t.Fatalf("member card status rejected: %v", err) + } + if _, err := st.GetCard("member", card.ID); err != nil { + t.Fatal(err) + } +} + +func TestCardArchiveAccountCheckedCopiesAndDismissesAtomically(t *testing.T) { + st := openTestStore(t) + project, _, card := cardAccountFixture(t, st) + if err := st.SaveCardAccountChecked("alice", &card, "", false); err != nil { + t.Fatal(err) + } + if isSQLite(st) { + if _, err := st.db.Exec(`CREATE TRIGGER block_archive_dismiss BEFORE UPDATE ON activity_cards + WHEN NEW.status='dismissed' BEGIN SELECT RAISE(ABORT, 'blocked'); END`); err != nil { + t.Fatal(err) + } + if _, _, _, err := st.ArchiveCardToProjectAccountChecked("alice", card.ID, time.Now().UTC(), false); err == nil { + t.Fatal("archive should fail when origin dismissal fails") + } + if _, err := st.CardIDByRef("alice", "to_project:"+card.ID); !errors.Is(err, ErrNotFound) { + t.Fatalf("failed dismissal committed project copy: %v", err) + } + if _, err := st.db.Exec(`DROP TRIGGER block_archive_dismiss`); err != nil { + t.Fatal(err) + } + } + archived, origin, created, err := st.ArchiveCardToProjectAccountChecked("alice", card.ID, time.Now().UTC(), false) + if err != nil || !created || archived.ProjectID != project.ID || archived.ConversationID != "" || archived.AnchorMessageID != core.ID(card.ID) || origin.Status != core.CardDismissed { + t.Fatalf("archive did not atomically copy and dismiss: archived=%+v origin=%+v created=%v err=%v", archived, origin, created, err) + } + again, _, created, err := st.ArchiveCardToProjectAccountChecked("alice", card.ID, time.Now().UTC(), false) + if err != nil || created || again.ID != archived.ID { + t.Fatalf("retry duplicated archive: %+v created=%v err=%v", again, created, err) + } + if _, _, _, err := st.ArchiveCardToProjectAccountChecked("bob", card.ID, time.Now().UTC(), false); !errors.Is(err, ErrNotFound) { + t.Fatalf("foreign actor archived card: %v", err) + } + account, err := st.GetAccountByOwner("alice") + if err != nil { + t.Fatal(err) + } + if _, err := st.SetAccountEnabled("admin", account.ID, account.StateVersion, false); err != nil { + t.Fatal(err) + } + if _, _, _, err := st.ArchiveCardToProjectAccountChecked("alice", card.ID, time.Now().UTC(), false); !errors.Is(err, ErrAccountForbidden) { + t.Fatalf("disabled actor passed idempotent archive: %v", err) + } +} + +func TestCardAccountCheckedImplicitDefaultRequiresAllowance(t *testing.T) { + st := openTestStore(t) + card := core.ActivityCard{ID: NewID("card"), Title: "local", CreatedAt: time.Now().UTC(), UpdatedAt: time.Now().UTC()} + if err := st.SaveCardAccountChecked(OwnerDefault, &card, "", false); !errors.Is(err, ErrAccountForbidden) { + t.Fatalf("implicit owner admitted without allowance: %v", err) + } + if err := st.SaveCardAccountChecked(OwnerDefault, &card, "", true); err != nil { + t.Fatal(err) + } + if err := st.EnsureUser(OwnerDefault, ""); err != nil { + t.Fatal(err) + } + if _, err := st.DB().Exec(st.q(`UPDATE users SET enabled=FALSE WHERE owner=?`), OwnerDefault); err != nil { + t.Fatal(err) + } + if err := st.SaveCardVersionedAccountChecked(OwnerDefault, &card, "", 1, true); !errors.Is(err, ErrAccountForbidden) { + t.Fatalf("explicitly disabled local actor updated card: %v", err) + } +} + +func TestPGCardAccountFenceSerializesWithDisable(t *testing.T) { + st := openTestStore(t) + if st.Dialect() != "postgres" { + t.Skip("real PostgreSQL required") + } + other := reopenAccountModelStore(t, st) + _, _, card := cardAccountFixture(t, st) + account, err := st.GetAccountByOwner("alice") + if err != nil { + t.Fatal(err) + } + release := holdPGAccountWrite(t, st, "activity_cards", "INSERT", "card_create") + writeDone := make(chan error, 1) + go func() { writeDone <- st.SaveCardAccountChecked("alice", &card, "", false) }() + waitForPGAccountWrite(t, st, "card_create") + disableDone := make(chan error, 1) + go func() { + _, err := other.SetAccountEnabled("admin", account.ID, account.StateVersion, false) + disableDone <- err + }() + select { + case err := <-disableDone: + t.Fatalf("disable committed ahead of paused card insert: %v", err) + case <-time.After(100 * time.Millisecond): + } + release() + if err := awaitPGAccountResult(t, writeDone); err != nil { + t.Fatal(err) + } + if err := awaitPGAccountResult(t, disableDone); err != nil { + t.Fatal(err) + } + card.Title = "late" + if err := st.SaveCardVersionedAccountChecked("alice", &card, "", 1, false); !errors.Is(err, ErrAccountForbidden) { + t.Fatalf("card write crossed disable boundary: %v", err) + } + got, version, err := st.GetCardVersion("alice", card.ID) + if err != nil || got.Title != "Review" || version != 1 { + t.Fatalf("late write changed card: %+v version=%d err=%v", got, version, err) + } +} diff --git a/internal/store/config.go b/internal/store/config.go index 47f6b19..1b79683 100644 --- a/internal/store/config.go +++ b/internal/store/config.go @@ -3,6 +3,7 @@ package store import ( "database/sql" "errors" + "strings" core "agent-system-core" ) @@ -63,6 +64,67 @@ func (s *Store) DeleteProfile(owner, id string) error { 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:;PK=(owner,scope),租户隔离)---- func (s *Store) GetSettingsScope(owner, scope string) (map[string]any, error) { @@ -149,6 +211,74 @@ func (s *Store) UpdateSettingsScope(owner, scope string, fn func(cur map[string] 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) { diff --git a/internal/store/content.go b/internal/store/content.go index c740f98..4fcac63 100644 --- a/internal/store/content.go +++ b/internal/store/content.go @@ -5,6 +5,7 @@ import ( "database/sql" "errors" "fmt" + "strings" "time" core "agent-system-core" @@ -34,27 +35,124 @@ func (s *Store) CreateArtifact(a *core.Artifact, content []byte) error { return nil } +// CreateArtifactAccountChecked stages content under a fresh, unreferenced key. +// The artifact only becomes visible when its row commits with the actor's +// effective-account check. A rejected transaction cannot expose staged bytes. +func (s *Store) CreateArtifactAccountChecked(actor string, a *core.Artifact, content []byte, allowImplicitDefault bool) error { + if a == nil || a.Owner != actor { + return ErrNotFound + } + if actor == "" { + return ErrAccountForbidden + } + if a.ID == "" { + a.ID = newID("art") + } + now := time.Now().UTC() + a.CreatedAt, a.UpdatedAt = now, now + a.Size = int64(len(content)) + ref, err := s.blobs.Put(newID("blob"), content) + if err != nil { + return err + } + var callbackReached bool + var callbackErr error + err = s.withEffectiveAccountWrite(actor, allowImplicitDefault, func(tx *sql.Tx, _ Account) error { + callbackReached = true + _, callbackErr = tx.Exec(s.q(`INSERT INTO artifacts(id,project_id,owner,name,kind,mime,size,source_run,content_path,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?,?)`), + a.ID, a.ProjectID, actor, a.Name, a.Kind, a.MIME, a.Size, a.SourceRun, ref, nowISO(), nowISO()) + return callbackErr + }) + if err != nil && (!callbackReached || callbackErr != nil) { + _ = s.blobs.Delete(ref) + } + return err +} + func (s *Store) UpdateArtifactContent(owner, id string, content []byte) error { - var path string - err := s.db.QueryRow(s.q(`SELECT content_path FROM artifacts WHERE id=? AND owner=?`), id, owner).Scan(&path) - if errors.Is(err, sql.ErrNoRows) { - return ErrNotFound + // Legacy callers retain the old signature, but share immutable replacement: + // account-checked creations may already point at a key other than the ID. + ref, err := s.blobs.Put(newID("blob"), content) + if err != nil { + return err + } + tx, err := s.db.Begin() + if err != nil { + _ = s.blobs.Delete(ref) + return err + } + defer tx.Rollback() + oldRef, err := s.switchArtifactContentTx(tx, owner, id, ref, len(content)) + if err != nil { + _ = tx.Rollback() + _ = s.blobs.Delete(ref) + return err + } + if err := tx.Commit(); err != nil { + // The commit may have reached the DB. Preserve the staged object. + return err + } + // The DB pointer is authoritative. Blob deletion is external and may fail + // after a successful commit; callers can retry cleanup separately. + if oldRef == "" { + return nil + } + return s.blobs.Delete(oldRef) +} + +// UpdateArtifactContentAccountChecked swaps only the visible DB reference in +// the account-guarded transaction. Never overwrite the old referenced object. +func (s *Store) UpdateArtifactContentAccountChecked(actor, id string, content []byte, allowImplicitDefault bool) error { + if actor == "" { + return ErrAccountForbidden + } + ref, err := s.blobs.Put(newID("blob"), content) + if err != nil { + return err + } + var oldRef string + var callbackReached bool + var callbackErr error + err = s.withEffectiveAccountWrite(actor, allowImplicitDefault, func(tx *sql.Tx, _ Account) error { + callbackReached = true + oldRef, callbackErr = s.switchArtifactContentTx(tx, actor, id, ref, len(content)) + return callbackErr + }) + if err != nil && (!callbackReached || callbackErr != nil) { + _ = s.blobs.Delete(ref) + return err } if err != nil { return err } - // 同 ID 原子覆写(临时文件+rename);行内引用不变,避免行/对象漂移。 - if _, err := s.blobs.Put(id, content); err != nil { - return err + // The old object can be removed only after the new reference commits. + if oldRef == "" { + return nil } - res, err := s.db.Exec(s.q(`UPDATE artifacts SET size=?, updated_at=? WHERE id=? AND owner=?`), len(content), nowISO(), id, owner) + return s.blobs.Delete(oldRef) +} + +func (s *Store) switchArtifactContentTx(tx *sql.Tx, owner, id, newRef string, size int) (string, error) { + query := s.q(`SELECT content_path FROM artifacts WHERE id=? AND owner=?`) + if s.Dialect() == "postgres" { + query += ` FOR UPDATE` + } + var oldRef string + if err := tx.QueryRow(query, id, owner).Scan(&oldRef); errors.Is(err, sql.ErrNoRows) { + return "", ErrNotFound + } else if err != nil { + return "", err + } + res, err := tx.Exec(s.q(`UPDATE artifacts SET content_path=?, size=?, updated_at=? WHERE id=? AND owner=?`), newRef, size, nowISO(), id, owner) if err != nil { - return err + return "", err } - if n, _ := res.RowsAffected(); n == 0 { - return ErrNotFound + if n, err := res.RowsAffected(); err != nil { + return "", err + } else if n != 1 { + return "", ErrNotFound } - return nil + return oldRef, nil } const artifactColumns = `id,project_id,owner,name,kind,mime,size,source_run,created_at,updated_at` @@ -175,6 +273,44 @@ func (s *Store) DeleteArtifact(owner, id string) error { return s.blobs.Delete(path) } +// DeleteArtifactAccountChecked removes Backend-owned rows in one guarded +// transaction. The blob is unlinked only after commit; an unlink failure may +// leave an orphan, but cannot restore a deleted DB row or authorize a late write. +func (s *Store) DeleteArtifactAccountChecked(actor, id string, allowImplicitDefault bool) error { + var ref string + err := s.withEffectiveAccountWrite(actor, allowImplicitDefault, func(tx *sql.Tx, _ Account) error { + query := s.q(`SELECT content_path FROM artifacts WHERE id=? AND owner=?`) + if s.Dialect() == "postgres" { + query += ` FOR UPDATE` + } + if err := tx.QueryRow(query, id, actor).Scan(&ref); errors.Is(err, sql.ErrNoRows) { + return ErrNotFound + } else if err != nil { + return err + } + if _, err := tx.Exec(s.q(`DELETE FROM knowledge_vectors WHERE ref=? AND owner=?`), id, actor); err != nil { + return err + } + if _, err := tx.Exec(s.q(`DELETE FROM knowledge WHERE ref=? AND owner=?`), id, actor); err != nil { + return err + } + res, err := tx.Exec(s.q(`DELETE FROM artifacts WHERE id=? AND owner=?`), id, actor) + 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 err + } + return s.blobs.Delete(ref) +} + // ---- HITL 卡片(resolve 原子化:条件更新消灭先查后改窗口)---- func (s *Store) SaveHITLCard(owner string, c *core.HITLCard) error { @@ -292,6 +428,79 @@ func (s *Store) AddKnowledge(docs ...core.KnowledgeDoc) error { return err } defer tx.Rollback() + if err := s.addKnowledgeTx(tx, docs); err != nil { + return err + } + return tx.Commit() +} + +// AddKnowledgeAccountChecked keeps the whole batch behind one final account +// check and transaction. A caller cannot publish rows under another owner. +func (s *Store) AddKnowledgeAccountChecked(actor string, allowImplicitDefault bool, docs ...core.KnowledgeDoc) error { + if len(docs) == 0 { + return nil + } + for _, doc := range docs { + if doc.Owner != core.ID(actor) { + return ErrNotFound + } + } + return s.withEffectiveAccountWrite(actor, allowImplicitDefault, func(tx *sql.Tx, _ Account) error { + return s.addKnowledgeTx(tx, docs) + }) +} + +// ReplaceKnowledgeAccountChecked atomically removes the affected publication +// refs and inserts all replacement chunks. A failed insert rolls the removals +// back; embeddings and their later vectors are separate guarded operations. +// scoped=true limits each ref to the exact project and plugin namespace. +func (s *Store) ReplaceKnowledgeAccountChecked(ctx context.Context, actor, pluginID string, scoped, allowImplicitDefault bool, docs ...core.KnowledgeDoc) error { + if len(docs) == 0 { + return nil + } + if scoped && pluginID == "" { + return ErrNotFound + } + prefix := "plugin:" + pluginID + type publicationRef struct{ projectID, ref string } + refs := map[publicationRef]bool{} + for _, doc := range docs { + if doc.Owner != core.ID(actor) { + return ErrNotFound + } + if scoped && doc.Source != prefix && !strings.HasPrefix(doc.Source, prefix+":") { + return ErrNotFound + } + if doc.Ref != "" { + refs[publicationRef{projectID: string(doc.ProjectID), ref: doc.Ref}] = true + } + } + return s.withEffectiveAccountWrite(actor, allowImplicitDefault, func(tx *sql.Tx, _ Account) error { + if err := ctx.Err(); err != nil { + return err + } + for ref := range refs { + pred := `owner=? AND ref=?` + args := []any{actor, ref.ref} + if scoped { + pred += ` AND project_id=? AND (source=? OR substr(source,1,length(?))=?)` + args = append(args, ref.projectID, prefix, prefix+":", prefix+":") + } else if ref.projectID != "" { + pred += ` AND project_id=?` + args = append(args, ref.projectID) + } + if _, err := s.deleteKnowledgeAndVectorsTx(ctx, tx, pred, args...); err != nil { + return err + } + } + if err := ctx.Err(); err != nil { + return err + } + return s.addKnowledgeTx(tx, docs) + }) +} + +func (s *Store) addKnowledgeTx(tx *sql.Tx, docs []core.KnowledgeDoc) error { ts := nowISO() // 按下标写回生成的 ID:调用方(rag 管线)用 ID 关联向量。 for i := range docs { @@ -303,7 +512,7 @@ func (s *Store) AddKnowledge(docs ...core.KnowledgeDoc) error { return err } } - return tx.Commit() + return nil } // DeleteKnowledgeByRef 删除引用对应的知识(工件删除时联动清索引)。 @@ -312,6 +521,35 @@ func (s *Store) DeleteKnowledgeByRef(owner, ref string) error { return err } +// DeleteKnowledgeByRefAccountChecked removes the index rows and their vectors +// in the same account-guarded transaction. +func (s *Store) DeleteKnowledgeByRefAccountChecked(ctx context.Context, actor, ref string, allowImplicitDefault bool) error { + _, err := s.deleteKnowledgeAccountChecked(ctx, actor, allowImplicitDefault, `owner=? AND ref=?`, actor, ref) + return err +} + +// DeleteKnowledgeBySourceAccountChecked treats the prefix literally and only +// matches its namespace (prefix itself or prefix followed by ':'). SQL LIKE +// would treat plugin IDs containing '_' or '%' as wildcards. +func (s *Store) DeleteKnowledgeBySourceAccountChecked(ctx context.Context, actor, sourcePrefix string, allowImplicitDefault bool) (int64, error) { + if sourcePrefix == "" { + return 0, ErrNotFound + } + childPrefix := sourcePrefix + ":" + return s.deleteKnowledgeAccountChecked(ctx, actor, allowImplicitDefault, + `owner=? AND (source=? OR substr(source,1,length(?))=?)`, actor, sourcePrefix, childPrefix, childPrefix) +} + +func (s *Store) deleteKnowledgeAccountChecked(ctx context.Context, actor string, allowImplicitDefault bool, pred string, args ...any) (int64, error) { + var deleted int64 + err := s.withEffectiveAccountWrite(actor, allowImplicitDefault, func(tx *sql.Tx, _ Account) error { + var err error + deleted, err = s.deleteKnowledgeAndVectorsTx(ctx, tx, pred, args...) + return err + }) + return deleted, err +} + // DeleteKnowledgeBySource 按来源前缀撤销知识(EXT-9):删除该 owner 下 source 匹配 // 前缀的行,并经 ref 关联清理向量;幂等(无匹配返回 0, nil)。供宿主在 // KnowledgeDelete(前缀已强制 plugin:<插件ID>)后调用——不作用他人来源。 @@ -421,6 +659,19 @@ func (s *Store) DeleteProjectKnowledgeByRef(ctx context.Context, owner, projectI return s.deleteKnowledgeAndVectors(ctx, pred, args...) } +// DeleteProjectKnowledgeByRefAccountChecked retains legacy project scoping: +// empty projectID means all of this actor's projects for the given ref. +func (s *Store) DeleteProjectKnowledgeByRefAccountChecked(ctx context.Context, actor, projectID, ref string, allowImplicitDefault bool) error { + args := []any{actor, ref} + pred := `owner=? AND ref=?` + if projectID != "" { + pred += ` AND project_id=?` + args = append(args, projectID) + } + _, err := s.deleteKnowledgeAccountChecked(ctx, actor, allowImplicitDefault, pred, args...) + return err +} + // DeletePluginProjectKnowledgeByRef replaces only this actor/plugin publication // scope. Empty project is an exact unscoped domain, never a wildcard. The source // prefix is compared literally, including any percent/underscore in plugin IDs. @@ -433,21 +684,45 @@ func (s *Store) DeletePluginProjectKnowledgeByRef(ctx context.Context, owner, pr return s.deleteKnowledgeAndVectors(ctx, pred, owner, projectID, ref, prefix, prefix+":", prefix+":") } +// DeletePluginProjectKnowledgeByRefAccountChecked retains the exact project, +// ref and plugin namespace restriction, including literal '%' and '_' IDs. +func (s *Store) DeletePluginProjectKnowledgeByRefAccountChecked(ctx context.Context, actor, projectID, ref, pluginID string, allowImplicitDefault bool) error { + if pluginID == "" { + return ErrNotFound + } + prefix := "plugin:" + pluginID + pred := `owner=? AND project_id=? AND ref=? AND (source=? OR substr(source,1,length(?))=?)` + _, err := s.deleteKnowledgeAccountChecked(ctx, actor, allowImplicitDefault, pred, + actor, projectID, ref, prefix, prefix+":", prefix+":") + return err +} + func (s *Store) deleteKnowledgeAndVectors(ctx context.Context, pred string, args ...any) error { tx, err := s.db.BeginTx(ctx, nil) if err != nil { return err } defer tx.Rollback() - if _, err := tx.ExecContext(ctx, s.q("DELETE FROM knowledge_vectors WHERE doc_id IN (SELECT id FROM knowledge WHERE "+pred+")"), args...); err != nil { - return err - } - if _, err := tx.ExecContext(ctx, s.q("DELETE FROM knowledge WHERE "+pred), args...); err != nil { + if _, err := s.deleteKnowledgeAndVectorsTx(ctx, tx, pred, args...); err != nil { return err } return tx.Commit() } +func (s *Store) deleteKnowledgeAndVectorsTx(ctx context.Context, tx *sql.Tx, pred string, args ...any) (int64, error) { + if err := ctx.Err(); err != nil { + return 0, err + } + if _, err := tx.ExecContext(ctx, s.q("DELETE FROM knowledge_vectors WHERE doc_id IN (SELECT id FROM knowledge WHERE "+pred+")"), args...); err != nil { + return 0, err + } + res, err := tx.ExecContext(ctx, s.q("DELETE FROM knowledge WHERE "+pred), args...) + if err != nil { + return 0, err + } + return res.RowsAffected() +} + func (s *Store) searchKnowledgeSQLite(owner string, q core.RetrievalQuery) ([]core.RetrievalHit, error) { out, err := s.searchKnowledgeFTS(ownerVisible(owner, q), q) if err != nil { diff --git a/internal/store/conversation_account_write.go b/internal/store/conversation_account_write.go new file mode 100644 index 0000000..1a10b62 --- /dev/null +++ b/internal/store/conversation_account_write.go @@ -0,0 +1,35 @@ +package store + +import ( + "database/sql" + "errors" + "time" + + core "agent-system-core" +) + +// CreateConversationAccountChecked creates a conversation only while its actor +// is effective. The project lookup and insert use the same guarded transaction; +// shared-project authorization remains the caller's separate domain decision. +func (s *Store) CreateConversationAccountChecked(actor, projectID, title, profileID string, allowImplicitDefault, requireProjectOwner bool) (core.Conversation, error) { + c := core.Conversation{ID: newID("cnv"), ProjectID: projectID, Owner: actor, Title: title, ProfileID: profileID, + AllowCrossRead: true, CreatedAt: time.Now().UTC(), UpdatedAt: time.Now().UTC()} + err := s.withEffectiveAccountWrite(actor, allowImplicitDefault, func(tx *sql.Tx, _ Account) error { + query := s.q(`SELECT 1 FROM projects WHERE id=?`) + args := []any{projectID} + if requireProjectOwner { + query = s.q(`SELECT 1 FROM projects WHERE id=? AND owner=?`) + args = append(args, actor) + } + var one int + if err := tx.QueryRow(query, args...).Scan(&one); errors.Is(err, sql.ErrNoRows) { + return ErrNotFound + } else if err != nil { + return err + } + _, err := tx.Exec(s.q(`INSERT INTO conversations(id,project_id,owner,title,profile_id,created_at,updated_at) VALUES(?,?,?,?,?,?,?)`), + c.ID, c.ProjectID, actor, c.Title, c.ProfileID, nowISO(), nowISO()) + return err + }) + return c, err +} diff --git a/internal/store/conversation_metadata_fence_test.go b/internal/store/conversation_metadata_fence_test.go new file mode 100644 index 0000000..34ba900 --- /dev/null +++ b/internal/store/conversation_metadata_fence_test.go @@ -0,0 +1,167 @@ +package store + +import ( + "errors" + "testing" + "time" +) + +func TestConversationMetadataAccountCheckedStateAndOwner(t *testing.T) { + st := openTestStore(t) + t.Logf("conversation metadata fence dialect=%s", st.Dialect()) + for _, account := range []struct { + owner string + admin bool + }{{"admin", true}, {"alice", false}, {"bob", false}} { + if err := st.CreateAccount(account.owner, "", "", account.admin); err != nil { + t.Fatal(err) + } + } + project, err := st.CreateProject("alice", "p") + if err != nil { + t.Fatal(err) + } + conversation, err := st.CreateConversation("alice", project.ID, "before", "profile-before") + if err != nil { + t.Fatal(err) + } + yes := true + for _, mutate := range []func(string) error{ + func(actor string) error { + return st.UpdateConversationAccountChecked(actor, conversation.ID, "after", "profile-after", false) + }, + func(actor string) error { + return st.SetConversationFlagsAccountChecked(actor, conversation.ID, &yes, nil, nil, nil, false) + }, + func(actor string) error { + return st.SetPermissionModeAccountChecked(actor, conversation.ID, "plan", false) + }, + func(actor string) error { + return st.SetSessionNoteAccountChecked(actor, conversation.ID, "note", false) + }, + func(actor string) error { return st.DeleteConversationAccountChecked(actor, conversation.ID, false) }, + } { + if err := mutate("bob"); !errors.Is(err, ErrNotFound) { + t.Fatalf("foreign actor changed conversation: %v", err) + } + if err := mutate("missing"); !errors.Is(err, ErrAccountForbidden) { + t.Fatalf("missing actor changed conversation: %v", err) + } + } + if err := st.UpdateConversationAccountChecked("alice", conversation.ID, "after", "profile-after", false); err != nil { + t.Fatal(err) + } + if err := st.SetConversationFlagsAccountChecked("alice", conversation.ID, &yes, nil, nil, nil, false); err != nil { + t.Fatal(err) + } + if err := st.SetPermissionModeAccountChecked("alice", conversation.ID, "plan", false); err != nil { + t.Fatal(err) + } + if err := st.SetSessionNoteAccountChecked("alice", conversation.ID, "note", false); err != nil { + t.Fatal(err) + } + got, err := st.GetConversation("alice", conversation.ID) + if err != nil || got.Title != "after" || got.ProfileID != "profile-after" || !got.Pinned || got.PermissionMode != "plan" || got.SessionNote != "note" { + t.Fatalf("metadata writes did not persist: %+v %v", got, err) + } + if err := st.SetPermissionModeAccountChecked("alice", conversation.ID, "invalid", false); err == nil { + t.Fatal("invalid permission mode was accepted") + } + account, err := st.GetAccountByOwner("alice") + if err != nil { + t.Fatal(err) + } + if _, err := st.SetAccountEnabled("admin", account.ID, account.StateVersion, false); err != nil { + t.Fatal(err) + } + for _, mutate := range []func() error{ + func() error { + return st.UpdateConversationAccountChecked("alice", conversation.ID, "late", "late", false) + }, + func() error { + return st.SetConversationFlagsAccountChecked("alice", conversation.ID, nil, nil, nil, nil, false) + }, + func() error { return st.SetPermissionModeAccountChecked("alice", conversation.ID, "auto_edit", false) }, + func() error { return st.SetSessionNoteAccountChecked("alice", conversation.ID, "late", false) }, + func() error { return st.DeleteConversationAccountChecked("alice", conversation.ID, false) }, + } { + if err := mutate(); !errors.Is(err, ErrAccountForbidden) { + t.Fatalf("disabled actor changed conversation: %v", err) + } + } + got, err = st.GetConversation("alice", conversation.ID) + if err != nil || got.Title != "after" || got.ProfileID != "profile-after" || got.PermissionMode != "plan" || got.SessionNote != "note" { + t.Fatalf("disabled metadata write changed conversation: %+v %v", got, err) + } + + bobProject, err := st.CreateProject("bob", "bob project") + if err != nil { + t.Fatal(err) + } + bobConversation, err := st.CreateConversation("bob", bobProject.ID, "temporary", "") + if err != nil { + t.Fatal(err) + } + if err := st.DeleteConversationAccountChecked("bob", bobConversation.ID, false); err != nil { + t.Fatal(err) + } + if _, err := st.GetConversation("bob", bobConversation.ID); !errors.Is(err, ErrNotFound) { + t.Fatalf("successful delete left conversation behind: %v", err) + } +} + +func TestPGConversationMetadataFenceSerializesWithDisable(t *testing.T) { + st := openTestStore(t) + if st.Dialect() != "postgres" { + t.Skip("real PostgreSQL required") + } + other := reopenAccountModelStore(t, st) + if err := st.CreateAccount("admin", "", "", true); err != nil { + t.Fatal(err) + } + if err := st.CreateAccount("alice", "", "", false); err != nil { + t.Fatal(err) + } + project, err := st.CreateProject("alice", "p") + if err != nil { + t.Fatal(err) + } + conversation, err := st.CreateConversation("alice", project.ID, "before", "") + if err != nil { + t.Fatal(err) + } + account, err := st.GetAccountByOwner("alice") + if err != nil { + t.Fatal(err) + } + release := holdPGAccountWrite(t, st, "conversations", "UPDATE", "conversation_metadata") + updateDone := make(chan error, 1) + go func() { + updateDone <- st.UpdateConversationAccountChecked("alice", conversation.ID, "after", "", false) + }() + waitForPGAccountWrite(t, st, "conversation_metadata") + disableDone := make(chan error, 1) + go func() { + _, err := other.SetAccountEnabled("admin", account.ID, account.StateVersion, false) + disableDone <- err + }() + select { + case err := <-disableDone: + t.Fatalf("disable committed ahead of paused metadata write: %v", err) + case <-time.After(100 * time.Millisecond): + } + release() + if err := awaitPGAccountResult(t, updateDone); err != nil { + t.Fatal(err) + } + if err := awaitPGAccountResult(t, disableDone); err != nil { + t.Fatal(err) + } + if err := st.SetSessionNoteAccountChecked("alice", conversation.ID, "late", false); !errors.Is(err, ErrAccountForbidden) { + t.Fatalf("metadata write passed after disable: %v", err) + } + got, err := st.GetConversation("alice", conversation.ID) + if err != nil || got.Title != "after" || got.SessionNote != "" { + t.Fatalf("metadata crossed disable boundary: %+v %v", got, err) + } +} diff --git a/internal/store/execution.go b/internal/store/execution.go index a2f761d..c7b4ab1 100644 --- a/internal/store/execution.go +++ b/internal/store/execution.go @@ -37,9 +37,6 @@ type ExecutionState struct { // TryClaimExecution 占用(或租约到期后接管)会话执行权。 // 返回接管后的 epoch(接管必然 +1)。被他人持有且未到期 → ErrExecutionBusy。 func (s *Store) TryClaimExecution(conversationID, instanceID, runID string, leaseSec int) (int64, error) { - if leaseSec <= 0 { - leaseSec = 90 - } tx, err := s.db.Begin() if err != nil { return 0, err @@ -48,20 +45,63 @@ func (s *Store) TryClaimExecution(conversationID, instanceID, runID string, leas if err := s.lockAdmission(tx); err != nil { // 复用全局账本锁串行化清点 return 0, err } + epoch, err := s.claimExecutionTx(tx, conversationID, instanceID, runID, leaseSec) + if err != nil { + return epoch, err + } + if err := tx.Commit(); err != nil { + return 0, err + } + return epoch, nil +} + +// TryClaimExecutionAccountChecked locks the actor before the admission ledger +// and verifies the conversation/run owner in the same final write transaction. +func (s *Store) TryClaimExecutionAccountChecked(actor, conversationID, instanceID, runID string, leaseSec int, allowImplicitDefault bool) (int64, error) { + var epoch int64 + err := s.withEffectiveAccountWrite(actor, allowImplicitDefault, func(tx *sql.Tx, _ Account) error { + if err := s.lockAdmission(tx); err != nil { + return err + } + var owner string + if err := tx.QueryRow(s.q(`SELECT owner FROM conversations WHERE id=?`), conversationID).Scan(&owner); errors.Is(err, sql.ErrNoRows) { + return ErrNotFound + } else if err != nil { + return err + } + if owner != actor { + return ErrNotFound + } + if runID != "" { + var one int + if err := tx.QueryRow(s.q(`SELECT 1 FROM runs WHERE id=? AND owner=? AND conversation_id=?`), runID, actor, conversationID).Scan(&one); errors.Is(err, sql.ErrNoRows) { + return ErrNotFound + } else if err != nil { + return err + } + } + var err error + epoch, err = s.claimExecutionTx(tx, conversationID, instanceID, runID, leaseSec) + return err + }) + return epoch, err +} + +func (s *Store) claimExecutionTx(tx *sql.Tx, conversationID, instanceID, runID string, leaseSec int) (int64, error) { + if leaseSec <= 0 { + leaseSec = 90 + } now := time.Now().UTC() until := now.Add(time.Duration(leaseSec) * time.Second).UTC().Format(time.RFC3339Nano) var inst, lease string var epoch int64 - err = tx.QueryRow(s.q(`SELECT instance_id, epoch, lease_until FROM conversation_executions WHERE conversation_id=?`), conversationID).Scan(&inst, &epoch, &lease) + err := tx.QueryRow(s.q(`SELECT instance_id, epoch, lease_until FROM conversation_executions WHERE conversation_id=?`), conversationID).Scan(&inst, &epoch, &lease) switch { case errors.Is(err, sql.ErrNoRows): if _, err := tx.Exec(s.q(`INSERT INTO conversation_executions (conversation_id, instance_id, run_id, epoch, lease_until, updated_at) VALUES (?,?,?,1,?,?)`), conversationID, instanceID, runID, until, nowISO()); err != nil { return 0, err } - if err := tx.Commit(); err != nil { - return 0, err - } return 1, nil case err != nil: return 0, err @@ -74,9 +114,6 @@ func (s *Store) TryClaimExecution(conversationID, instanceID, runID string, leas instanceID, runID, until, nowISO(), conversationID); err != nil { return 0, err } - if err := tx.Commit(); err != nil { - return 0, err - } return epoch + 1, nil } @@ -95,6 +132,39 @@ func (s *Store) RenewExecution(conversationID, instanceID string, epoch int64, l return n > 0 } +// RenewExecutionAccountChecked only extends the lease while the conversation +// owner is effective in the same transaction as the conditional UPDATE. +func (s *Store) RenewExecutionAccountChecked(actor, conversationID, instanceID string, epoch int64, leaseSec int, allowImplicitDefault bool) bool { + if leaseSec <= 0 { + leaseSec = 90 + } + until := time.Now().UTC().Add(time.Duration(leaseSec) * time.Second).UTC().Format(time.RFC3339Nano) + updated := false + err := s.withEffectiveAccountWrite(actor, allowImplicitDefault, func(tx *sql.Tx, _ Account) error { + var owner string + if err := tx.QueryRow(s.q(`SELECT owner FROM conversations WHERE id=?`), conversationID).Scan(&owner); errors.Is(err, sql.ErrNoRows) { + return ErrNotFound + } else if err != nil { + return err + } + if owner != actor { + return ErrNotFound + } + res, err := tx.Exec(s.q(`UPDATE conversation_executions SET lease_until=?, updated_at=? WHERE conversation_id=? AND instance_id=? AND epoch=?`), + until, nowISO(), conversationID, instanceID, epoch) + if err != nil { + return err + } + n, err := res.RowsAffected() + if err != nil { + return err + } + updated = n == 1 + return nil + }) + return err == nil && updated +} + // ReleaseExecution 主动释放(优雅停机):行保留、租约置为已过期——epoch 保留, // 下一次占用仍递增(fencing 单调)。 func (s *Store) ReleaseExecution(conversationID, instanceID string, epoch int64) { @@ -173,6 +243,28 @@ func (s *Store) EnqueueCommand(commandID, kind, runID, conversationID string, ep return nil } +// EnqueueCommandAccountChecked resolves the run owner and conversation from +// persisted rows in the final account-guarded transaction. A client-supplied +// run ID can locate a command target but cannot authorize another owner's run. +func (s *Store) EnqueueCommandAccountChecked(actor, commandID, kind, runID string, epoch int64, payload any, allowImplicitDefault bool) error { + return s.withEffectiveAccountWrite(actor, allowImplicitDefault, func(tx *sql.Tx, _ Account) error { + var owner, conversationID string + query := s.q(`SELECT owner,conversation_id FROM runs WHERE id=?`) + if s.Dialect() == "postgres" { + query += ` FOR UPDATE` + } + if err := tx.QueryRow(query, runID).Scan(&owner, &conversationID); errors.Is(err, sql.ErrNoRows) { + return ErrNotFound + } else if err != nil { + return err + } + if owner != actor { + return ErrNotFound + } + return s.EnqueueCommandInTx(tx, commandID, kind, runID, conversationID, epoch, payload) + }) +} + // ClaimPendingCommands 认领待处理命令(原子 pending→consumed)。 // PG:FOR UPDATE SKIP LOCKED(多实例并发认领不重复);SQLite:单写者条件更新。 // 只认领「本实例相关」的命令由调用方过滤——收件箱按执行权路由:命令在 diff --git a/internal/store/execution_account_write_test.go b/internal/store/execution_account_write_test.go new file mode 100644 index 0000000..38fa879 --- /dev/null +++ b/internal/store/execution_account_write_test.go @@ -0,0 +1,152 @@ +package store + +import ( + "errors" + "path/filepath" + "testing" + + core "agent-system-core" +) + +type executionAccountWriter interface { + TryClaimExecutionAccountChecked(actor, conversationID, instanceID, runID string, leaseSec int, allowImplicitDefault bool) (int64, error) + RenewExecutionAccountChecked(actor, conversationID, instanceID string, epoch int64, leaseSec int, allowImplicitDefault bool) bool + TryAcquireExecutionSlotAccountChecked(actor, runID string, budget ExecutionBudget, allowImplicitDefault bool) error +} + +func executionWriter(t *testing.T, st *Store) executionAccountWriter { + t.Helper() + w, ok := any(st).(executionAccountWriter) + if !ok { + t.Fatal("Store is missing checked execution lease/admission APIs") + } + return w +} + +func executionFenceFixture(t *testing.T) (*Store, core.Conversation, core.Run) { + t.Helper() + st, err := OpenSQLite(filepath.Join(t.TempDir(), "execution.db")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = st.Close() }) + for _, owner := range []string{"alice", "bob"} { + if err := st.EnsureUser(owner, ""); err != nil { + t.Fatal(err) + } + } + p, err := st.CreateProject("alice", "p") + if err != nil { + t.Fatal(err) + } + conv, err := st.CreateConversation("alice", p.ID, "c", "") + if err != nil { + t.Fatal(err) + } + run := core.Run{ID: "run-exec-account", Owner: "alice", ConversationID: conv.ID, ProjectID: p.ID, Status: core.RunPending} + if err := st.CreateRun(&run); err != nil { + t.Fatal(err) + } + return st, conv, run +} + +func TestExecutionAccountFenceClaimRejectsDisabledAndForeignActor(t *testing.T) { + st, conv, _ := executionFenceFixture(t) + w := executionWriter(t, st) + if _, err := w.TryClaimExecutionAccountChecked("bob", conv.ID, "instance", "", 60, false); !errors.Is(err, ErrNotFound) { + t.Fatalf("foreign claim: got %v, want ErrNotFound", err) + } + if _, err := st.DB().Exec(`UPDATE users SET enabled=FALSE WHERE owner='alice'`); err != nil { + t.Fatal(err) + } + if _, err := w.TryClaimExecutionAccountChecked("alice", conv.ID, "instance", "", 60, false); !errors.Is(err, ErrAccountForbidden) { + t.Fatalf("disabled claim: got %v, want ErrAccountForbidden", err) + } + if _, err := st.GetExecution(conv.ID); !errors.Is(err, ErrNotFound) { + t.Fatalf("rejected claim left execution lease: %v", err) + } +} + +func TestExecutionAccountFenceRenewDoesNotExtendAfterDisable(t *testing.T) { + st, conv, run := executionFenceFixture(t) + w := executionWriter(t, st) + epoch, err := w.TryClaimExecutionAccountChecked("alice", conv.ID, "instance", run.ID, 60, false) + if err != nil || epoch != 1 { + t.Fatalf("initial claim: epoch=%d err=%v", epoch, err) + } + before, err := st.GetExecution(conv.ID) + if err != nil { + t.Fatal(err) + } + if _, err := st.DB().Exec(`UPDATE users SET enabled=FALSE WHERE owner='alice'`); err != nil { + t.Fatal(err) + } + if w.RenewExecutionAccountChecked("alice", conv.ID, "instance", epoch, 180, false) { + t.Fatal("disabled actor renewed execution") + } + after, err := st.GetExecution(conv.ID) + if err != nil || after.LeaseUntil != before.LeaseUntil || after.Epoch != before.Epoch { + t.Fatalf("disabled renew changed lease: before=%+v after=%+v err=%v", before, after, err) + } +} + +func TestExecutionAccountFenceSlotRejectsDisabledForeignRunAndBudgetOff(t *testing.T) { + st, _, run := executionFenceFixture(t) + w := executionWriter(t, st) + budget := ExecutionBudget{Global: 2, PerUser: 1} + if err := w.TryAcquireExecutionSlotAccountChecked("bob", run.ID, budget, false); !errors.Is(err, ErrNotFound) { + t.Fatalf("foreign run slot: got %v, want ErrNotFound", err) + } + if _, err := st.DB().Exec(`UPDATE users SET enabled=FALSE WHERE owner='alice'`); err != nil { + t.Fatal(err) + } + if err := w.TryAcquireExecutionSlotAccountChecked("alice", run.ID, budget, false); !errors.Is(err, ErrAccountForbidden) { + t.Fatalf("disabled slot: got %v, want ErrAccountForbidden", err) + } + if err := w.TryAcquireExecutionSlotAccountChecked("alice", run.ID, ExecutionBudget{}, false); err != nil { + t.Fatalf("off budget should remain a no-write fast path: %v", err) + } + if n, _, err := st.ActiveExecutionSlots(); err != nil || n != 0 { + t.Fatalf("rejected/off slot created %d leases: %v", n, err) + } +} + +func TestExecutionAccountFenceImplicitLocalStopsAtRecordedDisable(t *testing.T) { + st, err := OpenSQLite(filepath.Join(t.TempDir(), "execution.db")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = st.Close() }) + w := executionWriter(t, st) + p, err := st.CreateProject(OwnerDefault, "local") + if err != nil { + t.Fatal(err) + } + conv, err := st.CreateConversation(OwnerDefault, p.ID, "c", "") + if err != nil { + t.Fatal(err) + } + run := core.Run{ID: "run-local-exec", Owner: OwnerDefault, ConversationID: conv.ID, ProjectID: p.ID, Status: core.RunPending} + if err := st.CreateRun(&run); err != nil { + t.Fatal(err) + } + epoch, err := w.TryClaimExecutionAccountChecked(OwnerDefault, conv.ID, "instance", run.ID, 60, true) + if err != nil || epoch != 1 { + t.Fatalf("implicit local claim: epoch=%d err=%v", epoch, err) + } + if err := w.TryAcquireExecutionSlotAccountChecked(OwnerDefault, run.ID, ExecutionBudget{Global: 1}, true); err != nil { + t.Fatalf("implicit local slot: %v", err) + } + if err := st.EnsureUser(OwnerDefault, ""); err != nil { + t.Fatal(err) + } + if _, err := st.DB().Exec(`UPDATE users SET enabled=FALSE WHERE owner=?`, OwnerDefault); err != nil { + t.Fatal(err) + } + if w.RenewExecutionAccountChecked(OwnerDefault, conv.ID, "instance", epoch, 180, true) { + t.Fatal("recorded disabled local account renewed lease") + } + if err := w.TryAcquireExecutionSlotAccountChecked(OwnerDefault, run.ID, ExecutionBudget{Global: 1}, true); !errors.Is(err, ErrAccountForbidden) { + t.Fatalf("recorded disabled local account reacquired slot: %v", err) + } +} diff --git a/internal/store/hitl_account_write.go b/internal/store/hitl_account_write.go new file mode 100644 index 0000000..4a6a7da --- /dev/null +++ b/internal/store/hitl_account_write.go @@ -0,0 +1,71 @@ +package store + +import ( + "database/sql" + "errors" + + core "agent-system-core" +) + +func (s *Store) SaveHITLCardAccountChecked(actor string, card *core.HITLCard, allowImplicitDefault bool) error { + if card == nil { + return ErrNotFound + } + return s.withEffectiveAccountWrite(actor, allowImplicitDefault, func(tx *sql.Tx, _ Account) error { + var runOwner, conversationID string + if err := tx.QueryRow(s.q(`SELECT owner,conversation_id FROM runs WHERE id=?`), card.RunID).Scan(&runOwner, &conversationID); errors.Is(err, sql.ErrNoRows) { + return ErrNotFound + } else if err != nil { + return err + } + if runOwner != actor || conversationID != string(card.ConversationID) { + return ErrNotFound + } + _, err := tx.Exec(s.q(`INSERT INTO hitl_cards(id,owner,run_id,conversation_id,payload,created_at,resolved,resolution) VALUES(?,?,?,?,?,?,?,?) + ON CONFLICT(id) DO UPDATE SET payload=excluded.payload,resolved=excluded.resolved,resolution=excluded.resolution`), + card.ID, actor, card.RunID, card.ConversationID, marshalJSON(card), nowISO(), boolInt(card.Resolved), "") + return err + }) +} + +func (s *Store) ResolveHITLCardAccountChecked(actor, id string, resolution core.HITLResolvedPayload, allowImplicitDefault bool) error { + return s.withEffectiveAccountWrite(actor, allowImplicitDefault, func(tx *sql.Tx, _ Account) error { + query := s.q(`SELECT payload,run_id,conversation_id,resolved FROM hitl_cards WHERE id=? AND owner=?`) + if s.Dialect() == "postgres" { + query += ` FOR UPDATE` + } + var payload, runID, conversationID string + var resolved bool + if err := tx.QueryRow(query, id, actor).Scan(&payload, &runID, &conversationID, &resolved); errors.Is(err, sql.ErrNoRows) { + return ErrNotFound + } else if err != nil { + return err + } + if resolved { + return ErrConflict + } + var runOwner, runConversation string + if err := tx.QueryRow(s.q(`SELECT owner,conversation_id FROM runs WHERE id=?`), runID).Scan(&runOwner, &runConversation); err != nil { + return ErrNotFound + } + if runOwner != actor || runConversation != conversationID { + return ErrNotFound + } + card, err := unmarshalJSON[core.HITLCard](payload) + if err != nil { + return err + } + card.Resolved = true + res, err := tx.Exec(s.q(`UPDATE hitl_cards SET resolved=1,payload=?,resolution=? WHERE id=? AND owner=? AND resolved=0`), + marshalJSON(card), marshalJSON(resolution), id, actor) + if err != nil { + return err + } + if n, err := res.RowsAffected(); err != nil { + return err + } else if n != 1 { + return ErrConflict + } + return nil + }) +} diff --git a/internal/store/knowledge_account_write_test.go b/internal/store/knowledge_account_write_test.go new file mode 100644 index 0000000..f9a9519 --- /dev/null +++ b/internal/store/knowledge_account_write_test.go @@ -0,0 +1,252 @@ +package store + +import ( + "context" + "errors" + "path/filepath" + "testing" + + core "agent-system-core" +) + +type knowledgeAccountWriter interface { + AddKnowledgeAccountChecked(actor string, allowImplicitDefault bool, docs ...core.KnowledgeDoc) error + DeleteKnowledgeBySourceAccountChecked(ctx context.Context, actor, sourcePrefix string, allowImplicitDefault bool) (int64, error) + DeleteKnowledgeByRefAccountChecked(ctx context.Context, actor, ref string, allowImplicitDefault bool) error + DeleteProjectKnowledgeByRefAccountChecked(ctx context.Context, actor, projectID, ref string, allowImplicitDefault bool) error + DeletePluginProjectKnowledgeByRefAccountChecked(ctx context.Context, actor, projectID, ref, pluginID string, allowImplicitDefault bool) error + ReplaceKnowledgeAccountChecked(ctx context.Context, actor, pluginID string, scoped, allowImplicitDefault bool, docs ...core.KnowledgeDoc) error + UpsertKnowledgeVectorExtAccountChecked(ctx context.Context, actor, docID, ref, model, contentHash string, vec []float32, allowImplicitDefault bool) error + DeleteKnowledgeVectorsAccountChecked(ctx context.Context, actor, ref string, allowImplicitDefault bool) error +} + +func knowledgeWriter(t *testing.T, st *Store) knowledgeAccountWriter { + t.Helper() + w, ok := any(st).(knowledgeAccountWriter) + if !ok { + t.Fatal("Store is missing the account-checked knowledge and vector API") + } + return w +} + +func knowledgeRows(t *testing.T, st *Store, table, owner, ref string) int { + t.Helper() + var n int + if err := st.DB().QueryRow(`SELECT COUNT(*) FROM `+table+` WHERE owner=? AND ref=?`, owner, ref).Scan(&n); err != nil { + t.Fatal(err) + } + return n +} + +func TestKnowledgeAccountFenceRejectsDisabledAndMixedOwnerBatch(t *testing.T) { + st := openArtifactFenceStore(t, filepath.Join(t.TempDir(), "knowledge.db")) + w := knowledgeWriter(t, st) + if err := st.EnsureUser("alice", ""); err != nil { + t.Fatal(err) + } + good := core.KnowledgeDoc{ID: "knw-good", Owner: "alice", Ref: "batch", Source: "manual", Chunk: "good"} + foreign := core.KnowledgeDoc{ID: "knw-foreign", Owner: "bob", Ref: "batch", Source: "manual", Chunk: "foreign"} + if err := w.AddKnowledgeAccountChecked("alice", false, good, foreign); !errors.Is(err, ErrNotFound) { + t.Fatalf("mixed owner batch: got %v, want ErrNotFound", err) + } + if n := knowledgeRows(t, st, "knowledge", "alice", "batch"); n != 0 { + t.Fatalf("mixed owner batch wrote %d rows", n) + } + if _, err := st.DB().Exec(`UPDATE users SET enabled=FALSE WHERE owner='alice'`); err != nil { + t.Fatal(err) + } + if err := w.AddKnowledgeAccountChecked("alice", false, good); !errors.Is(err, ErrAccountForbidden) { + t.Fatalf("disabled add: got %v, want ErrAccountForbidden", err) + } + if n := knowledgeRows(t, st, "knowledge", "alice", "batch"); n != 0 { + t.Fatalf("disabled add wrote %d rows", n) + } +} + +func TestKnowledgeAccountFenceDeletesOnlyExactSourceAndScope(t *testing.T) { + ctx := context.Background() + st := openArtifactFenceStore(t, filepath.Join(t.TempDir(), "knowledge.db")) + w := knowledgeWriter(t, st) + for _, owner := range []string{"alice", "bob"} { + if err := st.EnsureUser(owner, ""); err != nil { + t.Fatal(err) + } + } + docs := []core.KnowledgeDoc{ + {ID: "knw-a1", Owner: "alice", ProjectID: "p1", Ref: "r1", Source: "plugin:a_b", Chunk: "a1"}, + {ID: "knw-a2", Owner: "alice", ProjectID: "p1", Ref: "r1", Source: "plugin:a_b:child", Chunk: "a2"}, + {ID: "knw-a3", Owner: "alice", ProjectID: "p1", Ref: "r1", Source: "plugin:axb", Chunk: "a3"}, + {ID: "knw-a4", Owner: "alice", ProjectID: "p2", Ref: "r1", Source: "plugin:a_b", Chunk: "a4"}, + {ID: "knw-b1", Owner: "bob", ProjectID: "p1", Ref: "r1", Source: "plugin:a_b", Chunk: "b1"}, + } + if err := st.AddKnowledge(docs...); err != nil { + t.Fatal(err) + } + for _, d := range docs { + if err := st.UpsertKnowledgeVectorExt(ctx, string(d.Owner), string(d.ID), d.Ref, "test", "hash", []float32{1, 0}); err != nil { + t.Fatal(err) + } + } + if n, err := w.DeleteKnowledgeBySourceAccountChecked(ctx, "alice", "plugin:a_b", false); err != nil || n != 3 { + t.Fatalf("exact source delete: %d, %v; want 3", n, err) + } + if n := knowledgeRows(t, st, "knowledge", "alice", "r1"); n != 1 { + t.Fatalf("literal prefix matched unrelated source: %d alice rows remain", n) + } + if n := knowledgeRows(t, st, "knowledge_vectors", "alice", "r1"); n != 1 { + t.Fatalf("source delete left wrong vector set: %d", n) + } + if n := knowledgeRows(t, st, "knowledge", "bob", "r1"); n != 1 { + t.Fatalf("source delete crossed owner: %d", n) + } + if err := w.DeletePluginProjectKnowledgeByRefAccountChecked(ctx, "alice", "p1", "r1", "axb", false); err != nil { + t.Fatal(err) + } + if n := knowledgeRows(t, st, "knowledge", "alice", "r1"); n != 0 { + t.Fatalf("plugin scoped delete left %d rows", n) + } +} + +func TestKnowledgeAccountFenceProjectPluginAndRefDeletesKeepOtherScopes(t *testing.T) { + ctx := context.Background() + st := openArtifactFenceStore(t, filepath.Join(t.TempDir(), "knowledge.db")) + w := knowledgeWriter(t, st) + if err := st.EnsureUser("alice", ""); err != nil { + t.Fatal(err) + } + docs := []core.KnowledgeDoc{ + {ID: "knw-p1-a", Owner: "alice", ProjectID: "p1", Ref: "shared", Source: "plugin:alpha", Chunk: "one"}, + {ID: "knw-p1-b", Owner: "alice", ProjectID: "p1", Ref: "shared", Source: "plugin:beta", Chunk: "two"}, + {ID: "knw-p2-a", Owner: "alice", ProjectID: "p2", Ref: "shared", Source: "plugin:alpha", Chunk: "three"}, + } + if err := st.AddKnowledge(docs...); err != nil { + t.Fatal(err) + } + for _, d := range docs { + if err := st.UpsertKnowledgeVectorExt(ctx, "alice", string(d.ID), d.Ref, "test", "hash", []float32{1, 0}); err != nil { + t.Fatal(err) + } + } + if err := w.DeletePluginProjectKnowledgeByRefAccountChecked(ctx, "alice", "p1", "shared", "alpha", false); err != nil { + t.Fatal(err) + } + if n := knowledgeRows(t, st, "knowledge", "alice", "shared"); n != 2 { + t.Fatalf("plugin delete removed other scope: %d remain", n) + } + if err := w.DeleteProjectKnowledgeByRefAccountChecked(ctx, "alice", "p1", "shared", false); err != nil { + t.Fatal(err) + } + if n := knowledgeRows(t, st, "knowledge", "alice", "shared"); n != 1 { + t.Fatalf("project delete removed other project: %d remain", n) + } + if err := w.DeleteKnowledgeByRefAccountChecked(ctx, "alice", "shared", false); err != nil { + t.Fatal(err) + } + if n := knowledgeRows(t, st, "knowledge", "alice", "shared"); n != 0 { + t.Fatalf("ref delete left %d knowledge rows", n) + } + if n := knowledgeRows(t, st, "knowledge_vectors", "alice", "shared"); n != 0 { + t.Fatalf("ref delete left %d vectors", n) + } +} + +func TestKnowledgeAccountFenceVectorRequiresOwnedMatchingKnowledge(t *testing.T) { + ctx := context.Background() + st := openArtifactFenceStore(t, filepath.Join(t.TempDir(), "knowledge.db")) + w := knowledgeWriter(t, st) + for _, owner := range []string{"alice", "bob"} { + if err := st.EnsureUser(owner, ""); err != nil { + t.Fatal(err) + } + } + if err := st.AddKnowledge( + core.KnowledgeDoc{ID: "knw-alice", Owner: "alice", Ref: "a", Chunk: "alice"}, + core.KnowledgeDoc{ID: "knw-bob", Owner: "bob", Ref: "b", Chunk: "bob"}, + ); err != nil { + t.Fatal(err) + } + if err := w.UpsertKnowledgeVectorExtAccountChecked(ctx, "alice", "knw-bob", "b", "test", "hash", []float32{1, 0}, false); !errors.Is(err, ErrNotFound) { + t.Fatalf("foreign doc vector: got %v, want ErrNotFound", err) + } + if err := w.UpsertKnowledgeVectorExtAccountChecked(ctx, "alice", "knw-alice", "wrong-ref", "test", "hash", []float32{1, 0}, false); !errors.Is(err, ErrNotFound) { + t.Fatalf("mismatched ref vector: got %v, want ErrNotFound", err) + } + if err := st.UpsertKnowledgeVectorExt(ctx, "bob", "knw-alice", "a", "legacy", "hash", []float32{0, 1}); err != nil { + t.Fatal(err) + } + if err := w.UpsertKnowledgeVectorExtAccountChecked(ctx, "alice", "knw-alice", "a", "test", "hash", []float32{1, 0}, false); !errors.Is(err, ErrNotFound) { + t.Fatalf("foreign existing vector takeover: got %v, want ErrNotFound", err) + } + if err := st.DeleteKnowledgeVectors(ctx, "bob", "a"); err != nil { + t.Fatal(err) + } + if err := w.UpsertKnowledgeVectorExtAccountChecked(ctx, "alice", "knw-alice", "a", "test", "hash", []float32{1, 0}, false); err != nil { + t.Fatal(err) + } + if _, err := st.DB().Exec(`UPDATE users SET enabled=FALSE WHERE owner='alice'`); err != nil { + t.Fatal(err) + } + if err := w.UpsertKnowledgeVectorExtAccountChecked(ctx, "alice", "knw-alice", "a", "test", "hash2", []float32{0, 1}, false); !errors.Is(err, ErrAccountForbidden) { + t.Fatalf("disabled vector upsert: got %v, want ErrAccountForbidden", err) + } + if err := w.DeleteKnowledgeVectorsAccountChecked(ctx, "alice", "a", false); !errors.Is(err, ErrAccountForbidden) { + t.Fatalf("disabled vector delete: got %v, want ErrAccountForbidden", err) + } + if n := knowledgeRows(t, st, "knowledge_vectors", "alice", "a"); n != 1 { + t.Fatalf("disabled actor changed %d vector rows", n) + } + if _, err := st.DB().Exec(`UPDATE users SET enabled=TRUE WHERE owner='alice'`); err != nil { + t.Fatal(err) + } + if err := w.DeleteKnowledgeVectorsAccountChecked(ctx, "alice", "a", false); err != nil { + t.Fatal(err) + } + if n := knowledgeRows(t, st, "knowledge_vectors", "alice", "a"); n != 0 { + t.Fatalf("guarded vector delete left %d rows", n) + } +} + +func TestKnowledgeAccountFenceReplaceRollsBackDeletionWhenInsertFails(t *testing.T) { + ctx := context.Background() + st := openArtifactFenceStore(t, filepath.Join(t.TempDir(), "knowledge.db")) + w := knowledgeWriter(t, st) + if err := st.EnsureUser("alice", ""); err != nil { + t.Fatal(err) + } + old := core.KnowledgeDoc{ID: "knw-old", Owner: "alice", ProjectID: "p1", Ref: "shared", Source: "plugin:alpha", Chunk: "old"} + other := core.KnowledgeDoc{ID: "knw-fixed", Owner: "alice", ProjectID: "p1", Ref: "shared", Source: "plugin:beta", Chunk: "other plugin"} + otherProject := core.KnowledgeDoc{ID: "knw-p2", Owner: "alice", ProjectID: "p2", Ref: "shared", Source: "plugin:alpha", Chunk: "other project"} + if err := st.AddKnowledge(old, other, otherProject); err != nil { + t.Fatal(err) + } + if err := st.UpsertKnowledgeVectorExt(ctx, "alice", string(old.ID), old.Ref, "test", "hash", []float32{1, 0}); err != nil { + t.Fatal(err) + } + first := core.KnowledgeDoc{ID: "knw-new", Owner: "alice", ProjectID: "p1", Ref: "shared", Source: "plugin:alpha", Chunk: "replacement"} + duplicate := core.KnowledgeDoc{ID: other.ID, Owner: "alice", ProjectID: "p1", Ref: "shared", Source: "plugin:alpha", Chunk: "duplicate id"} + if err := w.ReplaceKnowledgeAccountChecked(ctx, "alice", "alpha", true, false, first, duplicate); err == nil { + t.Fatal("replacement with duplicate ID unexpectedly committed") + } + var n int + if err := st.DB().QueryRow(`SELECT COUNT(*) FROM knowledge WHERE id=?`, old.ID).Scan(&n); err != nil || n != 1 { + t.Fatalf("failed replacement removed original row: %d, %v", n, err) + } + if err := st.DB().QueryRow(`SELECT COUNT(*) FROM knowledge WHERE id=?`, first.ID).Scan(&n); err != nil || n != 0 { + t.Fatalf("failed replacement inserted first row: %d, %v", n, err) + } + if n := knowledgeRows(t, st, "knowledge_vectors", "alice", "shared"); n != 1 { + t.Fatalf("failed replacement removed original vector: %d", n) + } + if err := w.ReplaceKnowledgeAccountChecked(ctx, "alice", "alpha", true, false, first); err != nil { + t.Fatal(err) + } + if err := st.DB().QueryRow(`SELECT COUNT(*) FROM knowledge WHERE id IN (?,?,?)`, first.ID, other.ID, otherProject.ID).Scan(&n); err != nil || n != 3 { + t.Fatalf("successful replacement lost other scopes: %d, %v", n, err) + } + if err := st.DB().QueryRow(`SELECT COUNT(*) FROM knowledge WHERE id=?`, old.ID).Scan(&n); err != nil || n != 0 { + t.Fatalf("successful replacement kept old row: %d, %v", n, err) + } + if n := knowledgeRows(t, st, "knowledge_vectors", "alice", "shared"); n != 0 { + t.Fatalf("successful replacement kept old vector: %d", n) + } +} diff --git a/internal/store/knowledge_owned_account_write.go b/internal/store/knowledge_owned_account_write.go new file mode 100644 index 0000000..950be78 --- /dev/null +++ b/internal/store/knowledge_owned_account_write.go @@ -0,0 +1,35 @@ +package store + +import ( + "database/sql" + "errors" + + core "agent-system-core" +) + +// AddKnowledgeForOwnedProjectAccountChecked is the HTTP owner's project path. +// Domain/plugin publication has a separate authorization callback and uses the +// broader account-checked method; this entry rechecks physical ownership in +// the final Backend transaction. +func (s *Store) AddKnowledgeForOwnedProjectAccountChecked(actor, projectID string, allowImplicitDefault bool, docs ...core.KnowledgeDoc) error { + if len(docs) == 0 { + return nil + } + for _, doc := range docs { + if doc.Owner != core.ID(actor) || doc.ProjectID != core.ID(projectID) { + return ErrNotFound + } + } + return s.withEffectiveAccountWrite(actor, allowImplicitDefault, func(tx *sql.Tx, _ Account) error { + var owner string + if err := tx.QueryRow(s.q(`SELECT owner FROM projects WHERE id=?`), projectID).Scan(&owner); errors.Is(err, sql.ErrNoRows) { + return ErrNotFound + } else if err != nil { + return err + } + if owner != actor { + return ErrNotFound + } + return s.addKnowledgeTx(tx, docs) + }) +} diff --git a/internal/store/login_key_admin_write.go b/internal/store/login_key_admin_write.go new file mode 100644 index 0000000..a34479e --- /dev/null +++ b/internal/store/login_key_admin_write.go @@ -0,0 +1,66 @@ +package store + +import ( + "database/sql" + "errors" +) + +// RevokeLoginKeyCascadeAdmin rechecks the actual administrator and the target +// grant under sorted account locks before revoking it. The path owner is an +// exact-match locator, never an authorization source. +func (s *Store) RevokeLoginKeyCascadeAdmin(actor, targetOwner, id string) error { + if actor == "" || targetOwner == "" || id == "" { + return ErrNotFound + } + var routeOwner string + if err := s.db.QueryRow(s.q(`SELECT owner FROM login_keys WHERE id=?`), id).Scan(&routeOwner); errors.Is(err, sql.ErrNoRows) { + return ErrNotFound + } else if err != nil { + return err + } + if routeOwner != targetOwner { + return ErrNotFound + } + tx, err := s.db.Begin() + if err != nil { + return err + } + defer tx.Rollback() + locked, err := s.lockAccountUsers(tx, actor, targetOwner) + if errors.Is(err, ErrNotFound) { + return ErrAccountForbidden + } + if err != nil { + return err + } + if !locked[actor].Enabled || !locked[actor].IsAdmin { + return ErrAccountForbidden + } + query := s.q(`SELECT owner FROM login_keys WHERE id=?`) + if s.Dialect() == "postgres" { + query += ` FOR UPDATE` + } + var currentOwner string + if err := tx.QueryRow(query, id).Scan(¤tOwner); errors.Is(err, sql.ErrNoRows) { + return ErrNotFound + } else if err != nil { + return err + } + if currentOwner != targetOwner { + return ErrNotFound + } + if _, err := tx.Exec(s.q(`UPDATE login_keys SET status=? WHERE id=? AND status=?`), + LoginKeyStatusRevoked, id, LoginKeyStatusUnused); err != nil { + return err + } + if _, err := tx.Exec(s.q(`DELETE FROM sessions WHERE owner=? AND scope=?`), targetOwner, SessionScopeSetPasswordOnly); err != nil { + return err + } + if err := s.insertAccountEvent(tx, actor, "admin-http", targetOwner, "login_key_revoked", id); err != nil { + return err + } + if err := s.auditAccountMutation(tx, targetOwner, actor, "account.login_key.revoke"); err != nil { + return err + } + return tx.Commit() +} diff --git a/internal/store/message_choice_account_write.go b/internal/store/message_choice_account_write.go new file mode 100644 index 0000000..0526252 --- /dev/null +++ b/internal/store/message_choice_account_write.go @@ -0,0 +1,64 @@ +package store + +import ( + "database/sql" + "errors" +) + +// ExcludeRunGroupAccountChecked and SelectVariantAccountChecked recheck the +// message's persisted conversation owner after the actor lock. The entire +// group update commits behind the same disable fence. +func (s *Store) ExcludeRunGroupAccountChecked(actor, id string, excluded, allowImplicitDefault bool) error { + return s.mutateMessageChoiceAccountChecked(actor, id, excluded, false, allowImplicitDefault) +} + +func (s *Store) SelectVariantAccountChecked(actor, id string, allowImplicitDefault bool) error { + return s.mutateMessageChoiceAccountChecked(actor, id, false, true, allowImplicitDefault) +} + +func (s *Store) mutateMessageChoiceAccountChecked(actor, id string, excluded, selectVariant, allowImplicitDefault bool) error { + var routeConversation string + if err := s.db.QueryRow(s.q(`SELECT conversation_id FROM messages WHERE id=?`), id).Scan(&routeConversation); errors.Is(err, sql.ErrNoRows) { + return ErrNotFound + } else if err != nil { + return err + } + return s.withEffectiveAccountWrite(actor, allowImplicitDefault, func(tx *sql.Tx, _ Account) error { + if err := s.dialect.lockConversation(tx, routeConversation); err != nil { + return err + } + query := s.q(`SELECT m.run_id,m.variant_group,m.conversation_id,c.owner FROM messages m + JOIN conversations c ON c.id=m.conversation_id WHERE m.id=?`) + if s.Dialect() == "postgres" { + query += ` FOR UPDATE OF m` + } + var runID, group, conversationID, owner string + if err := tx.QueryRow(query, id).Scan(&runID, &group, &conversationID, &owner); errors.Is(err, sql.ErrNoRows) { + return ErrNotFound + } else if err != nil { + return err + } + if owner != actor || conversationID != routeConversation { + return ErrNotFound + } + if selectVariant && group != "" { + if _, err := tx.Exec(s.q(`UPDATE messages SET excluded=1 WHERE conversation_id=? AND variant_group=?`), conversationID, group); err != nil { + return err + } + _, err := tx.Exec(s.q(`UPDATE messages SET excluded=0 WHERE id=? AND conversation_id=?`), id, conversationID) + return err + } + flag := 0 + if excluded { + flag = 1 + } + if _, err := tx.Exec(s.q(`UPDATE messages SET excluded=? WHERE id=? AND conversation_id=?`), flag, id, conversationID); err != nil { + return err + } + if runID != "" { + _, err := tx.Exec(s.q(`UPDATE messages SET excluded=? WHERE run_id=? AND conversation_id=?`), flag, runID, conversationID) + return err + } + return nil + }) +} diff --git a/internal/store/messages.go b/internal/store/messages.go index 58724f2..e5a1f31 100644 --- a/internal/store/messages.go +++ b/internal/store/messages.go @@ -35,6 +35,16 @@ func (s *Store) AppendMessage(m *core.Message) error { // AppendMessageWithRequest 同 AppendMessage,并记录可选的 request_id。 // 同一会话里非空 request_id 已存在时,把 *m 换成已有行并返回 DuplicateRequestError。 func (s *Store) AppendMessageWithRequest(m *core.Message, requestID string) error { + return s.appendMessageWithRequest(m, requestID, "", false, false) +} + +// AppendMessageWithRequestAccountChecked fences the final user-message write +// against account disable. The actor lock precedes the conversation lock. +func (s *Store) AppendMessageWithRequestAccountChecked(actor string, allowImplicitDefault bool, m *core.Message, requestID string) error { + return s.appendMessageWithRequest(m, requestID, actor, allowImplicitDefault, true) +} + +func (s *Store) appendMessageWithRequest(m *core.Message, requestID, actor string, allowImplicitDefault, guarded bool) error { if m.ID == "" { m.ID = newID("msg") } @@ -46,9 +56,8 @@ func (s *Store) AppendMessageWithRequest(m *core.Message, requestID string) erro // 竞态在锁下消失。唯一约束仍留作兜底——冲突只剩事务边界外的极端 // 竞态(如手工写入),小退避重试即可收敛。 for attempt := 0; attempt < 8; attempt++ { - err := s.appendMessageTx(m, requestID, insert) + err := s.appendMessageTx(m, requestID, insert, actor, allowImplicitDefault, guarded) if err == nil { - s.TouchConversation(m.ConversationID) return nil } if !isUniqueViolation(err) { @@ -72,19 +81,43 @@ func (s *Store) AppendMessageWithRequest(m *core.Message, requestID string) erro // appendMessageTx 单事务完成同会话消息追加:会话锁 → 序号分配 → INSERT。 // 请求幂等依赖 uq_messages_conv_request 唯一索引在事务内暴露冲突。 -func (s *Store) appendMessageTx(m *core.Message, requestID, insert string) error { +func (s *Store) appendMessageTx(m *core.Message, requestID, insert, actor string, allowImplicitDefault, guarded bool) error { + if guarded { + return s.withEffectiveAccountWrite(actor, allowImplicitDefault, func(tx *sql.Tx, _ Account) error { + return s.appendMessageRows(tx, m, requestID, insert, actor) + }) + } tx, err := s.db.Begin() if err != nil { return err } defer tx.Rollback() + if err := s.appendMessageRows(tx, m, requestID, insert, ""); err != nil { + return err + } + return tx.Commit() +} + +func (s *Store) appendMessageRows(tx *sql.Tx, m *core.Message, requestID, insert, actor string) error { + if actor != "" { + var owner string + if err := tx.QueryRow(s.q(`SELECT owner FROM conversations WHERE id=?`), m.ConversationID).Scan(&owner); errors.Is(err, sql.ErrNoRows) { + return ErrNotFound + } else if err != nil { + return err + } + if owner != actor { + return ErrNotFound + } + } if err := s.dialect.lockConversation(tx, string(m.ConversationID)); err != nil { return err } if _, err := tx.Exec(s.q(insert), m.ID, m.ConversationID, m.RunID, string(m.Role), marshalJSON(m.Parts), nowISO(), requestID, m.ConversationID); err != nil { return err } - return tx.Commit() + _, err := tx.Exec(s.q(`UPDATE conversations SET updated_at=? WHERE id=?`), nowISO(), m.ConversationID) + return err } func (s *Store) ListMessages(conversationID string) ([]core.Message, error) { @@ -278,6 +311,83 @@ func (s *Store) appendEvents(conversationID string, evs []core.StreamEvent, owne return 0, err } defer tx.Rollback() + base, err := s.appendEventsInTx(tx, conversationID, evs, owner, producer) + if err != nil { + return 0, err + } + if err := tx.Commit(); err != nil { + return 0, err + } + assignEventSequences(evs, base) + return base, nil +} + +// AppendEventsAccountChecked is the final persistence boundary for an +// actor-attributed card/surface event. The actor account lock precedes the +// channel sequence lock and event insert. +func (s *Store) AppendEventsAccountChecked(actor, conversationID string, evs []core.StreamEvent, allowImplicitDefault bool) (int64, error) { + if len(evs) == 0 { + return 0, nil + } + var base int64 + err := s.withEffectiveAccountWrite(actor, allowImplicitDefault, func(tx *sql.Tx, _ Account) error { + if projectID, projectChannel := core.ProjectFromChannel(core.ID(conversationID)); projectChannel { + var one int + if err := tx.QueryRow(s.q(`SELECT 1 FROM projects WHERE id=?`), projectID).Scan(&one); errors.Is(err, sql.ErrNoRows) { + return ErrNotFound + } else if err != nil { + return err + } + } else { + var owner string + if err := tx.QueryRow(s.q(`SELECT owner FROM conversations WHERE id=?`), conversationID).Scan(&owner); errors.Is(err, sql.ErrNoRows) { + return ErrNotFound + } else if err != nil { + return err + } + if owner != actor { + return ErrNotFound + } + } + var err error + base, err = s.appendEventsInTx(tx, conversationID, evs, "", "") + return err + }) + if err != nil { + return 0, err + } + assignEventSequences(evs, base) + return base, nil +} + +// AppendEventsForEffectiveChannel resolves an internal run event's account +// from the persisted channel before entering the final guarded append. Card +// and Surface APIs use their explicit actor-aware emitter instead. +func (s *Store) AppendEventsForEffectiveChannel(conversationID string, evs []core.StreamEvent, allowImplicitDefault bool) (int64, error) { + var owner string + if projectID, projectChannel := core.ProjectFromChannel(core.ID(conversationID)); projectChannel { + if err := s.db.QueryRow(s.q(`SELECT owner FROM projects WHERE id=?`), projectID).Scan(&owner); errors.Is(err, sql.ErrNoRows) { + return 0, ErrNotFound + } else if err != nil { + return 0, err + } + } else { + if err := s.db.QueryRow(s.q(`SELECT owner FROM conversations WHERE id=?`), conversationID).Scan(&owner); errors.Is(err, sql.ErrNoRows) { + return 0, ErrNotFound + } else if err != nil { + return 0, err + } + } + return s.AppendEventsAccountChecked(owner, conversationID, evs, allowImplicitDefault) +} + +func assignEventSequences(evs []core.StreamEvent, base int64) { + for i := range evs { + evs[i].Seq = base + int64(i) + 1 + } +} + +func (s *Store) appendEventsInTx(tx *sql.Tx, conversationID string, evs []core.StreamEvent, owner, producer string) (int64, error) { if err := s.dialect.lockConversation(tx, conversationID); err != nil { return 0, err } @@ -317,12 +427,6 @@ func (s *Store) appendEvents(conversationID string, evs []core.StreamEvent, owne return 0, err } } - if err := tx.Commit(); err != nil { - return 0, err - } - for i := range evs { - evs[i].Seq = base.Int64 + int64(i) + 1 - } return base.Int64, nil } diff --git a/internal/store/migrate.go b/internal/store/migrate.go index 8e19df1..23ea6ce 100644 --- a/internal/store/migrate.go +++ b/internal/store/migrate.go @@ -882,6 +882,21 @@ func init() { up: map[string][]string{"sqlite": recoveryFoundationUp, "postgres": recoveryFoundationUp}, down: map[string][]string{"sqlite": recoveryFoundationDown, "postgres": recoveryFoundationDown}, }, + migration{version: 34, name: "shared_account_cursor_key", + up: map[string][]string{"sqlite": { + `ALTER TABLE account_directory_revision ADD COLUMN cursor_key_version INTEGER NOT NULL DEFAULT 1`, + `ALTER TABLE account_directory_revision ADD COLUMN cursor_secret TEXT NOT NULL DEFAULT ''`, + }, "postgres": { + `ALTER TABLE account_directory_revision ADD COLUMN cursor_key_version INTEGER NOT NULL DEFAULT 1`, + `ALTER TABLE account_directory_revision ADD COLUMN cursor_secret TEXT NOT NULL DEFAULT ''`, + }}, + down: map[string][]string{"sqlite": { + `ALTER TABLE account_directory_revision DROP COLUMN cursor_secret`, + `ALTER TABLE account_directory_revision DROP COLUMN cursor_key_version`, + }, "postgres": { + `ALTER TABLE account_directory_revision DROP COLUMN cursor_secret`, + `ALTER TABLE account_directory_revision DROP COLUMN cursor_key_version`, + }}}, ) } diff --git a/internal/store/migrate_compat.go b/internal/store/migrate_compat.go index e22c16f..066fcbd 100644 --- a/internal/store/migrate_compat.go +++ b/internal/store/migrate_compat.go @@ -38,6 +38,7 @@ var migrationBreaksOldReaders = map[int]bool{ 31: false, // account_audit 给 audit_events 增加带默认值的列,旧制品按列名读取 32: true, // 账号有效态/凭据来源是安全协议边界;旧认证写者必须排除 33: true, // 本人证明与 grant 绑定协议;旧恢复写者和兑换者必须排除 + 34: false, // 游标签名密钥只新增内部列;旧读者按列名读取既有字段 } // SchemaTarget 是本二进制会迁移到的 schema 版本。 diff --git a/internal/store/notices.go b/internal/store/notices.go index 72e5327..f497c7a 100644 --- a/internal/store/notices.go +++ b/internal/store/notices.go @@ -25,6 +25,25 @@ func (s *Store) AppendNotice(n Notice) error { return err } +// AppendNoticeAccountChecked stores a new result notice only while the owner +// is effective in the same transaction that creates the notice. +func (s *Store) AppendNoticeAccountChecked(n Notice, allowImplicitDefault bool) error { + return s.withEffectiveAccountWrite(n.Owner, allowImplicitDefault, func(tx *sql.Tx, _ Account) error { + var owner string + if err := tx.QueryRow(s.q(`SELECT owner FROM conversations WHERE id=?`), n.ConversationID).Scan(&owner); errors.Is(err, sql.ErrNoRows) { + return ErrNotFound + } else if err != nil { + return err + } + if owner != n.Owner { + return ErrNotFound + } + _, err := tx.Exec(s.q(`INSERT INTO pending_notices (id,conversation_id,owner,name,payload,created_at) VALUES(?,?,?,?,?,?)`), + n.ID, n.ConversationID, n.Owner, n.Name, marshalJSON(&n.Payload), nowISO()) + return err + }) +} + // ListNotices 取回全部待投递通知(Manager 启动时装入内存队列)。 func (s *Store) ListNotices() ([]Notice, error) { rows, err := s.db.Query(s.q(`SELECT id,conversation_id,owner,name,payload,created_at FROM pending_notices ORDER BY created_at`)) diff --git a/internal/store/plan_todo_account_write.go b/internal/store/plan_todo_account_write.go new file mode 100644 index 0000000..4255096 --- /dev/null +++ b/internal/store/plan_todo_account_write.go @@ -0,0 +1,39 @@ +package store + +import ( + "database/sql" + "errors" + + core "agent-system-core" +) + +func (s *Store) SavePlanAccountChecked(actor, conversationID string, plan core.PlanSnapshot, allowImplicitDefault bool) error { + return s.saveConversationPayloadAccountChecked(actor, conversationID, "plans", marshalJSON(plan), allowImplicitDefault) +} + +func (s *Store) SaveTodosAccountChecked(actor, conversationID string, todos core.TodoList, allowImplicitDefault bool) error { + return s.saveConversationPayloadAccountChecked(actor, conversationID, "todos", marshalJSON(todos), allowImplicitDefault) +} + +func (s *Store) saveConversationPayloadAccountChecked(actor, conversationID, table, payload string, allowImplicitDefault bool) error { + if table != "plans" && table != "todos" { + return ErrAccountForbidden + } + return s.withEffectiveAccountWrite(actor, allowImplicitDefault, func(tx *sql.Tx, _ Account) error { + if err := s.dialect.lockConversation(tx, conversationID); err != nil { + return err + } + var owner string + if err := tx.QueryRow(s.q(`SELECT owner FROM conversations WHERE id=?`), conversationID).Scan(&owner); errors.Is(err, sql.ErrNoRows) { + return ErrNotFound + } else if err != nil { + return err + } + if owner != actor { + return ErrNotFound + } + _, err := tx.Exec(s.q(`INSERT INTO `+table+`(conversation_id,payload) VALUES(?,?) + ON CONFLICT(conversation_id) DO UPDATE SET payload=excluded.payload`), conversationID, payload) + return err + }) +} diff --git a/internal/store/platform.go b/internal/store/platform.go index 0c11aa8..2b65b9d 100644 --- a/internal/store/platform.go +++ b/internal/store/platform.go @@ -2,6 +2,7 @@ package store import ( "database/sql" + "errors" "fmt" "time" @@ -235,6 +236,37 @@ func (s *Store) SavePendingConfirm(cardID, owner, payload string) error { return err } +func (s *Store) SavePendingConfirmAccountChecked(cardID, owner, payload string, allowImplicitDefault bool) error { + now := time.Now().UTC().Format(time.RFC3339) + return s.withEffectiveAccountWrite(owner, allowImplicitDefault, func(tx *sql.Tx, _ Account) error { + _, err := tx.Exec(s.q(`INSERT INTO pending_confirms(card_id,owner,payload,created_at) VALUES(?,?,?,?) + ON CONFLICT(card_id) DO UPDATE SET owner=excluded.owner, payload=excluded.payload, created_at=excluded.created_at`), + cardID, owner, payload, now) + return err + }) +} + +func (s *Store) TakePendingConfirmAccountChecked(cardID, actor string, allowImplicitDefault bool) (owner, payload string, ok bool, err error) { + err = s.withEffectiveAccountWrite(actor, allowImplicitDefault, func(tx *sql.Tx, _ Account) error { + err := tx.QueryRow(s.q(`SELECT owner,payload FROM pending_confirms WHERE card_id=?`), cardID).Scan(&owner, &payload) + if errors.Is(err, sql.ErrNoRows) { + return nil + } + if err != nil { + return err + } + if owner != actor { + return ErrNotFound + } + if _, err := tx.Exec(s.q(`DELETE FROM pending_confirms WHERE card_id=? AND owner=?`), cardID, actor); err != nil { + return err + } + ok = true + return nil + }) + return owner, payload, ok, err +} + func (s *Store) TakePendingConfirm(cardID string) (owner, payload string, ok bool, err error) { tx, err := s.db.Begin() if err != nil { @@ -279,6 +311,18 @@ type BudgetStatus struct { } func (s *Store) AccountTokens(owner string, tokens, budget int) (BudgetStatus, error) { + if tokens == 0 { + day := time.Now().UTC().Format("2006-01-02") + var total int + err := s.db.QueryRow(s.q(`SELECT tokens FROM llm_usage_daily WHERE owner=? AND day=?`), owner, day).Scan(&total) + if errors.Is(err, sql.ErrNoRows) { + return BudgetStatus{Over: false}, nil + } + if err != nil { + return BudgetStatus{}, fmt.Errorf("llm usage: %w", err) + } + return BudgetStatus{Total: total, Over: budget > 0 && total > budget}, nil + } total, err := s.AddDailyTokens(owner, "", tokens) if err != nil { return BudgetStatus{}, fmt.Errorf("llm usage: %w", err) diff --git a/internal/store/platform_account_write.go b/internal/store/platform_account_write.go new file mode 100644 index 0000000..cea41a6 --- /dev/null +++ b/internal/store/platform_account_write.go @@ -0,0 +1,95 @@ +package store + +import ( + "database/sql" + "time" + + core "agent-system-core" +) + +func (s *Store) CreateBackgroundTaskAccountChecked(actor, kind, title, conversationID string, allowImplicitDefault bool) (BackgroundTask, error) { + now := time.Now().UTC().Format(time.RFC3339) + task := BackgroundTask{ID: NewID("task"), Owner: actor, Kind: kind, Title: title, Status: "queued", + ConversationID: conversationID, CreatedAt: now, UpdatedAt: now} + err := s.withEffectiveAccountWrite(actor, allowImplicitDefault, func(tx *sql.Tx, _ Account) error { + _, err := tx.Exec(s.q(`INSERT INTO background_tasks(id,owner,kind,title,status,progress,error,conversation_id,created_at,updated_at) + VALUES(?,?,?,?,?,?,?,?,?,?)`), task.ID, actor, task.Kind, task.Title, task.Status, task.Progress, + task.Error, task.ConversationID, task.CreatedAt, task.UpdatedAt) + return err + }) + return task, err +} + +func (s *Store) UpdateBackgroundTaskAccountChecked(actor, id, status string, progress int, errText string, allowImplicitDefault bool) error { + return s.withEffectiveAccountWrite(actor, allowImplicitDefault, func(tx *sql.Tx, _ Account) error { + res, err := tx.Exec(s.q(`UPDATE background_tasks SET status=?,progress=?,error=?,updated_at=? WHERE id=? AND owner=?`), + status, progress, errText, time.Now().UTC().Format(time.RFC3339), id, actor) + if err != nil { + return err + } + if n, err := res.RowsAffected(); err != nil { + return err + } else if n != 1 { + return ErrNotFound + } + return nil + }) +} + +func (s *Store) PutMemoryAccountChecked(actor string, entry core.MemoryEntry, allowImplicitDefault bool) (core.MemoryEntry, error) { + if entry.ID == "" { + entry.ID = NewID("mem") + } + now := time.Now().UTC() + if entry.CreatedAt.IsZero() { + entry.CreatedAt = now + } + entry.LastAccessed = now + expiry := "" + if entry.ExpiresAt != nil { + expiry = entry.ExpiresAt.UTC().Format(time.RFC3339) + } + err := s.withEffectiveAccountWrite(actor, allowImplicitDefault, func(tx *sql.Tx, _ Account) error { + _, err := tx.Exec(s.q(`INSERT INTO memory_entries(id,owner,scope,project_id,typ,content,source,confidence,created_at,last_accessed,expires_at) + VALUES(?,?,?,?,?,?,?,?,?,?,?)`), entry.ID, actor, entry.Scope, string(entry.ProjectID), entry.Type, + entry.Content, entry.Source, entry.Confidence, entry.CreatedAt.UTC().Format(time.RFC3339), + entry.LastAccessed.UTC().Format(time.RFC3339), expiry) + return err + }) + return entry, err +} + +func (s *Store) AppendAuditEventAccountChecked(actor string, event AuditEvent, allowImplicitDefault bool) error { + event.Owner = actor + if event.Actor == "" { + event.Actor = actor + } + if event.Time == "" { + event.Time = time.Now().UTC().Format(time.RFC3339) + } + return s.withEffectiveAccountWrite(actor, allowImplicitDefault, func(tx *sql.Tx, _ Account) error { + _, err := tx.Exec(s.q(`INSERT INTO audit_events(time,owner,action,actor,target,evidence,trace_id) VALUES(?,?,?,?,?,?,?)`), + event.Time, actor, event.Action, event.Actor, event.Target, event.Evidence, event.TraceID) + return err + }) +} + +func (s *Store) SetPluginEnabledAccountChecked(actor, pluginID string, enabled, allowImplicitDefault bool) error { + flag := 0 + if enabled { + flag = 1 + } + return s.withEffectiveAccountWrite(actor, allowImplicitDefault, func(tx *sql.Tx, _ Account) error { + _, err := tx.Exec(s.q(`INSERT INTO plugin_grants(owner,plugin_id,enabled) VALUES(?,?,?) + ON CONFLICT(owner,plugin_id) DO UPDATE SET enabled=excluded.enabled`), actor, pluginID, flag) + return err + }) +} + +func (s *Store) PutPluginKVAccountChecked(actor, pluginID, key, value string, allowImplicitDefault bool) error { + return s.withEffectiveAccountWrite(actor, allowImplicitDefault, func(tx *sql.Tx, _ Account) error { + _, err := tx.Exec(s.q(`INSERT INTO plugin_kv(plugin_id,owner,k,v) VALUES(?,?,?,?) + ON CONFLICT(plugin_id,owner,k) DO UPDATE SET v=excluded.v`), pluginID, actor, key, value) + return err + }) +} diff --git a/internal/store/plugin_account_write.go b/internal/store/plugin_account_write.go new file mode 100644 index 0000000..dad4ee6 --- /dev/null +++ b/internal/store/plugin_account_write.go @@ -0,0 +1,41 @@ +package store + +import ( + "database/sql" + "fmt" + "time" + + core "agent-system-core" +) + +// UpsertPluginAccountChecked fences the node-level plugin registry write with +// the request actor's effective account state. Admin authorization belongs to +// the caller; an enabled account alone does not authorize plugin management. +func (s *Store) UpsertPluginAccountChecked(actor, id, dir string, m core.PluginManifest, allowImplicitDefault bool) error { + return s.withEffectiveAccountWrite(actor, allowImplicitDefault, func(tx *sql.Tx, _ Account) error { + _, err := tx.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 + }) +} + +// SetPluginSchemaVersionAccountChecked checks the stored version and writes it +// in the final actor-fenced transaction. It preserves the legacy loader's +// refusal to downgrade a plugin schema. +func (s *Store) SetPluginSchemaVersionAccountChecked(actor, pluginID string, version int, allowImplicitDefault bool) error { + return s.withEffectiveAccountWrite(actor, allowImplicitDefault, func(tx *sql.Tx, _ Account) error { + var current int + err := tx.QueryRow(s.q(`SELECT version FROM plugin_schema WHERE plugin_id=?`), pluginID).Scan(¤t) + if err != nil && err != sql.ErrNoRows { + return err + } + if version < current { + return fmt.Errorf("plugins: %s: schema %d older than stored %d", pluginID, version, current) + } + _, err = tx.Exec(s.q(`INSERT INTO plugin_schema(plugin_id,version,updated_at) VALUES(?,?,?) + ON CONFLICT(plugin_id) DO UPDATE SET version=excluded.version, updated_at=excluded.updated_at`), + pluginID, version, time.Now().UTC().Format(time.RFC3339)) + return err + }) +} diff --git a/internal/store/plugin_account_write_test.go b/internal/store/plugin_account_write_test.go new file mode 100644 index 0000000..b1b3332 --- /dev/null +++ b/internal/store/plugin_account_write_test.go @@ -0,0 +1,102 @@ +package store + +import ( + "errors" + "path/filepath" + "testing" + "time" + + core "agent-system-core" +) + +func TestPluginAccountCheckedWritesStopAfterDisable(t *testing.T) { + st, err := OpenSQLite(filepath.Join(t.TempDir(), "plugins.db")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { st.Close() }) + for _, a := range []struct { + owner string + admin bool + }{{"admin", true}, {"alice", false}} { + if err := st.CreateAccount(a.owner, "", "hash", a.admin); err != nil { + t.Fatal(err) + } + } + manifest := core.PluginManifest{ID: "p", Name: "P", Version: "1.0.0"} + if err := st.UpsertPluginAccountChecked("alice", "p", "/tmp/p", manifest, false); err != nil { + t.Fatal(err) + } + if err := st.SetPluginSchemaVersionAccountChecked("alice", "p", 2, false); err != nil { + t.Fatal(err) + } + account, err := st.GetAccountByOwner("alice") + if err != nil { + t.Fatal(err) + } + if _, err := st.SetAccountEnabled("admin", account.ID, account.StateVersion, false); err != nil { + t.Fatal(err) + } + manifest.Version = "2.0.0" + if err := st.UpsertPluginAccountChecked("alice", "p", "/tmp/p2", manifest, false); !errors.Is(err, ErrAccountForbidden) { + t.Fatalf("upsert after disable: %v", err) + } + if err := st.SetPluginSchemaVersionAccountChecked("alice", "p", 3, false); !errors.Is(err, ErrAccountForbidden) { + t.Fatalf("schema update after disable: %v", err) + } + plugins, err := st.ListPlugins() + if err != nil || len(plugins) != 1 || plugins[0].Version != "1.0.0" { + t.Fatalf("plugin row changed: %+v, %v", plugins, err) + } + if version, err := st.PluginSchemaVersion("p"); err != nil || version != 2 { + t.Fatalf("schema version changed: %d, %v", version, err) + } +} + +func TestPGPluginAccountCheckedWriteSerializesWithDisable(t *testing.T) { + st := openTestStore(t) + if st.Dialect() != "postgres" { + t.Skip("real PostgreSQL required") + } + other := reopenAccountModelStore(t, st) + if err := st.CreateAccount("admin", "", "hash", true); err != nil { + t.Fatal(err) + } + if err := st.CreateAccount("alice", "", "hash", false); err != nil { + t.Fatal(err) + } + account, err := st.GetAccountByOwner("alice") + if err != nil { + t.Fatal(err) + } + release := holdPGAccountWrite(t, st, "plugins", "INSERT", "plugin_upsert") + writeDone := make(chan error, 1) + go func() { + writeDone <- st.UpsertPluginAccountChecked("alice", "p", "/tmp/p", core.PluginManifest{ID: "p", Name: "P", Version: "1"}, false) + }() + waitForPGAccountWrite(t, st, "plugin_upsert") + disableDone := make(chan error, 1) + go func() { + _, err := other.SetAccountEnabled("admin", account.ID, account.StateVersion, false) + disableDone <- err + }() + select { + case err := <-disableDone: + t.Fatalf("disable committed ahead of paused plugin upsert: %v", err) + case <-time.After(100 * time.Millisecond): + } + release() + if err := awaitPGAccountResult(t, writeDone); err != nil { + t.Fatal(err) + } + if err := awaitPGAccountResult(t, disableDone); err != nil { + t.Fatal(err) + } + if err := st.UpsertPluginAccountChecked("alice", "late", "/tmp/late", core.PluginManifest{ID: "late", Name: "Late", Version: "1"}, false); !errors.Is(err, ErrAccountForbidden) { + t.Fatalf("late plugin write passed after disable: %v", err) + } + plugins, err := st.ListPlugins() + if err != nil || len(plugins) != 1 || plugins[0].ID != "p" { + t.Fatalf("plugin writes crossed disable boundary: %+v, %v", plugins, err) + } +} diff --git a/internal/store/profile_prompt_fence_test.go b/internal/store/profile_prompt_fence_test.go new file mode 100644 index 0000000..3163e1d --- /dev/null +++ b/internal/store/profile_prompt_fence_test.go @@ -0,0 +1,157 @@ +package store + +import ( + "errors" + "testing" + "time" + + core "agent-system-core" +) + +func TestProfileAndPromptAccountCheckedWrites(t *testing.T) { + st := openTestStore(t) + t.Logf("profile and prompt fence dialect=%s", st.Dialect()) + for _, account := range []struct { + owner string + admin bool + }{{"admin", true}, {"alice", false}, {"bob", false}} { + if err := st.CreateAccount(account.owner, "", "", account.admin); err != nil { + t.Fatal(err) + } + } + profile := core.AgentProfile{ID: "owned-profile", Name: "Alice"} + if err := st.SaveProfileAccountChecked("missing", profile, false); !errors.Is(err, ErrAccountForbidden) { + t.Fatalf("missing actor saved profile: %v", err) + } + if err := st.SaveProfileAccountChecked("alice", profile, false); err != nil { + t.Fatal(err) + } + if err := st.SaveProfileAccountChecked("alice", core.AgentProfile{ID: "delete-me"}, false); err != nil { + t.Fatal(err) + } + if err := st.DeleteProfileAccountChecked("alice", "delete-me", false); err != nil { + t.Fatal(err) + } + if _, err := st.GetProfile("alice", "delete-me"); !errors.Is(err, ErrNotFound) { + t.Fatalf("successful profile delete left its row behind: %v", err) + } + if err := st.SaveProfileAccountChecked("bob", core.AgentProfile{ID: profile.ID, Name: "Stolen"}, false); !errors.Is(err, ErrNotFound) { + t.Fatalf("foreign actor took over profile ID: %v", err) + } + if err := st.SaveProfileAccountChecked("alice", core.AgentProfile{ID: "spoofed-owner", Owner: "bob"}, false); !errors.Is(err, ErrAccountForbidden) { + t.Fatalf("actor saved a profile for another owner: %v", err) + } + if err := st.DeleteProfileAccountChecked("bob", profile.ID, false); !errors.Is(err, ErrNotFound) { + t.Fatalf("foreign actor deleted profile: %v", err) + } + if err := st.SaveProfileAccountChecked("alice", core.AgentProfile{ID: "forged-builtin", Builtin: true}, false); !errors.Is(err, ErrAccountForbidden) { + t.Fatalf("user saved globally visible builtin profile: %v", err) + } + if err := st.SaveProfile(core.AgentProfile{ID: "default", Builtin: true, Name: "builtin"}); err != nil { + t.Fatal(err) + } + if err := st.SaveProfileAccountChecked("alice", core.AgentProfile{ID: "default", Name: "replacement"}, false); !errors.Is(err, ErrNotFound) { + t.Fatalf("user replaced a stored builtin profile: %v", err) + } + stored, err := st.GetProfile("alice", profile.ID) + if err != nil || stored.Owner != "alice" || stored.Name != "Alice" || stored.Builtin { + t.Fatalf("profile owner/content changed: %+v %v", stored, err) + } + module := core.PromptModule{ID: "task.example", Layer: core.LayerTask, Body: "Alice"} + if err := st.SavePromptModuleAccountChecked("alice", module, false); err != nil { + t.Fatal(err) + } + if err := st.SavePromptModuleAccountChecked("alice", core.PromptModule{ID: "spoofed-owner", Owner: "bob"}, false); !errors.Is(err, ErrAccountForbidden) { + t.Fatalf("actor saved a prompt for another owner: %v", err) + } + if err := st.SavePromptModuleAccountChecked("bob", core.PromptModule{ID: module.ID, Layer: core.LayerTask, Body: "Bob"}, false); err != nil { + t.Fatalf("different owner could not use the same module ID: %v", err) + } + if err := st.DeletePromptModuleAccountChecked("bob", module.ID, false); err != nil { + t.Fatal(err) + } + if err := st.DeletePromptModuleAccountChecked("bob", module.ID, false); err != nil { + t.Fatalf("missing owner-scoped module delete changed legacy no-op semantics: %v", err) + } + storedModule, err := st.GetPromptModule("alice", module.ID) + if err != nil || storedModule.Owner != "alice" || storedModule.Body != "Alice" { + t.Fatalf("foreign module delete affected alice: %+v %v", storedModule, err) + } + account, err := st.GetAccountByOwner("alice") + if err != nil { + t.Fatal(err) + } + if _, err := st.SetAccountEnabled("admin", account.ID, account.StateVersion, false); err != nil { + t.Fatal(err) + } + for _, write := range []func() error{ + func() error { + return st.SaveProfileAccountChecked("alice", core.AgentProfile{ID: profile.ID, Name: "late"}, false) + }, + func() error { return st.DeleteProfileAccountChecked("alice", profile.ID, false) }, + func() error { + return st.SavePromptModuleAccountChecked("alice", core.PromptModule{ID: module.ID, Body: "late"}, false) + }, + func() error { return st.DeletePromptModuleAccountChecked("alice", module.ID, false) }, + } { + if err := write(); !errors.Is(err, ErrAccountForbidden) { + t.Fatalf("disabled actor changed profile or module: %v", err) + } + } + stored, err = st.GetProfile("alice", profile.ID) + if err != nil || stored.Name != "Alice" { + t.Fatalf("disabled profile write changed data: %+v %v", stored, err) + } + storedModule, err = st.GetPromptModule("alice", module.ID) + if err != nil || storedModule.Body != "Alice" { + t.Fatalf("disabled module write changed data: %+v %v", storedModule, err) + } +} + +func TestPGProfileWriteAccountFenceSerializesWithDisable(t *testing.T) { + st := openTestStore(t) + if st.Dialect() != "postgres" { + t.Skip("real PostgreSQL required") + } + other := reopenAccountModelStore(t, st) + if err := st.CreateAccount("admin", "", "", true); err != nil { + t.Fatal(err) + } + if err := st.CreateAccount("alice", "", "", false); err != nil { + t.Fatal(err) + } + account, err := st.GetAccountByOwner("alice") + if err != nil { + t.Fatal(err) + } + release := holdPGAccountWrite(t, st, "profiles", "INSERT", "profile") + writeDone := make(chan error, 1) + go func() { + writeDone <- st.SaveProfileAccountChecked("alice", core.AgentProfile{ID: "race", Name: "first"}, false) + }() + waitForPGAccountWrite(t, st, "profile") + disableDone := make(chan error, 1) + go func() { + _, err := other.SetAccountEnabled("admin", account.ID, account.StateVersion, false) + disableDone <- err + }() + select { + case err := <-disableDone: + t.Fatalf("disable committed ahead of paused profile insert: %v", err) + case <-time.After(100 * time.Millisecond): + } + release() + if err := awaitPGAccountResult(t, writeDone); err != nil { + t.Fatal(err) + } + if err := awaitPGAccountResult(t, disableDone); err != nil { + t.Fatal(err) + } + if err := st.DeleteProfileAccountChecked("alice", "race", false); !errors.Is(err, ErrAccountForbidden) { + t.Fatalf("profile delete passed after disable: %v", err) + } + stored, err := st.GetProfile("alice", "race") + if err != nil || stored.Name != "first" { + t.Fatalf("profile write crossed disable boundary: %+v %v", stored, err) + } +} diff --git a/internal/store/project_events.go b/internal/store/project_events.go index ea6e7c4..ee8d75c 100644 --- a/internal/store/project_events.go +++ b/internal/store/project_events.go @@ -133,6 +133,31 @@ func (s *Store) AppendProjectSharedEvents(owner, producer, channel string, event return s.appendEvents(channel, events, owner, producer) } +// AppendProjectSharedEventsAccountChecked commits the structural event and +// sharing proof behind the actual actor's account fence. +func (s *Store) AppendProjectSharedEventsAccountChecked(actor, producer, channel string, events []core.StreamEvent, allowImplicitDefault bool) (int64, error) { + if actor == "" || strings.TrimSpace(producer) == "" { + return 0, ErrAccountForbidden + } + if _, ok := core.ProjectFromChannel(channel); !ok { + return 0, ErrNotFound + } + if len(events) == 0 { + return 0, nil + } + var base int64 + err := s.withEffectiveAccountWrite(actor, allowImplicitDefault, func(tx *sql.Tx, _ Account) error { + var inner error + base, inner = s.appendEventsInTx(tx, channel, events, actor, producer) + return inner + }) + if err != nil { + return 0, err + } + assignEventSequences(events, base) + return base, nil +} + func (s *Store) saveProjectEventProof(tx *sql.Tx, channel string, seq int64, e core.StreamEvent, owner, producer string) error { e.ConversationID = channel resource, err := s.projectEventResource(tx, e) diff --git a/internal/store/project_fence_test.go b/internal/store/project_fence_test.go new file mode 100644 index 0000000..c92917a --- /dev/null +++ b/internal/store/project_fence_test.go @@ -0,0 +1,183 @@ +package store + +import ( + "errors" + "testing" + "time" +) + +func TestProjectAccountCheckedMutationsRespectEffectiveStateAndOwner(t *testing.T) { + st := openTestStore(t) + t.Logf("project account fence dialect=%s", st.Dialect()) + for _, account := range []struct { + owner string + admin bool + }{{"admin", true}, {"alice", false}, {"bob", false}} { + if err := st.CreateAccount(account.owner, "", "", account.admin); err != nil { + t.Fatal(err) + } + } + if _, err := st.CreateProjectAccountChecked("missing", "forbidden", false); !errors.Is(err, ErrAccountForbidden) { + t.Fatalf("missing actor created a project: %v", err) + } + project, err := st.CreateProjectAccountChecked("alice", "original", false) + if err != nil || project.Owner != "alice" { + t.Fatalf("active owner could not create project: %+v %v", project, err) + } + for _, mutate := range []func() error{ + func() error { return st.RenameProjectAccountChecked("bob", project.ID, "foreign", false) }, + func() error { + return st.UpdateProjectSettingsAccountChecked("bob", project.ID, map[string]any{"foreign": true}, false) + }, + func() error { return st.DeleteProjectAccountChecked("bob", project.ID, false) }, + } { + if err := mutate(); !errors.Is(err, ErrNotFound) { + t.Fatalf("foreign actor changed project: %v", err) + } + } + if err := st.RenameProjectAccountChecked("alice", project.ID, "renamed", false); err != nil { + t.Fatal(err) + } + if err := st.UpdateProjectSettingsAccountChecked("alice", project.ID, map[string]any{"value": "retained"}, false); err != nil { + t.Fatal(err) + } + deletable, err := st.CreateProjectAccountChecked("bob", "temporary", false) + if err != nil { + t.Fatal(err) + } + child, err := st.CreateConversation("bob", deletable.ID, "child", "") + if err != nil { + t.Fatal(err) + } + if err := st.DeleteProjectAccountChecked("bob", deletable.ID, false); err != nil { + t.Fatal(err) + } + if _, err := st.GetProject("bob", deletable.ID); !errors.Is(err, ErrNotFound) { + t.Fatalf("successful delete left project behind: %v", err) + } + if _, err := st.GetConversation("bob", child.ID); !errors.Is(err, ErrNotFound) { + t.Fatalf("project deletion did not cascade to its conversation: %v", err) + } + account, err := st.GetAccountByOwner("alice") + if err != nil { + t.Fatal(err) + } + if _, err := st.SetAccountEnabled("admin", account.ID, account.StateVersion, false); err != nil { + t.Fatal(err) + } + if _, err := st.CreateProjectAccountChecked("alice", "late", false); !errors.Is(err, ErrAccountForbidden) { + t.Fatalf("disabled actor created a project: %v", err) + } + for _, mutate := range []func() error{ + func() error { return st.RenameProjectAccountChecked("alice", project.ID, "late", false) }, + func() error { + return st.UpdateProjectSettingsAccountChecked("alice", project.ID, map[string]any{"late": true}, false) + }, + func() error { return st.DeleteProjectAccountChecked("alice", project.ID, false) }, + } { + if err := mutate(); !errors.Is(err, ErrAccountForbidden) { + t.Fatalf("disabled actor changed project: %v", err) + } + } + got, err := st.GetProject("alice", project.ID) + if err != nil || got.Name != "renamed" || got.Settings["value"] != "retained" || got.Settings["late"] != nil { + t.Fatalf("rejected mutations changed project: %+v %v", got, err) + } + projects, err := st.ListProjects("alice") + if err != nil || len(projects) != 1 { + t.Fatalf("rejected create wrote a project: %+v %v", projects, err) + } + if err := st.DeleteProjectAccountChecked("bob", "missing-project", false); !errors.Is(err, ErrNotFound) { + t.Fatalf("missing project delete: %v", err) + } + if err := st.DeleteProjectAccountChecked("admin", project.ID, false); !errors.Is(err, ErrNotFound) { + t.Fatalf("administrator crossed project ownership boundary: %v", err) + } +} + +func TestProjectAccountCheckedImplicitDefaultRequiresExplicitAllowance(t *testing.T) { + st := openTestStore(t) + if _, err := st.CreateProjectAccountChecked(OwnerDefault, "rejected", false); !errors.Is(err, ErrAccountForbidden) { + t.Fatalf("implicit default admitted without authorization: %v", err) + } + project, err := st.CreateProjectAccountChecked(OwnerDefault, "local", true) + if err != nil || project.Owner != OwnerDefault { + t.Fatalf("implicit local owner was rejected: %+v %v", project, err) + } + if err := st.RenameProjectAccountChecked(OwnerDefault, project.ID, "renamed", true); err != nil { + t.Fatal(err) + } + if err := st.UpdateProjectSettingsAccountChecked(OwnerDefault, project.ID, map[string]any{"local": true}, true); err != nil { + t.Fatal(err) + } + if err := st.CreateAccount("admin", "", "", true); err != nil { + t.Fatal(err) + } + if err := st.EnsureUser(OwnerDefault, ""); err != nil { + t.Fatal(err) + } + account, err := st.GetAccountByOwner(OwnerDefault) + if err != nil { + t.Fatal(err) + } + if _, err := st.SetAccountEnabled("admin", account.ID, account.StateVersion, false); err != nil { + t.Fatal(err) + } + if err := st.DeleteProjectAccountChecked(OwnerDefault, project.ID, true); !errors.Is(err, ErrAccountForbidden) { + t.Fatalf("explicitly disabled local owner deleted project: %v", err) + } +} + +func TestPGProjectCreateAccountFenceSerializesWithDisable(t *testing.T) { + st := openTestStore(t) + if st.Dialect() != "postgres" { + t.Skip("real PostgreSQL required") + } + other := reopenAccountModelStore(t, st) + if err := st.CreateAccount("admin", "", "", true); err != nil { + t.Fatal(err) + } + if err := st.CreateAccount("alice", "", "", false); err != nil { + t.Fatal(err) + } + account, err := st.GetAccountByOwner("alice") + if err != nil { + t.Fatal(err) + } + release := holdPGAccountWrite(t, st, "projects", "INSERT", "project_create") + type result struct { + owner string + err error + } + createDone := make(chan result, 1) + go func() { + project, err := st.CreateProjectAccountChecked("alice", "first", false) + createDone <- result{project.Owner, err} + }() + waitForPGAccountWrite(t, st, "project_create") + disableDone := make(chan error, 1) + go func() { + _, err := other.SetAccountEnabled("admin", account.ID, account.StateVersion, false) + disableDone <- err + }() + select { + case err := <-disableDone: + t.Fatalf("disable committed ahead of paused final project insert: %v", err) + case <-time.After(100 * time.Millisecond): + } + release() + created := awaitPGAccountResult(t, createDone) + if created.err != nil || created.owner != "alice" { + t.Fatalf("project insert did not complete before disable: %+v", created) + } + if err := awaitPGAccountResult(t, disableDone); err != nil { + t.Fatal(err) + } + if _, err := st.CreateProjectAccountChecked("alice", "late", false); !errors.Is(err, ErrAccountForbidden) { + t.Fatalf("late project write passed after disable: %v", err) + } + projects, err := st.ListProjects("alice") + if err != nil || len(projects) != 1 || projects[0].Name != "first" { + t.Fatalf("project writes crossed disable boundary: %+v %v", projects, err) + } +} diff --git a/internal/store/project_projection.go b/internal/store/project_projection.go index 13aa99d..675d0f6 100644 --- a/internal/store/project_projection.go +++ b/internal/store/project_projection.go @@ -97,6 +97,120 @@ func (s *Store) RenameOwnedProject(owner, id, name string) (core.Project, error) 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.withEffectiveAccountWrite(actor, allowImplicitDefault, func(tx *sql.Tx, _ Account) 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.withEffectiveAccountWrite(actor, allowImplicitDefault, func(tx *sql.Tx, _ Account) 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) diff --git a/internal/store/project_projection_fence_test.go b/internal/store/project_projection_fence_test.go new file mode 100644 index 0000000..57f4161 --- /dev/null +++ b/internal/store/project_projection_fence_test.go @@ -0,0 +1,149 @@ +package store + +import ( + "errors" + "sync" + "testing" + "time" +) + +func TestProjectProjectionAccountCheckedStateOwnershipAndIdempotency(t *testing.T) { + st := openTestStore(t) + t.Logf("project projection fence dialect=%s", st.Dialect()) + for _, account := range []struct { + owner string + admin bool + }{{"admin", true}, {"alice", false}, {"bob", false}} { + if err := st.CreateAccount(account.owner, "", "", account.admin); err != nil { + t.Fatal(err) + } + } + if _, err := st.EnsureProjectAccountChecked("missing", "missing", "never", "Never", false); !errors.Is(err, ErrAccountForbidden) { + t.Fatalf("missing actor created projection: %v", err) + } + if _, err := st.EnsureProjectAccountChecked("", "alice", "never", "Never", false); !errors.Is(err, ErrAccountForbidden) { + t.Fatalf("empty actor created projection: %v", err) + } + created, err := st.EnsureProjectAccountChecked("alice", "alice", "stable", " Shared ", false) + if err != nil || created.ID != "stable" || created.Owner != "alice" || created.Name != "Shared" { + t.Fatalf("projection create changed stable identity: %+v %v", created, err) + } + retry, err := st.EnsureProjectAccountChecked("alice", "alice", created.ID, "Shared", false) + if err != nil || retry.ID != created.ID || !retry.CreatedAt.Equal(created.CreatedAt) { + t.Fatalf("identical retry was not stable: %+v %v", retry, err) + } + if _, err := st.EnsureProjectAccountChecked("bob", "bob", created.ID, "Shared", false); !errors.Is(err, ErrNotFound) { + t.Fatalf("foreign owner reused stable ID: %v", err) + } + if _, err := st.RenameOwnedProjectAccountChecked("bob", "bob", created.ID, "Foreign", false); !errors.Is(err, ErrNotFound) { + t.Fatalf("foreign owner renamed projection: %v", err) + } + if _, err := st.EnsureProjectAccountChecked("alice", "alice", created.ID, "Different", false); !errors.Is(err, ErrConflict) { + t.Fatalf("non-identical retry replaced projection: %v", err) + } + if _, err := st.EnsureProjectAccountChecked("bob", "bob", "other", "Shared", false); !errors.Is(err, ErrConflict) { + t.Fatalf("duplicate global name was admitted: %v", err) + } + start := make(chan struct{}) + results := make(chan error, 2) + var wg sync.WaitGroup + for _, owner := range []string{"alice", "bob"} { + wg.Add(1) + go func(owner string) { + defer wg.Done() + <-start + _, err := st.EnsureProjectAccountChecked(owner, owner, "race-"+owner, "Race", false) + results <- err + }(owner) + } + close(start) + wg.Wait() + close(results) + wins, conflicts := 0, 0 + for err := range results { + switch { + case err == nil: + wins++ + case errors.Is(err, ErrConflict): + conflicts++ + default: + t.Fatalf("guarded name admission: %v", err) + } + } + if wins != 1 || conflicts != 1 { + t.Fatalf("guarded name admission wins=%d conflicts=%d", wins, conflicts) + } + renamed, err := st.RenameOwnedProjectAccountChecked("alice", "alice", created.ID, " Renamed ", false) + if err != nil || renamed.ID != created.ID || renamed.Owner != "alice" || renamed.Name != "Renamed" { + t.Fatalf("projection rename changed identity: %+v %v", renamed, err) + } + account, err := st.GetAccountByOwner("alice") + if err != nil { + t.Fatal(err) + } + if _, err := st.SetAccountEnabled("admin", account.ID, account.StateVersion, false); err != nil { + t.Fatal(err) + } + if _, err := st.EnsureProjectAccountChecked("alice", "alice", created.ID, "Renamed", false); !errors.Is(err, ErrAccountForbidden) { + t.Fatalf("disabled actor passed idempotent projection retry: %v", err) + } + if _, err := st.EnsureProjectAccountChecked("alice", "alice", "late", "Late", false); !errors.Is(err, ErrAccountForbidden) { + t.Fatalf("disabled actor created projection: %v", err) + } + if _, err := st.RenameOwnedProjectAccountChecked("alice", "alice", created.ID, "Late", false); !errors.Is(err, ErrAccountForbidden) { + t.Fatalf("disabled actor renamed projection: %v", err) + } + got, err := st.ProjectMetadata(created.ID) + if err != nil || got.ID != created.ID || got.Owner != "alice" || got.Name != "Renamed" { + t.Fatalf("denied write changed projection: %+v %v", got, err) + } +} + +func TestPGProjectProjectionAccountFenceSerializesWithDisable(t *testing.T) { + st := openTestStore(t) + if st.Dialect() != "postgres" { + t.Skip("real PostgreSQL required") + } + other := reopenAccountModelStore(t, st) + if err := st.CreateAccount("admin", "", "", true); err != nil { + t.Fatal(err) + } + if err := st.CreateAccount("alice", "", "", false); err != nil { + t.Fatal(err) + } + account, err := st.GetAccountByOwner("alice") + if err != nil { + t.Fatal(err) + } + release := holdPGAccountWrite(t, st, "projects", "INSERT", "projection") + writeDone := make(chan error, 1) + go func() { + _, err := st.EnsureProjectAccountChecked("alice", "alice", "stable", "Name", false) + writeDone <- err + }() + waitForPGAccountWrite(t, st, "projection") + disableDone := make(chan error, 1) + go func() { + _, err := other.SetAccountEnabled("admin", account.ID, account.StateVersion, false) + disableDone <- err + }() + select { + case err := <-disableDone: + t.Fatalf("disable committed ahead of paused projection insert: %v", err) + case <-time.After(100 * time.Millisecond): + } + release() + if err := awaitPGAccountResult(t, writeDone); err != nil { + t.Fatal(err) + } + if err := awaitPGAccountResult(t, disableDone); err != nil { + t.Fatal(err) + } + if _, err := st.RenameOwnedProjectAccountChecked("alice", "alice", "stable", "Late", false); !errors.Is(err, ErrAccountForbidden) { + t.Fatalf("projection rename passed after disable: %v", err) + } + got, err := st.ProjectMetadata("stable") + if err != nil || got.Name != "Name" || got.Owner != "alice" { + t.Fatalf("projection crossed disable boundary: %+v %v", got, err) + } +} diff --git a/internal/store/projects.go b/internal/store/projects.go index 009a701..1b22fbb 100644 --- a/internal/store/projects.go +++ b/internal/store/projects.go @@ -125,6 +125,114 @@ func (s *Store) updateProjectRow(owner, id, query string, args ...any) error { return nil } +// CreateProjectAccountChecked checks the actor's effective state in the final +// transaction and makes that actor the new project's owner. +func (s *Store) CreateProjectAccountChecked(actor, name string, allowImplicitDefault bool) (core.Project, error) { + var project core.Project + err := s.withEffectiveAccountWrite(actor, allowImplicitDefault, func(tx *sql.Tx, _ Account) error { + now := time.Now().UTC() + project = core.Project{ + ID: newID("prj"), Owner: actor, 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(?,?,?,?,?,?)`), + project.ID, project.Owner, project.Name, "{}", now.Format(time.RFC3339Nano), now.Format(time.RFC3339Nano)) + return err + }) + if err != nil { + return core.Project{}, err + } + return project, nil +} + +// projectForAccountWrite locks the stored project row after actor admission +// and returns ErrNotFound unless that actor owns it. +func (s *Store) projectForAccountWrite(tx *sql.Tx, actor, id string) (string, error) { + var owner, settings string + query := s.q(`SELECT owner,settings FROM projects WHERE id=?`) + if s.Dialect() == "postgres" { + query += ` FOR UPDATE` + } + err := tx.QueryRow(query, id).Scan(&owner, &settings) + if errors.Is(err, sql.ErrNoRows) { + return "", ErrNotFound + } + if err != nil { + return "", err + } + if owner != actor { + return "", ErrNotFound + } + return settings, nil +} + +// RenameProjectAccountChecked keeps the legacy owner-only mutation semantics. +func (s *Store) RenameProjectAccountChecked(actor, id, name string, allowImplicitDefault bool) error { + return s.withEffectiveAccountWrite(actor, allowImplicitDefault, func(tx *sql.Tx, _ Account) error { + if _, err := s.projectForAccountWrite(tx, actor, id); err != nil { + return err + } + res, err := tx.Exec(s.q(`UPDATE projects SET name=?,updated_at=? WHERE id=? AND owner=?`), name, nowISO(), id, actor) + if err != nil { + return err + } + if n, err := res.RowsAffected(); err != nil { + return err + } else if n != 1 { + return ErrNotFound + } + return nil + }) +} + +// DeleteProjectAccountChecked keeps the legacy owner-only deletion semantics. +func (s *Store) DeleteProjectAccountChecked(actor, id string, allowImplicitDefault bool) error { + return s.withEffectiveAccountWrite(actor, allowImplicitDefault, func(tx *sql.Tx, _ Account) error { + if _, err := s.projectForAccountWrite(tx, actor, id); err != nil { + return err + } + res, err := tx.Exec(s.q(`DELETE FROM projects WHERE id=? AND owner=?`), id, actor) + if err != nil { + return err + } + if n, err := res.RowsAffected(); err != nil { + return err + } else if n != 1 { + return ErrNotFound + } + return nil + }) +} + +// UpdateProjectSettingsAccountChecked merges settings under the actor lock, +// then the project scope and row locks, in the final write transaction. +func (s *Store) UpdateProjectSettingsAccountChecked(actor, id string, values map[string]any, allowImplicitDefault bool) error { + return s.withEffectiveAccountWrite(actor, allowImplicitDefault, func(tx *sql.Tx, _ Account) error { + if err := s.dialect.lockScope(tx, actor, "project:"+id); err != nil { + return err + } + settings, err := s.projectForAccountWrite(tx, actor, id) + if err != nil { + return err + } + cur := map[string]any{} + if settings != "" && settings != "{}" { + cur, _ = unmarshalJSON[map[string]any](settings) + } + merged := core.MergeSettings(cur, values) + res, err := tx.Exec(s.q(`UPDATE projects SET settings=?,updated_at=? WHERE id=? AND owner=?`), marshalJSON(merged), nowISO(), id, actor) + if err != nil { + return err + } + if n, err := res.RowsAffected(); err != nil { + return err + } else if n != 1 { + return ErrNotFound + } + return nil + }) +} + func scanProjects(rows *sql.Rows) ([]core.Project, error) { out := []core.Project{} for rows.Next() { @@ -333,3 +441,116 @@ func (s *Store) SetPermissionMode(owner, id, mode string) error { func (s *Store) DeleteConversation(owner, id string) error { return s.updateProjectRow(owner, id, `DELETE FROM conversations WHERE id=? AND owner=?`, id, owner) } + +// withOwnedConversationWrite locks the actor before the conversation and +// resolves ownership from the stored row in the final write transaction. +func (s *Store) withOwnedConversationWrite(actor, id string, allowImplicitDefault bool, write func(*sql.Tx) error) error { + return s.withEffectiveAccountWrite(actor, allowImplicitDefault, func(tx *sql.Tx, _ Account) error { + if err := s.dialect.lockConversation(tx, id); err != nil { + return err + } + query := s.q(`SELECT owner FROM conversations WHERE id=?`) + if s.Dialect() == "postgres" { + query += ` FOR UPDATE` + } + var owner string + err := tx.QueryRow(query, id).Scan(&owner) + if errors.Is(err, sql.ErrNoRows) { + return ErrNotFound + } + if err != nil { + return err + } + if owner != actor { + return ErrNotFound + } + return write(tx) + }) +} + +func (s *Store) execOwnedConversationWrite(tx *sql.Tx, query string, args ...any) error { + res, err := tx.Exec(s.q(query), args...) + if err != nil { + return err + } + if n, err := res.RowsAffected(); err != nil { + return err + } else if n != 1 { + return ErrNotFound + } + return nil +} + +// UpdateConversationAccountChecked changes title/profile only for its active owner. +func (s *Store) UpdateConversationAccountChecked(actor, id, title, profileID string, allowImplicitDefault bool) error { + return s.withOwnedConversationWrite(actor, id, allowImplicitDefault, func(tx *sql.Tx) error { + if title == "" { + return s.execOwnedConversationWrite(tx, + `UPDATE conversations SET profile_id=?,updated_at=? WHERE id=? AND owner=?`, + profileID, nowISO(), id, actor) + } + return s.execOwnedConversationWrite(tx, + `UPDATE conversations SET title=?,profile_id=?,updated_at=? WHERE id=? AND owner=?`, + title, profileID, nowISO(), id, actor) + }) +} + +// DeleteConversationAccountChecked retains the legacy owner-only delete rule. +func (s *Store) DeleteConversationAccountChecked(actor, id string, allowImplicitDefault bool) error { + return s.withOwnedConversationWrite(actor, id, allowImplicitDefault, func(tx *sql.Tx) error { + return s.execOwnedConversationWrite(tx, `DELETE FROM conversations WHERE id=? AND owner=?`, id, actor) + }) +} + +// SetConversationFlagsAccountChecked applies the legacy partial flag update +// while checking the actor and owner even when all flags are nil. +func (s *Store) SetConversationFlagsAccountChecked(actor, id string, pinned, archived, allowCrossRead, crossPrivate *bool, allowImplicitDefault bool) error { + var sets []string + var args []any + for _, field := range []struct { + name string + value *bool + }{ + {"pinned", pinned}, {"archived", archived}, + {"allow_cross_read", allowCrossRead}, {"cross_private", crossPrivate}, + } { + if field.value != nil { + sets = append(sets, field.name+"=?") + args = append(args, *field.value) + } + } + return s.withOwnedConversationWrite(actor, id, allowImplicitDefault, func(tx *sql.Tx) error { + if len(sets) == 0 { + return nil + } + return s.execOwnedConversationWrite(tx, + `UPDATE conversations SET `+strings.Join(sets, ", ")+`,updated_at=? WHERE id=? AND owner=?`, + append(args, nowISO(), id, actor)...) + }) +} + +// SetPermissionModeAccountChecked validates and stores the legacy mode values. +func (s *Store) SetPermissionModeAccountChecked(actor, id, mode string, allowImplicitDefault bool) error { + switch mode { + case "", "default", "plan", "auto_edit": + default: + return fmt.Errorf("store: unknown permission mode %q", mode) + } + if mode == "default" { + mode = "" + } + return s.withOwnedConversationWrite(actor, id, allowImplicitDefault, func(tx *sql.Tx) error { + return s.execOwnedConversationWrite(tx, + `UPDATE conversations SET permission_mode=?,updated_at=? WHERE id=? AND owner=?`, + mode, nowISO(), id, actor) + }) +} + +// SetSessionNoteAccountChecked stores the note for the active conversation owner. +func (s *Store) SetSessionNoteAccountChecked(actor, id, note string, allowImplicitDefault bool) error { + return s.withOwnedConversationWrite(actor, id, allowImplicitDefault, func(tx *sql.Tx) error { + return s.execOwnedConversationWrite(tx, + `UPDATE conversations SET session_note=?,updated_at=? WHERE id=? AND owner=?`, + note, nowISO(), id, actor) + }) +} diff --git a/internal/store/prompts.go b/internal/store/prompts.go index 2b164ad..7be7aa2 100644 --- a/internal/store/prompts.go +++ b/internal/store/prompts.go @@ -1,7 +1,9 @@ package store import ( + "database/sql" "encoding/json" + "errors" core "agent-system-core" ) @@ -61,3 +63,45 @@ func (s *Store) DeletePromptModule(owner, id string) error { _, err := s.db.Exec(s.q(`DELETE FROM prompt_modules WHERE id=? AND owner=?`), id, owner) return err } + +// SavePromptModuleAccountChecked stores an actor-owned module in the final +// account-guarded transaction. The payload owner cannot name another account. +func (s *Store) SavePromptModuleAccountChecked(actor string, m core.PromptModule, allowImplicitDefault bool) error { + if m.Owner != "" && m.Owner != actor { + return ErrAccountForbidden + } + m.Owner = actor + payload, err := json.Marshal(m) + if err != nil { + return err + } + return s.withEffectiveAccountWrite(actor, allowImplicitDefault, func(tx *sql.Tx, _ Account) error { + _, err := tx.Exec(s.q(`INSERT INTO prompt_modules(id,owner,payload) VALUES(?,?,?) + ON CONFLICT(id,owner) DO UPDATE SET payload=excluded.payload`), m.ID, actor, payload) + return err + }) +} + +// DeletePromptModuleAccountChecked preserves the legacy absent-row no-op while +// checking the stored owner inside the final actor-guarded transaction. +func (s *Store) DeletePromptModuleAccountChecked(actor, id string, allowImplicitDefault bool) error { + return s.withEffectiveAccountWrite(actor, allowImplicitDefault, func(tx *sql.Tx, _ Account) error { + var owner string + query := s.q(`SELECT owner FROM prompt_modules WHERE id=? AND owner=?`) + if s.Dialect() == "postgres" { + query += ` FOR UPDATE` + } + err := tx.QueryRow(query, id, actor).Scan(&owner) + if errors.Is(err, sql.ErrNoRows) { + return nil + } + if err != nil { + return err + } + if owner != actor { + return ErrNotFound + } + _, err = tx.Exec(s.q(`DELETE FROM prompt_modules WHERE id=? AND owner=?`), id, actor) + return err + }) +} diff --git a/internal/store/recovery.go b/internal/store/recovery.go index 08b0354..6adfb0b 100644 --- a/internal/store/recovery.go +++ b/internal/store/recovery.go @@ -270,13 +270,18 @@ func (s *Store) settleRunOnce(r *core.Run, msg *core.Message, fence *fenceRef, a return false, false, err } } + if msg.VariantGroup != "" { + if _, err := tx.Exec(s.q(`DELETE FROM regen_pending WHERE conversation_id=? AND variant_group=? AND variant_index=?`), msg.ConversationID, msg.VariantGroup, msg.VariantIndex); err != nil { + return false, false, err + } + } + if _, err := tx.Exec(s.q(`UPDATE conversations SET updated_at=? WHERE id=?`), nowISO(), msg.ConversationID); err != nil { + return false, false, err + } } if err := tx.Commit(); err != nil { return false, false, err } - if msg != nil { - s.TouchConversation(string(msg.ConversationID)) - } return n == 1, false, nil } diff --git a/internal/store/recovery_holds.go b/internal/store/recovery_holds.go index 4ec9674..5ae946e 100644 --- a/internal/store/recovery_holds.go +++ b/internal/store/recovery_holds.go @@ -83,3 +83,16 @@ func (s *Store) ReleaseRecoveryHold(runID string) error { _ = res // 0 行 = 已解除或不存在:幂等成功 return nil } + +// ReleaseRecoveryHoldAccountChecked rechecks the actual operator at the final +// write. The hold may belong to a different owner; this method grants no +// project-content read permission. +func (s *Store) ReleaseRecoveryHoldAccountChecked(actor, runID string, allowImplicitDefault bool) error { + return s.withEffectiveAccountWrite(actor, allowImplicitDefault, func(tx *sql.Tx, account Account) error { + if !account.IsAdmin && !(allowImplicitDefault && actor == OwnerDefault && account.ID == "") { + return ErrAccountForbidden + } + _, err := tx.Exec(s.q(`UPDATE recovery_holds SET released_at=? WHERE run_id=? AND released_at IS NULL`), nowISO(), runID) + return err + }) +} diff --git a/internal/store/recovery_holds_test.go b/internal/store/recovery_holds_test.go index 0572dbd..69b50c8 100644 --- a/internal/store/recovery_holds_test.go +++ b/internal/store/recovery_holds_test.go @@ -51,3 +51,25 @@ func TestRecoveryHoldsStoreCRUD(t *testing.T) { t.Fatalf("open holds=%v err=%v", holds, err) } } + +func TestRecoveryHoldReleaseRequiresCurrentAdmin(t *testing.T) { + st := openTestStore(t) + if err := st.CreateAccount("admin", "Admin", "hash", true); err != nil { + t.Fatal(err) + } + if err := st.CreateAccount("ordinary", "Ordinary", "hash", false); err != nil { + t.Fatal(err) + } + if err := st.SetRecoveryHold("run-held", "ordinary", "", "unknown effect"); err != nil { + t.Fatal(err) + } + if err := st.ReleaseRecoveryHoldAccountChecked("ordinary", "run-held", false); !errors.Is(err, ErrAccountForbidden) { + t.Fatalf("ordinary account released an admin hold: %v", err) + } + if held, err := st.HasOpenRecoveryHold("run-held", ""); err != nil || !held { + t.Fatalf("rejected release changed hold: held=%v err=%v", held, err) + } + if err := st.ReleaseRecoveryHoldAccountChecked("admin", "run-held", false); err != nil { + t.Fatal(err) + } +} diff --git a/internal/store/regeneration_account_write.go b/internal/store/regeneration_account_write.go new file mode 100644 index 0000000..152daa3 --- /dev/null +++ b/internal/store/regeneration_account_write.go @@ -0,0 +1,120 @@ +package store + +import ( + "database/sql" + "errors" + + core "agent-system-core" +) + +// PrepareRegenerationAccountChecked excludes the selected assistant run and +// records the next variant in one final actor-checked transaction. The target +// and conversation owner are read again after the account and conversation +// locks, so an earlier HTTP lookup cannot authorize a late write. +func (s *Store) PrepareRegenerationAccountChecked(actor, conversationID, messageID string, allowImplicitDefault bool) (string, int, error) { + var group string + var nextIndex int + err := s.withEffectiveAccountWrite(actor, allowImplicitDefault, func(tx *sql.Tx, _ Account) error { + if err := s.dialect.lockConversation(tx, conversationID); err != nil { + return err + } + query := s.q(`SELECT COALESCE(m.run_id,''),m.variant_group,m.variant_index,m.role,c.owner + FROM messages m JOIN conversations c ON c.id=m.conversation_id + WHERE m.id=? AND m.conversation_id=?`) + if s.Dialect() == "postgres" { + query += ` FOR UPDATE OF m` + } + var runID, existingGroup, role, owner string + var variantIndex int + if err := tx.QueryRow(query, messageID, conversationID).Scan(&runID, &existingGroup, &variantIndex, &role, &owner); errors.Is(err, sql.ErrNoRows) { + return ErrNotFound + } else if err != nil { + return err + } + if owner != actor || role != string(core.RoleAssistant) { + return ErrNotFound + } + maxIndex := variantIndex + if existingGroup != "" { + if err := tx.QueryRow(s.q(`SELECT COALESCE(MAX(variant_index),0) FROM messages WHERE conversation_id=? AND variant_group=?`), + conversationID, existingGroup).Scan(&maxIndex); err != nil { + return err + } + if variantIndex > maxIndex { + maxIndex = variantIndex + } + } + group = existingGroup + if group == "" { + group = "var_" + messageID + } + var pendingGroup string + var pendingIndex int + err := tx.QueryRow(s.q(`SELECT variant_group,variant_index FROM regen_pending WHERE conversation_id=?`), conversationID).Scan(&pendingGroup, &pendingIndex) + if err != nil && !errors.Is(err, sql.ErrNoRows) { + return err + } + if pendingGroup == group && pendingIndex > maxIndex { + maxIndex = pendingIndex + } + nextIndex = maxIndex + 1 + if _, err := tx.Exec(s.q(`UPDATE messages SET excluded=1,variant_group=?,variant_index=? WHERE id=? AND conversation_id=?`), + group, variantIndex, messageID, conversationID); err != nil { + return err + } + if runID != "" { + if _, err := tx.Exec(s.q(`UPDATE messages SET excluded=1 WHERE run_id=? AND conversation_id=?`), runID, conversationID); err != nil { + return err + } + } + _, err = tx.Exec(s.q(`INSERT INTO regen_pending(conversation_id,variant_group,variant_index) VALUES(?,?,?) + ON CONFLICT(conversation_id) DO UPDATE SET variant_group=excluded.variant_group,variant_index=excluded.variant_index`), + conversationID, group, nextIndex) + return err + }) + if err != nil { + return "", 0, err + } + return group, nextIndex, nil +} + +// ClearRegenPendingAfterFailedStart is safety cleanup, not a new business +// write. It may remove this request's stale marker after account disable, but +// still locks the actor and verifies persisted conversation ownership. A later +// regeneration with another group or index is never cleared. +func (s *Store) ClearRegenPendingAfterFailedStart(actor, conversationID, group string, index int, allowImplicitDefault bool) error { + if actor == "" || conversationID == "" || group == "" || index <= 0 { + return ErrAccountForbidden + } + tx, err := s.db.Begin() + if err != nil { + return err + } + defer tx.Rollback() + _, err = s.lockAccountUsers(tx, actor) + if errors.Is(err, ErrNotFound) && allowImplicitDefault && actor == OwnerDefault { + err = nil + } + if errors.Is(err, ErrNotFound) { + return ErrAccountForbidden + } + if err != nil { + return err + } + if err := s.dialect.lockConversation(tx, conversationID); err != nil { + return err + } + var owner string + if err := tx.QueryRow(s.q(`SELECT owner FROM conversations WHERE id=?`), conversationID).Scan(&owner); errors.Is(err, sql.ErrNoRows) { + return ErrNotFound + } else if err != nil { + return err + } + if owner != actor { + return ErrNotFound + } + if _, err := tx.Exec(s.q(`DELETE FROM regen_pending WHERE conversation_id=? AND variant_group=? AND variant_index=?`), conversationID, group, index); err != nil { + return err + } + return tx.Commit() +} diff --git a/internal/store/regeneration_account_write_test.go b/internal/store/regeneration_account_write_test.go new file mode 100644 index 0000000..2902292 --- /dev/null +++ b/internal/store/regeneration_account_write_test.go @@ -0,0 +1,176 @@ +package store + +import ( + "errors" + "testing" + + core "agent-system-core" +) + +func TestPrepareRegenerationAccountCheckedFencesAllWrites(t *testing.T) { + st := openTestStore(t) + for _, account := range []struct { + owner string + admin bool + }{{"admin", true}, {"alice", false}, {"bob", false}} { + if err := st.CreateAccount(account.owner, "", "", account.admin); err != nil { + t.Fatal(err) + } + } + project, err := st.CreateProject("alice", "regeneration") + if err != nil { + t.Fatal(err) + } + conv, err := st.CreateConversation("alice", project.ID, "thread", "") + if err != nil { + t.Fatal(err) + } + run := seedRun(t, st, conv, core.RunCompleted) + target := core.Message{ConversationID: conv.ID, RunID: run.ID, Role: core.RoleAssistant} + sibling := core.Message{ConversationID: conv.ID, RunID: run.ID, Role: core.RoleAssistant} + for _, msg := range []*core.Message{&target, &sibling} { + if err := st.AppendMessage(msg); err != nil { + t.Fatal(err) + } + } + assertUnchanged := func() { + t.Helper() + msgs, err := st.ListMessages(conv.ID) + if err != nil { + t.Fatal(err) + } + if len(msgs) != 2 || msgs[0].Excluded || msgs[1].Excluded || msgs[0].VariantGroup != "" { + t.Fatalf("rejected regeneration changed messages: %+v", msgs) + } + if _, _, ok := st.PeekRegenPending(conv.ID); ok { + t.Fatal("rejected regeneration created pending marker") + } + } + if _, _, err := st.PrepareRegenerationAccountChecked("bob", conv.ID, target.ID, false); !errors.Is(err, ErrNotFound) { + t.Fatalf("foreign actor: %v", err) + } + assertUnchanged() + account, err := st.GetAccountByOwner("alice") + if err != nil { + t.Fatal(err) + } + if _, err := st.SetAccountEnabled("admin", account.ID, account.StateVersion, false); err != nil { + t.Fatal(err) + } + if _, _, err := st.PrepareRegenerationAccountChecked("alice", conv.ID, target.ID, false); !errors.Is(err, ErrAccountForbidden) { + t.Fatalf("disabled actor: %v", err) + } + assertUnchanged() + account, err = st.GetAccountByOwner("alice") + if err != nil { + t.Fatal(err) + } + if _, err := st.SetAccountEnabled("admin", account.ID, account.StateVersion, true); err != nil { + t.Fatal(err) + } + group, index, err := st.PrepareRegenerationAccountChecked("alice", conv.ID, target.ID, false) + if err != nil || group != "var_"+target.ID || index != 1 { + t.Fatalf("active regeneration: group=%q index=%d err=%v", group, index, err) + } + msgs, err := st.ListMessages(conv.ID) + if err != nil || len(msgs) != 2 || !msgs[0].Excluded || !msgs[1].Excluded || msgs[0].VariantGroup != group { + t.Fatalf("active regeneration missed message state: %+v %v", msgs, err) + } + if pendingGroup, pendingIndex, ok := st.PeekRegenPending(conv.ID); !ok || pendingGroup != group || pendingIndex != index { + t.Fatalf("active regeneration marker: group=%q index=%d ok=%v", pendingGroup, pendingIndex, ok) + } + if err := st.ClearRegenPendingAfterFailedStart("alice", conv.ID, group, index, false); err != nil { + t.Fatal(err) + } + if _, _, ok := st.PeekRegenPending(conv.ID); ok { + t.Fatal("failed-start cleanup left pending marker") + } + group, index, err = st.PrepareRegenerationAccountChecked("alice", conv.ID, target.ID, false) + if err != nil { + t.Fatal(err) + } + if err := st.ClearRegenPendingAfterFailedStart("bob", conv.ID, group, index, false); !errors.Is(err, ErrNotFound) { + t.Fatalf("foreign marker cleanup: %v", err) + } + account, err = st.GetAccountByOwner("alice") + if err != nil { + t.Fatal(err) + } + if _, err := st.SetAccountEnabled("admin", account.ID, account.StateVersion, false); err != nil { + t.Fatal(err) + } + if err := st.ClearRegenPendingAfterFailedStart("alice", conv.ID, group, index, false); err != nil { + t.Fatalf("disabled marker safety cleanup: %v", err) + } + if _, _, ok := st.PeekRegenPending(conv.ID); ok { + t.Fatal("failed-start cleanup left stale marker after disable") + } +} + +func TestPrepareRegenerationAccountCheckedUsesExistingVariantGroup(t *testing.T) { + st := openTestStore(t) + if err := st.CreateAccount("alice", "", "", false); err != nil { + t.Fatal(err) + } + project, err := st.CreateProject("alice", "regeneration") + if err != nil { + t.Fatal(err) + } + conv, err := st.CreateConversation("alice", project.ID, "thread", "") + if err != nil { + t.Fatal(err) + } + target := core.Message{ConversationID: conv.ID, Role: core.RoleAssistant} + later := core.Message{ConversationID: conv.ID, Role: core.RoleAssistant} + for _, msg := range []*core.Message{&target, &later} { + if err := st.AppendMessage(msg); err != nil { + t.Fatal(err) + } + } + if err := st.SetMessageFlags(target.ID, false, "variants", 1); err != nil { + t.Fatal(err) + } + if err := st.SetMessageFlags(later.ID, false, "variants", 3); err != nil { + t.Fatal(err) + } + group, index, err := st.PrepareRegenerationAccountChecked("alice", conv.ID, target.ID, false) + if err != nil || group != "variants" || index != 4 { + t.Fatalf("existing group: %q index=%d err=%v", group, index, err) + } + if pendingGroup, pendingIndex, ok := st.PeekRegenPending(conv.ID); !ok || pendingGroup != group || pendingIndex != index { + t.Fatalf("next group index: group=%q index=%d ok=%v", pendingGroup, pendingIndex, ok) + } +} + +func TestFailedStartCleanupDoesNotRemoveNewerSameGroupMarker(t *testing.T) { + st := openTestStore(t) + if err := st.CreateAccount("alice", "", "", false); err != nil { + t.Fatal(err) + } + project, err := st.CreateProject("alice", "regeneration") + if err != nil { + t.Fatal(err) + } + conv, err := st.CreateConversation("alice", project.ID, "thread", "") + if err != nil { + t.Fatal(err) + } + target := core.Message{ConversationID: conv.ID, Role: core.RoleAssistant} + if err := st.AppendMessage(&target); err != nil { + t.Fatal(err) + } + group, first, err := st.PrepareRegenerationAccountChecked("alice", conv.ID, target.ID, false) + if err != nil { + t.Fatal(err) + } + group2, second, err := st.PrepareRegenerationAccountChecked("alice", conv.ID, target.ID, false) + if err != nil || group2 != group || second <= first { + t.Fatalf("newer marker failed to advance: %q %d -> %q %d: %v", group, first, group2, second, err) + } + if err := st.ClearRegenPendingAfterFailedStart("alice", conv.ID, group, first, false); err != nil { + t.Fatal(err) + } + if gotGroup, gotIndex, ok := st.PeekRegenPending(conv.ID); !ok || gotGroup != group || gotIndex != second { + t.Fatalf("old cleanup removed newer marker: %q %d %v", gotGroup, gotIndex, ok) + } +} diff --git a/internal/store/run_account_write.go b/internal/store/run_account_write.go new file mode 100644 index 0000000..2cefab7 --- /dev/null +++ b/internal/store/run_account_write.go @@ -0,0 +1,101 @@ +package store + +import ( + "database/sql" + "errors" + + core "agent-system-core" +) + +// UpdateRunAccountChecked fences forward run state and completed output at the +// final run-row mutation. Terminal failure cleanup has a separate legacy path. +func (s *Store) UpdateRunAccountChecked(actor string, r *core.Run, allowImplicitDefault bool) error { + if r == nil || actor == "" || string(r.Owner) != actor { + return ErrAccountForbidden + } + return s.withEffectiveAccountWrite(actor, allowImplicitDefault, func(tx *sql.Tx, _ Account) error { + var owner string + if err := tx.QueryRow(s.q(`SELECT owner FROM runs WHERE id=?`), r.ID).Scan(&owner); errors.Is(err, sql.ErrNoRows) { + return ErrNotFound + } else if err != nil { + return err + } + if owner != actor { + return ErrNotFound + } + var ended any + if r.EndedAt != nil { + ended = nowISO() + } + _, err := tx.Exec(s.q(`UPDATE runs SET status=?,phase=?,usage=?,error=?,ended_at=? WHERE id=? AND owner=?`), + string(r.Status), string(r.Phase), marshalJSON(r.Usage), marshalJSON(r.Error), ended, r.ID, actor) + return err + }) +} + +// CreateRunAccountChecked fences run admission against account disable. The +// conversation's stored owner is checked in the same final transaction. +func (s *Store) CreateRunAccountChecked(r *core.Run, allowImplicitDefault bool) error { + if r == nil || r.Owner == "" { + return ErrAccountForbidden + } + if r.ID == "" { + r.ID = newID("run") + } + r.CreatedAt = r.CreatedAt.UTC() + return s.withEffectiveAccountWrite(string(r.Owner), allowImplicitDefault, func(tx *sql.Tx, _ Account) error { + var owner string + if err := tx.QueryRow(s.q(`SELECT owner FROM conversations WHERE id=?`), r.ConversationID).Scan(&owner); errors.Is(err, sql.ErrNoRows) { + return ErrNotFound + } else if err != nil { + return err + } + if owner != string(r.Owner) { + return ErrNotFound + } + _, err := tx.Exec(s.q(`INSERT INTO runs(id,parent_run_id,conversation_id,project_id,owner,kind,profile_id,status,phase,agent_name,created_at,instance_id) VALUES(?,?,?,?,?,?,?,?,?,?,?,?)`), + r.ID, r.ParentRunID, r.ConversationID, r.ProjectID, r.Owner, string(r.Kind), r.ProfileID, string(r.Status), string(r.Phase), r.AgentName, nowISO(), s.instanceID) + return err + }) +} + +// SetMessageRunIDAccountChecked links an admitted run only while its actor is +// still effective. Both rows must belong to the actor's conversation. +func (s *Store) SetMessageRunIDAccountChecked(actor, messageID, runID string, allowImplicitDefault bool) error { + var routeConversation string + if err := s.db.QueryRow(s.q(`SELECT conversation_id FROM messages WHERE id=?`), messageID).Scan(&routeConversation); errors.Is(err, sql.ErrNoRows) { + return ErrNotFound + } else if err != nil { + return err + } + return s.withEffectiveAccountWrite(actor, allowImplicitDefault, func(tx *sql.Tx, _ Account) error { + if err := s.dialect.lockConversation(tx, routeConversation); err != nil { + return err + } + var messageConversation, messageOwner, runConversation, runOwner string + if err := tx.QueryRow(s.q(`SELECT m.conversation_id,c.owner FROM messages m JOIN conversations c ON c.id=m.conversation_id WHERE m.id=?`), messageID). + Scan(&messageConversation, &messageOwner); errors.Is(err, sql.ErrNoRows) { + return ErrNotFound + } else if err != nil { + return err + } + if err := tx.QueryRow(s.q(`SELECT conversation_id,owner FROM runs WHERE id=?`), runID).Scan(&runConversation, &runOwner); errors.Is(err, sql.ErrNoRows) { + return ErrNotFound + } else if err != nil { + return err + } + if messageConversation != routeConversation || runConversation != routeConversation || messageOwner != actor || runOwner != actor { + return ErrNotFound + } + res, err := tx.Exec(s.q(`UPDATE messages SET run_id=? WHERE id=? AND conversation_id=?`), runID, messageID, routeConversation) + if err != nil { + return err + } + if n, err := res.RowsAffected(); err != nil { + return err + } else if n != 1 { + return ErrNotFound + } + return nil + }) +} diff --git a/internal/store/runtime_account_sink_test.go b/internal/store/runtime_account_sink_test.go new file mode 100644 index 0000000..4a83a79 --- /dev/null +++ b/internal/store/runtime_account_sink_test.go @@ -0,0 +1,145 @@ +package store + +import ( + "errors" + "testing" + "time" + + agentcommand "agent-command" + core "agent-system-core" +) + +func TestDisabledAccountCannotAdvanceRunOrCreateNotice(t *testing.T) { + st := openTestStore(t) + if err := st.CreateAccount("admin", "Admin", "hash", true); err != nil { + t.Fatal(err) + } + if err := st.CreateAccount("alice", "Alice", "hash", false); err != nil { + t.Fatal(err) + } + project, err := st.CreateProject("alice", "fixture") + if err != nil { + t.Fatal(err) + } + conv, err := st.CreateConversation("alice", string(project.ID), "fixture", "") + if err != nil { + t.Fatal(err) + } + run := core.Run{ConversationID: conv.ID, ProjectID: project.ID, Owner: "alice", Kind: core.RunConversation, Status: core.RunPending, Phase: core.PhaseThinking} + if err := st.CreateRun(&run); err != nil { + t.Fatal(err) + } + account, err := st.GetAccountByOwner("alice") + if err != nil { + t.Fatal(err) + } + if _, err := st.SetAccountEnabled("admin", account.ID, account.StateVersion, false); err != nil { + t.Fatal(err) + } + run.Status = core.RunRunning + if err := st.UpdateRunAccountChecked("alice", &run, false); !errors.Is(err, ErrAccountForbidden) { + t.Fatalf("disabled account advanced run: %v", err) + } + if err := st.AppendNoticeAccountChecked(Notice{ID: "late", ConversationID: string(conv.ID), Owner: "alice"}, false); !errors.Is(err, ErrAccountForbidden) { + t.Fatalf("disabled account created notice: %v", err) + } + got, err := st.GetRunByID(run.ID) + if err != nil || got.Status != core.RunPending { + t.Fatalf("run changed after disable: %+v %v", got, err) + } + var count int + if err := st.DB().QueryRow(st.q(`SELECT COUNT(*) FROM pending_notices WHERE id=?`), "late").Scan(&count); err != nil || count != 0 { + t.Fatalf("late notice persisted: %d %v", count, err) + } +} + +func TestRegenMarkerIsConsumedOnlyBySuccessfulSettlement(t *testing.T) { + st := openTestStore(t) + if err := st.CreateAccount("admin", "Admin", "hash", true); err != nil { + t.Fatal(err) + } + if err := st.CreateAccount("alice", "Alice", "hash", false); err != nil { + t.Fatal(err) + } + project, err := st.CreateProject("alice", "fixture") + if err != nil { + t.Fatal(err) + } + conv, err := st.CreateConversation("alice", string(project.ID), "fixture", "") + if err != nil { + t.Fatal(err) + } + run := core.Run{ConversationID: conv.ID, ProjectID: project.ID, Owner: "alice", Kind: core.RunConversation, Status: core.RunRunning, Phase: core.PhaseThinking} + if err := st.CreateRun(&run); err != nil { + t.Fatal(err) + } + if err := st.SetRegenPending(string(conv.ID), "group", 2); err != nil { + t.Fatal(err) + } + account, err := st.GetAccountByOwner("alice") + if err != nil { + t.Fatal(err) + } + if _, err := st.SetAccountEnabled("admin", account.ID, account.StateVersion, false); err != nil { + t.Fatal(err) + } + now := time.Now().UTC() + run.Status, run.Phase, run.EndedAt = core.RunCompleted, core.PhaseDone, &now + msg := core.Message{ConversationID: conv.ID, RunID: run.ID, Role: core.RoleAssistant, + VariantGroup: "group", VariantIndex: 2, Parts: []core.Part{{Type: core.PartText, Text: "answer"}}} + if _, err := st.SettleRunAccountChecked(&run, &msg, false); !errors.Is(err, ErrAccountForbidden) { + t.Fatalf("disabled account settled regenerated output: %v", err) + } + if group, index, ok := st.PeekRegenPending(string(conv.ID)); !ok || group != "group" || index != 2 { + t.Fatalf("failed settlement consumed marker: %q %d %v", group, index, ok) + } + account, err = st.GetAccountByOwner("alice") + if err != nil { + t.Fatal(err) + } + if _, err := st.SetAccountEnabled("admin", account.ID, account.StateVersion, true); err != nil { + t.Fatal(err) + } + if updated, err := st.SettleRunAccountChecked(&run, &msg, false); err != nil || !updated { + t.Fatalf("enabled settlement: %v updated=%v", err, updated) + } + if _, _, ok := st.PeekRegenPending(string(conv.ID)); ok { + t.Fatal("settled regeneration left marker") + } +} + +func TestCommandBusDurableSinksRejectDisabledAccount(t *testing.T) { + st := openTestStore(t) + if err := st.CreateAccount("admin", "Admin", "hash", true); err != nil { + t.Fatal(err) + } + if err := st.CreateAccount("alice", "Alice", "hash", false); err != nil { + t.Fatal(err) + } + if err := st.SavePendingConfirmAccountChecked("card-one", "alice", `{}`, false); err != nil { + t.Fatal(err) + } + account, err := st.GetAccountByOwner("alice") + if err != nil { + t.Fatal(err) + } + if _, err := st.SetAccountEnabled("admin", account.ID, account.StateVersion, false); err != nil { + t.Fatal(err) + } + if err := st.SavePendingConfirmAccountChecked("card-late", "alice", `{}`, false); !errors.Is(err, ErrAccountForbidden) { + t.Fatalf("late pending confirm: %v", err) + } + if _, _, ok, err := st.TakePendingConfirmAccountChecked("card-one", "alice", false); !errors.Is(err, ErrAccountForbidden) || ok { + t.Fatalf("disabled account consumed pending confirm: ok=%v err=%v", ok, err) + } + if err := st.AppendAuditAccountChecked("alice", agentcommand.AuditEnvelope{Time: time.Now().UTC(), CommandID: "demo"}, false); !errors.Is(err, ErrAccountForbidden) { + t.Fatalf("late command audit: %v", err) + } + var pending, audits int + if err := st.DB().QueryRow(st.q(`SELECT COUNT(*) FROM pending_confirms WHERE card_id=?`), "card-one").Scan(&pending); err != nil || pending != 1 { + t.Fatalf("original pending lost: %d %v", pending, err) + } + if err := st.DB().QueryRow(st.q(`SELECT COUNT(*) FROM command_audit WHERE owner=?`), "alice").Scan(&audits); err != nil || audits != 0 { + t.Fatalf("late command audit persisted: %d %v", audits, err) + } +} diff --git a/internal/store/settings_account_write_test.go b/internal/store/settings_account_write_test.go new file mode 100644 index 0000000..a23ca49 --- /dev/null +++ b/internal/store/settings_account_write_test.go @@ -0,0 +1,169 @@ +package store + +import ( + "errors" + "testing" + "time" + + core "agent-system-core" +) + +func TestSettingsAccountCheckedScopesAndSnapshots(t *testing.T) { + st := openTestStore(t) + for _, a := range []struct { + owner string + admin bool + }{{"admin", true}, {"admin2", true}, {"alice", false}, {"bob", false}} { + if err := st.CreateAccount(a.owner, "", "", a.admin); err != nil { + t.Fatal(err) + } + } + project, err := st.CreateProject("alice", "owned") + if err != nil { + t.Fatal(err) + } + merge := func(cur map[string]any) (map[string]any, error) { + return core.MergeSettings(cur, map[string]any{"ui": map[string]any{"theme": "light"}}), nil + } + if err := st.UpdateSettingsScopeAccountChecked("alice", "alice", "user", false, merge); err != nil { + t.Fatal(err) + } + if err := st.UpdateSettingsScopeAccountChecked("alice", "alice", "project:"+project.ID, false, merge); err != nil { + t.Fatal(err) + } + if err := st.UpdateSettingsScopeAccountChecked("bob", "alice", "user", false, merge); !errors.Is(err, ErrAccountForbidden) { + t.Fatalf("foreign user scope admitted: %v", err) + } + if err := st.UpdateSettingsScopeAccountChecked("alice", "", "system", false, merge); !errors.Is(err, ErrAccountForbidden) { + t.Fatalf("non-admin system scope admitted: %v", err) + } + if err := st.UpdateSettingsScopeAccountChecked("admin", "", "system", false, merge); err != nil { + t.Fatal(err) + } + if err := st.UpdateSettingsScopeAccountChecked("alice", "alice", "project:missing", false, merge); !errors.Is(err, ErrNotFound) { + t.Fatalf("missing project scope admitted: %v", err) + } + if saved, err := st.SaveSnapshotIfChangedAccountChecked("alice", "alice", "user", `{"ui":{"theme":"light"}}`, "save", false); err != nil || !saved { + t.Fatalf("guarded snapshot: saved=%v err=%v", saved, err) + } + if saved, err := st.SaveSnapshotIfChangedAccountChecked("alice", "alice", "user", `{"ui":{"theme":"light"}}`, "save", false); err != nil || saved { + t.Fatalf("unchanged snapshot duplicated: saved=%v err=%v", saved, err) + } + if _, err := st.SaveSnapshotIfChangedAccountChecked("alice", "", "system", `{}`, "save", false); !errors.Is(err, ErrAccountForbidden) { + t.Fatalf("non-admin system snapshot admitted: %v", err) + } + account, err := st.GetAccountByOwner("alice") + if err != nil { + t.Fatal(err) + } + if _, err := st.SetAccountEnabled("admin", account.ID, account.StateVersion, false); err != nil { + t.Fatal(err) + } + for _, mutate := range []func() error{ + func() error { return st.UpdateSettingsScopeAccountChecked("alice", "alice", "user", false, merge) }, + func() error { + return st.UpdateSettingsScopeAccountChecked("alice", "alice", "project:"+project.ID, false, merge) + }, + func() error { + _, err := st.SaveSnapshotIfChangedAccountChecked("alice", "alice", "user", `{"late":true}`, "save", false) + return err + }, + } { + if err := mutate(); !errors.Is(err, ErrAccountForbidden) { + t.Fatalf("disabled account wrote settings/snapshot: %v", err) + } + } + var snapshots int + if err := st.DB().QueryRow(st.q(`SELECT COUNT(*) FROM settings_snapshots WHERE owner=? AND scope=?`), "alice", "user").Scan(&snapshots); err != nil || snapshots != 1 { + t.Fatalf("rejected snapshot changed row count: %d %v", snapshots, err) + } + admin, err := st.GetAccountByOwner("admin") + if err != nil { + t.Fatal(err) + } + if _, err := st.SetAccountEnabled("admin2", admin.ID, admin.StateVersion, false); err != nil { + t.Fatal(err) + } + if err := st.UpdateSettingsScopeAccountChecked("admin", "", "system", false, merge); !errors.Is(err, ErrAccountForbidden) { + t.Fatalf("disabled administrator wrote system settings: %v", err) + } + if _, err := st.SaveSnapshotIfChangedAccountChecked("admin", "", "system", `{}`, "save", false); !errors.Is(err, ErrAccountForbidden) { + t.Fatalf("disabled administrator wrote system snapshot: %v", err) + } +} + +func TestSettingsAccountCheckedImplicitDefault(t *testing.T) { + st := openTestStore(t) + merge := func(cur map[string]any) (map[string]any, error) { return map[string]any{"enabled": true}, nil } + if err := st.UpdateSettingsScopeAccountChecked(OwnerDefault, OwnerDefault, "user", false, merge); !errors.Is(err, ErrAccountForbidden) { + t.Fatalf("unapproved implicit default admitted: %v", err) + } + if err := st.UpdateSettingsScopeAccountChecked(OwnerDefault, OwnerDefault, "user", true, merge); err != nil { + t.Fatal(err) + } + if err := st.UpdateSettingsScopeAccountChecked(OwnerDefault, "", "system", true, merge); err != nil { + t.Fatalf("embedded local system write rejected: %v", err) + } + if err := st.CreateAccount("admin", "", "", true); err != nil { + t.Fatal(err) + } + if err := st.EnsureUser(OwnerDefault, ""); err != nil { + t.Fatal(err) + } + account, err := st.GetAccountByOwner(OwnerDefault) + if err != nil { + t.Fatal(err) + } + if _, err := st.SetAccountEnabled("admin", account.ID, account.StateVersion, false); err != nil { + t.Fatal(err) + } + if err := st.UpdateSettingsScopeAccountChecked(OwnerDefault, "", "system", true, merge); !errors.Is(err, ErrAccountForbidden) { + t.Fatalf("disabled embedded local system write admitted: %v", err) + } +} + +func TestPGSettingsAccountWriteSerializesWithDisable(t *testing.T) { + st := openTestStore(t) + if st.Dialect() != "postgres" { + t.Skip("real PostgreSQL required") + } + other := reopenAccountModelStore(t, st) + if err := st.CreateAccount("admin", "", "", true); err != nil { + t.Fatal(err) + } + if err := st.CreateAccount("alice", "", "", false); err != nil { + t.Fatal(err) + } + account, err := st.GetAccountByOwner("alice") + if err != nil { + t.Fatal(err) + } + release := holdPGAccountWrite(t, st, "settings", "INSERT", "settings") + writeDone := make(chan error, 1) + go func() { + writeDone <- st.UpdateSettingsScopeAccountChecked("alice", "alice", "user", false, + func(cur map[string]any) (map[string]any, error) { return map[string]any{"first": true}, nil }) + }() + waitForPGAccountWrite(t, st, "settings") + disableDone := make(chan error, 1) + go func() { + _, err := other.SetAccountEnabled("admin", account.ID, account.StateVersion, false) + disableDone <- err + }() + select { + case err := <-disableDone: + t.Fatalf("disable committed ahead of paused settings insert: %v", err) + case <-time.After(100 * time.Millisecond): + } + release() + if err := awaitPGAccountResult(t, writeDone); err != nil { + t.Fatal(err) + } + if err := awaitPGAccountResult(t, disableDone); err != nil { + t.Fatal(err) + } + if err := st.UpdateSettingsScopeAccountChecked("alice", "alice", "user", false, + func(cur map[string]any) (map[string]any, error) { return map[string]any{"late": true}, nil }); !errors.Is(err, ErrAccountForbidden) { + t.Fatalf("late settings write passed: %v", err) + } +} diff --git a/internal/store/store_test.go b/internal/store/store_test.go index 01a265d..7c274c5 100644 --- a/internal/store/store_test.go +++ b/internal/store/store_test.go @@ -165,6 +165,11 @@ func TestTenantIsolation(t *testing.T) { t.Fatalf("cross-tenant artifact content must be NotFound, got %v", err) } // 密钥与设置隔离。 + // This legacy integration identity is a fixture, separate from formal key + // issuance. Creating a project above does not implicitly create an account. + if err := st.EnsureUser(a, ""); err != nil { + t.Fatal(err) + } keyA, err := st.CreateAPIKey(a, "ka") if err != nil { t.Fatal(err) diff --git a/internal/store/surface_account_write_test.go b/internal/store/surface_account_write_test.go new file mode 100644 index 0000000..5d64702 --- /dev/null +++ b/internal/store/surface_account_write_test.go @@ -0,0 +1,104 @@ +package store + +import ( + "errors" + "testing" + + core "agent-system-core" +) + +func TestSurfaceAccountCheckedWritesStopAfterDisable(t *testing.T) { + st := openTestStore(t) + for _, a := range []struct { + owner string + admin bool + }{{"admin", true}, {"alice", false}} { + if err := st.CreateAccount(a.owner, "", "", a.admin); err != nil { + t.Fatal(err) + } + } + activation, err := st.SetActivationAccountChecked("alice", "genui", true, false) + if err != nil || !activation.Enabled { + t.Fatalf("activation: %+v %v", activation, err) + } + snap := core.SurfaceSnapshot{Identity: core.SurfaceIdentity{SurfaceID: "surf_checked", Owner: "alice", PluginID: "genui"}, + Compat: core.SurfaceCompat{SchemaVersion: core.SurfaceSchemaVersion, CatalogVersion: core.SurfaceCatalogVersion}, + ActivationEpoch: activation.Epoch, Spec: core.SurfaceSpec{Title: "Before"}} + snap, err = st.SaveSurfaceSnapshotAccountChecked("alice", snap, false) + if err != nil { + t.Fatal(err) + } + if err := st.SetSurfaceEventPendingAccountChecked("alice", snap.Identity.SurfaceID, snap.Revision, true, false); err != nil { + t.Fatal(err) + } + action := SurfaceActionRecord{Owner: "alice", SurfaceID: string(snap.Identity.SurfaceID), ActionID: "builtin.help", RequestID: "req1", Revision: snap.Revision, State: "submitted"} + if _, first, err := st.SaveSurfaceActionIfAbsentAccountChecked("alice", action, false); err != nil || !first { + t.Fatalf("action insert: %v %v", first, err) + } + account, err := st.GetAccountByOwner("alice") + if err != nil { + t.Fatal(err) + } + if _, err := st.SetAccountEnabled("admin", account.ID, account.StateVersion, false); err != nil { + t.Fatal(err) + } + for name, write := range map[string]func() error{ + "activation": func() error { _, err := st.SetActivationAccountChecked("alice", "genui", false, false); return err }, + "snapshot": func() error { _, err := st.SaveSurfaceSnapshotAccountChecked("alice", snap, false); return err }, + "pending": func() error { + return st.SetSurfaceEventPendingAccountChecked("alice", snap.Identity.SurfaceID, snap.Revision, false, false) + }, + "action insert": func() error { + action.RequestID = "req2" + _, _, err := st.SaveSurfaceActionIfAbsentAccountChecked("alice", action, false) + return err + }, + "action final": func() error { + return st.UpdateSurfaceActionStateAccountChecked("alice", action.SurfaceID, action.ActionID, "req1", "committed", "{}", "", false) + }, + } { + if err := write(); !errors.Is(err, ErrAccountForbidden) { + t.Errorf("%s: got %v, want account forbidden", name, err) + } + } + got, err := st.GetSurfaceAction("alice", action.SurfaceID, action.ActionID, "req1") + if err != nil || got.State != "submitted" { + t.Fatalf("disabled actor changed action: %+v %v", got, err) + } + if _, err := st.GetSurfaceAction("alice", action.SurfaceID, action.ActionID, "req2"); !errors.Is(err, ErrNotFound) { + t.Fatalf("late action inserted: %v", err) + } +} + +func TestSurfaceAccountCheckedOwnerAndImplicitDefault(t *testing.T) { + st := openTestStore(t) + if _, err := st.SetActivationAccountChecked(OwnerDefault, "genui", true, false); !errors.Is(err, ErrAccountForbidden) { + t.Fatalf("implicit owner without allowance: %v", err) + } + if _, err := st.SetActivationAccountChecked(OwnerDefault, "genui", true, true); err != nil { + t.Fatal(err) + } + if err := st.CreateAccount("admin", "", "", true); err != nil { + t.Fatal(err) + } + if err := st.CreateAccount("alice", "", "", false); err != nil { + t.Fatal(err) + } + if _, err := st.SetActivationAccountChecked("alice", "genui", true, false); err != nil { + t.Fatal(err) + } + snap := core.SurfaceSnapshot{Identity: core.SurfaceIdentity{SurfaceID: "surf_alice", Owner: "alice", PluginID: "genui"}, Spec: core.SurfaceSpec{Title: "Owner"}} + if _, err := st.SaveSurfaceSnapshotAccountChecked("admin", snap, false); !errors.Is(err, ErrAccountForbidden) { + t.Fatalf("cross-owner snapshot: %v", err) + } + snap, err := st.SaveSurfaceSnapshotAccountChecked("alice", snap, false) + if err != nil { + t.Fatal(err) + } + if err := st.SetSurfaceEventPendingAccountChecked("admin", snap.Identity.SurfaceID, snap.Revision, true, false); !errors.Is(err, ErrNotFound) { + t.Fatalf("cross-owner pending: %v", err) + } + if _, _, err := st.SaveSurfaceActionIfAbsentAccountChecked("admin", SurfaceActionRecord{Owner: "admin", SurfaceID: string(snap.Identity.SurfaceID), ActionID: "builtin.help", RequestID: "foreign"}, false); !errors.Is(err, ErrNotFound) { + t.Fatalf("cross-owner action: %v", err) + } +} diff --git a/internal/store/surface_actions.go b/internal/store/surface_actions.go index 7125efd..e59875c 100644 --- a/internal/store/surface_actions.go +++ b/internal/store/surface_actions.go @@ -30,8 +30,39 @@ var ErrIdempotencyConflict = errors.New("surface action: request id reused with // SaveSurfaceActionIfAbsent 幂等写入:首个提交插入成功(first=true); // 已存在时返回既有行(first=false)。参数一致性由调用方比对 ParamsHash。 func (s *Store) SaveSurfaceActionIfAbsent(rec SurfaceActionRecord) (SurfaceActionRecord, bool, error) { + return s.saveSurfaceActionIfAbsentExec(s.db, rec) +} + +// SaveSurfaceActionIfAbsentAccountChecked fences the ledger insert and checks +// that the referenced surface still belongs to the actor in that transaction. +func (s *Store) SaveSurfaceActionIfAbsentAccountChecked(actor string, rec SurfaceActionRecord, allowImplicitDefault bool) (SurfaceActionRecord, bool, error) { + if actor != rec.Owner { + return rec, false, ErrAccountForbidden + } + var existing SurfaceActionRecord + var first bool + err := s.withEffectiveAccountWrite(actor, allowImplicitDefault, func(tx *sql.Tx, _ Account) error { + var owner string + if err := tx.QueryRow(s.q(`SELECT owner FROM surfaces WHERE surface_id=? AND owner=?`), rec.SurfaceID, actor).Scan(&owner); errors.Is(err, sql.ErrNoRows) { + return ErrNotFound + } else if err != nil { + return err + } + var err error + existing, first, err = s.saveSurfaceActionIfAbsentExec(tx, rec) + return err + }) + return existing, first, err +} + +type surfaceActionDB interface { + Exec(string, ...any) (sql.Result, error) + QueryRow(string, ...any) *sql.Row +} + +func (s *Store) saveSurfaceActionIfAbsentExec(db surfaceActionDB, rec SurfaceActionRecord) (SurfaceActionRecord, bool, error) { rec.CreatedAt = time.Now().UTC().Format(time.RFC3339) - res, err := s.db.Exec(s.q(`INSERT INTO surface_actions(owner,surface_id,action_id,request_id,revision,params_hash,state,receipt,card_id,created_at) + res, err := db.Exec(s.q(`INSERT INTO surface_actions(owner,surface_id,action_id,request_id,revision,params_hash,state,receipt,card_id,created_at) VALUES(?,?,?,?,?,?,?,?,?,?) ON CONFLICT(owner, surface_id, action_id, request_id) DO NOTHING`), rec.Owner, rec.SurfaceID, rec.ActionID, rec.RequestID, rec.Revision, @@ -40,7 +71,7 @@ func (s *Store) SaveSurfaceActionIfAbsent(rec SurfaceActionRecord) (SurfaceActio return rec, false, err } n, _ := res.RowsAffected() - existing, err := s.GetSurfaceAction(rec.Owner, rec.SurfaceID, rec.ActionID, rec.RequestID) + existing, err := s.getSurfaceActionQuery(db, rec.Owner, rec.SurfaceID, rec.ActionID, rec.RequestID) if err != nil { return rec, false, err } @@ -49,8 +80,12 @@ func (s *Store) SaveSurfaceActionIfAbsent(rec SurfaceActionRecord) (SurfaceActio // GetSurfaceAction 读取台账行(不存在 → ErrNotFound)。 func (s *Store) GetSurfaceAction(owner, surfaceID, actionID, requestID string) (SurfaceActionRecord, error) { + return s.getSurfaceActionQuery(s.db, owner, surfaceID, actionID, requestID) +} + +func (s *Store) getSurfaceActionQuery(db surfaceActionDB, owner, surfaceID, actionID, requestID string) (SurfaceActionRecord, error) { var r SurfaceActionRecord - err := s.db.QueryRow(s.q(`SELECT owner,surface_id,action_id,request_id,revision,params_hash,state,receipt,card_id,created_at + err := db.QueryRow(s.q(`SELECT owner,surface_id,action_id,request_id,revision,params_hash,state,receipt,card_id,created_at FROM surface_actions WHERE owner=? AND surface_id=? AND action_id=? AND request_id=?`), owner, surfaceID, actionID, requestID). Scan(&r.Owner, &r.SurfaceID, &r.ActionID, &r.RequestID, &r.Revision, &r.ParamsHash, &r.State, &r.Receipt, &r.CardID, &r.CreatedAt) @@ -62,7 +97,19 @@ func (s *Store) GetSurfaceAction(owner, surfaceID, actionID, requestID string) ( // UpdateSurfaceActionState 回写分发结果(首个提交提交后的状态迁移)。 func (s *Store) UpdateSurfaceActionState(owner, surfaceID, actionID, requestID, state, receipt, cardID string) error { - res, err := s.db.Exec(s.q(`UPDATE surface_actions SET state=?, receipt=?, card_id=? + return s.updateSurfaceActionStateExec(s.db, owner, surfaceID, actionID, requestID, state, receipt, cardID) +} + +// UpdateSurfaceActionStateAccountChecked fences the final result write. A +// dispatch can have happened before disable, but its later local write cannot. +func (s *Store) UpdateSurfaceActionStateAccountChecked(actor, surfaceID, actionID, requestID, state, receipt, cardID string, allowImplicitDefault bool) error { + return s.withEffectiveAccountWrite(actor, allowImplicitDefault, func(tx *sql.Tx, _ Account) error { + return s.updateSurfaceActionStateExec(tx, actor, surfaceID, actionID, requestID, state, receipt, cardID) + }) +} + +func (s *Store) updateSurfaceActionStateExec(db surfaceExecer, owner, surfaceID, actionID, requestID, state, receipt, cardID string) error { + res, err := db.Exec(s.q(`UPDATE surface_actions SET state=?, receipt=?, card_id=? WHERE owner=? AND surface_id=? AND action_id=? AND request_id=?`), state, receipt, cardID, owner, surfaceID, actionID, requestID) if err != nil { diff --git a/internal/store/surfaces.go b/internal/store/surfaces.go index 0ebf068..f75b799 100644 --- a/internal/store/surfaces.go +++ b/internal/store/surfaces.go @@ -80,9 +80,32 @@ func (s *Store) SetActivation(owner, pluginID string, enabled bool) (ActivationR return ActivationRow{}, err } defer tx.Rollback() + row, err := s.setActivationTx(tx, owner, pluginID, enabled) + if err != nil { + return ActivationRow{}, err + } + if err := tx.Commit(); err != nil { + return ActivationRow{}, err + } + return row, nil +} + +// SetActivationAccountChecked serializes the actor's effective state and the +// activation change in one final transaction. +func (s *Store) SetActivationAccountChecked(actor, pluginID string, enabled, allowImplicitDefault bool) (ActivationRow, error) { + var row ActivationRow + err := s.withEffectiveAccountWrite(actor, allowImplicitDefault, func(tx *sql.Tx, _ Account) error { + var err error + row, err = s.setActivationTx(tx, actor, pluginID, enabled) + return err + }) + return row, err +} + +func (s *Store) setActivationTx(tx *sql.Tx, owner, pluginID string, enabled bool) (ActivationRow, error) { now := time.Now().UTC().Format(time.RFC3339) var epoch int64 - err = tx.QueryRow(s.q(`SELECT epoch FROM plugin_activation WHERE owner=? AND plugin_id=?`), owner, pluginID).Scan(&epoch) + err := tx.QueryRow(s.q(`SELECT epoch FROM plugin_activation WHERE owner=? AND plugin_id=?`), owner, pluginID).Scan(&epoch) if errors.Is(err, sql.ErrNoRows) { epoch = 0 } else if err != nil { @@ -98,9 +121,6 @@ func (s *Store) SetActivation(owner, pluginID string, enabled bool) (ActivationR owner, pluginID, flag, epoch, now); err != nil { return ActivationRow{}, err } - if err := tx.Commit(); err != nil { - return ActivationRow{}, err - } return ActivationRow{Owner: owner, PluginID: pluginID, Enabled: enabled, Epoch: epoch, UpdatedAt: now}, nil } @@ -113,6 +133,27 @@ func (s *Store) SaveSurfaceSnapshot(snap core.SurfaceSnapshot) (core.SurfaceSnap return snap, err } defer tx.Rollback() + snap, err = s.saveSurfaceSnapshotTx(tx, snap) + if err != nil { + return snap, err + } + return snap, tx.Commit() +} + +// SaveSurfaceSnapshotAccountChecked ties the revision CAS to the actor state. +func (s *Store) SaveSurfaceSnapshotAccountChecked(actor string, snap core.SurfaceSnapshot, allowImplicitDefault bool) (core.SurfaceSnapshot, error) { + if actor != snap.Identity.Owner { + return snap, ErrAccountForbidden + } + err := s.withEffectiveAccountWrite(actor, allowImplicitDefault, func(tx *sql.Tx, _ Account) error { + var err error + snap, err = s.saveSurfaceSnapshotTx(tx, snap) + return err + }) + return snap, err +} + +func (s *Store) saveSurfaceSnapshotTx(tx *sql.Tx, snap core.SurfaceSnapshot) (core.SurfaceSnapshot, error) { now := time.Now().UTC().Format(time.RFC3339) specJSON, err := json.Marshal(snap.Spec) if err != nil { @@ -155,7 +196,7 @@ func (s *Store) SaveSurfaceSnapshot(snap core.SurfaceSnapshot) (core.SurfaceSnap snap.ActivationEpoch, string(specJSON), snap.SemanticText, 0, now); err != nil { return snap, err } - return snap, tx.Commit() + return snap, nil } func (s *Store) GetSurface(owner, surfaceID string) (SurfaceRecord, error) { @@ -255,11 +296,26 @@ func (s *Store) SurfaceOwner(surfaceID string) (string, error) { // SetSurfaceEventPending 标记/清除修订行的投影事件待恢复态(写快照后事件发布失败, // KTD「持久事件或重建快照恢复」;恢复扫描走 ListSurfaceEventsPending)。 func (s *Store) SetSurfaceEventPending(owner, surfaceID string, revision int, pending bool) error { + return s.setSurfaceEventPendingExec(s.db, owner, surfaceID, revision, pending) +} + +// SetSurfaceEventPendingAccountChecked fences the post-snapshot recovery mark. +func (s *Store) SetSurfaceEventPendingAccountChecked(actor, surfaceID string, revision int, pending, allowImplicitDefault bool) error { + return s.withEffectiveAccountWrite(actor, allowImplicitDefault, func(tx *sql.Tx, _ Account) error { + return s.setSurfaceEventPendingExec(tx, actor, surfaceID, revision, pending) + }) +} + +type surfaceExecer interface { + Exec(string, ...any) (sql.Result, error) +} + +func (s *Store) setSurfaceEventPendingExec(db surfaceExecer, owner, surfaceID string, revision int, pending bool) error { flag := 0 if pending { flag = 1 } - res, err := s.db.Exec(s.q(`UPDATE surface_revisions SET event_pending=? + res, err := db.Exec(s.q(`UPDATE surface_revisions SET event_pending=? WHERE surface_id=? AND revision=? AND EXISTS(SELECT 1 FROM surfaces WHERE surface_id=? AND owner=?)`), flag, surfaceID, revision, surfaceID, owner) if err != nil { diff --git a/internal/store/vectors.go b/internal/store/vectors.go index e98c544..f559698 100644 --- a/internal/store/vectors.go +++ b/internal/store/vectors.go @@ -2,7 +2,9 @@ package store import ( "context" + "database/sql" "encoding/binary" + "errors" "math" "sort" "strconv" @@ -84,6 +86,60 @@ func (s *Store) UpsertKnowledgeVectorExt(ctx context.Context, owner, docID, ref, return err } +func (s *Store) UpsertKnowledgeVectorAccountChecked(ctx context.Context, actor, docID, ref string, vec []float32, allowImplicitDefault bool) error { + return s.UpsertKnowledgeVectorExtAccountChecked(ctx, actor, docID, ref, "", "", vec, allowImplicitDefault) +} + +// UpsertKnowledgeVectorExtAccountChecked requires an authoritative knowledge +// row owned by actor with the same ref. Conflict updates cannot take over a +// vector row whose owner/ref differs, even if legacy data is inconsistent. +func (s *Store) UpsertKnowledgeVectorExtAccountChecked(ctx context.Context, actor, docID, ref, model, contentHash string, vec []float32, allowImplicitDefault bool) error { + if err := ctx.Err(); err != nil { + return err + } + vecPG := s.dialect.name() == "postgres" && s.ensureVecPG() + return s.withEffectiveAccountWrite(actor, allowImplicitDefault, func(tx *sql.Tx, _ Account) error { + if err := ctx.Err(); err != nil { + return err + } + query := s.q(`SELECT owner,ref FROM knowledge WHERE id=? AND owner=? AND ref=?`) + if s.Dialect() == "postgres" { + query += ` FOR UPDATE` + } + var owner, storedRef string + if err := tx.QueryRowContext(ctx, query, docID, actor, ref).Scan(&owner, &storedRef); errors.Is(err, sql.ErrNoRows) { + return ErrNotFound + } else if err != nil { + return err + } + if owner != actor || storedRef != ref { + return ErrNotFound + } + var res sql.Result + var err error + if vecPG { + res, err = tx.ExecContext(ctx, s.q(`INSERT INTO knowledge_vectors(doc_id,owner,ref,dim,vec,model,content_hash,vec_pg,created_at) VALUES(?,?,?,?,?,?,?,?,?) + ON CONFLICT(doc_id) DO UPDATE SET owner=excluded.owner, ref=excluded.ref, dim=excluded.dim, vec=excluded.vec, model=excluded.model, content_hash=excluded.content_hash, vec_pg=excluded.vec_pg, created_at=excluded.created_at + WHERE knowledge_vectors.owner=excluded.owner AND knowledge_vectors.ref=excluded.ref`), + docID, actor, ref, len(vec), encodeVector(vec), model, contentHash, vectorLiteral(vec), nowISO()) + } else { + res, err = tx.ExecContext(ctx, s.q(`INSERT INTO knowledge_vectors(doc_id,owner,ref,dim,vec,model,content_hash,created_at) VALUES(?,?,?,?,?,?,?,?) + ON CONFLICT(doc_id) DO UPDATE SET owner=excluded.owner, ref=excluded.ref, dim=excluded.dim, vec=excluded.vec, model=excluded.model, content_hash=excluded.content_hash, created_at=excluded.created_at + WHERE knowledge_vectors.owner=excluded.owner AND knowledge_vectors.ref=excluded.ref`), + docID, actor, ref, len(vec), encodeVector(vec), model, contentHash, nowISO()) + } + if err != nil { + return err + } + if n, err := res.RowsAffected(); err != nil { + return err + } else if n != 1 { + return ErrNotFound + } + return nil + }) +} + // ensureVecPG 探测 vec_pg 列是否可用(pgvector 扩展在场;一次性缓存)。 func (s *Store) ensureVecPG() bool { s.vecPGOnce.Do(func() { @@ -100,6 +156,16 @@ func (s *Store) DeleteKnowledgeVectors(ctx context.Context, owner, ref string) e return err } +func (s *Store) DeleteKnowledgeVectorsAccountChecked(ctx context.Context, actor, ref string, allowImplicitDefault bool) error { + return s.withEffectiveAccountWrite(actor, allowImplicitDefault, func(tx *sql.Tx, _ Account) error { + if err := ctx.Err(); err != nil { + return err + } + _, err := tx.ExecContext(ctx, s.q(`DELETE FROM knowledge_vectors WHERE ref=? AND owner=?`), ref, actor) + return err + }) +} + // SearchKnowledgeVectors 余弦扫描:候选 = 租户本人 + 共享(owner=”),dim 匹配, // 再按 RetrievalQuery 的项目/来源过滤。 // SearchKnowledgeVectorsShared EXT-8 余项:他人 owner 的向量行 + 谓词后置判定 diff --git a/internal/surfacesvc/account_write_test.go b/internal/surfacesvc/account_write_test.go new file mode 100644 index 0000000..2f0f406 --- /dev/null +++ b/internal/surfacesvc/account_write_test.go @@ -0,0 +1,133 @@ +package surfacesvc + +import ( + "context" + "encoding/json" + "errors" + "path/filepath" + "testing" + + "agent-system-backend/internal/store" + core "agent-system-core" +) + +func TestAccountCheckedSurfaceActionDoesNotWriteStateAfterDisableDuringDispatch(t *testing.T) { + path := filepath.Join(t.TempDir(), "surface.db") + st, err := store.OpenSQLite(path) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { st.Close() }) + other, err := store.OpenSQLite(path) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { other.Close() }) + for _, a := range []struct { + owner string + admin bool + }{{"admin", true}, {"alice", false}} { + if err := st.CreateAccount(a.owner, "", "", a.admin); err != nil { + t.Fatal(err) + } + } + svc := New(st, nil) + ctx := context.Background() + if _, err := svc.SetActivationAccountChecked(ctx, "alice", "genui", true, false); err != nil { + t.Fatal(err) + } + snap, err := svc.SubmitAccountChecked(ctx, "alice", "genui", SubmitRequest{Spec: json.RawMessage(`{"title":"Form","nodes":[{"type":"form","id":"f1","fields":[{"key":"name","label":"Name","type":"text"}]}]}`)}, false) + if err != nil { + t.Fatal(err) + } + var calls int + svc.SetDispatcher(func(context.Context, ActionDispatch) (ActionOutcome, error) { + calls++ + account, err := other.GetAccountByOwner("alice") + if err != nil { + return ActionOutcome{}, err + } + if _, err := other.SetAccountEnabled("admin", account.ID, account.StateVersion, false); err != nil { + return ActionOutcome{}, err + } + return ActionOutcome{State: "committed"}, nil + }) + result, err := svc.SubmitActionAccountChecked(ctx, "alice", SubmitActionRequest{ + SurfaceID: snap.Identity.SurfaceID, ActionID: "builtin.help", RequestID: "disable-during-dispatch", Revision: snap.Revision, + }, false) + if err != nil || result.State != "committed" || calls != 1 { + t.Fatalf("dispatch result: %+v %v calls=%d", result, err, calls) + } + record, err := st.GetSurfaceAction("alice", string(snap.Identity.SurfaceID), "builtin.help", "disable-during-dispatch") + if err != nil || record.State != "submitted" { + t.Fatalf("late final write persisted: %+v %v", record, err) + } + if _, err := svc.SubmitActionAccountChecked(ctx, "alice", SubmitActionRequest{ + SurfaceID: snap.Identity.SurfaceID, ActionID: "builtin.help", RequestID: "after-disable", Revision: snap.Revision, + }, false); !errors.Is(err, store.ErrAccountForbidden) { + t.Fatalf("disabled dispatch admitted: %v", err) + } + if calls != 1 { + t.Fatalf("disabled action dispatched: calls=%d", calls) + } + if _, err := svc.SubmitAccountChecked(ctx, "alice", "genui", SubmitRequest{Spec: json.RawMessage(`{"title":"Late","nodes":[{"type":"form","id":"f1","fields":[{"key":"name","label":"Name","type":"text"}]}]}`)}, false); !errors.Is(err, store.ErrAccountForbidden) { + t.Fatalf("disabled snapshot admitted: %v", err) + } + if _, err := svc.SetActivationAccountChecked(ctx, "alice", "genui", false, false); !errors.Is(err, store.ErrAccountForbidden) { + t.Fatalf("disabled activation admitted: %v", err) + } +} + +func TestAccountCheckedSurfaceRecoveryWaitsForEffectiveOwner(t *testing.T) { + st, err := store.OpenSQLite(filepath.Join(t.TempDir(), "surface.db")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { st.Close() }) + for _, a := range []struct { + owner string + admin bool + }{{"admin", true}, {"alice", false}} { + if err := st.CreateAccount(a.owner, "", "", a.admin); err != nil { + t.Fatal(err) + } + } + var calls int + svc := New(st, func(string, string, core.StreamEventType, any) error { + calls++ + return errors.New("offline") + }) + svc.SetAccountCheckedEmitter(func(string, string, string, core.StreamEventType, any) error { + calls++ + return errors.New("offline") + }) + ctx := context.Background() + if _, err := svc.SetActivationAccountChecked(ctx, "alice", "genui", true, false); err != nil { + t.Fatal(err) + } + _, err = svc.SubmitAccountChecked(ctx, "alice", "genui", SubmitRequest{ConversationID: "c1", Spec: json.RawMessage(`{"title":"Form","nodes":[{"type":"form","id":"f1","fields":[{"key":"name","label":"Name","type":"text"}]}]}`)}, false) + if err != nil { + t.Fatal(err) + } + pending, err := st.ListSurfaceEventsPending(10) + if err != nil || len(pending) != 1 { + t.Fatalf("expected pending snapshot: %v rows=%d", err, len(pending)) + } + account, err := st.GetAccountByOwner("alice") + if err != nil { + t.Fatal(err) + } + if _, err := st.SetAccountEnabled("admin", account.ID, account.StateVersion, false); err != nil { + t.Fatal(err) + } + if n, err := svc.RecoverPendingEventsAccountChecked(ctx, false); err != nil || n != 0 { + t.Fatalf("disabled recovery: n=%d err=%v", n, err) + } + if calls != 1 { + t.Fatalf("disabled owner emitted recovery event, calls=%d", calls) + } + pending, err = st.ListSurfaceEventsPending(10) + if err != nil || len(pending) != 1 { + t.Fatalf("disabled recovery cleared marker: %v rows=%d", err, len(pending)) + } +} diff --git a/internal/surfacesvc/surfacesvc.go b/internal/surfacesvc/surfacesvc.go index d0d1d38..1febf5e 100644 --- a/internal/surfacesvc/surfacesvc.go +++ b/internal/surfacesvc/surfacesvc.go @@ -19,6 +19,7 @@ import ( // Emitter 由宿主注入(与 cardsvc 同一事件管道):持久化、seq 排序、断线重放。 type Emitter func(conversationID, runID string, typ core.StreamEventType, payload any) error +type AccountCheckedEmitter func(actor, conversationID, runID string, typ core.StreamEventType, payload any) error var ( ErrNotFound = store.ErrNotFound @@ -47,11 +48,12 @@ type SubmitRequest struct { } type Service struct { - st *store.Store - emit Emitter - dispatch Dispatcher - now func() (t string) - logln func(format string, v ...any) + st *store.Store + emit Emitter + accountCheckedEmit AccountCheckedEmitter + dispatch Dispatcher + now func() (t string) + logln func(format string, v ...any) } func New(st *store.Store, emit Emitter) *Service { @@ -60,9 +62,24 @@ func New(st *store.Store, emit Emitter) *Service { logln: log.Printf} } +func (s *Service) SetAccountCheckedEmitter(emit AccountCheckedEmitter) { s.accountCheckedEmit = emit } + // Submit 是唯一的写入咽喉:严格解码 → 目录校验 → 启禁/epoch → CAS 保存 → 事件。 // 写入成功但事件发布失败时标记待恢复并照常返回快照(可恢复的最终一致)。 func (s *Service) Submit(ctx context.Context, owner, pluginID string, req SubmitRequest) (core.SurfaceSnapshot, error) { + return s.submit(ctx, owner, pluginID, req, false, false) +} + +// SubmitAccountChecked fences the snapshot and any pending-event marker at +// their final Store writes. The caller supplies the auth-off default allowance. +func (s *Service) SubmitAccountChecked(ctx context.Context, owner, pluginID string, req SubmitRequest, allowImplicitDefault bool) (core.SurfaceSnapshot, error) { + return s.submit(ctx, owner, pluginID, req, true, allowImplicitDefault) +} + +func (s *Service) submit(ctx context.Context, owner, pluginID string, req SubmitRequest, checked, allowImplicitDefault bool) (core.SurfaceSnapshot, error) { + if checked && s.emit != nil && s.accountCheckedEmit == nil { + return core.SurfaceSnapshot{}, ErrUnavailable + } if owner == "" || pluginID == "" { return core.SurfaceSnapshot{}, ErrUnavailable } @@ -107,11 +124,16 @@ func (s *Service) Submit(ctx context.Context, owner, pluginID string, req Submit Spec: spec, SemanticText: req.SemanticText, } - saved, err := s.st.SaveSurfaceSnapshot(snap) + var saved core.SurfaceSnapshot + if checked { + saved, err = s.st.SaveSurfaceSnapshotAccountChecked(owner, snap, allowImplicitDefault) + } else { + saved, err = s.st.SaveSurfaceSnapshot(snap) + } if err != nil { return saved, err } - s.publish(saved, false) + s.publish(saved, false, checked, allowImplicitDefault) return saved, nil } @@ -133,7 +155,21 @@ func (s *Service) Activation(ctx context.Context, owner, pluginID string) (store // SetActivation 启/禁 Surface 能力:先持久化并递增 epoch,再返回(禁用返回即 // 准入截止点);审计留痕。新插件默认关闭(无行=disabled)。 func (s *Service) SetActivation(ctx context.Context, owner, pluginID string, enabled bool) (store.ActivationRow, error) { - row, err := s.st.SetActivation(owner, pluginID, enabled) + return s.setActivation(ctx, owner, pluginID, enabled, false, false) +} + +func (s *Service) SetActivationAccountChecked(ctx context.Context, owner, pluginID string, enabled, allowImplicitDefault bool) (store.ActivationRow, error) { + return s.setActivation(ctx, owner, pluginID, enabled, true, allowImplicitDefault) +} + +func (s *Service) setActivation(ctx context.Context, owner, pluginID string, enabled, checked, allowImplicitDefault bool) (store.ActivationRow, error) { + var row store.ActivationRow + var err error + if checked { + row, err = s.st.SetActivationAccountChecked(owner, pluginID, enabled, allowImplicitDefault) + } else { + row, err = s.st.SetActivation(owner, pluginID, enabled) + } if err != nil { return row, err } @@ -141,22 +177,51 @@ func (s *Service) SetActivation(ctx context.Context, owner, pluginID string, ena if enabled { action = "surface.enabled" } - if err := s.st.AppendAuditEvent(store.AuditEvent{ + event := store.AuditEvent{ Owner: owner, Actor: "host", Action: action, Target: pluginID, Evidence: fmt.Sprintf("epoch=%d", row.Epoch), - }); err != nil { + } + if checked { + err = s.st.AppendAuditEventAccountChecked(owner, event, allowImplicitDefault) + } else { + err = s.st.AppendAuditEvent(event) + } + if err != nil { return row, err } return row, nil } // publish 发布投影事件;失败则标记待恢复(快照已落盘,不回滚写入)。 -func (s *Service) publish(snap core.SurfaceSnapshot, recovery bool) { - if s.emit == nil || snap.Identity.ConversationID == "" { +func (s *Service) publish(snap core.SurfaceSnapshot, recovery, checked, allowImplicitDefault bool) { + if snap.Identity.ConversationID == "" || (checked && s.accountCheckedEmit == nil) || (!checked && s.emit == nil) { return } - if err := s.emit(string(snap.Identity.ConversationID), string(snap.Identity.SourceRunID), core.EvSurfaceUpdated, snap); err != nil { - if markErr := s.st.SetSurfaceEventPending(snap.Identity.Owner, string(snap.Identity.SurfaceID), snap.Revision, true); markErr != nil { + // The callback may write to a separate event sink. This is the last local + // effective-state check; a cross-sink check-to-effect race remains. + if checked { + if err := s.st.CheckEffectiveAccountForExternalEffect(snap.Identity.Owner, allowImplicitDefault); err != nil { + s.logln("surface: event publication skipped: %v", err) + return + } + } + var emitErr error + if checked { + if s.accountCheckedEmit == nil { + return + } + emitErr = s.accountCheckedEmit(snap.Identity.Owner, string(snap.Identity.ConversationID), string(snap.Identity.SourceRunID), core.EvSurfaceUpdated, snap) + } else { + emitErr = s.emit(string(snap.Identity.ConversationID), string(snap.Identity.SourceRunID), core.EvSurfaceUpdated, snap) + } + if err := emitErr; err != nil { + var markErr error + if checked { + markErr = s.st.SetSurfaceEventPendingAccountChecked(snap.Identity.Owner, string(snap.Identity.SurfaceID), snap.Revision, true, allowImplicitDefault) + } else { + markErr = s.st.SetSurfaceEventPending(snap.Identity.Owner, string(snap.Identity.SurfaceID), snap.Revision, true) + } + if markErr != nil { s.logln("surface: event publish failed (%v) and pending-mark failed (%v) rev=%d", err, markErr, snap.Revision) return } @@ -182,7 +247,7 @@ func (s *Service) RecoverPendingEvents(ctx context.Context) (int, error) { if err != nil { continue } - s.publish(snap, true) + s.publish(snap, true, false, false) if err := s.st.SetSurfaceEventPending(owner, row.SurfaceID, row.Revision, false); err != nil { continue } @@ -191,6 +256,43 @@ func (s *Service) RecoverPendingEvents(ctx context.Context) (int, error) { return n, nil } +// RecoverPendingEventsAccountChecked retries only while the snapshot owner is +// effective. The final pending clear has its own account-guarded transaction. +func (s *Service) RecoverPendingEventsAccountChecked(ctx context.Context, allowImplicitDefault bool) (int, error) { + rows, err := s.st.ListSurfaceEventsPending(100) + if err != nil { + return 0, err + } + n := 0 + for _, row := range rows { + if err := ctx.Err(); err != nil { + return n, err + } + owner, err := s.st.SurfaceOwner(row.SurfaceID) + if err != nil { + continue + } + snap, err := s.st.GetSurfaceSnapshot(owner, row.SurfaceID, row.Revision) + if err != nil { + continue + } + if s.accountCheckedEmit == nil || snap.Identity.ConversationID == "" { + continue + } + if err := s.st.CheckEffectiveAccountForExternalEffect(owner, allowImplicitDefault); err != nil { + continue + } + if err := s.accountCheckedEmit(owner, string(snap.Identity.ConversationID), string(snap.Identity.SourceRunID), core.EvSurfaceUpdated, snap); err != nil { + continue + } + if err := s.st.SetSurfaceEventPendingAccountChecked(owner, row.SurfaceID, row.Revision, false, allowImplicitDefault); err != nil { + continue + } + n++ + } + return n, nil +} + // decodeSpecStrict 严格解码:未知字段拒绝(DisallowUnknownFields),并限制体积。 func decodeSpecStrict(raw json.RawMessage) (core.SurfaceSpec, error) { if len(raw) == 0 { @@ -270,6 +372,17 @@ func (s *Service) SetDispatcher(d Dispatcher) { s.dispatch = d } // 本方法不接收也不透传 Confirmed:高危确认只能经既有确认卡回道置位, // 模型或客户端伪造 Confirmed 无入口(红测断言)。 func (s *Service) SubmitAction(ctx context.Context, owner string, req SubmitActionRequest) (SurfaceActionResult, error) { + return s.submitAction(ctx, owner, req, false, false) +} + +// SubmitActionAccountChecked guards both ledger writes and performs a fresh +// state check immediately before dispatch. Dispatch is an external effect, so +// this check does not make it atomic with a subsequent account disable. +func (s *Service) SubmitActionAccountChecked(ctx context.Context, owner string, req SubmitActionRequest, allowImplicitDefault bool) (SurfaceActionResult, error) { + return s.submitAction(ctx, owner, req, true, allowImplicitDefault) +} + +func (s *Service) submitAction(ctx context.Context, owner string, req SubmitActionRequest, checked, allowImplicitDefault bool) (SurfaceActionResult, error) { cmd, ok := AllowedActions[req.ActionID] if !ok { return SurfaceActionResult{}, ErrUnknownAction @@ -294,10 +407,17 @@ func (s *Service) SubmitAction(ctx context.Context, owner string, req SubmitActi } raw, _ := json.Marshal(req.Data) hash := fmt.Sprintf("%x", sha256.Sum256(raw)) - existing, first, err := s.st.SaveSurfaceActionIfAbsent(store.SurfaceActionRecord{ + entry := store.SurfaceActionRecord{ Owner: owner, SurfaceID: string(req.SurfaceID), ActionID: req.ActionID, RequestID: req.RequestID, Revision: req.Revision, ParamsHash: hash, State: "submitted", - }) + } + var existing store.SurfaceActionRecord + var first bool + if checked { + existing, first, err = s.st.SaveSurfaceActionIfAbsentAccountChecked(owner, entry, allowImplicitDefault) + } else { + existing, first, err = s.st.SaveSurfaceActionIfAbsent(entry) + } if err != nil { return SurfaceActionResult{}, err } @@ -324,6 +444,11 @@ func (s *Service) SubmitAction(ctx context.Context, owner string, req SubmitActi } params[k] = v } + if checked { + if err := s.st.CheckEffectiveAccountForExternalEffect(owner, allowImplicitDefault); err != nil { + return SurfaceActionResult{}, err + } + } out, err := s.dispatch(ctx, ActionDispatch{ CommandID: cmd, Owner: owner, ConversationID: rec.ConversationID, Params: params, }) @@ -332,8 +457,15 @@ func (s *Service) SubmitAction(ctx context.Context, owner string, req SubmitActi state, cardID = "rejected", "" } receiptJSON, _ := json.Marshal(out.Receipt) - if uerr := s.st.UpdateSurfaceActionState(owner, string(req.SurfaceID), req.ActionID, req.RequestID, - state, string(receiptJSON), cardID); uerr != nil { + var uerr error + if checked { + uerr = s.st.UpdateSurfaceActionStateAccountChecked(owner, string(req.SurfaceID), req.ActionID, req.RequestID, + state, string(receiptJSON), cardID, allowImplicitDefault) + } else { + uerr = s.st.UpdateSurfaceActionState(owner, string(req.SurfaceID), req.ActionID, req.RequestID, + state, string(receiptJSON), cardID) + } + if uerr != nil { s.logln("surface: action state update failed: %v", uerr) } if err != nil { diff --git a/internal/tools/codeorch_tool.go b/internal/tools/codeorch_tool.go index c578900..e49d86a 100644 --- a/internal/tools/codeorch_tool.go +++ b/internal/tools/codeorch_tool.go @@ -54,11 +54,17 @@ func execCodeScript(rc ToolExecCtx, args json.RawMessage) (string, error) { if root == "" { return "", fmt.Errorf("代码编排没有会话目录") } + if err := checkToolExternalEffect(rc); err != nil { + return "", err + } lim := codeorch.Limits{FullDir: root} if req.Timeout > 0 { lim.Timeout = time.Duration(req.Timeout) * time.Second } - out := codeorch.Run(context.Background(), root, req.Script, lim, nil, func(_ context.Context, call codeorch.Call) (string, error) { + out := codeorch.Run(execParentCtx(rc), root, req.Script, lim, nil, func(callCtx context.Context, call codeorch.Call) (string, error) { + if err := checkExternalEffect(callCtx, func() error { return checkToolExternalEffect(rc) }); err != nil { + return "", err + } if call.Op != "tool.call" { return "", fmt.Errorf("不能调用 %s", call.Op) } diff --git a/internal/tools/effect_guard.go b/internal/tools/effect_guard.go new file mode 100644 index 0000000..b224af3 --- /dev/null +++ b/internal/tools/effect_guard.go @@ -0,0 +1,36 @@ +package tools + +import "context" + +// externalEffectChecker is supplied by the runtime context for account and +// resource admission immediately before an effect. Older embedded tool +// contexts can omit it; the runtime is responsible for wiring the real check. +type externalEffectChecker interface { + CheckExternalEffect() error +} + +func checkToolExternalEffect(rc ToolExecCtx) error { + return checkExternalEffect(execParentCtx(rc), func() error { + if checker, ok := rc.(externalEffectChecker); ok { + return checker.CheckExternalEffect() + } + return nil + }) +} + +func checkExternalEffect(ctx context.Context, check func() error) error { + if ctx != nil { + if err := ctx.Err(); err != nil { + return err + } + } + if check != nil { + if err := check(); err != nil { + return err + } + } + if ctx != nil { + return ctx.Err() + } + return nil +} diff --git a/internal/tools/exec.go b/internal/tools/exec.go index 5a42cf0..df972e2 100644 --- a/internal/tools/exec.go +++ b/internal/tools/exec.go @@ -75,6 +75,9 @@ func execSandbox(rc ToolExecCtx) (string, error) { if err != nil { return "", err } + if err := checkToolExternalEffect(rc); err != nil { + return "", err + } if err := os.MkdirAll(sandbox, 0o755); err != nil { return "", fmt.Errorf("exec tools: create sandbox: %w", err) } @@ -133,6 +136,9 @@ func execRegisterArtifact(rc ToolExecCtx, args json.RawMessage) (string, error) } name := filepath.Base(abs) mime := mimeOfExt(name) + if err := checkToolExternalEffect(rc); err != nil { + return "", err + } art, err := rc.WriteArtifact(name, artifactKindOf(name, mime), mime, b) if err != nil { return "", err diff --git a/internal/tools/exec_backend.go b/internal/tools/exec_backend.go index 4d11ac6..379479c 100644 --- a/internal/tools/exec_backend.go +++ b/internal/tools/exec_backend.go @@ -36,10 +36,11 @@ type ExecSpec struct { DepsDir string // S0 工作区 overlay(sandbox 档;三者须同批非空):冻结基底(--overlay-src)、 // 私有 upper(--overlay RWSRC)、overlayfs work 目录。空 = EXT-16 现状直写。 - WorkspaceFrozen string - WorkspaceUpper string - WorkspaceWork string - Timeout time.Duration // 硬超时(调用方 ctx.WithTimeout 之前传入,仅记录用) + WorkspaceFrozen string + WorkspaceUpper string + WorkspaceWork string + Timeout time.Duration // 硬超时(调用方 ctx.WithTimeout 之前传入,仅记录用) + CheckExternalEffect func() error // 启动与 overlay 写回前重验账号;nil 为旧内部调用 } // ExecProcess 后端启动的执行体句柄:Wait 等待退出;Kill 杀整个执行体 @@ -106,6 +107,9 @@ func (directBackend) Start(ctx context.Context, spec ExecSpec, out io.Writer) (E } cmd.Stdout = out cmd.Stderr = out + if err := checkExternalEffect(ctx, spec.CheckExternalEffect); err != nil { + return nil, err + } if err := cmd.Start(); err != nil { return nil, err } @@ -158,6 +162,9 @@ func (sandboxBackend) Start(ctx context.Context, spec ExecSpec, out io.Writer) ( } cmd.Stdout = out cmd.Stderr = out + if err := checkExternalEffect(ctx, spec.CheckExternalEffect); err != nil { + return nil, err + } if err := cmd.Start(); err != nil { return nil, err } diff --git a/internal/tools/exec_interpreter.go b/internal/tools/exec_interpreter.go index 55538b1..7030839 100644 --- a/internal/tools/exec_interpreter.go +++ b/internal/tools/exec_interpreter.go @@ -101,10 +101,16 @@ func truncateOutput(b []byte) string { // writeScript 把模型脚本落盘到工作区 .agent/ 下(字节精确,执行后保留),返回绝对路径。 func writeScript(rc ToolExecCtx, sandbox, prefix, ext, content string) (string, error) { dir := filepath.Join(sandbox, execScriptDir) + if err := checkToolExternalEffect(rc); err != nil { + return "", err + } if err := os.MkdirAll(dir, 0o755); err != nil { return "", err } p := filepath.Join(dir, fmt.Sprintf("%s-%s-%d%s", prefix, rc.ConversationID(), time.Now().UnixNano(), ext)) + if err := checkToolExternalEffect(rc); err != nil { + return "", err + } if err := os.WriteFile(p, []byte(content), 0o600); err != nil { return "", err } @@ -144,7 +150,7 @@ func resolveInterpreter(name string) (string, error) { // runInterpreter 启动白名单解释器执行落盘脚本(脚本路径为唯一参数)。 // 进程启动经 EXT-16 执行后端(direct=现状直启 / sandbox=bwrap 隔离档)。 // 非零退出、超时与请求取消都是正常反馈,以文本返回;仅启动失败算错误。 -func runInterpreter(parent context.Context, interpreter, scriptPath, dir string, timeoutS int, sink func(stream, text string)) (string, error) { +func runInterpreter(parent context.Context, interpreter, scriptPath, dir string, timeoutS int, sink func(stream, text string), checks ...func() error) (string, error) { if parent == nil { parent = context.Background() } @@ -157,9 +163,14 @@ func runInterpreter(parent context.Context, interpreter, scriptPath, dir string, } ctx, cancel := context.WithTimeout(parent, time.Duration(timeoutS)*time.Second) defer cancel() + var check func() error + if len(checks) > 0 { + check = checks[0] + } spec := ExecSpec{ Bin: bin, Args: []string{scriptPath}, Dir: dir, Env: execEnv(), SkillsDir: skillsRoot, DepsDir: execDepsDir, Timeout: time.Duration(timeoutS) * time.Second, + CheckExternalEffect: check, } pr, pw := io.Pipe() var buf bytes.Buffer @@ -245,7 +256,7 @@ func execShell(rc ToolExecCtx, args json.RawMessage) (string, error) { if err != nil { return "", err } - return runInterpreter(execParentCtx(rc), "bash", p, sandbox, firstPos(a.TimeoutS, a.TimeoutSec), toolOutputSink(rc)) + return runInterpreter(execParentCtx(rc), "bash", p, sandbox, firstPos(a.TimeoutS, a.TimeoutSec), toolOutputSink(rc), func() error { return checkToolExternalEffect(rc) }) } // ScriptCommand 准备一条以当前用户身份、工作目录为会话沙箱的 python3 命令。 @@ -301,7 +312,7 @@ func execPython(rc ToolExecCtx, args json.RawMessage) (string, error) { if err != nil { return "", err } - return runInterpreter(execParentCtx(rc), "python3", p, sandbox, firstPos(a.TimeoutS, a.TimeoutSec), toolOutputSink(rc)) + return runInterpreter(execParentCtx(rc), "python3", p, sandbox, firstPos(a.TimeoutS, a.TimeoutSec), toolOutputSink(rc), func() error { return checkToolExternalEffect(rc) }) } // toolOutputSink 在执行上下文实现了 EmitToolOutput 时,把 stdout 增量交出去。 diff --git a/internal/tools/exec_workspace.go b/internal/tools/exec_workspace.go index 765f2e1..4d8a995 100644 --- a/internal/tools/exec_workspace.go +++ b/internal/tools/exec_workspace.go @@ -52,11 +52,18 @@ func upperDir(sessionDir, id string) string { // freezeBase 硬链接克隆 dir 到 frozen(已存在则报错——每个 id 只冻结一次), // 返回基线清单 {相对路径: sha256}。符号链接原样保留(链接本身少见内容变更)。 func freezeBase(dir, frozen string) (map[string]string, error) { + return freezeBaseChecked(context.Background(), nil, dir, frozen) +} + +func freezeBaseChecked(ctx context.Context, check func() error, dir, frozen string) (map[string]string, error) { if _, err := os.Lstat(frozen); err == nil { return nil, fmt.Errorf("freeze id reused: %s", frozen) } else if !errors.Is(err, os.ErrNotExist) { return nil, err } + if err := checkExternalEffect(ctx, check); err != nil { + return nil, err + } if err := os.MkdirAll(frozen, 0o755); err != nil { return nil, err } @@ -81,6 +88,9 @@ func freezeBase(dir, frozen string) (map[string]string, error) { if ierr != nil { return ierr } + if err := checkExternalEffect(ctx, check); err != nil { + return err + } switch { case d.IsDir(): return os.Mkdir(target, info.Mode().Perm()) @@ -101,7 +111,7 @@ func freezeBase(dir, frozen string) (map[string]string, error) { } else if !errors.Is(err, syscall.EXDEV) && !errors.Is(err, os.ErrPermission) { return err } - return copyFile(p, target, info.Mode().Perm()) // 受限场景退化复制 + return copyFileChecked(ctx, check, p, target, info.Mode().Perm()) // 受限场景退化复制 default: return nil // 套接字/设备等:会话工作区不出现,跳过 } @@ -125,7 +135,14 @@ type Change struct { // whiteout(overlayfs 删除标记:字符设备 0/0)解释为删除。 // 返回变更清单(按路径排序)与错误(目录级失败即停——宁可少改不乱改)。 func applyUpper(upper, dir string, manifest map[string]string) ([]Change, error) { + return applyUpperChecked(context.Background(), nil, upper, dir, manifest) +} + +func applyUpperChecked(ctx context.Context, check func() error, upper, dir string, manifest map[string]string) ([]Change, error) { var changes []Change + if err := checkExternalEffect(ctx, check); err != nil { + return changes, err + } err := filepath.WalkDir(upper, func(p string, d os.DirEntry, err error) error { if err != nil { return err @@ -146,6 +163,9 @@ func applyUpper(upper, dir string, manifest map[string]string) ([]Change, error) case d.IsDir(): // 目录本身不构成变更;缺失时补建(rename 文件需要父目录在位)。 if _, serr := os.Lstat(target); serr != nil { + if err := checkExternalEffect(ctx, check); err != nil { + return err + } return os.MkdirAll(target, info.Mode().Perm()) } return nil @@ -166,6 +186,9 @@ func applyUpper(upper, dir string, manifest map[string]string) ([]Change, error) changes = append(changes, Change{Rel: rel, Op: "del", Applied: false, Conflict: true}) return nil // 被外部改过:不删,报告冲突 default: + if err := checkExternalEffect(ctx, check); err != nil { + return err + } if rmErr := os.Remove(target); rmErr != nil && !errors.Is(rmErr, os.ErrNotExist) { return rmErr } @@ -185,14 +208,17 @@ func applyUpper(upper, dir string, manifest map[string]string) ([]Change, error) } conflict := targetExists && curSum != baseSum // 外部已改(或基底没有却被外部新建) if conflict { - if cpErr := copyFile(p, target+".conflict-"+time.Now().Format("0102-150405"), info.Mode().Perm()); cpErr != nil { + if cpErr := copyFileChecked(ctx, check, p, target+".conflict-"+time.Now().Format("0102-150405"), info.Mode().Perm()); cpErr != nil { return cpErr } changes = append(changes, Change{Rel: rel, Op: changeOp(hadBase, targetExists), Applied: false, Conflict: true}) return nil } + if err := checkExternalEffect(ctx, check); err != nil { + return err + } if mvErr := os.Rename(p, target); mvErr != nil { // 同 fs 原子替换 - if cpErr := copyFile(p, target, info.Mode().Perm()); cpErr != nil { // 跨设备兜底 + if cpErr := copyFileChecked(ctx, check, p, target, info.Mode().Perm()); cpErr != nil { // 跨设备兜底 return cpErr } } @@ -244,14 +270,24 @@ func fileSHA256(path string) (string, error) { } func copyFile(src, dst string, perm os.FileMode) error { + return copyFileChecked(context.Background(), nil, src, dst, perm) +} + +func copyFileChecked(ctx context.Context, check func() error, src, dst string, perm os.FileMode) error { in, err := os.Open(src) if err != nil { return err } defer in.Close() + if err := checkExternalEffect(ctx, check); err != nil { + return err + } if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil { return err } + if err := checkExternalEffect(ctx, check); err != nil { + return err + } out, err := os.OpenFile(dst+".tmp-s0", os.O_WRONLY|os.O_CREATE|os.O_TRUNC, perm) if err != nil { return err @@ -264,6 +300,10 @@ func copyFile(src, dst string, perm os.FileMode) error { if err := out.Close(); err != nil { return err } + if err := checkExternalEffect(ctx, check); err != nil { + _ = os.Remove(dst + ".tmp-s0") + return err + } return os.Rename(dst+".tmp-s0", dst) // 复制路径也走原子替换 } @@ -311,12 +351,16 @@ func startOverlaySandbox(ctx context.Context, spec ExecSpec, bin string, out io. upper := filepath.Join(wsRoot, "upper") work := filepath.Join(wsRoot, "work") for _, d := range []string{upper, work} { // frozen 由 freezeBase 自建(重用检查依赖不存在) + if err := checkExternalEffect(ctx, spec.CheckExternalEffect); err != nil { + os.RemoveAll(wsRoot) + return nil, err + } if err := os.MkdirAll(d, 0o755); err != nil { os.RemoveAll(wsRoot) return nil, fmt.Errorf("workspace overlay: %w", err) } } - manifest, err := freezeBase(spec.Dir, frozen) + manifest, err := freezeBaseChecked(ctx, spec.CheckExternalEffect, spec.Dir, frozen) if err != nil { os.RemoveAll(wsRoot) return nil, fmt.Errorf("workspace freeze: %w", err) @@ -339,11 +383,15 @@ func startOverlaySandbox(ctx context.Context, spec ExecSpec, bin string, out io. } cmd.Stdout = out cmd.Stderr = out + if err := checkExternalEffect(ctx, spec.CheckExternalEffect); err != nil { + os.RemoveAll(wsRoot) + return nil, err + } if err := cmd.Start(); err != nil { os.RemoveAll(wsRoot) return nil, err } - return &overlayProcess{inner: cmdProcess{cmd: cmd}, ctx: ctx, wsRoot: wsRoot, dir: spec.Dir, manifest: manifest}, nil + return &overlayProcess{inner: cmdProcess{cmd: cmd}, ctx: ctx, check: spec.CheckExternalEffect, wsRoot: wsRoot, dir: spec.Dir, manifest: manifest}, nil } // overlayProcess 在进程退出时回收工作区:ctx 已取消(超时/中止)→ 整体丢弃; @@ -351,6 +399,7 @@ func startOverlaySandbox(ctx context.Context, spec ExecSpec, bin string, out io. type overlayProcess struct { inner ExecProcess ctx context.Context + check func() error wsRoot string dir string manifest map[string]string @@ -364,9 +413,10 @@ func (p *overlayProcess) Wait() error { p.summary = "\n[工作区变更:已丢弃——执行超时或被中止,本次改动未落地]" return err } - changes, aerr := applyUpper(filepath.Join(p.wsRoot, "upper"), p.dir, p.manifest) + changes, aerr := applyUpperChecked(p.ctx, p.check, filepath.Join(p.wsRoot, "upper"), p.dir, p.manifest) if aerr != nil { log.Printf("[exec] workspace apply partial failure (stopping at first dir error): %v", aerr) + return aerr } if s := workspaceChangeSummary(changes); s != "" { p.summary = "\n" + s diff --git a/internal/tools/external_effect_test.go b/internal/tools/external_effect_test.go new file mode 100644 index 0000000..023a86c --- /dev/null +++ b/internal/tools/external_effect_test.go @@ -0,0 +1,189 @@ +package tools + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "sync/atomic" + "testing" + "time" + + agenttools "agent-native/runtime/tools" + core "agent-system-core" +) + +var errToolActorDisabled = errors.New("tool actor disabled") + +type guardedToolContext struct { + ToolExecCtx + ctx context.Context + check func() error +} + +func (c guardedToolContext) Context() context.Context { + if c.ctx != nil { + return c.ctx + } + return context.Background() +} + +func (c guardedToolContext) CheckExternalEffect() error { + if c.check != nil { + return c.check() + } + return nil +} + +func TestHTTPToolChecksActorAndPropagatesCancellation(t *testing.T) { + var calls atomic.Int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls.Add(1) + <-r.Context().Done() + })) + defer srv.Close() + exec := HTTPExecutor(core.HTTPToolSpec{Method: "POST", URL: srv.URL}, nil) + denied := guardedToolContext{ToolExecCtx: dummyCtx{}, check: func() error { return errToolActorDisabled }} + if _, err := exec(denied, nil); !errors.Is(err, errToolActorDisabled) || calls.Load() != 0 { + t.Fatalf("disabled HTTP effect: %v, calls=%d", err, calls.Load()) + } + ctx, cancel := context.WithCancel(context.Background()) + cancel() + if _, err := exec(guardedToolContext{ToolExecCtx: dummyCtx{}, ctx: ctx}, nil); !errors.Is(err, context.Canceled) || calls.Load() != 0 { + t.Fatalf("canceled HTTP effect: %v, calls=%d", err, calls.Load()) + } +} + +func TestHTTPToolCancelsInFlightRequest(t *testing.T) { + started := make(chan struct{}) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + close(started) + <-r.Context().Done() + })) + defer srv.Close() + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + rc := guardedToolContext{ToolExecCtx: dummyCtx{}, ctx: ctx} + done := make(chan error, 1) + go func() { + _, err := HTTPExecutor(core.HTTPToolSpec{Method: "GET", URL: srv.URL}, nil)(rc, nil) + done <- err + }() + select { + case <-started: + case <-time.After(3 * time.Second): + t.Fatal("HTTP request did not reach server") + } + cancel() + select { + case err := <-done: + if !errors.Is(err, context.Canceled) { + t.Fatalf("in-flight request cancellation: %v", err) + } + case <-time.After(3 * time.Second): + t.Fatal("in-flight HTTP request ignored cancellation") + } +} + +type observedSearchProvider struct { + calls int + ctx context.Context +} + +func (p *observedSearchProvider) Search(ctx context.Context, _ agenttools.SearchRequest) (agenttools.SearchResponse, error) { + p.calls++ + p.ctx = ctx + return agenttools.SearchResponse{}, nil +} + +func TestSearchToolChecksActorAndPassesRunContext(t *testing.T) { + p := &observedSearchProvider{} + r := NewRegistry() + if err := RegisterSearchTools(r, p); err != nil { + t.Fatal(err) + } + denied := guardedToolContext{ToolExecCtx: dummyCtx{}, check: func() error { return errToolActorDisabled }} + if res := r.Execute(denied, core.ToolCallRequest{Name: "web_search", Arguments: json.RawMessage(`{"query":"q"}`)}); res.Status != "error" || !strings.Contains(res.Output, errToolActorDisabled.Error()) || p.calls != 0 { + t.Fatalf("disabled search effect: %+v, calls=%d", res, p.calls) + } + ctx, cancel := context.WithCancel(context.Background()) + cancel() + if res := r.Execute(guardedToolContext{ToolExecCtx: dummyCtx{}, ctx: ctx}, core.ToolCallRequest{Name: "web_search", Arguments: json.RawMessage(`{"query":"q"}`)}); res.Status != "error" || p.calls != 0 { + t.Fatalf("canceled search effect: %+v, calls=%d", res, p.calls) + } + if res := r.Execute(guardedToolContext{ToolExecCtx: dummyCtx{}}, core.ToolCallRequest{Name: "web_search", Arguments: json.RawMessage(`{"query":"q"}`)}); res.Status != "ok" || p.calls != 1 || p.ctx == nil { + t.Fatalf("search context: %+v, calls=%d, context=%v", res, p.calls, p.ctx) + } +} + +func TestFSAndInterpreterCheckBeforeFilesystemEffects(t *testing.T) { + root := t.TempDir() + fsCtx := &fakeFSCtx{root: root, owner: "o", conv: "c", fsToolsOn: true} + deniedFS := guardedToolContext{ToolExecCtx: fsCtx, check: func() error { return errToolActorDisabled }} + if _, err := execFSWrite(deniedFS, json.RawMessage(`{"path":"note.md","content":"x"}`)); !errors.Is(err, errToolActorDisabled) { + t.Fatalf("disabled fs write: %v", err) + } + if _, err := os.Stat(filepath.Join(root, "o", "c")); !os.IsNotExist(err) { + t.Fatalf("disabled fs write created sandbox: %v", err) + } + execCtx := &execFake{root: root, owner: "o", conv: "c", execOn: true} + deniedExec := guardedToolContext{ToolExecCtx: execCtx, check: func() error { return errToolActorDisabled }} + if _, err := execShell(deniedExec, json.RawMessage(`{"script":"echo x"}`)); !errors.Is(err, errToolActorDisabled) { + t.Fatalf("disabled shell write/start: %v", err) + } + if _, err := os.Stat(filepath.Join(root, "o", "c")); !os.IsNotExist(err) { + t.Fatalf("disabled shell created sandbox: %v", err) + } +} + +func TestInterpreterRechecksBeforeProcessStart(t *testing.T) { + root := t.TempDir() + mark := filepath.Join(t.TempDir(), "executed") + base := &execFake{root: root, owner: "o", conv: "c", execOn: true} + checks := 0 + rc := guardedToolContext{ToolExecCtx: base, check: func() error { + checks++ + if checks >= 4 { + return errToolActorDisabled + } + return nil + }} + _, err := execShell(rc, json.RawMessage(`{"script":"touch `+mark+`"}`)) + if !errors.Is(err, errToolActorDisabled) || checks < 4 { + t.Fatalf("process start recheck: %v, checks=%d", err, checks) + } + if _, err := os.Stat(mark); !os.IsNotExist(err) { + t.Fatalf("script ran after actor disable: %v", err) + } +} + +type guardedCodeContext struct{ guardedToolContext } + +func (guardedCodeContext) ExecuteTool(core.ToolCallRequest) core.ToolCallResult { + return core.ToolCallResult{Status: "ok"} +} + +func TestCodeOrchestrationChecksBeforeStarting(t *testing.T) { + root := t.TempDir() + base := &execFake{root: root, owner: "o", conv: "c", settings: map[string]bool{"runtime.codeOrchestration": true}} + rc := guardedCodeContext{guardedToolContext{ToolExecCtx: base, check: func() error { return errToolActorDisabled }}} + if _, err := execCodeScript(rc, json.RawMessage(`{"script":"print('hi')"}`)); !errors.Is(err, errToolActorDisabled) { + t.Fatalf("disabled code orchestration: %v", err) + } +} + +func TestApplyUpperCheckedStopsBeforeWorkspaceWrite(t *testing.T) { + dir := t.TempDir() + upper := t.TempDir() + write(t, filepath.Join(upper, "new.txt"), "new") + if _, err := applyUpperChecked(context.Background(), func() error { return errToolActorDisabled }, upper, dir, map[string]string{}); !errors.Is(err, errToolActorDisabled) { + t.Fatalf("disabled workspace apply: %v", err) + } + if _, err := os.Stat(filepath.Join(dir, "new.txt")); !os.IsNotExist(err) { + t.Fatalf("disabled workspace apply wrote file: %v", err) + } +} diff --git a/internal/tools/fs.go b/internal/tools/fs.go index 913c93a..b44d139 100644 --- a/internal/tools/fs.go +++ b/internal/tools/fs.go @@ -100,6 +100,9 @@ func execFSRead(rc ToolExecCtx, args json.RawMessage) (string, error) { return "", err } if stamps, ok := rc.(fileStamps); ok { + if err := checkToolExternalEffect(rc); err != nil { + return "", err + } _ = stamps.NoteFileRead(abs) } return string(b), nil @@ -135,6 +138,9 @@ func execFSWrite(rc ToolExecCtx, args json.RawMessage) (string, error) { if err != nil { return "", err } + if err := checkToolExternalEffect(rc); err != nil { + return "", err + } if err := os.MkdirAll(filepath.Dir(abs), 0o755); err != nil { return "", err } @@ -152,16 +158,25 @@ func execFSWrite(rc ToolExecCtx, args json.RawMessage) (string, error) { } } before, existed := fsBefore(abs) + if err := checkToolExternalEffect(rc); err != nil { + return "", err + } if err := os.WriteFile(abs, []byte(a.Content), 0o644); err != nil { return "", err } if cp, ok := rc.(interface { NoteFileCheckpoint(rel string, before []byte, existed bool, after []byte) error }); ok { + if err := checkToolExternalEffect(rc); err != nil { + return "", err + } _ = cp.NoteFileCheckpoint(a.Path, before, existed, []byte(a.Content)) } // 不变式:工作区文件 = 工件面板可见(内容同步入对象库,工件自包含可下载)。 base := filepath.Base(abs) + if err := checkToolExternalEffect(rc); err != nil { + return "", err + } art, err := rc.WriteArtifact(base, "file", mimeOfExt(base), []byte(a.Content)) if err != nil { return "", fmt.Errorf("fs_write_file: written %s but artifact registration failed: %w", abs, err) diff --git a/internal/tools/http.go b/internal/tools/http.go index dce8bc9..0483cd1 100644 --- a/internal/tools/http.go +++ b/internal/tools/http.go @@ -31,7 +31,8 @@ func HTTPExecutor(spec core.HTTPToolSpec, settingsLookup func(key string) string if spec.BodyTemplate != "" && (method == http.MethodPost || method == http.MethodPut || method == http.MethodPatch) { body = bytes.NewReader([]byte(expandTemplate(spec.BodyTemplate, params))) } - req, err := http.NewRequest(method, urlStr, body) + ctx := execParentCtx(rc) + req, err := http.NewRequestWithContext(ctx, method, urlStr, body) if err != nil { return "", err } @@ -56,6 +57,9 @@ func HTTPExecutor(spec core.HTTPToolSpec, settingsLookup func(key string) string timeout = 15 * time.Second } client := &http.Client{Timeout: timeout} + if err := checkToolExternalEffect(rc); err != nil { + return "", err + } resp, err := client.Do(req) if err != nil { return "", fmt.Errorf("http tool: %w", err) diff --git a/internal/tools/registry.go b/internal/tools/registry.go index c5900b2..18ec0a4 100644 --- a/internal/tools/registry.go +++ b/internal/tools/registry.go @@ -20,6 +20,8 @@ type Executor func(rc ToolExecCtx, args json.RawMessage) (string, error) // PeerConversations/PeerConversationMessages 的门禁(项目开关 crossSession.read、 // 出站 allowCrossRead、入站 crossPrivate、租户/项目归属)在实现方 runmgr 内执行, // fail-closed;本包只做参数校验与渲染(提案 2026-09-30-cross-session-reading)。 +// 运行时还可实现 Context() 与 CheckExternalEffect():外部请求/进程/文件写入 +// 使用前者取消在途操作,并在每个副作用紧前调用后者复核有效账号与资源权限。 type ToolExecCtx interface { ProjectID() string ConversationID() string diff --git a/internal/tools/search.go b/internal/tools/search.go index 1315acc..9ed030c 100644 --- a/internal/tools/search.go +++ b/internal/tools/search.go @@ -4,7 +4,6 @@ package tools import ( - "context" "encoding/json" "fmt" "strings" @@ -39,7 +38,7 @@ func registerSearch(r *Registry, providerFor func(ToolExecCtx) agenttools.Search if provider == nil { return "", fmt.Errorf("搜索未配置:settings.tools.tavily.apiKeys(或对应 secret)为空;配置后立即生效,无需重启") } - return execSearch(provider, args) + return execSearch(rc, provider, args) } if err := r.Register(core.ToolDesc{ Name: "tavily_search", @@ -69,7 +68,7 @@ func registerSearch(r *Registry, providerFor func(ToolExecCtx) agenttools.Search }, tavily) } -func execSearch(provider agenttools.SearchProvider, args json.RawMessage) (string, error) { +func execSearch(rc ToolExecCtx, provider agenttools.SearchProvider, args json.RawMessage) (string, error) { var params struct { Query string `json:"query"` SearchDepth string `json:"search_depth"` @@ -82,7 +81,10 @@ func execSearch(provider agenttools.SearchProvider, args json.RawMessage) (strin if query == "" { return "", fmt.Errorf("Error: query cannot be empty.") } - resp, err := provider.Search(context.Background(), agenttools.SearchRequest{ + if err := checkToolExternalEffect(rc); err != nil { + return "", err + } + resp, err := provider.Search(execParentCtx(rc), agenttools.SearchRequest{ Query: query, Depth: params.SearchDepth, MaxResults: int(params.MaxResults), From 7492feab42132fd2e7ed2a0151eeb2bc203f7202 Mon Sep 17 00:00:00 2001 From: weichen Date: Wed, 14 Oct 2026 08:17:31 +0000 Subject: [PATCH 10/17] Fence delegated project projection by its real operator 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. --- docs/iam-u3-account-write-fence-inventory.md | 6 +- internal/app/compiled.go | 2 +- internal/app/delegated_project_projection.go | 122 ++++++++++++ ...delegated_project_projection_entry_test.go | 108 +++++++++++ .../delegated_project_projection_pg_test.go | 135 +++++++++++++ internal/app/project_lifecycle.go | 2 +- internal/app/project_lifecycle_test.go | 182 ++++++++++++++++++ internal/app/scoped_host_test.go | 3 + internal/store/account_write_guard.go | 48 +++++ internal/store/project_projection.go | 4 +- .../store/project_projection_fence_test.go | 175 +++++++++++++++++ pkg/extension/projects.go | 12 +- pkg/extension/surface.txt | 2 + 13 files changed, 793 insertions(+), 8 deletions(-) create mode 100644 internal/app/delegated_project_projection.go create mode 100644 internal/app/delegated_project_projection_entry_test.go create mode 100644 internal/app/delegated_project_projection_pg_test.go diff --git a/docs/iam-u3-account-write-fence-inventory.md b/docs/iam-u3-account-write-fence-inventory.md index 48edd6e..0f4b0b2 100644 --- a/docs/iam-u3-account-write-fence-inventory.md +++ b/docs/iam-u3-account-write-fence-inventory.md @@ -1,6 +1,6 @@ # IAM U3 A1: account write fence inventory -Status: stage 4–5 implementation inventory with **one open delegated-projection blocker**; stage 6 is not ready. The route table records the **4053c4d baseline finding** and the intended final sink; its `Now` column is historical, not a claim about the amended candidate. Scope follows the approved Documents amendment `docs/proposals/2026-10-14-iam-u3-stage6-review-amendment.md` at `fb3a8214df963697ebd745ad11eb852ed9444fe3`. The pending additional proposal is `docs/proposals/2026-10-14-iam-u3-delegated-projection-actor-amendment.md` in the Documents task branch. +Status: stage 4–5 implementation inventory; the fixed candidate is the commit containing this file, and stage 6 human review remains pending. The route table records the **4053c4d baseline finding** and the intended final sink; its `Now` column is historical, not a claim about the amended candidate. Scope follows the approved Documents A1–A4 amendment `fb3a8214df963697ebd745ad11eb852ed9444fe3` and approved delegated-actor amendment `5783940e4c434102cbca8c22bd282d87b28fa840`. The 66 non-GET `/api` registrations were enumerated from `internal/server/server.go`. Paths below omit `/api`. `A` means `s.owner(r)` (auth-off: implicit `store.OwnerDefault`); `B` means body owner, `K` redeemed-key owner, `S` current password-session owner. Target resource owner never substitutes for actor. In the historical `Now` column, `T` means the baseline already had a final Store check; `M` means a Backend DB write lacked one; `E` means an external or process-local effect; `—` means no business mutation or permitted credential cleanup. `D` means a short Store transaction with actor account lock and enabled read **before** resource/scope/conversation locks; `F` means stage immutable bytes, then `D` for the visible DB reference; `X` means last effective-state check before external action plus cancellation; `C` means `D` at each command handler's actual sink; `P` means `D` for Backend projection and `X` at the separate RDM callback. `Vnn` identifies the route for traceability; verification uses representative concurrency cases and complete source-to-sink review, rather than one duplicate test per route. @@ -103,9 +103,9 @@ At the 4053c4d baseline, Host/runmgr effective-account queries were early checks This section supersedes the historical `Now` column when checking the amended candidate. V01–V15 retain their existing account/credential transaction or response-only behavior; V16 uses actor-checked login-key revocation. V17–V23 route project, conversation, message, run creation and link writes through account-checked Store entries. V24–V35 use guarded file/message and borrow/secret/context/command entries; V25 prepares the message exclusion and regeneration marker in one checked transaction, then consumes the marker only with successful run settlement. V36–V44 guard command queue, HITL, card, surface/event, artifact metadata and index writes. Artifact content updates switch immutable blob references in the checked transaction. V45–V51 guard profile, prompt, settings and snapshot writes, using the **request actor** for system settings. V52–V59 combine last checks for process/HTTP/tool effects with guarded plugin, surface, card, command pending and audit DB writes. V60–V66 use guarded knowledge, task, memory, audit and hold updates. V65 refuses whole-database backup under authenticated mode because administrator status does not grant access to other owners' business content; auth-off backup has a last check. V67 is read-only at zero tokens. -VH01–VH04/VH06/VH08 use the Host owner and final Store account checks for Backend-owned rows; H05's RDM callback has a separate last actor check but the delegated rename gap below remains. VH07 checks immediately before its external model call and passes the caller context. VG01–VG09 use checked run, admission/lease, event, HITL, card, artifact, index, notice, command and settle sinks. File staging, transcript, tool HTTP/search/code execution and overlay writeback check just before effects and propagate cancellation. Failed/cancelled/interrupted run sealing, lease release, notice deletion and failed-start marker deletion are narrowly scoped safety cleanup; positive token usage is accounting for model work already incurred. +VH01–VH04/VH06/VH08 use the Host owner and final Store account checks for Backend-owned rows. H05 delegated rename now derives a **single-project** projection from the current authenticated lifecycle or command Host; its final Store transaction locks the real operator and creator in stable order, checks both effective states and verifies the persisted project owner. The global `NewScopedHost` cannot derive this delegated capability; its original creator-owned methods remain. VH07 checks immediately before its external model call and passes the caller context. VG01–VG09 use checked run, admission/lease, event, HITL, card, artifact, index, notice, command and settle sinks. File staging, transcript, tool HTTP/search/code execution and overlay writeback check just before effects and propagate cancellation. Failed/cancelled/interrupted run sealing, lease release, notice deletion and failed-start marker deletion are narrowly scoped safety cleanup; positive token usage is accounting for model work already incurred. -**Open H05/V17–V19 blocker:** delegated RDM rename reconstructs a creator-scoped Host after authorizing the real operator. The current Backend `RenameOwnedProject` final transaction checks the creator as actor and can therefore commit after the delegated operator is disabled. This is a Backend same-database late write, outside the accepted cross-database limitation. The additional Documents proposal specifies a request-bound operator projection capability and a two-account final fence. Do not mark A1 or stage 6 complete until that change is approved, implemented and verified. +The H05/V17–V19 late-write blocker was reproduced in the earlier candidate and repaired under the separately approved Documents `5783940` plan. Tests cover creator/operator effective state, a disable between early check and final write on SQLite/PG, a two-process PG disable, opposite lock order progress, and both HTTP PATCH and no-conversation command Host sources. The RDM database authorization/intent commit is still separate from Backend's account/project transaction; a permission withdrawal or cross-database failure is handled by the existing I1 failure intent and recovery path rather than a zero-window assertion. Completion and stage 6 claims require exact-HEAD receipts and human review. The account lock closes the **account-enabled versus Backend-owned business write** race on the same database. It does not prove that a session or API key revoked after request authentication cannot complete a write while that account remains enabled; that is a distinct credential-version fence and outside this amendment's promise. RDM commits in its own database and external requests, processes and files cannot share the Backend transaction. The last check can race with those effects, and a multi-file operation can leave earlier files committed when a later check fails. No cross-database or external zero-window claim is made. diff --git a/internal/app/compiled.go b/internal/app/compiled.go index 1e3c90a..644978a 100644 --- a/internal/app/compiled.go +++ b/internal/app/compiled.go @@ -64,7 +64,7 @@ func (r pluginRegistry) AddCommandHandler(spec json.RawMessage, h extension.Hand return fmt.Errorf("extension: command spec: %w", err) } return r.bus.RegisterPluginCommand(s, func(ctx context.Context, req cmdbus.Request) (any, error) { - host := inprocHost{caps: r.caps, pluginID: r.pluginID, owner: req.Owner, actor: req.Actor} + host := requestBoundHost{inprocHost{caps: r.caps, pluginID: r.pluginID, owner: req.Owner, actor: req.Actor}} return h(ctx, host, extension.Request{ ID: req.ID, Params: req.Params, Entrance: req.Entrance, Actor: req.Actor, Owner: req.Owner, ConversationID: req.ConversationID, diff --git a/internal/app/delegated_project_projection.go b/internal/app/delegated_project_projection.go new file mode 100644 index 0000000..eecb699 --- /dev/null +++ b/internal/app/delegated_project_projection.go @@ -0,0 +1,122 @@ +package app + +import ( + "agent-system-backend/internal/store" + "agent-system-backend/pkg/extension" + core "agent-system-core" +) + +// requestBoundHost is created only for a current lifecycle or command request. +// A reconstructed global ScopedHost is a plain inprocHost and cannot expose +// the delegated projection method by type assertion. +type requestBoundHost struct{ inprocHost } + +// ProjectProjectionForCreator only derives from a live request Host of the +// registered RDM provider. The operator is inherited from that Host, never +// accepted as a caller-supplied field. RDM's project.settings decision remains +// its own domain authority; this capability fences the Backend projection. +func (bound requestBoundHost) ProjectProjectionForCreator(creator, projectID string) (extension.ProjectHost, error) { + h := bound.inprocHost + if h.actor == "" || h.actor != h.owner || h.pluginID != "rdm" || + h.caps.projects == nil || h.caps.st == nil || creator == "" || projectID == "" { + return nil, store.ErrAccountForbidden + } + slot := h.caps.projects + slot.mu.Lock() + registered := slot.provider != nil && slot.holder == h.pluginID + slot.mu.Unlock() + if !registered { + return nil, store.ErrAccountForbidden + } + if err := h.requireEffective(); err != nil { + return nil, err + } + if h.caps.accountGuard != nil { + if err := h.caps.accountGuard(creator); err != nil { + return nil, err + } + } + project, err := h.caps.st.ProjectMetadata(projectID) + if err != nil { + return nil, err + } + if string(project.Owner) != creator || string(project.ID) != projectID { + return nil, store.ErrNotFound + } + return delegatedProjectProjection{caps: h.caps, pluginID: h.pluginID, + actor: h.actor, creator: creator, projectID: projectID}, nil +} + +// delegatedProjectProjection exposes only ProjectHost, and every method is +// confined to the bound project. It cannot be cast to the broader Host. +type delegatedProjectProjection struct { + caps hostcaps + pluginID string + actor string + creator string + projectID string +} + +var _ extension.ProjectHost = delegatedProjectProjection{} + +func (p delegatedProjectProjection) Owner() string { return p.creator } + +func (p delegatedProjectProjection) check() error { + if p.caps.accountGuard == nil { + return nil + } + if err := p.caps.accountGuard(p.actor); err != nil { + return err + } + return p.caps.accountGuard(p.creator) +} + +func (p delegatedProjectProjection) target(id string) error { + if id == "" || id != p.projectID { + return store.ErrNotFound + } + return nil +} + +func (p delegatedProjectProjection) OwnedProjects() ([]core.Project, error) { + return nil, store.ErrAccountForbidden +} + +func (p delegatedProjectProjection) OwnedProject(string) (core.Project, error) { + return core.Project{}, store.ErrAccountForbidden +} + +func (p delegatedProjectProjection) EnsureProject(string, string) (core.Project, error) { + return core.Project{}, store.ErrAccountForbidden +} + +func (p delegatedProjectProjection) ProjectMetadata(id string) (core.Project, error) { + if err := p.target(id); err != nil { + return core.Project{}, err + } + if err := p.check(); err != nil { + return core.Project{}, err + } + view := inprocHost{caps: p.caps, pluginID: p.pluginID, owner: p.creator, actor: p.actor} + return view.ProjectMetadata(id) +} + +func (p delegatedProjectProjection) ProjectNameAvailable(name, exceptID string) (bool, error) { + if err := p.target(exceptID); err != nil { + return false, err + } + if err := p.check(); err != nil { + return false, err + } + return p.caps.st.ProjectNameAvailable(name, exceptID) +} + +func (p delegatedProjectProjection) RenameOwnedProject(id, name string) (core.Project, error) { + if err := p.target(id); err != nil { + return core.Project{}, err + } + if err := p.check(); err != nil { + return core.Project{}, err + } + return p.caps.st.RenameOwnedProjectAccountChecked(p.actor, p.creator, p.projectID, name, p.caps.allowImplicitLocal) +} diff --git a/internal/app/delegated_project_projection_entry_test.go b/internal/app/delegated_project_projection_entry_test.go new file mode 100644 index 0000000..ddd9320 --- /dev/null +++ b/internal/app/delegated_project_projection_entry_test.go @@ -0,0 +1,108 @@ +package app + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" + + agentcommand "agent-command" + "agent-system-backend/internal/cmdbus" + "agent-system-backend/internal/server" + "agent-system-backend/internal/settings" + "agent-system-backend/internal/store" + "agent-system-backend/pkg/extension" + core "agent-system-core" +) + +func TestDelegatedProjectionIsBoundAtHTTPAndCommandEntries(t *testing.T) { + st, err := store.OpenSQLite(t.TempDir() + "/entries.db") + if err != nil { + t.Fatal(err) + } + defer st.Close() + for _, owner := range []string{"alice", "bob"} { + if err := st.EnsureUser(owner, ""); err != nil { + t.Fatal(err) + } + } + httpProject, err := st.CreateProject("alice", "HTTP original") + if err != nil { + t.Fatal(err) + } + commandProject, err := st.CreateProject("alice", "Command original") + if err != nil { + t.Fatal(err) + } + check := func(owner string) error { + state, err := st.GetUserAuthState(owner) + if err != nil { + return err + } + if !state.Enabled { + return store.ErrAccountForbidden + } + return nil + } + projectFromRequest := func(h extension.Host, id, name string) (core.Project, error) { + bound, ok := h.(extension.DelegatedProjectProjectionHost) + if !ok { + return core.Project{}, errors.New("request Host lost operator-bound projection") + } + projection, err := bound.ProjectProjectionForCreator("alice", id) + if err != nil { + return core.Project{}, err + } + return projection.RenameOwnedProject(id, name) + } + slot := &projectLifecycleSlot{} + caps := hostcaps{st: st, set: settings.NewService(st), projects: slot, accountGuard: check} + provider := completeLifecycle() + provider.Rename = func(_ context.Context, h extension.Host, id, name string) (core.Project, error) { + return projectFromRequest(h, id, name) + } + if err := (pluginRegistry{pluginID: "rdm", caps: caps}).RegisterProjectLifecycle(provider); err != nil { + t.Fatal(err) + } + srv := server.New(st, nil, nil, nil, caps.set, nil) + srv.Auth = server.NewAuthenticator(st, true) + slot.wire(srv, caps) + key, err := st.CreateAPIKey("bob", "delegate-entry") + if err != nil { + t.Fatal(err) + } + req := httptest.NewRequest(http.MethodPatch, "/api/projects/"+string(httpProject.ID), strings.NewReader(`{"name":"via HTTP"}`)) + req.Header.Set("Authorization", "Bearer "+key) + rec := httptest.NewRecorder() + srv.Handler().ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("HTTP lifecycle rename: status=%d body=%s", rec.Code, rec.Body.String()) + } + bus := cmdbus.New(st, nil, nil) + bus.SetEffectiveAccountGuard(func(_ context.Context, owner string) error { return check(owner) }) + bus.RequireAccountCheckedWrites(false) + reg := pluginRegistry{pluginID: "rdm", caps: caps, compiledRegistry: compiledRegistry{bus: bus}} + err = reg.AddCommandHandler(json.RawMessage(`{"id":"rdm.fixture.rename","title":"Rename"}`), + func(_ context.Context, h extension.Host, req extension.Request) (any, error) { + id, _ := req.Params["id"].(string) + name, _ := req.Params["name"].(string) + return projectFromRequest(h, id, name) + }) + if err != nil { + t.Fatal(err) + } + result := bus.Dispatch(context.Background(), cmdbus.Request{ID: "rdm.fixture.rename", Owner: "bob", Actor: "bob", + Params: map[string]any{"id": string(commandProject.ID), "name": "via command"}}) + if result.Receipt.State != agentcommand.StateCommitted { + t.Fatalf("command rename: %+v", result.Receipt) + } + for _, tc := range []struct{ id, want string }{{string(httpProject.ID), "via HTTP"}, {string(commandProject.ID), "via command"}} { + project, err := st.ProjectMetadata(tc.id) + if err != nil || project.Name != tc.want { + t.Fatalf("entry %s did not reach bound projection: %+v %v", tc.id, project, err) + } + } +} diff --git a/internal/app/delegated_project_projection_pg_test.go b/internal/app/delegated_project_projection_pg_test.go new file mode 100644 index 0000000..f1259c5 --- /dev/null +++ b/internal/app/delegated_project_projection_pg_test.go @@ -0,0 +1,135 @@ +package app + +import ( + "bytes" + "errors" + "os" + "os/exec" + "path/filepath" + "strings" + "sync/atomic" + "testing" + "time" + + "agent-system-backend/internal/store" +) + +// Two processes share one isolated PostgreSQL schema. The child has already +// accepted a stale early account read when the parent commits disable; only +// the final Backend transaction can stop the physical rename. +func TestPGDelegatedProjectionDisableAcrossProcesses(t *testing.T) { + baseDSN := os.Getenv("AGENT_PG_TEST_DSN") + if baseDSN == "" { + t.Skip("isolated PostgreSQL DSN required") + } + separator := "?" + if strings.Contains(baseDSN, "?") { + separator = "&" + } + if os.Getenv("IAM_U3_DELEGATE_CHILD") == "1" { + dsn := baseDSN + separator + "search_path=" + os.Getenv("IAM_U3_DELEGATE_SCHEMA") + ",public" + st, err := store.OpenExistingPostgres(dsn, filepath.Join(t.TempDir(), "artifacts")) + if err != nil { + t.Fatal(err) + } + defer st.Close() + var bobChecks atomic.Int32 + check := func(owner string) error { + state, err := st.GetUserAuthState(owner) + if err != nil { + return err + } + if !state.Enabled { + return store.ErrAccountForbidden + } + if owner == "bob" && bobChecks.Add(1) == 2 { + if err := os.WriteFile(os.Getenv("IAM_U3_DELEGATE_READY"), []byte("ready"), 0o600); err != nil { + return err + } + deadline := time.Now().Add(5 * time.Second) + for { + if _, err := os.Stat(os.Getenv("IAM_U3_DELEGATE_RELEASE")); err == nil { + break + } + if time.Now().After(deadline) { + return errors.New("parent did not release paused child") + } + time.Sleep(10 * time.Millisecond) + } + } + return nil + } + slot := &projectLifecycleSlot{} + caps := hostcaps{st: st, projects: slot, accountGuard: check} + if err := (pluginRegistry{pluginID: "rdm", caps: caps}).RegisterProjectLifecycle(completeLifecycle()); err != nil { + t.Fatal(err) + } + host := requestBoundHost{inprocHost{caps: caps, pluginID: "rdm", owner: "bob", actor: "bob"}} + projection, err := host.ProjectProjectionForCreator("alice", os.Getenv("IAM_U3_DELEGATE_PROJECT")) + if err != nil { + t.Fatal(err) + } + if _, err := projection.RenameOwnedProject(os.Getenv("IAM_U3_DELEGATE_PROJECT"), "late"); !errors.Is(err, store.ErrAccountForbidden) { + t.Fatalf("late physical rename was not fenced: %v", err) + } + return + } + st := openDirectoryTestStore(t) + if err := st.EnsureSystemIdentity("u3-delegate-two-process", "pg"); err != nil { + t.Fatal(err) + } + for _, account := range []struct { + owner string + admin bool + }{{"admin", true}, {"alice", false}, {"bob", false}} { + if err := st.CreateAccount(account.owner, "", "", account.admin); err != nil { + t.Fatal(err) + } + } + project, err := st.CreateProject("alice", "before") + if err != nil { + t.Fatal(err) + } + var schema string + if err := st.DB().QueryRow(`SELECT current_schema()`).Scan(&schema); err != nil { + t.Fatal(err) + } + barrier := t.TempDir() + ready, release := filepath.Join(barrier, "ready"), filepath.Join(barrier, "release") + cmd := exec.Command(os.Args[0], "-test.run=^TestPGDelegatedProjectionDisableAcrossProcesses$") + cmd.Env = append(os.Environ(), "IAM_U3_DELEGATE_CHILD=1", "IAM_U3_DELEGATE_SCHEMA="+schema, + "IAM_U3_DELEGATE_PROJECT="+string(project.ID), "IAM_U3_DELEGATE_READY="+ready, "IAM_U3_DELEGATE_RELEASE="+release) + var output bytes.Buffer + cmd.Stdout, cmd.Stderr = &output, &output + if err := cmd.Start(); err != nil { + t.Fatal(err) + } + deadline := time.Now().Add(5 * time.Second) + for { + if _, err := os.Stat(ready); err == nil { + break + } + if time.Now().After(deadline) { + _ = cmd.Process.Kill() + t.Fatalf("child did not reach stale early check: %s", strings.ReplaceAll(output.String(), baseDSN, "[REDACTED]")) + } + time.Sleep(10 * time.Millisecond) + } + bob, err := st.GetAccountByOwner("bob") + if err != nil { + t.Fatal(err) + } + if _, err := st.SetAccountEnabled("admin", bob.ID, bob.StateVersion, false); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(release, []byte("release"), 0o600); err != nil { + t.Fatal(err) + } + if err := cmd.Wait(); err != nil { + t.Fatalf("child failed: %v: %s", err, strings.ReplaceAll(output.String(), baseDSN, "[REDACTED]")) + } + got, err := st.ProjectMetadata(string(project.ID)) + if err != nil || got.Name != "before" { + t.Fatalf("disabled operator's project rename persisted: %+v %v", got, err) + } +} diff --git a/internal/app/project_lifecycle.go b/internal/app/project_lifecycle.go index c35f5c3..cc79eeb 100644 --- a/internal/app/project_lifecycle.go +++ b/internal/app/project_lifecycle.go @@ -51,7 +51,7 @@ func (slot *projectLifecycleSlot) wire(srv *server.Server, caps hostcaps) { } holder := slot.holder srv.SetProjectLifecycle(*slot.provider, func(owner string) extension.Host { - return inprocHost{caps: caps, owner: owner, actor: owner, pluginID: holder} + return requestBoundHost{inprocHost{caps: caps, owner: owner, actor: owner, pluginID: holder}} }) } diff --git a/internal/app/project_lifecycle_test.go b/internal/app/project_lifecycle_test.go index 6422f4e..f23d3ee 100644 --- a/internal/app/project_lifecycle_test.go +++ b/internal/app/project_lifecycle_test.go @@ -11,7 +11,12 @@ import ( "encoding/json" "errors" "net/http/httptest" + "os" + "path/filepath" + "strings" + "sync/atomic" "testing" + "time" ) func completeLifecycle() extension.ProjectLifecycle { @@ -169,3 +174,180 @@ func TestProjectHostMetadataMasksSharedSecretsWithoutMutation(t *testing.T) { t.Fatal("metadata read mutated persisted owner settings") } } + +func TestRequestBoundDelegatedProjectionFencesRealOperator(t *testing.T) { + st, err := store.OpenSQLite(t.TempDir() + "/delegated-project.db") + if err != nil { + t.Fatal(err) + } + defer st.Close() + for _, account := range []struct { + owner string + admin bool + }{{"admin", true}, {"alice", false}, {"bob", false}} { + if err := st.CreateAccount(account.owner, "", "", account.admin); err != nil { + t.Fatal(err) + } + } + project, err := st.CreateProject("alice", "original") + if err != nil { + t.Fatal(err) + } + guard := accountEffectiveAuthorization(st, true) + check := func(owner string) error { + valid, err := guard(context.Background(), owner) + if err != nil { + return err + } + if !valid { + return store.ErrAccountForbidden + } + return nil + } + slot := &projectLifecycleSlot{} + caps := hostcaps{st: st, projects: slot, accountGuard: check} + if err := (pluginRegistry{pluginID: "rdm", caps: caps}).RegisterProjectLifecycle(completeLifecycle()); err != nil { + t.Fatal(err) + } + request := requestBoundHost{inprocHost{caps: caps, pluginID: "rdm", owner: "bob", actor: "bob"}} + delegator, ok := any(request).(interface { + ProjectProjectionForCreator(creator, projectID string) (extension.ProjectHost, error) + }) + if !ok { + t.Fatal("request-bound delegated projection capability missing") + } + projection, err := delegator.ProjectProjectionForCreator("alice", string(project.ID)) + if err != nil { + t.Fatal(err) + } + if projection.Owner() != "alice" { + t.Fatalf("trusted projection owner=%s", projection.Owner()) + } + if _, ok := projection.(extension.Host); ok { + t.Fatal("single-project projection exposed broad creator Host capabilities") + } + if _, err := projection.RenameOwnedProject(string(project.ID), "delegated"); err != nil { + t.Fatalf("active delegated rename: %v", err) + } + if _, err := projection.RenameOwnedProject("another-project", "hijack"); !errors.Is(err, store.ErrNotFound) { + t.Fatalf("projection escaped bound project: %v", err) + } + if _, err := projection.EnsureProject("another-project", "hijack"); !errors.Is(err, store.ErrAccountForbidden) { + t.Fatalf("projection created another project: %v", err) + } + bob, err := st.GetAccountByOwner("bob") + if err != nil { + t.Fatal(err) + } + if _, err := st.SetAccountEnabled("admin", bob.ID, bob.StateVersion, false); err != nil { + t.Fatal(err) + } + if _, err := projection.RenameOwnedProject(string(project.ID), "late"); !errors.Is(err, store.ErrAccountForbidden) { + t.Fatalf("disabled real operator renamed creator project: %v", err) + } + got, err := st.ProjectMetadata(string(project.ID)) + if err != nil || got.Name != "delegated" { + t.Fatalf("late rename changed project: %+v %v", got, err) + } +} + +func TestDelegatedProjectionRejectsDisableBetweenEarlyCheckAndWrite(t *testing.T) { + var st, other *store.Store + var err error + if baseDSN := os.Getenv("AGENT_PG_TEST_DSN"); baseDSN != "" { + st = openDirectoryTestStore(t) + if err := st.EnsureSystemIdentity("u3-delegated-projection", "pg"); err != nil { + t.Fatal(err) + } + var schema string + if err := st.DB().QueryRow(`SELECT current_schema()`).Scan(&schema); err != nil { + t.Fatal(err) + } + separator := "?" + if strings.Contains(baseDSN, "?") { + separator = "&" + } + other, err = store.OpenExistingPostgres(baseDSN+separator+"search_path="+schema+",public", filepath.Join(t.TempDir(), "other-artifacts")) + } else { + db := filepath.Join(t.TempDir(), "shared.db") + st, err = store.OpenSQLite(db) + if err == nil { + other, err = store.OpenExistingSQLite(db) + } + if st != nil { + defer st.Close() + } + } + if err != nil { + t.Fatal(err) + } + defer other.Close() + for _, account := range []struct { + owner string + admin bool + }{{"admin", true}, {"alice", false}, {"bob", false}} { + if err := st.CreateAccount(account.owner, "", "", account.admin); err != nil { + t.Fatal(err) + } + } + project, err := st.CreateProject("alice", "before") + if err != nil { + t.Fatal(err) + } + entered, release := make(chan struct{}), make(chan struct{}) + var bobChecks atomic.Int32 + check := func(owner string) error { + state, err := st.GetUserAuthState(owner) + if err != nil { + return err + } + if !state.Enabled { + return store.ErrAccountForbidden + } + if owner == "bob" && bobChecks.Add(1) == 2 { + close(entered) + <-release + } + return nil + } + slot := &projectLifecycleSlot{} + caps := hostcaps{st: st, projects: slot, accountGuard: check} + if err := (pluginRegistry{pluginID: "rdm", caps: caps}).RegisterProjectLifecycle(completeLifecycle()); err != nil { + t.Fatal(err) + } + host := requestBoundHost{inprocHost{caps: caps, pluginID: "rdm", owner: "bob", actor: "bob"}} + projection, err := host.ProjectProjectionForCreator("alice", string(project.ID)) + if err != nil { + t.Fatal(err) + } + written := make(chan error, 1) + go func() { + _, err := projection.RenameOwnedProject(string(project.ID), "late") + written <- err + }() + select { + case <-entered: + case <-time.After(3 * time.Second): + t.Fatal("rename did not reach paused early account check") + } + bob, err := st.GetAccountByOwner("bob") + if err != nil { + t.Fatal(err) + } + if _, err := other.SetAccountEnabled("admin", bob.ID, bob.StateVersion, false); err != nil { + t.Fatal(err) + } + close(release) + select { + case err := <-written: + if !errors.Is(err, store.ErrAccountForbidden) { + t.Fatalf("late delegated rename committed: %v", err) + } + case <-time.After(3 * time.Second): + t.Fatal("late rename did not return") + } + got, err := st.ProjectMetadata(string(project.ID)) + if err != nil || got.Name != "before" { + t.Fatalf("project changed after disable: %+v %v", got, err) + } +} diff --git a/internal/app/scoped_host_test.go b/internal/app/scoped_host_test.go index 2a279e1..0791606 100644 --- a/internal/app/scoped_host_test.go +++ b/internal/app/scoped_host_test.go @@ -42,6 +42,9 @@ func TestScopedHostFactory(t *testing.T) { if h.Owner() != "alice" || h.PluginID() != "rdm" { t.Fatalf("Owner/PluginID = %s/%s, want alice/rdm", h.Owner(), h.PluginID()) } + if _, ok := h.(extension.DelegatedProjectProjectionHost); ok { + t.Fatal("globally reconstructed Host exposed request-bound projection authority") + } // KV 往返落 owner 作用域。 if err := h.KVPut("k1", "v1"); err != nil { t.Fatal(err) diff --git a/internal/store/account_write_guard.go b/internal/store/account_write_guard.go index a5acf66..02f3f75 100644 --- a/internal/store/account_write_guard.go +++ b/internal/store/account_write_guard.go @@ -3,6 +3,7 @@ package store import ( "database/sql" "errors" + "sort" ) // withEffectiveAccountWrite runs a Backend-owned business mutation after the @@ -38,3 +39,50 @@ func (s *Store) withEffectiveAccountWrite(actor string, allowImplicitDefault boo } return tx.Commit() } + +// withEffectiveProjectActorsWrite fences a delegated project projection against +// both the real operator and the persisted creator. Distinct accounts are +// acquired together in owner order before the global project-name lock. +func (s *Store) withEffectiveProjectActorsWrite(actor, creator string, allowImplicitDefault bool, fn func(*sql.Tx) error) error { + if actor == "" || creator == "" || fn == nil { + return ErrAccountForbidden + } + if actor == creator { + return s.withEffectiveAccountWrite(actor, allowImplicitDefault, func(tx *sql.Tx, _ Account) error { + return fn(tx) + }) + } + tx, err := s.db.Begin() + if err != nil { + return err + } + defer tx.Rollback() + ordered := []string{actor, creator} + sort.Strings(ordered) + query := s.q(`SELECT ` + accountColumns + ` FROM users WHERE owner=?`) + if s.Dialect() == "postgres" { + query += ` FOR UPDATE` + } + for _, owner := range ordered { + if err := s.dialect.lockAccount(tx, owner); err != nil { + return err + } + account, err := scanAccount(tx.QueryRow(query, owner)) + if errors.Is(err, ErrNotFound) && allowImplicitDefault && owner == OwnerDefault { + continue + } + if errors.Is(err, ErrNotFound) { + return ErrAccountForbidden + } + if err != nil { + return err + } + if !account.Enabled { + return ErrAccountForbidden + } + } + if err := fn(tx); err != nil { + return err + } + return tx.Commit() +} diff --git a/internal/store/project_projection.go b/internal/store/project_projection.go index 675d0f6..83fc9bd 100644 --- a/internal/store/project_projection.go +++ b/internal/store/project_projection.go @@ -125,7 +125,7 @@ func (s *Store) EnsureProjectAccountChecked(actor, owner, id, name string, allow return core.Project{}, errors.New("store: project owner, id and name required") } var project core.Project - err := s.withEffectiveAccountWrite(actor, allowImplicitDefault, func(tx *sql.Tx, _ Account) error { + err := s.withEffectiveProjectActorsWrite(actor, owner, allowImplicitDefault, func(tx *sql.Tx) error { if err := s.lockProjectNames(tx); err != nil { return err } @@ -173,7 +173,7 @@ func (s *Store) RenameOwnedProjectAccountChecked(actor, owner, id, name string, return core.Project{}, errors.New("store: project name required") } var project core.Project - err := s.withEffectiveAccountWrite(actor, allowImplicitDefault, func(tx *sql.Tx, _ Account) error { + err := s.withEffectiveProjectActorsWrite(actor, owner, allowImplicitDefault, func(tx *sql.Tx) error { if err := s.lockProjectNames(tx); err != nil { return err } diff --git a/internal/store/project_projection_fence_test.go b/internal/store/project_projection_fence_test.go index 57f4161..a7a6e4e 100644 --- a/internal/store/project_projection_fence_test.go +++ b/internal/store/project_projection_fence_test.go @@ -2,11 +2,110 @@ package store import ( "errors" + "fmt" "sync" "testing" "time" ) +func TestPGDelegatedProjectionOppositeActorOrderMakesProgress(t *testing.T) { + st := openTestStore(t) + if st.Dialect() != "postgres" { + t.Skip("isolated PostgreSQL required") + } + other := reopenAccountModelStore(t, st) + for _, owner := range []string{"alice", "bob"} { + if err := st.CreateAccount(owner, "", "", false); err != nil { + t.Fatal(err) + } + } + aliceProject, err := st.CreateProject("alice", "Alice original") + if err != nil { + t.Fatal(err) + } + bobProject, err := st.CreateProject("bob", "Bob original") + if err != nil { + t.Fatal(err) + } + for round := 0; round < 8; round++ { + start := make(chan struct{}) + results := make(chan error, 2) + go func(n int) { + <-start + _, err := st.RenameOwnedProjectAccountChecked("bob", "alice", string(aliceProject.ID), fmt.Sprintf("Alice %d", n), false) + results <- err + }(round) + go func(n int) { + <-start + _, err := other.RenameOwnedProjectAccountChecked("alice", "bob", string(bobProject.ID), fmt.Sprintf("Bob %d", n), false) + results <- err + }(round) + close(start) + for i := 0; i < 2; i++ { + select { + case err := <-results: + if err != nil { + t.Fatalf("opposite delegated account order, round %d: %v", round, err) + } + case <-time.After(5 * time.Second): + t.Fatalf("opposite delegated account order stalled at round %d", round) + } + } + } +} + +func TestPGDelegatedRenameCommitsBeforeCompetingDisable(t *testing.T) { + st := openTestStore(t) + if st.Dialect() != "postgres" { + t.Skip("isolated PostgreSQL required") + } + other := reopenAccountModelStore(t, st) + for _, account := range []struct { + owner string + admin bool + }{{"admin", true}, {"alice", false}, {"bob", false}} { + if err := st.CreateAccount(account.owner, "", "", account.admin); err != nil { + t.Fatal(err) + } + } + project, err := st.CreateProject("alice", "original") + if err != nil { + t.Fatal(err) + } + bob, err := st.GetAccountByOwner("bob") + if err != nil { + t.Fatal(err) + } + release := holdPGAccountWrite(t, st, "projects", "UPDATE", "delegate-rename") + writeDone := make(chan error, 1) + go func() { + _, err := st.RenameOwnedProjectAccountChecked("bob", "alice", string(project.ID), "renamed", false) + writeDone <- err + }() + waitForPGAccountWrite(t, st, "delegate-rename") + disableDone := make(chan error, 1) + go func() { + _, err := other.SetAccountEnabled("admin", bob.ID, bob.StateVersion, false) + disableDone <- err + }() + select { + case err := <-disableDone: + t.Fatalf("disable committed before paused rename: %v", err) + case <-time.After(100 * time.Millisecond): + } + release() + if err := awaitPGAccountResult(t, writeDone); err != nil { + t.Fatal(err) + } + if err := awaitPGAccountResult(t, disableDone); err != nil { + t.Fatal(err) + } + got, err := st.ProjectMetadata(string(project.ID)) + if err != nil || got.Name != "renamed" { + t.Fatalf("completed rename was lost: %+v %v", got, err) + } +} + func TestProjectProjectionAccountCheckedStateOwnershipAndIdempotency(t *testing.T) { st := openTestStore(t) t.Logf("project projection fence dialect=%s", st.Dialect()) @@ -99,6 +198,82 @@ func TestProjectProjectionAccountCheckedStateOwnershipAndIdempotency(t *testing. } } +func TestDelegatedProjectionRejectsDisabledCreatorAtFinalWrite(t *testing.T) { + st := openTestStore(t) + for _, account := range []struct { + owner string + admin bool + }{{"admin", true}, {"alice", false}, {"bob", false}} { + if err := st.CreateAccount(account.owner, "", "", account.admin); err != nil { + t.Fatal(err) + } + } + project, err := st.CreateProject("alice", "original") + if err != nil { + t.Fatal(err) + } + alice, err := st.GetAccountByOwner("alice") + if err != nil { + t.Fatal(err) + } + if _, err := st.SetAccountEnabled("admin", alice.ID, alice.StateVersion, false); err != nil { + t.Fatal(err) + } + if _, err := st.RenameOwnedProjectAccountChecked("bob", "alice", string(project.ID), "late", false); !errors.Is(err, ErrAccountForbidden) { + t.Fatalf("delegated write ignored disabled creator: %v", err) + } + got, err := st.ProjectMetadata(string(project.ID)) + if err != nil || got.Name != "original" { + t.Fatalf("disabled creator's project changed: %+v %v", got, err) + } +} + +func TestDelegatedProjectionImplicitLocalAppliesOnlyToMissingLocal(t *testing.T) { + st := openTestStore(t) + if err := st.CreateAccount("admin", "", "", true); err != nil { + t.Fatal(err) + } + if err := st.CreateAccount("alice", "", "", false); err != nil { + t.Fatal(err) + } + aliceProject, err := st.CreateProject("alice", "Alice original") + if err != nil { + t.Fatal(err) + } + localProject, err := st.CreateProject(OwnerDefault, "Local original") + if err != nil { + t.Fatal(err) + } + if _, err := st.RenameOwnedProjectAccountChecked(OwnerDefault, "alice", string(aliceProject.ID), "Auth-on rejection", false); !errors.Is(err, ErrAccountForbidden) { + t.Fatalf("missing local operator passed auth-on fence: %v", err) + } + if _, err := st.RenameOwnedProjectAccountChecked("alice", OwnerDefault, string(localProject.ID), "Auth-on rejection", false); !errors.Is(err, ErrAccountForbidden) { + t.Fatalf("missing local creator passed auth-on fence: %v", err) + } + if _, err := st.RenameOwnedProjectAccountChecked(OwnerDefault, "alice", string(aliceProject.ID), "Local delegated", true); err != nil { + t.Fatalf("missing auth-off local operator rejected: %v", err) + } + if _, err := st.RenameOwnedProjectAccountChecked("alice", OwnerDefault, string(localProject.ID), "Alice delegated", true); err != nil { + t.Fatalf("missing auth-off local creator rejected: %v", err) + } + if err := st.EnsureUser(OwnerDefault, ""); err != nil { + t.Fatal(err) + } + local, err := st.GetAccountByOwner(OwnerDefault) + if err != nil { + t.Fatal(err) + } + if _, err := st.SetAccountEnabled("admin", local.ID, local.StateVersion, false); err != nil { + t.Fatal(err) + } + if _, err := st.RenameOwnedProjectAccountChecked(OwnerDefault, "alice", string(aliceProject.ID), "Late operator", true); !errors.Is(err, ErrAccountForbidden) { + t.Fatalf("disabled local operator renamed: %v", err) + } + if _, err := st.RenameOwnedProjectAccountChecked("alice", OwnerDefault, string(localProject.ID), "Late creator", true); !errors.Is(err, ErrAccountForbidden) { + t.Fatalf("disabled local creator renamed: %v", err) + } +} + func TestPGProjectProjectionAccountFenceSerializesWithDisable(t *testing.T) { st := openTestStore(t) if st.Dialect() != "postgres" { diff --git a/pkg/extension/projects.go b/pkg/extension/projects.go index d0f5042..29bf9fa 100644 --- a/pkg/extension/projects.go +++ b/pkg/extension/projects.go @@ -7,7 +7,9 @@ import ( // ProjectHost is an optional actor-scoped host projection capability. Metadata // can be shared by an authorizer; registered-lifecycle metadata masks credentials. -// OwnedProject remains an owner-only management lookup; mutations remain owner-only. +// OwnedProject remains an owner-only management lookup. A request-bound +// delegated projection may carry a separate real operator and fixed creator; +// its final Store transaction checks both accounts and the persisted owner. type ProjectHost interface { Owner() string OwnedProjects() ([]core.Project, error) @@ -18,6 +20,14 @@ type ProjectHost interface { ProjectNameAvailable(name, exceptID string) (bool, error) } +// DelegatedProjectProjectionHost is an optional capability of a current, +// authenticated RDM request Host. It derives a single-project creator +// projection while retaining the original operator for final account fencing. +// A globally reconstructed ScopedHost does not implement this capability. +type DelegatedProjectProjectionHost interface { + ProjectProjectionForCreator(creator, projectID string) (ProjectHost, error) +} + // ProjectLifecycle supplies the domain's public project lifecycle and live work // authorization. Each Host is scoped to the authenticated actor. Errors with a // StatusCode() int method may report 400, 403, 404, 409, or 503 to HTTP clients. diff --git a/pkg/extension/surface.txt b/pkg/extension/surface.txt index 8a682ad..ca7118a 100644 --- a/pkg/extension/surface.txt +++ b/pkg/extension/surface.txt @@ -26,6 +26,8 @@ AdminProvider AdminProvider.IsAdmin AdminZoneSpec Compiled +DelegatedProjectProjectionHost +DelegatedProjectProjectionHost.ProjectProjectionForCreator ErrNotLinked Handler Host From b080e9838871532b975ed24e5eff81c172f4e2fa Mon Sep 17 00:00:00 2001 From: weichen Date: Wed, 14 Oct 2026 14:03:53 +0000 Subject: [PATCH 11/17] Exclude seeded account revision from migration target occupancy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Why: U3 seeds a directory revision in every new target database, which made an empty PostgreSQL target look occupied to the migration guard. Evidence: Isolated PostgreSQL cmd/storage-migrate integration tests passed after the previously failing cases reproduced the error. Impact: A fresh target remains eligible for migration; existing business rows and foreign migration identities still block it. Refs: IAM U3 Stage 7; docs/proposals/2026-10-14-iam-u3-account-contract-and-plan.md §10 --- cmd/storage-migrate/main.go | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/cmd/storage-migrate/main.go b/cmd/storage-migrate/main.go index 496e5f7..84543fc 100644 --- a/cmd/storage-migrate/main.go +++ b/cmd/storage-migrate/main.go @@ -451,6 +451,11 @@ func guardIdentity(db *sql.DB, migrationID, snapID string, srcVersion int) error func businessRows(db *sql.DB) (int64, error) { var total int64 for _, t := range copyOrder { + // Schema setup seeds this singleton before a migration starts. It is + // replaced from the source during copying and is not target business data. + if t == "account_directory_revision" { + continue + } var n int64 if err := db.QueryRow(`SELECT COUNT(*) FROM "` + t + `"`).Scan(&n); err != nil { if strings.Contains(err.Error(), "does not exist") { From b8af4341abe333e5edd35cdc3e5e7b1fead4a02b Mon Sep 17 00:00:00 2001 From: weichen Date: Wed, 14 Oct 2026 14:07:28 +0000 Subject: [PATCH 12/17] Expose persisted run events for process review Why: Completed replies currently drop access to streamed thinking and tool output. Evidence: go test ./...; isolated run-event pagination and conversation binding test. Impact: Add owner-scoped paginated event reading for a single conversation run. Refs: Agent-System-Dev feature/work-companion-visibility UI1 --- internal/server/run_process_events_test.go | 56 ++++++++++++++++++++++ internal/server/server.go | 43 +++++++++++++++++ internal/store/messages.go | 27 +++++++++++ 3 files changed, 126 insertions(+) create mode 100644 internal/server/run_process_events_test.go diff --git a/internal/server/run_process_events_test.go b/internal/server/run_process_events_test.go new file mode 100644 index 0000000..5e7978f --- /dev/null +++ b/internal/server/run_process_events_test.go @@ -0,0 +1,56 @@ +package server + +import ( + "encoding/json" + "net/http" + "strconv" + "testing" + "time" + + core "agent-system-core" +) + +func TestRunProcessEventsAreOrderedAndBoundToConversation(t *testing.T) { + srv, st := newTestServer(t) + h := srv.Handler() + _, convID := setupConversation(t, h) + _, otherConvID := setupConversation(t, h) + runID := postMessage(t, h, convID, "整理材料") + waitRunTerminal(t, h, runID, 5*time.Second) + last, err := st.LastEventSeq(convID) + if err != nil { + t.Fatal(err) + } + events := []core.StreamEvent{ + {RunID: runID, Type: core.EvThinkingDelta, Payload: json.RawMessage(`{"index":0,"text":"先核对日期"}`)}, + {RunID: runID, Type: core.EvToolOutput, Payload: json.RawMessage(`{"callId":"call-1","stream":"stdout","text":"完整工具输出"}`)}, + } + if _, err := st.AppendEvents(convID, events); err != nil { + t.Fatal(err) + } + path := "/api/conversations/" + convID + "/runs/" + runID + "/events?after=" + strconv.FormatInt(last, 10) + "&limit=1" + code, first := doJSON(t, h, http.MethodGet, path, nil) + if code != http.StatusOK { + t.Fatalf("first page: %d %v", code, first) + } + page := first["events"].([]any) + if len(page) != 1 || page[0].(map[string]any)["type"] != string(core.EvThinkingDelta) { + t.Fatalf("first page: %v", page) + } + seq := int64(page[0].(map[string]any)["seq"].(float64)) + code, second := doJSON(t, h, http.MethodGet, "/api/conversations/"+convID+"/runs/"+runID+"/events?after="+strconv.FormatInt(seq, 10)+"&limit=1", nil) + if code != http.StatusOK { + t.Fatalf("second page: %d %v", code, second) + } + page = second["events"].([]any) + if len(page) != 1 || page[0].(map[string]any)["type"] != string(core.EvToolOutput) { + t.Fatalf("second page: %v", page) + } + if payload := page[0].(map[string]any)["payload"].(map[string]any); payload["text"] != "完整工具输出" { + t.Fatalf("payload: %v", payload) + } + code, _ = doJSON(t, h, http.MethodGet, "/api/conversations/"+otherConvID+"/runs/"+runID+"/events", nil) + if code != http.StatusNotFound { + t.Fatalf("mismatched conversation exposed run: %d", code) + } +} diff --git a/internal/server/server.go b/internal/server/server.go index d90b3c3..fb905f3 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -300,6 +300,7 @@ func (s *Server) Handler() http.Handler { mux.HandleFunc("POST /api/projects/{id}/conversations", s.createConversation) mux.HandleFunc("GET /api/conversations", s.listTasks) mux.HandleFunc("GET /api/conversations/{id}", s.conversationDetail) + mux.HandleFunc("GET /api/conversations/{id}/runs/{runId}/events", s.runProcessEvents) mux.HandleFunc("GET /api/conversations/{id}/export", s.exportConversation) mux.HandleFunc("PATCH /api/conversations/{id}", s.patchConversation) mux.HandleFunc("DELETE /api/conversations/{id}", s.deleteConversation) @@ -878,6 +879,48 @@ func (s *Server) conversationDetail(w http.ResponseWriter, r *http.Request) { }) } +// runProcessEvents lets an owner reopen the exact persisted process after the +// transient SSE bubble has been replaced by the settled assistant message. +func (s *Server) runProcessEvents(w http.ResponseWriter, r *http.Request) { + id, runID := r.PathValue("id"), r.PathValue("runId") + owner := s.owner(r) + if _, err := s.st.GetConversation(owner, id); err != nil { + writeStoreErr(w, err) + return + } + run, err := s.st.GetRun(owner, runID) + if err != nil { + writeStoreErr(w, err) + return + } + if string(run.ConversationID) != id { + http.NotFound(w, r) + return + } + after, err := strconv.ParseInt(r.URL.Query().Get("after"), 10, 64) + if r.URL.Query().Get("after") == "" { + after, err = 0, nil + } + if err != nil || after < 0 { + http.Error(w, "invalid after", http.StatusBadRequest) + return + } + limit := 500 + if raw := r.URL.Query().Get("limit"); raw != "" { + limit, err = strconv.Atoi(raw) + if err != nil || limit < 1 || limit > 500 { + http.Error(w, "invalid limit", http.StatusBadRequest) + return + } + } + events, err := s.st.RunEventsAfter(id, runID, after, limit) + if err != nil { + writeStoreErr(w, err) + return + } + writeJSON(w, http.StatusOK, map[string]any{"events": events}) +} + func (s *Server) patchConversation(w http.ResponseWriter, r *http.Request) { id := r.PathValue("id") var req struct { diff --git a/internal/store/messages.go b/internal/store/messages.go index 58724f2..e015d68 100644 --- a/internal/store/messages.go +++ b/internal/store/messages.go @@ -355,6 +355,33 @@ func (s *Store) EventsAfter(conversationID string, since int64, limit int) ([]co return out, rows.Err() } +// RunEventsAfter reads a single run's persisted event stream in conversation seq order. +// The API checks conversation and run ownership before calling this method. +func (s *Store) RunEventsAfter(conversationID, runID string, since int64, limit int) ([]core.StreamEvent, error) { + rows, err := s.db.Query(s.q(`SELECT seq,run_id,ts,v,type,payload FROM events WHERE conversation_id=? AND run_id=? AND seq>? ORDER BY seq LIMIT ?`), + conversationID, runID, since, limit) + if err != nil { + return nil, err + } + defer rows.Close() + out := []core.StreamEvent{} + for rows.Next() { + var e core.StreamEvent + var typ string + var payload []byte + if err := rows.Scan(&e.Seq, &e.RunID, &e.TS, &e.V, &typ, &payload); err != nil { + return nil, err + } + e.ConversationID = conversationID + e.Type = core.StreamEventType(typ) + if len(payload) > 0 { + e.Payload = payload + } + out = append(out, e) + } + return out, rows.Err() +} + // EventAt 读取单个事件(PG 档 NOTIFY 扇出的按序号取回路径)。 func (s *Store) EventAt(conversationID string, seq int64) (core.StreamEvent, error) { var e core.StreamEvent From 26305cacf91f8a2e20e5a232a7a036a1bb82fb2c Mon Sep 17 00:00:00 2001 From: weichen Date: Wed, 14 Oct 2026 14:09:01 +0000 Subject: [PATCH 13/17] Assert account directory cursor fidelity in migration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Why: The U3 migration must replace the target schema seed with the source directory revision and cursor key. Evidence: Isolated PostgreSQL TestMigrateEndToEndAndIdempotent passed with field-by-field source and target comparison. Impact: Migration regressions that lose directory cursor identity now fail the existing end-to-end test. Refs: IAM U3 Stage 7; docs/proposals/2026-10-14-iam-u3-account-contract-and-plan.md §10 --- cmd/storage-migrate/integration_test.go | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/cmd/storage-migrate/integration_test.go b/cmd/storage-migrate/integration_test.go index d4997c0..1d15c90 100644 --- a/cmd/storage-migrate/integration_test.go +++ b/cmd/storage-migrate/integration_test.go @@ -45,6 +45,18 @@ func TestMigrateEndToEndAndIdempotent(t *testing.T) { target, cleanup := pgSchemaDSN(t) defer cleanup() fixture := buildFixture(t) + src, err := sql.Open("sqlite", fixture) + if err != nil { + t.Fatal(err) + } + defer src.Close() + var sourcePublic, sourceStaff, sourcePrivate, sourceSecret string + var sourceKeyVersion int64 + if err := src.QueryRow(`SELECT public_revision,staff_no_revision,private_revision,cursor_key_version,cursor_secret FROM account_directory_revision WHERE id=1`).Scan( + &sourcePublic, &sourceStaff, &sourcePrivate, &sourceKeyVersion, &sourceSecret, + ); err != nil { + t.Fatal(err) + } if err := cmdMigrate([]string{"--source", fixture, "--target", target, "--migration-id", "mig-e2e"}); err != nil { t.Fatalf("首次迁移:%v", err) @@ -61,6 +73,16 @@ func TestMigrateEndToEndAndIdempotent(t *testing.T) { } defer st.Close() db := st.DB() + var targetPublic, targetStaff, targetPrivate, targetSecret string + var targetKeyVersion int64 + if err := db.QueryRow(`SELECT public_revision,staff_no_revision,private_revision,cursor_key_version,cursor_secret FROM account_directory_revision WHERE id=1`).Scan( + &targetPublic, &targetStaff, &targetPrivate, &targetKeyVersion, &targetSecret, + ); err != nil { + t.Fatal(err) + } + if targetPublic != sourcePublic || targetStaff != sourceStaff || targetPrivate != sourcePrivate || targetKeyVersion != sourceKeyVersion || targetSecret != sourceSecret { + t.Fatal("迁移后的目录版本或游标密钥与源库不一致") + } var pinned, archived bool if err := db.QueryRow(`SELECT pinned, archived FROM conversations WHERE id='c1'`).Scan(&pinned, &archived); err != nil || !pinned || archived { t.Fatalf("布尔映射:pinned=%v archived=%v err=%v", pinned, archived, err) From bb5419182c38cb02e989dd8d5c8e6085cfe32832 Mon Sep 17 00:00:00 2001 From: weichen Date: Wed, 14 Oct 2026 14:17:25 +0000 Subject: [PATCH 14/17] Include child collaboration status in run history Why: Spawned agent progress disappeared when the parent reply settled. Evidence: go test ./...; run-event route test covers child status and raw-output exclusion. Impact: Return child progress and result events in parent history without exposing child raw tool streams. Refs: Agent-System-Dev feature/work-companion-visibility UI1 --- internal/server/run_process_events_test.go | 25 ++++++++++++++++++++++ internal/store/messages.go | 11 +++++++--- 2 files changed, 33 insertions(+), 3 deletions(-) diff --git a/internal/server/run_process_events_test.go b/internal/server/run_process_events_test.go index 5e7978f..76d0533 100644 --- a/internal/server/run_process_events_test.go +++ b/internal/server/run_process_events_test.go @@ -49,6 +49,31 @@ func TestRunProcessEventsAreOrderedAndBoundToConversation(t *testing.T) { if payload := page[0].(map[string]any)["payload"].(map[string]any); payload["text"] != "完整工具输出" { t.Fatalf("payload: %v", payload) } + parent, err := st.GetRunByID(runID) + if err != nil { + t.Fatal(err) + } + child := core.Run{ID: "run_child_process", ParentRunID: parent.ID, ConversationID: parent.ConversationID, + ProjectID: parent.ProjectID, Owner: parent.Owner, Kind: core.RunSubagent, Status: core.RunCompleted} + if err := st.CreateRun(&child); err != nil { + t.Fatal(err) + } + childEvents := []core.StreamEvent{ + {RunID: child.ID, Type: core.EvAgentProgress, Payload: json.RawMessage(`{"agentRun":"run_child_process","note":"正在核对附录"}`)}, + {RunID: child.ID, Type: core.EvToolOutput, Payload: json.RawMessage(`{"callId":"hidden","text":"child raw output"}`)}, + {RunID: child.ID, Type: core.EvAgentResult, Payload: json.RawMessage(`{"agentRun":"run_child_process","status":"completed","summary":"附录已核对"}`)}, + } + if _, err := st.AppendEvents(convID, childEvents); err != nil { + t.Fatal(err) + } + code, childPage := doJSON(t, h, http.MethodGet, "/api/conversations/"+convID+"/runs/"+runID+"/events?after="+strconv.FormatInt(events[1].Seq, 10), nil) + if code != http.StatusOK { + t.Fatalf("child page: %d %v", code, childPage) + } + childRows := childPage["events"].([]any) + if len(childRows) != 2 || childRows[0].(map[string]any)["type"] != string(core.EvAgentProgress) || childRows[1].(map[string]any)["type"] != string(core.EvAgentResult) { + t.Fatalf("child progress/result not retained or raw child output leaked: %v", childRows) + } code, _ = doJSON(t, h, http.MethodGet, "/api/conversations/"+otherConvID+"/runs/"+runID+"/events", nil) if code != http.StatusNotFound { t.Fatalf("mismatched conversation exposed run: %d", code) diff --git a/internal/store/messages.go b/internal/store/messages.go index e015d68..e2b7490 100644 --- a/internal/store/messages.go +++ b/internal/store/messages.go @@ -355,11 +355,16 @@ func (s *Store) EventsAfter(conversationID string, since int64, limit int) ([]co return out, rows.Err() } -// RunEventsAfter reads a single run's persisted event stream in conversation seq order. +// RunEventsAfter reads a run's persisted stream plus its direct children's +// collaboration status events in conversation seq order. Raw child output is +// intentionally outside the parent's previously visible process stream. // The API checks conversation and run ownership before calling this method. func (s *Store) RunEventsAfter(conversationID, runID string, since int64, limit int) ([]core.StreamEvent, error) { - rows, err := s.db.Query(s.q(`SELECT seq,run_id,ts,v,type,payload FROM events WHERE conversation_id=? AND run_id=? AND seq>? ORDER BY seq LIMIT ?`), - conversationID, runID, since, limit) + rows, err := s.db.Query(s.q(`SELECT seq,run_id,ts,v,type,payload FROM events + WHERE conversation_id=? AND seq>? AND ( + run_id=? OR (run_id IN (SELECT id FROM runs WHERE parent_run_id=? AND conversation_id=?) + AND type IN ('agent.progress','agent.result')) + ) ORDER BY seq LIMIT ?`), conversationID, since, runID, runID, conversationID, limit) if err != nil { return nil, err } From 121b4d60e7c80aeed681931362ba84c53007b1fd Mon Sep 17 00:00:00 2001 From: weichen Date: Wed, 14 Oct 2026 14:23:39 +0000 Subject: [PATCH 15/17] Allow private project replay test to reach shared event Why: The SSE test canceled after 200 ms while filtering more than a thousand private events; CI and race builds stopped before the shared frame. Evidence: The failure reproduced under go test -race with HTTP 200, no private leak, and no shared frame; after a two-second deadline the race case passed three times and the Server package passed. Impact: The privacy assertion is unchanged and now reports which condition failed, while slower replay still reaches the event under test. Refs: IAM U3 Stage 7; Backend PR #198; CI run 3170 --- internal/server/project_events_test.go | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/internal/server/project_events_test.go b/internal/server/project_events_test.go index 06eb7f4..7bff0cd 100644 --- a/internal/server/project_events_test.go +++ b/internal/server/project_events_test.go @@ -20,7 +20,9 @@ func projectStreamReplay(t *testing.T, s *Server, key, project string) *httptest t.Helper() r := httptest.NewRequest("GET", "/api/events?scope=project&projectId="+project+"&since=0", nil) r.Header.Set("Authorization", "Bearer "+key) - ctx, cancel := context.WithTimeout(r.Context(), 200*time.Millisecond) + // Replay can pass more than a thousand private events before the first + // visible frame; account checks on CI and under -race need headroom. + ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second) defer cancel() w := httptest.NewRecorder() s.Handler().ServeHTTP(w, r.WithContext(ctx)) @@ -78,7 +80,8 @@ func TestProjectEventsSharedProofAndPrivatePagination(t *testing.T) { } w := projectStreamReplay(t, s, bob, p) if w.Code != 200 || bytes.Contains(w.Body.Bytes(), []byte("PRIVATE HISTORY")) || !bytes.Contains(w.Body.Bytes(), []byte("EXPLICIT SHARED")) { - t.Fatal("private pagination/spoof proof failed") + t.Fatalf("private pagination/spoof proof failed: code=%d private=%t shared=%t bytes=%d", w.Code, + bytes.Contains(w.Body.Bytes(), []byte("PRIVATE HISTORY")), bytes.Contains(w.Body.Bytes(), []byte("EXPLICIT SHARED")), w.Body.Len()) } } func TestProjectEventsLivePrivacyAndRevocation(t *testing.T) { From 97d9c2b0c78c7c88b1b488348517643a16810675 Mon Sep 17 00:00:00 2001 From: weichen Date: Wed, 14 Oct 2026 14:46:24 +0000 Subject: [PATCH 16/17] Guard process event replay for disabled local accounts Why: Merging run-process visibility introduced a read endpoint that bypassed U3 effective-account checks when authentication was off. Evidence: A new regression first returned HTTP 200 for disabled local; with entry and final response checks it passed, and the event pagination test passed ten repeated runs after anchoring to its appended event sequence. Impact: Explicitly disabled local accounts cannot read stored process events; missing implicit local remains compatible. Refs: IAM U3 Stage 8 integration review; Backend PR #198; main PR #199 --- internal/server/run_process_events_test.go | 34 ++++++++++++++++++---- internal/server/server.go | 10 +++++++ 2 files changed, 39 insertions(+), 5 deletions(-) diff --git a/internal/server/run_process_events_test.go b/internal/server/run_process_events_test.go index 76d0533..62de1a5 100644 --- a/internal/server/run_process_events_test.go +++ b/internal/server/run_process_events_test.go @@ -7,6 +7,7 @@ import ( "testing" "time" + "agent-system-backend/internal/store" core "agent-system-core" ) @@ -17,10 +18,6 @@ func TestRunProcessEventsAreOrderedAndBoundToConversation(t *testing.T) { _, otherConvID := setupConversation(t, h) runID := postMessage(t, h, convID, "整理材料") waitRunTerminal(t, h, runID, 5*time.Second) - last, err := st.LastEventSeq(convID) - if err != nil { - t.Fatal(err) - } events := []core.StreamEvent{ {RunID: runID, Type: core.EvThinkingDelta, Payload: json.RawMessage(`{"index":0,"text":"先核对日期"}`)}, {RunID: runID, Type: core.EvToolOutput, Payload: json.RawMessage(`{"callId":"call-1","stream":"stdout","text":"完整工具输出"}`)}, @@ -28,7 +25,9 @@ func TestRunProcessEventsAreOrderedAndBoundToConversation(t *testing.T) { if _, err := st.AppendEvents(convID, events); err != nil { t.Fatal(err) } - path := "/api/conversations/" + convID + "/runs/" + runID + "/events?after=" + strconv.FormatInt(last, 10) + "&limit=1" + // The runner may persist its terminal event after its status becomes terminal. + // Anchor paging to the events this test appended, not an earlier snapshot. + path := "/api/conversations/" + convID + "/runs/" + runID + "/events?after=" + strconv.FormatInt(events[0].Seq-1, 10) + "&limit=1" code, first := doJSON(t, h, http.MethodGet, path, nil) if code != http.StatusOK { t.Fatalf("first page: %d %v", code, first) @@ -79,3 +78,28 @@ func TestRunProcessEventsAreOrderedAndBoundToConversation(t *testing.T) { t.Fatalf("mismatched conversation exposed run: %d", code) } } + +func TestRunProcessEventsRejectsDisabledImplicitLocal(t *testing.T) { + srv, st := newTestServer(t) + if err := st.CreateAccount("admin", "", "", true); err != nil { + t.Fatal(err) + } + h := srv.Handler() + _, convID := setupConversation(t, h) + runID := postMessage(t, h, convID, "整理材料") + waitRunTerminal(t, h, runID, 5*time.Second) + if err := st.EnsureUser(store.OwnerDefault, ""); err != nil { + t.Fatal(err) + } + account, err := st.GetAccountByOwner(store.OwnerDefault) + if err != nil { + t.Fatal(err) + } + if _, err := st.SetAccountEnabled("admin", account.ID, account.StateVersion, false); err != nil { + t.Fatal(err) + } + code, _ := doJSON(t, h, http.MethodGet, "/api/conversations/"+convID+"/runs/"+runID+"/events", nil) + if code != http.StatusForbidden { + t.Fatalf("disabled local account read process events: %d", code) + } +} diff --git a/internal/server/server.go b/internal/server/server.go index d7a9cc3..c183f7c 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -917,6 +917,10 @@ func (s *Server) conversationDetail(w http.ResponseWriter, r *http.Request) { func (s *Server) runProcessEvents(w http.ResponseWriter, r *http.Request) { id, runID := r.PathValue("id"), r.PathValue("runId") owner := s.owner(r) + if err := s.checkActorBeforeExternalEffect(owner); err != nil { + writeStoreErr(w, err) + return + } if _, err := s.st.GetConversation(owner, id); err != nil { writeStoreErr(w, err) return @@ -951,6 +955,12 @@ func (s *Server) runProcessEvents(w http.ResponseWriter, r *http.Request) { writeStoreErr(w, err) return } + // The authenticated account can be disabled while stored events are read. + // Recheck immediately before returning private process output. + if err := s.checkActorBeforeExternalEffect(owner); err != nil { + writeStoreErr(w, err) + return + } writeJSON(w, http.StatusOK, map[string]any{"events": events}) } From d53b20ecd72058b1baf76eccdfe5c8b1a7f54e97 Mon Sep 17 00:00:00 2001 From: weichen Date: Wed, 14 Oct 2026 14:52:05 +0000 Subject: [PATCH 17/17] Verify disabled process replay across independent stores Why: The previous endpoint regression used an SQLite-only server fixture even when isolated PostgreSQL was configured. Evidence: The revised HTTP test passed with dialect=sqlite and dialect=postgres; it creates data through one Store and reads through another before and after disabling the local account. Impact: U3 effective-state checks for the new process replay route now have a direct two-store PostgreSQL regression without changing production behavior. Refs: IAM U3 affected Stage 7 review; Backend PR #198 --- internal/server/run_process_events_test.go | 46 +++++++++++++++++----- 1 file changed, 36 insertions(+), 10 deletions(-) diff --git a/internal/server/run_process_events_test.go b/internal/server/run_process_events_test.go index 62de1a5..c879d6d 100644 --- a/internal/server/run_process_events_test.go +++ b/internal/server/run_process_events_test.go @@ -80,25 +80,51 @@ func TestRunProcessEventsAreOrderedAndBoundToConversation(t *testing.T) { } func TestRunProcessEventsRejectsDisabledImplicitLocal(t *testing.T) { - srv, st := newTestServer(t) - if err := st.CreateAccount("admin", "", "", true); err != nil { + first, second := twinRecoveryStores(t) + t.Logf("process-event replay dialect=%s independent stores", first.Dialect()) + if err := first.CreateAccount("admin", "", "", true); err != nil { t.Fatal(err) } - h := srv.Handler() - _, convID := setupConversation(t, h) - runID := postMessage(t, h, convID, "整理材料") - waitRunTerminal(t, h, runID, 5*time.Second) - if err := st.EnsureUser(store.OwnerDefault, ""); err != nil { + if err := first.EnsureUser(store.OwnerDefault, ""); err != nil { t.Fatal(err) } - account, err := st.GetAccountByOwner(store.OwnerDefault) + project, err := first.CreateProject(store.OwnerDefault, "Project") if err != nil { t.Fatal(err) } - if _, err := st.SetAccountEnabled("admin", account.ID, account.StateVersion, false); err != nil { + conversation, err := first.CreateConversation(store.OwnerDefault, project.ID, "Process", "") + if err != nil { t.Fatal(err) } - code, _ := doJSON(t, h, http.MethodGet, "/api/conversations/"+convID+"/runs/"+runID+"/events", nil) + run := core.Run{ID: "run_local_process", ConversationID: conversation.ID, ProjectID: project.ID, + Owner: store.OwnerDefault, Kind: core.RunConversation, Status: core.RunCompleted} + if err := first.CreateRun(&run); err != nil { + t.Fatal(err) + } + if _, err := first.AppendEvents(conversation.ID, []core.StreamEvent{{RunID: run.ID, Type: core.EvThinkingDelta, + Payload: json.RawMessage(`{"index":0,"text":"private process"}`)}}); err != nil { + t.Fatal(err) + } + srv := newAuthTestServerOnStore(t, second, t.TempDir()) + h := srv.Handler() + path := "/api/conversations/" + conversation.ID + "/runs/" + run.ID + "/events" + code, _ := doJSON(t, h, http.MethodGet, path, nil) + if code != http.StatusUnauthorized { + t.Fatalf("auth-on replay without credential: %d", code) + } + srv.Auth.Enabled = false + code, _ = doJSON(t, h, http.MethodGet, path, nil) + if code != http.StatusOK { + t.Fatalf("enabled local account could not read process events: %d", code) + } + account, err := first.GetAccountByOwner(store.OwnerDefault) + if err != nil { + t.Fatal(err) + } + if _, err := first.SetAccountEnabled("admin", account.ID, account.StateVersion, false); err != nil { + t.Fatal(err) + } + code, _ = doJSON(t, h, http.MethodGet, path, nil) if code != http.StatusForbidden { t.Fatalf("disabled local account read process events: %d", code) }