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.

ci/agent/agent.go +52−5

@@ -85,6 +85,16 @@ type runnerJob struct {
85 85 // The runner starts the clock with the first step and cuts every later step short
86 86 // when the budget runs out.
87 87 Timeout string `json:"timeout,omitempty"`
88 // Bot is the account this job acts as, and Credential is the key it may act with.
89 // The key is written into the run directory and is valid until the run ends.
90 Bot string `json:"bot,omitempty"`
91 Credential *runnerCredential `json:"credential,omitempty"`
92 // CredentialError means the job asked for an account it cannot act as. The runner
93 // fails the job before the container starts rather than running it without a key.
94 CredentialError string `json:"credential_error,omitempty"`
95 // HostKey is the instance's public host key line, so the job can verify the host
96 // it pushes to without scanning it.
97 HostKey string `json:"host_key,omitempty"`
88 98 // Image is a registry reference or "file:<name>". For the latter, ImageDefinition
89 99 // is the pinned recipe and ImageSHA its digest; the runner builds it locally.
90 100 Image string `json:"image"`
@@ -111,6 +121,12 @@ type runnerJob struct {
111 121 Pages *runnerPages `json:"pages,omitempty"`
112 122 }
113 123
124 // runnerCredential is the private half of a run's key, delivered once.
125 type runnerCredential struct {
126 PrivateKey string `json:"private_key"`
127 Fingerprint string `json:"fingerprint"`
128 }
129
114 130 // runnerSecret is one delivered value and the version it came from. The version is
115 131 // what the attempt record reports, so a result says which value ran without
116 132 // carrying it.
@@ -564,6 +580,12 @@ func (s *runnerSession) run(remote, cache string, job *runnerJob, lease time.Dur
564 580 fmt.Fprintf(log, "\n%s\n", msg)
565 581 s.finish(remote, co, job, log, 1, nil, nil, nil, box)
566 582 }
583 // A job that asked to act as an account it cannot act as is broken as written, not
584 // degraded: fail it before the image is pulled and say why.
585 if job.CredentialError != "" {
586 fail("%s", job.CredentialError)
587 return
588 }
567 589 fmt.Fprintf(log, "=== %s · %s · %s @ %s\n", job.Repo, job.Job, job.Branch, format.SHA(job.SHA))
568 590 // The job's network request is resolved before anything runs, so a runner that was
569 591 // handed a job it cannot isolate refuses it instead of leaking the network into a
@@ -603,7 +625,7 @@ func (s *runnerSession) run(remote, cache string, job *runnerJob, lease time.Dur
603 625 // an argument at all.
604 626 env := job.Env
605 627 runDir := ""
606 if job.EventPayload != "" || len(job.Secrets) > 0 {
628 if job.EventPayload != "" || len(job.Secrets) > 0 || job.Credential != nil {
607 629 runDir = filepath.Join(filepath.Dir(co.Work), "run-"+job.ID+"-"+fmt.Sprint(job.Attempt))
608 630 if err := os.MkdirAll(runDir, 0o700); err != nil {
609 631 fail("cannot prepare the run directory: %s", err)
@@ -638,6 +660,29 @@ func (s *runnerSession) run(remote, cache string, job *runnerJob, lease time.Dur
638 660 }
639 661 secretsFile = "/run/vole/secrets.env"
640 662 }
663 if job.Credential != nil {
664 // The key is a file, not a value in the environment, so `git push` finds it by
665 // path and no step has to interpolate it. The known_hosts entry comes from the
666 // lease: the job cannot scan the host without trusting whatever answers.
667 if env, err = withCredentialEnv(env, remote, job); err != nil {
668 fail("%s", err)
669 return
670 }
671 if err := os.WriteFile(filepath.Join(runDir, "id_bot"), []byte(job.Credential.PrivateKey), 0o600); err != nil {
672 fail("cannot write the run credential: %s", err)
673 return
674 }
675 known, err := knownHostsLine(remote, job.HostKey)
676 if err != nil {
677 fail("%s", err)
678 return
679 }
680 if err := os.WriteFile(filepath.Join(runDir, "known_hosts"), []byte(known), 0o644); err != nil {
681 fail("cannot write the known_hosts entry: %s", err)
682 return
683 }
684 fmt.Fprintf(log, "acting as %s for this run\n", job.Bot)
685 }
641 686 if s.cacheRoot != "" {
642 687 defer func() {
643 688 if !s.cacheReuse {
@@ -1181,13 +1226,15 @@ func (s *runnerSession) runCommand(ctx context.Context, cmd *exec.Cmd, runID str
1181 1226 // secretValues is what the log has to redact: the values this job was actually
1182 1227 // given, not the names it asked for.
1183 1228 func secretValues(job *runnerJob) []string {
1184 if len(job.Secrets) == 0 {
1185 return nil
1186 }
1187 values := make([]string, 0, len(job.Secrets))
1229 values := make([]string, 0, len(job.Secrets)+1)
1188 1230 for _, delivered := range job.Secrets {
1189 1231 values = append(values, delivered.Value)
1190 1232 }
1233 // The run credential is a secret in the same sense: short-lived and scoped, but a
1234 // step that cats the key should not leave it in the log the instance keeps.
1235 if job.Credential != nil && job.Credential.PrivateKey != "" {
1236 values = append(values, job.Credential.PrivateKey)
1237 }
1191 1238 return values
1192 1239 }
1193 1240