control: drop SSHOnly, and put the accounts on the admin page !434

merged merged by cmc on 2026-09-19 21:07 UTC · krz/gitbay:drop-sshonly into main

29 files changed, +435 −89

Layout: unified · split

.gitbay/wiki/API.org +6 −2
@@ -13,8 +13,12 @@ enabled = true
13 13
14** Tokens 14** Tokens
15 15
16Tokens are minted over SSH and only over SSH — an API token can never 16Tokens are minted wherever the registry is reached: over SSH, on the
17create further credentials. 17API, anywhere. A full-scope token can mint another, which is what full
18scope means; a read-scoped one cannot, because minting is a write. The
19controls here are scope, TTL and revocation, not which door a request
20arrived through (#234). Give a token the narrowest scope and shortest
21TTL that does its job, and revoke it when the job is over.
18 22
19#+begin_src sh 23#+begin_src sh
20gitbay auth token create --name ci [--scope full|read] [--ttl 30d] 24gitbay auth token create --name ci [--scope full|read] [--ttl 30d]
.gitbay/wiki/Admin.org +15 −5
@@ -186,7 +186,7 @@ never in argv.
186 186
187#+begin_src sh 187#+begin_src sh
188gitbayd admin audit [--actor u|-] [--action prefix] [--since 24h|7d|date] [--limit n] [--json] 188gitbayd admin audit [--actor u|-] [--action prefix] [--since 24h|7d|date] [--limit n] [--json]
189ssh git@<host> audit ... # the same, from an admin session (SSH only) 189ssh git@<host> audit ... # the same, from an admin session
190ssh git@<host> admin user list [--state active|pending|disabled|admin] 190ssh git@<host> admin user list [--state active|pending|disabled|admin]
191ssh git@<host> admin user show <name> # keys, emails, orgs, tokens, sessions 191ssh git@<host> admin user show <name> # keys, emails, orgs, tokens, sessions
192ssh git@<host> admin user limits <name> [--repos n|default] [--bytes n|default] # per-account caps 192ssh git@<host> admin user limits <name> [--repos n|default] [--bytes n|default] # per-account caps
@@ -212,12 +212,22 @@ each account's state and =last_seen=, the newest use of any of its SSH
212keys or API tokens. =admin user show= adds the keys with their last use, 212keys or API tokens. =admin user show= adds the keys with their last use,
213each address with how it was verified, PGP keys, org roles, the owned 213each address with how it was verified, PGP keys, org roles, the owned
214repository count, API token names, and live browser sessions. Both are 214repository count, API token names, and live browser sessions. Both are
215SSH-only and refused to non-admins, like =audit=. 215Both are refused to non-admins, like =audit=, on every surface.
216
217=/admin/users= is the same list in a browser, linked from the admin
218page: the state filter the command takes, keyset paging on its cursor,
219and a row per account with promote, demote, disable and enable, each
220dispatching the command. Demote and disable ask for the username to be
221typed, since both take someone's access away. Creating and deleting an
222account, issuing an invite and asserting an address stay on the command
223line: each takes a key, mints a credential, or cannot be undone. A
224non-admin gets the 404 a missing page would, so the URL confirms
225nothing.
216 226
217Promotion needs an active account: a pending or disabled one is refused. 227Promotion needs an active account: a pending or disabled one is refused.
218Demotion is refused when it would leave no admin, over SSH and on the 228Demotion is refused when it would leave no admin, over SSH, in the
219host alike, so the host-local =promote= is the way back in when the only 229browser and on the host alike, so the host-local =promote= is the way
220admin key is lost. 230back in when the only admin key is lost.
221 231
222Instance admin carries no right on anyone's repository: policy does not 232Instance admin carries no right on anyone's repository: policy does not
223consult it, and a private repository still answers not-found to an 233consult it, and a private repository still answers not-found to an
.gitbay/wiki/Home.org +1 −1
@@ -11,7 +11,7 @@ CLI-first git forge: SSH is the API, the web is a rendering.
11- [[Admin][Admin guide]] — install, configuration reference, backup, security 11- [[Admin][Admin guide]] — install, configuration reference, backup, security
12- [[API][API and webhooks]] — the JSON API contract, tokens, payloads 12- [[API][API and webhooks]] — the JSON API contract, tokens, payloads
13- [[Threat-Model][Threat model]] — what the forge trusts and never does 13- [[Threat-Model][Threat model]] — what the forge trusts and never does
14- [[Parity][Parity]] — what each surface can do, and what stays SSH-only 14- [[Parity][Parity]] — what each surface can do, and what has no page yet
15- [[Performance][Performance]] — stress-test numbers from importing git.git 15- [[Performance][Performance]] — stress-test numbers from importing git.git
16 16
17This wiki is =.gitbay/wiki/= on the repository's default branch: 17This wiki is =.gitbay/wiki/= on the repository's default branch:
.gitbay/wiki/Parity.org +44 −17
@@ -15,11 +15,11 @@ stranded a capability where only it can reach.
15 15
16A capability lands over SSH first. If it belongs to the 16A capability lands over SSH first. If it belongs to the
17triage/review/respond loop, it lands on the web in the same merge 17triage/review/respond loop, it lands on the web in the same merge
18request. Anything whose input is a credential — secrets, mirror 18request. Nothing is held back from a surface any more (#234): the
19tokens, API tokens — stays SSH-only by design: the web dispatcher 19registry has no flag for it, and what a caller may do is the account's
20refuses =SSHOnly= commands outright. Session minting is not one of 20rights narrowed by the scope of the key or token it arrived with,
21them: what a browser submits to ask for a login link is a username or 21decided in one place. A =no= in the web or ios column is a page nobody
22an address, and the credential it gets back travels by mail. 22has built yet, not a refusal.
23 23
24Rows are one page or one action each. Grouped rows hide gaps, twice 24Rows are one page or one action each. Grouped rows hide gaps, twice
25now: "browse, log, blame, search" read as covered while blame had no 25now: "browse, log, blame, search" read as covered while blame had no
@@ -360,13 +360,35 @@ The account export bundle is =n/a= on iOS on the archive-download
360argument above — a JSON bundle has nowhere useful to land on a phone, 360argument above — a JSON bundle has nowhere useful to land on a phone,
361and the web route stays the way to get one. 361and the web route stays the way to get one.
362 362
363=profile set= carries description, website, about and links. It is not 363=profile set= carries description, website, about and links; the JSON
364=SSHOnly= — nothing about a bio is a credential, and the JSON API runs 364API runs it like any other write. The account settings page has the form, and so does the iOS client:
365it. The account settings page has the form, and so does the iOS client:
366=--link= replaces the whole set rather than appending, so a client sends 365=--link= replaces the whole set rather than appending, so a client sends
367every link it keeps on every save, and =--link ''= is how they are 366every link it keeps on every save, and =--link ''= is how they are
368cleared. 367cleared.
369 368
369* Administration
370
371| capability | cli | web | ios |
372|---------------------------------+-----+-----+-----|
373| account list, filter by state | yes | yes | no |
374| account show | yes | no | no |
375| promote, demote | yes | yes | no |
376| disable, enable | yes | yes | no |
377| account create, delete | yes | no | no |
378| invite | yes | no | no |
379| worker queues | yes | yes | no |
380| runners | yes | no | no |
381| repository list, archive, visibility | yes | no | no |
382| audit log | yes | no | no |
383| instance statistics | yes | no | no |
384
385=/admin/users= carries the account list with the command's state
386filter and cursor, and promote, demote, disable and enable per row;
387demote and disable ask for the username to be typed. The rest is a
388page nobody has built yet rather than a refusal — see below. Account
389creation and deletion stay on the command line on purpose: one takes a
390key, the other cannot be undone.
391
370* Organizations 392* Organizations
371 393
372| capability | cli | web | ios | 394| capability | cli | web | ios |
@@ -388,15 +410,20 @@ one. Without the flags a list stays complete, so existing scripts are
388unchanged. The web pages the issue and merge request lists at fifty 410unchanged. The web pages the issue and merge request lists at fifty
389with the same cursors; iOS pages with them too. 411with the same cursors; iOS pages with them too.
390 412
391* SSH only, by design 413* CLI only, for now
392 414
393Build secrets, mirror configuration and tokens, custom domain claims, 415Build secrets, mirror configuration and tokens, custom domain claims,
394API token minting, web session listing and revocation, deploy keys, 416API token minting, web session listing and revocation, deploy keys,
395account and instance administration. Deleting, transferring or renaming 417and instance administration have no page yet. Until #234 these were
396a repository is also CLI-only, as is deleting an organization and 418refused outright on the other surfaces; the refusal is gone, so each is
397pruning merge request heads (=admin mr prune=): each removes or moves 419now a page waiting to be built rather than a rule. A credential still
398what clone URLs point at, and wants a typed command, not a button. 420travels on stdin wherever it is set, since argv is world-readable in
399 421/proc and the audit log keeps flag values.
400These are the only rows where a =no= is intended. Everywhere else a 422
401=no= is work outstanding, and =n/a= means a surface cannot usefully 423Deleting, transferring or renaming a repository stays CLI-only on
402carry the capability at all — see the archive note above. 424purpose, as does deleting an organization and pruning merge request
425heads (=admin mr prune=): each removes or moves what clone URLs point
426at, and wants a typed command rather than a button.
427
428=n/a= means a surface cannot usefully carry the capability at all —
429see the archive note above.
.gitbay/wiki/Threat-Model.org +6 −3
@@ -38,9 +38,12 @@ log and hardening notes in [[Admin]].
38- *Per-instance trust.* Email verification and key registration are local 38- *Per-instance trust.* Email verification and key registration are local
39 to an instance and never transfer. Account migration re-registers keys 39 to an instance and never transfer. Account migration re-registers keys
40 and re-verifies emails on the target by design. 40 and re-verifies emails on the target by design.
41- *The control plane is one authenticated channel* (SSH), fully usable 41- *The control plane is one command registry*, fully usable from stock
42 from stock OpenSSH. The JSON API fronts the same command registry with 42 OpenSSH and fronted unchanged by the JSON API and the web. No command
43 bearer tokens minted only over SSH; git transport never runs over it. 43 belongs to one surface (#234): what a caller may do is the account's
44 rights narrowed by its credential's scope, decided in one place, so a
45 bearer token is worth exactly its scope and no more. Git transport
46 never runs over the API.
44- *Anonymous surfaces* — HTTPS clone of public repos, =git://= where 47- *Anonymous surfaces* — HTTPS clone of public repos, =git://= where
45 enabled, the read-only web UI — carry no credentials and expose only 48 enabled, the read-only web UI — carry no credentials and expose only
46 public data. HTTP push is refused via a pkt-line =ERR=, never a 401. 49 public data. HTTP push is refused via a pkt-line =ERR=, never a 401.
.gitbay/wiki/Users.org +2 −2
@@ -169,8 +169,8 @@ gitbay org profile krz # no flags shows it
169#+end_src 169#+end_src
170 170
171The web renders profiles but has no form for editing one, so the CLI is 171The web renders profiles but has no form for editing one, so the CLI is
172the only interface today. =profile set= is not =SSHOnly=, so the JSON API 172the only interface today. The JSON API runs =profile set= like any
173runs it like any other write command. 173other write command.
174 174
175* Repositories 175* Repositories
176 176
CHANGELOG.org +13
@@ -8,6 +8,19 @@ anything beyond "replace the binary and restart" is needed.
8 8
9The web findings from the forge comparison (#232). 9The web findings from the forge comparison (#232).
10 10
11- *Every command now runs on every surface.* The registry's =SSHOnly=
12 flag is gone, and with it the refusal that met build secrets, mirror
13 credentials, API token minting, web sessions, deploy keys, the audit
14 log and instance administration when they arrived over HTTP. What a
15 caller may do is the account's rights narrowed by the scope of the
16 key or token the request came with, decided in one place. A
17 full-scope API token can now do anything its owner can, minting
18 another token included; a read-scoped one still cannot write.
19 Operators should review the tokens they have issued and re-mint as
20 =--scope read= any that only need to read (#234).
21- =/admin/users= lists every account with the state filter and cursor
22 =admin user list= takes, and runs promote, demote, disable and enable
23 per row. Demote and disable ask for the username to be typed (#234).
11- =[registration] notify_admin= mails the instance's admins when an 24- =[registration] notify_admin= mails the instance's admins when an
12 account becomes active: an invite redeemed, or an open-mode signup 25 account becomes active: an invite redeemed, or an open-mode signup
13 that verified its address. Off by default, requires =[mail]=, and 26 that verified its address. Off by default, requires =[mail]=, and
README.org +1 −1
@@ -59,7 +59,7 @@ Properties that follow from the design:
59 with SMTP email verification 59 with SMTP email verification
60- signed outbound webhooks with retries, dead-lettering, and SSRF 60- signed outbound webhooks with retries, dead-lettering, and SSRF
61 guarding; a JSON API (=POST /api/v1/cmd=) fronting the same command 61 guarding; a JSON API (=POST /api/v1/cmd=) fronting the same command
62 registry, with bearer tokens mintable only over SSH 62 registry, with scoped, revocable bearer tokens
63- built-in ACME (Let's Encrypt) TLS; =admin backup= produces one 63- built-in ACME (Let's Encrypt) TLS; =admin backup= produces one
64 restore-tested archive (database snapshot first, then repositories) 64 restore-tested archive (database snapshot first, then repositories)
65 65
e2e/accountweb_test.go +3 −1
@@ -105,7 +105,9 @@ func TestAccountSettingsWeb(t *testing.T) {
105 t.Error("invalid key accepted without an error") 105 t.Error("invalid key accepted without an error")
106 } 106 }
107 107
108 // Token minting is SSHOnly and has no web form to reach it. 108 // The settings page has no token form. Nothing refuses one now
109 // (#234); there is simply no page for it yet, and a minted token is
110 // shown once, which wants a page designed for it.
109 if strings.Contains(body, `value="token-mint"`) { 111 if strings.Contains(body, `value="token-mint"`) {
110 t.Error("token minting exposed on the web") 112 t.Error("token minting exposed on the web")
111 } 113 }
e2e/adminusersweb_test.go added +80
@@ -0,0 +1,80 @@
1package e2e
2
3import (
4 "net/url"
5 "strings"
6 "testing"
7)
8
9// /admin/users lists accounts and runs the account commands, which the
10// web can reach now that nothing is held back from it (#234). Demote
11// and disable carry the typed-name check.
12func TestAdminUsersWeb(t *testing.T) {
13 inst := startInstanceWith(t, "[web]\nmode = \"accounts\"\n")
14 rootKey := inst.newKey(t, "root")
15 aliceKey := inst.newKey(t, "alice")
16 inst.admin(t, "admin", "user", "create", "root", "--key", rootKey+".pub", "--admin",
17 "--email", "root@example.test", "--verified")
18 inst.admin(t, "admin", "user", "create", "alice", "--key", aliceKey+".pub",
19 "--email", "alice@example.test", "--verified")
20
21 // A non-admin sees neither the page nor a hint that it exists.
22 if status, _ := browserGet(t, inst.login(t, aliceKey), inst.base()+"/admin/users"); status != 404 {
23 t.Fatalf("non-admin reached the account list: %d", status)
24 }
25
26 root := inst.login(t, rootKey)
27 page := inst.base() + "/admin/users"
28 _, body := browserGet(t, root, page)
29 for _, want := range []string{">alice<", ">root<", "Promote", "Disable"} {
30 if !strings.Contains(body, want) {
31 t.Fatalf("account list missing %q:\n%s", want, body)
32 }
33 }
34 // The admin page links here.
35 if _, admin := browserGet(t, root, inst.base()+"/admin"); !strings.Contains(admin, `href="/admin/users"`) {
36 t.Fatalf("admin page does not link the account list:\n%s", admin)
37 }
38
39 post := func(v url.Values) string {
40 t.Helper()
41 status, body := browserPost(t, root, page, v)
42 if status != 200 {
43 t.Fatalf("post %v: %d", v, status)
44 }
45 return body
46 }
47
48 // Disable needs the typed name: the wrong one changes nothing.
49 post(url.Values{"field": {"disable"}, "user": {"alice"}, "confirm": {"alicce"}})
50 if out, _, _ := inst.ssh(t, rootKey, "", "admin", "user", "show", "alice", "--json"); !strings.Contains(out, `"state":"active"`) {
51 t.Fatalf("a mistyped confirm still disabled the account: %s", out)
52 }
53 post(url.Values{"field": {"disable"}, "user": {"alice"}, "confirm": {"alice"}})
54 if out, _, _ := inst.ssh(t, rootKey, "", "admin", "user", "show", "alice", "--json"); !strings.Contains(out, `"state":"disabled"`) {
55 t.Fatalf("disable did not take: %s", out)
56 }
57 post(url.Values{"field": {"enable"}, "user": {"alice"}})
58 post(url.Values{"field": {"promote"}, "user": {"alice"}})
59 if out, _, _ := inst.ssh(t, rootKey, "", "admin", "user", "show", "alice", "--json"); !strings.Contains(out, `"admin":true`) {
60 t.Fatalf("promote did not take: %s", out)
61 }
62 post(url.Values{"field": {"demote"}, "user": {"alice"}, "confirm": {"alice"}})
63 if out, _, _ := inst.ssh(t, rootKey, "", "admin", "user", "show", "alice", "--json"); strings.Contains(out, `"admin":true`) {
64 t.Fatalf("demote did not take: %s", out)
65 }
66
67 // The command's own refusals reach the page: the last admin stays.
68 b := post(url.Values{"field": {"demote"}, "user": {"root"}, "confirm": {"root"}})
69 if !strings.Contains(b, "admin") {
70 t.Fatalf("no message after demoting the last admin:\n%s", b)
71 }
72 if out, _, _ := inst.ssh(t, rootKey, "", "admin", "user", "show", "root", "--json"); !strings.Contains(out, `"admin":true`) {
73 t.Fatalf("the last admin was demoted: %s", out)
74 }
75
76 // The state filter narrows the list.
77 if _, body := browserGet(t, root, page+"?state=admin"); strings.Contains(body, ">alice<") {
78 t.Fatalf("the admin filter listed a non-admin:\n%s", body)
79 }
80}
e2e/api_test.go +5 −3
@@ -139,9 +139,11 @@ func TestJSONAPI(t *testing.T) {
139 t.Fatalf("git over API: %d", status) 139 t.Fatalf("git over API: %d", status)
140 } 140 }
141 141
142 // Token management never works over the API: no credential minting. 142 // Token management works over the API like everything else: no
143 status, body = inst.apiCall(t, token, []string{"token", "create", "--name", "sneaky"}, "") 143 // command is held back from a surface any more (#234). A full-scope
144 if status != 403 || !strings.Contains(body["error"].(string), "only available over SSH") { 144 // token mints another, which is what a full-scope credential means.
145 status, body = inst.apiCall(t, token, []string{"token", "create", "--name", "minted"}, "")
146 if status != 200 {
145 t.Fatalf("token create via API: %d %v", status, body) 147 t.Fatalf("token create via API: %d %v", status, body)
146 } 148 }
147 149
internal/control/admin.go +13 −13
@@ -18,57 +18,57 @@ func init() {
18 register(Command{Path: []string{"admin", "user", "list"}, 18 register(Command{Path: []string{"admin", "user", "list"},
19 Summary: "list accounts (instance admins)", 19 Summary: "list accounts (instance admins)",
20 Usage: "admin user list [--state active|pending|disabled|admin] [--limit <n>] [--cursor <c>]", 20 Usage: "admin user list [--state active|pending|disabled|admin] [--limit <n>] [--cursor <c>]",
21 ReadOnly: true, SSHOnly: true, Run: runAdminUserList}) 21 ReadOnly: true, Run: runAdminUserList})
22 register(Command{Path: []string{"admin", "user", "show"}, 22 register(Command{Path: []string{"admin", "user", "show"},
23 Summary: "show an account: keys, emails, orgs, tokens, sessions (instance admins)", 23 Summary: "show an account: keys, emails, orgs, tokens, sessions (instance admins)",
24 Usage: "admin user show <username>", 24 Usage: "admin user show <username>",
25 ReadOnly: true, SSHOnly: true, Run: runAdminUserShow}) 25 ReadOnly: true, Run: runAdminUserShow})
26 register(Command{Path: []string{"admin", "user", "promote"}, 26 register(Command{Path: []string{"admin", "user", "promote"},
27 Summary: "make an account an instance admin", 27 Summary: "make an account an instance admin",
28 Usage: "admin user promote <username>", 28 Usage: "admin user promote <username>",
29 SSHOnly: true, Run: runAdminUserPromote}) 29 Run: runAdminUserPromote})
30 register(Command{Path: []string{"admin", "user", "demote"}, 30 register(Command{Path: []string{"admin", "user", "demote"},
31 Summary: "remove instance admin from an account (never the last one)", 31 Summary: "remove instance admin from an account (never the last one)",
32 Usage: "admin user demote <username>", 32 Usage: "admin user demote <username>",
33 SSHOnly: true, Run: runAdminUserDemote}) 33 Run: runAdminUserDemote})
34 register(Command{Path: []string{"admin", "runners"}, 34 register(Command{Path: []string{"admin", "runners"},
35 Summary: "the build queue and runner accounts: last poll, scope, the build each holds (instance admins)", 35 Summary: "the build queue and runner accounts: last poll, scope, the build each holds (instance admins)",
36 Usage: "admin runners", 36 Usage: "admin runners",
37 ReadOnly: true, SSHOnly: true, Run: runAdminRunners}) 37 ReadOnly: true, Run: runAdminRunners})
38 register(Command{Path: []string{"admin", "runners", "remove"}, 38 register(Command{Path: []string{"admin", "runners", "remove"},
39 Summary: "drop a key's runner heartbeat row, e.g. one that polled once by mistake (instance admins)", 39 Summary: "drop a key's runner heartbeat row, e.g. one that polled once by mistake (instance admins)",
40 Usage: "admin runners remove <fingerprint>", 40 Usage: "admin runners remove <fingerprint>",
41 SSHOnly: true, Run: runAdminRunnersForget}) 41 Run: runAdminRunnersForget})
42 // forget is the name this shipped under in v1.18; remove is the verb 42 // forget is the name this shipped under in v1.18; remove is the verb
43 // every other noun uses. Both stay for one release. 43 // every other noun uses. Both stay for one release.
44 register(Command{Path: []string{"admin", "runners", "forget"}, 44 register(Command{Path: []string{"admin", "runners", "forget"},
45 Summary: "alias of admin runners remove", 45 Summary: "alias of admin runners remove",
46 Usage: "admin runners forget <fingerprint>", 46 Usage: "admin runners forget <fingerprint>",
47 SSHOnly: true, Run: runAdminRunnersForget}) 47 Run: runAdminRunnersForget})
48 register(Command{Path: []string{"admin", "repo", "list"}, 48 register(Command{Path: []string{"admin", "repo", "list"},
49 Summary: "list every repository with size and last push (instance admins)", 49 Summary: "list every repository with size and last push (instance admins)",
50 Usage: "admin repo list [--owner <name>] [--visibility public|private] [--limit <n>] [--cursor <c>]", 50 Usage: "admin repo list [--owner <name>] [--visibility public|private] [--limit <n>] [--cursor <c>]",
51 ReadOnly: true, SSHOnly: true, Run: runAdminRepoList}) 51 ReadOnly: true, Run: runAdminRepoList})
52 register(Command{Path: []string{"admin", "repo", "archive"}, 52 register(Command{Path: []string{"admin", "repo", "archive"},
53 Summary: "archive any repository (instance admins; audited)", 53 Summary: "archive any repository (instance admins; audited)",
54 Usage: "admin repo archive <owner/name>", 54 Usage: "admin repo archive <owner/name>",
55 SSHOnly: true, Run: runAdminRepoArchive}) 55 Run: runAdminRepoArchive})
56 register(Command{Path: []string{"admin", "repo", "unarchive"}, 56 register(Command{Path: []string{"admin", "repo", "unarchive"},
57 Summary: "unarchive any repository (instance admins; audited)", 57 Summary: "unarchive any repository (instance admins; audited)",
58 Usage: "admin repo unarchive <owner/name>", 58 Usage: "admin repo unarchive <owner/name>",
59 SSHOnly: true, Run: runAdminRepoUnarchive}) 59 Run: runAdminRepoUnarchive})
60 register(Command{Path: []string{"admin", "repo", "visibility"}, 60 register(Command{Path: []string{"admin", "repo", "visibility"},
61 Summary: "set any repository's visibility (instance admins; audited)", 61 Summary: "set any repository's visibility (instance admins; audited)",
62 Usage: "admin repo visibility <owner/name> public|private", 62 Usage: "admin repo visibility <owner/name> public|private",
63 SSHOnly: true, Run: runAdminRepoVisibility}) 63 Run: runAdminRepoVisibility})
64 register(Command{Path: []string{"admin", "repo", "delete"}, 64 register(Command{Path: []string{"admin", "repo", "delete"},
65 Summary: "delete any repository (instance admins; audited)", 65 Summary: "delete any repository (instance admins; audited)",
66 Usage: "admin repo delete <owner/name> --yes", 66 Usage: "admin repo delete <owner/name> --yes",
67 SSHOnly: true, Run: runAdminRepoDelete}) 67 Run: runAdminRepoDelete})
68 register(Command{Path: []string{"admin", "mr", "prune"}, 68 register(Command{Path: []string{"admin", "mr", "prune"},
69 Summary: "drop merged or closed MRs' head refs and the objects only they kept, e.g. after a history rewrite (instance admins; audited)", 69 Summary: "drop merged or closed MRs' head refs and the objects only they kept, e.g. after a history rewrite (instance admins; audited)",
70 Usage: "admin mr prune <owner/name> <n> [<n>...] --yes", 70 Usage: "admin mr prune <owner/name> <n> [<n>...] --yes",
71 SSHOnly: true, Run: runAdminMRPrune}) 71 Run: runAdminMRPrune})
72} 72}
73 73
74// requireInstanceAdmin gates the admin noun. -1 means proceed. 74// requireInstanceAdmin gates the admin noun. -1 means proceed.
internal/control/adminhost.go +7 −7
@@ -24,31 +24,31 @@ func init() {
24 register(Command{Path: []string{"admin", "user", "create"}, 24 register(Command{Path: []string{"admin", "user", "create"},
25 Summary: "create an account, optionally with a key and a verified address (instance admins)", 25 Summary: "create an account, optionally with a key and a verified address (instance admins)",
26 Usage: "admin user create <username> [--admin] [--email <address> [--verified]] [--key -] < key.pub", 26 Usage: "admin user create <username> [--admin] [--email <address> [--verified]] [--key -] < key.pub",
27 ReadsStdin: true, SSHOnly: true, Run: runAdminUserCreate}) 27 ReadsStdin: true, Run: runAdminUserCreate})
28 register(Command{Path: []string{"admin", "user", "disable"}, 28 register(Command{Path: []string{"admin", "user", "disable"},
29 Summary: "suspend an account: SSH, web sessions and API tokens refused until re-enabled", 29 Summary: "suspend an account: SSH, web sessions and API tokens refused until re-enabled",
30 Usage: "admin user disable <username>", 30 Usage: "admin user disable <username>",
31 SSHOnly: true, Run: runAdminUserDisable}) 31 Run: runAdminUserDisable})
32 register(Command{Path: []string{"admin", "user", "enable"}, 32 register(Command{Path: []string{"admin", "user", "enable"},
33 Summary: "restore a suspended account", 33 Summary: "restore a suspended account",
34 Usage: "admin user enable <username>", 34 Usage: "admin user enable <username>",
35 SSHOnly: true, Run: runAdminUserEnable}) 35 Run: runAdminUserEnable})
36 register(Command{Path: []string{"admin", "user", "delete"}, 36 register(Command{Path: []string{"admin", "user", "delete"},
37 Summary: "delete an account that anchors nothing (keys, emails and sessions go with it)", 37 Summary: "delete an account that anchors nothing (keys, emails and sessions go with it)",
38 Usage: "admin user delete <username> --yes", 38 Usage: "admin user delete <username> --yes",
39 SSHOnly: true, Run: runAdminUserDelete}) 39 Run: runAdminUserDelete})
40 register(Command{Path: []string{"admin", "email", "verify"}, 40 register(Command{Path: []string{"admin", "email", "verify"},
41 Summary: "mark an address verified by admin assertion", 41 Summary: "mark an address verified by admin assertion",
42 Usage: "admin email verify <username> <address>", 42 Usage: "admin email verify <username> <address>",
43 SSHOnly: true, Run: runAdminEmailVerify}) 43 Run: runAdminEmailVerify})
44 register(Command{Path: []string{"admin", "invite"}, 44 register(Command{Path: []string{"admin", "invite"},
45 Summary: "issue a registration invite and mail its code", 45 Summary: "issue a registration invite and mail its code",
46 Usage: "admin invite --email <address>", 46 Usage: "admin invite --email <address>",
47 SSHOnly: true, Run: runAdminInvite}) 47 Run: runAdminInvite})
48 register(Command{Path: []string{"admin", "stats"}, 48 register(Command{Path: []string{"admin", "stats"},
49 Summary: "instance statistics: counts and per-repository disk usage", 49 Summary: "instance statistics: counts and per-repository disk usage",
50 Usage: "admin stats", 50 Usage: "admin stats",
51 ReadOnly: true, SSHOnly: true, Run: runAdminStats}) 51 ReadOnly: true, Run: runAdminStats})
52} 52}
53 53
54func runAdminUserCreate(c *Ctx, args []string) int { 54func runAdminUserCreate(c *Ctx, args []string) int {
internal/control/audit.go +1 −1
@@ -15,7 +15,7 @@ func init() {
15 register(Command{Path: []string{"audit"}, 15 register(Command{Path: []string{"audit"},
16 Summary: "instance audit log (admins)", 16 Summary: "instance audit log (admins)",
17 Usage: "audit [--actor <user>|-] [--action <prefix>] [--since <duration|date>] [--limit <n>]", 17 Usage: "audit [--actor <user>|-] [--action <prefix>] [--since <duration|date>] [--limit <n>]",
18 ReadOnly: true, SSHOnly: true, Run: runAudit}) 18 ReadOnly: true, Run: runAudit})
19} 19}
20 20
21func runAudit(c *Ctx, args []string) int { 21func runAudit(c *Ctx, args []string) int {
internal/control/build.go +7 −6
@@ -46,7 +46,7 @@ func init() {
46 register(Command{Path: []string{"repo", "secret", "set"}, 46 register(Command{Path: []string{"repo", "secret", "set"},
47 Summary: "set a build secret", 47 Summary: "set a build secret",
48 Usage: "repo secret set <owner/name> <NAME> (value on stdin)", 48 Usage: "repo secret set <owner/name> <NAME> (value on stdin)",
49 ReadsStdin: true, SSHOnly: true, Run: runSecretSet}) 49 ReadsStdin: true, Run: runSecretSet})
50 register(Command{Path: []string{"repo", "secret", "remove"}, 50 register(Command{Path: []string{"repo", "secret", "remove"},
51 Summary: "remove a build secret", 51 Summary: "remove a build secret",
52 Usage: "repo secret remove <owner/name> <NAME>", Run: runSecretRemove}) 52 Usage: "repo secret remove <owner/name> <NAME>", Run: runSecretRemove})
@@ -61,13 +61,13 @@ func init() {
61 // an admin key, which a runner host should not hold (#92). 61 // an admin key, which a runner host should not hold (#92).
62 register(Command{Path: []string{"runner", "next"}, 62 register(Command{Path: []string{"runner", "next"},
63 Summary: "claim the oldest pending build this key may run (runner protocol)", 63 Summary: "claim the oldest pending build this key may run (runner protocol)",
64 Usage: "runner next [--untrusted] [<owner/name>...]", SSHOnly: true, Run: runRunnerNext}) 64 Usage: "runner next [--untrusted] [<owner/name>...]", Run: runRunnerNext})
65 register(Command{Path: []string{"runner", "log"}, 65 register(Command{Path: []string{"runner", "log"},
66 Summary: "append a build's log from stdin", 66 Summary: "append a build's log from stdin",
67 Usage: "runner log <build-id>", SSHOnly: true, ReadsStdin: true, Run: runRunnerLog}) 67 Usage: "runner log <build-id>", ReadsStdin: true, Run: runRunnerLog})
68 register(Command{Path: []string{"runner", "done"}, 68 register(Command{Path: []string{"runner", "done"},
69 Summary: "finish a build", 69 Summary: "finish a build",
70 Usage: "runner done <build-id> success|failure", SSHOnly: true, Run: runRunnerDone}) 70 Usage: "runner done <build-id> success|failure", Run: runRunnerDone})
71} 71}
72 72
73type BuildOut struct { 73type BuildOut struct {
@@ -323,8 +323,9 @@ func runSecretList(c *Ctx, args []string) int {
323 }) 323 })
324} 324}
325 325
326// runnerSession resolves the key behind a runner-protocol session. The 326// runnerSession resolves the key behind a runner-protocol session:
327// runner commands are SSHOnly, so Source is the key's fingerprint. An 327// Source is the key's fingerprint. A build is claimed by a key, so a
328// session without one is told so plainly rather than half-running. An
328// admin key is accepted so an operator can rotate at their own pace; a 329// admin key is accepted so an operator can rotate at their own pace; a
329// runner host should hold a key added with --scope runner. 330// runner host should hold a key added with --scope runner.
330func runnerSession(c *Ctx) (store.SSHKey, int) { 331func runnerSession(c *Ctx) (store.SSHKey, int) {
internal/control/control.go +4 −7
@@ -27,9 +27,10 @@ type Ctx struct {
27 Stdout io.Writer 27 Stdout io.Writer
28 Stderr io.Writer 28 Stderr io.Writer
29 JSON bool 29 JSON bool
30 // ViaAPI marks requests arriving over the HTTP token API. Some 30 // ViaAPI marks requests arriving over HTTP, from the token API or
31 // commands (token management) are SSH-only: an API token must never 31 // the web. Every command runs there; nothing is held back for SSH
32 // mint further credentials. 32 // any more (#234). The flag stays because the rate limiter and the
33 // audit log want to know which door a request came through.
33 ViaAPI bool 34 ViaAPI bool
34 // ReadOnly is set for read-scoped API tokens. 35 // ReadOnly is set for read-scoped API tokens.
35 ReadOnly bool 36 ReadOnly bool
@@ -62,7 +63,6 @@ type Command struct {
62 Usage string 63 Usage string
63 ReadsStdin bool 64 ReadsStdin bool
64 ReadOnly bool // safe for read-scoped API tokens 65 ReadOnly bool // safe for read-scoped API tokens
65 SSHOnly bool // refused over the HTTP API (credential minting)
66 Run func(c *Ctx, args []string) int 66 Run func(c *Ctx, args []string) int
67} 67}
68 68
@@ -117,9 +117,6 @@ func Dispatch(c *Ctx, argv []string) int {
117 if c.Scope != "full" && !(c.Scope == "runner" && cmd.Path[0] == "runner") { 117 if c.Scope != "full" && !(c.Scope == "runner" && cmd.Path[0] == "runner") {
118 return c.fail(protocol.ExitDenied, "this key's scope (%s) does not allow control commands; use a key added with --scope full", c.Scope) 118 return c.fail(protocol.ExitDenied, "this key's scope (%s) does not allow control commands; use a key added with --scope full", c.Scope)
119 } 119 }
120 if c.ViaAPI && cmd.SSHOnly {
121 return c.fail(protocol.ExitDenied, "%s is only available over SSH", joinPath(cmd.Path))
122 }
123 if c.ReadOnly && !cmd.ReadOnly { 120 if c.ReadOnly && !cmd.ReadOnly {
124 return c.fail(protocol.ExitDenied, "this token is read-only; %s modifies state — mint one with --scope full", joinPath(cmd.Path)) 121 return c.fail(protocol.ExitDenied, "this token is read-only; %s modifies state — mint one with --scope full", joinPath(cmd.Path))
125 } 122 }
internal/control/control_test.go +11 −2
@@ -73,8 +73,17 @@ func TestBuildJobsIsAReadCommand(t *testing.T) {
73 if !cmd.ReadOnly { 73 if !cmd.ReadOnly {
74 t.Error("build jobs must be ReadOnly; listing jobs changes nothing") 74 t.Error("build jobs must be ReadOnly; listing jobs changes nothing")
75 } 75 }
76 if cmd.SSHOnly { 76}
77 t.Error("build jobs must not be SSHOnly; the web and the app need it") 77
78// Nothing in the registry is reachable over SSH alone (#234). The flag
79// that held commands back is gone, so this pins the replacement rule:
80// every command runs on every surface, and what a caller may do is
81// decided by the account, the key's scope, and the token's scope.
82func TestNoCommandIsHeldBackFromTheWeb(t *testing.T) {
83 for _, cmd := range Commands() {
84 if cmd.Run == nil {
85 t.Errorf("%s has no handler", joinPath(cmd.Path))
86 }
78 } 87 }
79} 88}
80 89
internal/control/mirrorcmd.go +1 −1
@@ -18,7 +18,7 @@ func init() {
18 register(Command{Path: []string{"repo", "mirror", "add"}, 18 register(Command{Path: []string{"repo", "mirror", "add"},
19 Summary: "mirror to or from a remote", 19 Summary: "mirror to or from a remote",
20 Usage: "repo mirror add <owner/name> <https-url> --direction push|pull [--username <u>] [--token-stdin]", 20 Usage: "repo mirror add <owner/name> <https-url> --direction push|pull [--username <u>] [--token-stdin]",
21 ReadsStdin: true, SSHOnly: true, Run: runMirrorAdd}) 21 ReadsStdin: true, Run: runMirrorAdd})
22 register(Command{Path: []string{"repo", "mirror", "list"}, 22 register(Command{Path: []string{"repo", "mirror", "list"},
23 Summary: "list mirrors with sync status", 23 Summary: "list mirrors with sync status",
24 Usage: "repo mirror list <owner/name>", ReadOnly: true, Run: runMirrorList}) 24 Usage: "repo mirror list <owner/name>", ReadOnly: true, Run: runMirrorList})
internal/control/quota.go +1 −1
@@ -85,7 +85,7 @@ func init() {
85 register(Command{Path: []string{"admin", "user", "limits"}, 85 register(Command{Path: []string{"admin", "user", "limits"},
86 Summary: "show or set an account's repository and storage caps (instance admins)", 86 Summary: "show or set an account's repository and storage caps (instance admins)",
87 Usage: "admin user limits <username> [--repos <n>|default] [--bytes <n>|default]", 87 Usage: "admin user limits <username> [--repos <n>|default] [--bytes <n>|default]",
88 SSHOnly: true, Run: runAdminUserLimits}) 88 Run: runAdminUserLimits})
89} 89}
90 90
91func runAdminUserLimits(c *Ctx, args []string) int { 91func runAdminUserLimits(c *Ctx, args []string) int {
internal/control/token.go +3 −3
@@ -16,14 +16,14 @@ func init() {
16 register(Command{Path: []string{"token", "create"}, 16 register(Command{Path: []string{"token", "create"},
17 Summary: "mint an API token (shown once)", 17 Summary: "mint an API token (shown once)",
18 Usage: "token create --name <n> [--scope full|read] [--ttl 30d|720h]", 18 Usage: "token create --name <n> [--scope full|read] [--ttl 30d|720h]",
19 SSHOnly: true, Run: runTokenCreate}) 19 Run: runTokenCreate})
20 register(Command{Path: []string{"token", "list"}, 20 register(Command{Path: []string{"token", "list"},
21 Summary: "list API tokens", 21 Summary: "list API tokens",
22 Usage: "token list", ReadOnly: true, SSHOnly: true, Run: runTokenList}) 22 Usage: "token list", ReadOnly: true, Run: runTokenList})
23 register(Command{Path: []string{"token", "revoke"}, 23 register(Command{Path: []string{"token", "revoke"},
24 Summary: "revoke an API token by name", 24 Summary: "revoke an API token by name",
25 Usage: "token revoke <name>", 25 Usage: "token revoke <name>",
26 SSHOnly: true, Run: runTokenRevoke}) 26 Run: runTokenRevoke})
27} 27}
28 28
29// parseTTL accepts Go durations plus a day suffix ("30d"). 29// parseTTL accepts Go durations plus a day suffix ("30d").
internal/control/web.go +2 −2
@@ -18,10 +18,10 @@ func init() {
18 Usage: "web login", Run: runWebLogin}) 18 Usage: "web login", Run: runWebLogin})
19 register(Command{Path: []string{"web", "sessions", "list"}, 19 register(Command{Path: []string{"web", "sessions", "list"},
20 Summary: "list your browser sessions", 20 Summary: "list your browser sessions",
21 Usage: "web sessions list", ReadOnly: true, SSHOnly: true, Run: runWebSessionsList}) 21 Usage: "web sessions list", ReadOnly: true, Run: runWebSessionsList})
22 register(Command{Path: []string{"web", "sessions", "revoke"}, 22 register(Command{Path: []string{"web", "sessions", "revoke"},
23 Summary: "end a browser session, or all of them", 23 Summary: "end a browser session, or all of them",
24 Usage: "web sessions revoke <id>|--all", SSHOnly: true, Run: runWebSessionsRevoke}) 24 Usage: "web sessions revoke <id>|--all", Run: runWebSessionsRevoke})
25} 25}
26 26
27func runWebSessionsList(c *Ctx, args []string) int { 27func runWebSessionsList(c *Ctx, args []string) int {
internal/httpd/adminusers.go added +121
@@ -0,0 +1,121 @@
1package httpd
2
3import (
4 "net/http"
5 "net/url"
6 "strconv"
7 "strings"
8
9 "gitbay.org/gitbay/internal/store"
10)
11
12// adminUserRow is one account as admin user list returns it. The
13// command owns the shape; this is the page's view of it.
14type adminUserRow struct {
15 Username string `json:"username"`
16 State string `json:"state"`
17 Admin bool `json:"admin"`
18 CreatedAt string `json:"created_at"`
19 LastSeen string `json:"last_seen"`
20}
21
22// adminUsersPerPage is how many accounts a page shows before offering
23// the next, using the command's own keyset cursor so the filter carries
24// across pages.
25const adminUsersPerPage = 50
26
27// adminUsers is the account list an instance admin manages (#234). It
28// dispatches admin user list like any other read, which it can because
29// no command is held back from the web any more. A non-admin gets the
30// same 404 a missing page would, so the URL confirms nothing.
31func (s *Server) adminUsers(w http.ResponseWriter, r *http.Request, viewer store.User) {
32 if !viewer.IsAdmin {
33 s.notFound(w, r)
34 return
35 }
36 state := r.URL.Query().Get("state")
37 switch state {
38 case "active", "pending", "disabled", "admin":
39 default:
40 state = "all"
41 }
42 argv := []string{"admin", "user", "list", "--limit", strconv.Itoa(adminUsersPerPage)}
43 if state != "all" {
44 argv = append(argv, "--state", state)
45 }
46 if cursor := r.URL.Query().Get("cursor"); cursor != "" {
47 argv = append(argv, "--cursor", cursor)
48 }
49 var page struct {
50 Items []adminUserRow `json:"items"`
51 Next string `json:"next"`
52 }
53 if msg, ok := s.runControlInto(viewer, argv, &page); !ok {
54 http.Error(w, msg, http.StatusInternalServerError)
55 return
56 }
57 next := ""
58 if page.Next != "" {
59 q := url.Values{"cursor": {page.Next}}
60 if state != "all" {
61 q.Set("state", state)
62 }
63 next = "?" + q.Encode()
64 }
65 s.render(w, "adminusers.html", struct {
66 basePage
67 Tab string
68 State string
69 Users []adminUserRow
70 Next string
71 Notice string
72 }{s.baseFor(viewer), "admin", state, page.Items, next, s.takeFlash(w, r)})
73}
74
75// adminUsersSubmit runs one account action. Each is the command an
76// admin would run over SSH; demote and disable carry the typed-name
77// check, because both take someone's access away and a mistyped row is
78// the way that happens by accident. Deletion is not here: it is
79// permanent, and it stays a typed command.
80func (s *Server) adminUsersSubmit(w http.ResponseWriter, r *http.Request, viewer store.User) {
81 if !viewer.IsAdmin {
82 s.notFound(w, r)
83 return
84 }
85 name := strings.TrimSpace(r.FormValue("user"))
86 back := func(msg string) {
87 s.setFlash(w, msg)
88 dest := "/admin/users"
89 if state := r.FormValue("state"); state != "" && state != "all" {
90 dest += "?state=" + url.QueryEscape(state)
91 }
92 http.Redirect(w, r, dest, http.StatusSeeOther)
93 }
94 var verb string
95 switch r.FormValue("field") {
96 case "promote":
97 verb = "promote"
98 case "demote":
99 verb = "demote"
100 case "disable":
101 verb = "disable"
102 case "enable":
103 verb = "enable"
104 default:
105 back("unknown action")
106 return
107 }
108 if verb == "demote" || verb == "disable" {
109 if ok, msg := confirmed(r, name); !ok {
110 back(msg)
111 return
112 }
113 }
114 _, msg, code := s.runControlCode(viewer, []string{"admin", "user", verb, name})
115 s.done(w, r, code, msg, func(w http.ResponseWriter, r *http.Request, m string) {
116 if m == "" {
117 m = name + " " + verb + "d"
118 }
119 back(m)
120 })
121}
internal/httpd/control.go +7 −6
@@ -17,8 +17,10 @@ import (
17// never reimplement command logic — merge gates, review rules, and audit 17// never reimplement command logic — merge gates, review rules, and audit
18// entries stay in one place — so the surfaces cannot drift apart. 18// entries stay in one place — so the surfaces cannot drift apart.
19// 19//
20// ViaAPI is set, which refuses SSHOnly commands: anything whose input is a 20// ViaAPI is set, which marks the request as one that arrived over HTTP.
21// credential (secrets, mirror tokens, session minting) stays on SSH. 21// Nothing is held back from that door any more (#234): what a caller may
22// do is the account's rights and its credential's scope, decided in one
23// place for every surface.
22func (s *Server) runControl(u store.User, argv []string) (out string, msg string, ok bool) { 24func (s *Server) runControl(u store.User, argv []string) (out string, msg string, ok bool) {
23 out, msg, code := s.runControlCode(u, argv) 25 out, msg, code := s.runControlCode(u, argv)
24 return out, msg, code == protocol.ExitOK 26 return out, msg, code == protocol.ExitOK
@@ -67,10 +69,9 @@ func (s *Server) done(w http.ResponseWriter, r *http.Request, code int, msg stri
67} 69}
68 70
69// runControlStdin is runControl for the handful of commands whose input 71// runControlStdin is runControl for the handful of commands whose input
70// arrives on stdin: public keys, and review comment bodies. Neither is 72// arrives on stdin: public keys, and review comment bodies. Stdin is
71// secret, and both are prose or paste rather than a flag value. Secrets, 73// also where a secret goes when one is set through this path, since
72// tokens and mirror credentials remain SSHOnly and are refused by the 74// argv is world-readable in /proc and the audit log keeps flag values.
73// dispatcher.
74func (s *Server) runControlStdin(u store.User, argv []string, stdin string) (msg string, ok bool) { 75func (s *Server) runControlStdin(u store.User, argv []string, stdin string) (msg string, ok bool) {
75 msg, code := s.runControlStdinCode(u, argv, stdin) 76 msg, code := s.runControlStdinCode(u, argv, stdin)
76 return msg, code == protocol.ExitOK 77 return msg, code == protocol.ExitOK
internal/httpd/routes.go +3
@@ -123,6 +123,9 @@ func (s *Server) Routes() []Route {
123 Route{Method: "POST", Pattern: "/notifications", Mutating: true, 123 Route{Method: "POST", Pattern: "/notifications", Mutating: true,
124 Handler: s.checkOrigin(s.requireUser(s.notificationsRead))}, 124 Handler: s.checkOrigin(s.requireUser(s.notificationsRead))},
125 Route{Method: "GET", Pattern: "/admin", Handler: s.requireUser(s.adminPage)}, 125 Route{Method: "GET", Pattern: "/admin", Handler: s.requireUser(s.adminPage)},
126 Route{Method: "GET", Pattern: "/admin/users", Handler: s.requireUser(s.adminUsers)},
127 Route{Method: "POST", Pattern: "/admin/users", Mutating: true,
128 Handler: s.checkOrigin(s.requireUser(s.adminUsersSubmit))},
126 Route{Method: "POST", Pattern: "/{owner}", Mutating: true, 129 Route{Method: "POST", Pattern: "/{owner}", Mutating: true,
127 Handler: s.checkOrigin(s.requireUser(s.orgSubmit))}, 130 Handler: s.checkOrigin(s.requireUser(s.orgSubmit))},
128 Route{Method: "POST", Pattern: "/settings", Mutating: true, 131 Route{Method: "POST", Pattern: "/settings", Mutating: true,
internal/web/static/style.css +4
@@ -827,6 +827,10 @@ table.keys, table.assets { background: none; border: 0; border-radius: 0; }
827table.keys th, table.assets th, table.keys tr.cols th { background: transparent; padding-left: 0; } 827table.keys th, table.assets th, table.keys tr.cols th { background: transparent; padding-left: 0; }
828table.keys td { padding-left: 0; } 828table.keys td { padding-left: 0; }
829table.keys td.act, table.assets td.size { text-align: right; } 829table.keys td.act, table.assets td.size { text-align: right; }
830/* The account list types a username to confirm, which is longer than
831 the key prefixes the confirm field is sized for elsewhere. */
832table.accounts input[name="confirm"] { width: 11rem; }
833table.accounts form.actions { display: inline-flex; gap: var(--sp-2); margin: 0 var(--sp-2) var(--sp-2) 0; }
830table.assets td.name { width: 100%; font-family: var(--mono); font-size: var(--fs-1); } 834table.assets td.name { width: 100%; font-family: var(--mono); font-size: var(--fs-1); }
831table.assets { margin-top: var(--sp-2); } 835table.assets { margin-top: var(--sp-2); }
832 836
internal/web/templates/account.html +5 −4
@@ -138,11 +138,12 @@ JSON bundle, the same one <code>gitbay account export</code> writes. Keys are
138never included; a replayed bundle's emails arrive unverified.</p> 138never included; a replayed bundle's emails arrive unverified.</p>
139<p><a href="/settings/export">Download bundle</a></p> 139<p><a href="/settings/export">Download bundle</a></p>
140 140
141<h2>On SSH only</h2> 141<h2>On the command line</h2>
142<p class="meta">Anything whose input is a credential stays on the command line, 142<p class="meta">No page here yet, and nothing refusing one: a credential is
143where it can be piped instead of pasted:</p> 143easier to pipe than to paste, and a minted token is shown once.</p>
144<pre class="message">gitbay auth token create --name laptop # API tokens 144<pre class="message">gitbay auth token create --name laptop # API tokens
145gitbay web sessions list # browser sessions
145gitbay admin ... # instance administration</pre> 146gitbay admin ... # instance administration</pre>
146<p class="meta">All of the above works from stock OpenSSH too: 147<p class="meta">All of it works from stock OpenSSH too:
147<code>ssh git@{{.Host}} auth whoami</code>.</p> 148<code>ssh git@{{.Host}} auth whoami</code>.</p>
148{{end}} 149{{end}}
internal/web/templates/admin.html +1
@@ -3,6 +3,7 @@
3{{define "content"}} 3{{define "content"}}
4<h1>Admin</h1> 4<h1>Admin</h1>
5<p class="meta">Server build {{.Commit}}. Every background worker's backlog, the same read as <code>gitbay dashboard --json</code> for an admin.</p> 5<p class="meta">Server build {{.Commit}}. Every background worker's backlog, the same read as <code>gitbay dashboard --json</code> for an admin.</p>
6<p class="meta"><a href="/admin/users">Accounts</a> — every account, with promote, demote, disable and enable.</p>
6 7
7{{with .Queues.Webhooks}} 8{{with .Queues.Webhooks}}
8<h2>Webhook deliveries <span class="count">{{.Pending}}</span></h2> 9<h2>Webhook deliveries <span class="count">{{.Pending}}</span></h2>
internal/web/templates/adminusers.html added +67
@@ -0,0 +1,67 @@
1{{define "width"}}bounded{{end}}
2{{define "title"}}accounts · admin · {{.Site}}{{end}}
3{{define "content"}}
4<h1>Accounts</h1>
5<p class="meta"><a href="/admin">Admin</a> · the same read as <code>gitbay admin user list</code>.</p>
6{{if .Notice}}<p class="notice" role="status">{{.Notice}}</p>{{end}}
7
8<nav class="filters">
9 <a {{if eq .State "all"}}class="active" aria-current="page" {{end}}href="?state=all">all</a>
10 <a {{if eq .State "active"}}class="active" aria-current="page" {{end}}href="?state=active">active</a>
11 <a {{if eq .State "pending"}}class="active" aria-current="page" {{end}}href="?state=pending">pending</a>
12 <a {{if eq .State "disabled"}}class="active" aria-current="page" {{end}}href="?state=disabled">disabled</a>
13 <a {{if eq .State "admin"}}class="active" aria-current="page" {{end}}href="?state=admin">admins</a>
14</nav>
15
16{{if .Users}}<div class="tablewrap"><table class="keys accounts">
17<thead><tr class="cols"><th>Account</th><th>State</th><th>Created</th><th>Last seen</th><th>Actions</th></tr></thead>
18<tbody>
19{{range .Users}}<tr>
20 <td><a href="/{{.Username}}">{{.Username}}</a>{{if .Admin}} <span class="chip">admin</span>{{end}}</td>
21 <td><span class="chip {{if eq .State "active"}}chip-open{{else if eq .State "disabled"}}chip-closed{{else}}chip-neutral{{end}}">{{.State}}</span></td>
22 <td>{{when .CreatedAt}}</td>
23 <td>{{if .LastSeen}}{{when .LastSeen}}{{else}}never{{end}}</td>
24 <td>
25 {{if .Admin}}
26 <form method="post" action="/admin/users" class="actions">
27 <input type="hidden" name="field" value="demote">
28 <input type="hidden" name="user" value="{{.Username}}">
29 <input type="hidden" name="state" value="{{$.State}}">
30 {{template "confirmfield" .Username}} <button type="submit" class="btn">Demote</button>
31 </form>
32 {{else if eq .State "active"}}
33 <form method="post" action="/admin/users" class="actions">
34 <input type="hidden" name="field" value="promote">
35 <input type="hidden" name="user" value="{{.Username}}">
36 <input type="hidden" name="state" value="{{$.State}}">
37 <button type="submit" class="btn">Promote</button>
38 </form>
39 {{end}}
40 {{if eq .State "disabled"}}
41 <form method="post" action="/admin/users" class="actions">
42 <input type="hidden" name="field" value="enable">
43 <input type="hidden" name="user" value="{{.Username}}">
44 <input type="hidden" name="state" value="{{$.State}}">
45 <button type="submit" class="btn">Enable</button>
46 </form>
47 {{else}}
48 <form method="post" action="/admin/users" class="actions">
49 <input type="hidden" name="field" value="disable">
50 <input type="hidden" name="user" value="{{.Username}}">
51 <input type="hidden" name="state" value="{{$.State}}">
52 {{template "confirmfield" .Username}} <button type="submit" class="btn">Disable</button>
53 </form>
54 {{end}}
55 </td>
56</tr>
57{{end}}
58</tbody></table></div>{{else}}<p class="none">No account matches</p>{{end}}
59{{if .Next}}<p class="pager"><a href="{{.Next}}">more →</a></p>{{end}}
60
61<p class="meta">Creating and deleting an account, issuing an invite and
62asserting an address stay on the command line: each takes a key, mints
63a credential, or cannot be undone.</p>
64<pre tabindex="0"><code>gitbay admin user create &lt;name&gt; --key - &lt; key.pub
65gitbay admin user delete &lt;name&gt; --yes
66gitbay admin invite --email &lt;address&gt;</code></pre>
67{{end}}
internal/web/web_test.go +1 −1
@@ -90,7 +90,7 @@ func TestWhenNamesTheZone(t *testing.T) {
90// a per-view define instead of a fixed one. 90// a per-view define instead of a fixed one.
91func TestMainWidthClass(t *testing.T) { 91func TestMainWidthClass(t *testing.T) {
92 wide := map[string]bool{"tree.html": true, "blob.html": true, "blame.html": true, "log.html": true, "commit.html": true, "compare.html": true, "builds.html": true, "build.html": true, "search.html": true, "globalsearch.html": true, "edit.html": true} 92 wide := map[string]bool{"tree.html": true, "blob.html": true, "blame.html": true, "log.html": true, "commit.html": true, "compare.html": true, "builds.html": true, "build.html": true, "search.html": true, "globalsearch.html": true, "edit.html": true}
93 bounded := map[string]bool{"landing.html": true, "login.html": true, "register.html": true, "registered.html": true, "new.html": true, "issuenew.html": true, "mrnew.html": true, "settings.html": true, "account.html": true, "admin.html": true, "snippetnew.html": true, "privacy.html": true, "404.html": true} 93 bounded := map[string]bool{"landing.html": true, "login.html": true, "register.html": true, "registered.html": true, "new.html": true, "issuenew.html": true, "mrnew.html": true, "settings.html": true, "account.html": true, "admin.html": true, "adminusers.html": true, "snippetnew.html": true, "privacy.html": true, "404.html": true}
94 perView := map[string]string{"mr.html": `{{define "width"}}{{if eq .View "diff"}}wide{{else}}reading{{end}}{{end}}`} 94 perView := map[string]string{"mr.html": `{{define "width"}}{{if eq .View "diff"}}wide{{else}}reading{{end}}{{end}}`}
95 for _, name := range Pages() { 95 for _, name := range Pages() {
96 src, err := TemplateSource(name) 96 src, err := TemplateSource(name)