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 | } | ||