Skip to main content

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 records
  • create: Insert new records
  • update: Modify existing records
  • delete: Remove records
  • aggregate: Perform aggregations
  • export: 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 queries
  • FILTER_UPDATE: Limit which rows can be updated
  • FILTER_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:

  1. Does user have update permission?
  2. Does row 42 match authorization conditions?
  3. 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/sync or restart service