Skip to content
quarry Artifact [b805c3ec43]
Login

Artifact [b805c3ec43]

Artifact b805c3ec43c35b64262798f3b7627a087785514d138d8e0b150da16f14b6051c:


// 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
	// 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.
	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.db.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
	}
	res, err := d.db.Exec(`UPDATE account SET pwhash = ? 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)
}

// 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.db.Exec(`UPDATE account SET pwhash = ? 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.
		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
			}
			if err := d.ClearLegacyCredential(name); 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, pwhash = ''
		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.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.db.Exec(`DELETE FROM repo_caps WHERE account = ?`, name); err != nil {
		return err
	}
	_, err := d.db.Exec(`DELETE FROM account WHERE name = ?`, name)
	return err
}

// Grant sets one account's role on one repo, replacing any current role.
// 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.
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. Site
// admins are RepoAdmin everywhere. For a gate, ask MayAt instead — a
// role is a reading, not a permission.
func (d *DB) RoleOf(account Account, repo string) Role {
	if account.Admin {
		return RepoAdmin
	}
	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.
func (d *DB) MayAt(account Account, repo string, needAdmin bool) bool {
	if account.Admin {
		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
// (MayAt) 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) {
	var a Account
	var admin int
	if err := d.db.QueryRow(`SELECT name, admin, created, email, email_pending, disabled, last_login, pwhash = ''
		FROM account WHERE name = ?`,
		name).Scan(&a.Name, &admin, &a.Created, &a.Email, &a.EmailPending, &a.Disabled, &a.LastLogin, &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 (the old one keeps
// working until it expires; a lost email is the common case).
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 = '' FROM account WHERE name = ?`, name).Scan(&pending)
	switch {
	case errors.Is(err, sql.ErrNoRows):
		if _, err := d.db.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.db.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[:])
	_, err = d.db.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.
func (d *DB) RedeemInvite(token, password string) (name string, err error) {
	name, ok := d.InviteFor(token)
	if !ok {
		return "", errors.New("oltp: this invite is not valid any more")
	}
	if err := d.SetPassword(name, password); err != nil {
		return "", err
	}
	_, err = d.db.Exec(`UPDATE invite SET used = ? WHERE token = ?`,
		time.Now().UTC().Format(time.RFC3339), hashToken(token))
	return name, err
}

// 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.db.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.db.Exec(`UPDATE account SET email = ? WHERE name = ?`, email, name)
	return err
}