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/hook/scope.go +205

@@ -0,0 +1,205 @@
1 package hook
2
3 // What a job may write.
4 //
5 // A run credential authenticates as the account the job acts as, but it is not that
6 // account's key: it exists for one job, in one repository, until the run ends. The
7 // role still decides what the account may do — the credential does not widen it — and
8 // this file is the one place that turns a metadata diff into that decision.
9 //
10 // The rule is the browser's write surface, moved to the push boundary. The browser
11 // already lets a contributor comment, open a change from a branch they may push, and
12 // lets an eligible approver approve; everything else it does is a maintainer action.
13 // A job gets the same list and no more, which is what makes it safe to hand a bot a
14 // key without handing it the role that used to be the only way to write metadata.
15
16 import (
17 "fmt"
18 "sort"
19 "strings"
20 "time"
21
22 "github.com/rodent-software/vole/ci/runs"
23 "github.com/rodent-software/vole/core/git"
24 "github.com/rodent-software/vole/forge/access"
25 "github.com/rodent-software/vole/metadata"
26 )
27
28 // lookupRunCredential resolves the fingerprint a session authenticated with to the
29 // job it belongs to, or nil when the session used an account key. An expired
30 // credential reads as absent: the row is swept later, and the refusal it produces is
31 // the same one an unknown key gives.
32 // isRequestFile reports whether a path is a change request's own file. It is spelled
33 // out here rather than assumed from a prefix so a future file beside request.md is
34 // not silently treated as one.
35 func isRequestFile(path string) bool {
36 return strings.HasPrefix(path, metadata.RequestsDir+"/") && strings.HasSuffix(path, "/request.md")
37 }
38
39 func lookupRunCredential(cfg Config, fingerprint string) (*runs.RunCredential, error) {
40 if cfg.RunsDB == "" {
41 return nil, nil
42 }
43 open := openRuns(cfg.RunsDB)
44 store, err := open()
45 if err != nil {
46 return nil, err
47 }
48 defer store.Close()
49 return store.RunCredential(fingerprint, time.Now().UTC())
50 }
51
52 // scopedMetadata reports what a push to the issues branch may not contain when it
53 // came from a run credential.
54 //
55 // The rule is the account's capabilities, asked the way the browser asks them. A
56 // credential does not make the account stronger or weaker: a maintainer may do what a
57 // maintainer may do, a contributor may file issues, comment, open a change from a
58 // branch it could push, and manage the items it wrote, and an account that may approve
59 // may approve. The credential replaces exactly one requirement — that writing the
60 // issues branch at all needs maintain — and adds no rule of its own.
61 func scopedMetadata(g *git.Repo, c change, acl *access.ACL, actor string) []string {
62 if c.old == "" || c.old == git.ZeroSHA {
63 return []string{"a job cannot create the issues branch; it must already exist"}
64 }
65 // Triaging other people's items covers every write the issues branch carries, so a
66 // maintainer needs nothing further: what a key may do, a credential may do.
67 if acl.CanTriage(actor) {
68 return nil
69 }
70 status, err := g.DiffStatus(c.old, c.new)
71 if err != nil {
72 return []string{"cannot check what this job's push writes: " + err.Error()}
73 }
74 paths := make([]string, 0, len(status))
75 for path := range status {
76 paths = append(paths, path)
77 }
78 sort.Strings(paths)
79
80 var problems []string
81 for _, path := range paths {
82 change := status[path]
83 switch {
84 case metadata.IsIssueCommentPath(path), metadata.IsRequestCommentPath(path):
85 // Adding a comment is discussing. Changing or removing one is the author's,
86 // and commentOwnership already checked that against the old blob.
87 if change != "A" {
88 continue
89 }
90 if !acl.CanDiscuss(actor) {
91 problems = append(problems, fmt.Sprintf(
92 "%s: %s may not discuss in this repository", path, actor))
93 continue
94 }
95 author, ok := authorAt(g, c.new, path)
96 if !ok {
97 problems = append(problems, fmt.Sprintf("%s: cannot read whose comment this is", path))
98 continue
99 }
100 if author != actor {
101 problems = append(problems, fmt.Sprintf(
102 "%s: a job writes as %s, and this comment says %s", path, actor, author))
103 }
104 case isIssueFile(path):
105 id, _ := metadata.IssueIDFromPath(path)
106 if change == "A" {
107 // Filing an issue is part of discussing.
108 if !acl.CanDiscuss(actor) {
109 problems = append(problems, fmt.Sprintf(
110 "%s: %s may not file an issue in this repository", path, actor))
111 }
112 continue
113 }
114 // State, labels, assignees, and the description: a maintainer may triage
115 // any issue (handled above), and an author may manage their own.
116 old, found, err := metadata.LoadIssueAtCommit(g, c.old, id)
117 if err != nil || !found || old == nil {
118 problems = append(problems, fmt.Sprintf("%s: cannot read the issue being changed", path))
119 continue
120 }
121 if old.Author != actor {
122 problems = append(problems, fmt.Sprintf(
123 "%s: %s may not change an issue filed by %s", path, actor, old.Author))
124 }
125 case isRequestFile(path):
126 id, _ := metadata.RequestIDFromPath(path)
127 old, _, err := metadata.LoadRequestAt(g, c.old, id)
128 if change == "M" {
129 if err != nil || old == nil {
130 problems = append(problems, fmt.Sprintf("%s: cannot read the change being changed", path))
131 continue
132 }
133 if old.Author != actor {
134 problems = append(problems, fmt.Sprintf(
135 "%s: %s may not change a change opened by %s", path, actor, old.Author))
136 }
137 continue
138 }
139 if change != "A" {
140 problems = append(problems, fmt.Sprintf("%s: a job may not remove a change request", path))
141 continue
142 }
143 req, found, err := metadata.LoadRequestAt(g, c.new, id)
144 if err != nil || !found || req == nil {
145 problems = append(problems, fmt.Sprintf("%s: cannot read the change request: %v", path, err))
146 continue
147 }
148 if !acl.CanOpenRequest(actor) {
149 problems = append(problems, fmt.Sprintf(
150 "%s: %s may not open a change in this repository", path, actor))
151 continue
152 }
153 if req.Author != actor {
154 problems = append(problems, fmt.Sprintf(
155 "%s: a job opens a change as %s, and this one says %s", path, actor, req.Author))
156 }
157 // A contributor proposes a branch it could have pushed itself. A bare
158 // commit is the maintainer form, and a contributor has no business
159 // pointing at one.
160 switch {
161 case req.HeadRef == "":
162 problems = append(problems, fmt.Sprintf(
163 "%s: a contribution must follow a branch, not a bare commit", path))
164 case !access.IsContributorRef(actor, "refs/heads/"+req.HeadRef):
165 problems = append(problems, fmt.Sprintf(
166 "%s: %s may only propose a branch under %s",
167 path, actor, access.ContributorBranchPrefix(actor)))
168 }
169 case metadata.ApprovalPath(path) != "":
170 // approvalOwnership already requires the file to name the actor; this is
171 // the eligibility the browser checks, which the push boundary used to get
172 // for free from "writing the issues branch requires maintain".
173 if !acl.CanApprove(actor) {
174 problems = append(problems, fmt.Sprintf(
175 "%s: %s may not approve changes here", path, actor))
176 }
177 default:
178 problems = append(problems, fmt.Sprintf(
179 "%s: a job may write a comment, an issue, a change request, or an approval", path))
180 }
181 }
182 return problems
183 }
184
185 // authorAt reads the author of a metadata file at a commit.
186 func authorAt(g *git.Repo, commit, path string) (string, bool) {
187 raw, err := g.BlobAt(commit, path)
188 if err != nil {
189 return "", false
190 }
191 author, err := metadata.AuthorOf(raw)
192 if err != nil {
193 return "", false
194 }
195 return author, true
196 }
197
198 // isIssueFile reports whether path is an issue's own file rather than one of its
199 // comments.
200 func isIssueFile(path string) bool {
201 if _, ok := metadata.IssueIDFromPath(path); !ok {
202 return false
203 }
204 return !metadata.IsIssueCommentPath(path)
205 }