Artifact 293354f1f30e77ab0fd6b8af46cad5aebdd5da333e44b03412bfe0fbfeda4abd:
- File
internal/oltp/account.go
— part of check-in
[bf5ba9d2fc]
at
2026-08-16 17:07:31
on branch trunk
— oltp: accounts and per-repo roles
0001 §8's v2 tables. Accounts are the web/API identity (argon2id verifier, site-admin flag, read/write/admin per repo); the RFC §6.3 shared secret stays the sync credential, since the protocol fixes its derivation and the server must recompute it.
ops.Auth becomes the required role, so the Admin level that was already declared now means something. The gate stays open until the first account exists, mirroring the sync-user policy, so existing deployments and local single-user serving are unaffected. (user: dmestas, size: 7090) [annotate] [blame] [check-ins using]
// account.go is 0001 §8's v2 growth: accounts and per-repo roles. §8 // pinned the architecture in v1 ("role granularity is v2 and adds tables, // not architecture") — this is those tables. // // Accounts are the web/API identity. They are NOT the sync protocol's // credential, which stays in sync_user because RFC §6.3 dictates its // derivation (see password.go). One account may own a sync_user row per // repo, which is what makes a push attributable to a person. package oltp import ( "database/sql" "errors" "fmt" "sort" "strings" "time" ) // Role is a repo-scoped capability. Ordered: each contains the ones // before it, so a comparison answers "is this enough?". type Role int const ( NoRole Role = iota Read Write RepoAdmin ) func (r Role) String() string { switch r { case Read: return "read" case Write: return "write" case RepoAdmin: return "admin" } return "none" } // ParseRole is the inverse, for CLI and storage. func ParseRole(s string) (Role, error) { switch s { case "read": return Read, nil case "write": return Write, nil case "admin": return RepoAdmin, nil } return NoRole, fmt.Errorf("oltp: unknown role %q (want read, write or admin)", s) } // Account is one login. The verifier never leaves the database. type Account struct { Name string Admin bool // site admin: every repo, every capability Created string } const accountSchema = ` CREATE TABLE IF NOT EXISTS account ( name TEXT PRIMARY KEY, pwhash TEXT NOT NULL, -- argon2id PHC string; plaintext never stored admin INTEGER NOT NULL DEFAULT 0, -- site admin created TEXT NOT NULL ); CREATE TABLE IF NOT EXISTS repo_role ( account TEXT NOT NULL, repo TEXT NOT NULL, role TEXT NOT NULL, -- read | write | admin PRIMARY KEY (account, repo) );` // AddAccount creates an account, hashing the password. Fails if the name // is taken — silently replacing a login would be an account takeover. func (d *DB) AddAccount(name, password string, admin bool) error { if name == "" { return errors.New("oltp: empty account name") } hash, err := HashPassword(password) if err != nil { return err } _, err = d.db.Exec(`INSERT INTO account (name, pwhash, admin, created) VALUES (?, ?, ?, ?)`, name, hash, boolInt(admin), time.Now().UTC().Format(time.RFC3339)) if err != nil && strings.Contains(err.Error(), "UNIQUE") { return fmt.Errorf("oltp: account %q already exists", name) } return err } // SetPassword replaces an account's verifier. func (d *DB) SetPassword(name, password string) error { hash, err := HashPassword(password) if err != nil { return err } res, err := d.db.Exec(`UPDATE account SET pwhash = ? WHERE name = ?`, hash, name) if err != nil { return err } if n, _ := res.RowsAffected(); n == 0 { return fmt.Errorf("oltp: no such account %q", name) } return nil } // Authenticate reports whether the password matches, and returns the // account. A missing account still pays for a hash comparison, so // probing for valid names by timing gets nothing. func (d *DB) Authenticate(name, password string) (Account, bool) { var a Account var hash string var admin int err := d.db.QueryRow(`SELECT name, pwhash, admin, created FROM account WHERE name = ?`, name).Scan(&a.Name, &hash, &admin, &a.Created) if errors.Is(err, sql.ErrNoRows) { // Same work as a real verification, so a nonexistent account is // not distinguishable from a wrong password by response time. VerifyPassword(decoyHash, password) return Account{}, false } if err != nil { return Account{}, false } if !VerifyPassword(hash, password) { return Account{}, false } a.Admin = admin != 0 return a, true } // decoyHash is a real argon2id verifier for a password nobody has; it // exists only to give Authenticate constant work on a missing account. var decoyHash = func() string { h, _ := HashPassword("decoy: no account has this password") return h }() // HasAccounts is the policy switch, mirroring Gated for sync users: with // no accounts the web/API gate stays open (single-user local serving), // and the first account closes it. func (d *DB) HasAccounts() bool { var n int if err := d.db.QueryRow(`SELECT count(*) FROM account`).Scan(&n); err != nil { return false } return n > 0 } // Accounts lists every account, name-ordered. func (d *DB) Accounts() ([]Account, error) { rows, err := d.db.Query(`SELECT name, admin, created FROM account ORDER BY name`) if err != nil { return nil, err } defer rows.Close() var out []Account for rows.Next() { var a Account var admin int if err := rows.Scan(&a.Name, &admin, &a.Created); err != nil { return nil, err } a.Admin = admin != 0 out = append(out, a) } return out, rows.Err() } // DeleteAccount removes the account and every role it held. func (d *DB) DeleteAccount(name string) error { if _, err := d.db.Exec(`DELETE FROM repo_role WHERE account = ?`, name); err != nil { return err } _, err := d.db.Exec(`DELETE FROM account WHERE name = ?`, name) return err } // Grant sets one account's role on one repo, replacing any current role. func (d *DB) Grant(account, repo string, role Role) error { if role == NoRole { return d.Revoke(account, repo) } var n int if err := d.db.QueryRow(`SELECT count(*) FROM account WHERE name = ?`, account).Scan(&n); err != nil { return err } if n == 0 { return fmt.Errorf("oltp: no such account %q", account) } _, err := d.db.Exec(`INSERT OR REPLACE INTO repo_role (account, repo, role) VALUES (?, ?, ?)`, account, repo, role.String()) return err } // Revoke drops an account's role on a repo. func (d *DB) Revoke(account, repo string) error { _, err := d.db.Exec(`DELETE FROM repo_role WHERE account = ? AND repo = ?`, account, repo) return err } // RoleOf returns the account's effective role on a repo. A site admin // holds RepoAdmin everywhere, which is what "site admin" means. func (d *DB) RoleOf(account Account, repo string) Role { if account.Admin { return RepoAdmin } var s string if err := d.db.QueryRow(`SELECT role FROM repo_role WHERE account = ? AND repo = ?`, account.Name, repo).Scan(&s); err != nil { return NoRole } r, err := ParseRole(s) if err != nil { return NoRole } return r } // GrantsFor lists an account's per-repo roles, repo-ordered. func (d *DB) GrantsFor(account string) (map[string]Role, error) { rows, err := d.db.Query(`SELECT repo, role FROM repo_role WHERE account = ?`, account) if err != nil { return nil, err } defer rows.Close() out := map[string]Role{} for rows.Next() { var repo, role string if err := rows.Scan(&repo, &role); err != nil { return nil, err } r, err := ParseRole(role) if err != nil { continue } out[repo] = r } return out, rows.Err() } // SortedRepos is a small helper so callers render grants deterministically. func SortedRepos(m map[string]Role) []string { out := make([]string, 0, len(m)) for k := range m { out = append(out, k) } sort.Strings(out) return out } func boolInt(b bool) int { if b { return 1 } return 0 }