diff --git a/src/libs/perm/compilers/sql.ts b/src/libs/perm/compilers/sql.ts index 7bca39e..14b7cc7 100644 --- a/src/libs/perm/compilers/sql.ts +++ b/src/libs/perm/compilers/sql.ts @@ -32,6 +32,7 @@ import { } from '../consts.ts'; import { PERM_ERROR_MSG_NO_SQL_ALLOW, PERM_ERROR_MSG_SQL_RESIDUAL } from '../errors.ts'; import { actionMatches } from '../match.ts'; +import { getPath } from '../path.ts'; import type { ExprIR, PolicyIR, QueryCompiler, QueryPlan, SubjectRef } from '../types.ts'; export interface SqlCompileResult { @@ -99,15 +100,23 @@ export function createSqlCompiler(options: CreateSqlCompilerOptions = {}): Query return param(expr.value); case PERM_EXPR_REF: + // `actor` and `context` references read whatever the runtime + // passes — possibly nested objects (e.g. `actor.risk.mfa`). + // The runtime evaluator uses `getPath()` so the SQL compiler + // must do the same; otherwise dotted paths split inconsistently + // (the in-memory eval would resolve `risk.mfa`, the DB filter + // would key the actor by the literal `'risk.mfa'` property). if (expr.root === PERM_ROOT_RESOURCE) return `${alias}.${columnName(expr.path)}`; if (expr.root === PERM_ROOT_ACTOR) { const value = expr.path - ? (input.actor as Record)[expr.path] + ? getPath(input.actor, expr.path) : input.actor; return param(value); } if (expr.root === PERM_ROOT_CONTEXT) { - const value = expr.path ? input.context?.[expr.path] : input.context; + const value = expr.path + ? getPath(input.context, expr.path) + : input.context; return param(value); } return undefined; diff --git a/src/libs/perm/test/sql-nested-paths.test.ts b/src/libs/perm/test/sql-nested-paths.test.ts new file mode 100644 index 0000000..78382f7 --- /dev/null +++ b/src/libs/perm/test/sql-nested-paths.test.ts @@ -0,0 +1,132 @@ +/** + * Regression test for the audit P1 finding "SQL compiler does not + * resolve nested paths the same way runtime evaluation does". + * + * Before the fix, `actor.path` and `context.path` references in the + * SQL compiler used a literal property lookup + * (`(input.actor as Record)[expr.path]`), so a + * dotted path like `risk.mfa` produced `undefined` from the compiler + * while the runtime evaluator (which uses `getPath`) resolved it to + * the nested value. The fix routes both through `getPath`, so the + * SQL parameter and the in-memory comparison agree. + */ + +import { describe, expect, it } from 'vitest'; +import { createSqlCompiler } from '../compilers/sql.ts'; +import { + PERM_EFFECT_ALLOW, + PERM_EXPR_CONST, + PERM_EXPR_EQ, + PERM_EXPR_REF, + PERM_ROOT_ACTOR, + PERM_ROOT_CONTEXT +} from '../consts.ts'; +import type { PolicyIR, SubjectRef } from '../types.ts'; + +const schema = { tenants: false, attributes: {} } as never; + +describe('createSqlCompiler — nested actor / context paths', () => { + it('resolves a nested actor path with getPath instead of a literal lookup', () => { + const compiler = createSqlCompiler(); + + const policy: PolicyIR = { + id: 'allow-mfa', + effect: PERM_EFFECT_ALLOW, + priority: 0, + target: { action: 'read', resource: 'doc' }, + condition: { + op: PERM_EXPR_EQ, + left: { op: PERM_EXPR_REF, root: PERM_ROOT_ACTOR, path: 'risk.mfa' }, + right: { op: PERM_EXPR_CONST, value: true } + } + } as PolicyIR; + + const actor: SubjectRef = { + id: 'u-1', + risk: { mfa: true } + } as unknown as SubjectRef; + + const plan = compiler.compile({ + schema, + policies: [policy], + action: 'read', + actor, + resourceType: 'doc' + }); + + // The nested value travels through `getPath`; the parameter the + // compiler emits is `true`, not `undefined`. + const predicate = (plan as { predicate?: { params: Record } }) + .predicate; + const params = predicate?.params ?? {}; + const values = Object.values(params); + expect(values).toContain(true); + expect(values).not.toContain(undefined); + }); + + it('resolves a nested context path the same way', () => { + const compiler = createSqlCompiler(); + + const policy: PolicyIR = { + id: 'allow-region', + effect: PERM_EFFECT_ALLOW, + priority: 0, + target: { action: 'read', resource: 'doc' }, + condition: { + op: PERM_EXPR_EQ, + left: { op: PERM_EXPR_REF, root: PERM_ROOT_CONTEXT, path: 'request.region' }, + right: { op: PERM_EXPR_CONST, value: 'eu' } + } + } as PolicyIR; + + const plan = compiler.compile({ + schema, + policies: [policy], + action: 'read', + actor: { id: 'u-1' } as SubjectRef, + resourceType: 'doc', + context: { request: { region: 'eu' } } + }); + + const predicate = (plan as { predicate?: { params: Record } }) + .predicate; + const params = predicate?.params ?? {}; + const values = Object.values(params); + expect(values).toContain('eu'); + expect(values).not.toContain(undefined); + }); + + it('returns undefined parameter when the nested path is missing (matches runtime)', () => { + const compiler = createSqlCompiler(); + + const policy: PolicyIR = { + id: 'allow-region', + effect: PERM_EFFECT_ALLOW, + priority: 0, + target: { action: 'read', resource: 'doc' }, + condition: { + op: PERM_EXPR_EQ, + left: { op: PERM_EXPR_REF, root: PERM_ROOT_ACTOR, path: 'profile.department' }, + right: { op: PERM_EXPR_CONST, value: 'finance' } + } + } as PolicyIR; + + const plan = compiler.compile({ + schema, + policies: [policy], + action: 'read', + actor: { id: 'u-1' } as SubjectRef, // no `profile` + resourceType: 'doc' + }); + + // `getPath` returns `undefined` when the chain breaks; the + // runtime evaluator does the same. This test pins behavior so + // a future change can't silently re-introduce a literal + // `actor['profile.department']` path. + const predicate = (plan as { predicate?: { params: Record } }) + .predicate; + const params = predicate?.params ?? {}; + const values = Object.values(params); + expect(values).toContain(undefined); + }); +});