// 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()
}
// SetCaps stores (replacing) one account's explicit caps on a repo.
func (d *DB) SetCaps(account, repo, caps string) error {
if caps == "" {
_, err := d.w.Exec(`DELETE FROM repo_caps WHERE account = ? AND repo = ?`, account, repo)
return err
}
_, err := d.w.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.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 {
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.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(d.categoryFor(repo, SourceNobody), SourceNobody)
if account != "" {
add(d.categoryFor(repo, SourceAnonymous), SourceAnonymous)
add(own, SourceOwn)
if team, caps := d.teamGrant(account, repo); team != "" {
add(caps, TeamSource(team))
}
if acct, found := d.AccountByName(account); found && acct.Admin {
add("s", SourceSite)
}
}
if _, ok := src['u']; ok {
add(d.categoryFor(repo, SourceReader), SourceReader)
}
if _, ok := src['v']; ok {
add(d.categoryFor(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)
}