Files
beardrive/internal/webapp/orgs.go
T
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

582 lines
16 KiB
Go

package webapp
import (
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"sort"
"strings"
"sync"
"time"
)
// Organizations group accounts and own projects: every project belongs to
// exactly one org, and only that org's members can see or sync it. The OSS
// server supports any number of orgs on one hub (a self-hosted deployment is
// typically just one); membership is by account email with two roles. Share
// links (/s/) deliberately stay outside this wall — public is their point.
//
// Same file-backed discipline as the other registries: orgs.json is loaded
// at open and rewritten atomically (temp + rename) on every change.
const (
RoleOwner = "owner"
RoleMember = "member"
)
// Org is one organization.
type Org struct {
ID string `json:"id"`
Name string `json:"name"`
Members map[string]string `json:"members"` // lowercase email → role
Created time.Time `json:"created"`
}
// OrgInvite is a mint-once join link. Redeeming it while signed in adds the
// account to the org as a member.
type OrgInvite struct {
Token string `json:"token"`
Org string `json:"org"`
Creator string `json:"creator,omitempty"` // account email
Created time.Time `json:"created"`
Expires time.Time `json:"expires"`
Uses int `json:"uses"` // how many accounts have joined via this link
}
// RecordInviteUse bumps the join counter for an invite (best effort).
func (db *OrgDB) RecordInviteUse(token string) {
db.mu.Lock()
defer db.mu.Unlock()
if inv, ok := db.invites[token]; ok {
inv.Uses++
db.invites[token] = inv
db.repo.PutInvite(inv)
}
}
func (i OrgInvite) expired() bool { return time.Now().After(i.Expires) }
// DefaultInviteTTL bounds invite links that don't ask for an expiry.
const DefaultInviteTTL = 7 * 24 * time.Hour
// OrgDB is the in-memory org registry over a MetaStore OrgRepo (orgs + invites).
type OrgDB struct {
repo OrgRepo
mu sync.Mutex
byID map[string]Org
invites map[string]OrgInvite
}
// NewOrgDB builds the registry over a repo, loading orgs and invites.
func NewOrgDB(repo OrgRepo) (*OrgDB, error) {
db := &OrgDB{repo: repo, byID: make(map[string]Org), invites: make(map[string]OrgInvite)}
orgs, invites, err := repo.Load()
if err != nil {
return nil, err
}
for _, o := range orgs {
db.byID[o.ID] = o
}
for _, i := range invites {
db.invites[i.Token] = i
}
return db, nil
}
// OpenOrgDB loads the file-backed registry at path.
func OpenOrgDB(path string) (*OrgDB, error) {
return NewOrgDB(newFileOrgRepo(path))
}
func normEmail(email string) string { return strings.ToLower(strings.TrimSpace(email)) }
// Create makes a new org owned by ownerEmail.
func (db *OrgDB) Create(name, ownerEmail string) (Org, error) {
name = trimName(name)
if name == "" {
return Org{}, fmt.Errorf("organization name must not be empty")
}
o := Org{
ID: "o-" + randHex(4), Name: name,
Members: map[string]string{}, Created: time.Now().UTC(),
}
if e := normEmail(ownerEmail); e != "" {
o.Members[e] = RoleOwner
}
db.mu.Lock()
defer db.mu.Unlock()
db.byID[o.ID] = o
if err := db.repo.PutOrg(o); err != nil {
delete(db.byID, o.ID)
return Org{}, err
}
return o, nil
}
func (db *OrgDB) Get(id string) (Org, bool) {
db.mu.Lock()
defer db.mu.Unlock()
o, ok := db.byID[id]
return o, ok
}
// Role returns the account's role in the org, or "" for non-members.
func (db *OrgDB) Role(orgID, email string) string {
db.mu.Lock()
defer db.mu.Unlock()
o, ok := db.byID[orgID]
if !ok {
return ""
}
return o.Members[normEmail(email)]
}
// OrgsFor returns the orgs the account belongs to, sorted by name.
func (db *OrgDB) OrgsFor(email string) []Org {
e := normEmail(email)
db.mu.Lock()
defer db.mu.Unlock()
var out []Org
for _, o := range db.byID {
if o.Members[e] != "" {
out = append(out, o)
}
}
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
return out
}
// AddMember adds (or keeps) the account in the org with the given role. An
// existing member's role is never downgraded by an invite.
func (db *OrgDB) AddMember(orgID, email, role string) error {
e := normEmail(email)
if e == "" {
return fmt.Errorf("email must not be empty")
}
if role != RoleOwner && role != RoleMember {
return fmt.Errorf("invalid role %q", role)
}
db.mu.Lock()
defer db.mu.Unlock()
o, ok := db.byID[orgID]
if !ok {
return fmt.Errorf("no such organization")
}
if o.Members[e] == RoleOwner {
return nil
}
o.Members[e] = role
db.byID[orgID] = o
return db.repo.PutOrg(o)
}
// RemoveMember drops an account from the org. The last owner cannot be
// removed (an org must always have someone who can administer it).
func (db *OrgDB) RemoveMember(orgID, email string) error {
e := normEmail(email)
db.mu.Lock()
defer db.mu.Unlock()
o, ok := db.byID[orgID]
if !ok {
return fmt.Errorf("no such organization")
}
if o.Members[e] == "" {
return fmt.Errorf("%s is not a member", email)
}
if o.Members[e] == RoleOwner && db.ownerCount(o) <= 1 {
return fmt.Errorf("cannot remove the last owner")
}
delete(o.Members, e)
db.byID[orgID] = o
return db.repo.PutOrg(o)
}
// SetRole changes an account's role. Demoting the last owner is refused.
func (db *OrgDB) SetRole(orgID, email, role string) error {
if role != RoleOwner && role != RoleMember {
return fmt.Errorf("invalid role %q", role)
}
e := normEmail(email)
db.mu.Lock()
defer db.mu.Unlock()
o, ok := db.byID[orgID]
if !ok {
return fmt.Errorf("no such organization")
}
if o.Members[e] == "" {
return fmt.Errorf("%s is not a member", email)
}
if o.Members[e] == RoleOwner && role == RoleMember && db.ownerCount(o) <= 1 {
return fmt.Errorf("cannot demote the last owner")
}
o.Members[e] = role
db.byID[orgID] = o
return db.repo.PutOrg(o)
}
// Rename changes the org's display name.
func (db *OrgDB) Rename(orgID, name string) error {
name = trimName(name)
if name == "" {
return fmt.Errorf("organization name must not be empty")
}
db.mu.Lock()
defer db.mu.Unlock()
o, ok := db.byID[orgID]
if !ok {
return fmt.Errorf("no such organization")
}
o.Name = name
db.byID[orgID] = o
return db.repo.PutOrg(o)
}
// ownerCount counts owners in an org. Callers hold mu.
func (db *OrgDB) ownerCount(o Org) int {
n := 0
for _, role := range o.Members {
if role == RoleOwner {
n++
}
}
return n
}
// ListInvites returns the org's live (non-expired) invites.
func (db *OrgDB) ListInvites(orgID string) []OrgInvite {
db.mu.Lock()
defer db.mu.Unlock()
var out []OrgInvite
for _, inv := range db.invites {
if inv.Org == orgID && !inv.expired() {
out = append(out, inv)
}
}
sort.Slice(out, func(i, j int) bool { return out[i].Created.After(out[j].Created) })
return out
}
// RevokeInvite deletes an invite so its link stops working immediately.
func (db *OrgDB) RevokeInvite(token string) bool {
db.mu.Lock()
defer db.mu.Unlock()
if _, ok := db.invites[token]; !ok {
return false
}
delete(db.invites, token)
db.repo.DeleteInvite(token)
return true
}
// CreateInvite mints a join link for the org.
func (db *OrgDB) CreateInvite(orgID, creator string, ttl time.Duration) (OrgInvite, error) {
if ttl <= 0 {
ttl = DefaultInviteTTL
}
db.mu.Lock()
defer db.mu.Unlock()
if _, ok := db.byID[orgID]; !ok {
return OrgInvite{}, fmt.Errorf("no such organization")
}
inv := OrgInvite{
Token: randHex(16), Org: orgID, Creator: normEmail(creator),
Created: time.Now().UTC(), Expires: time.Now().UTC().Add(ttl),
}
db.invites[inv.Token] = inv
if err := db.repo.PutInvite(inv); err != nil {
delete(db.invites, inv.Token)
return OrgInvite{}, err
}
return inv, nil
}
// Redeem consumes nothing — an invite link can onboard a whole team until it
// expires — it just resolves the token to its live invite.
func (db *OrgDB) Redeem(token string) (OrgInvite, bool) {
db.mu.Lock()
defer db.mu.Unlock()
inv, ok := db.invites[token]
if !ok || inv.expired() {
return OrgInvite{}, false
}
return inv, true
}
// ValidInvite reports whether a token is a live invite, without consuming it.
// It lets the signup page permit account creation from an invite link even
// when public self-signup is closed (invite-only hubs).
func (db *OrgDB) ValidInvite(token string) bool {
db.mu.Lock()
defer db.mu.Unlock()
inv, ok := db.invites[token]
return ok && !inv.expired()
}
// ---- migration ----
// MigrateOrgs assigns every org-less project to a default org so a hub that
// predates organizations keeps working with zero manual steps. All existing
// accounts join it — they could all see every project before, so anything
// narrower would lock someone out — with the oldest account as owner.
// orgWriter is the slice of Directory that MigrateOrgs needs: it creates one
// org and fills it. Taking the narrow type keeps the sweep usable with a bare
// OrgDB (which is what the CLI has at that point) instead of forcing a
// LocalDirectory wrapper on a function that has no use for ManageURL.
type orgWriter interface {
Create(name, ownerEmail string) (Org, error)
AddMember(orgID, email, role string) error
}
func MigrateOrgs(projects *ProjectDB, orgs orgWriter, accounts []User) error {
var orphans []Project
for _, p := range projects.List() {
if p.Org == "" {
orphans = append(orphans, p)
}
}
if len(orphans) == 0 {
return nil
}
owner := ""
if len(accounts) > 0 {
owner = accounts[0].Email
}
def, err := orgs.Create("default", owner)
if err != nil {
return err
}
for _, u := range accounts[min(1, len(accounts)):] {
if err := orgs.AddMember(def.ID, u.Email, RoleMember); err != nil {
return err
}
}
for _, p := range orphans {
if err := projects.SetOrg(p.ID, def.ID); err != nil {
return err
}
}
return nil
}
// ---- HTTP ----
// orgFor resolves a project's org; zero value when orgs are off.
func (s *Server) orgOf(projectID string) string {
if s.Projects == nil {
return ""
}
p, _ := s.Projects.Get(projectID)
return p.Org
}
// handleOrgList returns the caller's orgs with members (visible to any
// member) and the caller's role.
func (s *Server) handleOrgList(w http.ResponseWriter, r *http.Request) {
if s.Dir == nil {
writeJSON(w, map[string]any{"orgs": []any{}})
return
}
me := s.requestUser(r)
out := []map[string]any{}
for _, o := range s.Dir.OrgsFor(me.Email) {
members := make([]map[string]string, 0, len(o.Members))
for email, role := range o.Members {
members = append(members, map[string]string{"email": email, "role": role})
}
sort.Slice(members, func(i, j int) bool { return members[i]["email"] < members[j]["email"] })
out = append(out, map[string]any{
"id": o.ID, "name": o.Name, "role": o.Members[normEmail(me.Email)],
"members": members, "created": o.Created,
"manage_url": s.Dir.ManageURL(o.ID),
})
}
writeJSON(w, map[string]any{"orgs": out})
}
// writeDirErr answers a failed directory write. A directory that does not own
// its organizations says so with ErrManagedElsewhere, and the answer is 409
// plus the page that does own them — the request was well-formed, it is the
// state of the world that makes it wrong. The hub never learns WHY the write
// was refused, only where to send the user.
func (s *Server) writeDirErr(w http.ResponseWriter, orgID string, err error) {
if errors.Is(err, ErrManagedElsewhere) {
http.Error(w, err.Error()+": "+s.Dir.ManageURL(orgID), http.StatusConflict)
return
}
http.Error(w, err.Error(), http.StatusBadRequest)
}
// requireOwner returns true and the caller's email when they own the org;
// otherwise it writes the error response and returns false.
func (s *Server) requireOwner(w http.ResponseWriter, r *http.Request, orgID string) (string, bool) {
if s.Dir == nil {
http.Error(w, "organizations are not enabled on this server", http.StatusNotFound)
return "", false
}
me := s.requestUser(r)
if s.Dir.Role(orgID, me.Email) != RoleOwner {
http.Error(w, "only an organization owner can do that", http.StatusForbidden)
return "", false
}
return normEmail(me.Email), true
}
// handleOrgRename renames the org. Owners only.
func (s *Server) handleOrgRename(w http.ResponseWriter, r *http.Request) {
orgID := r.PathValue("org")
if _, ok := s.requireOwner(w, r, orgID); !ok {
return
}
var req struct {
Name string `json:"name"`
}
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.Dir.Rename(orgID, req.Name); err != nil {
s.writeDirErr(w, orgID, err)
return
}
writeJSON(w, map[string]any{"ok": true})
}
// handleMemberUpdate changes a member's role. Owners only.
func (s *Server) handleMemberUpdate(w http.ResponseWriter, r *http.Request) {
orgID := r.PathValue("org")
if _, ok := s.requireOwner(w, r, orgID); !ok {
return
}
var req struct {
Role string `json:"role"`
}
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.Dir.SetRole(orgID, r.PathValue("email"), req.Role); err != nil {
s.writeDirErr(w, orgID, err)
return
}
writeJSON(w, map[string]any{"ok": true})
}
// handleMemberRemove drops a member. Owners only.
func (s *Server) handleMemberRemove(w http.ResponseWriter, r *http.Request) {
orgID := r.PathValue("org")
if _, ok := s.requireOwner(w, r, orgID); !ok {
return
}
if err := s.Dir.RemoveMember(orgID, r.PathValue("email")); err != nil {
s.writeDirErr(w, orgID, err)
return
}
writeJSON(w, map[string]any{"ok": true})
}
// handleInviteList shows an org's live invite links. Owners only.
func (s *Server) handleInviteList(w http.ResponseWriter, r *http.Request) {
orgID := r.PathValue("org")
if _, ok := s.requireOwner(w, r, orgID); !ok {
return
}
invs := s.Dir.ListInvites(orgID)
out := make([]map[string]any, 0, len(invs))
for _, inv := range invs {
out = append(out, map[string]any{
"token": inv.Token, "url": requestBaseURL(r) + "/join/" + inv.Token,
"creator": inv.Creator, "created": inv.Created, "expires": inv.Expires, "uses": inv.Uses,
})
}
writeJSON(w, map[string]any{"invites": out})
}
// handleInviteRevoke kills an invite link. Owners only.
func (s *Server) handleInviteRevoke(w http.ResponseWriter, r *http.Request) {
orgID := r.PathValue("org")
if _, ok := s.requireOwner(w, r, orgID); !ok {
return
}
// Confirm the invite belongs to this org before revoking.
inv, ok := s.Dir.Redeem(r.PathValue("token"))
if !ok || inv.Org != orgID {
http.Error(w, "no such invite", http.StatusNotFound)
return
}
s.Dir.RevokeInvite(r.PathValue("token"))
writeJSON(w, map[string]any{"ok": true})
}
// handleInviteCreate mints an invite link. Owners only.
func (s *Server) handleInviteCreate(w http.ResponseWriter, r *http.Request) {
if s.Dir == nil {
http.Error(w, "organizations are not enabled on this server", http.StatusNotFound)
return
}
orgID := r.PathValue("org")
if s.Dir.Role(orgID, s.requestUser(r).Email) != RoleOwner {
http.Error(w, "only an organization owner can invite", http.StatusForbidden)
return
}
var req struct {
ExpiresIn string `json:"expires_in,omitempty"` // Go duration, e.g. "168h"
}
json.NewDecoder(io.LimitReader(r.Body, 1<<16)).Decode(&req) // body is optional
var ttl time.Duration
if req.ExpiresIn != "" {
d, err := time.ParseDuration(req.ExpiresIn)
if err != nil || d <= 0 {
http.Error(w, "invalid expires_in", http.StatusBadRequest)
return
}
ttl = d
}
inv, err := s.Dir.CreateInvite(orgID, s.requestUser(r).Email, ttl)
if err != nil {
s.writeDirErr(w, orgID, err)
return
}
writeJSON(w, map[string]any{
"token": inv.Token,
"url": requestBaseURL(r) + "/join/" + inv.Token,
"expires": inv.Expires,
})
}
// handleInviteAccept joins the signed-in account to the invite's org.
func (s *Server) handleInviteAccept(w http.ResponseWriter, r *http.Request) {
if s.Dir == nil {
http.Error(w, "organizations are not enabled on this server", http.StatusNotFound)
return
}
me := s.requestUser(r)
if me.Email == "" {
http.Error(w, "sign in to accept an invite", http.StatusUnauthorized)
return
}
inv, ok := s.Dir.Redeem(r.PathValue("token"))
if !ok {
http.Error(w, "this invite is invalid or expired", http.StatusNotFound)
return
}
org, _ := s.Dir.Get(inv.Org)
if org.Members[normEmail(me.Email)] == "" {
if err := s.quota().CheckSeat(inv.Org, len(org.Members)); err != nil {
http.Error(w, err.Error(), http.StatusForbidden)
return
}
}
newMember := org.Members[normEmail(me.Email)] == ""
if err := s.Dir.AddMember(inv.Org, me.Email, RoleMember); err != nil {
s.writeDirErr(w, inv.Org, err)
return
}
if newMember {
s.Dir.RecordInviteUse(r.PathValue("token"))
}
writeJSON(w, map[string]any{"ok": true, "org": map[string]string{"id": org.ID, "name": org.Name}})
}