mirror of
https://github.com/runbear-io/beardrive.git
synced 2026-08-25 08:08:08 +02:00
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:
co-authored by
Claude Opus 5
parent
1463fd8979
commit
69e7231a70
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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) ----
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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); }
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"])
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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")) {
|
||||
|
||||
+1
-1
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
@@ -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>
|
||||
|
||||
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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/).
|
||||
|
||||
Reference in New Issue
Block a user