diff --git a/cmd/bdrive/web.go b/cmd/bdrive/web.go index bf20215..996e896 100644 --- a/cmd/bdrive/web.go +++ b/cmd/bdrive/web.go @@ -319,7 +319,7 @@ credentials); otherwise it is relayed through this server.`, if err != nil { return fmt.Errorf("open org registry: %w", err) } - srv.Orgs = orgs + srv.Dir = webapp.LocalDirectory{OrgDB: orgs} // Invite links can bootstrap an account on an invite-only hub. auth.InviteValid = orgs.ValidInvite // A hub that predates organizations: sweep its projects into diff --git a/internal/syncer/org_authz_test.go b/internal/syncer/org_authz_test.go index d4e5892..39a5396 100644 --- a/internal/syncer/org_authz_test.go +++ b/internal/syncer/org_authz_test.go @@ -38,7 +38,7 @@ func orgHub(t *testing.T, storage remote.Backend) (ts *httptest.Server, aliceTok srv := &webapp.Server{ Root: storage, Projects: db, Refresh: 0, Upload: webapp.UploadConfig{Enabled: true}, - Auth: auth, Orgs: orgs, + Auth: auth, Dir: webapp.LocalDirectory{OrgDB: orgs}, } ts = httptest.NewServer(srv.Handler()) t.Cleanup(ts.Close) diff --git a/internal/webapp/admin.go b/internal/webapp/admin.go index 33146f9..dc8e9dd 100644 --- a/internal/webapp/admin.go +++ b/internal/webapp/admin.go @@ -14,14 +14,14 @@ import ( // projectOwner returns true when the request's account owns the project's org. func (s *Server) projectOwner(r *http.Request, projectID string) bool { - if s.Orgs == nil || s.Auth == nil { + if s.Dir == nil || s.Auth == nil { return true } org := s.orgOf(projectID) if org == "" { return true } - return s.Orgs.Role(org, s.requestUser(r).Email) == RoleOwner + return s.Dir.Role(org, s.requestUser(r).Email) == RoleOwner } // handleProjectRename renames a project. Owner of its org only. @@ -80,12 +80,12 @@ func (s *Server) handleProjectDelete(w http.ResponseWriter, r *http.Request) { // so an owner can audit "what have we made public?" in one place. Any org // member may view; only owners revoke (via the existing per-share endpoint). func (s *Server) handleOrgShares(w http.ResponseWriter, r *http.Request) { - if s.Shares == nil || s.Orgs == nil { + if s.Shares == nil || s.Dir == nil { http.Error(w, "sharing is not enabled on this server", http.StatusNotFound) return } orgID := r.PathValue("org") - if s.Orgs.Role(orgID, s.requestUser(r).Email) == "" { + if s.Dir.Role(orgID, s.requestUser(r).Email) == "" { http.Error(w, "you are not a member of this organization", http.StatusForbidden) return } @@ -103,11 +103,19 @@ func (s *Server) handleOrgShares(w http.ResponseWriter, r *http.Request) { writeJSON(w, map[string]any{"shares": out}) } -// builtinAuth returns the concrete OSS auth provider, or nil for a swapped -// provider that doesn't support approval. -func (s *Server) builtinAuth() *BuiltinAuth { - a, _ := s.Auth.(*BuiltinAuth) - return a +// approver returns the auth provider's account-administration half, if it has +// one. A provider whose accounts live in an external identity system does not: +// there is no local approval queue to show and no local policy to flip. +func (s *Server) approver(w http.ResponseWriter) (AccountApprover, bool) { + a, ok := s.Auth.(AccountApprover) + if !ok { + // 503, not an empty list: "no queue here" and "queue is empty" are + // different answers, and only one of them is true. + http.Error(w, "accounts on this hub are administered in its identity provider", + http.StatusServiceUnavailable) + return nil, false + } + return a, true } // handleAdminPending lists accounts awaiting approval. Hub admins only. @@ -116,9 +124,8 @@ func (s *Server) handleAdminPending(w http.ResponseWriter, r *http.Request) { http.Error(w, "hub admins only", http.StatusForbidden) return } - a := s.builtinAuth() - if a == nil { - writeJSON(w, map[string]any{"pending": []any{}}) + a, ok := s.approver(w) + if !ok { return } writeJSON(w, map[string]any{"pending": a.PendingUsers()}) @@ -133,9 +140,8 @@ func (s *Server) handleAdminPolicy(w http.ResponseWriter, r *http.Request) { http.Error(w, "hub admins only", http.StatusForbidden) return } - a := s.builtinAuth() - if a == nil { - http.Error(w, "policy is not supported by this auth provider", http.StatusNotFound) + a, ok := s.approver(w) + if !ok { return } if r.Method == http.MethodPost { @@ -149,7 +155,7 @@ func (s *Server) handleAdminPolicy(w http.ResponseWriter, r *http.Request) { } // Email verification is only a real gate with a mailer; refuse to turn // it on without SMTP rather than silently logging links. - if req.RequireVerification && a.Mail == nil { + if req.RequireVerification && !a.Policy().Mailer { http.Error(w, "email verification needs SMTP configured on the server", http.StatusBadRequest) return } @@ -158,18 +164,7 @@ func (s *Server) handleAdminPolicy(w http.ResponseWriter, r *http.Request) { return } } - admins := make([]string, 0, len(a.Admins)) - for e := range a.Admins { - admins = append(admins, e) - } - writeJSON(w, map[string]any{ - "require_verification": a.RequireVerification, - "require_approval": a.RequireApproval, - "allow_signup": a.AllowSignup, - "allowed_domains": a.AllowedDomains, // read-only (server config) - "admins": admins, // read-only (server config) - "mailer": a.Mail != nil, - }) + writeJSON(w, a.Policy()) } // handleAdminApprove activates a pending account. Hub admins only. @@ -178,9 +173,8 @@ func (s *Server) handleAdminApprove(w http.ResponseWriter, r *http.Request) { http.Error(w, "hub admins only", http.StatusForbidden) return } - a := s.builtinAuth() - if a == nil { - http.Error(w, "approval is not supported by this auth provider", http.StatusNotFound) + a, ok := s.approver(w) + if !ok { return } if err := a.Approve(r.PathValue("id")); err != nil { @@ -196,9 +190,8 @@ func (s *Server) handleAdminDeny(w http.ResponseWriter, r *http.Request) { http.Error(w, "hub admins only", http.StatusForbidden) return } - a := s.builtinAuth() - if a == nil { - http.Error(w, "approval is not supported by this auth provider", http.StatusNotFound) + a, ok := s.approver(w) + if !ok { return } if err := a.Deny(r.PathValue("id")); err != nil { diff --git a/internal/webapp/auth.go b/internal/webapp/auth.go index 5d0eab1..26931a4 100644 --- a/internal/webapp/auth.go +++ b/internal/webapp/auth.go @@ -31,6 +31,40 @@ type AuthProvider interface { // 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. diff --git a/internal/webapp/authlocal.go b/internal/webapp/authlocal.go index ccfddf4..b55bce9 100644 --- a/internal/webapp/authlocal.go +++ b/internal/webapp/authlocal.go @@ -400,6 +400,29 @@ func (a *BuiltinAuth) grantDevice(id, userID string) bool { return true } +// Branding is the hub name this provider renders on its own pages. +func (a *BuiltinAuth) Branding() string { return a.Brand } + +// Policy reports this provider's signup gates (webapp.AccountApprover). The +// provider assembles it so the hub never reaches into these fields itself. +func (a *BuiltinAuth) Policy() SignupPolicy { + a.mu.Lock() + defer a.mu.Unlock() + admins := make([]string, 0, len(a.Admins)) + for e := range a.Admins { + admins = append(admins, e) + } + sort.Strings(admins) + return SignupPolicy{ + RequireVerification: a.RequireVerification, + RequireApproval: a.RequireApproval, + AllowSignup: a.AllowSignup, + AllowedDomains: a.AllowedDomains, + Admins: admins, + Mailer: a.Mail != nil, + } +} + // PendingUsers lists accounts awaiting admin approval, oldest first. func (a *BuiltinAuth) PendingUsers() []User { a.mu.Lock() @@ -606,8 +629,34 @@ font-family:ui-monospace,Menlo,monospace} } func field(label, name, typ, value string) string { - return fmt.Sprintf(``, - html.EscapeString(label), name, typ, html.EscapeString(value)) + return fmt.Sprintf(``, + name, html.EscapeString(label), name, name, typ, html.EscapeString(value), autocompleteFor(name, typ)) +} + +// autocompleteFor names a field's purpose so browsers and password managers +// fill it (WCAG 1.3.5). A password field's purpose depends on the page, not +// its name: offering a saved credential on a signup form is worse than +// offering nothing, so those callers use newPasswordField. +func autocompleteFor(name, typ string) string { + switch name { + case "email": + return "email" + case "name": + return "name" + case "code": + return "one-time-code" + } + if typ == "password" { + return "current-password" + } + return "on" +} + +// newPasswordField is field() for a password being CREATED (signup, reset), +// so a manager generates one instead of filling an existing login. +func newPasswordField(label, name string) string { + return fmt.Sprintf(``, + name, html.EscapeString(label), name, name) } func (a *BuiltinAuth) pageLogin(w http.ResponseWriter, r *http.Request) { @@ -715,7 +764,7 @@ func (a *BuiltinAuth) pageSignup(w http.ResponseWriter, r *http.Request) { field("Name", "name", "text", r.FormValue("name")), field("Email", "email", "email", r.FormValue("email")), domainNote, - field("Password (min 8 chars)", "password", "password", ""), + newPasswordField("Password (min 8 chars)", "password"), errMsg, url.QueryEscape(next))) } @@ -863,7 +912,7 @@ func (a *BuiltinAuth) pageResetConfirm(w http.ResponseWriter, r *http.Request) { func resetForm(token, msg string) string { return fmt.Sprintf(`
`, - html.EscapeString(token), field("New password (min 8 chars)", "password", "password", ""), msg) + html.EscapeString(token), newPasswordField("New password (min 8 chars)", "password"), msg) } func requestBaseURL(r *http.Request) string { diff --git a/internal/webapp/directory.go b/internal/webapp/directory.go new file mode 100644 index 0000000..00d0298 --- /dev/null +++ b/internal/webapp/directory.go @@ -0,0 +1,64 @@ +package webapp + +import ( + "errors" + "time" +) + +// Directory is where a hub's organizations live. The built-in one is this +// package's OrgDB (LocalDirectory); a deployment whose users, orgs and +// memberships are owned by an external identity system supplies its own, +// alongside its AuthProvider. +// +// Two rules shape this interface: +// +// Reads are on the request path. Role is called for every project request, +// including the /store/* sync endpoints a device hits every few seconds with +// a device token that carries no identity claims. An implementation backed by +// a remote system therefore has to answer Role from a local cache, and that +// cache is the implementation's business — the hub does not keep one, does not +// refresh one, and must never be written to from the side. (It used to be: +// the hub owned the mirror and the auth provider poked at it, which is how a +// hub-invented org that the identity system had never heard of could exist.) +// +// Writes are optional. A directory that does not own its data returns +// ErrManagedElsewhere and the handler answers 409 with ManageURL, so the hub +// never needs to know WHY it cannot write — only where the user should go. +type Directory interface { + // ---- reads (request path) ---- + Role(orgID, email string) string + Get(orgID string) (Org, bool) + OrgsFor(email string) []Org + ListInvites(orgID string) []OrgInvite + ValidInvite(token string) bool + // ManageURL is where this org is administered: a path within this hub + // when it owns its orgs, an external page when it does not. The client + // follows it and never has to know which kind of hub it is talking to. + ManageURL(orgID string) string + + // ---- writes (ErrManagedElsewhere when the directory is read-only) ---- + Create(name, ownerEmail string) (Org, error) + Rename(orgID, name string) error + AddMember(orgID, email, role string) error + SetRole(orgID, email, role string) error + RemoveMember(orgID, email string) error + CreateInvite(orgID, creator string, ttl time.Duration) (OrgInvite, error) + RevokeInvite(token string) bool + Redeem(token string) (OrgInvite, bool) + RecordInviteUse(token string) +} + +// ErrManagedElsewhere is returned by a directory that does not own its +// organizations. Handlers turn it into 409 plus the org's ManageURL — the +// request was well-formed, it is the state of the world that makes it wrong. +var ErrManagedElsewhere = errors.New("this organization is managed outside this hub") + +// LocalDirectory is the built-in directory: organizations owned by this hub, +// stored in its own metadata store. This is what every self-hosted install +// runs, and its behavior is exactly OrgDB's — the type exists to add the one +// thing an org store has no opinion about, which is where to send a browser +// to administer an org. +type LocalDirectory struct{ *OrgDB } + +// ManageURL is the hub's own org page (a route in the frontend). +func (LocalDirectory) ManageURL(orgID string) string { return "/orgs/" + orgID } diff --git a/internal/webapp/directory_test.go b/internal/webapp/directory_test.go new file mode 100644 index 0000000..bc22e5f --- /dev/null +++ b/internal/webapp/directory_test.go @@ -0,0 +1,146 @@ +package webapp + +import ( + "errors" + "path/filepath" + "testing" + "time" +) + +// Every Directory implementation has to behave the same way from the hub's +// side, whether it owns its organizations or mirrors someone else's. This is +// the contract, run against each implementation — the same shape as +// db_conformance_test does for MetaStore backends. +// +// A read-only directory (one whose orgs live in an external identity system) +// is a first-class case: it answers reads normally and refuses writes with +// ErrManagedElsewhere, which is what lets the hub answer 409 without knowing +// anything about the system on the other side. + +// readOnlyDir wraps a directory and refuses every write, the way an +// implementation backed by an external identity system does. +type readOnlyDir struct{ Directory } + +func (readOnlyDir) Create(string, string) (Org, error) { return Org{}, ErrManagedElsewhere } +func (readOnlyDir) Rename(string, string) error { return ErrManagedElsewhere } +func (readOnlyDir) AddMember(string, string, string) error { return ErrManagedElsewhere } +func (readOnlyDir) SetRole(string, string, string) error { return ErrManagedElsewhere } +func (readOnlyDir) RemoveMember(string, string) error { return ErrManagedElsewhere } +func (readOnlyDir) CreateInvite(string, string, time.Duration) (OrgInvite, error) { + return OrgInvite{}, ErrManagedElsewhere +} +func (readOnlyDir) ManageURL(orgID string) string { return "https://elsewhere.example/" + orgID } + +func TestDirectoryConformance(t *testing.T) { + t.Run("LocalDirectory", func(t *testing.T) { + db, err := OpenOrgDB(filepath.Join(t.TempDir(), "orgs.json")) + if err != nil { + t.Fatal(err) + } + dirReads(t, LocalDirectory{OrgDB: db}, true) + }) + + t.Run("read-only directory", func(t *testing.T) { + db, err := OpenOrgDB(filepath.Join(t.TempDir(), "orgs.json")) + if err != nil { + t.Fatal(err) + } + // Seed through the underlying store: an external directory's contents + // arrive by mirroring, not through the hub's write path. + o, err := db.Create("acme", "alice@x.io") + if err != nil { + t.Fatal(err) + } + dir := readOnlyDir{Directory: LocalDirectory{OrgDB: db}} + dirReads(t, dir, false) + + // Reads still work on the seeded org... + if got := dir.Role(o.ID, "alice@x.io"); got != RoleOwner { + t.Errorf("Role = %q, want owner", got) + } + // ...and every write is refused the same recognizable way. + for name, err := range map[string]error{ + "Rename": dir.Rename(o.ID, "nope"), + "AddMember": dir.AddMember(o.ID, "bob@x.io", RoleMember), + "SetRole": dir.SetRole(o.ID, "alice@x.io", RoleMember), + "RemoveMember": dir.RemoveMember(o.ID, "alice@x.io"), + } { + if !errors.Is(err, ErrManagedElsewhere) { + t.Errorf("%s err = %v, want ErrManagedElsewhere", name, err) + } + } + if _, err := dir.Create("other", "alice@x.io"); !errors.Is(err, ErrManagedElsewhere) { + t.Errorf("Create err = %v, want ErrManagedElsewhere", err) + } + if _, err := dir.CreateInvite(o.ID, "alice@x.io", 0); !errors.Is(err, ErrManagedElsewhere) { + t.Errorf("CreateInvite err = %v, want ErrManagedElsewhere", err) + } + }) +} + +// dirReads exercises the read surface every implementation must answer, and +// the writes only an owning directory supports. +func dirReads(t *testing.T, dir Directory, writable bool) { + t.Helper() + + if writable { + o, err := dir.Create("acme", "alice@x.io") + if err != nil { + t.Fatal(err) + } + if got, ok := dir.Get(o.ID); !ok || got.Name != "acme" { + t.Fatalf("Get = %+v ok=%v", got, ok) + } + if got := dir.Role(o.ID, "ALICE@x.io"); got != RoleOwner { + t.Errorf("Role is not email-normalized: %q", got) + } + if err := dir.AddMember(o.ID, "bob@x.io", RoleMember); err != nil { + t.Fatal(err) + } + // The invariants live in the implementation, not in the caller. + if err := dir.RemoveMember(o.ID, "alice@x.io"); err == nil { + t.Error("removing the last owner must be refused") + } + if err := dir.SetRole(o.ID, "alice@x.io", RoleMember); err == nil { + t.Error("demoting the last owner must be refused") + } + inv, err := dir.CreateInvite(o.ID, "alice@x.io", time.Hour) + if err != nil { + t.Fatal(err) + } + if !dir.ValidInvite(inv.Token) { + t.Error("fresh invite is not valid") + } + if got := dir.ListInvites(o.ID); len(got) != 1 { + t.Errorf("ListInvites = %d, want 1", len(got)) + } + if _, ok := dir.Redeem(inv.Token); !ok { + t.Error("Redeem failed") + } + dir.RecordInviteUse(inv.Token) + if !dir.RevokeInvite(inv.Token) { + t.Error("RevokeInvite failed") + } + if dir.ValidInvite(inv.Token) { + t.Error("revoked invite is still valid") + } + if len(dir.OrgsFor("bob@x.io")) != 1 { + t.Error("OrgsFor missed the org bob belongs to") + } + } + + // Unknown ids are answered, never panicked on — Role in particular runs on + // every project request including device sync. + if got := dir.Role("o-nope", "nobody@x.io"); got != "" { + t.Errorf("Role of a non-member = %q, want \"\"", got) + } + if _, ok := dir.Get("o-nope"); ok { + t.Error("Get of an unknown org reported ok") + } + if dir.ValidInvite("not-a-token") { + t.Error("bogus invite reported valid") + } + if dir.ManageURL("o-1234") == "" { + t.Error("ManageURL must always give the client somewhere to go") + } +} diff --git a/internal/webapp/e2e_serve_test.go b/internal/webapp/e2e_serve_test.go index f16053b..7cea327 100644 --- a/internal/webapp/e2e_serve_test.go +++ b/internal/webapp/e2e_serve_test.go @@ -99,7 +99,7 @@ func TestE2EServe(t *testing.T) { if err := db.SetOrg(p.ID, org.ID); err != nil { t.Fatal(err) } - srv.Orgs = orgs + srv.Dir = LocalDirectory{OrgDB: orgs} auth.InviteValid = orgs.ValidInvite shares, err := OpenShareDB(filepath.Join(state, "shares.json")) diff --git a/internal/webapp/frontend/e2e/admin.spec.ts b/internal/webapp/frontend/e2e/admin.spec.ts index 33eedc2..3b46819 100644 --- a/internal/webapp/frontend/e2e/admin.spec.ts +++ b/internal/webapp/frontend/e2e/admin.spec.ts @@ -16,7 +16,9 @@ test("org admin: members with roles, self marked, rename round-trip", async ({ p await login(page); await openOrgSettings(page); await expect(page.locator("#org-title")).toHaveText("default"); - await expect(page.locator("#crumb")).toHaveText("default"); + // The crumb names the surface; repeating the org name under theThis organization doesn't exist, or you're no longer a member.
+ +