Skip to content
281 changes: 281 additions & 0 deletions prisma/migrations/20250402214855_enable_rls/migration.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,281 @@
-- Enable Row Level Security on all tables
ALTER TABLE "UserProfiles" ENABLE ROW LEVEL SECURITY;
ALTER TABLE "Bills" ENABLE ROW LEVEL SECURITY;
ALTER TABLE "Applications" ENABLE ROW LEVEL SECURITY;
ALTER TABLE "Wallets" ENABLE ROW LEVEL SECURITY;
ALTER TABLE "WalletActivations" ENABLE ROW LEVEL SECURITY;
ALTER TABLE "WalletRecoveries" ENABLE ROW LEVEL SECURITY;
ALTER TABLE "WalletExports" ENABLE ROW LEVEL SECURITY;
ALTER TABLE "WorkKeyShares" ENABLE ROW LEVEL SECURITY;
ALTER TABLE "RecoveryKeyShares" ENABLE ROW LEVEL SECURITY;
ALTER TABLE "Challenges" ENABLE ROW LEVEL SECURITY;
ALTER TABLE "AnonChallenges" ENABLE ROW LEVEL SECURITY;
ALTER TABLE "DevicesAndLocations" ENABLE ROW LEVEL SECURITY;
ALTER TABLE "Sessions" ENABLE ROW LEVEL SECURITY;
ALTER TABLE "ApplicationSessions" ENABLE ROW LEVEL SECURITY;
ALTER TABLE "LoginAttempts" ENABLE ROW LEVEL SECURITY;
ALTER TABLE "Organizations" ENABLE ROW LEVEL SECURITY;
ALTER TABLE "Teams" ENABLE ROW LEVEL SECURITY;
ALTER TABLE "Memberships" ENABLE ROW LEVEL SECURITY;

-- Force RLS even for superusers
ALTER TABLE "UserProfiles" FORCE ROW LEVEL SECURITY;
ALTER TABLE "Bills" FORCE ROW LEVEL SECURITY;
ALTER TABLE "Applications" FORCE ROW LEVEL SECURITY;
ALTER TABLE "Wallets" FORCE ROW LEVEL SECURITY;
ALTER TABLE "WalletActivations" FORCE ROW LEVEL SECURITY;
ALTER TABLE "WalletRecoveries" FORCE ROW LEVEL SECURITY;
ALTER TABLE "WalletExports" FORCE ROW LEVEL SECURITY;
ALTER TABLE "WorkKeyShares" FORCE ROW LEVEL SECURITY;
ALTER TABLE "RecoveryKeyShares" FORCE ROW LEVEL SECURITY;
ALTER TABLE "Challenges" FORCE ROW LEVEL SECURITY;
ALTER TABLE "AnonChallenges" FORCE ROW LEVEL SECURITY;
ALTER TABLE "DevicesAndLocations" FORCE ROW LEVEL SECURITY;
ALTER TABLE "Sessions" FORCE ROW LEVEL SECURITY;
ALTER TABLE "ApplicationSessions" FORCE ROW LEVEL SECURITY;
ALTER TABLE "LoginAttempts" FORCE ROW LEVEL SECURITY;
ALTER TABLE "Organizations" FORCE ROW LEVEL SECURITY;
ALTER TABLE "Teams" FORCE ROW LEVEL SECURITY;
ALTER TABLE "Memberships" FORCE ROW LEVEL SECURITY;

-- Policy for service roles to access all tables
-- This creates a policy that allows users with the 'service_role' claim to access all data
CREATE POLICY "Service role access all UserProfiles" ON "UserProfiles"
USING (current_setting('request.jwt.claims', true)::json->>'role' = 'service_role');
CREATE POLICY "Service role access all Bills" ON "Bills"
USING (current_setting('request.jwt.claims', true)::json->>'role' = 'service_role');
CREATE POLICY "Service role access all Applications" ON "Applications"
USING (current_setting('request.jwt.claims', true)::json->>'role' = 'service_role');
CREATE POLICY "Service role access all Wallets" ON "Wallets"
USING (current_setting('request.jwt.claims', true)::json->>'role' = 'service_role');
CREATE POLICY "Service role access all WalletActivations" ON "WalletActivations"
USING (current_setting('request.jwt.claims', true)::json->>'role' = 'service_role');
CREATE POLICY "Service role access all WalletRecoveries" ON "WalletRecoveries"
USING (current_setting('request.jwt.claims', true)::json->>'role' = 'service_role');
CREATE POLICY "Service role access all WalletExports" ON "WalletExports"
USING (current_setting('request.jwt.claims', true)::json->>'role' = 'service_role');
CREATE POLICY "Service role access all WorkKeyShares" ON "WorkKeyShares"
USING (current_setting('request.jwt.claims', true)::json->>'role' = 'service_role');
CREATE POLICY "Service role access all RecoveryKeyShares" ON "RecoveryKeyShares"
USING (current_setting('request.jwt.claims', true)::json->>'role' = 'service_role');
CREATE POLICY "Service role access all Challenges" ON "Challenges"
USING (current_setting('request.jwt.claims', true)::json->>'role' = 'service_role');
CREATE POLICY "Service role access all AnonChallenges" ON "AnonChallenges"
USING (current_setting('request.jwt.claims', true)::json->>'role' = 'service_role');
CREATE POLICY "Service role access all DevicesAndLocations" ON "DevicesAndLocations"
USING (current_setting('request.jwt.claims', true)::json->>'role' = 'service_role');
CREATE POLICY "Service role access all Sessions" ON "Sessions"
USING (current_setting('request.jwt.claims', true)::json->>'role' = 'service_role');
CREATE POLICY "Service role access all ApplicationSessions" ON "ApplicationSessions"
USING (current_setting('request.jwt.claims', true)::json->>'role' = 'service_role');
CREATE POLICY "Service role access all LoginAttempts" ON "LoginAttempts"
USING (current_setting('request.jwt.claims', true)::json->>'role' = 'service_role');
CREATE POLICY "Service role access all Organizations" ON "Organizations"
USING (current_setting('request.jwt.claims', true)::json->>'role' = 'service_role');
CREATE POLICY "Service role access all Teams" ON "Teams"
USING (current_setting('request.jwt.claims', true)::json->>'role' = 'service_role');
CREATE POLICY "Service role access all Memberships" ON "Memberships"
USING (current_setting('request.jwt.claims', true)::json->>'role' = 'service_role');

DO $$
BEGIN
IF EXISTS (SELECT 1 FROM pg_namespace WHERE nspname = 'auth') THEN
EXECUTE $auth_policies$
-- User-specific policies
-- UserProfiles
CREATE POLICY "Users can view their own profile" ON "UserProfiles"
FOR SELECT USING (auth.uid() = "supId");
CREATE POLICY "Users can update their own profile" ON "UserProfiles"
FOR UPDATE USING (auth.uid() = "supId");

-- Wallets
CREATE POLICY "Users can view their own wallets" ON "Wallets"
FOR SELECT USING (auth.uid() = "userId");
CREATE POLICY "Users can update their own wallets" ON "Wallets"
FOR UPDATE USING (auth.uid() = "userId");
CREATE POLICY "Users can insert their own wallets" ON "Wallets"
FOR INSERT WITH CHECK (auth.uid() = "userId");
CREATE POLICY "Users can delete their own wallets" ON "Wallets"
FOR DELETE USING (auth.uid() = "userId");

-- WalletActivations
CREATE POLICY "Users can view their own wallet activations" ON "WalletActivations"
FOR SELECT USING (auth.uid() = "userId");
CREATE POLICY "Users can insert their own wallet activations" ON "WalletActivations"
FOR INSERT WITH CHECK (auth.uid() = "userId");

-- WalletRecoveries
CREATE POLICY "Users can view their own wallet recoveries" ON "WalletRecoveries"
FOR SELECT USING (auth.uid() = "userId");
CREATE POLICY "Users can insert their own wallet recoveries" ON "WalletRecoveries"
FOR INSERT WITH CHECK (auth.uid() = "userId");

-- WalletExports
CREATE POLICY "Users can view their own wallet exports" ON "WalletExports"
FOR SELECT USING (auth.uid() = "userId");
CREATE POLICY "Users can insert their own wallet exports" ON "WalletExports"
FOR INSERT WITH CHECK (auth.uid() = "userId");

-- WorkKeyShares
CREATE POLICY "Users can view their own work key shares" ON "WorkKeyShares"
FOR SELECT USING (auth.uid() = "userId");
CREATE POLICY "Users can insert their own work key shares" ON "WorkKeyShares"
FOR INSERT WITH CHECK (auth.uid() = "userId");
CREATE POLICY "Users can update their own work key shares" ON "WorkKeyShares"
FOR UPDATE USING (auth.uid() = "userId");

-- RecoveryKeyShares
CREATE POLICY "Users can view their own recovery key shares" ON "RecoveryKeyShares"
FOR SELECT USING (auth.uid() = "userId");
CREATE POLICY "Users can insert their own recovery key shares" ON "RecoveryKeyShares"
FOR INSERT WITH CHECK (auth.uid() = "userId");

-- Challenges
CREATE POLICY "Users can view their own challenges" ON "Challenges"
FOR SELECT USING (auth.uid() = "userId");
CREATE POLICY "Users can insert their own challenges" ON "Challenges"
FOR INSERT WITH CHECK (auth.uid() = "userId");
CREATE POLICY "Users can update their own challenges" ON "Challenges"
FOR UPDATE USING (auth.uid() = "userId");
CREATE POLICY "Users can delete their own challenges" ON "Challenges"
FOR DELETE USING (auth.uid() = "userId");

-- DevicesAndLocations
CREATE POLICY "Users can view their own devices and locations" ON "DevicesAndLocations"
FOR SELECT USING (auth.uid() = "userId" OR "userId" IS NULL);
CREATE POLICY "Users can insert devices and locations" ON "DevicesAndLocations"
FOR INSERT WITH CHECK (auth.uid() = "userId" OR "userId" IS NULL);

-- Sessions
CREATE POLICY "Users can view their own sessions" ON "Sessions"
FOR SELECT USING (auth.uid() = "userId");
CREATE POLICY "Users can update their own sessions" ON "Sessions"
FOR UPDATE USING (auth.uid() = "userId");
CREATE POLICY "Users can delete their own sessions" ON "Sessions"
FOR DELETE USING (auth.uid() = "userId");

-- Membership-related policies
-- Organizations
CREATE POLICY "Users can view organizations they are members of" ON "Organizations"
FOR SELECT USING (
EXISTS (
SELECT 1 FROM "Memberships"
WHERE "Memberships"."organizationId" = "Organizations"."id"
AND "Memberships"."userId" = auth.uid()
)
);

CREATE POLICY "Users with owner role can update their organizations" ON "Organizations"
FOR UPDATE USING (
EXISTS (
SELECT 1 FROM "Memberships"
WHERE "Memberships"."organizationId" = "Organizations"."id"
AND "Memberships"."userId" = auth.uid()
AND "Memberships"."role" = 'OWNER'
)
);

-- Teams
CREATE POLICY "Users can view teams they are members of" ON "Teams"
FOR SELECT USING (
EXISTS (
SELECT 1 FROM "Memberships"
WHERE "Memberships"."teamId" = "Teams"."id"
AND "Memberships"."userId" = auth.uid()
)
);

CREATE POLICY "Users with owner/admin role can update their teams" ON "Teams"
FOR UPDATE USING (
EXISTS (
SELECT 1 FROM "Memberships"
WHERE "Memberships"."teamId" = "Teams"."id"
AND "Memberships"."userId" = auth.uid()
AND "Memberships"."role" IN ('OWNER', 'ADMIN')
)
);

-- Applications
CREATE POLICY "Team members can view applications" ON "Applications"
FOR SELECT USING (
EXISTS (
SELECT 1 FROM "Memberships"
WHERE "Memberships"."userId" = auth.uid()
AND "Memberships"."teamId" = "Applications"."teamId"
)
);

CREATE POLICY "Team owners/admins can insert applications" ON "Applications"
FOR INSERT WITH CHECK (
EXISTS (
SELECT 1 FROM "Memberships"
WHERE "Memberships"."userId" = auth.uid()
AND "Memberships"."teamId" = "Applications"."teamId"
AND "Memberships"."role" IN ('OWNER', 'ADMIN')
)
);

CREATE POLICY "Team owners/admins can update applications" ON "Applications"
FOR UPDATE USING (
EXISTS (
SELECT 1 FROM "Memberships"
WHERE "Memberships"."userId" = auth.uid()
AND "Memberships"."teamId" = "Applications"."teamId"
AND "Memberships"."role" IN ('OWNER', 'ADMIN')
)
);

CREATE POLICY "Team owners/admins can delete applications" ON "Applications"
FOR DELETE USING (
EXISTS (
SELECT 1 FROM "Memberships"
WHERE "Memberships"."userId" = auth.uid()
AND "Memberships"."teamId" = "Applications"."teamId"
AND "Memberships"."role" IN ('OWNER', 'ADMIN')
)
);

-- Memberships
CREATE POLICY "Users can view memberships they are part of" ON "Memberships"
FOR SELECT USING (
"userId" = auth.uid() OR
EXISTS (
SELECT 1 FROM "Memberships" AS m
WHERE m."userId" = auth.uid()
AND m."organizationId" = "Memberships"."organizationId"
)
);

CREATE POLICY "Team owners can manage memberships" ON "Memberships"
FOR ALL USING (
EXISTS (
SELECT 1 FROM "Memberships" AS m
WHERE m."userId" = auth.uid()
AND m."organizationId" = "Memberships"."organizationId"
AND m."teamId" = "Memberships"."teamId"
AND m."role" = 'OWNER'
)
);

-- Bills
CREATE POLICY "Users can view bills for their organizations" ON "Bills"
FOR SELECT USING (
EXISTS (
SELECT 1 FROM "Memberships"
WHERE "Memberships"."userId" = auth.uid()
AND "Memberships"."organizationId" = "Bills"."organizationId"
)
);

-- Application Sessions
CREATE POLICY "Users can view their own application sessions" ON "ApplicationSessions"
FOR SELECT USING (
EXISTS (
SELECT 1 FROM "Sessions"
WHERE "Sessions"."id" = "ApplicationSessions"."sessionId"
AND "Sessions"."userId" = auth.uid()
)
);
$auth_policies$;
END IF;
END $$;
17 changes: 14 additions & 3 deletions server/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { inferAsyncReturnType } from "@trpc/server";
import { Session } from "@prisma/client";
import { createServerClient } from "@/server/utils/supabase/supabase-server-client";
import { jwtDecode } from "jwt-decode";
import { prisma } from "./utils/prisma/prisma-client";
import { basePrisma, createAuthenticatedPrismaClient } from "./utils/prisma/prisma-client";
import {
getClientCountryCode,
getClientIp,
Expand All @@ -14,6 +14,9 @@ export async function createContext({ req }: { req: Request }) {
const clientId = req.headers.get("x-client-id");
const applicationId = req.headers.get("x-application-id") || "";

// Default to using unauthenticated prisma client
let prisma = basePrisma;

if (!authHeader || !clientId) {
return createEmptyContext();
}
Expand Down Expand Up @@ -42,6 +45,11 @@ export async function createContext({ req }: { req: Request }) {
}

const user = data.user;

// Create an authenticated Prisma client with the user's JWT token
// Pass 'authenticated' role to activate RLS policies
prisma = createAuthenticatedPrismaClient(user.id, 'authenticated') as typeof basePrisma;

let ip = getClientIp(req);

if (process.env.NODE_ENV === "development") {
Expand Down Expand Up @@ -91,7 +99,10 @@ async function getAndUpdateSession(
if (Object.keys(sessionUpdates).length > 0) {
console.log("Updating session:", sessionUpdates);

prisma.session
// Use authenticated prisma client for the current user
const authPrisma = createAuthenticatedPrismaClient(userId, 'authenticated');

authPrisma.session
.update({
where: { id: sessionId },
data: sessionUpdates,
Expand Down Expand Up @@ -119,7 +130,7 @@ function decodeJwt(token: string) {

function createEmptyContext() {
return {
prisma,
prisma: basePrisma, // Use base prisma client for unauthenticated requests
user: null,
session: createSessionObject(null),
};
Expand Down
38 changes: 36 additions & 2 deletions server/utils/prisma/prisma-client.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,41 @@
import { PrismaClient } from "@prisma/client";

// Create a base Prisma client
const globalForPrisma = global as unknown as { prisma: PrismaClient };
export const basePrisma = globalForPrisma.prisma || new PrismaClient();

export const prisma = globalForPrisma.prisma || new PrismaClient();
if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = basePrisma;

if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma;
// Function to create an authenticated Prisma client instance
export function createAuthenticatedPrismaClient(userId?: string, role?: string) {
// User ID is required for authenticated client
// It should be extracted from the JWT token
if (!userId && !role) {
return basePrisma;
}

// Create a new client with extensions to handle authentication
const prisma = new PrismaClient({
datasources: {
db: {
url: process.env.DATABASE_URL,
},
},
});

return prisma.$extends({
client: {
async $beforeQuery() {
// Set PostgreSQL role and JWT claims via raw queries before each operation
if (role) {
await prisma.$executeRawUnsafe(`SET ROLE ${role}`);
}

if (userId) {
const claimsJson = JSON.stringify({ sub: userId, role });
await prisma.$executeRawUnsafe(`SET LOCAL "request.jwt.claims" = '${claimsJson}'`);
}
}
}
});
}
7 changes: 5 additions & 2 deletions server/utils/validation/validation.utils.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { prisma } from "../prisma/prisma-client";
import { createAuthenticatedPrismaClient } from "../prisma/prisma-client";
import { TRPCError } from "@trpc/server";

function extractDomain(origin: string | null): string | null {
Expand Down Expand Up @@ -30,9 +30,12 @@ function isDomainAllowed(
export async function validateApplication(
clientId: string,
origin: string,
sessionId?: string
sessionId?: string,
userId?: string
): Promise<string> {
try {
const prisma = createAuthenticatedPrismaClient(userId, 'authenticated');

const application = await prisma.application
.findUnique({
where: { clientId },
Expand Down