Skip to content
quarry Artifact [bbec489bb5]
Login

Artifact [bbec489bb5]

Artifact bbec489bb5e9e024e902bc833a05165c55390b7d9e97540226878110b918f56d:


// 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 ''`,
	// operator names the person answerable for a MACHINE-OPERATED
	// account, and its non-emptiness is what makes the account one.
	// One column answers both questions, because neither is worth
	// much alone: "a machine wrote this" without "and X runs it"
	// leaves a reader knowing to distrust the words and not who to
	// ask about them.
	`ALTER TABLE account ADD COLUMN operator 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.w.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.w.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.
//
// Three rules, and until now this function knew only two of them. The
// umbrella caps resolve here: s grants everything, and a grants
// everything except s, x and y (fossil's Admin-vs-Setup rule). What it
// did NOT do was consult fossil's implies table, which Closure has
// carried all along: w implies rcn, i implies o, k implies jm, and
// each forum level implies the one below it.
//
// So a gate refused an account holding only w the right to READ a
// ticket, although fossil grants it and our own table says so. The
// same for check-in without explicit read, wiki edit without wiki
// read, and posting without reading the forum. It stayed latent
// because every role hands out a full set, so only a hand-written
// letter string reached it — and a hand-written string is exactly what
// a repository admin types into the settings page.
//
// Closure, which knew the implies table, in turn knew nothing about
// the umbrella caps. Each function held half the rules, and
// account.explain answered one question with each: its sentence came
// from this side and its letter list from the other, so a repository
// admin was told they could check in above a list with no i in it
// (8ea1e46d, G-84). dmestas's call was that we follow fossil, so both
// halves are fossil's now and they are the same half.
func (c CapSet) HasAny(letters string) bool {
	held := Closure(string(c))
	for _, l := range letters {
		if strings.ContainsRune(held, l) {
			return true
		}
		if strings.ContainsRune(held, 's') {
			return true
		}
		if strings.ContainsRune(held, '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()
}

// noSetup maps fossil's setup letter to admin in a repository-scoped
// string. s is what a site admin holds everywhere and only the forge
// account may grant it: a fossil repository's own setup user is that
// repository's admin here, and a stored s would pass every site-admin
// gate (HasAny("s")) from a repository's row.
func noSetup(caps string) string {
	if !strings.ContainsRune(caps, 's') {
		return caps
	}
	return capUnion(strings.ReplaceAll(caps, "s", ""), "a")
}

// categoryOnly cleans a category string. A category is what a whole
// class of visitors holds, so it never carries admin or setup: both
// are dropped, not mapped.
func categoryOnly(caps string) string {
	return strings.NewReplacer("s", "", "a", "").Replace(caps)
}

// MigrateSetupCaps rewrites stored strings that carry s — repository,
// team and category rows — in one transaction. Idempotent, and run on
// every open.
func (d *DB) MigrateSetupCaps() error {
	tx, err := d.w.Begin()
	if err != nil {
		return err
	}
	defer tx.Rollback()
	for _, t := range []struct {
		table, key string
		clean      func(string) string
	}{
		{"repo_caps", "account, repo", noSetup},
		{"team_member", "team, account", noSetup},
		{"repo_category_caps", "repo, category", categoryOnly},
	} {
		rows, err := tx.Query(`SELECT ` + t.key + `, caps FROM ` + t.table + ` WHERE instr(caps, 's') > 0 OR instr(caps, 'a') > 0`)
		if err != nil {
			return err
		}
		type row struct{ k1, k2, caps string }
		var fix []row
		for rows.Next() {
			var r row
			if err := rows.Scan(&r.k1, &r.k2, &r.caps); err != nil {
				rows.Close()
				return err
			}
			if t.clean(r.caps) != r.caps {
				fix = append(fix, r)
			}
		}
		rows.Close()
		for _, r := range fix {
			k := strings.Split(t.key, ", ")
			if _, err := tx.Exec(`UPDATE `+t.table+` SET caps = ? WHERE `+k[0]+` = ? AND `+k[1]+` = ?`,
				t.clean(r.caps), r.k1, r.k2); err != nil {
				return err
			}
		}
	}
	return tx.Commit()
}

// SetCaps stores (replacing) one account's explicit caps on a repo,
// unguarded: for imports from a fossil user table and for Grant. A
// change a person makes through the forge is SetCapsGuarded.
func (d *DB) SetCaps(account, repo, caps string) error {
	return setCaps(d.w, account, repo, caps)
}

func setCaps(ex execer, account, repo, caps string) error {
	caps = noSetup(caps)
	if caps == "" {
		_, err := ex.Exec(`DELETE FROM repo_caps WHERE account = ? AND repo = ?`, account, repo)
		return err
	}
	_, err := ex.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) {
	return capsOf(d.db, account, repo)
}

func capsOf(q querier, account, repo string) (string, error) {
	var caps string
	err := q.QueryRow(`SELECT caps FROM repo_caps WHERE account = ? AND repo = ?`,
		account, repo).Scan(&caps)
	if err != nil {
		return "", nil
	}
	return noSetup(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 {
	caps = categoryOnly(caps)
	_, err := d.w.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 {
	return categoryCaps(d.db, repo, category)
}

func categoryCaps(q querier, repo, category string) string {
	var caps string
	err := q.QueryRow(`SELECT caps FROM repo_category_caps WHERE repo = ? AND category = ?`,
		repo, category).Scan(&caps)
	if err == nil {
		return categoryOnly(caps)
	}
	if repo != fleetRepo {
		err = q.QueryRow(`SELECT caps FROM repo_category_caps WHERE repo = '' AND category = ?`,
			category).Scan(&caps)
		if err == nil {
			return categoryOnly(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 at forge scope
// ("") and nothing more on a repository than their grants (#318).
func (d *DB) EffectiveCaps(account, repo string) CapSet {
	// A run token's login is no account: it holds its run's letters on
	// its one repository, and what the public holds everywhere else.
	if reservedRunName(account) {
		return d.runLoginCaps(account, repo)
	}
	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 {
	return d.effectiveIn(d.db, account, repo, own)
}

// effectiveIn is effective read through q, so a guard holding the
// write lock can resolve caps inside its own transaction.
func (d *DB) effectiveIn(q querier, account, repo, own string) CapSet {
	eff := categoryFor(q, repo, "nobody")
	if account != "" {
		eff = capUnion(eff, categoryFor(q, 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.teamCapsIn(q, account, repo))
	}
	if strings.ContainsRune(eff, 'u') {
		eff = capUnion(eff, categoryFor(q, repo, "reader"))
	}
	if strings.ContainsRune(eff, 'v') {
		eff = capUnion(eff, categoryFor(q, repo, "developer"))
	}
	// Setup is the forge account's, never a string's, and the cleanup
	// runs after every union above so no source can carry it in —
	// signed-out visitors included. The site-admin flag is s at forge
	// scope and nowhere else: an operator administers the forge, not the
	// repositories under it, and on one of those holds what their grants
	// say (#318).
	eff = noSetup(eff)
	if account != "" && repo == fleetRepo {
		if acct, found := accountByName(q, account); found && acct.Admin {
			eff = capUnion(eff, "s")
		}
	}
	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 categoryFor(q querier, repo, category string) string {
	if repo != fleetRepo {
		return categoryCaps(q, 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) {
	return capHolders(d.db, repo)
}

func capHolders(q querier, repo string) ([]CapHolder, error) {
	rows, err := q.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
		}
		h.Caps, h.TeamCaps = noSetup(h.Caps), noSetup(h.TeamCaps)
		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.w.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.w.Exec(`DROP TABLE repo_role`)
	return err
}

// Where each letter of an effective set comes from. The per-person
// editor used to print "granted to everyone signed in" beside EVERY
// ability held other than through the account's own string, which was
// right only for the anonymous category: check-in arrived from the
// developer category, ticket editing from the reader category, reading
// the code from every visitor, and the page named none of them.
//
// Source names, as the web layer turns them into words:
//
//	"nobody"      every visitor
//	"anonymous"   anyone signed in
//	"own"         this account's string on this repository
//	"team:<name>" a team this account reaches the repository through
//	"site"        a site administrator's s
//	"reader"      the reader category, pulled in by u
//	"developer"   the developer category, pulled in by v
const (
	SourceNobody     = "nobody"
	SourceAnonymous  = "anonymous"
	SourceOwn        = "own"
	SourceSite       = "site"
	SourceReader     = "reader"
	SourceDeveloper  = "developer"
	sourceTeamPrefix = "team:"
)

// TeamSource names a team as a source.
func TeamSource(team string) string { return sourceTeamPrefix + team }

// SourcesWith attributes every letter effective(account, repo, own)
// grants to the FIRST place, in effective()'s own order, that grants it.
// First, because the broadest grant is the one that explains the letter:
// if every visitor can read tickets, removing the reader category would
// not take that away, so "reader category" would be the wrong answer.
//
// It replays effective() step for step rather than decorating its
// result, and TestSourcesCoverExactlyTheEffectiveSet holds the two
// together: every letter effective grants has a source, and nothing
// has a source that effective does not grant.
func (d *DB) SourcesWith(account, repo, own string) map[rune]string {
	src := map[rune]string{}
	add := func(letters, from string) {
		for _, r := range letters {
			if _, seen := src[r]; !seen && r != ' ' {
				src[r] = from
			}
		}
	}
	add(categoryFor(d.db, repo, SourceNobody), SourceNobody)
	if account != "" {
		add(categoryFor(d.db, repo, SourceAnonymous), SourceAnonymous)
		add(noSetup(own), SourceOwn)
		if team, caps := d.teamGrant(account, repo); team != "" {
			add(noSetup(caps), TeamSource(team))
		}
		if repo == fleetRepo {
			if acct, found := d.AccountByName(account); found && acct.Admin {
				add("s", SourceSite)
			}
		}
	}
	if _, ok := src['u']; ok {
		add(categoryFor(d.db, repo, SourceReader), SourceReader)
	}
	if _, ok := src['v']; ok {
		add(categoryFor(d.db, repo, SourceDeveloper), SourceDeveloper)
	}
	return src
}

// teamGrant is teamCaps with the team named, for attribution. Kept in
// step with teamCaps: the same query, the same floor, the same empties.
func (d *DB) teamGrant(account, repo string) (team, caps string) {
	if account == "" || repo == "" || !d.hasAnyTeam() {
		return "", ""
	}
	var extra string
	if err := d.db.QueryRow(`SELECT r.team, m.caps FROM team_repo r
		JOIN team_member m ON m.team = r.team
		WHERE r.repo = ? AND m.account = ?`, repo, account).Scan(&team, &extra); err != nil {
		return "", ""
	}
	return team, capUnion(teamFloor, extra)
}