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/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.

Code

394 commit(s) on main

Default the run verbs to origin Keenan Nemetz · 9m ago 2e6aa96e
history blame raw 25.5 KiB
1package identity
2
3import (
4 "crypto/ed25519"
5 "crypto/rand"
6 "os"
7 "path/filepath"
8 "strings"
9 "sync"
10 "testing"
11
12 "golang.org/x/crypto/ssh"
13 "rodent.software/vole/forge/access"
14)
15
16func newStore(t *testing.T) *Store {
17 t.Helper()
18 path := filepath.Join(t.TempDir(), "users.db")
19 if err := Init(path); err != nil {
20 t.Fatal(err)
21 }
22 s, err := Open(path)
23 if err != nil {
24 t.Fatal(err)
25 }
26 t.Cleanup(func() { s.Close() })
27 s.AuditPath = filepath.Join(filepath.Dir(path), "audit.log")
28 return s
29}
30
31func pubLine(t *testing.T) (string, string) {
32 t.Helper()
33 pub, _, err := ed25519.GenerateKey(rand.Reader)
34 if err != nil {
35 t.Fatal(err)
36 }
37 k, err := ssh.NewPublicKey(pub)
38 if err != nil {
39 t.Fatal(err)
40 }
41 return strings.TrimSpace(string(ssh.MarshalAuthorizedKey(k))), ssh.FingerprintSHA256(k)
42}
43
44func mustCreate(t *testing.T, s *Store, name string) *Account {
45 t.Helper()
46 return mustCreateBy(t, s, name, "test")
47}
48
49func mustCreateBy(t *testing.T, s *Store, name, actor string) *Account {
50 t.Helper()
51 line, _ := pubLine(t)
52 a, err := s.Create(name, line, actor)
53 if err != nil {
54 t.Fatalf("create %s: %v", name, err)
55 }
56 return a
57}
58
59func TestEmptyRosterLoads(t *testing.T) {
60 t.Parallel()
61 s := newStore(t)
62 if got := s.Snapshot().Count; got != 0 {
63 t.Fatalf("count = %d, want 0", got)
64 }
65}
66
67func TestMissingDatabaseIsAnError(t *testing.T) {
68 t.Parallel()
69 if _, err := Open(filepath.Join(t.TempDir(), "nope.db")); err == nil {
70 t.Fatal("Open succeeded on a missing database")
71 }
72}
73
74func TestCreateAccountWithKeyAndAddKey(t *testing.T) {
75 t.Parallel()
76 s := newStore(t)
77 line, wantFP := pubLine(t)
78 a, err := s.Create("keenan", line, "test")
79 if err != nil {
80 t.Fatal(err)
81 }
82 if len(a.Keys) != 1 || a.Keys[0].Fingerprint != wantFP {
83 t.Fatalf("created account keys = %+v, want the supplied key %s", a.Keys, wantFP)
84 }
85 if name, ok := s.Lookup(wantFP); !ok || name != "keenan" {
86 t.Fatalf("Lookup = %q,%v; want keenan,true", name, ok)
87 }
88
89 // add-key remains the way to attach a second credential.
90 second, secondFP := pubLine(t)
91 fp, err := s.AddKey("keenan", second, "test")
92 if err != nil {
93 t.Fatal(err)
94 }
95 if fp != secondFP {
96 t.Fatalf("added fingerprint = %s, want %s", fp, secondFP)
97 }
98}
99
100// TestCreateRequiresAKeyAndDoesNotReserveTheName: a failed attempt must leave
101// neither an account nor a name reservation behind, or open registration could
102// claim names without presenting a credential.
103func TestCreateRequiresAKeyAndDoesNotReserveTheName(t *testing.T) {
104 t.Parallel()
105 s := newStore(t)
106 for _, bad := range []string{"", "not an SSH key"} {
107 if _, err := s.Create("alice", bad, "test"); err == nil {
108 t.Fatalf("Create accepted key %q", bad)
109 }
110 }
111 if _, err := s.Find("alice"); err == nil {
112 t.Fatal("a failed create left an account behind")
113 }
114 line, _ := pubLine(t)
115 a, err := s.Create("alice", line, "test")
116 if err != nil {
117 t.Fatalf("the failed attempts reserved the name: %v", err)
118 }
119 if got := len(a.Keys); got != 1 {
120 t.Fatalf("keys = %d, want 1", got)
121 }
122}
123
124// TestCreateRejectsAKeyAlreadyRegistered: two accounts cannot share a key, and
125// the second account must not be created at all.
126func TestCreateRejectsAKeyAlreadyRegistered(t *testing.T) {
127 t.Parallel()
128 s := newStore(t)
129 line, _ := pubLine(t)
130 if _, err := s.Create("alice", line, "test"); err != nil {
131 t.Fatal(err)
132 }
133 if _, err := s.Create("bob", line, "test"); err == nil {
134 t.Fatal("a registered key was accepted for a second account")
135 } else if !strings.Contains(err.Error(), "already registered") {
136 t.Fatalf("unclear error: %v", err)
137 }
138 if _, err := s.Find("bob"); err == nil {
139 t.Fatal("the duplicate-key failure left an account behind")
140 }
141 fresh, _ := pubLine(t)
142 if _, err := s.Create("bob", fresh, "test"); err != nil {
143 t.Fatalf("the duplicate-key failure reserved bob's name: %v", err)
144 }
145}
146
147func TestMultipleKeysPerAccount(t *testing.T) {
148 t.Parallel()
149 s := newStore(t)
150 line, _ := pubLine(t)
151 if _, err := s.Create("keenan", line, "test"); err != nil {
152 t.Fatal(err)
153 }
154 var fps []string
155 for i := 0; i < 2; i++ {
156 line, fp := pubLine(t)
157 if _, err := s.AddKey("keenan", line, "test"); err != nil {
158 t.Fatal(err)
159 }
160 fps = append(fps, fp)
161 }
162 reopened, err := Open(s.Path())
163 if err != nil {
164 t.Fatal(err)
165 }
166 defer reopened.Close()
167 if got := len(reopened.Snapshot().ByName["keenan"].Keys); got != 3 {
168 t.Fatalf("keys after reopen = %d, want 3", got)
169 }
170 if got := len(s.Snapshot().ByName["keenan"].Keys); got != 3 {
171 t.Fatalf("keys = %d, want 3", got)
172 }
173 for _, fp := range fps {
174 if _, ok := s.Lookup(fp); !ok {
175 t.Fatalf("key %s lost", fp)
176 }
177 }
178}
179
180func TestDuplicateKeyAcrossAccountsRejected(t *testing.T) {
181 t.Parallel()
182 s := newStore(t)
183 line, _ := pubLine(t)
184 if _, err := s.Create("alice", line, "test"); err != nil {
185 t.Fatal(err)
186 }
187 bobLine, _ := pubLine(t)
188 if _, err := s.Create("bob", bobLine, "test"); err != nil {
189 t.Fatal(err)
190 }
191 if _, err := s.AddKey("bob", line, "test"); err == nil {
192 t.Fatal("same key accepted for a second account")
193 } else if !strings.Contains(err.Error(), "already registered") {
194 t.Fatalf("unclear error: %v", err)
195 }
196}
197
198// TestOneKeyBelongsToOneAccount is now a database constraint rather than a
199// parse-time check: user_keys is keyed by fingerprint, so a hand-written second
200// row cannot exist.
201func TestOneKeyBelongsToOneAccount(t *testing.T) {
202 t.Parallel()
203 s := newStore(t)
204 mustCreate(t, s, "alice")
205 mustCreate(t, s, "bob")
206 line, fp := pubLine(t)
207 if _, err := s.AddKey("alice", line, "test"); err != nil {
208 t.Fatal(err)
209 }
210 bob, err := s.Find("bob")
211 if err != nil {
212 t.Fatal(err)
213 }
214 raw, err := openDB(s.Path())
215 if err != nil {
216 t.Fatal(err)
217 }
218 defer raw.Close()
219 if _, err := raw.Exec(`INSERT INTO user_keys(fingerprint, user_id, type, line) VALUES(?,?,'ssh-ed25519',?)`,
220 fp, bob.Name, line); err == nil {
221 t.Fatal("a second account was allowed to claim a registered key")
222 }
223}
224
225// TestCorruptRosterFailsStartup: a roster that does not parse must refuse to
226// load, and a running store must keep its previous snapshot rather than boot
227// with a partial roster.
228func TestCorruptRosterFailsStartup(t *testing.T) {
229 t.Parallel()
230 s := newStore(t)
231 mustCreate(t, s, "alice")
232 raw, err := openDB(s.Path())
233 if err != nil {
234 t.Fatal(err)
235 }
236 if _, err := raw.Exec(`INSERT INTO users(name, status, created) VALUES('Not A Name', 'active', 0)`); err != nil {
237 t.Fatal(err)
238 }
239 raw.Close()
240 if _, err := Open(s.Path()); err == nil {
241 t.Fatal("a corrupt roster loaded; it must refuse to start")
242 }
243 if err := s.Reload(); err == nil {
244 t.Fatal("Reload accepted a corrupt roster")
245 }
246 if _, ok := s.Snapshot().ByName["alice"]; !ok {
247 t.Fatal("a failed reload discarded the working snapshot")
248 }
249}
250
251func TestRemoveKey(t *testing.T) {
252 t.Parallel()
253 s := newStore(t)
254 keepLine, keepFP := pubLine(t)
255 dropLine, dropFP := pubLine(t)
256 if _, err := s.Create("keenan", keepLine, "test"); err != nil {
257 t.Fatal(err)
258 }
259 if _, err := s.AddKey("keenan", dropLine, "test"); err != nil {
260 t.Fatal(err)
261 }
262 if err := s.RemoveKey("keenan", dropFP, "test"); err != nil {
263 t.Fatal(err)
264 }
265 if _, ok := s.Lookup(dropFP); ok {
266 t.Fatal("revoked key still authenticates")
267 }
268 if _, ok := s.Lookup(keepFP); !ok {
269 t.Fatal("removing one key took the other with it")
270 }
271}
272
273// TestRetireStopsAuthenticationAndReservesTheName is the property that stops a
274// new person inheriting a departed one's access.
275func TestRetireStopsAuthenticationAndReservesTheName(t *testing.T) {
276 t.Parallel()
277 s := newStore(t)
278 line, fp := pubLine(t)
279 a, err := s.Create("wilbur", line, "test")
280 if err != nil {
281 t.Fatal(err)
282 }
283 if _, err := s.Retire("wilbur", "test"); err != nil {
284 t.Fatal(err)
285 }
286 if _, ok := s.Lookup(fp); ok {
287 t.Fatal("a retired account's key still authenticates")
288 }
289 reissue, _ := pubLine(t)
290 if _, err := s.Create("wilbur", reissue, "test"); err == nil {
291 t.Fatalf("the retired name was reissued to a new account")
292 } else if !strings.Contains(err.Error(), "cannot be reused") {
293 t.Fatalf("unclear error: %v", err)
294 }
295 // The account itself still exists for history.
296 if got, err := s.Find(a.Name); err != nil || got.Status != "retired" {
297 t.Fatalf("Find after retire = %v, %v", got, err)
298 }
299}
300
301// TestNameIsTheOnlyIdentity: an account is its registered name. The name may
302// not be taken by a second account, retired or not.
303func TestNameIsTheOnlyIdentity(t *testing.T) {
304 t.Parallel()
305 s := newStore(t)
306 line, fp := pubLine(t)
307 a, err := s.Create("wilbur", line, "test")
308 if err != nil {
309 t.Fatal(err)
310 }
311 if a.Name != "wilbur" {
312 t.Fatalf("created account name = %q", a.Name)
313 }
314 if name, ok := s.Lookup(fp); !ok || name != a.Name {
315 t.Fatalf("Lookup = %q,%v; want %q", name, ok, a.Name)
316 }
317 reissue, _ := pubLine(t)
318 if _, err := s.Create("wilbur", reissue, "test"); err == nil {
319 t.Fatal("a second account took the name")
320 }
321 if _, err := s.Find("nobody"); err == nil {
322 t.Fatal("Find accepted an unknown account")
323 }
324}
325
326func TestInvalidNamesRefused(t *testing.T) {
327 t.Parallel()
328 s := newStore(t)
329 line, _ := pubLine(t)
330 for _, name := range []string{"Keenan", "has space", "a", "with/slash", "trailing."} {
331 if _, err := s.Create(name, line, "test"); err == nil {
332 t.Fatalf("%q accepted as a name", name)
333 }
334 }
335}
336
337// TestConcurrentAddsAllLand: key adds from concurrent goroutines each commit their
338// own row, so none may be lost to a read-modify-write race.
339func TestConcurrentAddsAllLand(t *testing.T) {
340 t.Parallel()
341 s := newStore(t)
342 const n = 8
343 lines := make([]string, n)
344 for i := range lines {
345 lines[i], _ = pubLine(t)
346 }
347 if _, err := s.Create("keenan", lines[0], "test"); err != nil {
348 t.Fatal(err)
349 }
350 var wg sync.WaitGroup
351 errs := make([]error, n)
352 for i := 1; i < n; i++ {
353 wg.Add(1)
354 go func(i int) {
355 defer wg.Done()
356 _, errs[i] = s.AddKey("keenan", lines[i], "test")
357 }(i)
358 }
359 wg.Wait()
360 for i, err := range errs {
361 if err != nil {
362 t.Fatalf("add %d: %v", i, err)
363 }
364 }
365 if err := s.Reload(); err != nil {
366 t.Fatal(err)
367 }
368 if got := len(s.Snapshot().ByName["keenan"].Keys); got != n {
369 t.Fatalf("after concurrent adds: %d keys, want %d", got, n)
370 }
371}
372
373func TestAuditLogRecordsChanges(t *testing.T) {
374 t.Parallel()
375 s := newStore(t)
376 line, fp := pubLine(t)
377 if _, err := s.Create("keenan", line, "test"); err != nil {
378 t.Fatal(err)
379 }
380 second, secondFP := pubLine(t)
381 if _, err := s.AddKey("keenan", second, "test"); err != nil {
382 t.Fatal(err)
383 }
384 if err := s.RemoveKey("keenan", secondFP, "test"); err != nil {
385 t.Fatal(err)
386 }
387 data, err := os.ReadFile(s.AuditPath)
388 if err != nil {
389 t.Fatal(err)
390 }
391 log := string(data)
392 for _, want := range []string{"identity.create", "identity.add-key", "identity.remove-key", fp, secondFP} {
393 if !strings.Contains(log, want) {
394 t.Fatalf("audit log missing %q:\n%s", want, log)
395 }
396 }
397}
398
399// TestRosterUpgradesFromTheOldSchema: rosters used to key accounts by an internal
400// id. Opening one must rebuild every table around the registered name and drop
401// the alias table rather than leave a list of former names nothing resolves.
402func TestRosterUpgradesFromTheOldSchema(t *testing.T) {
403 t.Parallel()
404 path := filepath.Join(t.TempDir(), "users.db")
405 db, err := openDB(path)
406 if err != nil {
407 t.Fatal(err)
408 }
409 keyLine, keyFP := pubLine(t)
410 if _, err := db.Exec(`
411CREATE TABLE users (
412 id TEXT PRIMARY KEY,
413 name TEXT NOT NULL UNIQUE,
414 display TEXT NOT NULL DEFAULT '',
415 status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active','retired')),
416 created INTEGER NOT NULL
417);
418CREATE TABLE user_names (
419 name TEXT PRIMARY KEY,
420 user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE
421);
422CREATE TABLE user_keys (
423 fingerprint TEXT PRIMARY KEY,
424 user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
425 type TEXT NOT NULL,
426 line TEXT NOT NULL,
427 comment TEXT NOT NULL DEFAULT ''
428);
429CREATE TABLE instance_admins (
430 user_id TEXT PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE
431);
432CREATE TABLE user_credentials (
433 credential_id TEXT PRIMARY KEY,
434 user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
435 public_key BLOB NOT NULL,
436 attestation_type TEXT NOT NULL DEFAULT '',
437 transports TEXT NOT NULL DEFAULT '[]',
438 flags INTEGER NOT NULL DEFAULT 0,
439 aaguid BLOB,
440 sign_count INTEGER NOT NULL DEFAULT 0,
441 clone_warning INTEGER NOT NULL DEFAULT 0,
442 attachment TEXT NOT NULL DEFAULT '',
443 label TEXT NOT NULL DEFAULT '',
444 created INTEGER NOT NULL,
445 last_used INTEGER NOT NULL DEFAULT 0
446);
447CREATE TABLE user_invites (
448 token_hash TEXT PRIMARY KEY,
449 user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
450 created INTEGER NOT NULL,
451 expires INTEGER NOT NULL,
452 used_at INTEGER,
453 created_by TEXT NOT NULL DEFAULT ''
454);
455CREATE TABLE sessions (
456 token_hash TEXT PRIMARY KEY,
457 account_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
458 credential_id TEXT REFERENCES user_credentials(credential_id) ON DELETE CASCADE,
459 created INTEGER NOT NULL,
460 last_seen INTEGER NOT NULL,
461 expires INTEGER NOT NULL,
462 user_agent TEXT NOT NULL DEFAULT ''
463);
464INSERT INTO users(id, name, display, status, created) VALUES('01M5JR2JK29KKMAA1T9MDNTPBP', 'wilbur', 'Wilbur Wright', 'active', 0);
465INSERT INTO user_names(name, user_id) VALUES('wilbur', '01M5JR2JK29KKMAA1T9MDNTPBP');
466INSERT INTO user_names(name, user_id) VALUES('robert', '01M5JR2JK29KKMAA1T9MDNTPBP');
467INSERT INTO instance_admins(user_id) VALUES('01M5JR2JK29KKMAA1T9MDNTPBP');
468INSERT INTO user_credentials(credential_id, user_id, public_key, created) VALUES('cred-1', '01M5JR2JK29KKMAA1T9MDNTPBP', x'00', 0);
469INSERT INTO user_invites(token_hash, user_id, created, expires) VALUES('` + hashToken("tok") + `', '01M5JR2JK29KKMAA1T9MDNTPBP', 0, 9999999999);
470INSERT INTO sessions(token_hash, account_id, credential_id, created, last_seen, expires) VALUES('sess-1', '01M5JR2JK29KKMAA1T9MDNTPBP', 'cred-1', 0, 0, 9999999999);
471CREATE TABLE repo_acl (
472 repo_id TEXT PRIMARY KEY,
473 acl TEXT NOT NULL,
474 updated INTEGER NOT NULL,
475 updated_by TEXT NOT NULL DEFAULT ''
476);
477INSERT INTO repo_acl(repo_id, acl, updated, updated_by) VALUES('repo-1',
478 '{"version":1,"default":"none","entries":{"01M5JR2JK29KKMAA1T9MDNTPBP":"admin"},"runners":["01M5JR2JK29KKMAA1T9MDNTPBP"]}',
479 0, '01M5JR2JK29KKMAA1T9MDNTPBP');
480CREATE TABLE lfs_locks (
481 id TEXT PRIMARY KEY,
482 repo_id TEXT NOT NULL,
483 path TEXT NOT NULL,
484 ref TEXT NOT NULL DEFAULT '',
485 owner TEXT NOT NULL,
486 locked_at INTEGER NOT NULL
487);
488INSERT INTO lfs_locks(id, repo_id, path, owner, locked_at) VALUES('lock-1', 'repo-1', 'art.psd', '01M5JR2JK29KKMAA1T9MDNTPBP', 0);`); err != nil {
489 t.Fatal(err)
490 }
491 if _, err := db.Exec(`INSERT INTO user_keys(fingerprint, user_id, type, line) VALUES(?,?,?,?)`,
492 keyFP, "01M5JR2JK29KKMAA1T9MDNTPBP", "ssh-ed25519", keyLine); err != nil {
493 t.Fatal(err)
494 }
495 if err := db.Close(); err != nil {
496 t.Fatal(err)
497 }
498
499 s, err := Open(path)
500 if err != nil {
501 t.Fatal(err)
502 }
503 defer s.Close()
504 a, err := s.Find("wilbur")
505 if err != nil {
506 t.Fatal(err)
507 }
508 if a.Name != "wilbur" || !a.Admin || len(a.Keys) != 1 {
509 t.Fatalf("upgraded account = %+v", a)
510 }
511 // An account that predates the kind column is a person: defaulting an upgrade
512 // to bot would turn every operator into an automation that cannot sign in.
513 if a.Kind != KindPerson || a.Bot() {
514 t.Fatalf("upgraded account kind = %q, want %q", a.Kind, KindPerson)
515 }
516 if name, ok := s.Lookup(a.Keys[0].Fingerprint); !ok || name != "wilbur" {
517 t.Fatalf("upgraded key resolves to %q,%v", name, ok)
518 }
519 if creds, err := s.Credentials("wilbur"); err != nil || len(creds) != 1 {
520 t.Fatalf("upgraded credentials = %v, err = %v", creds, err)
521 }
522 if inviter, err := s.InviteAccount("tok"); err != nil || inviter.Name != "wilbur" {
523 t.Fatalf("upgraded invite = %v, err = %v", inviter, err)
524 }
525 var sessions int
526 if err := s.db.QueryRow(`SELECT count(*) FROM sessions WHERE user_name='wilbur'`).Scan(&sessions); err != nil || sessions != 1 {
527 t.Fatalf("upgraded sessions = %d, err = %v", sessions, err)
528 }
529 var acl, aclUpdatedBy, lockOwner string
530 if err := s.db.QueryRow(`SELECT acl, updated_by FROM repo_acl WHERE repo_id='repo-1'`).Scan(&acl, &aclUpdatedBy); err != nil {
531 t.Fatal(err)
532 }
533 if !strings.Contains(acl, `"wilbur"`) || strings.Contains(acl, "01M5JR2JK29") || aclUpdatedBy != "wilbur" {
534 t.Fatalf("upgraded ACL = %s (updated_by %q)", acl, aclUpdatedBy)
535 }
536 if err := s.db.QueryRow(`SELECT owner FROM lfs_locks WHERE id='lock-1'`).Scan(&lockOwner); err != nil || lockOwner != "wilbur" {
537 t.Fatalf("upgraded lock owner = %q, err = %v", lockOwner, err)
538 }
539 // The former name was only ever an alias, so it no longer resolves.
540 if _, err := s.Find("robert"); err == nil {
541 t.Fatal("an entry from the dropped alias table still resolves")
542 }
543 var n int
544 if err := s.db.QueryRow(`SELECT COUNT(*) FROM sqlite_master WHERE name='user_names'`).Scan(&n); err != nil {
545 t.Fatal(err)
546 }
547 if n != 0 {
548 t.Fatal("the user_names table survived the upgrade")
549 }
550}
551
552// TestRosterIsOneDatabaseFile proves the backup story: the roster is a single
553// SQLite file, and Backup takes a consistent copy of it without a system repo or
554// a directory of key files beside it.
555func TestRosterIsOneDatabaseFile(t *testing.T) {
556 t.Parallel()
557 s := newStore(t)
558 line, fp := pubLine(t)
559 a, err := s.Create("keenan", line, "test")
560 if err != nil {
561 t.Fatal(err)
562 }
563
564 copied := filepath.Join(t.TempDir(), "users.db")
565 if err := s.Backup(copied); err != nil {
566 t.Fatal(err)
567 }
568 other, err := Open(copied)
569 if err != nil {
570 t.Fatal(err)
571 }
572 defer other.Close()
573 if got, err := other.Find("keenan"); err != nil || got.Name != a.Name {
574 t.Fatalf("copied roster does not resolve keenan: %v", err)
575 }
576 if name, ok := other.Lookup(fp); !ok || name != a.Name {
577 t.Fatal("copied roster lost the key")
578 }
579
580 for _, absent := range []string{"system.git", "trusted_keys"} {
581 if _, err := os.Stat(filepath.Join(filepath.Dir(s.Path()), absent)); err == nil {
582 t.Fatalf("%s exists; the roster is supposed to be one database file", absent)
583 }
584 }
585}
586
587func TestInstanceAdminsAreExplicitAndAudited(t *testing.T) {
588 t.Parallel()
589 s := newStore(t)
590 alice := mustCreateBy(t, s, "alice", "operator")
591 if s.IsAdmin(alice.Name) {
592 t.Fatal("a new account is an instance admin")
593 }
594 if _, err := s.SetAdmin("alice", true, "operator"); err != nil {
595 t.Fatal(err)
596 }
597 if !s.IsAdmin(alice.Name) {
598 t.Fatal("alice was not made an instance admin")
599 }
600 if a, err := s.Find("alice"); err != nil || !a.Admin {
601 t.Fatalf("admin flag was not loaded into the account: %+v, %v", a, err)
602 }
603 log, err := os.ReadFile(s.AuditPath)
604 if err != nil {
605 t.Fatal(err)
606 }
607 if !strings.Contains(string(log), "identity.grant-admin\toperator\talice") {
608 t.Fatalf("admin audit line does not name the actor:\n%s", log)
609 }
610 bobKey, _ := pubLine(t)
611 if _, err := s.Create("bob", bobKey, "operator"); err != nil {
612 t.Fatal(err)
613 }
614 if _, err := s.SetAdmin("bob", true, "operator"); err != nil {
615 t.Fatal(err)
616 }
617 if _, err := s.SetAdmin("alice", false, "operator"); err != nil {
618 t.Fatal(err)
619 }
620 if s.IsAdmin(alice.Name) {
621 t.Fatal("admin grant survived revocation")
622 }
623}
624
625func TestInstanceRolesAreAccountStanding(t *testing.T) {
626 t.Parallel()
627 s := newStore(t)
628 line, _ := pubLine(t)
629 alice, err := s.Create("alice", line, "operator")
630 if err != nil {
631 t.Fatal(err)
632 }
633 if alice.Role != access.None || s.InstanceRole(alice.Name) != access.None {
634 t.Fatalf("new account role = %v", alice.Role)
635 }
636 if _, err := s.SetInstanceRole(alice.Name, access.Contribute, "operator"); err != nil {
637 t.Fatal(err)
638 }
639 a, err := s.Find(alice.Name)
640 if err != nil {
641 t.Fatal(err)
642 }
643 if a.Role != access.Contribute || a.Admin || s.IsAdmin(alice.Name) {
644 t.Fatalf("instance role = %v admin=%t", a.Role, a.Admin)
645 }
646 if s.InstanceRole(alice.Name) != access.Contribute {
647 t.Fatalf("InstanceRole = %v", s.InstanceRole(alice.Name))
648 }
649 bob, inv, err := s.CreateWithInviteRole("bob", access.Maintain, "operator", 0)
650 if err != nil || inv == nil {
651 t.Fatal(err)
652 }
653 if bob.Role != access.Maintain {
654 t.Fatalf("invited role = %v", bob.Role)
655 }
656 if _, err := s.SetInstanceRole(alice.Name, access.Admin, "operator"); err != nil {
657 t.Fatal(err)
658 }
659 if !s.IsAdmin(alice.Name) {
660 t.Fatal("Admin role did not grant instance admin")
661 }
662 if _, err := s.SetInstanceRole(alice.Name, access.None, "operator"); err == nil {
663 t.Fatal("removed the last instance admin")
664 }
665 if _, err := s.Retire(bob.Name, "operator"); err != nil {
666 t.Fatal(err)
667 }
668 if got := s.InstanceRole(bob.Name); got != access.None {
669 t.Fatalf("retired account role = %v, want none", got)
670 }
671 log, err := os.ReadFile(s.AuditPath)
672 if err != nil {
673 t.Fatal(err)
674 }
675 if !strings.Contains(string(log), "identity.set-role\toperator\talice\trole=contribute") {
676 t.Fatalf("role change was not audited:\n%s", log)
677 }
678}
679
680func TestLastInstanceAdminCannotBeRemoved(t *testing.T) {
681 t.Parallel()
682 s := newStore(t)
683 alice := mustCreateBy(t, s, "alice", "operator")
684 bob := mustCreateBy(t, s, "bob", "operator")
685 if _, err := s.SetAdmin(alice.Name, true, "operator"); err != nil {
686 t.Fatal(err)
687 }
688 if _, err := s.SetAdmin(alice.Name, false, "operator"); err == nil {
689 t.Fatal("revoked the last instance admin")
690 }
691 if _, err := s.Retire(alice.Name, "operator"); err == nil {
692 t.Fatal("retired the last instance admin")
693 }
694 if _, err := s.SetAdmin(bob.Name, true, "operator"); err != nil {
695 t.Fatal(err)
696 }
697 if _, err := s.SetAdmin(alice.Name, false, "operator"); err != nil {
698 t.Fatalf("could not revoke an admin when another remained: %v", err)
699 }
700 if _, err := s.Retire(bob.Name, "operator"); err == nil {
701 t.Fatal("retired the last instance admin after revoking the other")
702 }
703}
704
705// A key comment is whatever the person pasting the key typed. A tab or a newline
706// in it must not add a column or a second roster change to the log.
707func TestKeyCommentCannotInjectColumnsIntoTheAuditLog(t *testing.T) {
708 t.Parallel()
709 s := newStore(t)
710 mustCreate(t, s, "keenan")
711 before, err := os.ReadFile(s.AuditPath)
712 if err != nil {
713 t.Fatal(err)
714 }
715 line, _ := pubLine(t)
716 injected := line + " evil\tcomment\n2026-01-01T00:00:00Z\tidentity.create\troot\troot"
717 if _, err := s.AddKey("keenan", injected, "keenan"); err != nil {
718 t.Fatalf("AddKey: %v", err)
719 }
720 after, err := os.ReadFile(s.AuditPath)
721 if err != nil {
722 t.Fatal(err)
723 }
724 added := strings.TrimPrefix(string(after), string(before))
725 if added == "" || added == string(after) {
726 t.Fatalf("the add-key change was not recorded:\n%s", after)
727 }
728 if n := strings.Count(added, "\n"); n != 1 {
729 t.Fatalf("the key comment wrote %d lines:\n%s", n, added)
730 }
731 cols := strings.Split(strings.TrimSuffix(added, "\n"), "\t")
732 if len(cols) != 5 {
733 t.Fatalf("the key comment wrote %d columns:\n%s", len(cols), added)
734 }
735 if cols[1] != "identity.add-key" || cols[2] != "keenan" || cols[3] != "keenan" {
736 t.Fatalf("record = %q", added)
737 }
738}
739
740// A roster change that cannot be recorded does not happen: the roster is the
741// instance's only copy of who may authenticate, so an unrecorded grant is one
742// nobody can explain or revoke.
743func TestMutationsFailWhenTheLogCannotBeWritten(t *testing.T) {
744 t.Parallel()
745 s := newStore(t)
746 line, _ := pubLine(t)
747 blocker := filepath.Join(t.TempDir(), "blocked")
748 if err := os.WriteFile(blocker, []byte("x"), 0o600); err != nil {
749 t.Fatal(err)
750 }
751 s.AuditPath = filepath.Join(blocker, "audit.log")
752 if _, err := s.Create("eve", line, "test"); err == nil {
753 t.Fatal("Create succeeded with an unwritable audit log")
754 }
755 if _, err := s.Find("eve"); err == nil {
756 t.Fatal("the account was created anyway")
757 }
758}
759
760// A bot is an account with no browser: it can hold a repository role and a key, it
761// cannot be given a passkey or an enrollment link, and it cannot be an instance
762// admin. The kind is fixed at creation, like the name.
763func TestBotAccountsHaveNoBrowser(t *testing.T) {
764 t.Parallel()
765 s := newStore(t)
766 line, _ := pubLine(t)
767
768 bot, err := s.CreateBot("coding-agent", access.Contribute, line, "admin")
769 if err != nil {
770 t.Fatalf("create bot: %v", err)
771 }
772 if !bot.Bot() || bot.Kind != KindBot {
773 t.Fatalf("kind = %q, want %q", bot.Kind, KindBot)
774 }
775 // The roster carries the kind, so every reader of Snapshot sees it without a
776 // second lookup.
777 if got := s.Snapshot().ByName["coding-agent"]; got == nil || !got.Bot() {
778 t.Fatalf("the roster lost the kind: %+v", got)
779 }
780 // A person is unchanged: the default is what an upgrade gives every existing
781 // account.
782 person := mustCreate(t, s, "alice")
783 if person.Bot() || person.Kind != KindPerson {
784 t.Fatalf("a person became %q", person.Kind)
785 }
786
787 // No passkey, no invite link: a bot has no browser to sign in from, so a
788 // browser credential would be a secret nobody can use.
789 cred := testCredential(t, "phone")
790 if err := s.AddCredential("coding-agent", cred, "admin"); err == nil {
791 t.Fatal("a bot account was given a passkey")
792 }
793 if _, err := s.Invite("coding-agent", "admin", 0); err == nil {
794 t.Fatal("a bot account was given an enrollment link")
795 }
796
797 // An instance admin reaches every repository; a bot's role is a repository
798 // grant, so admin is refused at creation and when changing the role later.
799 if _, err := s.CreateBot("admin-bot", access.Admin, "", "admin"); err == nil {
800 t.Fatal("a bot account was created as an instance admin")
801 }
802 if _, err := s.SetInstanceRole("coding-agent", access.Admin, "admin"); err == nil {
803 t.Fatal("a bot account was promoted to instance admin")
804 }
805}
806
807// A bot with a repository role is otherwise an ordinary account: it can be
808// assigned, mentioned, and found by name.
809func TestABotIsAnAccount(t *testing.T) {
810 t.Parallel()
811 s := newStore(t)
812 if _, err := s.CreateBot("coding-agent", access.None, "", "admin"); err != nil {
813 t.Fatalf("create bot without a key: %v", err)
814 }
815 got, err := s.Find("coding-agent")
816 if err != nil || got == nil || !got.Bot() {
817 t.Fatalf("find bot: %+v %v", got, err)
818 }
819 if len(got.Keys) != 0 {
820 t.Fatalf("a keyless bot has keys: %v", got.Keys)
821 }
822}
823