Skip to content

CASL Access Filters

Problem

The base CASL Authorization system works for point-level checks — "can this user update agent X?" But it cannot handle collection-level filtering — "get all agents this user is allowed to see."

Without access filters, every getAll or paginated endpoint either:

  • Returns everything and post-filters in memory (slow, insecure at scale)
  • Uses hardcoded boolean flags like onlyPublic (doesn't scale with custom policies)
  • Can't enforce conditions like "only agents with visibility = 'public'" at the database level

Access Filters solve this by translating CASL policy conditions into TypeORM FindOptionsWhere clauses that repositories use directly in SQL queries.

Policy Evaluation Model

This system follows the AWS IAM policy evaluation model. Understanding these rules is essential for writing correct policies.

The Four Rules

RuleBehaviorExample
Within a single policy: different fieldsAND{ visibility: 'public', isEnabled: true } → must be public AND enabled
Across policies: multiple allow statementsOR (union, additive)Policy A allows public agents, Policy B allows agent X → user sees public agents OR agent X
Same field across policies: multiple valuesOR{ visibility: 'public' } + { visibility: 'restricted' } → public OR restricted
Deny rulesAlways winEven if manage:all allows everything, a deny on agent X excludes agent X

Why Allow Policies Are Additive (OR)

Each allow policy independently expands what a user can access. Policies never restrict each other — they accumulate.

Policy A: "can read agents where visibility = 'public'"
Policy B: "can read agent where id = 'specific-agent'"

Result: user can see ALL public agents + that specific agent
         (NOT "only specific-agent if it's public")

If you want the restrictive behavior (visibility AND id), put both conditions in one policy:

json
{
  "action": "read",
  "subject": "ai.agent",
  "conditions": { "visibility": "public", "id": "specific-agent" }
}

This produces: WHERE visibility = 'public' AND id = 'specific-agent' — only that specific agent, and only if it's public.

orgId Is Always Forced

The orgId is never taken from CASL conditions. It is always injected from the authenticated session. This prevents:

  • Cross-org data leakage if a policy has a wrong orgId
  • Deny rules from negating orgId (which would show data from other orgs)

Examples

Example 1: Super admin sees everything in their org

Policies:

json
[{ "action": "manage", "subject": "all", "conditions": { "orgId": "org-123" } }]

Generated filter:

typescript
[{ orgId: 'org-123' }];
sql
WHERE org_id = 'org-123'

No additional restrictions — the user sees all agents in their org.

Example 2: User can only see public and restricted agents

Policies:

json
[
  { "action": "read", "subject": "ai.agent", "conditions": { "visibility": "public" } },
  { "action": "read", "subject": "ai.agent", "conditions": { "visibility": "restricted" } }
]

Generated filter:

typescript
[
  { orgId: 'org-123', visibility: 'public' },
  { orgId: 'org-123', visibility: 'restricted' },
];
sql
WHERE (org_id = 'org-123' AND visibility = 'public')
   OR (org_id = 'org-123' AND visibility = 'restricted')

Two allow policies → OR. Each one includes the forced orgId.

Example 3: See everything except one specific agent

Policies:

json
[
  { "action": "manage", "subject": "all", "conditions": { "orgId": "org-123" } },
  {
    "action": "read",
    "subject": "ai.agent",
    "conditions": { "id": "secret-agent" },
    "inverted": true
  }
]

Generated filter:

typescript
[{ orgId: 'org-123', id: Not(Equal('secret-agent')) }];
sql
WHERE org_id = 'org-123' AND id != 'secret-agent'

The deny rule's id exclusion is AND'd into the allow condition. The orgId from the deny rule is stripped (never negated).

Example 4: Public agents + one specific private agent

Policies:

json
[
  { "action": "read", "subject": "ai.agent", "conditions": { "visibility": "public" } },
  { "action": "read", "subject": "ai.agent", "conditions": { "id": "private-agent-99" } }
]

Generated filter:

typescript
[
  { orgId: 'org-123', visibility: 'public' },
  { orgId: 'org-123', id: 'private-agent-99' },
];
sql
WHERE (org_id = 'org-123' AND visibility = 'public')
   OR (org_id = 'org-123' AND id = 'private-agent-99')

The user sees all public agents OR the specific private agent. The policies are additive.

Example 5: Deny two specific agents, see everything else

Policies:

json
[
  { "action": "manage", "subject": "all", "conditions": { "orgId": "org-123" } },
  { "action": "read", "subject": "ai.agent", "conditions": { "id": "agent-a" }, "inverted": true },
  { "action": "read", "subject": "ai.agent", "conditions": { "id": "agent-b" }, "inverted": true }
]

Generated filter:

typescript
[{ orgId: 'org-123', id: And(Not(Equal('agent-a')), Not(Equal('agent-b'))) }];
sql
WHERE org_id = 'org-123' AND id != 'agent-a' AND id != 'agent-b'

Multiple deny rules on the same field are combined with And(). Both agents are excluded.

Example 6: Date range + visibility filter in one policy

Policies:

json
[
  {
    "action": "read",
    "subject": "ai.agent",
    "conditions": { "visibility": "public", "createdAt": { "$gte": "2025-01-01" } }
  }
]

Generated filter:

typescript
[{ orgId: 'org-123', visibility: 'public', createdAt: MoreThanOrEqual('2025-01-01') }];
sql
WHERE org_id = 'org-123' AND visibility = 'public' AND created_at >= '2025-01-01'

Multiple fields within one policy are AND'd.

Example 7: Complex — 2 allow rules + deny on different fields

Policies:

json
[
  { "action": "read", "subject": "ai.agent", "conditions": { "visibility": "public" } },
  { "action": "read", "subject": "ai.agent", "conditions": { "visibility": "restricted" } },
  {
    "action": "read",
    "subject": "ai.agent",
    "conditions": { "id": "hidden-agent" },
    "inverted": true
  },
  {
    "action": "read",
    "subject": "ai.agent",
    "conditions": { "isEnabled": false },
    "inverted": true
  }
]

Generated filter:

typescript
[
  {
    orgId: 'org-123',
    visibility: 'public',
    id: Not(Equal('hidden-agent')),
    isEnabled: Not(Equal(false)),
  },
  {
    orgId: 'org-123',
    visibility: 'restricted',
    id: Not(Equal('hidden-agent')),
    isEnabled: Not(Equal(false)),
  },
];
sql
WHERE (org_id = 'org-123' AND visibility = 'public' AND id != 'hidden-agent' AND is_enabled != false)
   OR (org_id = 'org-123' AND visibility = 'restricted' AND id != 'hidden-agent' AND is_enabled != false)

Deny exclusions are injected into every allow condition.

Architecture

Components

ComponentLocationPurpose
SubjectFieldRegistrycore/authorization/access-filter/Defines which fields per subject accept conditions and which operators
AccessFilterServicecore/authorization/access-filter/Translates CASL rules → TypeORM FindOptionsWhere[]
ConditionValidatorcore/authorization/access-filter/Validates conditions when creating custom policies
AuthorizationService.buildAccessFilter()core/authorization/Public entry point: resolves ability + delegates to AccessFilterService
AgentFieldSchemaProvidermodules/ai/infrastructure/loaders/Registers ai.agent filterable fields

Data Flow

Controller                          Query Handler                    Repository
   │                                     │                              │
   │  GetAllAgentsQuery(user, orgId)      │                              │
   │─────────────────────────────────────>│                              │
   │                                     │  authService.buildAccessFilter()
   │                                     │─────────────┐                │
   │                                     │             │ resolveAbility()
   │                                     │             │ accessFilter.buildAccessFilter()
   │                                     │<────────────┘                │
   │                                     │  FindOptionsWhere[]          │
   │                                     │                              │
   │                                     │  repo.findWithAccessFilter() │
   │                                     │─────────────────────────────>│
   │                                     │                              │ repo.find({ where })
   │                                     │<─────────────────────────────│
   │  Result<Agent[]>                    │                              │
   │<────────────────────────────────────│                              │

Supported Operators

OperatorCASL FormatTypeORM OutputExample
$eq{ field: value } or { field: { $eq: value } }Direct valuevisibility: 'public'
$ne{ field: { $ne: value } }Not(Equal(value))visibility: Not(Equal('private'))
$in{ field: { $in: [...] } }In([...])id: In(['a', 'b', 'c'])
$gte{ field: { $gte: value } }MoreThanOrEqual(value)createdAt: MoreThanOrEqual('2025-01-01')
$lte{ field: { $lte: value } }LessThanOrEqual(value)createdAt: LessThanOrEqual('2025-12-31')

When operators are negated (deny rules):

OriginalNegatedReason
$eqNot(Equal(v))!=
$neDirect valueDouble negation cancels
$inNot(In([...]))NOT IN
$gteLessThan(v)NOT >= is <
$lteMoreThan(v)NOT <= is >

Registered Fields for ai.agent

FieldTypeAllowed Operators
iduuid$eq, $in
orgIduuid$eq
visibilityenum (public, private, restricted)$eq, $ne, $in
internalNameIdstring$eq, $in
createdAtdate$eq, $gte, $lte
isEnabledboolean$eq

Condition Validation on Policy Creation

When an org owner creates a custom policy with conditions, the ConditionValidator validates against the SubjectFieldRegistry:

  • Unknown fields → rejected (e.g., stats is not filterable for ai.agent)
  • Unsupported operators → rejected (e.g., $regex is not supported)
  • Invalid operator for field → rejected (e.g., $gte on a uuid field)
  • Invalid enum values → rejected (e.g., visibility: 'INVALID')
  • Unregistered subjects → validation skipped (backward compatibility)

This ensures only safe, well-defined conditions reach the database.

Adding Access Filters to a New Subject

To register filterable fields for a new subject (e.g., storage.document):

  1. Create a field schema provider in the module's infrastructure/loaders/:
typescript
@Injectable()
export class DocumentFieldSchemaProvider implements OnModuleInit {
  constructor(private readonly registry: SubjectFieldRegistry) {}

  onModuleInit(): void {
    this.registry.register({
      subject: 'storage.document',
      fields: [
        {
          field: 'id',
          type: 'uuid',
          allowedOperators: [ConditionOperator.EQ, ConditionOperator.IN],
        },
        { field: 'orgId', type: 'uuid', allowedOperators: [ConditionOperator.EQ] },
        {
          field: 'mimeType',
          type: 'string',
          allowedOperators: [ConditionOperator.EQ, ConditionOperator.IN],
        },
        // ... more fields
      ],
    });
  }
}
  1. Add the provider to the module's providers array.

  2. Add findWithAccessFilter to the repository interface and implementation.

  3. Use in the query handler:

typescript
const accessFilter = await this.authorizationService.buildAccessFilter(
  { ...authContext, orgId },
  'read',
  'storage.document',
);