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>
This commit is contained in:
Snow W. Lee (Sungwon)
2026-07-27 10:41:57 +09:00
committed by GitHub
co-authored by Claude Opus 5
parent 1463fd8979
commit 69e7231a70
40 changed files with 1990 additions and 273 deletions
+34 -1
View File
@@ -255,6 +255,38 @@ sweeps its existing projects into a `default` org that all existing
accounts join, so nothing breaks. Public share links stay outside the
wall on purpose.
Inside an org, each project carries its own **permissions** — four ordered
levels, edited under Project settings → People:
| Level | Can |
|---|---|
| `none` | nothing: the project is hidden — absent from the project list, every route denied |
| `read` | browse, view, download, history, read heat — and **pull**, so a device stays current |
| `write` | + upload, sync push, and minting/revoking share links |
| `admin` | + rename, delete, and edit this project's permissions |
The default is `write` for every org member, which is exactly the old
behavior — an upgraded hub changes nothing until someone edits
permissions. Setting the **default** to `No access` makes a project
invite-only: only explicit grants get in. Whoever creates a project becomes
its first admin, and **org owners are implicitly admin on every project in
their org**, so nobody can lock them out. Grants are org members only, and
a project always keeps at least one admin.
Two things follow on the **device** side, because a refusal is not the same
as being offline (see `bdrive status`):
- **read-only** — pushes are refused, so the daemon goes **pull-only**. Your
local edits stay journaled on the device, never pushed and never lost;
they go out if you're granted `write` again.
- **no access** — pulls are refused too, so sync **pauses**. Nothing is
pulled, pushed, or written: revoking access never deletes or reverts a
file on someone's disk. Re-granting resumes on the next tick.
Public `/s/<token>` share links are **unaffected** by any of this: they are
anonymous by design and keep serving until revoked, so cutting someone's
access does not kill links they already minted.
```sh
# On the server device (knows the storage)
bdrive web -c config.json
@@ -289,7 +321,8 @@ blob uploads go direct to the object store via the same short-lived
presigned URLs browser uploads use (falling back to relaying when the
backend can't presign). Client pushes and project creation require the
server to run with `--upload`; against a read-only hub, clients still pull
and their pushes wait (offline semantics) until allowed.
and `bdrive status` reports `access: read-only (pull only)` rather than
pretending to be offline.
### Sharing files by URL
+4 -1
View File
@@ -28,7 +28,9 @@ classDiagram
+LocalOps +PulledOps
+Conflicts +Materialized
+Pushed +Offline +OfflineErr
+ReadOnly +NoAccess +AccessErr
}
note for Result "Offline / ReadOnly / NoAccess are three different answers: unreachable (retry all), push refused (pull-only), pull refused (pause, touch nothing)"
class Filter {
+Skip(rel) bool
@@ -59,8 +61,9 @@ classDiagram
class Backend {
<<interface>>
+Put +Get +List +Exists +Close
+ErrForbidden sentinel
}
note for Backend "internal/remote — client devices use the https:// hub backend (token from BDRIVE_TOKEN / settings.json)"
note for Backend "internal/remote — client devices use the https:// hub backend (token from BDRIVE_TOKEN / settings.json); a hub 403 wraps ErrForbidden, which is what Result turns into ReadOnly/NoAccess instead of Offline"
class daemon {
+Run(folder, scan, remote)
+20
View File
@@ -121,11 +121,26 @@ classDiagram
-repo ProjectRepo
-byID
+Get +Create +Update +Rename +List
+SetCreator +SetDefault
+SetPerm +ClearPerm
}
class Project {
+ID +Name +Org +Created
+Description +Icon
+Creator string
+Default string
+Perms map email→level
}
note for Project "Default == \"\" means write — the historical behavior, so an upgraded hub needs no migration. SetPerm/ClearPerm refuse to drop the last explicit admin."
class projectPerm {
<<resolver>>
org owner → admin
explicit grant → that level
org member → project Default
otherwise → none
}
note for projectPerm "perms.go — the single authorization ladder. proj(level, h) in server.go is the one choke point: every per-project route declares its level at registration."
class ShareDB {
-repo ShareRepo
@@ -198,6 +213,9 @@ classDiagram
BuiltinAuth ..> OrgDB : InviteValid wiring
ProjectDB ..> Project
Server *-- projectPerm : gates every per-project route
projectPerm ..> Project : Perms + Default
projectPerm ..> Directory : org role
ShareDB ..> Share
DeviceRegistry ..> DeviceInfo
ReadLedger ..> ReadStat
@@ -257,7 +275,9 @@ classDiagram
class sqlMetaStore {
one database/sql impl
sqlite (modernc) or postgres (pgx)
+addColumns() idempotent ALTER
}
note for sqlMetaStore "ProjectRepo.Put is transactional over projects + project_perms (same shape as orgs + org_members); addColumns probes the live column set so a running hub gains projects.creator / default_level on restart."
MetaStore <|.. fileMetaStore
MetaStore <|.. sqlMetaStore
+7
View File
@@ -9,6 +9,7 @@ import (
"github.com/runbear-io/beardrive/internal/config"
"github.com/runbear-io/beardrive/internal/daemon"
"github.com/runbear-io/beardrive/internal/journal"
"github.com/runbear-io/beardrive/internal/store"
"github.com/runbear-io/beardrive/internal/syncer"
)
@@ -167,6 +168,12 @@ func statusCmd() *cobra.Command {
pending = 0
}
fmt.Printf(" pending: %d local change(s) not yet pushed\n", pending)
switch st.Access {
case store.AccessReadOnly:
fmt.Printf(" access: read-only (pull only) — %d local change(s) stay on this device\n", pending)
case store.AccessNone:
fmt.Printf(" access: no access to this project — sync paused\n")
}
}
}
return nil
+4
View File
@@ -124,6 +124,10 @@ func printCycle(res *syncer.Result) {
}
fmt.Printf(" files updated: %d\n", res.Materialized)
switch {
case res.NoAccess:
fmt.Printf(" remote: no access — sync paused (ask a project admin for access)\n")
case res.ReadOnly:
fmt.Printf(" remote: read-only (pull only) — local changes stay on this device\n")
case res.Offline:
fmt.Printf(" remote: offline (%v)\n", res.OfflineErr)
case res.Pushed:
+22
View File
@@ -145,6 +145,9 @@ func Run(folder string, scanInterval, remoteInterval time.Duration) error {
}()
var lastRemote time.Time
var lastToken string
// Which access state we last logged, so a degraded daemon says it once
// instead of on every tick.
lastAccess := store.AccessOK
for {
// Re-read the project config each tick: picks up `bdrive remote set`
@@ -216,6 +219,21 @@ func Run(folder string, scanInterval, remoteInterval time.Duration) error {
return nil
case err != nil:
log.Printf("cycle error: %v", err)
case res.NoAccess:
// The connection is fine, the answer isn't: keep the backend and
// keep ticking cheaply so a re-grant self-heals. Log the
// transition only — a paused daemon must stay quiet.
if lastAccess != store.AccessNone {
log.Printf("access revoked for this project; sync paused (%v)", res.AccessErr)
lastAccess = store.AccessNone
}
lastRemote = time.Now()
case res.ReadOnly:
if lastAccess != store.AccessReadOnly {
log.Printf("read-only on this project, pulling only; local changes stay on this device")
lastAccess = store.AccessReadOnly
}
lastRemote = time.Now()
case res.Offline:
log.Printf("offline, will retry: %v", res.OfflineErr)
if be != nil {
@@ -224,6 +242,10 @@ func Run(folder string, scanInterval, remoteInterval time.Duration) error {
}
lastRemote = time.Now()
default:
if lastAccess != store.AccessOK {
log.Printf("access restored; syncing normally")
lastAccess = store.AccessOK
}
if res.Activity() {
log.Printf("local+%d pulled+%d conflicts=%d files~%d pushed=%v",
res.LocalOps, res.PulledOps, res.Conflicts, res.Materialized, res.Pushed)
+14 -3
View File
@@ -89,10 +89,16 @@ func (b *httpBackend) endpoint(name string, q url.Values) string {
}
// httpError turns a non-2xx response into an error carrying the server's
// message.
// message. A 403 additionally wraps ErrForbidden: only the hub's own
// endpoints go through here, so that status is always an authorization
// answer, never a storage hiccup.
func httpError(resp *http.Response) error {
msg, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
return fmt.Errorf("server: %s: %s", resp.Status, strings.TrimSpace(string(msg)))
err := fmt.Errorf("server: %s: %s", resp.Status, strings.TrimSpace(string(msg)))
if resp.StatusCode == http.StatusForbidden {
return fmt.Errorf("%w: %w", ErrForbidden, err)
}
return err
}
func (b *httpBackend) List(ctx context.Context, prefix string) ([]Object, error) {
@@ -225,7 +231,12 @@ func (b *httpBackend) putDirect(ctx context.Context, plan putPlan, r io.Reader,
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return fmt.Errorf("direct upload: %w", httpError(resp))
// Deliberately not httpError: this response comes from the object
// store, not the hub, and its 403 means an expired presigned URL —
// mapping it to ErrForbidden would park the device in permanent
// read-only over a transient signing problem.
msg, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
return fmt.Errorf("direct upload: %s: %s", resp.Status, strings.TrimSpace(string(msg)))
}
return nil
}
+86
View File
@@ -0,0 +1,86 @@
package remote
import (
"bytes"
"context"
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
// Every hub endpoint must turn a 403 into ErrForbidden: that sentinel is what
// tells the syncer "you were refused" rather than "the network is down", and a
// miss on any one call would put that path back into a silent forever-retry.
func TestHubForbiddenIsSentinel(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Error(w, "you have read-only access to this project", http.StatusForbidden)
}))
defer ts.Close()
be, err := Open(context.Background(), ts.URL+"/p/p-0123abcd")
if err != nil {
t.Fatal(err)
}
defer be.Close()
ctx := context.Background()
calls := map[string]func() error{
"List": func() error { _, err := be.List(ctx, "journal/"); return err },
"Get": func() error { _, err := be.Get(ctx, "journal/d.jsonl"); return err },
"Exists": func() error { _, err := be.Exists(ctx, "journal/d.jsonl"); return err },
"Put": func() error { return be.Put(ctx, "blobs/abc", strings.NewReader("hi"), 2) },
}
for name, call := range calls {
err := call()
if err == nil {
t.Errorf("%s: no error on 403", name)
continue
}
if !errors.Is(err, ErrForbidden) {
t.Errorf("%s: %v does not wrap ErrForbidden", name, err)
}
if !strings.Contains(err.Error(), "read-only") {
t.Errorf("%s: the server's message is lost: %v", name, err)
}
}
if rr, ok := be.(ReadReporter); ok {
if err := rr.ReportReads(ctx, []ReadEvent{{Path: "a.md"}}); !errors.Is(err, ErrForbidden) {
t.Errorf("ReportReads: %v does not wrap ErrForbidden", err)
}
}
}
// A 403 relayed from the object store is an expired presigned URL, not an
// authorization answer. Mapping it would park a healthy device in permanent
// read-only over a transient signing problem.
func TestPresignedForbiddenIsNotAuthz(t *testing.T) {
storage := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Error(w, "<Error><Code>AccessDenied</Code></Error>", http.StatusForbidden)
}))
defer storage.Close()
hub := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !strings.HasSuffix(r.URL.Path, "/store/sign") {
http.Error(w, "unexpected", http.StatusNotFound)
return
}
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"mode":"direct","exists":false,"url":"` + storage.URL + `/blob","method":"PUT"}`))
}))
defer hub.Close()
be, err := Open(context.Background(), hub.URL+"/p/p-0123abcd")
if err != nil {
t.Fatal(err)
}
defer be.Close()
err = be.Put(context.Background(), "blobs/abc", bytes.NewReader([]byte("hi")), 2)
if err == nil {
t.Fatal("direct upload to a 403 target should fail")
}
if errors.Is(err, ErrForbidden) {
t.Fatalf("a presigned-target 403 must not be ErrForbidden: %v", err)
}
}
+7
View File
@@ -15,6 +15,7 @@ package remote
import (
"context"
"errors"
"fmt"
"io"
"net/url"
@@ -22,6 +23,12 @@ import (
"time"
)
// ErrForbidden marks a refusal by the hub's authorization — the device asked
// correctly and was told no, which is a different thing from being offline.
// The syncer keys its degraded states off it: a refused push means read-only
// (keep pulling), a refused pull means access is gone (pause, touch nothing).
var ErrForbidden = errors.New("forbidden")
type Object struct {
Key string
Size int64
+13 -2
View File
@@ -181,9 +181,20 @@ func (s *Store) SaveCache(mountID string, c map[string]CachedFile) error {
// ---- sync state (sync.json) ----
// Access records how the hub answered this device on the last cycle that
// reached it. It is persisted so `bdrive status` — which never runs a cycle —
// can report a degraded state, and so the daemon can log a transition once
// instead of on every tick.
const (
AccessOK = "" // normal read+write sync
AccessReadOnly = "read-only" // pushes refused: pull-only
AccessNone = "no-access" // pulls refused too: sync paused
)
type SyncState struct {
Lamport int64 `json:"lamport"`
PushedOps int64 `json:"pushed_ops"` // how many of our own ops the remote has
Lamport int64 `json:"lamport"`
PushedOps int64 `json:"pushed_ops"` // how many of our own ops the remote has
Access string `json:"access,omitempty"` // "", "read-only", or "no-access"
}
func (s *Store) LoadSync() (SyncState, error) {
+5 -3
View File
@@ -80,7 +80,9 @@ func TestSyncThroughWebServer(t *testing.T) {
}
// With uploads disabled on the server, a client can still pull (read-only
// follower) — its pushes degrade to offline instead of failing the cycle.
// follower) — its pushes report ReadOnly instead of failing the cycle. Not
// Offline: the server answered, it just said no, and retrying forever as if
// the network were down would hide that from the user.
func TestReadOnlyServerClientStillPulls(t *testing.T) {
storage := sharedRemote(t)
ts, p := newHub(t, storage, false) // read-only hub
@@ -101,8 +103,8 @@ func TestReadOnlyServerClientStillPulls(t *testing.T) {
if err != nil {
t.Fatal(err)
}
if !res.Offline {
t.Fatalf("push against read-only server should degrade to offline: %+v", res)
if !res.ReadOnly || res.Offline {
t.Fatalf("push against a read-only server should report ReadOnly, not Offline: %+v", res)
}
if read(t, a.Folder, "shared.md") != "server-side truth" {
t.Fatal("client should still pull from a read-only server")
+3 -3
View File
@@ -115,7 +115,7 @@ func signupDeviceToken(t *testing.T, ts *httptest.Server, email, name string) st
}
// A device signed in to the wrong org can neither push into nor pull from a
// project: sync degrades to offline (never partial access) and no data
// project: sync pauses with NoAccess (never partial access) and no data
// crosses the wall in either direction.
func TestOrgWallsDeviceSync(t *testing.T) {
storage := sharedRemote(t)
@@ -147,8 +147,8 @@ func TestOrgWallsDeviceSync(t *testing.T) {
if err != nil {
t.Fatal(err)
}
if !res.Offline {
t.Fatal("cross-org sync must degrade to offline, not succeed")
if !res.NoAccess || res.Offline {
t.Fatalf("cross-org sync must pause with NoAccess, not succeed or look offline: %+v", res)
}
if _, err := os.Stat(filepath.Join(b.Folder, "secret.md")); err == nil {
t.Fatal("org A's file leaked to a device in org B")
+47 -7
View File
@@ -14,6 +14,7 @@ import (
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"io"
"io/fs"
@@ -79,6 +80,13 @@ func (s *Session) mountID() string {
}
// Result summarizes one sync cycle.
//
// Offline, ReadOnly, and NoAccess are three different answers and must not be
// conflated: offline means the hub could not be reached and everything should
// be retried; ReadOnly means it refused our push (we keep pulling, local ops
// stay journaled and unpushed); NoAccess means it refused our pull too, so the
// cycle does nothing at all and leaves the working folder alone. Regaining
// access self-heals on a later cycle with no manual step.
type Result struct {
LocalOps int // local changes committed to the journal
PulledOps int // ops received from other devices
@@ -87,6 +95,9 @@ type Result struct {
Pushed bool // own journal/blobs uploaded
Offline bool // remote configured but unreachable this cycle
OfflineErr error
ReadOnly bool // the hub refused our push: pull-only from here
NoAccess bool // the hub refused our pull: sync paused, nothing touched
AccessErr error
}
func (r *Result) Activity() bool {
@@ -150,7 +161,17 @@ func (s *Session) Cycle(ctx context.Context) (*Result, error) {
var pulled []journal.Op
if s.Backend != nil {
pulled, err = s.pull(ctx)
if err != nil {
switch {
case err == nil:
case errors.Is(err, remote.ErrForbidden):
// Access to this project was revoked. Stop here: materializing a
// replay we can no longer refresh would look like the hub
// reverting the user's files. Nothing is pushed, nothing is
// deleted, and the next cycle re-checks.
res.NoAccess, res.AccessErr = true, err
st.Access = store.AccessNone
return res, s.finish(cache, st)
default:
res.Offline = true
res.OfflineErr = err
}
@@ -191,11 +212,19 @@ func (s *Session) Cycle(ctx context.Context) (*Result, error) {
// 5. Push our blobs and journal.
if s.Backend != nil && !res.Offline && int64(len(myOps)) > st.PushedOps {
if err := s.push(ctx, myOps, &st); err != nil {
switch err := s.push(ctx, myOps, &st); {
case err == nil:
res.Pushed = true
case errors.Is(err, remote.ErrForbidden):
// Read-only on this project: pull and materialize already ran, so
// pull-only is the steady state. Our own ops stay in the local
// journal — never pushed, never dropped. The push is still
// attempted once per remote interval (no hot loop, and a re-grant
// self-heals).
res.ReadOnly, res.AccessErr = true, err
default:
res.Offline = true
res.OfflineErr = err
} else {
res.Pushed = true
}
}
@@ -214,15 +243,26 @@ func (s *Session) Cycle(ctx context.Context) (*Result, error) {
}
}
if err := s.Store.SaveCache(s.mountID(), cache); err != nil {
return nil, err
st.Access = store.AccessOK
if res.ReadOnly {
st.Access = store.AccessReadOnly
}
if err := s.Store.SaveSync(st); err != nil {
if err := s.finish(cache, st); err != nil {
return nil, err
}
return res, nil
}
// finish persists the two pieces of state a cycle mutates. Saving the cache
// matters even on a cut-short cycle: the scan already journaled local edits,
// and dropping the cache would make the next scan journal them all again.
func (s *Session) finish(cache map[string]store.CachedFile, st store.SyncState) error {
if err := s.Store.SaveCache(s.mountID(), cache); err != nil {
return err
}
return s.Store.SaveSync(st)
}
// scan diffs the working folder against the state cache and returns ops for
// every local change, storing new content in the blob store. Filtered paths
// are neither journaled nor deleted: a path that becomes ignored is dropped
+194
View File
@@ -3,10 +3,14 @@ package syncer
import (
"context"
"fmt"
"io"
"io/fs"
"maps"
"os"
"path/filepath"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
@@ -382,3 +386,193 @@ func TestNestedMountExcluded(t *testing.T) {
t.Fatalf("b readme.md = %q, want root v2", got)
}
}
// gated wraps a backend and refuses the operations the hub would refuse for a
// given permission level, with the same sentinel the http backend produces.
// The flags are read on every call so a test can revoke (or restore) access
// mid-run, which is exactly the case that used to look like a network fault.
type gated struct {
remote.Backend
read *atomic.Bool // pulls allowed (List/Get)
write *atomic.Bool // pushes allowed (Put)
}
func newGated(be remote.Backend) *gated {
g := &gated{Backend: be, read: &atomic.Bool{}, write: &atomic.Bool{}}
g.read.Store(true)
g.write.Store(true)
return g
}
func (g *gated) List(ctx context.Context, prefix string) ([]remote.Object, error) {
if !g.read.Load() {
return nil, fmt.Errorf("%w: server: 403 Forbidden", remote.ErrForbidden)
}
return g.Backend.List(ctx, prefix)
}
func (g *gated) Get(ctx context.Context, key string) (io.ReadCloser, error) {
if !g.read.Load() {
return nil, fmt.Errorf("%w: server: 403 Forbidden", remote.ErrForbidden)
}
return g.Backend.Get(ctx, key)
}
func (g *gated) Put(ctx context.Context, key string, r io.Reader, size int64) error {
if !g.write.Load() {
return fmt.Errorf("%w: server: 403 Forbidden", remote.ErrForbidden)
}
return g.Backend.Put(ctx, key, r, size)
}
// A read-only device keeps pulling its teammates' changes and journals its own
// edits locally, but nothing of its own ever reaches the remote — and the
// cycle says ReadOnly, never Offline, so the user is told rather than left
// watching a silent retry loop.
func TestReadOnlyDevicePullsOnly(t *testing.T) {
be := sharedRemote(t)
a := newDevice(t, "deva", be)
gate := newGated(be)
b := newDevice(t, "devb", gate)
write(t, a.Folder, "shared.md", "from A")
cycle(t, a)
gate.write.Store(false) // B is downgraded to read
write(t, b.Folder, "mine.md", "local only")
res, err := b.Cycle(context.Background())
if err != nil {
t.Fatal(err)
}
if !res.ReadOnly || res.Offline {
t.Fatalf("read-only push: %+v, want ReadOnly and not Offline", res)
}
if got := read(t, b.Folder, "shared.md"); got != "from A" {
t.Fatalf("b shared.md = %q — a read-only device must still pull", got)
}
// B's own edit is journaled locally...
ops, err := b.Store.DeviceOps(b.Device.ID)
if err != nil {
t.Fatal(err)
}
if len(ops) != 1 || ops[0].Path != "mine.md" {
t.Fatalf("b's local journal = %+v, want one op for mine.md", ops)
}
// ...and never lands in the shared remote, however many cycles run.
for i := 0; i < 3; i++ {
if _, err := b.Cycle(context.Background()); err != nil {
t.Fatal(err)
}
}
c := newDevice(t, "devc", be)
cycle(t, c)
if _, err := os.Stat(filepath.Join(c.Folder, "mine.md")); !os.IsNotExist(err) {
t.Fatal("a read-only device's edit reached the remote")
}
// The state is persisted so `bdrive status` can report it without a cycle.
if st, err := b.Store.LoadSync(); err != nil || st.Access != store.AccessReadOnly {
t.Fatalf("persisted access = %q (%v), want read-only", st.Access, err)
}
// Restoring write self-heals: the held-back op finally goes out.
gate.write.Store(true)
if res := cycle(t, b); !res.Pushed {
t.Fatalf("re-granted device did not push: %+v", res)
}
cycle(t, c)
if got := read(t, c.Folder, "mine.md"); got != "local only" {
t.Fatalf("c mine.md = %q after the re-grant", got)
}
if st, _ := b.Store.LoadSync(); st.Access != store.AccessOK {
t.Fatalf("persisted access = %q after re-grant, want cleared", st.Access)
}
}
// A device whose access is revoked entirely pauses: the cycle reports
// NoAccess (not Offline), the working folder is left byte-for-byte alone —
// revoking access must never look like the hub deleting someone's files — and
// re-granting resumes normal sync with no manual step.
func TestNoAccessPausesSync(t *testing.T) {
be := sharedRemote(t)
a := newDevice(t, "deva", be)
gate := newGated(be)
c := newDevice(t, "devc", gate)
write(t, a.Folder, "doc.md", "v1")
cycle(t, a)
cycle(t, c)
if got := read(t, c.Folder, "doc.md"); got != "v1" {
t.Fatalf("c doc.md = %q, want v1", got)
}
// A moves on while C's access is cut.
write(t, a.Folder, "doc.md", "v2")
write(t, a.Folder, "new.md", "after the cut")
cycle(t, a)
gate.read.Store(false)
gate.write.Store(false)
before := snapshotDir(t, c.Folder)
write(t, c.Folder, "cs-own.md", "written while cut off")
before["cs-own.md"] = "written while cut off"
for i := 0; i < 3; i++ {
res, err := c.Cycle(context.Background())
if err != nil {
t.Fatal(err)
}
if !res.NoAccess || res.Offline {
t.Fatalf("cycle %d: %+v, want NoAccess and not Offline", i, res)
}
if res.Materialized != 0 || res.Pushed {
t.Fatalf("cycle %d touched the folder or pushed: %+v", i, res)
}
}
if got := snapshotDir(t, c.Folder); !maps.Equal(got, before) {
t.Fatalf("working folder changed while access was revoked:\n got %v\nwant %v", got, before)
}
if st, _ := c.Store.LoadSync(); st.Access != store.AccessNone {
t.Fatalf("persisted access = %q, want no-access", st.Access)
}
// Re-granting needs no intervention: the next cycle converges both ways.
gate.read.Store(true)
gate.write.Store(true)
cycle(t, c)
if got := read(t, c.Folder, "doc.md"); got != "v2" {
t.Fatalf("c doc.md = %q after the re-grant, want v2", got)
}
if got := read(t, c.Folder, "new.md"); got != "after the cut" {
t.Fatalf("c new.md = %q after the re-grant", got)
}
cycle(t, a)
if got := read(t, a.Folder, "cs-own.md"); got != "written while cut off" {
t.Fatalf("a cs-own.md = %q — C's held-back edit should arrive", got)
}
}
// snapshotDir reads every file under folder (excluding .bdrive) as path→content.
func snapshotDir(t *testing.T, folder string) map[string]string {
t.Helper()
out := map[string]string{}
err := filepath.WalkDir(folder, func(p string, d fs.DirEntry, err error) error {
if err != nil || d.IsDir() {
return err
}
rel, _ := filepath.Rel(folder, p)
rel = filepath.ToSlash(rel)
if strings.HasPrefix(rel, config.ProjectDir+"/") {
return nil
}
b, err := os.ReadFile(p)
if err != nil {
return err
}
out[rel] = string(b)
return nil
})
if err != nil {
t.Fatal(err)
}
return out
}
+9 -38
View File
@@ -12,34 +12,14 @@ import (
// operable — an admin can offboard, clean up, and audit — without editing
// JSON files on the server by hand.
// 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.Dir == nil || s.Auth == nil {
return true
}
org := s.orgOf(projectID)
if org == "" {
return true
}
return s.Dir.Role(org, s.requestUser(r).Email) == RoleOwner
}
// handleProjectUpdate edits a project's name, description and icon. Owner 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.
// 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 s.Projects == nil {
http.Error(w, "this server does not host projects", http.StatusNotFound)
return
}
if _, ok := s.Projects.Get(id); !ok || !s.projectAllowed(r, id) {
http.Error(w, "no such project", http.StatusNotFound)
return
}
if !s.projectOwner(r, id) {
http.Error(w, "only an organization owner can rename a project", http.StatusForbidden)
if _, ok := s.project(w, r, id, PermAdmin); !ok {
return
}
var req struct {
@@ -58,20 +38,11 @@ func (s *Server) handleProjectUpdate(w http.ResponseWriter, r *http.Request) {
writeJSON(w, map[string]any{"ok": true})
}
// handleProjectDelete removes a project from the registry. Owner only.
// Storage (blobs, journals) is intentionally left in place.
// 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 s.Projects == nil {
http.Error(w, "this server does not host projects", http.StatusNotFound)
return
}
if _, ok := s.Projects.Get(id); !ok || !s.projectAllowed(r, id) {
http.Error(w, "no such project", http.StatusNotFound)
return
}
if !s.projectOwner(r, id) {
http.Error(w, "only an organization owner can delete a project", http.StatusForbidden)
if _, ok := s.project(w, r, id, PermAdmin); !ok {
return
}
if err := s.Projects.Delete(id); err != nil {
+81 -3
View File
@@ -134,9 +134,26 @@ func TestMetaStoreConformance(t *testing.T) {
t.Fatal(err)
}
p2, _, _ := projects.GetOrCreate("scratch", "o-1")
if err := projects.SetPerm(p2.ID, "doomed@x.io", PermAdmin); err != nil {
t.Fatal(err)
}
if err := projects.Delete(p2.ID); err != nil {
t.Fatal(err)
}
// per-project permissions ride along with the project record
if err := projects.SetCreator(p1.ID, "Boss@X.io"); err != nil {
t.Fatal(err)
}
if err := projects.SetDefault(p1.ID, PermNone); err != nil {
t.Fatal(err)
}
for email, level := range map[string]string{
"boss@x.io": PermAdmin, "reader@x.io": PermRead, "cutoff@x.io": PermNone,
} {
if err := projects.SetPerm(p1.ID, email, level); err != nil {
t.Fatal(err)
}
}
orgs, err := NewOrgDB(st.Orgs())
if err != nil {
@@ -225,12 +242,23 @@ func TestMetaStoreConformance(t *testing.T) {
if !ok || hb.Name != "handbook" {
t.Fatalf("rename lost across reload: %+v", hb)
}
// Description/icon are the columns migrate() has to ADD to an
// already-created projects table; the reopen above already ran
// migrate() a second time, so surviving here proves it's a no-op.
// Description/icon and creator/default_level are all columns
// migrate() has to ADD to an already-created projects table; the
// reopen above already ran migrate() a second time, so surviving
// here proves it's a no-op.
if hb.Description != "everything support needs" || hb.Icon != "book-open" {
t.Fatalf("description/icon lost across reload: %+v", hb)
}
if hb.Creator != "boss@x.io" || hb.Default != PermNone {
t.Fatalf("creator/default lost across reload: %+v", hb)
}
if hb.Perms["boss@x.io"] != PermAdmin || hb.Perms["reader@x.io"] != PermRead ||
hb.Perms["cutoff@x.io"] != PermNone || len(hb.Perms) != 3 {
t.Fatalf("grants lost across reload: %+v", hb.Perms)
}
if _, ok := projects2.Get(p2.ID); ok {
t.Fatal("deleted project (and its grants) came back after reload")
}
orgs2, _ := NewOrgDB(st2.Orgs())
ro, ok := orgs2.Get(org.ID)
@@ -275,3 +303,53 @@ func TestMetaStoreConformance(t *testing.T) {
})
}
}
// migrate() only ever created tables, so the columns permissions added need a
// real ALTER on a hub that is already running. Prove both halves: an old
// projects table gains them (with its rows intact), and migrating again is a
// no-op rather than an error.
func TestSQLMigrateAddsPermissionColumns(t *testing.T) {
path := filepath.Join(t.TempDir(), "old.db")
old, err := sql.Open("sqlite", path)
if err != nil {
t.Fatal(err)
}
// the pre-permissions schema, verbatim
if _, err := old.Exec(`CREATE TABLE projects (
id TEXT PRIMARY KEY, name TEXT NOT NULL, org TEXT NOT NULL DEFAULT '',
created TEXT NOT NULL DEFAULT '')`); err != nil {
t.Fatal(err)
}
if _, err := old.Exec(`INSERT INTO projects (id,name,org,created) VALUES ('p-0000abcd','wiki','o-1','')`); err != nil {
t.Fatal(err)
}
old.Close()
for i := 0; i < 2; i++ { // opening twice re-runs migrate()
st, err := OpenSQLStore("sqlite", path)
if err != nil {
t.Fatalf("open %d: %v", i, err)
}
projects, err := NewProjectDB(st.Projects())
if err != nil {
t.Fatalf("load %d: %v", i, err)
}
p, ok := projects.Get("p-0000abcd")
if !ok || p.Name != "wiki" {
t.Fatalf("pre-existing row lost on upgrade: %+v", p)
}
// An upgraded row has no creator and an empty default, which reads as
// write — the whole "no behavior change on upgrade" promise.
if p.Creator != "" || p.Default != "" || p.level() != PermWrite {
t.Fatalf("upgraded row = %+v, want empty creator/default reading as write", p)
}
if i == 0 {
if err := projects.SetPerm(p.ID, "a@x.io", PermRead); err != nil {
t.Fatal(err)
}
} else if p.Perms["a@x.io"] != PermRead {
t.Fatalf("grant lost across reopen: %+v", p.Perms)
}
st.Close()
}
}
+85 -12
View File
@@ -161,6 +161,9 @@ func (s *sqlMetaStore) migrate() error {
kind TEXT NOT NULL, actor TEXT NOT NULL,
count INTEGER NOT NULL DEFAULT 0, last TEXT NOT NULL DEFAULT '',
PRIMARY KEY (project, path, day, kind, actor))`,
`CREATE TABLE IF NOT EXISTS project_perms (
project TEXT NOT NULL, email TEXT NOT NULL, level TEXT NOT NULL,
PRIMARY KEY (project, email))`,
}
for _, st := range stmts {
if _, err := s.db.Exec(st); err != nil {
@@ -170,8 +173,10 @@ func (s *sqlMetaStore) migrate() error {
// Columns added after the tables shipped. CREATE TABLE IF NOT EXISTS does
// nothing for an existing table, so these need a real (idempotent) ALTER.
return s.addColumns("projects", map[string]string{
"description": `TEXT NOT NULL DEFAULT ''`,
"icon": `TEXT NOT NULL DEFAULT ''`,
"description": `TEXT NOT NULL DEFAULT ''`,
"icon": `TEXT NOT NULL DEFAULT ''`,
"creator": `TEXT NOT NULL DEFAULT ''`,
"default_level": `TEXT NOT NULL DEFAULT ''`,
})
}
@@ -293,33 +298,101 @@ func (r *sqlAccountRepo) PutPolicy(p authPolicy) error {
type sqlProjectRepo struct{ s *sqlMetaStore }
func (r *sqlProjectRepo) Load() ([]Project, error) {
rows, err := r.s.db.Query(`SELECT id, name, org, created, description, icon FROM projects`)
rows, err := r.s.db.Query(
`SELECT id, name, org, created, description, icon, creator, default_level FROM projects`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []Project
byID := map[string]*Project{}
var order []string
for rows.Next() {
var p Project
var created string
if err := rows.Scan(&p.ID, &p.Name, &p.Org, &created, &p.Description, &p.Icon); err != nil {
if err := rows.Scan(&p.ID, &p.Name, &p.Org, &created,
&p.Description, &p.Icon, &p.Creator, &p.Default); err != nil {
rows.Close()
return nil, err
}
p.Created = tdec(created)
out = append(out, p)
byID[p.ID] = &p
order = append(order, p.ID)
}
return out, rows.Err()
rows.Close()
if err := rows.Err(); err != nil {
return nil, err
}
rows, err = r.s.db.Query(`SELECT project, email, level FROM project_perms`)
if err != nil {
return nil, err
}
for rows.Next() {
var project, email, level string
if err := rows.Scan(&project, &email, &level); err != nil {
rows.Close()
return nil, err
}
if p := byID[project]; p != nil {
if p.Perms == nil {
p.Perms = map[string]string{}
}
p.Perms[email] = level
}
}
rows.Close()
if err := rows.Err(); err != nil {
return nil, err
}
out := make([]Project, 0, len(order))
for _, id := range order {
out = append(out, *byID[id])
}
return out, nil
}
// Put writes the project and replaces its grants in one transaction — same
// shape as PutOrg over orgs/org_members.
func (r *sqlProjectRepo) Put(p Project) error {
return r.s.exec(`INSERT INTO projects (id,name,org,created,description,icon) VALUES (?,?,?,?,?,?)
tx, err := r.s.db.Begin()
if err != nil {
return err
}
defer tx.Rollback()
if _, err := tx.Exec(r.s.q(
`INSERT INTO projects (id,name,org,created,description,icon,creator,default_level)
VALUES (?,?,?,?,?,?,?,?)
ON CONFLICT(id) DO UPDATE SET name=excluded.name, org=excluded.org, created=excluded.created,
description=excluded.description, icon=excluded.icon`,
p.ID, p.Name, p.Org, tenc(p.Created), p.Description, p.Icon)
description=excluded.description, icon=excluded.icon,
creator=excluded.creator, default_level=excluded.default_level`),
p.ID, p.Name, p.Org, tenc(p.Created), p.Description, p.Icon, p.Creator, p.Default); err != nil {
return err
}
if _, err := tx.Exec(r.s.q(`DELETE FROM project_perms WHERE project = ?`), p.ID); err != nil {
return err
}
for email, level := range p.Perms {
if _, err := tx.Exec(r.s.q(`INSERT INTO project_perms (project,email,level) VALUES (?,?,?)`),
p.ID, email, level); err != nil {
return err
}
}
return tx.Commit()
}
func (r *sqlProjectRepo) Delete(id string) error {
return r.s.exec(`DELETE FROM projects WHERE id = ?`, id)
tx, err := r.s.db.Begin()
if err != nil {
return err
}
defer tx.Rollback()
if _, err := tx.Exec(r.s.q(`DELETE FROM project_perms WHERE project = ?`), id); err != nil {
return err
}
if _, err := tx.Exec(r.s.q(`DELETE FROM projects WHERE id = ?`), id); err != nil {
return err
}
return tx.Commit()
}
// ---- orgs (+ members, + invites) ----
+13 -2
View File
@@ -29,6 +29,7 @@ const (
e2eAdmin = "e2e@example.com"
e2eMember = "member@example.com"
e2eSolo = "solo@example.com"
e2eReader = "reader@example.com" // org member with a read-only grant on "wiki"
e2ePassword = "e2e-pass-1"
)
@@ -85,6 +86,9 @@ func TestE2EServe(t *testing.T) {
if _, err := auth.signup(e2eSolo, "E2E Solo", e2ePassword); err != nil {
t.Fatal(err)
}
if _, err := auth.signup(e2eReader, "E2E Reader", e2ePassword); err != nil {
t.Fatal(err)
}
auth.Admins = map[string]bool{e2eAdmin: true}
srv.Auth = auth
@@ -96,12 +100,19 @@ func TestE2EServe(t *testing.T) {
if err != nil {
t.Fatal(err)
}
if err := orgs.AddMember(org.ID, e2eMember, RoleMember); err != nil {
t.Fatal(err)
for _, m := range []string{e2eMember, e2eReader} {
if err := orgs.AddMember(org.ID, m, RoleMember); err != nil {
t.Fatal(err)
}
}
if err := db.SetOrg(p.ID, org.ID); err != nil {
t.Fatal(err)
}
// One member cut back to read: the suite checks that write affordances
// are absent for them, not merely that the server would 403.
if err := db.SetPerm(p.ID, e2eReader, PermRead); err != nil {
t.Fatal(err)
}
srv.Dir = LocalDirectory{OrgDB: orgs}
auth.InviteValid = orgs.ValidInvite
+2
View File
@@ -2,6 +2,8 @@ import { Page } from "@playwright/test";
export const ADMIN = "e2e@example.com";
export const MEMBER = "member@example.com";
// Org member cut back to read-only on "wiki" by the seeded harness.
export const READER = "reader@example.com";
export const PASSWORD = "e2e-pass-1";
// One real form login per identity per run, then the session cookie is
+38 -1
View File
@@ -1,5 +1,5 @@
import { test, expect } from "@playwright/test";
import { login, wikiId, MEMBER, expectToast } from "./helpers";
import { login, wikiId, MEMBER, READER, expectToast } from "./helpers";
// Phase 3: project home (connect guide + embedded insights), the dedicated
// insights route, and the history views. Ports the original parity checks
@@ -294,3 +294,40 @@ test("project settings: delete needs the exact name typed, then navigates away",
await expect(page).not.toHaveURL(new RegExp(pid));
await expect(page.locator("#projects .row .label", { hasText: "condemned" })).toHaveCount(0);
});
test("project settings: People shows the default level and the grants", async ({ page }) => {
await login(page);
const pid = await wikiId(page);
await page.goto(`/${pid}/settings`);
const people = page.locator(".ps-people");
await expect(people).toBeVisible();
// An admin gets live controls...
await expect(people.locator('select[aria-label="Default access for workspace members"]')).toBeEnabled();
await expect(people.locator("button", { hasText: "+ Add" })).toBeVisible();
// ...the seeded read-only member is listed as an exception...
await expect(people.locator(`select[aria-label="Access for ${READER}"]`)).toHaveValue("read");
// ...and the workspace owner is shown as permanently admin, not editable.
await expect(people.locator(".admin-item", { hasText: "Workspace owner" })).toBeVisible();
});
test("a read-only member: no Share, no danger zone, People is read-only", async ({ page }) => {
await login(page, READER);
const pid = await wikiId(page);
// The project is fully visible and browsable.
await page.goto(`/${pid}/index.md`);
await expect(page.locator("#content h1")).toHaveText("Wiki");
// ...but nothing that writes is offered.
await expect(page.locator("#share-btn")).toHaveCount(0);
await page.goto(`/${pid}/settings`);
await expect(page.locator(".project-settings h2")).toContainText("wiki");
await expect(page.locator(".ps-chip")).toHaveText("Read-only");
await expect(page.locator(".ps-danger")).toHaveCount(0);
// The People table is shown, disabled — same layout, no controls.
await expect(page.locator(".ps-people")).toBeVisible();
await expect(
page.locator('.ps-people select[aria-label="Default access for workspace members"]'),
).toBeDisabled();
await expect(page.locator(".ps-people button", { hasText: "+ Add" })).toHaveCount(0);
});
+24 -1
View File
@@ -19,7 +19,17 @@ export interface ServerConfig {
me?: { email: string; name: string };
}
// GET /api/projects (handleProjectList → Project, projects.go)
// Per-project permission levels (perms.go). Ordered: each includes the ones
// before it.
export type PermLevel = "none" | "read" | "write" | "admin";
const PERM_RANK: Record<string, number> = { read: 1, write: 2, admin: 3 };
// Mirrors atLeast() on the server. The UI uses it to hide affordances; the
// server still enforces every one of them.
export function atLeast(have: string | undefined, want: PermLevel): boolean {
return (PERM_RANK[have || ""] || 0) >= (PERM_RANK[want] || 0);
}
// GET /api/projects (handleProjectList, server.go)
export interface Project {
id: string;
name: string;
@@ -28,6 +38,19 @@ export interface Project {
description?: string;
/** lucide icon name (kebab-case); unknown or absent → the folder placeholder */
icon?: string;
creator?: string;
// The signed-in account's effective level on this project, resolved
// server-side. A project you cannot read never appears in the list at all,
// so this is always read or better here.
perm?: PermLevel;
}
// GET /api/p/{id}/permissions (handleProjectPerms, perms.go)
export interface ProjectPerms {
default: PermLevel; // what org members get without a grant
me: PermLevel; // the caller's own effective level
creator?: string;
grants: Array<{ email: string; level: PermLevel }>;
}
export interface ProjectList {
@@ -7,6 +7,7 @@ import {
} from "react";
import { useQueryClient } from "@tanstack/react-query";
import { Button } from "@/components/ui/button";
import { atLeast } from "../api/types";
import type { Project, ServerConfig } from "../api/types";
import { useHeat, useTree } from "../hooks/useBrowse";
import { urlForPath, urlForView, type Route } from "../router";
@@ -149,7 +150,9 @@ export default function Browser(props: {
const downloadRef = useRef<HTMLAnchorElement>(null);
const panel = props.panel ?? null;
const canShare = !panel && hub && !!project && isFile;
// Minting a public link is a write. A read-only member sees no Share
// button rather than a button that 403s.
const canShare = !panel && hub && !!project && isFile && atLeast(project.perm, "write");
const canHistory = !panel && hub && !!project;
// Browser upload is deliberately absent (for now): content enters through
// local sync only; the web app is a read/share/history surface.
@@ -2,10 +2,11 @@ import { useEffect } from "react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { useQueryClient } from "@tanstack/react-query";
import { api } from "../api/http";
import { modalPrompt } from "../modal";
import { modalConfirm, modalPrompt } from "../modal";
import { toast } from "../toast";
import { useHubRefresh } from "../hooks/useHub";
import { useHubRefresh, usePermissions } from "../hooks/useHub";
import { PROJECT_ICONS, ProjectIcon } from "./shell";
import { projColor } from "./ProjectNav";
import { Button } from "@/components/ui/button";
@@ -20,11 +21,13 @@ import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Separator } from "@/components/ui/separator";
import { Textarea } from "@/components/ui/textarea";
import type { Org, Project } from "../api/types";
import { atLeast } from "../api/types";
import type { Org, PermLevel, Project, ProjectPerms } from "../api/types";
// Settings for the open project (sidebar menu): General edits the name,
// description and icon; About holds the identity facts; the danger zone
// deletes. Install/connect lives on the Installation page.
// description and icon; About holds the identity facts; People says who can
// do what; the danger zone deletes. Install/connect lives on the Installation
// page.
const MAX_DESC = 280;
@@ -50,9 +53,9 @@ export function ProjectSettings({
onDeleted: () => Promise<void>;
}) {
const refresh = useHubRefresh();
// Owner-only, and only as UX: handleProjectUpdate enforces it too. Swap for
// the project-level permission once BEA-2 lands.
const mayEdit = org?.role === "owner";
// Project admins, and only as UX: handleProjectUpdate enforces it too.
// (Workspace owners resolve to admin server-side, so they still pass.)
const mayEdit = atLeast(project.perm, "admin");
const form = useForm<Values>({
resolver: zodResolver(schema),
@@ -97,7 +100,10 @@ export function ProjectSettings({
return (
<div className="project-settings">
<h2>{project.name}</h2>
<h2>
{project.name}
{!atLeast(project.perm, "write") && <span className="ps-chip">Read-only</span>}
</h2>
<Card>
<CardHeader>
@@ -239,8 +245,10 @@ export function ProjectSettings({
</CardContent>
</Card>
{/* Owner-only, and only as UX: handleProjectDelete enforces it too. */}
{org?.role === "owner" && (
<People project={project} org={org} />
{/* Admin-only, and only as UX: handleProjectDelete enforces it too. */}
{mayEdit && (
<Card className="ps-danger">
<CardHeader>
<CardTitle>Danger zone</CardTitle>
@@ -279,3 +287,171 @@ export function ProjectSettings({
</div>
);
}
const LEVELS: Array<{ value: PermLevel; label: string }> = [
{ value: "admin", label: "Admin" },
{ value: "write", label: "Write" },
{ value: "read", label: "Read" },
{ value: "none", label: "No access" },
];
const LABEL: Record<string, string> = Object.fromEntries(LEVELS.map((l) => [l.value, l.label]));
// People: the level everyone in the workspace gets, plus per-person
// exceptions. Visible to every member with access; editable only for admins,
// who see the same table with live controls.
function People({ project, org }: { project: Project; org: Org | null }) {
const qc = useQueryClient();
const { data, error } = usePermissions(project.id);
const isAdmin = atLeast(project.perm, "admin");
const reload = () => {
qc.invalidateQueries({ queryKey: ["permissions", project.id] });
qc.invalidateQueries({ queryKey: ["projects"] });
};
const run = async (fn: () => Promise<unknown>, ok: string) => {
try {
await fn();
toast(ok);
} catch (e) {
toast((e as Error).message, true);
}
reload();
};
if (error) return null; // permissions are unavailable in single-volume mode
if (!data) return null;
const perms: ProjectPerms = data;
const base = `/api/p/${project.id}/permissions`;
// Workspace owners are always project admins, whatever the grant list says.
const owners = new Set(
(org?.members || []).filter((m) => m.role === "owner").map((m) => m.email.toLowerCase()),
);
const rows = [
...perms.grants.filter((g) => !owners.has(g.email.toLowerCase())),
...[...owners].sort().map((email) => ({ email, level: "admin" as PermLevel, owner: true })),
];
const addPerson = async () => {
const email = await modalPrompt(
"Add an exception",
"Email of a workspace member. They get Read access; change it in the table.",
"",
"Add",
);
if (email === null || !email.trim()) return;
await run(() => api("PUT", `${base}/${encodeURIComponent(email.trim())}`, { level: "read" }), "Added.");
};
return (
<Card className="ps-people">
<CardHeader>
<CardTitle>People</CardTitle>
<CardDescription>Who can see and change this project.</CardDescription>
</CardHeader>
<Separator />
<CardContent>
<p className="ps-row">
<span>Everyone in {org?.name || "this workspace"} can</span>
<select
aria-label="Default access for workspace members"
disabled={!isAdmin}
value={perms.default}
onChange={async (e) => {
const level = e.target.value as PermLevel;
if (
level === "none" &&
!(await modalConfirm(
"Make this project invite-only?",
"Only people listed below (and workspace owners) will see this project.",
"Make invite-only",
))
) {
reload();
return;
}
await run(() => api("PUT", base, { default: level }), "Default access updated.");
}}
>
{LEVELS.filter((l) => l.value !== "admin").map((l) => (
<option key={l.value} value={l.value}>
{l.label}
</option>
))}
</select>
</p>
{perms.default === "none" && (
<p className="ps-note">
This project is invite-only: only the people below and workspace owners can see it.
</p>
)}
<div className="ps-people-head">
<h4>Exceptions</h4>
{isAdmin && (
<Button type="button" variant="subtle" onClick={addPerson}>
+ Add
</Button>
)}
</div>
{rows.length === 0 ? (
<p className="ps-note">No exceptions everyone gets the access above.</p>
) : (
<div className="admin-list">
{rows.map((g) => {
const isOwner = "owner" in g;
return (
<div className="admin-item" key={g.email}>
<span className="ai-main" title={g.email}>
{g.email}
{perms.creator && g.email.toLowerCase() === perms.creator.toLowerCase() && (
<span className="ai-tag"> (creator)</span>
)}
</span>
{isOwner ? (
<span className="ai-tag">Workspace owner always admin</span>
) : (
<span className="role-cell">
<select
aria-label={`Access for ${g.email}`}
disabled={!isAdmin}
value={g.level}
onChange={(e) =>
run(
() =>
api("PUT", `${base}/${encodeURIComponent(g.email)}`, {
level: e.target.value,
}),
`${g.email} is now ${LABEL[e.target.value] || e.target.value}.`,
)
}
>
{LEVELS.map((l) => (
<option key={l.value} value={l.value}>
{l.label}
</option>
))}
</select>
{isAdmin && (
<button
className="ai-del"
aria-label={`Remove exception for ${g.email}`}
onClick={() =>
run(
() => api("DELETE", `${base}/${encodeURIComponent(g.email)}`),
"Reverted to the default access.",
)
}
>
Remove
</button>
)}
</span>
)}
</div>
);
})}
</div>
)}
</CardContent>
</Card>
);
}
+11 -1
View File
@@ -1,6 +1,6 @@
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { getJSON } from "../api/http";
import type { OrgList, PendingList, ProjectList } from "../api/types";
import type { OrgList, PendingList, ProjectList, ProjectPerms } from "../api/types";
// Hub-wide server state: the project list (polled — new projects appear
// without a reload, matching the classic app's 30s refresh) and the orgs
@@ -25,6 +25,16 @@ export function useOrgs(enabled: boolean) {
});
}
// One project's permission settings (default level + explicit grants). Any
// member with read may fetch it; only an admin may change it.
export function usePermissions(projectId: string | undefined) {
return useQuery({
queryKey: ["permissions", projectId],
queryFn: () => getJSON<ProjectPerms>(`/api/p/${projectId}/permissions`),
enabled: !!projectId,
});
}
// Pending signups; only fetched for hub admins (the admin bar shows the
// count).
export function usePending(enabled: boolean) {
+12 -1
View File
@@ -373,7 +373,7 @@ button, input, a.btn { font-family: inherit; }
[data-slot="dropdown-menu-content"] { border-color: var(--border); }
/* ---- project settings ---- */
/* Sectioned cards: General (editable), About (facts), Danger zone. */
/* Sectioned cards: General (editable), About (facts), People, Danger zone. */
.project-settings { display: flex; flex-direction: column; gap: 14px; }
.project-settings > h2 { font-size: 21px; font-weight: 640; letter-spacing: -.02em; margin: 0 0 4px; color: #f4f6f9; }
.ps-form { display: flex; flex-direction: column; gap: 18px; }
@@ -395,6 +395,17 @@ button, input, a.btn { font-family: inherit; }
.ps-icon-cell.active { border-color: var(--accent); color: var(--accent-bright); }
/* Irreversible actions keep their own, clearly-marked card. */
.ps-danger [data-slot="card-title"] { font-size: 10.5px; text-transform: uppercase; letter-spacing: .07em; color: #d2695e; font-weight: 600; }
/* Who can do what one more card in the same stack. The read-only view is
the same markup with its controls disabled, so nothing shifts when the
caller's level changes. Native selects here rather than the shadcn Select:
the org admin's role picker already uses them, and this table matches it. */
.ps-chip { margin-left: 10px; padding: 2px 8px; border-radius: 999px; border: 1px solid var(--border); background: var(--surface); color: var(--text-faint); font-size: 11px; font-weight: 600; letter-spacing: .02em; vertical-align: middle; }
.ps-people h4 { font-size: 12.5px; font-weight: 600; color: var(--text-dim); margin: 0; }
.ps-row { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; font-size: 13px; color: var(--text-dim); margin: 0 0 10px; }
.ps-people select { height: 28px; padding: 0 8px; border-radius: 6px; border: 1px solid var(--border); background: var(--surface); color: var(--text); font: inherit; font-size: 12.5px; }
.ps-people select:disabled { opacity: .6; cursor: default; }
.ps-note { color: var(--text-faint); font-size: 12.5px; margin: 0 0 12px; max-width: 56ch; line-height: 1.55; }
.ps-people-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin: 20px 0 8px; }
.ps-danger p { color: var(--text-dim); font-size: 13px; margin: 0 0 14px; max-width: 52ch; line-height: 1.55; }
.ps-facts { display: grid; grid-template-columns: auto 1fr; gap: 8px 20px; margin: 0; font-size: 13px; }
.ps-facts dt { color: var(--text-faint); }
-14
View File
@@ -372,20 +372,6 @@ func (s *Server) orgOf(projectID string) string {
return p.Org
}
// projectAllowed says whether the request's account may touch the project.
// Without an org registry (single-volume mode, tests, pre-org hubs) every
// authenticated request passes, preserving the old behavior.
func (s *Server) projectAllowed(r *http.Request, projectID string) bool {
if s.Dir == nil || s.Auth == nil {
return true
}
org := s.orgOf(projectID)
if org == "" {
return true // org-less project (migration happens at startup)
}
return s.Dir.Role(org, s.requestUser(r).Email) != ""
}
// 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) {
+235
View File
@@ -0,0 +1,235 @@
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
}
+359
View File
@@ -0,0 +1,359 @@
package webapp
import (
"encoding/json"
"net/http"
"path/filepath"
"strings"
"testing"
)
func TestPermRankAndAtLeast(t *testing.T) {
// An unknown level must fail closed — it is the answer for a corrupt
// grant, and reading it as anything but "none" would open a hole.
for _, l := range []string{"", "none", "bogus", "Admin"} {
if permRank(l) != 0 {
t.Errorf("permRank(%q) = %d, want 0", l, permRank(l))
}
}
if !(permRank(PermRead) < permRank(PermWrite) && permRank(PermWrite) < permRank(PermAdmin)) {
t.Fatal("levels are not ordered read < write < admin")
}
if !atLeast(PermAdmin, PermWrite) || !atLeast(PermWrite, PermWrite) || atLeast(PermRead, PermWrite) {
t.Fatal("atLeast is wrong")
}
}
// permHub builds an org hub where alice owns the org, bob and carol are plain
// members, and dave is in another org entirely. The project is alice's.
func permHub(t *testing.T) (h http.Handler, srv *Server, cookies map[string]*http.Cookie, p Project) {
t.Helper()
srv, _, _ = newHub(t, true, nil)
auth, err := OpenBuiltinAuth(filepath.Join(t.TempDir(), "auth.json"), true, nil)
if err != nil {
t.Fatal(err)
}
srv.Auth = auth
orgs, err := OpenOrgDB(filepath.Join(t.TempDir(), "orgs.json"))
if err != nil {
t.Fatal(err)
}
srv.Dir = LocalDirectory{OrgDB: orgs}
shares, err := OpenShareDB(filepath.Join(t.TempDir(), "shares.json"))
if err != nil {
t.Fatal(err)
}
srv.Shares = shares
h = srv.Handler()
cookies = map[string]*http.Cookie{}
for _, who := range []string{"alice", "bob", "carol", "dave"} {
cookies[who] = signupAndSession(t, h, who+"@x.io", strings.ToUpper(who[:1])+who[1:], "password1")
}
rec := doAs(t, h, "POST", "/api/projects", map[string]string{"name": "wiki"}, cookies["alice"])
if rec.Code != 200 {
t.Fatalf("create project: %d %s", rec.Code, rec.Body)
}
var out struct {
Project Project `json:"project"`
}
json.Unmarshal(rec.Body.Bytes(), &out)
p = out.Project
for _, who := range []string{"bob", "carol"} {
if err := orgs.AddMember(p.Org, who+"@x.io", RoleMember); err != nil {
t.Fatal(err)
}
}
return h, srv, cookies, p
}
// Nothing changes for an existing hub: with no permission edits, every org
// member still has full read+write on every project.
func TestDefaultIsWriteForEveryMember(t *testing.T) {
h, _, c, p := permHub(t)
if rec := doAs(t, h, "GET", "/api/p/"+p.ID+"/tree", nil, c["bob"]); rec.Code != 200 {
t.Fatalf("member read: %d %s", rec.Code, rec.Body)
}
if rec := doAs(t, h, "PUT", "/api/p/"+p.ID+"/store/object?key=journal/d.jsonl", []byte("{}"), c["bob"]); rec.Code == http.StatusForbidden {
t.Fatalf("member write refused by default: %s", rec.Body)
}
// and an outsider is still walled out
if rec := doAs(t, h, "GET", "/api/p/"+p.ID+"/tree", nil, c["dave"]); rec.Code != http.StatusForbidden {
t.Fatalf("outsider read: %d, want 403", rec.Code)
}
}
// The creator of a project becomes its first admin — unless they are an org
// owner, who is implicitly admin and needs no grant.
func TestCreatorBecomesAdmin(t *testing.T) {
h, srv, c, p := permHub(t)
// alice created it as an org owner: implicit admin, no explicit grant.
if got, _ := srv.Projects.Get(p.ID); got.Creator != "alice@x.io" {
t.Fatalf("creator = %q, want alice@x.io", got.Creator)
}
// bob, a plain member, creates one: he gets the explicit admin grant.
rec := doAs(t, h, "POST", "/api/projects", map[string]any{"name": "bobs", "org": p.Org}, c["bob"])
if rec.Code != 200 {
t.Fatalf("bob create: %d %s", rec.Code, rec.Body)
}
var out struct {
Project map[string]any `json:"project"`
}
json.Unmarshal(rec.Body.Bytes(), &out)
if out.Project["perm"] != PermAdmin {
t.Fatalf("creator's own level = %v, want admin", out.Project["perm"])
}
bp, _ := srv.Projects.Get(out.Project["id"].(string))
if bp.Perms["bob@x.io"] != PermAdmin {
t.Fatalf("creator grant = %+v", bp.Perms)
}
// and a plain member who is a project admin can rename and delete it —
// this used to be org-owner-only.
id := out.Project["id"].(string)
if rec := doAs(t, h, "PATCH", "/api/projects/"+id, map[string]string{"name": "bobs2"}, c["bob"]); rec.Code != 200 {
t.Fatalf("project admin rename: %d %s", rec.Code, rec.Body)
}
if rec := doAs(t, h, "DELETE", "/api/projects/"+id, nil, c["bob"]); rec.Code != 200 {
t.Fatalf("project admin delete: %d %s", rec.Code, rec.Body)
}
}
// A read grant admits every read route and refuses every write route.
func TestReadOnlyMemberRoutes(t *testing.T) {
h, srv, c, p := permHub(t)
if err := srv.Projects.SetPerm(p.ID, "bob@x.io", PermRead); err != nil {
t.Fatal(err)
}
base := "/api/p/" + p.ID + "/"
writes := []struct {
method, url string
body any
}{
{"POST", base + "upload/init", map[string]any{"path": "x.md", "sha256": strings.Repeat("a", 64), "size": 1}},
{"PUT", base + "upload/content?path=x.md", []byte("hi")},
{"POST", base + "upload/commit", map[string]any{"path": "x.md", "sha256": strings.Repeat("a", 64), "size": 1}},
{"PUT", base + "store/object?key=journal/d.jsonl", []byte("{}")},
{"POST", base + "store/sign", map[string]any{"key": "blobs/" + strings.Repeat("a", 64), "size": 1}},
{"POST", base + "shares", map[string]string{"path": "x.md"}},
{"PATCH", "/api/projects/" + p.ID, map[string]string{"name": "nope"}},
{"DELETE", "/api/projects/" + p.ID, nil},
{"PUT", base + "permissions", map[string]string{"default": "read"}},
{"PUT", base + "permissions/carol@x.io", map[string]string{"level": "read"}},
{"DELETE", base + "permissions/carol@x.io", nil},
}
for _, rt := range writes {
if rec := doAs(t, h, rt.method, rt.url, rt.body, c["bob"]); rec.Code != http.StatusForbidden {
t.Errorf("%s %s as read-only: %d, want 403", rt.method, rt.url, rec.Code)
}
}
reads := []struct{ method, url string }{
{"GET", base + "tree"},
{"GET", base + "file?path=x.md"},
{"GET", base + "download?path=x.md"},
{"GET", base + "render?path=x.md"},
{"GET", base + "history"},
{"GET", base + "blob?sha=" + strings.Repeat("a", 64)},
{"GET", base + "heat"},
{"GET", base + "shares"},
{"GET", base + "store/list?prefix=journal/"},
{"GET", base + "store/object?key=journal/d.jsonl"},
{"GET", base + "store/exists?key=journal/d.jsonl"},
{"GET", base + "permissions"},
}
for _, rt := range reads {
if rec := doAs(t, h, rt.method, rt.url, nil, c["bob"]); rec.Code == http.StatusForbidden {
t.Errorf("%s %s as read-only: 403, want access (%s)", rt.method, rt.url, rec.Body)
}
}
if rec := doAs(t, h, "POST", base+"reads", map[string]any{"reads": []any{}}, c["bob"]); rec.Code == http.StatusForbidden {
t.Errorf("read report as read-only: 403, want access")
}
// a read member still sees the project and can open it
if rec := doAs(t, h, "GET", "/api/projects", nil, c["bob"]); !strings.Contains(rec.Body.String(), p.ID) {
t.Error("read-only member does not see the project in the list")
}
}
// A none grant is treated exactly like a non-member: hidden from the list,
// 403 everywhere.
func TestNoAccessMemberIsInvisible(t *testing.T) {
h, srv, c, p := permHub(t)
if err := srv.Projects.SetPerm(p.ID, "bob@x.io", PermNone); err != nil {
t.Fatal(err)
}
base := "/api/p/" + p.ID + "/"
for _, url := range []string{"tree", "history", "heat", "shares", "permissions", "store/list?prefix=journal/"} {
if rec := doAs(t, h, "GET", base+url, nil, c["bob"]); rec.Code != http.StatusForbidden {
t.Errorf("GET %s as none: %d, want 403", url, rec.Code)
}
}
if rec := doAs(t, h, "GET", "/api/projects", nil, c["bob"]); strings.Contains(rec.Body.String(), p.ID) {
t.Error("a none member sees the project in the list")
}
// create-or-join by name must not hand the id back either
if rec := doAs(t, h, "POST", "/api/projects", map[string]any{"name": p.Name, "org": p.Org}, c["bob"]); rec.Code != http.StatusForbidden {
t.Errorf("join-by-name as none: %d, want 403", rec.Code)
}
// carol, with no explicit grant, is unaffected
if rec := doAs(t, h, "GET", base+"tree", nil, c["carol"]); rec.Code != 200 {
t.Errorf("carol: %d %s", rec.Code, rec.Body)
}
}
// Default none makes a project invite-only: only explicit grants and org
// owners get in.
func TestInviteOnlyDefault(t *testing.T) {
h, srv, c, p := permHub(t)
if rec := doAs(t, h, "PUT", "/api/p/"+p.ID+"/permissions", map[string]string{"default": "none"}, c["alice"]); rec.Code != 200 {
t.Fatalf("set default: %d %s", rec.Code, rec.Body)
}
if err := srv.Projects.SetPerm(p.ID, "bob@x.io", PermRead); err != nil {
t.Fatal(err)
}
base := "/api/p/" + p.ID + "/tree"
if rec := doAs(t, h, "GET", base, nil, c["carol"]); rec.Code != http.StatusForbidden {
t.Errorf("carol with default none: %d, want 403", rec.Code)
}
if rec := doAs(t, h, "GET", base, nil, c["bob"]); rec.Code != 200 {
t.Errorf("bob with an explicit read grant: %d %s", rec.Code, rec.Body)
}
if rec := doAs(t, h, "GET", base, nil, c["alice"]); rec.Code != 200 {
t.Errorf("org owner locked out by default none: %d", rec.Code)
}
// admin is not a legal default
if rec := doAs(t, h, "PUT", "/api/p/"+p.ID+"/permissions", map[string]string{"default": "admin"}, c["alice"]); rec.Code != http.StatusBadRequest {
t.Errorf("default admin: %d, want 400", rec.Code)
}
}
// An org owner always resolves to admin, whatever the grant list says, and a
// grant naming one is refused rather than silently ignored.
func TestOrgOwnerAlwaysAdmin(t *testing.T) {
h, srv, c, p := permHub(t)
if err := srv.Projects.SetPerm(p.ID, "bob@x.io", PermAdmin); err != nil {
t.Fatal(err)
}
// bob (a project admin) tries to cut alice, the org owner, out
rec := doAs(t, h, "PUT", "/api/p/"+p.ID+"/permissions/alice@x.io", map[string]string{"level": "none"}, c["bob"])
if rec.Code != http.StatusBadRequest {
t.Fatalf("grant on an org owner: %d, want 400", rec.Code)
}
if rec := doAs(t, h, "GET", "/api/p/"+p.ID+"/tree", nil, c["alice"]); rec.Code != 200 {
t.Fatalf("org owner locked out: %d", rec.Code)
}
// even a hand-written grant in storage cannot outrank her
if err := srv.Projects.SetPerm(p.ID, "alice@x.io", PermNone); err != nil {
t.Fatal(err)
}
if rec := doAs(t, h, "DELETE", "/api/projects/"+p.ID, nil, c["alice"]); rec.Code != 200 {
t.Fatalf("org owner delete after a none grant: %d %s", rec.Code, rec.Body)
}
}
// The last explicit admin cannot be removed or demoted — including by
// themselves.
func TestLastProjectAdminHeld(t *testing.T) {
h, srv, c, p := permHub(t)
if err := srv.Projects.SetPerm(p.ID, "bob@x.io", PermAdmin); err != nil {
t.Fatal(err)
}
for _, tc := range []struct {
method, url string
body any
}{
{"PUT", "/api/p/" + p.ID + "/permissions/bob@x.io", map[string]string{"level": "none"}},
{"PUT", "/api/p/" + p.ID + "/permissions/bob@x.io", map[string]string{"level": "read"}},
{"DELETE", "/api/p/" + p.ID + "/permissions/bob@x.io", nil},
} {
if rec := doAs(t, h, tc.method, tc.url, tc.body, c["bob"]); rec.Code != http.StatusBadRequest {
t.Errorf("%s %s: %d, want 400", tc.method, tc.url, rec.Code)
}
if got, _ := srv.Projects.Get(p.ID); got.Perms["bob@x.io"] != PermAdmin {
t.Fatalf("last admin changed anyway: %+v", got.Perms)
}
}
// with a second admin, the first can step down
if rec := doAs(t, h, "PUT", "/api/p/"+p.ID+"/permissions/carol@x.io", map[string]string{"level": "admin"}, c["bob"]); rec.Code != 200 {
t.Fatalf("grant second admin: %d %s", rec.Code, rec.Body)
}
if rec := doAs(t, h, "DELETE", "/api/p/"+p.ID+"/permissions/bob@x.io", nil, c["bob"]); rec.Code != 200 {
t.Fatalf("step down with another admin present: %d %s", rec.Code, rec.Body)
}
}
// Grants are org members only.
func TestGrantsAreOrgMembersOnly(t *testing.T) {
h, _, c, p := permHub(t)
rec := doAs(t, h, "PUT", "/api/p/"+p.ID+"/permissions/dave@x.io", map[string]string{"level": "read"}, c["alice"])
if rec.Code != http.StatusBadRequest {
t.Fatalf("grant to a non-member: %d, want 400", rec.Code)
}
rec = doAs(t, h, "PUT", "/api/p/"+p.ID+"/permissions/bob@x.io", map[string]string{"level": "bogus"}, c["alice"])
if rec.Code != http.StatusBadRequest {
t.Fatalf("unknown level: %d, want 400", rec.Code)
}
}
// GET /permissions reports the default, the caller's own level, and grants.
func TestPermissionsGET(t *testing.T) {
h, srv, c, p := permHub(t)
if err := srv.Projects.SetPerm(p.ID, "bob@x.io", PermRead); err != nil {
t.Fatal(err)
}
rec := doAs(t, h, "GET", "/api/p/"+p.ID+"/permissions", nil, c["bob"])
if rec.Code != 200 {
t.Fatalf("GET permissions as a read member: %d %s", rec.Code, rec.Body)
}
var out struct {
Default string `json:"default"`
Me string `json:"me"`
Grants []map[string]string `json:"grants"`
}
json.Unmarshal(rec.Body.Bytes(), &out)
if out.Default != PermWrite || out.Me != PermRead {
t.Fatalf("default=%q me=%q, want write/read", out.Default, out.Me)
}
if len(out.Grants) != 1 || out.Grants[0]["email"] != "bob@x.io" {
t.Fatalf("grants = %+v", out.Grants)
}
}
// The project list carries the caller's level *alongside* every ordinary
// Project field. Regression guard: an earlier version hand-listed the fields
// it returned, which silently dropped description and icon the moment those
// were added — the client saw a project with no metadata and no error.
func TestProjectListCarriesWholeProject(t *testing.T) {
h, srv, c, p := permHub(t)
desc, icon := "everything support needs", "book-open"
if err := srv.Projects.Update(p.ID, nil, &desc, &icon); err != nil {
t.Fatal(err)
}
rec := doAs(t, h, "GET", "/api/projects", nil, c["alice"])
var out struct {
Projects []map[string]any `json:"projects"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
t.Fatal(err)
}
var row map[string]any
for _, r := range out.Projects {
if r["id"] == p.ID {
row = r
}
}
if row == nil {
t.Fatalf("project missing from the list: %s", rec.Body)
}
for key, want := range map[string]any{
"name": p.Name, "description": desc, "icon": icon, "perm": PermAdmin,
} {
if row[key] != want {
t.Errorf("list row %q = %v, want %v", key, row[key], want)
}
}
// The grant list is not list-response material — /permissions owns it.
if _, leaked := row["perms"]; leaked {
t.Errorf("grant list leaked into the project list: %v", row["perms"])
}
}
+114
View File
@@ -22,6 +22,23 @@ type Project struct {
Created time.Time `json:"created"`
Description string `json:"description,omitempty"` // optional one-line subtitle
Icon string `json:"icon,omitempty"` // optional lucide icon name
// Creator is the account that first created the project; it gets an
// explicit admin grant at creation. Empty on projects that predate
// per-project permissions — those are governed by org owners.
Creator string `json:"creator,omitempty"`
// Default is the level every org member gets without an explicit grant.
// Empty means write: the historical behavior, so no row needs migrating.
Default string `json:"default,omitempty"`
// Perms are the explicit grants, lowercase email → level.
Perms map[string]string `json:"perms,omitempty"`
}
// level is the project's effective default level for org members.
func (p Project) level() string {
if p.Default == "" {
return PermWrite
}
return p.Default
}
var projectIDRe = regexp.MustCompile(`^p-[0-9a-f]{8}$`)
@@ -188,6 +205,103 @@ func (db *ProjectDB) Delete(id string) error {
return db.repo.Delete(id)
}
// SetCreator records who created a project (and is its first admin).
func (db *ProjectDB) SetCreator(id, email string) error {
db.mu.Lock()
defer db.mu.Unlock()
p, ok := db.byID[id]
if !ok {
return fmt.Errorf("no such project %q", id)
}
p.Creator = normEmail(email)
db.byID[id] = p
return db.repo.Put(p)
}
// SetDefault sets the level org members get without an explicit grant.
func (db *ProjectDB) SetDefault(id, level string) error {
if !validLevel(level) || level == PermAdmin {
return fmt.Errorf("invalid default level %q", level)
}
db.mu.Lock()
defer db.mu.Unlock()
p, ok := db.byID[id]
if !ok {
return fmt.Errorf("no such project %q", id)
}
p.Default = level
db.byID[id] = p
return db.repo.Put(p)
}
// SetPerm grants one account an explicit level on the project. Demoting the
// last explicit admin is refused, the same shape as OrgDB's last-owner rule:
// a project must keep someone who can administer it (org owners aside, who
// are implicitly admin and never appear in this list).
func (db *ProjectDB) SetPerm(id, email, level string) error {
if !validLevel(level) {
return fmt.Errorf("invalid level %q", level)
}
e := normEmail(email)
if e == "" {
return fmt.Errorf("email must not be empty")
}
db.mu.Lock()
defer db.mu.Unlock()
p, ok := db.byID[id]
if !ok {
return fmt.Errorf("no such project %q", id)
}
if level != PermAdmin && p.Perms[e] == PermAdmin && adminCount(p) <= 1 {
return fmt.Errorf("cannot demote the last project admin")
}
perms := make(map[string]string, len(p.Perms)+1)
for k, v := range p.Perms {
perms[k] = v
}
perms[e] = level
p.Perms = perms
db.byID[id] = p
return db.repo.Put(p)
}
// ClearPerm drops an explicit grant, reverting the account to the default.
func (db *ProjectDB) ClearPerm(id, email string) error {
e := normEmail(email)
db.mu.Lock()
defer db.mu.Unlock()
p, ok := db.byID[id]
if !ok {
return fmt.Errorf("no such project %q", id)
}
if _, has := p.Perms[e]; !has {
return fmt.Errorf("%s has no permission set on this project", email)
}
if p.Perms[e] == PermAdmin && adminCount(p) <= 1 {
return fmt.Errorf("cannot remove the last project admin")
}
perms := make(map[string]string, len(p.Perms))
for k, v := range p.Perms {
if k != e {
perms[k] = v
}
}
p.Perms = perms
db.byID[id] = p
return db.repo.Put(p)
}
// adminCount counts explicit admin grants on a project.
func adminCount(p Project) int {
n := 0
for _, l := range p.Perms {
if l == PermAdmin {
n++
}
}
return n
}
// SetOrg moves a project into an org (used by the startup migration).
func (db *ProjectDB) SetOrg(id, org string) error {
db.mu.Lock()
+88 -34
View File
@@ -19,9 +19,9 @@ package webapp
import (
"context"
"errors"
"embed"
"encoding/json"
"errors"
"fmt"
"io"
"io/fs"
@@ -292,7 +292,9 @@ func (s *Server) Handler() http.Handler {
// Volume resolution per route family: fixed single volume, or by
// project id in hub mode. One handler implementation serves both.
single := func(h func(*volume, http.ResponseWriter, *http.Request)) http.HandlerFunc {
// Single-volume mode has no per-project permissions, so it ignores the
// declared level; hub mode enforces it.
single := func(_ string, h func(*volume, http.ResponseWriter, *http.Request)) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if s.Source == nil {
http.Error(w, "this server hosts projects; use /api/p/<project-id>/...", http.StatusNotFound)
@@ -301,7 +303,7 @@ func (s *Server) Handler() http.Handler {
h(s.single(), w, r)
}
}
proj := func(h func(*volume, http.ResponseWriter, *http.Request)) http.HandlerFunc {
proj := func(level string, h func(*volume, http.ResponseWriter, *http.Request)) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("project")
v, err := s.projectVolume(id)
@@ -309,12 +311,11 @@ func (s *Server) Handler() http.Handler {
http.Error(w, err.Error(), http.StatusNotFound)
return
}
if !s.projectAllowed(r, id) {
http.Error(w, "you are not a member of this project's organization", http.StatusForbidden)
if !s.requirePerm(w, r, id, level) {
return
}
// Read recording (and anything else downstream) finds the project
// id in the context; membership has already passed at this point.
// id in the context; permission has already passed at this point.
h(v, w, withProjectID(r, id))
}
}
@@ -324,17 +325,17 @@ func (s *Server) Handler() http.Handler {
mux.HandleFunc("POST /api/projects", s.handleProjectCreate)
mux.HandleFunc("GET /api/projects/{project}", s.handleProjectGet)
for prefix, resolve := range map[string]func(func(*volume, http.ResponseWriter, *http.Request)) http.HandlerFunc{
for prefix, resolve := range map[string]func(string, func(*volume, http.ResponseWriter, *http.Request)) http.HandlerFunc{
"/api/": single,
"/api/p/{project}/": proj,
} {
mux.HandleFunc("GET "+prefix+"tree", resolve(s.handleTree))
mux.HandleFunc("GET "+prefix+"file", resolve(s.handleFile))
mux.HandleFunc("GET "+prefix+"download", resolve(s.handleDownload))
mux.HandleFunc("GET "+prefix+"render", resolve(s.handleRender))
mux.HandleFunc("POST "+prefix+"upload/init", resolve(s.handleUploadInit))
mux.HandleFunc("PUT "+prefix+"upload/content", resolve(s.handleUploadContent))
mux.HandleFunc("POST "+prefix+"upload/commit", resolve(s.handleUploadCommit))
mux.HandleFunc("GET "+prefix+"tree", resolve(PermRead, s.handleTree))
mux.HandleFunc("GET "+prefix+"file", resolve(PermRead, s.handleFile))
mux.HandleFunc("GET "+prefix+"download", resolve(PermRead, s.handleDownload))
mux.HandleFunc("GET "+prefix+"render", resolve(PermRead, s.handleRender))
mux.HandleFunc("POST "+prefix+"upload/init", resolve(PermWrite, s.handleUploadInit))
mux.HandleFunc("PUT "+prefix+"upload/content", resolve(PermWrite, s.handleUploadContent))
mux.HandleFunc("POST "+prefix+"upload/commit", resolve(PermWrite, s.handleUploadCommit))
}
mux.HandleFunc("GET /api/orgs", s.handleOrgList)
@@ -356,22 +357,28 @@ func (s *Server) Handler() http.Handler {
mux.HandleFunc("POST /api/admin/pending/{id}/approve", s.handleAdminApprove)
mux.HandleFunc("POST /api/admin/pending/{id}/deny", s.handleAdminDeny)
mux.HandleFunc("GET /api/p/{project}/history", proj(s.handleHistory))
mux.HandleFunc("GET /api/p/{project}/blob", proj(s.handleBlob))
mux.HandleFunc("GET /api/p/{project}/heat", proj(s.handleHeat))
mux.HandleFunc("POST /api/p/{project}/reads", proj(s.handleReadReport))
mux.HandleFunc("POST /api/p/{project}/shares", proj(s.handleShareCreate))
mux.HandleFunc("GET /api/p/{project}/shares", proj(s.handleShareList))
mux.HandleFunc("GET /api/p/{project}/history", proj(PermRead, s.handleHistory))
mux.HandleFunc("GET /api/p/{project}/blob", proj(PermRead, s.handleBlob))
mux.HandleFunc("GET /api/p/{project}/heat", proj(PermRead, s.handleHeat))
mux.HandleFunc("POST /api/p/{project}/reads", proj(PermRead, s.handleReadReport))
mux.HandleFunc("POST /api/p/{project}/shares", proj(PermWrite, s.handleShareCreate))
mux.HandleFunc("GET /api/p/{project}/shares", proj(PermRead, s.handleShareList))
mux.HandleFunc("DELETE /api/shares/{token}", s.handleShareRevoke)
mux.HandleFunc("GET /s/{token}", s.handleShared)
mux.HandleFunc("GET /api/p/{project}/permissions", s.handleProjectPerms)
mux.HandleFunc("PUT /api/p/{project}/permissions", s.handleProjectPermDefault)
mux.HandleFunc("PUT /api/p/{project}/permissions/{email}", s.handleProjectPermSet)
mux.HandleFunc("DELETE /api/p/{project}/permissions/{email}", s.handleProjectPermClear)
// The sync (store) API only exists per project: hub mode is what
// storage-blind devices sync through.
mux.HandleFunc("GET /api/p/{project}/store/list", proj(s.handleStoreList))
mux.HandleFunc("GET /api/p/{project}/store/object", proj(s.handleStoreGet))
mux.HandleFunc("GET /api/p/{project}/store/exists", proj(s.handleStoreExists))
mux.HandleFunc("POST /api/p/{project}/store/sign", proj(s.handleStoreSign))
mux.HandleFunc("PUT /api/p/{project}/store/object", proj(s.handleStorePut))
// storage-blind devices sync through. Reading the store is how a
// pull-only (read) device stays current; writing needs write.
mux.HandleFunc("GET /api/p/{project}/store/list", proj(PermRead, s.handleStoreList))
mux.HandleFunc("GET /api/p/{project}/store/object", proj(PermRead, s.handleStoreGet))
mux.HandleFunc("GET /api/p/{project}/store/exists", proj(PermRead, s.handleStoreExists))
mux.HandleFunc("POST /api/p/{project}/store/sign", proj(PermWrite, s.handleStoreSign))
mux.HandleFunc("PUT /api/p/{project}/store/object", proj(PermWrite, s.handleStorePut))
mux.Handle("GET /", s.frontend(static))
if s.Auth != nil {
@@ -479,27 +486,48 @@ func (s *Server) handleProjectList(w http.ResponseWriter, r *http.Request) {
http.Error(w, "this server does not host projects", http.StatusNotFound)
return
}
list := s.Projects.List()
visible := make([]Project, 0, len(list))
for _, p := range list {
if s.projectAllowed(r, p.ID) {
visible = append(visible, p)
// Each row carries the caller's own level, so the frontend can hide write
// affordances without a second fetch per project on every render.
visible := []projectView{}
for _, p := range s.Projects.List() {
perm := s.projectPerm(r, p.ID)
if !atLeast(perm, PermRead) {
continue
}
visible = append(visible, projectJSON(p, perm))
}
writeJSON(w, map[string]any{"projects": visible})
}
// projectJSON renders a project for the API with the caller's effective level.
// projectView is a Project plus the caller's own effective level on it.
// It embeds rather than re-listing fields on purpose: hand-listing them means
// every new Project field silently fails to reach the client until someone
// remembers to add it here.
type projectView struct {
Project
Perm string `json:"perm"`
}
func projectJSON(p Project, perm string) projectView {
// The grant list and the default belong to /api/p/{id}/permissions, which
// has its own gate; they'd be noise on every row of every project list.
p.Perms, p.Default = nil, ""
return projectView{p, perm}
}
func (s *Server) handleProjectGet(w http.ResponseWriter, r *http.Request) {
if s.Projects == nil {
http.Error(w, "this server does not host projects", http.StatusNotFound)
return
}
p, ok := s.Projects.Get(r.PathValue("project"))
if !ok || !s.projectAllowed(r, p.ID) {
perm := s.projectPerm(r, p.ID)
if !ok || !atLeast(perm, PermRead) {
http.Error(w, "no such project", http.StatusNotFound)
return
}
writeJSON(w, p)
writeJSON(w, projectJSON(p, perm))
}
// handleProjectCreate creates a project by name, or returns the existing one
@@ -539,7 +567,33 @@ func (s *Server) handleProjectCreate(w http.ResponseWriter, r *http.Request) {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
writeJSON(w, map[string]any{"project": p, "created": created})
if created {
// The creator is the project's first admin. Both writes are
// best-effort in the sense that a failure leaves a usable project
// governed by org owners — but report it rather than lie.
me := normEmail(s.requestUser(r).Email)
if me != "" {
if err := s.Projects.SetCreator(p.ID, me); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
// An org owner is already implicitly admin; an explicit grant on
// one is refused elsewhere, so don't write one here either.
if s.Dir == nil || org == "" || s.Dir.Role(org, me) != RoleOwner {
if err := s.Projects.SetPerm(p.ID, me, PermAdmin); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
}
p, _ = s.Projects.Get(p.ID)
}
} else if !atLeast(s.projectPerm(r, p.ID), PermRead) {
// GetOrCreate is create-or-join by name: without this, POSTing the
// name of a project you've been cut off from would hand back its id.
http.Error(w, permDenied(PermRead), http.StatusForbidden)
return
}
writeJSON(w, map[string]any{"project": projectJSON(p, s.projectPerm(r, p.ID)), "created": created})
}
// orgForCreate resolves which org a new project lands in: the explicitly
+4 -2
View File
@@ -190,9 +190,11 @@ func (s *Server) handleShareRevoke(w http.ResponseWriter, r *http.Request) {
http.Error(w, "sharing is not enabled on this server", http.StatusNotFound)
return
}
// This route is /api/shares/{token} — outside the proj() wrapper — so the
// level check lives here: minting and killing public links are the same
// authority.
sh, ok := s.Shares.Get(r.PathValue("token"))
if ok && !s.projectAllowed(r, sh.Project) {
http.Error(w, "you are not a member of this project's organization", http.StatusForbidden)
if ok && !s.requirePerm(w, r, sh.Project, PermWrite) {
return
}
if s.Shares.Revoke(r.PathValue("token")) {
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -5,8 +5,8 @@
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>BearDrive</title>
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32' fill='%23f5a623'><rect x='4' y='4' width='5.6' height='24'/><rect x='11.2' y='4' width='14.4' height='11.2'/><rect x='11.2' y='16.8' width='16.8' height='11.2'/></svg>">
<script type="module" crossorigin src="/assets/index-Vwh6tzvU.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-FkLsvBWJ.css">
<script type="module" crossorigin src="/assets/index-DNEdP71j.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-C9MMaLMG.css">
</head>
<body>
<div id="root"></div>
+11 -5
View File
@@ -455,7 +455,12 @@ Interpretation:
- **`remote`** — `(none — local only)` means changes are journaled locally but never leave the device.
- **`daemon`** — `running (pid N)` or `stopped`. If stopped, run `bdrive init <folder>` to start a daemon, or `bdrive sync <folder>` for a one-shot.
- **`files`** — tracked file count and total bytes.
- **`pending`** — local journal ops not yet pushed. Should be 0 shortly after a successful sync. Stuck > 0 usually means broken remote/creds, stopped daemon, or a custom `--remote-interval`.
- **`pending`** — local journal ops not yet pushed. Should be 0 shortly after a successful sync. Stuck > 0 usually means broken remote/creds, stopped daemon, or a custom `--remote-interval` — or an `access:` line below.
- **`access`** — only printed when the hub is refusing this device. Two forms, and they are not the same as being offline:
- `read-only (pull only) — N local change(s) stay on this device` — you have `read` on this project. The daemon keeps pulling teammates' changes and materializing them; your own edits are journaled locally and never pushed (never lost either — they go out if you're granted `write` again).
- `no access to this project — sync paused` — your access was revoked. Nothing is pulled, pushed, or written; your files are left exactly as they are. Re-granting access resumes on the next tick with nothing to do by hand.
Same two lines appear in the `bdrive sync` output as `remote: read-only (pull only)` / `remote: no access — sync paused`, and once (not per tick) in `daemon.log`. Ask a project admin or workspace owner for access.
### `bdrive log [<folder>]`
@@ -513,11 +518,12 @@ Useful when `pending` is stuck > 0, the daemon flips to `stopped` after a restar
### Diagnostic flow ("beardrive doesn't seem to be working")
1. `bdrive status` — folder listed? daemon `running`? `pending` stuck > 0?
1. `bdrive status` — folder listed? daemon `running`? `pending` stuck > 0? any `access:` line?
2. `daemon: stopped``bdrive init <folder>` to restart it.
3. `pending` stuck → `bdrive sync <folder>` and read the cycle output. Errors here point at the remote — see the cloud-storage troubleshooting table above.
4. Sync succeeds but the other device doesn't see changes → `bdrive sync` on the other device + `bdrive log` to confirm the op crossed over.
5. Daemon keeps dying → tail `~/.bdrive/volumes/<mount-id>/daemon.log` for the cause.
3. `access: read-only` or `access: no access` → this is a **permissions** answer, not a broken remote. Nothing to fix on the device; the project's admin (or a workspace owner) has to raise the level in the hub's Project settings → People. Local files and journal are intact either way, and the daemon resumes on its own once the grant changes.
4. `pending` stuck (with no `access:` line) → `bdrive sync <folder>` and read the cycle output. Errors here point at the remote — see the cloud-storage troubleshooting table above.
5. Sync succeeds but the other device doesn't see changes → `bdrive sync` on the other device + `bdrive log` to confirm the op crossed over.
6. Daemon keeps dying → tail `~/.bdrive/volumes/<mount-id>/daemon.log` for the cause.
---
+4 -1
View File
@@ -118,7 +118,10 @@ export default defineConfig({
},
{
label: "Concepts",
items: [{ label: "How sync works", slug: "concepts/how-it-works" }],
items: [
{ label: "How sync works", slug: "concepts/how-it-works" },
{ label: "Project permissions", slug: "concepts/permissions" },
],
},
],
}),
@@ -0,0 +1,93 @@
---
title: Project permissions
description: Four levels per project — none, read, write, admin — plus invite-only projects and what a read-only or revoked device actually does.
---
Organizations decide who is in your workspace. **Project permissions** decide
what each of those people can do with each project.
Nothing here changes an existing hub until you use it: the default is `write`
for every workspace member, which is exactly the behavior BearDrive has always
had.
## The four levels
Higher includes lower.
| Level | Can |
|---|---|
| `none` | nothing — the project is hidden: absent from the project list, every request denied |
| `read` | browse, view, render, download, per-file history, read heat — and **pull**, so a device stays current |
| `write` | everything in `read`, plus upload, sync push, and creating or revoking share links |
| `admin` | everything in `write`, plus rename the project, delete it, and edit its permissions |
Edit them in the hub UI: **Project settings → People**. Everyone with access
sees the section; only an admin gets live controls.
## Who is what
- **The default** applies to every workspace member without an explicit grant.
It starts as `write`.
- **Exceptions** are per-person grants. They are limited to members of the
project's workspace — there are no outside collaborators.
- **The creator** of a project becomes its first admin.
- **Workspace owners are implicitly admin** on every project in their
workspace, whether or not they appear in the list. Granting an owner a level
is refused rather than silently ignored: they always resolve to admin, so a
project admin can never lock an owner out.
- **A project always keeps at least one admin.** Removing or demoting the last
explicit admin grant is refused — including an admin trying to remove
themselves.
Projects created before permissions existed have no recorded creator, so they
start with no explicit admins and are governed by workspace owners until
someone grants one. If your workspace has no owner-level account left, you
cannot administer those projects — promote an owner first.
## Invite-only projects
Set the **default** to `No access` and the project becomes invite-only: only
the people listed as exceptions (and workspace owners) can see it. Everyone
else is treated exactly like a non-member — the project does not appear in
their project list at all.
## What a device does when it is refused
A hub saying *no* is not the same as a hub being unreachable, and BearDrive
keeps them apart. In both cases your local files and your local history are
left alone — losing access never deletes or reverts anything on your disk.
### Read-only: the daemon goes pull-only
Your teammates' changes keep arriving and materializing normally. Your own
edits are still journaled locally; they are simply never pushed. They are not
dropped either — if you are granted `write` again, they go out on the next
cycle with nothing to do by hand.
```
pending: 3 local change(s) not yet pushed
access: read-only (pull only) — 3 local change(s) stay on this device
```
### No access: sync pauses
Nothing is pulled, pushed, or written. The daemon keeps ticking cheaply and
re-checks, so a re-grant resumes on its own.
```
access: no access to this project — sync paused
```
Either line shows up in `bdrive status`, in `bdrive sync` output as
`remote: read-only (pull only)` / `remote: no access — sync paused`, and once
— on the transition, not every tick — in the project's `daemon.log`.
If you see one, there is nothing to fix on the device. Ask a project admin or
a workspace owner to change your level.
## Share links are not affected
Public `/s/<token>` links are anonymous by design and keep serving until
revoked. Cutting someone's access to a project does **not** kill share links
they minted — revoke those separately (`bdrive share --list` /
`bdrive share --revoke`, or the workspace's shares view).
@@ -49,6 +49,30 @@ Stamps session context — an agent session id, say — onto changes. It shows u
`bdrive log` and hub history, and keeps applying to daemon-committed changes
until `--note-ttl` expires.
### `bdrive status` — and the two degraded access states
Alongside `pending`, `status` prints an `access:` line whenever the hub is
refusing this device. Neither is the same as being offline, and neither ever
touches your files:
```
pending: 3 local change(s) not yet pushed
access: read-only (pull only) — 3 local change(s) stay on this device
```
- **`read-only (pull only)`** — you have `read` on the project. The daemon keeps
pulling teammates' changes; your own edits stay journaled locally, never
pushed and never dropped. They go out if you are granted `write` again.
- **`no access to this project — sync paused`** — your access was revoked.
Nothing is pulled, pushed, or written; the working folder is left exactly as
it is. Re-granting resumes on the next tick with no manual step.
`bdrive sync` shows the same two as `remote: read-only (pull only)` /
`remote: no access — sync paused`, and the daemon logs each once on
transition rather than on every tick. Both are permission answers: they are
fixed in the hub's Project settings → People, not on the device. See
[Project permissions](/concepts/permissions/).
### `bdrive login` and switching hubs
`bdrive login` remembers the server in `settings.json` under the bdrive home. To
@@ -98,5 +98,11 @@ falling back to relaying when the backend can't presign. Journals are never
presigned — only immutable blobs.
Client pushes and project creation require the server to run with `--upload`.
Against a read-only hub, clients still pull and their pushes wait (offline
semantics) until allowed.
Against a read-only hub, clients still pull, and `bdrive status` says
`access: read-only (pull only)` rather than reporting a phantom outage.
Per-project permissions gate the same API: `read` admits `store/list`,
`store/object`, and `store/exists` — everything a pull needs — while
`PUT store/object` and `store/sign` need `write`. That is what makes a
read-only teammate's device pull-only instead of stuck. See
[Project permissions](/concepts/permissions/).