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/repo/repo.go +74−68
| @@ -14,16 +14,16 @@ import ( | |||
| 14 | 14 | "strings" | |
| 15 | 15 | "time" | |
| 16 | 16 | ||
| 17 | "github.com/rodent-software/vole/internal/access" | ||
| 17 | 18 | "github.com/rodent-software/vole/internal/acl" | |
| 18 | 19 | "github.com/rodent-software/vole/internal/gitx" | |
| 19 | 20 | ) | |
| 20 | 21 | ||
| 21 | 22 | const ( | |
| 22 | ACLRef = "refs/vole/acl" | ||
| 23 | ACLPath = "acl.json" | ||
| 24 | idFile = "vole-id" | ||
| 25 | descFile = "description" | ||
| 26 | maxCASRetry = 32 | ||
| 23 | ACLRef = "refs/vole/acl" | ||
| 24 | ACLPath = "acl.json" | ||
| 25 | idFile = "vole-id" | ||
| 26 | descFile = "description" | ||
| 27 | 27 | ) | |
| 28 | 28 | ||
| 29 | 29 | var nameRE = regexp.MustCompile(`^[a-z0-9][a-z0-9._-]{0,62}$`) | |
| @@ -50,9 +50,15 @@ type Store struct { | |||
| 50 | 50 | // git enforces the push cap on every transport instead of the proxy | |
| 51 | 51 | // buffering to count bytes. | |
| 52 | 52 | MaxInputBytes int64 | |
| 53 | // ACLs is where access rules live. They are not in the repository: they are | ||
| 54 | // configuration the instance owns, keyed by account id, read on every push and | ||
| 55 | // every request (see the access package). | ||
| 56 | ACLs *access.Store | ||
| 53 | 57 | } | |
| 54 | 58 | ||
| 55 | func New(root, binPath string) *Store { return &Store{Root: root, BinPath: binPath} } | ||
| 59 | func New(root, binPath string, acls *access.Store) *Store { | ||
| 60 | return &Store{Root: root, BinPath: binPath, ACLs: acls} | ||
| 61 | } | ||
| 56 | 62 | ||
| 57 | 63 | // Meta is a repository's identity plus the facts the web layer needs. | |
| 58 | 64 | type Meta struct { | |
| @@ -62,6 +68,9 @@ type Meta struct { | |||
| 62 | 68 | Created time.Time | |
| 63 | 69 | Description string | |
| 64 | 70 | DefaultBranch string | |
| 71 | // ACLs is the store this repository's rules are read from. It is copied in from | ||
| 72 | // the Store so a Meta always knows where its rules live. | ||
| 73 | ACLs *access.Store | ||
| 65 | 74 | } | |
| 66 | 75 | ||
| 67 | 76 | func (s *Store) path(name string) string { return filepath.Join(s.Root, name+".git") } | |
| @@ -102,7 +111,8 @@ func (s *Store) Create(name, owner, description string) (*Meta, error) { | |||
| 102 | 111 | return nil, err | |
| 103 | 112 | } | |
| 104 | 113 | } | |
| 105 | m := &Meta{ID: id, Name: name, Dir: dir, Created: time.Now().UTC(), Description: description, DefaultBranch: "main"} | ||
| 114 | m := &Meta{ID: id, Name: name, Dir: dir, Created: time.Now().UTC(), Description: description, | ||
| 115 | DefaultBranch: "main", ACLs: s.ACLs} | ||
| 106 | 116 | if err := m.SetACL(acl.Empty(owner), owner); err != nil { | |
| 107 | 117 | return nil, err | |
| 108 | 118 | } | |
| @@ -126,7 +136,7 @@ func (s *Store) Open(name string) (*Meta, error) { | |||
| 126 | 136 | if err != nil { | |
| 127 | 137 | return nil, fmt.Errorf("repo %q is not a Vole repository (no %s)", name, idFile) | |
| 128 | 138 | } | |
| 129 | m := &Meta{ID: strings.TrimSpace(string(idb)), Name: name, Dir: dir, DefaultBranch: "main"} | ||
| 139 | m := &Meta{ID: strings.TrimSpace(string(idb)), Name: name, Dir: dir, DefaultBranch: "main", ACLs: s.ACLs} | ||
| 130 | 140 | if st, err := os.Stat(dir); err == nil { | |
| 131 | 141 | m.Created = st.ModTime().UTC() | |
| 132 | 142 | } | |
| @@ -183,77 +193,90 @@ func (s *Store) Rename(oldName, newName string) error { | |||
| 183 | 193 | } | |
| 184 | 194 | ||
| 185 | 195 | func (s *Store) Delete(name string) error { | |
| 186 | dir := s.path(name) | ||
| 187 | if _, err := os.Stat(dir); err != nil { | ||
| 196 | m, err := s.Open(name) | ||
| 197 | if err != nil { | ||
| 188 | 198 | return fmt.Errorf("repo %q not found", name) | |
| 189 | 199 | } | |
| 190 | return os.RemoveAll(dir) | ||
| 200 | if err := os.RemoveAll(m.Dir); err != nil { | ||
| 201 | return err | ||
| 202 | } | ||
| 203 | // The rules go with the repository, and only after it is gone: a row without a | ||
| 204 | // repository is unreachable, while a repository without its row would deny | ||
| 205 | // everybody until somebody noticed. | ||
| 206 | if s.ACLs != nil { | ||
| 207 | return s.ACLs.Delete(m.ID) | ||
| 208 | } | ||
| 209 | return nil | ||
| 191 | 210 | } | |
| 192 | 211 | ||
| 193 | 212 | func (m *Meta) Git() *gitx.Repo { return gitx.Open(m.Dir) } | |
| 194 | 213 | ||
| 195 | // ACL reads the repo's access rules from refs/vole/acl. A repo with no ACL ref | ||
| 196 | // gets a deny-by-default ACL with no principals. | ||
| 214 | // ACL reads the repo's access rules from the instance database. A repository with no | ||
| 215 | // row gets a deny-by-default ACL with no principals, which is also what a repository | ||
| 216 | // whose rules were never written looks like. | ||
| 197 | 217 | func (m *Meta) ACL() (*acl.ACL, error) { | |
| 198 | g := m.Git() | ||
| 199 | commit, err := g.RevParse(ACLRef) | ||
| 218 | if m.ACLs == nil { | ||
| 219 | return nil, fmt.Errorf("repo %q has no access store wired, so its rules cannot be read", m.Name) | ||
| 220 | } | ||
| 221 | a, err := m.ACLs.Get(m.ID) | ||
| 200 | 222 | if err != nil { | |
| 201 | 223 | return nil, err | |
| 202 | 224 | } | |
| 203 | if commit == "" { | ||
| 225 | if a == nil { | ||
| 204 | 226 | return acl.Empty(""), nil | |
| 205 | 227 | } | |
| 206 | data, err := g.BlobAt(commit, ACLPath) | ||
| 207 | if err != nil { | ||
| 208 | return nil, fmt.Errorf("read acl: %w", err) | ||
| 209 | } | ||
| 210 | return acl.Parse(data) | ||
| 228 | return a, nil | ||
| 211 | 229 | } | |
| 212 | 230 | ||
| 213 | // SetACL writes the ACL as a commit on refs/vole/acl with a compare-and-swap ref | ||
| 214 | // update, so two concurrent edits cannot silently lose one another. | ||
| 231 | // SetACL replaces the repo's access rules. It is one row now, so two concurrent edits | ||
| 232 | // are ordered by SQLite instead of by a compare-and-swap retry, and the old copy's | ||
| 233 | // commit message (who changed what) is kept in the row and the audit log. | ||
| 215 | 234 | func (m *Meta) SetACL(a *acl.ACL, actor string) error { | |
| 216 | payload, err := a.Bytes() | ||
| 217 | if err != nil { | ||
| 218 | return err | ||
| 235 | if m.ACLs == nil { | ||
| 236 | return fmt.Errorf("repo %q has no access store wired, so its rules cannot be written", m.Name) | ||
| 219 | 237 | } | |
| 220 | g := m.Git() | ||
| 221 | blob, err := g.HashObject(payload) | ||
| 238 | return m.ACLs.Set(m.ID, a, actor, time.Now().UTC()) | ||
| 239 | } | ||
| 240 | |||
| 241 | // ImportACLs reads rules from refs/vole/acl in every repository that has them and no | ||
| 242 | // row yet, which is how an instance that stored them in git keeps them across the | ||
| 243 | // upgrade. It is idempotent and reports how many repositories it took rules from. | ||
| 244 | func (s *Store) ImportACLs() (int, error) { | ||
| 245 | if s.ACLs == nil { | ||
| 246 | return 0, fmt.Errorf("no access store is wired") | ||
| 247 | } | ||
| 248 | repos, err := s.List() | ||
| 222 | 249 | if err != nil { | |
| 223 | return err | ||
| 250 | return 0, err | ||
| 224 | 251 | } | |
| 225 | var lastErr error | ||
| 226 | for attempt := 0; attempt < maxCASRetry; attempt++ { | ||
| 227 | old, err := g.RevParse(ACLRef) | ||
| 252 | imported := 0 | ||
| 253 | for _, m := range repos { | ||
| 254 | have, err := s.ACLs.Get(m.ID) | ||
| 228 | 255 | if err != nil { | |
| 229 | return err | ||
| 256 | return imported, err | ||
| 230 | 257 | } | |
| 231 | var entries []gitx.Entry | ||
| 232 | if old != "" { | ||
| 233 | entries, err = g.LsTree(old) | ||
| 234 | if err != nil { | ||
| 235 | return err | ||
| 236 | } | ||
| 258 | if have != nil { | ||
| 259 | continue | ||
| 237 | 260 | } | |
| 238 | entries = upsert(entries, gitx.Entry{Mode: "100644", Type: "blob", SHA: blob, Name: ACLPath}) | ||
| 239 | tree, err := g.MkTree(entries) | ||
| 240 | if err != nil { | ||
| 241 | return err | ||
| 261 | g := m.Git() | ||
| 262 | commit, err := g.RevParse(ACLRef) | ||
| 263 | if err != nil || commit == "" { | ||
| 264 | continue // nothing to import: a repository with no rules is deny-all | ||
| 242 | 265 | } | |
| 243 | commit, err := g.CommitTree(tree, old, fmt.Sprintf("acl: update by %s", actorOr(actor))) | ||
| 266 | data, err := g.BlobAt(commit, ACLPath) | ||
| 244 | 267 | if err != nil { | |
| 245 | return err | ||
| 268 | return imported, fmt.Errorf("%s: read %s: %w", m.Name, ACLRef, err) | ||
| 246 | 269 | } | |
| 247 | ok, err := g.UpdateRefCAS(ACLRef, commit, old) | ||
| 270 | a, err := acl.Parse(data) | ||
| 248 | 271 | if err != nil { | |
| 249 | return err | ||
| 272 | return imported, fmt.Errorf("%s: %w", m.Name, err) | ||
| 250 | 273 | } | |
| 251 | if ok { | ||
| 252 | return nil | ||
| 274 | if err := s.ACLs.Set(m.ID, a, "import from "+ACLRef, time.Now().UTC()); err != nil { | ||
| 275 | return imported, err | ||
| 253 | 276 | } | |
| 254 | lastErr = fmt.Errorf("acl: lost %d ref races", attempt+1) | ||
| 277 | imported++ | ||
| 255 | 278 | } | |
| 256 | return lastErr | ||
| 279 | return imported, nil | ||
| 257 | 280 | } | |
| 258 | 281 | ||
| 259 | 282 | // InstallHooks writes the server-side hook that enforces policy. It is | |
| @@ -295,23 +318,6 @@ func shellQuote(s string) string { | |||
| 295 | 318 | return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'" | |
| 296 | 319 | } | |
| 297 | 320 | ||
| 298 | func upsert(es []gitx.Entry, add gitx.Entry) []gitx.Entry { | ||
| 299 | for i := range es { | ||
| 300 | if es[i].Name == add.Name { | ||
| 301 | es[i] = add | ||
| 302 | return es | ||
| 303 | } | ||
| 304 | } | ||
| 305 | return append(es, add) | ||
| 306 | } | ||
| 307 | |||
| 308 | func actorOr(a string) string { | ||
| 309 | if strings.TrimSpace(a) == "" { | ||
| 310 | return "unknown" | ||
| 311 | } | ||
| 312 | return a | ||
| 313 | } | ||
| 314 | |||
| 315 | 321 | func newID() (string, error) { | |
| 316 | 322 | b := make([]byte, 12) | |
| 317 | 323 | if _, err := rand.Read(b); err != nil { | |