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/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})