Files
beardrive/internal/webapp/auth.go
T
Snow Lee dfef5720df webapp: organizations behind a Directory seam
The hub already abstracted authentication — AuthProvider, with BuiltinAuth
as the built-in implementation — and then reached around that seam three
times: Accounts() was declared on neither interface, admin.go type-asserted
*BuiltinAuth (five handlers silently degraded to 404/empty under any other
provider), and organizations were not on the seam at all.

That last gap had teeth. A deployment whose identities come from elsewhere
had no way to own its orgs, so the code that did own them wrote into the
hub's OrgDB from the side — and nothing stopped the hub from inventing an
org that the identity system had never heard of. One did: a hub-created org
held every project while the mirrored one sat empty, and no sync path could
see the difference.

Directory (directory.go) is where organizations live now. LocalDirectory
wraps today's OrgDB unchanged — same last-owner protection, same normEmail,
same "o-"+randHex(4) ids, same file/SQL persistence — so a self-hosted hub
behaves exactly as before. A deployment whose orgs are owned elsewhere
implements the same interface, returns ErrManagedElsewhere from the write
half, and the handlers answer 409 with ManageURL. The hub never learns why
a write was refused, only where to send the user.

Two rules shape the interface. Reads are on the request path: Role runs on
every project request, including the /store/* endpoints a device hits every
few seconds with a token that carries no identity claims, so an
implementation backed by a remote system answers from its own cache — and
that cache is its business, not the hub's. Writes are optional, because
"this hub owns its orgs" is a deployment fact, not a code path.

- Server.Orgs *OrgDB becomes Server.Dir Directory: 28 call sites, 8
  nil-checks, one writeDirErr helper for the 409 translation.
- /api/orgs gains manage_url per org — the destination of the account
  menu's Settings entry. The client follows a link and never branches on
  which kind of hub it is talking to.
- Org administration becomes a real route, /orgs/<id>, retiring one of the
  two URL-less panels CLAUDE.md grandfathers. When a directory's ManageURL
  is not hub-local, the SPA fallback redirects there instead — so a hub that
  cannot administer its orgs cannot paint a console whose every control 409s.
- Accounts() moves onto AuthProvider. admin.go's type assertion becomes an
  optional AccountApprover, and a provider without one now answers 503
  rather than an empty approval queue: "no queue here" and "queue is empty"
  are different answers and only one of them was true.

Two reviews drove the rest. The architecture review caught a browser page
load that could delete org members (a display read ran the full membership
reconcile, and a 200 with an empty user list evicted everyone), one write
site that escaped the 409 translation, and a webhook that could wedge an
event stream behind an unappliable event. The design review, over eight
rounds, caught the org page rendering live controls on a hub that cannot
use them, a share link made unrevokable by a long filename, nine keyboard
tab stops parked off-screen behind a closed drawer, and — five separate
times — a fix of mine that looked right in the source and did nothing in
the browser.

Conformance tests run both a writable and a read-only implementation against
one contract; the seat, prune, and out-of-order regressions each have a test
written to fail against the old code.
2026-07-20 03:06:19 -07:00

103 lines
4.0 KiB
Go

package webapp
import (
"net/http"
"strings"
)
// Authentication is opt-in (`auth` in the server config) and sits behind the
// AuthProvider interface. The open-source server ships exactly one
// implementation, BuiltinAuth (email + password accounts in a file-backed
// registry, server-owned /auth/* pages). A managed deployment can swap in a
// different provider (e.g. PropelAuth-backed) without touching the CLI or
// the API: the CLI learns the login page from /api/config and the callback
// flow is provider-agnostic.
// User is an authenticated account as the rest of the server sees it.
type User struct {
ID string `json:"id"`
Email string `json:"email"`
Name string `json:"name"`
Admin bool `json:"admin,omitempty"` // hub admin (approve users, govern shares)
}
// AuthProvider is the seam between the server and an identity system.
type AuthProvider interface {
// CLILoginPath is the page `bdrive login` opens in a browser. The CLI
// appends ?redirect=http://127.0.0.1:<port>/callback&state=<nonce>.
CLILoginPath() string
// Authenticate resolves the request's Bearer token or session cookie.
Authenticate(r *http.Request) (User, bool)
// Register mounts the provider's own pages and endpoints (/auth/*,
// /api/auth/*) on the server mux.
Register(mux *http.ServeMux)
// Accounts lists every account the provider knows, oldest first. Startup
// tasks (the org migration) need it, and both implementations already had
// it — declaring it here stops callers reaching for a concrete type.
Accounts() []User
}
// 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
// say so (503) rather than pretending the queue is empty.
type AccountApprover interface {
PendingUsers() []User
Approve(id string) error
Deny(id string) error
SetPolicy(requireVerification, requireApproval bool) error
// Policy reports the signup gates as configured. The provider assembles
// it, so the hub never reaches into provider fields to render the page.
Policy() SignupPolicy
}
// Brander is the optional hub-name half: a provider that renders its own
// sign-in pages knows what to call this hub.
type Brander interface{ Branding() string }
// SignupPolicy is what /api/admin/policy reports: which gates are on, and
// which of them are server-config owned (read-only to a browser session, so
// that no one can widen access by clicking).
type SignupPolicy struct {
RequireVerification bool `json:"require_verification"`
RequireApproval bool `json:"require_approval"`
AllowSignup bool `json:"allow_signup"`
AllowedDomains []string `json:"allowed_domains"` // read-only
Admins []string `json:"admins"` // read-only
Mailer bool `json:"mailer"` // SMTP configured?
}
// authGate wraps the API with authentication when a provider is configured.
// The static frontend and the provider's own surface stay reachable so a
// browser can get to the login page; everything else under /api/ needs a
// valid identity.
func (s *Server) authGate(next http.Handler) http.Handler {
if s.Auth == nil {
return next
}
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
p := r.URL.Path
open := strings.HasPrefix(p, "/auth/") ||
strings.HasPrefix(p, "/api/auth/") ||
p == "/api/config" ||
!strings.HasPrefix(p, "/api/") // static frontend; its API calls are gated
if !open {
if _, ok := s.Auth.Authenticate(r); !ok {
http.Error(w, "authentication required (bdrive login, or sign in at /auth/login)", http.StatusUnauthorized)
return
}
}
next.ServeHTTP(w, r)
})
}
// requestUser returns the authenticated user, or a zero User when auth is
// disabled (everything then runs as an anonymous single user).
func (s *Server) requestUser(r *http.Request) User {
if s.Auth == nil {
return User{}
}
u, _ := s.Auth.Authenticate(r)
return u
}