Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/e2e/src/tests/demo-role.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
6 changes: 3 additions & 3 deletions apps/web/src/frontend/app/shell/DemoBanner.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<div
Expand All @@ -24,7 +24,7 @@ export const DemoBanner: React.FC = () => {
<Eye className="w-3.5 h-3.5 text-sky-300 shrink-0" />
<span className="font-bold">Demo</span>
<span className="text-sky-200/90">
{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.'}
</span>
Expand Down
7 changes: 4 additions & 3 deletions apps/web/src/frontend/app/shell/ProfileMenu.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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);
Expand Down Expand Up @@ -159,8 +160,8 @@ export function ProfileMenu(): React.ReactElement | null {
<Globe className="w-4 h-4" /> foxschema.com
</a>

{/* 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 && (
<button
type="button"
data-testid="profile-sign-out-others"
Expand Down
7 changes: 7 additions & 0 deletions docs/DEPLOYMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -471,6 +471,13 @@ settings make that safe:
demo account) or sign the other visitors out. Its permissions are edited like
any role's; adding `editor.dml` there turns the demo into a sandbox, since
the SQL it may run follows those permissions.

Read-only is a permission, not the role's name: the Demo role is simply the
one without **Save and change** (`app.write`) and **Manage own account**
(`account.self`). Remove `app.write` from any role and its accounts read only
too; give it to Demo and the demo can save. Without it, an account may call a
route only if it is a GET or declares that it reads (running a SELECT,
comparing, loading a schema, its own preferences, switching workspace).
- **Demo mode** (Access control → Sign-in → Demo mode). Shows a demo banner on
every page and your sign-in hint — typically the shared login — on the
sign-in page. Anyone can read the hint.
Expand Down
34 changes: 27 additions & 7 deletions packages/server/src/api/demo-account.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ process.env.APP_ENCRYPTION_KEY ||= '0'.repeat(64);

import { DEFAULT_ROLE_PERMISSIONS } from '@foxschema/shared';
import { getStore } from '../database/store';
import { DEMO_ALLOWED_WRITES, DEMO_READ_ONLY } from '../platform/authorization/demo-access';
import { READ_ONLY_ACCOUNT as DEMO_READ_ONLY } from '../platform/authorization/read-only';
import { FEATURES, createFeatureContext } from '../app/feature-registry';

let port = 0;
Expand All @@ -29,17 +29,34 @@ let demoId = '';
let viewer = '';

/** Every route the features mount, with its mount's access, built once. */
let routeTable: Array<{ method: string; path: string; access: string }> | null = null;
let routeTable: Array<{ method: string; path: string; access: string; reads: boolean }> | null = null;
function mountedRoutes() {
if (!routeTable) {
const ctx = createFeatureContext();
routeTable = FEATURES.flatMap((f) => f.mounts).flatMap((m) =>
m.routes(ctx).flatten(m.prefix).map((r) => ({ method: r.method, path: r.path, access: m.access }))
m.routes(ctx).flatten(m.prefix).map((r) => ({ method: r.method, path: r.path, access: m.access, reads: r.reads === true }))
);
}
return routeTable;
}

/**
* The writes declared to only read (`reads` beside their handler). A read-only
* account (the Demo role) may call exactly these: adding one here is a review
* of whether it really changes nothing a visitor would share.
*/
const REVIEWED_READS = [
'POST /api/compare',
'POST /api/schema/db-access',
'POST /api/schema/list',
'POST /api/schema/load',
'POST /api/schema/table-insight',
'POST /api/sql/code-cell/transpile',
'POST /api/sql/execute',
'POST /api/workspaces/:id/select',
'PUT /api/user/preferences',
];

const DEMO_EMAIL = 'demo-visitor@example.com';
const DEMO_PASSWORD = 'shared-demo-login-9';

Expand Down Expand Up @@ -126,7 +143,7 @@ describe('a demo account', () => {
it('is refused every write behind sign-in except the few that read — before the route runs', async () => {
const guarded = mountedRoutes().filter((r) => (r.access === 'user' || r.access === 'registered') && r.method !== 'GET');
expect(guarded.length).toBeGreaterThan(50);
const allowed = new Set(DEMO_ALLOWED_WRITES.map((r) => `${r.method} ${r.path}`));
const allowed = new Set(guarded.filter((r) => r.reads).map((r) => `${r.method} ${r.path}`));

const wrong: string[] = [];
for (const route of guarded) {
Expand All @@ -139,9 +156,12 @@ describe('a demo account', () => {
expect(wrong, 'allow-listed routes must pass the guard; every other write must be refused').toEqual([]);
}, 120_000);

it('lists only routes that exist — a renamed route fails here instead of closing quietly', () => {
const served = new Set(mountedRoutes().map((r) => `${r.method} ${r.path}`));
expect(DEMO_ALLOWED_WRITES.map((r) => `${r.method} ${r.path}`).filter((k) => !served.has(k))).toEqual([]);
it('declares reads on exactly the reviewed writes — a new one is a deliberate change here', () => {
const declared = mountedRoutes()
.filter((r) => r.reads && r.method !== 'GET')
.map((r) => `${r.method} ${r.path}`)
.sort();
expect(declared).toEqual(REVIEWED_READS);
});

it('runs the reads it is allowed: the guard lets them through to their own checks', async () => {
Expand Down
3 changes: 2 additions & 1 deletion packages/server/src/features/access/access.routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import { scopeOf } from '../../platform/http/scope';
import type { WorkspaceScope } from '../../platform/http/scope';
import type { FastifyReply } from 'fastify';
import type { AppRequest } from '../../platform/http/types';
import { Router } from '../../platform/http/router';
import { Router, reads } from '../../platform/http/router';
import type { ConnectionModule } from '@foxschema/db';
import { requireAnyPermission, requirePermissions } from '../../platform/authorization/rbac.guard';
import { rateLimit } from '../../platform/guards/rate-limit';
Expand Down Expand Up @@ -195,6 +195,7 @@ export function createAccessRoutes(deps: AccessRouteDeps): Router {

router.post(
'/schema/db-access',
reads,
dbAccessLimiter,
// Access workspace tabs use access.*; Utilities → Database Access uses
// utility.access. Either family may load the catalog.
Expand Down
2 changes: 1 addition & 1 deletion packages/server/src/features/auth/auth.routes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import type { AuthMailer } from '../../platform/identity/auth-mail';
import type { SignupModule } from '../users';
import { createAuthRoutes } from './auth.routes';

const USER = { id: 'u-1', email: 'u-1@example.test', role: 'editor' };
const USER = { id: 'u-1', email: 'u-1@example.test', role: 'editor', permissions: ['app.write', 'account.self'] };
const WORKSPACE = { id: 'personal-u-1', name: 'Personal' };

function routes() {
Expand Down
16 changes: 8 additions & 8 deletions packages/server/src/features/auth/auth.routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,7 @@ import { DemoMode } from '../../platform/identity/demo-mode.service';
import { SignupModule } from '../users';
import { AppSettingsStore } from '../../platform/settings/app-settings.service';
import { sendError } from '../../platform/http/respond';
import { DEMO_READ_ONLY } from '../../platform/authorization/demo-access';
import { isDemoRole } from '@foxschema/shared';
import { READ_ONLY_ACCOUNT } from '../../platform/authorization/read-only';
import { ServiceError } from '../../platform/contracts/actor';
import { rateLimit } from '../../platform/guards/rate-limit';
import { getLogger } from '../../platform/logger/logger';
Expand Down Expand Up @@ -146,9 +145,10 @@ export function createAuthRoutes(
sendError(res, 'unauthenticated', 'Sign in to verify your email.');
return;
}
// Any visitor could mail the demo address over and over: its profile is locked.
if (isDemoRole(session.user.role)) {
sendError(res, 'forbidden', DEMO_READ_ONLY);
// A shared login (the Demo role) does not manage its own account: any
// visitor could mail its address over and over.
if (!session.user.permissions.includes('account.self')) {
sendError(res, 'forbidden', READ_ONLY_ACCOUNT);
return;
}
if ((await auth.emailVerification(session.user.id)).verified) {
Expand Down Expand Up @@ -253,9 +253,9 @@ export function createAuthRoutes(
sendError(res, 'unauthenticated', 'Authentication required');
return;
}
// A demo login is every visitor at once: this would sign all the others out.
if (isDemoRole(user.role)) {
sendError(res, 'forbidden', DEMO_READ_ONLY);
// A shared login is every visitor at once: this would sign all the others out.
if (!user.permissions.includes('account.self')) {
sendError(res, 'forbidden', READ_ONLY_ACCOUNT);
return;
}
res.send({ signedOut: await auth.signOutOtherSessions(user.id, token) });
Expand Down
4 changes: 2 additions & 2 deletions packages/server/src/features/compare/compare.routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
*
* Declaration only: path, method, guards. No logic.
*/
import { Router } from '../../platform/http/router';
import { Router, reads } from '../../platform/http/router';
import { requirePermissions } from '../../platform/authorization/rbac.guard';
import { makeCompareController } from './compare.controller';
import { makeCompareHandlers } from './compare.handler';
Expand All @@ -15,7 +15,7 @@ export function createCompareRoutes(deps: { compareService: CompareService }): R
const router = Router();
const handlers = makeCompareHandlers(makeCompareController(deps));

router.post('/compare', requirePermissions('schema.compare'), handlers.compare);
router.post('/compare', reads, requirePermissions('schema.compare'), handlers.compare);

return router;
}
7 changes: 4 additions & 3 deletions packages/server/src/features/schema/schema.routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import { scopeOf } from '../../platform/http/scope';
import type { ConnectionResolver } from '../../platform/connections/resolve';
import type { FastifyReply } from 'fastify';
import type { AppRequest } from '../../platform/http/types';
import { Router } from '../../platform/http/router';
import { Router, reads } from '../../platform/http/router';
import { requirePermissions } from '../../platform/authorization/rbac.guard';
import type { AuthedRequest } from '../../platform/http/types';
import type { ConnectionRef } from '../../platform/connections/resolve';
Expand All @@ -26,7 +26,7 @@ export interface SchemaRouteDeps {

export function createSchemaRoutes(deps: SchemaRouteDeps): Router {
const router = Router();
router.post('/schema/list', requirePermissions('schema.browse'), async (req: AppRequest, res: FastifyReply) => {
router.post('/schema/list', reads, requirePermissions('schema.browse'), async (req: AppRequest, res: FastifyReply) => {
try {
const { dialect, option } = await deps.resolveRef(scopeOf(req as AuthedRequest), req.body as ConnectionRef);
const provider = deps.connectionModule.getProvider(dialect);
Expand All @@ -40,7 +40,7 @@ export function createSchemaRoutes(deps: SchemaRouteDeps): Router {
}
});

router.post('/schema/load', requirePermissions('schema.browse'), async (req: AppRequest, res: FastifyReply) => {
router.post('/schema/load', reads, requirePermissions('schema.browse'), async (req: AppRequest, res: FastifyReply) => {
const { scope, ...ref } = req.body as ConnectionRef & { scope: DbObjectType[] };
try {
const { dialect, option, schema } = await deps.resolveRef(scopeOf(req as AuthedRequest), ref);
Expand All @@ -59,6 +59,7 @@ export function createSchemaRoutes(deps: SchemaRouteDeps): Router {
const tableInsightLimiter = rateLimit({ windowMs: 60 * 1000, max: 30 });
router.post(
'/schema/table-insight',
reads,
tableInsightLimiter,
// This powers Data Peek's Insight tab in the SQL Editor. Requiring
// utility.access locked the whole tab out for the built-in viewer role,
Expand Down
6 changes: 3 additions & 3 deletions packages/server/src/features/sql-editor/editor.routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import { scopeOf } from '../../platform/http/scope';
import type { ConnectionResolver } from '../../platform/connections/resolve';
import type { FastifyReply } from 'fastify';
import type { AppRequest } from '../../platform/http/types';
import { Router } from '../../platform/http/router';
import { Router, reads } from '../../platform/http/router';
import { denyUnless } from '../../platform/authorization/rbac.guard';
import { rateLimit } from '../../platform/guards/rate-limit';
import { idempotency } from '../../platform/guards/idempotency';
Expand Down Expand Up @@ -80,7 +80,7 @@ export function createEditorRoutes(deps: EditorRouteDeps): Router {
// made Express call it with (req, res, next); it returned a handler and never
// called next(), so the request hung with no error and no response.
const writeIdempotency = idempotency();
router.post('/sql/execute', sqlExecuteLimiter, writeIdempotency, async (req: AppRequest, res: FastifyReply) => {
router.post('/sql/execute', reads, sqlExecuteLimiter, writeIdempotency, async (req: AppRequest, res: FastifyReply) => {
const { statements, maxRows, offset, params, datagridAction, seek, orderBy, stopOnError, transaction, ...ref } = req.body as ConnectionRef & {
statements?: unknown;
maxRows?: unknown;
Expand Down Expand Up @@ -229,7 +229,7 @@ export function createEditorRoutes(deps: EditorRouteDeps): Router {
// The server already has it for Node cells, so the browser sends the source
// here. It is compiled, never run: the same permission as opening the editor.
const transpileLimiter = rateLimit({ windowMs: 60 * 1000, max: 120 });
router.post('/sql/code-cell/transpile', transpileLimiter, async (req: AppRequest, res: FastifyReply) => {
router.post('/sql/code-cell/transpile', reads, transpileLimiter, async (req: AppRequest, res: FastifyReply) => {
if (denyUnless(req as AuthedRequest, res, 'editor.access')) return;
const source = (req.body as { source?: unknown } | undefined)?.source;
if (typeof source !== 'string' || !source.trim()) {
Expand Down
4 changes: 2 additions & 2 deletions packages/server/src/features/users/user.routes.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { FastifyReply } from 'fastify';
import type { AppRequest } from '../../platform/http/types';
import { Router } from '../../platform/http/router';
import { Router, reads } from '../../platform/http/router';
import { UserModule, UserPreferences } from './user.service';
import type { AuthedRequest } from '../../platform/http/types';

Expand All @@ -12,7 +12,7 @@ export function createUserRoutes(user: UserModule): Router {
res.send({ preferences: await user.getPreferences(req.userId!) });
});

router.put('/preferences', async (req: AuthedRequest, res: FastifyReply) => {
router.put('/preferences', reads, async (req: AuthedRequest, res: FastifyReply) => {
const { role, primaryDatabase, primaryGoal, theme, onboardingCompleted } = req.body as Partial<UserPreferences>;
res.send({
preferences: await user.updatePreferences(req.userId!, {
Expand Down
4 changes: 2 additions & 2 deletions packages/server/src/features/workspaces/workspaces.routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
* (name, archive, members). Mounted behind `authGuard`.
*/
import type { FastifyReply } from 'fastify';
import { Router } from '../../platform/http/router';
import { Router, reads } from '../../platform/http/router';
import type { AuthedRequest } from '../../platform/http/types';
import { sendError } from '../../platform/http/respond';
import { ServiceError } from '../../platform/contracts/actor';
Expand Down Expand Up @@ -132,7 +132,7 @@ export function createWorkspaceRoutes(
});

/** Act in this workspace from now on (every tab and the CLI's next run follow). */
router.post('/:id/select', async (req: AuthedRequest, res: FastifyReply) => {
router.post('/:id/select', reads, async (req: AuthedRequest, res: FastifyReply) => {
try {
await directory.select(req.userId!, id(req));
res.send({ ok: true });
Expand Down
68 changes: 0 additions & 68 deletions packages/server/src/platform/authorization/demo-access.ts

This file was deleted.

Loading
Loading