fix(hub): device binding is a provider contract, not a BuiltinAuth field (#147)

ownJournal refuses a journal write unless the device id is bound to the
caller's account, for EVERY provider — it asks only whether s.Devices is
nil. The only thing that creates that binding is DeviceRegistry.Bind, whose
only caller is BuiltinAuth.finishLogin, and that hook was wired behind
`if a, ok := s.Auth.(*BuiltinAuth); ok`.

So a hub running a managed AuthProvider — the deployment the seam exists
for — bound nothing, ever, and refused every journal push from every device
forever. Everything around it read healthy: /api/auth/me answered, project
permissions said write, and blobs (content-addressed, so ownerless) uploaded
fine. Only the journal PUT died. Signing in again could not help, because
signing in was the step that was supposed to bind.

UseDeviceBinder moves the hook onto the AuthProvider interface — a breaking
change for an out-of-tree provider, deliberately, so one that ignores a
precondition of a gate the hub enforces for it does not compile. The hub
cannot bind on the provider's behalf: a bind must be reachable only from a
completed authentication, and Authenticate reports who a request is, never
which credential class it presented. A device token still cannot reach a
bind; no new door was added, and every /store/* door still creates nothing.

Bind also reported success for a row the store had refused — observeLocked
logged the write failure and swallowed it — so a login could hand back a
token whose every push was then denied, with nothing in the hub explaining
why. It now propagates and the login fails honestly.

And a hub that refuses a journal write while holding no binding at all logs
that its provider is not calling the binder — the sentence that would have
ended this investigation on day one instead of day two.

Tested by driving the real binary against a hub with a managed provider
(cli_provider_e2e_test.go), which is the configuration no test in this repo
covered and the reason this shipped: every existing test used BuiltinAuth,
where the wiring happened to work. Both directions are pinned — a provider
that binds pushes, one that ignores the binder reproduces the reported
symptom exactly.


Claude-Session: https://claude.ai/code/session_01GSHsQU4pBCzKkPyPeXSwTm

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Snow Lee (Sungwon)
2026-08-11 08:23:34 -07:00
committed by GitHub
co-authored by Claude Opus 5
parent dd35453c33
commit 94091a8795
10 changed files with 550 additions and 19 deletions
+1 -1
View File
@@ -49,7 +49,7 @@ Package roles (`internal/`):
`cmd/bdrive/` is a thin cobra CLI over these packages (`login`, `logout`, `init`, `stop`, `sync`, `scope`, `forget`, `status`, `log`, `share`, `export`, `import`, `url`, `hooks`, `resume`, `autostart`, `read-log`, `serve`, `whoami`, `daemon`, `version``mnt`/`umnt`/`remote` are gone; `init` is the front door and `stop` pauses). `export`/`import` (`migrate.go`) move a whole project between hubs with full fidelity: the archive is the remote store layout (all devices' journals + all blobs) in a tar.gz, streamed through the existing `remote.Backend` — no server-side support needed, so it works against any hub in either direction (the anti-lock-in story for cloud-hesitant users). `bdrive login` signs the device in (bare form uses the remembered server or `config.DefaultServer` = beardrive.ai; loopback-callback browser flow in `login.go`, `--device` for headless) and stores server+token+account in `settings.json`; `bdrive logout` revokes this device's token on the hub (`DELETE /api/auth/token`, authenticated by the token itself) and then clears the saved token+account, keeping the remembered server unless `--forget`; a revocation it could not reach the hub for is reported, never swallowed. Switching hubs is `bdrive login <new-url>` then re-`init``init` is the only thing that writes a folder's remote (always a hub, `server + "/p/" + id`); there is no client command to point a folder at a raw bucket. `bdrive init` is interactive on a TTY (survey menus: create-new vs connect-existing with a project list; whole-folder vs only-some-subfolders) with full flag bypass (`--name/--project/--only/--yes`) and never prompts without a TTY; it runs the login flow first when there is no session, writes `.bdrive/config.json`, seeds `.bdriveignore`, registers agent sync hooks in each platform's USER config (`~/.claude/settings.json` and friends — once per machine, never inside a project: platforms read hook config only from the directory a session starts in, so a per-project file covers only sessions that start there and, living in a mount, would sync to the team; `Install` also migrates away hooks older versions wrote into projects), and starts sync via `startSync`; re-running it resumes — including after a folder move. **A mount is always exactly the folder named** — there is no re-rooting flag. Syncing only part of a mount is `--only wiki,docs`, which writes a bdrive-managed block of `.bdriveignore` negation rules (`cmd/bdrive/scopefile.go`; `bdrive scope add/rm` edits the same block) rather than a second scope mechanism: the old `Include` list in `config.json` is legacy — still honored, never written. Because the rules live in the synced `.bdriveignore`, scope is team-wide, which is why `sync --prune` refuses when `!` rules are present (it would strip everything outside the scope from the hub for everyone; `bdrive forget <path>` is the per-path tool). `init` also refuses a second folder for a project this device already syncs — one device writes one journal per project, so two mounts would overwrite each other's ops. `bdrive serve -c config.json` configures the server from a file, explicit flags winning.
Authentication (`webapp/auth.go`, `authlocal.go`, `mail.go`) is **mandatory in hub mode** — the config's `auth` block tunes `users_db`/`allow_signup`/`allowed_domains`/`require_verification`/`require_approval`/`admins`/`smtp`; the plain-folder viewer stays auth-free — and sits behind the `AuthProvider` interface — the OSS server ships only `BuiltinAuth` (email+password accounts and device tokens in a file-backed `auth.json`; bcrypt for passwords, SHA-256 digests for tokens, plaintext never stored; server-owned `/auth/*` pages; one-time codes for the CLI callback and device flows; SMTP reset mail with a log-link fallback). **Signup is invite-only by default** (`allow_signup` defaults false): a valid org invite bootstraps an account even when self-signup is closed — `BuiltinAuth.InviteValid` (wired to `OrgDB.ValidInvite`) lets `pageSignup`/`pageLogin` offer account creation for a `/join/<token>` target, and `signupInvited` skips the domain/verification/approval gates and activates immediately (the invite is the vetting). `BuiltinAuth.ValidateSignupPolicy` (called at hub startup, `web.go`) refuses an ungated open hub and email-verification-without-SMTP rather than silently leaving the door open. The three postures: invite-only (default), approval-gated (`require_approval`), and domain-restricted+verified (`allowed_domains`+`require_verification`+`smtp`); `allow_signup`/`allowed_domains`/`admins` stay server-config-owned so a browser session can't widen access. A managed deployment can swap in a different provider (e.g. PropelAuth) without touching the CLI or API — keep provider-specific code out of this repo. The sync client picks up its token from `BDRIVE_TOKEN` or `settings.json` and sends `X-Bdrive-Device{,-Name,-Os}` headers (`remote/http.go`); the hub's file-backed device registry (`webapp/devices.go`) records per-device name/OS/account/server-observed IP. Journal ops carry the signed-in account (`Op.User`/`UserName` from `Session.Account`; `Author` remains the git/OS fallback). History (`webapp/history.go`): `GET /api/p/<id>/history?path=|prefix=` (newest first, device-registry join) and `GET /api/p/<id>/blob?sha=` stream any exact version — blobs are retained forever, so the future revert phase is just re-putting an old blob as a new op. Share links (`webapp/shares.go`, file-backed `shares.json`): any signed-in member mints `/s/<token>` public URLs (`bdrive share`, or the UI's Share button) serving the file's LATEST content until revoked (optional expiry); `/s/*` responses are sandboxed (CSP `sandbox allow-scripts`, no auth cookies) so shared HTML can't attack hub sessions — keep that header on any change; `/s/*` also sits behind a per-IP token bucket (`ratelimit.go`, `share_rpm` config), and markdown share pages get a "Shared with BearDrive" footer (raw HTML is never injected into).
Authentication (`webapp/auth.go`, `authlocal.go`, `mail.go`) is **mandatory in hub mode** — the config's `auth` block tunes `users_db`/`allow_signup`/`allowed_domains`/`require_verification`/`require_approval`/`admins`/`smtp`; the plain-folder viewer stays auth-free — and sits behind the `AuthProvider` interface — the OSS server ships only `BuiltinAuth` (email+password accounts and device tokens in a file-backed `auth.json`; bcrypt for passwords, SHA-256 digests for tokens, plaintext never stored; server-owned `/auth/*` pages; one-time codes for the CLI callback and device flows; SMTP reset mail with a log-link fallback). **Signup is invite-only by default** (`allow_signup` defaults false): a valid org invite bootstraps an account even when self-signup is closed — `BuiltinAuth.InviteValid` (wired to `OrgDB.ValidInvite`) lets `pageSignup`/`pageLogin` offer account creation for a `/join/<token>` target, and `signupInvited` skips the domain/verification/approval gates and activates immediately (the invite is the vetting). `BuiltinAuth.ValidateSignupPolicy` (called at hub startup, `web.go`) refuses an ungated open hub and email-verification-without-SMTP rather than silently leaving the door open. The three postures: invite-only (default), approval-gated (`require_approval`), and domain-restricted+verified (`allowed_domains`+`require_verification`+`smtp`); `allow_signup`/`allowed_domains`/`admins` stay server-config-owned so a browser session can't widen access. A managed deployment can swap in a different provider (e.g. PropelAuth) without touching the CLI or API — keep provider-specific code out of this repo. **Any provider must call the binder handed to `AuthProvider.UseDeviceBinder` at every point it mints a CLI token, and refuse the login when it errors**: `store.go:ownJournal` refuses a journal write unless the device id is bound to the caller's account, for every provider, and `DeviceRegistry.Bind` is the only thing that binds. That hook was a field on `BuiltinAuth` wired behind `if a, ok := s.Auth.(*BuiltinAuth); ok`, so a hub running a managed provider bound nothing and refused *every* push from *every* device forever while login, permissions and blob uploads all read healthy — it is on the interface now so a provider that ignores it does not compile. The hub cannot bind on the provider's behalf: binding must be reachable only from a completed authentication (a device token that could reach a bind would let a stolen credential squat a teammate's id), and `Authenticate` reports who a request is, never which credential class it presented. The sync client picks up its token from `BDRIVE_TOKEN` or `settings.json` and sends `X-Bdrive-Device{,-Name,-Os}` headers (`remote/http.go`); the hub's file-backed device registry (`webapp/devices.go`) records per-device name/OS/account/server-observed IP. Journal ops carry the signed-in account (`Op.User`/`UserName` from `Session.Account`; `Author` remains the git/OS fallback). History (`webapp/history.go`): `GET /api/p/<id>/history?path=|prefix=` (newest first, device-registry join) and `GET /api/p/<id>/blob?sha=` stream any exact version — blobs are retained forever, so the future revert phase is just re-putting an old blob as a new op. Share links (`webapp/shares.go`, file-backed `shares.json`): any signed-in member mints `/s/<token>` public URLs (`bdrive share`, or the UI's Share button) serving the file's LATEST content until revoked (optional expiry); `/s/*` responses are sandboxed (CSP `sandbox allow-scripts`, no auth cookies) so shared HTML can't attack hub sessions — keep that header on any change; `/s/*` also sits behind a per-IP token bucket (`ratelimit.go`, `share_rpm` config), and markdown share pages get a "Shared with BearDrive" footer (raw HTML is never injected into).
## Invariants — do not break these
+2
View File
@@ -142,7 +142,9 @@ classDiagram
+Authenticate(r) User
+Register(mux)
+Accounts() []User
+UseDeviceBinder(bind)
}
note for AuthProvider "UseDeviceBinder is a PRECONDITION of ownJournal, which refuses a journal write for every provider: the provider must call the hub's binder at every token mint. It was a field on BuiltinAuth wired behind a type assertion, so a managed provider bound nothing and every push 403'd forever. On the interface so a provider that ignores it does not compile. The hub cannot do it instead — Authenticate reports who a request is, never which credential class it used, and a device token must not reach a bind"
class AccountApprover {
<<interface>>
+PendingUsers() +Approve +Deny +SetPolicy +Policy
+28
View File
@@ -35,8 +35,36 @@ type AuthProvider interface {
// tasks (the org migration) need it, and both implementations already had
// it — declaring it here stops callers reaching for a concrete type.
Accounts() []User
// UseDeviceBinder hands the provider the hub's device-binding hook. The
// provider MUST call it at every point it mints a CLI token, before the
// token is handed over, and MUST refuse the login if it returns an error.
//
// This is on the interface — a breaking change for an out-of-tree provider,
// on purpose — because it is a PRECONDITION of a gate the hub enforces for
// every provider. store.go's ownJournal refuses a journal write unless the
// device id is bound to the caller's account, and DeviceRegistry.Bind is the
// only thing that binds. That hook used to be a field on BuiltinAuth, wired
// behind `if a, ok := s.Auth.(*BuiltinAuth); ok` — so on a hub running any
// other provider nothing ever called it, no device was ever bound, and EVERY
// journal push 403'd forever while login, permissions and blob uploads all
// looked healthy. A gate whose enabler is wired to one concrete type is a
// gate that is enforced further than it can be satisfied; declaring it here
// is what makes a provider that ignores it fail to compile instead.
//
// The hub cannot do this for the provider. Binding must be reachable only
// from a completed authentication — a device token that could reach a bind
// would let a stolen credential squat a teammate's id — and Authenticate
// reports only WHO a request is, never which credential class it presented.
// Only the provider knows it is minting, so only the provider can bind.
UseDeviceBinder(bind DeviceBinder)
}
// DeviceBinder records that the device identified by the request's
// X-Bdrive-Device header belongs to email. It returns an error when the id is
// already another account's, which a provider must surface as a failed login
// rather than a token that cannot push.
type DeviceBinder func(email string, r *http.Request) error
// AccountApprover is the optional half of account administration: signup
// policy and the approval queue behind /api/admin/*. A provider whose accounts
// live in an external identity system does not implement it, and those routes
+7 -2
View File
@@ -55,8 +55,8 @@ type BuiltinAuth struct {
// BindDevice, when set, records that a device id belongs to an account, at
// the moment a token is minted for it. It is the ONLY way an ownership row
// is created for an id that has never synced — see DeviceRegistry.Bind for
// why first-claim-on-write could not be. Wired to Server.bindDevice.
BindDevice func(email string, r *http.Request) error
// why first-claim-on-write could not be. Installed by UseDeviceBinder.
BindDevice DeviceBinder
store AccountRepo
ver versionGate // skips the re-read when the store has not moved
@@ -754,6 +754,11 @@ const sessionCookie = "bdrive_session"
func (a *BuiltinAuth) CLILoginPath() string { return "/auth/cli" }
// UseDeviceBinder installs the hub's binding hook. finishLogin — the one place
// this provider mints a CLI token, reached by all three flows — calls it before
// it issues anything.
func (a *BuiltinAuth) UseDeviceBinder(bind DeviceBinder) { a.BindDevice = bind }
func (a *BuiltinAuth) Authenticate(r *http.Request) (User, bool) {
if h := r.Header.Get("Authorization"); strings.HasPrefix(h, "Bearer ") {
return a.userForToken(strings.TrimPrefix(h, "Bearer "))
+218
View File
@@ -0,0 +1,218 @@
package webapp
import (
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"os/exec"
"path/filepath"
"strings"
"sync"
"testing"
"github.com/runbear-io/beardrive/internal/remote"
)
// The real binary, over real HTTP, against a hub whose AuthProvider is NOT
// BuiltinAuth — the shape every managed deployment has, and the one no test in
// this repo drove.
//
// That gap is why the outage shipped and survived a release. `ownJournal`
// refuses a journal write for every provider, but the binder that satisfies it
// was wired behind `if a, ok := s.Auth.(*BuiltinAuth); ok`, so on a managed hub
// nothing was ever bound and every push was refused forever. Every existing
// test used BuiltinAuth, where the wiring happened to work, and every in-process
// test of the gate asserted the refusal — which was still correct. Only a hub
// with a different provider, driven the whole way from `bdrive login` to a
// journal object in the store, tells the two apart.
//
// The two sub-tests differ in ONE thing: whether the provider calls the binder
// at mint. Everything else — the CLI, the flags, the files — is identical.
// cliprovAuth is a managed provider: it authenticates against its own notion of
// a session, mints its own CLI tokens, and the hub knows nothing about either.
// Modelled on the cloud's, including the mint point being the only place a
// binding could be made.
type cliprovAuth struct {
callBinder bool
user User
token string
mu sync.Mutex
bind DeviceBinder
}
func (a *cliprovAuth) CLILoginPath() string { return "/auth/cli" }
func (a *cliprovAuth) Accounts() []User { return []User{a.user} }
func (a *cliprovAuth) Authenticate(r *http.Request) (User, bool) {
if r.Header.Get("Authorization") != "Bearer "+a.token {
return User{}, false
}
return a.user, true
}
func (a *cliprovAuth) UseDeviceBinder(b DeviceBinder) {
a.mu.Lock()
defer a.mu.Unlock()
a.bind = b
}
// finishLogin is the mint point. The contract is: bind before the token goes
// out, and refuse the login if the bind is refused.
func (a *cliprovAuth) finishLogin(w http.ResponseWriter, r *http.Request) {
a.mu.Lock()
bind := a.bind
a.mu.Unlock()
if a.callBinder && bind != nil {
if err := bind(a.user.Email, r); err != nil {
http.Error(w, err.Error(), http.StatusConflict)
return
}
}
json.NewEncoder(w).Encode(map[string]any{"token": a.token, "user": a.user})
}
// Register mounts the CLI endpoints. The device-code flow approves itself on
// the first poll, so the whole run is headless — no browser, no cookie.
func (a *cliprovAuth) Register(mux *http.ServeMux) {
mux.HandleFunc("POST /api/auth/device/start", func(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode(map[string]any{
"code": "cliprov", "verify_url": "/auth/device/cliprov", "interval": 1,
})
})
mux.HandleFunc("POST /api/auth/device/poll", a.finishLogin)
mux.HandleFunc("POST /api/auth/exchange", a.finishLogin)
}
// startManagedHub is startTestHub with a managed provider, and with the syncing
// account as a plain org MEMBER of somebody else's project. That last part is
// load-bearing: ownJournal has an admin recovery arm, so a user who created the
// project pushes fine with no binding at all and the bug hides. The field report
// was a member on a project owned by someone else, which is what this is.
func startManagedHub(t *testing.T, callBinder bool) (*httptest.Server, string, string) {
t.Helper()
state := t.TempDir()
be, err := remote.Open(t.Context(), "file://"+filepath.Join(state, "storage"))
if err != nil {
t.Fatal(err)
}
db, err := OpenProjectDB(filepath.Join(state, "projects.json"))
if err != nil {
t.Fatal(err)
}
const member = "member@x.io"
srv := &Server{Root: be, Projects: db, Device: webDevice, Upload: UploadConfig{Enabled: true}}
srv.Devices, _ = OpenDeviceRegistry(filepath.Join(state, "devices.json"))
srv.Auth = &cliprovAuth{
callBinder: callBinder,
user: User{ID: "u-member", Email: member, Name: "Member"},
token: "bdt_cliprov_token",
}
orgs, err := OpenOrgDB(filepath.Join(state, "orgs.json"))
if err != nil {
t.Fatal(err)
}
org, err := orgs.Create("acme", "owner@x.io")
if err != nil {
t.Fatal(err)
}
if err := orgs.AddMember(org.ID, member, "member"); err != nil {
t.Fatal(err)
}
srv.Dir = LocalDirectory{OrgDB: orgs}
p, _, err := db.GetOrCreate("team", org.ID)
if err != nil {
t.Fatal(err)
}
ts := httptest.NewServer(srv.Handler())
t.Cleanup(ts.Close)
return ts, p.ID, filepath.Join(state, "storage", p.ID, "journal")
}
// cliprovRun signs the CLI in, connects it to the project, and syncs one file.
// It returns the `bdrive sync` output and the hub's journal directory.
func cliprovRun(t *testing.T, callBinder bool) (string, string) {
t.Helper()
if testing.Short() {
t.Skip("builds and execs the bdrive binary; skipped with -short")
}
bin := filepath.Join(t.TempDir(), "bdrive")
if out, err := exec.Command("go", "build", "-o", bin,
"github.com/runbear-io/beardrive/cmd/bdrive").CombinedOutput(); err != nil {
t.Fatalf("go build: %v\n%s", err, out)
}
hub, projectID, journalDir := startManagedHub(t, callBinder)
home := t.TempDir()
env := append(envWithout("HOME", "BDRIVE_HOME"),
"HOME="+home, "BDRIVE_HOME="+filepath.Join(home, ".bdrive"))
run := func(dir string, args ...string) (string, error) {
cmd := exec.Command(bin, args...)
cmd.Dir, cmd.Env = dir, env
out, err := cmd.CombinedOutput()
return string(out), err
}
// The device flow approves itself, so this is the whole sign-in.
if out, err := run(home, "login", "--device", hub.URL); err != nil {
t.Fatalf("login: %v\n%s", err, out)
}
work := t.TempDir()
if err := os.WriteFile(filepath.Join(work, "notes.md"), []byte("from the field report"), 0o644); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { run(work, "stop", work) }) // never leak the daemon
if out, err := run(work, "init", "--project", projectID, "--yes", "--no-autostart"); err != nil {
t.Fatalf("init: %v\n%s", err, out)
}
// The daemon init started keeps cycling; `sync` takes the same volume flock,
// so the two serialize rather than race. Not stopped first on purpose —
// `bdrive stop` PAUSES the mount, and a paused mount refuses to sync at all.
out, err := run(work, "sync", work)
if err != nil {
t.Fatalf("sync: %v\n%s", err, out)
}
return out, journalDir
}
// A managed provider that honours the contract: the device binds at mint and
// the CLI's journal reaches the hub.
func TestCLIManagedProviderDeviceCanPush(t *testing.T) {
out, journalDir := cliprovRun(t, true)
if strings.Contains(out, "read-only") {
t.Fatalf("a bound device was refused:\n%s", out)
}
entries, err := os.ReadDir(journalDir)
if err != nil || len(entries) == 0 {
t.Fatalf("no journal reached the hub (%v): the push did not land\n%s", err, out)
}
body, err := os.ReadFile(filepath.Join(journalDir, entries[0].Name()))
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(body), "notes.md") {
t.Fatalf("the hub's journal does not carry the edit: %s", body)
}
}
// And the outage itself, so the fix cannot silently regress into it: a provider
// that ignores the binder produces exactly the reported symptom — a `write`
// member whose blobs upload and whose journal is refused, forever.
func TestCLIManagedProviderThatIgnoresTheBinderIsRefused(t *testing.T) {
out, journalDir := cliprovRun(t, false)
if !strings.Contains(out, "read-only") {
t.Fatalf("expected the documented refusal, got:\n%s", out)
}
if !strings.Contains(out, "not registered to your account") {
t.Fatalf("the refusal did not say why — that sentence is the only thing that "+
"tells this apart from a genuine read-only grant:\n%s", out)
}
if entries, err := os.ReadDir(journalDir); err == nil && len(entries) > 0 {
t.Fatalf("an unbound device wrote a journal to the hub: %v", entries)
}
}
+52 -13
View File
@@ -179,12 +179,36 @@ func (r *DeviceRegistry) Observe(d DeviceInfo) {
d.ID = canonDeviceID(d.ID)
r.mu.Lock()
defer r.mu.Unlock()
r.observeLocked(d)
// Telemetry: a row that did not reach disk is logged and retried, never
// reported. Bind is the caller that cannot say that — see there.
_ = r.observeLocked(d)
}
// AnyOwned reports whether this hub holds a single device row with an account
// on it. It is a diagnostic, not a gate: a hub whose auth provider never calls
// the binder can never answer yes, and that is the one observable difference
// between "your device is not registered" (fix it by signing in) and "no device
// on this hub is registered, and none can be" (fix the provider). See
// Server.ownJournal, which turns a no into a line in the operator's log.
func (r *DeviceRegistry) AnyOwned() bool {
if r == nil {
return false
}
r.mu.Lock()
defer r.mu.Unlock()
r.refresh()
for k := range r.byKey {
if k.User != "" {
return true
}
}
return false
}
// observeLocked is Observe's body. Callers hold r.mu — Bind needs the claim
// check and the write to be one critical section.
func (r *DeviceRegistry) observeLocked(d DeviceInfo) {
// check and the write to be one critical section. It returns the store's error
// so a caller for whom persistence is the whole point can refuse.
func (r *DeviceRegistry) observeLocked(d DeviceInfo) error {
k := devKey{d.User, d.ID}
if d.User == "" {
// A caller claiming no account asserts no identity (auth-less hub, or
@@ -195,6 +219,11 @@ func (r *DeviceRegistry) observeLocked(d DeviceInfo) {
}
}
cur := r.byKey[k]
// No User term here, deliberately: k IS {d.User, d.ID}, so a row that
// exists under it was stored with that account and an absent one is caught
// by cur.ID == "". A binding therefore always counts as a change and always
// reaches the store — checked with a panic probe across the whole suite
// before this comment replaced the clause that said otherwise.
changed := cur.ID == "" || cur.Name != d.Name || cur.OS != d.OS || cur.IP != d.IP
if cur.FirstSeen.IsZero() {
cur.FirstSeen = time.Now().UTC()
@@ -216,17 +245,20 @@ func (r *DeviceRegistry) observeLocked(d DeviceInfo) {
r.byKey[k] = cur
r.latest[d.ID] = k
if changed || time.Since(r.lastSav[k]) > time.Minute {
if err := r.repo.Put(cur); err == nil {
r.lastSav[k] = time.Now()
} else if !r.warned {
// Silently discarded, this made a registry that reports a device
// as observed while nothing about it ever reaches disk. Telemetry
// still must not fail the request, so it logs once and the next
// observation retries.
r.warned = true
log.Printf("beardrive: device registry write failed (will retry): %v", err)
if err := r.repo.Put(cur); err != nil {
if !r.warned {
// Silently discarded, this made a registry that reports a device
// as observed while nothing about it ever reaches disk. Telemetry
// still must not fail the request, so it logs once and the next
// observation retries.
r.warned = true
log.Printf("beardrive: device registry write failed (will retry): %v", err)
}
return err
}
r.lastSav[k] = time.Now()
}
return nil
}
// Get returns the most recently observed row for an id, whoever owns it. It is
@@ -385,7 +417,14 @@ func (r *DeviceRegistry) Bind(user string, d DeviceInfo, visible func(owner stri
"delete device.json in your BearDrive home and sign in again to mint a new one", d.ID)
}
d.User = user
r.observeLocked(d)
// A binding that did not reach the store is not a binding. Observe may log
// and retry — its next call carries the same facts — but this row is the
// only evidence that will ever exist for this id, and reporting a claim the
// store refused hands back a token whose every push is then refused, with
// nothing in the hub to explain why.
if err := r.observeLocked(d); err != nil {
return fmt.Errorf("could not record this device on the hub, so its changes could not be pushed: %w", err)
}
return nil
}
+6
View File
@@ -535,6 +535,12 @@ func (a secapiStubAuth) Authenticate(*http.Request) (User, bool) { return a.user
func (a secapiStubAuth) Register(*http.ServeMux) {}
func (a secapiStubAuth) Accounts() []User { return nil }
// This test is about the identity a provider hands back, not about devices —
// but a provider that drops the binder is the whole subject of
// sec_provider_test.go, so the no-op is deliberate and belongs to this fixture
// only.
func (a secapiStubAuth) UseDeviceBinder(DeviceBinder) {}
// Every authorization decision on the hub is keyed on the email an
// AuthProvider hands back. BuiltinAuth happens to guarantee a non-empty,
// lowercased, unique address; the interface promises none of that, and a
+208
View File
@@ -0,0 +1,208 @@
package webapp
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
)
// ---------------------------------------------------------------------------
// The gate and its enabler have to have the same reach.
//
// `ownJournal` refuses a journal write unless DeviceRegistry.OwnerOf says the
// id belongs to the caller, for EVERY provider — it asks only whether
// s.Devices is nil. The only thing that ever creates that ownership row is
// DeviceRegistry.Bind, and its only caller is BuiltinAuth.finishLogin, which
// used to be wired behind `if a, ok := s.Auth.(*BuiltinAuth); ok`.
//
// So on a hub running a managed provider — the deployment the AuthProvider seam
// exists FOR — nothing ever bound a device, and every journal push from every
// device was refused forever. Everything around it looked healthy, which is why
// it took two days and a device-id rotation to find: /api/auth/me answered,
// project permissions read `write`, blobs (content-addressed, ownerless)
// uploaded fine, and only the journal PUT died. Rotating to a fresh random id
// reproduced it identically, which is what ruled out any stale row and pointed
// here.
// ---------------------------------------------------------------------------
// secprovAuth is a managed deployment's provider: it authenticates against its
// own identity system and mints its own tokens, and the hub knows nothing about
// either. What it must still do is bind the device at the moment it mints —
// which it can only do if the hub hands it the binder.
type secprovAuth struct {
user User
bind DeviceBinder // captured from UseDeviceBinder; nil means the hub never offered one
}
func (a *secprovAuth) CLILoginPath() string { return "/auth/login" }
func (a *secprovAuth) Authenticate(*http.Request) (User, bool) { return a.user, a.user.Email != "" }
func (a *secprovAuth) Register(*http.ServeMux) {}
func (a *secprovAuth) Accounts() []User { return []User{a.user} }
func (a *secprovAuth) UseDeviceBinder(b DeviceBinder) { a.bind = b }
// mint is the provider's token-mint moment, standing in for the handler a
// managed provider serves at /api/auth/exchange. The contract is that it calls
// the hub's binder before handing the token over.
func (a *secprovAuth) mint(t *testing.T, dev string) error {
t.Helper()
if a.bind == nil {
t.Fatal("the hub never handed this provider a device binder: nothing it does at mint " +
"can bind a device, so ownJournal will refuse every push on this hub forever")
}
r := httptest.NewRequest("POST", "/api/auth/exchange", nil)
r.Header.Set("X-Bdrive-Device", dev)
r.Header.Set("X-Bdrive-Device-Name", "machine-"+dev)
r.Header.Set("X-Bdrive-Os", "linux/amd64")
return a.bind(a.user.Email, r)
}
// secprovHub is permHub re-served behind a managed provider: the accounts, org
// and project are real (created through BuiltinAuth), and then the hub is
// rebuilt with a provider it does not own — which is the shape of every
// managed deployment.
func secprovHub(t *testing.T) (http.Handler, *Server, *secprovAuth, Project) {
t.Helper()
_, srv, _, p := permHub(t)
auth := &secprovAuth{user: User{ID: "u-bob", Email: "bob@x.io", Name: "Bob"}}
srv.Auth = auth
return srv.Handler(), srv, auth, p
}
func secprovPush(t *testing.T, h http.Handler, project, dev string) *httptest.ResponseRecorder {
t.Helper()
body := secaudOpLine(1, dev, "put", "notes.md", strings.Repeat("a", 64))
return secfx4Store(t, h, "PUT",
"/api/p/"+project+"/store/object?key=journal/"+dev+".jsonl", body, nil, dev)
}
// The regression itself: a provider that is not BuiltinAuth must be able to
// bind a device, and its devices must then be able to push.
func TestSec_Device_AManagedProviderCanBindAndItsDevicesCanPush(t *testing.T) {
h, srv, auth, p := secprovHub(t)
const dev = "ce898b5e82bf"
// Before the binding, the gate is on and refusing — that half always worked.
if rec := secprovPush(t, h, p.ID, dev); rec.Code != http.StatusForbidden {
t.Fatalf("an unbound device pushed a journal: %d %s", rec.Code, rec.Body)
}
// The hub must have handed this provider the binder. This is the assertion
// that fails on the old tree: the wiring was behind a type assertion on
// *BuiltinAuth, so a managed provider got nil and no login it served could
// ever bind anything.
if auth.bind == nil {
t.Fatal("Server.Handler did not hand the provider a device binder — every push on a hub " +
"with a managed AuthProvider is refused forever, however often anyone signs in")
}
// AnyOwned is what tells "your device is not registered" from "no device on
// this hub is registered, and none can be" — the difference between a user
// who should sign in and an operator who must fix their provider. It is the
// discriminator behind the log line ownJournal emits, so it has to actually
// discriminate.
if srv.Devices.AnyOwned() {
t.Fatal("AnyOwned() is true on a hub that has bound nothing")
}
// The provider mints a token and binds, as the contract requires.
if err := auth.mint(t, dev); err != nil {
t.Fatalf("binding a fresh id at mint: %v", err)
}
if !srv.Devices.AnyOwned() {
t.Fatal("AnyOwned() is still false after a successful bind — the hub would keep " +
"telling its operator the provider is broken after it was fixed")
}
if owner, known := srv.Devices.OwnerOf(dev); !known || owner != "bob@x.io" {
t.Fatalf("OwnerOf(%s) = %q,%v after the provider bound it", dev, owner, known)
}
// And the push that was refused now lands.
if rec := secprovPush(t, h, p.ID, dev); rec.Code != http.StatusOK {
t.Fatalf("a bound device still cannot push its own journal: %d %s", rec.Code, rec.Body)
}
}
// The binding a managed provider makes is the same binding BuiltinAuth makes,
// conflict rules included: it does not become a way to take an id that is
// already somebody else's just because the provider is external.
func TestSec_Device_AManagedProviderCannotBindSomebodyElsesId(t *testing.T) {
h, srv, auth, p := secprovHub(t)
const dev = "aa11bb22cc33"
if err := auth.mint(t, dev); err != nil {
t.Fatalf("bob's own bind: %v", err)
}
if rec := secprovPush(t, h, p.ID, dev); rec.Code != http.StatusOK {
t.Fatalf("bob cannot push after binding: %d %s", rec.Code, rec.Body)
}
// carol signs in on the same hub and names bob's id.
auth.user = User{ID: "u-carol", Email: "carol@x.io", Name: "Carol"}
if err := auth.mint(t, dev); err == nil {
t.Fatal("a second account bound an id already registered to somebody else")
}
if owner, _ := srv.Devices.OwnerOf(dev); owner != "bob@x.io" {
t.Fatalf("OwnerOf(%s) = %q — carol's refused bind moved the claim", dev, owner)
}
// And she cannot write his journal.
if rec := secprovPush(t, h, p.ID, dev); rec.Code != http.StatusForbidden {
t.Fatalf("carol wrote bob's journal: %d %s", rec.Code, rec.Body)
}
}
// A device token still cannot reach a bind. The fix moved WHERE the binder is
// wired (every provider, not just BuiltinAuth); it did not add a door that a
// sync credential can push on, which is the property round 10 recorded when the
// CISO declined an automatic self-heal.
func TestSec_Device_TheProviderSeamAddsNoBindingDoorForSyncTraffic(t *testing.T) {
h, _, auth, p := secprovHub(t)
const unowned = "never-bound-9f21"
// Every /store door, under a fully authenticated identity, naming an id
// nothing has bound. None of them may create the row.
base := "/api/p/" + p.ID + "/store/"
for _, probe := range []struct{ method, target string }{
{"GET", base + "list?prefix=journal/"},
{"GET", base + "object?key=journal/" + unowned + ".jsonl"},
{"GET", base + "exists?key=journal/" + unowned + ".jsonl"},
{"POST", base + "sign?key=journal/" + unowned + ".jsonl&size=1"},
} {
secfx4Store(t, h, probe.method, probe.target, "", nil, unowned)
}
if rec := secprovPush(t, h, p.ID, unowned); rec.Code != http.StatusForbidden {
t.Errorf("an unowned id became writable through the store API: %d %s", rec.Code, rec.Body)
}
if auth.bind == nil {
t.Fatal("no binder handed to the provider")
}
// Nothing in that traffic bound anything: only the provider's mint can.
h2, srv2, _, _ := secprovHub(t)
_ = h2
if owner, known := srv2.Devices.OwnerOf(unowned); known || owner != "" {
t.Errorf("OwnerOf(%s) = %q,%v — sync traffic created an ownership row", unowned, owner, known)
}
}
// A hub that cannot persist a device row must not report the binding as done:
// the login would hand back a token whose every push is then refused, with
// nothing anywhere saying why.
func TestSec_Device_ABindingThatDidNotReachTheStoreIsNotReported(t *testing.T) {
_, srv, auth, _ := secprovHub(t)
srv.Devices.repo = secprovDeadRepo{srv.Devices.repo}
if err := auth.mint(t, "dd44ee55ff66"); err == nil {
t.Fatal("Bind reported success while the store refused the row — the account gets a token " +
"it cannot push with, and the claim vanishes at the next restart")
}
}
// secprovDeadRepo accepts reads and refuses every write.
type secprovDeadRepo struct{ DeviceRepo }
func (secprovDeadRepo) Put(DeviceInfo) error { return errSecprovDead }
var errSecprovDead = &secprovErr{}
type secprovErr struct{}
func (*secprovErr) Error() string { return "disk is full" }
+13 -3
View File
@@ -120,7 +120,11 @@ type Server struct {
// reachable hub lets any client pick its own rate-limit bucket.
TrustProxy bool
xffWarnOnce sync.Once
xffWarnOnce sync.Once
// unboundOnce logs, at most once, that this hub refused a journal write
// while holding no device binding at all — the signature of a provider that
// never calls the binder. See ownJournal.
unboundOnce sync.Once
shareLimOnce sync.Once
shareLim *rateLimiter
authLimOnce sync.Once
@@ -668,8 +672,14 @@ func (s *Server) Handler() http.Handler {
// A device identity is bound to an account when its token is minted, and
// nowhere else. Wired here rather than at startup because the fixtures (and
// a hub rebuilt from its repos) assemble Auth and Devices independently.
if a, ok := s.Auth.(*BuiltinAuth); ok && a.BindDevice == nil {
a.BindDevice = s.bindDevice
//
// EVERY provider, not `if a, ok := s.Auth.(*BuiltinAuth); ok`. ownJournal
// refuses a journal write for any provider, and the type assertion meant a
// hub running a managed provider enforced a gate nothing could ever satisfy:
// no device bound, every push 403 forever, while login and permissions read
// perfectly healthy. See AuthProvider.UseDeviceBinder.
if s.Auth != nil {
s.Auth.UseDeviceBinder(s.bindDevice)
}
// Volume resolution per route family: fixed single volume, or by
+15
View File
@@ -133,6 +133,21 @@ func (s *Server) ownJournal(w http.ResponseWriter, r *http.Request, key string)
// machine's token (DeviceRegistry.Bind), which is a moment the hub
// authenticates and the machine cannot forge. So an unowned id is
// simply not anybody's to write, and the remedy is to sign in.
// A hub that has never bound ANY device is not refusing this
// caller — it is refusing everyone, and no amount of signing in
// will change it, because its auth provider is not calling the
// binder (AuthProvider.UseDeviceBinder). The user-facing text
// cannot say that: a brand-new hub also holds no bindings, and
// telling its first user the server is broken would be wrong. The
// operator is the one who can act, so this goes to the log, once.
if s.Devices != nil && !s.Devices.AnyOwned() {
s.unboundOnce.Do(func() {
log.Printf("beardrive: refused a journal write and NO device is registered on this hub — " +
"if this hub uses a custom AuthProvider, it must call the binder handed to " +
"UseDeviceBinder at every point it mints a CLI token, or every push from every " +
"device will be refused no matter how often anyone signs in")
})
}
// "run `bdrive login`" alone sent one user in a circle for an
// afternoon: the binding is made by the login request naming its
// device, which a CLI older than this gate does not do, so signing in