v0.13.0 dc42452

principal.go RawHistoryFolder

1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package domain
4
5import (
6	"slices"
7	"strings"
8	"time"
9)
10
11// principalKind tells who a caller is.
12type principalKind uint8
13
14// Principal kinds. The zero value is anonymous, the least privileged.
15const (
16	kindAnonymous principalKind = iota
17	kindRoot
18	kindToken
19	kindOwner
20	// kindArchive is the holder of the archive token of an action: it holds no
21	// scope at all, only a grant to download one commit of one repository (see
22	// ArchivePrincipal).
23	kindArchive
24)
25
26// Principal is the authenticated identity of a caller: nobody, the root
27// token, the owner signed in on the web, or a stored token with its scopes
28// and repositories.
29type Principal struct {
30	kind    principalKind
31	tokenID string
32	scopes  []Scope
33	repos   []RepoName
34	// branchPrefixes limits the branches a token may write; empty = all.
35	branchPrefixes []string
36	expiresAt      time.Time
37	label          string
38	// archiveRepo and archiveSha are the grant of a kindArchive principal.
39	archiveRepo RepoName
40	archiveSha  string
41}
42
43// Anonymous returns the principal of a caller without credentials.
44func Anonymous() Principal { return Principal{kind: kindAnonymous} }
45
46// Root returns the principal of the admin token holder.
47func Root() Principal { return Principal{kind: kindRoot} }
48
49// Owner returns the principal of the owner's web session (ADR 0018): the
50// rights of root, labelled "owner" in logs and messages. Only the web
51// middleware creates it, for a valid session cookie; no secret maps to it.
52func Owner() Principal { return Principal{kind: kindOwner} }
53
54// TokenPrincipal returns the principal of the holder of a stored token.
55func TokenPrincipal(t Token) Principal {
56	return Principal{
57		kind:           kindToken,
58		tokenID:        t.ID,
59		scopes:         slices.Clone(t.Scopes),
60		repos:          slices.Clone(t.Repos),
61		branchPrefixes: slices.Clone(t.BranchPrefixes),
62		expiresAt:      t.ExpiresAt,
63		label:          t.Label,
64	}
65}
66
67// Label names the kind of principal for logs and messages: "anonymous",
68// "root", "owner" or "token".
69func (p Principal) Label() string {
70	switch p.kind {
71	case kindRoot:
72		return "root"
73	case kindOwner:
74		return "owner"
75	case kindToken:
76		return "token"
77	case kindArchive:
78		return "archive"
79	}
80	return "anonymous"
81}
82
83// IsRoot reports whether p may do everything: the root token, or the owner
84// signed in on the web.
85func (p Principal) IsRoot() bool { return p.kind == kindRoot || p.kind == kindOwner }
86
87// HasOwnerRights reports whether p is the owner: root, the owner's web
88// session, or a stored token that holds every scope and is limited neither to
89// a list of repositories nor to branch prefixes. It decides who may manage browser sessions.
90func (p Principal) HasOwnerRights() bool {
91	if p.IsRoot() {
92		return true
93	}
94	if p.kind != kindToken || len(p.repos) > 0 || p.IsBranchLimited() {
95		return false
96	}
97	for _, s := range AllScopes() {
98		if !slices.Contains(p.scopes, s) {
99			return false
100		}
101	}
102	return true
103}
104
105// IsOwner reports whether p is the owner signed in on the web.
106func (p Principal) IsOwner() bool { return p.kind == kindOwner }
107
108// IsAnonymous reports whether p has no credentials.
109func (p Principal) IsAnonymous() bool { return p.kind == kindAnonymous }
110
111// TokenID returns the ID of the stored token, or "" for other principals.
112func (p Principal) TokenID() string { return p.tokenID }
113
114// Scopes returns the scopes of a stored token; nil for other principals.
115func (p Principal) Scopes() []Scope { return slices.Clone(p.scopes) }
116
117// Repos returns the repository list of a stored token; empty means every
118// repository.
119func (p Principal) Repos() []RepoName { return slices.Clone(p.repos) }
120
121// BranchPrefixes returns the branch prefixes of a stored token; empty means
122// every branch.
123func (p Principal) BranchPrefixes() []string { return slices.Clone(p.branchPrefixes) }
124
125// IsBranchLimited reports whether p is limited to branch prefixes. A limited
126// principal has no owner rights, creates no repository and administers none.
127func (p Principal) IsBranchLimited() bool { return len(p.branchPrefixes) > 0 }
128
129// CoversBranchPrefix reports whether s lies under one of the branch prefixes of
130// p: s starts with one of them. An empty prefix, which ParseBranchPrefix never
131// lets in, covers nothing instead of everything, so that a corrupt record
132// limits a token and never widens it. It is false without prefixes: callers
133// test IsBranchLimited first.
134func (p Principal) CoversBranchPrefix(s string) bool {
135	for _, prefix := range p.branchPrefixes {
136		if prefix != "" && strings.HasPrefix(s, prefix) {
137			return true
138		}
139	}
140	return false
141}
142
143// AllowsRef reports whether p may write the ref (create, update or delete
144// it). Without branch prefixes every ref is allowed. With them, only a branch
145// "refs/heads/<name>" under one of the prefixes is: tags, every other ref and
146// a branch outside the prefixes are not.
147//
148// It judges the name of the ref only, and rests on two assumptions about the
149// repositories ermite serves. First, no symbolic ref lives under refs/heads
150// (HEAD is the only symref): ermite never creates one and receive-pack cannot,
151// so a name under a prefix never points at a branch outside it. Second, ref
152// names are stored case-sensitively (Linux file systems): "refs/heads/Agent/x"
153// is not "refs/heads/agent/x". On a case-insensitive file system the two could
154// name the same ref and the prefixes would not hold.
155func (p Principal) AllowsRef(ref string) bool {
156	if !p.IsBranchLimited() {
157		return true
158	}
159	name, ok := strings.CutPrefix(ref, "refs/heads/")
160	if !ok {
161		return false
162	}
163	if _, err := ParseBranchName(name); err != nil {
164		return false
165	}
166	return p.CoversBranchPrefix(name)
167}
168
169// ViaJobToken reports whether p is the holder of a job repository token (the
170// GITHUB_TOKEN of a CI job, or an action archive token): what it does must not
171// start CI runs, and it may not change workflows (CI-3, D3).
172func (p Principal) ViaJobToken() bool {
173	return (p.kind == kindToken || p.kind == kindArchive) && IsRepoTokenID(p.tokenID)
174}
175
176// ExpiresAt returns the expiry of a stored token; zero means none.
177func (p Principal) ExpiresAt() time.Time { return p.expiresAt }
178
179// HasScope reports whether p holds the scope, for some repository. Root and
180// the owner hold every scope; anonymous none.
181func (p Principal) HasScope(s Scope) bool {
182	switch p.kind {
183	case kindRoot, kindOwner:
184		return true
185	case kindToken:
186		return slices.Contains(p.scopes, s)
187	}
188	return false
189}
190
191// Allows reports whether p holds the scope on the repository: the scope, and
192// the repository within the token's list when it has one.
193func (p Principal) Allows(s Scope, repo RepoName) bool {
194	if !p.HasScope(s) {
195		return false
196	}
197	if p.kind == kindToken && len(p.repos) > 0 {
198		return slices.Contains(p.repos, repo)
199	}
200	return true
201}
202
203// TokenLabel returns the label of the stored token, or "" for other
204// principals.
205func (p Principal) TokenLabel() string { return p.label }
206
207// ArchiveGrant returns the one commit of one repository that p may download as
208// an archive, and ok true, when p is the holder of an archive token. Only the
209// archive endpoint of the REST subset reads it: for everything else an archive
210// principal holds no scope, so it reads what an anonymous caller reads.
211func (p Principal) ArchiveGrant() (repo RepoName, sha string, ok bool) {
212	if p.kind != kindArchive {
213		return RepoName{}, "", false
214	}
215	return p.archiveRepo, p.archiveSha, true
216}

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