mirror of
https://github.com/runbear-io/beardrive.git
synced 2026-08-25 08:08:08 +02:00
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>
236 lines
7.7 KiB
Go
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
|
|
}
|