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/access/access_test.go +91

@@ -0,0 +1,91 @@
1 package access
2
3 // Access rules are one row per repository in the instance database. These tests pin the
4 // two things the file-based version gave for free: a repository with no rules denies
5 // everything, and a change leaves a trace.
6
7 import (
8 "os"
9 "path/filepath"
10 "strings"
11 "testing"
12 "time"
13
14 "github.com/rodent-software/vole/internal/acl"
15 )
16
17 func open(t *testing.T) (*Store, string) {
18 t.Helper()
19 dir := t.TempDir()
20 path := filepath.Join(dir, "users.db")
21 s, err := Open(path)
22 if err != nil {
23 t.Fatal(err)
24 }
25 t.Cleanup(func() { s.Close() })
26 return s, dir
27 }
28
29 func TestRulesRoundTripAndReplace(t *testing.T) {
30 s, _ := open(t)
31 if a, err := s.Get("repo-1"); err != nil || a != nil {
32 t.Fatalf("a repository with no rules returned %+v, %v", a, err)
33 }
34
35 a := acl.Empty("01M5OWNER0000000000000000A")
36 if err := a.SetEntry("01M5WRITER000000000000000B", "write"); err != nil {
37 t.Fatal(err)
38 }
39 a.Protected = []string{"refs/heads/main"}
40 a.SetRunners([]string{"01M5RUNNER000000000000000C"})
41 if err := s.Set("repo-1", a, "keenan", time.Now().UTC()); err != nil {
42 t.Fatal(err)
43 }
44
45 got, err := s.Get("repo-1")
46 if err != nil || got == nil {
47 t.Fatalf("get: %+v, %v", got, err)
48 }
49 if got.RoleFor("01M5WRITER000000000000000B") != acl.Write ||
50 !got.CanReport("01M5RUNNER000000000000000C") ||
51 len(got.Protected) != 1 {
52 t.Fatalf("rules came back different: %+v", got)
53 }
54
55 // A replacement is a replacement: nothing of the old list survives.
56 next := acl.Empty("01M5OWNER0000000000000000A")
57 if err := s.Set("repo-1", next, "keenan", time.Now().UTC()); err != nil {
58 t.Fatal(err)
59 }
60 got, _ = s.Get("repo-1")
61 if got.RoleFor("01M5WRITER000000000000000B") != acl.None {
62 t.Fatalf("the previous rule survived a replacement: %+v", got)
63 }
64
65 // Deleting the repository forgets its rules, so a new repository with a recycled id
66 // cannot inherit them.
67 if err := s.Delete("repo-1"); err != nil {
68 t.Fatal(err)
69 }
70 if a, err := s.Get("repo-1"); err != nil || a != nil {
71 t.Fatalf("rules survived deletion: %+v, %v", a, err)
72 }
73 }
74
75 // An access change is the one administrative act that has to leave a trace: the copy
76 // that lived in git kept it in the commit message.
77 func TestChangesAreAudited(t *testing.T) {
78 s, dir := open(t)
79 audit := filepath.Join(dir, "audit.log")
80 s.AuditPath = audit
81 if err := s.Set("repo-1", acl.Empty("01M5OWNER0000000000000000A"), "keenan", time.Now().UTC()); err != nil {
82 t.Fatal(err)
83 }
84 raw, err := os.ReadFile(audit)
85 if err != nil {
86 t.Fatalf("no audit line: %v", err)
87 }
88 if !strings.Contains(string(raw), "acl repo-1 updated by keenan") {
89 t.Fatalf("audit log = %q", raw)
90 }
91 }