Refactor report retrieval to include permission checks and enhance filtering options

- Updated GET reports endpoint to use `getReportsWithPermissions` for improved access control.
- Added support for filtering reports based on `shared_with_me` and `only_my_reports` flags.
- Introduced `getReportsWithPermissions` function to handle complex permission logic in the database layer.
- Enhanced report metadata retrieval to ensure proper access validation before fetching report details.
This commit is contained in:
dal 2025-08-22 11:14:50 -06:00
parent 17ee5d2197
commit f3cf5f46c4
No known key found for this signature in database
GPG Key ID: 16F4B0E1E9F61122
6 changed files with 278 additions and 5 deletions

View File

@ -1,5 +1,5 @@
import type { User } from '@buster/database';
import { getReportsList } from '@buster/database';
import { getReportsWithPermissions } from '@buster/database';
import type { GetReportsListRequest, GetReportsListResponse } from '@buster/server-shared/reports';
import { GetReportsListRequestSchema } from '@buster/server-shared/reports';
import { zValidator } from '@hono/zod-validator';
@ -9,12 +9,14 @@ async function getReportsListHandler(
request: GetReportsListRequest,
user: User
): Promise<GetReportsListResponse> {
const { page, page_size } = request;
const { page, page_size, shared_with_me, only_my_reports } = request;
const reports = await getReportsList({
const reports = await getReportsWithPermissions({
userId: user.id,
page,
page_size,
sharedWithMe: shared_with_me,
onlyMyReports: only_my_reports,
});
const result: GetReportsListResponse = reports;

View File

@ -1,4 +1,5 @@
import { getReport } from '@buster/database';
import { hasAssetPermission } from '@buster/access-controls';
import { getReport, getReportMetadata } from '@buster/database';
import type { GetReportResponse } from '@buster/server-shared/reports';
import { Hono } from 'hono';
import { HTTPException } from 'hono/http-exception';
@ -8,6 +9,34 @@ export async function getReportHandler(
reportId: string,
user: { id: string }
): Promise<GetReportResponse> {
// Get report metadata for access control
let reportData: Awaited<ReturnType<typeof getReportMetadata>>;
try {
reportData = await getReportMetadata({ reportId });
} catch (error) {
console.error('Error getting report metadata:', error);
throw new HTTPException(404, { message: 'Report not found' });
}
if (!reportData) {
throw new HTTPException(404, { message: 'Report not found' });
}
// Check access using existing asset permission system
const hasAccess = await hasAssetPermission({
userId: user.id,
assetId: reportId,
assetType: 'report_file',
requiredRole: 'can_view',
organizationId: reportData.organizationId,
workspaceSharing: reportData.workspaceSharing,
});
if (!hasAccess) {
throw new HTTPException(403, { message: 'You do not have access to this report' });
}
// If access is granted, get the full report data
const report = await getReport({ reportId, userId: user.id });
const response: GetReportResponse = report;

View File

@ -0,0 +1,234 @@
import { type SQL, and, count, desc, eq, exists, isNull, ne, or } from 'drizzle-orm';
import { z } from 'zod';
import { db } from '../../connection';
import { assetPermissions, reportFiles, teamsToUsers, users } from '../../schema';
import { getUserOrganizationId } from '../organizations';
import { type PaginatedResponse, createPaginatedResponse } from '../shared-types';
import { withPagination } from '../shared-types/with-pagination';
export const GetReportsWithPermissionsInputSchema = z.object({
userId: z.string().uuid('User ID must be a valid UUID'),
page: z.number().optional().default(1),
page_size: z.number().optional().default(250),
sharedWithMe: z.boolean().optional(),
onlyMyReports: z.boolean().optional(),
});
export type GetReportsWithPermissionsInput = z.infer<typeof GetReportsWithPermissionsInputSchema>;
export type ReportWithPermissionItem = {
id: string;
name: string;
created_at: string;
updated_at: string;
publicly_accessible: boolean;
created_by_id: string;
created_by_name: string | null;
created_by_avatar: string | null;
workspace_sharing: string;
is_shared: boolean;
permission: string | null;
};
/**
* Get paginated list of reports that the user has access to.
* This includes:
* 1. Reports the user created
* 2. Reports the user has direct permissions for
* 3. Reports the user has team permissions for
* 4. Reports shared with the workspace (if workspace sharing is enabled)
*/
export async function getReportsWithPermissions(
input: GetReportsWithPermissionsInput
): Promise<PaginatedResponse<ReportWithPermissionItem>> {
const { userId, page, page_size, sharedWithMe, onlyMyReports } =
GetReportsWithPermissionsInputSchema.parse(input);
// Get the user's organization ID
const userOrg = await getUserOrganizationId(userId);
if (!userOrg?.organizationId) {
// Return empty result if user has no organization
return createPaginatedResponse({
data: [],
page,
page_size,
total: 0,
});
}
const { organizationId } = userOrg;
// Build the where conditions based on filters
let whereConditions: SQL<unknown> | undefined;
if (onlyMyReports) {
// Only show reports created by the user
whereConditions = and(
eq(reportFiles.createdBy, userId),
isNull(reportFiles.deletedAt),
eq(reportFiles.organizationId, organizationId)
);
} else if (sharedWithMe) {
// Only show reports shared with the user (not created by them)
whereConditions = and(
isNull(reportFiles.deletedAt),
eq(reportFiles.organizationId, organizationId),
// Not created by the user
ne(reportFiles.createdBy, userId),
// But user has access through permissions or workspace sharing
or(
// Direct user permission
exists(
db
.select({ one: assetPermissions.assetId })
.from(assetPermissions)
.where(
and(
eq(assetPermissions.assetId, reportFiles.id),
eq(assetPermissions.assetType, 'report_file'),
eq(assetPermissions.identityType, 'user'),
eq(assetPermissions.identityId, userId),
isNull(assetPermissions.deletedAt)
)
)
),
// Team permission
exists(
db
.select({ one: assetPermissions.assetId })
.from(assetPermissions)
.innerJoin(
teamsToUsers,
and(
eq(teamsToUsers.teamId, assetPermissions.identityId),
eq(teamsToUsers.userId, userId),
isNull(teamsToUsers.deletedAt)
)
)
.where(
and(
eq(assetPermissions.assetId, reportFiles.id),
eq(assetPermissions.assetType, 'report_file'),
eq(assetPermissions.identityType, 'team'),
isNull(assetPermissions.deletedAt)
)
)
),
// Workspace sharing (not 'none')
ne(reportFiles.workspaceSharing, 'none')
)
);
} else {
// Show all reports the user has access to
whereConditions = and(
isNull(reportFiles.deletedAt),
eq(reportFiles.organizationId, organizationId),
or(
// Created by the user
eq(reportFiles.createdBy, userId),
// Direct user permission
exists(
db
.select({ one: assetPermissions.assetId })
.from(assetPermissions)
.where(
and(
eq(assetPermissions.assetId, reportFiles.id),
eq(assetPermissions.assetType, 'report_file'),
eq(assetPermissions.identityType, 'user'),
eq(assetPermissions.identityId, userId),
isNull(assetPermissions.deletedAt)
)
)
),
// Team permission
exists(
db
.select({ one: assetPermissions.assetId })
.from(assetPermissions)
.innerJoin(
teamsToUsers,
and(
eq(teamsToUsers.teamId, assetPermissions.identityId),
eq(teamsToUsers.userId, userId),
isNull(teamsToUsers.deletedAt)
)
)
.where(
and(
eq(assetPermissions.assetId, reportFiles.id),
eq(assetPermissions.assetType, 'report_file'),
eq(assetPermissions.identityType, 'team'),
isNull(assetPermissions.deletedAt)
)
)
),
// Workspace sharing (not 'none')
ne(reportFiles.workspaceSharing, 'none')
)
);
}
// Create the query with left join to get user permissions
const getData = withPagination(
db
.select({
id: reportFiles.id,
name: reportFiles.name,
publicly_accessible: reportFiles.publiclyAccessible,
created_at: reportFiles.createdAt,
updated_at: reportFiles.updatedAt,
workspace_sharing: reportFiles.workspaceSharing,
created_by_id: reportFiles.createdBy,
created_by_name: users.name,
created_by_avatar: users.avatarUrl,
// Get the user's permission for this report
permission: assetPermissions.role,
})
.from(reportFiles)
.innerJoin(users, eq(reportFiles.createdBy, users.id))
.leftJoin(
assetPermissions,
and(
eq(assetPermissions.assetId, reportFiles.id),
eq(assetPermissions.assetType, 'report_file'),
eq(assetPermissions.identityType, 'user'),
eq(assetPermissions.identityId, userId),
isNull(assetPermissions.deletedAt)
)
)
.where(whereConditions)
.$dynamic(),
desc(reportFiles.updatedAt), // Most recently updated reports first
page,
page_size
);
// Create count query
const getTotal = db.select({ count: count() }).from(reportFiles).where(whereConditions);
try {
// Execute data and count queries in parallel
const [data, totalResult] = await Promise.all([getData, getTotal]);
const total = totalResult[0]?.count ?? 0;
// Transform the data to include is_shared flag
const transformedData = data.map((report) => ({
...report,
is_shared: report.created_by_id !== userId,
// If no explicit permission but user is creator, they're the owner
permission: report.permission || (report.created_by_id === userId ? 'owner' : null),
}));
return createPaginatedResponse({
data: transformedData,
page,
page_size,
total,
});
} catch (error) {
console.error('Error fetching reports with permissions:', error);
throw new Error('Failed to fetch reports with permissions');
}
}

View File

@ -3,6 +3,7 @@ export * from './get-reports-list';
export * from './get-report';
export * from './get-report-metadata';
export * from './get-report-content';
export * from './get-reports-with-permissions';
export * from './update-report';
export * from './replace-report-content';
export * from './append-report-content';

View File

@ -9,8 +9,12 @@ export const ReportListItemSchema = z.object({
created_by_id: z.string(),
created_by_name: z.string().nullable(),
created_by_avatar: z.string().nullable(),
created_at: z.string(),
updated_at: z.string(),
publicly_accessible: z.boolean(),
workspace_sharing: z.string(),
is_shared: z.boolean(),
permission: z.string().nullable(),
});
export const ReportIndividualResponseSchema = z.object({

View File

@ -1,7 +1,10 @@
import { z } from 'zod';
import { PaginatedRequestSchema } from '../type-utilities/pagination';
export const GetReportsListRequestSchema = PaginatedRequestSchema;
export const GetReportsListRequestSchema = PaginatedRequestSchema.extend({
shared_with_me: z.coerce.boolean().optional(),
only_my_reports: z.coerce.boolean().optional(),
});
// Define UpdateReportRequestSchema with explicit type annotation
export const UpdateReportRequestSchema = z