mirror of
https://github.com/runbear-io/beardrive.git
synced 2026-08-25 08:08:08 +02:00
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>
209 lines
9.1 KiB
Go
209 lines
9.1 KiB
Go
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" }
|