Merge pull request #4317 from umami-software/opencode/nimble-planet

Fix board share entity authorization
This commit is contained in:
Mike Cao
2026-06-03 23:43:48 -04:00
committed by GitHub
7 changed files with 252 additions and 10 deletions
+18 -2
View File
@@ -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 });
+6 -2
View File
@@ -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),
+89 -5
View File
@@ -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();
+84
View File
@@ -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);
});
+31 -1
View File
@@ -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) {
+12
View File
@@ -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;
}
+12
View File
@@ -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;
}