Files
Agent-System-Backend/internal/store/account_model.go
T
weichenandCursor b4a19e784d
governance / governance (pull_request) Successful in 18s
ci / vet-and-test (pull_request) Successful in 1m5s
Merge Backend main into IAM U4 role authority
Why: the U4 branch is 89 commits behind main, which now carries the shared
document renderer, two-trees project work, the SQLite instance guard and
exec env layers (PR #209 merged as c76d8bd); it conflicted in storage
migrations, host capabilities and the extension surface. The user authorized
syncing U4 after the renderer line on 2026-10-18.
Evidence: content is the agent-exec transport 3b64eb5 (cursor worker, unit
backend-iam-u4-onto-main-c76d8bd-r1, run on a checkout whose tree equals main,
receipt /tmp/cleanup-20261018/render/u4-backend-onto-run.json, SELF). Host
check: main migrations 1-48 unchanged (migrate.go has no deleted lines versus
main), U4 migrations moved from 35-36 to 49-50 with identical SQL,
surface.txt is the sorted union, deps stay at main's pins.
Impact: databases that ran unmerged U4 builds with the old numbering must be
recreated. SurfaceVersion becomes 2.25.0-experimental. Main's admin password
account creation now requires the system.users.create action like the other
U4 account writes.
Refs: Backend main c76d8bd, PR #209

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-18 12:50:32 +00:00

472 lines
16 KiB
Go

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
}
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`
type accountQuerier interface {
QueryRow(string, ...any) *sql.Row
}
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,
&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
}
if err := s.dialect.lockAccount(tx, owner); err != nil {
return nil, err
}
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) {
return Account{}, ErrAccountForbidden
}
if err != nil {
return Account{}, err
}
target, err := scanAccount(tx.QueryRow(s.q(`SELECT `+accountColumns+` FROM users WHERE id=?`), userID))
if errors.Is(err, ErrNotFound) && actor.ID != userID {
return Account{}, ErrAccountForbidden
}
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 {
return Account{}, ErrAccountForbidden
}
if actor.ID != target.ID {
if err := s.requireSystemActionTx(tx, actor, "system.users.edit"); err != nil {
return Account{}, err
}
}
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 := s.auditAccountMutation(tx, target.Owner, actorOwner, "account.profile"); 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 err := s.requireSystemActionTx(tx, actor, "system.users.create"); err != nil {
return Account{}, err
}
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 := s.auditAccountMutation(tx, owner, actorOwner, "account.create_pending"); err != nil {
return Account{}, err
}
if err := tx.Commit(); err != nil {
return Account{}, err
}
return a, nil
}
// CreateAdminPasswordAccount creates an ordinary user with an administrator-
// supplied password hash. The account row, hash, and audit entries commit
// together so a failed write cannot leave a credential without its account.
func (s *Store) CreateAdminPasswordAccount(actorOwner, owner string, profile AccountProfile, passwordHash string) (Account, error) {
if passwordHash == "" {
return Account{}, ErrConflict
}
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 err := s.requireSystemActionTx(tx, actor, "system.users.create"); err != nil {
return Account{}, err
}
a, err := s.insertPendingAccountTx(tx, owner, profile)
if err != nil {
return Account{}, err
}
if _, err := tx.Exec(s.q(`UPDATE users SET password_hash=?,password_set_at=?,must_change_password=?,credential_origin=?,initialization_state=? WHERE id=?`),
passwordHash, nowISO(), true, CredentialOriginAdminPassword, InitializationInitialized, a.ID); err != nil {
return Account{}, err
}
a, err = s.accountByOwner(tx, owner)
if err != nil {
return Account{}, err
}
if err := s.insertAccountEvent(tx, actorOwner, "account-management", owner, "account_created_password", a.ID); err != nil {
return Account{}, err
}
if err := s.auditAccountMutation(tx, owner, actorOwner, "account.create_password"); 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
}
// 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 {
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
}