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.
| 1 | // Package hook enforces policy on the server side. It runs as pre-receive, so a |
| 2 | // rejected push moves no refs at all: the decision is per-push, never per-ref. |
| 3 | package hook |
| 4 | |
| 5 | import ( |
| 6 | "bufio" |
| 7 | "fmt" |
| 8 | "io" |
| 9 | "os" |
| 10 | "path/filepath" |
| 11 | "strings" |
| 12 | "time" |
| 13 | |
| 14 | "rodent.software/vole/ci" |
| 15 | "rodent.software/vole/ci/runs" |
| 16 | "rodent.software/vole/core/git" |
| 17 | "rodent.software/vole/core/lfs" |
| 18 | "rodent.software/vole/forge/access" |
| 19 | "rodent.software/vole/forge/identity" |
| 20 | "rodent.software/vole/forge/index" |
| 21 | "rodent.software/vole/forge/landing" |
| 22 | "rodent.software/vole/forge/repo" |
| 23 | "rodent.software/vole/internal/audit" |
| 24 | "rodent.software/vole/internal/format" |
| 25 | "rodent.software/vole/metadata" |
| 26 | ) |
| 27 | |
| 28 | type Config struct { |
| 29 | RepoDir string |
| 30 | Actor string |
| 31 | // KeyID is the fingerprint the session authenticated with. It is not the account: |
| 32 | // it is what tells a run credential apart from the account's own key, because a |
| 33 | // credential may write less than the account it acts as. |
| 34 | KeyID string |
| 35 | MaxBlobBytes int64 |
| 36 | // UsersDB, when set, is the roster database, read here for access rules and |
| 37 | // file locks. Attribution itself is the Actor name resolved at the handshake. |
| 38 | UsersDB string |
| 39 | // SpoolDir receives one file per accepted ref update, for the CI queue. |
| 40 | SpoolDir string |
| 41 | // RunsDB is the run queue, read here so required checks are enforced at the |
| 42 | // same single point as everything else. |
| 43 | RunsDB string |
| 44 | // LFS is the object store, read here so a pointer that names an object the |
| 45 | // server does not have fails the push that introduced it. |
| 46 | LFS *lfs.Store |
| 47 | // LFSWarnBytes flags binaries committed straight into git above this size. |
| 48 | // Zero disables the warning. |
| 49 | LFSWarnBytes int64 |
| 50 | // Quota bounds for this repository. Zero disables the check, which is what a |
| 51 | // test or an offline command wants; the instance always sets them. |
| 52 | MaxRefs int |
| 53 | MaxMetadataEntries int |
| 54 | MaxMetadataBytes int64 |
| 55 | MaxRepoBytes int64 |
| 56 | MaxLFSBytes int64 |
| 57 | // RepoID is the repository's stable id, which is how the LFS store keys its |
| 58 | // links. Empty disables the LFS quota. |
| 59 | RepoID string |
| 60 | // IndexDB, with RepoID, lets post-receive bring the read model up to the |
| 61 | // branch the push left behind, so the next page does not apply the delta. |
| 62 | // Empty disables the refresh. |
| 63 | IndexDB string |
| 64 | // AuditPath receives lines about privileged metadata operations. When it is |
| 65 | // empty or unwritable, an override is refused and an ordinary push is answered |
| 66 | // with a warning: the refs have already moved by the time post-receive runs. |
| 67 | AuditPath string |
| 68 | Out io.Writer |
| 69 | In io.Reader |
| 70 | } |
| 71 | |
| 72 | type change struct { |
| 73 | old, new, ref string |
| 74 | } |
| 75 | |
| 76 | // PreReceive returns a process exit code: 0 allows the push, 1 rejects it. |
| 77 | func PreReceive(cfg Config) int { |
| 78 | if cfg.Out == nil { |
| 79 | cfg.Out = io.Discard |
| 80 | } |
| 81 | if cfg.In == nil { |
| 82 | cfg.In = strings.NewReader("") |
| 83 | } |
| 84 | // Access rules are in the instance database, keyed by the repository's stable id, |
| 85 | // so the gate has to open the repository the same way the server does rather than |
| 86 | // assume a directory is a repository. |
| 87 | var ( |
| 88 | acls *access.Store |
| 89 | lockTable *lfs.LockStore |
| 90 | err error |
| 91 | ) |
| 92 | if cfg.UsersDB != "" { |
| 93 | acls, err = access.Open(cfg.UsersDB) |
| 94 | if err != nil { |
| 95 | reject(cfg.Out, "cannot open the access rules: %v", err) |
| 96 | return 1 |
| 97 | } |
| 98 | defer acls.Close() |
| 99 | // The push gate runs as a separate process, so it opens the roster itself |
| 100 | // to learn the actor's instance-wide role. Without this the hook would |
| 101 | // enforce only repository entries and silently ignore an invitation that |
| 102 | // granted contribute everywhere. |
| 103 | ident, err := identity.Open(cfg.UsersDB) |
| 104 | if err != nil { |
| 105 | reject(cfg.Out, "cannot open the roster: %v", err) |
| 106 | return 1 |
| 107 | } |
| 108 | defer ident.Close() |
| 109 | acls.InstanceRole = ident.InstanceRole |
| 110 | // An entry or a runners-list membership outlives the account: a retired |
| 111 | // approver or runner must not keep satisfying this gate. |
| 112 | acls.Active = ident.Active |
| 113 | // The lock table shares that database, so the push path learns what is held |
| 114 | // without a second endpoint and without the client's cooperation. |
| 115 | lockTable, err = lfs.OpenLocks(cfg.UsersDB) |
| 116 | if err != nil { |
| 117 | reject(cfg.Out, "cannot open the lock table: %v", err) |
| 118 | return 1 |
| 119 | } |
| 120 | defer lockTable.Close() |
| 121 | } |
| 122 | // GIT_DIR may be "." — that is what git hands a hook — so the directory is made |
| 123 | // absolute before anything is derived from it. |
| 124 | dir, err := filepath.Abs(cfg.RepoDir) |
| 125 | if err != nil { |
| 126 | reject(cfg.Out, "cannot resolve %s: %v", cfg.RepoDir, err) |
| 127 | return 1 |
| 128 | } |
| 129 | m, mErr := repo.New(filepath.Dir(dir), "", acls).Open(repoNameFromDir(dir)) |
| 130 | if mErr != nil { |
| 131 | reject(cfg.Out, "cannot open %s: %v", dir, mErr) |
| 132 | return 1 |
| 133 | } |
| 134 | a, err := m.ACL() |
| 135 | if err != nil { |
| 136 | reject(cfg.Out, "cannot read repository access rules: %v", err) |
| 137 | return 1 |
| 138 | } |
| 139 | |
| 140 | // The account name is the authorization identity: it comes from the SSH key |
| 141 | // the server resolved, never from the client, and every ACL entry, approval |
| 142 | // and record is keyed by it. |
| 143 | actor := strings.TrimSpace(cfg.Actor) |
| 144 | if actor == "" { |
| 145 | reject(cfg.Out, "pushes must be authenticated; no user was supplied") |
| 146 | return 1 |
| 147 | } |
| 148 | |
| 149 | role := a.RoleFor(actor) |
| 150 | if !role.AtLeast(access.Contribute) { |
| 151 | reject(cfg.Out, "%s does not have maintain or contribute access to %s (role: %s)", actor, m.Name, role) |
| 152 | return 1 |
| 153 | } |
| 154 | |
| 155 | // A session opened with a run credential acts for one job, in one repository, |
| 156 | // until that job ends. The role is still the account's and the per-path rules below |
| 157 | // are the same ones a key gets; what the credential adds is the binding: this |
| 158 | // repository, and no other. |
| 159 | if keyID := strings.TrimSpace(cfg.KeyID); keyID != "" { |
| 160 | found, err := lookupRunCredential(cfg, keyID) |
| 161 | if err != nil { |
| 162 | reject(cfg.Out, "cannot read this job's credential: %v", err) |
| 163 | return 1 |
| 164 | } |
| 165 | if found != nil { |
| 166 | switch { |
| 167 | case found.Account != actor: |
| 168 | reject(cfg.Out, "this job's credential is for %s, not %s", found.Account, actor) |
| 169 | return 1 |
| 170 | case found.Repo != m.Name: |
| 171 | reject(cfg.Out, "this job's credential is for %s, not %s", found.Repo, m.Name) |
| 172 | return 1 |
| 173 | } |
| 174 | } |
| 175 | } |
| 176 | |
| 177 | changes, err := readChanges(cfg.In) |
| 178 | if err != nil { |
| 179 | reject(cfg.Out, "cannot read ref updates: %v", err) |
| 180 | return 1 |
| 181 | } |
| 182 | if len(changes) == 0 { |
| 183 | return 0 |
| 184 | } |
| 185 | |
| 186 | g := git.Open(cfg.RepoDir) |
| 187 | // receive-pack reveals the pushed objects through a quarantine: it points |
| 188 | // GIT_OBJECT_DIRECTORY at the temporary object directory and lists the real |
| 189 | // one in the alternates. gitx scrubs those variables so commands against other |
| 190 | // repositories are not redirected, so they are restored here for this one. |
| 191 | if os.Getenv("GIT_QUARANTINE_PATH") != "" { |
| 192 | for _, k := range []string{"GIT_QUARANTINE_PATH", "GIT_OBJECT_DIRECTORY", "GIT_ALTERNATE_OBJECT_DIRECTORIES"} { |
| 193 | if v := os.Getenv(k); v != "" { |
| 194 | g.ExtraEnv = append(g.ExtraEnv, k+"="+v) |
| 195 | } |
| 196 | } |
| 197 | } |
| 198 | // Quotas are checked once, against the repository this push would leave, and |
| 199 | // before any per-ref work: a push that cannot be accepted should say so in one |
| 200 | // line rather than after a page of review findings. |
| 201 | problems := quotaProblems(cfg, g, changes) |
| 202 | for _, c := range changes { |
| 203 | // Only branches and tags are writable. The namespace beside them used to hold |
| 204 | // the ACL; nothing lives there now (see the access package), so it needs no rule |
| 205 | // of its own — the rule below already covers every ref that is not a branch or a |
| 206 | // tag. |
| 207 | if !strings.HasPrefix(c.ref, "refs/heads/") && !strings.HasPrefix(c.ref, "refs/tags/") { |
| 208 | problems = append(problems, fmt.Sprintf( |
| 209 | "%s: only refs/heads/* and refs/tags/* may be pushed", c.ref)) |
| 210 | continue |
| 211 | } |
| 212 | // A contributor gets one branch namespace, named after the account name. |
| 213 | // The name is fixed at creation and never reissued, so it identifies the |
| 214 | // owner without a lookup and no rename can strand a branch. Everything |
| 215 | // else — metadata, shared branches, tags — belongs to write or admin |
| 216 | // access. The check is here rather than at the role gate because the gate |
| 217 | // only knows that the account may push something. |
| 218 | if !role.AtLeast(access.Maintain) && !access.IsContributorRef(actor, c.ref) && |
| 219 | c.ref != metadata.Ref { |
| 220 | problems = append(problems, fmt.Sprintf( |
| 221 | "%s: the contribute role may only push branches under %s and the issues branch (role: %s)", |
| 222 | c.ref, access.ContributorBranchPrefix(actor), role)) |
| 223 | continue |
| 224 | } |
| 225 | // The metadata branch is append-only in shape: linear history, no merge |
| 226 | // commits. A merge node in metadata says nothing and makes the log harder |
| 227 | // to read than the change it describes. |
| 228 | if c.ref == metadata.Ref { |
| 229 | if merges, err := g.CountMerges(c.old, c.new); err != nil { |
| 230 | problems = append(problems, fmt.Sprintf("%s: cannot check history shape: %v", c.ref, err)) |
| 231 | } else if merges > 0 { |
| 232 | problems = append(problems, fmt.Sprintf( |
| 233 | "%s: metadata must have linear history (%d merge commit(s) would be added); rebase instead of merging", c.ref, merges)) |
| 234 | } |
| 235 | } |
| 236 | |
| 237 | // A rewrite of the metadata branch is allowed, because discussion content |
| 238 | // sometimes has to be redacted, but only by an admin and never silently. |
| 239 | if c.old != "" && c.old != git.ZeroSHA && c.new != git.ZeroSHA { |
| 240 | ff, err := g.IsAncestor(c.old, c.new) |
| 241 | if err != nil { |
| 242 | problems = append(problems, fmt.Sprintf("%s: cannot check fast-forward: %v", c.ref, err)) |
| 243 | } else if !ff { |
| 244 | switch { |
| 245 | case role.AtLeast(access.Admin): |
| 246 | // A rewrite is allowed, but only on the record: an override that |
| 247 | // cannot be written is refused, which is the whole value of it. |
| 248 | if err := auditLine(cfg.AuditPath, audit.Record{ |
| 249 | Action: "push.force", |
| 250 | Actor: actor, |
| 251 | Subject: m.Name, |
| 252 | Detail: fmt.Sprintf("%s %s -> %s", c.ref, format.SHA(c.old), format.SHA(c.new)), |
| 253 | }); err != nil { |
| 254 | problems = append(problems, fmt.Sprintf( |
| 255 | "%s: cannot record the forced push, refusing it: %v", c.ref, err)) |
| 256 | } |
| 257 | case role == access.Contribute && access.IsContributorRef(actor, c.ref): |
| 258 | // The namespace is theirs: rebasing or deleting their own branch is |
| 259 | // not a rewrite of shared history. |
| 260 | default: |
| 261 | problems = append(problems, fmt.Sprintf( |
| 262 | "%s: rewriting history requires admin access; %s is %s", c.ref, actor, role)) |
| 263 | } |
| 264 | } |
| 265 | } |
| 266 | |
| 267 | // Workflows are executable policy, so the version being pushed is validated |
| 268 | // here rather than when a runner picks it up. |
| 269 | if strings.HasPrefix(c.ref, "refs/heads/") && c.new != "" && c.new != git.ZeroSHA { |
| 270 | touches, err := touchesWorkflows(g, c) |
| 271 | if err != nil { |
| 272 | problems = append(problems, fmt.Sprintf("%s: cannot check %s: %v", c.ref, ci.Dir, err)) |
| 273 | } else if touches { |
| 274 | problems = append(problems, ci.ValidateTree(g, c.new)...) |
| 275 | } |
| 276 | } |
| 277 | |
| 278 | // Integrity checks run before the protected-branch gate, because an admin's |
| 279 | // approval bypass is about review, not about whether the repository works: |
| 280 | // a pointer with no object behind it is broken no matter who pushed it. |
| 281 | if cfg.MaxBlobBytes > 0 && c.new != git.ZeroSHA { |
| 282 | overs, err := oversizedBlobs(g, c, cfg.MaxBlobBytes) |
| 283 | if err != nil { |
| 284 | problems = append(problems, fmt.Sprintf("%s: cannot check object sizes: %v", c.ref, err)) |
| 285 | continue |
| 286 | } |
| 287 | problems = append(problems, overs...) |
| 288 | } |
| 289 | // LFS objects live outside the pack, so receive-pack cannot notice that a |
| 290 | // pointer names nothing. This is the only place that can. |
| 291 | if cfg.LFS != nil && c.new != git.ZeroSHA { |
| 292 | lfsProblems, warnings, err := lfsFindings(g, m.Name, c.new, cfg.LFS, cfg.LFSWarnBytes) |
| 293 | if err != nil { |
| 294 | problems = append(problems, fmt.Sprintf("%s: cannot check LFS pointers: %v", c.ref, err)) |
| 295 | } else { |
| 296 | problems = append(problems, lfsProblems...) |
| 297 | printWarnings(cfg.Out, warnings) |
| 298 | } |
| 299 | } |
| 300 | |
| 301 | // An LFS lock is held on a path, and a push that changes that path is exactly |
| 302 | // what the holder asked to be told about. This sits with the integrity checks |
| 303 | // rather than with review: a lock is not a review decision, and an admin may |
| 304 | // override it, audited, because a lock whose holder has left would otherwise |
| 305 | // make a path unpushable forever. |
| 306 | if lockTable != nil { |
| 307 | held, err := lockTable.Paths(m.ID) |
| 308 | if err != nil { |
| 309 | problems = append(problems, fmt.Sprintf("%s: cannot read the lock table: %v", c.ref, err)) |
| 310 | } else if lockProblems, err := lockedPathFindings(g, c, held, actor); err != nil { |
| 311 | problems = append(problems, fmt.Sprintf("%s: cannot check locks: %v", c.ref, err)) |
| 312 | } else if len(lockProblems) > 0 { |
| 313 | if role.AtLeast(access.Admin) { |
| 314 | for _, p := range lockProblems { |
| 315 | if err := auditLine(cfg.AuditPath, audit.Record{ |
| 316 | Action: "lock.override", |
| 317 | Actor: actor, |
| 318 | Subject: m.Name, |
| 319 | Detail: c.ref + ": " + p, |
| 320 | }); err != nil { |
| 321 | problems = append(problems, fmt.Sprintf( |
| 322 | "%s: cannot record the lock override, refusing it: %v", c.ref, err)) |
| 323 | continue |
| 324 | } |
| 325 | reject(cfg.Out, "admin override, audited: %s", p) |
| 326 | } |
| 327 | } else { |
| 328 | problems = append(problems, lockProblems...) |
| 329 | } |
| 330 | } |
| 331 | } |
| 332 | |
| 333 | // A published release is immutable: the tag that names it and the pointers |
| 334 | // that carry its bytes. This sits with the integrity checks rather than |
| 335 | // with the review gate, because a download that can change bytes is broken |
| 336 | // no matter who pushed it — but an admin may override, audited, since a |
| 337 | // mistyped tag is a real problem and the alternative is a repository |
| 338 | // nobody can repair. |
| 339 | if relProblems := releaseFindings(g, c); len(relProblems) > 0 { |
| 340 | if role.AtLeast(access.Admin) { |
| 341 | for _, p := range relProblems { |
| 342 | if err := auditLine(cfg.AuditPath, audit.Record{ |
| 343 | Action: "release.rewrite", |
| 344 | Actor: actor, |
| 345 | Subject: m.Name, |
| 346 | Detail: c.ref + ": " + p, |
| 347 | }); err != nil { |
| 348 | problems = append(problems, fmt.Sprintf( |
| 349 | "%s: cannot record the release rewrite, refusing it: %v", c.ref, err)) |
| 350 | continue |
| 351 | } |
| 352 | reject(cfg.Out, "admin override, audited: %s", p) |
| 353 | } |
| 354 | } else { |
| 355 | problems = append(problems, relProblems...) |
| 356 | } |
| 357 | } |
| 358 | |
| 359 | // The gate a push meets is the same one a server-side merge meets, so it is |
| 360 | // asked here in the same function the merge operation calls |
| 361 | // (forge/landing). An unprotected ref needs nothing; an admin keeps the bypass |
| 362 | // it always had; everyone else needs an approved request naming this exact |
| 363 | // commit, with the required checks passing. |
| 364 | if a.IsProtected(c.ref) { |
| 365 | branch := strings.TrimPrefix(c.ref, "refs/heads/") |
| 366 | merge, gateErr := landing.Gate{ |
| 367 | Repo: g, Name: repoNameFromDir(cfg.RepoDir), |
| 368 | Runs: openRuns(cfg.RunsDB), |
| 369 | ACL: a, Actor: actor, Role: role, |
| 370 | }.Check(branch, c.new, c.old) |
| 371 | if gateErr != nil { |
| 372 | problems = append(problems, gateErr.Error()) |
| 373 | continue |
| 374 | } |
| 375 | if merge != nil { |
| 376 | // The landing itself is in the repository's history, so a log that |
| 377 | // cannot be written is reported rather than turned into a refusal. |
| 378 | if err := auditLine(cfg.AuditPath, audit.Record{ |
| 379 | Action: "merge", |
| 380 | Actor: actor, |
| 381 | Subject: m.Name, |
| 382 | Detail: fmt.Sprintf("%s via request %s (head %s) approved by %s", |
| 383 | c.ref, merge.Request.ID, format.SHA(merge.Request.Head), strings.Join(merge.Approvers, ",")), |
| 384 | }); err != nil { |
| 385 | reject(cfg.Out, "audit log write failed: %v", err) |
| 386 | } |
| 387 | } |
| 388 | } |
| 389 | // The issues branch is user-authored content, so it is validated at the |
| 390 | // boundary: bad data never reaches the tree the web reads. |
| 391 | if c.ref == metadata.Ref && c.new != git.ZeroSHA { |
| 392 | for _, prob := range metadata.ValidateIssues(g, c.new) { |
| 393 | problems = append(problems, "issues: "+prob) |
| 394 | } |
| 395 | for _, prob := range metadata.ValidateRequests(g, c.new) { |
| 396 | problems = append(problems, "requests: "+prob) |
| 397 | } |
| 398 | // The repository may require a linked issue when a request is opened. |
| 399 | // Check only the requests this push adds: existing requests keep their |
| 400 | // history when the policy is turned on, and an edit to one is not a new |
| 401 | // opening. The browser writes metadata directly, so it makes the same |
| 402 | // check in CreateChangeRequest. |
| 403 | if a.RequiresLinkedIssue() { |
| 404 | added, err := metadata.NewRequests(g, c.old, c.new) |
| 405 | if err != nil { |
| 406 | problems = append(problems, "requests: cannot check the linked-issue rule: "+err.Error()) |
| 407 | } else { |
| 408 | for _, req := range added { |
| 409 | if len(req.Closes) == 0 { |
| 410 | problems = append(problems, fmt.Sprintf( |
| 411 | "requests: %s: opening a change request here requires at least one linked issue (use --closes <issue>)", req.ID)) |
| 412 | } |
| 413 | } |
| 414 | } |
| 415 | } |
| 416 | // An approval is a statement about the actor's own review, so the file |
| 417 | // name must be the actor's account name. Without this, anyone with write |
| 418 | // access could commit approvals/<someone-else>.json and approve on their |
| 419 | // behalf. |
| 420 | for _, prob := range approvalOwnership(g, c, actor, role, cfg.AuditPath, m.Name) { |
| 421 | problems = append(problems, prob) |
| 422 | } |
| 423 | // A comment is somebody's own words, so changing or deleting it is |
| 424 | // theirs too — checked against the author on the *old* blob, which is |
| 425 | // the one that was validated when it landed. |
| 426 | for _, prob := range commentOwnership(g, c, actor, role, cfg.AuditPath, cfg.Out, m.Name) { |
| 427 | problems = append(problems, prob) |
| 428 | } |
| 429 | // What this push may write there is the account's capabilities, per path. |
| 430 | // The browser applies the same rules one action at a time, and the two |
| 431 | // transports have to keep offering the same account the same things. |
| 432 | problems = append(problems, writableMetadata(g, c, a, actor)...) |
| 433 | } |
| 434 | } |
| 435 | |
| 436 | if len(problems) == 0 { |
| 437 | return 0 |
| 438 | } |
| 439 | reject(cfg.Out, "push rejected (%d problem(s)):", len(problems)) |
| 440 | for _, p := range problems { |
| 441 | fmt.Fprintf(cfg.Out, "vole: %s\n", p) |
| 442 | } |
| 443 | return 1 |
| 444 | } |
| 445 | |
| 446 | func oversizedBlobs(g *git.Repo, c change, max int64) ([]string, error) { |
| 447 | sizes, err := g.NewBlobSizes(c.old, c.new) |
| 448 | if err != nil { |
| 449 | return nil, err |
| 450 | } |
| 451 | var out []string |
| 452 | for sha, n := range sizes { |
| 453 | if n <= max { |
| 454 | continue |
| 455 | } |
| 456 | out = append(out, fmt.Sprintf( |
| 457 | "object %s is %d bytes, over the %d byte limit (consider git-lfs)", sha[:12], n, max)) |
| 458 | if len(out) >= 5 { |
| 459 | out = append(out, "further oversized objects not listed") |
| 460 | break |
| 461 | } |
| 462 | } |
| 463 | return out, nil |
| 464 | } |
| 465 | |
| 466 | func readChanges(r io.Reader) ([]change, error) { |
| 467 | var out []change |
| 468 | sc := bufio.NewScanner(r) |
| 469 | sc.Buffer(make([]byte, 0, 64<<10), 1<<20) |
| 470 | for sc.Scan() { |
| 471 | line := strings.TrimSpace(sc.Text()) |
| 472 | if line == "" { |
| 473 | continue |
| 474 | } |
| 475 | f := strings.Fields(line) |
| 476 | if len(f) != 3 { |
| 477 | return nil, fmt.Errorf("malformed ref update %q", line) |
| 478 | } |
| 479 | out = append(out, change{old: f[0], new: f[1], ref: f[2]}) |
| 480 | } |
| 481 | return out, sc.Err() |
| 482 | } |
| 483 | |
| 484 | // approvalOwnership enforces that an approval is written by the principal it |
| 485 | // names. The file name is how the tree records "who approved", so it has to be |
| 486 | // backed by the SSH identity the server resolved, not by anything the pusher |
| 487 | // chose to type. |
| 488 | // |
| 489 | // Every write counts: added, modified, renamed, retyped. Checking only additions |
| 490 | // is not enough, because a file that already exists can simply be edited to name |
| 491 | // a different commit. |
| 492 | func approvalOwnership(g *git.Repo, c change, actor string, role access.Role, auditPath, repoName string) []string { |
| 493 | // On the first push the branch has no old commit, so every approval in the |
| 494 | // tree is new and must be checked. Skipping that would let whoever creates the |
| 495 | // branch seed approvals in other people's names. |
| 496 | if c.old == "" || c.old == git.ZeroSHA { |
| 497 | blobs, err := g.LsTreeR(c.new) |
| 498 | if err != nil { |
| 499 | return []string{fmt.Sprintf("requests: cannot verify approvals in this push: %v", err)} |
| 500 | } |
| 501 | var problems []string |
| 502 | for _, b := range blobs { |
| 503 | if owner := metadata.ApprovalPath(b.Name); owner != "" && owner != actor { |
| 504 | problems = append(problems, fmt.Sprintf( |
| 505 | "requests: %s: %s may not create an approval for %s", b.Name, actor, owner)) |
| 506 | } |
| 507 | } |
| 508 | return problems |
| 509 | } |
| 510 | |
| 511 | status, err := g.DiffStatus(c.old, c.new) |
| 512 | if err != nil { |
| 513 | return []string{fmt.Sprintf("requests: cannot verify approvals in this push: %v", err)} |
| 514 | } |
| 515 | var problems []string |
| 516 | for path, st := range status { |
| 517 | owner := metadata.ApprovalPath(path) |
| 518 | if owner == "" || owner == actor { |
| 519 | continue |
| 520 | } |
| 521 | if st == "D" { |
| 522 | // Removing someone else's approval forges nothing, but it erases their |
| 523 | // statement. The owner may withdraw; an admin may clean up, because the |
| 524 | // alternative (force-push) is heavier for a one-line fix. Neither is |
| 525 | // silent: the admin path is audited. |
| 526 | if !role.AtLeast(access.Admin) { |
| 527 | problems = append(problems, fmt.Sprintf( |
| 528 | "requests: %s: %s may not delete %s's approval; only %s can withdraw it", |
| 529 | path, actor, owner, owner)) |
| 530 | } else if err := auditLine(auditPath, audit.Record{ |
| 531 | Action: "approval.remove", |
| 532 | Actor: actor, |
| 533 | Subject: repoName, |
| 534 | Detail: path, |
| 535 | }); err != nil { |
| 536 | problems = append(problems, fmt.Sprintf( |
| 537 | "requests: %s: cannot record the removal of an approval, refusing the push: %v", path, err)) |
| 538 | } |
| 539 | continue |
| 540 | } |
| 541 | problems = append(problems, fmt.Sprintf( |
| 542 | "requests: %s: %s may not add or change %s's approval; only that account can approve", |
| 543 | path, actor, owner)) |
| 544 | } |
| 545 | return problems |
| 546 | } |
| 547 | |
| 548 | // commentOwnership enforces that a comment is changed or removed by the account |
| 549 | // whose words it is.// |
| 550 | // The author of a comment is a name in a file, and a name is not authentication. |
| 551 | // What makes this check sound is *which* file it reads: the old blob was |
| 552 | // validated when it landed, whereas the new one is whatever the pusher wrote. |
| 553 | // Comparing the old author is also what closes the obvious bypass — editing the |
| 554 | // author field to your own name and the body in the same push. |
| 555 | // |
| 556 | // Three things are deliberately not enforced here: |
| 557 | // |
| 558 | // - Adding a comment is free. A new file's author is a claim, exactly like an |
| 559 | // issue's author field, and the push boundary has never pretended otherwise. |
| 560 | // - Issue files are not covered. They carry shared state — state, labels, |
| 561 | // assignees — so closing or labelling somebody else's issue is normal triage, |
| 562 | // and a description can be corrected by any writer, which it already could be. |
| 563 | // - A request's own file is not covered either, for the same reason: its state, |
| 564 | // head and base are shared, and updating them is the author's normal edit. |
| 565 | // - A first push has nothing to compare against, so it is skipped. |
| 566 | func commentOwnership(g *git.Repo, c change, actor string, role access.Role, auditPath string, out io.Writer, repoName string) []string { |
| 567 | if c.old == "" || c.old == git.ZeroSHA { |
| 568 | return nil |
| 569 | } |
| 570 | status, err := g.DiffStatus(c.old, c.new) |
| 571 | if err != nil { |
| 572 | return []string{fmt.Sprintf("issues: cannot verify comment authorship: %v", err)} |
| 573 | } |
| 574 | var problems []string |
| 575 | for path, st := range status { |
| 576 | // An issue comment and a review comment on a change request are the same |
| 577 | // file shape, so one rule covers both. |
| 578 | if !metadata.IsIssueCommentPath(path) && !metadata.IsRequestCommentPath(path) { |
| 579 | continue |
| 580 | } |
| 581 | // A rename has no blob at the destination, so its author cannot be |
| 582 | // established; that is a refusal rather than a pass. |
| 583 | if st != "M" && st != "D" && st != "R" { |
| 584 | continue |
| 585 | } |
| 586 | raw, err := g.BlobAt(c.old, path) |
| 587 | if err != nil { |
| 588 | problems = append(problems, fmt.Sprintf( |
| 589 | "issues: %s: cannot establish whose comment this is, refusing the push: %v", path, err)) |
| 590 | continue |
| 591 | } |
| 592 | author, err := metadata.AuthorOf(raw) |
| 593 | if err != nil { |
| 594 | problems = append(problems, fmt.Sprintf( |
| 595 | "issues: %s: cannot establish whose comment this is, refusing the push: %v", path, err)) |
| 596 | continue |
| 597 | } |
| 598 | if author == actor { |
| 599 | continue |
| 600 | } |
| 601 | verb := "change" |
| 602 | if st == "D" { |
| 603 | verb = "delete" |
| 604 | } |
| 605 | // An admin may repair a thread — a comment by somebody who has left |
| 606 | // cannot be fixed by them — but never silently: the override is audited, |
| 607 | // like a force-push and a release rewrite. |
| 608 | if role.AtLeast(access.Admin) { |
| 609 | action := "comment.change" |
| 610 | if st == "D" { |
| 611 | action = "comment.delete" |
| 612 | } |
| 613 | if err := auditLine(auditPath, audit.Record{ |
| 614 | Action: action, |
| 615 | Actor: actor, |
| 616 | Subject: repoName, |
| 617 | Detail: fmt.Sprintf("%s (author %s)", path, author), |
| 618 | }); err != nil { |
| 619 | problems = append(problems, fmt.Sprintf( |
| 620 | "issues: %s: cannot record the override, refusing the push: %v", path, err)) |
| 621 | continue |
| 622 | } |
| 623 | reject(out, "admin override, audited: %s %s %s's comment", actor, verb, author) |
| 624 | continue |
| 625 | } |
| 626 | problems = append(problems, fmt.Sprintf( |
| 627 | "issues: %s: only %s can %s this comment; %s is not that account", |
| 628 | path, author, verb, actor)) |
| 629 | } |
| 630 | return problems |
| 631 | } |
| 632 | |
| 633 | // touchesWorkflows reports whether this push changes anything under the workflow |
| 634 | // directory, so repositories without CI pay one diff and no more. |
| 635 | func touchesWorkflows(g *git.Repo, c change) (bool, error) { |
| 636 | if c.old == "" || c.old == git.ZeroSHA { |
| 637 | blobs, err := g.LsTreeR(c.new) |
| 638 | if err != nil { |
| 639 | return false, err |
| 640 | } |
| 641 | for _, b := range blobs { |
| 642 | if strings.HasPrefix(b.Name, ci.Dir+"/") { |
| 643 | return true, nil |
| 644 | } |
| 645 | } |
| 646 | return false, nil |
| 647 | } |
| 648 | status, err := g.DiffStatus(c.old, c.new) |
| 649 | if err != nil { |
| 650 | return false, err |
| 651 | } |
| 652 | for p := range status { |
| 653 | if strings.HasPrefix(p, ci.Dir+"/") { |
| 654 | return true, nil |
| 655 | } |
| 656 | } |
| 657 | return false, nil |
| 658 | } |
| 659 | |
| 660 | // repoNameFromDir derives the repository name from the directory git handed the |
| 661 | // hook. It cannot use the path as given: hooks run with GIT_DIR="." (relative to |
| 662 | // the repository), and receive-pack may pass a trailing separator, so a bare |
| 663 | // filepath.Base returns "." for both. |
| 664 | func repoNameFromDir(dir string) string { |
| 665 | abs, err := filepath.Abs(dir) |
| 666 | if err != nil { |
| 667 | abs = dir |
| 668 | } |
| 669 | return strings.TrimSuffix(filepath.Base(abs), ".git") |
| 670 | } |
| 671 | |
| 672 | // auditLine is best-effort: failing to write the log must not change policy. |
| 673 | // auditLine appends one record. Pre-receive treats an error as a refusal, because |
| 674 | // the alternative is an override that happened and cannot be found; post-receive |
| 675 | // reports it on the pusher's terminal, because the refs have already moved. |
| 676 | func auditLine(path string, r audit.Record) error { |
| 677 | return audit.Append(path, r) |
| 678 | } |
| 679 | |
| 680 | func reject(w io.Writer, format string, args ...any) { |
| 681 | fmt.Fprintf(w, "vole: "+format+"\n", args...) |
| 682 | } |
| 683 | |
| 684 | // PostReceive hands accepted ref updates to the CI queue by writing one file per |
| 685 | // update into the spool directory. It runs after the refs have moved, so what it |
| 686 | // records is what actually landed, and it never fails a push: a spool write that |
| 687 | // does not happen costs a CI run, not a commit. |
| 688 | // PostReceive records what a push changed and queues its CI. |
| 689 | // |
| 690 | // The recording is the push log, and it is the whole of this forge's audit story for an |
| 691 | // ordinary push: attribution is established once, at the boundary (the SSH handshake names |
| 692 | // the account, and pre-receive refuses anything the account may not do), so what is left |
| 693 | // for the instance to do is *write down what it decided*. One line per accepted ref |
| 694 | // update, in the same append-only log as the privileged acts. |
| 695 | // |
| 696 | // It runs here rather than in pre-receive because this is the point at which the refs have |
| 697 | // actually moved: pre-receive returning 0 is a permission, not an event. |
| 698 | // |
| 699 | // Nothing here may fail the push. A push that landed is done, and a full disk must not |
| 700 | // turn it into an error the client reports as a rejected update. |
| 701 | func PostReceive(cfg Config) int { |
| 702 | if cfg.Out == nil { |
| 703 | cfg.Out = io.Discard |
| 704 | } |
| 705 | repo := repoNameFromDir(cfg.RepoDir) |
| 706 | actor := strings.TrimSpace(cfg.Actor) |
| 707 | who := actor |
| 708 | if who == "" { |
| 709 | who = "unknown" |
| 710 | } |
| 711 | following := openFollower(cfg) |
| 712 | defer following.close() |
| 713 | sc := bufio.NewScanner(cfg.In) |
| 714 | for sc.Scan() { |
| 715 | fields := strings.Fields(sc.Text()) |
| 716 | if len(fields) != 3 { |
| 717 | continue |
| 718 | } |
| 719 | old, next, ref := fields[0], fields[1], fields[2] |
| 720 | if err := auditLine(cfg.AuditPath, audit.Record{ |
| 721 | Action: "push", |
| 722 | Actor: who, |
| 723 | Subject: repo, |
| 724 | Detail: fmt.Sprintf("%s %s -> %s", ref, shortOrNone(old), shortOrNone(next)), |
| 725 | }); err != nil { |
| 726 | // The update landed before this process ran, so the only honest report |
| 727 | // is on the pusher's terminal; failing the hook would make the client |
| 728 | // re-send something that already worked. |
| 729 | fmt.Fprintf(cfg.Out, "vole: audit log write failed: %v\n", err) |
| 730 | } |
| 731 | // A change request opened from a branch follows it: the push that just moved |
| 732 | // the ref moves the request's head in the same breath. It runs here, after the |
| 733 | // ref update, because the new tip is the event; pre-receive could only guess. |
| 734 | // Nothing about this may fail the push, and a sync that does not happen leaves |
| 735 | // the previous (already reviewed) head in place rather than approving new work. |
| 736 | // |
| 737 | // A branch no open request follows is the common case by a long way, and the |
| 738 | // answer is one indexed row: leaving it out of this block would make every |
| 739 | // push read the metadata branch and queue behind every comment. |
| 740 | if strings.HasPrefix(ref, "refs/heads/") && ref != metadata.Ref && |
| 741 | next != "" && next != git.ZeroSHA && actor != "" { |
| 742 | branch := strings.TrimPrefix(ref, "refs/heads/") |
| 743 | if following.follows(branch) { |
| 744 | n, err := metadata.SyncBranchHead(git.Open(cfg.RepoDir), branch, next, actor) |
| 745 | switch { |
| 746 | case err != nil: |
| 747 | fmt.Fprintf(cfg.Out, "vole: could not move requests following %s: %v\n", branch, err) |
| 748 | case n > 0: |
| 749 | if err := auditLine(cfg.AuditPath, audit.Record{ |
| 750 | Action: "request.follow", |
| 751 | Actor: actor, |
| 752 | Subject: repo, |
| 753 | Detail: fmt.Sprintf("%s -> %s (%d request(s))", ref, shortOrNone(next), n), |
| 754 | }); err != nil { |
| 755 | fmt.Fprintf(cfg.Out, "vole: audit log write failed: %v\n", err) |
| 756 | } |
| 757 | } |
| 758 | } |
| 759 | } |
| 760 | if cfg.SpoolDir == "" { |
| 761 | continue |
| 762 | } |
| 763 | event := runs.Event{ |
| 764 | Repo: repo, |
| 765 | Ref: ref, |
| 766 | Old: old, |
| 767 | New: next, |
| 768 | At: time.Now().UTC().Format(time.RFC3339Nano), |
| 769 | } |
| 770 | if err := runs.WriteSpool(cfg.SpoolDir, event); err != nil { |
| 771 | fmt.Fprintf(cfg.Out, "vole: could not queue CI for %s: %v\n", ref, err) |
| 772 | } |
| 773 | } |
| 774 | if err := RefreshIndex(cfg); err != nil { |
| 775 | fmt.Fprintf(cfg.Out, "vole: could not refresh the index: %v\n", err) |
| 776 | } |
| 777 | return 0 |
| 778 | } |
| 779 | |
| 780 | // RefreshIndex brings the derived rows up to the metadata branch the push left |
| 781 | // behind. It runs in post-receive, after the ref update and after a followed |
| 782 | // request's head has moved, so a page or a search finds the work already applied. |
| 783 | // |
| 784 | // A failure is reported and ignored: the refs have moved and the push cannot be |
| 785 | // failed now, and the next read still checks the branch and repairs the rows. |
| 786 | func RefreshIndex(cfg Config) error { |
| 787 | if cfg.IndexDB == "" || cfg.RepoID == "" { |
| 788 | return nil |
| 789 | } |
| 790 | store, err := index.Open(cfg.IndexDB) |
| 791 | if err != nil { |
| 792 | return err |
| 793 | } |
| 794 | defer store.Close() |
| 795 | m := &repo.Meta{ID: cfg.RepoID, Dir: cfg.RepoDir} |
| 796 | if err := store.Ensure(m); err != nil { |
| 797 | return err |
| 798 | } |
| 799 | // A tag is a ref, not metadata: no metadata commit announces a release, so |
| 800 | // the feed's release events are refreshed from the refs on every push. |
| 801 | if err := store.RefreshTags(m); err != nil { |
| 802 | return err |
| 803 | } |
| 804 | // The default branch tip, its tree and its newest commits are what the |
| 805 | // landing and overview pages show. RefreshHead compares the tip first, so a |
| 806 | // metadata or tag push does not walk the tree or the history. |
| 807 | _, err = store.RefreshHead(m) |
| 808 | return err |
| 809 | } |
| 810 | |
| 811 | // requestsFollow reports whether any open change request follows branch, which is |
| 812 | // the question post-receive asks before it reads the metadata branch to move a |
| 813 | // request's head. |
| 814 | // |
| 815 | // Every doubt answers yes. A missing, unreadable, or stale index means the caller |
| 816 | // does the work, because skipping the sync leaves a request naming an old head and |
| 817 | // nothing repairs that afterwards; doing it unnecessarily costs one metadata commit |
| 818 | // that changes nothing. |
| 819 | type follower struct { |
| 820 | cfg Config |
| 821 | store *index.Store |
| 822 | tried bool |
| 823 | } |
| 824 | |
| 825 | // openFollower prepares the question a push asks once per branch it moved. The index |
| 826 | // is opened at most once for the whole push, and not at all for a push that touched |
| 827 | // no branch. |
| 828 | func openFollower(cfg Config) *follower { return &follower{cfg: cfg} } |
| 829 | |
| 830 | func (f *follower) follows(branch string) bool { |
| 831 | if f.cfg.IndexDB == "" || f.cfg.RepoID == "" { |
| 832 | return true |
| 833 | } |
| 834 | if !f.tried { |
| 835 | f.tried = true |
| 836 | if store, err := index.Open(f.cfg.IndexDB); err == nil { |
| 837 | f.store = store |
| 838 | } |
| 839 | } |
| 840 | if f.store == nil { |
| 841 | return true |
| 842 | } |
| 843 | m := &repo.Meta{ID: f.cfg.RepoID, Dir: f.cfg.RepoDir} |
| 844 | follows, ok, err := f.store.RequestsFollowing(m, branch) |
| 845 | if err != nil || !ok { |
| 846 | return true |
| 847 | } |
| 848 | return follows |
| 849 | } |
| 850 | |
| 851 | func (f *follower) close() { |
| 852 | if f.store != nil { |
| 853 | _ = f.store.Close() |
| 854 | } |
| 855 | } |
| 856 | |
| 857 | // shortOrNone renders a sha for the log, naming the two states that are not a commit: |
| 858 | // a ref being created and one being deleted. |
| 859 | func shortOrNone(sha string) string { |
| 860 | if sha == "" || sha == git.ZeroSHA { |
| 861 | return "none" |
| 862 | } |
| 863 | return format.SHA(sha) |
| 864 | } |
| 865 | |
| 866 | // openRuns opens the run queue for one gate check. The pre-receive hook is a |
| 867 | // process that answers one push and exits, so the store is opened per check and not |
| 868 | // closed: the close would come after the answer, and the process ends there anyway. |
| 869 | // The server, which is long-lived, passes its own open handle instead. |
| 870 | func openRuns(path string) func() (*runs.Store, error) { |
| 871 | if path == "" { |
| 872 | return nil |
| 873 | } |
| 874 | return func() (*runs.Store, error) { return runs.Open(path) } |
| 875 | } |
| 876 |