Commit 000e6436f3
Verified · cmc ci/build: success ci/test: success
.gitbay/wiki/API.org +6 −2
| @@ -13,8 +13,12 @@ enabled = true | ||
| 13 | 13 | |
| 14 | 14 | ** Tokens |
| 15 | 15 | |
| 16 | Tokens are minted over SSH and only over SSH — an API token can never | |
| 17 | create further credentials. | |
| 16 | Tokens are minted wherever the registry is reached: over SSH, on the | |
| 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 | 23 | #+begin_src sh |
| 20 | 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 | 187 | #+begin_src sh |
| 188 | 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 | 190 | ssh git@<host> admin user list [--state active|pending|disabled|admin] |
| 191 | 191 | ssh git@<host> admin user show <name> # keys, emails, orgs, tokens, sessions |
| 192 | 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 | 212 | keys or API tokens. =admin user show= adds the keys with their last use, |
| 213 | 213 | each address with how it was verified, PGP keys, org roles, the owned |
| 214 | 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 | 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 | |
| 219 | host alike, so the host-local =promote= is the way back in when the only | |
| 220 | admin key is lost. | |
| 228 | Demotion is refused when it would leave no admin, over SSH, in the | |
| 229 | browser and on the host alike, so the host-local =promote= is the way | |
| 230 | back in when the only admin key is lost. | |
| 221 | 231 | |
| 222 | 232 | Instance admin carries no right on anyone's repository: policy does not |
| 223 | 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 | 11 | - [[Admin][Admin guide]] — install, configuration reference, backup, security |
| 12 | 12 | - [[API][API and webhooks]] — the JSON API contract, tokens, payloads |
| 13 | 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 | 15 | - [[Performance][Performance]] — stress-test numbers from importing git.git |
| 16 | 16 | |
| 17 | 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 | 16 | A capability lands over SSH first. If it belongs to the |
| 17 | 17 | triage/review/respond loop, it lands on the web in the same merge |
| 18 | request. Anything whose input is a credential — secrets, mirror | |
| 19 | tokens, API tokens — stays SSH-only by design: the web dispatcher | |
| 20 | refuses =SSHOnly= commands outright. Session minting is not one of | |
| 21 | them: what a browser submits to ask for a login link is a username or | |
| 22 | an address, and the credential it gets back travels by mail. | |
| 18 | request. Nothing is held back from a surface any more (#234): the | |
| 19 | registry has no flag for it, and what a caller may do is the account's | |
| 20 | rights narrowed by the scope of the key or token it arrived with, | |
| 21 | decided in one place. A =no= in the web or ios column is a page nobody | |
| 22 | has built yet, not a refusal. | |
| 23 | 23 | |
| 24 | 24 | Rows are one page or one action each. Grouped rows hide gaps, twice |
| 25 | 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 | 360 | argument above — a JSON bundle has nowhere useful to land on a phone, |
| 361 | 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 | |
| 364 | =SSHOnly= — nothing about a bio is a credential, and the JSON API runs | |
| 365 | it. The account settings page has the form, and so does the iOS client: | |
| 363 | =profile set= carries description, website, about and links; the JSON | |
| 364 | API runs it like any other write. The account settings page has the form, and so does the iOS client: | |
| 366 | 365 | =--link= replaces the whole set rather than appending, so a client sends |
| 367 | 366 | every link it keeps on every save, and =--link ''= is how they are |
| 368 | 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 | 392 | * Organizations |
| 371 | 393 | |
| 372 | 394 | | capability | cli | web | ios | |
| @@ -388,15 +410,20 @@ one. Without the flags a list stays complete, so existing scripts are | ||
| 388 | 410 | unchanged. The web pages the issue and merge request lists at fifty |
| 389 | 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 | 415 | Build secrets, mirror configuration and tokens, custom domain claims, |
| 394 | 416 | API token minting, web session listing and revocation, deploy keys, |
| 395 | account and instance administration. Deleting, transferring or renaming | |
| 396 | a repository is also CLI-only, as is deleting an organization and | |
| 397 | pruning merge request heads (=admin mr prune=): each removes or moves | |
| 398 | what clone URLs point at, and wants a typed command, not a button. | |
| 399 | ||
| 400 | These 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 | |
| 402 | carry the capability at all — see the archive note above. | |
| 417 | and instance administration have no page yet. Until #234 these were | |
| 418 | refused outright on the other surfaces; the refusal is gone, so each is | |
| 419 | now a page waiting to be built rather than a rule. A credential still | |
| 420 | travels on stdin wherever it is set, since argv is world-readable in | |
| 421 | /proc and the audit log keeps flag values. | |
| 422 | ||
| 423 | Deleting, transferring or renaming a repository stays CLI-only on | |
| 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 | 38 | - *Per-instance trust.* Email verification and key registration are local |
| 39 | 39 | to an instance and never transfer. Account migration re-registers keys |
| 40 | 40 | 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. | |
| 44 | 47 | - *Anonymous surfaces* — HTTPS clone of public repos, =git://= where |
| 45 | 48 | enabled, the read-only web UI — carry no credentials and expose only |
| 46 | 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 | 169 | #+end_src |
| 170 | 170 | |
| 171 | 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 | |
| 173 | runs it like any other write command. | |
| 172 | the only interface today. The JSON API runs =profile set= like any | |
| 173 | other write command. | |
| 174 | 174 | |
| 175 | 175 | * Repositories |
| 176 | 176 | |
CHANGELOG.org +13
| @@ -8,6 +8,19 @@ anything beyond "replace the binary and restart" is needed. | ||
| 8 | 8 | |
| 9 | 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 | 24 | - =[registration] notify_admin= mails the instance's admins when an |
| 12 | 25 | account becomes active: an invite redeemed, or an open-mode signup |
| 13 | 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 | 59 | with SMTP email verification |
| 60 | 60 | - signed outbound webhooks with retries, dead-lettering, and SSRF |
| 61 | 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 | 63 | - built-in ACME (Let's Encrypt) TLS; =admin backup= produces one |
| 64 | 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 | 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 | 111 | if strings.Contains(body, `value="token-mint"`) { |
| 110 | 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 | 139 | t.Fatalf("git over API: %d", status) |
| 140 | 140 | } |
| 141 | 141 | |
| 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 { | |
| 145 | 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 | 18 | register(Command{Path: []string{"admin", "user", "list"}, |
| 19 | 19 | Summary: "list accounts (instance admins)", |
| 20 | 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 | 22 | register(Command{Path: []string{"admin", "user", "show"}, |
| 23 | 23 | Summary: "show an account: keys, emails, orgs, tokens, sessions (instance admins)", |
| 24 | 24 | Usage: "admin user show <username>", |
| 25 | ReadOnly: true, SSHOnly: true, Run: runAdminUserShow}) | |
| 25 | ReadOnly: true, Run: runAdminUserShow}) | |
| 26 | 26 | register(Command{Path: []string{"admin", "user", "promote"}, |
| 27 | 27 | Summary: "make an account an instance admin", |
| 28 | 28 | Usage: "admin user promote <username>", |
| 29 | SSHOnly: true, Run: runAdminUserPromote}) | |
| 29 | Run: runAdminUserPromote}) | |
| 30 | 30 | register(Command{Path: []string{"admin", "user", "demote"}, |
| 31 | 31 | Summary: "remove instance admin from an account (never the last one)", |
| 32 | 32 | Usage: "admin user demote <username>", |
| 33 | SSHOnly: true, Run: runAdminUserDemote}) | |
| 33 | Run: runAdminUserDemote}) | |
| 34 | 34 | register(Command{Path: []string{"admin", "runners"}, |
| 35 | 35 | Summary: "the build queue and runner accounts: last poll, scope, the build each holds (instance admins)", |
| 36 | 36 | Usage: "admin runners", |
| 37 | ReadOnly: true, SSHOnly: true, Run: runAdminRunners}) | |
| 37 | ReadOnly: true, Run: runAdminRunners}) | |
| 38 | 38 | register(Command{Path: []string{"admin", "runners", "remove"}, |
| 39 | 39 | Summary: "drop a key's runner heartbeat row, e.g. one that polled once by mistake (instance admins)", |
| 40 | 40 | Usage: "admin runners remove <fingerprint>", |
| 41 | SSHOnly: true, Run: runAdminRunnersForget}) | |
| 41 | Run: runAdminRunnersForget}) | |
| 42 | 42 | // forget is the name this shipped under in v1.18; remove is the verb |
| 43 | 43 | // every other noun uses. Both stay for one release. |
| 44 | 44 | register(Command{Path: []string{"admin", "runners", "forget"}, |
| 45 | 45 | Summary: "alias of admin runners remove", |
| 46 | 46 | Usage: "admin runners forget <fingerprint>", |
| 47 | SSHOnly: true, Run: runAdminRunnersForget}) | |
| 47 | Run: runAdminRunnersForget}) | |
| 48 | 48 | register(Command{Path: []string{"admin", "repo", "list"}, |
| 49 | 49 | Summary: "list every repository with size and last push (instance admins)", |
| 50 | 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 | 52 | register(Command{Path: []string{"admin", "repo", "archive"}, |
| 53 | 53 | Summary: "archive any repository (instance admins; audited)", |
| 54 | 54 | Usage: "admin repo archive <owner/name>", |
| 55 | SSHOnly: true, Run: runAdminRepoArchive}) | |
| 55 | Run: runAdminRepoArchive}) | |
| 56 | 56 | register(Command{Path: []string{"admin", "repo", "unarchive"}, |
| 57 | 57 | Summary: "unarchive any repository (instance admins; audited)", |
| 58 | 58 | Usage: "admin repo unarchive <owner/name>", |
| 59 | SSHOnly: true, Run: runAdminRepoUnarchive}) | |
| 59 | Run: runAdminRepoUnarchive}) | |
| 60 | 60 | register(Command{Path: []string{"admin", "repo", "visibility"}, |
| 61 | 61 | Summary: "set any repository's visibility (instance admins; audited)", |
| 62 | 62 | Usage: "admin repo visibility <owner/name> public|private", |
| 63 | SSHOnly: true, Run: runAdminRepoVisibility}) | |
| 63 | Run: runAdminRepoVisibility}) | |
| 64 | 64 | register(Command{Path: []string{"admin", "repo", "delete"}, |
| 65 | 65 | Summary: "delete any repository (instance admins; audited)", |
| 66 | 66 | Usage: "admin repo delete <owner/name> --yes", |
| 67 | SSHOnly: true, Run: runAdminRepoDelete}) | |
| 67 | Run: runAdminRepoDelete}) | |
| 68 | 68 | register(Command{Path: []string{"admin", "mr", "prune"}, |
| 69 | 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 | 70 | Usage: "admin mr prune <owner/name> <n> [<n>...] --yes", |
| 71 | SSHOnly: true, Run: runAdminMRPrune}) | |
| 71 | Run: runAdminMRPrune}) | |
| 72 | 72 | } |
| 73 | 73 | |
| 74 | 74 | // requireInstanceAdmin gates the admin noun. -1 means proceed. |
internal/control/adminhost.go +7 −7
| @@ -24,31 +24,31 @@ func init() { | ||
| 24 | 24 | register(Command{Path: []string{"admin", "user", "create"}, |
| 25 | 25 | Summary: "create an account, optionally with a key and a verified address (instance admins)", |
| 26 | 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 | 28 | register(Command{Path: []string{"admin", "user", "disable"}, |
| 29 | 29 | Summary: "suspend an account: SSH, web sessions and API tokens refused until re-enabled", |
| 30 | 30 | Usage: "admin user disable <username>", |
| 31 | SSHOnly: true, Run: runAdminUserDisable}) | |
| 31 | Run: runAdminUserDisable}) | |
| 32 | 32 | register(Command{Path: []string{"admin", "user", "enable"}, |
| 33 | 33 | Summary: "restore a suspended account", |
| 34 | 34 | Usage: "admin user enable <username>", |
| 35 | SSHOnly: true, Run: runAdminUserEnable}) | |
| 35 | Run: runAdminUserEnable}) | |
| 36 | 36 | register(Command{Path: []string{"admin", "user", "delete"}, |
| 37 | 37 | Summary: "delete an account that anchors nothing (keys, emails and sessions go with it)", |
| 38 | 38 | Usage: "admin user delete <username> --yes", |
| 39 | SSHOnly: true, Run: runAdminUserDelete}) | |
| 39 | Run: runAdminUserDelete}) | |
| 40 | 40 | register(Command{Path: []string{"admin", "email", "verify"}, |
| 41 | 41 | Summary: "mark an address verified by admin assertion", |
| 42 | 42 | Usage: "admin email verify <username> <address>", |
| 43 | SSHOnly: true, Run: runAdminEmailVerify}) | |
| 43 | Run: runAdminEmailVerify}) | |
| 44 | 44 | register(Command{Path: []string{"admin", "invite"}, |
| 45 | 45 | Summary: "issue a registration invite and mail its code", |
| 46 | 46 | Usage: "admin invite --email <address>", |
| 47 | SSHOnly: true, Run: runAdminInvite}) | |
| 47 | Run: runAdminInvite}) | |
| 48 | 48 | register(Command{Path: []string{"admin", "stats"}, |
| 49 | 49 | Summary: "instance statistics: counts and per-repository disk usage", |
| 50 | 50 | Usage: "admin stats", |
| 51 | ReadOnly: true, SSHOnly: true, Run: runAdminStats}) | |
| 51 | ReadOnly: true, Run: runAdminStats}) | |
| 52 | 52 | } |
| 53 | 53 | |
| 54 | 54 | func runAdminUserCreate(c *Ctx, args []string) int { |
internal/control/audit.go +1 −1
| @@ -15,7 +15,7 @@ func init() { | ||
| 15 | 15 | register(Command{Path: []string{"audit"}, |
| 16 | 16 | Summary: "instance audit log (admins)", |
| 17 | 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 | 21 | func runAudit(c *Ctx, args []string) int { |
internal/control/build.go +7 −6
| @@ -46,7 +46,7 @@ func init() { | ||
| 46 | 46 | register(Command{Path: []string{"repo", "secret", "set"}, |
| 47 | 47 | Summary: "set a build secret", |
| 48 | 48 | Usage: "repo secret set <owner/name> <NAME> (value on stdin)", |
| 49 | ReadsStdin: true, SSHOnly: true, Run: runSecretSet}) | |
| 49 | ReadsStdin: true, Run: runSecretSet}) | |
| 50 | 50 | register(Command{Path: []string{"repo", "secret", "remove"}, |
| 51 | 51 | Summary: "remove a build secret", |
| 52 | 52 | Usage: "repo secret remove <owner/name> <NAME>", Run: runSecretRemove}) |
| @@ -61,13 +61,13 @@ func init() { | ||
| 61 | 61 | // an admin key, which a runner host should not hold (#92). |
| 62 | 62 | register(Command{Path: []string{"runner", "next"}, |
| 63 | 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 | 65 | register(Command{Path: []string{"runner", "log"}, |
| 66 | 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 | 68 | register(Command{Path: []string{"runner", "done"}, |
| 69 | 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 | 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 | |
| 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 | |
| 328 | 329 | // admin key is accepted so an operator can rotate at their own pace; a |
| 329 | 330 | // runner host should hold a key added with --scope runner. |
| 330 | 331 | func runnerSession(c *Ctx) (store.SSHKey, int) { |
internal/control/control.go +4 −7
| @@ -27,9 +27,10 @@ type Ctx struct { | ||
| 27 | 27 | Stdout io.Writer |
| 28 | 28 | Stderr io.Writer |
| 29 | 29 | 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. | |
| 33 | 34 | ViaAPI bool |
| 34 | 35 | // ReadOnly is set for read-scoped API tokens. |
| 35 | 36 | ReadOnly bool |
| @@ -62,7 +63,6 @@ type Command struct { | ||
| 62 | 63 | Usage string |
| 63 | 64 | ReadsStdin bool |
| 64 | 65 | ReadOnly bool // safe for read-scoped API tokens |
| 65 | SSHOnly bool // refused over the HTTP API (credential minting) | |
| 66 | 66 | Run func(c *Ctx, args []string) int |
| 67 | 67 | } |
| 68 | 68 | |
| @@ -117,9 +117,6 @@ func Dispatch(c *Ctx, argv []string) int { | ||
| 117 | 117 | if c.Scope != "full" && !(c.Scope == "runner" && cmd.Path[0] == "runner") { |
| 118 | 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 | 120 | if c.ReadOnly && !cmd.ReadOnly { |
| 124 | 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 | 73 | if !cmd.ReadOnly { |
| 74 | 74 | t.Error("build jobs must be ReadOnly; listing jobs changes nothing") |
| 75 | 75 | } |
| 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. | |
| 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 | 18 | register(Command{Path: []string{"repo", "mirror", "add"}, |
| 19 | 19 | Summary: "mirror to or from a remote", |
| 20 | 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 | 22 | register(Command{Path: []string{"repo", "mirror", "list"}, |
| 23 | 23 | Summary: "list mirrors with sync status", |
| 24 | 24 | Usage: "repo mirror list <owner/name>", ReadOnly: true, Run: runMirrorList}) |
internal/control/quota.go +1 −1
| @@ -85,7 +85,7 @@ func init() { | ||
| 85 | 85 | register(Command{Path: []string{"admin", "user", "limits"}, |
| 86 | 86 | Summary: "show or set an account's repository and storage caps (instance admins)", |
| 87 | 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 | 91 | func runAdminUserLimits(c *Ctx, args []string) int { |
internal/control/token.go +3 −3
| @@ -16,14 +16,14 @@ func init() { | ||
| 16 | 16 | register(Command{Path: []string{"token", "create"}, |
| 17 | 17 | Summary: "mint an API token (shown once)", |
| 18 | 18 | Usage: "token create --name <n> [--scope full|read] [--ttl 30d|720h]", |
| 19 | SSHOnly: true, Run: runTokenCreate}) | |
| 19 | Run: runTokenCreate}) | |
| 20 | 20 | register(Command{Path: []string{"token", "list"}, |
| 21 | 21 | Summary: "list API tokens", |
| 22 | Usage: "token list", ReadOnly: true, SSHOnly: true, Run: runTokenList}) | |
| 22 | Usage: "token list", ReadOnly: true, Run: runTokenList}) | |
| 23 | 23 | register(Command{Path: []string{"token", "revoke"}, |
| 24 | 24 | Summary: "revoke an API token by name", |
| 25 | 25 | Usage: "token revoke <name>", |
| 26 | SSHOnly: true, Run: runTokenRevoke}) | |
| 26 | Run: runTokenRevoke}) | |
| 27 | 27 | } |
| 28 | 28 | |
| 29 | 29 | // parseTTL accepts Go durations plus a day suffix ("30d"). |
internal/control/web.go +2 −2
| @@ -18,10 +18,10 @@ func init() { | ||
| 18 | 18 | Usage: "web login", Run: runWebLogin}) |
| 19 | 19 | register(Command{Path: []string{"web", "sessions", "list"}, |
| 20 | 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 | 22 | register(Command{Path: []string{"web", "sessions", "revoke"}, |
| 23 | 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 | 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 | 17 | // never reimplement command logic — merge gates, review rules, and audit |
| 18 | 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 | |
| 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. | |
| 22 | 24 | func (s *Server) runControl(u store.User, argv []string) (out string, msg string, ok bool) { |
| 23 | 25 | out, msg, code := s.runControlCode(u, argv) |
| 24 | 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 | 71 | // 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. | |
| 74 | 75 | func (s *Server) runControlStdin(u store.User, argv []string, stdin string) (msg string, ok bool) { |
| 75 | 76 | msg, code := s.runControlStdinCode(u, argv, stdin) |
| 76 | 77 | return msg, code == protocol.ExitOK |
internal/httpd/routes.go +3
| @@ -123,6 +123,9 @@ func (s *Server) Routes() []Route { | ||
| 123 | 123 | Route{Method: "POST", Pattern: "/notifications", Mutating: true, |
| 124 | 124 | Handler: s.checkOrigin(s.requireUser(s.notificationsRead))}, |
| 125 | 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 | 129 | Route{Method: "POST", Pattern: "/{owner}", Mutating: true, |
| 127 | 130 | Handler: s.checkOrigin(s.requireUser(s.orgSubmit))}, |
| 128 | 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 | 827 | table.keys th, table.assets th, table.keys tr.cols th { background: transparent; padding-left: 0; } |
| 828 | 828 | table.keys td { padding-left: 0; } |
| 829 | 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 | 834 | table.assets td.name { width: 100%; font-family: var(--mono); font-size: var(--fs-1); } |
| 831 | 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 | 138 | never included; a replayed bundle's emails arrive unverified.</p> |
| 139 | 139 | <p><a href="/settings/export">Download bundle</a></p> |
| 140 | 140 | |
| 141 | <h2>On SSH only</h2> | |
| 142 | <p class="meta">Anything whose input is a credential stays on the command line, | |
| 143 | where 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 | |
| 143 | easier to pipe than to paste, and a minted token is shown once.</p> | |
| 144 | 144 | <pre class="message">gitbay auth token create --name laptop # API tokens |
| 145 | gitbay web sessions list # browser sessions | |
| 145 | 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 | 148 | <code>ssh git@{{.Host}} auth whoami</code>.</p> |
| 148 | 149 | {{end}} |
internal/web/templates/admin.html +1
| @@ -3,6 +3,7 @@ | ||
| 3 | 3 | {{define "content"}} |
| 4 | 4 | <h1>Admin</h1> |
| 5 | 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 | 8 | {{with .Queues.Webhooks}} |
| 8 | 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 | 90 | // a per-view define instead of a fixed one. |
| 91 | 91 | func TestMainWidthClass(t *testing.T) { |
| 92 | 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 | 94 | perView := map[string]string{"mr.html": `{{define "width"}}{{if eq .View "diff"}}wide{{else}}reading{{end}}{{end}}`} |
| 95 | 95 | for _, name := range Pages() { |
| 96 | 96 | src, err := TemplateSource(name) |