vole
Clone
Read only, no credentials:
https://rodent.software/git/vole.git
Write over SSH with a registered key:
git clone ssh://git@0.0.0.0:2222/vole.git
A free and open-source forge that lives in your Git repo.
Merge change request 01M70WES5ZZDJ6RP226WB11BBZ: Let people rename and remove their own passkeys
keenan · 1h ago
4b8c9e1d
| 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 | import ( |
| 7 | "errors" |
| 8 | "fmt" |
| 9 | "os" |
| 10 | "path/filepath" |
| 11 | "strings" |
| 12 | "sync" |
| 13 | "testing" |
| 14 | "time" |
| 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 | s.AuditPath = filepath.Join(dir, "audit.log") |
| 26 | t.Cleanup(func() { s.Close() }) |
| 27 | return s, dir |
| 28 | } |
| 29 | func TestRulesRoundTripAndReplace(t *testing.T) { |
| 30 | t.Parallel() |
| 31 | s, _ := open(t) |
| 32 | if a, err := s.Get("repo-1"); err != nil || a != nil { |
| 33 | t.Fatalf("a repository with no rules returned %+v, %v", a, err) |
| 34 | } |
| 35 | a := 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 | got, err := s.Get("repo-1") |
| 45 | if err != nil || got == nil { |
| 46 | t.Fatalf("get: %+v, %v", got, err) |
| 47 | } |
| 48 | if got.RoleFor("01M5WRITER000000000000000B") != Write || |
| 49 | !got.CanReport("01M5RUNNER000000000000000C") || |
| 50 | len(got.Protected) != 1 { |
| 51 | t.Fatalf("rules came back different: %+v", got) |
| 52 | } |
| 53 | // A blind replacement is refused: a rule set that was not read from the row |
| 54 | // cannot know what it is overwriting, so it could restore a role somebody just |
| 55 | // revoked. |
| 56 | if err := s.Set("repo-1", Empty("01M5OWNER0000000000000000A"), "keenan", time.Now().UTC()); !errors.Is(err, ErrConflict) { |
| 57 | t.Fatalf("a blind replacement = %v, want ErrConflict", err) |
| 58 | } |
| 59 | // A replacement through the rule set that was read is allowed, and nothing of |
| 60 | // the old list survives it. |
| 61 | next := Empty("01M5OWNER0000000000000000A") |
| 62 | next.revision = got.revision |
| 63 | if err := s.Set("repo-1", next, "keenan", time.Now().UTC()); err != nil { |
| 64 | t.Fatal(err) |
| 65 | } |
| 66 | got, _ = s.Get("repo-1") |
| 67 | if got.RoleFor("01M5WRITER000000000000000B") != None { |
| 68 | t.Fatalf("the previous rule survived a replacement: %+v", got) |
| 69 | } |
| 70 | // Deleting the repository forgets its rules, so a new repository with a recycled id |
| 71 | // cannot inherit them. |
| 72 | if err := s.Delete("repo-1"); err != nil { |
| 73 | t.Fatal(err) |
| 74 | } |
| 75 | if a, err := s.Get("repo-1"); err != nil || a != nil { |
| 76 | t.Fatalf("rules survived deletion: %+v, %v", a, err) |
| 77 | } |
| 78 | } |
| 79 | |
| 80 | // The store is where the roster's instance-wide role meets a repository's rules. |
| 81 | // A public repository gets the global grant; a private one does not, unless the |
| 82 | // account is an instance admin. |
| 83 | func TestStoreAttachesInstanceRoles(t *testing.T) { |
| 84 | t.Parallel() |
| 85 | s, _ := open(t) |
| 86 | s.InstanceRole = func(name string) Role { |
| 87 | switch name { |
| 88 | case "invited": |
| 89 | return Contribute |
| 90 | case "boss": |
| 91 | return Admin |
| 92 | } |
| 93 | return None |
| 94 | } |
| 95 | pub := Empty("owner") |
| 96 | pub.Default = "read" |
| 97 | if err := s.Set("public-repo", pub, "test", time.Now().UTC()); err != nil { |
| 98 | t.Fatal(err) |
| 99 | } |
| 100 | private := Empty("owner") |
| 101 | if err := s.Set("private-repo", private, "test", time.Now().UTC()); err != nil { |
| 102 | t.Fatal(err) |
| 103 | } |
| 104 | |
| 105 | got, err := s.Get("public-repo") |
| 106 | if err != nil { |
| 107 | t.Fatal(err) |
| 108 | } |
| 109 | if got.RoleFor("invited") != Contribute { |
| 110 | t.Fatalf("public instance role = %v", got.RoleFor("invited")) |
| 111 | } |
| 112 | if got.RepositoryRoleFor("invited") != Read { |
| 113 | t.Fatalf("repository-only role = %v, want the default read", got.RepositoryRoleFor("invited")) |
| 114 | } |
| 115 | got, err = s.Get("private-repo") |
| 116 | if err != nil { |
| 117 | t.Fatal(err) |
| 118 | } |
| 119 | if got.RoleFor("invited") != None { |
| 120 | t.Fatalf("private repo admitted instance contributor: %v", got.RoleFor("invited")) |
| 121 | } |
| 122 | if got.RoleFor("boss") != Admin { |
| 123 | t.Fatalf("instance admin on private repo = %v", got.RoleFor("boss")) |
| 124 | } |
| 125 | // A repository without a row still has to see the instance rules. |
| 126 | empty := s.Attach(Empty("")) |
| 127 | if empty.RoleFor("boss") != Admin { |
| 128 | t.Fatalf("attached empty ACL = %v", empty.RoleFor("boss")) |
| 129 | } |
| 130 | } |
| 131 | |
| 132 | // An access change is the one administrative act that has to leave a trace: the copy |
| 133 | // that lived in git kept it in the commit message. |
| 134 | func TestChangesAreAudited(t *testing.T) { |
| 135 | t.Parallel() |
| 136 | s, dir := open(t) |
| 137 | audit := filepath.Join(dir, "audit.log") |
| 138 | s.AuditPath = audit |
| 139 | if err := s.Set("repo-1", Empty("01M5OWNER0000000000000000A"), "keenan", time.Now().UTC()); err != nil { |
| 140 | t.Fatal(err) |
| 141 | } |
| 142 | raw, err := os.ReadFile(audit) |
| 143 | if err != nil { |
| 144 | t.Fatalf("no audit line: %v", err) |
| 145 | } |
| 146 | fields := strings.Split(strings.TrimSuffix(string(raw), "\n"), "\t") |
| 147 | if len(fields) != 5 { |
| 148 | t.Fatalf("audit line has %d columns, want 5: %q", len(fields), raw) |
| 149 | } |
| 150 | if fields[1] != "acl.update" || fields[2] != "keenan" || fields[3] != "repo-1" { |
| 151 | t.Fatalf("audit line = %q, want action acl.update by keenan on repo-1", raw) |
| 152 | } |
| 153 | } |
| 154 | |
| 155 | // An ACL change that cannot be recorded must not happen: the rule set is the |
| 156 | // instance's only copy, and an unrecorded grant is a grant nobody can explain. |
| 157 | func TestChangesFailWhenTheLogCannotBeWritten(t *testing.T) { |
| 158 | t.Parallel() |
| 159 | s, dir := open(t) |
| 160 | blocker := filepath.Join(dir, "blocked") |
| 161 | if err := os.WriteFile(blocker, []byte("x"), 0o600); err != nil { |
| 162 | t.Fatal(err) |
| 163 | } |
| 164 | s.AuditPath = filepath.Join(blocker, "audit.log") |
| 165 | owner := "01M5OWNER0000000000000000A" |
| 166 | if err := s.Set("repo-1", Empty(owner), "keenan", time.Now().UTC()); err == nil { |
| 167 | t.Fatal("Set succeeded with an unwritable audit log") |
| 168 | } |
| 169 | if got, err := s.Get("repo-1"); err != nil || got != nil { |
| 170 | t.Fatalf("rules were written anyway: %+v, %v", got, err) |
| 171 | } |
| 172 | // The deletion is recorded separately because the repository's content has to |
| 173 | // survive a failed audit, so it fails the same way and for the same reason. |
| 174 | if err := s.RecordDelete("repo-1", "keenan"); err == nil { |
| 175 | t.Fatal("RecordDelete succeeded with an unwritable audit log") |
| 176 | } |
| 177 | } |
| 178 | |
| 179 | // A key comment, a repository name and an account name are user content; none may |
| 180 | // add a column or a record of its own. |
| 181 | func TestAuditFieldsCannotInjectColumns(t *testing.T) { |
| 182 | t.Parallel() |
| 183 | s, dir := open(t) |
| 184 | audit := filepath.Join(dir, "audit.log") |
| 185 | s.AuditPath = audit |
| 186 | evile := "a\tb\nc" |
| 187 | if err := s.Set(evile, Empty("01M5OWNER0000000000000000A"), evile, time.Now().UTC()); err != nil { |
| 188 | t.Fatal(err) |
| 189 | } |
| 190 | raw, err := os.ReadFile(audit) |
| 191 | if err != nil { |
| 192 | t.Fatal(err) |
| 193 | } |
| 194 | if n := strings.Count(string(raw), "\n"); n != 1 { |
| 195 | t.Fatalf("injected field wrote %d lines: %q", n, raw) |
| 196 | } |
| 197 | if got := len(strings.Split(strings.TrimSuffix(string(raw), "\n"), "\t")); got != 5 { |
| 198 | t.Fatalf("injected field wrote %d columns: %q", got, raw) |
| 199 | } |
| 200 | } |
| 201 | |
| 202 | // The race the settings page used to lose: a repository admin's page was loaded |
| 203 | // while they were still an admin, an instance admin revokes that role, and the |
| 204 | // stale save then wrote the whole rule set back and restored it. A write against a |
| 205 | // rule set that was read before somebody else's change is now refused, so the |
| 206 | // revocation wins. |
| 207 | func TestARepoAdminSaveCannotRestoreARevokedRole(t *testing.T) { |
| 208 | t.Parallel() |
| 209 | s, _ := open(t) |
| 210 | owner := "01M5OWNER0000000000000000A" |
| 211 | if err := s.Set("repo-1", Empty(owner), "keenan", time.Now().UTC()); err != nil { |
| 212 | t.Fatal(err) |
| 213 | } |
| 214 | // Two readers load the same revision; the second is about to save a rule set |
| 215 | // that says somebody is an admin. |
| 216 | first, err := s.Get("repo-1") |
| 217 | if err != nil { |
| 218 | t.Fatal(err) |
| 219 | } |
| 220 | stale, err := s.Get("repo-1") |
| 221 | if err != nil { |
| 222 | t.Fatal(err) |
| 223 | } |
| 224 | if err := first.SetEntry("eve", "admin"); err != nil { |
| 225 | t.Fatal(err) |
| 226 | } |
| 227 | if err := s.Set("repo-1", first, "keenan", time.Now().UTC()); err != nil { |
| 228 | t.Fatal(err) |
| 229 | } |
| 230 | // The revocation lands. |
| 231 | if _, err := s.Update("repo-1", "root", func(a *ACL) error { |
| 232 | return a.SetEntry("eve", "none") |
| 233 | }); err != nil { |
| 234 | t.Fatal(err) |
| 235 | } |
| 236 | // The stale save cannot restore it. |
| 237 | if err := s.Set("repo-1", stale, "keenan", time.Now().UTC()); !errors.Is(err, ErrConflict) { |
| 238 | t.Fatalf("a stale write = %v, want ErrConflict", err) |
| 239 | } |
| 240 | got, err := s.Get("repo-1") |
| 241 | if err != nil { |
| 242 | t.Fatal(err) |
| 243 | } |
| 244 | if got.RoleFor("eve") == Admin { |
| 245 | t.Fatalf("a stale write restored a revoked role: %+v", got.Entries) |
| 246 | } |
| 247 | } |
| 248 | |
| 249 | // Update changes the row as it is now, so a change to one field cannot undo a |
| 250 | // concurrent change to another. |
| 251 | func TestUpdateKeepsAConcurrentChange(t *testing.T) { |
| 252 | t.Parallel() |
| 253 | s, _ := open(t) |
| 254 | if _, err := s.Update("repo-1", "keenan", func(a *ACL) error { |
| 255 | return a.SetEntry("owner", "admin") |
| 256 | }); err != nil { |
| 257 | t.Fatal(err) |
| 258 | } |
| 259 | // Both writers loaded nothing in particular; each applies the field it owns. |
| 260 | if _, err := s.Update("repo-1", "keenan", func(a *ACL) error { |
| 261 | return a.SetEntry("alice", "maintain") |
| 262 | }); err != nil { |
| 263 | t.Fatal(err) |
| 264 | } |
| 265 | if _, err := s.Update("repo-1", "keenan", func(a *ACL) error { |
| 266 | return a.SetEntry("bob", "read") |
| 267 | }); err != nil { |
| 268 | t.Fatal(err) |
| 269 | } |
| 270 | got, err := s.Get("repo-1") |
| 271 | if err != nil { |
| 272 | t.Fatal(err) |
| 273 | } |
| 274 | for name, want := range map[string]Role{"owner": Admin, "alice": Maintain, "bob": Read} { |
| 275 | if got.RoleFor(name) != want { |
| 276 | t.Fatalf("%s = %v, want %v (entries %+v)", name, got.RoleFor(name), want, got.Entries) |
| 277 | } |
| 278 | } |
| 279 | } |
| 280 | |
| 281 | // Concurrent field-scoped edits all survive: the store retries the compare-and-swap |
| 282 | // instead of dropping the losers. |
| 283 | func TestConcurrentUpdatesAllLand(t *testing.T) { |
| 284 | t.Parallel() |
| 285 | s, _ := open(t) |
| 286 | const writers = 8 |
| 287 | var wg sync.WaitGroup |
| 288 | errs := make(chan error, writers) |
| 289 | for i := 0; i < writers; i++ { |
| 290 | wg.Add(1) |
| 291 | go func(i int) { |
| 292 | defer wg.Done() |
| 293 | name := fmt.Sprintf("account-%d", i) |
| 294 | _, err := s.Update("repo-1", "keenan", func(a *ACL) error { |
| 295 | return a.SetEntry(name, "read") |
| 296 | }) |
| 297 | if err != nil { |
| 298 | errs <- err |
| 299 | } |
| 300 | }(i) |
| 301 | } |
| 302 | wg.Wait() |
| 303 | close(errs) |
| 304 | for err := range errs { |
| 305 | t.Fatalf("Update: %v", err) |
| 306 | } |
| 307 | got, err := s.Get("repo-1") |
| 308 | if err != nil { |
| 309 | t.Fatal(err) |
| 310 | } |
| 311 | for i := 0; i < writers; i++ { |
| 312 | if name := fmt.Sprintf("account-%d", i); got.RoleFor(name) != Read { |
| 313 | t.Fatalf("%s did not land: %+v", name, got.Entries) |
| 314 | } |
| 315 | } |
| 316 | } |
| 317 | |
| 318 | // A retired account stops counting even while its name is still in the rules: the |
| 319 | // row outlives the account, so membership alone is not a grant that lasts. |
| 320 | func TestRetiredAccountsStopCounting(t *testing.T) { |
| 321 | t.Parallel() |
| 322 | s, _ := open(t) |
| 323 | s.Active = func(name string) bool { return name != "retired" } |
| 324 | if _, err := s.Update("repo-1", "keenan", func(a *ACL) error { |
| 325 | if err := a.SetEntry("retired", "maintain"); err != nil { |
| 326 | return err |
| 327 | } |
| 328 | a.SetRunners([]string{"retired", "active-runner"}) |
| 329 | return nil |
| 330 | }); err != nil { |
| 331 | t.Fatal(err) |
| 332 | } |
| 333 | got, err := s.Get("repo-1") |
| 334 | if err != nil { |
| 335 | t.Fatal(err) |
| 336 | } |
| 337 | if got.EligibleApprover("retired") { |
| 338 | t.Fatal("a retired maintainer still counts as an approver") |
| 339 | } |
| 340 | if !got.EligibleApprover("keenan") && got.RoleFor("keenan") == Admin { |
| 341 | t.Fatal("an active admin stopped counting") |
| 342 | } |
| 343 | if got.CanReport("retired") { |
| 344 | t.Fatal("a retired runner still counts as a runner") |
| 345 | } |
| 346 | if !got.CanReport("active-runner") { |
| 347 | t.Fatal("an active runner stopped counting") |
| 348 | } |
| 349 | } |
| 350 | |
| 351 | // An approval needs current standing, not just a file on the branch: a contributor |
| 352 | // who writes approvals/<their-own-name>.md cannot satisfy a review. |
| 353 | func TestEligibleApproverNeedsMaintainOrAdmin(t *testing.T) { |
| 354 | t.Parallel() |
| 355 | s, _ := open(t) |
| 356 | if _, err := s.Update("repo-1", "keenan", func(a *ACL) error { |
| 357 | if err := a.SetEntry("writer", "contribute"); err != nil { |
| 358 | return err |
| 359 | } |
| 360 | a.Default = "read" |
| 361 | return nil |
| 362 | }); err != nil { |
| 363 | t.Fatal(err) |
| 364 | } |
| 365 | got, err := s.Get("repo-1") |
| 366 | if err != nil { |
| 367 | t.Fatal(err) |
| 368 | } |
| 369 | if got.EligibleApprover("writer") { |
| 370 | t.Fatal("a contributor's approval counts") |
| 371 | } |
| 372 | if got.EligibleApprover("stranger") { |
| 373 | t.Fatal("an approval from somebody with only the default role counts") |
| 374 | } |
| 375 | } |
| 376 |