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
This commit is contained in:
2026-10-14 02:57:31 +00:00
parent 5d5fc9891c
commit 6c0289faf7
5 changed files with 863 additions and 16 deletions
+368
View File
@@ -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
}
+423
View File
@@ -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@example.org>", "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)
}
}
+21 -16
View File
@@ -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 校验用户名密码。
+50
View File
@@ -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 (
+1
View File
@@ -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 版本。