503 lines
18 KiB
Go
503 lines
18 KiB
Go
package store
|
|
|
|
import (
|
|
"crypto/rand"
|
|
"database/sql"
|
|
"encoding/hex"
|
|
"errors"
|
|
"fmt"
|
|
"net/mail"
|
|
"sort"
|
|
"strings"
|
|
"unicode"
|
|
"unicode/utf8"
|
|
)
|
|
|
|
var ErrAccountForbidden = errors.New("store: account action forbidden")
|
|
var ErrInvalidAccountProfile = errors.New("store: invalid account profile")
|
|
|
|
// CredentialOrigin describes persisted credential history, not the authority
|
|
// of the caller. Unknown values never supply owner proof.
|
|
type CredentialOrigin string
|
|
|
|
const (
|
|
CredentialOriginLegacyUnknown CredentialOrigin = "legacy_unknown"
|
|
CredentialOriginAdminPassword CredentialOrigin = "admin_password"
|
|
CredentialOriginOwnerInitialization CredentialOrigin = "owner_initialization"
|
|
CredentialOriginOwnerRecovery CredentialOrigin = "owner_recovery"
|
|
InitializationLegacy = "legacy"
|
|
InitializationPending = "pending"
|
|
InitializationInitialized = "initialized"
|
|
AuthMethodLegacyUnknown = "legacy_unknown"
|
|
AuthMethodPassword = "password"
|
|
AuthMethodGrant = "grant"
|
|
)
|
|
|
|
// TrustedForOwnerProof checks only lineage. The recovery boundary must also
|
|
// check the exact live password session, account/version and proposal binding.
|
|
func (origin CredentialOrigin) TrustedForOwnerProof() bool {
|
|
return origin == CredentialOriginOwnerInitialization || origin == CredentialOriginOwnerRecovery
|
|
}
|
|
|
|
type AccountProfile struct {
|
|
Name string `json:"name"`
|
|
Phone string `json:"phone"`
|
|
Email string `json:"email"`
|
|
StaffNo string `json:"staffNo"`
|
|
Department string `json:"department"`
|
|
Notes string `json:"notes"`
|
|
}
|
|
|
|
// Account is an internal account view without credential material. Handlers and
|
|
// directory providers must project only the fields authorized for their caller.
|
|
type Account struct {
|
|
ID string
|
|
Owner string
|
|
Profile AccountProfile
|
|
ProfileVersion int64
|
|
Enabled bool
|
|
StateVersion int64
|
|
IsAdmin bool
|
|
IsSuperAdmin bool
|
|
HasPassword bool
|
|
MustChangePassword bool
|
|
AuthVersion int
|
|
CredentialOrigin CredentialOrigin
|
|
InitializationState string
|
|
InitializationGeneration int64
|
|
}
|
|
|
|
type AccountDirectoryRevision struct {
|
|
Public string
|
|
StaffNo string
|
|
Private string
|
|
}
|
|
|
|
type AccountCursorKey struct {
|
|
Version int
|
|
Secret [32]byte
|
|
}
|
|
|
|
const accountColumns = `id,owner,COALESCE(name,''),phone,email,staff_no,department,notes,
|
|
profile_version,enabled,state_version,is_admin,is_super_admin,password_hash<>'',must_change_password,
|
|
auth_version,credential_origin,initialization_state,initialization_generation`
|
|
|
|
type accountQuerier interface {
|
|
QueryRow(string, ...any) *sql.Row
|
|
}
|
|
|
|
type accountScanner interface{ Scan(...any) error }
|
|
|
|
func scanAccount(row accountScanner) (Account, error) {
|
|
var a Account
|
|
err := row.Scan(&a.ID, &a.Owner, &a.Profile.Name, &a.Profile.Phone, &a.Profile.Email,
|
|
&a.Profile.StaffNo, &a.Profile.Department, &a.Profile.Notes, &a.ProfileVersion,
|
|
&a.Enabled, &a.StateVersion, &a.IsAdmin, &a.IsSuperAdmin, &a.HasPassword, &a.MustChangePassword,
|
|
&a.AuthVersion, &a.CredentialOrigin, &a.InitializationState, &a.InitializationGeneration)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return Account{}, ErrNotFound
|
|
}
|
|
return a, err
|
|
}
|
|
|
|
// GetAccount uses the stable identity; it performs no caller authorization.
|
|
func (s *Store) GetAccount(userID string) (Account, error) {
|
|
return scanAccount(s.db.QueryRow(s.q(`SELECT `+accountColumns+` FROM users WHERE id=?`), userID))
|
|
}
|
|
|
|
func (s *Store) GetAccountByOwner(owner string) (Account, error) {
|
|
return s.accountByOwner(s.db, owner)
|
|
}
|
|
|
|
func (s *Store) accountByOwner(db accountQuerier, owner string) (Account, error) {
|
|
return scanAccount(db.QueryRow(s.q(`SELECT `+accountColumns+` FROM users WHERE owner=?`), owner))
|
|
}
|
|
|
|
// lockAccountUsers is the common first lock for account transactions. Always
|
|
// acquire all participating users in owner order before proposal, grant and
|
|
// session locks. PG locks persist to commit; SQLite already holds its immediate
|
|
// transaction write lock. Never lock a second user later in an arbitrary order.
|
|
func (s *Store) lockAccountUsers(tx *sql.Tx, owners ...string) (map[string]Account, error) {
|
|
ordered := append([]string(nil), owners...)
|
|
sort.Strings(ordered)
|
|
accounts := make(map[string]Account, len(ordered))
|
|
query := s.q(`SELECT ` + accountColumns + ` FROM users WHERE owner=?`)
|
|
if s.Dialect() == "postgres" {
|
|
query += ` FOR UPDATE`
|
|
}
|
|
for _, owner := range ordered {
|
|
if _, exists := accounts[owner]; exists {
|
|
continue
|
|
}
|
|
if err := s.dialect.lockAccount(tx, owner); err != nil {
|
|
return nil, err
|
|
}
|
|
a, err := scanAccount(tx.QueryRow(query, owner))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
accounts[owner] = a
|
|
}
|
|
return accounts, nil
|
|
}
|
|
|
|
func validateAccountProfilePatch(patch map[string]string) (map[string]string, error) {
|
|
limits := map[string]int{"name": 128, "phone": 64, "email": 254, "staffNo": 64, "department": 128, "notes": 2000}
|
|
normalized := make(map[string]string, len(patch))
|
|
for field, value := range patch {
|
|
limit, ok := limits[field]
|
|
if !ok {
|
|
return nil, fmt.Errorf("%w: unknown field", ErrInvalidAccountProfile)
|
|
}
|
|
if !utf8.ValidString(value) {
|
|
return nil, fmt.Errorf("%w: %s is not UTF-8", ErrInvalidAccountProfile, field)
|
|
}
|
|
for _, r := range value {
|
|
if unicode.IsControl(r) {
|
|
return nil, fmt.Errorf("%w: %s contains control characters", ErrInvalidAccountProfile, field)
|
|
}
|
|
}
|
|
value = strings.TrimSpace(value)
|
|
if utf8.RuneCountInString(value) > limit {
|
|
return nil, fmt.Errorf("%w: %s exceeds %d characters", ErrInvalidAccountProfile, field, limit)
|
|
}
|
|
if field == "email" && value != "" {
|
|
address, err := mail.ParseAddress(value)
|
|
if err != nil || address.Address != value || address.Name != "" {
|
|
return nil, fmt.Errorf("%w: invalid email", ErrInvalidAccountProfile)
|
|
}
|
|
}
|
|
normalized[field] = value
|
|
}
|
|
return normalized, nil
|
|
}
|
|
|
|
func applyAccountProfilePatch(profile AccountProfile, patch map[string]string) AccountProfile {
|
|
for field, value := range patch {
|
|
switch field {
|
|
case "name":
|
|
profile.Name = value
|
|
case "phone":
|
|
profile.Phone = value
|
|
case "email":
|
|
profile.Email = value
|
|
case "staffNo":
|
|
profile.StaffNo = value
|
|
case "department":
|
|
profile.Department = value
|
|
case "notes":
|
|
profile.Notes = value
|
|
}
|
|
}
|
|
return profile
|
|
}
|
|
|
|
// PatchAccountProfile takes an actor from a trusted authentication boundary;
|
|
// callers must enforce its full password-session requirement. Current account
|
|
// status/admin authorization is rechecked under the same locks as the write.
|
|
func (s *Store) PatchAccountProfile(actorOwner, userID string, expectedProfileVersion int64, patch map[string]string) (Account, error) {
|
|
if expectedProfileVersion < 1 {
|
|
return Account{}, fmt.Errorf("%w: expectedProfileVersion is required", ErrInvalidAccountProfile)
|
|
}
|
|
normalized, err := validateAccountProfilePatch(patch)
|
|
if err != nil {
|
|
return Account{}, err
|
|
}
|
|
tx, err := s.db.Begin()
|
|
if err != nil {
|
|
return Account{}, err
|
|
}
|
|
defer tx.Rollback()
|
|
actor, err := s.accountByOwner(tx, actorOwner)
|
|
if errors.Is(err, ErrNotFound) || (err == nil && !actor.Enabled) {
|
|
return Account{}, ErrAccountForbidden
|
|
}
|
|
if err != nil {
|
|
return Account{}, err
|
|
}
|
|
target, err := scanAccount(tx.QueryRow(s.q(`SELECT `+accountColumns+` FROM users WHERE id=?`), userID))
|
|
if errors.Is(err, ErrNotFound) && actor.ID != userID {
|
|
return Account{}, ErrAccountForbidden
|
|
}
|
|
if err != nil {
|
|
return Account{}, err
|
|
}
|
|
locked, err := s.lockAccountUsers(tx, actorOwner, target.Owner)
|
|
if err != nil {
|
|
return Account{}, err
|
|
}
|
|
actor, target = locked[actorOwner], locked[target.Owner]
|
|
if !actor.Enabled {
|
|
return Account{}, ErrAccountForbidden
|
|
}
|
|
if err := requireSuperAdminTargetAccess(actor, target); err != nil {
|
|
return Account{}, err
|
|
}
|
|
if actor.ID != target.ID {
|
|
if err := s.requireSystemActionTx(tx, actor, "system.users.edit"); err != nil {
|
|
return Account{}, err
|
|
}
|
|
}
|
|
if target.ID != userID || target.ProfileVersion != expectedProfileVersion {
|
|
return Account{}, ErrConflict
|
|
}
|
|
profile := applyAccountProfilePatch(target.Profile, normalized)
|
|
result, err := tx.Exec(s.q(`UPDATE users SET name=?,phone=?,email=?,staff_no=?,department=?,notes=?,profile_version=profile_version+1 WHERE id=? AND profile_version=?`),
|
|
profile.Name, profile.Phone, profile.Email, profile.StaffNo, profile.Department, profile.Notes, userID, expectedProfileVersion)
|
|
if err != nil {
|
|
return Account{}, err
|
|
}
|
|
if n, err := result.RowsAffected(); err != nil {
|
|
return Account{}, err
|
|
} else if n != 1 {
|
|
return Account{}, ErrConflict
|
|
}
|
|
if profile != target.Profile {
|
|
if err := s.bumpAccountDirectoryRevision(tx, profile.Name != target.Profile.Name, profile.StaffNo != target.Profile.StaffNo); err != nil {
|
|
return Account{}, err
|
|
}
|
|
}
|
|
// Audit field names and versions only, never profile values.
|
|
fields := make([]string, 0, len(normalized))
|
|
for field := range normalized {
|
|
fields = append(fields, field)
|
|
}
|
|
sort.Strings(fields)
|
|
if err := s.insertAccountEvent(tx, actorOwner, "account-profile", target.Owner, "profile_updated", fmt.Sprintf("version=%d fields=%s", expectedProfileVersion+1, strings.Join(fields, ","))); err != nil {
|
|
return Account{}, err
|
|
}
|
|
if err := s.auditAccountMutation(tx, target.Owner, actorOwner, "account.profile"); err != nil {
|
|
return Account{}, err
|
|
}
|
|
if err := tx.Commit(); err != nil {
|
|
return Account{}, err
|
|
}
|
|
target.Profile, target.ProfileVersion = profile, expectedProfileVersion+1
|
|
return target, nil
|
|
}
|
|
|
|
// CreatePendingAccount is the ordinary account-management primitive. It cannot
|
|
// set passwords, administrator privileges or trusted credential provenance.
|
|
func (s *Store) CreatePendingAccount(actorOwner, owner string, profile AccountProfile) (Account, error) {
|
|
tx, err := s.db.Begin()
|
|
if err != nil {
|
|
return Account{}, err
|
|
}
|
|
defer tx.Rollback()
|
|
locked, err := s.lockAccountUsers(tx, actorOwner)
|
|
if errors.Is(err, ErrNotFound) {
|
|
return Account{}, ErrAccountForbidden
|
|
}
|
|
if err != nil {
|
|
return Account{}, err
|
|
}
|
|
actor := locked[actorOwner]
|
|
if err := s.requireSystemActionTx(tx, actor, "system.users.create"); err != nil {
|
|
return Account{}, err
|
|
}
|
|
a, err := s.insertPendingAccountTx(tx, owner, profile)
|
|
if err != nil {
|
|
return Account{}, err
|
|
}
|
|
if err := s.insertAccountEvent(tx, actorOwner, "account-management", owner, "account_created_pending", a.ID); err != nil {
|
|
return Account{}, err
|
|
}
|
|
if err := s.auditAccountMutation(tx, owner, actorOwner, "account.create_pending"); err != nil {
|
|
return Account{}, err
|
|
}
|
|
if err := tx.Commit(); err != nil {
|
|
return Account{}, err
|
|
}
|
|
return a, nil
|
|
}
|
|
|
|
// CreateAdminPasswordAccount creates an ordinary user with an administrator-
|
|
// supplied password hash. The account row, hash, and audit entries commit
|
|
// together so a failed write cannot leave a credential without its account.
|
|
func (s *Store) CreateAdminPasswordAccount(actorOwner, owner string, profile AccountProfile, passwordHash string) (Account, error) {
|
|
return s.CreatePasswordAccountWithAuthority(actorOwner, owner, profile, passwordHash, false)
|
|
}
|
|
|
|
// CreatePasswordAccountWithAuthority is CreateAdminPasswordAccount with an
|
|
// optional administrator appointment. Appointment is reserved to the enabled
|
|
// super administrator and is rechecked under the actor lock (R3, R4); every
|
|
// other actor receives ErrAccountForbidden without any partial write.
|
|
func (s *Store) CreatePasswordAccountWithAuthority(actorOwner, owner string, profile AccountProfile, passwordHash string, isAdmin bool) (Account, error) {
|
|
if passwordHash == "" {
|
|
return Account{}, ErrConflict
|
|
}
|
|
tx, err := s.db.Begin()
|
|
if err != nil {
|
|
return Account{}, err
|
|
}
|
|
defer tx.Rollback()
|
|
locked, err := s.lockAccountUsers(tx, actorOwner)
|
|
if errors.Is(err, ErrNotFound) {
|
|
return Account{}, ErrAccountForbidden
|
|
}
|
|
if err != nil {
|
|
return Account{}, err
|
|
}
|
|
actor := locked[actorOwner]
|
|
if err := s.requireSystemActionTx(tx, actor, "system.users.create"); err != nil {
|
|
return Account{}, err
|
|
}
|
|
if isAdmin && !actor.IsSuperAdmin {
|
|
return Account{}, ErrAccountForbidden
|
|
}
|
|
a, err := s.insertPendingAccountTx(tx, owner, profile)
|
|
if err != nil {
|
|
return Account{}, err
|
|
}
|
|
if _, err := tx.Exec(s.q(`UPDATE users SET password_hash=?,password_set_at=?,must_change_password=?,credential_origin=?,initialization_state=?,is_admin=? WHERE id=?`),
|
|
passwordHash, nowISO(), true, CredentialOriginAdminPassword, InitializationInitialized, isAdmin, a.ID); err != nil {
|
|
return Account{}, err
|
|
}
|
|
if isAdmin {
|
|
if err := s.seedSystemAdminGrantsTx(tx, owner); err != nil {
|
|
return Account{}, err
|
|
}
|
|
if err := s.bumpAuthorityEpochTx(tx); err != nil {
|
|
return Account{}, err
|
|
}
|
|
}
|
|
a, err = s.accountByOwner(tx, owner)
|
|
if err != nil {
|
|
return Account{}, err
|
|
}
|
|
if err := s.insertAccountEvent(tx, actorOwner, "account-management", owner, "account_created_password", a.ID); err != nil {
|
|
return Account{}, err
|
|
}
|
|
if err := s.auditAccountMutation(tx, owner, actorOwner, "account.create_password"); err != nil {
|
|
return Account{}, err
|
|
}
|
|
if isAdmin {
|
|
if err := s.insertAccountEvent(tx, actorOwner, "account-management", owner, "admin_appointed", a.ID); err != nil {
|
|
return Account{}, err
|
|
}
|
|
if err := s.auditAccountMutation(tx, owner, actorOwner, "account.admin_appoint"); err != nil {
|
|
return Account{}, err
|
|
}
|
|
}
|
|
if err := tx.Commit(); err != nil {
|
|
return Account{}, err
|
|
}
|
|
return a, nil
|
|
}
|
|
|
|
// insertPendingAccountTx lets the trusted local initializer create and grant in
|
|
// one transaction without impersonating an administrator. The caller owns its
|
|
// authorization, audit and commit; this helper always inserts an ordinary user.
|
|
func (s *Store) insertPendingAccountTx(tx *sql.Tx, owner string, profile AccountProfile) (Account, error) {
|
|
if owner == "" || owner != strings.TrimSpace(owner) || !utf8.ValidString(owner) {
|
|
return Account{}, fmt.Errorf("%w: invalid owner", ErrInvalidAccountProfile)
|
|
}
|
|
for _, r := range owner {
|
|
if unicode.IsControl(r) {
|
|
return Account{}, fmt.Errorf("%w: invalid owner", ErrInvalidAccountProfile)
|
|
}
|
|
}
|
|
patch, err := validateAccountProfilePatch(map[string]string{"name": profile.Name, "phone": profile.Phone, "email": profile.Email, "staffNo": profile.StaffNo, "department": profile.Department, "notes": profile.Notes})
|
|
if err != nil {
|
|
return Account{}, err
|
|
}
|
|
profile = applyAccountProfilePatch(AccountProfile{}, patch)
|
|
_, err = tx.Exec(s.q(`INSERT INTO users(id,owner,name,phone,email,staff_no,department,notes,created_at,initialization_state) VALUES(?,?,?,?,?,?,?,?,?,?)`),
|
|
newID("usr"), owner, profile.Name, profile.Phone, profile.Email, profile.StaffNo, profile.Department, profile.Notes, nowISO(), InitializationPending)
|
|
if isUniqueViolation(err) {
|
|
return Account{}, ErrConflict
|
|
}
|
|
if err != nil {
|
|
return Account{}, err
|
|
}
|
|
if err := s.bumpAccountDirectoryRevision(tx, true, true); err != nil {
|
|
return Account{}, err
|
|
}
|
|
return s.accountByOwner(tx, owner)
|
|
}
|
|
|
|
func (s *Store) GetAccountDirectoryRevision() (AccountDirectoryRevision, error) {
|
|
var revision AccountDirectoryRevision
|
|
err := s.db.QueryRow(`SELECT public_revision,staff_no_revision,private_revision FROM account_directory_revision WHERE id=1`).Scan(&revision.Public, &revision.StaffNo, &revision.Private)
|
|
return revision, err
|
|
}
|
|
|
|
// GetAccountCursorKey initializes one random signing key in the shared account
|
|
// database. The compare-and-set update lets concurrent Backend instances
|
|
// converge on the same key without exposing it through directory responses.
|
|
func (s *Store) GetAccountCursorKey() (AccountCursorKey, error) {
|
|
var key AccountCursorKey
|
|
var generated [32]byte
|
|
if _, err := rand.Read(generated[:]); err != nil {
|
|
return key, err
|
|
}
|
|
tx, err := s.db.Begin()
|
|
if err != nil {
|
|
return key, err
|
|
}
|
|
defer tx.Rollback()
|
|
if _, err := tx.Exec(s.q(`UPDATE account_directory_revision SET cursor_secret=? WHERE id=1 AND cursor_secret=''`), hex.EncodeToString(generated[:])); err != nil {
|
|
return key, err
|
|
}
|
|
var encoded string
|
|
if err := tx.QueryRow(`SELECT cursor_key_version,cursor_secret FROM account_directory_revision WHERE id=1`).Scan(&key.Version, &encoded); err != nil {
|
|
return key, err
|
|
}
|
|
decoded, err := hex.DecodeString(encoded)
|
|
if err != nil || len(decoded) != len(key.Secret) || key.Version < 1 {
|
|
return AccountCursorKey{}, ErrConflict
|
|
}
|
|
copy(key.Secret[:], decoded)
|
|
if err := tx.Commit(); err != nil {
|
|
return AccountCursorKey{}, err
|
|
}
|
|
return key, nil
|
|
}
|
|
|
|
func newAccountDirectoryRevision() (string, error) {
|
|
var bytes [16]byte
|
|
if _, err := rand.Read(bytes[:]); err != nil {
|
|
return "", err
|
|
}
|
|
return hex.EncodeToString(bytes[:]), nil
|
|
}
|
|
|
|
// Call only after all user locks. Public covers name/identity/valid; StaffNo is
|
|
// composed into a directory version only when that field is authorized. Private
|
|
// changes on every profile edit, without exposing hidden-field edits to callers.
|
|
func (s *Store) bumpAccountDirectoryRevision(tx *sql.Tx, publicChanged, staffNoChanged bool) error {
|
|
private, err := newAccountDirectoryRevision()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
assignments := []string{`private_revision=?`}
|
|
args := []any{private}
|
|
if publicChanged {
|
|
public, err := newAccountDirectoryRevision()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
assignments = append(assignments, `public_revision=?`)
|
|
args = append(args, public)
|
|
}
|
|
if staffNoChanged {
|
|
staffNo, err := newAccountDirectoryRevision()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
assignments = append(assignments, `staff_no_revision=?`)
|
|
args = append(args, staffNo)
|
|
}
|
|
query := `UPDATE account_directory_revision SET ` + strings.Join(assignments, ",") + ` WHERE id=1`
|
|
result, err := tx.Exec(s.q(query), args...)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if n, err := result.RowsAffected(); err != nil {
|
|
return err
|
|
} else if n != 1 {
|
|
return errors.New("store: account directory revision missing")
|
|
}
|
|
return nil
|
|
}
|