vole
Clone
Read only, no credentials:
https://rodent.software/vole.git
Write over SSH with a registered key:
git clone ssh://git@rodent.software:2222/vole.git
A free and open-source forge that lives in your Git repo.
Phase 4: access rules live in the instance database
ec5c519cb9fc4277baa8263cfd31891a95f1d7ac · Keenan Nemetz · 17d ago · parent 90fdaf39
Phase 4: access rules live in the instance database The ACL was one acl.json on refs/vole/acl: a ref only the server writes, read on every push and every request. It is configuration the instance owns, keyed by account id, and keeping it in git cost a commit, a compare-and-swap retry and a rev-parse per read to store a handful of rows — the argument that already moved the roster out of data/system.git, applied to the rules that name its accounts. It is now a row in data/users.db beside the roster, in the same file as the ids it refers to: - access.Store: repo_acl(repo_id, acl, updated, updated_by), one row per repository, keyed by the stable repository id so a rename moves nothing. - Meta.ACL reads the row; a repository with no row is deny-everything, exactly what a missing ref meant. - Meta.SetACL is one upsert, so SQLite orders concurrent edits and the CAS loop with its retries is gone. The author stays in the row and in data/audit.log, which is what the commit message used to say. - Deleting a repository forgets its rules, after the directory is gone. - repo.Store.ImportACLs reads refs/vole/acl once at startup for any repository without a row, so an existing instance keeps its rules. It never overwrites a row, and a second run imports nothing. - The index's acl_sha is a digest of the rules rather than the ACL commit id. - pre-receive reads the rules from VOLE_USERS_DB and makes GIT_DIR absolute before deriving the repository from it, since git hands a hook ".". What the ref bought — rules that travel with a clone — is what this gives up, deliberately: the rules are account ids, so a copy in a clone is unreadable without this instance's roster, and the backup that holds the accounts now holds them too. Measured: internal/repo imports rules from a real refs/vole/acl commit and is idempotent on a second run; internal/access covers round-trip, replacement, deletion and the audit line; the full suite — auth, site visibility, transport and pushes — runs against rules read from SQLite.
internal/access/access.go +123
| @@ -0,0 +1,123 @@ | |||
| 1 | // Package access stores each repository's access rules. | ||
| 2 | // | ||
| 3 | // The rules live in the instance database beside the roster, not in the repository. | ||
| 4 | // They are configuration: only the instance writes them, only the instance reads them, | ||
| 5 | // and they are keyed by account id — so a copy in a clone means nothing without the | ||
| 6 | // roster that gives those ids names. Keeping them in git bought a commit, a | ||
| 7 | // compare-and-swap retry and a rev-parse per read to store a handful of rows, and that | ||
| 8 | // is the same argument that moved the roster out of data/system.git. | ||
| 9 | // | ||
| 10 | // What the repository used to carry is imported once (repo.Store.ImportACLs), so an | ||
| 11 | // instance that has refs/vole/acl keeps its rules when it upgrades. | ||
| 12 | package access | ||
| 13 | |||
| 14 | import ( | ||
| 15 | "database/sql" | ||
| 16 | "fmt" | ||
| 17 | "os" | ||
| 18 | "time" | ||
| 19 | |||
| 20 | "github.com/rodent-software/vole/internal/acl" | ||
| 21 | |||
| 22 | _ "modernc.org/sqlite" | ||
| 23 | ) | ||
| 24 | |||
| 25 | type Store struct { | ||
| 26 | db *sql.DB | ||
| 27 | // AuditPath receives one line per change. Empty means no audit sink, which is only | ||
| 28 | // acceptable for tests: an access change is the one administrative act that must | ||
| 29 | // leave a trace, since the old copy was a commit message. | ||
| 30 | AuditPath string | ||
| 31 | } | ||
| 32 | |||
| 33 | // Open opens (and creates) the access table in the instance database. It is the same | ||
| 34 | // file as the roster: access rules are keyed by account id, so the two have to be | ||
| 35 | // backed up and restored together or the ids stop resolving. | ||
| 36 | func Open(path string) (*Store, error) { | ||
| 37 | dsn := path + "?_pragma=busy_timeout(5000)&_pragma=journal_mode(WAL)" | ||
| 38 | db, err := sql.Open("sqlite", dsn) | ||
| 39 | if err != nil { | ||
| 40 | return nil, err | ||
| 41 | } | ||
| 42 | db.SetMaxOpenConns(2) | ||
| 43 | if _, err := db.Exec(` | ||
| 44 | CREATE TABLE IF NOT EXISTS repo_acl ( | ||
| 45 | repo_id TEXT PRIMARY KEY, | ||
| 46 | acl TEXT NOT NULL, | ||
| 47 | updated INTEGER NOT NULL, | ||
| 48 | updated_by TEXT NOT NULL DEFAULT '' | ||
| 49 | );`); err != nil { | ||
| 50 | db.Close() | ||
| 51 | return nil, err | ||
| 52 | } | ||
| 53 | return &Store{db: db}, nil | ||
| 54 | } | ||
| 55 | |||
| 56 | func (s *Store) Close() error { return s.db.Close() } | ||
| 57 | |||
| 58 | // Get returns a repository's rules, or nil when it has none — which callers must treat | ||
| 59 | // as deny-everything, the same as a repository that never had an ACL. | ||
| 60 | func (s *Store) Get(repoID string) (*acl.ACL, error) { | ||
| 61 | var raw string | ||
| 62 | err := s.db.QueryRow(`SELECT acl FROM repo_acl WHERE repo_id = ?`, repoID).Scan(&raw) | ||
| 63 | if err == sql.ErrNoRows { | ||
| 64 | return nil, nil | ||
| 65 | } | ||
| 66 | if err != nil { | ||
| 67 | return nil, err | ||
| 68 | } | ||
| 69 | a, err := acl.Parse([]byte(raw)) | ||
| 70 | if err != nil { | ||
| 71 | return nil, fmt.Errorf("access: %s: %w", repoID, err) | ||
| 72 | } | ||
| 73 | return a, nil | ||
| 74 | } | ||
| 75 | |||
| 76 | // Set replaces a repository's rules. The row keeps who changed them and when, because | ||
| 77 | // the git copy used to keep that in the commit and losing it would make an access | ||
| 78 | // change the one thing nobody can look up afterwards. | ||
| 79 | func (s *Store) Set(repoID string, a *acl.ACL, actor string, now time.Time) error { | ||
| 80 | raw, err := a.Bytes() | ||
| 81 | if err != nil { | ||
| 82 | return err | ||
| 83 | } | ||
| 84 | if _, err := s.db.Exec(`INSERT INTO repo_acl (repo_id, acl, updated, updated_by) VALUES (?,?,?,?) | ||
| 85 | ON CONFLICT(repo_id) DO UPDATE SET acl = excluded.acl, | ||
| 86 | updated = excluded.updated, updated_by = excluded.updated_by`, | ||
| 87 | repoID, string(raw), now.UTC().Unix(), actor); err != nil { | ||
| 88 | return err | ||
| 89 | } | ||
| 90 | s.audit(fmt.Sprintf("acl %s updated by %s", repoID, actorOr(actor))) | ||
| 91 | return nil | ||
| 92 | } | ||
| 93 | |||
| 94 | // Delete forgets a repository's rules, which is what deleting the repository does. | ||
| 95 | func (s *Store) Delete(repoID string) error { | ||
| 96 | var actor string | ||
| 97 | _ = s.db.QueryRow(`SELECT updated_by FROM repo_acl WHERE repo_id = ?`, repoID).Scan(&actor) | ||
| 98 | if _, err := s.db.Exec(`DELETE FROM repo_acl WHERE repo_id = ?`, repoID); err != nil { | ||
| 99 | return err | ||
| 100 | } | ||
| 101 | s.audit(fmt.Sprintf("acl %s deleted with the repository (last changed by %s)", repoID, actorOr(actor))) | ||
| 102 | return nil | ||
| 103 | } | ||
| 104 | |||
| 105 | // audit is best-effort: failing to write the log must not change policy. | ||
| 106 | func (s *Store) audit(line string) { | ||
| 107 | if s.AuditPath == "" { | ||
| 108 | return | ||
| 109 | } | ||
| 110 | f, err := os.OpenFile(s.AuditPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644) | ||
| 111 | if err != nil { | ||
| 112 | return | ||
| 113 | } | ||
| 114 | defer f.Close() | ||
| 115 | fmt.Fprintf(f, "%s\t%s\n", time.Now().UTC().Format(time.RFC3339), line) | ||
| 116 | } | ||
| 117 | |||
| 118 | func actorOr(actor string) string { | ||
| 119 | if actor == "" { | ||
| 120 | return "unknown" | ||
| 121 | } | ||
| 122 | return actor | ||
| 123 | } | ||