// 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
)
func (r Role) String() string {
switch r {
case Read:
return "reader"
case Write:
return "developer"
case Maintain:
return "maintainer"
case RepoAdmin:
return "admin"
}
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
);
`
// 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)
}
// 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()
}
// DeleteAccount removes the account and every role it held.
func (d *DB) DeleteAccount(name string) error {
if _, err := d.w.Exec(`DELETE FROM repo_caps WHERE account = ?`, name); err != nil {
return err
}
_, err := d.w.Exec(`DELETE FROM account WHERE name = ?`, name)
return err
}
// 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)
}
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), 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
}
rd := Infer(caps)
for r := Read; r <= RepoAdmin; r++ {
if r.Preset() == rd.Role {
return r
}
}
return NoRole // Custom: no single role names it
}
// 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
}
}
return NoRole
}
// 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)
}
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
}
// 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 = ?`,
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 > ?`,
now, hashToken(token), now)
if err != nil {
return "", err
}
if n, _ := res.RowsAffected(); n != 1 {
return "", spent
}
var kind string
if err := tx.QueryRow(`SELECT account, kind FROM invite WHERE token = ?`, hashToken(token)).Scan(&name, &kind); err != nil {
return "", err
}
// An invite sets the FIRST password and nothing after it. Checked
// in the write itself, so no claim can land between the check and
// the UPDATE: once the owner has a credential, a link the admin
// still holds changes nothing (#319).
claimable := `1`
if kind != "reset" {
claimable = `pwhash = '' AND legacy_pw = ''`
}
// 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 = ? AND `+claimable, hash, name)
if err != nil {
return "", err
}
if n, _ := res.RowsAffected(); n == 0 {
return "", spent
}
// 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
}