Files
69e7231a70 feat(hub): per-project permissions — none/read/write/admin, invite-only projects, honest degraded sync (#46)
Access was binary and org-wide: any org member got full read+write on every
project. Now each project carries four ordered levels, resolved by one
resolver and enforced at one choke point.

- `projectPerm` (perms.go) replaces `projectAllowed`; `proj(level, h)` in
  server.go gates every per-project route by the level it declares at
  registration, so no handler grows its own check.
- `Project` gains Creator/Default/Perms. `Default == ""` means write, so an
  upgraded hub behaves identically until someone edits permissions.
- Creator becomes the first project admin; org owners are implicitly admin
  everywhere in their org and a grant naming one is refused, not ignored; a
  project always keeps at least one explicit admin.
- Default `none` makes a project invite-only. A `none` member is treated
  exactly like a non-member, including on create-or-join by name.
- Rename/delete move from org-owner-only to project `admin`.
- Both metadata backends persist it: the file store rides along, the SQL
  store gains `project_perms` plus an idempotent ALTER for the two new
  columns (migrate() had only ever created tables).

Client side, a refusal stops looking like an outage: `remote.ErrForbidden`
plus `Result.ReadOnly` (push refused → pull-only) and `Result.NoAccess`
(pull refused → paused, working folder untouched). Neither sets Offline,
neither loses a local op, and re-granting self-heals on the next cycle.
`bdrive status`/`sync` and the daemon (once, on transition) say which.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 10:41:57 +09:00

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)
}
}