v0.8.0 eaf4295

token.go RawHistoryFolder

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	// ScopeCIWrite re-runs or cancels CI runs.
34	ScopeCIWrite Scope = "ci:write"
35	// ScopeSecretsWrite sets and deletes secrets and variables.
36	ScopeSecretsWrite Scope = "secrets:write"
37	// ScopeRunnerRegister registers a CI runner.
38	ScopeRunnerRegister Scope = "runner:register"
39	// ScopeTokenAdmin creates, lists and revokes stored tokens.
40	ScopeTokenAdmin Scope = "token:admin"
41)
42
43// AllScopes returns the nine scopes in a stable order.
44func AllScopes() []Scope {
45	return []Scope{
46		ScopeRepoRead, ScopeRepoWrite, ScopeRepoAdmin,
47		ScopeMRWrite, ScopeMRMerge,
48		ScopeCIWrite, ScopeSecretsWrite, ScopeRunnerRegister,
49		ScopeTokenAdmin,
50	}
51}
52
53// ParseScope parses the text form of a scope, such as "repo:read".
54func ParseScope(s string) (Scope, error) {
55	sc := Scope(s)
56	if slices.Contains(AllScopes(), sc) {
57		return sc, nil
58	}
59	return "", fmt.Errorf("%w: %q", ErrInvalidScope, s)
60}
61
62// String returns the scope as text.
63func (s Scope) String() string { return string(s) }
64
65// TokenHash is the SHA-256 of a token secret. Secrets are never stored.
66type TokenHash [sha256.Size]byte
67
68// HashTokenSecret returns the hash of a token secret.
69func HashTokenSecret(secret string) TokenHash { return sha256.Sum256([]byte(secret)) }
70
71// String returns the hash as lowercase hex.
72func (h TokenHash) String() string { return hex.EncodeToString(h[:]) }
73
74// Token is a stored credential: what it may do, where, and until when.
75type Token struct {
76	// ID identifies the token; it is not secret.
77	ID    string
78	Label string
79	Hash  TokenHash
80	// Scopes is never empty.
81	Scopes []Scope
82	// Repos limits the token to these repositories; empty means every one.
83	Repos []RepoName
84	// BranchPrefixes limits the branches the token may write to those under
85	// one of these prefixes (each ends with "/"); empty means every branch.
86	// A limited token cannot write tags or other refs, nor create repositories.
87	BranchPrefixes []string
88	// ExpiresAt is the zero time for a token that never expires.
89	ExpiresAt  time.Time
90	CreatedAt  time.Time
91	LastUsedAt time.Time // zero until first used
92}
93
94// Expired reports whether the token is no longer valid at now.
95func (t Token) Expired(now time.Time) bool {
96	return !t.ExpiresAt.IsZero() && !now.Before(t.ExpiresAt)
97}
98
99const (
100	// MaxBranchPrefixLen is the longest branch prefix, in bytes.
101	MaxBranchPrefixLen = 100
102	// MaxBranchPrefixes is the most branch prefixes of a token.
103	MaxBranchPrefixes = 8
104)
105
106// ParseBranchPrefix validates a branch prefix such as "agent/claude/": it ends
107// with "/", the rest is a valid branch name, and it has at most
108// MaxBranchPrefixLen bytes. Errors match ErrInvalidToken.
109func ParseBranchPrefix(s string) (string, error) {
110	if len(s) > MaxBranchPrefixLen {
111		return "", fmt.Errorf("%w: branch prefix is longer than %d bytes", ErrInvalidToken, MaxBranchPrefixLen)
112	}
113	name, ok := strings.CutSuffix(s, "/")
114	if !ok {
115		return "", fmt.Errorf("%w: branch prefix %q must end with /", ErrInvalidToken, s)
116	}
117	if _, err := ParseBranchName(name); err != nil {
118		return "", fmt.Errorf("%w: branch prefix %q: %w", ErrInvalidToken, s, err)
119	}
120	return s, nil
121}
122
123// ParseBranchPrefixes validates a list of branch prefixes: at most
124// MaxBranchPrefixes, each valid, duplicates removed, order kept. Errors match
125// ErrInvalidToken. The result is nil for an empty list.
126func ParseBranchPrefixes(in []string) ([]string, error) {
127	var out []string
128	for _, s := range in {
129		p, err := ParseBranchPrefix(s)
130		if err != nil {
131			return nil, err
132		}
133		if !slices.Contains(out, p) {
134			out = append(out, p)
135		}
136	}
137	if len(out) > MaxBranchPrefixes {
138		return nil, fmt.Errorf("%w: at most %d branch prefixes", ErrInvalidToken, MaxBranchPrefixes)
139	}
140	return out, nil
141}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 3

Tags 43

↑↓ moveenter openesc close