Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions .github/workflows/cerbos-app-test.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
name: Cerbos Tests

on:
push:
branches: [ master ]
pull_request:
branches: [ master ]

jobs:
test:
runs-on: ubuntu-latest

steps:
- uses: actions/checkout@v3

- name: Set up Python
uses: actions/setup-python@v4
with:
python-version: '3.x'

- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install pytest pytest-asyncio aiohttp

- name: Start services
run: |
./app-tests/run-cerbos-services.sh

- name: Run tests
run: |
pytest app-tests/cerbos-test.py -v

- name: Show service logs on failure
if: failure()
run: |
docker compose -f app-tests/docker-compose-app-tests-cerbos.yml logs

- name: Cleanup
if: always() # Run cleanup even if tests fail
run: |
./app-tests/clean-cerbos-services.sh
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -140,3 +140,4 @@ dmypy.json

# Private Claude Code working artifacts (plans/specs) — never commit
.claude/
/.mcp.json
4 changes: 4 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ help:
@echo " docker-run-client - Run opal-client in Docker"
@echo " docker-run-server - Run opal-server in Docker"
@echo " docker-build-client-eopa - Build opal-client-eopa Docker image"
@echo " docker-build-client-cerbos - Build opal-client-cerbos Docker image"

OPAL_SERVER_URL ?= http://host.docker.internal:7002
OPAL_AUTH_PRIVATE_KEY ?= /root/ssh/opal_rsa
Expand Down Expand Up @@ -68,6 +69,9 @@ docker-build-client-eopa:
docker-build-client-cedar:
@docker build -t permitio/opal-client-cedar --target client-cedar -f docker/Dockerfile .

docker-build-client-cerbos:
@docker build -t permitio/opal-client-cerbos --target client-cerbos -f docker/Dockerfile .

docker-build-client-standalone:
@docker build -t permitio/opal-client-standalone --target client-standalone -f docker/Dockerfile .

Expand Down
208 changes: 208 additions & 0 deletions app-tests/cerbos-test.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,208 @@
import json
from typing import Any, Dict, List

import aiohttp
import pytest

# Constants
CERBOS_URL = "http://localhost:3592"

# Test cases, mirroring the "User Role" / "Admin Role" suites from
# cerbos/example-cerbos-policy-repository's basicResource_test.yaml.
# Format: (principal_id, roles, resource_id, attrs, action, expected_allow, description)
RESOURCES = {
"resource1": {"ownerId": "sally", "isPublished": True},
"resource2": {"ownerId": "sally", "isPublished": True},
"resource3": {"ownerId": "sally", "isPublished": False},
}

TEST_CASES = [
# Admin can do everything
(
"ian",
["ADMIN"],
"resource1",
RESOURCES["resource1"],
"read",
True,
"Admin can read",
),
(
"ian",
["ADMIN"],
"resource1",
RESOURCES["resource1"],
"update",
True,
"Admin can update",
),
(
"ian",
["ADMIN"],
"resource3",
RESOURCES["resource3"],
"delete",
True,
"Admin can delete unpublished",
),
# Owner (sally) can do everything to her own resources
(
"sally",
["USER"],
"resource1",
RESOURCES["resource1"],
"read",
True,
"Owner can read",
),
(
"sally",
["USER"],
"resource1",
RESOURCES["resource1"],
"update",
True,
"Owner can update",
),
(
"sally",
["USER"],
"resource3",
RESOURCES["resource3"],
"delete",
True,
"Owner can delete unpublished own resource",
),
# Non-owner (frank) can read published resources but not modify them
(
"frank",
["USER"],
"resource1",
RESOURCES["resource1"],
"read",
True,
"Non-owner can read published resource",
),
(
"frank",
["USER"],
"resource1",
RESOURCES["resource1"],
"update",
False,
"Non-owner cannot update",
),
(
"frank",
["USER"],
"resource1",
RESOURCES["resource1"],
"delete",
False,
"Non-owner cannot delete",
),
# Non-owner cannot even read unpublished resources
(
"frank",
["USER"],
"resource3",
RESOURCES["resource3"],
"read",
False,
"Non-owner cannot read unpublished resource",
),
]


@pytest.fixture
async def http_client() -> aiohttp.ClientSession:
async with aiohttp.ClientSession() as client:
yield client


class CerbosApiClient:
"""Helper class for Cerbos check API interactions."""

def __init__(self, client: aiohttp.ClientSession):
self.client = client

async def check(
self,
principal_id: str,
roles: List[str],
resource_id: str,
resource_kind: str,
attrs: Dict[str, Any],
actions: List[str],
) -> Dict[str, Any]:
url = f"{CERBOS_URL}/api/check/resources"
payload = {
"principal": {"id": principal_id, "roles": roles},
"resources": [
{
"resource": {
"id": resource_id,
"kind": resource_kind,
"attr": attrs,
},
"actions": actions,
}
],
}
async with self.client.post(url, json=payload) as response:
response.raise_for_status()
result = await response.json()
print(f"\nCheck: {principal_id} {actions} {resource_id}")
print(f"Response: {json.dumps(result, indent=2)}")
return result


class TestCerbosPermissions:
"""Test suite for Cerbos permissions, against the real basicResource policy
from cerbos/example-cerbos-policy-repository."""

@pytest.fixture
async def cerbos_client(
self, http_client: aiohttp.ClientSession
) -> CerbosApiClient:
return CerbosApiClient(http_client)

@pytest.mark.parametrize(
"principal_id, roles, resource_id, attrs, action, expected_allow, description",
TEST_CASES,
)
async def test_permissions(
self,
cerbos_client: CerbosApiClient,
principal_id: str,
roles: List[str],
resource_id: str,
attrs: Dict[str, Any],
action: str,
expected_allow: bool,
description: str,
):
result = await cerbos_client.check(
principal_id, roles, resource_id, "basicResource", attrs, [action]
)
actions = result["results"][0]["actions"]
expected_effect = "EFFECT_ALLOW" if expected_allow else "EFFECT_DENY"
assert actions.get(action) == expected_effect, (
f"Test failed: {description}\n"
f"Principal: {principal_id}, Action: {action}, Resource: {resource_id}\n"
f"Expected: {expected_effect}, Got: {actions.get(action)}"
)

async def test_policy_synced(self, http_client: aiohttp.ClientSession):
"""Confirms OPAL actually pushed the real policy (not just that the
Cerbos PDP is up) - fails loudly if the policy sync silently no-op'd."""
async with http_client.get(
f"{CERBOS_URL}/admin/policies",
auth=aiohttp.BasicAuth("cerbos", "cerbosAdmin"),
) as response:
response.raise_for_status()
result = await response.json()
print("\nSynced policies:", result)
assert result.get(
"policyIds"
), "No policies found in Cerbos - sync did not happen"
9 changes: 9 additions & 0 deletions app-tests/clean-cerbos-services.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
#!/bin/bash

# Make paths below independent of the caller's cwd.
cd "$(dirname "$0")"

echo "Cleaning up services..."
docker compose -f docker-compose-app-tests-cerbos.yml down -v

echo "Cleanup complete"
62 changes: 62 additions & 0 deletions app-tests/docker-compose-app-tests-cerbos.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
name: opal-cerbos-app-tests

services:
broadcast_channel:
image: postgres:alpine
environment:
- POSTGRES_DB=postgres
- POSTGRES_USER=postgres
- POSTGRES_PASSWORD=postgres
networks:
- opal-network

opal_server:
image: permitio/opal-server:latest
environment:
- OPAL_BROADCAST_URI=postgres://postgres:postgres@broadcast_channel:5432/postgres
- UVICORN_NUM_WORKERS=4
- OPAL_POLICY_REPO_URL=https://github.com/cerbos/example-cerbos-policy-repository
- OPAL_POLICY_REPO_MAIN_BRANCH=main
# Two separate filters decide what lands in the bundle, and both must allow
# yaml. FILTER_FILE_EXTENSIONS picks which repo files are read at all;
# POLICY_REPO_POLICY_EXTENSIONS decides which of those count as policy
# modules. The published opal-server image defaults FILTER_FILE_EXTENSIONS
# to .rego/.json, so without this the Cerbos yaml policies are dropped
# before the second filter ever sees them.
- OPAL_FILTER_FILE_EXTENSIONS=.rego,.json,.yaml,.yml
- OPAL_POLICY_REPO_POLICY_EXTENSIONS=.yaml,.yml,.json
# Cerbos example repos ship test suites and engine config next to the real
# policies; neither is a policy document.
- OPAL_BUNDLE_IGNORE=*_test.yaml,.cerbos.yaml,.cerbos-hub.yaml
- OPAL_POLICY_REPO_POLLING_INTERVAL=30
- OPAL_DATA_CONFIG_SOURCES={"config":{"entries":[{"url":"http://opal_server:7002/policy-data","topics":["policy_data"],"dst_path":"/static"}]}}
- OPAL_LOG_FORMAT_INCLUDE_PID=true
ports:
- "7002:7002"
depends_on:
- broadcast_channel
networks:
- opal-network

opal_client_cerbos:
build:
context: ..
dockerfile: docker/Dockerfile
target: client-cerbos
environment:
- OPAL_SERVER_URL=http://opal_server:7002
- OPAL_LOG_FORMAT_INCLUDE_PID=true
ports:
- "7766:7000"
- "3592:3592"
depends_on:
- opal_server
networks:
- opal-network
volumes:
- ./wait-for-policy-bundle.sh:/opal/wait-for-policy-bundle.sh:ro
command: sh -c "exec ./wait-for.sh opal_server:7002 --timeout=40 -- sh -c './wait-for-policy-bundle.sh http://opal_server:7002 && exec ./start.sh'"

networks:
opal-network:
driver: bridge
45 changes: 45 additions & 0 deletions app-tests/run-cerbos-services.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
#!/bin/bash
set -e

# Make paths below independent of the caller's cwd.
cd "$(dirname "$0")"

echo "Building client-cerbos image..."
docker compose -f docker-compose-app-tests-cerbos.yml build

echo "Starting Cerbos and OPAL services..."
docker compose -f docker-compose-app-tests-cerbos.yml up -d

echo "Waiting for opal-client to finish syncing policy..."
ready=false
for _ in $(seq 1 30); do
if curl -sf http://localhost:7766/ready > /dev/null 2>&1; then
ready=true
break
fi
sleep 2
done

if [ "${ready}" != "true" ]; then
echo "opal-client did not become ready" >&2
docker compose -f docker-compose-app-tests-cerbos.yml logs
exit 1
fi

# /ready only confirms OPAL successfully PUT the policy to Cerbos's admin API -
# Cerbos's own storage layer (sqlite3 driver, file-watch reload) can briefly lag
# behind that write before the policy is actually queryable/enforceable. Poll
# Cerbos's own admin API directly so we don't start the tests during that gap.
echo "Waiting for Cerbos to index the synced policy..."
for _ in $(seq 1 30); do
if curl -sf -u cerbos:cerbosAdmin http://localhost:3592/admin/policies \
| python3 -c "import json, sys; sys.exit(0 if json.load(sys.stdin).get('policyIds') else 1)" 2>/dev/null; then
echo "Services ready"
exit 0
fi
sleep 1
done

echo "Cerbos did not index the synced policy" >&2
docker compose -f docker-compose-app-tests-cerbos.yml logs
exit 1
Loading
Loading