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',
);