Sign in

Search issue titles and bodies, change requests and comments. Private repositories you cannot read are not included.

vole
Clone

Read only, no credentials:

https://rodent.software/vole.git

Write over SSH with a registered key:

git clone ssh://git@rodent.software:2222/vole.git

A free and open-source forge that lives in your Git repo.

Code

395 commit(s) on main

update readme Keenan Nemetz · 25m ago 32290114
history blame raw 7.5 KiB
1package web
2
3// Instance admin accounts: the operations that used to need `vole admin account`.
4// Account creation goes through CreateWithInvite, so the browser never asks an
5// operator to paste somebody else's SSH key; the one-time enrollment link is the
6// credential and is shown once.
7
8import (
9 "net/http"
10 "strings"
11 "time"
12
13 "rodent.software/vole/forge/access"
14 "rodent.software/vole/forge/identity"
15 "rodent.software/vole/forge/session"
16)
17
18type adminUserView struct {
19 Page
20 Account *identity.Account
21 Sessions []session.Session
22 // RoleScope and RoleHelp keep the role picker honest for a bot: it may hold
23 // an instance role, but not the instance-admin role.
24 RoleScope string
25 RoleHelp string
26 Error string
27}
28
29type adminInviteView struct {
30 Page
31 Account *identity.Account
32 Link string
33 Expires time.Time
34 BrowserLogin bool
35}
36
37func (w *Web) adminUserView(r *http.Request, acct *identity.Account, msg string) adminUserView {
38 v := adminUserView{
39 Page: w.page(r, acct.Name), Account: acct, Error: msg,
40 RoleScope: "instance",
41 RoleHelp: "Instance roles apply to public repositories. A repository-specific role can grant more on one repository. admin is the exception: it applies to every repository, private ones included.",
42 }
43 if acct.Bot() {
44 v.RoleScope = "bot"
45 v.RoleHelp = "Instance roles apply to public repositories. A repository-specific role can grant more on one repository. A bot cannot be an instance admin."
46 }
47 if w.Sessions != nil {
48 v.Sessions, _ = w.Sessions.List(acct.Name)
49 }
50 return v
51}
52
53func (w *Web) adminInviteView(r *http.Request, acct *identity.Account, inv *identity.Invite) adminInviteView {
54 // The token is in the fragment, so it is not sent to the server, not written to
55 // an access log in front of it, and not carried in a Referer when the page
56 // links anywhere.
57 link := "/-/invite#" + inv.Token
58 if base := w.Cfg.BaseURL(); base != "" {
59 link = base + link
60 }
61 return adminInviteView{
62 Page: w.page(r, "Enrollment link for "+acct.Name), Account: acct,
63 Link: link, Expires: inv.Expires, BrowserLogin: w.enabled(),
64 }
65}
66
67// adminUserPost is the shared gate for the action endpoints: instance admin,
68// existing account, CSRF, and a parsed form.
69func (w *Web) adminUserPost(rw http.ResponseWriter, r *http.Request) (*identity.Account, *principal, bool) {
70 pr, ok := w.beginAdminPost(rw, r, maxSmallForm)
71 if !ok {
72 return nil, nil, false
73 }
74 acct, err := w.Ident.Find(r.PathValue("name"))
75 if err != nil {
76 w.notFound(rw, r)
77 return nil, nil, false
78 }
79 return acct, pr, true
80}
81
82// AdminUsersPage keeps the old roster URL working. The roster is the Accounts tab
83// on the Admin page now; the gate still runs here so an anonymous visitor is sent
84// to login and a non-admin still gets the same 404 a missing page gets.
85func (w *Web) AdminUsersPage(rw http.ResponseWriter, r *http.Request) {
86 if _, ok := w.requireInstanceAdmin(rw, r); !ok {
87 return
88 }
89 http.Redirect(rw, r, "/-/admin?tab=accounts", http.StatusMovedPermanently)
90}
91
92func (w *Web) CreateAdminUser(rw http.ResponseWriter, r *http.Request) {
93 if _, ok := w.beginAdminPost(rw, r, maxSmallForm); !ok {
94 return
95 }
96 name := strings.TrimSpace(r.PostFormValue("name"))
97 role, err := access.ParseRole(r.PostFormValue("role"))
98 if err != nil {
99 w.renderAdminTab(rw, r, "accounts", err.Error(), http.StatusBadRequest)
100 return
101 }
102 acct, inv, err := w.Ident.CreateWithInviteRole(name, role, w.accountName(r), 0)
103 if err != nil {
104 w.renderAdminTab(rw, r, "accounts", err.Error(), http.StatusBadRequest)
105 return
106 }
107 w.noStore(rw)
108 w.render(rw, "admin_invite", w.adminInviteView(r, acct, inv), 0)
109}
110
111// CreateAdminBot creates an automation account. A bot has no browser sign-in, so
112// an enrollment link would be a secret it could never use: the form collects the
113// SSH key it acts with instead. The identity layer records the same audit line
114// the CLI does, with the kind and the role.
115func (w *Web) CreateAdminBot(rw http.ResponseWriter, r *http.Request) {
116 pr, ok := w.beginAdminPost(rw, r, maxSmallForm)
117 if !ok {
118 return
119 }
120 name := strings.TrimSpace(r.PostFormValue("name"))
121 role, err := access.ParseRole(r.PostFormValue("role"))
122 if err != nil {
123 w.renderAdminTab(rw, r, "accounts", err.Error(), http.StatusBadRequest)
124 return
125 }
126 key := strings.TrimSpace(r.PostFormValue("key"))
127 if key == "" {
128 w.renderAdminTab(rw, r, "accounts", "Paste a public key for the bot. A bot signs in with a key, not a browser.", http.StatusBadRequest)
129 return
130 }
131 acct, err := w.Ident.CreateBot(name, role, key, pr.account.Name)
132 if err != nil {
133 w.renderAdminTab(rw, r, "accounts", err.Error(), http.StatusBadRequest)
134 return
135 }
136 http.Redirect(rw, r, "/-/users/"+acct.Name, http.StatusSeeOther)
137}
138
139func (w *Web) AdminUserPage(rw http.ResponseWriter, r *http.Request) {
140 if _, ok := w.requireInstanceAdmin(rw, r); !ok {
141 return
142 }
143 acct, err := w.Ident.Find(r.PathValue("name"))
144 if err != nil {
145 w.notFound(rw, r)
146 return
147 }
148 w.render(rw, "admin_user", w.adminUserView(r, acct, ""), 0)
149}
150
151func (w *Web) SetAdminUserRole(rw http.ResponseWriter, r *http.Request) {
152 acct, pr, ok := w.adminUserPost(rw, r)
153 if !ok {
154 return
155 }
156 role, err := access.ParseRole(r.PostFormValue("role"))
157 if err != nil {
158 w.render(rw, "admin_user", w.adminUserView(r, acct, err.Error()), http.StatusBadRequest)
159 return
160 }
161 if _, err := w.Ident.SetInstanceRole(acct.Name, role, pr.account.Name); err != nil {
162 w.render(rw, "admin_user", w.adminUserView(r, acct, err.Error()), http.StatusBadRequest)
163 return
164 }
165 http.Redirect(rw, r, "/-/users/"+acct.Name, http.StatusSeeOther)
166}
167
168func (w *Web) RetireAdminUser(rw http.ResponseWriter, r *http.Request) {
169 acct, pr, ok := w.adminUserPost(rw, r)
170 if !ok {
171 return
172 }
173 if got := strings.TrimSpace(r.PostFormValue("confirm")); got != acct.Name {
174 w.render(rw, "admin_user", w.adminUserView(r, acct, "Type the account name exactly to confirm retirement."), http.StatusBadRequest)
175 return
176 }
177 if _, err := w.Ident.Retire(acct.Name, pr.account.Name); err != nil {
178 w.render(rw, "admin_user", w.adminUserView(r, acct, err.Error()), http.StatusBadRequest)
179 return
180 }
181 http.Redirect(rw, r, "/-/admin?tab=accounts", http.StatusSeeOther)
182}
183
184func (w *Web) InviteAdminUser(rw http.ResponseWriter, r *http.Request) {
185 acct, pr, ok := w.adminUserPost(rw, r)
186 if !ok {
187 return
188 }
189 inv, err := w.Ident.Invite(acct.Name, pr.account.Name, 0)
190 if err != nil {
191 w.render(rw, "admin_user", w.adminUserView(r, acct, err.Error()), http.StatusBadRequest)
192 return
193 }
194 w.noStore(rw)
195 w.render(rw, "admin_invite", w.adminInviteView(r, acct, inv), 0)
196}
197
198func (w *Web) RevokeAdminUserSessions(rw http.ResponseWriter, r *http.Request) {
199 acct, _, ok := w.adminUserPost(rw, r)
200 if !ok {
201 return
202 }
203 if w.Sessions == nil {
204 w.serverError(rw, r, "sessions are unavailable")
205 return
206 }
207 if _, err := w.Sessions.RevokeAccount(acct.Name); err != nil {
208 w.serverError(rw, r, "cannot revoke sessions: "+err.Error())
209 return
210 }
211 http.Redirect(rw, r, "/-/users/"+acct.Name, http.StatusSeeOther)
212}
213
214func (w *Web) RemoveAdminUserKey(rw http.ResponseWriter, r *http.Request) {
215 acct, pr, ok := w.adminUserPost(rw, r)
216 if !ok {
217 return
218 }
219 fingerprint := strings.TrimSpace(r.PostFormValue("fingerprint"))
220 if fingerprint == "" {
221 w.render(rw, "admin_user", w.adminUserView(r, acct, "Choose a key to remove."), http.StatusBadRequest)
222 return
223 }
224 if err := w.Ident.RemoveKey(acct.Name, fingerprint, pr.account.Name); err != nil {
225 w.render(rw, "admin_user", w.adminUserView(r, acct, err.Error()), http.StatusBadRequest)
226 return
227 }
228 http.Redirect(rw, r, "/-/users/"+acct.Name, http.StatusSeeOther)
229}
230