Skip to content

Commit 9a45f7e

Browse files
committed
fix: auto join tenant
1 parent cd9d0d6 commit 9a45f7e

File tree

3 files changed

+138
-32
lines changed

3 files changed

+138
-32
lines changed

src/lib/membership-service.ts

Lines changed: 21 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -39,22 +39,29 @@ export async function associateUserWithTenant(
3939
role: string = "member",
4040
): Promise<void> {
4141
try {
42-
// CRITICAL: RLS policy requires BOTH is_tenant_owner() AND check_tenant_user_limit()
43-
const { data: _data, error } = await supabase
44-
.from("tenant_members")
45-
.insert({
46-
tenant_id: tenantId,
47-
user_id: userId,
48-
role,
49-
})
50-
.select()
51-
.single();
42+
// Verify auth session before attempting to join
43+
const {
44+
data: { session },
45+
} = await supabase.auth.getSession();
46+
47+
if (!session) {
48+
throw new Error("No active session - user must be authenticated to join tenant");
49+
}
50+
51+
if (session.user.id !== userId) {
52+
throw new Error("Session user ID mismatch");
53+
}
54+
55+
// Use the join_tenant_as_member function to bypass RLS issues
56+
// This function does all the security checks and performs the insert as postgres
57+
const { error } = await supabase.rpc("join_tenant_as_member", {
58+
p_tenant_id: tenantId,
59+
p_user_id: userId,
60+
});
5261

5362
if (error) {
54-
if (error.code === "PGRST116") {
55-
throw new Error("Tenant not found or access denied");
56-
}
57-
throw new Error(error.message);
63+
// The function returns user-friendly error messages
64+
throw new Error(error.message || "Failed to join tenant");
5865
}
5966

6067
// Profile creation happens automatically via database triggers

supabase/migrations/20260103120000_allow_public_tenant_join.sql

Lines changed: 0 additions & 18 deletions
This file was deleted.
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
-- Enable public tenant joining via SECURITY DEFINER function
2+
-- This bypasses RLS "chicken and egg" issues where non-members can't INSERT
3+
-- into tenant_members even with permissive policies
4+
5+
-- ============================================
6+
-- 1. Improve helper functions (SECURITY DEFINER for RLS bypass)
7+
-- ============================================
8+
9+
-- Fix check_tenant_user_limit to properly bypass RLS and handle NULLs
10+
CREATE OR REPLACE FUNCTION "public"."check_tenant_user_limit"("tenant_uuid" "uuid")
11+
RETURNS boolean
12+
LANGUAGE "sql"
13+
SECURITY DEFINER
14+
SET search_path = 'public'
15+
AS $$
16+
SELECT COUNT(*) < COALESCE(
17+
(SELECT pt.user_limit
18+
FROM tenants t
19+
JOIN price_tiers pt ON t.price_tier_id = pt.id
20+
WHERE t.id = tenant_uuid
21+
),
22+
0
23+
)
24+
FROM tenant_members
25+
WHERE tenant_id = tenant_uuid;
26+
$$;
27+
28+
ALTER FUNCTION "public"."check_tenant_user_limit"("tenant_uuid" "uuid") OWNER TO "postgres";
29+
GRANT EXECUTE ON FUNCTION "public"."check_tenant_user_limit"("tenant_uuid" "uuid") TO authenticated;
30+
31+
-- Fix is_tenant_owner to bypass RLS
32+
CREATE OR REPLACE FUNCTION "public"."is_tenant_owner"("tenant_uuid" "uuid")
33+
RETURNS boolean
34+
LANGUAGE "sql"
35+
SECURITY DEFINER
36+
SET search_path = 'public'
37+
AS $$
38+
SELECT EXISTS (
39+
SELECT 1 FROM tenant_members
40+
WHERE tenant_id = tenant_uuid
41+
AND user_id = auth.uid()
42+
AND role = 'owner'
43+
);
44+
$$;
45+
46+
ALTER FUNCTION "public"."is_tenant_owner"("tenant_uuid" "uuid") OWNER TO "postgres";
47+
GRANT EXECUTE ON FUNCTION "public"."is_tenant_owner"("tenant_uuid" "uuid") TO authenticated;
48+
49+
-- Fix is_tenant_member to bypass RLS
50+
CREATE OR REPLACE FUNCTION "public"."is_tenant_member"("tenant_uuid" "uuid")
51+
RETURNS boolean
52+
LANGUAGE "sql"
53+
SECURITY DEFINER
54+
SET search_path = 'public'
55+
AS $$
56+
SELECT EXISTS (
57+
SELECT 1 FROM tenant_members
58+
WHERE tenant_id = tenant_uuid
59+
AND user_id = auth.uid()
60+
);
61+
$$;
62+
63+
ALTER FUNCTION "public"."is_tenant_member"("tenant_uuid" "uuid") OWNER TO "postgres";
64+
GRANT EXECUTE ON FUNCTION "public"."is_tenant_member"("tenant_uuid" "uuid") TO authenticated;
65+
66+
-- ============================================
67+
-- 2. Create join_tenant_as_member function (THE FIX)
68+
-- ============================================
69+
70+
CREATE OR REPLACE FUNCTION "public"."join_tenant_as_member"(
71+
"p_tenant_id" "uuid",
72+
"p_user_id" "uuid"
73+
)
74+
RETURNS "uuid"
75+
LANGUAGE "plpgsql"
76+
SECURITY DEFINER
77+
SET search_path = 'public'
78+
AS $$
79+
DECLARE
80+
v_member_id uuid;
81+
BEGIN
82+
-- Security checks
83+
-- 1. User must be joining themselves
84+
IF p_user_id != auth.uid() THEN
85+
RAISE EXCEPTION 'Cannot join another user to tenant';
86+
END IF;
87+
88+
-- 2. Tenant must be under user limit
89+
IF NOT check_tenant_user_limit(p_tenant_id) THEN
90+
RAISE EXCEPTION 'Tenant has reached maximum user limit';
91+
END IF;
92+
93+
-- 3. User cannot already be a member (idempotent)
94+
IF EXISTS (
95+
SELECT 1 FROM tenant_members
96+
WHERE tenant_id = p_tenant_id AND user_id = p_user_id
97+
) THEN
98+
SELECT id INTO v_member_id
99+
FROM tenant_members
100+
WHERE tenant_id = p_tenant_id AND user_id = p_user_id;
101+
RETURN v_member_id;
102+
END IF;
103+
104+
-- Insert the member (bypasses RLS)
105+
INSERT INTO tenant_members (tenant_id, user_id, role)
106+
VALUES (p_tenant_id, p_user_id, 'member')
107+
RETURNING id INTO v_member_id;
108+
109+
RETURN v_member_id;
110+
END;
111+
$$;
112+
113+
ALTER FUNCTION "public"."join_tenant_as_member"("p_tenant_id" "uuid", "p_user_id" "uuid") OWNER TO "postgres";
114+
GRANT EXECUTE ON FUNCTION "public"."join_tenant_as_member"("p_tenant_id" "uuid", "p_user_id" "uuid") TO authenticated;
115+
116+
COMMENT ON FUNCTION "public"."join_tenant_as_member" IS
117+
'Allows authenticated users to join a tenant as a member. Bypasses RLS via SECURITY DEFINER.';

0 commit comments

Comments
 (0)