Sign in

Search issue titles and bodies, change requests and comments. Private repositories you cannot read are not included.

vole
Clone

Read only, no credentials:

https://rodent.software/git/vole.git

Write over SSH with a registered key:

git clone ssh://git@0.0.0.0:2222/vole.git

A free and open-source forge that lives in your Git repo.

Give a job a credential for the bot it acts as

6e1fca5115b709d31eeb51864b187d79aa104e5f · Keenan Nemetz · 6d ago · parent 446542e6

Give a job a credential for the bot it acts as

A workflow can name a bot account with `bot:`. When the job is leased the
instance generates a key for that run, stores the public half with the run,
repository and expiry, and sends the private half on the lease channel. The
runner writes it into the job's run directory with a known_hosts entry for the
instance, so a step's `git push` or `vole issue comment` authenticates as the
account and nothing has to be wired per job.

The account's role is the permission, in full. The credential replaces exactly one
requirement — that writing to the issues branch needs `maintain` — and adds no rule
of its own. What a job may write there is what its account may do, asked the same
way the browser asks: discuss, file an issue, manage an item it wrote, open a
change from a branch it may push, triage and approve when the role allows it. Those
are the ACL's own capabilities (`CanDiscuss`, `CanOpenRequest`, `CanTriage`,
`CanApprove`), so a maintainer's job may do everything a maintainer's key may do,
including labels, states, and other people's items.

A session is scoped only when the fingerprint it authenticated with is a live
credential row; an expired one reads as absent, so the push falls back to the
account's own role and is refused where a contribute account cannot write.

Two things end the key: the run finishing, which drops the row, and the expiry,
which is what stops a credential whose runner was killed and never reported. The
key is also masked in the log like a secret, since a step that cats it should not
leave it in the attempt.

Closes #165.

server/ssh/credential.go +91

@@ -0,0 +1,91 @@
1 package ssh
2
3 // The key a job acts with.
4 //
5 // A workflow that names `bot: <account>` gets a credential for that account when the
6 // job is leased: an ed25519 pair generated here, the public half stored in the queue
7 // with the run, repository and expiry, and the private half sent on the lease
8 // channel. The runner writes it into the job's run directory, so a step's `git push`
9 // or `vole issue comment` authenticates as the account without the repository
10 // holding a long-lived key.
11 //
12 // Two checks decide whether the job can have it: the account must exist, be active,
13 // and be a bot account. The role it may act with is not decided here — the hook
14 // applies the same role rules to a credential session that it applies to the
15 // account's own key, so a job cannot act as more than its account may.
16
17 import (
18 "crypto/ed25519"
19 "crypto/rand"
20 "encoding/pem"
21 "fmt"
22 "time"
23
24 "github.com/rodent-software/vole/ci/runs"
25 xssh "golang.org/x/crypto/ssh"
26 )
27
28 const (
29 // credentialGrace is how long a run's key remains valid after the job's own
30 // budget ends. A step may need to push its last commit after the last step, and
31 // the runner's finish is what normally ends the key; this is the backstop for a
32 // runner that dies without one.
33 credentialGrace = 30 * time.Minute
34 // credentialMin and credentialMax bound the backstop. A run with no budget gets
35 // the minimum; a long one is still capped, so a key cannot outlive its job by a
36 // working day.
37 credentialMin = time.Hour
38 credentialMax = 6 * time.Hour
39 )
40
41 // mintCredential issues a key for one run and returns the private half and the
42 // instance's host key line. A non-empty reason means the run asked for an account it
43 // cannot have, and the job fails with that line instead of running without a key.
44 func (s *Server) mintCredential(run *runs.Run, now time.Time) (*runnerCredential, string, string) {
45 acct, err := s.cfg.Identity.Find(run.Bot)
46 if err != nil || acct == nil {
47 return nil, "", fmt.Sprintf("this job acts as %s, which is not an account on this instance", run.Bot)
48 }
49 if !acct.Active() {
50 return nil, "", fmt.Sprintf("this job acts as %s, which is retired", run.Bot)
51 }
52 if !acct.Bot() {
53 return nil, "", fmt.Sprintf("this job acts as %s, which is not a bot account", run.Bot)
54 }
55 pub, priv, err := ed25519.GenerateKey(rand.Reader)
56 if err != nil {
57 return nil, "", "cannot generate a credential: " + err.Error()
58 }
59 publicKey, err := xssh.NewPublicKey(pub)
60 if err != nil {
61 return nil, "", "cannot prepare a credential: " + err.Error()
62 }
63 block, err := xssh.MarshalPrivateKey(priv, "vole run "+run.ID)
64 if err != nil {
65 return nil, "", "cannot encode a credential: " + err.Error()
66 }
67 fingerprint := xssh.FingerprintSHA256(publicKey)
68 if err := s.cfg.Runs.SetRunCredential(runs.RunCredential{
69 Fingerprint: fingerprint,
70 Account: run.Bot,
71 Repo: run.Repo,
72 RunID: run.ID,
73 Expires: now.Add(credentialTTL(run.Timeout)),
74 }, now); err != nil {
75 return nil, "", "cannot record a credential: " + err.Error()
76 }
77 return &runnerCredential{
78 PrivateKey: string(pem.EncodeToMemory(block)),
79 Fingerprint: fingerprint,
80 }, s.hostKey, ""
81 }
82
83 // credentialTTL is how long a run's key may live: its own budget plus a grace, so a
84 // step that pushes at the end of the budget is not cut off, bounded at both ends.
85 func credentialTTL(budget string) time.Duration {
86 ttl := credentialMin
87 if d, err := time.ParseDuration(budget); err == nil && d > 0 {
88 ttl = d
89 }
90 return min(ttl+credentialGrace, credentialMax)
91 }