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.

Blame · forge/identity/identity_test.go

Line attribution on main · 822 line(s)

a5f19b3c
Keenan Nemetz
18d ago
1 package identity
2
3 import (
4 "crypto/ed25519"
5 "crypto/rand"
6 "os"
7 "path/filepath"
8 "strings"
9 "sync"
10 "testing"
11
12 "golang.org/x/crypto/ssh"
d37d301f
Keenan Nemetz
21h ago
13 "rodent.software/vole/forge/access"
a5f19b3c
Keenan Nemetz
18d ago
14 )
15
197ecaa2
Keenan Nemetz
18d ago
16 func newStore(t *testing.T) *Store {
a5f19b3c
Keenan Nemetz
18d ago
17 t.Helper()
427c4761
Keenan Nemetz
17d ago
18 path := filepath.Join(t.TempDir(), "users.db")
19 if err := Init(path); err != nil {
a5f19b3c
Keenan Nemetz
18d ago
20 t.Fatal(err)
21 }
427c4761
Keenan Nemetz
17d ago
22 s, err := Open(path)
a5f19b3c
Keenan Nemetz
18d ago
23 if err != nil {
24 t.Fatal(err)
25 }
427c4761
Keenan Nemetz
17d ago
26 t.Cleanup(func() { s.Close() })
27 s.AuditPath = filepath.Join(filepath.Dir(path), "audit.log")
197ecaa2
Keenan Nemetz
18d ago
28 return s
a5f19b3c
Keenan Nemetz
18d ago
29 }
30
31 func 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
c36d5073
Keenan Nemetz
16d ago
44 func mustCreate(t *testing.T, s *Store, name string) *Account {
45 t.Helper()
46 return mustCreateBy(t, s, name, "test")
47 }
48
49 func mustCreateBy(t *testing.T, s *Store, name, actor string) *Account {
50 t.Helper()
51 line, _ := pubLine(t)
62576fa8
Keenan Nemetz
14d ago
52 a, err := s.Create(name, line, actor)
c36d5073
Keenan Nemetz
16d ago
53 if err != nil {
54 t.Fatalf("create %s: %v", name, err)
55 }
56 return a
57 }
58
197ecaa2
Keenan Nemetz
18d ago
59 func TestEmptyRosterLoads(t *testing.T) {
44530e74
Keenan Nemetz
17d ago
60 t.Parallel()
197ecaa2
Keenan Nemetz
18d ago
61 s := newStore(t)
31df8bae
Keenan Nemetz
18d ago
62 if got := s.Snapshot().Count; got != 0 {
63 t.Fatalf("count = %d, want 0", got)
197ecaa2
Keenan Nemetz
18d ago
64 }
65 }
66
427c4761
Keenan Nemetz
17d ago
67 func TestMissingDatabaseIsAnError(t *testing.T) {
44530e74
Keenan Nemetz
17d ago
68 t.Parallel()
427c4761
Keenan Nemetz
17d ago
69 if _, err := Open(filepath.Join(t.TempDir(), "nope.db")); err == nil {
70 t.Fatal("Open succeeded on a missing database")
197ecaa2
Keenan Nemetz
18d ago
71 }
72 }
73
c36d5073
Keenan Nemetz
16d ago
74 func TestCreateAccountWithKeyAndAddKey(t *testing.T) {
44530e74
Keenan Nemetz
17d ago
75 t.Parallel()
197ecaa2
Keenan Nemetz
18d ago
76 s := newStore(t)
c36d5073
Keenan Nemetz
16d ago
77 line, wantFP := pubLine(t)
62576fa8
Keenan Nemetz
14d ago
78 a, err := s.Create("keenan", line, "test")
31df8bae
Keenan Nemetz
18d ago
79 if err != nil {
a5f19b3c
Keenan Nemetz
18d ago
80 t.Fatal(err)
81 }
c36d5073
Keenan Nemetz
16d ago
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)
a5f19b3c
Keenan Nemetz
18d ago
84 }
62576fa8
Keenan Nemetz
14d ago
85 if name, ok := s.Lookup(wantFP); !ok || name != "keenan" {
86 t.Fatalf("Lookup = %q,%v; want keenan,true", name, ok)
197ecaa2
Keenan Nemetz
18d ago
87 }
c36d5073
Keenan Nemetz
16d ago
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.
103 func TestCreateRequiresAKeyAndDoesNotReserveTheName(t *testing.T) {
104 t.Parallel()
105 s := newStore(t)
106 for _, bad := range []string{"", "not an SSH key"} {
62576fa8
Keenan Nemetz
14d ago
107 if _, err := s.Create("alice", bad, "test"); err == nil {
c36d5073
Keenan Nemetz
16d ago
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)
62576fa8
Keenan Nemetz
14d ago
115 a, err := s.Create("alice", line, "test")
c36d5073
Keenan Nemetz
16d ago
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.
126 func TestCreateRejectsAKeyAlreadyRegistered(t *testing.T) {
127 t.Parallel()
128 s := newStore(t)
129 line, _ := pubLine(t)
62576fa8
Keenan Nemetz
14d ago
130 if _, err := s.Create("alice", line, "test"); err != nil {
c36d5073
Keenan Nemetz
16d ago
131 t.Fatal(err)
132 }
62576fa8
Keenan Nemetz
14d ago
133 if _, err := s.Create("bob", line, "test"); err == nil {
c36d5073
Keenan Nemetz
16d ago
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)
62576fa8
Keenan Nemetz
14d ago
142 if _, err := s.Create("bob", fresh, "test"); err != nil {
c36d5073
Keenan Nemetz
16d ago
143 t.Fatalf("the duplicate-key failure reserved bob's name: %v", err)
144 }
197ecaa2
Keenan Nemetz
18d ago
145 }
146
31df8bae
Keenan Nemetz
18d ago
147 func TestMultipleKeysPerAccount(t *testing.T) {
44530e74
Keenan Nemetz
17d ago
148 t.Parallel()
197ecaa2
Keenan Nemetz
18d ago
149 s := newStore(t)
c36d5073
Keenan Nemetz
16d ago
150 line, _ := pubLine(t)
62576fa8
Keenan Nemetz
14d ago
151 if _, err := s.Create("keenan", line, "test"); err != nil {
197ecaa2
Keenan Nemetz
18d ago
152 t.Fatal(err)
153 }
154 var fps []string
c36d5073
Keenan Nemetz
16d ago
155 for i := 0; i < 2; i++ {
197ecaa2
Keenan Nemetz
18d ago
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 }
427c4761
Keenan Nemetz
17d ago
162 reopened, err := Open(s.Path())
197ecaa2
Keenan Nemetz
18d ago
163 if err != nil {
164 t.Fatal(err)
165 }
427c4761
Keenan Nemetz
17d ago
166 defer reopened.Close()
31df8bae
Keenan Nemetz
18d ago
167 if got := len(reopened.Snapshot().ByName["keenan"].Keys); got != 3 {
197ecaa2
Keenan Nemetz
18d ago
168 t.Fatalf("keys after reopen = %d, want 3", got)
169 }
31df8bae
Keenan Nemetz
18d ago
170 if got := len(s.Snapshot().ByName["keenan"].Keys); got != 3 {
171 t.Fatalf("keys = %d, want 3", got)
172 }
197ecaa2
Keenan Nemetz
18d ago
173 for _, fp := range fps {
62576fa8
Keenan Nemetz
14d ago
174 if _, ok := s.Lookup(fp); !ok {
31df8bae
Keenan Nemetz
18d ago
175 t.Fatalf("key %s lost", fp)
197ecaa2
Keenan Nemetz
18d ago
176 }
a5f19b3c
Keenan Nemetz
18d ago
177 }
178 }
179
31df8bae
Keenan Nemetz
18d ago
180 func TestDuplicateKeyAcrossAccountsRejected(t *testing.T) {
44530e74
Keenan Nemetz
17d ago
181 t.Parallel()
197ecaa2
Keenan Nemetz
18d ago
182 s := newStore(t)
a5f19b3c
Keenan Nemetz
18d ago
183 line, _ := pubLine(t)
62576fa8
Keenan Nemetz
14d ago
184 if _, err := s.Create("alice", line, "test"); err != nil {
c36d5073
Keenan Nemetz
16d ago
185 t.Fatal(err)
186 }
187 bobLine, _ := pubLine(t)
62576fa8
Keenan Nemetz
14d ago
188 if _, err := s.Create("bob", bobLine, "test"); err != nil {
a5f19b3c
Keenan Nemetz
18d ago
189 t.Fatal(err)
190 }
191 if _, err := s.AddKey("bob", line, "test"); err == nil {
31df8bae
Keenan Nemetz
18d ago
192 t.Fatal("same key accepted for a second account")
a5f19b3c
Keenan Nemetz
18d ago
193 } else if !strings.Contains(err.Error(), "already registered") {
194 t.Fatalf("unclear error: %v", err)
195 }
196 }
197
427c4761
Keenan Nemetz
17d ago
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.
201 func TestOneKeyBelongsToOneAccount(t *testing.T) {
44530e74
Keenan Nemetz
17d ago
202 t.Parallel()
197ecaa2
Keenan Nemetz
18d ago
203 s := newStore(t)
c36d5073
Keenan Nemetz
16d ago
204 mustCreate(t, s, "alice")
205 mustCreate(t, s, "bob")
427c4761
Keenan Nemetz
17d ago
206 line, fp := pubLine(t)
197ecaa2
Keenan Nemetz
18d ago
207 if _, err := s.AddKey("alice", line, "test"); err != nil {
a5f19b3c
Keenan Nemetz
18d ago
208 t.Fatal(err)
209 }
427c4761
Keenan Nemetz
17d ago
210 bob, err := s.Find("bob")
31df8bae
Keenan Nemetz
18d ago
211 if err != nil {
212 t.Fatal(err)
213 }
427c4761
Keenan Nemetz
17d ago
214 raw, err := openDB(s.Path())
215 if err != nil {
a5f19b3c
Keenan Nemetz
18d ago
216 t.Fatal(err)
217 }
427c4761
Keenan Nemetz
17d ago
218 defer raw.Close()
219 if _, err := raw.Exec(`INSERT INTO user_keys(fingerprint, user_id, type, line) VALUES(?,?,'ssh-ed25519',?)`,
62576fa8
Keenan Nemetz
14d ago
220 fp, bob.Name, line); err == nil {
427c4761
Keenan Nemetz
17d ago
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.
228 func TestCorruptRosterFailsStartup(t *testing.T) {
44530e74
Keenan Nemetz
17d ago
229 t.Parallel()
427c4761
Keenan Nemetz
17d ago
230 s := newStore(t)
c36d5073
Keenan Nemetz
16d ago
231 mustCreate(t, s, "alice")
427c4761
Keenan Nemetz
17d ago
232 raw, err := openDB(s.Path())
233 if err != nil {
234 t.Fatal(err)
235 }
62576fa8
Keenan Nemetz
14d ago
236 if _, err := raw.Exec(`INSERT INTO users(name, status, created) VALUES('Not A Name', 'active', 0)`); err != nil {
427c4761
Keenan Nemetz
17d ago
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")
197ecaa2
Keenan Nemetz
18d ago
248 }
249 }
250
251 func TestRemoveKey(t *testing.T) {
44530e74
Keenan Nemetz
17d ago
252 t.Parallel()
197ecaa2
Keenan Nemetz
18d ago
253 s := newStore(t)
254 keepLine, keepFP := pubLine(t)
255 dropLine, dropFP := pubLine(t)
62576fa8
Keenan Nemetz
14d ago
256 if _, err := s.Create("keenan", keepLine, "test"); err != nil {
c36d5073
Keenan Nemetz
16d ago
257 t.Fatal(err)
258 }
259 if _, err := s.AddKey("keenan", dropLine, "test"); err != nil {
260 t.Fatal(err)
197ecaa2
Keenan Nemetz
18d ago
261 }
262 if err := s.RemoveKey("keenan", dropFP, "test"); err != nil {
263 t.Fatal(err)
264 }
62576fa8
Keenan Nemetz
14d ago
265 if _, ok := s.Lookup(dropFP); ok {
a5f19b3c
Keenan Nemetz
18d ago
266 t.Fatal("revoked key still authenticates")
267 }
62576fa8
Keenan Nemetz
14d ago
268 if _, ok := s.Lookup(keepFP); !ok {
197ecaa2
Keenan Nemetz
18d ago
269 t.Fatal("removing one key took the other with it")
270 }
a5f19b3c
Keenan Nemetz
18d ago
271 }
272
31df8bae
Keenan Nemetz
18d ago
273 // TestRetireStopsAuthenticationAndReservesTheName is the property that stops a
274 // new person inheriting a departed one's access.
275 func TestRetireStopsAuthenticationAndReservesTheName(t *testing.T) {
44530e74
Keenan Nemetz
17d ago
276 t.Parallel()
197ecaa2
Keenan Nemetz
18d ago
277 s := newStore(t)
31df8bae
Keenan Nemetz
18d ago
278 line, fp := pubLine(t)
62576fa8
Keenan Nemetz
14d ago
279 a, err := s.Create("wilbur", line, "test")
c36d5073
Keenan Nemetz
16d ago
280 if err != nil {
a5f19b3c
Keenan Nemetz
18d ago
281 t.Fatal(err)
282 }
31df8bae
Keenan Nemetz
18d ago
283 if _, err := s.Retire("wilbur", "test"); err != nil {
197ecaa2
Keenan Nemetz
18d ago
284 t.Fatal(err)
285 }
62576fa8
Keenan Nemetz
14d ago
286 if _, ok := s.Lookup(fp); ok {
31df8bae
Keenan Nemetz
18d ago
287 t.Fatal("a retired account's key still authenticates")
288 }
c36d5073
Keenan Nemetz
16d ago
289 reissue, _ := pubLine(t)
62576fa8
Keenan Nemetz
14d ago
290 if _, err := s.Create("wilbur", reissue, "test"); err == nil {
31df8bae
Keenan Nemetz
18d ago
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 }
62576fa8
Keenan Nemetz
14d ago
295 // The account itself still exists for history.
296 if got, err := s.Find(a.Name); err != nil || got.Status != "retired" {
31df8bae
Keenan Nemetz
18d ago
297 t.Fatalf("Find after retire = %v, %v", got, err)
197ecaa2
Keenan Nemetz
18d ago
298 }
299 }
300
62576fa8
Keenan Nemetz
14d ago
301 // TestNameIsTheOnlyIdentity: an account is its registered name. The name may
302 // not be taken by a second account, retired or not.
303 func TestNameIsTheOnlyIdentity(t *testing.T) {
44530e74
Keenan Nemetz
17d ago
304 t.Parallel()
197ecaa2
Keenan Nemetz
18d ago
305 s := newStore(t)
306 line, fp := pubLine(t)
62576fa8
Keenan Nemetz
14d ago
307 a, err := s.Create("wilbur", line, "test")
c36d5073
Keenan Nemetz
16d ago
308 if err != nil {
a5f19b3c
Keenan Nemetz
18d ago
309 t.Fatal(err)
310 }
62576fa8
Keenan Nemetz
14d ago
311 if a.Name != "wilbur" {
312 t.Fatalf("created account name = %q", a.Name)
474a812d
Keenan Nemetz
16d ago
313 }
62576fa8
Keenan Nemetz
14d ago
314 if name, ok := s.Lookup(fp); !ok || name != a.Name {
315 t.Fatalf("Lookup = %q,%v; want %q", name, ok, a.Name)
474a812d
Keenan Nemetz
16d ago
316 }
c36d5073
Keenan Nemetz
16d ago
317 reissue, _ := pubLine(t)
62576fa8
Keenan Nemetz
14d ago
318 if _, err := s.Create("wilbur", reissue, "test"); err == nil {
474a812d
Keenan Nemetz
16d ago
319 t.Fatal("a second account took the name")
31df8bae
Keenan Nemetz
18d ago
320 }
62576fa8
Keenan Nemetz
14d ago
321 if _, err := s.Find("nobody"); err == nil {
322 t.Fatal("Find accepted an unknown account")
197ecaa2
Keenan Nemetz
18d ago
323 }
324 }
325
326 func TestInvalidNamesRefused(t *testing.T) {
44530e74
Keenan Nemetz
17d ago
327 t.Parallel()
197ecaa2
Keenan Nemetz
18d ago
328 s := newStore(t)
c36d5073
Keenan Nemetz
16d ago
329 line, _ := pubLine(t)
197ecaa2
Keenan Nemetz
18d ago
330 for _, name := range []string{"Keenan", "has space", "a", "with/slash", "trailing."} {
62576fa8
Keenan Nemetz
14d ago
331 if _, err := s.Create(name, line, "test"); err == nil {
31df8bae
Keenan Nemetz
18d ago
332 t.Fatalf("%q accepted as a name", name)
a5f19b3c
Keenan Nemetz
18d ago
333 }
334 }
335 }
336
427c4761
Keenan Nemetz
17d ago
337 // TestConcurrentAddsAllLand: key adds from concurrent goroutines each commit their
338 // own row, so none may be lost to a read-modify-write race.
a5f19b3c
Keenan Nemetz
18d ago
339 func TestConcurrentAddsAllLand(t *testing.T) {
44530e74
Keenan Nemetz
17d ago
340 t.Parallel()
197ecaa2
Keenan Nemetz
18d ago
341 s := newStore(t)
a5f19b3c
Keenan Nemetz
18d ago
342 const n = 8
343 lines := make([]string, n)
344 for i := range lines {
345 lines[i], _ = pubLine(t)
346 }
62576fa8
Keenan Nemetz
14d ago
347 if _, err := s.Create("keenan", lines[0], "test"); err != nil {
c36d5073
Keenan Nemetz
16d ago
348 t.Fatal(err)
349 }
a5f19b3c
Keenan Nemetz
18d ago
350 var wg sync.WaitGroup
351 errs := make([]error, n)
c36d5073
Keenan Nemetz
16d ago
352 for i := 1; i < n; i++ {
a5f19b3c
Keenan Nemetz
18d ago
353 wg.Add(1)
354 go func(i int) {
355 defer wg.Done()
197ecaa2
Keenan Nemetz
18d ago
356 _, errs[i] = s.AddKey("keenan", lines[i], "test")
a5f19b3c
Keenan Nemetz
18d ago
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 }
31df8bae
Keenan Nemetz
18d ago
365 if err := s.Reload(); err != nil {
a5f19b3c
Keenan Nemetz
18d ago
366 t.Fatal(err)
367 }
31df8bae
Keenan Nemetz
18d ago
368 if got := len(s.Snapshot().ByName["keenan"].Keys); got != n {
197ecaa2
Keenan Nemetz
18d ago
369 t.Fatalf("after concurrent adds: %d keys, want %d", got, n)
a5f19b3c
Keenan Nemetz
18d ago
370 }
371 }
372
197ecaa2
Keenan Nemetz
18d ago
373 func TestAuditLogRecordsChanges(t *testing.T) {
44530e74
Keenan Nemetz
17d ago
374 t.Parallel()
197ecaa2
Keenan Nemetz
18d ago
375 s := newStore(t)
c36d5073
Keenan Nemetz
16d ago
376 line, fp := pubLine(t)
62576fa8
Keenan Nemetz
14d ago
377 if _, err := s.Create("keenan", line, "test"); err != nil {
a5f19b3c
Keenan Nemetz
18d ago
378 t.Fatal(err)
379 }
c36d5073
Keenan Nemetz
16d ago
380 second, secondFP := pubLine(t)
381 if _, err := s.AddKey("keenan", second, "test"); err != nil {
a5f19b3c
Keenan Nemetz
18d ago
382 t.Fatal(err)
383 }
474a812d
Keenan Nemetz
16d ago
384 if err := s.RemoveKey("keenan", secondFP, "test"); err != nil {
a5f19b3c
Keenan Nemetz
18d ago
385 t.Fatal(err)
386 }
197ecaa2
Keenan Nemetz
18d ago
387 data, err := os.ReadFile(s.AuditPath)
a5f19b3c
Keenan Nemetz
18d ago
388 if err != nil {
389 t.Fatal(err)
390 }
197ecaa2
Keenan Nemetz
18d ago
391 log := string(data)
62576fa8
Keenan Nemetz
14d ago
392 for _, want := range []string{"identity.create", "identity.add-key", "identity.remove-key", fp, secondFP} {
197ecaa2
Keenan Nemetz
18d ago
393 if !strings.Contains(log, want) {
394 t.Fatalf("audit log missing %q:\n%s", want, log)
395 }
a5f19b3c
Keenan Nemetz
18d ago
396 }
397 }
398
62576fa8
Keenan Nemetz
14d ago
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.
402 func TestRosterUpgradesFromTheOldSchema(t *testing.T) {
474a812d
Keenan Nemetz
16d ago
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 }
62576fa8
Keenan Nemetz
14d ago
409 keyLine, keyFP := pubLine(t)
474a812d
Keenan Nemetz
16d ago
410 if _, err := db.Exec(`
411 CREATE TABLE users (
412 id TEXT PRIMARY KEY,
413 name TEXT NOT NULL UNIQUE,
62576fa8
Keenan Nemetz
14d ago
414 display TEXT NOT NULL DEFAULT '',
474a812d
Keenan Nemetz
16d ago
415 status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active','retired')),
416 created INTEGER NOT NULL
417 );
418 CREATE TABLE user_names (
419 name TEXT PRIMARY KEY,
420 user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE
421 );
62576fa8
Keenan Nemetz
14d ago
422 CREATE 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 );
429 CREATE TABLE instance_admins (
430 user_id TEXT PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE
431 );
432 CREATE 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 );
447 CREATE 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 );
455 CREATE 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 );
464 INSERT INTO users(id, name, display, status, created) VALUES('01M5JR2JK29KKMAA1T9MDNTPBP', 'wilbur', 'Wilbur Wright', 'active', 0);
474a812d
Keenan Nemetz
16d ago
465 INSERT INTO user_names(name, user_id) VALUES('wilbur', '01M5JR2JK29KKMAA1T9MDNTPBP');
62576fa8
Keenan Nemetz
14d ago
466 INSERT INTO user_names(name, user_id) VALUES('robert', '01M5JR2JK29KKMAA1T9MDNTPBP');
467 INSERT INTO instance_admins(user_id) VALUES('01M5JR2JK29KKMAA1T9MDNTPBP');
468 INSERT INTO user_credentials(credential_id, user_id, public_key, created) VALUES('cred-1', '01M5JR2JK29KKMAA1T9MDNTPBP', x'00', 0);
469 INSERT INTO user_invites(token_hash, user_id, created, expires) VALUES('` + hashToken("tok") + `', '01M5JR2JK29KKMAA1T9MDNTPBP', 0, 9999999999);
470 INSERT INTO sessions(token_hash, account_id, credential_id, created, last_seen, expires) VALUES('sess-1', '01M5JR2JK29KKMAA1T9MDNTPBP', 'cred-1', 0, 0, 9999999999);
471 CREATE 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 );
477 INSERT 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');
480 CREATE 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 );
488 INSERT 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 {
474a812d
Keenan Nemetz
16d ago
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 }
62576fa8
Keenan Nemetz
14d ago
508 if a.Name != "wilbur" || !a.Admin || len(a.Keys) != 1 {
509 t.Fatalf("upgraded account = %+v", a)
510 }
29a795b9
Keenan Nemetz
7d ago
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 }
62576fa8
Keenan Nemetz
14d ago
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)
474a812d
Keenan Nemetz
16d ago
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
427c4761
Keenan Nemetz
17d ago
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.
555 func TestRosterIsOneDatabaseFile(t *testing.T) {
44530e74
Keenan Nemetz
17d ago
556 t.Parallel()
197ecaa2
Keenan Nemetz
18d ago
557 s := newStore(t)
427c4761
Keenan Nemetz
17d ago
558 line, fp := pubLine(t)
62576fa8
Keenan Nemetz
14d ago
559 a, err := s.Create("keenan", line, "test")
c36d5073
Keenan Nemetz
16d ago
560 if err != nil {
427c4761
Keenan Nemetz
17d ago
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)
31df8bae
Keenan Nemetz
18d ago
569 if err != nil {
570 t.Fatal(err)
571 }
427c4761
Keenan Nemetz
17d ago
572 defer other.Close()
62576fa8
Keenan Nemetz
14d ago
573 if got, err := other.Find("keenan"); err != nil || got.Name != a.Name {
574 t.Fatalf("copied roster does not resolve keenan: %v", err)
31df8bae
Keenan Nemetz
18d ago
575 }
62576fa8
Keenan Nemetz
14d ago
576 if name, ok := other.Lookup(fp); !ok || name != a.Name {
427c4761
Keenan Nemetz
17d ago
577 t.Fatal("copied roster lost the key")
31df8bae
Keenan Nemetz
18d ago
578 }
427c4761
Keenan Nemetz
17d ago
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 }
31df8bae
Keenan Nemetz
18d ago
584 }
585 }
586
3abbe749
Keenan Nemetz
16d ago
587 func TestInstanceAdminsAreExplicitAndAudited(t *testing.T) {
588 t.Parallel()
589 s := newStore(t)
c36d5073
Keenan Nemetz
16d ago
590 alice := mustCreateBy(t, s, "alice", "operator")
62576fa8
Keenan Nemetz
14d ago
591 if s.IsAdmin(alice.Name) {
3abbe749
Keenan Nemetz
16d ago
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 }
62576fa8
Keenan Nemetz
14d ago
597 if !s.IsAdmin(alice.Name) {
3abbe749
Keenan Nemetz
16d ago
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 }
c36d5073
Keenan Nemetz
16d ago
610 bobKey, _ := pubLine(t)
62576fa8
Keenan Nemetz
14d ago
611 if _, err := s.Create("bob", bobKey, "operator"); err != nil {
3abbe749
Keenan Nemetz
16d ago
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 }
62576fa8
Keenan Nemetz
14d ago
620 if s.IsAdmin(alice.Name) {
3abbe749
Keenan Nemetz
16d ago
621 t.Fatal("admin grant survived revocation")
622 }
623 }
624
5b814e99
Keenan Nemetz
14d ago
625 func 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
3abbe749
Keenan Nemetz
16d ago
680 func TestLastInstanceAdminCannotBeRemoved(t *testing.T) {
681 t.Parallel()
682 s := newStore(t)
c36d5073
Keenan Nemetz
16d ago
683 alice := mustCreateBy(t, s, "alice", "operator")
684 bob := mustCreateBy(t, s, "bob", "operator")
62576fa8
Keenan Nemetz
14d ago
685 if _, err := s.SetAdmin(alice.Name, true, "operator"); err != nil {
3abbe749
Keenan Nemetz
16d ago
686 t.Fatal(err)
687 }
62576fa8
Keenan Nemetz
14d ago
688 if _, err := s.SetAdmin(alice.Name, false, "operator"); err == nil {
3abbe749
Keenan Nemetz
16d ago
689 t.Fatal("revoked the last instance admin")
690 }
62576fa8
Keenan Nemetz
14d ago
691 if _, err := s.Retire(alice.Name, "operator"); err == nil {
3abbe749
Keenan Nemetz
16d ago
692 t.Fatal("retired the last instance admin")
693 }
62576fa8
Keenan Nemetz
14d ago
694 if _, err := s.SetAdmin(bob.Name, true, "operator"); err != nil {
3abbe749
Keenan Nemetz
16d ago
695 t.Fatal(err)
696 }
62576fa8
Keenan Nemetz
14d ago
697 if _, err := s.SetAdmin(alice.Name, false, "operator"); err != nil {
3abbe749
Keenan Nemetz
16d ago
698 t.Fatalf("could not revoke an admin when another remained: %v", err)
699 }
62576fa8
Keenan Nemetz
14d ago
700 if _, err := s.Retire(bob.Name, "operator"); err == nil {
3abbe749
Keenan Nemetz
16d ago
701 t.Fatal("retired the last instance admin after revoking the other")
702 }
703 }
b1499c72
Keenan Nemetz
13d ago
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.
707 func 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.
743 func 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 }
29a795b9
Keenan Nemetz
7d ago
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.
763 func 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.
809 func 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 }