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_test.go +133

@@ -0,0 +1,133 @@
1 package repo
2
3 // The upgrade path: an instance that stored access rules in refs/vole/acl keeps them
4 // when the rules move into the database. That has to be measured, because the failure
5 // mode is silent — a repository with no rules denies everybody, admins included.
6
7 import (
8 "path/filepath"
9 "testing"
10
11 "github.com/rodent-software/vole/internal/access"
12 "github.com/rodent-software/vole/internal/acl"
13 "github.com/rodent-software/vole/internal/gitx"
14 )
15
16 // withACLs returns a store whose rules live in a database beside the repositories, the
17 // way the instance has them.
18 func withACLs(t *testing.T) (*Store, *access.Store) {
19 t.Helper()
20 root := t.TempDir()
21 acls, err := access.Open(filepath.Join(root, "users.db"))
22 if err != nil {
23 t.Fatal(err)
24 }
25 t.Cleanup(func() { acls.Close() })
26 return New(filepath.Join(root, "repos"), "", acls), acls
27 }
28
29 // legacyWriteACL does what the old code did: a commit on refs/vole/acl holding
30 // acl.json. It is how a pre-upgrade repository looks.
31 func legacyWriteACL(t *testing.T, m *Meta, a *acl.ACL) {
32 t.Helper()
33 g := m.Git()
34 raw, err := a.Bytes()
35 if err != nil {
36 t.Fatal(err)
37 }
38 blob, err := g.HashObject(raw)
39 if err != nil {
40 t.Fatal(err)
41 }
42 tree, err := g.MkTree([]gitx.Entry{{Mode: "100644", Type: "blob", SHA: blob, Name: ACLPath}})
43 if err != nil {
44 t.Fatal(err)
45 }
46 commit, err := g.CommitTree(tree, "", "acl: update by keenan")
47 if err != nil {
48 t.Fatal(err)
49 }
50 if err := g.UpdateRef(ACLRef, commit); err != nil {
51 t.Fatal(err)
52 }
53 }
54
55 func TestImportACLsTakesRulesFromTheOldRef(t *testing.T) {
56 st, acls := withACLs(t)
57 m, err := st.Create("demo", "01M5OWNER0000000000000000A", "")
58 if err != nil {
59 t.Fatal(err)
60 }
61 // Drop the row the creation wrote, so the repository looks pre-upgrade, and put the
62 // rules where they used to live.
63 if err := acls.Delete(m.ID); err != nil {
64 t.Fatal(err)
65 }
66 legacy := acl.Empty("01M5OWNER0000000000000000A")
67 if err := legacy.SetEntry("01M5WRITER000000000000000B", "write"); err != nil {
68 t.Fatal(err)
69 }
70 legacy.SetRunners([]string{"01M5RUNNER000000000000000C"})
71 legacyWriteACL(t, m, legacy)
72
73 imported, err := st.ImportACLs()
74 if err != nil {
75 t.Fatal(err)
76 }
77 if imported != 1 {
78 t.Fatalf("imported %d repo(s), want 1", imported)
79 }
80 // The repository the caller is already holding has to see the rules too: the gate
81 // reads them from the same store.
82 got, err := m.ACL()
83 if err != nil {
84 t.Fatal(err)
85 }
86 if got.RoleFor("01M5WRITER000000000000000B") != acl.Write || !got.CanReport("01M5RUNNER000000000000000C") {
87 t.Fatalf("rules did not survive the import: %+v", got)
88 }
89
90 // Idempotent: a second start takes nothing, and a database row is never overwritten
91 // by a stale ref.
92 again, err := st.ImportACLs()
93 if err != nil || again != 0 {
94 t.Fatalf("second import = %d, %v", again, err)
95 }
96 }
97
98 func TestImportACLsLeavesRepositoriesWithoutTheRefAlone(t *testing.T) {
99 st, acls := withACLs(t)
100 m, err := st.Create("demo", "01M5OWNER0000000000000000A", "")
101 if err != nil {
102 t.Fatal(err)
103 }
104 if err := acls.Delete(m.ID); err != nil {
105 t.Fatal(err)
106 }
107 if n, err := st.ImportACLs(); err != nil || n != 0 {
108 t.Fatalf("imported %d repo(s) from nothing: %v", n, err)
109 }
110 // No rules is deny-everything, which is what a repository with no ACL ref always
111 // meant — and it is why the import has to run before anybody pushes.
112 got, err := m.ACL()
113 if err != nil {
114 t.Fatal(err)
115 }
116 if got.RoleFor("01M5OWNER0000000000000000A") != acl.None {
117 t.Fatalf("a repository with no rules granted something: %+v", got)
118 }
119 }
120
121 func TestDeletingARepositoryForgetsItsRules(t *testing.T) {
122 st, acls := withACLs(t)
123 m, err := st.Create("demo", "01M5OWNER0000000000000000A", "")
124 if err != nil {
125 t.Fatal(err)
126 }
127 if err := st.Delete("demo"); err != nil {
128 t.Fatal(err)
129 }
130 if got, err := acls.Get(m.ID); err != nil || got != nil {
131 t.Fatalf("rules outlived the repository: %+v, %v", got, err)
132 }
133 }