Sign in

Search issue titles and bodies, change requests and comments. Private repositories you cannot read are not included.

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 {