docs/plans/2026-09-27-credentials-and-sessions.md
3568 lines · 115133 bytes
Credentials and sessions implementation plan
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.
Goal: Revocation of an SSH key takes effect on open connections
(#256); an expiring credential cannot mint one that outlives it, and
credentials record the token that made them (#257); SSH and deploy
keys take an optional expiry and show their last use (#277); browser
sessions end after 12 hours idle (#276); web login over SSH spends
the login-link budget (#278).
Architecture: The store announces revocations it commits
(Store.OnRevoke); the SSH listener tracks which key opened each
connection and cuts the ones a revocation names, killing a git
transport's process group. A 15-second sweep catches revocations made
by another process and keys that expire while connected. Every exec
re-reads its key. Command.MintsCredential marks the commands that
create credentials; Dispatch refuses them when Ctx.Expires is set.
api_tokens and ssh_keys gain created_by_token; ssh_keys gains
expires_at; web_sessions gains a sliding expiry under an absolute
cap.
Tech stack: Go, golang.org/x/crypto/ssh, SQLite (modernc), OpenSSH
client for e2e.
Spec: issues #256, #257, #276, #277, #278 on krz/gitbay (the decisions on #256 and #257 are recorded there, and the later ones in "Decisions" at the end of this plan).
Global constraints
- Each MR on its own branch off
main. Commits are signed (the repo refuses unsigned), messages reference issues (Ref #N, andCloses #Non the commit that finishes one). No attribution to any assistant, model or AI anywhere: commits, MR bodies, comments. - MR:
gitbay mr create --source <branch> --target main --title "..."; merge withgitbay mr merge <n> --strategy ffonce CI is green, then delete the branch locally and on the remote. Behind main → rebase, force-push, merge again. - Locally:
go build ./...,go vet ./..., unit tests of touched packages, and at most the one e2e test being written (go test ./e2e -run TestName -count=1). CI on bay1 runs the full suite. - Registries that fail CI when a new thing lacks its row: top-level route
word in
internal/policy/names.go; new page template in the width map ofTestMainWidthClass(internal/web/web_test.go); newReadOnlycommand inreadArgsine2e/readonly_test.go; new control command needs apass()entry incmd/gitbay/main.go(coverage test); a command reading stdin needsReadsStdin: true. This plan adds no route, template, command or read command; it changes flags and aSummaryof none, socmd/gitbay/summaries_gen.gostays current. - Migrations: this plan owns 0060–0064 and uses 0060, 0061, 0062. Six
plans are written in parallel with pre-assigned ranges; whoever lands
second renumbers to the next free number at execution time.
Migrations come in
.up.sql/.down.sqlpairs. Hand-written SQL, no ORM.TestMigrateUpDown(internal/store/store_test.go) exercises every down script. - Secrets travel on stdin, never argv; never logged or echoed.
- Wiki pages live in
.gitbay/wiki/(Parity, API, Admin, Threat-Model, CI, Users, Performance, and theArchitecture/folder with its Known-Gaps table and controls matrix). Update the page in the same MR that changes the behaviour it describes, and remove the matching row fromArchitecture/10-Known-Gaps.org. - Release notes go in
CHANGELOG.orgunder the topmost heading that has no tag yet. If the top heading is a released version, add* Unreleasedabove it; whoever tags renames it. - Writing style: plain, direct, no hype; code comments match the surrounding density. Comments and docs state facts, never before/after narration.
- Work in a worktree of
krz/gitbay; the main checkout may hold another session's edits.
Order and dependencies
| # | Branch | Closes | Migration | Depends on |
|---|---|---|---|---|
| 1 | revoke-closes-connections |
#256 | none | — |
| 2 | token-delegation |
#257 | 0060 | MR 1 (Store.announce, fingerprint e2e helper, Exec taking a key) |
| 3 | key-expiry |
#277 | 0061 | MR 1 (sweep, per-exec check), MR 2 (KeyOrigin, Ctx.Expires) |
| 4 | session-idle |
#276 | 0062 | — |
| 5 | weblogin-limit |
#278 | none | — |
#256 goes first. #257 and #277 both add columns to ssh_keys; both
are nullable ADD COLUMNs with no table rebuild, so they compose in
either order, and KeyOrigin (MR 2) is the one insert path both use.
Other plans:
- Plan 5 (web-ux) #264 depends on MR 2's
--scope readdefault. - Plan 3 (server-hardening) touches the same code: #262 limits
concurrent git processes in
gitutil.Transport/sshd.runGit(MR 1 changes both signatures), #275 audits refused commands incontrol.Dispatch(MR 2 adds a refusal there, which #275 should audit like the others), #282 changeshookd. Whoever lands second rebases; the conflicts are mechanical.
File map
| File | MR | Responsibility |
|---|---|---|
internal/store/revoke.go (create) |
1 | Revoked, OnRevoke, announce, LiveSSHKeys |
internal/store/store.go |
1 | subscriber fields on Store |
internal/store/users.go |
1, 2, 3 | removals announce; KeyOrigin, AddSSHKeyFrom; expires_at |
internal/sshd/sshd.go |
1, 3 | connection tracking, cut, sweep, per-exec check, Exec(key); expired keys refused |
internal/gitutil/gitutil.go, proc_unix.go, proc_other.go |
1 | Transport takes a cancel channel, kills the process group |
cmd/gitbayd/system.go |
1, 3 | Exec call; expired keys in system mode |
internal/control/control.go |
2 | MintsCredential, Ctx.TokenID, Ctx.Expires, the refusal |
internal/store/tokens.go |
2 | token id, creator, chained revoke |
internal/control/token.go |
2, 3 | default read, creator, revoke --created; ttlFlag |
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 |
internal/httpd/api.go |
2 | token into Ctx |
internal/store/sessions.go |
4 | sliding session expiry |
internal/control/loginlink.go |
5 | comment |
e2e/revoke_test.go (create) |
1 | multiplexed connection cut |
e2e/tokenorigin_test.go (create) |
2 | expiring token refused, revoke --created |
.gitbay/wiki/*, CHANGELOG.org |
all | docs in the MR that changes behaviour |
MR 1: removing a key closes its connections (branch revoke-closes-connections, #256)
Decision on the issue: revocation is immediate, running commands included. What that means here, stated in the Threat-Model page:
- Every exec and every git transport session re-reads its key: gone, moved to another account, or re-scoped takes effect on the next command.
keys remove,repo deploy-key remove,admin user disable,admin user deleteand (MR 2)token revoke --createdclose every connection opened by an affected key. A git transport on it is killed with its process group. A control command in flight loses its channel; one that watchesDone(build log --follow) stops, one already inside its store write finishes that write and its output is lost.- A push cut before its pre-receive hook answers updates no refs. The ref transaction that follows the hook is not interrupted mid-write in practice; it is milliseconds long.
- Revocations committed by another process (
gitbayd adminon the host) are found by a 15-second sweep. - System mode (
ssh.mode = "system") runs one process per exec, so the per-exec check applies; a forced-command process already running is not cut (open question 4).
Task 1.1: the store announces revocations and answers which keys are live
Files:
- Create:
internal/store/revoke.go - Modify:
internal/store/store.go:23-30(Storestruct) - Modify:
internal/store/users.go—DeleteUser(58-101),SetUserDisabled(171-194),RemoveSSHKey(291-309),RemoveDeployKey(534-554) - Test:
internal/store/revoke_test.go(create)
Interfaces:
-
Produces:
type Revoked struct { KeyIDs []int64; UserID int64 }func (s *Store) OnRevoke(f func(Revoked))func (s *Store) announce(r Revoked)(package-private; MR 2 calls it)func (s *Store) LiveSSHKeys(ids []int64) (map[int64]bool, error)
-
Step 1: Write the failing test
internal/store/revoke_test.go:
package store
import (
"slices"
"testing"
)
func revokeFixture(t *testing.T) (*Store, int64, *[]Revoked) {
t.Helper()
s := open(t)
if err := s.MigrateUp(); err != nil {
t.Fatal(err)
}
uid, err := s.CreateUser("alice", false)
if err != nil {
t.Fatal(err)
}
var got []Revoked
s.OnRevoke(func(r Revoked) { got = append(got, r) })
return s, uid, &got
}
func keyID(t *testing.T, s *Store, fp string) int64 {
t.Helper()
k, err := s.SSHKeyByFingerprint(fp)
if err != nil {
t.Fatal(err)
}
return k.ID
}
func TestRemovalsAnnounceTheirKeys(t *testing.T) {
s, uid, got := revokeFixture(t)
if err := s.AddSSHKey(uid, "SHA256:a", "ssh-ed25519", []byte("a"), "full", ""); err != nil {
t.Fatal(err)
}
if err := s.AddSSHKey(uid, "SHA256:d", "ssh-ed25519", []byte("d"), "deploy:7:ro", ""); err != nil {
t.Fatal(err)
}
a, d := keyID(t, s, "SHA256:a"), keyID(t, s, "SHA256:d")
if err := s.RemoveSSHKey(uid, "SHA256:a"); err != nil {
t.Fatal(err)
}
if err := s.RemoveDeployKey(7, "SHA256:d"); err != nil {
t.Fatal(err)
}
if err := s.SetUserDisabled(uid, true); err != nil {
t.Fatal(err)
}
if err := s.SetUserDisabled(uid, false); err != nil {
t.Fatal(err)
}
want := []Revoked{{KeyIDs: []int64{a}}, {KeyIDs: []int64{d}}, {UserID: uid}}
if !slices.EqualFunc(*got, want, func(x, y Revoked) bool {
return slices.Equal(x.KeyIDs, y.KeyIDs) && x.UserID == y.UserID
}) {
t.Fatalf("announced %+v, want %+v (enabling announces nothing)", *got, want)
}
// A removal that found nothing announces nothing.
if err := s.RemoveSSHKey(uid, "SHA256:a"); err != ErrNotFound {
t.Fatalf("second remove: %v", err)
}
if len(*got) != 3 {
t.Fatalf("a miss was announced: %+v", *got)
}
}
func TestDeleteUserAnnounces(t *testing.T) {
s, uid, got := revokeFixture(t)
if err := s.DeleteUser(uid); err != nil {
t.Fatal(err)
}
if len(*got) != 1 || (*got)[0].UserID != uid {
t.Fatalf("announced %+v", *got)
}
}
func TestLiveSSHKeys(t *testing.T) {
s, uid, _ := revokeFixture(t)
bob, err := s.CreateUser("bob", false)
if err != nil {
t.Fatal(err)
}
for _, k := range []struct {
uid int64
fp string
}{{uid, "SHA256:a"}, {bob, "SHA256:b"}} {
if err := s.AddSSHKey(k.uid, k.fp, "ssh-ed25519", []byte(k.fp), "full", ""); err != nil {
t.Fatal(err)
}
}
a, b := keyID(t, s, "SHA256:a"), keyID(t, s, "SHA256:b")
if _, err := s.DB.Exec("UPDATE users SET disabled = 1 WHERE id = ?", bob); err != nil {
t.Fatal(err)
}
live, err := s.LiveSSHKeys([]int64{a, b, 999})
if err != nil {
t.Fatal(err)
}
if !live[a] || live[b] || live[999] {
t.Fatalf("live = %v; want only %d", live, a)
}
if live, err := s.LiveSSHKeys(nil); err != nil || len(live) != 0 {
t.Fatalf("no ids: %v %v", live, err)
}
}
- Step 2: Run it and see it fail
Run: go test ./internal/store -run 'TestRemovalsAnnounceTheirKeys|TestDeleteUserAnnounces|TestLiveSSHKeys' -count=1
Expected: FAIL to compile, s.OnRevoke undefined.
- Step 3: Add the subscriber fields
In internal/store/store.go, the Store struct becomes:
type Store struct {
DB *sql.DB
// logWait holds one channel per build someone is following, closed
// by the next change to that build's row (BuildLogWait).
logMu sync.Mutex
logWait map[int64]chan struct{}
// onRevoke runs after each key revocation this process commits.
revokeMu sync.Mutex
onRevoke []func(Revoked)
}
- Step 4: Create
internal/store/revoke.go
package store
import (
"slices"
"strings"
)
// Revoked names SSH keys that stopped being valid: by id, or every key
// of an account. The SSH listener closes the connections they opened.
type Revoked struct {
KeyIDs []int64
UserID int64 // every key of this account; 0 for none
}
// OnRevoke registers f to run after each revocation this process
// commits. Revocations committed by another process (gitbayd admin on
// the host) are not announced; the listener's sweep finds those.
func (s *Store) OnRevoke(f func(Revoked)) {
s.revokeMu.Lock()
defer s.revokeMu.Unlock()
s.onRevoke = append(s.onRevoke, f)
}
// announce runs the subscribers. Call it after the commit, outside any
// transaction.
func (s *Store) announce(r Revoked) {
s.revokeMu.Lock()
fs := slices.Clone(s.onRevoke)
s.revokeMu.Unlock()
for _, f := range fs {
f(r)
}
}
// LiveSSHKeys reports which of ids still name a registered key on an
// account that is not disabled.
func (s *Store) LiveSSHKeys(ids []int64) (map[int64]bool, error) {
live := map[int64]bool{}
if len(ids) == 0 {
return live, nil
}
args := make([]any, len(ids))
for i, id := range ids {
args[i] = id
}
rows, err := s.DB.Query(`SELECT k.id FROM ssh_keys k JOIN users u ON u.id = k.user_id
WHERE u.disabled = 0 AND k.id IN (?`+strings.Repeat(", ?", len(ids)-1)+`)`, args...)
if err != nil {
return nil, err
}
defer rows.Close()
for rows.Next() {
var id int64
if err := rows.Scan(&id); err != nil {
return nil, err
}
live[id] = true
}
return live, rows.Err()
}
- Step 5: Announce from the four removals
RemoveSSHKey in internal/store/users.go:
// RemoveSSHKey removes a key owned by userID, bumps the key epoch, and
// announces the revocation.
func (s *Store) RemoveSSHKey(userID int64, fingerprint string) error {
tx, err := s.DB.Begin()
if err != nil {
return err
}
defer tx.Rollback()
var id int64
err = tx.QueryRow("DELETE FROM ssh_keys WHERE user_id = ? AND fingerprint = ? RETURNING id", userID, fingerprint).Scan(&id)
if errors.Is(err, sql.ErrNoRows) {
return ErrNotFound
}
if err != nil {
return err
}
if err := bumpKeyEpoch(tx); err != nil {
return err
}
if err := tx.Commit(); err != nil {
return err
}
s.announce(Revoked{KeyIDs: []int64{id}})
return nil
}
RemoveDeployKey:
// RemoveDeployKey removes a deploy key from a repository by fingerprint;
// any repo admin may remove it regardless of who added it.
func (s *Store) RemoveDeployKey(repoID int64, fingerprint string) error {
tx, err := s.DB.Begin()
if err != nil {
return err
}
defer tx.Rollback()
var id int64
err = tx.QueryRow(
"DELETE FROM ssh_keys WHERE fingerprint = ? AND scope LIKE 'deploy:' || ? || ':%' RETURNING id",
fingerprint, repoID).Scan(&id)
if errors.Is(err, sql.ErrNoRows) {
return ErrNotFound
}
if err != nil {
return err
}
if err := bumpKeyEpoch(tx); err != nil {
return err
}
if err := tx.Commit(); err != nil {
return err
}
s.announce(Revoked{KeyIDs: []int64{id}})
return nil
}
SetUserDisabled, the tail from if disabled {:
if disabled {
// A pending login link is a session in waiting, so it goes with
// the sessions and API tokens. Re-enabling means minting again.
for _, table := range []string{"web_sessions", "api_tokens", "login_tokens"} {
if _, err := s.DB.Exec("DELETE FROM "+table+" WHERE user_id = ?", userID); err != nil {
return err
}
}
s.announce(Revoked{UserID: userID})
}
return nil
}
Update 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."
DeleteUser, the tail:
res, err := s.DB.Exec("DELETE FROM users WHERE id = ?", id)
if err != nil {
return err
}
if n, _ := res.RowsAffected(); n == 0 {
return ErrNotFound
}
s.announce(Revoked{UserID: id})
return nil
}
- Step 6: Run the tests
Run: go test ./internal/store -count=1
Expected: PASS.
- Step 7: Commit
git add internal/store/revoke.go internal/store/revoke_test.go internal/store/store.go internal/store/users.go
git commit -S -m "store: announce key revocations; LiveSSHKeys
Ref #256"
Task 1.2: gitutil.Transport can be cancelled
Files:
- Modify:
internal/gitutil/gitutil.go:39-56 - Create:
internal/gitutil/proc_unix.go,internal/gitutil/proc_other.go - Test:
internal/gitutil/transport_test.go(create)
Interfaces:
-
Produces:
func Transport(service, repoPath string, stdin io.Reader, stdout, errW io.Writer, extraEnv []string, maxPack int64, cancel <-chan struct{}) error— closingcancelkills the git process and its children; a nilcancelnever fires. -
Step 1: Write the failing test
internal/gitutil/transport_test.go:
package gitutil
import (
"io"
"os/exec"
"strings"
"testing"
"time"
)
// Closing cancel kills the transport; it does not wait for the client
// to hang up. Stdin ends only after the kill, as a cut connection's
// does, so a clean exit here would mean the kill never happened.
func TestTransportCancelKillsGit(t *testing.T) {
dir := t.TempDir()
if out, err := exec.Command("git", "init", "-q", "--bare", dir).CombinedOutput(); err != nil {
t.Fatalf("git init: %v\n%s", err, out)
}
in, w := io.Pipe()
cancel := make(chan struct{})
errc := make(chan error, 1)
go func() { errc <- Transport("git-upload-pack", dir, in, io.Discard, io.Discard, nil, 0, cancel) }()
close(cancel)
time.AfterFunc(500*time.Millisecond, func() { w.Close() })
select {
case err := <-errc:
if err == nil || !strings.Contains(err.Error(), "killed") {
t.Fatalf("Transport returned %v, want the process killed", err)
}
case <-time.After(5 * time.Second):
t.Fatal("Transport did not return after cancel")
}
}
- Step 2: Run it and see it fail
Run: go test ./internal/gitutil -run TestTransportCancelKillsGit -count=1
Expected: FAIL to compile, too many arguments to Transport.
- Step 3: Process-group helpers
internal/gitutil/proc_unix.go:
//go:build unix
package gitutil
import (
"os/exec"
"syscall"
)
// ownProcessGroup puts cmd in a process group of its own, so killTree
// ends what it started too: receive-pack runs index-pack and the hooks.
func ownProcessGroup(cmd *exec.Cmd) {
if cmd.SysProcAttr == nil {
cmd.SysProcAttr = &syscall.SysProcAttr{}
}
cmd.SysProcAttr.Setpgid = true
}
func killTree(cmd *exec.Cmd) {
if cmd.Process == nil {
return
}
if err := syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL); err != nil {
cmd.Process.Kill()
}
}
internal/gitutil/proc_other.go:
//go:build !unix
package gitutil
import "os/exec"
func ownProcessGroup(cmd *exec.Cmd) {}
func killTree(cmd *exec.Cmd) {
if cmd.Process != nil {
cmd.Process.Kill()
}
}
- Step 4: Transport
Replace Transport in internal/gitutil/gitutil.go:
// Transport runs a git transport service against repoPath with the
// client's streams. Closing cancel kills the service and everything it
// started; a push killed before its pre-receive hook answers updates no
// refs.
func Transport(service, repoPath string, stdin io.Reader, stdout, errW io.Writer, extraEnv []string, maxPack int64, cancel <-chan struct{}) error {
var args []string
switch service {
case "git-upload-pack", "git-receive-pack", "git-upload-archive":
if service == "git-receive-pack" && maxPack > 0 {
args = []string{"-c", fmt.Sprintf("receive.maxInputSize=%d", maxPack)}
}
args = append(args, strings.TrimPrefix(service, "git-"), repoPath)
default:
return fmt.Errorf("unknown service %q", service)
}
cmd := exec.Command(toolpath.Look("git"), args...)
cmd.Env = append(os.Environ(), extraEnv...)
cmd.Stdin = stdin
cmd.Stdout = stdout
cmd.Stderr = errW
ownProcessGroup(cmd)
if err := cmd.Start(); err != nil {
return err
}
finished := make(chan struct{})
go func() {
select {
case <-cancel:
killTree(cmd)
case <-finished:
}
}()
err := cmd.Wait()
close(finished)
return err
}
If the current doc comment above Transport (line 38 and up) says something different, keep its first sentence and replace the rest with the above.
- Step 5: Fix the caller so the tree builds
In internal/sshd/sshd.go:464, pass nil for now (Task 1.3 wires the channel):
if err := gitutil.Transport(service, dir, stdin, stdout, stderr, env, maxPack, nil); err != nil {
- Step 6: Run the tests
Run: go build ./... && go test ./internal/gitutil -count=1
Expected: PASS.
- Step 7: Commit
git add internal/gitutil internal/sshd/sshd.go
git commit -S -m "gitutil: Transport takes a cancel channel and kills its process group
Ref #256"
Task 1.3: sshd re-reads the key per exec and cuts revoked connections
Files:
- 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) - Modify:
cmd/gitbayd/system.go:97 - Modify:
internal/sshd/sshd_test.go:20-87(followServersplit) - Test:
internal/sshd/revoke_test.go(create)
Interfaces:
-
Consumes:
store.Revoked,Store.OnRevoke,Store.LiveSSHKeys(Task 1.1);gitutil.Transport(..., cancel)(Task 1.2). -
Produces:
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 fromkey.func (s *Server) sweepOnce()(tests call it).- 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).
-
Step 1: Split the test fixture
In internal/sshd/sshd_test.go, replace followServer (lines 20-87) with:
// testServer is an embedded server over a fresh store holding alice
// with one full-scope key, and a client connected with that key.
type testServer struct {
srv *Server
st *store.Store
client *ssh.Client
uid int64
keyID int64
fp string
}
func newTestServer(t *testing.T) testServer {
t.Helper()
root := t.TempDir()
st, err := store.Open(filepath.Join(root, "gitbay.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { st.Close() })
if err := st.MigrateUp(); err != nil {
t.Fatal(err)
}
uid, err := st.CreateUser("alice", false)
if err != nil {
t.Fatal(err)
}
_, priv, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
t.Fatal(err)
}
signer, err := ssh.NewSignerFromKey(priv)
if err != nil {
t.Fatal(err)
}
pub := signer.PublicKey()
fp := ssh.FingerprintSHA256(pub)
if err := st.AddSSHKey(uid, fp, pub.Type(), pub.Marshal(), "full", "test"); err != nil {
t.Fatal(err)
}
key, err := st.SSHKeyByFingerprint(fp)
if err != nil {
t.Fatal(err)
}
cfg := config.Default()
cfg.Server.Root = root
srv, err := New(cfg, st)
if err != nil {
t.Fatal(err)
}
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
go srv.Serve(ln)
t.Cleanup(func() { ln.Close() })
client, err := ssh.Dial("tcp", ln.Addr().String(), &ssh.ClientConfig{
User: "git",
Auth: []ssh.AuthMethod{ssh.PublicKeys(signer)},
HostKeyCallback: ssh.InsecureIgnoreHostKey(),
Timeout: 5 * time.Second,
})
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { client.Close() })
return testServer{srv: srv, st: st, client: client, uid: uid, keyID: key.ID, fp: fp}
}
// withBuild gives alice the public repo alice/app and a queued build 1
// whose log has one line.
func withBuild(t *testing.T, ts testServer) {
t.Helper()
repoID, err := ts.st.CreateRepo("user", ts.uid, "app", "public")
if err != nil {
t.Fatal(err)
}
id, err := ts.st.CreateBuild(repoID, "unit", "abc", "main", `["true"]`, "", "", true)
if err != nil {
t.Fatal(err)
}
if err := ts.st.AppendBuildLog(id, []byte("queued\n")); err != nil {
t.Fatal(err)
}
}
// followServer starts an embedded server holding alice, her public repo
// alice/app and a queued build 1 whose log has one line, and returns it
// with a client connected as alice.
func followServer(t *testing.T) (*Server, *ssh.Client) {
t.Helper()
ts := newTestServer(t)
withBuild(t, ts)
return ts.srv, ts.client
}
Run: go test ./internal/sshd -count=1
Expected: PASS (behaviour unchanged).
- Step 2: Write the failing tests
internal/sshd/revoke_test.go:
package sshd
import (
"bytes"
"errors"
"strings"
"testing"
"time"
"golang.org/x/crypto/ssh"
)
// execStatus runs cmd on a new session and returns its exit status and
// stderr; -1 when the session could not run.
func execStatus(client *ssh.Client, cmd string) (int, string) {
sess, err := client.NewSession()
if err != nil {
return -1, err.Error()
}
defer sess.Close()
var stderr bytes.Buffer
sess.Stderr = &stderr
err = sess.Run(cmd)
var exit *ssh.ExitError
switch {
case err == nil:
return 0, stderr.String()
case errors.As(err, &exit):
return exit.ExitStatus(), stderr.String()
}
return -1, err.Error()
}
// waitClosed fails unless the server closes the client's connection
// within five seconds.
func waitClosed(t *testing.T, client *ssh.Client) {
t.Helper()
done := make(chan struct{})
go func() { client.Wait(); close(done) }()
select {
case <-done:
case <-time.After(5 * time.Second):
t.Fatal("the connection stayed open")
}
}
// Each exec reads the key again. The rows change behind the store's
// back here, so no revocation is announced and the connection stays up:
// what refuses the command is the per-exec check alone.
func TestExecRevalidatesKey(t *testing.T) {
ts := newTestServer(t)
if code, errOut := execStatus(ts.client, "whoami"); code != 0 {
t.Fatalf("whoami: %d %s", code, errOut)
}
if _, err := ts.st.DB.Exec("UPDATE ssh_keys SET scope = 'git' WHERE id = ?", ts.keyID); err != nil {
t.Fatal(err)
}
if code, errOut := execStatus(ts.client, "whoami"); code != 4 || !strings.Contains(errOut, "does not allow control commands") {
t.Fatalf("whoami after re-scope: %d %q", code, errOut)
}
if _, err := ts.st.DB.Exec("DELETE FROM ssh_keys WHERE id = ?", ts.keyID); err != nil {
t.Fatal(err)
}
if code, errOut := execStatus(ts.client, "whoami"); code != 4 || !strings.Contains(errOut, "no longer registered") {
t.Fatalf("whoami after delete: %d %q", code, errOut)
}
}
// Removing the key cuts the connection, ending a command running on it.
func TestRemoveKeyCutsConnection(t *testing.T) {
ts := newTestServer(t)
withBuild(t, ts)
var stderr bytes.Buffer
sess := startFollow(t, ts.client, &stderr)
if err := ts.st.RemoveSSHKey(ts.uid, ts.fp); err != nil {
t.Fatal(err)
}
waited := make(chan error, 1)
go func() { waited <- sess.Wait() }()
select {
case err := <-waited:
if err == nil {
t.Fatal("the follow exited cleanly after its key was removed")
}
case <-time.After(5 * time.Second):
t.Fatal("the follow outlived its key")
}
waitClosed(t, ts.client)
}
func TestDisableCutsConnection(t *testing.T) {
ts := newTestServer(t)
if err := ts.st.SetUserDisabled(ts.uid, true); err != nil {
t.Fatal(err)
}
waitClosed(t, ts.client)
}
// A revocation made by another process is not announced here; the
// sweep finds it. A live key survives the sweep.
func TestSweepCutsOutOfProcessRevocation(t *testing.T) {
ts := newTestServer(t)
ts.srv.sweepOnce()
if code, errOut := execStatus(ts.client, "whoami"); code != 0 {
t.Fatalf("the sweep cut a live key: %d %s", code, errOut)
}
if _, err := ts.st.DB.Exec("UPDATE users SET disabled = 1 WHERE id = ?", ts.uid); err != nil {
t.Fatal(err)
}
ts.srv.sweepOnce()
waitClosed(t, ts.client)
}
- Step 3: Run them and see them fail
Run: go test ./internal/sshd -run 'TestExecRevalidatesKey|TestRemoveKeyCutsConnection|TestDisableCutsConnection|TestSweepCutsOutOfProcessRevocation' -count=1
Expected: FAIL to compile, ts.srv.sweepOnce undefined.
- Step 4: Track the key on each connection
In internal/sshd/sshd.go add "slices" to the imports. Replace conn:
// conn is one accepted connection and how many sessions it is running.
// A CLI's shared connection sits idle between commands; on shutdown an
// idle connection is closed at once and only a session mid-command is
// waited for (#141).
type conn struct {
net net.Conn
active atomic.Int32
// keyID and userID are the key that authenticated the connection and
// its account: 0 before the handshake and for an unregistered key.
// Guarded by Server.mu.
keyID, userID int64
revoked chan struct{} // closed by cut
cutOnce sync.Once
}
// cut ends the connection because its key was revoked: a git transport
// on it is killed, and every other command loses its channel.
func (c *conn) cut() {
c.cutOnce.Do(func() { close(c.revoked) })
c.net.Close()
}
In New, after s.sshCfg = sc:
st.OnRevoke(s.revoke)
Serve becomes:
// Serve accepts connections on ln until it is closed.
func (s *Server) Serve(ln net.Listener) error {
served := make(chan struct{})
defer close(served)
go s.sweep(served)
for {
nc, err := ln.Accept()
if err != nil {
return err
}
c := &conn{net: nc, revoked: make(chan struct{})}
s.mu.Lock()
s.conns[c] = struct{}{}
s.mu.Unlock()
s.sessions.Add(1)
go func() {
defer s.sessions.Done()
defer func() {
s.mu.Lock()
delete(s.conns, c)
s.mu.Unlock()
}()
s.handleConn(c)
}()
}
}
Add after Serve:
// revoke closes the connections opened by the keys r names.
func (s *Server) revoke(r store.Revoked) {
s.mu.Lock()
defer s.mu.Unlock()
for c := range s.conns {
if c.keyID == 0 {
continue
}
if (r.UserID != 0 && c.userID == r.UserID) || slices.Contains(r.KeyIDs, c.keyID) {
c.cut()
}
}
}
// sweepInterval bounds how long a revocation this process was not told
// about (gitbayd admin on the host) leaves a connection open.
const sweepInterval = 15 * time.Second
func (s *Server) sweep(served <-chan struct{}) {
t := time.NewTicker(sweepInterval)
defer t.Stop()
for {
select {
case <-t.C:
s.sweepOnce()
case <-served:
return
case <-s.stopping:
return
}
}
}
// sweepOnce cuts every connection whose key is no longer live. Only
// connections whose key was asked about are judged: one that
// authenticated while the query ran waits for the next sweep.
func (s *Server) sweepOnce() {
asked := map[int64]bool{}
s.mu.Lock()
for c := range s.conns {
if c.keyID != 0 {
asked[c.keyID] = true
}
}
s.mu.Unlock()
if len(asked) == 0 {
return
}
live, err := s.st.LiveSSHKeys(slices.Collect(maps.Keys(asked)))
if err != nil {
slog.Error("ssh sweep: key lookup", "err", err)
return
}
s.mu.Lock()
defer s.mu.Unlock()
for c := range s.conns {
if asked[c.keyID] && !live[c.keyID] {
c.cut()
}
}
}
Add "maps" to the imports.
In handleConn, after defer sconn.Close():
ext := sconn.Permissions.Extensions
s.mu.Lock()
c.keyID, _ = strconv.ParseInt(ext["key-id"], 10, 64)
c.userID, _ = strconv.ParseInt(ext["user-id"], 10, 64)
s.mu.Unlock()
and pass c to the session: s.handleSession(c, sconn, ch, chReqs).
handleSession takes the connection:
func (s *Server) handleSession(c *conn, sconn *ssh.ServerConn, ch ssh.Channel, reqs <-chan *ssh.Request) {
and its exec case calls code := s.runExec(c, sconn, ch, term, payload.Command, done).
- Step 5: Re-read the key per exec
Replace runExec:
func (s *Server) runExec(c *conn, sconn *ssh.ServerConn, ch ssh.Channel, term control.Term, cmdline string, done <-chan struct{}) int {
ext := sconn.Permissions.Extensions
if blob := ext["anon-key"]; blob != "" {
return s.runAnonymous(ch, blob, cmdline)
}
userID, _ := strconv.ParseInt(ext["user-id"], 10, 64)
keyID, _ := strconv.ParseInt(ext["key-id"], 10, 64)
// A connection outlives its commands, so the key is read again for
// each one: what it may do is what it may do now (#256).
key, err := s.st.SSHKeyByID(keyID)
if errors.Is(err, store.ErrNotFound) || (err == nil && key.UserID != userID) {
fmt.Fprintln(ch.Stderr(), "this key is no longer registered")
return protocol.ExitDenied
}
if err != nil {
slog.Error("ssh exec: key lookup", "err", err)
fmt.Fprintln(ch.Stderr(), "authentication temporarily unavailable")
return protocol.ExitFailure
}
user, err := s.st.UserByID(userID)
if err != nil {
fmt.Fprintln(ch.Stderr(), "account no longer exists")
return protocol.ExitDenied
}
_ = s.st.TouchSSHKey(keyID)
return Exec(s.cfg, s.st, user, key, term, cmdline, ch, ch, ch.Stderr(), done, s.stopping, c.revoked)
}
- Step 6:
Exectakes the key;runGittakes the cancel channel
// Exec runs one SSH exec command line for an authenticated key. It is the
// single dispatch path shared by the embedded listener and the system-sshd
// forced command (gitbayd shell). Closing revoked kills a git transport.
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 {
Inside 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.
runGit gains a last parameter revoked <-chan struct{} and passes it on:
func runGit(cfg config.Config, st *store.Store, user store.User, scope string, argv []string,
stdin io.Reader, stdout, stderr io.Writer, revoked <-chan struct{}) int {
if err := gitutil.Transport(service, dir, stdin, stdout, stderr, env, maxPack, revoked); err != nil {
In cmd/gitbayd/system.go:97:
code := sshd.Exec(cfg, st, user, key, control.ParseTerm(os.Getenv("GITBAY_TERM")), cmdline, os.Stdin, os.Stdout, os.Stderr, nil, nil, nil)
- Step 7: Run the tests
Run: go build ./... && go vet ./... && go test ./internal/sshd ./internal/gitutil ./internal/store -count=1
Expected: PASS.
- Step 8: Commit
git add internal/sshd cmd/gitbayd/system.go
git commit -S -m "sshd: re-read the key per exec; revocation cuts its connections
Ref #256"
Task 1.4: e2e — a multiplexed connection is cut, a push in flight moves no ref
Files:
- Create:
e2e/revoke_test.go
Interfaces:
-
Produces (package
e2e):pkt(s string) string,readPkt(r *bufio.Reader) (string, error),fingerprint(t *testing.T, pubPath string) string— MR 2 usesfingerprint. -
Step 1: Write the test
package e2e
import (
"bufio"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"testing"
"time"
)
// pkt frames one pkt-line.
func pkt(s string) string { return fmt.Sprintf("%04x%s", len(s)+4, s) }
// readPkt reads one pkt-line; a flush reads as "".
func readPkt(r *bufio.Reader) (string, error) {
var n [4]byte
if _, err := io.ReadFull(r, n[:]); err != nil {
return "", err
}
size, err := strconv.ParseUint(string(n[:]), 16, 16)
if err != nil {
return "", err
}
if size == 0 {
return "", nil
}
buf := make([]byte, size-4)
_, err = io.ReadFull(r, buf)
return string(buf), err
}
// fingerprint is the SHA256 fingerprint of a public key file.
func fingerprint(t *testing.T, pubPath string) string {
t.Helper()
out, err := exec.Command("ssh-keygen", "-lf", pubPath).Output()
if err != nil {
t.Fatalf("ssh-keygen -lf: %v", err)
}
return strings.Fields(string(out))[1]
}
// Removing a key cuts the connections it opened: every session
// multiplexed on a ControlMaster, and a push in flight, which moves no
// ref (#256).
func TestRemovedKeyCutsMultiplexedConnection(t *testing.T) {
t.Parallel()
inst := startInstance(t)
aliceKey := setupPublicRepo(t, inst, "alice/app")
spare := inst.newKey(t, "spare")
pub, err := os.ReadFile(spare + ".pub")
if err != nil {
t.Fatal(err)
}
if _, errOut, code := inst.ssh(t, aliceKey, string(pub), "keys", "add"); code != 0 {
t.Fatalf("keys add: %s", errOut)
}
// The control socket sits under the system temp dir: t.TempDir() on
// macOS is long enough to pass the 104-byte socket path limit.
cmDir, err := os.MkdirTemp("", "cm")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { os.RemoveAll(cmDir) })
muxArgs := []string{
"-p", fmt.Sprint(inst.port),
"-i", aliceKey,
"-o", "IdentitiesOnly=yes",
"-o", "StrictHostKeyChecking=no",
"-o", "UserKnownHostsFile=" + filepath.Join(inst.sshDir, "known_hosts"),
"-o", "BatchMode=yes",
"-o", "ControlMaster=auto",
"-o", "ControlPath=" + filepath.Join(cmDir, "%C"),
"-o", "ControlPersist=60",
}
mux := func(args ...string) *exec.Cmd {
return exec.Command("ssh", append(append([]string{}, muxArgs...), args...)...)
}
t.Cleanup(func() { mux("-O", "exit", "git@127.0.0.1").Run() })
if out, err := mux("git@127.0.0.1", "whoami").Output(); err != nil || strings.TrimSpace(string(out)) != "alice" {
t.Fatalf("whoami over the master: %v %q", err, out)
}
// A push held open mid-pack: the ref update is sent, the pack is not.
push := mux("git@127.0.0.1", "git-receive-pack", "alice/app")
stdin, err := push.StdinPipe()
if err != nil {
t.Fatal(err)
}
stdout, err := push.StdoutPipe()
if err != nil {
t.Fatal(err)
}
if err := push.Start(); err != nil {
t.Fatal(err)
}
adv := bufio.NewReader(stdout)
first, err := readPkt(adv)
if err != nil || len(first) < 40 {
t.Fatalf("advertisement: %q %v", first, err)
}
oldSHA := first[:40]
for {
line, err := readPkt(adv)
if err != nil {
t.Fatalf("advertisement: %v", err)
}
if line == "" {
break
}
}
newSHA := strings.Repeat("1", 40)
io.WriteString(stdin, pkt(oldSHA+" "+newSHA+" refs/heads/main\x00report-status\n")+"0000")
// A pack header announcing one object, and no object.
stdin.Write([]byte("PACK\x00\x00\x00\x02\x00\x00\x00\x01"))
exited := make(chan error, 1)
go func() {
io.Copy(io.Discard, adv)
exited <- push.Wait()
}()
if _, errOut, code := inst.ssh(t, spare, "", "keys", "remove", fingerprint(t, aliceKey+".pub")); code != 0 {
t.Fatalf("keys remove: %s", errOut)
}
select {
case err := <-exited:
if err == nil {
t.Fatal("the push exited cleanly after its key was removed")
}
case <-time.After(10 * time.Second):
t.Fatal("the push outlived its key")
}
// The master went with the connection; a new one authenticates
// again, and the key is unknown.
if out, err := mux("git@127.0.0.1", "whoami").CombinedOutput(); err == nil {
t.Fatalf("whoami after removal succeeded: %s", out)
}
refs := mustGit(t, t.TempDir(), inst.gitEnv(spare), "ls-remote", inst.sshURL("alice/app"), "refs/heads/main")
if !strings.HasPrefix(refs, oldSHA) {
t.Fatalf("main moved: %s, want %s", refs, oldSHA)
}
}
- Step 2: Run it
Run: go test ./e2e -run TestRemovedKeyCutsMultiplexedConnection -count=1
Expected: PASS. To see it fail, stash Task 1.3's st.OnRevoke(s.revoke) line: the push then hangs until the 10-second timeout.
- Step 3: Commit
git add e2e/revoke_test.go
git commit -S -m "e2e: removing a key cuts its multiplexed connection and a push in flight
Ref #256"
Task 1.5: docs
Files:
-
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) -
Step 1: Edit the pages
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 ...".
09-Controls.org, the revocation row becomes:
| 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=) |
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.
08-Operations.org: delete the two lines "Open connections of a removed key keep working until they close; see #256."
Threat-Model.org, add to "Trust boundaries" after the "SSH public key = identity" bullet:
- *Revocation is immediate.* Every exec and every git transport session
re-reads its key. Removing a key, removing a deploy key, disabling or
deleting an account closes the connections the affected keys opened:
a git transport is killed with its children, and a push killed before
its pre-receive hook answers moves no ref. A control command already
inside its database write finishes it; its output is lost. A
revocation made by =gitbayd admin= on the host, another process, is
found within 15 seconds. In =ssh.mode = "system"= each exec is its
own process: the next exec is refused, one already running is not cut.
Users.org, after the paragraph that ends "Labels are one line of up to 64 bytes.":
Removing a key closes every connection it opened, including the CLI's
shared one; removing the key the current command runs on ends that
command's connection too.
- Step 2: Commit, open the MR
git add .gitbay/wiki
git commit -S -m "wiki: revocation closes open connections
Closes #256"
git push -u origin revoke-closes-connections
gitbay mr create --source revoke-closes-connections --target main --title "sshd: removing a key closes its connections"
Merge with --strategy ff once CI is green; delete the branch locally and on the remote.
MR 2: expiring credentials cannot mint; credentials record their token (branch token-delegation, #257)
Decisions on the issue: a token with an expiry is refused on every
credential-minting command, marked on Command and checked in
Dispatch; tokens and keys record the token that created them;
token revoke lists what the token created and can revoke it too;
token create defaults to --scope read.
Commands marked MintsCredential: token create, keys add,
repo deploy-key add, repo runner add (attaches or creates a key
that can claim builds), web login (a login link opens a seven-day
session), admin invite, admin user create (with --key or a
verified address it is a way in), email verify and
admin email verify (a verified address receives login links). See
open question 2.
created_by_token references api_tokens(id) ON DELETE SET NULL: a
revoked token's id is never reused for a live row, because SQLite
reuses the highest rowid after it is deleted and a dangling integer
would then name the wrong token. Revoking without --created lists
what the token made and then drops the link.
Task 2.1: migration 0060 and the store
Files:
- Create:
internal/store/migrations/0060_credential_origin.up.sql,.down.sql - Modify:
internal/store/tokens.go(whole file) - Modify:
internal/store/users.go—SSHKey(18-28),AddSSHKey(270-289),ListSSHKeys(335-353) - Test:
internal/store/tokens_test.go(create)
Interfaces:
-
Consumes:
Store.announce,Revoked(MR 1). -
Produces:
type APIToken struct { ID int64; Name, Scope, CreatedAt string; ExpiresAt, LastUsedAt *time.Time; CreatedBy string }—CreatedByis the creating token's name, "" for none.func (s *Store) CreateAPIToken(userID int64, name, tokenHash, scope string, expires *time.Time, createdByToken int64) errorfunc (s *Store) APITokenUser(tokenHash string) (User, APIToken, error)type Created struct { Tokens []string; Keys []string }— token names and key fingerprints.func (s *Store) RevokeAPIToken(userID int64, name string, withCreated bool) (Created, error)type KeyOrigin struct { CreatedByToken int64 }(MR 3 addsExpiresAt)func (s *Store) AddSSHKeyFrom(userID int64, fingerprint, algo string, blob []byte, scope, label string, o KeyOrigin) error;AddSSHKeykeeps its signature and calls it withKeyOrigin{}.SSHKey.CreatedBy string— filled byListSSHKeysonly.
-
Step 1: Write the failing test
internal/store/tokens_test.go:
package store
import (
"slices"
"testing"
"time"
)
func tokenID(t *testing.T, s *Store, hash string) int64 {
t.Helper()
_, tok, err := s.APITokenUser(hash)
if err != nil {
t.Fatal(err)
}
return tok.ID
}
// parent made child, child made grandchild and a key; the key belongs
// to another account, as admin user create --key makes one.
func tokenChain(t *testing.T) (*Store, int64, *[]Revoked) {
t.Helper()
s, uid, got := revokeFixture(t)
bob, err := s.CreateUser("bob", false)
if err != nil {
t.Fatal(err)
}
if err := s.CreateAPIToken(uid, "parent", "h-parent", "full", nil, 0); err != nil {
t.Fatal(err)
}
if err := s.CreateAPIToken(uid, "child", "h-child", "full", nil, tokenID(t, s, "h-parent")); err != nil {
t.Fatal(err)
}
child := tokenID(t, s, "h-child")
if err := s.CreateAPIToken(uid, "grandchild", "h-grand", "read", nil, child); err != nil {
t.Fatal(err)
}
if err := s.AddSSHKeyFrom(bob, "SHA256:k", "ssh-ed25519", []byte("k"), "full", "", KeyOrigin{CreatedByToken: child}); err != nil {
t.Fatal(err)
}
return s, uid, got
}
func TestTokenRecordsItsCreator(t *testing.T) {
s, uid, _ := tokenChain(t)
toks, err := s.ListAPITokens(uid)
if err != nil {
t.Fatal(err)
}
by := map[string]string{}
for _, tk := range toks {
by[tk.Name] = tk.CreatedBy
}
if by["parent"] != "" || by["child"] != "parent" || by["grandchild"] != "child" {
t.Fatalf("created by: %v", by)
}
bob, _ := s.UserByUsername("bob")
keys, err := s.ListSSHKeys(bob.ID)
if err != nil || len(keys) != 1 || keys[0].CreatedBy != "child" {
t.Fatalf("key created by: %+v %v", keys, err)
}
}
func TestRevokeAPITokenListsWhatItCreated(t *testing.T) {
s, uid, got := tokenChain(t)
c, err := s.RevokeAPIToken(uid, "parent", false)
if err != nil {
t.Fatal(err)
}
if !slices.Equal(c.Tokens, []string{"child", "grandchild"}) || !slices.Equal(c.Keys, []string{"SHA256:k"}) {
t.Fatalf("created = %+v", c)
}
// Listed, not removed; the link to the revoked parent is gone.
toks, _ := s.ListAPITokens(uid)
if len(toks) != 2 || toks[0].Name != "child" || toks[0].CreatedBy != "" {
t.Fatalf("tokens after revoke: %+v", toks)
}
if _, err := s.SSHKeyByFingerprint("SHA256:k"); err != nil {
t.Fatalf("the key went: %v", err)
}
if len(*got) != 0 {
t.Fatalf("announced %+v with nothing revoked but the token", *got)
}
}
func TestRevokeAPITokenWithCreated(t *testing.T) {
s, uid, got := tokenChain(t)
k, _ := s.SSHKeyByFingerprint("SHA256:k")
if _, err := s.RevokeAPIToken(uid, "parent", true); err != nil {
t.Fatal(err)
}
if toks, _ := s.ListAPITokens(uid); len(toks) != 0 {
t.Fatalf("tokens left: %+v", toks)
}
if _, err := s.SSHKeyByFingerprint("SHA256:k"); err != ErrNotFound {
t.Fatalf("key left: %v", err)
}
if len(*got) != 1 || !slices.Equal((*got)[0].KeyIDs, []int64{k.ID}) {
t.Fatalf("announced %+v", *got)
}
if _, err := s.RevokeAPIToken(uid, "parent", true); err != ErrNotFound {
t.Fatalf("second revoke: %v", err)
}
}
func TestAPITokenUserCarriesExpiry(t *testing.T) {
s, uid, _ := revokeFixture(t)
exp := time.Now().Add(time.Hour)
if err := s.CreateAPIToken(uid, "brief", "h-brief", "full", &exp, 0); err != nil {
t.Fatal(err)
}
_, tok, err := s.APITokenUser("h-brief")
if err != nil || tok.ExpiresAt == nil || tok.Name != "brief" || tok.ID == 0 {
t.Fatalf("token %+v %v", tok, err)
}
}
- Step 2: Run it and see it fail
Run: go test ./internal/store -run 'TestTokenRecordsItsCreator|TestRevokeAPIToken|TestAPITokenUserCarriesExpiry' -count=1
Expected: FAIL to compile.
- Step 3: Migration
internal/store/migrations/0060_credential_origin.up.sql:
-- The API token a credential was created through. NULL when it was not,
-- and once that token is revoked.
ALTER TABLE api_tokens ADD COLUMN created_by_token INTEGER REFERENCES api_tokens(id) ON DELETE SET NULL;
ALTER TABLE ssh_keys ADD COLUMN created_by_token INTEGER REFERENCES api_tokens(id) ON DELETE SET NULL;
internal/store/migrations/0060_credential_origin.down.sql:
ALTER TABLE ssh_keys DROP COLUMN created_by_token;
ALTER TABLE api_tokens DROP COLUMN created_by_token;
- Step 4: Tokens in the store
Replace internal/store/tokens.go:
package store
import (
"database/sql"
"errors"
"fmt"
"strings"
"time"
)
type APIToken struct {
ID int64
Name string
Scope string
CreatedAt string
ExpiresAt *time.Time
LastUsedAt *time.Time
CreatedBy string // name of the token that created this one; "" for none
}
// nullID stores 0 as NULL.
func nullID(id int64) any {
if id == 0 {
return nil
}
return id
}
// CreateAPIToken stores a token hash; expires nil means no expiry,
// createdByToken 0 means it was not created through a token.
func (s *Store) CreateAPIToken(userID int64, name, tokenHash, scope string, expires *time.Time, createdByToken int64) error {
var exp any
if expires != nil {
exp = fmtTime(*expires)
}
_, err := s.DB.Exec(
"INSERT INTO api_tokens (user_id, name, token_hash, scope, expires_at, created_by_token) VALUES (?, ?, ?, ?, ?, ?)",
userID, name, tokenHash, scope, exp, nullID(createdByToken))
if isUniqueErr(err) {
return fmt.Errorf("you already have a token named %q", name)
}
return err
}
// APITokenUser resolves a presented token to its user and the token;
// expired and unknown tokens fail identically.
func (s *Store) APITokenUser(tokenHash string) (User, APIToken, error) {
var userID int64
var t APIToken
var exp sql.NullString
err := s.DB.QueryRow(`
SELECT user_id, id, name, scope, expires_at FROM api_tokens
WHERE token_hash = ? AND (expires_at IS NULL OR expires_at > ?)`,
tokenHash, fmtTime(time.Now())).Scan(&userID, &t.ID, &t.Name, &t.Scope, &exp)
if errors.Is(err, sql.ErrNoRows) {
return User{}, APIToken{}, ErrNotFound
}
if err != nil {
return User{}, APIToken{}, err
}
t.ExpiresAt = parseTime(exp)
s.DB.Exec("UPDATE api_tokens SET last_used_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE token_hash = ?", tokenHash)
u, err := s.UserByID(userID)
return u, t, err
}
func (s *Store) ListAPITokens(userID int64) ([]APIToken, error) {
rows, err := s.DB.Query(`
SELECT t.id, t.name, t.scope, t.created_at, t.expires_at, t.last_used_at, COALESCE(p.name, '')
FROM api_tokens t LEFT JOIN api_tokens p ON p.id = t.created_by_token
WHERE t.user_id = ? ORDER BY t.name`, userID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []APIToken
for rows.Next() {
var t APIToken
var exp, used sql.NullString
if err := rows.Scan(&t.ID, &t.Name, &t.Scope, &t.CreatedAt, &exp, &used, &t.CreatedBy); err != nil {
return nil, err
}
t.ExpiresAt = parseTime(exp)
t.LastUsedAt = parseTime(used)
out = append(out, t)
}
return out, rows.Err()
}
// Created is what a token made, directly or through tokens it made:
// token names and SSH key fingerprints.
type Created struct {
Tokens []string `json:"tokens"`
Keys []string `json:"keys"`
}
// chainCTE selects the token named by the first argument and every
// token created from it, at any depth.
const chainCTE = `WITH RECURSIVE chain(id) AS (
SELECT ? UNION SELECT t.id FROM api_tokens t JOIN chain ON t.created_by_token = chain.id)`
// RevokeAPIToken deletes the user's token by name and returns what it
// created. withCreated deletes those too; otherwise they stay and lose
// the link to the revoked token.
func (s *Store) RevokeAPIToken(userID int64, name string, withCreated bool) (Created, error) {
tx, err := s.DB.Begin()
if err != nil {
return Created{}, err
}
defer tx.Rollback()
var id int64
err = tx.QueryRow("SELECT id FROM api_tokens WHERE user_id = ? AND name = ?", userID, name).Scan(&id)
if errors.Is(err, sql.ErrNoRows) {
return Created{}, ErrNotFound
}
if err != nil {
return Created{}, err
}
var c Created
rows, err := tx.Query(chainCTE+` SELECT name FROM api_tokens WHERE id IN (SELECT id FROM chain) AND id != ? ORDER BY name`, id, id)
if err != nil {
return Created{}, err
}
for rows.Next() {
var n string
if err := rows.Scan(&n); err != nil {
rows.Close()
return Created{}, err
}
c.Tokens = append(c.Tokens, n)
}
rows.Close()
var keyIDs []int64
rows, err = tx.Query(chainCTE+` SELECT id, fingerprint FROM ssh_keys WHERE created_by_token IN (SELECT id FROM chain) ORDER BY id`, id)
if err != nil {
return Created{}, err
}
for rows.Next() {
var kid int64
var fp string
if err := rows.Scan(&kid, &fp); err != nil {
rows.Close()
return Created{}, err
}
keyIDs = append(keyIDs, kid)
c.Keys = append(c.Keys, fp)
}
rows.Close()
if !withCreated {
if _, err := tx.Exec("DELETE FROM api_tokens WHERE id = ?", id); err != nil {
return Created{}, err
}
return c, tx.Commit()
}
if len(keyIDs) > 0 {
args := make([]any, len(keyIDs))
for i, k := range keyIDs {
args[i] = k
}
if _, err := tx.Exec("DELETE FROM ssh_keys WHERE id IN (?"+strings.Repeat(", ?", len(keyIDs)-1)+")", args...); err != nil {
return Created{}, err
}
if err := bumpKeyEpoch(tx); err != nil {
return Created{}, err
}
}
if _, err := tx.Exec(chainCTE+` DELETE FROM api_tokens WHERE id IN (SELECT id FROM chain)`, id); err != nil {
return Created{}, err
}
if err := tx.Commit(); err != nil {
return Created{}, err
}
if len(keyIDs) > 0 {
s.announce(Revoked{KeyIDs: keyIDs})
}
return c, nil
}
Before writing nullID, run grep -rn "func nullID" internal/store; if one exists, use it and drop this copy.
- Step 5: Keys in the store
In internal/store/users.go, add to SSHKey after LastUsedAt:
CreatedBy string // name of the API token that added the key; "" for none. ListSSHKeys only.
Replace AddSSHKey:
// KeyOrigin is how a key came to be.
type KeyOrigin struct {
CreatedByToken int64 // the API token that added it; 0 for none
}
// AddSSHKey registers a key and bumps the key epoch in one transaction.
func (s *Store) AddSSHKey(userID int64, fingerprint, algo string, blob []byte, scope, label string) error {
return s.AddSSHKeyFrom(userID, fingerprint, algo, blob, scope, label, KeyOrigin{})
}
// AddSSHKeyFrom is AddSSHKey recording where the key came from.
func (s *Store) AddSSHKeyFrom(userID int64, fingerprint, algo string, blob []byte, scope, label string, o KeyOrigin) error {
tx, err := s.DB.Begin()
if err != nil {
return err
}
defer tx.Rollback()
if _, err := tx.Exec(
"INSERT INTO ssh_keys (user_id, fingerprint, algo, blob, scope, label, created_by_token) VALUES (?, ?, ?, ?, ?, ?, ?)",
userID, fingerprint, algo, blob, scope, label, nullID(o.CreatedByToken)); err != nil {
if isUniqueErr(err) {
return ErrDuplicateKey
}
return err
}
if err := bumpKeyEpoch(tx); err != nil {
return err
}
return tx.Commit()
}
ListSSHKeys:
func (s *Store) ListSSHKeys(userID int64) ([]SSHKey, error) {
rows, err := s.DB.Query(
`SELECT k.id, k.user_id, k.fingerprint, k.algo, k.blob, k.scope, k.label, k.created_at,
COALESCE(k.last_used_at, ''), COALESCE(t.name, '')
FROM ssh_keys k LEFT JOIN api_tokens t ON t.id = k.created_by_token
WHERE k.user_id = ? ORDER BY k.id`,
userID)
if err != nil {
return nil, err
}
defer rows.Close()
var keys []SSHKey
for rows.Next() {
var k SSHKey
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 {
return nil, err
}
keys = append(keys, k)
}
return keys, rows.Err()
}
- Step 6: Run the store tests
Run: go test ./internal/store -count=1
Expected: PASS. (go build ./... fails until Task 2.2 updates the callers.)
- Step 7: Commit
git add internal/store
git commit -S -m "store: tokens and keys record the token that created them; chained revoke
Ref #257"
Task 2.2: MintsCredential, Ctx.Expires, and the API wiring
Files:
- Modify:
internal/control/control.go—Ctx(21-57),Command(79-91),Dispatch(after line 161) - Modify:
internal/httpd/api.go:30-78,:126-144;internal/httpd/apiread.go:26 - 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 - Test:
internal/control/token_test.go(create)
Interfaces:
-
Consumes:
store.APITokenUserreturningAPIToken(Task 2.1). -
Produces:
Command.MintsCredential boolCtx.TokenID int64— the API token behind the request, 0 for none.Ctx.Expires *time.Time— when the credential behind the request lapses; nil when it does not. MR 3 sets it for keys.
-
Step 1: Write the failing tests
internal/control/token_test.go:
package control
import (
"bytes"
"slices"
"strings"
"testing"
"time"
"gitbay.org/gitbay/internal/protocol"
"gitbay.org/gitbay/internal/store"
)
// The minting commands, pinned: adding one to the list, or dropping
// one, is a decision this test makes someone take.
func TestMintingCommandsMarked(t *testing.T) {
want := []string{
"admin email verify", "admin invite", "admin user create", "email verify",
"keys add", "repo deploy-key add", "repo runner add", "token create", "web login",
}
var got []string
for _, cmd := range Commands() {
if cmd.MintsCredential {
got = append(got, joinPath(cmd.Path))
}
}
slices.Sort(got)
if !slices.Equal(got, want) {
t.Fatalf("MintsCredential on %q, want %q", got, want)
}
}
// Dispatch refuses before the command runs, so no arguments are needed.
func TestExpiringCredentialCannotMint(t *testing.T) {
exp := time.Now().Add(time.Hour)
for _, cmd := range Commands() {
if !cmd.MintsCredential {
continue
}
var out, errOut bytes.Buffer
c := &Ctx{User: store.User{ID: 1, Username: "root", IsAdmin: true}, Scope: "full", Expires: &exp, Stdout: &out, Stderr: &errOut}
if code := Dispatch(c, cmd.Path); code != protocol.ExitDenied || !strings.Contains(errOut.String(), "expires") {
t.Errorf("%s: exit %d %q, want %d and the reason", joinPath(cmd.Path), code, errOut.String(), protocol.ExitDenied)
}
}
}
func TestTokenCreateDefaultsToReadAndRecordsCreator(t *testing.T) {
st, _, uid := newQueueTestRepo(t)
if err := st.CreateAPIToken(uid, "parent", "h-parent", "full", nil, 0); err != nil {
t.Fatal(err)
}
_, parent, err := st.APITokenUser("h-parent")
if err != nil {
t.Fatal(err)
}
c, errOut := pruneCtx(st, t.TempDir(), store.User{ID: uid, Username: "alice"})
c.Cfg.Limits.WriteRate = -1
c.TokenID = parent.ID
if code := Dispatch(c, []string{"token", "create", "--name", "child"}); code != protocol.ExitOK {
t.Fatalf("exit %d: %s", code, errOut)
}
toks, err := st.ListAPITokens(uid)
if err != nil {
t.Fatal(err)
}
for _, tk := range toks {
if tk.Name == "child" && (tk.Scope != "read" || tk.CreatedBy != "parent") {
t.Fatalf("child: %+v", tk)
}
}
}
- Step 2: Run them and see them fail
Run: go test ./internal/control -run 'TestMintingCommandsMarked|TestExpiringCredentialCannotMint|TestTokenCreateDefaultsToRead' -count=1
Expected: FAIL to compile, unknown field MintsCredential.
- Step 3:
Ctx,Command,Dispatch
In Ctx, after Source string:
// TokenID is the API token behind this request, 0 for none. A
// credential the request creates records it.
TokenID int64
// Expires is when the credential behind this request lapses; nil
// when it does not. Dispatch refuses MintsCredential commands when
// it is set.
Expires *time.Time
In Command, after ReadOnly:
// MintsCredential marks a command that creates a credential or a way
// to obtain one: tokens, keys, login links, invites, accounts,
// verified addresses. An expiring credential may not run it.
MintsCredential bool
In Dispatch, after the c.ReadOnly && !cmd.ReadOnly check:
// What an expiring credential creates would outlive it (#257).
if cmd.MintsCredential && c.Expires != nil {
return c.fail(protocol.ExitDenied,
"%s creates a credential, and the one this request came with expires; use a token or key without an expiry", joinPath(cmd.Path))
}
- Step 4: Mark the nine commands
Add 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:
register(Command{Path: []string{"web", "login"},
Summary: "mint a one-time browser login URL",
Usage: "web login",
MintsCredential: true,
Examples: []string{"web login"}, Run: runWebLogin})
- Step 5: The API passes the token
In internal/httpd/api.go, apiAuth returns the token:
// apiAuth resolves the bearer token; failures are uniform 401s.
func (s *Server) apiAuth(w http.ResponseWriter, r *http.Request) (store.User, store.APIToken, bool) {
token, ok := strings.CutPrefix(r.Header.Get("Authorization"), "Bearer ")
if !ok || token == "" {
w.Header().Set("WWW-Authenticate", `Bearer realm="gitbay api"`)
apiError(w, http.StatusUnauthorized, "missing bearer token; mint one over SSH: token create --name <n>")
return store.User{}, store.APIToken{}, false
}
user, tok, err := s.st.APITokenUser(store.HashToken(strings.TrimSpace(token)))
if err != nil {
if errors.Is(err, store.ErrNotFound) {
apiError(w, http.StatusUnauthorized, "invalid or expired token")
return store.User{}, store.APIToken{}, false
}
apiError(w, http.StatusInternalServerError, "internal error")
return store.User{}, store.APIToken{}, false
}
return user, tok, true
}
In apiCmd: user, tok, ok := s.apiAuth(w, r), and in the Ctx literal replace ReadOnly: scope == "read", with:
ReadOnly: tok.Scope == "read",
TokenID: tok.ID,
Expires: tok.ExpiresAt,
apiRead keeps user, _, ok := s.apiAuth(w, r); it compiles unchanged.
- Step 6: Run the tests
Run: go test ./internal/control -run 'TestMintingCommandsMarked|TestExpiringCredentialCannotMint' -count=1
Expected: PASS. TestTokenCreateDefaultsToRead... still fails until Task 2.3.
- Step 7: Commit
git add internal/control/control.go internal/control/token_test.go internal/control/*.go internal/httpd/api.go
git commit -S -m "control: expiring credentials cannot run credential-minting commands
Ref #257"
Task 2.3: commands record their token; token create defaults to read; token revoke --created
Files:
- Modify:
internal/control/token.go(registrations 16-34,runTokenCreate49-88,runTokenList90-122,runTokenRevoke124-137) - Modify:
internal/control/identity.go—runKeysList(76-100),runKeysAdd(152) - Modify:
internal/control/deploykey.go:71,internal/control/runnerrepo.go:60,internal/control/adminhost.go:125 - Modify:
e2e/api_test.go:50,:266-282(mintToken)
Interfaces:
-
Consumes:
Ctx.TokenID,store.KeyOrigin,Store.AddSSHKeyFrom,Store.RevokeAPIToken(Tasks 2.1–2.2). -
Step 1:
token create
Registration:
register(Command{Path: []string{"token", "create"},
Summary: "mint an API token (shown once)",
Usage: "token create --name <n> [--scope read|full] [--ttl 30d|720h]",
Flags: []Flag{
{"--name", "<n>", "the token's name", ""},
{"--scope", "read|full", "what the token may do; full is needed to change anything", "read"},
{"--ttl", "30d|720h", "how long the token is valid; an expiring token cannot mint credentials", "never expires"},
},
Examples: []string{"token create --name laptop --ttl 30d", "token create --name phone --scope full"},
MintsCredential: true,
Run: runTokenCreate})
In runTokenCreate: the parseFlags usage becomes Usage: c.Cmd.Usage; name, scope, ttl := f.Value("--name"), "read", f.Value("--ttl"); the store call:
if err := c.Store.CreateAPIToken(c.User.ID, name, store.HashToken(token), scope, expires, c.TokenID); err != nil {
- Step 2:
token listshows the creator in JSON
In 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.
- Step 3:
token revoke [--created]
Registration:
register(Command{Path: []string{"token", "revoke"},
Summary: "revoke an API token by name",
Usage: "token revoke <name> [--created]",
Flags: []Flag{
{"--created", "", "also revoke the tokens and keys it created, at any depth", ""},
},
Examples: []string{"token revoke laptop", "token revoke laptop --created"},
Run: runTokenRevoke})
func runTokenRevoke(c *Ctx, args []string) int {
f, err := parseFlags(args, flagSpec{Bools: []string{"--created"}, MaxPos: 1, Usage: c.Cmd.Usage})
if err != nil {
return c.fail(protocol.ExitUsage, "%v", err)
}
name := f.pos(0)
if name == "" {
return c.usage()
}
withCreated := f.Has("--created")
created, err := c.Store.RevokeAPIToken(c.User.ID, name, withCreated)
if err != nil {
if errors.Is(err, store.ErrNotFound) {
return c.fail(protocol.ExitNotFound, "no token named %q", name)
}
return c.fail(protocol.ExitFailure, "%v", err)
}
type out struct {
Revoked string `json:"revoked"`
Created store.Created `json:"created"`
CreatedRevoked bool `json:"created_revoked"`
}
d := out{name, created, withCreated}
return c.emit(d, func(w io.Writer) {
fmt.Fprintf(w, "revoked %s\n", name)
if len(created.Tokens)+len(created.Keys) == 0 {
return
}
if withCreated {
fmt.Fprintln(w, "and what it created:")
} else {
fmt.Fprintln(w, "it created these, still in place:")
}
for _, n := range created.Tokens {
fmt.Fprintf(w, " token %s\n", n)
}
for _, fp := range created.Keys {
fmt.Fprintf(w, " key %s\n", fp)
}
})
}
- Step 4: Key-adding commands record the token
identity.go, runKeysAdd:
if err := c.Store.AddSSHKeyFrom(c.User.ID, fp, pub.Type(), pub.Marshal(), scope, label, store.KeyOrigin{CreatedByToken: c.TokenID}); err != nil {
deploykey.go:71:
if err := c.Store.AddSSHKeyFrom(c.User.ID, fp, pub.Type(), pub.Marshal(), scope, label, store.KeyOrigin{CreatedByToken: c.TokenID}); err != nil {
runnerrepo.go:60:
if err := c.Store.AddSSHKeyFrom(c.User.ID, fp, pub.Type(), pub.Marshal(), "runner", label, store.KeyOrigin{CreatedByToken: c.TokenID}); err != nil {
adminhost.go:125:
if err := c.Store.AddSSHKeyFrom(uid, fp, pub.Type(), pub.Marshal(), "full", label, store.KeyOrigin{CreatedByToken: c.TokenID}); err != nil {
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.
- Step 5: e2e callers that write with a default-scope token
e2e/api_test.go:50: "token", "create", "--name", "ci", "--scope", "full", "--json".
mintToken (e2e/api_test.go:268): "token", "create", "--name", name, "--scope", "full", "--json".
Then grep -rn '"token", "create"' e2e and check each remaining call: a token only used for reads, or for a refusal, needs nothing.
- Step 6: Run the tests
Run: go build ./... && go vet ./... && go test ./internal/control ./internal/store ./internal/httpd -count=1
Expected: PASS, including TestHelpIsComplete (every flag in the usage is described) and TestTokenCreateDefaultsToReadAndRecordsCreator.
- Step 7: Commit
git add internal/control e2e/api_test.go
git commit -S -m "token: default --scope read; record the creating token; revoke --created
Ref #257"
Task 2.4: e2e — delegation over the API
Files:
- Create:
e2e/tokenorigin_test.go
Interfaces:
-
Consumes:
fingerprint(MR 1,e2e/revoke_test.go),inst.apiCall. -
Step 1: Write the test
package e2e
import (
"encoding/json"
"fmt"
"os"
"strings"
"testing"
)
// An expiring token cannot mint a credential that outlives it, and
// revoking a token can take what it created with it (#257).
func TestTokenDelegation(t *testing.T) {
t.Parallel()
inst := startInstanceWith(t, "[api]\nenabled = true\n")
aliceKey := inst.newKey(t, "alice")
inst.admin(t, "admin", "user", "create", "alice", "--key", aliceKey+".pub")
mint := func(args ...string) (token, scope string) {
t.Helper()
out, errOut, code := inst.ssh(t, aliceKey, "", append([]string{"token", "create", "--json"}, args...)...)
if code != 0 {
t.Fatalf("token create %v: %s", args, errOut)
}
var env struct {
Data struct {
Token string `json:"token"`
Scope string `json:"scope"`
} `json:"data"`
}
if err := json.Unmarshal([]byte(out), &env); err != nil {
t.Fatalf("token create output: %v %s", err, out)
}
return env.Data.Token, env.Data.Scope
}
if _, scope := mint("--name", "plain"); scope != "read" {
t.Fatalf("default scope %q, want read", scope)
}
brief, _ := mint("--name", "brief", "--scope", "full", "--ttl", "1h")
lasting, _ := mint("--name", "lasting", "--scope", "full")
spare := inst.newKey(t, "spare")
pub, err := os.ReadFile(spare + ".pub")
if err != nil {
t.Fatal(err)
}
status, body := inst.apiCall(t, brief, []string{"keys", "add"}, string(pub))
if status != 403 || !strings.Contains(fmt.Sprint(body["error"]), "expires") {
t.Fatalf("expiring token added a key: %d %v", status, body)
}
if status, _ := inst.apiCall(t, brief, []string{"whoami"}, ""); status != 200 {
t.Fatalf("expiring token refused a read: %d", status)
}
if status, body := inst.apiCall(t, lasting, []string{"keys", "add"}, string(pub)); status != 200 {
t.Fatalf("keys add: %d %v", status, body)
}
if status, body := inst.apiCall(t, lasting, []string{"token", "create", "--name", "child"}, ""); status != 200 {
t.Fatalf("token create: %d %v", status, body)
}
if _, errOut, code := inst.ssh(t, spare, "", "whoami"); code != 0 {
t.Fatalf("the added key does not work: %s", errOut)
}
out, errOut, code := inst.ssh(t, aliceKey, "", "token", "revoke", "lasting", "--created")
if code != 0 || !strings.Contains(out, "token child") || !strings.Contains(out, fingerprint(t, spare+".pub")) {
t.Fatalf("revoke --created: exit %d\n%s%s", code, out, errOut)
}
if _, _, code := inst.ssh(t, spare, "", "whoami"); code == 0 {
t.Fatal("a key the revoked token created still works")
}
if out, _, _ := inst.ssh(t, aliceKey, "", "token", "list"); strings.Contains(out, "child") {
t.Fatalf("the child token survived:\n%s", out)
}
}
- Step 2: Run it
Run: go test ./e2e -run TestTokenDelegation -count=1
Expected: PASS.
- Step 3: Commit
git add e2e/tokenorigin_test.go
git commit -S -m "e2e: expiring tokens refused on minting; revoke --created
Ref #257"
Task 2.5: docs and release note
Files:
-
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 -
Step 1: Edit the pages
API.org, the Tokens section's first paragraph and code block become:
Tokens are minted wherever the registry is reached: over SSH, on the
API, anywhere. =token create= makes a =read= token unless =--scope full=
is given; a read token runs only commands marked read-only. A full-scope
token can mint another, but a token with a =--ttl= cannot run any
command that creates a credential — =token create=, =keys add=,
=repo deploy-key add=, =repo runner add=, =web login=, =admin invite=,
=admin user create=, =email verify=, =admin email verify= — since what
it made would outlive it. Give a token the narrowest scope and shortest
TTL that does its job, and revoke it when the job is over.
#+begin_src sh
gitbay auth token create --name ci [--scope read|full] [--ttl 30d]
gitbay auth token list
gitbay auth token revoke ci [--created]
#+end_src
Tokens and keys record the token they were created through. =token
revoke= prints what the token created, at any depth; with =--created=
it revokes those too, and their SSH connections close. Without it they
stay and the link is dropped.
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."
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]=.
09-Controls.org:
| Delegation bounded by the delegating credential | in place | expiring tokens refused on =MintsCredential= commands; credentials record their creating token (=internal/control/control.go=) |
10-Known-Gaps.org: delete the #257 row and the "#257 refuses credential creation ..." sentence, leaving the paragraph out if nothing remains in it.
Parity.org, after the API token mint row:
| API token revoke with what it created | yes | no | no |
account.html:194: gitbay auth token create --name laptop # API tokens, read-only unless --scope full.
CHANGELOG.org, under the unreleased heading (see Global constraints):
*Upgrade note.* =token create= makes a =read= token unless given
=--scope full=. A script that mints a token and then writes with it
must add =--scope full=. Existing tokens keep their scope.
- A token with a =--ttl= is refused on every command that creates a
credential: tokens, keys, deploy keys, runner keys, login links,
invites, accounts and verified addresses (#257).
- Tokens and SSH keys record the token they were created through.
=token revoke <name>= lists what it created; =--created= revokes
those too.
- Removing an SSH key, a deploy key, or disabling an account closes the
connections the key opened, a push in flight included (#256).
(The #256 line belongs to MR 1's changes; add it here if MR 1 did not touch the changelog.)
- Step 2: Commit, open the MR
git add .gitbay/wiki CHANGELOG.org internal/web/templates/account.html
git commit -S -m "wiki: token delegation, read default; release note
Closes #257"
git push -u origin token-delegation
gitbay mr create --source token-delegation --target main --title "token: expiring tokens cannot mint credentials; record creator; default read scope"
MR 3: optional expiry for SSH and deploy keys (branch key-expiry, #277)
The issue says to consider this with #257. An expiring key is treated
like an expiring token: Exec sets Ctx.Expires, so Dispatch refuses
the minting commands to it (open question 1).
Task 3.1: migration 0061 and the store
Files:
- Create:
internal/store/migrations/0061_ssh_key_expiry.up.sql,.down.sql - Modify:
internal/store/users.go—SSHKey,KeyOrigin,AddSSHKeyFrom,SSHKeyByFingerprint(324-333),SSHKeyByID(502-511),ListSSHKeys,ListDeployKeys(514-532) - Modify:
internal/store/revoke.go—LiveSSHKeys - Test:
internal/store/keyexpiry_test.go(create)
Interfaces:
-
Produces:
SSHKey.ExpiresAt *time.Time— nil when the key never expires; filled by every key query.func (k SSHKey) Expired(now time.Time) boolKeyOrigin.ExpiresAt *time.TimeLiveSSHKeysalso excludes expired keys.
-
Step 1: Write the failing test
package store
import (
"testing"
"time"
)
func TestKeyExpiry(t *testing.T) {
s, uid, _ := revokeFixture(t)
past, future := time.Now().Add(-time.Minute), time.Now().Add(time.Hour)
for fp, exp := range map[string]*time.Time{"SHA256:old": &past, "SHA256:new": &future, "SHA256:ever": nil} {
if err := s.AddSSHKeyFrom(uid, fp, "ssh-ed25519", []byte(fp), "full", "", KeyOrigin{ExpiresAt: exp}); err != nil {
t.Fatal(err)
}
}
now := time.Now()
ids := map[string]int64{}
for _, fp := range []string{"SHA256:old", "SHA256:new", "SHA256:ever"} {
k, err := s.SSHKeyByFingerprint(fp)
if err != nil {
t.Fatal(err)
}
ids[fp] = k.ID
byID, err := s.SSHKeyByID(k.ID)
if err != nil || (byID.ExpiresAt == nil) != (k.ExpiresAt == nil) {
t.Fatalf("%s by id: %+v %v", fp, byID, err)
}
if got, want := k.Expired(now), fp == "SHA256:old"; got != want {
t.Errorf("%s Expired = %v, want %v", fp, got, want)
}
}
live, err := s.LiveSSHKeys([]int64{ids["SHA256:old"], ids["SHA256:new"], ids["SHA256:ever"]})
if err != nil {
t.Fatal(err)
}
if live[ids["SHA256:old"]] || !live[ids["SHA256:new"]] || !live[ids["SHA256:ever"]] {
t.Fatalf("live = %v", live)
}
keys, err := s.ListSSHKeys(uid)
if err != nil || len(keys) != 3 || keys[2].ExpiresAt != nil {
t.Fatalf("list: %+v %v", keys, err)
}
}
- Step 2: Run it and see it fail
Run: go test ./internal/store -run TestKeyExpiry -count=1
Expected: FAIL to compile, unknown field ExpiresAt in struct literal of type KeyOrigin.
- Step 3: Migration
0061_ssh_key_expiry.up.sql:
-- When the key stops authenticating; NULL for never.
ALTER TABLE ssh_keys ADD COLUMN expires_at TEXT;
0061_ssh_key_expiry.down.sql:
ALTER TABLE ssh_keys DROP COLUMN expires_at;
- Step 4: Store
SSHKey gains, after CreatedBy:
ExpiresAt *time.Time // nil when the key never expires
and the method:
// Expired reports whether the key has lapsed at now.
func (k SSHKey) Expired(now time.Time) bool {
return k.ExpiresAt != nil && !k.ExpiresAt.After(now)
}
KeyOrigin:
// KeyOrigin is how a key came to be.
type KeyOrigin struct {
CreatedByToken int64 // the API token that added it; 0 for none
ExpiresAt *time.Time // when it stops authenticating; nil for never
}
AddSSHKeyFrom's insert:
var exp any
if o.ExpiresAt != nil {
exp = fmtTime(*o.ExpiresAt)
}
if _, err := tx.Exec(
"INSERT INTO ssh_keys (user_id, fingerprint, algo, blob, scope, label, created_by_token, expires_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
userID, fingerprint, algo, blob, scope, label, nullID(o.CreatedByToken), exp); err != nil {
SSHKeyByFingerprint and SSHKeyByID select expires_at last and scan it through a sql.NullString:
func (s *Store) SSHKeyByFingerprint(fingerprint string) (SSHKey, error) {
var k SSHKey
var exp sql.NullString
err := s.DB.QueryRow(
"SELECT id, user_id, fingerprint, algo, blob, scope, label, expires_at FROM ssh_keys WHERE fingerprint = ?",
fingerprint).Scan(&k.ID, &k.UserID, &k.Fingerprint, &k.Algo, &k.Blob, &k.Scope, &k.Label, &exp)
if errors.Is(err, sql.ErrNoRows) {
return k, ErrNotFound
}
k.ExpiresAt = parseTime(exp)
return k, err
}
SSHKeyByID is the same with WHERE id = ? and id.
ListSSHKeys: add k.expires_at after COALESCE(t.name, ''), scan into var exp sql.NullString declared per row, then k.ExpiresAt = parseTime(exp).
ListDeployKeys:
func (s *Store) ListDeployKeys(repoID int64) ([]SSHKey, error) {
rows, err := s.DB.Query(
`SELECT id, user_id, fingerprint, algo, blob, scope, label, COALESCE(last_used_at, ''), expires_at
FROM ssh_keys WHERE scope LIKE 'deploy:' || ? || ':%' ORDER BY id`,
repoID)
if err != nil {
return nil, err
}
defer rows.Close()
var keys []SSHKey
for rows.Next() {
var k SSHKey
var exp sql.NullString
if err := rows.Scan(&k.ID, &k.UserID, &k.Fingerprint, &k.Algo, &k.Blob, &k.Scope, &k.Label, &k.LastUsedAt, &exp); err != nil {
return nil, err
}
k.ExpiresAt = parseTime(exp)
keys = append(keys, k)
}
return keys, rows.Err()
}
LiveSSHKeys in revoke.go:
// LiveSSHKeys reports which of ids still name a registered, unexpired
// key on an account that is not disabled.
func (s *Store) LiveSSHKeys(ids []int64) (map[int64]bool, error) {
live := map[int64]bool{}
if len(ids) == 0 {
return live, nil
}
args := []any{fmtTime(time.Now())}
for _, id := range ids {
args = append(args, id)
}
rows, err := s.DB.Query(`SELECT k.id FROM ssh_keys k JOIN users u ON u.id = k.user_id
WHERE u.disabled = 0 AND (k.expires_at IS NULL OR k.expires_at > ?)
AND k.id IN (?`+strings.Repeat(", ?", len(ids)-1)+`)`, args...)
if err != nil {
return nil, err
}
defer rows.Close()
for rows.Next() {
var id int64
if err := rows.Scan(&id); err != nil {
return nil, err
}
live[id] = true
}
return live, rows.Err()
}
Add "time" to revoke.go's imports.
- Step 5: Run the tests
Run: go test ./internal/store -count=1
Expected: PASS.
- Step 6: Commit
git add internal/store
git commit -S -m "store: ssh_keys.expires_at; expired keys are not live
Ref #277"
Task 3.2: expired keys refused at authentication, per exec, and in system mode
Files:
- Modify:
internal/sshd/sshd.go—authenticate(after line 148),runExec,Exec(theCtxliteral) - Modify:
cmd/gitbayd/system.go:45-48,:80-84 - Test:
internal/sshd/revoke_test.go(append)
Interfaces:
-
Consumes:
SSHKey.Expired,SSHKey.ExpiresAt(Task 3.1);Ctx.Expires(MR 2);newTestServer,execStatus,waitClosed(MR 1). -
Step 1: Write the failing tests
Append to internal/sshd/revoke_test.go:
// A key that expires while connected: the next exec is refused, and
// the sweep closes the connection.
func TestExpiredKeyRefusedAndCut(t *testing.T) {
ts := newTestServer(t)
past := time.Now().Add(-time.Second).UTC().Format("2006-01-02T15:04:05.000Z")
if _, err := ts.st.DB.Exec("UPDATE ssh_keys SET expires_at = ? WHERE id = ?", past, ts.keyID); err != nil {
t.Fatal(err)
}
if code, errOut := execStatus(ts.client, "whoami"); code != 4 || !strings.Contains(errOut, "expired") {
t.Fatalf("whoami with an expired key: %d %q", code, errOut)
}
ts.srv.sweepOnce()
waitClosed(t, ts.client)
}
// An expiring key may not mint.
func TestExpiringKeyCannotMint(t *testing.T) {
ts := newTestServer(t)
future := time.Now().Add(time.Hour).UTC().Format("2006-01-02T15:04:05.000Z")
if _, err := ts.st.DB.Exec("UPDATE ssh_keys SET expires_at = ? WHERE id = ?", future, ts.keyID); err != nil {
t.Fatal(err)
}
if code, errOut := execStatus(ts.client, "token create --name x"); code != 4 || !strings.Contains(errOut, "expires") {
t.Fatalf("token create with an expiring key: %d %q", code, errOut)
}
}
And a handshake test, which needs its own key: add to revoke_test.go
func TestExpiredKeyRefusedAtAuth(t *testing.T) {
ts := newTestServer(t)
_, priv, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
t.Fatal(err)
}
signer, err := ssh.NewSignerFromKey(priv)
if err != nil {
t.Fatal(err)
}
pub := signer.PublicKey()
past := time.Now().Add(-time.Minute)
if err := ts.st.AddSSHKeyFrom(ts.uid, ssh.FingerprintSHA256(pub), pub.Type(), pub.Marshal(), "full", "", store.KeyOrigin{ExpiresAt: &past}); err != nil {
t.Fatal(err)
}
_, err = ssh.Dial("tcp", ts.client.RemoteAddr().String(), &ssh.ClientConfig{
User: "git",
Auth: []ssh.AuthMethod{ssh.PublicKeys(signer)},
HostKeyCallback: ssh.InsecureIgnoreHostKey(),
Timeout: 5 * time.Second,
})
if err == nil {
t.Fatal("an expired key authenticated")
}
}
with "crypto/ed25519", "crypto/rand" and "gitbay.org/gitbay/internal/store" in the imports.
- Step 2: Run them and see them fail
Run: go test ./internal/sshd -run 'TestExpiredKey|TestExpiringKeyCannotMint' -count=1
Expected: FAIL: the expired key authenticates and whoami exits 0.
- Step 3: sshd
In authenticate, after the if err != nil { ... } block that handles unknown keys and before s.authLimiter.success(ip):
if key.Expired(time.Now()) {
s.st.Audit(key.UserID, "auth.expired", map[string]any{"ip": ip, "fingerprint": fp})
return nil, fmt.Errorf("key %s has expired", fp)
}
In runExec, after the lookup's error handling and before UserByID:
if key.Expired(time.Now()) {
fmt.Fprintln(ch.Stderr(), "this key has expired; remove it and add a new one")
return protocol.ExitDenied
}
In Exec, the Ctx literal gains Expires: key.ExpiresAt,.
- Step 4: System mode
cmd/gitbayd/system.go, authorized-keys:
key, err := st.SSHKeyByFingerprint(ssh.FingerprintSHA256(pub))
if err != nil || key.Expired(time.Now()) {
return nil // unknown or expired key: no output, auth fails
}
shell, after the SSHKeyByID error check:
if key.Expired(time.Now()) {
fmt.Fprintln(os.Stderr, "this key has expired; remove it and add a new one")
os.Exit(protocol.ExitDenied)
}
Add "time" to the imports.
- Step 5: Run the tests
Run: go build ./... && go test ./internal/sshd -count=1
Expected: PASS.
- Step 6: Commit
git add internal/sshd cmd/gitbayd/system.go
git commit -S -m "sshd: refuse expired keys at auth and per exec; expiring keys cannot mint
Ref #277"
Task 3.3: --ttl on keys add and repo deploy-key add; lists show last use and expiry
Files:
- Modify:
internal/control/token.go(addttlFlagafterparseTTL) - Modify:
internal/control/identity.go—keys addregistration (33-44),runKeysList,runKeysAdd - Modify:
internal/control/deploykey.go— registration (16-23),runDeployKeyAdd(36-80),runDeployKeyList(82-115) - Modify:
e2e/ssh_test.go:267,:276 - Test:
internal/control/keyexpiry_test.go(create)
Interfaces:
-
Produces:
func (c *Ctx) ttlFlag(f flags) (*time.Time, int)— nil when--ttlis absent; code -1 when the caller may go on.func (c *Ctx) usedText(ts string) string,func expiresText(t *time.Time, now time.Time) string
-
Step 1: Write the failing test
internal/control/keyexpiry_test.go:
package control
import (
"bytes"
"crypto/ed25519"
"crypto/rand"
"strings"
"testing"
"time"
"golang.org/x/crypto/ssh"
"gitbay.org/gitbay/internal/protocol"
"gitbay.org/gitbay/internal/store"
)
// authorizedKey is a fresh public key as an authorized_keys line.
func authorizedKey(t *testing.T, comment string) string {
t.Helper()
pub, _, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
t.Fatal(err)
}
sp, err := ssh.NewPublicKey(pub)
if err != nil {
t.Fatal(err)
}
return strings.TrimSpace(string(ssh.MarshalAuthorizedKey(sp))) + " " + comment + "\n"
}
func TestKeysAddTTLAndList(t *testing.T) {
st, repo, uid := newQueueTestRepo(t)
user := store.User{ID: uid, Username: "alice"}
run := func(stdin string, argv ...string) (string, string, int) {
c, errOut := pruneCtx(st, t.TempDir(), user)
c.Cfg.Limits.WriteRate = -1
c.Stdin = strings.NewReader(stdin)
code := Dispatch(c, argv)
return c.Stdout.(*bytes.Buffer).String(), errOut.String(), code
}
if _, errOut, code := run(authorizedKey(t, "laptop"), "keys", "add", "--ttl", "1h"); code != protocol.ExitOK {
t.Fatalf("keys add --ttl: %d %s", code, errOut)
}
if _, errOut, code := run(authorizedKey(t, "ci"), "repo", "deploy-key", "add", repo.Path(), "--ttl", "2d"); code != protocol.ExitOK {
t.Fatalf("deploy-key add --ttl: %d %s", code, errOut)
}
if _, _, code := run(authorizedKey(t, "x"), "keys", "add", "--ttl", "soon"); code != protocol.ExitUsage {
t.Fatalf("bad ttl: exit %d", code)
}
keys, err := st.ListSSHKeys(uid)
if err != nil || len(keys) != 2 {
t.Fatalf("keys: %+v %v", keys, err)
}
for _, k := range keys {
if k.ExpiresAt == nil || k.ExpiresAt.Before(time.Now()) || k.ExpiresAt.After(time.Now().Add(49*time.Hour)) {
t.Errorf("%s expires %v", k.Label, k.ExpiresAt)
}
}
out, _, _ := run("", "keys", "list")
if !strings.Contains(out, "\tlaptop\tnever used\texpires ") {
t.Fatalf("keys list:\n%s", out)
}
out, _, _ = run("", "repo", "deploy-key", "list", repo.Path())
if !strings.Contains(out, "\tci\tnever used\texpires ") {
t.Fatalf("deploy-key list:\n%s", out)
}
}
- Step 2: Run it and see it fail
Run: go test ./internal/control -run TestKeysAddTTLAndList -count=1
Expected: FAIL, keys add --ttl exits 2 (unknown flag).
- Step 3: Helpers
In internal/control/token.go after parseTTL:
// ttlFlag reads --ttl as an expiry; nil when the flag is absent. The
// code is -1 when the caller may go on.
func (c *Ctx) ttlFlag(f flags) (*time.Time, int) {
if !f.Has("--ttl") {
return nil, -1
}
d, err := parseTTL(f.Value("--ttl"))
if err != nil || d <= 0 {
return nil, c.fail(protocol.ExitUsage, "bad ttl %q: give a duration such as 30d or 720h", f.Value("--ttl"))
}
t := time.Now().Add(d)
return &t, -1
}
In internal/control/identity.go after keyLabel:
// usedText is a key's last use as a list shows it.
func (c *Ctx) usedText(ts string) string {
switch {
case ts == "":
return "never used"
case c.Term.Cols == 0:
return "used " + stamp(ts)
}
return "used " + relAge(ts, termNow())
}
// expiresText is a credential's expiry as a list shows it. It is
// absolute at a terminal too: relAge reads only the past.
func expiresText(t *time.Time, now time.Time) string {
if t == nil {
return "never expires"
}
s := stamp(t.UTC().Format(time.RFC3339Nano))
if !t.After(now) {
return "expired " + s
}
return "expires " + s
}
Add "time" to identity.go's imports.
- Step 4:
keys add --ttl
Registration:
register(Command{
Path: []string{"keys", "add"},
Summary: "register an SSH public key (authorized_keys format)",
Usage: "keys add [--scope full|git|runner] [--label <text>] [--ttl 30d|720h] < key.pub",
Flags: []Flag{
{"--scope", "full|git|runner", "what the key may do", "full"},
{"--label", "<text>", "a name for the key", ""},
{"--ttl", "30d|720h", "how long the key authenticates; an expiring key cannot mint credentials", "never expires"},
},
Examples: []string{"keys add --label laptop < key.pub", "keys add --scope git --ttl 90d < ci.pub"},
ReadsStdin: true,
MintsCredential: true,
Run: runKeysAdd,
})
In runKeysAdd: parseFlags(args, flagSpec{Values: []string{"--scope", "--label", "--ttl"}, MaxPos: 0, Usage: c.Cmd.Usage}); after the scope check:
expires, code := c.ttlFlag(f)
if code >= 0 {
return code
}
the store call:
if err := c.Store.AddSSHKeyFrom(c.User.ID, fp, pub.Type(), pub.Marshal(), scope, label, store.KeyOrigin{CreatedByToken: c.TokenID, ExpiresAt: expires}); err != nil {
and the output:
type out struct {
Fingerprint string `json:"fingerprint"`
Scope string `json:"scope"`
Label string `json:"label"`
ExpiresAt *time.Time `json:"expires_at,omitempty"`
}
d := out{fp, scope, label, expires}
return c.emit(d, func(w io.Writer) {
line := fmt.Sprintf("added %s (%s)", d.Fingerprint, d.Scope)
if d.Label != "" {
line += " " + d.Label
}
if d.ExpiresAt != nil {
line += ", " + expiresText(d.ExpiresAt, time.Now())
}
fmt.Fprintln(w, line)
})
- Step 5:
keys listcolumns
type out struct {
Fingerprint string `json:"fingerprint"`
Algo string `json:"algo"`
Scope string `json:"scope"`
Label string `json:"label"`
CreatedBy string `json:"created_by,omitempty"`
LastUsedAt string `json:"last_used_at,omitempty"`
ExpiresAt *time.Time `json:"expires_at,omitempty"`
}
var ds []out
for _, k := range keys {
ds = append(ds, out{k.Fingerprint, k.Algo, k.Scope, k.Label, k.CreatedBy, k.LastUsedAt, k.ExpiresAt})
}
now := time.Now()
return c.emit(ds, func(w io.Writer) {
tb := c.table(w, "FINGERPRINT", "ALGO", "SCOPE", "LABEL", "USED", "EXPIRES")
for _, d := range ds {
tb.row(cFlex(d.Fingerprint), cText(d.Algo), cState(d.Scope), cText(d.Label),
cText(c.usedText(d.LastUsedAt)), cText(expiresText(d.ExpiresAt, now)))
}
tb.flush()
})
- Step 6:
repo deploy-key add --ttl, list columns
Registration:
register(Command{Path: []string{"repo", "deploy-key", "add"},
Summary: "bind a read-only (or --rw) key to one repository",
Usage: "repo deploy-key add <owner/name> [--rw] [--ttl 30d|720h] < key.pub",
Flags: []Flag{
{"--rw", "", "the key may push, not just fetch", ""},
{"--ttl", "30d|720h", "how long the key authenticates", "never expires"},
},
Examples: []string{"repo deploy-key add krz/gitbay < key.pub", "repo deploy-key add krz/gitbay --ttl 30d < key.pub"},
ReadsStdin: true,
MintsCredential: true,
Run: runDeployKeyAdd})
runDeployKeyAdd, replacing its argument loop (lines 37-52):
func runDeployKeyAdd(c *Ctx, args []string) int {
f, err := parseFlags(args, flagSpec{Values: []string{"--ttl"}, Bools: []string{"--rw"}, MaxPos: 1, Usage: c.Cmd.Usage})
if err != nil {
return c.fail(protocol.ExitUsage, "%v", err)
}
path := f.pos(0)
if path == "" {
return c.usage()
}
mode := "ro"
if f.Has("--rw") {
mode = "rw"
}
expires, code := c.ttlFlag(f)
if code >= 0 {
return code
}
repo, code := resolveRepo(c, path, policy.CanAdmin)
if code >= 0 {
return code
}
The rest is as before except the store call and output:
if err := c.Store.AddSSHKeyFrom(c.User.ID, fp, pub.Type(), pub.Marshal(), scope, label, store.KeyOrigin{CreatedByToken: c.TokenID, ExpiresAt: expires}); err != nil {
if errors.Is(err, store.ErrDuplicateKey) {
return c.failErr(err)
}
return c.fail(protocol.ExitFailure, "%v", err)
}
d := map[string]any{"fingerprint": fp, "mode": mode}
if expires != nil {
d["expires_at"] = expires
}
return c.emit(d, func(w io.Writer) {
line := fmt.Sprintf("deploy key %s (%s) bound to %s", fp, mode, repo.Path())
if expires != nil {
line += ", " + expiresText(expires, time.Now())
}
fmt.Fprintln(w, line)
})
runDeployKeyList:
type out struct {
Fingerprint string `json:"fingerprint"`
Algo string `json:"algo"`
Mode string `json:"mode"`
Label string `json:"label"`
LastUsedAt string `json:"last_used_at,omitempty"`
ExpiresAt *time.Time `json:"expires_at,omitempty"`
}
var ds []out
for _, k := range keys {
mode := "ro"
if policy.DeployScopeAllows(k.Scope, repo.ID, true) {
mode = "rw"
}
ds = append(ds, out{k.Fingerprint, k.Algo, mode, k.Label, k.LastUsedAt, k.ExpiresAt})
}
now := time.Now()
return c.emit(ds, func(w io.Writer) {
tb := c.table(w, "FINGERPRINT", "ALGO", "MODE", "LABEL", "USED", "EXPIRES")
for _, d := range ds {
tb.row(cFlex(d.Fingerprint), cText(d.Algo), cState(d.Mode), cText(d.Label),
cText(c.usedText(d.LastUsedAt)), cText(expiresText(d.ExpiresAt, now)))
}
tb.flush()
})
Add "time" to deploykey.go's imports.
- Step 7: The e2e rows that end at the label
e2e/ssh_test.go:267: if !strings.Contains(out, "\tgit\talice2\t") {
e2e/ssh_test.go:276: if !strings.Contains(out, "\tgit\tbuild box\t") {
- Step 8: Run the tests
Run: go build ./... && go vet ./... && go test ./internal/control ./internal/store ./internal/sshd -count=1 && go test ./e2e -run TestSSHControlPlane -count=1
(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.)
Expected: PASS.
- Step 9: Commit
git add internal/control e2e/ssh_test.go
git commit -S -m "keys: --ttl on keys add and repo deploy-key add; lists show last use and expiry
Ref #277"
Task 3.4: docs
Files:
-
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 -
Step 1: Edit the pages
Users.org, add to the keys code block:
gitbay auth keys add --scope git --ttl 90d < ~/.ssh/ci_key.pub
and after the labels paragraph:
=--ttl 90d= (or any Go duration, =720h=) makes a key stop
authenticating after that long; =repo deploy-key add= takes the same
flag. An expiring key cannot create credentials: tokens, keys, login
links. =keys list= shows when each key was last used and when it
expires, so a key nobody uses is easy to spot.
05-Identity-and-Access.org: SSH user key and deploy key Expiry cells become optional =--ttl=, refused at auth.
09-Controls.org:
| Credential expiry | in place | optional =--ttl= on API tokens, SSH and deploy keys; checked at auth and per exec |
10-Known-Gaps.org: delete the #277 row.
Parity.org, after SSH key label:
| SSH key expiry and last use | yes | no | no |
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".
CHANGELOG.org:
- =keys add= and =repo deploy-key add= take =--ttl=; an expired key is
refused at authentication, and an open connection on it closes within
15 seconds. An expiring key cannot create credentials, like an
expiring token. =keys list= and =repo deploy-key list= gain =USED= and
=EXPIRES= columns, after the label (#277).
- Step 2: Commit, open the MR
git add .gitbay/wiki CHANGELOG.org
git commit -S -m "wiki: key expiry
Closes #277"
git push -u origin key-expiry
gitbay mr create --source key-expiry --target main --title "keys: optional expiry for SSH and deploy keys"
MR 4: idle timeout for browser sessions (branch session-idle, #276)
A session lapses after 12 hours without a request and after seven
days regardless. expires_at holds the sliding expiry, so the auth
query and the retention sweep (expires_at <= ?,
internal/store/retention.go:51) need no change;
absolute_expires_at holds the cap. Renewal writes at most once a
minute per session. The cookie's MaxAge stays seven days.
Task 4.1: migration 0062 and the store
Files:
- Create:
internal/store/migrations/0062_web_session_idle.up.sql,.down.sql - Modify:
internal/store/sessions.go:67-121 - Test:
internal/store/sessions_test.go(append)
Interfaces:
-
Produces:
const WebSessionIdle = 12 * time.HourCreateWebSession(hash string, userID int64, ttl time.Duration) error— unchanged signature;ttlis now the absolute cap.WebSessionUser(hash string) (User, error)— unchanged signature; renews.WebSession.LastUsedAt stringjson:"last_used_at"``
-
Step 1: Write the failing tests
Append to internal/store/sessions_test.go:
func sessionFixture(t *testing.T) (*Store, int64) {
t.Helper()
s := open(t)
if err := s.MigrateUp(); err != nil {
t.Fatal(err)
}
uid, err := s.CreateUser("cmc", false)
if err != nil {
t.Fatal(err)
}
return s, uid
}
func sessionTimes(t *testing.T, s *Store, hash string) (expires, absolute time.Time) {
t.Helper()
var e, a string
if err := s.DB.QueryRow("SELECT expires_at, absolute_expires_at FROM web_sessions WHERE token_hash = ?", hash).Scan(&e, &a); err != nil {
t.Fatal(err)
}
return *parseTime(sql.NullString{String: e, Valid: true}), *parseTime(sql.NullString{String: a, Valid: true})
}
func TestWebSessionIdleExpiry(t *testing.T) {
s, uid := sessionFixture(t)
if err := s.CreateWebSession("h", uid, 7*24*time.Hour); err != nil {
t.Fatal(err)
}
exp, abs := sessionTimes(t, s, "h")
if d := time.Until(exp); d < WebSessionIdle-time.Minute || d > WebSessionIdle {
t.Fatalf("a new session expires in %s, want %s", d, WebSessionIdle)
}
if d := time.Until(abs); d < 7*24*time.Hour-time.Minute {
t.Fatalf("absolute cap in %s", d)
}
// Idle past the window: gone.
old := fmtTime(time.Now().Add(-time.Second))
s.DB.Exec("UPDATE web_sessions SET expires_at = ? WHERE token_hash = 'h'", old)
if _, err := s.WebSessionUser("h"); err != ErrNotFound {
t.Fatalf("idle session: %v", err)
}
}
func TestWebSessionRenewsUpToTheCap(t *testing.T) {
s, uid := sessionFixture(t)
if err := s.CreateWebSession("h", uid, 7*24*time.Hour); err != nil {
t.Fatal(err)
}
// Last used two minutes ago, one minute left: a request renews it.
s.DB.Exec("UPDATE web_sessions SET last_used_at = ?, expires_at = ? WHERE token_hash = 'h'",
fmtTime(time.Now().Add(-2*time.Minute)), fmtTime(time.Now().Add(time.Minute)))
if _, err := s.WebSessionUser("h"); err != nil {
t.Fatal(err)
}
if exp, _ := sessionTimes(t, s, "h"); time.Until(exp) < WebSessionIdle-time.Minute {
t.Fatalf("not renewed: expires in %s", time.Until(exp))
}
// Near the cap, renewal stops at it.
capAt := time.Now().Add(time.Hour)
s.DB.Exec("UPDATE web_sessions SET last_used_at = ?, absolute_expires_at = ? WHERE token_hash = 'h'",
fmtTime(time.Now().Add(-2*time.Minute)), fmtTime(capAt))
if _, err := s.WebSessionUser("h"); err != nil {
t.Fatal(err)
}
if exp, _ := sessionTimes(t, s, "h"); exp.After(capAt) {
t.Fatalf("renewed past the cap: %s > %s", exp, capAt)
}
list, err := s.ListWebSessions(uid)
if err != nil || len(list) != 1 || list[0].LastUsedAt == "" {
t.Fatalf("list: %+v %v", list, err)
}
}
Add "database/sql" to the file's imports.
- Step 2: Run them and see them fail
Run: go test ./internal/store -run 'TestWebSession' -count=1
Expected: FAIL to compile, undefined: WebSessionIdle.
- Step 3: Migration
0062_web_session_idle.up.sql:
-- expires_at slides forward on use, never past absolute_expires_at.
-- Sessions open now keep their cap and get a full idle window from here.
ALTER TABLE web_sessions ADD COLUMN absolute_expires_at TEXT;
ALTER TABLE web_sessions ADD COLUMN last_used_at TEXT;
UPDATE web_sessions SET
absolute_expires_at = expires_at,
last_used_at = strftime('%Y-%m-%dT%H:%M:%fZ','now'),
expires_at = min(expires_at, strftime('%Y-%m-%dT%H:%M:%fZ','now','+12 hours'));
0062_web_session_idle.down.sql:
UPDATE web_sessions SET expires_at = absolute_expires_at;
ALTER TABLE web_sessions DROP COLUMN last_used_at;
ALTER TABLE web_sessions DROP COLUMN absolute_expires_at;
- Step 4: Store
Replace CreateWebSession and WebSessionUser:
// WebSessionIdle is how long a browser session lasts without a request.
// Each use moves its expiry this far ahead, never past the cap it was
// created with. Migration 0062 repeats the value for sessions it
// converts.
const WebSessionIdle = 12 * time.Hour
// CreateWebSession stores a session that lapses after WebSessionIdle
// without use, and after ttl regardless.
func (s *Store) CreateWebSession(hash string, userID int64, ttl time.Duration) error {
now := time.Now()
_, err := s.DB.Exec(
"INSERT INTO web_sessions (token_hash, user_id, expires_at, absolute_expires_at, last_used_at) VALUES (?, ?, ?, ?, ?)",
hash, userID, fmtTime(now.Add(min(ttl, WebSessionIdle))), fmtTime(now.Add(ttl)), fmtTime(now))
return err
}
// WebSessionUser resolves a session cookie hash to its user and renews
// the session's idle expiry. A session is written at most once a
// minute, so a burst of requests costs one UPDATE.
func (s *Store) WebSessionUser(hash string) (User, error) {
now := time.Now()
var userID int64
err := s.DB.QueryRow(
"SELECT user_id FROM web_sessions WHERE token_hash = ? AND expires_at > ?",
hash, fmtTime(now)).Scan(&userID)
if errors.Is(err, sql.ErrNoRows) {
return User{}, ErrNotFound
}
if err != nil {
return User{}, err
}
s.DB.Exec(`UPDATE web_sessions SET last_used_at = ?, expires_at = min(absolute_expires_at, ?)
WHERE token_hash = ? AND last_used_at < ?`,
fmtTime(now), fmtTime(now.Add(WebSessionIdle)), hash, fmtTime(now.Add(-time.Minute)))
return s.UserByID(userID)
}
WebSession and ListWebSessions:
type WebSession struct {
ID string `json:"id"`
CreatedAt string `json:"created_at"`
ExpiresAt string `json:"expires_at"`
LastUsedAt string `json:"last_used_at"`
}
// ListWebSessions lists the user's unexpired browser sessions, newest first.
func (s *Store) ListWebSessions(userID int64) ([]WebSession, error) {
rows, err := s.DB.Query(`SELECT substr(token_hash, 1, 12), created_at, expires_at, COALESCE(last_used_at, created_at)
FROM web_sessions WHERE user_id = ? AND expires_at > ? ORDER BY created_at DESC`,
userID, fmtTime(time.Now()))
if err != nil {
return nil, err
}
defer rows.Close()
var out []WebSession
for rows.Next() {
var ws WebSession
if err := rows.Scan(&ws.ID, &ws.CreatedAt, &ws.ExpiresAt, &ws.LastUsedAt); err != nil {
return nil, err
}
out = append(out, ws)
}
return out, rows.Err()
}
- Step 5: Run the tests
Run: go test ./internal/store -count=1
Expected: PASS, including TestSweepRemovesExpiredSessionsAndTokens (a -time.Hour ttl still makes a dead session).
- Step 6: Commit
git add internal/store
git commit -S -m "store: web sessions lapse after 12 hours idle, under the absolute cap
Ref #276"
Task 4.2: web sessions list shows last use; docs
Files:
-
Modify:
internal/control/web.go:33-54 -
Modify:
internal/httpd/accounts.go:147(comment only) -
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 -
Step 1: The list
return c.emit(sessions, func(w io.Writer) {
tb := c.table(w, "ID", "SINCE", "UNTIL", "USED")
for _, s := range sessions {
since, until, used := s.CreatedAt, s.ExpiresAt, s.LastUsedAt
if c.Term.Cols == 0 {
since, until, used = stamp(since), stamp(until), stamp(used)
} else {
since, until, used = relAge(since, termNow()), relAge(until, termNow()), relAge(used, termNow())
}
tb.row(cRef(s.ID), cText("since "+since), cText("until "+until), cText("used "+used))
}
tb.flush()
})
- Step 2: The call site says what the ttl is
internal/httpd/accounts.go, above line 147:
// Seven days is the cap; the store ends it sooner after
// store.WebSessionIdle without a request.
- Step 3: Run the tests
Run: go build ./... && go test ./internal/control ./internal/httpd -count=1 && go test ./e2e -run TestWebSessionsListRevoke -count=1
Expected: PASS.
- Step 4: Docs
Users.org, the Browser sessions paragraph:
=gitbay web login= mints a one-time URL; the session it opens ends
after twelve hours without a request, and after seven days in any case.
=gitbay web sessions list= shows each of yours by a short id with its
creation, expiry and last use, and =gitbay web sessions revoke <id>=
or =--all= ends them from the terminal, which is where a lost laptop is
handled.
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)".
09-Controls.org:
| Session lifetime | in place | 12 hours idle, 7 days absolute (=internal/store/sessions.go=) |
10-Known-Gaps.org: delete the #276 row.
CHANGELOG.org:
- Browser sessions end after twelve hours without a request, and after
seven days as before. Sessions open at upgrade get a fresh twelve
hours. =web sessions list= shows when each was last used (#276).
- Step 5: Commit, open the MR
git add internal/control/web.go internal/httpd/accounts.go .gitbay/wiki CHANGELOG.org
git commit -S -m "web: sessions list shows last use; docs for the idle timeout
Closes #276"
git push -u origin session-idle
gitbay mr create --source session-idle --target main --title "web: idle timeout for browser sessions"
MR 5: web login over SSH spends the login-link budget (branch weblogin-limit, #278)
Task 5.1: the limit in runWebLogin
Files:
- Modify:
internal/control/web.go:80-99 - Modify:
internal/control/loginlink.go:12-19(comment) - Test:
internal/control/weblogin_test.go(create) - Modify:
.gitbay/wiki/Architecture/10-Known-Gaps.org,CHANGELOG.org
Interfaces:
-
Consumes:
maxLoginLinksPerHour(loginlink.go:19),Store.CountLoginTokensSince. -
Step 1: Write the failing test
package control
import (
"strings"
"testing"
"gitbay.org/gitbay/internal/protocol"
"gitbay.org/gitbay/internal/store"
)
// web login over SSH counts against the same hourly bound as the
// mailed links, since both insert into login_tokens (#278).
func TestWebLoginSharesTheLoginLinkLimit(t *testing.T) {
st, _, uid := newQueueTestRepo(t)
for i := 0; i <= maxLoginLinksPerHour; i++ {
c, errOut := pruneCtx(st, t.TempDir(), store.User{ID: uid, Username: "alice"})
c.Cfg.Web.Mode = "accounts"
c.Cfg.Server.SiteURL = "https://gitbay.test"
c.Cfg.Limits.WriteRate = -1
code := Dispatch(c, []string{"web", "login"})
switch {
case i < maxLoginLinksPerHour && code != protocol.ExitOK:
t.Fatalf("link %d: exit %d %s", i+1, code, errOut)
case i == maxLoginLinksPerHour && (code != protocol.ExitDenied || !strings.Contains(errOut.String(), "login links")):
t.Fatalf("link %d: exit %d %q, want refused", i+1, code, errOut)
}
}
}
- Step 2: Run it and see it fail
Run: go test ./internal/control -run TestWebLoginSharesTheLoginLinkLimit -count=1
Expected: FAIL, the sixth link exits 0.
- Step 3: Implement
In runWebLogin, after the web-mode check:
n, err := c.Store.CountLoginTokensSince(c.User.ID, time.Now().Add(-time.Hour))
if err != nil {
return c.fail(protocol.ExitFailure, "%v", err)
}
if n >= maxLoginLinksPerHour {
return c.fail(protocol.ExitDenied,
"%d login links in the last hour is the most an account gets; use one of those, or wait", maxLoginLinksPerHour)
}
loginlink.go, the comment above maxLoginLinksPerHour:
// maxLoginLinksPerHour bounds what one account's address can be made to
// receive. It matches maxEmailAddsPerHour: enough for a person who mistypes
// and retries, nothing for a script. CountLoginTokensSince counts every row
// in login_tokens, so links minted with "web login" over SSH and links
// mailed from the login page share the budget, and both refuse past it.
- Step 4: Run the tests
Run: go test ./internal/control -count=1
Expected: 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.
- Step 5: Docs
10-Known-Gaps.org: delete the #278 row.
CHANGELOG.org:
- =web login= over SSH refuses a sixth link in an hour, the same bound
the login page's mailed links have (#278).
- Step 6: Commit, open the MR
git add internal/control/web.go internal/control/loginlink.go internal/control/weblogin_test.go .gitbay/wiki CHANGELOG.org
git commit -S -m "web: login over SSH applies the login-link limit
Closes #278"
git push -u origin weblogin-limit
gitbay mr create --source weblogin-limit --target main --title "web login over SSH applies the login-link rate limit"
Decisions (2026-09-28)
- Expiring SSH keys and minting. Confirmed: an expiring key is refused the minting commands like an expiring token (MR 3, Task 3.2).
- Which commands mint. Confirmed: the issue's five plus
repo runner add,admin user create,email verifyandadmin email verify, pinned byTestMintingCommandsMarked. - LFS transfer tokens. Filed as #285; not in this plan.
- Removing the key you are on. Confirmed: the session's own connection is cut after the removal commits.
- Idle window. A constant (
store.WebSessionIdle, 12 h); make it configurable only when someone needs another value.
Documented limits, stated on the Threat-Model page in MR 1:
- With
ssh.mode = "system"each exec is its own forced-command process; the per-exec check applies, a running one is not cut. bay1 runs embedded. - A control command already inside its store write when the key is
revoked finishes the write and its output is lost; git transports are
killed and commands watching
Donestop.
The token list future-expiry display is #286.
Self-review
- Coverage. #256: per-exec re-read (Task 1.3
runExec), git transport session re-read (same path;Execdispatches git), connection tracking and closing on keys remove / deploy-key remove / disable / delete (1.1, 1.3), cancelling running commands and pushes (1.2, 1.3), interrupted receive-pack moves no ref (1.4), e2e with a multiplexed connection (1.4). #257:MintsCredentialchecked inDispatch(2.2), migration recording the creator for tokens and keys (2.1),token revokelisting and revoking with--created(2.1, 2.3), default--scope readand release note (2.3, 2.5), API and Threat-Model pages (2.5). #277:--ttlon both add commands (3.3), enforced at authentication (3.2), last use inkeys list(3.3), considered with #257 (3.2, open question 1), migration designed with 0060 (both nullableADD COLUMN, one insert path viaKeyOrigin). #276: idle timeout with renewal, absolute cap kept, last use listed (4.1, 4.2). #278: limit applied, comment corrected (5.1). - Placeholders. None; every code step carries the code. Two steps
ask the executor to confirm a name with grep (
nullID, thessh_test.gotest name) because they were not unique facts to pin. - Types.
Revoked{KeyIDs []int64; UserID int64},OnRevoke,announce,LiveSSHKeys([]int64) (map[int64]bool, error)are used with those shapes in 1.1, 1.3, 2.1, 3.1.Exec(..., key store.SSHKey, ..., done, stopping, revoked)matches in 1.3, 3.2 andsystem.go.APITokenUserreturns(User, APIToken, error)in 2.1, 2.2 and the tests.KeyOrigin{CreatedByToken, ExpiresAt}is introduced in 2.1 and extended in 3.1.Ctx.TokenID int64,Ctx.Expires *time.Timematch across 2.2, 2.3, 3.2, 3.3.SSHKey.CreatedBy(name) andKeyOrigin.CreatedByToken(id) are distinct on purpose.