1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package usecase
4
5import (
6 "crypto/rand"
7 "encoding/hex"
8 "slices"
9
10 "bnema.dev/ermite/internal/domain"
11 "bnema.dev/ermite/internal/ports"
12)
13
14// Job repository tokens (ADR 0025, CI-2). A job gets one at its assignment:
15// "ermitegh_" and 32 random bytes, stored hashed, read-only on the repository
16// of its run. Auth.Authenticate resolves it to a principal shaped like a
17// stored token, so git over HTTP, the API and the REST subset need no rule of
18// their own. It is written in the batch of the assignment, deleted in the
19// batch that ends the job (CompleteJob, a cancellation that concludes, a lost
20// runner, a timeout, the queue and log limits) or that takes the assignment
21// back, and with the run when the run is deleted. Its expiry is the deadline of
22// the job plus a few minutes, whatever happens.
23
24func newRepoTokenID() string {
25 var raw [8]byte
26 _, _ = rand.Read(raw[:])
27 return hex.EncodeToString(raw[:])
28}
29
30// repoTokenExpiry is when a token of the job expires at the latest: the
31// deadline of the job and the grace.
32func (c *CI) repoTokenExpiry(j domain.CIJob) (expires domain.CIRepoToken) {
33 expires.ExpiresAt = c.deadline(j).Add(domain.RepoTokenGrace)
34 return expires
35}
36
37// issueRepoToken adds a repository token for j to the next flush and returns
38// its secret. j must be assigned (its AssignedAt is set).
39func (rs *runState) issueRepoToken(j *domain.CIJob) string {
40 return rs.issueRepoTokenFor(j, rs.run.Repo, "")
41}
42
43// issueRepoTokenFor is issueRepoToken for a token that reads repo. A sha makes
44// it the archive token of that commit of repo, which downloads that archive and
45// nothing else.
46func (rs *runState) issueRepoTokenFor(j *domain.CIJob, repo domain.RepoName, sha string) string {
47 c := rs.c
48 now := c.now()
49 at := *j
50 if at.AssignedAt.IsZero() {
51 at.AssignedAt = now
52 }
53 secret := newToken(domain.RepoTokenPrefix)
54 t := c.repoTokenExpiry(at)
55 t.ID, t.Hash, t.Repo = newRepoTokenID(), domain.HashTokenSecret(secret), repo
56 t.RunID, t.RunAttempt, t.JobID, t.Archive, t.Sha, t.CreatedAt = j.RunID, j.RunAttempt, j.ID, sha != "", sha, now
57 if sha == "" {
58 // The job's own token: what it may do on the run's repository follows
59 // from the permissions of the job, lowered to read when the run is not
60 // trusted (D1, D3).
61 t.Permissions = domain.TokenPermissions(j.Permissions, rs.run.Trusted)
62 }
63 rs.tokens = append(rs.tokens, t)
64 return secret
65}
66
67// revokeTokens deletes, with the next flush, every repository token of j, and
68// forgets the archive tokens kept in memory.
69func (rs *runState) revokeTokens(j *domain.CIJob) {
70 ref := ports.CIJobRef{RunID: j.RunID, RunAttempt: j.RunAttempt, JobID: j.ID}
71 if !slices.Contains(rs.revoke, ref) {
72 rs.revoke = append(rs.revoke, ref)
73 }
74 rs.c.mu.Lock()
75 delete(rs.c.archiveTokens, jobRef(*j))
76 rs.c.mu.Unlock()
77}