mirror of
https://github.com/runbear-io/beardrive.git
synced 2026-08-25 08:08:08 +02:00
Access was binary and org-wide: any org member got full read+write on every project. Now each project carries four ordered levels, resolved by one resolver and enforced at one choke point. - `projectPerm` (perms.go) replaces `projectAllowed`; `proj(level, h)` in server.go gates every per-project route by the level it declares at registration, so no handler grows its own check. - `Project` gains Creator/Default/Perms. `Default == ""` means write, so an upgraded hub behaves identically until someone edits permissions. - Creator becomes the first project admin; org owners are implicitly admin everywhere in their org and a grant naming one is refused, not ignored; a project always keeps at least one explicit admin. - Default `none` makes a project invite-only. A `none` member is treated exactly like a non-member, including on create-or-join by name. - Rename/delete move from org-owner-only to project `admin`. - Both metadata backends persist it: the file store rides along, the SQL store gains `project_perms` plus an idempotent ALTER for the two new columns (migrate() had only ever created tables). Client side, a refusal stops looking like an outage: `remote.ErrForbidden` plus `Result.ReadOnly` (push refused → pull-only) and `Result.NoAccess` (pull refused → paused, working folder untouched). Neither sets Offline, neither loses a local op, and re-granting self-heals on the next cycle. `bdrive status`/`sync` and the daemon (once, on transition) say which. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
87 lines
2.8 KiB
Go
87 lines
2.8 KiB
Go
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)
|
|
}
|
|
}
|