Merge pull request #4317 from umami-software/opencode/nimble-planet
Fix board share entity authorization
This commit is contained in:
@@ -1,8 +1,9 @@
|
||||
import { z } from 'zod';
|
||||
import { BOARD_TYPES, normalizeBoardType } from '@/lib/boards';
|
||||
import type { BoardParameters } from '@/lib/types';
|
||||
import { parseRequest } from '@/lib/request';
|
||||
import { json, ok, serverError, unauthorized } from '@/lib/response';
|
||||
import { canDeleteBoard, canUpdateBoard, canViewBoard } from '@/permissions';
|
||||
import { badRequest, json, ok, serverError, unauthorized } from '@/lib/response';
|
||||
import { canDeleteBoard, canUpdateBoard, canViewBoard, canViewBoardEntities } from '@/permissions';
|
||||
import { deleteBoard, getBoard, updateBoard } from '@/queries/prisma';
|
||||
|
||||
export async function GET(request: Request, { params }: { params: Promise<{ boardId: string }> }) {
|
||||
@@ -54,6 +55,21 @@ export async function POST(request: Request, { params }: { params: Promise<{ boa
|
||||
return unauthorized();
|
||||
}
|
||||
|
||||
if (type !== undefined || parameters !== undefined) {
|
||||
const currentBoard = await getBoard(boardId);
|
||||
|
||||
if (!currentBoard) {
|
||||
return unauthorized();
|
||||
}
|
||||
|
||||
const nextType = type ?? currentBoard.type;
|
||||
const nextParameters = (parameters ?? currentBoard.parameters) as BoardParameters;
|
||||
|
||||
if (!(await canViewBoardEntities(auth, nextType, nextParameters))) {
|
||||
return badRequest({ message: 'Board contains inaccessible entities.' });
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const board = await updateBoard(boardId, { type, name, description, parameters });
|
||||
|
||||
|
||||
@@ -2,9 +2,9 @@ import { z } from 'zod';
|
||||
import { BOARD_TYPES, normalizeBoardType } from '@/lib/boards';
|
||||
import { uuid } from '@/lib/crypto';
|
||||
import { getQueryFilters, parseRequest } from '@/lib/request';
|
||||
import { json, unauthorized } from '@/lib/response';
|
||||
import { badRequest, json, unauthorized } from '@/lib/response';
|
||||
import { pagingParams, searchParams, sortingParams } from '@/lib/schema';
|
||||
import { canCreateTeamWebsite, canCreateWebsite } from '@/permissions';
|
||||
import { canCreateTeamWebsite, canCreateWebsite, canViewBoardEntities } from '@/permissions';
|
||||
import { createBoard, getUserBoards } from '@/queries/prisma';
|
||||
|
||||
export async function GET(request: Request) {
|
||||
@@ -58,6 +58,10 @@ export async function POST(request: Request) {
|
||||
return unauthorized();
|
||||
}
|
||||
|
||||
if (!(await canViewBoardEntities(auth, body.type, body.parameters))) {
|
||||
return badRequest({ message: 'Board contains inaccessible entities.' });
|
||||
}
|
||||
|
||||
const data = {
|
||||
...body,
|
||||
type: normalizeBoardType(body.type),
|
||||
|
||||
@@ -5,8 +5,12 @@ import { createToken } from '@/lib/jwt';
|
||||
import prisma from '@/lib/prisma';
|
||||
import redis from '@/lib/redis';
|
||||
import { json, notFound } from '@/lib/response';
|
||||
import type { BoardParameters, WhiteLabel } from '@/lib/types';
|
||||
import { getBoard, getLink, getPixel, getShareByCode, getWebsite } from '@/queries/prisma';
|
||||
import type { Auth, BoardParameters, WhiteLabel } from '@/lib/types';
|
||||
import { canViewLink, canViewPixel, canViewWebsite } from '@/permissions';
|
||||
import { getBoard, getLink, getPixel, getShareByCode, getUser, getWebsite } from '@/queries/prisma';
|
||||
|
||||
type BoardEntityIds = ReturnType<typeof getBoardEntityIds>;
|
||||
type OwnedEntity = { userId?: string | null; teamId?: string | null } | null;
|
||||
|
||||
async function getAccountId(entity: { userId?: string; teamId?: string }): Promise<string | null> {
|
||||
if (entity.userId) {
|
||||
@@ -44,6 +48,85 @@ async function getWhiteLabel(accountId: string): Promise<WhiteLabel | null> {
|
||||
return null;
|
||||
}
|
||||
|
||||
async function filterEntityIds(
|
||||
ids: string[],
|
||||
canView: (id: string) => Promise<boolean>,
|
||||
): Promise<string[]> {
|
||||
const results = await Promise.all(
|
||||
ids.map(async id => {
|
||||
try {
|
||||
return (await canView(id)) ? id : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
return results.filter((id): id is string => !!id);
|
||||
}
|
||||
|
||||
async function getTeamUserIds(teamId: string) {
|
||||
const teamUsers = await prisma.client.teamUser.findMany({
|
||||
where: { teamId },
|
||||
select: { userId: true },
|
||||
});
|
||||
|
||||
return new Set(teamUsers.map(({ userId }) => userId));
|
||||
}
|
||||
|
||||
function isOwnedByTeam(entity: OwnedEntity, teamId: string, teamUserIds: Set<string>) {
|
||||
return entity?.teamId === teamId || !!(entity?.userId && teamUserIds.has(entity.userId));
|
||||
}
|
||||
|
||||
async function filterBoardEntityIdsForShare(
|
||||
entity: { userId?: string | null; teamId?: string | null },
|
||||
ids: BoardEntityIds,
|
||||
): Promise<BoardEntityIds> {
|
||||
if (entity.teamId) {
|
||||
const teamUserIds = await getTeamUserIds(entity.teamId);
|
||||
|
||||
return {
|
||||
websiteIds: await filterEntityIds(
|
||||
ids.websiteIds,
|
||||
async id => isOwnedByTeam(await getWebsite(id), entity.teamId, teamUserIds),
|
||||
),
|
||||
pixelIds: await filterEntityIds(
|
||||
ids.pixelIds,
|
||||
async id => isOwnedByTeam(await getPixel(id), entity.teamId, teamUserIds),
|
||||
),
|
||||
linkIds: await filterEntityIds(
|
||||
ids.linkIds,
|
||||
async id => isOwnedByTeam(await getLink(id), entity.teamId, teamUserIds),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
if (!entity.userId) {
|
||||
return { websiteIds: [], pixelIds: [], linkIds: [] };
|
||||
}
|
||||
|
||||
const user = await getUser(entity.userId);
|
||||
|
||||
if (!user) {
|
||||
return { websiteIds: [], pixelIds: [], linkIds: [] };
|
||||
}
|
||||
|
||||
const auth: Auth = {
|
||||
user: {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
role: user.role,
|
||||
isAdmin: user.role === ROLES.admin,
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
websiteIds: await filterEntityIds(ids.websiteIds, id => canViewWebsite(auth, id)),
|
||||
pixelIds: await filterEntityIds(ids.pixelIds, id => canViewPixel(auth, id)),
|
||||
linkIds: await filterEntityIds(ids.linkIds, id => canViewLink(auth, id)),
|
||||
};
|
||||
}
|
||||
|
||||
export async function GET(_request: Request, { params }: { params: Promise<{ slug: string }> }) {
|
||||
const { slug } = await params;
|
||||
|
||||
@@ -70,9 +153,10 @@ export async function GET(_request: Request, { params }: { params: Promise<{ slu
|
||||
type: board.type,
|
||||
parameters: board.parameters as BoardParameters,
|
||||
});
|
||||
data.websiteIds = boardEntityIds.websiteIds;
|
||||
data.pixelIds = boardEntityIds.pixelIds;
|
||||
data.linkIds = boardEntityIds.linkIds;
|
||||
const authorizedEntityIds = await filterBoardEntityIdsForShare(board, boardEntityIds);
|
||||
data.websiteIds = authorizedEntityIds.websiteIds;
|
||||
data.pixelIds = authorizedEntityIds.pixelIds;
|
||||
data.linkIds = authorizedEntityIds.linkIds;
|
||||
} else if (share.shareType === ENTITY_TYPE.website) {
|
||||
entity = await getWebsite(share.entityId);
|
||||
if (!entity) return notFound();
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import { beforeEach, expect, test, vi } from 'vitest';
|
||||
import { BOARD_TYPES } from '@/lib/boards';
|
||||
import { canViewBoardEntities } from './board';
|
||||
import { canViewLink } from './link';
|
||||
import { canViewPixel } from './pixel';
|
||||
import { canViewWebsite } from './website';
|
||||
|
||||
vi.mock('@/queries/prisma', () => ({
|
||||
getBoard: vi.fn(),
|
||||
getTeamUser: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/prisma', () => ({
|
||||
default: {},
|
||||
}));
|
||||
|
||||
vi.mock('./website', () => ({
|
||||
canViewWebsite: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('./pixel', () => ({
|
||||
canViewPixel: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('./link', () => ({
|
||||
canViewLink: vi.fn(),
|
||||
}));
|
||||
|
||||
const auth = {
|
||||
user: {
|
||||
id: 'user-1',
|
||||
username: 'user',
|
||||
role: 'user',
|
||||
isAdmin: false,
|
||||
},
|
||||
shareToken: {
|
||||
websiteIds: ['victim-website-id'],
|
||||
},
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(canViewWebsite).mockReset();
|
||||
vi.mocked(canViewPixel).mockReset();
|
||||
vi.mocked(canViewLink).mockReset();
|
||||
});
|
||||
|
||||
test('canViewBoardEntities validates board IDs with user auth only', async () => {
|
||||
vi.mocked(canViewWebsite).mockResolvedValue(true);
|
||||
|
||||
await expect(
|
||||
canViewBoardEntities(auth, BOARD_TYPES.website, { websiteId: 'owned-website-id' }),
|
||||
).resolves.toBe(true);
|
||||
|
||||
expect(canViewWebsite).toHaveBeenCalledWith(
|
||||
{
|
||||
user: auth.user,
|
||||
},
|
||||
'owned-website-id',
|
||||
);
|
||||
});
|
||||
|
||||
test('canViewBoardEntities rejects IDs not accessible to the user', async () => {
|
||||
vi.mocked(canViewWebsite).mockResolvedValue(false);
|
||||
|
||||
await expect(
|
||||
canViewBoardEntities(auth, BOARD_TYPES.mixed, {
|
||||
rows: [
|
||||
{
|
||||
id: 'row-1',
|
||||
columns: [
|
||||
{
|
||||
id: 'column-1',
|
||||
component: {
|
||||
type: 'WebsiteChart',
|
||||
entityType: 'website',
|
||||
entityId: 'victim-website-id',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}),
|
||||
).resolves.toBe(false);
|
||||
});
|
||||
@@ -1,7 +1,37 @@
|
||||
import { hasPermission } from '@/lib/auth';
|
||||
import { getBoardEntityIds } from '@/lib/boards';
|
||||
import { PERMISSIONS } from '@/lib/constants';
|
||||
import type { Auth } from '@/lib/types';
|
||||
import type { Auth, BoardParameters } from '@/lib/types';
|
||||
import { getBoard, getTeamUser } from '@/queries/prisma';
|
||||
import { canViewLink } from './link';
|
||||
import { canViewPixel } from './pixel';
|
||||
import { canViewWebsite } from './website';
|
||||
|
||||
async function checkBoardEntityAccess(check: Promise<boolean>) {
|
||||
try {
|
||||
return await check;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function canViewBoardEntities(
|
||||
auth: Auth,
|
||||
type: string | undefined,
|
||||
parameters: BoardParameters = {},
|
||||
) {
|
||||
const { websiteIds, pixelIds, linkIds } = getBoardEntityIds({ type, parameters });
|
||||
const userOnlyAuth: Auth = { user: auth.user };
|
||||
const checks = [
|
||||
...websiteIds.map(id => checkBoardEntityAccess(canViewWebsite(userOnlyAuth, id))),
|
||||
...pixelIds.map(id => checkBoardEntityAccess(canViewPixel(userOnlyAuth, id))),
|
||||
...linkIds.map(id => checkBoardEntityAccess(canViewLink(userOnlyAuth, id))),
|
||||
];
|
||||
|
||||
const results = await Promise.all(checks);
|
||||
|
||||
return results.every(Boolean);
|
||||
}
|
||||
|
||||
export async function canViewBoard({ user, shareToken }: Auth, boardId: string) {
|
||||
if (user?.isAdmin) {
|
||||
|
||||
@@ -22,6 +22,10 @@ export async function canViewLink({ user, shareToken }: Auth, linkId: string) {
|
||||
|
||||
const link = await getLink(linkId);
|
||||
|
||||
if (!link) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (link.userId) {
|
||||
return user.id === link.userId;
|
||||
}
|
||||
@@ -46,6 +50,10 @@ export async function canUpdateLink({ user }: Auth, linkId: string) {
|
||||
|
||||
const link = await getLink(linkId);
|
||||
|
||||
if (!link) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (link.userId) {
|
||||
return user.id === link.userId;
|
||||
}
|
||||
@@ -70,6 +78,10 @@ export async function canDeleteLink({ user }: Auth, linkId: string) {
|
||||
|
||||
const link = await getLink(linkId);
|
||||
|
||||
if (!link) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (link.userId) {
|
||||
return user.id === link.userId;
|
||||
}
|
||||
|
||||
@@ -22,6 +22,10 @@ export async function canViewPixel({ user, shareToken }: Auth, pixelId: string)
|
||||
|
||||
const pixel = await getPixel(pixelId);
|
||||
|
||||
if (!pixel) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (pixel.userId) {
|
||||
return user.id === pixel.userId;
|
||||
}
|
||||
@@ -46,6 +50,10 @@ export async function canUpdatePixel({ user }: Auth, pixelId: string) {
|
||||
|
||||
const pixel = await getPixel(pixelId);
|
||||
|
||||
if (!pixel) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (pixel.userId) {
|
||||
return user.id === pixel.userId;
|
||||
}
|
||||
@@ -70,6 +78,10 @@ export async function canDeletePixel({ user }: Auth, pixelId: string) {
|
||||
|
||||
const pixel = await getPixel(pixelId);
|
||||
|
||||
if (!pixel) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (pixel.userId) {
|
||||
return user.id === pixel.userId;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user