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.

Phase 4: a runner can narrow what it takes, and cannot widen it

b238ac2448290fb706d0503c2e65798fc618f994 · Keenan Nemetz · 17d ago · parent 8d336083

Phase 4: a runner can narrow what it takes, and cannot widen it

`vole runner --repos name` (repeatable) restricts a session to the
repositories it names. The restriction is applied by the server as an
intersection with what the account's runner allowlist grants, which is the
whole design: the flag is a filter, not a grant, so the worst a client can do
is run less than it was allowed to. Honouring the client's list directly
would have made an SSH flag an access-control decision.

An empty filter means "no narrowing" rather than "nothing", which keeps an
unfiltered runner following allowlist edits instead of freezing its list at
connect time.

The filter has a failure mode with no symptom: a runner whose --repos matches
nothing connects, waits, and never sees work, which looks exactly like a quiet
instance. So "ping" now reports the account's grant and the runner refuses at
startup, naming what it asked for and what it was allowed — the same class of
check as the signing key, cheaper before a build than after one that never
happened. An account with an empty grant and no filter is only a warning,
because an allowlist nobody has written yet takes effect without a restart.

runs.Store.Lease now takes a LeaseRequest struct. It gained a second list of
names, and two adjacent []string arguments are exactly the shape that gets
transposed in silence.

Measured: TestARequestCannotLeaseBeyondTheAllowlist — a repository the account
may *write* to but is not a runner for is refused even when asked for by name,
and an admin can still lease it. TestARepoFilterTakesOnlyWhatItNames,
TestNarrowCanOnlySubtract, TestRepoFilterThatCanNeverMatchIsRefused and
TestARepoFilterOutsideTheGrantIsRefusedAtStartup cover the rest.

cmd/vole/runner_filter_test.go +61

@@ -0,0 +1,61 @@
1 package main
2
3 // A runner started with --repos narrows the work it will take. The narrowing itself is
4 // the server's job (see sshserver's narrow), but the *mistake* is the client's, and a
5 // filter that matches nothing has no symptom: it connects, it waits, and nothing ever
6 // arrives. So it is refused at startup, next to the signing-key check, for the same
7 // reason — finding out after a build that never ran is finding out far too late.
8
9 import (
10 "strings"
11 "testing"
12 )
13
14 func TestRepoFilterThatCanNeverMatchIsRefused(t *testing.T) {
15 who := runnerReply{Name: "keenan", Repos: []string{"demo", "docs"}}
16
17 // Anything that intersects the grant is fine, and extra names are simply dropped:
18 // an operator may reasonably name a repository before it exists.
19 for _, asked := range [][]string{
20 {"demo"},
21 {"docs", "not-yet"},
22 {"demo", "docs"},
23 } {
24 if err := checkRepoFilter(asked, who); err != nil {
25 t.Fatalf("--repos %v was refused: %v", asked, err)
26 }
27 }
28
29 // A filter entirely outside the grant is the typo this check exists for.
30 err := checkRepoFilter([]string{"dmeo"}, who)
31 if err == nil {
32 t.Fatal("a filter naming nothing the account may run was accepted")
33 }
34 for _, want := range []string{"dmeo", "keenan", "demo, docs"} {
35 if !strings.Contains(err.Error(), want) {
36 t.Fatalf("the refusal does not mention %q: %v", want, err)
37 }
38 }
39
40 // With no grant at all, any filter is refused, and the message says there is
41 // nothing to narrow rather than listing an empty allowed set.
42 err = checkRepoFilter([]string{"demo"}, runnerReply{Name: "keenan"})
43 if err == nil {
44 t.Fatal("a filter was accepted for an account that may run nothing")
45 }
46 if !strings.Contains(err.Error(), "allowed: none") {
47 t.Fatalf("the refusal does not say the grant is empty: %v", err)
48 }
49 }
50
51 func TestNoRepoFilterNeedsNoCheck(t *testing.T) {
52 // A runner without --repos takes everything its account may run, so an empty grant
53 // is a waiting state rather than a mistake: an allowlist can be written later and
54 // takes effect without a restart. Refusing here would force a restart to fix it.
55 if err := checkRepoFilter(nil, runnerReply{Name: "keenan"}); err != nil {
56 t.Fatalf("an unfiltered runner was refused while its grant is empty: %v", err)
57 }
58 if err := checkRepoFilter(nil, runnerReply{Name: "keenan", Repos: []string{"demo"}}); err != nil {
59 t.Fatalf("an unfiltered runner was refused: %v", err)
60 }
61 }