diff --git a/CLAUDE.md b/CLAUDE.md index d75904c..9e6042a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 ` 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 ` 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/` 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//history?path=|prefix=` (newest first, device-registry join) and `GET /api/p//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/` 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/` 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//history?path=|prefix=` (newest first, device-registry join) and `GET /api/p//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/` 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 diff --git a/architecture/webapp-server.md b/architecture/webapp-server.md index c7b28d7..a020e69 100644 --- a/architecture/webapp-server.md +++ b/architecture/webapp-server.md @@ -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 { <> +PendingUsers() +Approve +Deny +SetPolicy +Policy diff --git a/internal/webapp/auth.go b/internal/webapp/auth.go index 26931a4..463721f 100644 --- a/internal/webapp/auth.go +++ b/internal/webapp/auth.go @@ -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 diff --git a/internal/webapp/authlocal.go b/internal/webapp/authlocal.go index 4316a90..e0f9cd9 100644 --- a/internal/webapp/authlocal.go +++ b/internal/webapp/authlocal.go @@ -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 ")) diff --git a/internal/webapp/cli_provider_e2e_test.go b/internal/webapp/cli_provider_e2e_test.go new file mode 100644 index 0000000..072a025 --- /dev/null +++ b/internal/webapp/cli_provider_e2e_test.go @@ -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) + } +} diff --git a/internal/webapp/devices.go b/internal/webapp/devices.go index a110c30..9a9968a 100644 --- a/internal/webapp/devices.go +++ b/internal/webapp/devices.go @@ -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 } diff --git a/internal/webapp/sec_api_test.go b/internal/webapp/sec_api_test.go index 79e83a3..18a3d4e 100644 --- a/internal/webapp/sec_api_test.go +++ b/internal/webapp/sec_api_test.go @@ -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 diff --git a/internal/webapp/sec_provider_test.go b/internal/webapp/sec_provider_test.go new file mode 100644 index 0000000..3d6e506 --- /dev/null +++ b/internal/webapp/sec_provider_test.go @@ -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" } diff --git a/internal/webapp/server.go b/internal/webapp/server.go index 885bf4c..f62f0fa 100644 --- a/internal/webapp/server.go +++ b/internal/webapp/server.go @@ -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 diff --git a/internal/webapp/store.go b/internal/webapp/store.go index 5c8bfbb..c6b4e25 100644 --- a/internal/webapp/store.go +++ b/internal/webapp/store.go @@ -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