Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
477 changes: 477 additions & 0 deletions packages/foundation/plugin-security/__tests__/query-trimmer.test.ts

Large diffs are not rendered by default.

11 changes: 11 additions & 0 deletions packages/foundation/plugin-security/jest.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
roots: ['<rootDir>/__tests__'],
testMatch: ['**/*.test.ts'],
moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'],
collectCoverageFrom: [
'src/**/*.ts',
'!src/**/*.d.ts',
],
Copy link

Copilot AI Jan 30, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The jest.config.js file is missing the moduleNameMapper and transform configuration that is used in other packages like core, driver-knex, and driver-mongo. This configuration is needed to enable tests to run without a build step by mapping workspace packages to their TypeScript source files. Without this, the tests will fail when run before building.

Add the moduleNameMapper to map '@objectql/*' to source directories and configure ts-jest with isolatedModules as shown in packages/foundation/core/jest.config.js.

Suggested change
],
],
moduleNameMapper: {
'^@objectql/(.*)$': '<rootDir>/../$1/src',
},
transform: {
'^.+\\.tsx?$': [
'ts-jest',
{
isolatedModules: true,
tsconfig: '<rootDir>/tsconfig.json',
},
],
},

Copilot uses AI. Check for mistakes.
};
5 changes: 4 additions & 1 deletion packages/foundation/plugin-security/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,9 @@
"@objectstack/core": "^0.6.1"
},
"devDependencies": {
"typescript": "^5.3.0"
"typescript": "^5.3.0",
"@types/jest": "^30.0.0",
"jest": "^30.2.0",
"ts-jest": "^29.4.6"
}
}
248 changes: 229 additions & 19 deletions packages/foundation/plugin-security/src/query-trimmer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -153,35 +153,245 @@ export class QueryTrimmer {
}

if (condition.type === 'formula') {
// Formula conditions can't be directly converted to filters
// They need to be evaluated in memory after fetching
// TODO: Consider implementing a formula-to-SQL compiler for common patterns
console.warn(
'Formula conditions cannot be converted to query filters and will be evaluated in-memory. ' +
'This may result in fetching more data than necessary. ' +
'Consider using simple or complex conditions for better performance, ' +
'or ensure formula conditions are combined with other filterable conditions.'
);
// Formula-based conditions: Evaluate the formula and convert to filters
try {
const filter = this.formulaToFilter(condition.formula, user);
if (filter && Object.keys(filter).length > 0) {
return filter;
}
} catch (error: any) {
console.warn(
`Failed to convert formula condition to filter: ${error.message}. ` +
'Formula will be evaluated in-memory, which may affect performance.'
);
}

// If we can't convert to a filter, return empty object
// The record will need to be fetched and evaluated in-memory
return {};
Comment on lines +156 to 171
Copy link

Copilot AI Jan 30, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The error handling swallows exceptions from formula and lookup conversion silently with only a console.warn, then returns an empty filter object. An empty filter in RLS context could be dangerous - it might cause the system to fetch all records and evaluate the condition in-memory, which could leak sensitive data if the in-memory evaluation fails or isn't implemented.

For security-critical code like RLS, failures should be more explicit. Consider either throwing an error to prevent the query from executing, or returning a restrictive filter (like { _id: null }) to ensure no records are returned when the condition cannot be safely converted.

Copilot uses AI. Check for mistakes.
}

if (condition.type === 'lookup') {
// Lookup conditions require join logic
// TODO: Implement support for lookup conditions in a future release
console.warn(
'Lookup conditions are not yet supported in query trimming. ' +
'This feature is planned for a future release. ' +
'As a workaround, consider: ' +
'1. Using record-level rules instead of row-level security for lookup-based filtering ' +
'2. Denormalizing the lookup field into the main table ' +
'3. Implementing custom query logic in beforeQuery hooks'
);
// Lookup conditions: Convert to subquery filter
try {
const filter = this.lookupToFilter(condition, user);
if (filter && Object.keys(filter).length > 0) {
return filter;
}
} catch (error: any) {
console.warn(
`Failed to convert lookup condition to filter: ${error.message}. ` +
'Lookup will be evaluated in-memory, which may affect performance.'
);
}

// If we can't convert to a filter, return empty object
return {};
Comment on lines +188 to 189
Copy link

Copilot AI Jan 30, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same issue as with formula conditions - returning an empty filter object when lookup conversion fails could allow records to be fetched that should be restricted. The console.warn indicates the lookup "will be evaluated in-memory" but there's no guarantee that in-memory evaluation is implemented or will work correctly.

For RLS security, it's safer to fail closed. Consider throwing an error or returning a restrictive filter ({ _id: null }) when lookup conditions cannot be safely converted to database filters.

Suggested change
// If we can't convert to a filter, return empty object
return {};
// If we can't convert to a filter safely, fail closed with a restrictive filter
return { _id: null };

Copilot uses AI. Check for mistakes.
}

return {};
}

/**
* Convert a formula condition to a query filter
*
* This function attempts to parse common formula patterns and convert them
* to database filters. Complex formulas that can't be converted will need
* to be evaluated in-memory.
*
* Supported patterns:
* - Field comparisons: "field == value", "field > value"
* - User context: "$current_user.id", "$current_user.role"
* - Logical operators: "&&", "||"
* - Simple arithmetic: "field + 10 > 100"
*/
private formulaToFilter(formula: string, user: any): any {
// Remove whitespace for easier parsing
const normalized = formula.trim();

// Pattern 1: Logical OR (check first since it has lower precedence)
// Split on || but not inside quotes
const orParts = this.splitOnOperator(normalized, '||');
if (orParts.length > 1) {
const filters = orParts.map(part => this.formulaToFilter(part, user)).filter(f => Object.keys(f).length > 0);
if (filters.length > 1) {
return { $or: filters };
} else if (filters.length === 1) {
return filters[0];
}
}

// Pattern 2: Logical AND
const andParts = this.splitOnOperator(normalized, '&&');
if (andParts.length > 1) {
const filters = andParts.map(part => this.formulaToFilter(part, user)).filter(f => Object.keys(f).length > 0);
if (filters.length > 1) {
return { $and: filters };
} else if (filters.length === 1) {
return filters[0];
}
}

// Pattern 3: Simple field comparison (e.g., "status == 'active'")
const simpleCompareMatch = normalized.match(/^(\w+)\s*(==|!=|>|>=|<|<=)\s*(.+)$/);
if (simpleCompareMatch) {
const field = simpleCompareMatch[1];
const operator = simpleCompareMatch[2];
let value: any = simpleCompareMatch[3].trim();

// Remove quotes from string values
if ((value.startsWith("'") && value.endsWith("'")) ||
(value.startsWith('"') && value.endsWith('"'))) {
value = value.slice(1, -1);
} else if (/^\d+$/.test(value)) {
// Parse numeric values
value = parseInt(value, 10);
} else if (/^\d+\.\d+$/.test(value)) {
// Parse float values
value = parseFloat(value);
} else if (value === 'true') {
value = true;
} else if (value === 'false') {
value = false;
} else if (value === 'null') {
value = null;
}

// Resolve user context
if (typeof value === 'string' && value.startsWith('$current_user.')) {
const path = value.substring('$current_user.'.length);
value = this.getFieldValue(user, path);
}

// Convert to MongoDB filter
const mongoOp = this.jsOperatorToMongoOperator(operator);
return this.operatorToMongoFilter(field, mongoOp, value);
Comment on lines +266 to +268
Copy link

Copilot AI Jan 30, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The operatorToMongoFilter method is called but uses MongoDB-specific syntax ($gt, $gte, $ne, etc.) which violates the architecture principle that QueryTrimmer should work at the AST level. The comment on line 18 states it "Works at the AST level to inject security filters before SQL generation" but the implementation leaks MongoDB-specific operators.

According to the ObjectQL architecture, packages/foundation must NEVER contain dialect-specific logic. Either refactor to use an abstract filter representation that drivers can interpret, or document this limitation.

Copilot generated this review using guidance from repository custom instructions.
}

// Pattern 4: Field existence check (e.g., "owner" or "!owner")
if (/^!?\w+$/.test(normalized)) {
const negate = normalized.startsWith('!');
const field = negate ? normalized.slice(1) : normalized;

return negate
? { [field]: { $in: [null, undefined, ''] } }
: { [field]: { $ne: null } };
}

// Unable to convert formula to filter
throw new Error(`Unsupported formula pattern: ${formula}`);
}
Comment on lines +236 to +283
Copy link

Copilot AI Jan 30, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The formula parser does not validate or sanitize the user-provided formula expression before evaluation. While it attempts to parse common patterns, there's no prevention of code injection through formula strings. For example, a malicious formula could potentially include method calls or property access that aren't intended to be supported.

Add input validation to ensure only safe operators and field references are allowed in formulas. Consider using an allowlist approach where only specific operators and syntax patterns are permitted.

Copilot uses AI. Check for mistakes.
Comment on lines +208 to +283
Copy link

Copilot AI Jan 30, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The formula parser uses a simplistic regex-based approach that doesn't handle parentheses or operator precedence correctly. For example, the formula "status == 'active' && (role == 'admin' || role == 'manager')" would be incorrectly parsed because the splitOnOperator function splits on || first, which would break the parenthetical grouping. This could lead to incorrect security filters being applied, potentially allowing unauthorized access.

Consider implementing a proper expression parser or document the limitations of formula conditions to avoid security issues. At minimum, add validation to reject formulas containing parentheses if they cannot be safely parsed.

Copilot uses AI. Check for mistakes.

/**
* Split formula on an operator, respecting quotes
*/
private splitOnOperator(formula: string, operator: string): string[] {
const parts: string[] = [];
let current = '';
let inQuote = false;
let quoteChar = '';

for (let i = 0; i < formula.length; i++) {
const char = formula[i];
const nextChar = formula[i + 1];

// Handle quotes
if ((char === '"' || char === "'") && (i === 0 || formula[i - 1] !== '\\')) {
if (!inQuote) {
inQuote = true;
quoteChar = char;
} else if (char === quoteChar) {
inQuote = false;
quoteChar = '';
}
current += char;
continue;
}

// Check for operator
if (!inQuote) {
if (operator === '||' && char === '|' && nextChar === '|') {
if (current.trim()) {
parts.push(current.trim());
}
current = '';
i++; // Skip next character
continue;
} else if (operator === '&&' && char === '&' && nextChar === '&') {
if (current.trim()) {
parts.push(current.trim());
}
current = '';
i++; // Skip next character
continue;
}
}

current += char;
}

if (current.trim()) {
parts.push(current.trim());
}

return parts.length > 0 ? parts : [formula];
}

/**
* Convert a lookup condition to a query filter
*
* This converts lookup chains into subquery filters that can be pushed
* down to the database layer.
*/
private lookupToFilter(condition: any, user: any): any {
const { object, via, condition: nestedCondition } = condition;

// For simple lookup conditions, we can use MongoDB-style $lookup or
// SQL-style JOIN filters. The exact implementation depends on the driver.

// Convert the nested condition to a filter
const nestedFilter = this.conditionToFilter(nestedCondition, user);

if (Object.keys(nestedFilter).length === 0) {
throw new Error('Unable to convert nested lookup condition');
}

// Create a lookup filter
// This will be interpreted by the driver layer
return {
$lookup: {
from: object,
localField: via,
foreignField: '_id',
filter: nestedFilter
}
};
}
Comment on lines +346 to +369
Copy link

Copilot AI Jan 30, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The $lookup filter syntax used here is MongoDB-specific, but the QueryTrimmer is supposed to be database-agnostic and work at the AST level before SQL generation. According to the ObjectQL architecture principles, this should compile to abstract intent that can be interpreted by different drivers (SQL, MongoDB, etc.).

This implementation violates the "Compiler, Not ORM" principle by leaking database-specific syntax into the core security layer. The $lookup operator won't work with SQL drivers. Consider creating an abstract representation that can be translated by each driver, or document that lookup conditions only work with MongoDB.

Copilot generated this review using guidance from repository custom instructions.

/**
* Convert JavaScript comparison operator to MongoDB operator
*/
private jsOperatorToMongoOperator(op: string): string {
switch (op) {
case '==':
case '===':
return '=';
case '!=':
case '!==':
return '!=';
case '>':
return '>';
case '>=':
return '>=';
case '<':
return '<';
case '<=':
return '<=';
default:
return '=';
}
}
Comment on lines +374 to +393
Copy link

Copilot AI Jan 30, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The function name "jsOperatorToMongoOperator" is misleading. It claims to convert JavaScript operators to MongoDB operators, but the function actually converts to generic comparison operators ('=', '!=', etc.) that are then later converted to MongoDB syntax by operatorToMongoFilter.

Rename this to something more accurate like "normalizeComparisonOperator" or "jsOperatorToGenericOperator" to better reflect what it actually does.

Copilot uses AI. Check for mistakes.

/**
* Convert a complex condition expression to a filter
*/
Comment on lines +195 to 397
Copy link

Copilot AI Jan 30, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

According to the ObjectQL architecture guideline (Step 3 of the Development Lifecycle), "All development MUST include documentation updates." This PR adds significant new functionality (formula and lookup RLS conditions) but does not include any updates to documentation or the @objectstack/spec repository.

The formula and lookup condition support should be documented in:

  1. The plugin-security documentation explaining the syntax and limitations
  2. The @objectstack/spec repository to define the protocol
  3. Usage examples showing how to use formula and lookup conditions in RLS configurations

Without documentation, users won't know about these features or how to use them safely.

Copilot generated this review using guidance from repository custom instructions.
Expand Down
100 changes: 100 additions & 0 deletions packages/tools/cli/__tests__/commands.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -431,4 +431,104 @@ describe('CLI Commands', () => {
expect(originalContent).toBeDefined();
});
});

describe('test command', () => {
beforeEach(() => {
// Create a test package.json for testing
const packageJson = {
name: 'test-project',
version: '1.0.0',
scripts: {
test: 'echo "test script"'
}
};

fs.writeFileSync(
path.join(testDir, 'package.json'),
JSON.stringify(packageJson, null, 2),
'utf-8'
);
});

it('should detect missing test runner', async () => {
const packageJson = {
name: 'test-project',
version: '1.0.0',
scripts: {}
};

fs.writeFileSync(
path.join(testDir, 'package.json'),
JSON.stringify(packageJson, null, 2),
'utf-8'
);

// Mock console.log to capture output
const consoleSpy = jest.spyOn(console, 'log');

const { test } = require('../src/commands/test');
await test({ dir: testDir });

// Should show no test configuration message
expect(consoleSpy).toHaveBeenCalledWith(
expect.stringContaining('No test configuration found')
);

consoleSpy.mockRestore();
});
});
Comment on lines +435 to +479
Copy link

Copilot AI Jan 30, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The test only verifies that a "No test configuration found" message is shown, but doesn't verify the actual behavior when jest or vitest are installed. The test should also cover the cases where:

  1. jest is installed and detected
  2. vitest is installed and detected
  3. Both are installed (to test priority)
  4. A specific runner is requested via --runner flag
  5. Runner is requested but not installed

These scenarios are critical to ensure the auto-detection and runner selection logic works correctly.

Copilot uses AI. Check for mistakes.

describe('doctor command', () => {
it('should check Node.js version', async () => {
const consoleSpy = jest.spyOn(console, 'log');

const { doctorCommand } = require('../src/commands/doctor');
await doctorCommand();

// Should print Node.js version
expect(consoleSpy).toHaveBeenCalledWith(
expect.stringContaining('Node.js:')
);

consoleSpy.mockRestore();
});

it('should check for package.json', async () => {
const originalCwd = process.cwd();

try {
// Change to test directory
process.chdir(testDir);

// Create a minimal package.json
const packageJson = {
name: 'test-project',
version: '1.0.0',
dependencies: {
'@objectql/core': '^4.0.0'
}
};

fs.writeFileSync(
path.join(testDir, 'package.json'),
JSON.stringify(packageJson, null, 2),
'utf-8'
);

const consoleSpy = jest.spyOn(console, 'log');

const { doctorCommand } = require('../src/commands/doctor');
await doctorCommand();

// Should detect package.json
expect(consoleSpy).toHaveBeenCalledWith(
expect.stringContaining('package.json')
);

consoleSpy.mockRestore();
} finally {
process.chdir(originalCwd);
}
});
});
Comment on lines +481 to +533
Copy link

Copilot AI Jan 30, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The test mocks console.log to verify output but doesn't check for specific error paths or edge cases. For example, it doesn't test what happens when package.json contains invalid JSON for dependencies, when TypeScript version extraction fails, or when Node version is below v16.

Add tests for error paths and edge cases to ensure robust error handling in the doctor command, such as when version parsing fails or dependencies are malformed.

Copilot uses AI. Check for mistakes.
});
Loading
Loading