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>
360 lines
14 KiB
Go
360 lines
14 KiB
Go
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"])
|
|
}
|
|
}
|