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 { z } from 'zod';
|
||||||
import { BOARD_TYPES, normalizeBoardType } from '@/lib/boards';
|
import { BOARD_TYPES, normalizeBoardType } from '@/lib/boards';
|
||||||
|
import type { BoardParameters } from '@/lib/types';
|
||||||
import { parseRequest } from '@/lib/request';
|
import { parseRequest } from '@/lib/request';
|
||||||
import { json, ok, serverError, unauthorized } from '@/lib/response';
|
import { badRequest, json, ok, serverError, unauthorized } from '@/lib/response';
|
||||||
import { canDeleteBoard, canUpdateBoard, canViewBoard } from '@/permissions';
|
import { canDeleteBoard, canUpdateBoard, canViewBoard, canViewBoardEntities } from '@/permissions';
|
||||||
import { deleteBoard, getBoard, updateBoard } from '@/queries/prisma';
|
import { deleteBoard, getBoard, updateBoard } from '@/queries/prisma';
|
||||||
|
|
||||||
export async function GET(request: Request, { params }: { params: Promise<{ boardId: string }> }) {
|
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();
|
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 {
|
try {
|
||||||
const board = await updateBoard(boardId, { type, name, description, parameters });
|
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 { BOARD_TYPES, normalizeBoardType } from '@/lib/boards';
|
||||||
import { uuid } from '@/lib/crypto';
|
import { uuid } from '@/lib/crypto';
|
||||||
import { getQueryFilters, parseRequest } from '@/lib/request';
|
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 { pagingParams, searchParams, sortingParams } from '@/lib/schema';
|
||||||
import { canCreateTeamWebsite, canCreateWebsite } from '@/permissions';
|
import { canCreateTeamWebsite, canCreateWebsite, canViewBoardEntities } from '@/permissions';
|
||||||
import { createBoard, getUserBoards } from '@/queries/prisma';
|
import { createBoard, getUserBoards } from '@/queries/prisma';
|
||||||
|
|
||||||
export async function GET(request: Request) {
|
export async function GET(request: Request) {
|
||||||
@@ -58,6 +58,10 @@ export async function POST(request: Request) {
|
|||||||
return unauthorized();
|
return unauthorized();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!(await canViewBoardEntities(auth, body.type, body.parameters))) {
|
||||||
|
return badRequest({ message: 'Board contains inaccessible entities.' });
|
||||||
|
}
|
||||||
|
|
||||||
const data = {
|
const data = {
|
||||||
...body,
|
...body,
|
||||||
type: normalizeBoardType(body.type),
|
type: normalizeBoardType(body.type),
|
||||||
|
|||||||
@@ -5,8 +5,12 @@ import { createToken } from '@/lib/jwt';
|
|||||||
import prisma from '@/lib/prisma';
|
import prisma from '@/lib/prisma';
|
||||||
import redis from '@/lib/redis';
|
import redis from '@/lib/redis';
|
||||||
import { json, notFound } from '@/lib/response';
|
import { json, notFound } from '@/lib/response';
|
||||||
import type { BoardParameters, WhiteLabel } from '@/lib/types';
|
import type { Auth, BoardParameters, WhiteLabel } from '@/lib/types';
|
||||||
import { getBoard, getLink, getPixel, getShareByCode, getWebsite } from '@/queries/prisma';
|
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> {
|
async function getAccountId(entity: { userId?: string; teamId?: string }): Promise<string | null> {
|
||||||
if (entity.userId) {
|
if (entity.userId) {
|
||||||
@@ -44,6 +48,85 @@ async function getWhiteLabel(accountId: string): Promise<WhiteLabel | null> {
|
|||||||
return 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 }> }) {
|
export async function GET(_request: Request, { params }: { params: Promise<{ slug: string }> }) {
|
||||||
const { slug } = await params;
|
const { slug } = await params;
|
||||||
|
|
||||||
@@ -70,9 +153,10 @@ export async function GET(_request: Request, { params }: { params: Promise<{ slu
|
|||||||
type: board.type,
|
type: board.type,
|
||||||
parameters: board.parameters as BoardParameters,
|
parameters: board.parameters as BoardParameters,
|
||||||
});
|
});
|
||||||
data.websiteIds = boardEntityIds.websiteIds;
|
const authorizedEntityIds = await filterBoardEntityIdsForShare(board, boardEntityIds);
|
||||||
data.pixelIds = boardEntityIds.pixelIds;
|
data.websiteIds = authorizedEntityIds.websiteIds;
|
||||||
data.linkIds = boardEntityIds.linkIds;
|
data.pixelIds = authorizedEntityIds.pixelIds;
|
||||||
|
data.linkIds = authorizedEntityIds.linkIds;
|
||||||
} else if (share.shareType === ENTITY_TYPE.website) {
|
} else if (share.shareType === ENTITY_TYPE.website) {
|
||||||
entity = await getWebsite(share.entityId);
|
entity = await getWebsite(share.entityId);
|
||||||
if (!entity) return notFound();
|
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 { hasPermission } from '@/lib/auth';
|
||||||
|
import { getBoardEntityIds } from '@/lib/boards';
|
||||||
import { PERMISSIONS } from '@/lib/constants';
|
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 { 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) {
|
export async function canViewBoard({ user, shareToken }: Auth, boardId: string) {
|
||||||
if (user?.isAdmin) {
|
if (user?.isAdmin) {
|
||||||
|
|||||||
@@ -22,6 +22,10 @@ export async function canViewLink({ user, shareToken }: Auth, linkId: string) {
|
|||||||
|
|
||||||
const link = await getLink(linkId);
|
const link = await getLink(linkId);
|
||||||
|
|
||||||
|
if (!link) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
if (link.userId) {
|
if (link.userId) {
|
||||||
return user.id === link.userId;
|
return user.id === link.userId;
|
||||||
}
|
}
|
||||||
@@ -46,6 +50,10 @@ export async function canUpdateLink({ user }: Auth, linkId: string) {
|
|||||||
|
|
||||||
const link = await getLink(linkId);
|
const link = await getLink(linkId);
|
||||||
|
|
||||||
|
if (!link) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
if (link.userId) {
|
if (link.userId) {
|
||||||
return user.id === link.userId;
|
return user.id === link.userId;
|
||||||
}
|
}
|
||||||
@@ -70,6 +78,10 @@ export async function canDeleteLink({ user }: Auth, linkId: string) {
|
|||||||
|
|
||||||
const link = await getLink(linkId);
|
const link = await getLink(linkId);
|
||||||
|
|
||||||
|
if (!link) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
if (link.userId) {
|
if (link.userId) {
|
||||||
return user.id === 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);
|
const pixel = await getPixel(pixelId);
|
||||||
|
|
||||||
|
if (!pixel) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
if (pixel.userId) {
|
if (pixel.userId) {
|
||||||
return user.id === pixel.userId;
|
return user.id === pixel.userId;
|
||||||
}
|
}
|
||||||
@@ -46,6 +50,10 @@ export async function canUpdatePixel({ user }: Auth, pixelId: string) {
|
|||||||
|
|
||||||
const pixel = await getPixel(pixelId);
|
const pixel = await getPixel(pixelId);
|
||||||
|
|
||||||
|
if (!pixel) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
if (pixel.userId) {
|
if (pixel.userId) {
|
||||||
return user.id === pixel.userId;
|
return user.id === pixel.userId;
|
||||||
}
|
}
|
||||||
@@ -70,6 +78,10 @@ export async function canDeletePixel({ user }: Auth, pixelId: string) {
|
|||||||
|
|
||||||
const pixel = await getPixel(pixelId);
|
const pixel = await getPixel(pixelId);
|
||||||
|
|
||||||
|
if (!pixel) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
if (pixel.userId) {
|
if (pixel.userId) {
|
||||||
return user.id === pixel.userId;
|
return user.id === pixel.userId;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user