UNPKG

@adonis-agora/agent

Version:

Governed, durable-backed AI agent (chat + tool-calling + governance) for AdonisJS — part of the Agora ecosystem.

169 lines • 7.93 kB
const STRING_LITERAL_TYPES = new Set(['string', 'single_quote_string', 'double_quote_string']); /** * Rewrites a SELECT so every reference to a scoped table is constrained to a * single tenant: `<tenantColumn> = '<tenantRef>'` is AND-ed into the WHERE for * each scoped table in the FROM. An existing predicate for a different tenant * is rejected (no cross-tenant reads). `tenantRef === undefined` is the * privileged path and passes the SQL through unchanged. * * CRITICAL: only a strictly `=== undefined` tenantRef is privileged. A `null` * or empty-string tenantRef is NOT privileged — it flows into the rewrite and * injects a predicate that matches nothing (fail-closed), so a missing tenant * can never accidentally read every tenant's rows. * * Scoped mode rejects CTEs, UNION/INTERSECT/EXCEPT, and subqueries in FROM: * those make it impossible to statically guarantee every tenant-bearing source * is constrained, so we fail closed and ask the caller to rephrase. * * The `node-sql-parser` `Parser` is injected (see {@link loadSqlParser}) so this file never imports the * optional peer at module load. */ export class TenantScopeRewriter { parser; tenantColumn; scopedTables; constructor(config, parser) { this.parser = parser; this.tenantColumn = config.tenantColumn; this.scopedTables = new Set(config.scopedTables); } /** Rewrite `sql` to constrain scoped tables to `tenantRef`. Strictly-`undefined` → pass through. */ rewrite(sql, tenantRef) { if (tenantRef === undefined) return sql; const parsed = this.parser.astify(sql, { database: 'MySQL' }); const ast = (Array.isArray(parsed) ? parsed[0] : parsed); if (ast.type !== 'select') { throw new Error('tenant scope: only SELECT is supported'); } if (ast.with) { throw new Error('tenant scope: WITH (CTE) is not supported in scoped mode — rewrite using JOINs/subqueries in FROM'); } if (ast._next) { throw new Error('tenant scope: UNION/INTERSECT/EXCEPT is not supported in scoped mode — run each branch as a separate query'); } const fromEntries = ast.from ?? []; for (const entry of fromEntries) { if (!entry.table && entry.expr?.ast) { throw new Error('tenant scope: subqueries in FROM are not supported in scoped mode'); } } const scopedFrom = fromEntries.filter((entry) => typeof entry.table === 'string' && this.scopedTables.has(entry.table)); if (scopedFrom.length === 0) return sql; // Mismatch rejection looks at every tenant literal anywhere in the tree — including under an // OR or NOT — because a query that so much as *names* a foreign tenant is suspicious and must // throw, not silently be AND-ed down to zero rows. const allPredicates = this.collectAllTenantPredicates(ast.where); for (const predicate of allPredicates) { if (predicate.value !== tenantRef) { throw new Error('tenant scope: tenant mismatch — query targets a tenant other than the current session'); } } // Coverage is a different question: a predicate only actually constrains every row the query // can return when it is on the top-level AND spine. A predicate under an OR (or NOT, or any // other non-conjunctive operator) does not guarantee the constraint holds, so it must NOT // suppress the AND-ed constraint below — see `collectConjunctiveTenantPredicates`. const conjunctive = this.collectConjunctiveTenantPredicates(ast.where); const coveredAliases = new Set(conjunctive.map((predicate) => predicate.tableAlias)); for (const entry of scopedFrom) { const alias = entry.as ?? entry.table; const isAmbiguous = scopedFrom.length > 1; const covered = coveredAliases.has(alias) || (!isAmbiguous && coveredAliases.has(null)); if (covered) continue; ast.where = this.andCondition(ast.where, this.buildTenantEquality(isAmbiguous ? alias : null, tenantRef)); } return this.parser.sqlify(ast, { database: 'MySQL' }); } /** * Every tenant-column `=` predicate anywhere in the tree (recurses into both `AND` and `OR`). * Used ONLY for the mismatch check: a query naming a foreign tenant anywhere must be rejected, * even where that predicate cannot be relied on to actually constrain the result set. */ collectAllTenantPredicates(where) { if (!isBinaryExpr(where)) return []; if (where.operator === 'AND' || where.operator === 'OR') { return [ ...this.collectAllTenantPredicates(where.left), ...this.collectAllTenantPredicates(where.right), ]; } return this.extractTenantPredicate(where); } /** * Tenant-column `=` predicates on the top-level `AND` spine only. Descent stops at `OR` (and at * any operator other than `AND`/`=`, e.g. `NOT`, which the parser represents as a `unary_expr` * and so is never a `binary_expr` here) because only a conjunctively-combined predicate is * guaranteed to hold for every row the query can return. Used for coverage: a table alias is * "already scoped" only when one of these covers it. */ collectConjunctiveTenantPredicates(where) { if (!isBinaryExpr(where)) return []; if (where.operator === 'AND') { return [ ...this.collectConjunctiveTenantPredicates(where.left), ...this.collectConjunctiveTenantPredicates(where.right), ]; } return this.extractTenantPredicate(where); } extractTenantPredicate(where) { if (where.operator !== '=') return []; const lhs = where.left; const rhs = where.right; if (!isColumnRef(lhs) || lhs.column !== this.tenantColumn) return []; if (!isStringLiteral(rhs)) return []; return [{ tableAlias: lhs.table ?? null, value: rhs.value }]; } buildTenantEquality(tableAlias, tenantRef) { return { type: 'binary_expr', operator: '=', left: { type: 'column_ref', table: tableAlias, column: this.tenantColumn }, right: { type: 'single_quote_string', value: tenantRef }, }; } andCondition(existing, added) { if (existing == null) return added; return { type: 'binary_expr', operator: 'AND', // Force `parentheses: true` regardless of whether the original SQL already had explicit // parens here — see the doc comment on `BinaryExpr.parentheses`. Without it, an OR-rooted // `existing` prints without the grouping parens a real database needs to apply the AND-ed // tenant predicate to the WHOLE existing expression rather than just its last disjunct. left: this.forceParens(existing), right: added, }; } forceParens(node) { if (typeof node !== 'object' || node === null) return node; return { ...node, parentheses: true }; } } function isBinaryExpr(value) { return (typeof value === 'object' && value !== null && value.type === 'binary_expr'); } function isColumnRef(value) { return (typeof value === 'object' && value !== null && value.type === 'column_ref'); } function isStringLiteral(value) { return (typeof value === 'object' && value !== null && typeof value.type === 'string' && STRING_LITERAL_TYPES.has(value.type) && typeof value.value === 'string'); } //# sourceMappingURL=tenant-scope.js.map