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 版本。