From 02f7a24bcdf773979ba0acb7d435d4b846bacbf3 Mon Sep 17 00:00:00 2001 From: reginaldalfret Date: Fri, 2 Oct 2026 09:15:25 +0530 Subject: [PATCH 1/2] chore(plugins): move cedarpolicyplugin to plugins/external/cedar Move the cedarpolicyplugin external plugin from the mcp-context-forge monorepo to plugins/external/cedar/ as part of the plugin ecosystem separation. Fixes #21. Signed-off-by: reginaldalfret --- plugins/external/cedar/.dockerignore | 362 ++++++++++ plugins/external/cedar/.env.template | 63 ++ plugins/external/cedar/.ruff.toml | 63 ++ plugins/external/cedar/Containerfile | 47 ++ plugins/external/cedar/MANIFEST.in | 65 ++ plugins/external/cedar/Makefile | 448 ++++++++++++ plugins/external/cedar/README.md | 403 +++++++++++ .../cedar/cedarpolicyplugin/__init__.py | 24 + .../cedarpolicyplugin/plugin-manifest.yaml | 9 + .../cedar/cedarpolicyplugin/plugin.py | 670 ++++++++++++++++++ .../cedar/cedarpolicyplugin/schema.py | 60 ++ .../external/cedar/examples/config-cedar.yaml | 103 +++ .../external/cedar/examples/config-dsl.yaml | 43 ++ plugins/external/cedar/pyproject.toml | 96 +++ .../cedar/resources/plugins/config.yaml | 79 +++ .../cedar/resources/runtime/config.yaml | 71 ++ plugins/external/cedar/run-server.sh | 43 ++ plugins/external/cedar/tests/__init__.py | 6 + plugins/external/cedar/tests/pytest.ini | 13 + .../cedar/tests/test_cedarpolicyplugin.py | 634 +++++++++++++++++ 20 files changed, 3302 insertions(+) create mode 100644 plugins/external/cedar/.dockerignore create mode 100644 plugins/external/cedar/.env.template create mode 100644 plugins/external/cedar/.ruff.toml create mode 100644 plugins/external/cedar/Containerfile create mode 100644 plugins/external/cedar/MANIFEST.in create mode 100644 plugins/external/cedar/Makefile create mode 100644 plugins/external/cedar/README.md create mode 100644 plugins/external/cedar/cedarpolicyplugin/__init__.py create mode 100644 plugins/external/cedar/cedarpolicyplugin/plugin-manifest.yaml create mode 100644 plugins/external/cedar/cedarpolicyplugin/plugin.py create mode 100644 plugins/external/cedar/cedarpolicyplugin/schema.py create mode 100644 plugins/external/cedar/examples/config-cedar.yaml create mode 100644 plugins/external/cedar/examples/config-dsl.yaml create mode 100644 plugins/external/cedar/pyproject.toml create mode 100644 plugins/external/cedar/resources/plugins/config.yaml create mode 100644 plugins/external/cedar/resources/runtime/config.yaml create mode 100644 plugins/external/cedar/run-server.sh create mode 100644 plugins/external/cedar/tests/__init__.py create mode 100644 plugins/external/cedar/tests/pytest.ini create mode 100644 plugins/external/cedar/tests/test_cedarpolicyplugin.py diff --git a/plugins/external/cedar/.dockerignore b/plugins/external/cedar/.dockerignore new file mode 100644 index 0000000..afcb3fc --- /dev/null +++ b/plugins/external/cedar/.dockerignore @@ -0,0 +1,362 @@ +# syntax=docker/dockerfile:1 +#---------------------------------------------------------------------- +# Docker Build Context Optimization +# +# This .dockerignore file excludes unnecessary files from the Docker +# build context to improve build performance and security. +#---------------------------------------------------------------------- + +#---------------------------------------------------------------------- +# 1. Development and source directories (not needed in production) +#---------------------------------------------------------------------- +agent_runtimes/ +charts/ +deployment/ +docs/ +deployment/k8s/ +mcp-servers/ +tests/ +test/ +attic/ +*.md +.benchmarks/ + +# Development environment directories +.devcontainer/ +.github/ +.vscode/ +.idea/ + +#---------------------------------------------------------------------- +# 2. Version control +#---------------------------------------------------------------------- +.git/ +.gitignore +.gitattributes +.gitmodules + +#---------------------------------------------------------------------- +# 3. Python build artifacts and caches +#---------------------------------------------------------------------- +# Byte-compiled files +__pycache__/ +*.py[cod] +*.pyc +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST +.wily/ + +# PyInstaller +*.manifest +*.spec + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ +.pytype/ + +# Cython debug symbols +cython_debug/ + +#---------------------------------------------------------------------- +# 4. Virtual environments +#---------------------------------------------------------------------- +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ +.python37/ +.python39/ +.python-version + +# PDM +pdm.lock +.pdm.toml +.pdm-python + +#---------------------------------------------------------------------- +# 5. Package managers and dependencies +#---------------------------------------------------------------------- +# Node.js +node_modules/ +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.npm +.yarn + +# pip +pip-log.txt +pip-delete-this-directory.txt + +#---------------------------------------------------------------------- +# 6. Docker and container files (avoid recursive copies) +#---------------------------------------------------------------------- +Dockerfile +Dockerfile.* +Containerfile +Containerfile.* +docker-compose.yml +docker-compose.*.yml +podman-compose*.yaml +.dockerignore + +#---------------------------------------------------------------------- +# 7. IDE and editor files +#---------------------------------------------------------------------- +# JetBrains +.idea/ +*.iml +*.iws +*.ipr + +# VSCode +.vscode/ +*.code-workspace + +# Vim +*.swp +*.swo +*~ + +# Emacs +*~ +\#*\# +.\#* + +# macOS +.DS_Store +.AppleDouble +.LSOverride + +#---------------------------------------------------------------------- +# 8. Build tools and CI/CD configurations +#---------------------------------------------------------------------- +# Testing configurations +.coveragerc +.pylintrc +pytest.ini +tox.ini +.pytest.ini + +# Linting and formatting +.hadolint.yaml +.pre-commit-config.yaml +.pycodestyle +.pyre_configuration +.pyspelling.yaml +.ruff.toml +.shellcheckrc + +# Build configurations +Makefile +setup.cfg +pyproject.toml.bak +MANIFEST.in + +# CI/CD +.travis.* +.gitlab-ci.yml +.circleci/ +.github/ +azure-pipelines.yml +Jenkinsfile + +# Code quality +sonar-code.properties +sonar-project.properties +.scannerwork/ +whitesource.config +.whitesource + +# Other tools +.bumpversion.cfg +.editorconfig +mypy.ini + +#---------------------------------------------------------------------- +# 9. Application runtime files (should not be in image) +#---------------------------------------------------------------------- +# Databases +*.db +*.sqlite +*.sqlite3 +mcp.db +db.sqlite3 + +# Logs +*.log +logs/ +log/ + +# Certificates and secrets +certs/ +*.pem +*.key +*.crt +*.csr +.env +.env.* + +# Generated files +public/ +static/ +media/ + +# Application instances +instance/ +local_settings.py + +#---------------------------------------------------------------------- +# 10. Framework-specific files +#---------------------------------------------------------------------- +# Django +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal +media/ + +# Flask +instance/ +.webassets-cache + +# Scrapy +.scrapy + +# Sphinx documentation +docs/_build/ +docs/build/ + +# PyBuilder +target/ + +# Jupyter Notebook +.ipynb_checkpoints +*.ipynb + +# IPython +profile_default/ +ipython_config.py + +# celery +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +#---------------------------------------------------------------------- +# 11. Backup and temporary files +#---------------------------------------------------------------------- +*.bak +*.backup +*.tmp +*.temp +*.orig +*.rej +.backup/ +backup/ +tmp/ +temp/ + +#---------------------------------------------------------------------- +# 12. Documentation and miscellaneous +#---------------------------------------------------------------------- +*.md +!README.md +LICENSE +CHANGELOG +AUTHORS +CONTRIBUTORS +TODO +TODO.md +DEVELOPING.md +CONTRIBUTING.md + +# Spelling +.spellcheck-en.txt +*.dic + +# Shell scripts (if not needed in container) +test.sh +scripts/test/ +scripts/dev/ + +#---------------------------------------------------------------------- +# 13. OS-specific files +#---------------------------------------------------------------------- +# Windows +Thumbs.db +ehthumbs.db +Desktop.ini +$RECYCLE.BIN/ + +# Linux +*~ +.fuse_hidden* +.directory +.Trash-* +.nfs* + +#---------------------------------------------------------------------- +# End of .dockerignore +#---------------------------------------------------------------------- diff --git a/plugins/external/cedar/.env.template b/plugins/external/cedar/.env.template new file mode 100644 index 0000000..5dbc574 --- /dev/null +++ b/plugins/external/cedar/.env.template @@ -0,0 +1,63 @@ +##################################### +# Plugins Settings +##################################### + +# Enable the plugin framework +PLUGINS_ENABLED=false + +# Enable auto-completion for plugins CLI +PLUGINS_CLI_COMPLETION=false + +# default host port to listen on +PLUGINS_SERVER_HOST=0.0.0.0 + +# Set markup mode for plugins CLI +# Valid options: +# rich: use rich markup +# markdown: allow markdown in help strings +# disabled: disable markup +# If unset (commented out), uses "rich" if rich is detected, otherwise disables it. +PLUGINS_CLI_MARKUP_MODE=rich + +# Configuration path for plugin loader +PLUGINS_CONFIG=./resources/plugins/config.yaml + +# Configuration path for chuck mcp runtime +CHUK_MCP_CONFIG_PATH=./resources/runtime/config.yaml + +# Configuration for plugins transport +PLUGINS_TRANSPORT=streamablehttp + +##################################### +# MCP External Plugin Server - mTLS Configuration +##################################### + +# Enable SSL/TLS for external plugin MCP server +# Options: true, false (default) +# When true: Enables HTTPS and optionally mTLS for the plugin MCP server +MCP_SSL_ENABLED=false + +# SSL/TLS Certificate Files +# Path to server private key (required when MCP_SSL_ENABLED=true) +# Generate with: openssl genrsa -out certs/mcp/server.key 2048 +# MCP_SSL_KEYFILE=certs/mcp/server.key + +# Path to server certificate (required when MCP_SSL_ENABLED=true) +# Generate with: openssl req -new -x509 -key certs/mcp/server.key -out certs/mcp/server.crt -days 365 +# MCP_SSL_CERTFILE=certs/mcp/server.crt + +# Optional password for encrypted private key +# MCP_SSL_KEYFILE_PASSWORD= + +# mTLS (Mutual TLS) Configuration +# Client certificate verification mode: +# 0 (CERT_NONE): No client certificate required - standard TLS (default) +# 1 (CERT_OPTIONAL): Client certificate optional - validate if provided +# 2 (CERT_REQUIRED): Client certificate required - full mTLS +# Default: 0 (standard TLS without client verification) +MCP_SSL_CERT_REQS=0 + +# CA certificate bundle for verifying client certificates +# Required when MCP_SSL_CERT_REQS=1 or MCP_SSL_CERT_REQS=2 +# Can be a single CA file or a bundle containing multiple CAs +# MCP_SSL_CA_CERTS=certs/mcp/ca.crt diff --git a/plugins/external/cedar/.ruff.toml b/plugins/external/cedar/.ruff.toml new file mode 100644 index 0000000..443a275 --- /dev/null +++ b/plugins/external/cedar/.ruff.toml @@ -0,0 +1,63 @@ +# Exclude a variety of commonly ignored directories. +exclude = [ + ".bzr", + ".direnv", + ".eggs", + ".git", + ".git-rewrite", + ".hg", + ".ipynb_checkpoints", + ".mypy_cache", + ".nox", + ".pants.d", + ".pyenv", + ".pytest_cache", + ".pytype", + ".ruff_cache", + ".svn", + ".tox", + ".venv", + ".vscode", + "__pypackages__", + "_build", + "buck-out", + "build", + "dist", + "node_modules", + "site-packages", + "venv", + "docs", + "test" +] + +# 200 line length +line-length = 200 +indent-width = 4 + +# Assume Python 3.11 +target-version = "py311" + +[lint] +# Enable Pyflakes (`F`) and a subset of the pycodestyle (`E`) codes by default. +select = ["E4", "E7", "E9", "F"] +ignore = [] + +# Allow fix for all enabled rules (when `--fix`) is provided. +fixable = ["ALL"] +unfixable = [] + +# Allow unused variables when underscore-prefixed. +dummy-variable-rgx = "^(_+|(_+[a-zA-Z0-9_]*[a-zA-Z0-9]+?))$" + +[format] +# Like Black, use double quotes for strings. +quote-style = "double" + +# Like Black, indent with spaces, rather than tabs. +indent-style = "space" + +# Like Black, respect magic trailing commas. +skip-magic-trailing-comma = false + +# Like Black, automatically detect the appropriate line ending. +line-ending = "auto" diff --git a/plugins/external/cedar/Containerfile b/plugins/external/cedar/Containerfile new file mode 100644 index 0000000..85206d2 --- /dev/null +++ b/plugins/external/cedar/Containerfile @@ -0,0 +1,47 @@ +# syntax=docker/dockerfile:1.7 +ARG UBI=python-312-minimal + +FROM registry.access.redhat.com/ubi10/${UBI} AS builder + +ARG PYTHON_VERSION=3.12 + +ARG VERSION +ARG COMMIT_ID +ARG SKILLS_SDK_COMMIT_ID +ARG SKILLS_SDK_VERSION +ARG BUILD_TIME_SKILLS_INSTALL + +ENV APP_HOME=/app + +USER 0 + +# Image pre-requisites +RUN INSTALL_PKGS="git make gcc gcc-c++ python${PYTHON_VERSION}-devel" && \ + microdnf -y --setopt=tsflags=nodocs --setopt=install_weak_deps=0 install $INSTALL_PKGS && \ + microdnf -y clean all --enablerepo='*' + +# Setup alias from HOME to APP_HOME +RUN mkdir -p ${APP_HOME} && \ + chown -R 1001:0 ${APP_HOME} && \ + ln -s ${HOME} ${APP_HOME} && \ + mkdir -p ${HOME}/resources/config && \ + chown -R 1001:0 ${HOME}/resources/config + +USER 1001 + +# Install plugin package and create writable cache directory — single layer. +COPY . . +# hadolint ignore=DL3013 +RUN pip install --no-cache-dir uv \ + && python -m uv pip install . \ + && mkdir -p -m 0776 ${HOME}/.cache + +# Update labels +LABEL maintainer="ContextForge Team" \ + name="mcp/mcppluginserver" \ + version="${VERSION}" \ + url="https://github.com/IBM/mcp-context-forge" \ + description="MCP Plugin Server for ContextForge" + +# App entrypoint +ENTRYPOINT ["sh", "-c", "${HOME}/run-server.sh"] diff --git a/plugins/external/cedar/MANIFEST.in b/plugins/external/cedar/MANIFEST.in new file mode 100644 index 0000000..b88f918 --- /dev/null +++ b/plugins/external/cedar/MANIFEST.in @@ -0,0 +1,65 @@ +# ────────────────────────────────────────────────────────────── +# MANIFEST.in - source-distribution contents for cedarpolicyplugin +# ────────────────────────────────────────────────────────────── + +# 1️⃣ Core project files that SDists/Wheels should always carry +include LICENSE +include README.md +include pyproject.toml +include Containerfile + +# 2️⃣ Top-level config, examples and helper scripts +include *.py +include *.md +include *.example +include *.lock +include *.properties +include *.toml +include *.yaml +include *.yml +include *.json +include *.sh +include *.txt +recursive-include tests/async *.py +recursive-include tests/async *.yaml + +# 3️⃣ Tooling/lint configuration dot-files (explicit so they're not lost) +include .env.make +include .interrogaterc +include .jshintrc +include whitesource.config +include .dockerignore +include .htmlhintrc +include .pycodestyle +include .pylintrc +include .whitesource +include .coveragerc +# include .gitignore # purely optional but many projects ship it +include .bumpversion.cfg +include .yamllint +include .editorconfig +include .snyk + +# 4️⃣ Runtime data that lives *inside* the package at import time +recursive-include resources/plugins *.yaml +recursive-include cedarpolicyplugin *.yaml + +# 5️⃣ (Optional) include MKDocs-based docs in the sdist +# graft docs + +# 6️⃣ Never publish caches, compiled or build outputs, deployment, agent_runtimes, etc. +global-exclude __pycache__ *.py[cod] *.so *.dylib +prune build +prune dist +prune .eggs +prune *.egg-info +prune charts +prune k8s +prune .devcontainer +exclude CLAUDE.* +exclude llms-full.txt + +# Exclude deployment, mcp-servers and agent_runtimes +prune deployment +prune mcp-servers +prune agent_runtimes diff --git a/plugins/external/cedar/Makefile b/plugins/external/cedar/Makefile new file mode 100644 index 0000000..de98f77 --- /dev/null +++ b/plugins/external/cedar/Makefile @@ -0,0 +1,448 @@ + +REQUIRED_BUILD_BINS := uv + +SHELL := /bin/bash +.SHELLFLAGS := -eu -o pipefail -c + +# Project variables +PACKAGE_NAME = cedarpolicyplugin +PROJECT_NAME = cedarpolicyplugin +TARGET ?= cedarpolicyplugin + +# Virtual-environment variables +VENV_DIR ?= $(CURDIR)/.venv + +# ============================================================================= +# Linters +# ============================================================================= + +black: + @echo "🎨 black $(TARGET)..." && $(VENV_DIR)/bin/black -l 200 $(TARGET) + +black-check: + @echo "🎨 black --check $(TARGET)..." && $(VENV_DIR)/bin/black -l 200 --check --diff $(TARGET) + +ruff: + @echo "⚡ ruff $(TARGET)..." && $(VENV_DIR)/bin/ruff check $(TARGET) && $(VENV_DIR)/bin/ruff format $(TARGET) + +ruff-check: + @echo "⚡ ruff check $(TARGET)..." && $(VENV_DIR)/bin/ruff check $(TARGET) + +ruff-fix: + @echo "⚡ ruff check --fix $(TARGET)..." && $(VENV_DIR)/bin/ruff check --fix $(TARGET) + +ruff-format: + @echo "⚡ ruff format $(TARGET)..." && $(VENV_DIR)/bin/ruff format $(TARGET) + +# ============================================================================= +# Container runtime configuration and operations +# ============================================================================= + +# Container resource limits +CONTAINER_MEMORY = 2048m +CONTAINER_CPUS = 2 + +# Auto-detect container runtime if not specified - DEFAULT TO DOCKER +CONTAINER_RUNTIME ?= $(shell command -v docker >/dev/null 2>&1 && echo docker || echo podman) + +# Alternative: Always default to docker unless explicitly overridden +# CONTAINER_RUNTIME ?= docker + +# Container port +CONTAINER_PORT ?= 8000 +CONTAINER_INTERNAL_PORT ?= 8000 + +print-runtime: + @echo Using container runtime: $(CONTAINER_RUNTIME) + +# Base image name (without any prefix) +IMAGE_BASE ?= mcpgateway/$(PROJECT_NAME) +IMAGE_TAG ?= latest + +# Handle runtime-specific image naming +ifeq ($(CONTAINER_RUNTIME),podman) + # Podman adds localhost/ prefix for local builds + IMAGE_LOCAL := localhost/$(IMAGE_BASE):$(IMAGE_TAG) + IMAGE_LOCAL_DEV := localhost/$(IMAGE_BASE)-dev:$(IMAGE_TAG) + IMAGE_PUSH := $(IMAGE_BASE):$(IMAGE_TAG) +else + # Docker doesn't add prefix + IMAGE_LOCAL := $(IMAGE_BASE):$(IMAGE_TAG) + IMAGE_LOCAL_DEV := $(IMAGE_BASE)-dev:$(IMAGE_TAG) + IMAGE_PUSH := $(IMAGE_BASE):$(IMAGE_TAG) +endif + +print-image: + @echo "🐳 Container Runtime: $(CONTAINER_RUNTIME)" + @echo "Using image: $(IMAGE_LOCAL)" + @echo "Development image: $(IMAGE_LOCAL_DEV)" + @echo "Push image: $(IMAGE_PUSH)" + + + +# Function to get the actual image name as it appears in image list +define get_image_name +$(shell $(CONTAINER_RUNTIME) images --format "{{.Repository}}:{{.Tag}}" | grep -E "(localhost/)?$(IMAGE_BASE):$(IMAGE_TAG)" | head -1) +endef + +# Function to normalize image name for operations +define normalize_image +$(if $(findstring localhost/,$(1)),$(1),$(if $(filter podman,$(CONTAINER_RUNTIME)),localhost/$(1),$(1))) +endef + +# Containerfile to use (can be overridden) +#CONTAINER_FILE ?= Containerfile +CONTAINER_FILE ?= $(shell [ -f "Containerfile" ] && echo "Containerfile" || echo "Dockerfile") + +# Define COMMA for the conditional Z flag +COMMA := , + +container-info: + @echo "🐳 Container Runtime Configuration" + @echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + @echo "Runtime: $(CONTAINER_RUNTIME)" + @echo "Base Image: $(IMAGE_BASE)" + @echo "Tag: $(IMAGE_TAG)" + @echo "Local Image: $(IMAGE_LOCAL)" + @echo "Push Image: $(IMAGE_PUSH)" + @echo "Actual Image: $(call get_image_name)" + @echo "Container File: $(CONTAINER_FILE)" + @echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + +# Auto-detect platform based on uname +PLATFORM ?= linux/$(shell uname -m | sed 's/x86_64/amd64/;s/aarch64/arm64/') + +container-build: + @echo "🔨 Building with $(CONTAINER_RUNTIME) for platform $(PLATFORM)..." + $(CONTAINER_RUNTIME) build \ + --platform=$(PLATFORM) \ + -f $(CONTAINER_FILE) \ + --tag $(IMAGE_BASE):$(IMAGE_TAG) \ + . + @echo "✅ Built image: $(call get_image_name)" + $(CONTAINER_RUNTIME) images $(IMAGE_BASE):$(IMAGE_TAG) + +container-run: container-check-image + @echo "🚀 Running with $(CONTAINER_RUNTIME)..." + -$(CONTAINER_RUNTIME) stop $(PROJECT_NAME) 2>/dev/null || true + -$(CONTAINER_RUNTIME) rm $(PROJECT_NAME) 2>/dev/null || true + $(CONTAINER_RUNTIME) run --name $(PROJECT_NAME) \ + --env-file=.env \ + -p $(CONTAINER_PORT):$(CONTAINER_INTERNAL_PORT) \ + --restart=always \ + --memory=$(CONTAINER_MEMORY) --cpus=$(CONTAINER_CPUS) \ + --health-cmd="curl --fail http://localhost:$(CONTAINER_INTERNAL_PORT)/health || exit 1" \ + --health-interval=1m --health-retries=3 \ + --health-start-period=30s --health-timeout=10s \ + -d $(call get_image_name) + @sleep 2 + @echo "✅ Container started" + @echo "🔍 Health check status:" + @$(CONTAINER_RUNTIME) inspect $(PROJECT_NAME) --format='{{.State.Health.Status}}' 2>/dev/null || echo "No health check configured" + +container-run-host: container-check-image + @echo "🚀 Running with $(CONTAINER_RUNTIME)..." + -$(CONTAINER_RUNTIME) stop $(PROJECT_NAME) 2>/dev/null || true + -$(CONTAINER_RUNTIME) rm $(PROJECT_NAME) 2>/dev/null || true + $(CONTAINER_RUNTIME) run --name $(PROJECT_NAME) \ + --env-file=.env \ + --network=host \ + -p $(CONTAINER_PORT):$(CONTAINER_INTERNAL_PORT) \ + --restart=always \ + --memory=$(CONTAINER_MEMORY) --cpus=$(CONTAINER_CPUS) \ + --health-cmd="curl --fail http://localhost:$(CONTAINER_INTERNAL_PORT)/health || exit 1" \ + --health-interval=1m --health-retries=3 \ + --health-start-period=30s --health-timeout=10s \ + -d $(call get_image_name) + @sleep 2 + @echo "✅ Container started" + @echo "🔍 Health check status:" + @$(CONTAINER_RUNTIME) inspect $(PROJECT_NAME) --format='{{.State.Health.Status}}' 2>/dev/null || echo "No health check configured" + +container-push: container-check-image + @echo "📤 Preparing to push image..." + @# For Podman, we need to remove localhost/ prefix for push + @if [ "$(CONTAINER_RUNTIME)" = "podman" ]; then \ + actual_image=$$($(CONTAINER_RUNTIME) images --format "{{.Repository}}:{{.Tag}}" | grep -E "$(IMAGE_BASE):$(IMAGE_TAG)" | head -1); \ + if echo "$$actual_image" | grep -q "^localhost/"; then \ + echo "🏷️ Tagging for push (removing localhost/ prefix)..."; \ + $(CONTAINER_RUNTIME) tag "$$actual_image" $(IMAGE_PUSH); \ + fi; \ + fi + $(CONTAINER_RUNTIME) push $(IMAGE_PUSH) + @echo "✅ Pushed: $(IMAGE_PUSH)" + +container-check-image: + @echo "🔍 Checking for image..." + @if [ "$(CONTAINER_RUNTIME)" = "podman" ]; then \ + if ! $(CONTAINER_RUNTIME) image exists $(IMAGE_LOCAL) 2>/dev/null && \ + ! $(CONTAINER_RUNTIME) image exists $(IMAGE_BASE):$(IMAGE_TAG) 2>/dev/null; then \ + echo "❌ Image not found: $(IMAGE_LOCAL)"; \ + echo "💡 Run 'make container-build' first"; \ + exit 1; \ + fi; \ + else \ + if ! $(CONTAINER_RUNTIME) images -q $(IMAGE_LOCAL) 2>/dev/null | grep -q . && \ + ! $(CONTAINER_RUNTIME) images -q $(IMAGE_BASE):$(IMAGE_TAG) 2>/dev/null | grep -q .; then \ + echo "❌ Image not found: $(IMAGE_LOCAL)"; \ + echo "💡 Run 'make container-build' first"; \ + exit 1; \ + fi; \ + fi + @echo "✅ Image found" + +container-stop: + @echo "🛑 Stopping container..." + -$(CONTAINER_RUNTIME) stop $(PROJECT_NAME) 2>/dev/null || true + -$(CONTAINER_RUNTIME) rm $(PROJECT_NAME) 2>/dev/null || true + @echo "✅ Container stopped and removed" + +container-logs: + @echo "📜 Streaming logs (Ctrl+C to exit)..." + $(CONTAINER_RUNTIME) logs -f $(PROJECT_NAME) + +container-shell: + @echo "🔧 Opening shell in container..." + @if ! $(CONTAINER_RUNTIME) ps -q -f name=$(PROJECT_NAME) | grep -q .; then \ + echo "❌ Container $(PROJECT_NAME) is not running"; \ + echo "💡 Run 'make container-run' first"; \ + exit 1; \ + fi + @$(CONTAINER_RUNTIME) exec -it $(PROJECT_NAME) /bin/bash 2>/dev/null || \ + $(CONTAINER_RUNTIME) exec -it $(PROJECT_NAME) /bin/sh + +container-health: + @echo "🏥 Checking container health..." + @if ! $(CONTAINER_RUNTIME) ps -q -f name=$(PROJECT_NAME) | grep -q .; then \ + echo "❌ Container $(PROJECT_NAME) is not running"; \ + exit 1; \ + fi + @echo "Status: $$($(CONTAINER_RUNTIME) inspect $(PROJECT_NAME) --format='{{.State.Health.Status}}' 2>/dev/null || echo 'No health check')" + @echo "Logs:" + @$(CONTAINER_RUNTIME) inspect $(PROJECT_NAME) --format='{{range .State.Health.Log}}{{.Output}}{{end}}' 2>/dev/null || true + +container-build-multi: + @echo "🔨 Building multi-architecture image..." + @if [ "$(CONTAINER_RUNTIME)" = "docker" ]; then \ + if ! docker buildx inspect $(PROJECT_NAME)-builder >/dev/null 2>&1; then \ + echo "📦 Creating buildx builder..."; \ + docker buildx create --name $(PROJECT_NAME)-builder; \ + fi; \ + docker buildx use $(PROJECT_NAME)-builder; \ + docker buildx build \ + --platform=linux/amd64,linux/arm64 \ + -f $(CONTAINER_FILE) \ + --tag $(IMAGE_BASE):$(IMAGE_TAG) \ + --push \ + .; \ + elif [ "$(CONTAINER_RUNTIME)" = "podman" ]; then \ + echo "📦 Building manifest with Podman..."; \ + $(CONTAINER_RUNTIME) build --platform=linux/amd64,linux/arm64 \ + -f $(CONTAINER_FILE) \ + --manifest $(IMAGE_BASE):$(IMAGE_TAG) \ + .; \ + echo "💡 To push: podman manifest push $(IMAGE_BASE):$(IMAGE_TAG)"; \ + else \ + echo "❌ Multi-arch builds require Docker buildx or Podman"; \ + exit 1; \ + fi + +# Helper targets for debugging image issues +image-list: + @echo "📋 Images matching $(IMAGE_BASE):" + @$(CONTAINER_RUNTIME) images --format "table {{.Repository}}:{{.Tag}}\t{{.ID}}\t{{.Created}}\t{{.Size}}" | \ + grep -E "(IMAGE|$(IMAGE_BASE))" || echo "No matching images found" + +image-clean: + @echo "🧹 Removing all $(IMAGE_BASE) images..." + @$(CONTAINER_RUNTIME) images --format "{{.Repository}}:{{.Tag}}" | \ + grep -E "(localhost/)?$(IMAGE_BASE)" | \ + xargs $(XARGS_FLAGS) $(CONTAINER_RUNTIME) rmi -f 2>/dev/null + @echo "✅ Images cleaned" + +# Fix image naming issues +image-retag: + @echo "🏷️ Retagging images for consistency..." + @if [ "$(CONTAINER_RUNTIME)" = "podman" ]; then \ + if $(CONTAINER_RUNTIME) image exists $(IMAGE_BASE):$(IMAGE_TAG) 2>/dev/null; then \ + $(CONTAINER_RUNTIME) tag $(IMAGE_BASE):$(IMAGE_TAG) $(IMAGE_LOCAL) 2>/dev/null || true; \ + fi; \ + else \ + if $(CONTAINER_RUNTIME) images -q $(IMAGE_LOCAL) 2>/dev/null | grep -q .; then \ + $(CONTAINER_RUNTIME) tag $(IMAGE_LOCAL) $(IMAGE_BASE):$(IMAGE_TAG) 2>/dev/null || true; \ + fi; \ + fi + @echo "✅ Images retagged" # This always shows success + +# Runtime switching helpers +use-docker: + @echo "export CONTAINER_RUNTIME=docker" + @echo "💡 Run: export CONTAINER_RUNTIME=docker" + +use-podman: + @echo "export CONTAINER_RUNTIME=podman" + @echo "💡 Run: export CONTAINER_RUNTIME=podman" + +show-runtime: + @echo "Current runtime: $(CONTAINER_RUNTIME)" + @echo "Detected from: $$(command -v $(CONTAINER_RUNTIME) || echo 'not found')" # Added + @echo "To switch: make use-docker or make use-podman" + + + +# ============================================================================= +# Targets +# ============================================================================= + +.PHONY: venv +venv: + @rm -Rf "$(VENV_DIR)" + @mkdir -p "$(VENV_DIR)" + @python3 -m venv "$(VENV_DIR)" + @/bin/bash -c "source $(VENV_DIR)/bin/activate && python3 -m pip install --upgrade pip setuptools pdm uv" + @echo -e "✅ Virtual env created.\n💡 Enter it with:\n . $(VENV_DIR)/bin/activate\n" + +.PHONY: install +install: venv + $(foreach bin,$(REQUIRED_BUILD_BINS), $(if $(shell command -v $(bin) 2> /dev/null),,$(error Couldn't find `$(bin)`))) + @/bin/bash -c "source $(VENV_DIR)/bin/activate && python3 -m uv pip install ." + +.PHONY: install-dev +install-dev: venv + $(foreach bin,$(REQUIRED_BUILD_BINS), $(if $(shell command -v $(bin) 2> /dev/null),,$(error Couldn't find `$(bin)`))) + @/bin/bash -c "source $(VENV_DIR)/bin/activate && python3 -m uv pip install -e .[dev]" + +.PHONY: install-editable +install-editable: venv + $(foreach bin,$(REQUIRED_BUILD_BINS), $(if $(shell command -v $(bin) 2> /dev/null),,$(error Couldn't find `$(bin)`))) + @/bin/bash -c "source $(VENV_DIR)/bin/activate && python3 -m uv pip install -e .[dev]" + +.PHONY: uninstall +uninstall: + pip uninstall $(PACKAGE_NAME) + +.PHONY: dist +dist: clean ## Build wheel + sdist into ./dist + @test -d "$(VENV_DIR)" || $(MAKE) --no-print-directory venv + @/bin/bash -eu -c "\ + source $(VENV_DIR)/bin/activate && \ + python3 -m pip install --quiet --upgrade pip build && \ + python3 -m build" + @echo '🛠 Wheel & sdist written to ./dist' + +.PHONY: wheel +wheel: ## Build wheel only + @test -d "$(VENV_DIR)" || $(MAKE) --no-print-directory venv + @/bin/bash -eu -c "\ + source $(VENV_DIR)/bin/activate && \ + python3 -m pip install --quiet --upgrade pip build && \ + python3 -m build -w" + @echo '🛠 Wheel written to ./dist' + +.PHONY: sdist +sdist: ## Build source distribution only + @test -d "$(VENV_DIR)" || $(MAKE) --no-print-directory venv + @/bin/bash -eu -c "\ + source $(VENV_DIR)/bin/activate && \ + python3 -m pip install --quiet --upgrade pip build && \ + python3 -m build -s" + @echo '🛠 Source distribution written to ./dist' + +.PHONY: verify +verify: dist ## Build, run metadata & manifest checks + @/bin/bash -c "source $(VENV_DIR)/bin/activate && \ + twine check dist/* && \ + check-manifest && \ + pyroma -d ." + @echo "✅ Package verified - ready to publish." + +.PHONY: lint-fix +lint-fix: + @# Handle file arguments + @target_file="$(word 2,$(MAKECMDGOALS))"; \ + if [ -n "$$target_file" ] && [ "$$target_file" != "" ]; then \ + actual_target="$$target_file"; \ + else \ + actual_target="$(TARGET)"; \ + fi; \ + for target in $$(echo $$actual_target); do \ + if [ ! -e "$$target" ]; then \ + echo "❌ File/directory not found: $$target"; \ + exit 1; \ + fi; \ + done; \ + echo "🔧 Fixing lint issues in $$actual_target..."; \ + $(MAKE) --no-print-directory black TARGET="$$actual_target"; \ + $(MAKE) --no-print-directory ruff-fix TARGET="$$actual_target" + +.PHONY: lint-check +lint-check: + @# Handle file arguments + @target_file="$(word 2,$(MAKECMDGOALS))"; \ + if [ -n "$$target_file" ] && [ "$$target_file" != "" ]; then \ + actual_target="$$target_file"; \ + else \ + actual_target="$(TARGET)"; \ + fi; \ + for target in $$(echo $$actual_target); do \ + if [ ! -e "$$target" ]; then \ + echo "❌ File/directory not found: $$target"; \ + exit 1; \ + fi; \ + done; \ + echo "🔧 Fixing lint issues in $$actual_target..."; \ + $(MAKE) --no-print-directory black-check TARGET="$$actual_target"; \ + $(MAKE) --no-print-directory ruff-check TARGET="$$actual_target" + +.PHONY: lock +lock: + $(foreach bin,$(REQUIRED_BUILD_BINS), $(if $(shell command -v $(bin) 2> /dev/null),,$(error Couldn't find `$(bin)`. Please run `make init`))) + uv lock + +.PHONY: test +test: + pytest tests + +.PHONY: serve +serve: + @echo "Implement me." + +.PHONY: build +build: + @$(MAKE) container-build + +.PHONY: start +start: + @$(MAKE) container-run + +.PHONY: stop +stop: + @$(MAKE) container-stop + +.PHONY: clean +clean: + find . -type f -name '*.py[co]' -delete -o -type d -name __pycache__ -delete + rm -rf *.egg-info .pytest_cache tests/.pytest_cache build dist .ruff_cache .coverage + +.PHONY: help +help: + @echo "This Makefile is offered for convenience." + @echo "" + @echo "The following are the valid targets for this Makefile:" + @echo "...install Install package from sources" + @echo "...install-dev Install package from sources with dev packages" + @echo "...install-editable Install package from sources in editabled mode" + @echo "...uninstall Uninstall package" + @echo "...dist Clean-build wheel *and* sdist into ./dist" + @echo "...wheel Build wheel only" + @echo "...sdist Build source distribution only" + @echo "...verify Build + twine + check-manifest + pyroma (no upload)" + @echo "...serve Start API server locally" + @echo "...build Build API server container image" + @echo "...start Start the API server container" + @echo "...start Stop the API server container" + @echo "...lock Lock dependencies" + @echo "...lint-fix Check and fix lint errors" + @echo "...lint-check Check for lint errors" + @echo "...test Run all tests" + @echo "...clean Remove all artifacts and builds" diff --git a/plugins/external/cedar/README.md b/plugins/external/cedar/README.md new file mode 100644 index 0000000..a3b05ff --- /dev/null +++ b/plugins/external/cedar/README.md @@ -0,0 +1,403 @@ +# Cedar RBAC Plugin for ContextForge + +> Author: Shriti Priya +> Version: 0.1.0 + +A plugin that evaluates Cedar policies and user‑friendly custom-DSL policies on incoming requests, and then allows or denies those requests using RBAC-based decisions which are enforced in cedar language and using library `cedarpy`. + +## Cedar Language + +Cedar is an open-source language and specification for defining and evaluating permission policies. It allows you to specify who is authorized to perform which actions within your application. +For more details: https://www.cedarpolicy.com/en + +## RBAC + +Role-based access control (RBAC) is an authorization model where permissions are attached to roles (like admin, manager, viewer), and users are assigned to those roles instead of getting permissions directly. This makes access control easier to manage and reason about in larger systems. + +## CedarPolicyPlugin + +This plugin supports two ways of defining policies in the configuration file, controlled by the `policy_lang` parameter. + +### Cedar Mode + +`plugins/external/cedar/resources/config.yaml` + +When `policy_lang` is set to cedar, policies are written in the Cedar language under the policy key, using the following structure: + +```yaml + - id: allow-employee-basic-access + effect: Permit + principal: Role::"employee" + action: + - Action::"get_leave_balance" #tool name + - Action::"request_certificate" + resource: + - Server::"askHR" # mcp-server name + - Agent::"employee_agent" # agent name +``` +1. **id** is a unique string identifier for the policy. +2. **effect** can be either Permit or Forbid and determines whether matching requests are allowed or denied. +3. **principal** specifies who the policy applies to; here it targets the employee role. +4. **action** lists one or more tools that the principal is attempting to invoke. It could also be actions controlling the visibility of output, either to see full output or redacted output based on user role. +5. **resource** lists the servers, agents, prompts and resources that the actions can target. + +### Custom DSL mode + +`plugins/external/cedar/examples/config-dsl.yaml` + +When `policy_lang` is set to `custom_dsl`, policies are written in a compact, human-readable mini-language as a YAML multiline string. This allows non-experts to define role, resource, and action in a single, easy-to-scan block. +following syntax: + + +## Syntax + +Policies use the following basic pattern: + +``` +[role::/] + + +``` + +For example: + +```yaml + [role:hr:server/hr_tool] + update_payroll +``` + +In this example, role is hr, resource is server, and action is hr_tool. The line update_payroll represents the specific operation being authorized for that role–resource–action tuple. + + +## Configuration + +1. **policy_lang**: Specifies the policy language used, `cedar` or `custom_dsl`. +2. **policy_output_keywords**: Defines keywords for output views such as `view_full_output` and `view_redacted_output` which can be used in policies or applications to control the output visibility. +3. **policy_redaction_spec**: Contains a regex pattern for redaction; in this case, the pattern matches currency-like strings (e.g., "$123,456") for potential redaction in the policy output, protecting sensitive information. +4. **policy**: Defines the RBAC policy + +## Installation + +1. In the folder `plugins/external/cedar`, copy `.env.example` to `.env` file. +2. If you are using `policy_lang` to be `cedar`, add the plugin configuration to `plugins/external/cedar/resources/plugins/config.yaml`: + +```yaml +plugins: + - name: "CedarPolicyPlugin" + kind: "cedarpolicyplugin.plugin.CedarPolicyPlugin" + description: "A plugin that does policy decision and enforcement using cedar" + version: "0.1.0" + author: "Shriti Priya" + hooks: ["prompt_pre_fetch", "prompt_post_fetch", "tool_pre_invoke", "tool_post_invoke", "resource_pre_fetch", "resource_post_fetch"] + tags: ["plugin"] + mode: "enforce" # enforce | permissive | disabled + priority: 150 + conditions: + # Apply to specific tools/servers + - server_ids: [] # Apply to all servers + tenant_ids: [] # Apply to all tenants + config: + policy_lang: cedar + policy_output_keywords: + view_full: "view_full_output" + view_redacted: "view_redacted_output" + policy_redaction_spec: + pattern: '"\$\d{1,}(,\d{1,})*"' # provide regex, if none, then replace all + policy: + ### Tool invocation policies ### + - id: allow-employee-basic-access + effect: Permit + principal: Role::"employee" + action: + - Action::"get_leave_balance" #tool name + - Action::"request_certificate" + resource: + - Server::"askHR" # mcp-server name + - Agent::"employee_agent" # agent name + + - id: allow-manager-full-access + effect: Permit + principal: Role::"manager" + action: + - Action::"get_leave_balance" + - Action::"approve_leave" + - Action::"promote_employee" + - Action::"view_performance" + - Action::"view_full_output" + resource: + - Agent::"manager_agent" + - Server::"payroll_tool" + + - id: allow-hr-hr_tool + effect: Permit + principal: Role::"hr" + action: + - Action::"update_payroll" + - Action::"view_performance" + - Action::"view_full_output" + resource: Server::"hr_tool" + + - id: redact-non-manager-views + effect: Permit + principal: Role::"employee" + action: Action::"view_redacted_output" + resource: + - Server::"payroll_tool" + - Agent::"manager_agent" + - Server::"askHR" + + ### Resource invocation policies ### + - id: allow-admin-resources # policy for resources + effect: Permit + principal: Role::"admin" + action: + - Action::"view_full_output" + resource: Resource::""https://example.com/data"" #Resource:: + + - id: allow-employee-redacted-resources # policy for resources + effect: Permit + principal: Role::"employee" + action: + - Action::"view_redacted_output" + resource: Resource::""https://example.com/data"" #Resource:: + + ### Prompt invocation policies ### + - id: allow-admin-prompts # policy for resources + effect: Permit + principal: Role::"admin" + action: + - Action::"view_full_output" + resource: Prompt::"judge_prompts" #Prompt:: + + + - id: allow-employee-redacted-prompts # policy for resources + effect: Permit + principal: Role::"employee" + action: + - Action::"view_redacted_output" + resource: Prompt::"judge_prompts" #Prompt:: + +``` + +#### Tool Invocation Policies + +For the RBAC policy related to `tool_pre_invoke` and `tool_post_invoke` +Example: +```yaml + - id: allow-employee-basic-access + effect: Permit + principal: Role::"employee" + action: + - Action::"get_leave_balance" #tool name + - Action::"request_certificate" + resource: + - Server::"askHR" # mcp-server name + - Agent::"employee_agent" # agent name +``` + +Here, user with role `employee` (**Role**) is only allowed to invoke tool `get_leave_balance` (**Action**) belonging to the MCP server or (**Server**). + +In another policy defined for tools + +```yaml + + - id: allow-hr-hr_tool + effect: Permit + principal: Role::"hr" + action: + - Action::"update_payroll" + - Action::"view_performance" + - Action::"view_full_output" + resource: Server::"hr_tool" + + - id: redact-non-manager-views + effect: Permit + principal: Role::"employee" + action: Action::"view_redacted_output" + resource: + - Server::"payroll_tool" + - Agent::"manager_agent" + - Server::"askHR" +``` + + +The actions like `view_full_output` and `view_redacted_output` has been used. This basically controls the +level of output visibile to the user. In the above policy, user with role `hr` is only allowed to view the output of `update_payroll`. Similary for the second policy, user with role `employee` is only allowed to view redacted output of the tool. + + +#### Prompt Invocation Policies + + +```yaml + + ### Prompt invocation policies ### + - id: allow-admin-prompts # policy for resources + effect: Permit + principal: Role::"admin" + action: + - Action::"view_full_output" + resource: Prompt::"judge_prompts" #Prompt:: + + + - id: allow-employee-redacted-prompts # policy for resources + effect: Permit + principal: Role::"employee" + action: + - Action::"view_redacted_output" + resource: Prompt::"judge_prompts" #Prompt:: +``` + +Here, in the above polcicy, given a prompt template `judge_prompts`, user of role `admin` is only allowed to view full prompt. However, if a user is of role `employee`, then it could only see redacted version of the prompt. + + +#### Resource Invocation Policies + +**NOTE:** Please don't be confused with the word resource in cedar to the word resource in MCP ContextForge. + +```yaml + + - id: allow-admin-resources # policy for resources + effect: Permit + principal: Role::"admin" + action: + - Action::"view_full_output" + resource: Resource::"https://example.com/data" #Resource:: + + - id: allow-employee-redacted-resources # policy for resources + effect: Permit + principal: Role::"employee" + action: + - Action::"view_redacted_output" + resource: Resource::"https://example.com/data" #Resource:: +``` + +Here, `Resource` word used in policy, is if resource hooks are invoked. So, in the above policy, +user with role `admin` is only allowed to view full output of uri `https://example.com/data`. Where, the user is of `employee` role, it can only see the redacted versionaaaaa of the resource output. + + +#### policy_output_keywords + +``` + view_full: "view_full_output" + view_redacted: "view_redacted_output" +``` + +has been provided, so everytime a user defines a policy, if it wants to control the output visibility of +any of the tool, prompt, resource or agent in MCP gateway, it can provide the keyword, it's supposed to use in the policy in `policy_output_keywords`. CedarPolicyPlugin will internally use this mapping to redact or fully display the tool, prompt or resource response in post hooks. + + + + +3. Now, the policy and plugin configurations are defined in `resources/config.yaml` file, next step is build this as an external MCP server. + +* `make venv`: This will create a virtual environment to develop or build your plugin. +* `make install && make install-dev`: To install all the required libraries in the environment. +* `make build`: This will build a docker image named `mcpgateway/cedarpolicyplugin` +* `make start`: This will start the cedarpolicyplugin container. + +This confirms that your container is running fine: +``` +WARNING:mcpgateway.observability:OpenTelemetry not installed. Proceeding with graceful fallbacks. +INFO: Started server process [9] +INFO: Waiting for application startup. +INFO: Application startup complete. +INFO: Uvicorn running on http://0.0.0.0:8000 (Press CTRL+C to quit) +INFO: 127.0.0.1:55196 - "GET /health HTTP/1.1" 200 OK + +``` + +4. Now, you can add this external plugin configuration, in `plugins/config.yaml`: +3. The next step is to enable the opa plugin which you can do by adding `PLUGINS_ENABLED=true` and the following blob in `plugins/config.yaml` file. This will indicate that OPA Plugin is running as an external MCP server. + + ```yaml + - name: "CedarPolicyPlugin" + kind: "external" + priority: 10 # adjust the priority + mcp: + proto: STREAMABLEHTTP + url: http://127.0.0.1:8000/mcp + ``` + +## Testing + +There are set of test cases in the `cedar/tests` folder. The file named `test_cedarpolicyplugin.py` file which contains detailed test cases for RBAC policies enforced on tools, prompts and resources. +run `make test` to run all the test cases. + +## End to End Testing from the UI for ContextForge + +Run the following from the project root folder: + +```bash +# Generate JWT bearer token +python3 -m mcpgateway.utils.create_jwt_token --username admin@example.com --exp 10080 --secret my-test-key-but-now-longer-than-32-bytes + +# Export for API calls +export MCPGATEWAY_BEARER_TOKEN=$(python3 -m mcpgateway.utils.create_jwt_token --username admin@example.com --exp 0 --secret my-test-key-but-now-longer-than-32-bytes) +``` + + +1. Add server fast-time that exposes git tools in the mcp gateway + +Run the mcp server + +```bash +python3 -m mcpgateway.translate \ + --stdio "uvx mcp-server-git" \ + --expose-sse \ + --expose-streamable-http \ + --port 9000 +``` + +Add it to the gateway +```bash +curl -s -X POST -H "Authorization: Bearer $MCPGATEWAY_BEARER_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"name":"fast-time","url":"http://localhost:9000/sse"}' \ + http://localhost:4444/gateways +``` + +2. This adds server to the gateway and exposes all the tools for git. You would see `fast-time-git-status` as the tool appearing in the tools tab of mcp gateway. + +3. The next step is to run CedarPolicyPlugin as an external MCP server +Go into `plugins/external/cedar` and run the following: +- `make venv`, and enter the environment +- `make install && make install-dev` installs all the packages +- `make build` +- `make start` + +This will start CedarPolicyPlugin on port `8000`. + +4. Next step, is to add CedarPolicyPlugin in plugins by `PLUGINS_ENABLED=true` and the following blob in `plugins/config.yaml` file. This will indicate that CedarPolicyPlugin is running as an external MCP server. + + ```yaml + - name: "CedarPolicyPlugin" + kind: "external" + priority: 10 # adjust the priority + mcp: + proto: STREAMABLEHTTP + url: http://127.0.0.1:8000/mcp + ``` + +2. To test cedar plugin from the UI +Invoke the `fast-time-git-status` from the UI, with `admin` as role. +The request will be allowed by the CedarPolicyPlugin. + +If you switch to a another tool which is not in the policy `fast-time-git-show` expect denial by the CedarPolicyPlugin. For more details into policy, `plugins/external/cedar/resources/config.yaml` +You get the following as output in the UI: +```json + { + "message": "Plugin Violation: tool_pre_invoke not allowed", + "data": { + "description": "tool_pre_invoke not allowed", + "plugin_error_code": "DENY", + "plugin_name": "CedarPolicyPlugin" + } + } +``` + + +## Difference from OPAPlugin + +The OPA plugin runs an OPA server to enforce policies, whereas the Cedar plugin uses the `cedarpy` library and performs policy enforcement locally without requiring an external service. +OPA plugin requires to know `rego` to define policies by user while the `Cedar` plugin can be defined either in `cedar` or user friendly `custom_dsl` language. +Right now, the cedar plugin enforces RBAC policies and it could be extended to enforce ABAC policies using the same plugin. diff --git a/plugins/external/cedar/cedarpolicyplugin/__init__.py b/plugins/external/cedar/cedarpolicyplugin/__init__.py new file mode 100644 index 0000000..d44fada --- /dev/null +++ b/plugins/external/cedar/cedarpolicyplugin/__init__.py @@ -0,0 +1,24 @@ +# -*- coding: utf-8 -*- +"""ContextForge CedarPolicyPlugin Plugin - A plugin that does policy decision and enforcement using cedar. + +Copyright 2025 +SPDX-License-Identifier: Apache-2.0 +Authors: Shriti Priya + +""" + +import importlib.metadata + +# Package version +try: + __version__ = importlib.metadata.version("cedarpolicyplugin") +except Exception: + __version__ = "0.1.0" + +__author__ = "Shriti Priya" +__copyright__ = "Copyright 2025" +__license__ = "Apache 2.0" +__description__ = "A plugin that does policy decision and enforcement using cedar" +__url__ = "https://ibm.github.io/mcp-context-forge/" +__download_url__ = "https://github.com/IBM/mcp-context-forge" +__packages__ = ["cedarpolicyplugin"] diff --git a/plugins/external/cedar/cedarpolicyplugin/plugin-manifest.yaml b/plugins/external/cedar/cedarpolicyplugin/plugin-manifest.yaml new file mode 100644 index 0000000..38ec3ce --- /dev/null +++ b/plugins/external/cedar/cedarpolicyplugin/plugin-manifest.yaml @@ -0,0 +1,9 @@ +description: "A plugin that does policy decision and enforcement using cedar" +author: "Shriti Priya" +version: "0.1.0" +available_hooks: + - "prompt_pre_hook" + - "prompt_post_hook" + - "tool_pre_hook" + - "tool_post_hook" +default_configs: diff --git a/plugins/external/cedar/cedarpolicyplugin/plugin.py b/plugins/external/cedar/cedarpolicyplugin/plugin.py new file mode 100644 index 0000000..ad197e8 --- /dev/null +++ b/plugins/external/cedar/cedarpolicyplugin/plugin.py @@ -0,0 +1,670 @@ +# -*- coding: utf-8 -*- +"""A plugin that does policy decision and enforcement using cedar. + +Copyright 2025 +SPDX-License-Identifier: Apache-2.0 +Authors: Shriti Priya + +This module loads configurations for plugins. +""" + +# Standard +import asyncio +import copy +import logging +import re + +from enum import Enum +from typing import Any +from urllib.parse import urlparse + +# Third-Party +from cedarpy import AuthzResult, Decision, is_authorized, is_authorized_batch + +# First-Party +from cedarpolicyplugin.schema import CedarConfig, CedarInput +from cpex.framework import ( + Plugin, + PluginConfig, + PluginContext, + PluginError, + PluginErrorModel, + PluginViolation, + PromptPosthookPayload, + PromptPosthookResult, + PromptPrehookPayload, + PromptPrehookResult, + PromptHookType, + ResourcePostFetchPayload, + ResourcePostFetchResult, + ResourcePreFetchPayload, + ResourcePreFetchResult, + ResourceHookType, + ToolHookType, + ToolPostInvokePayload, + ToolPostInvokeResult, + ToolPreInvokePayload, + ToolPreInvokeResult, +) + +# Initialize logging service first +logger = logging.getLogger(__name__) + + +class CedarCodes(str, Enum): + """CedarCodes implementation.""" + + ALLOW_CODE = "ALLOW" + DENIAL_CODE = "DENY" + AUDIT_CODE = "AUDIT" + REQUIRES_HUMAN_APPROVAL_CODE = "REQUIRES_APPROVAL" + + +class CedarResponseTemplates(str, Enum): + """CedarResponseTemplates implementation.""" + + CEDAR_REASON = "Cedar policy denied for {hook_type}" + CEDAR_DESC = "{hook_type} not allowed" + + +class CedarResourceTemplates(str, Enum): + """CedarResourceTemplates implementation.""" + + SERVER = 'Server::"{resource_type}"' + AGENT = 'Agent::"{resource_type}"' + PROMPT = 'Prompt::"{resource_type}"' + RESOURCE = 'Resource::"{resource_type}"' + + +class CedarErrorCodes(str, Enum): + """CedarPolicyPlugin errors""" + + UNSUPPORTED_RESOURCE_TYPE = "Unspecified resource types, accepted resources server, prompt, agent and resource" + UNSPECIFIED_USER_ROLE = "User role is not defined" + UNSPECIFIED_POLICY = "No policy has been provided" + UNSPECIFIED_OUTPUT_ACTION = "Unspecified output action in policy configuration" + UNSPECIFIED_SERVER = "Unspecified server for tool request" + UNSUPPORTED_CONTENT_TYPE = "Unsupported content type" + INVALID_CEDAR_CONFIG = "Invalid cedar configuration" + CEDAR_POLICY_EVALUATION_ERROR = "Cedar policy evaluation error" + + +# Template for cedar policy +CEDAR_POLICY_TEMPLATE = """ +permit( + principal == {principal_str}, + action in [{actions_str}], + resource == {resource_str} +); +""" + + +class CedarPolicyPlugin(Plugin): + """A plugin that does policy decision and enforcement using cedar.""" + + def __init__(self, config: PluginConfig): + """Entry init block for plugin. + + Args: + logger: logger that the skill can make use of + config: the skill configuration + """ + super().__init__(config) + self.cedar_config = CedarConfig.model_validate(self._config.config) + self.cedar_context_key = "cedar_policy_context" + self.jwt_info: dict[str, dict[str, str]] = {} + self._cedar_policy: str | None = None + self._output_redaction_pattern: str | re.Pattern[str] | None = "all" + self._output_redaction_string: str = "[REDACTED]" + + # Regexes are compiled once + self._custom_dsl_pattern = re.compile(r"\[role:([A-Za-z0-9_]+):(resource|prompt|server|agent)/([^\]]+)\]") + redaction_spec = self.cedar_config.policy_redaction_spec + + if redaction_spec: + self._output_redaction_string = redaction_spec.redaction_str + if not redaction_spec.pattern: + self._output_redaction_pattern = None + elif redaction_spec.pattern == "all": + self._output_redaction_pattern = "all" + else: + self._output_redaction_pattern = re.compile(redaction_spec.pattern) + + if self.cedar_config.policy_lang == "cedar": + if self.cedar_config.policy: + self._cedar_policy = self._yamlpolicy2text(self.cedar_config.policy) + else: + logger.error(f"{CedarErrorCodes.UNSPECIFIED_POLICY.value}") + raise PluginError(PluginErrorModel(message=CedarErrorCodes.UNSPECIFIED_POLICY.value, plugin_name="CedarPolicyPlugin")) + if self.cedar_config.policy_lang == "custom_dsl": + if self.cedar_config.policy: + self._cedar_policy = self._dsl2cedar(self.cedar_config.policy) + else: + logger.error(f"{CedarErrorCodes.UNSPECIFIED_POLICY.value}") + raise PluginError(PluginErrorModel(message=CedarErrorCodes.UNSPECIFIED_POLICY.value, plugin_name="CedarPolicyPlugin")) + logger.info(f"CedarPolicyPlugin initialised with configuration {self.cedar_config}") + + def _set_jwt_info(self, user_role_mapping: dict) -> None: + """Sets user role mapping information from jwt tokens + + Args: + info(dict): with user mappings + """ + self.jwt_info["users"] = user_role_mapping + + def _create_dsl_policy_template(self, current_role: str, resource_category: str, current_actions: list, resource_name: str) -> dict[str, Any]: + """Create a Cedar policy dict from DSL-parsed role/resource/action components. + + Args: + current_role(str): role of the user + resource_category(str): category of resource + current_actions(list): a specified list of actions + resource_name(str): name of the resource + """ + return { + "id": f"allow-{current_role}-{resource_category}", + "effect": "Permit", + "principal": f'Role::"{current_role}"', + "action": [f'Action::"{a}"' for a in current_actions], + "resource": f'{resource_category}::"{resource_name}"', + } + + def _extract_payload_key(self, content: Any = None, key: str = None, result: dict[str, list] = None) -> None: + """Function to extract values of passed in key in the payload recursively based on if the content is of type list, dict + str or pydantic structure. The value is inplace updated in result. + + Args: + content: The content of post hook results. + key: The key for which value needs to be extracted for. + result: A list of all the values for a key. + """ + if isinstance(content, list): + for element in content: + if isinstance(element, dict) and key in element: + self._extract_payload_key(element, key, result) + elif isinstance(content, dict): + if key in content or hasattr(content, key): + result[key].append(content[key]) + elif isinstance(content, str): + result[key].append(content) + elif hasattr(content, key): + result[key].append(getattr(content, key)) + else: + logger.error(f"{CedarErrorCodes.UNSUPPORTED_CONTENT_TYPE.value}: {type(content)}") + raise PluginError(PluginErrorModel(message=CedarErrorCodes.UNSUPPORTED_CONTENT_TYPE.value, plugin_name="CedarPolicyPlugin")) + + async def _evaluate_policy(self, request: dict, policy_expr: str) -> str: + """Function that evaluates and enforce cedar policy using is_authorized function in cedarpy library + Args: + request(dict): The request dict consisting of principal, action, resource or context keys. + policy_exp(str): The policy expression to evaluate the request on + + Returns: + decision(str): "Allow" or "Deny" + """ + result: AuthzResult = await asyncio.to_thread(is_authorized, request, policy_expr, []) + if result.decision == Decision.NoDecision: + raise PluginError(PluginErrorModel(message=CedarErrorCodes.CEDAR_POLICY_EVALUATION_ERROR.value, plugin_name="CedarPolicyPlugin", details=result.diagnostics.errors)) + return "Allow" if result.decision == Decision.Allow else "Deny" + + async def _evaluate_policy_batch(self, batch_requests: dict, policy_expr: str) -> dict: + """Function that evaluates and enforce cedar policy using is_authorized function in cedarpy library + Args: + request(dict): The request dict consisting of principal, action, resource or context keys. + policy_exp(str): The policy expression to evaluate the request on + + Returns: + decision(str): "Allow" or "Deny" + """ + results: list[AuthzResult] = await asyncio.to_thread(is_authorized_batch, batch_requests, policy_expr, []) + return {res.correlation_id: res.decision.value for res in results} + + def _yamlpolicy2text(self, policies: list) -> str: + """Function to convert yaml representation of policies to text + Args: + policies(list): A list of cedar policies with dict values consisting of individual policies + + Returns: + cedar_policy_text(str): string representation of policy + """ + cedar_policy_texts = [] + for policy in policies: + actions = policy["action"] if isinstance(policy["action"], list) else [policy["action"]] + resources = policy["resource"] if isinstance(policy["resource"], list) else [policy["resource"]] + for res in resources: + cedar_policy_texts.append(CEDAR_POLICY_TEMPLATE.format(principal_str=policy["principal"], actions_str=", ".join(actions), resource_str=res)) + return "\n\n".join(cedar_policy_texts) + + def _dsl2cedar(self, policy_string: str) -> str: + """Function to convert custom dsl representation of policies to cedar + Args: + policy_string: string representation of policies + + Returns: + cedar_policy_text(str): string representation of policy + """ + lines = [line.strip() for line in policy_string.splitlines() if line.strip()] + policies = [] + current_role = None + current_actions = [] + resource_category = None + resource_name = None + for line in lines: + match = self._custom_dsl_pattern.match(line) + if match: + if current_role and resource_category and resource_name and current_actions: + policies.append( + self._create_dsl_policy_template(current_role=current_role, resource_category=resource_category.capitalize(), current_actions=current_actions, resource_name=resource_name) + ) + current_role, resource_category, resource_name = match.groups() + current_actions = [] + else: + current_actions.append(line) + + if current_role and resource_category and resource_name and current_actions: + policies.append(self._create_dsl_policy_template(current_role=current_role, resource_category=resource_category.capitalize(), current_actions=current_actions, resource_name=resource_name)) + + cedar_policy_text = self._yamlpolicy2text(policies) + return cedar_policy_text + + def _preprocess_request(self, user: Any = None, action: str = "", resource: str = "", hook_type: str = "", context: dict | None = None, correlation_id: str = "") -> CedarInput: + """Function to pre process request into a format that cedar accepts + Args: + user(str): name of the user + action(str): action requested by the user + resource(str): resource requested by the user + hook_type(str): the hook type on which invocation is made + + Returns: + request(CedarInput): pydantic representation of request as excpected by cedar policy + """ + if user is None: + user = {} + if context is None: + context = {} + user_role = None + if "tool" in hook_type: + resource_expr = CedarResourceTemplates.SERVER.format(resource_type=resource) + elif "agent" in hook_type: + resource_expr = CedarResourceTemplates.AGENT.format(resource_type=resource) + elif "resource" in hook_type: + resource_expr = CedarResourceTemplates.RESOURCE.format(resource_type=resource) + elif "prompt" in hook_type: + resource_expr = CedarResourceTemplates.PROMPT.format(resource_type=resource) + else: + logger.error(f"{CedarErrorCodes.UNSUPPORTED_RESOURCE_TYPE.value}: {hook_type}") + raise PluginError(PluginErrorModel(message=CedarErrorCodes.UNSUPPORTED_RESOURCE_TYPE.value, plugin_name="CedarPolicyPlugin")) + + if len(self.jwt_info) > 0 and "users" in self.jwt_info: + user_role = self.jwt_info["users"].get(user) + elif "is_admin" in user: + user_role = "admin" if user["is_admin"] else "non-admin" + else: + logger.error(f"{CedarErrorCodes.UNSPECIFIED_USER_ROLE.value}") + raise PluginError(PluginErrorModel(message=CedarErrorCodes.UNSPECIFIED_USER_ROLE.value, plugin_name="CedarPolicyPlugin")) + + principal_expr = f'Role::"{user_role}"' + action_expr = f'Action::"{action}"' + request = CedarInput(principal=principal_expr, action=action_expr, resource=resource_expr, context=context, correlation_id=correlation_id).model_dump() + return request + + def _redact_output(self, payload: Any) -> Any: + """Function that redacts the output of prompt, tool or resource + NOTE: It's an extremely simple logic for redaction, could be replaced with more advanced + as per need. + Args: + payload(str): payload or output + """ + if not self.cedar_config.policy_redaction_spec: + return payload + + if not self._output_redaction_pattern: + return payload + + if isinstance(payload, dict): + for key, value in payload.items(): + payload[key] = self._redact_output(value) + elif isinstance(payload, list): + for i, item in enumerate(payload): + payload[i] = self._redact_output(item) + elif isinstance(payload, str): + if self._output_redaction_pattern == "all": + payload = self._output_redaction_string + else: + payload = self._output_redaction_pattern.sub(self._output_redaction_string, payload) + + return payload + + def _get_output_request_list(self, user: str, payload: str, hook_type: str) -> list: + """Function that takes in user, payload and hook_type to give an appended list of requests for + full view and redacted view + Args: + user(str): user name or id + payload(str): payload or output + hook_type(str): the type of hook. For example - tool_pre_invoke, tool_post_invoke etc. + """ + output_view_checks = [] + resource = payload + + view_full = self.cedar_config.policy_output_keywords.get("view_full", None) + view_redacted = self.cedar_config.policy_output_keywords.get("view_redacted", None) + if view_full: + request = self._preprocess_request(user, view_full, resource, hook_type, context={}, correlation_id="view_full") + output_view_checks.append(request) + if view_redacted: + request = self._preprocess_request(user, view_redacted, resource, hook_type, context={}, correlation_id="view_redacted") + output_view_checks.append(request) + return output_view_checks + + def _check_view_disallowed(self, results_map: dict) -> bool: + """Function that takes in results of policy evaluation and then checks gives final allow or deny on view type + Args: + results_map(str): dict + """ + return ("view_full" in results_map and results_map["view_full"] == "Deny") and ("view_redacted" in results_map and results_map["view_redacted"] == "Deny") + + async def prompt_pre_fetch(self, payload: PromptPrehookPayload, context: PluginContext) -> PromptPrehookResult: + """The plugin hook run before a prompt is retrieved and rendered. + + Args: + payload: The prompt payload to be analyzed. + context: contextual information about the hook call. + + Returns: + The result of the plugin's analysis, including whether the prompt can proceed. + """ + hook_type = PromptHookType.PROMPT_PRE_FETCH + logger.info(f"Processing {hook_type} for '{payload.args}' with {len(payload.args) if payload.args else 0}") + logger.info(f"Processing context {context}") + + if not payload.args: + return PromptPrehookResult() + + user = "" + + if context.global_context.user: + user = context.global_context.user + + # If a cedar policy has been defined + if self._cedar_policy: + # If output keywords have been defined + if self.cedar_config.policy_output_keywords: + output_view_checks = self._get_output_request_list(user, payload.prompt_id, hook_type) + if len(output_view_checks) > 0: + results_map = await self._evaluate_policy_batch(output_view_checks, self._cedar_policy) + if self._check_view_disallowed(results_map): + violation = PluginViolation( + reason=CedarResponseTemplates.CEDAR_REASON.format(hook_type=hook_type), + description=CedarResponseTemplates.CEDAR_DESC.format(hook_type=hook_type), + code=CedarCodes.DENIAL_CODE, + details={}, + ) + return PromptPrehookResult(modified_payload=payload, violation=violation, continue_processing=False) + else: + return PromptPrehookResult(continue_processing=True) + else: + logger.error(f"{CedarErrorCodes.UNSPECIFIED_OUTPUT_ACTION.value}") + raise PluginError(PluginErrorModel(message=CedarErrorCodes.UNSPECIFIED_OUTPUT_ACTION.value, plugin_name="CedarPolicyPlugin")) + return PromptPrehookResult(continue_processing=True) + + async def prompt_post_fetch(self, payload: PromptPosthookPayload, context: PluginContext) -> PromptPosthookResult: + """Plugin hook run after a prompt is rendered. + + Args: + payload: The prompt payload to be analyzed. + context: Contextual information about the hook call. + + Returns: + The result of the plugin's analysis, including whether the prompt can proceed. + """ + hook_type = PromptHookType.PROMPT_POST_FETCH + logger.info(f"Processing {hook_type} for '{payload.result}'") + logger.info(f"Processing context {context}") + + if not payload.result: + return PromptPosthookResult() + + user = "" + + if context.global_context.user: + user = context.global_context.user + + if self._cedar_policy: + # If output keywords have been defined + if self.cedar_config.policy_output_keywords: + output_view_checks = self._get_output_request_list(user, payload.prompt_id, hook_type) + if len(output_view_checks) > 0: + results_map = await self._evaluate_policy_batch(output_view_checks, self._cedar_policy) + if "view_full" in results_map and results_map["view_full"] == "Allow": + return PromptPosthookResult(continue_processing=True) + elif "view_redacted" in results_map and results_map["view_redacted"] == "Allow": + if payload.result.messages: + for index, message in enumerate(payload.result.messages): + value = copy.deepcopy(message.content.text) + payload.result.messages[index].content.text = self._redact_output(value) + return PromptPosthookResult(modified_payload=payload, continue_processing=True) + else: + violation = PluginViolation( + reason=CedarResponseTemplates.CEDAR_REASON.format(hook_type=hook_type), + description=CedarResponseTemplates.CEDAR_DESC.format(hook_type=hook_type), + code=CedarCodes.DENIAL_CODE, + details={}, + ) + return PromptPosthookResult(modified_payload=payload, violation=violation, continue_processing=False) + else: + return PromptPosthookResult(continue_processing=True) + else: + logger.error(f"{CedarErrorCodes.UNSPECIFIED_OUTPUT_ACTION.value}") + raise PluginError(PluginErrorModel(message=CedarErrorCodes.UNSPECIFIED_OUTPUT_ACTION.value, plugin_name="CedarPolicyPlugin")) + return PromptPosthookResult(continue_processing=True) + + async def tool_pre_invoke(self, payload: ToolPreInvokePayload, context: PluginContext) -> ToolPreInvokeResult: + """Plugin hook run before a tool is invoked. + + Args: + payload: The tool payload to be analyzed. + context: Contextual information about the hook call. + + Returns: + The result of the plugin's analysis, including whether the tool can proceed. + """ + hook_type = ToolHookType.TOOL_PRE_INVOKE + logger.info(f"Processing {hook_type} for '{payload.args}' with {len(payload.args) if payload.args else 0}") + logger.info(f"Processing context {context}") + + if not payload.args: + return ToolPreInvokeResult() + + user = "" + server_id = "" + if context.global_context.user: + user = context.global_context.user + + if "gateway" in context.global_context.metadata and "name" in context.global_context.metadata["gateway"]: + server_id = context.global_context.metadata["gateway"]["name"] + else: + server_id = context.global_context.server_id + + if server_id: + request = self._preprocess_request(user, payload.name, server_id, hook_type) + else: + logger.error(f"{CedarErrorCodes.UNSPECIFIED_SERVER.value}") + raise PluginError(PluginErrorModel(message=CedarErrorCodes.UNSPECIFIED_SERVER.value, plugin_name="CedarPolicyPlugin")) + + if self._cedar_policy: + decision = await self._evaluate_policy(request, self._cedar_policy) + if decision == "Deny": + violation = PluginViolation( + reason=CedarResponseTemplates.CEDAR_REASON.format(hook_type=hook_type), + description=CedarResponseTemplates.CEDAR_DESC.format(hook_type=hook_type), + code=CedarCodes.DENIAL_CODE, + details={}, + ) + return ToolPreInvokeResult(modified_payload=payload, violation=violation, continue_processing=False) + return ToolPreInvokeResult(continue_processing=True) + + async def tool_post_invoke(self, payload: ToolPostInvokePayload, context: PluginContext) -> ToolPostInvokeResult: + """Plugin hook run after a tool is invoked. + + Args: + payload: The tool result payload to be analyzed. + context: Contextual information about the hook call. + + Returns: + The result of the plugin's analysis, including whether the tool result should proceed. + """ + hook_type = ToolHookType.TOOL_POST_INVOKE + logger.info(f"Processing {hook_type} for '{payload.result}' with {len(payload.result) if payload.result else 0}") + logger.info(f"Processing context {context}") + + if not payload.result: + return ToolPostInvokeResult() + + user = "" + server_id = "" + + if context.global_context.user: + user = context.global_context.user + + if "gateway" in context.global_context.metadata and "name" in context.global_context.metadata["gateway"]: + server_id = context.global_context.metadata["gateway"]["name"] + else: + server_id = context.global_context.server_id + + # If a cedar policy has been defined + if self._cedar_policy: + # If output keywords have been defined + if self.cedar_config.policy_output_keywords: + output_view_checks = self._get_output_request_list(user, server_id, hook_type) + if len(output_view_checks) > 0: + request = self._preprocess_request(user, payload.name, server_id, hook_type, context={}, correlation_id="tool") + output_view_checks.append(request) + results_map = await self._evaluate_policy_batch(output_view_checks, self._cedar_policy) + if results_map["tool"] == "Allow": + if "view_full" in results_map and results_map["view_full"] == "Allow": + return ToolPostInvokeResult(continue_processing=True) + elif "view_redacted" in results_map and results_map["view_redacted"] == "Allow": + data = copy.deepcopy(payload.result) + redacted = self._redact_output(payload=data) + payload = payload.model_copy(update={"result": redacted}) + return ToolPostInvokeResult(continue_processing=True, modified_payload=payload) + else: + return ToolPostInvokeResult(continue_processing=True) + else: + violation = PluginViolation( + reason=CedarResponseTemplates.CEDAR_REASON.format(hook_type=hook_type), + description=CedarResponseTemplates.CEDAR_DESC.format(hook_type=hook_type), + code=CedarCodes.DENIAL_CODE, + details={}, + ) + return ToolPostInvokeResult(modified_payload=payload, violation=violation, continue_processing=False) + else: + logger.error(f"{CedarErrorCodes.UNSPECIFIED_OUTPUT_ACTION.value}") + raise PluginError(PluginErrorModel(message=CedarErrorCodes.UNSPECIFIED_OUTPUT_ACTION.value, plugin_name="CedarPolicyPlugin")) + return ToolPostInvokeResult(continue_processing=True) + + async def resource_pre_fetch(self, payload: ResourcePreFetchPayload, context: PluginContext) -> ResourcePreFetchResult: + """Cedar Plugin hook that runs before resource fetch. This hook takes in payload and context and evaluates cedar + policies on the input. + + Args: + payload: The resource pre fetch input or payload to be analyzed. + context: Contextual information about the hook call. + + Returns: + The result of the plugin's analysis, including whether the resource input can be passed further. + """ + + hook_type = ResourceHookType.RESOURCE_PRE_FETCH.value + logger.info(f"Processing {hook_type} for '{payload.uri}'") + logger.info(f"Processing context {context}") + + if not payload.uri: + return ResourcePreFetchResult() + + try: + parsed = urlparse(payload.uri) + except Exception as e: + violation = PluginViolation(reason="Invalid URI", description=f"Could not parse resource URI: {e}", code="INVALID_URI", details={"uri": payload.uri, "error": str(e)}) + return ResourcePreFetchResult(continue_processing=False, violation=violation) + + # Check if URI has a scheme + if not parsed.scheme: + violation = PluginViolation(reason="Invalid URI format", description="URI must have a valid scheme (protocol)", code="INVALID_URI", details={"uri": payload.uri}) + return ResourcePreFetchResult(continue_processing=False, violation=violation) + + user = "" + + if context.global_context.user: + user = context.global_context.user + + if self._cedar_policy: + # If output keywords have been defined + if self.cedar_config.policy_output_keywords: + output_view_checks = self._get_output_request_list(user, payload.uri, hook_type) + if len(output_view_checks) > 0: + results_map = await self._evaluate_policy_batch(output_view_checks, self._cedar_policy) + if self._check_view_disallowed(results_map): + violation = PluginViolation( + reason=CedarResponseTemplates.CEDAR_REASON.format(hook_type=hook_type), + description=CedarResponseTemplates.CEDAR_DESC.format(hook_type=hook_type), + code=CedarCodes.DENIAL_CODE, + details={}, + ) + return ResourcePreFetchResult(modified_payload=payload, violation=violation, continue_processing=False) + else: + return ResourcePreFetchResult(continue_processing=True) + else: + logger.error(f"{CedarErrorCodes.UNSPECIFIED_OUTPUT_ACTION.value}") + raise PluginError(PluginErrorModel(message=CedarErrorCodes.UNSPECIFIED_OUTPUT_ACTION.value, plugin_name="CedarPolicyPlugin")) + return ResourcePreFetchResult(continue_processing=True) + + async def resource_post_fetch(self, payload: ResourcePostFetchPayload, context: PluginContext) -> ResourcePostFetchResult: + """Cedar Plugin hook that runs after resource post fetch. This hook takes in payload and context and evaluates cedar + policies on the output. + + Args: + payload: The resource post fetch output or payload to be analyzed. + context: Contextual information about the hook call. + + Returns: + The result of the plugin's analysis, including whether the resource output can be passed further. + """ + hook_type = ResourceHookType.RESOURCE_POST_FETCH.value + logger.info(f"Processing {hook_type} for '{payload.uri}'") + logger.info(f"Processing context {context}") + + user = "" + + if context.global_context.user: + user = context.global_context.user + + if self._cedar_policy: + # If output keywords have been defined + if self.cedar_config.policy_output_keywords: + output_view_checks = self._get_output_request_list(user, payload.uri, hook_type) + if len(output_view_checks) > 0: + results_map = await self._evaluate_policy_batch(output_view_checks, self._cedar_policy) + if "view_full" in results_map and results_map["view_full"] == "Allow": + return ResourcePostFetchResult(continue_processing=True) + elif "view_redacted" in results_map and results_map["view_redacted"] == "Allow": + if payload.content: + if hasattr(payload.content, "text"): + value = copy.deepcopy(payload.content.text) + payload.content.text = self._redact_output(value) + return ResourcePostFetchResult(modified_payload=payload, continue_processing=True) + else: + violation = PluginViolation( + reason=CedarResponseTemplates.CEDAR_REASON.format(hook_type=hook_type), + description=CedarResponseTemplates.CEDAR_DESC.format(hook_type=hook_type), + code=CedarCodes.DENIAL_CODE, + details={}, + ) + return ResourcePostFetchResult(modified_payload=payload, violation=violation, continue_processing=False) + else: + return ResourcePostFetchResult(continue_processing=True) + else: + logger.error(f"{CedarErrorCodes.UNSPECIFIED_OUTPUT_ACTION.value}") + raise PluginError(PluginErrorModel(message=CedarErrorCodes.UNSPECIFIED_OUTPUT_ACTION.value, plugin_name="CedarPolicyPlugin")) + + return ResourcePostFetchResult(continue_processing=True) diff --git a/plugins/external/cedar/cedarpolicyplugin/schema.py b/plugins/external/cedar/cedarpolicyplugin/schema.py new file mode 100644 index 0000000..3dcfb56 --- /dev/null +++ b/plugins/external/cedar/cedarpolicyplugin/schema.py @@ -0,0 +1,60 @@ +# -*- coding: utf-8 -*- +"""A schema file for OPA plugin. + +Copyright 2025 +SPDX-License-Identifier: Apache-2.0 +Authors: Shriti Priya + +This module defines schema for Cedar plugin. +""" + +# Standard +from typing import Any, Optional, Union + +# Third-Party +from pydantic import BaseModel + + +class CedarInput(BaseModel): + """BaseOPAInputKeys + + Attributes: + user (str) : specifying the user + action (str): specifies the action + resource (str): specifies the resource + context (Optional[dict[str, Any]]) : context provided for policy evaluation. + """ + + principal: str = "" + action: str = "" + resource: str = "" + context: Optional[dict[Any, Any]] = None + correlation_id: Optional[str] = None + + +class Redaction(BaseModel): + """Configuration for Redaction + + Attributes: + pattern (str) : pattern detected in output to redact + redaction_str (str): string to output + """ + + pattern: str = "" + redaction_str: str = "" + + +class CedarConfig(BaseModel): + """Configuration for the Cedar plugin. + + Attributes: + policy_lang (str) : cedar or custom_dsl. If policy is represented in cedar mode or custom_dsl mode + policy (Union[list, str]): RBAC policy defined + policy_output_keywords (dict): this is to internally check if certain type of views are allowed for outputs + policy_redaction_spec (Redaction) : pattern or other parameters provided to redact the output + """ + + policy_lang: str = "None" + policy: Optional[Union[list, str]] = None + policy_output_keywords: Optional[dict] = None + policy_redaction_spec: Optional[Redaction] = None diff --git a/plugins/external/cedar/examples/config-cedar.yaml b/plugins/external/cedar/examples/config-cedar.yaml new file mode 100644 index 0000000..a586bf9 --- /dev/null +++ b/plugins/external/cedar/examples/config-cedar.yaml @@ -0,0 +1,103 @@ +plugins: + - name: "CedarPolicyPlugin" + kind: "cedarpolicyplugin.plugin.CedarPolicyPlugin" + description: "A plugin that does policy decision and enforcement using cedar" + version: "0.1.0" + author: "Shriti Priya" + hooks: ["prompt_pre_fetch", "prompt_post_fetch", "tool_pre_invoke", "tool_post_invoke"] + tags: ["plugin"] + mode: "sequential" # sequential | transform | disabled + priority: 150 + conditions: + # Apply to specific tools/servers + - server_ids: [] # Apply to all servers + tenant_ids: [] # Apply to all tenants + config: + policy_lang: cedar + policy_output_keywords: + view_full: "view_full_output" + view_redacted: "view_redacted_output" + policy_redaction_spec: + pattern: '"\$\d{1,}(,\d{1,})*"' # provide regex, if none, then replace all + policy: + - id: allow-employee-basic-access + effect: Permit + principal: Role::"employee" + action: + - Action::"get_leave_balance" #tool name + - Action::"request_certificate" + resource: + - Server::"askHR" # mcp-server name + - Agent::"employee_agent" # agent name + + - id: allow-manager-full-access + effect: Permit + principal: Role::"manager" + action: + - Action::"get_leave_balance" + - Action::"approve_leave" + - Action::"promote_employee" + - Action::"view_performance" + - Action::"view_full_output" + resource: + - Agent::"manager_agent" + - Server::"payroll_tool" + + - id: allow-hr-hr_tool + effect: Permit + principal: Role::"hr" + action: + - Action::"update_payroll" + - Action::"view_performance" + - Action::"view_full_output" + resource: Server::"hr_tool" + + - id: redact-non-manager-views + effect: Permit + principal: Role::"employee" + action: Action::"view_redacted_output" + resource: + - Server::"payroll_tool" + - Agent::"manager_agent" + - Server::"askHR" + + - id: allow-admin-resources # policy for resources + effect: Permit + principal: Role::"admin" + action: + - Action::"view_full_output" + resource: Resource::""https://example.com/data"" #Resource:: + + - id: allow-employee-redacted-resources # policy for resources + effect: Permit + principal: Role::"employee" + action: + - Action::"view_redacted_output" + resource: Resource::""https://example.com/data"" #Resource:: + + - id: allow-admin-prompts # policy for resources + effect: Permit + principal: Role::"admin" + action: + - Action::"view_full_output" + resource: Prompts::"judge_prompts" #Prompt:: + + - id: allow-employee-redacted-prompts # policy for resources + effect: Permit + principal: Role::"employee" + action: + - Action::"view_redacted_output" + resource: Prompts::"judge_prompts" #Prompt:: + +# Plugin directories to scan +plugin_dirs: + - "cedarpolicyplugin" + +# Global plugin settings +plugin_settings: + parallel_execution_within_band: true + plugin_timeout: 30 + fail_on_plugin_error: false + enable_plugin_api: true + plugin_health_check_interval: 60 + include_user_info: true diff --git a/plugins/external/cedar/examples/config-dsl.yaml b/plugins/external/cedar/examples/config-dsl.yaml new file mode 100644 index 0000000..d7df05c --- /dev/null +++ b/plugins/external/cedar/examples/config-dsl.yaml @@ -0,0 +1,43 @@ +plugins: + - name: "CedarPolicyPlugin" + kind: "cedarpolicyplugin.plugin.CedarPolicyPlugin" + description: "A plugin that does policy decision and enforcement using cedar" + version: "0.1.0" + author: "Shriti Priya" + hooks: ["prompt_pre_fetch", "prompt_post_fetch", "tool_pre_invoke", "tool_post_invoke"] + tags: ["plugin"] + mode: "sequential" # sequential | transform | disabled + priority: 150 + conditions: + # Apply to specific tools/servers + - server_ids: [] # Apply to all servers + tenant_ids: [] # Apply to all tenants + config: + policy_lang: custom_dsl + policy_output_keywords: + view_full: "view_full_output" + view_redacted: "view_redacted_output" + policy_redaction_spec: + pattern: '"\$\d{1,}(,\d{1,})*"' # provide regex, if none, then replace all + policy: | + [role:hr:server/hr_tool] + update_payroll + + [role:admin:resource/example.com/data] + view_full_output + + [role:admin:prompt/judge_prompts] + view_full_output + + +# Plugin directories to scan +plugin_dirs: + - "cedarpolicyplugin" + +# Global plugin settings +plugin_settings: + parallel_execution_within_band: true + plugin_timeout: 30 + fail_on_plugin_error: false + enable_plugin_api: true + plugin_health_check_interval: 60 diff --git a/plugins/external/cedar/pyproject.toml b/plugins/external/cedar/pyproject.toml new file mode 100644 index 0000000..58370df --- /dev/null +++ b/plugins/external/cedar/pyproject.toml @@ -0,0 +1,96 @@ +# ---------------------------------------------------------------- +# 💡 Build system (PEP 517) +# - setuptools ≥ 78.1.1 gives SPDX licence support (PEP 639) and fixes CVE-2025-47273 +# - wheel is needed by most build front-ends +# ---------------------------------------------------------------- +[build-system] +requires = ["setuptools>=78.1.1", "wheel>=0.46.2"] +build-backend = "setuptools.build_meta" + +# ---------------------------------------------------------------- +# 📦 Core project metadata (PEP 621) +# ---------------------------------------------------------------- +[project] +name = "cedarpolicyplugin" +version = "0.1.0" +description = "A plugin that does policy decision and enforcement using cedar" +keywords = ["MCP","API","gateway","tools", + "agents","agentic ai","model context protocol","multi-agent","fastapi", + "json-rpc","sse","websocket","federation","security","authentication" +] +classifiers = [ + "Development Status :: 4 - Beta", + "Intended Audience :: Developers", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Framework :: FastAPI", + "Framework :: AsyncIO", + "Topic :: Internet :: WWW/HTTP :: WSGI :: Application", + "Topic :: Software Development :: Libraries :: Application Frameworks" +] +readme = "README.md" +requires-python = ">=3.12,<3.14" +license = "Apache-2.0" +license-files = ["LICENSE"] + +maintainers = [ + {name = "Shriti Priya", email = "shritip@ibm.com"} +] + +authors = [ + {name = "Shriti Priya", email = "shritip@ibm.com"} +] + +dependencies = [ + "cedarpy>=4.8.7", + "mcp>=1.28.1,<2", + "mcp-contextforge-gateway>=1.0.11", +] + +# URLs +[project.urls] +Homepage = "https://ibm.github.io/mcp-context-forge/" +Documentation = "https://ibm.github.io/mcp-context-forge/" +Repository = "https://github.com/IBM/mcp-context-forge" +"Bug Tracker" = "https://github.com/IBM/mcp-context-forge/issues" +Changelog = "https://github.com/IBM/mcp-context-forge/blob/main/CHANGELOG.md" + +[tool.uv.sources] +mcp-contextforge-gateway = { git = "https://github.com/IBM/mcp-context-forge.git", rev = "main" } + +# ---------------------------------------------------------------- +# Optional dependency groups (extras) +# ---------------------------------------------------------------- +[project.optional-dependencies] +dev = [ + "black>=26.5.1", + "pytest>=9.1.1", + "pytest-asyncio>=1.4.0", + "pytest-cov>=7.1.0", + "pytest-dotenv>=0.5.2", + "pytest-env>=1.7.0", + "pytest-examples>=0.0.18", + "pytest-rerunfailures>=16.4", + "pytest-trio>=0.8.0", + "pytest-xdist>=3.8.0", + "ruff>=0.16.0", + "uv>=0.11.32", +] + +# -------------------------------------------------------------------- +# 🔧 setuptools-specific configuration +# -------------------------------------------------------------------- +[tool.setuptools] +include-package-data = true # ensure wheels include the data files + +# Automatic discovery: keep every package that starts with "cedarpolicyplugin" +[tool.setuptools.packages.find] +include = ["cedarpolicyplugin*"] +exclude = ["tests*"] + +## Runtime data files ------------------------------------------------ +[tool.setuptools.package-data] +cedarpolicyplugin = [ + "resources/plugins/config.yaml", +] diff --git a/plugins/external/cedar/resources/plugins/config.yaml b/plugins/external/cedar/resources/plugins/config.yaml new file mode 100644 index 0000000..eebbe94 --- /dev/null +++ b/plugins/external/cedar/resources/plugins/config.yaml @@ -0,0 +1,79 @@ +# Plugin directories to scan +plugin_dirs: + - "plugins/native" # Built-in plugins + - "plugins/custom" # Custom organization plugins + - "/etc/mcpgateway/plugins" # System-wide plugins + +# Global plugin settings +plugin_settings: + parallel_execution_within_band: true + plugin_timeout: 120 + fail_on_plugin_error: false + enable_plugin_api: true + plugin_health_check_interval: 120 + include_user_info: true + +plugins: + - name: "CedarPolicyPlugin" + kind: "cedarpolicyplugin.plugin.CedarPolicyPlugin" + description: "A plugin that does policy decision and enforcement using cedar" + version: "0.1.0" + author: "Shriti Priya" + hooks: ["prompt_pre_fetch", "prompt_post_fetch", "tool_pre_invoke", "tool_post_invoke"] + tags: ["plugin"] + mode: "sequential" # sequential | transform | disabled + priority: 150 + conditions: + # Apply to specific tools/servers + - server_ids: [] # Apply to all servers + tenant_ids: [] # Apply to all tenants + config: + policy_lang: cedar + policy_output_keywords: + view_full: "view_full_output" + view_redacted: "view_redacted_output" + policy: + - id: allow-admin-basic-access + effect: Permit + principal: Role::"admin" + action: + - Action::"fast-time-git-status" #tool name + resource: + - Server::"fast-time" # mcp-server name + + - id: redact-non-admin-views + effect: Permit + principal: Role::"non-admin" + action: + - Action::"fast-time-git-status" #tool name + - Action::"view_redacted_output" + resource: + - Server::"fast-time" + + - id: allow-admin-resources # policy for resources + effect: Permit + principal: Role::"admin" + action: + - Action::"view_full_output" + resource: Resource::"https://example.com/data" #Resource:: + + - id: allow-employee-redacted-resources # policy for resources + effect: Permit + principal: Role::"non-admin" + action: + - Action::"view_redacted_output" + resource: Resource::"https://example.com/data" #Resource:: + + - id: allow-admin-prompts # policy for resources + effect: Permit + principal: Role::"admin" + action: + - Action::"view_full_output" + resource: Prompts::"judge_prompts" #Prompt:: + + - id: allow-employee-redacted-prompts # policy for resources + effect: Permit + principal: Role::"non-admin" + action: + - Action::"view_redacted_output" + resource: Prompts::"judge_prompts" #Prompt:: diff --git a/plugins/external/cedar/resources/runtime/config.yaml b/plugins/external/cedar/resources/runtime/config.yaml new file mode 100644 index 0000000..5b26791 --- /dev/null +++ b/plugins/external/cedar/resources/runtime/config.yaml @@ -0,0 +1,71 @@ +# config.yaml +host: + name: "cedarpolicyplugin" + log_level: "INFO" + +server: + type: "streamable-http" # "stdio" or "sse" or "streamable-http" + #auth: "bearer" # this line is needed to enable bearer auth + +# Logging configuration - controls all logging behavior +logging: + level: "WARNING" # Changed from INFO to WARNING for quieter default + format: "%(asctime)s - %(name)s - %(levelname)s - %(message)s" + reset_handlers: true + quiet_libraries: true + + # Specific logger overrides to silence noisy components + loggers: + # Your existing overrides + "chuk_mcp_runtime.proxy": "WARNING" + "chuk_mcp_runtime.proxy.manager": "WARNING" + "chuk_mcp_runtime.proxy.tool_wrapper": "WARNING" + "chuk_tool_processor.mcp.stream_manager": "WARNING" + "chuk_tool_processor.mcp.register": "WARNING" + "chuk_tool_processor.mcp.setup_stdio": "WARNING" + "chuk_mcp_runtime.common.tool_naming": "WARNING" + "chuk_mcp_runtime.common.openai_compatibility": "WARNING" + + # NEW: Add the noisy loggers you're seeing + "chuk_sessions.session_manager": "ERROR" + "chuk_mcp_runtime.session.native": "ERROR" + "chuk_mcp_runtime.tools.artifacts": "ERROR" + "chuk_mcp_runtime.tools.session": "ERROR" + "chuk_artifacts.store": "ERROR" + "chuk_mcp_runtime.entry": "WARNING" # Keep some info but less chatty + "chuk_mcp_runtime.server": "WARNING" # Server start/stop messages + +# optional overrides +sse: + host: "0.0.0.0" + port: 8000 + sse_path: "/sse" + message_path: "/messages/" + health_path: "/health" + log_level: "info" + access_log: true + +streamable-http: + host: "0.0.0.0" + port: 8000 + mcp_path: "/mcp" + stateless: true + json_response: true + health_path: "/health" + log_level: "info" + access_log: true + +proxy: + enabled: false + namespace: "proxy" + openai_compatible: false # ← set to true if you want underscores + +# Session tools (disabled by default - must enable explicitly) +session_tools: + enabled: false # Must explicitly enable + +# Artifact storage (disabled by default - must enable explicitly) +artifacts: + enabled: false # Must explicitly enable + storage_provider: "filesystem" + session_provider: "memory" diff --git a/plugins/external/cedar/run-server.sh b/plugins/external/cedar/run-server.sh new file mode 100644 index 0000000..20594e5 --- /dev/null +++ b/plugins/external/cedar/run-server.sh @@ -0,0 +1,43 @@ +#!/usr/bin/env bash +#─────────────────────────────────────────────────────────────────────────────── +# Script : run-server.sh +# Purpose: Launch ContextForge's Plugin API +# +# Description: +# This script launches an API server using +# chuck runtime. +# +# Environment Variables: +# API_SERVER_SCRIPT : Path to the server script (optional, auto-detected) +# PLUGINS_CONFIG_PATH : Path to the plugin config (optional, default: ./resources/plugins/config.yaml) +# CHUK_MCP_CONFIG_PATH : Path to the chuck-mcp-runtime config (optional, default: ./resources/runtime/config.yaml) +# +# Usage: +# ./run-server.sh # Run server +#─────────────────────────────────────────────────────────────────────────────── + +# Exit immediately on error, undefined variable, or pipe failure +set -euo pipefail + +#──────────────────────────────────────────────────────────────────────────────── +# SECTION 1: Script Location Detection +# Determine the absolute path of the API server script +#──────────────────────────────────────────────────────────────────────────────── +if [[ -z "${API_SERVER_SCRIPT:-}" ]]; then + API_SERVER_SCRIPT="$(python -c 'import cpex.framework.external.mcp.server.runtime as server; print(server.__file__)')" + echo "✓ API server script path auto-detected: ${API_SERVER_SCRIPT}" +else + echo "✓ Using provided API server script path: ${API_SERVER_SCRIPT}" +fi + +#──────────────────────────────────────────────────────────────────────────────── +# SECTION 2: Run the API server +# Run the API server from configuration +#──────────────────────────────────────────────────────────────────────────────── + +PLUGINS_CONFIG_PATH=${PLUGINS_CONFIG_PATH:-./resources/plugins/config.yaml} +CHUK_MCP_CONFIG_PATH=${CHUK_MCP_CONFIG_PATH:-./resources/runtime/config.yaml} + +echo "✓ Using plugin config from: ${PLUGINS_CONFIG_PATH}" +echo "✓ Running API server with config from: ${CHUK_MCP_CONFIG_PATH}" +python ${API_SERVER_SCRIPT} diff --git a/plugins/external/cedar/tests/__init__.py b/plugins/external/cedar/tests/__init__.py new file mode 100644 index 0000000..2e033f6 --- /dev/null +++ b/plugins/external/cedar/tests/__init__.py @@ -0,0 +1,6 @@ +# -*- coding: utf-8 -*- +"""Location: ./tests/__init__.py +Copyright 2025 +SPDX-License-Identifier: Apache-2.0 +Authors: Shriti Priya +""" diff --git a/plugins/external/cedar/tests/pytest.ini b/plugins/external/cedar/tests/pytest.ini new file mode 100644 index 0000000..ff60648 --- /dev/null +++ b/plugins/external/cedar/tests/pytest.ini @@ -0,0 +1,13 @@ +[pytest] +log_cli = false +log_cli_level = INFO +log_cli_format = %(asctime)s [%(module)s] [%(levelname)s] %(message)s +log_cli_date_format = %Y-%m-%d %H:%M:%S +log_level = INFO +log_format = %(asctime)s [%(module)s] [%(levelname)s] %(message)s +log_date_format = %Y-%m-%d %H:%M:%S +addopts = --cov --cov-report term-missing +env_files = .env +pythonpath = . src +filterwarnings = + ignore::DeprecationWarning:pydantic.* diff --git a/plugins/external/cedar/tests/test_cedarpolicyplugin.py b/plugins/external/cedar/tests/test_cedarpolicyplugin.py new file mode 100644 index 0000000..d24ab27 --- /dev/null +++ b/plugins/external/cedar/tests/test_cedarpolicyplugin.py @@ -0,0 +1,634 @@ +# -*- coding: utf-8 -*- +"""Tests for plugin.""" + +# Third-Party +from cedarpolicyplugin.plugin import CedarPolicyPlugin +import pytest + +# First-Party +from mcpgateway.common.models import Message, PromptResult, ResourceContent, Role, TextContent +from cpex.framework.hooks.prompts import PromptPosthookPayload, PromptPrehookPayload +from cpex.framework.hooks.resources import ResourcePostFetchPayload, ResourcePreFetchPayload +from cpex.framework.hooks.tools import ToolPostInvokePayload, ToolPreInvokePayload +from cpex.framework.models import ( + GlobalContext, + PluginConfig, + PluginContext, +) + + +# This test case is responsible for verifying cedarplugin functionality for post tool hooks in cdear native mode +@pytest.mark.asyncio +async def test_cedarpolicyplugin_post_tool_invoke_rbac(): + """Test plugin for post tool invocation""" + policy_config = [ + { + "id": "allow-employee-basic-access", + "effect": "Permit", + "principal": 'Role::"employee"', + "action": ['Action::"get_leave_balance"', 'Action::"request_certificate"'], + "resource": ['Server::"askHR"', 'Agent::"employee_agent"'], + }, + { + "id": "allow-manager-full-access", + "effect": "Permit", + "principal": 'Role::"manager"', + "action": ['Action::"get_leave_balance"', 'Action::"approve_leave"', 'Action::"promote_employee"', 'Action::"view_performance"', 'Action::"view_full_output"'], + "resource": ['Agent::"manager_agent"', 'Server::"payroll_tool"'], + }, + { + "id": "allow-hr-hr_tool", + "effect": "Permit", + "principal": 'Role::"hr"', + "action": ['Action::"update_payroll"', 'Action::"view_performance"', 'Action::"view_full_output"'], + "resource": ['Server::"hr_tool"'], + }, + { + "id": "redact-non-manager-views", + "effect": "Permit", + "principal": 'Role::"employee"', + "action": ['Action::"view_redacted_output"'], + "resource": ['Server::"payroll_tool"', 'Agent::"manager_agent"', 'Server::"askHR"'], + }, + ] + + policy_output_keywords = {"view_full": "view_full_output", "view_redacted": "view_redacted_output"} + policy_redaction_spec = {"pattern": r"\$\d{1,}(,\d{1,})*", "redaction_str": "[REDACTED]"} + config = PluginConfig( + name="test", + kind="cedarpolicyplugin.CedarPolicyPlugin", + hooks=["tool_pre_invoke"], + config={"policy_lang": "cedar", "policy": policy_config, "policy_output_keywords": policy_output_keywords, "policy_redaction_spec": policy_redaction_spec}, + ) + plugin = CedarPolicyPlugin(config) + info = {"alice": "employee", "bob": "manager", "carol": "hr", "robert": "admin"} + plugin._set_jwt_info(info) + requests = [ + {"user": "alice", "action": "get_leave_balance", "resource": "askHR"}, + {"user": "bob", "action": "view_performance", "resource": "payroll_tool"}, + {"user": "carol", "action": "update_payroll", "resource": "hr_tool"}, + {"user": "alice", "action": "update_payroll", "resource": "hr_tool"}, + ] + + redact_count = 0 + allow_count = 0 + deny_count = 0 + for req in requests: + payload = ToolPostInvokePayload(name=req["action"], result={"text": "Alice has a salary of $250,000"}) + context = PluginContext(global_context=GlobalContext(request_id="1", server_id=req["resource"], user=req["user"])) + result = await plugin.tool_post_invoke(payload, context) + if result.modified_payload and "[REDACTED]" in result.modified_payload.result["text"]: + redact_count += 1 + if result.continue_processing: + allow_count += 1 + if not result.continue_processing: + deny_count += 1 + + assert redact_count == 1 + assert allow_count == 3 + assert deny_count == 1 + + +# This test case is responsible for verifying cedarplugin functionality for post tool invocation with policy in custom dsl mode +@pytest.mark.asyncio +async def test_cedarpolicyplugin_post_tool_invoke_custom_dsl_rbac(): + """Test plugin for post tool invocation""" + policy_config = "[role:employee:server/askHR]\nget_leave_balance\nrequest_certificate\n\n\ + [role:employee:agent/employee_agent]\nget_leave_balance\nrequest_certificate\n\n[role:manager:agent/manager_agent]\nget_leave_balance\napprove_leave\npromote_employee\nview_performance\nview_full_output\n\n[role:manager:server/payroll_tool]\ + \nget_leave_balance\napprove_leave\npromote_employee\nview_performance\nview_full_output\n\n[role:hr:server/hr_tool]\nupdate_payroll\nview_performance\nview_full_output\n\n[role:employee:server/payroll_tool]\nview_redacted_output\n\n[role:employee:agent/manager_agent]\nview_redacted_output\n\n\ + [role:employee:server/askHR]\nview_redacted_output" + + policy_output_keywords = {"view_full": "view_full_output", "view_redacted": "view_redacted_output"} + policy_redaction_spec = {"pattern": r"\$\d{1,}(,\d{1,})*", "redaction_str": "[REDACTED]"} + config = PluginConfig( + name="test", + kind="cedarpolicyplugin.CedarPolicyPlugin", + hooks=["tool_pre_invoke"], + config={"policy_lang": "custom_dsl", "policy": policy_config, "policy_output_keywords": policy_output_keywords, "policy_redaction_spec": policy_redaction_spec}, + ) + plugin = CedarPolicyPlugin(config) + info = {"alice": "employee", "bob": "manager", "carol": "hr", "robert": "admin"} + plugin._set_jwt_info(info) + requests = [ + {"user": "alice", "action": "get_leave_balance", "resource": "askHR"}, + {"user": "bob", "action": "view_performance", "resource": "payroll_tool"}, + {"user": "carol", "action": "update_payroll", "resource": "hr_tool"}, + {"user": "alice", "action": "update_payroll", "resource": "hr_tool"}, + ] + + redact_count = 0 + allow_count = 0 + deny_count = 0 + for req in requests: + payload = ToolPostInvokePayload(name=req["action"], result={"text": "Alice has a salary of $250,000"}) + context = PluginContext(global_context=GlobalContext(request_id="1", server_id=req["resource"], user=req["user"])) + result = await plugin.tool_post_invoke(payload, context) + if result.modified_payload and "[REDACTED]" in result.modified_payload.result["text"]: + redact_count += 1 + if result.continue_processing: + allow_count += 1 + if not result.continue_processing: + deny_count += 1 + + assert redact_count == 1 + assert allow_count == 3 + assert deny_count == 1 + + +# This test case is responsible for verifying cedarplugin functionality for tool pre invoke in cedar native mode +@pytest.mark.asyncio +async def test_cedarpolicyplugin_pre_tool_invoke_cedar_rbac(): + """Test plugin tool pre invoke hook.""" + policy_config = [ + { + "id": "allow-employee-basic-access", + "effect": "Permit", + "principal": 'Role::"employee"', + "action": ['Action::"get_leave_balance"', 'Action::"request_certificate"'], + "resource": ['Server::"askHR"', 'Agent::"employee_agent"'], + }, + { + "id": "allow-manager-full-access", + "effect": "Permit", + "principal": 'Role::"manager"', + "action": ['Action::"get_leave_balance"', 'Action::"approve_leave"', 'Action::"promote_employee"', 'Action::"view_performance"', 'Action::"view_full_output"'], + "resource": ['Agent::"manager_agent"', 'Server::"payroll_tool"'], + }, + { + "id": "allow-hr-hr_tool", + "effect": "Permit", + "principal": 'Role::"hr"', + "action": ['Action::"update_payroll"', 'Action::"view_performance"', 'Action::"view_full_output"'], + "resource": ['Server::"hr_tool"'], + }, + { + "id": "redact-non-manager-views", + "effect": "Permit", + "principal": 'Role::"employee"', + "action": ['Action::"view_redacted_output"'], + "resource": ['Server::"payroll_tool"', 'Agent::"manager_agent"', 'Server::"askHR"'], + }, + ] + + policy_output_keywords = {"view_full": "view_full_output", "view_redacted": "view_redacted_output"} + policy_redaction_spec = {"pattern": r"\$\d{1,}(,\d{1,})*"} + config = PluginConfig( + name="test", + kind="cedarpolicyplugin.CedarPolicyPlugin", + hooks=["tool_pre_invoke"], + config={"policy_lang": "cedar", "policy": policy_config, "policy_output_keywords": policy_output_keywords, "policy_redaction_spec": policy_redaction_spec}, + ) + plugin = CedarPolicyPlugin(config) + info = {"alice": "employee", "bob": "manager", "carol": "hr", "robert": "admin"} + plugin._set_jwt_info(info) + requests = [ + {"user": "alice", "action": "get_leave_balance", "resource": "askHR"}, + {"user": "bob", "action": "view_performance", "resource": "payroll_tool"}, + {"user": "carol", "action": "update_payroll", "resource": "hr_tool"}, + {"user": "alice", "action": "update_payroll", "resource": "hr_tool"}, + ] + + allow_count = 0 + deny_count = 0 + for req in requests: + payload = ToolPreInvokePayload(name=req["action"], args={"arg1": "sample arg"}) + context = PluginContext(global_context=GlobalContext(request_id="1", server_id=req["resource"], user=req["user"])) + result = await plugin.tool_pre_invoke(payload, context) + if result.continue_processing: + allow_count += 1 + if not result.continue_processing: + deny_count += 1 + + assert allow_count == 3 + assert deny_count == 1 + + +# This test case is responsible for verifying cedarplugin functionality for tool pre invoke in custom dsl mode +@pytest.mark.asyncio +async def test_cedarpolicyplugin_pre_tool_invoke_custom_dsl_rbac(): + """Test plugin tool pre invoke.""" + policy_config = "[role:employee:server/askHR]\nget_leave_balance\nrequest_certificate\n\n[role:employee:agent/employee_agent]\n\ + get_leave_balance\nrequest_certificate\n\n[role:manager:agent/manager_agent]\nget_leave_balance\napprove_leave\npromote_employee\n\ + view_performance\nview_full_output\n\n[role:manager:server/payroll_tool]\nget_leave_balance\napprove_leave\npromote_employee\nview_performance\n\ + view_full_output\n\n[role:hr:server/hr_tool]\nupdate_payroll\nview_performance\nview_full_output\n\n[role:employee:server/payroll_tool]\n\ + view_redacted_output\n\n[role:employee:agent/manager_agent]\nview_redacted_output\n\n[role:employee:server/askHR]\nview_redacted_output" + policy_output_keywords = {"view_full": "view_full_output", "view_redacted": "view_redacted_output"} + policy_redaction_spec = {"pattern": r"\$\d{1,}(,\d{1,})*"} + config = PluginConfig( + name="test", + kind="cedarpolicyplugin.CedarPolicyPlugin", + hooks=["tool_pre_invoke"], + config={"policy_lang": "custom_dsl", "policy": policy_config, "policy_output_keywords": policy_output_keywords, "policy_redaction_spec": policy_redaction_spec}, + ) + plugin = CedarPolicyPlugin(config) + info = {"alice": "employee", "bob": "manager", "carol": "hr", "robert": "admin"} + plugin._set_jwt_info(info) + requests = [ + {"user": "alice", "action": "get_leave_balance", "resource": "askHR"}, + {"user": "bob", "action": "view_performance", "resource": "payroll_tool"}, + {"user": "carol", "action": "update_payroll", "resource": "hr_tool"}, + {"user": "alice", "action": "update_payroll", "resource": "hr_tool"}, + ] + + allow_count = 0 + deny_count = 0 + for req in requests: + payload = ToolPreInvokePayload(name=req["action"], args={"arg1": "sample arg"}) + context = PluginContext(global_context=GlobalContext(request_id="1", server_id=req["resource"], user=req["user"])) + result = await plugin.tool_pre_invoke(payload, context) + if result.continue_processing: + allow_count += 1 + if not result.continue_processing: + deny_count += 1 + + assert allow_count == 3 + assert deny_count == 1 + + +# This test case is responsible for verifying cedarplugin functionality for prompt pre fetch in cedar mode +@pytest.mark.asyncio +async def test_cedarpolicyplugin_prompt_pre_fetch_rbac(): + """Test plugin prompt prefetch hook.""" + policy_config = [ + {"id": "redact-non-admin-views", "effect": "Permit", "principal": 'Role::"employee"', "action": ['Action::"view_redacted_output"'], "resource": 'Prompt::"judge_prompts"'}, + { + "id": "allow-admin-prompts", # policy for resources + "effect": "Permit", + "principal": 'Role::"admin"', + "action": ['Action::"view_full_output"'], + "resource": 'Prompt::"judge_prompts"', # Prompt:: + }, + ] + + policy_output_keywords = {"view_full": "view_full_output", "view_redacted": "view_redacted_output"} + policy_redaction_spec = {"pattern": "all"} + config = PluginConfig( + name="test", + kind="cedarpolicyplugin.CedarPolicyPlugin", + hooks=["tool_pre_invoke"], + config={"policy_lang": "cedar", "policy": policy_config, "policy_output_keywords": policy_output_keywords, "policy_redaction_spec": policy_redaction_spec}, + ) + plugin = CedarPolicyPlugin(config) + info = {"alice": "employee", "bob": "manager", "carol": "hr", "robert": "admin"} + plugin._set_jwt_info(info) + requests = [ + {"user": "alice", "resource": "judge_prompts"}, # allow + {"user": "robert", "resource": "judge_prompts"}, # allow + {"user": "carol", "resource": "judge_prompts"}, # deny + ] + + allow_count = 0 + deny_count = 0 + + for req in requests: + # Prompt pre hook input + payload = PromptPrehookPayload(prompt_id=req["resource"], args={"text": "You are curseword"}) + context = PluginContext(global_context=GlobalContext(request_id="1", server_id="2", user=req["user"])) + result = await plugin.prompt_pre_fetch(payload, context) + if result.continue_processing: + allow_count += 1 + if not result.continue_processing: + deny_count += 1 + + assert allow_count == 2 + assert deny_count == 1 + + +# This test case is responsible for verifying cedarplugin functionality for prompt pre fetch in custom dsl mode +@pytest.mark.asyncio +async def test_cedarpolicyplugin_prompt_pre_fetch_custom_dsl_rbac(): + """Test plugin prompt prefetch hook.""" + policy_config = "[role:employee:prompt/judge_prompts]\nview_redacted_output\n\n[role:admin:prompt/judge_prompts]\nview_full_output" + + policy_output_keywords = {"view_full": "view_full_output", "view_redacted": "view_redacted_output"} + policy_redaction_spec = {"pattern": "all"} + config = PluginConfig( + name="test", + kind="cedarpolicyplugin.CedarPolicyPlugin", + hooks=["tool_pre_invoke"], + config={"policy_lang": "custom_dsl", "policy": policy_config, "policy_output_keywords": policy_output_keywords, "policy_redaction_spec": policy_redaction_spec}, + ) + plugin = CedarPolicyPlugin(config) + info = {"alice": "employee", "bob": "manager", "carol": "hr", "robert": "admin"} + plugin._set_jwt_info(info) + requests = [ + {"user": "alice", "resource": "judge_prompts"}, # allow + {"user": "robert", "resource": "judge_prompts"}, # allow + {"user": "carol", "resource": "judge_prompts"}, # deny + ] + + allow_count = 0 + deny_count = 0 + + for req in requests: + # Prompt pre hook input + payload = PromptPrehookPayload(prompt_id=req["resource"], args={"text": "You are curseword"}) + context = PluginContext(global_context=GlobalContext(request_id="1", server_id="2", user=req["user"])) + result = await plugin.prompt_pre_fetch(payload, context) + if result.continue_processing: + allow_count += 1 + if not result.continue_processing: + deny_count += 1 + + assert allow_count == 2 + assert deny_count == 1 + + +# This test case is responsible for verifying cedarplugin functionality for prompt post fetch in cedar native mode +@pytest.mark.asyncio +async def test_cedarpolicyplugin_prompt_post_fetch_cedar_rbac(): + """Test plugin prompt postfetch hook.""" + policy_config = [ + {"id": "redact-non-admin-views", "effect": "Permit", "principal": 'Role::"employee"', "action": ['Action::"view_redacted_output"'], "resource": 'Prompt::"judge_prompts"'}, + { + "id": "allow-admin-prompts", # policy for resources + "effect": "Permit", + "principal": 'Role::"admin"', + "action": ['Action::"view_full_output"'], + "resource": 'Prompt::"judge_prompts"', # Prompt:: + }, + ] + + policy_output_keywords = {"view_full": "view_full_output", "view_redacted": "view_redacted_output"} + policy_redaction_spec = {"pattern": "all", "redaction_str": "[REDACTED]"} + config = PluginConfig( + name="test", + kind="cedarpolicyplugin.CedarPolicyPlugin", + hooks=["tool_pre_invoke"], + config={"policy_lang": "cedar", "policy": policy_config, "policy_output_keywords": policy_output_keywords, "policy_redaction_spec": policy_redaction_spec}, + ) + plugin = CedarPolicyPlugin(config) + info = {"alice": "employee", "bob": "manager", "carol": "hr", "robert": "admin"} + plugin._set_jwt_info(info) + requests = [ + {"user": "alice", "resource": "judge_prompts"}, # allow + {"user": "robert", "resource": "judge_prompts"}, # allow + {"user": "carol", "resource": "judge_prompts"}, # deny + ] + + allow_count = 0 + deny_count = 0 + redact_count = 0 + + for req in requests: + # Prompt post hook output + message = Message(content=TextContent(type="text", text="abc"), role=Role.USER) + prompt_result = PromptResult(messages=[message]) + payload = PromptPosthookPayload(prompt_id=req["resource"], result=prompt_result) + context = PluginContext(global_context=GlobalContext(request_id="1", server_id="2", user=req["user"])) + result = await plugin.prompt_post_fetch(payload, context) + if result.continue_processing: + allow_count += 1 + if result.modified_payload and "[REDACTED]" in result.modified_payload.result.messages[0].content.text: + redact_count += 1 + if not result.continue_processing: + deny_count += 1 + + assert allow_count == 2 + assert deny_count == 1 + assert redact_count == 1 + + +# This test case is responsible for verifying cedarplugin functionality for prompt post fetch in custom dsl mode +@pytest.mark.asyncio +async def test_cedarpolicyplugin_prompt_post_fetch_custom_dsl_rbac(): + """Test plugin prompt postfetch hook.""" + policy_config = "[role:employee:prompt/judge_prompts]\nview_redacted_output\n\n[role:admin:prompt/judge_prompts]\nview_full_output" + + policy_output_keywords = {"view_full": "view_full_output", "view_redacted": "view_redacted_output"} + policy_redaction_spec = {"pattern": "all", "redaction_str": "[REDACTED]"} + config = PluginConfig( + name="test", + kind="cedarpolicyplugin.CedarPolicyPlugin", + hooks=["tool_pre_invoke"], + config={"policy_lang": "custom_dsl", "policy": policy_config, "policy_output_keywords": policy_output_keywords, "policy_redaction_spec": policy_redaction_spec}, + ) + plugin = CedarPolicyPlugin(config) + info = {"alice": "employee", "bob": "manager", "carol": "hr", "robert": "admin"} + plugin._set_jwt_info(info) + requests = [ + {"user": "alice", "resource": "judge_prompts"}, # allow + {"user": "robert", "resource": "judge_prompts"}, # allow + {"user": "carol", "resource": "judge_prompts"}, # deny + ] + + allow_count = 0 + deny_count = 0 + redact_count = 0 + + for req in requests: + # Prompt post hook output + message = Message(content=TextContent(type="text", text="abc"), role=Role.USER) + prompt_result = PromptResult(messages=[message]) + payload = PromptPosthookPayload(prompt_id=req["resource"], result=prompt_result) + context = PluginContext(global_context=GlobalContext(request_id="1", server_id="2", user=req["user"])) + result = await plugin.prompt_post_fetch(payload, context) + if result.continue_processing: + allow_count += 1 + if result.modified_payload and "[REDACTED]" in result.modified_payload.result.messages[0].content.text: + redact_count += 1 + if not result.continue_processing: + deny_count += 1 + + assert allow_count == 2 + assert deny_count == 1 + assert redact_count == 1 + + +# This test case is responsible for verifying cedarplugin functionality for resource pre fetch in cedar native mode +@pytest.mark.asyncio +async def test_cedarpolicyplugin_resource_pre_fetch_cedar_rbac(): + """Test plugin resource prefetch hook.""" + policy_config = [ + { + "id": "redact-non-admin-resource-views", + "effect": "Permit", + "principal": 'Role::"employee"', + "action": ['Action::"view_redacted_output"'], + "resource": 'Resource::"https://example.com/data"', + }, + { + "id": "allow-admin-resources", # policy for resources + "effect": "Permit", + "principal": 'Role::"admin"', + "action": ['Action::"view_full_output"'], + "resource": 'Resource::"https://example.com/data"', + }, + ] + + policy_output_keywords = {"view_full": "view_full_output", "view_redacted": "view_redacted_output"} + policy_redaction_spec = {"pattern": "[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}"} + config = PluginConfig( + name="test", + kind="cedarpolicyplugin.CedarPolicyPlugin", + hooks=["tool_pre_invoke"], + config={"policy_lang": "cedar", "policy": policy_config, "policy_output_keywords": policy_output_keywords, "policy_redaction_spec": policy_redaction_spec}, + ) + plugin = CedarPolicyPlugin(config) + info = {"alice": "employee", "bob": "manager", "carol": "hr", "robert": "admin"} + plugin._set_jwt_info(info) + requests = [ + {"user": "alice", "resource": "https://example.com/data"}, # allow + {"user": "robert", "resource": "https://example.com/data"}, # allow + {"user": "carol", "resource": "https://example.com/data"}, # deny + ] + + allow_count = 0 + deny_count = 0 + + for req in requests: + # Prompt post hook output + payload = ResourcePreFetchPayload(uri="https://example.com/data", metadata={}) + context = PluginContext(global_context=GlobalContext(request_id="1", server_id="2", user=req["user"])) + result = await plugin.resource_pre_fetch(payload, context) + if result.continue_processing: + allow_count += 1 + if not result.continue_processing: + deny_count += 1 + + assert allow_count == 2 + assert deny_count == 1 + + +# This test case is responsible for verifying cedarplugin functionality for resource pre fetch in custom dsl mode +@pytest.mark.asyncio +async def test_cedarpolicyplugin_resource_pre_fetch_custom_dsl_rbac(): + """Test plugin resource prefetch hook.""" + policy_config = "[role:employee:resource/https://example.com/data]\nview_redacted_output\n\n[role:admin:resource/https://example.com/data]\nview_full_output" + + policy_output_keywords = {"view_full": "view_full_output", "view_redacted": "view_redacted_output"} + policy_redaction_spec = {"pattern": "[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}"} + config = PluginConfig( + name="test", + kind="cedarpolicyplugin.CedarPolicyPlugin", + hooks=["tool_pre_invoke"], + config={"policy_lang": "custom_dsl", "policy": policy_config, "policy_output_keywords": policy_output_keywords, "policy_redaction_spec": policy_redaction_spec}, + ) + plugin = CedarPolicyPlugin(config) + info = {"alice": "employee", "bob": "manager", "carol": "hr", "robert": "admin"} + plugin._set_jwt_info(info) + requests = [ + {"user": "alice", "resource": "https://example.com/data"}, # allow + {"user": "robert", "resource": "https://example.com/data"}, # allow + {"user": "carol", "resource": "https://example.com/data"}, # deny + ] + + allow_count = 0 + deny_count = 0 + + for req in requests: + # Prompt post hook output + payload = ResourcePreFetchPayload(uri="https://example.com/data", metadata={}) + context = PluginContext(global_context=GlobalContext(request_id="1", server_id="2", user=req["user"])) + result = await plugin.resource_pre_fetch(payload, context) + if result.continue_processing: + allow_count += 1 + if not result.continue_processing: + deny_count += 1 + + assert allow_count == 2 + assert deny_count == 1 + + +# This test case is responsible for verifying cedarplugin functionality for resource post fetch in cedar native mode +@pytest.mark.asyncio +async def test_cedarpolicyplugin_resource_post_fetch_cedar_rbac(): + """Test plugin resource post fetch.""" + policy_config = [ + { + "id": "redact-non-admin-resource-views", + "effect": "Permit", + "principal": 'Role::"employee"', + "action": ['Action::"view_redacted_output"'], + "resource": 'Resource::"https://example.com/data"', + }, + { + "id": "allow-admin-resources", # policy for resources + "effect": "Permit", + "principal": 'Role::"admin"', + "action": ['Action::"view_full_output"'], + "resource": 'Resource::"https://example.com/data"', + }, + ] + + policy_output_keywords = {"view_full": "view_full_output", "view_redacted": "view_redacted_output"} + policy_redaction_spec = {"pattern": "[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}", "redaction_str": "[REDACTED]"} + config = PluginConfig( + name="test", + kind="cedarpolicyplugin.CedarPolicyPlugin", + hooks=["tool_pre_invoke"], + config={"policy_lang": "cedar", "policy": policy_config, "policy_output_keywords": policy_output_keywords, "policy_redaction_spec": policy_redaction_spec}, + ) + plugin = CedarPolicyPlugin(config) + info = {"alice": "employee", "bob": "manager", "carol": "hr", "robert": "admin"} + plugin._set_jwt_info(info) + requests = [ + {"user": "alice", "resource": "https://example.com/data"}, # allow + {"user": "robert", "resource": "https://example.com/data"}, # allow + {"user": "carol", "resource": "https://example.com/data"}, # deny + ] + + allow_count = 0 + deny_count = 0 + redact_count = 0 + + for req in requests: + # Prompt post hook output + content = ResourceContent(type="resource", uri="test://large", text="test://abc@example.com", id="1") + payload = ResourcePostFetchPayload(uri="https://example.com/data", content=content) + context = PluginContext(global_context=GlobalContext(request_id="1", server_id="2", user=req["user"])) + result = await plugin.resource_post_fetch(payload, context) + if result.continue_processing: + allow_count += 1 + if result.modified_payload and "[REDACTED]" in result.modified_payload.content.text: + redact_count += 1 + if not result.continue_processing: + deny_count += 1 + + assert allow_count == 2 + assert deny_count == 1 + assert redact_count == 1 + + +# This test case is responsible for verifying cedarplugin functionality for resource post fetch in custom dsl mode +@pytest.mark.asyncio +async def test_cedarpolicyplugin_resource_post_fetch_custom_dsl_rbac(): + """Test plugin resource postfetch hook.""" + policy_config = "[role:employee:resource/https://example.com/data]\nview_redacted_output\n\n[role:admin:resource/https://example.com/data]\nview_full_output" + policy_output_keywords = {"view_full": "view_full_output", "view_redacted": "view_redacted_output"} + policy_redaction_spec = {"pattern": "[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}", "redaction_str": "[REDACTED]"} + config = PluginConfig( + name="test", + kind="cedarpolicyplugin.CedarPolicyPlugin", + hooks=["tool_pre_invoke"], + config={"policy_lang": "custom_dsl", "policy": policy_config, "policy_output_keywords": policy_output_keywords, "policy_redaction_spec": policy_redaction_spec}, + ) + plugin = CedarPolicyPlugin(config) + info = {"alice": "employee", "bob": "manager", "carol": "hr", "robert": "admin"} + plugin._set_jwt_info(info) + requests = [ + {"user": "alice", "resource": "https://example.com/data"}, # allow + {"user": "robert", "resource": "https://example.com/data"}, # allow + {"user": "carol", "resource": "https://example.com/data"}, # deny + ] + + allow_count = 0 + deny_count = 0 + redact_count = 0 + + for req in requests: + # Prompt post hook output + content = ResourceContent(type="resource", uri="test://large", text="test://abc@example.com", id="1") + payload = ResourcePostFetchPayload(uri="https://example.com/data", content=content) + context = PluginContext(global_context=GlobalContext(request_id="1", server_id="2", user=req["user"])) + result = await plugin.resource_post_fetch(payload, context) + if result.continue_processing: + allow_count += 1 + if result.modified_payload and "[REDACTED]" in result.modified_payload.content.text: + redact_count += 1 + if not result.continue_processing: + deny_count += 1 + + assert allow_count == 2 + assert deny_count == 1 + assert redact_count == 1 From 1acf749f54b1b78d85a91c0652bcc1ba8afc1514 Mon Sep 17 00:00:00 2001 From: reginaldalfret Date: Fri, 2 Oct 2026 16:37:23 +0530 Subject: [PATCH 2/2] fix(cedar): address review feedback on dependencies, security, and docs Address maintainer review on PR #50: - pyproject.toml: pin mcp-contextforge-gateway to '>=1.0.8,<1.0.11' to resolve dependency conflict with mcp<2, declare cpex>=0.1.3 explicitly, remove tool.uv.sources, remove conflicting pytest-env, update project URLs to contextforge-examples, and fix package-data. - LICENSE: add missing Apache 2.0 license file to plugins/external/cedar/. - plugin.py: fix fail-open behavior in tool_post_invoke when tool is Allowed but neither view_full nor view_redacted is permitted, returning a DENY violation. - tests: add regression test test_cedarpolicyplugin_post_tool_invoke_both_views_denied. - configs: correct Prompts:: to Prompt::, fix doubled quotes in Resource:: URIs, and include resource hooks. - plugin-manifest.yaml: update available_hooks with canonical names and resource hooks, populate default_configs. - schema.py: clean up leftover OPA docstring references. - README.md: fix .env.template, config.yaml paths, OPA remnants, server description, typo, and step numbering. - .github/workflows/cedar-plugin-tests.yml: add CI workflow for ruff lint and pytest. Signed-off-by: reginaldalfret --- .github/workflows/cedar-plugin-tests.yml | 62 ++++++ plugins/external/cedar/LICENSE | 201 ++++++++++++++++++ plugins/external/cedar/README.md | 21 +- .../cedarpolicyplugin/plugin-manifest.yaml | 14 +- .../cedar/cedarpolicyplugin/plugin.py | 8 +- .../cedar/cedarpolicyplugin/schema.py | 4 +- .../external/cedar/examples/config-cedar.yaml | 10 +- .../external/cedar/examples/config-dsl.yaml | 2 +- plugins/external/cedar/pyproject.toml | 19 +- .../cedar/resources/plugins/config.yaml | 6 +- .../cedar/tests/test_cedarpolicyplugin.py | 33 +++ 11 files changed, 342 insertions(+), 38 deletions(-) create mode 100644 .github/workflows/cedar-plugin-tests.yml create mode 100644 plugins/external/cedar/LICENSE diff --git a/.github/workflows/cedar-plugin-tests.yml b/.github/workflows/cedar-plugin-tests.yml new file mode 100644 index 0000000..2dbd33f --- /dev/null +++ b/.github/workflows/cedar-plugin-tests.yml @@ -0,0 +1,62 @@ +# =============================================================== +# Cedar Policy Plugin — Lint & Tests +# =============================================================== +# +# Runs ruff and the pytest suite for the external Cedar policy plugin. +# Scoped to changes within plugins/external/cedar/ and this workflow. +# =============================================================== + +name: Cedar Policy Plugin Tests + +on: + push: + branches: ["main"] + paths: + - "plugins/external/cedar/**" + - ".github/workflows/cedar-plugin-tests.yml" + pull_request: + types: [opened, synchronize, ready_for_review] + branches: ["main"] + paths: + - "plugins/external/cedar/**" + - ".github/workflows/cedar-plugin-tests.yml" + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + test: + if: github.event_name != 'pull_request' || !github.event.pull_request.draft + name: pytest and lint + runs-on: ubuntu-latest + timeout-minutes: 15 + + defaults: + run: + working-directory: plugins/external/cedar + + steps: + - name: Checkout code + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5 + with: + persist-credentials: false + + - name: Set up Python + uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6 + with: + python-version: "3.12" + cache: pip + + - name: Install package (dev extras) + run: python3 -m pip install -e ".[dev]" + + - name: Run ruff check + run: ruff check . + + - name: Run pytest with coverage + run: pytest -v --cov=cedarpolicyplugin --cov-report=term-missing diff --git a/plugins/external/cedar/LICENSE b/plugins/external/cedar/LICENSE new file mode 100644 index 0000000..261eeb9 --- /dev/null +++ b/plugins/external/cedar/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/plugins/external/cedar/README.md b/plugins/external/cedar/README.md index a3b05ff..12df3b0 100644 --- a/plugins/external/cedar/README.md +++ b/plugins/external/cedar/README.md @@ -20,7 +20,7 @@ This plugin supports two ways of defining policies in the configuration file, co ### Cedar Mode -`plugins/external/cedar/resources/config.yaml` +`plugins/external/cedar/resources/plugins/config.yaml` When `policy_lang` is set to cedar, policies are written in the Cedar language under the policy key, using the following structure: @@ -78,7 +78,7 @@ In this example, role is hr, resource is server, and action is hr_tool. The line ## Installation -1. In the folder `plugins/external/cedar`, copy `.env.example` to `.env` file. +1. In the folder `plugins/external/cedar`, copy `.env.template` to `.env` file. 2. If you are using `policy_lang` to be `cedar`, add the plugin configuration to `plugins/external/cedar/resources/plugins/config.yaml`: ```yaml @@ -152,14 +152,14 @@ plugins: principal: Role::"admin" action: - Action::"view_full_output" - resource: Resource::""https://example.com/data"" #Resource:: + resource: Resource::"https://example.com/data" #Resource:: - id: allow-employee-redacted-resources # policy for resources effect: Permit principal: Role::"employee" action: - Action::"view_redacted_output" - resource: Resource::""https://example.com/data"" #Resource:: + resource: Resource::"https://example.com/data" #Resource:: ### Prompt invocation policies ### - id: allow-admin-prompts # policy for resources @@ -272,7 +272,7 @@ Here, in the above polcicy, given a prompt template `judge_prompts`, user of rol ``` Here, `Resource` word used in policy, is if resource hooks are invoked. So, in the above policy, -user with role `admin` is only allowed to view full output of uri `https://example.com/data`. Where, the user is of `employee` role, it can only see the redacted versionaaaaa of the resource output. +user with role `admin` is only allowed to view full output of uri `https://example.com/data`. Where, the user is of `employee` role, it can only see the redacted version of the resource output. #### policy_output_keywords @@ -288,7 +288,7 @@ any of the tool, prompt, resource or agent in MCP gateway, it can provide the ke -3. Now, the policy and plugin configurations are defined in `resources/config.yaml` file, next step is build this as an external MCP server. +3. Now, the policy and plugin configurations are defined in `resources/plugins/config.yaml` file, next step is build this as an external MCP server. * `make venv`: This will create a virtual environment to develop or build your plugin. * `make install && make install-dev`: To install all the required libraries in the environment. @@ -306,8 +306,7 @@ INFO: 127.0.0.1:55196 - "GET /health HTTP/1.1" 200 OK ``` -4. Now, you can add this external plugin configuration, in `plugins/config.yaml`: -3. The next step is to enable the opa plugin which you can do by adding `PLUGINS_ENABLED=true` and the following blob in `plugins/config.yaml` file. This will indicate that OPA Plugin is running as an external MCP server. +4. The next step is to enable the CedarPolicyPlugin which you can do by adding `PLUGINS_ENABLED=true` and the following blob in `plugins/config.yaml` file. This will indicate that CedarPolicyPlugin is running as an external MCP server: ```yaml - name: "CedarPolicyPlugin" @@ -336,7 +335,7 @@ export MCPGATEWAY_BEARER_TOKEN=$(python3 -m mcpgateway.utils.create_jwt_token -- ``` -1. Add server fast-time that exposes git tools in the mcp gateway +1. Add server fast-time (running `mcp-server-git`) that exposes git tools in the mcp gateway Run the mcp server @@ -378,11 +377,11 @@ This will start CedarPolicyPlugin on port `8000`. url: http://127.0.0.1:8000/mcp ``` -2. To test cedar plugin from the UI +5. To test cedar plugin from the UI Invoke the `fast-time-git-status` from the UI, with `admin` as role. The request will be allowed by the CedarPolicyPlugin. -If you switch to a another tool which is not in the policy `fast-time-git-show` expect denial by the CedarPolicyPlugin. For more details into policy, `plugins/external/cedar/resources/config.yaml` +If you switch to a another tool which is not in the policy `fast-time-git-show` expect denial by the CedarPolicyPlugin. For more details into policy, `plugins/external/cedar/resources/plugins/config.yaml` You get the following as output in the UI: ```json { diff --git a/plugins/external/cedar/cedarpolicyplugin/plugin-manifest.yaml b/plugins/external/cedar/cedarpolicyplugin/plugin-manifest.yaml index 38ec3ce..8e43350 100644 --- a/plugins/external/cedar/cedarpolicyplugin/plugin-manifest.yaml +++ b/plugins/external/cedar/cedarpolicyplugin/plugin-manifest.yaml @@ -2,8 +2,14 @@ description: "A plugin that does policy decision and enforcement using cedar" author: "Shriti Priya" version: "0.1.0" available_hooks: - - "prompt_pre_hook" - - "prompt_post_hook" - - "tool_pre_hook" - - "tool_post_hook" + - "prompt_pre_fetch" + - "prompt_post_fetch" + - "tool_pre_invoke" + - "tool_post_invoke" + - "resource_pre_fetch" + - "resource_post_fetch" default_configs: + policy_lang: "cedar" + policy_output_keywords: + view_full: "view_full_output" + view_redacted: "view_redacted_output" diff --git a/plugins/external/cedar/cedarpolicyplugin/plugin.py b/plugins/external/cedar/cedarpolicyplugin/plugin.py index ad197e8..efdd6a0 100644 --- a/plugins/external/cedar/cedarpolicyplugin/plugin.py +++ b/plugins/external/cedar/cedarpolicyplugin/plugin.py @@ -549,7 +549,13 @@ async def tool_post_invoke(self, payload: ToolPostInvokePayload, context: Plugin payload = payload.model_copy(update={"result": redacted}) return ToolPostInvokeResult(continue_processing=True, modified_payload=payload) else: - return ToolPostInvokeResult(continue_processing=True) + violation = PluginViolation( + reason=CedarResponseTemplates.CEDAR_REASON.format(hook_type=hook_type), + description=CedarResponseTemplates.CEDAR_DESC.format(hook_type=hook_type), + code=CedarCodes.DENIAL_CODE, + details={}, + ) + return ToolPostInvokeResult(modified_payload=payload, violation=violation, continue_processing=False) else: violation = PluginViolation( reason=CedarResponseTemplates.CEDAR_REASON.format(hook_type=hook_type), diff --git a/plugins/external/cedar/cedarpolicyplugin/schema.py b/plugins/external/cedar/cedarpolicyplugin/schema.py index 3dcfb56..2fee06e 100644 --- a/plugins/external/cedar/cedarpolicyplugin/schema.py +++ b/plugins/external/cedar/cedarpolicyplugin/schema.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -"""A schema file for OPA plugin. +"""A schema file for Cedar plugin. Copyright 2025 SPDX-License-Identifier: Apache-2.0 @@ -16,7 +16,7 @@ class CedarInput(BaseModel): - """BaseOPAInputKeys + """BaseCedarInputKeys Attributes: user (str) : specifying the user diff --git a/plugins/external/cedar/examples/config-cedar.yaml b/plugins/external/cedar/examples/config-cedar.yaml index a586bf9..4ee973c 100644 --- a/plugins/external/cedar/examples/config-cedar.yaml +++ b/plugins/external/cedar/examples/config-cedar.yaml @@ -4,7 +4,7 @@ plugins: description: "A plugin that does policy decision and enforcement using cedar" version: "0.1.0" author: "Shriti Priya" - hooks: ["prompt_pre_fetch", "prompt_post_fetch", "tool_pre_invoke", "tool_post_invoke"] + hooks: ["prompt_pre_fetch", "prompt_post_fetch", "tool_pre_invoke", "tool_post_invoke", "resource_pre_fetch", "resource_post_fetch"] tags: ["plugin"] mode: "sequential" # sequential | transform | disabled priority: 150 @@ -66,28 +66,28 @@ plugins: principal: Role::"admin" action: - Action::"view_full_output" - resource: Resource::""https://example.com/data"" #Resource:: + resource: Resource::"https://example.com/data" #Resource:: - id: allow-employee-redacted-resources # policy for resources effect: Permit principal: Role::"employee" action: - Action::"view_redacted_output" - resource: Resource::""https://example.com/data"" #Resource:: + resource: Resource::"https://example.com/data" #Resource:: - id: allow-admin-prompts # policy for resources effect: Permit principal: Role::"admin" action: - Action::"view_full_output" - resource: Prompts::"judge_prompts" #Prompt:: + resource: Prompt::"judge_prompts" #Prompt:: - id: allow-employee-redacted-prompts # policy for resources effect: Permit principal: Role::"employee" action: - Action::"view_redacted_output" - resource: Prompts::"judge_prompts" #Prompt:: + resource: Prompt::"judge_prompts" #Prompt:: # Plugin directories to scan plugin_dirs: diff --git a/plugins/external/cedar/examples/config-dsl.yaml b/plugins/external/cedar/examples/config-dsl.yaml index d7df05c..de1cf91 100644 --- a/plugins/external/cedar/examples/config-dsl.yaml +++ b/plugins/external/cedar/examples/config-dsl.yaml @@ -4,7 +4,7 @@ plugins: description: "A plugin that does policy decision and enforcement using cedar" version: "0.1.0" author: "Shriti Priya" - hooks: ["prompt_pre_fetch", "prompt_post_fetch", "tool_pre_invoke", "tool_post_invoke"] + hooks: ["prompt_pre_fetch", "prompt_post_fetch", "tool_pre_invoke", "tool_post_invoke", "resource_pre_fetch", "resource_post_fetch"] tags: ["plugin"] mode: "sequential" # sequential | transform | disabled priority: 150 diff --git a/plugins/external/cedar/pyproject.toml b/plugins/external/cedar/pyproject.toml index 58370df..82ad2bb 100644 --- a/plugins/external/cedar/pyproject.toml +++ b/plugins/external/cedar/pyproject.toml @@ -44,20 +44,18 @@ authors = [ dependencies = [ "cedarpy>=4.8.7", + "cpex>=0.1.3", "mcp>=1.28.1,<2", - "mcp-contextforge-gateway>=1.0.11", + "mcp-contextforge-gateway>=1.0.8,<1.0.11", ] # URLs [project.urls] -Homepage = "https://ibm.github.io/mcp-context-forge/" -Documentation = "https://ibm.github.io/mcp-context-forge/" -Repository = "https://github.com/IBM/mcp-context-forge" -"Bug Tracker" = "https://github.com/IBM/mcp-context-forge/issues" -Changelog = "https://github.com/IBM/mcp-context-forge/blob/main/CHANGELOG.md" - -[tool.uv.sources] -mcp-contextforge-gateway = { git = "https://github.com/IBM/mcp-context-forge.git", rev = "main" } +Homepage = "https://ibm.github.io/contextforge-examples/" +Documentation = "https://ibm.github.io/contextforge-examples/" +Repository = "https://github.com/IBM/contextforge-examples" +"Bug Tracker" = "https://github.com/IBM/contextforge-examples/issues" +Changelog = "https://github.com/IBM/contextforge-examples/blob/main/CHANGELOG.md" # ---------------------------------------------------------------- # Optional dependency groups (extras) @@ -69,7 +67,6 @@ dev = [ "pytest-asyncio>=1.4.0", "pytest-cov>=7.1.0", "pytest-dotenv>=0.5.2", - "pytest-env>=1.7.0", "pytest-examples>=0.0.18", "pytest-rerunfailures>=16.4", "pytest-trio>=0.8.0", @@ -92,5 +89,5 @@ exclude = ["tests*"] ## Runtime data files ------------------------------------------------ [tool.setuptools.package-data] cedarpolicyplugin = [ - "resources/plugins/config.yaml", + "*.yaml", ] diff --git a/plugins/external/cedar/resources/plugins/config.yaml b/plugins/external/cedar/resources/plugins/config.yaml index eebbe94..2593209 100644 --- a/plugins/external/cedar/resources/plugins/config.yaml +++ b/plugins/external/cedar/resources/plugins/config.yaml @@ -19,7 +19,7 @@ plugins: description: "A plugin that does policy decision and enforcement using cedar" version: "0.1.0" author: "Shriti Priya" - hooks: ["prompt_pre_fetch", "prompt_post_fetch", "tool_pre_invoke", "tool_post_invoke"] + hooks: ["prompt_pre_fetch", "prompt_post_fetch", "tool_pre_invoke", "tool_post_invoke", "resource_pre_fetch", "resource_post_fetch"] tags: ["plugin"] mode: "sequential" # sequential | transform | disabled priority: 150 @@ -69,11 +69,11 @@ plugins: principal: Role::"admin" action: - Action::"view_full_output" - resource: Prompts::"judge_prompts" #Prompt:: + resource: Prompt::"judge_prompts" #Prompt:: - id: allow-employee-redacted-prompts # policy for resources effect: Permit principal: Role::"non-admin" action: - Action::"view_redacted_output" - resource: Prompts::"judge_prompts" #Prompt:: + resource: Prompt::"judge_prompts" #Prompt:: diff --git a/plugins/external/cedar/tests/test_cedarpolicyplugin.py b/plugins/external/cedar/tests/test_cedarpolicyplugin.py index d24ab27..a800ffe 100644 --- a/plugins/external/cedar/tests/test_cedarpolicyplugin.py +++ b/plugins/external/cedar/tests/test_cedarpolicyplugin.py @@ -632,3 +632,36 @@ async def test_cedarpolicyplugin_resource_post_fetch_custom_dsl_rbac(): assert allow_count == 2 assert deny_count == 1 assert redact_count == 1 + + +# This test case verifies that tool_post_invoke denies when tool action is permitted but neither full nor redacted view is permitted +@pytest.mark.asyncio +async def test_cedarpolicyplugin_post_tool_invoke_both_views_denied(): + """Test tool_post_invoke denies when tool action is permitted but view is denied.""" + policy_config = [ + { + "id": "allow-tool-only", + "effect": "Permit", + "principal": 'Role::"viewer"', + "action": ['Action::"read_data"'], + "resource": ['Server::"data_tool"'], + }, + ] + policy_output_keywords = {"view_full": "view_full_output", "view_redacted": "view_redacted_output"} + config = PluginConfig( + name="test", + kind="cedarpolicyplugin.CedarPolicyPlugin", + hooks=["tool_post_invoke"], + config={"policy_lang": "cedar", "policy": policy_config, "policy_output_keywords": policy_output_keywords}, + ) + plugin = CedarPolicyPlugin(config) + info = {"dave": "viewer"} + plugin._set_jwt_info(info) + + payload = ToolPostInvokePayload(name="read_data", result={"text": "sensitive data"}) + context = PluginContext(global_context=GlobalContext(request_id="1", server_id="data_tool", user="dave")) + result = await plugin.tool_post_invoke(payload, context) + + assert result.continue_processing is False + assert result.violation is not None + assert result.violation.code == "DENY"