Artifact ad303218ae17d06af7d2c5c9d23028f4f4b866fbd8678e11df44706157eb7209:
- File
internal/oltp/caps.go
— part of check-in
[b1e46974fc]
at
2026-08-22 16:32:12
on branch fossil-caps
— caps phase 5c: capability editor in settings
Per-repo category strings and per-account caps, editable in place with the expanded effective set shown beside each. CapsAdmin is an optional seam — a forge without it simply has no editor. (user: dmestas, size: 6462) [annotate] [blame] [check-ins using] [more...]
// 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. var accountMigrations = []string{ `ALTER TABLE account ADD COLUMN legacy_pw TEXT NOT NULL DEFAULT ''`, } // 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) return err } // CategoryCaps returns a repo's category string, stock when unset. 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 } 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 { eff := d.CategoryCaps(repo, "nobody") if account != "" { eff = capUnion(eff, d.CategoryCaps(repo, "anonymous")) own, _ := d.CapsOf(account, repo) eff = capUnion(eff, own) if acct, found := d.AccountByName(account); found && acct.Admin { eff = capUnion(eff, "s") } } if strings.ContainsRune(eff, 'u') { eff = capUnion(eff, d.CategoryCaps(repo, "reader")) } if strings.ContainsRune(eff, 'v') { eff = capUnion(eff, d.CategoryCaps(repo, "developer")) } return CapSet(eff) } // CapHolder is one account's stored caps on a repository. type CapHolder struct { Account string Caps string } // 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. func (d *DB) CapHolders(repo string) ([]CapHolder, error) { rows, err := d.db.Query(`SELECT a.name, COALESCE(c.caps, '') FROM account a LEFT JOIN repo_caps c ON c.account = a.name AND c.repo = ? ORDER BY a.name`, 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); 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 { _, 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')`) return err }