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.

Add browser auth, contributor access, and gated CI

6c84d387e5f608360e30484a1a5b1cff41076850 · Keenan Nemetz · 16d ago · parent c36d5073

Add browser auth, contributor access, and gated CI

Browser sign-in now uses passkeys. Enrollment links are one-time, sessions are hashed in users.db, and issue/comment/approval writes use the session account. HTTPS public_url is required for WebAuthn.

Add the contributor role between read and maintain. Contributors can discuss and open change requests, and may push only refs/heads/contrib/<account-id>/. Contributor CI is held per commit until a maintainer allows the exact tip from the repository page.

Rename the write role to maintain. write remains accepted as the historical spelling, and access.Write remains as a Go alias so existing callers and ACL rows keep working.

Update the web, CLI, queue schema, and guides for the new flows, and cover the role, namespace, auth, and CI-gating paths in tests.

forge/session/session_test.go +324

@@ -0,0 +1,324 @@
1 package session
2
3 import (
4 "crypto/ed25519"
5 "crypto/rand"
6 "errors"
7 "path/filepath"
8 "strings"
9 "testing"
10 "time"
11
12 "github.com/rodent-software/vole/forge/identity"
13 "golang.org/x/crypto/ssh"
14 )
15
16 type fixture struct {
17 ident *identity.Store
18 store *Store
19 now time.Time
20 }
21
22 func newFixture(t *testing.T) *fixture {
23 t.Helper()
24 path := filepath.Join(t.TempDir(), "users.db")
25 if err := identity.Init(path); err != nil {
26 t.Fatal(err)
27 }
28 ident, err := identity.Open(path)
29 if err != nil {
30 t.Fatal(err)
31 }
32 t.Cleanup(func() { ident.Close() })
33 store, err := Open(path)
34 if err != nil {
35 ident.Close()
36 t.Fatal(err)
37 }
38 t.Cleanup(func() { store.Close() })
39 f := &fixture{ident: ident, store: store, now: time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC)}
40 store.Now = func() time.Time { return f.now }
41 store.AuditPath = filepath.Join(filepath.Dir(path), "audit.log")
42 return f
43 }
44
45 func (f *fixture) account(t *testing.T, name string) *identity.Account {
46 t.Helper()
47 pub, _, err := ed25519.GenerateKey(rand.Reader)
48 if err != nil {
49 t.Fatal(err)
50 }
51 key, err := ssh.NewPublicKey(pub)
52 if err != nil {
53 t.Fatal(err)
54 }
55 a, err := f.ident.Create(name, strings.TrimSpace(string(ssh.MarshalAuthorizedKey(key))), "test")
56 if err != nil {
57 t.Fatal(err)
58 }
59 return a
60 }
61
62 func (f *fixture) credential(t *testing.T, a *identity.Account) identity.Credential {
63 t.Helper()
64 id := make([]byte, 20)
65 pub := make([]byte, 32)
66 if _, err := rand.Read(id); err != nil {
67 t.Fatal(err)
68 }
69 if _, err := rand.Read(pub); err != nil {
70 t.Fatal(err)
71 }
72 cred := identity.Credential{ID: id, PublicKey: pub, Label: "laptop"}
73 if err := f.ident.AddCredential(a.ID, cred, "test"); err != nil {
74 t.Fatal(err)
75 }
76 return cred
77 }
78
79 func TestCreateLookupAndRevoke(t *testing.T) {
80 t.Parallel()
81 f := newFixture(t)
82 a := f.account(t, "alice")
83 sess, err := f.store.Create(a.ID, nil, "test-agent")
84 if err != nil {
85 t.Fatal(err)
86 }
87 if sess.Token == "" || !sess.Expires.Equal(f.now.Add(DefaultAbsolute)) {
88 t.Fatalf("created session = %+v", sess)
89 }
90 // The plaintext token must not be in the database.
91 var plain int
92 if err := f.store.db.QueryRow(`SELECT count(*) FROM sessions WHERE token_hash=?`, sess.Token).Scan(&plain); err != nil {
93 t.Fatal(err)
94 }
95 if plain != 0 {
96 t.Fatal("the plaintext session token was stored")
97 }
98 got, err := f.store.Lookup(sess.Token)
99 if err != nil {
100 t.Fatal(err)
101 }
102 if got.AccountID != a.ID || got.UserAgent != "test-agent" || got.Token != "" {
103 t.Fatalf("lookup = %+v", got)
104 }
105 if _, err := f.store.Lookup("not-a-token"); !errors.Is(err, ErrNoSession) {
106 t.Fatalf("unknown token = %v, want ErrNoSession", err)
107 }
108 if err := f.store.Revoke(sess.Token); err != nil {
109 t.Fatal(err)
110 }
111 if _, err := f.store.Lookup(sess.Token); !errors.Is(err, ErrNoSession) {
112 t.Fatalf("revoked token still works: %v", err)
113 }
114 if err := f.store.Revoke(sess.Token); err != nil {
115 t.Fatalf("revoking twice = %v", err)
116 }
117 }
118
119 func TestIdleExpiryAndSlidingUse(t *testing.T) {
120 t.Parallel()
121 f := newFixture(t)
122 f.store.Idle = time.Hour
123 f.store.Absolute = 24 * time.Hour
124 a := f.account(t, "alice")
125 sess, err := f.store.Create(a.ID, nil, "agent")
126 if err != nil {
127 t.Fatal(err)
128 }
129 f.now = f.now.Add(50 * time.Minute)
130 if _, err := f.store.Lookup(sess.Token); err != nil {
131 t.Fatalf("session expired before the idle limit: %v", err)
132 }
133 // The lookup refreshed the idle clock; another 50 minutes is still inside it.
134 f.now = f.now.Add(50 * time.Minute)
135 if _, err := f.store.Lookup(sess.Token); err != nil {
136 t.Fatalf("session expired despite recent use: %v", err)
137 }
138 f.now = f.now.Add(61 * time.Minute)
139 if _, err := f.store.Lookup(sess.Token); !errors.Is(err, ErrNoSession) {
140 t.Fatalf("idle session survived: %v", err)
141 }
142 }
143
144 func TestAbsoluteExpiry(t *testing.T) {
145 t.Parallel()
146 f := newFixture(t)
147 f.store.Idle = 100 * time.Hour
148 f.store.Absolute = 2 * time.Hour
149 a := f.account(t, "alice")
150 sess, err := f.store.Create(a.ID, nil, "agent")
151 if err != nil {
152 t.Fatal(err)
153 }
154 f.now = f.now.Add(2*time.Hour + time.Second)
155 if _, err := f.store.Lookup(sess.Token); !errors.Is(err, ErrNoSession) {
156 t.Fatalf("expired session survived: %v", err)
157 }
158 }
159
160 func TestLastSeenIsWrittenCoarsely(t *testing.T) {
161 t.Parallel()
162 f := newFixture(t)
163 a := f.account(t, "alice")
164 sess, err := f.store.Create(a.ID, nil, "agent")
165 if err != nil {
166 t.Fatal(err)
167 }
168 first := f.now.Unix()
169 f.now = f.now.Add(LastSeenEvery + time.Minute)
170 if _, err := f.store.Lookup(sess.Token); err != nil {
171 t.Fatal(err)
172 }
173 var seen int64
174 if err := f.store.db.QueryRow(`SELECT last_seen FROM sessions WHERE account_id=?`, a.ID).Scan(&seen); err != nil {
175 t.Fatal(err)
176 }
177 if seen != f.now.Unix() || seen == first {
178 t.Fatalf("last_seen = %d, want coarse update to %d", seen, f.now.Unix())
179 }
180 // A request inside the interval must not write again.
181 f.now = f.now.Add(time.Minute)
182 if _, err := f.store.Lookup(sess.Token); err != nil {
183 t.Fatal(err)
184 }
185 var again int64
186 if err := f.store.db.QueryRow(`SELECT last_seen FROM sessions WHERE account_id=?`, a.ID).Scan(&again); err != nil {
187 t.Fatal(err)
188 }
189 if again != seen {
190 t.Fatalf("last_seen was rewritten inside the coarse interval: %d -> %d", seen, again)
191 }
192 }
193
194 func TestRevokeAccountAndCredential(t *testing.T) {
195 t.Parallel()
196 f := newFixture(t)
197 a := f.account(t, "alice")
198 cred := f.credential(t, a)
199 byCredential, err := f.store.Create(a.ID, cred.ID, "laptop")
200 if err != nil {
201 t.Fatal(err)
202 }
203 byCookie, err := f.store.Create(a.ID, nil, "other")
204 if err != nil {
205 t.Fatal(err)
206 }
207 n, err := f.store.RevokeCredential(cred.ID)
208 if err != nil {
209 t.Fatal(err)
210 }
211 if n != 1 {
212 t.Fatalf("RevokeCredential deleted %d, want 1", n)
213 }
214 if _, err := f.store.Lookup(byCredential.Token); !errors.Is(err, ErrNoSession) {
215 t.Fatal("passkey session survived credential revoke")
216 }
217 if _, err := f.store.Lookup(byCookie.Token); err != nil {
218 t.Fatalf("unrelated session was revoked: %v", err)
219 }
220 n, err = f.store.RevokeAccount(a.ID)
221 if err != nil {
222 t.Fatal(err)
223 }
224 if n != 1 {
225 t.Fatalf("RevokeAccount deleted %d, want 1", n)
226 }
227 if _, err := f.store.Lookup(byCookie.Token); !errors.Is(err, ErrNoSession) {
228 t.Fatal("account session survived account revoke")
229 }
230 }
231
232 func TestRetireAndCredentialRemovalEndSessions(t *testing.T) {
233 t.Parallel()
234 f := newFixture(t)
235 a := f.account(t, "alice")
236 cred := f.credential(t, a)
237 sess, err := f.store.Create(a.ID, cred.ID, "laptop")
238 if err != nil {
239 t.Fatal(err)
240 }
241 if err := f.ident.RemoveCredential(a.ID, cred.ID, "test"); err != nil {
242 t.Fatal(err)
243 }
244 if _, err := f.store.Lookup(sess.Token); !errors.Is(err, ErrNoSession) {
245 t.Fatal("removing the passkey left its session alive")
246 }
247 sess, err = f.store.Create(a.ID, nil, "cookie")
248 if err != nil {
249 t.Fatal(err)
250 }
251 if _, err := f.ident.Retire(a.ID, "test"); err != nil {
252 t.Fatal(err)
253 }
254 if _, err := f.store.Lookup(sess.Token); !errors.Is(err, ErrNoSession) {
255 t.Fatal("retiring the account left its session alive")
256 }
257 if _, err := f.store.Create(a.ID, nil, "retired"); !errors.Is(err, ErrNoAccount) {
258 t.Fatalf("created a session for a retired account: %v", err)
259 }
260 }
261
262 func TestListShowsLiveSessionsWithoutTokens(t *testing.T) {
263 t.Parallel()
264 f := newFixture(t)
265 a := f.account(t, "alice")
266 if _, err := f.store.Create(a.ID, nil, "one"); err != nil {
267 t.Fatal(err)
268 }
269 f.now = f.now.Add(time.Minute)
270 if _, err := f.store.Create(a.ID, nil, "two"); err != nil {
271 t.Fatal(err)
272 }
273 list, err := f.store.List(a.ID)
274 if err != nil {
275 t.Fatal(err)
276 }
277 if len(list) != 2 {
278 t.Fatalf("List = %d sessions, want 2", len(list))
279 }
280 if list[0].UserAgent != "two" || list[1].UserAgent != "one" {
281 t.Fatalf("List order = %q, %q; want newest first", list[0].UserAgent, list[1].UserAgent)
282 }
283 if list[0].Token != "" || list[1].Token != "" {
284 t.Fatal("List exposed a token")
285 }
286 }
287
288 func TestRevokeAll(t *testing.T) {
289 t.Parallel()
290 f := newFixture(t)
291 alice := f.account(t, "alice")
292 bob := f.account(t, "bob")
293 a, err := f.store.Create(alice.ID, nil, "one")
294 if err != nil {
295 t.Fatal(err)
296 }
297 b, err := f.store.Create(bob.ID, nil, "two")
298 if err != nil {
299 t.Fatal(err)
300 }
301 n, err := f.store.RevokeAll()
302 if err != nil {
303 t.Fatal(err)
304 }
305 if n != 2 {
306 t.Fatalf("RevokeAll deleted %d, want 2", n)
307 }
308 for _, token := range []string{a.Token, b.Token} {
309 if _, err := f.store.Lookup(token); !errors.Is(err, ErrNoSession) {
310 t.Fatalf("session survived RevokeAll: %v", err)
311 }
312 }
313 }
314
315 func TestCreateRejectsAnotherAccountsPasskey(t *testing.T) {
316 t.Parallel()
317 f := newFixture(t)
318 alice := f.account(t, "alice")
319 bob := f.account(t, "bob")
320 cred := f.credential(t, alice)
321 if _, err := f.store.Create(bob.ID, cred.ID, "wrong"); !errors.Is(err, ErrCredentialMismatch) {
322 t.Fatalf("created a session for somebody else's passkey: %v", err)
323 }
324 }