Data Service Authorization
Policy-based authorization with Cerbos for fine-grained access control and row-level security.
Overview
Taruvi Data Service uses Cerbos for policy-based authorization, providing:
- Fine-grained access control: Control who can do what on which data
- Row-level security: Users only see/modify rows they're authorized for
- Policy-as-code: Version-controlled, testable authorization rules
- Dynamic filtering: Automatic query filters based on user context
- Audit trail: Track authorization decisions
Key Concepts:
- Principal: The user making the request (with roles and attributes)
- Resource: The data being accessed (DataTable with metadata)
- Action: What's being done (read, create, update, delete)
- Policy: Rules that determine if action is allowed
- Effect: Result (ALLOW, DENY, FILTER_*)
Base URL: /api/data/policies/
Architecture
Authorization Flow
1. User Request
↓
2. JWT Authentication
↓
3. Authorization Middleware
↓
4. Cerbos Policy Check
↓
5. ALLOW / DENY / FILTER_*
↓
6. Query Execution (with filters if FILTER_*)
↓
7. Response
Principal + Resource + Action
Principal (Who is making the request):
{
"id": "user_123",
"roles": ["user", "editor"],
"attr": {
"user_id": 123,
"organization_id": 5,
"department": "engineering",
"is_manager": true
}
}
Resource (What is being accessed):
{
"kind": "datatable",
"id": "posts",
"attr": {
"app_id": "blog-app",
"owner_id": 456,
"status": "published",
"visibility": "public"
}
}
Action (What operation):
read: View recordscreate: Insert new recordsupdate: Modify existing recordsdelete: Remove recordsaggregate: Perform aggregationsexport: Export data
Policy Structure
Basic Policy
---
apiVersion: api.cerbos.dev/v1
resourcePolicy:
version: "default"
resource: "datatable"
rules:
- actions:
- read
effect: EFFECT_ALLOW
roles:
- user
- admin
- actions:
- create
- update
- delete
effect: EFFECT_ALLOW
roles:
- admin
Row-Level Security with Conditions
Use CEL (Common Expression Language) for conditions:
resourcePolicy:
resource: "datatable:posts"
rules:
# Users can read their own posts
- actions: [read]
effect: EFFECT_ALLOW
roles: [user]
condition:
match:
expr: request.principal.attr.user_id == resource.attr.owner_id
# Users can update only draft posts they own
- actions: [update]
effect: EFFECT_ALLOW
roles: [user]
condition:
match:
all:
of:
- expr: request.principal.attr.user_id == resource.attr.owner_id
- expr: resource.attr.status == "draft"
# Admins can do everything
- actions: [read, create, update, delete]
effect: EFFECT_ALLOW
roles: [admin]
FILTER Effects
Automatically inject WHERE clauses to limit visible rows:
derivedRoles:
name: "post_roles"
definitions:
- name: "owner"
parentRoles: ["user"]
condition:
match:
expr: request.principal.attr.user_id == resource.attr.owner_id
resourcePolicy:
resource: "datatable:posts"
rules:
# Regular users see only published posts or their own drafts
- actions: [read]
effect: FILTER_READ
roles: [user]
condition:
match:
expr: |
resource.attr.status == "published" ||
(resource.attr.status == "draft" && resource.attr.owner_id == request.principal.attr.user_id)
# Admins see everything
- actions: [read]
effect: EFFECT_ALLOW
roles: [admin]
FILTER Effects:
FILTER_READ: Inject filter on SELECT queriesFILTER_UPDATE: Limit which rows can be updatedFILTER_DELETE: Limit which rows can be deleted
Resulting SQL:
-- User request (user_id=123)
SELECT * FROM posts
WHERE status = 'published' OR (status = 'draft' AND owner_id = 123);
-- Admin request
SELECT * FROM posts;
Policy Management Endpoints
Create/Update Policy
POST /api/data/policies/resource
Content-Type: application/json
{
"policy": {
"apiVersion": "api.cerbos.dev/v1",
"resourcePolicy": {
"version": "default",
"resource": "datatable:users",
"rules": [
{
"actions": ["read"],
"effect": "EFFECT_ALLOW",
"roles": ["user", "admin"]
}
]
}
}
}
Response (201 Created):
{
"policy_id": "resource.datatable.users.vdefault",
"status": "created",
"synced": true
}
List Policies
GET /api/data/policies/
Response:
{
"policies": [
{
"id": "resource.datatable.posts.vdefault",
"resource": "datatable:posts",
"version": "default",
"rules_count": 5,
"created_at": "2024-01-15T10:30:00Z"
},
{
"id": "resource.datatable.users.vdefault",
"resource": "datatable:users",
"version": "default",
"rules_count": 3,
"created_at": "2024-01-16T14:20:00Z"
}
],
"total": 2
}
Get Policy
GET /api/data/policies/resource.datatable.posts.vdefault
Response:
{
"policy": {
"apiVersion": "api.cerbos.dev/v1",
"resourcePolicy": {
"version": "default",
"resource": "datatable:posts",
"rules": [...]
}
},
"metadata": {
"created_at": "2024-01-15T10:30:00Z",
"updated_at": "2024-01-20T09:15:00Z"
}
}
Delete Policy
DELETE /api/data/policies/resource.datatable.posts.vdefault
Response (204 No Content)
Bulk Create
POST /api/data/policies/bulk
Content-Type: application/json
{
"policies": [
{"resourcePolicy": {...}},
{"resourcePolicy": {...}},
{"derivedRoles": {...}}
]
}
Validate Policy
Test policy syntax without saving:
POST /api/data/policies/validate
Content-Type: application/json
{
"policy": {
"resourcePolicy": {...}
}
}
Response:
{
"valid": true,
"errors": []
}
Or if invalid:
{
"valid": false,
"errors": [
"Invalid CEL expression at line 15: unknown identifier 'resorce'",
"Missing required field: rules"
]
}
Health Check
GET /api/data/policies/health/
Response:
{
"status": "healthy",
"cerbos_connected": true,
"policy_count": 15,
"last_sync": "2024-01-20T10:00:00Z"
}
Sync Policies
Force synchronization with Cerbos:
GET /api/data/policies/sync
Policy Diff
Compare policy versions:
GET /api/data/policies/diff/resource.datatable.posts.vdefault?compare_to=v1
Export Policies
POST /api/data/policies/export
Content-Type: application/json
{
"format": "yaml",
"resources": ["datatable:posts", "datatable:users"]
}
Response: ZIP file with policy YAML files
Authorization Examples
Public Read, Owner Write
resourcePolicy:
resource: "datatable:posts"
rules:
# Anyone can read published posts
- actions: [read]
effect: EFFECT_ALLOW
roles: ["*"]
condition:
match:
expr: resource.attr.status == "published"
# Authors can CRUD their own posts
- actions: [read, update, delete]
effect: EFFECT_ALLOW
roles: [author]
condition:
match:
expr: request.principal.attr.user_id == resource.attr.owner_id
- actions: [create]
effect: EFFECT_ALLOW
roles: [author]
Manager Approval Workflow
resourcePolicy:
resource: "datatable:expense_reports"
rules:
# Employees can create and view their own
- actions: [create, read]
effect: EFFECT_ALLOW
roles: [employee]
condition:
match:
expr: request.principal.attr.user_id == resource.attr.employee_id
# Employees can update only pending reports
- actions: [update]
effect: EFFECT_ALLOW
roles: [employee]
condition:
match:
all:
of:
- expr: request.principal.attr.user_id == resource.attr.employee_id
- expr: resource.attr.status == "pending"
# Managers can approve/reject reports in their department
- actions: [read, update]
effect: EFFECT_ALLOW
roles: [manager]
condition:
match:
expr: request.principal.attr.department == resource.attr.department
Department-Based Access
resourcePolicy:
resource: "datatable:documents"
rules:
# Users see documents from their department
- actions: [read]
effect: FILTER_READ
roles: [user]
condition:
match:
expr: request.principal.attr.department in resource.attr.allowed_departments
# Cross-department access for shared documents
- actions: [read]
effect: EFFECT_ALLOW
roles: [user]
condition:
match:
expr: resource.attr.visibility == "shared"
Time-Based Access
resourcePolicy:
resource: "datatable:exams"
rules:
# Students can view exams only during active period
- actions: [read]
effect: EFFECT_ALLOW
roles: [student]
condition:
match:
all:
of:
- expr: timestamp(resource.attr.start_time) <= now()
- expr: timestamp(resource.attr.end_time) >= now()
# Instructors can always access
- actions: [read, create, update, delete]
effect: EFFECT_ALLOW
roles: [instructor]
Testing Policies
Unrestricted Policy for Testing
Create a wide-open policy for development:
GET /api/data/policies/resource/unrestricted?resource=datatable:test_table
Generates:
resourcePolicy:
resource: "datatable:test_table"
rules:
- actions: [read, create, update, delete, aggregate, export]
effect: EFFECT_ALLOW
roles: ["*"]
Testing Authorization
Use the validation endpoint to test decisions:
POST /api/data/policies/validate
Content-Type: application/json
{
"principal": {
"id": "user_123",
"roles": ["user"],
"attr": {"user_id": 123, "department": "engineering"}
},
"resource": {
"kind": "datatable:posts",
"id": "post_456",
"attr": {"owner_id": 123, "status": "draft"}
},
"actions": ["read", "update", "delete"]
}
Response:
{
"decisions": {
"read": {
"allowed": true,
"effect": "EFFECT_ALLOW"
},
"update": {
"allowed": true,
"effect": "EFFECT_ALLOW"
},
"delete": {
"allowed": false,
"effect": "EFFECT_DENY"
}
}
}
Integration with Data Endpoints
Authorization is automatically applied to all data endpoints:
Read with Filtering
GET /api/apps/blog/datatables/posts/data/
Authorization: Bearer <user_token>
Internally becomes:
SELECT * FROM posts
WHERE status = 'published' OR owner_id = <user_id>;
Update with Row-Level Check
PATCH /api/apps/blog/datatables/posts/data/42/
Authorization: Bearer <user_token>
{"status": "published"}
Before update, checks:
- Does user have
updatepermission? - Does row 42 match authorization conditions?
- If yes, proceed; if no, return 403 Forbidden
Create with Attribute Injection
POST /api/apps/blog/datatables/posts/data/
Authorization: Bearer <user_token>
{"title": "My Post", "content": "..."}
Automatically adds:
{
"title": "My Post",
"content": "...",
"owner_id": 123, // From JWT token
"created_by": 123
}
Common CEL Expressions
Principal Attributes
request.principal.id == "user_123"
request.principal.roles.contains("admin")
request.principal.attr.user_id == 123
request.principal.attr.department == "engineering"
request.principal.attr.is_manager == true
Resource Attributes
resource.attr.owner_id == 456
resource.attr.status == "published"
resource.attr.visibility in ["public", "shared"]
resource.attr.tags.contains("featured")
Combining Conditions
// AND
request.principal.attr.user_id == resource.attr.owner_id &&
resource.attr.status == "draft"
// OR
resource.attr.status == "published" ||
request.principal.roles.contains("admin")
// IN
request.principal.attr.department in resource.attr.allowed_departments
resource.attr.category in ["tech", "programming", "tutorial"]
Date/Time
timestamp(resource.attr.created_at) > timestamp("2024-01-01T00:00:00Z")
timestamp(resource.attr.expires_at) >= now()
duration(resource.attr.end_time - resource.attr.start_time) < duration("24h")
String Operations
resource.attr.email.endsWith("@company.com")
resource.attr.title.startsWith("Draft:")
resource.attr.description.contains("urgent")
resource.attr.name.size() > 10
Best Practices
1. Start with Deny-All, Explicitly Allow
# ❌ Don't rely on implicit deny
rules:
- actions: [update]
effect: EFFECT_ALLOW
roles: [admin]
# ✅ Do: Be explicit about what's allowed
rules:
- actions: [create, read, update, delete]
effect: EFFECT_DENY
roles: ["*"]
- actions: [read]
effect: EFFECT_ALLOW
roles: [user]
- actions: [create, read, update, delete]
effect: EFFECT_ALLOW
roles: [admin]
2. Use Roles Over Individual Checks
# ❌ Don't check individual user IDs in policies
condition:
match:
expr: request.principal.id in ["user_1", "user_2", "user_3"]
# ✅ Do: Use roles
condition:
match:
expr: request.principal.roles.contains("editor")
3. Test Policies Before Production
- Use validation endpoint
- Test with different user roles
- Verify FILTER_ effects generate correct SQL
- Check edge cases (null values, missing attributes)
4. Version Control Policies
# Store policies in Git
policies/
├── datatables/
│ ├── posts.yaml
│ ├── users.yaml
│ └── comments.yaml
├── derived_roles/
│ └── post_roles.yaml
└── README.md
5. Monitor Policy Performance
- Use Cerbos metrics
- Log authorization decisions
- Alert on frequent denials (may indicate misconfigured policy)
- Profile slow CEL expressions
6. Use Derived Roles for Complex Logic
derivedRoles:
name: "post_roles"
definitions:
- name: "owner"
parentRoles: [user]
condition:
match:
expr: request.principal.attr.user_id == resource.attr.owner_id
- name: "collaborator"
parentRoles: [user]
condition:
match:
expr: request.principal.attr.user_id in resource.attr.collaborator_ids
resourcePolicy:
resource: "datatable:posts"
rules:
- actions: [read, update]
effect: EFFECT_ALLOW
derivedRoles: [owner, collaborator] # Cleaner than duplicating conditions
7. Document Policy Intent
# Add comments to complex policies
resourcePolicy:
resource: "datatable:financial_reports"
rules:
# Finance team members can view all reports in their region
# Requirement: FIN-SEC-001
- actions: [read]
effect: FILTER_READ
roles: [finance_user]
condition:
match:
expr: |
resource.attr.region == request.principal.attr.assigned_region &&
resource.attr.fiscal_year >= request.principal.attr.clearance_year
Limitations
Policy Evaluation Performance
- Complex CEL expressions can slow authorization
- FILTER_ effects add WHERE clauses to every query
- Solution: Keep conditions simple, use indexes on filtered fields
Attribute Availability
- Resource attributes must be in query scope
- Can't access attributes from JOINed tables directly
- Solution: Denormalize critical authorization attributes
Dynamic Policies
- Policies are loaded at startup
- Changes require policy sync
- Solution: Use
GET /policies/syncor restart service
Related Documentation
- CRUD Operations - How authorization affects data operations
- Querying Guide - How FILTER_ effects modify queries
- Schema Management - Adding authorization metadata to schemas
- Policy Management API - Complete policy API reference
- Cerbos Documentation - Official Cerbos docs