Overview
Permission Mongo uses a custom expression language for defining RBAC policies. Expressions are compiled into MongoDB query filters and evaluated against documents and user context.
Architecture
The expression language is implemented as a three-stage pipeline:
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ Lexer │────▶│ Parser │────▶│ Compiler │
│ (Tokenize) │ │ (Build AST) │ │ (→ MongoDB) │
└──────────────┘ └──────────────┘ └──────────────┘
│ │ │
Tokens AST Nodes bson.M Filter
Key Components:
Lexer (pkg/rbac/lexer.go) - Tokenizes expression strings
Parser (pkg/rbac/parser.go) - Builds Abstract Syntax Tree (AST)
Compiler (pkg/rbac/compiler.go) - Compiles AST to MongoDB filters
Engine (pkg/rbac/engine.go) - Evaluates expressions against documents
Expression Syntax
Basic Structure
expression = comparison | logical_expr | unary_expr
Expressions are evaluated in RBAC policy when clauses:
policies :
orders :
manager :
actions : [ read , update ]
when : |
doc.company_id == user.tenant_id &&
doc.created_by in user.$subordinates
Operators
Comparison Operators
Operator Description Example ==Equal doc.status == "active"!=Not equal doc.status != "deleted">Greater than doc.amount > 1000>=Greater than or equal doc.amount >= 1000<Less than doc.priority < 5<=Less than or equal doc.priority <= 5
Examples:
# Numeric comparison
when : doc.amount >= 10000
# String comparison
when : doc.status == "approved"
# Field-to-field comparison (user context)
when : doc.created_by == user.id
Logical Operators
Operator Description Example &&Logical AND doc.status == "active" && doc.amount > 100` ` Logical OR `doc.priority == 1 doc.urgent == true` !Logical NOT !(doc.status == "deleted")
Examples:
# AND - both conditions must be true
when : doc.company_id == user.tenant_id && doc.status == "active"
# OR - at least one condition must be true
when : doc.created_by == user.id || doc.assigned_to == user.id
# NOT - negate a condition
when : !(doc.archived == true)
# Complex combinations
when : |
(doc.status == "draft" || doc.status == "pending") &&
doc.company_id == user.tenant_id
Membership Operators
Operator Description Example inValue in array doc.status in ["active", "pending"]not inValue not in array user.id not in doc.blocked_users
Examples:
# Check if field value is in array
when : doc.status in ["active", "approved", "completed"]
# Check if user ID is in document's team array
when : user.id in doc.team_members
# Check if document field is in user's subordinates
when : doc.created_by in user.$subordinates
# Negation
when : doc.region not in ["EMEA", "APAC"]
References
Document References (doc.*)
Access document fields using dot notation:
# Top-level field
when : doc.status == "active"
# Nested field
when : doc.metadata.category == "urgent"
# Array field (membership check)
when : user.id in doc.team_members
AST Representation:
// pkg/rbac/ast.go:94-104
type Reference struct {
Root string // "doc" or "user"
Path [] string // field path, e.g., ["metadata", "category"]
}
User References (user.*)
Access authenticated user context:
# User ID
when : doc.created_by == user.id
# Tenant ID
when : doc.company_id == user.tenant_id
# User roles
when : "admin" in user.roles
# Custom claims
when : doc.department == user.claims.department
Available User Fields:
Field Type Description user.idstring User’s unique ID user.tenant_idstring User’s tenant/organization ID user.roles[]string User’s assigned roles user.claims.*any Custom JWT claims
Special Variables
User hierarchy data for manager/subordinate relationships:
Variable Type Description user.$subordinates[]string All subordinates (transitive) user.$directReports[]string Direct reports only user.$ancestors[]string All managers up the chain
Examples:
# Manager can access subordinate's documents
manager :
actions : [ read , update ]
when : doc.created_by in user.$subordinates
# Employee can access their own or direct reports' documents
employee :
actions : [ read ]
when : |
doc.created_by == user.id ||
doc.created_by in user.$directReports
# Check if document creator is in user's management chain
when : doc.approved_by in user.$ancestors
Literals
String Literals
# Double quotes
when : doc.status == "active"
# Single quotes
when : doc.status == 'pending'
# Escape sequences
when : doc.notes == "Line 1\nLine 2"
Numeric Literals
# Integer
when : doc.quantity > 100
# Float
when : doc.price >= 99.99
# Negative
when : doc.balance > -1000
Boolean Literals
# Boolean comparison
when : doc.is_active == true
# Boolean field (implicit true check)
when : doc.is_verified
# Negation
when : doc.is_deleted != true
Null Literal
# Check for null
when : doc.deleted_at == null
# Check for non-null
when : doc.approved_by != null
Array Literals
# Array of strings
when : doc.status in ["active", "pending", "approved"]
# Array of numbers
when : doc.priority in [1, 2, 3]
# Mixed types (avoid - not recommended)
when : doc.value in [100, "high", true]
Compilation to MongoDB
Expressions are compiled to MongoDB query filters:
Example: Simple Comparison
Expression:
when : doc.status == "active"
Compiled MongoDB Filter:
bson . M { "status" : "active" }
Example: Logical AND
Expression:
when : doc.company_id == user.tenant_id && doc.status == "active"
Compiled Filter:
bson . M {
"$and" : [] bson . M {
{ "company_id" : "tenant123" }, // user.tenant_id resolved at compile time
{ "status" : "active" },
},
}
Example: Membership (IN)
Expression:
when : doc.created_by in user.$subordinates
Compiled Filter:
bson . M {
"created_by" : bson . M {
"$in" : [] string { "user456" , "user789" }, // subordinates resolved from hierarchy cache
},
}
Example: Negation
Expression:
when : !(doc.status == "deleted")
Compiled Filter:
bson . M {
"status" : bson . M { "$ne" : "deleted" },
}
AST Structure
The parser builds an Abstract Syntax Tree with these node types:
Expression Nodes
// pkg/rbac/ast.go:21-26
type BinaryExpr struct {
Left Expression
Operator TokenType // AND, OR
Right Expression
}
// pkg/rbac/ast.go:38-42
type UnaryExpr struct {
Operator TokenType // NOT
Operand Expression
}
// pkg/rbac/ast.go:50-55
type ComparisonExpr struct {
Left Expression
Operator TokenType // EQ, NEQ, GT, GTE, LT, LTE
Right Expression
}
// pkg/rbac/ast.go:78-83
type InExpr struct {
Left Expression
Right Expression
Negated bool // true for "not in"
}
Operand Nodes
// pkg/rbac/ast.go:94-104
type Reference struct {
Root string // "doc" or "user"
Path [] string // e.g., ["metadata", "category"]
}
// pkg/rbac/ast.go:111-114
type Literal struct {
Value interface {} // string, float64, bool, or nil
}
// pkg/rbac/ast.go:129-132
type ArrayExpr struct {
Elements [] Expression
}
Compilation Process
Step 1: Lexical Analysis
Tokenize the expression string:
// Input
expr := "doc.status == 'active' && doc.amount > 100"
// Tokens (pkg/rbac/lexer.go)
// IDENT(doc) DOT IDENT(status) EQ STRING(active) AND IDENT(doc) DOT IDENT(amount) GT NUMBER(100)
Step 2: Parsing
Build AST from tokens:
// pkg/rbac/parser.go:66-83
parser := NewParser ( expr )
ast , err := parser . Parse ()
// AST structure:
// BinaryExpr{
// Operator: AND,
// Left: ComparisonExpr{
// Left: Reference{Root: "doc", Path: ["status"]},
// Operator: EQ,
// Right: Literal{Value: "active"},
// },
// Right: ComparisonExpr{
// Left: Reference{Root: "doc", Path: ["amount"]},
// Operator: GT,
// Right: Literal{Value: 100.0},
// },
// }
Step 3: Compilation
Compile AST to MongoDB filter:
// pkg/rbac/compiler.go:65-81
compiler := NewCompiler ()
ctx := & CompileContext {
UserID : "user123" ,
TenantID : "tenant456" ,
}
filter , err := compiler . Compile ( expr , ctx )
// Result:
// bson.M{
// "$and": []bson.M{
// {"status": "active"},
// {"amount": bson.M{"$gt": 100}},
// },
// }
AST Caching
The compiler caches parsed ASTs to avoid re-parsing:
// pkg/rbac/compiler.go:36-38
type Compiler struct {
astCache sync . Map // thread-safe cache
}
// Automatic caching on Compile()
filter , err := compiler . Compile ( expr , ctx ) // Parses and caches
filter , err = compiler . Compile ( expr , ctx ) // Uses cached AST
// Cache management
size := compiler . CacheSize () // Number of cached expressions
compiler . ClearCache () // Clear on policy reload
Benefits:
10-100x faster for repeated expressions
Zero lock contention (using sync.Map)
Memory efficient - only unique expressions cached
Evaluation Modes
Compile-Time Evaluation (Query Mode)
Resolves user context at compile time, generates MongoDB filter:
// Used for: List/query operations
compiler := NewCompiler ()
ctx := & CompileContext { UserID : "user123" , TenantID : "tenant456" }
filter , err := compiler . Compile ( "doc.company_id == user.tenant_id" , ctx )
// → bson.M{"company_id": "tenant456"}
Runtime Evaluation (Document Mode)
Evaluates expression against specific document:
// Used for: Get/update operations with document access
engine := NewEngine ( policy )
doc := map [ string ] interface {}{
"company_id" : "tenant456" ,
"status" : "active" ,
}
allowed , err := engine . CheckDocumentAccess ( authCtx , "orders" , schema . Read , doc )
// → true (if expression matches document)
Expression Complexity
Keep expressions simple for best performance:
Use == and in operators (fastest)
Minimize logical operators (&&, ||)
Avoid deep nesting (>3 levels)
Prefer in over multiple || conditions
Good:
when : doc.status in ["active", "pending"] && doc.company_id == user.tenant_id
Avoid:
when : |
(doc.status == "active" || doc.status == "pending") &&
(doc.company_id == user.tenant_id ||
(doc.created_by == user.id && doc.shared == true))
Indexing Strategy
Create MongoDB indexes on fields used in when clauses:
// Create indexes for common RBAC filters
db . orders . createIndex ({ "company_id" : 1 , "status" : 1 })
db . orders . createIndex ({ "created_by" : 1 })
db . orders . createIndex ({ "team_members" : 1 }) // For "in" checks
Common Patterns
Tenant Isolation
when : doc.company_id == user.tenant_id
Creator Access
when : doc.created_by == user.id
Manager Access
when : doc.created_by in user.$subordinates
Team Member Access
when : user.id in doc.team_members
Combined Multi-Condition
when : |
doc.company_id == user.tenant_id &&
(doc.created_by == user.id ||
doc.created_by in user.$subordinates)
Error Handling
Parse Errors
ast , err := ParseExpression ( "doc.status = 'active'" ) // Invalid: single '='
// Error: parse error at position 11: expected ==, got = (token: =)
Compile Errors
filter , err := compiler . Compile ( "doc.field1 == doc.field2" , ctx )
// Error: document-to-document field comparison not yet supported
Runtime Evaluation Errors
allowed , err := engine . CheckDocumentAccess ( ctx , "orders" , schema . Read , doc )
// Error: unknown user field: invalid_field
Next Steps
Performance Tuning Optimize expression evaluation with AST caching
Caching Strategy Cache compiled expressions for high throughput