Files
beardrive/internal/webapp/admin.go
69e7231a70 feat(hub): per-project permissions — none/read/write/admin, invite-only projects, honest degraded sync (#46)
Access was binary and org-wide: any org member got full read+write on every
project. Now each project carries four ordered levels, resolved by one
resolver and enforced at one choke point.

- `projectPerm` (perms.go) replaces `projectAllowed`; `proj(level, h)` in
  server.go gates every per-project route by the level it declares at
  registration, so no handler grows its own check.
- `Project` gains Creator/Default/Perms. `Default == ""` means write, so an
  upgraded hub behaves identically until someone edits permissions.
- Creator becomes the first project admin; org owners are implicitly admin
  everywhere in their org and a grant naming one is refused, not ignored; a
  project always keeps at least one explicit admin.
- Default `none` makes a project invite-only. A `none` member is treated
  exactly like a non-member, including on create-or-join by name.
- Rename/delete move from org-owner-only to project `admin`.
- Both metadata backends persist it: the file store rides along, the SQL
  store gains `project_perms` plus an idempotent ALTER for the two new
  columns (migrate() had only ever created tables).

Client side, a refusal stops looking like an outage: `remote.ErrForbidden`
plus `Result.ReadOnly` (push refused → pull-only) and `Result.NoAccess`
(pull refused → paused, working folder untouched). Neither sets Offline,
neither loses a local op, and re-granting self-heals on the next cycle.
`bdrive status`/`sync` and the daemon (once, on transition) say which.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 10:41:57 +09:00

179 lines
5.8 KiB
Go

package webapp
import (
"encoding/json"
"io"
"net/http"
)
// Administration surfaces: project lifecycle (rename/delete by the owning
// org's owner), hub-admin approval of pending signups, and an org-wide view
// of public share links. All of this is what makes a hub actually
// operable — an admin can offboard, clean up, and audit — without editing
// JSON files on the server by hand.
// handleProjectUpdate edits a project's name, description and icon. Project
// admins (and, implicitly, the owners of its org) only. It's a partial update:
// every field is a pointer, so only the keys actually present in the body
// change — {"description":""} clears the description, omitting the key leaves
// it alone.
func (s *Server) handleProjectUpdate(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("project")
if _, ok := s.project(w, r, id, PermAdmin); !ok {
return
}
var req struct {
Name *string `json:"name"`
Description *string `json:"description"`
Icon *string `json:"icon"`
}
if err := json.NewDecoder(io.LimitReader(r.Body, 1<<16)).Decode(&req); err != nil {
http.Error(w, "bad request: "+err.Error(), http.StatusBadRequest)
return
}
if err := s.Projects.Update(id, req.Name, req.Description, req.Icon); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
writeJSON(w, map[string]any{"ok": true})
}
// handleProjectDelete removes a project from the registry. Project admins
// only. Storage (blobs, journals) is intentionally left in place.
func (s *Server) handleProjectDelete(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("project")
if _, ok := s.project(w, r, id, PermAdmin); !ok {
return
}
if err := s.Projects.Delete(id); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
writeJSON(w, map[string]any{"ok": true})
}
// handleOrgShares lists every live public share across the org's projects,
// 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.Dir == nil {
http.Error(w, "sharing is not enabled on this server", http.StatusNotFound)
return
}
orgID := r.PathValue("org")
if s.Dir.Role(orgID, s.requestUser(r).Email) == "" {
http.Error(w, "you are not a member of this organization", http.StatusForbidden)
return
}
out := []map[string]any{}
for _, p := range s.Projects.List() {
if p.Org != orgID {
continue
}
for _, sh := range s.Shares.List(p.ID) {
j := shareJSON(r, sh)
j["project_name"] = p.Name
out = append(out, j)
}
}
writeJSON(w, map[string]any{"shares": out})
}
// 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.
func (s *Server) handleAdminPending(w http.ResponseWriter, r *http.Request) {
if !s.requestUser(r).Admin {
http.Error(w, "hub admins only", http.StatusForbidden)
return
}
a, ok := s.approver(w)
if !ok {
return
}
writeJSON(w, map[string]any{"pending": a.PendingUsers()})
}
// handleAdminPolicy reads (GET) or updates (POST) the signup/access policy.
// Domains and the admin list are reported read-only — they're server-config
// owned so a browser session can't widen access — while verification and
// approval toggles can be flipped live and are persisted.
func (s *Server) handleAdminPolicy(w http.ResponseWriter, r *http.Request) {
if !s.requestUser(r).Admin {
http.Error(w, "hub admins only", http.StatusForbidden)
return
}
a, ok := s.approver(w)
if !ok {
return
}
if r.Method == http.MethodPost {
var req struct {
RequireVerification bool `json:"require_verification"`
RequireApproval bool `json:"require_approval"`
}
if err := json.NewDecoder(io.LimitReader(r.Body, 1<<16)).Decode(&req); err != nil {
http.Error(w, "bad request: "+err.Error(), http.StatusBadRequest)
return
}
// 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.Policy().Mailer {
http.Error(w, "email verification needs SMTP configured on the server", http.StatusBadRequest)
return
}
if err := a.SetPolicy(req.RequireVerification, req.RequireApproval); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
}
writeJSON(w, a.Policy())
}
// handleAdminApprove activates a pending account. Hub admins only.
func (s *Server) handleAdminApprove(w http.ResponseWriter, r *http.Request) {
if !s.requestUser(r).Admin {
http.Error(w, "hub admins only", http.StatusForbidden)
return
}
a, ok := s.approver(w)
if !ok {
return
}
if err := a.Approve(r.PathValue("id")); err != nil {
http.Error(w, err.Error(), http.StatusNotFound)
return
}
writeJSON(w, map[string]any{"ok": true})
}
// handleAdminDeny removes a pending account. Hub admins only.
func (s *Server) handleAdminDeny(w http.ResponseWriter, r *http.Request) {
if !s.requestUser(r).Admin {
http.Error(w, "hub admins only", http.StatusForbidden)
return
}
a, ok := s.approver(w)
if !ok {
return
}
if err := a.Deny(r.PathValue("id")); err != nil {
http.Error(w, err.Error(), http.StatusNotFound)
return
}
writeJSON(w, map[string]any{"ok": true})
}