Skip to content
quarry Artifact [ea8b6e73df]
Login

Artifact [ea8b6e73df]

Artifact ea8b6e73dfdd5d414d07689dd9b75339b501575ffce25966d187bd8f3957a76a:


// caps.go — fossil's capability model, quarry-native (RFC 0004). An
// account's authority on a repository is an unordered, case-sensitive
// string of fossil capability letters, expanded through fossil's four
// user categories. The expansion rules are fossil's exactly, including
// the deliberate trap: v (developer) does NOT imply u (reader).
//
// Letters quarry has no surface for yet are stored and inert: imports
// stay lossless, and a letter starts working the day its surface ships,
// with no migration.
package oltp

import "strings"

const capsSchema = `
CREATE TABLE IF NOT EXISTS repo_caps (
	account TEXT NOT NULL,
	repo    TEXT NOT NULL,
	caps    TEXT NOT NULL, -- fossil cap string, verbatim
	PRIMARY KEY (account, repo)
);
CREATE TABLE IF NOT EXISTS repo_category_caps (
	repo     TEXT NOT NULL,
	category TEXT NOT NULL, -- nobody | anonymous | reader | developer
	caps     TEXT NOT NULL,
	PRIMARY KEY (repo, category)
);`

// accountMigrations: legacy_pw carries an imported fossil user's RFC
// §6.3 hash until their first successful login rehashes to argon2.
// legacy_code carries the project code that hash was derived under —
// the derivation is SHA1(project-code/login/password), so the hash is
// meaningless without it, and quarry accounts are forge-global while
// fossil's are per-repository.
var accountMigrations = []string{
	`ALTER TABLE account ADD COLUMN legacy_pw TEXT NOT NULL DEFAULT ''`,
	`ALTER TABLE account ADD COLUMN legacy_code TEXT NOT NULL DEFAULT ''`,
	// Private-beta machinery: who a person is reachable as, whether an
	// admin has shut the door, and when they last signed in. An account
	// with an EMPTY pwhash is an unclaimed invite: it exists so grants can
	// name it, and it cannot sign in until its invite is redeemed.
	`ALTER TABLE account ADD COLUMN email TEXT NOT NULL DEFAULT ''`,
	`ALTER TABLE account ADD COLUMN disabled TEXT NOT NULL DEFAULT ''`,
	`ALTER TABLE account ADD COLUMN last_login TEXT NOT NULL DEFAULT ''`,
}

// ImportedUser is one row arriving from a fossil peer or file.
type ImportedUser struct {
	Login  string
	Caps   string
	Info   string
	PWHash string
}

// ImportUsers writes a peer's user table into this forge (RFC 0004 §6).
//
// The collision rule is deliberately conservative: an account that
// already exists here keeps ITS credential — an import must never be a
// way to take over a login — while its capabilities on this repository
// are replaced by the peer's, because that is the fact being imported.
// A login quarry has never seen is created with the peer's §6.3 hash
// so its owner can sign in with the password they already have.
//
// Category rows (nobody/anonymous/reader/developer) carry the peer's
// category capabilities; fossil only sends rows it has recorded, so a
// category the peer omits keeps quarry's stock default rather than
// being blanked into a lockout.
func (d *DB) ImportUsers(repo, projectCode string, users []ImportedUser) (created, updated int, err error) {
	categories := map[string]bool{
		"nobody": true, "anonymous": true, "reader": true, "developer": true,
	}
	for _, u := range users {
		if u.Login == "" {
			continue
		}
		if categories[u.Login] {
			if err := d.SetCategoryCaps(repo, u.Login, u.Caps); err != nil {
				return created, updated, err
			}
			updated++
			continue
		}
		_, exists := d.AccountByName(u.Login)
		if !exists {
			if _, err := d.db.Exec(`INSERT INTO account (name, pwhash, admin, created, legacy_pw, legacy_code)
				VALUES (?, '', 0, ?, ?, ?)`,
				u.Login, now(), u.PWHash, projectCode); err != nil {
				return created, updated, err
			}
			created++
		} else {
			updated++
		}
		if err := d.SetCaps(u.Login, repo, u.Caps); err != nil {
			return created, updated, err
		}
	}
	return created, updated, nil
}

// LegacyCredential returns an account's imported §6.3 hash and the
// project code it was derived under; ok is false once the account has
// a native credential or never had an imported one.
func (d *DB) LegacyCredential(account string) (hash, projectCode string, ok bool) {
	err := d.db.QueryRow(`SELECT legacy_pw, legacy_code FROM account WHERE name = ?`,
		account).Scan(&hash, &projectCode)
	return hash, projectCode, err == nil && hash != ""
}

// ClearLegacyCredential drops the imported hash once the account has
// been given a native one — the imported secret should not outlive its
// usefulness.
func (d *DB) ClearLegacyCredential(account string) error {
	_, err := d.db.Exec(`UPDATE account SET legacy_pw = '', legacy_code = '' WHERE name = ?`, account)
	return err
}

// Stock category defaults — fossil's shipped values (caps-index §ucat).
const (
	StockNobody    = "gjorz"
	StockAnonymous = "hmnc"
	StockReader    = "kptw"
	StockDeveloper = "ei"
)

// allCaps is the closed fossil vocabulary, for s-expansion display; the
// letters d, u, v, L are meta/legacy and excluded from concrete grants.
const allCaps = "abcefghijklmnopqrstwxyz234567ABCD"

// CapSet is an expanded, concrete capability set. Always produced by
// EffectiveCaps — never construct one from raw stored strings, which
// are unexpanded.
type CapSet string

// HasAny reports whether the set holds any of the asked letters. The
// two umbrella caps resolve here: s grants everything; a grants
// everything except s, x, y (fossil's Admin-vs-Setup rule).
func (c CapSet) HasAny(letters string) bool {
	for _, l := range letters {
		if strings.ContainsRune(string(c), l) {
			return true
		}
		if strings.ContainsRune(string(c), 's') {
			return true
		}
		if strings.ContainsRune(string(c), 'a') && !strings.ContainsRune("sxy", l) {
			return true
		}
	}
	return false
}

// capUnion merges cap strings, deduplicated, order-free.
func capUnion(sets ...string) string {
	var b strings.Builder
	seen := map[rune]bool{}
	for _, s := range sets {
		for _, r := range s {
			if !seen[r] {
				seen[r] = true
				b.WriteRune(r)
			}
		}
	}
	return b.String()
}

// SetCaps stores (replacing) one account's explicit caps on a repo.
func (d *DB) SetCaps(account, repo, caps string) error {
	if caps == "" {
		_, err := d.db.Exec(`DELETE FROM repo_caps WHERE account = ? AND repo = ?`, account, repo)
		return err
	}
	_, err := d.db.Exec(`INSERT INTO repo_caps (account, repo, caps) VALUES (?, ?, ?)
		ON CONFLICT (account, repo) DO UPDATE SET caps = excluded.caps`, account, repo, caps)
	return err
}

// CapsOf returns the stored, UNEXPANDED cap string ("" when none).
func (d *DB) CapsOf(account, repo string) (string, error) {
	var caps string
	err := d.db.QueryRow(`SELECT caps FROM repo_caps WHERE account = ? AND repo = ?`,
		account, repo).Scan(&caps)
	if err != nil {
		return "", nil
	}
	return caps, nil
}

// SetCategoryCaps overrides one category's caps for one repo. An empty
// string is a REAL value — fossil "takes a repo private" by blanking
// nobody and anonymous, so absence and emptiness must differ.
func (d *DB) SetCategoryCaps(repo, category, caps string) error {
	_, err := d.db.Exec(`INSERT INTO repo_category_caps (repo, category, caps) VALUES (?, ?, ?)
		ON CONFLICT (repo, category) DO UPDATE SET caps = excluded.caps`, repo, category, caps)
	if err != nil {
		return err
	}
	if category == "nobody" || category == "anonymous" {
		return d.syncVisibilityColumn(repo)
	}
	return nil
}

// CategoryCaps returns a repo's category string: its own row, else the
// fleet default's, else fossil's stock value.
func (d *DB) CategoryCaps(repo, category string) string {
	var caps string
	err := d.db.QueryRow(`SELECT caps FROM repo_category_caps WHERE repo = ? AND category = ?`,
		repo, category).Scan(&caps)
	if err == nil {
		return caps
	}
	if repo != fleetRepo {
		err = d.db.QueryRow(`SELECT caps FROM repo_category_caps WHERE repo = '' AND category = ?`,
			category).Scan(&caps)
		if err == nil {
			return caps
		}
	}
	switch category {
	case "nobody":
		return StockNobody
	case "anonymous":
		return StockAnonymous
	case "reader":
		return StockReader
	case "developer":
		return StockDeveloper
	}
	return ""
}

// EffectiveCaps expands an account's authority on a repo (RFC 0004 §3):
// every visitor holds nobody; a signed-in account adds anonymous and
// its explicit caps; u pulls in the reader category, v the developer
// category (v never implies u); a site admin holds s everywhere.
func (d *DB) EffectiveCaps(account, repo string) CapSet {
	own, _ := d.CapsOf(account, repo)
	return d.effective(account, repo, own)
}

// EffectiveCapsWith is EffectiveCaps for a string not yet stored: the
// live editor asks "what would this grant" without a write (RFC 0008).
func (d *DB) EffectiveCapsWith(account, repo, own string) CapSet {
	return d.effective(account, repo, own)
}

// effective is EffectiveCaps with the account's own string supplied,
// so a guard can ask what a string WOULD grant before writing it.
func (d *DB) effective(account, repo, own string) CapSet {
	eff := d.categoryFor(repo, "nobody")
	if account != "" {
		eff = capUnion(eff, d.categoryFor(repo, "anonymous"))
		eff = capUnion(eff, own)
		// Teams (RFC 0018), unioned exactly where the account's own
		// string is and for the same reason: a team's `u` must pull in
		// the reader category below, and a letter this build has no
		// surface for must stay stored and inert.
		//
		// A union, never a cap. Putting a repository under a team
		// cannot reduce what its people already held, and taking it
		// out takes back exactly what the team gave. No forge surveyed
		// for 0018 lets a team subtract, and four independent
		// implementations declining to build restriction was the
		// clearest signal in that survey.
		eff = capUnion(eff, d.teamCaps(account, repo))
		if acct, found := d.AccountByName(account); found && acct.Admin {
			eff = capUnion(eff, "s")
		}
	}
	if strings.ContainsRune(eff, 'u') {
		eff = capUnion(eff, d.categoryFor(repo, "reader"))
	}
	if strings.ContainsRune(eff, 'v') {
		eff = capUnion(eff, d.categoryFor(repo, "developer"))
	}
	return CapSet(eff)
}

// categoryFor is CategoryCaps for an EFFECTIVE-set computation. The
// two differ on the empty repository name: the "" category rows are
// the fleet default — the template a new repository copies — not some
// empty repo's own categories, so caps resolved for repo-less ops
// (people.list, search.all) come from fossil's stock strings instead.
// Without this, seeding the fleet default to Public project handed h
// to signed-out visitors fleet-wide, and an all-private forge leaked
// its account names through GET /api/v1/people.
func (d *DB) categoryFor(repo, category string) string {
	if repo != fleetRepo {
		return d.CategoryCaps(repo, category)
	}
	switch category {
	case "nobody":
		return StockNobody
	case "anonymous":
		return StockAnonymous
	case "reader":
		return StockReader
	case "developer":
		return StockDeveloper
	}
	return ""
}

// CapHolder is one account's stored caps on a repository.
type CapHolder struct {
	Account string
	Caps    string
	// Team is the team this account reaches the repository through, or
	// "" for nobody. TeamCaps is what that team row grants ON TOP of
	// the membership floor, so an EMPTY TeamCaps with a non-empty Team
	// is the floor alone — RFC 0018's third route, and the one a page
	// would otherwise have no way to explain.
	Team     string
	TeamCaps string
}

// Reaches reports whether this account can reach the repository at all,
// by any route. The settings roster used to ask `Caps == ""` and drop
// the row, which was right until teams existed: a floor-only member has
// no repo_caps row, so they resolved to empty and vanished from the
// page while holding read access to a private repository.
//
// RFC 0018 called that "the riskiest change in the feature, and not one
// of the new pages". It is this method.
func (h CapHolder) Reaches() bool { return h.Caps != "" || h.Team != "" }

// Route names how this account reaches the repository, in RFC 0018's
// three words: directly, by a team role, or by membership alone. Empty
// when they do not reach it.
func (h CapHolder) Route() string {
	switch {
	case h.Caps != "" && h.Team != "":
		return "directly, and via " + h.Team
	case h.Caps != "":
		return "directly"
	case h.TeamCaps != "":
		return "via " + h.Team
	case h.Team != "":
		return "by membership of " + h.Team
	}
	return ""
}

// CapHolders lists every account with explicit caps on a repo, plus
// every account at all — the settings editor needs both the ones who
// hold letters and the ones who could.
//
// The team columns come from the same query rather than a second pass,
// so a caller cannot read the direct half and forget the other. Both
// team joins are LEFT: an account in no team, and a repository under
// no team, are the ordinary cases and must not drop rows.
func (d *DB) CapHolders(repo string) ([]CapHolder, error) {
	rows, err := d.db.Query(`SELECT a.name, COALESCE(c.caps, ''),
			COALESCE(m.team, ''), COALESCE(m.caps, '')
		FROM account a
		LEFT JOIN repo_caps c ON c.account = a.name AND c.repo = ?
		LEFT JOIN team_repo r ON r.repo = ?
		LEFT JOIN team_member m ON m.team = r.team AND m.account = a.name
		ORDER BY a.name`, repo, repo)
	if err != nil {
		return nil, err
	}
	defer rows.Close()
	var out []CapHolder
	for rows.Next() {
		var h CapHolder
		if err := rows.Scan(&h.Account, &h.Caps, &h.Team, &h.TeamCaps); err != nil {
			return nil, err
		}
		out = append(out, h)
	}
	return out, rows.Err()
}

// MigrateRolesToCaps translates legacy repo_role rows into cap strings:
// read→u, write→uv, admin→a. INSERT OR IGNORE is the idempotency —
// explicit caps are never clobbered, and reruns are free. repo_role
// itself stays readable for one release (RFC 0004 §5).
func (d *DB) MigrateRolesToCaps() error {
	var n int
	if err := d.db.QueryRow(`SELECT count(*) FROM sqlite_master WHERE type = 'table' AND name = 'repo_role'`).Scan(&n); err != nil {
		return err
	}
	if n == 0 {
		return nil // already gone: the ordinary case after the first open
	}
	// The legacy strings, not the RFC 0008 presets: a row written as
	// "write" meant uv when it was written, and a migration that
	// widened it to Developer would grant forum and chat behind an
	// administrator's back. Widening is the People page's job, in the
	// open, one person at a time.
	if _, err := d.db.Exec(`INSERT OR IGNORE INTO repo_caps (account, repo, caps)
		SELECT account, repo,
			CASE role WHEN 'read' THEN 'u' WHEN 'write' THEN 'uv' WHEN 'admin' THEN 'a' ELSE '' END
		FROM repo_role WHERE role IN ('read','write','admin')`); err != nil {
		return err
	}
	// Audit follow-up (ticket ed5bdd89c39a): dropping the table is only
	// safe when every row was carried over. A role value this migration
	// does not know — nothing this code ever wrote, but the table is
	// just SQLite — stays where an operator can look at it, and the
	// next open tries again.
	var unknown int
	if err := d.db.QueryRow(`SELECT count(*) FROM repo_role
		WHERE role NOT IN ('read','write','admin')`).Scan(&unknown); err != nil {
		return err
	}
	if unknown > 0 {
		return nil
	}
	_, err := d.db.Exec(`DROP TABLE repo_role`)
	return err
}