From 53a4849119a5dd40d5248e5df0b80c5486029423 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Wed, 28 Oct 2026 18:28:32 +0000 Subject: [PATCH 1/2] Admins get no access to other people's workspaces (v0.27.1) Anthony: "in readm3 i can share to other users's workspaces is that a bug or what? seems pretty intrusive." A site admin was treated as owner of every organization and every document: organizations_list returned all workspaces (so the share and new-document pickers offered everyone's personal workspace), documents_list returned every document including private ones, and access()/orgRole()/validateTeam() let an admin read, edit, delete, or create documents anywhere. Now admin is a site role only. Workspace and document access come from membership, ownership, grants and share links, the same as for everyone. The admin_* operations (users list, claim) and the billing experiment view keep their explicit user.admin checks. Test: the super-admin claim test now asserts the admin cannot list, open, edit, delete or create in another user's workspace, and that the owner's document is untouched (fails on the old code). Co-Authored-By: Claude Opus 5.5 --- package.json | 2 +- server/operations.ts | 17 +++++++---------- server/store.ts | 13 ++++++------- server/test/sharing.test.ts | 29 +++++++++++++++++++++++------ src/cli.ts | 2 +- 5 files changed, 38 insertions(+), 25 deletions(-) diff --git a/package.json b/package.json index 957406f..3421cc8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@profullstack/readm3", - "version": "0.27.0", + "version": "0.27.1", "description": "Markdown editing and sharing for people and teams. Web/PWA, CLI, API and MCP with version history, view/edit links, and a terminal reader.", "type": "module", "license": "MIT", diff --git a/server/operations.ts b/server/operations.ts index faa9842..966a9c2 100644 --- a/server/operations.ts +++ b/server/operations.ts @@ -65,11 +65,11 @@ export function operate( ), }; case "organizations_list": + // Only the caller's own memberships, admins included: a site admin must not + // see (or share into) other people's workspaces. return store.all( - user.admin - ? "SELECT o.*,'owner' AS role FROM organizations o ORDER BY o.createdAt DESC" - : "SELECT o.*,m.role FROM organizations o JOIN members m ON m.orgId=o.id WHERE m.userId=? ORDER BY o.createdAt DESC", - ...(user.admin ? [] : [user.id]), + "SELECT o.*,m.role FROM organizations o JOIN members m ON m.orgId=o.id WHERE m.userId=? ORDER BY o.createdAt DESC", + user.id, ); case "organizations_create": { const org = { @@ -328,12 +328,9 @@ export function operate( return { ok: true }; } case "documents_list": { - const accessible = user.admin - ? "1=1" - : `(d.ownerId=? OR EXISTS (SELECT 1 FROM permissions p WHERE p.documentId=d.id AND p.userId=?) OR (d.access!='private' AND EXISTS (SELECT 1 FROM members m WHERE m.orgId=d.orgId AND m.userId=?) AND (d.teamId IS NULL OR EXISTS (SELECT 1 FROM team_members tm WHERE tm.teamId=d.teamId AND tm.userId=?))))`; - const values: string[] = user.admin - ? [] - : [user.id, user.id, user.id, user.id]; + // Same rule for everyone: owned, granted, or visible through membership. + const accessible = `(d.ownerId=? OR EXISTS (SELECT 1 FROM permissions p WHERE p.documentId=d.id AND p.userId=?) OR (d.access!='private' AND EXISTS (SELECT 1 FROM members m WHERE m.orgId=d.orgId AND m.userId=?) AND (d.teamId IS NULL OR EXISTS (SELECT 1 FROM team_members tm WHERE tm.teamId=d.teamId AND tm.userId=?))))`; + const values: string[] = [user.id, user.id, user.id, user.id]; if (args.orgId) values.push(orgId()); if (args.search) values.push(text(args.search, "Search")); const docs = store.all( diff --git a/server/store.ts b/server/store.ts index 3fd1401..cfa8ab6 100644 --- a/server/store.ts +++ b/server/store.ts @@ -225,12 +225,12 @@ export class Store { ); return { id: sessionId, token, expiresAt, label }; } + /** + * The caller's role in an organization: membership only. Site admins get no + * standing in other people's workspaces; admin powers live on the admin_* + * operations, which check user.admin themselves. + */ orgRole(user: User, orgId: string) { - if ( - user.admin && - this.get("SELECT id FROM organizations WHERE id=?", orgId) - ) - return "owner"; return this.get<{ role: string }>( "SELECT role FROM members WHERE orgId=? AND userId=?", orgId, @@ -252,7 +252,7 @@ export class Store { doc: Doc, shareToken?: string, ): "owner" | "edit" | "view" | null { - if (user && (user.admin || user.id === doc.ownerId)) return "owner"; + if (user && user.id === doc.ownerId) return "owner"; let role: "edit" | "view" | null = null; if (user) { role = @@ -332,7 +332,6 @@ export class Store { if (!this.get("SELECT id FROM teams WHERE id=? AND orgId=?", teamId, orgId)) throw new HttpError(400, "Team must belong to this organization."); if ( - !user.admin && this.orgRole(user, orgId) !== "owner" && !this.get( "SELECT userId FROM team_members WHERE teamId=? AND userId=?", diff --git a/server/test/sharing.test.ts b/server/test/sharing.test.ts index 9f74fc4..70adc8a 100644 --- a/server/test/sharing.test.ts +++ b/server/test/sharing.test.ts @@ -437,7 +437,7 @@ test("pinned versions stay immutable, restore adds history, and deletion invalid } }); -test("the one-time super-admin claim is secret gated and grants access across all owners", async () => { +test("the one-time super-admin claim is secret gated and gives no access to other people's workspaces", async () => { const before = process.env.READM3_ADMIN_BOOTSTRAP_SECRET; process.env.READM3_ADMIN_BOOTSTRAP_SECRET = "test-admin-" + id(); const f = fixture(); @@ -470,10 +470,14 @@ test("the one-time super-admin claim is secret gated and grants access across al ).status, ).toBe(409); expect((await admin.action("admin_users")).data).toHaveLength(2); + // Admin is a site role, not a key to everyone's workspace. + const orgs = (await admin.action("organizations_list")).data; + expect(orgs.map((o: { id: string }) => o.id)).not.toContain(first.org.id); + const docs = (await admin.action("documents_list", {})).data; + expect(docs.map((d: { id: string }) => d.id)).not.toContain(doc.id); expect( - (await admin.action("documents_get", { documentId: doc.id })).data - .canManage, - ).toBe(true); + (await admin.action("documents_get", { documentId: doc.id })).status, + ).not.toBe(200); expect( ( await admin.action("documents_update", { @@ -482,10 +486,23 @@ test("the one-time super-admin claim is secret gated and grants access across al source: "# Moderated", }) ).status, - ).toBe(200); + ).not.toBe(200); expect( (await admin.action("documents_delete", { documentId: doc.id })).status, - ).toBe(200); + ).not.toBe(200); + expect( + ( + await admin.action("documents_create", { + orgId: first.org.id, + title: "Pushed.md", + source: "# Into someone else's workspace", + }) + ).status, + ).toBe(403); + expect( + (await first.action("documents_get", { documentId: doc.id })).data.version + .source, + ).toBe("# Private"); } finally { f.store.close(); if (before === undefined) delete process.env.READM3_ADMIN_BOOTSTRAP_SECRET; diff --git a/src/cli.ts b/src/cli.ts index 60cc95b..506aa8c 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -12,7 +12,7 @@ import { run, type ViewerOptions } from "./viewer.ts"; import { isCloudCommand, cloudMain, CLOUD_USAGE } from "./cloud-cli.ts"; import { isDesktopCommand, desktopMain, installedDesktop, prefersDesktop, DESKTOP_USAGE } from "./desktop-cli.ts"; -export const VERSION = "0.27.0"; +export const VERSION = "0.27.1"; /** The help text, exported so a test can hold it to the flags it documents. */ export const USAGE = `readm3 — a plain text editor for Markdown and code From e7f941ba1339034d6928b3bd9bd1e5a963f7b445 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Wed, 28 Oct 2026 18:31:20 +0000 Subject: [PATCH 2/2] Browser test: super admin gets no access to another user's private file Co-Authored-By: Claude Opus 5.5 --- site/test/sharing.spec.ts | 17 +++++++---------- 1 file changed, 7 insertions(+), 10 deletions(-) diff --git a/site/test/sharing.spec.ts b/site/test/sharing.spec.ts index 3bbf6a8..3e08870 100644 --- a/site/test/sharing.spec.ts +++ b/site/test/sharing.spec.ts @@ -229,7 +229,7 @@ test("revoked links stop working and cloud documents never enter the offline wor } }); -test("super-admin setup grants the account administration of another user's private file", async ({ +test("super-admin setup gives no access to another user's private file", async ({ page, context, browser, @@ -244,18 +244,15 @@ test("super-admin setup grants the account administration of another user's priv await register(context, "superadmin_" + suffix()); await page.goto("/admin#claim=browser-test-admin-secret"); await expect(page.locator("#account-label")).toContainText("Super admin"); + // Admin is a site role, not a key to other people's workspaces. await expect( - page.getByRole("link", { name: "Project plan.md" }).first(), - ).toBeVisible(); + page.getByRole("link", { name: "Project plan.md" }), + ).toHaveCount(0); await page.goto(`/viewer?doc=${doc.id}`); - await expect(page.locator("#edit-mode")).toBeEnabled(); - await expect(page.locator("#share")).toBeVisible(); - await page.locator("#edit-mode").click(); - await editorInput(page).fill("# Super-admin correction"); - await page.locator("#cloud-save").click(); - await expect(page.locator("#save-status")).toHaveText( - "Version saved online", + await expect(page.locator("body")).toContainText( + "if this file is private", ); + await expect(page.locator("#cloud-save")).toBeHidden(); } finally { await other.close(); }