Files
beardrive/internal/webapp/devices.go
T
94091a8795 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>
2026-08-11 08:23:34 -07:00

693 lines
27 KiB
Go

package webapp
import (
"fmt"
"log"
"net"
"net/http"
"regexp"
"strings"
"sync"
"time"
)
// DeviceInfo is what the server knows about one syncing device: self-reported
// name/OS (headers sent by the client), plus what the server itself observed
// (public IP of the last push, last activity, the signed-in account). History
// joins ops against this registry so ops stay small — but it reports only
// id/name/os (historyDevice, history.go): the IP is recorded here, not
// repeated to every project member on every change.
type DeviceInfo struct {
ID string `json:"id"`
Name string `json:"name,omitempty"`
OS string `json:"os,omitempty"`
User string `json:"user,omitempty"` // account email last seen using this device
IP string `json:"ip,omitempty"` // as observed by the server
// FirstSeen is when this account was first observed syncing under this id.
// It is the only ownership fact the hub actually holds: a row is created by
// the caller's own header, so "does this account have a row" is a question
// the asking request already answered for itself, while "who was here
// first" is something a later caller cannot manufacture.
FirstSeen time.Time `json:"first_seen,omitzero"`
LastSeen time.Time `json:"last_seen"`
}
// deviceIDRe is the shape of a device identity. `bdrive` mints 12 hex chars,
// but the id is also a storage key component — journal/<id>.jsonl, the same
// set store.go's journalKeyRe accepts — so anything outside it cannot be a
// real syncing device. Enforcing it here matters because the id doubles as
// the read-heat actor column: free text would let a member hand /heat an
// account email (or any sentence they like) and have the hub serve it back to
// the whole project as a reader.
// One definition, two uses: store.go's journalKeyRe builds the journal key
// shape from it. They drifted — the key had no length cap while an id capped
// at 64 — so a journal key existed that no device row could ever own, the
// ownership gate could never engage on it, and History echoed the 200-char
// string to every member as the device that made each change.
const deviceIDPattern = `[A-Za-z0-9._-]{1,64}`
var deviceIDRe = regexp.MustCompile(`^` + deviceIDPattern + `$`)
func validDeviceID(id string) bool { return deviceIDRe.MatchString(id) }
// canonDeviceID folds a client-asserted device id to the one spelling the hub
// reasons about. Every ownership decision here used to be a byte compare
// (Bind, OwnerOf, ownJournal), so "DEVA9F21" was an id nobody owned while
// "deva9f21" belonged to somebody — and the object store underneath disagreed:
// a `file://` store on APFS or NTFS (macOS and Windows defaults, and what the
// quickstart self-hoster gets) writes journal/DEVA9F21.jsonl and
// journal/deva9f21.jsonl to ONE file. One login and one PUT and a plain member
// had replaced a peer's journal — the one-writer invariant the whole
// concurrency design rests on, broken with ordinary permissions.
//
// Folding here rather than at each comparison is the point: a comparison that
// has to remember to be case-insensitive is a comparison that will be written
// exact-case next time. `bdrive` mints lowercase hex (config.NewDeviceID), so
// the canonical spelling is what every real device already sends.
func canonDeviceID(id string) string { return strings.ToLower(id) }
// deviceID is the single door a device id enters the hub through. Nothing else
// reads X-Bdrive-Device.
func deviceID(r *http.Request) string { return canonDeviceID(r.Header.Get("X-Bdrive-Device")) }
// devKey is the registry's identity: a device id is client-asserted, so it is
// only meaningful inside the account that presented it. Two accounts naming
// the same id hold two separate rows and cannot overwrite or lock out each
// other — which is what makes "first caller owns it" unnecessary.
type devKey struct{ User, ID string }
// DeviceRegistry is the in-memory device table over a MetaStore DeviceRepo.
type DeviceRegistry struct {
repo DeviceRepo
mu sync.Mutex
ver versionGate // skips the re-read when the store has not moved
byKey map[devKey]DeviceInfo
latest map[string]devKey // id → most recently observed row, for display joins
// lastSav throttles disk writes per row.
lastSav map[devKey]time.Time
warned bool // repo write failure logged once
warnedR bool // repo re-read failure logged once (see refresh)
}
// NewDeviceRegistry builds the registry over a repo, loading its contents.
func NewDeviceRegistry(repo DeviceRepo) (*DeviceRegistry, error) {
r := &DeviceRegistry{
repo: repo,
byKey: make(map[devKey]DeviceInfo),
latest: make(map[string]devKey),
lastSav: make(map[devKey]time.Time),
}
list, err := repo.Load()
if err != nil {
return nil, err
}
for _, d := range list {
// Rows written before ids were canonical fold on the way in, so an
// established device keeps its claim under the one spelling.
d.ID = canonDeviceID(d.ID)
k := devKey{d.User, d.ID}
r.byKey[k] = d
r.latest[d.ID] = k
}
return r, nil
}
// OpenDeviceRegistry loads the file-backed registry at path.
func OpenDeviceRegistry(path string) (*DeviceRegistry, error) {
return NewDeviceRegistry(newFileDeviceRepo(path))
}
// refresh re-reads the registry from the store before a decision is made out of
// it. Callers hold r.mu.
//
// Round 13 recorded DeviceRegistry as not-applicable to the second-process class
// on the strength of the BIND-AWAY direction alone; the RELEASE direction was
// never driven. byKey was loaded at open, so offboarding released a device claim
// on whichever process served the deletion and on no other: the next hire's
// login binds nothing and every push she makes 403s, and — the authorization
// direction — a re-created address inherits the departed account's row and with
// it write access to that device's journal on every project on the hub.
//
// A store that cannot answer leaves the current map in place: the previous
// answer is the last one the store agreed to, and dropping the whole registry on
// a transient read error would unclaim every device at once.
//
// Gated on the store's change token (Versioned) like ProjectDB.refresh.
//
// ponytail: Observe deliberately does NOT refresh at all — it is the
// per-request hot path, it only merges the caller's own row, and every gate
// above it (OwnerOf, MayActAs) refreshes before deciding.
func (r *DeviceRegistry) refresh() {
token, stale := r.ver.stale(r.repo)
if !stale {
return
}
list, err := r.repo.Load()
if err != nil {
if !r.warnedR {
r.warnedR = true
log.Printf("beardrive: device registry re-read failed, serving the last known claims: %v", err)
}
return
}
r.warnedR = false
r.ver.fresh(token)
byKey := make(map[devKey]DeviceInfo, len(list))
latest := make(map[string]devKey, len(list))
for _, d := range list {
d.ID = canonDeviceID(d.ID)
k := devKey{d.User, d.ID}
byKey[k] = d
latest[d.ID] = k
}
r.byKey, r.latest = byKey, latest
}
// Observe merges what a request revealed about a device, into the row owned by
// the account that made it. Disk writes are throttled: identity changes
// persist immediately, bare last-seen bumps at most once a minute.
//
// The repo keeps one row per id (its primary key), so what survives a restart
// is the most recent observation. That is a display cache, not the authority:
// MayActAs reads the in-memory rows, and a device re-registers on its very
// next sync cycle.
func (r *DeviceRegistry) Observe(d DeviceInfo) {
if r == nil || d.ID == "" {
return
}
d.ID = canonDeviceID(d.ID)
r.mu.Lock()
defer r.mu.Unlock()
// 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. 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
// a partial refresh): it merges into whatever row already exists for
// this id rather than opening a competing, ownerless one.
if prev, ok := r.latest[d.ID]; ok {
k = prev
}
}
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()
}
cur.ID = d.ID
if d.Name != "" {
cur.Name = d.Name
}
if d.OS != "" {
cur.OS = d.OS
}
if d.User != "" {
cur.User = d.User
}
if d.IP != "" {
cur.IP = d.IP
}
cur.LastSeen = time.Now().UTC()
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 {
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
// an unscoped display lookup: anything that serves the result to a project
// must use LookupIn instead, or it hands one org's device metadata to another.
func (r *DeviceRegistry) Get(id string) (DeviceInfo, bool) {
if r == nil {
return DeviceInfo{}, false
}
r.mu.Lock()
defer r.mu.Unlock()
r.refresh()
k, ok := r.latest[canonDeviceID(id)]
if !ok {
return DeviceInfo{}, false
}
d, ok := r.byKey[k]
return d, ok
}
// LookupIn returns the row for an id whose owner passes allowed — the join
// every per-project surface uses, so a device belonging to an account outside
// the project's org resolves to nothing instead of leaking its machine name
// and OS (and confirming that the id exists at all).
//
// When several accounts hold rows for one id, the FIRST claim wins. Picking
// the most recently observed row instead meant the second account to name an
// id decided what the whole project sees: one ordinary store request relabels
// a peer's device in History and in /heat?by=device, which is the forgery the
// per-account rekey was supposed to end.
func (r *DeviceRegistry) LookupIn(id string, allowed func(user string) bool) (DeviceInfo, bool) {
if r == nil {
return DeviceInfo{}, false
}
r.mu.Lock()
defer r.mu.Unlock()
r.refresh()
id = canonDeviceID(id)
var best DeviceInfo
found := false
for k, d := range r.byKey {
if k.ID != id || !allowed(k.User) {
continue
}
if !found || claimedBefore(d, best) {
best, found = d, true
}
}
return best, found
}
// claimedBefore orders two rows for the same id by when their account first
// appeared under it. A zero FirstSeen is a row written before the field
// existed, and sorts oldest — the safe direction: an established device keeps
// its identity against a newcomer.
func claimedBefore(a, b DeviceInfo) bool {
if a.FirstSeen.IsZero() != b.FirstSeen.IsZero() {
return a.FirstSeen.IsZero()
}
return a.FirstSeen.Before(b.FirstSeen)
}
// OwnerOf resolves who a device id belongs to, hub-wide: the account whose row
// for it was created first. It is the WRITE gate's resolver (ownJournal), and
// it differs from LookupIn — the display join — in the two ways that made
// round 4's binding fail:
//
// - it is not scoped to an org. A claim that disappears when its owner is
// offboarded hands her journal to whoever is left in the org, and History
// keeps crediting her.
// - a row with no owner (a pre-accounts devices.json, or an auth-less
// observation) claims nothing. Round 4 read "no owner" as "no objection",
// so on every upgraded hub the binding was off for exactly the established
// devices.
//
// It returns the owning account and whether the hub has ever seen the id at
// all. The two differ, and the difference is a hole round 4 fell into: a row
// with no owner means a device exists whose account is unknown (known=true,
// owner=""), which is a reason to refuse, not to wave through.
func (r *DeviceRegistry) OwnerOf(id string) (owner string, known bool) {
if r == nil {
return "", false
}
r.mu.Lock()
defer r.mu.Unlock()
r.refresh()
id = canonDeviceID(id)
var best DeviceInfo
found := false
for k, d := range r.byKey {
if k.ID != id {
continue
}
known = true
if k.User == "" {
continue
}
if !found || claimedBefore(d, best) {
best, found = d, true
}
}
return best.User, known
}
// Bind records that a device id belongs to an account, and is the ONLY way an
// ownership row comes into existence for an id that has never synced. It is
// called at token issuance (`bdrive login`, the device-code flow, and the login
// `bdrive init` runs inside itself), where the hub has just authenticated the
// account and the machine is the one asking.
//
// This exists because the alternative was a race nobody could win. Ownership
// used to be minted by the first authorized journal PUT, admitted through
// `!known && journalNames(dev, ops)` — a check that reads a field the writer
// itself writes. A device that syncs with READ permission can never reach that
// door, so its id stayed unclaimed hub-wide forever and the first member with
// write on any project took it: permanently, with the victim's ops attributed
// to her device in History, and no remedy but abandoning device.json. Two
// hacker rounds found it from opposite sides and their tests were mutually
// unsatisfiable while first-claim-on-write was the only way a binding existed.
//
// Claim-or-refuse is one critical section on purpose: two logins racing for one
// id must not both believe they won.
//
// visible reports whether the conflicting owner is somebody the caller can
// already see on this hub, and it decides which of THREE outcomes a conflict
// gets. OwnerOf is deliberately hub-wide, so turning its answer into a status
// code makes the login a hub-wide device-existence oracle — the class round 3
// closed for History, round 4 for the registry join and round 5 for
// /store/sign, arriving at a fourth door. So:
//
// - no conflicting row: bind.
// - conflict with an owner the caller can already see (same org): refuse, in
// words. Nothing is disclosed that the org's own surfaces do not disclose,
// and the machine learns why its sign-in did not take.
// - conflict with an owner the caller cannot see: bind NOTHING and succeed.
// The token is real, no ownership row is created either way, and the push
// door already answers "owned by someone else" and "owned by nobody" with
// the same 403 — so this loses no defence and answers no question.
func (r *DeviceRegistry) Bind(user string, d DeviceInfo, visible func(owner string) bool) error {
if r == nil || user == "" || d.ID == "" {
return nil // no registry, or nothing asserted: nothing to bind
}
r.mu.Lock()
defer r.mu.Unlock()
r.refresh()
d.ID = canonDeviceID(d.ID)
me := normEmail(user)
for k := range r.byKey {
if k.ID != d.ID || k.User == "" || normEmail(k.User) == me {
continue
}
if visible != nil && !visible(k.User) {
return nil // binds nothing, tells the caller nothing
}
return fmt.Errorf("device id %s is already registered to another account on this hub; "+
"delete device.json in your BearDrive home and sign in again to mint a new one", d.ID)
}
d.User = user
// 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
}
// Release drops every device row an account owns, in memory and on disk. It is
// the device half of offboarding, and it is not bookkeeping: OwnerOf is the
// WRITE gate (store.go ownJournal) and Bind refuses an id another account
// claims, so a claim that outlives its account is a claim nobody can ever
// dislodge.
//
// The offboarding scenario IS "the laptop goes to the next hire", and without
// this that hire is permanently locked out in the least debuggable way: the
// departed account no longer shares an org with anybody, so Bind's
// invisible-conflict arm binds NOTHING and lets the login SUCCEED — every later
// push then 403s telling the user to run `bdrive login`, which is what she just
// did. The mirror case is worse: re-create the address and the new account
// silently inherits the departed employee's device row and write access to that
// device's journal.
//
// A row the store refuses to delete stays in memory. Reporting a release that
// isn't one is the widening direction — the claim comes back at the next
// restart while the hub has already handed the id to somebody else.
func (r *DeviceRegistry) Release(user string) {
if r == nil {
return
}
e := normEmail(user)
if e == "" {
return
}
r.mu.Lock()
defer r.mu.Unlock()
r.refresh()
for k := range r.byKey {
if normEmail(k.User) != e {
continue
}
if err := r.repo.Delete(k.User, k.ID); err != nil {
log.Printf("beardrive: offboard %s: device %s NOT released, the id keeps its claim: %v", e, k.ID, err)
continue
}
delete(r.byKey, k)
delete(r.lastSav, k)
if r.latest[k.ID] == k {
// The display join has to point at a row that still exists. Pick the
// most recently observed survivor for the id, if any.
delete(r.latest, k.ID)
for k2, d2 := range r.byKey {
if k2.ID != k.ID {
continue
}
if best, ok := r.latest[k.ID]; !ok || r.byKey[best].LastSeen.Before(d2.LastSeen) {
r.latest[k.ID] = k2
}
}
}
}
}
// MayActAs reports whether an account may name a device id as itself: yes if
// this account has been seen syncing under it, and yes if nobody else has —
// an id nobody has claimed is the ordinary case of a device whose telemetry
// arrives before its first push, and refusing it would mean read heat never
// starts. What it refuses is naming a device another account is syncing.
//
// This is the permissive question, and it is the right one for telemetry: a
// squatted id must still count its real owner's reads. Anything that WRITES on
// the strength of a device identity asks OwnerOf instead.
func (r *DeviceRegistry) MayActAs(user, id string) bool {
if r == nil {
return true
}
r.mu.Lock()
defer r.mu.Unlock()
r.refresh()
id = canonDeviceID(id)
if _, mine := r.byKey[devKey{user, id}]; mine {
return true
}
for k := range r.byKey {
if k.ID == id && k.User != "" && k.User != user {
return false
}
}
return true
}
// ownsDevice reports whether the caller may act AS the device id it named.
//
// Two things have to hold. The id must be a device id at all (validDeviceID):
// it ends up in /heat as an actor, and free text there is an identity the
// caller invented. And no other account may already be syncing under it — the
// registry is populated by /store/* traffic, which is the only thing that
// proves a device exists, and this route never registers anything itself.
// Observing here is what made the round-2 check a one-request speed bump: the
// request it refused registered the refused id to the caller, so the second
// one passed.
//
// A hub with no registry cannot judge, and has no accounts to impersonate
// either (single-volume / auth-less), so it allows.
func (s *Server) ownsDevice(r *http.Request, id string) bool {
if !validDeviceID(id) {
return false
}
if s.Devices == nil {
return true
}
return s.Devices.MayActAs(s.requestUser(r).Email, id)
}
// refreshDevice records traffic from a device the caller's account ALREADY
// owns, and records nothing at all otherwise.
//
// Creating the first row for an id is a CLAIM: OwnerOf is hub-wide and
// first-claim-wins, and it is what ownJournal consults. So a door that grants
// nothing must claim nothing — otherwise one GET naming an id nobody has
// synced under yet locks that device out of its own journal on every project
// on the hub, which ownJournal's own refusal calls a permanent lockout. Only
// an authorized journal write may claim (handleStorePut, after ownJournal).
func (s *Server) refreshDevice(r *http.Request) {
if s.Devices == nil {
return
}
me := normEmail(s.requestUser(r).Email)
owner, _ := s.Devices.OwnerOf(deviceID(r))
switch {
case me == "":
// The caller asserts no account at all — an auth-less hub, which is the
// only configuration that reaches a write door without one. The row
// this creates carries no account, and an ownerless row claims nothing
// (OwnerOf skips it), so there is nothing here to impersonate.
case normEmail(owner) == me:
// My device, refreshing its own row.
default:
// Somebody else's device id, or nobody's. Creating the row here is what
// let ownJournal's admin RECOVERY arm write a competing ownership row
// and lock the real owner out of `bdrive login` forever.
return
}
s.observeDevice(r)
}
// observeDevice records the device behind a store-API request, creating the
// row if it is new. Only an authorized journal write calls it: a device is
// something that syncs its own journal, and any other route registering an id
// would let a caller mint — or squat — a device identity out of a header.
func (s *Server) observeDevice(r *http.Request) {
if s.Devices == nil {
return
}
// The shape check lives here, at the trust boundary, not inside the
// registry: what a client may assert about itself is a server decision,
// while the registry (and the repos under it) must store whatever it is
// handed faithfully.
if !validDeviceID(deviceID(r)) {
return
}
s.Devices.Observe(s.deviceFromRequest(r, s.requestUser(r).Email))
}
// deviceFromRequest turns the identity headers into a row. Name and OS are free
// text, and they end up in a metadata store where a control character is a
// divergence between backends (Postgres refuses a NUL in a text column; sqlite
// and the file backend keep it, so the same device reads back differently
// depending on the hub's database).
//
// trimText, not a local rule. The old printableOnly was `r < 0x20 || r == 0x7f`
// and nothing else, while trimText — the rule project names and account display
// names already go through — refuses the C1s, the bidi controls and the
// invisible formats and caps the length. X-Bdrive-Device-Name reaches further
// than either: it is joined into every History row the whole project reads, so
// a device that sends an RLO relabels its own rows and reorders somebody else's
// line. Same door, same rule, and the cap comes with it.
//
// One spelling, two callers: the sync doors' refresh and the login-time bind.
// They used to be one, and when binding moved to login the second copy would
// have been the third place this repo learned not to spell a rule twice.
func (s *Server) deviceFromRequest(r *http.Request, email string) DeviceInfo {
return DeviceInfo{
ID: deviceID(r),
Name: trimText(r.Header.Get("X-Bdrive-Device-Name"), 128),
OS: trimText(r.Header.Get("X-Bdrive-Os"), 128),
User: email,
// clientIP, not the raw X-Forwarded-For: the recorded address is the
// one the server actually saw unless the operator says a proxy fronts
// this hub, so a client cannot choose what the registry says about it.
IP: s.clientIP(r),
}
}
// requestIP is the address the server actually saw on the connection. It
// ignores X-Forwarded-For on purpose: its caller (the CLI device grant) has no
// TrustProxy setting to consult, and an address the client chose, recorded as
// one the server observed, is worse than the proxy's own address recorded
// honestly. Anything that has a *Server uses s.clientIP.
func requestIP(r *http.Request) string {
host, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
return r.RemoteAddr
}
return host
}
// deviceVisibleIn is the predicate LookupIn takes for one project: a device
// row may be joined into this project's surfaces only when the account that
// owns it belongs to the project's org. An unowned row (auth-less hub, or a
// pre-accounts observation) asserts no identity and stays visible; a hub with
// no directory has no orgs to cross in the first place.
func (s *Server) deviceVisibleIn(projectID string) func(string) bool {
if s.Dir == nil {
return func(string) bool { return true }
}
org := s.orgOf(projectID)
return func(user string) bool {
if user == "" {
return true
}
return org != "" && s.Dir.Role(org, user) != ""
}
}
// bindDevice is the server side of BuiltinAuth.BindDevice: the device id a
// signing-in machine names becomes that account's, hub-wide, before the token
// is handed over.
//
// A caller that names no device (a browser, an older CLI) binds nothing and is
// not refused — the binding is the machine's assertion about itself, and a
// login is still a login without one. What it cannot do is name an id that is
// already somebody else's.
func (s *Server) bindDevice(email string, r *http.Request) error {
id := deviceID(r)
if s.Devices == nil || id == "" {
return nil
}
if !validDeviceID(id) {
return fmt.Errorf("invalid device id")
}
return s.Devices.Bind(email, s.deviceFromRequest(r, email), s.sharesOrgWith(email))
}
// sharesOrgWith reports, for one account, whether another account is somebody
// it can already see on this hub. It is the visibility predicate Bind's
// conflict arm takes: a refusal that names a tenant the caller cannot reach
// through any other surface is a cross-org oracle, and this is the one fact
// that decides whether the refusal discloses anything new.
//
// A hub with no directory has no org wall to cross (single-volume, auth-less,
// or a fixture) and everybody is visible to everybody, which keeps the plain
// refusal for exactly the configurations where it leaks nothing.
func (s *Server) sharesOrgWith(me string) func(string) bool {
if s.Dir == nil {
return func(string) bool { return true }
}
mine := map[string]bool{}
for _, o := range s.Dir.OrgsFor(me) {
mine[o.ID] = true
}
return func(other string) bool {
for _, o := range s.Dir.OrgsFor(other) {
if mine[o.ID] {
return true
}
}
return false
}
}