mirror of
https://github.com/runbear-io/beardrive.git
synced 2026-08-25 08:08:08 +02:00
fix(auth): first-account bootstrap — admin emails activate on signup; add CI
A fresh hub following docs/self-hosting.md was a locked room: invite-only (the default) showed "Sign up disabled" with nobody to mint an invite, and the approval-gated posture stranded the first admin as pending forever. Emails on the config's admin list are operator-vetted, so they now activate immediately on signup (any posture), and while the hub has zero accounts they may sign up even on an invite-only hub. Strangers still can't take the bootstrap slot, and the door closes after the first account. Validated end to end from scratch: hub boot → admin signup → device-code login × 2 devices → init → bidirectional sync → hooks install. Also adds the missing GitHub Actions CI workflow (build/vet/test on ubuntu + macos) — the repo previously had no CI at all. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
16210f3236
commit
623da892d2
@@ -0,0 +1,21 @@
|
|||||||
|
name: ci
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
pull_request:
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-test:
|
||||||
|
runs-on: ${{ matrix.os }}
|
||||||
|
strategy:
|
||||||
|
matrix:
|
||||||
|
os: [ubuntu-latest, macos-latest]
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: actions/setup-go@v5
|
||||||
|
with:
|
||||||
|
go-version-file: go.mod
|
||||||
|
- run: go build ./...
|
||||||
|
- run: go vet ./...
|
||||||
|
- run: go test ./...
|
||||||
+10
-5
@@ -31,8 +31,9 @@ hub over HTTPS.
|
|||||||
"upload": true,
|
"upload": true,
|
||||||
"auth": {
|
"auth": {
|
||||||
// Signup is invite-only by default — the safe posture for a public
|
// Signup is invite-only by default — the safe posture for a public
|
||||||
// URL. Your first account: start once with signup gated (or use an
|
// URL. Bootstrap: while the hub has zero accounts, the emails listed
|
||||||
// org invite), then invite teammates from the web UI.
|
// in "admins" can sign up directly; then invite teammates from the
|
||||||
|
// web UI.
|
||||||
"admins": ["you@example.com"],
|
"admins": ["you@example.com"],
|
||||||
"users_db": "/var/lib/bdrive/auth.json"
|
"users_db": "/var/lib/bdrive/auth.json"
|
||||||
},
|
},
|
||||||
@@ -58,8 +59,11 @@ as bearer tokens. For containers, the repo ships a `Dockerfile`
|
|||||||
|
|
||||||
## 5. First sign-in and first project
|
## 5. First sign-in and first project
|
||||||
|
|
||||||
1. Open `https://your-hub/` → create your account (first run: use the
|
1. Open `https://your-hub/` → sign up with one of the `admins` emails
|
||||||
signup posture you configured; hub admins are the `admins` emails).
|
from your config. On a brand-new hub these can always create the
|
||||||
|
first account (whatever the signup posture); it activates
|
||||||
|
immediately, and admin-listed emails skip the approval/verification
|
||||||
|
gates on any posture.
|
||||||
2. On any machine: `bdrive login https://your-hub` (browser flow), then
|
2. On any machine: `bdrive login https://your-hub` (browser flow), then
|
||||||
in the folder you want synced:
|
in the folder you want synced:
|
||||||
`bdrive init --name wiki --yes` — or `--shared docs` inside a repo to
|
`bdrive init --name wiki --yes` — or `--shared docs` inside a repo to
|
||||||
@@ -85,7 +89,8 @@ no plaintext credentials ever touch disk).
|
|||||||
**Signup is invite-only by default** — the safe posture for a hub on a
|
**Signup is invite-only by default** — the safe posture for a hub on a
|
||||||
public URL. New people get in only through an expiring invite link an owner
|
public URL. New people get in only through an expiring invite link an owner
|
||||||
mints; the link lets them create an account (bypassing the gates below) and
|
mints; the link lets them create an account (bypassing the gates below) and
|
||||||
join, in one step. To allow self-service signup instead, set
|
join, in one step. (One exception so a fresh hub isn't a locked room: while
|
||||||
|
zero accounts exist, the config's `admins` emails may sign up directly.) To allow self-service signup instead, set
|
||||||
`"allow_signup": true` **with a gate** — the server refuses to start an open
|
`"allow_signup": true` **with a gate** — the server refuses to start an open
|
||||||
hub that has none, so a fake email can never just walk in. Three postures:
|
hub that has none, so a fake email can never just walk in. Three postures:
|
||||||
|
|
||||||
|
|||||||
@@ -142,6 +142,63 @@ func TestSignupDisabled(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A fresh approval-gated hub must not strand its first admin: an email on the
|
||||||
|
// config's admin list activates on signup instead of waiting for an approver
|
||||||
|
// who doesn't exist yet.
|
||||||
|
func TestSignupAdminBypassesApproval(t *testing.T) {
|
||||||
|
srv, auth, _ := authHub(t, true)
|
||||||
|
auth.RequireApproval = true
|
||||||
|
auth.Admins = map[string]bool{"admin@x.io": true}
|
||||||
|
h := srv.Handler()
|
||||||
|
|
||||||
|
cookie := signupAndSession(t, h, "admin@x.io", "Admin", "password1")
|
||||||
|
if cookie == nil {
|
||||||
|
t.Fatal("admin signup did not start a session")
|
||||||
|
}
|
||||||
|
|
||||||
|
// A non-admin under the same posture still lands pending.
|
||||||
|
form := url.Values{"email": {"b@x.io"}, "name": {"B"}, "password": {"password1"}}
|
||||||
|
req := httptest.NewRequest("POST", "/auth/signup", strings.NewReader(form.Encode()))
|
||||||
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(rec, req)
|
||||||
|
if rec.Code != 200 || !strings.Contains(rec.Body.String(), "waiting for an administrator") {
|
||||||
|
t.Fatalf("non-admin signup should be pending: %d %s", rec.Code, rec.Body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A brand-new invite-only hub has nobody to mint an invite: until the first
|
||||||
|
// account exists, config-listed admin emails may sign up directly; everyone
|
||||||
|
// else stays out, and the door closes again after the first account.
|
||||||
|
func TestSignupInviteOnlyBootstrap(t *testing.T) {
|
||||||
|
srv, auth, _ := authHub(t, false)
|
||||||
|
auth.Admins = map[string]bool{"admin@x.io": true}
|
||||||
|
h := srv.Handler()
|
||||||
|
|
||||||
|
// a stranger can't take the bootstrap slot
|
||||||
|
form := url.Values{"email": {"b@x.io"}, "name": {"B"}, "password": {"password1"}}
|
||||||
|
req := httptest.NewRequest("POST", "/auth/signup", strings.NewReader(form.Encode()))
|
||||||
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(rec, req)
|
||||||
|
if rec.Code != 200 || !strings.Contains(rec.Body.String(), "only a hub admin") {
|
||||||
|
t.Fatalf("stranger during bootstrap: %d %s", rec.Code, rec.Body)
|
||||||
|
}
|
||||||
|
|
||||||
|
// the configured admin signs up and is active immediately
|
||||||
|
if signupAndSession(t, h, "admin@x.io", "Admin", "password1") == nil {
|
||||||
|
t.Fatal("admin bootstrap signup did not start a session")
|
||||||
|
}
|
||||||
|
|
||||||
|
// with the first account in place the hub is invite-only again
|
||||||
|
req = httptest.NewRequest("GET", "/auth/signup", nil)
|
||||||
|
rec = httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(rec, req)
|
||||||
|
if !strings.Contains(rec.Body.String(), "invite-only") {
|
||||||
|
t.Fatalf("signup page after bootstrap should be disabled: %s", rec.Body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// The browser flow bdrive login drives: session → /auth/cli redirect with a
|
// The browser flow bdrive login drives: session → /auth/cli redirect with a
|
||||||
// one-time code → exchange for a device token.
|
// one-time code → exchange for a device token.
|
||||||
func TestCLICallbackFlow(t *testing.T) {
|
func TestCLICallbackFlow(t *testing.T) {
|
||||||
|
|||||||
@@ -194,7 +194,11 @@ func (a *BuiltinAuth) createAccount(email, name, password string, viaInvite bool
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
status := a.initialStatus()
|
status := a.initialStatus()
|
||||||
if viaInvite {
|
// An invite is an owner's explicit grant; an email on the config's admin
|
||||||
|
// list is the operator's own. Both are stronger vetting than the signup
|
||||||
|
// gates, so these accounts activate immediately — otherwise a fresh
|
||||||
|
// approval-gated hub would strand its first admin as pending forever.
|
||||||
|
if viaInvite || a.isAdmin(email) {
|
||||||
status = statusActive
|
status = statusActive
|
||||||
}
|
}
|
||||||
a.mu.Lock()
|
a.mu.Lock()
|
||||||
@@ -287,6 +291,12 @@ func (a *BuiltinAuth) isAdmin(email string) bool {
|
|||||||
return a.Admins != nil && a.Admins[normEmail(email)]
|
return a.Admins != nil && a.Admins[normEmail(email)]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (a *BuiltinAuth) accountCount() int {
|
||||||
|
a.mu.Lock()
|
||||||
|
defer a.mu.Unlock()
|
||||||
|
return len(a.users)
|
||||||
|
}
|
||||||
|
|
||||||
func (a *BuiltinAuth) verifyPassword(email, password string) *authUser {
|
func (a *BuiltinAuth) verifyPassword(email, password string) *authUser {
|
||||||
a.mu.Lock()
|
a.mu.Lock()
|
||||||
u := a.findByEmail(email)
|
u := a.findByEmail(email)
|
||||||
@@ -712,9 +722,13 @@ func (a *BuiltinAuth) pageLogin(w http.ResponseWriter, r *http.Request) {
|
|||||||
func (a *BuiltinAuth) pageSignup(w http.ResponseWriter, r *http.Request) {
|
func (a *BuiltinAuth) pageSignup(w http.ResponseWriter, r *http.Request) {
|
||||||
next := safeNext(r.FormValue("next"))
|
next := safeNext(r.FormValue("next"))
|
||||||
// An invite link authorizes account creation even when public self-signup
|
// An invite link authorizes account creation even when public self-signup
|
||||||
// is closed — it's the only way into an invite-only hub.
|
// is closed — it's the only way into an invite-only hub. Except once: a
|
||||||
|
// brand-new hub has no accounts to mint an invite with, so until the
|
||||||
|
// first account exists, the emails on the config's admin list may sign
|
||||||
|
// up directly (the operator wrote them there — that's the vetting).
|
||||||
inviteTok := a.invitedVia(next)
|
inviteTok := a.invitedVia(next)
|
||||||
if !a.AllowSignup && inviteTok == "" {
|
bootstrap := !a.AllowSignup && inviteTok == "" && len(a.Admins) > 0 && a.accountCount() == 0
|
||||||
|
if !a.AllowSignup && inviteTok == "" && !bootstrap {
|
||||||
authPage(w, "Sign up disabled", `<p>This server is invite-only. Ask a team owner for an invite link, or sign in if you already have an account.</p>
|
authPage(w, "Sign up disabled", `<p>This server is invite-only. Ask a team owner for an invite link, or sign in if you already have an account.</p>
|
||||||
<p class="alt"><a href="/auth/login">Back to sign in</a></p>`)
|
<p class="alt"><a href="/auth/login">Back to sign in</a></p>`)
|
||||||
return
|
return
|
||||||
@@ -725,6 +739,11 @@ func (a *BuiltinAuth) pageSignup(w http.ResponseWriter, r *http.Request) {
|
|||||||
if inviteTok != "" {
|
if inviteTok != "" {
|
||||||
signup = a.signupInvited // invite is the vetting: skip gates, activate
|
signup = a.signupInvited // invite is the vetting: skip gates, activate
|
||||||
}
|
}
|
||||||
|
if bootstrap && !a.isAdmin(r.FormValue("email")) {
|
||||||
|
signup = func(string, string, string) (*authUser, error) {
|
||||||
|
return nil, fmt.Errorf("this server is invite-only; only a hub admin can create the first account")
|
||||||
|
}
|
||||||
|
}
|
||||||
u, err := signup(r.FormValue("email"), r.FormValue("name"), r.FormValue("password"))
|
u, err := signup(r.FormValue("email"), r.FormValue("name"), r.FormValue("password"))
|
||||||
if err == nil {
|
if err == nil {
|
||||||
switch u.Status {
|
switch u.Status {
|
||||||
|
|||||||
Reference in New Issue
Block a user