Skip to content

feat(sql): add CASE/WHEN operator for mixed inverted and direct rules#72

Draft
Copilot wants to merge 2 commits intomasterfrom
copilot/handle-inverted-direct-rules
Draft

feat(sql): add CASE/WHEN operator for mixed inverted and direct rules#72
Copilot wants to merge 2 commits intomasterfrom
copilot/handle-inverted-direct-rules

Conversation

Copy link
Copy Markdown
Contributor

Copilot AI commented Mar 7, 2026

When CASL rules combine can and cannot for the same resource/attributes, translating to SQL via simple AND/OR produces logically incorrect queries (e.g., NOT (id = 1) AND id = 1 is always FALSE). The rule priority ordering from CASL's runtime is lost in translation.

Changes

  • packages/sql/src/interpreters.ts — New sqlCase compound operator that interprets CompoundCondition('case', [...]) into a CASE/WHEN SQL expression. Children wrapped in not become WHEN ... THEN 0 (inverted/cannot), direct children become WHEN ... THEN 1 (can). Uses (CASE ... ELSE 0 END = 1) for cross-dialect compatibility.
  • packages/sql/src/defaults.ts — Registered as case in allInterpreters.
  • packages/sql/spec/interpreters.spec.ts — 7 test cases covering single/mixed/compound conditions and MySQL dialect.

Usage

// Rules ordered highest priority first:
// cannot('manage', 'Post', { id: 1, private: true })
// can('manage', 'Post', { id: 1 })
// can('manage', 'Post', { id: 10 })
// cannot('manage', 'Post', { id: { $gte: 5 } })

const condition = new CompoundCondition('case', [
  new CompoundCondition('not', [
    new CompoundCondition('and', [
      new FieldCondition('eq', 'id', 1),
      new FieldCondition('eq', 'private', true),
    ])
  ]),
  new FieldCondition('eq', 'id', 1),
  new FieldCondition('eq', 'id', 10),
  new CompoundCondition('not', [new FieldCondition('gte', 'id', 5)]),
]);

const [sql, params] = interpret(condition, { ...pg, joinRelation: () => false });
// (CASE WHEN ("id" = $1 and "private" = $2) THEN 0 WHEN "id" = $3 THEN 1 WHEN "id" = $4 THEN 1 WHEN "id" >= $5 THEN 0 ELSE 0 END = 1)
// [1, true, 1, 10, 5]

This is a 1:1 match with CASL's runtime evaluation semantics, as discussed in stalniy/casl#1010.

Warning

Firewall rules blocked me from connecting to one or more addresses (expand for details)

I tried to connect to the following addresses, but was blocked by firewall rules:

  • https://api.github.com/repos/stalniy/casl/issues/comments/2576798050
    • Triggering command: /home/REDACTED/work/_temp/ghcca-node/node/bin/node /home/REDACTED/work/_temp/ghcca-node/node/bin/node --enable-source-maps /home/REDACTED/work/_temp/copilot-developer-action-main/dist/index.js (http block)
    • Triggering command: /usr/bin/curl curl -s REDACTED -H Accept: application/vnd.github.v3+json (http block)

If you need me to access, download, or install something from one of these locations, you can either:

Original prompt

This section details on the original issue you should resolve

<issue_title>Handling inverted and direct rules with same condition when interpreting SQL conditions </issue_title>
<issue_description>Hello!

First, thanks so much for this library! We've gotten a lot of use out of CASL so far. Up to this point, we've yet to convert resource collections of our REST API to be scoped based on the CASL ability instance for a given user (we're reliant on legacy code to do this). Making this leap is one of the last steps we need to take to deprecate our legacy permissions model implementation.

We use knex and scope queries on REST resource collections before applying user-provided filters onto the query. We would instead like to use the interpreter in @ucast/sql to process the Ability instance AST; producing a raw where constraint we can apply to our query builder.

For additive permissions, everything seems to work as expected. However, we need to be able to support negative (inverted) permissions as well. The ability instance handles this great when querying permissions on individual resources. However, something is lost in translation in our processing of the underlying AST when producing SQL and after tinkering for a while and doing some research, we're left thinking this HAS to be a common problem others have solved.

I've created a simple example that demonstrates what we're trying to achieve:

const { subject, createMongoAbility, AbilityBuilder } = require('@casl/ability')
const { rulesToAST } = require('@casl/ability/extra')
const { allInterpreters, createSqlInterpreter, mysql } = require('@ucast/sql')

const { can, cannot, build } = new AbilityBuilder(createMongoAbility)

cannot('manage', 'Post', { id: 1 })
can('manage', 'Post', { id: 1 })

const ability = build()

console.log(
  `We can update a post with id === 1:`,
  ability.can('update', subject('Post', { id: 1 }))
)

const condition = rulesToAST(ability, 'update', 'Post')

console.log(`Our condition AST is...`)
console.log(JSON.stringify(condition, null, 2))

const interpret = createSqlInterpreter(allInterpreters)
const [sql, replacements] = interpret(condition, {
  ...mysql,
  paramPlaceholder: () => '?',
})

console.log(
  `The matching SQL constraint would be:`,
  sql,
  replacements
)

Output when ordering is cannot before can

We can update a post with id === 1: true
Our condition AST is...
{
  "operator": "and",
  "value": [
    {
      "operator": "not",
      "value": [
        {
          "operator": "eq",
          "value": 1,
          "field": "id"
        }
      ]
    },
    {
      "operator": "eq",
      "value": 1,
      "field": "id"
    }
  ]
}

The matching SQL constraint would be: (not (`id` = ?) and `id` = ?) [ 1, 1 ]

Output when ordering is can before cannot

We can update a post with id === 1: false
Our condition AST is...
{
  "operator": "and",
  "value": [
    {
      "operator": "not",
      "value": [
        {
          "operator": "eq",
          "value": 1,
          "field": "id"
        }
      ]
    },
    {
      "operator": "eq",
      "value": 1,
      "field": "id"
    }
  ]
}

The matching SQL constraint would be: (not (`id` = ?) and `id` = ?) [ 1, 1 ]

A few important notes:

  1. If the order of rules is changed, the runtime ability.can check behaves as expected. I will lose access if the cannot comes last and I retain access if can is last.
  2. Regardless of the order, the AST is the same; which makes sense! It should theoretically be the same. However, I'm lost on how to translate this to SQL in that case such that the ordering of the rules produces different SQL.

We've also come across https://gist.github.com/ygrishajev/9ef01444fdb5c386c43b6611400c0fc6 which uses rulesToQuery to return AST nodes and constructs a compound condition manually. However, it seems to hit this same issue when there are inverted and non-inverted rules applied to a resource where you get a lossy translation to the database; differing from results you see calling ability.can.

</issue_description>

<agent_instructions>Implement handling of inverted rules with help of CASE/WHEN in SQL. add support for this in @ucast/sql</agent_instructions>

Comments on the Issue (you are @copilot in this section)

@stalniy I also always had hard time to translate such intersection to DB query and as a result this edge case has never been solved on casl side.

The recommendation says to get rid of inverted rules as much as possible. If you do not use them then you don’t have to solve this edge case. </comment_new>
<comment_new>@stalniy
But it works better than it sounds from my message above. Because the issue appears only if we use the same property in can and cannot rules. So the ...


🔒 GitHub Advanced Security automatically protects Copilot coding agent pull requests. You can protect all pull requests by enabling Advanced Security for your repositories. Learn more about Advanced Security.

Add a new `sqlCase` compound operator that generates CASE/WHEN SQL
statements to correctly handle the combination of inverted (cannot)
and direct (can) rules with priority ordering.

Each child of a CompoundCondition('case', [...]) is interpreted as:
- Direct conditions → WHEN condition THEN 1
- Not-wrapped conditions → WHEN condition THEN 0

The overall SQL is: (CASE WHEN ... ELSE 0 END = 1)

This ensures consistency with CASL's runtime priority-based rule
evaluation when translating to SQL queries.

Co-authored-by: stalniy <1159966+stalniy@users.noreply.github.com>
Copilot AI changed the title [WIP] Handle inverted and direct rules with same condition in SQL feat(sql): add CASE/WHEN operator for mixed inverted and direct rules Mar 7, 2026
@codecov
Copy link
Copy Markdown

codecov bot commented Mar 7, 2026

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 92.06%. Comparing base (8c518ea) to head (98ad9b2).
⚠️ Report is 4 commits behind head on master.

Additional details and impacted files
@@            Coverage Diff             @@
##           master      #72      +/-   ##
==========================================
+ Coverage   91.93%   92.06%   +0.12%     
==========================================
  Files          12       12              
  Lines         248      252       +4     
  Branches       40       41       +1     
==========================================
+ Hits          228      232       +4     
  Misses         12       12              
  Partials        8        8              

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Handling inverted and direct rules with same condition when interpreting SQL conditions

2 participants