Access Control
ContentGrid uses Attribute-Based Access Control (ABAC) to enforce fine-grained permissions on data access. Instead of folder-based permissions or role hierarchies, policies evaluate entity attributes and user attributes to make authorization decisions.
Why ABAC?
Traditional ECM systems often tie permissions to folder structures. If a document is in a folder, you need permissions on that folder. This approach has significant limitations:
- Artificial Organization: Data must be organized into folder hierarchies even when that doesn’t match the domain model
- Rigid Permissions: Changing access requirements often requires reorganizing folders
- No Multi-Dimensional Access: Hard to express “users can see invoices from their own department AND invoices they created”
- Weak Scalability: Permission checks traverse folder hierarchies, becoming expensive with deep nesting
ContentGrid replaces this with ABAC, where permissions are based on attributes:
- User Attributes: Department, role, clearance level, location, etc.
- Entity Attributes: Status, owner, creation date, sensitivity level, etc.
- Request Context: Operation (read/write/delete)
Example Policy: “Users can view invoices where invoice.department == user.department OR invoice.status == 'published'”
This policy doesn’t care about folder structures, as it evaluates attributes directly. Changing the invoice’s attribute change the permissions, without having to explicitly change permissions.
Architecture Components
Open Policy Agent (OPA)
ContentGrid uses Open Policy Agent, an open-source policy engine, to evaluate access control policies. Policies are written in Rego, OPA’s policy language.
Why OPA?
- Industry Standard: Widely adopted in cloud-native environments
- Declarative Policies: Rego is declarative—you specify what should be allowed, not how to check it
- Partial Evaluation: OPA can return residual expressions when it cannot fully evaluate a policy (crucial for efficiency)
- Decoupled from Application: Policies live in Rego files, not scattered through application code
Rego Policies
Rego policies define authorization rules. Here’s a simplified example:
package contentgrid.invoices
import future.keywords
default can_read_invoice := false
# Allow reading invoices from the user's own department
can_read_invoice if {
input.entity.department == input.auth.principal.department
}
# Allow reading published invoices regardless of department
can_read_invoice if {
input.entity.status == "published"
}
allow if {
input.request.method == "GET"
# Path /invoices
count(input.request.path) == 1
input.request.path[0] == "invoices"
can_read_invoice == true
}When the application queries OPA, it provides:
- input.request.method: The HTTP method (GET, POST, PUT, DELETE)
- input.request.path: The path being accessed
- input.auth.principal: User attributes from the JWT
- input.entity: Entity object with attributes and relations (when checking a specific record)
OPA evaluates all allow rules. If any rule evaluates to true, access is granted.
Partial Evaluation
The key to ABAC efficiency in ContentGrid is OPA’s partial evaluation feature.
The Problem: When a user requests /invoices, the application needs to return only invoices the user can access.
Naively, you might:
- Load all invoices from the database
- For each invoice, query OPA: “Can this user access this invoice?”
- Filter out invoices where OPA says “no”
This is catastrophically inefficient. For 10,000 invoices, you’d make 10,000 OPA queries and transfer 10,000 invoices from the database just to filter most of them out.
The Solution: Partial evaluation allows OPA to evaluate policies with incomplete information.
The application queries OPA: “The user wants to see invoices. What filter should I apply?”
OPA evaluates the policy but doesn’t know which invoices exist. It returns a residual expression, a simplified policy that only contains the parts that could not be fully evaluated:
input.entity.department == "sales" OR input.entity.status == "published"The application translates this residual expression to a SQL WHERE clause:
SELECT *
FROM invoices
WHERE department = 'sales'
OR status = 'published'Now only authorized invoices leave the database. One OPA query, efficient SQL filtering and minimal data transfer.
Architecture
Centralized OPA
ContentGrid currently uses a shared OPA instance for all applications deployed in the Runtime Platform.
Components
- Gateway: Entry point for all requests, responsible for policy enforcement
- Centralized OPA: Shared OPA instance that evaluates policies for all applications
- Solon: Service that collects Rego policy files from all applications and bundles them for OPA
- Application: Serves its policy file and receives residual expressions from the Gateway
Request Flow
sequenceDiagram
autonumber
participant Client
participant Gateway
participant OPA as Centralized OPA
participant Solon
participant App as Application
participant DB as Database
Note over Solon, App: Policy Distribution Phase
App ->> Solon: Serve policy file (HTTP endpoint)
Solon ->> Solon: Bundle all policies
OPA ->> Solon: Download policy bundle
Note over Client, DB: Request Processing Phase
Client ->> Gateway: HTTP Request
Gateway ->> OPA: Authorization query
OPA -->> Gateway: Allow/Deny/Residual expression
Gateway ->> Gateway: Encode residual in JWT
Gateway ->> App: Forward request + JWT with residual
App ->> App: Decode residual from JWT
App ->> DB: Query with residual as filter
DB -->> App: Filtered results
App -->> Client: Response
How It Works:
- Policy Distribution: Solon collects Rego policies from all applications via HTTP endpoints and bundles them for the centralized OPA
- Request Processing: When a request arrives, the Gateway queries the centralized OPA for authorization
- Residual Encoding: OPA returns a residual expression that the Gateway encodes in a JWT
- Application Processing: The application decodes the residual from the JWT and applies it as a SQL filter
- Data Retrieval: Only authorized data is retrieved from the database
Policy Evaluation in Practice
The examples below illustrate key concepts for readers. While payloads may differ from real implementations, the underlying principles remain the same.
For collection queries (e.g., “What invoices can this user see?”), the application provides partial information:
{
"input": {
"method": "GET",
"entity": "invoices",
"user": {
"id": "user-123",
"department": "sales",
"role": "employee"
}
}
}Notice entity is missing—the application doesn’t know which invoices exist yet. OPA performs partial evaluation
and returns a residual expression:
{
"result": {
"or": [
{
"eq": [
{
"ref": [
"entity",
"department"
]
},
"sales"
]
},
{
"eq": [
{
"ref": [
"entity",
"status"
]
},
"published"
]
}
]
}
}The application translates this to SQL. The exact translation mechanism uses an internal query expression language called Thunx that maps to SQL WHERE clauses.
Policy Development Workflow
Policies are defined in the Management Platform alongside the data model:
- Define Permissions: In the Management Platform, you define permission rules as part of the application model
- Generate Rego: Scribe generates Rego policies from the permission definitions
- Bundle Policies: Rego policies are included in the application artifact
- Deploy: When the application starts, it makes them available for OPA (via Solon)
- Enforce: The Gateway queries OPA for every request, enforcing the policies
Policy changes follow the same deployment pipeline as model changes, and are deployed in tandem. Update permissions, regenerate artifact, redeploy application.
Performance Considerations
Query-Level Filtering: Pushing authorization filters to SQL ensures only authorized data leaves the database. This is dramatically more efficient than application-level filtering.
OPA Response Time: OPA policy evaluation is fast (microseconds to low milliseconds).
Database Indexes: Authorization filters often involve specific columns (e.g., department, owner). Proper indexes
on these columns ensure efficient query execution.
Security Benefits
Defense in Depth: Authorization happens at multiple layers:
- OPA evaluates policies before queries execute
- Database enforces SQL filters (data never loaded without authorization)
Principle of Least Privilege: ABAC naturally supports least-privilege models. Users only see data matching their attributes, and policies can be as granular as needed.
Dynamic Permissions: Attribute changes immediately affect permissions—no cache invalidation needed. If a user changes departments, their access automatically reflects the new department.
Summary
ContentGrid’s Attribute-Based Access Control provides fine-grained, efficient authorization:
- Flexible Policies: Based on entity and user attributes, not artificial folder hierarchies
- Efficient Enforcement: Partial evaluation pushes authorization filters to SQL
- Centralized Architecture: Shared OPA instance with policy collection via Solon
- Declarative Rego: Policies are readable, maintainable, and separate from application code
ABAC enables expressing complex authorization requirements naturally while maintaining query performance through intelligent filter pushdown.