docs/plans/2026-09-27-credentials-and-sessions.md
3568 lines · 115133 bytes
28 symbols in this file
Credentials and sessions implementation planGlobal constraintsOrder and dependenciesFile mapMR 1: removing a key closes its connections (branch `revoke-closes-connections`, #256)Task 1.1: the store announces revocations and answers which keys are liveTask 1.2: `gitutil.Transport` can be cancelledTask 1.3: sshd re-reads the key per exec and cuts revoked connectionsTask 1.4: e2e — a multiplexed connection is cut, a push in flight moves no refTask 1.5: docsMR 2: expiring credentials cannot mint; credentials record their token (branch `token-delegation`, #257)Task 2.1: migration 0060 and the storeTask 2.2: `MintsCredential`, `Ctx.Expires`, and the API wiringTask 2.3: commands record their token; `token create` defaults to read; `token revoke --created`Task 2.4: e2e — delegation over the APITask 2.5: docs and release noteMR 3: optional expiry for SSH and deploy keys (branch `key-expiry`, #277)Task 3.1: migration 0061 and the storeTask 3.2: expired keys refused at authentication, per exec, and in system modeTask 3.3: `--ttl` on `keys add` and `repo deploy-key add`; lists show last use and expiryTask 3.4: docsMR 4: idle timeout for browser sessions (branch `session-idle`, #276)Task 4.1: migration 0062 and the storeTask 4.2: `web sessions list` shows last use; docsMR 5: `web login` over SSH spends the login-link budget (branch `weblogin-limit`, #278)Task 5.1: the limit in `runWebLogin`Decisions (2026-09-28)Self-review
1# Credentials and sessions implementation plan
2
3> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
4
5**Goal:** Revocation of an SSH key takes effect on open connections
6(#256); an expiring credential cannot mint one that outlives it, and
7credentials record the token that made them (#257); SSH and deploy
8keys take an optional expiry and show their last use (#277); browser
9sessions end after 12 hours idle (#276); `web login` over SSH spends
10the login-link budget (#278).
11
12**Architecture:** The store announces revocations it commits
13(`Store.OnRevoke`); the SSH listener tracks which key opened each
14connection and cuts the ones a revocation names, killing a git
15transport's process group. A 15-second sweep catches revocations made
16by another process and keys that expire while connected. Every exec
17re-reads its key. `Command.MintsCredential` marks the commands that
18create credentials; `Dispatch` refuses them when `Ctx.Expires` is set.
19`api_tokens` and `ssh_keys` gain `created_by_token`; `ssh_keys` gains
20`expires_at`; `web_sessions` gains a sliding expiry under an absolute
21cap.
22
23**Tech stack:** Go, `golang.org/x/crypto/ssh`, SQLite (modernc), OpenSSH
24client for e2e.
25
26**Spec:** issues #256, #257, #276, #277, #278 on krz/gitbay (the
27decisions on #256 and #257 are recorded there, and the later ones in
28"Decisions" at the end of this plan).
29
30## Global constraints
31
32- Each MR on its own branch off `main`. Commits are signed (the repo
33 refuses unsigned), messages reference issues (`Ref #N`, and
34 `Closes #N` on the commit that finishes one). No attribution to any
35 assistant, model or AI anywhere: commits, MR bodies, comments.
36- MR: `gitbay mr create --source <branch> --target main --title "..."`;
37 merge with `gitbay mr merge <n> --strategy ff` once CI is green, then
38 delete the branch locally and on the remote. Behind main → rebase,
39 force-push, merge again.
40- Locally: `go build ./...`, `go vet ./...`, unit tests of touched
41 packages, and at most the one e2e test being written
42 (`go test ./e2e -run TestName -count=1`). CI on bay1 runs the full suite.
43- Registries that fail CI when a new thing lacks its row: top-level route
44 word in `internal/policy/names.go`; new page template in the width map
45 of `TestMainWidthClass` (`internal/web/web_test.go`); new `ReadOnly`
46 command in `readArgs` in `e2e/readonly_test.go`; new control command
47 needs a `pass()` entry in `cmd/gitbay/main.go` (coverage test);
48 a command reading stdin needs `ReadsStdin: true`. This plan adds no
49 route, template, command or read command; it changes flags and a
50 `Summary` of none, so `cmd/gitbay/summaries_gen.go` stays current.
51- Migrations: this plan owns 0060–0064 and uses 0060, 0061, 0062. Six
52 plans are written in parallel with pre-assigned ranges; whoever lands
53 second renumbers to the next free number at execution time.
54 Migrations come in `.up.sql`/`.down.sql` pairs. Hand-written SQL, no
55 ORM. `TestMigrateUpDown` (`internal/store/store_test.go`) exercises
56 every down script.
57- Secrets travel on stdin, never argv; never logged or echoed.
58- Wiki pages live in `.gitbay/wiki/` (Parity, API, Admin, Threat-Model,
59 CI, Users, Performance, and the `Architecture/` folder with its
60 Known-Gaps table and controls matrix). Update the page in the same MR
61 that changes the behaviour it describes, and remove the matching
62 row from `Architecture/10-Known-Gaps.org`.
63- Release notes go in `CHANGELOG.org` under the topmost heading that
64 has no tag yet. If the top heading is a released version, add
65 `* Unreleased` above it; whoever tags renames it.
66- Writing style: plain, direct, no hype; code comments match the
67 surrounding density. Comments and docs state facts, never
68 before/after narration.
69- Work in a worktree of `krz/gitbay`; the main checkout may hold
70 another session's edits.
71
72## Order and dependencies
73
74| # | Branch | Closes | Migration | Depends on |
75|---|---|---|---|---|
76| 1 | `revoke-closes-connections` | #256 | none | — |
77| 2 | `token-delegation` | #257 | 0060 | MR 1 (`Store.announce`, `fingerprint` e2e helper, `Exec` taking a key) |
78| 3 | `key-expiry` | #277 | 0061 | MR 1 (sweep, per-exec check), MR 2 (`KeyOrigin`, `Ctx.Expires`) |
79| 4 | `session-idle` | #276 | 0062 | — |
80| 5 | `weblogin-limit` | #278 | none | — |
81
82\#256 goes first. #257 and #277 both add columns to `ssh_keys`; both
83are nullable `ADD COLUMN`s with no table rebuild, so they compose in
84either order, and `KeyOrigin` (MR 2) is the one insert path both use.
85
86Other plans:
87
88- Plan 5 (web-ux) #264 depends on MR 2's `--scope read` default.
89- Plan 3 (server-hardening) touches the same code: #262 limits
90 concurrent git processes in `gitutil.Transport` / `sshd.runGit`
91 (MR 1 changes both signatures), #275 audits refused commands in
92 `control.Dispatch` (MR 2 adds a refusal there, which #275 should
93 audit like the others), #282 changes `hookd`. Whoever lands second
94 rebases; the conflicts are mechanical.
95
96## File map
97
98| File | MR | Responsibility |
99|---|---|---|
100| `internal/store/revoke.go` (create) | 1 | `Revoked`, `OnRevoke`, `announce`, `LiveSSHKeys` |
101| `internal/store/store.go` | 1 | subscriber fields on `Store` |
102| `internal/store/users.go` | 1, 2, 3 | removals announce; `KeyOrigin`, `AddSSHKeyFrom`; `expires_at` |
103| `internal/sshd/sshd.go` | 1, 3 | connection tracking, `cut`, sweep, per-exec check, `Exec(key)`; expired keys refused |
104| `internal/gitutil/gitutil.go`, `proc_unix.go`, `proc_other.go` | 1 | `Transport` takes a cancel channel, kills the process group |
105| `cmd/gitbayd/system.go` | 1, 3 | `Exec` call; expired keys in system mode |
106| `internal/control/control.go` | 2 | `MintsCredential`, `Ctx.TokenID`, `Ctx.Expires`, the refusal |
107| `internal/store/tokens.go` | 2 | token id, creator, chained revoke |
108| `internal/control/token.go` | 2, 3 | default read, creator, `revoke --created`; `ttlFlag` |
109| `internal/control/identity.go`, `deploykey.go`, `runnerrepo.go`, `adminhost.go`, `register.go`, `web.go` | 2, 3, 4, 5 | flags, creator, `--ttl`, list columns, login limit |
110| `internal/httpd/api.go` | 2 | token into `Ctx` |
111| `internal/store/sessions.go` | 4 | sliding session expiry |
112| `internal/control/loginlink.go` | 5 | comment |
113| `e2e/revoke_test.go` (create) | 1 | multiplexed connection cut |
114| `e2e/tokenorigin_test.go` (create) | 2 | expiring token refused, `revoke --created` |
115| `.gitbay/wiki/*`, `CHANGELOG.org` | all | docs in the MR that changes behaviour |
116
117---
118
119# MR 1: removing a key closes its connections (branch `revoke-closes-connections`, #256)
120
121Decision on the issue: revocation is immediate, running commands
122included. What that means here, stated in the Threat-Model page:
123
124- Every exec and every git transport session re-reads its key: gone,
125 moved to another account, or re-scoped takes effect on the next
126 command.
127- `keys remove`, `repo deploy-key remove`, `admin user disable`,
128 `admin user delete` and (MR 2) `token revoke --created` close every
129 connection opened by an affected key. A git transport on it is
130 killed with its process group. A control command in flight loses its
131 channel; one that watches `Done` (`build log --follow`) stops, one
132 already inside its store write finishes that write and its output is
133 lost.
134- A push cut before its pre-receive hook answers updates no refs. The
135 ref transaction that follows the hook is not interrupted mid-write in
136 practice; it is milliseconds long.
137- Revocations committed by another process (`gitbayd admin` on the
138 host) are found by a 15-second sweep.
139- System mode (`ssh.mode = "system"`) runs one process per exec, so
140 the per-exec check applies; a forced-command process already running
141 is not cut (open question 4).
142
143### Task 1.1: the store announces revocations and answers which keys are live
144
145**Files:**
146- Create: `internal/store/revoke.go`
147- Modify: `internal/store/store.go:23-30` (`Store` struct)
148- Modify: `internal/store/users.go` — `DeleteUser` (58-101), `SetUserDisabled` (171-194), `RemoveSSHKey` (291-309), `RemoveDeployKey` (534-554)
149- Test: `internal/store/revoke_test.go` (create)
150
151**Interfaces:**
152- Produces:
153 - `type Revoked struct { KeyIDs []int64; UserID int64 }`
154 - `func (s *Store) OnRevoke(f func(Revoked))`
155 - `func (s *Store) announce(r Revoked)` (package-private; MR 2 calls it)
156 - `func (s *Store) LiveSSHKeys(ids []int64) (map[int64]bool, error)`
157
158- [ ] **Step 1: Write the failing test**
159
160`internal/store/revoke_test.go`:
161
162```go
163package store
164
165import (
166 "slices"
167 "testing"
168)
169
170func revokeFixture(t *testing.T) (*Store, int64, *[]Revoked) {
171 t.Helper()
172 s := open(t)
173 if err := s.MigrateUp(); err != nil {
174 t.Fatal(err)
175 }
176 uid, err := s.CreateUser("alice", false)
177 if err != nil {
178 t.Fatal(err)
179 }
180 var got []Revoked
181 s.OnRevoke(func(r Revoked) { got = append(got, r) })
182 return s, uid, &got
183}
184
185func keyID(t *testing.T, s *Store, fp string) int64 {
186 t.Helper()
187 k, err := s.SSHKeyByFingerprint(fp)
188 if err != nil {
189 t.Fatal(err)
190 }
191 return k.ID
192}
193
194func TestRemovalsAnnounceTheirKeys(t *testing.T) {
195 s, uid, got := revokeFixture(t)
196 if err := s.AddSSHKey(uid, "SHA256:a", "ssh-ed25519", []byte("a"), "full", ""); err != nil {
197 t.Fatal(err)
198 }
199 if err := s.AddSSHKey(uid, "SHA256:d", "ssh-ed25519", []byte("d"), "deploy:7:ro", ""); err != nil {
200 t.Fatal(err)
201 }
202 a, d := keyID(t, s, "SHA256:a"), keyID(t, s, "SHA256:d")
203
204 if err := s.RemoveSSHKey(uid, "SHA256:a"); err != nil {
205 t.Fatal(err)
206 }
207 if err := s.RemoveDeployKey(7, "SHA256:d"); err != nil {
208 t.Fatal(err)
209 }
210 if err := s.SetUserDisabled(uid, true); err != nil {
211 t.Fatal(err)
212 }
213 if err := s.SetUserDisabled(uid, false); err != nil {
214 t.Fatal(err)
215 }
216 want := []Revoked{{KeyIDs: []int64{a}}, {KeyIDs: []int64{d}}, {UserID: uid}}
217 if !slices.EqualFunc(*got, want, func(x, y Revoked) bool {
218 return slices.Equal(x.KeyIDs, y.KeyIDs) && x.UserID == y.UserID
219 }) {
220 t.Fatalf("announced %+v, want %+v (enabling announces nothing)", *got, want)
221 }
222 // A removal that found nothing announces nothing.
223 if err := s.RemoveSSHKey(uid, "SHA256:a"); err != ErrNotFound {
224 t.Fatalf("second remove: %v", err)
225 }
226 if len(*got) != 3 {
227 t.Fatalf("a miss was announced: %+v", *got)
228 }
229}
230
231func TestDeleteUserAnnounces(t *testing.T) {
232 s, uid, got := revokeFixture(t)
233 if err := s.DeleteUser(uid); err != nil {
234 t.Fatal(err)
235 }
236 if len(*got) != 1 || (*got)[0].UserID != uid {
237 t.Fatalf("announced %+v", *got)
238 }
239}
240
241func TestLiveSSHKeys(t *testing.T) {
242 s, uid, _ := revokeFixture(t)
243 bob, err := s.CreateUser("bob", false)
244 if err != nil {
245 t.Fatal(err)
246 }
247 for _, k := range []struct {
248 uid int64
249 fp string
250 }{{uid, "SHA256:a"}, {bob, "SHA256:b"}} {
251 if err := s.AddSSHKey(k.uid, k.fp, "ssh-ed25519", []byte(k.fp), "full", ""); err != nil {
252 t.Fatal(err)
253 }
254 }
255 a, b := keyID(t, s, "SHA256:a"), keyID(t, s, "SHA256:b")
256 if _, err := s.DB.Exec("UPDATE users SET disabled = 1 WHERE id = ?", bob); err != nil {
257 t.Fatal(err)
258 }
259 live, err := s.LiveSSHKeys([]int64{a, b, 999})
260 if err != nil {
261 t.Fatal(err)
262 }
263 if !live[a] || live[b] || live[999] {
264 t.Fatalf("live = %v; want only %d", live, a)
265 }
266 if live, err := s.LiveSSHKeys(nil); err != nil || len(live) != 0 {
267 t.Fatalf("no ids: %v %v", live, err)
268 }
269}
270```
271
272- [ ] **Step 2: Run it and see it fail**
273
274Run: `go test ./internal/store -run 'TestRemovalsAnnounceTheirKeys|TestDeleteUserAnnounces|TestLiveSSHKeys' -count=1`
275Expected: FAIL to compile, `s.OnRevoke undefined`.
276
277- [ ] **Step 3: Add the subscriber fields**
278
279In `internal/store/store.go`, the `Store` struct becomes:
280
281```go
282type Store struct {
283 DB *sql.DB
284
285 // logWait holds one channel per build someone is following, closed
286 // by the next change to that build's row (BuildLogWait).
287 logMu sync.Mutex
288 logWait map[int64]chan struct{}
289
290 // onRevoke runs after each key revocation this process commits.
291 revokeMu sync.Mutex
292 onRevoke []func(Revoked)
293}
294```
295
296- [ ] **Step 4: Create `internal/store/revoke.go`**
297
298```go
299package store
300
301import (
302 "slices"
303 "strings"
304)
305
306// Revoked names SSH keys that stopped being valid: by id, or every key
307// of an account. The SSH listener closes the connections they opened.
308type Revoked struct {
309 KeyIDs []int64
310 UserID int64 // every key of this account; 0 for none
311}
312
313// OnRevoke registers f to run after each revocation this process
314// commits. Revocations committed by another process (gitbayd admin on
315// the host) are not announced; the listener's sweep finds those.
316func (s *Store) OnRevoke(f func(Revoked)) {
317 s.revokeMu.Lock()
318 defer s.revokeMu.Unlock()
319 s.onRevoke = append(s.onRevoke, f)
320}
321
322// announce runs the subscribers. Call it after the commit, outside any
323// transaction.
324func (s *Store) announce(r Revoked) {
325 s.revokeMu.Lock()
326 fs := slices.Clone(s.onRevoke)
327 s.revokeMu.Unlock()
328 for _, f := range fs {
329 f(r)
330 }
331}
332
333// LiveSSHKeys reports which of ids still name a registered key on an
334// account that is not disabled.
335func (s *Store) LiveSSHKeys(ids []int64) (map[int64]bool, error) {
336 live := map[int64]bool{}
337 if len(ids) == 0 {
338 return live, nil
339 }
340 args := make([]any, len(ids))
341 for i, id := range ids {
342 args[i] = id
343 }
344 rows, err := s.DB.Query(`SELECT k.id FROM ssh_keys k JOIN users u ON u.id = k.user_id
345 WHERE u.disabled = 0 AND k.id IN (?`+strings.Repeat(", ?", len(ids)-1)+`)`, args...)
346 if err != nil {
347 return nil, err
348 }
349 defer rows.Close()
350 for rows.Next() {
351 var id int64
352 if err := rows.Scan(&id); err != nil {
353 return nil, err
354 }
355 live[id] = true
356 }
357 return live, rows.Err()
358}
359```
360
361- [ ] **Step 5: Announce from the four removals**
362
363`RemoveSSHKey` in `internal/store/users.go`:
364
365```go
366// RemoveSSHKey removes a key owned by userID, bumps the key epoch, and
367// announces the revocation.
368func (s *Store) RemoveSSHKey(userID int64, fingerprint string) error {
369 tx, err := s.DB.Begin()
370 if err != nil {
371 return err
372 }
373 defer tx.Rollback()
374 var id int64
375 err = tx.QueryRow("DELETE FROM ssh_keys WHERE user_id = ? AND fingerprint = ? RETURNING id", userID, fingerprint).Scan(&id)
376 if errors.Is(err, sql.ErrNoRows) {
377 return ErrNotFound
378 }
379 if err != nil {
380 return err
381 }
382 if err := bumpKeyEpoch(tx); err != nil {
383 return err
384 }
385 if err := tx.Commit(); err != nil {
386 return err
387 }
388 s.announce(Revoked{KeyIDs: []int64{id}})
389 return nil
390}
391```
392
393`RemoveDeployKey`:
394
395```go
396// RemoveDeployKey removes a deploy key from a repository by fingerprint;
397// any repo admin may remove it regardless of who added it.
398func (s *Store) RemoveDeployKey(repoID int64, fingerprint string) error {
399 tx, err := s.DB.Begin()
400 if err != nil {
401 return err
402 }
403 defer tx.Rollback()
404 var id int64
405 err = tx.QueryRow(
406 "DELETE FROM ssh_keys WHERE fingerprint = ? AND scope LIKE 'deploy:' || ? || ':%' RETURNING id",
407 fingerprint, repoID).Scan(&id)
408 if errors.Is(err, sql.ErrNoRows) {
409 return ErrNotFound
410 }
411 if err != nil {
412 return err
413 }
414 if err := bumpKeyEpoch(tx); err != nil {
415 return err
416 }
417 if err := tx.Commit(); err != nil {
418 return err
419 }
420 s.announce(Revoked{KeyIDs: []int64{id}})
421 return nil
422}
423```
424
425`SetUserDisabled`, the tail from `if disabled {`:
426
427```go
428 if disabled {
429 // A pending login link is a session in waiting, so it goes with
430 // the sessions and API tokens. Re-enabling means minting again.
431 for _, table := range []string{"web_sessions", "api_tokens", "login_tokens"} {
432 if _, err := s.DB.Exec("DELETE FROM "+table+" WHERE user_id = ?", userID); err != nil {
433 return err
434 }
435 }
436 s.announce(Revoked{UserID: userID})
437 }
438 return nil
439}
440```
441
442Update its doc comment's last clause to: "and leaves the SSH keys registered but refused at every entry point until re-enabled; connections they opened are closed."
443
444`DeleteUser`, the tail:
445
446```go
447 res, err := s.DB.Exec("DELETE FROM users WHERE id = ?", id)
448 if err != nil {
449 return err
450 }
451 if n, _ := res.RowsAffected(); n == 0 {
452 return ErrNotFound
453 }
454 s.announce(Revoked{UserID: id})
455 return nil
456}
457```
458
459- [ ] **Step 6: Run the tests**
460
461Run: `go test ./internal/store -count=1`
462Expected: PASS.
463
464- [ ] **Step 7: Commit**
465
466```bash
467git add internal/store/revoke.go internal/store/revoke_test.go internal/store/store.go internal/store/users.go
468git commit -S -m "store: announce key revocations; LiveSSHKeys
469
470Ref #256"
471```
472
473### Task 1.2: `gitutil.Transport` can be cancelled
474
475**Files:**
476- Modify: `internal/gitutil/gitutil.go:39-56`
477- Create: `internal/gitutil/proc_unix.go`, `internal/gitutil/proc_other.go`
478- Test: `internal/gitutil/transport_test.go` (create)
479
480**Interfaces:**
481- Produces: `func Transport(service, repoPath string, stdin io.Reader, stdout, errW io.Writer, extraEnv []string, maxPack int64, cancel <-chan struct{}) error` — closing `cancel` kills the git process and its children; a nil `cancel` never fires.
482
483- [ ] **Step 1: Write the failing test**
484
485`internal/gitutil/transport_test.go`:
486
487```go
488package gitutil
489
490import (
491 "io"
492 "os/exec"
493 "strings"
494 "testing"
495 "time"
496)
497
498// Closing cancel kills the transport; it does not wait for the client
499// to hang up. Stdin ends only after the kill, as a cut connection's
500// does, so a clean exit here would mean the kill never happened.
501func TestTransportCancelKillsGit(t *testing.T) {
502 dir := t.TempDir()
503 if out, err := exec.Command("git", "init", "-q", "--bare", dir).CombinedOutput(); err != nil {
504 t.Fatalf("git init: %v\n%s", err, out)
505 }
506 in, w := io.Pipe()
507 cancel := make(chan struct{})
508 errc := make(chan error, 1)
509 go func() { errc <- Transport("git-upload-pack", dir, in, io.Discard, io.Discard, nil, 0, cancel) }()
510 close(cancel)
511 time.AfterFunc(500*time.Millisecond, func() { w.Close() })
512 select {
513 case err := <-errc:
514 if err == nil || !strings.Contains(err.Error(), "killed") {
515 t.Fatalf("Transport returned %v, want the process killed", err)
516 }
517 case <-time.After(5 * time.Second):
518 t.Fatal("Transport did not return after cancel")
519 }
520}
521```
522
523- [ ] **Step 2: Run it and see it fail**
524
525Run: `go test ./internal/gitutil -run TestTransportCancelKillsGit -count=1`
526Expected: FAIL to compile, too many arguments to `Transport`.
527
528- [ ] **Step 3: Process-group helpers**
529
530`internal/gitutil/proc_unix.go`:
531
532```go
533//go:build unix
534
535package gitutil
536
537import (
538 "os/exec"
539 "syscall"
540)
541
542// ownProcessGroup puts cmd in a process group of its own, so killTree
543// ends what it started too: receive-pack runs index-pack and the hooks.
544func ownProcessGroup(cmd *exec.Cmd) {
545 if cmd.SysProcAttr == nil {
546 cmd.SysProcAttr = &syscall.SysProcAttr{}
547 }
548 cmd.SysProcAttr.Setpgid = true
549}
550
551func killTree(cmd *exec.Cmd) {
552 if cmd.Process == nil {
553 return
554 }
555 if err := syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL); err != nil {
556 cmd.Process.Kill()
557 }
558}
559```
560
561`internal/gitutil/proc_other.go`:
562
563```go
564//go:build !unix
565
566package gitutil
567
568import "os/exec"
569
570func ownProcessGroup(cmd *exec.Cmd) {}
571
572func killTree(cmd *exec.Cmd) {
573 if cmd.Process != nil {
574 cmd.Process.Kill()
575 }
576}
577```
578
579- [ ] **Step 4: Transport**
580
581Replace `Transport` in `internal/gitutil/gitutil.go`:
582
583```go
584// Transport runs a git transport service against repoPath with the
585// client's streams. Closing cancel kills the service and everything it
586// started; a push killed before its pre-receive hook answers updates no
587// refs.
588func Transport(service, repoPath string, stdin io.Reader, stdout, errW io.Writer, extraEnv []string, maxPack int64, cancel <-chan struct{}) error {
589 var args []string
590 switch service {
591 case "git-upload-pack", "git-receive-pack", "git-upload-archive":
592 if service == "git-receive-pack" && maxPack > 0 {
593 args = []string{"-c", fmt.Sprintf("receive.maxInputSize=%d", maxPack)}
594 }
595 args = append(args, strings.TrimPrefix(service, "git-"), repoPath)
596 default:
597 return fmt.Errorf("unknown service %q", service)
598 }
599 cmd := exec.Command(toolpath.Look("git"), args...)
600 cmd.Env = append(os.Environ(), extraEnv...)
601 cmd.Stdin = stdin
602 cmd.Stdout = stdout
603 cmd.Stderr = errW
604 ownProcessGroup(cmd)
605 if err := cmd.Start(); err != nil {
606 return err
607 }
608 finished := make(chan struct{})
609 go func() {
610 select {
611 case <-cancel:
612 killTree(cmd)
613 case <-finished:
614 }
615 }()
616 err := cmd.Wait()
617 close(finished)
618 return err
619}
620```
621
622If the current doc comment above `Transport` (line 38 and up) says something different, keep its first sentence and replace the rest with the above.
623
624- [ ] **Step 5: Fix the caller so the tree builds**
625
626In `internal/sshd/sshd.go:464`, pass `nil` for now (Task 1.3 wires the channel):
627
628```go
629 if err := gitutil.Transport(service, dir, stdin, stdout, stderr, env, maxPack, nil); err != nil {
630```
631
632- [ ] **Step 6: Run the tests**
633
634Run: `go build ./... && go test ./internal/gitutil -count=1`
635Expected: PASS.
636
637- [ ] **Step 7: Commit**
638
639```bash
640git add internal/gitutil internal/sshd/sshd.go
641git commit -S -m "gitutil: Transport takes a cancel channel and kills its process group
642
643Ref #256"
644```
645
646### Task 1.3: sshd re-reads the key per exec and cuts revoked connections
647
648**Files:**
649- Modify: `internal/sshd/sshd.go` — `conn` (46-53), `New` (55-71), `Serve` (158-180), `handleConn` (214-238), `handleSession` (240-292), `runExec` (299-313), `Exec` (339-385), `runGit` (387-468)
650- Modify: `cmd/gitbayd/system.go:97`
651- Modify: `internal/sshd/sshd_test.go:20-87` (`followServer` split)
652- Test: `internal/sshd/revoke_test.go` (create)
653
654**Interfaces:**
655- Consumes: `store.Revoked`, `Store.OnRevoke`, `Store.LiveSSHKeys` (Task 1.1); `gitutil.Transport(..., cancel)` (Task 1.2).
656- Produces:
657 - `func Exec(cfg config.Config, st *store.Store, user store.User, key store.SSHKey, term control.Term, cmdline string, stdin io.Reader, stdout, stderr io.Writer, done, stopping, revoked <-chan struct{}) int` — scope and audit source come from `key`.
658 - `func (s *Server) sweepOnce()` (tests call it).
659 - Test helpers in package `sshd`: `type testServer struct{ srv *Server; st *store.Store; client *ssh.Client; uid, keyID int64; fp string }`, `newTestServer(t) testServer`, `withBuild(t, ts)`, `execStatus(client, cmd) (int, string)`, `waitClosed(t, client)`.
660
661- [ ] **Step 1: Split the test fixture**
662
663In `internal/sshd/sshd_test.go`, replace `followServer` (lines 20-87) with:
664
665```go
666// testServer is an embedded server over a fresh store holding alice
667// with one full-scope key, and a client connected with that key.
668type testServer struct {
669 srv *Server
670 st *store.Store
671 client *ssh.Client
672 uid int64
673 keyID int64
674 fp string
675}
676
677func newTestServer(t *testing.T) testServer {
678 t.Helper()
679 root := t.TempDir()
680 st, err := store.Open(filepath.Join(root, "gitbay.db"))
681 if err != nil {
682 t.Fatal(err)
683 }
684 t.Cleanup(func() { st.Close() })
685 if err := st.MigrateUp(); err != nil {
686 t.Fatal(err)
687 }
688 uid, err := st.CreateUser("alice", false)
689 if err != nil {
690 t.Fatal(err)
691 }
692 _, priv, err := ed25519.GenerateKey(rand.Reader)
693 if err != nil {
694 t.Fatal(err)
695 }
696 signer, err := ssh.NewSignerFromKey(priv)
697 if err != nil {
698 t.Fatal(err)
699 }
700 pub := signer.PublicKey()
701 fp := ssh.FingerprintSHA256(pub)
702 if err := st.AddSSHKey(uid, fp, pub.Type(), pub.Marshal(), "full", "test"); err != nil {
703 t.Fatal(err)
704 }
705 key, err := st.SSHKeyByFingerprint(fp)
706 if err != nil {
707 t.Fatal(err)
708 }
709
710 cfg := config.Default()
711 cfg.Server.Root = root
712 srv, err := New(cfg, st)
713 if err != nil {
714 t.Fatal(err)
715 }
716 ln, err := net.Listen("tcp", "127.0.0.1:0")
717 if err != nil {
718 t.Fatal(err)
719 }
720 go srv.Serve(ln)
721 t.Cleanup(func() { ln.Close() })
722
723 client, err := ssh.Dial("tcp", ln.Addr().String(), &ssh.ClientConfig{
724 User: "git",
725 Auth: []ssh.AuthMethod{ssh.PublicKeys(signer)},
726 HostKeyCallback: ssh.InsecureIgnoreHostKey(),
727 Timeout: 5 * time.Second,
728 })
729 if err != nil {
730 t.Fatal(err)
731 }
732 t.Cleanup(func() { client.Close() })
733 return testServer{srv: srv, st: st, client: client, uid: uid, keyID: key.ID, fp: fp}
734}
735
736// withBuild gives alice the public repo alice/app and a queued build 1
737// whose log has one line.
738func withBuild(t *testing.T, ts testServer) {
739 t.Helper()
740 repoID, err := ts.st.CreateRepo("user", ts.uid, "app", "public")
741 if err != nil {
742 t.Fatal(err)
743 }
744 id, err := ts.st.CreateBuild(repoID, "unit", "abc", "main", `["true"]`, "", "", true)
745 if err != nil {
746 t.Fatal(err)
747 }
748 if err := ts.st.AppendBuildLog(id, []byte("queued\n")); err != nil {
749 t.Fatal(err)
750 }
751}
752
753// followServer starts an embedded server holding alice, her public repo
754// alice/app and a queued build 1 whose log has one line, and returns it
755// with a client connected as alice.
756func followServer(t *testing.T) (*Server, *ssh.Client) {
757 t.Helper()
758 ts := newTestServer(t)
759 withBuild(t, ts)
760 return ts.srv, ts.client
761}
762```
763
764Run: `go test ./internal/sshd -count=1`
765Expected: PASS (behaviour unchanged).
766
767- [ ] **Step 2: Write the failing tests**
768
769`internal/sshd/revoke_test.go`:
770
771```go
772package sshd
773
774import (
775 "bytes"
776 "errors"
777 "strings"
778 "testing"
779 "time"
780
781 "golang.org/x/crypto/ssh"
782)
783
784// execStatus runs cmd on a new session and returns its exit status and
785// stderr; -1 when the session could not run.
786func execStatus(client *ssh.Client, cmd string) (int, string) {
787 sess, err := client.NewSession()
788 if err != nil {
789 return -1, err.Error()
790 }
791 defer sess.Close()
792 var stderr bytes.Buffer
793 sess.Stderr = &stderr
794 err = sess.Run(cmd)
795 var exit *ssh.ExitError
796 switch {
797 case err == nil:
798 return 0, stderr.String()
799 case errors.As(err, &exit):
800 return exit.ExitStatus(), stderr.String()
801 }
802 return -1, err.Error()
803}
804
805// waitClosed fails unless the server closes the client's connection
806// within five seconds.
807func waitClosed(t *testing.T, client *ssh.Client) {
808 t.Helper()
809 done := make(chan struct{})
810 go func() { client.Wait(); close(done) }()
811 select {
812 case <-done:
813 case <-time.After(5 * time.Second):
814 t.Fatal("the connection stayed open")
815 }
816}
817
818// Each exec reads the key again. The rows change behind the store's
819// back here, so no revocation is announced and the connection stays up:
820// what refuses the command is the per-exec check alone.
821func TestExecRevalidatesKey(t *testing.T) {
822 ts := newTestServer(t)
823 if code, errOut := execStatus(ts.client, "whoami"); code != 0 {
824 t.Fatalf("whoami: %d %s", code, errOut)
825 }
826 if _, err := ts.st.DB.Exec("UPDATE ssh_keys SET scope = 'git' WHERE id = ?", ts.keyID); err != nil {
827 t.Fatal(err)
828 }
829 if code, errOut := execStatus(ts.client, "whoami"); code != 4 || !strings.Contains(errOut, "does not allow control commands") {
830 t.Fatalf("whoami after re-scope: %d %q", code, errOut)
831 }
832 if _, err := ts.st.DB.Exec("DELETE FROM ssh_keys WHERE id = ?", ts.keyID); err != nil {
833 t.Fatal(err)
834 }
835 if code, errOut := execStatus(ts.client, "whoami"); code != 4 || !strings.Contains(errOut, "no longer registered") {
836 t.Fatalf("whoami after delete: %d %q", code, errOut)
837 }
838}
839
840// Removing the key cuts the connection, ending a command running on it.
841func TestRemoveKeyCutsConnection(t *testing.T) {
842 ts := newTestServer(t)
843 withBuild(t, ts)
844 var stderr bytes.Buffer
845 sess := startFollow(t, ts.client, &stderr)
846 if err := ts.st.RemoveSSHKey(ts.uid, ts.fp); err != nil {
847 t.Fatal(err)
848 }
849 waited := make(chan error, 1)
850 go func() { waited <- sess.Wait() }()
851 select {
852 case err := <-waited:
853 if err == nil {
854 t.Fatal("the follow exited cleanly after its key was removed")
855 }
856 case <-time.After(5 * time.Second):
857 t.Fatal("the follow outlived its key")
858 }
859 waitClosed(t, ts.client)
860}
861
862func TestDisableCutsConnection(t *testing.T) {
863 ts := newTestServer(t)
864 if err := ts.st.SetUserDisabled(ts.uid, true); err != nil {
865 t.Fatal(err)
866 }
867 waitClosed(t, ts.client)
868}
869
870// A revocation made by another process is not announced here; the
871// sweep finds it. A live key survives the sweep.
872func TestSweepCutsOutOfProcessRevocation(t *testing.T) {
873 ts := newTestServer(t)
874 ts.srv.sweepOnce()
875 if code, errOut := execStatus(ts.client, "whoami"); code != 0 {
876 t.Fatalf("the sweep cut a live key: %d %s", code, errOut)
877 }
878 if _, err := ts.st.DB.Exec("UPDATE users SET disabled = 1 WHERE id = ?", ts.uid); err != nil {
879 t.Fatal(err)
880 }
881 ts.srv.sweepOnce()
882 waitClosed(t, ts.client)
883}
884```
885
886- [ ] **Step 3: Run them and see them fail**
887
888Run: `go test ./internal/sshd -run 'TestExecRevalidatesKey|TestRemoveKeyCutsConnection|TestDisableCutsConnection|TestSweepCutsOutOfProcessRevocation' -count=1`
889Expected: FAIL to compile, `ts.srv.sweepOnce undefined`.
890
891- [ ] **Step 4: Track the key on each connection**
892
893In `internal/sshd/sshd.go` add `"slices"` to the imports. Replace `conn`:
894
895```go
896// conn is one accepted connection and how many sessions it is running.
897// A CLI's shared connection sits idle between commands; on shutdown an
898// idle connection is closed at once and only a session mid-command is
899// waited for (#141).
900type conn struct {
901 net net.Conn
902 active atomic.Int32
903 // keyID and userID are the key that authenticated the connection and
904 // its account: 0 before the handshake and for an unregistered key.
905 // Guarded by Server.mu.
906 keyID, userID int64
907 revoked chan struct{} // closed by cut
908 cutOnce sync.Once
909}
910
911// cut ends the connection because its key was revoked: a git transport
912// on it is killed, and every other command loses its channel.
913func (c *conn) cut() {
914 c.cutOnce.Do(func() { close(c.revoked) })
915 c.net.Close()
916}
917```
918
919In `New`, after `s.sshCfg = sc`:
920
921```go
922 st.OnRevoke(s.revoke)
923```
924
925`Serve` becomes:
926
927```go
928// Serve accepts connections on ln until it is closed.
929func (s *Server) Serve(ln net.Listener) error {
930 served := make(chan struct{})
931 defer close(served)
932 go s.sweep(served)
933 for {
934 nc, err := ln.Accept()
935 if err != nil {
936 return err
937 }
938 c := &conn{net: nc, revoked: make(chan struct{})}
939 s.mu.Lock()
940 s.conns[c] = struct{}{}
941 s.mu.Unlock()
942 s.sessions.Add(1)
943 go func() {
944 defer s.sessions.Done()
945 defer func() {
946 s.mu.Lock()
947 delete(s.conns, c)
948 s.mu.Unlock()
949 }()
950 s.handleConn(c)
951 }()
952 }
953}
954```
955
956Add after `Serve`:
957
958```go
959// revoke closes the connections opened by the keys r names.
960func (s *Server) revoke(r store.Revoked) {
961 s.mu.Lock()
962 defer s.mu.Unlock()
963 for c := range s.conns {
964 if c.keyID == 0 {
965 continue
966 }
967 if (r.UserID != 0 && c.userID == r.UserID) || slices.Contains(r.KeyIDs, c.keyID) {
968 c.cut()
969 }
970 }
971}
972
973// sweepInterval bounds how long a revocation this process was not told
974// about (gitbayd admin on the host) leaves a connection open.
975const sweepInterval = 15 * time.Second
976
977func (s *Server) sweep(served <-chan struct{}) {
978 t := time.NewTicker(sweepInterval)
979 defer t.Stop()
980 for {
981 select {
982 case <-t.C:
983 s.sweepOnce()
984 case <-served:
985 return
986 case <-s.stopping:
987 return
988 }
989 }
990}
991
992// sweepOnce cuts every connection whose key is no longer live. Only
993// connections whose key was asked about are judged: one that
994// authenticated while the query ran waits for the next sweep.
995func (s *Server) sweepOnce() {
996 asked := map[int64]bool{}
997 s.mu.Lock()
998 for c := range s.conns {
999 if c.keyID != 0 {
1000 asked[c.keyID] = true
1001 }
1002 }
1003 s.mu.Unlock()
1004 if len(asked) == 0 {
1005 return
1006 }
1007 live, err := s.st.LiveSSHKeys(slices.Collect(maps.Keys(asked)))
1008 if err != nil {
1009 slog.Error("ssh sweep: key lookup", "err", err)
1010 return
1011 }
1012 s.mu.Lock()
1013 defer s.mu.Unlock()
1014 for c := range s.conns {
1015 if asked[c.keyID] && !live[c.keyID] {
1016 c.cut()
1017 }
1018 }
1019}
1020```
1021
1022Add `"maps"` to the imports.
1023
1024In `handleConn`, after `defer sconn.Close()`:
1025
1026```go
1027 ext := sconn.Permissions.Extensions
1028 s.mu.Lock()
1029 c.keyID, _ = strconv.ParseInt(ext["key-id"], 10, 64)
1030 c.userID, _ = strconv.ParseInt(ext["user-id"], 10, 64)
1031 s.mu.Unlock()
1032```
1033
1034and pass `c` to the session: `s.handleSession(c, sconn, ch, chReqs)`.
1035
1036`handleSession` takes the connection:
1037
1038```go
1039func (s *Server) handleSession(c *conn, sconn *ssh.ServerConn, ch ssh.Channel, reqs <-chan *ssh.Request) {
1040```
1041
1042and its exec case calls `code := s.runExec(c, sconn, ch, term, payload.Command, done)`.
1043
1044- [ ] **Step 5: Re-read the key per exec**
1045
1046Replace `runExec`:
1047
1048```go
1049func (s *Server) runExec(c *conn, sconn *ssh.ServerConn, ch ssh.Channel, term control.Term, cmdline string, done <-chan struct{}) int {
1050 ext := sconn.Permissions.Extensions
1051 if blob := ext["anon-key"]; blob != "" {
1052 return s.runAnonymous(ch, blob, cmdline)
1053 }
1054 userID, _ := strconv.ParseInt(ext["user-id"], 10, 64)
1055 keyID, _ := strconv.ParseInt(ext["key-id"], 10, 64)
1056 // A connection outlives its commands, so the key is read again for
1057 // each one: what it may do is what it may do now (#256).
1058 key, err := s.st.SSHKeyByID(keyID)
1059 if errors.Is(err, store.ErrNotFound) || (err == nil && key.UserID != userID) {
1060 fmt.Fprintln(ch.Stderr(), "this key is no longer registered")
1061 return protocol.ExitDenied
1062 }
1063 if err != nil {
1064 slog.Error("ssh exec: key lookup", "err", err)
1065 fmt.Fprintln(ch.Stderr(), "authentication temporarily unavailable")
1066 return protocol.ExitFailure
1067 }
1068 user, err := s.st.UserByID(userID)
1069 if err != nil {
1070 fmt.Fprintln(ch.Stderr(), "account no longer exists")
1071 return protocol.ExitDenied
1072 }
1073 _ = s.st.TouchSSHKey(keyID)
1074 return Exec(s.cfg, s.st, user, key, term, cmdline, ch, ch, ch.Stderr(), done, s.stopping, c.revoked)
1075}
1076```
1077
1078- [ ] **Step 6: `Exec` takes the key; `runGit` takes the cancel channel**
1079
1080```go
1081// Exec runs one SSH exec command line for an authenticated key. It is the
1082// single dispatch path shared by the embedded listener and the system-sshd
1083// forced command (gitbayd shell). Closing revoked kills a git transport.
1084func Exec(cfg config.Config, st *store.Store, user store.User, key store.SSHKey, term control.Term, cmdline string,
1085 stdin io.Reader, stdout, stderr io.Writer, done, stopping, revoked <-chan struct{}) int {
1086```
1087
1088Inside `Exec`: `runGit(cfg, st, user, key.Scope, argv, stdin, stdout, stderr, revoked)`, `runLFSAuthenticate(cfg, st, user, key.Scope, argv, stdout, stderr)`, and in the `Ctx` literal `Scope: key.Scope, Source: key.Fingerprint`.
1089
1090`runGit` gains a last parameter `revoked <-chan struct{}` and passes it on:
1091
1092```go
1093func runGit(cfg config.Config, st *store.Store, user store.User, scope string, argv []string,
1094 stdin io.Reader, stdout, stderr io.Writer, revoked <-chan struct{}) int {
1095```
1096
1097```go
1098 if err := gitutil.Transport(service, dir, stdin, stdout, stderr, env, maxPack, revoked); err != nil {
1099```
1100
1101In `cmd/gitbayd/system.go:97`:
1102
1103```go
1104 code := sshd.Exec(cfg, st, user, key, control.ParseTerm(os.Getenv("GITBAY_TERM")), cmdline, os.Stdin, os.Stdout, os.Stderr, nil, nil, nil)
1105```
1106
1107- [ ] **Step 7: Run the tests**
1108
1109Run: `go build ./... && go vet ./... && go test ./internal/sshd ./internal/gitutil ./internal/store -count=1`
1110Expected: PASS.
1111
1112- [ ] **Step 8: Commit**
1113
1114```bash
1115git add internal/sshd cmd/gitbayd/system.go
1116git commit -S -m "sshd: re-read the key per exec; revocation cuts its connections
1117
1118Ref #256"
1119```
1120
1121### Task 1.4: e2e — a multiplexed connection is cut, a push in flight moves no ref
1122
1123**Files:**
1124- Create: `e2e/revoke_test.go`
1125
1126**Interfaces:**
1127- Produces (package `e2e`): `pkt(s string) string`, `readPkt(r *bufio.Reader) (string, error)`, `fingerprint(t *testing.T, pubPath string) string` — MR 2 uses `fingerprint`.
1128
1129- [ ] **Step 1: Write the test**
1130
1131```go
1132package e2e
1133
1134import (
1135 "bufio"
1136 "fmt"
1137 "io"
1138 "os"
1139 "os/exec"
1140 "path/filepath"
1141 "strconv"
1142 "strings"
1143 "testing"
1144 "time"
1145)
1146
1147// pkt frames one pkt-line.
1148func pkt(s string) string { return fmt.Sprintf("%04x%s", len(s)+4, s) }
1149
1150// readPkt reads one pkt-line; a flush reads as "".
1151func readPkt(r *bufio.Reader) (string, error) {
1152 var n [4]byte
1153 if _, err := io.ReadFull(r, n[:]); err != nil {
1154 return "", err
1155 }
1156 size, err := strconv.ParseUint(string(n[:]), 16, 16)
1157 if err != nil {
1158 return "", err
1159 }
1160 if size == 0 {
1161 return "", nil
1162 }
1163 buf := make([]byte, size-4)
1164 _, err = io.ReadFull(r, buf)
1165 return string(buf), err
1166}
1167
1168// fingerprint is the SHA256 fingerprint of a public key file.
1169func fingerprint(t *testing.T, pubPath string) string {
1170 t.Helper()
1171 out, err := exec.Command("ssh-keygen", "-lf", pubPath).Output()
1172 if err != nil {
1173 t.Fatalf("ssh-keygen -lf: %v", err)
1174 }
1175 return strings.Fields(string(out))[1]
1176}
1177
1178// Removing a key cuts the connections it opened: every session
1179// multiplexed on a ControlMaster, and a push in flight, which moves no
1180// ref (#256).
1181func TestRemovedKeyCutsMultiplexedConnection(t *testing.T) {
1182 t.Parallel()
1183 inst := startInstance(t)
1184 aliceKey := setupPublicRepo(t, inst, "alice/app")
1185 spare := inst.newKey(t, "spare")
1186 pub, err := os.ReadFile(spare + ".pub")
1187 if err != nil {
1188 t.Fatal(err)
1189 }
1190 if _, errOut, code := inst.ssh(t, aliceKey, string(pub), "keys", "add"); code != 0 {
1191 t.Fatalf("keys add: %s", errOut)
1192 }
1193
1194 // The control socket sits under the system temp dir: t.TempDir() on
1195 // macOS is long enough to pass the 104-byte socket path limit.
1196 cmDir, err := os.MkdirTemp("", "cm")
1197 if err != nil {
1198 t.Fatal(err)
1199 }
1200 t.Cleanup(func() { os.RemoveAll(cmDir) })
1201 muxArgs := []string{
1202 "-p", fmt.Sprint(inst.port),
1203 "-i", aliceKey,
1204 "-o", "IdentitiesOnly=yes",
1205 "-o", "StrictHostKeyChecking=no",
1206 "-o", "UserKnownHostsFile=" + filepath.Join(inst.sshDir, "known_hosts"),
1207 "-o", "BatchMode=yes",
1208 "-o", "ControlMaster=auto",
1209 "-o", "ControlPath=" + filepath.Join(cmDir, "%C"),
1210 "-o", "ControlPersist=60",
1211 }
1212 mux := func(args ...string) *exec.Cmd {
1213 return exec.Command("ssh", append(append([]string{}, muxArgs...), args...)...)
1214 }
1215 t.Cleanup(func() { mux("-O", "exit", "git@127.0.0.1").Run() })
1216
1217 if out, err := mux("git@127.0.0.1", "whoami").Output(); err != nil || strings.TrimSpace(string(out)) != "alice" {
1218 t.Fatalf("whoami over the master: %v %q", err, out)
1219 }
1220
1221 // A push held open mid-pack: the ref update is sent, the pack is not.
1222 push := mux("git@127.0.0.1", "git-receive-pack", "alice/app")
1223 stdin, err := push.StdinPipe()
1224 if err != nil {
1225 t.Fatal(err)
1226 }
1227 stdout, err := push.StdoutPipe()
1228 if err != nil {
1229 t.Fatal(err)
1230 }
1231 if err := push.Start(); err != nil {
1232 t.Fatal(err)
1233 }
1234 adv := bufio.NewReader(stdout)
1235 first, err := readPkt(adv)
1236 if err != nil || len(first) < 40 {
1237 t.Fatalf("advertisement: %q %v", first, err)
1238 }
1239 oldSHA := first[:40]
1240 for {
1241 line, err := readPkt(adv)
1242 if err != nil {
1243 t.Fatalf("advertisement: %v", err)
1244 }
1245 if line == "" {
1246 break
1247 }
1248 }
1249 newSHA := strings.Repeat("1", 40)
1250 io.WriteString(stdin, pkt(oldSHA+" "+newSHA+" refs/heads/main\x00report-status\n")+"0000")
1251 // A pack header announcing one object, and no object.
1252 stdin.Write([]byte("PACK\x00\x00\x00\x02\x00\x00\x00\x01"))
1253 exited := make(chan error, 1)
1254 go func() {
1255 io.Copy(io.Discard, adv)
1256 exited <- push.Wait()
1257 }()
1258
1259 if _, errOut, code := inst.ssh(t, spare, "", "keys", "remove", fingerprint(t, aliceKey+".pub")); code != 0 {
1260 t.Fatalf("keys remove: %s", errOut)
1261 }
1262 select {
1263 case err := <-exited:
1264 if err == nil {
1265 t.Fatal("the push exited cleanly after its key was removed")
1266 }
1267 case <-time.After(10 * time.Second):
1268 t.Fatal("the push outlived its key")
1269 }
1270
1271 // The master went with the connection; a new one authenticates
1272 // again, and the key is unknown.
1273 if out, err := mux("git@127.0.0.1", "whoami").CombinedOutput(); err == nil {
1274 t.Fatalf("whoami after removal succeeded: %s", out)
1275 }
1276 refs := mustGit(t, t.TempDir(), inst.gitEnv(spare), "ls-remote", inst.sshURL("alice/app"), "refs/heads/main")
1277 if !strings.HasPrefix(refs, oldSHA) {
1278 t.Fatalf("main moved: %s, want %s", refs, oldSHA)
1279 }
1280}
1281```
1282
1283- [ ] **Step 2: Run it**
1284
1285Run: `go test ./e2e -run TestRemovedKeyCutsMultiplexedConnection -count=1`
1286Expected: PASS. To see it fail, stash Task 1.3's `st.OnRevoke(s.revoke)` line: the push then hangs until the 10-second timeout.
1287
1288- [ ] **Step 3: Commit**
1289
1290```bash
1291git add e2e/revoke_test.go
1292git commit -S -m "e2e: removing a key cuts its multiplexed connection and a push in flight
1293
1294Ref #256"
1295```
1296
1297### Task 1.5: docs
1298
1299**Files:**
1300- Modify: `.gitbay/wiki/Architecture/10-Known-Gaps.org`, `09-Controls.org:28`, `05-Identity-and-Access.org:17-18`, `08-Operations.org:88-89`, `.gitbay/wiki/Threat-Model.org` (Trust boundaries), `.gitbay/wiki/Users.org` (after the keys block, ~line 70)
1301
1302- [ ] **Step 1: Edit the pages**
1303
1304`10-Known-Gaps.org`: delete the `#256` row. In the paragraph under the table, drop "#256 closes a removed key's connections, running commands included;" so it opens "Decisions already taken on these: #257 refuses ...".
1305
1306`09-Controls.org`, the revocation row becomes:
1307
1308```
1309| Revocation takes effect immediately | in place | removing a key or disabling an account closes its connections; every exec re-reads its key (=internal/sshd/sshd.go=) |
1310```
1311
1312`05-Identity-and-Access.org`, the SSH user key and deploy key rows' Revocation cells become `=keys remove= (own keys); closes its connections` and `=repo deploy-key remove= (repo admin); closes its connections`.
1313
1314`08-Operations.org`: delete the two lines "Open connections of a removed key keep working until they close; see #256."
1315
1316`Threat-Model.org`, add to "Trust boundaries" after the "SSH public key = identity" bullet:
1317
1318```
1319- *Revocation is immediate.* Every exec and every git transport session
1320 re-reads its key. Removing a key, removing a deploy key, disabling or
1321 deleting an account closes the connections the affected keys opened:
1322 a git transport is killed with its children, and a push killed before
1323 its pre-receive hook answers moves no ref. A control command already
1324 inside its database write finishes it; its output is lost. A
1325 revocation made by =gitbayd admin= on the host, another process, is
1326 found within 15 seconds. In =ssh.mode = "system"= each exec is its
1327 own process: the next exec is refused, one already running is not cut.
1328```
1329
1330`Users.org`, after the paragraph that ends "Labels are one line of up to 64 bytes.":
1331
1332```
1333Removing a key closes every connection it opened, including the CLI's
1334shared one; removing the key the current command runs on ends that
1335command's connection too.
1336```
1337
1338- [ ] **Step 2: Commit, open the MR**
1339
1340```bash
1341git add .gitbay/wiki
1342git commit -S -m "wiki: revocation closes open connections
1343
1344Closes #256"
1345git push -u origin revoke-closes-connections
1346gitbay mr create --source revoke-closes-connections --target main --title "sshd: removing a key closes its connections"
1347```
1348
1349Merge with `--strategy ff` once CI is green; delete the branch locally and on the remote.
1350
1351---
1352
1353# MR 2: expiring credentials cannot mint; credentials record their token (branch `token-delegation`, #257)
1354
1355Decisions on the issue: a token with an expiry is refused on every
1356credential-minting command, marked on `Command` and checked in
1357`Dispatch`; tokens and keys record the token that created them;
1358`token revoke` lists what the token created and can revoke it too;
1359`token create` defaults to `--scope read`.
1360
1361Commands marked `MintsCredential`: `token create`, `keys add`,
1362`repo deploy-key add`, `repo runner add` (attaches or creates a key
1363that can claim builds), `web login` (a login link opens a seven-day
1364session), `admin invite`, `admin user create` (with `--key` or a
1365verified address it is a way in), `email verify` and
1366`admin email verify` (a verified address receives login links). See
1367open question 2.
1368
1369`created_by_token` references `api_tokens(id) ON DELETE SET NULL`: a
1370revoked token's id is never reused for a live row, because SQLite
1371reuses the highest rowid after it is deleted and a dangling integer
1372would then name the wrong token. Revoking without `--created` lists
1373what the token made and then drops the link.
1374
1375### Task 2.1: migration 0060 and the store
1376
1377**Files:**
1378- Create: `internal/store/migrations/0060_credential_origin.up.sql`, `.down.sql`
1379- Modify: `internal/store/tokens.go` (whole file)
1380- Modify: `internal/store/users.go` — `SSHKey` (18-28), `AddSSHKey` (270-289), `ListSSHKeys` (335-353)
1381- Test: `internal/store/tokens_test.go` (create)
1382
1383**Interfaces:**
1384- Consumes: `Store.announce`, `Revoked` (MR 1).
1385- Produces:
1386 - `type APIToken struct { ID int64; Name, Scope, CreatedAt string; ExpiresAt, LastUsedAt *time.Time; CreatedBy string }` — `CreatedBy` is the creating token's name, "" for none.
1387 - `func (s *Store) CreateAPIToken(userID int64, name, tokenHash, scope string, expires *time.Time, createdByToken int64) error`
1388 - `func (s *Store) APITokenUser(tokenHash string) (User, APIToken, error)`
1389 - `type Created struct { Tokens []string; Keys []string }` — token names and key fingerprints.
1390 - `func (s *Store) RevokeAPIToken(userID int64, name string, withCreated bool) (Created, error)`
1391 - `type KeyOrigin struct { CreatedByToken int64 }` (MR 3 adds `ExpiresAt`)
1392 - `func (s *Store) AddSSHKeyFrom(userID int64, fingerprint, algo string, blob []byte, scope, label string, o KeyOrigin) error`; `AddSSHKey` keeps its signature and calls it with `KeyOrigin{}`.
1393 - `SSHKey.CreatedBy string` — filled by `ListSSHKeys` only.
1394
1395- [ ] **Step 1: Write the failing test**
1396
1397`internal/store/tokens_test.go`:
1398
1399```go
1400package store
1401
1402import (
1403 "slices"
1404 "testing"
1405 "time"
1406)
1407
1408func tokenID(t *testing.T, s *Store, hash string) int64 {
1409 t.Helper()
1410 _, tok, err := s.APITokenUser(hash)
1411 if err != nil {
1412 t.Fatal(err)
1413 }
1414 return tok.ID
1415}
1416
1417// parent made child, child made grandchild and a key; the key belongs
1418// to another account, as admin user create --key makes one.
1419func tokenChain(t *testing.T) (*Store, int64, *[]Revoked) {
1420 t.Helper()
1421 s, uid, got := revokeFixture(t)
1422 bob, err := s.CreateUser("bob", false)
1423 if err != nil {
1424 t.Fatal(err)
1425 }
1426 if err := s.CreateAPIToken(uid, "parent", "h-parent", "full", nil, 0); err != nil {
1427 t.Fatal(err)
1428 }
1429 if err := s.CreateAPIToken(uid, "child", "h-child", "full", nil, tokenID(t, s, "h-parent")); err != nil {
1430 t.Fatal(err)
1431 }
1432 child := tokenID(t, s, "h-child")
1433 if err := s.CreateAPIToken(uid, "grandchild", "h-grand", "read", nil, child); err != nil {
1434 t.Fatal(err)
1435 }
1436 if err := s.AddSSHKeyFrom(bob, "SHA256:k", "ssh-ed25519", []byte("k"), "full", "", KeyOrigin{CreatedByToken: child}); err != nil {
1437 t.Fatal(err)
1438 }
1439 return s, uid, got
1440}
1441
1442func TestTokenRecordsItsCreator(t *testing.T) {
1443 s, uid, _ := tokenChain(t)
1444 toks, err := s.ListAPITokens(uid)
1445 if err != nil {
1446 t.Fatal(err)
1447 }
1448 by := map[string]string{}
1449 for _, tk := range toks {
1450 by[tk.Name] = tk.CreatedBy
1451 }
1452 if by["parent"] != "" || by["child"] != "parent" || by["grandchild"] != "child" {
1453 t.Fatalf("created by: %v", by)
1454 }
1455 bob, _ := s.UserByUsername("bob")
1456 keys, err := s.ListSSHKeys(bob.ID)
1457 if err != nil || len(keys) != 1 || keys[0].CreatedBy != "child" {
1458 t.Fatalf("key created by: %+v %v", keys, err)
1459 }
1460}
1461
1462func TestRevokeAPITokenListsWhatItCreated(t *testing.T) {
1463 s, uid, got := tokenChain(t)
1464 c, err := s.RevokeAPIToken(uid, "parent", false)
1465 if err != nil {
1466 t.Fatal(err)
1467 }
1468 if !slices.Equal(c.Tokens, []string{"child", "grandchild"}) || !slices.Equal(c.Keys, []string{"SHA256:k"}) {
1469 t.Fatalf("created = %+v", c)
1470 }
1471 // Listed, not removed; the link to the revoked parent is gone.
1472 toks, _ := s.ListAPITokens(uid)
1473 if len(toks) != 2 || toks[0].Name != "child" || toks[0].CreatedBy != "" {
1474 t.Fatalf("tokens after revoke: %+v", toks)
1475 }
1476 if _, err := s.SSHKeyByFingerprint("SHA256:k"); err != nil {
1477 t.Fatalf("the key went: %v", err)
1478 }
1479 if len(*got) != 0 {
1480 t.Fatalf("announced %+v with nothing revoked but the token", *got)
1481 }
1482}
1483
1484func TestRevokeAPITokenWithCreated(t *testing.T) {
1485 s, uid, got := tokenChain(t)
1486 k, _ := s.SSHKeyByFingerprint("SHA256:k")
1487 if _, err := s.RevokeAPIToken(uid, "parent", true); err != nil {
1488 t.Fatal(err)
1489 }
1490 if toks, _ := s.ListAPITokens(uid); len(toks) != 0 {
1491 t.Fatalf("tokens left: %+v", toks)
1492 }
1493 if _, err := s.SSHKeyByFingerprint("SHA256:k"); err != ErrNotFound {
1494 t.Fatalf("key left: %v", err)
1495 }
1496 if len(*got) != 1 || !slices.Equal((*got)[0].KeyIDs, []int64{k.ID}) {
1497 t.Fatalf("announced %+v", *got)
1498 }
1499 if _, err := s.RevokeAPIToken(uid, "parent", true); err != ErrNotFound {
1500 t.Fatalf("second revoke: %v", err)
1501 }
1502}
1503
1504func TestAPITokenUserCarriesExpiry(t *testing.T) {
1505 s, uid, _ := revokeFixture(t)
1506 exp := time.Now().Add(time.Hour)
1507 if err := s.CreateAPIToken(uid, "brief", "h-brief", "full", &exp, 0); err != nil {
1508 t.Fatal(err)
1509 }
1510 _, tok, err := s.APITokenUser("h-brief")
1511 if err != nil || tok.ExpiresAt == nil || tok.Name != "brief" || tok.ID == 0 {
1512 t.Fatalf("token %+v %v", tok, err)
1513 }
1514}
1515```
1516
1517- [ ] **Step 2: Run it and see it fail**
1518
1519Run: `go test ./internal/store -run 'TestTokenRecordsItsCreator|TestRevokeAPIToken|TestAPITokenUserCarriesExpiry' -count=1`
1520Expected: FAIL to compile.
1521
1522- [ ] **Step 3: Migration**
1523
1524`internal/store/migrations/0060_credential_origin.up.sql`:
1525
1526```sql
1527-- The API token a credential was created through. NULL when it was not,
1528-- and once that token is revoked.
1529ALTER TABLE api_tokens ADD COLUMN created_by_token INTEGER REFERENCES api_tokens(id) ON DELETE SET NULL;
1530ALTER TABLE ssh_keys ADD COLUMN created_by_token INTEGER REFERENCES api_tokens(id) ON DELETE SET NULL;
1531```
1532
1533`internal/store/migrations/0060_credential_origin.down.sql`:
1534
1535```sql
1536ALTER TABLE ssh_keys DROP COLUMN created_by_token;
1537ALTER TABLE api_tokens DROP COLUMN created_by_token;
1538```
1539
1540- [ ] **Step 4: Tokens in the store**
1541
1542Replace `internal/store/tokens.go`:
1543
1544```go
1545package store
1546
1547import (
1548 "database/sql"
1549 "errors"
1550 "fmt"
1551 "strings"
1552 "time"
1553)
1554
1555type APIToken struct {
1556 ID int64
1557 Name string
1558 Scope string
1559 CreatedAt string
1560 ExpiresAt *time.Time
1561 LastUsedAt *time.Time
1562 CreatedBy string // name of the token that created this one; "" for none
1563}
1564
1565// nullID stores 0 as NULL.
1566func nullID(id int64) any {
1567 if id == 0 {
1568 return nil
1569 }
1570 return id
1571}
1572
1573// CreateAPIToken stores a token hash; expires nil means no expiry,
1574// createdByToken 0 means it was not created through a token.
1575func (s *Store) CreateAPIToken(userID int64, name, tokenHash, scope string, expires *time.Time, createdByToken int64) error {
1576 var exp any
1577 if expires != nil {
1578 exp = fmtTime(*expires)
1579 }
1580 _, err := s.DB.Exec(
1581 "INSERT INTO api_tokens (user_id, name, token_hash, scope, expires_at, created_by_token) VALUES (?, ?, ?, ?, ?, ?)",
1582 userID, name, tokenHash, scope, exp, nullID(createdByToken))
1583 if isUniqueErr(err) {
1584 return fmt.Errorf("you already have a token named %q", name)
1585 }
1586 return err
1587}
1588
1589// APITokenUser resolves a presented token to its user and the token;
1590// expired and unknown tokens fail identically.
1591func (s *Store) APITokenUser(tokenHash string) (User, APIToken, error) {
1592 var userID int64
1593 var t APIToken
1594 var exp sql.NullString
1595 err := s.DB.QueryRow(`
1596 SELECT user_id, id, name, scope, expires_at FROM api_tokens
1597 WHERE token_hash = ? AND (expires_at IS NULL OR expires_at > ?)`,
1598 tokenHash, fmtTime(time.Now())).Scan(&userID, &t.ID, &t.Name, &t.Scope, &exp)
1599 if errors.Is(err, sql.ErrNoRows) {
1600 return User{}, APIToken{}, ErrNotFound
1601 }
1602 if err != nil {
1603 return User{}, APIToken{}, err
1604 }
1605 t.ExpiresAt = parseTime(exp)
1606 s.DB.Exec("UPDATE api_tokens SET last_used_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE token_hash = ?", tokenHash)
1607 u, err := s.UserByID(userID)
1608 return u, t, err
1609}
1610
1611func (s *Store) ListAPITokens(userID int64) ([]APIToken, error) {
1612 rows, err := s.DB.Query(`
1613 SELECT t.id, t.name, t.scope, t.created_at, t.expires_at, t.last_used_at, COALESCE(p.name, '')
1614 FROM api_tokens t LEFT JOIN api_tokens p ON p.id = t.created_by_token
1615 WHERE t.user_id = ? ORDER BY t.name`, userID)
1616 if err != nil {
1617 return nil, err
1618 }
1619 defer rows.Close()
1620 var out []APIToken
1621 for rows.Next() {
1622 var t APIToken
1623 var exp, used sql.NullString
1624 if err := rows.Scan(&t.ID, &t.Name, &t.Scope, &t.CreatedAt, &exp, &used, &t.CreatedBy); err != nil {
1625 return nil, err
1626 }
1627 t.ExpiresAt = parseTime(exp)
1628 t.LastUsedAt = parseTime(used)
1629 out = append(out, t)
1630 }
1631 return out, rows.Err()
1632}
1633
1634// Created is what a token made, directly or through tokens it made:
1635// token names and SSH key fingerprints.
1636type Created struct {
1637 Tokens []string `json:"tokens"`
1638 Keys []string `json:"keys"`
1639}
1640
1641// chainCTE selects the token named by the first argument and every
1642// token created from it, at any depth.
1643const chainCTE = `WITH RECURSIVE chain(id) AS (
1644 SELECT ? UNION SELECT t.id FROM api_tokens t JOIN chain ON t.created_by_token = chain.id)`
1645
1646// RevokeAPIToken deletes the user's token by name and returns what it
1647// created. withCreated deletes those too; otherwise they stay and lose
1648// the link to the revoked token.
1649func (s *Store) RevokeAPIToken(userID int64, name string, withCreated bool) (Created, error) {
1650 tx, err := s.DB.Begin()
1651 if err != nil {
1652 return Created{}, err
1653 }
1654 defer tx.Rollback()
1655 var id int64
1656 err = tx.QueryRow("SELECT id FROM api_tokens WHERE user_id = ? AND name = ?", userID, name).Scan(&id)
1657 if errors.Is(err, sql.ErrNoRows) {
1658 return Created{}, ErrNotFound
1659 }
1660 if err != nil {
1661 return Created{}, err
1662 }
1663 var c Created
1664 rows, err := tx.Query(chainCTE+` SELECT name FROM api_tokens WHERE id IN (SELECT id FROM chain) AND id != ? ORDER BY name`, id, id)
1665 if err != nil {
1666 return Created{}, err
1667 }
1668 for rows.Next() {
1669 var n string
1670 if err := rows.Scan(&n); err != nil {
1671 rows.Close()
1672 return Created{}, err
1673 }
1674 c.Tokens = append(c.Tokens, n)
1675 }
1676 rows.Close()
1677 var keyIDs []int64
1678 rows, err = tx.Query(chainCTE+` SELECT id, fingerprint FROM ssh_keys WHERE created_by_token IN (SELECT id FROM chain) ORDER BY id`, id)
1679 if err != nil {
1680 return Created{}, err
1681 }
1682 for rows.Next() {
1683 var kid int64
1684 var fp string
1685 if err := rows.Scan(&kid, &fp); err != nil {
1686 rows.Close()
1687 return Created{}, err
1688 }
1689 keyIDs = append(keyIDs, kid)
1690 c.Keys = append(c.Keys, fp)
1691 }
1692 rows.Close()
1693
1694 if !withCreated {
1695 if _, err := tx.Exec("DELETE FROM api_tokens WHERE id = ?", id); err != nil {
1696 return Created{}, err
1697 }
1698 return c, tx.Commit()
1699 }
1700 if len(keyIDs) > 0 {
1701 args := make([]any, len(keyIDs))
1702 for i, k := range keyIDs {
1703 args[i] = k
1704 }
1705 if _, err := tx.Exec("DELETE FROM ssh_keys WHERE id IN (?"+strings.Repeat(", ?", len(keyIDs)-1)+")", args...); err != nil {
1706 return Created{}, err
1707 }
1708 if err := bumpKeyEpoch(tx); err != nil {
1709 return Created{}, err
1710 }
1711 }
1712 if _, err := tx.Exec(chainCTE+` DELETE FROM api_tokens WHERE id IN (SELECT id FROM chain)`, id); err != nil {
1713 return Created{}, err
1714 }
1715 if err := tx.Commit(); err != nil {
1716 return Created{}, err
1717 }
1718 if len(keyIDs) > 0 {
1719 s.announce(Revoked{KeyIDs: keyIDs})
1720 }
1721 return c, nil
1722}
1723```
1724
1725Before writing `nullID`, run `grep -rn "func nullID" internal/store`; if one exists, use it and drop this copy.
1726
1727- [ ] **Step 5: Keys in the store**
1728
1729In `internal/store/users.go`, add to `SSHKey` after `LastUsedAt`:
1730
1731```go
1732 CreatedBy string // name of the API token that added the key; "" for none. ListSSHKeys only.
1733```
1734
1735Replace `AddSSHKey`:
1736
1737```go
1738// KeyOrigin is how a key came to be.
1739type KeyOrigin struct {
1740 CreatedByToken int64 // the API token that added it; 0 for none
1741}
1742
1743// AddSSHKey registers a key and bumps the key epoch in one transaction.
1744func (s *Store) AddSSHKey(userID int64, fingerprint, algo string, blob []byte, scope, label string) error {
1745 return s.AddSSHKeyFrom(userID, fingerprint, algo, blob, scope, label, KeyOrigin{})
1746}
1747
1748// AddSSHKeyFrom is AddSSHKey recording where the key came from.
1749func (s *Store) AddSSHKeyFrom(userID int64, fingerprint, algo string, blob []byte, scope, label string, o KeyOrigin) error {
1750 tx, err := s.DB.Begin()
1751 if err != nil {
1752 return err
1753 }
1754 defer tx.Rollback()
1755 if _, err := tx.Exec(
1756 "INSERT INTO ssh_keys (user_id, fingerprint, algo, blob, scope, label, created_by_token) VALUES (?, ?, ?, ?, ?, ?, ?)",
1757 userID, fingerprint, algo, blob, scope, label, nullID(o.CreatedByToken)); err != nil {
1758 if isUniqueErr(err) {
1759 return ErrDuplicateKey
1760 }
1761 return err
1762 }
1763 if err := bumpKeyEpoch(tx); err != nil {
1764 return err
1765 }
1766 return tx.Commit()
1767}
1768```
1769
1770`ListSSHKeys`:
1771
1772```go
1773func (s *Store) ListSSHKeys(userID int64) ([]SSHKey, error) {
1774 rows, err := s.DB.Query(
1775 `SELECT k.id, k.user_id, k.fingerprint, k.algo, k.blob, k.scope, k.label, k.created_at,
1776 COALESCE(k.last_used_at, ''), COALESCE(t.name, '')
1777 FROM ssh_keys k LEFT JOIN api_tokens t ON t.id = k.created_by_token
1778 WHERE k.user_id = ? ORDER BY k.id`,
1779 userID)
1780 if err != nil {
1781 return nil, err
1782 }
1783 defer rows.Close()
1784 var keys []SSHKey
1785 for rows.Next() {
1786 var k SSHKey
1787 if err := rows.Scan(&k.ID, &k.UserID, &k.Fingerprint, &k.Algo, &k.Blob, &k.Scope, &k.Label, &k.CreatedAt, &k.LastUsedAt, &k.CreatedBy); err != nil {
1788 return nil, err
1789 }
1790 keys = append(keys, k)
1791 }
1792 return keys, rows.Err()
1793}
1794```
1795
1796- [ ] **Step 6: Run the store tests**
1797
1798Run: `go test ./internal/store -count=1`
1799Expected: PASS. (`go build ./...` fails until Task 2.2 updates the callers.)
1800
1801- [ ] **Step 7: Commit**
1802
1803```bash
1804git add internal/store
1805git commit -S -m "store: tokens and keys record the token that created them; chained revoke
1806
1807Ref #257"
1808```
1809
1810### Task 2.2: `MintsCredential`, `Ctx.Expires`, and the API wiring
1811
1812**Files:**
1813- Modify: `internal/control/control.go` — `Ctx` (21-57), `Command` (79-91), `Dispatch` (after line 161)
1814- Modify: `internal/httpd/api.go:30-78`, `:126-144`; `internal/httpd/apiread.go:26`
1815- Modify: registrations in `internal/control/token.go:16`, `identity.go:33`, `deploykey.go:16`, `runnerrepo.go:21`, `web.go:16`, `adminhost.go:24`, `:53`, `:58`, `register.go:38`
1816- Test: `internal/control/token_test.go` (create)
1817
1818**Interfaces:**
1819- Consumes: `store.APITokenUser` returning `APIToken` (Task 2.1).
1820- Produces:
1821 - `Command.MintsCredential bool`
1822 - `Ctx.TokenID int64` — the API token behind the request, 0 for none.
1823 - `Ctx.Expires *time.Time` — when the credential behind the request lapses; nil when it does not. MR 3 sets it for keys.
1824
1825- [ ] **Step 1: Write the failing tests**
1826
1827`internal/control/token_test.go`:
1828
1829```go
1830package control
1831
1832import (
1833 "bytes"
1834 "slices"
1835 "strings"
1836 "testing"
1837 "time"
1838
1839 "gitbay.org/gitbay/internal/protocol"
1840 "gitbay.org/gitbay/internal/store"
1841)
1842
1843// The minting commands, pinned: adding one to the list, or dropping
1844// one, is a decision this test makes someone take.
1845func TestMintingCommandsMarked(t *testing.T) {
1846 want := []string{
1847 "admin email verify", "admin invite", "admin user create", "email verify",
1848 "keys add", "repo deploy-key add", "repo runner add", "token create", "web login",
1849 }
1850 var got []string
1851 for _, cmd := range Commands() {
1852 if cmd.MintsCredential {
1853 got = append(got, joinPath(cmd.Path))
1854 }
1855 }
1856 slices.Sort(got)
1857 if !slices.Equal(got, want) {
1858 t.Fatalf("MintsCredential on %q, want %q", got, want)
1859 }
1860}
1861
1862// Dispatch refuses before the command runs, so no arguments are needed.
1863func TestExpiringCredentialCannotMint(t *testing.T) {
1864 exp := time.Now().Add(time.Hour)
1865 for _, cmd := range Commands() {
1866 if !cmd.MintsCredential {
1867 continue
1868 }
1869 var out, errOut bytes.Buffer
1870 c := &Ctx{User: store.User{ID: 1, Username: "root", IsAdmin: true}, Scope: "full", Expires: &exp, Stdout: &out, Stderr: &errOut}
1871 if code := Dispatch(c, cmd.Path); code != protocol.ExitDenied || !strings.Contains(errOut.String(), "expires") {
1872 t.Errorf("%s: exit %d %q, want %d and the reason", joinPath(cmd.Path), code, errOut.String(), protocol.ExitDenied)
1873 }
1874 }
1875}
1876
1877func TestTokenCreateDefaultsToReadAndRecordsCreator(t *testing.T) {
1878 st, _, uid := newQueueTestRepo(t)
1879 if err := st.CreateAPIToken(uid, "parent", "h-parent", "full", nil, 0); err != nil {
1880 t.Fatal(err)
1881 }
1882 _, parent, err := st.APITokenUser("h-parent")
1883 if err != nil {
1884 t.Fatal(err)
1885 }
1886 c, errOut := pruneCtx(st, t.TempDir(), store.User{ID: uid, Username: "alice"})
1887 c.Cfg.Limits.WriteRate = -1
1888 c.TokenID = parent.ID
1889 if code := Dispatch(c, []string{"token", "create", "--name", "child"}); code != protocol.ExitOK {
1890 t.Fatalf("exit %d: %s", code, errOut)
1891 }
1892 toks, err := st.ListAPITokens(uid)
1893 if err != nil {
1894 t.Fatal(err)
1895 }
1896 for _, tk := range toks {
1897 if tk.Name == "child" && (tk.Scope != "read" || tk.CreatedBy != "parent") {
1898 t.Fatalf("child: %+v", tk)
1899 }
1900 }
1901}
1902```
1903
1904- [ ] **Step 2: Run them and see them fail**
1905
1906Run: `go test ./internal/control -run 'TestMintingCommandsMarked|TestExpiringCredentialCannotMint|TestTokenCreateDefaultsToRead' -count=1`
1907Expected: FAIL to compile, `unknown field MintsCredential`.
1908
1909- [ ] **Step 3: `Ctx`, `Command`, `Dispatch`**
1910
1911In `Ctx`, after `Source string`:
1912
1913```go
1914 // TokenID is the API token behind this request, 0 for none. A
1915 // credential the request creates records it.
1916 TokenID int64
1917 // Expires is when the credential behind this request lapses; nil
1918 // when it does not. Dispatch refuses MintsCredential commands when
1919 // it is set.
1920 Expires *time.Time
1921```
1922
1923In `Command`, after `ReadOnly`:
1924
1925```go
1926 // MintsCredential marks a command that creates a credential or a way
1927 // to obtain one: tokens, keys, login links, invites, accounts,
1928 // verified addresses. An expiring credential may not run it.
1929 MintsCredential bool
1930```
1931
1932In `Dispatch`, after the `c.ReadOnly && !cmd.ReadOnly` check:
1933
1934```go
1935 // What an expiring credential creates would outlive it (#257).
1936 if cmd.MintsCredential && c.Expires != nil {
1937 return c.fail(protocol.ExitDenied,
1938 "%s creates a credential, and the one this request came with expires; use a token or key without an expiry", joinPath(cmd.Path))
1939 }
1940```
1941
1942- [ ] **Step 4: Mark the nine commands**
1943
1944Add `MintsCredential: true,` to each registration: `token create` (`token.go:16`), `keys add` (`identity.go:33`), `repo deploy-key add` (`deploykey.go:16`), `repo runner add` (`runnerrepo.go:21`), `web login` (`web.go:16`), `admin user create` (`adminhost.go:24`), `admin email verify` (`adminhost.go:53`), `admin invite` (`adminhost.go:58`), `email verify` (`register.go:38`). For example:
1945
1946```go
1947 register(Command{Path: []string{"web", "login"},
1948 Summary: "mint a one-time browser login URL",
1949 Usage: "web login",
1950 MintsCredential: true,
1951 Examples: []string{"web login"}, Run: runWebLogin})
1952```
1953
1954- [ ] **Step 5: The API passes the token**
1955
1956In `internal/httpd/api.go`, `apiAuth` returns the token:
1957
1958```go
1959// apiAuth resolves the bearer token; failures are uniform 401s.
1960func (s *Server) apiAuth(w http.ResponseWriter, r *http.Request) (store.User, store.APIToken, bool) {
1961 token, ok := strings.CutPrefix(r.Header.Get("Authorization"), "Bearer ")
1962 if !ok || token == "" {
1963 w.Header().Set("WWW-Authenticate", `Bearer realm="gitbay api"`)
1964 apiError(w, http.StatusUnauthorized, "missing bearer token; mint one over SSH: token create --name <n>")
1965 return store.User{}, store.APIToken{}, false
1966 }
1967 user, tok, err := s.st.APITokenUser(store.HashToken(strings.TrimSpace(token)))
1968 if err != nil {
1969 if errors.Is(err, store.ErrNotFound) {
1970 apiError(w, http.StatusUnauthorized, "invalid or expired token")
1971 return store.User{}, store.APIToken{}, false
1972 }
1973 apiError(w, http.StatusInternalServerError, "internal error")
1974 return store.User{}, store.APIToken{}, false
1975 }
1976 return user, tok, true
1977}
1978```
1979
1980In `apiCmd`: `user, tok, ok := s.apiAuth(w, r)`, and in the `Ctx` literal replace `ReadOnly: scope == "read",` with:
1981
1982```go
1983 ReadOnly: tok.Scope == "read",
1984 TokenID: tok.ID,
1985 Expires: tok.ExpiresAt,
1986```
1987
1988`apiRead` keeps `user, _, ok := s.apiAuth(w, r)`; it compiles unchanged.
1989
1990- [ ] **Step 6: Run the tests**
1991
1992Run: `go test ./internal/control -run 'TestMintingCommandsMarked|TestExpiringCredentialCannotMint' -count=1`
1993Expected: PASS. `TestTokenCreateDefaultsToRead...` still fails until Task 2.3.
1994
1995- [ ] **Step 7: Commit**
1996
1997```bash
1998git add internal/control/control.go internal/control/token_test.go internal/control/*.go internal/httpd/api.go
1999git commit -S -m "control: expiring credentials cannot run credential-minting commands
2000
2001Ref #257"
2002```
2003
2004### Task 2.3: commands record their token; `token create` defaults to read; `token revoke --created`
2005
2006**Files:**
2007- Modify: `internal/control/token.go` (registrations 16-34, `runTokenCreate` 49-88, `runTokenList` 90-122, `runTokenRevoke` 124-137)
2008- Modify: `internal/control/identity.go` — `runKeysList` (76-100), `runKeysAdd` (152)
2009- Modify: `internal/control/deploykey.go:71`, `internal/control/runnerrepo.go:60`, `internal/control/adminhost.go:125`
2010- Modify: `e2e/api_test.go:50`, `:266-282` (`mintToken`)
2011
2012**Interfaces:**
2013- Consumes: `Ctx.TokenID`, `store.KeyOrigin`, `Store.AddSSHKeyFrom`, `Store.RevokeAPIToken` (Tasks 2.1–2.2).
2014
2015- [ ] **Step 1: `token create`**
2016
2017Registration:
2018
2019```go
2020 register(Command{Path: []string{"token", "create"},
2021 Summary: "mint an API token (shown once)",
2022 Usage: "token create --name <n> [--scope read|full] [--ttl 30d|720h]",
2023 Flags: []Flag{
2024 {"--name", "<n>", "the token's name", ""},
2025 {"--scope", "read|full", "what the token may do; full is needed to change anything", "read"},
2026 {"--ttl", "30d|720h", "how long the token is valid; an expiring token cannot mint credentials", "never expires"},
2027 },
2028 Examples: []string{"token create --name laptop --ttl 30d", "token create --name phone --scope full"},
2029 MintsCredential: true,
2030 Run: runTokenCreate})
2031```
2032
2033In `runTokenCreate`: the `parseFlags` usage becomes `Usage: c.Cmd.Usage`; `name, scope, ttl := f.Value("--name"), "read", f.Value("--ttl")`; the store call:
2034
2035```go
2036 if err := c.Store.CreateAPIToken(c.User.ID, name, store.HashToken(token), scope, expires, c.TokenID); err != nil {
2037```
2038
2039- [ ] **Step 2: `token list` shows the creator in JSON**
2040
2041In `runTokenList`, the `out` struct gains `CreatedBy string `json:"created_by,omitempty"`` after `LastUsedAt`, and the append becomes `out{t.Name, t.Scope, t.CreatedAt, t.ExpiresAt, t.LastUsedAt, t.CreatedBy}`. Plain output is unchanged.
2042
2043- [ ] **Step 3: `token revoke [--created]`**
2044
2045Registration:
2046
2047```go
2048 register(Command{Path: []string{"token", "revoke"},
2049 Summary: "revoke an API token by name",
2050 Usage: "token revoke <name> [--created]",
2051 Flags: []Flag{
2052 {"--created", "", "also revoke the tokens and keys it created, at any depth", ""},
2053 },
2054 Examples: []string{"token revoke laptop", "token revoke laptop --created"},
2055 Run: runTokenRevoke})
2056```
2057
2058```go
2059func runTokenRevoke(c *Ctx, args []string) int {
2060 f, err := parseFlags(args, flagSpec{Bools: []string{"--created"}, MaxPos: 1, Usage: c.Cmd.Usage})
2061 if err != nil {
2062 return c.fail(protocol.ExitUsage, "%v", err)
2063 }
2064 name := f.pos(0)
2065 if name == "" {
2066 return c.usage()
2067 }
2068 withCreated := f.Has("--created")
2069 created, err := c.Store.RevokeAPIToken(c.User.ID, name, withCreated)
2070 if err != nil {
2071 if errors.Is(err, store.ErrNotFound) {
2072 return c.fail(protocol.ExitNotFound, "no token named %q", name)
2073 }
2074 return c.fail(protocol.ExitFailure, "%v", err)
2075 }
2076 type out struct {
2077 Revoked string `json:"revoked"`
2078 Created store.Created `json:"created"`
2079 CreatedRevoked bool `json:"created_revoked"`
2080 }
2081 d := out{name, created, withCreated}
2082 return c.emit(d, func(w io.Writer) {
2083 fmt.Fprintf(w, "revoked %s\n", name)
2084 if len(created.Tokens)+len(created.Keys) == 0 {
2085 return
2086 }
2087 if withCreated {
2088 fmt.Fprintln(w, "and what it created:")
2089 } else {
2090 fmt.Fprintln(w, "it created these, still in place:")
2091 }
2092 for _, n := range created.Tokens {
2093 fmt.Fprintf(w, " token %s\n", n)
2094 }
2095 for _, fp := range created.Keys {
2096 fmt.Fprintf(w, " key %s\n", fp)
2097 }
2098 })
2099}
2100```
2101
2102- [ ] **Step 4: Key-adding commands record the token**
2103
2104`identity.go`, `runKeysAdd`:
2105
2106```go
2107 if err := c.Store.AddSSHKeyFrom(c.User.ID, fp, pub.Type(), pub.Marshal(), scope, label, store.KeyOrigin{CreatedByToken: c.TokenID}); err != nil {
2108```
2109
2110`deploykey.go:71`:
2111
2112```go
2113 if err := c.Store.AddSSHKeyFrom(c.User.ID, fp, pub.Type(), pub.Marshal(), scope, label, store.KeyOrigin{CreatedByToken: c.TokenID}); err != nil {
2114```
2115
2116`runnerrepo.go:60`:
2117
2118```go
2119 if err := c.Store.AddSSHKeyFrom(c.User.ID, fp, pub.Type(), pub.Marshal(), "runner", label, store.KeyOrigin{CreatedByToken: c.TokenID}); err != nil {
2120```
2121
2122`adminhost.go:125`:
2123
2124```go
2125 if err := c.Store.AddSSHKeyFrom(uid, fp, pub.Type(), pub.Marshal(), "full", label, store.KeyOrigin{CreatedByToken: c.TokenID}); err != nil {
2126```
2127
2128`keys list` JSON: in `runKeysList` the `out` struct gains `CreatedBy string `json:"created_by,omitempty"`` and the append passes `k.CreatedBy`. Plain output is unchanged in this MR.
2129
2130- [ ] **Step 5: e2e callers that write with a default-scope token**
2131
2132`e2e/api_test.go:50`: `"token", "create", "--name", "ci", "--scope", "full", "--json"`.
2133`mintToken` (`e2e/api_test.go:268`): `"token", "create", "--name", name, "--scope", "full", "--json"`.
2134Then `grep -rn '"token", "create"' e2e` and check each remaining call: a token only used for reads, or for a refusal, needs nothing.
2135
2136- [ ] **Step 6: Run the tests**
2137
2138Run: `go build ./... && go vet ./... && go test ./internal/control ./internal/store ./internal/httpd -count=1`
2139Expected: PASS, including `TestHelpIsComplete` (every flag in the usage is described) and `TestTokenCreateDefaultsToReadAndRecordsCreator`.
2140
2141- [ ] **Step 7: Commit**
2142
2143```bash
2144git add internal/control e2e/api_test.go
2145git commit -S -m "token: default --scope read; record the creating token; revoke --created
2146
2147Ref #257"
2148```
2149
2150### Task 2.4: e2e — delegation over the API
2151
2152**Files:**
2153- Create: `e2e/tokenorigin_test.go`
2154
2155**Interfaces:**
2156- Consumes: `fingerprint` (MR 1, `e2e/revoke_test.go`), `inst.apiCall`.
2157
2158- [ ] **Step 1: Write the test**
2159
2160```go
2161package e2e
2162
2163import (
2164 "encoding/json"
2165 "fmt"
2166 "os"
2167 "strings"
2168 "testing"
2169)
2170
2171// An expiring token cannot mint a credential that outlives it, and
2172// revoking a token can take what it created with it (#257).
2173func TestTokenDelegation(t *testing.T) {
2174 t.Parallel()
2175 inst := startInstanceWith(t, "[api]\nenabled = true\n")
2176 aliceKey := inst.newKey(t, "alice")
2177 inst.admin(t, "admin", "user", "create", "alice", "--key", aliceKey+".pub")
2178
2179 mint := func(args ...string) (token, scope string) {
2180 t.Helper()
2181 out, errOut, code := inst.ssh(t, aliceKey, "", append([]string{"token", "create", "--json"}, args...)...)
2182 if code != 0 {
2183 t.Fatalf("token create %v: %s", args, errOut)
2184 }
2185 var env struct {
2186 Data struct {
2187 Token string `json:"token"`
2188 Scope string `json:"scope"`
2189 } `json:"data"`
2190 }
2191 if err := json.Unmarshal([]byte(out), &env); err != nil {
2192 t.Fatalf("token create output: %v %s", err, out)
2193 }
2194 return env.Data.Token, env.Data.Scope
2195 }
2196 if _, scope := mint("--name", "plain"); scope != "read" {
2197 t.Fatalf("default scope %q, want read", scope)
2198 }
2199 brief, _ := mint("--name", "brief", "--scope", "full", "--ttl", "1h")
2200 lasting, _ := mint("--name", "lasting", "--scope", "full")
2201
2202 spare := inst.newKey(t, "spare")
2203 pub, err := os.ReadFile(spare + ".pub")
2204 if err != nil {
2205 t.Fatal(err)
2206 }
2207 status, body := inst.apiCall(t, brief, []string{"keys", "add"}, string(pub))
2208 if status != 403 || !strings.Contains(fmt.Sprint(body["error"]), "expires") {
2209 t.Fatalf("expiring token added a key: %d %v", status, body)
2210 }
2211 if status, _ := inst.apiCall(t, brief, []string{"whoami"}, ""); status != 200 {
2212 t.Fatalf("expiring token refused a read: %d", status)
2213 }
2214 if status, body := inst.apiCall(t, lasting, []string{"keys", "add"}, string(pub)); status != 200 {
2215 t.Fatalf("keys add: %d %v", status, body)
2216 }
2217 if status, body := inst.apiCall(t, lasting, []string{"token", "create", "--name", "child"}, ""); status != 200 {
2218 t.Fatalf("token create: %d %v", status, body)
2219 }
2220 if _, errOut, code := inst.ssh(t, spare, "", "whoami"); code != 0 {
2221 t.Fatalf("the added key does not work: %s", errOut)
2222 }
2223
2224 out, errOut, code := inst.ssh(t, aliceKey, "", "token", "revoke", "lasting", "--created")
2225 if code != 0 || !strings.Contains(out, "token child") || !strings.Contains(out, fingerprint(t, spare+".pub")) {
2226 t.Fatalf("revoke --created: exit %d\n%s%s", code, out, errOut)
2227 }
2228 if _, _, code := inst.ssh(t, spare, "", "whoami"); code == 0 {
2229 t.Fatal("a key the revoked token created still works")
2230 }
2231 if out, _, _ := inst.ssh(t, aliceKey, "", "token", "list"); strings.Contains(out, "child") {
2232 t.Fatalf("the child token survived:\n%s", out)
2233 }
2234}
2235```
2236
2237- [ ] **Step 2: Run it**
2238
2239Run: `go test ./e2e -run TestTokenDelegation -count=1`
2240Expected: PASS.
2241
2242- [ ] **Step 3: Commit**
2243
2244```bash
2245git add e2e/tokenorigin_test.go
2246git commit -S -m "e2e: expiring tokens refused on minting; revoke --created
2247
2248Ref #257"
2249```
2250
2251### Task 2.5: docs and release note
2252
2253**Files:**
2254- Modify: `.gitbay/wiki/API.org:14-33` (Tokens), `.gitbay/wiki/Threat-Model.org` (Trust boundaries), `.gitbay/wiki/Architecture/05-Identity-and-Access.org:20`, `09-Controls.org:29`, `10-Known-Gaps.org`, `.gitbay/wiki/Parity.org:358`, `CHANGELOG.org`, `internal/web/templates/account.html:194`
2255
2256- [ ] **Step 1: Edit the pages**
2257
2258`API.org`, the Tokens section's first paragraph and code block become:
2259
2260```
2261Tokens are minted wherever the registry is reached: over SSH, on the
2262API, anywhere. =token create= makes a =read= token unless =--scope full=
2263is given; a read token runs only commands marked read-only. A full-scope
2264token can mint another, but a token with a =--ttl= cannot run any
2265command that creates a credential — =token create=, =keys add=,
2266=repo deploy-key add=, =repo runner add=, =web login=, =admin invite=,
2267=admin user create=, =email verify=, =admin email verify= — since what
2268it made would outlive it. Give a token the narrowest scope and shortest
2269TTL that does its job, and revoke it when the job is over.
2270
2271#+begin_src sh
2272gitbay auth token create --name ci [--scope read|full] [--ttl 30d]
2273gitbay auth token list
2274gitbay auth token revoke ci [--created]
2275#+end_src
2276
2277Tokens and keys record the token they were created through. =token
2278revoke= prints what the token created, at any depth; with =--created=
2279it revokes those too, and their SSH connections close. Without it they
2280stay and the link is dropped.
2281```
2282
2283`Threat-Model.org`, in Trust boundaries, replace "so a bearer token is worth exactly its scope and no more." with "so a bearer token is worth exactly its scope and no more, and a credential with an expiry cannot create one that outlives it."
2284
2285`05-Identity-and-Access.org`: the API token row's Scope cell becomes `=read= (default) or =full=; with an expiry, no credential-minting command`, and its Revocation cell `=token revoke [--created]=`.
2286
2287`09-Controls.org`:
2288
2289```
2290| Delegation bounded by the delegating credential | in place | expiring tokens refused on =MintsCredential= commands; credentials record their creating token (=internal/control/control.go=) |
2291```
2292
2293`10-Known-Gaps.org`: delete the `#257` row and the "#257 refuses credential creation ..." sentence, leaving the paragraph out if nothing remains in it.
2294
2295`Parity.org`, after the `API token mint` row:
2296
2297```
2298| API token revoke with what it created | yes | no | no |
2299```
2300
2301`account.html:194`: `gitbay auth token create --name laptop # API tokens, read-only unless --scope full`.
2302
2303`CHANGELOG.org`, under the unreleased heading (see Global constraints):
2304
2305```
2306*Upgrade note.* =token create= makes a =read= token unless given
2307=--scope full=. A script that mints a token and then writes with it
2308must add =--scope full=. Existing tokens keep their scope.
2309
2310- A token with a =--ttl= is refused on every command that creates a
2311 credential: tokens, keys, deploy keys, runner keys, login links,
2312 invites, accounts and verified addresses (#257).
2313- Tokens and SSH keys record the token they were created through.
2314 =token revoke <name>= lists what it created; =--created= revokes
2315 those too.
2316- Removing an SSH key, a deploy key, or disabling an account closes the
2317 connections the key opened, a push in flight included (#256).
2318```
2319
2320(The #256 line belongs to MR 1's changes; add it here if MR 1 did not touch the changelog.)
2321
2322- [ ] **Step 2: Commit, open the MR**
2323
2324```bash
2325git add .gitbay/wiki CHANGELOG.org internal/web/templates/account.html
2326git commit -S -m "wiki: token delegation, read default; release note
2327
2328Closes #257"
2329git push -u origin token-delegation
2330gitbay mr create --source token-delegation --target main --title "token: expiring tokens cannot mint credentials; record creator; default read scope"
2331```
2332
2333---
2334
2335# MR 3: optional expiry for SSH and deploy keys (branch `key-expiry`, #277)
2336
2337The issue says to consider this with #257. An expiring key is treated
2338like an expiring token: `Exec` sets `Ctx.Expires`, so `Dispatch` refuses
2339the minting commands to it (open question 1).
2340
2341### Task 3.1: migration 0061 and the store
2342
2343**Files:**
2344- Create: `internal/store/migrations/0061_ssh_key_expiry.up.sql`, `.down.sql`
2345- Modify: `internal/store/users.go` — `SSHKey`, `KeyOrigin`, `AddSSHKeyFrom`, `SSHKeyByFingerprint` (324-333), `SSHKeyByID` (502-511), `ListSSHKeys`, `ListDeployKeys` (514-532)
2346- Modify: `internal/store/revoke.go` — `LiveSSHKeys`
2347- Test: `internal/store/keyexpiry_test.go` (create)
2348
2349**Interfaces:**
2350- Produces:
2351 - `SSHKey.ExpiresAt *time.Time` — nil when the key never expires; filled by every key query.
2352 - `func (k SSHKey) Expired(now time.Time) bool`
2353 - `KeyOrigin.ExpiresAt *time.Time`
2354 - `LiveSSHKeys` also excludes expired keys.
2355
2356- [ ] **Step 1: Write the failing test**
2357
2358```go
2359package store
2360
2361import (
2362 "testing"
2363 "time"
2364)
2365
2366func TestKeyExpiry(t *testing.T) {
2367 s, uid, _ := revokeFixture(t)
2368 past, future := time.Now().Add(-time.Minute), time.Now().Add(time.Hour)
2369 for fp, exp := range map[string]*time.Time{"SHA256:old": &past, "SHA256:new": &future, "SHA256:ever": nil} {
2370 if err := s.AddSSHKeyFrom(uid, fp, "ssh-ed25519", []byte(fp), "full", "", KeyOrigin{ExpiresAt: exp}); err != nil {
2371 t.Fatal(err)
2372 }
2373 }
2374 now := time.Now()
2375 ids := map[string]int64{}
2376 for _, fp := range []string{"SHA256:old", "SHA256:new", "SHA256:ever"} {
2377 k, err := s.SSHKeyByFingerprint(fp)
2378 if err != nil {
2379 t.Fatal(err)
2380 }
2381 ids[fp] = k.ID
2382 byID, err := s.SSHKeyByID(k.ID)
2383 if err != nil || (byID.ExpiresAt == nil) != (k.ExpiresAt == nil) {
2384 t.Fatalf("%s by id: %+v %v", fp, byID, err)
2385 }
2386 if got, want := k.Expired(now), fp == "SHA256:old"; got != want {
2387 t.Errorf("%s Expired = %v, want %v", fp, got, want)
2388 }
2389 }
2390 live, err := s.LiveSSHKeys([]int64{ids["SHA256:old"], ids["SHA256:new"], ids["SHA256:ever"]})
2391 if err != nil {
2392 t.Fatal(err)
2393 }
2394 if live[ids["SHA256:old"]] || !live[ids["SHA256:new"]] || !live[ids["SHA256:ever"]] {
2395 t.Fatalf("live = %v", live)
2396 }
2397 keys, err := s.ListSSHKeys(uid)
2398 if err != nil || len(keys) != 3 || keys[2].ExpiresAt != nil {
2399 t.Fatalf("list: %+v %v", keys, err)
2400 }
2401}
2402```
2403
2404- [ ] **Step 2: Run it and see it fail**
2405
2406Run: `go test ./internal/store -run TestKeyExpiry -count=1`
2407Expected: FAIL to compile, `unknown field ExpiresAt in struct literal of type KeyOrigin`.
2408
2409- [ ] **Step 3: Migration**
2410
2411`0061_ssh_key_expiry.up.sql`:
2412
2413```sql
2414-- When the key stops authenticating; NULL for never.
2415ALTER TABLE ssh_keys ADD COLUMN expires_at TEXT;
2416```
2417
2418`0061_ssh_key_expiry.down.sql`:
2419
2420```sql
2421ALTER TABLE ssh_keys DROP COLUMN expires_at;
2422```
2423
2424- [ ] **Step 4: Store**
2425
2426`SSHKey` gains, after `CreatedBy`:
2427
2428```go
2429 ExpiresAt *time.Time // nil when the key never expires
2430```
2431
2432and the method:
2433
2434```go
2435// Expired reports whether the key has lapsed at now.
2436func (k SSHKey) Expired(now time.Time) bool {
2437 return k.ExpiresAt != nil && !k.ExpiresAt.After(now)
2438}
2439```
2440
2441`KeyOrigin`:
2442
2443```go
2444// KeyOrigin is how a key came to be.
2445type KeyOrigin struct {
2446 CreatedByToken int64 // the API token that added it; 0 for none
2447 ExpiresAt *time.Time // when it stops authenticating; nil for never
2448}
2449```
2450
2451`AddSSHKeyFrom`'s insert:
2452
2453```go
2454 var exp any
2455 if o.ExpiresAt != nil {
2456 exp = fmtTime(*o.ExpiresAt)
2457 }
2458 if _, err := tx.Exec(
2459 "INSERT INTO ssh_keys (user_id, fingerprint, algo, blob, scope, label, created_by_token, expires_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
2460 userID, fingerprint, algo, blob, scope, label, nullID(o.CreatedByToken), exp); err != nil {
2461```
2462
2463`SSHKeyByFingerprint` and `SSHKeyByID` select `expires_at` last and scan it through a `sql.NullString`:
2464
2465```go
2466func (s *Store) SSHKeyByFingerprint(fingerprint string) (SSHKey, error) {
2467 var k SSHKey
2468 var exp sql.NullString
2469 err := s.DB.QueryRow(
2470 "SELECT id, user_id, fingerprint, algo, blob, scope, label, expires_at FROM ssh_keys WHERE fingerprint = ?",
2471 fingerprint).Scan(&k.ID, &k.UserID, &k.Fingerprint, &k.Algo, &k.Blob, &k.Scope, &k.Label, &exp)
2472 if errors.Is(err, sql.ErrNoRows) {
2473 return k, ErrNotFound
2474 }
2475 k.ExpiresAt = parseTime(exp)
2476 return k, err
2477}
2478```
2479
2480`SSHKeyByID` is the same with `WHERE id = ?` and `id`.
2481
2482`ListSSHKeys`: add `k.expires_at` after `COALESCE(t.name, '')`, scan into `var exp sql.NullString` declared per row, then `k.ExpiresAt = parseTime(exp)`.
2483
2484`ListDeployKeys`:
2485
2486```go
2487func (s *Store) ListDeployKeys(repoID int64) ([]SSHKey, error) {
2488 rows, err := s.DB.Query(
2489 `SELECT id, user_id, fingerprint, algo, blob, scope, label, COALESCE(last_used_at, ''), expires_at
2490 FROM ssh_keys WHERE scope LIKE 'deploy:' || ? || ':%' ORDER BY id`,
2491 repoID)
2492 if err != nil {
2493 return nil, err
2494 }
2495 defer rows.Close()
2496 var keys []SSHKey
2497 for rows.Next() {
2498 var k SSHKey
2499 var exp sql.NullString
2500 if err := rows.Scan(&k.ID, &k.UserID, &k.Fingerprint, &k.Algo, &k.Blob, &k.Scope, &k.Label, &k.LastUsedAt, &exp); err != nil {
2501 return nil, err
2502 }
2503 k.ExpiresAt = parseTime(exp)
2504 keys = append(keys, k)
2505 }
2506 return keys, rows.Err()
2507}
2508```
2509
2510`LiveSSHKeys` in `revoke.go`:
2511
2512```go
2513// LiveSSHKeys reports which of ids still name a registered, unexpired
2514// key on an account that is not disabled.
2515func (s *Store) LiveSSHKeys(ids []int64) (map[int64]bool, error) {
2516 live := map[int64]bool{}
2517 if len(ids) == 0 {
2518 return live, nil
2519 }
2520 args := []any{fmtTime(time.Now())}
2521 for _, id := range ids {
2522 args = append(args, id)
2523 }
2524 rows, err := s.DB.Query(`SELECT k.id FROM ssh_keys k JOIN users u ON u.id = k.user_id
2525 WHERE u.disabled = 0 AND (k.expires_at IS NULL OR k.expires_at > ?)
2526 AND k.id IN (?`+strings.Repeat(", ?", len(ids)-1)+`)`, args...)
2527 if err != nil {
2528 return nil, err
2529 }
2530 defer rows.Close()
2531 for rows.Next() {
2532 var id int64
2533 if err := rows.Scan(&id); err != nil {
2534 return nil, err
2535 }
2536 live[id] = true
2537 }
2538 return live, rows.Err()
2539}
2540```
2541
2542Add `"time"` to `revoke.go`'s imports.
2543
2544- [ ] **Step 5: Run the tests**
2545
2546Run: `go test ./internal/store -count=1`
2547Expected: PASS.
2548
2549- [ ] **Step 6: Commit**
2550
2551```bash
2552git add internal/store
2553git commit -S -m "store: ssh_keys.expires_at; expired keys are not live
2554
2555Ref #277"
2556```
2557
2558### Task 3.2: expired keys refused at authentication, per exec, and in system mode
2559
2560**Files:**
2561- Modify: `internal/sshd/sshd.go` — `authenticate` (after line 148), `runExec`, `Exec` (the `Ctx` literal)
2562- Modify: `cmd/gitbayd/system.go:45-48`, `:80-84`
2563- Test: `internal/sshd/revoke_test.go` (append)
2564
2565**Interfaces:**
2566- Consumes: `SSHKey.Expired`, `SSHKey.ExpiresAt` (Task 3.1); `Ctx.Expires` (MR 2); `newTestServer`, `execStatus`, `waitClosed` (MR 1).
2567
2568- [ ] **Step 1: Write the failing tests**
2569
2570Append to `internal/sshd/revoke_test.go`:
2571
2572```go
2573// A key that expires while connected: the next exec is refused, and
2574// the sweep closes the connection.
2575func TestExpiredKeyRefusedAndCut(t *testing.T) {
2576 ts := newTestServer(t)
2577 past := time.Now().Add(-time.Second).UTC().Format("2006-01-02T15:04:05.000Z")
2578 if _, err := ts.st.DB.Exec("UPDATE ssh_keys SET expires_at = ? WHERE id = ?", past, ts.keyID); err != nil {
2579 t.Fatal(err)
2580 }
2581 if code, errOut := execStatus(ts.client, "whoami"); code != 4 || !strings.Contains(errOut, "expired") {
2582 t.Fatalf("whoami with an expired key: %d %q", code, errOut)
2583 }
2584 ts.srv.sweepOnce()
2585 waitClosed(t, ts.client)
2586}
2587
2588// An expiring key may not mint.
2589func TestExpiringKeyCannotMint(t *testing.T) {
2590 ts := newTestServer(t)
2591 future := time.Now().Add(time.Hour).UTC().Format("2006-01-02T15:04:05.000Z")
2592 if _, err := ts.st.DB.Exec("UPDATE ssh_keys SET expires_at = ? WHERE id = ?", future, ts.keyID); err != nil {
2593 t.Fatal(err)
2594 }
2595 if code, errOut := execStatus(ts.client, "token create --name x"); code != 4 || !strings.Contains(errOut, "expires") {
2596 t.Fatalf("token create with an expiring key: %d %q", code, errOut)
2597 }
2598}
2599```
2600
2601And a handshake test, which needs its own key: add to `revoke_test.go`
2602
2603```go
2604func TestExpiredKeyRefusedAtAuth(t *testing.T) {
2605 ts := newTestServer(t)
2606 _, priv, err := ed25519.GenerateKey(rand.Reader)
2607 if err != nil {
2608 t.Fatal(err)
2609 }
2610 signer, err := ssh.NewSignerFromKey(priv)
2611 if err != nil {
2612 t.Fatal(err)
2613 }
2614 pub := signer.PublicKey()
2615 past := time.Now().Add(-time.Minute)
2616 if err := ts.st.AddSSHKeyFrom(ts.uid, ssh.FingerprintSHA256(pub), pub.Type(), pub.Marshal(), "full", "", store.KeyOrigin{ExpiresAt: &past}); err != nil {
2617 t.Fatal(err)
2618 }
2619 _, err = ssh.Dial("tcp", ts.client.RemoteAddr().String(), &ssh.ClientConfig{
2620 User: "git",
2621 Auth: []ssh.AuthMethod{ssh.PublicKeys(signer)},
2622 HostKeyCallback: ssh.InsecureIgnoreHostKey(),
2623 Timeout: 5 * time.Second,
2624 })
2625 if err == nil {
2626 t.Fatal("an expired key authenticated")
2627 }
2628}
2629```
2630
2631with `"crypto/ed25519"`, `"crypto/rand"` and `"gitbay.org/gitbay/internal/store"` in the imports.
2632
2633- [ ] **Step 2: Run them and see them fail**
2634
2635Run: `go test ./internal/sshd -run 'TestExpiredKey|TestExpiringKeyCannotMint' -count=1`
2636Expected: FAIL: the expired key authenticates and whoami exits 0.
2637
2638- [ ] **Step 3: sshd**
2639
2640In `authenticate`, after the `if err != nil { ... }` block that handles unknown keys and before `s.authLimiter.success(ip)`:
2641
2642```go
2643 if key.Expired(time.Now()) {
2644 s.st.Audit(key.UserID, "auth.expired", map[string]any{"ip": ip, "fingerprint": fp})
2645 return nil, fmt.Errorf("key %s has expired", fp)
2646 }
2647```
2648
2649In `runExec`, after the lookup's error handling and before `UserByID`:
2650
2651```go
2652 if key.Expired(time.Now()) {
2653 fmt.Fprintln(ch.Stderr(), "this key has expired; remove it and add a new one")
2654 return protocol.ExitDenied
2655 }
2656```
2657
2658In `Exec`, the `Ctx` literal gains `Expires: key.ExpiresAt,`.
2659
2660- [ ] **Step 4: System mode**
2661
2662`cmd/gitbayd/system.go`, `authorized-keys`:
2663
2664```go
2665 key, err := st.SSHKeyByFingerprint(ssh.FingerprintSHA256(pub))
2666 if err != nil || key.Expired(time.Now()) {
2667 return nil // unknown or expired key: no output, auth fails
2668 }
2669```
2670
2671`shell`, after the `SSHKeyByID` error check:
2672
2673```go
2674 if key.Expired(time.Now()) {
2675 fmt.Fprintln(os.Stderr, "this key has expired; remove it and add a new one")
2676 os.Exit(protocol.ExitDenied)
2677 }
2678```
2679
2680Add `"time"` to the imports.
2681
2682- [ ] **Step 5: Run the tests**
2683
2684Run: `go build ./... && go test ./internal/sshd -count=1`
2685Expected: PASS.
2686
2687- [ ] **Step 6: Commit**
2688
2689```bash
2690git add internal/sshd cmd/gitbayd/system.go
2691git commit -S -m "sshd: refuse expired keys at auth and per exec; expiring keys cannot mint
2692
2693Ref #277"
2694```
2695
2696### Task 3.3: `--ttl` on `keys add` and `repo deploy-key add`; lists show last use and expiry
2697
2698**Files:**
2699- Modify: `internal/control/token.go` (add `ttlFlag` after `parseTTL`)
2700- Modify: `internal/control/identity.go` — `keys add` registration (33-44), `runKeysList`, `runKeysAdd`
2701- Modify: `internal/control/deploykey.go` — registration (16-23), `runDeployKeyAdd` (36-80), `runDeployKeyList` (82-115)
2702- Modify: `e2e/ssh_test.go:267`, `:276`
2703- Test: `internal/control/keyexpiry_test.go` (create)
2704
2705**Interfaces:**
2706- Produces:
2707 - `func (c *Ctx) ttlFlag(f flags) (*time.Time, int)` — nil when `--ttl` is absent; code -1 when the caller may go on.
2708 - `func (c *Ctx) usedText(ts string) string`, `func expiresText(t *time.Time, now time.Time) string`
2709
2710- [ ] **Step 1: Write the failing test**
2711
2712`internal/control/keyexpiry_test.go`:
2713
2714```go
2715package control
2716
2717import (
2718 "bytes"
2719 "crypto/ed25519"
2720 "crypto/rand"
2721 "strings"
2722 "testing"
2723 "time"
2724
2725 "golang.org/x/crypto/ssh"
2726
2727 "gitbay.org/gitbay/internal/protocol"
2728 "gitbay.org/gitbay/internal/store"
2729)
2730
2731// authorizedKey is a fresh public key as an authorized_keys line.
2732func authorizedKey(t *testing.T, comment string) string {
2733 t.Helper()
2734 pub, _, err := ed25519.GenerateKey(rand.Reader)
2735 if err != nil {
2736 t.Fatal(err)
2737 }
2738 sp, err := ssh.NewPublicKey(pub)
2739 if err != nil {
2740 t.Fatal(err)
2741 }
2742 return strings.TrimSpace(string(ssh.MarshalAuthorizedKey(sp))) + " " + comment + "\n"
2743}
2744
2745func TestKeysAddTTLAndList(t *testing.T) {
2746 st, repo, uid := newQueueTestRepo(t)
2747 user := store.User{ID: uid, Username: "alice"}
2748 run := func(stdin string, argv ...string) (string, string, int) {
2749 c, errOut := pruneCtx(st, t.TempDir(), user)
2750 c.Cfg.Limits.WriteRate = -1
2751 c.Stdin = strings.NewReader(stdin)
2752 code := Dispatch(c, argv)
2753 return c.Stdout.(*bytes.Buffer).String(), errOut.String(), code
2754 }
2755 if _, errOut, code := run(authorizedKey(t, "laptop"), "keys", "add", "--ttl", "1h"); code != protocol.ExitOK {
2756 t.Fatalf("keys add --ttl: %d %s", code, errOut)
2757 }
2758 if _, errOut, code := run(authorizedKey(t, "ci"), "repo", "deploy-key", "add", repo.Path(), "--ttl", "2d"); code != protocol.ExitOK {
2759 t.Fatalf("deploy-key add --ttl: %d %s", code, errOut)
2760 }
2761 if _, _, code := run(authorizedKey(t, "x"), "keys", "add", "--ttl", "soon"); code != protocol.ExitUsage {
2762 t.Fatalf("bad ttl: exit %d", code)
2763 }
2764
2765 keys, err := st.ListSSHKeys(uid)
2766 if err != nil || len(keys) != 2 {
2767 t.Fatalf("keys: %+v %v", keys, err)
2768 }
2769 for _, k := range keys {
2770 if k.ExpiresAt == nil || k.ExpiresAt.Before(time.Now()) || k.ExpiresAt.After(time.Now().Add(49*time.Hour)) {
2771 t.Errorf("%s expires %v", k.Label, k.ExpiresAt)
2772 }
2773 }
2774 out, _, _ := run("", "keys", "list")
2775 if !strings.Contains(out, "\tlaptop\tnever used\texpires ") {
2776 t.Fatalf("keys list:\n%s", out)
2777 }
2778 out, _, _ = run("", "repo", "deploy-key", "list", repo.Path())
2779 if !strings.Contains(out, "\tci\tnever used\texpires ") {
2780 t.Fatalf("deploy-key list:\n%s", out)
2781 }
2782}
2783```
2784
2785- [ ] **Step 2: Run it and see it fail**
2786
2787Run: `go test ./internal/control -run TestKeysAddTTLAndList -count=1`
2788Expected: FAIL, `keys add --ttl` exits 2 (unknown flag).
2789
2790- [ ] **Step 3: Helpers**
2791
2792In `internal/control/token.go` after `parseTTL`:
2793
2794```go
2795// ttlFlag reads --ttl as an expiry; nil when the flag is absent. The
2796// code is -1 when the caller may go on.
2797func (c *Ctx) ttlFlag(f flags) (*time.Time, int) {
2798 if !f.Has("--ttl") {
2799 return nil, -1
2800 }
2801 d, err := parseTTL(f.Value("--ttl"))
2802 if err != nil || d <= 0 {
2803 return nil, c.fail(protocol.ExitUsage, "bad ttl %q: give a duration such as 30d or 720h", f.Value("--ttl"))
2804 }
2805 t := time.Now().Add(d)
2806 return &t, -1
2807}
2808```
2809
2810In `internal/control/identity.go` after `keyLabel`:
2811
2812```go
2813// usedText is a key's last use as a list shows it.
2814func (c *Ctx) usedText(ts string) string {
2815 switch {
2816 case ts == "":
2817 return "never used"
2818 case c.Term.Cols == 0:
2819 return "used " + stamp(ts)
2820 }
2821 return "used " + relAge(ts, termNow())
2822}
2823
2824// expiresText is a credential's expiry as a list shows it. It is
2825// absolute at a terminal too: relAge reads only the past.
2826func expiresText(t *time.Time, now time.Time) string {
2827 if t == nil {
2828 return "never expires"
2829 }
2830 s := stamp(t.UTC().Format(time.RFC3339Nano))
2831 if !t.After(now) {
2832 return "expired " + s
2833 }
2834 return "expires " + s
2835}
2836```
2837
2838Add `"time"` to `identity.go`'s imports.
2839
2840- [ ] **Step 4: `keys add --ttl`**
2841
2842Registration:
2843
2844```go
2845 register(Command{
2846 Path: []string{"keys", "add"},
2847 Summary: "register an SSH public key (authorized_keys format)",
2848 Usage: "keys add [--scope full|git|runner] [--label <text>] [--ttl 30d|720h] < key.pub",
2849 Flags: []Flag{
2850 {"--scope", "full|git|runner", "what the key may do", "full"},
2851 {"--label", "<text>", "a name for the key", ""},
2852 {"--ttl", "30d|720h", "how long the key authenticates; an expiring key cannot mint credentials", "never expires"},
2853 },
2854 Examples: []string{"keys add --label laptop < key.pub", "keys add --scope git --ttl 90d < ci.pub"},
2855 ReadsStdin: true,
2856 MintsCredential: true,
2857 Run: runKeysAdd,
2858 })
2859```
2860
2861In `runKeysAdd`: `parseFlags(args, flagSpec{Values: []string{"--scope", "--label", "--ttl"}, MaxPos: 0, Usage: c.Cmd.Usage})`; after the scope check:
2862
2863```go
2864 expires, code := c.ttlFlag(f)
2865 if code >= 0 {
2866 return code
2867 }
2868```
2869
2870the store call:
2871
2872```go
2873 if err := c.Store.AddSSHKeyFrom(c.User.ID, fp, pub.Type(), pub.Marshal(), scope, label, store.KeyOrigin{CreatedByToken: c.TokenID, ExpiresAt: expires}); err != nil {
2874```
2875
2876and the output:
2877
2878```go
2879 type out struct {
2880 Fingerprint string `json:"fingerprint"`
2881 Scope string `json:"scope"`
2882 Label string `json:"label"`
2883 ExpiresAt *time.Time `json:"expires_at,omitempty"`
2884 }
2885 d := out{fp, scope, label, expires}
2886 return c.emit(d, func(w io.Writer) {
2887 line := fmt.Sprintf("added %s (%s)", d.Fingerprint, d.Scope)
2888 if d.Label != "" {
2889 line += " " + d.Label
2890 }
2891 if d.ExpiresAt != nil {
2892 line += ", " + expiresText(d.ExpiresAt, time.Now())
2893 }
2894 fmt.Fprintln(w, line)
2895 })
2896```
2897
2898- [ ] **Step 5: `keys list` columns**
2899
2900```go
2901 type out struct {
2902 Fingerprint string `json:"fingerprint"`
2903 Algo string `json:"algo"`
2904 Scope string `json:"scope"`
2905 Label string `json:"label"`
2906 CreatedBy string `json:"created_by,omitempty"`
2907 LastUsedAt string `json:"last_used_at,omitempty"`
2908 ExpiresAt *time.Time `json:"expires_at,omitempty"`
2909 }
2910 var ds []out
2911 for _, k := range keys {
2912 ds = append(ds, out{k.Fingerprint, k.Algo, k.Scope, k.Label, k.CreatedBy, k.LastUsedAt, k.ExpiresAt})
2913 }
2914 now := time.Now()
2915 return c.emit(ds, func(w io.Writer) {
2916 tb := c.table(w, "FINGERPRINT", "ALGO", "SCOPE", "LABEL", "USED", "EXPIRES")
2917 for _, d := range ds {
2918 tb.row(cFlex(d.Fingerprint), cText(d.Algo), cState(d.Scope), cText(d.Label),
2919 cText(c.usedText(d.LastUsedAt)), cText(expiresText(d.ExpiresAt, now)))
2920 }
2921 tb.flush()
2922 })
2923```
2924
2925- [ ] **Step 6: `repo deploy-key add --ttl`, list columns**
2926
2927Registration:
2928
2929```go
2930 register(Command{Path: []string{"repo", "deploy-key", "add"},
2931 Summary: "bind a read-only (or --rw) key to one repository",
2932 Usage: "repo deploy-key add <owner/name> [--rw] [--ttl 30d|720h] < key.pub",
2933 Flags: []Flag{
2934 {"--rw", "", "the key may push, not just fetch", ""},
2935 {"--ttl", "30d|720h", "how long the key authenticates", "never expires"},
2936 },
2937 Examples: []string{"repo deploy-key add krz/gitbay < key.pub", "repo deploy-key add krz/gitbay --ttl 30d < key.pub"},
2938 ReadsStdin: true,
2939 MintsCredential: true,
2940 Run: runDeployKeyAdd})
2941```
2942
2943`runDeployKeyAdd`, replacing its argument loop (lines 37-52):
2944
2945```go
2946func runDeployKeyAdd(c *Ctx, args []string) int {
2947 f, err := parseFlags(args, flagSpec{Values: []string{"--ttl"}, Bools: []string{"--rw"}, MaxPos: 1, Usage: c.Cmd.Usage})
2948 if err != nil {
2949 return c.fail(protocol.ExitUsage, "%v", err)
2950 }
2951 path := f.pos(0)
2952 if path == "" {
2953 return c.usage()
2954 }
2955 mode := "ro"
2956 if f.Has("--rw") {
2957 mode = "rw"
2958 }
2959 expires, code := c.ttlFlag(f)
2960 if code >= 0 {
2961 return code
2962 }
2963 repo, code := resolveRepo(c, path, policy.CanAdmin)
2964 if code >= 0 {
2965 return code
2966 }
2967```
2968
2969The rest is as before except the store call and output:
2970
2971```go
2972 if err := c.Store.AddSSHKeyFrom(c.User.ID, fp, pub.Type(), pub.Marshal(), scope, label, store.KeyOrigin{CreatedByToken: c.TokenID, ExpiresAt: expires}); err != nil {
2973 if errors.Is(err, store.ErrDuplicateKey) {
2974 return c.failErr(err)
2975 }
2976 return c.fail(protocol.ExitFailure, "%v", err)
2977 }
2978 d := map[string]any{"fingerprint": fp, "mode": mode}
2979 if expires != nil {
2980 d["expires_at"] = expires
2981 }
2982 return c.emit(d, func(w io.Writer) {
2983 line := fmt.Sprintf("deploy key %s (%s) bound to %s", fp, mode, repo.Path())
2984 if expires != nil {
2985 line += ", " + expiresText(expires, time.Now())
2986 }
2987 fmt.Fprintln(w, line)
2988 })
2989```
2990
2991`runDeployKeyList`:
2992
2993```go
2994 type out struct {
2995 Fingerprint string `json:"fingerprint"`
2996 Algo string `json:"algo"`
2997 Mode string `json:"mode"`
2998 Label string `json:"label"`
2999 LastUsedAt string `json:"last_used_at,omitempty"`
3000 ExpiresAt *time.Time `json:"expires_at,omitempty"`
3001 }
3002 var ds []out
3003 for _, k := range keys {
3004 mode := "ro"
3005 if policy.DeployScopeAllows(k.Scope, repo.ID, true) {
3006 mode = "rw"
3007 }
3008 ds = append(ds, out{k.Fingerprint, k.Algo, mode, k.Label, k.LastUsedAt, k.ExpiresAt})
3009 }
3010 now := time.Now()
3011 return c.emit(ds, func(w io.Writer) {
3012 tb := c.table(w, "FINGERPRINT", "ALGO", "MODE", "LABEL", "USED", "EXPIRES")
3013 for _, d := range ds {
3014 tb.row(cFlex(d.Fingerprint), cText(d.Algo), cState(d.Mode), cText(d.Label),
3015 cText(c.usedText(d.LastUsedAt)), cText(expiresText(d.ExpiresAt, now)))
3016 }
3017 tb.flush()
3018 })
3019```
3020
3021Add `"time"` to `deploykey.go`'s imports.
3022
3023- [ ] **Step 7: The e2e rows that end at the label**
3024
3025`e2e/ssh_test.go:267`: `if !strings.Contains(out, "\tgit\talice2\t") {`
3026`e2e/ssh_test.go:276`: `if !strings.Contains(out, "\tgit\tbuild box\t") {`
3027
3028- [ ] **Step 8: Run the tests**
3029
3030Run: `go build ./... && go vet ./... && go test ./internal/control ./internal/store ./internal/sshd -count=1 && go test ./e2e -run TestSSHControlPlane -count=1`
3031
3032(Check the name of the test holding `e2e/ssh_test.go:255-276` with `grep -n "^func Test" e2e/ssh_test.go` and run that one.)
3033Expected: PASS.
3034
3035- [ ] **Step 9: Commit**
3036
3037```bash
3038git add internal/control e2e/ssh_test.go
3039git commit -S -m "keys: --ttl on keys add and repo deploy-key add; lists show last use and expiry
3040
3041Ref #277"
3042```
3043
3044### Task 3.4: docs
3045
3046**Files:**
3047- Modify: `.gitbay/wiki/Users.org` (keys block ~61-66 and the scopes paragraph), `.gitbay/wiki/Architecture/05-Identity-and-Access.org:17-18`, `09-Controls.org:27`, `10-Known-Gaps.org`, `.gitbay/wiki/Parity.org` (Accounts), `.gitbay/wiki/API.org` (the paragraph added in MR 2), `CHANGELOG.org`
3048
3049- [ ] **Step 1: Edit the pages**
3050
3051`Users.org`, add to the keys code block:
3052
3053```
3054gitbay auth keys add --scope git --ttl 90d < ~/.ssh/ci_key.pub
3055```
3056
3057and after the labels paragraph:
3058
3059```
3060=--ttl 90d= (or any Go duration, =720h=) makes a key stop
3061authenticating after that long; =repo deploy-key add= takes the same
3062flag. An expiring key cannot create credentials: tokens, keys, login
3063links. =keys list= shows when each key was last used and when it
3064expires, so a key nobody uses is easy to spot.
3065```
3066
3067`05-Identity-and-Access.org`: SSH user key and deploy key Expiry cells become `optional =--ttl=, refused at auth`.
3068
3069`09-Controls.org`:
3070
3071```
3072| Credential expiry | in place | optional =--ttl= on API tokens, SSH and deploy keys; checked at auth and per exec |
3073```
3074
3075`10-Known-Gaps.org`: delete the `#277` row.
3076
3077`Parity.org`, after `SSH key label`:
3078
3079```
3080| SSH key expiry and last use | yes | no | no |
3081```
3082
3083`API.org`: in the paragraph MR 2 added, "a token with a =--ttl= cannot run" becomes "a token or SSH key with a =--ttl= cannot run".
3084
3085`CHANGELOG.org`:
3086
3087```
3088- =keys add= and =repo deploy-key add= take =--ttl=; an expired key is
3089 refused at authentication, and an open connection on it closes within
3090 15 seconds. An expiring key cannot create credentials, like an
3091 expiring token. =keys list= and =repo deploy-key list= gain =USED= and
3092 =EXPIRES= columns, after the label (#277).
3093```
3094
3095- [ ] **Step 2: Commit, open the MR**
3096
3097```bash
3098git add .gitbay/wiki CHANGELOG.org
3099git commit -S -m "wiki: key expiry
3100
3101Closes #277"
3102git push -u origin key-expiry
3103gitbay mr create --source key-expiry --target main --title "keys: optional expiry for SSH and deploy keys"
3104```
3105
3106---
3107
3108# MR 4: idle timeout for browser sessions (branch `session-idle`, #276)
3109
3110A session lapses after 12 hours without a request and after seven
3111days regardless. `expires_at` holds the sliding expiry, so the auth
3112query and the retention sweep (`expires_at <= ?`,
3113`internal/store/retention.go:51`) need no change;
3114`absolute_expires_at` holds the cap. Renewal writes at most once a
3115minute per session. The cookie's `MaxAge` stays seven days.
3116
3117### Task 4.1: migration 0062 and the store
3118
3119**Files:**
3120- Create: `internal/store/migrations/0062_web_session_idle.up.sql`, `.down.sql`
3121- Modify: `internal/store/sessions.go:67-121`
3122- Test: `internal/store/sessions_test.go` (append)
3123
3124**Interfaces:**
3125- Produces:
3126 - `const WebSessionIdle = 12 * time.Hour`
3127 - `CreateWebSession(hash string, userID int64, ttl time.Duration) error` — unchanged signature; `ttl` is now the absolute cap.
3128 - `WebSessionUser(hash string) (User, error)` — unchanged signature; renews.
3129 - `WebSession.LastUsedAt string `json:"last_used_at"``
3130
3131- [ ] **Step 1: Write the failing tests**
3132
3133Append to `internal/store/sessions_test.go`:
3134
3135```go
3136func sessionFixture(t *testing.T) (*Store, int64) {
3137 t.Helper()
3138 s := open(t)
3139 if err := s.MigrateUp(); err != nil {
3140 t.Fatal(err)
3141 }
3142 uid, err := s.CreateUser("cmc", false)
3143 if err != nil {
3144 t.Fatal(err)
3145 }
3146 return s, uid
3147}
3148
3149func sessionTimes(t *testing.T, s *Store, hash string) (expires, absolute time.Time) {
3150 t.Helper()
3151 var e, a string
3152 if err := s.DB.QueryRow("SELECT expires_at, absolute_expires_at FROM web_sessions WHERE token_hash = ?", hash).Scan(&e, &a); err != nil {
3153 t.Fatal(err)
3154 }
3155 return *parseTime(sql.NullString{String: e, Valid: true}), *parseTime(sql.NullString{String: a, Valid: true})
3156}
3157
3158func TestWebSessionIdleExpiry(t *testing.T) {
3159 s, uid := sessionFixture(t)
3160 if err := s.CreateWebSession("h", uid, 7*24*time.Hour); err != nil {
3161 t.Fatal(err)
3162 }
3163 exp, abs := sessionTimes(t, s, "h")
3164 if d := time.Until(exp); d < WebSessionIdle-time.Minute || d > WebSessionIdle {
3165 t.Fatalf("a new session expires in %s, want %s", d, WebSessionIdle)
3166 }
3167 if d := time.Until(abs); d < 7*24*time.Hour-time.Minute {
3168 t.Fatalf("absolute cap in %s", d)
3169 }
3170 // Idle past the window: gone.
3171 old := fmtTime(time.Now().Add(-time.Second))
3172 s.DB.Exec("UPDATE web_sessions SET expires_at = ? WHERE token_hash = 'h'", old)
3173 if _, err := s.WebSessionUser("h"); err != ErrNotFound {
3174 t.Fatalf("idle session: %v", err)
3175 }
3176}
3177
3178func TestWebSessionRenewsUpToTheCap(t *testing.T) {
3179 s, uid := sessionFixture(t)
3180 if err := s.CreateWebSession("h", uid, 7*24*time.Hour); err != nil {
3181 t.Fatal(err)
3182 }
3183 // Last used two minutes ago, one minute left: a request renews it.
3184 s.DB.Exec("UPDATE web_sessions SET last_used_at = ?, expires_at = ? WHERE token_hash = 'h'",
3185 fmtTime(time.Now().Add(-2*time.Minute)), fmtTime(time.Now().Add(time.Minute)))
3186 if _, err := s.WebSessionUser("h"); err != nil {
3187 t.Fatal(err)
3188 }
3189 if exp, _ := sessionTimes(t, s, "h"); time.Until(exp) < WebSessionIdle-time.Minute {
3190 t.Fatalf("not renewed: expires in %s", time.Until(exp))
3191 }
3192 // Near the cap, renewal stops at it.
3193 capAt := time.Now().Add(time.Hour)
3194 s.DB.Exec("UPDATE web_sessions SET last_used_at = ?, absolute_expires_at = ? WHERE token_hash = 'h'",
3195 fmtTime(time.Now().Add(-2*time.Minute)), fmtTime(capAt))
3196 if _, err := s.WebSessionUser("h"); err != nil {
3197 t.Fatal(err)
3198 }
3199 if exp, _ := sessionTimes(t, s, "h"); exp.After(capAt) {
3200 t.Fatalf("renewed past the cap: %s > %s", exp, capAt)
3201 }
3202 list, err := s.ListWebSessions(uid)
3203 if err != nil || len(list) != 1 || list[0].LastUsedAt == "" {
3204 t.Fatalf("list: %+v %v", list, err)
3205 }
3206}
3207```
3208
3209Add `"database/sql"` to the file's imports.
3210
3211- [ ] **Step 2: Run them and see them fail**
3212
3213Run: `go test ./internal/store -run 'TestWebSession' -count=1`
3214Expected: FAIL to compile, `undefined: WebSessionIdle`.
3215
3216- [ ] **Step 3: Migration**
3217
3218`0062_web_session_idle.up.sql`:
3219
3220```sql
3221-- expires_at slides forward on use, never past absolute_expires_at.
3222-- Sessions open now keep their cap and get a full idle window from here.
3223ALTER TABLE web_sessions ADD COLUMN absolute_expires_at TEXT;
3224ALTER TABLE web_sessions ADD COLUMN last_used_at TEXT;
3225UPDATE web_sessions SET
3226 absolute_expires_at = expires_at,
3227 last_used_at = strftime('%Y-%m-%dT%H:%M:%fZ','now'),
3228 expires_at = min(expires_at, strftime('%Y-%m-%dT%H:%M:%fZ','now','+12 hours'));
3229```
3230
3231`0062_web_session_idle.down.sql`:
3232
3233```sql
3234UPDATE web_sessions SET expires_at = absolute_expires_at;
3235ALTER TABLE web_sessions DROP COLUMN last_used_at;
3236ALTER TABLE web_sessions DROP COLUMN absolute_expires_at;
3237```
3238
3239- [ ] **Step 4: Store**
3240
3241Replace `CreateWebSession` and `WebSessionUser`:
3242
3243```go
3244// WebSessionIdle is how long a browser session lasts without a request.
3245// Each use moves its expiry this far ahead, never past the cap it was
3246// created with. Migration 0062 repeats the value for sessions it
3247// converts.
3248const WebSessionIdle = 12 * time.Hour
3249
3250// CreateWebSession stores a session that lapses after WebSessionIdle
3251// without use, and after ttl regardless.
3252func (s *Store) CreateWebSession(hash string, userID int64, ttl time.Duration) error {
3253 now := time.Now()
3254 _, err := s.DB.Exec(
3255 "INSERT INTO web_sessions (token_hash, user_id, expires_at, absolute_expires_at, last_used_at) VALUES (?, ?, ?, ?, ?)",
3256 hash, userID, fmtTime(now.Add(min(ttl, WebSessionIdle))), fmtTime(now.Add(ttl)), fmtTime(now))
3257 return err
3258}
3259
3260// WebSessionUser resolves a session cookie hash to its user and renews
3261// the session's idle expiry. A session is written at most once a
3262// minute, so a burst of requests costs one UPDATE.
3263func (s *Store) WebSessionUser(hash string) (User, error) {
3264 now := time.Now()
3265 var userID int64
3266 err := s.DB.QueryRow(
3267 "SELECT user_id FROM web_sessions WHERE token_hash = ? AND expires_at > ?",
3268 hash, fmtTime(now)).Scan(&userID)
3269 if errors.Is(err, sql.ErrNoRows) {
3270 return User{}, ErrNotFound
3271 }
3272 if err != nil {
3273 return User{}, err
3274 }
3275 s.DB.Exec(`UPDATE web_sessions SET last_used_at = ?, expires_at = min(absolute_expires_at, ?)
3276 WHERE token_hash = ? AND last_used_at < ?`,
3277 fmtTime(now), fmtTime(now.Add(WebSessionIdle)), hash, fmtTime(now.Add(-time.Minute)))
3278 return s.UserByID(userID)
3279}
3280```
3281
3282`WebSession` and `ListWebSessions`:
3283
3284```go
3285type WebSession struct {
3286 ID string `json:"id"`
3287 CreatedAt string `json:"created_at"`
3288 ExpiresAt string `json:"expires_at"`
3289 LastUsedAt string `json:"last_used_at"`
3290}
3291
3292// ListWebSessions lists the user's unexpired browser sessions, newest first.
3293func (s *Store) ListWebSessions(userID int64) ([]WebSession, error) {
3294 rows, err := s.DB.Query(`SELECT substr(token_hash, 1, 12), created_at, expires_at, COALESCE(last_used_at, created_at)
3295 FROM web_sessions WHERE user_id = ? AND expires_at > ? ORDER BY created_at DESC`,
3296 userID, fmtTime(time.Now()))
3297 if err != nil {
3298 return nil, err
3299 }
3300 defer rows.Close()
3301 var out []WebSession
3302 for rows.Next() {
3303 var ws WebSession
3304 if err := rows.Scan(&ws.ID, &ws.CreatedAt, &ws.ExpiresAt, &ws.LastUsedAt); err != nil {
3305 return nil, err
3306 }
3307 out = append(out, ws)
3308 }
3309 return out, rows.Err()
3310}
3311```
3312
3313- [ ] **Step 5: Run the tests**
3314
3315Run: `go test ./internal/store -count=1`
3316Expected: PASS, including `TestSweepRemovesExpiredSessionsAndTokens` (a `-time.Hour` ttl still makes a dead session).
3317
3318- [ ] **Step 6: Commit**
3319
3320```bash
3321git add internal/store
3322git commit -S -m "store: web sessions lapse after 12 hours idle, under the absolute cap
3323
3324Ref #276"
3325```
3326
3327### Task 4.2: `web sessions list` shows last use; docs
3328
3329**Files:**
3330- Modify: `internal/control/web.go:33-54`
3331- Modify: `internal/httpd/accounts.go:147` (comment only)
3332- Modify: `.gitbay/wiki/Users.org:672-678`, `.gitbay/wiki/Architecture/05-Identity-and-Access.org:21`, `:36-38`, `09-Controls.org:26`, `10-Known-Gaps.org`, `CHANGELOG.org`
3333
3334- [ ] **Step 1: The list**
3335
3336```go
3337 return c.emit(sessions, func(w io.Writer) {
3338 tb := c.table(w, "ID", "SINCE", "UNTIL", "USED")
3339 for _, s := range sessions {
3340 since, until, used := s.CreatedAt, s.ExpiresAt, s.LastUsedAt
3341 if c.Term.Cols == 0 {
3342 since, until, used = stamp(since), stamp(until), stamp(used)
3343 } else {
3344 since, until, used = relAge(since, termNow()), relAge(until, termNow()), relAge(used, termNow())
3345 }
3346 tb.row(cRef(s.ID), cText("since "+since), cText("until "+until), cText("used "+used))
3347 }
3348 tb.flush()
3349 })
3350```
3351
3352- [ ] **Step 2: The call site says what the ttl is**
3353
3354`internal/httpd/accounts.go`, above line 147:
3355
3356```go
3357 // Seven days is the cap; the store ends it sooner after
3358 // store.WebSessionIdle without a request.
3359```
3360
3361- [ ] **Step 3: Run the tests**
3362
3363Run: `go build ./... && go test ./internal/control ./internal/httpd -count=1 && go test ./e2e -run TestWebSessionsListRevoke -count=1`
3364Expected: PASS.
3365
3366- [ ] **Step 4: Docs**
3367
3368`Users.org`, the Browser sessions paragraph:
3369
3370```
3371=gitbay web login= mints a one-time URL; the session it opens ends
3372after twelve hours without a request, and after seven days in any case.
3373=gitbay web sessions list= shows each of yours by a short id with its
3374creation, expiry and last use, and =gitbay web sessions revoke <id>=
3375or =--all= ends them from the terminal, which is where a lost laptop is
3376handled.
3377```
3378
3379`05-Identity-and-Access.org`: the Web session Expiry cell becomes `12 h idle, 7 days absolute`; in the paragraph under the table, "=MaxAge= 7 days" becomes "=MaxAge= 7 days (the session itself also ends after 12 hours idle)".
3380
3381`09-Controls.org`:
3382
3383```
3384| Session lifetime | in place | 12 hours idle, 7 days absolute (=internal/store/sessions.go=) |
3385```
3386
3387`10-Known-Gaps.org`: delete the `#276` row.
3388
3389`CHANGELOG.org`:
3390
3391```
3392- Browser sessions end after twelve hours without a request, and after
3393 seven days as before. Sessions open at upgrade get a fresh twelve
3394 hours. =web sessions list= shows when each was last used (#276).
3395```
3396
3397- [ ] **Step 5: Commit, open the MR**
3398
3399```bash
3400git add internal/control/web.go internal/httpd/accounts.go .gitbay/wiki CHANGELOG.org
3401git commit -S -m "web: sessions list shows last use; docs for the idle timeout
3402
3403Closes #276"
3404git push -u origin session-idle
3405gitbay mr create --source session-idle --target main --title "web: idle timeout for browser sessions"
3406```
3407
3408---
3409
3410# MR 5: `web login` over SSH spends the login-link budget (branch `weblogin-limit`, #278)
3411
3412### Task 5.1: the limit in `runWebLogin`
3413
3414**Files:**
3415- Modify: `internal/control/web.go:80-99`
3416- Modify: `internal/control/loginlink.go:12-19` (comment)
3417- Test: `internal/control/weblogin_test.go` (create)
3418- Modify: `.gitbay/wiki/Architecture/10-Known-Gaps.org`, `CHANGELOG.org`
3419
3420**Interfaces:**
3421- Consumes: `maxLoginLinksPerHour` (`loginlink.go:19`), `Store.CountLoginTokensSince`.
3422
3423- [ ] **Step 1: Write the failing test**
3424
3425```go
3426package control
3427
3428import (
3429 "strings"
3430 "testing"
3431
3432 "gitbay.org/gitbay/internal/protocol"
3433 "gitbay.org/gitbay/internal/store"
3434)
3435
3436// web login over SSH counts against the same hourly bound as the
3437// mailed links, since both insert into login_tokens (#278).
3438func TestWebLoginSharesTheLoginLinkLimit(t *testing.T) {
3439 st, _, uid := newQueueTestRepo(t)
3440 for i := 0; i <= maxLoginLinksPerHour; i++ {
3441 c, errOut := pruneCtx(st, t.TempDir(), store.User{ID: uid, Username: "alice"})
3442 c.Cfg.Web.Mode = "accounts"
3443 c.Cfg.Server.SiteURL = "https://gitbay.test"
3444 c.Cfg.Limits.WriteRate = -1
3445 code := Dispatch(c, []string{"web", "login"})
3446 switch {
3447 case i < maxLoginLinksPerHour && code != protocol.ExitOK:
3448 t.Fatalf("link %d: exit %d %s", i+1, code, errOut)
3449 case i == maxLoginLinksPerHour && (code != protocol.ExitDenied || !strings.Contains(errOut.String(), "login links")):
3450 t.Fatalf("link %d: exit %d %q, want refused", i+1, code, errOut)
3451 }
3452 }
3453}
3454```
3455
3456- [ ] **Step 2: Run it and see it fail**
3457
3458Run: `go test ./internal/control -run TestWebLoginSharesTheLoginLinkLimit -count=1`
3459Expected: FAIL, the sixth link exits 0.
3460
3461- [ ] **Step 3: Implement**
3462
3463In `runWebLogin`, after the web-mode check:
3464
3465```go
3466 n, err := c.Store.CountLoginTokensSince(c.User.ID, time.Now().Add(-time.Hour))
3467 if err != nil {
3468 return c.fail(protocol.ExitFailure, "%v", err)
3469 }
3470 if n >= maxLoginLinksPerHour {
3471 return c.fail(protocol.ExitDenied,
3472 "%d login links in the last hour is the most an account gets; use one of those, or wait", maxLoginLinksPerHour)
3473 }
3474```
3475
3476`loginlink.go`, the comment above `maxLoginLinksPerHour`:
3477
3478```go
3479// maxLoginLinksPerHour bounds what one account's address can be made to
3480// receive. It matches maxEmailAddsPerHour: enough for a person who mistypes
3481// and retries, nothing for a script. CountLoginTokensSince counts every row
3482// in login_tokens, so links minted with "web login" over SSH and links
3483// mailed from the login page share the budget, and both refuse past it.
3484```
3485
3486- [ ] **Step 4: Run the tests**
3487
3488Run: `go test ./internal/control -count=1`
3489Expected: PASS. Then `grep -c '\.login(t\|loginBrowser(t\|"web", "login"' e2e/*.go` and confirm no single e2e test logs one account in more than five times; `TestMRWebReviewLoop` logs alice in once and carol once, and each e2e test runs its own instance.
3490
3491- [ ] **Step 5: Docs**
3492
3493`10-Known-Gaps.org`: delete the `#278` row.
3494
3495`CHANGELOG.org`:
3496
3497```
3498- =web login= over SSH refuses a sixth link in an hour, the same bound
3499 the login page's mailed links have (#278).
3500```
3501
3502- [ ] **Step 6: Commit, open the MR**
3503
3504```bash
3505git add internal/control/web.go internal/control/loginlink.go internal/control/weblogin_test.go .gitbay/wiki CHANGELOG.org
3506git commit -S -m "web: login over SSH applies the login-link limit
3507
3508Closes #278"
3509git push -u origin weblogin-limit
3510gitbay mr create --source weblogin-limit --target main --title "web login over SSH applies the login-link rate limit"
3511```
3512
3513---
3514
3515## Decisions (2026-09-28)
3516
35171. **Expiring SSH keys and minting.** Confirmed: an expiring key is
3518 refused the minting commands like an expiring token (MR 3,
3519 Task 3.2).
35202. **Which commands mint.** Confirmed: the issue's five plus
3521 `repo runner add`, `admin user create`, `email verify` and
3522 `admin email verify`, pinned by `TestMintingCommandsMarked`.
35233. **LFS transfer tokens.** Filed as #285; not in this plan.
35244. **Removing the key you are on.** Confirmed: the session's own
3525 connection is cut after the removal commits.
35265. **Idle window.** A constant (`store.WebSessionIdle`, 12 h); make it
3527 configurable only when someone needs another value.
3528
3529Documented limits, stated on the Threat-Model page in MR 1:
3530
3531- With `ssh.mode = "system"` each exec is its own forced-command
3532 process; the per-exec check applies, a running one is not cut. bay1
3533 runs embedded.
3534- A control command already inside its store write when the key is
3535 revoked finishes the write and its output is lost; git transports are
3536 killed and commands watching `Done` stop.
3537
3538The `token list` future-expiry display is #286.
3539
3540## Self-review
3541
3542- **Coverage.** #256: per-exec re-read (Task 1.3 `runExec`), git
3543 transport session re-read (same path; `Exec` dispatches git),
3544 connection tracking and closing on keys remove / deploy-key remove /
3545 disable / delete (1.1, 1.3), cancelling running commands and pushes
3546 (1.2, 1.3), interrupted receive-pack moves no ref (1.4), e2e with a
3547 multiplexed connection (1.4). #257: `MintsCredential` checked in
3548 `Dispatch` (2.2), migration recording the creator for tokens and keys
3549 (2.1), `token revoke` listing and revoking with `--created` (2.1,
3550 2.3), default `--scope read` and release note (2.3, 2.5), API and
3551 Threat-Model pages (2.5). #277: `--ttl` on both add commands (3.3),
3552 enforced at authentication (3.2), last use in `keys list` (3.3),
3553 considered with #257 (3.2, open question 1), migration designed with
3554 0060 (both nullable `ADD COLUMN`, one insert path via `KeyOrigin`).
3555 #276: idle timeout with renewal, absolute cap kept, last use listed
3556 (4.1, 4.2). #278: limit applied, comment corrected (5.1).
3557- **Placeholders.** None; every code step carries the code. Two steps
3558 ask the executor to confirm a name with grep (`nullID`, the
3559 `ssh_test.go` test name) because they were not unique facts to pin.
3560- **Types.** `Revoked{KeyIDs []int64; UserID int64}`, `OnRevoke`,
3561 `announce`, `LiveSSHKeys([]int64) (map[int64]bool, error)` are used
3562 with those shapes in 1.1, 1.3, 2.1, 3.1. `Exec(..., key store.SSHKey,
3563 ..., done, stopping, revoked)` matches in 1.3, 3.2 and `system.go`.
3564 `APITokenUser` returns `(User, APIToken, error)` in 2.1, 2.2 and the
3565 tests. `KeyOrigin{CreatedByToken, ExpiresAt}` is introduced in 2.1
3566 and extended in 3.1. `Ctx.TokenID int64`, `Ctx.Expires *time.Time`
3567 match across 2.2, 2.3, 3.2, 3.3. `SSHKey.CreatedBy` (name) and
3568 `KeyOrigin.CreatedByToken` (id) are distinct on purpose.