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:
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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 校验用户名密码。
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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 版本。
|
||||
|
||||
Reference in New Issue
Block a user