Files
beardrive/internal/webapp/perms_test.go
T
Snow Lee (Sungwon)andGitHub 5f1ac98dae feat(hub): see what each agent session read, not just what it changed (BEA-98) (#135)
History showed what an agent run CHANGED. What it read lived in a daily
aggregate with no session dimension, so the two could not be joined and
nobody could answer "when my agent answered, what did it look at — and was
it the fresh version or archive/retired-spec.md?".

The join is one string carried through four places: hook -> spool -> hub ->
run card. A run card now marks each change the run also read, lists the
files it read and never touched, and says on screen why a read can be
missing.

The three landmines the issue asks be named here:

1. Op.Note is USER-SETTABLE (`bdrive sync --note`), so joining reads to
   writes on the note string would let any member with write access forge a
   note that collides with a teammate's run card and hang their reads off
   it. Fixed by adding journal.Op.Session — set only by `bdrive sync
   --hook`, never by --note — and joining on that. The note stays settable
   and stays untrusted; the join simply never reads it. Op.Session is
   additive JSONL and, like Mtime, is never an input to Less or Replay, so
   replay determinism is untouched and older ops carry "".

   The read half has the same hole one step further on: POST /reads takes
   the session id from the CLIENT, so a member could report reads under a
   teammate's session and paint files onto their card. Every session row is
   therefore pinned to the ownsDevice-validated device, and the query
   requires ?session= AND ?device= together — a forged row can only be found
   under the forger's own device, which MayActAs guarantees is never
   somebody else's.

2. BUCKET CARDINALITY. Putting the session in the read_stats key would take
   a 2k-file project from ~2k to ~100k rows/day, into a table ReadLedger
   loads whole at boot and full-scans on every heat request, hub-wide — so
   it would slow the Dashboard for projects that never ran an agent. This
   is the escape hatch the spec itself names, taken up front: session rows
   live in their own read_sessions repo, outside ReadLedger.byKey. No
   read_stats PK migration, no change to the resident-row count, ?by=device
   byte-identical. They get their own retention (session_retention_days,
   default 30) which DELETES rather than folds — no heat total was ever
   derived from them.

3. READS ARE RECORDED ONLY FOR PATHS IN THE CURRENT REPLAY, so a session
   that read a file it then deleted shows a change with no read. That is by
   design, and the run card says so in its footer rather than leaving it to
   read as a bug.

Privacy ruling, written into internal/webapp/reads.go before anything
serves it: a session id appears only in History responses on the op that
carries it, and as a ?session= filter INPUT. It is never enumerated — no
listing endpoint, no session column in /heat output, nothing new in
?by=device.

Also: PendingReads now dedupes on (path, session), not path alone. Two
agent sessions on one device between syncs used to collapse into one event
carrying whichever session flushed last — one session's reads silently
credited to another.

Tests: journal round-trip + Less-ignores-Session; the forge test (`sync
--note "claude-code session <someone-else's>"` leaves Session empty); a
multi-device syncer test carrying the session through convergence; spool
per-session dedup; hub round-trip, cross-device forge, query contract and
non-enumeration; db_conformance on file, sqlite AND postgres; runs.ts
grouping incl. legacy fallback; a Playwright spec on the seeded run card.
2026-08-11 04:18:53 +09:00

437 lines
17 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) {
h, srv, cookies, p, _ = permHubAt(t)
return
}
// permHubAt is permHub plus the storage root, for tests that seed a journal
// through newFakeRemoteAt.
func permHubAt(t *testing.T) (h http.Handler, srv *Server, cookies map[string]*http.Cookie, p Project, root string) {
t.Helper()
srv, _, root = 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
// A served hub always has one (cmd/bdrive/web.go), and without it every
// device-ownership decision in store.go returns before it can decide
// anything — so a journal push through this fixture would measure
// org/project permission only. See TestSec_Audit_PermHubRefusesAForeign
// JournalOutOfTheBox.
devices, err := OpenDeviceRegistry(filepath.Join(t.TempDir(), "devices.json"))
if err != nil {
t.Fatal(err)
}
srv.Devices = devices
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, root
}
// 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 + "/"
// A live link to re-date: PATCH /api/shares/{token} resolves the token
// before the level check, so an unknown one would 404 instead of 403.
sh, err := srv.Shares.Create(p.ID, "x.md", "alice@x.io", 0)
if err != nil {
t.Fatal(err)
}
writes := []struct {
method, url string
body any
}{
{"PATCH", "/api/shares/" + sh.Token, map[string]string{"expires_in": "24h"}},
{"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"}},
{"POST", base + "restore", map[string]string{"path": "x.md", "sha": strings.Repeat("a", 64)}},
{"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)
}
}
// A read member sees the People matrix and the project's public links, creator
// included. Deliberate, and answered by the owner (BEA-69, 2026-08-03): the
// benchmark is Google Drive, where a viewer can see who has access — hiding
// grants makes "why can't I edit this?" unanswerable, and the per-file
// ShareBanner already tells a read-only member the file they rely on is
// public. TestReadOnlyMemberRoutes only checks these two routes are not 403,
// so an "improvement" that returned an empty body to read members would pass
// every other test in the repo. Tightening either route to PermWrite is a
// product decision, not a hardening fix; this test is here so it cannot happen
// by accident.
func TestReadMemberSeesSharesAndGrants(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)
}
sh, err := srv.Shares.Create(p.ID, "x.md", "alice@x.io", 0)
if err != nil {
t.Fatal(err)
}
rec := doAs(t, h, "GET", "/api/p/"+p.ID+"/shares", nil, c["bob"])
if rec.Code != 200 {
t.Fatalf("GET shares as a read member: %d %s", rec.Code, rec.Body)
}
var out struct {
Shares []map[string]any `json:"shares"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
t.Fatal(err)
}
if len(out.Shares) != 1 {
t.Fatalf("shares = %+v, want the one link alice minted", out.Shares)
}
got := out.Shares[0]
if got["token"] != sh.Token || got["path"] != "x.md" {
t.Errorf("share = %+v, want token %q on x.md", got, sh.Token)
}
if url, _ := got["url"].(string); !strings.HasSuffix(url, "/s/"+sh.Token) {
t.Errorf("url = %q, want it to end in /s/%s", url, sh.Token)
}
// The field the issue is actually about, and the one a hardening pass
// would strip first. It exposes nothing new: the People table already
// shows every member's email to every member.
if got["creator"] != "alice@x.io" {
t.Errorf("creator = %v, want alice@x.io", got["creator"])
}
// And the grants alongside it, from the same read-only session.
rec = doAs(t, h, "GET", "/api/p/"+p.ID+"/permissions", nil, c["bob"])
if rec.Code != 200 || !strings.Contains(rec.Body.String(), "bob@x.io") {
t.Fatalf("GET permissions as a read member: %d %s", rec.Code, rec.Body)
}
}
// 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"])
}
}