1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package domain
4
5import (
6 "crypto/sha256"
7 "encoding/hex"
8 "fmt"
9 "slices"
10 "strings"
11 "time"
12)
13
14// TokenSecretPrefix starts every token secret.
15const TokenSecretPrefix = "ermite_"
16
17// Scope is one permission of a stored token.
18type Scope string
19
20// The scopes of ADR 0006. The last three only matter after v1 and are defined
21// now so that the token format never changes.
22const (
23 // ScopeRepoRead reads private repositories.
24 ScopeRepoRead Scope = "repo:read"
25 // ScopeRepoWrite pushes and creates a repository on first push.
26 ScopeRepoWrite Scope = "repo:write"
27 // ScopeRepoAdmin deletes a repository and changes its visibility or description.
28 ScopeRepoAdmin Scope = "repo:admin"
29 // ScopeMRWrite creates, comments on and closes merge requests.
30 ScopeMRWrite Scope = "mr:write"
31 // ScopeMRMerge merges, and deletes the merged source branch on request.
32 ScopeMRMerge Scope = "mr:merge"
33 // ScopeMRForceMerge bypasses failing or unfinished merge checks.
34 ScopeMRForceMerge Scope = "mr:force-merge"
35 // ScopeMRReview sets a verdict on a merge request.
36 ScopeMRReview Scope = "mr:review"
37 // ScopeCIWrite re-runs or cancels CI runs.
38 ScopeCIWrite Scope = "ci:write"
39 // ScopeSecretsWrite sets and deletes secrets and variables.
40 ScopeSecretsWrite Scope = "secrets:write"
41 // ScopeRunnerRegister registers a CI runner.
42 ScopeRunnerRegister Scope = "runner:register"
43 // ScopeTokenAdmin creates, lists and revokes stored tokens.
44 ScopeTokenAdmin Scope = "token:admin"
45 // ScopeIssueWrite creates, comments on, closes and reopens issues. Moderation
46 // is not part of it: it stays with the owner.
47 ScopeIssueWrite Scope = "issue:write"
48 // ScopeReleaseWrite creates, edits and deletes releases and their assets.
49 ScopeReleaseWrite Scope = "release:write"
50)
51
52// AllScopes returns the thirteen scopes in a stable order.
53func AllScopes() []Scope {
54 return []Scope{
55 ScopeRepoRead, ScopeRepoWrite, ScopeRepoAdmin,
56 ScopeMRWrite, ScopeMRMerge, ScopeMRForceMerge, ScopeMRReview,
57 ScopeCIWrite, ScopeSecretsWrite, ScopeRunnerRegister,
58 ScopeTokenAdmin,
59 ScopeIssueWrite, ScopeReleaseWrite,
60 }
61}
62
63// ParseScope parses the text form of a scope, such as "repo:read".
64func ParseScope(s string) (Scope, error) {
65 sc := Scope(s)
66 if slices.Contains(AllScopes(), sc) {
67 return sc, nil
68 }
69 return "", fmt.Errorf("%w: %q", ErrInvalidScope, s)
70}
71
72// String returns the scope as text.
73func (s Scope) String() string { return string(s) }
74
75// TokenHash is the SHA-256 of a token secret. Secrets are never stored.
76type TokenHash [sha256.Size]byte
77
78// HashTokenSecret returns the hash of a token secret.
79func HashTokenSecret(secret string) TokenHash { return sha256.Sum256([]byte(secret)) }
80
81// String returns the hash as lowercase hex.
82func (h TokenHash) String() string { return hex.EncodeToString(h[:]) }
83
84// Token is a stored credential: what it may do, where, and until when.
85type Token struct {
86 // ID identifies the token; it is not secret.
87 ID string
88 Label string
89 Hash TokenHash
90 // Scopes is never empty.
91 Scopes []Scope
92 // Repos limits the token to these repositories; empty means every one.
93 Repos []RepoName
94 // BranchPrefixes limits the branches the token may write to those under
95 // one of these prefixes (each ends with "/"); empty means every branch.
96 // A limited token cannot write tags or other refs, nor create repositories.
97 BranchPrefixes []string
98 // ExpiresAt is the zero time for a token that never expires.
99 ExpiresAt time.Time
100 CreatedAt time.Time
101 LastUsedAt time.Time // zero until first used
102}
103
104// Expired reports whether the token is no longer valid at now.
105func (t Token) Expired(now time.Time) bool {
106 return !t.ExpiresAt.IsZero() && !now.Before(t.ExpiresAt)
107}
108
109const (
110 // MaxBranchPrefixLen is the longest branch prefix, in bytes.
111 MaxBranchPrefixLen = 100
112 // MaxBranchPrefixes is the most branch prefixes of a token.
113 MaxBranchPrefixes = 8
114)
115
116// ParseBranchPrefix validates a branch prefix such as "agent/claude/": it ends
117// with "/", the rest is a valid branch name, and it has at most
118// MaxBranchPrefixLen bytes. Errors match ErrInvalidToken.
119func ParseBranchPrefix(s string) (string, error) {
120 if len(s) > MaxBranchPrefixLen {
121 return "", fmt.Errorf("%w: branch prefix is longer than %d bytes", ErrInvalidToken, MaxBranchPrefixLen)
122 }
123 name, ok := strings.CutSuffix(s, "/")
124 if !ok {
125 return "", fmt.Errorf("%w: branch prefix %q must end with /", ErrInvalidToken, s)
126 }
127 if _, err := ParseBranchName(name); err != nil {
128 return "", fmt.Errorf("%w: branch prefix %q: %w", ErrInvalidToken, s, err)
129 }
130 return s, nil
131}
132
133// ParseBranchPrefixes validates a list of branch prefixes: at most
134// MaxBranchPrefixes, each valid, duplicates removed, order kept. Errors match
135// ErrInvalidToken. The result is nil for an empty list.
136func ParseBranchPrefixes(in []string) ([]string, error) {
137 var out []string
138 for _, s := range in {
139 p, err := ParseBranchPrefix(s)
140 if err != nil {
141 return nil, err
142 }
143 if !slices.Contains(out, p) {
144 out = append(out, p)
145 }
146 }
147 if len(out) > MaxBranchPrefixes {
148 return nil, fmt.Errorf("%w: at most %d branch prefixes", ErrInvalidToken, MaxBranchPrefixes)
149 }
150 return out, nil
151}