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/runner.go +37
| @@ -123,6 +123,13 @@ type runnerLogChunk struct { | |||
| 123 | 123 | Data []byte `json:"data"` | |
| 124 | 124 | } | |
| 125 | 125 | ||
| 126 | // runnerCredential is the private half of a run's key. It is delivered once, written | ||
| 127 | // into the job's run directory, and never stored by the instance. | ||
| 128 | type runnerCredential struct { | ||
| 129 | PrivateKey string `json:"private_key"` | ||
| 130 | Fingerprint string `json:"fingerprint"` | ||
| 131 | } | ||
| 132 | |||
| 126 | 133 | // runnerSecret is one delivered value and the version it came from, so the attempt | |
| 127 | 134 | // record can say which value ran without carrying it. | |
| 128 | 135 | type runnerSecret struct { | |
| @@ -171,6 +178,18 @@ type runnerJob struct { | |||
| 171 | 178 | // workflow did not say. The runner applies it to the container it starts, so a job | |
| 172 | 179 | // that asks for none gets none even on a machine that could provide a bridge. | |
| 173 | 180 | Network string `json:"network,omitempty"` | |
| 181 | // Bot is the account this job acts as, and Credential is the key it may do it with. | ||
| 182 | // The private half is here and nowhere else: the queue holds the public half and | ||
| 183 | // the account name, so a credential can be refused the moment the run ends. | ||
| 184 | Bot string `json:"bot,omitempty"` | ||
| 185 | Credential *runnerCredential `json:"credential,omitempty"` | ||
| 186 | // CredentialError says the job asked to act as an account it cannot have. The | ||
| 187 | // runner fails the job with this line rather than running it without the key, | ||
| 188 | // because a step that cannot write is a broken job, not a degraded one. | ||
| 189 | CredentialError string `json:"credential_error,omitempty"` | ||
| 190 | // HostKey is where to find the instance when the job pushes, paired with the | ||
| 191 | // credential above. | ||
| 192 | HostKey string `json:"host_key,omitempty"` | ||
| 174 | 193 | // Timeout is the job's budget as a duration string, or empty when it has none. | |
| 175 | 194 | Timeout string `json:"timeout,omitempty"` | |
| 176 | 195 | // Image is a registry reference or "file:<name>". For the latter, ImageDefinition | |
| @@ -351,6 +370,19 @@ func (s *Server) serveRunner(ch xssh.Channel, user string) { | |||
| 351 | 370 | for _, st := range run.Steps { | |
| 352 | 371 | job.Steps = append(job.Steps, runnerStep{Name: st.Name, Run: st.Run, Timeout: st.Timeout}) | |
| 353 | 372 | } | |
| 373 | // A job that acts as a bot gets a key for this run only. The account and | ||
| 374 | // the repository are pinned in the row, so the hook can refuse a write that | ||
| 375 | // the account's role would not allow and the key stops working when the run | ||
| 376 | // ends. A bot that cannot be used fails the job here rather than at its | ||
| 377 | // first push, which is a much better place to learn it. | ||
| 378 | if run.Bot != "" { | ||
| 379 | cred, hostKey, reason := s.mintCredential(run, now) | ||
| 380 | if reason != "" { | ||
| 381 | job.CredentialError = reason | ||
| 382 | } else { | ||
| 383 | job.Bot, job.Credential, job.HostKey = run.Bot, cred, hostKey | ||
| 384 | } | ||
| 385 | } | ||
| 354 | 386 | cfg.Log.Info("runner leased a job", "user", user, "repo", run.Repo, | |
| 355 | 387 | "job", run.Job, "sha", run.SHA[:8], "run", run.ID, "attempt", run.Attempts) | |
| 356 | 388 | _ = cfg.Runs.SessionWorking(sessionID, run.Repo, run.ID, time.Now().UTC()) | |
| @@ -484,6 +516,11 @@ func (s *Server) serveRunner(ch xssh.Channel, user string) { | |||
| 484 | 516 | _ = enc.Encode(runnerReply{Op: "finish", Error: err.Error()}) | |
| 485 | 517 | continue | |
| 486 | 518 | } | |
| 519 | // The run is over, so the key it could act with is over too. The expiry would | ||
| 520 | // catch it eventually; this is the part that does not wait. | ||
| 521 | if err := cfg.Runs.DropRunCredentials(run.ID); err != nil { | ||
| 522 | cfg.Log.Warn("runner: cannot drop the run credential", "run", run.ID, "err", err) | ||
| 523 | } | ||
| 487 | 524 | cfg.Log.Info("run finished", "user", user, "run", run.ID, "exit", req.Record.Exit, | |
| 488 | 525 | "signer", req.Record.Signer, "recorded", ok) | |
| 489 | 526 | _ = enc.Encode(runnerReply{Op: "finish", OK: ok}) | |