Skip to main content

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

OperatorDescriptionExample
==Equaldoc.status == "active"
!=Not equaldoc.status != "deleted"
>Greater thandoc.amount > 1000
>=Greater than or equaldoc.amount >= 1000
<Less thandoc.priority < 5
<=Less than or equaldoc.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

OperatorDescriptionExample
&&Logical ANDdoc.status == "active" && doc.amount > 100
``Logical OR`doc.priority == 1doc.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

OperatorDescriptionExample
inValue in arraydoc.status in ["active", "pending"]
not inValue not in arrayuser.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:
FieldTypeDescription
user.idstringUser’s unique ID
user.tenant_idstringUser’s tenant/organization ID
user.roles[]stringUser’s assigned roles
user.claims.*anyCustom JWT claims

Special Variables

User hierarchy data for manager/subordinate relationships:
VariableTypeDescription
user.$subordinates[]stringAll subordinates (transitive)
user.$directReports[]stringDirect reports only
user.$ancestors[]stringAll 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)

Performance Optimization

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

Build docs developers (and LLMs) love