Files
beardrive/internal/webapp/perms.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

236 lines
7.7 KiB
Go

package webapp
import (
"encoding/json"
"io"
"net/http"
"sort"
)
// Per-project permissions. Orgs wall projects off from outsiders; these four
// ordered levels say what an insider may do with one project. The default
// level for a project is "write" — today's behavior — expressed as the empty
// string on Project.Default, so an existing hub upgrades with no migration and
// no change in behavior until someone edits permissions.
//
// One resolver (projectPerm) and one choke point (the proj() wrapper in
// server.go): every per-project route declares the level it needs at
// registration, so no handler grows its own check and a missed handler cannot
// become a silent authorization hole.
const (
PermNone = "none" // the project is hidden: absent from the list, 403 everywhere
PermRead = "read" // browse, view, download, history, heat
PermWrite = "write" // + upload, sync push, share links
PermAdmin = "admin" // + rename, delete, edit this project's permissions
)
// permRank orders the levels. An unknown level ranks as none: fail closed.
func permRank(level string) int {
switch level {
case PermRead:
return 1
case PermWrite:
return 2
case PermAdmin:
return 3
default:
return 0
}
}
// atLeast reports whether have satisfies want.
func atLeast(have, want string) bool { return permRank(have) >= permRank(want) }
// validLevel says whether a level is one an API caller may name.
func validLevel(l string) bool {
return l == PermNone || l == PermRead || l == PermWrite || l == PermAdmin
}
// projectPerm resolves the request account's effective level on a project:
//
// org owner of the project's org → admin (always; never lockable-out)
// explicit grant → that level ("none" = denied)
// member of the project's org → the project default (write unless changed)
// otherwise → none
//
// The two escape hatches are load-bearing and inherited verbatim from the
// projectAllowed this replaces: without a directory or auth (single-volume
// mode, tests) and for an org-less project (a pre-org hub mid-migration),
// everyone resolves to admin.
func (s *Server) projectPerm(r *http.Request, projectID string) string {
if s.Dir == nil || s.Auth == nil {
return PermAdmin
}
p, ok := s.Projects.Get(projectID)
if !ok || p.Org == "" {
return PermAdmin // org-less project (migration happens at startup)
}
email := normEmail(s.requestUser(r).Email)
role := s.Dir.Role(p.Org, email)
if role == RoleOwner {
return PermAdmin
}
if l, ok := p.Perms[email]; ok {
return l
}
if role == "" {
return PermNone // not a member of the project's org
}
return p.level()
}
// requirePerm answers the request itself when the caller is short of level.
func (s *Server) requirePerm(w http.ResponseWriter, r *http.Request, projectID, level string) bool {
if atLeast(s.projectPerm(r, projectID), level) {
return true
}
http.Error(w, permDenied(level), http.StatusForbidden)
return false
}
// permDenied is the operator-voice 403 body. Deliberately one shape for every
// level so the frontend's errorFor keeps mapping it.
func permDenied(level string) string {
switch level {
case PermAdmin:
return "you need admin permission on this project"
case PermWrite:
return "you have read-only access to this project"
default:
return "you do not have access to this project"
}
}
// ---- HTTP ----
// handleProjectPerms returns the project's permission settings: the default
// level, the caller's own effective level, and the explicit grants. Any member
// with read may look; the grants are org-internal, not secrets.
func (s *Server) handleProjectPerms(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("project")
p, ok := s.project(w, r, id, PermRead)
if !ok {
return
}
grants := make([]map[string]string, 0, len(p.Perms))
for email, level := range p.Perms {
grants = append(grants, map[string]string{"email": email, "level": level})
}
sort.Slice(grants, func(i, j int) bool { return grants[i]["email"] < grants[j]["email"] })
writeJSON(w, map[string]any{
"default": p.level(),
"me": s.projectPerm(r, id),
"creator": p.Creator,
"grants": grants,
})
}
// handleProjectPermDefault sets the level every org member gets without an
// explicit grant. Admin only. "admin" is not a legal default: it would make
// the last-admin rule meaningless.
func (s *Server) handleProjectPermDefault(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("project")
if _, ok := s.project(w, r, id, PermAdmin); !ok {
return
}
var req struct {
Default string `json:"default"`
}
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 !validLevel(req.Default) || req.Default == PermAdmin {
http.Error(w, "default must be none, read, or write", http.StatusBadRequest)
return
}
if err := s.Projects.SetDefault(id, req.Default); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
writeJSON(w, map[string]any{"ok": true})
}
// handleProjectPermSet grants one account an explicit level. Admin only.
func (s *Server) handleProjectPermSet(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("project")
p, ok := s.project(w, r, id, PermAdmin)
if !ok {
return
}
var req struct {
Level string `json:"level"`
}
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 !validLevel(req.Level) {
http.Error(w, "level must be none, read, write, or admin", http.StatusBadRequest)
return
}
email := normEmail(r.PathValue("email"))
if !s.grantable(w, p, email) {
return
}
if err := s.Projects.SetPerm(id, email, req.Level); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
writeJSON(w, map[string]any{"ok": true})
}
// handleProjectPermClear drops an explicit grant, reverting that account to
// the project default. Admin only.
func (s *Server) handleProjectPermClear(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("project")
if _, ok := s.project(w, r, id, PermAdmin); !ok {
return
}
if err := s.Projects.ClearPerm(id, normEmail(r.PathValue("email"))); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
writeJSON(w, map[string]any{"ok": true})
}
// grantable checks the target of a grant: org members only, and never an org
// owner (they are implicitly admin everywhere, so a grant on one would be
// silently ignored — and a write that quietly does nothing is worse than a
// refusal).
func (s *Server) grantable(w http.ResponseWriter, p Project, email string) bool {
if s.Dir == nil || p.Org == "" {
return true
}
switch s.Dir.Role(p.Org, email) {
case "":
http.Error(w, "that account is not a member of this project's organization", http.StatusBadRequest)
return false
case RoleOwner:
http.Error(w, "organization owners are always project admins", http.StatusBadRequest)
return false
}
return true
}
// project resolves a project id and the caller's level in one step, answering
// the request itself when either fails. A missing project is 404; an existing
// one the caller may not touch is 403 — the same answer a non-member gets, so
// the two are indistinguishable from outside.
func (s *Server) project(w http.ResponseWriter, r *http.Request, id, level string) (Project, bool) {
if s.Projects == nil {
http.Error(w, "this server does not host projects", http.StatusNotFound)
return Project{}, false
}
p, ok := s.Projects.Get(id)
if !ok {
http.Error(w, "no such project", http.StatusNotFound)
return Project{}, false
}
if !s.requirePerm(w, r, id, level) {
return Project{}, false
}
return p, true
}