Artifact e14bc9111cec7a96b56daef56e807afc2bcd2f8665e63dac61384d72d018f345:
- File
internal/oltp/account.go
— part of check-in
[6ddc78b549]
at
2026-10-03 00:48:35
on branch trunk
— land admins-send-reset-links
Checks tested the merge with trunk at 83aec99ff875; trunk has moved since. (user: dmestas, size: 28069) [annotate] [blame] [check-ins using] [more...]
// account.go is 0001 §8's v2 growth: accounts and per-repo roles. §8 // pinned the architecture in v1 ("role granularity is v2 and adds tables, // not architecture") — this is those tables. // // Accounts are the web/API identity. They are NOT the sync protocol's // credential, which stays in sync_user because RFC §6.3 dictates its // derivation (see password.go). One account may own a sync_user row per // repo, which is what makes a push attributable to a person. package oltp import ( "crypto/rand" "crypto/sha1" "crypto/subtle" "database/sql" "encoding/hex" "errors" "fmt" "sort" "strings" "time" ) // Role is a repo-scoped capability. Ordered: each contains the ones // before it, so a comparison answers "is this enough?". // Role is the CLI and API vocabulary for a preset (RFC 0008). It is // never stored: Grant writes the preset's cap string and RoleOf reads // it back by matching. The legacy names read, write and admin are kept // as spellings of Reader, Developer and Admin. type Role int const ( NoRole Role = iota Read // Reader Write // Developer Maintain // Maintainer RepoAdmin // Admin // Custom is a string no preset is near. It is not an ordering step: // it sits past Admin only so the preset loops stop before it. Custom ) func (r Role) String() string { switch r { case Read: return "reader" case Write: return "developer" case Maintain: return "maintainer" case RepoAdmin: return "admin" case Custom: return "custom" } return "none" } // Preset is the RFC 0008 preset a role names. func (r Role) Preset() string { switch r { case Read: return "Reader" case Write: return "Developer" case Maintain: return "Maintainer" case RepoAdmin: return "Admin" } return "" } // Caps is the string a role writes. func (r Role) Caps() string { for _, p := range Presets { if p.Name == r.Preset() { return p.Caps } } return "" } // ParseRole accepts both vocabularies: the legacy read/write/admin and // the preset names. func ParseRole(s string) (Role, error) { switch strings.ToLower(s) { case "read", "reader": return Read, nil case "write", "developer": return Write, nil case "maintain", "maintainer": return Maintain, nil case "admin": return RepoAdmin, nil } return NoRole, fmt.Errorf("oltp: unknown role %q (want reader, developer, maintainer or admin)", s) } // Account is one login. The verifier never leaves the database. type Account struct { Name string Admin bool // site admin: every repo, every capability Created string // Operator names the person answerable for a MACHINE-OPERATED // account; empty means a person's own (agent.go). Operator string // Email is where invites and resets go; '' when never given. This // is the CONFIRMED address — an admin or an invite may set it // directly (that trust is the same as typing a password for // someone), but a person changing their own sets EmailPending // instead until they prove receipt (email.go). Email string // EmailPending is an address typed but not yet confirmed; '' when // there is none in flight. It never receives account mail — Email // does, until this replaces it. EmailPending string // Disabled is when an admin shut the account, '' while open. A // disabled account cannot sign in and its sessions are gone. Disabled string // Pending: invited, never claimed — no password yet, native or // imported from fossil, so it cannot sign in at all. Pending bool // LastLogin is the last successful sign-in, '' if never. LastLogin string } const accountSchema = ` CREATE TABLE IF NOT EXISTS account ( name TEXT PRIMARY KEY, pwhash TEXT NOT NULL, -- argon2id PHC string; plaintext never stored admin INTEGER NOT NULL DEFAULT 0, -- site admin created TEXT NOT NULL ); ` + tombstoneSchema // validLogin is the closed account-name alphabet: word characters plus // . - _, led by a letter or digit. func validLogin(name string) bool { for i, r := range name { switch { case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9': case (r == '.' || r == '-' || r == '_') && i > 0: default: return false } } return true } // AddAccount creates an account, hashing the password. Fails if the name // is taken — silently replacing a login would be an account takeover. func (d *DB) AddAccount(name, password string, admin bool) error { if name == "" { return errors.New("oltp: empty account name") } // Audit follow-up (ticket ed5bdd89c39a): a login lives in URLs, // templates and one hand-built Datastar attribute; a closed // alphabet costs a new account nothing and removes a whole class // of "what if a name contains X" questions everywhere it appears. if !validLogin(name) { return fmt.Errorf("oltp: account name %q: use letters, digits, '.', '-' or '_', starting with a letter or digit", name) } if reservedRunName(name) { return runNameRefusal(name) } if d.Tombstoned(name) { return tombstoneRefusal(name) } // Accounts and teams share one namespace (RFC 0018): a grant row // names a string, and if that string could be either the forge // would have to guess on every capability resolution. The check // runs both ways — AddTeam refuses an account's name. if d.TeamNameTaken(name) { return fmt.Errorf("oltp: a team is already called %q", name) } // A repository's name too. Handles and team slugs share one pool // and either can be the first segment of a namespaced path, so the // same reasoning as AddTeam's repository check applies: an account // name that collides with a repository would make an existing URL // ambiguous, and the routing rule should not have to be clever // about which registry won. if d.RepoNameTaken(name) { return fmt.Errorf("oltp: a repository is already called %q", name) } hash, err := HashPassword(password) if err != nil { return err } _, err = d.w.Exec(`INSERT INTO account (name, pwhash, admin, created) VALUES (?, ?, ?, ?)`, name, hash, boolInt(admin), time.Now().UTC().Format(time.RFC3339)) if err != nil && strings.Contains(err.Error(), "UNIQUE") { return fmt.Errorf("oltp: account %q already exists", name) } return err } // SetPassword replaces an account's verifier. func (d *DB) SetPassword(name, password string) error { hash, err := HashPassword(password) if err != nil { return err } // The imported fossil credential goes with the old verifier: left // behind, the old fossil password would still sign in, and // Authenticate's upgrade would write it over this one. res, err := d.w.Exec(`UPDATE account SET pwhash = ?, legacy_pw = '', legacy_code = '' WHERE name = ?`, hash, name) if err != nil { return err } if n, _ := res.RowsAffected(); n == 0 { return fmt.Errorf("oltp: no such account %q", name) } // Changing a password is how someone responds to a credential they // think is compromised. Leaving existing sessions valid would make // that response do nothing about the browser already holding one. return d.EndAccountSessions(name) } // SetPasswordJournalled is SetPassword with its forge_act entry written // in the same transaction: the host operator's recovery (#319) either // lands with its record or does not land. func (d *DB) SetPasswordJournalled(name, password, actor, op string) error { hash, err := HashPassword(password) if err != nil { return err } tx, err := d.w.Begin() if err != nil { return err } defer tx.Rollback() res, err := tx.Exec(`UPDATE account SET pwhash = ?, legacy_pw = '', legacy_code = '' WHERE name = ?`, hash, name) if err != nil { return err } if n, _ := res.RowsAffected(); n == 0 { return fmt.Errorf("oltp: no such account %q", name) } if _, err := tx.Exec(`INSERT INTO forge_act (at, actor, op, repo, owner) VALUES (?, ?, ?, '', ?)`, time.Now().UTC().Format(time.RFC3339), actor, op, name); err != nil { return fmt.Errorf("oltp: journal: %w", err) } if err := endAccountSessions(tx, name); err != nil { return err } return tx.Commit() } // VerifyPassword reports whether password is name's CURRENT one, the // same verifier Authenticate itself calls — the seam a self-service // password change checks its "current password" field against, without // handing the caller a session or anything else Authenticate produces. func (d *DB) VerifyPassword(name, password string) bool { _, ok := d.Authenticate(name, password) return ok } // SetPasswordSelf is SetPassword aimed at the account making the // change itself: it replaces the verifier and then ends every OTHER // live session of the account, sparing the one named by keep (an audit // id from SessionID) — the browser running the change stays signed in, // where an admin's SetPassword has no such session to spare and ends // all of them. keep == "" behaves exactly like SetPassword. func (d *DB) SetPasswordSelf(name, password, keep string) error { hash, err := HashPassword(password) if err != nil { return err } res, err := d.w.Exec(`UPDATE account SET pwhash = ?, legacy_pw = '', legacy_code = '' WHERE name = ?`, hash, name) if err != nil { return err } if n, _ := res.RowsAffected(); n == 0 { return fmt.Errorf("oltp: no such account %q", name) } return d.EndAccountSessionsExcept(name, keep) } // Authenticate reports whether the password matches, and returns the // account. A missing account still pays for a hash comparison, so // probing for valid names by timing gets nothing. func (d *DB) Authenticate(name, password string) (Account, bool) { var a Account var hash string var admin int err := d.db.QueryRow(`SELECT name, pwhash, admin, created, email, disabled, last_login FROM account WHERE name = ?`, name).Scan(&a.Name, &hash, &admin, &a.Created, &a.Email, &a.Disabled, &a.LastLogin) if errors.Is(err, sql.ErrNoRows) { // Same work as a real verification, so a nonexistent account is // not distinguishable from a wrong password by response time. VerifyPassword(decoyHash, password) return Account{}, false } if err != nil { return Account{}, false } // Shut: same cost as a wrong password, so the state is not // distinguishable from outside. An unclaimed invite has no hash and // no legacy credential, so it falls through the checks below. if a.Disabled != "" { VerifyPassword(decoyHash, password) return Account{}, false } if !VerifyPassword(hash, password) { // An imported fossil user has no native verifier yet — only the // credential their old server held (RFC 0004 §6). Accept it // once, then upgrade: the account gets a real argon2 verifier // and the imported secret is dropped, so the fossil hash never // outlives its usefulness. SetPassword drops it. if legacy, code, ok := d.LegacyCredential(name); ok && subtle.ConstantTimeCompare([]byte(legacy), []byte(legacySecret(code, name, password))) == 1 { if err := d.SetPassword(name, password); err != nil { return Account{}, false } a.Admin = admin != 0 return a, true } return Account{}, false } a.Admin = admin != 0 return a, true } // legacySecret is the sync protocol's shared-secret derivation // (RFC §6.3): SHA1(project-code/login/password), 40 lowercase hex. It // is spelled here rather than imported so storage does not depend on // transport; xfer.SharedSecret is its twin and a test pins the two // together so they cannot drift apart. func legacySecret(projectCode, login, password string) string { sum := sha1.Sum([]byte(projectCode + "/" + login + "/" + password)) return hex.EncodeToString(sum[:]) } // decoyHash is a real argon2id verifier for a password nobody has; it // exists only to give Authenticate constant work on a missing account. var decoyHash = func() string { h, _ := HashPassword("decoy: no account has this password") return h }() // HasAccounts is the policy switch, mirroring Gated for sync users: with // no accounts the web/API gate stays open (single-user local serving), // and the first account closes it. func (d *DB) HasAccounts() bool { var n int if err := d.db.QueryRow(`SELECT count(*) FROM account`).Scan(&n); err != nil { return false } return n > 0 } // Accounts lists every account, name-ordered. func (d *DB) Accounts() ([]Account, error) { rows, err := d.db.Query(`SELECT name, admin, created, email, email_pending, disabled, last_login, operator, pwhash = '' AND legacy_pw = '' FROM account ORDER BY name`) if err != nil { return nil, err } defer rows.Close() var out []Account for rows.Next() { var a Account var admin int if err := rows.Scan(&a.Name, &admin, &a.Created, &a.Email, &a.EmailPending, &a.Disabled, &a.LastLogin, &a.Operator, &a.Pending); err != nil { return nil, err } a.Admin = admin != 0 out = append(out, a) } return out, rows.Err() } // tombstoneSchema remembers every deleted account name. A name that // ever belonged to a person never belongs to anyone else: rows keyed by // it (repositories, tokens, grants, mentions) must not reattach to a // successor. const tombstoneSchema = ` CREATE TABLE IF NOT EXISTS account_tombstone ( name TEXT PRIMARY KEY, deleted TEXT NOT NULL );` // Tombstoned reports whether name belonged to an account that was // deleted. Case does not matter: deleting "bob" also retires "Bob", so // nobody can pose as the deleted person. func (d *DB) Tombstoned(name string) bool { var one int return d.db.QueryRow(`SELECT 1 FROM account_tombstone WHERE name = ? COLLATE NOCASE`, name).Scan(&one) == nil } func tombstoneRefusal(name string) error { return Refusal{fmt.Sprintf("%q belonged to a deleted account and can never be used again", name)} } // accountOwnedTables are the tables whose rows exist only for one // account, keyed by the named column. Deleting the account deletes // them: left behind, a successor of the same name would inherit them. var accountOwnedTables = [][2]string{ {"repo_caps", "account"}, {"token", "account"}, {"session", "account"}, {"team_member", "account"}, {"invite", "account"}, {"email_token", "account"}, {"sync_user", "user"}, {"push_device", "account"}, {"account_pref", "account"}, {"forum_sub", "account"}, {"landing_auto", "account"}, {"artifact_carrier", "login"}, } // DeleteAccount removes the account, every role, credential and // pending token it held, and tombstones the name. It is refused while // the account owns repositories or is a team's only owner: transfer those first. func (d *DB) DeleteAccount(name string) error { return d.guarded(func(tx *sql.Tx) error { if _, ok := accountByName(tx, name); !ok { return fmt.Errorf("oltp: %q: %w", name, ErrNoAccount) } var n int if err := tx.QueryRow(`SELECT count(*) FROM repo WHERE owner = ?`, name).Scan(&n); err != nil { return err } if n > 0 { return Refusal{fmt.Sprintf("%q still owns %d repositories; transfer them before deleting the account", name, n)} } var teams int if err := tx.QueryRow(`SELECT count(*) FROM team_member m WHERE m.account = ? AND m.owner = 1 AND (SELECT count(*) FROM team_member o WHERE o.team = m.team AND o.owner = 1) = 1`, name).Scan(&teams); err != nil { return err } if teams > 0 { return Refusal{fmt.Sprintf("%q is the only owner of %d teams; name another owner before deleting the account", name, teams)} } for _, t := range accountOwnedTables { if !tableIn(tx, t[0]) { continue } if _, err := tx.Exec(`DELETE FROM `+t[0]+` WHERE `+t[1]+` = ?`, name); err != nil { return err } } if syncTokenTableIn(tx) { if _, err := tx.Exec(`DELETE FROM sync_token WHERE user = ?`, name); err != nil { return err } } if _, err := tx.Exec(`UPDATE fossil_user SET linked = '' WHERE linked = ?`, name); err != nil { return err } if _, err := tx.Exec(`DELETE FROM account WHERE name = ?`, name); err != nil { return err } _, err := tx.Exec(`INSERT OR IGNORE INTO account_tombstone (name, deleted) VALUES (?, ?)`, name, time.Now().UTC().Format(time.RFC3339)) return err }) } // tableIn reports whether the named table exists. func tableIn(q interface { QueryRow(string, ...any) *sql.Row }, name string) bool { var n int return q.QueryRow(`SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?`, name).Scan(&n) == nil } // Grant writes a role's cap string for an account on a repository — // replacing whatever string was there, because that is what granting a // role means. NoRole revokes. // // Unguarded, for the operator's own shell (quarry account grant) and // for seeding: there is no signed-in actor to judge, and the operator // bootstrapping a store's first admin is exactly the change a guard // has nothing to say about. A change a person makes through the forge // is GrantGuarded. func (d *DB) Grant(account, repo string, role Role) error { if role == NoRole { return d.Revoke(account, repo) } if role == Custom { return errors.New("oltp: custom is a reading, not a role to grant") } var n int if err := d.db.QueryRow(`SELECT count(*) FROM account WHERE name = ?`, account).Scan(&n); err != nil { return err } if n == 0 { return fmt.Errorf("oltp: no such account %q", account) } return d.SetCaps(account, repo, role.Caps()) } // Revoke removes an account's explicit string on a repository. The // categories still apply: revoking is "back to what any signed-in // account may do", not "nothing". func (d *DB) Revoke(account, repo string) error { return d.SetCaps(account, repo, "") } // RoleOf reads an account's explicit string on a repository as a role: // the preset it matches exactly, or the nearest one when the difference // is small (RFC 0008's Infer), Custom when no preset is near, or NoRole // for no string at all. A site admin's role is read the same way: the // flag is not a role on any repository (#318). For a gate, ask MayAt // instead — a role is a reading, not a permission. func (d *DB) RoleOf(account Account, repo string) Role { caps, err := d.CapsOf(account.Name, repo) if err != nil || caps == "" { return NoRole } return roleFromCaps(caps) } // MayAt is the gate the web verifier and the session plane ask: does // this account's EFFECTIVE set — categories expanded — meet the floor. // ops.Token is "signed in", and the op's own letters do the rest, which // is RFC 0004's model: an account with no explicit string still holds // what the anonymous category grants. ops.Admin is the a letter, which // s implies. A site admin holds s at forge scope only; at a repository // the floor is their grants, as for anyone (#318). func (d *DB) MayAt(account Account, repo string, needAdmin bool) bool { if account.Admin && repo == fleetRepo { return true } if !needAdmin { return true } return d.EffectiveCaps(account.Name, repo).HasAny("a") } // GrantsFor lists the repositories an account holds an explicit string // on, read as roles ("custom" when no single role names the string). func (d *DB) GrantsFor(account string) (map[string]Role, error) { rows, err := d.db.Query(`SELECT repo, caps FROM repo_caps WHERE account = ? AND caps <> ''`, account) if err != nil { return nil, err } defer rows.Close() out := map[string]Role{} for rows.Next() { var repo, caps string if err := rows.Scan(&repo, &caps); err != nil { return nil, err } out[repo] = roleFromCaps(caps) } return out, rows.Err() } // OwnedRepoCount counts the repositories where account holds an // explicit admin grant. This is the creation quota's unit — "how many // repositories does this account already administer here" — not how // many it can merely read; a site admin bypasses the quota entirely // and never needs this count. func (d *DB) OwnedRepoCount(account string) (int, error) { var n int // The quota counts what an account OWNS, not what it can reach. // Being made admin on somebody else's repository is not a draw on // your own allowance, and before the owner column existed this // could only be approximated by counting `a` grants. err := d.db.QueryRow(`SELECT COUNT(*) FROM repo WHERE owner = ?`, account).Scan(&n) return n, err } // roleFromCaps is RoleOf for a string already in hand. func roleFromCaps(caps string) Role { rd := Infer(caps) for r := Read; r <= RepoAdmin; r++ { if r.Preset() == rd.Role { return r } } if caps == "" { return NoRole } return Custom } // SortedRepos is a small helper so callers render grants deterministically. func SortedRepos(m map[string]Role) []string { out := make([]string, 0, len(m)) for k := range m { out = append(out, k) } sort.Strings(out) return out } func boolInt(b bool) int { if b { return 1 } return 0 } // AccountByName looks an account up without a password — for a caller // that has already authenticated by another means (a session cookie). func (d *DB) AccountByName(name string) (Account, bool) { return accountByName(d.db, name) } func accountByName(q querier, name string) (Account, bool) { var a Account var admin int if err := q.QueryRow(`SELECT name, admin, created, email, email_pending, disabled, last_login, operator, pwhash = '' AND legacy_pw = '' FROM account WHERE name = ?`, name).Scan(&a.Name, &admin, &a.Created, &a.Email, &a.EmailPending, &a.Disabled, &a.LastLogin, &a.Operator, &a.Pending); err != nil { return Account{}, false } a.Admin = admin != 0 return a, true } // InviteTTL is how long an invite link stays good. const InviteTTL = 7 * 24 * time.Hour const inviteSchema = ` CREATE TABLE IF NOT EXISTS invite ( token TEXT PRIMARY KEY, -- SHA-256 of the link's token, never the token account TEXT NOT NULL, created TEXT NOT NULL, expires TEXT NOT NULL, used TEXT NOT NULL DEFAULT '' ); CREATE INDEX IF NOT EXISTS invite_by_account ON invite (account);` // InviteAccount creates an account with no password and mints the // one-time token that lets its owner set one. The account exists at // once — grants can name it, the people page lists it as pending — but // nothing can sign in as it until the invite is redeemed. Inviting an // existing pending account again mints a fresh token and drops the old // one, as ResetPassword does: one live link per account, so a re-send // cannot leave a second credential in the admin's hands. func (d *DB) InviteAccount(name, email string, admin bool) (token string, err error) { if name == "" { return "", errors.New("oltp: empty account name") } if !validLogin(name) { return "", fmt.Errorf("oltp: account name %q: use letters, digits, '.', '-' or '_', starting with a letter or digit", name) } if reservedRunName(name) { return "", runNameRefusal(name) } if d.Tombstoned(name) { return "", tombstoneRefusal(name) } now := time.Now().UTC() var pending bool err = d.db.QueryRow(`SELECT pwhash = '' AND legacy_pw = '' FROM account WHERE name = ?`, name).Scan(&pending) switch { case errors.Is(err, sql.ErrNoRows): if _, err := d.w.Exec(`INSERT INTO account (name, pwhash, admin, created, email) VALUES (?, '', ?, ?, ?)`, name, boolInt(admin), now.Format(time.RFC3339), email); err != nil { return "", err } case err != nil: return "", err case !pending: return "", fmt.Errorf("oltp: account %q already exists and has a password", name) default: if _, err := d.w.Exec(`UPDATE account SET email = ?, admin = ? WHERE name = ?`, email, boolInt(admin), name); err != nil { return "", err } } var buf [32]byte if _, err := rand.Read(buf[:]); err != nil { return "", err } token = hex.EncodeToString(buf[:]) if _, err := d.w.Exec(`DELETE FROM invite WHERE account = ? AND used = ''`, name); err != nil { return "", err } _, err = d.w.Exec(`INSERT INTO invite (token, account, created, expires) VALUES (?, ?, ?, ?)`, hashToken(token), name, now.Format(time.RFC3339), now.Add(InviteTTL).Format(time.RFC3339)) return token, err } // tokenKindMatchesAccount is the SQL that ties a token's kind to its // account's state: an invite is the way into an account with no // credential at all, a reset the way back into one that has one — a // native password or an imported fossil credential. Without it an // invite left over from a re-send, or a reset left over from before the // account was blanked, would overwrite the account's credential (#319). // It expects the invite table unaliased as `invite`. const tokenKindMatchesAccount = `EXISTS (SELECT 1 FROM account WHERE account.name = invite.account AND ((invite.kind = 'invite' AND account.pwhash = '' AND account.legacy_pw = '') OR (invite.kind = 'reset' AND (account.pwhash != '' OR account.legacy_pw != ''))))` // InviteFor names the account a live invite token belongs to, or // reports that the token is unknown, spent, or expired. func (d *DB) InviteFor(token string) (name string, ok bool) { var used, expires string if err := d.db.QueryRow(`SELECT account, used, expires FROM invite WHERE token = ? AND `+tokenKindMatchesAccount, hashToken(token)).Scan(&name, &used, &expires); err != nil { return "", false } exp, err := time.Parse(time.RFC3339, expires) if used != "" || err != nil || time.Now().UTC().After(exp) { return "", false } return name, true } // RedeemInvite sets the account's password from a live invite and spends // the token. The account is open from this moment. // // Spending the token is the decision, not a read before it: the // conditional UPDATE runs first in the transaction, so of two // redemptions racing on one link exactly one changes the row and sets // its password; the other finds the token spent and changes nothing. func (d *DB) RedeemInvite(token, password string) (name string, err error) { spent := errors.New("oltp: this invite is not valid any more") // Hashed outside the transaction: it is the slow part, and holding // the write lock through it would stall every other writer. hash, err := HashPassword(password) if err != nil { return "", err } tx, err := d.w.Begin() if err != nil { return "", err } defer tx.Rollback() now := time.Now().UTC().Format(time.RFC3339) res, err := tx.Exec(`UPDATE invite SET used = ? WHERE token = ? AND used = '' AND expires > ? AND `+tokenKindMatchesAccount, now, hashToken(token), now) if err != nil { return "", err } if n, _ := res.RowsAffected(); n != 1 { return "", spent } if err := tx.QueryRow(`SELECT account FROM invite WHERE token = ?`, hashToken(token)).Scan(&name); err != nil { return "", err } // The imported fossil credential goes too: left behind, it would // still sign in with the password this link exists to replace. res, err = tx.Exec(`UPDATE account SET pwhash = ?, legacy_pw = '', legacy_code = '' WHERE name = ?`, hash, name) if err != nil { return "", err } if n, _ := res.RowsAffected(); n == 0 { return "", fmt.Errorf("oltp: no such account %q", name) } // Every other link the account has dies with this one, whichever // kind: the password it would set has just been chosen. if _, err := tx.Exec(`UPDATE invite SET used = ? WHERE account = ? AND used = ''`, now, name); err != nil { return "", err } // SetPassword's session sweep, inside the same transaction. if err := endAccountSessions(tx, name); err != nil { return "", err } return name, tx.Commit() } // SetDisabled shuts an account (or reopens it). Shutting ends every // session it holds, so the lockout is immediate rather than at the // next sign-in; the account, its grants and its history stay. func (d *DB) SetDisabled(name string, on bool) error { var exists int if err := d.db.QueryRow(`SELECT count(*) FROM account WHERE name = ?`, name).Scan(&exists); err != nil { return err } if exists == 0 { return fmt.Errorf("oltp: no account %q", name) } when := "" if on { when = time.Now().UTC().Format(time.RFC3339) } if _, err := d.w.Exec(`UPDATE account SET disabled = ? WHERE name = ?`, when, name); err != nil { return err } if on { return d.EndAccountSessions(name) } return nil } // SetEmail records where an account is reachable. func (d *DB) SetEmail(name, email string) error { _, err := d.w.Exec(`UPDATE account SET email = ? WHERE name = ?`, email, name) return err }