Commit 000e6436f3

000e6436f3dc1c36629af5e613868efdb95b0dd7

parent: 929f2440bf

Verified · cmc ci/build: success ci/test: success

cmc <hello@cleberg.net> · 2026-09-19 20:33 UTC

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

The flag and the dispatcher check that refused a command arriving over
HTTP are gone, along with the 32 registrations that set it: build
secrets, mirror credentials, API tokens, web sessions, deploy keys, the
audit log, quotas and instance administration.

What a caller may do is now decided in one place for every door: the
account's rights, narrowed by the scope of the key or token the request
came with. A full-scope API token can do anything its owner can, minting
another token included; a read-scoped one still cannot write, and the
runner protocol still needs a key session because a build is claimed by
a key. Secrets keep travelling on stdin wherever they are set, since
argv is world-readable in /proc and the audit log keeps flag values.

The first thing that unlocks is /admin/users: the account list with the
state filter and keyset cursor admin user list takes, and promote,
demote, disable and enable per row, each dispatching the command.
Demote and disable ask for the username to be typed, since both take
someone's access away. Account creation and deletion, invites and
address assertion stay on the command line: one takes a key, the others
mint a credential or cannot be undone. A non-admin gets the 404 a
missing page would.

Closes #234
.gitbay/wiki/API.org +6 −2
@@ -13,8 +13,12 @@ enabled = true
1313
1414** Tokens
1515
16Tokens are minted over SSH and only over SSH — an API token can never
17create further credentials.
16Tokens are minted wherever the registry is reached: over SSH, on the
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.
1822
1923#+begin_src sh
2024gitbay auth token create --name ci [--scope full|read] [--ttl 30d]
.gitbay/wiki/Admin.org +15 −5
@@ -186,7 +186,7 @@ never in argv.
186186
187187#+begin_src sh
188188gitbayd 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
190190ssh git@<host> admin user list [--state active|pending|disabled|admin]
191191ssh git@<host> admin user show <name> # keys, emails, orgs, tokens, sessions
192192ssh 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
212212keys or API tokens. =admin user show= adds the keys with their last use,
213213each address with how it was verified, PGP keys, org roles, the owned
214214repository 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.
216226
217227Promotion 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
219host alike, so the host-local =promote= is the way back in when the only
220admin key is lost.
228Demotion is refused when it would leave no admin, over SSH, in the
229browser and on the host alike, so the host-local =promote= is the way
230back in when the only admin key is lost.
221231
222232Instance admin carries no right on anyone's repository: policy does not
223233consult 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.
1111- [[Admin][Admin guide]] — install, configuration reference, backup, security
1212- [[API][API and webhooks]] — the JSON API contract, tokens, payloads
1313- [[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
1515- [[Performance][Performance]] — stress-test numbers from importing git.git
1616
1717This 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.
1515
1616A capability lands over SSH first. If it belongs to the
1717triage/review/respond loop, it lands on the web in the same merge
18request. Anything whose input is a credential — secrets, mirror
19tokens, API tokens — stays SSH-only by design: the web dispatcher
20refuses =SSHOnly= commands outright. Session minting is not one of
21them: what a browser submits to ask for a login link is a username or
22an address, and the credential it gets back travels by mail.
18request. Nothing is held back from a surface any more (#234): the
19registry has no flag for it, and what a caller may do is the account's
20rights narrowed by the scope of the key or token it arrived with,
21decided in one place. A =no= in the web or ios column is a page nobody
22has built yet, not a refusal.
2323
2424Rows are one page or one action each. Grouped rows hide gaps, twice
2525now: "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
360360argument above — a JSON bundle has nowhere useful to land on a phone,
361361and the web route stays the way to get one.
362362
363=profile set= carries description, website, about and links. It is not
364=SSHOnly= — nothing about a bio is a credential, and the JSON API runs
365it. The account settings page has the form, and so does the iOS client:
363=profile set= carries description, website, about and links; the JSON
364API runs it like any other write. The account settings page has the form, and so does the iOS client:
366365=--link= replaces the whole set rather than appending, so a client sends
367366every link it keeps on every save, and =--link ''= is how they are
368367cleared.
369368
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
370392* Organizations
371393
372394| capability | cli | web | ios |
@@ -388,15 +410,20 @@ one. Without the flags a list stays complete, so existing scripts are
388410unchanged. The web pages the issue and merge request lists at fifty
389411with the same cursors; iOS pages with them too.
390412
391* SSH only, by design
413* CLI only, for now
392414
393415Build secrets, mirror configuration and tokens, custom domain claims,
394416API token minting, web session listing and revocation, deploy keys,
395account and instance administration. Deleting, transferring or renaming
396a repository is also CLI-only, as is deleting an organization and
397pruning merge request heads (=admin mr prune=): each removes or moves
398what clone URLs point at, and wants a typed command, not a button.
399
400These are the only rows where a =no= is intended. Everywhere else a
401=no= is work outstanding, and =n/a= means a surface cannot usefully
402carry the capability at all — see the archive note above.
417and instance administration have no page yet. Until #234 these were
418refused outright on the other surfaces; the refusal is gone, so each is
419now a page waiting to be built rather than a rule. A credential still
420travels on stdin wherever it is set, since argv is world-readable in
421/proc and the audit log keeps flag values.
422
423Deleting, transferring or renaming a repository stays CLI-only on
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]].
3838- *Per-instance trust.* Email verification and key registration are local
3939 to an instance and never transfer. Account migration re-registers keys
4040 and re-verifies emails on the target by design.
41- *The control plane is one authenticated channel* (SSH), fully usable
42 from stock OpenSSH. The JSON API fronts the same command registry with
43 bearer tokens minted only over SSH; git transport never runs over it.
41- *The control plane is one command registry*, fully usable from stock
42 OpenSSH and fronted unchanged by the JSON API and the web. No command
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.
4447- *Anonymous surfaces* — HTTPS clone of public repos, =git://= where
4548 enabled, the read-only web UI — carry no credentials and expose only
4649 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
169169#+end_src
170170
171171The 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
173runs it like any other write command.
172the only interface today. The JSON API runs =profile set= like any
173other write command.
174174
175175* Repositories
176176
CHANGELOG.org +13
@@ -8,6 +8,19 @@ anything beyond "replace the binary and restart" is needed.
88
99The web findings from the forge comparison (#232).
1010
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).
1124- =[registration] notify_admin= mails the instance's admins when an
1225 account becomes active: an invite redeemed, or an open-mode signup
1326 that verified its address. Off by default, requires =[mail]=, and
README.org +1 −1
@@ -59,7 +59,7 @@ Properties that follow from the design:
5959 with SMTP email verification
6060- signed outbound webhooks with retries, dead-lettering, and SSRF
6161 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
6363- built-in ACME (Let's Encrypt) TLS; =admin backup= produces one
6464 restore-tested archive (database snapshot first, then repositories)
6565
e2e/accountweb_test.go +3 −1
@@ -105,7 +105,9 @@ func TestAccountSettingsWeb(t *testing.T) {
105105 t.Error("invalid key accepted without an error")
106106 }
107107
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.
109111 if strings.Contains(body, `value="token-mint"`) {
110112 t.Error("token minting exposed on the web")
111113 }
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) {
139139 t.Fatalf("git over API: %d", status)
140140 }
141141
142 // Token management never works over the API: no credential minting.
143 status, body = inst.apiCall(t, token, []string{"token", "create", "--name", "sneaky"}, "")
144 if status != 403 || !strings.Contains(body["error"].(string), "only available over SSH") {
142 // Token management works over the API like everything else: no
143 // command is held back from a surface any more (#234). A full-scope
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 {
145147 t.Fatalf("token create via API: %d %v", status, body)
146148 }
147149
internal/control/admin.go +13 −13
@@ -18,57 +18,57 @@ func init() {
1818 register(Command{Path: []string{"admin", "user", "list"},
1919 Summary: "list accounts (instance admins)",
2020 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})
2222 register(Command{Path: []string{"admin", "user", "show"},
2323 Summary: "show an account: keys, emails, orgs, tokens, sessions (instance admins)",
2424 Usage: "admin user show <username>",
25 ReadOnly: true, SSHOnly: true, Run: runAdminUserShow})
25 ReadOnly: true, Run: runAdminUserShow})
2626 register(Command{Path: []string{"admin", "user", "promote"},
2727 Summary: "make an account an instance admin",
2828 Usage: "admin user promote <username>",
29 SSHOnly: true, Run: runAdminUserPromote})
29 Run: runAdminUserPromote})
3030 register(Command{Path: []string{"admin", "user", "demote"},
3131 Summary: "remove instance admin from an account (never the last one)",
3232 Usage: "admin user demote <username>",
33 SSHOnly: true, Run: runAdminUserDemote})
33 Run: runAdminUserDemote})
3434 register(Command{Path: []string{"admin", "runners"},
3535 Summary: "the build queue and runner accounts: last poll, scope, the build each holds (instance admins)",
3636 Usage: "admin runners",
37 ReadOnly: true, SSHOnly: true, Run: runAdminRunners})
37 ReadOnly: true, Run: runAdminRunners})
3838 register(Command{Path: []string{"admin", "runners", "remove"},
3939 Summary: "drop a key's runner heartbeat row, e.g. one that polled once by mistake (instance admins)",
4040 Usage: "admin runners remove <fingerprint>",
41 SSHOnly: true, Run: runAdminRunnersForget})
41 Run: runAdminRunnersForget})
4242 // forget is the name this shipped under in v1.18; remove is the verb
4343 // every other noun uses. Both stay for one release.
4444 register(Command{Path: []string{"admin", "runners", "forget"},
4545 Summary: "alias of admin runners remove",
4646 Usage: "admin runners forget <fingerprint>",
47 SSHOnly: true, Run: runAdminRunnersForget})
47 Run: runAdminRunnersForget})
4848 register(Command{Path: []string{"admin", "repo", "list"},
4949 Summary: "list every repository with size and last push (instance admins)",
5050 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})
5252 register(Command{Path: []string{"admin", "repo", "archive"},
5353 Summary: "archive any repository (instance admins; audited)",
5454 Usage: "admin repo archive <owner/name>",
55 SSHOnly: true, Run: runAdminRepoArchive})
55 Run: runAdminRepoArchive})
5656 register(Command{Path: []string{"admin", "repo", "unarchive"},
5757 Summary: "unarchive any repository (instance admins; audited)",
5858 Usage: "admin repo unarchive <owner/name>",
59 SSHOnly: true, Run: runAdminRepoUnarchive})
59 Run: runAdminRepoUnarchive})
6060 register(Command{Path: []string{"admin", "repo", "visibility"},
6161 Summary: "set any repository's visibility (instance admins; audited)",
6262 Usage: "admin repo visibility <owner/name> public|private",
63 SSHOnly: true, Run: runAdminRepoVisibility})
63 Run: runAdminRepoVisibility})
6464 register(Command{Path: []string{"admin", "repo", "delete"},
6565 Summary: "delete any repository (instance admins; audited)",
6666 Usage: "admin repo delete <owner/name> --yes",
67 SSHOnly: true, Run: runAdminRepoDelete})
67 Run: runAdminRepoDelete})
6868 register(Command{Path: []string{"admin", "mr", "prune"},
6969 Summary: "drop merged or closed MRs' head refs and the objects only they kept, e.g. after a history rewrite (instance admins; audited)",
7070 Usage: "admin mr prune <owner/name> <n> [<n>...] --yes",
71 SSHOnly: true, Run: runAdminMRPrune})
71 Run: runAdminMRPrune})
7272}
7373
7474// requireInstanceAdmin gates the admin noun. -1 means proceed.
internal/control/adminhost.go +7 −7
@@ -24,31 +24,31 @@ func init() {
2424 register(Command{Path: []string{"admin", "user", "create"},
2525 Summary: "create an account, optionally with a key and a verified address (instance admins)",
2626 Usage: "admin user create <username> [--admin] [--email <address> [--verified]] [--key -] < key.pub",
27 ReadsStdin: true, SSHOnly: true, Run: runAdminUserCreate})
27 ReadsStdin: true, Run: runAdminUserCreate})
2828 register(Command{Path: []string{"admin", "user", "disable"},
2929 Summary: "suspend an account: SSH, web sessions and API tokens refused until re-enabled",
3030 Usage: "admin user disable <username>",
31 SSHOnly: true, Run: runAdminUserDisable})
31 Run: runAdminUserDisable})
3232 register(Command{Path: []string{"admin", "user", "enable"},
3333 Summary: "restore a suspended account",
3434 Usage: "admin user enable <username>",
35 SSHOnly: true, Run: runAdminUserEnable})
35 Run: runAdminUserEnable})
3636 register(Command{Path: []string{"admin", "user", "delete"},
3737 Summary: "delete an account that anchors nothing (keys, emails and sessions go with it)",
3838 Usage: "admin user delete <username> --yes",
39 SSHOnly: true, Run: runAdminUserDelete})
39 Run: runAdminUserDelete})
4040 register(Command{Path: []string{"admin", "email", "verify"},
4141 Summary: "mark an address verified by admin assertion",
4242 Usage: "admin email verify <username> <address>",
43 SSHOnly: true, Run: runAdminEmailVerify})
43 Run: runAdminEmailVerify})
4444 register(Command{Path: []string{"admin", "invite"},
4545 Summary: "issue a registration invite and mail its code",
4646 Usage: "admin invite --email <address>",
47 SSHOnly: true, Run: runAdminInvite})
47 Run: runAdminInvite})
4848 register(Command{Path: []string{"admin", "stats"},
4949 Summary: "instance statistics: counts and per-repository disk usage",
5050 Usage: "admin stats",
51 ReadOnly: true, SSHOnly: true, Run: runAdminStats})
51 ReadOnly: true, Run: runAdminStats})
5252}
5353
5454func runAdminUserCreate(c *Ctx, args []string) int {
internal/control/audit.go +1 −1
@@ -15,7 +15,7 @@ func init() {
1515 register(Command{Path: []string{"audit"},
1616 Summary: "instance audit log (admins)",
1717 Usage: "audit [--actor <user>|-] [--action <prefix>] [--since <duration|date>] [--limit <n>]",
18 ReadOnly: true, SSHOnly: true, Run: runAudit})
18 ReadOnly: true, Run: runAudit})
1919}
2020
2121func runAudit(c *Ctx, args []string) int {
internal/control/build.go +7 −6
@@ -46,7 +46,7 @@ func init() {
4646 register(Command{Path: []string{"repo", "secret", "set"},
4747 Summary: "set a build secret",
4848 Usage: "repo secret set <owner/name> <NAME> (value on stdin)",
49 ReadsStdin: true, SSHOnly: true, Run: runSecretSet})
49 ReadsStdin: true, Run: runSecretSet})
5050 register(Command{Path: []string{"repo", "secret", "remove"},
5151 Summary: "remove a build secret",
5252 Usage: "repo secret remove <owner/name> <NAME>", Run: runSecretRemove})
@@ -61,13 +61,13 @@ func init() {
6161 // an admin key, which a runner host should not hold (#92).
6262 register(Command{Path: []string{"runner", "next"},
6363 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})
6565 register(Command{Path: []string{"runner", "log"},
6666 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})
6868 register(Command{Path: []string{"runner", "done"},
6969 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})
7171}
7272
7373type BuildOut struct {
@@ -323,8 +323,9 @@ func runSecretList(c *Ctx, args []string) int {
323323 })
324324}
325325
326// runnerSession resolves the key behind a runner-protocol session. The
327// runner commands are SSHOnly, so Source is the key's fingerprint. An
326// runnerSession resolves the key behind a runner-protocol session:
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
328329// admin key is accepted so an operator can rotate at their own pace; a
329330// runner host should hold a key added with --scope runner.
330331func runnerSession(c *Ctx) (store.SSHKey, int) {
internal/control/control.go +4 −7
@@ -27,9 +27,10 @@ type Ctx struct {
2727 Stdout io.Writer
2828 Stderr io.Writer
2929 JSON bool
30 // ViaAPI marks requests arriving over the HTTP token API. Some
31 // commands (token management) are SSH-only: an API token must never
32 // mint further credentials.
30 // ViaAPI marks requests arriving over HTTP, from the token API or
31 // the web. Every command runs there; nothing is held back for SSH
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.
3334 ViaAPI bool
3435 // ReadOnly is set for read-scoped API tokens.
3536 ReadOnly bool
@@ -62,7 +63,6 @@ type Command struct {
6263 Usage string
6364 ReadsStdin bool
6465 ReadOnly bool // safe for read-scoped API tokens
65 SSHOnly bool // refused over the HTTP API (credential minting)
6666 Run func(c *Ctx, args []string) int
6767}
6868
@@ -117,9 +117,6 @@ func Dispatch(c *Ctx, argv []string) int {
117117 if c.Scope != "full" && !(c.Scope == "runner" && cmd.Path[0] == "runner") {
118118 return c.fail(protocol.ExitDenied, "this key's scope (%s) does not allow control commands; use a key added with --scope full", c.Scope)
119119 }
120 if c.ViaAPI && cmd.SSHOnly {
121 return c.fail(protocol.ExitDenied, "%s is only available over SSH", joinPath(cmd.Path))
122 }
123120 if c.ReadOnly && !cmd.ReadOnly {
124121 return c.fail(protocol.ExitDenied, "this token is read-only; %s modifies state — mint one with --scope full", joinPath(cmd.Path))
125122 }
internal/control/control_test.go +11 −2
@@ -73,8 +73,17 @@ func TestBuildJobsIsAReadCommand(t *testing.T) {
7373 if !cmd.ReadOnly {
7474 t.Error("build jobs must be ReadOnly; listing jobs changes nothing")
7575 }
76 if cmd.SSHOnly {
77 t.Error("build jobs must not be SSHOnly; the web and the app need it")
76}
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 }
7887 }
7988}
8089
internal/control/mirrorcmd.go +1 −1
@@ -18,7 +18,7 @@ func init() {
1818 register(Command{Path: []string{"repo", "mirror", "add"},
1919 Summary: "mirror to or from a remote",
2020 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})
2222 register(Command{Path: []string{"repo", "mirror", "list"},
2323 Summary: "list mirrors with sync status",
2424 Usage: "repo mirror list <owner/name>", ReadOnly: true, Run: runMirrorList})
internal/control/quota.go +1 −1
@@ -85,7 +85,7 @@ func init() {
8585 register(Command{Path: []string{"admin", "user", "limits"},
8686 Summary: "show or set an account's repository and storage caps (instance admins)",
8787 Usage: "admin user limits <username> [--repos <n>|default] [--bytes <n>|default]",
88 SSHOnly: true, Run: runAdminUserLimits})
88 Run: runAdminUserLimits})
8989}
9090
9191func runAdminUserLimits(c *Ctx, args []string) int {
internal/control/token.go +3 −3
@@ -16,14 +16,14 @@ func init() {
1616 register(Command{Path: []string{"token", "create"},
1717 Summary: "mint an API token (shown once)",
1818 Usage: "token create --name <n> [--scope full|read] [--ttl 30d|720h]",
19 SSHOnly: true, Run: runTokenCreate})
19 Run: runTokenCreate})
2020 register(Command{Path: []string{"token", "list"},
2121 Summary: "list API tokens",
22 Usage: "token list", ReadOnly: true, SSHOnly: true, Run: runTokenList})
22 Usage: "token list", ReadOnly: true, Run: runTokenList})
2323 register(Command{Path: []string{"token", "revoke"},
2424 Summary: "revoke an API token by name",
2525 Usage: "token revoke <name>",
26 SSHOnly: true, Run: runTokenRevoke})
26 Run: runTokenRevoke})
2727}
2828
2929// parseTTL accepts Go durations plus a day suffix ("30d").
internal/control/web.go +2 −2
@@ -18,10 +18,10 @@ func init() {
1818 Usage: "web login", Run: runWebLogin})
1919 register(Command{Path: []string{"web", "sessions", "list"},
2020 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})
2222 register(Command{Path: []string{"web", "sessions", "revoke"},
2323 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})
2525}
2626
2727func 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 (
1717// never reimplement command logic — merge gates, review rules, and audit
1818// entries stay in one place — so the surfaces cannot drift apart.
1919//
20// ViaAPI is set, which refuses SSHOnly commands: anything whose input is a
21// credential (secrets, mirror tokens, session minting) stays on SSH.
20// ViaAPI is set, which marks the request as one that arrived over HTTP.
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.
2224func (s *Server) runControl(u store.User, argv []string) (out string, msg string, ok bool) {
2325 out, msg, code := s.runControlCode(u, argv)
2426 return out, msg, code == protocol.ExitOK
@@ -67,10 +69,9 @@ func (s *Server) done(w http.ResponseWriter, r *http.Request, code int, msg stri
6769}
6870
6971// runControlStdin is runControl for the handful of commands whose input
70// arrives on stdin: public keys, and review comment bodies. Neither is
71// secret, and both are prose or paste rather than a flag value. Secrets,
72// tokens and mirror credentials remain SSHOnly and are refused by the
73// dispatcher.
72// arrives on stdin: public keys, and review comment bodies. Stdin is
73// also where a secret goes when one is set through this path, since
74// argv is world-readable in /proc and the audit log keeps flag values.
7475func (s *Server) runControlStdin(u store.User, argv []string, stdin string) (msg string, ok bool) {
7576 msg, code := s.runControlStdinCode(u, argv, stdin)
7677 return msg, code == protocol.ExitOK
internal/httpd/routes.go +3
@@ -123,6 +123,9 @@ func (s *Server) Routes() []Route {
123123 Route{Method: "POST", Pattern: "/notifications", Mutating: true,
124124 Handler: s.checkOrigin(s.requireUser(s.notificationsRead))},
125125 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))},
126129 Route{Method: "POST", Pattern: "/{owner}", Mutating: true,
127130 Handler: s.checkOrigin(s.requireUser(s.orgSubmit))},
128131 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; }
827827table.keys th, table.assets th, table.keys tr.cols th { background: transparent; padding-left: 0; }
828828table.keys td { padding-left: 0; }
829829table.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; }
830834table.assets td.name { width: 100%; font-family: var(--mono); font-size: var(--fs-1); }
831835table.assets { margin-top: var(--sp-2); }
832836
internal/web/templates/account.html +5 −4
@@ -138,11 +138,12 @@ JSON bundle, the same one <code>gitbay account export</code> writes. Keys are
138138never included; a replayed bundle's emails arrive unverified.</p>
139139<p><a href="/settings/export">Download bundle</a></p>
140140
141<h2>On SSH only</h2>
142<p class="meta">Anything whose input is a credential stays on the command line,
143where it can be piped instead of pasted:</p>
141<h2>On the command line</h2>
142<p class="meta">No page here yet, and nothing refusing one: a credential is
143easier to pipe than to paste, and a minted token is shown once.</p>
144144<pre class="message">gitbay auth token create --name laptop # API tokens
145gitbay web sessions list # browser sessions
145146gitbay 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:
147148<code>ssh git@{{.Host}} auth whoami</code>.</p>
148149{{end}}
internal/web/templates/admin.html +1
@@ -3,6 +3,7 @@
33{{define "content"}}
44<h1>Admin</h1>
55<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>
67
78{{with .Queues.Webhooks}}
89<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) {
9090// a per-view define instead of a fixed one.
9191func TestMainWidthClass(t *testing.T) {
9292 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}
9494 perView := map[string]string{"mr.html": `{{define "width"}}{{if eq .View "diff"}}wide{{else}}reading{{end}}{{end}}`}
9595 for _, name := range Pages() {
9696 src, err := TemplateSource(name)