control: drop SSHOnly, and put the accounts on the admin page !434
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 | ||
| 16 | Tokens are minted over SSH and only over SSH — an API token can never | 16 | Tokens are minted wherever the registry is reached: over SSH, on the |
| 17 | create further credentials. | 17 | API, anywhere. A full-scope token can mint another, which is what full |
| 18 | scope means; a read-scoped one cannot, because minting is a write. The | ||
| 19 | controls here are scope, TTL and revocation, not which door a request | ||
| 20 | arrived through (#234). Give a token the narrowest scope and shortest | ||
| 21 | TTL that does its job, and revoke it when the job is over. | ||
| 18 | 22 | ||
| 19 | #+begin_src sh | 23 | #+begin_src sh |
| 20 | gitbay auth token create --name ci [--scope full|read] [--ttl 30d] | 24 | gitbay 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 |
| 188 | gitbayd admin audit [--actor u|-] [--action prefix] [--since 24h|7d|date] [--limit n] [--json] | 188 | gitbayd admin audit [--actor u|-] [--action prefix] [--since 24h|7d|date] [--limit n] [--json] |
| 189 | ssh git@<host> audit ... # the same, from an admin session (SSH only) | 189 | ssh git@<host> audit ... # the same, from an admin session |
| 190 | ssh git@<host> admin user list [--state active|pending|disabled|admin] | 190 | ssh git@<host> admin user list [--state active|pending|disabled|admin] |
| 191 | ssh git@<host> admin user show <name> # keys, emails, orgs, tokens, sessions | 191 | ssh git@<host> admin user show <name> # keys, emails, orgs, tokens, sessions |
| 192 | ssh git@<host> admin user limits <name> [--repos n|default] [--bytes n|default] # per-account caps | 192 | ssh 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 | |||
| 212 | keys or API tokens. =admin user show= adds the keys with their last use, | 212 | keys or API tokens. =admin user show= adds the keys with their last use, |
| 213 | each address with how it was verified, PGP keys, org roles, the owned | 213 | each address with how it was verified, PGP keys, org roles, the owned |
| 214 | repository count, API token names, and live browser sessions. Both are | 214 | repository count, API token names, and live browser sessions. Both are |
| 215 | SSH-only and refused to non-admins, like =audit=. | 215 | Both 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 | ||
| 218 | page: the state filter the command takes, keyset paging on its cursor, | ||
| 219 | and a row per account with promote, demote, disable and enable, each | ||
| 220 | dispatching the command. Demote and disable ask for the username to be | ||
| 221 | typed, since both take someone's access away. Creating and deleting an | ||
| 222 | account, issuing an invite and asserting an address stay on the command | ||
| 223 | line: each takes a key, mints a credential, or cannot be undone. A | ||
| 224 | non-admin gets the 404 a missing page would, so the URL confirms | ||
| 225 | nothing. | ||
| 216 | 226 | ||
| 217 | Promotion needs an active account: a pending or disabled one is refused. | 227 | Promotion needs an active account: a pending or disabled one is refused. |
| 218 | Demotion is refused when it would leave no admin, over SSH and on the | 228 | Demotion is refused when it would leave no admin, over SSH, in the |
| 219 | host alike, so the host-local =promote= is the way back in when the only | 229 | browser and on the host alike, so the host-local =promote= is the way |
| 220 | admin key is lost. | 230 | back in when the only admin key is lost. |
| 221 | 231 | ||
| 222 | Instance admin carries no right on anyone's repository: policy does not | 232 | Instance admin carries no right on anyone's repository: policy does not |
| 223 | consult it, and a private repository still answers not-found to an | 233 | consult 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 | ||
| 17 | This wiki is =.gitbay/wiki/= on the repository's default branch: | 17 | This 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 | ||
| 16 | A capability lands over SSH first. If it belongs to the | 16 | A capability lands over SSH first. If it belongs to the |
| 17 | triage/review/respond loop, it lands on the web in the same merge | 17 | triage/review/respond loop, it lands on the web in the same merge |
| 18 | request. Anything whose input is a credential — secrets, mirror | 18 | request. Nothing is held back from a surface any more (#234): the |
| 19 | tokens, API tokens — stays SSH-only by design: the web dispatcher | 19 | registry has no flag for it, and what a caller may do is the account's |
| 20 | refuses =SSHOnly= commands outright. Session minting is not one of | 20 | rights narrowed by the scope of the key or token it arrived with, |
| 21 | them: what a browser submits to ask for a login link is a username or | 21 | decided in one place. A =no= in the web or ios column is a page nobody |
| 22 | an address, and the credential it gets back travels by mail. | 22 | has built yet, not a refusal. |
| 23 | 23 | ||
| 24 | Rows are one page or one action each. Grouped rows hide gaps, twice | 24 | Rows are one page or one action each. Grouped rows hide gaps, twice |
| 25 | now: "browse, log, blame, search" read as covered while blame had no | 25 | now: "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 | |||
| 360 | argument above — a JSON bundle has nowhere useful to land on a phone, | 360 | argument above — a JSON bundle has nowhere useful to land on a phone, |
| 361 | and the web route stays the way to get one. | 361 | and 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 | 364 | API runs it like any other write. The account settings page has the form, and so does the iOS client: |
| 365 | it. 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 |
| 367 | every link it keeps on every save, and =--link ''= is how they are | 366 | every link it keeps on every save, and =--link ''= is how they are |
| 368 | cleared. | 367 | cleared. |
| 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 | ||
| 386 | filter and cursor, and promote, demote, disable and enable per row; | ||
| 387 | demote and disable ask for the username to be typed. The rest is a | ||
| 388 | page nobody has built yet rather than a refusal — see below. Account | ||
| 389 | creation and deletion stay on the command line on purpose: one takes a | ||
| 390 | key, 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 | |||
| 388 | unchanged. The web pages the issue and merge request lists at fifty | 410 | unchanged. The web pages the issue and merge request lists at fifty |
| 389 | with the same cursors; iOS pages with them too. | 411 | with the same cursors; iOS pages with them too. |
| 390 | 412 | ||
| 391 | * SSH only, by design | 413 | * CLI only, for now |
| 392 | 414 | ||
| 393 | Build secrets, mirror configuration and tokens, custom domain claims, | 415 | Build secrets, mirror configuration and tokens, custom domain claims, |
| 394 | API token minting, web session listing and revocation, deploy keys, | 416 | API token minting, web session listing and revocation, deploy keys, |
| 395 | account and instance administration. Deleting, transferring or renaming | 417 | and instance administration have no page yet. Until #234 these were |
| 396 | a repository is also CLI-only, as is deleting an organization and | 418 | refused outright on the other surfaces; the refusal is gone, so each is |
| 397 | pruning merge request heads (=admin mr prune=): each removes or moves | 419 | now a page waiting to be built rather than a rule. A credential still |
| 398 | what clone URLs point at, and wants a typed command, not a button. | 420 | travels on stdin wherever it is set, since argv is world-readable in |
| 399 | 421 | /proc and the audit log keeps flag values. | |
| 400 | These 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 | 423 | Deleting, transferring or renaming a repository stays CLI-only on |
| 402 | carry the capability at all — see the archive note above. | 424 | purpose, as does deleting an organization and pruning merge request |
| 425 | heads (=admin mr prune=): each removes or moves what clone URLs point | ||
| 426 | at, and wants a typed command rather than a button. | ||
| 427 | |||
| 428 | =n/a= means a surface cannot usefully carry the capability at all — | ||
| 429 | see 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 | ||
| 171 | The web renders profiles but has no form for editing one, so the CLI is | 171 | The web renders profiles but has no form for editing one, so the CLI is |
| 172 | the only interface today. =profile set= is not =SSHOnly=, so the JSON API | 172 | the only interface today. The JSON API runs =profile set= like any |
| 173 | runs it like any other write command. | 173 | other 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 | ||
| 9 | The web findings from the forge comparison (#232). | 9 | The 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 @@ | |||
| 1 | package e2e | ||
| 2 | |||
| 3 | import ( | ||
| 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. | ||
| 12 | func 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 | ||
| 54 | func runAdminUserCreate(c *Ctx, args []string) int { | 54 | func 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 | ||
| 21 | func runAudit(c *Ctx, args []string) int { | 21 | func 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 | ||
| 73 | type BuildOut struct { | 73 | type 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. |
| 330 | func runnerSession(c *Ctx) (store.SSHKey, int) { | 331 | func 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. | ||
| 82 | func 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 | ||
| 91 | func runAdminUserLimits(c *Ctx, args []string) int { | 91 | func 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 | ||
| 27 | func runWebSessionsList(c *Ctx, args []string) int { | 27 | func runWebSessionsList(c *Ctx, args []string) int { |
internal/httpd/adminusers.go added +121
| @@ -0,0 +1,121 @@ | |||
| 1 | package httpd | ||
| 2 | |||
| 3 | import ( | ||
| 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. | ||
| 14 | type 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. | ||
| 25 | const 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. | ||
| 31 | func (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. | ||
| 80 | func (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. | ||
| 22 | func (s *Server) runControl(u store.User, argv []string) (out string, msg string, ok bool) { | 24 | func (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. | ||
| 74 | func (s *Server) runControlStdin(u store.User, argv []string, stdin string) (msg string, ok bool) { | 75 | func (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; } | |||
| 827 | table.keys th, table.assets th, table.keys tr.cols th { background: transparent; padding-left: 0; } | 827 | table.keys th, table.assets th, table.keys tr.cols th { background: transparent; padding-left: 0; } |
| 828 | table.keys td { padding-left: 0; } | 828 | table.keys td { padding-left: 0; } |
| 829 | table.keys td.act, table.assets td.size { text-align: right; } | 829 | table.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. */ | ||
| 832 | table.accounts input[name="confirm"] { width: 11rem; } | ||
| 833 | table.accounts form.actions { display: inline-flex; gap: var(--sp-2); margin: 0 var(--sp-2) var(--sp-2) 0; } | ||
| 830 | table.assets td.name { width: 100%; font-family: var(--mono); font-size: var(--fs-1); } | 834 | table.assets td.name { width: 100%; font-family: var(--mono); font-size: var(--fs-1); } |
| 831 | table.assets { margin-top: var(--sp-2); } | 835 | table.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 | |||
| 138 | never included; a replayed bundle's emails arrive unverified.</p> | 138 | never 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 |
| 143 | where it can be piped instead of pasted:</p> | 143 | easier 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 |
| 145 | gitbay web sessions list # browser sessions | ||
| 145 | gitbay admin ... # instance administration</pre> | 146 | gitbay 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 | ||
| 62 | asserting an address stay on the command line: each takes a key, mints | ||
| 63 | a credential, or cannot be undone.</p> | ||
| 64 | <pre tabindex="0"><code>gitbay admin user create <name> --key - < key.pub | ||
| 65 | gitbay admin user delete <name> --yes | ||
| 66 | gitbay admin invite --email <address></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. |
| 91 | func TestMainWidthClass(t *testing.T) { | 91 | func 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) |