Skip to content
Open
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
211 changes: 211 additions & 0 deletions supabase/migrations/20260716192819_enable_rls.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,211 @@
-- Enable Row Level Security on every public application table and define
-- owner-scoped access policies.
--
-- WHY THIS EXISTS
-- Supabase's PostgREST auto-exposes every table in the `public` schema over
-- HTTP, authorized by the *public* anon key (NEXT_PUBLIC_SUPABASE_ANON_KEY,
-- shipped to every browser). With RLS disabled, anyone holding that key can
-- read and modify all rows in these tables (workspaces, prompts, documents,
-- prolific study IDs, ...). Enabling RLS closes that hole.
--
-- WHY IT DOES NOT BREAK THE APP
-- The workbench never reads/writes these tables through PostgREST. All table
-- access goes through Drizzle over DATABASE_URL as the `postgres` role, which
-- has BYPASSRLS; supabase-js is used only for `.auth` and `.storage`. The
-- service_role key (used by the test suite) also bypasses RLS. We deliberately
-- do NOT use FORCE ROW LEVEL SECURITY, so the table-owning `postgres` role and
-- service_role continue to bypass — exactly the roles the app and tests use.
--
-- OWNERSHIP GRAPH
-- `workspaces.user_id` = `auth.uid()::text` is the root of ownership. Every
-- other table inherits ownership by walking back to its workspace (directly via
-- workspace_id, or via chart_id -> charts -> workspace). Policies are scoped
-- `TO authenticated`; `anon` matches no policy and is therefore denied on all
-- tables. auth.uid() is wrapped in a scalar sub-select so Postgres caches it as
-- an initplan (Supabase's recommended RLS performance pattern).
--
-- NOT INCLUDED (by design)
-- No anon-readable policy for `workspaces.public = true`. That sharing path is
-- not served over PostgREST today, and a blanket table policy would expose
-- user_id / prolific columns. Public sharing over the API, if ever needed,
-- should be a column-limited view, not a table policy.
--
-- ORPHAN TABLES
-- Some tables exist in the live DB but not in the Drizzle schema (e.g.
-- `generations`, left over from the removed generation panel). PostgREST
-- exposes those too. So rather than enable RLS on a hand-listed set, we enable
-- it on EVERY base table in `public` — this self-heals against current and
-- future orphans, locking them to default-deny (bypass roles only) until
-- someone gives them an explicit policy. The owner-scoped policies below then
-- layer onto the known application tables.
Comment on lines +33 to +40

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Map the migration and nearby related files first.
git ls-files 'supabase/migrations/*' 'supabase/**' | sed -n '1,200p'

echo
echo '--- migration outline ---'
ast-grep outline supabase/migrations/20260716192819_enable_rls.sql --view expanded || true

echo
echo '--- related references to RLS / default privileges / future tables ---'
rg -n --hidden --glob '!*node_modules*' --glob '!*dist*' --glob '!*build*' \
  -e 'enable row level security|row level security|default privileges|revoke .*anon|revoke .*authenticated|event trigger|future orphans|ORPHAN TABLES|policy' \
  supabase .github . 2>/dev/null | sed -n '1,240p'

echo
echo '--- target migration with line numbers ---'
cat -n supabase/migrations/20260716192819_enable_rls.sql | sed -n '1,220p'

Repository: ndif-team/workbench

Length of output: 17129


Future tables still need explicit RLS. The loop only enables RLS on tables that exist when this migration runs; any later CREATE TABLE public.* will still start without RLS, so the “future orphans” claim is too broad. Add an event trigger or a CI check that rejects new public tables without RLS.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@supabase/migrations/20260716192819_enable_rls.sql` around lines 33 - 40,
Update the orphan-table protection around the migration’s “ORPHAN TABLES” logic
so newly created public tables are also prevented from remaining without RLS.
Add an appropriate event trigger that enables RLS for future base tables in
public, or add a CI validation that rejects public tables lacking RLS; ensure
the existing migration-time coverage remains intact.

--
-- Idempotent: safe to re-run (enable-rls is a no-op if already on; policies are
-- dropped-if-exists before creation).

begin;

-- ── Enable RLS on every public base table (covers known + orphan tables) ─────
do $$
declare
t text;
begin
for t in
select tablename from pg_tables where schemaname = 'public'
loop
execute format('alter table public.%I enable row level security', t);
end loop;
end $$;

-- ── workspaces : the ownership root ──────────────────────────────────────────
drop policy if exists workspaces_owner_all on public.workspaces;
create policy workspaces_owner_all on public.workspaces
for all
to authenticated
using (user_id = (select auth.uid())::text)
with check (user_id = (select auth.uid())::text);

-- ── charts : owned via workspace_id ──────────────────────────────────────────
drop policy if exists charts_owner_all on public.charts;
create policy charts_owner_all on public.charts
for all
to authenticated
using (
exists (
select 1 from public.workspaces w
where w.id = charts.workspace_id
and w.user_id = (select auth.uid())::text
)
)
with check (
exists (
select 1 from public.workspaces w
where w.id = charts.workspace_id
and w.user_id = (select auth.uid())::text
)
);

-- ── configs : owned via workspace_id ─────────────────────────────────────────
drop policy if exists configs_owner_all on public.configs;
create policy configs_owner_all on public.configs
for all
to authenticated
using (
exists (
select 1 from public.workspaces w
where w.id = configs.workspace_id
and w.user_id = (select auth.uid())::text
)
)
with check (
exists (
select 1 from public.workspaces w
where w.id = configs.workspace_id
and w.user_id = (select auth.uid())::text
)
);

-- ── documents : owned via workspace_id ───────────────────────────────────────
drop policy if exists documents_owner_all on public.documents;
create policy documents_owner_all on public.documents
for all
to authenticated
using (
exists (
select 1 from public.workspaces w
where w.id = documents.workspace_id
and w.user_id = (select auth.uid())::text
)
)
with check (
exists (
select 1 from public.workspaces w
where w.id = documents.workspace_id
and w.user_id = (select auth.uid())::text
)
);

-- ── lens_runs : owned via workspace_id, and chart_id must agree ──────────────
-- Validating workspace_id alone would let an owner insert a run under their
-- workspace while pointing chart_id at another user's chart; join charts and
-- require both columns resolve to the same owned workspace.
drop policy if exists lens_runs_owner_all on public.lens_runs;
create policy lens_runs_owner_all on public.lens_runs
for all
to authenticated
using (
exists (
select 1 from public.charts c
join public.workspaces w on w.id = c.workspace_id
where c.id = lens_runs.chart_id
and c.workspace_id = lens_runs.workspace_id
and w.user_id = (select auth.uid())::text
)
)
with check (
exists (
select 1 from public.charts c
join public.workspaces w on w.id = c.workspace_id
where c.id = lens_runs.chart_id
and c.workspace_id = lens_runs.workspace_id
and w.user_id = (select auth.uid())::text
)
);
Comment thread
coderabbitai[bot] marked this conversation as resolved.

-- ── views : owned via chart_id -> charts -> workspace ────────────────────────
drop policy if exists views_owner_all on public.views;
create policy views_owner_all on public.views
for all
to authenticated
using (
exists (
select 1 from public.charts c
join public.workspaces w on w.id = c.workspace_id
where c.id = views.chart_id
and w.user_id = (select auth.uid())::text
)
)
with check (
exists (
select 1 from public.charts c
join public.workspaces w on w.id = c.workspace_id
where c.id = views.chart_id
and w.user_id = (select auth.uid())::text
)
);

-- ── chart_config_links : chart AND config must share one owned workspace ─────
-- Validating chart_id alone would let an owner link their chart to another
-- user's config (cross-tenant disclosure via copyChart); require the config to
-- live in the same owned workspace as the chart.
drop policy if exists chart_config_links_owner_all on public.chart_config_links;
create policy chart_config_links_owner_all on public.chart_config_links
for all
to authenticated
using (
exists (
select 1 from public.charts c
join public.workspaces w on w.id = c.workspace_id
join public.configs cfg on cfg.id = chart_config_links.config_id
where c.id = chart_config_links.chart_id
and cfg.workspace_id = c.workspace_id
and w.user_id = (select auth.uid())::text
)
)
with check (
exists (
select 1 from public.charts c
join public.workspaces w on w.id = c.workspace_id
join public.configs cfg on cfg.id = chart_config_links.config_id
where c.id = chart_config_links.chart_id
and cfg.workspace_id = c.workspace_id
and w.user_id = (select auth.uid())::text
)
);

-- ── workshops : admin-managed metadata, no client access ─────────────────────
-- Created and read only through server actions (Drizzle `postgres` role) and
-- the /w/{slug} join flow (also server-side). RLS is already enabled by the
-- do-loop above; with NO policy defined, anon and authenticated are both fully
-- denied over PostgREST — only bypass roles reach it. No ALTER needed here.

commit;
2 changes: 1 addition & 1 deletion workbench/_web/src/actions/workshop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@ export async function joinWorkshopAction(
// insert conflicts and it reuses the winner's workspace.
let workspace;
try {
workspace = await createWorkspace(user.id, workshop.name, workshop.id, prolific);
workspace = await createWorkspace(workshop.name, workshop.id, prolific);
} catch (err) {
if (!isUniqueViolation(err)) throw err;
const winner = await getWorkshopWorkspaceForUser(user.id, workshop.id);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,6 @@ import type { Lens2ConfigData } from "@/types/lens2";
import type { ActivationPatchingConfigData, SourcePosition } from "@/types/activationPatching";

interface AutoWorkspaceCreatorProps {
userId: string;
initialPrompt?: string;
initialModel?: string;
seedWithExamples?: boolean; // New prop to control seeding
Expand All @@ -34,7 +33,6 @@ interface AutoWorkspaceCreatorProps {
}

export function AutoWorkspaceCreator({
userId,
initialPrompt,
initialModel,
seedWithExamples = true, // Default to true for new users
Expand Down Expand Up @@ -68,13 +66,8 @@ export function AutoWorkspaceCreator({
console.log("Using existing workspace:", existingWorkspaceId);
targetWorkspaceId = existingWorkspaceId;
} else {
console.log(
"Creating workspace for user:",
userId,
"with name:",
workspaceName,
);
const newWorkspace = await createWorkspace(userId, workspaceName);
console.log("Creating workspace with name:", workspaceName);
const newWorkspace = await createWorkspace(workspaceName);
console.log("Created workspace:", newWorkspace);
targetWorkspaceId = newWorkspace.id;

Expand Down Expand Up @@ -201,7 +194,6 @@ export function AutoWorkspaceCreator({

createAndRedirect();
}, [
userId,
router,
initialPrompt,
initialModel,
Expand Down
15 changes: 6 additions & 9 deletions workbench/_web/src/app/workbench/components/WorkspaceList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,11 @@ import { Trash2, BarChart3, FileText, ChevronLeft, ChevronRight } from "lucide-r
import { useEffect, useMemo, useState } from "react";
import { useIsDark } from "@/hooks/useIsDark";
import { useModelsSection } from "@/stores/useModelsSection";
import { queryKeys } from "@/lib/queryKeys";

const PAGE_SIZE_EXPANDED = 8; // 2 rows × 4 cols at lg+, 4 rows × 2 cols at sm
const PAGE_SIZE_COLLAPSED = 16; // 4 rows × 4 cols at lg+ — uses the freed vertical space

interface WorkspaceListProps {
userId: string;
}

interface Workspace {
id: string;
name: string;
Expand Down Expand Up @@ -123,12 +120,12 @@ function WorkspaceCard({
);
}

export function WorkspaceList({ userId }: WorkspaceListProps) {
export function WorkspaceList() {
const deleteWorkspaceMutation = useDeleteWorkspace();

const { data: workspaces, isLoading } = useQuery<Workspace[]>({
queryKey: ["workspaces"],
queryFn: () => getWorkspaces(userId),
queryKey: queryKeys.workspaces.all,
queryFn: () => getWorkspaces(),
Comment thread
coderabbitai[bot] marked this conversation as resolved.
staleTime: 0,
});

Expand All @@ -155,7 +152,7 @@ export function WorkspaceList({ userId }: WorkspaceListProps) {
e.preventDefault();
e.stopPropagation();
if (confirm("Are you sure you want to delete this workspace?")) {
deleteWorkspaceMutation.mutate({ userId, workspaceId });
deleteWorkspaceMutation.mutate({ workspaceId });
}
};

Expand All @@ -171,7 +168,7 @@ export function WorkspaceList({ userId }: WorkspaceListProps) {
<>
<div className="flex justify-between items-center mb-6 pl-5">
<h2 className="text-lg">Workspaces</h2>
<CreateWorkspaceDialog userId={userId} />
<CreateWorkspaceDialog />
</div>

{!workspaces || workspaces.length === 0 ? (
Expand Down
8 changes: 3 additions & 5 deletions workbench/_web/src/app/workbench/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import type { User } from "@supabase/supabase-js";
import { ModelsSection } from "@/components/models/ModelsSection";
import { ModelsSectionStateController } from "@/app/workbench/components/ModelsSectionStateController";
import { WorkspaceList } from "@/app/workbench/components/WorkspaceList";
import { getWorkspaces, createWorkspace } from "@/lib/queries/workspaceQueries";
import { getWorkspaces } from "@/lib/queries/workspaceQueries";
import { AutoWorkspaceCreator } from "@/app/workbench/components/AutoWorkspaceCreator";
import { PendingRequestHandler } from "@/app/workbench/components/PendingRequestHandler";
import Link from "next/link";
Expand Down Expand Up @@ -49,7 +49,7 @@ export default async function WorkbenchPage({
hasWorkshopClaim(user);

// Check if user has any workspaces
const workspaces = await getWorkspaces(user.id);
const workspaces = await getWorkspaces();

// Get the prompt and model from search params
const params = await searchParams;
Expand Down Expand Up @@ -141,7 +141,6 @@ export default async function WorkbenchPage({

{useExistingWorkspace ? (
<AutoWorkspaceCreator
userId={user.id}
initialPrompt={prompt}
initialModel={model}
existingWorkspaceId={workspaceId}
Expand All @@ -155,7 +154,6 @@ export default async function WorkbenchPage({
/>
) : shouldCreateWorkspace ? (
<AutoWorkspaceCreator
userId={user.id}
initialPrompt={prompt}
initialModel={model}
workspaceName={createNew ? "Untitled" : "Default Workspace"}
Expand All @@ -169,7 +167,7 @@ export default async function WorkbenchPage({
deploy={deploy}
/>
) : (
<WorkspaceList userId={user.id} />
<WorkspaceList />
)}
</main>
</div>
Expand Down
7 changes: 1 addition & 6 deletions workbench/_web/src/components/CreateWorkspaceDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,7 @@ import { Label } from "@/components/ui/label";
import { useCreateWorkspace } from "@/lib/api/workspaceApi";
import { useRouter } from "next/navigation";

interface CreateWorkspaceDialogProps {
userId: string;
}

export function CreateWorkspaceDialog({ userId }: CreateWorkspaceDialogProps) {
export function CreateWorkspaceDialog() {
const [open, setOpen] = useState(false);
const [name, setName] = useState("");
const router = useRouter();
Expand All @@ -33,7 +29,6 @@ export function CreateWorkspaceDialog({ userId }: CreateWorkspaceDialogProps) {

try {
const newWorkspace = await createWorkspaceMutation.mutateAsync({
userId,
name: name.trim(),
});

Expand Down
2 changes: 1 addition & 1 deletion workbench/_web/src/components/LandingPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -240,7 +240,7 @@ export function LandingPage({ loggedIn }: { loggedIn: boolean }) {

const { data: workspacesList } = useQuery({
queryKey: ["workspaces", currentUser?.id],
queryFn: () => getWorkspaces(currentUser!.id),
queryFn: () => getWorkspaces(),
enabled: !!isSignedInUser,
});

Expand Down
Loading
Loading