From 14a9b7fd11c8fdac699ac109678cbdb1da78c340 Mon Sep 17 00:00:00 2001 From: huyplb Date: Sun, 25 Oct 2026 22:58:36 -0600 Subject: [PATCH] feat(rbac): read-only is a permission; routes declare that they read The Demo role was held read-only by a URL allow-list in platform code plus role-name checks. Now: - Routes declare `reads` beside their handler (a SELECT goes over POST but changes nothing); the binder passes it as route config. - `app.write`: an account without it may call a route only when it is a GET or declares `reads`. `account.self`: profile, password, reset, verify and sign-out-others. Both are install permissions every role from viewer up has; the Demo role is simply the role without them, and an admin can make any role read-only (or let the demo save) from the role matrix. - A one-time grant gives both to roles customized before they existed, so nothing that could save stops saving; an admin's later removal sticks. - The test that calls every guarded write now keeps the list of `reads` routes reviewed instead of an allow-list. Co-Authored-By: Claude Opus 5.5 --- apps/e2e/src/tests/demo-role.test.ts | 2 +- .../web/src/frontend/app/shell/DemoBanner.tsx | 6 +- .../src/frontend/app/shell/ProfileMenu.tsx | 7 +- docs/DEPLOYMENT.md | 7 ++ packages/server/src/api/demo-account.test.ts | 34 ++++++++-- .../src/features/access/access.routes.ts | 3 +- .../src/features/auth/auth.routes.test.ts | 2 +- .../server/src/features/auth/auth.routes.ts | 16 ++--- .../src/features/compare/compare.routes.ts | 4 +- .../src/features/schema/schema.routes.ts | 7 +- .../src/features/sql-editor/editor.routes.ts | 6 +- .../server/src/features/users/user.routes.ts | 4 +- .../features/workspaces/workspaces.routes.ts | 4 +- .../src/platform/authorization/demo-access.ts | 68 ------------------- .../authorization/rbac.service.test.ts | 24 +++++++ .../platform/authorization/rbac.service.ts | 7 ++ .../platform/authorization/read-only.test.ts | 30 ++++++++ .../src/platform/authorization/read-only.ts | 33 +++++++++ .../server/src/platform/http/fastify-bind.ts | 2 + packages/server/src/platform/http/router.ts | 36 +++++++--- .../src/platform/identity/auth.guard.ts | 13 ++-- .../src/platform/identity/auth.service.ts | 9 +-- packages/shared/src/permissions.ts | 24 +++++-- 23 files changed, 218 insertions(+), 130 deletions(-) delete mode 100644 packages/server/src/platform/authorization/demo-access.ts create mode 100644 packages/server/src/platform/authorization/read-only.test.ts create mode 100644 packages/server/src/platform/authorization/read-only.ts diff --git a/apps/e2e/src/tests/demo-role.test.ts b/apps/e2e/src/tests/demo-role.test.ts index e7cf1ff92..871fbe345 100644 --- a/apps/e2e/src/tests/demo-role.test.ts +++ b/apps/e2e/src/tests/demo-role.test.ts @@ -132,7 +132,7 @@ describe.skipIf(!hasSqlite3())('a published demo', () => { ]; for (const r of refused) { expect(r.status, JSON.stringify(r.json)).toBe(403); - expect(r.json.error).toMatch(/read-only demo/); + expect(r.json.error).toMatch(/read-only/); } // A write SQL statement is refused by the permissions the Demo role lacks. const conn = (await api('GET', '/connections', undefined, demoCookie)).json.connections.find( diff --git a/apps/web/src/frontend/app/shell/DemoBanner.tsx b/apps/web/src/frontend/app/shell/DemoBanner.tsx index e0a892c8f..c58d72750 100644 --- a/apps/web/src/frontend/app/shell/DemoBanner.tsx +++ b/apps/web/src/frontend/app/shell/DemoBanner.tsx @@ -8,12 +8,12 @@ */ import React from 'react'; import { Eye } from 'lucide-react'; -import { isDemoRole } from '@foxschema/shared'; import { useAuthStore } from '@/platform/authStore'; export const DemoBanner: React.FC = () => { const demo = useAuthStore((s) => s.demo); - const role = useAuthStore((s) => s.user?.role); + /** Read-only: the account cannot save (the Demo role, or any role without app.write). */ + const readOnly = useAuthStore((s) => s.user != null && !s.can('app.write')); if (!demo?.on) return null; return (
{ Demo - {isDemoRole(role) + {readOnly ? 'Explore the sample databases: browse, compare, and run queries. Nothing here can be saved or changed.' : 'Demo mode is on: visitors see this banner, and the sign-in page shows the demo login.'} diff --git a/apps/web/src/frontend/app/shell/ProfileMenu.tsx b/apps/web/src/frontend/app/shell/ProfileMenu.tsx index 6cc55d790..4ab4f8625 100644 --- a/apps/web/src/frontend/app/shell/ProfileMenu.tsx +++ b/apps/web/src/frontend/app/shell/ProfileMenu.tsx @@ -1,5 +1,4 @@ import React, { lazy, useEffect, useRef, useState } from 'react'; -import { isDemoRole } from '@foxschema/shared'; import { createPortal } from 'react-dom'; import { LogOut, Palette, ArrowUpCircle, Globe, Shield, MonitorX, MessageSquarePlus, UserPlus } from 'lucide-react'; import { apiSignOutOthers } from '@/shared/api/authApi'; @@ -23,6 +22,8 @@ export function ProfileMenu(): React.ReactElement | null { const { user, logout, launch } = useAuthStore(); const setActiveView = useUiStore((s) => s.setActiveView); const canAdminAccess = useAuthStore((s) => s.can('admin.users') || s.can('admin.roles')); + /** A shared login (the Demo role) does not run its own account; the server refuses it too. */ + const managesOwnAccount = useAuthStore((s) => s.can('account.self')); const setRegisterDialogOpen = useAuthStore((s) => s.setRegisterDialogOpen); const [open, setOpen] = useState(false); const [showAdmin, setShowAdmin] = useState(false); @@ -159,8 +160,8 @@ export function ProfileMenu(): React.ReactElement | null { foxschema.com - {/* A demo login is every visitor at once; the server refuses this for it. */} - {!isDemoRole(user?.role) && ( + {/* A shared login is every visitor at once; the server refuses this for it. */} + {managesOwnAccount && (