add better access control

This commit is contained in:
Carl-Gerhard Lindesvärd
2024-06-05 23:47:45 +02:00
parent 68c4530ea5
commit 1e6cd0dee2
17 changed files with 309 additions and 34 deletions

View File

@@ -29,6 +29,9 @@ ENV NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=$NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY
ARG CLERK_SECRET_KEY
ENV CLERK_SECRET_KEY=$CLERK_SECRET_KEY
ARG CLERK_PUBLIC_PEM_KEY
ENV CLERK_PUBLIC_PEM_KEY=$CLERK_PUBLIC_PEM_KEY
ARG SEVENTY_SEVEN_API_KEY
ENV SEVENTY_SEVEN_API_KEY=$SEVENTY_SEVEN_API_KEY

View File

@@ -26,6 +26,7 @@
"fastify": "^4.25.2",
"fastify-metrics": "^11.0.0",
"ico-to-png": "^0.2.1",
"jsonwebtoken": "^9.0.2",
"pino": "^8.17.2",
"pino-pretty": "^10.3.1",
"ramda": "^0.29.1",
@@ -41,6 +42,7 @@
"@openpanel/prettier-config": "workspace:*",
"@openpanel/sdk": "workspace:*",
"@openpanel/tsconfig": "workspace:*",
"@types/jsonwebtoken": "^9.0.6",
"@types/ramda": "^0.29.6",
"@types/request-ip": "^0.0.41",
"@types/sqlstring": "^2.3.2",

View File

@@ -1,4 +1,4 @@
import { getAuth } from '@clerk/fastify';
import { validateClerkJwt } from '@/utils/auth';
import type { FastifyReply, FastifyRequest } from 'fastify';
import { escape } from 'sqlstring';
import superjson from 'superjson';
@@ -13,6 +13,7 @@ import {
transformMinimalEvent,
} from '@openpanel/db';
import { redis, redisPub, redisSub } from '@openpanel/redis';
import { getProjectAccess } from '@openpanel/trpc';
export function getLiveEventInfo(key: string) {
return key.split(':').slice(2) as [string, string];
@@ -106,7 +107,7 @@ export function wsEvents(connection: { socket: WebSocket }) {
});
}
export function wsProjectEvents(
export async function wsProjectEvents(
connection: {
socket: WebSocket;
},
@@ -114,10 +115,19 @@ export function wsProjectEvents(
Params: {
projectId: string;
};
Querystring: {
token?: string;
};
}>
) {
const { params } = req;
const auth = getAuth(req);
const { params, query } = req;
const { token } = query;
const decoded = validateClerkJwt(token);
const userId = decoded?.sub;
const access = await getProjectAccess({
userId: userId!,
projectId: params.projectId,
});
redisSub.subscribe('event');
@@ -127,7 +137,7 @@ export function wsProjectEvents(
const profile = await getProfileById(event.profileId, event.projectId);
connection.socket.send(
superjson.stringify(
auth.userId
access
? {
...event,
profile,

View File

@@ -1,4 +1,5 @@
import type { RawRequestDefaultExpression } from 'fastify';
import jwt from 'jsonwebtoken';
import { verifyPassword } from '@openpanel/common';
import type { IServiceClient } from '@openpanel/db';
@@ -103,3 +104,23 @@ export async function validateExportRequest(
return client;
}
export function validateClerkJwt(token?: string) {
if (!token) {
return null;
}
try {
const decoded = jwt.verify(
token,
process.env.CLERK_PUBLIC_PEM_KEY!.replace(/\\n/g, '\n')
);
if (typeof decoded === 'object') {
return decoded;
}
} catch (e) {
//
}
return null;
}

View File

@@ -1,20 +1,34 @@
'use client';
import { useEffect, useState } from 'react';
import { use, useEffect, useMemo, useState } from 'react';
import { useAuth } from '@clerk/nextjs';
import useWebSocket from 'react-use-websocket';
import { getSuperJson } from '@openpanel/common';
export default function useWS<T>(path: string, onMessage: (event: T) => void) {
const auth = useAuth();
const ws = String(process.env.NEXT_PUBLIC_API_URL)
.replace(/^https/, 'wss')
.replace(/^http/, 'ws');
const [socketUrl, setSocketUrl] = useState(`${ws}${path}`);
const [baseUrl, setBaseUrl] = useState(`${ws}${path}`);
const [token, setToken] = useState<string | null>(null);
const socketUrl = useMemo(
() => (token ? `${baseUrl}?token=${token}` : baseUrl),
[baseUrl, token]
);
useEffect(() => {
if (socketUrl === `${ws}${path}`) return;
setSocketUrl(`${ws}${path}`);
}, [path, socketUrl, ws]);
if (auth.isSignedIn) {
auth.getToken().then(setToken);
}
}, [auth]);
useEffect(() => {
if (baseUrl === `${ws}${path}`) return;
setBaseUrl(`${ws}${path}`);
}, [path, baseUrl, ws]);
console.log('socketUrl', socketUrl);
useWebSocket(socketUrl, {
shouldReconnect: () => true,

View File

@@ -0,0 +1,17 @@
import { redis } from './redis';
export function cacheable<T extends (...args: any) => any>(
fn: T,
expire: number
) {
return async function (...args: Parameters<T>): Promise<ReturnType<T>> {
const key = `cachable:${fn.name}:${JSON.stringify(args)}`;
const cached = await redis.get(key);
if (cached) {
return JSON.parse(cached);
}
const result = await fn(...(args as any));
redis.setex(key, expire, JSON.stringify(result));
return result;
};
}

View File

@@ -1,10 +1,2 @@
import type { RedisOptions } from 'ioredis';
import Redis from 'ioredis';
const options: RedisOptions = {
connectTimeout: 10000,
};
export const redis = new Redis(process.env.REDIS_URL!, options);
export const redisSub = new Redis(process.env.REDIS_URL!, options);
export const redisPub = new Redis(process.env.REDIS_URL!, options);
export * from './redis';
export * from './cachable';

10
packages/redis/redis.ts Normal file
View File

@@ -0,0 +1,10 @@
import type { RedisOptions } from 'ioredis';
import Redis from 'ioredis';
const options: RedisOptions = {
connectTimeout: 10000,
};
export const redis = new Redis(process.env.REDIS_URL!, options);
export const redisSub = new Redis(process.env.REDIS_URL!, options);
export const redisPub = new Redis(process.env.REDIS_URL!, options);

View File

@@ -1,2 +1,3 @@
export * from './src/root';
export * from './src/trpc';
export { getProjectAccess } from './src/access';

View File

@@ -13,6 +13,7 @@
"@openpanel/constants": "workspace:*",
"@openpanel/db": "workspace:*",
"@openpanel/validation": "workspace:*",
"@openpanel/redis": "workspace:*",
"@seventy-seven/sdk": "0.0.0-beta.2",
"@trpc/server": "^10.45.1",
"date-fns": "^3.3.1",
@@ -44,4 +45,4 @@
]
},
"prettier": "@openpanel/prettier-config"
}
}

View File

@@ -0,0 +1,53 @@
import { clerkClient } from '@clerk/fastify';
import { getProjectById } from '@openpanel/db';
import { cacheable } from '@openpanel/redis';
export const getProjectAccessCached = cacheable(getProjectAccess, 60 * 60);
export async function getProjectAccess({
userId,
projectId,
}: {
userId: string;
projectId: string;
}) {
try {
// Check if user has access to the project
const [project, organizations] = await Promise.all([
getProjectById(projectId),
clerkClient.users.getOrganizationMembershipList({
userId,
}),
]);
if (!project) {
return false;
}
return !!organizations.data.find(
(org) => org.organization.slug === project.organizationSlug
);
} catch (err) {
return false;
}
}
export const getOrganizationAccessCached = cacheable(
getOrganizationAccess,
60 * 60
);
export async function getOrganizationAccess({
userId,
organizationId,
}: {
userId: string;
organizationId: string;
}) {
const organizations = await clerkClient.users.getOrganizationMembershipList({
userId,
});
return !!organizations.data.find(
(org) => org.organization.id === organizationId
);
}

View File

@@ -0,0 +1,7 @@
import { TRPCError } from '@trpc/server';
export const TRPCAccessError = (message: string) =>
new TRPCError({
code: 'UNAUTHORIZED',
message,
});

View File

@@ -3,10 +3,12 @@ import { escape } from 'sqlstring';
import { z } from 'zod';
import { average, max, min, round, slug, sum } from '@openpanel/common';
import { chQuery, createSqlBuilder } from '@openpanel/db';
import { chQuery, createSqlBuilder, db } from '@openpanel/db';
import { zChartInput } from '@openpanel/validation';
import type { IChartEvent, IChartInput } from '@openpanel/validation';
import { getProjectAccessCached } from '../access';
import { TRPCAccessError } from '../errors';
import { createTRPCRouter, protectedProcedure, publicProcedure } from '../trpc';
import {
getChartPrevStartEndDate,
@@ -111,8 +113,7 @@ export const chartRouter = createTRPCRouter({
)(properties);
}),
// TODO: Make this private
values: publicProcedure
values: protectedProcedure
.input(
z.object({
event: z.string(),
@@ -154,7 +155,7 @@ export const chartRouter = createTRPCRouter({
};
}),
funnel: publicProcedure.input(zChartInput).query(async ({ input }) => {
funnel: protectedProcedure.input(zChartInput).query(async ({ input }) => {
const currentPeriod = getChartStartEndDate(input);
const previousPeriod = getChartPrevStartEndDate({
range: input.range,
@@ -172,7 +173,7 @@ export const chartRouter = createTRPCRouter({
};
}),
funnelStep: publicProcedure
funnelStep: protectedProcedure
.input(
zChartInput.extend({
step: z.number(),
@@ -183,8 +184,27 @@ export const chartRouter = createTRPCRouter({
return getFunnelStep({ ...input, ...currentPeriod });
}),
// TODO: Make this private
chart: publicProcedure.input(zChartInput).query(async ({ input }) => {
chart: publicProcedure.input(zChartInput).query(async ({ input, ctx }) => {
if (ctx.session.userId) {
const access = await getProjectAccessCached({
projectId: input.projectId,
userId: ctx.session.userId,
});
if (!access) {
throw TRPCAccessError('You do not have access to this project');
}
} else {
const share = await db.shareOverview.findFirst({
where: {
projectId: input.projectId,
},
});
if (!share) {
throw TRPCAccessError('You do not have access to this project');
}
}
const currentPeriod = getChartStartEndDate(input);
const previousPeriod = getChartPrevStartEndDate({
range: input.range,

View File

@@ -3,6 +3,8 @@ import { z } from 'zod';
import { chQuery, convertClickhouseDateToJs, db } from '@openpanel/db';
import { getProjectAccessCached } from '../access';
import { TRPCAccessError } from '../errors';
import { createTRPCRouter, protectedProcedure, publicProcedure } from '../trpc';
export const eventRouter = createTRPCRouter({
@@ -37,7 +39,27 @@ export const eventRouter = createTRPCRouter({
limit: z.number().default(8),
})
)
.query(async ({ input: { projectId, cursor, limit } }) => {
.query(async ({ input: { projectId, cursor, limit }, ctx }) => {
if (ctx.session.userId) {
const access = await getProjectAccessCached({
projectId,
userId: ctx.session.userId,
});
if (!access) {
throw TRPCAccessError('You do not have access to this project');
}
} else {
const share = await db.shareOverview.findFirst({
where: {
projectId,
},
});
if (!share) {
throw TRPCAccessError('You do not have access to this project');
}
}
const [events, counts] = await Promise.all([
chQuery<{
id: string;

View File

@@ -4,7 +4,7 @@ import { z } from 'zod';
import { chQuery, createSqlBuilder } from '@openpanel/db';
import { createTRPCRouter, protectedProcedure, publicProcedure } from '../trpc';
import { createTRPCRouter, protectedProcedure } from '../trpc';
export const profileRouter = createTRPCRouter({
properties: protectedProcedure
@@ -28,7 +28,7 @@ export const profileRouter = createTRPCRouter({
)(properties);
}),
values: publicProcedure
values: protectedProcedure
.input(
z.object({
property: z.string(),

View File

@@ -1,9 +1,13 @@
import { getAuth } from '@clerk/fastify';
import { initTRPC, TRPCError } from '@trpc/server';
import type { CreateFastifyContextOptions } from '@trpc/server/adapters/fastify';
import { has } from 'ramda';
import superjson from 'superjson';
import { ZodError } from 'zod';
import { getProjectAccessCached } from './access';
import { TRPCAccessError } from './errors';
export function createContext({ req, res }: CreateFastifyContextOptions) {
return {
req,
@@ -41,10 +45,11 @@ const t = initTRPC.context<Context>().create({
},
});
const enforceUserIsAuthed = t.middleware(async ({ ctx, next }) => {
const enforceUserIsAuthed = t.middleware(async ({ ctx, next, input }) => {
if (!ctx.session?.userId) {
throw new TRPCError({ code: 'UNAUTHORIZED', message: 'Not authenticated' });
}
try {
return next({
ctx: {
@@ -60,7 +65,25 @@ const enforceUserIsAuthed = t.middleware(async ({ ctx, next }) => {
}
});
// Only used on protected routes
const enforceProjectAccess = t.middleware(async ({ ctx, next, rawInput }) => {
if (has('projectId', rawInput)) {
const access = await getProjectAccessCached({
userId: ctx.session.userId!,
projectId: rawInput.projectId as string,
});
if (!access) {
throw TRPCAccessError('You do not have access to this project');
}
}
return next();
});
export const createTRPCRouter = t.router;
export const publicProcedure = t.procedure;
export const protectedProcedure = t.procedure.use(enforceUserIsAuthed);
export const protectedProcedure = t.procedure
.use(enforceUserIsAuthed)
.use(enforceProjectAccess);

81
pnpm-lock.yaml generated
View File

@@ -71,6 +71,9 @@ importers:
ico-to-png:
specifier: ^0.2.1
version: 0.2.1
jsonwebtoken:
specifier: ^9.0.2
version: 9.0.2
pino:
specifier: ^8.17.2
version: 8.19.0
@@ -111,6 +114,9 @@ importers:
'@openpanel/tsconfig':
specifier: workspace:*
version: link:../../tooling/typescript
'@types/jsonwebtoken':
specifier: ^9.0.6
version: 9.0.6
'@types/ramda':
specifier: ^0.29.6
version: 0.29.10
@@ -1243,6 +1249,9 @@ importers:
'@openpanel/db':
specifier: workspace:*
version: link:../db
'@openpanel/redis':
specifier: workspace:*
version: link:../redis
'@openpanel/validation':
specifier: workspace:*
version: link:../validation
@@ -7531,6 +7540,12 @@ packages:
resolution: {integrity: sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==}
dev: false
/@types/jsonwebtoken@9.0.6:
resolution: {integrity: sha512-/5hndP5dCjloafCXns6SZyESp3Ldq7YjH3zwzwczYnjxIT0Fqzk5ROSYVGfFyczIue7IUEj8hkvLbPoLQ18vQw==}
dependencies:
'@types/node': 18.19.17
dev: true
/@types/katex@0.16.7:
resolution: {integrity: sha512-HMwFiRujE5PjrgwHQ25+bsLJgowjGjm5Z8FVSf0N6PwgJrwxH0QxzHYDcKsTfV3wva0vzrpqMTJS2jXPr5BMEQ==}
dev: false
@@ -8618,6 +8633,10 @@ packages:
buffer-fill: 1.0.0
dev: false
/buffer-equal-constant-time@1.0.1:
resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==}
dev: false
/buffer-fill@1.0.0:
resolution: {integrity: sha512-T7zexNBwiiaCOGDg9xNX9PBmjrubblRkENuptryuI64URkXDFum9il/JGL8Lm8wYfAXpredVXXZz7eMHilimiQ==}
dev: false
@@ -10012,6 +10031,12 @@ packages:
/eastasianwidth@0.2.0:
resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==}
/ecdsa-sig-formatter@1.0.11:
resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==}
dependencies:
safe-buffer: 5.2.1
dev: false
/ee-first@1.1.1:
resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==}
dev: false
@@ -12832,6 +12857,22 @@ packages:
graceful-fs: 4.2.11
dev: false
/jsonwebtoken@9.0.2:
resolution: {integrity: sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ==}
engines: {node: '>=12', npm: '>=6'}
dependencies:
jws: 3.2.2
lodash.includes: 4.3.0
lodash.isboolean: 3.0.3
lodash.isinteger: 4.0.4
lodash.isnumber: 3.0.3
lodash.isplainobject: 4.0.6
lodash.isstring: 4.0.1
lodash.once: 4.1.1
ms: 2.1.3
semver: 7.6.0
dev: false
/jsx-ast-utils@3.3.5:
resolution: {integrity: sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==}
engines: {node: '>=4.0'}
@@ -12842,6 +12883,21 @@ packages:
object.values: 1.1.7
dev: false
/jwa@1.4.1:
resolution: {integrity: sha512-qiLX/xhEEFKUAJ6FiBMbes3w9ATzyk5W7Hvzpa/SLYdxNtng+gcurvrI7TbACjIXlsJyr05/S1oUhZrc63evQA==}
dependencies:
buffer-equal-constant-time: 1.0.1
ecdsa-sig-formatter: 1.0.11
safe-buffer: 5.2.1
dev: false
/jws@3.2.2:
resolution: {integrity: sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==}
dependencies:
jwa: 1.4.1
safe-buffer: 5.2.1
dev: false
/katex@0.16.9:
resolution: {integrity: sha512-fsSYjWS0EEOwvy81j3vRA8TEAhQhKiqO+FQaKWp0m39qwOzHVBgAUBIXWj1pB+O2W3fIpNa6Y9KSKCVbfPhyAQ==}
hasBin: true
@@ -13073,17 +13129,40 @@ packages:
resolution: {integrity: sha512-z+Uw/vLuy6gQe8cfaFWD7p0wVv8fJl3mbzXh33RS+0oW2wvUqiRXiQ69gLWSLpgB5/6sU+r6BlQR0MBILadqTQ==}
dev: false
/lodash.includes@4.3.0:
resolution: {integrity: sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==}
dev: false
/lodash.isarguments@3.1.0:
resolution: {integrity: sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==}
dev: false
/lodash.isboolean@3.0.3:
resolution: {integrity: sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==}
dev: false
/lodash.isinteger@4.0.4:
resolution: {integrity: sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==}
dev: false
/lodash.isnumber@3.0.3:
resolution: {integrity: sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==}
dev: false
/lodash.isplainobject@4.0.6:
resolution: {integrity: sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==}
dev: true
/lodash.isstring@4.0.1:
resolution: {integrity: sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==}
dev: false
/lodash.merge@4.6.2:
resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==}
/lodash.once@4.1.1:
resolution: {integrity: sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==}
dev: false
/lodash.sortby@4.7.0:
resolution: {integrity: sha512-HDWXG8isMntAyRF5vZ7xKuEvOhT4AhlRt/3czTSjvGUxjYCBVRQY48ViDHyfYz9VIoBkW4TMGQNapx+l3RUwdA==}
dev: true