diff --git a/database/database-generated.types.ts b/database/database-generated.types.ts index 6454d3d114..3d1225e950 100644 --- a/database/database-generated.types.ts +++ b/database/database-generated.types.ts @@ -4488,6 +4488,10 @@ export type Database = { [_ in never]: never } Functions: { + delete_project: { + Args: { p_confirm: string; p_project_id: number } + Returns: boolean + } find_project: { Args: { p_org_ref: string; p_proj_ref: string } Returns: { diff --git a/editor/components/dialogs/rename-dialog.tsx b/editor/components/dialogs/rename-dialog.tsx index dfeee74248..2af1960acf 100644 --- a/editor/components/dialogs/rename-dialog.tsx +++ b/editor/components/dialogs/rename-dialog.tsx @@ -16,6 +16,7 @@ import { import { Field, FieldError, + FieldDescription, FieldGroup, FieldLabel, } from "@/components/ui/field"; @@ -29,6 +30,14 @@ interface RenameDialogProps { title?: string | React.ReactNode; description?: string | React.ReactNode; itemType?: string; + /** + * Optional short hint shown under the input (e.g. naming guideline). + */ + nameHint?: string | React.ReactNode; + /** + * Optional validation function. Return a user-facing message when invalid. + */ + validateName?: (name: string) => string | null; } // Add loading state to the component @@ -39,31 +48,49 @@ export function RenameDialog({ title = "Rename item", description = "Enter a new name for this item.", itemType = "item", + nameHint, + validateName, ...props }: React.ComponentProps & RenameDialogProps) { const [name, setName] = useState(currentName); const [error, setError] = useState(null); const [isLoading, setIsLoading] = useState(false); + const [showValidation, setShowValidation] = useState(false); + + const trimmedName = name.trim(); + // Avoid "flicker" while typing: only show validation errors after blur or submit. + const validationError = + showValidation && trimmedName ? validateName?.(trimmedName) : null; + const effectiveError = error ?? validationError ?? null; + const isUnchanged = trimmedName === currentName.trim(); + const canSubmit = !isLoading && !!trimmedName && !isUnchanged; // Update the submit handler to handle async operations const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); + setShowValidation(true); // Validate input - if (!name.trim()) { + if (!trimmedName) { setError(`${itemType} name cannot be empty`); return; } + const validationMessage = validateName?.(trimmedName); + if (validationMessage) { + setError(validationMessage); + return; + } + // If name hasn't changed - if (name.trim() === currentName.trim()) { + if (isUnchanged) { props.onOpenChange?.(false); return; } try { setIsLoading(true); - const result = await Promise.resolve(onRename(id, name.trim())); + const result = await Promise.resolve(onRename(id, trimmedName)); // Only close if the operation was successful or returned nothing (void) if (result !== false) { @@ -74,7 +101,11 @@ export function RenameDialog({ } } catch (err) { console.error("Error renaming:", err); - setError(`An error occurred while renaming the ${itemType}.`); + if (err instanceof Error && err.message) { + setError(err.message); + } else { + setError(`An error occurred while renaming the ${itemType}.`); + } } finally { setIsLoading(false); } @@ -89,7 +120,7 @@ export function RenameDialog({ {description} - + Name @@ -100,12 +131,16 @@ export function RenameDialog({ setName(e.target.value); setError(null); }} + onBlur={() => setShowValidation(true)} placeholder={`Enter ${itemType} name`} - className={error ? "border-red-500" : ""} + aria-invalid={!!effectiveError} autoFocus disabled={isLoading} /> - {error} + {nameHint && {nameHint}} +
+ {effectiveError} +
@@ -114,7 +149,7 @@ export function RenameDialog({ Cancel - diff --git a/editor/scaffolds/workspace/sidebar.tsx b/editor/scaffolds/workspace/sidebar.tsx index acdc0a9a9a..c929e5ede7 100644 --- a/editor/scaffolds/workspace/sidebar.tsx +++ b/editor/scaffolds/workspace/sidebar.tsx @@ -59,6 +59,7 @@ import { Badge } from "@/components/ui/badge"; import { Labels } from "@/k/labels"; import { Button } from "@/components/ui-editor/button"; import { ShineBorder } from "@/www/ui/shine-border"; +import { validateProjectName } from "@/services/utils/regex"; import type { GDocument, OrganizationWithAvatar, @@ -327,11 +328,16 @@ export function NavProjects({ title="Rename Project" description="Enter a new name for this project." currentName={renameProjectDialog.data?.name} + nameHint="Lowercase letters, numbers, and dashes (e.g. my-project)." + validateName={(name) => validateProjectName(name)} onRename={async (id: string, newName: string): Promise => { - const { count } = await client + const { count, error } = await client .from("project") .update({ name: newName }, { count: "exact" }) .eq("id", parseInt(id)); + if (error) { + throw new Error("Couldn’t rename the project. Please try again."); + } return count === 1; // TODO: needs to revalidate }} @@ -353,13 +359,13 @@ export function NavProjects({ } placeholder={deleteProjectDialog.data?.match} match={deleteProjectDialog.data?.match} - onDelete={async ({ id }) => { - const { count, error } = await client - .from("project") - .delete({ count: "exact" }) - .eq("id", id); + onDelete={async ({ id }, user_confirmation_txt) => { + const { data, error } = await client.rpc("delete_project", { + p_project_id: id, + p_confirm: user_confirmation_txt, + }); if (error) return false; - if (count === 1) { + if (data === true) { // TODO: needs to revalidate router.replace(`/${orgname}`); return true; diff --git a/editor/services/utils/regex.ts b/editor/services/utils/regex.ts index d3a5ca6bdf..04650d2770 100644 --- a/editor/services/utils/regex.ts +++ b/editor/services/utils/regex.ts @@ -14,6 +14,23 @@ export const username_validation_messages = { taken: "This name is taken", } as const; +/** + * Project name must match DB `project_name_check`. + * + * DB constraint: `^[a-z\d](?:[a-z\d]|-(?=[a-z\d])){1,38}$` + * - lowercase letters + digits + * - dashes allowed, but not consecutively and not at the end + * - length: 2–39 + */ +export const PROJECT_NAME_REGEX = /^[a-z\d](?:[a-z\d]|-(?=[a-z\d])){1,38}$/; + +export function validateProjectName(name: string): string | null { + if (!PROJECT_NAME_REGEX.test(name)) { + return "Use 2–39 characters: lowercase letters, numbers, and single dashes (e.g. my-project)."; + } + return null; +} + /** * Regex for validating a database name, table name, schema name, etc. */ diff --git a/supabase/AGENTS.md b/supabase/AGENTS.md new file mode 100644 index 0000000000..3354c11039 --- /dev/null +++ b/supabase/AGENTS.md @@ -0,0 +1,296 @@ +# Supabase `AGENTS.md` + +This file is **for LLM agents** working in `./supabase`. It provides project-specific context, security constraints, and conventions. + +If you’re a human contributor, start with `supabase/README.md`. + +--- + +## Scope and goal + +You are editing the **database security boundary**. Your goal is to make changes that are: + +- **Correct** (matches product intent) +- **Safe-by-default** (deny-by-default, tenant isolated) +- **Provable** (pgTAP tests demonstrate isolation and access) + +If you can’t prove it with tests and by reading the SQL, treat it as a security bug. + +--- + +## Security posture (treat the database as hostile-by-default) + +Supabase makes it easy to expose Postgres via PostgREST. **Assume tables are reachable unless you explicitly prevent it.** + +**Non-negotiables** + +- **RLS is mandatory** for any table/view that contains tenant/user data, and for anything reachable from the API surface. +- **Policies must be enforced by tests**. For every RLS-protected surface, add **pgTAP coverage** under `supabase/tests/`. +- **No “it should be fine”**: if you can’t prove it with a test (and by reading the SQL), treat it as a security bug. + +**RLS rules of thumb** + +- **Enable RLS explicitly** and prefer forcing it: + - `ALTER TABLE ... ENABLE ROW LEVEL SECURITY;` + - `ALTER TABLE ... FORCE ROW LEVEL SECURITY;` (when appropriate; be mindful of privileged roles and internal maintenance) +- **Deny-by-default**: start with no permissive policies; add the minimum policies required for product behavior. +- **No cross-tenant access**: every policy should be scoped by tenant boundary (org/project) and verified in tests using seeded “other tenant” users. +- **Be explicit about API roles**: + - `anon` and `authenticated` are untrusted. + - `service_role` bypasses RLS: only use it for controlled setup/maintenance (and in tests for fixture creation). + +**Views, functions, and `SECURITY DEFINER`** + +- Treat **views as part of the API surface**: they must not leak rows or columns across tenants. +- Avoid `SECURITY DEFINER` unless there is a clear, reviewed reason. + - If you must use it: lock down privileges, set a safe `search_path`, validate inputs, and write tests demonstrating it can’t be abused for escalation. +- Avoid dynamic SQL in privileged contexts unless absolutely necessary and carefully hardened. + +--- + +## “Reachable surface” mental model (what can leak) + +Assume any of the below can become internet-reachable over time: + +- **Tables / views in `public`** (via PostgREST) +- **RPC functions** (`/rpc/`) when `EXECUTE` is granted +- **Foreign keys + joins** that power policies (policy joins can accidentally “widen” access) + +So you must manage **three layers**: + +- **RLS**: row visibility + row write permissions +- **Grants**: what roles can `SELECT/INSERT/UPDATE/DELETE` or `EXECUTE` +- **Tests**: proofs that insider/outsider/other-tenant behave as intended + +--- + +## RLS policy patterns (recommended) + +- **Always write both sides**: + - `USING (...)` for read/delete visibility + - `WITH CHECK (...)` for insert/update validity +- **Prefer “membership join” checks** over trusting client-supplied IDs: + - Good: “caller must be a member of org owning this row” + - Bad: “row.org_id = ” without membership verification +- **Index what policies depend on**: + - If your policy filters by `project_id`, `org_id`, `owner_id`, membership join keys, etc., add indexes to avoid slow RLS scans. + +### Common foot-guns (avoid) + +- **Permissive policies for `anon`** unless explicitly required and tested. +- **Policy predicates that don’t include tenant boundary** (easy to leak cross-tenant). +- **Views that bypass RLS** (e.g. selecting from tables without RLS or using privileged functions). +- **`SECURITY DEFINER` functions that read tenant tables without enforcing tenant checks internally**. + +--- + +## RLS testing (pgTAP is required) + +We use **pgTAP** to assert RLS behavior at the database level. + +- **Tests live in** `supabase/tests/*.sql`. +- Create new tests with `supabase test new ` (local only). +- Run tests with `supabase db test` (local only). + +**How tests should be written** + +- Use **seed personas** (see “Seed data” below) to prove: + - **insider** can access their tenant’s data + - **other tenant** cannot access insider’s tenant + - **no membership** cannot access tenant-scoped data +- In tests, it’s acceptable to: + - `SET LOCAL ROLE service_role` for fixture setup + - Then switch to `authenticated` and set `request.jwt.claim.sub` to simulate a user session +- Always include: + - A plan (`SELECT plan(n);`) + - Positive and negative assertions (`ok(...)`, `is(...)`, `throws_ok(...)`, etc.) + +**Coverage expectation** + +- Any change that alters RLS, permissions, or tenant boundaries must ship with tests. +- New tables that hold tenant/user data must include at least: + - read isolation tests + - write isolation tests (insert/update/delete) + +### pgTAP skeleton (copy/paste) + +Use this as a starting point for new security-sensitive changes: + +```sql +BEGIN; +SELECT plan(9); + +-- Fixture creation (bypass RLS) +SET LOCAL ROLE service_role; +-- ... insert orgs/projects/users/memberships/rows ... + +-- Insider session +SET LOCAL ROLE authenticated; +SELECT set_config('request.jwt.claim.sub', '', true); + +-- Positive assertions +SELECT ok(exists(select 1 from public.some_table where id = ''), 'insider can read own tenant row'); + +-- Other-tenant session +SELECT set_config('request.jwt.claim.sub', '', true); +SELECT ok( + not exists(select 1 from public.some_table where id = ''), + 'other tenant cannot read insider row' +); + +-- No-membership session +SELECT set_config('request.jwt.claim.sub', '', true); +SELECT ok( + not exists(select 1 from public.some_table where id = ''), + 'no membership cannot read tenant row' +); + +SELECT * FROM finish(); +ROLLBACK; +``` + +--- + +## Seed data (multi-tenant by design) + +We seed local databases for: + +- fast local development (realistic data) +- repeatable security testing (multi-tenant isolation) + +**Seed sources** + +- `supabase/seed.sql`: executable seed (LOCAL ONLY) +- `supabase/seed.md`: describes the seeded personas and scenarios + +**Seed expectations** + +- Seed should be **idempotent or reset-friendly** (it runs as part of local resets). +- Seed must include **multiple tenants** (orgs/projects) and **multiple users** to make RLS failures obvious. +- Never add production secrets or production identifiers to seed content. + +--- + +## Migrations (source of truth) + +**Source of truth is** `supabase/migrations/*`. + +- Create migrations via `supabase migration new `. +- Keep migrations: + - small and reviewable + - forward-only (avoid rewriting already-applied migrations) + - explicit about RLS/policies/grants (don’t rely on defaults) + +**When adding or changing schema** + +- Add the table/type/function change in a migration. +- Add/adjust RLS policies in the same migration (or an immediately adjacent one). +- Add pgTAP tests proving the intended access model. +- Consider indexes and FK performance (especially for policy predicates and joins). + +### Migration order-of-operations (recommended) + +For a new tenant-scoped table, prefer this order: + +- Create table + constraints (FKs, not null, etc.) +- Add indexes needed for: + - FKs + - policy predicates / membership joins +- Enable (and often force) RLS +- Add the minimum policies for product behavior +- Lock down grants (explicitly grant only what you need) +- Add/adjust pgTAP tests + +--- + +## RPC / functions (especially `SECURITY DEFINER`) + +Prefer **plain RLS + normal DML**. Use RPC only when you need: + +- a multi-statement transaction with complex invariants +- performance that would be hard to achieve through PostgREST +- carefully audited “capability” operations (e.g. safe cascade deletion) + +### If you must use `SECURITY DEFINER` + +Non-negotiables for privileged RPC: + +- **Set a safe `search_path`** +- **Fully qualify** tables/functions where reasonable (`public.my_table`) +- **Validate inputs** (types, existence, tenant boundary) +- **Lock down privileges** (`REVOKE ALL ... FROM PUBLIC;` then grant to the minimum role) +- **Test escalation resistance** (outsider cannot delete/read/modify cross-tenant) + +Template: + +```sql +CREATE OR REPLACE FUNCTION public.my_rpc(arg_project_id uuid) +RETURNS void +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = pg_catalog, public +AS $$ +BEGIN + -- enforce tenant boundary explicitly inside the function + -- ... do work ... +END; +$$; + +REVOKE ALL ON FUNCTION public.my_rpc(uuid) FROM PUBLIC; +GRANT EXECUTE ON FUNCTION public.my_rpc(uuid) TO authenticated; +``` + +--- + +## `schemas/` (human reference, may drift) + +`supabase/schemas/*` is a **for-humans reference** to keep business tables aligned with the database. + +Important caveat: + +- It is **not guaranteed to be fully synced** with the real database state. +- Treat it as **best-effort documentation** (it may contain mistakes). +- Prefer reading migrations for ground truth, but **try to keep `schemas/` updated** when you make changes so it remains useful. + +See `supabase/schemas/README.md`. + +--- + +## Running and testing (agent safety rules) + +Agents are allowed to run **local-only** Supabase commands. + +**Allowed (local)** + +- `supabase start` / `supabase stop` / `supabase status` +- `supabase db reset` +- `supabase migration new ...` +- `supabase migration up` +- `supabase test new ...` +- `supabase db test` +- `supabase gen types typescript --local ...` + +**Forbidden without explicit user permission** + +- Anything that can target a remote project, especially: + - `supabase link ...` + - `supabase db push` + - any command that requires `SUPABASE_ACCESS_TOKEN` +- Any destructive command when a project might be linked (treat as remote-risk). + +**When uncertain** + +- Stop and ask for explicit permission before running a command that might affect a remote environment. + +--- + +## Review checklist (before you consider the work “done”) + +- **RLS**: enabled (and forced when appropriate) for tenant/user data tables. +- **Policies**: minimal, tenant-scoped, and readable. +- **Grants**: no accidental `PUBLIC` access; only required roles have access. +- **RPC**: privileged functions have safe `search_path`, locked-down `EXECUTE`, and tenant checks inside. +- **Tests**: pgTAP added/updated to prove tenant isolation and intended access. +- **Seed**: still supports multi-tenant scenarios and hasn’t become brittle. +- **Privileged code**: no unnecessary `SECURITY DEFINER`, safe `search_path` where used. +- **Docs**: `schemas/` updated where reasonable; avoid contradicting migrations. diff --git a/supabase/migrations/20260201133814_rpc_delete_project_with_timeout.sql b/supabase/migrations/20260201133814_rpc_delete_project_with_timeout.sql new file mode 100644 index 0000000000..57d7d118c7 --- /dev/null +++ b/supabase/migrations/20260201133814_rpc_delete_project_with_timeout.sql @@ -0,0 +1,28 @@ +-- RPC: delete project with fixed statement_timeout +-- Security: SECURITY INVOKER (RLS enforced exactly as direct DELETE) +-- +-- Usage (PostgREST): +-- select public.delete_project(, 'DELETE '); + +drop function if exists public.delete_project(bigint); + +create or replace function public.delete_project( + p_project_id bigint, + p_confirm text +) +returns boolean +language sql +security invoker +set statement_timeout to '30s' +as $$ + with d as ( + delete from public.project p + where p.id = p_project_id + and p_confirm = ('DELETE ' || p.name) + returning 1 + ) + select exists (select 1 from d); +$$; + +revoke all on function public.delete_project(bigint, text) from public; +grant execute on function public.delete_project(bigint, text) to authenticated, service_role; diff --git a/supabase/migrations/20260201135229_add_project_fk_indexes.sql b/supabase/migrations/20260201135229_add_project_fk_indexes.sql new file mode 100644 index 0000000000..7f98613d09 --- /dev/null +++ b/supabase/migrations/20260201135229_add_project_fk_indexes.sql @@ -0,0 +1,41 @@ +-- Add FK-supporting indexes for project deletes. +-- These speed up ON DELETE CASCADE / SET NULL from public.project(id). + +create index if not exists document_project_id_idx + on public.document (project_id); + +create index if not exists visitor_project_id_idx + on public.visitor (project_id); + +create index if not exists user_project_access_state_project_id_idx + on public.user_project_access_state (project_id); + +create index if not exists bucket_document_project_id_idx + on grida_storage.bucket_document (project_id); + +create index if not exists campaign_project_id_idx + on grida_west_referral.campaign (project_id); + +create index if not exists referrer_project_id_idx + on grida_west_referral.referrer (project_id); + +create index if not exists grida_forms_form_project_id_idx + on grida_forms.form (project_id); + +create index if not exists form_document_project_id_idx + on grida_forms.form_document (project_id); + +create index if not exists schema_document_project_id_idx + on grida_forms.schema_document (project_id); + +create index if not exists connection_commerce_store_project_id_idx + on grida_forms.connection_commerce_store (project_id); + +create index if not exists store_project_id_idx + on grida_commerce.store (project_id); + +create index if not exists manifest_project_id_idx + on grida_g11n.manifest (project_id); + +create index if not exists customer_auth_policy_project_id_idx + on grida_ciam.customer_auth_policy (project_id); diff --git a/supabase/tests/test_grida_ciam_rls_test.sql b/supabase/tests/test_grida_ciam_rls_test.sql index f9fdfd2211..e439ccdafc 100644 --- a/supabase/tests/test_grida_ciam_rls_test.sql +++ b/supabase/tests/test_grida_ciam_rls_test.sql @@ -1,5 +1,5 @@ BEGIN; -SELECT plan(22); +SELECT plan(17); -- Get user IDs and project ID from seed data DO $$ @@ -173,63 +173,6 @@ SELECT ok( ); RESET ROLE; ---------------------------------------------------------------------- --- Tests for tag_with_usage view (grida_ciam_public.tag_with_usage) ---------------------------------------------------------------------- - --- Test 9: Insider can see tag_with_usage with usage_count -SELECT test_set_auth('insider@grida.co'); -SELECT ok( - EXISTS ( - SELECT 1 - FROM grida_ciam_public.tag_with_usage - WHERE project_id = (SELECT id FROM public.project WHERE name = 'dev') - AND name = 'test-tag' - AND usage_count > 0 - ), - 'Insider (organization member) should see tag_with_usage with usage_count' -); -SELECT test_reset_auth(); - --- Test 10: Random user cannot see tag_with_usage (rejection test) -SELECT test_set_auth('random@example.com'); -SELECT ok( - NOT EXISTS ( - SELECT 1 - FROM grida_ciam_public.tag_with_usage - WHERE project_id = (SELECT id FROM public.project WHERE name = 'dev') - AND name = 'test-tag' - ), - 'Random user (not a member) should be REJECTED from seeing tag_with_usage' -); -SELECT test_reset_auth(); - --- Test 11: Anon cannot see tag_with_usage (rejection test) -SET ROLE anon; -SELECT ok( - NOT EXISTS ( - SELECT 1 - FROM grida_ciam_public.tag_with_usage - WHERE project_id = (SELECT id FROM public.project WHERE name = 'dev') - AND name = 'test-tag' - ), - 'Anon should be REJECTED from seeing tag_with_usage' -); -RESET ROLE; - --- Test 12: Service role can bypass RLS and see tag_with_usage -SET ROLE service_role; -SELECT ok( - EXISTS ( - SELECT 1 - FROM grida_ciam_public.tag_with_usage - WHERE project_id = (SELECT id FROM public.project WHERE name = 'dev') - AND name = 'test-tag' - ), - 'Service role should bypass RLS and see tag_with_usage' -); -RESET ROLE; - --------------------------------------------------------------------- -- Tests for customer_auth_policy view (grida_ciam_public.customer_auth_policy) --------------------------------------------------------------------- @@ -305,12 +248,12 @@ SELECT is( JOIN pg_class c ON c.relname = v.viewname JOIN pg_namespace n ON n.oid = c.relnamespace AND n.nspname = v.schemaname WHERE v.schemaname = 'grida_ciam_public' - AND v.viewname IN ('customer_auth_policy', 'customer_tag', 'customer_with_tags', 'tag_with_usage') + AND v.viewname IN ('customer_auth_policy', 'customer_tag', 'customer_with_tags') AND c.reloptions IS NOT NULL AND array_to_string(c.reloptions, ',') LIKE '%security_invoker=true%' ), - 4::bigint, - 'All 4 views should have security_invoker = true' + 3::bigint, + 'All 3 views should have security_invoker = true' ); -- Test 18: Verify data isolation - insider only sees their project's data @@ -370,18 +313,5 @@ SELECT ok( ); SELECT test_reset_auth(); --- Test 22: Alice (acme org) cannot see insider's tag_with_usage from local org (rejection test) -SELECT test_set_auth('alice@acme.com'); -SELECT ok( - NOT EXISTS ( - SELECT 1 - FROM grida_ciam_public.tag_with_usage - WHERE project_id = (SELECT id FROM public.project WHERE name = 'dev' AND organization_id = (SELECT id FROM public.organization WHERE name = 'local')) - AND name = 'test-tag' - ), - 'Alice (acme org) should be REJECTED from seeing tag_with_usage from insider''s local org (multi-tenant isolation)' -); -SELECT test_reset_auth(); - SELECT * FROM finish(); ROLLBACK; diff --git a/supabase/tests/test_project_delete_rpc_rls_test.sql b/supabase/tests/test_project_delete_rpc_rls_test.sql new file mode 100644 index 0000000000..a37c7762d0 --- /dev/null +++ b/supabase/tests/test_project_delete_rpc_rls_test.sql @@ -0,0 +1,214 @@ +BEGIN; +SELECT plan(12); + +-- Setup: create throwaway projects under seeded orgs. +DO $$ +DECLARE + local_org_id bigint; + acme_org_id bigint; + local_project_id bigint; + acme_project_id bigint; + -- Keep project names short; grida_www assigns a derived www.name with max length 32. + local_project_name text := + 'tloc-' || substr(replace(gen_random_uuid()::text, '-', ''), 1, 8); + acme_project_name text := + 'tacm-' || substr(replace(gen_random_uuid()::text, '-', ''), 1, 8); +BEGIN + -- Use seeded orgs + SELECT id INTO local_org_id FROM public.organization WHERE name = 'local' LIMIT 1; + IF local_org_id IS NULL THEN + RAISE EXCEPTION 'seed org "local" not found'; + END IF; + + SELECT id INTO acme_org_id FROM public.organization WHERE name = 'acme' LIMIT 1; + IF acme_org_id IS NULL THEN + RAISE EXCEPTION 'seed org "acme" not found'; + END IF; + + -- Create projects as service_role (bypass RLS for setup) + SET LOCAL ROLE service_role; + INSERT INTO public.project (organization_id, name) + VALUES (local_org_id, local_project_name) + RETURNING id INTO local_project_id; + + INSERT INTO public.project (organization_id, name) + VALUES (acme_org_id, acme_project_name) + RETURNING id INTO acme_project_id; + RESET ROLE; + + PERFORM set_config('test.project_id_local', local_project_id::text, false); + PERFORM set_config('test.project_id_acme', acme_project_id::text, false); +END $$; + +-- Helper: set auth context as authenticated user. +CREATE OR REPLACE FUNCTION test_set_auth(user_email text) +RETURNS void +LANGUAGE plpgsql +AS $$ +DECLARE + user_id uuid; +BEGIN + SELECT id INTO user_id FROM auth.users WHERE email = user_email; + IF user_id IS NULL THEN + RAISE EXCEPTION 'seed user not found: %', user_email; + END IF; + PERFORM set_config('request.jwt.claim.sub', user_id::text, true); + SET LOCAL ROLE authenticated; +END; +$$; + +-- Helper: reset auth context. +CREATE OR REPLACE FUNCTION test_reset_auth() +RETURNS void +LANGUAGE sql +AS $$ + SELECT set_config('request.jwt.claim.sub', '', true); + RESET ROLE; +$$; + +-- 1) Sanity: local project exists (service_role) +SET ROLE service_role; +SELECT ok( + EXISTS ( + SELECT 1 + FROM public.project + WHERE id = current_setting('test.project_id_local')::bigint + ), + 'Setup created local project' +); +RESET ROLE; + +-- 2) Sanity: acme project exists (service_role) +SET ROLE service_role; +SELECT ok( + EXISTS ( + SELECT 1 + FROM public.project + WHERE id = current_setting('test.project_id_acme')::bigint + ), + 'Setup created acme project' +); +RESET ROLE; + +-- 3) Random user (not member) cannot delete local via RPC (RLS enforced) +SELECT test_set_auth('random@example.com'); +SELECT is( + public.delete_project( + current_setting('test.project_id_local')::bigint, + 'DELETE whatever' + ), + false, + 'Random user cannot delete local project' +); +SELECT test_reset_auth(); + +-- 4) Insider cannot delete acme project (cross-tenant rejection) +SELECT test_set_auth('insider@grida.co'); +SELECT is( + public.delete_project( + current_setting('test.project_id_acme')::bigint, + 'DELETE whatever' + ), + false, + 'Insider cannot delete acme project (cross-tenant)' +); +SELECT test_reset_auth(); + +-- 5) Alice (acme) cannot delete local project (cross-tenant rejection) +SELECT test_set_auth('alice@acme.com'); +SELECT is( + public.delete_project( + current_setting('test.project_id_local')::bigint, + 'DELETE whatever' + ), + false, + 'Alice cannot delete local project (cross-tenant)' +); +SELECT test_reset_auth(); + +-- 6) Local project still exists after rejected deletes (service_role) +SET ROLE service_role; +SELECT ok( + EXISTS ( + SELECT 1 + FROM public.project + WHERE id = current_setting('test.project_id_local')::bigint + ), + 'Local project not deleted by rejected callers' +); +RESET ROLE; + +-- 7) Acme project still exists after rejected deletes (service_role) +SET ROLE service_role; +SELECT ok( + EXISTS ( + SELECT 1 + FROM public.project + WHERE id = current_setting('test.project_id_acme')::bigint + ), + 'Acme project not deleted by rejected callers' +); +RESET ROLE; + +-- 8) Insider (local member) can delete local project via RPC +SELECT test_set_auth('insider@grida.co'); +SELECT is( + public.delete_project( + current_setting('test.project_id_local')::bigint, + 'DELETE ' || + (select name from public.project where id = current_setting('test.project_id_local')::bigint) + ), + true, + 'Insider can delete local project' +); +SELECT test_reset_auth(); + +-- 9) Local project is deleted (service_role sees it) +SET ROLE service_role; +SELECT ok( + NOT EXISTS ( + SELECT 1 + FROM public.project + WHERE id = current_setting('test.project_id_local')::bigint + ), + 'Local project row is deleted' +); +RESET ROLE; + +-- 10) Alice (acme member) cannot delete acme project with wrong confirmation +SELECT test_set_auth('alice@acme.com'); +SELECT is( + public.delete_project(current_setting('test.project_id_acme')::bigint, 'DELETE wrong'), + false, + 'Alice cannot delete acme project with wrong confirmation' +); +SELECT test_reset_auth(); + +-- 10) Alice (acme member) can delete acme project via RPC +SELECT test_set_auth('alice@acme.com'); +SELECT is( + public.delete_project( + current_setting('test.project_id_acme')::bigint, + 'DELETE ' || + (select name from public.project where id = current_setting('test.project_id_acme')::bigint) + ), + true, + 'Alice can delete acme project' +); +SELECT test_reset_auth(); + +-- 11) Acme project is deleted (service_role sees it) +SET ROLE service_role; +SELECT ok( + NOT EXISTS ( + SELECT 1 + FROM public.project + WHERE id = current_setting('test.project_id_acme')::bigint + ), + 'Acme project row is deleted' +); +RESET ROLE; + +SELECT * FROM finish(); +ROLLBACK; +