Sign in

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

vole
Clone

Read only, no credentials:

https://rodent.software/vole.git

Write over SSH with a registered key:

git clone ssh://git@rodent.software:2222/vole.git

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

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.

ci/agent/credential.go +75

@@ -0,0 +1,75 @@
1 package agent
2
3 // The environment a job acting as a bot gets.
4 //
5 // The credential is a file in the run directory, and git and the `vole` CLI both
6 // already honour an ssh command, so a step needs no special handling: `git push` and
7 // `vole issue comment` authenticate as the account because the environment points at
8 // the key. The names are reserved in the workflow format, so a workflow cannot
9 // replace them.
10
11 import (
12 "fmt"
13 "net/url"
14 "strings"
15 )
16
17 // RunCredentialPath is where the key is written inside the job's container.
18 const RunCredentialPath = "/run/vole/id_bot"
19
20 // RunKnownHostsPath is where the instance's host key is written.
21 const RunKnownHostsPath = "/run/vole/known_hosts"
22
23 // withCredentialEnv returns a copy of the job's environment with the variables that
24 // point git and the CLI at the run credential. It copies rather than editing the
25 // pinned map, which belongs to the run record.
26 func withCredentialEnv(env map[string]string, remote string, job *runnerJob) (map[string]string, error) {
27 sshCommand, err := credentialSSHCommand(remote)
28 if err != nil {
29 return nil, err
30 }
31 out := make(map[string]string, len(env)+5)
32 for name, value := range env {
33 out[name] = value
34 }
35 out["VOLE_RUN_ID"] = job.ID
36 out["VOLE_REPO"] = job.Repo
37 out["VOLE_SSH_COMMAND"] = sshCommand
38 out["GIT_SSH_COMMAND"] = sshCommand
39 out["VOLE_KNOWN_HOSTS"] = RunKnownHostsPath
40 return out, nil
41 }
42
43 // credentialSSHCommand pins the key and the known_hosts file. IdentitiesOnly keeps
44 // ssh from offering an agent key that happens to be in the container, so the run
45 // credential is the only identity a step can present.
46 func credentialSSHCommand(remote string) (string, error) {
47 if _, _, err := splitRemote(remote); err != nil {
48 return "", err
49 }
50 return "ssh -i " + RunCredentialPath + " -o IdentitiesOnly=yes -o UserKnownHostsFile=" + RunKnownHostsPath, nil
51 }
52
53 // knownHostsLine is the entry that lets the job verify the instance it pushes to.
54 // OpenSSH brackets a host whose port is not the default.
55 func knownHostsLine(remote, hostKey string) (string, error) {
56 host, port, err := splitRemote(remote)
57 if err != nil {
58 return "", err
59 }
60 if strings.TrimSpace(hostKey) == "" {
61 return "", fmt.Errorf("the instance sent no host key with the job, so the credential has nothing to verify it against")
62 }
63 if port == "" || port == "22" {
64 return host + " " + strings.TrimSpace(hostKey) + "\n", nil
65 }
66 return "[" + host + "]:" + port + " " + strings.TrimSpace(hostKey) + "\n", nil
67 }
68
69 func splitRemote(remote string) (host, port string, err error) {
70 u, err := url.Parse(remote)
71 if err != nil || u.Scheme != "ssh" || u.Hostname() == "" {
72 return "", "", fmt.Errorf("%q is not an ssh URL, so the job cannot be told how to reach the instance", remote)
73 }
74 return u.Hostname(), u.Port(), nil
75 }