Skip to content
quarry Artifact [61983c8861]
Login

Artifact [61983c8861]

Artifact 61983c886112e0313a79fe241a4621b75d7444a28cc9a327d0d1714646dd24c0:


// 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?".
// 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
}

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
);
`

// 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_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()
}

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