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 // jobPerms are the permissions (key to level) of a job's own token; nil for
42 // every other principal.
43 jobPerms map[string]string
44}
45
46// Anonymous returns the principal of a caller without credentials.
47func Anonymous() Principal { return Principal{kind: kindAnonymous} }
48
49// Root returns the principal of the admin token holder.
50func Root() Principal { return Principal{kind: kindRoot} }
51
52// Owner returns the principal of the owner's web session (ADR 0018): the
53// rights of root, labelled "owner" in logs and messages. Only the web
54// middleware creates it, for a valid session cookie; no secret maps to it.
55func Owner() Principal { return Principal{kind: kindOwner} }
56
57// TokenPrincipal returns the principal of the holder of a stored token.
58func TokenPrincipal(t Token) Principal {
59 return Principal{
60 kind: kindToken,
61 tokenID: t.ID,
62 scopes: slices.Clone(t.Scopes),
63 repos: slices.Clone(t.Repos),
64 branchPrefixes: slices.Clone(t.BranchPrefixes),
65 expiresAt: t.ExpiresAt,
66 label: t.Label,
67 }
68}
69
70// Label names the kind of principal for logs and messages: "anonymous",
71// "root", "owner" or "token".
72func (p Principal) Label() string {
73 switch p.kind {
74 case kindRoot:
75 return "root"
76 case kindOwner:
77 return "owner"
78 case kindToken:
79 return "token"
80 case kindArchive:
81 return "archive"
82 }
83 return "anonymous"
84}
85
86// IsRoot reports whether p may do everything: the root token, or the owner
87// signed in on the web.
88func (p Principal) IsRoot() bool { return p.kind == kindRoot || p.kind == kindOwner }
89
90// HasOwnerRights reports whether p is the owner: root, the owner's web
91// session, or a stored token that holds every scope and is limited neither to
92// a list of repositories nor to branch prefixes. It decides who may manage browser sessions.
93func (p Principal) HasOwnerRights() bool {
94 if p.IsRoot() {
95 return true
96 }
97 if p.kind != kindToken || len(p.repos) > 0 || p.IsBranchLimited() {
98 return false
99 }
100 for _, s := range AllScopes() {
101 if !slices.Contains(p.scopes, s) {
102 return false
103 }
104 }
105 return true
106}
107
108// IsOwner reports whether p is the owner signed in on the web.
109func (p Principal) IsOwner() bool { return p.kind == kindOwner }
110
111// IsAnonymous reports whether p has no credentials.
112func (p Principal) IsAnonymous() bool { return p.kind == kindAnonymous }
113
114// TokenID returns the ID of the stored token, or "" for other principals.
115func (p Principal) TokenID() string { return p.tokenID }
116
117// Scopes returns the scopes of a stored token; nil for other principals.
118func (p Principal) Scopes() []Scope { return slices.Clone(p.scopes) }
119
120// Repos returns the repository list of a stored token; empty means every
121// repository.
122func (p Principal) Repos() []RepoName { return slices.Clone(p.repos) }
123
124// BranchPrefixes returns the branch prefixes of a stored token; empty means
125// every branch.
126func (p Principal) BranchPrefixes() []string { return slices.Clone(p.branchPrefixes) }
127
128// IsBranchLimited reports whether p is limited to branch prefixes. A limited
129// principal has no owner rights, creates no repository and administers none.
130func (p Principal) IsBranchLimited() bool { return len(p.branchPrefixes) > 0 }
131
132// CoversBranchPrefix reports whether s lies under one of the branch prefixes of
133// p: s starts with one of them. An empty prefix, which ParseBranchPrefix never
134// lets in, covers nothing instead of everything, so that a corrupt record
135// limits a token and never widens it. It is false without prefixes: callers
136// test IsBranchLimited first.
137func (p Principal) CoversBranchPrefix(s string) bool {
138 for _, prefix := range p.branchPrefixes {
139 if prefix != "" && strings.HasPrefix(s, prefix) {
140 return true
141 }
142 }
143 return false
144}
145
146// AllowsRef reports whether p may write the ref (create, update or delete
147// it). Without branch prefixes every ref is allowed. With them, only a branch
148// "refs/heads/<name>" under one of the prefixes is: tags, every other ref and
149// a branch outside the prefixes are not.
150//
151// It judges the name of the ref only, and rests on two assumptions about the
152// repositories ermite serves. First, no symbolic ref lives under refs/heads
153// (HEAD is the only symref): ermite never creates one and receive-pack cannot,
154// so a name under a prefix never points at a branch outside it. Second, ref
155// names are stored case-sensitively (Linux file systems): "refs/heads/Agent/x"
156// is not "refs/heads/agent/x". On a case-insensitive file system the two could
157// name the same ref and the prefixes would not hold.
158func (p Principal) AllowsRef(ref string) bool {
159 if !p.IsBranchLimited() {
160 return true
161 }
162 name, ok := strings.CutPrefix(ref, "refs/heads/")
163 if !ok {
164 return false
165 }
166 if _, err := ParseBranchName(name); err != nil {
167 return false
168 }
169 return p.CoversBranchPrefix(name)
170}
171
172// JobPermits reports whether p is the job's own token, for the repository, and
173// its workflow permissions give key at least the level (write includes read).
174// It is how an endpoint that GitHub gates on a permission with no ermite scope
175// (pull-requests: read) checks a job token; every other principal answers
176// false and is judged by its scopes.
177func (p Principal) JobPermits(key, level string, repo RepoName) bool {
178 if p.kind != kindToken || !IsRepoTokenID(p.tokenID) || !slices.Contains(p.repos, repo) {
179 return false
180 }
181 have := p.jobPerms[key]
182 return have == PermissionWrite || (have == PermissionRead && level == PermissionRead)
183}
184
185// ViaJobToken reports whether p is the holder of a job repository token (the
186// GITHUB_TOKEN of a CI job, or an action archive token): what it does must not
187// start CI runs, and it may not change workflows (CI-3, D3).
188func (p Principal) ViaJobToken() bool {
189 return (p.kind == kindToken || p.kind == kindArchive) && IsRepoTokenID(p.tokenID)
190}
191
192// ExpiresAt returns the expiry of a stored token; zero means none.
193func (p Principal) ExpiresAt() time.Time { return p.expiresAt }
194
195// HasScope reports whether p holds the scope, for some repository. Root and
196// the owner hold every scope; anonymous none.
197func (p Principal) HasScope(s Scope) bool {
198 switch p.kind {
199 case kindRoot, kindOwner:
200 return true
201 case kindToken:
202 return slices.Contains(p.scopes, s)
203 }
204 return false
205}
206
207// Allows reports whether p holds the scope on the repository: the scope, and
208// the repository within the token's list when it has one.
209func (p Principal) Allows(s Scope, repo RepoName) bool {
210 if !p.HasScope(s) {
211 return false
212 }
213 if p.kind == kindToken && len(p.repos) > 0 {
214 return slices.Contains(p.repos, repo)
215 }
216 return true
217}
218
219// TokenLabel returns the label of the stored token, or "" for other
220// principals.
221func (p Principal) TokenLabel() string { return p.label }
222
223// ArchiveGrant returns the one commit of one repository that p may download as
224// an archive, and ok true, when p is the holder of an archive token. Only the
225// archive endpoint of the REST subset reads it: for everything else an archive
226// principal holds no scope, so it reads what an anonymous caller reads.
227func (p Principal) ArchiveGrant() (repo RepoName, sha string, ok bool) {
228 if p.kind != kindArchive {
229 return RepoName{}, "", false
230 }
231 return p.archiveRepo, p.archiveSha, true
232}
233
234// AuthorName names who acts, for the author of a merge request or a comment: the
235// label of the token, "root" for the root token, "owner" for the owner signed
236// in on the web and for any other caller without a label.
237func AuthorName(p Principal) string {
238 switch {
239 case p.IsOwner():
240 return "owner"
241 case p.IsRoot():
242 return "root"
243 case p.TokenLabel() != "":
244 return p.TokenLabel()
245 }
246 return "owner"
247}