diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c5c88ca..906a328 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -237,6 +237,107 @@ jobs: name: mpe-${{ matrix.goos }}-${{ matrix.goarch }} path: target/mpe-${{ matrix.goos }}-${{ matrix.goarch }}${{ matrix.goos == 'windows' && '.exe' || '' }} + integration-tests: + name: Integration Tests + runs-on: ubuntu-latest + needs: [go-build] + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Download Linux AMD64 binary + uses: actions/download-artifact@v4 + with: + name: mpe-linux-amd64 + path: target/ + + - name: Make binary executable + run: chmod +x target/mpe-linux-amd64 + + - name: Create symlink for tests + run: | + mkdir -p target + ln -sf mpe-linux-amd64 target/mpe + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.11' + cache: 'pip' + cache-dependency-path: requirements-test.txt + + - name: Install Python dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements-test.txt + + - name: Run pytest + run: | + pytest -v --tb=short --color=yes --junitxml=pytest-report.xml --alluredir=allure-results + continue-on-error: false + + - name: Install Allure + if: always() + run: | + curl -o allure-2.24.0.tgz -L https://github.com/allure-framework/allure2/releases/download/2.24.0/allure-2.24.0.tgz + tar -zxvf allure-2.24.0.tgz -C /opt/ + sudo ln -s /opt/allure-2.24.0/bin/allure /usr/bin/allure + + - name: Generate Allure Report + if: always() + run: | + allure generate allure-results -o allure-report --clean + + - name: Upload Allure Report + if: always() + uses: actions/upload-artifact@v4 + with: + name: allure-report + path: allure-report/ + + - name: Generate test summary + if: always() + run: | + echo "## ๐Ÿงช Python Integration Test Results" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + + if [ -f pytest-report.xml ]; then + # Extract test counts from JUnit XML + TESTS=$(grep -o 'tests="[0-9]*"' pytest-report.xml | head -1 | grep -o '[0-9]*') + FAILURES=$(grep -o 'failures="[0-9]*"' pytest-report.xml | head -1 | grep -o '[0-9]*') + ERRORS=$(grep -o 'errors="[0-9]*"' pytest-report.xml | head -1 | grep -o '[0-9]*') + + PASSED=$((TESTS - FAILURES - ERRORS)) + + echo "- **Total Tests:** ${TESTS}" >> $GITHUB_STEP_SUMMARY + echo "- **Passed:** โœ… ${PASSED}" >> $GITHUB_STEP_SUMMARY + + if [ "${FAILURES}" != "0" ] || [ "${ERRORS}" != "0" ]; then + echo "- **Failed:** โŒ $((FAILURES + ERRORS))" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "โŒ Some integration tests failed." >> $GITHUB_STEP_SUMMARY + else + echo "" >> $GITHUB_STEP_SUMMARY + echo "โœ… All ${TESTS} integration tests passed!" >> $GITHUB_STEP_SUMMARY + fi + + echo "" >> $GITHUB_STEP_SUMMARY + echo "๐Ÿ“Š **Allure Report:** Check artifacts for detailed test report" >> $GITHUB_STEP_SUMMARY + else + echo "โŒ No test results found" >> $GITHUB_STEP_SUMMARY + fi + + - name: Upload test results + if: always() + uses: actions/upload-artifact@v4 + with: + name: pytest-results + path: | + pytest-report.xml + allure-results/ + .pytest_cache/ + htmlcov/ + example-validation: name: Validate Example PolicyDomains runs-on: ubuntu-latest diff --git a/.gitignore b/.gitignore index 7f602fb..1090fe1 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,16 @@ target/ # Generated during artifact builds NOTICES +# Python +__pycache__/ +*.py[cod] +*.class +.pytest_cache/ +.venv-test/ +htmlcov/ +.coverage +coverage.out +pytest-report.xml +allure-results/ +allure-report/ +allure-history/ diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..990649a --- /dev/null +++ b/pytest.ini @@ -0,0 +1,38 @@ +[pytest] +# Pytest configuration for PolicyEngine + +# Test discovery patterns +python_files = test_*.py *_test.py +python_classes = Test* *Tests +python_functions = test_* + +# Test paths +testpaths = tests + +# Output options +addopts = + -v + --strict-markers + --tb=short + --color=yes + -ra + +# Markers for organizing tests +markers = + unit: Unit tests + integration: Integration tests requiring PolicyEngine service + smoke: Quick smoke tests + api: HTTP API tests + grpc: gRPC service tests + policy: PolicyDomain validation tests + slow: Tests that take significant time + +# Logging +log_cli = false +log_cli_level = INFO +log_cli_format = %(asctime)s [%(levelname)8s] %(message)s +log_cli_date_format = %Y-%m-%d %H:%M:%S + +# Coverage options (if pytest-cov is installed) +# [tool:pytest] +# addopts = --cov=tests --cov-report=html --cov-report=term diff --git a/requirements-test.txt b/requirements-test.txt new file mode 100644 index 0000000..e45ad70 --- /dev/null +++ b/requirements-test.txt @@ -0,0 +1,25 @@ +# Python testing dependencies for PolicyEngine + +# Core testing framework +pytest>=7.4.0 +pytest-asyncio>=0.21.0 +pytest-timeout>=2.1.0 +pytest-xdist>=3.3.0 # Parallel test execution + +# Coverage reporting +pytest-cov>=4.1.0 +allure-pytest>=2.13.0 + +# HTTP client for API testing +requests>=2.31.0 +httpx>=0.24.0 # Async HTTP client + +# gRPC client (if testing gRPC endpoints) +grpcio>=1.56.0 +grpcio-tools>=1.56.0 + +# YAML parsing (for PolicyDomain files) +PyYAML>=6.0 + +# Utilities +python-dotenv>=1.0.0 # Environment variable management diff --git a/scripts/run-tests.sh b/scripts/run-tests.sh new file mode 100755 index 0000000..3e2a728 --- /dev/null +++ b/scripts/run-tests.sh @@ -0,0 +1,100 @@ +#!/usr/bin/env bash +# +# Run PolicyEngine Python integration tests +# + +set -euo pipefail + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' # No Color + +# Script directory +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" + +echo -e "${GREEN}PolicyEngine Test Runner${NC}" +echo "================================" + +# Check if Python 3 is available +if ! command -v python3 &> /dev/null; then + echo -e "${RED}Error: python3 is not installed${NC}" + exit 1 +fi + +# Check if virtual environment exists, create if not +VENV_DIR="${PROJECT_ROOT}/.venv-test" +if [ ! -d "$VENV_DIR" ]; then + echo -e "${YELLOW}Creating Python virtual environment...${NC}" + python3 -m venv "$VENV_DIR" +fi + +# Activate virtual environment +echo -e "${YELLOW}Activating virtual environment...${NC}" +source "${VENV_DIR}/bin/activate" + +# Install/upgrade dependencies +echo -e "${YELLOW}Installing test dependencies...${NC}" +pip install -q --upgrade pip +pip install -q -r "${PROJECT_ROOT}/requirements-test.txt" + +# Build the mpe binary if it doesn't exist +if [ ! -f "${PROJECT_ROOT}/target/mpe" ]; then + echo -e "${YELLOW}Building mpe binary...${NC}" + cd "$PROJECT_ROOT" + make build +fi + +# Parse arguments +PYTEST_ARGS=() +RUN_INTEGRATION=false + +while [[ $# -gt 0 ]]; do + case $1 in + --integration|-i) + RUN_INTEGRATION=true + shift + ;; + --smoke|-s) + PYTEST_ARGS+=("-m" "smoke") + shift + ;; + --coverage|-c) + PYTEST_ARGS+=("--cov=tests" "--cov-report=html" "--cov-report=term") + shift + ;; + *) + PYTEST_ARGS+=("$1") + shift + ;; + esac +done + +# Default: skip integration tests unless explicitly requested +if [ "$RUN_INTEGRATION" = false ]; then + PYTEST_ARGS+=("-m" "not integration") +fi + +# Run pytest +echo -e "${GREEN}Running tests...${NC}" +echo "Command: pytest ${PYTEST_ARGS[*]}" +echo "" + +cd "$PROJECT_ROOT" +pytest "${PYTEST_ARGS[@]}" +TEST_EXIT_CODE=$? + +# Deactivate virtual environment +deactivate + +# Print results +echo "" +if [ $TEST_EXIT_CODE -eq 0 ]; then + echo -e "${GREEN}โœ… All tests passed!${NC}" +else + echo -e "${RED}โŒ Tests failed with exit code ${TEST_EXIT_CODE}${NC}" +fi + +exit $TEST_EXIT_CODE diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..3cac2bf --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1,12 @@ +# PolicyEngine Python Integration Tests +""" +Integration tests for the Manetu PolicyEngine. + +This test suite provides Python-based integration testing for: +- HTTP API endpoints +- gRPC service interfaces +- PolicyDomain validation +- End-to-end policy evaluation workflows +""" + +__version__ = "0.1.0" diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..f3cdad2 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,142 @@ +""" +Pytest configuration and shared fixtures for PolicyEngine tests. +""" + +import os +import subprocess +import time +from pathlib import Path +from typing import Generator + +import pytest + + +# Project root directory +PROJECT_ROOT = Path(__file__).parent.parent + + +@pytest.fixture(scope="session") +def project_root() -> Path: + """Return the project root directory.""" + return PROJECT_ROOT + + +@pytest.fixture(scope="session") +def testdata_dir(project_root: Path) -> Path: + """Return the testdata directory path.""" + return project_root / "testdata" + + +@pytest.fixture(scope="session") +def mpe_binary(project_root: Path) -> Path: + """ + Ensure the mpe binary is built and return its path. + + This fixture builds the PolicyEngine CLI if it doesn't exist. + """ + binary_path = project_root / "target" / "mpe" + + if not binary_path.exists(): + print("\n๐Ÿ”จ Building mpe binary...") + subprocess.run( + ["make", "build"], + cwd=project_root, + check=True, + capture_output=True + ) + + assert binary_path.exists(), "Failed to build mpe binary" + return binary_path + + +@pytest.fixture +def sample_policy_domain(testdata_dir: Path) -> Path: + """Return path to a sample PolicyDomain YAML file.""" + policy_file = testdata_dir / "mpe-config.yaml" + assert policy_file.exists(), f"Sample policy not found: {policy_file}" + return policy_file + + +@pytest.fixture(scope="session") +def mpe_server_port() -> int: + """Return the port for the MPE server during tests.""" + return int(os.getenv("MPE_TEST_PORT", "9090")) + + +@pytest.fixture(scope="function") +def mpe_server( + mpe_binary: Path, + sample_policy_domain: Path, + mpe_server_port: int +) -> Generator[str, None, None]: + """ + Start a PolicyEngine server for integration tests. + + Yields: + Base URL of the running server (e.g., "http://localhost:9090") + """ + server_url = f"http://localhost:{mpe_server_port}" + + # Start the server + print(f"\n๐Ÿš€ Starting MPE server on {server_url}...") + process = subprocess.Popen( + [ + str(mpe_binary), + "serve", + "--bundle", str(sample_policy_domain), + "--http-port", str(mpe_server_port), + ], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + cwd=PROJECT_ROOT + ) + + # Wait for server to be ready + max_attempts = 30 + for attempt in range(max_attempts): + try: + import requests + response = requests.get(f"{server_url}/health", timeout=1) + if response.status_code == 200: + print(f"โœ… MPE server ready after {attempt + 1} attempts") + break + except Exception: + time.sleep(0.5) + else: + process.kill() + raise RuntimeError("MPE server failed to start within timeout") + + yield server_url + + # Cleanup + print("\n๐Ÿ›‘ Stopping MPE server...") + process.terminate() + try: + process.wait(timeout=5) + except subprocess.TimeoutExpired: + process.kill() + + +@pytest.fixture +def sample_principal() -> dict: + """Return a sample principal for testing authorization.""" + return { + "id": "user-123", + "email": "test@example.com", + "roles": ["admin"], + "attributes": { + "department": "engineering", + "clearance": "high" + } + } + + +@pytest.fixture +def sample_resource() -> dict: + """Return a sample resource for testing authorization.""" + return { + "id": "resource-456", + "type": "document", + "owner": "user-123", + "classification": "confidential" + } diff --git a/tests/mpe/examples/test_bad_rego_yml.py b/tests/mpe/examples/test_bad_rego_yml.py new file mode 100644 index 0000000..f0219be --- /dev/null +++ b/tests/mpe/examples/test_bad_rego_yml.py @@ -0,0 +1,63 @@ +import json +import os +import pytest +import allure +import subprocess +from pathlib import Path + +from tests.utils.mpe_runner import run_mpe_decision +from tests.utils.allure_helpers import attach_payload, attach_bundle_yaml + +BASE_DIR = Path(__file__).resolve().parents[3] + +JSON_PAYLOAD_DIR = BASE_DIR / "tests" / "test_data" / "api" / "payloads" / "porc_json" +YAML_BUNDLE_FILE = BASE_DIR / "tests" / "test_data" / "api" / "payloads" / "yml" / "bad_rego.yml" + +EXPECTED_ERRORS = [ + "rego compilation failed", + "package expected", + "var cannot be used for rule name" +] + +def load_payload(filename): + filepath = JSON_PAYLOAD_DIR / filename + if not filepath.exists(): + pytest.fail(f"JSON payload file not found: {filepath}") + with open(filepath, "r") as f: + return json.load(f) + +# ๐Ÿšจ BAD REGO TEST: Parametrized with different JSON payloads +bad_rego_test_cases = [ + "valid_admin.json", + "admin_with_write_api_scope.json", + "access_denied_when_role_is_null.json", + "access_allowed_when_scope_is_null.json", + "valid_admin_with_multiple_scopes_allows_access.json", + "non_admin_with_read_scope_is_denied_access.json", + "access_denied_with_invalid_scope_format.json", + "access_denied_with_additional_but_irrelevant_scopes.json", + "access_denied_for_unauthorized_http_method_operation.json", + "access_allowed_for_authorized_http_method_operation.json" +] + +@pytest.mark.parametrize("filename", bad_rego_test_cases, ids=[os.path.splitext(f)[0] for f in bad_rego_test_cases]) +def test_bad_rego_yml_fails_with_expected_errors(filename): + payload = load_payload(filename) + + if not YAML_BUNDLE_FILE.exists(): + pytest.fail(f"YAML bundle file not found: {YAML_BUNDLE_FILE}") + + # Attach payload and YAML to Allure + attach_payload(payload) + attach_bundle_yaml(YAML_BUNDLE_FILE) + + try: + run_mpe_decision(payload, YAML_BUNDLE_FILE, allow_error=False) + pytest.fail("Expected policy compilation to fail, but it succeeded.") + except subprocess.CalledProcessError as e: + stderr = e.stderr.decode() if isinstance(e.stderr, bytes) else e.stderr + # Attach stderr to Allure + allure.attach(stderr, name="MPE Compilation Error", attachment_type=allure.attachment_type.TEXT) + + for expected_error in EXPECTED_ERRORS: + assert expected_error in stderr, f"Missing expected error: '{expected_error}'" diff --git a/tests/mpe/examples/test_broken_alpha_yml.py b/tests/mpe/examples/test_broken_alpha_yml.py new file mode 100644 index 0000000..b269144 --- /dev/null +++ b/tests/mpe/examples/test_broken_alpha_yml.py @@ -0,0 +1,69 @@ +import json +import pytest +import allure +import subprocess +from pathlib import Path + +from tests.utils.mpe_runner import run_mpe_decision +from tests.utils.allure_helpers import attach_payload, attach_bundle_yaml, attach_output + +BASE_DIR = Path(__file__).resolve().parents[3] + +# Paths +JSON_PAYLOAD_DIR = BASE_DIR / "tests" / "test_data" / "api" / "payloads" / "porc_json" +YAML_BUNDLE_FILE = BASE_DIR / "tests" / "test_data" / "api" / "payloads" / "yml" / "broken_alpha.yml" + +# Expected error messages from broken_alpha.yml +EXPECTED_ERRORS = [ + "validation failed", + "undefined references", + "not found", + "cycle" +] + +# JSON files to test with the broken YAML +json_test_files = [ + "valid_admin.json", + "admin_with_write_api_scope.json", + "access_denied_when_role_is_null.json", + "access_allowed_when_scope_is_null.json", + "valid_admin_with_multiple_scopes_allows_access.json", + "non_admin_with_read_scope_is_denied_access.json", + "access_denied_with_invalid_scope_format.json", + "access_denied_with_additional_but_irrelevant_scopes.json", + "access_denied_for_unauthorized_http_method_operation.json", + "access_allowed_for_authorized_http_method_operation.json" +] + +def load_payload(filename): + filepath = JSON_PAYLOAD_DIR / filename + if not filepath.exists(): + pytest.fail(f"JSON payload file not found: {filepath}") + with open(filepath, "r") as f: + return json.load(f) + +@pytest.mark.parametrize("filename", json_test_files, ids=[f.replace(".json", "") for f in json_test_files]) +@allure.tag("iam", "Policy Engine", "Broken YAML", "Cycle Detection", "broken_alpha") +def test_broken_alpha_fails_with_expected_errors(filename): + payload = load_payload(filename) + + if not YAML_BUNDLE_FILE.exists(): + pytest.fail(f"YAML bundle not found: {YAML_BUNDLE_FILE}") + + # Attach artifacts for debugging + attach_payload(payload) + attach_bundle_yaml(YAML_BUNDLE_FILE) + + try: + result = run_mpe_decision(payload, YAML_BUNDLE_FILE, allow_error=False) + # If it succeeded, that's a failure in this test context + pytest.fail("Expected MPE to fail due to broken YAML, but it succeeded.") + except subprocess.CalledProcessError as e: + stdout = e.stdout.decode() if isinstance(e.stdout, bytes) else (e.stdout or "") + stderr = e.stderr.decode() if isinstance(e.stderr, bytes) else (e.stderr or "") + combined_output = stdout + "\n" + stderr + attach_output(combined_output or "No output captured from mpe command.") + + for expected_error in EXPECTED_ERRORS: + assert expected_error.lower() in combined_output.lower(), \ + f"Expected error '{expected_error}' not found in MPE output" diff --git a/tests/mpe/examples/test_broken_beta_yml.py b/tests/mpe/examples/test_broken_beta_yml.py new file mode 100644 index 0000000..ea494c3 --- /dev/null +++ b/tests/mpe/examples/test_broken_beta_yml.py @@ -0,0 +1,71 @@ +import json +import pytest +import allure +import subprocess +from pathlib import Path + +from tests.utils.mpe_runner import run_mpe_decision +from tests.utils.allure_helpers import attach_payload, attach_bundle_yaml, attach_output + +BASE_DIR = Path(__file__).resolve().parents[3] + +# === File locations === +JSON_PAYLOAD_DIR = BASE_DIR / "tests" / "test_data" / "api" / "payloads" / "porc_json" +YAML_BUNDLE_FILE = BASE_DIR / "tests" / "test_data" / "api" / "payloads" / "yml" / "broken_beta.yml" + +# === Expected errors for broken_beta.yml === +EXPECTED_ERRORS = [ + "validation failed", + "undefined references to domain 'alpha'", + "undefined references to domain 'gamma'", + "role reference 'mrn:iam:role:nonexistent' not found", + "policy reference 'mrn:iam:policy:missing' not found", + "cycle" +] + +# === Payloads to test === +json_test_files = [ + "valid_admin.json", + "admin_with_write_api_scope.json", + "access_denied_when_role_is_null.json", + "access_allowed_when_scope_is_null.json", + "valid_admin_with_multiple_scopes_allows_access.json", + "non_admin_with_read_scope_is_denied_access.json", + "access_denied_with_invalid_scope_format.json", + "access_denied_with_additional_but_irrelevant_scopes.json", + "access_denied_for_unauthorized_http_method_operation.json", + "access_allowed_for_authorized_http_method_operation.json" +] + +def load_payload(filename): + filepath = JSON_PAYLOAD_DIR / filename + if not filepath.exists(): + pytest.fail(f"JSON payload file not found: {filepath}") + with open(filepath, "r") as f: + return json.load(f) + +@pytest.mark.parametrize("filename", json_test_files, ids=[f.replace(".json", "") for f in json_test_files]) +@allure.tag("iam", "Policy Engine", "Broken YAML", "Reference Errors", "broken_beta") +def test_broken_beta_fails_with_expected_errors(filename): + payload = load_payload(filename) + + if not YAML_BUNDLE_FILE.exists(): + pytest.fail(f"YAML bundle not found: {YAML_BUNDLE_FILE}") + + # Attach files for Allure + attach_payload(payload) + attach_bundle_yaml(YAML_BUNDLE_FILE) + + try: + result = run_mpe_decision(payload, YAML_BUNDLE_FILE, allow_error=False) + pytest.fail("Expected MPE to fail due to broken YAML, but it succeeded.") + except subprocess.CalledProcessError as e: + stdout = e.stdout.decode() if isinstance(e.stdout, bytes) else (e.stdout or "") + stderr = e.stderr.decode() if isinstance(e.stderr, bytes) else (e.stderr or "") + combined_output = stdout + "\n" + stderr + + attach_output(combined_output or "No output captured from mpe command.") + + for expected_error in EXPECTED_ERRORS: + assert expected_error.lower() in combined_output.lower(), \ + f"Expected error '{expected_error}' not found in MPE output" diff --git a/tests/mpe/examples/test_broken_policy_reference_yml.py b/tests/mpe/examples/test_broken_policy_reference_yml.py new file mode 100644 index 0000000..038ad04 --- /dev/null +++ b/tests/mpe/examples/test_broken_policy_reference_yml.py @@ -0,0 +1,67 @@ +import json +import pytest +import allure +import subprocess +from pathlib import Path + +from tests.utils.mpe_runner import run_mpe_decision +from tests.utils.allure_helpers import attach_payload, attach_bundle_yaml, attach_output + +BASE_DIR = Path(__file__).resolve().parents[3] + +# === File locations === +JSON_PAYLOAD_DIR = BASE_DIR / "tests" / "test_data" / "api" / "payloads" / "porc_json" +YAML_BUNDLE_FILE = BASE_DIR / "tests" / "test_data" / "api" / "payloads" / "yml" / "invalid_policy_reference.yml" + +# === Expected error messages === +EXPECTED_ERRORS = [ + "missing-lib", + "not found in domain", + "invalid-policy", + "library reference", + +] + +# === JSON files to test === +json_test_files = [ + "valid_admin.json", + "admin_with_write_api_scope.json", + "access_denied_when_role_is_null.json", + "access_allowed_when_scope_is_null.json", + "valid_admin_with_multiple_scopes_allows_access.json", + "non_admin_with_read_scope_is_denied_access.json", + "access_denied_with_invalid_scope_format.json", + "access_denied_with_additional_but_irrelevant_scopes.json", + "access_denied_for_unauthorized_http_method_operation.json", + "access_allowed_for_authorized_http_method_operation.json" +] + +def load_payload(filename): + filepath = JSON_PAYLOAD_DIR / filename + if not filepath.exists(): + pytest.fail(f"JSON payload file not found: {filepath}") + with open(filepath, "r") as f: + return json.load(f) + +@pytest.mark.parametrize("filename", json_test_files, ids=[f.replace(".json", "") for f in json_test_files]) +@allure.tag("iam", "Policy Engine", "Broken YAML", "Invalid Policy Reference", "invalid_policy_reference") +def test_invalid_policy_reference_fails_with_expected_errors(filename): + payload = load_payload(filename) + + if not YAML_BUNDLE_FILE.exists(): + pytest.fail(f"YAML bundle not found: {YAML_BUNDLE_FILE}") + + # Allure attachments + attach_payload(payload) + attach_bundle_yaml(YAML_BUNDLE_FILE) + + try: + result = run_mpe_decision(payload, YAML_BUNDLE_FILE, allow_error=False) + pytest.fail("Expected MPE to fail due to invalid policy reference, but it succeeded.") + except subprocess.CalledProcessError as e: + combined_output = (e.stdout or "") + "\n" + (e.stderr or "") + attach_output(combined_output or "No output captured from mpe command.") + + for expected_error in EXPECTED_ERRORS: + assert expected_error.lower() in combined_output.lower(), \ + f"Expected error '{expected_error}' not found in MPE output" diff --git a/tests/mpe/examples/test_consolidated_yml.py b/tests/mpe/examples/test_consolidated_yml.py new file mode 100644 index 0000000..904f703 --- /dev/null +++ b/tests/mpe/examples/test_consolidated_yml.py @@ -0,0 +1,71 @@ +import json +import os +import pytest +import allure +from pathlib import Path + +from tests.utils.mpe_runner import run_mpe_decision, handle_mpe_failure, normalize_decision +from tests.utils.allure_helpers import attach_payload, attach_bundle_yaml, attach_output + +BASE_DIR = Path(__file__).resolve().parents[3] + +JSON_PAYLOAD_DIR = BASE_DIR / "tests" / "test_data" / "api" / "payloads" / "porc_json" +YAML_BUNDLE_FILE = BASE_DIR / "tests" / "test_data" / "api" / "payloads" / "yml" / "consolidated.yml" + +def load_payload(filename): + filepath = JSON_PAYLOAD_DIR / filename + if not filepath.exists(): + pytest.fail(f"JSON payload file not found: {filepath}") + with open(filepath, "r") as f: + return json.load(f) + +def run_and_validate_policy_test(filename, expected_allow: bool): + payload = load_payload(filename) + + if not YAML_BUNDLE_FILE.exists(): + pytest.fail(f"Bundle YAML file not found: {YAML_BUNDLE_FILE}") + + result = run_mpe_decision(payload, YAML_BUNDLE_FILE, allow_error=False) + + attach_payload(payload) + attach_bundle_yaml(YAML_BUNDLE_FILE) + attach_output(result.stdout) + + try: + response = json.loads(result.stdout) + except json.JSONDecodeError: + pytest.fail("Output is not valid JSON") + + decision = normalize_decision(response.get("decision")) + references = response.get("references", []) + + deny_refs = [r for r in references if normalize_decision(r.get("decision")) == "DENY"] + if expected_allow and deny_refs: + allure.attach(json.dumps(deny_refs, indent=2), name="Denied References", attachment_type=allure.attachment_type.JSON) + pytest.fail("Expected allow, but deny references found") + + if expected_allow: + assert decision == "GRANT", f"Expected allow (GRANT), got: {decision}" + else: + assert decision != "GRANT", f"Expected deny, but got allow" + +test_cases = [ + ("valid_admin.json", True), + ("admin_with_write_api_scope.json", False), + ("access_denied_when_role_is_null.json", False), + ("access_allowed_when_scope_is_null.json", True), + ("valid_admin_with_multiple_scopes_allows_access.json", True), + ("non_admin_with_read_scope_is_denied_access.json", False), + ("access_denied_with_invalid_scope_format.json", False), + ("access_denied_with_additional_but_irrelevant_scopes.json", False), + ("access_denied_for_unauthorized_http_method_operation.json", False), + ("access_allowed_for_authorized_http_method_operation.json", True) +] + +@pytest.mark.parametrize( + "filename,expected_allow", + test_cases, + ids=[os.path.splitext(name)[0] for name, _ in test_cases] +) +def test_policy_decision(filename, expected_allow): + run_and_validate_policy_test(filename, expected_allow) \ No newline at end of file diff --git a/tests/mpe/examples/test_malformed_yml.py b/tests/mpe/examples/test_malformed_yml.py new file mode 100644 index 0000000..54ea95c --- /dev/null +++ b/tests/mpe/examples/test_malformed_yml.py @@ -0,0 +1,73 @@ +import json +import pytest +import allure +import subprocess +from pathlib import Path + +from tests.utils.mpe_runner import run_mpe_decision, normalize_decision +from tests.utils.allure_helpers import attach_payload, attach_bundle_yaml, attach_output + +BASE_DIR = Path(__file__).resolve().parents[3] + +# === File locations === +JSON_PAYLOAD_DIR = BASE_DIR / "tests" / "test_data" / "api" / "payloads" / "porc_json" +YAML_BUNDLE_FILE = BASE_DIR / "tests" / "test_data" / "api" / "payloads" / "yml" / "malformed_bundle.yml" + +# === Expected YAML syntax errors === +EXPECTED_ERRORS = [ + "yaml", + "expected ':'" +] + +# === JSON payloads to run === +json_test_files = [ + "valid_admin.json", + "admin_with_write_api_scope.json", + "access_denied_when_role_is_null.json", + "access_allowed_when_scope_is_null.json", + "valid_admin_with_multiple_scopes_allows_access.json", + "non_admin_with_read_scope_is_denied_access.json", + "access_denied_with_invalid_scope_format.json", + "access_denied_with_additional_but_irrelevant_scopes.json", + "access_denied_for_unauthorized_http_method_operation.json", + "access_allowed_for_authorized_http_method_operation.json" +] + +def load_payload(filename): + filepath = JSON_PAYLOAD_DIR / filename + if not filepath.exists(): + pytest.fail(f"JSON payload file not found: {filepath}") + with open(filepath, "r") as f: + return json.load(f) + +@pytest.mark.parametrize("filename", json_test_files, ids=[f.replace(".json", "") for f in json_test_files]) +@allure.tag("iam", "Policy Engine", "Broken YAML", "Malformed YAML", "malformed_bundle") +def test_malformed_yaml_fails_with_expected_errors(filename): + payload = load_payload(filename) + + if not YAML_BUNDLE_FILE.exists(): + pytest.fail(f"YAML bundle not found: {YAML_BUNDLE_FILE}") + result = run_mpe_decision(payload, YAML_BUNDLE_FILE, allow_error=False) + attach_payload(payload) + attach_bundle_yaml(YAML_BUNDLE_FILE) + attach_output(result.stdout) + + # try: + + # pytest.fail("Expected MPE to fail due to malformed YAML, but it succeeded.") + # except subprocess.CalledProcessError as e: + # combined_output = (e.stdout or "") + "\n" + (e.stderr or "") + # attach_output(combined_output or "No output captured from mpe command.") + + # for expected_error in EXPECTED_ERRORS: + # assert expected_error.lower() in combined_output.lower(), \ + # f"Expected error '{expected_error}' not found in MPE output" + + try: + response = json.loads(result.stdout) + except json.JSONDecodeError: + pytest.fail("MPE output is not valid JSON") + + # Correct assertion + decision = normalize_decision(response["decision"]) + assert decision == "DENY", f"Expected DENY, got: {decision}" diff --git a/tests/mpe/examples/test_missing_role_reference_yml.py b/tests/mpe/examples/test_missing_role_reference_yml.py new file mode 100644 index 0000000..a7f0373 --- /dev/null +++ b/tests/mpe/examples/test_missing_role_reference_yml.py @@ -0,0 +1,71 @@ +import json +import pytest +import allure +import subprocess +from pathlib import Path + +from tests.utils.mpe_runner import run_mpe_decision, normalize_decision +from tests.utils.allure_helpers import attach_payload, attach_bundle_yaml, attach_output + +BASE_DIR = Path(__file__).resolve().parents[3] + +# === File locations === +JSON_PAYLOAD_DIR = BASE_DIR / "tests" / "test_data" / "api" / "payloads" / "porc_json" +YAML_BUNDLE_FILE = BASE_DIR / "tests" / "test_data" / "api" / "payloads" / "yml" / "missing_role_reference.yml" + +# === Expected Errors === +EXPECTED_ERRORS = [ + "role", + "not found" +] + +# === JSON payloads to test === +json_test_files = [ + "valid_admin.json", + "admin_with_write_api_scope.json", + "access_denied_when_role_is_null.json", + "access_allowed_when_scope_is_null.json", + "valid_admin_with_multiple_scopes_allows_access.json", + "non_admin_with_read_scope_is_denied_access.json", + "access_denied_with_invalid_scope_format.json", + "access_denied_with_additional_but_irrelevant_scopes.json", + "access_denied_for_unauthorized_http_method_operation.json", + "access_allowed_for_authorized_http_method_operation.json" +] + +def load_payload(filename): + filepath = JSON_PAYLOAD_DIR / filename + if not filepath.exists(): + pytest.fail(f"JSON payload file not found: {filepath}") + with open(filepath, "r") as f: + return json.load(f) + +@pytest.mark.parametrize("filename", json_test_files, ids=[f.replace(".json", "") for f in json_test_files]) +@allure.tag("iam", "Policy Engine", "Broken YAML", "Missing Role Reference", "missing_role_reference") +def test_missing_role_reference_fails_with_expected_errors(filename): + payload = load_payload(filename) + + if not YAML_BUNDLE_FILE.exists(): + pytest.fail(f"YAML bundle not found: {YAML_BUNDLE_FILE}") + + attach_payload(payload) + attach_bundle_yaml(YAML_BUNDLE_FILE) + + try: + result = run_mpe_decision(payload, YAML_BUNDLE_FILE, allow_error=False) + response = json.loads(result.stdout) + decision = normalize_decision(response.get("decision")) + attach_output(result.stdout) + + assert decision == "DENY", f"Expected decision=DENY (deny) due to missing role, but got: {decision}" + + except subprocess.CalledProcessError as e: + combined_output = (e.stdout or "") + "\n" + (e.stderr or "") + attach_output(combined_output or "No output captured from mpe command.") + + for expected_error in EXPECTED_ERRORS: + assert expected_error.lower() in combined_output.lower(), \ + f"Expected error '{expected_error}' not found in MPE output" + + except json.JSONDecodeError: + pytest.fail("MPE output is not valid JSON") diff --git a/tests/mpe/examples/test_mixed_invalid_yml.py b/tests/mpe/examples/test_mixed_invalid_yml.py new file mode 100644 index 0000000..3e5341a --- /dev/null +++ b/tests/mpe/examples/test_mixed_invalid_yml.py @@ -0,0 +1,72 @@ +import json +import pytest +import allure +import subprocess +from pathlib import Path + +from tests.utils.mpe_runner import run_mpe_decision +from tests.utils.allure_helpers import attach_payload, attach_bundle_yaml, attach_output + +BASE_DIR = Path(__file__).resolve().parents[3] + +# === File locations === +JSON_PAYLOAD_DIR = BASE_DIR / "tests" / "test_data" / "api" / "payloads" / "porc_json" +YAML_BUNDLE_FILE = BASE_DIR / "tests" / "test_data" / "api" / "payloads" / "yml" / "mixed_invalid.yml" + +# === Expected Errors === +EXPECTED_ERRORS = [ + "validation failed", + "library reference 'mrn:iam:library:missing' not found", + "policy reference 'mrn:iam:policy:missing' not found" +] + +# === JSON payloads to test === +json_test_files = [ + "valid_admin.json", + "admin_with_write_api_scope.json", + "access_denied_when_role_is_null.json", + "access_allowed_when_scope_is_null.json", + "valid_admin_with_multiple_scopes_allows_access.json", + "non_admin_with_read_scope_is_denied_access.json", + "access_denied_with_invalid_scope_format.json", + "access_denied_with_additional_but_irrelevant_scopes.json", + "access_denied_for_unauthorized_http_method_operation.json", + "access_allowed_for_authorized_http_method_operation.json" +] + +def load_payload(filename): + filepath = JSON_PAYLOAD_DIR / filename + if not filepath.exists(): + pytest.fail(f"JSON payload file not found: {filepath}") + with open(filepath, "r") as f: + return json.load(f) + +@pytest.mark.parametrize("filename", json_test_files, ids=[f.replace(".json", "") for f in json_test_files]) +@allure.tag("iam", "Policy Engine", "Invalid Domain", "Reference Errors", "mixed_invalid") +def test_mixed_invalid_fails_with_expected_errors(filename): + payload = load_payload(filename) + + if not YAML_BUNDLE_FILE.exists(): + pytest.fail(f"YAML bundle not found: {YAML_BUNDLE_FILE}") + + attach_payload(payload) + attach_bundle_yaml(YAML_BUNDLE_FILE) + + try: + result = run_mpe_decision(payload, YAML_BUNDLE_FILE, allow_error=False) + output = result.stdout + attach_output(output) + + # Try parsing output even if broken, in case decision is available + try: + response = json.loads(output) + decision = response.get("decision") + assert decision == "DENY", f"Expected decision=2 (deny) due to broken references, got: {decision}" + except json.JSONDecodeError: + pytest.fail("Output is not valid JSON") + + except subprocess.CalledProcessError as e: + combined_output = (e.stdout or "") + "\n" + (e.stderr or "") + attach_output(combined_output or "No output captured from mpe command.") + for expected_error in EXPECTED_ERRORS: + assert expected_error.lower() in combined_output.lower(), f"Expected error '{expected_error}' not found in output" diff --git a/tests/mpe/examples/test_mixed_valid_yml.py b/tests/mpe/examples/test_mixed_valid_yml.py new file mode 100644 index 0000000..6a225a1 --- /dev/null +++ b/tests/mpe/examples/test_mixed_valid_yml.py @@ -0,0 +1,70 @@ +import json +import pytest +import allure +import subprocess +from pathlib import Path + +from tests.utils.mpe_runner import run_mpe_decision, normalize_decision +from tests.utils.allure_helpers import attach_payload, attach_bundle_yaml, attach_output + +BASE_DIR = Path(__file__).resolve().parents[3] + +# Paths to JSONs and YAML +JSON_PAYLOAD_DIR = BASE_DIR / "tests" / "test_data" / "api" / "payloads" / "porc_json" +YAML_BUNDLE_FILE = BASE_DIR / "tests" / "test_data" / "api" / "payloads" / "yml" / "mixed_valid.yml" + +# Expected errors for this test +EXPECTED_ERRORS = [ + "operation not found", + "bundle not found" +] + +# All JSON payloads to test +json_test_files = [ + "valid_admin.json", + "admin_with_write_api_scope.json", + "access_denied_when_role_is_null.json", + "access_allowed_when_scope_is_null.json", + "valid_admin_with_multiple_scopes_allows_access.json", + "non_admin_with_read_scope_is_denied_access.json", + "access_denied_with_invalid_scope_format.json", + "access_denied_with_additional_but_irrelevant_scopes.json", + "access_denied_for_unauthorized_http_method_operation.json", + "access_allowed_for_authorized_http_method_operation.json" +] + +def load_payload(filename): + filepath = JSON_PAYLOAD_DIR / filename + if not filepath.exists(): + pytest.fail(f"JSON payload file not found: {filepath}") + with open(filepath, "r") as f: + return json.load(f) + +@pytest.mark.parametrize("filename", json_test_files, ids=[f.replace(".json", "") for f in json_test_files]) +@allure.tag("iam", "Policy Engine", "Mixed Bundle", "Access Denied", "Missing References", "Mixed_Valid") +def test_mixed_valid_with_multiple_jsons(filename): + payload = load_payload(filename) + + if not YAML_BUNDLE_FILE.exists(): + pytest.fail(f"YAML bundle not found: {YAML_BUNDLE_FILE}") + + attach_payload(payload) + attach_bundle_yaml(YAML_BUNDLE_FILE) + + try: + result = run_mpe_decision(payload, YAML_BUNDLE_FILE, allow_error=False) + output = result.stdout + attach_output(output) + + try: + response = json.loads(output) + decision = normalize_decision(response.get("decision")) + assert decision == "DENY", f"Expected decision=DENY (deny), got: {decision}" + except json.JSONDecodeError: + pytest.fail("MPE output is not valid JSON") + + except subprocess.CalledProcessError as e: + combined_output = (e.stdout or "") + "\n" + (e.stderr or "") + attach_output(combined_output or "No output captured from mpe command.") + for expected_error in EXPECTED_ERRORS: + assert expected_error.lower() in combined_output.lower(), f"Expected error '{expected_error}' not found in output" diff --git a/tests/mpe/examples/test_multiple_reference_errors_yml.py b/tests/mpe/examples/test_multiple_reference_errors_yml.py new file mode 100644 index 0000000..8b371fb --- /dev/null +++ b/tests/mpe/examples/test_multiple_reference_errors_yml.py @@ -0,0 +1,77 @@ +import json +import pytest +import allure +import subprocess +from pathlib import Path + +from tests.utils.mpe_runner import run_mpe_decision +from tests.utils.allure_helpers import attach_payload, attach_bundle_yaml, attach_output + +BASE_DIR = Path(__file__).resolve().parents[3] + +# Separate paths for JSON and YAML +JSON_PAYLOAD_DIR = BASE_DIR / "tests" / "test_data" / "api" / "payloads" / "porc_json" +YAML_BUNDLE_FILE = BASE_DIR / "tests" / "test_data" / "api" / "payloads" / "yml" / "multi_error.yml" + +# List of JSON test files +json_test_files = [ + "valid_admin.json", + "admin_with_write_api_scope.json", + "access_denied_when_role_is_null.json", + "access_allowed_when_scope_is_null.json", + "valid_admin_with_multiple_scopes_allows_access.json", + "non_admin_with_read_scope_is_denied_access.json", + "access_denied_with_invalid_scope_format.json", + "access_denied_with_additional_but_irrelevant_scopes.json", + "access_denied_for_unauthorized_http_method_operation.json", + "access_allowed_for_authorized_http_method_operation.json" +] + +# Expected error strings +EXPECTED_ERRORS = [ + "validation failed", + "library reference 'mrn:iam:library:missing1' not found", + "library reference 'mrn:iam:library:missing2' not found", + "undefined references to domain 'other-domain'", + "policy reference 'mrn:iam:policy:missing1' not found", + "policy reference 'mrn:iam:policy:missing2' not found", + "policy reference 'mrn:iam:policy:missing3' not found", + "role reference 'mrn:iam:role:missing1' not found", + "role reference 'mrn:iam:role:missing2' not found" +] + +def load_payload(filename): + filepath = JSON_PAYLOAD_DIR / filename + if not filepath.exists(): + pytest.fail(f"JSON payload file not found: {filepath}") + with open(filepath, "r") as f: + return json.load(f) + +@pytest.mark.parametrize("filename", json_test_files, ids=[f.replace(".json", "") for f in json_test_files]) +@allure.tag("iam", "Policy Engine", "Multi Reference Errors") +def test_multiple_reference_errors_with_json_variants(filename): + payload = load_payload(filename) + + if not YAML_BUNDLE_FILE.exists(): + pytest.fail(f"YAML bundle not found: {YAML_BUNDLE_FILE}") + + attach_payload(payload) + attach_bundle_yaml(YAML_BUNDLE_FILE) + + try: + result = run_mpe_decision(payload, YAML_BUNDLE_FILE, allow_error=False) + output = result.stdout + attach_output(output) + + try: + response = json.loads(output) + decision = response.get("decision") + assert decision == "DENY", f"Expected decision=DENY (deny), got: {decision}" + except json.JSONDecodeError: + pytest.fail("MPE output is not valid JSON") + + except subprocess.CalledProcessError as e: + combined_output = (e.stdout or "") + "\n" + (e.stderr or "") + attach_output(combined_output or "No output captured from mpe command.") + for expected_error in EXPECTED_ERRORS: + assert expected_error.lower() in combined_output.lower(), f"Expected error '{expected_error}' not found in output" diff --git a/tests/mpe/examples/test_valid_alpha_yml.py b/tests/mpe/examples/test_valid_alpha_yml.py new file mode 100644 index 0000000..a5fff40 --- /dev/null +++ b/tests/mpe/examples/test_valid_alpha_yml.py @@ -0,0 +1,71 @@ +import json +import pytest +import allure +import subprocess +from pathlib import Path + +from tests.utils.mpe_runner import run_mpe_decision, normalize_decision +from tests.utils.allure_helpers import attach_payload, attach_bundle_yaml, attach_output + +BASE_DIR = Path(__file__).resolve().parents[3] + +# Paths to test data +JSON_PAYLOAD_DIR = BASE_DIR / "tests" / "test_data" / "api" / "payloads" / "porc_json" +YAML_BUNDLE_FILE = BASE_DIR / "tests" / "test_data" / "api" / "payloads" / "yml" / "valid_alpha.yml" + +# List of JSON test files +json_test_files = [ + "valid_admin.json", + "admin_with_write_api_scope.json", + "access_denied_when_role_is_null.json", + "access_allowed_when_scope_is_null.json", + "valid_admin_with_multiple_scopes_allows_access.json", + "non_admin_with_read_scope_is_denied_access.json", + "access_denied_with_invalid_scope_format.json", + "access_denied_with_additional_but_irrelevant_scopes.json", + "access_denied_for_unauthorized_http_method_operation.json", + "access_allowed_for_authorized_http_method_operation.json" +] + +# Expected error output strings +EXPECTED_ERRORS = [ + "unexpected phase1 result: true", + "bundle not found" +] + +def load_payload(filename): + filepath = JSON_PAYLOAD_DIR / filename + if not filepath.exists(): + pytest.fail(f"JSON payload file not found: {filepath}") + with open(filepath, "r") as f: + return json.load(f) + +@pytest.mark.parametrize("filename", json_test_files, ids=[f.replace(".json", "") for f in json_test_files]) +@allure.tag("iam", "Valid Alpha", "Unexpected Rego Result", "Bundle Not Found") +def test_valid_alpha_with_multiple_payloads(filename): + payload = load_payload(filename) + + if not YAML_BUNDLE_FILE.exists(): + pytest.fail(f"YAML bundle not found: {YAML_BUNDLE_FILE}") + + attach_payload(payload) + attach_bundle_yaml(YAML_BUNDLE_FILE) + + try: + result = run_mpe_decision(payload, YAML_BUNDLE_FILE, allow_error=False) + output = result.stdout + attach_output(output) + + try: + response = json.loads(output) + decision = normalize_decision(response.get("decision")) + assert decision == 'DENY', f"Expected decision=DENY (deny), got: {decision}" + + except json.JSONDecodeError: + pytest.fail("MPE output is not valid JSON") + + except subprocess.CalledProcessError as e: + combined_output = (e.stdout or "") + "\n" + (e.stderr or "") + attach_output(combined_output or "No output captured from mpe command.") + for expected_error in EXPECTED_ERRORS: + assert expected_error.lower() in combined_output.lower(), f"Expected error '{expected_error}' not found in output" diff --git a/tests/test_data/api/payloads/porc_json/access_allowed_for_authorized_http_method_operation.json b/tests/test_data/api/payloads/porc_json/access_allowed_for_authorized_http_method_operation.json new file mode 100644 index 0000000..e6d3eec --- /dev/null +++ b/tests/test_data/api/payloads/porc_json/access_allowed_for_authorized_http_method_operation.json @@ -0,0 +1,78 @@ +{ + "principal": { + "aud": "manetu.io", + "mclearance": "MAXIMUM", + "mrealm": "1234", + "mroles": [ + "mrn:iam:role:admin" + ], + "scopes":[ + "mrn:iam:scope:api" + ], + "sub": "mrn:iam:1234:identity:5678" + }, + "operation": "petstore:http:post", + "resource": { + "id": "http://petstore/api/v3/pet/findByStatus?status=available", + "group": "mrn:iam:resource-group:allow-all" + }, + "context": { + "destination": { + "address": { + "Address": { + "SocketAddress": { + "PortSpecifier": { + "PortValue": 8080 + }, + "address": "192.168.194.60" + } + } + }, + "principal": "spiffe://cluster.local/ns/default/sa/petstore" + }, + "metadata_context": {}, + "request": { + "http": { + "headers": { + ":authority": "petstore.k8s.orb.local", + ":method": "GET", + ":path": "/api/v3/pet/findByStatus?status=available", + ":scheme": "http", + "accept": "application/xml", + "authorization": "Bearer eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJtcm46aWFtOjEyMzQ6aWRlbnRpdHk6NTY3OCIsIm1yZWFsbSI6IjEyMzQiLCJhdWQiOiJtYW5ldHUuaW8iLCJtcm9sZXMiOlsibXJuOmlhbTpyb2xlOmFkbWluIl0sIm1jbGVhcmFuY2UiOiJNQVhJTVVNIiwic2NvcGVzIjpbIm1ybjppYW06c2NvcGU6cmVhZC1hcGkiXX0.M7r2pH08rJF6cXrHSHAIXS72syrXb3z0qNN3NPa_uBU", + "user-agent": "curl/8.7.1", + "x-envoy-attempt-count": "1", + "x-envoy-internal": "true", + "x-forwarded-client-cert": "By=spiffe://cluster.local/ns/default/sa/petstore;Hash=71d0657c0a7cb514a9da810875859edd1b339fb95192f77db9fd1f84db038126;Subject=\"\";URI=spiffe://cluster.local/ns/istio-system/sa/istio-ingressgateway-service-account", + "x-forwarded-for": "192.168.194.1", + "x-forwarded-proto": "http", + "x-request-id": "f5fed997-94c2-482c-8cde-52c4e1dddef3" + }, + "host": "petstore.k8s.orb.local", + "id": "17554297361488606879", + "method": "GET", + "path": "/api/v3/pet/findByStatus?status=available", + "protocol": "HTTP/1.1", + "scheme": "http" + }, + "time": { + "nanos": 782232000, + "seconds": 1738935521 + } + }, + "route_metadata_context": {}, + "source": { + "address": { + "Address": { + "SocketAddress": { + "PortSpecifier": { + "PortValue": 47768 + }, + "address": "192.168.194.48" + } + } + }, + "principal": "spiffe://cluster.local/ns/istio-system/sa/istio-ingressgateway-service-account" + } + } +} \ No newline at end of file diff --git a/tests/test_data/api/payloads/porc_json/access_allowed_when_scope_is_null.json b/tests/test_data/api/payloads/porc_json/access_allowed_when_scope_is_null.json new file mode 100644 index 0000000..1935199 --- /dev/null +++ b/tests/test_data/api/payloads/porc_json/access_allowed_when_scope_is_null.json @@ -0,0 +1,76 @@ +{ + "principal": { + "aud": "manetu.io", + "mclearance": "MAXIMUM", + "mrealm": "1234", + "mroles": [ + "mrn:iam:role:admin" + ], + "scopes": [], + "sub": "mrn:iam:1234:identity:5678" + }, + "operation": "petstore:http:get", + "resource": { + "id": "http://petstore/api/v3/pet/findByStatus?status=available", + "group": "mrn:iam:resource-group:allow-all" + }, + "context": { + "destination": { + "address": { + "Address": { + "SocketAddress": { + "PortSpecifier": { + "PortValue": 8080 + }, + "address": "192.168.194.60" + } + } + }, + "principal": "spiffe://cluster.local/ns/default/sa/petstore" + }, + "metadata_context": {}, + "request": { + "http": { + "headers": { + ":authority": "petstore.k8s.orb.local", + ":method": "GET", + ":path": "/api/v3/pet/findByStatus?status=available", + ":scheme": "http", + "accept": "application/xml", + "authorization": "Bearer eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJtcm46aWFtOjEyMzQ6aWRlbnRpdHk6NTY3OCIsIm1yZWFsbSI6IjEyMzQiLCJhdWQiOiJtYW5ldHUuaW8iLCJtcm9sZXMiOlsibXJuOmlhbTpyb2xlOmFkbWluIl0sIm1jbGVhcmFuY2UiOiJNQVhJTVVNIiwic2NvcGVzIjpbIm1ybjppYW06c2NvcGU6cmVhZC1hcGkiXX0.M7r2pH08rJF6cXrHSHAIXS72syrXb3z0qNN3NPa_uBU", + "user-agent": "curl/8.7.1", + "x-envoy-attempt-count": "1", + "x-envoy-internal": "true", + "x-forwarded-client-cert": "By=spiffe://cluster.local/ns/default/sa/petstore;Hash=71d0657c0a7cb514a9da810875859edd1b339fb95192f77db9fd1f84db038126;Subject=\"\";URI=spiffe://cluster.local/ns/istio-system/sa/istio-ingressgateway-service-account", + "x-forwarded-for": "192.168.194.1", + "x-forwarded-proto": "http", + "x-request-id": "f5fed997-94c2-482c-8cde-52c4e1dddef3" + }, + "host": "petstore.k8s.orb.local", + "id": "17554297361488606879", + "method": "GET", + "path": "/api/v3/pet/findByStatus?status=available", + "protocol": "HTTP/1.1", + "scheme": "http" + }, + "time": { + "nanos": 782232000, + "seconds": 1738935521 + } + }, + "route_metadata_context": {}, + "source": { + "address": { + "Address": { + "SocketAddress": { + "PortSpecifier": { + "PortValue": 47768 + }, + "address": "192.168.194.48" + } + } + }, + "principal": "spiffe://cluster.local/ns/istio-system/sa/istio-ingressgateway-service-account" + } + } +} \ No newline at end of file diff --git a/tests/test_data/api/payloads/porc_json/access_denied_for_unauthorized_http_method_operation.json b/tests/test_data/api/payloads/porc_json/access_denied_for_unauthorized_http_method_operation.json new file mode 100644 index 0000000..0c733dc --- /dev/null +++ b/tests/test_data/api/payloads/porc_json/access_denied_for_unauthorized_http_method_operation.json @@ -0,0 +1,78 @@ +{ + "principal": { + "aud": "manetu.io", + "mclearance": "MAXIMUM", + "mrealm": "1234", + "mroles": [ + "mrn:iam:role:admin" + ], + "scopes":[ + "mrn:iam:scope:read-api" + ], + "sub": "mrn:iam:1234:identity:5678" + }, + "operation": "petstore:http:post", + "resource": { + "id": "http://petstore/api/v3/pet/findByStatus?status=available", + "group": "mrn:iam:resource-group:allow-all" + }, + "context": { + "destination": { + "address": { + "Address": { + "SocketAddress": { + "PortSpecifier": { + "PortValue": 8080 + }, + "address": "192.168.194.60" + } + } + }, + "principal": "spiffe://cluster.local/ns/default/sa/petstore" + }, + "metadata_context": {}, + "request": { + "http": { + "headers": { + ":authority": "petstore.k8s.orb.local", + ":method": "POST", + ":path": "/api/v3/pet/findByStatus?status=available", + ":scheme": "http", + "accept": "application/xml", + "authorization": "Bearer eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJtcm46aWFtOjEyMzQ6aWRlbnRpdHk6NTY3OCIsIm1yZWFsbSI6IjEyMzQiLCJhdWQiOiJtYW5ldHUuaW8iLCJtcm9sZXMiOlsibXJuOmlhbTpyb2xlOmFkbWluIl0sIm1jbGVhcmFuY2UiOiJNQVhJTVVNIiwic2NvcGVzIjpbIm1ybjppYW06c2NvcGU6cmVhZC1hcGkiXX0.M7r2pH08rJF6cXrHSHAIXS72syrXb3z0qNN3NPa_uBU", + "user-agent": "curl/8.7.1", + "x-envoy-attempt-count": "1", + "x-envoy-internal": "true", + "x-forwarded-client-cert": "By=spiffe://cluster.local/ns/default/sa/petstore;Hash=71d0657c0a7cb514a9da810875859edd1b339fb95192f77db9fd1f84db038126;Subject=\"\";URI=spiffe://cluster.local/ns/istio-system/sa/istio-ingressgateway-service-account", + "x-forwarded-for": "192.168.194.1", + "x-forwarded-proto": "http", + "x-request-id": "f5fed997-94c2-482c-8cde-52c4e1dddef3" + }, + "host": "petstore.k8s.orb.local", + "id": "17554297361488606879", + "method": "GET", + "path": "/api/v3/pet/findByStatus?status=available", + "protocol": "HTTP/1.1", + "scheme": "http" + }, + "time": { + "nanos": 782232000, + "seconds": 1738935521 + } + }, + "route_metadata_context": {}, + "source": { + "address": { + "Address": { + "SocketAddress": { + "PortSpecifier": { + "PortValue": 47768 + }, + "address": "192.168.194.48" + } + } + }, + "principal": "spiffe://cluster.local/ns/istio-system/sa/istio-ingressgateway-service-account" + } + } +} \ No newline at end of file diff --git a/tests/test_data/api/payloads/porc_json/access_denied_when_role_is_null.json b/tests/test_data/api/payloads/porc_json/access_denied_when_role_is_null.json new file mode 100644 index 0000000..ffd7019 --- /dev/null +++ b/tests/test_data/api/payloads/porc_json/access_denied_when_role_is_null.json @@ -0,0 +1,76 @@ +{ + "principal": { + "aud": "manetu.io", + "mclearance": "MAXIMUM", + "mrealm": "1234", + "mroles": [], + "scopes": [ + "mrn:iam:scope:read-api" + ], + "sub": "mrn:iam:1234:identity:5678" + }, + "operation": "petstore:http:get", + "resource": { + "id": "http://petstore/api/v3/pet/findByStatus?status=available", + "group": "mrn:iam:resource-group:allow-all" + }, + "context": { + "destination": { + "address": { + "Address": { + "SocketAddress": { + "PortSpecifier": { + "PortValue": 8080 + }, + "address": "192.168.194.60" + } + } + }, + "principal": "spiffe://cluster.local/ns/default/sa/petstore" + }, + "metadata_context": {}, + "request": { + "http": { + "headers": { + ":authority": "petstore.k8s.orb.local", + ":method": "GET", + ":path": "/api/v3/pet/findByStatus?status=available", + ":scheme": "http", + "accept": "application/xml", + "authorization": "Bearer eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJtcm46aWFtOjEyMzQ6aWRlbnRpdHk6NTY3OCIsIm1yZWFsbSI6IjEyMzQiLCJhdWQiOiJtYW5ldHUuaW8iLCJtcm9sZXMiOlsibXJuOmlhbTpyb2xlOmFkbWluIl0sIm1jbGVhcmFuY2UiOiJNQVhJTVVNIiwic2NvcGVzIjpbIm1ybjppYW06c2NvcGU6cmVhZC1hcGkiXX0.M7r2pH08rJF6cXrHSHAIXS72syrXb3z0qNN3NPa_uBU", + "user-agent": "curl/8.7.1", + "x-envoy-attempt-count": "1", + "x-envoy-internal": "true", + "x-forwarded-client-cert": "By=spiffe://cluster.local/ns/default/sa/petstore;Hash=71d0657c0a7cb514a9da810875859edd1b339fb95192f77db9fd1f84db038126;Subject=\"\";URI=spiffe://cluster.local/ns/istio-system/sa/istio-ingressgateway-service-account", + "x-forwarded-for": "192.168.194.1", + "x-forwarded-proto": "http", + "x-request-id": "f5fed997-94c2-482c-8cde-52c4e1dddef3" + }, + "host": "petstore.k8s.orb.local", + "id": "17554297361488606879", + "method": "GET", + "path": "/api/v3/pet/findByStatus?status=available", + "protocol": "HTTP/1.1", + "scheme": "http" + }, + "time": { + "nanos": 782232000, + "seconds": 1738935521 + } + }, + "route_metadata_context": {}, + "source": { + "address": { + "Address": { + "SocketAddress": { + "PortSpecifier": { + "PortValue": 47768 + }, + "address": "192.168.194.48" + } + } + }, + "principal": "spiffe://cluster.local/ns/istio-system/sa/istio-ingressgateway-service-account" + } + } +} \ No newline at end of file diff --git a/tests/test_data/api/payloads/porc_json/access_denied_with_additional_but_irrelevant_scopes.json b/tests/test_data/api/payloads/porc_json/access_denied_with_additional_but_irrelevant_scopes.json new file mode 100644 index 0000000..5669886 --- /dev/null +++ b/tests/test_data/api/payloads/porc_json/access_denied_with_additional_but_irrelevant_scopes.json @@ -0,0 +1,79 @@ +{ + "principal": { + "aud": "manetu.io", + "mclearance": "MAXIMUM", + "mrealm": "1234", + "mroles": [ + "mrn:iam:role:admin" + ], + "scopes":[ + "mrn:iam:scope:not-present", + "mrn:iam:scope:doesnt-exist" + ], + "sub": "mrn:iam:1234:identity:5678" + }, + "operation": "petstore:http:get", + "resource": { + "id": "http://petstore/api/v3/pet/findByStatus?status=available", + "group": "mrn:iam:resource-group:allow-all" + }, + "context": { + "destination": { + "address": { + "Address": { + "SocketAddress": { + "PortSpecifier": { + "PortValue": 8080 + }, + "address": "192.168.194.60" + } + } + }, + "principal": "spiffe://cluster.local/ns/default/sa/petstore" + }, + "metadata_context": {}, + "request": { + "http": { + "headers": { + ":authority": "petstore.k8s.orb.local", + ":method": "GET", + ":path": "/api/v3/pet/findByStatus?status=available", + ":scheme": "http", + "accept": "application/xml", + "authorization": "Bearer eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJtcm46aWFtOjEyMzQ6aWRlbnRpdHk6NTY3OCIsIm1yZWFsbSI6IjEyMzQiLCJhdWQiOiJtYW5ldHUuaW8iLCJtcm9sZXMiOlsibXJuOmlhbTpyb2xlOmFkbWluIl0sIm1jbGVhcmFuY2UiOiJNQVhJTVVNIiwic2NvcGVzIjpbIm1ybjppYW06c2NvcGU6cmVhZC1hcGkiXX0.M7r2pH08rJF6cXrHSHAIXS72syrXb3z0qNN3NPa_uBU", + "user-agent": "curl/8.7.1", + "x-envoy-attempt-count": "1", + "x-envoy-internal": "true", + "x-forwarded-client-cert": "By=spiffe://cluster.local/ns/default/sa/petstore;Hash=71d0657c0a7cb514a9da810875859edd1b339fb95192f77db9fd1f84db038126;Subject=\"\";URI=spiffe://cluster.local/ns/istio-system/sa/istio-ingressgateway-service-account", + "x-forwarded-for": "192.168.194.1", + "x-forwarded-proto": "http", + "x-request-id": "f5fed997-94c2-482c-8cde-52c4e1dddef3" + }, + "host": "petstore.k8s.orb.local", + "id": "17554297361488606879", + "method": "GET", + "path": "/api/v3/pet/findByStatus?status=available", + "protocol": "HTTP/1.1", + "scheme": "http" + }, + "time": { + "nanos": 782232000, + "seconds": 1738935521 + } + }, + "route_metadata_context": {}, + "source": { + "address": { + "Address": { + "SocketAddress": { + "PortSpecifier": { + "PortValue": 47768 + }, + "address": "192.168.194.48" + } + } + }, + "principal": "spiffe://cluster.local/ns/istio-system/sa/istio-ingressgateway-service-account" + } + } +} diff --git a/tests/test_data/api/payloads/porc_json/access_denied_with_invalid_scope_format.json b/tests/test_data/api/payloads/porc_json/access_denied_with_invalid_scope_format.json new file mode 100644 index 0000000..ee96db0 --- /dev/null +++ b/tests/test_data/api/payloads/porc_json/access_denied_with_invalid_scope_format.json @@ -0,0 +1,80 @@ +{ + "principal": { + "aud": "manetu.io", + "mclearance": "MAXIMUM", + "mrealm": "1234", + "mroles": [ + "mrn:iam:role:user" + ], + "scopes": [ + "read-api", + "scope::read_api", + "mrn::read-api" + ], + "sub": "mrn:iam:1234:identity:5678" + }, + "operation": "petstore:http:get", + "resource": { + "id": "http://petstore/api/v3/pet/findByStatus?status=available", + "group": "mrn:iam:resource-group:allow-all" + }, + "context": { + "destination": { + "address": { + "Address": { + "SocketAddress": { + "PortSpecifier": { + "PortValue": 8080 + }, + "address": "192.168.194.60" + } + } + }, + "principal": "spiffe://cluster.local/ns/default/sa/petstore" + }, + "metadata_context": {}, + "request": { + "http": { + "headers": { + ":authority": "petstore.k8s.orb.local", + ":method": "GET", + ":path": "/api/v3/pet/findByStatus?status=available", + ":scheme": "http", + "accept": "application/xml", + "authorization": "Bearer eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJtcm46aWFtOjEyMzQ6aWRlbnRpdHk6NTY3OCIsIm1yZWFsbSI6IjEyMzQiLCJhdWQiOiJtYW5ldHUuaW8iLCJtcm9sZXMiOlsibXJuOmlhbTpyb2xlOmFkbWluIl0sIm1jbGVhcmFuY2UiOiJNQVhJTVVNIiwic2NvcGVzIjpbIm1ybjppYW06c2NvcGU6cmVhZC1hcGkiXX0.M7r2pH08rJF6cXrHSHAIXS72syrXb3z0qNN3NPa_uBU", + "user-agent": "curl/8.7.1", + "x-envoy-attempt-count": "1", + "x-envoy-internal": "true", + "x-forwarded-client-cert": "By=spiffe://cluster.local/ns/default/sa/petstore;Hash=71d0657c0a7cb514a9da810875859edd1b339fb95192f77db9fd1f84db038126;Subject=\"\";URI=spiffe://cluster.local/ns/istio-system/sa/istio-ingressgateway-service-account", + "x-forwarded-for": "192.168.194.1", + "x-forwarded-proto": "http", + "x-request-id": "f5fed997-94c2-482c-8cde-52c4e1dddef3" + }, + "host": "petstore.k8s.orb.local", + "id": "17554297361488606879", + "method": "GET", + "path": "/api/v3/pet/findByStatus?status=available", + "protocol": "HTTP/1.1", + "scheme": "http" + }, + "time": { + "nanos": 782232000, + "seconds": 1738935521 + } + }, + "route_metadata_context": {}, + "source": { + "address": { + "Address": { + "SocketAddress": { + "PortSpecifier": { + "PortValue": 47768 + }, + "address": "192.168.194.48" + } + } + }, + "principal": "spiffe://cluster.local/ns/istio-system/sa/istio-ingressgateway-service-account" + } + } +} \ No newline at end of file diff --git a/tests/test_data/api/payloads/porc_json/admin_with_write_api_scope.json b/tests/test_data/api/payloads/porc_json/admin_with_write_api_scope.json new file mode 100644 index 0000000..73773c1 --- /dev/null +++ b/tests/test_data/api/payloads/porc_json/admin_with_write_api_scope.json @@ -0,0 +1,78 @@ +{ + "principal": { + "aud": "manetu.io", + "mclearance": "MAXIMUM", + "mrealm": "1234", + "mroles": [ + "mrn:iam:role:admin" + ], + "scopes": [ + "mrn:iam:scope:write-api" + ], + "sub": "mrn:iam:1234:identity:5678" + }, + "operation": "petstore:http:get", + "resource": { + "id": "http://petstore/api/v3/pet/findByStatus?status=available", + "group": "mrn:iam:resource-group:allow-all" + }, + "context": { + "destination": { + "address": { + "Address": { + "SocketAddress": { + "PortSpecifier": { + "PortValue": 8080 + }, + "address": "192.168.194.60" + } + } + }, + "principal": "spiffe://cluster.local/ns/default/sa/petstore" + }, + "metadata_context": {}, + "request": { + "http": { + "headers": { + ":authority": "petstore.k8s.orb.local", + ":method": "GET", + ":path": "/api/v3/pet/findByStatus?status=available", + ":scheme": "http", + "accept": "application/xml", + "authorization": "Bearer eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJtcm46aWFtOjEyMzQ6aWRlbnRpdHk6NTY3OCIsIm1yZWFsbSI6IjEyMzQiLCJhdWQiOiJtYW5ldHUuaW8iLCJtcm9sZXMiOlsibXJuOmlhbTpyb2xlOmFkbWluIl0sIm1jbGVhcmFuY2UiOiJNQVhJTVVNIiwic2NvcGVzIjpbIm1ybjppYW06c2NvcGU6cmVhZC1hcGkiXX0.M7r2pH08rJF6cXrHSHAIXS72syrXb3z0qNN3NPa_uBU", + "user-agent": "curl/8.7.1", + "x-envoy-attempt-count": "1", + "x-envoy-internal": "true", + "x-forwarded-client-cert": "By=spiffe://cluster.local/ns/default/sa/petstore;Hash=71d0657c0a7cb514a9da810875859edd1b339fb95192f77db9fd1f84db038126;Subject=\"\";URI=spiffe://cluster.local/ns/istio-system/sa/istio-ingressgateway-service-account", + "x-forwarded-for": "192.168.194.1", + "x-forwarded-proto": "http", + "x-request-id": "f5fed997-94c2-482c-8cde-52c4e1dddef3" + }, + "host": "petstore.k8s.orb.local", + "id": "17554297361488606879", + "method": "GET", + "path": "/api/v3/pet/findByStatus?status=available", + "protocol": "HTTP/1.1", + "scheme": "http" + }, + "time": { + "nanos": 782232000, + "seconds": 1738935521 + } + }, + "route_metadata_context": {}, + "source": { + "address": { + "Address": { + "SocketAddress": { + "PortSpecifier": { + "PortValue": 47768 + }, + "address": "192.168.194.48" + } + } + }, + "principal": "spiffe://cluster.local/ns/istio-system/sa/istio-ingressgateway-service-account" + } + } +} diff --git a/tests/test_data/api/payloads/porc_json/non_admin_with_read_scope_is_denied_access.json b/tests/test_data/api/payloads/porc_json/non_admin_with_read_scope_is_denied_access.json new file mode 100644 index 0000000..96dcc37 --- /dev/null +++ b/tests/test_data/api/payloads/porc_json/non_admin_with_read_scope_is_denied_access.json @@ -0,0 +1,78 @@ +{ + "principal": { + "aud": "manetu.io", + "mclearance": "MAXIMUM", + "mrealm": "1234", + "mroles": [ + "mrn:iam:role:user" + ], + "scopes": [ + "mrn:iam:scope:read-api" + ], + "sub": "mrn:iam:1234:identity:5678" + }, + "operation": "petstore:http:get", + "resource": { + "id": "http://petstore/api/v3/pet/findByStatus?status=available", + "group": "mrn:iam:resource-group:allow-all" + }, + "context": { + "destination": { + "address": { + "Address": { + "SocketAddress": { + "PortSpecifier": { + "PortValue": 8080 + }, + "address": "192.168.194.60" + } + } + }, + "principal": "spiffe://cluster.local/ns/default/sa/petstore" + }, + "metadata_context": {}, + "request": { + "http": { + "headers": { + ":authority": "petstore.k8s.orb.local", + ":method": "GET", + ":path": "/api/v3/pet/findByStatus?status=available", + ":scheme": "http", + "accept": "application/xml", + "authorization": "Bearer eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJtcm46aWFtOjEyMzQ6aWRlbnRpdHk6NTY3OCIsIm1yZWFsbSI6IjEyMzQiLCJhdWQiOiJtYW5ldHUuaW8iLCJtcm9sZXMiOlsibXJuOmlhbTpyb2xlOmFkbWluIl0sIm1jbGVhcmFuY2UiOiJNQVhJTVVNIiwic2NvcGVzIjpbIm1ybjppYW06c2NvcGU6cmVhZC1hcGkiXX0.M7r2pH08rJF6cXrHSHAIXS72syrXb3z0qNN3NPa_uBU", + "user-agent": "curl/8.7.1", + "x-envoy-attempt-count": "1", + "x-envoy-internal": "true", + "x-forwarded-client-cert": "By=spiffe://cluster.local/ns/default/sa/petstore;Hash=71d0657c0a7cb514a9da810875859edd1b339fb95192f77db9fd1f84db038126;Subject=\"\";URI=spiffe://cluster.local/ns/istio-system/sa/istio-ingressgateway-service-account", + "x-forwarded-for": "192.168.194.1", + "x-forwarded-proto": "http", + "x-request-id": "f5fed997-94c2-482c-8cde-52c4e1dddef3" + }, + "host": "petstore.k8s.orb.local", + "id": "17554297361488606879", + "method": "GET", + "path": "/api/v3/pet/findByStatus?status=available", + "protocol": "HTTP/1.1", + "scheme": "http" + }, + "time": { + "nanos": 782232000, + "seconds": 1738935521 + } + }, + "route_metadata_context": {}, + "source": { + "address": { + "Address": { + "SocketAddress": { + "PortSpecifier": { + "PortValue": 47768 + }, + "address": "192.168.194.48" + } + } + }, + "principal": "spiffe://cluster.local/ns/istio-system/sa/istio-ingressgateway-service-account" + } + } +} \ No newline at end of file diff --git a/tests/test_data/api/payloads/porc_json/valid_admin.json b/tests/test_data/api/payloads/porc_json/valid_admin.json new file mode 100644 index 0000000..0805741 --- /dev/null +++ b/tests/test_data/api/payloads/porc_json/valid_admin.json @@ -0,0 +1,78 @@ +{ + "principal": { + "aud": "manetu.io", + "mclearance": "MAXIMUM", + "mrealm": "1234", + "mroles": [ + "mrn:iam:role:admin" + ], + "scopes": [ + "mrn:iam:scope:read-api" + ], + "sub": "mrn:iam:1234:identity:5678" + }, + "operation": "petstore:http:get", + "resource": { + "id": "http://petstore/api/v3/pet/findByStatus?status=available", + "group": "mrn:iam:resource-group:allow-all" + }, + "context": { + "destination": { + "address": { + "Address": { + "SocketAddress": { + "PortSpecifier": { + "PortValue": 8080 + }, + "address": "192.168.194.60" + } + } + }, + "principal": "spiffe://cluster.local/ns/default/sa/petstore" + }, + "metadata_context": {}, + "request": { + "http": { + "headers": { + ":authority": "petstore.k8s.orb.local", + ":method": "GET", + ":path": "/api/v3/pet/findByStatus?status=available", + ":scheme": "http", + "accept": "application/xml", + "authorization": "Bearer eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJtcm46aWFtOjEyMzQ6aWRlbnRpdHk6NTY3OCIsIm1yZWFsbSI6IjEyMzQiLCJhdWQiOiJtYW5ldHUuaW8iLCJtcm9sZXMiOlsibXJuOmlhbTpyb2xlOmFkbWluIl0sIm1jbGVhcmFuY2UiOiJNQVhJTVVNIiwic2NvcGVzIjpbIm1ybjppYW06c2NvcGU6cmVhZC1hcGkiXX0.M7r2pH08rJF6cXrHSHAIXS72syrXb3z0qNN3NPa_uBU", + "user-agent": "curl/8.7.1", + "x-envoy-attempt-count": "1", + "x-envoy-internal": "true", + "x-forwarded-client-cert": "By=spiffe://cluster.local/ns/default/sa/petstore;Hash=71d0657c0a7cb514a9da810875859edd1b339fb95192f77db9fd1f84db038126;Subject=\"\";URI=spiffe://cluster.local/ns/istio-system/sa/istio-ingressgateway-service-account", + "x-forwarded-for": "192.168.194.1", + "x-forwarded-proto": "http", + "x-request-id": "f5fed997-94c2-482c-8cde-52c4e1dddef3" + }, + "host": "petstore.k8s.orb.local", + "id": "17554297361488606879", + "method": "GET", + "path": "/api/v3/pet/findByStatus?status=available", + "protocol": "HTTP/1.1", + "scheme": "http" + }, + "time": { + "nanos": 782232000, + "seconds": 1738935521 + } + }, + "route_metadata_context": {}, + "source": { + "address": { + "Address": { + "SocketAddress": { + "PortSpecifier": { + "PortValue": 47768 + }, + "address": "192.168.194.48" + } + } + }, + "principal": "spiffe://cluster.local/ns/istio-system/sa/istio-ingressgateway-service-account" + } + } +} \ No newline at end of file diff --git a/tests/test_data/api/payloads/porc_json/valid_admin_with_multiple_scopes_allows_access.json b/tests/test_data/api/payloads/porc_json/valid_admin_with_multiple_scopes_allows_access.json new file mode 100644 index 0000000..7bf1220 --- /dev/null +++ b/tests/test_data/api/payloads/porc_json/valid_admin_with_multiple_scopes_allows_access.json @@ -0,0 +1,79 @@ +{ + "principal": { + "aud": "manetu.io", + "mclearance": "MAXIMUM", + "mrealm": "1234", + "mroles": [ + "mrn:iam:role:admin" + ], + "scopes": [ + "mrn:iam:scope:read-api", + "mrn:iam:scope:api" + ], + "sub": "mrn:iam:1234:identity:5678" + }, + "operation": "petstore:http:get", + "resource": { + "id": "http://petstore/api/v3/pet/findByStatus?status=available", + "group": "mrn:iam:resource-group:allow-all" + }, + "context": { + "destination": { + "address": { + "Address": { + "SocketAddress": { + "PortSpecifier": { + "PortValue": 8080 + }, + "address": "192.168.194.60" + } + } + }, + "principal": "spiffe://cluster.local/ns/default/sa/petstore" + }, + "metadata_context": {}, + "request": { + "http": { + "headers": { + ":authority": "petstore.k8s.orb.local", + ":method": "GET", + ":path": "/api/v3/pet/findByStatus?status=available", + ":scheme": "http", + "accept": "application/xml", + "authorization": "Bearer eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJtcm46aWFtOjEyMzQ6aWRlbnRpdHk6NTY3OCIsIm1yZWFsbSI6IjEyMzQiLCJhdWQiOiJtYW5ldHUuaW8iLCJtcm9sZXMiOlsibXJuOmlhbTpyb2xlOmFkbWluIl0sIm1jbGVhcmFuY2UiOiJNQVhJTVVNIiwic2NvcGVzIjpbIm1ybjppYW06c2NvcGU6cmVhZC1hcGkiXX0.M7r2pH08rJF6cXrHSHAIXS72syrXb3z0qNN3NPa_uBU", + "user-agent": "curl/8.7.1", + "x-envoy-attempt-count": "1", + "x-envoy-internal": "true", + "x-forwarded-client-cert": "By=spiffe://cluster.local/ns/default/sa/petstore;Hash=71d0657c0a7cb514a9da810875859edd1b339fb95192f77db9fd1f84db038126;Subject=\"\";URI=spiffe://cluster.local/ns/istio-system/sa/istio-ingressgateway-service-account", + "x-forwarded-for": "192.168.194.1", + "x-forwarded-proto": "http", + "x-request-id": "f5fed997-94c2-482c-8cde-52c4e1dddef3" + }, + "host": "petstore.k8s.orb.local", + "id": "17554297361488606879", + "method": "GET", + "path": "/api/v3/pet/findByStatus?status=available", + "protocol": "HTTP/1.1", + "scheme": "http" + }, + "time": { + "nanos": 782232000, + "seconds": 1738935521 + } + }, + "route_metadata_context": {}, + "source": { + "address": { + "Address": { + "SocketAddress": { + "PortSpecifier": { + "PortValue": 47768 + }, + "address": "192.168.194.48" + } + } + }, + "principal": "spiffe://cluster.local/ns/istio-system/sa/istio-ingressgateway-service-account" + } + } +} \ No newline at end of file diff --git a/tests/test_data/api/payloads/yml/bad_rego.yml b/tests/test_data/api/payloads/yml/bad_rego.yml new file mode 100644 index 0000000..5e5f985 --- /dev/null +++ b/tests/test_data/api/payloads/yml/bad_rego.yml @@ -0,0 +1,10 @@ +apiVersion: iamlite.manetu.io/v1alpha3 +kind: PolicyDomain +metadata: + name: test +spec: + policies: + - mrn: "bad" + name: "bad" + description: "This is intentionally bad rego" + rego: "bad rego" diff --git a/tests/test_data/api/payloads/yml/broken_alpha.yml b/tests/test_data/api/payloads/yml/broken_alpha.yml new file mode 100644 index 0000000..ab64d30 --- /dev/null +++ b/tests/test_data/api/payloads/yml/broken_alpha.yml @@ -0,0 +1,42 @@ +apiVersion: iamlite.manetu.io/v1alpha3 +kind: PolicyDomain +metadata: + name: alpha +spec: + policy-libraries: + - mrn: "mrn:iam:library:utils" + name: utils + rego: | + package utils + ro_operations := {"*:get", "*:read"} + + - mrn: "mrn:iam:library:loopy-a" + name: loopy-a + dependencies: + - "beta/mrn:iam:library:loopy-b" # Start of 3-level cycle + rego: | + package loopya + ok := true + + # Multiple libraries in same domain - tests recursion within domain + - mrn: "mrn:iam:library:loopy-c" + name: loopy-c + dependencies: + - "mrn:iam:library:loopy-a" # Completes 3-level cycle: a->b->c->a + rego: | + package loopyc + ok := true + + policies: + - mrn: "mrn:iam:policy:broken" + name: broken + dependencies: + - "mrn:iam:library:nonexistent" # Missing library + rego: | + package authz + default allow = false + + roles: + - mrn: "mrn:iam:role:admin" + name: admin + policy: "mrn:iam:policy:missing-policy" # Missing policy diff --git a/tests/test_data/api/payloads/yml/broken_beta.yml b/tests/test_data/api/payloads/yml/broken_beta.yml new file mode 100644 index 0000000..cebae9e --- /dev/null +++ b/tests/test_data/api/payloads/yml/broken_beta.yml @@ -0,0 +1,49 @@ +apiVersion: iamlite.manetu.io/v1alpha3 +kind: PolicyDomain +metadata: + name: beta +spec: + policy-libraries: + - mrn: "mrn:iam:library:loopy-b" + name: loopy-b + dependencies: + - "alpha/mrn:iam:library:loopy-c" + rego: | + package loopyb + ok := true + + policies: + - mrn: "mrn:iam:policy:ghost-read" + name: ghost-read + dependencies: + - "alpha/mrn:iam:library:nonexistent" # Missing library + rego: | + package authz + default allow = true + + roles: + - mrn: "mrn:iam:role:auditor" + name: auditor + policy: "mrn:iam:policy:ghost-read" + + groups: + - mrn: "mrn:iam:group:users" + name: users + roles: + - "mrn:iam:role:nonexistent" # Missing role + + resource-groups: + - mrn: "mrn:iam:resource-group:audit" + name: audit + policy: "mrn:iam:policy:missing" # Missing policy + + scopes: + - mrn: "mrn:iam:scope:invalid" + name: invalid + policy: "gamma/mrn:iam:policy:external" # Missing domain + + operations: + - name: api + selector: + - ".*" + policy: "mrn:iam:policy:ghost-read" diff --git a/tests/test_data/api/payloads/yml/consolidated.yml b/tests/test_data/api/payloads/yml/consolidated.yml new file mode 100644 index 0000000..031d327 --- /dev/null +++ b/tests/test_data/api/payloads/yml/consolidated.yml @@ -0,0 +1,226 @@ +apiVersion: iamlite.manetu.io/v1alpha3 +kind: PolicyDomain +metadata: + name: consolidated +spec: + policy-libraries: + - mrn: &utils "mrn:iam:library:utils" + name: utils + description: "Utility functions common to multiple policies" + rego: | + package utils + + ro_operations := { + "*:get", + "*:read", + "*:list", + "*:subscribe", + "*:query", + } + + - mrn: &helpers "mrn:iam:library:helpers" + name: helpers + description: "Helper functions common to multiple policies" + dependencies: + - *utils + rego: | + package helpers + + match_any(candidates, value) { + glob.match(candidates[_], [], value) + } + policies: + - mrn: &mainapi "mrn:iam:policy:mainapi" + name: mainapi + description: "This policy is the main rego for apis which handles jwt and more" + rego: | + package authz + + #allow = -1 or 1 decision made, no more policy processing + # 0 proceed to phase 2 + # + #policy filters for allow==1 or -1 and lets everything else to be passed through + #for further processing + # 1 - public ops, graphql, + # admin not performing prohibted ops (this for preventing lockout) + # -1 - user ops and operator ops not with corresponding roles + default allow = 0 + + #------------ jwt checks --------------- + jwt_ok { + input.principal != {} + } + + #<<<<<<<<<<<<<< Rules >>>>>>>>>>>>>>>>>>>>>> + + #------------ not allowed ---------------- + allow = -1 { + not jwt_ok + } + + - mrn: &allow-all "mrn:iam:policy:allow-all" + name: allow-all + description: "This policy allows full access" + rego: | + package authz + default allow = true + + - mrn: &no-access "mrn:iam:policy:no-access" + name: no-access + description: "This policy denies all access requests" + rego: | + package authz + default allow = false + + - mrn: &read-only "mrn:iam:policy:read-only" + name: read-only + description: "This policy has read-only access to the entire realm" + dependencies: + - "mrn:iam:library:helpers" + rego: | + package authz + import data.helpers + + default allow = false + + allow { + helpers.match_any(permitted_operations, input.operation) + } + + permitted_operations := { + "*:get", + "*:head", + } + + - mrn: &share-by-clearance "mrn:iam:policy:share-by-clearance" + name: share-by-clearance + description: "Resource policy for sharing resources based on the caller's security clearance" + dependencies: + - "mrn:iam:library:helpers" + rego: | + package authz + import data.helpers + import data.utils + + default allow = false + + allow { + is_readonly + has_clearance + } + + allow { + is_owner + } + + ratings := {"LOW": 1, "MODERATE": 2, "HIGH": 3, "MAXIMUM": 4, "UNASSIGNED": 5} + + has_clearance { + ratings[input.principal.mclearance] >= ratings[input.resource.classification] + } + + is_readonly { + helpers.match_any(utils.ro_operations, input.operation) + } + + is_owner { + input.principal.sub == input.resource.owner + } + + roles: + - mrn: &admin-role "mrn:iam:role:admin" + name: admin + description: "This role is appropriate for an administrator" + policy: *allow-all + annotations: + - name: foo + value: "42" + - name: bar + value: "[1, 2, 3]" + - name: baz + value: "{\"bat\": 42}" + - mrn: "mrn:iam:role:no-access" + name: no-access + description: "This role will deny all access" + policy: *no-access + + groups: + - mrn: "mrn:iam:group:admin" + name: admin + description: "This group is appropriate for administrators" + roles: + - *admin-role + + resource-groups: + - mrn: "mrn:iam:resource-group:allow-all" + name: allow-all + description: "Resources in this group may be fully controlled by any authenticated user" + default: true # this is the resource-group to use as the default if the resource does not specify + policy: *allow-all + annotations: + - name: foo + value: "42" + - name: bar + value: "[1, 2, 3]" + - name: baz + value: "{\"bat\": 42}" + + - mrn: "mrn:iam:resource-group:share-by-clearance" + name: share-by-clearance + description: "Resources in this group are shared (read-only) based on classification and security clearance" + policy: *share-by-clearance + + scopes: + - mrn: "mrn:iam:scope:api" + name: api + description: "This scope grants the user complete read/write access to the API up to the resource-owners entitlement level" + policy: *allow-all + - mrn: "mrn:iam:scope:read-api" + name: read_api + description: "This scope grants the user read-only access to the API up to the resource-owners entitlement level" + policy: *read-only + + # Operations and mappings are evaluated in order: first match + # will return the associated policies. + # A selector must be a valid regex https://github.com/google/re2. + # Within an operation, the selectors are OR'ed together to create + # a regexp for matching. + operations: + - name: api + selector: + - ".*" + policy: *mainapi + + mappers: + - name: common-mapper + selector: + - ".*" # matches service-account name of the calling pod + rego: | + package mapper + + import rego.v1 + + default claims := {} + default service := "unknown" + + get_default(val, key, _) := val[key] + get_default(val, key, fallback) := fallback if not val[key] + + method := lower(get_default(input.request.http, "method", "GET")) + dest := split(input.destination.principal, "/") # "spiffe://cluster.local/ns/default/sa/petstore" + service := dest[count(dest) - 1] + path := get_default(input.request.http, "path", "/") + auth := input.request.http.headers.authorization + token := split(auth, "Bearer ")[1] + claims := io.jwt.decode(token)[1] + + porc := { + "principal": claims, + "operation": sprintf("%s:http:%s", [service, method]), + "resource": { + "id": sprintf("http://%s%s", [service, path]), + "group": "mrn:iam:resource-group:allow-all" + }, + "context": input, + } + diff --git a/tests/test_data/api/payloads/yml/invalid_policy_reference.yml b/tests/test_data/api/payloads/yml/invalid_policy_reference.yml new file mode 100644 index 0000000..e584bcd --- /dev/null +++ b/tests/test_data/api/payloads/yml/invalid_policy_reference.yml @@ -0,0 +1,19 @@ +apiVersion: iamlite.manetu.io/v1alpha3 +kind: PolicyDomain +metadata: + name: broken-test +spec: + policies: + - mrn: "mrn:iam:policy:invalid-policy" + name: invalid-policy + dependencies: + - "mrn:iam:library:missing-lib" # This library doesn't exist + rego: | + package authz + default allow = true + + operations: + - name: api + selector: + - ".*" + policy: "mrn:iam:policy:invalid-policy" \ No newline at end of file diff --git a/tests/test_data/api/payloads/yml/malformed_bundle.yml b/tests/test_data/api/payloads/yml/malformed_bundle.yml new file mode 100644 index 0000000..bb89200 --- /dev/null +++ b/tests/test_data/api/payloads/yml/malformed_bundle.yml @@ -0,0 +1,11 @@ +apiVersion: iamlite.manetu.io/v1alpha3 +kind: PolicyDomain +metadata: + name: malformed-domain +spec: + policies: + - mrn: "mrn:iam:policy:bad" + name: bad-policy + rego: | + package authz + default allow = true \ No newline at end of file diff --git a/tests/test_data/api/payloads/yml/missing_role_reference.yml b/tests/test_data/api/payloads/yml/missing_role_reference.yml new file mode 100644 index 0000000..0b558d0 --- /dev/null +++ b/tests/test_data/api/payloads/yml/missing_role_reference.yml @@ -0,0 +1,26 @@ +apiVersion: iamlite.manetu.io/v1alpha3 +kind: PolicyDomain +metadata: + name: broken-missing-role +spec: + policies: + - mrn: "mrn:iam:policy:role-reference-broken" + name: role-reference-broken + rego: | + package authz + default allow = false + + allow { + input.principal.mroles[_] == "mrn:iam:role:nonexistent-role" + } + + roles: + - mrn: "mrn:iam:role:admin" + name: admin + policy: "mrn:iam:policy:role-reference-broken" + + operations: + - name: api + selector: + - ".*" + policy: "mrn:iam:policy:role-reference-broken" \ No newline at end of file diff --git a/tests/test_data/api/payloads/yml/mixed_invalid.yml b/tests/test_data/api/payloads/yml/mixed_invalid.yml new file mode 100644 index 0000000..87bd472 --- /dev/null +++ b/tests/test_data/api/payloads/yml/mixed_invalid.yml @@ -0,0 +1,17 @@ +apiVersion: iamlite.manetu.io/v1alpha3 +kind: PolicyDomain +metadata: + name: invalid +spec: + policies: + - mrn: "mrn:iam:policy:broken" + name: broken + dependencies: + - "mrn:iam:library:missing" + rego: | + package authz + default allow = false + roles: + - mrn: "mrn:iam:role:broken" + name: broken + policy: "mrn:iam:policy:missing" diff --git a/tests/test_data/api/payloads/yml/mixed_valid.yml b/tests/test_data/api/payloads/yml/mixed_valid.yml new file mode 100644 index 0000000..561f28b --- /dev/null +++ b/tests/test_data/api/payloads/yml/mixed_valid.yml @@ -0,0 +1,15 @@ +apiVersion: iamlite.manetu.io/v1alpha3 +kind: PolicyDomain +metadata: + name: valid +spec: + policies: + - mrn: "mrn:iam:policy:allow-all" + name: allow-all + rego: | + package authz + default allow = true + roles: + - mrn: "mrn:iam:role:admin" + name: admin + policy: "mrn:iam:policy:allow-all" diff --git a/tests/test_data/api/payloads/yml/multi_error.yml b/tests/test_data/api/payloads/yml/multi_error.yml new file mode 100644 index 0000000..a2098ea --- /dev/null +++ b/tests/test_data/api/payloads/yml/multi_error.yml @@ -0,0 +1,43 @@ +apiVersion: iamlite.manetu.io/v1alpha3 +kind: PolicyDomain +metadata: + name: multi-error +spec: + policies: + - mrn: "mrn:iam:policy:broken1" + name: broken1 + dependencies: + - "mrn:iam:library:missing1" + - "mrn:iam:library:missing2" + rego: | + package authz + default allow = false + + - mrn: "mrn:iam:policy:broken2" + name: broken2 + dependencies: + - "other-domain/mrn:iam:library:external" + rego: | + package authz + default allow = false + + roles: + - mrn: "mrn:iam:role:broken1" + name: broken1 + policy: "mrn:iam:policy:missing1" + + - mrn: "mrn:iam:role:broken2" + name: broken2 + policy: "mrn:iam:policy:missing2" + + groups: + - mrn: "mrn:iam:group:broken" + name: broken + roles: + - "mrn:iam:role:missing1" + - "mrn:iam:role:missing2" + + resource-groups: + - mrn: "mrn:iam:resource-group:broken" + name: broken + policy: "mrn:iam:policy:missing3" diff --git a/tests/test_data/api/payloads/yml/valid_alpha.yml b/tests/test_data/api/payloads/yml/valid_alpha.yml new file mode 100644 index 0000000..f59651f --- /dev/null +++ b/tests/test_data/api/payloads/yml/valid_alpha.yml @@ -0,0 +1,35 @@ +apiVersion: iamlite.manetu.io/v1alpha3 +kind: PolicyDomain +metadata: + name: alpha +spec: + policy-libraries: + - mrn: "mrn:iam:library:utils" + name: utils + rego: | + package utils + ro_operations := {"*:get", "*:read"} + + policies: + - mrn: "mrn:iam:policy:allow-all" + name: allow-all + rego: | + package authz + default allow = true + + roles: + - mrn: "mrn:iam:role:admin" + name: admin + policy: "mrn:iam:policy:allow-all" + + resource-groups: + - mrn: "mrn:iam:resource-group:default" + name: default + default: true + policy: "mrn:iam:policy:allow-all" + + operations: + - name: api + selector: + - ".*" + policy: "mrn:iam:policy:allow-all" diff --git a/tests/utils/__init__.py b/tests/utils/__init__.py new file mode 100644 index 0000000..59fd2c7 --- /dev/null +++ b/tests/utils/__init__.py @@ -0,0 +1,2 @@ +"""Shared Python test utilities.""" + diff --git a/tests/utils/allure_helpers.py b/tests/utils/allure_helpers.py new file mode 100644 index 0000000..dd36e89 --- /dev/null +++ b/tests/utils/allure_helpers.py @@ -0,0 +1,26 @@ +import json + +import allure + + +def attach_payload(payload): + allure.attach( + json.dumps(payload, indent=2), + name="Input Payload", + attachment_type=allure.attachment_type.JSON, + ) + + +def attach_bundle_yaml(path): + with open(path, "r", encoding="utf-8") as file: + content = file.read() + allure.attach( + content, + name="YAML Bundle", + attachment_type=allure.attachment_type.YAML, + ) + + +def attach_output(output, name="mpe Output"): + allure.attach(output, name=name, attachment_type=allure.attachment_type.TEXT) + diff --git a/tests/utils/mpe_runner.py b/tests/utils/mpe_runner.py new file mode 100644 index 0000000..ff63210 --- /dev/null +++ b/tests/utils/mpe_runner.py @@ -0,0 +1,56 @@ +import json +import subprocess +from pathlib import Path + +import allure +import pytest + + +def _resolve_mpe_binary(): + # Go up 2 levels: tests/utils/mpe_runner.py -> tests/ -> project_root + project_root = Path(__file__).resolve().parents[2] + local_binary = project_root / "target" / "mpe" + + if local_binary.exists(): + return str(local_binary) + + return "mpe" + + +def run_mpe_decision(payload, bundle_path, allow_error=False): + result = subprocess.run( + [_resolve_mpe_binary(), "test", "decision", "--bundle", str(bundle_path)], + input=json.dumps(payload), + text=True, + capture_output=True, + check=not allow_error, + ) + return result + + +def normalize_decision(value): + if value is None: + return "" + normalized = str(value).strip().upper() + aliases = { + "ALLOW": "GRANT", + "PERMIT": "GRANT", + "DENY": "DENY", + "REJECT": "DENY", + } + return aliases.get(normalized, normalized) + + +def handle_mpe_failure(result): + allure.attach( + result.stdout or "", + name="stdout", + attachment_type=allure.attachment_type.JSON, + ) + allure.attach( + result.stderr or "", + name="stderr", + attachment_type=allure.attachment_type.JSON, + ) + pytest.fail(f"`mpe` command failed:\n{result.stderr}") +