Artifact 39ef4399ea1b5332d02d4157e7b6421ea940ea7d7d4874bd3d7734de5e9a455a:
- File
internal/oltp/caps.go
— part of check-in
[f70144e381]
at
2026-08-22 16:54:36
on branch trunk
— quarry users pull: import a remote fossil's users and caps
Fetch (admin-credentialed) then import: unknown logins are created with their fossil §6.3 credential and the project code it needs, existing accounts keep their own password, categories land as categories. An unmentioned category keeps quarry's default rather than blanking into a lockout. (user: dmestas, size: 9363) [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. // 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 ''`, } // 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) 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 }