diff --git a/apps/web/src/actions/analytics/events.ts b/apps/web/src/actions/analytics/events.ts index f15a426c20..692916f99e 100644 --- a/apps/web/src/actions/analytics/events.ts +++ b/apps/web/src/actions/analytics/events.ts @@ -64,6 +64,59 @@ export const captureEvent = async ({ event, ...rest }: AnalyticsEvent) => { await analytics.insert({ table, values: [value], format: 'JSONEachRow' }); }; +/** + * Helper function to build access control filters for analytics queries + * Includes both organization-level and user-level filtering + */ +type AccessControlResult = { + accessFilter: string; + accessParams: Record; +}; + +const buildAccessControlFilter = ( + authUserId: string | null, + isAdmin: boolean, + orgId: string | null | undefined, + authOrgId: string | null, + tablePrefix: string = '', +): AccessControlResult => { + // For personal accounts, query by userId instead of orgId + if (!orgId && !authUserId) { + throw new Error('Personal accounts require authenticated user ID'); + } + + const accessParams: Record = {}; + const prefix = tablePrefix ? `${tablePrefix}.` : ''; + + // Build organization scope filter first + const orgCondition = !orgId + ? `${prefix}orgId IS NULL` + : `${prefix}orgId = {orgId: String}`; + + if (orgId) { + accessParams.orgId = orgId; + } + + // Build access control: (userId = your_id) OR (you're admin AND orgId = your_org) + let accessCondition = ''; + if (authUserId) { + accessParams.authUserId = authUserId; + + if (orgId && isAdmin && authOrgId) { + // Admin can see: their own tasks OR all tasks in their org + accessParams.authOrgId = authOrgId; + accessCondition = ` AND (${prefix}userId = {authUserId: String} OR ${prefix}orgId = {authOrgId: String})`; + } else { + // Non-admin or personal account: only see your own tasks + accessCondition = ` AND ${prefix}userId = {authUserId: String}`; + } + } + + const accessFilter = `${orgCondition}${accessCondition}`; + + return { accessFilter, accessParams }; +}; + /** * getUsage */ @@ -89,33 +142,28 @@ export const getUsage = async ({ timePeriod?: AnyTimePeriod; userId?: string | null; }): Promise => { - const { effectiveUserId } = await authorizeAnalytics({ + const { authUserId, authOrgId, isAdmin } = await authorizeAnalytics({ requestedOrgId: orgId, requestedUserId: userId, }); // For personal accounts, query by userId instead of orgId - if (!orgId && !effectiveUserId) { + if (!orgId && !authUserId) { return {}; // Personal accounts must have a userId } - const userFilter = effectiveUserId ? 'AND userId = {userId: String}' : ''; + const { accessFilter, accessParams } = buildAccessControlFilter( + authUserId, + isAdmin, + orgId, + authOrgId, + ); - // Build query conditions based on account type - const orgCondition = !orgId ? 'orgId IS NULL' : 'orgId = {orgId: String}'; - - const queryParams: Record = { + const queryParams = { timePeriod, + ...accessParams, }; - if (orgId) { - queryParams.orgId = orgId; - } - - if (effectiveUserId) { - queryParams.userId = effectiveUserId; - } - const results = await analytics.query({ query: ` SELECT @@ -126,9 +174,8 @@ export const getUsage = async ({ SUM(COALESCE(cost, 0)) AS cost FROM events WHERE - ${orgCondition} + ${accessFilter} AND timestamp >= toUnixTimestamp(now() - INTERVAL {timePeriod: Int32} DAY) - ${userFilter} GROUP BY 1 `, format: 'JSONEachRow', @@ -425,7 +472,6 @@ export const getTasks = async ({ orgId, userId, taskId, - allowCrossUserAccess = false, skipAuth = false, limit = 20, cursor, @@ -434,41 +480,56 @@ export const getTasks = async ({ orgId?: string | null; userId?: string | null; taskId?: string | null; - allowCrossUserAccess?: boolean; skipAuth?: boolean; limit?: number; cursor?: number; filters?: Filter[]; }): Promise => { - let effectiveUserId = userId; + let authUserId: string | null = null; + let authOrgId: string | null = null; + let isAdmin = false; if (!skipAuth) { const authResult = await authorizeAnalytics({ requestedOrgId: orgId, requestedUserId: userId, - allowCrossUserAccess, }); - effectiveUserId = authResult.effectiveUserId; + authUserId = authResult.authUserId; + authOrgId = authResult.authOrgId; + isAdmin = authResult.isAdmin; } // For personal accounts, query by userId instead of orgId // Exception: when skipAuth is true (for public shares), we can query without userId - if (!orgId && !effectiveUserId && !skipAuth) { + if (!orgId && !authUserId && !skipAuth) { return { tasks: [], hasMore: false }; // Personal accounts must have a userId unless we're skipping auth } - const userFilter = effectiveUserId ? 'AND e.userId = {userId: String}' : ''; const taskFilter = taskId ? 'AND e.taskId = {taskId: String}' : ''; - - const messageUserFilter = effectiveUserId - ? 'AND userId = {userId: String}' - : ''; - const messageTaskFilter = taskId ? 'AND taskId = {taskId: String}' : ''; + // Build access control conditions based on the two rules: + // 1. You always have access to your own tasks + // 2. If you're an org:admin, you have access to all tasks in your org + let accessFilter = ''; + let messageAccessFilter = ''; + + if (!skipAuth && authUserId) { + if (orgId && isAdmin) { + // Admin can see all tasks in the org OR their own tasks + accessFilter = + 'AND (e.userId = {authUserId: String} OR (e.orgId = {authOrgId: String}))'; + messageAccessFilter = + 'AND (userId = {authUserId: String} OR (orgId = {authOrgId: String}))'; + } else { + // Non-admin or personal account: only see your own tasks + accessFilter = 'AND e.userId = {authUserId: String}'; + messageAccessFilter = 'AND userId = {authUserId: String}'; + } + } + // Build query conditions based on account type const orgCondition = !orgId ? 'e.orgId IS NULL' : 'e.orgId = {orgId: String}'; - const messageOrgCondition = !orgId ? 'orgId IS NULL' : 'orgId = {orgId: String}'; @@ -486,8 +547,12 @@ export const getTasks = async ({ queryParams.orgId = orgId; } - if (effectiveUserId) { - queryParams.userId = effectiveUserId; + if (authOrgId) { + queryParams.authOrgId = authOrgId; + } + + if (authUserId) { + queryParams.authUserId = authUserId; } if (taskId) { @@ -518,7 +583,7 @@ export const getTasks = async ({ argMin(mode, ts) as mode FROM messages WHERE ${messageOrgCondition} - ${messageUserFilter} + ${messageAccessFilter} ${messageTaskFilter} GROUP BY taskId ) @@ -542,7 +607,7 @@ export const getTasks = async ({ ${orgCondition} AND e.type IN ({types: Array(String)}) AND e.modelId IS NOT NULL - ${userFilter} + ${accessFilter} ${taskFilter} ${filterClause} GROUP BY 1, 2 @@ -606,38 +671,33 @@ export const getHourlyUsageByUser = async ({ userId?: string | null; filters?: Filter[]; }): Promise => { - const { effectiveUserId } = await authorizeAnalytics({ + const { authUserId, authOrgId, isAdmin } = await authorizeAnalytics({ requestedOrgId: orgId, requestedUserId: userId, }); // For personal accounts, query by userId instead of orgId - if (!orgId && !effectiveUserId) { + if (!orgId && !authUserId) { return []; // Personal accounts must have a userId } - const userFilter = effectiveUserId ? 'AND userId = {userId: String}' : ''; + const { accessFilter, accessParams } = buildAccessControlFilter( + authUserId, + isAdmin, + orgId, + authOrgId, + ); - // Build query conditions based on account type - const orgCondition = !orgId ? 'orgId IS NULL' : 'orgId = {orgId: String}'; - - const queryParams: Record = { + const queryParams = { timePeriod, types: [ TelemetryEventName.TASK_CREATED, TelemetryEventName.TASK_COMPLETED, TelemetryEventName.LLM_COMPLETION, ], + ...accessParams, }; - if (orgId) { - queryParams.orgId = orgId; - } - - if (effectiveUserId) { - queryParams.userId = effectiveUserId; - } - // Build filter conditions using shared helper const filterClause = buildFilterConditions(filters, queryParams); @@ -651,10 +711,9 @@ export const getHourlyUsageByUser = async ({ SUM(CASE WHEN type = '${TelemetryEventName.LLM_COMPLETION}' THEN COALESCE(cost, 0) ELSE 0 END) AS cost FROM events WHERE - ${orgCondition} + ${accessFilter} AND timestamp >= toUnixTimestamp(now() - INTERVAL {timePeriod: Int32} DAY) AND type IN ({types: Array(String)}) - ${userFilter} ${filterClause} GROUP BY 1, 2 ORDER BY hour_utc DESC, userId @@ -691,7 +750,6 @@ export const getTaskById = async ({ orgId, userId, limit: 1, - allowCrossUserAccess: true, // Allow access to tasks from other users in the same org }); return result.tasks[0] || null; diff --git a/apps/web/src/actions/analytics/messages.ts b/apps/web/src/actions/analytics/messages.ts index 1e1d3023a8..b2513e20fe 100644 --- a/apps/web/src/actions/analytics/messages.ts +++ b/apps/web/src/actions/analytics/messages.ts @@ -39,7 +39,6 @@ export const getMessages = async ( await authorizeAnalytics({ requestedOrgId: orgId, requestedUserId: userId, - allowCrossUserAccess: true, // Allow viewing messages within authorized tasks }); } diff --git a/apps/web/src/actions/auth.ts b/apps/web/src/actions/auth.ts index 159654bd88..0b8061baac 100644 --- a/apps/web/src/actions/auth.ts +++ b/apps/web/src/actions/auth.ts @@ -72,12 +72,10 @@ export async function authorizeAnalytics({ requestedOrgId, requestedUserId, requireAdmin = false, - allowCrossUserAccess = false, }: { requestedOrgId?: string | null; requestedUserId?: string | null; requireAdmin?: boolean; - allowCrossUserAccess?: boolean; }) { const { orgId: authOrgId, orgRole, userId: authUserId } = await auth(); @@ -85,6 +83,7 @@ export async function authorizeAnalytics({ throw new Error('Unauthorized: User required'); } + // Personal account access if (!authOrgId && !requestedOrgId) { if (requestedUserId && requestedUserId !== authUserId) { throw new Error( @@ -96,10 +95,11 @@ export async function authorizeAnalytics({ authOrgId: null, authUserId, orgRole: null, - effectiveUserId: authUserId, + isAdmin: false, }; } + // Organization account access if (!authOrgId || !authUserId || authOrgId !== requestedOrgId) { throw new Error('Unauthorized: Invalid organization access'); } @@ -108,24 +108,13 @@ export async function authorizeAnalytics({ throw new Error('Unauthorized: Administrator access required'); } - if ( - orgRole !== 'org:admin' && - requestedUserId && - requestedUserId !== authUserId - ) { - throw new Error('Unauthorized: Members can only access their own data'); - } - - const effectiveUserId = - orgRole !== 'org:admin' && !allowCrossUserAccess - ? authUserId - : requestedUserId || null; + const isAdmin = orgRole === 'org:admin'; return { authOrgId, authUserId, orgRole: isOrgRole(orgRole) ? orgRole : 'org:member', - effectiveUserId, + isAdmin, }; } diff --git a/apps/web/src/actions/taskSharing.ts b/apps/web/src/actions/taskSharing.ts index 9d108b9da1..c16aad2538 100644 --- a/apps/web/src/actions/taskSharing.ts +++ b/apps/web/src/actions/taskSharing.ts @@ -80,7 +80,6 @@ export async function canShareTask(taskId: string): Promise<{ const result = await getTasks({ taskId, orgId, - allowCrossUserAccess: true, }); const task = result.tasks[0]; @@ -298,7 +297,6 @@ export async function getTaskByShareToken(token: string): Promise<{ const result = await getTasks({ taskId: share.taskId, orgId: share.orgId, // Will be null for personal shares - allowCrossUserAccess: true, skipAuth: true, // Skip auth for both public and organization shares since we've already validated access above }); const task = result.tasks[0]; @@ -531,7 +529,6 @@ export async function getSharedTaskMessages( const result = await getTasks({ taskId: share.taskId, orgId: share.orgId, - allowCrossUserAccess: true, skipAuth: true, // We've already validated access above }); const task = result.tasks[0];