Appearance
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
| Rule | Behavior | Example |
|---|---|---|
| Within a single policy: different fields | AND | { visibility: 'public', isEnabled: true } → must be public AND enabled |
| Across policies: multiple allow statements | OR (union, additive) | Policy A allows public agents, Policy B allows agent X → user sees public agents OR agent X |
| Same field across policies: multiple values | OR | { visibility: 'public' } + { visibility: 'restricted' } → public OR restricted |
| Deny rules | Always win | Even 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
| Component | Location | Purpose |
|---|---|---|
SubjectFieldRegistry | core/authorization/access-filter/ | Defines which fields per subject accept conditions and which operators |
AccessFilterService | core/authorization/access-filter/ | Translates CASL rules → TypeORM FindOptionsWhere[] |
ConditionValidator | core/authorization/access-filter/ | Validates conditions when creating custom policies |
AuthorizationService.buildAccessFilter() | core/authorization/ | Public entry point: resolves ability + delegates to AccessFilterService |
AgentFieldSchemaProvider | modules/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
| Operator | CASL Format | TypeORM Output | Example |
|---|---|---|---|
$eq | { field: value } or { field: { $eq: value } } | Direct value | visibility: '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):
| Original | Negated | Reason |
|---|---|---|
$eq | Not(Equal(v)) | != |
$ne | Direct value | Double negation cancels |
$in | Not(In([...])) | NOT IN |
$gte | LessThan(v) | NOT >= is < |
$lte | MoreThan(v) | NOT <= is > |
Registered Fields for ai.agent
| Field | Type | Allowed Operators |
|---|---|---|
id | uuid | $eq, $in |
orgId | uuid | $eq |
visibility | enum (public, private, restricted) | $eq, $ne, $in |
internalNameId | string | $eq, $in |
createdAt | date | $eq, $gte, $lte |
isEnabled | boolean | $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.,
statsis not filterable forai.agent) - Unsupported operators → rejected (e.g.,
$regexis not supported) - Invalid operator for field → rejected (e.g.,
$gteon auuidfield) - 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):
- 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
],
});
}
}Add the provider to the module's providers array.
Add
findWithAccessFilterto the repository interface and implementation.Use in the query handler:
typescript
const accessFilter = await this.authorizationService.buildAccessFilter(
{ ...authContext, orgId },
'read',
'storage.document',
);Related Documentation
- CASL Authorization — base CASL system, ability factory, subject loaders, and deny rules
- RBAC and Custom Roles — role/policy management and assignment