Skip to content
quarry Artifact [6e76056d3b]
Login

Artifact [6e76056d3b]

Artifact 6e76056d3b2cc81af0090877f4719e7e3fcf06b423c0e6752f8959fdb0a209ac:


// 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/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?".
type Role int

const (
	NoRole Role = iota
	Read
	Write
	RepoAdmin
)

func (r Role) String() string {
	switch r {
	case Read:
		return "read"
	case Write:
		return "write"
	case RepoAdmin:
		return "admin"
	}
	return "none"
}

// ParseRole is the inverse, for CLI and storage.
func ParseRole(s string) (Role, error) {
	switch s {
	case "read":
		return Read, nil
	case "write":
		return Write, nil
	case "admin":
		return RepoAdmin, nil
	}
	return NoRole, fmt.Errorf("oltp: unknown role %q (want read, write 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
}

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
);
CREATE TABLE IF NOT EXISTS repo_role (
	account TEXT NOT NULL,
	repo    TEXT NOT NULL,
	role    TEXT NOT NULL,           -- read | write | admin
	PRIMARY KEY (account, repo)
);`

// 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")
	}
	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)
}

// 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 FROM account WHERE name = ?`,
		name).Scan(&a.Name, &hash, &admin, &a.Created)
	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
	}
	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 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); 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_role 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.
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)
	}
	_, err := d.db.Exec(`INSERT OR REPLACE INTO repo_role (account, repo, role) VALUES (?, ?, ?)`,
		account, repo, role.String())
	return err
}

// Revoke drops an account's role on a repo.
func (d *DB) Revoke(account, repo string) error {
	_, err := d.db.Exec(`DELETE FROM repo_role WHERE account = ? AND repo = ?`, account, repo)
	return err
}

// RoleOf returns the account's effective role on a repo. A site admin
// holds RepoAdmin everywhere, which is what "site admin" means.
func (d *DB) RoleOf(account Account, repo string) Role {
	if account.Admin {
		return RepoAdmin
	}
	var s string
	if err := d.db.QueryRow(`SELECT role FROM repo_role WHERE account = ? AND repo = ?`,
		account.Name, repo).Scan(&s); err != nil {
		return NoRole
	}
	r, err := ParseRole(s)
	if err != nil {
		return NoRole
	}
	return r
}

// GrantsFor lists an account's per-repo roles, repo-ordered.
func (d *DB) GrantsFor(account string) (map[string]Role, error) {
	rows, err := d.db.Query(`SELECT repo, role FROM repo_role WHERE account = ?`, account)
	if err != nil {
		return nil, err
	}
	defer rows.Close()
	out := map[string]Role{}
	for rows.Next() {
		var repo, role string
		if err := rows.Scan(&repo, &role); err != nil {
			return nil, err
		}
		r, err := ParseRole(role)
		if err != nil {
			continue
		}
		out[repo] = r
	}
	return out, rows.Err()
}

// 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 FROM account WHERE name = ?`,
		name).Scan(&a.Name, &admin, &a.Created); err != nil {
		return Account{}, false
	}
	a.Admin = admin != 0
	return a, true
}