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/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(); } 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