Merge remote-tracking branch 'origin/staging' into migrate-metric-data-endpoints-to-ts

This commit is contained in:
dal 2025-08-21 21:31:19 -06:00
commit 1eb3b8369a
No known key found for this signature in database
GPG Key ID: 16F4B0E1E9F61122
5 changed files with 148 additions and 43 deletions

View File

@ -9,6 +9,7 @@ const __dirname = dirname(__filename);
const apiUrl = new URL(env.NEXT_PUBLIC_API_URL).origin;
const api2Url = new URL(env.NEXT_PUBLIC_API2_URL).origin;
const profilePictureURL = 'https://googleusercontent.com';
const publicUrlOrigin = new URL(env.NEXT_PUBLIC_URL).origin;
// Derive Supabase origins (HTTP and WS) from env so CSP allows them in all modes
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
@ -44,7 +45,7 @@ const createCspHeader = (isEmbed = false) => {
? `frame-ancestors 'self' *`
: "frame-ancestors 'none'",
// Frame sources - allow embeds from accepted domains
"frame-src 'self' https://vercel.live https://*.twitter.com https://twitter.com https://*.x.com https://x.com https://*.youtube.com https://youtube.com https://*.youtube-nocookie.com https://youtube-nocookie.com https://*.youtu.be https://youtu.be https://*.vimeo.com https://vimeo.com",
"frame-src 'self' https://vercel.live https://*.twitter.com https://twitter.com https://*.x.com https://x.com https://*.youtube.com https://youtube.com https://*.youtube-nocookie.com https://youtube-nocookie.com https://*.youtu.be https://youtu.be https://*.vimeo.com https://vimeo.com ${publicUrlOrigin}",
// Connect sources for API calls
(() => {
const connectSources = [

View File

@ -255,7 +255,8 @@ describe('Permission Validator', () => {
expect(result).toEqual({
isAuthorized: false,
unauthorizedTables: [],
error: 'Permission validation failed: Database connection failed',
error:
'Permission validation failed: Database connection failed. Please verify your SQL query syntax and ensure you have access to the requested resources.',
});
});
@ -270,7 +271,7 @@ describe('Permission Validator', () => {
const result = await validateSqlPermissions('INVALID SQL SYNTAX HERE', 'user123');
expect(result.isAuthorized).toBe(false);
expect(result.error).toContain('Failed to parse SQL');
expect(result.error).toContain('Failed to parse SQL query');
});
it('should reject INSERT statements', async () => {
@ -295,7 +296,8 @@ describe('Permission Validator', () => {
);
expect(result.isAuthorized).toBe(false);
expect(result.error).toContain("Query type 'insert' is not allowed");
expect(result.error).toContain("Query type 'INSERT' is not allowed");
expect(result.error).toContain('To read data, use SELECT statements instead of INSERT');
expect(result.unauthorizedTables).toHaveLength(0);
});
@ -321,7 +323,8 @@ describe('Permission Validator', () => {
);
expect(result.isAuthorized).toBe(false);
expect(result.error).toContain("Query type 'update' is not allowed");
expect(result.error).toContain("Query type 'UPDATE' is not allowed");
expect(result.error).toContain('To read data, use SELECT statements instead of UPDATE');
});
it('should reject DELETE statements', async () => {
@ -346,21 +349,24 @@ describe('Permission Validator', () => {
);
expect(result.isAuthorized).toBe(false);
expect(result.error).toContain("Query type 'delete' is not allowed");
expect(result.error).toContain("Query type 'DELETE' is not allowed");
expect(result.error).toContain('To read data, use SELECT statements instead of DELETE');
});
it('should reject CREATE TABLE statements', async () => {
const result = await validateSqlPermissions('CREATE TABLE new_table (id INT)', 'user123');
expect(result.isAuthorized).toBe(false);
expect(result.error).toContain("Query type 'create' is not allowed");
expect(result.error).toContain("Query type 'CREATE' is not allowed");
expect(result.error).toContain('DDL operations like CREATE are not permitted');
});
it('should reject DROP TABLE statements', async () => {
const result = await validateSqlPermissions('DROP TABLE users', 'user123');
expect(result.isAuthorized).toBe(false);
expect(result.error).toContain("Query type 'drop' is not allowed");
expect(result.error).toContain("Query type 'DROP' is not allowed");
expect(result.error).toContain('DDL operations like DROP are not permitted');
});
it('should handle multiple datasets with overlapping tables', async () => {
@ -1219,13 +1225,13 @@ describe('Permission Validator', () => {
it('should handle single table', () => {
expect(createPermissionErrorMessage(['public.users'])).toBe(
'Insufficient permissions: You do not have access to table: public.users'
'Insufficient permissions: You do not have access to table: public.users. Please request access to this table or use a different table that you have permissions for.'
);
});
it('should handle multiple tables', () => {
expect(createPermissionErrorMessage(['public.users', 'sales.orders'])).toBe(
'Insufficient permissions: You do not have access to the following tables: public.users, sales.orders'
'Insufficient permissions: You do not have access to the following tables: public.users, sales.orders. Please request access to these tables or modify your query to use only authorized tables.'
);
});
@ -1247,7 +1253,9 @@ describe('Permission Validator', () => {
['private.secrets'],
[{ table: 'users', column: 'password' }]
);
expect(message).toContain('You do not have access to table: private.secrets');
expect(message).toContain(
'You do not have access to table: private.secrets. Please request access'
);
expect(message).toContain('Unauthorized column access');
expect(message).toContain('password');
});

View File

@ -39,7 +39,9 @@ export async function validateSqlPermissions(
return {
isAuthorized: false,
unauthorizedTables: [],
error: readOnlyCheck.error || 'Only read-only queries are allowed',
error:
readOnlyCheck.error ||
'Only SELECT statements are allowed for read-only access. Please modify your query to use SELECT instead of write operations like INSERT, UPDATE, DELETE, or DDL statements.',
};
}
@ -47,7 +49,9 @@ export async function validateSqlPermissions(
// Store the wildcard error but continue to validate columns to provide comprehensive feedback
let wildcardError: string | undefined;
if (!wildcardCheck.isValid) {
wildcardError = wildcardCheck.error || 'Wildcard usage on physical tables is not allowed';
wildcardError =
wildcardCheck.error ||
'SELECT * is not allowed on physical tables. Please explicitly list the column names you need (e.g., SELECT id, name, email FROM users). This helps prevent unintended data exposure and improves query performance.';
}
// Extract physical tables from SQL
@ -180,10 +184,29 @@ export async function validateSqlPermissions(
return result;
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
// Provide more specific guidance based on the error
if (errorMessage.includes('parse')) {
return {
isAuthorized: false,
unauthorizedTables: [],
error: `Failed to validate SQL permissions due to parsing error: ${errorMessage}. Please check your SQL syntax and ensure it's valid.`,
};
}
if (errorMessage.includes('permission')) {
return {
isAuthorized: false,
unauthorizedTables: [],
error: `Permission check failed: ${errorMessage}. Please ensure you have the necessary access rights for the requested tables and columns.`,
};
}
return {
isAuthorized: false,
unauthorizedTables: [],
error: `Permission validation failed: ${error instanceof Error ? error.message : 'Unknown error'}`,
error: `Permission validation failed: ${errorMessage}. Please verify your SQL query syntax and ensure you have access to the requested resources.`,
};
}
}
@ -197,13 +220,17 @@ export function createPermissionErrorMessage(
): string {
const messages: string[] = [];
// Handle unauthorized tables
// Handle unauthorized tables with actionable guidance
if (unauthorizedTables.length > 0) {
const tableList = unauthorizedTables.join(', ');
if (unauthorizedTables.length === 1) {
messages.push(`You do not have access to table: ${tableList}`);
messages.push(
`You do not have access to table: ${tableList}. Please request access to this table or use a different table that you have permissions for.`
);
} else {
messages.push(`You do not have access to the following tables: ${tableList}`);
messages.push(
`You do not have access to the following tables: ${tableList}. Please request access to these tables or modify your query to use only authorized tables.`
);
}
}
@ -225,14 +252,18 @@ export function createPermissionErrorMessage(
const columnMessages: string[] = [];
for (const [table, columns] of columnsByTable) {
const columnList = columns.join(', ');
columnMessages.push(`Table '${table}': columns [${columnList}] are not available`);
columnMessages.push(
`Table '${table}': columns [${columnList}] are not available in your permitted dataset`
);
}
if (columnMessages.length === 1) {
messages.push(`Unauthorized column access - ${columnMessages[0]}`);
messages.push(
`Unauthorized column access - ${columnMessages[0]}. Please use only the columns that are available in your permitted datasets, or request access to additional columns.`
);
} else {
messages.push(
`Unauthorized column access:\n${columnMessages.map((m) => ` - ${m}`).join('\n')}`
`Unauthorized column access:\n${columnMessages.map((m) => ` - ${m}`).join('\n')}\n\nPlease modify your query to use only the columns available in your permitted datasets, or request access to the additional columns you need.`
);
}
}

View File

@ -428,7 +428,7 @@ models:
const sql = 'SELECT * FROM users';
const result = validateWildcardUsage(sql);
expect(result.isValid).toBe(false);
expect(result.error).toContain('SELECT * is not allowed on physical tables');
expect(result.error).toContain('SELECT * is not allowed on physical table: users');
expect(result.blockedTables).toContain('users');
});
@ -436,7 +436,7 @@ models:
const sql = 'SELECT u.* FROM users u';
const result = validateWildcardUsage(sql);
expect(result.isValid).toBe(false);
expect(result.error).toContain('SELECT * is not allowed on physical tables');
expect(result.error).toContain('SELECT * is not allowed on physical table: users');
expect(result.blockedTables).toContain('users');
});
@ -472,7 +472,7 @@ models:
`;
const result = validateWildcardUsage(sql);
expect(result.isValid).toBe(false);
expect(result.error).toContain('SELECT * is not allowed on physical tables');
expect(result.error).toContain('SELECT * is not allowed on physical table: users');
expect(result.blockedTables).toContain('users');
});
@ -509,6 +509,7 @@ models:
const sql = 'SELECT u.*, o.* FROM users u JOIN orders o ON u.id = o.user_id';
const result = validateWildcardUsage(sql);
expect(result.isValid).toBe(false);
expect(result.error).toContain('SELECT * is not allowed on physical tables: users, orders');
expect(result.blockedTables).toEqual(expect.arrayContaining(['users', 'orders']));
});
@ -516,14 +517,14 @@ models:
const sql = 'SELECT * FROM public.users';
const result = validateWildcardUsage(sql);
expect(result.isValid).toBe(false);
expect(result.error).toContain('SELECT * is not allowed on physical tables');
expect(result.error).toContain('SELECT * is not allowed on physical table: users');
});
it('should handle invalid SQL gracefully', () => {
const sql = 'NOT VALID SQL';
const result = validateWildcardUsage(sql);
expect(result.isValid).toBe(false);
expect(result.error).toContain('Failed to validate wildcard usage');
expect(result.error).toContain('Failed to validate wildcard usage in SQL query');
});
});
@ -1195,42 +1196,48 @@ models:
);
expect(result.isReadOnly).toBe(false);
expect(result.queryType).toBe('insert');
expect(result.error).toContain("Query type 'insert' is not allowed");
expect(result.error).toContain("Query type 'INSERT' is not allowed");
expect(result.error).toContain('To read data, use SELECT statements instead of INSERT');
});
it('should reject UPDATE statements', () => {
const result = checkQueryIsReadOnly('UPDATE users SET name = "Jane" WHERE id = 1');
expect(result.isReadOnly).toBe(false);
expect(result.queryType).toBe('update');
expect(result.error).toContain("Query type 'update' is not allowed");
expect(result.error).toContain("Query type 'UPDATE' is not allowed");
expect(result.error).toContain('To read data, use SELECT statements instead of UPDATE');
});
it('should reject DELETE statements', () => {
const result = checkQueryIsReadOnly('DELETE FROM users WHERE id = 1');
expect(result.isReadOnly).toBe(false);
expect(result.queryType).toBe('delete');
expect(result.error).toContain("Query type 'delete' is not allowed");
expect(result.error).toContain("Query type 'DELETE' is not allowed");
expect(result.error).toContain('To read data, use SELECT statements instead of DELETE');
});
it('should reject CREATE statements', () => {
const result = checkQueryIsReadOnly('CREATE TABLE new_users (id INT, name VARCHAR(100))');
expect(result.isReadOnly).toBe(false);
expect(result.queryType).toBe('create');
expect(result.error).toContain("Query type 'create' is not allowed");
expect(result.error).toContain("Query type 'CREATE' is not allowed");
expect(result.error).toContain('DDL operations like CREATE are not permitted');
});
it('should reject DROP statements', () => {
const result = checkQueryIsReadOnly('DROP TABLE users');
expect(result.isReadOnly).toBe(false);
expect(result.queryType).toBe('drop');
expect(result.error).toContain("Query type 'drop' is not allowed");
expect(result.error).toContain("Query type 'DROP' is not allowed");
expect(result.error).toContain('DDL operations like DROP are not permitted');
});
it('should reject ALTER statements', () => {
const result = checkQueryIsReadOnly('ALTER TABLE users ADD COLUMN age INT');
expect(result.isReadOnly).toBe(false);
expect(result.queryType).toBe('alter');
expect(result.error).toContain("Query type 'alter' is not allowed");
expect(result.error).toContain("Query type 'ALTER' is not allowed");
expect(result.error).toContain('DDL operations like ALTER are not permitted');
});
it('should handle PostgreSQL dialect', () => {
@ -1241,7 +1248,7 @@ models:
it('should handle invalid SQL gracefully', () => {
const result = checkQueryIsReadOnly('NOT VALID SQL');
expect(result.isReadOnly).toBe(false);
expect(result.error).toContain('Failed to parse SQL');
expect(result.error).toContain('Failed to parse SQL query for validation');
});
});
});

View File

@ -129,8 +129,20 @@ export function extractPhysicalTables(sql: string, dataSourceSyntax?: string): P
return physicalTables;
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
// Provide more specific guidance based on common parsing errors
if (errorMessage.includes('Expected')) {
throw new Error(
`SQL syntax error: ${errorMessage}. Please check your SQL syntax and ensure it's valid for the ${dialect} dialect.`
);
}
if (errorMessage.includes('Unexpected token')) {
throw new Error(
`SQL parsing error: ${errorMessage}. This may be due to unsupported SQL features or incorrect syntax.`
);
}
throw new Error(
`Failed to parse SQL: ${error instanceof Error ? error.message : 'Unknown error'}`
`Failed to parse SQL query: ${errorMessage}. Please ensure your SQL is valid and uses standard ${dialect} syntax.`
);
}
}
@ -353,8 +365,11 @@ export function extractTablesFromYml(ymlContent: string): ParsedTable[] {
}
}
}
} catch (_error) {
// If YML parsing fails, return empty array
} catch (error) {
// Log the error for debugging but don't throw - return empty array
// This is expected behavior when YML content is invalid or not a dataset
const errorMessage = error instanceof Error ? error.message : String(error);
console.warn(`Failed to parse YML content for table extraction: ${errorMessage}`);
}
return tables;
@ -473,8 +488,11 @@ export function extractDatasetsFromYml(ymlContent: string): ParsedDataset[] {
}
}
}
} catch (_error) {
// If YML parsing fails, return empty array
} catch (error) {
// Log the error for debugging but don't throw - return empty array
// This is expected behavior when YML content is invalid or not a dataset
const errorMessage = error instanceof Error ? error.message : String(error);
console.warn(`Failed to parse YML content for dataset extraction: ${errorMessage}`);
}
return datasets;
@ -546,18 +564,25 @@ export function validateWildcardUsage(
}
if (blockedTables.length > 0) {
// Create a more helpful error message with specific tables and guidance
const tableList =
blockedTables.length > 1
? `tables: ${blockedTables.join(', ')}`
: `table: ${blockedTables[0]}`;
return {
isValid: false,
error: `SELECT * is not allowed on physical tables. Please specify the columns you need.`,
error: `SELECT * is not allowed on physical ${tableList}. Please explicitly specify the column names you need instead of using wildcards. For example, use 'SELECT column1, column2 FROM table' instead of 'SELECT * FROM table'. This restriction helps ensure data security and prevents unintended data exposure.`,
blockedTables,
};
}
return { isValid: true };
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
return {
isValid: false,
error: `Failed to validate wildcard usage: ${error instanceof Error ? error.message : 'Unknown error'}`,
error: `Failed to validate wildcard usage in SQL query: ${errorMessage}. Please ensure your SQL syntax is correct and try specifying explicit column names instead of using SELECT *.`,
};
}
}
@ -836,8 +861,12 @@ export function extractColumnReferences(
}
return tableColumnMap;
} catch (_error) {
// Return empty map if parsing fails
} catch (error) {
// Log the error for debugging but return empty map to allow validation to continue
const errorMessage = error instanceof Error ? error.message : String(error);
console.warn(
`Failed to extract column references from SQL: ${errorMessage}. Column-level permissions cannot be validated.`
);
return new Map();
}
}
@ -1633,10 +1662,38 @@ export function checkQueryIsReadOnly(sql: string, dataSourceSyntax?: string): Qu
// Only allow SELECT statements
if (queryType !== 'select') {
// Provide specific guidance based on the query type
let guidance = '';
switch (queryType) {
case 'insert':
guidance = ' To read data, use SELECT statements instead of INSERT.';
break;
case 'update':
guidance = ' To read data, use SELECT statements instead of UPDATE.';
break;
case 'delete':
guidance = ' To read data, use SELECT statements instead of DELETE.';
break;
case 'create':
guidance =
' DDL operations like CREATE are not permitted. Use SELECT to query existing data.';
break;
case 'drop':
guidance =
' DDL operations like DROP are not permitted. Use SELECT to query existing data.';
break;
case 'alter':
guidance =
' DDL operations like ALTER are not permitted. Use SELECT to query existing data.';
break;
default:
guidance = ' Please use SELECT statements to query data.';
}
return {
isReadOnly: false,
queryType: statement.type,
error: `Query type '${statement.type}' is not allowed. Only SELECT statements are permitted for read-only access.`,
error: `Query type '${statement.type.toUpperCase()}' is not allowed. Only SELECT statements are permitted for read-only access.${guidance}`,
};
}
}
@ -1647,9 +1704,10 @@ export function checkQueryIsReadOnly(sql: string, dataSourceSyntax?: string): Qu
queryType: 'select',
};
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
return {
isReadOnly: false,
error: `Failed to parse SQL for query type check: ${error instanceof Error ? error.message : 'Unknown error'}`,
error: `Failed to parse SQL query for validation: ${errorMessage}. Please ensure your SQL syntax is valid. Only SELECT statements are allowed for read-only access.`,
};
}
}