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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion .dockerignore
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,5 @@ SECURITY.md
CODE_OF_CONDUCT.md
CONTRIBUTING.md
docker-compose.yml
policy
.pre-commit-config.yaml
.gitignore
1 change: 0 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,3 @@ __pycache__/
dist/
build/
example.db
policy/
3 changes: 2 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,14 @@ Core package: `src/secure_sql_mcp`

- `config.py`
- Loads env config.
- Injects async driver suffixes for bare `DATABASE_URL` schemes (`postgresql://`, `mysql://`, `sqlite://`).
- Parses `ALLOWED_POLICY_FILE` in strict `table:columns` format.
- `query_validator.py`
- SQL safety checks (read-only, single statement).
- Strict table/column authorization checks.
- `database.py`
- Async SQLAlchemy access.
- Read-only session preparation and query timeout/row caps.
- Read-only session preparation and query timeout/row caps (PostgreSQL, MySQL, SQLite).
- `server.py`
- MCP tool surface (`query`, `list_tables`, `describe_table`).
- User/agent-facing responses.
Expand Down
16 changes: 14 additions & 2 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -8,15 +8,27 @@ RUN python -m venv /opt/venv \
&& /opt/venv/bin/pip install --no-cache-dir . \
&& find /opt/venv -type d -name __pycache__ -exec rm -rf {} + 2>/dev/null; true

FROM openpolicyagent/opa:1.5.1-static AS opa

FROM python:3.12-slim

ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
PATH="/opt/venv/bin:$PATH"
PATH="/opt/venv/bin:$PATH" \
OPA_URL="http://127.0.0.1:8181" \
OPA_DECISION_PATH="/v1/data/secure_sql/authz/decision" \
OPA_TIMEOUT_MS="50" \
OPA_FAIL_CLOSED="true"

COPY --from=builder /opt/venv /opt/venv
COPY --from=opa /opa /usr/local/bin/opa
COPY policy /app/policy
COPY docker/entrypoint.sh /app/entrypoint.sh
COPY docker/wait_for_opa.py /app/wait_for_opa.py

RUN chmod 0555 /usr/local/bin/opa /app/entrypoint.sh /app/wait_for_opa.py

RUN useradd -r -s /usr/sbin/nologin appuser
USER appuser

ENTRYPOINT ["python", "-m", "secure_sql_mcp.server"]
ENTRYPOINT ["/app/entrypoint.sh"]
124 changes: 109 additions & 15 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
# Secure SQL MCP Server

Read-only SQL MCP server with strict table/column policy controls.
Read-only SQL MCP server with strict table/column policy controls, with OPA-based
authorization running inside the same container.

[![CI](https://github.com/jrhuerta/secure-sql-mcp/actions/workflows/ci.yml/badge.svg)](https://github.com/jrhuerta/secure-sql-mcp/actions/workflows/ci.yml)
[![GHCR](https://img.shields.io/badge/ghcr-jrhuerta%2Fsecure--sql--mcp-blue)](https://github.com/jrhuerta/secure-sql-mcp/pkgs/container/secure-sql-mcp)
Expand Down Expand Up @@ -31,27 +32,33 @@ To use this server with Cursor, Claude Desktop, or other MCP clients, add it to

**Claude Desktop** (`claude_desktop_config.json`): same structure under `mcpServers`.

The `--env-file` should point to a file containing `DATABASE_URL` and `ALLOWED_POLICY_FILE=/run/policy/allowed_policy.txt` (see Environment Variables below). The volume mounts the policy directory read-only. Pull the image first: `docker pull ghcr.io/jrhuerta/secure-sql-mcp:latest`
The `--env-file` should point to a file containing `DATABASE_URL` and
`ALLOWED_POLICY_FILE=/run/policy/allowed_policy.txt` (see Environment Variables below).
The volume mounts the policy directory read-only. Pull the image first:
`docker pull ghcr.io/jrhuerta/secure-sql-mcp:latest`

## Security Model

- Database credentials stay server-side (env vars), never in prompts.
- Only read queries are allowed.
- Policy is strict and file-based:
- one required file: `ALLOWED_POLICY_FILE`
- each line is `table:col1,col2,col3` or `table:*`
- OPA authorization runs in-process for the container image (local loopback only, no external port exposure).
- Policy is strict and deny-by-default:
- baseline constraints and ACL rules are evaluated by OPA
- ACL source can come from a native OPA data file or transformed legacy `ALLOWED_POLICY_FILE`
- If a table/column is not explicitly allowed, it is blocked.

## Implemented Security Controls

- **Query shape enforcement**
- **OPA baseline constraints (`default_constraints`)**
- Exactly one SQL statement is allowed per request.
- Non-read operations are blocked (`INSERT`, `UPDATE`, `DELETE`, `DROP`, `ALTER`, `CREATE`, `TRUNCATE`, `GRANT`, `REVOKE`, `MERGE`, and related command expressions).
- **Strict access policy enforcement**
- Unqualified columns in multi-table queries are rejected under strict mode.
- **OPA ACL policy (`acl`)**
- Deny-by-default for tables and columns.
- Access checks apply across direct queries and composed queries (`JOIN`, `UNION`, subqueries, aliases).
- `SELECT *` is rejected unless the table policy is `table:*`.
- Unqualified columns in multi-table queries are rejected under strict mode.
- `SELECT *` is rejected unless ACL explicitly allows wildcard (`*`) for that table.
- **Composed authorization (`authz`)**
- Access is granted only when both `default_constraints` and `acl` allow.
- **Runtime safety controls**
- Query timeout and row cap are enforced server-side.
- Row-cap truncation is explicit in response payloads.
Expand All @@ -63,12 +70,29 @@ The `--env-file` should point to a file containing `DATABASE_URL` and `ALLOWED_P

| Variable | Required | Default | Description |
|----------|----------|---------|-------------|
| `DATABASE_URL` | Yes | — | SQLAlchemy async URL (e.g. `sqlite+aiosqlite:///./example.db` or `postgresql+asyncpg://...`) |
| `DATABASE_URL` | Yes | — | Database URL. Bare `postgresql://`, `mysql://`, and `sqlite://` URLs are accepted and auto-upgraded to async drivers (`+asyncpg`, `+aiomysql`, `+aiosqlite`). |
| `ALLOWED_POLICY_FILE` | Yes | — | Path to the policy file |
| `OPA_URL` | No | `http://127.0.0.1:8181` in Docker image; unset otherwise | OPA base URL. When set, queries/tools are authorized via OPA. |
| `OPA_DECISION_PATH` | No | `/v1/data/secure_sql/authz/decision` | OPA decision endpoint path. |
| `OPA_TIMEOUT_MS` | No | `50` | OPA decision timeout in milliseconds. |
| `OPA_FAIL_CLOSED` | No | `true` | If `true`, OPA errors/timeouts block access. |
| `OPA_ACL_DATA_FILE` | No | unset | Optional JSON ACL file (`secure_sql.acl.tables`) preferred over transformed `ALLOWED_POLICY_FILE`. |
| `WRITE_MODE_ENABLED` | No | `false` | Enables write execution path (`INSERT`/`UPDATE`/`DELETE`) when `true`. |
| `ALLOW_INSERT` | No | `false` | Allows `INSERT` statements when write mode is enabled. |
| `ALLOW_UPDATE` | No | `false` | Allows `UPDATE` statements when write mode is enabled. |
| `ALLOW_DELETE` | No | `false` | Allows `DELETE` statements when write mode is enabled. |
| `REQUIRE_WHERE_FOR_UPDATE` | No | `true` | When `true`, `UPDATE` requires a `WHERE` clause. |
| `REQUIRE_WHERE_FOR_DELETE` | No | `true` | When `true`, `DELETE` requires a `WHERE` clause. |
| `ALLOW_RETURNING` | No | `false` | Allows `RETURNING` on write statements when `true`. |
| `MAX_ROWS` | No | 100 | Maximum rows returned per query (1–10000) |
| `QUERY_TIMEOUT` | No | 30 | Query timeout in seconds (1–300) |
| `LOG_LEVEL` | No | INFO | Logging level (DEBUG, INFO, WARNING, ERROR) |

Write mode guardrails:
- All write-related flags default to `false` (deny-by-default).
- OPA remains the policy decision point, but config gates are enforced first as coarse runtime brakes.
- The server logs a `WARNING` when config gates block a write that policy would otherwise allow.

## Policy File Format

`allowed_policy.txt`:
Expand All @@ -84,6 +108,20 @@ Rules:
- `#` comments and blank lines are allowed.
- Matching is case-insensitive.

## OPA Policy Layout

- Rego bundle directory: `policy/rego/`
- `default_constraints.rego`
- `acl.rego`
- `authz.rego`
- Example ACL data file: `policy/data/acl.example.json`
- Policy authoring guide: [`docs/POLICY_AUTHORING.md`](docs/POLICY_AUTHORING.md)
- Controlled write mode design: [`docs/WRITE_MODE_DESIGN.md`](docs/WRITE_MODE_DESIGN.md)

ACL source precedence at runtime:
1. If `OPA_ACL_DATA_FILE` is set, ACL input is loaded from that JSON file.
2. Otherwise, `ALLOWED_POLICY_FILE` is transformed into equivalent ACL input.

## Agent Discoverability

The MCP server exposes:
Expand All @@ -96,7 +134,8 @@ The MCP server exposes:
- allowed columns for that table from policy
- schema metadata from DB when available
- `query(sql)`:
- executes only if query is read-only and within table/column policy
- executes read queries by default under table/column policy
- executes write queries only when write mode/action toggles allow them and policy permits

## Quick Start (uv)

Expand All @@ -115,12 +154,31 @@ QUERY_TIMEOUT=30
LOG_LEVEL=INFO
EOF

# Optional when testing against an external/local OPA process outside the container:
# OPA_URL=http://127.0.0.1:8181
# OPA_DECISION_PATH=/v1/data/secure_sql/authz/decision
# OPA_TIMEOUT_MS=50
# OPA_FAIL_CLOSED=true

mkdir -p policy
cat > policy/allowed_policy.txt <<'EOF'
customers:id,email
orders:*
EOF

cat > policy/acl.json <<'EOF'
{
"secure_sql": {
"acl": {
"tables": {
"customers": {"columns": ["id", "email"]},
"orders": {"columns": ["*"]}
}
}
}
}
EOF

# Create tables for local testing (optional)
sqlite3 example.db <<'SQL'
CREATE TABLE IF NOT EXISTS customers (id INTEGER PRIMARY KEY, email TEXT NOT NULL, ssn TEXT);
Expand Down Expand Up @@ -150,6 +208,7 @@ EOF
cat > .env <<'EOF'
DATABASE_URL=sqlite+aiosqlite:///./example.db
ALLOWED_POLICY_FILE=/run/policy/allowed_policy.txt
OPA_ACL_DATA_FILE=/run/policy/acl.json
MAX_ROWS=100
QUERY_TIMEOUT=30
LOG_LEVEL=INFO
Expand Down Expand Up @@ -191,6 +250,7 @@ docker compose up --build
- Avoid hardcoding credentials in shell history.
- Mount policy files read-only (`:ro`) in Docker.
- Keep `.env` and policy files out of version control.
- Keep OPA policy/data assets immutable in runtime containers.

## Dev Tooling

Expand All @@ -216,11 +276,44 @@ python -m pytest -q \
```

What these suites validate:
- read-only enforcement for mutation/privileged SQL operations
- default read-only enforcement for mutation/privileged SQL operations
- single-statement validation and parser hardening
- strict deny-by-default table/column ACL checks, including join/union/subquery paths
- write-mode guardrails (`WRITE_MODE_ENABLED` and per-action toggles), including WHERE safety checks
- protocol-level behavior over MCP stdio transport
- timeout, row cap truncation, and non-leaky actionable DB error responses
- OPA fail-closed behavior and ACL source precedence

## Real Docker + OPA Matrix Tests

Run comprehensive real-server scenarios against Dockerized MCP+OPA across
SQLite, PostgreSQL, and MySQL:

```bash
python -m pytest -q -m docker_integration tests/integration/docker/test_mcp_docker_opa_matrix.py
```

Run a faster smoke subset:

```bash
bash scripts/run-docker-opa-smoke.sh
```

Prerequisites:
- Docker Engine with Compose plugin (`docker compose`)
- ability to pull base images (`postgres:16-alpine`, `mysql:8.4`)

Troubleshooting:
- if MySQL/PostgreSQL startup is slow, rerun with `-m docker_integration -vv` to inspect per-scenario logs
- if Docker is unavailable, these tests auto-skip and unit/security suites still run normally
- if port/resource contention occurs, remove stale test stacks: `docker compose -f docker-compose.test.yml down -v --remove-orphans`

What the Docker matrix validates:
- read/write allow/deny behavior with real container runtime and OPA process
- policy-profile variants mounted as read-only files
- write gate toggles (`WRITE_MODE_ENABLED`, `ALLOW_*`) and WHERE/RETURNING controls
- bypass-focused checks (`INSERT ... SELECT`, source `SELECT *`, tautological WHERE)
- OPA fail-closed behavior when decision service is unavailable

## CI Security Gate Expectations

Expand All @@ -237,7 +330,7 @@ python -m pytest -q \

Recommended policy:
- block merges on any failure in the security suites above
- require test updates when changing query validation, policy parsing, or MCP tool responses
- require test updates when changing query validation, OPA policy inputs, policy parsing, or MCP tool responses
- keep security test fixtures deterministic (no shared state, no external DB dependency by default)

## Contributing
Expand All @@ -258,8 +351,9 @@ Before merging security-sensitive changes, verify:

- query validation still enforces exactly one statement per request
- mutation/DDL/privilege SQL operations are blocked with actionable messaging
- table and column access remains deny-by-default against `ALLOWED_POLICY_FILE`
- `SELECT *` is rejected unless policy explicitly allows `table:*`
- table and column access remains deny-by-default against effective ACL source
(`OPA_ACL_DATA_FILE` when present, else transformed `ALLOWED_POLICY_FILE`)
- `SELECT *` is rejected unless ACL explicitly allows wildcard
- multi-table queries still reject unqualified columns and enforce alias-aware ACLs
- timeout and row-cap protections remain active and tested
- DB error responses stay sanitized and do not expose credentials/internal connection details
Expand Down
59 changes: 59 additions & 0 deletions docker-compose.test.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
services:
secure-sql-mcp:
build:
context: .
dockerfile: Dockerfile
image: secure-sql-mcp:test
environment:
ALLOWED_POLICY_FILE: /run/policy/allowed_policy.txt
OPA_URL: http://127.0.0.1:8181
OPA_DECISION_PATH: /v1/data/secure_sql/authz/decision
OPA_TIMEOUT_MS: "50"
OPA_FAIL_CLOSED: "true"
WRITE_MODE_ENABLED: "false"
ALLOW_INSERT: "false"
ALLOW_UPDATE: "false"
ALLOW_DELETE: "false"
REQUIRE_WHERE_FOR_UPDATE: "true"
REQUIRE_WHERE_FOR_DELETE: "true"
ALLOW_RETURNING: "false"
MAX_ROWS: "100"
QUERY_TIMEOUT: "30"
LOG_LEVEL: "INFO"
stdin_open: true
tty: false
depends_on:
postgres:
condition: service_healthy
mysql:
condition: service_healthy

postgres:
image: postgres:16-alpine
environment:
POSTGRES_USER: secure
POSTGRES_PASSWORD: secure
POSTGRES_DB: secure_sql_test
volumes:
- ./tests/integration/docker/db-init/postgres.sql:/docker-entrypoint-initdb.d/01-init.sql:ro
healthcheck:
test: ["CMD-SHELL", "pg_isready -U secure -d secure_sql_test"]
interval: 2s
timeout: 2s
retries: 30

mysql:
image: mysql:8.4
command: ["--default-authentication-plugin=mysql_native_password"]
environment:
MYSQL_ROOT_PASSWORD: root
MYSQL_USER: secure
MYSQL_PASSWORD: secure
MYSQL_DATABASE: secure_sql_test
volumes:
- ./tests/integration/docker/db-init/mysql.sql:/docker-entrypoint-initdb.d/01-init.sql:ro
healthcheck:
test: ["CMD-SHELL", "mysqladmin ping -h 127.0.0.1 -usecure -psecure --silent"]
interval: 2s
timeout: 2s
retries: 60
7 changes: 7 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,13 @@ services:
environment:
DATABASE_URL: ${DATABASE_URL:-sqlite+aiosqlite:///./example.db}
ALLOWED_POLICY_FILE: ${ALLOWED_POLICY_FILE:-/run/policy/allowed_policy.txt}
OPA_DECISION_PATH: ${OPA_DECISION_PATH:-/v1/data/secure_sql/authz/decision}
OPA_TIMEOUT_MS: ${OPA_TIMEOUT_MS:-50}
OPA_FAIL_CLOSED: ${OPA_FAIL_CLOSED:-true}
WRITE_MODE_ENABLED: ${WRITE_MODE_ENABLED:-false}
ALLOW_INSERT: ${ALLOW_INSERT:-false}
ALLOW_UPDATE: ${ALLOW_UPDATE:-false}
ALLOW_DELETE: ${ALLOW_DELETE:-false}
MAX_ROWS: ${MAX_ROWS:-100}
QUERY_TIMEOUT: ${QUERY_TIMEOUT:-30}
LOG_LEVEL: ${LOG_LEVEL:-INFO}
Expand Down
18 changes: 18 additions & 0 deletions docker/entrypoint.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
#!/usr/bin/env sh
set -eu

OPA_BUNDLE_DIR="${OPA_BUNDLE_DIR:-/app/policy}"
OPA_ADDR="${OPA_ADDR:-127.0.0.1:8181}"

opa run --server --addr "$OPA_ADDR" --bundle "$OPA_BUNDLE_DIR" &
OPA_PID=$!

cleanup() {
kill "$OPA_PID" 2>/dev/null || true
}

trap cleanup INT TERM EXIT

python /app/wait_for_opa.py

exec python -m secure_sql_mcp.server
Loading